mirror of
https://github.com/expo/entity.git
synced 2026-11-14 17:50:58 -10:00
Compare commits
43
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 |
No files matched your search
+2
-1
@@ -27,4 +27,5 @@ This is Entity, a privacy-aware data layer for defining, caching, and authorizin
|
||||
- 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`
|
||||
- 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".
|
||||
+7
-1
@@ -1,2 +1,8 @@
|
||||
nodeLinker: node-modules
|
||||
|
||||
npmMinimalAgeGate: 2d
|
||||
|
||||
npmPreapprovedPackages:
|
||||
- "@expo/*"
|
||||
|
||||
yarnPath: .yarn/releases/yarn-4.12.0.cjs
|
||||
nodeLinker: node-modules
|
||||
@@ -3,6 +3,50 @@
|
||||
All notable changes to this project will be documented in this file.
|
||||
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
|
||||
|
||||
# [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
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"packages": ["packages/*"],
|
||||
"npmClient": "yarn",
|
||||
"version": "0.55.0",
|
||||
"version": "0.57.0",
|
||||
"$schema": "node_modules/lerna/schemas/lerna-schema.json"
|
||||
}
|
||||
+3
-3
@@ -28,21 +28,21 @@
|
||||
"@types/babel__preset-env": "7.10.0",
|
||||
"@types/node": "24.10.13",
|
||||
"ctix": "2.7.2",
|
||||
"eslint": "9.39.2",
|
||||
"eslint": "9.39.3",
|
||||
"eslint-config-universe": "15.0.3",
|
||||
"eslint-plugin-tsdoc": "0.5.0",
|
||||
"jest": "30.2.0",
|
||||
"lerna": "9.0.4",
|
||||
"prettier": "3.8.1",
|
||||
"prettier-plugin-organize-imports": "4.3.0",
|
||||
"typedoc": "0.28.16",
|
||||
"typedoc": "0.28.17",
|
||||
"typescript": "5.9.3"
|
||||
},
|
||||
"resolutions": {
|
||||
"collect-v8-coverage": "^1.0.3"
|
||||
},
|
||||
"volta": {
|
||||
"node": "24.13.1"
|
||||
"node": "24.14.0"
|
||||
},
|
||||
"packageManager": "[email protected]"
|
||||
}
|
||||
@@ -3,6 +3,14 @@
|
||||
All notable changes to this project will be documented in this file.
|
||||
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
|
||||
|
||||
# [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
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@expo/entity-cache-adapter-local-memory",
|
||||
"version": "0.55.0",
|
||||
"version": "0.57.0",
|
||||
"description": "Cross-request local memory cache adapter for @expo/entity",
|
||||
"files": [
|
||||
"build",
|
||||
|
||||
@@ -3,6 +3,16 @@
|
||||
All notable changes to this project will be documented in this file.
|
||||
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
|
||||
|
||||
# [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
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@expo/entity-cache-adapter-redis",
|
||||
"version": "0.55.0",
|
||||
"version": "0.57.0",
|
||||
"description": "Redis cache adapter for @expo/entity",
|
||||
"files": [
|
||||
"build",
|
||||
|
||||
@@ -109,7 +109,7 @@ describe(GenericRedisCacher, () => {
|
||||
return pipeline;
|
||||
},
|
||||
);
|
||||
when(mockPipeline.exec()).thenResolve({} as any);
|
||||
when(mockPipeline.exec()).thenResolve([]);
|
||||
const pipeline = instance(mockPipeline);
|
||||
|
||||
const mockRedisClient = mock<Redis>();
|
||||
@@ -154,7 +154,7 @@ describe(GenericRedisCacher, () => {
|
||||
return pipeline;
|
||||
},
|
||||
);
|
||||
when(mockPipeline.exec()).thenResolve({} as any);
|
||||
when(mockPipeline.exec()).thenResolve([]);
|
||||
const pipeline = instance(mockPipeline);
|
||||
|
||||
const mockRedisClient = mock<Redis>();
|
||||
|
||||
@@ -3,6 +3,21 @@
|
||||
All notable changes to this project will be documented in this file.
|
||||
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
|
||||
|
||||
# [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
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@expo/entity-codemod",
|
||||
"version": "0.55.0",
|
||||
"version": "0.57.0",
|
||||
"description": "jscodeshift codemods for @expo/entity upgrades",
|
||||
"files": [
|
||||
"build",
|
||||
|
||||
@@ -12,13 +12,9 @@ function isKnexSpecificMethodUsed(j: API['jscodeshift'], node: any): boolean {
|
||||
const parent = node.parent;
|
||||
|
||||
// Check if the loader call is directly chained with a knex method
|
||||
if (parent && parent.value.type === 'MemberExpression' && parent.value.object === node.value) {
|
||||
if (parent?.value.type === 'MemberExpression' && parent.value.object === node.value) {
|
||||
const grandParent = parent.parent;
|
||||
if (
|
||||
grandParent &&
|
||||
grandParent.value.type === 'CallExpression' &&
|
||||
grandParent.value.callee === parent.value
|
||||
) {
|
||||
if (grandParent?.value.type === 'CallExpression' && grandParent.value.callee === parent.value) {
|
||||
if (
|
||||
parent.value.property.type === 'Identifier' &&
|
||||
KNEX_SPECIFIC_METHODS.includes(parent.value.property.name)
|
||||
@@ -29,7 +25,7 @@ function isKnexSpecificMethodUsed(j: API['jscodeshift'], node: any): boolean {
|
||||
}
|
||||
|
||||
// Check if the loader is assigned to a variable and then used with knex methods
|
||||
if (parent && parent.value.type === 'VariableDeclarator' && parent.value.init === node.value) {
|
||||
if (parent?.value.type === 'VariableDeclarator' && parent.value.init === node.value) {
|
||||
const variableName = parent.value.id.name;
|
||||
const scope = parent.scope;
|
||||
|
||||
@@ -54,9 +50,9 @@ function isKnexSpecificMethodUsed(j: API['jscodeshift'], node: any): boolean {
|
||||
}
|
||||
|
||||
// Check await expressions
|
||||
if (parent && parent.value.type === 'AwaitExpression' && parent.value.argument === node.value) {
|
||||
if (parent?.value.type === 'AwaitExpression' && parent.value.argument === node.value) {
|
||||
const awaitParent = parent.parent;
|
||||
if (awaitParent && awaitParent.value.type === 'VariableDeclarator') {
|
||||
if (awaitParent?.value.type === 'VariableDeclarator') {
|
||||
const variableName = awaitParent.value.id.name;
|
||||
const scope = awaitParent.scope;
|
||||
|
||||
@@ -106,7 +102,7 @@ function transformLoaderToKnexLoader(j: API['jscodeshift'], root: Collection<any
|
||||
// Typically entity names start with uppercase letter
|
||||
if (loaderCallee.object.type === 'Identifier') {
|
||||
const firstChar = loaderCallee.object.name[0];
|
||||
if (firstChar && firstChar === firstChar.toUpperCase()) {
|
||||
if (firstChar === firstChar?.toUpperCase()) {
|
||||
// Check if this loader uses knex-specific methods
|
||||
if (isKnexSpecificMethodUsed(j, path)) {
|
||||
// Rename loader to knexLoader
|
||||
@@ -145,7 +141,7 @@ function transformLoaderWithAuthorizationResultsToKnexLoaderWithAuthorizationRes
|
||||
// Typically entity names start with uppercase letter
|
||||
if (loaderCallee.object.type === 'Identifier') {
|
||||
const firstChar = loaderCallee.object.name[0];
|
||||
if (firstChar && firstChar === firstChar.toUpperCase()) {
|
||||
if (firstChar === firstChar?.toUpperCase()) {
|
||||
// Check if this loader uses knex-specific methods
|
||||
if (isKnexSpecificMethodUsed(j, path)) {
|
||||
// Rename loaderWithAuthorizationResults to knexLoaderWithAuthorizationResults
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
# Change Log
|
||||
|
||||
All notable changes to this project will be documented in this file.
|
||||
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
|
||||
|
||||
# [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))
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@expo/entity-database-adapter-knex-testing-utils",
|
||||
"version": "0.55.0",
|
||||
"version": "0.57.0",
|
||||
"description": "Testing utilities for applications using Entity with Knex database adapter",
|
||||
"files": [
|
||||
"build",
|
||||
|
||||
+28
-28
@@ -10,12 +10,13 @@ import {
|
||||
} from '@expo/entity';
|
||||
import {
|
||||
BasePostgresEntityDatabaseAdapter,
|
||||
NullsOrdering,
|
||||
OrderByOrdering,
|
||||
SQLFragment,
|
||||
TableFieldMultiValueEqualityCondition,
|
||||
TableFieldSingleValueEqualityCondition,
|
||||
TableOrderByClause,
|
||||
TableQuerySelectionModifiers,
|
||||
TableQuerySelectionModifiersWithOrderByFragment,
|
||||
} from '@expo/entity-database-adapter-knex';
|
||||
import invariant from 'invariant';
|
||||
import { v7 as uuidv7 } from 'uuid';
|
||||
@@ -104,11 +105,8 @@ export class StubPostgresDatabaseAdapter<
|
||||
return results[0] ?? null;
|
||||
}
|
||||
|
||||
private static compareByOrderBys(
|
||||
orderBys: {
|
||||
columnName: string;
|
||||
order: OrderByOrdering;
|
||||
}[],
|
||||
private static compareByOrderBys<TFields extends Record<string, any>>(
|
||||
orderBys: TableOrderByClause<TFields>[],
|
||||
objectA: { [key: string]: any },
|
||||
objectB: { [key: string]: any },
|
||||
): 0 | 1 | -1 {
|
||||
@@ -117,19 +115,30 @@ export class StubPostgresDatabaseAdapter<
|
||||
}
|
||||
|
||||
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
|
||||
@@ -137,15 +146,6 @@ export class StubPostgresDatabaseAdapter<
|
||||
: 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
|
||||
@@ -160,7 +160,7 @@ export class StubPostgresDatabaseAdapter<
|
||||
tableName: string,
|
||||
tableFieldSingleValueEqualityOperands: TableFieldSingleValueEqualityCondition[],
|
||||
tableFieldMultiValueEqualityOperands: TableFieldMultiValueEqualityCondition[],
|
||||
querySelectionModifiers: TableQuerySelectionModifiers,
|
||||
querySelectionModifiers: TableQuerySelectionModifiers<TFields>,
|
||||
): Promise<object[]> {
|
||||
let filteredObjects = this.getObjectCollectionForTable(tableName);
|
||||
for (const { tableField, tableValue } of tableFieldSingleValueEqualityOperands) {
|
||||
@@ -196,7 +196,7 @@ export class StubPostgresDatabaseAdapter<
|
||||
_tableName: string,
|
||||
_rawWhereClause: string,
|
||||
_bindings: object | any[],
|
||||
_querySelectionModifiers: TableQuerySelectionModifiers,
|
||||
_querySelectionModifiers: TableQuerySelectionModifiers<TFields>,
|
||||
): Promise<object[]> {
|
||||
throw new Error('Raw WHERE clauses not supported for StubDatabaseAdapter');
|
||||
}
|
||||
@@ -204,8 +204,8 @@ export class StubPostgresDatabaseAdapter<
|
||||
protected fetchManyBySQLFragmentInternalAsync(
|
||||
_queryInterface: any,
|
||||
_tableName: string,
|
||||
_sqlFragment: SQLFragment,
|
||||
_querySelectionModifiers: TableQuerySelectionModifiersWithOrderByFragment,
|
||||
_sqlFragment: SQLFragment<TFields>,
|
||||
_querySelectionModifiers: TableQuerySelectionModifiers<TFields>,
|
||||
): Promise<object[]> {
|
||||
throw new Error('SQL fragments not supported for StubDatabaseAdapter');
|
||||
}
|
||||
|
||||
-17
@@ -3,27 +3,10 @@ import {
|
||||
EntityDatabaseAdapter,
|
||||
IEntityDatabaseAdapterProvider,
|
||||
} from '@expo/entity';
|
||||
import {
|
||||
installEntityCompanionExtensions,
|
||||
installEntityTableDataCoordinatorExtensions,
|
||||
installReadonlyEntityExtensions,
|
||||
installViewerScopedEntityCompanionExtensions,
|
||||
} from '@expo/entity-database-adapter-knex';
|
||||
|
||||
import { StubPostgresDatabaseAdapter } from './StubPostgresDatabaseAdapter';
|
||||
|
||||
export class StubPostgresDatabaseAdapterProvider implements IEntityDatabaseAdapterProvider {
|
||||
getExtensionsKey(): string {
|
||||
return 'StubPostgresDatabaseAdapterProvider';
|
||||
}
|
||||
|
||||
installExtensions(): void {
|
||||
installEntityCompanionExtensions();
|
||||
installEntityTableDataCoordinatorExtensions();
|
||||
installViewerScopedEntityCompanionExtensions();
|
||||
installReadonlyEntityExtensions();
|
||||
}
|
||||
|
||||
private readonly objectCollection = new Map();
|
||||
|
||||
getDatabaseAdapter<TFields extends Record<string, any>, TIDField extends keyof TFields>(
|
||||
|
||||
+113
-21
@@ -5,7 +5,7 @@ import {
|
||||
SingleFieldHolder,
|
||||
SingleFieldValueHolder,
|
||||
} from '@expo/entity';
|
||||
import { OrderByOrdering, sql } from '@expo/entity-database-adapter-knex';
|
||||
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';
|
||||
@@ -744,14 +744,15 @@ describe(StubPostgresDatabaseAdapter, () => {
|
||||
});
|
||||
|
||||
describe('compareByOrderBys', () => {
|
||||
describe('comparison', () => {
|
||||
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
|
||||
// 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
|
||||
@@ -771,6 +772,7 @@ describe('compareByOrderBys', () => {
|
||||
{
|
||||
columnName: 'hello',
|
||||
order,
|
||||
nulls: undefined,
|
||||
},
|
||||
],
|
||||
{
|
||||
@@ -782,6 +784,21 @@ describe('compareByOrderBys', () => {
|
||||
),
|
||||
).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'](
|
||||
@@ -796,29 +813,104 @@ describe('compareByOrderBys', () => {
|
||||
).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', () => {
|
||||
expect(
|
||||
StubPostgresDatabaseAdapter['compareByOrderBys'](
|
||||
[
|
||||
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,
|
||||
},
|
||||
],
|
||||
{
|
||||
columnName: 'hello',
|
||||
order: OrderByOrdering.ASCENDING,
|
||||
hello: 'a',
|
||||
world: 1,
|
||||
},
|
||||
{
|
||||
columnName: 'world',
|
||||
order: OrderByOrdering.ASCENDING,
|
||||
hello: 'a',
|
||||
world: 2,
|
||||
},
|
||||
],
|
||||
{
|
||||
hello: 'a',
|
||||
world: 1,
|
||||
},
|
||||
{
|
||||
hello: 'a',
|
||||
world: 2,
|
||||
},
|
||||
),
|
||||
).toEqual(-1);
|
||||
),
|
||||
).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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -3,6 +3,40 @@
|
||||
All notable changes to this project will be documented in this file.
|
||||
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
|
||||
|
||||
# [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
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@expo/entity-database-adapter-knex",
|
||||
"version": "0.55.0",
|
||||
"version": "0.57.0",
|
||||
"description": "Knex database adapter for @expo/entity",
|
||||
"files": [
|
||||
"build",
|
||||
|
||||
+244
-62
@@ -11,34 +11,62 @@ import { Result } from '@expo/results';
|
||||
import {
|
||||
FieldEqualityCondition,
|
||||
isSingleValueFieldEqualityCondition,
|
||||
NullsOrdering,
|
||||
OrderByOrdering,
|
||||
} from './BasePostgresEntityDatabaseAdapter';
|
||||
import { BaseSQLQueryBuilder } from './BaseSQLQueryBuilder';
|
||||
import { PaginationStrategy } from './PaginationStrategy';
|
||||
import { SQLFragment } from './SQLOperator';
|
||||
import type { Connection, PageInfo } from './internal/EntityKnexDataManager';
|
||||
import { EntityKnexDataManager } from './internal/EntityKnexDataManager';
|
||||
import { NonNullableKeys } from './internal/utilityTypes';
|
||||
|
||||
export interface EntityLoaderOrderByClause<
|
||||
TFields extends Record<string, any>,
|
||||
TSelectedFields extends keyof TFields,
|
||||
> {
|
||||
/**
|
||||
* The field name to order by.
|
||||
*/
|
||||
fieldName: TSelectedFields;
|
||||
|
||||
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,
|
||||
TSelectedFields extends keyof TFields = keyof TFields,
|
||||
> {
|
||||
/**
|
||||
* Order the entities by specified columns and orders.
|
||||
@@ -56,89 +84,242 @@ export interface EntityLoaderQuerySelectionModifiers<
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
export interface EntityLoaderQuerySelectionModifiersWithOrderByRaw<
|
||||
/**
|
||||
* 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 or combinations of multiple fields, such as `COALESCE(NULLIF(display_name, ''), split_part(full_name, '/', 2))`
|
||||
* to search by display name with fallback to full name.
|
||||
*/
|
||||
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,
|
||||
> extends EntityLoaderQuerySelectionModifiers<TFields, TSelectedFields> {
|
||||
> = 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,
|
||||
> {
|
||||
/**
|
||||
* Order the entities by a raw SQL `ORDER BY` clause.
|
||||
* The search term to search for. Must be a non-empty string.
|
||||
*/
|
||||
orderByRaw?: string;
|
||||
term: string;
|
||||
|
||||
/**
|
||||
* The fields to search within.
|
||||
*/
|
||||
fields: readonly EntityLoaderSearchFieldSpecification<TFields, TSelectedFields>[];
|
||||
}
|
||||
|
||||
export interface EntityLoaderQuerySelectionModifiersWithOrderByFragment<
|
||||
interface ILikeSearchSpecification<
|
||||
TFields extends Record<string, any>,
|
||||
TSelectedFields extends keyof TFields,
|
||||
> extends EntityLoaderQuerySelectionModifiers<TFields, TSelectedFields> {
|
||||
TSelectedFields extends keyof TFields = keyof TFields,
|
||||
> extends SearchSpecificationBase<TFields, TSelectedFields> {
|
||||
/**
|
||||
* Order the entities by a SQL fragment `ORDER BY` clause.
|
||||
* 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.
|
||||
*/
|
||||
orderByFragment?: SQLFragment;
|
||||
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>[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Base pagination arguments
|
||||
* Pagination specification for SQL-based pagination (with or without search).
|
||||
*/
|
||||
interface EntityLoaderBasePaginationArgs<
|
||||
export type PaginationSpecification<
|
||||
TFields extends Record<string, any>,
|
||||
TSelectedFields extends keyof TFields,
|
||||
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;
|
||||
where?: SQLFragment<Pick<TFields, TSelectedFields>>;
|
||||
|
||||
/**
|
||||
* Order the entities by specified columns and orders. If the ID field is not included in the orderBy, it will be automatically included as the last orderBy field to ensure stable pagination.
|
||||
* Pagination specification determining how to order and paginate results.
|
||||
*/
|
||||
orderBy?: EntityLoaderOrderByClause<TFields, TSelectedFields>[];
|
||||
pagination: PaginationSpecification<TFields, TSelectedFields>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Forward pagination arguments
|
||||
* Forward unified pagination arguments
|
||||
*/
|
||||
export interface EntityLoaderForwardPaginationArgs<
|
||||
export interface EntityLoaderForwardUnifiedPaginationArgs<
|
||||
TFields extends Record<string, any>,
|
||||
TSelectedFields extends keyof TFields,
|
||||
> extends EntityLoaderBasePaginationArgs<TFields, TSelectedFields> {
|
||||
TSelectedFields extends keyof TFields = keyof TFields,
|
||||
> extends EntityLoaderBaseUnifiedPaginationArgs<TFields, TSelectedFields> {
|
||||
/**
|
||||
* The number of entities to return starting from the entity after the cursor (for forward pagination). Must be a positive integer.
|
||||
* The number of entities to return starting from the entity after the cursor. Must be a positive integer.
|
||||
*/
|
||||
first: number;
|
||||
|
||||
/**
|
||||
* The cursor to paginate after for forward pagination, typically an opaque string encoding of the values of the cursor fields of the last entity in the previous page. If not provided, pagination starts from the beginning of the result set.
|
||||
* 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 pagination arguments
|
||||
* Backward unified pagination arguments
|
||||
*/
|
||||
export interface EntityLoaderBackwardPaginationArgs<
|
||||
export interface EntityLoaderBackwardUnifiedPaginationArgs<
|
||||
TFields extends Record<string, any>,
|
||||
TSelectedFields extends keyof TFields,
|
||||
> extends EntityLoaderBasePaginationArgs<TFields, TSelectedFields> {
|
||||
TSelectedFields extends keyof TFields = keyof TFields,
|
||||
> extends EntityLoaderBaseUnifiedPaginationArgs<TFields, TSelectedFields> {
|
||||
/**
|
||||
* The number of entities to return starting from the entity before the cursor (for backward pagination). Must be a positive integer.
|
||||
* 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 before for backward pagination, typically an opaque string encoding of the values of the cursor fields of the first entity in the previous page. If not provided, pagination starts from the end of the result set.
|
||||
* 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 pagination arguments.
|
||||
* 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,
|
||||
TSelectedFields extends keyof TFields = keyof TFields,
|
||||
> =
|
||||
| EntityLoaderForwardPaginationArgs<TFields, TSelectedFields>
|
||||
| EntityLoaderBackwardPaginationArgs<TFields, TSelectedFields>;
|
||||
| EntityLoaderForwardUnifiedPaginationArgs<TFields, TSelectedFields>
|
||||
| EntityLoaderBackwardUnifiedPaginationArgs<TFields, TSelectedFields>;
|
||||
|
||||
/**
|
||||
* Authorization-result-based knex entity loader for non-data-loader-based load methods.
|
||||
@@ -158,7 +339,7 @@ export class AuthorizationResultBasedKnexEntityLoader<
|
||||
TEntity,
|
||||
TSelectedFields
|
||||
>,
|
||||
TSelectedFields extends keyof TFields,
|
||||
TSelectedFields extends keyof TFields = keyof TFields,
|
||||
> {
|
||||
constructor(
|
||||
private readonly queryContext: EntityQueryContext,
|
||||
@@ -180,7 +361,7 @@ export class AuthorizationResultBasedKnexEntityLoader<
|
||||
* UnauthorizedError
|
||||
*/
|
||||
async loadFirstByFieldEqualityConjunctionAsync<N extends keyof Pick<TFields, TSelectedFields>>(
|
||||
fieldEqualityOperands: FieldEqualityCondition<TFields, N>[],
|
||||
fieldEqualityOperands: readonly FieldEqualityCondition<TFields, N>[],
|
||||
querySelectionModifiers: Omit<
|
||||
EntityLoaderQuerySelectionModifiers<TFields, TSelectedFields>,
|
||||
'limit'
|
||||
@@ -199,7 +380,7 @@ export class AuthorizationResultBasedKnexEntityLoader<
|
||||
* @returns array of entity results that match the query, where result error can be UnauthorizedError
|
||||
*/
|
||||
async loadManyByFieldEqualityConjunctionAsync<N extends keyof Pick<TFields, TSelectedFields>>(
|
||||
fieldEqualityOperands: FieldEqualityCondition<TFields, N>[],
|
||||
fieldEqualityOperands: readonly FieldEqualityCondition<TFields, N>[],
|
||||
querySelectionModifiers: EntityLoaderQuerySelectionModifiers<TFields, TSelectedFields> = {},
|
||||
): Promise<readonly Result<TEntity>[]> {
|
||||
for (const fieldEqualityOperand of fieldEqualityOperands) {
|
||||
@@ -221,14 +402,13 @@ export class AuthorizationResultBasedKnexEntityLoader<
|
||||
* 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
|
||||
* @throws Error when rawWhereClause or bindings are invalid
|
||||
*
|
||||
* @deprecated Use loadManyBySQL instead for safer value bindings and more flexible query building.
|
||||
*/
|
||||
async loadManyByRawWhereClauseAsync(
|
||||
rawWhereClause: string,
|
||||
bindings: any[] | object,
|
||||
querySelectionModifiers: EntityLoaderQuerySelectionModifiersWithOrderByRaw<
|
||||
TFields,
|
||||
TSelectedFields
|
||||
> = {},
|
||||
querySelectionModifiers: EntityLoaderQuerySelectionModifiers<TFields, TSelectedFields> = {},
|
||||
): Promise<readonly Result<TEntity>[]> {
|
||||
const fieldObjects = await this.knexDataManager.loadManyByRawWhereClauseAsync(
|
||||
this.queryContext,
|
||||
@@ -244,11 +424,8 @@ export class AuthorizationResultBasedKnexEntityLoader<
|
||||
* @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,
|
||||
modifiers: EntityLoaderQuerySelectionModifiersWithOrderByFragment<
|
||||
TFields,
|
||||
TSelectedFields
|
||||
> = {},
|
||||
fragment: SQLFragment<Pick<TFields, TSelectedFields>>,
|
||||
modifiers: EntityLoaderQuerySelectionModifiers<TFields, TSelectedFields> = {},
|
||||
): AuthorizationResultBasedSQLQueryBuilder<
|
||||
TFields,
|
||||
TIDField,
|
||||
@@ -267,18 +444,15 @@ export class AuthorizationResultBasedKnexEntityLoader<
|
||||
}
|
||||
|
||||
/**
|
||||
* Load a page of entities with Relay-style cursor pagination.
|
||||
* 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 loadPageBySQLAsync(
|
||||
async loadPageAsync(
|
||||
args: EntityLoaderLoadPageArgs<TFields, TSelectedFields>,
|
||||
): Promise<Connection<TEntity>> {
|
||||
const pageResult = await this.knexDataManager.loadPageBySQLFragmentAsync(
|
||||
this.queryContext,
|
||||
args,
|
||||
);
|
||||
const pageResult = await this.knexDataManager.loadPageAsync(this.queryContext, args);
|
||||
|
||||
const edgeResults = await Promise.all(
|
||||
pageResult.edges.map(async (edge) => {
|
||||
@@ -306,6 +480,14 @@ export class AuthorizationResultBasedKnexEntityLoader<
|
||||
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());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -336,8 +518,8 @@ export class AuthorizationResultBasedSQLQueryBuilder<
|
||||
TSelectedFields
|
||||
>,
|
||||
private readonly queryContext: EntityQueryContext,
|
||||
sqlFragment: SQLFragment,
|
||||
modifiers: EntityLoaderQuerySelectionModifiersWithOrderByFragment<TFields, TSelectedFields>,
|
||||
sqlFragment: SQLFragment<Pick<TFields, TSelectedFields>>,
|
||||
modifiers: EntityLoaderQuerySelectionModifiers<TFields, TSelectedFields>,
|
||||
) {
|
||||
super(sqlFragment, modifiers);
|
||||
}
|
||||
|
||||
@@ -58,6 +58,11 @@ export interface TableFieldMultiValueEqualityCondition {
|
||||
tableValues: readonly any[];
|
||||
}
|
||||
|
||||
export enum NullsOrdering {
|
||||
FIRST = 'first',
|
||||
LAST = 'last',
|
||||
}
|
||||
|
||||
/**
|
||||
* Ordering options for `orderBy` clauses.
|
||||
*/
|
||||
@@ -75,17 +80,41 @@ export enum OrderByOrdering {
|
||||
DESCENDING = 'desc',
|
||||
}
|
||||
|
||||
export interface PostgresOrderByClause<TFields extends Record<string, any>> {
|
||||
/**
|
||||
* The field name to order by.
|
||||
*/
|
||||
fieldName: keyof TFields;
|
||||
export type PostgresOrderByClause<TFields extends Record<string, any>> =
|
||||
| {
|
||||
/**
|
||||
* The field name to order by.
|
||||
*/
|
||||
fieldName: keyof TFields;
|
||||
|
||||
/**
|
||||
* The OrderByOrdering to order by.
|
||||
*/
|
||||
order: OrderByOrdering;
|
||||
}
|
||||
/**
|
||||
* 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.
|
||||
@@ -107,48 +136,35 @@ export interface PostgresQuerySelectionModifiers<TFields extends Record<string,
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
export interface PostgresQuerySelectionModifiersWithOrderByRaw<
|
||||
TFields extends Record<string, any>,
|
||||
> extends PostgresQuerySelectionModifiers<TFields> {
|
||||
/**
|
||||
* Order the entities by a raw SQL `ORDER BY` clause.
|
||||
*/
|
||||
orderByRaw?: string;
|
||||
}
|
||||
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 PostgresQuerySelectionModifiersWithOrderByFragment<
|
||||
TFields extends Record<string, any>,
|
||||
> extends PostgresQuerySelectionModifiers<TFields> {
|
||||
/**
|
||||
* Order the entities by a SQL fragment `ORDER BY` clause.
|
||||
*/
|
||||
orderByFragment?: SQLFragment;
|
||||
}
|
||||
|
||||
export interface TableQuerySelectionModifiers {
|
||||
orderBy:
|
||||
| {
|
||||
columnName: string;
|
||||
order: OrderByOrdering;
|
||||
}[]
|
||||
| undefined;
|
||||
export interface TableQuerySelectionModifiers<TFields extends Record<string, any>> {
|
||||
orderBy: TableOrderByClause<TFields>[] | undefined;
|
||||
offset: number | undefined;
|
||||
limit: number | undefined;
|
||||
}
|
||||
|
||||
export interface TableQuerySelectionModifiersWithOrderByRaw extends TableQuerySelectionModifiers {
|
||||
orderByRaw: string | undefined;
|
||||
orderByRawBindings?: readonly any[];
|
||||
}
|
||||
|
||||
export interface TableQuerySelectionModifiersWithOrderByFragment extends TableQuerySelectionModifiers {
|
||||
orderByFragment: SQLFragment | 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.
|
||||
@@ -160,7 +176,7 @@ export abstract class BasePostgresEntityDatabaseAdapter<
|
||||
*/
|
||||
async fetchManyByFieldEqualityConjunctionAsync<N extends keyof TFields>(
|
||||
queryContext: EntityQueryContext,
|
||||
fieldEqualityOperands: FieldEqualityCondition<TFields, N>[],
|
||||
fieldEqualityOperands: readonly FieldEqualityCondition<TFields, N>[],
|
||||
querySelectionModifiers: PostgresQuerySelectionModifiers<TFields>,
|
||||
): Promise<readonly Readonly<TFields>[]> {
|
||||
const tableFieldSingleValueOperands: TableFieldSingleValueEqualityCondition[] = [];
|
||||
@@ -197,7 +213,7 @@ export abstract class BasePostgresEntityDatabaseAdapter<
|
||||
tableName: string,
|
||||
tableFieldSingleValueEqualityOperands: TableFieldSingleValueEqualityCondition[],
|
||||
tableFieldMultiValueEqualityOperands: TableFieldMultiValueEqualityCondition[],
|
||||
querySelectionModifiers: TableQuerySelectionModifiers,
|
||||
querySelectionModifiers: TableQuerySelectionModifiers<TFields>,
|
||||
): Promise<object[]>;
|
||||
|
||||
/**
|
||||
@@ -213,14 +229,14 @@ export abstract class BasePostgresEntityDatabaseAdapter<
|
||||
queryContext: EntityQueryContext,
|
||||
rawWhereClause: string,
|
||||
bindings: any[] | object,
|
||||
querySelectionModifiers: PostgresQuerySelectionModifiersWithOrderByRaw<TFields>,
|
||||
querySelectionModifiers: PostgresQuerySelectionModifiers<TFields>,
|
||||
): Promise<readonly Readonly<TFields>[]> {
|
||||
const results = await this.fetchManyByRawWhereClauseInternalAsync(
|
||||
queryContext.getQueryInterface(),
|
||||
this.entityConfiguration.tableName,
|
||||
rawWhereClause,
|
||||
bindings,
|
||||
this.convertToTableQueryModifiersWithOrderByRaw(querySelectionModifiers),
|
||||
this.convertToTableQueryModifiers(querySelectionModifiers),
|
||||
);
|
||||
|
||||
return results.map((result) =>
|
||||
@@ -233,7 +249,7 @@ export abstract class BasePostgresEntityDatabaseAdapter<
|
||||
tableName: string,
|
||||
rawWhereClause: string,
|
||||
bindings: object | any[],
|
||||
querySelectionModifiers: TableQuerySelectionModifiersWithOrderByRaw,
|
||||
querySelectionModifiers: TableQuerySelectionModifiers<TFields>,
|
||||
): Promise<object[]>;
|
||||
|
||||
/**
|
||||
@@ -246,14 +262,14 @@ export abstract class BasePostgresEntityDatabaseAdapter<
|
||||
*/
|
||||
async fetchManyBySQLFragmentAsync(
|
||||
queryContext: EntityQueryContext,
|
||||
sqlFragment: SQLFragment,
|
||||
querySelectionModifiers: PostgresQuerySelectionModifiersWithOrderByFragment<TFields>,
|
||||
sqlFragment: SQLFragment<TFields>,
|
||||
querySelectionModifiers: PostgresQuerySelectionModifiers<TFields>,
|
||||
): Promise<readonly Readonly<TFields>[]> {
|
||||
const results = await this.fetchManyBySQLFragmentInternalAsync(
|
||||
queryContext.getQueryInterface(),
|
||||
this.entityConfiguration.tableName,
|
||||
sqlFragment,
|
||||
this.convertToTableQueryModifiersWithOrderByFragment(querySelectionModifiers),
|
||||
this.convertToTableQueryModifiers(querySelectionModifiers),
|
||||
);
|
||||
|
||||
return results.map((result) =>
|
||||
@@ -264,42 +280,35 @@ export abstract class BasePostgresEntityDatabaseAdapter<
|
||||
protected abstract fetchManyBySQLFragmentInternalAsync(
|
||||
queryInterface: Knex,
|
||||
tableName: string,
|
||||
sqlFragment: SQLFragment,
|
||||
querySelectionModifiers: TableQuerySelectionModifiersWithOrderByFragment,
|
||||
sqlFragment: SQLFragment<TFields>,
|
||||
querySelectionModifiers: TableQuerySelectionModifiers<TFields>,
|
||||
): Promise<object[]>;
|
||||
|
||||
private convertToTableQueryModifiersWithOrderByRaw(
|
||||
querySelectionModifiers: PostgresQuerySelectionModifiersWithOrderByRaw<TFields>,
|
||||
): TableQuerySelectionModifiersWithOrderByRaw {
|
||||
return {
|
||||
...this.convertToTableQueryModifiers(querySelectionModifiers),
|
||||
orderByRaw: querySelectionModifiers.orderByRaw,
|
||||
};
|
||||
}
|
||||
|
||||
private convertToTableQueryModifiersWithOrderByFragment(
|
||||
querySelectionModifiers: PostgresQuerySelectionModifiersWithOrderByFragment<TFields>,
|
||||
): TableQuerySelectionModifiersWithOrderByFragment {
|
||||
return {
|
||||
...this.convertToTableQueryModifiers(querySelectionModifiers),
|
||||
orderByFragment: querySelectionModifiers.orderByFragment,
|
||||
};
|
||||
}
|
||||
|
||||
private convertToTableQueryModifiers(
|
||||
querySelectionModifiers: PostgresQuerySelectionModifiers<TFields>,
|
||||
): TableQuerySelectionModifiers {
|
||||
): TableQuerySelectionModifiers<TFields> {
|
||||
const orderBy = querySelectionModifiers.orderBy;
|
||||
return {
|
||||
orderBy:
|
||||
orderBy !== undefined
|
||||
? orderBy.map((orderBySpecification) => ({
|
||||
columnName: getDatabaseFieldForEntityField(
|
||||
this.entityConfiguration,
|
||||
orderBySpecification.fieldName,
|
||||
),
|
||||
order: orderBySpecification.order,
|
||||
}))
|
||||
? 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,
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import {
|
||||
EntityLoaderOrderByClause,
|
||||
EntityLoaderQuerySelectionModifiersWithOrderByFragment,
|
||||
EntityLoaderQuerySelectionModifiers,
|
||||
} from './AuthorizationResultBasedKnexEntityLoader';
|
||||
import { OrderByOrdering } from './BasePostgresEntityDatabaseAdapter';
|
||||
import { NullsOrdering, OrderByOrdering } from './BasePostgresEntityDatabaseAdapter';
|
||||
import { SQLFragment } from './SQLOperator';
|
||||
|
||||
/**
|
||||
@@ -16,12 +16,11 @@ export abstract class BaseSQLQueryBuilder<
|
||||
private executed = false;
|
||||
|
||||
constructor(
|
||||
private readonly sqlFragment: SQLFragment,
|
||||
private readonly sqlFragment: SQLFragment<Pick<TFields, TSelectedFields>>,
|
||||
private readonly modifiers: {
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
orderBy?: readonly EntityLoaderOrderByClause<TFields, TSelectedFields>[];
|
||||
orderByFragment?: SQLFragment;
|
||||
},
|
||||
) {}
|
||||
|
||||
@@ -44,8 +43,12 @@ export abstract class BaseSQLQueryBuilder<
|
||||
/**
|
||||
* Order by a field. Can be called multiple times to add multiple order bys.
|
||||
*/
|
||||
orderBy(fieldName: TSelectedFields, order: OrderByOrdering = OrderByOrdering.ASCENDING): this {
|
||||
this.modifiers.orderBy = [...(this.modifiers.orderBy ?? []), { fieldName, order }];
|
||||
orderBy(
|
||||
fieldName: TSelectedFields,
|
||||
order: OrderByOrdering = OrderByOrdering.ASCENDING,
|
||||
nulls: NullsOrdering | undefined = undefined,
|
||||
): this {
|
||||
this.modifiers.orderBy = [...(this.modifiers.orderBy ?? []), { fieldName, order, nulls }];
|
||||
return this;
|
||||
}
|
||||
|
||||
@@ -55,38 +58,41 @@ export abstract class BaseSQLQueryBuilder<
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* import { sql, raw } from '@expo/entity-database-adapter-knex';
|
||||
*
|
||||
* // Safe parameterized ordering
|
||||
* .orderBySQL(sql`CASE WHEN priority = ${1} THEN 0 ELSE 1 END, created_at DESC`)
|
||||
*
|
||||
* // Dynamic column ordering
|
||||
* const sortColumn = 'name';
|
||||
* .orderBySQL(sql`${raw(sortColumn)} DESC NULLS LAST`)
|
||||
*
|
||||
* // Complex expressions
|
||||
* .orderBySQL(sql`array_length(tags, 1) DESC, score * ${multiplier} ASC`)
|
||||
* 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): this {
|
||||
this.modifiers.orderByFragment = fragment;
|
||||
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(): EntityLoaderQuerySelectionModifiersWithOrderByFragment<
|
||||
TFields,
|
||||
TSelectedFields
|
||||
> {
|
||||
protected getModifiers(): EntityLoaderQuerySelectionModifiers<TFields, TSelectedFields> {
|
||||
return this.modifiers;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the SQL fragment
|
||||
*/
|
||||
protected getSQLFragment(): SQLFragment {
|
||||
protected getSQLFragment(): SQLFragment<Pick<TFields, TSelectedFields>> {
|
||||
return this.sqlFragment;
|
||||
}
|
||||
|
||||
|
||||
@@ -11,8 +11,6 @@ import {
|
||||
AuthorizationResultBasedKnexEntityLoader,
|
||||
EntityLoaderLoadPageArgs,
|
||||
EntityLoaderQuerySelectionModifiers,
|
||||
EntityLoaderQuerySelectionModifiersWithOrderByFragment,
|
||||
EntityLoaderQuerySelectionModifiersWithOrderByRaw,
|
||||
} from './AuthorizationResultBasedKnexEntityLoader';
|
||||
import { FieldEqualityCondition } from './BasePostgresEntityDatabaseAdapter';
|
||||
import { BaseSQLQueryBuilder } from './BaseSQLQueryBuilder';
|
||||
@@ -130,19 +128,17 @@ export class EnforcingKnexEntityLoader<
|
||||
* ```
|
||||
* @param rawWhereClause - SQL WHERE clause. Interpolated values should be specified as ?-placeholders or :key_name
|
||||
* @param bindings - values to bind to the placeholders in the WHERE clause
|
||||
* @param querySelectionModifiers - limit, offset, and orderBy for the query. If orderBy is specified
|
||||
* as orderByRaw, specify as string orderBy SQL clause with uncheckd literal values or ?-placeholders
|
||||
* @param querySelectionModifiers - limit, offset, and orderBy for the query.
|
||||
* @returns entities matching the WHERE clause
|
||||
* @throws EntityNotAuthorizedError when viewer is not authorized to view one or more of the returned entities
|
||||
* @throws Error when rawWhereClause or bindings are invalid
|
||||
*
|
||||
* @deprecated Use loadManyBySQL instead for safer value bindings and more flexible query building.
|
||||
*/
|
||||
async loadManyByRawWhereClauseAsync(
|
||||
rawWhereClause: string,
|
||||
bindings: any[] | object,
|
||||
querySelectionModifiers: EntityLoaderQuerySelectionModifiersWithOrderByRaw<
|
||||
TFields,
|
||||
TSelectedFields
|
||||
> = {},
|
||||
querySelectionModifiers: EntityLoaderQuerySelectionModifiers<TFields, TSelectedFields> = {},
|
||||
): Promise<readonly TEntity[]> {
|
||||
const entityResults = await this.knexEntityLoader.loadManyByRawWhereClauseAsync(
|
||||
rawWhereClause,
|
||||
@@ -172,11 +168,8 @@ export class EnforcingKnexEntityLoader<
|
||||
* ```
|
||||
*/
|
||||
loadManyBySQL(
|
||||
fragment: SQLFragment,
|
||||
modifiers: EntityLoaderQuerySelectionModifiersWithOrderByFragment<
|
||||
TFields,
|
||||
TSelectedFields
|
||||
> = {},
|
||||
fragment: SQLFragment<Pick<TFields, TSelectedFields>>,
|
||||
modifiers: EntityLoaderQuerySelectionModifiers<TFields, TSelectedFields> = {},
|
||||
): EnforcingSQLQueryBuilder<
|
||||
TFields,
|
||||
TIDField,
|
||||
@@ -191,37 +184,14 @@ export class EnforcingKnexEntityLoader<
|
||||
/**
|
||||
* Load a page of entities with Relay-style cursor pagination.
|
||||
*
|
||||
* @param args - Pagination arguments with either first/after or last/before
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* // Forward pagination - get first 10 items
|
||||
* const users = await TestEntity.knexLoader(vc)
|
||||
* .loadPageBySQLAsync({
|
||||
* first: 10,
|
||||
* where: sql`age > ${18}`,
|
||||
* orderBy: 'created_at'
|
||||
* });
|
||||
*
|
||||
* // Backward pagination with cursor - get last 10 items before the cursor
|
||||
* const lastResults = await TestEntity.knexLoader(vc)
|
||||
* .loadPageBySQLAsync({
|
||||
* last: 10,
|
||||
* where: sql`status = ${'active'}`,
|
||||
* before: cursor,
|
||||
* });
|
||||
* ```
|
||||
*
|
||||
* @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 loadPageBySQLAsync(
|
||||
async loadPageAsync(
|
||||
args: EntityLoaderLoadPageArgs<TFields, TSelectedFields>,
|
||||
): Promise<Connection<TEntity>> {
|
||||
const pageResult = await this.knexDataManager.loadPageBySQLFragmentAsync(
|
||||
this.queryContext,
|
||||
args,
|
||||
);
|
||||
|
||||
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(
|
||||
@@ -240,6 +210,18 @@ export class EnforcingKnexEntityLoader<
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -269,8 +251,8 @@ export class EnforcingSQLQueryBuilder<
|
||||
TPrivacyPolicy,
|
||||
TSelectedFields
|
||||
>,
|
||||
sqlFragment: SQLFragment,
|
||||
modifiers: EntityLoaderQuerySelectionModifiersWithOrderByFragment<TFields, TSelectedFields>,
|
||||
sqlFragment: SQLFragment<Pick<TFields, TSelectedFields>>,
|
||||
modifiers: EntityLoaderQuerySelectionModifiers<TFields, TSelectedFields>,
|
||||
) {
|
||||
super(sqlFragment, modifiers);
|
||||
}
|
||||
|
||||
@@ -1,81 +0,0 @@
|
||||
import {
|
||||
IEntityClass,
|
||||
EntityPrivacyPolicy,
|
||||
EntityQueryContext,
|
||||
ReadonlyEntity,
|
||||
ViewerContext,
|
||||
} from '@expo/entity';
|
||||
|
||||
import { AuthorizationResultBasedKnexEntityLoader } from './AuthorizationResultBasedKnexEntityLoader';
|
||||
import { EnforcingKnexEntityLoader } from './EnforcingKnexEntityLoader';
|
||||
|
||||
/**
|
||||
* The primary interface for loading entities via non-data-loader-based methods
|
||||
* (knex queries). These methods are not batched or cached through dataloader.
|
||||
*/
|
||||
export class KnexEntityLoader<
|
||||
TFields extends Record<string, any>,
|
||||
TIDField extends keyof NonNullable<Pick<TFields, TSelectedFields>>,
|
||||
TViewerContext extends ViewerContext,
|
||||
TViewerContext2 extends TViewerContext,
|
||||
TEntity extends ReadonlyEntity<TFields, TIDField, TViewerContext, TSelectedFields>,
|
||||
TPrivacyPolicy extends EntityPrivacyPolicy<
|
||||
TFields,
|
||||
TIDField,
|
||||
TViewerContext,
|
||||
TEntity,
|
||||
TSelectedFields
|
||||
>,
|
||||
TSelectedFields extends keyof TFields,
|
||||
> {
|
||||
constructor(
|
||||
private readonly viewerContext: TViewerContext2,
|
||||
private readonly queryContext: EntityQueryContext,
|
||||
private readonly entityClass: IEntityClass<
|
||||
TFields,
|
||||
TIDField,
|
||||
TViewerContext,
|
||||
TEntity,
|
||||
TPrivacyPolicy,
|
||||
TSelectedFields
|
||||
>,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Enforcing knex entity loader. All loads through this loader are
|
||||
* guaranteed to be the values of successful results (or null for some loader methods),
|
||||
* and will throw otherwise.
|
||||
*/
|
||||
enforcing(): EnforcingKnexEntityLoader<
|
||||
TFields,
|
||||
TIDField,
|
||||
TViewerContext,
|
||||
TEntity,
|
||||
TPrivacyPolicy,
|
||||
TSelectedFields
|
||||
> {
|
||||
return this.viewerContext
|
||||
.getViewerScopedEntityCompanionForClass(this.entityClass)
|
||||
.getKnexLoaderFactory()
|
||||
.forLoadEnforcing(this.queryContext, { previousValue: null, cascadingDeleteCause: null });
|
||||
}
|
||||
|
||||
/**
|
||||
* Authorization-result-based knex entity loader. 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.
|
||||
*/
|
||||
withAuthorizationResults(): AuthorizationResultBasedKnexEntityLoader<
|
||||
TFields,
|
||||
TIDField,
|
||||
TViewerContext,
|
||||
TEntity,
|
||||
TPrivacyPolicy,
|
||||
TSelectedFields
|
||||
> {
|
||||
return this.viewerContext
|
||||
.getViewerScopedEntityCompanionForClass(this.entityClass)
|
||||
.getKnexLoaderFactory()
|
||||
.forLoad(this.queryContext, { previousValue: null, cascadingDeleteCause: null });
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import {
|
||||
EntityCompanion,
|
||||
EntityConstructionUtils,
|
||||
EntityPrivacyPolicy,
|
||||
EntityPrivacyPolicyEvaluationContext,
|
||||
EntityQueryContext,
|
||||
@@ -7,7 +8,6 @@ import {
|
||||
ViewerContext,
|
||||
IEntityMetricsAdapter,
|
||||
} from '@expo/entity';
|
||||
import { EntityConstructionUtils } from '@expo/entity/src/EntityConstructionUtils';
|
||||
|
||||
import { AuthorizationResultBasedKnexEntityLoader } from './AuthorizationResultBasedKnexEntityLoader';
|
||||
import { EnforcingKnexEntityLoader } from './EnforcingKnexEntityLoader';
|
||||
|
||||
@@ -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',
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
import {
|
||||
Entity,
|
||||
EntityPrivacyPolicy,
|
||||
EntityQueryContext,
|
||||
IEntityClass,
|
||||
ReadonlyEntity,
|
||||
ViewerContext,
|
||||
} from '@expo/entity';
|
||||
|
||||
import { AuthorizationResultBasedKnexEntityLoader } from './AuthorizationResultBasedKnexEntityLoader';
|
||||
import { EnforcingKnexEntityLoader } from './EnforcingKnexEntityLoader';
|
||||
import {
|
||||
knexLoader as knexLoaderFn,
|
||||
knexLoaderWithAuthorizationResults as knexLoaderWithAuthorizationResultsFn,
|
||||
} from './knexLoader';
|
||||
|
||||
/**
|
||||
* Abstract base class for mutable entities backed by Postgres.
|
||||
* Provides `knexLoader` and `knexLoaderWithAuthorizationResults` as inherited static methods,
|
||||
* in addition to the mutation methods inherited from `Entity`.
|
||||
*/
|
||||
export abstract class PostgresEntity<
|
||||
TFields extends Record<string, any>,
|
||||
TIDField extends keyof NonNullable<Pick<TFields, TSelectedFields>>,
|
||||
TViewerContext extends ViewerContext,
|
||||
TSelectedFields extends keyof TFields = keyof TFields,
|
||||
> extends Entity<TFields, TIDField, TViewerContext, TSelectedFields> {
|
||||
/**
|
||||
* Vend knex loader for loading entities via knex-specific methods in a given query context.
|
||||
*
|
||||
* @param viewerContext - viewer context of loading user
|
||||
* @param queryContext - query context in which to perform the load
|
||||
*/
|
||||
static knexLoader<
|
||||
TMFields extends object,
|
||||
TMIDField extends keyof NonNullable<Pick<TMFields, TMSelectedFields>>,
|
||||
TMViewerContext extends ViewerContext,
|
||||
TMViewerContext2 extends TMViewerContext,
|
||||
TMEntity extends ReadonlyEntity<TMFields, TMIDField, TMViewerContext, TMSelectedFields>,
|
||||
TMPrivacyPolicy extends EntityPrivacyPolicy<
|
||||
TMFields,
|
||||
TMIDField,
|
||||
TMViewerContext,
|
||||
TMEntity,
|
||||
TMSelectedFields
|
||||
>,
|
||||
TMSelectedFields extends keyof TMFields = keyof TMFields,
|
||||
>(
|
||||
this: IEntityClass<
|
||||
TMFields,
|
||||
TMIDField,
|
||||
TMViewerContext,
|
||||
TMEntity,
|
||||
TMPrivacyPolicy,
|
||||
TMSelectedFields
|
||||
>,
|
||||
viewerContext: TMViewerContext2,
|
||||
queryContext: EntityQueryContext = viewerContext
|
||||
.getViewerScopedEntityCompanionForClass(this)
|
||||
.getQueryContextProvider()
|
||||
.getQueryContext(),
|
||||
): EnforcingKnexEntityLoader<
|
||||
TMFields,
|
||||
TMIDField,
|
||||
TMViewerContext,
|
||||
TMEntity,
|
||||
TMPrivacyPolicy,
|
||||
TMSelectedFields
|
||||
> {
|
||||
return knexLoaderFn(this, viewerContext, queryContext);
|
||||
}
|
||||
|
||||
/**
|
||||
* Vend knex loader for loading entities via knex-specific methods in a given query context.
|
||||
* Returns authorization results instead of throwing on authorization errors.
|
||||
*
|
||||
* @param viewerContext - viewer context of loading user
|
||||
* @param queryContext - query context in which to perform the load
|
||||
*/
|
||||
static knexLoaderWithAuthorizationResults<
|
||||
TMFields extends object,
|
||||
TMIDField extends keyof NonNullable<Pick<TMFields, TMSelectedFields>>,
|
||||
TMViewerContext extends ViewerContext,
|
||||
TMViewerContext2 extends TMViewerContext,
|
||||
TMEntity extends ReadonlyEntity<TMFields, TMIDField, TMViewerContext, TMSelectedFields>,
|
||||
TMPrivacyPolicy extends EntityPrivacyPolicy<
|
||||
TMFields,
|
||||
TMIDField,
|
||||
TMViewerContext,
|
||||
TMEntity,
|
||||
TMSelectedFields
|
||||
>,
|
||||
TMSelectedFields extends keyof TMFields = keyof TMFields,
|
||||
>(
|
||||
this: IEntityClass<
|
||||
TMFields,
|
||||
TMIDField,
|
||||
TMViewerContext,
|
||||
TMEntity,
|
||||
TMPrivacyPolicy,
|
||||
TMSelectedFields
|
||||
>,
|
||||
viewerContext: TMViewerContext2,
|
||||
queryContext: EntityQueryContext = viewerContext
|
||||
.getViewerScopedEntityCompanionForClass(this)
|
||||
.getQueryContextProvider()
|
||||
.getQueryContext(),
|
||||
): AuthorizationResultBasedKnexEntityLoader<
|
||||
TMFields,
|
||||
TMIDField,
|
||||
TMViewerContext,
|
||||
TMEntity,
|
||||
TMPrivacyPolicy,
|
||||
TMSelectedFields
|
||||
> {
|
||||
return knexLoaderWithAuthorizationResultsFn(this, viewerContext, queryContext);
|
||||
}
|
||||
}
|
||||
@@ -1,15 +1,20 @@
|
||||
import { FieldTransformer, FieldTransformerMap } from '@expo/entity';
|
||||
import {
|
||||
EntityConfiguration,
|
||||
FieldTransformer,
|
||||
FieldTransformerMap,
|
||||
getDatabaseFieldForEntityField,
|
||||
} from '@expo/entity';
|
||||
import { Knex } from 'knex';
|
||||
|
||||
import {
|
||||
BasePostgresEntityDatabaseAdapter,
|
||||
NullsOrdering,
|
||||
TableFieldMultiValueEqualityCondition,
|
||||
TableFieldSingleValueEqualityCondition,
|
||||
TableQuerySelectionModifiers,
|
||||
TableQuerySelectionModifiersWithOrderByFragment,
|
||||
TableQuerySelectionModifiersWithOrderByRaw,
|
||||
} from './BasePostgresEntityDatabaseAdapter';
|
||||
import { JSONArrayField, MaybeJSONArrayField } from './EntityFields';
|
||||
import { PostgresEntityDatabaseAdapterConfiguration } from './PostgresEntityDatabaseAdapterProvider';
|
||||
import { SQLFragment } from './SQLOperator';
|
||||
import { wrapNativePostgresCallAsync } from './errors/wrapNativePostgresCallAsync';
|
||||
|
||||
@@ -17,6 +22,17 @@ export class PostgresEntityDatabaseAdapter<
|
||||
TFields extends Record<string, any>,
|
||||
TIDField extends keyof TFields,
|
||||
> extends BasePostgresEntityDatabaseAdapter<TFields, TIDField> {
|
||||
constructor(
|
||||
entityConfiguration: EntityConfiguration<TFields, TIDField>,
|
||||
private readonly adapterConfiguration: PostgresEntityDatabaseAdapterConfiguration = {},
|
||||
) {
|
||||
super(entityConfiguration);
|
||||
}
|
||||
|
||||
override get paginationMaxPageSize(): number | undefined {
|
||||
return this.adapterConfiguration.paginationMaxPageSize;
|
||||
}
|
||||
|
||||
protected getFieldTransformerMap(): FieldTransformerMap {
|
||||
return new Map<string, FieldTransformer<any>>([
|
||||
[
|
||||
@@ -106,23 +122,9 @@ export class PostgresEntityDatabaseAdapter<
|
||||
return results[0] ?? null;
|
||||
}
|
||||
|
||||
private applyQueryModifiersToQueryOrderByRaw(
|
||||
query: Knex.QueryBuilder,
|
||||
querySelectionModifiers: TableQuerySelectionModifiersWithOrderByRaw,
|
||||
): Knex.QueryBuilder {
|
||||
let ret = this.applyQueryModifiersToQuery(query, querySelectionModifiers);
|
||||
|
||||
const { orderByRaw } = querySelectionModifiers;
|
||||
if (orderByRaw !== undefined) {
|
||||
ret = ret.orderByRaw(orderByRaw);
|
||||
}
|
||||
|
||||
return ret;
|
||||
}
|
||||
|
||||
private applyQueryModifiersToQuery(
|
||||
query: Knex.QueryBuilder,
|
||||
querySelectionModifiers: TableQuerySelectionModifiers,
|
||||
querySelectionModifiers: TableQuerySelectionModifiers<TFields>,
|
||||
): Knex.QueryBuilder {
|
||||
const { orderBy, offset, limit } = querySelectionModifiers;
|
||||
|
||||
@@ -130,7 +132,23 @@ export class PostgresEntityDatabaseAdapter<
|
||||
|
||||
if (orderBy !== undefined) {
|
||||
for (const orderBySpecification of orderBy) {
|
||||
ret = ret.orderBy(orderBySpecification.columnName, orderBySpecification.order);
|
||||
if ('columnName' in orderBySpecification) {
|
||||
ret = ret.orderBy(
|
||||
orderBySpecification.columnName,
|
||||
orderBySpecification.order,
|
||||
orderBySpecification.nulls,
|
||||
);
|
||||
} else {
|
||||
const nullsSuffix = orderBySpecification.nulls
|
||||
? ` NULLS ${orderBySpecification.nulls === NullsOrdering.FIRST ? 'FIRST' : 'LAST'}`
|
||||
: '';
|
||||
ret = ret.orderByRaw(
|
||||
`(${orderBySpecification.columnFragment.sql}) ${orderBySpecification.order}${nullsSuffix}`,
|
||||
orderBySpecification.columnFragment.getKnexBindings((fieldName) =>
|
||||
getDatabaseFieldForEntityField(this.entityConfiguration, fieldName),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -150,7 +168,7 @@ export class PostgresEntityDatabaseAdapter<
|
||||
tableName: string,
|
||||
tableFieldSingleValueEqualityOperands: TableFieldSingleValueEqualityCondition[],
|
||||
tableFieldMultiValueEqualityOperands: TableFieldMultiValueEqualityCondition[],
|
||||
querySelectionModifiers: TableQuerySelectionModifiers,
|
||||
querySelectionModifiers: TableQuerySelectionModifiers<TFields>,
|
||||
): Promise<object[]> {
|
||||
let query = queryInterface.select().from(tableName);
|
||||
|
||||
@@ -196,31 +214,29 @@ export class PostgresEntityDatabaseAdapter<
|
||||
tableName: string,
|
||||
rawWhereClause: string,
|
||||
bindings: object | any[],
|
||||
querySelectionModifiers: TableQuerySelectionModifiersWithOrderByRaw,
|
||||
querySelectionModifiers: TableQuerySelectionModifiers<TFields>,
|
||||
): Promise<object[]> {
|
||||
let query = queryInterface
|
||||
.select()
|
||||
.from(tableName)
|
||||
.whereRaw(rawWhereClause, bindings as any);
|
||||
query = this.applyQueryModifiersToQueryOrderByRaw(query, querySelectionModifiers);
|
||||
let query = queryInterface.select().from(tableName).whereRaw(rawWhereClause, bindings);
|
||||
query = this.applyQueryModifiersToQuery(query, querySelectionModifiers);
|
||||
return await wrapNativePostgresCallAsync(() => query);
|
||||
}
|
||||
|
||||
protected async fetchManyBySQLFragmentInternalAsync(
|
||||
queryInterface: Knex,
|
||||
tableName: string,
|
||||
sqlFragment: SQLFragment,
|
||||
querySelectionModifiers: TableQuerySelectionModifiersWithOrderByFragment,
|
||||
sqlFragment: SQLFragment<TFields>,
|
||||
querySelectionModifiers: TableQuerySelectionModifiers<TFields>,
|
||||
): Promise<object[]> {
|
||||
let query = queryInterface
|
||||
.select()
|
||||
.from(tableName)
|
||||
.whereRaw(sqlFragment.sql, sqlFragment.getKnexBindings());
|
||||
.whereRaw(
|
||||
sqlFragment.sql,
|
||||
sqlFragment.getKnexBindings((fieldName) =>
|
||||
getDatabaseFieldForEntityField(this.entityConfiguration, fieldName),
|
||||
),
|
||||
);
|
||||
query = this.applyQueryModifiersToQuery(query, querySelectionModifiers);
|
||||
const { orderByFragment } = querySelectionModifiers;
|
||||
if (orderByFragment !== undefined) {
|
||||
query = query.orderByRaw(orderByFragment.sql, orderByFragment.getKnexBindings());
|
||||
}
|
||||
return await wrapNativePostgresCallAsync(() => query);
|
||||
}
|
||||
|
||||
|
||||
+10
-15
@@ -5,26 +5,21 @@ import {
|
||||
} from '@expo/entity';
|
||||
|
||||
import { PostgresEntityDatabaseAdapter } from './PostgresEntityDatabaseAdapter';
|
||||
import { installEntityCompanionExtensions } from './extensions/EntityCompanionExtensions';
|
||||
import { installEntityTableDataCoordinatorExtensions } from './extensions/EntityTableDataCoordinatorExtensions';
|
||||
import { installReadonlyEntityExtensions } from './extensions/ReadonlyEntityExtensions';
|
||||
import { installViewerScopedEntityCompanionExtensions } from './extensions/ViewerScopedEntityCompanionExtensions';
|
||||
|
||||
export interface PostgresEntityDatabaseAdapterConfiguration {
|
||||
/**
|
||||
* Maximum page size for pagination (first/last parameters).
|
||||
* If not specified, no limit is enforced.
|
||||
*/
|
||||
paginationMaxPageSize?: number;
|
||||
}
|
||||
|
||||
export class PostgresEntityDatabaseAdapterProvider implements IEntityDatabaseAdapterProvider {
|
||||
getExtensionsKey(): string {
|
||||
return 'PostgresEntityDatabaseAdapterProvider';
|
||||
}
|
||||
|
||||
installExtensions(): void {
|
||||
installEntityCompanionExtensions();
|
||||
installEntityTableDataCoordinatorExtensions();
|
||||
installViewerScopedEntityCompanionExtensions();
|
||||
installReadonlyEntityExtensions();
|
||||
}
|
||||
constructor(private readonly configuration: PostgresEntityDatabaseAdapterConfiguration = {}) {}
|
||||
|
||||
getDatabaseAdapter<TFields extends Record<string, any>, TIDField extends keyof TFields>(
|
||||
entityConfiguration: EntityConfiguration<TFields, TIDField>,
|
||||
): EntityDatabaseAdapter<TFields, TIDField> {
|
||||
return new PostgresEntityDatabaseAdapter(entityConfiguration);
|
||||
return new PostgresEntityDatabaseAdapter(entityConfiguration, this.configuration);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
import {
|
||||
EntityPrivacyPolicy,
|
||||
EntityQueryContext,
|
||||
IEntityClass,
|
||||
ReadonlyEntity,
|
||||
ViewerContext,
|
||||
} from '@expo/entity';
|
||||
|
||||
import { AuthorizationResultBasedKnexEntityLoader } from './AuthorizationResultBasedKnexEntityLoader';
|
||||
import { EnforcingKnexEntityLoader } from './EnforcingKnexEntityLoader';
|
||||
import {
|
||||
knexLoader as knexLoaderFn,
|
||||
knexLoaderWithAuthorizationResults as knexLoaderWithAuthorizationResultsFn,
|
||||
} from './knexLoader';
|
||||
|
||||
/**
|
||||
* Abstract base class for readonly entities backed by Postgres.
|
||||
* Provides `knexLoader` and `knexLoaderWithAuthorizationResults` as inherited static methods.
|
||||
*
|
||||
* Entities that should not be mutated (e.g., representing SQL views or immutable tables)
|
||||
* can extend this class instead of `ReadonlyEntity` to get knex loader ergonomics.
|
||||
*/
|
||||
export abstract class ReadonlyPostgresEntity<
|
||||
TFields extends Record<string, any>,
|
||||
TIDField extends keyof NonNullable<Pick<TFields, TSelectedFields>>,
|
||||
TViewerContext extends ViewerContext,
|
||||
TSelectedFields extends keyof TFields = keyof TFields,
|
||||
> extends ReadonlyEntity<TFields, TIDField, TViewerContext, TSelectedFields> {
|
||||
/**
|
||||
* Vend knex loader for loading entities via non-data-loader methods in a given query context.
|
||||
* @param viewerContext - viewer context of loading user
|
||||
* @param queryContext - query context in which to perform the load
|
||||
*/
|
||||
static knexLoader<
|
||||
TMFields extends object,
|
||||
TMIDField extends keyof NonNullable<Pick<TMFields, TMSelectedFields>>,
|
||||
TMViewerContext extends ViewerContext,
|
||||
TMEntity extends ReadonlyEntity<TMFields, TMIDField, TMViewerContext, TMSelectedFields>,
|
||||
TMPrivacyPolicy extends EntityPrivacyPolicy<
|
||||
TMFields,
|
||||
TMIDField,
|
||||
TMViewerContext,
|
||||
TMEntity,
|
||||
TMSelectedFields
|
||||
>,
|
||||
TMSelectedFields extends keyof TMFields = keyof TMFields,
|
||||
>(
|
||||
this: IEntityClass<
|
||||
TMFields,
|
||||
TMIDField,
|
||||
TMViewerContext,
|
||||
TMEntity,
|
||||
TMPrivacyPolicy,
|
||||
TMSelectedFields
|
||||
>,
|
||||
viewerContext: TMViewerContext,
|
||||
queryContext: EntityQueryContext = viewerContext
|
||||
.getViewerScopedEntityCompanionForClass(this)
|
||||
.getQueryContextProvider()
|
||||
.getQueryContext(),
|
||||
): EnforcingKnexEntityLoader<
|
||||
TMFields,
|
||||
TMIDField,
|
||||
TMViewerContext,
|
||||
TMEntity,
|
||||
TMPrivacyPolicy,
|
||||
TMSelectedFields
|
||||
> {
|
||||
return knexLoaderFn(this, viewerContext, queryContext);
|
||||
}
|
||||
|
||||
/**
|
||||
* Vend knex loader for loading entities via non-data-loader methods in a given query context.
|
||||
* Returns authorization results instead of throwing on authorization errors.
|
||||
* @param viewerContext - viewer context of loading user
|
||||
* @param queryContext - query context in which to perform the load
|
||||
*/
|
||||
static knexLoaderWithAuthorizationResults<
|
||||
TMFields extends object,
|
||||
TMIDField extends keyof NonNullable<Pick<TMFields, TMSelectedFields>>,
|
||||
TMViewerContext extends ViewerContext,
|
||||
TMEntity extends ReadonlyEntity<TMFields, TMIDField, TMViewerContext, TMSelectedFields>,
|
||||
TMPrivacyPolicy extends EntityPrivacyPolicy<
|
||||
TMFields,
|
||||
TMIDField,
|
||||
TMViewerContext,
|
||||
TMEntity,
|
||||
TMSelectedFields
|
||||
>,
|
||||
TMSelectedFields extends keyof TMFields = keyof TMFields,
|
||||
>(
|
||||
this: IEntityClass<
|
||||
TMFields,
|
||||
TMIDField,
|
||||
TMViewerContext,
|
||||
TMEntity,
|
||||
TMPrivacyPolicy,
|
||||
TMSelectedFields
|
||||
>,
|
||||
viewerContext: TMViewerContext,
|
||||
queryContext: EntityQueryContext = viewerContext
|
||||
.getViewerScopedEntityCompanionForClass(this)
|
||||
.getQueryContextProvider()
|
||||
.getQueryContext(),
|
||||
): AuthorizationResultBasedKnexEntityLoader<
|
||||
TMFields,
|
||||
TMIDField,
|
||||
TMViewerContext,
|
||||
TMEntity,
|
||||
TMPrivacyPolicy,
|
||||
TMSelectedFields
|
||||
> {
|
||||
return knexLoaderWithAuthorizationResultsFn(this, viewerContext, queryContext);
|
||||
}
|
||||
}
|
||||
@@ -17,29 +17,37 @@ export type SupportedSQLValue =
|
||||
/**
|
||||
* Types of bindings that can be used in SQL queries.
|
||||
*/
|
||||
export type SQLBinding =
|
||||
export type SQLBinding<TFields extends Record<string, any>> =
|
||||
| { type: 'value'; value: SupportedSQLValue }
|
||||
| { type: 'identifier'; name: string };
|
||||
| { type: 'identifier'; name: string }
|
||||
| { type: 'entityField'; fieldName: keyof TFields };
|
||||
|
||||
/**
|
||||
* SQL Fragment class that safely handles parameterized queries.
|
||||
*/
|
||||
export class SQLFragment {
|
||||
export class SQLFragment<TFields extends Record<string, any>> {
|
||||
constructor(
|
||||
public readonly sql: string,
|
||||
public readonly bindings: readonly SQLBinding[],
|
||||
public readonly bindings: readonly SQLBinding<TFields>[],
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Get bindings in the format expected by Knex.
|
||||
* Knex expects a flat array where both identifiers and values are mixed in order.
|
||||
*
|
||||
* @param getColumnForField - function that resolves an entity field name to its database column name
|
||||
*/
|
||||
getKnexBindings(): readonly (string | SupportedSQLValue)[] {
|
||||
getKnexBindings(
|
||||
getColumnForField: (fieldName: keyof TFields) => string,
|
||||
): readonly (string | SupportedSQLValue)[] {
|
||||
return this.bindings.map((b) => {
|
||||
if (b.type === 'identifier') {
|
||||
return b.name;
|
||||
} else {
|
||||
return b.value;
|
||||
switch (b.type) {
|
||||
case 'entityField':
|
||||
return getColumnForField(b.fieldName);
|
||||
case 'identifier':
|
||||
return b.name;
|
||||
case 'value':
|
||||
return b.value;
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -47,20 +55,21 @@ export class SQLFragment {
|
||||
/**
|
||||
* Combine SQL fragments
|
||||
*/
|
||||
append(other: SQLFragment): SQLFragment {
|
||||
return SQLFragment.join([this, other], ' ');
|
||||
append(other: SQLFragment<TFields>): SQLFragment<TFields> {
|
||||
return joinSQLFragments([this, other], ' ');
|
||||
}
|
||||
|
||||
/**
|
||||
* Join multiple SQL fragments into a single fragment with a separator.
|
||||
* Join multiple SQL fragments with a comma separator.
|
||||
* Useful for combining column lists, value lists, etc.
|
||||
*
|
||||
* @param fragments - Array of SQL fragments to join
|
||||
* @param separator - Separator string (default: ', ')
|
||||
* @returns - A new SQLFragment with the fragments joined by a comma and space
|
||||
*/
|
||||
static join(fragments: SQLFragment[], separator = ', '): SQLFragment {
|
||||
return new SQLFragment(
|
||||
fragments.map((f) => f.sql).join(separator),
|
||||
fragments.flatMap((f) => f.bindings),
|
||||
);
|
||||
static joinWithCommaSeparator<TFields extends Record<string, any>>(
|
||||
...fragments: readonly SQLFragment<TFields>[]
|
||||
): SQLFragment<TFields> {
|
||||
return joinSQLFragments(fragments, ', ');
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -75,15 +84,17 @@ export class SQLFragment {
|
||||
* // Generates: "SELECT * FROM users WHERE age > ? ORDER BY name"
|
||||
* ```
|
||||
*/
|
||||
static concat(...fragments: SQLFragment[]): SQLFragment {
|
||||
return SQLFragment.join(fragments, ' ');
|
||||
static concat<TFields extends Record<string, any>>(
|
||||
...fragments: readonly SQLFragment<TFields>[]
|
||||
): SQLFragment<TFields> {
|
||||
return joinSQLFragments(fragments, ' ');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a debug representation of the query with values inline
|
||||
* WARNING: This is for debugging only. Never execute the returned string directly.
|
||||
*/
|
||||
get toDebugString(): string {
|
||||
getDebugString(): string {
|
||||
let debugString = this.sql;
|
||||
let bindingIndex = 0;
|
||||
|
||||
@@ -101,6 +112,9 @@ export class SQLFragment {
|
||||
if (match === '??' && binding.type === 'identifier') {
|
||||
// For identifiers, show them quoted as they would appear
|
||||
return `"${binding.name.replace(/"/g, '""')}"`;
|
||||
} else if (match === '??' && binding.type === 'entityField') {
|
||||
// For entity fields, show the entity field name as the identifier for debugging
|
||||
return `"${binding.fieldName.toString()}"`;
|
||||
} else if (match === '?' && binding.type === 'value') {
|
||||
return SQLFragment.formatDebugValue(binding.value);
|
||||
} else {
|
||||
@@ -177,17 +191,24 @@ export class SQLIdentifier {
|
||||
constructor(public readonly name: string) {}
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper for referencing entity fields that can be used in SQL queries. This allows for type-safe references to fields of an entity
|
||||
* and does automatic translation to DB field names.
|
||||
*/
|
||||
export class SQLEntityField<TFields extends Record<string, any>> {
|
||||
constructor(public readonly fieldName: keyof TFields) {}
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper for raw SQL that should not be parameterized
|
||||
* WARNING: Only use this with trusted input to avoid SQL injection
|
||||
*/
|
||||
export class SQLRaw {
|
||||
export class SQLUnsafeRaw {
|
||||
constructor(public readonly rawSql: string) {}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a SQL identifier (table/column name) that will be escaped by Knex using ??.
|
||||
* The escaping is delegated to Knex which will handle it based on the database type.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
@@ -200,6 +221,18 @@ export function identifier(name: string): SQLIdentifier {
|
||||
return new SQLIdentifier(name);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a reference to an entity field that can be used in SQL queries. This allows for type-safe references to fields of an entity
|
||||
* and does automatic translation to DB field names and will be escaped by Knex using ??.
|
||||
*
|
||||
* @param fieldName - The entity field name to reference.
|
||||
*/
|
||||
export function entityField<TFields extends Record<string, any>>(
|
||||
fieldName: keyof TFields,
|
||||
): SQLEntityField<TFields> {
|
||||
return new SQLEntityField(fieldName);
|
||||
}
|
||||
|
||||
/**
|
||||
* Insert raw SQL that will not be parameterized
|
||||
* WARNING: This bypasses SQL injection protection. Only use with trusted input.
|
||||
@@ -208,14 +241,14 @@ export function identifier(name: string): SQLIdentifier {
|
||||
* ```ts
|
||||
* // Dynamic column names
|
||||
* const sortColumn = 'created_at';
|
||||
* const query = sql`ORDER BY ${raw(sortColumn)} DESC`;
|
||||
* const query = sql`ORDER BY ${unsafeRaw(sortColumn)} DESC`;
|
||||
*
|
||||
* // Dynamic SQL expressions
|
||||
* const query = sql`WHERE ${raw('EXTRACT(year FROM created_at)')} = ${2024}`;
|
||||
* const query = sql`WHERE ${unsafeRaw('EXTRACT(year FROM created_at)')} = ${2024}`;
|
||||
* ```
|
||||
*/
|
||||
export function raw(sqlString: string): SQLRaw {
|
||||
return new SQLRaw(sqlString);
|
||||
export function unsafeRaw(sqlString: string): SQLUnsafeRaw {
|
||||
return new SQLUnsafeRaw(sqlString);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -227,12 +260,18 @@ export function raw(sqlString: string): SQLRaw {
|
||||
* const query = sql`age >= ${age} AND status = ${'active'}`;
|
||||
* ```
|
||||
*/
|
||||
export function sql(
|
||||
export function sql<TFields extends Record<string, any>>(
|
||||
strings: TemplateStringsArray,
|
||||
...values: readonly (SupportedSQLValue | SQLFragment | SQLIdentifier | SQLRaw)[]
|
||||
): SQLFragment {
|
||||
...values: readonly (
|
||||
| SupportedSQLValue
|
||||
| SQLFragment<TFields>
|
||||
| SQLIdentifier
|
||||
| SQLUnsafeRaw
|
||||
| SQLEntityField<TFields>
|
||||
)[]
|
||||
): SQLFragment<TFields> {
|
||||
let sqlString = '';
|
||||
const bindings: SQLBinding[] = [];
|
||||
const bindings: SQLBinding<TFields>[] = [];
|
||||
|
||||
strings.forEach((string, i) => {
|
||||
sqlString += string;
|
||||
@@ -247,13 +286,17 @@ export function sql(
|
||||
// Handle identifiers (table/column names) with ?? placeholder
|
||||
sqlString += '??';
|
||||
bindings.push({ type: 'identifier', name: value.name });
|
||||
} else if (value instanceof SQLRaw) {
|
||||
} else if (value instanceof SQLEntityField) {
|
||||
// Handle entity field references by treating them as identifiers
|
||||
sqlString += '??';
|
||||
bindings.push({ type: 'entityField', fieldName: value.fieldName });
|
||||
} else if (value instanceof SQLUnsafeRaw) {
|
||||
// Handle raw SQL (WARNING: no parameterization)
|
||||
sqlString += value.rawSql;
|
||||
} else if (Array.isArray(value)) {
|
||||
// Handle IN clauses
|
||||
sqlString += `(${value.map(() => '?').join(', ')})`;
|
||||
bindings.push(...value.map((v) => ({ type: 'value' as const, value: v })));
|
||||
bindings.push(...value.map((v): SQLBinding<TFields> => ({ type: 'value', value: v })));
|
||||
} else {
|
||||
// Regular value binding
|
||||
sqlString += '?';
|
||||
@@ -265,6 +308,10 @@ export function sql(
|
||||
return new SQLFragment(sqlString, bindings);
|
||||
}
|
||||
|
||||
type PickSupportedSQLValueKeys<T> = {
|
||||
[K in keyof T]: T[K] extends SupportedSQLValue ? K : never;
|
||||
}[keyof T];
|
||||
|
||||
/**
|
||||
* Common SQL helper functions for building queries
|
||||
*/
|
||||
@@ -274,28 +321,33 @@ export const SQLFragmentHelpers = {
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* const query = SQLFragmentHelpers.inArray('status', ['active', 'pending']);
|
||||
* // Generates: ?? IN (?, ?) with bindings ['status', 'active', 'pending']
|
||||
* const query = SQLFragmentHelpers.inArray<MyFields, 'id'>('status', ['active', 'pending']);
|
||||
* // Generates: ?? IN (?, ?) with entityField binding for 'status' and value bindings
|
||||
* ```
|
||||
*/
|
||||
inArray<T extends SupportedSQLValue>(column: string, values: readonly T[]): SQLFragment {
|
||||
inArray<TFields extends Record<string, any>, N extends PickSupportedSQLValueKeys<TFields>>(
|
||||
fieldName: N,
|
||||
values: readonly TFields[N][],
|
||||
): SQLFragment<TFields> {
|
||||
if (values.length === 0) {
|
||||
// Handle empty array case - always false
|
||||
return sql`1 = 0`;
|
||||
}
|
||||
// The array is already correctly typed, just needs to be seen as SupportedSQLValue for the template
|
||||
return sql`${identifier(column)} IN ${values as readonly SupportedSQLValue[]}`;
|
||||
return sql`${entityField(fieldName)} IN ${values}`;
|
||||
},
|
||||
|
||||
/**
|
||||
* NOT IN clause helper
|
||||
*/
|
||||
notInArray<T extends SupportedSQLValue>(column: string, values: readonly T[]): SQLFragment {
|
||||
notInArray<TFields extends Record<string, any>, N extends PickSupportedSQLValueKeys<TFields>>(
|
||||
fieldName: N,
|
||||
values: readonly TFields[N][],
|
||||
): SQLFragment<TFields> {
|
||||
if (values.length === 0) {
|
||||
// Handle empty array case - always true
|
||||
return sql`1 = 1`;
|
||||
}
|
||||
return sql`${identifier(column)} NOT IN ${values as readonly SupportedSQLValue[]}`;
|
||||
return sql`${entityField(fieldName)} NOT IN ${values}`;
|
||||
},
|
||||
|
||||
/**
|
||||
@@ -303,19 +355,27 @@ export const SQLFragmentHelpers = {
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* const query = SQLFragmentHelpers.between('age', 18, 65);
|
||||
* // Generates: "age" BETWEEN ? AND ? with values [18, 65]
|
||||
* const query = SQLFragmentHelpers.between<MyFields, 'id'>('age', 18, 65);
|
||||
* // Generates: ?? BETWEEN ? AND ? with entityField binding for 'age' and value bindings
|
||||
* ```
|
||||
*/
|
||||
between<T extends string | number | Date>(column: string, min: T, max: T): SQLFragment {
|
||||
return sql`${identifier(column)} BETWEEN ${min} AND ${max}`;
|
||||
between<TFields extends Record<string, any>, N extends PickSupportedSQLValueKeys<TFields>>(
|
||||
fieldName: N,
|
||||
min: TFields[N],
|
||||
max: TFields[N],
|
||||
): SQLFragment<TFields> {
|
||||
return sql`${entityField(fieldName)} BETWEEN ${min} AND ${max}`;
|
||||
},
|
||||
|
||||
/**
|
||||
* NOT BETWEEN helper
|
||||
*/
|
||||
notBetween<T extends string | number | Date>(column: string, min: T, max: T): SQLFragment {
|
||||
return sql`${identifier(column)} NOT BETWEEN ${min} AND ${max}`;
|
||||
notBetween<TFields extends Record<string, any>, N extends PickSupportedSQLValueKeys<TFields>>(
|
||||
fieldName: N,
|
||||
min: TFields[N],
|
||||
max: TFields[N],
|
||||
): SQLFragment<TFields> {
|
||||
return sql`${entityField(fieldName)} NOT BETWEEN ${min} AND ${max}`;
|
||||
},
|
||||
|
||||
/**
|
||||
@@ -323,156 +383,221 @@ export const SQLFragmentHelpers = {
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* const query = SQLFragmentHelpers.like('name', '%John%');
|
||||
* // Generates: "name" LIKE ? with value '%John%'
|
||||
* const query = SQLFragmentHelpers.like<MyFields, 'id'>('name', '%John%');
|
||||
* // Generates: ?? LIKE ? with entityField binding for 'name' and value binding
|
||||
* ```
|
||||
*/
|
||||
like(column: string, pattern: string): SQLFragment {
|
||||
return sql`${identifier(column)} LIKE ${pattern}`;
|
||||
like<TFields extends Record<string, any>>(
|
||||
fieldName: keyof TFields,
|
||||
pattern: string,
|
||||
): SQLFragment<TFields> {
|
||||
return sql`${entityField(fieldName)} LIKE ${pattern}`;
|
||||
},
|
||||
|
||||
/**
|
||||
* NOT LIKE helper
|
||||
*/
|
||||
notLike(column: string, pattern: string): SQLFragment {
|
||||
return sql`${identifier(column)} NOT LIKE ${pattern}`;
|
||||
notLike<TFields extends Record<string, any>>(
|
||||
fieldName: keyof TFields,
|
||||
pattern: string,
|
||||
): SQLFragment<TFields> {
|
||||
return sql`${entityField(fieldName)} NOT LIKE ${pattern}`;
|
||||
},
|
||||
|
||||
/**
|
||||
* ILIKE helper for case-insensitive matching
|
||||
*/
|
||||
ilike(column: string, pattern: string): SQLFragment {
|
||||
return sql`${identifier(column)} ILIKE ${pattern}`;
|
||||
ilike<TFields extends Record<string, any>>(
|
||||
fieldName: keyof TFields,
|
||||
pattern: string,
|
||||
): SQLFragment<TFields> {
|
||||
return sql`${entityField(fieldName)} ILIKE ${pattern}`;
|
||||
},
|
||||
|
||||
/**
|
||||
* NOT ILIKE helper for case-insensitive non-matching
|
||||
*/
|
||||
notIlike(column: string, pattern: string): SQLFragment {
|
||||
return sql`${identifier(column)} NOT ILIKE ${pattern}`;
|
||||
notIlike<TFields extends Record<string, any>>(
|
||||
fieldName: keyof TFields,
|
||||
pattern: string,
|
||||
): SQLFragment<TFields> {
|
||||
return sql`${entityField(fieldName)} NOT ILIKE ${pattern}`;
|
||||
},
|
||||
|
||||
/**
|
||||
* NULL check helper
|
||||
*/
|
||||
isNull(column: string): SQLFragment {
|
||||
return sql`${identifier(column)} IS NULL`;
|
||||
isNull<TFields extends Record<string, any>>(fieldName: keyof TFields): SQLFragment<TFields> {
|
||||
return sql`${entityField(fieldName)} IS NULL`;
|
||||
},
|
||||
|
||||
/**
|
||||
* NOT NULL check helper
|
||||
*/
|
||||
isNotNull(column: string): SQLFragment {
|
||||
return sql`${identifier(column)} IS NOT NULL`;
|
||||
isNotNull<TFields extends Record<string, any>>(fieldName: keyof TFields): SQLFragment<TFields> {
|
||||
return sql`${entityField(fieldName)} IS NOT NULL`;
|
||||
},
|
||||
|
||||
/**
|
||||
* Single-equals-equality operator
|
||||
*/
|
||||
eq(column: string, value: SupportedSQLValue): SQLFragment {
|
||||
eq<TFields extends Record<string, any>, N extends PickSupportedSQLValueKeys<TFields>>(
|
||||
fieldName: N,
|
||||
value: TFields[N],
|
||||
): SQLFragment<TFields> {
|
||||
if (value === null || value === undefined) {
|
||||
return SQLFragmentHelpers.isNull(column);
|
||||
return SQLFragmentHelpers.isNull(fieldName);
|
||||
}
|
||||
return sql`${identifier(column)} = ${value}`;
|
||||
return sql`${entityField(fieldName)} = ${value}`;
|
||||
},
|
||||
|
||||
/**
|
||||
* Single-equals-inequality operator
|
||||
*/
|
||||
neq(column: string, value: SupportedSQLValue): SQLFragment {
|
||||
neq<TFields extends Record<string, any>, N extends PickSupportedSQLValueKeys<TFields>>(
|
||||
fieldName: N,
|
||||
value: TFields[N],
|
||||
): SQLFragment<TFields> {
|
||||
if (value === null || value === undefined) {
|
||||
return SQLFragmentHelpers.isNotNull(column);
|
||||
return SQLFragmentHelpers.isNotNull(fieldName);
|
||||
}
|
||||
return sql`${identifier(column)} != ${value}`;
|
||||
return sql`${entityField(fieldName)} != ${value}`;
|
||||
},
|
||||
|
||||
/**
|
||||
* Greater-than comparison operator
|
||||
*/
|
||||
gt(column: string, value: SupportedSQLValue): SQLFragment {
|
||||
return sql`${identifier(column)} > ${value}`;
|
||||
gt<TFields extends Record<string, any>, N extends PickSupportedSQLValueKeys<TFields>>(
|
||||
fieldName: N,
|
||||
value: TFields[N],
|
||||
): SQLFragment<TFields> {
|
||||
return sql`${entityField(fieldName)} > ${value}`;
|
||||
},
|
||||
|
||||
/**
|
||||
* Greater-than-or-equal-to comparison operator
|
||||
*/
|
||||
gte(column: string, value: SupportedSQLValue): SQLFragment {
|
||||
return sql`${identifier(column)} >= ${value}`;
|
||||
gte<TFields extends Record<string, any>, N extends PickSupportedSQLValueKeys<TFields>>(
|
||||
fieldName: N,
|
||||
value: TFields[N],
|
||||
): SQLFragment<TFields> {
|
||||
return sql`${entityField(fieldName)} >= ${value}`;
|
||||
},
|
||||
|
||||
/**
|
||||
* Less-than comparison operator
|
||||
*/
|
||||
lt(column: string, value: SupportedSQLValue): SQLFragment {
|
||||
return sql`${identifier(column)} < ${value}`;
|
||||
lt<TFields extends Record<string, any>, N extends PickSupportedSQLValueKeys<TFields>>(
|
||||
fieldName: N,
|
||||
value: TFields[N],
|
||||
): SQLFragment<TFields> {
|
||||
return sql`${entityField(fieldName)} < ${value}`;
|
||||
},
|
||||
|
||||
/**
|
||||
* Less-than-or-equal-to comparison operator
|
||||
*/
|
||||
lte(column: string, value: SupportedSQLValue): SQLFragment {
|
||||
return sql`${identifier(column)} <= ${value}`;
|
||||
lte<TFields extends Record<string, any>, N extends PickSupportedSQLValueKeys<TFields>>(
|
||||
fieldName: N,
|
||||
value: TFields[N],
|
||||
): SQLFragment<TFields> {
|
||||
return sql`${entityField(fieldName)} <= ${value}`;
|
||||
},
|
||||
|
||||
/**
|
||||
* JSON contains operator (\@\>)
|
||||
*/
|
||||
jsonContains(column: string, value: unknown): SQLFragment {
|
||||
return sql`${identifier(column)} @> ${JSON.stringify(value)}::jsonb`;
|
||||
jsonContains<TFields extends Record<string, any>>(
|
||||
fieldName: keyof TFields,
|
||||
value: unknown,
|
||||
): SQLFragment<TFields> {
|
||||
return sql`${entityField(fieldName)} @> ${JSON.stringify(value)}::jsonb`;
|
||||
},
|
||||
|
||||
/**
|
||||
* JSON contained by operator (\<\@\)
|
||||
*/
|
||||
jsonContainedBy(column: string, value: unknown): SQLFragment {
|
||||
return sql`${identifier(column)} <@ ${JSON.stringify(value)}::jsonb`;
|
||||
jsonContainedBy<TFields extends Record<string, any>>(
|
||||
fieldName: keyof TFields,
|
||||
value: unknown,
|
||||
): SQLFragment<TFields> {
|
||||
return sql`${entityField(fieldName)} <@ ${JSON.stringify(value)}::jsonb`;
|
||||
},
|
||||
|
||||
/**
|
||||
* JSON path extraction helper (-\>)
|
||||
*/
|
||||
jsonPath(column: string, path: string): SQLFragment {
|
||||
return new SQLFragment(`"${column}"->'${path}'`, []);
|
||||
jsonPath<TFields extends Record<string, any>>(
|
||||
fieldName: keyof TFields,
|
||||
path: string,
|
||||
): SQLFragment<TFields> {
|
||||
return sql`${entityField(fieldName)}->${path}`;
|
||||
},
|
||||
|
||||
/**
|
||||
* JSON path text extraction helper (-\>\>)
|
||||
*/
|
||||
jsonPathText(column: string, path: string): SQLFragment {
|
||||
return new SQLFragment(`"${column}"->>'${path}'`, []);
|
||||
jsonPathText<TFields extends Record<string, any>>(
|
||||
fieldName: keyof TFields,
|
||||
path: string,
|
||||
): SQLFragment<TFields> {
|
||||
return sql`${entityField(fieldName)}->>${path}`;
|
||||
},
|
||||
|
||||
/**
|
||||
* Logical AND of multiple fragments
|
||||
*/
|
||||
and(...conditions: SQLFragment[]): SQLFragment {
|
||||
and<TFields extends Record<string, any>>(
|
||||
...conditions: readonly SQLFragment<TFields>[]
|
||||
): SQLFragment<TFields> {
|
||||
if (conditions.length === 0) {
|
||||
return sql`1 = 1`;
|
||||
}
|
||||
return SQLFragment.join(conditions, ' AND ');
|
||||
return joinSQLFragments(
|
||||
conditions.map((c) => SQLFragmentHelpers.group(c)),
|
||||
' AND ',
|
||||
);
|
||||
},
|
||||
|
||||
/**
|
||||
* Logical OR of multiple fragments
|
||||
*/
|
||||
or(...conditions: SQLFragment[]): SQLFragment {
|
||||
or<TFields extends Record<string, any>>(
|
||||
...conditions: readonly SQLFragment<TFields>[]
|
||||
): SQLFragment<TFields> {
|
||||
if (conditions.length === 0) {
|
||||
return sql`1 = 0`;
|
||||
}
|
||||
return SQLFragment.join(conditions, ' OR ');
|
||||
return joinSQLFragments(
|
||||
conditions.map((c) => SQLFragmentHelpers.group(c)),
|
||||
' OR ',
|
||||
);
|
||||
},
|
||||
|
||||
/**
|
||||
* Logical NOT of a fragment
|
||||
*/
|
||||
not(condition: SQLFragment): SQLFragment {
|
||||
not<TFields extends Record<string, any>>(condition: SQLFragment<TFields>): SQLFragment<TFields> {
|
||||
return new SQLFragment('NOT (' + condition.sql + ')', condition.bindings);
|
||||
},
|
||||
|
||||
/**
|
||||
* Parentheses helper for grouping conditions
|
||||
*/
|
||||
group(condition: SQLFragment): SQLFragment {
|
||||
group<TFields extends Record<string, any>>(
|
||||
condition: SQLFragment<TFields>,
|
||||
): SQLFragment<TFields> {
|
||||
return new SQLFragment('(' + condition.sql + ')', condition.bindings);
|
||||
},
|
||||
};
|
||||
|
||||
// Internal helper function to join SQL fragments with a specified separator
|
||||
function joinSQLFragments<TFields extends Record<string, any>>(
|
||||
fragments: readonly SQLFragment<TFields>[],
|
||||
separator: string,
|
||||
): SQLFragment<TFields> {
|
||||
return new SQLFragment(
|
||||
fragments.map((f) => f.sql).join(separator),
|
||||
fragments.flatMap((f) => f.bindings),
|
||||
);
|
||||
}
|
||||
@@ -1,89 +0,0 @@
|
||||
import {
|
||||
EntityPrivacyPolicy,
|
||||
EntityPrivacyPolicyEvaluationContext,
|
||||
EntityQueryContext,
|
||||
ReadonlyEntity,
|
||||
ViewerContext,
|
||||
} from '@expo/entity';
|
||||
|
||||
import { AuthorizationResultBasedKnexEntityLoader } from './AuthorizationResultBasedKnexEntityLoader';
|
||||
import { EnforcingKnexEntityLoader } from './EnforcingKnexEntityLoader';
|
||||
import { KnexEntityLoaderFactory } from './KnexEntityLoaderFactory';
|
||||
|
||||
/**
|
||||
* Provides a cleaner API for loading entities via knex by passing through the ViewerContext.
|
||||
*/
|
||||
export class ViewerScopedKnexEntityLoaderFactory<
|
||||
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 knexEntityLoaderFactory: KnexEntityLoaderFactory<
|
||||
TFields,
|
||||
TIDField,
|
||||
TViewerContext,
|
||||
TEntity,
|
||||
TPrivacyPolicy,
|
||||
TSelectedFields
|
||||
>,
|
||||
private readonly viewerContext: TViewerContext,
|
||||
) {}
|
||||
|
||||
forLoad(
|
||||
queryContext: EntityQueryContext,
|
||||
privacyPolicyEvaluationContext: EntityPrivacyPolicyEvaluationContext<
|
||||
TFields,
|
||||
TIDField,
|
||||
TViewerContext,
|
||||
TEntity,
|
||||
TSelectedFields
|
||||
>,
|
||||
): AuthorizationResultBasedKnexEntityLoader<
|
||||
TFields,
|
||||
TIDField,
|
||||
TViewerContext,
|
||||
TEntity,
|
||||
TPrivacyPolicy,
|
||||
TSelectedFields
|
||||
> {
|
||||
return this.knexEntityLoaderFactory.forLoad(
|
||||
this.viewerContext,
|
||||
queryContext,
|
||||
privacyPolicyEvaluationContext,
|
||||
);
|
||||
}
|
||||
|
||||
forLoadEnforcing(
|
||||
queryContext: EntityQueryContext,
|
||||
privacyPolicyEvaluationContext: EntityPrivacyPolicyEvaluationContext<
|
||||
TFields,
|
||||
TIDField,
|
||||
TViewerContext,
|
||||
TEntity,
|
||||
TSelectedFields
|
||||
>,
|
||||
): EnforcingKnexEntityLoader<
|
||||
TFields,
|
||||
TIDField,
|
||||
TViewerContext,
|
||||
TEntity,
|
||||
TPrivacyPolicy,
|
||||
TSelectedFields
|
||||
> {
|
||||
return this.knexEntityLoaderFactory.forLoadEnforcing(
|
||||
this.viewerContext,
|
||||
queryContext,
|
||||
privacyPolicyEvaluationContext,
|
||||
);
|
||||
}
|
||||
}
|
||||
+25
-31
@@ -90,7 +90,7 @@ describe(createWithUniqueConstraintRecoveryAsync, () => {
|
||||
name: 'unique',
|
||||
};
|
||||
|
||||
const createdEntities = await vc1.runInTransactionForDatabaseAdaptorFlavorAsync(
|
||||
const createdEntities = await vc1.runInTransactionForDatabaseAdapterFlavorAsync(
|
||||
'postgres',
|
||||
async (queryContext) => {
|
||||
if (parallel) {
|
||||
@@ -154,38 +154,32 @@ describe(createWithUniqueConstraintRecoveryAsync, () => {
|
||||
let createdEntities: [PostgresUniqueTestEntity, PostgresUniqueTestEntity];
|
||||
if (parallel) {
|
||||
createdEntities = await Promise.all([
|
||||
await vc1.runInTransactionForDatabaseAdaptorFlavorAsync(
|
||||
'postgres',
|
||||
async (queryContext) => {
|
||||
return await createWithUniqueConstraintRecoveryAsync(
|
||||
vc1,
|
||||
PostgresUniqueTestEntity,
|
||||
PostgresUniqueTestEntity.getByNameAsync,
|
||||
args,
|
||||
PostgresUniqueTestEntity.createWithNameAsync,
|
||||
args,
|
||||
queryContext,
|
||||
);
|
||||
},
|
||||
),
|
||||
await vc1.runInTransactionForDatabaseAdaptorFlavorAsync(
|
||||
'postgres',
|
||||
async (queryContext) => {
|
||||
return await createWithUniqueConstraintRecoveryAsync(
|
||||
vc1,
|
||||
PostgresUniqueTestEntity,
|
||||
PostgresUniqueTestEntity.getByNameAsync,
|
||||
args,
|
||||
PostgresUniqueTestEntity.createWithNameAsync,
|
||||
args,
|
||||
queryContext,
|
||||
);
|
||||
},
|
||||
),
|
||||
vc1.runInTransactionForDatabaseAdapterFlavorAsync('postgres', async (queryContext) => {
|
||||
return await createWithUniqueConstraintRecoveryAsync(
|
||||
vc1,
|
||||
PostgresUniqueTestEntity,
|
||||
PostgresUniqueTestEntity.getByNameAsync,
|
||||
args,
|
||||
PostgresUniqueTestEntity.createWithNameAsync,
|
||||
args,
|
||||
queryContext,
|
||||
);
|
||||
}),
|
||||
vc1.runInTransactionForDatabaseAdapterFlavorAsync('postgres', async (queryContext) => {
|
||||
return await createWithUniqueConstraintRecoveryAsync(
|
||||
vc1,
|
||||
PostgresUniqueTestEntity,
|
||||
PostgresUniqueTestEntity.getByNameAsync,
|
||||
args,
|
||||
PostgresUniqueTestEntity.createWithNameAsync,
|
||||
args,
|
||||
queryContext,
|
||||
);
|
||||
}),
|
||||
]);
|
||||
} else {
|
||||
createdEntities = [
|
||||
await vc1.runInTransactionForDatabaseAdaptorFlavorAsync(
|
||||
await vc1.runInTransactionForDatabaseAdapterFlavorAsync(
|
||||
'postgres',
|
||||
async (queryContext) => {
|
||||
return await createWithUniqueConstraintRecoveryAsync(
|
||||
@@ -199,7 +193,7 @@ describe(createWithUniqueConstraintRecoveryAsync, () => {
|
||||
);
|
||||
},
|
||||
),
|
||||
await vc1.runInTransactionForDatabaseAdaptorFlavorAsync(
|
||||
await vc1.runInTransactionForDatabaseAdapterFlavorAsync(
|
||||
'postgres',
|
||||
async (queryContext) => {
|
||||
return await createWithUniqueConstraintRecoveryAsync(
|
||||
|
||||
+2293
-363
File diff suppressed because it is too large.
Load diff
+7
-7
@@ -41,7 +41,7 @@ describe(PostgresEntityQueryContextProvider, () => {
|
||||
await PostgresUniqueTestEntity.creator(vc1).setField('name', 'wat').createAsync()
|
||||
).getID();
|
||||
|
||||
await vc1.runInTransactionForDatabaseAdaptorFlavorAsync('postgres', async (queryContext) => {
|
||||
await vc1.runInTransactionForDatabaseAdapterFlavorAsync('postgres', async (queryContext) => {
|
||||
const entity = await PostgresUniqueTestEntity.loader(vc1, queryContext).loadByIDAsync(id);
|
||||
await PostgresUniqueTestEntity.updater(entity, queryContext)
|
||||
.setField('name', 'wat2')
|
||||
@@ -81,7 +81,7 @@ describe(PostgresEntityQueryContextProvider, () => {
|
||||
const entityLoaded = await PostgresUniqueTestEntity.loader(vc1).loadByIDAsync(entity.getID());
|
||||
expect(entityLoaded.getField('name')).toEqual('who');
|
||||
|
||||
await vc1.runInTransactionForDatabaseAdaptorFlavorAsync('postgres', async (queryContext) => {
|
||||
await vc1.runInTransactionForDatabaseAdapterFlavorAsync('postgres', async (queryContext) => {
|
||||
const entityLoadedOuter = await PostgresUniqueTestEntity.loader(vc1).loadByIDAsync(
|
||||
entity.getID(),
|
||||
);
|
||||
@@ -124,7 +124,7 @@ describe(PostgresEntityQueryContextProvider, () => {
|
||||
const entityLoaded = await PostgresUniqueTestEntity.loader(vc1).loadByIDAsync(entity.getID());
|
||||
expect(entityLoaded.getField('name')).toEqual('who');
|
||||
|
||||
await vc1.runInTransactionForDatabaseAdaptorFlavorAsync('postgres', async (queryContext) => {
|
||||
await vc1.runInTransactionForDatabaseAdapterFlavorAsync('postgres', async (queryContext) => {
|
||||
const entityLoadedOuter = await PostgresUniqueTestEntity.loader(vc1).loadByIDAsync(
|
||||
entity.getID(),
|
||||
);
|
||||
@@ -181,7 +181,7 @@ describe(PostgresEntityQueryContextProvider, () => {
|
||||
});
|
||||
|
||||
await Promise.all([
|
||||
vc1.runInTransactionForDatabaseAdaptorFlavorAsync('postgres', async (queryContext) => {
|
||||
vc1.runInTransactionForDatabaseAdapterFlavorAsync('postgres', async (queryContext) => {
|
||||
const entityLoadedOuter = await PostgresUniqueTestEntity.loader(vc1).loadByIDAsync(
|
||||
entity.getID(),
|
||||
);
|
||||
@@ -235,7 +235,7 @@ describe(PostgresEntityQueryContextProvider, () => {
|
||||
transactionalDataLoaderMode: TransactionalDataLoaderMode,
|
||||
): Promise<void> => {
|
||||
const vc1 = new ViewerContext(createKnexIntegrationTestEntityCompanionProvider(knexInstance));
|
||||
await vc1.runInTransactionForDatabaseAdaptorFlavorAsync(
|
||||
await vc1.runInTransactionForDatabaseAdapterFlavorAsync(
|
||||
'postgres',
|
||||
async (outerQueryContext) => {
|
||||
// put it in local dataloader
|
||||
@@ -334,7 +334,7 @@ describe(PostgresEntityQueryContextProvider, () => {
|
||||
transactionalDataLoaderMode: TransactionalDataLoaderMode,
|
||||
): Promise<void> => {
|
||||
const vc1 = new ViewerContext(createKnexIntegrationTestEntityCompanionProvider(knexInstance));
|
||||
await vc1.runInTransactionForDatabaseAdaptorFlavorAsync(
|
||||
await vc1.runInTransactionForDatabaseAdapterFlavorAsync(
|
||||
'postgres',
|
||||
async (outerQueryContext) => {
|
||||
// put it in local dataloader
|
||||
@@ -432,7 +432,7 @@ describe(PostgresEntityQueryContextProvider, () => {
|
||||
await PostgresUniqueTestEntity.creator(vc1).setField('name', 'wat').createAsync()
|
||||
).getID();
|
||||
|
||||
await vc1.runInTransactionForDatabaseAdaptorFlavorAsync('postgres', async (queryContext) => {
|
||||
await vc1.runInTransactionForDatabaseAdapterFlavorAsync('postgres', async (queryContext) => {
|
||||
await queryContext.runInNestedTransactionAsync(async (innerQueryContext) => {
|
||||
await innerQueryContext.runInNestedTransactionAsync(async (innerQueryContex2) => {
|
||||
await innerQueryContex2.runInNestedTransactionAsync(async (innerQueryContex3) => {
|
||||
|
||||
@@ -2,7 +2,6 @@ import {
|
||||
AlwaysAllowPrivacyPolicyRule,
|
||||
BooleanField,
|
||||
DateField,
|
||||
Entity,
|
||||
EntityCompanionDefinition,
|
||||
EntityConfiguration,
|
||||
EntityPrivacyPolicy,
|
||||
@@ -16,10 +15,12 @@ import {
|
||||
import { Knex } from 'knex';
|
||||
|
||||
import { BigIntField, JSONArrayField, MaybeJSONArrayField } from '../EntityFields';
|
||||
import { PostgresEntity } from '../PostgresEntity';
|
||||
|
||||
type PostgresTestEntityFields = {
|
||||
export type PostgresTestEntityFields = {
|
||||
id: string;
|
||||
name: string | null;
|
||||
label: string;
|
||||
hasADog: boolean | null;
|
||||
hasACat: boolean | null;
|
||||
stringArray: string[] | null;
|
||||
@@ -31,9 +32,14 @@ type PostgresTestEntityFields = {
|
||||
maybeJsonArrayField: string[] | { hello: string } | null;
|
||||
bigintField: string | null;
|
||||
binaryField: Buffer | null;
|
||||
createdAt: Date;
|
||||
};
|
||||
|
||||
export class PostgresTestEntity extends Entity<PostgresTestEntityFields, 'id', ViewerContext> {
|
||||
export class PostgresTestEntity extends PostgresEntity<
|
||||
PostgresTestEntityFields,
|
||||
'id',
|
||||
ViewerContext
|
||||
> {
|
||||
static defineCompanionDefinition(): EntityCompanionDefinition<
|
||||
PostgresTestEntityFields,
|
||||
'id',
|
||||
@@ -55,6 +61,7 @@ export class PostgresTestEntity extends Entity<PostgresTestEntityFields, 'id', V
|
||||
await knex.schema.createTable(tableName, (table) => {
|
||||
table.uuid('id').defaultTo(knex.raw('gen_random_uuid()')).primary();
|
||||
table.string('name');
|
||||
table.string('label').notNullable().defaultTo('');
|
||||
table.boolean('has_a_dog');
|
||||
table.boolean('has_a_cat');
|
||||
table.specificType('string_array', 'text[]');
|
||||
@@ -64,6 +71,7 @@ export class PostgresTestEntity extends Entity<PostgresTestEntityFields, 'id', V
|
||||
table.jsonb('maybe_json_array_field');
|
||||
table.bigint('bigint_field');
|
||||
table.binary('binary_field');
|
||||
table.dateTime('created_at', { useTz: true }).defaultTo(knex.fn.now());
|
||||
});
|
||||
}
|
||||
await knex.into(tableName).truncate();
|
||||
@@ -132,6 +140,9 @@ export const postgresTestEntityConfiguration = new EntityConfiguration<
|
||||
name: new StringField({
|
||||
columnName: 'name',
|
||||
}),
|
||||
label: new StringField({
|
||||
columnName: 'label',
|
||||
}),
|
||||
hasADog: new BooleanField({
|
||||
columnName: 'has_a_dog',
|
||||
}),
|
||||
@@ -159,6 +170,9 @@ export const postgresTestEntityConfiguration = new EntityConfiguration<
|
||||
binaryField: new BufferField({
|
||||
columnName: 'binary_field',
|
||||
}),
|
||||
createdAt: new DateField({
|
||||
columnName: 'created_at',
|
||||
}),
|
||||
},
|
||||
databaseAdapterFlavor: 'postgres',
|
||||
cacheAdapterFlavor: 'redis',
|
||||
|
||||
+457
-10
@@ -12,6 +12,7 @@ import { v4 as uuidv4 } from 'uuid';
|
||||
|
||||
import { AuthorizationResultBasedKnexEntityLoader } from '../AuthorizationResultBasedKnexEntityLoader';
|
||||
import { OrderByOrdering } from '../BasePostgresEntityDatabaseAdapter';
|
||||
import { PaginationStrategy } from '../PaginationStrategy';
|
||||
import { sql } from '../SQLOperator';
|
||||
import {
|
||||
TestEntity,
|
||||
@@ -436,7 +437,7 @@ describe(AuthorizationResultBasedKnexEntityLoader, () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('loads entities with loadPageBySQLAsync', () => {
|
||||
describe('loads entities with loadPageAsync', () => {
|
||||
it('returns paginated entities with forward pagination', async () => {
|
||||
const privacyPolicy = new TestEntityPrivacyPolicy();
|
||||
const spiedPrivacyPolicy = spy(privacyPolicy);
|
||||
@@ -460,7 +461,7 @@ describe(AuthorizationResultBasedKnexEntityLoader, () => {
|
||||
|
||||
const id1 = uuidv4();
|
||||
const id2 = uuidv4();
|
||||
when(knexDataManagerMock.loadPageBySQLFragmentAsync(queryContext, anything())).thenResolve({
|
||||
when(knexDataManagerMock.loadPageAsync(queryContext, anything())).thenResolve({
|
||||
edges: [
|
||||
{
|
||||
cursor: 'cursor1',
|
||||
@@ -511,10 +512,13 @@ describe(AuthorizationResultBasedKnexEntityLoader, () => {
|
||||
constructionUtils,
|
||||
);
|
||||
|
||||
const connection = await knexEntityLoader.loadPageBySQLAsync({
|
||||
const connection = await knexEntityLoader.loadPageAsync({
|
||||
first: 10,
|
||||
where: sql`intField > ${0}`,
|
||||
orderBy: [{ fieldName: 'intField', order: OrderByOrdering.ASCENDING }],
|
||||
pagination: {
|
||||
strategy: PaginationStrategy.STANDARD,
|
||||
orderBy: [{ fieldName: 'intField', order: OrderByOrdering.ASCENDING }],
|
||||
},
|
||||
});
|
||||
|
||||
expect(connection.edges).toHaveLength(2);
|
||||
@@ -564,7 +568,7 @@ describe(AuthorizationResultBasedKnexEntityLoader, () => {
|
||||
const id1 = uuidv4();
|
||||
const id2 = uuidv4();
|
||||
const id3 = uuidv4();
|
||||
when(knexDataManagerMock.loadPageBySQLFragmentAsync(queryContext, anything())).thenResolve({
|
||||
when(knexDataManagerMock.loadPageAsync(queryContext, anything())).thenResolve({
|
||||
edges: [
|
||||
{
|
||||
cursor: 'cursor1',
|
||||
@@ -623,10 +627,13 @@ describe(AuthorizationResultBasedKnexEntityLoader, () => {
|
||||
constructionUtils,
|
||||
);
|
||||
|
||||
const connection = await knexEntityLoader.loadPageBySQLAsync({
|
||||
const connection = await knexEntityLoader.loadPageAsync({
|
||||
first: 10,
|
||||
where: sql`score > ${0}`,
|
||||
orderBy: [{ fieldName: 'createdAt', order: OrderByOrdering.ASCENDING }],
|
||||
pagination: {
|
||||
strategy: PaginationStrategy.STANDARD,
|
||||
orderBy: [{ fieldName: 'createdAt', order: OrderByOrdering.ASCENDING }],
|
||||
},
|
||||
});
|
||||
|
||||
// Should only have 2 entities (unauthorized one filtered out)
|
||||
@@ -666,7 +673,7 @@ describe(AuthorizationResultBasedKnexEntityLoader, () => {
|
||||
const knexDataManagerMock =
|
||||
mock<EntityKnexDataManager<TestFields, 'customIdField'>>(EntityKnexDataManager);
|
||||
|
||||
when(knexDataManagerMock.loadPageBySQLFragmentAsync(queryContext, anything())).thenResolve({
|
||||
when(knexDataManagerMock.loadPageAsync(queryContext, anything())).thenResolve({
|
||||
edges: [
|
||||
{
|
||||
cursor: 'cursor5',
|
||||
@@ -706,10 +713,13 @@ describe(AuthorizationResultBasedKnexEntityLoader, () => {
|
||||
constructionUtils,
|
||||
);
|
||||
|
||||
const connection = await knexEntityLoader.loadPageBySQLAsync({
|
||||
const connection = await knexEntityLoader.loadPageAsync({
|
||||
last: 5,
|
||||
before: 'someCursor',
|
||||
orderBy: [{ fieldName: 'intField', order: OrderByOrdering.ASCENDING }],
|
||||
pagination: {
|
||||
strategy: PaginationStrategy.STANDARD,
|
||||
orderBy: [{ fieldName: 'intField', order: OrderByOrdering.ASCENDING }],
|
||||
},
|
||||
});
|
||||
|
||||
expect(connection.edges).toHaveLength(1);
|
||||
@@ -717,4 +727,441 @@ describe(AuthorizationResultBasedKnexEntityLoader, () => {
|
||||
expect(connection.pageInfo.hasNextPage).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('loads entities with loadPageAsync (search)', () => {
|
||||
it('performs ILIKE search and filters unauthorized entities', async () => {
|
||||
const privacyPolicy = new TestPaginationPrivacyPolicy();
|
||||
const viewerContext = instance(mock(ViewerContext));
|
||||
const privacyPolicyEvaluationContext =
|
||||
instance(
|
||||
mock<
|
||||
EntityPrivacyPolicyEvaluationContext<
|
||||
TestPaginationFields,
|
||||
'id',
|
||||
ViewerContext,
|
||||
TestPaginationEntity
|
||||
>
|
||||
>(),
|
||||
);
|
||||
const metricsAdapter = instance(mock<IEntityMetricsAdapter>());
|
||||
const queryContext = instance(mock<EntityQueryContext>());
|
||||
|
||||
const knexDataManagerMock =
|
||||
mock<EntityKnexDataManager<TestPaginationFields, 'id'>>(EntityKnexDataManager);
|
||||
|
||||
const id1 = uuidv4();
|
||||
const id2 = uuidv4();
|
||||
const id3 = uuidv4();
|
||||
|
||||
// Mock data manager to return 3 entities from search
|
||||
when(knexDataManagerMock.loadPageAsync(queryContext, anything())).thenResolve({
|
||||
edges: [
|
||||
{
|
||||
cursor: 'cursor1',
|
||||
node: {
|
||||
id: id1,
|
||||
name: 'Alice Johnson',
|
||||
status: 'active',
|
||||
createdAt: new Date(),
|
||||
score: 1,
|
||||
},
|
||||
},
|
||||
{
|
||||
cursor: 'cursor2',
|
||||
node: {
|
||||
id: id2,
|
||||
name: 'Bob Johnson',
|
||||
status: 'unauthorized', // This will fail authorization per TestPaginationPrivacyPolicy
|
||||
createdAt: new Date(),
|
||||
score: 2,
|
||||
},
|
||||
},
|
||||
{
|
||||
cursor: 'cursor3',
|
||||
node: {
|
||||
id: id3,
|
||||
name: 'Charlie Johnson',
|
||||
status: 'active',
|
||||
createdAt: new Date(),
|
||||
score: 3,
|
||||
},
|
||||
},
|
||||
],
|
||||
pageInfo: {
|
||||
hasNextPage: false,
|
||||
hasPreviousPage: false,
|
||||
startCursor: 'cursor1',
|
||||
endCursor: 'cursor3',
|
||||
},
|
||||
});
|
||||
|
||||
const constructionUtils = new EntityConstructionUtils(
|
||||
viewerContext,
|
||||
queryContext,
|
||||
privacyPolicyEvaluationContext,
|
||||
testPaginationEntityConfiguration,
|
||||
TestPaginationEntity,
|
||||
/* entitySelectedFields */ undefined,
|
||||
privacyPolicy,
|
||||
metricsAdapter,
|
||||
);
|
||||
|
||||
const knexEntityLoader = new AuthorizationResultBasedKnexEntityLoader(
|
||||
queryContext,
|
||||
instance(knexDataManagerMock),
|
||||
metricsAdapter,
|
||||
constructionUtils,
|
||||
);
|
||||
|
||||
const connection = await knexEntityLoader.loadPageAsync({
|
||||
first: 10,
|
||||
pagination: {
|
||||
strategy: PaginationStrategy.ILIKE_SEARCH,
|
||||
term: 'Johnson',
|
||||
fields: ['name'],
|
||||
},
|
||||
});
|
||||
|
||||
// Should only have 2 edges (Bob was filtered out)
|
||||
expect(connection.edges).toHaveLength(2);
|
||||
expect(connection.edges[0]?.node.getField('id')).toBe(id1);
|
||||
expect(connection.edges[0]?.node.getField('name')).toBe('Alice Johnson');
|
||||
expect(connection.edges[1]?.node.getField('id')).toBe(id3);
|
||||
expect(connection.edges[1]?.node.getField('name')).toBe('Charlie Johnson');
|
||||
|
||||
// Cursors should be updated to reflect only authorized entities
|
||||
expect(connection.pageInfo.startCursor).toBe('cursor1');
|
||||
expect(connection.pageInfo.endCursor).toBe('cursor3');
|
||||
});
|
||||
|
||||
it('performs TRIGRAM search with cursor pagination', async () => {
|
||||
const privacyPolicy = new TestPaginationPrivacyPolicy();
|
||||
const viewerContext = instance(mock(ViewerContext));
|
||||
const privacyPolicyEvaluationContext =
|
||||
instance(
|
||||
mock<
|
||||
EntityPrivacyPolicyEvaluationContext<
|
||||
TestPaginationFields,
|
||||
'id',
|
||||
ViewerContext,
|
||||
TestPaginationEntity
|
||||
>
|
||||
>(),
|
||||
);
|
||||
const metricsAdapter = instance(mock<IEntityMetricsAdapter>());
|
||||
const queryContext = instance(mock<EntityQueryContext>());
|
||||
|
||||
const knexDataManagerMock =
|
||||
mock<EntityKnexDataManager<TestPaginationFields, 'id'>>(EntityKnexDataManager);
|
||||
|
||||
const id1 = uuidv4();
|
||||
const id2 = uuidv4();
|
||||
|
||||
// Mock first page of TRIGRAM search results
|
||||
when(knexDataManagerMock.loadPageAsync(queryContext, anything())).thenResolve({
|
||||
edges: [
|
||||
{
|
||||
cursor: 'cursor1',
|
||||
node: {
|
||||
id: id1,
|
||||
name: 'Johnson', // Exact match
|
||||
status: 'active',
|
||||
createdAt: new Date(),
|
||||
score: 1,
|
||||
},
|
||||
},
|
||||
{
|
||||
cursor: 'cursor2',
|
||||
node: {
|
||||
id: id2,
|
||||
name: 'Jonson', // Similar match
|
||||
status: 'active',
|
||||
createdAt: new Date(),
|
||||
score: 2,
|
||||
},
|
||||
},
|
||||
],
|
||||
pageInfo: {
|
||||
hasNextPage: true,
|
||||
hasPreviousPage: false,
|
||||
startCursor: 'cursor1',
|
||||
endCursor: 'cursor2',
|
||||
},
|
||||
});
|
||||
|
||||
const constructionUtils = new EntityConstructionUtils(
|
||||
viewerContext,
|
||||
queryContext,
|
||||
privacyPolicyEvaluationContext,
|
||||
testPaginationEntityConfiguration,
|
||||
TestPaginationEntity,
|
||||
/* entitySelectedFields */ undefined,
|
||||
privacyPolicy,
|
||||
metricsAdapter,
|
||||
);
|
||||
|
||||
const knexEntityLoader = new AuthorizationResultBasedKnexEntityLoader(
|
||||
queryContext,
|
||||
instance(knexDataManagerMock),
|
||||
metricsAdapter,
|
||||
constructionUtils,
|
||||
);
|
||||
|
||||
const connection = await knexEntityLoader.loadPageAsync({
|
||||
first: 2,
|
||||
after: 'someCursor',
|
||||
pagination: {
|
||||
strategy: PaginationStrategy.TRIGRAM_SEARCH,
|
||||
term: 'Johnson',
|
||||
fields: ['name'],
|
||||
threshold: 0.3,
|
||||
extraOrderByFields: ['score'],
|
||||
},
|
||||
});
|
||||
|
||||
expect(connection.edges).toHaveLength(2);
|
||||
expect(connection.edges[0]?.node.getField('name')).toBe('Johnson');
|
||||
expect(connection.edges[1]?.node.getField('name')).toBe('Jonson');
|
||||
expect(connection.pageInfo.hasNextPage).toBe(true);
|
||||
});
|
||||
|
||||
it('handles backward pagination with search', async () => {
|
||||
const privacyPolicy = new TestPaginationPrivacyPolicy();
|
||||
const viewerContext = instance(mock(ViewerContext));
|
||||
const privacyPolicyEvaluationContext =
|
||||
instance(
|
||||
mock<
|
||||
EntityPrivacyPolicyEvaluationContext<
|
||||
TestPaginationFields,
|
||||
'id',
|
||||
ViewerContext,
|
||||
TestPaginationEntity
|
||||
>
|
||||
>(),
|
||||
);
|
||||
const metricsAdapter = instance(mock<IEntityMetricsAdapter>());
|
||||
const queryContext = instance(mock<EntityQueryContext>());
|
||||
|
||||
const knexDataManagerMock =
|
||||
mock<EntityKnexDataManager<TestPaginationFields, 'id'>>(EntityKnexDataManager);
|
||||
|
||||
const id1 = uuidv4();
|
||||
const id2 = uuidv4();
|
||||
|
||||
// Mock backward pagination results
|
||||
when(knexDataManagerMock.loadPageAsync(queryContext, anything())).thenResolve({
|
||||
edges: [
|
||||
{
|
||||
cursor: 'cursor1',
|
||||
node: {
|
||||
id: id1,
|
||||
name: 'Charlie Smith',
|
||||
status: 'active',
|
||||
createdAt: new Date(),
|
||||
score: 3,
|
||||
},
|
||||
},
|
||||
{
|
||||
cursor: 'cursor2',
|
||||
node: {
|
||||
id: id2,
|
||||
name: 'David Smith',
|
||||
status: 'active',
|
||||
createdAt: new Date(),
|
||||
score: 4,
|
||||
},
|
||||
},
|
||||
],
|
||||
pageInfo: {
|
||||
hasNextPage: false,
|
||||
hasPreviousPage: true,
|
||||
startCursor: 'cursor1',
|
||||
endCursor: 'cursor2',
|
||||
},
|
||||
});
|
||||
|
||||
const constructionUtils = new EntityConstructionUtils(
|
||||
viewerContext,
|
||||
queryContext,
|
||||
privacyPolicyEvaluationContext,
|
||||
testPaginationEntityConfiguration,
|
||||
TestPaginationEntity,
|
||||
/* entitySelectedFields */ undefined,
|
||||
privacyPolicy,
|
||||
metricsAdapter,
|
||||
);
|
||||
|
||||
const knexEntityLoader = new AuthorizationResultBasedKnexEntityLoader(
|
||||
queryContext,
|
||||
instance(knexDataManagerMock),
|
||||
metricsAdapter,
|
||||
constructionUtils,
|
||||
);
|
||||
|
||||
const connection = await knexEntityLoader.loadPageAsync({
|
||||
last: 2,
|
||||
before: 'someCursor',
|
||||
pagination: {
|
||||
strategy: PaginationStrategy.ILIKE_SEARCH,
|
||||
term: 'Smith',
|
||||
fields: ['name'],
|
||||
},
|
||||
});
|
||||
|
||||
expect(connection.edges).toHaveLength(2);
|
||||
expect(connection.pageInfo.hasPreviousPage).toBe(true);
|
||||
expect(connection.pageInfo.hasNextPage).toBe(false);
|
||||
});
|
||||
|
||||
it('handles empty search results', async () => {
|
||||
const privacyPolicy = new TestPaginationPrivacyPolicy();
|
||||
const viewerContext = instance(mock(ViewerContext));
|
||||
const privacyPolicyEvaluationContext =
|
||||
instance(
|
||||
mock<
|
||||
EntityPrivacyPolicyEvaluationContext<
|
||||
TestPaginationFields,
|
||||
'id',
|
||||
ViewerContext,
|
||||
TestPaginationEntity
|
||||
>
|
||||
>(),
|
||||
);
|
||||
const metricsAdapter = instance(mock<IEntityMetricsAdapter>());
|
||||
const queryContext = instance(mock<EntityQueryContext>());
|
||||
|
||||
const knexDataManagerMock =
|
||||
mock<EntityKnexDataManager<TestPaginationFields, 'id'>>(EntityKnexDataManager);
|
||||
|
||||
// Mock empty search results
|
||||
when(knexDataManagerMock.loadPageAsync(queryContext, anything())).thenResolve({
|
||||
edges: [],
|
||||
pageInfo: {
|
||||
hasNextPage: false,
|
||||
hasPreviousPage: false,
|
||||
startCursor: null,
|
||||
endCursor: null,
|
||||
},
|
||||
});
|
||||
|
||||
const constructionUtils = new EntityConstructionUtils(
|
||||
viewerContext,
|
||||
queryContext,
|
||||
privacyPolicyEvaluationContext,
|
||||
testPaginationEntityConfiguration,
|
||||
TestPaginationEntity,
|
||||
/* entitySelectedFields */ undefined,
|
||||
privacyPolicy,
|
||||
metricsAdapter,
|
||||
);
|
||||
|
||||
const knexEntityLoader = new AuthorizationResultBasedKnexEntityLoader(
|
||||
queryContext,
|
||||
instance(knexDataManagerMock),
|
||||
metricsAdapter,
|
||||
constructionUtils,
|
||||
);
|
||||
|
||||
const connection = await knexEntityLoader.loadPageAsync({
|
||||
first: 10,
|
||||
pagination: {
|
||||
strategy: PaginationStrategy.ILIKE_SEARCH,
|
||||
term: 'NonexistentTerm',
|
||||
fields: ['name'],
|
||||
},
|
||||
});
|
||||
|
||||
expect(connection.edges).toHaveLength(0);
|
||||
expect(connection.pageInfo.startCursor).toBeNull();
|
||||
expect(connection.pageInfo.endCursor).toBeNull();
|
||||
});
|
||||
|
||||
it('handles all entities failing authorization', async () => {
|
||||
const privacyPolicy = new TestPaginationPrivacyPolicy();
|
||||
const viewerContext = instance(mock(ViewerContext));
|
||||
const privacyPolicyEvaluationContext =
|
||||
instance(
|
||||
mock<
|
||||
EntityPrivacyPolicyEvaluationContext<
|
||||
TestPaginationFields,
|
||||
'id',
|
||||
ViewerContext,
|
||||
TestPaginationEntity
|
||||
>
|
||||
>(),
|
||||
);
|
||||
const metricsAdapter = instance(mock<IEntityMetricsAdapter>());
|
||||
const queryContext = instance(mock<EntityQueryContext>());
|
||||
|
||||
const knexDataManagerMock =
|
||||
mock<EntityKnexDataManager<TestPaginationFields, 'id'>>(EntityKnexDataManager);
|
||||
|
||||
const id1 = uuidv4();
|
||||
const id2 = uuidv4();
|
||||
|
||||
// Mock search results
|
||||
when(knexDataManagerMock.loadPageAsync(queryContext, anything())).thenResolve({
|
||||
edges: [
|
||||
{
|
||||
cursor: 'cursor1',
|
||||
node: {
|
||||
id: id1,
|
||||
name: 'Alice',
|
||||
status: 'unauthorized', // This will fail authorization
|
||||
createdAt: new Date(),
|
||||
score: 1,
|
||||
},
|
||||
},
|
||||
{
|
||||
cursor: 'cursor2',
|
||||
node: {
|
||||
id: id2,
|
||||
name: 'Bob',
|
||||
status: 'unauthorized', // This will fail authorization
|
||||
createdAt: new Date(),
|
||||
score: 2,
|
||||
},
|
||||
},
|
||||
],
|
||||
pageInfo: {
|
||||
hasNextPage: false,
|
||||
hasPreviousPage: false,
|
||||
startCursor: 'cursor1',
|
||||
endCursor: 'cursor2',
|
||||
},
|
||||
});
|
||||
|
||||
const constructionUtils = new EntityConstructionUtils(
|
||||
viewerContext,
|
||||
queryContext,
|
||||
privacyPolicyEvaluationContext,
|
||||
testPaginationEntityConfiguration,
|
||||
TestPaginationEntity,
|
||||
/* entitySelectedFields */ undefined,
|
||||
privacyPolicy,
|
||||
metricsAdapter,
|
||||
);
|
||||
|
||||
const knexEntityLoader = new AuthorizationResultBasedKnexEntityLoader(
|
||||
queryContext,
|
||||
instance(knexDataManagerMock),
|
||||
metricsAdapter,
|
||||
constructionUtils,
|
||||
);
|
||||
|
||||
const connection = await knexEntityLoader.loadPageAsync({
|
||||
first: 10,
|
||||
pagination: {
|
||||
strategy: PaginationStrategy.ILIKE_SEARCH,
|
||||
term: 'test',
|
||||
fields: ['name'],
|
||||
},
|
||||
});
|
||||
|
||||
// All entities filtered out due to failed authorization
|
||||
expect(connection.edges).toHaveLength(0);
|
||||
expect(connection.pageInfo.startCursor).toBeNull();
|
||||
expect(connection.pageInfo.endCursor).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
+7
@@ -129,6 +129,13 @@ class TestEntityDatabaseAdapter extends BasePostgresEntityDatabaseAdapter<
|
||||
}
|
||||
|
||||
describe(BasePostgresEntityDatabaseAdapter, () => {
|
||||
describe('get paginationMaxPageSize', () => {
|
||||
it('returns the default paginationMaxPageSize (undefined)', () => {
|
||||
const adapter = new TestEntityDatabaseAdapter({});
|
||||
expect(adapter.paginationMaxPageSize).toBe(undefined);
|
||||
});
|
||||
});
|
||||
|
||||
describe('fetchManyByFieldEqualityConjunction', () => {
|
||||
it('transforms object', async () => {
|
||||
const queryContext = instance(mock(EntityQueryContext));
|
||||
|
||||
+14
-7
@@ -8,6 +8,7 @@ import {
|
||||
AuthorizationResultBasedSQLQueryBuilder,
|
||||
} from '../AuthorizationResultBasedKnexEntityLoader';
|
||||
import { EnforcingKnexEntityLoader } from '../EnforcingKnexEntityLoader';
|
||||
import { PaginationStrategy } from '../PaginationStrategy';
|
||||
import { sql } from '../SQLOperator';
|
||||
import { EntityKnexDataManager } from '../internal/EntityKnexDataManager';
|
||||
|
||||
@@ -248,7 +249,7 @@ describe(EnforcingKnexEntityLoader, () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('loadPageBySQLAsync', () => {
|
||||
describe('loadPageAsync', () => {
|
||||
it('throws when result is unsuccessful', async () => {
|
||||
const queryContext = instance(mock(EntityQueryContext));
|
||||
const knexDataManagerMock = mock<EntityKnexDataManager<any, any>>(EntityKnexDataManager);
|
||||
@@ -257,7 +258,7 @@ describe(EnforcingKnexEntityLoader, () => {
|
||||
const rejection = new Error('Entity not authorized');
|
||||
|
||||
// Mock the data manager to return a connection with field objects
|
||||
when(knexDataManagerMock.loadPageBySQLFragmentAsync(anything(), anything())).thenResolve({
|
||||
when(knexDataManagerMock.loadPageAsync(anything(), anything())).thenResolve({
|
||||
edges: [
|
||||
{
|
||||
cursor: 'cursor1',
|
||||
@@ -286,9 +287,12 @@ describe(EnforcingKnexEntityLoader, () => {
|
||||
);
|
||||
|
||||
await expect(
|
||||
enforcingKnexEntityLoader.loadPageBySQLAsync({
|
||||
enforcingKnexEntityLoader.loadPageAsync({
|
||||
first: 10,
|
||||
orderBy: [],
|
||||
pagination: {
|
||||
strategy: PaginationStrategy.STANDARD,
|
||||
orderBy: [],
|
||||
},
|
||||
}),
|
||||
).rejects.toThrow(rejection);
|
||||
});
|
||||
@@ -301,7 +305,7 @@ describe(EnforcingKnexEntityLoader, () => {
|
||||
const entity1 = { id: '1', name: 'Entity 1', getID: () => '1' };
|
||||
const entity2 = { id: '2', name: 'Entity 2', getID: () => '2' };
|
||||
|
||||
when(knexDataManagerMock.loadPageBySQLFragmentAsync(anything(), anything())).thenResolve({
|
||||
when(knexDataManagerMock.loadPageAsync(anything(), anything())).thenResolve({
|
||||
edges: [
|
||||
{
|
||||
cursor: 'cursor1',
|
||||
@@ -339,9 +343,12 @@ describe(EnforcingKnexEntityLoader, () => {
|
||||
instance(constructionUtilsMock),
|
||||
);
|
||||
|
||||
const connection = await enforcingKnexEntityLoader.loadPageBySQLAsync({
|
||||
const connection = await enforcingKnexEntityLoader.loadPageAsync({
|
||||
first: 10,
|
||||
orderBy: [],
|
||||
pagination: {
|
||||
strategy: PaginationStrategy.STANDARD,
|
||||
orderBy: [],
|
||||
},
|
||||
});
|
||||
|
||||
expect(connection.edges).toHaveLength(2);
|
||||
|
||||
@@ -4,7 +4,7 @@ import { BigIntField, JSONArrayField, MaybeJSONArrayField } from '../EntityField
|
||||
|
||||
describeFieldTestCase(
|
||||
new JSONArrayField({ columnName: 'wat' }),
|
||||
[[[1, 2]] as any, [['hello']] as any],
|
||||
[[[1, 2]], [['hello']]],
|
||||
[1, 'hello'],
|
||||
);
|
||||
describeFieldTestCase(
|
||||
|
||||
@@ -0,0 +1,172 @@
|
||||
import {
|
||||
AlwaysAllowPrivacyPolicyRule,
|
||||
EntityCompanionDefinition,
|
||||
EntityConfiguration,
|
||||
EntityPrivacyPolicy,
|
||||
StringField,
|
||||
UUIDField,
|
||||
ViewerContext,
|
||||
} from '@expo/entity';
|
||||
import { describe, expect, it } from '@jest/globals';
|
||||
|
||||
import { AuthorizationResultBasedKnexEntityLoader } from '../AuthorizationResultBasedKnexEntityLoader';
|
||||
import { EnforcingKnexEntityLoader } from '../EnforcingKnexEntityLoader';
|
||||
import { PostgresEntity } from '../PostgresEntity';
|
||||
import { ReadonlyPostgresEntity } from '../ReadonlyPostgresEntity';
|
||||
import { createUnitTestPostgresEntityCompanionProvider } from './fixtures/createUnitTestPostgresEntityCompanionProvider';
|
||||
|
||||
type TestPostgresFields = {
|
||||
id: string;
|
||||
name: string;
|
||||
};
|
||||
|
||||
const testPostgresEntityConfiguration = new EntityConfiguration<TestPostgresFields, 'id'>({
|
||||
idField: 'id',
|
||||
tableName: 'postgres_entity_test',
|
||||
schema: {
|
||||
id: new UUIDField({ columnName: 'id', cache: true }),
|
||||
name: new StringField({ columnName: 'name' }),
|
||||
},
|
||||
databaseAdapterFlavor: 'postgres',
|
||||
cacheAdapterFlavor: 'redis',
|
||||
});
|
||||
|
||||
class TestPostgresEntityPrivacyPolicy extends EntityPrivacyPolicy<
|
||||
TestPostgresFields,
|
||||
'id',
|
||||
ViewerContext,
|
||||
TestPostgresEntity
|
||||
> {
|
||||
protected override readonly readRules = [
|
||||
new AlwaysAllowPrivacyPolicyRule<TestPostgresFields, 'id', ViewerContext, TestPostgresEntity>(),
|
||||
];
|
||||
protected override readonly createRules = [
|
||||
new AlwaysAllowPrivacyPolicyRule<TestPostgresFields, 'id', ViewerContext, TestPostgresEntity>(),
|
||||
];
|
||||
protected override readonly updateRules = [
|
||||
new AlwaysAllowPrivacyPolicyRule<TestPostgresFields, 'id', ViewerContext, TestPostgresEntity>(),
|
||||
];
|
||||
protected override readonly deleteRules = [
|
||||
new AlwaysAllowPrivacyPolicyRule<TestPostgresFields, 'id', ViewerContext, TestPostgresEntity>(),
|
||||
];
|
||||
}
|
||||
|
||||
class TestPostgresEntity extends PostgresEntity<TestPostgresFields, 'id', ViewerContext> {
|
||||
static defineCompanionDefinition(): EntityCompanionDefinition<
|
||||
TestPostgresFields,
|
||||
'id',
|
||||
ViewerContext,
|
||||
TestPostgresEntity,
|
||||
TestPostgresEntityPrivacyPolicy
|
||||
> {
|
||||
return {
|
||||
entityClass: TestPostgresEntity,
|
||||
entityConfiguration: testPostgresEntityConfiguration,
|
||||
privacyPolicyClass: TestPostgresEntityPrivacyPolicy,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
class TestReadonlyPostgresEntityPrivacyPolicy extends EntityPrivacyPolicy<
|
||||
TestPostgresFields,
|
||||
'id',
|
||||
ViewerContext,
|
||||
TestReadonlyPostgresEntity
|
||||
> {
|
||||
protected override readonly readRules = [
|
||||
new AlwaysAllowPrivacyPolicyRule<
|
||||
TestPostgresFields,
|
||||
'id',
|
||||
ViewerContext,
|
||||
TestReadonlyPostgresEntity
|
||||
>(),
|
||||
];
|
||||
protected override readonly createRules = [
|
||||
new AlwaysAllowPrivacyPolicyRule<
|
||||
TestPostgresFields,
|
||||
'id',
|
||||
ViewerContext,
|
||||
TestReadonlyPostgresEntity
|
||||
>(),
|
||||
];
|
||||
protected override readonly updateRules = [
|
||||
new AlwaysAllowPrivacyPolicyRule<
|
||||
TestPostgresFields,
|
||||
'id',
|
||||
ViewerContext,
|
||||
TestReadonlyPostgresEntity
|
||||
>(),
|
||||
];
|
||||
protected override readonly deleteRules = [
|
||||
new AlwaysAllowPrivacyPolicyRule<
|
||||
TestPostgresFields,
|
||||
'id',
|
||||
ViewerContext,
|
||||
TestReadonlyPostgresEntity
|
||||
>(),
|
||||
];
|
||||
}
|
||||
|
||||
class TestReadonlyPostgresEntity extends ReadonlyPostgresEntity<
|
||||
TestPostgresFields,
|
||||
'id',
|
||||
ViewerContext
|
||||
> {
|
||||
static defineCompanionDefinition(): EntityCompanionDefinition<
|
||||
TestPostgresFields,
|
||||
'id',
|
||||
ViewerContext,
|
||||
TestReadonlyPostgresEntity,
|
||||
TestReadonlyPostgresEntityPrivacyPolicy
|
||||
> {
|
||||
return {
|
||||
entityClass: TestReadonlyPostgresEntity,
|
||||
entityConfiguration: testPostgresEntityConfiguration,
|
||||
privacyPolicyClass: TestReadonlyPostgresEntityPrivacyPolicy,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
describe(PostgresEntity, () => {
|
||||
describe('knexLoader', () => {
|
||||
it('creates a new EnforcingKnexEntityLoader', () => {
|
||||
const companionProvider = createUnitTestPostgresEntityCompanionProvider();
|
||||
const viewerContext = new ViewerContext(companionProvider);
|
||||
expect(TestPostgresEntity.knexLoader(viewerContext)).toBeInstanceOf(
|
||||
EnforcingKnexEntityLoader,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('knexLoaderWithAuthorizationResults', () => {
|
||||
it('creates a new AuthorizationResultBasedKnexEntityLoader', () => {
|
||||
const companionProvider = createUnitTestPostgresEntityCompanionProvider();
|
||||
const viewerContext = new ViewerContext(companionProvider);
|
||||
expect(TestPostgresEntity.knexLoaderWithAuthorizationResults(viewerContext)).toBeInstanceOf(
|
||||
AuthorizationResultBasedKnexEntityLoader,
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe(ReadonlyPostgresEntity, () => {
|
||||
describe('knexLoader', () => {
|
||||
it('creates a new EnforcingKnexEntityLoader', () => {
|
||||
const companionProvider = createUnitTestPostgresEntityCompanionProvider();
|
||||
const viewerContext = new ViewerContext(companionProvider);
|
||||
expect(TestReadonlyPostgresEntity.knexLoader(viewerContext)).toBeInstanceOf(
|
||||
EnforcingKnexEntityLoader,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('knexLoaderWithAuthorizationResults', () => {
|
||||
it('creates a new AuthorizationResultBasedKnexEntityLoader', () => {
|
||||
const companionProvider = createUnitTestPostgresEntityCompanionProvider();
|
||||
const viewerContext = new ViewerContext(companionProvider);
|
||||
expect(
|
||||
TestReadonlyPostgresEntity.knexLoaderWithAuthorizationResults(viewerContext),
|
||||
).toBeInstanceOf(AuthorizationResultBasedKnexEntityLoader);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,16 +1,18 @@
|
||||
import { ReadonlyEntity, ViewerContext } from '@expo/entity';
|
||||
import { ViewerContext } from '@expo/entity';
|
||||
import { describe, expect, it } from '@jest/globals';
|
||||
|
||||
import { AuthorizationResultBasedKnexEntityLoader } from '../AuthorizationResultBasedKnexEntityLoader';
|
||||
import { EnforcingKnexEntityLoader } from '../EnforcingKnexEntityLoader';
|
||||
import { knexLoader, knexLoaderWithAuthorizationResults } from '../knexLoader';
|
||||
import { TestEntity } from './fixtures/TestEntity';
|
||||
import { createUnitTestPostgresEntityCompanionProvider } from './fixtures/createUnitTestPostgresEntityCompanionProvider';
|
||||
|
||||
describe(ReadonlyEntity, () => {
|
||||
describe('knexLoader', () => {
|
||||
describe('knexLoader', () => {
|
||||
it('creates a new EnforcingKnexEntityLoader', async () => {
|
||||
const companionProvider = createUnitTestPostgresEntityCompanionProvider();
|
||||
const viewerContext = new ViewerContext(companionProvider);
|
||||
expect(knexLoader(TestEntity, viewerContext)).toBeInstanceOf(EnforcingKnexEntityLoader);
|
||||
expect(TestEntity.knexLoader(viewerContext)).toBeInstanceOf(EnforcingKnexEntityLoader);
|
||||
});
|
||||
});
|
||||
@@ -19,6 +21,9 @@ describe(ReadonlyEntity, () => {
|
||||
it('creates a new AuthorizationResultBasedKnexEntityLoader', async () => {
|
||||
const companionProvider = createUnitTestPostgresEntityCompanionProvider();
|
||||
const viewerContext = new ViewerContext(companionProvider);
|
||||
expect(knexLoaderWithAuthorizationResults(TestEntity, viewerContext)).toBeInstanceOf(
|
||||
AuthorizationResultBasedKnexEntityLoader,
|
||||
);
|
||||
expect(TestEntity.knexLoaderWithAuthorizationResults(viewerContext)).toBeInstanceOf(
|
||||
AuthorizationResultBasedKnexEntityLoader,
|
||||
);
|
||||
|
||||
@@ -1,13 +1,20 @@
|
||||
import { getDatabaseFieldForEntityField } from '@expo/entity';
|
||||
import { describe, expect, it } from '@jest/globals';
|
||||
|
||||
import {
|
||||
entityField,
|
||||
identifier,
|
||||
raw,
|
||||
unsafeRaw,
|
||||
sql,
|
||||
SQLEntityField,
|
||||
SQLFragment,
|
||||
SQLFragmentHelpers,
|
||||
SQLIdentifier,
|
||||
} from '../SQLOperator';
|
||||
import { TestFields, testEntityConfiguration } from './fixtures/TestEntity';
|
||||
|
||||
const getColumnForField = (fieldName: string): string =>
|
||||
getDatabaseFieldForEntityField(testEntityConfiguration, fieldName as keyof TestFields);
|
||||
|
||||
describe('SQLOperator', () => {
|
||||
describe('sql template literal', () => {
|
||||
@@ -17,7 +24,7 @@ describe('SQLOperator', () => {
|
||||
const fragment = sql`age >= ${age} AND status = ${status}`;
|
||||
|
||||
expect(fragment.sql).toBe('age >= ? AND status = ?');
|
||||
expect(fragment.getKnexBindings()).toEqual([18, 'active']);
|
||||
expect(fragment.getKnexBindings(getColumnForField)).toEqual([18, 'active']);
|
||||
});
|
||||
|
||||
it('handles nested SQL fragments', () => {
|
||||
@@ -26,7 +33,7 @@ describe('SQLOperator', () => {
|
||||
const combined = sql`${condition1} AND ${condition2}`;
|
||||
|
||||
expect(combined.sql).toBe('age >= ? AND status = ?');
|
||||
expect(combined.getKnexBindings()).toEqual([18, 'active']);
|
||||
expect(combined.getKnexBindings(getColumnForField)).toEqual([18, 'active']);
|
||||
});
|
||||
|
||||
it('handles SQL identifiers', () => {
|
||||
@@ -34,7 +41,7 @@ describe('SQLOperator', () => {
|
||||
const fragment = sql`${identifier(columnName)} = ${'John'}`;
|
||||
|
||||
expect(fragment.sql).toBe('?? = ?');
|
||||
expect(fragment.getKnexBindings()).toEqual(['user_name', 'John']);
|
||||
expect(fragment.getKnexBindings(getColumnForField)).toEqual(['user_name', 'John']);
|
||||
});
|
||||
|
||||
it('handles arrays for IN clauses', () => {
|
||||
@@ -42,58 +49,62 @@ describe('SQLOperator', () => {
|
||||
const fragment = sql`status IN ${values}`;
|
||||
|
||||
expect(fragment.sql).toBe('status IN (?, ?, ?)');
|
||||
expect(fragment.getKnexBindings()).toEqual(['active', 'pending', 'approved']);
|
||||
expect(fragment.getKnexBindings(getColumnForField)).toEqual([
|
||||
'active',
|
||||
'pending',
|
||||
'approved',
|
||||
]);
|
||||
});
|
||||
|
||||
it('handles null values', () => {
|
||||
const fragment = sql`field = ${null}`;
|
||||
|
||||
expect(fragment.sql).toBe('field = ?');
|
||||
expect(fragment.getKnexBindings()).toEqual([null]);
|
||||
expect(fragment.getKnexBindings(getColumnForField)).toEqual([null]);
|
||||
});
|
||||
|
||||
it('handles empty strings', () => {
|
||||
const fragment = sql`field = ${''}`;
|
||||
|
||||
expect(fragment.sql).toBe('field = ?');
|
||||
expect(fragment.getKnexBindings()).toEqual(['']);
|
||||
expect(fragment.getKnexBindings(getColumnForField)).toEqual(['']);
|
||||
});
|
||||
|
||||
it('handles numbers including zero', () => {
|
||||
const fragment = sql`count = ${0} OR count = ${42}`;
|
||||
|
||||
expect(fragment.sql).toBe('count = ? OR count = ?');
|
||||
expect(fragment.getKnexBindings()).toEqual([0, 42]);
|
||||
expect(fragment.getKnexBindings(getColumnForField)).toEqual([0, 42]);
|
||||
});
|
||||
|
||||
it('handles boolean values', () => {
|
||||
const fragment = sql`active = ${true} AND deleted = ${false}`;
|
||||
|
||||
expect(fragment.sql).toBe('active = ? AND deleted = ?');
|
||||
expect(fragment.getKnexBindings()).toEqual([true, false]);
|
||||
expect(fragment.getKnexBindings(getColumnForField)).toEqual([true, false]);
|
||||
});
|
||||
|
||||
it('handles raw SQL', () => {
|
||||
const columnName = 'created_at';
|
||||
const fragment = sql`ORDER BY ${raw(columnName)} DESC`;
|
||||
const fragment = sql`ORDER BY ${unsafeRaw(columnName)} DESC`;
|
||||
|
||||
expect(fragment.sql).toBe('ORDER BY created_at DESC');
|
||||
expect(fragment.getKnexBindings()).toEqual([]);
|
||||
expect(fragment.getKnexBindings(getColumnForField)).toEqual([]);
|
||||
});
|
||||
|
||||
it('handles complex raw SQL expressions', () => {
|
||||
const fragment = sql`WHERE ${raw('EXTRACT(year FROM created_at)')} = ${2024}`;
|
||||
const fragment = sql`WHERE ${unsafeRaw('EXTRACT(year FROM created_at)')} = ${2024}`;
|
||||
|
||||
expect(fragment.sql).toBe('WHERE EXTRACT(year FROM created_at) = ?');
|
||||
expect(fragment.getKnexBindings()).toEqual([2024]);
|
||||
expect(fragment.getKnexBindings(getColumnForField)).toEqual([2024]);
|
||||
});
|
||||
|
||||
it('combines raw SQL with regular parameters', () => {
|
||||
const sortColumn = 'name';
|
||||
const fragment = sql`SELECT * FROM users WHERE age > ${18} ORDER BY ${raw(sortColumn)} ${raw('DESC')}`;
|
||||
const fragment = sql`SELECT * FROM users WHERE age > ${18} ORDER BY ${unsafeRaw(sortColumn)} ${unsafeRaw('DESC')}`;
|
||||
|
||||
expect(fragment.sql).toBe('SELECT * FROM users WHERE age > ? ORDER BY name DESC');
|
||||
expect(fragment.getKnexBindings()).toEqual([18]);
|
||||
expect(fragment.getKnexBindings(getColumnForField)).toEqual([18]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -105,52 +116,32 @@ describe('SQLOperator', () => {
|
||||
const combined = fragment1.append(fragment2);
|
||||
|
||||
expect(combined.sql).toBe('age >= ? status = ?');
|
||||
expect(combined.getKnexBindings()).toEqual([18, 'active']);
|
||||
expect(combined.getKnexBindings(getColumnForField)).toEqual([18, 'active']);
|
||||
});
|
||||
});
|
||||
|
||||
describe(SQLFragment.join, () => {
|
||||
it('joins fragments with custom separator', () => {
|
||||
const fragments = [
|
||||
new SQLFragment('name = ?', [{ type: 'value', value: 'Alice' }]),
|
||||
new SQLFragment('age = ?', [{ type: 'value', value: 30 }]),
|
||||
new SQLFragment('city = ?', [{ type: 'value', value: 'NYC' }]),
|
||||
];
|
||||
const joined = SQLFragment.join(fragments, ' AND ');
|
||||
|
||||
expect(joined.sql).toBe('name = ? AND age = ? AND city = ?');
|
||||
expect(joined.getKnexBindings()).toEqual(['Alice', 30, 'NYC']);
|
||||
});
|
||||
|
||||
describe(SQLFragment.joinWithCommaSeparator, () => {
|
||||
it('handles empty array in join', () => {
|
||||
const joined = SQLFragment.join([]);
|
||||
const joined = SQLFragment.joinWithCommaSeparator();
|
||||
|
||||
expect(joined.sql).toBe('');
|
||||
expect(joined.getKnexBindings()).toEqual([]);
|
||||
expect(joined.getKnexBindings(getColumnForField)).toEqual([]);
|
||||
});
|
||||
|
||||
it('joins SQL fragments with default separator', () => {
|
||||
it('joins SQL fragments with comma', () => {
|
||||
const columns = [sql`name`, sql`age`, sql`email`];
|
||||
const joined = SQLFragment.join(columns);
|
||||
const joined = SQLFragment.joinWithCommaSeparator(...columns);
|
||||
|
||||
expect(joined.sql).toBe('name, age, email');
|
||||
expect(joined.getKnexBindings()).toEqual([]);
|
||||
});
|
||||
|
||||
it('joins SQL fragments with custom separator', () => {
|
||||
const conditions = [sql`age > ${18}`, sql`status = ${'active'}`, sql`verified = ${true}`];
|
||||
const joined = SQLFragment.join(conditions, ' AND ');
|
||||
|
||||
expect(joined.sql).toBe('age > ? AND status = ? AND verified = ?');
|
||||
expect(joined.getKnexBindings()).toEqual([18, 'active', true]);
|
||||
expect(joined.getKnexBindings(getColumnForField)).toEqual([]);
|
||||
});
|
||||
|
||||
it('handles single fragment', () => {
|
||||
const single = [sql`name = ${'Alice'}`];
|
||||
const joined = SQLFragment.join(single);
|
||||
const joined = SQLFragment.joinWithCommaSeparator(...single);
|
||||
|
||||
expect(joined.sql).toBe('name = ?');
|
||||
expect(joined.getKnexBindings()).toEqual(['Alice']);
|
||||
expect(joined.getKnexBindings(getColumnForField)).toEqual(['Alice']);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -163,7 +154,7 @@ describe('SQLOperator', () => {
|
||||
const concatenated = SQLFragment.concat(select, where, orderBy);
|
||||
|
||||
expect(concatenated.sql).toBe('SELECT * FROM users WHERE age > ? ORDER BY name');
|
||||
expect(concatenated.getKnexBindings()).toEqual([18]);
|
||||
expect(concatenated.getKnexBindings(getColumnForField)).toEqual([18]);
|
||||
});
|
||||
|
||||
it('handles single fragment in concat', () => {
|
||||
@@ -171,27 +162,27 @@ describe('SQLOperator', () => {
|
||||
const concatenated = SQLFragment.concat(fragment);
|
||||
|
||||
expect(concatenated.sql).toBe('SELECT * FROM users');
|
||||
expect(concatenated.getKnexBindings()).toEqual([]);
|
||||
expect(concatenated.getKnexBindings(getColumnForField)).toEqual([]);
|
||||
});
|
||||
|
||||
it('handles empty concat', () => {
|
||||
const concatenated = SQLFragment.concat();
|
||||
|
||||
expect(concatenated.sql).toBe('');
|
||||
expect(concatenated.getKnexBindings()).toEqual([]);
|
||||
expect(concatenated.getKnexBindings(getColumnForField)).toEqual([]);
|
||||
});
|
||||
|
||||
it('supports dynamic query building with concat', () => {
|
||||
// Build a query dynamically
|
||||
const fragments: SQLFragment[] = [sql`SELECT * FROM products`];
|
||||
const fragments: SQLFragment<Record<string, any>>[] = [sql`SELECT * FROM products`];
|
||||
|
||||
// Conditionally add WHERE clause
|
||||
const filters: SQLFragment[] = [];
|
||||
const filters: SQLFragment<Record<string, any>>[] = [];
|
||||
filters.push(sql`price > ${100}`);
|
||||
filters.push(sql`category = ${'electronics'}`);
|
||||
|
||||
if (filters.length > 0) {
|
||||
fragments.push(sql`WHERE ${SQLFragment.join(filters, ' AND ')}`);
|
||||
fragments.push(sql`WHERE ${SQLFragmentHelpers.and(...filters)}`);
|
||||
}
|
||||
|
||||
// Add ORDER BY
|
||||
@@ -203,13 +194,13 @@ describe('SQLOperator', () => {
|
||||
const query = SQLFragment.concat(...fragments);
|
||||
|
||||
expect(query.sql).toBe(
|
||||
'SELECT * FROM products WHERE price > ? AND category = ? ORDER BY created_at DESC LIMIT ?',
|
||||
'SELECT * FROM products WHERE (price > ?) AND (category = ?) ORDER BY created_at DESC LIMIT ?',
|
||||
);
|
||||
expect(query.getKnexBindings()).toEqual([100, 'electronics', 10]);
|
||||
expect(query.getKnexBindings(getColumnForField)).toEqual([100, 'electronics', 10]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('toDebugString', () => {
|
||||
describe('getDebugString', () => {
|
||||
it('generates debug text with values inline', () => {
|
||||
const fragment = new SQLFragment(
|
||||
'SELECT * FROM users WHERE name = ? AND age > ? AND active = ? AND created_at > ?',
|
||||
@@ -221,7 +212,7 @@ describe('SQLOperator', () => {
|
||||
],
|
||||
);
|
||||
|
||||
const text = fragment.toDebugString;
|
||||
const text = fragment.getDebugString();
|
||||
expect(text).toContain("'Alice'");
|
||||
expect(text).toContain('18');
|
||||
expect(text).toContain('TRUE');
|
||||
@@ -235,13 +226,13 @@ describe('SQLOperator', () => {
|
||||
{ type: 'value', value: { key: 'value' } },
|
||||
]);
|
||||
|
||||
const text = fragment.toDebugString;
|
||||
const text = fragment.getDebugString();
|
||||
expect(text).toContain('NULL');
|
||||
expect(text).toContain("O''Reilly"); // SQL escaped single quote
|
||||
expect(text).toContain(`'{"key":"value"}'::jsonb`);
|
||||
});
|
||||
|
||||
it('handles all SupportedSQLValue types in toDebugString', () => {
|
||||
it('handles all SupportedSQLValue types in getDebugString', () => {
|
||||
const fragment = new SQLFragment('INSERT INTO test VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)', [
|
||||
{ type: 'value', value: 'string' },
|
||||
{ type: 'value', value: 123 },
|
||||
@@ -254,18 +245,18 @@ describe('SQLOperator', () => {
|
||||
{ type: 'value', value: [1, 2, 3] },
|
||||
]);
|
||||
|
||||
const text = fragment.toDebugString;
|
||||
const text = fragment.getDebugString();
|
||||
expect(text).toBe(
|
||||
"INSERT INTO test VALUES ('string', 123, TRUE, NULL, NULL, '2024-01-01T00:00:00.000Z', '\\x68656c6c6f', 999, ARRAY[1, 2, 3])",
|
||||
);
|
||||
});
|
||||
|
||||
it('handles nested arrays in toDebugString', () => {
|
||||
it('handles nested arrays in getDebugString', () => {
|
||||
const fragment = new SQLFragment('SELECT * FROM test WHERE tags = ?', [
|
||||
{ type: 'value', value: ['tag1', 'tag2', null] },
|
||||
]);
|
||||
|
||||
const text = fragment.toDebugString;
|
||||
const text = fragment.getDebugString();
|
||||
expect(text).toBe("SELECT * FROM test WHERE tags = ARRAY['tag1', 'tag2', NULL]");
|
||||
});
|
||||
|
||||
@@ -274,23 +265,23 @@ describe('SQLOperator', () => {
|
||||
{ type: 'value', value: 'value1' },
|
||||
]);
|
||||
|
||||
const text = fragment.toDebugString;
|
||||
const text = fragment.getDebugString();
|
||||
expect(text).toBe("SELECT * FROM test WHERE field1 = 'value1' AND field2 = ?");
|
||||
});
|
||||
|
||||
it('handles non-SupportedSQLValue types gracefully', () => {
|
||||
const fragment = new SQLFragment('SELECT * FROM test WHERE field = ? AND field2 = ?', [
|
||||
{ type: 'value', value: new Error('wat') as any },
|
||||
{ type: 'value', value: new Error('wat') },
|
||||
{ type: 'value', value: Object.create(null) },
|
||||
]);
|
||||
|
||||
const text = fragment.toDebugString;
|
||||
const text = fragment.getDebugString();
|
||||
expect(text).toBe(
|
||||
`SELECT * FROM test WHERE field = UnsupportedSQLValue[Error: wat] AND field2 = '{}'::jsonb`,
|
||||
);
|
||||
});
|
||||
|
||||
it('handles identifiers in toDebugString', () => {
|
||||
it('handles identifiers in getDebugString', () => {
|
||||
const fragment = new SQLFragment('SELECT ?? FROM ?? WHERE ?? = ?', [
|
||||
{ type: 'identifier', name: 'user_name' },
|
||||
{ type: 'identifier', name: 'users' },
|
||||
@@ -298,17 +289,29 @@ describe('SQLOperator', () => {
|
||||
{ type: 'value', value: 'active' },
|
||||
]);
|
||||
|
||||
const text = fragment.toDebugString;
|
||||
const text = fragment.getDebugString();
|
||||
expect(text).toBe('SELECT "user_name" FROM "users" WHERE "status" = \'active\'');
|
||||
});
|
||||
|
||||
it('handles entity fields in getDebugString', () => {
|
||||
const fragment = new SQLFragment('SELECT ?? FROM ?? WHERE ?? = ?', [
|
||||
{ type: 'entityField', fieldName: 'string_field' },
|
||||
{ type: 'identifier', name: 'test' },
|
||||
{ type: 'entityField', fieldName: 'number_field' },
|
||||
{ type: 'value', value: 42 },
|
||||
]);
|
||||
|
||||
const text = fragment.getDebugString();
|
||||
expect(text).toBe('SELECT "string_field" FROM "test" WHERE "number_field" = 42');
|
||||
});
|
||||
|
||||
it('handles undefined bindings gracefully', () => {
|
||||
const fragment = new SQLFragment('SELECT * FROM users WHERE id = ? AND name = ?', [
|
||||
{ type: 'value', value: 1 },
|
||||
undefined as any, // Simulate an edge case with undefined binding
|
||||
]);
|
||||
|
||||
const text = fragment.toDebugString;
|
||||
const text = fragment.getDebugString();
|
||||
expect(text).toBe('SELECT * FROM users WHERE id = 1 AND name = ?');
|
||||
});
|
||||
|
||||
@@ -319,7 +322,7 @@ describe('SQLOperator', () => {
|
||||
{ type: 'value', value: 'test' },
|
||||
]);
|
||||
|
||||
const text = fragment.toDebugString;
|
||||
const text = fragment.getDebugString();
|
||||
// When a binding is null, it leaves the placeholder unchanged and doesn't advance the index
|
||||
expect(text).toBe('SELECT * FROM "users" WHERE ?? = ?');
|
||||
});
|
||||
@@ -330,7 +333,7 @@ describe('SQLOperator', () => {
|
||||
{ type: 'value', value: 1 },
|
||||
]);
|
||||
|
||||
const text = fragment.toDebugString;
|
||||
const text = fragment.getDebugString();
|
||||
// Mismatched binding type leaves the placeholder unchanged
|
||||
expect(text).toBe('SELECT * FROM ?? WHERE id = 1');
|
||||
});
|
||||
@@ -341,7 +344,7 @@ describe('SQLOperator', () => {
|
||||
{ type: 'value', value: 'active' },
|
||||
]);
|
||||
|
||||
const text = fragment.toDebugString;
|
||||
const text = fragment.getDebugString();
|
||||
// Mismatched binding type leaves the placeholder unchanged
|
||||
expect(text).toBe("SELECT * FROM users WHERE ? = 'active'");
|
||||
});
|
||||
@@ -383,7 +386,7 @@ describe('SQLOperator', () => {
|
||||
const columnName = 'user"data';
|
||||
const fragment = sql`SELECT ${identifier(columnName)} FROM users`;
|
||||
expect(fragment.sql).toBe('SELECT ?? FROM users');
|
||||
expect(fragment.getKnexBindings()).toEqual(['user"data']);
|
||||
expect(fragment.getKnexBindings(getColumnForField)).toEqual(['user"data']);
|
||||
});
|
||||
|
||||
it('delegates escaping to Knex for SQL injection prevention', () => {
|
||||
@@ -391,258 +394,336 @@ describe('SQLOperator', () => {
|
||||
const fragment = sql`SELECT * FROM ${identifier(maliciousName)}`;
|
||||
// The identifier is passed as a binding to Knex which will escape it
|
||||
expect(fragment.sql).toBe('SELECT * FROM ??');
|
||||
expect(fragment.getKnexBindings()).toEqual(['id"; DELETE FROM users WHERE "1"="1']);
|
||||
expect(fragment.getKnexBindings(getColumnForField)).toEqual([
|
||||
'id"; DELETE FROM users WHERE "1"="1',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe(SQLEntityField, () => {
|
||||
it('stores the entity field name', () => {
|
||||
const field = entityField<TestFields>('stringField');
|
||||
expect(field.fieldName).toBe('stringField');
|
||||
});
|
||||
|
||||
it('uses ?? placeholder in SQL fragments', () => {
|
||||
const fragment = sql`SELECT ${entityField<TestFields>('stringField')} FROM users`;
|
||||
|
||||
expect(fragment.sql).toBe('SELECT ?? FROM users');
|
||||
expect(fragment.getKnexBindings(getColumnForField)).toEqual(['string_field']);
|
||||
});
|
||||
|
||||
it('translates entity field name to database column name via getKnexBindings', () => {
|
||||
const fragment = sql`WHERE ${entityField<TestFields>('intField')} = ${42}`;
|
||||
|
||||
expect(fragment.sql).toBe('WHERE ?? = ?');
|
||||
expect(fragment.getKnexBindings(getColumnForField)).toEqual(['number_field', 42]);
|
||||
});
|
||||
|
||||
it('translates the id field correctly', () => {
|
||||
const fragment = sql`WHERE ${entityField<TestFields>('customIdField')} = ${'some-id'}`;
|
||||
|
||||
expect(fragment.sql).toBe('WHERE ?? = ?');
|
||||
expect(fragment.getKnexBindings(getColumnForField)).toEqual(['custom_id', 'some-id']);
|
||||
});
|
||||
|
||||
it('works alongside identifiers and values', () => {
|
||||
const fragment = sql`SELECT ${identifier('table_name')}.${entityField<TestFields>('stringField')} WHERE ${entityField<TestFields>('intField')} > ${10}`;
|
||||
|
||||
expect(fragment.sql).toBe('SELECT ??.?? WHERE ?? > ?');
|
||||
expect(fragment.getKnexBindings(getColumnForField)).toEqual([
|
||||
'table_name',
|
||||
'string_field',
|
||||
'number_field',
|
||||
10,
|
||||
]);
|
||||
});
|
||||
|
||||
it('works with multiple entity fields', () => {
|
||||
const fragment = sql`SELECT ${entityField<TestFields>('stringField')}, ${entityField<TestFields>('intField')}, ${entityField<TestFields>('dateField')} FROM test`;
|
||||
|
||||
expect(fragment.sql).toBe('SELECT ??, ??, ?? FROM test');
|
||||
expect(fragment.getKnexBindings(getColumnForField)).toEqual([
|
||||
'string_field',
|
||||
'number_field',
|
||||
'date_field',
|
||||
]);
|
||||
});
|
||||
|
||||
it('works in nested SQL fragments', () => {
|
||||
const inner = sql`${entityField<TestFields>('stringField')} = ${'hello'}`;
|
||||
const outer = sql`SELECT * FROM test WHERE ${inner}`;
|
||||
|
||||
expect(outer.sql).toBe('SELECT * FROM test WHERE ?? = ?');
|
||||
expect(outer.getKnexBindings(getColumnForField)).toEqual(['string_field', 'hello']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('SQLFragmentHelpers', () => {
|
||||
describe(SQLFragmentHelpers.inArray, () => {
|
||||
it('generates IN clause with values', () => {
|
||||
const fragment = SQLFragmentHelpers.inArray('status', ['active', 'pending']);
|
||||
const fragment = SQLFragmentHelpers.inArray('stringField', ['active', 'pending']);
|
||||
|
||||
expect(fragment.sql).toBe('?? IN (?, ?)');
|
||||
expect(fragment.getKnexBindings()).toEqual(['status', 'active', 'pending']);
|
||||
expect(fragment.getKnexBindings(getColumnForField)).toEqual([
|
||||
'string_field',
|
||||
'active',
|
||||
'pending',
|
||||
]);
|
||||
});
|
||||
|
||||
it('handles empty array', () => {
|
||||
const fragment = SQLFragmentHelpers.inArray('status', []);
|
||||
const fragment = SQLFragmentHelpers.inArray('stringField', []);
|
||||
|
||||
expect(fragment.sql).toBe('1 = 0'); // Always false
|
||||
expect(fragment.getKnexBindings()).toEqual([]);
|
||||
expect(fragment.getKnexBindings(getColumnForField)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe(SQLFragmentHelpers.notInArray, () => {
|
||||
it('generates NOT IN clause with values', () => {
|
||||
const fragment = SQLFragmentHelpers.notInArray('status', ['deleted', 'archived']);
|
||||
const fragment = SQLFragmentHelpers.notInArray('stringField', ['deleted', 'archived']);
|
||||
|
||||
expect(fragment.sql).toBe('?? NOT IN (?, ?)');
|
||||
expect(fragment.getKnexBindings()).toEqual(['status', 'deleted', 'archived']);
|
||||
expect(fragment.getKnexBindings(getColumnForField)).toEqual([
|
||||
'string_field',
|
||||
'deleted',
|
||||
'archived',
|
||||
]);
|
||||
});
|
||||
|
||||
it('handles empty array', () => {
|
||||
const fragment = SQLFragmentHelpers.notInArray('status', []);
|
||||
const fragment = SQLFragmentHelpers.notInArray('stringField', []);
|
||||
|
||||
expect(fragment.sql).toBe('1 = 1'); // Always true
|
||||
expect(fragment.getKnexBindings()).toEqual([]);
|
||||
expect(fragment.getKnexBindings(getColumnForField)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe(SQLFragmentHelpers.between, () => {
|
||||
it('generates BETWEEN clause with numbers', () => {
|
||||
const fragment = SQLFragmentHelpers.between('age', 18, 65);
|
||||
const fragment = SQLFragmentHelpers.between('intField', 18, 65);
|
||||
|
||||
expect(fragment.sql).toBe('?? BETWEEN ? AND ?');
|
||||
expect(fragment.getKnexBindings()).toEqual(['age', 18, 65]);
|
||||
expect(fragment.getKnexBindings(getColumnForField)).toEqual(['number_field', 18, 65]);
|
||||
});
|
||||
|
||||
it('generates BETWEEN clause with dates', () => {
|
||||
const date1 = new Date('2024-01-01');
|
||||
const date2 = new Date('2024-12-31');
|
||||
const fragment = SQLFragmentHelpers.between('created_at', date1, date2);
|
||||
const fragment = SQLFragmentHelpers.between('dateField', date1, date2);
|
||||
|
||||
expect(fragment.sql).toBe('?? BETWEEN ? AND ?');
|
||||
expect(fragment.getKnexBindings()).toEqual(['created_at', date1, date2]);
|
||||
expect(fragment.getKnexBindings(getColumnForField)).toEqual(['date_field', date1, date2]);
|
||||
});
|
||||
|
||||
it('generates BETWEEN clause with strings', () => {
|
||||
const fragment = SQLFragmentHelpers.between('name', 'A', 'Z');
|
||||
const fragment = SQLFragmentHelpers.between('stringField', 'A', 'Z');
|
||||
|
||||
expect(fragment.sql).toBe('?? BETWEEN ? AND ?');
|
||||
expect(fragment.getKnexBindings()).toEqual(['name', 'A', 'Z']);
|
||||
expect(fragment.getKnexBindings(getColumnForField)).toEqual(['string_field', 'A', 'Z']);
|
||||
});
|
||||
});
|
||||
|
||||
describe(SQLFragmentHelpers.notBetween, () => {
|
||||
it('generates NOT BETWEEN clause with numbers', () => {
|
||||
const fragment = SQLFragmentHelpers.notBetween('age', 18, 65);
|
||||
const fragment = SQLFragmentHelpers.notBetween('intField', 18, 65);
|
||||
|
||||
expect(fragment.sql).toBe('?? NOT BETWEEN ? AND ?');
|
||||
expect(fragment.getKnexBindings()).toEqual(['age', 18, 65]);
|
||||
expect(fragment.getKnexBindings(getColumnForField)).toEqual(['number_field', 18, 65]);
|
||||
});
|
||||
|
||||
it('generates NOT BETWEEN clause with dates', () => {
|
||||
const date1 = new Date('2024-01-01');
|
||||
const date2 = new Date('2024-12-31');
|
||||
const fragment = SQLFragmentHelpers.notBetween('created_at', date1, date2);
|
||||
const fragment = SQLFragmentHelpers.notBetween('dateField', date1, date2);
|
||||
|
||||
expect(fragment.sql).toBe('?? NOT BETWEEN ? AND ?');
|
||||
expect(fragment.getKnexBindings()).toEqual(['created_at', date1, date2]);
|
||||
expect(fragment.getKnexBindings(getColumnForField)).toEqual(['date_field', date1, date2]);
|
||||
});
|
||||
});
|
||||
|
||||
describe(SQLFragmentHelpers.like, () => {
|
||||
it('generates LIKE clause', () => {
|
||||
const fragment = SQLFragmentHelpers.like('name', '%John%');
|
||||
const fragment = SQLFragmentHelpers.like('stringField', '%John%');
|
||||
|
||||
expect(fragment.sql).toBe('?? LIKE ?');
|
||||
expect(fragment.getKnexBindings()).toEqual(['name', '%John%']);
|
||||
expect(fragment.getKnexBindings(getColumnForField)).toEqual(['string_field', '%John%']);
|
||||
});
|
||||
});
|
||||
|
||||
describe(SQLFragmentHelpers.notLike, () => {
|
||||
it('generates NOT LIKE clause', () => {
|
||||
const fragment = SQLFragmentHelpers.notLike('name', '%test%');
|
||||
const fragment = SQLFragmentHelpers.notLike('stringField', '%test%');
|
||||
|
||||
expect(fragment.sql).toBe('?? NOT LIKE ?');
|
||||
expect(fragment.getKnexBindings()).toEqual(['name', '%test%']);
|
||||
expect(fragment.getKnexBindings(getColumnForField)).toEqual(['string_field', '%test%']);
|
||||
});
|
||||
});
|
||||
|
||||
describe(SQLFragmentHelpers.ilike, () => {
|
||||
it('generates ILIKE clause for case-insensitive matching', () => {
|
||||
const fragment = SQLFragmentHelpers.ilike('email', '%@example.com');
|
||||
const fragment = SQLFragmentHelpers.ilike('testIndexedField', '%@example.com');
|
||||
|
||||
expect(fragment.sql).toBe('?? ILIKE ?');
|
||||
expect(fragment.getKnexBindings()).toEqual(['email', '%@example.com']);
|
||||
expect(fragment.getKnexBindings(getColumnForField)).toEqual([
|
||||
'test_index',
|
||||
'%@example.com',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe(SQLFragmentHelpers.notIlike, () => {
|
||||
it('generates NOT ILIKE clause for case-insensitive non-matching', () => {
|
||||
const fragment = SQLFragmentHelpers.notIlike('email', '%@spam.com');
|
||||
const fragment = SQLFragmentHelpers.notIlike('testIndexedField', '%@spam.com');
|
||||
|
||||
expect(fragment.sql).toBe('?? NOT ILIKE ?');
|
||||
expect(fragment.getKnexBindings()).toEqual(['email', '%@spam.com']);
|
||||
expect(fragment.getKnexBindings(getColumnForField)).toEqual(['test_index', '%@spam.com']);
|
||||
});
|
||||
});
|
||||
|
||||
describe(SQLFragmentHelpers.isNull, () => {
|
||||
it('generates IS NULL', () => {
|
||||
const fragment = SQLFragmentHelpers.isNull('deleted_at');
|
||||
const fragment = SQLFragmentHelpers.isNull('nullableField');
|
||||
|
||||
expect(fragment.sql).toBe('?? IS NULL');
|
||||
expect(fragment.getKnexBindings()).toEqual(['deleted_at']);
|
||||
expect(fragment.getKnexBindings(getColumnForField)).toEqual(['nullable_field']);
|
||||
});
|
||||
});
|
||||
|
||||
describe(SQLFragmentHelpers.isNotNull, () => {
|
||||
it('generates IS NOT NULL', () => {
|
||||
const fragment = SQLFragmentHelpers.isNotNull('email');
|
||||
const fragment = SQLFragmentHelpers.isNotNull('testIndexedField');
|
||||
|
||||
expect(fragment.sql).toBe('?? IS NOT NULL');
|
||||
expect(fragment.getKnexBindings()).toEqual(['email']);
|
||||
expect(fragment.getKnexBindings(getColumnForField)).toEqual(['test_index']);
|
||||
});
|
||||
});
|
||||
|
||||
describe(SQLFragmentHelpers.eq, () => {
|
||||
it('generates equality check', () => {
|
||||
const fragment = SQLFragmentHelpers.eq('status', 'active');
|
||||
const fragment = SQLFragmentHelpers.eq('stringField', 'active');
|
||||
|
||||
expect(fragment.sql).toBe('?? = ?');
|
||||
expect(fragment.getKnexBindings()).toEqual(['status', 'active']);
|
||||
expect(fragment.getKnexBindings(getColumnForField)).toEqual(['string_field', 'active']);
|
||||
});
|
||||
|
||||
it('handles null in equality check', () => {
|
||||
const fragment = SQLFragmentHelpers.eq('field', null);
|
||||
const fragment = SQLFragmentHelpers.eq('nullableField', null);
|
||||
|
||||
expect(fragment.sql).toBe('?? IS NULL');
|
||||
expect(fragment.getKnexBindings()).toEqual(['field']);
|
||||
expect(fragment.getKnexBindings(getColumnForField)).toEqual(['nullable_field']);
|
||||
});
|
||||
|
||||
it('handles undefined in equality check', () => {
|
||||
const fragment = SQLFragmentHelpers.eq('field', undefined);
|
||||
const fragment = SQLFragmentHelpers.eq('nullableField', undefined);
|
||||
|
||||
expect(fragment.sql).toBe('?? IS NULL');
|
||||
expect(fragment.getKnexBindings()).toEqual(['field']);
|
||||
expect(fragment.getKnexBindings(getColumnForField)).toEqual(['nullable_field']);
|
||||
});
|
||||
});
|
||||
|
||||
describe(SQLFragmentHelpers.neq, () => {
|
||||
it('generates inequality check', () => {
|
||||
const fragment = SQLFragmentHelpers.neq('status', 'deleted');
|
||||
const fragment = SQLFragmentHelpers.neq('stringField', 'deleted');
|
||||
|
||||
expect(fragment.sql).toBe('?? != ?');
|
||||
expect(fragment.getKnexBindings()).toEqual(['status', 'deleted']);
|
||||
expect(fragment.getKnexBindings(getColumnForField)).toEqual(['string_field', 'deleted']);
|
||||
});
|
||||
|
||||
it('handles null in inequality check', () => {
|
||||
const fragment = SQLFragmentHelpers.neq('field', null);
|
||||
const fragment = SQLFragmentHelpers.neq('nullableField', null);
|
||||
|
||||
expect(fragment.sql).toBe('?? IS NOT NULL');
|
||||
expect(fragment.getKnexBindings()).toEqual(['field']);
|
||||
expect(fragment.getKnexBindings(getColumnForField)).toEqual(['nullable_field']);
|
||||
});
|
||||
|
||||
it('handles undefined in inequality check', () => {
|
||||
const fragment = SQLFragmentHelpers.neq('field', undefined);
|
||||
const fragment = SQLFragmentHelpers.neq('nullableField', undefined);
|
||||
|
||||
expect(fragment.sql).toBe('?? IS NOT NULL');
|
||||
expect(fragment.getKnexBindings()).toEqual(['field']);
|
||||
expect(fragment.getKnexBindings(getColumnForField)).toEqual(['nullable_field']);
|
||||
});
|
||||
});
|
||||
|
||||
describe(SQLFragmentHelpers.gt, () => {
|
||||
it('generates greater than', () => {
|
||||
const fragment = SQLFragmentHelpers.gt('age', 18);
|
||||
const fragment = SQLFragmentHelpers.gt('intField', 18);
|
||||
|
||||
expect(fragment.sql).toBe('?? > ?');
|
||||
expect(fragment.getKnexBindings()).toEqual(['age', 18]);
|
||||
expect(fragment.getKnexBindings(getColumnForField)).toEqual(['number_field', 18]);
|
||||
});
|
||||
});
|
||||
|
||||
describe(SQLFragmentHelpers.gte, () => {
|
||||
it('generates greater than or equal', () => {
|
||||
const fragment = SQLFragmentHelpers.gte('age', 18);
|
||||
const fragment = SQLFragmentHelpers.gte('intField', 18);
|
||||
|
||||
expect(fragment.sql).toBe('?? >= ?');
|
||||
expect(fragment.getKnexBindings()).toEqual(['age', 18]);
|
||||
expect(fragment.getKnexBindings(getColumnForField)).toEqual(['number_field', 18]);
|
||||
});
|
||||
});
|
||||
|
||||
describe(SQLFragmentHelpers.lt, () => {
|
||||
it('generates less than', () => {
|
||||
const fragment = SQLFragmentHelpers.lt('age', 65);
|
||||
const fragment = SQLFragmentHelpers.lt('intField', 65);
|
||||
|
||||
expect(fragment.sql).toBe('?? < ?');
|
||||
expect(fragment.getKnexBindings()).toEqual(['age', 65]);
|
||||
expect(fragment.getKnexBindings(getColumnForField)).toEqual(['number_field', 65]);
|
||||
});
|
||||
});
|
||||
|
||||
describe(SQLFragmentHelpers.lte, () => {
|
||||
it('generates less than or equal', () => {
|
||||
const fragment = SQLFragmentHelpers.lte('age', 65);
|
||||
const fragment = SQLFragmentHelpers.lte('intField', 65);
|
||||
|
||||
expect(fragment.sql).toBe('?? <= ?');
|
||||
expect(fragment.getKnexBindings()).toEqual(['age', 65]);
|
||||
expect(fragment.getKnexBindings(getColumnForField)).toEqual(['number_field', 65]);
|
||||
});
|
||||
});
|
||||
|
||||
describe(SQLFragmentHelpers.jsonContains, () => {
|
||||
it('generates JSON contains', () => {
|
||||
const fragment = SQLFragmentHelpers.jsonContains('metadata', { premium: true });
|
||||
const fragment = SQLFragmentHelpers.jsonContains('stringField', { premium: true });
|
||||
|
||||
expect(fragment.sql).toBe('?? @> ?::jsonb');
|
||||
expect(fragment.getKnexBindings()).toEqual(['metadata', '{"premium":true}']);
|
||||
expect(fragment.getKnexBindings(getColumnForField)).toEqual([
|
||||
'string_field',
|
||||
'{"premium":true}',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe(SQLFragmentHelpers.jsonContainedBy, () => {
|
||||
it('generates JSON contained by', () => {
|
||||
const fragment = SQLFragmentHelpers.jsonContainedBy('settings', {
|
||||
const fragment = SQLFragmentHelpers.jsonContainedBy('stringField', {
|
||||
theme: 'dark',
|
||||
lang: 'en',
|
||||
});
|
||||
|
||||
expect(fragment.sql).toBe('?? <@ ?::jsonb');
|
||||
expect(fragment.getKnexBindings()).toEqual(['settings', '{"theme":"dark","lang":"en"}']);
|
||||
expect(fragment.getKnexBindings(getColumnForField)).toEqual([
|
||||
'string_field',
|
||||
'{"theme":"dark","lang":"en"}',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe(SQLFragmentHelpers.jsonPath, () => {
|
||||
it('generates JSON path access', () => {
|
||||
const fragment = SQLFragmentHelpers.jsonPath('data', 'user');
|
||||
const fragment = SQLFragmentHelpers.jsonPath('stringField', 'user');
|
||||
|
||||
expect(fragment.sql).toBe(`"data"->'user'`);
|
||||
expect(fragment.getKnexBindings()).toEqual([]);
|
||||
expect(fragment.sql).toBe(`??->?`);
|
||||
expect(fragment.getKnexBindings(getColumnForField)).toEqual(['string_field', 'user']);
|
||||
});
|
||||
});
|
||||
|
||||
describe(SQLFragmentHelpers.jsonPathText, () => {
|
||||
it('generates JSON path text access', () => {
|
||||
const fragment = SQLFragmentHelpers.jsonPathText('data', 'email');
|
||||
const fragment = SQLFragmentHelpers.jsonPathText('stringField', 'email');
|
||||
|
||||
expect(fragment.sql).toBe(`"data"->>'email'`);
|
||||
expect(fragment.getKnexBindings()).toEqual([]);
|
||||
expect(fragment.sql).toBe(`??->>?`);
|
||||
expect(fragment.getKnexBindings(getColumnForField)).toEqual(['string_field', 'email']);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -652,23 +733,23 @@ describe('SQLOperator', () => {
|
||||
const cond2 = sql`status = ${'active'}`;
|
||||
const fragment = SQLFragmentHelpers.and(cond1, cond2);
|
||||
|
||||
expect(fragment.sql).toBe('age >= ? AND status = ?');
|
||||
expect(fragment.getKnexBindings()).toEqual([18, 'active']);
|
||||
expect(fragment.sql).toBe('(age >= ?) AND (status = ?)');
|
||||
expect(fragment.getKnexBindings(getColumnForField)).toEqual([18, 'active']);
|
||||
});
|
||||
|
||||
it('handles single condition in AND', () => {
|
||||
const cond = sql`age >= ${18}`;
|
||||
const fragment = SQLFragmentHelpers.and(cond);
|
||||
|
||||
expect(fragment.sql).toBe('age >= ?');
|
||||
expect(fragment.getKnexBindings()).toEqual([18]);
|
||||
expect(fragment.sql).toBe('(age >= ?)');
|
||||
expect(fragment.getKnexBindings(getColumnForField)).toEqual([18]);
|
||||
});
|
||||
|
||||
it('handles empty conditions in AND', () => {
|
||||
const fragment = SQLFragmentHelpers.and();
|
||||
|
||||
expect(fragment.sql).toBe('1 = 1');
|
||||
expect(fragment.getKnexBindings()).toEqual([]);
|
||||
expect(fragment.getKnexBindings(getColumnForField)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -678,23 +759,23 @@ describe('SQLOperator', () => {
|
||||
const cond2 = sql`status = ${'pending'}`;
|
||||
const fragment = SQLFragmentHelpers.or(cond1, cond2);
|
||||
|
||||
expect(fragment.sql).toBe('status = ? OR status = ?');
|
||||
expect(fragment.getKnexBindings()).toEqual(['active', 'pending']);
|
||||
expect(fragment.sql).toBe('(status = ?) OR (status = ?)');
|
||||
expect(fragment.getKnexBindings(getColumnForField)).toEqual(['active', 'pending']);
|
||||
});
|
||||
|
||||
it('handles single condition in OR', () => {
|
||||
const cond = sql`status = ${'active'}`;
|
||||
const fragment = SQLFragmentHelpers.or(cond);
|
||||
|
||||
expect(fragment.sql).toBe('status = ?');
|
||||
expect(fragment.getKnexBindings()).toEqual(['active']);
|
||||
expect(fragment.sql).toBe('(status = ?)');
|
||||
expect(fragment.getKnexBindings(getColumnForField)).toEqual(['active']);
|
||||
});
|
||||
|
||||
it('handles empty conditions in OR', () => {
|
||||
const fragment = SQLFragmentHelpers.or();
|
||||
|
||||
expect(fragment.sql).toBe('1 = 0');
|
||||
expect(fragment.getKnexBindings()).toEqual([]);
|
||||
expect(fragment.getKnexBindings(getColumnForField)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -704,7 +785,7 @@ describe('SQLOperator', () => {
|
||||
const fragment = SQLFragmentHelpers.not(cond);
|
||||
|
||||
expect(fragment.sql).toBe('NOT (status = ?)');
|
||||
expect(fragment.getKnexBindings()).toEqual(['deleted']);
|
||||
expect(fragment.getKnexBindings(getColumnForField)).toEqual(['deleted']);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -714,32 +795,35 @@ describe('SQLOperator', () => {
|
||||
const fragment = SQLFragmentHelpers.group(cond);
|
||||
|
||||
expect(fragment.sql).toBe('(age >= ? AND age <= ?)');
|
||||
expect(fragment.getKnexBindings()).toEqual([18, 65]);
|
||||
expect(fragment.getKnexBindings(getColumnForField)).toEqual([18, 65]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('complex combinations', () => {
|
||||
it('builds complex queries with multiple helpers', () => {
|
||||
const { and, or, between, inArray, isNotNull, group } = SQLFragmentHelpers;
|
||||
|
||||
const fragment = and(
|
||||
between('age', 18, 65),
|
||||
group(or(inArray('status', ['active', 'premium']), sql`role = ${'admin'}`)),
|
||||
isNotNull('email'),
|
||||
const fragment = SQLFragmentHelpers.and(
|
||||
SQLFragmentHelpers.between('intField', 18, 65),
|
||||
SQLFragmentHelpers.group(
|
||||
SQLFragmentHelpers.or(
|
||||
SQLFragmentHelpers.inArray('stringField', ['active', 'premium']),
|
||||
sql`role = ${'admin'}`,
|
||||
),
|
||||
),
|
||||
SQLFragmentHelpers.isNotNull('testIndexedField'),
|
||||
);
|
||||
|
||||
expect(fragment.sql).toBe(
|
||||
'?? BETWEEN ? AND ? AND (?? IN (?, ?) OR role = ?) AND ?? IS NOT NULL',
|
||||
'(?? BETWEEN ? AND ?) AND (((?? IN (?, ?)) OR (role = ?))) AND (?? IS NOT NULL)',
|
||||
);
|
||||
expect(fragment.getKnexBindings()).toEqual([
|
||||
'age',
|
||||
expect(fragment.getKnexBindings(getColumnForField)).toEqual([
|
||||
'number_field',
|
||||
18,
|
||||
65,
|
||||
'status',
|
||||
'string_field',
|
||||
'active',
|
||||
'premium',
|
||||
'admin',
|
||||
'email',
|
||||
'test_index',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
+28
-28
@@ -13,11 +13,12 @@ import { v7 as uuidv7 } from 'uuid';
|
||||
|
||||
import {
|
||||
BasePostgresEntityDatabaseAdapter,
|
||||
NullsOrdering,
|
||||
OrderByOrdering,
|
||||
TableFieldMultiValueEqualityCondition,
|
||||
TableFieldSingleValueEqualityCondition,
|
||||
TableOrderByClause,
|
||||
TableQuerySelectionModifiers,
|
||||
TableQuerySelectionModifiersWithOrderByFragment,
|
||||
} from '../../BasePostgresEntityDatabaseAdapter';
|
||||
import { SQLFragment } from '../../SQLOperator';
|
||||
|
||||
@@ -105,11 +106,8 @@ export class StubPostgresDatabaseAdapter<
|
||||
return results[0] ?? null;
|
||||
}
|
||||
|
||||
private static compareByOrderBys(
|
||||
orderBys: {
|
||||
columnName: string;
|
||||
order: OrderByOrdering;
|
||||
}[],
|
||||
private static compareByOrderBys<TFields extends Record<string, any>>(
|
||||
orderBys: TableOrderByClause<TFields>[],
|
||||
objectA: { [key: string]: any },
|
||||
objectB: { [key: string]: any },
|
||||
): 0 | 1 | -1 {
|
||||
@@ -118,19 +116,30 @@ export class StubPostgresDatabaseAdapter<
|
||||
}
|
||||
|
||||
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
|
||||
@@ -138,15 +147,6 @@ export class StubPostgresDatabaseAdapter<
|
||||
: 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
|
||||
@@ -161,7 +161,7 @@ export class StubPostgresDatabaseAdapter<
|
||||
tableName: string,
|
||||
tableFieldSingleValueEqualityOperands: TableFieldSingleValueEqualityCondition[],
|
||||
tableFieldMultiValueEqualityOperands: TableFieldMultiValueEqualityCondition[],
|
||||
querySelectionModifiers: TableQuerySelectionModifiers,
|
||||
querySelectionModifiers: TableQuerySelectionModifiers<TFields>,
|
||||
): Promise<object[]> {
|
||||
let filteredObjects = this.getObjectCollectionForTable(tableName);
|
||||
for (const { tableField, tableValue } of tableFieldSingleValueEqualityOperands) {
|
||||
@@ -197,7 +197,7 @@ export class StubPostgresDatabaseAdapter<
|
||||
_tableName: string,
|
||||
_rawWhereClause: string,
|
||||
_bindings: object | any[],
|
||||
_querySelectionModifiers: TableQuerySelectionModifiers,
|
||||
_querySelectionModifiers: TableQuerySelectionModifiers<TFields>,
|
||||
): Promise<object[]> {
|
||||
throw new Error('Raw WHERE clauses not supported for StubDatabaseAdapter');
|
||||
}
|
||||
@@ -205,8 +205,8 @@ export class StubPostgresDatabaseAdapter<
|
||||
protected fetchManyBySQLFragmentInternalAsync(
|
||||
_queryInterface: any,
|
||||
_tableName: string,
|
||||
_sqlFragment: SQLFragment,
|
||||
_querySelectionModifiers: TableQuerySelectionModifiersWithOrderByFragment,
|
||||
_sqlFragment: SQLFragment<TFields>,
|
||||
_querySelectionModifiers: TableQuerySelectionModifiers<TFields>,
|
||||
): Promise<object[]> {
|
||||
throw new Error('SQL fragments not supported for StubDatabaseAdapter');
|
||||
}
|
||||
|
||||
-15
@@ -5,23 +5,8 @@ import {
|
||||
} from '@expo/entity';
|
||||
|
||||
import { StubPostgresDatabaseAdapter } from './StubPostgresDatabaseAdapter';
|
||||
import { installEntityCompanionExtensions } from '../../extensions/EntityCompanionExtensions';
|
||||
import { installEntityTableDataCoordinatorExtensions } from '../../extensions/EntityTableDataCoordinatorExtensions';
|
||||
import { installReadonlyEntityExtensions } from '../../extensions/ReadonlyEntityExtensions';
|
||||
import { installViewerScopedEntityCompanionExtensions } from '../../extensions/ViewerScopedEntityCompanionExtensions';
|
||||
|
||||
export class StubPostgresDatabaseAdapterProvider implements IEntityDatabaseAdapterProvider {
|
||||
getExtensionsKey(): string {
|
||||
return 'StubPostgresDatabaseAdapterProvider';
|
||||
}
|
||||
|
||||
installExtensions(): void {
|
||||
installEntityCompanionExtensions();
|
||||
installEntityTableDataCoordinatorExtensions();
|
||||
installViewerScopedEntityCompanionExtensions();
|
||||
installReadonlyEntityExtensions();
|
||||
}
|
||||
|
||||
private readonly objectCollection = new Map();
|
||||
|
||||
getDatabaseAdapter<TFields extends Record<string, any>, TIDField extends keyof TFields>(
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import {
|
||||
Entity,
|
||||
EntityCompanionDefinition,
|
||||
EntityConfiguration,
|
||||
DateField,
|
||||
@@ -12,6 +11,8 @@ import {
|
||||
} from '@expo/entity';
|
||||
import { result, Result } from '@expo/results';
|
||||
|
||||
import { PostgresEntity } from '../../PostgresEntity';
|
||||
|
||||
export type TestFields = {
|
||||
customIdField: string;
|
||||
testIndexedField: string;
|
||||
@@ -75,7 +76,7 @@ export class TestEntityPrivacyPolicy extends EntityPrivacyPolicy<
|
||||
];
|
||||
}
|
||||
|
||||
export class TestEntity extends Entity<TestFields, 'customIdField', ViewerContext> {
|
||||
export class TestEntity extends PostgresEntity<TestFields, 'customIdField', ViewerContext> {
|
||||
static defineCompanionDefinition(): EntityCompanionDefinition<
|
||||
TestFields,
|
||||
'customIdField',
|
||||
|
||||
+7
-2
@@ -1,5 +1,4 @@
|
||||
import {
|
||||
Entity,
|
||||
EntityCompanionDefinition,
|
||||
EntityConfiguration,
|
||||
EntityPrivacyPolicy,
|
||||
@@ -13,6 +12,8 @@ import {
|
||||
RuleEvaluationResult,
|
||||
} from '@expo/entity';
|
||||
|
||||
import { PostgresEntity } from '../../PostgresEntity';
|
||||
|
||||
export interface TestPaginationFields {
|
||||
id: string;
|
||||
name: string;
|
||||
@@ -85,7 +86,11 @@ export class TestPaginationPrivacyPolicy extends EntityPrivacyPolicy<
|
||||
protected override readonly deleteRules = [];
|
||||
}
|
||||
|
||||
export class TestPaginationEntity extends Entity<TestPaginationFields, 'id', ViewerContext> {
|
||||
export class TestPaginationEntity extends PostgresEntity<
|
||||
TestPaginationFields,
|
||||
'id',
|
||||
ViewerContext
|
||||
> {
|
||||
static defineCompanionDefinition(): EntityCompanionDefinition<
|
||||
TestPaginationFields,
|
||||
'id',
|
||||
|
||||
@@ -1,81 +0,0 @@
|
||||
import { EntityCompanion, EntityPrivacyPolicy, ReadonlyEntity, ViewerContext } from '@expo/entity';
|
||||
|
||||
import { KnexEntityLoaderFactory } from '../KnexEntityLoaderFactory';
|
||||
|
||||
const KNEX_LOADER_FACTORY = Symbol('knexLoaderFactory');
|
||||
|
||||
declare module '@expo/entity' {
|
||||
interface EntityCompanion<
|
||||
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,
|
||||
> {
|
||||
[KNEX_LOADER_FACTORY]:
|
||||
| KnexEntityLoaderFactory<
|
||||
TFields,
|
||||
TIDField,
|
||||
TViewerContext,
|
||||
TEntity,
|
||||
TPrivacyPolicy,
|
||||
TSelectedFields
|
||||
>
|
||||
| undefined;
|
||||
|
||||
getKnexLoaderFactory(): KnexEntityLoaderFactory<
|
||||
TFields,
|
||||
TIDField,
|
||||
TViewerContext,
|
||||
TEntity,
|
||||
TPrivacyPolicy,
|
||||
TSelectedFields
|
||||
>;
|
||||
}
|
||||
}
|
||||
|
||||
export function installEntityCompanionExtensions(): void {
|
||||
EntityCompanion.prototype.getKnexLoaderFactory = function <
|
||||
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,
|
||||
>(
|
||||
this: EntityCompanion<
|
||||
TFields,
|
||||
TIDField,
|
||||
TViewerContext,
|
||||
TEntity,
|
||||
TPrivacyPolicy,
|
||||
TSelectedFields
|
||||
>,
|
||||
): KnexEntityLoaderFactory<
|
||||
TFields,
|
||||
TIDField,
|
||||
TViewerContext,
|
||||
TEntity,
|
||||
TPrivacyPolicy,
|
||||
TSelectedFields
|
||||
> {
|
||||
return (this[KNEX_LOADER_FACTORY] ??= new KnexEntityLoaderFactory(
|
||||
this,
|
||||
this.tableDataCoordinator.getKnexDataManager(),
|
||||
this.metricsAdapter,
|
||||
));
|
||||
};
|
||||
}
|
||||
-44
@@ -1,44 +0,0 @@
|
||||
import { EntityDatabaseAdapter, EntityTableDataCoordinator } from '@expo/entity';
|
||||
import assert from 'assert';
|
||||
|
||||
import { BasePostgresEntityDatabaseAdapter } from '../BasePostgresEntityDatabaseAdapter';
|
||||
import { EntityKnexDataManager } from '../internal/EntityKnexDataManager';
|
||||
|
||||
const KNEX_DATA_MANAGER = Symbol('knexDataManager');
|
||||
|
||||
declare module '@expo/entity' {
|
||||
interface EntityTableDataCoordinator<
|
||||
TFields extends Record<string, any>,
|
||||
TIDField extends keyof TFields,
|
||||
> {
|
||||
[KNEX_DATA_MANAGER]: EntityKnexDataManager<TFields, TIDField> | undefined;
|
||||
getKnexDataManager(): EntityKnexDataManager<TFields, TIDField>;
|
||||
}
|
||||
}
|
||||
|
||||
function requireBasePostgresAdapter<
|
||||
TFields extends Record<string, any>,
|
||||
TIDField extends keyof TFields,
|
||||
>(
|
||||
databaseAdapter: EntityDatabaseAdapter<TFields, TIDField>,
|
||||
): BasePostgresEntityDatabaseAdapter<TFields, TIDField> {
|
||||
assert(
|
||||
databaseAdapter instanceof BasePostgresEntityDatabaseAdapter,
|
||||
`Cannot create KnexDataManager for EntityTableDataCoordinator with non-Postgres database adapter.`,
|
||||
);
|
||||
return databaseAdapter;
|
||||
}
|
||||
|
||||
export function installEntityTableDataCoordinatorExtensions(): void {
|
||||
EntityTableDataCoordinator.prototype.getKnexDataManager = function <
|
||||
TFields extends Record<string, any>,
|
||||
TIDField extends keyof TFields,
|
||||
>(this: EntityTableDataCoordinator<TFields, TIDField>): EntityKnexDataManager<TFields, TIDField> {
|
||||
return (this[KNEX_DATA_MANAGER] ??= new EntityKnexDataManager(
|
||||
this.entityConfiguration,
|
||||
requireBasePostgresAdapter(this.databaseAdapter),
|
||||
this.metricsAdapter,
|
||||
this.entityClassName,
|
||||
));
|
||||
};
|
||||
}
|
||||
@@ -1,179 +0,0 @@
|
||||
import {
|
||||
EntityPrivacyPolicy,
|
||||
EntityQueryContext,
|
||||
IEntityClass,
|
||||
ReadonlyEntity,
|
||||
ViewerContext,
|
||||
} from '@expo/entity';
|
||||
|
||||
import { AuthorizationResultBasedKnexEntityLoader } from '../AuthorizationResultBasedKnexEntityLoader';
|
||||
import { EnforcingKnexEntityLoader } from '../EnforcingKnexEntityLoader';
|
||||
import { KnexEntityLoader } from '../KnexEntityLoader';
|
||||
|
||||
declare module '@expo/entity' {
|
||||
namespace ReadonlyEntity {
|
||||
export function knexLoader<
|
||||
TMFields extends object,
|
||||
TMIDField extends keyof NonNullable<Pick<TMFields, TMSelectedFields>>,
|
||||
TMViewerContext extends ViewerContext,
|
||||
TMViewerContext2 extends TMViewerContext,
|
||||
TMEntity extends ReadonlyEntity<TMFields, TMIDField, TMViewerContext, TMSelectedFields>,
|
||||
TMPrivacyPolicy extends EntityPrivacyPolicy<
|
||||
TMFields,
|
||||
TMIDField,
|
||||
TMViewerContext,
|
||||
TMEntity,
|
||||
TMSelectedFields
|
||||
>,
|
||||
TMSelectedFields extends keyof TMFields = keyof TMFields,
|
||||
>(
|
||||
this: IEntityClass<
|
||||
TMFields,
|
||||
TMIDField,
|
||||
TMViewerContext,
|
||||
TMEntity,
|
||||
TMPrivacyPolicy,
|
||||
TMSelectedFields
|
||||
>,
|
||||
viewerContext: TMViewerContext2,
|
||||
queryContext?: EntityQueryContext,
|
||||
): EnforcingKnexEntityLoader<
|
||||
TMFields,
|
||||
TMIDField,
|
||||
TMViewerContext,
|
||||
TMEntity,
|
||||
TMPrivacyPolicy,
|
||||
TMSelectedFields
|
||||
>;
|
||||
|
||||
export function knexLoaderWithAuthorizationResults<
|
||||
TMFields extends object,
|
||||
TMIDField extends keyof NonNullable<Pick<TMFields, TMSelectedFields>>,
|
||||
TMViewerContext extends ViewerContext,
|
||||
TMViewerContext2 extends TMViewerContext,
|
||||
TMEntity extends ReadonlyEntity<TMFields, TMIDField, TMViewerContext, TMSelectedFields>,
|
||||
TMPrivacyPolicy extends EntityPrivacyPolicy<
|
||||
TMFields,
|
||||
TMIDField,
|
||||
TMViewerContext,
|
||||
TMEntity,
|
||||
TMSelectedFields
|
||||
>,
|
||||
TMSelectedFields extends keyof TMFields = keyof TMFields,
|
||||
>(
|
||||
this: IEntityClass<
|
||||
TMFields,
|
||||
TMIDField,
|
||||
TMViewerContext,
|
||||
TMEntity,
|
||||
TMPrivacyPolicy,
|
||||
TMSelectedFields
|
||||
>,
|
||||
viewerContext: TMViewerContext2,
|
||||
queryContext?: EntityQueryContext,
|
||||
): AuthorizationResultBasedKnexEntityLoader<
|
||||
TMFields,
|
||||
TMIDField,
|
||||
TMViewerContext,
|
||||
TMEntity,
|
||||
TMPrivacyPolicy,
|
||||
TMSelectedFields
|
||||
>;
|
||||
}
|
||||
}
|
||||
|
||||
class ReadonlyEntityExtensions {
|
||||
/**
|
||||
* Vend knex loader for loading entities via non-data-loader methods in a given query context.
|
||||
* @param viewerContext - viewer context of loading user
|
||||
* @param queryContext - query context in which to perform the load
|
||||
*/
|
||||
static knexLoader<
|
||||
TMFields extends object,
|
||||
TMIDField extends keyof NonNullable<Pick<TMFields, TMSelectedFields>>,
|
||||
TMViewerContext extends ViewerContext,
|
||||
TMViewerContext2 extends TMViewerContext,
|
||||
TMEntity extends ReadonlyEntity<TMFields, TMIDField, TMViewerContext, TMSelectedFields>,
|
||||
TMPrivacyPolicy extends EntityPrivacyPolicy<
|
||||
TMFields,
|
||||
TMIDField,
|
||||
TMViewerContext,
|
||||
TMEntity,
|
||||
TMSelectedFields
|
||||
>,
|
||||
TMSelectedFields extends keyof TMFields = keyof TMFields,
|
||||
>(
|
||||
this: IEntityClass<
|
||||
TMFields,
|
||||
TMIDField,
|
||||
TMViewerContext,
|
||||
TMEntity,
|
||||
TMPrivacyPolicy,
|
||||
TMSelectedFields
|
||||
>,
|
||||
viewerContext: TMViewerContext2,
|
||||
queryContext: EntityQueryContext = viewerContext
|
||||
.getViewerScopedEntityCompanionForClass(this)
|
||||
.getQueryContextProvider()
|
||||
.getQueryContext(),
|
||||
): EnforcingKnexEntityLoader<
|
||||
TMFields,
|
||||
TMIDField,
|
||||
TMViewerContext,
|
||||
TMEntity,
|
||||
TMPrivacyPolicy,
|
||||
TMSelectedFields
|
||||
> {
|
||||
return new KnexEntityLoader(viewerContext, queryContext, this).enforcing();
|
||||
}
|
||||
|
||||
/**
|
||||
* Vend knex loader for loading entities via non-data-loader methods in a given query context.
|
||||
* @param viewerContext - viewer context of loading user
|
||||
* @param queryContext - query context in which to perform the load
|
||||
*/
|
||||
static knexLoaderWithAuthorizationResults<
|
||||
TMFields extends object,
|
||||
TMIDField extends keyof NonNullable<Pick<TMFields, TMSelectedFields>>,
|
||||
TMViewerContext extends ViewerContext,
|
||||
TMViewerContext2 extends TMViewerContext,
|
||||
TMEntity extends ReadonlyEntity<TMFields, TMIDField, TMViewerContext, TMSelectedFields>,
|
||||
TMPrivacyPolicy extends EntityPrivacyPolicy<
|
||||
TMFields,
|
||||
TMIDField,
|
||||
TMViewerContext,
|
||||
TMEntity,
|
||||
TMSelectedFields
|
||||
>,
|
||||
TMSelectedFields extends keyof TMFields = keyof TMFields,
|
||||
>(
|
||||
this: IEntityClass<
|
||||
TMFields,
|
||||
TMIDField,
|
||||
TMViewerContext,
|
||||
TMEntity,
|
||||
TMPrivacyPolicy,
|
||||
TMSelectedFields
|
||||
>,
|
||||
viewerContext: TMViewerContext2,
|
||||
queryContext: EntityQueryContext = viewerContext
|
||||
.getViewerScopedEntityCompanionForClass(this)
|
||||
.getQueryContextProvider()
|
||||
.getQueryContext(),
|
||||
): AuthorizationResultBasedKnexEntityLoader<
|
||||
TMFields,
|
||||
TMIDField,
|
||||
TMViewerContext,
|
||||
TMEntity,
|
||||
TMPrivacyPolicy,
|
||||
TMSelectedFields
|
||||
> {
|
||||
return new KnexEntityLoader(viewerContext, queryContext, this).withAuthorizationResults();
|
||||
}
|
||||
}
|
||||
|
||||
export function installReadonlyEntityExtensions(): void {
|
||||
ReadonlyEntity.knexLoader = ReadonlyEntityExtensions.knexLoader;
|
||||
ReadonlyEntity.knexLoaderWithAuthorizationResults =
|
||||
ReadonlyEntityExtensions.knexLoaderWithAuthorizationResults;
|
||||
}
|
||||
-72
@@ -1,72 +0,0 @@
|
||||
import {
|
||||
EntityPrivacyPolicy,
|
||||
ReadonlyEntity,
|
||||
ViewerContext,
|
||||
ViewerScopedEntityCompanion,
|
||||
} from '@expo/entity';
|
||||
|
||||
import { ViewerScopedKnexEntityLoaderFactory } from '../ViewerScopedKnexEntityLoaderFactory';
|
||||
|
||||
declare module '@expo/entity' {
|
||||
interface ViewerScopedEntityCompanion<
|
||||
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,
|
||||
> {
|
||||
getKnexLoaderFactory(): ViewerScopedKnexEntityLoaderFactory<
|
||||
TFields,
|
||||
TIDField,
|
||||
TViewerContext,
|
||||
TEntity,
|
||||
TPrivacyPolicy,
|
||||
TSelectedFields
|
||||
>;
|
||||
}
|
||||
}
|
||||
|
||||
export function installViewerScopedEntityCompanionExtensions(): void {
|
||||
ViewerScopedEntityCompanion.prototype.getKnexLoaderFactory = function <
|
||||
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,
|
||||
>(
|
||||
this: ViewerScopedEntityCompanion<
|
||||
TFields,
|
||||
TIDField,
|
||||
TViewerContext,
|
||||
TEntity,
|
||||
TPrivacyPolicy,
|
||||
TSelectedFields
|
||||
>,
|
||||
): ViewerScopedKnexEntityLoaderFactory<
|
||||
TFields,
|
||||
TIDField,
|
||||
TViewerContext,
|
||||
TEntity,
|
||||
TPrivacyPolicy,
|
||||
TSelectedFields
|
||||
> {
|
||||
return new ViewerScopedKnexEntityLoaderFactory(
|
||||
this.entityCompanion.getKnexLoaderFactory(),
|
||||
this.viewerContext,
|
||||
);
|
||||
};
|
||||
}
|
||||
@@ -9,16 +9,18 @@ export * from './BasePostgresEntityDatabaseAdapter';
|
||||
export * from './BaseSQLQueryBuilder';
|
||||
export * from './EnforcingKnexEntityLoader';
|
||||
export * from './EntityFields';
|
||||
export * from './KnexEntityLoader';
|
||||
export * from './KnexEntityLoaderFactory';
|
||||
export * from './knexLoader';
|
||||
export * from './PaginationStrategy';
|
||||
export * from './PostgresEntity';
|
||||
export * from './PostgresEntityDatabaseAdapter';
|
||||
export * from './PostgresEntityDatabaseAdapterProvider';
|
||||
export * from './PostgresEntityQueryContextProvider';
|
||||
export * from './ReadonlyPostgresEntity';
|
||||
export * from './SQLOperator';
|
||||
export * from './ViewerScopedKnexEntityLoaderFactory';
|
||||
export * from './errors/wrapNativePostgresCallAsync';
|
||||
export * from './extensions/EntityCompanionExtensions';
|
||||
export * from './extensions/EntityTableDataCoordinatorExtensions';
|
||||
export * from './extensions/ReadonlyEntityExtensions';
|
||||
export * from './extensions/ViewerScopedEntityCompanionExtensions';
|
||||
export * from './internal/EntityKnexDataManager';
|
||||
export * from './internal/getKnexDataManager';
|
||||
export * from './internal/getKnexEntityLoaderFactory';
|
||||
export * from './internal/utilityTypes';
|
||||
export * from './internal/weakMaps';
|
||||
@@ -12,48 +12,96 @@ import assert from 'assert';
|
||||
import {
|
||||
BasePostgresEntityDatabaseAdapter,
|
||||
FieldEqualityCondition,
|
||||
NullsOrdering,
|
||||
OrderByOrdering,
|
||||
PostgresOrderByClause,
|
||||
PostgresQuerySelectionModifiers,
|
||||
PostgresQuerySelectionModifiersWithOrderByFragment,
|
||||
PostgresQuerySelectionModifiersWithOrderByRaw,
|
||||
} from '../BasePostgresEntityDatabaseAdapter';
|
||||
import { SQLFragment, identifier, raw, sql } from '../SQLOperator';
|
||||
import { PaginationStrategy } from '../PaginationStrategy';
|
||||
import { SQLFragment, SQLFragmentHelpers, identifier, unsafeRaw, sql } from '../SQLOperator';
|
||||
import { DistributiveOmit, NonNullableKeys } from './utilityTypes';
|
||||
|
||||
/**
|
||||
* Base pagination arguments
|
||||
*/
|
||||
interface BasePaginationArgs<TFields extends Record<string, any>> {
|
||||
where?: SQLFragment;
|
||||
orderBy?: PostgresOrderByClause<TFields>[];
|
||||
interface DataManagerStandardSpecification<TFields extends Record<string, any>> {
|
||||
strategy: PaginationStrategy.STANDARD;
|
||||
orderBy: readonly PostgresOrderByClause<TFields>[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Forward pagination arguments
|
||||
*/
|
||||
export interface ForwardPaginationArgs<
|
||||
type DataManagerFieldNameConstructorFn<TFields extends Record<string, any>> = (
|
||||
fieldName: keyof TFields,
|
||||
) => SQLFragment<TFields>;
|
||||
|
||||
type DataManagerSearchFieldSQLFragmentFnSpecification<TFields extends Record<string, any>> = {
|
||||
fieldConstructor: (
|
||||
getFragmentForFieldName: DataManagerFieldNameConstructorFn<TFields>,
|
||||
) => SQLFragment<TFields>;
|
||||
};
|
||||
|
||||
function isDataManagerSearchFieldSQLFragmentFnSpecification<TFields extends Record<string, any>>(
|
||||
obj:
|
||||
| keyof TFields
|
||||
| SQLFragment<TFields>
|
||||
| DataManagerSearchFieldSQLFragmentFnSpecification<TFields>,
|
||||
): obj is DataManagerSearchFieldSQLFragmentFnSpecification<TFields> {
|
||||
return typeof obj === 'object' && obj !== null && 'fieldConstructor' in obj;
|
||||
}
|
||||
|
||||
type DataManagerSearchFieldSpecification<TFields extends Record<string, any>> =
|
||||
| NonNullableKeys<TFields>
|
||||
| DataManagerSearchFieldSQLFragmentFnSpecification<TFields>;
|
||||
|
||||
interface DataManagerSearchSpecificationBase<TFields extends Record<string, any>> {
|
||||
term: string;
|
||||
fields: readonly DataManagerSearchFieldSpecification<TFields>[];
|
||||
}
|
||||
|
||||
interface DataManagerILikeSearchSpecification<
|
||||
TFields extends Record<string, any>,
|
||||
> extends BasePaginationArgs<TFields> {
|
||||
> extends DataManagerSearchSpecificationBase<TFields> {
|
||||
strategy: PaginationStrategy.ILIKE_SEARCH;
|
||||
}
|
||||
|
||||
interface DataManagerTrigramSearchSpecification<
|
||||
TFields extends Record<string, any>,
|
||||
> extends DataManagerSearchSpecificationBase<TFields> {
|
||||
strategy: PaginationStrategy.TRIGRAM_SEARCH;
|
||||
threshold: number;
|
||||
extraOrderByFields?: readonly DataManagerSearchFieldSpecification<TFields>[];
|
||||
}
|
||||
|
||||
type DataManagerSearchSpecification<TFields extends Record<string, any>> =
|
||||
| DataManagerILikeSearchSpecification<TFields>
|
||||
| DataManagerTrigramSearchSpecification<TFields>;
|
||||
|
||||
type DataManagerPaginationSpecification<TFields extends Record<string, any>> =
|
||||
| DataManagerStandardSpecification<TFields>
|
||||
| DataManagerSearchSpecification<TFields>;
|
||||
|
||||
interface BaseUnifiedPaginationArgs<TFields extends Record<string, any>> {
|
||||
where?: SQLFragment<TFields>;
|
||||
pagination: DataManagerPaginationSpecification<TFields>;
|
||||
}
|
||||
|
||||
interface ForwardUnifiedPaginationArgs<
|
||||
TFields extends Record<string, any>,
|
||||
> extends BaseUnifiedPaginationArgs<TFields> {
|
||||
first: number;
|
||||
last?: never;
|
||||
before?: never;
|
||||
after?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Backward pagination arguments
|
||||
*/
|
||||
export interface BackwardPaginationArgs<
|
||||
interface BackwardUnifiedPaginationArgs<
|
||||
TFields extends Record<string, any>,
|
||||
> extends BasePaginationArgs<TFields> {
|
||||
> extends BaseUnifiedPaginationArgs<TFields> {
|
||||
first?: never;
|
||||
last: number;
|
||||
before?: string;
|
||||
after?: never;
|
||||
}
|
||||
|
||||
/**
|
||||
* Combined pagination arguments using discriminated union
|
||||
*/
|
||||
export type LoadPageArgs<TFields extends Record<string, any>> =
|
||||
| ForwardPaginationArgs<TFields>
|
||||
| BackwardPaginationArgs<TFields>;
|
||||
type LoadPageArgs<TFields extends Record<string, any>> =
|
||||
| ForwardUnifiedPaginationArgs<TFields>
|
||||
| BackwardUnifiedPaginationArgs<TFields>;
|
||||
|
||||
/**
|
||||
* Edge in a connection
|
||||
@@ -77,10 +125,27 @@ export interface PageInfo {
|
||||
* Relay-style Connection type
|
||||
*/
|
||||
export interface Connection<TNode> {
|
||||
edges: Edge<TNode>[];
|
||||
edges: readonly Edge<TNode>[];
|
||||
pageInfo: PageInfo;
|
||||
}
|
||||
|
||||
enum PaginationDirection {
|
||||
FORWARD = 'forward',
|
||||
BACKWARD = 'backward',
|
||||
}
|
||||
|
||||
const CURSOR_ROW_TABLE_ALIAS = 'cursor_row';
|
||||
|
||||
interface PaginationProvider<TFields extends Record<string, any>, TIDField extends keyof TFields> {
|
||||
whereClause: SQLFragment<TFields> | undefined;
|
||||
buildOrderBy: (direction: PaginationDirection) => readonly PostgresOrderByClause<TFields>[];
|
||||
buildCursorCondition: (
|
||||
decodedCursorId: TFields[TIDField],
|
||||
direction: PaginationDirection,
|
||||
orderByClauses: readonly PostgresOrderByClause<TFields>[],
|
||||
) => SQLFragment<TFields>;
|
||||
}
|
||||
|
||||
/**
|
||||
* A knex data manager is responsible for handling non-dataloader-based
|
||||
* database operations.
|
||||
@@ -109,9 +174,11 @@ export class EntityKnexDataManager<
|
||||
*/
|
||||
async loadManyByFieldEqualityConjunctionAsync<N extends keyof TFields>(
|
||||
queryContext: EntityQueryContext,
|
||||
fieldEqualityOperands: FieldEqualityCondition<TFields, N>[],
|
||||
fieldEqualityOperands: readonly FieldEqualityCondition<TFields, N>[],
|
||||
querySelectionModifiers: PostgresQuerySelectionModifiers<TFields>,
|
||||
): Promise<readonly Readonly<TFields>[]> {
|
||||
EntityKnexDataManager.validateOrderByClauses(querySelectionModifiers.orderBy);
|
||||
|
||||
return await timeAndLogLoadEventAsync(
|
||||
this.metricsAdapter,
|
||||
EntityMetricsLoadType.LOAD_MANY_EQUALITY_CONJUNCTION,
|
||||
@@ -138,9 +205,11 @@ export class EntityKnexDataManager<
|
||||
async loadManyByRawWhereClauseAsync(
|
||||
queryContext: EntityQueryContext,
|
||||
rawWhereClause: string,
|
||||
bindings: any[] | object,
|
||||
querySelectionModifiers: PostgresQuerySelectionModifiersWithOrderByRaw<TFields>,
|
||||
bindings: readonly any[] | object,
|
||||
querySelectionModifiers: PostgresQuerySelectionModifiers<TFields>,
|
||||
): Promise<readonly Readonly<TFields>[]> {
|
||||
EntityKnexDataManager.validateOrderByClauses(querySelectionModifiers.orderBy);
|
||||
|
||||
return await timeAndLogLoadEventAsync(
|
||||
this.metricsAdapter,
|
||||
EntityMetricsLoadType.LOAD_MANY_RAW,
|
||||
@@ -158,9 +227,11 @@ export class EntityKnexDataManager<
|
||||
|
||||
async loadManyBySQLFragmentAsync(
|
||||
queryContext: EntityQueryContext,
|
||||
sqlFragment: SQLFragment,
|
||||
querySelectionModifiers: PostgresQuerySelectionModifiersWithOrderByFragment<TFields>,
|
||||
sqlFragment: SQLFragment<TFields>,
|
||||
querySelectionModifiers: PostgresQuerySelectionModifiers<TFields>,
|
||||
): Promise<readonly Readonly<TFields>[]> {
|
||||
EntityKnexDataManager.validateOrderByClauses(querySelectionModifiers.orderBy);
|
||||
|
||||
return await timeAndLogLoadEventAsync(
|
||||
this.metricsAdapter,
|
||||
EntityMetricsLoadType.LOAD_MANY_SQL,
|
||||
@@ -176,91 +247,218 @@ export class EntityKnexDataManager<
|
||||
}
|
||||
|
||||
/**
|
||||
* Load a page of objects using cursor-based pagination.
|
||||
* Load a page of objects using cursor-based pagination with unified pagination specification.
|
||||
*
|
||||
* @remarks
|
||||
*
|
||||
* This method implements cursor-based pagination using the seek method for efficient pagination even on large datasets
|
||||
* given appropriate indexes. Cursors are opaque and encode the necessary information to fetch the next page based on the
|
||||
* specified pagination strategy (standard, ILIKE search, or trigram search). For this implementation in particular,
|
||||
* the cursor encodes the ID of the last entity in the page to ensure correct pagination for all strategies, even in cases
|
||||
* where multiple rows have the same value for all fields other than the ID. If the entity referenced by a cursor has been
|
||||
* deleted, the load will return an empty page with `hasNextPage: false`.
|
||||
*
|
||||
* @param queryContext - query context in which to perform the load
|
||||
* @param args - pagination arguments including first/after or last/before
|
||||
* @param idField - the ID field name for the entity
|
||||
* @param args - pagination arguments including pagination and first/after or last/before
|
||||
* @returns connection with edges containing field objects and page info
|
||||
*/
|
||||
async loadPageBySQLFragmentAsync(
|
||||
async loadPageAsync(
|
||||
queryContext: EntityQueryContext,
|
||||
args: LoadPageArgs<TFields>,
|
||||
): Promise<Connection<Readonly<TFields>>> {
|
||||
const { where, pagination } = args;
|
||||
|
||||
if (pagination.strategy === PaginationStrategy.STANDARD) {
|
||||
// Standard pagination
|
||||
EntityKnexDataManager.validateOrderByClauses(pagination.orderBy);
|
||||
EntityKnexDataManager.validateOrderByClausesHaveConsistentDirection(pagination.orderBy);
|
||||
|
||||
const idField = this.entityConfiguration.idField;
|
||||
const augmentedOrderByClauses = this.augmentOrderByIfNecessary(pagination.orderBy, idField);
|
||||
|
||||
const fieldsToUseInPostgresTupleCursor: readonly (keyof TFields | SQLFragment<TFields>)[] =
|
||||
augmentedOrderByClauses.map((order) =>
|
||||
'fieldName' in order ? order.fieldName : order.fieldFragment,
|
||||
);
|
||||
|
||||
// Create strategy for regular pagination
|
||||
const strategy: PaginationProvider<TFields, TIDField> = {
|
||||
whereClause: where,
|
||||
buildOrderBy: (direction: PaginationDirection) => {
|
||||
// For backward pagination, we flip the ORDER BY and NULLS direction to fetch records
|
||||
// in reverse order. This allows us to use a simple "less than" cursor comparison
|
||||
// instead of complex SQL. We'll reverse the results array later to restore
|
||||
// the original requested order.
|
||||
// Example: If user wants last 3 items ordered by name ASC, we:
|
||||
// 1. Flip to name DESC to get the last items first
|
||||
// 2. Apply cursor with < comparison
|
||||
// 3. Reverse the final array to present items in name ASC order
|
||||
return direction === PaginationDirection.FORWARD
|
||||
? augmentedOrderByClauses
|
||||
: augmentedOrderByClauses.map(
|
||||
(clause): PostgresOrderByClause<TFields> => ({
|
||||
...clause,
|
||||
order:
|
||||
clause.order === OrderByOrdering.ASCENDING
|
||||
? OrderByOrdering.DESCENDING
|
||||
: OrderByOrdering.ASCENDING,
|
||||
nulls: clause.nulls
|
||||
? EntityKnexDataManager.flipNullsOrderingSpread(clause.nulls)
|
||||
: undefined,
|
||||
}),
|
||||
);
|
||||
},
|
||||
buildCursorCondition: (decodedCursorId, _direction, orderByClauses) => {
|
||||
// all clauses are guaranteed to have the same order due to validation, so we can just look at the first one for effective ordering
|
||||
const effectiveOrdering = orderByClauses[0]?.order ?? OrderByOrdering.ASCENDING;
|
||||
return this.buildCursorCondition(
|
||||
decodedCursorId,
|
||||
fieldsToUseInPostgresTupleCursor,
|
||||
effectiveOrdering,
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
return await this.loadPageInternalAsync(queryContext, args, strategy);
|
||||
} else {
|
||||
// Search pagination (ILIKE or TRIGRAM)
|
||||
const search = pagination;
|
||||
|
||||
// Validate search parameters
|
||||
assert(search.term.length > 0, 'Search term must be a non-empty string');
|
||||
assert(search.fields.length > 0, 'Search fields must be a non-empty array');
|
||||
|
||||
const { searchWhere, searchOrderByClauses } = this.buildSearchConditionAndOrderBy(search);
|
||||
|
||||
// Combine WHERE conditions: base where + search where
|
||||
const whereClause =
|
||||
where && searchWhere ? SQLFragmentHelpers.and(where, searchWhere) : (where ?? searchWhere);
|
||||
|
||||
const fieldsToUseInPostgresTupleCursor =
|
||||
search.strategy === PaginationStrategy.TRIGRAM_SEARCH
|
||||
? // For trigram search, cursor includes extra order by fields (if specified) + ID to ensure stable ordering that matches ORDER BY clause
|
||||
[...(search.extraOrderByFields ?? []), this.entityConfiguration.idField]
|
||||
: // For ILIKE search, cursor includes search fields + ID to ensure stable ordering that matches ORDER BY clause
|
||||
[...search.fields, this.entityConfiguration.idField];
|
||||
|
||||
// Create strategy for search pagination
|
||||
const strategy: PaginationProvider<TFields, TIDField> = {
|
||||
whereClause,
|
||||
buildOrderBy: (direction) => {
|
||||
return direction === PaginationDirection.FORWARD
|
||||
? searchOrderByClauses
|
||||
: searchOrderByClauses.map(
|
||||
(clause): DistributiveOmit<PostgresOrderByClause<TFields>, 'nulls'> => ({
|
||||
...clause,
|
||||
order:
|
||||
clause.order === OrderByOrdering.ASCENDING
|
||||
? OrderByOrdering.DESCENDING
|
||||
: OrderByOrdering.ASCENDING,
|
||||
}),
|
||||
);
|
||||
},
|
||||
buildCursorCondition: (decodedCursorId, direction) =>
|
||||
search.strategy === PaginationStrategy.TRIGRAM_SEARCH
|
||||
? this.buildTrigramCursorCondition(search, decodedCursorId, direction)
|
||||
: this.buildCursorCondition(
|
||||
decodedCursorId,
|
||||
fieldsToUseInPostgresTupleCursor,
|
||||
// ILIKE search always orders ASC, so effective ordering matches direction
|
||||
direction === PaginationDirection.FORWARD
|
||||
? OrderByOrdering.ASCENDING
|
||||
: OrderByOrdering.DESCENDING,
|
||||
),
|
||||
};
|
||||
|
||||
return await this.loadPageInternalAsync(queryContext, args, strategy);
|
||||
}
|
||||
}
|
||||
|
||||
getCursorForEntityID(entityID: TFields[TIDField]): string {
|
||||
return this.encodeOpaqueCursor(entityID);
|
||||
}
|
||||
|
||||
/**
|
||||
* Internal method for loading a page with cursor-based pagination.
|
||||
* Shared logic for both regular and search pagination.
|
||||
*/
|
||||
private async loadPageInternalAsync(
|
||||
queryContext: EntityQueryContext,
|
||||
args: LoadPageArgs<TFields>,
|
||||
paginationProvider: PaginationProvider<TFields, TIDField>,
|
||||
): Promise<Connection<Readonly<TFields>>> {
|
||||
const idField = this.entityConfiguration.idField;
|
||||
|
||||
// Validate pagination arguments
|
||||
if ('first' in args) {
|
||||
const maxPageSize = this.databaseAdapter.paginationMaxPageSize;
|
||||
const isForward = 'first' in args && args.first !== undefined;
|
||||
if (isForward) {
|
||||
assert(
|
||||
Number.isInteger(args.first) && args.first > 0,
|
||||
'first must be an integer greater than 0',
|
||||
);
|
||||
if (maxPageSize !== undefined) {
|
||||
assert(
|
||||
args.first <= maxPageSize,
|
||||
`first must not exceed maximum page size of ${maxPageSize}`,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
assert(
|
||||
Number.isInteger(args.last) && args.last > 0,
|
||||
'last must be an integer greater than 0',
|
||||
);
|
||||
if (maxPageSize !== undefined) {
|
||||
assert(
|
||||
args.last <= maxPageSize,
|
||||
`last must not exceed maximum page size of ${maxPageSize}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const isForward = 'first' in args;
|
||||
const { where, orderBy } = args;
|
||||
|
||||
let limit: number;
|
||||
let cursor: string | undefined;
|
||||
if (isForward) {
|
||||
limit = args.first;
|
||||
cursor = args.after;
|
||||
} else {
|
||||
limit = args.last;
|
||||
cursor = args.before;
|
||||
}
|
||||
|
||||
// Augment orderBy with ID field for stability
|
||||
const orderByClauses = this.augmentOrderByIfNecessary(orderBy, idField);
|
||||
|
||||
// Build cursor fields from orderBy + id for stability
|
||||
const fieldsToUseInPostgresCursor = orderByClauses.map((order) => order.fieldName);
|
||||
const direction = isForward ? PaginationDirection.FORWARD : PaginationDirection.BACKWARD;
|
||||
const limit = isForward ? args.first : args.last;
|
||||
const cursor = isForward ? args.after : args.before;
|
||||
|
||||
// Decode cursor
|
||||
const decodedExternalCursorEntityID = cursor ? this.decodeOpaqueCursor(cursor) : null;
|
||||
|
||||
// Build WHERE clause with cursor condition for keyset pagination
|
||||
const whereClause = this.buildWhereClause({
|
||||
...(where && { where }),
|
||||
decodedExternalCursorEntityID,
|
||||
fieldsToUseInPostgresCursor,
|
||||
direction: isForward ? 'forward' : 'backward',
|
||||
});
|
||||
// Get ordering from strategy
|
||||
const orderByClauses = paginationProvider.buildOrderBy(direction);
|
||||
|
||||
// Adjust order for backward pagination
|
||||
const finalOrderByClauses = isForward
|
||||
? orderByClauses
|
||||
: orderByClauses.map((clause) => ({
|
||||
fieldName: clause.fieldName,
|
||||
order:
|
||||
clause.order === OrderByOrdering.ASCENDING
|
||||
? OrderByOrdering.DESCENDING
|
||||
: OrderByOrdering.ASCENDING,
|
||||
}));
|
||||
// Build WHERE clause with cursor condition
|
||||
const baseWhere = paginationProvider.whereClause;
|
||||
const cursorCondition = decodedExternalCursorEntityID
|
||||
? paginationProvider.buildCursorCondition(
|
||||
decodedExternalCursorEntityID,
|
||||
direction,
|
||||
orderByClauses,
|
||||
)
|
||||
: null;
|
||||
|
||||
const whereClause = this.combineWhereConditions(baseWhere, cursorCondition);
|
||||
|
||||
// Determine query modifiers
|
||||
const queryModifiers: PostgresQuerySelectionModifiers<TFields> = {
|
||||
...(orderByClauses !== undefined && { orderBy: orderByClauses }),
|
||||
limit: limit + 1, // Fetch data with limit + 1 to check for more pages
|
||||
};
|
||||
|
||||
// Fetch data with limit + 1 to check for more pages
|
||||
const fieldObjects = await timeAndLogLoadEventAsync(
|
||||
this.metricsAdapter,
|
||||
EntityMetricsLoadType.LOAD_PAGE,
|
||||
this.entityClassName,
|
||||
queryContext,
|
||||
)(
|
||||
this.databaseAdapter.fetchManyBySQLFragmentAsync(queryContext, whereClause, {
|
||||
orderBy: finalOrderByClauses,
|
||||
limit: limit + 1,
|
||||
}),
|
||||
);
|
||||
)(this.databaseAdapter.fetchManyBySQLFragmentAsync(queryContext, whereClause, queryModifiers));
|
||||
|
||||
// Process results
|
||||
const hasMore = fieldObjects.length > limit;
|
||||
const pageFieldObjects = hasMore ? fieldObjects.slice(0, limit) : [...fieldObjects];
|
||||
|
||||
if (!isForward) {
|
||||
if (direction === PaginationDirection.BACKWARD) {
|
||||
// Restore the original requested order by reversing the results.
|
||||
// We fetched with flipped ORDER BY for efficient cursor comparison,
|
||||
// so now we reverse to match the order the user expects.
|
||||
pageFieldObjects.reverse();
|
||||
}
|
||||
|
||||
@@ -271,8 +469,8 @@ export class EntityKnexDataManager<
|
||||
}));
|
||||
|
||||
const pageInfo: PageInfo = {
|
||||
hasNextPage: isForward ? hasMore : false,
|
||||
hasPreviousPage: !isForward ? hasMore : false,
|
||||
hasNextPage: direction === PaginationDirection.FORWARD ? hasMore : false,
|
||||
hasPreviousPage: direction === PaginationDirection.BACKWARD ? hasMore : false,
|
||||
startCursor: edges[0]?.cursor ?? null,
|
||||
endCursor: edges[edges.length - 1]?.cursor ?? null,
|
||||
};
|
||||
@@ -283,20 +481,81 @@ export class EntityKnexDataManager<
|
||||
};
|
||||
}
|
||||
|
||||
private combineWhereConditions(
|
||||
baseWhere: SQLFragment<TFields> | undefined,
|
||||
cursorCondition: SQLFragment<TFields> | null,
|
||||
): SQLFragment<TFields> {
|
||||
const conditions = [baseWhere, cursorCondition].filter((it) => !!it);
|
||||
if (conditions.length === 0) {
|
||||
return sql`1 = 1`;
|
||||
}
|
||||
if (conditions.length === 1) {
|
||||
return conditions[0]!;
|
||||
}
|
||||
// Wrap baseWhere in parens if combining with cursor condition
|
||||
// We know we have exactly 2 conditions at this point
|
||||
const [first, second] = conditions;
|
||||
return sql`(${first}) AND ${second}`;
|
||||
}
|
||||
|
||||
private augmentOrderByIfNecessary(
|
||||
orderBy: PostgresOrderByClause<TFields>[] | undefined,
|
||||
orderBy: readonly PostgresOrderByClause<TFields>[] | undefined,
|
||||
idField: TIDField,
|
||||
): PostgresOrderByClause<TFields>[] {
|
||||
): readonly PostgresOrderByClause<TFields>[] {
|
||||
const clauses = orderBy ?? [];
|
||||
|
||||
// Always ensure ID is included for stability and cursor correctness
|
||||
const hasId = clauses.some((spec) => spec.fieldName === idField);
|
||||
// Always ensure ID is included for stability and cursor correctness. Note that this may add a redundant order by
|
||||
// if ID is already included as a fragment, but that is preferable to the risk of incorrect pagination behavior.
|
||||
const hasId = clauses.some((spec) => 'fieldName' in spec && spec.fieldName === idField);
|
||||
if (!hasId) {
|
||||
return [...clauses, { fieldName: idField, order: OrderByOrdering.ASCENDING }];
|
||||
const lastClauseOrder =
|
||||
clauses.length > 0 ? clauses[clauses.length - 1]!.order : OrderByOrdering.ASCENDING;
|
||||
return [...clauses, { fieldName: idField, order: lastClauseOrder }];
|
||||
}
|
||||
return clauses;
|
||||
}
|
||||
|
||||
private static flipNullsOrderingSpread(
|
||||
nulls: NullsOrdering | undefined,
|
||||
): NullsOrdering | undefined {
|
||||
return nulls === NullsOrdering.FIRST ? NullsOrdering.LAST : NullsOrdering.FIRST;
|
||||
}
|
||||
|
||||
private static validateOrderByClauses<TFields extends Record<string, any>>(
|
||||
orderBy: readonly PostgresOrderByClause<TFields>[] | undefined,
|
||||
): void {
|
||||
if (orderBy === undefined) {
|
||||
return;
|
||||
}
|
||||
for (const clause of orderBy) {
|
||||
if ('fieldFragment' in clause) {
|
||||
const trimmedSql = clause.fieldFragment.sql.trimEnd();
|
||||
assert(
|
||||
!/\b(ASC|DESC)\s*$/i.test(trimmedSql),
|
||||
'fieldFragment must not contain ASC or DESC at the end. Use the order property to specify ordering direction.',
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Cursor-based pagination uses Postgres tuple comparison (e.g., (a, b) \> (x, y)) which
|
||||
* applies a single comparison direction to all columns. Mixed ordering directions would
|
||||
* produce incorrect pagination results.
|
||||
*/
|
||||
private static validateOrderByClausesHaveConsistentDirection<TFields extends Record<string, any>>(
|
||||
orderBy: readonly PostgresOrderByClause<TFields>[] | undefined,
|
||||
): void {
|
||||
if (orderBy === undefined || orderBy.length <= 1) {
|
||||
return;
|
||||
}
|
||||
const firstOrder = orderBy[0]!.order;
|
||||
assert(
|
||||
orderBy.every((clause) => clause.order === firstOrder),
|
||||
'All orderBy clauses must have the same ordering direction. Mixed ordering directions are not supported with cursor-based pagination.',
|
||||
);
|
||||
}
|
||||
|
||||
private encodeOpaqueCursor(idField: TFields[TIDField]): string {
|
||||
return Buffer.from(JSON.stringify({ id: idField })).toString('base64url');
|
||||
}
|
||||
@@ -322,38 +581,47 @@ export class EntityKnexDataManager<
|
||||
return parsedCursor.id;
|
||||
}
|
||||
|
||||
private buildWhereClause(options: {
|
||||
where?: SQLFragment;
|
||||
decodedExternalCursorEntityID: TFields[TIDField] | null;
|
||||
fieldsToUseInPostgresCursor: readonly (keyof TFields)[];
|
||||
direction: 'forward' | 'backward';
|
||||
}): SQLFragment {
|
||||
const { where, decodedExternalCursorEntityID, fieldsToUseInPostgresCursor, direction } =
|
||||
options;
|
||||
private resolveSearchFieldToSQLFragment(
|
||||
field:
|
||||
| keyof TFields
|
||||
| SQLFragment<TFields>
|
||||
| DataManagerSearchFieldSQLFragmentFnSpecification<TFields>,
|
||||
tableAlias?: typeof CURSOR_ROW_TABLE_ALIAS,
|
||||
): SQLFragment<TFields> {
|
||||
if (field instanceof SQLFragment) {
|
||||
return field;
|
||||
}
|
||||
|
||||
const cursorCondition = decodedExternalCursorEntityID
|
||||
? this.buildCursorCondition(
|
||||
decodedExternalCursorEntityID,
|
||||
fieldsToUseInPostgresCursor,
|
||||
direction === 'forward' ? '>' : '<',
|
||||
)
|
||||
: null;
|
||||
if (isDataManagerSearchFieldSQLFragmentFnSpecification<TFields>(field)) {
|
||||
return field.fieldConstructor((fieldName) => {
|
||||
const dbField = getDatabaseFieldForEntityField(this.entityConfiguration, fieldName);
|
||||
return tableAlias
|
||||
? sql`${unsafeRaw(tableAlias)}.${identifier(dbField)}`
|
||||
: sql`${identifier(dbField)}`;
|
||||
});
|
||||
}
|
||||
|
||||
// Combine conditions
|
||||
const conditions = [where, cursorCondition].filter((it) => !!it);
|
||||
|
||||
// Return combined WHERE clause or "1 = 1" if no conditions
|
||||
return conditions.length > 0 ? SQLFragment.join(conditions, ' AND ') : sql`1 = 1`;
|
||||
const dbField = getDatabaseFieldForEntityField(this.entityConfiguration, field);
|
||||
return tableAlias
|
||||
? sql`${unsafeRaw(tableAlias)}.${identifier(dbField)}`
|
||||
: sql`${identifier(dbField)}`;
|
||||
}
|
||||
|
||||
private buildCursorCondition(
|
||||
decodedExternalCursorEntityID: TFields[TIDField],
|
||||
fieldsToUseInPostgresCursor: readonly (keyof TFields)[],
|
||||
operator: '<' | '>',
|
||||
): SQLFragment {
|
||||
// We build a tuple comparison for fieldsToUseInPostgresCursor fields of the
|
||||
fieldsToUseInPostgresTupleCursor: readonly (
|
||||
| keyof TFields
|
||||
| SQLFragment<TFields>
|
||||
| DataManagerSearchFieldSQLFragmentFnSpecification<TFields>
|
||||
)[],
|
||||
effectiveOrdering: OrderByOrdering,
|
||||
): SQLFragment<TFields> {
|
||||
// We build a tuple comparison for fieldsToUseInPostgresTupleCursor fields of the
|
||||
// entity identified by the external cursor to ensure correct pagination behavior
|
||||
// even in cases where multiple rows have the same value all fields other than id.
|
||||
// If the cursor entity has been deleted, the subquery returns no rows and the
|
||||
// comparison evaluates to NULL, filtering out all results (empty page).
|
||||
const operator = effectiveOrdering === OrderByOrdering.ASCENDING ? '>' : '<';
|
||||
|
||||
const idField = getDatabaseFieldForEntityField(
|
||||
this.entityConfiguration,
|
||||
@@ -361,26 +629,204 @@ export class EntityKnexDataManager<
|
||||
);
|
||||
const tableName = this.entityConfiguration.tableName;
|
||||
|
||||
const postgresCursorFieldIdentifiers = fieldsToUseInPostgresCursor.map((f) => {
|
||||
const dbField = getDatabaseFieldForEntityField(this.entityConfiguration, f);
|
||||
return sql`${identifier(dbField)}`;
|
||||
});
|
||||
const postgresCursorFieldIdentifiers = fieldsToUseInPostgresTupleCursor.map((f) =>
|
||||
this.resolveSearchFieldToSQLFragment(f),
|
||||
);
|
||||
|
||||
// Build left side of comparison (current row's computed values)
|
||||
const leftSide = SQLFragment.join(postgresCursorFieldIdentifiers, ', ');
|
||||
const leftSide = SQLFragment.joinWithCommaSeparator(...postgresCursorFieldIdentifiers);
|
||||
|
||||
// Build right side using subquery to get computed values for cursor entity
|
||||
const postgresCursorRowFieldIdentifiers = fieldsToUseInPostgresCursor.map((f) => {
|
||||
const dbField = getDatabaseFieldForEntityField(this.entityConfiguration, f);
|
||||
return sql`cursor_row.${identifier(dbField)}`;
|
||||
});
|
||||
// Build right side using subquery to get computed values for cursor entity.
|
||||
// For field names, qualify with the cursor row alias. For SQL fragments,
|
||||
// use as-is since unqualified column names resolve to the only table in the subquery.
|
||||
const postgresCursorRowFieldIdentifiers = fieldsToUseInPostgresTupleCursor.map((f) =>
|
||||
this.resolveSearchFieldToSQLFragment(f, CURSOR_ROW_TABLE_ALIAS),
|
||||
);
|
||||
|
||||
// Build SELECT fields for subquery
|
||||
const rightSideSubquery = sql`
|
||||
SELECT ${SQLFragment.join(postgresCursorRowFieldIdentifiers, ', ')}
|
||||
FROM ${identifier(tableName)} AS cursor_row
|
||||
WHERE cursor_row.${identifier(idField)} = ${decodedExternalCursorEntityID}
|
||||
SELECT ${SQLFragment.joinWithCommaSeparator(...postgresCursorRowFieldIdentifiers)}
|
||||
FROM ${identifier(tableName)} AS ${unsafeRaw(CURSOR_ROW_TABLE_ALIAS)}
|
||||
WHERE ${unsafeRaw(CURSOR_ROW_TABLE_ALIAS)}.${identifier(idField)} = ${decodedExternalCursorEntityID}
|
||||
`;
|
||||
return sql`(${leftSide}) ${raw(operator)} (${rightSideSubquery})`;
|
||||
return sql`(${leftSide}) ${unsafeRaw(operator)} (${rightSideSubquery})`;
|
||||
}
|
||||
|
||||
private buildILikeConditions(
|
||||
search: DataManagerSearchSpecification<TFields>,
|
||||
tableAlias?: typeof CURSOR_ROW_TABLE_ALIAS,
|
||||
): readonly SQLFragment<TFields>[] {
|
||||
return search.fields.map((field) => {
|
||||
const fieldFragment = this.resolveSearchFieldToSQLFragment(field, tableAlias);
|
||||
return sql`${fieldFragment} ILIKE ${'%' + EntityKnexDataManager.escapeILikePattern(search.term) + '%'}`;
|
||||
});
|
||||
}
|
||||
|
||||
private buildTrigramSimilarityExpressions(
|
||||
search: DataManagerSearchSpecification<TFields>,
|
||||
tableAlias?: typeof CURSOR_ROW_TABLE_ALIAS,
|
||||
): readonly SQLFragment<TFields>[] {
|
||||
return search.fields.map((field) => {
|
||||
const fieldFragment = this.resolveSearchFieldToSQLFragment(field, tableAlias);
|
||||
return sql`similarity(${fieldFragment}, ${search.term})`;
|
||||
});
|
||||
}
|
||||
|
||||
private buildTrigramExactMatchCaseExpression(
|
||||
search: DataManagerSearchSpecification<TFields>,
|
||||
tableAlias?: typeof CURSOR_ROW_TABLE_ALIAS,
|
||||
): SQLFragment<TFields> {
|
||||
const ilikeConditions = this.buildILikeConditions(search, tableAlias);
|
||||
return sql`CASE WHEN ${SQLFragmentHelpers.or(...ilikeConditions)} THEN 1 ELSE 0 END`;
|
||||
}
|
||||
|
||||
private buildTrigramSimilarityGreatestExpression(
|
||||
search: DataManagerSearchSpecification<TFields>,
|
||||
tableAlias?: typeof CURSOR_ROW_TABLE_ALIAS,
|
||||
): SQLFragment<TFields> {
|
||||
const similarityExprs = this.buildTrigramSimilarityExpressions(search, tableAlias);
|
||||
return sql`GREATEST(${SQLFragment.joinWithCommaSeparator(...similarityExprs)})`;
|
||||
}
|
||||
|
||||
private buildTrigramCursorCondition(
|
||||
search: DataManagerTrigramSearchSpecification<TFields>,
|
||||
decodedExternalCursorEntityID: TFields[TIDField],
|
||||
direction: PaginationDirection,
|
||||
): SQLFragment<TFields> {
|
||||
// For TRIGRAM search, we compute the similarity values using a subquery, similar to normal cursor.
|
||||
// If the cursor entity has been deleted, the subquery returns no rows and the
|
||||
// comparison evaluates to NULL, filtering out all results (empty page).
|
||||
const operator = direction === PaginationDirection.FORWARD ? '<' : '>';
|
||||
const idField = getDatabaseFieldForEntityField(
|
||||
this.entityConfiguration,
|
||||
this.entityConfiguration.idField,
|
||||
);
|
||||
|
||||
const exactMatchExpr = this.buildTrigramExactMatchCaseExpression(search);
|
||||
const similarityExpr = this.buildTrigramSimilarityGreatestExpression(search);
|
||||
|
||||
// Build extra order by fields
|
||||
const extraOrderByFields = search.extraOrderByFields;
|
||||
const extraFields =
|
||||
extraOrderByFields?.map((f) => this.resolveSearchFieldToSQLFragment(f)) ?? [];
|
||||
|
||||
// Build left side of comparison (current row's computed values)
|
||||
const leftSide = SQLFragment.joinWithCommaSeparator(
|
||||
exactMatchExpr,
|
||||
similarityExpr,
|
||||
...extraFields,
|
||||
sql`${identifier(idField)}`,
|
||||
);
|
||||
|
||||
// Build right side using subquery to get computed values for cursor entity
|
||||
// We need to rebuild the same expressions for the cursor row
|
||||
|
||||
const cursorExactMatchExpr = this.buildTrigramExactMatchCaseExpression(
|
||||
search,
|
||||
CURSOR_ROW_TABLE_ALIAS,
|
||||
);
|
||||
const cursorSimilarityExpr = this.buildTrigramSimilarityGreatestExpression(
|
||||
search,
|
||||
CURSOR_ROW_TABLE_ALIAS,
|
||||
);
|
||||
|
||||
const cursorExtraFields =
|
||||
extraOrderByFields?.map((f) =>
|
||||
this.resolveSearchFieldToSQLFragment(f, CURSOR_ROW_TABLE_ALIAS),
|
||||
) ?? [];
|
||||
|
||||
// Build SELECT fields for subquery
|
||||
const selectFields: SQLFragment<TFields>[] = [
|
||||
cursorExactMatchExpr,
|
||||
cursorSimilarityExpr,
|
||||
...cursorExtraFields,
|
||||
sql`${unsafeRaw(CURSOR_ROW_TABLE_ALIAS)}.${identifier(idField)}`,
|
||||
];
|
||||
|
||||
const rightSideSubquery = sql`
|
||||
SELECT ${SQLFragment.joinWithCommaSeparator(...selectFields)}
|
||||
FROM ${identifier(this.entityConfiguration.tableName)} AS ${unsafeRaw(CURSOR_ROW_TABLE_ALIAS)}
|
||||
WHERE ${unsafeRaw(CURSOR_ROW_TABLE_ALIAS)}.${identifier(idField)} = ${decodedExternalCursorEntityID}
|
||||
`;
|
||||
|
||||
return sql`(${leftSide}) ${unsafeRaw(operator)} (${rightSideSubquery})`;
|
||||
}
|
||||
|
||||
private buildSearchConditionAndOrderBy(search: DataManagerSearchSpecification<TFields>): {
|
||||
searchWhere: SQLFragment<TFields>;
|
||||
searchOrderByClauses: readonly DistributiveOmit<PostgresOrderByClause<TFields>, 'nulls'>[];
|
||||
} {
|
||||
switch (search.strategy) {
|
||||
case PaginationStrategy.ILIKE_SEARCH: {
|
||||
const conditions = this.buildILikeConditions(search);
|
||||
|
||||
// Order by search fields + ID to match cursor fields
|
||||
const searchOrderByClauses: PostgresOrderByClause<TFields>[] = [
|
||||
...search.fields.map(
|
||||
(field): PostgresOrderByClause<TFields> => ({
|
||||
fieldFragment: this.resolveSearchFieldToSQLFragment(field),
|
||||
order: OrderByOrdering.ASCENDING,
|
||||
}),
|
||||
),
|
||||
{
|
||||
fieldName: this.entityConfiguration.idField,
|
||||
order: OrderByOrdering.ASCENDING,
|
||||
},
|
||||
];
|
||||
|
||||
return {
|
||||
searchWhere: conditions.length > 0 ? SQLFragmentHelpers.or(...conditions) : sql`1 = 0`,
|
||||
searchOrderByClauses,
|
||||
};
|
||||
}
|
||||
|
||||
case PaginationStrategy.TRIGRAM_SEARCH: {
|
||||
// PostgreSQL trigram similarity
|
||||
const ilikeConditions = this.buildILikeConditions(search);
|
||||
const similarityExprs = this.buildTrigramSimilarityExpressions(search);
|
||||
|
||||
assert(
|
||||
search.threshold >= 0 && search.threshold <= 1,
|
||||
`Trigram similarity threshold must be between 0 and 1, got ${search.threshold}`,
|
||||
);
|
||||
|
||||
const conditions = similarityExprs.map((expr) => sql`${expr} > ${search.threshold}`);
|
||||
|
||||
// Combine exact matches (ILIKE) with similarity
|
||||
const allConditions = [...ilikeConditions, ...conditions];
|
||||
|
||||
// Build ORDER BY clauses for trigram search:
|
||||
// 1. Exact matches first (ILIKE)
|
||||
// 2. Then by similarity score
|
||||
// 3. Then by extra fields and ID field for stability
|
||||
const searchOrderByClauses: DistributiveOmit<PostgresOrderByClause<TFields>, 'nulls'>[] = [
|
||||
{
|
||||
fieldFragment: this.buildTrigramExactMatchCaseExpression(search),
|
||||
order: OrderByOrdering.DESCENDING,
|
||||
},
|
||||
{
|
||||
fieldFragment: this.buildTrigramSimilarityGreatestExpression(search),
|
||||
order: OrderByOrdering.DESCENDING,
|
||||
},
|
||||
...(search.extraOrderByFields ?? []).map((field) => ({
|
||||
fieldFragment: this.resolveSearchFieldToSQLFragment(field),
|
||||
order: OrderByOrdering.DESCENDING,
|
||||
})),
|
||||
{
|
||||
fieldName: this.entityConfiguration.idField,
|
||||
order: OrderByOrdering.DESCENDING,
|
||||
},
|
||||
];
|
||||
|
||||
return {
|
||||
searchWhere: SQLFragmentHelpers.or(...allConditions),
|
||||
searchOrderByClauses,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static escapeILikePattern(term: string): string {
|
||||
return term.replace(/[%_\\]/g, '\\$&');
|
||||
}
|
||||
}
|
||||
+122
@@ -18,6 +18,8 @@ import {
|
||||
when,
|
||||
} from 'ts-mockito';
|
||||
|
||||
import { OrderByOrdering } from '../../BasePostgresEntityDatabaseAdapter';
|
||||
import { PaginationStrategy } from '../../PaginationStrategy';
|
||||
import { PostgresEntityDatabaseAdapter } from '../../PostgresEntityDatabaseAdapter';
|
||||
import {
|
||||
TestEntity,
|
||||
@@ -253,4 +255,124 @@ describe(EntityKnexDataManager, () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('pagination', () => {
|
||||
describe('max page size validation', () => {
|
||||
it('should throw when first exceeds maxPageSize', async () => {
|
||||
const queryContext = instance(mock<EntityQueryContext>());
|
||||
const databaseAdapterMock = mock<
|
||||
PostgresEntityDatabaseAdapter<TestFields, 'customIdField'>
|
||||
>(PostgresEntityDatabaseAdapter);
|
||||
|
||||
// Configure the adapter to return a maxPageSize of 100
|
||||
when(databaseAdapterMock.paginationMaxPageSize).thenReturn(100);
|
||||
|
||||
const entityDataManager = new EntityKnexDataManager(
|
||||
testEntityConfiguration,
|
||||
instance(databaseAdapterMock),
|
||||
new NoOpEntityMetricsAdapter(),
|
||||
TestEntity.name,
|
||||
);
|
||||
|
||||
await expect(
|
||||
entityDataManager.loadPageAsync(queryContext, {
|
||||
first: 101,
|
||||
pagination: {
|
||||
strategy: PaginationStrategy.STANDARD,
|
||||
orderBy: [{ fieldName: 'customIdField', order: OrderByOrdering.ASCENDING }],
|
||||
},
|
||||
}),
|
||||
).rejects.toThrow('first must not exceed maximum page size of 100');
|
||||
});
|
||||
|
||||
it('should throw when last exceeds maxPageSize', async () => {
|
||||
const queryContext = instance(mock<EntityQueryContext>());
|
||||
const databaseAdapterMock = mock<
|
||||
PostgresEntityDatabaseAdapter<TestFields, 'customIdField'>
|
||||
>(PostgresEntityDatabaseAdapter);
|
||||
|
||||
// Configure the adapter to return a maxPageSize of 100
|
||||
when(databaseAdapterMock.paginationMaxPageSize).thenReturn(100);
|
||||
|
||||
const entityDataManager = new EntityKnexDataManager(
|
||||
testEntityConfiguration,
|
||||
instance(databaseAdapterMock),
|
||||
new NoOpEntityMetricsAdapter(),
|
||||
TestEntity.name,
|
||||
);
|
||||
|
||||
await expect(
|
||||
entityDataManager.loadPageAsync(queryContext, {
|
||||
last: 101,
|
||||
pagination: {
|
||||
strategy: PaginationStrategy.STANDARD,
|
||||
orderBy: [{ fieldName: 'customIdField', order: OrderByOrdering.ASCENDING }],
|
||||
},
|
||||
}),
|
||||
).rejects.toThrow('last must not exceed maximum page size of 100');
|
||||
});
|
||||
|
||||
it('should allow first/last within maxPageSize', async () => {
|
||||
const queryContext = instance(mock<EntityQueryContext>());
|
||||
const databaseAdapterMock = mock<
|
||||
PostgresEntityDatabaseAdapter<TestFields, 'customIdField'>
|
||||
>(PostgresEntityDatabaseAdapter);
|
||||
|
||||
// Configure the adapter to return a maxPageSize of 100
|
||||
when(databaseAdapterMock.paginationMaxPageSize).thenReturn(100);
|
||||
when(
|
||||
databaseAdapterMock.fetchManyBySQLFragmentAsync(queryContext, anything(), anything()),
|
||||
).thenResolve([]);
|
||||
|
||||
const entityDataManager = new EntityKnexDataManager(
|
||||
testEntityConfiguration,
|
||||
instance(databaseAdapterMock),
|
||||
new NoOpEntityMetricsAdapter(),
|
||||
TestEntity.name,
|
||||
);
|
||||
|
||||
// This should not throw
|
||||
const result = await entityDataManager.loadPageAsync(queryContext, {
|
||||
first: 100,
|
||||
pagination: {
|
||||
strategy: PaginationStrategy.STANDARD,
|
||||
orderBy: [{ fieldName: 'customIdField', order: OrderByOrdering.ASCENDING }],
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.edges).toEqual([]);
|
||||
});
|
||||
|
||||
it('should allow pagination when maxPageSize is not configured', async () => {
|
||||
const queryContext = instance(mock<EntityQueryContext>());
|
||||
const databaseAdapterMock = mock<
|
||||
PostgresEntityDatabaseAdapter<TestFields, 'customIdField'>
|
||||
>(PostgresEntityDatabaseAdapter);
|
||||
|
||||
// Configure the adapter to return undefined for maxPageSize
|
||||
when(databaseAdapterMock.paginationMaxPageSize).thenReturn(undefined);
|
||||
when(
|
||||
databaseAdapterMock.fetchManyBySQLFragmentAsync(queryContext, anything(), anything()),
|
||||
).thenResolve([]);
|
||||
|
||||
const entityDataManager = new EntityKnexDataManager(
|
||||
testEntityConfiguration,
|
||||
instance(databaseAdapterMock),
|
||||
new NoOpEntityMetricsAdapter(),
|
||||
TestEntity.name,
|
||||
);
|
||||
|
||||
// This should not throw even with a large page size
|
||||
const result = await entityDataManager.loadPageAsync(queryContext, {
|
||||
first: 10000,
|
||||
pagination: {
|
||||
strategy: PaginationStrategy.STANDARD,
|
||||
orderBy: [{ fieldName: 'customIdField', order: OrderByOrdering.ASCENDING }],
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.edges).toEqual([]);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,25 @@
|
||||
import { describe, expect, it } from '@jest/globals';
|
||||
|
||||
import { computeIfAbsentInWeakMap } from '../weakMaps';
|
||||
|
||||
describe(computeIfAbsentInWeakMap, () => {
|
||||
it('computes a value when absent', () => {
|
||||
const map = new WeakMap<object, string>();
|
||||
const key = {};
|
||||
const blah = computeIfAbsentInWeakMap(map, key, () => 'world');
|
||||
expect(blah).toEqual(map.get(key));
|
||||
});
|
||||
|
||||
it('does not compute a value when already present', () => {
|
||||
const map = new WeakMap<object, string>();
|
||||
const key = {};
|
||||
map.set(key, 'world');
|
||||
let didCompute = false;
|
||||
const blah = computeIfAbsentInWeakMap(map, key, () => {
|
||||
didCompute = true;
|
||||
return 'world2';
|
||||
});
|
||||
expect(blah).toEqual(map.get(key));
|
||||
expect(didCompute).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,43 @@
|
||||
import { EntityDatabaseAdapter, EntityTableDataCoordinator } from '@expo/entity';
|
||||
import assert from 'assert';
|
||||
|
||||
import { BasePostgresEntityDatabaseAdapter } from '../BasePostgresEntityDatabaseAdapter';
|
||||
import { EntityKnexDataManager } from './EntityKnexDataManager';
|
||||
import { computeIfAbsentInWeakMap } from './weakMaps';
|
||||
|
||||
const knexDataManagerCache = new WeakMap<
|
||||
EntityTableDataCoordinator<any, any>,
|
||||
EntityKnexDataManager<any, any>
|
||||
>();
|
||||
|
||||
export function getKnexDataManager<
|
||||
TFields extends Record<string, any>,
|
||||
TIDField extends keyof TFields,
|
||||
>(
|
||||
tableDataCoordinator: EntityTableDataCoordinator<TFields, TIDField>,
|
||||
): EntityKnexDataManager<TFields, TIDField> {
|
||||
return computeIfAbsentInWeakMap(
|
||||
knexDataManagerCache,
|
||||
tableDataCoordinator,
|
||||
(coordinator) =>
|
||||
new EntityKnexDataManager(
|
||||
coordinator.entityConfiguration,
|
||||
requireBasePostgresAdapter(coordinator.databaseAdapter),
|
||||
coordinator.metricsAdapter,
|
||||
coordinator.entityClassName,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
function requireBasePostgresAdapter<
|
||||
TFields extends Record<string, any>,
|
||||
TIDField extends keyof TFields,
|
||||
>(
|
||||
databaseAdapter: EntityDatabaseAdapter<TFields, TIDField>,
|
||||
): BasePostgresEntityDatabaseAdapter<TFields, TIDField> {
|
||||
assert(
|
||||
databaseAdapter instanceof BasePostgresEntityDatabaseAdapter,
|
||||
`Cannot create KnexDataManager for EntityTableDataCoordinator with non-Postgres database adapter.`,
|
||||
);
|
||||
return databaseAdapter;
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import {
|
||||
EntityCompanion,
|
||||
EntityPrivacyPolicy,
|
||||
IEntityClass,
|
||||
ReadonlyEntity,
|
||||
ViewerContext,
|
||||
} from '@expo/entity';
|
||||
|
||||
import { KnexEntityLoaderFactory } from '../KnexEntityLoaderFactory';
|
||||
import { getKnexDataManager } from './getKnexDataManager';
|
||||
import { computeIfAbsentInWeakMap } from './weakMaps';
|
||||
|
||||
const knexEntityLoaderFactoryCache = new WeakMap<
|
||||
EntityCompanion<any, any, any, any, any, any>,
|
||||
KnexEntityLoaderFactory<any, any, any, any, any, any>
|
||||
>();
|
||||
|
||||
export function getKnexEntityLoaderFactory<
|
||||
TFields extends Record<string, any>,
|
||||
TIDField extends keyof NonNullable<Pick<TFields, TSelectedFields>>,
|
||||
TViewerContext extends ViewerContext,
|
||||
TViewerContext2 extends TViewerContext,
|
||||
TEntity extends ReadonlyEntity<TFields, TIDField, TViewerContext, TSelectedFields>,
|
||||
TPrivacyPolicy extends EntityPrivacyPolicy<
|
||||
TFields,
|
||||
TIDField,
|
||||
TViewerContext,
|
||||
TEntity,
|
||||
TSelectedFields
|
||||
>,
|
||||
TSelectedFields extends keyof TFields = keyof TFields,
|
||||
>(
|
||||
entityClass: IEntityClass<
|
||||
TFields,
|
||||
TIDField,
|
||||
TViewerContext,
|
||||
TEntity,
|
||||
TPrivacyPolicy,
|
||||
TSelectedFields
|
||||
>,
|
||||
viewerContext: TViewerContext2,
|
||||
): KnexEntityLoaderFactory<
|
||||
TFields,
|
||||
TIDField,
|
||||
TViewerContext,
|
||||
TEntity,
|
||||
TPrivacyPolicy,
|
||||
TSelectedFields
|
||||
> {
|
||||
return computeIfAbsentInWeakMap(
|
||||
knexEntityLoaderFactoryCache,
|
||||
viewerContext.entityCompanionProvider.getCompanionForEntity(entityClass),
|
||||
(companion) =>
|
||||
new KnexEntityLoaderFactory(
|
||||
companion,
|
||||
getKnexDataManager(companion.tableDataCoordinator),
|
||||
companion.metricsAdapter,
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
/* c8 ignore start - types only */
|
||||
|
||||
export type NonNullableKeys<T> = {
|
||||
[K in keyof T]: T[K] extends NonNullable<T[K]> ? K : never;
|
||||
}[keyof T];
|
||||
|
||||
export type PickNonNullable<T> = Pick<T, NonNullableKeys<T>>;
|
||||
|
||||
export type DistributiveOmit<T, K extends keyof any> = T extends any ? Omit<T, K> : never;
|
||||
|
||||
/* c8 ignore stop - types only */
|
||||
@@ -0,0 +1,19 @@
|
||||
/**
|
||||
* If the specified key is not already associated with a value in this weak map, computes
|
||||
* its value using the given mapping function and enters it into this map.
|
||||
*
|
||||
* @param map - map from which to get the key's value or compute and associate
|
||||
* @param key - key for which to get the value or with which the computed value is to be associated
|
||||
* @param mappingFunction - function to compute a value for key
|
||||
*/
|
||||
export const computeIfAbsentInWeakMap = <K extends WeakKey, V>(
|
||||
map: WeakMap<K, V>,
|
||||
key: K,
|
||||
mappingFunction: (key: K) => V,
|
||||
): V => {
|
||||
if (!map.has(key)) {
|
||||
const value = mappingFunction(key);
|
||||
map.set(key, value);
|
||||
}
|
||||
return map.get(key)!;
|
||||
};
|
||||
@@ -0,0 +1,110 @@
|
||||
import {
|
||||
EntityPrivacyPolicy,
|
||||
EntityQueryContext,
|
||||
IEntityClass,
|
||||
ReadonlyEntity,
|
||||
ViewerContext,
|
||||
} from '@expo/entity';
|
||||
|
||||
import { AuthorizationResultBasedKnexEntityLoader } from './AuthorizationResultBasedKnexEntityLoader';
|
||||
import { EnforcingKnexEntityLoader } from './EnforcingKnexEntityLoader';
|
||||
import { getKnexEntityLoaderFactory } from './internal/getKnexEntityLoaderFactory';
|
||||
|
||||
/**
|
||||
* Vend knex loader for loading entities via non-data-loader methods in a given query context.
|
||||
* @param entityClass - entity class to load
|
||||
* @param viewerContext - viewer context of loading user
|
||||
* @param queryContext - query context in which to perform the load
|
||||
*/
|
||||
export function knexLoader<
|
||||
TMFields extends object,
|
||||
TMIDField extends keyof NonNullable<Pick<TMFields, TMSelectedFields>>,
|
||||
TMViewerContext extends ViewerContext,
|
||||
TMViewerContext2 extends TMViewerContext,
|
||||
TMEntity extends ReadonlyEntity<TMFields, TMIDField, TMViewerContext, TMSelectedFields>,
|
||||
TMPrivacyPolicy extends EntityPrivacyPolicy<
|
||||
TMFields,
|
||||
TMIDField,
|
||||
TMViewerContext,
|
||||
TMEntity,
|
||||
TMSelectedFields
|
||||
>,
|
||||
TMSelectedFields extends keyof TMFields = keyof TMFields,
|
||||
>(
|
||||
entityClass: IEntityClass<
|
||||
TMFields,
|
||||
TMIDField,
|
||||
TMViewerContext,
|
||||
TMEntity,
|
||||
TMPrivacyPolicy,
|
||||
TMSelectedFields
|
||||
>,
|
||||
viewerContext: TMViewerContext2,
|
||||
queryContext: EntityQueryContext = viewerContext
|
||||
.getViewerScopedEntityCompanionForClass(entityClass)
|
||||
.getQueryContextProvider()
|
||||
.getQueryContext(),
|
||||
): EnforcingKnexEntityLoader<
|
||||
TMFields,
|
||||
TMIDField,
|
||||
TMViewerContext,
|
||||
TMEntity,
|
||||
TMPrivacyPolicy,
|
||||
TMSelectedFields
|
||||
> {
|
||||
return getKnexEntityLoaderFactory(entityClass, viewerContext).forLoadEnforcing(
|
||||
viewerContext,
|
||||
queryContext,
|
||||
{ previousValue: null, cascadingDeleteCause: null },
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Vend knex loader for loading entities via non-data-loader methods in a given query context.
|
||||
* Returns authorization results instead of throwing on authorization errors.
|
||||
* @param entityClass - entity class to load
|
||||
* @param viewerContext - viewer context of loading user
|
||||
* @param queryContext - query context in which to perform the load
|
||||
*/
|
||||
export function knexLoaderWithAuthorizationResults<
|
||||
TMFields extends object,
|
||||
TMIDField extends keyof NonNullable<Pick<TMFields, TMSelectedFields>>,
|
||||
TMViewerContext extends ViewerContext,
|
||||
TMViewerContext2 extends TMViewerContext,
|
||||
TMEntity extends ReadonlyEntity<TMFields, TMIDField, TMViewerContext, TMSelectedFields>,
|
||||
TMPrivacyPolicy extends EntityPrivacyPolicy<
|
||||
TMFields,
|
||||
TMIDField,
|
||||
TMViewerContext,
|
||||
TMEntity,
|
||||
TMSelectedFields
|
||||
>,
|
||||
TMSelectedFields extends keyof TMFields = keyof TMFields,
|
||||
>(
|
||||
entityClass: IEntityClass<
|
||||
TMFields,
|
||||
TMIDField,
|
||||
TMViewerContext,
|
||||
TMEntity,
|
||||
TMPrivacyPolicy,
|
||||
TMSelectedFields
|
||||
>,
|
||||
viewerContext: TMViewerContext2,
|
||||
queryContext: EntityQueryContext = viewerContext
|
||||
.getViewerScopedEntityCompanionForClass(entityClass)
|
||||
.getQueryContextProvider()
|
||||
.getQueryContext(),
|
||||
): AuthorizationResultBasedKnexEntityLoader<
|
||||
TMFields,
|
||||
TMIDField,
|
||||
TMViewerContext,
|
||||
TMEntity,
|
||||
TMPrivacyPolicy,
|
||||
TMSelectedFields
|
||||
> {
|
||||
return getKnexEntityLoaderFactory(entityClass, viewerContext).forLoad(
|
||||
viewerContext,
|
||||
queryContext,
|
||||
{ previousValue: null, cascadingDeleteCause: null },
|
||||
);
|
||||
}
|
||||
@@ -3,6 +3,23 @@
|
||||
All notable changes to this project will be documented in this file.
|
||||
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
|
||||
|
||||
# [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-example
|
||||
|
||||
# [0.56.0](https://github.com/expo/entity/compare/v0.55.0...v0.56.0) (2026-02-28)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **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))
|
||||
- move EntityPrivacyUtils back into core package ([#429](https://github.com/expo/entity/issues/429)) ([56ec27d](https://github.com/expo/entity/commit/56ec27d5f201d83930a2cc05dfc380a054bff1e1))
|
||||
- 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)
|
||||
|
||||
# [0.55.0](https://github.com/expo/entity/compare/v0.54.0...v0.55.0) (2026-01-27)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"name": "@expo/entity-example",
|
||||
"type": "module",
|
||||
"private": true,
|
||||
"version": "0.55.0",
|
||||
"version": "0.57.0",
|
||||
"description": "An example integration of the @expo/entity framework",
|
||||
"scripts": {
|
||||
"build": "tsc --build",
|
||||
@@ -29,8 +29,8 @@
|
||||
"@koa/bodyparser": "6.1.0",
|
||||
"@koa/cors": "5.0.0",
|
||||
"@koa/router": "15.3.0",
|
||||
"graphql": "16.12.0",
|
||||
"koa": "3.1.1"
|
||||
"graphql": "16.13.0",
|
||||
"koa": "3.1.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@jest/globals": "30.2.0",
|
||||
|
||||
@@ -10,14 +10,6 @@ import { v4 as uuidv4 } from 'uuid';
|
||||
const dbObjects: Readonly<{ [key: string]: any }>[] = [];
|
||||
|
||||
export class InMemoryDatabaseAdapterProvider implements IEntityDatabaseAdapterProvider {
|
||||
getExtensionsKey(): string {
|
||||
return 'InMemoryDatabaseAdapterProvider';
|
||||
}
|
||||
|
||||
installExtensions(): void {
|
||||
// No-op
|
||||
}
|
||||
|
||||
getDatabaseAdapter<TFields extends Record<string, any>, TIDField extends keyof TFields>(
|
||||
entityConfiguration: EntityConfiguration<TFields, TIDField>,
|
||||
): EntityDatabaseAdapter<TFields, TIDField> {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { ApolloServer } from '@apollo/server';
|
||||
import { ApolloServer, type BaseContext } from '@apollo/server';
|
||||
import { koaMiddleware } from '@as-integrations/koa';
|
||||
import { EntityCompanionProvider } from '@expo/entity';
|
||||
import { bodyParser } from '@koa/bodyparser';
|
||||
@@ -23,6 +23,10 @@ export type ExampleState = {
|
||||
entityCompanionProvider: EntityCompanionProvider; // entityCompanionMiddleware
|
||||
};
|
||||
|
||||
export interface GraphQLContext extends BaseContext {
|
||||
viewerContext: ExampleViewerContext;
|
||||
}
|
||||
|
||||
export async function createAppAsync(): Promise<Koa<ExampleState, ExampleContext>> {
|
||||
const app = new Koa<ExampleState, ExampleContext>();
|
||||
|
||||
@@ -44,15 +48,15 @@ export async function createAppAsync(): Promise<Koa<ExampleState, ExampleContext
|
||||
router.use(notesRouter.routes());
|
||||
|
||||
// GraphQL routes
|
||||
const server = new ApolloServer({
|
||||
const server = new ApolloServer<GraphQLContext>({
|
||||
typeDefs,
|
||||
resolvers,
|
||||
});
|
||||
await server.start();
|
||||
router.post(
|
||||
'/graphql',
|
||||
// https://github.com/apollographql/apollo-server/issues/7625
|
||||
koaMiddleware(server as any, {
|
||||
// @ts-expect-error - @as-integrations/koa peers on @apollo/server ^4 (CJS types) but we use v5 (ESM types)
|
||||
koaMiddleware<GraphQLContext, ExampleState, ExampleContext>(server, {
|
||||
context: async ({ ctx }: { ctx: ExampleContext }) => ({
|
||||
viewerContext: ctx.state.viewerContext,
|
||||
}),
|
||||
|
||||
@@ -3,6 +3,14 @@
|
||||
All notable changes to this project will be documented in this file.
|
||||
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
|
||||
|
||||
# [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-full-integration-tests
|
||||
|
||||
# [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-full-integration-tests
|
||||
|
||||
# [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-full-integration-tests
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@expo/entity-full-integration-tests",
|
||||
"private": true,
|
||||
"version": "0.55.0",
|
||||
"version": "0.57.0",
|
||||
"description": "Full redis and knex integration tests for the entity framework",
|
||||
"scripts": {
|
||||
"build": "tsc --build",
|
||||
|
||||
+1
-1
@@ -181,7 +181,7 @@ describe('Entity cache inconsistency', () => {
|
||||
|
||||
openBarrier2!();
|
||||
})(),
|
||||
viewerContext.runInTransactionForDatabaseAdaptorFlavorAsync(
|
||||
viewerContext.runInTransactionForDatabaseAdapterFlavorAsync(
|
||||
'postgres',
|
||||
async (queryContext) => {
|
||||
await TestEntity.updater(entity1, queryContext)
|
||||
|
||||
@@ -3,6 +3,14 @@
|
||||
All notable changes to this project will be documented in this file.
|
||||
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
|
||||
|
||||
# [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-ip-address-field
|
||||
|
||||
# [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-ip-address-field
|
||||
|
||||
# [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-ip-address-field
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@expo/entity-ip-address-field",
|
||||
"version": "0.55.0",
|
||||
"version": "0.57.0",
|
||||
"description": "IP address EntityField definitions for @expo/entity",
|
||||
"files": [
|
||||
"build",
|
||||
|
||||
@@ -3,6 +3,16 @@
|
||||
All notable changes to this project will be documented in this file.
|
||||
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
|
||||
|
||||
# [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-secondary-cache-local-memory
|
||||
|
||||
# [0.56.0](https://github.com/expo/entity/compare/v0.55.0...v0.56.0) (2026-02-28)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- 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)
|
||||
|
||||
# [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-secondary-cache-local-memory
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@expo/entity-secondary-cache-local-memory",
|
||||
"version": "0.55.0",
|
||||
"version": "0.57.0",
|
||||
"description": "Local memory secondary cache for @expo/entity",
|
||||
"files": [
|
||||
"build",
|
||||
|
||||
+36
@@ -1,9 +1,11 @@
|
||||
import {
|
||||
AlwaysAllowPrivacyPolicyRule,
|
||||
AuthorizationResultBasedEntityLoader,
|
||||
Entity,
|
||||
EntityCompanionDefinition,
|
||||
EntityCompanionProvider,
|
||||
EntityConfiguration,
|
||||
EntityConstructionUtils,
|
||||
EntityPrivacyPolicy,
|
||||
EntitySecondaryCacheLoader,
|
||||
IEntityMetricsAdapter,
|
||||
@@ -161,6 +163,32 @@ class TestSecondaryLocalMemoryCacheLoader extends EntitySecondaryCacheLoader<
|
||||
> {
|
||||
public databaseLoadCount = 0;
|
||||
|
||||
constructor(
|
||||
secondaryEntityCache: LocalMemorySecondaryEntityCache<
|
||||
LocalMemoryTestEntityFields,
|
||||
'id',
|
||||
TestLoadParams
|
||||
>,
|
||||
constructionUtils: EntityConstructionUtils<
|
||||
LocalMemoryTestEntityFields,
|
||||
'id',
|
||||
TestViewerContext,
|
||||
LocalMemoryTestEntity,
|
||||
LocalMemoryTestEntityPrivacyPolicy,
|
||||
keyof LocalMemoryTestEntityFields
|
||||
>,
|
||||
private readonly entityLoader: AuthorizationResultBasedEntityLoader<
|
||||
LocalMemoryTestEntityFields,
|
||||
'id',
|
||||
TestViewerContext,
|
||||
LocalMemoryTestEntity,
|
||||
LocalMemoryTestEntityPrivacyPolicy,
|
||||
keyof LocalMemoryTestEntityFields
|
||||
>,
|
||||
) {
|
||||
super(secondaryEntityCache, constructionUtils);
|
||||
}
|
||||
|
||||
protected async fetchObjectsFromDatabaseAsync(
|
||||
loadParamsArray: readonly Readonly<TestLoadParams>[],
|
||||
): Promise<ReadonlyMap<Readonly<TestLoadParams>, Readonly<LocalMemoryTestEntityFields> | null>> {
|
||||
@@ -193,6 +221,10 @@ describe(LocalMemorySecondaryEntityCache, () => {
|
||||
localMemoryTestEntityConfiguration,
|
||||
createTTLCache<LocalMemoryTestEntityFields>(),
|
||||
),
|
||||
EntitySecondaryCacheLoader.getConstructionUtilsForEntityClass(
|
||||
LocalMemoryTestEntity,
|
||||
viewerContext,
|
||||
),
|
||||
LocalMemoryTestEntity.loaderWithAuthorizationResults(viewerContext),
|
||||
);
|
||||
|
||||
@@ -229,6 +261,10 @@ describe(LocalMemorySecondaryEntityCache, () => {
|
||||
localMemoryTestEntityConfiguration,
|
||||
createTTLCache<LocalMemoryTestEntityFields>(),
|
||||
),
|
||||
EntitySecondaryCacheLoader.getConstructionUtilsForEntityClass(
|
||||
LocalMemoryTestEntity,
|
||||
viewerContext,
|
||||
),
|
||||
LocalMemoryTestEntity.loaderWithAuthorizationResults(viewerContext),
|
||||
);
|
||||
|
||||
|
||||
@@ -3,6 +3,16 @@
|
||||
All notable changes to this project will be documented in this file.
|
||||
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
|
||||
|
||||
# [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-secondary-cache-redis
|
||||
|
||||
# [0.56.0](https://github.com/expo/entity/compare/v0.55.0...v0.56.0) (2026-02-28)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- 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)
|
||||
|
||||
# [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-secondary-cache-redis
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@expo/entity-secondary-cache-redis",
|
||||
"version": "0.55.0",
|
||||
"version": "0.57.0",
|
||||
"description": "Redis secondary cache for @expo/entity",
|
||||
"files": [
|
||||
"build",
|
||||
|
||||
+31
-1
@@ -1,4 +1,10 @@
|
||||
import { EntitySecondaryCacheLoader, mapMapAsync, ViewerContext } from '@expo/entity';
|
||||
import {
|
||||
AuthorizationResultBasedEntityLoader,
|
||||
EntityConstructionUtils,
|
||||
EntitySecondaryCacheLoader,
|
||||
mapMapAsync,
|
||||
ViewerContext,
|
||||
} from '@expo/entity';
|
||||
import {
|
||||
GenericRedisCacheContext,
|
||||
RedisCacheInvalidationStrategy,
|
||||
@@ -33,6 +39,28 @@ class TestSecondaryRedisCacheLoader extends EntitySecondaryCacheLoader<
|
||||
> {
|
||||
public databaseLoadCount = 0;
|
||||
|
||||
constructor(
|
||||
secondaryEntityCache: RedisSecondaryEntityCache<RedisTestEntityFields, 'id', TestLoadParams>,
|
||||
constructionUtils: EntityConstructionUtils<
|
||||
RedisTestEntityFields,
|
||||
'id',
|
||||
TestViewerContext,
|
||||
RedisTestEntity,
|
||||
RedisTestEntityPrivacyPolicy,
|
||||
keyof RedisTestEntityFields
|
||||
>,
|
||||
private readonly entityLoader: AuthorizationResultBasedEntityLoader<
|
||||
RedisTestEntityFields,
|
||||
'id',
|
||||
TestViewerContext,
|
||||
RedisTestEntity,
|
||||
RedisTestEntityPrivacyPolicy,
|
||||
keyof RedisTestEntityFields
|
||||
>,
|
||||
) {
|
||||
super(secondaryEntityCache, constructionUtils);
|
||||
}
|
||||
|
||||
protected async fetchObjectsFromDatabaseAsync(
|
||||
loadParamsArray: readonly Readonly<TestLoadParams>[],
|
||||
): Promise<ReadonlyMap<Readonly<TestLoadParams>, Readonly<RedisTestEntityFields> | null>> {
|
||||
@@ -93,6 +121,7 @@ describe(RedisSecondaryEntityCache, () => {
|
||||
genericRedisCacheContext,
|
||||
(loadParams) => `test-key-${loadParams.id}`,
|
||||
),
|
||||
EntitySecondaryCacheLoader.getConstructionUtilsForEntityClass(RedisTestEntity, viewerContext),
|
||||
RedisTestEntity.loaderWithAuthorizationResults(viewerContext),
|
||||
);
|
||||
|
||||
@@ -132,6 +161,7 @@ describe(RedisSecondaryEntityCache, () => {
|
||||
genericRedisCacheContext,
|
||||
(loadParams) => `test-key-${loadParams.id}`,
|
||||
),
|
||||
EntitySecondaryCacheLoader.getConstructionUtilsForEntityClass(RedisTestEntity, viewerContext),
|
||||
RedisTestEntity.loaderWithAuthorizationResults(viewerContext),
|
||||
);
|
||||
|
||||
|
||||
@@ -3,6 +3,21 @@
|
||||
All notable changes to this project will be documented in this file.
|
||||
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
|
||||
|
||||
# [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-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))
|
||||
- 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 authorization action to EntityPrivacyPolicyRuleEvaluationContext ([#464](https://github.com/expo/entity/issues/464)) ([e71ae5f](https://github.com/expo/entity/commit/e71ae5fcbae95096e0d6a42a8c960896ab83ccfb))
|
||||
|
||||
# [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-testing-utils
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@expo/entity-testing-utils",
|
||||
"version": "0.55.0",
|
||||
"version": "0.57.0",
|
||||
"description": "A package containing utilities for testing applications that use Entity",
|
||||
"files": [
|
||||
"build",
|
||||
@@ -36,7 +36,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@jest/globals": "30.2.0",
|
||||
"@types/lodash": "4.17.23",
|
||||
"@types/lodash": "4.17.24",
|
||||
"@types/node": "24.10.13",
|
||||
"typescript": "5.9.3"
|
||||
}
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import {
|
||||
EntityPrivacyPolicyEvaluationContext,
|
||||
EntityQueryContext,
|
||||
ReadonlyEntity,
|
||||
ViewerContext,
|
||||
PrivacyPolicyRule,
|
||||
RuleEvaluationResult,
|
||||
EntityPrivacyPolicyRuleEvaluationContext,
|
||||
} from '@expo/entity';
|
||||
import { describe, expect, test } from '@jest/globals';
|
||||
|
||||
@@ -17,7 +17,7 @@ export interface Case<
|
||||
> {
|
||||
viewerContext: TViewerContext;
|
||||
queryContext: EntityQueryContext;
|
||||
evaluationContext: EntityPrivacyPolicyEvaluationContext<
|
||||
evaluationContext: EntityPrivacyPolicyRuleEvaluationContext<
|
||||
TFields,
|
||||
TIDField,
|
||||
TViewerContext,
|
||||
|
||||
@@ -7,14 +7,6 @@ import {
|
||||
import { StubDatabaseAdapter } from './StubDatabaseAdapter';
|
||||
|
||||
export class StubDatabaseAdapterProvider implements IEntityDatabaseAdapterProvider {
|
||||
getExtensionsKey(): string {
|
||||
return 'StubDatabaseAdapterProvider';
|
||||
}
|
||||
|
||||
installExtensions(): void {
|
||||
// No-op
|
||||
}
|
||||
|
||||
private readonly objectCollection = new Map();
|
||||
|
||||
getDatabaseAdapter<TFields extends Record<string, any>, TIDField extends keyof TFields>(
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import {
|
||||
EntityPrivacyPolicyEvaluationContext,
|
||||
EntityQueryContext,
|
||||
ViewerContext,
|
||||
AlwaysAllowPrivacyPolicyRule,
|
||||
AlwaysDenyPrivacyPolicyRule,
|
||||
EntityPrivacyPolicyRuleEvaluationContext,
|
||||
} from '@expo/entity';
|
||||
import { describe } from '@jest/globals';
|
||||
import { anything, instance, mock } from 'ts-mockito';
|
||||
@@ -20,7 +20,7 @@ describe(describePrivacyPolicyRuleWithAsyncTestCase, () => {
|
||||
viewerContext: instance(mock(ViewerContext)),
|
||||
queryContext: instance(mock(EntityQueryContext)),
|
||||
evaluationContext:
|
||||
instance(mock<EntityPrivacyPolicyEvaluationContext<any, any, any, any, any>>()),
|
||||
instance(mock<EntityPrivacyPolicyRuleEvaluationContext<any, any, any, any, any>>()),
|
||||
entity: anything(),
|
||||
}),
|
||||
],
|
||||
@@ -35,7 +35,7 @@ describe(describePrivacyPolicyRuleWithAsyncTestCase, () => {
|
||||
viewerContext: instance(mock(ViewerContext)),
|
||||
queryContext: instance(mock(EntityQueryContext)),
|
||||
evaluationContext:
|
||||
instance(mock<EntityPrivacyPolicyEvaluationContext<any, any, any, any, any>>()),
|
||||
instance(mock<EntityPrivacyPolicyRuleEvaluationContext<any, any, any, any, any>>()),
|
||||
entity: anything(),
|
||||
}),
|
||||
],
|
||||
|
||||
@@ -3,6 +3,26 @@
|
||||
All notable changes to this project will be documented in this file.
|
||||
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
|
||||
|
||||
# [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
|
||||
|
||||
# [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))
|
||||
- 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)
|
||||
- Type idField in EntityConfiguration as TIDField ([#425](https://github.com/expo/entity/issues/425)) ([be40bcf](https://github.com/expo/entity/commit/be40bcffa6e7db9b7ad75e1a80bbb48ff417c6c5))
|
||||
|
||||
### Features
|
||||
|
||||
- Add authorization action to EntityPrivacyPolicyRuleEvaluationContext ([#464](https://github.com/expo/entity/issues/464)) ([e71ae5f](https://github.com/expo/entity/commit/e71ae5fcbae95096e0d6a42a8c960896ab83ccfb))
|
||||
- Add paginated loader to entity-database-adapter-knex ([#422](https://github.com/expo/entity/issues/422)) ([58deffd](https://github.com/expo/entity/commit/58deffd426b0214073ede608cd47c04db2596d60))
|
||||
|
||||
# [0.55.0](https://github.com/expo/entity/compare/v0.54.0...v0.55.0) (2026-01-27)
|
||||
|
||||
### Features
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@expo/entity",
|
||||
"version": "0.55.0",
|
||||
"version": "0.57.0",
|
||||
"description": "A privacy-first data model",
|
||||
"files": [
|
||||
"build",
|
||||
@@ -34,7 +34,7 @@
|
||||
"devDependencies": {
|
||||
"@jest/globals": "30.2.0",
|
||||
"@types/invariant": "2.2.37",
|
||||
"@types/lodash": "4.17.23",
|
||||
"@types/lodash": "4.17.24",
|
||||
"@types/node": "24.10.13",
|
||||
"lodash": "4.17.23",
|
||||
"ts-mockito": "2.6.1",
|
||||
|
||||
@@ -16,7 +16,7 @@ export class AuthorizationResultBasedEntityAssociationLoader<
|
||||
TIDField extends keyof NonNullable<Pick<TFields, TSelectedFields>>,
|
||||
TViewerContext extends ViewerContext,
|
||||
TEntity extends ReadonlyEntity<TFields, TIDField, TViewerContext, TSelectedFields>,
|
||||
TSelectedFields extends keyof TFields,
|
||||
TSelectedFields extends keyof TFields = keyof TFields,
|
||||
> {
|
||||
constructor(
|
||||
private readonly entity: TEntity,
|
||||
@@ -123,10 +123,7 @@ export class AuthorizationResultBasedEntityAssociationLoader<
|
||||
.getViewerScopedEntityCompanionForClass(associatedEntityClass)
|
||||
.getLoaderFactory()
|
||||
.forLoad(this.queryContext, { previousValue: null, cascadingDeleteCause: null });
|
||||
return await loader.loadManyByFieldEqualingAsync(
|
||||
associatedEntityFieldContainingThisID,
|
||||
thisID as any,
|
||||
);
|
||||
return await loader.loadManyByFieldEqualingAsync(associatedEntityFieldContainingThisID, thisID);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -178,7 +175,7 @@ export class AuthorizationResultBasedEntityAssociationLoader<
|
||||
.forLoad(this.queryContext, { previousValue: null, cascadingDeleteCause: null });
|
||||
return await loader.loadByFieldEqualingAsync(
|
||||
associatedEntityLookupByField,
|
||||
associatedFieldValue as any,
|
||||
associatedFieldValue,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -232,7 +229,7 @@ export class AuthorizationResultBasedEntityAssociationLoader<
|
||||
.forLoad(this.queryContext, { previousValue: null, cascadingDeleteCause: null });
|
||||
return await loader.loadManyByFieldEqualingAsync(
|
||||
associatedEntityLookupByField,
|
||||
associatedFieldValue as any,
|
||||
associatedFieldValue,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -9,7 +9,6 @@ import {
|
||||
EntityConfiguration,
|
||||
} from './EntityConfiguration';
|
||||
import { EntityConstructionUtils } from './EntityConstructionUtils';
|
||||
import { EntityInvalidationUtils } from './EntityInvalidationUtils';
|
||||
import { EntityPrivacyPolicy } from './EntityPrivacyPolicy';
|
||||
import { EntityQueryContext } from './EntityQueryContext';
|
||||
import { ReadonlyEntity } from './ReadonlyEntity';
|
||||
@@ -19,7 +18,6 @@ import { CompositeFieldHolder, CompositeFieldValueHolder } from './internal/Comp
|
||||
import { CompositeFieldValueMap } from './internal/CompositeFieldValueMap';
|
||||
import { EntityDataManager } from './internal/EntityDataManager';
|
||||
import { SingleFieldHolder, SingleFieldValueHolder } from './internal/SingleFieldHolder';
|
||||
import { IEntityMetricsAdapter } from './metrics/IEntityMetricsAdapter';
|
||||
import { mapKeys, mapMap } from './utils/collections/maps';
|
||||
import { areSetsEqual } from './utils/collections/sets';
|
||||
|
||||
@@ -55,16 +53,7 @@ export class AuthorizationResultBasedEntityLoader<
|
||||
TSelectedFields
|
||||
>,
|
||||
private readonly dataManager: EntityDataManager<TFields, TIDField>,
|
||||
protected readonly metricsAdapter: IEntityMetricsAdapter,
|
||||
public readonly invalidationUtils: EntityInvalidationUtils<
|
||||
TFields,
|
||||
TIDField,
|
||||
TViewerContext,
|
||||
TEntity,
|
||||
TPrivacyPolicy,
|
||||
TSelectedFields
|
||||
>,
|
||||
public readonly constructionUtils: EntityConstructionUtils<
|
||||
private readonly constructionUtils: EntityConstructionUtils<
|
||||
TFields,
|
||||
TIDField,
|
||||
TViewerContext,
|
||||
|
||||
@@ -333,8 +333,17 @@ export class AuthorizationResultBasedCreateMutator<
|
||||
previousValue: null,
|
||||
cascadingDeleteCause: null,
|
||||
});
|
||||
const invalidationUtils = this.entityLoaderFactory.invalidationUtils();
|
||||
const constructionUtils = this.entityLoaderFactory.constructionUtils(
|
||||
this.viewerContext,
|
||||
queryContext,
|
||||
{
|
||||
previousValue: null,
|
||||
cascadingDeleteCause: null,
|
||||
},
|
||||
);
|
||||
|
||||
const temporaryEntityForPrivacyCheck = entityLoader.constructionUtils.constructEntity({
|
||||
const temporaryEntityForPrivacyCheck = constructionUtils.constructEntity({
|
||||
[this.entityConfiguration.idField]: '00000000-0000-0000-0000-000000000000', // zero UUID
|
||||
...this.fieldsForEntity,
|
||||
} as unknown as TFields);
|
||||
@@ -376,14 +385,13 @@ export class AuthorizationResultBasedCreateMutator<
|
||||
// Invalidate all caches for the new entity so that any previously-negatively-cached loads
|
||||
// are removed from the caches.
|
||||
queryContext.appendPostCommitInvalidationCallback(async () => {
|
||||
entityLoader.invalidationUtils.invalidateFieldsForTransaction(queryContext, insertResult);
|
||||
await entityLoader.invalidationUtils.invalidateFieldsAsync(insertResult);
|
||||
invalidationUtils.invalidateFieldsForTransaction(queryContext, insertResult);
|
||||
await invalidationUtils.invalidateFieldsAsync(insertResult);
|
||||
});
|
||||
|
||||
entityLoader.invalidationUtils.invalidateFieldsForTransaction(queryContext, insertResult);
|
||||
invalidationUtils.invalidateFieldsForTransaction(queryContext, insertResult);
|
||||
|
||||
const unauthorizedEntityAfterInsert =
|
||||
entityLoader.constructionUtils.constructEntity(insertResult);
|
||||
const unauthorizedEntityAfterInsert = constructionUtils.constructEntity(insertResult);
|
||||
const newEntity = await enforceAsyncResult(
|
||||
entityLoader.loadByIDAsync(unauthorizedEntityAfterInsert.getID()),
|
||||
);
|
||||
@@ -542,10 +550,17 @@ export class AuthorizationResultBasedUpdateMutator<
|
||||
previousValue: this.originalEntity,
|
||||
cascadingDeleteCause: this.cascadingDeleteCause,
|
||||
});
|
||||
|
||||
const entityAboutToBeUpdated = entityLoader.constructionUtils.constructEntity(
|
||||
this.fieldsForEntity,
|
||||
const invalidationUtils = this.entityLoaderFactory.invalidationUtils();
|
||||
const constructionUtils = this.entityLoaderFactory.constructionUtils(
|
||||
this.viewerContext,
|
||||
queryContext,
|
||||
{
|
||||
previousValue: this.originalEntity,
|
||||
cascadingDeleteCause: this.cascadingDeleteCause,
|
||||
},
|
||||
);
|
||||
|
||||
const entityAboutToBeUpdated = constructionUtils.constructEntity(this.fieldsForEntity);
|
||||
const authorizeUpdateResult = await asyncResult(
|
||||
this.privacyPolicy.authorizeUpdateAsync(
|
||||
this.viewerContext,
|
||||
@@ -609,30 +624,22 @@ export class AuthorizationResultBasedUpdateMutator<
|
||||
// version of the entity.
|
||||
|
||||
queryContext.appendPostCommitInvalidationCallback(async () => {
|
||||
entityLoader.invalidationUtils.invalidateFieldsForTransaction(
|
||||
invalidationUtils.invalidateFieldsForTransaction(
|
||||
queryContext,
|
||||
this.originalEntity.getAllDatabaseFields(),
|
||||
);
|
||||
entityLoader.invalidationUtils.invalidateFieldsForTransaction(
|
||||
queryContext,
|
||||
this.fieldsForEntity,
|
||||
);
|
||||
invalidationUtils.invalidateFieldsForTransaction(queryContext, this.fieldsForEntity);
|
||||
await Promise.all([
|
||||
entityLoader.invalidationUtils.invalidateFieldsAsync(
|
||||
this.originalEntity.getAllDatabaseFields(),
|
||||
),
|
||||
entityLoader.invalidationUtils.invalidateFieldsAsync(this.fieldsForEntity),
|
||||
invalidationUtils.invalidateFieldsAsync(this.originalEntity.getAllDatabaseFields()),
|
||||
invalidationUtils.invalidateFieldsAsync(this.fieldsForEntity),
|
||||
]);
|
||||
});
|
||||
|
||||
entityLoader.invalidationUtils.invalidateFieldsForTransaction(
|
||||
invalidationUtils.invalidateFieldsForTransaction(
|
||||
queryContext,
|
||||
this.originalEntity.getAllDatabaseFields(),
|
||||
);
|
||||
entityLoader.invalidationUtils.invalidateFieldsForTransaction(
|
||||
queryContext,
|
||||
this.fieldsForEntity,
|
||||
);
|
||||
invalidationUtils.invalidateFieldsForTransaction(queryContext, this.fieldsForEntity);
|
||||
|
||||
const updatedEntity = await enforceAsyncResult(
|
||||
entityLoader.loadByIDAsync(entityAboutToBeUpdated.getID()),
|
||||
@@ -850,23 +857,18 @@ export class AuthorizationResultBasedDeleteMutator<
|
||||
);
|
||||
}
|
||||
|
||||
const entityLoader = this.entityLoaderFactory.forLoad(this.viewerContext, queryContext, {
|
||||
previousValue: null,
|
||||
cascadingDeleteCause: this.cascadingDeleteCause,
|
||||
});
|
||||
const invalidationUtils = this.entityLoaderFactory.invalidationUtils();
|
||||
|
||||
// Invalidate all caches for the entity so that any previously-cached loads
|
||||
// are removed from the caches.
|
||||
queryContext.appendPostCommitInvalidationCallback(async () => {
|
||||
entityLoader.invalidationUtils.invalidateFieldsForTransaction(
|
||||
invalidationUtils.invalidateFieldsForTransaction(
|
||||
queryContext,
|
||||
this.entity.getAllDatabaseFields(),
|
||||
);
|
||||
await entityLoader.invalidationUtils.invalidateFieldsAsync(
|
||||
this.entity.getAllDatabaseFields(),
|
||||
);
|
||||
await invalidationUtils.invalidateFieldsAsync(this.entity.getAllDatabaseFields());
|
||||
});
|
||||
entityLoader.invalidationUtils.invalidateFieldsForTransaction(
|
||||
invalidationUtils.invalidateFieldsForTransaction(
|
||||
queryContext,
|
||||
this.entity.getAllDatabaseFields(),
|
||||
);
|
||||
@@ -984,7 +986,7 @@ export class AuthorizationResultBasedDeleteMutator<
|
||||
.loadManyByFieldEqualingAsync(
|
||||
fieldName,
|
||||
association.associatedEntityLookupByField
|
||||
? entity.getField(association.associatedEntityLookupByField as any)
|
||||
? entity.getField(association.associatedEntityLookupByField)
|
||||
: entity.getID(),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -6,7 +6,7 @@ import { ReadonlyEntity } from './ReadonlyEntity';
|
||||
import { ViewerContext } from './ViewerContext';
|
||||
|
||||
/**
|
||||
* Enforcing entity creator. All updates
|
||||
* Enforcing entity creator. All creates
|
||||
* through this creator will throw if authorization is not successful.
|
||||
*/
|
||||
export class EnforcingEntityCreator<
|
||||
|
||||
@@ -24,7 +24,7 @@ export class EnforcingEntityLoader<
|
||||
TEntity,
|
||||
TSelectedFields
|
||||
>,
|
||||
TSelectedFields extends keyof TFields,
|
||||
TSelectedFields extends keyof TFields = keyof TFields,
|
||||
> {
|
||||
constructor(
|
||||
private readonly entityLoader: AuthorizationResultBasedEntityLoader<
|
||||
|
||||
@@ -32,6 +32,12 @@ import { ViewerContext } from './ViewerContext';
|
||||
*
|
||||
* All concrete entity implementations should extend this class and provide their
|
||||
* own EntityCompanionDefinition.
|
||||
*
|
||||
* Generic type parameters:
|
||||
* TFields - the shape of the underlying data for this entity, typically corresponding to a database table schema. The mapping from TFields to the actual database schema is defined in the EntityCompanionDefinition for this entity.
|
||||
* TIDField - the key of the ID field in TFields, which must be non-nullable and is used to uniquely identify individual entities
|
||||
* TViewerContext - the type of ViewerContext that can be used with this entity
|
||||
* TSelectedFields - the keys of fields in TFields that belong to this entity; used when there are multiple entities backed by the same underlying table with different field subsets
|
||||
*/
|
||||
export abstract class Entity<
|
||||
TFields extends Record<string, any>,
|
||||
|
||||
@@ -133,7 +133,6 @@ export class EntityCompanionProvider {
|
||||
new Map();
|
||||
private readonly tableDataCoordinatorMap: Map<string, EntityTableDataCoordinator<any, any>> =
|
||||
new Map();
|
||||
private static readonly installedExtensions = new Set<string>();
|
||||
|
||||
/**
|
||||
* Instantiate an Entity framework.
|
||||
@@ -159,21 +158,7 @@ export class EntityCompanionProvider {
|
||||
any,
|
||||
any
|
||||
> = {},
|
||||
) {
|
||||
// Install any extensions required by the database adapter flavors
|
||||
for (const flavorDefinition of databaseAdapterFlavors.values()) {
|
||||
if (
|
||||
!EntityCompanionProvider.installedExtensions.has(
|
||||
flavorDefinition.adapterProvider.getExtensionsKey(),
|
||||
)
|
||||
) {
|
||||
flavorDefinition.adapterProvider.installExtensions();
|
||||
EntityCompanionProvider.installedExtensions.add(
|
||||
flavorDefinition.adapterProvider.getExtensionsKey(),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Get the entity companion for specified entity. If not already computed and cached, the entity
|
||||
@@ -223,13 +208,13 @@ export class EntityCompanionProvider {
|
||||
});
|
||||
}
|
||||
|
||||
getQueryContextProviderForDatabaseAdaptorFlavor(
|
||||
getQueryContextProviderForDatabaseAdapterFlavor(
|
||||
databaseAdapterFlavor: DatabaseAdapterFlavor,
|
||||
): EntityQueryContextProvider {
|
||||
const entityDatabaseAdapterFlavor = this.databaseAdapterFlavors.get(databaseAdapterFlavor);
|
||||
invariant(
|
||||
entityDatabaseAdapterFlavor,
|
||||
`No database adaptor configuration found for flavor: ${databaseAdapterFlavor}`,
|
||||
`No database adapter configuration found for flavor: ${databaseAdapterFlavor}`,
|
||||
);
|
||||
|
||||
return entityDatabaseAdapterFlavor.queryContextProvider;
|
||||
@@ -248,7 +233,7 @@ export class EntityCompanionProvider {
|
||||
);
|
||||
invariant(
|
||||
entityDatabaseAdapterFlavor,
|
||||
`No database adaptor configuration found for flavor: ${entityConfiguration.databaseAdapterFlavor}`,
|
||||
`No database adapter configuration found for flavor: ${entityConfiguration.databaseAdapterFlavor}`,
|
||||
);
|
||||
|
||||
const entityCacheAdapterFlavor = this.cacheAdapterFlavors.get(
|
||||
@@ -256,7 +241,7 @@ export class EntityCompanionProvider {
|
||||
);
|
||||
invariant(
|
||||
entityCacheAdapterFlavor,
|
||||
`No cache adaptor configuration found for flavor: ${entityConfiguration.cacheAdapterFlavor}`,
|
||||
`No cache adapter configuration found for flavor: ${entityConfiguration.cacheAdapterFlavor}`,
|
||||
);
|
||||
|
||||
return new EntityTableDataCoordinator(
|
||||
|
||||
@@ -103,7 +103,7 @@ export interface EntityAssociationDefinition<
|
||||
* Field by which to load the instance of associatedEntityClass. If not provided, the
|
||||
* associatedEntityClass instance is fetched by its ID.
|
||||
*/
|
||||
associatedEntityLookupByField?: keyof TAssociatedFields;
|
||||
associatedEntityLookupByField?: TAssociatedSelectedFields;
|
||||
|
||||
/**
|
||||
* What action to perform on the entity at the other end of this edge when the entity on the source end of
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import { AuthorizationResultBasedEntityLoader } from './AuthorizationResultBasedEntityLoader';
|
||||
import { EnforcingEntityLoader } from './EnforcingEntityLoader';
|
||||
import { IEntityClass } from './Entity';
|
||||
import { EntityConstructionUtils } from './EntityConstructionUtils';
|
||||
import { EntityInvalidationUtils } from './EntityInvalidationUtils';
|
||||
import { EntityPrivacyPolicy } from './EntityPrivacyPolicy';
|
||||
import { EntityQueryContext } from './EntityQueryContext';
|
||||
import { ReadonlyEntity } from './ReadonlyEntity';
|
||||
@@ -74,34 +72,4 @@ export class EntityLoader<
|
||||
.getLoaderFactory()
|
||||
.forLoad(this.queryContext, { previousValue: null, cascadingDeleteCause: null });
|
||||
}
|
||||
|
||||
/**
|
||||
* Entity cache invalidation utilities.
|
||||
* Calling into these should only be necessary in rare cases.
|
||||
*/
|
||||
public invalidationUtils(): EntityInvalidationUtils<
|
||||
TFields,
|
||||
TIDField,
|
||||
TViewerContext,
|
||||
TEntity,
|
||||
TPrivacyPolicy,
|
||||
TSelectedFields
|
||||
> {
|
||||
return this.withAuthorizationResults().invalidationUtils;
|
||||
}
|
||||
|
||||
/**
|
||||
* Entity construction and validation utilities.
|
||||
* Calling into these should only be necessary in rare cases.
|
||||
*/
|
||||
public constructionUtils(): EntityConstructionUtils<
|
||||
TFields,
|
||||
TIDField,
|
||||
TViewerContext,
|
||||
TEntity,
|
||||
TPrivacyPolicy,
|
||||
TSelectedFields
|
||||
> {
|
||||
return this.withAuthorizationResults().constructionUtils;
|
||||
}
|
||||
}
|
||||
@@ -39,6 +39,52 @@ export class EntityLoaderFactory<
|
||||
protected readonly metricsAdapter: IEntityMetricsAdapter,
|
||||
) {}
|
||||
|
||||
invalidationUtils(): EntityInvalidationUtils<
|
||||
TFields,
|
||||
TIDField,
|
||||
TViewerContext,
|
||||
TEntity,
|
||||
TPrivacyPolicy,
|
||||
TSelectedFields
|
||||
> {
|
||||
return new EntityInvalidationUtils(
|
||||
this.entityCompanion.entityCompanionDefinition.entityConfiguration,
|
||||
this.entityCompanion.entityCompanionDefinition.entityClass,
|
||||
this.dataManager,
|
||||
this.metricsAdapter,
|
||||
);
|
||||
}
|
||||
|
||||
constructionUtils(
|
||||
viewerContext: TViewerContext,
|
||||
queryContext: EntityQueryContext,
|
||||
privacyPolicyEvaluationContext: EntityPrivacyPolicyEvaluationContext<
|
||||
TFields,
|
||||
TIDField,
|
||||
TViewerContext,
|
||||
TEntity,
|
||||
TSelectedFields
|
||||
>,
|
||||
): EntityConstructionUtils<
|
||||
TFields,
|
||||
TIDField,
|
||||
TViewerContext,
|
||||
TEntity,
|
||||
TPrivacyPolicy,
|
||||
TSelectedFields
|
||||
> {
|
||||
return new EntityConstructionUtils(
|
||||
viewerContext,
|
||||
queryContext,
|
||||
privacyPolicyEvaluationContext,
|
||||
this.entityCompanion.entityCompanionDefinition.entityConfiguration,
|
||||
this.entityCompanion.entityCompanionDefinition.entityClass,
|
||||
this.entityCompanion.entityCompanionDefinition.entitySelectedFields,
|
||||
this.entityCompanion.privacyPolicy,
|
||||
this.metricsAdapter,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Vend loader for loading an entity in a given query context.
|
||||
* @param viewerContext - viewer context of loading user
|
||||
@@ -62,21 +108,10 @@ export class EntityLoaderFactory<
|
||||
TPrivacyPolicy,
|
||||
TSelectedFields
|
||||
> {
|
||||
const invalidationUtils = new EntityInvalidationUtils(
|
||||
this.entityCompanion.entityCompanionDefinition.entityConfiguration,
|
||||
this.entityCompanion.entityCompanionDefinition.entityClass,
|
||||
this.dataManager,
|
||||
this.metricsAdapter,
|
||||
);
|
||||
const constructionUtils = new EntityConstructionUtils(
|
||||
const constructionUtils = this.constructionUtils(
|
||||
viewerContext,
|
||||
queryContext,
|
||||
privacyPolicyEvaluationContext,
|
||||
this.entityCompanion.entityCompanionDefinition.entityConfiguration,
|
||||
this.entityCompanion.entityCompanionDefinition.entityClass,
|
||||
this.entityCompanion.entityCompanionDefinition.entitySelectedFields,
|
||||
this.entityCompanion.privacyPolicy,
|
||||
this.metricsAdapter,
|
||||
);
|
||||
|
||||
return new AuthorizationResultBasedEntityLoader(
|
||||
@@ -84,8 +119,6 @@ export class EntityLoaderFactory<
|
||||
this.entityCompanion.entityCompanionDefinition.entityConfiguration,
|
||||
this.entityCompanion.entityCompanionDefinition.entityClass,
|
||||
this.dataManager,
|
||||
this.metricsAdapter,
|
||||
invalidationUtils,
|
||||
constructionUtils,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -32,6 +32,25 @@ export type EntityPrivacyPolicyEvaluationContext<
|
||||
cascadingDeleteCause: EntityCascadingDeletionInfo | null;
|
||||
};
|
||||
|
||||
export type EntityPrivacyPolicyRuleEvaluationContext<
|
||||
TFields extends Record<string, any>,
|
||||
TIDField extends keyof NonNullable<Pick<TFields, TSelectedFields>>,
|
||||
TViewerContext extends ViewerContext,
|
||||
TEntity extends ReadonlyEntity<TFields, TIDField, TViewerContext, TSelectedFields>,
|
||||
TSelectedFields extends keyof TFields = keyof TFields,
|
||||
> = EntityPrivacyPolicyEvaluationContext<
|
||||
TFields,
|
||||
TIDField,
|
||||
TViewerContext,
|
||||
TEntity,
|
||||
TSelectedFields
|
||||
> & {
|
||||
/**
|
||||
* The CRUD action for which the privacy policy rule is being evaluated.
|
||||
*/
|
||||
action: EntityAuthorizationAction;
|
||||
};
|
||||
|
||||
/**
|
||||
* Evaluation mode for a EntityPrivacyPolicy. Useful when transitioning to
|
||||
* using Entity for privacy.
|
||||
@@ -123,6 +142,9 @@ export abstract class EntityPrivacyPolicy<
|
||||
TEntity extends ReadonlyEntity<TFields, TIDField, TViewerContext, TSelectedFields>,
|
||||
TSelectedFields extends keyof TFields = keyof TFields,
|
||||
> {
|
||||
/**
|
||||
* List of rules to evaluate for create authorization.
|
||||
*/
|
||||
protected readonly createRules: readonly PrivacyPolicyRule<
|
||||
TFields,
|
||||
TIDField,
|
||||
@@ -130,6 +152,10 @@ export abstract class EntityPrivacyPolicy<
|
||||
TEntity,
|
||||
TSelectedFields
|
||||
>[] = [];
|
||||
|
||||
/**
|
||||
* List of rules to evaluate for read authorization.
|
||||
*/
|
||||
protected readonly readRules: readonly PrivacyPolicyRule<
|
||||
TFields,
|
||||
TIDField,
|
||||
@@ -137,6 +163,10 @@ export abstract class EntityPrivacyPolicy<
|
||||
TEntity,
|
||||
TSelectedFields
|
||||
>[] = [];
|
||||
|
||||
/**
|
||||
* List of rules to evaluate for update authorization.
|
||||
*/
|
||||
protected readonly updateRules: readonly PrivacyPolicyRule<
|
||||
TFields,
|
||||
TIDField,
|
||||
@@ -144,6 +174,10 @@ export abstract class EntityPrivacyPolicy<
|
||||
TEntity,
|
||||
TSelectedFields
|
||||
>[] = [];
|
||||
|
||||
/**
|
||||
* List of rules to evaluate for delete authorization.
|
||||
*/
|
||||
protected readonly deleteRules: readonly PrivacyPolicyRule<
|
||||
TFields,
|
||||
TIDField,
|
||||
@@ -156,6 +190,9 @@ export abstract class EntityPrivacyPolicy<
|
||||
* Get the privacy policy evaluation mode and deny handler for this policy.
|
||||
* Defaults to normal enforcing policy.
|
||||
*
|
||||
* DRY_RUN mode is useful for testing and logging the effects of a policy without actually enforcing it, such as when
|
||||
* first rolling out a new policy. Entities that fail the policy will be allowed so caution should be take when using.
|
||||
*
|
||||
* @remarks
|
||||
*
|
||||
* Override to enable dry run evaluation of the policy.
|
||||
@@ -204,7 +241,9 @@ export abstract class EntityPrivacyPolicy<
|
||||
* Authorize an entity against read policy.
|
||||
* @param viewerContext - viewer context of user reading the entity
|
||||
* @param queryContext - query context in which to perform the read authorization
|
||||
* @param evaluationContext - context about the reason for this privacy policy evaluation
|
||||
* @param entity - entity to authorize
|
||||
* @param metricsAdapter - adapter for logging metrics about this authorization
|
||||
* @returns entity if authorized
|
||||
* @throws EntityNotAuthorizedError when not authorized
|
||||
*/
|
||||
@@ -236,7 +275,9 @@ export abstract class EntityPrivacyPolicy<
|
||||
* Authorize an entity against update policy.
|
||||
* @param viewerContext - viewer context of user updating the entity
|
||||
* @param queryContext - query context in which to perform the update authorization
|
||||
* @param evaluationContext - context about the reason for this privacy policy evaluation
|
||||
* @param entity - entity to authorize
|
||||
* @param metricsAdapter - adapter for logging metrics about this authorization
|
||||
* @returns entity if authorized
|
||||
* @throws EntityNotAuthorizedError when not authorized
|
||||
*/
|
||||
@@ -268,7 +309,9 @@ export abstract class EntityPrivacyPolicy<
|
||||
* Authorize an entity against deletion policy.
|
||||
* @param viewerContext - viewer context of user deleting the entity
|
||||
* @param queryContext - query context in which to perform the delete authorization
|
||||
* @param evaluationContext - context about the reason for this privacy policy evaluation
|
||||
* @param entity - entity to authorize
|
||||
* @param metricsAdapter - adapter for logging metrics about this authorization
|
||||
* @returns entity if authorized
|
||||
* @throws EntityNotAuthorizedError when not authorized
|
||||
*/
|
||||
@@ -436,7 +479,7 @@ export abstract class EntityPrivacyPolicy<
|
||||
const ruleEvaluationResult = await rule.evaluateAsync(
|
||||
viewerContext,
|
||||
queryContext,
|
||||
evaluationContext,
|
||||
{ ...evaluationContext, action },
|
||||
entity,
|
||||
);
|
||||
switch (ruleEvaluationResult) {
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { Result } from '@expo/results';
|
||||
|
||||
import { AuthorizationResultBasedEntityLoader } from './AuthorizationResultBasedEntityLoader';
|
||||
import { IEntityClass } from './Entity';
|
||||
import { EntityConstructionUtils } from './EntityConstructionUtils';
|
||||
import { EntityPrivacyPolicy } from './EntityPrivacyPolicy';
|
||||
import { EntityQueryContext } from './EntityQueryContext';
|
||||
import { ReadonlyEntity } from './ReadonlyEntity';
|
||||
import { ViewerContext } from './ViewerContext';
|
||||
import { mapMap } from './utils/collections/maps';
|
||||
@@ -60,7 +62,7 @@ export abstract class EntitySecondaryCacheLoader<
|
||||
> {
|
||||
constructor(
|
||||
private readonly secondaryEntityCache: ISecondaryEntityCache<TFields, TLoadParams>,
|
||||
protected readonly entityLoader: AuthorizationResultBasedEntityLoader<
|
||||
private readonly constructionUtils: EntityConstructionUtils<
|
||||
TFields,
|
||||
TIDField,
|
||||
TViewerContext,
|
||||
@@ -84,10 +86,9 @@ export abstract class EntitySecondaryCacheLoader<
|
||||
);
|
||||
|
||||
// convert value to and from array to reuse complex code
|
||||
const entitiesMap =
|
||||
await this.entityLoader.constructionUtils.constructAndAuthorizeEntitiesAsync(
|
||||
mapMap(loadParamsToFieldObjects, (fieldObject) => (fieldObject ? [fieldObject] : [])),
|
||||
);
|
||||
const entitiesMap = await this.constructionUtils.constructAndAuthorizeEntitiesAsync(
|
||||
mapMap(loadParamsToFieldObjects, (fieldObject) => (fieldObject ? [fieldObject] : [])),
|
||||
);
|
||||
return mapMap(entitiesMap, (fieldObjects) => fieldObjects[0] ?? null);
|
||||
}
|
||||
|
||||
@@ -110,4 +111,53 @@ export abstract class EntitySecondaryCacheLoader<
|
||||
protected abstract fetchObjectsFromDatabaseAsync(
|
||||
loadParamsArray: readonly Readonly<TLoadParams>[],
|
||||
): Promise<ReadonlyMap<Readonly<Readonly<TLoadParams>>, Readonly<TFields> | null>>;
|
||||
|
||||
/**
|
||||
* Helper to get construction utils for instantiating a EntitySecondaryCacheLoader.
|
||||
*
|
||||
* @param entityClass - the entity class for which to get construction utils for
|
||||
* @param viewerContext - the viewer context to use for construction utils
|
||||
* @param queryContext - query context to use for construction utils
|
||||
* @returns construction utils for the given entity class and viewer context
|
||||
*/
|
||||
public static getConstructionUtilsForEntityClass<
|
||||
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,
|
||||
>(
|
||||
entityClass: IEntityClass<
|
||||
TFields,
|
||||
TIDField,
|
||||
TViewerContext,
|
||||
TEntity,
|
||||
TPrivacyPolicy,
|
||||
TSelectedFields
|
||||
>,
|
||||
viewerContext: TViewerContext,
|
||||
queryContext: EntityQueryContext = viewerContext
|
||||
.getViewerScopedEntityCompanionForClass(entityClass)
|
||||
.getQueryContextProvider()
|
||||
.getQueryContext(),
|
||||
): EntityConstructionUtils<
|
||||
TFields,
|
||||
TIDField,
|
||||
TViewerContext,
|
||||
TEntity,
|
||||
TPrivacyPolicy,
|
||||
TSelectedFields
|
||||
> {
|
||||
return viewerContext
|
||||
.getViewerScopedEntityCompanionForClass(entityClass)
|
||||
.getLoaderFactory()
|
||||
.constructionUtils(queryContext, { previousValue: null, cascadingDeleteCause: null });
|
||||
}
|
||||
}
|
||||
@@ -8,16 +8,6 @@ import { EntityDatabaseAdapter } from './EntityDatabaseAdapter';
|
||||
* Allows for passing global configuration to databse adapters, making testing easier.
|
||||
*/
|
||||
export interface IEntityDatabaseAdapterProvider {
|
||||
/**
|
||||
* A unique key for this type of adapter provider, used to avoid installing extensions multiple times.
|
||||
*/
|
||||
getExtensionsKey(): string;
|
||||
|
||||
/**
|
||||
* Install any necessary extensions to the Entity system.
|
||||
*/
|
||||
installExtensions(): void;
|
||||
|
||||
/**
|
||||
* Vend a database adapter.
|
||||
*/
|
||||
|
||||
@@ -6,7 +6,6 @@ import { EnforcingEntityAssociationLoader } from './EnforcingEntityAssociationLo
|
||||
import { EnforcingEntityLoader } from './EnforcingEntityLoader';
|
||||
import { IEntityClass } from './Entity';
|
||||
import { EntityAssociationLoader } from './EntityAssociationLoader';
|
||||
import { EntityConstructionUtils } from './EntityConstructionUtils';
|
||||
import { EntityInvalidationUtils } from './EntityInvalidationUtils';
|
||||
import { EntityLoader } from './EntityLoader';
|
||||
import { EntityPrivacyPolicy } from './EntityPrivacyPolicy';
|
||||
@@ -232,7 +231,7 @@ export abstract class ReadonlyEntity<
|
||||
|
||||
/**
|
||||
* Utilities for entity invalidation.
|
||||
* Calling into these should only be necessary in rare cases.
|
||||
* Call these manually to keep entity cache consistent when performing operations outside of the entity framework.
|
||||
*/
|
||||
static invalidationUtils<
|
||||
TMFields extends object,
|
||||
@@ -258,10 +257,6 @@ export abstract class ReadonlyEntity<
|
||||
TMSelectedFields
|
||||
>,
|
||||
viewerContext: TMViewerContext2,
|
||||
queryContext: EntityQueryContext = viewerContext
|
||||
.getViewerScopedEntityCompanionForClass(this)
|
||||
.getQueryContextProvider()
|
||||
.getQueryContext(),
|
||||
): EntityInvalidationUtils<
|
||||
TMFields,
|
||||
TMIDField,
|
||||
@@ -270,49 +265,9 @@ export abstract class ReadonlyEntity<
|
||||
TMPrivacyPolicy,
|
||||
TMSelectedFields
|
||||
> {
|
||||
return new EntityLoader(viewerContext, queryContext, this).invalidationUtils();
|
||||
}
|
||||
|
||||
/**
|
||||
* Utilities for entity construction.
|
||||
* Calling into these should only be necessary in rare cases.
|
||||
*/
|
||||
static constructionUtils<
|
||||
TMFields extends object,
|
||||
TMIDField extends keyof NonNullable<Pick<TMFields, TMSelectedFields>>,
|
||||
TMViewerContext extends ViewerContext,
|
||||
TMViewerContext2 extends TMViewerContext,
|
||||
TMEntity extends ReadonlyEntity<TMFields, TMIDField, TMViewerContext, TMSelectedFields>,
|
||||
TMPrivacyPolicy extends EntityPrivacyPolicy<
|
||||
TMFields,
|
||||
TMIDField,
|
||||
TMViewerContext,
|
||||
TMEntity,
|
||||
TMSelectedFields
|
||||
>,
|
||||
TMSelectedFields extends keyof TMFields = keyof TMFields,
|
||||
>(
|
||||
this: IEntityClass<
|
||||
TMFields,
|
||||
TMIDField,
|
||||
TMViewerContext,
|
||||
TMEntity,
|
||||
TMPrivacyPolicy,
|
||||
TMSelectedFields
|
||||
>,
|
||||
viewerContext: TMViewerContext2,
|
||||
queryContext: EntityQueryContext = viewerContext
|
||||
return viewerContext
|
||||
.getViewerScopedEntityCompanionForClass(this)
|
||||
.getQueryContextProvider()
|
||||
.getQueryContext(),
|
||||
): EntityConstructionUtils<
|
||||
TMFields,
|
||||
TMIDField,
|
||||
TMViewerContext,
|
||||
TMEntity,
|
||||
TMPrivacyPolicy,
|
||||
TMSelectedFields
|
||||
> {
|
||||
return new EntityLoader(viewerContext, queryContext, this).constructionUtils();
|
||||
.getLoaderFactory()
|
||||
.invalidationUtils();
|
||||
}
|
||||
}
|
||||
@@ -64,30 +64,30 @@ export class ViewerContext {
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the regular (non-transactional) query context for a database adaptor flavor.
|
||||
* @param databaseAdaptorFlavor - database adaptor flavor
|
||||
* Get the regular (non-transactional) query context for a database adapter flavor.
|
||||
* @param databaseAdapterFlavor - database adapter flavor
|
||||
*/
|
||||
getQueryContextForDatabaseAdaptorFlavor(
|
||||
databaseAdaptorFlavor: DatabaseAdapterFlavor,
|
||||
getQueryContextForDatabaseAdapterFlavor(
|
||||
databaseAdapterFlavor: DatabaseAdapterFlavor,
|
||||
): EntityQueryContext {
|
||||
return this.entityCompanionProvider
|
||||
.getQueryContextProviderForDatabaseAdaptorFlavor(databaseAdaptorFlavor)
|
||||
.getQueryContextProviderForDatabaseAdapterFlavor(databaseAdapterFlavor)
|
||||
.getQueryContext();
|
||||
}
|
||||
|
||||
/**
|
||||
* Run a transaction of specified database adaptor flavor and execute the provided
|
||||
* Run a transaction of specified database adapter flavor and execute the provided
|
||||
* transaction-scoped closure within the transaction.
|
||||
* @param databaseAdaptorFlavor - databaseAdaptorFlavor
|
||||
* @param databaseAdapterFlavor - databaseAdapterFlavor
|
||||
* @param transactionScope - async callback to execute within the transaction
|
||||
*/
|
||||
async runInTransactionForDatabaseAdaptorFlavorAsync<TResult>(
|
||||
databaseAdaptorFlavor: DatabaseAdapterFlavor,
|
||||
async runInTransactionForDatabaseAdapterFlavorAsync<TResult>(
|
||||
databaseAdapterFlavor: DatabaseAdapterFlavor,
|
||||
transactionScope: (queryContext: EntityTransactionalQueryContext) => Promise<TResult>,
|
||||
transactionConfig?: TransactionConfig,
|
||||
): Promise<TResult> {
|
||||
return await this.entityCompanionProvider
|
||||
.getQueryContextProviderForDatabaseAdaptorFlavor(databaseAdaptorFlavor)
|
||||
.getQueryContextProviderForDatabaseAdapterFlavor(databaseAdapterFlavor)
|
||||
.getQueryContext()
|
||||
.runInTransactionIfNotInTransactionAsync(transactionScope, transactionConfig);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { AuthorizationResultBasedEntityLoader } from './AuthorizationResultBasedEntityLoader';
|
||||
import { EntityConstructionUtils } from './EntityConstructionUtils';
|
||||
import { EntityInvalidationUtils } from './EntityInvalidationUtils';
|
||||
import { EntityLoaderFactory } from './EntityLoaderFactory';
|
||||
import { EntityPrivacyPolicy, EntityPrivacyPolicyEvaluationContext } from './EntityPrivacyPolicy';
|
||||
import { EntityQueryContext } from './EntityQueryContext';
|
||||
@@ -34,6 +36,41 @@ export class ViewerScopedEntityLoaderFactory<
|
||||
private readonly viewerContext: TViewerContext,
|
||||
) {}
|
||||
|
||||
invalidationUtils(): EntityInvalidationUtils<
|
||||
TFields,
|
||||
TIDField,
|
||||
TViewerContext,
|
||||
TEntity,
|
||||
TPrivacyPolicy,
|
||||
TSelectedFields
|
||||
> {
|
||||
return this.entityLoaderFactory.invalidationUtils();
|
||||
}
|
||||
|
||||
constructionUtils(
|
||||
queryContext: EntityQueryContext,
|
||||
privacyPolicyEvaluationContext: EntityPrivacyPolicyEvaluationContext<
|
||||
TFields,
|
||||
TIDField,
|
||||
TViewerContext,
|
||||
TEntity,
|
||||
TSelectedFields
|
||||
>,
|
||||
): EntityConstructionUtils<
|
||||
TFields,
|
||||
TIDField,
|
||||
TViewerContext,
|
||||
TEntity,
|
||||
TPrivacyPolicy,
|
||||
TSelectedFields
|
||||
> {
|
||||
return this.entityLoaderFactory.constructionUtils(
|
||||
this.viewerContext,
|
||||
queryContext,
|
||||
privacyPolicyEvaluationContext,
|
||||
);
|
||||
}
|
||||
|
||||
forLoad(
|
||||
queryContext: EntityQueryContext,
|
||||
privacyPolicyEvaluationContext: EntityPrivacyPolicyEvaluationContext<
|
||||
|
||||
@@ -7,7 +7,6 @@ import { EntityCompanionDefinition } from '../EntityCompanionProvider';
|
||||
import { EntityConfiguration } from '../EntityConfiguration';
|
||||
import { EntityConstructionUtils } from '../EntityConstructionUtils';
|
||||
import { StringField } from '../EntityFields';
|
||||
import { EntityInvalidationUtils } from '../EntityInvalidationUtils';
|
||||
import { EntityPrivacyPolicy, EntityPrivacyPolicyEvaluationContext } from '../EntityPrivacyPolicy';
|
||||
import { ViewerContext } from '../ViewerContext';
|
||||
import { EntityDataManager } from '../internal/EntityDataManager';
|
||||
@@ -165,12 +164,6 @@ describe(AuthorizationResultBasedEntityLoader, () => {
|
||||
metricsAdapter,
|
||||
TestEntity.name,
|
||||
);
|
||||
const invalidationUtils = new EntityInvalidationUtils(
|
||||
testEntityConfiguration,
|
||||
TestEntity,
|
||||
dataManager,
|
||||
metricsAdapter,
|
||||
);
|
||||
const constructionUtils = new EntityConstructionUtils(
|
||||
viewerContext,
|
||||
queryContext,
|
||||
@@ -186,8 +179,6 @@ describe(AuthorizationResultBasedEntityLoader, () => {
|
||||
testEntityConfiguration,
|
||||
TestEntity,
|
||||
dataManager,
|
||||
metricsAdapter,
|
||||
invalidationUtils,
|
||||
constructionUtils,
|
||||
);
|
||||
|
||||
|
||||
Loaded 100 of 135 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user