mirror of
https://github.com/expo/entity.git
synced 2026-11-13 20:28:06 -10:00
Compare commits
403
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dbd1cc8479 | ||
|
|
977b790ba7 | ||
|
|
2693fbc000 | ||
|
|
4709f53b45 | ||
|
|
f4496b9cc7 | ||
|
|
15ebe7d584 | ||
|
|
0d00605a93 | ||
|
|
73d45fdca3 | ||
|
|
3421a746be | ||
|
|
5ae965b736 | ||
|
|
bfa703e4ed | ||
|
|
174d6c602b | ||
|
|
0c0c6a3496 | ||
|
|
3f103e1dbd | ||
|
|
d90dce72c2 | ||
|
|
d800024283 | ||
|
|
b6c950900d | ||
|
|
4965cc2388 | ||
|
|
e3cee07091 | ||
|
|
0a1d1b5972 | ||
|
|
db887d3d7d | ||
|
|
2214f7d19a | ||
|
|
4f4243d314 | ||
|
|
a505216608 | ||
|
|
bc40501535 | ||
|
|
790e75160b | ||
|
|
cd38019789 | ||
|
|
e1c47de3a7 | ||
|
|
a6f1dd7232 | ||
|
|
112d4dc9b3 | ||
|
|
f33d388b48 | ||
|
|
f138841a59 | ||
|
|
090c046bbc | ||
|
|
81906aed16 | ||
|
|
ea9c45fade | ||
|
|
2fc88cb204 | ||
|
|
e4133a865e | ||
|
|
f75ed7f749 | ||
|
|
6bfde96bc3 | ||
|
|
575d8545f7 | ||
|
|
1aae31b0bf | ||
|
|
6cbe50a048 | ||
|
|
0b4385698c | ||
|
|
1a8535f983 | ||
|
|
1f66b9f8f3 | ||
|
|
6a07e6bd66 | ||
|
|
9202ecbb23 | ||
|
|
a3085ed911 | ||
|
|
934e5f3199 | ||
|
|
e36dc81bd2 | ||
|
|
2fc9d9c196 | ||
|
|
535b6811e0 | ||
|
|
792c6c851b | ||
|
|
038b2bff76 | ||
|
|
599fccaf73 | ||
|
|
c361dfd7ad | ||
|
|
41bdcf464e | ||
|
|
a07429fa98 | ||
|
|
a653ce137c | ||
|
|
7bea8fbccf | ||
|
|
bfe1aad035 | ||
|
|
b931daa9a0 | ||
|
|
72b3c80d12 | ||
|
|
57e9e06601 | ||
|
|
82978f8364 | ||
|
|
7dab49a36d | ||
|
|
21f2f6f461 | ||
|
|
4126dcd8f2 | ||
|
|
6889178e60 | ||
|
|
a0d3d30265 | ||
|
|
d0825c0446 | ||
|
|
e8440f11c0 | ||
|
|
baa1674629 | ||
|
|
25520c68e8 | ||
|
|
905e8522b3 | ||
|
|
e263bd218c | ||
|
|
e876cfb27b | ||
|
|
b9db8a4717 | ||
|
|
459a5ff850 | ||
|
|
beda973c0c | ||
|
|
b258dad2ed | ||
|
|
976e2c3386 | ||
|
|
e32c4c2bcc | ||
|
|
2c4c649a4b | ||
|
|
898f71ce08 | ||
|
|
90ae6e299b | ||
|
|
53b0604152 | ||
|
|
018a40bc01 | ||
|
|
6849468d15 | ||
|
|
c60f4cd455 | ||
|
|
820fe88217 | ||
|
|
6c0838dc57 | ||
|
|
5c11b598c4 | ||
|
|
c946630a82 | ||
|
|
15adeb88cf | ||
|
|
9940fcbed2 | ||
|
|
0615ffacff | ||
|
|
5f3f31bb7c | ||
|
|
097ac65fc4 | ||
|
|
e71ae5fcba | ||
|
|
3065f360db | ||
|
|
c3fe15e655 | ||
|
|
2d41777be0 | ||
|
|
45da844a5c | ||
|
|
4dc156c7a8 | ||
|
|
cc78029117 | ||
|
|
517d57b5df | ||
|
|
c933db1f8a | ||
|
|
d9377a290b | ||
|
|
457f58ad2c | ||
|
|
ed6b2e0553 | ||
|
|
40b409f6f8 | ||
|
|
51d632da16 | ||
|
|
3d4aa0e9ef | ||
|
|
f5c728be56 | ||
|
|
285f22c461 | ||
|
|
3ec4b5f0cd | ||
|
|
f72cbcaf5a | ||
|
|
fb720611f0 | ||
|
|
7b8a49c203 | ||
|
|
d97707a213 | ||
|
|
ced36081b8 | ||
|
|
b511981a69 | ||
|
|
58deffd426 | ||
|
|
56ec27d5f2 | ||
|
|
be40bcffa6 | ||
|
|
a9d09f42b6 | ||
|
|
a9980b2785 | ||
|
|
7600c7e7d8 | ||
|
|
31e226e22a | ||
|
|
66297676e4 | ||
|
|
273d1df8ba | ||
|
|
aba0ce5b5f | ||
|
|
eeccf594c1 | ||
|
|
e76264c613 | ||
|
|
6fce3a7604 | ||
|
|
567bb61172 | ||
|
|
2590b95734 | ||
|
|
bc74562bf9 | ||
|
|
e37284f54a | ||
|
|
064431924b | ||
|
|
cd81c5a5b6 | ||
|
|
7b6ff09c68 | ||
|
|
ad6c81def2 | ||
|
|
2791d9a05f | ||
|
|
c28e89e15c | ||
|
|
9a9374b173 | ||
|
|
2dda22d761 | ||
|
|
9e88cf6aab | ||
|
|
de7548680d | ||
|
|
5db89777c6 | ||
|
|
f3b575cd00 | ||
|
|
56ed357b92 | ||
|
|
b758c4d344 | ||
|
|
d4c68112dc | ||
|
|
23d8b25805 | ||
|
|
197ebd9120 | ||
|
|
a8223d994a | ||
|
|
7c2b812ef2 | ||
|
|
283c02eedb | ||
|
|
c70032dc2a | ||
|
|
6c8bdfaf97 | ||
|
|
acd5d715db | ||
|
|
6523f07a5b | ||
|
|
38f451fd59 | ||
|
|
43dd87fcba | ||
|
|
b125ea3473 | ||
|
|
7244f7b591 | ||
|
|
17b2105a99 | ||
|
|
0659e8f4a2 | ||
|
|
8fb53f2f49 | ||
|
|
757393c707 | ||
|
|
a75b41a515 | ||
|
|
23f121c91e | ||
|
|
b984fa949e | ||
|
|
cb2f4fa8ae | ||
|
|
0a70d083eb | ||
|
|
0f719432a9 | ||
|
|
a9a730cd83 | ||
|
|
544bb7c3f2 | ||
|
|
c38717d237 | ||
|
|
ea6c128d9c | ||
|
|
1cc57eddce | ||
|
|
5dff512745 | ||
|
|
60fed15603 | ||
|
|
fe7f7303aa | ||
|
|
47b167e3bb | ||
|
|
3997adf002 | ||
|
|
858f8715e6 | ||
|
|
67f7516489 | ||
|
|
7947fe9871 | ||
|
|
5f9485627a | ||
|
|
37fc067970 | ||
|
|
a6d2d3c55c | ||
|
|
ece647b31f | ||
|
|
be72c6bf4b | ||
|
|
b7f2148654 | ||
|
|
73f0dfa223 | ||
|
|
35bf335a5d | ||
|
|
971e73037d | ||
|
|
328d6ac319 | ||
|
|
ea21dc0f76 | ||
|
|
94808312eb | ||
|
|
8998970643 | ||
|
|
f9441a77a8 | ||
|
|
4146afc887 | ||
|
|
5b7739691f | ||
|
|
002bbd5bbf | ||
|
|
9f939269cf | ||
|
|
ee6e57fd3b | ||
|
|
4cad01a940 | ||
|
|
7038912ca5 | ||
|
|
ce55922e3e | ||
|
|
d8dccb95e4 | ||
|
|
5679f27209 | ||
|
|
bcce8ef0ae | ||
|
|
833e2e8c55 | ||
|
|
1a7dfadf64 | ||
|
|
767d9513ff | ||
|
|
c4537262d8 | ||
|
|
9140938e1c | ||
|
|
019809fbe9 | ||
|
|
4559f8fe1b | ||
|
|
30f4a62bfa | ||
|
|
a1f1934fd7 | ||
|
|
2f9d4873aa | ||
|
|
59e024eb19 | ||
|
|
0f53c24faf | ||
|
|
1b9ddaa95f | ||
|
|
4ab2cf1cc4 | ||
|
|
4c3fd175a9 | ||
|
|
f6639f9def | ||
|
|
949f00d93b | ||
|
|
02f92fc6ac | ||
|
|
077d53cd86 | ||
|
|
7b4bec8f84 | ||
|
|
4c6f340d17 | ||
|
|
4a3eda209f | ||
|
|
31b52a5f75 | ||
|
|
9a03fdb7ba | ||
|
|
28cbac54b4 | ||
|
|
79f61f8d7f | ||
|
|
0038d633b3 | ||
|
|
18a0d297d0 | ||
|
|
93d84807da | ||
|
|
64057b6d52 | ||
|
|
850fa2b886 | ||
|
|
65d50d6cb3 | ||
|
|
1fd0144cc3 | ||
|
|
6cecd9f6b9 | ||
|
|
875cce3432 | ||
|
|
fe2d246f87 | ||
|
|
a3ab61bfa8 | ||
|
|
78bc264b33 | ||
|
|
b9b09933e5 | ||
|
|
d3e89bdd82 | ||
|
|
b6089524bb | ||
|
|
e054a0d74b | ||
|
|
4ddea7da77 | ||
|
|
8c52ace8d1 | ||
|
|
618383027d | ||
|
|
9758fd5df8 | ||
|
|
8414d96d94 | ||
|
|
ed8528d6a6 | ||
|
|
0ea917ad02 | ||
|
|
485894af16 | ||
|
|
1da5cc01e1 | ||
|
|
b7e524c418 | ||
|
|
d9c1852706 | ||
|
|
ca1b69e2cb | ||
|
|
f0aa0dafeb | ||
|
|
06b3cb7f80 | ||
|
|
ad41af1c82 | ||
|
|
f3a2bf62de | ||
|
|
1d95eba1f9 | ||
|
|
aa56b705db | ||
|
|
ee4be736a9 | ||
|
|
4a2b61f2bd | ||
|
|
a976e76509 | ||
|
|
72f34f0bee | ||
|
|
74c396255c | ||
|
|
5cb88ca6e7 | ||
|
|
4d070248d8 | ||
|
|
acd52f18ce | ||
|
|
d4eb0a6214 | ||
|
|
3cd004ad55 | ||
|
|
381651d8bb | ||
|
|
089cfbb040 | ||
|
|
02f7428257 | ||
|
|
26ad65d657 | ||
|
|
fa78ce84e2 | ||
|
|
c9ab838a75 | ||
|
|
3c2a1920f0 | ||
|
|
ab34b4ae8a | ||
|
|
d63ddd6599 | ||
|
|
c6f74a14a9 | ||
|
|
be9c40e8d7 | ||
|
|
081c3576a8 | ||
|
|
92bac05687 | ||
|
|
4d56065518 | ||
|
|
275c63d277 | ||
|
|
2504aecddb | ||
|
|
9426a08a32 | ||
|
|
ae3a6c5c77 | ||
|
|
27a358d259 | ||
|
|
c2ae21b5cc | ||
|
|
5ab1f7953c | ||
|
|
d7cc0c23b9 | ||
|
|
162b19257a | ||
|
|
15117fc85e | ||
|
|
59fc77810a | ||
|
|
8c5f81bcca | ||
|
|
28e3afb62b | ||
|
|
f17a33c428 | ||
|
|
93905be81c | ||
|
|
2edc7af7e2 | ||
|
|
6569486ecd | ||
|
|
778d9e304e | ||
|
|
05726d45fa | ||
|
|
7e2cea1697 | ||
|
|
7c3c9854a4 | ||
|
|
1e8ea64017 | ||
|
|
af495a9a85 | ||
|
|
60fc9a4c4b | ||
|
|
14bd959e53 | ||
|
|
91d46ba49f | ||
|
|
16e7c21b1c | ||
|
|
3ce0ac91f8 | ||
|
|
428cc6fc5c | ||
|
|
8490ad5a19 | ||
|
|
8a448b5c42 | ||
|
|
3fd8a92cc8 | ||
|
|
b5998063a6 | ||
|
|
8fc0849618 | ||
|
|
87532525d0 | ||
|
|
33771295c5 | ||
|
|
1bdfbd6b56 | ||
|
|
0fa6f554a2 | ||
|
|
8b0b31fdde | ||
|
|
cc6f3dc1e6 | ||
|
|
9c43a56a39 | ||
|
|
ef7b452c9f | ||
|
|
659e08386f | ||
|
|
aad94aca47 | ||
|
|
d2a4405fa1 | ||
|
|
503a2fb6a8 | ||
|
|
3a00a43036 | ||
|
|
05b14843a6 | ||
|
|
6f8020fbf8 | ||
|
|
4b18010d42 | ||
|
|
2069a0d9fd | ||
|
|
83fe9cdea5 | ||
|
|
1934216b70 | ||
|
|
92f5529eba | ||
|
|
67732d6847 | ||
|
|
bb0cd51eb5 | ||
|
|
abd86b91d3 | ||
|
|
b5f734b1e1 | ||
|
|
b11bd24351 | ||
|
|
684f91b1f2 | ||
|
|
e381f5b2ac | ||
|
|
69692c3b64 | ||
|
|
dbf1e03039 | ||
|
|
0fb74137f2 | ||
|
|
a77b914ad6 | ||
|
|
acbad826a3 | ||
|
|
8bdd9f7ac2 | ||
|
|
bc6a0d4310 | ||
|
|
a067a44230 | ||
|
|
bffa7ca870 | ||
|
|
847c20fabe | ||
|
|
8a98f35b91 | ||
|
|
7829d90f4b | ||
|
|
2817d78392 | ||
|
|
af81981772 | ||
|
|
0c6aa30cdd | ||
|
|
8530fb0983 | ||
|
|
ce0ac6f8cb | ||
|
|
c8f81a76c3 | ||
|
|
a0c35ddd7b | ||
|
|
25d14bf8c1 | ||
|
|
0dbf5e98bb | ||
|
|
4347d9d577 | ||
|
|
9a91b19466 | ||
|
|
cb7aaad979 | ||
|
|
9fcfbf746a | ||
|
|
9c44e8e8d4 | ||
|
|
d731b0ddb7 | ||
|
|
293868b1a7 | ||
|
|
02d2f24bb6 | ||
|
|
e76ca8c2f7 | ||
|
|
0736e20987 | ||
|
|
5c82edcd9f | ||
|
|
96e1023d40 | ||
|
|
3a81a4c059 | ||
|
|
6b6e70e75f | ||
|
|
bc378b6c79 | ||
|
|
fefa4100fc | ||
|
|
85d35752fa | ||
|
|
09d48a617c | ||
|
|
0adcf7238f | ||
|
|
41bd0722c9 | ||
|
|
03a1fce067 |
No files matched your search
@@ -0,0 +1,31 @@
|
||||
# CLAUDE.md
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
|
||||
## Project Overview
|
||||
|
||||
This is Entity, a privacy-aware data layer for defining, caching, and authorizing access to application data models.
|
||||
|
||||
## Essential Development Commands
|
||||
|
||||
### Dependencies
|
||||
- `yarn add` - Add a dependency. Run this only if necessary and ask before running.
|
||||
|
||||
### Building and Linting
|
||||
- `yarn tsc` - Typecheck the code
|
||||
- `yarn lint` - Run ESLint on source code
|
||||
- `yarn ctix` - Build barrel index.ts files
|
||||
|
||||
### Testing
|
||||
- `yarn test` - Run unit tests
|
||||
- `yarn integration` - Run integration tests against dockerized environment
|
||||
|
||||
### Commit messages
|
||||
- All PRs use conventional commits for their titles: https://www.conventionalcommits.org/
|
||||
|
||||
## Code conventions
|
||||
- Backwards compatibility is a non-goal of refactorings of this library. Consumers rely upon typescript to know what needs to be migrated, so all breaking changes should be detectable via running typescript post-entity-version-upgrade in a consumer application that makes use of this package.
|
||||
- Do not use jest's `test.skip`
|
||||
- Prefer not to use dynamic imports for now
|
||||
- Whenever technically correct, use typescript types for something instead of `any`
|
||||
- Prefer American English spellings of words over British English. For example, prefer "Adapter" over "Adaptor".
|
||||
@@ -0,0 +1,54 @@
|
||||
{
|
||||
"mode": "bundle",
|
||||
"fileExt": "keep",
|
||||
"overwrite": true,
|
||||
"backup": false,
|
||||
"generationStyle": "default-alias-named-star",
|
||||
"options": [
|
||||
{
|
||||
"project": "packages/entity/tsconfig.json",
|
||||
"output": "packages/entity/src",
|
||||
"exclude": [ "**/__testfixtures__/**", "**/__tests__/**" ]
|
||||
},
|
||||
{
|
||||
"project": "packages/entity-cache-adapter-local-memory/tsconfig.json",
|
||||
"output": "packages/entity-cache-adapter-local-memory/src",
|
||||
"exclude": [ "**/__testfixtures__/**", "**/__tests__/**" ]
|
||||
},
|
||||
{
|
||||
"project": "packages/entity-cache-adapter-redis/tsconfig.json",
|
||||
"output": "packages/entity-cache-adapter-redis/src",
|
||||
"exclude": [ "**/__testfixtures__/**", "**/__tests__/**" ]
|
||||
},
|
||||
{
|
||||
"project": "packages/entity-database-adapter-knex/tsconfig.json",
|
||||
"output": "packages/entity-database-adapter-knex/src",
|
||||
"exclude": [ "**/__testfixtures__/**", "**/__tests__/**" ]
|
||||
},
|
||||
{
|
||||
"project": "packages/entity-ip-address-field/tsconfig.json",
|
||||
"output": "packages/entity-ip-address-field/src",
|
||||
"exclude": [ "**/__testfixtures__/**", "**/__tests__/**" ]
|
||||
},
|
||||
{
|
||||
"project": "packages/entity-secondary-cache-local-memory/tsconfig.json",
|
||||
"output": "packages/entity-secondary-cache-local-memory/src",
|
||||
"exclude": [ "**/__testfixtures__/**", "**/__tests__/**" ]
|
||||
},
|
||||
{
|
||||
"project": "packages/entity-secondary-cache-redis/tsconfig.json",
|
||||
"output": "packages/entity-secondary-cache-redis/src",
|
||||
"exclude": [ "**/__testfixtures__/**", "**/__tests__/**" ]
|
||||
},
|
||||
{
|
||||
"project": "packages/entity-testing-utils/tsconfig.json",
|
||||
"output": "packages/entity-testing-utils/src",
|
||||
"exclude": [ "**/__testfixtures__/**", "**/__tests__/**" ]
|
||||
},
|
||||
{
|
||||
"project": "packages/entity-database-adapter-knex-testing-utils/tsconfig.json",
|
||||
"output": "packages/entity-database-adapter-knex-testing-utils/src",
|
||||
"exclude": [ "**/__testfixtures__/**", "**/__tests__/**" ]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,55 +0,0 @@
|
||||
{
|
||||
"root": true,
|
||||
"extends": [
|
||||
"universe/node",
|
||||
"universe/shared/typescript-analysis"
|
||||
],
|
||||
"plugins": [
|
||||
"eslint-plugin-tsdoc"
|
||||
],
|
||||
"rules": {
|
||||
"no-restricted-imports": ["error",{ "paths": [".", "..", "../.."] }],
|
||||
"tsdoc/syntax": "warn",
|
||||
"no-console": "warn",
|
||||
"handle-callback-err": "off"
|
||||
},
|
||||
"overrides": [
|
||||
{
|
||||
"files": [
|
||||
"*.ts",
|
||||
"*.tsx",
|
||||
"*.d.ts"
|
||||
],
|
||||
"parserOptions": {
|
||||
"project": "./tsconfig.json"
|
||||
},
|
||||
"rules": {
|
||||
"@typescript-eslint/explicit-function-return-type": [
|
||||
"warn",
|
||||
{
|
||||
"allowExpressions": true
|
||||
}
|
||||
],
|
||||
"@typescript-eslint/naming-convention": [
|
||||
"warn",
|
||||
{
|
||||
"selector": "typeLike",
|
||||
"format": [
|
||||
"PascalCase"
|
||||
]
|
||||
},
|
||||
{
|
||||
"selector": "enumMember",
|
||||
"format": [
|
||||
"UPPER_CASE"
|
||||
]
|
||||
}
|
||||
],
|
||||
"no-dupe-class-members": "off",
|
||||
"@typescript-eslint/no-dupe-class-members": [
|
||||
"error"
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
yarn.lock -diff linguist-generated
|
||||
@@ -1,27 +0,0 @@
|
||||
name: docs
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
runs-on: ubuntu-18.04
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- uses: actions/setup-node@v1
|
||||
with:
|
||||
node-version: 12.x
|
||||
- name: Cache Node.js modules
|
||||
uses: actions/cache@v1
|
||||
with:
|
||||
path: ${{ github.workspace }}/node_modules
|
||||
key: ${{ runner.OS }}-node_modules-${{ hashFiles('yarn.lock') }}
|
||||
- run: yarn --frozen-lockfile
|
||||
- run: yarn typedoc
|
||||
- name: Deploy
|
||||
uses: peaceiris/actions-gh-pages@v3
|
||||
with:
|
||||
github_token: ${{ secrets.GITHUB_TOKEN }}
|
||||
publish_dir: ./doc
|
||||
@@ -0,0 +1,20 @@
|
||||
name: Lint PR
|
||||
|
||||
on:
|
||||
pull_request_target:
|
||||
types:
|
||||
- opened
|
||||
- reopened
|
||||
- edited
|
||||
- synchronize
|
||||
|
||||
jobs:
|
||||
main:
|
||||
name: Validate PR title
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
pull-requests: read
|
||||
steps:
|
||||
- uses: amannn/action-semantic-pull-request@48f256284bd46cdaab1048c3721360e808335d50 # v6.1.1
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
+45
-29
@@ -2,37 +2,53 @@ name: tests
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ master ]
|
||||
branches: [main]
|
||||
pull_request:
|
||||
|
||||
env:
|
||||
# renovate: datasource=github-releases depName=jdx/mise
|
||||
MISE_VERSION: v2026.3.17
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.sha }}
|
||||
- uses: actions/setup-node@v1
|
||||
with:
|
||||
node-version: 12.x
|
||||
- name: Cache Node.js modules
|
||||
uses: actions/cache@v1
|
||||
with:
|
||||
path: ${{ github.workspace }}/node_modules
|
||||
key: ${{ runner.OS }}-node_modules-${{ hashFiles('yarn.lock') }}
|
||||
- run: yarn --frozen-lockfile
|
||||
- run: yarn lint -- -- --max-warnings=0
|
||||
- run: yarn test -- -- --coverage
|
||||
- run: yarn integration -- -- --coverage
|
||||
- uses: codecov/codecov-action@v1
|
||||
with:
|
||||
token: ${{ secrets.CODECOV_TOKEN }}
|
||||
file: ./packages/*/coverage/clover.xml
|
||||
flags: unittest
|
||||
fail_ci_if_error: true
|
||||
- uses: codecov/codecov-action@v1
|
||||
with:
|
||||
token: ${{ secrets.CODECOV_TOKEN }}
|
||||
file: ./packages/*/coverage-integration/clover.xml
|
||||
flags: integration
|
||||
fail_ci_if_error: true
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- uses: jdx/mise-action@1648a7812b9aeae629881980618f079932869151 # v4.0.1
|
||||
with:
|
||||
github_token: ${{ secrets.GITHUB_TOKEN }}
|
||||
version: ${{ env.MISE_VERSION }}
|
||||
- uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
|
||||
with:
|
||||
cache: yarn
|
||||
- run: mise run ci
|
||||
- uses: codecov/codecov-action@75cd11691c0faa626561e295848008c8a7dddffe # v5.5.4
|
||||
with:
|
||||
token: ${{ secrets.CODECOV_TOKEN }}
|
||||
directory: ./coverage
|
||||
flags: unittest
|
||||
fail_ci_if_error: true
|
||||
- uses: codecov/codecov-action@75cd11691c0faa626561e295848008c8a7dddffe # v5.5.4
|
||||
with:
|
||||
token: ${{ secrets.CODECOV_TOKEN }}
|
||||
directory: ./coverage-integration
|
||||
flags: integration
|
||||
fail_ci_if_error: true
|
||||
docs:
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- uses: jdx/mise-action@1648a7812b9aeae629881980618f079932869151 # v4.0.1
|
||||
with:
|
||||
github_token: ${{ secrets.GITHUB_TOKEN }}
|
||||
version: ${{ env.MISE_VERSION }}
|
||||
- uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
|
||||
with:
|
||||
cache: yarn
|
||||
- run: mise run typedoc
|
||||
- name: Deploy
|
||||
if: ${{ github.ref == 'refs/heads/main' }}
|
||||
uses: peaceiris/actions-gh-pages@4f9cc6602d3f66b9c108549d475ec49e8ef4d45e # v4.0.0
|
||||
with:
|
||||
github_token: ${{ secrets.GITHUB_TOKEN }}
|
||||
publish_dir: ./doc
|
||||
+13
-1
@@ -23,12 +23,24 @@ node_modules/
|
||||
.yarn-integrity
|
||||
|
||||
# yarn v2
|
||||
|
||||
.yarn/cache
|
||||
.yarn/unplugged
|
||||
.yarn/build-state.yml
|
||||
.pnp.*
|
||||
|
||||
# [yarn](https://yarnpkg.com/getting-started/qa#which-files-should-be-gitignored)
|
||||
**/.yarn/*
|
||||
!**/.yarn/cache
|
||||
!**/.yarn/patches
|
||||
!**/.yarn/plugins
|
||||
!**/.yarn/releases
|
||||
!**/.yarn/sdks
|
||||
!**/.yarn/versions
|
||||
**/.pnp.*
|
||||
|
||||
.nx/cache
|
||||
.nx/workspace-data
|
||||
|
||||
# Entity-specific ignores
|
||||
|
||||
build/
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
nodeLinker: node-modules
|
||||
|
||||
npmMinimalAgeGate: 2d
|
||||
|
||||
npmPreapprovedPackages:
|
||||
- "@expo/*"
|
||||
+448
-191
@@ -3,349 +3,606 @@
|
||||
All notable changes to this project will be documented in this file.
|
||||
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
|
||||
|
||||
# [0.22.0](https://github.com/expo/entity/compare/v0.21.0...v0.22.0) (2022-02-04)
|
||||
# [0.62.0](https://github.com/expo/entity/compare/v0.61.0...v0.62.0) (2026-03-16)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- only throw EntityDatabaseAdapterPaginationCursorInvalidError from cursor decoding logic ([#537](https://github.com/expo/entity/issues/537)) ([e3cee07](https://github.com/expo/entity/commit/e3cee07091af8d1e6c5bb2d82f13e837c7658ea9))
|
||||
- remove unused arg from EntityInvalidationUtils ([#536](https://github.com/expo/entity/issues/536)) ([0a1d1b5](https://github.com/expo/entity/commit/0a1d1b5972c15c932f5c31015f2519163f882f83))
|
||||
|
||||
# [0.61.0](https://github.com/expo/entity/compare/v0.60.0...v0.61.0) (2026-03-12)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **deps:** update dependency graphql to v16.13.1 ([#510](https://github.com/expo/entity/issues/510)) ([1a8535f](https://github.com/expo/entity/commit/1a8535f983f38aaa462a8d587e2f62f5fab1043c))
|
||||
|
||||
### Features
|
||||
|
||||
* Upgrade knex to 1.x in `@expo/entity-database-adapter-knex` ([#150](https://github.com/expo/entity/issues/150)) ([e1b0ee5](https://github.com/expo/entity/commit/e1b0ee528175646239d88aa71ffce73983eeb52f))
|
||||
- update the SQLExpression and SQLFragmentHelpers APIs ([#516](https://github.com/expo/entity/issues/516)) ([112d4dc](https://github.com/expo/entity/commit/112d4dc9b379da5317ff6acaf1d2e9bd597128a6))
|
||||
|
||||
# [0.60.0](https://github.com/expo/entity/compare/v0.59.0...v0.60.0) (2026-03-06)
|
||||
|
||||
### Features
|
||||
|
||||
- Add SQLExpression class that supports fluent comparison method API ([#506](https://github.com/expo/entity/issues/506)) ([535b681](https://github.com/expo/entity/commit/535b6811e08578aba1aadb095f3e401cfe7eaa26))
|
||||
|
||||
# [0.59.0](https://github.com/expo/entity/compare/v0.58.0...v0.59.0) (2026-03-03)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **deps:** update dependency @koa/router to v15.3.1 ([#497](https://github.com/expo/entity/issues/497)) ([72b3c80](https://github.com/expo/entity/commit/72b3c80d12a1afa3c5593bcf6d7ad233c3524341))
|
||||
|
||||
### Features
|
||||
|
||||
- add support to SQLFragment for `= ANY(?)` ([#498](https://github.com/expo/entity/issues/498)) ([b931daa](https://github.com/expo/entity/commit/b931daa9a082cdb33f4559d6d32e1655f0459b79))
|
||||
|
||||
# [0.58.0](https://github.com/expo/entity/compare/v0.57.0...v0.58.0) (2026-03-02)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- escape all matching portions of parts of cacher cache keys ([#486](https://github.com/expo/entity/issues/486)) ([21f2f6f](https://github.com/expo/entity/commit/21f2f6f461ee771e8b1c1b22dbaf9b275094c0db))
|
||||
- fix some security review suggestions ([#485](https://github.com/expo/entity/issues/485)) ([e263bd2](https://github.com/expo/entity/commit/e263bd218cda579365766d0918c4961304bd1d9f))
|
||||
|
||||
# [0.57.0](https://github.com/expo/entity/compare/v0.56.0...v0.57.0) (2026-02-28)
|
||||
|
||||
**Note:** Version bump only for package root
|
||||
|
||||
# [0.56.0](https://github.com/expo/entity/compare/v0.55.0...v0.56.0) (2026-02-28)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Add default for TSelectedFields for application-instantiated types ([#438](https://github.com/expo/entity/issues/438)) ([3d4aa0e](https://github.com/expo/entity/commit/3d4aa0e9efa519a36f3db69bd8f117a993c100fa))
|
||||
- Apply same viewer context subclass type mechanism to knexLoader and related methods ([#472](https://github.com/expo/entity/issues/472)) ([018a40b](https://github.com/expo/entity/commit/018a40bc01d579ffbf88f308e641921e6fc39097))
|
||||
- correct discriminated union type for forward/backward pagination ([#474](https://github.com/expo/entity/issues/474)) ([90ae6e2](https://github.com/expo/entity/commit/90ae6e299bda26ac9a80d83e83334d4152c022dd))
|
||||
- correct pagination behavior for standard pagination with descending ordering clauses ([#477](https://github.com/expo/entity/issues/477)) ([e32c4c2](https://github.com/expo/entity/commit/e32c4c2bcc05f225a556f95c90f1ee0f4e24de74))
|
||||
- correct precedence parentheses for SQLFragment joins ([#476](https://github.com/expo/entity/issues/476)) ([2c4c649](https://github.com/expo/entity/commit/2c4c649a4bca5bc9ffdc41eb3135b2dfe4db5e57)), closes [/github.com/expo/entity/blob/main/packages/entity-database-adapter-knex/src/internal/EntityKnexDataManager.ts#L303](https://github.com//github.com/expo/entity/blob/main/packages/entity-database-adapter-knex/src/internal/EntityKnexDataManager.ts/issues/L303)
|
||||
- **deps:** update dependency @apollo/server to v5.4.0 [security] ([#420](https://github.com/expo/entity/issues/420)) ([cd81c5a](https://github.com/expo/entity/commit/cd81c5a5b6be43ef60248eeea4031a883dd1eae8))
|
||||
- **deps:** update dependency @koa/bodyparser to v6.1.0 ([#405](https://github.com/expo/entity/issues/405)) ([2dda22d](https://github.com/expo/entity/commit/2dda22d761f87851e3fcccc8443597daf8ac42e8))
|
||||
- **deps:** update dependency @koa/router to v15.3.0 ([#406](https://github.com/expo/entity/issues/406)) ([9e88cf6](https://github.com/expo/entity/commit/9e88cf6aab76902c65994d28de8dab73f1267021))
|
||||
- **deps:** update dependency graphql to v16.13.0 ([#479](https://github.com/expo/entity/issues/479)) ([976e2c3](https://github.com/expo/entity/commit/976e2c33867da71a2920d050c4086e2af8f6d54d))
|
||||
- **deps:** update dependency koa to v3.1.2 [security] ([#484](https://github.com/expo/entity/issues/484)) ([b258dad](https://github.com/expo/entity/commit/b258dad2ed69c473f04d57d26910eab2032d142c))
|
||||
- eliminate some unnecessary any casts ([#448](https://github.com/expo/entity/issues/448)) ([cc78029](https://github.com/expo/entity/commit/cc78029117c14510b0934b5e0d19876a45570f8d))
|
||||
- explicitly document behavior when cursor entity no longer exists ([#453](https://github.com/expo/entity/issues/453)) ([4dc156c](https://github.com/expo/entity/commit/4dc156c7a8dc50bc9e2802be133b9e258b289f54)), closes [#422](https://github.com/expo/entity/issues/422) [#431](https://github.com/expo/entity/issues/431)
|
||||
- move EntityPrivacyUtils back into core package ([#429](https://github.com/expo/entity/issues/429)) ([56ec27d](https://github.com/expo/entity/commit/56ec27d5f201d83930a2cc05dfc380a054bff1e1))
|
||||
- pass in augmentable classes to installation ([#437](https://github.com/expo/entity/issues/437)) ([f5c728b](https://github.com/expo/entity/commit/f5c728be565e01c0a8ace9ef51f6de8f49184dab)), closes [/github.com/expo/entity/pull/410#pullrequestreview-3765893898](https://github.com//github.com/expo/entity/pull/410/issues/pullrequestreview-3765893898)
|
||||
- refactor install method to instead be free functions ([#441](https://github.com/expo/entity/issues/441)) ([51d632d](https://github.com/expo/entity/commit/51d632da1619dc039d5c41ce6533f63254ec4337)), closes [#410](https://github.com/expo/entity/issues/410)
|
||||
- Reorganize constructionUtils to make non-public ([#454](https://github.com/expo/entity/issues/454)) ([d9377a2](https://github.com/expo/entity/commit/d9377a290b7e0c6803f9e83537eddb7204bc9320)), closes [#410](https://github.com/expo/entity/issues/410)
|
||||
- revert change to codemod now that Entity.knexLoader is supported again via subclass ([#443](https://github.com/expo/entity/issues/443)) ([ed6b2e0](https://github.com/expo/entity/commit/ed6b2e05539c77ee2abd2c6da74e2a451967d06e)), closes [#441](https://github.com/expo/entity/issues/441)
|
||||
- Type idField in EntityConfiguration as TIDField ([#425](https://github.com/expo/entity/issues/425)) ([be40bcf](https://github.com/expo/entity/commit/be40bcffa6e7db9b7ad75e1a80bbb48ff417c6c5))
|
||||
- update integration test to use BlahEntity.knexLoader syntax ([#444](https://github.com/expo/entity/issues/444)) ([457f58a](https://github.com/expo/entity/commit/457f58ad2c4e1cf09f3132f3b9af5008ba0290c7)), closes [#441](https://github.com/expo/entity/issues/441)
|
||||
- Use TSelectedFields for knex loader order by method ([#424](https://github.com/expo/entity/issues/424)) ([a9d09f4](https://github.com/expo/entity/commit/a9d09f42b67378d06133bcfb5b99c9afb17aac0d))
|
||||
|
||||
### Features
|
||||
|
||||
- Add authorization action to EntityPrivacyPolicyRuleEvaluationContext ([#464](https://github.com/expo/entity/issues/464)) ([e71ae5f](https://github.com/expo/entity/commit/e71ae5fcbae95096e0d6a42a8c960896ab83ccfb))
|
||||
- add codemod for upcoming v0.55.0-v0.56.0 ([#413](https://github.com/expo/entity/issues/413)) ([aba0ce5](https://github.com/expo/entity/commit/aba0ce5b5f38cb7b01956cde14a4714e2a03abb4)), closes [#407](https://github.com/expo/entity/issues/407) [-#410](https://github.com/-/issues/410)
|
||||
- add entity-database-adapter-knex-testing-utils containing StubPostgresDatabaseAdapter ([#412](https://github.com/expo/entity/issues/412)) ([eeccf59](https://github.com/expo/entity/commit/eeccf594c11bf090aa83bbf71fb4ead8668aec7d))
|
||||
- add entityField SQL helper ([#481](https://github.com/expo/entity/issues/481)) ([459a5ff](https://github.com/expo/entity/commit/459a5ff85014a8c7396c0856dffacb1491ccb963))
|
||||
- Add ilike and trigram similarity search to pagination ([#431](https://github.com/expo/entity/issues/431)) ([f72cbca](https://github.com/expo/entity/commit/f72cbcaf5a722e6bb8299644ec32b07f1d3362a9)), closes [#422](https://github.com/expo/entity/issues/422)
|
||||
- add method to get pagination cursor for single entity ([#475](https://github.com/expo/entity/issues/475)) ([898f71c](https://github.com/expo/entity/commit/898f71ce082c2593585575b60bd6ef2546717124))
|
||||
- Add paginated loader to entity-database-adapter-knex ([#422](https://github.com/expo/entity/issues/422)) ([58deffd](https://github.com/expo/entity/commit/58deffd426b0214073ede608cd47c04db2596d60))
|
||||
- Add pagination max page size configuration ([#436](https://github.com/expo/entity/issues/436)) ([3ec4b5f](https://github.com/expo/entity/commit/3ec4b5f0cd53bf18d6bcfa27dd29b70d3f4d5f19))
|
||||
- add PostgresEntity/ReadonlyPostgresEntity classes that expose knexLoader methods ([#442](https://github.com/expo/entity/issues/442)) ([40b409f](https://github.com/expo/entity/commit/40b409f6f889febf8b90b8837caecac26ff64174)), closes [#441](https://github.com/expo/entity/issues/441)
|
||||
- Add raw sqlfragment orderBy capability for pagination ([#457](https://github.com/expo/entity/issues/457)) ([0615ffa](https://github.com/expo/entity/commit/0615ffacffbe9e903a9bf03b171c102f0554b992))
|
||||
- add support for nulls ordering in order by clauses ([#468](https://github.com/expo/entity/issues/468)) ([6c0838d](https://github.com/expo/entity/commit/6c0838dc577a3b6190e3551ee834925d7bd53bc6))
|
||||
- support nullable search fields with postgres transforms ([#470](https://github.com/expo/entity/issues/470)) ([53b0604](https://github.com/expo/entity/commit/53b0604152b8ca5ad9fc616d18478f93473c7eb0))
|
||||
|
||||
# [0.55.0](https://github.com/expo/entity/compare/v0.54.0...v0.55.0) (2026-01-27)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **deps:** update dependency @apollo/server to v5.3.0 ([#403](https://github.com/expo/entity/issues/403)) ([b758c4d](https://github.com/expo/entity/commit/b758c4d344294797430de0afb88e47f558b6fa8d))
|
||||
|
||||
### Features
|
||||
|
||||
- add AllowIf[Any|All]SubRulesAllowPrivacyPolicyRule rules ([#401](https://github.com/expo/entity/issues/401)) ([5db8977](https://github.com/expo/entity/commit/5db89777c605eda9a8393227ceeb641e460dc463))
|
||||
- Add AllowIfInParentCascadeDeletionPrivacyPolicyRule ([#399](https://github.com/expo/entity/issues/399)) ([56ed357](https://github.com/expo/entity/commit/56ed357b923a8374a3c120e3eef5d487d5fb2f9c))
|
||||
- add EvaluateIfEntityFieldPredicatePrivacyPolicyRule ([#400](https://github.com/expo/entity/issues/400)) ([f3b575c](https://github.com/expo/entity/commit/f3b575cd00d47c747cf45e96c6db7857592b0f93))
|
||||
|
||||
# [0.54.0](https://github.com/expo/entity/compare/v0.53.0...v0.54.0) (2026-01-21)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **deps:** pin dependencies ([#328](https://github.com/expo/entity/issues/328)) ([5679f27](https://github.com/expo/entity/commit/5679f27209f1515ca5e626d858c4aa054b414625))
|
||||
- **deps:** pin dependency @apollo/server to 4.12.2 ([#330](https://github.com/expo/entity/issues/330)) ([7038912](https://github.com/expo/entity/commit/7038912ca5e5b203ebbca4b46bda5612e7660eac))
|
||||
- **deps:** pin dependency @koa/bodyparser to 6.0.0 ([#339](https://github.com/expo/entity/issues/339)) ([9f93926](https://github.com/expo/entity/commit/9f939269cf843059ec0ac729d470db074730348e))
|
||||
- **deps:** pin dependency koa to 2.16.3 ([#331](https://github.com/expo/entity/issues/331)) ([ce55922](https://github.com/expo/entity/commit/ce55922e3ec6692a9ea4847427dc475902174830))
|
||||
- Inline map().filter() loops in EntityLoaderUtils ([#369](https://github.com/expo/entity/issues/369)) ([ea6c128](https://github.com/expo/entity/commit/ea6c128d9c8fc21484645796f79e1aa8839dd395))
|
||||
- run tsc during tests CI job ([#373](https://github.com/expo/entity/issues/373)) ([1cc57ed](https://github.com/expo/entity/commit/1cc57eddce72663cff17a5102afa6c3e8248aee4))
|
||||
|
||||
### Features
|
||||
|
||||
- synthetically set cascading set-null fields to null during canViewerDeleteAsync evaluation ([#376](https://github.com/expo/entity/issues/376)) ([544bb7c](https://github.com/expo/entity/commit/544bb7c3f2f41419cb3b779f7e3d968e09455fa0))
|
||||
|
||||
# [0.53.0](https://github.com/expo/entity/compare/v0.52.0...v0.53.0) (2025-12-19)
|
||||
|
||||
**Note:** Version bump only for package root
|
||||
|
||||
# [0.52.0](https://github.com/expo/entity/compare/v0.51.0...v0.52.0) (2025-12-19)
|
||||
|
||||
### Features
|
||||
|
||||
- switch local cache to an interface ([#322](https://github.com/expo/entity/issues/322)) ([c453726](https://github.com/expo/entity/commit/c4537262d810ea01e519f9aec6ae5b17a54ce936))
|
||||
|
||||
# [0.51.0](https://github.com/expo/entity/compare/v0.50.0...v0.51.0) (2025-11-21)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- add default for TSelectedFields in PrivacyPolicyTestUtils Case ([#316](https://github.com/expo/entity/issues/316)) ([2f9d487](https://github.com/expo/entity/commit/2f9d4873aaefe891920c25a41088f7c467da6113))
|
||||
|
||||
# [0.50.0](https://github.com/expo/entity/compare/v0.49.0...v0.50.0) (2025-10-07)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- make in memory and stub database adapters behave more similarly to sql ([#315](https://github.com/expo/entity/issues/315)) ([0f53c24](https://github.com/expo/entity/commit/0f53c24faff8a994906e630d7f17d67d89ff5b0f)), closes [#311](https://github.com/expo/entity/issues/311)
|
||||
|
||||
# [0.49.0](https://github.com/expo/entity/compare/v0.48.0...v0.49.0) (2025-10-07)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- add readonly modifier to partitionArray utility method ([#307](https://github.com/expo/entity/issues/307)) ([077d53c](https://github.com/expo/entity/commit/077d53cd86d4c54132ea2ebcc59d1d75e2b507d0))
|
||||
- validate that UUID field is lower-case ([#310](https://github.com/expo/entity/issues/310)) ([949f00d](https://github.com/expo/entity/commit/949f00d93b52e405188b9b23e23ae59ce1c546de)), closes [/github.com/expo/entity/blob/077d53cd86d4c54132ea2ebcc59d1d75e2b507d0/packages/entity/src/EntityDatabaseAdapter.ts#L182](https://github.com//github.com/expo/entity/blob/077d53cd86d4c54132ea2ebcc59d1d75e2b507d0/packages/entity/src/EntityDatabaseAdapter.ts/issues/L182) [#309](https://github.com/expo/entity/issues/309)
|
||||
|
||||
### Features
|
||||
|
||||
- add more documentation to entity errors ([#312](https://github.com/expo/entity/issues/312)) ([4c3fd17](https://github.com/expo/entity/commit/4c3fd175a91cf3107b1ac413e39e52e051a9dc4b))
|
||||
- throw specific errors for database adapter issues ([#311](https://github.com/expo/entity/issues/311)) ([f6639f9](https://github.com/expo/entity/commit/f6639f9def4ac20b628dd48fdca8d7cf61b6347b))
|
||||
|
||||
# [0.48.0](https://github.com/expo/entity/compare/v0.47.0...v0.48.0) (2025-09-19)
|
||||
|
||||
**Note:** Version bump only for package root
|
||||
|
||||
# [0.47.0](https://github.com/expo/entity/compare/v0.46.0...v0.47.0) (2025-09-19)
|
||||
|
||||
### Features
|
||||
|
||||
- add support for Buffer fields ([#305](https://github.com/expo/entity/issues/305)) ([31b52a5](https://github.com/expo/entity/commit/31b52a5f75842f4810f66c12ad03a10d9192f889))
|
||||
- allow manual specification of cascadingDeleteCause in delete mutator factory method ([#306](https://github.com/expo/entity/issues/306)) ([4a3eda2](https://github.com/expo/entity/commit/4a3eda209fa747ee093ca3053b3196bf9a52193d))
|
||||
|
||||
# [0.46.0](https://github.com/expo/entity/compare/v0.45.0...v0.46.0) (2025-06-26)
|
||||
|
||||
**Note:** Version bump only for package root
|
||||
|
||||
# [0.45.0](https://github.com/expo/entity/compare/v0.44.0...v0.45.0) (2025-06-06)
|
||||
|
||||
### Features
|
||||
|
||||
- add ability to define a custom cache key invalidation version supplier ([#290](https://github.com/expo/entity/issues/290)) ([a3ab61b](https://github.com/expo/entity/commit/a3ab61bfa8d2eab3bbd9c703c5e483184bb67f13))
|
||||
- add createOrGetExistingAsync and createWithUniqueConstraintRecoveryAsync utility methods ([#291](https://github.com/expo/entity/issues/291)) ([78bc264](https://github.com/expo/entity/commit/78bc264b3328692b4b6722388a57d298f02e65c5))
|
||||
|
||||
# [0.44.0](https://github.com/expo/entity/compare/v0.43.0...v0.44.0) (2025-05-29)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- introduce transactional dataloader mode and fix global transaction ID generation ([#289](https://github.com/expo/entity/issues/289)) ([d3e89bd](https://github.com/expo/entity/commit/d3e89bdd82e6df5c5b35522bc3574fd9e1cfddff))
|
||||
|
||||
### Features
|
||||
|
||||
- add transaction-scoped dataloaders ([#284](https://github.com/expo/entity/issues/284)) ([e054a0d](https://github.com/expo/entity/commit/e054a0d74b3a39158ca7d73d05f59d3633113400)), closes [#98](https://github.com/expo/entity/issues/98) [#194](https://github.com/expo/entity/issues/194)
|
||||
|
||||
# [0.43.0](https://github.com/expo/entity/compare/v0.42.0...v0.43.0) (2025-04-10)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- fix generated tsc definition sentinel type visibility ([#283](https://github.com/expo/entity/issues/283)) ([9758fd5](https://github.com/expo/entity/commit/9758fd5df8695a8121258cd988c0c79ce8a3e4b6))
|
||||
|
||||
# [0.42.0](https://github.com/expo/entity/compare/v0.41.0...v0.42.0) (2025-04-10)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- fix metrics for dataloader waterfall logging ([#267](https://github.com/expo/entity/issues/267)) ([1d95eba](https://github.com/expo/entity/commit/1d95eba1f9efb67164c3affc917c9b2514e1529f))
|
||||
- move source of truth of loader docblocks to enforcing loader since it is not the default loader ([#264](https://github.com/expo/entity/issues/264)) ([aa56b70](https://github.com/expo/entity/commit/aa56b705db115cf760125208eb2d3585a3794e58))
|
||||
|
||||
### Features
|
||||
|
||||
- add @expo/entity-testing-utils package ([#280](https://github.com/expo/entity/issues/280)) ([485894a](https://github.com/expo/entity/commit/485894af16e233d533a9480285bbda56812cbe0b))
|
||||
- add composite field loading and caching ([#272](https://github.com/expo/entity/issues/272)) ([f0aa0da](https://github.com/expo/entity/commit/f0aa0dafebdb56418cbd22dda437233d850db3e7))
|
||||
- add some utility classes and methods in preparation for composite keys ([#269](https://github.com/expo/entity/issues/269)) ([ad41af1](https://github.com/expo/entity/commit/ad41af1c826fb267f5e463771107c85802578a19)), closes [#201](https://github.com/expo/entity/issues/201)
|
||||
- change entity id field generic to field name and derive type where necessary ([#278](https://github.com/expo/entity/issues/278)) ([b7e524c](https://github.com/expo/entity/commit/b7e524c41892797608d0b884410b8d520a80a9ef))
|
||||
- convert batched/cached loader interface to holder pattern ([#271](https://github.com/expo/entity/issues/271)) ([06b3cb7](https://github.com/expo/entity/commit/06b3cb7f8076ef9e5b83cdf154da159801bd14cc)), closes [#201](https://github.com/expo/entity/issues/201)
|
||||
- enforce explicit id field cache property ([#276](https://github.com/expo/entity/issues/276)) ([1da5cc0](https://github.com/expo/entity/commit/1da5cc01e1affc3b3338a0ab2050504b5853ac6f))
|
||||
- invalidate n+/-1 cacheKeyVersion for entities for push safety ([#275](https://github.com/expo/entity/issues/275)) ([d9c1852](https://github.com/expo/entity/commit/d9c1852706f98f0761ff24ddd244e4ed2beca580))
|
||||
|
||||
# [0.41.0](https://github.com/expo/entity/compare/v0.40.0...v0.41.0) (2025-03-07)
|
||||
|
||||
- feat!: Default all loaders and mutators to enforcing (#256) ([a976e76](https://github.com/expo/entity/commit/a976e765098bca27049585e43a3bed5c71b9ac6b)), closes [#256](https://github.com/expo/entity/issues/256)
|
||||
|
||||
### Features
|
||||
|
||||
- Create codemod for v0.40.0 -> v0.41.0 ([#260](https://github.com/expo/entity/issues/260)) ([4a2b61f](https://github.com/expo/entity/commit/4a2b61f2bd8edfc19109ef4c8ac780eaf0f7795c))
|
||||
|
||||
### BREAKING CHANGES
|
||||
|
||||
- Default all loaders and mutators to enforcing
|
||||
|
||||
# [0.40.0](https://github.com/expo/entity/compare/v0.39.0...v0.40.0) (2025-02-27)
|
||||
|
||||
### Features
|
||||
|
||||
- Create codemod for v0.39.0 -> v0.40.0 ([#259](https://github.com/expo/entity/issues/259)) ([d63ddd6](https://github.com/expo/entity/commit/d63ddd65997a441a914a71b158a111339970aeab))
|
||||
|
||||
### Reverts
|
||||
|
||||
- Revert "v0.40.0" ([fa78ce8](https://github.com/expo/entity/commit/fa78ce84e2f2eb8199e36fd51779fc68905001c0))
|
||||
|
||||
# [0.39.0](https://github.com/expo/entity/compare/v0.38.0...v0.39.0) (2024-11-21)
|
||||
|
||||
### Features
|
||||
|
||||
- add function to get authorization results for canViewerUpdateAsync/canViewerDeleteAsync ([#249](https://github.com/expo/entity/issues/249)) ([27a358d](https://github.com/expo/entity/commit/27a358d259503aa8d7d7413bfc1e52e0974dc626))
|
||||
|
||||
# [0.38.0](https://github.com/expo/entity/compare/v0.37.0...v0.38.0) (2024-06-27)
|
||||
|
||||
### Features
|
||||
|
||||
- add EntityEdgeDeletionAuthorizationInferenceBehavior for canViewerDeleteAsync ([#243](https://github.com/expo/entity/issues/243)) ([162b192](https://github.com/expo/entity/commit/162b19257ac720c94fb84d64808fb75748adcdc7))
|
||||
|
||||
# [0.37.0](https://github.com/expo/entity/compare/v0.36.0...v0.37.0) (2024-06-12)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- update barrels ([#241](https://github.com/expo/entity/issues/241)) ([8c5f81b](https://github.com/expo/entity/commit/8c5f81bcca107e9b7bbea5f0ab41b0105057806e))
|
||||
|
||||
# [0.36.0](https://github.com/expo/entity/compare/v0.35.0...v0.36.0) (2024-06-12)
|
||||
|
||||
### Breaking Changes
|
||||
|
||||
- make using non-enforcing entity loader explicit ([#238](https://github.com/expo/entity/issues/238)) ([2edc7af](https://github.com/expo/entity/commit/2edc7af7e20c362688ff2ee674fa2245205d6de4))
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- always reload entity after update since cascading changes may have changed it since commit ([#233](https://github.com/expo/entity/issues/233)) ([7c3c985](https://github.com/expo/entity/commit/7c3c9854a4dd91d4b73ebdb18bdeadea8b63f4c7))
|
||||
- constrain entity fields type to string-keyed object ([#235](https://github.com/expo/entity/issues/235)) ([7e2cea1](https://github.com/expo/entity/commit/7e2cea16973a0ae1917f867cd25d6ef7c8eaecef))
|
||||
- disallow keys of JS Object prototype for safety ([#236](https://github.com/expo/entity/issues/236)) ([05726d4](https://github.com/expo/entity/commit/05726d45fa9b91a6b25ddbc9c7e3e097b02097ca))
|
||||
|
||||
### Features
|
||||
|
||||
- Add global mutation trigger field to EntityCompanionProvider ([#215](https://github.com/expo/entity/issues/215)) ([6569486](https://github.com/expo/entity/commit/6569486ecddf2fd33c626f634c761e06636b1730))
|
||||
- add previousValue to privacy policy context for updates ([#232](https://github.com/expo/entity/issues/232)) ([af495a9](https://github.com/expo/entity/commit/af495a9a856a5042f00cfd150348e415139cb85e))
|
||||
- Add StrictEnumField with better validation ([#222](https://github.com/expo/entity/issues/222)) ([8753252](https://github.com/expo/entity/commit/87532525d090b3a67f1cedd95ac990f7dcb3ab5d))
|
||||
- make canViewerDeleteAsync recursive ([#224](https://github.com/expo/entity/issues/224)) ([60fc9a4](https://github.com/expo/entity/commit/60fc9a4c4b587b866f2f5a1b3bd22145bc77e6e9))
|
||||
- Use uuid v7 in stub database adapter ([#234](https://github.com/expo/entity/issues/234)) ([1e8ea64](https://github.com/expo/entity/commit/1e8ea64017430a2ce5ea197678bd78713ad77ecd))
|
||||
- move entity loader utils into their own object ([#239](https://github.com/expo/entity/issues/239))([93905be](https://github.com/expo/entity/commit/93905be81cf74c5626ca19358794261c17223f95))
|
||||
|
||||
# [0.35.0](https://github.com/expo/entity/compare/v0.34.0...v0.35.0) (2024-04-11)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Revert: require explicit query context specification ([#219](https://github.com/expo/entity/issues/219)) ([1bdfbd6](https://github.com/expo/entity/commit/1bdfbd6b562d1200e4029df0533b5adb2d917831))
|
||||
|
||||
# [0.34.0](https://github.com/expo/entity/compare/v0.33.0...v0.34.0) (2024-04-11)
|
||||
|
||||
### Features
|
||||
|
||||
- Allow UUIDv6/7/8 in UUIDField validation ([#221](https://github.com/expo/entity/issues/221)) ([cc6f3dc](https://github.com/expo/entity/commit/cc6f3dc1e677382eec220723c37b5b883d980372))
|
||||
- require explicit query context specification ([#219](https://github.com/expo/entity/issues/219)) ([8b0b31f](https://github.com/expo/entity/commit/8b0b31fdde5bd565aa527719003ef283a45f55cc))
|
||||
|
||||
# [0.33.0](https://github.com/expo/entity/compare/v0.32.0...v0.33.0) (2023-10-06)
|
||||
|
||||
### Features
|
||||
|
||||
- add loadManyByIDsNullableAsync loader method ([#214](https://github.com/expo/entity/issues/214)) ([aad94ac](https://github.com/expo/entity/commit/aad94aca479ac07cc7f76b5e1a122ff85a2d1542))
|
||||
|
||||
# [0.32.0](https://github.com/expo/entity/compare/v0.31.1...v0.32.0) (2023-06-10)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- remove unused field from redis configuration ([#213](https://github.com/expo/entity/issues/213)) ([503a2fb](https://github.com/expo/entity/commit/503a2fb6a8d57f6a09f6da99daf5ab92fc3e7945))
|
||||
|
||||
### Features
|
||||
|
||||
- add ability to specify knex transaction config ([#207](https://github.com/expo/entity/issues/207)) ([2069a0d](https://github.com/expo/entity/commit/2069a0d9fd1d8f7d090a71a90a758b2429ea5dd3))
|
||||
- entity companion definition static thunk ([#210](https://github.com/expo/entity/issues/210)) ([4b18010](https://github.com/expo/entity/commit/4b18010d42be50ef329f428b08330e21bf676586))
|
||||
|
||||
# [0.31.1](https://github.com/expo/entity/compare/v0.31.0...v0.31.1) (2023-03-06)
|
||||
|
||||
### Features
|
||||
|
||||
- add loadFirstByFieldEqualityConjunction, a convenience method ([#206](https://github.com/expo/entity/pull/206)) ([1934216](https://github.com/expo/entity/commit/1934216b70572cf2b9693e0f14939f49b90427dd))
|
||||
|
||||
# [0.31.0](https://github.com/expo/entity/compare/v0.30.0...v0.31.0) (2023-01-19)
|
||||
|
||||
**Note:** Version bump only for package root
|
||||
|
||||
# [0.30.0](https://github.com/expo/entity/compare/v0.29.0...v0.30.0) (2022-12-23)
|
||||
|
||||
**Note:** Version bump only for package root
|
||||
|
||||
# [0.29.0](https://github.com/expo/entity/compare/v0.28.0...v0.29.0) (2022-10-06)
|
||||
|
||||
### Features
|
||||
|
||||
- add example of redis mget batching using @expo/batcher ([#199](https://github.com/expo/entity/issues/199)) ([684f91b](https://github.com/expo/entity/commit/684f91b1f270851dbe331df2e2620664eea17266))
|
||||
- change redis cache adapter to use redis-like interface ([#200](https://github.com/expo/entity/issues/200)) ([e381f5b](https://github.com/expo/entity/commit/e381f5b2acfc17dcade2cf6e8506fa1c1c255e73))
|
||||
|
||||
# [0.28.0](https://github.com/expo/entity/compare/v0.27.0...v0.28.0) (2022-09-13)
|
||||
|
||||
### Features
|
||||
|
||||
- add BigIntField ([#193](https://github.com/expo/entity/issues/193)) ([acbad82](https://github.com/expo/entity/commit/acbad826a37bb5a054b4dcf9be91d11aa5f7f29a))
|
||||
- nested transactions ([#194](https://github.com/expo/entity/issues/194)) ([a77b914](https://github.com/expo/entity/commit/a77b914ad6dc018d1e309c9caf4971088a553ecf))
|
||||
|
||||
# [0.27.0](https://github.com/expo/entity/compare/v0.26.1...v0.27.0) (2022-08-09)
|
||||
|
||||
### Features
|
||||
|
||||
- add orderByRaw to loadManyByRawWhereClauseAsync ([#185](https://github.com/expo/entity/issues/185)) ([2817d78](https://github.com/expo/entity/commit/2817d78392dfecd0093111c24d581bdf01dba06d))
|
||||
|
||||
## [0.26.1](https://github.com/expo/entity/compare/v0.26.0...v0.26.1) (2022-07-28)
|
||||
|
||||
### Features
|
||||
|
||||
- recache to higher cache levels when loading from a composed cache ([#182](https://github.com/expo/entity/pull/182))
|
||||
|
||||
# [0.26.0](https://github.com/expo/entity/compare/v0.25.3...v0.26.0) (2022-07-14)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- fix workflow branch specification ([#179](https://github.com/expo/entity/issues/179)) ([25d14bf](https://github.com/expo/entity/commit/25d14bf8c1688524ad488aa2b63221393a4f0b05))
|
||||
|
||||
### Features
|
||||
|
||||
- add EntityEdgeDeletionBehavior.SET_NULL_INVALIDATE_CACHE_ONLY ([#178](https://github.com/expo/entity/issues/178)) ([a0c35dd](https://github.com/expo/entity/commit/a0c35ddd7bca05d0c3f41dcf48e16d058918231c))
|
||||
- update ioredis to v5, make it a devDependency of the cache packages ([#172](https://github.com/expo/entity/issues/172)) ([9fcfbf7](https://github.com/expo/entity/commit/9fcfbf746a61554cefedf7fb0f1a3a9056b58ffe))
|
||||
|
||||
## [0.25.3](https://github.com/expo/entity/compare/v0.25.2...v0.25.3) (2022-03-12)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- lock lru-cache to v6.0.0 ([#170](https://github.com/expo/entity/issues/170)) ([293868b](https://github.com/expo/entity/commit/293868b1a73df9f749216af68b1ae94ac22ac2b9))
|
||||
|
||||
## [0.25.2](https://github.com/expo/entity/compare/v0.25.1...v0.25.2) (2022-03-12)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- lru-cache yarn workspace version resolution ([#168](https://github.com/expo/entity/issues/168)) ([e76ca8c](https://github.com/expo/entity/commit/e76ca8c2f72da84aa372a64ef1898ade220ed0a8))
|
||||
|
||||
## [0.25.1](https://github.com/expo/entity/compare/v0.25.0...v0.25.1) (2022-03-10)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- add composed cache to index ([#166](https://github.com/expo/entity/issues/166)) ([5c82edc](https://github.com/expo/entity/commit/5c82edcd9f26854878d29950a05852e117bbd723))
|
||||
|
||||
# [0.25.0](https://github.com/expo/entity/compare/v0.24.0...v0.25.0) (2022-03-10)
|
||||
|
||||
### Features
|
||||
|
||||
- composed cache adapter, composed secondary cache, local memory secondary cache ([#164](https://github.com/expo/entity/issues/164)) ([6b6e70e](https://github.com/expo/entity/commit/6b6e70e75f7e42a4dce13edeb406a4d88d9264f0))
|
||||
- privacy policy evaluation context ([#162](https://github.com/expo/entity/issues/162)) ([3a81a4c](https://github.com/expo/entity/commit/3a81a4c059e2e995eb800e35e76b3d432c22b23f))
|
||||
|
||||
# [0.24.0](https://github.com/expo/entity/compare/v0.23.0...v0.24.0) (2022-02-16)
|
||||
|
||||
### Features
|
||||
|
||||
- add local memory cache package ([#158](https://github.com/expo/entity/issues/158)) ([85d3575](https://github.com/expo/entity/commit/85d35752fa0919a62efb776a762a6829e83ca7af))
|
||||
|
||||
### Chores
|
||||
|
||||
- Refactor cachers to use an abstract class and interface ([#157](https://github.com/expo/entity/pull/157))
|
||||
|
||||
# [0.23.0](https://github.com/expo/entity/compare/v0.22.0...v0.23.0) (2022-02-09)
|
||||
|
||||
### Breaking changes
|
||||
|
||||
- Move data transforming responsibilities to the CacheAdapter instead of the ReadThroughEntityCache. Affects any classes extending the EntityCacheAdapter and relying on the transformation behavior of the ReadThroughEntityCache. ([#153](https://github.com/expo/entity/pull/153))
|
||||
|
||||
# [0.22.0](https://github.com/expo/entity/compare/v0.21.0...v0.22.0) (2022-02-04)
|
||||
|
||||
### Features
|
||||
|
||||
- Upgrade knex to 1.x in `@expo/entity-database-adapter-knex` ([#150](https://github.com/expo/entity/issues/150)) ([e1b0ee5](https://github.com/expo/entity/commit/e1b0ee528175646239d88aa71ffce73983eeb52f))
|
||||
|
||||
# [0.21.0](https://github.com/expo/entity/compare/v0.20.0...v0.21.0) (2022-01-03)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* add pre-commit callbacks on EntityQueryContext ([#147](https://github.com/expo/entity/issues/147)) ([f1d9847](https://github.com/expo/entity/commit/f1d9847210f5775de964509e44ce493e44482b21))
|
||||
|
||||
|
||||
|
||||
|
||||
- add pre-commit callbacks on EntityQueryContext ([#147](https://github.com/expo/entity/issues/147)) ([f1d9847](https://github.com/expo/entity/commit/f1d9847210f5775de964509e44ce493e44482b21))
|
||||
|
||||
# [0.20.0](https://github.com/expo/entity/compare/v0.19.0...v0.20.0) (2021-12-29)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* separate post-commit invalidation callbacks from trigger and user callbacks ([#144](https://github.com/expo/entity/issues/144)) ([efa131e](https://github.com/expo/entity/commit/efa131ef29532accf0e51b6d867ffe15a27b4c90))
|
||||
|
||||
- separate post-commit invalidation callbacks from trigger and user callbacks ([#144](https://github.com/expo/entity/issues/144)) ([efa131e](https://github.com/expo/entity/commit/efa131ef29532accf0e51b6d867ffe15a27b4c90))
|
||||
|
||||
### Features
|
||||
|
||||
* add cascading deletion info ([#145](https://github.com/expo/entity/issues/145)) ([3727191](https://github.com/expo/entity/commit/372719176742d3ead6b7cbca29cb66b09b3fe09c))
|
||||
|
||||
|
||||
|
||||
|
||||
- add cascading deletion info ([#145](https://github.com/expo/entity/issues/145)) ([3727191](https://github.com/expo/entity/commit/372719176742d3ead6b7cbca29cb66b09b3fe09c))
|
||||
|
||||
# [0.19.0](https://github.com/expo/entity/compare/v0.18.0...v0.19.0) (2021-10-28)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* add authorization metrics ([#142](https://github.com/expo/entity/issues/142)) ([7cf54d8](https://github.com/expo/entity/commit/7cf54d83c52e104159a3e2e675cdda2d29afe204))
|
||||
* add IP support ([#138](https://github.com/expo/entity/issues/138)) ([1405a91](https://github.com/expo/entity/commit/1405a9126ee3a56c3e2a38bf3b697283744f5b5d))
|
||||
|
||||
|
||||
|
||||
|
||||
- add authorization metrics ([#142](https://github.com/expo/entity/issues/142)) ([7cf54d8](https://github.com/expo/entity/commit/7cf54d83c52e104159a3e2e675cdda2d29afe204))
|
||||
- add IP support ([#138](https://github.com/expo/entity/issues/138)) ([1405a91](https://github.com/expo/entity/commit/1405a9126ee3a56c3e2a38bf3b697283744f5b5d))
|
||||
|
||||
# [0.18.0](https://github.com/expo/entity/compare/v0.17.0...v0.18.0) (2021-10-14)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* enable noImplicitOverride tsc setting ([#135](https://github.com/expo/entity/issues/135)) ([4263cb9](https://github.com/expo/entity/commit/4263cb9b8b69fe4fb68c74ec1c7aba04508a1555))
|
||||
* expose describeFieldTestCase for use in other packages ([#139](https://github.com/expo/entity/issues/139)) ([1220477](https://github.com/expo/entity/commit/1220477aa331349b482ad015b44f76fa2bda7b90))
|
||||
* upgrade TypeScript to 4.4 ([#134](https://github.com/expo/entity/issues/134)) ([7612392](https://github.com/expo/entity/commit/7612392dbfd9778d25c465c1626c372d75a6d05a))
|
||||
* use better QueryContext types for transactional triggers and validators ([#136](https://github.com/expo/entity/issues/136)) ([5c7efe1](https://github.com/expo/entity/commit/5c7efe1678424ff907687e9327175bdc2538c3e0))
|
||||
|
||||
|
||||
|
||||
|
||||
- enable noImplicitOverride tsc setting ([#135](https://github.com/expo/entity/issues/135)) ([4263cb9](https://github.com/expo/entity/commit/4263cb9b8b69fe4fb68c74ec1c7aba04508a1555))
|
||||
- expose describeFieldTestCase for use in other packages ([#139](https://github.com/expo/entity/issues/139)) ([1220477](https://github.com/expo/entity/commit/1220477aa331349b482ad015b44f76fa2bda7b90))
|
||||
- upgrade TypeScript to 4.4 ([#134](https://github.com/expo/entity/issues/134)) ([7612392](https://github.com/expo/entity/commit/7612392dbfd9778d25c465c1626c372d75a6d05a))
|
||||
- use better QueryContext types for transactional triggers and validators ([#136](https://github.com/expo/entity/issues/136)) ([5c7efe1](https://github.com/expo/entity/commit/5c7efe1678424ff907687e9327175bdc2538c3e0))
|
||||
|
||||
# [0.17.0](https://github.com/expo/entity/compare/v0.16.0...v0.17.0) (2021-08-16)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* Add IntField and FloatField, deprecate NumberField ([#131](https://github.com/expo/entity/issues/131)) ([2f2d963](https://github.com/expo/entity/commit/2f2d963cf92c6575d78f8eeca344a4aadcc9b8d9))
|
||||
|
||||
|
||||
|
||||
|
||||
- Add IntField and FloatField, deprecate NumberField ([#131](https://github.com/expo/entity/issues/131)) ([2f2d963](https://github.com/expo/entity/commit/2f2d963cf92c6575d78f8eeca344a4aadcc9b8d9))
|
||||
|
||||
# [0.16.0](https://github.com/expo/entity/compare/v0.15.0...v0.16.0) (2021-07-07)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* allow null field values in loadManyByFieldEqualityConjunctionAsync ([#130](https://github.com/expo/entity/issues/130)) ([2f37dc6](https://github.com/expo/entity/commit/2f37dc6a37d368b12b3c8375e04f0777fd448797))
|
||||
|
||||
|
||||
|
||||
|
||||
- allow null field values in loadManyByFieldEqualityConjunctionAsync ([#130](https://github.com/expo/entity/issues/130)) ([2f37dc6](https://github.com/expo/entity/commit/2f37dc6a37d368b12b3c8375e04f0777fd448797))
|
||||
|
||||
# [0.15.0](https://github.com/expo/entity/compare/v0.14.1...v0.15.0) (2021-05-26)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* upgrade ioredis, knex, and pg ([#125](https://github.com/expo/entity/issues/125)) ([7c43edf](https://github.com/expo/entity/commit/7c43edf3de37e8af13e2a99bd44f03f54803d9b9))
|
||||
* upgrade some other packages to fix dependabot alerts ([#126](https://github.com/expo/entity/issues/126)) ([226bffe](https://github.com/expo/entity/commit/226bffe0a95d7b8a3e9e9d46aebdb0edf1eeec1a))
|
||||
* use column name instead of field name for redis cache key ([#124](https://github.com/expo/entity/issues/124)) ([d78f452](https://github.com/expo/entity/commit/d78f452bb3ac5527069813a03f5b2264375bd8ab))
|
||||
|
||||
- upgrade ioredis, knex, and pg ([#125](https://github.com/expo/entity/issues/125)) ([7c43edf](https://github.com/expo/entity/commit/7c43edf3de37e8af13e2a99bd44f03f54803d9b9))
|
||||
- upgrade some other packages to fix dependabot alerts ([#126](https://github.com/expo/entity/issues/126)) ([226bffe](https://github.com/expo/entity/commit/226bffe0a95d7b8a3e9e9d46aebdb0edf1eeec1a))
|
||||
- use column name instead of field name for redis cache key ([#124](https://github.com/expo/entity/issues/124)) ([d78f452](https://github.com/expo/entity/commit/d78f452bb3ac5527069813a03f5b2264375bd8ab))
|
||||
|
||||
### Features
|
||||
|
||||
* secondary cache loader ([#123](https://github.com/expo/entity/issues/123)) ([4cba01e](https://github.com/expo/entity/commit/4cba01eb259c87d60b3026ce776e46f781363690))
|
||||
|
||||
|
||||
|
||||
|
||||
- secondary cache loader ([#123](https://github.com/expo/entity/issues/123)) ([4cba01e](https://github.com/expo/entity/commit/4cba01eb259c87d60b3026ce776e46f781363690))
|
||||
|
||||
## [0.14.1](https://github.com/expo/entity/compare/v0.14.0...v0.14.1) (2021-03-24)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* typescript typedef of EntityMutationInfo ([#121](https://github.com/expo/entity/issues/121)) ([8e8f0dc](https://github.com/expo/entity/commit/8e8f0dc8636e343e29a6012d155e4111477a8989))
|
||||
|
||||
|
||||
|
||||
|
||||
- typescript typedef of EntityMutationInfo ([#121](https://github.com/expo/entity/issues/121)) ([8e8f0dc](https://github.com/expo/entity/commit/8e8f0dc8636e343e29a6012d155e4111477a8989))
|
||||
|
||||
# [0.14.0](https://github.com/expo/entity/compare/v0.13.0...v0.14.0) (2021-03-24)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* mutation validator and trigger cyclic import structure ([#118](https://github.com/expo/entity/issues/118)) ([b11dbc0](https://github.com/expo/entity/commit/b11dbc087ba5ad13c748032698dd73279b7960b0))
|
||||
|
||||
- mutation validator and trigger cyclic import structure ([#118](https://github.com/expo/entity/issues/118)) ([b11dbc0](https://github.com/expo/entity/commit/b11dbc087ba5ad13c748032698dd73279b7960b0))
|
||||
|
||||
### Features
|
||||
|
||||
* add mutation type to validator and trigger execute functions ([#119](https://github.com/expo/entity/issues/119)) ([ef40fa5](https://github.com/expo/entity/commit/ef40fa5e9cdf53e25e78a9423c698e6d1c9737af))
|
||||
|
||||
|
||||
|
||||
|
||||
- add mutation type to validator and trigger execute functions ([#119](https://github.com/expo/entity/issues/119)) ([ef40fa5](https://github.com/expo/entity/commit/ef40fa5e9cdf53e25e78a9423c698e6d1c9737af))
|
||||
|
||||
# [0.13.0](https://github.com/expo/entity/compare/v0.12.0...v0.13.0) (2021-02-12)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* allow undefined value for all fields ([#114](https://github.com/expo/entity/issues/114)) ([022e0e9](https://github.com/expo/entity/commit/022e0e9b88a5e84e0c1bc6c8810c850beb05f0cf))
|
||||
|
||||
- allow undefined value for all fields ([#114](https://github.com/expo/entity/issues/114)) ([022e0e9](https://github.com/expo/entity/commit/022e0e9b88a5e84e0c1bc6c8810c850beb05f0cf))
|
||||
|
||||
### Features
|
||||
|
||||
* add basic field type runtime validators ([#113](https://github.com/expo/entity/issues/113)) ([6c4d4b0](https://github.com/expo/entity/commit/6c4d4b03b5404dd776a12c5eb6f0af77a8c964f8))
|
||||
|
||||
|
||||
|
||||
|
||||
- add basic field type runtime validators ([#113](https://github.com/expo/entity/issues/113)) ([6c4d4b0](https://github.com/expo/entity/commit/6c4d4b03b5404dd776a12c5eb6f0af77a8c964f8))
|
||||
|
||||
# [0.12.0](https://github.com/expo/entity/compare/v0.11.0...v0.12.0) (2021-01-22)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* move runInTransaction to ViewerContext ([#108](https://github.com/expo/entity/issues/108)) ([b7309e1](https://github.com/expo/entity/commit/b7309e18a2dd780b64cd306d566de1f12bf8df3a))
|
||||
* remove cache and database adaptor flavor enums ([#109](https://github.com/expo/entity/issues/109)) ([72a77f8](https://github.com/expo/entity/commit/72a77f8b6893a4b76d3b1cce16659bdf3ce473ee))
|
||||
|
||||
- move runInTransaction to ViewerContext ([#108](https://github.com/expo/entity/issues/108)) ([b7309e1](https://github.com/expo/entity/commit/b7309e18a2dd780b64cd306d566de1f12bf8df3a))
|
||||
- remove cache and database adaptor flavor enums ([#109](https://github.com/expo/entity/issues/109)) ([72a77f8](https://github.com/expo/entity/commit/72a77f8b6893a4b76d3b1cce16659bdf3ce473ee))
|
||||
|
||||
### Features
|
||||
|
||||
* better typing on filterMap for use with predicates ([#106](https://github.com/expo/entity/issues/106)) ([01b3bfe](https://github.com/expo/entity/commit/01b3bfef7c6da55e7d89d8cccb52a0050187aee8))
|
||||
|
||||
|
||||
|
||||
|
||||
- better typing on filterMap for use with predicates ([#106](https://github.com/expo/entity/issues/106)) ([01b3bfe](https://github.com/expo/entity/commit/01b3bfef7c6da55e7d89d8cccb52a0050187aee8))
|
||||
|
||||
# [0.11.0](https://github.com/expo/entity/compare/v0.10.0...v0.11.0) (2020-12-24)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* add type generic default in test utils ([#104](https://github.com/expo/entity/issues/104)) ([fe2917e](https://github.com/expo/entity/commit/fe2917e6b5af808f166aa354d476e168c41e6d21))
|
||||
* maps utils invariants for collections containing undefined values ([#103](https://github.com/expo/entity/issues/103)) ([29c1a43](https://github.com/expo/entity/commit/29c1a434ab6cd8d30e9f0e7c742065c8d46d8d8a))
|
||||
* remove any cast from EntityLoader ([#100](https://github.com/expo/entity/issues/100)) ([b8e07f9](https://github.com/expo/entity/commit/b8e07f9ddc4077768980c000d61f5ddcc824e2e3))
|
||||
|
||||
- add type generic default in test utils ([#104](https://github.com/expo/entity/issues/104)) ([fe2917e](https://github.com/expo/entity/commit/fe2917e6b5af808f166aa354d476e168c41e6d21))
|
||||
- maps utils invariants for collections containing undefined values ([#103](https://github.com/expo/entity/issues/103)) ([29c1a43](https://github.com/expo/entity/commit/29c1a434ab6cd8d30e9f0e7c742065c8d46d8d8a))
|
||||
- remove any cast from EntityLoader ([#100](https://github.com/expo/entity/issues/100)) ([b8e07f9](https://github.com/expo/entity/commit/b8e07f9ddc4077768980c000d61f5ddcc824e2e3))
|
||||
|
||||
### Features
|
||||
|
||||
* better cache adapter error handling ([#102](https://github.com/expo/entity/issues/102)) ([15546aa](https://github.com/expo/entity/commit/15546aad97734ab9ac67f1c012485f096a0e94f1))
|
||||
* better database adapter error handling ([#101](https://github.com/expo/entity/issues/101)) ([5208aee](https://github.com/expo/entity/commit/5208aeedb0192f0460ba3d575770a04175f5c3aa))
|
||||
|
||||
|
||||
|
||||
|
||||
- better cache adapter error handling ([#102](https://github.com/expo/entity/issues/102)) ([15546aa](https://github.com/expo/entity/commit/15546aad97734ab9ac67f1c012485f096a0e94f1))
|
||||
- better database adapter error handling ([#101](https://github.com/expo/entity/issues/101)) ([5208aee](https://github.com/expo/entity/commit/5208aeedb0192f0460ba3d575770a04175f5c3aa))
|
||||
|
||||
# [0.10.0](https://github.com/expo/entity/compare/v0.9.1...v0.10.0) (2020-11-17)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* fix null handling in load paths (try 2) ([#96](https://github.com/expo/entity/issues/96)) ([d960329](https://github.com/expo/entity/commit/d9603298e0cc5c3c2b0cca959876e9195555fb57))
|
||||
|
||||
|
||||
|
||||
|
||||
- fix null handling in load paths (try 2) ([#96](https://github.com/expo/entity/issues/96)) ([d960329](https://github.com/expo/entity/commit/d9603298e0cc5c3c2b0cca959876e9195555fb57))
|
||||
|
||||
## [0.9.1](https://github.com/expo/entity/compare/v0.9.0...v0.9.1) (2020-11-06)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* edge deletion behavior cyclic import structure again ([#93](https://github.com/expo/entity/issues/93)) ([012c05d](https://github.com/expo/entity/commit/012c05d62d2ecd559eb331c6ca358052bfe66dc8))
|
||||
|
||||
|
||||
|
||||
|
||||
- edge deletion behavior cyclic import structure again ([#93](https://github.com/expo/entity/issues/93)) ([012c05d](https://github.com/expo/entity/commit/012c05d62d2ecd559eb331c6ca358052bfe66dc8))
|
||||
|
||||
# [0.9.0](https://github.com/expo/entity/compare/v0.8.1...v0.9.0) (2020-11-05)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* edge deletion behavior cyclic import structure ([#89](https://github.com/expo/entity/issues/89)) ([cca8fc1](https://github.com/expo/entity/commit/cca8fc19870057ebc7033081b59fab4a142fbf1a))
|
||||
|
||||
- edge deletion behavior cyclic import structure ([#89](https://github.com/expo/entity/issues/89)) ([cca8fc1](https://github.com/expo/entity/commit/cca8fc19870057ebc7033081b59fab4a142fbf1a))
|
||||
|
||||
### Features
|
||||
|
||||
* add ability to invalidate an entity manually ([#91](https://github.com/expo/entity/issues/91)) ([d1a6f13](https://github.com/expo/entity/commit/d1a6f1339648942f12b3fb605eb329aefe5cdd63))
|
||||
|
||||
|
||||
|
||||
|
||||
- add ability to invalidate an entity manually ([#91](https://github.com/expo/entity/issues/91)) ([d1a6f13](https://github.com/expo/entity/commit/d1a6f1339648942f12b3fb605eb329aefe5cdd63))
|
||||
|
||||
## [0.8.1](https://github.com/expo/entity/compare/v0.8.0...v0.8.1) (2020-10-09)
|
||||
|
||||
**Note:** Version bump only for package root
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
# [0.8.0](https://github.com/expo/entity/compare/v0.7.1...v0.8.0) (2020-10-09)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* add entityClassName to metrics adapter events ([#83](https://github.com/expo/entity/issues/83)) ([f4cdc01](https://github.com/expo/entity/commit/f4cdc01c6cb8868c24ce3da2274f0901687e05bd))
|
||||
|
||||
|
||||
|
||||
|
||||
- add entityClassName to metrics adapter events ([#83](https://github.com/expo/entity/issues/83)) ([f4cdc01](https://github.com/expo/entity/commit/f4cdc01c6cb8868c24ce3da2274f0901687e05bd))
|
||||
|
||||
## [0.7.1](https://github.com/expo/entity/compare/v0.7.0...v0.7.1) (2020-10-01)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* correct type of runInTransactionAsync ([#81](https://github.com/expo/entity/issues/81)) ([f98f7d1](https://github.com/expo/entity/commit/f98f7d14b6149a496a7a3e97dd63d89b2842cc26))
|
||||
|
||||
|
||||
|
||||
|
||||
- correct type of runInTransactionAsync ([#81](https://github.com/expo/entity/issues/81)) ([f98f7d1](https://github.com/expo/entity/commit/f98f7d14b6149a496a7a3e97dd63d89b2842cc26))
|
||||
|
||||
# [0.7.0](https://github.com/expo/entity/compare/v0.6.0...v0.7.0) (2020-10-01)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* fix incorrect loader types for field selecitons ([#72](https://github.com/expo/entity/issues/72)) ([cd2df8d](https://github.com/expo/entity/commit/cd2df8da4b81bcd4769e950d3124c18886df2816))
|
||||
* fix index export for EntityMutationTrigger and EntityMutationValidator ([#73](https://github.com/expo/entity/issues/73)) ([7e829f3](https://github.com/expo/entity/commit/7e829f30fde0bd343cb8a584ccda5530cf61ec33))
|
||||
* move cache invalidation to after transaction commit ([#77](https://github.com/expo/entity/issues/77)) ([dbc3c81](https://github.com/expo/entity/commit/dbc3c81b359a668b5134189ce6de49b5893bb5e7))
|
||||
* upgrade packages ([#78](https://github.com/expo/entity/issues/78)) ([9891e74](https://github.com/expo/entity/commit/9891e7469467a28589f529c8d87b10fc2232d3ff))
|
||||
|
||||
|
||||
|
||||
|
||||
- fix incorrect loader types for field selecitons ([#72](https://github.com/expo/entity/issues/72)) ([cd2df8d](https://github.com/expo/entity/commit/cd2df8da4b81bcd4769e950d3124c18886df2816))
|
||||
- fix index export for EntityMutationTrigger and EntityMutationValidator ([#73](https://github.com/expo/entity/issues/73)) ([7e829f3](https://github.com/expo/entity/commit/7e829f30fde0bd343cb8a584ccda5530cf61ec33))
|
||||
- move cache invalidation to after transaction commit ([#77](https://github.com/expo/entity/issues/77)) ([dbc3c81](https://github.com/expo/entity/commit/dbc3c81b359a668b5134189ce6de49b5893bb5e7))
|
||||
- upgrade packages ([#78](https://github.com/expo/entity/issues/78)) ([9891e74](https://github.com/expo/entity/commit/9891e7469467a28589f529c8d87b10fc2232d3ff))
|
||||
|
||||
# [0.6.0](https://github.com/expo/entity/compare/v0.5.2...v0.6.0) (2020-07-23)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* ensure transitive deletion triggers are run for CASCADE_DELETE_INVALIDATE_CACHE ([#66](https://github.com/expo/entity/issues/66)) ([5744c51](https://github.com/expo/entity/commit/5744c51e6b3178bd9f229f3f465b683bb4474c8a))
|
||||
* separate out EntityMutationValidator ([#68](https://github.com/expo/entity/issues/68)) ([547a1ef](https://github.com/expo/entity/commit/547a1efcecd17cc085702d1a3e9888ce5b644b13))
|
||||
* update setField to correct typing ([#62](https://github.com/expo/entity/issues/62)) ([7a77afc](https://github.com/expo/entity/commit/7a77afc83ea732c9b062a5d6865eff9d3131d015))
|
||||
|
||||
- ensure transitive deletion triggers are run for CASCADE_DELETE_INVALIDATE_CACHE ([#66](https://github.com/expo/entity/issues/66)) ([5744c51](https://github.com/expo/entity/commit/5744c51e6b3178bd9f229f3f465b683bb4474c8a))
|
||||
- separate out EntityMutationValidator ([#68](https://github.com/expo/entity/issues/68)) ([547a1ef](https://github.com/expo/entity/commit/547a1efcecd17cc085702d1a3e9888ce5b644b13))
|
||||
- update setField to correct typing ([#62](https://github.com/expo/entity/issues/62)) ([7a77afc](https://github.com/expo/entity/commit/7a77afc83ea732c9b062a5d6865eff9d3131d015))
|
||||
|
||||
### Features
|
||||
|
||||
* entity mutation validators ([#67](https://github.com/expo/entity/issues/67)) ([fc4377d](https://github.com/expo/entity/commit/fc4377d8839da07417b88afc138f73556383d896))
|
||||
* mutation triggers ([#65](https://github.com/expo/entity/issues/65)) ([fd6060c](https://github.com/expo/entity/commit/fd6060cc844f60635b3ce4c400c4877f2df8fa44))
|
||||
|
||||
|
||||
|
||||
|
||||
- entity mutation validators ([#67](https://github.com/expo/entity/issues/67)) ([fc4377d](https://github.com/expo/entity/commit/fc4377d8839da07417b88afc138f73556383d896))
|
||||
- mutation triggers ([#65](https://github.com/expo/entity/issues/65)) ([fd6060c](https://github.com/expo/entity/commit/fd6060cc844f60635b3ce4c400c4877f2df8fa44))
|
||||
|
||||
## [0.5.2](https://github.com/expo/entity/compare/v0.5.1...v0.5.2) (2020-07-02)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* fix stub cache key generation for global cache ([#59](https://github.com/expo/entity/issues/59)) ([7c180e8](https://github.com/expo/entity/commit/7c180e84d9a8cdfd9380fac0eab2d02a96ec18f3))
|
||||
|
||||
|
||||
|
||||
|
||||
- fix stub cache key generation for global cache ([#59](https://github.com/expo/entity/issues/59)) ([7c180e8](https://github.com/expo/entity/commit/7c180e84d9a8cdfd9380fac0eab2d02a96ec18f3))
|
||||
|
||||
## [0.5.1](https://github.com/expo/entity/compare/v0.5.0...v0.5.1) (2020-07-01)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* correct some typing issues and exports ([#57](https://github.com/expo/entity/issues/57)) ([e91cfba](https://github.com/expo/entity/commit/e91cfba99321c2c4da2078aafd8627cbf0ceca50))
|
||||
|
||||
|
||||
|
||||
|
||||
- correct some typing issues and exports ([#57](https://github.com/expo/entity/issues/57)) ([e91cfba](https://github.com/expo/entity/commit/e91cfba99321c2c4da2078aafd8627cbf0ceca50))
|
||||
|
||||
# 0.5.0 (2020-07-01)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* support number ID field generation in StubDatabaseAdapter ([#43](https://github.com/expo/entity/issues/43)) ([60cc0fb](https://github.com/expo/entity/commit/60cc0fbda460c3ada1f5244a2780f90382fe3ebd))
|
||||
* upgrade Apollo server for security advisory ([#38](https://github.com/expo/entity/issues/38)) ([d25c1e1](https://github.com/expo/entity/commit/d25c1e142d52385725593f4f155d1795697ab490))
|
||||
|
||||
- support number ID field generation in StubDatabaseAdapter ([#43](https://github.com/expo/entity/issues/43)) ([60cc0fb](https://github.com/expo/entity/commit/60cc0fbda460c3ada1f5244a2780f90382fe3ebd))
|
||||
- upgrade Apollo server for security advisory ([#38](https://github.com/expo/entity/issues/38)) ([d25c1e1](https://github.com/expo/entity/commit/d25c1e142d52385725593f4f155d1795697ab490))
|
||||
|
||||
### Features
|
||||
|
||||
* add two fan out methods to association loader ([#52](https://github.com/expo/entity/issues/52)) ([89cfb3d](https://github.com/expo/entity/commit/89cfb3d5a01d4da90f64acc52ae1b839ba348a35))
|
||||
* support entity fields subset of db fields ([#49](https://github.com/expo/entity/issues/49)) ([4e40b2e](https://github.com/expo/entity/commit/4e40b2e521407e521d236978ec3b3b56db3990be))
|
||||
|
||||
- add two fan out methods to association loader ([#52](https://github.com/expo/entity/issues/52)) ([89cfb3d](https://github.com/expo/entity/commit/89cfb3d5a01d4da90f64acc52ae1b839ba348a35))
|
||||
- support entity fields subset of db fields ([#49](https://github.com/expo/entity/issues/49)) ([4e40b2e](https://github.com/expo/entity/commit/4e40b2e521407e521d236978ec3b3b56db3990be))
|
||||
|
||||
# 0.4.0 (2020-06-03)
|
||||
|
||||
|
||||
* All Packages
|
||||
* Documentation Improvements
|
||||
* CI & Coverage
|
||||
* TypeScript 3.9 ([#18](https://github.com/expo/entity/pull/18))
|
||||
* `@expo/entity`
|
||||
* Enforcing mutator convenience functions ([#5](https://github.com/expo/entity/pull/5))
|
||||
* `loadByIDNullable` loader method ([#28](https://github.com/expo/entity/pull/28))
|
||||
* Add ability to check update and delete policies outside of mutation ([#30](https://github.com/expo/entity/pull/30))
|
||||
* `@expo/entity-cache-adapter-redis`
|
||||
* `@expo/entity-database-adapter-knex`
|
||||
* Throw when database adapter receives unexpected mutation results ([#31](https://github.com/expo/entity/pull/31))
|
||||
* `@expo/entity-example`
|
||||
* Initial Version
|
||||
- All Packages
|
||||
- Documentation Improvements
|
||||
- CI & Coverage
|
||||
- TypeScript 3.9 ([#18](https://github.com/expo/entity/pull/18))
|
||||
- `@expo/entity`
|
||||
- Enforcing mutator convenience functions ([#5](https://github.com/expo/entity/pull/5))
|
||||
- `loadByIDNullable` loader method ([#28](https://github.com/expo/entity/pull/28))
|
||||
- Add ability to check update and delete policies outside of mutation ([#30](https://github.com/expo/entity/pull/30))
|
||||
- `@expo/entity-cache-adapter-redis`
|
||||
- `@expo/entity-database-adapter-knex`
|
||||
- Throw when database adapter receives unexpected mutation results ([#31](https://github.com/expo/entity/pull/31))
|
||||
- `@expo/entity-example`
|
||||
- Initial Version
|
||||
|
||||
# 0.3.0 (2020-04-28)
|
||||
|
||||
* `@expo/entity`
|
||||
* Initial Version
|
||||
* `@expo/entity-cache-adapter-redis`
|
||||
* Initial Version
|
||||
* `@expo/entity-database-adapter-knex`
|
||||
* Initial Version
|
||||
- `@expo/entity`
|
||||
- Initial Version
|
||||
- `@expo/entity-cache-adapter-redis`
|
||||
- Initial Version
|
||||
- `@expo/entity-database-adapter-knex`
|
||||
- Initial Version
|
||||
@@ -4,21 +4,22 @@ Entity is a privacy-aware data layer for defining, caching, and authorizing acce
|
||||
|
||||
[](https://github.com/expo/entity/actions?query=workflow%3Atests)
|
||||
[](https://expo.github.io/entity/)
|
||||
[](https://codecov.io/gh/expo/entity)
|
||||
[](https://codecov.io/gh/expo/entity)
|
||||
[](https://www.npmjs.com/package/@expo/entity)
|
||||
[](https://www.npmjs.com/package/@expo/entity)
|
||||
|
||||
## Core Features
|
||||
|
||||
- Declarative actor authorization using Privacy Policies
|
||||
- Configurable data storage using Database Adapters
|
||||
- Configurable, optional full-object caching using Cache Adapters
|
||||
- [Dataloader](https://github.com/graphql/dataloader) in-memory caching
|
||||
- Well-typed model declaration
|
||||
- Typescript ORM.
|
||||
- Declarative actor pre-authorization of all CRUD operations using Privacy Policies.
|
||||
- Configurable data storage using Database Adapters.
|
||||
- Configurable, optional, full-object read-through caching using Cache Adapters.
|
||||
- [Dataloader](https://github.com/graphql/dataloader) in-memory batching and read-through caching.
|
||||
|
||||
|
||||
## Getting Started
|
||||
|
||||
- [Example application](packages/entity-example)
|
||||
- [Example application](https://github.com/expo/entity/tree/main/packages/entity-example)
|
||||
- [Documentation](https://expo.github.io/entity/)
|
||||
|
||||
|
||||
@@ -78,20 +79,23 @@ Entity is not limited in where it can or should be used, but was designed for us
|
||||
|
||||
Note: The entity framework instance should not be shared across multiple requests since it contains a unique memoized [Dataloader](https://github.com/graphql/dataloader#class-dataloader). A long-lived instance is prone to data synchronization issues, especially when the application is scaled horizontally and multiple shared caches would exist for the same data.
|
||||
|
||||
## Contributing
|
||||
|
||||
We control development tooling versions using [mise-en-place](https://mise.jdx.dev). If you have it installed, then:
|
||||
|
||||
- Running `mise run ci` will run all tests that our CI environment will run.
|
||||
- Running `mise t` will show a list of mise tasks you can run with `mise run <name>`.
|
||||
- Running `mise en .` will enter a shell environment which includes node and yarn.
|
||||
|
||||
If you don't have `mise` installed, [you can install it](https://mise.jdx.dev/installing-mise.html#https-mise-run) as easily as running `curl https://mise.run/zsh | sh`.
|
||||
|
||||
## Releasing
|
||||
|
||||
To release a new version, let's say `v1.0.0` for example:
|
||||
1. `git checkout -b bump-v1.0.0`
|
||||
1. `yarn lerna version minor -- --no-push --conventional-commits`
|
||||
1. `git push -u origin bump-v1.0.0`
|
||||
1. Create a PR from the `bump-v1.0.0` branch, wait for tests, and commit the PR using GitHub interface.
|
||||
1. `git checkout master && git pull`
|
||||
1. `git tag -d v1.0.0`
|
||||
1. `git tag v1.0.0`
|
||||
1. `git push origin v1.0.0`
|
||||
To release a new version:
|
||||
1. `git checkout main`
|
||||
1. `npm login` (to refresh token)
|
||||
1. `yarn lerna publish [patch|minor|major] -- --conventional-commits --force-publish`
|
||||
1. In GitHub release interface, create a new release from the tag, copy changelog changes to release description.
|
||||
1. `yarn clean && yarn tsc`
|
||||
1. In each public subpackage, run `npm publish` and enter 2FA token.
|
||||
|
||||
## License
|
||||
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
export default {
|
||||
presets: [['@babel/preset-env', { targets: { node: 'current' } }], '@babel/preset-typescript'],
|
||||
};
|
||||
@@ -0,0 +1,124 @@
|
||||
import { defineConfig, globalIgnores } from 'eslint/config';
|
||||
import universeNodeConfig from 'eslint-config-universe/flat/node.js';
|
||||
import universeSharedTypescriptAnalysisConfig from 'eslint-config-universe/flat/shared/typescript-analysis.js';
|
||||
import tsdoc from 'eslint-plugin-tsdoc';
|
||||
|
||||
export default defineConfig([
|
||||
globalIgnores([
|
||||
'packages/entity-codemod/**/__testfixtures__/**',
|
||||
'packages/*/build',
|
||||
'coverage',
|
||||
'coverage-integration',
|
||||
'doc',
|
||||
]),
|
||||
universeNodeConfig,
|
||||
universeSharedTypescriptAnalysisConfig,
|
||||
{
|
||||
languageOptions: {
|
||||
parserOptions: {
|
||||
projectService: true,
|
||||
},
|
||||
},
|
||||
plugins: {
|
||||
tsdoc,
|
||||
},
|
||||
rules: {
|
||||
'no-restricted-imports': ['error', { paths: ['.', '..', '../..'] }],
|
||||
'tsdoc/syntax': 'warn',
|
||||
'no-console': 'warn',
|
||||
'handle-callback-err': 'off',
|
||||
eqeqeq: ['warn', 'always'],
|
||||
'no-restricted-properties': [
|
||||
'warn',
|
||||
{
|
||||
object: 'it',
|
||||
property: 'only',
|
||||
message: 'it.only should not be committed to main.',
|
||||
},
|
||||
{
|
||||
object: 'test',
|
||||
property: 'only',
|
||||
message: 'test.only should not be committed to main.',
|
||||
},
|
||||
{
|
||||
object: 'describe',
|
||||
property: 'only',
|
||||
message: 'describe.only should not be committed to main.',
|
||||
},
|
||||
{
|
||||
object: 'it',
|
||||
property: 'skip',
|
||||
message: 'it.skip should not be committed to main.',
|
||||
},
|
||||
{
|
||||
object: 'test',
|
||||
property: 'skip',
|
||||
message: 'test.skip should not be committed to main.',
|
||||
},
|
||||
{
|
||||
object: 'describe',
|
||||
property: 'skip',
|
||||
message: 'describe.skip should not be committed to main.',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
settings: {
|
||||
jest: {
|
||||
version: 29,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
files: ['**/*.ts', '**/*.tsx', '**/*.d.ts'],
|
||||
rules: {
|
||||
'no-void': [
|
||||
'warn',
|
||||
{
|
||||
allowAsStatement: true,
|
||||
},
|
||||
],
|
||||
'@typescript-eslint/explicit-function-return-type': [
|
||||
'warn',
|
||||
{
|
||||
allowExpressions: true,
|
||||
},
|
||||
],
|
||||
'@typescript-eslint/naming-convention': [
|
||||
'warn',
|
||||
{
|
||||
selector: 'typeLike',
|
||||
format: ['PascalCase'],
|
||||
},
|
||||
{
|
||||
selector: 'enumMember',
|
||||
format: ['UPPER_CASE'],
|
||||
},
|
||||
// async functions must have names ending with 'Async'
|
||||
{
|
||||
selector: ['function', 'classMethod', 'typeMethod'],
|
||||
format: ['camelCase'],
|
||||
modifiers: ['async'],
|
||||
suffix: ['Async'],
|
||||
},
|
||||
],
|
||||
'no-dupe-class-members': 'off',
|
||||
'@typescript-eslint/no-dupe-class-members': ['error'],
|
||||
},
|
||||
},
|
||||
{
|
||||
files: ['**/*.ts', '**/*.tsx', '**/*.d.ts'],
|
||||
ignores: ['**/*tests__/**', 'packages/entity-codemod/src/transforms/**'],
|
||||
rules: {
|
||||
'no-restricted-exports': ['error', { restrictDefaultExports: { direct: true } }],
|
||||
},
|
||||
},
|
||||
{
|
||||
files: ['**/__tests__/**/*.ts', '**/__tests__/**/*.tsx', '**/__tests__/**/*.d.ts'],
|
||||
rules: {
|
||||
// ts-mockito verify function needs void functions within verify which is a void function
|
||||
'@typescript-eslint/no-confusing-void-expression': 'off',
|
||||
},
|
||||
},
|
||||
]);
|
||||
@@ -0,0 +1,7 @@
|
||||
import unitConfig from './jest.config.js';
|
||||
|
||||
export default {
|
||||
...unitConfig,
|
||||
coverageDirectory: 'coverage-integration',
|
||||
testMatch: ['**/__integration-tests__/**/*-test.ts'],
|
||||
};
|
||||
@@ -0,0 +1,11 @@
|
||||
import { stripTypeScriptTypes } from 'node:module';
|
||||
|
||||
export function process(sourceText, sourcePath) {
|
||||
return {
|
||||
code: stripTypeScriptTypes(sourceText, {
|
||||
mode: 'transform',
|
||||
sourceMap: true,
|
||||
sourceUrl: sourcePath,
|
||||
}),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
export default {
|
||||
// This is an absolute path determined at runtime so that running `yarn test`
|
||||
// both from each package's directory and from root works correctly.
|
||||
transform: { '\\.[jt]sx?$': new URL('./jest-transform.js', import.meta.url).toString() },
|
||||
extensionsToTreatAsEsm: ['.ts'],
|
||||
collectCoverageFrom: [
|
||||
'packages/*/src/**',
|
||||
'!packages/*/src/index.ts',
|
||||
'!packages/entity-example/**',
|
||||
'!packages/entity-codemod/**',
|
||||
'!packages/entity-testing-utils/**',
|
||||
'!**/__*test*__/**',
|
||||
],
|
||||
coverageProvider: 'v8',
|
||||
testMatch: ['**/__tests__/**/*-test.ts'],
|
||||
};
|
||||
+3
-5
@@ -1,8 +1,6 @@
|
||||
{
|
||||
"packages": [
|
||||
"packages/*"
|
||||
],
|
||||
"packages": ["packages/*"],
|
||||
"npmClient": "yarn",
|
||||
"useWorkspaces": true,
|
||||
"version": "0.22.0"
|
||||
"version": "0.63.0",
|
||||
"$schema": "node_modules/lerna/schemas/lerna-schema.json"
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
[tools]
|
||||
node = "24.14.1"
|
||||
|
||||
[tasks.yarn]
|
||||
run = "yarn install --immutable"
|
||||
|
||||
[tasks.build]
|
||||
depends = "yarn"
|
||||
run = "yarn build"
|
||||
|
||||
[tasks.typedoc]
|
||||
depends = "build"
|
||||
run = "yarn typedoc"
|
||||
|
||||
[tasks."checks:lint"]
|
||||
depends = "yarn"
|
||||
run = "yarn lint --max-warnings=0"
|
||||
|
||||
[tasks."checks:barrel"]
|
||||
description = "Check if barrel files are up-to-date"
|
||||
depends = "yarn"
|
||||
run = [
|
||||
"yarn ctix",
|
||||
'[ -z "$(git status --porcelain)" ]',
|
||||
]
|
||||
|
||||
[tasks."checks:unit"]
|
||||
depends = "yarn"
|
||||
run = "yarn test --coverage"
|
||||
|
||||
[tasks."checks:integration"]
|
||||
depends = "yarn"
|
||||
run = "yarn integration --coverage"
|
||||
|
||||
[tasks.ci]
|
||||
run = { task = "checks:*" }
|
||||
|
||||
[settings]
|
||||
experimental = true # needed for postinstall hook
|
||||
|
||||
[hooks]
|
||||
postinstall = [
|
||||
# Enable yarn shim. Version controlled by package.json#packageManager
|
||||
# NOTE: mise can install yarn directly, but renovate does not use mise, so we
|
||||
# control yarn versions using the packageManager field.
|
||||
"corepack enable yarn"
|
||||
]
|
||||
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"targetDefaults": {
|
||||
"tsc": {
|
||||
"dependsOn": ["^clean", "clean", "^tsc"]
|
||||
},
|
||||
"build": {
|
||||
"dependsOn": ["^clean", "clean", "^build"]
|
||||
},
|
||||
"test": {
|
||||
"dependsOn": ["^tsc"]
|
||||
},
|
||||
"integration": {
|
||||
"dependsOn": ["^tsc"]
|
||||
}
|
||||
}
|
||||
}
|
||||
+29
-40
@@ -5,47 +5,36 @@
|
||||
"packages/*"
|
||||
],
|
||||
"scripts": {
|
||||
"tsc": "lerna run tsc",
|
||||
"clean": "lerna run clean",
|
||||
"lint": "lerna run lint",
|
||||
"lint-fix": "lerna run lint-fix",
|
||||
"test": "lerna run test",
|
||||
"integration": "./resources/run-with-docker lerna run integration-no-setup",
|
||||
"prepublish": "lerna link && lerna run clean",
|
||||
"prepare": "lerna run tsc",
|
||||
"barrelsby": "lerna run barrelsby"
|
||||
"tsc": "tsc --build",
|
||||
"build": "tsc --build",
|
||||
"clean": "yarn build --clean",
|
||||
"lint": "eslint .",
|
||||
"lint-fix": "yarn lint --fix",
|
||||
"test": "yarn build --noCheck && NODE_OPTIONS='--experimental-vm-modules --disable-warning=ExperimentalWarning' jest",
|
||||
"test:all": "yarn test",
|
||||
"integration": "./resources/run-with-docker yarn test --config jest-integration.config.js --runInBand",
|
||||
"integration:all": "yarn integration",
|
||||
"ctix": "ctix build && resources/prepend-barrels.sh",
|
||||
"package-files": "yarn workspaces foreach --all --no-private --parallel --topological pack --dry-run",
|
||||
"typedoc": "yarn build && typedoc"
|
||||
},
|
||||
"type": "module",
|
||||
"devDependencies": {
|
||||
"@expo/entity": "file:packages/entity",
|
||||
"@expo/results": "^1.0.0",
|
||||
"@types/invariant": "^2.2.33",
|
||||
"@types/ioredis": "^4.26.4",
|
||||
"@types/jest": "^26.0.10",
|
||||
"@types/jsbn": "^1.2.29",
|
||||
"@types/node": "^14.6.0",
|
||||
"@types/uuid": "^8.3.0",
|
||||
"@typescript-eslint/eslint-plugin": "^4.14.0",
|
||||
"@typescript-eslint/parser": "^4.14.0",
|
||||
"barrelsby": "^2.2.0",
|
||||
"eslint": "^7.7.0",
|
||||
"eslint-config-universe": "^7.0.0",
|
||||
"eslint-plugin-tsdoc": "^0.2.11",
|
||||
"jest": "^26.6.3",
|
||||
"lerna": "^4.0.0",
|
||||
"nullthrows": "^1.1.1",
|
||||
"pg": "^8.6.0",
|
||||
"prettier": "^2.3.2",
|
||||
"ts-jest": "~26.4.4",
|
||||
"ts-mockito": "^2.6.1",
|
||||
"typedoc": "^0.21.9",
|
||||
"typescript": "^4.4.2",
|
||||
"uuid": "^8.3.0"
|
||||
"@tsconfig/bases": "1.0.23",
|
||||
"@types/node": "24.12.0",
|
||||
"ctix": "2.7.5",
|
||||
"eslint": "9.39.4",
|
||||
"eslint-config-universe": "15.0.3",
|
||||
"eslint-plugin-tsdoc": "0.5.2",
|
||||
"jest": "30.3.0",
|
||||
"lerna": "9.0.7",
|
||||
"prettier": "3.8.1",
|
||||
"prettier-plugin-organize-imports": "4.3.0",
|
||||
"typedoc": "0.28.18",
|
||||
"typescript": "5.9.3"
|
||||
},
|
||||
"dependencies": {
|
||||
"@expo/entity": "file:packages/entity",
|
||||
"@expo/entity-cache-adapter-redis": "file:packages/entity-cache-adapter-redis",
|
||||
"@expo/entity-database-adapter-knex": "file:packages/entity-database-adapter-knex",
|
||||
"@expo/entity-secondary-cache-redis": "file:packages/entity-secondary-cache-redis",
|
||||
"@expo/entity-ip-address-field": "file:packages/entity-ip-address-field"
|
||||
}
|
||||
"resolutions": {
|
||||
"collect-v8-coverage": "^1.0.3"
|
||||
},
|
||||
"packageManager": "[email protected]+sha512.f45ab632439a67f8bc759bf32ead036a1f413287b9042726b7cc4818b7b49e14e9423ba49b18f9e06ea4941c1ad062385b1d8760a8d5091a1a31e5f6219afca8"
|
||||
}
|
||||
@@ -0,0 +1,212 @@
|
||||
# Change Log
|
||||
|
||||
All notable changes to this project will be documented in this file.
|
||||
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
|
||||
|
||||
# [0.62.0](https://github.com/expo/entity/compare/v0.61.0...v0.62.0) (2026-03-16)
|
||||
|
||||
**Note:** Version bump only for package @expo/entity-cache-adapter-local-memory
|
||||
|
||||
# [0.61.0](https://github.com/expo/entity/compare/v0.60.0...v0.61.0) (2026-03-12)
|
||||
|
||||
**Note:** Version bump only for package @expo/entity-cache-adapter-local-memory
|
||||
|
||||
# [0.60.0](https://github.com/expo/entity/compare/v0.59.0...v0.60.0) (2026-03-06)
|
||||
|
||||
**Note:** Version bump only for package @expo/entity-cache-adapter-local-memory
|
||||
|
||||
# [0.59.0](https://github.com/expo/entity/compare/v0.58.0...v0.59.0) (2026-03-03)
|
||||
|
||||
**Note:** Version bump only for package @expo/entity-cache-adapter-local-memory
|
||||
|
||||
# [0.58.0](https://github.com/expo/entity/compare/v0.57.0...v0.58.0) (2026-03-02)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- escape all matching portions of parts of cacher cache keys ([#486](https://github.com/expo/entity/issues/486)) ([21f2f6f](https://github.com/expo/entity/commit/21f2f6f461ee771e8b1c1b22dbaf9b275094c0db))
|
||||
|
||||
# [0.57.0](https://github.com/expo/entity/compare/v0.56.0...v0.57.0) (2026-02-28)
|
||||
|
||||
**Note:** Version bump only for package @expo/entity-cache-adapter-local-memory
|
||||
|
||||
# [0.56.0](https://github.com/expo/entity/compare/v0.55.0...v0.56.0) (2026-02-28)
|
||||
|
||||
**Note:** Version bump only for package @expo/entity-cache-adapter-local-memory
|
||||
|
||||
# [0.55.0](https://github.com/expo/entity/compare/v0.54.0...v0.55.0) (2026-01-27)
|
||||
|
||||
**Note:** Version bump only for package @expo/entity-cache-adapter-local-memory
|
||||
|
||||
# [0.54.0](https://github.com/expo/entity/compare/v0.53.0...v0.54.0) (2026-01-21)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **deps:** pin dependencies ([#328](https://github.com/expo/entity/issues/328)) ([5679f27](https://github.com/expo/entity/commit/5679f27209f1515ca5e626d858c4aa054b414625))
|
||||
|
||||
# [0.53.0](https://github.com/expo/entity/compare/v0.52.0...v0.53.0) (2025-12-19)
|
||||
|
||||
**Note:** Version bump only for package @expo/entity-cache-adapter-local-memory
|
||||
|
||||
# [0.52.0](https://github.com/expo/entity/compare/v0.51.0...v0.52.0) (2025-12-19)
|
||||
|
||||
### Features
|
||||
|
||||
- switch local cache to an interface ([#322](https://github.com/expo/entity/issues/322)) ([c453726](https://github.com/expo/entity/commit/c4537262d810ea01e519f9aec6ae5b17a54ce936))
|
||||
|
||||
# [0.51.0](https://github.com/expo/entity/compare/v0.50.0...v0.51.0) (2025-11-21)
|
||||
|
||||
**Note:** Version bump only for package @expo/entity-cache-adapter-local-memory
|
||||
|
||||
# [0.50.0](https://github.com/expo/entity/compare/v0.49.0...v0.50.0) (2025-10-07)
|
||||
|
||||
**Note:** Version bump only for package @expo/entity-cache-adapter-local-memory
|
||||
|
||||
# [0.49.0](https://github.com/expo/entity/compare/v0.48.0...v0.49.0) (2025-10-07)
|
||||
|
||||
**Note:** Version bump only for package @expo/entity-cache-adapter-local-memory
|
||||
|
||||
# [0.48.0](https://github.com/expo/entity/compare/v0.47.0...v0.48.0) (2025-09-19)
|
||||
|
||||
**Note:** Version bump only for package @expo/entity-cache-adapter-local-memory
|
||||
|
||||
# [0.47.0](https://github.com/expo/entity/compare/v0.46.0...v0.47.0) (2025-09-19)
|
||||
|
||||
**Note:** Version bump only for package @expo/entity-cache-adapter-local-memory
|
||||
|
||||
# [0.46.0](https://github.com/expo/entity/compare/v0.45.0...v0.46.0) (2025-06-26)
|
||||
|
||||
**Note:** Version bump only for package @expo/entity-cache-adapter-local-memory
|
||||
|
||||
# [0.45.0](https://github.com/expo/entity/compare/v0.44.0...v0.45.0) (2025-06-06)
|
||||
|
||||
**Note:** Version bump only for package @expo/entity-cache-adapter-local-memory
|
||||
|
||||
# [0.44.0](https://github.com/expo/entity/compare/v0.43.0...v0.44.0) (2025-05-29)
|
||||
|
||||
**Note:** Version bump only for package @expo/entity-cache-adapter-local-memory
|
||||
|
||||
# [0.43.0](https://github.com/expo/entity/compare/v0.42.0...v0.43.0) (2025-04-10)
|
||||
|
||||
**Note:** Version bump only for package @expo/entity-cache-adapter-local-memory
|
||||
|
||||
# [0.42.0](https://github.com/expo/entity/compare/v0.41.0...v0.42.0) (2025-04-10)
|
||||
|
||||
### Features
|
||||
|
||||
- add @expo/entity-testing-utils package ([#280](https://github.com/expo/entity/issues/280)) ([485894a](https://github.com/expo/entity/commit/485894af16e233d533a9480285bbda56812cbe0b))
|
||||
- add composite field loading and caching ([#272](https://github.com/expo/entity/issues/272)) ([f0aa0da](https://github.com/expo/entity/commit/f0aa0dafebdb56418cbd22dda437233d850db3e7))
|
||||
- change entity id field generic to field name and derive type where necessary ([#278](https://github.com/expo/entity/issues/278)) ([b7e524c](https://github.com/expo/entity/commit/b7e524c41892797608d0b884410b8d520a80a9ef))
|
||||
- convert batched/cached loader interface to holder pattern ([#271](https://github.com/expo/entity/issues/271)) ([06b3cb7](https://github.com/expo/entity/commit/06b3cb7f8076ef9e5b83cdf154da159801bd14cc)), closes [#201](https://github.com/expo/entity/issues/201)
|
||||
- invalidate n+/-1 cacheKeyVersion for entities for push safety ([#275](https://github.com/expo/entity/issues/275)) ([d9c1852](https://github.com/expo/entity/commit/d9c1852706f98f0761ff24ddd244e4ed2beca580))
|
||||
|
||||
# [0.41.0](https://github.com/expo/entity/compare/v0.40.0...v0.41.0) (2025-03-07)
|
||||
|
||||
- feat!: Default all loaders and mutators to enforcing (#256) ([a976e76](https://github.com/expo/entity/commit/a976e765098bca27049585e43a3bed5c71b9ac6b)), closes [#256](https://github.com/expo/entity/issues/256)
|
||||
|
||||
### BREAKING CHANGES
|
||||
|
||||
- Default all loaders and mutators to enforcing
|
||||
|
||||
# [0.40.0](https://github.com/expo/entity/compare/v0.39.0...v0.40.0) (2025-02-27)
|
||||
|
||||
### Reverts
|
||||
|
||||
- Revert "v0.40.0" ([fa78ce8](https://github.com/expo/entity/commit/fa78ce84e2f2eb8199e36fd51779fc68905001c0))
|
||||
|
||||
# [0.39.0](https://github.com/expo/entity/compare/v0.38.0...v0.39.0) (2024-11-21)
|
||||
|
||||
**Note:** Version bump only for package @expo/entity-cache-adapter-local-memory
|
||||
|
||||
# [0.38.0](https://github.com/expo/entity/compare/v0.37.0...v0.38.0) (2024-06-27)
|
||||
|
||||
**Note:** Version bump only for package @expo/entity-cache-adapter-local-memory
|
||||
|
||||
# [0.37.0](https://github.com/expo/entity/compare/v0.36.0...v0.37.0) (2024-06-12)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- update barrels ([#241](https://github.com/expo/entity/issues/241)) ([8c5f81b](https://github.com/expo/entity/commit/8c5f81bcca107e9b7bbea5f0ab41b0105057806e))
|
||||
|
||||
# [0.36.0](https://github.com/expo/entity/compare/v0.35.0...v0.36.0) (2024-06-12)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- constrain entity fields type to string-keyed object ([#235](https://github.com/expo/entity/issues/235)) ([7e2cea1](https://github.com/expo/entity/commit/7e2cea16973a0ae1917f867cd25d6ef7c8eaecef))
|
||||
|
||||
# [0.35.0](https://github.com/expo/entity/compare/v0.34.0...v0.35.0) (2024-04-11)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Revert: require explicit query context specification ([#219](https://github.com/expo/entity/issues/219)) ([1bdfbd6](https://github.com/expo/entity/commit/1bdfbd6b562d1200e4029df0533b5adb2d917831))
|
||||
|
||||
# [0.34.0](https://github.com/expo/entity/compare/v0.33.0...v0.34.0) (2024-04-11)
|
||||
|
||||
### Features
|
||||
|
||||
- require explicit query context specification ([#219](https://github.com/expo/entity/issues/219)) ([8b0b31f](https://github.com/expo/entity/commit/8b0b31fdde5bd565aa527719003ef283a45f55cc))
|
||||
|
||||
# [0.33.0](https://github.com/expo/entity/compare/v0.32.0...v0.33.0) (2023-10-06)
|
||||
|
||||
**Note:** Version bump only for package @expo/entity-cache-adapter-local-memory
|
||||
|
||||
# [0.32.0](https://github.com/expo/entity/compare/v0.31.1...v0.32.0) (2023-06-10)
|
||||
|
||||
### Features
|
||||
|
||||
- entity companion definition static thunk ([#210](https://github.com/expo/entity/issues/210)) ([4b18010](https://github.com/expo/entity/commit/4b18010d42be50ef329f428b08330e21bf676586))
|
||||
|
||||
# [0.31.0](https://github.com/expo/entity/compare/v0.30.0...v0.31.0) (2023-01-19)
|
||||
|
||||
**Note:** Version bump only for package @expo/entity-cache-adapter-local-memory
|
||||
|
||||
# [0.30.0](https://github.com/expo/entity/compare/v0.29.0...v0.30.0) (2022-12-23)
|
||||
|
||||
**Note:** Version bump only for package @expo/entity-cache-adapter-local-memory
|
||||
|
||||
# [0.29.0](https://github.com/expo/entity/compare/v0.28.0...v0.29.0) (2022-10-06)
|
||||
|
||||
**Note:** Version bump only for package @expo/entity-cache-adapter-local-memory
|
||||
|
||||
# [0.28.0](https://github.com/expo/entity/compare/v0.27.0...v0.28.0) (2022-09-13)
|
||||
|
||||
**Note:** Version bump only for package @expo/entity-cache-adapter-local-memory
|
||||
|
||||
# [0.27.0](https://github.com/expo/entity/compare/v0.26.1...v0.27.0) (2022-08-09)
|
||||
|
||||
**Note:** Version bump only for package @expo/entity-cache-adapter-local-memory
|
||||
|
||||
## [0.26.1](https://github.com/expo/entity/compare/v0.26.0...v0.26.1) (2022-07-28)
|
||||
|
||||
**Note:** Version bump only for package @expo/entity-cache-adapter-local-memory
|
||||
|
||||
# [0.26.0](https://github.com/expo/entity/compare/v0.25.3...v0.26.0) (2022-07-14)
|
||||
|
||||
**Note:** Version bump only for package @expo/entity-cache-adapter-local-memory
|
||||
|
||||
## [0.25.3](https://github.com/expo/entity/compare/v0.25.2...v0.25.3) (2022-03-12)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- lock lru-cache to v6.0.0 ([#170](https://github.com/expo/entity/issues/170)) ([293868b](https://github.com/expo/entity/commit/293868b1a73df9f749216af68b1ae94ac22ac2b9))
|
||||
|
||||
## [0.25.2](https://github.com/expo/entity/compare/v0.25.1...v0.25.2) (2022-03-12)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- lru-cache yarn workspace version resolution ([#168](https://github.com/expo/entity/issues/168)) ([e76ca8c](https://github.com/expo/entity/commit/e76ca8c2f72da84aa372a64ef1898ade220ed0a8))
|
||||
|
||||
## [0.25.1](https://github.com/expo/entity/compare/v0.25.0...v0.25.1) (2022-03-10)
|
||||
|
||||
**Note:** Version bump only for package @expo/entity-cache-adapter-local-memory
|
||||
|
||||
# [0.25.0](https://github.com/expo/entity/compare/v0.24.0...v0.25.0) (2022-03-10)
|
||||
|
||||
### Features
|
||||
|
||||
- composed cache adapter, composed secondary cache, local memory secondary cache ([#164](https://github.com/expo/entity/issues/164)) ([6b6e70e](https://github.com/expo/entity/commit/6b6e70e75f7e42a4dce13edeb406a4d88d9264f0))
|
||||
|
||||
# [0.24.0](https://github.com/expo/entity/compare/v0.23.0...v0.24.0) (2022-02-16)
|
||||
|
||||
### Features
|
||||
|
||||
- add local memory cache package ([#158](https://github.com/expo/entity/issues/158)) ([85d3575](https://github.com/expo/entity/commit/85d35752fa0919a62efb776a762a6829e83ca7af))
|
||||
@@ -0,0 +1,49 @@
|
||||
# `@expo/entity-cache-adapter-local-memory`
|
||||
|
||||
Cross-request [LRU](https://github.com/isaacs/node-lru-cache) cache adapter for `@expo/entity`. Use
|
||||
this cache with caution - it is nonstandard. The cache is shared between requests in the node process.
|
||||
|
||||
Note: This uses version 6.0.0 of `node-lru-cache` since it the most tuned version for our use case (low TTL + LRU). Upgrading
|
||||
to 7.x will cause high memory usage for the entity cache adapter use case since it allocates fixed-size data structures up front to tune for the non-TTL use case: https://github.com/isaacs/node-lru-cache/issues/208.
|
||||
|
||||
[Documentation](https://expo.github.io/entity/modules/_expo_cache_adapter_local_memory.html)
|
||||
|
||||
## Why NOT use this cache
|
||||
|
||||
Because this is an in-memory cache, cross-machine invalidation is not possible. Do not use this cache
|
||||
if you have the following use cases:
|
||||
|
||||
- The objects stored are mutable
|
||||
- Reading a stale object from the cache is not acceptable in your application
|
||||
- Cross-machine invalidation is not possible
|
||||
|
||||
## Typical use cases
|
||||
|
||||
If your application sees many requests fetching the same objects, you can save a trip to your cache
|
||||
cluster and backing datastore by using this in-memory cache. Here are some good use cases:
|
||||
|
||||
- The objects stored are mostly immutable, and reading a stale object for a short TTL is acceptable
|
||||
- You have a low TTL setting in your cache
|
||||
|
||||
## Usage
|
||||
|
||||
During `EntityCompanionProvider` instantiation:
|
||||
|
||||
```typescript
|
||||
export const createDefaultEntityCompanionProvider = (
|
||||
metricsAdapter: IEntityMetricsAdapter = new NoOpEntityMetricsAdapter()
|
||||
): EntityCompanionProvider => {
|
||||
return new EntityCompanionProvider(
|
||||
metricsAdapter,
|
||||
{
|
||||
...
|
||||
},
|
||||
{
|
||||
['local-memory']: {
|
||||
cacheAdapterProvider: new LocalMemoryCacheAdapterProvider.getProvider(),
|
||||
},
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
```
|
||||
@@ -0,0 +1,40 @@
|
||||
{
|
||||
"name": "@expo/entity-cache-adapter-local-memory",
|
||||
"version": "0.63.0",
|
||||
"description": "Cross-request local memory cache adapter for @expo/entity",
|
||||
"files": [
|
||||
"build",
|
||||
"!*.tsbuildinfo",
|
||||
"!__*",
|
||||
"src"
|
||||
],
|
||||
"main": "build/src/index.js",
|
||||
"types": "build/src/index.d.ts",
|
||||
"scripts": {
|
||||
"build": "tsc --build",
|
||||
"prepack": "rm -rf build && yarn build",
|
||||
"clean": "yarn build --clean",
|
||||
"lint": "yarn run --top-level eslint src",
|
||||
"lint-fix": "yarn lint --fix",
|
||||
"test": "yarn test:all --rootDir $(pwd)",
|
||||
"integration": "yarn integration:all --rootDir $(pwd)"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"keywords": [
|
||||
"entity"
|
||||
],
|
||||
"author": "Expo",
|
||||
"license": "MIT",
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
"@expo/entity": "workspace:^"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@expo/entity-testing-utils": "workspace:^",
|
||||
"@isaacs/ttlcache": "2.1.4",
|
||||
"@jest/globals": "30.3.0",
|
||||
"typescript": "5.9.3"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
import type {
|
||||
CacheLoadResult,
|
||||
EntityConfiguration,
|
||||
IEntityGenericCacher,
|
||||
IEntityLoadKey,
|
||||
IEntityLoadValue,
|
||||
} from '@expo/entity';
|
||||
import { CacheStatus } from '@expo/entity';
|
||||
|
||||
// Sentinel value we store in local memory to negatively cache a database miss.
|
||||
// The sentinel value is distinct from any (positively) cached value.
|
||||
export const DOES_NOT_EXIST_LOCAL_MEMORY_CACHE = Symbol('doesNotExist');
|
||||
|
||||
/**
|
||||
* Type of value stored in the local memory cache. This is either the cached fields, or the
|
||||
* DOES_NOT_EXIST_LOCAL_MEMORY_CACHE sentinel value.
|
||||
*/
|
||||
export type LocalMemoryCacheValue<TFields extends Record<string, any>> =
|
||||
| Readonly<TFields>
|
||||
| typeof DOES_NOT_EXIST_LOCAL_MEMORY_CACHE;
|
||||
|
||||
/**
|
||||
* Interface for a local memory cache used by GenericLocalMemoryCacher. Most often, this is something like
|
||||
* a TTLCache from the `@isaacs/ttlcache` package or an lru-cache.
|
||||
*/
|
||||
export interface ILocalMemoryCache<TFields extends Record<string, any>> {
|
||||
/**
|
||||
* Gets a value from the cache for specified key.
|
||||
* @param key - key to get
|
||||
* @returns the cached value, or undefined if not present
|
||||
*/
|
||||
get(key: string): LocalMemoryCacheValue<TFields> | undefined;
|
||||
|
||||
/**
|
||||
* Sets a value in the cache for specified key.
|
||||
* @param key - key to set
|
||||
* @param value - value to set
|
||||
*/
|
||||
set(key: string, value: LocalMemoryCacheValue<TFields>): void;
|
||||
|
||||
/**
|
||||
* Deletes a value from the cache for specified key.
|
||||
* @param key - key to delete
|
||||
*/
|
||||
delete(key: string): void;
|
||||
}
|
||||
|
||||
export class GenericLocalMemoryCacher<
|
||||
TFields extends Record<string, any>,
|
||||
TIDField extends keyof TFields,
|
||||
> implements IEntityGenericCacher<TFields, TIDField> {
|
||||
constructor(
|
||||
private readonly entityConfiguration: EntityConfiguration<TFields, TIDField>,
|
||||
private readonly localMemoryCache: ILocalMemoryCache<TFields>,
|
||||
) {}
|
||||
|
||||
static createNoOpCache<TFields extends Record<string, any>>(): ILocalMemoryCache<TFields> {
|
||||
return {
|
||||
get(_key: string): LocalMemoryCacheValue<TFields> | undefined {
|
||||
return undefined;
|
||||
},
|
||||
set(_key: string, _value: LocalMemoryCacheValue<TFields>): void {},
|
||||
delete(_key: string): void {},
|
||||
};
|
||||
}
|
||||
|
||||
public async loadManyAsync(
|
||||
keys: readonly string[],
|
||||
): Promise<ReadonlyMap<string, CacheLoadResult<TFields>>> {
|
||||
const cacheResults = new Map<string, CacheLoadResult<TFields>>();
|
||||
for (const key of keys) {
|
||||
const cacheResult = this.localMemoryCache.get(key);
|
||||
if (cacheResult === DOES_NOT_EXIST_LOCAL_MEMORY_CACHE) {
|
||||
cacheResults.set(key, {
|
||||
status: CacheStatus.NEGATIVE,
|
||||
});
|
||||
} else if (cacheResult) {
|
||||
cacheResults.set(key, {
|
||||
status: CacheStatus.HIT,
|
||||
item: cacheResult as unknown as TFields,
|
||||
});
|
||||
} else {
|
||||
cacheResults.set(key, {
|
||||
status: CacheStatus.MISS,
|
||||
});
|
||||
}
|
||||
}
|
||||
return cacheResults;
|
||||
}
|
||||
|
||||
public async cacheManyAsync(objectMap: ReadonlyMap<string, Readonly<TFields>>): Promise<void> {
|
||||
for (const [key, item] of objectMap) {
|
||||
this.localMemoryCache.set(key, item);
|
||||
}
|
||||
}
|
||||
|
||||
public async cacheDBMissesAsync(keys: readonly string[]): Promise<void> {
|
||||
for (const key of keys) {
|
||||
this.localMemoryCache.set(key, DOES_NOT_EXIST_LOCAL_MEMORY_CACHE);
|
||||
}
|
||||
}
|
||||
|
||||
public async invalidateManyAsync(keys: readonly string[]): Promise<void> {
|
||||
for (const key of keys) {
|
||||
this.localMemoryCache.delete(key);
|
||||
}
|
||||
}
|
||||
|
||||
public makeCacheKeyForStorage<
|
||||
TLoadKey extends IEntityLoadKey<TFields, TIDField, TSerializedLoadValue, TLoadValue>,
|
||||
TSerializedLoadValue,
|
||||
TLoadValue extends IEntityLoadValue<TSerializedLoadValue>,
|
||||
>(key: TLoadKey, value: TLoadValue): string {
|
||||
const cacheKeyType = key.getLoadMethodType();
|
||||
const keyAndValueParts = key.createCacheKeyPartsForLoadValue(this.entityConfiguration, value);
|
||||
const parts = [
|
||||
this.entityConfiguration.tableName,
|
||||
cacheKeyType,
|
||||
`${this.entityConfiguration.cacheKeyVersion}`,
|
||||
...keyAndValueParts,
|
||||
];
|
||||
|
||||
const delimiter = ':';
|
||||
const escapedParts = parts.map((part) =>
|
||||
part.replaceAll('\\', '\\\\').replaceAll(delimiter, `\\${delimiter}`),
|
||||
);
|
||||
return escapedParts.join(delimiter);
|
||||
}
|
||||
|
||||
public makeCacheKeysForInvalidation<
|
||||
TLoadKey extends IEntityLoadKey<TFields, TIDField, TSerializedLoadValue, TLoadValue>,
|
||||
TSerializedLoadValue,
|
||||
TLoadValue extends IEntityLoadValue<TSerializedLoadValue>,
|
||||
>(key: TLoadKey, value: TLoadValue): readonly string[] {
|
||||
// for local memory caches, we don't need to invalidate old versions of the cache keys
|
||||
// since they are not persisted across deploys
|
||||
return [this.makeCacheKeyForStorage(key, value)];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import type {
|
||||
EntityConfiguration,
|
||||
IEntityCacheAdapter,
|
||||
IEntityCacheAdapterProvider,
|
||||
} from '@expo/entity';
|
||||
import { computeIfAbsent, GenericEntityCacheAdapter } from '@expo/entity';
|
||||
|
||||
import type { ILocalMemoryCache } from './GenericLocalMemoryCacher.ts';
|
||||
import { GenericLocalMemoryCacher } from './GenericLocalMemoryCacher.ts';
|
||||
|
||||
export type LocalMemoryCacheCreator = <
|
||||
TFields extends Record<string, any>,
|
||||
>() => ILocalMemoryCache<TFields>;
|
||||
/**
|
||||
* Vends local memory cache adapters. An instance of this class may be shared across requests to
|
||||
* share the local memory cache.
|
||||
*/
|
||||
export class LocalMemoryCacheAdapterProvider implements IEntityCacheAdapterProvider {
|
||||
/**
|
||||
* @returns a no-op local memory cache adapter provider, or one that doesn't cache at all.
|
||||
*/
|
||||
static createNoOpProvider(): IEntityCacheAdapterProvider {
|
||||
return new LocalMemoryCacheAdapterProvider(<TFields extends Record<string, any>>() =>
|
||||
GenericLocalMemoryCacher.createNoOpCache<TFields>(),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns a local memory cache adapter provider configured with the supplied local memory cache creator function.
|
||||
*/
|
||||
static createProviderWithCacheCreator(
|
||||
cacheCreator: LocalMemoryCacheCreator,
|
||||
): IEntityCacheAdapterProvider {
|
||||
return new LocalMemoryCacheAdapterProvider(cacheCreator);
|
||||
}
|
||||
|
||||
private readonly localMemoryCacheAdapterMap = new Map<
|
||||
string,
|
||||
GenericEntityCacheAdapter<any, any>
|
||||
>();
|
||||
|
||||
private constructor(private readonly localMemoryCacheCreator: LocalMemoryCacheCreator) {}
|
||||
|
||||
public getCacheAdapter<TFields extends Record<string, any>, TIDField extends keyof TFields>(
|
||||
entityConfiguration: EntityConfiguration<TFields, TIDField>,
|
||||
): IEntityCacheAdapter<TFields, TIDField> {
|
||||
return computeIfAbsent(this.localMemoryCacheAdapterMap, entityConfiguration.tableName, () => {
|
||||
const localMemoryCache = this.localMemoryCacheCreator<TFields>();
|
||||
return new GenericEntityCacheAdapter(
|
||||
new GenericLocalMemoryCacher(entityConfiguration, localMemoryCache),
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
+99
@@ -0,0 +1,99 @@
|
||||
import type { EntityCompanionDefinition, ViewerContext } from '@expo/entity';
|
||||
import {
|
||||
AlwaysAllowPrivacyPolicyRule,
|
||||
DateField,
|
||||
Entity,
|
||||
EntityConfiguration,
|
||||
EntityPrivacyPolicy,
|
||||
StringField,
|
||||
UUIDField,
|
||||
} from '@expo/entity';
|
||||
|
||||
export type LocalMemoryTestEntityFields = {
|
||||
id: string;
|
||||
name: string;
|
||||
dateField: Date | null;
|
||||
};
|
||||
|
||||
export class LocalMemoryTestEntity extends Entity<
|
||||
LocalMemoryTestEntityFields,
|
||||
'id',
|
||||
ViewerContext
|
||||
> {
|
||||
static defineCompanionDefinition(): EntityCompanionDefinition<
|
||||
LocalMemoryTestEntityFields,
|
||||
'id',
|
||||
ViewerContext,
|
||||
LocalMemoryTestEntity,
|
||||
LocalMemoryTestEntityPrivacyPolicy
|
||||
> {
|
||||
return {
|
||||
entityClass: LocalMemoryTestEntity,
|
||||
entityConfiguration: localMemoryTestEntityConfiguration,
|
||||
privacyPolicyClass: LocalMemoryTestEntityPrivacyPolicy,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export class LocalMemoryTestEntityPrivacyPolicy extends EntityPrivacyPolicy<
|
||||
LocalMemoryTestEntityFields,
|
||||
'id',
|
||||
ViewerContext,
|
||||
LocalMemoryTestEntity
|
||||
> {
|
||||
protected override readonly createRules = [
|
||||
new AlwaysAllowPrivacyPolicyRule<
|
||||
LocalMemoryTestEntityFields,
|
||||
'id',
|
||||
ViewerContext,
|
||||
LocalMemoryTestEntity
|
||||
>(),
|
||||
];
|
||||
protected override readonly readRules = [
|
||||
new AlwaysAllowPrivacyPolicyRule<
|
||||
LocalMemoryTestEntityFields,
|
||||
'id',
|
||||
ViewerContext,
|
||||
LocalMemoryTestEntity
|
||||
>(),
|
||||
];
|
||||
protected override readonly updateRules = [
|
||||
new AlwaysAllowPrivacyPolicyRule<
|
||||
LocalMemoryTestEntityFields,
|
||||
'id',
|
||||
ViewerContext,
|
||||
LocalMemoryTestEntity
|
||||
>(),
|
||||
];
|
||||
protected override readonly deleteRules = [
|
||||
new AlwaysAllowPrivacyPolicyRule<
|
||||
LocalMemoryTestEntityFields,
|
||||
'id',
|
||||
ViewerContext,
|
||||
LocalMemoryTestEntity
|
||||
>(),
|
||||
];
|
||||
}
|
||||
|
||||
export const localMemoryTestEntityConfiguration = new EntityConfiguration<
|
||||
LocalMemoryTestEntityFields,
|
||||
'id'
|
||||
>({
|
||||
idField: 'id',
|
||||
tableName: 'local_memory_test_entities',
|
||||
schema: {
|
||||
id: new UUIDField({
|
||||
columnName: 'id',
|
||||
cache: true,
|
||||
}),
|
||||
name: new StringField({
|
||||
columnName: 'name',
|
||||
cache: true,
|
||||
}),
|
||||
dateField: new DateField({
|
||||
columnName: 'date_field',
|
||||
}),
|
||||
},
|
||||
databaseAdapterFlavor: 'postgres',
|
||||
cacheAdapterFlavor: 'local-memory',
|
||||
});
|
||||
+63
@@ -0,0 +1,63 @@
|
||||
import type { IEntityMetricsAdapter } from '@expo/entity';
|
||||
import { EntityCompanionProvider, NoOpEntityMetricsAdapter } from '@expo/entity';
|
||||
import { StubDatabaseAdapterProvider, StubQueryContextProvider } from '@expo/entity-testing-utils';
|
||||
import { TTLCache } from '@isaacs/ttlcache';
|
||||
|
||||
import type { ILocalMemoryCache, LocalMemoryCacheValue } from '../GenericLocalMemoryCacher.ts';
|
||||
import { LocalMemoryCacheAdapterProvider } from '../LocalMemoryCacheAdapterProvider.ts';
|
||||
|
||||
const queryContextProvider = new StubQueryContextProvider();
|
||||
|
||||
function createTTLCache<TFields extends Record<string, any>>(
|
||||
options: { maxSize?: number; ttlSeconds?: number } = {},
|
||||
): ILocalMemoryCache<TFields> {
|
||||
const DEFAULT_LRU_CACHE_MAX_AGE_SECONDS = 10;
|
||||
const DEFAULT_LRU_CACHE_SIZE = 10000;
|
||||
const maxAgeSeconds = options.ttlSeconds ?? DEFAULT_LRU_CACHE_MAX_AGE_SECONDS;
|
||||
return new TTLCache<string, LocalMemoryCacheValue<TFields>>({
|
||||
max: options.maxSize ?? DEFAULT_LRU_CACHE_SIZE,
|
||||
ttl: maxAgeSeconds * 1000, // convert to ms
|
||||
updateAgeOnGet: true,
|
||||
});
|
||||
}
|
||||
|
||||
export const createLocalMemoryTestEntityCompanionProvider = (
|
||||
localMemoryOptions: { maxSize?: number; ttlSeconds?: number } = {},
|
||||
metricsAdapter: IEntityMetricsAdapter = new NoOpEntityMetricsAdapter(),
|
||||
): EntityCompanionProvider => {
|
||||
const localMemoryCacheAdapterProvider =
|
||||
localMemoryOptions.maxSize === 0 && localMemoryOptions.ttlSeconds === 0
|
||||
? LocalMemoryCacheAdapterProvider.createNoOpProvider()
|
||||
: LocalMemoryCacheAdapterProvider.createProviderWithCacheCreator(() =>
|
||||
createTTLCache(localMemoryOptions),
|
||||
);
|
||||
return new EntityCompanionProvider(
|
||||
metricsAdapter,
|
||||
new Map([
|
||||
[
|
||||
'postgres',
|
||||
{
|
||||
adapterProvider: new StubDatabaseAdapterProvider(),
|
||||
queryContextProvider,
|
||||
},
|
||||
],
|
||||
]),
|
||||
new Map([
|
||||
[
|
||||
'local-memory',
|
||||
{
|
||||
cacheAdapterProvider: localMemoryCacheAdapterProvider,
|
||||
},
|
||||
],
|
||||
]),
|
||||
);
|
||||
};
|
||||
|
||||
export const createNoOpLocalMemoryIntegrationTestEntityCompanionProvider = (
|
||||
metricsAdapter: IEntityMetricsAdapter = new NoOpEntityMetricsAdapter(),
|
||||
): EntityCompanionProvider => {
|
||||
return createLocalMemoryTestEntityCompanionProvider(
|
||||
{ maxSize: 0, ttlSeconds: 0 },
|
||||
metricsAdapter,
|
||||
);
|
||||
};
|
||||
+178
@@ -0,0 +1,178 @@
|
||||
import type { GenericEntityCacheAdapter, IEntityGenericCacher } from '@expo/entity';
|
||||
import {
|
||||
CacheStatus,
|
||||
SingleFieldHolder,
|
||||
SingleFieldValueHolder,
|
||||
ViewerContext,
|
||||
} from '@expo/entity';
|
||||
import { describe, expect, it } from '@jest/globals';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
|
||||
import { GenericLocalMemoryCacher } from '../GenericLocalMemoryCacher.ts';
|
||||
import type { LocalMemoryCacheAdapterProvider } from '../LocalMemoryCacheAdapterProvider.ts';
|
||||
import type { LocalMemoryTestEntityFields } from '../__testfixtures__/LocalMemoryTestEntity.ts';
|
||||
import { LocalMemoryTestEntity } from '../__testfixtures__/LocalMemoryTestEntity.ts';
|
||||
import {
|
||||
createLocalMemoryTestEntityCompanionProvider,
|
||||
createNoOpLocalMemoryIntegrationTestEntityCompanionProvider,
|
||||
} from '../__testfixtures__/createLocalMemoryTestEntityCompanionProvider.ts';
|
||||
|
||||
describe(GenericLocalMemoryCacher, () => {
|
||||
it('has correct caching behavior', async () => {
|
||||
const entityCompanionProvider = createLocalMemoryTestEntityCompanionProvider();
|
||||
const viewerContext = new ViewerContext(entityCompanionProvider);
|
||||
const genericCacher = (
|
||||
viewerContext.entityCompanionProvider.getCompanionForEntity(LocalMemoryTestEntity)[
|
||||
'tableDataCoordinator'
|
||||
]['cacheAdapter'] as GenericEntityCacheAdapter<LocalMemoryTestEntityFields, 'id'>
|
||||
)['genericCacher'];
|
||||
|
||||
const date = new Date();
|
||||
const entity1Created = await LocalMemoryTestEntity.creator(viewerContext)
|
||||
.setField('name', 'blah')
|
||||
.setField('dateField', date)
|
||||
.createAsync();
|
||||
|
||||
// loading an entity should put it in cache
|
||||
const entity1 = await LocalMemoryTestEntity.loader(viewerContext).loadByIDAsync(
|
||||
entity1Created.getID(),
|
||||
);
|
||||
|
||||
const localMemoryCacheAdapterProvider = entityCompanionProvider['cacheAdapterFlavors'].get(
|
||||
'local-memory',
|
||||
)!.cacheAdapterProvider as LocalMemoryCacheAdapterProvider;
|
||||
const entitySpecificGenericCacher = localMemoryCacheAdapterProvider[
|
||||
'localMemoryCacheAdapterMap'
|
||||
].get(
|
||||
viewerContext.entityCompanionProvider.getCompanionForEntity(LocalMemoryTestEntity)
|
||||
.entityCompanionDefinition.entityConfiguration.tableName,
|
||||
)!['genericCacher'] as IEntityGenericCacher<LocalMemoryTestEntityFields, 'id'>;
|
||||
const cachedResult = await entitySpecificGenericCacher.loadManyAsync([
|
||||
genericCacher.makeCacheKeyForStorage(
|
||||
new SingleFieldHolder('id'),
|
||||
new SingleFieldValueHolder(entity1.getID()),
|
||||
),
|
||||
]);
|
||||
const cachedValue = cachedResult.get(
|
||||
genericCacher.makeCacheKeyForStorage(
|
||||
new SingleFieldHolder('id'),
|
||||
new SingleFieldValueHolder(entity1.getID()),
|
||||
),
|
||||
)!;
|
||||
expect(cachedValue).toMatchObject({
|
||||
status: CacheStatus.HIT,
|
||||
item: {
|
||||
id: entity1.getID(),
|
||||
name: 'blah',
|
||||
dateField: date,
|
||||
},
|
||||
});
|
||||
|
||||
// simulate non existent db fetch, should write negative result ('') to cache
|
||||
const nonExistentId = uuidv4();
|
||||
|
||||
const entityNonExistentResult =
|
||||
await LocalMemoryTestEntity.loaderWithAuthorizationResults(viewerContext).loadByIDAsync(
|
||||
nonExistentId,
|
||||
);
|
||||
expect(entityNonExistentResult.ok).toBe(false);
|
||||
|
||||
const nonExistentCachedResult = await entitySpecificGenericCacher.loadManyAsync([
|
||||
genericCacher.makeCacheKeyForStorage(
|
||||
new SingleFieldHolder('id'),
|
||||
new SingleFieldValueHolder(nonExistentId),
|
||||
),
|
||||
]);
|
||||
expect(
|
||||
nonExistentCachedResult.get(
|
||||
genericCacher.makeCacheKeyForStorage(
|
||||
new SingleFieldHolder('id'),
|
||||
new SingleFieldValueHolder(nonExistentId),
|
||||
),
|
||||
),
|
||||
).toMatchObject({
|
||||
status: CacheStatus.NEGATIVE,
|
||||
});
|
||||
|
||||
// load again through entities framework to ensure it reads negative result
|
||||
const entityNonExistentResult2 =
|
||||
await LocalMemoryTestEntity.loaderWithAuthorizationResults(viewerContext).loadByIDAsync(
|
||||
nonExistentId,
|
||||
);
|
||||
expect(entityNonExistentResult2.ok).toBe(false);
|
||||
|
||||
// invalidate from cache to ensure it invalidates correctly
|
||||
await LocalMemoryTestEntity.invalidationUtils(viewerContext).invalidateFieldsAsync(
|
||||
entity1.getAllFields(),
|
||||
);
|
||||
const keys = genericCacher.makeCacheKeysForInvalidation(
|
||||
new SingleFieldHolder('id'),
|
||||
new SingleFieldValueHolder(entity1.getID()),
|
||||
);
|
||||
const cachedResultMiss = await entitySpecificGenericCacher.loadManyAsync(keys);
|
||||
expect(Array.from(cachedResultMiss.values()).every((v) => v.status === CacheStatus.MISS)).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it('respects the parameters of a noop cache', async () => {
|
||||
const entityCompanionProvider = createNoOpLocalMemoryIntegrationTestEntityCompanionProvider();
|
||||
const viewerContext = new ViewerContext(entityCompanionProvider);
|
||||
const genericCacher = (
|
||||
viewerContext.entityCompanionProvider.getCompanionForEntity(LocalMemoryTestEntity)[
|
||||
'tableDataCoordinator'
|
||||
]['cacheAdapter'] as GenericEntityCacheAdapter<LocalMemoryTestEntityFields, 'id'>
|
||||
)['genericCacher'];
|
||||
const cacheKeyMaker = genericCacher['makeCacheKeyForStorage'].bind(genericCacher);
|
||||
|
||||
const date = new Date();
|
||||
const entity1Created = await LocalMemoryTestEntity.creator(viewerContext)
|
||||
.setField('name', 'blah')
|
||||
.setField('dateField', date)
|
||||
.createAsync();
|
||||
|
||||
// loading an entity will try to put it in cache but it's a noop cache, so it should be a miss
|
||||
const entity1 = await LocalMemoryTestEntity.loader(viewerContext).loadByIDAsync(
|
||||
entity1Created.getID(),
|
||||
);
|
||||
|
||||
const localMemoryCacheAdapterProvider = entityCompanionProvider['cacheAdapterFlavors'].get(
|
||||
'local-memory',
|
||||
)!.cacheAdapterProvider as LocalMemoryCacheAdapterProvider;
|
||||
const entitySpecificGenericCacher = localMemoryCacheAdapterProvider[
|
||||
'localMemoryCacheAdapterMap'
|
||||
].get(
|
||||
viewerContext.entityCompanionProvider.getCompanionForEntity(LocalMemoryTestEntity)
|
||||
.entityCompanionDefinition.entityConfiguration.tableName,
|
||||
)!['genericCacher'] as IEntityGenericCacher<LocalMemoryTestEntityFields, 'id'>;
|
||||
const cachedResult = await entitySpecificGenericCacher.loadManyAsync([
|
||||
cacheKeyMaker(new SingleFieldHolder('id'), new SingleFieldValueHolder(entity1.getID())),
|
||||
]);
|
||||
const cachedValue = cachedResult.get(
|
||||
cacheKeyMaker(new SingleFieldHolder('id'), new SingleFieldValueHolder(entity1.getID())),
|
||||
)!;
|
||||
expect(cachedValue).toMatchObject({
|
||||
status: CacheStatus.MISS,
|
||||
});
|
||||
|
||||
// a non existent db fetch should try to write negative result ('') but it's a noop cache, so it should be a miss
|
||||
const nonExistentId = uuidv4();
|
||||
|
||||
const entityNonExistentResult =
|
||||
await LocalMemoryTestEntity.loaderWithAuthorizationResults(viewerContext).loadByIDAsync(
|
||||
nonExistentId,
|
||||
);
|
||||
expect(entityNonExistentResult.ok).toBe(false);
|
||||
|
||||
const nonExistentCachedResult = await entitySpecificGenericCacher.loadManyAsync([
|
||||
cacheKeyMaker(new SingleFieldHolder('id'), new SingleFieldValueHolder(nonExistentId)),
|
||||
]);
|
||||
expect(
|
||||
nonExistentCachedResult.get(
|
||||
cacheKeyMaker(new SingleFieldHolder('id'), new SingleFieldValueHolder(nonExistentId)),
|
||||
),
|
||||
).toMatchObject({
|
||||
status: CacheStatus.MISS,
|
||||
});
|
||||
});
|
||||
});
|
||||
+170
@@ -0,0 +1,170 @@
|
||||
import {
|
||||
CacheStatus,
|
||||
EntityConfiguration,
|
||||
GenericEntityCacheAdapter,
|
||||
SingleFieldHolder,
|
||||
SingleFieldValueHolder,
|
||||
SingleFieldValueHolderMap,
|
||||
UUIDField,
|
||||
} from '@expo/entity';
|
||||
import { TTLCache } from '@isaacs/ttlcache';
|
||||
import { describe, expect, it } from '@jest/globals';
|
||||
|
||||
import type { ILocalMemoryCache, LocalMemoryCacheValue } from '../GenericLocalMemoryCacher.ts';
|
||||
import {
|
||||
DOES_NOT_EXIST_LOCAL_MEMORY_CACHE,
|
||||
GenericLocalMemoryCacher,
|
||||
} from '../GenericLocalMemoryCacher.ts';
|
||||
|
||||
type BlahFields = {
|
||||
id: string;
|
||||
};
|
||||
|
||||
const entityConfiguration = new EntityConfiguration<BlahFields, 'id'>({
|
||||
idField: 'id',
|
||||
tableName: 'blah',
|
||||
schema: {
|
||||
id: new UUIDField({ columnName: 'id', cache: true }),
|
||||
},
|
||||
databaseAdapterFlavor: 'postgres',
|
||||
cacheAdapterFlavor: 'local-memory',
|
||||
});
|
||||
|
||||
function createTTLCache<TFields extends Record<string, any>>(): ILocalMemoryCache<TFields> {
|
||||
return new TTLCache<string, LocalMemoryCacheValue<TFields>>({
|
||||
max: 10000,
|
||||
ttl: 10 * 1000, // convert to ms
|
||||
updateAgeOnGet: true,
|
||||
});
|
||||
}
|
||||
|
||||
describe('Use within GenericEntityCacheAdapter', () => {
|
||||
describe('loadManyAsync', () => {
|
||||
it('returns appropriate cache results', async () => {
|
||||
const cacheAdapter = new GenericEntityCacheAdapter(
|
||||
new GenericLocalMemoryCacher(entityConfiguration, createTTLCache()),
|
||||
);
|
||||
|
||||
const cacheHits = new Map<SingleFieldValueHolder<BlahFields, 'id'>, Readonly<BlahFields>>([
|
||||
[new SingleFieldValueHolder('test-id-1'), { id: 'test-id-1' }],
|
||||
]);
|
||||
await cacheAdapter.cacheManyAsync(
|
||||
new SingleFieldHolder<BlahFields, 'id', 'id'>('id'),
|
||||
cacheHits,
|
||||
);
|
||||
await cacheAdapter.cacheDBMissesAsync(new SingleFieldHolder('id'), [
|
||||
new SingleFieldValueHolder('test-id-2'),
|
||||
]);
|
||||
|
||||
const results = await cacheAdapter.loadManyAsync(new SingleFieldHolder('id'), [
|
||||
new SingleFieldValueHolder('test-id-1'),
|
||||
new SingleFieldValueHolder('test-id-2'),
|
||||
new SingleFieldValueHolder('test-id-3'),
|
||||
]);
|
||||
|
||||
expect(results.get(new SingleFieldValueHolder('test-id-1'))).toMatchObject({
|
||||
status: CacheStatus.HIT,
|
||||
item: { id: 'test-id-1' },
|
||||
});
|
||||
expect(results.get(new SingleFieldValueHolder('test-id-2'))).toMatchObject({
|
||||
status: CacheStatus.NEGATIVE,
|
||||
});
|
||||
expect(results.get(new SingleFieldValueHolder('test-id-3'))).toMatchObject({
|
||||
status: CacheStatus.MISS,
|
||||
});
|
||||
expect(results.size).toBe(3);
|
||||
});
|
||||
|
||||
it('returns empty map when passed empty array of values', async () => {
|
||||
const cacheAdapter = new GenericEntityCacheAdapter(
|
||||
new GenericLocalMemoryCacher(entityConfiguration, createTTLCache()),
|
||||
);
|
||||
const results = await cacheAdapter.loadManyAsync(
|
||||
new SingleFieldHolder<BlahFields, 'id', 'id'>('id'),
|
||||
[] as SingleFieldValueHolder<BlahFields, 'id'>[],
|
||||
);
|
||||
expect(results).toEqual(new SingleFieldValueHolderMap(new Map()));
|
||||
});
|
||||
});
|
||||
|
||||
describe('cacheManyAsync', () => {
|
||||
it('correctly caches all objects', async () => {
|
||||
const localMemoryCache = createTTLCache<BlahFields>();
|
||||
|
||||
const localMemoryCacher = new GenericLocalMemoryCacher(entityConfiguration, localMemoryCache);
|
||||
const cacheAdapter = new GenericEntityCacheAdapter(localMemoryCacher);
|
||||
await cacheAdapter.cacheManyAsync(
|
||||
new SingleFieldHolder('id'),
|
||||
new Map([[new SingleFieldValueHolder('test-id-1'), { id: 'test-id-1' }]]),
|
||||
);
|
||||
|
||||
const cacheKey = localMemoryCacher['makeCacheKeyForStorage'](
|
||||
new SingleFieldHolder('id'),
|
||||
new SingleFieldValueHolder('test-id-1'),
|
||||
);
|
||||
expect(localMemoryCache.get(cacheKey)).toMatchObject({
|
||||
id: 'test-id-1',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('cacheDBMissesAsync', () => {
|
||||
it('correctly caches misses', async () => {
|
||||
const localMemoryCache = createTTLCache<BlahFields>();
|
||||
|
||||
const localMemoryCacher = new GenericLocalMemoryCacher(entityConfiguration, localMemoryCache);
|
||||
const cacheAdapter = new GenericEntityCacheAdapter(localMemoryCacher);
|
||||
await cacheAdapter.cacheDBMissesAsync(new SingleFieldHolder('id'), [
|
||||
new SingleFieldValueHolder('test-id-1'),
|
||||
]);
|
||||
|
||||
const cacheKey = localMemoryCacher['makeCacheKeyForStorage'](
|
||||
new SingleFieldHolder('id'),
|
||||
new SingleFieldValueHolder('test-id-1'),
|
||||
);
|
||||
expect(localMemoryCache.get(cacheKey)).toEqual(DOES_NOT_EXIST_LOCAL_MEMORY_CACHE);
|
||||
});
|
||||
});
|
||||
|
||||
describe('invalidateManyAsync', () => {
|
||||
it('invalidates correctly', async () => {
|
||||
const localMemoryCache = createTTLCache<BlahFields>();
|
||||
|
||||
const cacheAdapter = new GenericEntityCacheAdapter(
|
||||
new GenericLocalMemoryCacher(entityConfiguration, localMemoryCache),
|
||||
);
|
||||
await cacheAdapter.cacheManyAsync(
|
||||
new SingleFieldHolder('id'),
|
||||
new Map([[new SingleFieldValueHolder('test-id-1'), { id: 'test-id-1' }]]),
|
||||
);
|
||||
await cacheAdapter.cacheDBMissesAsync(new SingleFieldHolder('id'), [
|
||||
new SingleFieldValueHolder('test-id-2'),
|
||||
]);
|
||||
await cacheAdapter.invalidateManyAsync(new SingleFieldHolder('id'), [
|
||||
new SingleFieldValueHolder('test-id-1'),
|
||||
new SingleFieldValueHolder('test-id-2'),
|
||||
]);
|
||||
|
||||
const results = await cacheAdapter.loadManyAsync(new SingleFieldHolder('id'), [
|
||||
new SingleFieldValueHolder('test-id-1'),
|
||||
new SingleFieldValueHolder('test-id-2'),
|
||||
]);
|
||||
expect(results.get(new SingleFieldValueHolder('test-id-1'))).toMatchObject({
|
||||
status: CacheStatus.MISS,
|
||||
});
|
||||
expect(results.get(new SingleFieldValueHolder('test-id-2'))).toMatchObject({
|
||||
status: CacheStatus.MISS,
|
||||
});
|
||||
});
|
||||
|
||||
it('returns when passed empty array of values', async () => {
|
||||
const cacheAdapter = new GenericEntityCacheAdapter(
|
||||
new GenericLocalMemoryCacher(entityConfiguration, createTTLCache<BlahFields>()),
|
||||
);
|
||||
await cacheAdapter.invalidateManyAsync(
|
||||
new SingleFieldHolder<BlahFields, 'id', 'id'>('id'),
|
||||
[] as SingleFieldValueHolder<BlahFields, 'id'>[],
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,8 @@
|
||||
/* eslint-disable tsdoc/syntax */
|
||||
/**
|
||||
* @packageDocumentation
|
||||
* @module @expo/entity-cache-adapter-local-memory
|
||||
*/
|
||||
|
||||
export * from './GenericLocalMemoryCacher.ts';
|
||||
export * from './LocalMemoryCacheAdapterProvider.ts';
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"include": ["src/**/*"],
|
||||
"references": [{ "path": "../entity" }, { "path": "../entity-testing-utils" }]
|
||||
}
|
||||
@@ -3,215 +3,337 @@
|
||||
All notable changes to this project will be documented in this file.
|
||||
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
|
||||
|
||||
# [0.22.0](https://github.com/expo/entity/compare/v0.21.0...v0.22.0) (2022-02-04)
|
||||
# [0.62.0](https://github.com/expo/entity/compare/v0.61.0...v0.62.0) (2026-03-16)
|
||||
|
||||
**Note:** Version bump only for package @expo/entity-cache-adapter-redis
|
||||
|
||||
# [0.61.0](https://github.com/expo/entity/compare/v0.60.0...v0.61.0) (2026-03-12)
|
||||
|
||||
**Note:** Version bump only for package @expo/entity-cache-adapter-redis
|
||||
|
||||
# [0.60.0](https://github.com/expo/entity/compare/v0.59.0...v0.60.0) (2026-03-06)
|
||||
|
||||
**Note:** Version bump only for package @expo/entity-cache-adapter-redis
|
||||
|
||||
# [0.59.0](https://github.com/expo/entity/compare/v0.58.0...v0.59.0) (2026-03-03)
|
||||
|
||||
**Note:** Version bump only for package @expo/entity-cache-adapter-redis
|
||||
|
||||
# [0.58.0](https://github.com/expo/entity/compare/v0.57.0...v0.58.0) (2026-03-02)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- escape all matching portions of parts of cacher cache keys ([#486](https://github.com/expo/entity/issues/486)) ([21f2f6f](https://github.com/expo/entity/commit/21f2f6f461ee771e8b1c1b22dbaf9b275094c0db))
|
||||
|
||||
# [0.57.0](https://github.com/expo/entity/compare/v0.56.0...v0.57.0) (2026-02-28)
|
||||
|
||||
**Note:** Version bump only for package @expo/entity-cache-adapter-redis
|
||||
|
||||
# [0.56.0](https://github.com/expo/entity/compare/v0.55.0...v0.56.0) (2026-02-28)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- eliminate some unnecessary any casts ([#448](https://github.com/expo/entity/issues/448)) ([cc78029](https://github.com/expo/entity/commit/cc78029117c14510b0934b5e0d19876a45570f8d))
|
||||
|
||||
# [0.55.0](https://github.com/expo/entity/compare/v0.54.0...v0.55.0) (2026-01-27)
|
||||
|
||||
**Note:** Version bump only for package @expo/entity-cache-adapter-redis
|
||||
|
||||
# [0.54.0](https://github.com/expo/entity/compare/v0.53.0...v0.54.0) (2026-01-21)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **deps:** pin dependencies ([#328](https://github.com/expo/entity/issues/328)) ([5679f27](https://github.com/expo/entity/commit/5679f27209f1515ca5e626d858c4aa054b414625))
|
||||
|
||||
# [0.53.0](https://github.com/expo/entity/compare/v0.52.0...v0.53.0) (2025-12-19)
|
||||
|
||||
**Note:** Version bump only for package @expo/entity-cache-adapter-redis
|
||||
|
||||
# [0.52.0](https://github.com/expo/entity/compare/v0.51.0...v0.52.0) (2025-12-19)
|
||||
|
||||
**Note:** Version bump only for package @expo/entity-cache-adapter-redis
|
||||
|
||||
# [0.51.0](https://github.com/expo/entity/compare/v0.50.0...v0.51.0) (2025-11-21)
|
||||
|
||||
**Note:** Version bump only for package @expo/entity-cache-adapter-redis
|
||||
|
||||
# [0.50.0](https://github.com/expo/entity/compare/v0.49.0...v0.50.0) (2025-10-07)
|
||||
|
||||
**Note:** Version bump only for package @expo/entity-cache-adapter-redis
|
||||
|
||||
# [0.49.0](https://github.com/expo/entity/compare/v0.48.0...v0.49.0) (2025-10-07)
|
||||
|
||||
**Note:** Version bump only for package @expo/entity-cache-adapter-redis
|
||||
|
||||
# [0.48.0](https://github.com/expo/entity/compare/v0.47.0...v0.48.0) (2025-09-19)
|
||||
|
||||
**Note:** Version bump only for package @expo/entity-cache-adapter-redis
|
||||
|
||||
# [0.47.0](https://github.com/expo/entity/compare/v0.46.0...v0.47.0) (2025-09-19)
|
||||
|
||||
### Features
|
||||
|
||||
- add support for Buffer fields ([#305](https://github.com/expo/entity/issues/305)) ([31b52a5](https://github.com/expo/entity/commit/31b52a5f75842f4810f66c12ad03a10d9192f889))
|
||||
|
||||
# [0.46.0](https://github.com/expo/entity/compare/v0.45.0...v0.46.0) (2025-06-26)
|
||||
|
||||
**Note:** Version bump only for package @expo/entity-cache-adapter-redis
|
||||
|
||||
# [0.45.0](https://github.com/expo/entity/compare/v0.44.0...v0.45.0) (2025-06-06)
|
||||
|
||||
### Features
|
||||
|
||||
- add ability to define a custom cache key invalidation version supplier ([#290](https://github.com/expo/entity/issues/290)) ([a3ab61b](https://github.com/expo/entity/commit/a3ab61bfa8d2eab3bbd9c703c5e483184bb67f13))
|
||||
|
||||
# [0.44.0](https://github.com/expo/entity/compare/v0.43.0...v0.44.0) (2025-05-29)
|
||||
|
||||
**Note:** Version bump only for package @expo/entity-cache-adapter-redis
|
||||
|
||||
# [0.43.0](https://github.com/expo/entity/compare/v0.42.0...v0.43.0) (2025-04-10)
|
||||
|
||||
**Note:** Version bump only for package @expo/entity-cache-adapter-redis
|
||||
|
||||
# [0.42.0](https://github.com/expo/entity/compare/v0.41.0...v0.42.0) (2025-04-10)
|
||||
|
||||
### Features
|
||||
|
||||
- add @expo/entity-testing-utils package ([#280](https://github.com/expo/entity/issues/280)) ([485894a](https://github.com/expo/entity/commit/485894af16e233d533a9480285bbda56812cbe0b))
|
||||
- add composite field loading and caching ([#272](https://github.com/expo/entity/issues/272)) ([f0aa0da](https://github.com/expo/entity/commit/f0aa0dafebdb56418cbd22dda437233d850db3e7))
|
||||
- change entity id field generic to field name and derive type where necessary ([#278](https://github.com/expo/entity/issues/278)) ([b7e524c](https://github.com/expo/entity/commit/b7e524c41892797608d0b884410b8d520a80a9ef))
|
||||
- convert batched/cached loader interface to holder pattern ([#271](https://github.com/expo/entity/issues/271)) ([06b3cb7](https://github.com/expo/entity/commit/06b3cb7f8076ef9e5b83cdf154da159801bd14cc)), closes [#201](https://github.com/expo/entity/issues/201)
|
||||
- invalidate n+/-1 cacheKeyVersion for entities for push safety ([#275](https://github.com/expo/entity/issues/275)) ([d9c1852](https://github.com/expo/entity/commit/d9c1852706f98f0761ff24ddd244e4ed2beca580))
|
||||
|
||||
# [0.41.0](https://github.com/expo/entity/compare/v0.40.0...v0.41.0) (2025-03-07)
|
||||
|
||||
- feat!: Default all loaders and mutators to enforcing (#256) ([a976e76](https://github.com/expo/entity/commit/a976e765098bca27049585e43a3bed5c71b9ac6b)), closes [#256](https://github.com/expo/entity/issues/256)
|
||||
|
||||
### BREAKING CHANGES
|
||||
|
||||
- Default all loaders and mutators to enforcing
|
||||
|
||||
# [0.40.0](https://github.com/expo/entity/compare/v0.39.0...v0.40.0) (2025-02-27)
|
||||
|
||||
### Reverts
|
||||
|
||||
- Revert "v0.40.0" ([fa78ce8](https://github.com/expo/entity/commit/fa78ce84e2f2eb8199e36fd51779fc68905001c0))
|
||||
|
||||
# [0.39.0](https://github.com/expo/entity/compare/v0.38.0...v0.39.0) (2024-11-21)
|
||||
|
||||
**Note:** Version bump only for package @expo/entity-cache-adapter-redis
|
||||
|
||||
# [0.38.0](https://github.com/expo/entity/compare/v0.37.0...v0.38.0) (2024-06-27)
|
||||
|
||||
**Note:** Version bump only for package @expo/entity-cache-adapter-redis
|
||||
|
||||
# [0.37.0](https://github.com/expo/entity/compare/v0.36.0...v0.37.0) (2024-06-12)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- update barrels ([#241](https://github.com/expo/entity/issues/241)) ([8c5f81b](https://github.com/expo/entity/commit/8c5f81bcca107e9b7bbea5f0ab41b0105057806e))
|
||||
|
||||
# [0.36.0](https://github.com/expo/entity/compare/v0.35.0...v0.36.0) (2024-06-12)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- constrain entity fields type to string-keyed object ([#235](https://github.com/expo/entity/issues/235)) ([7e2cea1](https://github.com/expo/entity/commit/7e2cea16973a0ae1917f867cd25d6ef7c8eaecef))
|
||||
|
||||
# [0.35.0](https://github.com/expo/entity/compare/v0.34.0...v0.35.0) (2024-04-11)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Revert: require explicit query context specification ([#219](https://github.com/expo/entity/issues/219)) ([1bdfbd6](https://github.com/expo/entity/commit/1bdfbd6b562d1200e4029df0533b5adb2d917831))
|
||||
|
||||
# [0.34.0](https://github.com/expo/entity/compare/v0.33.0...v0.34.0) (2024-04-11)
|
||||
|
||||
### Features
|
||||
|
||||
- require explicit query context specification ([#219](https://github.com/expo/entity/issues/219)) ([8b0b31f](https://github.com/expo/entity/commit/8b0b31fdde5bd565aa527719003ef283a45f55cc))
|
||||
|
||||
# [0.33.0](https://github.com/expo/entity/compare/v0.32.0...v0.33.0) (2023-10-06)
|
||||
|
||||
**Note:** Version bump only for package @expo/entity-cache-adapter-redis
|
||||
|
||||
# [0.32.0](https://github.com/expo/entity/compare/v0.31.1...v0.32.0) (2023-06-10)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- remove unused field from redis configuration ([#213](https://github.com/expo/entity/issues/213)) ([503a2fb](https://github.com/expo/entity/commit/503a2fb6a8d57f6a09f6da99daf5ab92fc3e7945))
|
||||
|
||||
### Features
|
||||
|
||||
- entity companion definition static thunk ([#210](https://github.com/expo/entity/issues/210)) ([4b18010](https://github.com/expo/entity/commit/4b18010d42be50ef329f428b08330e21bf676586))
|
||||
|
||||
# [0.31.0](https://github.com/expo/entity/compare/v0.30.0...v0.31.0) (2023-01-19)
|
||||
|
||||
**Note:** Version bump only for package @expo/entity-cache-adapter-redis
|
||||
|
||||
# [0.30.0](https://github.com/expo/entity/compare/v0.29.0...v0.30.0) (2022-12-23)
|
||||
|
||||
**Note:** Version bump only for package @expo/entity-cache-adapter-redis
|
||||
|
||||
# [0.29.0](https://github.com/expo/entity/compare/v0.28.0...v0.29.0) (2022-10-06)
|
||||
|
||||
### Features
|
||||
|
||||
- add example of redis mget batching using @expo/batcher ([#199](https://github.com/expo/entity/issues/199)) ([684f91b](https://github.com/expo/entity/commit/684f91b1f270851dbe331df2e2620664eea17266))
|
||||
- change redis cache adapter to use redis-like interface ([#200](https://github.com/expo/entity/issues/200)) ([e381f5b](https://github.com/expo/entity/commit/e381f5b2acfc17dcade2cf6e8506fa1c1c255e73))
|
||||
|
||||
# [0.28.0](https://github.com/expo/entity/compare/v0.27.0...v0.28.0) (2022-09-13)
|
||||
|
||||
**Note:** Version bump only for package @expo/entity-cache-adapter-redis
|
||||
|
||||
# [0.27.0](https://github.com/expo/entity/compare/v0.26.1...v0.27.0) (2022-08-09)
|
||||
|
||||
**Note:** Version bump only for package @expo/entity-cache-adapter-redis
|
||||
|
||||
## [0.26.1](https://github.com/expo/entity/compare/v0.26.0...v0.26.1) (2022-07-28)
|
||||
|
||||
**Note:** Version bump only for package @expo/entity-cache-adapter-redis
|
||||
|
||||
# [0.26.0](https://github.com/expo/entity/compare/v0.25.3...v0.26.0) (2022-07-14)
|
||||
|
||||
### Features
|
||||
|
||||
- update ioredis to v5, make it a devDependency of the cache packages ([#172](https://github.com/expo/entity/issues/172)) ([9fcfbf7](https://github.com/expo/entity/commit/9fcfbf746a61554cefedf7fb0f1a3a9056b58ffe))
|
||||
|
||||
## [0.25.3](https://github.com/expo/entity/compare/v0.25.2...v0.25.3) (2022-03-12)
|
||||
|
||||
**Note:** Version bump only for package @expo/entity-cache-adapter-redis
|
||||
|
||||
## [0.25.2](https://github.com/expo/entity/compare/v0.25.1...v0.25.2) (2022-03-12)
|
||||
|
||||
**Note:** Version bump only for package @expo/entity-cache-adapter-redis
|
||||
|
||||
## [0.25.1](https://github.com/expo/entity/compare/v0.25.0...v0.25.1) (2022-03-10)
|
||||
|
||||
**Note:** Version bump only for package @expo/entity-cache-adapter-redis
|
||||
|
||||
# [0.25.0](https://github.com/expo/entity/compare/v0.24.0...v0.25.0) (2022-03-10)
|
||||
|
||||
**Note:** Version bump only for package @expo/entity-cache-adapter-redis
|
||||
|
||||
# [0.24.0](https://github.com/expo/entity/compare/v0.23.0...v0.24.0) (2022-02-16)
|
||||
|
||||
### Chores
|
||||
|
||||
- Refactor cachers to use an abstract class and interface ([#157](https://github.com/expo/entity/pull/157))
|
||||
|
||||
# [0.23.0](https://github.com/expo/entity/compare/v0.22.0...v0.23.0) (2022-02-09)
|
||||
|
||||
### Chores
|
||||
|
||||
- Move data transforming responsibilities to the CacheAdapter instead of the ReadThroughEntityCache. ([#153](https://github.com/expo/entity/pull/153))
|
||||
|
||||
# [0.22.0](https://github.com/expo/entity/compare/v0.21.0...v0.22.0) (2022-02-04)
|
||||
|
||||
**Note:** Version bump only for package @expo/entity-cache-adapter-redis
|
||||
|
||||
# [0.21.0](https://github.com/expo/entity/compare/v0.20.0...v0.21.0) (2022-01-03)
|
||||
|
||||
**Note:** Version bump only for package @expo/entity-cache-adapter-redis
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
# [0.20.0](https://github.com/expo/entity/compare/v0.19.0...v0.20.0) (2021-12-29)
|
||||
|
||||
**Note:** Version bump only for package @expo/entity-cache-adapter-redis
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
# [0.19.0](https://github.com/expo/entity/compare/v0.18.0...v0.19.0) (2021-10-28)
|
||||
|
||||
**Note:** Version bump only for package @expo/entity-cache-adapter-redis
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
# [0.18.0](https://github.com/expo/entity/compare/v0.17.0...v0.18.0) (2021-10-14)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* enable noImplicitOverride tsc setting ([#135](https://github.com/expo/entity/issues/135)) ([4263cb9](https://github.com/expo/entity/commit/4263cb9b8b69fe4fb68c74ec1c7aba04508a1555))
|
||||
* upgrade TypeScript to 4.4 ([#134](https://github.com/expo/entity/issues/134)) ([7612392](https://github.com/expo/entity/commit/7612392dbfd9778d25c465c1626c372d75a6d05a))
|
||||
|
||||
|
||||
|
||||
|
||||
- enable noImplicitOverride tsc setting ([#135](https://github.com/expo/entity/issues/135)) ([4263cb9](https://github.com/expo/entity/commit/4263cb9b8b69fe4fb68c74ec1c7aba04508a1555))
|
||||
- upgrade TypeScript to 4.4 ([#134](https://github.com/expo/entity/issues/134)) ([7612392](https://github.com/expo/entity/commit/7612392dbfd9778d25c465c1626c372d75a6d05a))
|
||||
|
||||
# [0.17.0](https://github.com/expo/entity/compare/v0.16.0...v0.17.0) (2021-08-16)
|
||||
|
||||
**Note:** Version bump only for package @expo/entity-cache-adapter-redis
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
# [0.16.0](https://github.com/expo/entity/compare/v0.15.0...v0.16.0) (2021-07-07)
|
||||
|
||||
**Note:** Version bump only for package @expo/entity-cache-adapter-redis
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
# [0.15.0](https://github.com/expo/entity/compare/v0.14.1...v0.15.0) (2021-05-26)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* upgrade ioredis, knex, and pg ([#125](https://github.com/expo/entity/issues/125)) ([7c43edf](https://github.com/expo/entity/commit/7c43edf3de37e8af13e2a99bd44f03f54803d9b9))
|
||||
* use column name instead of field name for redis cache key ([#124](https://github.com/expo/entity/issues/124)) ([d78f452](https://github.com/expo/entity/commit/d78f452bb3ac5527069813a03f5b2264375bd8ab))
|
||||
|
||||
- upgrade ioredis, knex, and pg ([#125](https://github.com/expo/entity/issues/125)) ([7c43edf](https://github.com/expo/entity/commit/7c43edf3de37e8af13e2a99bd44f03f54803d9b9))
|
||||
- use column name instead of field name for redis cache key ([#124](https://github.com/expo/entity/issues/124)) ([d78f452](https://github.com/expo/entity/commit/d78f452bb3ac5527069813a03f5b2264375bd8ab))
|
||||
|
||||
### Features
|
||||
|
||||
* secondary cache loader ([#123](https://github.com/expo/entity/issues/123)) ([4cba01e](https://github.com/expo/entity/commit/4cba01eb259c87d60b3026ce776e46f781363690))
|
||||
|
||||
|
||||
|
||||
|
||||
- secondary cache loader ([#123](https://github.com/expo/entity/issues/123)) ([4cba01e](https://github.com/expo/entity/commit/4cba01eb259c87d60b3026ce776e46f781363690))
|
||||
|
||||
## [0.14.1](https://github.com/expo/entity/compare/v0.14.0...v0.14.1) (2021-03-24)
|
||||
|
||||
**Note:** Version bump only for package @expo/entity-cache-adapter-redis
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
# [0.14.0](https://github.com/expo/entity/compare/v0.13.0...v0.14.0) (2021-03-24)
|
||||
|
||||
**Note:** Version bump only for package @expo/entity-cache-adapter-redis
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
# [0.13.0](https://github.com/expo/entity/compare/v0.12.0...v0.13.0) (2021-02-12)
|
||||
|
||||
**Note:** Version bump only for package @expo/entity-cache-adapter-redis
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
# [0.12.0](https://github.com/expo/entity/compare/v0.11.0...v0.12.0) (2021-01-22)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* remove cache and database adaptor flavor enums ([#109](https://github.com/expo/entity/issues/109)) ([72a77f8](https://github.com/expo/entity/commit/72a77f8b6893a4b76d3b1cce16659bdf3ce473ee))
|
||||
|
||||
|
||||
|
||||
|
||||
- remove cache and database adaptor flavor enums ([#109](https://github.com/expo/entity/issues/109)) ([72a77f8](https://github.com/expo/entity/commit/72a77f8b6893a4b76d3b1cce16659bdf3ce473ee))
|
||||
|
||||
# [0.11.0](https://github.com/expo/entity/compare/v0.10.0...v0.11.0) (2020-12-24)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* better cache adapter error handling ([#102](https://github.com/expo/entity/issues/102)) ([15546aa](https://github.com/expo/entity/commit/15546aad97734ab9ac67f1c012485f096a0e94f1))
|
||||
|
||||
|
||||
|
||||
|
||||
- better cache adapter error handling ([#102](https://github.com/expo/entity/issues/102)) ([15546aa](https://github.com/expo/entity/commit/15546aad97734ab9ac67f1c012485f096a0e94f1))
|
||||
|
||||
# [0.10.0](https://github.com/expo/entity/compare/v0.9.1...v0.10.0) (2020-11-17)
|
||||
|
||||
**Note:** Version bump only for package @expo/entity-cache-adapter-redis
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
## [0.9.1](https://github.com/expo/entity/compare/v0.9.0...v0.9.1) (2020-11-06)
|
||||
|
||||
**Note:** Version bump only for package @expo/entity-cache-adapter-redis
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
# [0.9.0](https://github.com/expo/entity/compare/v0.8.1...v0.9.0) (2020-11-05)
|
||||
|
||||
**Note:** Version bump only for package @expo/entity-cache-adapter-redis
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
## [0.8.1](https://github.com/expo/entity/compare/v0.8.0...v0.8.1) (2020-10-09)
|
||||
|
||||
**Note:** Version bump only for package @expo/entity-cache-adapter-redis
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
# [0.8.0](https://github.com/expo/entity/compare/v0.7.1...v0.8.0) (2020-10-09)
|
||||
|
||||
**Note:** Version bump only for package @expo/entity-cache-adapter-redis
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
## [0.7.1](https://github.com/expo/entity/compare/v0.7.0...v0.7.1) (2020-10-01)
|
||||
|
||||
**Note:** Version bump only for package @expo/entity-cache-adapter-redis
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
# [0.7.0](https://github.com/expo/entity/compare/v0.6.0...v0.7.0) (2020-10-01)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* upgrade packages ([#78](https://github.com/expo/entity/issues/78)) ([9891e74](https://github.com/expo/entity/commit/9891e7469467a28589f529c8d87b10fc2232d3ff))
|
||||
|
||||
|
||||
|
||||
|
||||
- upgrade packages ([#78](https://github.com/expo/entity/issues/78)) ([9891e74](https://github.com/expo/entity/commit/9891e7469467a28589f529c8d87b10fc2232d3ff))
|
||||
|
||||
# [0.6.0](https://github.com/expo/entity/compare/v0.5.2...v0.6.0) (2020-07-23)
|
||||
|
||||
**Note:** Version bump only for package @expo/entity-cache-adapter-redis
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
## [0.5.2](https://github.com/expo/entity/compare/v0.5.1...v0.5.2) (2020-07-02)
|
||||
|
||||
**Note:** Version bump only for package @expo/entity-cache-adapter-redis
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
## [0.5.1](https://github.com/expo/entity/compare/v0.5.0...v0.5.1) (2020-07-01)
|
||||
|
||||
**Note:** Version bump only for package @expo/entity-cache-adapter-redis
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
# 0.5.0 (2020-07-01)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* support entity fields subset of db fields ([#49](https://github.com/expo/entity/issues/49)) ([4e40b2e](https://github.com/expo/entity/commit/4e40b2e521407e521d236978ec3b3b56db3990be))
|
||||
- support entity fields subset of db fields ([#49](https://github.com/expo/entity/issues/49)) ([4e40b2e](https://github.com/expo/entity/commit/4e40b2e521407e521d236978ec3b3b56db3990be))
|
||||
@@ -11,17 +11,16 @@ During `EntityCompanionProvider` instantiation:
|
||||
```typescript
|
||||
import Redis from 'ioredis';
|
||||
|
||||
const redisCacheAdapterContext = {
|
||||
const genericRedisCacherContext = {
|
||||
redisClient: new Redis(new URL(process.env['REDIS_URL']!).toString()),
|
||||
makeKeyFn(...parts: string[]): string {
|
||||
const delimiter = ':';
|
||||
const escapedParts = parts.map((part) =>
|
||||
part.replace('\\', '\\\\').replace(delimiter, `\\${delimiter}`)
|
||||
part.replaceAll('\\', '\\\\').replaceAll(delimiter, `\\${delimiter}`)
|
||||
);
|
||||
return escapedParts.join(delimiter);
|
||||
},
|
||||
cacheKeyPrefix: 'ent-',
|
||||
cacheKeyVersion: 1,
|
||||
ttlSecondsPositive: 86400, // 1 day
|
||||
ttlSecondsNegative: 600, // 10 minutes
|
||||
};
|
||||
@@ -36,7 +35,7 @@ export const createDefaultEntityCompanionProvider = (
|
||||
},
|
||||
{
|
||||
['redis']: {
|
||||
cacheAdapterProvider: new RedisCacheAdapterProvider(redisCacheAdapterContext),
|
||||
cacheAdapterProvider: new RedisCacheAdapterProvider(genericRedisCacheContext),
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
@@ -1,38 +1,45 @@
|
||||
{
|
||||
"name": "@expo/entity-cache-adapter-redis",
|
||||
"version": "0.22.0",
|
||||
"version": "0.63.0",
|
||||
"description": "Redis cache adapter for @expo/entity",
|
||||
"files": [
|
||||
"build",
|
||||
"!*.tsbuildinfo",
|
||||
"!__*",
|
||||
"src"
|
||||
],
|
||||
"main": "build/index.js",
|
||||
"types": "build/index.d.ts",
|
||||
"main": "build/src/index.js",
|
||||
"types": "build/src/index.d.ts",
|
||||
"scripts": {
|
||||
"tsc": "tsc",
|
||||
"clean": "rm -rf build coverage coverage-integration",
|
||||
"lint": "eslint src",
|
||||
"lint-fix": "eslint src --fix",
|
||||
"test": "jest --rootDir . --config ../../resources/jest.config.js",
|
||||
"integration": "../../resources/run-with-docker yarn integration-no-setup",
|
||||
"integration-no-setup": "jest --config ../../resources/jest-integration.config.js --rootDir . --runInBand --passWithNoTests",
|
||||
"barrelsby": "barrelsby --directory src --location top --exclude tests__ --singleQuotes --exportDefault --delete"
|
||||
"build": "tsc --build",
|
||||
"prepack": "rm -rf build && yarn build",
|
||||
"clean": "yarn build --clean",
|
||||
"lint": "yarn run --top-level eslint src",
|
||||
"lint-fix": "yarn lint --fix",
|
||||
"test": "yarn test:all --rootDir $(pwd)",
|
||||
"integration": "yarn integration:all --rootDir $(pwd)"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
"node": ">=18"
|
||||
},
|
||||
"keywords": [
|
||||
"entity"
|
||||
],
|
||||
"author": "Expo",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@expo/entity": "*"
|
||||
},
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
"ioredis": "^4.27.3"
|
||||
"@expo/entity": "workspace:^"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"ioredis": ">=5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@expo/entity": "^0.22.0"
|
||||
"@expo/batcher": "1.0.0",
|
||||
"@expo/entity-testing-utils": "workspace:^",
|
||||
"@jest/globals": "30.3.0",
|
||||
"ioredis": "5.10.1",
|
||||
"ts-mockito": "2.6.1",
|
||||
"typescript": "5.9.3"
|
||||
}
|
||||
}
|
||||
@@ -1,17 +1,101 @@
|
||||
import { CacheLoadResult, CacheStatus } from '@expo/entity';
|
||||
import { Redis } from 'ioredis';
|
||||
import type {
|
||||
CacheLoadResult,
|
||||
EntityConfiguration,
|
||||
IEntityGenericCacher,
|
||||
IEntityLoadKey,
|
||||
IEntityLoadValue,
|
||||
} from '@expo/entity';
|
||||
import {
|
||||
CacheStatus,
|
||||
transformCacheObjectToFields,
|
||||
transformFieldsToCacheObject,
|
||||
} from '@expo/entity';
|
||||
|
||||
import wrapNativeRedisCallAsync from './errors/wrapNativeRedisCallAsync';
|
||||
import { redisTransformerMap } from './RedisCommon.ts';
|
||||
import { wrapNativeRedisCallAsync } from './errors/wrapNativeRedisCallAsync.ts';
|
||||
import { getSurroundingCacheKeyVersionsForInvalidation } from './utils/getSurroundingCacheKeyVersionsForInvalidation.ts';
|
||||
|
||||
// Sentinel value we store in Redis to negatively cache a database miss.
|
||||
// The sentinel value is distinct from any (positively) cached value.
|
||||
const DOES_NOT_EXIST_REDIS = '';
|
||||
|
||||
export interface IRedisTransaction {
|
||||
set(key: string, value: string, secondsToken: 'EX', seconds: number): this;
|
||||
exec(): Promise<any>;
|
||||
}
|
||||
|
||||
export interface IRedis {
|
||||
mget(...args: [...keys: string[]]): Promise<(string | null)[]>;
|
||||
multi(): IRedisTransaction;
|
||||
del(...args: [...keys: string[]]): Promise<any>;
|
||||
}
|
||||
|
||||
/**
|
||||
* The strategy for generating the set of cache keys to invalidate in the Redis cache after entity mutation.
|
||||
*/
|
||||
export enum RedisCacheInvalidationStrategy {
|
||||
/**
|
||||
* Invalidate just the cache key(s) for the current cacheKeyVersion of the entity.
|
||||
*/
|
||||
CURRENT_CACHE_KEY_VERSION = 'current-cache-key-version',
|
||||
|
||||
/**
|
||||
* Invalidate the cache key(s) for the current cacheKeyVersion and the surrounding cache key versions
|
||||
* (e.g. `1`, `2` and `3` if the current version is `2`). This can be useful for deployment safety, where
|
||||
* some machines may be operating on an old version of the code and thus an old cacheKeyVersion, and some the new version.
|
||||
* This strategy generates cache keys for both old and potential future new versions.
|
||||
*/
|
||||
SURROUNDING_CACHE_KEY_VERSIONS = 'surrounding-cache-key-versions',
|
||||
|
||||
/**
|
||||
* Invalidate cache keys based on user-specified function from the current cacheKeyVersion to a list of cache key
|
||||
* versions to invalidate.
|
||||
*/
|
||||
CUSTOM = 'custom',
|
||||
}
|
||||
|
||||
export type GenericRedisCacheInvalidationConfig =
|
||||
| {
|
||||
/**
|
||||
* Invalidation strategy for the cache.
|
||||
*/
|
||||
invalidationStrategy: RedisCacheInvalidationStrategy.CURRENT_CACHE_KEY_VERSION;
|
||||
}
|
||||
| {
|
||||
/**
|
||||
* Invalidation strategy for the cache.
|
||||
*/
|
||||
invalidationStrategy: RedisCacheInvalidationStrategy.SURROUNDING_CACHE_KEY_VERSIONS;
|
||||
}
|
||||
| {
|
||||
/**
|
||||
* Invalidation strategy for the cache.
|
||||
*/
|
||||
invalidationStrategy: RedisCacheInvalidationStrategy.CUSTOM;
|
||||
|
||||
/**
|
||||
* Function that takes the current cache key version and returns the cache key versions to invalidate.
|
||||
*/
|
||||
cacheKeyVersionsToInvalidateFn: (cacheKeyVersion: number) => readonly number[];
|
||||
};
|
||||
|
||||
export interface GenericRedisCacheContext {
|
||||
/**
|
||||
* Instance of ioredis.Redis
|
||||
*/
|
||||
redisClient: Redis;
|
||||
redisClient: IRedis;
|
||||
|
||||
/**
|
||||
* Create a key string for key parts (cache key prefix, versions, entity name, etc).
|
||||
* Most commonly a simple `parts.join(':')`. See integration test for example.
|
||||
*/
|
||||
makeKeyFn: (...parts: string[]) => string;
|
||||
|
||||
/**
|
||||
* Prefix prepended to all entity cache keys. Useful for adding a short, human-readable
|
||||
* distintion for entity keys, e.g. `ent-`
|
||||
*/
|
||||
cacheKeyPrefix: string;
|
||||
|
||||
/**
|
||||
* TTL for caching database hits. Successive entity loads within this TTL
|
||||
@@ -24,23 +108,34 @@ export interface GenericRedisCacheContext {
|
||||
* this TTL will be assumed not present in the database (unless invalidated).
|
||||
*/
|
||||
ttlSecondsNegative: number;
|
||||
|
||||
/**
|
||||
* Configuration for cache invalidation strategy.
|
||||
*/
|
||||
invalidationConfig: GenericRedisCacheInvalidationConfig;
|
||||
}
|
||||
|
||||
export default class GenericRedisCacher {
|
||||
constructor(private readonly context: GenericRedisCacheContext) {}
|
||||
export class GenericRedisCacher<
|
||||
TFields extends Record<string, any>,
|
||||
TIDField extends keyof TFields,
|
||||
> implements IEntityGenericCacher<TFields, TIDField> {
|
||||
constructor(
|
||||
private readonly context: GenericRedisCacheContext,
|
||||
private readonly entityConfiguration: EntityConfiguration<TFields, TIDField>,
|
||||
) {}
|
||||
|
||||
public async loadManyAsync(
|
||||
keys: readonly string[]
|
||||
): Promise<ReadonlyMap<string, CacheLoadResult>> {
|
||||
keys: readonly string[],
|
||||
): Promise<ReadonlyMap<string, CacheLoadResult<TFields>>> {
|
||||
if (keys.length === 0) {
|
||||
return new Map();
|
||||
}
|
||||
|
||||
const redisResults = await wrapNativeRedisCallAsync(() =>
|
||||
this.context.redisClient.mget(...keys)
|
||||
this.context.redisClient.mget(...keys),
|
||||
);
|
||||
|
||||
const results = new Map<string, CacheLoadResult>();
|
||||
const results = new Map<string, CacheLoadResult<TFields>>();
|
||||
for (let i = 0; i < keys.length; i++) {
|
||||
const key = keys[i]!;
|
||||
const redisResult = redisResults[i];
|
||||
@@ -52,7 +147,11 @@ export default class GenericRedisCacher {
|
||||
} else if (redisResult) {
|
||||
results.set(key, {
|
||||
status: CacheStatus.HIT,
|
||||
item: JSON.parse(redisResult),
|
||||
item: transformCacheObjectToFields(
|
||||
this.entityConfiguration,
|
||||
redisTransformerMap,
|
||||
JSON.parse(redisResult),
|
||||
),
|
||||
});
|
||||
} else {
|
||||
results.set(key, {
|
||||
@@ -63,7 +162,7 @@ export default class GenericRedisCacher {
|
||||
return results;
|
||||
}
|
||||
|
||||
public async cacheManyAsync(objectMap: ReadonlyMap<string, object>): Promise<void> {
|
||||
public async cacheManyAsync(objectMap: ReadonlyMap<string, Readonly<TFields>>): Promise<void> {
|
||||
if (objectMap.size === 0) {
|
||||
return;
|
||||
}
|
||||
@@ -72,15 +171,17 @@ export default class GenericRedisCacher {
|
||||
objectMap.forEach((object, key) => {
|
||||
redisTransaction = redisTransaction.set(
|
||||
key,
|
||||
JSON.stringify(object),
|
||||
JSON.stringify(
|
||||
transformFieldsToCacheObject(this.entityConfiguration, redisTransformerMap, object),
|
||||
),
|
||||
'EX',
|
||||
this.context.ttlSecondsPositive
|
||||
this.context.ttlSecondsPositive,
|
||||
);
|
||||
});
|
||||
await wrapNativeRedisCallAsync(() => redisTransaction.exec());
|
||||
}
|
||||
|
||||
public async cacheDBMissesAsync(keys: string[]): Promise<void> {
|
||||
public async cacheDBMissesAsync(keys: readonly string[]): Promise<void> {
|
||||
if (keys.length === 0) {
|
||||
return;
|
||||
}
|
||||
@@ -91,17 +192,70 @@ export default class GenericRedisCacher {
|
||||
key,
|
||||
DOES_NOT_EXIST_REDIS,
|
||||
'EX',
|
||||
this.context.ttlSecondsNegative
|
||||
this.context.ttlSecondsNegative,
|
||||
);
|
||||
});
|
||||
await wrapNativeRedisCallAsync(() => redisTransaction.exec());
|
||||
}
|
||||
|
||||
public async invalidateManyAsync(keys: string[]): Promise<void> {
|
||||
public async invalidateManyAsync(keys: readonly string[]): Promise<void> {
|
||||
if (keys.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
await wrapNativeRedisCallAsync(() => this.context.redisClient.del(...keys));
|
||||
}
|
||||
|
||||
private makeCacheKeyForCacheKeyVersion<
|
||||
TLoadKey extends IEntityLoadKey<TFields, TIDField, TSerializedLoadValue, TLoadValue>,
|
||||
TSerializedLoadValue,
|
||||
TLoadValue extends IEntityLoadValue<TSerializedLoadValue>,
|
||||
>(key: TLoadKey, value: TLoadValue, cacheKeyVersion: number): string {
|
||||
const cacheKeyType = key.getLoadMethodType();
|
||||
const parts = key.createCacheKeyPartsForLoadValue(this.entityConfiguration, value);
|
||||
return this.context.makeKeyFn(
|
||||
this.context.cacheKeyPrefix,
|
||||
cacheKeyType,
|
||||
this.entityConfiguration.tableName,
|
||||
`v3.${cacheKeyVersion}`,
|
||||
...parts,
|
||||
);
|
||||
}
|
||||
|
||||
public makeCacheKeyForStorage<
|
||||
TLoadKey extends IEntityLoadKey<TFields, TIDField, TSerializedLoadValue, TLoadValue>,
|
||||
TSerializedLoadValue,
|
||||
TLoadValue extends IEntityLoadValue<TSerializedLoadValue>,
|
||||
>(key: TLoadKey, value: TLoadValue): string {
|
||||
return this.makeCacheKeyForCacheKeyVersion(
|
||||
key,
|
||||
value,
|
||||
this.entityConfiguration.cacheKeyVersion,
|
||||
);
|
||||
}
|
||||
|
||||
public makeCacheKeysForInvalidation<
|
||||
TLoadKey extends IEntityLoadKey<TFields, TIDField, TSerializedLoadValue, TLoadValue>,
|
||||
TSerializedLoadValue,
|
||||
TLoadValue extends IEntityLoadValue<TSerializedLoadValue>,
|
||||
>(key: TLoadKey, value: TLoadValue): readonly string[] {
|
||||
switch (this.context.invalidationConfig.invalidationStrategy) {
|
||||
case RedisCacheInvalidationStrategy.CURRENT_CACHE_KEY_VERSION:
|
||||
return [
|
||||
this.makeCacheKeyForCacheKeyVersion(key, value, this.entityConfiguration.cacheKeyVersion),
|
||||
];
|
||||
case RedisCacheInvalidationStrategy.SURROUNDING_CACHE_KEY_VERSIONS:
|
||||
return getSurroundingCacheKeyVersionsForInvalidation(
|
||||
this.entityConfiguration.cacheKeyVersion,
|
||||
).map((cacheKeyVersion) =>
|
||||
this.makeCacheKeyForCacheKeyVersion(key, value, cacheKeyVersion),
|
||||
);
|
||||
case RedisCacheInvalidationStrategy.CUSTOM:
|
||||
return this.context.invalidationConfig
|
||||
.cacheKeyVersionsToInvalidateFn(this.entityConfiguration.cacheKeyVersion)
|
||||
.map((cacheKeyVersion) =>
|
||||
this.makeCacheKeyForCacheKeyVersion(key, value, cacheKeyVersion),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,133 +0,0 @@
|
||||
import {
|
||||
EntityCacheAdapter,
|
||||
EntityConfiguration,
|
||||
CacheLoadResult,
|
||||
FieldTransformerMap,
|
||||
mapKeys,
|
||||
} from '@expo/entity';
|
||||
import invariant from 'invariant';
|
||||
import { Redis } from 'ioredis';
|
||||
|
||||
import GenericRedisCacher from './GenericRedisCacher';
|
||||
import { redisTransformerMap } from './RedisCommon';
|
||||
|
||||
export interface RedisCacheAdapterContext {
|
||||
/**
|
||||
* Instance of ioredis.Redis
|
||||
*/
|
||||
redisClient: Redis;
|
||||
|
||||
/**
|
||||
* Create a key string for key parts (cache key prefix, versions, entity name, etc).
|
||||
* Most commonly a simple `parts.join(':')`. See integration test for example.
|
||||
*/
|
||||
makeKeyFn: (...parts: string[]) => string;
|
||||
|
||||
/**
|
||||
* Global cache version for the entity framework. Bumping this version will
|
||||
* invalidate the cache for all entities at once.
|
||||
*/
|
||||
cacheKeyVersion: number;
|
||||
|
||||
/**
|
||||
* Prefix prepended to all entity cache keys. Useful for adding a short, human-readable
|
||||
* distintion for entity keys, e.g. `ent-`
|
||||
*/
|
||||
cacheKeyPrefix: string;
|
||||
|
||||
/**
|
||||
* TTL for caching database hits. Successive entity loads within this TTL
|
||||
* will be read from cache (unless invalidated).
|
||||
*/
|
||||
ttlSecondsPositive: number;
|
||||
|
||||
/**
|
||||
* TTL for negatively caching database misses. Successive entity loads within
|
||||
* this TTL will be assumed not present in the database (unless invalidated).
|
||||
*/
|
||||
ttlSecondsNegative: number;
|
||||
}
|
||||
|
||||
export default class RedisCacheAdapter<TFields> extends EntityCacheAdapter<TFields> {
|
||||
private readonly genericRedisCacher: GenericRedisCacher;
|
||||
constructor(
|
||||
entityConfiguration: EntityConfiguration<TFields>,
|
||||
private readonly context: RedisCacheAdapterContext
|
||||
) {
|
||||
super(entityConfiguration);
|
||||
|
||||
this.genericRedisCacher = new GenericRedisCacher({
|
||||
redisClient: context.redisClient,
|
||||
ttlSecondsNegative: context.ttlSecondsNegative,
|
||||
ttlSecondsPositive: context.ttlSecondsPositive,
|
||||
});
|
||||
}
|
||||
|
||||
public getFieldTransformerMap(): FieldTransformerMap {
|
||||
return redisTransformerMap;
|
||||
}
|
||||
|
||||
public async loadManyAsync<N extends keyof TFields>(
|
||||
fieldName: N,
|
||||
fieldValues: readonly NonNullable<TFields[N]>[]
|
||||
): Promise<ReadonlyMap<NonNullable<TFields[N]>, CacheLoadResult>> {
|
||||
const redisCacheKeyToFieldValueMapping = new Map(
|
||||
fieldValues.map((fieldValue) => [this.makeCacheKey(fieldName, fieldValue), fieldValue])
|
||||
);
|
||||
const cacheResults = await this.genericRedisCacher.loadManyAsync(
|
||||
Array.from(redisCacheKeyToFieldValueMapping.keys())
|
||||
);
|
||||
|
||||
return mapKeys(cacheResults, (redisCacheKey) => {
|
||||
const fieldValue = redisCacheKeyToFieldValueMapping.get(redisCacheKey);
|
||||
invariant(
|
||||
fieldValue !== undefined,
|
||||
'Unspecified cache key %s returned from generic Redis cacher',
|
||||
redisCacheKey
|
||||
);
|
||||
return fieldValue;
|
||||
});
|
||||
}
|
||||
|
||||
public async cacheManyAsync<N extends keyof TFields>(
|
||||
fieldName: N,
|
||||
objectMap: ReadonlyMap<NonNullable<TFields[N]>, object>
|
||||
): Promise<void> {
|
||||
await this.genericRedisCacher.cacheManyAsync(
|
||||
mapKeys(objectMap, (fieldValue) => this.makeCacheKey(fieldName, fieldValue))
|
||||
);
|
||||
}
|
||||
|
||||
public async cacheDBMissesAsync<N extends keyof TFields>(
|
||||
fieldName: N,
|
||||
fieldValues: readonly NonNullable<TFields[N]>[]
|
||||
): Promise<void> {
|
||||
await this.genericRedisCacher.cacheDBMissesAsync(
|
||||
fieldValues.map((fieldValue) => this.makeCacheKey(fieldName, fieldValue))
|
||||
);
|
||||
}
|
||||
|
||||
public async invalidateManyAsync<N extends keyof TFields>(
|
||||
fieldName: N,
|
||||
fieldValues: readonly NonNullable<TFields[N]>[]
|
||||
): Promise<void> {
|
||||
await this.genericRedisCacher.invalidateManyAsync(
|
||||
fieldValues.map((fieldValue) => this.makeCacheKey(fieldName, fieldValue))
|
||||
);
|
||||
}
|
||||
|
||||
private makeCacheKey<N extends keyof TFields>(
|
||||
fieldName: N,
|
||||
fieldValue: NonNullable<TFields[N]>
|
||||
): string {
|
||||
const columnName = this.entityConfiguration.entityToDBFieldsKeyMapping.get(fieldName);
|
||||
invariant(columnName, `database field mapping missing for ${fieldName}`);
|
||||
return this.context.makeKeyFn(
|
||||
this.context.cacheKeyPrefix,
|
||||
this.entityConfiguration.tableName,
|
||||
`v2.${this.entityConfiguration.cacheKeyVersion}`,
|
||||
columnName,
|
||||
String(fieldValue)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,13 +1,19 @@
|
||||
import { IEntityCacheAdapterProvider, EntityConfiguration, EntityCacheAdapter } from '@expo/entity';
|
||||
import type {
|
||||
EntityConfiguration,
|
||||
IEntityCacheAdapter,
|
||||
IEntityCacheAdapterProvider,
|
||||
} from '@expo/entity';
|
||||
import { GenericEntityCacheAdapter } from '@expo/entity';
|
||||
|
||||
import RedisCacheAdapter, { RedisCacheAdapterContext } from './RedisCacheAdapter';
|
||||
import type { GenericRedisCacheContext } from './GenericRedisCacher.ts';
|
||||
import { GenericRedisCacher } from './GenericRedisCacher.ts';
|
||||
|
||||
export default class RedisCacheAdapterProvider implements IEntityCacheAdapterProvider {
|
||||
constructor(private readonly context: RedisCacheAdapterContext) {}
|
||||
export class RedisCacheAdapterProvider implements IEntityCacheAdapterProvider {
|
||||
constructor(private readonly context: GenericRedisCacheContext) {}
|
||||
|
||||
getCacheAdapter<TFields>(
|
||||
entityConfiguration: EntityConfiguration<TFields>
|
||||
): EntityCacheAdapter<TFields> {
|
||||
return new RedisCacheAdapter(entityConfiguration, this.context);
|
||||
getCacheAdapter<TFields extends Record<string, any>, TIDField extends keyof TFields>(
|
||||
entityConfiguration: EntityConfiguration<TFields, TIDField>,
|
||||
): IEntityCacheAdapter<TFields, TIDField> {
|
||||
return new GenericEntityCacheAdapter(new GenericRedisCacher(this.context, entityConfiguration));
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { DateField } from '@expo/entity';
|
||||
import { BufferField, DateField } from '@expo/entity';
|
||||
|
||||
export const redisTransformerMap = new Map([
|
||||
[
|
||||
@@ -22,4 +22,25 @@ export const redisTransformerMap = new Map([
|
||||
read: (val: any) => (val ? new Date(val) : val),
|
||||
},
|
||||
],
|
||||
[
|
||||
BufferField.name,
|
||||
{
|
||||
/**
|
||||
* All Redis fields are serialized to JSON before being written.
|
||||
* In the case of buffers, they need to be explicitly reconstructed
|
||||
* as JavaScript Buffer objects upon retrieval. We also explicitly
|
||||
* serialize them to base64 strings to ensure no underlying Redis
|
||||
* changes affect the fields.
|
||||
*
|
||||
* Write behavior: Value to write will always be either a Buffer or null.
|
||||
* Behavior is to pass through null and convert buffers
|
||||
* to base64 strings.
|
||||
* Read behavior: Value will always be either null or a base64 string.
|
||||
* behavior is to convert base64 strings back into Buffer
|
||||
* objects and pass through null.
|
||||
*/
|
||||
write: (val: Buffer) => (val ? val.toString('base64') : null),
|
||||
read: (val: any) => (val ? Buffer.from(val, 'base64') : val),
|
||||
},
|
||||
],
|
||||
]);
|
||||
+186
@@ -0,0 +1,186 @@
|
||||
import { Batcher } from '@expo/batcher';
|
||||
import type { GenericEntityCacheAdapter } from '@expo/entity';
|
||||
import { SingleFieldHolder, SingleFieldValueHolder, ViewerContext, zipToMap } from '@expo/entity';
|
||||
import nullthrows from '@expo/nullthrows';
|
||||
import { afterAll, beforeAll, beforeEach, describe, expect, it, jest } from '@jest/globals';
|
||||
import invariant from 'invariant';
|
||||
import { Redis } from 'ioredis';
|
||||
import { URL } from 'url';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
|
||||
import type { GenericRedisCacheContext, IRedis, IRedisTransaction } from '../GenericRedisCacher.ts';
|
||||
import { GenericRedisCacher, RedisCacheInvalidationStrategy } from '../GenericRedisCacher.ts';
|
||||
import type { RedisTestEntityFields } from '../__testfixtures__/RedisTestEntity.ts';
|
||||
import { RedisTestEntity } from '../__testfixtures__/RedisTestEntity.ts';
|
||||
import { createRedisIntegrationTestEntityCompanionProvider } from '../__testfixtures__/createRedisIntegrationTestEntityCompanionProvider.ts';
|
||||
|
||||
class BatchedRedis implements IRedis {
|
||||
private readonly mgetBatcher = new Batcher<string[], (string | null)[]>(
|
||||
this.batchMgetAsync.bind(this),
|
||||
{
|
||||
maxBatchInterval: 0,
|
||||
},
|
||||
);
|
||||
|
||||
constructor(private readonly redis: Redis) {}
|
||||
|
||||
private async batchMgetAsync(keySets: string[][]): Promise<(string | null)[][]> {
|
||||
// ordered distinct keys to fetch
|
||||
const allKeysToFetch = [...new Set(keySets.flat())];
|
||||
|
||||
// fetch the distinct keys
|
||||
const allResults = await this.redis.mget(...allKeysToFetch);
|
||||
|
||||
// put them into a map for fast lookup
|
||||
const keysToResults = zipToMap(allKeysToFetch, allResults);
|
||||
|
||||
// re-associate them with original key sets
|
||||
return keySets.map((keySet) =>
|
||||
keySet.map((key) => {
|
||||
const result = keysToResults.get(key);
|
||||
invariant(result !== undefined, 'result should not be undefined');
|
||||
return result;
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
async mget(...args: string[]): Promise<(string | null)[]> {
|
||||
return await this.mgetBatcher.batchAsync(args);
|
||||
}
|
||||
|
||||
multi(): IRedisTransaction {
|
||||
return this.redis.multi();
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
async del(...args: string[]): Promise<void> {
|
||||
await this.redis.del(...args);
|
||||
}
|
||||
}
|
||||
|
||||
describe(GenericRedisCacher, () => {
|
||||
const redis = new Redis(new URL(process.env['REDIS_URL']!).toString());
|
||||
const redisClient = new BatchedRedis(redis);
|
||||
|
||||
let genericRedisCacheContext: GenericRedisCacheContext;
|
||||
|
||||
beforeAll(() => {
|
||||
genericRedisCacheContext = {
|
||||
redisClient,
|
||||
makeKeyFn(...parts: string[]): string {
|
||||
const delimiter = ':';
|
||||
const escapedParts = parts.map((part) =>
|
||||
part.replaceAll('\\', '\\\\').replaceAll(delimiter, `\\${delimiter}`),
|
||||
);
|
||||
return escapedParts.join(delimiter);
|
||||
},
|
||||
cacheKeyPrefix: 'test-',
|
||||
ttlSecondsPositive: 86400, // 1 day
|
||||
ttlSecondsNegative: 600, // 10 minutes
|
||||
invalidationConfig: {
|
||||
invalidationStrategy: RedisCacheInvalidationStrategy.CURRENT_CACHE_KEY_VERSION,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await redis.flushdb();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
redis.disconnect();
|
||||
});
|
||||
|
||||
it('has correct caching behavior', async () => {
|
||||
// simulate two requests
|
||||
const viewerContext = new ViewerContext(
|
||||
createRedisIntegrationTestEntityCompanionProvider(genericRedisCacheContext),
|
||||
);
|
||||
|
||||
const mgetSpy = jest.spyOn(redis, 'mget');
|
||||
|
||||
const genericCacher = (
|
||||
viewerContext.entityCompanionProvider.getCompanionForEntity(RedisTestEntity)[
|
||||
'tableDataCoordinator'
|
||||
]['cacheAdapter'] as GenericEntityCacheAdapter<RedisTestEntityFields, 'id'>
|
||||
)['genericCacher'];
|
||||
|
||||
const entity1Created = await RedisTestEntity.creator(viewerContext)
|
||||
.setField('name', 'blah')
|
||||
.createAsync();
|
||||
|
||||
// loading an entity should put it in cache. load by multiple requests and multiple fields in same tick to ensure batch works
|
||||
mgetSpy.mockClear();
|
||||
const viewerContext1 = new ViewerContext(
|
||||
createRedisIntegrationTestEntityCompanionProvider(genericRedisCacheContext),
|
||||
);
|
||||
const viewerContext2 = new ViewerContext(
|
||||
createRedisIntegrationTestEntityCompanionProvider(genericRedisCacheContext),
|
||||
);
|
||||
const viewerContext3 = new ViewerContext(
|
||||
createRedisIntegrationTestEntityCompanionProvider(genericRedisCacheContext),
|
||||
);
|
||||
const [entity1, entity2, entity3] = await Promise.all([
|
||||
RedisTestEntity.loader(viewerContext1).loadByIDAsync(entity1Created.getID()),
|
||||
RedisTestEntity.loader(viewerContext2).loadByIDAsync(entity1Created.getID()),
|
||||
RedisTestEntity.loader(viewerContext3).loadByFieldEqualingAsync(
|
||||
'name',
|
||||
entity1Created.getField('name'),
|
||||
),
|
||||
]);
|
||||
|
||||
expect(mgetSpy).toHaveBeenCalledTimes(1);
|
||||
expect(mgetSpy.mock.calls[0]).toHaveLength(2); // should dedupe the first two loads
|
||||
expect(entity1.getID()).toEqual(entity2.getID());
|
||||
expect(entity2.getID()).toEqual(nullthrows(entity3).getID());
|
||||
|
||||
const cacheKeyEntity1 = genericCacher.makeCacheKeyForStorage(
|
||||
new SingleFieldHolder('id'),
|
||||
new SingleFieldValueHolder(entity1Created.getID()),
|
||||
);
|
||||
const cachedJSON = await redis.get(cacheKeyEntity1);
|
||||
const cachedValue = JSON.parse(cachedJSON!);
|
||||
expect(cachedValue).toMatchObject({
|
||||
id: entity1.getID(),
|
||||
name: 'blah',
|
||||
});
|
||||
|
||||
const cacheKeyEntity1NameField = genericCacher.makeCacheKeyForStorage(
|
||||
new SingleFieldHolder('name'),
|
||||
new SingleFieldValueHolder(entity1Created.getField('name')),
|
||||
);
|
||||
await RedisTestEntity.loader(viewerContext).loadByFieldEqualingAsync(
|
||||
'name',
|
||||
entity1Created.getField('name'),
|
||||
);
|
||||
await expect(redis.get(cacheKeyEntity1NameField)).resolves.toEqual(cachedJSON);
|
||||
|
||||
// simulate non existent db fetch, should write negative result ('') to cache
|
||||
const nonExistentId = uuidv4();
|
||||
const entityNonExistentResult =
|
||||
await RedisTestEntity.loaderWithAuthorizationResults(viewerContext).loadByIDAsync(
|
||||
nonExistentId,
|
||||
);
|
||||
expect(entityNonExistentResult.ok).toBe(false);
|
||||
const cacheKeyNonExistent = genericCacher.makeCacheKeyForStorage(
|
||||
new SingleFieldHolder('id'),
|
||||
new SingleFieldValueHolder(nonExistentId),
|
||||
);
|
||||
const nonExistentCachedValue = await redis.get(cacheKeyNonExistent);
|
||||
expect(nonExistentCachedValue).toEqual('');
|
||||
// load again through entities framework to ensure it reads negative result
|
||||
const entityNonExistentResult2 =
|
||||
await RedisTestEntity.loaderWithAuthorizationResults(viewerContext).loadByIDAsync(
|
||||
nonExistentId,
|
||||
);
|
||||
expect(entityNonExistentResult2.ok).toBe(false);
|
||||
|
||||
// invalidate from cache to ensure it invalidates correctly in both caches
|
||||
await RedisTestEntity.invalidationUtils(viewerContext).invalidateFieldsAsync(
|
||||
entity1.getAllFields(),
|
||||
);
|
||||
await expect(redis.get(cacheKeyEntity1)).resolves.toBeNull();
|
||||
await expect(redis.get(cacheKeyEntity1NameField)).resolves.toBeNull();
|
||||
});
|
||||
});
|
||||
+234
@@ -0,0 +1,234 @@
|
||||
import type { GenericEntityCacheAdapter } from '@expo/entity';
|
||||
import {
|
||||
CompositeFieldHolder,
|
||||
CompositeFieldValueHolder,
|
||||
SingleFieldHolder,
|
||||
SingleFieldValueHolder,
|
||||
ViewerContext,
|
||||
} from '@expo/entity';
|
||||
import { enforceAsyncResult } from '@expo/results';
|
||||
import { afterAll, beforeAll, beforeEach, describe, expect, it } from '@jest/globals';
|
||||
import { Redis } from 'ioredis';
|
||||
import { URL } from 'url';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
|
||||
import type { GenericRedisCacheContext } from '../GenericRedisCacher.ts';
|
||||
import { GenericRedisCacher, RedisCacheInvalidationStrategy } from '../GenericRedisCacher.ts';
|
||||
import type { RedisTestEntityFields } from '../__testfixtures__/RedisTestEntity.ts';
|
||||
import { RedisTestEntity } from '../__testfixtures__/RedisTestEntity.ts';
|
||||
import { createRedisIntegrationTestEntityCompanionProvider } from '../__testfixtures__/createRedisIntegrationTestEntityCompanionProvider.ts';
|
||||
|
||||
class TestViewerContext extends ViewerContext {}
|
||||
|
||||
describe(GenericRedisCacher, () => {
|
||||
let genericRedisCacheContext: GenericRedisCacheContext;
|
||||
|
||||
beforeAll(() => {
|
||||
genericRedisCacheContext = {
|
||||
redisClient: new Redis(new URL(process.env['REDIS_URL']!).toString()),
|
||||
makeKeyFn(...parts: string[]): string {
|
||||
const delimiter = ':';
|
||||
const escapedParts = parts.map((part) =>
|
||||
part.replaceAll('\\', '\\\\').replaceAll(delimiter, `\\${delimiter}`),
|
||||
);
|
||||
return escapedParts.join(delimiter);
|
||||
},
|
||||
cacheKeyPrefix: 'test-',
|
||||
ttlSecondsPositive: 86400, // 1 day
|
||||
ttlSecondsNegative: 600, // 10 minutes
|
||||
invalidationConfig: {
|
||||
invalidationStrategy: RedisCacheInvalidationStrategy.CURRENT_CACHE_KEY_VERSION,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await (genericRedisCacheContext.redisClient as Redis).flushdb();
|
||||
});
|
||||
afterAll(async () => {
|
||||
(genericRedisCacheContext.redisClient as Redis).disconnect();
|
||||
});
|
||||
|
||||
it('has correct caching behavior', async () => {
|
||||
const viewerContext = new TestViewerContext(
|
||||
createRedisIntegrationTestEntityCompanionProvider(genericRedisCacheContext),
|
||||
);
|
||||
const genericCacher = (
|
||||
viewerContext.entityCompanionProvider.getCompanionForEntity(RedisTestEntity)[
|
||||
'tableDataCoordinator'
|
||||
]['cacheAdapter'] as GenericEntityCacheAdapter<RedisTestEntityFields, 'id'>
|
||||
)['genericCacher'];
|
||||
|
||||
const entity1Created = await RedisTestEntity.creator(viewerContext)
|
||||
.setField('name', 'blah')
|
||||
.setField('dateField', new Date())
|
||||
.setField('bufferField', Buffer.from('hello world'))
|
||||
.createAsync();
|
||||
|
||||
// loading an entity should put it in cache
|
||||
const entity1 = await RedisTestEntity.loader(viewerContext).loadByIDAsync(
|
||||
entity1Created.getID(),
|
||||
);
|
||||
|
||||
const cachedSingleJSON = await (genericRedisCacheContext.redisClient as Redis).get(
|
||||
genericCacher.makeCacheKeyForStorage(
|
||||
new SingleFieldHolder('id'),
|
||||
new SingleFieldValueHolder(entity1.getID()),
|
||||
),
|
||||
);
|
||||
const cachedSingleValue = JSON.parse(cachedSingleJSON!);
|
||||
expect(cachedSingleValue).toMatchObject({
|
||||
id: entity1.getID(),
|
||||
name: 'blah',
|
||||
});
|
||||
|
||||
// loading an entity by composite field should put it in cache
|
||||
const entity2 = await RedisTestEntity.loader(viewerContext).loadByCompositeFieldEqualingAsync(
|
||||
['name', 'id'],
|
||||
{ name: 'blah', id: entity1.getID() },
|
||||
);
|
||||
const cachedCompositeJSON = await (genericRedisCacheContext.redisClient as Redis).get(
|
||||
genericCacher.makeCacheKeyForStorage(
|
||||
new CompositeFieldHolder<RedisTestEntityFields, 'id'>(['id', 'name']),
|
||||
new CompositeFieldValueHolder({ id: entity2!.getID(), name: 'blah' }),
|
||||
),
|
||||
);
|
||||
const cachedCompositeValue = JSON.parse(cachedCompositeJSON!);
|
||||
expect(cachedCompositeValue).toMatchObject({
|
||||
id: entity2!.getID(),
|
||||
name: 'blah',
|
||||
});
|
||||
|
||||
// simulate non existent db fetch, should write negative result ('') to cache
|
||||
const nonExistentId = uuidv4();
|
||||
|
||||
const entityNonExistentResult =
|
||||
await RedisTestEntity.loaderWithAuthorizationResults(viewerContext).loadByIDAsync(
|
||||
nonExistentId,
|
||||
);
|
||||
expect(entityNonExistentResult.ok).toBe(false);
|
||||
const nonExistentCachedValue = await (genericRedisCacheContext.redisClient as Redis).get(
|
||||
genericCacher.makeCacheKeyForStorage(
|
||||
new SingleFieldHolder('id'),
|
||||
new SingleFieldValueHolder(nonExistentId),
|
||||
),
|
||||
);
|
||||
expect(nonExistentCachedValue).toEqual('');
|
||||
|
||||
const entityNonExistentCompositeResult = await RedisTestEntity.loader(
|
||||
viewerContext,
|
||||
).loadByCompositeFieldEqualingAsync(['name', 'id'], { name: 'blah', id: nonExistentId });
|
||||
expect(entityNonExistentCompositeResult).toBe(null);
|
||||
const nonExistentCompositeCachedValue = await (
|
||||
genericRedisCacheContext.redisClient as Redis
|
||||
).get(
|
||||
genericCacher.makeCacheKeyForStorage(
|
||||
new CompositeFieldHolder<RedisTestEntityFields, 'id'>(['id', 'name']),
|
||||
new CompositeFieldValueHolder({ id: nonExistentId, name: 'blah' }),
|
||||
),
|
||||
);
|
||||
expect(nonExistentCompositeCachedValue).toEqual('');
|
||||
|
||||
// load again through entities framework to ensure it reads negative result
|
||||
const entityNonExistentResult2 =
|
||||
await RedisTestEntity.loaderWithAuthorizationResults(viewerContext).loadByIDAsync(
|
||||
nonExistentId,
|
||||
);
|
||||
expect(entityNonExistentResult2.ok).toBe(false);
|
||||
const entityNonExistentCompositeResult2 = await RedisTestEntity.loader(
|
||||
viewerContext,
|
||||
).loadByCompositeFieldEqualingAsync(['name', 'id'], { name: 'blah', id: nonExistentId });
|
||||
expect(entityNonExistentCompositeResult2).toBe(null);
|
||||
|
||||
// invalidate from cache to ensure it invalidates correctly
|
||||
await RedisTestEntity.invalidationUtils(viewerContext).invalidateFieldsAsync(
|
||||
entity1.getAllFields(),
|
||||
);
|
||||
const cachedValueNullKeys = genericCacher.makeCacheKeysForInvalidation(
|
||||
new SingleFieldHolder('id'),
|
||||
new SingleFieldValueHolder(entity1.getID()),
|
||||
);
|
||||
const cachedValueNull = await (genericRedisCacheContext.redisClient as Redis).mget(
|
||||
...cachedValueNullKeys,
|
||||
);
|
||||
expect(cachedValueNull.every((c) => c === null)).toBe(true);
|
||||
|
||||
const cachedValueNullCompositeKeys = genericCacher.makeCacheKeysForInvalidation(
|
||||
new CompositeFieldHolder<RedisTestEntityFields, 'id'>(['id', 'name']),
|
||||
new CompositeFieldValueHolder({ id: entity1.getID(), name: 'blah' }),
|
||||
);
|
||||
const cachedValueNullComposite = await (genericRedisCacheContext.redisClient as Redis).mget(
|
||||
...cachedValueNullCompositeKeys,
|
||||
);
|
||||
expect(cachedValueNullComposite.every((c) => c === null)).toBe(true);
|
||||
});
|
||||
|
||||
it('caches and restores date fields', async () => {
|
||||
const viewerContext = new TestViewerContext(
|
||||
createRedisIntegrationTestEntityCompanionProvider(genericRedisCacheContext),
|
||||
);
|
||||
const date = new Date();
|
||||
const entity1 = await enforceAsyncResult(
|
||||
RedisTestEntity.creatorWithAuthorizationResults(viewerContext)
|
||||
.setField('dateField', date)
|
||||
.createAsync(),
|
||||
);
|
||||
expect(entity1.getField('dateField')).toEqual(date);
|
||||
|
||||
const entity2 = await RedisTestEntity.loader(viewerContext).loadByIDAsync(entity1.getID());
|
||||
expect(entity2.getField('dateField')).toEqual(date);
|
||||
|
||||
// simulate new request
|
||||
const vc2 = new TestViewerContext(
|
||||
createRedisIntegrationTestEntityCompanionProvider(genericRedisCacheContext),
|
||||
);
|
||||
const entity3 = await RedisTestEntity.loader(vc2).loadByIDAsync(entity1.getID());
|
||||
expect(entity3.getField('dateField')).toEqual(date);
|
||||
});
|
||||
|
||||
it('caches and restores empty string field keys', async () => {
|
||||
const viewerContext = new TestViewerContext(
|
||||
createRedisIntegrationTestEntityCompanionProvider(genericRedisCacheContext),
|
||||
);
|
||||
const entity1 = await enforceAsyncResult(
|
||||
RedisTestEntity.creatorWithAuthorizationResults(viewerContext)
|
||||
.setField('name', '')
|
||||
.createAsync(),
|
||||
);
|
||||
const entity2 = await RedisTestEntity.loader(viewerContext).loadByFieldEqualingAsync(
|
||||
'name',
|
||||
'',
|
||||
);
|
||||
expect(entity2?.getID()).toEqual(entity1.getID());
|
||||
|
||||
// simulate new request
|
||||
const vc2 = new TestViewerContext(
|
||||
createRedisIntegrationTestEntityCompanionProvider(genericRedisCacheContext),
|
||||
);
|
||||
const entity3 = await RedisTestEntity.loader(vc2).loadByFieldEqualingAsync('name', '');
|
||||
expect(entity3?.getID()).toEqual(entity1.getID());
|
||||
});
|
||||
|
||||
it('caches and restores buffer fields', async () => {
|
||||
const viewerContext = new TestViewerContext(
|
||||
createRedisIntegrationTestEntityCompanionProvider(genericRedisCacheContext),
|
||||
);
|
||||
const buffer = Buffer.from('hello world');
|
||||
const entity1 = await enforceAsyncResult(
|
||||
RedisTestEntity.creatorWithAuthorizationResults(viewerContext)
|
||||
.setField('bufferField', buffer)
|
||||
.createAsync(),
|
||||
);
|
||||
expect(entity1.getField('bufferField')).toEqual(buffer);
|
||||
|
||||
const entity2 = await RedisTestEntity.loader(viewerContext).loadByIDAsync(entity1.getID());
|
||||
expect(entity2.getField('bufferField')).toEqual(buffer);
|
||||
|
||||
// simulate new request
|
||||
const vc2 = new TestViewerContext(
|
||||
createRedisIntegrationTestEntityCompanionProvider(genericRedisCacheContext),
|
||||
);
|
||||
const entity3 = await RedisTestEntity.loader(vc2).loadByIDAsync(entity1.getID());
|
||||
expect(entity3.getField('bufferField')).toEqual(buffer);
|
||||
});
|
||||
});
|
||||
+123
@@ -0,0 +1,123 @@
|
||||
import { CacheStatus, ViewerContext } from '@expo/entity';
|
||||
import { afterAll, beforeAll, beforeEach, describe, expect, it } from '@jest/globals';
|
||||
import { Redis } from 'ioredis';
|
||||
import { URL } from 'url';
|
||||
|
||||
import type { GenericRedisCacheContext } from '../GenericRedisCacher.ts';
|
||||
import { GenericRedisCacher, RedisCacheInvalidationStrategy } from '../GenericRedisCacher.ts';
|
||||
import type { RedisTestEntityFields } from '../__testfixtures__/RedisTestEntity.ts';
|
||||
import {
|
||||
RedisTestEntity,
|
||||
redisTestEntityConfiguration,
|
||||
} from '../__testfixtures__/RedisTestEntity.ts';
|
||||
import { createRedisIntegrationTestEntityCompanionProvider } from '../__testfixtures__/createRedisIntegrationTestEntityCompanionProvider.ts';
|
||||
|
||||
class TestViewerContext extends ViewerContext {}
|
||||
|
||||
describe(GenericRedisCacher, () => {
|
||||
let genericRedisCacheContext: GenericRedisCacheContext;
|
||||
|
||||
beforeAll(() => {
|
||||
genericRedisCacheContext = {
|
||||
redisClient: new Redis(new URL(process.env['REDIS_URL']!).toString()),
|
||||
makeKeyFn(...parts: string[]): string {
|
||||
const delimiter = ':';
|
||||
const escapedParts = parts.map((part) =>
|
||||
part.replaceAll('\\', '\\\\').replaceAll(delimiter, `\\${delimiter}`),
|
||||
);
|
||||
return escapedParts.join(delimiter);
|
||||
},
|
||||
cacheKeyPrefix: 'test-',
|
||||
ttlSecondsPositive: 86400, // 1 day
|
||||
ttlSecondsNegative: 600, // 10 minutes
|
||||
invalidationConfig: {
|
||||
invalidationStrategy: RedisCacheInvalidationStrategy.CURRENT_CACHE_KEY_VERSION,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await (genericRedisCacheContext.redisClient as Redis).flushdb();
|
||||
});
|
||||
afterAll(async () => {
|
||||
(genericRedisCacheContext.redisClient as Redis).disconnect();
|
||||
});
|
||||
|
||||
it('has correct caching and loading behavior', async () => {
|
||||
const viewerContext = new TestViewerContext(
|
||||
createRedisIntegrationTestEntityCompanionProvider(genericRedisCacheContext),
|
||||
);
|
||||
const genericRedisCacher = new GenericRedisCacher(
|
||||
genericRedisCacheContext,
|
||||
redisTestEntityConfiguration,
|
||||
);
|
||||
const date = new Date();
|
||||
const buffer = Buffer.from('hello world');
|
||||
const entity1Created = await RedisTestEntity.creator(viewerContext)
|
||||
.setField('name', 'blah')
|
||||
.setField('dateField', date)
|
||||
.setField('bufferField', buffer)
|
||||
.createAsync();
|
||||
const testKey = `test-id-key-${entity1Created.getID()}`;
|
||||
const objectMap = new Map<string, Readonly<RedisTestEntityFields>>([
|
||||
[testKey, entity1Created.getAllFields()],
|
||||
]);
|
||||
await genericRedisCacher.cacheManyAsync(objectMap);
|
||||
|
||||
const cachedJSON = await (genericRedisCacheContext.redisClient as Redis).get(testKey);
|
||||
const cachedValue = JSON.parse(cachedJSON!);
|
||||
expect(cachedValue).toMatchObject({
|
||||
id: entity1Created.getID(),
|
||||
dateField: date.toISOString(),
|
||||
});
|
||||
|
||||
const loadedObjectMap = await genericRedisCacher.loadManyAsync([testKey]);
|
||||
const cacheLoadResult = loadedObjectMap.get(testKey)!;
|
||||
expect(cacheLoadResult).toMatchObject({
|
||||
status: CacheStatus.HIT,
|
||||
item: entity1Created.getAllFields(),
|
||||
});
|
||||
expect(loadedObjectMap.size).toBe(1);
|
||||
});
|
||||
|
||||
it('has correct negative caching behaviour', async () => {
|
||||
const genericRedisCacher = new GenericRedisCacher(
|
||||
genericRedisCacheContext,
|
||||
redisTestEntityConfiguration,
|
||||
);
|
||||
|
||||
const testKey = `test-id-key-non-existent-id`;
|
||||
await genericRedisCacher.cacheDBMissesAsync([testKey]);
|
||||
const loadedObjectMap = await genericRedisCacher.loadManyAsync([testKey]);
|
||||
const cacheLoadResult = loadedObjectMap.get(testKey)!;
|
||||
expect(cacheLoadResult.status).toBe(CacheStatus.NEGATIVE);
|
||||
});
|
||||
|
||||
it('has correct invalidation behaviour', async () => {
|
||||
const viewerContext = new TestViewerContext(
|
||||
createRedisIntegrationTestEntityCompanionProvider(genericRedisCacheContext),
|
||||
);
|
||||
const genericRedisCacher = new GenericRedisCacher(
|
||||
genericRedisCacheContext,
|
||||
redisTestEntityConfiguration,
|
||||
);
|
||||
const date = new Date();
|
||||
const buffer = Buffer.from('hello world');
|
||||
const entity1Created = await RedisTestEntity.creator(viewerContext)
|
||||
.setField('name', 'blah')
|
||||
.setField('dateField', date)
|
||||
.setField('bufferField', buffer)
|
||||
.createAsync();
|
||||
const testKey = `test-id-key-${entity1Created.getID()}`;
|
||||
const objectMap = new Map<string, Readonly<RedisTestEntityFields>>([
|
||||
[testKey, entity1Created.getAllFields()],
|
||||
]);
|
||||
await genericRedisCacher.cacheManyAsync(objectMap);
|
||||
|
||||
await genericRedisCacher.invalidateManyAsync([testKey]);
|
||||
|
||||
const loadedObjectMap = await genericRedisCacher.loadManyAsync([testKey]);
|
||||
const cacheLoadResult = loadedObjectMap.get(testKey)!;
|
||||
expect(cacheLoadResult.status).toBe(CacheStatus.MISS);
|
||||
});
|
||||
});
|
||||
-139
@@ -1,139 +0,0 @@
|
||||
import { ViewerContext } from '@expo/entity';
|
||||
import { enforceAsyncResult } from '@expo/results';
|
||||
import Redis from 'ioredis';
|
||||
import { URL } from 'url';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
|
||||
import RedisCacheAdapter, { RedisCacheAdapterContext } from '../RedisCacheAdapter';
|
||||
import RedisTestEntity from '../testfixtures/RedisTestEntity';
|
||||
import { createRedisIntegrationTestEntityCompanionProvider } from '../testfixtures/createRedisIntegrationTestEntityCompanionProvider';
|
||||
|
||||
class TestViewerContext extends ViewerContext {}
|
||||
|
||||
describe(RedisCacheAdapter, () => {
|
||||
let redisCacheAdapterContext: RedisCacheAdapterContext;
|
||||
|
||||
beforeAll(() => {
|
||||
redisCacheAdapterContext = {
|
||||
redisClient: new Redis(new URL(process.env['REDIS_URL']!).toString()),
|
||||
makeKeyFn(...parts: string[]): string {
|
||||
const delimiter = ':';
|
||||
const escapedParts = parts.map((part) =>
|
||||
part.replace('\\', '\\\\').replace(delimiter, `\\${delimiter}`)
|
||||
);
|
||||
return escapedParts.join(delimiter);
|
||||
},
|
||||
cacheKeyPrefix: 'test-',
|
||||
cacheKeyVersion: 1,
|
||||
ttlSecondsPositive: 86400, // 1 day
|
||||
ttlSecondsNegative: 600, // 10 minutes
|
||||
};
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await redisCacheAdapterContext.redisClient.flushdb();
|
||||
});
|
||||
afterAll(async () => {
|
||||
redisCacheAdapterContext.redisClient.disconnect();
|
||||
});
|
||||
|
||||
it('has correct caching behavior', async () => {
|
||||
const viewerContext = new TestViewerContext(
|
||||
createRedisIntegrationTestEntityCompanionProvider(redisCacheAdapterContext)
|
||||
);
|
||||
const cacheAdapter = viewerContext.entityCompanionProvider.getCompanionForEntity(
|
||||
RedisTestEntity,
|
||||
RedisTestEntity.getCompanionDefinition()
|
||||
)['tableDataCoordinator']['cacheAdapter'];
|
||||
const cacheKeyMaker = cacheAdapter['makeCacheKey'].bind(cacheAdapter);
|
||||
|
||||
const entity1Created = await RedisTestEntity.creator(viewerContext)
|
||||
.setField('name', 'blah')
|
||||
.enforceCreateAsync();
|
||||
|
||||
// loading an entity should put it in cache
|
||||
const entity1 = await RedisTestEntity.loader(viewerContext)
|
||||
.enforcing()
|
||||
.loadByIDAsync(entity1Created.getID());
|
||||
|
||||
const cachedJSON = await redisCacheAdapterContext.redisClient.get(
|
||||
cacheKeyMaker('id', entity1.getID())
|
||||
);
|
||||
const cachedValue = JSON.parse(cachedJSON!);
|
||||
expect(cachedValue).toMatchObject({
|
||||
id: entity1.getID(),
|
||||
name: 'blah',
|
||||
});
|
||||
|
||||
// simulate non existent db fetch, should write negative result ('') to cache
|
||||
const nonExistentId = uuidv4();
|
||||
|
||||
const entityNonExistentResult = await RedisTestEntity.loader(viewerContext).loadByIDAsync(
|
||||
nonExistentId
|
||||
);
|
||||
expect(entityNonExistentResult.ok).toBe(false);
|
||||
|
||||
const nonExistentCachedValue = await redisCacheAdapterContext.redisClient.get(
|
||||
cacheKeyMaker('id', nonExistentId)
|
||||
);
|
||||
expect(nonExistentCachedValue).toEqual('');
|
||||
|
||||
// load again through entities framework to ensure it reads negative result
|
||||
const entityNonExistentResult2 = await RedisTestEntity.loader(viewerContext).loadByIDAsync(
|
||||
nonExistentId
|
||||
);
|
||||
expect(entityNonExistentResult2.ok).toBe(false);
|
||||
|
||||
// invalidate from cache to ensure it invalidates correctly
|
||||
await RedisTestEntity.loader(viewerContext).invalidateFieldsAsync(entity1.getAllFields());
|
||||
const cachedValueNull = await redisCacheAdapterContext.redisClient.get(
|
||||
cacheKeyMaker('id', entity1.getID())
|
||||
);
|
||||
expect(cachedValueNull).toBe(null);
|
||||
});
|
||||
|
||||
it('caches and restores date fields', async () => {
|
||||
const viewerContext = new TestViewerContext(
|
||||
createRedisIntegrationTestEntityCompanionProvider(redisCacheAdapterContext)
|
||||
);
|
||||
const date = new Date();
|
||||
const entity1 = await enforceAsyncResult(
|
||||
RedisTestEntity.creator(viewerContext).setField('dateField', date).createAsync()
|
||||
);
|
||||
expect(entity1.getField('dateField')).toEqual(date);
|
||||
|
||||
const entity2 = await RedisTestEntity.loader(viewerContext)
|
||||
.enforcing()
|
||||
.loadByIDAsync(entity1.getID());
|
||||
expect(entity2.getField('dateField')).toEqual(date);
|
||||
|
||||
// simulate new request
|
||||
const vc2 = new TestViewerContext(
|
||||
createRedisIntegrationTestEntityCompanionProvider(redisCacheAdapterContext)
|
||||
);
|
||||
const entity3 = await RedisTestEntity.loader(vc2).enforcing().loadByIDAsync(entity1.getID());
|
||||
expect(entity3.getField('dateField')).toEqual(date);
|
||||
});
|
||||
|
||||
it('caches and restores empty string field keys', async () => {
|
||||
const viewerContext = new TestViewerContext(
|
||||
createRedisIntegrationTestEntityCompanionProvider(redisCacheAdapterContext)
|
||||
);
|
||||
const entity1 = await enforceAsyncResult(
|
||||
RedisTestEntity.creator(viewerContext).setField('name', '').createAsync()
|
||||
);
|
||||
const entity2 = await RedisTestEntity.loader(viewerContext)
|
||||
.enforcing()
|
||||
.loadByFieldEqualingAsync('name', '');
|
||||
expect(entity2?.getID()).toEqual(entity1.getID());
|
||||
|
||||
// simulate new request
|
||||
const vc2 = new TestViewerContext(
|
||||
createRedisIntegrationTestEntityCompanionProvider(redisCacheAdapterContext)
|
||||
);
|
||||
const entity3 = await RedisTestEntity.loader(vc2)
|
||||
.enforcing()
|
||||
.loadByFieldEqualingAsync('name', '');
|
||||
expect(entity3?.getID()).toEqual(entity1.getID());
|
||||
});
|
||||
});
|
||||
@@ -1,46 +1,51 @@
|
||||
import { EntityCacheAdapterTransientError, ViewerContext } from '@expo/entity';
|
||||
import Redis from 'ioredis';
|
||||
import { beforeAll, beforeEach, describe, expect, it } from '@jest/globals';
|
||||
import { Redis } from 'ioredis';
|
||||
import { URL } from 'url';
|
||||
|
||||
import RedisCacheAdapter, { RedisCacheAdapterContext } from '../RedisCacheAdapter';
|
||||
import RedisTestEntity from '../testfixtures/RedisTestEntity';
|
||||
import { createRedisIntegrationTestEntityCompanionProvider } from '../testfixtures/createRedisIntegrationTestEntityCompanionProvider';
|
||||
import type { GenericRedisCacheContext } from '../GenericRedisCacher.ts';
|
||||
import { GenericRedisCacher, RedisCacheInvalidationStrategy } from '../GenericRedisCacher.ts';
|
||||
import { RedisTestEntity } from '../__testfixtures__/RedisTestEntity.ts';
|
||||
import { createRedisIntegrationTestEntityCompanionProvider } from '../__testfixtures__/createRedisIntegrationTestEntityCompanionProvider.ts';
|
||||
|
||||
class TestViewerContext extends ViewerContext {}
|
||||
|
||||
describe(RedisCacheAdapter, () => {
|
||||
let redisCacheAdapterContext: RedisCacheAdapterContext;
|
||||
describe(GenericRedisCacher, () => {
|
||||
const redisClient = new Redis(new URL(process.env['REDIS_URL']!).toString());
|
||||
let genericRedisCacheContext: GenericRedisCacheContext;
|
||||
|
||||
beforeAll(() => {
|
||||
redisCacheAdapterContext = {
|
||||
redisClient: new Redis(new URL(process.env['REDIS_URL']!).toString()),
|
||||
genericRedisCacheContext = {
|
||||
redisClient,
|
||||
makeKeyFn(...parts: string[]): string {
|
||||
const delimiter = ':';
|
||||
const escapedParts = parts.map((part) =>
|
||||
part.replace('\\', '\\\\').replace(delimiter, `\\${delimiter}`)
|
||||
part.replaceAll('\\', '\\\\').replaceAll(delimiter, `\\${delimiter}`),
|
||||
);
|
||||
return escapedParts.join(delimiter);
|
||||
},
|
||||
cacheKeyPrefix: 'test-',
|
||||
cacheKeyVersion: 1,
|
||||
ttlSecondsPositive: 86400, // 1 day
|
||||
ttlSecondsNegative: 600, // 10 minutes
|
||||
invalidationConfig: {
|
||||
invalidationStrategy: RedisCacheInvalidationStrategy.CURRENT_CACHE_KEY_VERSION,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await redisCacheAdapterContext.redisClient.flushdb();
|
||||
await redisClient.flushdb();
|
||||
});
|
||||
|
||||
it('throws when redis is disconnected', async () => {
|
||||
redisCacheAdapterContext.redisClient.disconnect();
|
||||
redisClient.disconnect();
|
||||
|
||||
const vc1 = new TestViewerContext(
|
||||
createRedisIntegrationTestEntityCompanionProvider(redisCacheAdapterContext)
|
||||
createRedisIntegrationTestEntityCompanionProvider(genericRedisCacheContext),
|
||||
);
|
||||
|
||||
await expect(
|
||||
RedisTestEntity.creator(vc1).setField('name', 'blah').enforceCreateAsync()
|
||||
RedisTestEntity.creator(vc1).setField('name', 'blah').createAsync(),
|
||||
).rejects.toThrow(EntityCacheAdapterTransientError);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,83 @@
|
||||
import type { EntityCompanionDefinition, ViewerContext } from '@expo/entity';
|
||||
import {
|
||||
AlwaysAllowPrivacyPolicyRule,
|
||||
BufferField,
|
||||
DateField,
|
||||
Entity,
|
||||
EntityConfiguration,
|
||||
EntityPrivacyPolicy,
|
||||
StringField,
|
||||
UUIDField,
|
||||
} from '@expo/entity';
|
||||
|
||||
export type RedisTestEntityFields = {
|
||||
id: string;
|
||||
name: string;
|
||||
dateField: Date | null;
|
||||
bufferField: Buffer | null;
|
||||
};
|
||||
|
||||
export class RedisTestEntity extends Entity<RedisTestEntityFields, 'id', ViewerContext> {
|
||||
static defineCompanionDefinition(): EntityCompanionDefinition<
|
||||
RedisTestEntityFields,
|
||||
'id',
|
||||
ViewerContext,
|
||||
RedisTestEntity,
|
||||
RedisTestEntityPrivacyPolicy
|
||||
> {
|
||||
return {
|
||||
entityClass: RedisTestEntity,
|
||||
entityConfiguration: redisTestEntityConfiguration,
|
||||
privacyPolicyClass: RedisTestEntityPrivacyPolicy,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export class RedisTestEntityPrivacyPolicy extends EntityPrivacyPolicy<
|
||||
RedisTestEntityFields,
|
||||
'id',
|
||||
ViewerContext,
|
||||
RedisTestEntity
|
||||
> {
|
||||
protected override readonly createRules = [
|
||||
new AlwaysAllowPrivacyPolicyRule<RedisTestEntityFields, 'id', ViewerContext, RedisTestEntity>(),
|
||||
];
|
||||
protected override readonly readRules = [
|
||||
new AlwaysAllowPrivacyPolicyRule<RedisTestEntityFields, 'id', ViewerContext, RedisTestEntity>(),
|
||||
];
|
||||
protected override readonly updateRules = [
|
||||
new AlwaysAllowPrivacyPolicyRule<RedisTestEntityFields, 'id', ViewerContext, RedisTestEntity>(),
|
||||
];
|
||||
protected override readonly deleteRules = [
|
||||
new AlwaysAllowPrivacyPolicyRule<RedisTestEntityFields, 'id', ViewerContext, RedisTestEntity>(),
|
||||
];
|
||||
}
|
||||
|
||||
export const redisTestEntityConfiguration = new EntityConfiguration<RedisTestEntityFields, 'id'>({
|
||||
idField: 'id',
|
||||
tableName: 'redis_test_entities',
|
||||
schema: {
|
||||
id: new UUIDField({
|
||||
columnName: 'id',
|
||||
cache: true,
|
||||
}),
|
||||
name: new StringField({
|
||||
columnName: 'name',
|
||||
cache: true,
|
||||
}),
|
||||
dateField: new DateField({
|
||||
columnName: 'date_field',
|
||||
}),
|
||||
bufferField: new BufferField({
|
||||
columnName: 'buffer_field',
|
||||
cache: true,
|
||||
}),
|
||||
},
|
||||
databaseAdapterFlavor: 'postgres',
|
||||
cacheAdapterFlavor: 'redis',
|
||||
compositeFieldDefinitions: [
|
||||
{ compositeField: ['id', 'name'], cache: true },
|
||||
{ compositeField: ['id', 'dateField'], cache: true },
|
||||
{ compositeField: ['id', 'bufferField'], cache: true },
|
||||
],
|
||||
});
|
||||
+36
@@ -0,0 +1,36 @@
|
||||
import type { IEntityMetricsAdapter } from '@expo/entity';
|
||||
import { EntityCompanionProvider, NoOpEntityMetricsAdapter } from '@expo/entity';
|
||||
import { StubDatabaseAdapterProvider, StubQueryContextProvider } from '@expo/entity-testing-utils';
|
||||
|
||||
import type { GenericRedisCacheContext } from '../GenericRedisCacher.ts';
|
||||
import { RedisCacheAdapterProvider } from '../RedisCacheAdapterProvider.ts';
|
||||
|
||||
// share across all in calls in test to simulate postgres
|
||||
const adapterProvider = new StubDatabaseAdapterProvider();
|
||||
const queryContextProvider = new StubQueryContextProvider();
|
||||
|
||||
export const createRedisIntegrationTestEntityCompanionProvider = (
|
||||
genericRedisCacheContext: GenericRedisCacheContext,
|
||||
metricsAdapter: IEntityMetricsAdapter = new NoOpEntityMetricsAdapter(),
|
||||
): EntityCompanionProvider => {
|
||||
return new EntityCompanionProvider(
|
||||
metricsAdapter,
|
||||
new Map([
|
||||
[
|
||||
'postgres',
|
||||
{
|
||||
adapterProvider,
|
||||
queryContextProvider,
|
||||
},
|
||||
],
|
||||
]),
|
||||
new Map([
|
||||
[
|
||||
'redis',
|
||||
{
|
||||
cacheAdapterProvider: new RedisCacheAdapterProvider(genericRedisCacheContext),
|
||||
},
|
||||
],
|
||||
]),
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,307 @@
|
||||
import {
|
||||
CacheStatus,
|
||||
EntityConfiguration,
|
||||
SingleFieldHolder,
|
||||
SingleFieldValueHolder,
|
||||
UUIDField,
|
||||
} from '@expo/entity';
|
||||
import { describe, expect, it } from '@jest/globals';
|
||||
import type { Pipeline, Redis } from 'ioredis';
|
||||
import { anything, instance, mock, verify, when } from 'ts-mockito';
|
||||
|
||||
import { GenericRedisCacher, RedisCacheInvalidationStrategy } from '../GenericRedisCacher.ts';
|
||||
|
||||
type BlahFields = {
|
||||
id: string;
|
||||
};
|
||||
|
||||
const entityConfiguration = new EntityConfiguration<BlahFields, 'id'>({
|
||||
idField: 'id',
|
||||
tableName: 'blah',
|
||||
cacheKeyVersion: 2,
|
||||
schema: {
|
||||
id: new UUIDField({ columnName: 'id', cache: true }),
|
||||
},
|
||||
databaseAdapterFlavor: 'postgres',
|
||||
cacheAdapterFlavor: 'redis',
|
||||
});
|
||||
|
||||
describe(GenericRedisCacher, () => {
|
||||
describe('loadManyAsync', () => {
|
||||
it('returns appropriate cache results', async () => {
|
||||
const redisResults = new Map();
|
||||
|
||||
const mockRedisClient = mock<Redis>();
|
||||
|
||||
// need to have one anything() for each element of spread, in this case 3
|
||||
when(mockRedisClient.mget(anything(), anything(), anything())).thenCall(async (...keys) =>
|
||||
keys.map((k) => redisResults.get(k) ?? null),
|
||||
);
|
||||
|
||||
const genericCacher = new GenericRedisCacher(
|
||||
{
|
||||
redisClient: instance(mockRedisClient),
|
||||
makeKeyFn: (...parts) => parts.join(':'),
|
||||
cacheKeyPrefix: 'hello-',
|
||||
ttlSecondsPositive: 1,
|
||||
ttlSecondsNegative: 2,
|
||||
invalidationConfig: {
|
||||
invalidationStrategy: RedisCacheInvalidationStrategy.CURRENT_CACHE_KEY_VERSION,
|
||||
},
|
||||
},
|
||||
entityConfiguration,
|
||||
);
|
||||
|
||||
const cacheKeyWat = genericCacher['makeCacheKeyForStorage'](
|
||||
new SingleFieldHolder('id'),
|
||||
new SingleFieldValueHolder('wat'),
|
||||
);
|
||||
const cacheKeyWho = genericCacher['makeCacheKeyForStorage'](
|
||||
new SingleFieldHolder('id'),
|
||||
new SingleFieldValueHolder('who'),
|
||||
);
|
||||
const cacheKeyWhy = genericCacher['makeCacheKeyForStorage'](
|
||||
new SingleFieldHolder('id'),
|
||||
new SingleFieldValueHolder('why'),
|
||||
);
|
||||
|
||||
redisResults.set(cacheKeyWat, JSON.stringify({ id: 'wat' }));
|
||||
redisResults.set(cacheKeyWho, '');
|
||||
|
||||
const results = await genericCacher.loadManyAsync([cacheKeyWat, cacheKeyWho, cacheKeyWhy]);
|
||||
|
||||
expect(results.get(cacheKeyWat)).toMatchObject({
|
||||
status: CacheStatus.HIT,
|
||||
item: { id: 'wat' },
|
||||
});
|
||||
expect(results.get(cacheKeyWho)).toMatchObject({ status: CacheStatus.NEGATIVE });
|
||||
expect(results.get(cacheKeyWhy)).toMatchObject({ status: CacheStatus.MISS });
|
||||
expect(results.size).toBe(3);
|
||||
});
|
||||
|
||||
it('returns empty map when passed empty array of fieldValues', async () => {
|
||||
const genericCacher = new GenericRedisCacher(
|
||||
{
|
||||
redisClient: instance(mock<Redis>()),
|
||||
makeKeyFn: (...parts) => parts.join(':'),
|
||||
cacheKeyPrefix: 'hello-',
|
||||
ttlSecondsPositive: 1,
|
||||
ttlSecondsNegative: 2,
|
||||
invalidationConfig: {
|
||||
invalidationStrategy: RedisCacheInvalidationStrategy.CURRENT_CACHE_KEY_VERSION,
|
||||
},
|
||||
},
|
||||
entityConfiguration,
|
||||
);
|
||||
const results = await genericCacher.loadManyAsync([]);
|
||||
expect(results).toEqual(new Map());
|
||||
});
|
||||
});
|
||||
|
||||
describe('cacheManyAsync', () => {
|
||||
it('correctly caches all objects', async () => {
|
||||
const redisResults = new Map();
|
||||
|
||||
const mockPipeline = mock<Pipeline>();
|
||||
when(mockPipeline.set(anything(), anything(), anything(), anything())).thenCall(
|
||||
(key, value, code, ttl) => {
|
||||
redisResults.set(key, { value, code, ttl });
|
||||
return pipeline;
|
||||
},
|
||||
);
|
||||
when(mockPipeline.exec()).thenResolve([]);
|
||||
const pipeline = instance(mockPipeline);
|
||||
|
||||
const mockRedisClient = mock<Redis>();
|
||||
when(mockRedisClient.multi()).thenReturn(pipeline);
|
||||
|
||||
const genericCacher = new GenericRedisCacher(
|
||||
{
|
||||
redisClient: instance(mockRedisClient),
|
||||
makeKeyFn: (...parts) => parts.join(':'),
|
||||
cacheKeyPrefix: 'hello-',
|
||||
ttlSecondsPositive: 1,
|
||||
ttlSecondsNegative: 2,
|
||||
invalidationConfig: {
|
||||
invalidationStrategy: RedisCacheInvalidationStrategy.CURRENT_CACHE_KEY_VERSION,
|
||||
},
|
||||
},
|
||||
entityConfiguration,
|
||||
);
|
||||
|
||||
const cacheKey = genericCacher['makeCacheKeyForStorage'](
|
||||
new SingleFieldHolder('id'),
|
||||
new SingleFieldValueHolder('wat'),
|
||||
);
|
||||
|
||||
await genericCacher.cacheManyAsync(new Map([[cacheKey, { id: 'wat' }]]));
|
||||
|
||||
expect(redisResults.get(cacheKey)).toMatchObject({
|
||||
value: JSON.stringify({ id: 'wat' }),
|
||||
code: 'EX',
|
||||
ttl: 1,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('cacheDBMissesAsync', () => {
|
||||
it('correctly caches misses', async () => {
|
||||
const redisResults = new Map();
|
||||
const mockPipeline = mock<Pipeline>();
|
||||
when(mockPipeline.set(anything(), anything(), anything(), anything())).thenCall(
|
||||
(key, value, code, ttl) => {
|
||||
redisResults.set(key, { value, code, ttl });
|
||||
return pipeline;
|
||||
},
|
||||
);
|
||||
when(mockPipeline.exec()).thenResolve([]);
|
||||
const pipeline = instance(mockPipeline);
|
||||
|
||||
const mockRedisClient = mock<Redis>();
|
||||
when(mockRedisClient.multi()).thenReturn(pipeline);
|
||||
|
||||
const genericCacher = new GenericRedisCacher(
|
||||
{
|
||||
redisClient: instance(mockRedisClient),
|
||||
makeKeyFn: (...parts) => parts.join(':'),
|
||||
cacheKeyPrefix: 'hello-',
|
||||
ttlSecondsPositive: 1,
|
||||
ttlSecondsNegative: 2,
|
||||
invalidationConfig: {
|
||||
invalidationStrategy: RedisCacheInvalidationStrategy.CURRENT_CACHE_KEY_VERSION,
|
||||
},
|
||||
},
|
||||
entityConfiguration,
|
||||
);
|
||||
|
||||
const cacheKey = genericCacher['makeCacheKeyForStorage'](
|
||||
new SingleFieldHolder('id'),
|
||||
new SingleFieldValueHolder('wat'),
|
||||
);
|
||||
|
||||
await genericCacher.cacheDBMissesAsync([cacheKey]);
|
||||
|
||||
expect(redisResults.get(cacheKey)).toMatchObject({
|
||||
value: '',
|
||||
code: 'EX',
|
||||
ttl: 2,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('invalidateManyAsync', () => {
|
||||
it('invalidates correctly with RedisCacheInvalidationStrategy.CURRENT_CACHE_KEY_VERSION', async () => {
|
||||
const mockRedisClient = mock<Redis>();
|
||||
when(mockRedisClient.del()).thenResolve(1);
|
||||
|
||||
const genericCacher = new GenericRedisCacher(
|
||||
{
|
||||
redisClient: instance(mockRedisClient),
|
||||
makeKeyFn: (...parts) => parts.join(':'),
|
||||
cacheKeyPrefix: 'hello-',
|
||||
ttlSecondsPositive: 1,
|
||||
ttlSecondsNegative: 2,
|
||||
invalidationConfig: {
|
||||
invalidationStrategy: RedisCacheInvalidationStrategy.CURRENT_CACHE_KEY_VERSION,
|
||||
},
|
||||
},
|
||||
entityConfiguration,
|
||||
);
|
||||
const cacheKeys = genericCacher['makeCacheKeysForInvalidation'](
|
||||
new SingleFieldHolder('id'),
|
||||
new SingleFieldValueHolder('wat'),
|
||||
);
|
||||
expect(cacheKeys).toHaveLength(1);
|
||||
expect(cacheKeys[0]).toBe('hello-:single:blah:v3.2:id:wat');
|
||||
|
||||
await genericCacher.invalidateManyAsync(cacheKeys);
|
||||
verify(mockRedisClient.del(...cacheKeys)).once();
|
||||
});
|
||||
|
||||
it('invalidates correctly with RedisCacheInvalidationStrategy.SURROUNDING_CACHE_KEY_VERSIONS', async () => {
|
||||
const mockRedisClient = mock<Redis>();
|
||||
when(mockRedisClient.del()).thenResolve(1);
|
||||
|
||||
const genericCacher = new GenericRedisCacher(
|
||||
{
|
||||
redisClient: instance(mockRedisClient),
|
||||
makeKeyFn: (...parts) => parts.join(':'),
|
||||
cacheKeyPrefix: 'hello-',
|
||||
ttlSecondsPositive: 1,
|
||||
ttlSecondsNegative: 2,
|
||||
invalidationConfig: {
|
||||
invalidationStrategy: RedisCacheInvalidationStrategy.SURROUNDING_CACHE_KEY_VERSIONS,
|
||||
},
|
||||
},
|
||||
entityConfiguration,
|
||||
);
|
||||
const cacheKeys = genericCacher['makeCacheKeysForInvalidation'](
|
||||
new SingleFieldHolder('id'),
|
||||
new SingleFieldValueHolder('wat'),
|
||||
);
|
||||
expect(cacheKeys).toHaveLength(3);
|
||||
expect(cacheKeys[0]).toBe('hello-:single:blah:v3.1:id:wat');
|
||||
expect(cacheKeys[1]).toBe('hello-:single:blah:v3.2:id:wat');
|
||||
expect(cacheKeys[2]).toBe('hello-:single:blah:v3.3:id:wat');
|
||||
|
||||
await genericCacher.invalidateManyAsync(cacheKeys);
|
||||
verify(mockRedisClient.del(...cacheKeys)).once();
|
||||
});
|
||||
|
||||
it('invalidates correctly with RedisCacheInvalidationStrategy.CUSTOM', async () => {
|
||||
const mockRedisClient = mock<Redis>();
|
||||
when(mockRedisClient.del()).thenResolve(1);
|
||||
|
||||
const genericCacher = new GenericRedisCacher(
|
||||
{
|
||||
redisClient: instance(mockRedisClient),
|
||||
makeKeyFn: (...parts) => parts.join(':'),
|
||||
cacheKeyPrefix: 'hello-',
|
||||
ttlSecondsPositive: 1,
|
||||
ttlSecondsNegative: 2,
|
||||
invalidationConfig: {
|
||||
invalidationStrategy: RedisCacheInvalidationStrategy.CUSTOM,
|
||||
cacheKeyVersionsToInvalidateFn(cacheKeyVersion) {
|
||||
return [
|
||||
cacheKeyVersion,
|
||||
cacheKeyVersion + 1,
|
||||
cacheKeyVersion + 2,
|
||||
cacheKeyVersion + 3,
|
||||
];
|
||||
},
|
||||
},
|
||||
},
|
||||
entityConfiguration,
|
||||
);
|
||||
const cacheKeys = genericCacher['makeCacheKeysForInvalidation'](
|
||||
new SingleFieldHolder('id'),
|
||||
new SingleFieldValueHolder('wat'),
|
||||
);
|
||||
expect(cacheKeys).toHaveLength(4);
|
||||
expect(cacheKeys[0]).toBe('hello-:single:blah:v3.2:id:wat');
|
||||
expect(cacheKeys[1]).toBe('hello-:single:blah:v3.3:id:wat');
|
||||
expect(cacheKeys[2]).toBe('hello-:single:blah:v3.4:id:wat');
|
||||
expect(cacheKeys[3]).toBe('hello-:single:blah:v3.5:id:wat');
|
||||
|
||||
await genericCacher.invalidateManyAsync(cacheKeys);
|
||||
verify(mockRedisClient.del(...cacheKeys)).once();
|
||||
});
|
||||
|
||||
it('returns when passed empty array of fieldValues', async () => {
|
||||
const genericCacher = new GenericRedisCacher(
|
||||
{
|
||||
redisClient: instance(mock<Redis>()),
|
||||
makeKeyFn: (...parts) => parts.join(':'),
|
||||
cacheKeyPrefix: 'hello-',
|
||||
ttlSecondsPositive: 1,
|
||||
ttlSecondsNegative: 2,
|
||||
invalidationConfig: {
|
||||
invalidationStrategy: RedisCacheInvalidationStrategy.CURRENT_CACHE_KEY_VERSION,
|
||||
},
|
||||
},
|
||||
entityConfiguration,
|
||||
);
|
||||
await genericCacher.invalidateManyAsync([]);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,169 +0,0 @@
|
||||
import { CacheStatus, UUIDField, EntityConfiguration } from '@expo/entity';
|
||||
import { Redis, Pipeline } from 'ioredis';
|
||||
import { mock, when, instance, anything, verify } from 'ts-mockito';
|
||||
|
||||
import RedisCacheAdapter from '../RedisCacheAdapter';
|
||||
|
||||
type BlahFields = {
|
||||
id: string;
|
||||
};
|
||||
|
||||
const entityConfiguration = new EntityConfiguration<BlahFields>({
|
||||
idField: 'id',
|
||||
tableName: 'blah',
|
||||
schema: {
|
||||
id: new UUIDField({ columnName: 'id', cache: true }),
|
||||
},
|
||||
databaseAdapterFlavor: 'postgres',
|
||||
cacheAdapterFlavor: 'redis',
|
||||
});
|
||||
|
||||
describe(RedisCacheAdapter, () => {
|
||||
describe('loadManyAsync', () => {
|
||||
it('returns appropriate cache results', async () => {
|
||||
const redisResults = new Map();
|
||||
|
||||
const mockRedisClient = mock<Redis>();
|
||||
|
||||
// need to have one anything() for each element of spread, in this case 3
|
||||
when(mockRedisClient.mget(anything(), anything(), anything())).thenCall(async (...keys) =>
|
||||
keys.map((k) => redisResults.get(k) ?? null)
|
||||
);
|
||||
|
||||
const cacheAdapter = new RedisCacheAdapter(entityConfiguration, {
|
||||
redisClient: instance(mockRedisClient),
|
||||
makeKeyFn: (...parts) => parts.join(':'),
|
||||
cacheKeyVersion: 1,
|
||||
cacheKeyPrefix: 'hello-',
|
||||
ttlSecondsPositive: 1,
|
||||
ttlSecondsNegative: 2,
|
||||
});
|
||||
|
||||
redisResults.set(cacheAdapter['makeCacheKey']('id', 'wat'), JSON.stringify({ id: 'wat' }));
|
||||
redisResults.set(cacheAdapter['makeCacheKey']('id', 'who'), '');
|
||||
|
||||
const results = await cacheAdapter.loadManyAsync('id', ['wat', 'who', 'why']);
|
||||
|
||||
expect(results.get('wat')).toMatchObject({ status: CacheStatus.HIT, item: { id: 'wat' } });
|
||||
expect(results.get('who')).toMatchObject({ status: CacheStatus.NEGATIVE });
|
||||
expect(results.get('why')).toMatchObject({ status: CacheStatus.MISS });
|
||||
expect(results.size).toBe(3);
|
||||
});
|
||||
|
||||
it('returns empty map when passed empty array of fieldValues', async () => {
|
||||
const cacheAdapter = new RedisCacheAdapter(entityConfiguration, {
|
||||
redisClient: instance(mock<Redis>()),
|
||||
makeKeyFn: (...parts) => parts.join(':'),
|
||||
cacheKeyVersion: 1,
|
||||
cacheKeyPrefix: 'hello-',
|
||||
ttlSecondsPositive: 1,
|
||||
ttlSecondsNegative: 2,
|
||||
});
|
||||
const results = await cacheAdapter.loadManyAsync('id', []);
|
||||
expect(results).toEqual(new Map());
|
||||
});
|
||||
});
|
||||
|
||||
describe('cacheManyAsync', () => {
|
||||
it('correctly caches all objects', async () => {
|
||||
const redisResults = new Map();
|
||||
|
||||
const mockPipeline = mock<Pipeline>();
|
||||
when(mockPipeline.set(anything(), anything(), anything(), anything())).thenCall(
|
||||
(key, value, code, ttl) => {
|
||||
redisResults.set(key, { value, code, ttl });
|
||||
return pipeline;
|
||||
}
|
||||
);
|
||||
when(mockPipeline.exec()).thenResolve({} as any);
|
||||
const pipeline = instance(mockPipeline);
|
||||
|
||||
const mockRedisClient = mock<Redis>();
|
||||
when(mockRedisClient.multi()).thenReturn(pipeline);
|
||||
|
||||
const cacheAdapter = new RedisCacheAdapter(entityConfiguration, {
|
||||
redisClient: instance(mockRedisClient),
|
||||
makeKeyFn: (...parts) => parts.join(':'),
|
||||
cacheKeyVersion: 1,
|
||||
cacheKeyPrefix: 'hello-',
|
||||
ttlSecondsPositive: 1,
|
||||
ttlSecondsNegative: 2,
|
||||
});
|
||||
await cacheAdapter.cacheManyAsync('id', new Map([['wat', { id: 'wat' }]]));
|
||||
|
||||
const cacheKey = cacheAdapter['makeCacheKey']('id', 'wat');
|
||||
expect(redisResults.get(cacheKey)).toMatchObject({
|
||||
value: JSON.stringify({ id: 'wat' }),
|
||||
code: 'EX',
|
||||
ttl: 1,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('cacheDBMissesAsync', () => {
|
||||
it('correctly caches misses', async () => {
|
||||
const redisResults = new Map();
|
||||
const mockPipeline = mock<Pipeline>();
|
||||
when(mockPipeline.set(anything(), anything(), anything(), anything())).thenCall(
|
||||
(key, value, code, ttl) => {
|
||||
redisResults.set(key, { value, code, ttl });
|
||||
return pipeline;
|
||||
}
|
||||
);
|
||||
when(mockPipeline.exec()).thenResolve({} as any);
|
||||
const pipeline = instance(mockPipeline);
|
||||
|
||||
const mockRedisClient = mock<Redis>();
|
||||
when(mockRedisClient.multi()).thenReturn(pipeline);
|
||||
|
||||
const cacheAdapter = new RedisCacheAdapter(entityConfiguration, {
|
||||
redisClient: instance(mockRedisClient),
|
||||
makeKeyFn: (...parts) => parts.join(':'),
|
||||
cacheKeyVersion: 1,
|
||||
cacheKeyPrefix: 'hello-',
|
||||
ttlSecondsPositive: 1,
|
||||
ttlSecondsNegative: 2,
|
||||
});
|
||||
await cacheAdapter.cacheDBMissesAsync('id', ['wat']);
|
||||
|
||||
const cacheKey = cacheAdapter['makeCacheKey']('id', 'wat');
|
||||
expect(redisResults.get(cacheKey)).toMatchObject({
|
||||
value: '',
|
||||
code: 'EX',
|
||||
ttl: 2,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('invalidateManyAsync', () => {
|
||||
it('invalidates correctly', async () => {
|
||||
const mockRedisClient = mock<Redis>();
|
||||
when(mockRedisClient.del()).thenResolve(1);
|
||||
|
||||
const cacheAdapter = new RedisCacheAdapter(entityConfiguration, {
|
||||
redisClient: instance(mockRedisClient),
|
||||
makeKeyFn: (...parts) => parts.join(':'),
|
||||
cacheKeyVersion: 1,
|
||||
cacheKeyPrefix: 'hello-',
|
||||
ttlSecondsPositive: 1,
|
||||
ttlSecondsNegative: 2,
|
||||
});
|
||||
await cacheAdapter.invalidateManyAsync('id', ['wat']);
|
||||
|
||||
const cacheKey = cacheAdapter['makeCacheKey']('id', 'wat');
|
||||
verify(mockRedisClient.del(cacheKey)).once();
|
||||
});
|
||||
|
||||
it('returns when passed empty array of fieldValues', async () => {
|
||||
const cacheAdapter = new RedisCacheAdapter(entityConfiguration, {
|
||||
redisClient: instance(mock<Redis>()),
|
||||
makeKeyFn: (...parts) => parts.join(':'),
|
||||
cacheKeyVersion: 1,
|
||||
cacheKeyPrefix: 'hello-',
|
||||
ttlSecondsPositive: 1,
|
||||
ttlSecondsNegative: 2,
|
||||
});
|
||||
await cacheAdapter.invalidateManyAsync('id', []);
|
||||
});
|
||||
});
|
||||
});
|
||||
+3
-2
@@ -1,11 +1,12 @@
|
||||
import { EntityCacheAdapterTransientError } from '@expo/entity';
|
||||
import { describe, expect, it } from '@jest/globals';
|
||||
|
||||
import wrapNativeRedisCallAsync from '../wrapNativeRedisCallAsync';
|
||||
import { wrapNativeRedisCallAsync } from '../wrapNativeRedisCallAsync.ts';
|
||||
|
||||
describe(wrapNativeRedisCallAsync, () => {
|
||||
it('rethrows literals', async () => {
|
||||
const throwingFn = async (): Promise<void> => {
|
||||
// eslint-disable-next-line no-throw-literal,@typescript-eslint/no-throw-literal
|
||||
// eslint-disable-next-line no-throw-literal,@typescript-eslint/only-throw-error
|
||||
throw 'hello';
|
||||
};
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { EntityCacheAdapterTransientError } from '@expo/entity';
|
||||
|
||||
export default async function wrapNativeRedisCallAsync<T>(fn: () => Promise<T>): Promise<T> {
|
||||
export async function wrapNativeRedisCallAsync<T>(fn: () => Promise<T>): Promise<T> {
|
||||
try {
|
||||
return await fn();
|
||||
} catch (e) {
|
||||
|
||||
@@ -4,9 +4,8 @@
|
||||
* @module @expo/entity-cache-adapter-redis
|
||||
*/
|
||||
|
||||
export { default as RedisCacheAdapter } from './RedisCacheAdapter';
|
||||
export * from './RedisCacheAdapter';
|
||||
export { default as RedisCacheAdapterProvider } from './RedisCacheAdapterProvider';
|
||||
export * from './RedisCommon';
|
||||
export { default as GenericRedisCacher } from './GenericRedisCacher';
|
||||
export * from './GenericRedisCacher';
|
||||
export * from './GenericRedisCacher.ts';
|
||||
export * from './RedisCacheAdapterProvider.ts';
|
||||
export * from './RedisCommon.ts';
|
||||
export * from './errors/wrapNativeRedisCallAsync.ts';
|
||||
export * from './utils/getSurroundingCacheKeyVersionsForInvalidation.ts';
|
||||
@@ -1,95 +0,0 @@
|
||||
import {
|
||||
AlwaysAllowPrivacyPolicyRule,
|
||||
EntityPrivacyPolicy,
|
||||
ViewerContext,
|
||||
UUIDField,
|
||||
DateField,
|
||||
StringField,
|
||||
EntityConfiguration,
|
||||
EntityCompanionDefinition,
|
||||
Entity,
|
||||
} from '@expo/entity';
|
||||
|
||||
export type RedisTestEntityFields = {
|
||||
id: string;
|
||||
name: string;
|
||||
dateField: Date | null;
|
||||
};
|
||||
|
||||
export default class RedisTestEntity extends Entity<RedisTestEntityFields, string, ViewerContext> {
|
||||
static getCompanionDefinition(): EntityCompanionDefinition<
|
||||
RedisTestEntityFields,
|
||||
string,
|
||||
ViewerContext,
|
||||
RedisTestEntity,
|
||||
RedisTestEntityPrivacyPolicy
|
||||
> {
|
||||
return redisTestEntityCompanionDefinition;
|
||||
}
|
||||
}
|
||||
|
||||
export class RedisTestEntityPrivacyPolicy extends EntityPrivacyPolicy<
|
||||
RedisTestEntityFields,
|
||||
string,
|
||||
ViewerContext,
|
||||
RedisTestEntity
|
||||
> {
|
||||
protected override readonly createRules = [
|
||||
new AlwaysAllowPrivacyPolicyRule<
|
||||
RedisTestEntityFields,
|
||||
string,
|
||||
ViewerContext,
|
||||
RedisTestEntity
|
||||
>(),
|
||||
];
|
||||
protected override readonly readRules = [
|
||||
new AlwaysAllowPrivacyPolicyRule<
|
||||
RedisTestEntityFields,
|
||||
string,
|
||||
ViewerContext,
|
||||
RedisTestEntity
|
||||
>(),
|
||||
];
|
||||
protected override readonly updateRules = [
|
||||
new AlwaysAllowPrivacyPolicyRule<
|
||||
RedisTestEntityFields,
|
||||
string,
|
||||
ViewerContext,
|
||||
RedisTestEntity
|
||||
>(),
|
||||
];
|
||||
protected override readonly deleteRules = [
|
||||
new AlwaysAllowPrivacyPolicyRule<
|
||||
RedisTestEntityFields,
|
||||
string,
|
||||
ViewerContext,
|
||||
RedisTestEntity
|
||||
>(),
|
||||
];
|
||||
}
|
||||
|
||||
export const redisTestEntityConfiguration = new EntityConfiguration<RedisTestEntityFields>({
|
||||
idField: 'id',
|
||||
tableName: 'redis_test_entities',
|
||||
schema: {
|
||||
id: new UUIDField({
|
||||
columnName: 'id',
|
||||
cache: true,
|
||||
}),
|
||||
name: new StringField({
|
||||
columnName: 'name',
|
||||
cache: true,
|
||||
}),
|
||||
dateField: new DateField({
|
||||
columnName: 'date_field',
|
||||
}),
|
||||
},
|
||||
databaseAdapterFlavor: 'postgres',
|
||||
cacheAdapterFlavor: 'redis',
|
||||
});
|
||||
|
||||
const redisTestEntityCompanionDefinition = new EntityCompanionDefinition({
|
||||
entityClass: RedisTestEntity,
|
||||
entityConfiguration: redisTestEntityConfiguration,
|
||||
privacyPolicyClass: RedisTestEntityPrivacyPolicy,
|
||||
});
|
||||
-36
@@ -1,36 +0,0 @@
|
||||
import {
|
||||
NoOpEntityMetricsAdapter,
|
||||
IEntityMetricsAdapter,
|
||||
EntityCompanionProvider,
|
||||
StubQueryContextProvider,
|
||||
StubDatabaseAdapterProvider,
|
||||
} from '@expo/entity';
|
||||
|
||||
import { RedisCacheAdapterContext } from '../RedisCacheAdapter';
|
||||
import RedisCacheAdapterProvider from '../RedisCacheAdapterProvider';
|
||||
|
||||
export const createRedisIntegrationTestEntityCompanionProvider = (
|
||||
redisCacheAdapterContext: RedisCacheAdapterContext,
|
||||
metricsAdapter: IEntityMetricsAdapter = new NoOpEntityMetricsAdapter()
|
||||
): EntityCompanionProvider => {
|
||||
return new EntityCompanionProvider(
|
||||
metricsAdapter,
|
||||
new Map([
|
||||
[
|
||||
'postgres',
|
||||
{
|
||||
adapterProvider: new StubDatabaseAdapterProvider(),
|
||||
queryContextProvider: StubQueryContextProvider,
|
||||
},
|
||||
],
|
||||
]),
|
||||
new Map([
|
||||
[
|
||||
'redis',
|
||||
{
|
||||
cacheAdapterProvider: new RedisCacheAdapterProvider(redisCacheAdapterContext),
|
||||
},
|
||||
],
|
||||
])
|
||||
);
|
||||
};
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
import { describe, expect, it } from '@jest/globals';
|
||||
|
||||
import { getSurroundingCacheKeyVersionsForInvalidation } from '../getSurroundingCacheKeyVersionsForInvalidation.ts';
|
||||
|
||||
describe(getSurroundingCacheKeyVersionsForInvalidation, () => {
|
||||
it('returns the correct cache key versions to invalidate', () => {
|
||||
expect(getSurroundingCacheKeyVersionsForInvalidation(0)).toEqual([0, 1]);
|
||||
expect(getSurroundingCacheKeyVersionsForInvalidation(1)).toEqual([0, 1, 2]);
|
||||
expect(getSurroundingCacheKeyVersionsForInvalidation(2)).toEqual([1, 2, 3]);
|
||||
});
|
||||
});
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
export function getSurroundingCacheKeyVersionsForInvalidation(
|
||||
cacheKeyVersion: number,
|
||||
): readonly number[] {
|
||||
return [
|
||||
...(cacheKeyVersion === 0 ? [] : [cacheKeyVersion - 1]),
|
||||
cacheKeyVersion,
|
||||
cacheKeyVersion + 1,
|
||||
];
|
||||
}
|
||||
@@ -1,7 +1,5 @@
|
||||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "build",
|
||||
},
|
||||
"include": ["src"]
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"include": ["src/**/*"],
|
||||
"references": [{ "path": "../entity" }, { "path": "../entity-testing-utils" }]
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
# Change Log
|
||||
|
||||
All notable changes to this project will be documented in this file.
|
||||
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
|
||||
|
||||
# [0.62.0](https://github.com/expo/entity/compare/v0.61.0...v0.62.0) (2026-03-16)
|
||||
|
||||
**Note:** Version bump only for package @expo/entity-codemod
|
||||
|
||||
# [0.61.0](https://github.com/expo/entity/compare/v0.60.0...v0.61.0) (2026-03-12)
|
||||
|
||||
**Note:** Version bump only for package @expo/entity-codemod
|
||||
|
||||
# [0.60.0](https://github.com/expo/entity/compare/v0.59.0...v0.60.0) (2026-03-06)
|
||||
|
||||
**Note:** Version bump only for package @expo/entity-codemod
|
||||
|
||||
# [0.59.0](https://github.com/expo/entity/compare/v0.58.0...v0.59.0) (2026-03-03)
|
||||
|
||||
**Note:** Version bump only for package @expo/entity-codemod
|
||||
|
||||
# [0.58.0](https://github.com/expo/entity/compare/v0.57.0...v0.58.0) (2026-03-02)
|
||||
|
||||
**Note:** Version bump only for package @expo/entity-codemod
|
||||
|
||||
# [0.57.0](https://github.com/expo/entity/compare/v0.56.0...v0.57.0) (2026-02-28)
|
||||
|
||||
**Note:** Version bump only for package @expo/entity-codemod
|
||||
|
||||
# [0.56.0](https://github.com/expo/entity/compare/v0.55.0...v0.56.0) (2026-02-28)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- refactor install method to instead be free functions ([#441](https://github.com/expo/entity/issues/441)) ([51d632d](https://github.com/expo/entity/commit/51d632da1619dc039d5c41ce6533f63254ec4337)), closes [#410](https://github.com/expo/entity/issues/410)
|
||||
- revert change to codemod now that Entity.knexLoader is supported again via subclass ([#443](https://github.com/expo/entity/issues/443)) ([ed6b2e0](https://github.com/expo/entity/commit/ed6b2e05539c77ee2abd2c6da74e2a451967d06e)), closes [#441](https://github.com/expo/entity/issues/441)
|
||||
|
||||
### Features
|
||||
|
||||
- add codemod for upcoming v0.55.0-v0.56.0 ([#413](https://github.com/expo/entity/issues/413)) ([aba0ce5](https://github.com/expo/entity/commit/aba0ce5b5f38cb7b01956cde14a4714e2a03abb4)), closes [#407](https://github.com/expo/entity/issues/407) [-#410](https://github.com/-/issues/410)
|
||||
|
||||
# [0.55.0](https://github.com/expo/entity/compare/v0.54.0...v0.55.0) (2026-01-27)
|
||||
|
||||
**Note:** Version bump only for package @expo/entity-codemod
|
||||
|
||||
# [0.54.0](https://github.com/expo/entity/compare/v0.53.0...v0.54.0) (2026-01-21)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **deps:** pin dependencies ([#328](https://github.com/expo/entity/issues/328)) ([5679f27](https://github.com/expo/entity/commit/5679f27209f1515ca5e626d858c4aa054b414625))
|
||||
|
||||
# [0.53.0](https://github.com/expo/entity/compare/v0.52.0...v0.53.0) (2025-12-19)
|
||||
|
||||
**Note:** Version bump only for package @expo/entity-codemod
|
||||
|
||||
# [0.52.0](https://github.com/expo/entity/compare/v0.51.0...v0.52.0) (2025-12-19)
|
||||
|
||||
**Note:** Version bump only for package @expo/entity-codemod
|
||||
|
||||
# [0.51.0](https://github.com/expo/entity/compare/v0.50.0...v0.51.0) (2025-11-21)
|
||||
|
||||
**Note:** Version bump only for package @expo/entity-codemod
|
||||
|
||||
# [0.50.0](https://github.com/expo/entity/compare/v0.49.0...v0.50.0) (2025-10-07)
|
||||
|
||||
**Note:** Version bump only for package @expo/entity-codemod
|
||||
|
||||
# [0.49.0](https://github.com/expo/entity/compare/v0.48.0...v0.49.0) (2025-10-07)
|
||||
|
||||
**Note:** Version bump only for package @expo/entity-codemod
|
||||
|
||||
# [0.48.0](https://github.com/expo/entity/compare/v0.47.0...v0.48.0) (2025-09-19)
|
||||
|
||||
**Note:** Version bump only for package @expo/entity-codemod
|
||||
|
||||
# [0.47.0](https://github.com/expo/entity/compare/v0.46.0...v0.47.0) (2025-09-19)
|
||||
|
||||
**Note:** Version bump only for package @expo/entity-codemod
|
||||
|
||||
# [0.46.0](https://github.com/expo/entity/compare/v0.45.0...v0.46.0) (2025-06-26)
|
||||
|
||||
**Note:** Version bump only for package @expo/entity-codemod
|
||||
|
||||
# [0.45.0](https://github.com/expo/entity/compare/v0.44.0...v0.45.0) (2025-06-06)
|
||||
|
||||
**Note:** Version bump only for package @expo/entity-codemod
|
||||
|
||||
# [0.44.0](https://github.com/expo/entity/compare/v0.43.0...v0.44.0) (2025-05-29)
|
||||
|
||||
**Note:** Version bump only for package @expo/entity-codemod
|
||||
|
||||
# [0.43.0](https://github.com/expo/entity/compare/v0.42.0...v0.43.0) (2025-04-10)
|
||||
|
||||
**Note:** Version bump only for package @expo/entity-codemod
|
||||
|
||||
# [0.42.0](https://github.com/expo/entity/compare/v0.41.0...v0.42.0) (2025-04-10)
|
||||
|
||||
**Note:** Version bump only for package @expo/entity-codemod
|
||||
|
||||
# [0.41.0](https://github.com/expo/entity/compare/v0.40.0...v0.41.0) (2025-03-07)
|
||||
|
||||
### Features
|
||||
|
||||
- Create codemod for v0.40.0 -> v0.41.0 ([#260](https://github.com/expo/entity/issues/260)) ([4a2b61f](https://github.com/expo/entity/commit/4a2b61f2bd8edfc19109ef4c8ac780eaf0f7795c))
|
||||
|
||||
# [0.40.0](https://github.com/expo/entity/compare/v0.39.0...v0.40.0) (2025-02-27)
|
||||
|
||||
### Features
|
||||
|
||||
- Create codemod for v0.39.0 -> v0.40.0 ([#259](https://github.com/expo/entity/issues/259)) ([d63ddd6](https://github.com/expo/entity/commit/d63ddd65997a441a914a71b158a111339970aeab))
|
||||
|
||||
### Reverts
|
||||
|
||||
- Revert "v0.40.0" ([fa78ce8](https://github.com/expo/entity/commit/fa78ce84e2f2eb8199e36fd51779fc68905001c0))
|
||||
@@ -0,0 +1,26 @@
|
||||
# @expo/entity-codemod
|
||||
|
||||
A package containing jscodeshift codemods for @expo/entity upgrades.
|
||||
|
||||
Codemods are transformations that run on your codebase programmatically. This allows for a large amount of changes to be applied without having to manually go through every file.
|
||||
|
||||
## Usage
|
||||
|
||||
These should be used via the [jscodeshift CLI](https://github.com/facebook/jscodeshift?tab=readme-ov-file#usage-cli).
|
||||
|
||||
For example:
|
||||
|
||||
```
|
||||
yarn add -D @expo/entity-codemod
|
||||
```
|
||||
|
||||
Then, in package.json scripts:
|
||||
|
||||
```json
|
||||
"jscodeshift": "jscodeshift --extensions=ts --parser=ts"
|
||||
```
|
||||
|
||||
And finally:
|
||||
```sh
|
||||
yarn jscodeshift src -t node_modules/@expo/entity-codemod/build/v0.39.0-v0.40.0.js
|
||||
```
|
||||
@@ -0,0 +1,37 @@
|
||||
{
|
||||
"name": "@expo/entity-codemod",
|
||||
"version": "0.63.0",
|
||||
"description": "jscodeshift codemods for @expo/entity upgrades",
|
||||
"files": [
|
||||
"build",
|
||||
"!*.tsbuildinfo",
|
||||
"!__*",
|
||||
"src"
|
||||
],
|
||||
"main": "build/src/index.js",
|
||||
"types": "build/src/index.d.ts",
|
||||
"scripts": {
|
||||
"build": "tsc --build",
|
||||
"prepack": "rm -rf build && yarn build",
|
||||
"clean": "yarn build --clean",
|
||||
"lint": "yarn run --top-level eslint src",
|
||||
"lint-fix": "yarn lint --fix",
|
||||
"test": "yarn test:all --rootDir $(pwd)"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"keywords": [
|
||||
"entity"
|
||||
],
|
||||
"author": "Expo",
|
||||
"license": "MIT",
|
||||
"type": "module",
|
||||
"devDependencies": {
|
||||
"@jest/globals": "30.3.0",
|
||||
"@types/jscodeshift": "17.3.0",
|
||||
"@types/node": "24.12.0",
|
||||
"jscodeshift": "17.3.0",
|
||||
"typescript": "5.9.3"
|
||||
}
|
||||
}
|
||||
Whitespace-only changes.
+79
@@ -0,0 +1,79 @@
|
||||
/* eslint-disable */
|
||||
|
||||
import { ViewerContext } from '@expo/entity';
|
||||
|
||||
async function testWithAuthorizationResults(viewerContext: ViewerContext): Promise<void> {
|
||||
// creator
|
||||
const entityResult = await TestEntity.creator(viewerContext).setField('wat', 2).createAsync();
|
||||
|
||||
// updater
|
||||
const updatedEntityResult = await TestEntity.updater(entityResult.enforceValue()).setField('wat', 3).updateAsync();
|
||||
|
||||
// deleter
|
||||
await TestEntity.deleteAsync(updatedEntityResult.enforceValue());
|
||||
}
|
||||
|
||||
async function testEnforcing(viewerContext: ViewerContext): Promise<void> {
|
||||
// creator
|
||||
const entity = await TestEntity.creator(viewerContext).setField('wat', 2).enforceCreateAsync();
|
||||
const entity2 = await TestEntity.creator(viewerContext).setField('wat', 2).setField('who', 3).enforceCreateAsync();
|
||||
const entity3 = await TestEntity.creator(viewerContext)
|
||||
.setField('wat', 2)
|
||||
.setField('who', 3)
|
||||
.setField('who', 4)
|
||||
.setField('who', 5)
|
||||
.setField('who', 6)
|
||||
.setField('who', 7)
|
||||
.setField('who', 8)
|
||||
.setField('who', 9)
|
||||
.enforceCreateAsync();
|
||||
|
||||
// updater
|
||||
const updatedEntity = await TestEntity.updater(entity).setField('wat', 3).enforceUpdateAsync();
|
||||
const updatedEntity2 = await TestEntity.updater(entity2).setField('wat', 3).setField('who', 4).enforceUpdateAsync();
|
||||
const updatedEntity3 = await TestEntity.updater(entity3)
|
||||
.setField('wat', 3)
|
||||
.setField('who', 4)
|
||||
.setField('who', 5)
|
||||
.setField('who', 6)
|
||||
.setField('who', 7)
|
||||
.setField('who', 8)
|
||||
.setField('who', 9)
|
||||
.enforceUpdateAsync();
|
||||
|
||||
// deleter
|
||||
await TestEntity.enforceDeleteAsync(entity);
|
||||
}
|
||||
|
||||
async function testNoSetField(viewerContext: ViewerContext): Promise<void> {
|
||||
// creator
|
||||
const entity = await TestEntity.creator(viewerContext).enforceCreateAsync();
|
||||
|
||||
// updater
|
||||
const updatedEntity = await TestEntity.updater(entity).enforceUpdateAsync();
|
||||
}
|
||||
|
||||
async function testEnforcingWithQueryContext(viewerContext: ViewerContext): Promise<void> {
|
||||
// creator
|
||||
const entity = await TestEntity.creator(viewerContext, queryContext).setField('wat', 2).enforceCreateAsync();
|
||||
|
||||
// updater
|
||||
const updatedEntity = await TestEntity.updater(entity, queryContext).setField('wat', 3).enforceUpdateAsync();
|
||||
|
||||
// deleter
|
||||
await TestEntity.enforceDeleteAsync(entity, queryContext);
|
||||
}
|
||||
|
||||
async function testAssociationLoader(): Promise<void> {
|
||||
await this.associationLoader().loadAssociatedEntityAsync(
|
||||
'another_id',
|
||||
AnotherEntity,
|
||||
queryContext
|
||||
);
|
||||
|
||||
await entity.associationLoader().loadAssociatedEntityAsync(
|
||||
'another_id',
|
||||
AnotherEntity,
|
||||
queryContext
|
||||
)
|
||||
}
|
||||
+79
@@ -0,0 +1,79 @@
|
||||
/* eslint-disable */
|
||||
|
||||
import { ViewerContext } from '@expo/entity';
|
||||
|
||||
async function testWithAuthorizationResults(viewerContext: ViewerContext): Promise<void> {
|
||||
// creator
|
||||
const entityResult = await TestEntity.creator(viewerContext).withAuthorizationResults().setField('wat', 2).createAsync();
|
||||
|
||||
// updater
|
||||
const updatedEntityResult = await TestEntity.updater(entityResult.enforceValue()).withAuthorizationResults().setField('wat', 3).updateAsync();
|
||||
|
||||
// deleter
|
||||
await TestEntity.deleter(updatedEntityResult.enforceValue()).withAuthorizationResults().deleteAsync();
|
||||
}
|
||||
|
||||
async function testEnforcing(viewerContext: ViewerContext): Promise<void> {
|
||||
// creator
|
||||
const entity = await TestEntity.creator(viewerContext).enforcing().setField('wat', 2).createAsync();
|
||||
const entity2 = await TestEntity.creator(viewerContext).enforcing().setField('wat', 2).setField('who', 3).createAsync();
|
||||
const entity3 = await TestEntity.creator(viewerContext).enforcing()
|
||||
.setField('wat', 2)
|
||||
.setField('who', 3)
|
||||
.setField('who', 4)
|
||||
.setField('who', 5)
|
||||
.setField('who', 6)
|
||||
.setField('who', 7)
|
||||
.setField('who', 8)
|
||||
.setField('who', 9)
|
||||
.createAsync();
|
||||
|
||||
// updater
|
||||
const updatedEntity = await TestEntity.updater(entity).enforcing().setField('wat', 3).updateAsync();
|
||||
const updatedEntity2 = await TestEntity.updater(entity2).enforcing().setField('wat', 3).setField('who', 4).updateAsync();
|
||||
const updatedEntity3 = await TestEntity.updater(entity3).enforcing()
|
||||
.setField('wat', 3)
|
||||
.setField('who', 4)
|
||||
.setField('who', 5)
|
||||
.setField('who', 6)
|
||||
.setField('who', 7)
|
||||
.setField('who', 8)
|
||||
.setField('who', 9)
|
||||
.updateAsync();
|
||||
|
||||
// deleter
|
||||
await TestEntity.deleter(entity).enforcing().deleteAsync();
|
||||
}
|
||||
|
||||
async function testNoSetField(viewerContext: ViewerContext): Promise<void> {
|
||||
// creator
|
||||
const entity = await TestEntity.creator(viewerContext).enforcing().createAsync();
|
||||
|
||||
// updater
|
||||
const updatedEntity = await TestEntity.updater(entity).enforcing().updateAsync();
|
||||
}
|
||||
|
||||
async function testEnforcingWithQueryContext(viewerContext: ViewerContext): Promise<void> {
|
||||
// creator
|
||||
const entity = await TestEntity.creator(viewerContext, queryContext).enforcing().setField('wat', 2).createAsync();
|
||||
|
||||
// updater
|
||||
const updatedEntity = await TestEntity.updater(entity, queryContext).enforcing().setField('wat', 3).updateAsync();
|
||||
|
||||
// deleter
|
||||
await TestEntity.deleter(entity, queryContext).enforcing().deleteAsync();
|
||||
}
|
||||
|
||||
async function testAssociationLoader(): Promise<void> {
|
||||
await this.associationLoader().withAuthorizationResults().loadAssociatedEntityAsync(
|
||||
'another_id',
|
||||
AnotherEntity,
|
||||
queryContext
|
||||
);
|
||||
|
||||
await entity.associationLoader().withAuthorizationResults().loadAssociatedEntityAsync(
|
||||
'another_id',
|
||||
AnotherEntity,
|
||||
queryContext
|
||||
)
|
||||
}
|
||||
+10
@@ -0,0 +1,10 @@
|
||||
/* eslint-disable */
|
||||
|
||||
async function testAsync(viewerContext: ViewerContext): Promise<void> {
|
||||
const testTrigger = await TestTriggerEntity.createAsync(actorViewerContext, undefined, {
|
||||
appId,
|
||||
});
|
||||
|
||||
await TestTriggerEntity.enforceDeleteAsync(testTrigger);
|
||||
return testTrigger;
|
||||
};
|
||||
+10
@@ -0,0 +1,10 @@
|
||||
/* eslint-disable */
|
||||
|
||||
async function testAsync(viewerContext: ViewerContext): Promise<void> {
|
||||
const testTrigger = await TestTriggerEntity.createAsync(actorViewerContext, undefined, {
|
||||
appId,
|
||||
});
|
||||
|
||||
await TestTriggerEntity.deleter(testTrigger).enforcing().deleteAsync();
|
||||
return testTrigger;
|
||||
};
|
||||
+5
@@ -0,0 +1,5 @@
|
||||
/* eslint-disable */
|
||||
|
||||
async function anotherCase(updater: any): Promise<any> {
|
||||
return await updater.enforceUpdateAsync();
|
||||
}
|
||||
+5
@@ -0,0 +1,5 @@
|
||||
/* eslint-disable */
|
||||
|
||||
async function anotherCase(updater: any): Promise<any> {
|
||||
return await updater.enforceUpdateAsync();
|
||||
}
|
||||
+100
@@ -0,0 +1,100 @@
|
||||
/* eslint-disable */
|
||||
|
||||
import { ViewerContext } from '@expo/entity';
|
||||
|
||||
async function testWithAuthorizationResults(viewerContext: ViewerContext): Promise<void> {
|
||||
// creator
|
||||
const entityResult = await TestEntity.creator(viewerContext).withAuthorizationResults().setField('wat', 2).createAsync();
|
||||
|
||||
// updater
|
||||
const updatedEntityResult = await TestEntity.updater(entityResult.enforceValue()).withAuthorizationResults().setField('wat', 3).updateAsync();
|
||||
|
||||
// loader
|
||||
const loadedEntityResult = await TestEntity.loader(viewerContext).withAuthorizationResults().loadByIDAsync('test');
|
||||
|
||||
// deleter
|
||||
await TestEntity.deleter(updatedEntityResult.enforceValue()).withAuthorizationResults().deleteAsync();
|
||||
}
|
||||
|
||||
async function testEnforcing(viewerContext: ViewerContext): Promise<void> {
|
||||
// creator
|
||||
const entity = await TestEntity.creator(viewerContext).enforcing().setField('wat', 2).createAsync();
|
||||
const entity2 = await TestEntity.creator(viewerContext).enforcing().setField('wat', 2).setField('who', 3).createAsync();
|
||||
const entity3 = await TestEntity.creator(viewerContext).enforcing()
|
||||
.setField('wat', 2)
|
||||
.setField('who', 3)
|
||||
.setField('who', 4)
|
||||
.setField('who', 5)
|
||||
.setField('who', 6)
|
||||
.setField('who', 7)
|
||||
.setField('who', 8)
|
||||
.setField('who', 9)
|
||||
.createAsync();
|
||||
|
||||
// updater
|
||||
const updatedEntity = await TestEntity.updater(entity).enforcing().setField('wat', 3).updateAsync();
|
||||
const updatedEntity2 = await TestEntity.updater(entity2).enforcing().setField('wat', 3).setField('who', 4).updateAsync();
|
||||
const updatedEntity3 = await TestEntity.updater(entity3).enforcing()
|
||||
.setField('wat', 3)
|
||||
.setField('who', 4)
|
||||
.setField('who', 5)
|
||||
.setField('who', 6)
|
||||
.setField('who', 7)
|
||||
.setField('who', 8)
|
||||
.setField('who', 9)
|
||||
.updateAsync();
|
||||
|
||||
// loader
|
||||
const loadedEntity = await TestEntity.loader(viewerContext).enforcing().loadByIDAsync('test');
|
||||
|
||||
// deleter
|
||||
await TestEntity.deleter(entity).enforcing().deleteAsync();
|
||||
}
|
||||
|
||||
async function testNoSetField(viewerContext: ViewerContext): Promise<void> {
|
||||
// creator
|
||||
const entity = await TestEntity.creator(viewerContext).enforcing().createAsync();
|
||||
|
||||
// updater
|
||||
const updatedEntity = await TestEntity.updater(entity).enforcing().updateAsync();
|
||||
}
|
||||
|
||||
async function testEnforcingWithQueryContext(viewerContext: ViewerContext): Promise<void> {
|
||||
// creator
|
||||
const entity = await TestEntity.creator(viewerContext, queryContext).enforcing().setField('wat', 2).createAsync();
|
||||
|
||||
// updater
|
||||
const updatedEntity = await TestEntity.updater(entity, queryContext).enforcing().setField('wat', 3).updateAsync();
|
||||
|
||||
// loader
|
||||
const loadedEntity = await TestEntity.loader(viewerContext, queryContext).enforcing().loadByIDAsync('test');
|
||||
|
||||
// deleter
|
||||
await TestEntity.deleter(entity, queryContext).enforcing().deleteAsync();
|
||||
}
|
||||
|
||||
async function testAssociationLoader(): Promise<void> {
|
||||
await this.associationLoader().withAuthorizationResults().loadAssociatedEntityAsync(
|
||||
'another_id',
|
||||
AnotherEntity,
|
||||
queryContext
|
||||
);
|
||||
|
||||
await entity.associationLoader().withAuthorizationResults().loadAssociatedEntityAsync(
|
||||
'another_id',
|
||||
AnotherEntity,
|
||||
queryContext
|
||||
)
|
||||
|
||||
await this.associationLoader().enforcing().loadAssociatedEntityAsync(
|
||||
'another_id',
|
||||
AnotherEntity,
|
||||
queryContext
|
||||
);
|
||||
|
||||
await entity.associationLoader().enforcing().loadAssociatedEntityAsync(
|
||||
'another_id',
|
||||
AnotherEntity,
|
||||
queryContext
|
||||
)
|
||||
}
|
||||
+100
@@ -0,0 +1,100 @@
|
||||
/* eslint-disable */
|
||||
|
||||
import { ViewerContext } from '@expo/entity';
|
||||
|
||||
async function testWithAuthorizationResults(viewerContext: ViewerContext): Promise<void> {
|
||||
// creator
|
||||
const entityResult = await TestEntity.creatorWithAuthorizationResults(viewerContext).setField('wat', 2).createAsync();
|
||||
|
||||
// updater
|
||||
const updatedEntityResult = await TestEntity.updaterWithAuthorizationResults(entityResult.enforceValue()).setField('wat', 3).updateAsync();
|
||||
|
||||
// loader
|
||||
const loadedEntityResult = await TestEntity.loaderWithAuthorizationResults(viewerContext).loadByIDAsync('test');
|
||||
|
||||
// deleter
|
||||
await TestEntity.deleterWithAuthorizationResults(updatedEntityResult.enforceValue()).deleteAsync();
|
||||
}
|
||||
|
||||
async function testEnforcing(viewerContext: ViewerContext): Promise<void> {
|
||||
// creator
|
||||
const entity = await TestEntity.creator(viewerContext).setField('wat', 2).createAsync();
|
||||
const entity2 = await TestEntity.creator(viewerContext).setField('wat', 2).setField('who', 3).createAsync();
|
||||
const entity3 = await TestEntity.creator(viewerContext)
|
||||
.setField('wat', 2)
|
||||
.setField('who', 3)
|
||||
.setField('who', 4)
|
||||
.setField('who', 5)
|
||||
.setField('who', 6)
|
||||
.setField('who', 7)
|
||||
.setField('who', 8)
|
||||
.setField('who', 9)
|
||||
.createAsync();
|
||||
|
||||
// updater
|
||||
const updatedEntity = await TestEntity.updater(entity).setField('wat', 3).updateAsync();
|
||||
const updatedEntity2 = await TestEntity.updater(entity2).setField('wat', 3).setField('who', 4).updateAsync();
|
||||
const updatedEntity3 = await TestEntity.updater(entity3)
|
||||
.setField('wat', 3)
|
||||
.setField('who', 4)
|
||||
.setField('who', 5)
|
||||
.setField('who', 6)
|
||||
.setField('who', 7)
|
||||
.setField('who', 8)
|
||||
.setField('who', 9)
|
||||
.updateAsync();
|
||||
|
||||
// loader
|
||||
const loadedEntity = await TestEntity.loader(viewerContext).loadByIDAsync('test');
|
||||
|
||||
// deleter
|
||||
await TestEntity.deleter(entity).deleteAsync();
|
||||
}
|
||||
|
||||
async function testNoSetField(viewerContext: ViewerContext): Promise<void> {
|
||||
// creator
|
||||
const entity = await TestEntity.creator(viewerContext).createAsync();
|
||||
|
||||
// updater
|
||||
const updatedEntity = await TestEntity.updater(entity).updateAsync();
|
||||
}
|
||||
|
||||
async function testEnforcingWithQueryContext(viewerContext: ViewerContext): Promise<void> {
|
||||
// creator
|
||||
const entity = await TestEntity.creator(viewerContext, queryContext).setField('wat', 2).createAsync();
|
||||
|
||||
// updater
|
||||
const updatedEntity = await TestEntity.updater(entity, queryContext).setField('wat', 3).updateAsync();
|
||||
|
||||
// loader
|
||||
const loadedEntity = await TestEntity.loader(viewerContext, queryContext).loadByIDAsync('test');
|
||||
|
||||
// deleter
|
||||
await TestEntity.deleter(entity, queryContext).deleteAsync();
|
||||
}
|
||||
|
||||
async function testAssociationLoader(): Promise<void> {
|
||||
await this.associationLoaderWithAuthorizationResults().loadAssociatedEntityAsync(
|
||||
'another_id',
|
||||
AnotherEntity,
|
||||
queryContext
|
||||
);
|
||||
|
||||
await entity.associationLoaderWithAuthorizationResults().loadAssociatedEntityAsync(
|
||||
'another_id',
|
||||
AnotherEntity,
|
||||
queryContext
|
||||
)
|
||||
|
||||
await this.associationLoader().loadAssociatedEntityAsync(
|
||||
'another_id',
|
||||
AnotherEntity,
|
||||
queryContext
|
||||
);
|
||||
|
||||
await entity.associationLoader().loadAssociatedEntityAsync(
|
||||
'another_id',
|
||||
AnotherEntity,
|
||||
queryContext
|
||||
)
|
||||
}
|
||||
+37
@@ -0,0 +1,37 @@
|
||||
import { ViewerContext } from '@expo/entity';
|
||||
import { PostEntity } from './entities/PostEntity.ts';
|
||||
import { UserEntity } from './entities/UserEntity.ts';
|
||||
|
||||
async function loadUser(viewerContext: ViewerContext) {
|
||||
// Basic loader calls - only transformed when using knex-specific methods
|
||||
const userLoader = UserEntity.loader(viewerContext);
|
||||
const postLoader = PostEntity.loader(viewerContext);
|
||||
|
||||
// These use knex-specific methods, so they should be transformed
|
||||
const posts = await postLoader.loadManyByFieldEqualityConjunctionAsync([
|
||||
{ fieldName: 'status', fieldValue: 'published' }
|
||||
]);
|
||||
const firstPost = await postLoader.loadFirstByFieldEqualityConjunctionAsync([
|
||||
{ fieldName: 'id', fieldValue: '123' }
|
||||
]);
|
||||
|
||||
// Loader with authorization results - only transformed when using knex methods
|
||||
const userLoaderWithAuth = UserEntity.loaderWithAuthorizationResults(viewerContext);
|
||||
const rawResults = await userLoaderWithAuth.loadManyByRawWhereClauseAsync('age > ?', [18]);
|
||||
|
||||
// Loader that doesn't use knex methods - should NOT be transformed
|
||||
const standardLoader = PostEntity.loader(viewerContext);
|
||||
const post = await standardLoader.loadByIDAsync('456');
|
||||
|
||||
// Should not transform instance methods or other properties
|
||||
const user = await userLoader.loadByIDAsync('123');
|
||||
const userLoadMethod = user.loader; // This should not be transformed
|
||||
|
||||
// Should not transform lowercase object methods
|
||||
const customLoader = {
|
||||
loader: (ctx: any) => ctx,
|
||||
};
|
||||
customLoader.loader(viewerContext);
|
||||
|
||||
return user;
|
||||
}
|
||||
+37
@@ -0,0 +1,37 @@
|
||||
import { ViewerContext } from '@expo/entity';
|
||||
import { PostEntity } from './entities/PostEntity.ts';
|
||||
import { UserEntity } from './entities/UserEntity.ts';
|
||||
|
||||
async function loadUser(viewerContext: ViewerContext) {
|
||||
// Basic loader calls - only transformed when using knex-specific methods
|
||||
const userLoader = UserEntity.loader(viewerContext);
|
||||
const postLoader = PostEntity.knexLoader(viewerContext);
|
||||
|
||||
// These use knex-specific methods, so they should be transformed
|
||||
const posts = await postLoader.loadManyByFieldEqualityConjunctionAsync([
|
||||
{ fieldName: 'status', fieldValue: 'published' }
|
||||
]);
|
||||
const firstPost = await postLoader.loadFirstByFieldEqualityConjunctionAsync([
|
||||
{ fieldName: 'id', fieldValue: '123' }
|
||||
]);
|
||||
|
||||
// Loader with authorization results - only transformed when using knex methods
|
||||
const userLoaderWithAuth = UserEntity.knexLoaderWithAuthorizationResults(viewerContext);
|
||||
const rawResults = await userLoaderWithAuth.loadManyByRawWhereClauseAsync('age > ?', [18]);
|
||||
|
||||
// Loader that doesn't use knex methods - should NOT be transformed
|
||||
const standardLoader = PostEntity.loader(viewerContext);
|
||||
const post = await standardLoader.loadByIDAsync('456');
|
||||
|
||||
// Should not transform instance methods or other properties
|
||||
const user = await userLoader.loadByIDAsync('123');
|
||||
const userLoadMethod = user.loader; // This should not be transformed
|
||||
|
||||
// Should not transform lowercase object methods
|
||||
const customLoader = {
|
||||
loader: (ctx: any) => ctx,
|
||||
};
|
||||
customLoader.loader(viewerContext);
|
||||
|
||||
return user;
|
||||
}
|
||||
+33
@@ -0,0 +1,33 @@
|
||||
import { ViewerContext } from '@expo/entity';
|
||||
import { CommentEntity } from './entities/CommentEntity.ts';
|
||||
|
||||
// Chained calls
|
||||
const loadComments = async (viewerContext: ViewerContext) => {
|
||||
// Direct chaining with knex-specific method
|
||||
const comments = await CommentEntity.loader(viewerContext)
|
||||
.loadManyByFieldEqualityConjunctionAsync([
|
||||
{ fieldName: 'postId', fieldValue: '123' }
|
||||
]);
|
||||
|
||||
// Direct chaining with regular method - should NOT be transformed
|
||||
const singleComment = await CommentEntity
|
||||
.loader(viewerContext)
|
||||
.loadByIDAsync('456');
|
||||
|
||||
// With authorization results and knex method
|
||||
const commentsWithAuth = await CommentEntity
|
||||
.loaderWithAuthorizationResults(viewerContext)
|
||||
.loadManyByRawWhereClauseAsync('postId = ?', ['456']);
|
||||
|
||||
// Edge cases - these should NOT be transformed
|
||||
const anotherEntity = {
|
||||
loader: (ctx: any) => ctx, // This is not an entity class
|
||||
};
|
||||
anotherEntity.loader(viewerContext); // Should NOT be transformed (lowercase object)
|
||||
|
||||
// Complex chaining with regular method - should NOT be transformed
|
||||
return CommentEntity
|
||||
.loader(viewerContext)
|
||||
.withAuthenticationResults()
|
||||
.loadByIDAsync('789');
|
||||
};
|
||||
+33
@@ -0,0 +1,33 @@
|
||||
import { ViewerContext } from '@expo/entity';
|
||||
import { CommentEntity } from './entities/CommentEntity.ts';
|
||||
|
||||
// Chained calls
|
||||
const loadComments = async (viewerContext: ViewerContext) => {
|
||||
// Direct chaining with knex-specific method
|
||||
const comments = await CommentEntity.knexLoader(viewerContext)
|
||||
.loadManyByFieldEqualityConjunctionAsync([
|
||||
{ fieldName: 'postId', fieldValue: '123' }
|
||||
]);
|
||||
|
||||
// Direct chaining with regular method - should NOT be transformed
|
||||
const singleComment = await CommentEntity
|
||||
.loader(viewerContext)
|
||||
.loadByIDAsync('456');
|
||||
|
||||
// With authorization results and knex method
|
||||
const commentsWithAuth = await CommentEntity
|
||||
.knexLoaderWithAuthorizationResults(viewerContext)
|
||||
.loadManyByRawWhereClauseAsync('postId = ?', ['456']);
|
||||
|
||||
// Edge cases - these should NOT be transformed
|
||||
const anotherEntity = {
|
||||
loader: (ctx: any) => ctx, // This is not an entity class
|
||||
};
|
||||
anotherEntity.loader(viewerContext); // Should NOT be transformed (lowercase object)
|
||||
|
||||
// Complex chaining with regular method - should NOT be transformed
|
||||
return CommentEntity
|
||||
.loader(viewerContext)
|
||||
.withAuthenticationResults()
|
||||
.loadByIDAsync('789');
|
||||
};
|
||||
@@ -0,0 +1,28 @@
|
||||
import { readFile, readdir } from 'fs/promises';
|
||||
import { defineInlineTest } from 'jscodeshift/src/testUtils.js';
|
||||
|
||||
async function readfixtureAsync(mod: string, name: string): Promise<string> {
|
||||
return await readFile(new URL(`../__testfixtures__/${mod}/${name}`, import.meta.url), {
|
||||
encoding: 'utf8',
|
||||
});
|
||||
}
|
||||
|
||||
function prefixes(suffix: string): (names: string[]) => string[] {
|
||||
return (names) => names.filter((n) => n.endsWith(suffix)).map((n) => n.replace(suffix, ''));
|
||||
}
|
||||
|
||||
const modules = await readdir(new URL('../', import.meta.url)).then(prefixes('.ts'));
|
||||
|
||||
for (const mod of modules) {
|
||||
const transform = await import(`../${mod}.ts`);
|
||||
const fixtureDir = new URL(`../__testfixtures__/${mod}`, import.meta.url);
|
||||
const fixtures = await readdir(fixtureDir).then(prefixes('.input.ts'));
|
||||
for (const fixture of fixtures) {
|
||||
defineInlineTest(
|
||||
transform,
|
||||
{},
|
||||
await readfixtureAsync(mod, `${fixture}.input.ts`),
|
||||
await readfixtureAsync(mod, `${fixture}.output.ts`),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,213 @@
|
||||
import type { API, Collection, FileInfo, Options } from 'jscodeshift';
|
||||
|
||||
function transformEnforcingEntityChainMethod(
|
||||
j: API['jscodeshift'],
|
||||
root: Collection<any>,
|
||||
type: 'enforcing' | 'withAuthorizationResults',
|
||||
crudType: 'creator' | 'updater',
|
||||
methodBefore: string,
|
||||
methodAfter: string,
|
||||
): void {
|
||||
// Find all entity creator expressions of the form
|
||||
// `TestEntity.creator(viewerContext).setField('wat', 2).enforceCreateAsync()`
|
||||
root
|
||||
.find(j.CallExpression, {
|
||||
callee: {
|
||||
type: 'MemberExpression',
|
||||
property: {
|
||||
name: methodBefore,
|
||||
},
|
||||
},
|
||||
})
|
||||
.forEach((path) => {
|
||||
const enforceCreateAsyncCallExpression = path.node; // TestEntity.creator(viewerContext).setField('wat', 2).enforceCreateAsync()
|
||||
const enforceCreateAsyncCallee = enforceCreateAsyncCallExpression.callee; // TestEntity.creator(viewerContext).setField('wat', 2).enforceCreateAsync
|
||||
if (enforceCreateAsyncCallee.type !== 'MemberExpression') {
|
||||
return;
|
||||
}
|
||||
|
||||
if (enforceCreateAsyncCallee.property.type !== 'Identifier') {
|
||||
return;
|
||||
}
|
||||
|
||||
// traverse in until we find the first non-setField call
|
||||
let lastCallee = enforceCreateAsyncCallee;
|
||||
while (true) {
|
||||
// TestEntity.creator(viewerContext).setField('wat', 2)
|
||||
const maybeSetFieldObjectCallExpression = lastCallee.object;
|
||||
if (maybeSetFieldObjectCallExpression.type !== 'CallExpression') {
|
||||
break;
|
||||
}
|
||||
|
||||
// TestEntity.creator(viewerContext).setField
|
||||
const maybeSetFieldObjectCallee = maybeSetFieldObjectCallExpression.callee;
|
||||
if (maybeSetFieldObjectCallee.type !== 'MemberExpression') {
|
||||
break;
|
||||
}
|
||||
|
||||
const maybeSetFieldObjectCalleeProperty = maybeSetFieldObjectCallee.property;
|
||||
if (maybeSetFieldObjectCalleeProperty.type !== 'Identifier') {
|
||||
break;
|
||||
}
|
||||
|
||||
if (maybeSetFieldObjectCalleeProperty.name === 'setField') {
|
||||
lastCallee = maybeSetFieldObjectCallee;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// TestEntity.creator(viewerContext)
|
||||
const maybeCreatorCallExpression = lastCallee.object;
|
||||
if (maybeCreatorCallExpression.type !== 'CallExpression') {
|
||||
return;
|
||||
}
|
||||
if (
|
||||
maybeCreatorCallExpression.callee.type !== 'MemberExpression' ||
|
||||
maybeCreatorCallExpression.callee.property.type !== 'Identifier' ||
|
||||
maybeCreatorCallExpression.callee.property.name !== crudType
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
// TestEntity.creator(viewerContext).enforcing()
|
||||
const newCreatorCallExpression = j.callExpression(
|
||||
j.memberExpression(maybeCreatorCallExpression, j.identifier(type)),
|
||||
[],
|
||||
);
|
||||
|
||||
// TestEntity.creator(viewerContext).enforcing().setField('wat', 2).createAsync()
|
||||
lastCallee.object = newCreatorCallExpression;
|
||||
|
||||
// change enforceCreateAsync to createAsync at the end so if this returns early it doesn't mutate
|
||||
enforceCreateAsyncCallee.property.name = methodAfter;
|
||||
});
|
||||
}
|
||||
|
||||
function transformEnforcingEntityDeleteMethod(
|
||||
j: API['jscodeshift'],
|
||||
root: Collection<any>,
|
||||
type: 'enforcing' | 'withAuthorizationResults',
|
||||
methodBefore: string,
|
||||
methodAfter: string,
|
||||
): void {
|
||||
// Find all entity creator expressions of the form
|
||||
// `TestEntity.enforceDeleteAsync(entity)`
|
||||
root
|
||||
.find(j.CallExpression, {
|
||||
callee: {
|
||||
type: 'MemberExpression',
|
||||
property: {
|
||||
name: methodBefore,
|
||||
},
|
||||
},
|
||||
})
|
||||
.forEach((path) => {
|
||||
const enforceDeleteAsyncCallExpression = path.node; // TestEntity.enforceDeleteAsync(entity)
|
||||
const args = enforceDeleteAsyncCallExpression.arguments; // [entity]
|
||||
enforceDeleteAsyncCallExpression.arguments = [];
|
||||
const enforceDeleteAsyncCallee = enforceDeleteAsyncCallExpression.callee; // TestEntity.enforceDeleteAsync
|
||||
if (enforceDeleteAsyncCallee.type !== 'MemberExpression') {
|
||||
return;
|
||||
}
|
||||
|
||||
// change enforceDeleteAsync to deleteAsync
|
||||
if (enforceDeleteAsyncCallee.property.type !== 'Identifier') {
|
||||
return;
|
||||
}
|
||||
enforceDeleteAsyncCallee.property.name = methodAfter;
|
||||
|
||||
enforceDeleteAsyncCallee.object = j.callExpression(
|
||||
j.memberExpression(
|
||||
j.callExpression(
|
||||
j.memberExpression(enforceDeleteAsyncCallee.object, j.identifier('deleter')),
|
||||
args,
|
||||
),
|
||||
j.identifier(type),
|
||||
),
|
||||
[],
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
function transformAssociationLoaderMethod(j: API['jscodeshift'], root: Collection<any>): void {
|
||||
// Find all entity associationLoader expressions of the form
|
||||
// `this.associationLoader()`
|
||||
root
|
||||
.find(j.CallExpression, {
|
||||
callee: {
|
||||
type: 'MemberExpression',
|
||||
property: {
|
||||
name: 'associationLoader',
|
||||
},
|
||||
},
|
||||
})
|
||||
.forEach((path) => {
|
||||
const associationLoaderCallExpression = path.node; // this.associationLoader()
|
||||
|
||||
// replace with this.associationLoader().withAuthorizationResults()
|
||||
path.replace(
|
||||
j.callExpression(
|
||||
j.memberExpression(
|
||||
associationLoaderCallExpression,
|
||||
j.identifier('withAuthorizationResults'),
|
||||
),
|
||||
[],
|
||||
),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
export default function transformer(file: FileInfo, api: API, _options: Options): string {
|
||||
const j = api.jscodeshift;
|
||||
const root = j.withParser('ts')(file.source);
|
||||
|
||||
// do authorization results first since it uses the same detection pattern as enforcing post-transform (i.e. it looks for createAsync)
|
||||
transformEnforcingEntityChainMethod(
|
||||
j,
|
||||
root,
|
||||
'withAuthorizationResults',
|
||||
'creator',
|
||||
'createAsync',
|
||||
'createAsync',
|
||||
);
|
||||
transformEnforcingEntityChainMethod(
|
||||
j,
|
||||
root,
|
||||
'withAuthorizationResults',
|
||||
'updater',
|
||||
'updateAsync',
|
||||
'updateAsync',
|
||||
);
|
||||
transformEnforcingEntityDeleteMethod(
|
||||
j,
|
||||
root,
|
||||
'withAuthorizationResults',
|
||||
'deleteAsync',
|
||||
'deleteAsync',
|
||||
);
|
||||
|
||||
// now do enforcing
|
||||
transformEnforcingEntityChainMethod(
|
||||
j,
|
||||
root,
|
||||
'enforcing',
|
||||
'creator',
|
||||
'enforceCreateAsync',
|
||||
'createAsync',
|
||||
);
|
||||
transformEnforcingEntityChainMethod(
|
||||
j,
|
||||
root,
|
||||
'enforcing',
|
||||
'updater',
|
||||
'enforceUpdateAsync',
|
||||
'updateAsync',
|
||||
);
|
||||
transformEnforcingEntityDeleteMethod(j, root, 'enforcing', 'enforceDeleteAsync', 'deleteAsync');
|
||||
|
||||
// association loader
|
||||
transformAssociationLoaderMethod(j, root);
|
||||
|
||||
return root.toSource();
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import type { API, Collection, FileInfo, Options } from 'jscodeshift';
|
||||
|
||||
function transformWithAuthorizationResultsEntityChainMethod(
|
||||
j: API['jscodeshift'],
|
||||
root: Collection<any>,
|
||||
): void {
|
||||
// Find all entity expressions of the form
|
||||
// `TestEntity.thing(viewerContext).withAuthorizationResults()`
|
||||
root
|
||||
.find(j.CallExpression, {
|
||||
callee: {
|
||||
type: 'MemberExpression',
|
||||
property: {
|
||||
name: 'withAuthorizationResults',
|
||||
},
|
||||
},
|
||||
})
|
||||
.forEach((path) => {
|
||||
const withAuthorizationResultsCallExpression = path.node; // TestEntity.thing(viewerContext).withAuthorizationResults()
|
||||
const withAuthorizationResultsCallee = withAuthorizationResultsCallExpression.callee; // TestEntity.thing(viewerContext).withAuthorizationResults
|
||||
if (withAuthorizationResultsCallee.type !== 'MemberExpression') {
|
||||
return;
|
||||
}
|
||||
|
||||
const thingCallExpression = withAuthorizationResultsCallee.object; // TestEntity.thing(viewerContext)
|
||||
if (thingCallExpression.type !== 'CallExpression') {
|
||||
return;
|
||||
}
|
||||
const thingCallee = thingCallExpression.callee; // TestEntity.thing
|
||||
if (thingCallee.type !== 'MemberExpression') {
|
||||
return;
|
||||
}
|
||||
|
||||
if (thingCallee.property.type !== 'Identifier') {
|
||||
return;
|
||||
}
|
||||
|
||||
const newCreatorCalleeName = thingCallee.property.name + 'WithAuthorizationResults';
|
||||
thingCallee.property.name = newCreatorCalleeName;
|
||||
|
||||
path.replace(thingCallExpression);
|
||||
});
|
||||
}
|
||||
|
||||
function transformEnforcingEntityChainMethod(j: API['jscodeshift'], root: Collection<any>): void {
|
||||
// Find all entity expressions of the form
|
||||
// `TestEntity.thing(viewerContext).enforcing()`
|
||||
root
|
||||
.find(j.CallExpression, {
|
||||
callee: {
|
||||
type: 'MemberExpression',
|
||||
property: {
|
||||
name: 'enforcing',
|
||||
},
|
||||
},
|
||||
})
|
||||
.forEach((path) => {
|
||||
const enforcingCallExpression = path.node; // TestEntity.thing(viewerContext).enforcing()
|
||||
const enforcingCallee = enforcingCallExpression.callee; // TestEntity.thing(viewerContext).enforcing
|
||||
if (enforcingCallee.type !== 'MemberExpression') {
|
||||
return;
|
||||
}
|
||||
|
||||
const thingCallExpression = enforcingCallee.object; // TestEntity.thing(viewerContext)
|
||||
if (thingCallExpression.type !== 'CallExpression') {
|
||||
return;
|
||||
}
|
||||
|
||||
path.replace(thingCallExpression);
|
||||
});
|
||||
}
|
||||
|
||||
export default function transformer(file: FileInfo, api: API, _options: Options): string {
|
||||
const j = api.jscodeshift;
|
||||
const root = j.withParser('ts')(file.source);
|
||||
|
||||
transformWithAuthorizationResultsEntityChainMethod(j, root);
|
||||
transformEnforcingEntityChainMethod(j, root);
|
||||
|
||||
return root.toSource();
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
import type { API, Collection, FileInfo, Options } from 'jscodeshift';
|
||||
|
||||
const KNEX_SPECIFIC_METHODS = [
|
||||
'loadFirstByFieldEqualityConjunctionAsync',
|
||||
'loadManyByFieldEqualityConjunctionAsync',
|
||||
'loadManyByRawWhereClauseAsync',
|
||||
];
|
||||
|
||||
function isKnexSpecificMethodUsed(j: API['jscodeshift'], node: any): boolean {
|
||||
// Check if this loader call is followed by a knex-specific method
|
||||
// We need to traverse the AST to find usages of the loader result
|
||||
const parent = node.parent;
|
||||
|
||||
// Check if the loader call is directly chained with a knex method
|
||||
if (parent?.value.type === 'MemberExpression' && parent.value.object === node.value) {
|
||||
const grandParent = parent.parent;
|
||||
if (grandParent?.value.type === 'CallExpression' && grandParent.value.callee === parent.value) {
|
||||
if (
|
||||
parent.value.property.type === 'Identifier' &&
|
||||
KNEX_SPECIFIC_METHODS.includes(parent.value.property.name)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check if the loader is assigned to a variable and then used with knex methods
|
||||
if (parent?.value.type === 'VariableDeclarator' && parent.value.init === node.value) {
|
||||
const variableName = parent.value.id.name;
|
||||
const scope = parent.scope;
|
||||
|
||||
// Find all references to this variable in the same scope
|
||||
const references = j(scope.path)
|
||||
.find(j.Identifier, { name: variableName })
|
||||
.filter((path) => {
|
||||
// Check if this identifier is used as object in member expression
|
||||
const parentNode = path.parent.value;
|
||||
if (parentNode.type === 'MemberExpression' && parentNode.object === path.value) {
|
||||
const prop = parentNode.property;
|
||||
if (prop.type === 'Identifier' && KNEX_SPECIFIC_METHODS.includes(prop.name)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
});
|
||||
|
||||
if (references.size() > 0) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// Check await expressions
|
||||
if (parent?.value.type === 'AwaitExpression' && parent.value.argument === node.value) {
|
||||
const awaitParent = parent.parent;
|
||||
if (awaitParent?.value.type === 'VariableDeclarator') {
|
||||
const variableName = awaitParent.value.id.name;
|
||||
const scope = awaitParent.scope;
|
||||
|
||||
// Find all references to this variable in the same scope
|
||||
const references = j(scope.path)
|
||||
.find(j.Identifier, { name: variableName })
|
||||
.filter((path) => {
|
||||
const parentNode = path.parent.value;
|
||||
if (parentNode.type === 'MemberExpression' && parentNode.object === path.value) {
|
||||
const prop = parentNode.property;
|
||||
if (prop.type === 'Identifier' && KNEX_SPECIFIC_METHODS.includes(prop.name)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
});
|
||||
|
||||
if (references.size() > 0) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
function transformLoaderToKnexLoader(j: API['jscodeshift'], root: Collection<any>): void {
|
||||
// Find all entity expressions of the form `Entity.loader(viewerContext)`
|
||||
root
|
||||
.find(j.CallExpression, {
|
||||
callee: {
|
||||
type: 'MemberExpression',
|
||||
property: {
|
||||
name: 'loader',
|
||||
},
|
||||
},
|
||||
})
|
||||
.forEach((path) => {
|
||||
const loaderCallExpression = path.node; // Entity.loader(viewerContext)
|
||||
const loaderCallee = loaderCallExpression.callee; // Entity.loader
|
||||
|
||||
if (loaderCallee.type !== 'MemberExpression') {
|
||||
return;
|
||||
}
|
||||
|
||||
// Make sure this is a static method call on an entity (not on an instance)
|
||||
// Typically entity names start with uppercase letter
|
||||
if (loaderCallee.object.type === 'Identifier') {
|
||||
const firstChar = loaderCallee.object.name[0];
|
||||
if (firstChar === firstChar?.toUpperCase()) {
|
||||
// Check if this loader uses knex-specific methods
|
||||
if (isKnexSpecificMethodUsed(j, path)) {
|
||||
// Rename loader to knexLoader
|
||||
if (loaderCallee.property.type === 'Identifier') {
|
||||
loaderCallee.property.name = 'knexLoader';
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function transformLoaderWithAuthorizationResultsToKnexLoaderWithAuthorizationResults(
|
||||
j: API['jscodeshift'],
|
||||
root: Collection<any>,
|
||||
): void {
|
||||
// Find all entity expressions of the form `Entity.loaderWithAuthorizationResults(viewerContext)`
|
||||
root
|
||||
.find(j.CallExpression, {
|
||||
callee: {
|
||||
type: 'MemberExpression',
|
||||
property: {
|
||||
name: 'loaderWithAuthorizationResults',
|
||||
},
|
||||
},
|
||||
})
|
||||
.forEach((path) => {
|
||||
const loaderCallExpression = path.node; // Entity.loaderWithAuthorizationResults(viewerContext)
|
||||
const loaderCallee = loaderCallExpression.callee; // Entity.loaderWithAuthorizationResults
|
||||
|
||||
if (loaderCallee.type !== 'MemberExpression') {
|
||||
return;
|
||||
}
|
||||
|
||||
// Make sure this is a static method call on an entity (not on an instance)
|
||||
// Typically entity names start with uppercase letter
|
||||
if (loaderCallee.object.type === 'Identifier') {
|
||||
const firstChar = loaderCallee.object.name[0];
|
||||
if (firstChar === firstChar?.toUpperCase()) {
|
||||
// Check if this loader uses knex-specific methods
|
||||
if (isKnexSpecificMethodUsed(j, path)) {
|
||||
// Rename loaderWithAuthorizationResults to knexLoaderWithAuthorizationResults
|
||||
if (loaderCallee.property.type === 'Identifier') {
|
||||
loaderCallee.property.name = 'knexLoaderWithAuthorizationResults';
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export default function transformer(file: FileInfo, api: API, _options: Options): string {
|
||||
const j = api.jscodeshift;
|
||||
const root = j.withParser('ts')(file.source);
|
||||
|
||||
transformLoaderToKnexLoader(j, root);
|
||||
transformLoaderWithAuthorizationResultsToKnexLoaderWithAuthorizationResults(j, root);
|
||||
|
||||
return root.toSource();
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["**/__testfixtures__/**"]
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
# Change Log
|
||||
|
||||
All notable changes to this project will be documented in this file.
|
||||
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
|
||||
|
||||
# [0.62.0](https://github.com/expo/entity/compare/v0.61.0...v0.62.0) (2026-03-16)
|
||||
|
||||
**Note:** Version bump only for package @expo/entity-database-adapter-knex-testing-utils
|
||||
|
||||
# [0.61.0](https://github.com/expo/entity/compare/v0.60.0...v0.61.0) (2026-03-12)
|
||||
|
||||
**Note:** Version bump only for package @expo/entity-database-adapter-knex-testing-utils
|
||||
|
||||
# [0.60.0](https://github.com/expo/entity/compare/v0.59.0...v0.60.0) (2026-03-06)
|
||||
|
||||
**Note:** Version bump only for package @expo/entity-database-adapter-knex-testing-utils
|
||||
|
||||
# [0.59.0](https://github.com/expo/entity/compare/v0.58.0...v0.59.0) (2026-03-03)
|
||||
|
||||
**Note:** Version bump only for package @expo/entity-database-adapter-knex-testing-utils
|
||||
|
||||
# [0.58.0](https://github.com/expo/entity/compare/v0.57.0...v0.58.0) (2026-03-02)
|
||||
|
||||
**Note:** Version bump only for package @expo/entity-database-adapter-knex-testing-utils
|
||||
|
||||
# [0.57.0](https://github.com/expo/entity/compare/v0.56.0...v0.57.0) (2026-02-28)
|
||||
|
||||
**Note:** Version bump only for package @expo/entity-database-adapter-knex-testing-utils
|
||||
|
||||
# [0.56.0](https://github.com/expo/entity/compare/v0.55.0...v0.56.0) (2026-02-28)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- move EntityPrivacyUtils back into core package ([#429](https://github.com/expo/entity/issues/429)) ([56ec27d](https://github.com/expo/entity/commit/56ec27d5f201d83930a2cc05dfc380a054bff1e1))
|
||||
- pass in augmentable classes to installation ([#437](https://github.com/expo/entity/issues/437)) ([f5c728b](https://github.com/expo/entity/commit/f5c728be565e01c0a8ace9ef51f6de8f49184dab)), closes [/github.com/expo/entity/pull/410#pullrequestreview-3765893898](https://github.com//github.com/expo/entity/pull/410/issues/pullrequestreview-3765893898)
|
||||
- refactor install method to instead be free functions ([#441](https://github.com/expo/entity/issues/441)) ([51d632d](https://github.com/expo/entity/commit/51d632da1619dc039d5c41ce6533f63254ec4337)), closes [#410](https://github.com/expo/entity/issues/410)
|
||||
|
||||
### Features
|
||||
|
||||
- add entity-database-adapter-knex-testing-utils containing StubPostgresDatabaseAdapter ([#412](https://github.com/expo/entity/issues/412)) ([eeccf59](https://github.com/expo/entity/commit/eeccf594c11bf090aa83bbf71fb4ead8668aec7d))
|
||||
- add entityField SQL helper ([#481](https://github.com/expo/entity/issues/481)) ([459a5ff](https://github.com/expo/entity/commit/459a5ff85014a8c7396c0856dffacb1491ccb963))
|
||||
- Add raw sqlfragment orderBy capability for pagination ([#457](https://github.com/expo/entity/issues/457)) ([0615ffa](https://github.com/expo/entity/commit/0615ffacffbe9e903a9bf03b171c102f0554b992))
|
||||
- add support for nulls ordering in order by clauses ([#468](https://github.com/expo/entity/issues/468)) ([6c0838d](https://github.com/expo/entity/commit/6c0838dc577a3b6190e3551ee834925d7bd53bc6))
|
||||
@@ -0,0 +1,3 @@
|
||||
# @expo/entity-database-adapter-knex-testing-utils
|
||||
|
||||
Testing utilities for applications using Entity with Knex database adapter.
|
||||
@@ -0,0 +1,45 @@
|
||||
{
|
||||
"name": "@expo/entity-database-adapter-knex-testing-utils",
|
||||
"version": "0.63.0",
|
||||
"description": "Testing utilities for applications using Entity with Knex database adapter",
|
||||
"files": [
|
||||
"build",
|
||||
"!*.tsbuildinfo",
|
||||
"!__*",
|
||||
"src"
|
||||
],
|
||||
"main": "build/src/index.js",
|
||||
"types": "build/src/index.d.ts",
|
||||
"scripts": {
|
||||
"build": "tsc --build",
|
||||
"prepack": "rm -rf build && yarn build",
|
||||
"clean": "yarn build --clean",
|
||||
"lint": "yarn run --top-level eslint src",
|
||||
"lint-fix": "yarn run lint --fix",
|
||||
"test": "yarn test:all --rootDir $(pwd)"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"keywords": [
|
||||
"entity"
|
||||
],
|
||||
"author": "Expo",
|
||||
"license": "MIT",
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
"@expo/entity": "workspace:^",
|
||||
"@expo/entity-database-adapter-knex": "workspace:^",
|
||||
"invariant": "^2.2.4",
|
||||
"uuid": "^13.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@jest/globals": "*"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@jest/globals": "30.3.0",
|
||||
"@types/invariant": "2.2.37",
|
||||
"@types/node": "24.12.0",
|
||||
"typescript": "5.9.3"
|
||||
}
|
||||
}
|
||||
+134
-76
@@ -1,37 +1,49 @@
|
||||
import invariant from 'invariant';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
|
||||
import EntityConfiguration from '../../EntityConfiguration';
|
||||
import EntityDatabaseAdapter, {
|
||||
TableFieldSingleValueEqualityCondition,
|
||||
TableFieldMultiValueEqualityCondition,
|
||||
TableQuerySelectionModifiers,
|
||||
OrderByOrdering,
|
||||
} from '../../EntityDatabaseAdapter';
|
||||
import { StringField, IntField } from '../../EntityFields';
|
||||
import type { EntityConfiguration, FieldTransformerMap } from '@expo/entity';
|
||||
import {
|
||||
computeIfAbsent,
|
||||
getDatabaseFieldForEntityField,
|
||||
FieldTransformerMap,
|
||||
IntField,
|
||||
mapMap,
|
||||
StringField,
|
||||
transformFieldsToDatabaseObject,
|
||||
} from '../../internal/EntityFieldTransformationUtils';
|
||||
import { computeIfAbsent, mapMap } from '../collections/maps';
|
||||
} from '@expo/entity';
|
||||
import type {
|
||||
SQLFragment,
|
||||
TableFieldMultiValueEqualityCondition,
|
||||
TableFieldSingleValueEqualityCondition,
|
||||
TableOrderByClause,
|
||||
TableQuerySelectionModifiers,
|
||||
} from '@expo/entity-database-adapter-knex';
|
||||
import {
|
||||
BasePostgresEntityDatabaseAdapter,
|
||||
NullsOrdering,
|
||||
OrderByOrdering,
|
||||
} from '@expo/entity-database-adapter-knex';
|
||||
import invariant from 'invariant';
|
||||
import { v7 as uuidv7 } from 'uuid';
|
||||
|
||||
export default class StubDatabaseAdapter<T> extends EntityDatabaseAdapter<T> {
|
||||
export class StubPostgresDatabaseAdapter<
|
||||
TFields extends Record<string, any>,
|
||||
TIDField extends keyof TFields,
|
||||
> extends BasePostgresEntityDatabaseAdapter<TFields, TIDField> {
|
||||
constructor(
|
||||
private readonly entityConfiguration2: EntityConfiguration<T>,
|
||||
private readonly dataStore: Map<string, Readonly<{ [key: string]: any }>[]>
|
||||
private readonly entityConfiguration2: EntityConfiguration<TFields, TIDField>,
|
||||
private readonly dataStore: Map<string, Readonly<{ [key: string]: any }>[]>,
|
||||
) {
|
||||
super(entityConfiguration2);
|
||||
}
|
||||
|
||||
public static convertFieldObjectsToDataStore<T>(
|
||||
entityConfiguration: EntityConfiguration<T>,
|
||||
dataStore: Map<string, Readonly<T>[]>
|
||||
public static convertFieldObjectsToDataStore<
|
||||
TFields extends Record<string, any>,
|
||||
TIDField extends keyof TFields,
|
||||
>(
|
||||
entityConfiguration: EntityConfiguration<TFields, TIDField>,
|
||||
dataStore: Map<string, Readonly<TFields>[]>,
|
||||
): Map<string, Readonly<{ [key: string]: any }>[]> {
|
||||
return mapMap(dataStore, (objectsForTable) =>
|
||||
objectsForTable.map((objectForTable) =>
|
||||
transformFieldsToDatabaseObject(entityConfiguration, new Map(), objectForTable)
|
||||
)
|
||||
transformFieldsToDatabaseObject(entityConfiguration, new Map(), objectForTable),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -43,69 +55,103 @@ export default class StubDatabaseAdapter<T> extends EntityDatabaseAdapter<T> {
|
||||
return new Map();
|
||||
}
|
||||
|
||||
private static uniqBy<T>(a: T[], keyExtractor: (k: T) => string): T[] {
|
||||
const seen = new Set();
|
||||
return a.filter((item) => {
|
||||
const k = keyExtractor(item);
|
||||
if (seen.has(k)) {
|
||||
return false;
|
||||
}
|
||||
seen.add(k);
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
protected async fetchManyWhereInternalAsync(
|
||||
_queryInterface: any,
|
||||
tableName: string,
|
||||
tableField: string,
|
||||
tableValues: readonly any[]
|
||||
tableColumns: readonly string[],
|
||||
tableTuples: (readonly any[])[],
|
||||
): Promise<object[]> {
|
||||
const objectCollection = this.getObjectCollectionForTable(tableName);
|
||||
return tableValues.reduce((acc, fieldValue) => {
|
||||
return acc.concat(
|
||||
objectCollection.filter((obj) => {
|
||||
return obj[tableField] === fieldValue;
|
||||
})
|
||||
);
|
||||
}, [] as { [key: string]: any });
|
||||
const results = StubPostgresDatabaseAdapter.uniqBy(tableTuples, (tuple) =>
|
||||
tuple.join(':'),
|
||||
).reduce(
|
||||
(acc, tableTuple) => {
|
||||
return acc.concat(
|
||||
objectCollection.filter((obj) => {
|
||||
return tableColumns.every((tableColumn, index) => {
|
||||
return obj[tableColumn] === tableTuple[index];
|
||||
});
|
||||
}),
|
||||
);
|
||||
},
|
||||
[] as { [key: string]: any }[],
|
||||
);
|
||||
return [...results];
|
||||
}
|
||||
|
||||
private static compareByOrderBys(
|
||||
orderBys: {
|
||||
columnName: string;
|
||||
order: OrderByOrdering;
|
||||
}[],
|
||||
protected async fetchOneWhereInternalAsync(
|
||||
queryInterface: any,
|
||||
tableName: string,
|
||||
tableColumns: readonly string[],
|
||||
tableTuple: readonly any[],
|
||||
): Promise<object | null> {
|
||||
const results = await this.fetchManyWhereInternalAsync(
|
||||
queryInterface,
|
||||
tableName,
|
||||
tableColumns,
|
||||
[tableTuple],
|
||||
);
|
||||
return results[0] ?? null;
|
||||
}
|
||||
|
||||
private static compareByOrderBys<TFields extends Record<string, any>>(
|
||||
orderBys: TableOrderByClause<TFields>[],
|
||||
objectA: { [key: string]: any },
|
||||
objectB: { [key: string]: any }
|
||||
objectB: { [key: string]: any },
|
||||
): 0 | 1 | -1 {
|
||||
if (orderBys.length === 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const currentOrderBy = orderBys[0]!;
|
||||
if (!('columnName' in currentOrderBy)) {
|
||||
throw new Error('SQL fragment order by not supported for StubDatabaseAdapter');
|
||||
}
|
||||
const aField = objectA[currentOrderBy.columnName];
|
||||
const bField = objectB[currentOrderBy.columnName];
|
||||
|
||||
// Determine effective nulls ordering:
|
||||
// - If explicitly set, use that
|
||||
// - Otherwise use PostgreSQL defaults: NULLS LAST for ASC, NULLS FIRST for DESC
|
||||
const nullsFirst =
|
||||
currentOrderBy.nulls !== undefined
|
||||
? currentOrderBy.nulls === NullsOrdering.FIRST
|
||||
: currentOrderBy.order === OrderByOrdering.DESCENDING;
|
||||
|
||||
if (aField === null && bField === null) {
|
||||
return this.compareByOrderBys(orderBys.slice(1), objectA, objectB);
|
||||
} else if (aField === null) {
|
||||
return nullsFirst ? -1 : 1;
|
||||
} else if (bField === null) {
|
||||
return nullsFirst ? 1 : -1;
|
||||
}
|
||||
|
||||
switch (currentOrderBy.order) {
|
||||
case OrderByOrdering.DESCENDING: {
|
||||
// simulate NULLS FIRST for DESC
|
||||
if (aField === null && bField === null) {
|
||||
return 0;
|
||||
} else if (aField === null) {
|
||||
return -1;
|
||||
} else if (bField === null) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
return aField > bField
|
||||
? -1
|
||||
: aField < bField
|
||||
? 1
|
||||
: this.compareByOrderBys(orderBys.slice(1), objectA, objectB);
|
||||
? 1
|
||||
: this.compareByOrderBys(orderBys.slice(1), objectA, objectB);
|
||||
}
|
||||
case OrderByOrdering.ASCENDING: {
|
||||
// simulate NULLS LAST for ASC
|
||||
if (aField === null && bField === null) {
|
||||
return 0;
|
||||
} else if (bField === null) {
|
||||
return -1;
|
||||
} else if (aField === null) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
return bField > aField
|
||||
? -1
|
||||
: bField < aField
|
||||
? 1
|
||||
: this.compareByOrderBys(orderBys.slice(1), objectA, objectB);
|
||||
? 1
|
||||
: this.compareByOrderBys(orderBys.slice(1), objectA, objectB);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -115,7 +161,7 @@ export default class StubDatabaseAdapter<T> extends EntityDatabaseAdapter<T> {
|
||||
tableName: string,
|
||||
tableFieldSingleValueEqualityOperands: TableFieldSingleValueEqualityCondition[],
|
||||
tableFieldMultiValueEqualityOperands: TableFieldMultiValueEqualityCondition[],
|
||||
querySelectionModifiers: TableQuerySelectionModifiers
|
||||
querySelectionModifiers: TableQuerySelectionModifiers<TFields>,
|
||||
): Promise<object[]> {
|
||||
let filteredObjects = this.getObjectCollectionForTable(tableName);
|
||||
for (const { tableField, tableValue } of tableFieldSingleValueEqualityOperands) {
|
||||
@@ -129,7 +175,7 @@ export default class StubDatabaseAdapter<T> extends EntityDatabaseAdapter<T> {
|
||||
const orderBy = querySelectionModifiers.orderBy;
|
||||
if (orderBy !== undefined) {
|
||||
filteredObjects = filteredObjects.sort((a, b) =>
|
||||
StubDatabaseAdapter.compareByOrderBys(orderBy, a, b)
|
||||
StubPostgresDatabaseAdapter.compareByOrderBys(orderBy, a, b),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -146,26 +192,28 @@ export default class StubDatabaseAdapter<T> extends EntityDatabaseAdapter<T> {
|
||||
return filteredObjects;
|
||||
}
|
||||
|
||||
protected fetchManyByRawWhereClauseInternalAsync(
|
||||
protected fetchManyBySQLFragmentInternalAsync(
|
||||
_queryInterface: any,
|
||||
_tableName: string,
|
||||
_rawWhereClause: string,
|
||||
_bindings: object | any[],
|
||||
_querySelectionModifiers: TableQuerySelectionModifiers
|
||||
_sqlFragment: SQLFragment<TFields>,
|
||||
_querySelectionModifiers: TableQuerySelectionModifiers<TFields>,
|
||||
): Promise<object[]> {
|
||||
throw new Error('Raw WHERE clauses not supported for StubDatabaseAdapter');
|
||||
throw new Error('SQL fragments not supported for StubDatabaseAdapter');
|
||||
}
|
||||
|
||||
private generateRandomID(): any {
|
||||
const idSchemaField = this.entityConfiguration2.schema.get(this.entityConfiguration2.idField);
|
||||
invariant(idSchemaField, `No schema field found for ${this.entityConfiguration2.idField}`);
|
||||
invariant(
|
||||
idSchemaField,
|
||||
`No schema field found for ${String(this.entityConfiguration2.idField)}`,
|
||||
);
|
||||
if (idSchemaField instanceof StringField) {
|
||||
return uuidv4();
|
||||
return uuidv7();
|
||||
} else if (idSchemaField instanceof IntField) {
|
||||
return Math.floor(Math.random() * Number.MAX_SAFE_INTEGER);
|
||||
} else {
|
||||
throw new Error(
|
||||
`Unsupported ID type for StubDatabaseAdapter: ${idSchemaField.constructor.name}`
|
||||
`Unsupported ID type for StubPostgresDatabaseAdapter: ${idSchemaField.constructor.name}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -173,13 +221,13 @@ export default class StubDatabaseAdapter<T> extends EntityDatabaseAdapter<T> {
|
||||
protected async insertInternalAsync(
|
||||
_queryInterface: any,
|
||||
tableName: string,
|
||||
object: object
|
||||
object: object,
|
||||
): Promise<object[]> {
|
||||
const objectCollection = this.getObjectCollectionForTable(tableName);
|
||||
|
||||
const idField = getDatabaseFieldForEntityField(
|
||||
this.entityConfiguration2,
|
||||
this.entityConfiguration2.idField
|
||||
this.entityConfiguration2.idField,
|
||||
);
|
||||
const objectToInsert = {
|
||||
[idField]: this.generateRandomID(),
|
||||
@@ -194,8 +242,8 @@ export default class StubDatabaseAdapter<T> extends EntityDatabaseAdapter<T> {
|
||||
tableName: string,
|
||||
tableIdField: string,
|
||||
id: any,
|
||||
object: object
|
||||
): Promise<object[]> {
|
||||
object: object,
|
||||
): Promise<{ updatedRowCount: number }> {
|
||||
// SQL does not support empty updates, mirror behavior here for better test simulation
|
||||
if (Object.keys(object).length === 0) {
|
||||
throw new Error(`Empty update (${tableIdField} = ${id})`);
|
||||
@@ -206,28 +254,38 @@ export default class StubDatabaseAdapter<T> extends EntityDatabaseAdapter<T> {
|
||||
const objectIndex = objectCollection.findIndex((obj) => {
|
||||
return obj[tableIdField] === id;
|
||||
});
|
||||
invariant(objectIndex >= 0, 'should exist');
|
||||
|
||||
// SQL updates to a nonexistent row succeed but affect 0 rows,
|
||||
// mirror that behavior here for better test simulation
|
||||
if (objectIndex < 0) {
|
||||
return { updatedRowCount: 0 };
|
||||
}
|
||||
|
||||
objectCollection[objectIndex] = {
|
||||
...objectCollection[objectIndex],
|
||||
...object,
|
||||
};
|
||||
return [objectCollection[objectIndex]!];
|
||||
return { updatedRowCount: 1 };
|
||||
}
|
||||
|
||||
protected async deleteInternalAsync(
|
||||
_queryInterface: any,
|
||||
tableName: string,
|
||||
tableIdField: string,
|
||||
id: any
|
||||
id: any,
|
||||
): Promise<number> {
|
||||
const objectCollection = this.getObjectCollectionForTable(tableName);
|
||||
|
||||
const objectIndex = objectCollection.findIndex((obj) => {
|
||||
return obj[tableIdField] === id;
|
||||
});
|
||||
|
||||
// SQL deletes to a nonexistent row succeed and affect 0 rows,
|
||||
// mirror that behavior here for better test simulation
|
||||
if (objectIndex < 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
objectCollection.splice(objectIndex, 1);
|
||||
return 1;
|
||||
}
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
import type {
|
||||
EntityConfiguration,
|
||||
EntityDatabaseAdapter,
|
||||
IEntityDatabaseAdapterProvider,
|
||||
} from '@expo/entity';
|
||||
|
||||
import { StubPostgresDatabaseAdapter } from './StubPostgresDatabaseAdapter.ts';
|
||||
|
||||
export class StubPostgresDatabaseAdapterProvider implements IEntityDatabaseAdapterProvider {
|
||||
private readonly objectCollection = new Map();
|
||||
|
||||
getDatabaseAdapter<TFields extends Record<string, any>, TIDField extends keyof TFields>(
|
||||
entityConfiguration: EntityConfiguration<TFields, TIDField>,
|
||||
): EntityDatabaseAdapter<TFields, TIDField> {
|
||||
return new StubPostgresDatabaseAdapter(entityConfiguration, this.objectCollection);
|
||||
}
|
||||
}
|
||||
+61
@@ -0,0 +1,61 @@
|
||||
import type { EntityCompanionDefinition, ViewerContext } from '@expo/entity';
|
||||
import {
|
||||
AlwaysAllowPrivacyPolicyRule,
|
||||
DateField,
|
||||
Entity,
|
||||
EntityConfiguration,
|
||||
EntityPrivacyPolicy,
|
||||
} from '@expo/entity';
|
||||
|
||||
export type DateIDTestFields = {
|
||||
id: Date;
|
||||
};
|
||||
|
||||
export const dateIDTestEntityConfiguration = new EntityConfiguration<DateIDTestFields, 'id'>({
|
||||
idField: 'id',
|
||||
tableName: 'simple_test_entity_should_not_write_to_db',
|
||||
schema: {
|
||||
id: new DateField({
|
||||
columnName: 'custom_id',
|
||||
cache: true,
|
||||
}),
|
||||
},
|
||||
databaseAdapterFlavor: 'postgres',
|
||||
cacheAdapterFlavor: 'redis',
|
||||
});
|
||||
|
||||
export class DateIDTestEntityPrivacyPolicy extends EntityPrivacyPolicy<
|
||||
DateIDTestFields,
|
||||
'id',
|
||||
ViewerContext,
|
||||
DateIDTestEntity
|
||||
> {
|
||||
protected override readonly readRules = [
|
||||
new AlwaysAllowPrivacyPolicyRule<DateIDTestFields, 'id', ViewerContext, DateIDTestEntity>(),
|
||||
];
|
||||
protected override readonly createRules = [
|
||||
new AlwaysAllowPrivacyPolicyRule<DateIDTestFields, 'id', ViewerContext, DateIDTestEntity>(),
|
||||
];
|
||||
protected override readonly updateRules = [
|
||||
new AlwaysAllowPrivacyPolicyRule<DateIDTestFields, 'id', ViewerContext, DateIDTestEntity>(),
|
||||
];
|
||||
protected override readonly deleteRules = [
|
||||
new AlwaysAllowPrivacyPolicyRule<DateIDTestFields, 'id', ViewerContext, DateIDTestEntity>(),
|
||||
];
|
||||
}
|
||||
|
||||
export class DateIDTestEntity extends Entity<DateIDTestFields, 'id', ViewerContext> {
|
||||
static defineCompanionDefinition(): EntityCompanionDefinition<
|
||||
DateIDTestFields,
|
||||
'id',
|
||||
ViewerContext,
|
||||
DateIDTestEntity,
|
||||
DateIDTestEntityPrivacyPolicy
|
||||
> {
|
||||
return {
|
||||
entityClass: DateIDTestEntity,
|
||||
entityConfiguration: dateIDTestEntityConfiguration,
|
||||
privacyPolicyClass: DateIDTestEntityPrivacyPolicy,
|
||||
};
|
||||
}
|
||||
}
|
||||
+24
-24
@@ -1,10 +1,11 @@
|
||||
import Entity from '../Entity';
|
||||
import { EntityCompanionDefinition } from '../EntityCompanionProvider';
|
||||
import EntityConfiguration from '../EntityConfiguration';
|
||||
import { UUIDField } from '../EntityFields';
|
||||
import EntityPrivacyPolicy from '../EntityPrivacyPolicy';
|
||||
import ViewerContext from '../ViewerContext';
|
||||
import AlwaysAllowPrivacyPolicyRule from '../rules/AlwaysAllowPrivacyPolicyRule';
|
||||
import type { EntityCompanionDefinition, ViewerContext } from '@expo/entity';
|
||||
import {
|
||||
AlwaysAllowPrivacyPolicyRule,
|
||||
Entity,
|
||||
EntityConfiguration,
|
||||
EntityPrivacyPolicy,
|
||||
UUIDField,
|
||||
} from '@expo/entity';
|
||||
|
||||
export type SimpleTestFields = {
|
||||
id: string;
|
||||
@@ -12,12 +13,13 @@ export type SimpleTestFields = {
|
||||
|
||||
export type SimpleTestFieldSelection = keyof SimpleTestFields;
|
||||
|
||||
export const simpleTestEntityConfiguration = new EntityConfiguration<SimpleTestFields>({
|
||||
export const simpleTestEntityConfiguration = new EntityConfiguration<SimpleTestFields, 'id'>({
|
||||
idField: 'id',
|
||||
tableName: 'simple_test_entity_should_not_write_to_db',
|
||||
schema: {
|
||||
id: new UUIDField({
|
||||
columnName: 'custom_id',
|
||||
cache: true,
|
||||
}),
|
||||
},
|
||||
databaseAdapterFlavor: 'postgres',
|
||||
@@ -26,7 +28,7 @@ export const simpleTestEntityConfiguration = new EntityConfiguration<SimpleTestF
|
||||
|
||||
export class SimpleTestEntityPrivacyPolicy extends EntityPrivacyPolicy<
|
||||
SimpleTestFields,
|
||||
string,
|
||||
'id',
|
||||
ViewerContext,
|
||||
SimpleTestEntity,
|
||||
SimpleTestFieldSelection
|
||||
@@ -34,7 +36,7 @@ export class SimpleTestEntityPrivacyPolicy extends EntityPrivacyPolicy<
|
||||
protected override readonly readRules = [
|
||||
new AlwaysAllowPrivacyPolicyRule<
|
||||
SimpleTestFields,
|
||||
string,
|
||||
'id',
|
||||
ViewerContext,
|
||||
SimpleTestEntity,
|
||||
SimpleTestFieldSelection
|
||||
@@ -43,7 +45,7 @@ export class SimpleTestEntityPrivacyPolicy extends EntityPrivacyPolicy<
|
||||
protected override readonly createRules = [
|
||||
new AlwaysAllowPrivacyPolicyRule<
|
||||
SimpleTestFields,
|
||||
string,
|
||||
'id',
|
||||
ViewerContext,
|
||||
SimpleTestEntity,
|
||||
SimpleTestFieldSelection
|
||||
@@ -52,7 +54,7 @@ export class SimpleTestEntityPrivacyPolicy extends EntityPrivacyPolicy<
|
||||
protected override readonly updateRules = [
|
||||
new AlwaysAllowPrivacyPolicyRule<
|
||||
SimpleTestFields,
|
||||
string,
|
||||
'id',
|
||||
ViewerContext,
|
||||
SimpleTestEntity,
|
||||
SimpleTestFieldSelection
|
||||
@@ -61,7 +63,7 @@ export class SimpleTestEntityPrivacyPolicy extends EntityPrivacyPolicy<
|
||||
protected override readonly deleteRules = [
|
||||
new AlwaysAllowPrivacyPolicyRule<
|
||||
SimpleTestFields,
|
||||
string,
|
||||
'id',
|
||||
ViewerContext,
|
||||
SimpleTestEntity,
|
||||
SimpleTestFieldSelection
|
||||
@@ -69,26 +71,24 @@ export class SimpleTestEntityPrivacyPolicy extends EntityPrivacyPolicy<
|
||||
];
|
||||
}
|
||||
|
||||
export default class SimpleTestEntity extends Entity<
|
||||
export class SimpleTestEntity extends Entity<
|
||||
SimpleTestFields,
|
||||
string,
|
||||
'id',
|
||||
ViewerContext,
|
||||
SimpleTestFieldSelection
|
||||
> {
|
||||
static getCompanionDefinition(): EntityCompanionDefinition<
|
||||
static defineCompanionDefinition(): EntityCompanionDefinition<
|
||||
SimpleTestFields,
|
||||
string,
|
||||
'id',
|
||||
ViewerContext,
|
||||
SimpleTestEntity,
|
||||
SimpleTestEntityPrivacyPolicy,
|
||||
SimpleTestFieldSelection
|
||||
> {
|
||||
return testEntityCompanion;
|
||||
return {
|
||||
entityClass: SimpleTestEntity,
|
||||
entityConfiguration: simpleTestEntityConfiguration,
|
||||
privacyPolicyClass: SimpleTestEntityPrivacyPolicy,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export const testEntityCompanion = new EntityCompanionDefinition({
|
||||
entityClass: SimpleTestEntity,
|
||||
entityConfiguration: simpleTestEntityConfiguration,
|
||||
privacyPolicyClass: SimpleTestEntityPrivacyPolicy,
|
||||
});
|
||||
+130
@@ -0,0 +1,130 @@
|
||||
import type { EntityCompanionDefinition, ViewerContext } from '@expo/entity';
|
||||
import {
|
||||
AlwaysAllowPrivacyPolicyRule,
|
||||
DateField,
|
||||
Entity,
|
||||
EntityConfiguration,
|
||||
EntityPrivacyPolicy,
|
||||
IntField,
|
||||
StringField,
|
||||
UUIDField,
|
||||
} from '@expo/entity';
|
||||
import type { Result } from '@expo/results';
|
||||
import { result } from '@expo/results';
|
||||
|
||||
export type TestFields = {
|
||||
customIdField: string;
|
||||
testIndexedField: string;
|
||||
stringField: string;
|
||||
intField: number;
|
||||
dateField: Date;
|
||||
nullableField: string | null;
|
||||
};
|
||||
|
||||
export const testEntityConfiguration = new EntityConfiguration<TestFields, 'customIdField'>({
|
||||
idField: 'customIdField',
|
||||
tableName: 'test_entity_should_not_write_to_db',
|
||||
schema: {
|
||||
customIdField: new UUIDField({
|
||||
columnName: 'custom_id',
|
||||
cache: true,
|
||||
}),
|
||||
testIndexedField: new StringField({
|
||||
columnName: 'test_index',
|
||||
cache: true,
|
||||
}),
|
||||
stringField: new StringField({
|
||||
columnName: 'string_field',
|
||||
}),
|
||||
intField: new IntField({
|
||||
columnName: 'number_field',
|
||||
}),
|
||||
dateField: new DateField({
|
||||
columnName: 'date_field',
|
||||
}),
|
||||
nullableField: new StringField({
|
||||
columnName: 'nullable_field',
|
||||
}),
|
||||
},
|
||||
databaseAdapterFlavor: 'postgres',
|
||||
cacheAdapterFlavor: 'redis',
|
||||
compositeFieldDefinitions: [
|
||||
{ compositeField: ['stringField', 'intField'], cache: false },
|
||||
{ compositeField: ['stringField', 'testIndexedField'], cache: true },
|
||||
{ compositeField: ['nullableField', 'testIndexedField'], cache: true },
|
||||
],
|
||||
});
|
||||
|
||||
export class TestEntityPrivacyPolicy extends EntityPrivacyPolicy<
|
||||
TestFields,
|
||||
'customIdField',
|
||||
ViewerContext,
|
||||
TestEntity
|
||||
> {
|
||||
protected override readonly readRules = [
|
||||
new AlwaysAllowPrivacyPolicyRule<TestFields, 'customIdField', ViewerContext, TestEntity>(),
|
||||
];
|
||||
protected override readonly createRules = [
|
||||
new AlwaysAllowPrivacyPolicyRule<TestFields, 'customIdField', ViewerContext, TestEntity>(),
|
||||
];
|
||||
protected override readonly updateRules = [
|
||||
new AlwaysAllowPrivacyPolicyRule<TestFields, 'customIdField', ViewerContext, TestEntity>(),
|
||||
];
|
||||
protected override readonly deleteRules = [
|
||||
new AlwaysAllowPrivacyPolicyRule<TestFields, 'customIdField', ViewerContext, TestEntity>(),
|
||||
];
|
||||
}
|
||||
|
||||
export class TestEntity extends Entity<TestFields, 'customIdField', ViewerContext> {
|
||||
static defineCompanionDefinition(): EntityCompanionDefinition<
|
||||
TestFields,
|
||||
'customIdField',
|
||||
ViewerContext,
|
||||
TestEntity,
|
||||
TestEntityPrivacyPolicy
|
||||
> {
|
||||
return {
|
||||
entityClass: TestEntity,
|
||||
entityConfiguration: testEntityConfiguration,
|
||||
privacyPolicyClass: TestEntityPrivacyPolicy,
|
||||
};
|
||||
}
|
||||
|
||||
getBlah(): string {
|
||||
return 'Hello World!';
|
||||
}
|
||||
|
||||
static async helloAsync(
|
||||
viewerContext: ViewerContext,
|
||||
testValue: string,
|
||||
): Promise<Result<TestEntity>> {
|
||||
const fields = {
|
||||
customIdField: testValue,
|
||||
testIndexedField: 'hello',
|
||||
stringField: 'hello',
|
||||
intField: 1,
|
||||
dateField: new Date(),
|
||||
nullableField: null,
|
||||
};
|
||||
return result(
|
||||
new TestEntity({
|
||||
viewerContext,
|
||||
id: testValue,
|
||||
databaseFields: fields,
|
||||
selectedFields: fields,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
static async returnErrorAsync(_viewerContext: ViewerContext): Promise<Result<TestEntity>> {
|
||||
return result(new Error('return entity'));
|
||||
}
|
||||
|
||||
static async throwErrorAsync(_viewerContext: ViewerContext): Promise<Result<TestEntity>> {
|
||||
throw new Error('threw entity');
|
||||
}
|
||||
|
||||
static async nonResultAsync(_viewerContext: ViewerContext, testValue: string): Promise<string> {
|
||||
return testValue;
|
||||
}
|
||||
}
|
||||
+61
@@ -0,0 +1,61 @@
|
||||
import type { EntityCompanionDefinition, ViewerContext } from '@expo/entity';
|
||||
import {
|
||||
AlwaysAllowPrivacyPolicyRule,
|
||||
Entity,
|
||||
EntityConfiguration,
|
||||
EntityPrivacyPolicy,
|
||||
IntField,
|
||||
} from '@expo/entity';
|
||||
|
||||
export type NumberKeyFields = {
|
||||
id: number;
|
||||
};
|
||||
|
||||
export const numberKeyEntityConfiguration = new EntityConfiguration<NumberKeyFields, 'id'>({
|
||||
idField: 'id',
|
||||
tableName: 'simple_test_entity_should_not_write_to_db',
|
||||
schema: {
|
||||
id: new IntField({
|
||||
columnName: 'custom_id',
|
||||
cache: false,
|
||||
}),
|
||||
},
|
||||
databaseAdapterFlavor: 'postgres',
|
||||
cacheAdapterFlavor: 'redis',
|
||||
});
|
||||
|
||||
export class NumberKeyPrivacyPolicy extends EntityPrivacyPolicy<
|
||||
NumberKeyFields,
|
||||
'id',
|
||||
ViewerContext,
|
||||
NumberKeyEntity
|
||||
> {
|
||||
protected override readonly readRules = [
|
||||
new AlwaysAllowPrivacyPolicyRule<NumberKeyFields, 'id', ViewerContext, NumberKeyEntity>(),
|
||||
];
|
||||
protected override readonly createRules = [
|
||||
new AlwaysAllowPrivacyPolicyRule<NumberKeyFields, 'id', ViewerContext, NumberKeyEntity>(),
|
||||
];
|
||||
protected override readonly updateRules = [
|
||||
new AlwaysAllowPrivacyPolicyRule<NumberKeyFields, 'id', ViewerContext, NumberKeyEntity>(),
|
||||
];
|
||||
protected override readonly deleteRules = [
|
||||
new AlwaysAllowPrivacyPolicyRule<NumberKeyFields, 'id', ViewerContext, NumberKeyEntity>(),
|
||||
];
|
||||
}
|
||||
|
||||
export class NumberKeyEntity extends Entity<NumberKeyFields, 'id', ViewerContext> {
|
||||
static defineCompanionDefinition(): EntityCompanionDefinition<
|
||||
NumberKeyFields,
|
||||
'id',
|
||||
ViewerContext,
|
||||
NumberKeyEntity,
|
||||
NumberKeyPrivacyPolicy
|
||||
> {
|
||||
return {
|
||||
entityClass: NumberKeyEntity,
|
||||
entityConfiguration: numberKeyEntityConfiguration,
|
||||
privacyPolicyClass: NumberKeyPrivacyPolicy,
|
||||
};
|
||||
}
|
||||
}
|
||||
+32
@@ -0,0 +1,32 @@
|
||||
import { expect, it } from '@jest/globals';
|
||||
import { readFile } from 'fs/promises';
|
||||
|
||||
it.each([
|
||||
'StubPostgresDatabaseAdapter',
|
||||
'StubPostgresDatabaseAdapterProvider',
|
||||
'createUnitTestPostgresEntityCompanionProvider',
|
||||
])('%s is the same as in @expo/entity-database-adapter-knex', async (fileName) => {
|
||||
// These stub adapters need to be shared for testing, but we can't have them in the main
|
||||
// entity-database-adapter-knex package since they would be exposed in production.
|
||||
// Therefore, we duplicate them and ensure they stay in sync.
|
||||
|
||||
const fileContentsFromEntityDatabaseAdapterKnex = await readFile(
|
||||
new URL(
|
||||
`../../../entity-database-adapter-knex/src/__tests__/fixtures/${fileName}.ts`,
|
||||
import.meta.url,
|
||||
),
|
||||
'utf-8',
|
||||
);
|
||||
const fileContentsFromTestingUtils = await readFile(
|
||||
new URL(`../${fileName}.ts`, import.meta.url),
|
||||
'utf-8',
|
||||
);
|
||||
|
||||
const trimmedFiles = [
|
||||
fileContentsFromEntityDatabaseAdapterKnex,
|
||||
fileContentsFromTestingUtils,
|
||||
].map((file) => file.substring(file.indexOf('export')));
|
||||
|
||||
expect(trimmedFiles[0]?.length).toBeGreaterThan(0);
|
||||
expect(trimmedFiles[0]).toEqual(trimmedFiles[1]);
|
||||
});
|
||||
+911
@@ -0,0 +1,911 @@
|
||||
import {
|
||||
CompositeFieldHolder,
|
||||
CompositeFieldValueHolder,
|
||||
EntityQueryContext,
|
||||
SingleFieldHolder,
|
||||
SingleFieldValueHolder,
|
||||
} from '@expo/entity';
|
||||
import { NullsOrdering, OrderByOrdering, sql } from '@expo/entity-database-adapter-knex';
|
||||
import { describe, expect, it, jest } from '@jest/globals';
|
||||
import { instance, mock } from 'ts-mockito';
|
||||
import { validate, version } from 'uuid';
|
||||
|
||||
import { StubPostgresDatabaseAdapter } from '../StubPostgresDatabaseAdapter.ts';
|
||||
import type { DateIDTestFields } from '../__testfixtures__/DateIDTestEntity.ts';
|
||||
import { dateIDTestEntityConfiguration } from '../__testfixtures__/DateIDTestEntity.ts';
|
||||
import type { SimpleTestFields } from '../__testfixtures__/SimpleTestEntity.ts';
|
||||
import { simpleTestEntityConfiguration } from '../__testfixtures__/SimpleTestEntity.ts';
|
||||
import type { TestFields } from '../__testfixtures__/TestEntity.ts';
|
||||
import { testEntityConfiguration } from '../__testfixtures__/TestEntity.ts';
|
||||
import type { NumberKeyFields } from '../__testfixtures__/TestEntityNumberKey.ts';
|
||||
import { numberKeyEntityConfiguration } from '../__testfixtures__/TestEntityNumberKey.ts';
|
||||
|
||||
// uuid keeps state internally for v7 generation, so we fix the time for all tests for consistent test results
|
||||
const expectedTime = new Date('2024-06-03T20:16:33.761Z');
|
||||
jest.useFakeTimers({
|
||||
now: expectedTime,
|
||||
});
|
||||
|
||||
describe(StubPostgresDatabaseAdapter, () => {
|
||||
describe('fetchManyWhereAsync', () => {
|
||||
it('fetches many where single', async () => {
|
||||
const queryContext = instance(mock(EntityQueryContext));
|
||||
const databaseAdapter = new StubPostgresDatabaseAdapter<TestFields, 'customIdField'>(
|
||||
testEntityConfiguration,
|
||||
StubPostgresDatabaseAdapter.convertFieldObjectsToDataStore(
|
||||
testEntityConfiguration,
|
||||
new Map([
|
||||
[
|
||||
testEntityConfiguration.tableName,
|
||||
[
|
||||
{
|
||||
customIdField: 'hello',
|
||||
testIndexedField: 'h1',
|
||||
intField: 5,
|
||||
stringField: 'huh',
|
||||
dateField: new Date(),
|
||||
nullableField: null,
|
||||
},
|
||||
{
|
||||
customIdField: 'world',
|
||||
testIndexedField: 'h2',
|
||||
intField: 3,
|
||||
stringField: 'wat',
|
||||
dateField: new Date(),
|
||||
nullableField: null,
|
||||
},
|
||||
],
|
||||
],
|
||||
]),
|
||||
),
|
||||
);
|
||||
|
||||
const results = await databaseAdapter.fetchManyWhereAsync(
|
||||
queryContext,
|
||||
new SingleFieldHolder('stringField'),
|
||||
[new SingleFieldValueHolder('huh')],
|
||||
);
|
||||
expect(results.get(new SingleFieldValueHolder('huh'))).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('fetches many where composite', async () => {
|
||||
const queryContext = instance(mock(EntityQueryContext));
|
||||
const databaseAdapter = new StubPostgresDatabaseAdapter<TestFields, 'customIdField'>(
|
||||
testEntityConfiguration,
|
||||
StubPostgresDatabaseAdapter.convertFieldObjectsToDataStore(
|
||||
testEntityConfiguration,
|
||||
new Map([
|
||||
[
|
||||
testEntityConfiguration.tableName,
|
||||
[
|
||||
{
|
||||
customIdField: 'hello',
|
||||
testIndexedField: 'h1',
|
||||
intField: 5,
|
||||
stringField: 'huh',
|
||||
dateField: new Date(),
|
||||
nullableField: null,
|
||||
},
|
||||
{
|
||||
customIdField: 'world',
|
||||
testIndexedField: 'h2',
|
||||
intField: 3,
|
||||
stringField: 'wat',
|
||||
dateField: new Date(),
|
||||
nullableField: null,
|
||||
},
|
||||
],
|
||||
],
|
||||
]),
|
||||
),
|
||||
);
|
||||
|
||||
const results = await databaseAdapter.fetchManyWhereAsync(
|
||||
queryContext,
|
||||
new CompositeFieldHolder<TestFields, 'customIdField'>(['stringField', 'intField']),
|
||||
[new CompositeFieldValueHolder({ stringField: 'huh', intField: 5 })],
|
||||
);
|
||||
expect(
|
||||
results.get(new CompositeFieldValueHolder({ stringField: 'huh', intField: 5 })),
|
||||
).toHaveLength(1);
|
||||
|
||||
const results2 = await databaseAdapter.fetchManyWhereAsync(
|
||||
queryContext,
|
||||
new CompositeFieldHolder<TestFields, 'customIdField'>(['stringField', 'intField']),
|
||||
[new CompositeFieldValueHolder({ stringField: 'not-in-db', intField: 5 })],
|
||||
);
|
||||
expect(
|
||||
results2.get(new CompositeFieldValueHolder({ stringField: 'not-in-db', intField: 5 })),
|
||||
).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('handles duplicate tuples and filters them out', async () => {
|
||||
const queryContext = instance(mock(EntityQueryContext));
|
||||
const databaseAdapter = new StubPostgresDatabaseAdapter<TestFields, 'customIdField'>(
|
||||
testEntityConfiguration,
|
||||
StubPostgresDatabaseAdapter.convertFieldObjectsToDataStore(
|
||||
testEntityConfiguration,
|
||||
new Map([
|
||||
[
|
||||
testEntityConfiguration.tableName,
|
||||
[
|
||||
{
|
||||
customIdField: 'id1',
|
||||
testIndexedField: 'h1',
|
||||
intField: 5,
|
||||
stringField: 'test1',
|
||||
dateField: new Date(),
|
||||
nullableField: null,
|
||||
},
|
||||
{
|
||||
customIdField: 'id2',
|
||||
testIndexedField: 'h2',
|
||||
intField: 10,
|
||||
stringField: 'test2',
|
||||
dateField: new Date(),
|
||||
nullableField: null,
|
||||
},
|
||||
],
|
||||
],
|
||||
]),
|
||||
),
|
||||
);
|
||||
|
||||
// Fetch with duplicate field values - this will cause uniqBy to filter duplicates
|
||||
const results = await databaseAdapter.fetchManyWhereAsync(
|
||||
queryContext,
|
||||
new SingleFieldHolder('customIdField'),
|
||||
[
|
||||
new SingleFieldValueHolder('id1'),
|
||||
new SingleFieldValueHolder('id2'),
|
||||
new SingleFieldValueHolder('id1'),
|
||||
new SingleFieldValueHolder('id2'),
|
||||
],
|
||||
);
|
||||
|
||||
// Should only get 2 unique results despite passing 4 values (2 duplicates)
|
||||
expect(results.get(new SingleFieldValueHolder('id1'))).toHaveLength(1);
|
||||
expect(results.get(new SingleFieldValueHolder('id2'))).toHaveLength(1);
|
||||
|
||||
// Verify the actual objects returned
|
||||
const id1Results = results.get(new SingleFieldValueHolder('id1'));
|
||||
expect(id1Results?.[0]).toMatchObject({ customIdField: 'id1', stringField: 'test1' });
|
||||
|
||||
const id2Results = results.get(new SingleFieldValueHolder('id2'));
|
||||
expect(id2Results?.[0]).toMatchObject({ customIdField: 'id2', stringField: 'test2' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('fetchOneWhereAsync', () => {
|
||||
it('fetches one where single', async () => {
|
||||
const queryContext = instance(mock(EntityQueryContext));
|
||||
const databaseAdapter = new StubPostgresDatabaseAdapter<TestFields, 'customIdField'>(
|
||||
testEntityConfiguration,
|
||||
StubPostgresDatabaseAdapter.convertFieldObjectsToDataStore(
|
||||
testEntityConfiguration,
|
||||
new Map([
|
||||
[
|
||||
testEntityConfiguration.tableName,
|
||||
[
|
||||
{
|
||||
customIdField: 'hello',
|
||||
testIndexedField: 'h1',
|
||||
intField: 5,
|
||||
stringField: 'huh',
|
||||
dateField: new Date(),
|
||||
nullableField: null,
|
||||
},
|
||||
{
|
||||
customIdField: 'world',
|
||||
testIndexedField: 'h2',
|
||||
intField: 3,
|
||||
stringField: 'huh',
|
||||
dateField: new Date(),
|
||||
nullableField: null,
|
||||
},
|
||||
],
|
||||
],
|
||||
]),
|
||||
),
|
||||
);
|
||||
|
||||
const result = await databaseAdapter.fetchOneWhereAsync(
|
||||
queryContext,
|
||||
new SingleFieldHolder('stringField'),
|
||||
new SingleFieldValueHolder('huh'),
|
||||
);
|
||||
expect(result).toMatchObject({
|
||||
stringField: 'huh',
|
||||
});
|
||||
});
|
||||
|
||||
it('returns null when no record found', async () => {
|
||||
const queryContext = instance(mock(EntityQueryContext));
|
||||
const databaseAdapter = new StubPostgresDatabaseAdapter<TestFields, 'customIdField'>(
|
||||
testEntityConfiguration,
|
||||
new Map(),
|
||||
);
|
||||
|
||||
const result = await databaseAdapter.fetchOneWhereAsync(
|
||||
queryContext,
|
||||
new SingleFieldHolder('stringField'),
|
||||
new SingleFieldValueHolder('huh'),
|
||||
);
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('fetches one where composite', async () => {
|
||||
const queryContext = instance(mock(EntityQueryContext));
|
||||
const databaseAdapter = new StubPostgresDatabaseAdapter<TestFields, 'customIdField'>(
|
||||
testEntityConfiguration,
|
||||
StubPostgresDatabaseAdapter.convertFieldObjectsToDataStore(
|
||||
testEntityConfiguration,
|
||||
new Map([
|
||||
[
|
||||
testEntityConfiguration.tableName,
|
||||
[
|
||||
{
|
||||
customIdField: 'hello',
|
||||
testIndexedField: 'h1',
|
||||
intField: 5,
|
||||
stringField: 'huh',
|
||||
dateField: new Date(),
|
||||
nullableField: null,
|
||||
},
|
||||
{
|
||||
customIdField: 'world',
|
||||
testIndexedField: 'h2',
|
||||
intField: 5,
|
||||
stringField: 'huh',
|
||||
dateField: new Date(),
|
||||
nullableField: null,
|
||||
},
|
||||
],
|
||||
],
|
||||
]),
|
||||
),
|
||||
);
|
||||
|
||||
const result = await databaseAdapter.fetchOneWhereAsync(
|
||||
queryContext,
|
||||
new CompositeFieldHolder<TestFields, 'customIdField'>(['stringField', 'intField']),
|
||||
new CompositeFieldValueHolder({ stringField: 'huh', intField: 5 }),
|
||||
);
|
||||
expect(result).toMatchObject({
|
||||
stringField: 'huh',
|
||||
intField: 5,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('fetchManyByFieldEqualityConjunctionAsync', () => {
|
||||
it('supports conjuntions and query modifiers', async () => {
|
||||
const queryContext = instance(mock(EntityQueryContext));
|
||||
const databaseAdapter = new StubPostgresDatabaseAdapter<TestFields, 'customIdField'>(
|
||||
testEntityConfiguration,
|
||||
StubPostgresDatabaseAdapter.convertFieldObjectsToDataStore(
|
||||
testEntityConfiguration,
|
||||
new Map([
|
||||
[
|
||||
testEntityConfiguration.tableName,
|
||||
[
|
||||
{
|
||||
customIdField: 'hello',
|
||||
testIndexedField: 'h1',
|
||||
intField: 3,
|
||||
stringField: 'a',
|
||||
dateField: new Date(),
|
||||
nullableField: null,
|
||||
},
|
||||
{
|
||||
customIdField: 'world',
|
||||
testIndexedField: 'h2',
|
||||
intField: 3,
|
||||
stringField: 'b',
|
||||
dateField: new Date(),
|
||||
nullableField: null,
|
||||
},
|
||||
{
|
||||
customIdField: 'world',
|
||||
testIndexedField: 'h2',
|
||||
intField: 3,
|
||||
stringField: 'c',
|
||||
dateField: new Date(),
|
||||
nullableField: null,
|
||||
},
|
||||
],
|
||||
],
|
||||
]),
|
||||
),
|
||||
);
|
||||
|
||||
const results = await databaseAdapter.fetchManyByFieldEqualityConjunctionAsync(
|
||||
queryContext,
|
||||
[
|
||||
{
|
||||
fieldName: 'customIdField',
|
||||
fieldValues: ['hello', 'world'],
|
||||
},
|
||||
{
|
||||
fieldName: 'intField',
|
||||
fieldValue: 3,
|
||||
},
|
||||
],
|
||||
{
|
||||
limit: 2,
|
||||
offset: 1,
|
||||
orderBy: [
|
||||
{
|
||||
fieldName: 'stringField',
|
||||
order: OrderByOrdering.DESCENDING,
|
||||
},
|
||||
],
|
||||
},
|
||||
);
|
||||
|
||||
expect(results).toHaveLength(2);
|
||||
expect(results.map((e) => e.stringField)).toEqual(['b', 'a']);
|
||||
});
|
||||
|
||||
it('supports multiple order bys', async () => {
|
||||
const queryContext = instance(mock(EntityQueryContext));
|
||||
const databaseAdapter = new StubPostgresDatabaseAdapter<TestFields, 'customIdField'>(
|
||||
testEntityConfiguration,
|
||||
StubPostgresDatabaseAdapter.convertFieldObjectsToDataStore(
|
||||
testEntityConfiguration,
|
||||
new Map([
|
||||
[
|
||||
testEntityConfiguration.tableName,
|
||||
[
|
||||
{
|
||||
customIdField: 'hello',
|
||||
testIndexedField: 'h1',
|
||||
intField: 3,
|
||||
stringField: 'a',
|
||||
dateField: new Date(),
|
||||
nullableField: null,
|
||||
},
|
||||
{
|
||||
customIdField: 'world',
|
||||
testIndexedField: 'h2',
|
||||
intField: 3,
|
||||
stringField: 'b',
|
||||
dateField: new Date(),
|
||||
nullableField: null,
|
||||
},
|
||||
{
|
||||
customIdField: 'world',
|
||||
testIndexedField: 'h2',
|
||||
intField: 3,
|
||||
stringField: 'c',
|
||||
dateField: new Date(),
|
||||
nullableField: null,
|
||||
},
|
||||
],
|
||||
],
|
||||
]),
|
||||
),
|
||||
);
|
||||
|
||||
const results = await databaseAdapter.fetchManyByFieldEqualityConjunctionAsync(
|
||||
queryContext,
|
||||
[
|
||||
{
|
||||
fieldName: 'intField',
|
||||
fieldValue: 3,
|
||||
},
|
||||
],
|
||||
{
|
||||
orderBy: [
|
||||
{
|
||||
fieldName: 'intField',
|
||||
order: OrderByOrdering.DESCENDING,
|
||||
},
|
||||
{
|
||||
fieldName: 'stringField',
|
||||
order: OrderByOrdering.DESCENDING,
|
||||
},
|
||||
],
|
||||
},
|
||||
);
|
||||
|
||||
expect(results).toHaveLength(3);
|
||||
expect(results.map((e) => e.stringField)).toEqual(['c', 'b', 'a']);
|
||||
});
|
||||
|
||||
it('supports null field values', async () => {
|
||||
const queryContext = instance(mock(EntityQueryContext));
|
||||
const databaseAdapter = new StubPostgresDatabaseAdapter<TestFields, 'customIdField'>(
|
||||
testEntityConfiguration,
|
||||
StubPostgresDatabaseAdapter.convertFieldObjectsToDataStore(
|
||||
testEntityConfiguration,
|
||||
new Map([
|
||||
[
|
||||
testEntityConfiguration.tableName,
|
||||
[
|
||||
{
|
||||
customIdField: '1',
|
||||
testIndexedField: 'h1',
|
||||
intField: 1,
|
||||
stringField: 'a',
|
||||
dateField: new Date(),
|
||||
nullableField: 'a',
|
||||
},
|
||||
{
|
||||
customIdField: '2',
|
||||
testIndexedField: 'h2',
|
||||
intField: 2,
|
||||
stringField: 'a',
|
||||
dateField: new Date(),
|
||||
nullableField: 'b',
|
||||
},
|
||||
{
|
||||
customIdField: '3',
|
||||
testIndexedField: 'h3',
|
||||
intField: 3,
|
||||
stringField: 'a',
|
||||
dateField: new Date(),
|
||||
nullableField: null,
|
||||
},
|
||||
{
|
||||
customIdField: '4',
|
||||
testIndexedField: 'h4',
|
||||
intField: 4,
|
||||
stringField: 'b',
|
||||
dateField: new Date(),
|
||||
nullableField: null,
|
||||
},
|
||||
],
|
||||
],
|
||||
]),
|
||||
),
|
||||
);
|
||||
|
||||
const results = await databaseAdapter.fetchManyByFieldEqualityConjunctionAsync(
|
||||
queryContext,
|
||||
[{ fieldName: 'nullableField', fieldValue: null }],
|
||||
{},
|
||||
);
|
||||
expect(results).toHaveLength(2);
|
||||
expect(results[0]!.nullableField).toBeNull();
|
||||
|
||||
const results2 = await databaseAdapter.fetchManyByFieldEqualityConjunctionAsync(
|
||||
queryContext,
|
||||
[
|
||||
{ fieldName: 'nullableField', fieldValues: ['a', null] },
|
||||
{ fieldName: 'stringField', fieldValue: 'a' },
|
||||
],
|
||||
{
|
||||
orderBy: [
|
||||
{
|
||||
fieldName: 'nullableField',
|
||||
order: OrderByOrdering.DESCENDING,
|
||||
},
|
||||
],
|
||||
},
|
||||
);
|
||||
expect(results2).toHaveLength(2);
|
||||
expect(results2.map((e) => e.nullableField)).toEqual([null, 'a']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('fetchManyBySQLFragmentAsync', () => {
|
||||
it('throws because it is unsupported', async () => {
|
||||
const queryContext = instance(mock(EntityQueryContext));
|
||||
const databaseAdapter = new StubPostgresDatabaseAdapter<TestFields, 'customIdField'>(
|
||||
testEntityConfiguration,
|
||||
new Map(),
|
||||
);
|
||||
await expect(
|
||||
databaseAdapter.fetchManyBySQLFragmentAsync(queryContext, sql``, {}),
|
||||
).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('insertAsync', () => {
|
||||
it('inserts a record', async () => {
|
||||
const queryContext = instance(mock(EntityQueryContext));
|
||||
const databaseAdapter = new StubPostgresDatabaseAdapter<TestFields, 'customIdField'>(
|
||||
testEntityConfiguration,
|
||||
new Map(),
|
||||
);
|
||||
const result = await databaseAdapter.insertAsync(queryContext, {
|
||||
stringField: 'hello',
|
||||
});
|
||||
expect(result).toMatchObject({
|
||||
stringField: 'hello',
|
||||
});
|
||||
|
||||
expect(
|
||||
databaseAdapter.getObjectCollectionForTable(testEntityConfiguration.tableName),
|
||||
).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('inserts a record with valid v7 id', async () => {
|
||||
const queryContext = instance(mock(EntityQueryContext));
|
||||
const databaseAdapter = new StubPostgresDatabaseAdapter<TestFields, 'customIdField'>(
|
||||
testEntityConfiguration,
|
||||
new Map(),
|
||||
);
|
||||
const result = await databaseAdapter.insertAsync(queryContext, {
|
||||
stringField: 'hello',
|
||||
});
|
||||
|
||||
const ts = getTimeFromUUIDv7(result.customIdField);
|
||||
expect(ts).toEqual(expectedTime);
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateAsync', () => {
|
||||
it('updates a record', async () => {
|
||||
const queryContext = instance(mock(EntityQueryContext));
|
||||
const databaseAdapter = new StubPostgresDatabaseAdapter<TestFields, 'customIdField'>(
|
||||
testEntityConfiguration,
|
||||
StubPostgresDatabaseAdapter.convertFieldObjectsToDataStore(
|
||||
testEntityConfiguration,
|
||||
new Map([
|
||||
[
|
||||
testEntityConfiguration.tableName,
|
||||
[
|
||||
{
|
||||
customIdField: 'hello',
|
||||
testIndexedField: 'h1',
|
||||
intField: 3,
|
||||
stringField: 'a',
|
||||
dateField: new Date(),
|
||||
nullableField: null,
|
||||
},
|
||||
],
|
||||
],
|
||||
]),
|
||||
),
|
||||
);
|
||||
await databaseAdapter.updateAsync(queryContext, 'customIdField', 'hello', {
|
||||
stringField: 'b',
|
||||
});
|
||||
});
|
||||
|
||||
it('throws error when empty update to match common DBMS behavior', async () => {
|
||||
const queryContext = instance(mock(EntityQueryContext));
|
||||
const databaseAdapter = new StubPostgresDatabaseAdapter<TestFields, 'customIdField'>(
|
||||
testEntityConfiguration,
|
||||
StubPostgresDatabaseAdapter.convertFieldObjectsToDataStore(
|
||||
testEntityConfiguration,
|
||||
new Map([
|
||||
[
|
||||
testEntityConfiguration.tableName,
|
||||
[
|
||||
{
|
||||
customIdField: 'hello',
|
||||
testIndexedField: 'h1',
|
||||
intField: 3,
|
||||
stringField: 'a',
|
||||
dateField: new Date(),
|
||||
nullableField: null,
|
||||
},
|
||||
],
|
||||
],
|
||||
]),
|
||||
),
|
||||
);
|
||||
await expect(
|
||||
databaseAdapter.updateAsync(queryContext, 'customIdField', 'hello', {}),
|
||||
).rejects.toThrow(`Empty update (custom_id = hello)`);
|
||||
});
|
||||
|
||||
it('throws error when updating nonexistent record', async () => {
|
||||
const queryContext = instance(mock(EntityQueryContext));
|
||||
const databaseAdapter = new StubPostgresDatabaseAdapter<TestFields, 'customIdField'>(
|
||||
testEntityConfiguration,
|
||||
StubPostgresDatabaseAdapter.convertFieldObjectsToDataStore(
|
||||
testEntityConfiguration,
|
||||
new Map([
|
||||
[
|
||||
testEntityConfiguration.tableName,
|
||||
[
|
||||
{
|
||||
customIdField: 'existing-id',
|
||||
testIndexedField: 'h1',
|
||||
intField: 3,
|
||||
stringField: 'a',
|
||||
dateField: new Date(),
|
||||
nullableField: null,
|
||||
},
|
||||
],
|
||||
],
|
||||
]),
|
||||
),
|
||||
);
|
||||
|
||||
// Try to update a record that doesn't exist
|
||||
await expect(
|
||||
databaseAdapter.updateAsync(
|
||||
queryContext,
|
||||
'customIdField',
|
||||
'nonexistent-id', // This ID doesn't exist in the data store
|
||||
{ stringField: 'updated' },
|
||||
),
|
||||
).rejects.toThrow('Empty results from database adapter update');
|
||||
});
|
||||
});
|
||||
|
||||
describe('deleteAsync', () => {
|
||||
it('deletes an object', async () => {
|
||||
const queryContext = instance(mock(EntityQueryContext));
|
||||
const databaseAdapter = new StubPostgresDatabaseAdapter<TestFields, 'customIdField'>(
|
||||
testEntityConfiguration,
|
||||
StubPostgresDatabaseAdapter.convertFieldObjectsToDataStore(
|
||||
testEntityConfiguration,
|
||||
new Map([
|
||||
[
|
||||
testEntityConfiguration.tableName,
|
||||
[
|
||||
{
|
||||
customIdField: 'hello',
|
||||
testIndexedField: 'h1',
|
||||
intField: 3,
|
||||
stringField: 'a',
|
||||
dateField: new Date(),
|
||||
nullableField: null,
|
||||
},
|
||||
],
|
||||
],
|
||||
]),
|
||||
),
|
||||
);
|
||||
|
||||
await databaseAdapter.deleteAsync(queryContext, 'customIdField', 'hello');
|
||||
|
||||
expect(
|
||||
databaseAdapter.getObjectCollectionForTable(testEntityConfiguration.tableName),
|
||||
).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('handles deletion of nonexistent record gracefully', async () => {
|
||||
const queryContext = instance(mock(EntityQueryContext));
|
||||
const databaseAdapter = new StubPostgresDatabaseAdapter<TestFields, 'customIdField'>(
|
||||
testEntityConfiguration,
|
||||
StubPostgresDatabaseAdapter.convertFieldObjectsToDataStore(
|
||||
testEntityConfiguration,
|
||||
new Map([
|
||||
[
|
||||
testEntityConfiguration.tableName,
|
||||
[
|
||||
{
|
||||
customIdField: 'existing-id',
|
||||
testIndexedField: 'h1',
|
||||
intField: 3,
|
||||
stringField: 'a',
|
||||
dateField: new Date(),
|
||||
nullableField: null,
|
||||
},
|
||||
],
|
||||
],
|
||||
]),
|
||||
),
|
||||
);
|
||||
|
||||
// Delete a record that doesn't exist
|
||||
// Unlike update, delete doesn't throw an error when the record doesn't exist
|
||||
await databaseAdapter.deleteAsync(
|
||||
queryContext,
|
||||
'customIdField',
|
||||
'nonexistent-id', // This ID doesn't exist in the data store
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('supports string and number IDs', async () => {
|
||||
const queryContext = instance(mock(EntityQueryContext));
|
||||
const databaseAdapter1 = new StubPostgresDatabaseAdapter<SimpleTestFields, 'id'>(
|
||||
simpleTestEntityConfiguration,
|
||||
new Map(),
|
||||
);
|
||||
const insertedObject1 = await databaseAdapter1.insertAsync(queryContext, {});
|
||||
expect(typeof insertedObject1.id).toBe('string');
|
||||
|
||||
const databaseAdapter2 = new StubPostgresDatabaseAdapter<NumberKeyFields, 'id'>(
|
||||
numberKeyEntityConfiguration,
|
||||
new Map(),
|
||||
);
|
||||
const insertedObject2 = await databaseAdapter2.insertAsync(queryContext, {});
|
||||
expect(typeof insertedObject2.id).toBe('number');
|
||||
|
||||
const databaseAdapter3 = new StubPostgresDatabaseAdapter<DateIDTestFields, 'id'>(
|
||||
dateIDTestEntityConfiguration,
|
||||
new Map(),
|
||||
);
|
||||
await expect(databaseAdapter3.insertAsync(queryContext, {})).rejects.toThrow(
|
||||
'Unsupported ID type for StubPostgresDatabaseAdapter: DateField',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('compareByOrderBys', () => {
|
||||
describe('comparison with default nulls ordering', () => {
|
||||
it.each([
|
||||
// Default nulls ordering: NULLS FIRST for DESC, NULLS LAST for ASC
|
||||
// nulls compare with 0
|
||||
[OrderByOrdering.DESCENDING, null, 0, -1],
|
||||
[OrderByOrdering.ASCENDING, null, 0, 1],
|
||||
[OrderByOrdering.DESCENDING, 0, null, 1],
|
||||
[OrderByOrdering.ASCENDING, 0, null, -1],
|
||||
// nulls compare with nulls (fall through to next order by, which is empty, so 0)
|
||||
[OrderByOrdering.DESCENDING, null, null, 0],
|
||||
[OrderByOrdering.ASCENDING, null, null, 0],
|
||||
// nulls compare with -1
|
||||
[OrderByOrdering.DESCENDING, null, -1, -1],
|
||||
[OrderByOrdering.ASCENDING, null, -1, 1],
|
||||
[OrderByOrdering.DESCENDING, -1, null, 1],
|
||||
[OrderByOrdering.ASCENDING, -1, null, -1],
|
||||
// basic compares
|
||||
[OrderByOrdering.ASCENDING, 'a', 'b', -1],
|
||||
[OrderByOrdering.ASCENDING, 'b', 'a', 1],
|
||||
[OrderByOrdering.DESCENDING, 'a', 'b', 1],
|
||||
[OrderByOrdering.DESCENDING, 'b', 'a', -1],
|
||||
])('case (%p; %p; %p)', (order, v1, v2, expectedResult) => {
|
||||
expect(
|
||||
StubPostgresDatabaseAdapter['compareByOrderBys'](
|
||||
[
|
||||
{
|
||||
columnName: 'hello',
|
||||
order,
|
||||
nulls: undefined,
|
||||
},
|
||||
],
|
||||
{
|
||||
hello: v1,
|
||||
},
|
||||
{
|
||||
hello: v2,
|
||||
},
|
||||
),
|
||||
).toEqual(expectedResult);
|
||||
});
|
||||
it('throws for SQL fragment order by', () => {
|
||||
expect(() =>
|
||||
StubPostgresDatabaseAdapter['compareByOrderBys'](
|
||||
[
|
||||
{
|
||||
columnFragment: sql`some_function(col)`,
|
||||
order: OrderByOrdering.ASCENDING,
|
||||
nulls: undefined,
|
||||
},
|
||||
],
|
||||
{ hello: 'a' },
|
||||
{ hello: 'b' },
|
||||
),
|
||||
).toThrow('SQL fragment order by not supported for StubDatabaseAdapter');
|
||||
});
|
||||
it('works for empty', () => {
|
||||
expect(
|
||||
StubPostgresDatabaseAdapter['compareByOrderBys'](
|
||||
[],
|
||||
{
|
||||
hello: 'test',
|
||||
},
|
||||
{
|
||||
hello: 'blah',
|
||||
},
|
||||
),
|
||||
).toEqual(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('comparison with explicit nulls ordering', () => {
|
||||
it.each([
|
||||
// ASC NULLS FIRST: nulls come before non-nulls
|
||||
[OrderByOrdering.ASCENDING, NullsOrdering.FIRST, null, 0, -1],
|
||||
[OrderByOrdering.ASCENDING, NullsOrdering.FIRST, 0, null, 1],
|
||||
// ASC NULLS LAST: nulls come after non-nulls (same as default ASC)
|
||||
[OrderByOrdering.ASCENDING, NullsOrdering.LAST, null, 0, 1],
|
||||
[OrderByOrdering.ASCENDING, NullsOrdering.LAST, 0, null, -1],
|
||||
// DESC NULLS FIRST: nulls come before non-nulls (same as default DESC)
|
||||
[OrderByOrdering.DESCENDING, NullsOrdering.FIRST, null, 0, -1],
|
||||
[OrderByOrdering.DESCENDING, NullsOrdering.FIRST, 0, null, 1],
|
||||
// DESC NULLS LAST: nulls come after non-nulls
|
||||
[OrderByOrdering.DESCENDING, NullsOrdering.LAST, null, 0, 1],
|
||||
[OrderByOrdering.DESCENDING, NullsOrdering.LAST, 0, null, -1],
|
||||
// Non-null values should not be affected by nulls ordering
|
||||
[OrderByOrdering.ASCENDING, NullsOrdering.FIRST, 'a', 'b', -1],
|
||||
[OrderByOrdering.ASCENDING, NullsOrdering.LAST, 'a', 'b', -1],
|
||||
[OrderByOrdering.DESCENDING, NullsOrdering.FIRST, 'a', 'b', 1],
|
||||
[OrderByOrdering.DESCENDING, NullsOrdering.LAST, 'a', 'b', 1],
|
||||
// Both null should fall through to next order by (which is empty, so 0)
|
||||
[OrderByOrdering.ASCENDING, NullsOrdering.FIRST, null, null, 0],
|
||||
[OrderByOrdering.DESCENDING, NullsOrdering.LAST, null, null, 0],
|
||||
])('case (%p; nulls %p; %p; %p)', (order, nulls, v1, v2, expectedResult) => {
|
||||
expect(
|
||||
StubPostgresDatabaseAdapter['compareByOrderBys'](
|
||||
[
|
||||
{
|
||||
columnName: 'hello',
|
||||
order,
|
||||
nulls,
|
||||
},
|
||||
],
|
||||
{
|
||||
hello: v1,
|
||||
},
|
||||
{
|
||||
hello: v2,
|
||||
},
|
||||
),
|
||||
).toEqual(expectedResult);
|
||||
});
|
||||
});
|
||||
|
||||
describe('recursing', () => {
|
||||
it('recurses to secondary order by on tie', () => {
|
||||
expect(
|
||||
StubPostgresDatabaseAdapter['compareByOrderBys'](
|
||||
[
|
||||
{
|
||||
columnName: 'hello',
|
||||
order: OrderByOrdering.ASCENDING,
|
||||
nulls: undefined,
|
||||
},
|
||||
{
|
||||
columnName: 'world',
|
||||
order: OrderByOrdering.ASCENDING,
|
||||
nulls: undefined,
|
||||
},
|
||||
],
|
||||
{
|
||||
hello: 'a',
|
||||
world: 1,
|
||||
},
|
||||
{
|
||||
hello: 'a',
|
||||
world: 2,
|
||||
},
|
||||
),
|
||||
).toEqual(-1);
|
||||
});
|
||||
|
||||
it('recurses to secondary order by when both values are null', () => {
|
||||
expect(
|
||||
StubPostgresDatabaseAdapter['compareByOrderBys'](
|
||||
[
|
||||
{
|
||||
columnName: 'hello',
|
||||
order: OrderByOrdering.ASCENDING,
|
||||
nulls: undefined,
|
||||
},
|
||||
{
|
||||
columnName: 'world',
|
||||
order: OrderByOrdering.ASCENDING,
|
||||
nulls: undefined,
|
||||
},
|
||||
],
|
||||
{
|
||||
hello: null,
|
||||
world: 1,
|
||||
},
|
||||
{
|
||||
hello: null,
|
||||
world: 2,
|
||||
},
|
||||
),
|
||||
).toEqual(-1);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Returns the Date object encoded in the first 48 bits of the given UUIDv7.
|
||||
* @throws TypeError if the UUID is not version 7
|
||||
*/
|
||||
function getTimeFromUUIDv7(uuid: string): Date {
|
||||
if (!(validate(uuid) && version(uuid) === 7)) {
|
||||
throw new TypeError(`Invalid UUID: ${uuid}`);
|
||||
}
|
||||
|
||||
// The first 48 bits = 12 hex characters of the UUID encode the timestamp in big endian
|
||||
const hexCharacters = uuid.replaceAll('-', '').split('', 12);
|
||||
const milliseconds = hexCharacters.reduce(
|
||||
(milliseconds, character) => milliseconds * 16 + parseInt(character, 16),
|
||||
0,
|
||||
);
|
||||
return new Date(milliseconds);
|
||||
}
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
import { EntityCompanionProvider, ViewerContext } from '@expo/entity';
|
||||
import { describe, expect, it } from '@jest/globals';
|
||||
|
||||
import { TestEntity } from '../__testfixtures__/TestEntity.ts';
|
||||
import { createUnitTestPostgresEntityCompanionProvider } from '../createUnitTestPostgresEntityCompanionProvider.ts';
|
||||
|
||||
describe(createUnitTestPostgresEntityCompanionProvider, () => {
|
||||
it('creates a new EntityCompanionProvider', async () => {
|
||||
const companionProvider = createUnitTestPostgresEntityCompanionProvider();
|
||||
expect(companionProvider).toBeInstanceOf(EntityCompanionProvider);
|
||||
const viewerContext = new ViewerContext(companionProvider);
|
||||
await expect(TestEntity.creator(viewerContext).createAsync()).resolves.toBeInstanceOf(
|
||||
TestEntity,
|
||||
);
|
||||
});
|
||||
});
|
||||
+39
@@ -0,0 +1,39 @@
|
||||
import type { IEntityMetricsAdapter } from '@expo/entity';
|
||||
import { EntityCompanionProvider, NoOpEntityMetricsAdapter } from '@expo/entity';
|
||||
import {
|
||||
InMemoryFullCacheStubCacheAdapterProvider,
|
||||
StubQueryContextProvider,
|
||||
} from '@expo/entity-testing-utils';
|
||||
|
||||
import { StubPostgresDatabaseAdapterProvider } from './StubPostgresDatabaseAdapterProvider.ts';
|
||||
|
||||
const queryContextProvider = new StubQueryContextProvider();
|
||||
|
||||
/**
|
||||
* Entity companion provider for use in knex unit tests. All database and cache implementations
|
||||
* are replaced with in-memory simulations.
|
||||
*/
|
||||
export const createUnitTestPostgresEntityCompanionProvider = (
|
||||
metricsAdapter: IEntityMetricsAdapter = new NoOpEntityMetricsAdapter(),
|
||||
): EntityCompanionProvider => {
|
||||
return new EntityCompanionProvider(
|
||||
metricsAdapter,
|
||||
new Map([
|
||||
[
|
||||
'postgres',
|
||||
{
|
||||
adapterProvider: new StubPostgresDatabaseAdapterProvider(),
|
||||
queryContextProvider,
|
||||
},
|
||||
],
|
||||
]),
|
||||
new Map([
|
||||
[
|
||||
'redis',
|
||||
{
|
||||
cacheAdapterProvider: new InMemoryFullCacheStubCacheAdapterProvider(),
|
||||
},
|
||||
],
|
||||
]),
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,9 @@
|
||||
/* eslint-disable tsdoc/syntax */
|
||||
/**
|
||||
* @packageDocumentation
|
||||
* @module @expo/entity-database-adapter-knex-testing-utils
|
||||
*/
|
||||
|
||||
export * from './createUnitTestPostgresEntityCompanionProvider.ts';
|
||||
export * from './StubPostgresDatabaseAdapter.ts';
|
||||
export * from './StubPostgresDatabaseAdapterProvider.ts';
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"include": ["src/**/*"],
|
||||
"references": [
|
||||
{ "path": "../entity" },
|
||||
{ "path": "../entity-database-adapter-knex" }
|
||||
]
|
||||
}
|
||||
@@ -3,254 +3,398 @@
|
||||
All notable changes to this project will be documented in this file.
|
||||
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
|
||||
|
||||
# [0.22.0](https://github.com/expo/entity/compare/v0.21.0...v0.22.0) (2022-02-04)
|
||||
# [0.62.0](https://github.com/expo/entity/compare/v0.61.0...v0.62.0) (2026-03-16)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- only throw EntityDatabaseAdapterPaginationCursorInvalidError from cursor decoding logic ([#537](https://github.com/expo/entity/issues/537)) ([e3cee07](https://github.com/expo/entity/commit/e3cee07091af8d1e6c5bb2d82f13e837c7658ea9))
|
||||
|
||||
# [0.61.0](https://github.com/expo/entity/compare/v0.60.0...v0.61.0) (2026-03-12)
|
||||
|
||||
### Features
|
||||
|
||||
* Upgrade knex to 1.x in `@expo/entity-database-adapter-knex` ([#150](https://github.com/expo/entity/issues/150)) ([e1b0ee5](https://github.com/expo/entity/commit/e1b0ee528175646239d88aa71ffce73983eeb52f))
|
||||
- update the SQLExpression and SQLFragmentHelpers APIs ([#516](https://github.com/expo/entity/issues/516)) ([112d4dc](https://github.com/expo/entity/commit/112d4dc9b379da5317ff6acaf1d2e9bd597128a6))
|
||||
|
||||
# [0.60.0](https://github.com/expo/entity/compare/v0.59.0...v0.60.0) (2026-03-06)
|
||||
|
||||
### Features
|
||||
|
||||
- Add SQLExpression class that supports fluent comparison method API ([#506](https://github.com/expo/entity/issues/506)) ([535b681](https://github.com/expo/entity/commit/535b6811e08578aba1aadb095f3e401cfe7eaa26))
|
||||
|
||||
# [0.59.0](https://github.com/expo/entity/compare/v0.58.0...v0.59.0) (2026-03-03)
|
||||
|
||||
### Features
|
||||
|
||||
- add support to SQLFragment for `= ANY(?)` ([#498](https://github.com/expo/entity/issues/498)) ([b931daa](https://github.com/expo/entity/commit/b931daa9a082cdb33f4559d6d32e1655f0459b79))
|
||||
|
||||
# [0.58.0](https://github.com/expo/entity/compare/v0.57.0...v0.58.0) (2026-03-02)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- fix some security review suggestions ([#485](https://github.com/expo/entity/issues/485)) ([e263bd2](https://github.com/expo/entity/commit/e263bd218cda579365766d0918c4961304bd1d9f))
|
||||
|
||||
# [0.57.0](https://github.com/expo/entity/compare/v0.56.0...v0.57.0) (2026-02-28)
|
||||
|
||||
**Note:** Version bump only for package @expo/entity-database-adapter-knex
|
||||
|
||||
# [0.56.0](https://github.com/expo/entity/compare/v0.55.0...v0.56.0) (2026-02-28)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Add default for TSelectedFields for application-instantiated types ([#438](https://github.com/expo/entity/issues/438)) ([3d4aa0e](https://github.com/expo/entity/commit/3d4aa0e9efa519a36f3db69bd8f117a993c100fa))
|
||||
- Apply same viewer context subclass type mechanism to knexLoader and related methods ([#472](https://github.com/expo/entity/issues/472)) ([018a40b](https://github.com/expo/entity/commit/018a40bc01d579ffbf88f308e641921e6fc39097))
|
||||
- correct discriminated union type for forward/backward pagination ([#474](https://github.com/expo/entity/issues/474)) ([90ae6e2](https://github.com/expo/entity/commit/90ae6e299bda26ac9a80d83e83334d4152c022dd))
|
||||
- correct pagination behavior for standard pagination with descending ordering clauses ([#477](https://github.com/expo/entity/issues/477)) ([e32c4c2](https://github.com/expo/entity/commit/e32c4c2bcc05f225a556f95c90f1ee0f4e24de74))
|
||||
- correct precedence parentheses for SQLFragment joins ([#476](https://github.com/expo/entity/issues/476)) ([2c4c649](https://github.com/expo/entity/commit/2c4c649a4bca5bc9ffdc41eb3135b2dfe4db5e57)), closes [/github.com/expo/entity/blob/main/packages/entity-database-adapter-knex/src/internal/EntityKnexDataManager.ts#L303](https://github.com//github.com/expo/entity/blob/main/packages/entity-database-adapter-knex/src/internal/EntityKnexDataManager.ts/issues/L303)
|
||||
- eliminate some unnecessary any casts ([#448](https://github.com/expo/entity/issues/448)) ([cc78029](https://github.com/expo/entity/commit/cc78029117c14510b0934b5e0d19876a45570f8d))
|
||||
- explicitly document behavior when cursor entity no longer exists ([#453](https://github.com/expo/entity/issues/453)) ([4dc156c](https://github.com/expo/entity/commit/4dc156c7a8dc50bc9e2802be133b9e258b289f54)), closes [#422](https://github.com/expo/entity/issues/422) [#431](https://github.com/expo/entity/issues/431)
|
||||
- move EntityPrivacyUtils back into core package ([#429](https://github.com/expo/entity/issues/429)) ([56ec27d](https://github.com/expo/entity/commit/56ec27d5f201d83930a2cc05dfc380a054bff1e1))
|
||||
- pass in augmentable classes to installation ([#437](https://github.com/expo/entity/issues/437)) ([f5c728b](https://github.com/expo/entity/commit/f5c728be565e01c0a8ace9ef51f6de8f49184dab)), closes [/github.com/expo/entity/pull/410#pullrequestreview-3765893898](https://github.com//github.com/expo/entity/pull/410/issues/pullrequestreview-3765893898)
|
||||
- refactor install method to instead be free functions ([#441](https://github.com/expo/entity/issues/441)) ([51d632d](https://github.com/expo/entity/commit/51d632da1619dc039d5c41ce6533f63254ec4337)), closes [#410](https://github.com/expo/entity/issues/410)
|
||||
- update integration test to use BlahEntity.knexLoader syntax ([#444](https://github.com/expo/entity/issues/444)) ([457f58a](https://github.com/expo/entity/commit/457f58ad2c4e1cf09f3132f3b9af5008ba0290c7)), closes [#441](https://github.com/expo/entity/issues/441)
|
||||
- Use TSelectedFields for knex loader order by method ([#424](https://github.com/expo/entity/issues/424)) ([a9d09f4](https://github.com/expo/entity/commit/a9d09f42b67378d06133bcfb5b99c9afb17aac0d))
|
||||
|
||||
### Features
|
||||
|
||||
- add entity-database-adapter-knex-testing-utils containing StubPostgresDatabaseAdapter ([#412](https://github.com/expo/entity/issues/412)) ([eeccf59](https://github.com/expo/entity/commit/eeccf594c11bf090aa83bbf71fb4ead8668aec7d))
|
||||
- add entityField SQL helper ([#481](https://github.com/expo/entity/issues/481)) ([459a5ff](https://github.com/expo/entity/commit/459a5ff85014a8c7396c0856dffacb1491ccb963))
|
||||
- Add ilike and trigram similarity search to pagination ([#431](https://github.com/expo/entity/issues/431)) ([f72cbca](https://github.com/expo/entity/commit/f72cbcaf5a722e6bb8299644ec32b07f1d3362a9)), closes [#422](https://github.com/expo/entity/issues/422)
|
||||
- add method to get pagination cursor for single entity ([#475](https://github.com/expo/entity/issues/475)) ([898f71c](https://github.com/expo/entity/commit/898f71ce082c2593585575b60bd6ef2546717124))
|
||||
- Add paginated loader to entity-database-adapter-knex ([#422](https://github.com/expo/entity/issues/422)) ([58deffd](https://github.com/expo/entity/commit/58deffd426b0214073ede608cd47c04db2596d60))
|
||||
- Add pagination max page size configuration ([#436](https://github.com/expo/entity/issues/436)) ([3ec4b5f](https://github.com/expo/entity/commit/3ec4b5f0cd53bf18d6bcfa27dd29b70d3f4d5f19))
|
||||
- add PostgresEntity/ReadonlyPostgresEntity classes that expose knexLoader methods ([#442](https://github.com/expo/entity/issues/442)) ([40b409f](https://github.com/expo/entity/commit/40b409f6f889febf8b90b8837caecac26ff64174)), closes [#441](https://github.com/expo/entity/issues/441)
|
||||
- Add raw sqlfragment orderBy capability for pagination ([#457](https://github.com/expo/entity/issues/457)) ([0615ffa](https://github.com/expo/entity/commit/0615ffacffbe9e903a9bf03b171c102f0554b992))
|
||||
- add support for nulls ordering in order by clauses ([#468](https://github.com/expo/entity/issues/468)) ([6c0838d](https://github.com/expo/entity/commit/6c0838dc577a3b6190e3551ee834925d7bd53bc6))
|
||||
- support nullable search fields with postgres transforms ([#470](https://github.com/expo/entity/issues/470)) ([53b0604](https://github.com/expo/entity/commit/53b0604152b8ca5ad9fc616d18478f93473c7eb0))
|
||||
|
||||
# [0.55.0](https://github.com/expo/entity/compare/v0.54.0...v0.55.0) (2026-01-27)
|
||||
|
||||
**Note:** Version bump only for package @expo/entity-database-adapter-knex
|
||||
|
||||
# [0.54.0](https://github.com/expo/entity/compare/v0.53.0...v0.54.0) (2026-01-21)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **deps:** pin dependencies ([#328](https://github.com/expo/entity/issues/328)) ([5679f27](https://github.com/expo/entity/commit/5679f27209f1515ca5e626d858c4aa054b414625))
|
||||
|
||||
# [0.53.0](https://github.com/expo/entity/compare/v0.52.0...v0.53.0) (2025-12-19)
|
||||
|
||||
**Note:** Version bump only for package @expo/entity-database-adapter-knex
|
||||
|
||||
# [0.52.0](https://github.com/expo/entity/compare/v0.51.0...v0.52.0) (2025-12-19)
|
||||
|
||||
**Note:** Version bump only for package @expo/entity-database-adapter-knex
|
||||
|
||||
# [0.51.0](https://github.com/expo/entity/compare/v0.50.0...v0.51.0) (2025-11-21)
|
||||
|
||||
**Note:** Version bump only for package @expo/entity-database-adapter-knex
|
||||
|
||||
# [0.50.0](https://github.com/expo/entity/compare/v0.49.0...v0.50.0) (2025-10-07)
|
||||
|
||||
**Note:** Version bump only for package @expo/entity-database-adapter-knex
|
||||
|
||||
# [0.49.0](https://github.com/expo/entity/compare/v0.48.0...v0.49.0) (2025-10-07)
|
||||
|
||||
### Features
|
||||
|
||||
- throw specific errors for database adapter issues ([#311](https://github.com/expo/entity/issues/311)) ([f6639f9](https://github.com/expo/entity/commit/f6639f9def4ac20b628dd48fdca8d7cf61b6347b))
|
||||
|
||||
# [0.48.0](https://github.com/expo/entity/compare/v0.47.0...v0.48.0) (2025-09-19)
|
||||
|
||||
**Note:** Version bump only for package @expo/entity-database-adapter-knex
|
||||
|
||||
# [0.47.0](https://github.com/expo/entity/compare/v0.46.0...v0.47.0) (2025-09-19)
|
||||
|
||||
### Features
|
||||
|
||||
- add support for Buffer fields ([#305](https://github.com/expo/entity/issues/305)) ([31b52a5](https://github.com/expo/entity/commit/31b52a5f75842f4810f66c12ad03a10d9192f889))
|
||||
|
||||
# [0.46.0](https://github.com/expo/entity/compare/v0.45.0...v0.46.0) (2025-06-26)
|
||||
|
||||
**Note:** Version bump only for package @expo/entity-database-adapter-knex
|
||||
|
||||
# [0.45.0](https://github.com/expo/entity/compare/v0.44.0...v0.45.0) (2025-06-06)
|
||||
|
||||
### Features
|
||||
|
||||
- add createOrGetExistingAsync and createWithUniqueConstraintRecoveryAsync utility methods ([#291](https://github.com/expo/entity/issues/291)) ([78bc264](https://github.com/expo/entity/commit/78bc264b3328692b4b6722388a57d298f02e65c5))
|
||||
|
||||
# [0.44.0](https://github.com/expo/entity/compare/v0.43.0...v0.44.0) (2025-05-29)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- introduce transactional dataloader mode and fix global transaction ID generation ([#289](https://github.com/expo/entity/issues/289)) ([d3e89bd](https://github.com/expo/entity/commit/d3e89bdd82e6df5c5b35522bc3574fd9e1cfddff))
|
||||
|
||||
### Features
|
||||
|
||||
- add transaction-scoped dataloaders ([#284](https://github.com/expo/entity/issues/284)) ([e054a0d](https://github.com/expo/entity/commit/e054a0d74b3a39158ca7d73d05f59d3633113400)), closes [#98](https://github.com/expo/entity/issues/98) [#194](https://github.com/expo/entity/issues/194)
|
||||
|
||||
# [0.43.0](https://github.com/expo/entity/compare/v0.42.0...v0.43.0) (2025-04-10)
|
||||
|
||||
**Note:** Version bump only for package @expo/entity-database-adapter-knex
|
||||
|
||||
# [0.42.0](https://github.com/expo/entity/compare/v0.41.0...v0.42.0) (2025-04-10)
|
||||
|
||||
### Features
|
||||
|
||||
- add @expo/entity-testing-utils package ([#280](https://github.com/expo/entity/issues/280)) ([485894a](https://github.com/expo/entity/commit/485894af16e233d533a9480285bbda56812cbe0b))
|
||||
- add composite field loading and caching ([#272](https://github.com/expo/entity/issues/272)) ([f0aa0da](https://github.com/expo/entity/commit/f0aa0dafebdb56418cbd22dda437233d850db3e7))
|
||||
- change entity id field generic to field name and derive type where necessary ([#278](https://github.com/expo/entity/issues/278)) ([b7e524c](https://github.com/expo/entity/commit/b7e524c41892797608d0b884410b8d520a80a9ef))
|
||||
- convert batched/cached loader interface to holder pattern ([#271](https://github.com/expo/entity/issues/271)) ([06b3cb7](https://github.com/expo/entity/commit/06b3cb7f8076ef9e5b83cdf154da159801bd14cc)), closes [#201](https://github.com/expo/entity/issues/201)
|
||||
- enforce explicit id field cache property ([#276](https://github.com/expo/entity/issues/276)) ([1da5cc0](https://github.com/expo/entity/commit/1da5cc01e1affc3b3338a0ab2050504b5853ac6f))
|
||||
|
||||
# [0.41.0](https://github.com/expo/entity/compare/v0.40.0...v0.41.0) (2025-03-07)
|
||||
|
||||
- feat!: Default all loaders and mutators to enforcing (#256) ([a976e76](https://github.com/expo/entity/commit/a976e765098bca27049585e43a3bed5c71b9ac6b)), closes [#256](https://github.com/expo/entity/issues/256)
|
||||
|
||||
### BREAKING CHANGES
|
||||
|
||||
- Default all loaders and mutators to enforcing
|
||||
|
||||
# [0.40.0](https://github.com/expo/entity/compare/v0.39.0...v0.40.0) (2025-02-27)
|
||||
|
||||
### Reverts
|
||||
|
||||
- Revert "v0.40.0" ([fa78ce8](https://github.com/expo/entity/commit/fa78ce84e2f2eb8199e36fd51779fc68905001c0))
|
||||
|
||||
# [0.39.0](https://github.com/expo/entity/compare/v0.38.0...v0.39.0) (2024-11-21)
|
||||
|
||||
**Note:** Version bump only for package @expo/entity-database-adapter-knex
|
||||
|
||||
# [0.38.0](https://github.com/expo/entity/compare/v0.37.0...v0.38.0) (2024-06-27)
|
||||
|
||||
**Note:** Version bump only for package @expo/entity-database-adapter-knex
|
||||
|
||||
# [0.37.0](https://github.com/expo/entity/compare/v0.36.0...v0.37.0) (2024-06-12)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- update barrels ([#241](https://github.com/expo/entity/issues/241)) ([8c5f81b](https://github.com/expo/entity/commit/8c5f81bcca107e9b7bbea5f0ab41b0105057806e))
|
||||
|
||||
# [0.36.0](https://github.com/expo/entity/compare/v0.35.0...v0.36.0) (2024-06-12)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- constrain entity fields type to string-keyed object ([#235](https://github.com/expo/entity/issues/235)) ([7e2cea1](https://github.com/expo/entity/commit/7e2cea16973a0ae1917f867cd25d6ef7c8eaecef))
|
||||
|
||||
# [0.35.0](https://github.com/expo/entity/compare/v0.34.0...v0.35.0) (2024-04-11)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Revert: require explicit query context specification ([#219](https://github.com/expo/entity/issues/219)) ([1bdfbd6](https://github.com/expo/entity/commit/1bdfbd6b562d1200e4029df0533b5adb2d917831))
|
||||
|
||||
# [0.34.0](https://github.com/expo/entity/compare/v0.33.0...v0.34.0) (2024-04-11)
|
||||
|
||||
### Features
|
||||
|
||||
- require explicit query context specification ([#219](https://github.com/expo/entity/issues/219)) ([8b0b31f](https://github.com/expo/entity/commit/8b0b31fdde5bd565aa527719003ef283a45f55cc))
|
||||
|
||||
# [0.33.0](https://github.com/expo/entity/compare/v0.32.0...v0.33.0) (2023-10-06)
|
||||
|
||||
**Note:** Version bump only for package @expo/entity-database-adapter-knex
|
||||
|
||||
# [0.32.0](https://github.com/expo/entity/compare/v0.31.1...v0.32.0) (2023-06-10)
|
||||
|
||||
### Features
|
||||
|
||||
- add ability to specify knex transaction config ([#207](https://github.com/expo/entity/issues/207)) ([2069a0d](https://github.com/expo/entity/commit/2069a0d9fd1d8f7d090a71a90a758b2429ea5dd3))
|
||||
- entity companion definition static thunk ([#210](https://github.com/expo/entity/issues/210)) ([4b18010](https://github.com/expo/entity/commit/4b18010d42be50ef329f428b08330e21bf676586))
|
||||
|
||||
# [0.31.0](https://github.com/expo/entity/compare/v0.30.0...v0.31.0) (2023-01-19)
|
||||
|
||||
**Note:** Version bump only for package @expo/entity-database-adapter-knex
|
||||
|
||||
# [0.30.0](https://github.com/expo/entity/compare/v0.29.0...v0.30.0) (2022-12-23)
|
||||
|
||||
**Note:** Version bump only for package @expo/entity-database-adapter-knex
|
||||
|
||||
# [0.29.0](https://github.com/expo/entity/compare/v0.28.0...v0.29.0) (2022-10-06)
|
||||
|
||||
**Note:** Version bump only for package @expo/entity-database-adapter-knex
|
||||
|
||||
# [0.28.0](https://github.com/expo/entity/compare/v0.27.0...v0.28.0) (2022-09-13)
|
||||
|
||||
### Features
|
||||
|
||||
- add BigIntField ([#193](https://github.com/expo/entity/issues/193)) ([acbad82](https://github.com/expo/entity/commit/acbad826a37bb5a054b4dcf9be91d11aa5f7f29a))
|
||||
- nested transactions ([#194](https://github.com/expo/entity/issues/194)) ([a77b914](https://github.com/expo/entity/commit/a77b914ad6dc018d1e309c9caf4971088a553ecf))
|
||||
|
||||
# [0.27.0](https://github.com/expo/entity/compare/v0.26.1...v0.27.0) (2022-08-09)
|
||||
|
||||
### Features
|
||||
|
||||
- add orderByRaw to loadManyByRawWhereClauseAsync ([#185](https://github.com/expo/entity/issues/185)) ([2817d78](https://github.com/expo/entity/commit/2817d78392dfecd0093111c24d581bdf01dba06d))
|
||||
|
||||
## [0.26.1](https://github.com/expo/entity/compare/v0.26.0...v0.26.1) (2022-07-28)
|
||||
|
||||
**Note:** Version bump only for package @expo/entity-database-adapter-knex
|
||||
|
||||
# [0.26.0](https://github.com/expo/entity/compare/v0.25.3...v0.26.0) (2022-07-14)
|
||||
|
||||
**Note:** Version bump only for package @expo/entity-database-adapter-knex
|
||||
|
||||
## [0.25.3](https://github.com/expo/entity/compare/v0.25.2...v0.25.3) (2022-03-12)
|
||||
|
||||
**Note:** Version bump only for package @expo/entity-database-adapter-knex
|
||||
|
||||
## [0.25.2](https://github.com/expo/entity/compare/v0.25.1...v0.25.2) (2022-03-12)
|
||||
|
||||
**Note:** Version bump only for package @expo/entity-database-adapter-knex
|
||||
|
||||
## [0.25.1](https://github.com/expo/entity/compare/v0.25.0...v0.25.1) (2022-03-10)
|
||||
|
||||
**Note:** Version bump only for package @expo/entity-database-adapter-knex
|
||||
|
||||
# [0.25.0](https://github.com/expo/entity/compare/v0.24.0...v0.25.0) (2022-03-10)
|
||||
|
||||
**Note:** Version bump only for package @expo/entity-database-adapter-knex
|
||||
|
||||
# [0.24.0](https://github.com/expo/entity/compare/v0.23.0...v0.24.0) (2022-02-16)
|
||||
|
||||
**Note:** Version bump only for package @expo/entity-database-adapter-knex
|
||||
|
||||
# [0.23.0](https://github.com/expo/entity/compare/v0.22.0...v0.23.0) (2022-02-09)
|
||||
|
||||
**Note:** Version bump only for package @expo/entity-database-adapter-knex
|
||||
|
||||
# [0.22.0](https://github.com/expo/entity/compare/v0.21.0...v0.22.0) (2022-02-04)
|
||||
|
||||
### Features
|
||||
|
||||
- Upgrade knex to 1.x in `@expo/entity-database-adapter-knex` ([#150](https://github.com/expo/entity/issues/150)) ([e1b0ee5](https://github.com/expo/entity/commit/e1b0ee528175646239d88aa71ffce73983eeb52f))
|
||||
|
||||
# [0.21.0](https://github.com/expo/entity/compare/v0.20.0...v0.21.0) (2022-01-03)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* add pre-commit callbacks on EntityQueryContext ([#147](https://github.com/expo/entity/issues/147)) ([f1d9847](https://github.com/expo/entity/commit/f1d9847210f5775de964509e44ce493e44482b21))
|
||||
|
||||
|
||||
|
||||
|
||||
- add pre-commit callbacks on EntityQueryContext ([#147](https://github.com/expo/entity/issues/147)) ([f1d9847](https://github.com/expo/entity/commit/f1d9847210f5775de964509e44ce493e44482b21))
|
||||
|
||||
# [0.20.0](https://github.com/expo/entity/compare/v0.19.0...v0.20.0) (2021-12-29)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* add cascading deletion info ([#145](https://github.com/expo/entity/issues/145)) ([3727191](https://github.com/expo/entity/commit/372719176742d3ead6b7cbca29cb66b09b3fe09c))
|
||||
|
||||
|
||||
|
||||
|
||||
- add cascading deletion info ([#145](https://github.com/expo/entity/issues/145)) ([3727191](https://github.com/expo/entity/commit/372719176742d3ead6b7cbca29cb66b09b3fe09c))
|
||||
|
||||
# [0.19.0](https://github.com/expo/entity/compare/v0.18.0...v0.19.0) (2021-10-28)
|
||||
|
||||
**Note:** Version bump only for package @expo/entity-database-adapter-knex
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
# [0.18.0](https://github.com/expo/entity/compare/v0.17.0...v0.18.0) (2021-10-14)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* enable noImplicitOverride tsc setting ([#135](https://github.com/expo/entity/issues/135)) ([4263cb9](https://github.com/expo/entity/commit/4263cb9b8b69fe4fb68c74ec1c7aba04508a1555))
|
||||
* upgrade TypeScript to 4.4 ([#134](https://github.com/expo/entity/issues/134)) ([7612392](https://github.com/expo/entity/commit/7612392dbfd9778d25c465c1626c372d75a6d05a))
|
||||
|
||||
|
||||
|
||||
|
||||
- enable noImplicitOverride tsc setting ([#135](https://github.com/expo/entity/issues/135)) ([4263cb9](https://github.com/expo/entity/commit/4263cb9b8b69fe4fb68c74ec1c7aba04508a1555))
|
||||
- upgrade TypeScript to 4.4 ([#134](https://github.com/expo/entity/issues/134)) ([7612392](https://github.com/expo/entity/commit/7612392dbfd9778d25c465c1626c372d75a6d05a))
|
||||
|
||||
# [0.17.0](https://github.com/expo/entity/compare/v0.16.0...v0.17.0) (2021-08-16)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* Add IntField and FloatField, deprecate NumberField ([#131](https://github.com/expo/entity/issues/131)) ([2f2d963](https://github.com/expo/entity/commit/2f2d963cf92c6575d78f8eeca344a4aadcc9b8d9))
|
||||
|
||||
|
||||
|
||||
|
||||
- Add IntField and FloatField, deprecate NumberField ([#131](https://github.com/expo/entity/issues/131)) ([2f2d963](https://github.com/expo/entity/commit/2f2d963cf92c6575d78f8eeca344a4aadcc9b8d9))
|
||||
|
||||
# [0.16.0](https://github.com/expo/entity/compare/v0.15.0...v0.16.0) (2021-07-07)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* allow null field values in loadManyByFieldEqualityConjunctionAsync ([#130](https://github.com/expo/entity/issues/130)) ([2f37dc6](https://github.com/expo/entity/commit/2f37dc6a37d368b12b3c8375e04f0777fd448797))
|
||||
|
||||
|
||||
|
||||
|
||||
- allow null field values in loadManyByFieldEqualityConjunctionAsync ([#130](https://github.com/expo/entity/issues/130)) ([2f37dc6](https://github.com/expo/entity/commit/2f37dc6a37d368b12b3c8375e04f0777fd448797))
|
||||
|
||||
# [0.15.0](https://github.com/expo/entity/compare/v0.14.1...v0.15.0) (2021-05-26)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* upgrade ioredis, knex, and pg ([#125](https://github.com/expo/entity/issues/125)) ([7c43edf](https://github.com/expo/entity/commit/7c43edf3de37e8af13e2a99bd44f03f54803d9b9))
|
||||
|
||||
- upgrade ioredis, knex, and pg ([#125](https://github.com/expo/entity/issues/125)) ([7c43edf](https://github.com/expo/entity/commit/7c43edf3de37e8af13e2a99bd44f03f54803d9b9))
|
||||
|
||||
### Features
|
||||
|
||||
* secondary cache loader ([#123](https://github.com/expo/entity/issues/123)) ([4cba01e](https://github.com/expo/entity/commit/4cba01eb259c87d60b3026ce776e46f781363690))
|
||||
|
||||
|
||||
|
||||
|
||||
- secondary cache loader ([#123](https://github.com/expo/entity/issues/123)) ([4cba01e](https://github.com/expo/entity/commit/4cba01eb259c87d60b3026ce776e46f781363690))
|
||||
|
||||
## [0.14.1](https://github.com/expo/entity/compare/v0.14.0...v0.14.1) (2021-03-24)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* typescript typedef of EntityMutationInfo ([#121](https://github.com/expo/entity/issues/121)) ([8e8f0dc](https://github.com/expo/entity/commit/8e8f0dc8636e343e29a6012d155e4111477a8989))
|
||||
|
||||
|
||||
|
||||
|
||||
- typescript typedef of EntityMutationInfo ([#121](https://github.com/expo/entity/issues/121)) ([8e8f0dc](https://github.com/expo/entity/commit/8e8f0dc8636e343e29a6012d155e4111477a8989))
|
||||
|
||||
# [0.14.0](https://github.com/expo/entity/compare/v0.13.0...v0.14.0) (2021-03-24)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* mutation validator and trigger cyclic import structure ([#118](https://github.com/expo/entity/issues/118)) ([b11dbc0](https://github.com/expo/entity/commit/b11dbc087ba5ad13c748032698dd73279b7960b0))
|
||||
|
||||
|
||||
|
||||
|
||||
- mutation validator and trigger cyclic import structure ([#118](https://github.com/expo/entity/issues/118)) ([b11dbc0](https://github.com/expo/entity/commit/b11dbc087ba5ad13c748032698dd73279b7960b0))
|
||||
|
||||
# [0.13.0](https://github.com/expo/entity/compare/v0.12.0...v0.13.0) (2021-02-12)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* add basic field type runtime validators ([#113](https://github.com/expo/entity/issues/113)) ([6c4d4b0](https://github.com/expo/entity/commit/6c4d4b03b5404dd776a12c5eb6f0af77a8c964f8))
|
||||
|
||||
|
||||
|
||||
|
||||
- add basic field type runtime validators ([#113](https://github.com/expo/entity/issues/113)) ([6c4d4b0](https://github.com/expo/entity/commit/6c4d4b03b5404dd776a12c5eb6f0af77a8c964f8))
|
||||
|
||||
# [0.12.0](https://github.com/expo/entity/compare/v0.11.0...v0.12.0) (2021-01-22)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* move runInTransaction to ViewerContext ([#108](https://github.com/expo/entity/issues/108)) ([b7309e1](https://github.com/expo/entity/commit/b7309e18a2dd780b64cd306d566de1f12bf8df3a))
|
||||
* remove cache and database adaptor flavor enums ([#109](https://github.com/expo/entity/issues/109)) ([72a77f8](https://github.com/expo/entity/commit/72a77f8b6893a4b76d3b1cce16659bdf3ce473ee))
|
||||
|
||||
|
||||
|
||||
|
||||
- move runInTransaction to ViewerContext ([#108](https://github.com/expo/entity/issues/108)) ([b7309e1](https://github.com/expo/entity/commit/b7309e18a2dd780b64cd306d566de1f12bf8df3a))
|
||||
- remove cache and database adaptor flavor enums ([#109](https://github.com/expo/entity/issues/109)) ([72a77f8](https://github.com/expo/entity/commit/72a77f8b6893a4b76d3b1cce16659bdf3ce473ee))
|
||||
|
||||
# [0.11.0](https://github.com/expo/entity/compare/v0.10.0...v0.11.0) (2020-12-24)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* better cache adapter error handling ([#102](https://github.com/expo/entity/issues/102)) ([15546aa](https://github.com/expo/entity/commit/15546aad97734ab9ac67f1c012485f096a0e94f1))
|
||||
* better database adapter error handling ([#101](https://github.com/expo/entity/issues/101)) ([5208aee](https://github.com/expo/entity/commit/5208aeedb0192f0460ba3d575770a04175f5c3aa))
|
||||
|
||||
|
||||
|
||||
|
||||
- better cache adapter error handling ([#102](https://github.com/expo/entity/issues/102)) ([15546aa](https://github.com/expo/entity/commit/15546aad97734ab9ac67f1c012485f096a0e94f1))
|
||||
- better database adapter error handling ([#101](https://github.com/expo/entity/issues/101)) ([5208aee](https://github.com/expo/entity/commit/5208aeedb0192f0460ba3d575770a04175f5c3aa))
|
||||
|
||||
# [0.10.0](https://github.com/expo/entity/compare/v0.9.1...v0.10.0) (2020-11-17)
|
||||
|
||||
**Note:** Version bump only for package @expo/entity-database-adapter-knex
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
## [0.9.1](https://github.com/expo/entity/compare/v0.9.0...v0.9.1) (2020-11-06)
|
||||
|
||||
**Note:** Version bump only for package @expo/entity-database-adapter-knex
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
# [0.9.0](https://github.com/expo/entity/compare/v0.8.1...v0.9.0) (2020-11-05)
|
||||
|
||||
**Note:** Version bump only for package @expo/entity-database-adapter-knex
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
## [0.8.1](https://github.com/expo/entity/compare/v0.8.0...v0.8.1) (2020-10-09)
|
||||
|
||||
**Note:** Version bump only for package @expo/entity-database-adapter-knex
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
# [0.8.0](https://github.com/expo/entity/compare/v0.7.1...v0.8.0) (2020-10-09)
|
||||
|
||||
**Note:** Version bump only for package @expo/entity-database-adapter-knex
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
## [0.7.1](https://github.com/expo/entity/compare/v0.7.0...v0.7.1) (2020-10-01)
|
||||
|
||||
**Note:** Version bump only for package @expo/entity-database-adapter-knex
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
# [0.7.0](https://github.com/expo/entity/compare/v0.6.0...v0.7.0) (2020-10-01)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* fix index export for EntityMutationTrigger and EntityMutationValidator ([#73](https://github.com/expo/entity/issues/73)) ([7e829f3](https://github.com/expo/entity/commit/7e829f30fde0bd343cb8a584ccda5530cf61ec33))
|
||||
* move cache invalidation to after transaction commit ([#77](https://github.com/expo/entity/issues/77)) ([dbc3c81](https://github.com/expo/entity/commit/dbc3c81b359a668b5134189ce6de49b5893bb5e7))
|
||||
* upgrade packages ([#78](https://github.com/expo/entity/issues/78)) ([9891e74](https://github.com/expo/entity/commit/9891e7469467a28589f529c8d87b10fc2232d3ff))
|
||||
|
||||
|
||||
|
||||
|
||||
- fix index export for EntityMutationTrigger and EntityMutationValidator ([#73](https://github.com/expo/entity/issues/73)) ([7e829f3](https://github.com/expo/entity/commit/7e829f30fde0bd343cb8a584ccda5530cf61ec33))
|
||||
- move cache invalidation to after transaction commit ([#77](https://github.com/expo/entity/issues/77)) ([dbc3c81](https://github.com/expo/entity/commit/dbc3c81b359a668b5134189ce6de49b5893bb5e7))
|
||||
- upgrade packages ([#78](https://github.com/expo/entity/issues/78)) ([9891e74](https://github.com/expo/entity/commit/9891e7469467a28589f529c8d87b10fc2232d3ff))
|
||||
|
||||
# [0.6.0](https://github.com/expo/entity/compare/v0.5.2...v0.6.0) (2020-07-23)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* separate out EntityMutationValidator ([#68](https://github.com/expo/entity/issues/68)) ([547a1ef](https://github.com/expo/entity/commit/547a1efcecd17cc085702d1a3e9888ce5b644b13))
|
||||
|
||||
- separate out EntityMutationValidator ([#68](https://github.com/expo/entity/issues/68)) ([547a1ef](https://github.com/expo/entity/commit/547a1efcecd17cc085702d1a3e9888ce5b644b13))
|
||||
|
||||
### Features
|
||||
|
||||
* entity mutation validators ([#67](https://github.com/expo/entity/issues/67)) ([fc4377d](https://github.com/expo/entity/commit/fc4377d8839da07417b88afc138f73556383d896))
|
||||
* mutation triggers ([#65](https://github.com/expo/entity/issues/65)) ([fd6060c](https://github.com/expo/entity/commit/fd6060cc844f60635b3ce4c400c4877f2df8fa44))
|
||||
|
||||
|
||||
|
||||
|
||||
- entity mutation validators ([#67](https://github.com/expo/entity/issues/67)) ([fc4377d](https://github.com/expo/entity/commit/fc4377d8839da07417b88afc138f73556383d896))
|
||||
- mutation triggers ([#65](https://github.com/expo/entity/issues/65)) ([fd6060c](https://github.com/expo/entity/commit/fd6060cc844f60635b3ce4c400c4877f2df8fa44))
|
||||
|
||||
## [0.5.2](https://github.com/expo/entity/compare/v0.5.1...v0.5.2) (2020-07-02)
|
||||
|
||||
**Note:** Version bump only for package @expo/entity-database-adapter-knex
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
## [0.5.1](https://github.com/expo/entity/compare/v0.5.0...v0.5.1) (2020-07-01)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* correct some typing issues and exports ([#57](https://github.com/expo/entity/issues/57)) ([e91cfba](https://github.com/expo/entity/commit/e91cfba99321c2c4da2078aafd8627cbf0ceca50))
|
||||
|
||||
|
||||
|
||||
|
||||
- correct some typing issues and exports ([#57](https://github.com/expo/entity/issues/57)) ([e91cfba](https://github.com/expo/entity/commit/e91cfba99321c2c4da2078aafd8627cbf0ceca50))
|
||||
|
||||
# 0.5.0 (2020-07-01)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* support entity fields subset of db fields ([#49](https://github.com/expo/entity/issues/49)) ([4e40b2e](https://github.com/expo/entity/commit/4e40b2e521407e521d236978ec3b3b56db3990be))
|
||||
- support entity fields subset of db fields ([#49](https://github.com/expo/entity/issues/49)) ([4e40b2e](https://github.com/expo/entity/commit/4e40b2e521407e521d236978ec3b3b56db3990be))
|
||||
@@ -1,39 +1,42 @@
|
||||
{
|
||||
"name": "@expo/entity-database-adapter-knex",
|
||||
"version": "0.22.0",
|
||||
"version": "0.63.0",
|
||||
"description": "Knex database adapter for @expo/entity",
|
||||
"files": [
|
||||
"build",
|
||||
"!*.tsbuildinfo",
|
||||
"!__*",
|
||||
"src"
|
||||
],
|
||||
"main": "build/index.js",
|
||||
"types": "build/index.d.ts",
|
||||
"main": "build/src/index.js",
|
||||
"types": "build/src/index.d.ts",
|
||||
"scripts": {
|
||||
"tsc": "tsc",
|
||||
"clean": "rm -rf build coverage coverage-integration",
|
||||
"lint": "eslint src",
|
||||
"lint-fix": "eslint src --fix",
|
||||
"test": "jest --rootDir . --config ../../resources/jest.config.js --passWithNoTests",
|
||||
"integration": "../../resources/run-with-docker yarn integration-no-setup",
|
||||
"integration-no-setup": "jest --config ../../resources/jest-integration.config.js --rootDir . --runInBand --passWithNoTests",
|
||||
"barrelsby": "barrelsby --directory src --location top --exclude tests__ --singleQuotes --exportDefault --delete"
|
||||
"build": "tsc --build",
|
||||
"prepack": "rm -rf build && yarn build",
|
||||
"clean": "yarn build --clean",
|
||||
"lint": "yarn run --top-level eslint src",
|
||||
"lint-fix": "yarn lint --fix",
|
||||
"test": "yarn test:all --rootDir $(pwd)",
|
||||
"integration": "yarn integration:all --rootDir $(pwd)"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
"node": ">=18"
|
||||
},
|
||||
"keywords": [
|
||||
"entity"
|
||||
],
|
||||
"author": "Expo",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@expo/entity": "*"
|
||||
},
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
"knex": "^1.0.2"
|
||||
"@expo/entity": "workspace:^",
|
||||
"knex": "^3.1.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@expo/entity": "^0.22.0",
|
||||
"pg": "^8.7.1"
|
||||
"@expo/entity-testing-utils": "workspace:^",
|
||||
"@jest/globals": "30.3.0",
|
||||
"pg": "8.20.0",
|
||||
"ts-mockito": "2.6.1",
|
||||
"typescript": "5.9.3"
|
||||
}
|
||||
}
|
||||
+519
@@ -0,0 +1,519 @@
|
||||
import type {
|
||||
EntityConstructionUtils,
|
||||
EntityPrivacyPolicy,
|
||||
EntityQueryContext,
|
||||
IEntityMetricsAdapter,
|
||||
ReadonlyEntity,
|
||||
ViewerContext,
|
||||
} from '@expo/entity';
|
||||
import type { Result } from '@expo/results';
|
||||
|
||||
import type {
|
||||
FieldEqualityCondition,
|
||||
NullsOrdering,
|
||||
OrderByOrdering,
|
||||
} from './BasePostgresEntityDatabaseAdapter.ts';
|
||||
import { isSingleValueFieldEqualityCondition } from './BasePostgresEntityDatabaseAdapter.ts';
|
||||
import { BaseSQLQueryBuilder } from './BaseSQLQueryBuilder.ts';
|
||||
import type { PaginationStrategy } from './PaginationStrategy.ts';
|
||||
import type { SQLFragment } from './SQLOperator.ts';
|
||||
import type {
|
||||
Connection,
|
||||
EntityKnexDataManager,
|
||||
PageInfo,
|
||||
} from './internal/EntityKnexDataManager.ts';
|
||||
import type { NonNullableKeys } from './internal/utilityTypes.ts';
|
||||
|
||||
export type EntityLoaderBaseOrderByClause = {
|
||||
/**
|
||||
* The OrderByOrdering to order by.
|
||||
*/
|
||||
order: OrderByOrdering;
|
||||
|
||||
/**
|
||||
* Optional nulls ordering for this order by clause. If not provided, no specific nulls ordering is applied.
|
||||
*/
|
||||
nulls?: NullsOrdering | undefined;
|
||||
};
|
||||
|
||||
export type EntityLoaderFieldNameOrderByClause<
|
||||
TFields extends Record<string, any>,
|
||||
TSelectedFields extends keyof TFields = keyof TFields,
|
||||
> = EntityLoaderBaseOrderByClause & {
|
||||
/**
|
||||
* The field name to order by.
|
||||
*/
|
||||
fieldName: TSelectedFields;
|
||||
};
|
||||
|
||||
export type EntityLoaderFieldFragmentOrderByClause<
|
||||
TFields extends Record<string, any>,
|
||||
TSelectedFields extends keyof TFields = keyof TFields,
|
||||
> = EntityLoaderBaseOrderByClause & {
|
||||
/**
|
||||
* The SQL fragment to order by, which can reference selected fields. Example: `COALESCE(NULLIF(display_name, ''), split_part(full_name, '/', 2))`.
|
||||
* May not contain ASC or DESC, as ordering direction is controlled by the `order` property.
|
||||
*/
|
||||
fieldFragment: SQLFragment<Pick<TFields, TSelectedFields>>;
|
||||
};
|
||||
|
||||
export type EntityLoaderOrderByClause<
|
||||
TFields extends Record<string, any>,
|
||||
TSelectedFields extends keyof TFields = keyof TFields,
|
||||
> =
|
||||
| EntityLoaderFieldNameOrderByClause<TFields, TSelectedFields>
|
||||
| EntityLoaderFieldFragmentOrderByClause<TFields, TSelectedFields>;
|
||||
|
||||
/**
|
||||
* SQL modifiers that only affect the selection but not the projection.
|
||||
*/
|
||||
export interface EntityLoaderQuerySelectionModifiers<
|
||||
TFields extends Record<string, any>,
|
||||
TSelectedFields extends keyof TFields = keyof TFields,
|
||||
> {
|
||||
/**
|
||||
* Order the entities by specified columns and orders.
|
||||
*/
|
||||
orderBy?: readonly EntityLoaderOrderByClause<TFields, TSelectedFields>[];
|
||||
|
||||
/**
|
||||
* Skip the specified number of entities queried before returning.
|
||||
*/
|
||||
offset?: number;
|
||||
|
||||
/**
|
||||
* Limit the number of entities returned.
|
||||
*/
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Function to be used for constructing search fields based on field names.
|
||||
* The function takes a field name and returns a SQLFragment that can be used in the search field portion.
|
||||
*/
|
||||
export type EntityLoaderFieldNameConstructorFn<
|
||||
TFields extends Record<string, any>,
|
||||
TSelectedFields extends keyof TFields = keyof TFields,
|
||||
> = (fieldName: TSelectedFields) => SQLFragment<TFields>;
|
||||
|
||||
/**
|
||||
* Specification for a search field that is a manually constructed SQLFragment. Useful for complex search fields that require
|
||||
* transformations to make nullable fields non-null or to make combinations of multiple fields.
|
||||
*/
|
||||
export type EntityLoaderSearchFieldSQLFragmentFnSpecification<
|
||||
TFields extends Record<string, any>,
|
||||
TSelectedFields extends keyof TFields = keyof TFields,
|
||||
> = {
|
||||
/**
|
||||
* Function that constructs the SQLFragment for the search field.
|
||||
*
|
||||
* @param getFragmentForFieldName - A helper function that takes a field name and returns a SQLFragment for that field, which should be used
|
||||
* to construct the final SQLFragment for the search field.
|
||||
* @returns a SQLFragment representing the search field, which must reference selected fields through the provided helper function.
|
||||
*
|
||||
* @example
|
||||
* For example, to construct a search field that searches by display name (nullable column) with fallback to full name,
|
||||
* the fieldConstructor function could be implemented as follows:
|
||||
* ```
|
||||
* fieldConstructor: (getFragmentForFieldName) => {
|
||||
* const displayNameFragment = getFragmentForFieldName('display_name');
|
||||
* const fullNameFragment = getFragmentForFieldName('full_name');
|
||||
* return sql`COALESCE(NULLIF(${displayNameFragment}, ''), split_part(${fullNameFragment}, '/', 2))`;
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
fieldConstructor: (
|
||||
/**
|
||||
* Helper function to get a SQLFragment for a given field name, which should be used to construct the final SQLFragment for the search field.
|
||||
*/
|
||||
getFragmentForFieldName: EntityLoaderFieldNameConstructorFn<TFields, TSelectedFields>,
|
||||
) => SQLFragment<Pick<TFields, TSelectedFields>>;
|
||||
};
|
||||
|
||||
/**
|
||||
* Set of selected fields that are non-nullable, which can be used for search fields that require non-nullable fields.
|
||||
* To use a nullable field as a search field, use an EntityLoaderSearchFieldSQLFragmentFnSpecification with appropriate SQL
|
||||
* to handle null values, such as `COALESCE(field_name, '')` to treat nulls as empty strings for searching.
|
||||
*/
|
||||
export type NonNullableSelectedFields<
|
||||
TFields extends Record<string, any>,
|
||||
TSelectedFields extends keyof TFields,
|
||||
> = TSelectedFields & NonNullableKeys<TFields>;
|
||||
|
||||
/**
|
||||
* Specification for a search field that can be either a simple selected field name (which must be non-nullable) or a manually constructed
|
||||
* SQLFragment using EntityLoaderSearchFieldSQLFragmentFnSpecification.
|
||||
*/
|
||||
export type EntityLoaderSearchFieldSpecification<
|
||||
TFields extends Record<string, any>,
|
||||
TSelectedFields extends keyof TFields = keyof TFields,
|
||||
> =
|
||||
| NonNullableSelectedFields<TFields, TSelectedFields>
|
||||
| EntityLoaderSearchFieldSQLFragmentFnSpecification<TFields, TSelectedFields>;
|
||||
|
||||
interface SearchSpecificationBase<
|
||||
TFields extends Record<string, any>,
|
||||
TSelectedFields extends keyof TFields = keyof TFields,
|
||||
> {
|
||||
/**
|
||||
* The search term to search for. Must be a non-empty string.
|
||||
*/
|
||||
term: string;
|
||||
|
||||
/**
|
||||
* The fields to search within.
|
||||
*/
|
||||
fields: readonly EntityLoaderSearchFieldSpecification<TFields, TSelectedFields>[];
|
||||
}
|
||||
|
||||
interface ILikeSearchSpecification<
|
||||
TFields extends Record<string, any>,
|
||||
TSelectedFields extends keyof TFields = keyof TFields,
|
||||
> extends SearchSpecificationBase<TFields, TSelectedFields> {
|
||||
/**
|
||||
* Case-insensitive pattern matching search using SQL ILIKE operator.
|
||||
* Results are ordered by the fields being searched within in the order specified, then by ID for tie-breaking and stable pagination.
|
||||
*/
|
||||
strategy: PaginationStrategy.ILIKE_SEARCH;
|
||||
}
|
||||
|
||||
interface TrigramSearchSpecification<
|
||||
TFields extends Record<string, any>,
|
||||
TSelectedFields extends keyof TFields = keyof TFields,
|
||||
> extends SearchSpecificationBase<TFields, TSelectedFields> {
|
||||
/**
|
||||
* Similarity search using PostgreSQL trigram similarity. Results are ordered by exact match priority, then by similarity score, then by specified extra order by fields if provided, then by ID for tie-breaking and stable pagination.
|
||||
* Note that trigram similarity search can be significantly slower than ILIKE search, especially on large datasets without appropriate indexes, and results may not be as relevant as more advanced full-text search solutions.
|
||||
* It is recommended to use this strategy only when ILIKE search does not meet the application's needs and to ensure appropriate database indexing for performance.
|
||||
*/
|
||||
strategy: PaginationStrategy.TRIGRAM_SEARCH;
|
||||
|
||||
/**
|
||||
* Similarity threshold for trigram matching.
|
||||
* Must be between 0 and 1, where:
|
||||
* - 0 matches everything
|
||||
* - 1 requires exact match
|
||||
*
|
||||
* Recommended threshold values:
|
||||
* - 0.3: Loose matching, allows more variation (default PostgreSQL similarity threshold)
|
||||
* - 0.4-0.5: Moderate matching, good balance for most use cases
|
||||
* - 0.6+: Strict matching, requires high similarity
|
||||
*/
|
||||
threshold: number;
|
||||
|
||||
/**
|
||||
* Optional additional fields to order by after similarity score and before ID for tie-breaking.
|
||||
* These fields are independent of search fields and can be used to provide meaningful
|
||||
* ordering when multiple results have the same similarity score.
|
||||
*/
|
||||
extraOrderByFields?: readonly EntityLoaderSearchFieldSpecification<TFields, TSelectedFields>[];
|
||||
}
|
||||
|
||||
interface StandardPaginationSpecification<
|
||||
TFields extends Record<string, any>,
|
||||
TSelectedFields extends keyof TFields = keyof TFields,
|
||||
> {
|
||||
/**
|
||||
* Standard pagination without search. Results are ordered by the specified orderBy fields.
|
||||
*/
|
||||
strategy: PaginationStrategy.STANDARD;
|
||||
|
||||
/**
|
||||
* Order the entities by specified columns and orders. If the ID field is not included, it will be automatically added for stable pagination.
|
||||
*/
|
||||
orderBy: readonly EntityLoaderOrderByClause<TFields, TSelectedFields>[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Pagination specification for SQL-based pagination (with or without search).
|
||||
*/
|
||||
export type PaginationSpecification<
|
||||
TFields extends Record<string, any>,
|
||||
TSelectedFields extends keyof TFields = keyof TFields,
|
||||
> =
|
||||
| StandardPaginationSpecification<TFields, TSelectedFields>
|
||||
| ILikeSearchSpecification<TFields, TSelectedFields>
|
||||
| TrigramSearchSpecification<TFields, TSelectedFields>;
|
||||
|
||||
/**
|
||||
* Base unified pagination arguments
|
||||
*/
|
||||
interface EntityLoaderBaseUnifiedPaginationArgs<
|
||||
TFields extends Record<string, any>,
|
||||
TSelectedFields extends keyof TFields = keyof TFields,
|
||||
> {
|
||||
/**
|
||||
* SQLFragment representing the WHERE clause to filter the entities being paginated.
|
||||
*/
|
||||
where?: SQLFragment<Pick<TFields, TSelectedFields>>;
|
||||
|
||||
/**
|
||||
* Pagination specification determining how to order and paginate results.
|
||||
*/
|
||||
pagination: PaginationSpecification<TFields, TSelectedFields>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Forward unified pagination arguments
|
||||
*/
|
||||
export interface EntityLoaderForwardUnifiedPaginationArgs<
|
||||
TFields extends Record<string, any>,
|
||||
TSelectedFields extends keyof TFields = keyof TFields,
|
||||
> extends EntityLoaderBaseUnifiedPaginationArgs<TFields, TSelectedFields> {
|
||||
/**
|
||||
* The number of entities to return starting from the entity after the cursor. Must be a positive integer.
|
||||
*/
|
||||
first: number;
|
||||
|
||||
/**
|
||||
* The number of entities to return starting from the entity before the cursor. Must be a positive integer.
|
||||
* Not allowed with forward pagination.
|
||||
*/
|
||||
last?: never;
|
||||
|
||||
/**
|
||||
* The cursor to paginate after for forward pagination.
|
||||
*/
|
||||
after?: string;
|
||||
|
||||
/**
|
||||
* The cursor to paginate before for backward pagination.
|
||||
* Not allowed with forward pagination.
|
||||
*/
|
||||
before?: never;
|
||||
}
|
||||
|
||||
/**
|
||||
* Backward unified pagination arguments
|
||||
*/
|
||||
export interface EntityLoaderBackwardUnifiedPaginationArgs<
|
||||
TFields extends Record<string, any>,
|
||||
TSelectedFields extends keyof TFields = keyof TFields,
|
||||
> extends EntityLoaderBaseUnifiedPaginationArgs<TFields, TSelectedFields> {
|
||||
/**
|
||||
* The number of entities to return starting from the entity after the cursor. Must be a positive integer.
|
||||
* Not allowed with backward pagination.
|
||||
*/
|
||||
first?: never;
|
||||
|
||||
/**
|
||||
* The number of entities to return starting from the entity before the cursor. Must be a positive integer.
|
||||
*/
|
||||
last: number;
|
||||
|
||||
/**
|
||||
* The cursor to paginate after for forward pagination.
|
||||
* Not allowed with backward pagination.
|
||||
*/
|
||||
after?: never;
|
||||
|
||||
/**
|
||||
* The cursor to paginate before for backward pagination.
|
||||
*/
|
||||
before?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Load page pagination arguments, which can be either forward or backward unified pagination arguments.
|
||||
*/
|
||||
export type EntityLoaderLoadPageArgs<
|
||||
TFields extends Record<string, any>,
|
||||
TSelectedFields extends keyof TFields = keyof TFields,
|
||||
> =
|
||||
| EntityLoaderForwardUnifiedPaginationArgs<TFields, TSelectedFields>
|
||||
| EntityLoaderBackwardUnifiedPaginationArgs<TFields, TSelectedFields>;
|
||||
|
||||
/**
|
||||
* Authorization-result-based knex entity loader for non-data-loader-based load methods.
|
||||
* All loads through this loader are results (or null for some loader methods), where an
|
||||
* unsuccessful result means an authorization error or entity construction error occurred.
|
||||
* Other errors are thrown.
|
||||
*/
|
||||
export class AuthorizationResultBasedKnexEntityLoader<
|
||||
TFields extends Record<string, any>,
|
||||
TIDField extends keyof NonNullable<Pick<TFields, TSelectedFields>>,
|
||||
TViewerContext extends ViewerContext,
|
||||
TEntity extends ReadonlyEntity<TFields, TIDField, TViewerContext, TSelectedFields>,
|
||||
TPrivacyPolicy extends EntityPrivacyPolicy<
|
||||
TFields,
|
||||
TIDField,
|
||||
TViewerContext,
|
||||
TEntity,
|
||||
TSelectedFields
|
||||
>,
|
||||
TSelectedFields extends keyof TFields = keyof TFields,
|
||||
> {
|
||||
constructor(
|
||||
private readonly queryContext: EntityQueryContext,
|
||||
private readonly knexDataManager: EntityKnexDataManager<TFields, TIDField>,
|
||||
protected readonly metricsAdapter: IEntityMetricsAdapter,
|
||||
private readonly constructionUtils: EntityConstructionUtils<
|
||||
TFields,
|
||||
TIDField,
|
||||
TViewerContext,
|
||||
TEntity,
|
||||
TPrivacyPolicy,
|
||||
TSelectedFields
|
||||
>,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Authorization-result-based version of the EnforcingKnexEntityLoader method by the same name.
|
||||
* @returns the first entity results that matches the query, where result error can be
|
||||
* UnauthorizedError
|
||||
*/
|
||||
async loadFirstByFieldEqualityConjunctionAsync<N extends keyof Pick<TFields, TSelectedFields>>(
|
||||
fieldEqualityOperands: readonly FieldEqualityCondition<TFields, N>[],
|
||||
querySelectionModifiers: Omit<
|
||||
EntityLoaderQuerySelectionModifiers<TFields, TSelectedFields>,
|
||||
'limit'
|
||||
> &
|
||||
Required<Pick<EntityLoaderQuerySelectionModifiers<TFields, TSelectedFields>, 'orderBy'>>,
|
||||
): Promise<Result<TEntity> | null> {
|
||||
const results = await this.loadManyByFieldEqualityConjunctionAsync(fieldEqualityOperands, {
|
||||
...querySelectionModifiers,
|
||||
limit: 1,
|
||||
});
|
||||
return results[0] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Authorization-result-based version of the EnforcingKnexEntityLoader method by the same name.
|
||||
* @returns array of entity results that match the query, where result error can be UnauthorizedError
|
||||
*/
|
||||
async loadManyByFieldEqualityConjunctionAsync<N extends keyof Pick<TFields, TSelectedFields>>(
|
||||
fieldEqualityOperands: readonly FieldEqualityCondition<TFields, N>[],
|
||||
querySelectionModifiers: EntityLoaderQuerySelectionModifiers<TFields, TSelectedFields> = {},
|
||||
): Promise<readonly Result<TEntity>[]> {
|
||||
for (const fieldEqualityOperand of fieldEqualityOperands) {
|
||||
const fieldValues = isSingleValueFieldEqualityCondition(fieldEqualityOperand)
|
||||
? [fieldEqualityOperand.fieldValue]
|
||||
: fieldEqualityOperand.fieldValues;
|
||||
this.constructionUtils.validateFieldAndValues(fieldEqualityOperand.fieldName, fieldValues);
|
||||
}
|
||||
|
||||
const fieldObjects = await this.knexDataManager.loadManyByFieldEqualityConjunctionAsync(
|
||||
this.queryContext,
|
||||
fieldEqualityOperands,
|
||||
querySelectionModifiers,
|
||||
);
|
||||
return await this.constructionUtils.constructAndAuthorizeEntitiesArrayAsync(fieldObjects);
|
||||
}
|
||||
|
||||
/**
|
||||
* Authorization-result-based version of the EnforcingKnexEntityLoader method by the same name.
|
||||
* @returns SQL query builder for building and executing SQL queries that when executed returns entity results where result error can be UnauthorizedError.
|
||||
*/
|
||||
loadManyBySQL(
|
||||
fragment: SQLFragment<Pick<TFields, TSelectedFields>>,
|
||||
modifiers: EntityLoaderQuerySelectionModifiers<TFields, TSelectedFields> = {},
|
||||
): AuthorizationResultBasedSQLQueryBuilder<
|
||||
TFields,
|
||||
TIDField,
|
||||
TViewerContext,
|
||||
TEntity,
|
||||
TPrivacyPolicy,
|
||||
TSelectedFields
|
||||
> {
|
||||
return new AuthorizationResultBasedSQLQueryBuilder(
|
||||
this.knexDataManager,
|
||||
this.constructionUtils,
|
||||
this.queryContext,
|
||||
fragment,
|
||||
modifiers,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Load a page of entities with Relay-style cursor pagination using a unified pagination specification.
|
||||
* Only returns successfully authorized entities for cursor stability; failed authorization results are filtered out.
|
||||
*
|
||||
* @returns Connection with only successfully authorized entities
|
||||
*/
|
||||
async loadPageAsync(
|
||||
args: EntityLoaderLoadPageArgs<TFields, TSelectedFields>,
|
||||
): Promise<Connection<TEntity>> {
|
||||
const pageResult = await this.knexDataManager.loadPageAsync(this.queryContext, args);
|
||||
|
||||
const edgeResults = await Promise.all(
|
||||
pageResult.edges.map(async (edge) => {
|
||||
const entityResult = await this.constructionUtils.constructAndAuthorizeEntityAsync(
|
||||
edge.node,
|
||||
);
|
||||
if (!entityResult.ok) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
...edge,
|
||||
node: entityResult.value,
|
||||
};
|
||||
}),
|
||||
);
|
||||
const edges = edgeResults.filter((edge) => edge !== null);
|
||||
const pageInfo: PageInfo = {
|
||||
...pageResult.pageInfo,
|
||||
startCursor: edges[0]?.cursor ?? null,
|
||||
endCursor: edges[edges.length - 1]?.cursor ?? null,
|
||||
};
|
||||
|
||||
return {
|
||||
edges,
|
||||
pageInfo,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Authorization-result-based version of the EnforcingKnexEntityLoader method by the same name.
|
||||
* @returns The pagination cursor for the given entity.
|
||||
*/
|
||||
getPaginationCursorForEntity(entity: TEntity): string {
|
||||
return this.knexDataManager.getCursorForEntityID(entity.getID());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* SQL query builder implementation for AuthorizationResultBasedKnexEntityLoader.
|
||||
*/
|
||||
export class AuthorizationResultBasedSQLQueryBuilder<
|
||||
TFields extends Record<string, any>,
|
||||
TIDField extends keyof NonNullable<Pick<TFields, TSelectedFields>>,
|
||||
TViewerContext extends ViewerContext,
|
||||
TEntity extends ReadonlyEntity<TFields, TIDField, TViewerContext, TSelectedFields>,
|
||||
TPrivacyPolicy extends EntityPrivacyPolicy<
|
||||
TFields,
|
||||
TIDField,
|
||||
TViewerContext,
|
||||
TEntity,
|
||||
TSelectedFields
|
||||
>,
|
||||
TSelectedFields extends keyof TFields,
|
||||
> extends BaseSQLQueryBuilder<TFields, TSelectedFields, Result<TEntity>> {
|
||||
constructor(
|
||||
private readonly knexDataManager: EntityKnexDataManager<TFields, TIDField>,
|
||||
private readonly constructionUtils: EntityConstructionUtils<
|
||||
TFields,
|
||||
TIDField,
|
||||
TViewerContext,
|
||||
TEntity,
|
||||
TPrivacyPolicy,
|
||||
TSelectedFields
|
||||
>,
|
||||
private readonly queryContext: EntityQueryContext,
|
||||
sqlFragment: SQLFragment<Pick<TFields, TSelectedFields>>,
|
||||
modifiers: EntityLoaderQuerySelectionModifiers<TFields, TSelectedFields>,
|
||||
) {
|
||||
super(sqlFragment, modifiers);
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute the query and return results.
|
||||
*/
|
||||
async executeInternalAsync(): Promise<readonly Result<TEntity>[]> {
|
||||
const fieldObjects = await this.knexDataManager.loadManyBySQLFragmentAsync(
|
||||
this.queryContext,
|
||||
this.getSQLFragment(),
|
||||
this.getModifiers(),
|
||||
);
|
||||
return await this.constructionUtils.constructAndAuthorizeEntitiesArrayAsync(fieldObjects);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,281 @@
|
||||
import type { EntityQueryContext } from '@expo/entity';
|
||||
import {
|
||||
EntityDatabaseAdapter,
|
||||
getDatabaseFieldForEntityField,
|
||||
transformDatabaseObjectToFields,
|
||||
} from '@expo/entity';
|
||||
import type { Knex } from 'knex';
|
||||
|
||||
import type { SQLFragment } from './SQLOperator.ts';
|
||||
|
||||
/**
|
||||
* Equality operand that is used for selecting entities with a field with a single value.
|
||||
*/
|
||||
export interface SingleValueFieldEqualityCondition<
|
||||
TFields extends Record<string, any>,
|
||||
N extends keyof TFields = keyof TFields,
|
||||
> {
|
||||
fieldName: N;
|
||||
fieldValue: TFields[N];
|
||||
}
|
||||
|
||||
/**
|
||||
* Equality operand that is used for selecting entities with a field matching one of multiple values.
|
||||
*/
|
||||
export interface MultiValueFieldEqualityCondition<
|
||||
TFields extends Record<string, any>,
|
||||
N extends keyof TFields = keyof TFields,
|
||||
> {
|
||||
fieldName: N;
|
||||
fieldValues: readonly TFields[N][];
|
||||
}
|
||||
|
||||
/**
|
||||
* A single equality operand for use in a selection clause.
|
||||
* See EntityLoader.loadManyByFieldEqualityConjunctionAsync documentation for examples.
|
||||
*/
|
||||
export type FieldEqualityCondition<
|
||||
TFields extends Record<string, any>,
|
||||
N extends keyof TFields = keyof TFields,
|
||||
> = SingleValueFieldEqualityCondition<TFields, N> | MultiValueFieldEqualityCondition<TFields, N>;
|
||||
|
||||
export function isSingleValueFieldEqualityCondition<
|
||||
TFields extends Record<string, any>,
|
||||
N extends keyof TFields = keyof TFields,
|
||||
>(
|
||||
condition: FieldEqualityCondition<TFields, N>,
|
||||
): condition is SingleValueFieldEqualityCondition<TFields, N> {
|
||||
return (condition as SingleValueFieldEqualityCondition<TFields, N>).fieldValue !== undefined;
|
||||
}
|
||||
|
||||
export interface TableFieldSingleValueEqualityCondition {
|
||||
tableField: string;
|
||||
tableValue: any;
|
||||
}
|
||||
|
||||
export interface TableFieldMultiValueEqualityCondition {
|
||||
tableField: string;
|
||||
tableValues: readonly any[];
|
||||
}
|
||||
|
||||
export enum NullsOrdering {
|
||||
FIRST = 'first',
|
||||
LAST = 'last',
|
||||
}
|
||||
|
||||
/**
|
||||
* Ordering options for `orderBy` clauses.
|
||||
*/
|
||||
export enum OrderByOrdering {
|
||||
/**
|
||||
* Ascending order (lowest to highest).
|
||||
* Ascending order puts smaller values first, where "smaller" is defined in terms of the %3C operator.
|
||||
*/
|
||||
ASCENDING = 'asc',
|
||||
|
||||
/**
|
||||
* Descending order (highest to lowest).
|
||||
* Descending order puts larger values first, where "larger" is defined in terms of the %3E operator.
|
||||
*/
|
||||
DESCENDING = 'desc',
|
||||
}
|
||||
|
||||
export type PostgresOrderByClause<TFields extends Record<string, any>> =
|
||||
| {
|
||||
/**
|
||||
* The field name to order by.
|
||||
*/
|
||||
fieldName: keyof TFields;
|
||||
|
||||
/**
|
||||
* The OrderByOrdering to order by.
|
||||
*/
|
||||
order: OrderByOrdering;
|
||||
|
||||
/**
|
||||
* Optional nulls ordering. If not provided, the database default is used
|
||||
* (NULLS LAST for ASC, NULLS FIRST for DESC in PostgreSQL).
|
||||
*/
|
||||
nulls?: NullsOrdering | undefined;
|
||||
}
|
||||
| {
|
||||
/**
|
||||
* A raw SQL fragment to order by. May not contain ASC or DESC, as ordering direction is determined by the `order` property.
|
||||
*/
|
||||
fieldFragment: SQLFragment<TFields>;
|
||||
|
||||
/**
|
||||
* The OrderByOrdering to order by.
|
||||
*/
|
||||
order: OrderByOrdering;
|
||||
|
||||
/**
|
||||
* Optional nulls ordering. If not provided, the database default is used
|
||||
* (NULLS LAST for ASC, NULLS FIRST for DESC in PostgreSQL).
|
||||
*/
|
||||
nulls?: NullsOrdering | undefined;
|
||||
};
|
||||
|
||||
/**
|
||||
* SQL modifiers that only affect the selection but not the projection.
|
||||
*/
|
||||
export interface PostgresQuerySelectionModifiers<TFields extends Record<string, any>> {
|
||||
/**
|
||||
* Order the entities by specified columns and orders.
|
||||
*/
|
||||
orderBy?: readonly PostgresOrderByClause<TFields>[];
|
||||
|
||||
/**
|
||||
* Skip the specified number of entities queried before returning.
|
||||
*/
|
||||
offset?: number;
|
||||
|
||||
/**
|
||||
* Limit the number of entities returned.
|
||||
*/
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
export type TableOrderByClause<TFields extends Record<string, any>> =
|
||||
| {
|
||||
columnName: string;
|
||||
order: OrderByOrdering;
|
||||
nulls: NullsOrdering | undefined;
|
||||
}
|
||||
| {
|
||||
columnFragment: SQLFragment<TFields>;
|
||||
order: OrderByOrdering;
|
||||
nulls: NullsOrdering | undefined;
|
||||
};
|
||||
|
||||
export interface TableQuerySelectionModifiers<TFields extends Record<string, any>> {
|
||||
orderBy: TableOrderByClause<TFields>[] | undefined;
|
||||
offset: number | undefined;
|
||||
limit: number | undefined;
|
||||
}
|
||||
|
||||
export abstract class BasePostgresEntityDatabaseAdapter<
|
||||
TFields extends Record<string, any>,
|
||||
TIDField extends keyof TFields,
|
||||
> extends EntityDatabaseAdapter<TFields, TIDField> {
|
||||
/**
|
||||
* Get the maximum page size for pagination.
|
||||
* @returns maximum page size if configured, undefined otherwise
|
||||
*/
|
||||
get paginationMaxPageSize(): number | undefined {
|
||||
return undefined;
|
||||
}
|
||||
/**
|
||||
* Fetch many objects matching the conjunction of where clauses constructed from
|
||||
* specified field equality operands.
|
||||
*
|
||||
* @param queryContext - query context with which to perform the fetch
|
||||
* @param fieldEqualityOperands - list of field equality where clause operand specifications
|
||||
* @param querySelectionModifiers - limit, offset, orderBy, and orderByRaw for the query
|
||||
* @returns array of objects matching the query
|
||||
*/
|
||||
async fetchManyByFieldEqualityConjunctionAsync<N extends keyof TFields>(
|
||||
queryContext: EntityQueryContext,
|
||||
fieldEqualityOperands: readonly FieldEqualityCondition<TFields, N>[],
|
||||
querySelectionModifiers: PostgresQuerySelectionModifiers<TFields>,
|
||||
): Promise<readonly Readonly<TFields>[]> {
|
||||
const tableFieldSingleValueOperands: TableFieldSingleValueEqualityCondition[] = [];
|
||||
const tableFieldMultipleValueOperands: TableFieldMultiValueEqualityCondition[] = [];
|
||||
for (const operand of fieldEqualityOperands) {
|
||||
if (isSingleValueFieldEqualityCondition(operand)) {
|
||||
tableFieldSingleValueOperands.push({
|
||||
tableField: getDatabaseFieldForEntityField(this.entityConfiguration, operand.fieldName),
|
||||
tableValue: operand.fieldValue,
|
||||
});
|
||||
} else {
|
||||
tableFieldMultipleValueOperands.push({
|
||||
tableField: getDatabaseFieldForEntityField(this.entityConfiguration, operand.fieldName),
|
||||
tableValues: operand.fieldValues,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const results = await this.fetchManyByFieldEqualityConjunctionInternalAsync(
|
||||
queryContext.getQueryInterface(),
|
||||
this.entityConfiguration.tableName,
|
||||
tableFieldSingleValueOperands,
|
||||
tableFieldMultipleValueOperands,
|
||||
this.convertToTableQueryModifiers(querySelectionModifiers),
|
||||
);
|
||||
|
||||
return results.map((result) =>
|
||||
transformDatabaseObjectToFields(this.entityConfiguration, this.fieldTransformerMap, result),
|
||||
);
|
||||
}
|
||||
|
||||
protected abstract fetchManyByFieldEqualityConjunctionInternalAsync(
|
||||
queryInterface: Knex,
|
||||
tableName: string,
|
||||
tableFieldSingleValueEqualityOperands: TableFieldSingleValueEqualityCondition[],
|
||||
tableFieldMultiValueEqualityOperands: TableFieldMultiValueEqualityCondition[],
|
||||
querySelectionModifiers: TableQuerySelectionModifiers<TFields>,
|
||||
): Promise<object[]>;
|
||||
|
||||
/**
|
||||
* Fetch many objects matching the SQL fragment.
|
||||
*
|
||||
* @param queryContext - query context with which to perform the fetch
|
||||
* @param sqlFragment - SQLFragment for the WHERE clause of the query
|
||||
* @param querySelectionModifiers - limit, offset, and orderByFragment for the query
|
||||
* @returns array of objects matching the query
|
||||
*/
|
||||
async fetchManyBySQLFragmentAsync(
|
||||
queryContext: EntityQueryContext,
|
||||
sqlFragment: SQLFragment<TFields>,
|
||||
querySelectionModifiers: PostgresQuerySelectionModifiers<TFields>,
|
||||
): Promise<readonly Readonly<TFields>[]> {
|
||||
const results = await this.fetchManyBySQLFragmentInternalAsync(
|
||||
queryContext.getQueryInterface(),
|
||||
this.entityConfiguration.tableName,
|
||||
sqlFragment,
|
||||
this.convertToTableQueryModifiers(querySelectionModifiers),
|
||||
);
|
||||
|
||||
return results.map((result) =>
|
||||
transformDatabaseObjectToFields(this.entityConfiguration, this.fieldTransformerMap, result),
|
||||
);
|
||||
}
|
||||
|
||||
protected abstract fetchManyBySQLFragmentInternalAsync(
|
||||
queryInterface: Knex,
|
||||
tableName: string,
|
||||
sqlFragment: SQLFragment<TFields>,
|
||||
querySelectionModifiers: TableQuerySelectionModifiers<TFields>,
|
||||
): Promise<object[]>;
|
||||
|
||||
private convertToTableQueryModifiers(
|
||||
querySelectionModifiers: PostgresQuerySelectionModifiers<TFields>,
|
||||
): TableQuerySelectionModifiers<TFields> {
|
||||
const orderBy = querySelectionModifiers.orderBy;
|
||||
return {
|
||||
orderBy:
|
||||
orderBy !== undefined
|
||||
? orderBy.map((orderBySpecification): TableOrderByClause<TFields> => {
|
||||
if ('fieldName' in orderBySpecification) {
|
||||
return {
|
||||
columnName: getDatabaseFieldForEntityField(
|
||||
this.entityConfiguration,
|
||||
orderBySpecification.fieldName,
|
||||
),
|
||||
order: orderBySpecification.order,
|
||||
nulls: orderBySpecification.nulls,
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
columnFragment: orderBySpecification.fieldFragment,
|
||||
order: orderBySpecification.order,
|
||||
nulls: orderBySpecification.nulls,
|
||||
};
|
||||
}
|
||||
})
|
||||
: undefined,
|
||||
offset: querySelectionModifiers.offset,
|
||||
limit: querySelectionModifiers.limit,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
import type {
|
||||
EntityLoaderOrderByClause,
|
||||
EntityLoaderQuerySelectionModifiers,
|
||||
} from './AuthorizationResultBasedKnexEntityLoader.ts';
|
||||
import type { NullsOrdering } from './BasePostgresEntityDatabaseAdapter.ts';
|
||||
import { OrderByOrdering } from './BasePostgresEntityDatabaseAdapter.ts';
|
||||
import type { SQLFragment } from './SQLOperator.ts';
|
||||
|
||||
/**
|
||||
* Base SQL query builder that provides common functionality for building SQL queries.
|
||||
*/
|
||||
export abstract class BaseSQLQueryBuilder<
|
||||
TFields extends Record<string, any>,
|
||||
TSelectedFields extends keyof TFields,
|
||||
TResultType,
|
||||
> {
|
||||
private executed = false;
|
||||
|
||||
constructor(
|
||||
private readonly sqlFragment: SQLFragment<Pick<TFields, TSelectedFields>>,
|
||||
private readonly modifiers: {
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
orderBy?: readonly EntityLoaderOrderByClause<TFields, TSelectedFields>[];
|
||||
},
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Limit the number of results
|
||||
*/
|
||||
limit(n: number): this {
|
||||
this.modifiers.limit = n;
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Skip a number of results
|
||||
*/
|
||||
offset(n: number): this {
|
||||
this.modifiers.offset = n;
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Order by a field. Can be called multiple times to add multiple order bys.
|
||||
*/
|
||||
orderBy(
|
||||
fieldName: TSelectedFields,
|
||||
order: OrderByOrdering = OrderByOrdering.ASCENDING,
|
||||
nulls: NullsOrdering | undefined = undefined,
|
||||
): this {
|
||||
this.modifiers.orderBy = [...(this.modifiers.orderBy ?? []), { fieldName, order, nulls }];
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Order by a SQL fragment expression.
|
||||
* Provides type-safe, parameterized ORDER BY clauses
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* query.orderByFragment(
|
||||
* sql`(data->>'createdAt')::timestamp`,
|
||||
* OrderByOrdering.DESCENDING,
|
||||
* );
|
||||
* // Generates ORDER BY clause that orders by the createdAt field in the JSONB data column, cast to a timestamp, in descending order.
|
||||
* // Note that the SQL fragment is parameterized, so it is safe from SQL injection.
|
||||
* // The generated SQL would look like: ORDER BY (data->>'createdAt')::timestamp DESC
|
||||
* ```
|
||||
*
|
||||
* @param fragment - The SQL fragment to order by. Must not include the ASC/DESC keyword, as ordering direction is determined by the `order` parameter.
|
||||
* @param order - The ordering direction (ascending or descending). Defaults to ascending.
|
||||
*/
|
||||
orderBySQL(
|
||||
fragment: SQLFragment<Pick<TFields, TSelectedFields>>,
|
||||
order: OrderByOrdering = OrderByOrdering.ASCENDING,
|
||||
nulls: NullsOrdering | undefined = undefined,
|
||||
): this {
|
||||
this.modifiers.orderBy = [
|
||||
...(this.modifiers.orderBy ?? []),
|
||||
{ fieldFragment: fragment, order, nulls },
|
||||
];
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current modifiers as QuerySelectionModifiersWithOrderByFragment<TFields>
|
||||
*/
|
||||
protected getModifiers(): EntityLoaderQuerySelectionModifiers<TFields, TSelectedFields> {
|
||||
return this.modifiers;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the SQL fragment
|
||||
*/
|
||||
protected getSQLFragment(): SQLFragment<Pick<TFields, TSelectedFields>> {
|
||||
return this.sqlFragment;
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute the query and return results.
|
||||
* Implementation depends on the specific loader type.
|
||||
*/
|
||||
public async executeAsync(): Promise<readonly TResultType[]> {
|
||||
if (this.executed) {
|
||||
throw new Error(
|
||||
'Query has already been executed. Create a new query builder to execute again.',
|
||||
);
|
||||
}
|
||||
this.executed = true;
|
||||
return await this.executeInternalAsync();
|
||||
}
|
||||
|
||||
protected abstract executeInternalAsync(): Promise<readonly TResultType[]>;
|
||||
}
|
||||
@@ -0,0 +1,227 @@
|
||||
import type {
|
||||
EntityConstructionUtils,
|
||||
EntityPrivacyPolicy,
|
||||
EntityQueryContext,
|
||||
IEntityMetricsAdapter,
|
||||
ReadonlyEntity,
|
||||
ViewerContext,
|
||||
} from '@expo/entity';
|
||||
|
||||
import type {
|
||||
AuthorizationResultBasedKnexEntityLoader,
|
||||
EntityLoaderLoadPageArgs,
|
||||
EntityLoaderQuerySelectionModifiers,
|
||||
} from './AuthorizationResultBasedKnexEntityLoader.ts';
|
||||
import type { FieldEqualityCondition } from './BasePostgresEntityDatabaseAdapter.ts';
|
||||
import { BaseSQLQueryBuilder } from './BaseSQLQueryBuilder.ts';
|
||||
import type { SQLFragment } from './SQLOperator.ts';
|
||||
import type { Connection, EntityKnexDataManager } from './internal/EntityKnexDataManager.ts';
|
||||
|
||||
/**
|
||||
* Enforcing knex entity loader for non-data-loader-based load methods.
|
||||
* All loads through this loader will throw if the load is not successful.
|
||||
*/
|
||||
export class EnforcingKnexEntityLoader<
|
||||
TFields extends Record<string, any>,
|
||||
TIDField extends keyof NonNullable<Pick<TFields, TSelectedFields>>,
|
||||
TViewerContext extends ViewerContext,
|
||||
TEntity extends ReadonlyEntity<TFields, TIDField, TViewerContext, TSelectedFields>,
|
||||
TPrivacyPolicy extends EntityPrivacyPolicy<
|
||||
TFields,
|
||||
TIDField,
|
||||
TViewerContext,
|
||||
TEntity,
|
||||
TSelectedFields
|
||||
>,
|
||||
TSelectedFields extends keyof TFields,
|
||||
> {
|
||||
constructor(
|
||||
private readonly knexEntityLoader: AuthorizationResultBasedKnexEntityLoader<
|
||||
TFields,
|
||||
TIDField,
|
||||
TViewerContext,
|
||||
TEntity,
|
||||
TPrivacyPolicy,
|
||||
TSelectedFields
|
||||
>,
|
||||
private readonly queryContext: EntityQueryContext,
|
||||
private readonly knexDataManager: EntityKnexDataManager<TFields, TIDField>,
|
||||
protected readonly metricsAdapter: IEntityMetricsAdapter,
|
||||
private readonly constructionUtils: EntityConstructionUtils<
|
||||
TFields,
|
||||
TIDField,
|
||||
TViewerContext,
|
||||
TEntity,
|
||||
TPrivacyPolicy,
|
||||
TSelectedFields
|
||||
>,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Load the first entity matching the conjunction of field equality operands and
|
||||
* query modifiers.
|
||||
*
|
||||
* This is a convenience method for {@link loadManyByFieldEqualityConjunctionAsync}. However, the
|
||||
* orderBy query modifier is required to ensure consistent results if more than one entity matches
|
||||
* the filters.
|
||||
*
|
||||
* @throws EntityNotAuthorizedError if viewer is not authorized to view the entity
|
||||
* @returns the first entity matching the filters, or null if none match
|
||||
*/
|
||||
async loadFirstByFieldEqualityConjunctionAsync<N extends keyof Pick<TFields, TSelectedFields>>(
|
||||
fieldEqualityOperands: FieldEqualityCondition<TFields, N>[],
|
||||
querySelectionModifiers: Omit<
|
||||
EntityLoaderQuerySelectionModifiers<TFields, TSelectedFields>,
|
||||
'limit'
|
||||
> &
|
||||
Required<Pick<EntityLoaderQuerySelectionModifiers<TFields, TSelectedFields>, 'orderBy'>>,
|
||||
): Promise<TEntity | null> {
|
||||
const entityResult = await this.knexEntityLoader.loadFirstByFieldEqualityConjunctionAsync(
|
||||
fieldEqualityOperands,
|
||||
querySelectionModifiers,
|
||||
);
|
||||
return entityResult?.enforceValue() ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Load entities matching the conjunction of field equality operands and
|
||||
* query modifiers.
|
||||
*
|
||||
* Typically this is used for complex queries that cannot be expressed through simpler
|
||||
* convenience methods such as {@link loadManyByFieldEqualingAsync}.
|
||||
*
|
||||
* @throws EntityNotAuthorizedError if viewer is not authorized to view the entity
|
||||
* @returns entities matching the filters
|
||||
*/
|
||||
async loadManyByFieldEqualityConjunctionAsync<N extends keyof Pick<TFields, TSelectedFields>>(
|
||||
fieldEqualityOperands: FieldEqualityCondition<TFields, N>[],
|
||||
querySelectionModifiers: EntityLoaderQuerySelectionModifiers<TFields, TSelectedFields> = {},
|
||||
): Promise<readonly TEntity[]> {
|
||||
const entityResults = await this.knexEntityLoader.loadManyByFieldEqualityConjunctionAsync(
|
||||
fieldEqualityOperands,
|
||||
querySelectionModifiers,
|
||||
);
|
||||
return entityResults.map((result) => result.enforceValue());
|
||||
}
|
||||
|
||||
/**
|
||||
* Load entities using a SQL query builder. When executed, all queries will enforce authorization and throw if not authorized.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* const entities = await ExampleEntity.loader(vc)
|
||||
* .loadManyBySQL(sql`age >= ${18} AND status = ${'active'}`)
|
||||
* .orderBy('createdAt', 'DESC')
|
||||
* .limit(10)
|
||||
* .executeAsync();
|
||||
*
|
||||
* const { between, inArray } = SQLExpression;
|
||||
* const filtered = await ExampleEntity.loader(vc)
|
||||
* .loadManyBySQL(
|
||||
* sql`${between('age', 18, 65)} AND ${inArray('role', ['admin', 'moderator'])}`
|
||||
* )
|
||||
* .executeAsync();
|
||||
* ```
|
||||
*/
|
||||
loadManyBySQL(
|
||||
fragment: SQLFragment<Pick<TFields, TSelectedFields>>,
|
||||
modifiers: EntityLoaderQuerySelectionModifiers<TFields, TSelectedFields> = {},
|
||||
): EnforcingSQLQueryBuilder<
|
||||
TFields,
|
||||
TIDField,
|
||||
TViewerContext,
|
||||
TEntity,
|
||||
TPrivacyPolicy,
|
||||
TSelectedFields
|
||||
> {
|
||||
return new EnforcingSQLQueryBuilder(this.knexEntityLoader, fragment, modifiers);
|
||||
}
|
||||
|
||||
/**
|
||||
* Load a page of entities with Relay-style cursor pagination.
|
||||
*
|
||||
* @param args - Pagination arguments with pagination and either first/after or last/before
|
||||
* @returns a page of entities matching the pagination arguments
|
||||
* @throws EntityNotAuthorizedError if viewer is not authorized to view any returned entity
|
||||
*/
|
||||
async loadPageAsync(
|
||||
args: EntityLoaderLoadPageArgs<TFields, TSelectedFields>,
|
||||
): Promise<Connection<TEntity>> {
|
||||
const pageResult = await this.knexDataManager.loadPageAsync(this.queryContext, args);
|
||||
const edges = await Promise.all(
|
||||
pageResult.edges.map(async (edge) => {
|
||||
const entityResult = await this.constructionUtils.constructAndAuthorizeEntityAsync(
|
||||
edge.node,
|
||||
);
|
||||
const entity = entityResult.enforceValue();
|
||||
return {
|
||||
...edge,
|
||||
node: entity,
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
return {
|
||||
edges,
|
||||
pageInfo: pageResult.pageInfo,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get cursor for a given entity that matches what loadPageAsync would produce.
|
||||
* Useful for constructing pagination cursors for entities returned from other loader methods that can then be passed to loadPageAsync for pagination.
|
||||
* Most commonly used for testing pagination behavior.
|
||||
*
|
||||
* @param entity - The entity to get the pagination cursor for.
|
||||
* @returns The pagination cursor for the given entity.
|
||||
*/
|
||||
getPaginationCursorForEntity(entity: TEntity): string {
|
||||
return this.knexEntityLoader.getPaginationCursorForEntity(entity);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* SQL query builder for EnforcingKnexEntityLoader.
|
||||
* Provides a fluent API for building and executing SQL queries with enforced authorization.
|
||||
*/
|
||||
export class EnforcingSQLQueryBuilder<
|
||||
TFields extends Record<string, any>,
|
||||
TIDField extends keyof NonNullable<Pick<TFields, TSelectedFields>>,
|
||||
TViewerContext extends ViewerContext,
|
||||
TEntity extends ReadonlyEntity<TFields, TIDField, TViewerContext, TSelectedFields>,
|
||||
TPrivacyPolicy extends EntityPrivacyPolicy<
|
||||
TFields,
|
||||
TIDField,
|
||||
TViewerContext,
|
||||
TEntity,
|
||||
TSelectedFields
|
||||
>,
|
||||
TSelectedFields extends keyof TFields,
|
||||
> extends BaseSQLQueryBuilder<TFields, TSelectedFields, TEntity> {
|
||||
constructor(
|
||||
private readonly knexEntityLoader: AuthorizationResultBasedKnexEntityLoader<
|
||||
TFields,
|
||||
TIDField,
|
||||
TViewerContext,
|
||||
TEntity,
|
||||
TPrivacyPolicy,
|
||||
TSelectedFields
|
||||
>,
|
||||
sqlFragment: SQLFragment<Pick<TFields, TSelectedFields>>,
|
||||
modifiers: EntityLoaderQuerySelectionModifiers<TFields, TSelectedFields>,
|
||||
) {
|
||||
super(sqlFragment, modifiers);
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute the query.
|
||||
* @returns entities matching the query
|
||||
* @throws EntityNotAuthorizedError if viewer is not authorized to view any entity
|
||||
*/
|
||||
async executeInternalAsync(): Promise<readonly TEntity[]> {
|
||||
const entityResults = await this.knexEntityLoader
|
||||
.loadManyBySQL(this.getSQLFragment(), this.getModifiers())
|
||||
.executeAsync();
|
||||
return entityResults.map((result) => result.enforceValue());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import { EntityFieldDefinition } from '@expo/entity';
|
||||
|
||||
/**
|
||||
* EntityFieldDefinition for a Postres column with a JS JSON array type.
|
||||
*/
|
||||
export class JSONArrayField<TRequireExplicitCache extends boolean> extends EntityFieldDefinition<
|
||||
any[],
|
||||
TRequireExplicitCache
|
||||
> {
|
||||
protected validateInputValueInternal(value: any[]): boolean {
|
||||
return Array.isArray(value);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* EntityFieldDefinition for a Postgres column that may be a JS JSON array type.
|
||||
* Does not do any validation.
|
||||
*/
|
||||
export class MaybeJSONArrayField<
|
||||
TRequireExplicitCache extends boolean,
|
||||
> extends EntityFieldDefinition<any | any[], TRequireExplicitCache> {
|
||||
protected validateInputValueInternal(_value: any): boolean {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* EntityFieldDefinition for a Postgres BIGINT column.
|
||||
*/
|
||||
export class BigIntField<TRequireExplicitCache extends boolean> extends EntityFieldDefinition<
|
||||
string,
|
||||
TRequireExplicitCache
|
||||
> {
|
||||
protected validateInputValueInternal(value: string): boolean {
|
||||
return typeof value === 'string';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
import type {
|
||||
EntityCompanion,
|
||||
EntityPrivacyPolicy,
|
||||
EntityPrivacyPolicyEvaluationContext,
|
||||
EntityQueryContext,
|
||||
IEntityMetricsAdapter,
|
||||
ReadonlyEntity,
|
||||
ViewerContext,
|
||||
} from '@expo/entity';
|
||||
import { EntityConstructionUtils } from '@expo/entity';
|
||||
|
||||
import { AuthorizationResultBasedKnexEntityLoader } from './AuthorizationResultBasedKnexEntityLoader.ts';
|
||||
import { EnforcingKnexEntityLoader } from './EnforcingKnexEntityLoader.ts';
|
||||
import type { EntityKnexDataManager } from './internal/EntityKnexDataManager.ts';
|
||||
|
||||
/**
|
||||
* The primary entry point for loading entities via knex queries (non-data-loader methods).
|
||||
*/
|
||||
export class KnexEntityLoaderFactory<
|
||||
TFields extends Record<string, any>,
|
||||
TIDField extends keyof NonNullable<Pick<TFields, TSelectedFields>>,
|
||||
TViewerContext extends ViewerContext,
|
||||
TEntity extends ReadonlyEntity<TFields, TIDField, TViewerContext, TSelectedFields>,
|
||||
TPrivacyPolicy extends EntityPrivacyPolicy<
|
||||
TFields,
|
||||
TIDField,
|
||||
TViewerContext,
|
||||
TEntity,
|
||||
TSelectedFields
|
||||
>,
|
||||
TSelectedFields extends keyof TFields,
|
||||
> {
|
||||
constructor(
|
||||
private readonly entityCompanion: EntityCompanion<
|
||||
TFields,
|
||||
TIDField,
|
||||
TViewerContext,
|
||||
TEntity,
|
||||
TPrivacyPolicy,
|
||||
TSelectedFields
|
||||
>,
|
||||
private readonly knexDataManager: EntityKnexDataManager<TFields, TIDField>,
|
||||
protected readonly metricsAdapter: IEntityMetricsAdapter,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Vend knex loader for loading an entity in a given query context.
|
||||
* @param viewerContext - viewer context of loading user
|
||||
* @param queryContext - query context in which to perform the load
|
||||
*/
|
||||
forLoad(
|
||||
viewerContext: TViewerContext,
|
||||
queryContext: EntityQueryContext,
|
||||
privacyPolicyEvaluationContext: EntityPrivacyPolicyEvaluationContext<
|
||||
TFields,
|
||||
TIDField,
|
||||
TViewerContext,
|
||||
TEntity,
|
||||
TSelectedFields
|
||||
>,
|
||||
): AuthorizationResultBasedKnexEntityLoader<
|
||||
TFields,
|
||||
TIDField,
|
||||
TViewerContext,
|
||||
TEntity,
|
||||
TPrivacyPolicy,
|
||||
TSelectedFields
|
||||
> {
|
||||
const constructionUtils = new EntityConstructionUtils(
|
||||
viewerContext,
|
||||
queryContext,
|
||||
privacyPolicyEvaluationContext,
|
||||
this.entityCompanion.entityCompanionDefinition.entityConfiguration,
|
||||
this.entityCompanion.entityCompanionDefinition.entityClass,
|
||||
this.entityCompanion.entityCompanionDefinition.entitySelectedFields,
|
||||
this.entityCompanion.privacyPolicy,
|
||||
this.metricsAdapter,
|
||||
);
|
||||
|
||||
return new AuthorizationResultBasedKnexEntityLoader(
|
||||
queryContext,
|
||||
this.knexDataManager,
|
||||
this.metricsAdapter,
|
||||
constructionUtils,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Vend enforcing knex loader for loading an entity in a given query context.
|
||||
* @param viewerContext - viewer context of loading user
|
||||
* @param queryContext - query context in which to perform the load
|
||||
*/
|
||||
forLoadEnforcing(
|
||||
viewerContext: TViewerContext,
|
||||
queryContext: EntityQueryContext,
|
||||
privacyPolicyEvaluationContext: EntityPrivacyPolicyEvaluationContext<
|
||||
TFields,
|
||||
TIDField,
|
||||
TViewerContext,
|
||||
TEntity,
|
||||
TSelectedFields
|
||||
>,
|
||||
): EnforcingKnexEntityLoader<
|
||||
TFields,
|
||||
TIDField,
|
||||
TViewerContext,
|
||||
TEntity,
|
||||
TPrivacyPolicy,
|
||||
TSelectedFields
|
||||
> {
|
||||
const constructionUtils = new EntityConstructionUtils(
|
||||
viewerContext,
|
||||
queryContext,
|
||||
privacyPolicyEvaluationContext,
|
||||
this.entityCompanion.entityCompanionDefinition.entityConfiguration,
|
||||
this.entityCompanion.entityCompanionDefinition.entityClass,
|
||||
this.entityCompanion.entityCompanionDefinition.entitySelectedFields,
|
||||
this.entityCompanion.privacyPolicy,
|
||||
this.metricsAdapter,
|
||||
);
|
||||
|
||||
return new EnforcingKnexEntityLoader(
|
||||
this.forLoad(viewerContext, queryContext, privacyPolicyEvaluationContext),
|
||||
queryContext,
|
||||
this.knexDataManager,
|
||||
this.metricsAdapter,
|
||||
constructionUtils,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
/**
|
||||
* Search strategy for SQL-based pagination.
|
||||
*/
|
||||
export enum PaginationStrategy {
|
||||
/**
|
||||
* Standard pagination with ORDER BY. Results are ordered by the specified orderBy fields, with ID field automatically included for stable pagination if not already present.
|
||||
*/
|
||||
STANDARD = 'standard',
|
||||
|
||||
/**
|
||||
* Case-insensitive pattern matching search using SQL ILIKE operator.
|
||||
* Results are ordered by the fields being searched within in the order specified, then by ID for tie-breaking and stable pagination.
|
||||
*/
|
||||
ILIKE_SEARCH = 'ilike-search',
|
||||
|
||||
/**
|
||||
* Similarity search using PostgreSQL trigram similarity. Results are ordered by exact match priority, then by similarity score, then by specified extra order by fields if provided, then by ID for tie-breaking and stable pagination.
|
||||
*
|
||||
* Performance considerations:
|
||||
* - Trigram search can be significantly slower than ILIKE search, especially on large datasets without appropriate indexes.
|
||||
* - Consider using ILIKE search for smaller datasets or when exact substring matching is sufficient
|
||||
* - For larger datasets, ensure proper indexing or consider dedicated full-text search solutions.
|
||||
* - For optimal performance, create GIN or GIST indexes on searchable columns:
|
||||
* ```sql
|
||||
* CREATE EXTENSION IF NOT EXISTS pg_trgm;
|
||||
* CREATE INDEX idx_table_field_trigram ON table_name USING gin(field_name gin_trgm_ops);
|
||||
* -- Or for multiple columns:
|
||||
* CREATE INDEX idx_table_search ON table_name USING gin((field1 || ' ' || field2) gin_trgm_ops);
|
||||
* ```
|
||||
*/
|
||||
TRIGRAM_SEARCH = 'trigram',
|
||||
}
|
||||
Loaded 100 of 393 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user