mirror of
https://github.com/expo/entity.git
synced 2026-11-15 13:18:13 -10:00
Compare commits
54
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7dab49a36d | ||
|
|
21f2f6f461 | ||
|
|
4126dcd8f2 | ||
|
|
6889178e60 | ||
|
|
a0d3d30265 | ||
|
|
d0825c0446 | ||
|
|
e8440f11c0 | ||
|
|
baa1674629 | ||
|
|
25520c68e8 | ||
|
|
905e8522b3 | ||
|
|
e263bd218c | ||
|
|
e876cfb27b | ||
|
|
b9db8a4717 | ||
|
|
459a5ff850 | ||
|
|
beda973c0c | ||
|
|
b258dad2ed | ||
|
|
976e2c3386 | ||
|
|
e32c4c2bcc | ||
|
|
2c4c649a4b | ||
|
|
898f71ce08 | ||
|
|
90ae6e299b | ||
|
|
53b0604152 | ||
|
|
018a40bc01 | ||
|
|
6849468d15 | ||
|
|
c60f4cd455 | ||
|
|
820fe88217 | ||
|
|
6c0838dc57 | ||
|
|
5c11b598c4 | ||
|
|
c946630a82 | ||
|
|
15adeb88cf | ||
|
|
9940fcbed2 | ||
|
|
0615ffacff | ||
|
|
5f3f31bb7c | ||
|
|
097ac65fc4 | ||
|
|
e71ae5fcba | ||
|
|
3065f360db | ||
|
|
c3fe15e655 | ||
|
|
2d41777be0 | ||
|
|
45da844a5c | ||
|
|
4dc156c7a8 | ||
|
|
cc78029117 | ||
|
|
517d57b5df | ||
|
|
c933db1f8a | ||
|
|
d9377a290b | ||
|
|
457f58ad2c | ||
|
|
ed6b2e0553 | ||
|
|
40b409f6f8 | ||
|
|
51d632da16 | ||
|
|
3d4aa0e9ef | ||
|
|
f5c728be56 | ||
|
|
285f22c461 | ||
|
|
3ec4b5f0cd | ||
|
|
f72cbcaf5a | ||
|
|
fb720611f0 |
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,57 @@
|
||||
All notable changes to this project will be documented in this file.
|
||||
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
|
||||
|
||||
# [0.58.0](https://github.com/expo/entity/compare/v0.57.0...v0.58.0) (2026-03-02)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- escape all matching portions of parts of cacher cache keys ([#486](https://github.com/expo/entity/issues/486)) ([21f2f6f](https://github.com/expo/entity/commit/21f2f6f461ee771e8b1c1b22dbaf9b275094c0db))
|
||||
- fix some security review suggestions ([#485](https://github.com/expo/entity/issues/485)) ([e263bd2](https://github.com/expo/entity/commit/e263bd218cda579365766d0918c4961304bd1d9f))
|
||||
|
||||
# [0.57.0](https://github.com/expo/entity/compare/v0.56.0...v0.57.0) (2026-02-28)
|
||||
|
||||
**Note:** Version bump only for package root
|
||||
|
||||
# [0.56.0](https://github.com/expo/entity/compare/v0.55.0...v0.56.0) (2026-02-28)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Add default for TSelectedFields for application-instantiated types ([#438](https://github.com/expo/entity/issues/438)) ([3d4aa0e](https://github.com/expo/entity/commit/3d4aa0e9efa519a36f3db69bd8f117a993c100fa))
|
||||
- Apply same viewer context subclass type mechanism to knexLoader and related methods ([#472](https://github.com/expo/entity/issues/472)) ([018a40b](https://github.com/expo/entity/commit/018a40bc01d579ffbf88f308e641921e6fc39097))
|
||||
- correct discriminated union type for forward/backward pagination ([#474](https://github.com/expo/entity/issues/474)) ([90ae6e2](https://github.com/expo/entity/commit/90ae6e299bda26ac9a80d83e83334d4152c022dd))
|
||||
- correct pagination behavior for standard pagination with descending ordering clauses ([#477](https://github.com/expo/entity/issues/477)) ([e32c4c2](https://github.com/expo/entity/commit/e32c4c2bcc05f225a556f95c90f1ee0f4e24de74))
|
||||
- correct precedence parentheses for SQLFragment joins ([#476](https://github.com/expo/entity/issues/476)) ([2c4c649](https://github.com/expo/entity/commit/2c4c649a4bca5bc9ffdc41eb3135b2dfe4db5e57)), closes [/github.com/expo/entity/blob/main/packages/entity-database-adapter-knex/src/internal/EntityKnexDataManager.ts#L303](https://github.com//github.com/expo/entity/blob/main/packages/entity-database-adapter-knex/src/internal/EntityKnexDataManager.ts/issues/L303)
|
||||
- **deps:** update dependency @apollo/server to v5.4.0 [security] ([#420](https://github.com/expo/entity/issues/420)) ([cd81c5a](https://github.com/expo/entity/commit/cd81c5a5b6be43ef60248eeea4031a883dd1eae8))
|
||||
- **deps:** update dependency @koa/bodyparser to v6.1.0 ([#405](https://github.com/expo/entity/issues/405)) ([2dda22d](https://github.com/expo/entity/commit/2dda22d761f87851e3fcccc8443597daf8ac42e8))
|
||||
- **deps:** update dependency @koa/router to v15.3.0 ([#406](https://github.com/expo/entity/issues/406)) ([9e88cf6](https://github.com/expo/entity/commit/9e88cf6aab76902c65994d28de8dab73f1267021))
|
||||
- **deps:** update dependency graphql to v16.13.0 ([#479](https://github.com/expo/entity/issues/479)) ([976e2c3](https://github.com/expo/entity/commit/976e2c33867da71a2920d050c4086e2af8f6d54d))
|
||||
- **deps:** update dependency koa to v3.1.2 [security] ([#484](https://github.com/expo/entity/issues/484)) ([b258dad](https://github.com/expo/entity/commit/b258dad2ed69c473f04d57d26910eab2032d142c))
|
||||
- eliminate some unnecessary any casts ([#448](https://github.com/expo/entity/issues/448)) ([cc78029](https://github.com/expo/entity/commit/cc78029117c14510b0934b5e0d19876a45570f8d))
|
||||
- explicitly document behavior when cursor entity no longer exists ([#453](https://github.com/expo/entity/issues/453)) ([4dc156c](https://github.com/expo/entity/commit/4dc156c7a8dc50bc9e2802be133b9e258b289f54)), closes [#422](https://github.com/expo/entity/issues/422) [#431](https://github.com/expo/entity/issues/431)
|
||||
- move EntityPrivacyUtils back into core package ([#429](https://github.com/expo/entity/issues/429)) ([56ec27d](https://github.com/expo/entity/commit/56ec27d5f201d83930a2cc05dfc380a054bff1e1))
|
||||
- pass in augmentable classes to installation ([#437](https://github.com/expo/entity/issues/437)) ([f5c728b](https://github.com/expo/entity/commit/f5c728be565e01c0a8ace9ef51f6de8f49184dab)), closes [/github.com/expo/entity/pull/410#pullrequestreview-3765893898](https://github.com//github.com/expo/entity/pull/410/issues/pullrequestreview-3765893898)
|
||||
- refactor install method to instead be free functions ([#441](https://github.com/expo/entity/issues/441)) ([51d632d](https://github.com/expo/entity/commit/51d632da1619dc039d5c41ce6533f63254ec4337)), closes [#410](https://github.com/expo/entity/issues/410)
|
||||
- Reorganize constructionUtils to make non-public ([#454](https://github.com/expo/entity/issues/454)) ([d9377a2](https://github.com/expo/entity/commit/d9377a290b7e0c6803f9e83537eddb7204bc9320)), closes [#410](https://github.com/expo/entity/issues/410)
|
||||
- revert change to codemod now that Entity.knexLoader is supported again via subclass ([#443](https://github.com/expo/entity/issues/443)) ([ed6b2e0](https://github.com/expo/entity/commit/ed6b2e05539c77ee2abd2c6da74e2a451967d06e)), closes [#441](https://github.com/expo/entity/issues/441)
|
||||
- Type idField in EntityConfiguration as TIDField ([#425](https://github.com/expo/entity/issues/425)) ([be40bcf](https://github.com/expo/entity/commit/be40bcffa6e7db9b7ad75e1a80bbb48ff417c6c5))
|
||||
- update integration test to use BlahEntity.knexLoader syntax ([#444](https://github.com/expo/entity/issues/444)) ([457f58a](https://github.com/expo/entity/commit/457f58ad2c4e1cf09f3132f3b9af5008ba0290c7)), closes [#441](https://github.com/expo/entity/issues/441)
|
||||
- Use TSelectedFields for knex loader order by method ([#424](https://github.com/expo/entity/issues/424)) ([a9d09f4](https://github.com/expo/entity/commit/a9d09f42b67378d06133bcfb5b99c9afb17aac0d))
|
||||
|
||||
### Features
|
||||
|
||||
- Add authorization action to EntityPrivacyPolicyRuleEvaluationContext ([#464](https://github.com/expo/entity/issues/464)) ([e71ae5f](https://github.com/expo/entity/commit/e71ae5fcbae95096e0d6a42a8c960896ab83ccfb))
|
||||
- add codemod for upcoming v0.55.0-v0.56.0 ([#413](https://github.com/expo/entity/issues/413)) ([aba0ce5](https://github.com/expo/entity/commit/aba0ce5b5f38cb7b01956cde14a4714e2a03abb4)), closes [#407](https://github.com/expo/entity/issues/407) [-#410](https://github.com/-/issues/410)
|
||||
- add entity-database-adapter-knex-testing-utils containing StubPostgresDatabaseAdapter ([#412](https://github.com/expo/entity/issues/412)) ([eeccf59](https://github.com/expo/entity/commit/eeccf594c11bf090aa83bbf71fb4ead8668aec7d))
|
||||
- add entityField SQL helper ([#481](https://github.com/expo/entity/issues/481)) ([459a5ff](https://github.com/expo/entity/commit/459a5ff85014a8c7396c0856dffacb1491ccb963))
|
||||
- Add ilike and trigram similarity search to pagination ([#431](https://github.com/expo/entity/issues/431)) ([f72cbca](https://github.com/expo/entity/commit/f72cbcaf5a722e6bb8299644ec32b07f1d3362a9)), closes [#422](https://github.com/expo/entity/issues/422)
|
||||
- add method to get pagination cursor for single entity ([#475](https://github.com/expo/entity/issues/475)) ([898f71c](https://github.com/expo/entity/commit/898f71ce082c2593585575b60bd6ef2546717124))
|
||||
- Add paginated loader to entity-database-adapter-knex ([#422](https://github.com/expo/entity/issues/422)) ([58deffd](https://github.com/expo/entity/commit/58deffd426b0214073ede608cd47c04db2596d60))
|
||||
- Add pagination max page size configuration ([#436](https://github.com/expo/entity/issues/436)) ([3ec4b5f](https://github.com/expo/entity/commit/3ec4b5f0cd53bf18d6bcfa27dd29b70d3f4d5f19))
|
||||
- add PostgresEntity/ReadonlyPostgresEntity classes that expose knexLoader methods ([#442](https://github.com/expo/entity/issues/442)) ([40b409f](https://github.com/expo/entity/commit/40b409f6f889febf8b90b8837caecac26ff64174)), closes [#441](https://github.com/expo/entity/issues/441)
|
||||
- Add raw sqlfragment orderBy capability for pagination ([#457](https://github.com/expo/entity/issues/457)) ([0615ffa](https://github.com/expo/entity/commit/0615ffacffbe9e903a9bf03b171c102f0554b992))
|
||||
- add support for nulls ordering in order by clauses ([#468](https://github.com/expo/entity/issues/468)) ([6c0838d](https://github.com/expo/entity/commit/6c0838dc577a3b6190e3551ee834925d7bd53bc6))
|
||||
- support nullable search fields with postgres transforms ([#470](https://github.com/expo/entity/issues/470)) ([53b0604](https://github.com/expo/entity/commit/53b0604152b8ca5ad9fc616d18478f93473c7eb0))
|
||||
|
||||
# [0.55.0](https://github.com/expo/entity/compare/v0.54.0...v0.55.0) (2026-01-27)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"packages": ["packages/*"],
|
||||
"npmClient": "yarn",
|
||||
"version": "0.55.0",
|
||||
"version": "0.58.0",
|
||||
"$schema": "node_modules/lerna/schemas/lerna-schema.json"
|
||||
}
|
||||
+5
-5
@@ -26,23 +26,23 @@
|
||||
"@tsconfig/strictest": "2.0.8",
|
||||
"@types/babel__core": "7.20.5",
|
||||
"@types/babel__preset-env": "7.10.0",
|
||||
"@types/node": "24.10.13",
|
||||
"@types/node": "24.10.15",
|
||||
"ctix": "2.7.2",
|
||||
"eslint": "9.39.2",
|
||||
"eslint": "9.39.3",
|
||||
"eslint-config-universe": "15.0.3",
|
||||
"eslint-plugin-tsdoc": "0.5.0",
|
||||
"eslint-plugin-tsdoc": "0.5.2",
|
||||
"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,20 @@
|
||||
All notable changes to this project will be documented in this file.
|
||||
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
|
||||
|
||||
# [0.58.0](https://github.com/expo/entity/compare/v0.57.0...v0.58.0) (2026-03-02)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- escape all matching portions of parts of cacher cache keys ([#486](https://github.com/expo/entity/issues/486)) ([21f2f6f](https://github.com/expo/entity/commit/21f2f6f461ee771e8b1c1b22dbaf9b275094c0db))
|
||||
|
||||
# [0.57.0](https://github.com/expo/entity/compare/v0.56.0...v0.57.0) (2026-02-28)
|
||||
|
||||
**Note:** Version bump only for package @expo/entity-cache-adapter-local-memory
|
||||
|
||||
# [0.56.0](https://github.com/expo/entity/compare/v0.55.0...v0.56.0) (2026-02-28)
|
||||
|
||||
**Note:** Version bump only for package @expo/entity-cache-adapter-local-memory
|
||||
|
||||
# [0.55.0](https://github.com/expo/entity/compare/v0.54.0...v0.55.0) (2026-01-27)
|
||||
|
||||
**Note:** Version bump only for package @expo/entity-cache-adapter-local-memory
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@expo/entity-cache-adapter-local-memory",
|
||||
"version": "0.55.0",
|
||||
"version": "0.58.0",
|
||||
"description": "Cross-request local memory cache adapter for @expo/entity",
|
||||
"files": [
|
||||
"build",
|
||||
|
||||
@@ -122,7 +122,7 @@ export class GenericLocalMemoryCacher<
|
||||
|
||||
const delimiter = ':';
|
||||
const escapedParts = parts.map((part) =>
|
||||
part.replace('\\', '\\\\').replace(delimiter, `\\${delimiter}`),
|
||||
part.replaceAll('\\', '\\\\').replaceAll(delimiter, `\\${delimiter}`),
|
||||
);
|
||||
return escapedParts.join(delimiter);
|
||||
}
|
||||
|
||||
@@ -3,6 +3,22 @@
|
||||
All notable changes to this project will be documented in this file.
|
||||
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
|
||||
|
||||
# [0.58.0](https://github.com/expo/entity/compare/v0.57.0...v0.58.0) (2026-03-02)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- escape all matching portions of parts of cacher cache keys ([#486](https://github.com/expo/entity/issues/486)) ([21f2f6f](https://github.com/expo/entity/commit/21f2f6f461ee771e8b1c1b22dbaf9b275094c0db))
|
||||
|
||||
# [0.57.0](https://github.com/expo/entity/compare/v0.56.0...v0.57.0) (2026-02-28)
|
||||
|
||||
**Note:** Version bump only for package @expo/entity-cache-adapter-redis
|
||||
|
||||
# [0.56.0](https://github.com/expo/entity/compare/v0.55.0...v0.56.0) (2026-02-28)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- eliminate some unnecessary any casts ([#448](https://github.com/expo/entity/issues/448)) ([cc78029](https://github.com/expo/entity/commit/cc78029117c14510b0934b5e0d19876a45570f8d))
|
||||
|
||||
# [0.55.0](https://github.com/expo/entity/compare/v0.54.0...v0.55.0) (2026-01-27)
|
||||
|
||||
**Note:** Version bump only for package @expo/entity-cache-adapter-redis
|
||||
|
||||
@@ -16,7 +16,7 @@ const genericRedisCacherContext = {
|
||||
makeKeyFn(...parts: string[]): string {
|
||||
const delimiter = ':';
|
||||
const escapedParts = parts.map((part) =>
|
||||
part.replace('\\', '\\\\').replace(delimiter, `\\${delimiter}`)
|
||||
part.replaceAll('\\', '\\\\').replaceAll(delimiter, `\\${delimiter}`)
|
||||
);
|
||||
return escapedParts.join(delimiter);
|
||||
},
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@expo/entity-cache-adapter-redis",
|
||||
"version": "0.55.0",
|
||||
"version": "0.58.0",
|
||||
"description": "Redis cache adapter for @expo/entity",
|
||||
"files": [
|
||||
"build",
|
||||
@@ -37,7 +37,7 @@
|
||||
"@expo/batcher": "1.0.0",
|
||||
"@expo/entity-testing-utils": "workspace:^",
|
||||
"@jest/globals": "30.2.0",
|
||||
"ioredis": "5.9.3",
|
||||
"ioredis": "5.10.0",
|
||||
"ts-mockito": "2.6.1",
|
||||
"typescript": "5.9.3"
|
||||
}
|
||||
|
||||
@@ -215,7 +215,7 @@ export class GenericRedisCacher<
|
||||
this.context.cacheKeyPrefix,
|
||||
cacheKeyType,
|
||||
this.entityConfiguration.tableName,
|
||||
`v2.${cacheKeyVersion}`,
|
||||
`v3.${cacheKeyVersion}`,
|
||||
...parts,
|
||||
);
|
||||
}
|
||||
|
||||
+1
-1
@@ -80,7 +80,7 @@ describe(GenericRedisCacher, () => {
|
||||
makeKeyFn(...parts: string[]): string {
|
||||
const delimiter = ':';
|
||||
const escapedParts = parts.map((part) =>
|
||||
part.replace('\\', '\\\\').replace(delimiter, `\\${delimiter}`),
|
||||
part.replaceAll('\\', '\\\\').replaceAll(delimiter, `\\${delimiter}`),
|
||||
);
|
||||
return escapedParts.join(delimiter);
|
||||
},
|
||||
|
||||
+1
-1
@@ -31,7 +31,7 @@ describe(GenericRedisCacher, () => {
|
||||
makeKeyFn(...parts: string[]): string {
|
||||
const delimiter = ':';
|
||||
const escapedParts = parts.map((part) =>
|
||||
part.replace('\\', '\\\\').replace(delimiter, `\\${delimiter}`),
|
||||
part.replaceAll('\\', '\\\\').replaceAll(delimiter, `\\${delimiter}`),
|
||||
);
|
||||
return escapedParts.join(delimiter);
|
||||
},
|
||||
|
||||
+1
-1
@@ -26,7 +26,7 @@ describe(GenericRedisCacher, () => {
|
||||
makeKeyFn(...parts: string[]): string {
|
||||
const delimiter = ':';
|
||||
const escapedParts = parts.map((part) =>
|
||||
part.replace('\\', '\\\\').replace(delimiter, `\\${delimiter}`),
|
||||
part.replaceAll('\\', '\\\\').replaceAll(delimiter, `\\${delimiter}`),
|
||||
);
|
||||
return escapedParts.join(delimiter);
|
||||
},
|
||||
|
||||
@@ -23,7 +23,7 @@ describe(GenericRedisCacher, () => {
|
||||
makeKeyFn(...parts: string[]): string {
|
||||
const delimiter = ':';
|
||||
const escapedParts = parts.map((part) =>
|
||||
part.replace('\\', '\\\\').replace(delimiter, `\\${delimiter}`),
|
||||
part.replaceAll('\\', '\\\\').replaceAll(delimiter, `\\${delimiter}`),
|
||||
);
|
||||
return escapedParts.join(delimiter);
|
||||
},
|
||||
|
||||
@@ -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>();
|
||||
@@ -212,7 +212,7 @@ describe(GenericRedisCacher, () => {
|
||||
new SingleFieldValueHolder('wat'),
|
||||
);
|
||||
expect(cacheKeys).toHaveLength(1);
|
||||
expect(cacheKeys[0]).toBe('hello-:single:blah:v2.2:id:wat');
|
||||
expect(cacheKeys[0]).toBe('hello-:single:blah:v3.2:id:wat');
|
||||
|
||||
await genericCacher.invalidateManyAsync(cacheKeys);
|
||||
verify(mockRedisClient.del(...cacheKeys)).once();
|
||||
@@ -240,9 +240,9 @@ describe(GenericRedisCacher, () => {
|
||||
new SingleFieldValueHolder('wat'),
|
||||
);
|
||||
expect(cacheKeys).toHaveLength(3);
|
||||
expect(cacheKeys[0]).toBe('hello-:single:blah:v2.1:id:wat');
|
||||
expect(cacheKeys[1]).toBe('hello-:single:blah:v2.2:id:wat');
|
||||
expect(cacheKeys[2]).toBe('hello-:single:blah:v2.3:id:wat');
|
||||
expect(cacheKeys[0]).toBe('hello-:single:blah:v3.1:id:wat');
|
||||
expect(cacheKeys[1]).toBe('hello-:single:blah:v3.2:id:wat');
|
||||
expect(cacheKeys[2]).toBe('hello-:single:blah:v3.3:id:wat');
|
||||
|
||||
await genericCacher.invalidateManyAsync(cacheKeys);
|
||||
verify(mockRedisClient.del(...cacheKeys)).once();
|
||||
@@ -278,10 +278,10 @@ describe(GenericRedisCacher, () => {
|
||||
new SingleFieldValueHolder('wat'),
|
||||
);
|
||||
expect(cacheKeys).toHaveLength(4);
|
||||
expect(cacheKeys[0]).toBe('hello-:single:blah:v2.2:id:wat');
|
||||
expect(cacheKeys[1]).toBe('hello-:single:blah:v2.3:id:wat');
|
||||
expect(cacheKeys[2]).toBe('hello-:single:blah:v2.4:id:wat');
|
||||
expect(cacheKeys[3]).toBe('hello-:single:blah:v2.5:id:wat');
|
||||
expect(cacheKeys[0]).toBe('hello-:single:blah:v3.2:id:wat');
|
||||
expect(cacheKeys[1]).toBe('hello-:single:blah:v3.3:id:wat');
|
||||
expect(cacheKeys[2]).toBe('hello-:single:blah:v3.4:id:wat');
|
||||
expect(cacheKeys[3]).toBe('hello-:single:blah:v3.5:id:wat');
|
||||
|
||||
await genericCacher.invalidateManyAsync(cacheKeys);
|
||||
verify(mockRedisClient.del(...cacheKeys)).once();
|
||||
|
||||
@@ -3,6 +3,25 @@
|
||||
All notable changes to this project will be documented in this file.
|
||||
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
|
||||
|
||||
# [0.58.0](https://github.com/expo/entity/compare/v0.57.0...v0.58.0) (2026-03-02)
|
||||
|
||||
**Note:** Version bump only for package @expo/entity-codemod
|
||||
|
||||
# [0.57.0](https://github.com/expo/entity/compare/v0.56.0...v0.57.0) (2026-02-28)
|
||||
|
||||
**Note:** Version bump only for package @expo/entity-codemod
|
||||
|
||||
# [0.56.0](https://github.com/expo/entity/compare/v0.55.0...v0.56.0) (2026-02-28)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- refactor install method to instead be free functions ([#441](https://github.com/expo/entity/issues/441)) ([51d632d](https://github.com/expo/entity/commit/51d632da1619dc039d5c41ce6533f63254ec4337)), closes [#410](https://github.com/expo/entity/issues/410)
|
||||
- revert change to codemod now that Entity.knexLoader is supported again via subclass ([#443](https://github.com/expo/entity/issues/443)) ([ed6b2e0](https://github.com/expo/entity/commit/ed6b2e05539c77ee2abd2c6da74e2a451967d06e)), closes [#441](https://github.com/expo/entity/issues/441)
|
||||
|
||||
### Features
|
||||
|
||||
- add codemod for upcoming v0.55.0-v0.56.0 ([#413](https://github.com/expo/entity/issues/413)) ([aba0ce5](https://github.com/expo/entity/commit/aba0ce5b5f38cb7b01956cde14a4714e2a03abb4)), closes [#407](https://github.com/expo/entity/issues/407) [-#410](https://github.com/-/issues/410)
|
||||
|
||||
# [0.55.0](https://github.com/expo/entity/compare/v0.54.0...v0.55.0) (2026-01-27)
|
||||
|
||||
**Note:** Version bump only for package @expo/entity-codemod
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@expo/entity-codemod",
|
||||
"version": "0.55.0",
|
||||
"version": "0.58.0",
|
||||
"description": "jscodeshift codemods for @expo/entity upgrades",
|
||||
"files": [
|
||||
"build",
|
||||
@@ -29,7 +29,7 @@
|
||||
"devDependencies": {
|
||||
"@jest/globals": "30.2.0",
|
||||
"@types/jscodeshift": "17.3.0",
|
||||
"@types/node": "24.10.13",
|
||||
"@types/node": "24.10.15",
|
||||
"jscodeshift": "17.3.0",
|
||||
"typescript": "5.9.3"
|
||||
}
|
||||
|
||||
@@ -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,27 @@
|
||||
# Change Log
|
||||
|
||||
All notable changes to this project will be documented in this file.
|
||||
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
|
||||
|
||||
# [0.58.0](https://github.com/expo/entity/compare/v0.57.0...v0.58.0) (2026-03-02)
|
||||
|
||||
**Note:** Version bump only for package @expo/entity-database-adapter-knex-testing-utils
|
||||
|
||||
# [0.57.0](https://github.com/expo/entity/compare/v0.56.0...v0.57.0) (2026-02-28)
|
||||
|
||||
**Note:** Version bump only for package @expo/entity-database-adapter-knex-testing-utils
|
||||
|
||||
# [0.56.0](https://github.com/expo/entity/compare/v0.55.0...v0.56.0) (2026-02-28)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- move EntityPrivacyUtils back into core package ([#429](https://github.com/expo/entity/issues/429)) ([56ec27d](https://github.com/expo/entity/commit/56ec27d5f201d83930a2cc05dfc380a054bff1e1))
|
||||
- pass in augmentable classes to installation ([#437](https://github.com/expo/entity/issues/437)) ([f5c728b](https://github.com/expo/entity/commit/f5c728be565e01c0a8ace9ef51f6de8f49184dab)), closes [/github.com/expo/entity/pull/410#pullrequestreview-3765893898](https://github.com//github.com/expo/entity/pull/410/issues/pullrequestreview-3765893898)
|
||||
- refactor install method to instead be free functions ([#441](https://github.com/expo/entity/issues/441)) ([51d632d](https://github.com/expo/entity/commit/51d632da1619dc039d5c41ce6533f63254ec4337)), closes [#410](https://github.com/expo/entity/issues/410)
|
||||
|
||||
### Features
|
||||
|
||||
- add entity-database-adapter-knex-testing-utils containing StubPostgresDatabaseAdapter ([#412](https://github.com/expo/entity/issues/412)) ([eeccf59](https://github.com/expo/entity/commit/eeccf594c11bf090aa83bbf71fb4ead8668aec7d))
|
||||
- add entityField SQL helper ([#481](https://github.com/expo/entity/issues/481)) ([459a5ff](https://github.com/expo/entity/commit/459a5ff85014a8c7396c0856dffacb1491ccb963))
|
||||
- Add raw sqlfragment orderBy capability for pagination ([#457](https://github.com/expo/entity/issues/457)) ([0615ffa](https://github.com/expo/entity/commit/0615ffacffbe9e903a9bf03b171c102f0554b992))
|
||||
- add support for nulls ordering in order by clauses ([#468](https://github.com/expo/entity/issues/468)) ([6c0838d](https://github.com/expo/entity/commit/6c0838dc577a3b6190e3551ee834925d7bd53bc6))
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@expo/entity-database-adapter-knex-testing-utils",
|
||||
"version": "0.55.0",
|
||||
"version": "0.58.0",
|
||||
"description": "Testing utilities for applications using Entity with Knex database adapter",
|
||||
"files": [
|
||||
"build",
|
||||
@@ -38,7 +38,7 @@
|
||||
"devDependencies": {
|
||||
"@jest/globals": "30.2.0",
|
||||
"@types/invariant": "2.2.37",
|
||||
"@types/node": "24.10.13",
|
||||
"@types/node": "24.10.15",
|
||||
"@types/uuid": "10.0.0",
|
||||
"typescript": "5.9.3"
|
||||
}
|
||||
|
||||
+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');
|
||||
}
|
||||
|
||||
+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,46 @@
|
||||
All notable changes to this project will be documented in this file.
|
||||
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
|
||||
|
||||
# [0.58.0](https://github.com/expo/entity/compare/v0.57.0...v0.58.0) (2026-03-02)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- fix some security review suggestions ([#485](https://github.com/expo/entity/issues/485)) ([e263bd2](https://github.com/expo/entity/commit/e263bd218cda579365766d0918c4961304bd1d9f))
|
||||
|
||||
# [0.57.0](https://github.com/expo/entity/compare/v0.56.0...v0.57.0) (2026-02-28)
|
||||
|
||||
**Note:** Version bump only for package @expo/entity-database-adapter-knex
|
||||
|
||||
# [0.56.0](https://github.com/expo/entity/compare/v0.55.0...v0.56.0) (2026-02-28)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Add default for TSelectedFields for application-instantiated types ([#438](https://github.com/expo/entity/issues/438)) ([3d4aa0e](https://github.com/expo/entity/commit/3d4aa0e9efa519a36f3db69bd8f117a993c100fa))
|
||||
- Apply same viewer context subclass type mechanism to knexLoader and related methods ([#472](https://github.com/expo/entity/issues/472)) ([018a40b](https://github.com/expo/entity/commit/018a40bc01d579ffbf88f308e641921e6fc39097))
|
||||
- correct discriminated union type for forward/backward pagination ([#474](https://github.com/expo/entity/issues/474)) ([90ae6e2](https://github.com/expo/entity/commit/90ae6e299bda26ac9a80d83e83334d4152c022dd))
|
||||
- correct pagination behavior for standard pagination with descending ordering clauses ([#477](https://github.com/expo/entity/issues/477)) ([e32c4c2](https://github.com/expo/entity/commit/e32c4c2bcc05f225a556f95c90f1ee0f4e24de74))
|
||||
- correct precedence parentheses for SQLFragment joins ([#476](https://github.com/expo/entity/issues/476)) ([2c4c649](https://github.com/expo/entity/commit/2c4c649a4bca5bc9ffdc41eb3135b2dfe4db5e57)), closes [/github.com/expo/entity/blob/main/packages/entity-database-adapter-knex/src/internal/EntityKnexDataManager.ts#L303](https://github.com//github.com/expo/entity/blob/main/packages/entity-database-adapter-knex/src/internal/EntityKnexDataManager.ts/issues/L303)
|
||||
- eliminate some unnecessary any casts ([#448](https://github.com/expo/entity/issues/448)) ([cc78029](https://github.com/expo/entity/commit/cc78029117c14510b0934b5e0d19876a45570f8d))
|
||||
- explicitly document behavior when cursor entity no longer exists ([#453](https://github.com/expo/entity/issues/453)) ([4dc156c](https://github.com/expo/entity/commit/4dc156c7a8dc50bc9e2802be133b9e258b289f54)), closes [#422](https://github.com/expo/entity/issues/422) [#431](https://github.com/expo/entity/issues/431)
|
||||
- move EntityPrivacyUtils back into core package ([#429](https://github.com/expo/entity/issues/429)) ([56ec27d](https://github.com/expo/entity/commit/56ec27d5f201d83930a2cc05dfc380a054bff1e1))
|
||||
- pass in augmentable classes to installation ([#437](https://github.com/expo/entity/issues/437)) ([f5c728b](https://github.com/expo/entity/commit/f5c728be565e01c0a8ace9ef51f6de8f49184dab)), closes [/github.com/expo/entity/pull/410#pullrequestreview-3765893898](https://github.com//github.com/expo/entity/pull/410/issues/pullrequestreview-3765893898)
|
||||
- refactor install method to instead be free functions ([#441](https://github.com/expo/entity/issues/441)) ([51d632d](https://github.com/expo/entity/commit/51d632da1619dc039d5c41ce6533f63254ec4337)), closes [#410](https://github.com/expo/entity/issues/410)
|
||||
- update integration test to use BlahEntity.knexLoader syntax ([#444](https://github.com/expo/entity/issues/444)) ([457f58a](https://github.com/expo/entity/commit/457f58ad2c4e1cf09f3132f3b9af5008ba0290c7)), closes [#441](https://github.com/expo/entity/issues/441)
|
||||
- Use TSelectedFields for knex loader order by method ([#424](https://github.com/expo/entity/issues/424)) ([a9d09f4](https://github.com/expo/entity/commit/a9d09f42b67378d06133bcfb5b99c9afb17aac0d))
|
||||
|
||||
### Features
|
||||
|
||||
- add entity-database-adapter-knex-testing-utils containing StubPostgresDatabaseAdapter ([#412](https://github.com/expo/entity/issues/412)) ([eeccf59](https://github.com/expo/entity/commit/eeccf594c11bf090aa83bbf71fb4ead8668aec7d))
|
||||
- add entityField SQL helper ([#481](https://github.com/expo/entity/issues/481)) ([459a5ff](https://github.com/expo/entity/commit/459a5ff85014a8c7396c0856dffacb1491ccb963))
|
||||
- Add ilike and trigram similarity search to pagination ([#431](https://github.com/expo/entity/issues/431)) ([f72cbca](https://github.com/expo/entity/commit/f72cbcaf5a722e6bb8299644ec32b07f1d3362a9)), closes [#422](https://github.com/expo/entity/issues/422)
|
||||
- add method to get pagination cursor for single entity ([#475](https://github.com/expo/entity/issues/475)) ([898f71c](https://github.com/expo/entity/commit/898f71ce082c2593585575b60bd6ef2546717124))
|
||||
- Add paginated loader to entity-database-adapter-knex ([#422](https://github.com/expo/entity/issues/422)) ([58deffd](https://github.com/expo/entity/commit/58deffd426b0214073ede608cd47c04db2596d60))
|
||||
- Add pagination max page size configuration ([#436](https://github.com/expo/entity/issues/436)) ([3ec4b5f](https://github.com/expo/entity/commit/3ec4b5f0cd53bf18d6bcfa27dd29b70d3f4d5f19))
|
||||
- add PostgresEntity/ReadonlyPostgresEntity classes that expose knexLoader methods ([#442](https://github.com/expo/entity/issues/442)) ([40b409f](https://github.com/expo/entity/commit/40b409f6f889febf8b90b8837caecac26ff64174)), closes [#441](https://github.com/expo/entity/issues/441)
|
||||
- Add raw sqlfragment orderBy capability for pagination ([#457](https://github.com/expo/entity/issues/457)) ([0615ffa](https://github.com/expo/entity/commit/0615ffacffbe9e903a9bf03b171c102f0554b992))
|
||||
- add support for nulls ordering in order by clauses ([#468](https://github.com/expo/entity/issues/468)) ([6c0838d](https://github.com/expo/entity/commit/6c0838dc577a3b6190e3551ee834925d7bd53bc6))
|
||||
- support nullable search fields with postgres transforms ([#470](https://github.com/expo/entity/issues/470)) ([53b0604](https://github.com/expo/entity/commit/53b0604152b8ca5ad9fc616d18478f93473c7eb0))
|
||||
|
||||
# [0.55.0](https://github.com/expo/entity/compare/v0.54.0...v0.55.0) (2026-01-27)
|
||||
|
||||
**Note:** Version bump only for package @expo/entity-database-adapter-knex
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@expo/entity-database-adapter-knex",
|
||||
"version": "0.55.0",
|
||||
"version": "0.58.0",
|
||||
"description": "Knex database adapter for @expo/entity",
|
||||
"files": [
|
||||
"build",
|
||||
@@ -34,7 +34,7 @@
|
||||
"devDependencies": {
|
||||
"@expo/entity-testing-utils": "workspace:^",
|
||||
"@jest/globals": "30.2.0",
|
||||
"pg": "8.18.0",
|
||||
"pg": "8.19.0",
|
||||
"ts-mockito": "2.6.1",
|
||||
"typescript": "5.9.3"
|
||||
}
|
||||
|
||||
+139
-40
@@ -11,6 +11,7 @@ import { Result } from '@expo/results';
|
||||
import {
|
||||
FieldEqualityCondition,
|
||||
isSingleValueFieldEqualityCondition,
|
||||
NullsOrdering,
|
||||
OrderByOrdering,
|
||||
} from './BasePostgresEntityDatabaseAdapter';
|
||||
import { BaseSQLQueryBuilder } from './BaseSQLQueryBuilder';
|
||||
@@ -18,21 +19,47 @@ 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 = 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.
|
||||
@@ -57,25 +84,69 @@ 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,
|
||||
> extends EntityLoaderQuerySelectionModifiers<TFields, TSelectedFields> {
|
||||
/**
|
||||
* Order the entities by a raw SQL `ORDER BY` clause.
|
||||
*/
|
||||
orderByRaw?: string;
|
||||
}
|
||||
> = (fieldName: TSelectedFields) => SQLFragment<TFields>;
|
||||
|
||||
export interface EntityLoaderQuerySelectionModifiersWithOrderByFragment<
|
||||
/**
|
||||
* Specification for a search field that is a manually constructed SQLFragment. Useful for complex search fields that require
|
||||
* transformations to make nullable fields non-null or to make combinations of multiple fields.
|
||||
*/
|
||||
export type EntityLoaderSearchFieldSQLFragmentFnSpecification<
|
||||
TFields extends Record<string, any>,
|
||||
TSelectedFields extends keyof TFields = keyof TFields,
|
||||
> extends EntityLoaderQuerySelectionModifiers<TFields, TSelectedFields> {
|
||||
> = {
|
||||
/**
|
||||
* Order the entities by a SQL fragment `ORDER BY` clause.
|
||||
* 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))`;
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
orderByFragment?: SQLFragment;
|
||||
}
|
||||
fieldConstructor: (
|
||||
/**
|
||||
* Helper function to get a SQLFragment for a given field name, which should be used to construct the final SQLFragment for the search field.
|
||||
*/
|
||||
getFragmentForFieldName: EntityLoaderFieldNameConstructorFn<TFields, TSelectedFields>,
|
||||
) => SQLFragment<Pick<TFields, TSelectedFields>>;
|
||||
};
|
||||
|
||||
/**
|
||||
* Set of selected fields that are non-nullable, which can be used for search fields that require non-nullable fields.
|
||||
* To use a nullable field as a search field, use an EntityLoaderSearchFieldSQLFragmentFnSpecification with appropriate SQL
|
||||
* to handle null values, such as `COALESCE(field_name, '')` to treat nulls as empty strings for searching.
|
||||
*/
|
||||
export type NonNullableSelectedFields<
|
||||
TFields extends Record<string, any>,
|
||||
TSelectedFields extends keyof TFields,
|
||||
> = TSelectedFields & NonNullableKeys<TFields>;
|
||||
|
||||
/**
|
||||
* Specification for a search field that can be either a simple selected field name (which must be non-nullable) or a manually constructed
|
||||
* SQLFragment using EntityLoaderSearchFieldSQLFragmentFnSpecification.
|
||||
*/
|
||||
export type EntityLoaderSearchFieldSpecification<
|
||||
TFields extends Record<string, any>,
|
||||
TSelectedFields extends keyof TFields = keyof TFields,
|
||||
> =
|
||||
| NonNullableSelectedFields<TFields, TSelectedFields>
|
||||
| EntityLoaderSearchFieldSQLFragmentFnSpecification<TFields, TSelectedFields>;
|
||||
|
||||
interface SearchSpecificationBase<
|
||||
TFields extends Record<string, any>,
|
||||
@@ -87,9 +158,9 @@ interface SearchSpecificationBase<
|
||||
term: string;
|
||||
|
||||
/**
|
||||
* The fields to search within. Must be a non-empty array.
|
||||
* The fields to search within.
|
||||
*/
|
||||
fields: TSelectedFields[];
|
||||
fields: readonly EntityLoaderSearchFieldSpecification<TFields, TSelectedFields>[];
|
||||
}
|
||||
|
||||
interface ILikeSearchSpecification<
|
||||
@@ -132,7 +203,7 @@ interface TrigramSearchSpecification<
|
||||
* These fields are independent of search fields and can be used to provide meaningful
|
||||
* ordering when multiple results have the same similarity score.
|
||||
*/
|
||||
extraOrderByFields?: TSelectedFields[];
|
||||
extraOrderByFields?: readonly EntityLoaderSearchFieldSpecification<TFields, TSelectedFields>[];
|
||||
}
|
||||
|
||||
interface StandardPaginationSpecification<
|
||||
@@ -147,7 +218,7 @@ interface StandardPaginationSpecification<
|
||||
/**
|
||||
* Order the entities by specified columns and orders. If the ID field is not included, it will be automatically added for stable pagination.
|
||||
*/
|
||||
orderBy: EntityLoaderOrderByClause<TFields, TSelectedFields>[];
|
||||
orderBy: readonly EntityLoaderOrderByClause<TFields, TSelectedFields>[];
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -171,7 +242,7 @@ interface EntityLoaderBaseUnifiedPaginationArgs<
|
||||
/**
|
||||
* SQLFragment representing the WHERE clause to filter the entities being paginated.
|
||||
*/
|
||||
where?: SQLFragment;
|
||||
where?: SQLFragment<Pick<TFields, TSelectedFields>>;
|
||||
|
||||
/**
|
||||
* Pagination specification determining how to order and paginate results.
|
||||
@@ -191,10 +262,22 @@ export interface EntityLoaderForwardUnifiedPaginationArgs<
|
||||
*/
|
||||
first: number;
|
||||
|
||||
/**
|
||||
* The number of entities to return starting from the entity before the cursor. Must be a positive integer.
|
||||
* Not allowed with forward pagination.
|
||||
*/
|
||||
last?: never;
|
||||
|
||||
/**
|
||||
* The cursor to paginate after for forward pagination.
|
||||
*/
|
||||
after?: string;
|
||||
|
||||
/**
|
||||
* The cursor to paginate before for backward pagination.
|
||||
* Not allowed with forward pagination.
|
||||
*/
|
||||
before?: never;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -204,11 +287,23 @@ export interface EntityLoaderBackwardUnifiedPaginationArgs<
|
||||
TFields extends Record<string, any>,
|
||||
TSelectedFields extends keyof TFields = keyof TFields,
|
||||
> extends EntityLoaderBaseUnifiedPaginationArgs<TFields, TSelectedFields> {
|
||||
/**
|
||||
* The number of entities to return starting from the entity after the cursor. Must be a positive integer.
|
||||
* Not allowed with backward pagination.
|
||||
*/
|
||||
first?: never;
|
||||
|
||||
/**
|
||||
* The number of entities to return starting from the entity before the cursor. Must be a positive integer.
|
||||
*/
|
||||
last: number;
|
||||
|
||||
/**
|
||||
* The cursor to paginate after for forward pagination.
|
||||
* Not allowed with backward pagination.
|
||||
*/
|
||||
after?: never;
|
||||
|
||||
/**
|
||||
* The cursor to paginate before for backward pagination.
|
||||
*/
|
||||
@@ -265,7 +360,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'
|
||||
@@ -284,7 +379,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) {
|
||||
@@ -306,14 +401,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,
|
||||
@@ -329,11 +423,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,
|
||||
@@ -388,6 +479,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());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -418,8 +517,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,44 +136,24 @@ 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,
|
||||
@@ -167,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[] = [];
|
||||
@@ -204,7 +213,7 @@ export abstract class BasePostgresEntityDatabaseAdapter<
|
||||
tableName: string,
|
||||
tableFieldSingleValueEqualityOperands: TableFieldSingleValueEqualityCondition[],
|
||||
tableFieldMultiValueEqualityOperands: TableFieldMultiValueEqualityCondition[],
|
||||
querySelectionModifiers: TableQuerySelectionModifiers,
|
||||
querySelectionModifiers: TableQuerySelectionModifiers<TFields>,
|
||||
): Promise<object[]>;
|
||||
|
||||
/**
|
||||
@@ -220,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) =>
|
||||
@@ -240,7 +249,7 @@ export abstract class BasePostgresEntityDatabaseAdapter<
|
||||
tableName: string,
|
||||
rawWhereClause: string,
|
||||
bindings: object | any[],
|
||||
querySelectionModifiers: TableQuerySelectionModifiersWithOrderByRaw,
|
||||
querySelectionModifiers: TableQuerySelectionModifiers<TFields>,
|
||||
): Promise<object[]>;
|
||||
|
||||
/**
|
||||
@@ -253,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) =>
|
||||
@@ -271,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,
|
||||
@@ -217,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);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -246,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);
|
||||
}
|
||||
|
||||
@@ -35,6 +35,7 @@ export abstract class PostgresEntity<
|
||||
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,
|
||||
@@ -53,7 +54,7 @@ export abstract class PostgresEntity<
|
||||
TMPrivacyPolicy,
|
||||
TMSelectedFields
|
||||
>,
|
||||
viewerContext: TMViewerContext,
|
||||
viewerContext: TMViewerContext2,
|
||||
queryContext: EntityQueryContext = viewerContext
|
||||
.getViewerScopedEntityCompanionForClass(this)
|
||||
.getQueryContextProvider()
|
||||
@@ -80,6 +81,7 @@ export abstract class PostgresEntity<
|
||||
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,
|
||||
@@ -98,7 +100,7 @@ export abstract class PostgresEntity<
|
||||
TMPrivacyPolicy,
|
||||
TMSelectedFields
|
||||
>,
|
||||
viewerContext: TMViewerContext,
|
||||
viewerContext: TMViewerContext2,
|
||||
queryContext: EntityQueryContext = viewerContext
|
||||
.getViewerScopedEntityCompanionForClass(this)
|
||||
.getQueryContextProvider()
|
||||
|
||||
@@ -1,13 +1,18 @@
|
||||
import { EntityConfiguration, FieldTransformer, FieldTransformerMap } from '@expo/entity';
|
||||
import {
|
||||
EntityConfiguration,
|
||||
FieldTransformer,
|
||||
FieldTransformerMap,
|
||||
getDatabaseFieldForEntityField,
|
||||
} from '@expo/entity';
|
||||
import { Knex } from 'knex';
|
||||
|
||||
import {
|
||||
BasePostgresEntityDatabaseAdapter,
|
||||
NullsOrdering,
|
||||
OrderByOrdering,
|
||||
TableFieldMultiValueEqualityCondition,
|
||||
TableFieldSingleValueEqualityCondition,
|
||||
TableQuerySelectionModifiers,
|
||||
TableQuerySelectionModifiersWithOrderByFragment,
|
||||
TableQuerySelectionModifiersWithOrderByRaw,
|
||||
} from './BasePostgresEntityDatabaseAdapter';
|
||||
import { JSONArrayField, MaybeJSONArrayField } from './EntityFields';
|
||||
import { PostgresEntityDatabaseAdapterConfiguration } from './PostgresEntityDatabaseAdapterProvider';
|
||||
@@ -118,28 +123,9 @@ export class PostgresEntityDatabaseAdapter<
|
||||
return results[0] ?? null;
|
||||
}
|
||||
|
||||
private applyQueryModifiersToQueryOrderByRaw(
|
||||
query: Knex.QueryBuilder,
|
||||
querySelectionModifiers: TableQuerySelectionModifiersWithOrderByRaw,
|
||||
): Knex.QueryBuilder {
|
||||
const { orderByRaw } = querySelectionModifiers;
|
||||
|
||||
// orderByRaw takes precedence over orderBy - they are mutually exclusive
|
||||
if (orderByRaw !== undefined) {
|
||||
// Apply only orderByRaw (offset/limit still applied, but not orderBy)
|
||||
return this.applyQueryModifiersToQuery(query, {
|
||||
...querySelectionModifiers,
|
||||
orderBy: undefined, // Explicitly exclude orderBy when orderByRaw is present
|
||||
}).orderByRaw(orderByRaw);
|
||||
} else {
|
||||
// Apply regular orderBy (and offset/limit)
|
||||
return this.applyQueryModifiersToQuery(query, querySelectionModifiers);
|
||||
}
|
||||
}
|
||||
|
||||
private applyQueryModifiersToQuery(
|
||||
query: Knex.QueryBuilder,
|
||||
querySelectionModifiers: TableQuerySelectionModifiers,
|
||||
querySelectionModifiers: TableQuerySelectionModifiers<TFields>,
|
||||
): Knex.QueryBuilder {
|
||||
const { orderBy, offset, limit } = querySelectionModifiers;
|
||||
|
||||
@@ -147,7 +133,25 @@ 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 orderDirection =
|
||||
orderBySpecification.order === OrderByOrdering.ASCENDING ? 'ASC' : 'DESC';
|
||||
const nullsSuffix = orderBySpecification.nulls
|
||||
? ` NULLS ${orderBySpecification.nulls === NullsOrdering.FIRST ? 'FIRST' : 'LAST'}`
|
||||
: '';
|
||||
ret = ret.orderByRaw(
|
||||
`(${orderBySpecification.columnFragment.sql}) ${orderDirection}${nullsSuffix}`,
|
||||
orderBySpecification.columnFragment.getKnexBindings((fieldName) =>
|
||||
getDatabaseFieldForEntityField(this.entityConfiguration, fieldName),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -167,7 +171,7 @@ export class PostgresEntityDatabaseAdapter<
|
||||
tableName: string,
|
||||
tableFieldSingleValueEqualityOperands: TableFieldSingleValueEqualityCondition[],
|
||||
tableFieldMultiValueEqualityOperands: TableFieldMultiValueEqualityCondition[],
|
||||
querySelectionModifiers: TableQuerySelectionModifiers,
|
||||
querySelectionModifiers: TableQuerySelectionModifiers<TFields>,
|
||||
): Promise<object[]> {
|
||||
let query = queryInterface.select().from(tableName);
|
||||
|
||||
@@ -213,40 +217,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());
|
||||
|
||||
// Apply order by modifiers
|
||||
// orderByFragment takes precedence over orderBy - they are mutually exclusive
|
||||
const { orderByFragment } = querySelectionModifiers;
|
||||
if (orderByFragment !== undefined) {
|
||||
// Apply only orderByFragment (offset/limit still applied, but not orderBy)
|
||||
query = this.applyQueryModifiersToQuery(query, {
|
||||
...querySelectionModifiers,
|
||||
orderBy: undefined, // Explicitly exclude orderBy when orderByFragment is present
|
||||
}).orderByRaw(orderByFragment.sql, orderByFragment.getKnexBindings());
|
||||
} else {
|
||||
// Apply regular orderBy (and offset/limit)
|
||||
query = this.applyQueryModifiersToQuery(query, querySelectionModifiers);
|
||||
}
|
||||
.whereRaw(
|
||||
sqlFragment.sql,
|
||||
sqlFragment.getKnexBindings((fieldName) =>
|
||||
getDatabaseFieldForEntityField(this.entityConfiguration, fieldName),
|
||||
),
|
||||
);
|
||||
query = this.applyQueryModifiersToQuery(query, querySelectionModifiers);
|
||||
return await wrapNativePostgresCallAsync(() => query);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import assert from 'assert';
|
||||
|
||||
/**
|
||||
* Supported SQL value types that can be safely parameterized.
|
||||
* This ensures type safety and prevents passing unsupported types to SQL queries.
|
||||
@@ -17,29 +19,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 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 +57,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 +86,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 +114,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 +193,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 +223,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 +243,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 +262,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 +288,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 +310,23 @@ export function sql(
|
||||
return new SQLFragment(sqlString, bindings);
|
||||
}
|
||||
|
||||
type PickSupportedSQLValueKeys<T> = {
|
||||
[K in keyof T]: T[K] extends SupportedSQLValue ? K : never;
|
||||
}[keyof T];
|
||||
|
||||
type PickStringValueKeys<T> = {
|
||||
[K in keyof T]: T[K] extends string | null | undefined ? K : never;
|
||||
}[keyof T];
|
||||
|
||||
type JsonSerializable =
|
||||
| string
|
||||
| number
|
||||
| boolean
|
||||
| null
|
||||
| undefined
|
||||
| readonly JsonSerializable[]
|
||||
| { readonly [key: string]: JsonSerializable };
|
||||
|
||||
/**
|
||||
* Common SQL helper functions for building queries
|
||||
*/
|
||||
@@ -274,28 +336,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 +370,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 +398,233 @@ 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: PickStringValueKeys<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: PickStringValueKeys<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: PickStringValueKeys<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: PickStringValueKeys<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: JsonSerializable,
|
||||
): SQLFragment<TFields> {
|
||||
const serialized = JSON.stringify(value);
|
||||
// JSON.stringify returns undefined for unsupported types, but we also want to allow undefined as a value
|
||||
assert(
|
||||
serialized !== undefined || value === undefined,
|
||||
'jsonContains: value is not JSON-serializable',
|
||||
);
|
||||
return sql`${entityField(fieldName)} @> ${serialized}::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: JsonSerializable,
|
||||
): SQLFragment<TFields> {
|
||||
const serialized = JSON.stringify(value);
|
||||
// JSON.stringify returns undefined for unsupported types, but we also want to allow undefined as a value
|
||||
assert(
|
||||
serialized !== undefined || value === undefined,
|
||||
'jsonContainedBy: value is not JSON-serializable',
|
||||
);
|
||||
return sql`${entityField(fieldName)} <@ ${serialized}::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),
|
||||
);
|
||||
}
|
||||
+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(
|
||||
|
||||
+1024
-146
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) => {
|
||||
|
||||
@@ -17,9 +17,10 @@ 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;
|
||||
@@ -60,6 +61,7 @@ export class PostgresTestEntity extends PostgresEntity<
|
||||
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[]');
|
||||
@@ -138,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',
|
||||
}),
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,376 @@ 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}',
|
||||
]);
|
||||
});
|
||||
|
||||
it('generates JSON contains for null and undefined values', () => {
|
||||
const fragmentNull = SQLFragmentHelpers.jsonContains('stringField', null);
|
||||
const fragmentUndefined = SQLFragmentHelpers.jsonContains('stringField', undefined);
|
||||
|
||||
expect(fragmentNull.sql).toBe('?? @> ?::jsonb');
|
||||
expect(fragmentNull.getKnexBindings(getColumnForField)).toEqual(['string_field', 'null']);
|
||||
|
||||
expect(fragmentUndefined.sql).toBe('?? @> ?::jsonb');
|
||||
expect(fragmentUndefined.getKnexBindings(getColumnForField)).toEqual([
|
||||
'string_field',
|
||||
undefined,
|
||||
]);
|
||||
});
|
||||
|
||||
it('throws when value is not JSON-serializable', () => {
|
||||
expect(() => SQLFragmentHelpers.jsonContains('stringField', (() => {}) as any)).toThrow(
|
||||
'jsonContains: value is not JSON-serializable',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
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"}',
|
||||
]);
|
||||
});
|
||||
|
||||
it('generates JSON contained by for null and undefined values', () => {
|
||||
const fragmentNull = SQLFragmentHelpers.jsonContainedBy('stringField', null);
|
||||
const fragmentUndefined = SQLFragmentHelpers.jsonContainedBy('stringField', undefined);
|
||||
|
||||
expect(fragmentNull.sql).toBe('?? <@ ?::jsonb');
|
||||
expect(fragmentNull.getKnexBindings(getColumnForField)).toEqual(['string_field', 'null']);
|
||||
|
||||
expect(fragmentUndefined.sql).toBe('?? <@ ?::jsonb');
|
||||
expect(fragmentUndefined.getKnexBindings(getColumnForField)).toEqual([
|
||||
'string_field',
|
||||
undefined,
|
||||
]);
|
||||
});
|
||||
|
||||
it('throws when value is not JSON-serializable', () => {
|
||||
expect(() => SQLFragmentHelpers.jsonContainedBy('stringField', (() => {}) as any)).toThrow(
|
||||
'jsonContainedBy: value is not JSON-serializable',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
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 +773,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 +799,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 +825,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 +835,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');
|
||||
}
|
||||
|
||||
@@ -22,4 +22,5 @@ export * from './errors/wrapNativePostgresCallAsync';
|
||||
export * from './internal/EntityKnexDataManager';
|
||||
export * from './internal/getKnexDataManager';
|
||||
export * from './internal/getKnexEntityLoaderFactory';
|
||||
export * from './internal/utilityTypes';
|
||||
export * from './internal/weakMaps';
|
||||
@@ -12,23 +12,46 @@ import assert from 'assert';
|
||||
import {
|
||||
BasePostgresEntityDatabaseAdapter,
|
||||
FieldEqualityCondition,
|
||||
NullsOrdering,
|
||||
OrderByOrdering,
|
||||
PostgresOrderByClause,
|
||||
PostgresQuerySelectionModifiers,
|
||||
PostgresQuerySelectionModifiersWithOrderByFragment,
|
||||
PostgresQuerySelectionModifiersWithOrderByRaw,
|
||||
} from '../BasePostgresEntityDatabaseAdapter';
|
||||
import { PaginationStrategy } from '../PaginationStrategy';
|
||||
import { SQLFragment, SQLFragmentHelpers, identifier, raw, sql } from '../SQLOperator';
|
||||
import { SQLFragment, SQLFragmentHelpers, identifier, unsafeRaw, sql } from '../SQLOperator';
|
||||
import { DistributiveOmit, NonNullableKeys } from './utilityTypes';
|
||||
|
||||
interface DataManagerStandardSpecification<TFields extends Record<string, any>> {
|
||||
strategy: PaginationStrategy.STANDARD;
|
||||
orderBy: PostgresOrderByClause<TFields>[];
|
||||
orderBy: readonly PostgresOrderByClause<TFields>[];
|
||||
}
|
||||
|
||||
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: (keyof TFields)[];
|
||||
fields: readonly DataManagerSearchFieldSpecification<TFields>[];
|
||||
}
|
||||
|
||||
interface DataManagerILikeSearchSpecification<
|
||||
@@ -42,7 +65,7 @@ interface DataManagerTrigramSearchSpecification<
|
||||
> extends DataManagerSearchSpecificationBase<TFields> {
|
||||
strategy: PaginationStrategy.TRIGRAM_SEARCH;
|
||||
threshold: number;
|
||||
extraOrderByFields?: (keyof TFields)[];
|
||||
extraOrderByFields?: readonly DataManagerSearchFieldSpecification<TFields>[];
|
||||
}
|
||||
|
||||
type DataManagerSearchSpecification<TFields extends Record<string, any>> =
|
||||
@@ -54,7 +77,7 @@ type DataManagerPaginationSpecification<TFields extends Record<string, any>> =
|
||||
| DataManagerSearchSpecification<TFields>;
|
||||
|
||||
interface BaseUnifiedPaginationArgs<TFields extends Record<string, any>> {
|
||||
where?: SQLFragment;
|
||||
where?: SQLFragment<TFields>;
|
||||
pagination: DataManagerPaginationSpecification<TFields>;
|
||||
}
|
||||
|
||||
@@ -62,14 +85,18 @@ interface ForwardUnifiedPaginationArgs<
|
||||
TFields extends Record<string, any>,
|
||||
> extends BaseUnifiedPaginationArgs<TFields> {
|
||||
first: number;
|
||||
last?: never;
|
||||
before?: never;
|
||||
after?: string;
|
||||
}
|
||||
|
||||
interface BackwardUnifiedPaginationArgs<
|
||||
TFields extends Record<string, any>,
|
||||
> extends BaseUnifiedPaginationArgs<TFields> {
|
||||
first?: never;
|
||||
last: number;
|
||||
before?: string;
|
||||
after?: never;
|
||||
}
|
||||
|
||||
type LoadPageArgs<TFields extends Record<string, any>> =
|
||||
@@ -98,7 +125,7 @@ export interface PageInfo {
|
||||
* Relay-style Connection type
|
||||
*/
|
||||
export interface Connection<TNode> {
|
||||
edges: Edge<TNode>[];
|
||||
edges: readonly Edge<TNode>[];
|
||||
pageInfo: PageInfo;
|
||||
}
|
||||
|
||||
@@ -110,15 +137,13 @@ enum PaginationDirection {
|
||||
const CURSOR_ROW_TABLE_ALIAS = 'cursor_row';
|
||||
|
||||
interface PaginationProvider<TFields extends Record<string, any>, TIDField extends keyof TFields> {
|
||||
whereClause: SQLFragment | undefined;
|
||||
buildOrderBy: (direction: PaginationDirection) => {
|
||||
clauses?: PostgresOrderByClause<TFields>[];
|
||||
fragment?: SQLFragment | undefined;
|
||||
};
|
||||
whereClause: SQLFragment<TFields> | undefined;
|
||||
buildOrderBy: (direction: PaginationDirection) => readonly PostgresOrderByClause<TFields>[];
|
||||
buildCursorCondition: (
|
||||
decodedCursorId: TFields[TIDField],
|
||||
direction: PaginationDirection,
|
||||
) => SQLFragment;
|
||||
orderByClauses: readonly PostgresOrderByClause<TFields>[],
|
||||
) => SQLFragment<TFields>;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -149,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,
|
||||
@@ -178,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,
|
||||
@@ -198,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,
|
||||
@@ -218,6 +249,15 @@ export class EntityKnexDataManager<
|
||||
/**
|
||||
* 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 pagination and first/after or last/before
|
||||
* @returns connection with edges containing field objects and page info
|
||||
@@ -230,18 +270,22 @@ export class EntityKnexDataManager<
|
||||
|
||||
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 = augmentedOrderByClauses.map(
|
||||
(order) => order.fieldName,
|
||||
);
|
||||
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) => {
|
||||
// For backward pagination, we flip the ORDER BY direction to fetch records
|
||||
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.
|
||||
@@ -249,20 +293,30 @@ export class EntityKnexDataManager<
|
||||
// 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
|
||||
const clauses =
|
||||
direction === PaginationDirection.FORWARD
|
||||
? augmentedOrderByClauses
|
||||
: augmentedOrderByClauses.map((clause) => ({
|
||||
fieldName: clause.fieldName,
|
||||
return direction === PaginationDirection.FORWARD
|
||||
? augmentedOrderByClauses
|
||||
: augmentedOrderByClauses.map(
|
||||
(clause): PostgresOrderByClause<TFields> => ({
|
||||
...clause,
|
||||
order:
|
||||
clause.order === OrderByOrdering.ASCENDING
|
||||
? OrderByOrdering.DESCENDING
|
||||
: OrderByOrdering.ASCENDING,
|
||||
}));
|
||||
return { clauses };
|
||||
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,
|
||||
);
|
||||
},
|
||||
buildCursorCondition: (decodedCursorId, direction) =>
|
||||
this.buildCursorCondition(decodedCursorId, fieldsToUseInPostgresTupleCursor, direction),
|
||||
};
|
||||
|
||||
return await this.loadPageInternalAsync(queryContext, args, strategy);
|
||||
@@ -274,12 +328,7 @@ export class EntityKnexDataManager<
|
||||
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 direction =
|
||||
'first' in args ? PaginationDirection.FORWARD : PaginationDirection.BACKWARD;
|
||||
const { searchWhere, searchOrderByFragment } = this.buildSearchConditionAndOrderBy(
|
||||
search,
|
||||
direction,
|
||||
);
|
||||
const { searchWhere, searchOrderByClauses } = this.buildSearchConditionAndOrderBy(search);
|
||||
|
||||
// Combine WHERE conditions: base where + search where
|
||||
const whereClause =
|
||||
@@ -295,8 +344,18 @@ export class EntityKnexDataManager<
|
||||
// Create strategy for search pagination
|
||||
const strategy: PaginationProvider<TFields, TIDField> = {
|
||||
whereClause,
|
||||
buildOrderBy: () => {
|
||||
return { fragment: searchOrderByFragment };
|
||||
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
|
||||
@@ -304,7 +363,10 @@ export class EntityKnexDataManager<
|
||||
: this.buildCursorCondition(
|
||||
decodedCursorId,
|
||||
fieldsToUseInPostgresTupleCursor,
|
||||
direction,
|
||||
// ILIKE search always orders ASC, so effective ordering matches direction
|
||||
direction === PaginationDirection.FORWARD
|
||||
? OrderByOrdering.ASCENDING
|
||||
: OrderByOrdering.DESCENDING,
|
||||
),
|
||||
};
|
||||
|
||||
@@ -312,6 +374,10 @@ export class EntityKnexDataManager<
|
||||
}
|
||||
}
|
||||
|
||||
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.
|
||||
@@ -325,7 +391,7 @@ export class EntityKnexDataManager<
|
||||
|
||||
// Validate pagination arguments
|
||||
const maxPageSize = this.databaseAdapter.paginationMaxPageSize;
|
||||
const isForward = 'first' in args;
|
||||
const isForward = 'first' in args && args.first !== undefined;
|
||||
if (isForward) {
|
||||
assert(
|
||||
Number.isInteger(args.first) && args.first > 0,
|
||||
@@ -357,21 +423,23 @@ export class EntityKnexDataManager<
|
||||
// Decode cursor
|
||||
const decodedExternalCursorEntityID = cursor ? this.decodeOpaqueCursor(cursor) : null;
|
||||
|
||||
// Get ordering from strategy
|
||||
const orderByClauses = paginationProvider.buildOrderBy(direction);
|
||||
|
||||
// Build WHERE clause with cursor condition
|
||||
const baseWhere = paginationProvider.whereClause;
|
||||
const cursorCondition = decodedExternalCursorEntityID
|
||||
? paginationProvider.buildCursorCondition(decodedExternalCursorEntityID, direction)
|
||||
? paginationProvider.buildCursorCondition(
|
||||
decodedExternalCursorEntityID,
|
||||
direction,
|
||||
orderByClauses,
|
||||
)
|
||||
: null;
|
||||
|
||||
const whereClause = this.combineWhereConditions(baseWhere, cursorCondition);
|
||||
|
||||
// Get ordering from strategy
|
||||
const { clauses: orderByClauses, fragment: orderByFragment } =
|
||||
paginationProvider.buildOrderBy(direction);
|
||||
|
||||
// Determine query modifiers
|
||||
const queryModifiers: PostgresQuerySelectionModifiersWithOrderByFragment<TFields> = {
|
||||
...(orderByFragment !== undefined && { orderByFragment }),
|
||||
const queryModifiers: PostgresQuerySelectionModifiers<TFields> = {
|
||||
...(orderByClauses !== undefined && { orderBy: orderByClauses }),
|
||||
limit: limit + 1, // Fetch data with limit + 1 to check for more pages
|
||||
};
|
||||
@@ -414,9 +482,9 @@ export class EntityKnexDataManager<
|
||||
}
|
||||
|
||||
private combineWhereConditions(
|
||||
baseWhere: SQLFragment | undefined,
|
||||
cursorCondition: SQLFragment | null,
|
||||
): SQLFragment {
|
||||
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`;
|
||||
@@ -431,19 +499,63 @@ export class EntityKnexDataManager<
|
||||
}
|
||||
|
||||
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');
|
||||
}
|
||||
@@ -469,15 +581,47 @@ export class EntityKnexDataManager<
|
||||
return parsedCursor.id;
|
||||
}
|
||||
|
||||
private resolveSearchFieldToSQLFragment(
|
||||
field:
|
||||
| keyof TFields
|
||||
| SQLFragment<TFields>
|
||||
| DataManagerSearchFieldSQLFragmentFnSpecification<TFields>,
|
||||
tableAlias?: typeof CURSOR_ROW_TABLE_ALIAS,
|
||||
): SQLFragment<TFields> {
|
||||
if (field instanceof SQLFragment) {
|
||||
return field;
|
||||
}
|
||||
|
||||
if (isDataManagerSearchFieldSQLFragmentFnSpecification<TFields>(field)) {
|
||||
return field.fieldConstructor((fieldName) => {
|
||||
const dbField = getDatabaseFieldForEntityField(this.entityConfiguration, fieldName);
|
||||
return tableAlias
|
||||
? sql`${unsafeRaw(tableAlias)}.${identifier(dbField)}`
|
||||
: sql`${identifier(dbField)}`;
|
||||
});
|
||||
}
|
||||
|
||||
const dbField = getDatabaseFieldForEntityField(this.entityConfiguration, field);
|
||||
return tableAlias
|
||||
? sql`${unsafeRaw(tableAlias)}.${identifier(dbField)}`
|
||||
: sql`${identifier(dbField)}`;
|
||||
}
|
||||
|
||||
private buildCursorCondition(
|
||||
decodedExternalCursorEntityID: TFields[TIDField],
|
||||
fieldsToUseInPostgresTupleCursor: readonly (keyof TFields)[],
|
||||
direction: PaginationDirection,
|
||||
): SQLFragment {
|
||||
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.
|
||||
const operator = direction === PaginationDirection.FORWARD ? '>' : '<';
|
||||
// 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,
|
||||
@@ -485,77 +629,73 @@ export class EntityKnexDataManager<
|
||||
);
|
||||
const tableName = this.entityConfiguration.tableName;
|
||||
|
||||
const postgresCursorFieldIdentifiers = fieldsToUseInPostgresTupleCursor.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 = fieldsToUseInPostgresTupleCursor.map((f) => {
|
||||
const dbField = getDatabaseFieldForEntityField(this.entityConfiguration, f);
|
||||
return sql`${raw(CURSOR_ROW_TABLE_ALIAS)}.${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 ${raw(CURSOR_ROW_TABLE_ALIAS)}
|
||||
WHERE ${raw(CURSOR_ROW_TABLE_ALIAS)}.${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,
|
||||
): SQLFragment[] {
|
||||
): readonly SQLFragment<TFields>[] {
|
||||
return search.fields.map((field) => {
|
||||
const dbField = getDatabaseFieldForEntityField(this.entityConfiguration, field);
|
||||
const fieldIdentifier = tableAlias
|
||||
? sql`${raw(tableAlias)}.${identifier(dbField)}`
|
||||
: sql`${identifier(dbField)}`;
|
||||
return sql`${fieldIdentifier} ILIKE ${'%' + EntityKnexDataManager.escapeILikePattern(search.term) + '%'}`;
|
||||
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,
|
||||
): SQLFragment[] {
|
||||
): readonly SQLFragment<TFields>[] {
|
||||
return search.fields.map((field) => {
|
||||
const dbField = getDatabaseFieldForEntityField(this.entityConfiguration, field);
|
||||
const fieldIdentifier = tableAlias
|
||||
? sql`${raw(tableAlias)}.${identifier(dbField)}`
|
||||
: sql`${identifier(dbField)}`;
|
||||
return sql`similarity(${fieldIdentifier}, ${search.term})`;
|
||||
const fieldFragment = this.resolveSearchFieldToSQLFragment(field, tableAlias);
|
||||
return sql`similarity(${fieldFragment}, ${search.term})`;
|
||||
});
|
||||
}
|
||||
|
||||
private buildTrigramExactMatchCaseExpression(
|
||||
search: DataManagerSearchSpecification<TFields>,
|
||||
tableAlias?: typeof CURSOR_ROW_TABLE_ALIAS,
|
||||
): SQLFragment {
|
||||
): SQLFragment<TFields> {
|
||||
const ilikeConditions = this.buildILikeConditions(search, tableAlias);
|
||||
return sql`CASE WHEN ${SQLFragment.join(ilikeConditions, ' OR ')} THEN 1 ELSE 0 END`;
|
||||
return sql`CASE WHEN ${SQLFragmentHelpers.or(...ilikeConditions)} THEN 1 ELSE 0 END`;
|
||||
}
|
||||
|
||||
private buildTrigramSimilarityGreatestExpression(
|
||||
search: DataManagerSearchSpecification<TFields>,
|
||||
tableAlias?: typeof CURSOR_ROW_TABLE_ALIAS,
|
||||
): SQLFragment {
|
||||
): SQLFragment<TFields> {
|
||||
const similarityExprs = this.buildTrigramSimilarityExpressions(search, tableAlias);
|
||||
return sql`GREATEST(${SQLFragment.join(similarityExprs, ', ')})`;
|
||||
return sql`GREATEST(${SQLFragment.joinWithCommaSeparator(...similarityExprs)})`;
|
||||
}
|
||||
|
||||
private buildTrigramCursorCondition(
|
||||
search: DataManagerTrigramSearchSpecification<TFields>,
|
||||
decodedExternalCursorEntityID: TFields[TIDField],
|
||||
direction: PaginationDirection,
|
||||
): SQLFragment {
|
||||
// For TRIGRAM search, we compute the similarity values using a subquery, similar to normal cursor
|
||||
): 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,
|
||||
@@ -568,15 +708,14 @@ export class EntityKnexDataManager<
|
||||
// Build extra order by fields
|
||||
const extraOrderByFields = search.extraOrderByFields;
|
||||
const extraFields =
|
||||
extraOrderByFields?.map((f) => {
|
||||
const dbField = getDatabaseFieldForEntityField(this.entityConfiguration, f);
|
||||
return sql`${identifier(dbField)}`;
|
||||
}) ?? [];
|
||||
extraOrderByFields?.map((f) => this.resolveSearchFieldToSQLFragment(f)) ?? [];
|
||||
|
||||
// Build left side of comparison (current row's computed values)
|
||||
const leftSide = SQLFragment.join(
|
||||
[exactMatchExpr, similarityExpr, ...extraFields, sql`${identifier(idField)}`],
|
||||
', ',
|
||||
const leftSide = SQLFragment.joinWithCommaSeparator(
|
||||
exactMatchExpr,
|
||||
similarityExpr,
|
||||
...extraFields,
|
||||
sql`${identifier(idField)}`,
|
||||
);
|
||||
|
||||
// Build right side using subquery to get computed values for cursor entity
|
||||
@@ -592,50 +731,52 @@ export class EntityKnexDataManager<
|
||||
);
|
||||
|
||||
const cursorExtraFields =
|
||||
extraOrderByFields?.map((f) => {
|
||||
const dbField = getDatabaseFieldForEntityField(this.entityConfiguration, f);
|
||||
return sql`${raw(CURSOR_ROW_TABLE_ALIAS)}.${identifier(dbField)}`;
|
||||
}) ?? [];
|
||||
extraOrderByFields?.map((f) =>
|
||||
this.resolveSearchFieldToSQLFragment(f, CURSOR_ROW_TABLE_ALIAS),
|
||||
) ?? [];
|
||||
|
||||
// Build SELECT fields for subquery
|
||||
const selectFields = [
|
||||
const selectFields: SQLFragment<TFields>[] = [
|
||||
cursorExactMatchExpr,
|
||||
cursorSimilarityExpr,
|
||||
...cursorExtraFields,
|
||||
sql`${raw(CURSOR_ROW_TABLE_ALIAS)}.${identifier(idField)}`,
|
||||
sql`${unsafeRaw(CURSOR_ROW_TABLE_ALIAS)}.${identifier(idField)}`,
|
||||
];
|
||||
|
||||
const rightSideSubquery = sql`
|
||||
SELECT ${SQLFragment.join(selectFields, ', ')}
|
||||
FROM ${identifier(this.entityConfiguration.tableName)} AS ${raw(CURSOR_ROW_TABLE_ALIAS)}
|
||||
WHERE ${raw(CURSOR_ROW_TABLE_ALIAS)}.${identifier(idField)} = ${decodedExternalCursorEntityID}
|
||||
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}) ${raw(operator)} (${rightSideSubquery})`;
|
||||
return sql`(${leftSide}) ${unsafeRaw(operator)} (${rightSideSubquery})`;
|
||||
}
|
||||
|
||||
private buildSearchConditionAndOrderBy(
|
||||
search: DataManagerSearchSpecification<TFields>,
|
||||
direction: PaginationDirection,
|
||||
): {
|
||||
searchWhere: SQLFragment;
|
||||
searchOrderByFragment: SQLFragment | undefined;
|
||||
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 orderByFields = [...search.fields, this.entityConfiguration.idField].map((field) => {
|
||||
const dbField = getDatabaseFieldForEntityField(this.entityConfiguration, field);
|
||||
return sql`${identifier(dbField)} ${raw(
|
||||
direction === PaginationDirection.FORWARD ? 'ASC' : 'DESC',
|
||||
)}`;
|
||||
});
|
||||
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 ? SQLFragment.join(conditions, ' OR ') : sql`1 = 0`,
|
||||
searchOrderByFragment: SQLFragment.join(orderByFields, ', '),
|
||||
searchWhere: conditions.length > 0 ? SQLFragmentHelpers.or(...conditions) : sql`1 = 0`,
|
||||
searchOrderByClauses,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -654,79 +795,37 @@ export class EntityKnexDataManager<
|
||||
// Combine exact matches (ILIKE) with similarity
|
||||
const allConditions = [...ilikeConditions, ...conditions];
|
||||
|
||||
// Build ORDER BY components
|
||||
const exactMatchPriority = this.buildTrigramExactMatchPriority(ilikeConditions, direction);
|
||||
const similarityRanking = this.buildTrigramSimilarityRanking(search, direction);
|
||||
const tieBreakers = this.buildTrigramTieBreakers(search, direction);
|
||||
// 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: SQLFragment.join(allConditions, ' OR '),
|
||||
// For trigram search, order by relevance with direction-aware ordering
|
||||
// 1. Exact matches first (ILIKE)
|
||||
// 2. Then by similarity score
|
||||
// 3. Then by extra fields and ID field for stability
|
||||
searchOrderByFragment: SQLFragment.join(
|
||||
[exactMatchPriority, similarityRanking, tieBreakers],
|
||||
', ',
|
||||
),
|
||||
searchWhere: SQLFragmentHelpers.or(...allConditions),
|
||||
searchOrderByClauses,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the exact match priority component of the ORDER BY clause for trigram search.
|
||||
* Exact matches (via ILIKE) are prioritized over similarity matches.
|
||||
*/
|
||||
private buildTrigramExactMatchPriority(
|
||||
ilikeConditions: SQLFragment[],
|
||||
direction: PaginationDirection,
|
||||
): SQLFragment {
|
||||
const sortOrder = direction === PaginationDirection.FORWARD ? 'DESC' : 'ASC';
|
||||
const exactMatchCondition = SQLFragment.join(ilikeConditions, ' OR ');
|
||||
return sql`CASE WHEN ${exactMatchCondition} THEN 1 ELSE 0 END ${raw(sortOrder)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the similarity score ranking component of the ORDER BY clause.
|
||||
* Uses the highest similarity score across all search fields.
|
||||
*/
|
||||
private buildTrigramSimilarityRanking(
|
||||
search: DataManagerSearchSpecification<TFields>,
|
||||
direction: PaginationDirection,
|
||||
): SQLFragment {
|
||||
const sortOrder = direction === PaginationDirection.FORWARD ? 'DESC' : 'ASC';
|
||||
const similarityGreatestExpr = this.buildTrigramSimilarityGreatestExpression(search);
|
||||
return sql`${similarityGreatestExpr} ${raw(sortOrder)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the tie-breaker fields component of the ORDER BY clause.
|
||||
* Includes extra order-by fields (if specified) and always includes the ID field for stability.
|
||||
*/
|
||||
private buildTrigramTieBreakers(
|
||||
search: DataManagerTrigramSearchSpecification<TFields>,
|
||||
direction: PaginationDirection,
|
||||
): SQLFragment {
|
||||
const idField = getDatabaseFieldForEntityField(
|
||||
this.entityConfiguration,
|
||||
this.entityConfiguration.idField,
|
||||
);
|
||||
|
||||
const extraOrderByFields = search.extraOrderByFields?.map((field) =>
|
||||
getDatabaseFieldForEntityField(this.entityConfiguration, field),
|
||||
);
|
||||
|
||||
const sortOrder = direction === PaginationDirection.FORWARD ? 'DESC' : 'ASC';
|
||||
|
||||
const allTieBreakerFields = [...(extraOrderByFields ?? []), idField];
|
||||
const tieBreakerClauses = allTieBreakerFields.map(
|
||||
(field) => sql`${identifier(field)} ${raw(sortOrder)}`,
|
||||
);
|
||||
|
||||
return SQLFragment.join(tieBreakerClauses, ', ');
|
||||
}
|
||||
|
||||
private static escapeILikePattern(term: string): string {
|
||||
return term.replace(/[%_\\]/g, '\\$&');
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@ 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,
|
||||
@@ -37,7 +38,7 @@ export function getKnexEntityLoaderFactory<
|
||||
TPrivacyPolicy,
|
||||
TSelectedFields
|
||||
>,
|
||||
viewerContext: TViewerContext,
|
||||
viewerContext: TViewerContext2,
|
||||
): KnexEntityLoaderFactory<
|
||||
TFields,
|
||||
TIDField,
|
||||
|
||||
@@ -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 */
|
||||
@@ -20,6 +20,7 @@ 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,
|
||||
@@ -38,7 +39,7 @@ export function knexLoader<
|
||||
TMPrivacyPolicy,
|
||||
TMSelectedFields
|
||||
>,
|
||||
viewerContext: TMViewerContext,
|
||||
viewerContext: TMViewerContext2,
|
||||
queryContext: EntityQueryContext = viewerContext
|
||||
.getViewerScopedEntityCompanionForClass(entityClass)
|
||||
.getQueryContextProvider()
|
||||
@@ -69,6 +70,7 @@ 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,
|
||||
@@ -87,7 +89,7 @@ export function knexLoaderWithAuthorizationResults<
|
||||
TMPrivacyPolicy,
|
||||
TMSelectedFields
|
||||
>,
|
||||
viewerContext: TMViewerContext,
|
||||
viewerContext: TMViewerContext2,
|
||||
queryContext: EntityQueryContext = viewerContext
|
||||
.getViewerScopedEntityCompanionForClass(entityClass)
|
||||
.getQueryContextProvider()
|
||||
|
||||
@@ -3,6 +3,27 @@
|
||||
All notable changes to this project will be documented in this file.
|
||||
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
|
||||
|
||||
# [0.58.0](https://github.com/expo/entity/compare/v0.57.0...v0.58.0) (2026-03-02)
|
||||
|
||||
**Note:** Version bump only for package @expo/entity-example
|
||||
|
||||
# [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.58.0",
|
||||
"description": "An example integration of the @expo/entity framework",
|
||||
"scripts": {
|
||||
"build": "tsc --build",
|
||||
@@ -29,15 +29,15 @@
|
||||
"@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",
|
||||
"@types/koa": "3.0.1",
|
||||
"@types/koa__cors": "5.0.1",
|
||||
"@types/koa__router": "12.0.5",
|
||||
"@types/supertest": "6.0.3",
|
||||
"@types/supertest": "7.2.0",
|
||||
"supertest": "7.2.2",
|
||||
"typescript": "5.9.3"
|
||||
}
|
||||
|
||||
@@ -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,20 @@
|
||||
All notable changes to this project will be documented in this file.
|
||||
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
|
||||
|
||||
# [0.58.0](https://github.com/expo/entity/compare/v0.57.0...v0.58.0) (2026-03-02)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- escape all matching portions of parts of cacher cache keys ([#486](https://github.com/expo/entity/issues/486)) ([21f2f6f](https://github.com/expo/entity/commit/21f2f6f461ee771e8b1c1b22dbaf9b275094c0db))
|
||||
|
||||
# [0.57.0](https://github.com/expo/entity/compare/v0.56.0...v0.57.0) (2026-02-28)
|
||||
|
||||
**Note:** Version bump only for package @expo/entity-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.58.0",
|
||||
"description": "Full redis and knex integration tests for the entity framework",
|
||||
"scripts": {
|
||||
"build": "tsc --build",
|
||||
|
||||
+2
-2
@@ -118,7 +118,7 @@ describe('Entity cache inconsistency', () => {
|
||||
makeKeyFn(...parts: string[]): string {
|
||||
const delimiter = ':';
|
||||
const escapedParts = parts.map((part) =>
|
||||
part.replace('\\', '\\\\').replace(delimiter, `\\${delimiter}`),
|
||||
part.replaceAll('\\', '\\\\').replaceAll(delimiter, `\\${delimiter}`),
|
||||
);
|
||||
return escapedParts.join(delimiter);
|
||||
},
|
||||
@@ -181,7 +181,7 @@ describe('Entity cache inconsistency', () => {
|
||||
|
||||
openBarrier2!();
|
||||
})(),
|
||||
viewerContext.runInTransactionForDatabaseAdaptorFlavorAsync(
|
||||
viewerContext.runInTransactionForDatabaseAdapterFlavorAsync(
|
||||
'postgres',
|
||||
async (queryContext) => {
|
||||
await TestEntity.updater(entity1, queryContext)
|
||||
|
||||
+2
-2
@@ -118,7 +118,7 @@ describe('Lack of entity cache push safety with RedisCacheInvalidationStrategy.C
|
||||
makeKeyFn(...parts: string[]): string {
|
||||
const delimiter = ':';
|
||||
const escapedParts = parts.map((part) =>
|
||||
part.replace('\\', '\\\\').replace(delimiter, `\\${delimiter}`),
|
||||
part.replaceAll('\\', '\\\\').replaceAll(delimiter, `\\${delimiter}`),
|
||||
);
|
||||
return escapedParts.join(delimiter);
|
||||
},
|
||||
@@ -210,7 +210,7 @@ describe('Entity cache push safety with RedisCacheInvalidationStrategy.SURROUNDI
|
||||
makeKeyFn(...parts: string[]): string {
|
||||
const delimiter = ':';
|
||||
const escapedParts = parts.map((part) =>
|
||||
part.replace('\\', '\\\\').replace(delimiter, `\\${delimiter}`),
|
||||
part.replaceAll('\\', '\\\\').replaceAll(delimiter, `\\${delimiter}`),
|
||||
);
|
||||
return escapedParts.join(delimiter);
|
||||
},
|
||||
|
||||
+1
-1
@@ -56,7 +56,7 @@ describe('EntityMutator.processEntityDeletionForInboundEdgesAsync', () => {
|
||||
makeKeyFn(...parts: string[]): string {
|
||||
const delimiter = ':';
|
||||
const escapedParts = parts.map((part) =>
|
||||
part.replace('\\', '\\\\').replace(delimiter, `\\${delimiter}`),
|
||||
part.replaceAll('\\', '\\\\').replaceAll(delimiter, `\\${delimiter}`),
|
||||
);
|
||||
return escapedParts.join(delimiter);
|
||||
},
|
||||
|
||||
+1
-1
@@ -107,7 +107,7 @@ describe('Entity integrity', () => {
|
||||
makeKeyFn(...parts: string[]): string {
|
||||
const delimiter = ':';
|
||||
const escapedParts = parts.map((part) =>
|
||||
part.replace('\\', '\\\\').replace(delimiter, `\\${delimiter}`),
|
||||
part.replaceAll('\\', '\\\\').replaceAll(delimiter, `\\${delimiter}`),
|
||||
);
|
||||
return escapedParts.join(delimiter);
|
||||
},
|
||||
|
||||
+1
-1
@@ -195,7 +195,7 @@ describe('EntityMutator.processEntityDeletionForInboundEdgesAsync', () => {
|
||||
makeKeyFn(...parts: string[]): string {
|
||||
const delimiter = ':';
|
||||
const escapedParts = parts.map((part) =>
|
||||
part.replace('\\', '\\\\').replace(delimiter, `\\${delimiter}`),
|
||||
part.replaceAll('\\', '\\\\').replaceAll(delimiter, `\\${delimiter}`),
|
||||
);
|
||||
return escapedParts.join(delimiter);
|
||||
},
|
||||
|
||||
@@ -3,6 +3,18 @@
|
||||
All notable changes to this project will be documented in this file.
|
||||
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
|
||||
|
||||
# [0.58.0](https://github.com/expo/entity/compare/v0.57.0...v0.58.0) (2026-03-02)
|
||||
|
||||
**Note:** Version bump only for package @expo/entity-ip-address-field
|
||||
|
||||
# [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.58.0",
|
||||
"description": "IP address EntityField definitions for @expo/entity",
|
||||
"files": [
|
||||
"build",
|
||||
|
||||
@@ -3,6 +3,20 @@
|
||||
All notable changes to this project will be documented in this file.
|
||||
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
|
||||
|
||||
# [0.58.0](https://github.com/expo/entity/compare/v0.57.0...v0.58.0) (2026-03-02)
|
||||
|
||||
**Note:** Version bump only for package @expo/entity-secondary-cache-local-memory
|
||||
|
||||
# [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.58.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,20 @@
|
||||
All notable changes to this project will be documented in this file.
|
||||
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
|
||||
|
||||
# [0.58.0](https://github.com/expo/entity/compare/v0.57.0...v0.58.0) (2026-03-02)
|
||||
|
||||
**Note:** Version bump only for package @expo/entity-secondary-cache-redis
|
||||
|
||||
# [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.58.0",
|
||||
"description": "Redis secondary cache for @expo/entity",
|
||||
"files": [
|
||||
"build",
|
||||
@@ -32,7 +32,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@jest/globals": "30.2.0",
|
||||
"ioredis": "5.9.3",
|
||||
"ioredis": "5.10.0",
|
||||
"nullthrows": "1.1.1",
|
||||
"typescript": "5.9.3"
|
||||
}
|
||||
|
||||
+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,25 @@
|
||||
All notable changes to this project will be documented in this file.
|
||||
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
|
||||
|
||||
# [0.58.0](https://github.com/expo/entity/compare/v0.57.0...v0.58.0) (2026-03-02)
|
||||
|
||||
**Note:** Version bump only for package @expo/entity-testing-utils
|
||||
|
||||
# [0.57.0](https://github.com/expo/entity/compare/v0.56.0...v0.57.0) (2026-02-28)
|
||||
|
||||
**Note:** Version bump only for package @expo/entity-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.58.0",
|
||||
"description": "A package containing utilities for testing applications that use Entity",
|
||||
"files": [
|
||||
"build",
|
||||
@@ -36,8 +36,8 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@jest/globals": "30.2.0",
|
||||
"@types/lodash": "4.17.23",
|
||||
"@types/node": "24.10.13",
|
||||
"@types/lodash": "4.17.24",
|
||||
"@types/node": "24.10.15",
|
||||
"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,
|
||||
|
||||
@@ -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,32 @@
|
||||
All notable changes to this project will be documented in this file.
|
||||
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
|
||||
|
||||
# [0.58.0](https://github.com/expo/entity/compare/v0.57.0...v0.58.0) (2026-03-02)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- escape all matching portions of parts of cacher cache keys ([#486](https://github.com/expo/entity/issues/486)) ([21f2f6f](https://github.com/expo/entity/commit/21f2f6f461ee771e8b1c1b22dbaf9b275094c0db))
|
||||
|
||||
# [0.57.0](https://github.com/expo/entity/compare/v0.56.0...v0.57.0) (2026-02-28)
|
||||
|
||||
**Note:** Version bump only for package @expo/entity
|
||||
|
||||
# [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.58.0",
|
||||
"description": "A privacy-first data model",
|
||||
"files": [
|
||||
"build",
|
||||
@@ -34,8 +34,8 @@
|
||||
"devDependencies": {
|
||||
"@jest/globals": "30.2.0",
|
||||
"@types/invariant": "2.2.37",
|
||||
"@types/lodash": "4.17.23",
|
||||
"@types/node": "24.10.13",
|
||||
"@types/lodash": "4.17.24",
|
||||
"@types/node": "24.10.15",
|
||||
"lodash": "4.17.23",
|
||||
"ts-mockito": "2.6.1",
|
||||
"typescript": "5.9.3",
|
||||
|
||||
@@ -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>,
|
||||
|
||||
@@ -208,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;
|
||||
@@ -233,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(
|
||||
@@ -241,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 });
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
|
||||
@@ -90,12 +90,6 @@ describe(AuthorizationResultBasedEntityLoader, () => {
|
||||
instance(mock<IEntityMetricsAdapter>()),
|
||||
TestEntity.name,
|
||||
);
|
||||
const invalidationUtils = new EntityInvalidationUtils(
|
||||
testEntityConfiguration,
|
||||
TestEntity,
|
||||
dataManager,
|
||||
metricsAdapter,
|
||||
);
|
||||
const constructionUtils = new EntityConstructionUtils(
|
||||
viewerContext,
|
||||
queryContext,
|
||||
@@ -111,8 +105,6 @@ describe(AuthorizationResultBasedEntityLoader, () => {
|
||||
testEntityConfiguration,
|
||||
TestEntity,
|
||||
dataManager,
|
||||
metricsAdapter,
|
||||
invalidationUtils,
|
||||
constructionUtils,
|
||||
);
|
||||
|
||||
@@ -240,12 +232,6 @@ describe(AuthorizationResultBasedEntityLoader, () => {
|
||||
instance(mock<IEntityMetricsAdapter>()),
|
||||
TestEntity.name,
|
||||
);
|
||||
const invalidationUtils = new EntityInvalidationUtils(
|
||||
testEntityConfiguration,
|
||||
TestEntity,
|
||||
dataManager,
|
||||
metricsAdapter,
|
||||
);
|
||||
const constructionUtils = new EntityConstructionUtils(
|
||||
viewerContext,
|
||||
queryContext,
|
||||
@@ -261,8 +247,6 @@ describe(AuthorizationResultBasedEntityLoader, () => {
|
||||
testEntityConfiguration,
|
||||
TestEntity,
|
||||
dataManager,
|
||||
metricsAdapter,
|
||||
invalidationUtils,
|
||||
constructionUtils,
|
||||
);
|
||||
|
||||
@@ -385,12 +369,6 @@ describe(AuthorizationResultBasedEntityLoader, () => {
|
||||
instance(mock<IEntityMetricsAdapter>()),
|
||||
TestEntity.name,
|
||||
);
|
||||
const invalidationUtils = new EntityInvalidationUtils(
|
||||
testEntityConfiguration,
|
||||
TestEntity,
|
||||
dataManager,
|
||||
metricsAdapter,
|
||||
);
|
||||
const constructionUtils = new EntityConstructionUtils(
|
||||
viewerContext,
|
||||
queryContext,
|
||||
@@ -406,8 +384,6 @@ describe(AuthorizationResultBasedEntityLoader, () => {
|
||||
testEntityConfiguration,
|
||||
TestEntity,
|
||||
dataManager,
|
||||
metricsAdapter,
|
||||
invalidationUtils,
|
||||
constructionUtils,
|
||||
);
|
||||
const entity = await enforceAsyncResult(entityLoader.loadByIDAsync(id1));
|
||||
@@ -423,21 +399,7 @@ describe(AuthorizationResultBasedEntityLoader, () => {
|
||||
});
|
||||
|
||||
it('invalidates upon invalidate one', async () => {
|
||||
const viewerContext = instance(mock(ViewerContext));
|
||||
const privacyPolicyEvaluationContext =
|
||||
instance(
|
||||
mock<
|
||||
EntityPrivacyPolicyEvaluationContext<
|
||||
TestFields,
|
||||
'customIdField',
|
||||
ViewerContext,
|
||||
TestEntity
|
||||
>
|
||||
>(),
|
||||
);
|
||||
const metricsAdapter = instance(mock<IEntityMetricsAdapter>());
|
||||
const queryContext = new StubQueryContextProvider().getQueryContext();
|
||||
const privacyPolicy = instance(mock(TestEntityPrivacyPolicy));
|
||||
const dataManagerMock = mock<EntityDataManager<TestFields, 'customIdField'>>();
|
||||
const dataManagerInstance = instance(dataManagerMock);
|
||||
|
||||
@@ -448,29 +410,10 @@ describe(AuthorizationResultBasedEntityLoader, () => {
|
||||
dataManagerInstance,
|
||||
metricsAdapter,
|
||||
);
|
||||
const constructionUtils = new EntityConstructionUtils(
|
||||
viewerContext,
|
||||
queryContext,
|
||||
privacyPolicyEvaluationContext,
|
||||
testEntityConfiguration,
|
||||
TestEntity,
|
||||
/* entitySelectedFields */ undefined,
|
||||
privacyPolicy,
|
||||
metricsAdapter,
|
||||
);
|
||||
const entityLoader = new AuthorizationResultBasedEntityLoader(
|
||||
queryContext,
|
||||
testEntityConfiguration,
|
||||
TestEntity,
|
||||
dataManagerInstance,
|
||||
metricsAdapter,
|
||||
invalidationUtils,
|
||||
constructionUtils,
|
||||
);
|
||||
|
||||
const date = new Date();
|
||||
|
||||
await entityLoader.invalidationUtils.invalidateFieldsAsync({
|
||||
await invalidationUtils.invalidateFieldsAsync({
|
||||
customIdField: id1,
|
||||
testIndexedField: 'h1',
|
||||
intField: 5,
|
||||
@@ -522,21 +465,7 @@ describe(AuthorizationResultBasedEntityLoader, () => {
|
||||
});
|
||||
|
||||
it('invalidates upon invalidate by entity', async () => {
|
||||
const viewerContext = instance(mock(ViewerContext));
|
||||
const privacyPolicyEvaluationContext =
|
||||
instance(
|
||||
mock<
|
||||
EntityPrivacyPolicyEvaluationContext<
|
||||
TestFields,
|
||||
'customIdField',
|
||||
ViewerContext,
|
||||
TestEntity
|
||||
>
|
||||
>(),
|
||||
);
|
||||
const metricsAdapter = instance(mock<IEntityMetricsAdapter>());
|
||||
const queryContext = new StubQueryContextProvider().getQueryContext();
|
||||
const privacyPolicy = instance(mock(TestEntityPrivacyPolicy));
|
||||
const dataManagerMock = mock<EntityDataManager<TestFields, 'customIdField'>>();
|
||||
const dataManagerInstance = instance(dataManagerMock);
|
||||
|
||||
@@ -560,26 +489,8 @@ describe(AuthorizationResultBasedEntityLoader, () => {
|
||||
dataManagerInstance,
|
||||
metricsAdapter,
|
||||
);
|
||||
const constructionUtils = new EntityConstructionUtils(
|
||||
viewerContext,
|
||||
queryContext,
|
||||
privacyPolicyEvaluationContext,
|
||||
testEntityConfiguration,
|
||||
TestEntity,
|
||||
/* entitySelectedFields */ undefined,
|
||||
privacyPolicy,
|
||||
metricsAdapter,
|
||||
);
|
||||
const entityLoader = new AuthorizationResultBasedEntityLoader(
|
||||
queryContext,
|
||||
testEntityConfiguration,
|
||||
TestEntity,
|
||||
dataManagerInstance,
|
||||
metricsAdapter,
|
||||
invalidationUtils,
|
||||
constructionUtils,
|
||||
);
|
||||
await entityLoader.invalidationUtils.invalidateEntityAsync(entityInstance);
|
||||
|
||||
await invalidationUtils.invalidateEntityAsync(entityInstance);
|
||||
|
||||
verify(dataManagerMock.invalidateKeyValuePairsAsync(anything())).once();
|
||||
verify(
|
||||
@@ -624,21 +535,8 @@ describe(AuthorizationResultBasedEntityLoader, () => {
|
||||
});
|
||||
|
||||
it('invalidates upon invalidate by entity within transaction', async () => {
|
||||
const viewerContext = instance(mock(ViewerContext));
|
||||
const privacyPolicyEvaluationContext =
|
||||
instance(
|
||||
mock<
|
||||
EntityPrivacyPolicyEvaluationContext<
|
||||
TestFields,
|
||||
'customIdField',
|
||||
ViewerContext,
|
||||
TestEntity
|
||||
>
|
||||
>(),
|
||||
);
|
||||
const metricsAdapter = instance(mock<IEntityMetricsAdapter>());
|
||||
|
||||
const privacyPolicy = instance(mock(TestEntityPrivacyPolicy));
|
||||
const dataManagerMock = mock<EntityDataManager<TestFields, 'customIdField'>>();
|
||||
const dataManagerInstance = instance(dataManagerMock);
|
||||
|
||||
@@ -663,26 +561,7 @@ describe(AuthorizationResultBasedEntityLoader, () => {
|
||||
dataManagerInstance,
|
||||
metricsAdapter,
|
||||
);
|
||||
const constructionUtils = new EntityConstructionUtils(
|
||||
viewerContext,
|
||||
queryContext,
|
||||
privacyPolicyEvaluationContext,
|
||||
testEntityConfiguration,
|
||||
TestEntity,
|
||||
/* entitySelectedFields */ undefined,
|
||||
privacyPolicy,
|
||||
metricsAdapter,
|
||||
);
|
||||
const entityLoader = new AuthorizationResultBasedEntityLoader(
|
||||
queryContext,
|
||||
testEntityConfiguration,
|
||||
TestEntity,
|
||||
dataManagerInstance,
|
||||
metricsAdapter,
|
||||
invalidationUtils,
|
||||
constructionUtils,
|
||||
);
|
||||
entityLoader.invalidationUtils.invalidateEntityForTransaction(queryContext, entityInstance);
|
||||
invalidationUtils.invalidateEntityForTransaction(queryContext, entityInstance);
|
||||
|
||||
verify(
|
||||
dataManagerMock.invalidateKeyValuePairsForTransaction(queryContext, anything()),
|
||||
@@ -781,12 +660,6 @@ describe(AuthorizationResultBasedEntityLoader, () => {
|
||||
const privacyPolicy = instance(privacyPolicyMock);
|
||||
const dataManagerInstance = instance(dataManagerMock);
|
||||
|
||||
const invalidationUtils = new EntityInvalidationUtils(
|
||||
testEntityConfiguration,
|
||||
TestEntity,
|
||||
dataManagerInstance,
|
||||
metricsAdapter,
|
||||
);
|
||||
const constructionUtils = new EntityConstructionUtils(
|
||||
viewerContext,
|
||||
queryContext,
|
||||
@@ -802,8 +675,6 @@ describe(AuthorizationResultBasedEntityLoader, () => {
|
||||
testEntityConfiguration,
|
||||
TestEntity,
|
||||
dataManagerInstance,
|
||||
metricsAdapter,
|
||||
invalidationUtils,
|
||||
constructionUtils,
|
||||
);
|
||||
|
||||
@@ -839,12 +710,6 @@ describe(AuthorizationResultBasedEntityLoader, () => {
|
||||
|
||||
const dataManagerInstance = instance(dataManagerMock);
|
||||
|
||||
const invalidationUtils = new EntityInvalidationUtils(
|
||||
testEntityConfiguration,
|
||||
TestEntity,
|
||||
dataManagerInstance,
|
||||
metricsAdapter,
|
||||
);
|
||||
const constructionUtils = new EntityConstructionUtils(
|
||||
viewerContext,
|
||||
queryContext,
|
||||
@@ -860,8 +725,6 @@ describe(AuthorizationResultBasedEntityLoader, () => {
|
||||
testEntityConfiguration,
|
||||
TestEntity,
|
||||
dataManagerInstance,
|
||||
metricsAdapter,
|
||||
invalidationUtils,
|
||||
constructionUtils,
|
||||
);
|
||||
|
||||
|
||||
@@ -115,7 +115,7 @@ class TestLocalCacheAdapter<
|
||||
cacheKeyType,
|
||||
`${this.entityConfiguration.cacheKeyVersion}`,
|
||||
...parts,
|
||||
].map((part) => part.replace('\\', '\\\\').replace(delimiter, `\\${delimiter}`));
|
||||
].map((part) => part.replaceAll('\\', '\\\\').replaceAll(delimiter, `\\${delimiter}`));
|
||||
return escapedParts.join(delimiter);
|
||||
}
|
||||
}
|
||||
@@ -289,7 +289,7 @@ describe(ComposedEntityCacheAdapter, () => {
|
||||
const { cacheAdapter } = makeTestCacheAdapters();
|
||||
const results = await cacheAdapter.loadManyAsync(
|
||||
new SingleFieldHolder<BlahFields, 'id', 'id'>('id'),
|
||||
[] as any,
|
||||
[] as readonly SingleFieldValueHolder<BlahFields, 'id'>[],
|
||||
);
|
||||
expect(results.size).toBe(0);
|
||||
});
|
||||
@@ -298,7 +298,7 @@ describe(ComposedEntityCacheAdapter, () => {
|
||||
const cacheAdapter = new ComposedEntityCacheAdapter<BlahFields, 'id'>([]);
|
||||
const results = await cacheAdapter.loadManyAsync(
|
||||
new SingleFieldHolder<BlahFields, 'id', 'id'>('id'),
|
||||
[] as any,
|
||||
[] as readonly SingleFieldValueHolder<BlahFields, 'id'>[],
|
||||
);
|
||||
expect(results.size).toBe(0);
|
||||
});
|
||||
@@ -430,7 +430,7 @@ describe(ComposedEntityCacheAdapter, () => {
|
||||
|
||||
await cacheAdapter.invalidateManyAsync(
|
||||
new SingleFieldHolder<BlahFields, 'id', 'id'>('id'),
|
||||
[] as any,
|
||||
[] as readonly SingleFieldValueHolder<BlahFields, 'id'>[],
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -175,7 +175,7 @@ describe(EntityConfiguration, () => {
|
||||
])('disallows %p as field key', (keyName) => {
|
||||
expect(
|
||||
() =>
|
||||
new EntityConfiguration<any, 'id'>({
|
||||
new EntityConfiguration<{ id: string }, 'id'>({
|
||||
idField: 'id',
|
||||
tableName: 'blah_table',
|
||||
schema: {
|
||||
@@ -186,7 +186,7 @@ describe(EntityConfiguration, () => {
|
||||
[keyName]: new StringField({
|
||||
columnName: 'any',
|
||||
}),
|
||||
} as any,
|
||||
},
|
||||
databaseAdapterFlavor: 'postgres',
|
||||
cacheAdapterFlavor: 'redis',
|
||||
}),
|
||||
|
||||
@@ -228,14 +228,14 @@ describe(EntityDatabaseAdapter, () => {
|
||||
it('transforms object', async () => {
|
||||
const queryContext = instance(mock(EntityQueryContext));
|
||||
const adapter = new TestEntityDatabaseAdapter({ insertResults: [{ string_field: 'hello' }] });
|
||||
const result = await adapter.insertAsync(queryContext, {} as any);
|
||||
const result = await adapter.insertAsync(queryContext, {});
|
||||
expect(result).toEqual({ stringField: 'hello' });
|
||||
});
|
||||
|
||||
it('throws when insert result count zero', async () => {
|
||||
const queryContext = instance(mock(EntityQueryContext));
|
||||
const adapter = new TestEntityDatabaseAdapter({ insertResults: [] });
|
||||
await expect(adapter.insertAsync(queryContext, {} as any)).rejects.toThrow(
|
||||
await expect(adapter.insertAsync(queryContext, {})).rejects.toThrow(
|
||||
EntityDatabaseAdapterEmptyInsertResultError,
|
||||
);
|
||||
});
|
||||
@@ -245,7 +245,7 @@ describe(EntityDatabaseAdapter, () => {
|
||||
const adapter = new TestEntityDatabaseAdapter({
|
||||
insertResults: [{ string_field: 'hello' }, { string_field: 'hello2' }],
|
||||
});
|
||||
await expect(adapter.insertAsync(queryContext, {} as any)).rejects.toThrow(
|
||||
await expect(adapter.insertAsync(queryContext, {})).rejects.toThrow(
|
||||
EntityDatabaseAdapterExcessiveInsertResultError,
|
||||
);
|
||||
});
|
||||
@@ -255,16 +255,16 @@ describe(EntityDatabaseAdapter, () => {
|
||||
it('transforms object', async () => {
|
||||
const queryContext = instance(mock(EntityQueryContext));
|
||||
const adapter = new TestEntityDatabaseAdapter({ updateResults: [{ string_field: 'hello' }] });
|
||||
const result = await adapter.updateAsync(queryContext, 'customIdField', 'wat', {} as any);
|
||||
const result = await adapter.updateAsync(queryContext, 'customIdField', 'wat', {});
|
||||
expect(result).toEqual({ stringField: 'hello' });
|
||||
});
|
||||
|
||||
it('throws when update result count zero', async () => {
|
||||
const queryContext = instance(mock(EntityQueryContext));
|
||||
const adapter = new TestEntityDatabaseAdapter({ updateResults: [] });
|
||||
await expect(
|
||||
adapter.updateAsync(queryContext, 'customIdField', 'wat', {} as any),
|
||||
).rejects.toThrow(EntityDatabaseAdapterEmptyUpdateResultError);
|
||||
await expect(adapter.updateAsync(queryContext, 'customIdField', 'wat', {})).rejects.toThrow(
|
||||
EntityDatabaseAdapterEmptyUpdateResultError,
|
||||
);
|
||||
});
|
||||
|
||||
it('throws when update result count greater than 1', async () => {
|
||||
@@ -272,9 +272,9 @@ describe(EntityDatabaseAdapter, () => {
|
||||
const adapter = new TestEntityDatabaseAdapter({
|
||||
updateResults: [{ string_field: 'hello' }, { string_field: 'hello2' }],
|
||||
});
|
||||
await expect(
|
||||
adapter.updateAsync(queryContext, 'customIdField', 'wat', {} as any),
|
||||
).rejects.toThrow(EntityDatabaseAdapterExcessiveUpdateResultError);
|
||||
await expect(adapter.updateAsync(queryContext, 'customIdField', 'wat', {})).rejects.toThrow(
|
||||
EntityDatabaseAdapterExcessiveUpdateResultError,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -2,7 +2,6 @@ import { describe, expect, it } from '@jest/globals';
|
||||
|
||||
import { AuthorizationResultBasedEntityLoader } from '../AuthorizationResultBasedEntityLoader';
|
||||
import { EnforcingEntityLoader } from '../EnforcingEntityLoader';
|
||||
import { EntityConstructionUtils } from '../EntityConstructionUtils';
|
||||
import { EntityInvalidationUtils } from '../EntityInvalidationUtils';
|
||||
import { EntityLoader } from '../EntityLoader';
|
||||
import { ViewerContext } from '../ViewerContext';
|
||||
@@ -37,14 +36,4 @@ describe(EntityLoader, () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('constructionUtils', () => {
|
||||
it('returns a instance of EntityConstructionUtils', async () => {
|
||||
const companionProvider = createUnitTestEntityCompanionProvider();
|
||||
const viewerContext = new ViewerContext(companionProvider);
|
||||
expect(SimpleTestEntity.constructionUtils(viewerContext)).toBeInstanceOf(
|
||||
EntityConstructionUtils,
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1511,7 +1511,6 @@ describe(EntityMutatorFactory, () => {
|
||||
>
|
||||
>(EntityConstructionUtils);
|
||||
when(entityConstructionUtilsMock.constructEntity(anything())).thenReturn(fakeEntity);
|
||||
when(entityLoaderMock.constructionUtils).thenReturn(instance(entityConstructionUtilsMock));
|
||||
const entityLoader = instance(entityLoaderMock);
|
||||
|
||||
const entityLoaderFactoryMock =
|
||||
@@ -1532,6 +1531,13 @@ describe(EntityMutatorFactory, () => {
|
||||
anything(),
|
||||
),
|
||||
).thenReturn(entityLoader);
|
||||
when(
|
||||
entityLoaderFactoryMock.constructionUtils(
|
||||
viewerContext,
|
||||
anyOfClass(EntityTransactionalQueryContext),
|
||||
anything(),
|
||||
),
|
||||
).thenReturn(instance(entityConstructionUtilsMock));
|
||||
const entityLoaderFactory = instance(entityLoaderFactoryMock);
|
||||
|
||||
const rejectionError = new Error();
|
||||
@@ -1646,7 +1652,6 @@ describe(EntityMutatorFactory, () => {
|
||||
>
|
||||
>(EntityConstructionUtils);
|
||||
when(entityConstructionUtilsMock.constructEntity(anything())).thenReturn(fakeEntity);
|
||||
when(entityLoaderMock.constructionUtils).thenReturn(instance(entityConstructionUtilsMock));
|
||||
const entityLoader = instance(entityLoaderMock);
|
||||
|
||||
const entityLoaderFactoryMock =
|
||||
@@ -1667,6 +1672,13 @@ describe(EntityMutatorFactory, () => {
|
||||
anything(),
|
||||
),
|
||||
).thenReturn(entityLoader);
|
||||
when(
|
||||
entityLoaderFactoryMock.constructionUtils(
|
||||
viewerContext,
|
||||
anyOfClass(EntityTransactionalQueryContext),
|
||||
anything(),
|
||||
),
|
||||
).thenReturn(instance(entityConstructionUtilsMock));
|
||||
const entityLoaderFactory = instance(entityLoaderFactoryMock);
|
||||
|
||||
const rejectionError = new Error();
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
EntityPrivacyPolicyEvaluationContext,
|
||||
EntityPrivacyPolicyEvaluationMode,
|
||||
EntityPrivacyPolicyEvaluator,
|
||||
EntityPrivacyPolicyRuleEvaluationContext,
|
||||
} from '../EntityPrivacyPolicy';
|
||||
import { EntityQueryContext } from '../EntityQueryContext';
|
||||
import { ViewerContext } from '../ViewerContext';
|
||||
@@ -273,6 +274,47 @@ class EmptyPolicy extends EntityPrivacyPolicy<BlahFields, 'id', ViewerContext, B
|
||||
protected override readonly deleteRules = [];
|
||||
}
|
||||
|
||||
class ContextCapturingRule extends PrivacyPolicyRule<BlahFields, 'id', ViewerContext, BlahEntity> {
|
||||
public capturedContext: EntityPrivacyPolicyRuleEvaluationContext<
|
||||
BlahFields,
|
||||
'id',
|
||||
ViewerContext,
|
||||
BlahEntity
|
||||
> | null = null;
|
||||
|
||||
async evaluateAsync(
|
||||
_viewerContext: ViewerContext,
|
||||
_queryContext: EntityQueryContext,
|
||||
evaluationContext: EntityPrivacyPolicyRuleEvaluationContext<
|
||||
BlahFields,
|
||||
'id',
|
||||
ViewerContext,
|
||||
BlahEntity
|
||||
>,
|
||||
_entity: BlahEntity,
|
||||
): Promise<RuleEvaluationResult> {
|
||||
this.capturedContext = evaluationContext;
|
||||
return RuleEvaluationResult.ALLOW;
|
||||
}
|
||||
}
|
||||
|
||||
class ContextCapturingPolicy extends EntityPrivacyPolicy<
|
||||
BlahFields,
|
||||
'id',
|
||||
ViewerContext,
|
||||
BlahEntity
|
||||
> {
|
||||
public readonly createRule = new ContextCapturingRule();
|
||||
public readonly readRule = new ContextCapturingRule();
|
||||
public readonly updateRule = new ContextCapturingRule();
|
||||
public readonly deleteRule = new ContextCapturingRule();
|
||||
|
||||
protected override readonly createRules = [this.createRule];
|
||||
protected override readonly readRules = [this.readRule];
|
||||
protected override readonly updateRules = [this.updateRule];
|
||||
protected override readonly deleteRules = [this.deleteRule];
|
||||
}
|
||||
|
||||
describe(EntityPrivacyPolicy, () => {
|
||||
describe(EntityPrivacyPolicyEvaluationMode.ENFORCE.toString(), () => {
|
||||
it('throws EntityNotAuthorizedError when deny', async () => {
|
||||
@@ -718,4 +760,64 @@ describe(EntityPrivacyPolicy, () => {
|
||||
verify(metricsAdapterMock.logAuthorizationEvent(anything())).never();
|
||||
});
|
||||
});
|
||||
|
||||
describe('EntityPrivacyPolicyRuleEvaluationContext', () => {
|
||||
it('passes correct authorization action to rules for each CRUD operation', async () => {
|
||||
const viewerContext = instance(mock(ViewerContext));
|
||||
const queryContext = instance(mock(EntityQueryContext));
|
||||
const privacyPolicyEvaluationContext: EntityPrivacyPolicyEvaluationContext<
|
||||
BlahFields,
|
||||
'id',
|
||||
ViewerContext,
|
||||
BlahEntity
|
||||
> = {
|
||||
previousValue: null,
|
||||
cascadingDeleteCause: null,
|
||||
};
|
||||
const metricsAdapter = instance(mock<IEntityMetricsAdapter>());
|
||||
const entity = new BlahEntity({
|
||||
viewerContext,
|
||||
id: '1',
|
||||
databaseFields: { id: '1' },
|
||||
selectedFields: { id: '1' },
|
||||
});
|
||||
const policy = new ContextCapturingPolicy();
|
||||
|
||||
await policy.authorizeCreateAsync(
|
||||
viewerContext,
|
||||
queryContext,
|
||||
privacyPolicyEvaluationContext,
|
||||
entity,
|
||||
metricsAdapter,
|
||||
);
|
||||
expect(policy.createRule.capturedContext?.action).toBe(EntityAuthorizationAction.CREATE);
|
||||
|
||||
await policy.authorizeReadAsync(
|
||||
viewerContext,
|
||||
queryContext,
|
||||
privacyPolicyEvaluationContext,
|
||||
entity,
|
||||
metricsAdapter,
|
||||
);
|
||||
expect(policy.readRule.capturedContext?.action).toBe(EntityAuthorizationAction.READ);
|
||||
|
||||
await policy.authorizeUpdateAsync(
|
||||
viewerContext,
|
||||
queryContext,
|
||||
privacyPolicyEvaluationContext,
|
||||
entity,
|
||||
metricsAdapter,
|
||||
);
|
||||
expect(policy.updateRule.capturedContext?.action).toBe(EntityAuthorizationAction.UPDATE);
|
||||
|
||||
await policy.authorizeDeleteAsync(
|
||||
viewerContext,
|
||||
queryContext,
|
||||
privacyPolicyEvaluationContext,
|
||||
entity,
|
||||
metricsAdapter,
|
||||
);
|
||||
expect(policy.deleteRule.capturedContext?.action).toBe(EntityAuthorizationAction.DELETE);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -49,7 +49,7 @@ describe(EntityQueryContext, () => {
|
||||
);
|
||||
});
|
||||
|
||||
await viewerContext.runInTransactionForDatabaseAdaptorFlavorAsync(
|
||||
await viewerContext.runInTransactionForDatabaseAdapterFlavorAsync(
|
||||
'postgres',
|
||||
async (queryContext) => {
|
||||
queryContext.appendPostCommitCallback(postCommitCallback);
|
||||
@@ -76,7 +76,7 @@ describe(EntityQueryContext, () => {
|
||||
const postCommitCallback = jest.fn(async (): Promise<void> => {});
|
||||
|
||||
await expect(
|
||||
viewerContext.runInTransactionForDatabaseAdaptorFlavorAsync(
|
||||
viewerContext.runInTransactionForDatabaseAdapterFlavorAsync(
|
||||
'postgres',
|
||||
async (queryContext) => {
|
||||
queryContext.appendPostCommitCallback(postCommitCallback);
|
||||
@@ -105,7 +105,7 @@ describe(EntityQueryContext, () => {
|
||||
const postCommitCallback = jest.fn(async (): Promise<void> => {});
|
||||
const postCommitNestedCallback = jest.fn(async (): Promise<void> => {});
|
||||
|
||||
await viewerContext.runInTransactionForDatabaseAdaptorFlavorAsync(
|
||||
await viewerContext.runInTransactionForDatabaseAdapterFlavorAsync(
|
||||
'postgres',
|
||||
async (queryContext) => {
|
||||
queryContext.appendPostCommitCallback(postCommitCallback);
|
||||
@@ -152,13 +152,13 @@ describe(EntityQueryContext, () => {
|
||||
const viewerContext = new ViewerContext(companionProvider);
|
||||
|
||||
const queryContextProvider =
|
||||
companionProvider.getQueryContextProviderForDatabaseAdaptorFlavor('postgres');
|
||||
companionProvider.getQueryContextProviderForDatabaseAdapterFlavor('postgres');
|
||||
const queryContextProviderSpy = jest.spyOn(queryContextProvider, 'runInTransactionAsync');
|
||||
|
||||
const transactionScopeFn = async (): Promise<any> => {};
|
||||
const transactionConfig = { isolationLevel: TransactionIsolationLevel.SERIALIZABLE };
|
||||
|
||||
await viewerContext.runInTransactionForDatabaseAdaptorFlavorAsync(
|
||||
await viewerContext.runInTransactionForDatabaseAdapterFlavorAsync(
|
||||
'postgres',
|
||||
transactionScopeFn,
|
||||
transactionConfig,
|
||||
@@ -171,7 +171,7 @@ describe(EntityQueryContext, () => {
|
||||
const companionProvider = createUnitTestEntityCompanionProvider();
|
||||
const viewerContext = new ViewerContext(companionProvider);
|
||||
|
||||
await viewerContext.runInTransactionForDatabaseAdaptorFlavorAsync(
|
||||
await viewerContext.runInTransactionForDatabaseAdapterFlavorAsync(
|
||||
'postgres',
|
||||
async (queryContext) => {
|
||||
assert(queryContext.isInTransaction());
|
||||
@@ -182,7 +182,7 @@ describe(EntityQueryContext, () => {
|
||||
{ transactionalDataLoaderMode: TransactionalDataLoaderMode.DISABLED },
|
||||
);
|
||||
|
||||
await viewerContext.runInTransactionForDatabaseAdaptorFlavorAsync(
|
||||
await viewerContext.runInTransactionForDatabaseAdapterFlavorAsync(
|
||||
'postgres',
|
||||
async (queryContext) => {
|
||||
assert(queryContext.isInTransaction());
|
||||
@@ -193,7 +193,7 @@ describe(EntityQueryContext, () => {
|
||||
{ transactionalDataLoaderMode: TransactionalDataLoaderMode.ENABLED_BATCH_ONLY },
|
||||
);
|
||||
|
||||
await viewerContext.runInTransactionForDatabaseAdaptorFlavorAsync(
|
||||
await viewerContext.runInTransactionForDatabaseAdapterFlavorAsync(
|
||||
'postgres',
|
||||
async (queryContext) => {
|
||||
assert(queryContext.isInTransaction());
|
||||
@@ -252,7 +252,7 @@ describe(EntityQueryContext, () => {
|
||||
);
|
||||
const viewerContext = new ViewerContext(companionProvider);
|
||||
|
||||
await viewerContext.runInTransactionForDatabaseAdaptorFlavorAsync(
|
||||
await viewerContext.runInTransactionForDatabaseAdapterFlavorAsync(
|
||||
'postgres',
|
||||
async (queryContext) => {
|
||||
assert(queryContext.isInTransaction());
|
||||
@@ -263,7 +263,7 @@ describe(EntityQueryContext, () => {
|
||||
{ transactionalDataLoaderMode: TransactionalDataLoaderMode.DISABLED },
|
||||
);
|
||||
|
||||
await viewerContext.runInTransactionForDatabaseAdaptorFlavorAsync(
|
||||
await viewerContext.runInTransactionForDatabaseAdapterFlavorAsync(
|
||||
'postgres',
|
||||
async (queryContext) => {
|
||||
assert(queryContext.isInTransaction());
|
||||
@@ -274,7 +274,7 @@ describe(EntityQueryContext, () => {
|
||||
{ transactionalDataLoaderMode: TransactionalDataLoaderMode.ENABLED_BATCH_ONLY },
|
||||
);
|
||||
|
||||
await viewerContext.runInTransactionForDatabaseAdaptorFlavorAsync(
|
||||
await viewerContext.runInTransactionForDatabaseAdapterFlavorAsync(
|
||||
'postgres',
|
||||
async (queryContext) => {
|
||||
assert(queryContext.isInTransaction());
|
||||
|
||||
@@ -46,7 +46,7 @@ describe(EntitySecondaryCacheLoader, () => {
|
||||
|
||||
const secondaryCacheLoader = new TestSecondaryRedisCacheLoader(
|
||||
secondaryEntityCache,
|
||||
SimpleTestEntity.loaderWithAuthorizationResults(vc1),
|
||||
EntitySecondaryCacheLoader.getConstructionUtilsForEntityClass(SimpleTestEntity, vc1),
|
||||
);
|
||||
|
||||
await secondaryCacheLoader.loadManyAsync([loadParams]);
|
||||
@@ -70,8 +70,11 @@ describe(EntitySecondaryCacheLoader, () => {
|
||||
const secondaryEntityCache = instance(secondaryEntityCacheMock);
|
||||
|
||||
const loader = SimpleTestEntity.loaderWithAuthorizationResults(vc1);
|
||||
const spiedPrivacyPolicy = spy(loader.constructionUtils['privacyPolicy']);
|
||||
const secondaryCacheLoader = new TestSecondaryRedisCacheLoader(secondaryEntityCache, loader);
|
||||
const spiedPrivacyPolicy = spy(loader['constructionUtils']['privacyPolicy']);
|
||||
const secondaryCacheLoader = new TestSecondaryRedisCacheLoader(
|
||||
secondaryEntityCache,
|
||||
EntitySecondaryCacheLoader.getConstructionUtilsForEntityClass(SimpleTestEntity, vc1),
|
||||
);
|
||||
|
||||
const result = await secondaryCacheLoader.loadManyAsync([loadParams]);
|
||||
expect(result.get(loadParams)?.enforceValue().getID()).toEqual(createdEntity.getID());
|
||||
@@ -98,8 +101,10 @@ describe(EntitySecondaryCacheLoader, () => {
|
||||
const secondaryEntityCacheMock =
|
||||
mock<ISecondaryEntityCache<SimpleTestFields, TestLoadParams>>();
|
||||
const secondaryEntityCache = instance(secondaryEntityCacheMock);
|
||||
const loader = SimpleTestEntity.loaderWithAuthorizationResults(vc1);
|
||||
const secondaryCacheLoader = new TestSecondaryRedisCacheLoader(secondaryEntityCache, loader);
|
||||
const secondaryCacheLoader = new TestSecondaryRedisCacheLoader(
|
||||
secondaryEntityCache,
|
||||
EntitySecondaryCacheLoader.getConstructionUtilsForEntityClass(SimpleTestEntity, vc1),
|
||||
);
|
||||
await secondaryCacheLoader.invalidateManyAsync([loadParams]);
|
||||
|
||||
verify(secondaryEntityCacheMock.invalidateManyAsync(deepEqual([loadParams]))).once();
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { describe, it, expect } from '@jest/globals';
|
||||
import nullthrows from 'nullthrows';
|
||||
|
||||
import { AuthorizationResultBasedEntityLoader } from '../AuthorizationResultBasedEntityLoader';
|
||||
import { EntityConstructionUtils } from '../EntityConstructionUtils';
|
||||
import { EntitySecondaryCacheLoader } from '../EntitySecondaryCacheLoader';
|
||||
import { GenericSecondaryEntityCache } from '../GenericSecondaryEntityCache';
|
||||
import { IEntityGenericCacher } from '../IEntityGenericCacher';
|
||||
@@ -100,6 +102,28 @@ class TestSecondaryCacheLoader extends EntitySecondaryCacheLoader<
|
||||
> {
|
||||
public databaseLoadCount = 0;
|
||||
|
||||
constructor(
|
||||
secondaryEntityCache: TestSecondaryEntityCache<TestFields, 'customIdField', TestLoadParams>,
|
||||
constructionUtils: EntityConstructionUtils<
|
||||
TestFields,
|
||||
'customIdField',
|
||||
ViewerContext,
|
||||
TestEntity,
|
||||
TestEntityPrivacyPolicy,
|
||||
keyof TestFields
|
||||
>,
|
||||
private readonly entityLoader: AuthorizationResultBasedEntityLoader<
|
||||
TestFields,
|
||||
'customIdField',
|
||||
ViewerContext,
|
||||
TestEntity,
|
||||
TestEntityPrivacyPolicy,
|
||||
keyof TestFields
|
||||
>,
|
||||
) {
|
||||
super(secondaryEntityCache, constructionUtils);
|
||||
}
|
||||
|
||||
protected override async fetchObjectsFromDatabaseAsync(
|
||||
loadParamsArray: readonly Readonly<TestLoadParams>[],
|
||||
): Promise<ReadonlyMap<Readonly<Readonly<TestLoadParams>>, Readonly<TestFields> | null>> {
|
||||
@@ -129,6 +153,7 @@ describe(GenericSecondaryEntityCache, () => {
|
||||
new TestGenericCacher(),
|
||||
(params) => `intValue.${params.intValue}`,
|
||||
),
|
||||
EntitySecondaryCacheLoader.getConstructionUtilsForEntityClass(TestEntity, viewerContext),
|
||||
TestEntity.loaderWithAuthorizationResults(viewerContext),
|
||||
);
|
||||
|
||||
@@ -165,6 +190,7 @@ describe(GenericSecondaryEntityCache, () => {
|
||||
new TestGenericCacher(),
|
||||
(params) => `intValue.${params.intValue}`,
|
||||
),
|
||||
EntitySecondaryCacheLoader.getConstructionUtilsForEntityClass(TestEntity, viewerContext),
|
||||
TestEntity.loaderWithAuthorizationResults(viewerContext),
|
||||
);
|
||||
|
||||
|
||||
@@ -5,7 +5,6 @@ import { AuthorizationResultBasedEntityAssociationLoader } from '../Authorizatio
|
||||
import { AuthorizationResultBasedEntityLoader } from '../AuthorizationResultBasedEntityLoader';
|
||||
import { EnforcingEntityAssociationLoader } from '../EnforcingEntityAssociationLoader';
|
||||
import { EnforcingEntityLoader } from '../EnforcingEntityLoader';
|
||||
import { EntityConstructionUtils } from '../EntityConstructionUtils';
|
||||
import { EntityInvalidationUtils } from '../EntityInvalidationUtils';
|
||||
import { ReadonlyEntity } from '../ReadonlyEntity';
|
||||
import { ViewerContext } from '../ViewerContext';
|
||||
@@ -224,14 +223,4 @@ describe(ReadonlyEntity, () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('constructionUtils', () => {
|
||||
it('creates a new EntityConstructionUtils', async () => {
|
||||
const companionProvider = createUnitTestEntityCompanionProvider();
|
||||
const viewerContext = new ViewerContext(companionProvider);
|
||||
expect(SimpleTestEntity.constructionUtils(viewerContext)).toBeInstanceOf(
|
||||
EntityConstructionUtils,
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -5,22 +5,22 @@ import { ViewerContext } from '../ViewerContext';
|
||||
import { createUnitTestEntityCompanionProvider } from '../utils/__testfixtures__/createUnitTestEntityCompanionProvider';
|
||||
|
||||
describe(ViewerContext, () => {
|
||||
describe('getQueryContextForDatabaseAdaptorFlavor', () => {
|
||||
describe('getQueryContextForDatabaseAdapterFlavor', () => {
|
||||
it('creates a new regular query context', () => {
|
||||
const companionProvider = createUnitTestEntityCompanionProvider();
|
||||
const viewerContext = new ViewerContext(companionProvider);
|
||||
const queryContext = viewerContext.getQueryContextForDatabaseAdaptorFlavor('postgres');
|
||||
const queryContext = viewerContext.getQueryContextForDatabaseAdapterFlavor('postgres');
|
||||
expect(queryContext).toBeInstanceOf(EntityQueryContext);
|
||||
expect(queryContext.isInTransaction()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('runInTransactionForDatabaseAdaptorFlavorAsync', () => {
|
||||
describe('runInTransactionForDatabaseAdapterFlavorAsync', () => {
|
||||
it('creates a new transactional query context', async () => {
|
||||
const companionProvider = createUnitTestEntityCompanionProvider();
|
||||
const viewerContext = new ViewerContext(companionProvider);
|
||||
const didCreateTransaction =
|
||||
await viewerContext.runInTransactionForDatabaseAdaptorFlavorAsync(
|
||||
await viewerContext.runInTransactionForDatabaseAdapterFlavorAsync(
|
||||
'postgres',
|
||||
async (queryContext) => {
|
||||
return queryContext.isInTransaction();
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import { describe, expect, it } from '@jest/globals';
|
||||
import { instance, mock } from 'ts-mockito';
|
||||
|
||||
import { ViewerContext } from '../../ViewerContext';
|
||||
import { SimpleTestEntity } from '../../utils/__testfixtures__/SimpleTestEntity';
|
||||
import { EntityCacheAdapterTransientError } from '../EntityCacheAdapterError';
|
||||
import { EntityErrorCode, EntityErrorState } from '../EntityError';
|
||||
import { EntityInvalidFieldValueError } from '../EntityInvalidFieldValueError';
|
||||
@@ -14,16 +17,21 @@ describe('EntityError subclasses', () => {
|
||||
});
|
||||
|
||||
it('EntityNotAuthorizedError has correct state and code', () => {
|
||||
const mockEntity = { constructor: { name: 'TestEntity' }, toString: () => 'TestEntity' } as any;
|
||||
const mockViewerContext = { toString: () => 'TestViewer' } as any;
|
||||
const error = new EntityNotAuthorizedError(mockEntity, mockViewerContext, 0, 0);
|
||||
const viewerContext = instance(mock(ViewerContext));
|
||||
const data = { id: '1' };
|
||||
const testEntity = new SimpleTestEntity({
|
||||
viewerContext,
|
||||
id: 'what',
|
||||
databaseFields: data,
|
||||
selectedFields: data,
|
||||
});
|
||||
const error = new EntityNotAuthorizedError(testEntity, viewerContext, 0, 0);
|
||||
expect(error.state).toBe(EntityErrorState.PERMANENT);
|
||||
expect(error.code).toBe(EntityErrorCode.ERR_ENTITY_NOT_AUTHORIZED);
|
||||
});
|
||||
|
||||
it('EntityInvalidFieldValueError has correct state and code', () => {
|
||||
const mockEntityClass = { name: 'TestEntity' } as any;
|
||||
const error = new EntityInvalidFieldValueError(mockEntityClass, 'testField', 'badValue');
|
||||
const error = new EntityInvalidFieldValueError(SimpleTestEntity, 'id', 'badValue');
|
||||
expect(error.state).toBe(EntityErrorState.PERMANENT);
|
||||
expect(error.code).toBe(EntityErrorCode.ERR_ENTITY_INVALID_FIELD_VALUE);
|
||||
});
|
||||
|
||||
@@ -51,7 +51,7 @@ export const transformDatabaseObjectToFields = <
|
||||
fieldTransformerMap: FieldTransformerMap,
|
||||
databaseObject: { [key: string]: any },
|
||||
): Readonly<TFields> => {
|
||||
const fields: TFields = {} as any;
|
||||
const fields: Partial<TFields> = {};
|
||||
for (const k in databaseObject) {
|
||||
const val = databaseObject[k];
|
||||
const fieldsKey = entityConfiguration.dbToEntityFieldsKeyMapping.get(k);
|
||||
@@ -64,7 +64,7 @@ export const transformDatabaseObjectToFields = <
|
||||
);
|
||||
}
|
||||
}
|
||||
return fields;
|
||||
return fields as Readonly<TFields>;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -81,7 +81,7 @@ export const transformFieldsToDatabaseObject = <
|
||||
const databaseObject: { [key: string]: any } = {};
|
||||
for (const k in fields) {
|
||||
const val = fields[k]!;
|
||||
const databaseKey = entityConfiguration.entityToDBFieldsKeyMapping.get(k as any);
|
||||
const databaseKey = entityConfiguration.entityToDBFieldsKeyMapping.get(k);
|
||||
invariant(databaseKey, `must be database key for field: ${k}`);
|
||||
databaseObject[databaseKey] = maybeTransformFieldValueToDatabaseValue(
|
||||
entityConfiguration,
|
||||
@@ -104,7 +104,7 @@ export const transformCacheObjectToFields = <
|
||||
fieldTransformerMap: FieldTransformerMap,
|
||||
cacheObject: { [key: string]: any },
|
||||
): Readonly<TFields> => {
|
||||
const fields: TFields = {} as any;
|
||||
const fields: Partial<TFields> = {};
|
||||
for (const fieldsKey in cacheObject) {
|
||||
const val = cacheObject[fieldsKey]!;
|
||||
fields[fieldsKey as keyof TFields] = maybeTransformCacheValueToFieldValue(
|
||||
@@ -114,7 +114,7 @@ export const transformCacheObjectToFields = <
|
||||
val,
|
||||
);
|
||||
}
|
||||
return fields;
|
||||
return fields as Readonly<TFields>;
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -3,6 +3,8 @@ import { ISerializable, SerializableKeyMap } from '../utils/collections/Serializ
|
||||
|
||||
/**
|
||||
* Load method type identifier of a load key. Used for keying data loaders and identification in metrics.
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
export enum EntityLoadMethodType {
|
||||
/**
|
||||
|
||||
Loaded 100 of 118 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user