mirror of
https://github.com/expo/entity.git
synced 2026-11-15 03:48:08 -10:00
Compare commits
8
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8b010df1d2 | ||
|
|
40502d6044 | ||
|
|
701c768d1a | ||
|
|
429ae055b2 | ||
|
|
2a319a4275 | ||
|
|
34057b8ed8 | ||
|
|
105989b09c | ||
|
|
89f4ba6e07 |
No files matched your search
+1
-2
@@ -27,5 +27,4 @@ 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`
|
||||
- Prefer American English spellings of words over British English. For example, prefer "Adapter" over "Adaptor".
|
||||
- Whenever technically correct, use typescript types for something instead of `any`
|
||||
+1
-7
@@ -1,8 +1,2 @@
|
||||
nodeLinker: node-modules
|
||||
|
||||
npmMinimalAgeGate: 2d
|
||||
|
||||
npmPreapprovedPackages:
|
||||
- "@expo/*"
|
||||
|
||||
yarnPath: .yarn/releases/yarn-4.12.0.cjs
|
||||
nodeLinker: node-modules
|
||||
@@ -3,50 +3,6 @@
|
||||
All notable changes to this project will be documented in this file.
|
||||
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
|
||||
|
||||
# [0.57.0](https://github.com/expo/entity/compare/v0.56.0...v0.57.0) (2026-02-28)
|
||||
|
||||
**Note:** Version bump only for package root
|
||||
|
||||
# [0.56.0](https://github.com/expo/entity/compare/v0.55.0...v0.56.0) (2026-02-28)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Add default for TSelectedFields for application-instantiated types ([#438](https://github.com/expo/entity/issues/438)) ([3d4aa0e](https://github.com/expo/entity/commit/3d4aa0e9efa519a36f3db69bd8f117a993c100fa))
|
||||
- Apply same viewer context subclass type mechanism to knexLoader and related methods ([#472](https://github.com/expo/entity/issues/472)) ([018a40b](https://github.com/expo/entity/commit/018a40bc01d579ffbf88f308e641921e6fc39097))
|
||||
- correct discriminated union type for forward/backward pagination ([#474](https://github.com/expo/entity/issues/474)) ([90ae6e2](https://github.com/expo/entity/commit/90ae6e299bda26ac9a80d83e83334d4152c022dd))
|
||||
- correct pagination behavior for standard pagination with descending ordering clauses ([#477](https://github.com/expo/entity/issues/477)) ([e32c4c2](https://github.com/expo/entity/commit/e32c4c2bcc05f225a556f95c90f1ee0f4e24de74))
|
||||
- correct precedence parentheses for SQLFragment joins ([#476](https://github.com/expo/entity/issues/476)) ([2c4c649](https://github.com/expo/entity/commit/2c4c649a4bca5bc9ffdc41eb3135b2dfe4db5e57)), closes [/github.com/expo/entity/blob/main/packages/entity-database-adapter-knex/src/internal/EntityKnexDataManager.ts#L303](https://github.com//github.com/expo/entity/blob/main/packages/entity-database-adapter-knex/src/internal/EntityKnexDataManager.ts/issues/L303)
|
||||
- **deps:** update dependency @apollo/server to v5.4.0 [security] ([#420](https://github.com/expo/entity/issues/420)) ([cd81c5a](https://github.com/expo/entity/commit/cd81c5a5b6be43ef60248eeea4031a883dd1eae8))
|
||||
- **deps:** update dependency @koa/bodyparser to v6.1.0 ([#405](https://github.com/expo/entity/issues/405)) ([2dda22d](https://github.com/expo/entity/commit/2dda22d761f87851e3fcccc8443597daf8ac42e8))
|
||||
- **deps:** update dependency @koa/router to v15.3.0 ([#406](https://github.com/expo/entity/issues/406)) ([9e88cf6](https://github.com/expo/entity/commit/9e88cf6aab76902c65994d28de8dab73f1267021))
|
||||
- **deps:** update dependency graphql to v16.13.0 ([#479](https://github.com/expo/entity/issues/479)) ([976e2c3](https://github.com/expo/entity/commit/976e2c33867da71a2920d050c4086e2af8f6d54d))
|
||||
- **deps:** update dependency koa to v3.1.2 [security] ([#484](https://github.com/expo/entity/issues/484)) ([b258dad](https://github.com/expo/entity/commit/b258dad2ed69c473f04d57d26910eab2032d142c))
|
||||
- eliminate some unnecessary any casts ([#448](https://github.com/expo/entity/issues/448)) ([cc78029](https://github.com/expo/entity/commit/cc78029117c14510b0934b5e0d19876a45570f8d))
|
||||
- explicitly document behavior when cursor entity no longer exists ([#453](https://github.com/expo/entity/issues/453)) ([4dc156c](https://github.com/expo/entity/commit/4dc156c7a8dc50bc9e2802be133b9e258b289f54)), closes [#422](https://github.com/expo/entity/issues/422) [#431](https://github.com/expo/entity/issues/431)
|
||||
- move EntityPrivacyUtils back into core package ([#429](https://github.com/expo/entity/issues/429)) ([56ec27d](https://github.com/expo/entity/commit/56ec27d5f201d83930a2cc05dfc380a054bff1e1))
|
||||
- pass in augmentable classes to installation ([#437](https://github.com/expo/entity/issues/437)) ([f5c728b](https://github.com/expo/entity/commit/f5c728be565e01c0a8ace9ef51f6de8f49184dab)), closes [/github.com/expo/entity/pull/410#pullrequestreview-3765893898](https://github.com//github.com/expo/entity/pull/410/issues/pullrequestreview-3765893898)
|
||||
- refactor install method to instead be free functions ([#441](https://github.com/expo/entity/issues/441)) ([51d632d](https://github.com/expo/entity/commit/51d632da1619dc039d5c41ce6533f63254ec4337)), closes [#410](https://github.com/expo/entity/issues/410)
|
||||
- Reorganize constructionUtils to make non-public ([#454](https://github.com/expo/entity/issues/454)) ([d9377a2](https://github.com/expo/entity/commit/d9377a290b7e0c6803f9e83537eddb7204bc9320)), closes [#410](https://github.com/expo/entity/issues/410)
|
||||
- revert change to codemod now that Entity.knexLoader is supported again via subclass ([#443](https://github.com/expo/entity/issues/443)) ([ed6b2e0](https://github.com/expo/entity/commit/ed6b2e05539c77ee2abd2c6da74e2a451967d06e)), closes [#441](https://github.com/expo/entity/issues/441)
|
||||
- Type idField in EntityConfiguration as TIDField ([#425](https://github.com/expo/entity/issues/425)) ([be40bcf](https://github.com/expo/entity/commit/be40bcffa6e7db9b7ad75e1a80bbb48ff417c6c5))
|
||||
- update integration test to use BlahEntity.knexLoader syntax ([#444](https://github.com/expo/entity/issues/444)) ([457f58a](https://github.com/expo/entity/commit/457f58ad2c4e1cf09f3132f3b9af5008ba0290c7)), closes [#441](https://github.com/expo/entity/issues/441)
|
||||
- Use TSelectedFields for knex loader order by method ([#424](https://github.com/expo/entity/issues/424)) ([a9d09f4](https://github.com/expo/entity/commit/a9d09f42b67378d06133bcfb5b99c9afb17aac0d))
|
||||
|
||||
### Features
|
||||
|
||||
- Add authorization action to EntityPrivacyPolicyRuleEvaluationContext ([#464](https://github.com/expo/entity/issues/464)) ([e71ae5f](https://github.com/expo/entity/commit/e71ae5fcbae95096e0d6a42a8c960896ab83ccfb))
|
||||
- add codemod for upcoming v0.55.0-v0.56.0 ([#413](https://github.com/expo/entity/issues/413)) ([aba0ce5](https://github.com/expo/entity/commit/aba0ce5b5f38cb7b01956cde14a4714e2a03abb4)), closes [#407](https://github.com/expo/entity/issues/407) [-#410](https://github.com/-/issues/410)
|
||||
- add entity-database-adapter-knex-testing-utils containing StubPostgresDatabaseAdapter ([#412](https://github.com/expo/entity/issues/412)) ([eeccf59](https://github.com/expo/entity/commit/eeccf594c11bf090aa83bbf71fb4ead8668aec7d))
|
||||
- add entityField SQL helper ([#481](https://github.com/expo/entity/issues/481)) ([459a5ff](https://github.com/expo/entity/commit/459a5ff85014a8c7396c0856dffacb1491ccb963))
|
||||
- Add ilike and trigram similarity search to pagination ([#431](https://github.com/expo/entity/issues/431)) ([f72cbca](https://github.com/expo/entity/commit/f72cbcaf5a722e6bb8299644ec32b07f1d3362a9)), closes [#422](https://github.com/expo/entity/issues/422)
|
||||
- add method to get pagination cursor for single entity ([#475](https://github.com/expo/entity/issues/475)) ([898f71c](https://github.com/expo/entity/commit/898f71ce082c2593585575b60bd6ef2546717124))
|
||||
- Add paginated loader to entity-database-adapter-knex ([#422](https://github.com/expo/entity/issues/422)) ([58deffd](https://github.com/expo/entity/commit/58deffd426b0214073ede608cd47c04db2596d60))
|
||||
- Add pagination max page size configuration ([#436](https://github.com/expo/entity/issues/436)) ([3ec4b5f](https://github.com/expo/entity/commit/3ec4b5f0cd53bf18d6bcfa27dd29b70d3f4d5f19))
|
||||
- add PostgresEntity/ReadonlyPostgresEntity classes that expose knexLoader methods ([#442](https://github.com/expo/entity/issues/442)) ([40b409f](https://github.com/expo/entity/commit/40b409f6f889febf8b90b8837caecac26ff64174)), closes [#441](https://github.com/expo/entity/issues/441)
|
||||
- Add raw sqlfragment orderBy capability for pagination ([#457](https://github.com/expo/entity/issues/457)) ([0615ffa](https://github.com/expo/entity/commit/0615ffacffbe9e903a9bf03b171c102f0554b992))
|
||||
- add support for nulls ordering in order by clauses ([#468](https://github.com/expo/entity/issues/468)) ([6c0838d](https://github.com/expo/entity/commit/6c0838dc577a3b6190e3551ee834925d7bd53bc6))
|
||||
- support nullable search fields with postgres transforms ([#470](https://github.com/expo/entity/issues/470)) ([53b0604](https://github.com/expo/entity/commit/53b0604152b8ca5ad9fc616d18478f93473c7eb0))
|
||||
|
||||
# [0.55.0](https://github.com/expo/entity/compare/v0.54.0...v0.55.0) (2026-01-27)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"packages": ["packages/*"],
|
||||
"npmClient": "yarn",
|
||||
"version": "0.57.0",
|
||||
"version": "0.55.0",
|
||||
"$schema": "node_modules/lerna/schemas/lerna-schema.json"
|
||||
}
|
||||
+3
-3
@@ -28,21 +28,21 @@
|
||||
"@types/babel__preset-env": "7.10.0",
|
||||
"@types/node": "24.10.13",
|
||||
"ctix": "2.7.2",
|
||||
"eslint": "9.39.3",
|
||||
"eslint": "9.39.2",
|
||||
"eslint-config-universe": "15.0.3",
|
||||
"eslint-plugin-tsdoc": "0.5.0",
|
||||
"jest": "30.2.0",
|
||||
"lerna": "9.0.4",
|
||||
"prettier": "3.8.1",
|
||||
"prettier-plugin-organize-imports": "4.3.0",
|
||||
"typedoc": "0.28.17",
|
||||
"typedoc": "0.28.16",
|
||||
"typescript": "5.9.3"
|
||||
},
|
||||
"resolutions": {
|
||||
"collect-v8-coverage": "^1.0.3"
|
||||
},
|
||||
"volta": {
|
||||
"node": "24.14.0"
|
||||
"node": "24.13.1"
|
||||
},
|
||||
"packageManager": "[email protected]"
|
||||
}
|
||||
@@ -3,14 +3,6 @@
|
||||
All notable changes to this project will be documented in this file.
|
||||
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
|
||||
|
||||
# [0.57.0](https://github.com/expo/entity/compare/v0.56.0...v0.57.0) (2026-02-28)
|
||||
|
||||
**Note:** Version bump only for package @expo/entity-cache-adapter-local-memory
|
||||
|
||||
# [0.56.0](https://github.com/expo/entity/compare/v0.55.0...v0.56.0) (2026-02-28)
|
||||
|
||||
**Note:** Version bump only for package @expo/entity-cache-adapter-local-memory
|
||||
|
||||
# [0.55.0](https://github.com/expo/entity/compare/v0.54.0...v0.55.0) (2026-01-27)
|
||||
|
||||
**Note:** Version bump only for package @expo/entity-cache-adapter-local-memory
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@expo/entity-cache-adapter-local-memory",
|
||||
"version": "0.57.0",
|
||||
"version": "0.55.0",
|
||||
"description": "Cross-request local memory cache adapter for @expo/entity",
|
||||
"files": [
|
||||
"build",
|
||||
|
||||
@@ -3,16 +3,6 @@
|
||||
All notable changes to this project will be documented in this file.
|
||||
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
|
||||
|
||||
# [0.57.0](https://github.com/expo/entity/compare/v0.56.0...v0.57.0) (2026-02-28)
|
||||
|
||||
**Note:** Version bump only for package @expo/entity-cache-adapter-redis
|
||||
|
||||
# [0.56.0](https://github.com/expo/entity/compare/v0.55.0...v0.56.0) (2026-02-28)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- eliminate some unnecessary any casts ([#448](https://github.com/expo/entity/issues/448)) ([cc78029](https://github.com/expo/entity/commit/cc78029117c14510b0934b5e0d19876a45570f8d))
|
||||
|
||||
# [0.55.0](https://github.com/expo/entity/compare/v0.54.0...v0.55.0) (2026-01-27)
|
||||
|
||||
**Note:** Version bump only for package @expo/entity-cache-adapter-redis
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@expo/entity-cache-adapter-redis",
|
||||
"version": "0.57.0",
|
||||
"version": "0.55.0",
|
||||
"description": "Redis cache adapter for @expo/entity",
|
||||
"files": [
|
||||
"build",
|
||||
|
||||
@@ -109,7 +109,7 @@ describe(GenericRedisCacher, () => {
|
||||
return pipeline;
|
||||
},
|
||||
);
|
||||
when(mockPipeline.exec()).thenResolve([]);
|
||||
when(mockPipeline.exec()).thenResolve({} as any);
|
||||
const pipeline = instance(mockPipeline);
|
||||
|
||||
const mockRedisClient = mock<Redis>();
|
||||
@@ -154,7 +154,7 @@ describe(GenericRedisCacher, () => {
|
||||
return pipeline;
|
||||
},
|
||||
);
|
||||
when(mockPipeline.exec()).thenResolve([]);
|
||||
when(mockPipeline.exec()).thenResolve({} as any);
|
||||
const pipeline = instance(mockPipeline);
|
||||
|
||||
const mockRedisClient = mock<Redis>();
|
||||
|
||||
@@ -3,21 +3,6 @@
|
||||
All notable changes to this project will be documented in this file.
|
||||
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
|
||||
|
||||
# [0.57.0](https://github.com/expo/entity/compare/v0.56.0...v0.57.0) (2026-02-28)
|
||||
|
||||
**Note:** Version bump only for package @expo/entity-codemod
|
||||
|
||||
# [0.56.0](https://github.com/expo/entity/compare/v0.55.0...v0.56.0) (2026-02-28)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- refactor install method to instead be free functions ([#441](https://github.com/expo/entity/issues/441)) ([51d632d](https://github.com/expo/entity/commit/51d632da1619dc039d5c41ce6533f63254ec4337)), closes [#410](https://github.com/expo/entity/issues/410)
|
||||
- revert change to codemod now that Entity.knexLoader is supported again via subclass ([#443](https://github.com/expo/entity/issues/443)) ([ed6b2e0](https://github.com/expo/entity/commit/ed6b2e05539c77ee2abd2c6da74e2a451967d06e)), closes [#441](https://github.com/expo/entity/issues/441)
|
||||
|
||||
### Features
|
||||
|
||||
- add codemod for upcoming v0.55.0-v0.56.0 ([#413](https://github.com/expo/entity/issues/413)) ([aba0ce5](https://github.com/expo/entity/commit/aba0ce5b5f38cb7b01956cde14a4714e2a03abb4)), closes [#407](https://github.com/expo/entity/issues/407) [-#410](https://github.com/-/issues/410)
|
||||
|
||||
# [0.55.0](https://github.com/expo/entity/compare/v0.54.0...v0.55.0) (2026-01-27)
|
||||
|
||||
**Note:** Version bump only for package @expo/entity-codemod
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@expo/entity-codemod",
|
||||
"version": "0.57.0",
|
||||
"version": "0.55.0",
|
||||
"description": "jscodeshift codemods for @expo/entity upgrades",
|
||||
"files": [
|
||||
"build",
|
||||
|
||||
@@ -12,9 +12,13 @@ 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?.value.type === 'MemberExpression' && parent.value.object === node.value) {
|
||||
if (parent && parent.value.type === 'MemberExpression' && parent.value.object === node.value) {
|
||||
const grandParent = parent.parent;
|
||||
if (grandParent?.value.type === 'CallExpression' && grandParent.value.callee === parent.value) {
|
||||
if (
|
||||
grandParent &&
|
||||
grandParent.value.type === 'CallExpression' &&
|
||||
grandParent.value.callee === parent.value
|
||||
) {
|
||||
if (
|
||||
parent.value.property.type === 'Identifier' &&
|
||||
KNEX_SPECIFIC_METHODS.includes(parent.value.property.name)
|
||||
@@ -25,7 +29,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?.value.type === 'VariableDeclarator' && parent.value.init === node.value) {
|
||||
if (parent && parent.value.type === 'VariableDeclarator' && parent.value.init === node.value) {
|
||||
const variableName = parent.value.id.name;
|
||||
const scope = parent.scope;
|
||||
|
||||
@@ -50,9 +54,9 @@ function isKnexSpecificMethodUsed(j: API['jscodeshift'], node: any): boolean {
|
||||
}
|
||||
|
||||
// Check await expressions
|
||||
if (parent?.value.type === 'AwaitExpression' && parent.value.argument === node.value) {
|
||||
if (parent && parent.value.type === 'AwaitExpression' && parent.value.argument === node.value) {
|
||||
const awaitParent = parent.parent;
|
||||
if (awaitParent?.value.type === 'VariableDeclarator') {
|
||||
if (awaitParent && awaitParent.value.type === 'VariableDeclarator') {
|
||||
const variableName = awaitParent.value.id.name;
|
||||
const scope = awaitParent.scope;
|
||||
|
||||
@@ -102,7 +106,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?.toUpperCase()) {
|
||||
if (firstChar && firstChar === firstChar.toUpperCase()) {
|
||||
// Check if this loader uses knex-specific methods
|
||||
if (isKnexSpecificMethodUsed(j, path)) {
|
||||
// Rename loader to knexLoader
|
||||
@@ -141,7 +145,7 @@ function transformLoaderWithAuthorizationResultsToKnexLoaderWithAuthorizationRes
|
||||
// Typically entity names start with uppercase letter
|
||||
if (loaderCallee.object.type === 'Identifier') {
|
||||
const firstChar = loaderCallee.object.name[0];
|
||||
if (firstChar === firstChar?.toUpperCase()) {
|
||||
if (firstChar && firstChar === firstChar.toUpperCase()) {
|
||||
// Check if this loader uses knex-specific methods
|
||||
if (isKnexSpecificMethodUsed(j, path)) {
|
||||
// Rename loaderWithAuthorizationResults to knexLoaderWithAuthorizationResults
|
||||
|
||||
@@ -1,23 +0,0 @@
|
||||
# Change Log
|
||||
|
||||
All notable changes to this project will be documented in this file.
|
||||
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
|
||||
|
||||
# [0.57.0](https://github.com/expo/entity/compare/v0.56.0...v0.57.0) (2026-02-28)
|
||||
|
||||
**Note:** Version bump only for package @expo/entity-database-adapter-knex-testing-utils
|
||||
|
||||
# [0.56.0](https://github.com/expo/entity/compare/v0.55.0...v0.56.0) (2026-02-28)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- move EntityPrivacyUtils back into core package ([#429](https://github.com/expo/entity/issues/429)) ([56ec27d](https://github.com/expo/entity/commit/56ec27d5f201d83930a2cc05dfc380a054bff1e1))
|
||||
- pass in augmentable classes to installation ([#437](https://github.com/expo/entity/issues/437)) ([f5c728b](https://github.com/expo/entity/commit/f5c728be565e01c0a8ace9ef51f6de8f49184dab)), closes [/github.com/expo/entity/pull/410#pullrequestreview-3765893898](https://github.com//github.com/expo/entity/pull/410/issues/pullrequestreview-3765893898)
|
||||
- refactor install method to instead be free functions ([#441](https://github.com/expo/entity/issues/441)) ([51d632d](https://github.com/expo/entity/commit/51d632da1619dc039d5c41ce6533f63254ec4337)), closes [#410](https://github.com/expo/entity/issues/410)
|
||||
|
||||
### Features
|
||||
|
||||
- add entity-database-adapter-knex-testing-utils containing StubPostgresDatabaseAdapter ([#412](https://github.com/expo/entity/issues/412)) ([eeccf59](https://github.com/expo/entity/commit/eeccf594c11bf090aa83bbf71fb4ead8668aec7d))
|
||||
- add entityField SQL helper ([#481](https://github.com/expo/entity/issues/481)) ([459a5ff](https://github.com/expo/entity/commit/459a5ff85014a8c7396c0856dffacb1491ccb963))
|
||||
- Add raw sqlfragment orderBy capability for pagination ([#457](https://github.com/expo/entity/issues/457)) ([0615ffa](https://github.com/expo/entity/commit/0615ffacffbe9e903a9bf03b171c102f0554b992))
|
||||
- add support for nulls ordering in order by clauses ([#468](https://github.com/expo/entity/issues/468)) ([6c0838d](https://github.com/expo/entity/commit/6c0838dc577a3b6190e3551ee834925d7bd53bc6))
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@expo/entity-database-adapter-knex-testing-utils",
|
||||
"version": "0.57.0",
|
||||
"version": "0.55.0",
|
||||
"description": "Testing utilities for applications using Entity with Knex database adapter",
|
||||
"files": [
|
||||
"build",
|
||||
|
||||
+28
-28
@@ -10,13 +10,12 @@ 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';
|
||||
@@ -105,8 +104,11 @@ export class StubPostgresDatabaseAdapter<
|
||||
return results[0] ?? null;
|
||||
}
|
||||
|
||||
private static compareByOrderBys<TFields extends Record<string, any>>(
|
||||
orderBys: TableOrderByClause<TFields>[],
|
||||
private static compareByOrderBys(
|
||||
orderBys: {
|
||||
columnName: string;
|
||||
order: OrderByOrdering;
|
||||
}[],
|
||||
objectA: { [key: string]: any },
|
||||
objectB: { [key: string]: any },
|
||||
): 0 | 1 | -1 {
|
||||
@@ -115,30 +117,19 @@ 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
|
||||
@@ -146,6 +137,15 @@ 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<TFields>,
|
||||
querySelectionModifiers: TableQuerySelectionModifiers,
|
||||
): 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<TFields>,
|
||||
_querySelectionModifiers: TableQuerySelectionModifiers,
|
||||
): 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<TFields>,
|
||||
_querySelectionModifiers: TableQuerySelectionModifiers<TFields>,
|
||||
_sqlFragment: SQLFragment,
|
||||
_querySelectionModifiers: TableQuerySelectionModifiersWithOrderByFragment,
|
||||
): Promise<object[]> {
|
||||
throw new Error('SQL fragments not supported for StubDatabaseAdapter');
|
||||
}
|
||||
|
||||
+21
-113
@@ -5,7 +5,7 @@ import {
|
||||
SingleFieldHolder,
|
||||
SingleFieldValueHolder,
|
||||
} from '@expo/entity';
|
||||
import { NullsOrdering, OrderByOrdering, sql } from '@expo/entity-database-adapter-knex';
|
||||
import { 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,15 +744,14 @@ describe(StubPostgresDatabaseAdapter, () => {
|
||||
});
|
||||
|
||||
describe('compareByOrderBys', () => {
|
||||
describe('comparison with default nulls ordering', () => {
|
||||
describe('comparison', () => {
|
||||
it.each([
|
||||
// Default nulls ordering: NULLS FIRST for DESC, NULLS LAST for ASC
|
||||
// nulls compare with 0
|
||||
[OrderByOrdering.DESCENDING, null, 0, -1],
|
||||
[OrderByOrdering.ASCENDING, null, 0, 1],
|
||||
[OrderByOrdering.DESCENDING, 0, null, 1],
|
||||
[OrderByOrdering.ASCENDING, 0, null, -1],
|
||||
// nulls compare with nulls (fall through to next order by, which is empty, so 0)
|
||||
// nulls compare with nulls
|
||||
[OrderByOrdering.DESCENDING, null, null, 0],
|
||||
[OrderByOrdering.ASCENDING, null, null, 0],
|
||||
// nulls compare with -1
|
||||
@@ -772,7 +771,6 @@ describe('compareByOrderBys', () => {
|
||||
{
|
||||
columnName: 'hello',
|
||||
order,
|
||||
nulls: undefined,
|
||||
},
|
||||
],
|
||||
{
|
||||
@@ -784,21 +782,6 @@ 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'](
|
||||
@@ -813,104 +796,29 @@ 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', () => {
|
||||
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,
|
||||
},
|
||||
],
|
||||
expect(
|
||||
StubPostgresDatabaseAdapter['compareByOrderBys'](
|
||||
[
|
||||
{
|
||||
hello: 'a',
|
||||
world: 1,
|
||||
columnName: 'hello',
|
||||
order: OrderByOrdering.ASCENDING,
|
||||
},
|
||||
{
|
||||
hello: 'a',
|
||||
world: 2,
|
||||
columnName: 'world',
|
||||
order: OrderByOrdering.ASCENDING,
|
||||
},
|
||||
),
|
||||
).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);
|
||||
});
|
||||
],
|
||||
{
|
||||
hello: 'a',
|
||||
world: 1,
|
||||
},
|
||||
{
|
||||
hello: 'a',
|
||||
world: 2,
|
||||
},
|
||||
),
|
||||
).toEqual(-1);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -3,40 +3,6 @@
|
||||
All notable changes to this project will be documented in this file.
|
||||
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
|
||||
|
||||
# [0.57.0](https://github.com/expo/entity/compare/v0.56.0...v0.57.0) (2026-02-28)
|
||||
|
||||
**Note:** Version bump only for package @expo/entity-database-adapter-knex
|
||||
|
||||
# [0.56.0](https://github.com/expo/entity/compare/v0.55.0...v0.56.0) (2026-02-28)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Add default for TSelectedFields for application-instantiated types ([#438](https://github.com/expo/entity/issues/438)) ([3d4aa0e](https://github.com/expo/entity/commit/3d4aa0e9efa519a36f3db69bd8f117a993c100fa))
|
||||
- Apply same viewer context subclass type mechanism to knexLoader and related methods ([#472](https://github.com/expo/entity/issues/472)) ([018a40b](https://github.com/expo/entity/commit/018a40bc01d579ffbf88f308e641921e6fc39097))
|
||||
- correct discriminated union type for forward/backward pagination ([#474](https://github.com/expo/entity/issues/474)) ([90ae6e2](https://github.com/expo/entity/commit/90ae6e299bda26ac9a80d83e83334d4152c022dd))
|
||||
- correct pagination behavior for standard pagination with descending ordering clauses ([#477](https://github.com/expo/entity/issues/477)) ([e32c4c2](https://github.com/expo/entity/commit/e32c4c2bcc05f225a556f95c90f1ee0f4e24de74))
|
||||
- correct precedence parentheses for SQLFragment joins ([#476](https://github.com/expo/entity/issues/476)) ([2c4c649](https://github.com/expo/entity/commit/2c4c649a4bca5bc9ffdc41eb3135b2dfe4db5e57)), closes [/github.com/expo/entity/blob/main/packages/entity-database-adapter-knex/src/internal/EntityKnexDataManager.ts#L303](https://github.com//github.com/expo/entity/blob/main/packages/entity-database-adapter-knex/src/internal/EntityKnexDataManager.ts/issues/L303)
|
||||
- eliminate some unnecessary any casts ([#448](https://github.com/expo/entity/issues/448)) ([cc78029](https://github.com/expo/entity/commit/cc78029117c14510b0934b5e0d19876a45570f8d))
|
||||
- explicitly document behavior when cursor entity no longer exists ([#453](https://github.com/expo/entity/issues/453)) ([4dc156c](https://github.com/expo/entity/commit/4dc156c7a8dc50bc9e2802be133b9e258b289f54)), closes [#422](https://github.com/expo/entity/issues/422) [#431](https://github.com/expo/entity/issues/431)
|
||||
- move EntityPrivacyUtils back into core package ([#429](https://github.com/expo/entity/issues/429)) ([56ec27d](https://github.com/expo/entity/commit/56ec27d5f201d83930a2cc05dfc380a054bff1e1))
|
||||
- pass in augmentable classes to installation ([#437](https://github.com/expo/entity/issues/437)) ([f5c728b](https://github.com/expo/entity/commit/f5c728be565e01c0a8ace9ef51f6de8f49184dab)), closes [/github.com/expo/entity/pull/410#pullrequestreview-3765893898](https://github.com//github.com/expo/entity/pull/410/issues/pullrequestreview-3765893898)
|
||||
- refactor install method to instead be free functions ([#441](https://github.com/expo/entity/issues/441)) ([51d632d](https://github.com/expo/entity/commit/51d632da1619dc039d5c41ce6533f63254ec4337)), closes [#410](https://github.com/expo/entity/issues/410)
|
||||
- update integration test to use BlahEntity.knexLoader syntax ([#444](https://github.com/expo/entity/issues/444)) ([457f58a](https://github.com/expo/entity/commit/457f58ad2c4e1cf09f3132f3b9af5008ba0290c7)), closes [#441](https://github.com/expo/entity/issues/441)
|
||||
- Use TSelectedFields for knex loader order by method ([#424](https://github.com/expo/entity/issues/424)) ([a9d09f4](https://github.com/expo/entity/commit/a9d09f42b67378d06133bcfb5b99c9afb17aac0d))
|
||||
|
||||
### Features
|
||||
|
||||
- add entity-database-adapter-knex-testing-utils containing StubPostgresDatabaseAdapter ([#412](https://github.com/expo/entity/issues/412)) ([eeccf59](https://github.com/expo/entity/commit/eeccf594c11bf090aa83bbf71fb4ead8668aec7d))
|
||||
- add entityField SQL helper ([#481](https://github.com/expo/entity/issues/481)) ([459a5ff](https://github.com/expo/entity/commit/459a5ff85014a8c7396c0856dffacb1491ccb963))
|
||||
- Add ilike and trigram similarity search to pagination ([#431](https://github.com/expo/entity/issues/431)) ([f72cbca](https://github.com/expo/entity/commit/f72cbcaf5a722e6bb8299644ec32b07f1d3362a9)), closes [#422](https://github.com/expo/entity/issues/422)
|
||||
- add method to get pagination cursor for single entity ([#475](https://github.com/expo/entity/issues/475)) ([898f71c](https://github.com/expo/entity/commit/898f71ce082c2593585575b60bd6ef2546717124))
|
||||
- Add paginated loader to entity-database-adapter-knex ([#422](https://github.com/expo/entity/issues/422)) ([58deffd](https://github.com/expo/entity/commit/58deffd426b0214073ede608cd47c04db2596d60))
|
||||
- Add pagination max page size configuration ([#436](https://github.com/expo/entity/issues/436)) ([3ec4b5f](https://github.com/expo/entity/commit/3ec4b5f0cd53bf18d6bcfa27dd29b70d3f4d5f19))
|
||||
- add PostgresEntity/ReadonlyPostgresEntity classes that expose knexLoader methods ([#442](https://github.com/expo/entity/issues/442)) ([40b409f](https://github.com/expo/entity/commit/40b409f6f889febf8b90b8837caecac26ff64174)), closes [#441](https://github.com/expo/entity/issues/441)
|
||||
- Add raw sqlfragment orderBy capability for pagination ([#457](https://github.com/expo/entity/issues/457)) ([0615ffa](https://github.com/expo/entity/commit/0615ffacffbe9e903a9bf03b171c102f0554b992))
|
||||
- add support for nulls ordering in order by clauses ([#468](https://github.com/expo/entity/issues/468)) ([6c0838d](https://github.com/expo/entity/commit/6c0838dc577a3b6190e3551ee834925d7bd53bc6))
|
||||
- support nullable search fields with postgres transforms ([#470](https://github.com/expo/entity/issues/470)) ([53b0604](https://github.com/expo/entity/commit/53b0604152b8ca5ad9fc616d18478f93473c7eb0))
|
||||
|
||||
# [0.55.0](https://github.com/expo/entity/compare/v0.54.0...v0.55.0) (2026-01-27)
|
||||
|
||||
**Note:** Version bump only for package @expo/entity-database-adapter-knex
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@expo/entity-database-adapter-knex",
|
||||
"version": "0.57.0",
|
||||
"version": "0.55.0",
|
||||
"description": "Knex database adapter for @expo/entity",
|
||||
"files": [
|
||||
"build",
|
||||
|
||||
+35
-135
@@ -11,7 +11,6 @@ import { Result } from '@expo/results';
|
||||
import {
|
||||
FieldEqualityCondition,
|
||||
isSingleValueFieldEqualityCondition,
|
||||
NullsOrdering,
|
||||
OrderByOrdering,
|
||||
} from './BasePostgresEntityDatabaseAdapter';
|
||||
import { BaseSQLQueryBuilder } from './BaseSQLQueryBuilder';
|
||||
@@ -19,47 +18,21 @@ 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 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<
|
||||
export interface EntityLoaderOrderByClause<
|
||||
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.
|
||||
* The OrderByOrdering to order by.
|
||||
*/
|
||||
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>;
|
||||
order: OrderByOrdering;
|
||||
}
|
||||
|
||||
/**
|
||||
* SQL modifiers that only affect the selection but not the projection.
|
||||
@@ -84,70 +57,25 @@ export interface EntityLoaderQuerySelectionModifiers<
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Function to be used for constructing search fields based on field names.
|
||||
* The function takes a field name and returns a SQLFragment that can be used in the search field portion.
|
||||
*/
|
||||
export type EntityLoaderFieldNameConstructorFn<
|
||||
export interface EntityLoaderQuerySelectionModifiersWithOrderByRaw<
|
||||
TFields extends Record<string, any>,
|
||||
TSelectedFields extends keyof TFields = keyof TFields,
|
||||
> = (fieldName: TSelectedFields) => SQLFragment<TFields>;
|
||||
|
||||
/**
|
||||
* Specification for a search field that is a manually constructed SQLFragment. Useful for complex search fields that require
|
||||
* transformations or combinations of multiple fields, such as `COALESCE(NULLIF(display_name, ''), split_part(full_name, '/', 2))`
|
||||
* to search by display name with fallback to full name.
|
||||
*/
|
||||
export type EntityLoaderSearchFieldSQLFragmentFnSpecification<
|
||||
TFields extends Record<string, any>,
|
||||
TSelectedFields extends keyof TFields = keyof TFields,
|
||||
> = {
|
||||
> extends EntityLoaderQuerySelectionModifiers<TFields, TSelectedFields> {
|
||||
/**
|
||||
* 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))`;
|
||||
* }
|
||||
* ```
|
||||
* Order the entities by a raw SQL `ORDER BY` clause.
|
||||
*/
|
||||
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>>;
|
||||
};
|
||||
orderByRaw?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<
|
||||
export interface EntityLoaderQuerySelectionModifiersWithOrderByFragment<
|
||||
TFields extends Record<string, any>,
|
||||
TSelectedFields extends keyof TFields = keyof TFields,
|
||||
> =
|
||||
| NonNullableSelectedFields<TFields, TSelectedFields>
|
||||
| EntityLoaderSearchFieldSQLFragmentFnSpecification<TFields, TSelectedFields>;
|
||||
> extends EntityLoaderQuerySelectionModifiers<TFields, TSelectedFields> {
|
||||
/**
|
||||
* Order the entities by a SQL fragment `ORDER BY` clause.
|
||||
*/
|
||||
orderByFragment?: SQLFragment;
|
||||
}
|
||||
|
||||
interface SearchSpecificationBase<
|
||||
TFields extends Record<string, any>,
|
||||
@@ -159,9 +87,9 @@ interface SearchSpecificationBase<
|
||||
term: string;
|
||||
|
||||
/**
|
||||
* The fields to search within.
|
||||
* The fields to search within. Must be a non-empty array.
|
||||
*/
|
||||
fields: readonly EntityLoaderSearchFieldSpecification<TFields, TSelectedFields>[];
|
||||
fields: TSelectedFields[];
|
||||
}
|
||||
|
||||
interface ILikeSearchSpecification<
|
||||
@@ -204,7 +132,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?: readonly EntityLoaderSearchFieldSpecification<TFields, TSelectedFields>[];
|
||||
extraOrderByFields?: TSelectedFields[];
|
||||
}
|
||||
|
||||
interface StandardPaginationSpecification<
|
||||
@@ -219,7 +147,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: readonly EntityLoaderOrderByClause<TFields, TSelectedFields>[];
|
||||
orderBy: EntityLoaderOrderByClause<TFields, TSelectedFields>[];
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -243,7 +171,7 @@ interface EntityLoaderBaseUnifiedPaginationArgs<
|
||||
/**
|
||||
* SQLFragment representing the WHERE clause to filter the entities being paginated.
|
||||
*/
|
||||
where?: SQLFragment<Pick<TFields, TSelectedFields>>;
|
||||
where?: SQLFragment;
|
||||
|
||||
/**
|
||||
* Pagination specification determining how to order and paginate results.
|
||||
@@ -263,22 +191,10 @@ 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;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -288,23 +204,11 @@ 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.
|
||||
*/
|
||||
@@ -361,7 +265,7 @@ export class AuthorizationResultBasedKnexEntityLoader<
|
||||
* UnauthorizedError
|
||||
*/
|
||||
async loadFirstByFieldEqualityConjunctionAsync<N extends keyof Pick<TFields, TSelectedFields>>(
|
||||
fieldEqualityOperands: readonly FieldEqualityCondition<TFields, N>[],
|
||||
fieldEqualityOperands: FieldEqualityCondition<TFields, N>[],
|
||||
querySelectionModifiers: Omit<
|
||||
EntityLoaderQuerySelectionModifiers<TFields, TSelectedFields>,
|
||||
'limit'
|
||||
@@ -380,7 +284,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: readonly FieldEqualityCondition<TFields, N>[],
|
||||
fieldEqualityOperands: FieldEqualityCondition<TFields, N>[],
|
||||
querySelectionModifiers: EntityLoaderQuerySelectionModifiers<TFields, TSelectedFields> = {},
|
||||
): Promise<readonly Result<TEntity>[]> {
|
||||
for (const fieldEqualityOperand of fieldEqualityOperands) {
|
||||
@@ -402,13 +306,14 @@ 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: EntityLoaderQuerySelectionModifiers<TFields, TSelectedFields> = {},
|
||||
querySelectionModifiers: EntityLoaderQuerySelectionModifiersWithOrderByRaw<
|
||||
TFields,
|
||||
TSelectedFields
|
||||
> = {},
|
||||
): Promise<readonly Result<TEntity>[]> {
|
||||
const fieldObjects = await this.knexDataManager.loadManyByRawWhereClauseAsync(
|
||||
this.queryContext,
|
||||
@@ -424,8 +329,11 @@ 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<Pick<TFields, TSelectedFields>>,
|
||||
modifiers: EntityLoaderQuerySelectionModifiers<TFields, TSelectedFields> = {},
|
||||
fragment: SQLFragment,
|
||||
modifiers: EntityLoaderQuerySelectionModifiersWithOrderByFragment<
|
||||
TFields,
|
||||
TSelectedFields
|
||||
> = {},
|
||||
): AuthorizationResultBasedSQLQueryBuilder<
|
||||
TFields,
|
||||
TIDField,
|
||||
@@ -480,14 +388,6 @@ 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());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -518,8 +418,8 @@ export class AuthorizationResultBasedSQLQueryBuilder<
|
||||
TSelectedFields
|
||||
>,
|
||||
private readonly queryContext: EntityQueryContext,
|
||||
sqlFragment: SQLFragment<Pick<TFields, TSelectedFields>>,
|
||||
modifiers: EntityLoaderQuerySelectionModifiers<TFields, TSelectedFields>,
|
||||
sqlFragment: SQLFragment,
|
||||
modifiers: EntityLoaderQuerySelectionModifiersWithOrderByFragment<TFields, TSelectedFields>,
|
||||
) {
|
||||
super(sqlFragment, modifiers);
|
||||
}
|
||||
|
||||
@@ -58,11 +58,6 @@ export interface TableFieldMultiValueEqualityCondition {
|
||||
tableValues: readonly any[];
|
||||
}
|
||||
|
||||
export enum NullsOrdering {
|
||||
FIRST = 'first',
|
||||
LAST = 'last',
|
||||
}
|
||||
|
||||
/**
|
||||
* Ordering options for `orderBy` clauses.
|
||||
*/
|
||||
@@ -80,41 +75,17 @@ export enum OrderByOrdering {
|
||||
DESCENDING = 'desc',
|
||||
}
|
||||
|
||||
export type PostgresOrderByClause<TFields extends Record<string, any>> =
|
||||
| {
|
||||
/**
|
||||
* The field name to order by.
|
||||
*/
|
||||
fieldName: keyof TFields;
|
||||
export interface PostgresOrderByClause<TFields extends Record<string, any>> {
|
||||
/**
|
||||
* The field name to order by.
|
||||
*/
|
||||
fieldName: keyof TFields;
|
||||
|
||||
/**
|
||||
* The OrderByOrdering to order by.
|
||||
*/
|
||||
order: OrderByOrdering;
|
||||
|
||||
/**
|
||||
* Optional nulls ordering. If not provided, the database default is used
|
||||
* (NULLS LAST for ASC, NULLS FIRST for DESC in PostgreSQL).
|
||||
*/
|
||||
nulls?: NullsOrdering | undefined;
|
||||
}
|
||||
| {
|
||||
/**
|
||||
* A raw SQL fragment to order by. May not contain ASC or DESC, as ordering direction is determined by the `order` property.
|
||||
*/
|
||||
fieldFragment: SQLFragment<TFields>;
|
||||
|
||||
/**
|
||||
* The OrderByOrdering to order by.
|
||||
*/
|
||||
order: OrderByOrdering;
|
||||
|
||||
/**
|
||||
* Optional nulls ordering. If not provided, the database default is used
|
||||
* (NULLS LAST for ASC, NULLS FIRST for DESC in PostgreSQL).
|
||||
*/
|
||||
nulls?: NullsOrdering | undefined;
|
||||
};
|
||||
/**
|
||||
* The OrderByOrdering to order by.
|
||||
*/
|
||||
order: OrderByOrdering;
|
||||
}
|
||||
|
||||
/**
|
||||
* SQL modifiers that only affect the selection but not the projection.
|
||||
@@ -136,24 +107,44 @@ export interface PostgresQuerySelectionModifiers<TFields extends Record<string,
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
export type TableOrderByClause<TFields extends Record<string, any>> =
|
||||
| {
|
||||
columnName: string;
|
||||
order: OrderByOrdering;
|
||||
nulls: NullsOrdering | undefined;
|
||||
}
|
||||
| {
|
||||
columnFragment: SQLFragment<TFields>;
|
||||
order: OrderByOrdering;
|
||||
nulls: NullsOrdering | undefined;
|
||||
};
|
||||
export interface PostgresQuerySelectionModifiersWithOrderByRaw<
|
||||
TFields extends Record<string, any>,
|
||||
> extends PostgresQuerySelectionModifiers<TFields> {
|
||||
/**
|
||||
* Order the entities by a raw SQL `ORDER BY` clause.
|
||||
*/
|
||||
orderByRaw?: string;
|
||||
}
|
||||
|
||||
export interface TableQuerySelectionModifiers<TFields extends Record<string, any>> {
|
||||
orderBy: TableOrderByClause<TFields>[] | 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;
|
||||
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,
|
||||
@@ -176,7 +167,7 @@ export abstract class BasePostgresEntityDatabaseAdapter<
|
||||
*/
|
||||
async fetchManyByFieldEqualityConjunctionAsync<N extends keyof TFields>(
|
||||
queryContext: EntityQueryContext,
|
||||
fieldEqualityOperands: readonly FieldEqualityCondition<TFields, N>[],
|
||||
fieldEqualityOperands: FieldEqualityCondition<TFields, N>[],
|
||||
querySelectionModifiers: PostgresQuerySelectionModifiers<TFields>,
|
||||
): Promise<readonly Readonly<TFields>[]> {
|
||||
const tableFieldSingleValueOperands: TableFieldSingleValueEqualityCondition[] = [];
|
||||
@@ -213,7 +204,7 @@ export abstract class BasePostgresEntityDatabaseAdapter<
|
||||
tableName: string,
|
||||
tableFieldSingleValueEqualityOperands: TableFieldSingleValueEqualityCondition[],
|
||||
tableFieldMultiValueEqualityOperands: TableFieldMultiValueEqualityCondition[],
|
||||
querySelectionModifiers: TableQuerySelectionModifiers<TFields>,
|
||||
querySelectionModifiers: TableQuerySelectionModifiers,
|
||||
): Promise<object[]>;
|
||||
|
||||
/**
|
||||
@@ -229,14 +220,14 @@ export abstract class BasePostgresEntityDatabaseAdapter<
|
||||
queryContext: EntityQueryContext,
|
||||
rawWhereClause: string,
|
||||
bindings: any[] | object,
|
||||
querySelectionModifiers: PostgresQuerySelectionModifiers<TFields>,
|
||||
querySelectionModifiers: PostgresQuerySelectionModifiersWithOrderByRaw<TFields>,
|
||||
): Promise<readonly Readonly<TFields>[]> {
|
||||
const results = await this.fetchManyByRawWhereClauseInternalAsync(
|
||||
queryContext.getQueryInterface(),
|
||||
this.entityConfiguration.tableName,
|
||||
rawWhereClause,
|
||||
bindings,
|
||||
this.convertToTableQueryModifiers(querySelectionModifiers),
|
||||
this.convertToTableQueryModifiersWithOrderByRaw(querySelectionModifiers),
|
||||
);
|
||||
|
||||
return results.map((result) =>
|
||||
@@ -249,7 +240,7 @@ export abstract class BasePostgresEntityDatabaseAdapter<
|
||||
tableName: string,
|
||||
rawWhereClause: string,
|
||||
bindings: object | any[],
|
||||
querySelectionModifiers: TableQuerySelectionModifiers<TFields>,
|
||||
querySelectionModifiers: TableQuerySelectionModifiersWithOrderByRaw,
|
||||
): Promise<object[]>;
|
||||
|
||||
/**
|
||||
@@ -262,14 +253,14 @@ export abstract class BasePostgresEntityDatabaseAdapter<
|
||||
*/
|
||||
async fetchManyBySQLFragmentAsync(
|
||||
queryContext: EntityQueryContext,
|
||||
sqlFragment: SQLFragment<TFields>,
|
||||
querySelectionModifiers: PostgresQuerySelectionModifiers<TFields>,
|
||||
sqlFragment: SQLFragment,
|
||||
querySelectionModifiers: PostgresQuerySelectionModifiersWithOrderByFragment<TFields>,
|
||||
): Promise<readonly Readonly<TFields>[]> {
|
||||
const results = await this.fetchManyBySQLFragmentInternalAsync(
|
||||
queryContext.getQueryInterface(),
|
||||
this.entityConfiguration.tableName,
|
||||
sqlFragment,
|
||||
this.convertToTableQueryModifiers(querySelectionModifiers),
|
||||
this.convertToTableQueryModifiersWithOrderByFragment(querySelectionModifiers),
|
||||
);
|
||||
|
||||
return results.map((result) =>
|
||||
@@ -280,35 +271,42 @@ export abstract class BasePostgresEntityDatabaseAdapter<
|
||||
protected abstract fetchManyBySQLFragmentInternalAsync(
|
||||
queryInterface: Knex,
|
||||
tableName: string,
|
||||
sqlFragment: SQLFragment<TFields>,
|
||||
querySelectionModifiers: TableQuerySelectionModifiers<TFields>,
|
||||
sqlFragment: SQLFragment,
|
||||
querySelectionModifiers: TableQuerySelectionModifiersWithOrderByFragment,
|
||||
): 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<TFields> {
|
||||
): TableQuerySelectionModifiers {
|
||||
const orderBy = querySelectionModifiers.orderBy;
|
||||
return {
|
||||
orderBy:
|
||||
orderBy !== undefined
|
||||
? orderBy.map((orderBySpecification): TableOrderByClause<TFields> => {
|
||||
if ('fieldName' in orderBySpecification) {
|
||||
return {
|
||||
columnName: getDatabaseFieldForEntityField(
|
||||
this.entityConfiguration,
|
||||
orderBySpecification.fieldName,
|
||||
),
|
||||
order: orderBySpecification.order,
|
||||
nulls: orderBySpecification.nulls,
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
columnFragment: orderBySpecification.fieldFragment,
|
||||
order: orderBySpecification.order,
|
||||
nulls: orderBySpecification.nulls,
|
||||
};
|
||||
}
|
||||
})
|
||||
? orderBy.map((orderBySpecification) => ({
|
||||
columnName: getDatabaseFieldForEntityField(
|
||||
this.entityConfiguration,
|
||||
orderBySpecification.fieldName,
|
||||
),
|
||||
order: orderBySpecification.order,
|
||||
}))
|
||||
: undefined,
|
||||
offset: querySelectionModifiers.offset,
|
||||
limit: querySelectionModifiers.limit,
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import {
|
||||
EntityLoaderOrderByClause,
|
||||
EntityLoaderQuerySelectionModifiers,
|
||||
EntityLoaderQuerySelectionModifiersWithOrderByFragment,
|
||||
} from './AuthorizationResultBasedKnexEntityLoader';
|
||||
import { NullsOrdering, OrderByOrdering } from './BasePostgresEntityDatabaseAdapter';
|
||||
import { OrderByOrdering } from './BasePostgresEntityDatabaseAdapter';
|
||||
import { SQLFragment } from './SQLOperator';
|
||||
|
||||
/**
|
||||
@@ -16,11 +16,12 @@ export abstract class BaseSQLQueryBuilder<
|
||||
private executed = false;
|
||||
|
||||
constructor(
|
||||
private readonly sqlFragment: SQLFragment<Pick<TFields, TSelectedFields>>,
|
||||
private readonly sqlFragment: SQLFragment,
|
||||
private readonly modifiers: {
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
orderBy?: readonly EntityLoaderOrderByClause<TFields, TSelectedFields>[];
|
||||
orderByFragment?: SQLFragment;
|
||||
},
|
||||
) {}
|
||||
|
||||
@@ -43,12 +44,8 @@ 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,
|
||||
nulls: NullsOrdering | undefined = undefined,
|
||||
): this {
|
||||
this.modifiers.orderBy = [...(this.modifiers.orderBy ?? []), { fieldName, order, nulls }];
|
||||
orderBy(fieldName: TSelectedFields, order: OrderByOrdering = OrderByOrdering.ASCENDING): this {
|
||||
this.modifiers.orderBy = [...(this.modifiers.orderBy ?? []), { fieldName, order }];
|
||||
return this;
|
||||
}
|
||||
|
||||
@@ -58,41 +55,38 @@ export abstract class BaseSQLQueryBuilder<
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* query.orderByFragment(
|
||||
* sql`(data->>'createdAt')::timestamp`,
|
||||
* OrderByOrdering.DESCENDING,
|
||||
* );
|
||||
* // Generates ORDER BY clause that orders by the createdAt field in the JSONB data column, cast to a timestamp, in descending order.
|
||||
* // Note that the SQL fragment is parameterized, so it is safe from SQL injection.
|
||||
* // The generated SQL would look like: ORDER BY (data->>'createdAt')::timestamp DESC
|
||||
* ```
|
||||
* import { sql, raw } from '@expo/entity-database-adapter-knex';
|
||||
*
|
||||
* @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.
|
||||
* // 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`)
|
||||
* ```
|
||||
*/
|
||||
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 },
|
||||
];
|
||||
orderBySQL(fragment: SQLFragment): this {
|
||||
this.modifiers.orderByFragment = fragment;
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current modifiers as QuerySelectionModifiersWithOrderByFragment<TFields>
|
||||
*/
|
||||
protected getModifiers(): EntityLoaderQuerySelectionModifiers<TFields, TSelectedFields> {
|
||||
protected getModifiers(): EntityLoaderQuerySelectionModifiersWithOrderByFragment<
|
||||
TFields,
|
||||
TSelectedFields
|
||||
> {
|
||||
return this.modifiers;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the SQL fragment
|
||||
*/
|
||||
protected getSQLFragment(): SQLFragment<Pick<TFields, TSelectedFields>> {
|
||||
protected getSQLFragment(): SQLFragment {
|
||||
return this.sqlFragment;
|
||||
}
|
||||
|
||||
|
||||
@@ -11,6 +11,8 @@ import {
|
||||
AuthorizationResultBasedKnexEntityLoader,
|
||||
EntityLoaderLoadPageArgs,
|
||||
EntityLoaderQuerySelectionModifiers,
|
||||
EntityLoaderQuerySelectionModifiersWithOrderByFragment,
|
||||
EntityLoaderQuerySelectionModifiersWithOrderByRaw,
|
||||
} from './AuthorizationResultBasedKnexEntityLoader';
|
||||
import { FieldEqualityCondition } from './BasePostgresEntityDatabaseAdapter';
|
||||
import { BaseSQLQueryBuilder } from './BaseSQLQueryBuilder';
|
||||
@@ -128,17 +130,19 @@ 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.
|
||||
* @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
|
||||
* @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: EntityLoaderQuerySelectionModifiers<TFields, TSelectedFields> = {},
|
||||
querySelectionModifiers: EntityLoaderQuerySelectionModifiersWithOrderByRaw<
|
||||
TFields,
|
||||
TSelectedFields
|
||||
> = {},
|
||||
): Promise<readonly TEntity[]> {
|
||||
const entityResults = await this.knexEntityLoader.loadManyByRawWhereClauseAsync(
|
||||
rawWhereClause,
|
||||
@@ -168,8 +172,11 @@ export class EnforcingKnexEntityLoader<
|
||||
* ```
|
||||
*/
|
||||
loadManyBySQL(
|
||||
fragment: SQLFragment<Pick<TFields, TSelectedFields>>,
|
||||
modifiers: EntityLoaderQuerySelectionModifiers<TFields, TSelectedFields> = {},
|
||||
fragment: SQLFragment,
|
||||
modifiers: EntityLoaderQuerySelectionModifiersWithOrderByFragment<
|
||||
TFields,
|
||||
TSelectedFields
|
||||
> = {},
|
||||
): EnforcingSQLQueryBuilder<
|
||||
TFields,
|
||||
TIDField,
|
||||
@@ -210,18 +217,6 @@ 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);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -251,8 +246,8 @@ export class EnforcingSQLQueryBuilder<
|
||||
TPrivacyPolicy,
|
||||
TSelectedFields
|
||||
>,
|
||||
sqlFragment: SQLFragment<Pick<TFields, TSelectedFields>>,
|
||||
modifiers: EntityLoaderQuerySelectionModifiers<TFields, TSelectedFields>,
|
||||
sqlFragment: SQLFragment,
|
||||
modifiers: EntityLoaderQuerySelectionModifiersWithOrderByFragment<TFields, TSelectedFields>,
|
||||
) {
|
||||
super(sqlFragment, modifiers);
|
||||
}
|
||||
|
||||
@@ -35,7 +35,6 @@ 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,
|
||||
@@ -54,7 +53,7 @@ export abstract class PostgresEntity<
|
||||
TMPrivacyPolicy,
|
||||
TMSelectedFields
|
||||
>,
|
||||
viewerContext: TMViewerContext2,
|
||||
viewerContext: TMViewerContext,
|
||||
queryContext: EntityQueryContext = viewerContext
|
||||
.getViewerScopedEntityCompanionForClass(this)
|
||||
.getQueryContextProvider()
|
||||
@@ -81,7 +80,6 @@ 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,
|
||||
@@ -100,7 +98,7 @@ export abstract class PostgresEntity<
|
||||
TMPrivacyPolicy,
|
||||
TMSelectedFields
|
||||
>,
|
||||
viewerContext: TMViewerContext2,
|
||||
viewerContext: TMViewerContext,
|
||||
queryContext: EntityQueryContext = viewerContext
|
||||
.getViewerScopedEntityCompanionForClass(this)
|
||||
.getQueryContextProvider()
|
||||
|
||||
@@ -1,17 +1,13 @@
|
||||
import {
|
||||
EntityConfiguration,
|
||||
FieldTransformer,
|
||||
FieldTransformerMap,
|
||||
getDatabaseFieldForEntityField,
|
||||
} from '@expo/entity';
|
||||
import { EntityConfiguration, FieldTransformer, FieldTransformerMap } from '@expo/entity';
|
||||
import { Knex } from 'knex';
|
||||
|
||||
import {
|
||||
BasePostgresEntityDatabaseAdapter,
|
||||
NullsOrdering,
|
||||
TableFieldMultiValueEqualityCondition,
|
||||
TableFieldSingleValueEqualityCondition,
|
||||
TableQuerySelectionModifiers,
|
||||
TableQuerySelectionModifiersWithOrderByFragment,
|
||||
TableQuerySelectionModifiersWithOrderByRaw,
|
||||
} from './BasePostgresEntityDatabaseAdapter';
|
||||
import { JSONArrayField, MaybeJSONArrayField } from './EntityFields';
|
||||
import { PostgresEntityDatabaseAdapterConfiguration } from './PostgresEntityDatabaseAdapterProvider';
|
||||
@@ -122,9 +118,28 @@ 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<TFields>,
|
||||
querySelectionModifiers: TableQuerySelectionModifiers,
|
||||
): Knex.QueryBuilder {
|
||||
const { orderBy, offset, limit } = querySelectionModifiers;
|
||||
|
||||
@@ -132,23 +147,7 @@ export class PostgresEntityDatabaseAdapter<
|
||||
|
||||
if (orderBy !== undefined) {
|
||||
for (const orderBySpecification of orderBy) {
|
||||
if ('columnName' in orderBySpecification) {
|
||||
ret = ret.orderBy(
|
||||
orderBySpecification.columnName,
|
||||
orderBySpecification.order,
|
||||
orderBySpecification.nulls,
|
||||
);
|
||||
} else {
|
||||
const nullsSuffix = orderBySpecification.nulls
|
||||
? ` NULLS ${orderBySpecification.nulls === NullsOrdering.FIRST ? 'FIRST' : 'LAST'}`
|
||||
: '';
|
||||
ret = ret.orderByRaw(
|
||||
`(${orderBySpecification.columnFragment.sql}) ${orderBySpecification.order}${nullsSuffix}`,
|
||||
orderBySpecification.columnFragment.getKnexBindings((fieldName) =>
|
||||
getDatabaseFieldForEntityField(this.entityConfiguration, fieldName),
|
||||
),
|
||||
);
|
||||
}
|
||||
ret = ret.orderBy(orderBySpecification.columnName, orderBySpecification.order);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -168,7 +167,7 @@ export class PostgresEntityDatabaseAdapter<
|
||||
tableName: string,
|
||||
tableFieldSingleValueEqualityOperands: TableFieldSingleValueEqualityCondition[],
|
||||
tableFieldMultiValueEqualityOperands: TableFieldMultiValueEqualityCondition[],
|
||||
querySelectionModifiers: TableQuerySelectionModifiers<TFields>,
|
||||
querySelectionModifiers: TableQuerySelectionModifiers,
|
||||
): Promise<object[]> {
|
||||
let query = queryInterface.select().from(tableName);
|
||||
|
||||
@@ -214,29 +213,40 @@ export class PostgresEntityDatabaseAdapter<
|
||||
tableName: string,
|
||||
rawWhereClause: string,
|
||||
bindings: object | any[],
|
||||
querySelectionModifiers: TableQuerySelectionModifiers<TFields>,
|
||||
querySelectionModifiers: TableQuerySelectionModifiersWithOrderByRaw,
|
||||
): Promise<object[]> {
|
||||
let query = queryInterface.select().from(tableName).whereRaw(rawWhereClause, bindings);
|
||||
query = this.applyQueryModifiersToQuery(query, querySelectionModifiers);
|
||||
let query = queryInterface
|
||||
.select()
|
||||
.from(tableName)
|
||||
.whereRaw(rawWhereClause, bindings as any);
|
||||
query = this.applyQueryModifiersToQueryOrderByRaw(query, querySelectionModifiers);
|
||||
return await wrapNativePostgresCallAsync(() => query);
|
||||
}
|
||||
|
||||
protected async fetchManyBySQLFragmentInternalAsync(
|
||||
queryInterface: Knex,
|
||||
tableName: string,
|
||||
sqlFragment: SQLFragment<TFields>,
|
||||
querySelectionModifiers: TableQuerySelectionModifiers<TFields>,
|
||||
sqlFragment: SQLFragment,
|
||||
querySelectionModifiers: TableQuerySelectionModifiersWithOrderByFragment,
|
||||
): Promise<object[]> {
|
||||
let query = queryInterface
|
||||
.select()
|
||||
.from(tableName)
|
||||
.whereRaw(
|
||||
sqlFragment.sql,
|
||||
sqlFragment.getKnexBindings((fieldName) =>
|
||||
getDatabaseFieldForEntityField(this.entityConfiguration, fieldName),
|
||||
),
|
||||
);
|
||||
query = this.applyQueryModifiersToQuery(query, querySelectionModifiers);
|
||||
.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);
|
||||
}
|
||||
return await wrapNativePostgresCallAsync(() => query);
|
||||
}
|
||||
|
||||
|
||||
@@ -17,37 +17,29 @@ export type SupportedSQLValue =
|
||||
/**
|
||||
* Types of bindings that can be used in SQL queries.
|
||||
*/
|
||||
export type SQLBinding<TFields extends Record<string, any>> =
|
||||
export type SQLBinding =
|
||||
| { type: 'value'; value: SupportedSQLValue }
|
||||
| { type: 'identifier'; name: string }
|
||||
| { type: 'entityField'; fieldName: keyof TFields };
|
||||
| { type: 'identifier'; name: string };
|
||||
|
||||
/**
|
||||
* SQL Fragment class that safely handles parameterized queries.
|
||||
*/
|
||||
export class SQLFragment<TFields extends Record<string, any>> {
|
||||
export class SQLFragment {
|
||||
constructor(
|
||||
public readonly sql: string,
|
||||
public readonly bindings: readonly SQLBinding<TFields>[],
|
||||
public readonly bindings: readonly SQLBinding[],
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 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(
|
||||
getColumnForField: (fieldName: keyof TFields) => string,
|
||||
): readonly (string | SupportedSQLValue)[] {
|
||||
getKnexBindings(): readonly (string | SupportedSQLValue)[] {
|
||||
return this.bindings.map((b) => {
|
||||
switch (b.type) {
|
||||
case 'entityField':
|
||||
return getColumnForField(b.fieldName);
|
||||
case 'identifier':
|
||||
return b.name;
|
||||
case 'value':
|
||||
return b.value;
|
||||
if (b.type === 'identifier') {
|
||||
return b.name;
|
||||
} else {
|
||||
return b.value;
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -55,21 +47,20 @@ export class SQLFragment<TFields extends Record<string, any>> {
|
||||
/**
|
||||
* Combine SQL fragments
|
||||
*/
|
||||
append(other: SQLFragment<TFields>): SQLFragment<TFields> {
|
||||
return joinSQLFragments([this, other], ' ');
|
||||
append(other: SQLFragment): SQLFragment {
|
||||
return SQLFragment.join([this, other], ' ');
|
||||
}
|
||||
|
||||
/**
|
||||
* Join multiple SQL fragments with a comma separator.
|
||||
* Useful for combining column lists, value lists, etc.
|
||||
*
|
||||
* Join multiple SQL fragments into a single fragment with a separator.
|
||||
* @param fragments - Array of SQL fragments to join
|
||||
* @returns - A new SQLFragment with the fragments joined by a comma and space
|
||||
* @param separator - Separator string (default: ', ')
|
||||
*/
|
||||
static joinWithCommaSeparator<TFields extends Record<string, any>>(
|
||||
...fragments: readonly SQLFragment<TFields>[]
|
||||
): SQLFragment<TFields> {
|
||||
return joinSQLFragments(fragments, ', ');
|
||||
static join(fragments: SQLFragment[], separator = ', '): SQLFragment {
|
||||
return new SQLFragment(
|
||||
fragments.map((f) => f.sql).join(separator),
|
||||
fragments.flatMap((f) => f.bindings),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -84,17 +75,15 @@ export class SQLFragment<TFields extends Record<string, any>> {
|
||||
* // Generates: "SELECT * FROM users WHERE age > ? ORDER BY name"
|
||||
* ```
|
||||
*/
|
||||
static concat<TFields extends Record<string, any>>(
|
||||
...fragments: readonly SQLFragment<TFields>[]
|
||||
): SQLFragment<TFields> {
|
||||
return joinSQLFragments(fragments, ' ');
|
||||
static concat(...fragments: SQLFragment[]): SQLFragment {
|
||||
return SQLFragment.join(fragments, ' ');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a debug representation of the query with values inline
|
||||
* WARNING: This is for debugging only. Never execute the returned string directly.
|
||||
*/
|
||||
getDebugString(): string {
|
||||
get toDebugString(): string {
|
||||
let debugString = this.sql;
|
||||
let bindingIndex = 0;
|
||||
|
||||
@@ -112,9 +101,6 @@ export class SQLFragment<TFields extends Record<string, any>> {
|
||||
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 {
|
||||
@@ -191,24 +177,17 @@ 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 SQLUnsafeRaw {
|
||||
export class SQLRaw {
|
||||
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
|
||||
@@ -221,18 +200,6 @@ 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.
|
||||
@@ -241,14 +208,14 @@ export function entityField<TFields extends Record<string, any>>(
|
||||
* ```ts
|
||||
* // Dynamic column names
|
||||
* const sortColumn = 'created_at';
|
||||
* const query = sql`ORDER BY ${unsafeRaw(sortColumn)} DESC`;
|
||||
* const query = sql`ORDER BY ${raw(sortColumn)} DESC`;
|
||||
*
|
||||
* // Dynamic SQL expressions
|
||||
* const query = sql`WHERE ${unsafeRaw('EXTRACT(year FROM created_at)')} = ${2024}`;
|
||||
* const query = sql`WHERE ${raw('EXTRACT(year FROM created_at)')} = ${2024}`;
|
||||
* ```
|
||||
*/
|
||||
export function unsafeRaw(sqlString: string): SQLUnsafeRaw {
|
||||
return new SQLUnsafeRaw(sqlString);
|
||||
export function raw(sqlString: string): SQLRaw {
|
||||
return new SQLRaw(sqlString);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -260,18 +227,12 @@ export function unsafeRaw(sqlString: string): SQLUnsafeRaw {
|
||||
* const query = sql`age >= ${age} AND status = ${'active'}`;
|
||||
* ```
|
||||
*/
|
||||
export function sql<TFields extends Record<string, any>>(
|
||||
export function sql(
|
||||
strings: TemplateStringsArray,
|
||||
...values: readonly (
|
||||
| SupportedSQLValue
|
||||
| SQLFragment<TFields>
|
||||
| SQLIdentifier
|
||||
| SQLUnsafeRaw
|
||||
| SQLEntityField<TFields>
|
||||
)[]
|
||||
): SQLFragment<TFields> {
|
||||
...values: readonly (SupportedSQLValue | SQLFragment | SQLIdentifier | SQLRaw)[]
|
||||
): SQLFragment {
|
||||
let sqlString = '';
|
||||
const bindings: SQLBinding<TFields>[] = [];
|
||||
const bindings: SQLBinding[] = [];
|
||||
|
||||
strings.forEach((string, i) => {
|
||||
sqlString += string;
|
||||
@@ -286,17 +247,13 @@ export function sql<TFields extends Record<string, any>>(
|
||||
// Handle identifiers (table/column names) with ?? placeholder
|
||||
sqlString += '??';
|
||||
bindings.push({ type: 'identifier', name: value.name });
|
||||
} 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) {
|
||||
} else if (value instanceof SQLRaw) {
|
||||
// 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): SQLBinding<TFields> => ({ type: 'value', value: v })));
|
||||
bindings.push(...value.map((v) => ({ type: 'value' as const, value: v })));
|
||||
} else {
|
||||
// Regular value binding
|
||||
sqlString += '?';
|
||||
@@ -308,10 +265,6 @@ export function sql<TFields extends Record<string, any>>(
|
||||
return new SQLFragment(sqlString, bindings);
|
||||
}
|
||||
|
||||
type PickSupportedSQLValueKeys<T> = {
|
||||
[K in keyof T]: T[K] extends SupportedSQLValue ? K : never;
|
||||
}[keyof T];
|
||||
|
||||
/**
|
||||
* Common SQL helper functions for building queries
|
||||
*/
|
||||
@@ -321,33 +274,28 @@ export const SQLFragmentHelpers = {
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* const query = SQLFragmentHelpers.inArray<MyFields, 'id'>('status', ['active', 'pending']);
|
||||
* // Generates: ?? IN (?, ?) with entityField binding for 'status' and value bindings
|
||||
* const query = SQLFragmentHelpers.inArray('status', ['active', 'pending']);
|
||||
* // Generates: ?? IN (?, ?) with bindings ['status', 'active', 'pending']
|
||||
* ```
|
||||
*/
|
||||
inArray<TFields extends Record<string, any>, N extends PickSupportedSQLValueKeys<TFields>>(
|
||||
fieldName: N,
|
||||
values: readonly TFields[N][],
|
||||
): SQLFragment<TFields> {
|
||||
inArray<T extends SupportedSQLValue>(column: string, values: readonly T[]): SQLFragment {
|
||||
if (values.length === 0) {
|
||||
// Handle empty array case - always false
|
||||
return sql`1 = 0`;
|
||||
}
|
||||
return sql`${entityField(fieldName)} IN ${values}`;
|
||||
// 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[]}`;
|
||||
},
|
||||
|
||||
/**
|
||||
* NOT IN clause helper
|
||||
*/
|
||||
notInArray<TFields extends Record<string, any>, N extends PickSupportedSQLValueKeys<TFields>>(
|
||||
fieldName: N,
|
||||
values: readonly TFields[N][],
|
||||
): SQLFragment<TFields> {
|
||||
notInArray<T extends SupportedSQLValue>(column: string, values: readonly T[]): SQLFragment {
|
||||
if (values.length === 0) {
|
||||
// Handle empty array case - always true
|
||||
return sql`1 = 1`;
|
||||
}
|
||||
return sql`${entityField(fieldName)} NOT IN ${values}`;
|
||||
return sql`${identifier(column)} NOT IN ${values as readonly SupportedSQLValue[]}`;
|
||||
},
|
||||
|
||||
/**
|
||||
@@ -355,27 +303,19 @@ export const SQLFragmentHelpers = {
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* const query = SQLFragmentHelpers.between<MyFields, 'id'>('age', 18, 65);
|
||||
* // Generates: ?? BETWEEN ? AND ? with entityField binding for 'age' and value bindings
|
||||
* const query = SQLFragmentHelpers.between('age', 18, 65);
|
||||
* // Generates: "age" BETWEEN ? AND ? with values [18, 65]
|
||||
* ```
|
||||
*/
|
||||
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}`;
|
||||
between<T extends string | number | Date>(column: string, min: T, max: T): SQLFragment {
|
||||
return sql`${identifier(column)} BETWEEN ${min} AND ${max}`;
|
||||
},
|
||||
|
||||
/**
|
||||
* NOT BETWEEN helper
|
||||
*/
|
||||
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}`;
|
||||
notBetween<T extends string | number | Date>(column: string, min: T, max: T): SQLFragment {
|
||||
return sql`${identifier(column)} NOT BETWEEN ${min} AND ${max}`;
|
||||
},
|
||||
|
||||
/**
|
||||
@@ -383,221 +323,156 @@ export const SQLFragmentHelpers = {
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* const query = SQLFragmentHelpers.like<MyFields, 'id'>('name', '%John%');
|
||||
* // Generates: ?? LIKE ? with entityField binding for 'name' and value binding
|
||||
* const query = SQLFragmentHelpers.like('name', '%John%');
|
||||
* // Generates: "name" LIKE ? with value '%John%'
|
||||
* ```
|
||||
*/
|
||||
like<TFields extends Record<string, any>>(
|
||||
fieldName: keyof TFields,
|
||||
pattern: string,
|
||||
): SQLFragment<TFields> {
|
||||
return sql`${entityField(fieldName)} LIKE ${pattern}`;
|
||||
like(column: string, pattern: string): SQLFragment {
|
||||
return sql`${identifier(column)} LIKE ${pattern}`;
|
||||
},
|
||||
|
||||
/**
|
||||
* NOT LIKE helper
|
||||
*/
|
||||
notLike<TFields extends Record<string, any>>(
|
||||
fieldName: keyof TFields,
|
||||
pattern: string,
|
||||
): SQLFragment<TFields> {
|
||||
return sql`${entityField(fieldName)} NOT LIKE ${pattern}`;
|
||||
notLike(column: string, pattern: string): SQLFragment {
|
||||
return sql`${identifier(column)} NOT LIKE ${pattern}`;
|
||||
},
|
||||
|
||||
/**
|
||||
* ILIKE helper for case-insensitive matching
|
||||
*/
|
||||
ilike<TFields extends Record<string, any>>(
|
||||
fieldName: keyof TFields,
|
||||
pattern: string,
|
||||
): SQLFragment<TFields> {
|
||||
return sql`${entityField(fieldName)} ILIKE ${pattern}`;
|
||||
ilike(column: string, pattern: string): SQLFragment {
|
||||
return sql`${identifier(column)} ILIKE ${pattern}`;
|
||||
},
|
||||
|
||||
/**
|
||||
* NOT ILIKE helper for case-insensitive non-matching
|
||||
*/
|
||||
notIlike<TFields extends Record<string, any>>(
|
||||
fieldName: keyof TFields,
|
||||
pattern: string,
|
||||
): SQLFragment<TFields> {
|
||||
return sql`${entityField(fieldName)} NOT ILIKE ${pattern}`;
|
||||
notIlike(column: string, pattern: string): SQLFragment {
|
||||
return sql`${identifier(column)} NOT ILIKE ${pattern}`;
|
||||
},
|
||||
|
||||
/**
|
||||
* NULL check helper
|
||||
*/
|
||||
isNull<TFields extends Record<string, any>>(fieldName: keyof TFields): SQLFragment<TFields> {
|
||||
return sql`${entityField(fieldName)} IS NULL`;
|
||||
isNull(column: string): SQLFragment {
|
||||
return sql`${identifier(column)} IS NULL`;
|
||||
},
|
||||
|
||||
/**
|
||||
* NOT NULL check helper
|
||||
*/
|
||||
isNotNull<TFields extends Record<string, any>>(fieldName: keyof TFields): SQLFragment<TFields> {
|
||||
return sql`${entityField(fieldName)} IS NOT NULL`;
|
||||
isNotNull(column: string): SQLFragment {
|
||||
return sql`${identifier(column)} IS NOT NULL`;
|
||||
},
|
||||
|
||||
/**
|
||||
* Single-equals-equality operator
|
||||
*/
|
||||
eq<TFields extends Record<string, any>, N extends PickSupportedSQLValueKeys<TFields>>(
|
||||
fieldName: N,
|
||||
value: TFields[N],
|
||||
): SQLFragment<TFields> {
|
||||
eq(column: string, value: SupportedSQLValue): SQLFragment {
|
||||
if (value === null || value === undefined) {
|
||||
return SQLFragmentHelpers.isNull(fieldName);
|
||||
return SQLFragmentHelpers.isNull(column);
|
||||
}
|
||||
return sql`${entityField(fieldName)} = ${value}`;
|
||||
return sql`${identifier(column)} = ${value}`;
|
||||
},
|
||||
|
||||
/**
|
||||
* Single-equals-inequality operator
|
||||
*/
|
||||
neq<TFields extends Record<string, any>, N extends PickSupportedSQLValueKeys<TFields>>(
|
||||
fieldName: N,
|
||||
value: TFields[N],
|
||||
): SQLFragment<TFields> {
|
||||
neq(column: string, value: SupportedSQLValue): SQLFragment {
|
||||
if (value === null || value === undefined) {
|
||||
return SQLFragmentHelpers.isNotNull(fieldName);
|
||||
return SQLFragmentHelpers.isNotNull(column);
|
||||
}
|
||||
return sql`${entityField(fieldName)} != ${value}`;
|
||||
return sql`${identifier(column)} != ${value}`;
|
||||
},
|
||||
|
||||
/**
|
||||
* Greater-than comparison operator
|
||||
*/
|
||||
gt<TFields extends Record<string, any>, N extends PickSupportedSQLValueKeys<TFields>>(
|
||||
fieldName: N,
|
||||
value: TFields[N],
|
||||
): SQLFragment<TFields> {
|
||||
return sql`${entityField(fieldName)} > ${value}`;
|
||||
gt(column: string, value: SupportedSQLValue): SQLFragment {
|
||||
return sql`${identifier(column)} > ${value}`;
|
||||
},
|
||||
|
||||
/**
|
||||
* Greater-than-or-equal-to comparison operator
|
||||
*/
|
||||
gte<TFields extends Record<string, any>, N extends PickSupportedSQLValueKeys<TFields>>(
|
||||
fieldName: N,
|
||||
value: TFields[N],
|
||||
): SQLFragment<TFields> {
|
||||
return sql`${entityField(fieldName)} >= ${value}`;
|
||||
gte(column: string, value: SupportedSQLValue): SQLFragment {
|
||||
return sql`${identifier(column)} >= ${value}`;
|
||||
},
|
||||
|
||||
/**
|
||||
* Less-than comparison operator
|
||||
*/
|
||||
lt<TFields extends Record<string, any>, N extends PickSupportedSQLValueKeys<TFields>>(
|
||||
fieldName: N,
|
||||
value: TFields[N],
|
||||
): SQLFragment<TFields> {
|
||||
return sql`${entityField(fieldName)} < ${value}`;
|
||||
lt(column: string, value: SupportedSQLValue): SQLFragment {
|
||||
return sql`${identifier(column)} < ${value}`;
|
||||
},
|
||||
|
||||
/**
|
||||
* Less-than-or-equal-to comparison operator
|
||||
*/
|
||||
lte<TFields extends Record<string, any>, N extends PickSupportedSQLValueKeys<TFields>>(
|
||||
fieldName: N,
|
||||
value: TFields[N],
|
||||
): SQLFragment<TFields> {
|
||||
return sql`${entityField(fieldName)} <= ${value}`;
|
||||
lte(column: string, value: SupportedSQLValue): SQLFragment {
|
||||
return sql`${identifier(column)} <= ${value}`;
|
||||
},
|
||||
|
||||
/**
|
||||
* JSON contains operator (\@\>)
|
||||
*/
|
||||
jsonContains<TFields extends Record<string, any>>(
|
||||
fieldName: keyof TFields,
|
||||
value: unknown,
|
||||
): SQLFragment<TFields> {
|
||||
return sql`${entityField(fieldName)} @> ${JSON.stringify(value)}::jsonb`;
|
||||
jsonContains(column: string, value: unknown): SQLFragment {
|
||||
return sql`${identifier(column)} @> ${JSON.stringify(value)}::jsonb`;
|
||||
},
|
||||
|
||||
/**
|
||||
* JSON contained by operator (\<\@\)
|
||||
*/
|
||||
jsonContainedBy<TFields extends Record<string, any>>(
|
||||
fieldName: keyof TFields,
|
||||
value: unknown,
|
||||
): SQLFragment<TFields> {
|
||||
return sql`${entityField(fieldName)} <@ ${JSON.stringify(value)}::jsonb`;
|
||||
jsonContainedBy(column: string, value: unknown): SQLFragment {
|
||||
return sql`${identifier(column)} <@ ${JSON.stringify(value)}::jsonb`;
|
||||
},
|
||||
|
||||
/**
|
||||
* JSON path extraction helper (-\>)
|
||||
*/
|
||||
jsonPath<TFields extends Record<string, any>>(
|
||||
fieldName: keyof TFields,
|
||||
path: string,
|
||||
): SQLFragment<TFields> {
|
||||
return sql`${entityField(fieldName)}->${path}`;
|
||||
jsonPath(column: string, path: string): SQLFragment {
|
||||
return new SQLFragment(`"${column}"->'${path}'`, []);
|
||||
},
|
||||
|
||||
/**
|
||||
* JSON path text extraction helper (-\>\>)
|
||||
*/
|
||||
jsonPathText<TFields extends Record<string, any>>(
|
||||
fieldName: keyof TFields,
|
||||
path: string,
|
||||
): SQLFragment<TFields> {
|
||||
return sql`${entityField(fieldName)}->>${path}`;
|
||||
jsonPathText(column: string, path: string): SQLFragment {
|
||||
return new SQLFragment(`"${column}"->>'${path}'`, []);
|
||||
},
|
||||
|
||||
/**
|
||||
* Logical AND of multiple fragments
|
||||
*/
|
||||
and<TFields extends Record<string, any>>(
|
||||
...conditions: readonly SQLFragment<TFields>[]
|
||||
): SQLFragment<TFields> {
|
||||
and(...conditions: SQLFragment[]): SQLFragment {
|
||||
if (conditions.length === 0) {
|
||||
return sql`1 = 1`;
|
||||
}
|
||||
return joinSQLFragments(
|
||||
conditions.map((c) => SQLFragmentHelpers.group(c)),
|
||||
' AND ',
|
||||
);
|
||||
return SQLFragment.join(conditions, ' AND ');
|
||||
},
|
||||
|
||||
/**
|
||||
* Logical OR of multiple fragments
|
||||
*/
|
||||
or<TFields extends Record<string, any>>(
|
||||
...conditions: readonly SQLFragment<TFields>[]
|
||||
): SQLFragment<TFields> {
|
||||
or(...conditions: SQLFragment[]): SQLFragment {
|
||||
if (conditions.length === 0) {
|
||||
return sql`1 = 0`;
|
||||
}
|
||||
return joinSQLFragments(
|
||||
conditions.map((c) => SQLFragmentHelpers.group(c)),
|
||||
' OR ',
|
||||
);
|
||||
return SQLFragment.join(conditions, ' OR ');
|
||||
},
|
||||
|
||||
/**
|
||||
* Logical NOT of a fragment
|
||||
*/
|
||||
not<TFields extends Record<string, any>>(condition: SQLFragment<TFields>): SQLFragment<TFields> {
|
||||
not(condition: SQLFragment): SQLFragment {
|
||||
return new SQLFragment('NOT (' + condition.sql + ')', condition.bindings);
|
||||
},
|
||||
|
||||
/**
|
||||
* Parentheses helper for grouping conditions
|
||||
*/
|
||||
group<TFields extends Record<string, any>>(
|
||||
condition: SQLFragment<TFields>,
|
||||
): SQLFragment<TFields> {
|
||||
group(condition: SQLFragment): SQLFragment {
|
||||
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),
|
||||
);
|
||||
}
|
||||
+34
-28
@@ -90,7 +90,7 @@ describe(createWithUniqueConstraintRecoveryAsync, () => {
|
||||
name: 'unique',
|
||||
};
|
||||
|
||||
const createdEntities = await vc1.runInTransactionForDatabaseAdapterFlavorAsync(
|
||||
const createdEntities = await vc1.runInTransactionForDatabaseAdaptorFlavorAsync(
|
||||
'postgres',
|
||||
async (queryContext) => {
|
||||
if (parallel) {
|
||||
@@ -154,32 +154,7 @@ describe(createWithUniqueConstraintRecoveryAsync, () => {
|
||||
let createdEntities: [PostgresUniqueTestEntity, PostgresUniqueTestEntity];
|
||||
if (parallel) {
|
||||
createdEntities = await Promise.all([
|
||||
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.runInTransactionForDatabaseAdapterFlavorAsync(
|
||||
await vc1.runInTransactionForDatabaseAdaptorFlavorAsync(
|
||||
'postgres',
|
||||
async (queryContext) => {
|
||||
return await createWithUniqueConstraintRecoveryAsync(
|
||||
@@ -193,7 +168,38 @@ describe(createWithUniqueConstraintRecoveryAsync, () => {
|
||||
);
|
||||
},
|
||||
),
|
||||
await vc1.runInTransactionForDatabaseAdapterFlavorAsync(
|
||||
await vc1.runInTransactionForDatabaseAdaptorFlavorAsync(
|
||||
'postgres',
|
||||
async (queryContext) => {
|
||||
return await createWithUniqueConstraintRecoveryAsync(
|
||||
vc1,
|
||||
PostgresUniqueTestEntity,
|
||||
PostgresUniqueTestEntity.getByNameAsync,
|
||||
args,
|
||||
PostgresUniqueTestEntity.createWithNameAsync,
|
||||
args,
|
||||
queryContext,
|
||||
);
|
||||
},
|
||||
),
|
||||
]);
|
||||
} else {
|
||||
createdEntities = [
|
||||
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(
|
||||
|
||||
+146
-1024
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.runInTransactionForDatabaseAdapterFlavorAsync('postgres', async (queryContext) => {
|
||||
await vc1.runInTransactionForDatabaseAdaptorFlavorAsync('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.runInTransactionForDatabaseAdapterFlavorAsync('postgres', async (queryContext) => {
|
||||
await vc1.runInTransactionForDatabaseAdaptorFlavorAsync('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.runInTransactionForDatabaseAdapterFlavorAsync('postgres', async (queryContext) => {
|
||||
await vc1.runInTransactionForDatabaseAdaptorFlavorAsync('postgres', async (queryContext) => {
|
||||
const entityLoadedOuter = await PostgresUniqueTestEntity.loader(vc1).loadByIDAsync(
|
||||
entity.getID(),
|
||||
);
|
||||
@@ -181,7 +181,7 @@ describe(PostgresEntityQueryContextProvider, () => {
|
||||
});
|
||||
|
||||
await Promise.all([
|
||||
vc1.runInTransactionForDatabaseAdapterFlavorAsync('postgres', async (queryContext) => {
|
||||
vc1.runInTransactionForDatabaseAdaptorFlavorAsync('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.runInTransactionForDatabaseAdapterFlavorAsync(
|
||||
await vc1.runInTransactionForDatabaseAdaptorFlavorAsync(
|
||||
'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.runInTransactionForDatabaseAdapterFlavorAsync(
|
||||
await vc1.runInTransactionForDatabaseAdaptorFlavorAsync(
|
||||
'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.runInTransactionForDatabaseAdapterFlavorAsync('postgres', async (queryContext) => {
|
||||
await vc1.runInTransactionForDatabaseAdaptorFlavorAsync('postgres', async (queryContext) => {
|
||||
await queryContext.runInNestedTransactionAsync(async (innerQueryContext) => {
|
||||
await innerQueryContext.runInNestedTransactionAsync(async (innerQueryContex2) => {
|
||||
await innerQueryContex2.runInNestedTransactionAsync(async (innerQueryContex3) => {
|
||||
|
||||
@@ -17,10 +17,9 @@ import { Knex } from 'knex';
|
||||
import { BigIntField, JSONArrayField, MaybeJSONArrayField } from '../EntityFields';
|
||||
import { PostgresEntity } from '../PostgresEntity';
|
||||
|
||||
export type PostgresTestEntityFields = {
|
||||
type PostgresTestEntityFields = {
|
||||
id: string;
|
||||
name: string | null;
|
||||
label: string;
|
||||
hasADog: boolean | null;
|
||||
hasACat: boolean | null;
|
||||
stringArray: string[] | null;
|
||||
@@ -61,7 +60,6 @@ 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[]');
|
||||
@@ -140,9 +138,6 @@ 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]], [['hello']]],
|
||||
[[[1, 2]] as any, [['hello']] as any],
|
||||
[1, 'hello'],
|
||||
);
|
||||
describeFieldTestCase(
|
||||
|
||||
@@ -1,20 +1,13 @@
|
||||
import { getDatabaseFieldForEntityField } from '@expo/entity';
|
||||
import { describe, expect, it } from '@jest/globals';
|
||||
|
||||
import {
|
||||
entityField,
|
||||
identifier,
|
||||
unsafeRaw,
|
||||
raw,
|
||||
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', () => {
|
||||
@@ -24,7 +17,7 @@ describe('SQLOperator', () => {
|
||||
const fragment = sql`age >= ${age} AND status = ${status}`;
|
||||
|
||||
expect(fragment.sql).toBe('age >= ? AND status = ?');
|
||||
expect(fragment.getKnexBindings(getColumnForField)).toEqual([18, 'active']);
|
||||
expect(fragment.getKnexBindings()).toEqual([18, 'active']);
|
||||
});
|
||||
|
||||
it('handles nested SQL fragments', () => {
|
||||
@@ -33,7 +26,7 @@ describe('SQLOperator', () => {
|
||||
const combined = sql`${condition1} AND ${condition2}`;
|
||||
|
||||
expect(combined.sql).toBe('age >= ? AND status = ?');
|
||||
expect(combined.getKnexBindings(getColumnForField)).toEqual([18, 'active']);
|
||||
expect(combined.getKnexBindings()).toEqual([18, 'active']);
|
||||
});
|
||||
|
||||
it('handles SQL identifiers', () => {
|
||||
@@ -41,7 +34,7 @@ describe('SQLOperator', () => {
|
||||
const fragment = sql`${identifier(columnName)} = ${'John'}`;
|
||||
|
||||
expect(fragment.sql).toBe('?? = ?');
|
||||
expect(fragment.getKnexBindings(getColumnForField)).toEqual(['user_name', 'John']);
|
||||
expect(fragment.getKnexBindings()).toEqual(['user_name', 'John']);
|
||||
});
|
||||
|
||||
it('handles arrays for IN clauses', () => {
|
||||
@@ -49,62 +42,58 @@ describe('SQLOperator', () => {
|
||||
const fragment = sql`status IN ${values}`;
|
||||
|
||||
expect(fragment.sql).toBe('status IN (?, ?, ?)');
|
||||
expect(fragment.getKnexBindings(getColumnForField)).toEqual([
|
||||
'active',
|
||||
'pending',
|
||||
'approved',
|
||||
]);
|
||||
expect(fragment.getKnexBindings()).toEqual(['active', 'pending', 'approved']);
|
||||
});
|
||||
|
||||
it('handles null values', () => {
|
||||
const fragment = sql`field = ${null}`;
|
||||
|
||||
expect(fragment.sql).toBe('field = ?');
|
||||
expect(fragment.getKnexBindings(getColumnForField)).toEqual([null]);
|
||||
expect(fragment.getKnexBindings()).toEqual([null]);
|
||||
});
|
||||
|
||||
it('handles empty strings', () => {
|
||||
const fragment = sql`field = ${''}`;
|
||||
|
||||
expect(fragment.sql).toBe('field = ?');
|
||||
expect(fragment.getKnexBindings(getColumnForField)).toEqual(['']);
|
||||
expect(fragment.getKnexBindings()).toEqual(['']);
|
||||
});
|
||||
|
||||
it('handles numbers including zero', () => {
|
||||
const fragment = sql`count = ${0} OR count = ${42}`;
|
||||
|
||||
expect(fragment.sql).toBe('count = ? OR count = ?');
|
||||
expect(fragment.getKnexBindings(getColumnForField)).toEqual([0, 42]);
|
||||
expect(fragment.getKnexBindings()).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(getColumnForField)).toEqual([true, false]);
|
||||
expect(fragment.getKnexBindings()).toEqual([true, false]);
|
||||
});
|
||||
|
||||
it('handles raw SQL', () => {
|
||||
const columnName = 'created_at';
|
||||
const fragment = sql`ORDER BY ${unsafeRaw(columnName)} DESC`;
|
||||
const fragment = sql`ORDER BY ${raw(columnName)} DESC`;
|
||||
|
||||
expect(fragment.sql).toBe('ORDER BY created_at DESC');
|
||||
expect(fragment.getKnexBindings(getColumnForField)).toEqual([]);
|
||||
expect(fragment.getKnexBindings()).toEqual([]);
|
||||
});
|
||||
|
||||
it('handles complex raw SQL expressions', () => {
|
||||
const fragment = sql`WHERE ${unsafeRaw('EXTRACT(year FROM created_at)')} = ${2024}`;
|
||||
const fragment = sql`WHERE ${raw('EXTRACT(year FROM created_at)')} = ${2024}`;
|
||||
|
||||
expect(fragment.sql).toBe('WHERE EXTRACT(year FROM created_at) = ?');
|
||||
expect(fragment.getKnexBindings(getColumnForField)).toEqual([2024]);
|
||||
expect(fragment.getKnexBindings()).toEqual([2024]);
|
||||
});
|
||||
|
||||
it('combines raw SQL with regular parameters', () => {
|
||||
const sortColumn = 'name';
|
||||
const fragment = sql`SELECT * FROM users WHERE age > ${18} ORDER BY ${unsafeRaw(sortColumn)} ${unsafeRaw('DESC')}`;
|
||||
const fragment = sql`SELECT * FROM users WHERE age > ${18} ORDER BY ${raw(sortColumn)} ${raw('DESC')}`;
|
||||
|
||||
expect(fragment.sql).toBe('SELECT * FROM users WHERE age > ? ORDER BY name DESC');
|
||||
expect(fragment.getKnexBindings(getColumnForField)).toEqual([18]);
|
||||
expect(fragment.getKnexBindings()).toEqual([18]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -116,32 +105,52 @@ describe('SQLOperator', () => {
|
||||
const combined = fragment1.append(fragment2);
|
||||
|
||||
expect(combined.sql).toBe('age >= ? status = ?');
|
||||
expect(combined.getKnexBindings(getColumnForField)).toEqual([18, 'active']);
|
||||
expect(combined.getKnexBindings()).toEqual([18, 'active']);
|
||||
});
|
||||
});
|
||||
|
||||
describe(SQLFragment.joinWithCommaSeparator, () => {
|
||||
it('handles empty array in join', () => {
|
||||
const joined = SQLFragment.joinWithCommaSeparator();
|
||||
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('');
|
||||
expect(joined.getKnexBindings(getColumnForField)).toEqual([]);
|
||||
expect(joined.sql).toBe('name = ? AND age = ? AND city = ?');
|
||||
expect(joined.getKnexBindings()).toEqual(['Alice', 30, 'NYC']);
|
||||
});
|
||||
|
||||
it('joins SQL fragments with comma', () => {
|
||||
it('handles empty array in join', () => {
|
||||
const joined = SQLFragment.join([]);
|
||||
|
||||
expect(joined.sql).toBe('');
|
||||
expect(joined.getKnexBindings()).toEqual([]);
|
||||
});
|
||||
|
||||
it('joins SQL fragments with default separator', () => {
|
||||
const columns = [sql`name`, sql`age`, sql`email`];
|
||||
const joined = SQLFragment.joinWithCommaSeparator(...columns);
|
||||
const joined = SQLFragment.join(columns);
|
||||
|
||||
expect(joined.sql).toBe('name, age, email');
|
||||
expect(joined.getKnexBindings(getColumnForField)).toEqual([]);
|
||||
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]);
|
||||
});
|
||||
|
||||
it('handles single fragment', () => {
|
||||
const single = [sql`name = ${'Alice'}`];
|
||||
const joined = SQLFragment.joinWithCommaSeparator(...single);
|
||||
const joined = SQLFragment.join(single);
|
||||
|
||||
expect(joined.sql).toBe('name = ?');
|
||||
expect(joined.getKnexBindings(getColumnForField)).toEqual(['Alice']);
|
||||
expect(joined.getKnexBindings()).toEqual(['Alice']);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -154,7 +163,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(getColumnForField)).toEqual([18]);
|
||||
expect(concatenated.getKnexBindings()).toEqual([18]);
|
||||
});
|
||||
|
||||
it('handles single fragment in concat', () => {
|
||||
@@ -162,27 +171,27 @@ describe('SQLOperator', () => {
|
||||
const concatenated = SQLFragment.concat(fragment);
|
||||
|
||||
expect(concatenated.sql).toBe('SELECT * FROM users');
|
||||
expect(concatenated.getKnexBindings(getColumnForField)).toEqual([]);
|
||||
expect(concatenated.getKnexBindings()).toEqual([]);
|
||||
});
|
||||
|
||||
it('handles empty concat', () => {
|
||||
const concatenated = SQLFragment.concat();
|
||||
|
||||
expect(concatenated.sql).toBe('');
|
||||
expect(concatenated.getKnexBindings(getColumnForField)).toEqual([]);
|
||||
expect(concatenated.getKnexBindings()).toEqual([]);
|
||||
});
|
||||
|
||||
it('supports dynamic query building with concat', () => {
|
||||
// Build a query dynamically
|
||||
const fragments: SQLFragment<Record<string, any>>[] = [sql`SELECT * FROM products`];
|
||||
const fragments: SQLFragment[] = [sql`SELECT * FROM products`];
|
||||
|
||||
// Conditionally add WHERE clause
|
||||
const filters: SQLFragment<Record<string, any>>[] = [];
|
||||
const filters: SQLFragment[] = [];
|
||||
filters.push(sql`price > ${100}`);
|
||||
filters.push(sql`category = ${'electronics'}`);
|
||||
|
||||
if (filters.length > 0) {
|
||||
fragments.push(sql`WHERE ${SQLFragmentHelpers.and(...filters)}`);
|
||||
fragments.push(sql`WHERE ${SQLFragment.join(filters, ' AND ')}`);
|
||||
}
|
||||
|
||||
// Add ORDER BY
|
||||
@@ -194,13 +203,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(getColumnForField)).toEqual([100, 'electronics', 10]);
|
||||
expect(query.getKnexBindings()).toEqual([100, 'electronics', 10]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getDebugString', () => {
|
||||
describe('toDebugString', () => {
|
||||
it('generates debug text with values inline', () => {
|
||||
const fragment = new SQLFragment(
|
||||
'SELECT * FROM users WHERE name = ? AND age > ? AND active = ? AND created_at > ?',
|
||||
@@ -212,7 +221,7 @@ describe('SQLOperator', () => {
|
||||
],
|
||||
);
|
||||
|
||||
const text = fragment.getDebugString();
|
||||
const text = fragment.toDebugString;
|
||||
expect(text).toContain("'Alice'");
|
||||
expect(text).toContain('18');
|
||||
expect(text).toContain('TRUE');
|
||||
@@ -226,13 +235,13 @@ describe('SQLOperator', () => {
|
||||
{ type: 'value', value: { key: 'value' } },
|
||||
]);
|
||||
|
||||
const text = fragment.getDebugString();
|
||||
const text = fragment.toDebugString;
|
||||
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 getDebugString', () => {
|
||||
it('handles all SupportedSQLValue types in toDebugString', () => {
|
||||
const fragment = new SQLFragment('INSERT INTO test VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)', [
|
||||
{ type: 'value', value: 'string' },
|
||||
{ type: 'value', value: 123 },
|
||||
@@ -245,18 +254,18 @@ describe('SQLOperator', () => {
|
||||
{ type: 'value', value: [1, 2, 3] },
|
||||
]);
|
||||
|
||||
const text = fragment.getDebugString();
|
||||
const text = fragment.toDebugString;
|
||||
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 getDebugString', () => {
|
||||
it('handles nested arrays in toDebugString', () => {
|
||||
const fragment = new SQLFragment('SELECT * FROM test WHERE tags = ?', [
|
||||
{ type: 'value', value: ['tag1', 'tag2', null] },
|
||||
]);
|
||||
|
||||
const text = fragment.getDebugString();
|
||||
const text = fragment.toDebugString;
|
||||
expect(text).toBe("SELECT * FROM test WHERE tags = ARRAY['tag1', 'tag2', NULL]");
|
||||
});
|
||||
|
||||
@@ -265,23 +274,23 @@ describe('SQLOperator', () => {
|
||||
{ type: 'value', value: 'value1' },
|
||||
]);
|
||||
|
||||
const text = fragment.getDebugString();
|
||||
const text = fragment.toDebugString;
|
||||
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') },
|
||||
{ type: 'value', value: new Error('wat') as any },
|
||||
{ type: 'value', value: Object.create(null) },
|
||||
]);
|
||||
|
||||
const text = fragment.getDebugString();
|
||||
const text = fragment.toDebugString;
|
||||
expect(text).toBe(
|
||||
`SELECT * FROM test WHERE field = UnsupportedSQLValue[Error: wat] AND field2 = '{}'::jsonb`,
|
||||
);
|
||||
});
|
||||
|
||||
it('handles identifiers in getDebugString', () => {
|
||||
it('handles identifiers in toDebugString', () => {
|
||||
const fragment = new SQLFragment('SELECT ?? FROM ?? WHERE ?? = ?', [
|
||||
{ type: 'identifier', name: 'user_name' },
|
||||
{ type: 'identifier', name: 'users' },
|
||||
@@ -289,29 +298,17 @@ describe('SQLOperator', () => {
|
||||
{ type: 'value', value: 'active' },
|
||||
]);
|
||||
|
||||
const text = fragment.getDebugString();
|
||||
const text = fragment.toDebugString;
|
||||
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.getDebugString();
|
||||
const text = fragment.toDebugString;
|
||||
expect(text).toBe('SELECT * FROM users WHERE id = 1 AND name = ?');
|
||||
});
|
||||
|
||||
@@ -322,7 +319,7 @@ describe('SQLOperator', () => {
|
||||
{ type: 'value', value: 'test' },
|
||||
]);
|
||||
|
||||
const text = fragment.getDebugString();
|
||||
const text = fragment.toDebugString;
|
||||
// When a binding is null, it leaves the placeholder unchanged and doesn't advance the index
|
||||
expect(text).toBe('SELECT * FROM "users" WHERE ?? = ?');
|
||||
});
|
||||
@@ -333,7 +330,7 @@ describe('SQLOperator', () => {
|
||||
{ type: 'value', value: 1 },
|
||||
]);
|
||||
|
||||
const text = fragment.getDebugString();
|
||||
const text = fragment.toDebugString;
|
||||
// Mismatched binding type leaves the placeholder unchanged
|
||||
expect(text).toBe('SELECT * FROM ?? WHERE id = 1');
|
||||
});
|
||||
@@ -344,7 +341,7 @@ describe('SQLOperator', () => {
|
||||
{ type: 'value', value: 'active' },
|
||||
]);
|
||||
|
||||
const text = fragment.getDebugString();
|
||||
const text = fragment.toDebugString;
|
||||
// Mismatched binding type leaves the placeholder unchanged
|
||||
expect(text).toBe("SELECT * FROM users WHERE ? = 'active'");
|
||||
});
|
||||
@@ -386,7 +383,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(getColumnForField)).toEqual(['user"data']);
|
||||
expect(fragment.getKnexBindings()).toEqual(['user"data']);
|
||||
});
|
||||
|
||||
it('delegates escaping to Knex for SQL injection prevention', () => {
|
||||
@@ -394,336 +391,258 @@ 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(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']);
|
||||
expect(fragment.getKnexBindings()).toEqual(['id"; DELETE FROM users WHERE "1"="1']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('SQLFragmentHelpers', () => {
|
||||
describe(SQLFragmentHelpers.inArray, () => {
|
||||
it('generates IN clause with values', () => {
|
||||
const fragment = SQLFragmentHelpers.inArray('stringField', ['active', 'pending']);
|
||||
const fragment = SQLFragmentHelpers.inArray('status', ['active', 'pending']);
|
||||
|
||||
expect(fragment.sql).toBe('?? IN (?, ?)');
|
||||
expect(fragment.getKnexBindings(getColumnForField)).toEqual([
|
||||
'string_field',
|
||||
'active',
|
||||
'pending',
|
||||
]);
|
||||
expect(fragment.getKnexBindings()).toEqual(['status', 'active', 'pending']);
|
||||
});
|
||||
|
||||
it('handles empty array', () => {
|
||||
const fragment = SQLFragmentHelpers.inArray('stringField', []);
|
||||
const fragment = SQLFragmentHelpers.inArray('status', []);
|
||||
|
||||
expect(fragment.sql).toBe('1 = 0'); // Always false
|
||||
expect(fragment.getKnexBindings(getColumnForField)).toEqual([]);
|
||||
expect(fragment.getKnexBindings()).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe(SQLFragmentHelpers.notInArray, () => {
|
||||
it('generates NOT IN clause with values', () => {
|
||||
const fragment = SQLFragmentHelpers.notInArray('stringField', ['deleted', 'archived']);
|
||||
const fragment = SQLFragmentHelpers.notInArray('status', ['deleted', 'archived']);
|
||||
|
||||
expect(fragment.sql).toBe('?? NOT IN (?, ?)');
|
||||
expect(fragment.getKnexBindings(getColumnForField)).toEqual([
|
||||
'string_field',
|
||||
'deleted',
|
||||
'archived',
|
||||
]);
|
||||
expect(fragment.getKnexBindings()).toEqual(['status', 'deleted', 'archived']);
|
||||
});
|
||||
|
||||
it('handles empty array', () => {
|
||||
const fragment = SQLFragmentHelpers.notInArray('stringField', []);
|
||||
const fragment = SQLFragmentHelpers.notInArray('status', []);
|
||||
|
||||
expect(fragment.sql).toBe('1 = 1'); // Always true
|
||||
expect(fragment.getKnexBindings(getColumnForField)).toEqual([]);
|
||||
expect(fragment.getKnexBindings()).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe(SQLFragmentHelpers.between, () => {
|
||||
it('generates BETWEEN clause with numbers', () => {
|
||||
const fragment = SQLFragmentHelpers.between('intField', 18, 65);
|
||||
const fragment = SQLFragmentHelpers.between('age', 18, 65);
|
||||
|
||||
expect(fragment.sql).toBe('?? BETWEEN ? AND ?');
|
||||
expect(fragment.getKnexBindings(getColumnForField)).toEqual(['number_field', 18, 65]);
|
||||
expect(fragment.getKnexBindings()).toEqual(['age', 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('dateField', date1, date2);
|
||||
const fragment = SQLFragmentHelpers.between('created_at', date1, date2);
|
||||
|
||||
expect(fragment.sql).toBe('?? BETWEEN ? AND ?');
|
||||
expect(fragment.getKnexBindings(getColumnForField)).toEqual(['date_field', date1, date2]);
|
||||
expect(fragment.getKnexBindings()).toEqual(['created_at', date1, date2]);
|
||||
});
|
||||
|
||||
it('generates BETWEEN clause with strings', () => {
|
||||
const fragment = SQLFragmentHelpers.between('stringField', 'A', 'Z');
|
||||
const fragment = SQLFragmentHelpers.between('name', 'A', 'Z');
|
||||
|
||||
expect(fragment.sql).toBe('?? BETWEEN ? AND ?');
|
||||
expect(fragment.getKnexBindings(getColumnForField)).toEqual(['string_field', 'A', 'Z']);
|
||||
expect(fragment.getKnexBindings()).toEqual(['name', 'A', 'Z']);
|
||||
});
|
||||
});
|
||||
|
||||
describe(SQLFragmentHelpers.notBetween, () => {
|
||||
it('generates NOT BETWEEN clause with numbers', () => {
|
||||
const fragment = SQLFragmentHelpers.notBetween('intField', 18, 65);
|
||||
const fragment = SQLFragmentHelpers.notBetween('age', 18, 65);
|
||||
|
||||
expect(fragment.sql).toBe('?? NOT BETWEEN ? AND ?');
|
||||
expect(fragment.getKnexBindings(getColumnForField)).toEqual(['number_field', 18, 65]);
|
||||
expect(fragment.getKnexBindings()).toEqual(['age', 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('dateField', date1, date2);
|
||||
const fragment = SQLFragmentHelpers.notBetween('created_at', date1, date2);
|
||||
|
||||
expect(fragment.sql).toBe('?? NOT BETWEEN ? AND ?');
|
||||
expect(fragment.getKnexBindings(getColumnForField)).toEqual(['date_field', date1, date2]);
|
||||
expect(fragment.getKnexBindings()).toEqual(['created_at', date1, date2]);
|
||||
});
|
||||
});
|
||||
|
||||
describe(SQLFragmentHelpers.like, () => {
|
||||
it('generates LIKE clause', () => {
|
||||
const fragment = SQLFragmentHelpers.like('stringField', '%John%');
|
||||
const fragment = SQLFragmentHelpers.like('name', '%John%');
|
||||
|
||||
expect(fragment.sql).toBe('?? LIKE ?');
|
||||
expect(fragment.getKnexBindings(getColumnForField)).toEqual(['string_field', '%John%']);
|
||||
expect(fragment.getKnexBindings()).toEqual(['name', '%John%']);
|
||||
});
|
||||
});
|
||||
|
||||
describe(SQLFragmentHelpers.notLike, () => {
|
||||
it('generates NOT LIKE clause', () => {
|
||||
const fragment = SQLFragmentHelpers.notLike('stringField', '%test%');
|
||||
const fragment = SQLFragmentHelpers.notLike('name', '%test%');
|
||||
|
||||
expect(fragment.sql).toBe('?? NOT LIKE ?');
|
||||
expect(fragment.getKnexBindings(getColumnForField)).toEqual(['string_field', '%test%']);
|
||||
expect(fragment.getKnexBindings()).toEqual(['name', '%test%']);
|
||||
});
|
||||
});
|
||||
|
||||
describe(SQLFragmentHelpers.ilike, () => {
|
||||
it('generates ILIKE clause for case-insensitive matching', () => {
|
||||
const fragment = SQLFragmentHelpers.ilike('testIndexedField', '%@example.com');
|
||||
const fragment = SQLFragmentHelpers.ilike('email', '%@example.com');
|
||||
|
||||
expect(fragment.sql).toBe('?? ILIKE ?');
|
||||
expect(fragment.getKnexBindings(getColumnForField)).toEqual([
|
||||
'test_index',
|
||||
'%@example.com',
|
||||
]);
|
||||
expect(fragment.getKnexBindings()).toEqual(['email', '%@example.com']);
|
||||
});
|
||||
});
|
||||
|
||||
describe(SQLFragmentHelpers.notIlike, () => {
|
||||
it('generates NOT ILIKE clause for case-insensitive non-matching', () => {
|
||||
const fragment = SQLFragmentHelpers.notIlike('testIndexedField', '%@spam.com');
|
||||
const fragment = SQLFragmentHelpers.notIlike('email', '%@spam.com');
|
||||
|
||||
expect(fragment.sql).toBe('?? NOT ILIKE ?');
|
||||
expect(fragment.getKnexBindings(getColumnForField)).toEqual(['test_index', '%@spam.com']);
|
||||
expect(fragment.getKnexBindings()).toEqual(['email', '%@spam.com']);
|
||||
});
|
||||
});
|
||||
|
||||
describe(SQLFragmentHelpers.isNull, () => {
|
||||
it('generates IS NULL', () => {
|
||||
const fragment = SQLFragmentHelpers.isNull('nullableField');
|
||||
const fragment = SQLFragmentHelpers.isNull('deleted_at');
|
||||
|
||||
expect(fragment.sql).toBe('?? IS NULL');
|
||||
expect(fragment.getKnexBindings(getColumnForField)).toEqual(['nullable_field']);
|
||||
expect(fragment.getKnexBindings()).toEqual(['deleted_at']);
|
||||
});
|
||||
});
|
||||
|
||||
describe(SQLFragmentHelpers.isNotNull, () => {
|
||||
it('generates IS NOT NULL', () => {
|
||||
const fragment = SQLFragmentHelpers.isNotNull('testIndexedField');
|
||||
const fragment = SQLFragmentHelpers.isNotNull('email');
|
||||
|
||||
expect(fragment.sql).toBe('?? IS NOT NULL');
|
||||
expect(fragment.getKnexBindings(getColumnForField)).toEqual(['test_index']);
|
||||
expect(fragment.getKnexBindings()).toEqual(['email']);
|
||||
});
|
||||
});
|
||||
|
||||
describe(SQLFragmentHelpers.eq, () => {
|
||||
it('generates equality check', () => {
|
||||
const fragment = SQLFragmentHelpers.eq('stringField', 'active');
|
||||
const fragment = SQLFragmentHelpers.eq('status', 'active');
|
||||
|
||||
expect(fragment.sql).toBe('?? = ?');
|
||||
expect(fragment.getKnexBindings(getColumnForField)).toEqual(['string_field', 'active']);
|
||||
expect(fragment.getKnexBindings()).toEqual(['status', 'active']);
|
||||
});
|
||||
|
||||
it('handles null in equality check', () => {
|
||||
const fragment = SQLFragmentHelpers.eq('nullableField', null);
|
||||
const fragment = SQLFragmentHelpers.eq('field', null);
|
||||
|
||||
expect(fragment.sql).toBe('?? IS NULL');
|
||||
expect(fragment.getKnexBindings(getColumnForField)).toEqual(['nullable_field']);
|
||||
expect(fragment.getKnexBindings()).toEqual(['field']);
|
||||
});
|
||||
|
||||
it('handles undefined in equality check', () => {
|
||||
const fragment = SQLFragmentHelpers.eq('nullableField', undefined);
|
||||
const fragment = SQLFragmentHelpers.eq('field', undefined);
|
||||
|
||||
expect(fragment.sql).toBe('?? IS NULL');
|
||||
expect(fragment.getKnexBindings(getColumnForField)).toEqual(['nullable_field']);
|
||||
expect(fragment.getKnexBindings()).toEqual(['field']);
|
||||
});
|
||||
});
|
||||
|
||||
describe(SQLFragmentHelpers.neq, () => {
|
||||
it('generates inequality check', () => {
|
||||
const fragment = SQLFragmentHelpers.neq('stringField', 'deleted');
|
||||
const fragment = SQLFragmentHelpers.neq('status', 'deleted');
|
||||
|
||||
expect(fragment.sql).toBe('?? != ?');
|
||||
expect(fragment.getKnexBindings(getColumnForField)).toEqual(['string_field', 'deleted']);
|
||||
expect(fragment.getKnexBindings()).toEqual(['status', 'deleted']);
|
||||
});
|
||||
|
||||
it('handles null in inequality check', () => {
|
||||
const fragment = SQLFragmentHelpers.neq('nullableField', null);
|
||||
const fragment = SQLFragmentHelpers.neq('field', null);
|
||||
|
||||
expect(fragment.sql).toBe('?? IS NOT NULL');
|
||||
expect(fragment.getKnexBindings(getColumnForField)).toEqual(['nullable_field']);
|
||||
expect(fragment.getKnexBindings()).toEqual(['field']);
|
||||
});
|
||||
|
||||
it('handles undefined in inequality check', () => {
|
||||
const fragment = SQLFragmentHelpers.neq('nullableField', undefined);
|
||||
const fragment = SQLFragmentHelpers.neq('field', undefined);
|
||||
|
||||
expect(fragment.sql).toBe('?? IS NOT NULL');
|
||||
expect(fragment.getKnexBindings(getColumnForField)).toEqual(['nullable_field']);
|
||||
expect(fragment.getKnexBindings()).toEqual(['field']);
|
||||
});
|
||||
});
|
||||
|
||||
describe(SQLFragmentHelpers.gt, () => {
|
||||
it('generates greater than', () => {
|
||||
const fragment = SQLFragmentHelpers.gt('intField', 18);
|
||||
const fragment = SQLFragmentHelpers.gt('age', 18);
|
||||
|
||||
expect(fragment.sql).toBe('?? > ?');
|
||||
expect(fragment.getKnexBindings(getColumnForField)).toEqual(['number_field', 18]);
|
||||
expect(fragment.getKnexBindings()).toEqual(['age', 18]);
|
||||
});
|
||||
});
|
||||
|
||||
describe(SQLFragmentHelpers.gte, () => {
|
||||
it('generates greater than or equal', () => {
|
||||
const fragment = SQLFragmentHelpers.gte('intField', 18);
|
||||
const fragment = SQLFragmentHelpers.gte('age', 18);
|
||||
|
||||
expect(fragment.sql).toBe('?? >= ?');
|
||||
expect(fragment.getKnexBindings(getColumnForField)).toEqual(['number_field', 18]);
|
||||
expect(fragment.getKnexBindings()).toEqual(['age', 18]);
|
||||
});
|
||||
});
|
||||
|
||||
describe(SQLFragmentHelpers.lt, () => {
|
||||
it('generates less than', () => {
|
||||
const fragment = SQLFragmentHelpers.lt('intField', 65);
|
||||
const fragment = SQLFragmentHelpers.lt('age', 65);
|
||||
|
||||
expect(fragment.sql).toBe('?? < ?');
|
||||
expect(fragment.getKnexBindings(getColumnForField)).toEqual(['number_field', 65]);
|
||||
expect(fragment.getKnexBindings()).toEqual(['age', 65]);
|
||||
});
|
||||
});
|
||||
|
||||
describe(SQLFragmentHelpers.lte, () => {
|
||||
it('generates less than or equal', () => {
|
||||
const fragment = SQLFragmentHelpers.lte('intField', 65);
|
||||
const fragment = SQLFragmentHelpers.lte('age', 65);
|
||||
|
||||
expect(fragment.sql).toBe('?? <= ?');
|
||||
expect(fragment.getKnexBindings(getColumnForField)).toEqual(['number_field', 65]);
|
||||
expect(fragment.getKnexBindings()).toEqual(['age', 65]);
|
||||
});
|
||||
});
|
||||
|
||||
describe(SQLFragmentHelpers.jsonContains, () => {
|
||||
it('generates JSON contains', () => {
|
||||
const fragment = SQLFragmentHelpers.jsonContains('stringField', { premium: true });
|
||||
const fragment = SQLFragmentHelpers.jsonContains('metadata', { premium: true });
|
||||
|
||||
expect(fragment.sql).toBe('?? @> ?::jsonb');
|
||||
expect(fragment.getKnexBindings(getColumnForField)).toEqual([
|
||||
'string_field',
|
||||
'{"premium":true}',
|
||||
]);
|
||||
expect(fragment.getKnexBindings()).toEqual(['metadata', '{"premium":true}']);
|
||||
});
|
||||
});
|
||||
|
||||
describe(SQLFragmentHelpers.jsonContainedBy, () => {
|
||||
it('generates JSON contained by', () => {
|
||||
const fragment = SQLFragmentHelpers.jsonContainedBy('stringField', {
|
||||
const fragment = SQLFragmentHelpers.jsonContainedBy('settings', {
|
||||
theme: 'dark',
|
||||
lang: 'en',
|
||||
});
|
||||
|
||||
expect(fragment.sql).toBe('?? <@ ?::jsonb');
|
||||
expect(fragment.getKnexBindings(getColumnForField)).toEqual([
|
||||
'string_field',
|
||||
'{"theme":"dark","lang":"en"}',
|
||||
]);
|
||||
expect(fragment.getKnexBindings()).toEqual(['settings', '{"theme":"dark","lang":"en"}']);
|
||||
});
|
||||
});
|
||||
|
||||
describe(SQLFragmentHelpers.jsonPath, () => {
|
||||
it('generates JSON path access', () => {
|
||||
const fragment = SQLFragmentHelpers.jsonPath('stringField', 'user');
|
||||
const fragment = SQLFragmentHelpers.jsonPath('data', 'user');
|
||||
|
||||
expect(fragment.sql).toBe(`??->?`);
|
||||
expect(fragment.getKnexBindings(getColumnForField)).toEqual(['string_field', 'user']);
|
||||
expect(fragment.sql).toBe(`"data"->'user'`);
|
||||
expect(fragment.getKnexBindings()).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe(SQLFragmentHelpers.jsonPathText, () => {
|
||||
it('generates JSON path text access', () => {
|
||||
const fragment = SQLFragmentHelpers.jsonPathText('stringField', 'email');
|
||||
const fragment = SQLFragmentHelpers.jsonPathText('data', 'email');
|
||||
|
||||
expect(fragment.sql).toBe(`??->>?`);
|
||||
expect(fragment.getKnexBindings(getColumnForField)).toEqual(['string_field', 'email']);
|
||||
expect(fragment.sql).toBe(`"data"->>'email'`);
|
||||
expect(fragment.getKnexBindings()).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -733,23 +652,23 @@ describe('SQLOperator', () => {
|
||||
const cond2 = sql`status = ${'active'}`;
|
||||
const fragment = SQLFragmentHelpers.and(cond1, cond2);
|
||||
|
||||
expect(fragment.sql).toBe('(age >= ?) AND (status = ?)');
|
||||
expect(fragment.getKnexBindings(getColumnForField)).toEqual([18, 'active']);
|
||||
expect(fragment.sql).toBe('age >= ? AND status = ?');
|
||||
expect(fragment.getKnexBindings()).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(getColumnForField)).toEqual([18]);
|
||||
expect(fragment.sql).toBe('age >= ?');
|
||||
expect(fragment.getKnexBindings()).toEqual([18]);
|
||||
});
|
||||
|
||||
it('handles empty conditions in AND', () => {
|
||||
const fragment = SQLFragmentHelpers.and();
|
||||
|
||||
expect(fragment.sql).toBe('1 = 1');
|
||||
expect(fragment.getKnexBindings(getColumnForField)).toEqual([]);
|
||||
expect(fragment.getKnexBindings()).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -759,23 +678,23 @@ describe('SQLOperator', () => {
|
||||
const cond2 = sql`status = ${'pending'}`;
|
||||
const fragment = SQLFragmentHelpers.or(cond1, cond2);
|
||||
|
||||
expect(fragment.sql).toBe('(status = ?) OR (status = ?)');
|
||||
expect(fragment.getKnexBindings(getColumnForField)).toEqual(['active', 'pending']);
|
||||
expect(fragment.sql).toBe('status = ? OR status = ?');
|
||||
expect(fragment.getKnexBindings()).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(getColumnForField)).toEqual(['active']);
|
||||
expect(fragment.sql).toBe('status = ?');
|
||||
expect(fragment.getKnexBindings()).toEqual(['active']);
|
||||
});
|
||||
|
||||
it('handles empty conditions in OR', () => {
|
||||
const fragment = SQLFragmentHelpers.or();
|
||||
|
||||
expect(fragment.sql).toBe('1 = 0');
|
||||
expect(fragment.getKnexBindings(getColumnForField)).toEqual([]);
|
||||
expect(fragment.getKnexBindings()).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -785,7 +704,7 @@ describe('SQLOperator', () => {
|
||||
const fragment = SQLFragmentHelpers.not(cond);
|
||||
|
||||
expect(fragment.sql).toBe('NOT (status = ?)');
|
||||
expect(fragment.getKnexBindings(getColumnForField)).toEqual(['deleted']);
|
||||
expect(fragment.getKnexBindings()).toEqual(['deleted']);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -795,35 +714,32 @@ describe('SQLOperator', () => {
|
||||
const fragment = SQLFragmentHelpers.group(cond);
|
||||
|
||||
expect(fragment.sql).toBe('(age >= ? AND age <= ?)');
|
||||
expect(fragment.getKnexBindings(getColumnForField)).toEqual([18, 65]);
|
||||
expect(fragment.getKnexBindings()).toEqual([18, 65]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('complex combinations', () => {
|
||||
it('builds complex queries with multiple helpers', () => {
|
||||
const fragment = SQLFragmentHelpers.and(
|
||||
SQLFragmentHelpers.between('intField', 18, 65),
|
||||
SQLFragmentHelpers.group(
|
||||
SQLFragmentHelpers.or(
|
||||
SQLFragmentHelpers.inArray('stringField', ['active', 'premium']),
|
||||
sql`role = ${'admin'}`,
|
||||
),
|
||||
),
|
||||
SQLFragmentHelpers.isNotNull('testIndexedField'),
|
||||
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'),
|
||||
);
|
||||
|
||||
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(getColumnForField)).toEqual([
|
||||
'number_field',
|
||||
expect(fragment.getKnexBindings()).toEqual([
|
||||
'age',
|
||||
18,
|
||||
65,
|
||||
'string_field',
|
||||
'status',
|
||||
'active',
|
||||
'premium',
|
||||
'admin',
|
||||
'test_index',
|
||||
'email',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
+28
-28
@@ -13,12 +13,11 @@ import { v7 as uuidv7 } from 'uuid';
|
||||
|
||||
import {
|
||||
BasePostgresEntityDatabaseAdapter,
|
||||
NullsOrdering,
|
||||
OrderByOrdering,
|
||||
TableFieldMultiValueEqualityCondition,
|
||||
TableFieldSingleValueEqualityCondition,
|
||||
TableOrderByClause,
|
||||
TableQuerySelectionModifiers,
|
||||
TableQuerySelectionModifiersWithOrderByFragment,
|
||||
} from '../../BasePostgresEntityDatabaseAdapter';
|
||||
import { SQLFragment } from '../../SQLOperator';
|
||||
|
||||
@@ -106,8 +105,11 @@ export class StubPostgresDatabaseAdapter<
|
||||
return results[0] ?? null;
|
||||
}
|
||||
|
||||
private static compareByOrderBys<TFields extends Record<string, any>>(
|
||||
orderBys: TableOrderByClause<TFields>[],
|
||||
private static compareByOrderBys(
|
||||
orderBys: {
|
||||
columnName: string;
|
||||
order: OrderByOrdering;
|
||||
}[],
|
||||
objectA: { [key: string]: any },
|
||||
objectB: { [key: string]: any },
|
||||
): 0 | 1 | -1 {
|
||||
@@ -116,30 +118,19 @@ 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
|
||||
@@ -147,6 +138,15 @@ 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<TFields>,
|
||||
querySelectionModifiers: TableQuerySelectionModifiers,
|
||||
): 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<TFields>,
|
||||
_querySelectionModifiers: TableQuerySelectionModifiers,
|
||||
): 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<TFields>,
|
||||
_querySelectionModifiers: TableQuerySelectionModifiers<TFields>,
|
||||
_sqlFragment: SQLFragment,
|
||||
_querySelectionModifiers: TableQuerySelectionModifiersWithOrderByFragment,
|
||||
): Promise<object[]> {
|
||||
throw new Error('SQL fragments not supported for StubDatabaseAdapter');
|
||||
}
|
||||
|
||||
@@ -22,5 +22,4 @@ 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,46 +12,23 @@ import assert from 'assert';
|
||||
import {
|
||||
BasePostgresEntityDatabaseAdapter,
|
||||
FieldEqualityCondition,
|
||||
NullsOrdering,
|
||||
OrderByOrdering,
|
||||
PostgresOrderByClause,
|
||||
PostgresQuerySelectionModifiers,
|
||||
PostgresQuerySelectionModifiersWithOrderByFragment,
|
||||
PostgresQuerySelectionModifiersWithOrderByRaw,
|
||||
} from '../BasePostgresEntityDatabaseAdapter';
|
||||
import { PaginationStrategy } from '../PaginationStrategy';
|
||||
import { SQLFragment, SQLFragmentHelpers, identifier, unsafeRaw, sql } from '../SQLOperator';
|
||||
import { DistributiveOmit, NonNullableKeys } from './utilityTypes';
|
||||
import { SQLFragment, SQLFragmentHelpers, identifier, raw, sql } from '../SQLOperator';
|
||||
|
||||
interface DataManagerStandardSpecification<TFields extends Record<string, any>> {
|
||||
strategy: PaginationStrategy.STANDARD;
|
||||
orderBy: readonly PostgresOrderByClause<TFields>[];
|
||||
orderBy: 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: readonly DataManagerSearchFieldSpecification<TFields>[];
|
||||
fields: (keyof TFields)[];
|
||||
}
|
||||
|
||||
interface DataManagerILikeSearchSpecification<
|
||||
@@ -65,7 +42,7 @@ interface DataManagerTrigramSearchSpecification<
|
||||
> extends DataManagerSearchSpecificationBase<TFields> {
|
||||
strategy: PaginationStrategy.TRIGRAM_SEARCH;
|
||||
threshold: number;
|
||||
extraOrderByFields?: readonly DataManagerSearchFieldSpecification<TFields>[];
|
||||
extraOrderByFields?: (keyof TFields)[];
|
||||
}
|
||||
|
||||
type DataManagerSearchSpecification<TFields extends Record<string, any>> =
|
||||
@@ -77,7 +54,7 @@ type DataManagerPaginationSpecification<TFields extends Record<string, any>> =
|
||||
| DataManagerSearchSpecification<TFields>;
|
||||
|
||||
interface BaseUnifiedPaginationArgs<TFields extends Record<string, any>> {
|
||||
where?: SQLFragment<TFields>;
|
||||
where?: SQLFragment;
|
||||
pagination: DataManagerPaginationSpecification<TFields>;
|
||||
}
|
||||
|
||||
@@ -85,18 +62,14 @@ 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>> =
|
||||
@@ -125,7 +98,7 @@ export interface PageInfo {
|
||||
* Relay-style Connection type
|
||||
*/
|
||||
export interface Connection<TNode> {
|
||||
edges: readonly Edge<TNode>[];
|
||||
edges: Edge<TNode>[];
|
||||
pageInfo: PageInfo;
|
||||
}
|
||||
|
||||
@@ -137,13 +110,15 @@ enum PaginationDirection {
|
||||
const CURSOR_ROW_TABLE_ALIAS = 'cursor_row';
|
||||
|
||||
interface PaginationProvider<TFields extends Record<string, any>, TIDField extends keyof TFields> {
|
||||
whereClause: SQLFragment<TFields> | undefined;
|
||||
buildOrderBy: (direction: PaginationDirection) => readonly PostgresOrderByClause<TFields>[];
|
||||
whereClause: SQLFragment | undefined;
|
||||
buildOrderBy: (direction: PaginationDirection) => {
|
||||
clauses?: PostgresOrderByClause<TFields>[];
|
||||
fragment?: SQLFragment | undefined;
|
||||
};
|
||||
buildCursorCondition: (
|
||||
decodedCursorId: TFields[TIDField],
|
||||
direction: PaginationDirection,
|
||||
orderByClauses: readonly PostgresOrderByClause<TFields>[],
|
||||
) => SQLFragment<TFields>;
|
||||
) => SQLFragment;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -174,11 +149,9 @@ export class EntityKnexDataManager<
|
||||
*/
|
||||
async loadManyByFieldEqualityConjunctionAsync<N extends keyof TFields>(
|
||||
queryContext: EntityQueryContext,
|
||||
fieldEqualityOperands: readonly FieldEqualityCondition<TFields, N>[],
|
||||
fieldEqualityOperands: FieldEqualityCondition<TFields, N>[],
|
||||
querySelectionModifiers: PostgresQuerySelectionModifiers<TFields>,
|
||||
): Promise<readonly Readonly<TFields>[]> {
|
||||
EntityKnexDataManager.validateOrderByClauses(querySelectionModifiers.orderBy);
|
||||
|
||||
return await timeAndLogLoadEventAsync(
|
||||
this.metricsAdapter,
|
||||
EntityMetricsLoadType.LOAD_MANY_EQUALITY_CONJUNCTION,
|
||||
@@ -205,11 +178,9 @@ export class EntityKnexDataManager<
|
||||
async loadManyByRawWhereClauseAsync(
|
||||
queryContext: EntityQueryContext,
|
||||
rawWhereClause: string,
|
||||
bindings: readonly any[] | object,
|
||||
querySelectionModifiers: PostgresQuerySelectionModifiers<TFields>,
|
||||
bindings: any[] | object,
|
||||
querySelectionModifiers: PostgresQuerySelectionModifiersWithOrderByRaw<TFields>,
|
||||
): Promise<readonly Readonly<TFields>[]> {
|
||||
EntityKnexDataManager.validateOrderByClauses(querySelectionModifiers.orderBy);
|
||||
|
||||
return await timeAndLogLoadEventAsync(
|
||||
this.metricsAdapter,
|
||||
EntityMetricsLoadType.LOAD_MANY_RAW,
|
||||
@@ -227,11 +198,9 @@ export class EntityKnexDataManager<
|
||||
|
||||
async loadManyBySQLFragmentAsync(
|
||||
queryContext: EntityQueryContext,
|
||||
sqlFragment: SQLFragment<TFields>,
|
||||
querySelectionModifiers: PostgresQuerySelectionModifiers<TFields>,
|
||||
sqlFragment: SQLFragment,
|
||||
querySelectionModifiers: PostgresQuerySelectionModifiersWithOrderByFragment<TFields>,
|
||||
): Promise<readonly Readonly<TFields>[]> {
|
||||
EntityKnexDataManager.validateOrderByClauses(querySelectionModifiers.orderBy);
|
||||
|
||||
return await timeAndLogLoadEventAsync(
|
||||
this.metricsAdapter,
|
||||
EntityMetricsLoadType.LOAD_MANY_SQL,
|
||||
@@ -249,15 +218,6 @@ 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
|
||||
@@ -270,22 +230,18 @@ 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: readonly (keyof TFields | SQLFragment<TFields>)[] =
|
||||
augmentedOrderByClauses.map((order) =>
|
||||
'fieldName' in order ? order.fieldName : order.fieldFragment,
|
||||
);
|
||||
const fieldsToUseInPostgresTupleCursor = augmentedOrderByClauses.map(
|
||||
(order) => order.fieldName,
|
||||
);
|
||||
|
||||
// Create strategy for regular pagination
|
||||
const strategy: PaginationProvider<TFields, TIDField> = {
|
||||
whereClause: where,
|
||||
buildOrderBy: (direction: PaginationDirection) => {
|
||||
// For backward pagination, we flip the ORDER BY and NULLS direction to fetch records
|
||||
buildOrderBy: (direction) => {
|
||||
// For backward pagination, we flip the ORDER BY 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.
|
||||
@@ -293,30 +249,20 @@ 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
|
||||
return direction === PaginationDirection.FORWARD
|
||||
? augmentedOrderByClauses
|
||||
: augmentedOrderByClauses.map(
|
||||
(clause): PostgresOrderByClause<TFields> => ({
|
||||
...clause,
|
||||
const clauses =
|
||||
direction === PaginationDirection.FORWARD
|
||||
? augmentedOrderByClauses
|
||||
: augmentedOrderByClauses.map((clause) => ({
|
||||
fieldName: clause.fieldName,
|
||||
order:
|
||||
clause.order === OrderByOrdering.ASCENDING
|
||||
? OrderByOrdering.DESCENDING
|
||||
: OrderByOrdering.ASCENDING,
|
||||
nulls: clause.nulls
|
||||
? EntityKnexDataManager.flipNullsOrderingSpread(clause.nulls)
|
||||
: undefined,
|
||||
}),
|
||||
);
|
||||
},
|
||||
buildCursorCondition: (decodedCursorId, _direction, orderByClauses) => {
|
||||
// all clauses are guaranteed to have the same order due to validation, so we can just look at the first one for effective ordering
|
||||
const effectiveOrdering = orderByClauses[0]?.order ?? OrderByOrdering.ASCENDING;
|
||||
return this.buildCursorCondition(
|
||||
decodedCursorId,
|
||||
fieldsToUseInPostgresTupleCursor,
|
||||
effectiveOrdering,
|
||||
);
|
||||
}));
|
||||
return { clauses };
|
||||
},
|
||||
buildCursorCondition: (decodedCursorId, direction) =>
|
||||
this.buildCursorCondition(decodedCursorId, fieldsToUseInPostgresTupleCursor, direction),
|
||||
};
|
||||
|
||||
return await this.loadPageInternalAsync(queryContext, args, strategy);
|
||||
@@ -328,7 +274,12 @@ 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 { searchWhere, searchOrderByClauses } = this.buildSearchConditionAndOrderBy(search);
|
||||
const direction =
|
||||
'first' in args ? PaginationDirection.FORWARD : PaginationDirection.BACKWARD;
|
||||
const { searchWhere, searchOrderByFragment } = this.buildSearchConditionAndOrderBy(
|
||||
search,
|
||||
direction,
|
||||
);
|
||||
|
||||
// Combine WHERE conditions: base where + search where
|
||||
const whereClause =
|
||||
@@ -344,18 +295,8 @@ export class EntityKnexDataManager<
|
||||
// Create strategy for search pagination
|
||||
const strategy: PaginationProvider<TFields, TIDField> = {
|
||||
whereClause,
|
||||
buildOrderBy: (direction) => {
|
||||
return direction === PaginationDirection.FORWARD
|
||||
? searchOrderByClauses
|
||||
: searchOrderByClauses.map(
|
||||
(clause): DistributiveOmit<PostgresOrderByClause<TFields>, 'nulls'> => ({
|
||||
...clause,
|
||||
order:
|
||||
clause.order === OrderByOrdering.ASCENDING
|
||||
? OrderByOrdering.DESCENDING
|
||||
: OrderByOrdering.ASCENDING,
|
||||
}),
|
||||
);
|
||||
buildOrderBy: () => {
|
||||
return { fragment: searchOrderByFragment };
|
||||
},
|
||||
buildCursorCondition: (decodedCursorId, direction) =>
|
||||
search.strategy === PaginationStrategy.TRIGRAM_SEARCH
|
||||
@@ -363,10 +304,7 @@ export class EntityKnexDataManager<
|
||||
: this.buildCursorCondition(
|
||||
decodedCursorId,
|
||||
fieldsToUseInPostgresTupleCursor,
|
||||
// ILIKE search always orders ASC, so effective ordering matches direction
|
||||
direction === PaginationDirection.FORWARD
|
||||
? OrderByOrdering.ASCENDING
|
||||
: OrderByOrdering.DESCENDING,
|
||||
direction,
|
||||
),
|
||||
};
|
||||
|
||||
@@ -374,10 +312,6 @@ 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.
|
||||
@@ -391,7 +325,7 @@ export class EntityKnexDataManager<
|
||||
|
||||
// Validate pagination arguments
|
||||
const maxPageSize = this.databaseAdapter.paginationMaxPageSize;
|
||||
const isForward = 'first' in args && args.first !== undefined;
|
||||
const isForward = 'first' in args;
|
||||
if (isForward) {
|
||||
assert(
|
||||
Number.isInteger(args.first) && args.first > 0,
|
||||
@@ -423,23 +357,21 @@ 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,
|
||||
orderByClauses,
|
||||
)
|
||||
? paginationProvider.buildCursorCondition(decodedExternalCursorEntityID, direction)
|
||||
: null;
|
||||
|
||||
const whereClause = this.combineWhereConditions(baseWhere, cursorCondition);
|
||||
|
||||
// Get ordering from strategy
|
||||
const { clauses: orderByClauses, fragment: orderByFragment } =
|
||||
paginationProvider.buildOrderBy(direction);
|
||||
|
||||
// Determine query modifiers
|
||||
const queryModifiers: PostgresQuerySelectionModifiers<TFields> = {
|
||||
const queryModifiers: PostgresQuerySelectionModifiersWithOrderByFragment<TFields> = {
|
||||
...(orderByFragment !== undefined && { orderByFragment }),
|
||||
...(orderByClauses !== undefined && { orderBy: orderByClauses }),
|
||||
limit: limit + 1, // Fetch data with limit + 1 to check for more pages
|
||||
};
|
||||
@@ -482,9 +414,9 @@ export class EntityKnexDataManager<
|
||||
}
|
||||
|
||||
private combineWhereConditions(
|
||||
baseWhere: SQLFragment<TFields> | undefined,
|
||||
cursorCondition: SQLFragment<TFields> | null,
|
||||
): SQLFragment<TFields> {
|
||||
baseWhere: SQLFragment | undefined,
|
||||
cursorCondition: SQLFragment | null,
|
||||
): SQLFragment {
|
||||
const conditions = [baseWhere, cursorCondition].filter((it) => !!it);
|
||||
if (conditions.length === 0) {
|
||||
return sql`1 = 1`;
|
||||
@@ -499,63 +431,19 @@ export class EntityKnexDataManager<
|
||||
}
|
||||
|
||||
private augmentOrderByIfNecessary(
|
||||
orderBy: readonly PostgresOrderByClause<TFields>[] | undefined,
|
||||
orderBy: PostgresOrderByClause<TFields>[] | undefined,
|
||||
idField: TIDField,
|
||||
): readonly PostgresOrderByClause<TFields>[] {
|
||||
): PostgresOrderByClause<TFields>[] {
|
||||
const clauses = orderBy ?? [];
|
||||
|
||||
// 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);
|
||||
// Always ensure ID is included for stability and cursor correctness
|
||||
const hasId = clauses.some((spec) => spec.fieldName === idField);
|
||||
if (!hasId) {
|
||||
const lastClauseOrder =
|
||||
clauses.length > 0 ? clauses[clauses.length - 1]!.order : OrderByOrdering.ASCENDING;
|
||||
return [...clauses, { fieldName: idField, order: lastClauseOrder }];
|
||||
return [...clauses, { fieldName: idField, order: OrderByOrdering.ASCENDING }];
|
||||
}
|
||||
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');
|
||||
}
|
||||
@@ -581,47 +469,15 @@ 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
|
||||
| SQLFragment<TFields>
|
||||
| DataManagerSearchFieldSQLFragmentFnSpecification<TFields>
|
||||
)[],
|
||||
effectiveOrdering: OrderByOrdering,
|
||||
): SQLFragment<TFields> {
|
||||
fieldsToUseInPostgresTupleCursor: readonly (keyof TFields)[],
|
||||
direction: PaginationDirection,
|
||||
): SQLFragment {
|
||||
// We build a tuple comparison for fieldsToUseInPostgresTupleCursor fields of the
|
||||
// entity identified by the external cursor to ensure correct pagination behavior
|
||||
// even in cases where multiple rows have the same value all fields other than id.
|
||||
// If the cursor entity has been deleted, the subquery returns no rows and the
|
||||
// comparison evaluates to NULL, filtering out all results (empty page).
|
||||
const operator = effectiveOrdering === OrderByOrdering.ASCENDING ? '>' : '<';
|
||||
const operator = direction === PaginationDirection.FORWARD ? '>' : '<';
|
||||
|
||||
const idField = getDatabaseFieldForEntityField(
|
||||
this.entityConfiguration,
|
||||
@@ -629,73 +485,77 @@ export class EntityKnexDataManager<
|
||||
);
|
||||
const tableName = this.entityConfiguration.tableName;
|
||||
|
||||
const postgresCursorFieldIdentifiers = fieldsToUseInPostgresTupleCursor.map((f) =>
|
||||
this.resolveSearchFieldToSQLFragment(f),
|
||||
);
|
||||
const postgresCursorFieldIdentifiers = fieldsToUseInPostgresTupleCursor.map((f) => {
|
||||
const dbField = getDatabaseFieldForEntityField(this.entityConfiguration, f);
|
||||
return sql`${identifier(dbField)}`;
|
||||
});
|
||||
|
||||
// Build left side of comparison (current row's computed values)
|
||||
const leftSide = SQLFragment.joinWithCommaSeparator(...postgresCursorFieldIdentifiers);
|
||||
const leftSide = SQLFragment.join(postgresCursorFieldIdentifiers, ', ');
|
||||
|
||||
// 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 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 SELECT fields for subquery
|
||||
const rightSideSubquery = sql`
|
||||
SELECT ${SQLFragment.joinWithCommaSeparator(...postgresCursorRowFieldIdentifiers)}
|
||||
FROM ${identifier(tableName)} AS ${unsafeRaw(CURSOR_ROW_TABLE_ALIAS)}
|
||||
WHERE ${unsafeRaw(CURSOR_ROW_TABLE_ALIAS)}.${identifier(idField)} = ${decodedExternalCursorEntityID}
|
||||
SELECT ${SQLFragment.join(postgresCursorRowFieldIdentifiers, ', ')}
|
||||
FROM ${identifier(tableName)} AS ${raw(CURSOR_ROW_TABLE_ALIAS)}
|
||||
WHERE ${raw(CURSOR_ROW_TABLE_ALIAS)}.${identifier(idField)} = ${decodedExternalCursorEntityID}
|
||||
`;
|
||||
return sql`(${leftSide}) ${unsafeRaw(operator)} (${rightSideSubquery})`;
|
||||
return sql`(${leftSide}) ${raw(operator)} (${rightSideSubquery})`;
|
||||
}
|
||||
|
||||
private buildILikeConditions(
|
||||
search: DataManagerSearchSpecification<TFields>,
|
||||
tableAlias?: typeof CURSOR_ROW_TABLE_ALIAS,
|
||||
): readonly SQLFragment<TFields>[] {
|
||||
): SQLFragment[] {
|
||||
return search.fields.map((field) => {
|
||||
const fieldFragment = this.resolveSearchFieldToSQLFragment(field, tableAlias);
|
||||
return sql`${fieldFragment} ILIKE ${'%' + EntityKnexDataManager.escapeILikePattern(search.term) + '%'}`;
|
||||
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) + '%'}`;
|
||||
});
|
||||
}
|
||||
|
||||
private buildTrigramSimilarityExpressions(
|
||||
search: DataManagerSearchSpecification<TFields>,
|
||||
tableAlias?: typeof CURSOR_ROW_TABLE_ALIAS,
|
||||
): readonly SQLFragment<TFields>[] {
|
||||
): SQLFragment[] {
|
||||
return search.fields.map((field) => {
|
||||
const fieldFragment = this.resolveSearchFieldToSQLFragment(field, tableAlias);
|
||||
return sql`similarity(${fieldFragment}, ${search.term})`;
|
||||
const dbField = getDatabaseFieldForEntityField(this.entityConfiguration, field);
|
||||
const fieldIdentifier = tableAlias
|
||||
? sql`${raw(tableAlias)}.${identifier(dbField)}`
|
||||
: sql`${identifier(dbField)}`;
|
||||
return sql`similarity(${fieldIdentifier}, ${search.term})`;
|
||||
});
|
||||
}
|
||||
|
||||
private buildTrigramExactMatchCaseExpression(
|
||||
search: DataManagerSearchSpecification<TFields>,
|
||||
tableAlias?: typeof CURSOR_ROW_TABLE_ALIAS,
|
||||
): SQLFragment<TFields> {
|
||||
): SQLFragment {
|
||||
const ilikeConditions = this.buildILikeConditions(search, tableAlias);
|
||||
return sql`CASE WHEN ${SQLFragmentHelpers.or(...ilikeConditions)} THEN 1 ELSE 0 END`;
|
||||
return sql`CASE WHEN ${SQLFragment.join(ilikeConditions, ' OR ')} THEN 1 ELSE 0 END`;
|
||||
}
|
||||
|
||||
private buildTrigramSimilarityGreatestExpression(
|
||||
search: DataManagerSearchSpecification<TFields>,
|
||||
tableAlias?: typeof CURSOR_ROW_TABLE_ALIAS,
|
||||
): SQLFragment<TFields> {
|
||||
): SQLFragment {
|
||||
const similarityExprs = this.buildTrigramSimilarityExpressions(search, tableAlias);
|
||||
return sql`GREATEST(${SQLFragment.joinWithCommaSeparator(...similarityExprs)})`;
|
||||
return sql`GREATEST(${SQLFragment.join(similarityExprs, ', ')})`;
|
||||
}
|
||||
|
||||
private buildTrigramCursorCondition(
|
||||
search: DataManagerTrigramSearchSpecification<TFields>,
|
||||
decodedExternalCursorEntityID: TFields[TIDField],
|
||||
direction: PaginationDirection,
|
||||
): SQLFragment<TFields> {
|
||||
// For TRIGRAM search, we compute the similarity values using a subquery, similar to normal cursor.
|
||||
// If the cursor entity has been deleted, the subquery returns no rows and the
|
||||
// comparison evaluates to NULL, filtering out all results (empty page).
|
||||
): SQLFragment {
|
||||
// For TRIGRAM search, we compute the similarity values using a subquery, similar to normal cursor
|
||||
const operator = direction === PaginationDirection.FORWARD ? '<' : '>';
|
||||
const idField = getDatabaseFieldForEntityField(
|
||||
this.entityConfiguration,
|
||||
@@ -708,14 +568,15 @@ export class EntityKnexDataManager<
|
||||
// Build extra order by fields
|
||||
const extraOrderByFields = search.extraOrderByFields;
|
||||
const extraFields =
|
||||
extraOrderByFields?.map((f) => this.resolveSearchFieldToSQLFragment(f)) ?? [];
|
||||
extraOrderByFields?.map((f) => {
|
||||
const dbField = getDatabaseFieldForEntityField(this.entityConfiguration, f);
|
||||
return sql`${identifier(dbField)}`;
|
||||
}) ?? [];
|
||||
|
||||
// Build left side of comparison (current row's computed values)
|
||||
const leftSide = SQLFragment.joinWithCommaSeparator(
|
||||
exactMatchExpr,
|
||||
similarityExpr,
|
||||
...extraFields,
|
||||
sql`${identifier(idField)}`,
|
||||
const leftSide = SQLFragment.join(
|
||||
[exactMatchExpr, similarityExpr, ...extraFields, sql`${identifier(idField)}`],
|
||||
', ',
|
||||
);
|
||||
|
||||
// Build right side using subquery to get computed values for cursor entity
|
||||
@@ -731,52 +592,50 @@ export class EntityKnexDataManager<
|
||||
);
|
||||
|
||||
const cursorExtraFields =
|
||||
extraOrderByFields?.map((f) =>
|
||||
this.resolveSearchFieldToSQLFragment(f, CURSOR_ROW_TABLE_ALIAS),
|
||||
) ?? [];
|
||||
extraOrderByFields?.map((f) => {
|
||||
const dbField = getDatabaseFieldForEntityField(this.entityConfiguration, f);
|
||||
return sql`${raw(CURSOR_ROW_TABLE_ALIAS)}.${identifier(dbField)}`;
|
||||
}) ?? [];
|
||||
|
||||
// Build SELECT fields for subquery
|
||||
const selectFields: SQLFragment<TFields>[] = [
|
||||
const selectFields = [
|
||||
cursorExactMatchExpr,
|
||||
cursorSimilarityExpr,
|
||||
...cursorExtraFields,
|
||||
sql`${unsafeRaw(CURSOR_ROW_TABLE_ALIAS)}.${identifier(idField)}`,
|
||||
sql`${raw(CURSOR_ROW_TABLE_ALIAS)}.${identifier(idField)}`,
|
||||
];
|
||||
|
||||
const rightSideSubquery = sql`
|
||||
SELECT ${SQLFragment.joinWithCommaSeparator(...selectFields)}
|
||||
FROM ${identifier(this.entityConfiguration.tableName)} AS ${unsafeRaw(CURSOR_ROW_TABLE_ALIAS)}
|
||||
WHERE ${unsafeRaw(CURSOR_ROW_TABLE_ALIAS)}.${identifier(idField)} = ${decodedExternalCursorEntityID}
|
||||
SELECT ${SQLFragment.join(selectFields, ', ')}
|
||||
FROM ${identifier(this.entityConfiguration.tableName)} AS ${raw(CURSOR_ROW_TABLE_ALIAS)}
|
||||
WHERE ${raw(CURSOR_ROW_TABLE_ALIAS)}.${identifier(idField)} = ${decodedExternalCursorEntityID}
|
||||
`;
|
||||
|
||||
return sql`(${leftSide}) ${unsafeRaw(operator)} (${rightSideSubquery})`;
|
||||
return sql`(${leftSide}) ${raw(operator)} (${rightSideSubquery})`;
|
||||
}
|
||||
|
||||
private buildSearchConditionAndOrderBy(search: DataManagerSearchSpecification<TFields>): {
|
||||
searchWhere: SQLFragment<TFields>;
|
||||
searchOrderByClauses: readonly DistributiveOmit<PostgresOrderByClause<TFields>, 'nulls'>[];
|
||||
private buildSearchConditionAndOrderBy(
|
||||
search: DataManagerSearchSpecification<TFields>,
|
||||
direction: PaginationDirection,
|
||||
): {
|
||||
searchWhere: SQLFragment;
|
||||
searchOrderByFragment: SQLFragment | undefined;
|
||||
} {
|
||||
switch (search.strategy) {
|
||||
case PaginationStrategy.ILIKE_SEARCH: {
|
||||
const conditions = this.buildILikeConditions(search);
|
||||
|
||||
// Order by search fields + ID to match cursor fields
|
||||
const searchOrderByClauses: PostgresOrderByClause<TFields>[] = [
|
||||
...search.fields.map(
|
||||
(field): PostgresOrderByClause<TFields> => ({
|
||||
fieldFragment: this.resolveSearchFieldToSQLFragment(field),
|
||||
order: OrderByOrdering.ASCENDING,
|
||||
}),
|
||||
),
|
||||
{
|
||||
fieldName: this.entityConfiguration.idField,
|
||||
order: OrderByOrdering.ASCENDING,
|
||||
},
|
||||
];
|
||||
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',
|
||||
)}`;
|
||||
});
|
||||
|
||||
return {
|
||||
searchWhere: conditions.length > 0 ? SQLFragmentHelpers.or(...conditions) : sql`1 = 0`,
|
||||
searchOrderByClauses,
|
||||
searchWhere: conditions.length > 0 ? SQLFragment.join(conditions, ' OR ') : sql`1 = 0`,
|
||||
searchOrderByFragment: SQLFragment.join(orderByFields, ', '),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -795,37 +654,79 @@ export class EntityKnexDataManager<
|
||||
// Combine exact matches (ILIKE) with similarity
|
||||
const allConditions = [...ilikeConditions, ...conditions];
|
||||
|
||||
// Build ORDER BY clauses for trigram search:
|
||||
// 1. Exact matches first (ILIKE)
|
||||
// 2. Then by similarity score
|
||||
// 3. Then by extra fields and ID field for stability
|
||||
const searchOrderByClauses: DistributiveOmit<PostgresOrderByClause<TFields>, 'nulls'>[] = [
|
||||
{
|
||||
fieldFragment: this.buildTrigramExactMatchCaseExpression(search),
|
||||
order: OrderByOrdering.DESCENDING,
|
||||
},
|
||||
{
|
||||
fieldFragment: this.buildTrigramSimilarityGreatestExpression(search),
|
||||
order: OrderByOrdering.DESCENDING,
|
||||
},
|
||||
...(search.extraOrderByFields ?? []).map((field) => ({
|
||||
fieldFragment: this.resolveSearchFieldToSQLFragment(field),
|
||||
order: OrderByOrdering.DESCENDING,
|
||||
})),
|
||||
{
|
||||
fieldName: this.entityConfiguration.idField,
|
||||
order: OrderByOrdering.DESCENDING,
|
||||
},
|
||||
];
|
||||
// Build ORDER BY components
|
||||
const exactMatchPriority = this.buildTrigramExactMatchPriority(ilikeConditions, direction);
|
||||
const similarityRanking = this.buildTrigramSimilarityRanking(search, direction);
|
||||
const tieBreakers = this.buildTrigramTieBreakers(search, direction);
|
||||
|
||||
return {
|
||||
searchWhere: SQLFragmentHelpers.or(...allConditions),
|
||||
searchOrderByClauses,
|
||||
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],
|
||||
', ',
|
||||
),
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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,7 +19,6 @@ 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,
|
||||
@@ -38,7 +37,7 @@ export function getKnexEntityLoaderFactory<
|
||||
TPrivacyPolicy,
|
||||
TSelectedFields
|
||||
>,
|
||||
viewerContext: TViewerContext2,
|
||||
viewerContext: TViewerContext,
|
||||
): KnexEntityLoaderFactory<
|
||||
TFields,
|
||||
TIDField,
|
||||
|
||||
@@ -1,11 +0,0 @@
|
||||
/* 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,7 +20,6 @@ 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,
|
||||
@@ -39,7 +38,7 @@ export function knexLoader<
|
||||
TMPrivacyPolicy,
|
||||
TMSelectedFields
|
||||
>,
|
||||
viewerContext: TMViewerContext2,
|
||||
viewerContext: TMViewerContext,
|
||||
queryContext: EntityQueryContext = viewerContext
|
||||
.getViewerScopedEntityCompanionForClass(entityClass)
|
||||
.getQueryContextProvider()
|
||||
@@ -70,7 +69,6 @@ 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,
|
||||
@@ -89,7 +87,7 @@ export function knexLoaderWithAuthorizationResults<
|
||||
TMPrivacyPolicy,
|
||||
TMSelectedFields
|
||||
>,
|
||||
viewerContext: TMViewerContext2,
|
||||
viewerContext: TMViewerContext,
|
||||
queryContext: EntityQueryContext = viewerContext
|
||||
.getViewerScopedEntityCompanionForClass(entityClass)
|
||||
.getQueryContextProvider()
|
||||
|
||||
@@ -3,23 +3,6 @@
|
||||
All notable changes to this project will be documented in this file.
|
||||
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
|
||||
|
||||
# [0.57.0](https://github.com/expo/entity/compare/v0.56.0...v0.57.0) (2026-02-28)
|
||||
|
||||
**Note:** Version bump only for package @expo/entity-example
|
||||
|
||||
# [0.56.0](https://github.com/expo/entity/compare/v0.55.0...v0.56.0) (2026-02-28)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **deps:** update dependency @apollo/server to v5.4.0 [security] ([#420](https://github.com/expo/entity/issues/420)) ([cd81c5a](https://github.com/expo/entity/commit/cd81c5a5b6be43ef60248eeea4031a883dd1eae8))
|
||||
- **deps:** update dependency @koa/bodyparser to v6.1.0 ([#405](https://github.com/expo/entity/issues/405)) ([2dda22d](https://github.com/expo/entity/commit/2dda22d761f87851e3fcccc8443597daf8ac42e8))
|
||||
- **deps:** update dependency @koa/router to v15.3.0 ([#406](https://github.com/expo/entity/issues/406)) ([9e88cf6](https://github.com/expo/entity/commit/9e88cf6aab76902c65994d28de8dab73f1267021))
|
||||
- **deps:** update dependency graphql to v16.13.0 ([#479](https://github.com/expo/entity/issues/479)) ([976e2c3](https://github.com/expo/entity/commit/976e2c33867da71a2920d050c4086e2af8f6d54d))
|
||||
- **deps:** update dependency koa to v3.1.2 [security] ([#484](https://github.com/expo/entity/issues/484)) ([b258dad](https://github.com/expo/entity/commit/b258dad2ed69c473f04d57d26910eab2032d142c))
|
||||
- eliminate some unnecessary any casts ([#448](https://github.com/expo/entity/issues/448)) ([cc78029](https://github.com/expo/entity/commit/cc78029117c14510b0934b5e0d19876a45570f8d))
|
||||
- move EntityPrivacyUtils back into core package ([#429](https://github.com/expo/entity/issues/429)) ([56ec27d](https://github.com/expo/entity/commit/56ec27d5f201d83930a2cc05dfc380a054bff1e1))
|
||||
- refactor install method to instead be free functions ([#441](https://github.com/expo/entity/issues/441)) ([51d632d](https://github.com/expo/entity/commit/51d632da1619dc039d5c41ce6533f63254ec4337)), closes [#410](https://github.com/expo/entity/issues/410)
|
||||
|
||||
# [0.55.0](https://github.com/expo/entity/compare/v0.54.0...v0.55.0) (2026-01-27)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"name": "@expo/entity-example",
|
||||
"type": "module",
|
||||
"private": true,
|
||||
"version": "0.57.0",
|
||||
"version": "0.55.0",
|
||||
"description": "An example integration of the @expo/entity framework",
|
||||
"scripts": {
|
||||
"build": "tsc --build",
|
||||
@@ -29,8 +29,8 @@
|
||||
"@koa/bodyparser": "6.1.0",
|
||||
"@koa/cors": "5.0.0",
|
||||
"@koa/router": "15.3.0",
|
||||
"graphql": "16.13.0",
|
||||
"koa": "3.1.2"
|
||||
"graphql": "16.12.0",
|
||||
"koa": "3.1.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@jest/globals": "30.2.0",
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { ApolloServer, type BaseContext } from '@apollo/server';
|
||||
import { ApolloServer } from '@apollo/server';
|
||||
import { koaMiddleware } from '@as-integrations/koa';
|
||||
import { EntityCompanionProvider } from '@expo/entity';
|
||||
import { bodyParser } from '@koa/bodyparser';
|
||||
@@ -23,10 +23,6 @@ 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>();
|
||||
|
||||
@@ -48,15 +44,15 @@ export async function createAppAsync(): Promise<Koa<ExampleState, ExampleContext
|
||||
router.use(notesRouter.routes());
|
||||
|
||||
// GraphQL routes
|
||||
const server = new ApolloServer<GraphQLContext>({
|
||||
const server = new ApolloServer({
|
||||
typeDefs,
|
||||
resolvers,
|
||||
});
|
||||
await server.start();
|
||||
router.post(
|
||||
'/graphql',
|
||||
// @ts-expect-error - @as-integrations/koa peers on @apollo/server ^4 (CJS types) but we use v5 (ESM types)
|
||||
koaMiddleware<GraphQLContext, ExampleState, ExampleContext>(server, {
|
||||
// https://github.com/apollographql/apollo-server/issues/7625
|
||||
koaMiddleware(server as any, {
|
||||
context: async ({ ctx }: { ctx: ExampleContext }) => ({
|
||||
viewerContext: ctx.state.viewerContext,
|
||||
}),
|
||||
|
||||
@@ -3,14 +3,6 @@
|
||||
All notable changes to this project will be documented in this file.
|
||||
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
|
||||
|
||||
# [0.57.0](https://github.com/expo/entity/compare/v0.56.0...v0.57.0) (2026-02-28)
|
||||
|
||||
**Note:** Version bump only for package @expo/entity-full-integration-tests
|
||||
|
||||
# [0.56.0](https://github.com/expo/entity/compare/v0.55.0...v0.56.0) (2026-02-28)
|
||||
|
||||
**Note:** Version bump only for package @expo/entity-full-integration-tests
|
||||
|
||||
# [0.55.0](https://github.com/expo/entity/compare/v0.54.0...v0.55.0) (2026-01-27)
|
||||
|
||||
**Note:** Version bump only for package @expo/entity-full-integration-tests
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@expo/entity-full-integration-tests",
|
||||
"private": true,
|
||||
"version": "0.57.0",
|
||||
"version": "0.55.0",
|
||||
"description": "Full redis and knex integration tests for the entity framework",
|
||||
"scripts": {
|
||||
"build": "tsc --build",
|
||||
|
||||
+1
-1
@@ -181,7 +181,7 @@ describe('Entity cache inconsistency', () => {
|
||||
|
||||
openBarrier2!();
|
||||
})(),
|
||||
viewerContext.runInTransactionForDatabaseAdapterFlavorAsync(
|
||||
viewerContext.runInTransactionForDatabaseAdaptorFlavorAsync(
|
||||
'postgres',
|
||||
async (queryContext) => {
|
||||
await TestEntity.updater(entity1, queryContext)
|
||||
|
||||
@@ -3,14 +3,6 @@
|
||||
All notable changes to this project will be documented in this file.
|
||||
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
|
||||
|
||||
# [0.57.0](https://github.com/expo/entity/compare/v0.56.0...v0.57.0) (2026-02-28)
|
||||
|
||||
**Note:** Version bump only for package @expo/entity-ip-address-field
|
||||
|
||||
# [0.56.0](https://github.com/expo/entity/compare/v0.55.0...v0.56.0) (2026-02-28)
|
||||
|
||||
**Note:** Version bump only for package @expo/entity-ip-address-field
|
||||
|
||||
# [0.55.0](https://github.com/expo/entity/compare/v0.54.0...v0.55.0) (2026-01-27)
|
||||
|
||||
**Note:** Version bump only for package @expo/entity-ip-address-field
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@expo/entity-ip-address-field",
|
||||
"version": "0.57.0",
|
||||
"version": "0.55.0",
|
||||
"description": "IP address EntityField definitions for @expo/entity",
|
||||
"files": [
|
||||
"build",
|
||||
|
||||
@@ -3,16 +3,6 @@
|
||||
All notable changes to this project will be documented in this file.
|
||||
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
|
||||
|
||||
# [0.57.0](https://github.com/expo/entity/compare/v0.56.0...v0.57.0) (2026-02-28)
|
||||
|
||||
**Note:** Version bump only for package @expo/entity-secondary-cache-local-memory
|
||||
|
||||
# [0.56.0](https://github.com/expo/entity/compare/v0.55.0...v0.56.0) (2026-02-28)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Reorganize constructionUtils to make non-public ([#454](https://github.com/expo/entity/issues/454)) ([d9377a2](https://github.com/expo/entity/commit/d9377a290b7e0c6803f9e83537eddb7204bc9320)), closes [#410](https://github.com/expo/entity/issues/410)
|
||||
|
||||
# [0.55.0](https://github.com/expo/entity/compare/v0.54.0...v0.55.0) (2026-01-27)
|
||||
|
||||
**Note:** Version bump only for package @expo/entity-secondary-cache-local-memory
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@expo/entity-secondary-cache-local-memory",
|
||||
"version": "0.57.0",
|
||||
"version": "0.55.0",
|
||||
"description": "Local memory secondary cache for @expo/entity",
|
||||
"files": [
|
||||
"build",
|
||||
|
||||
-36
@@ -1,11 +1,9 @@
|
||||
import {
|
||||
AlwaysAllowPrivacyPolicyRule,
|
||||
AuthorizationResultBasedEntityLoader,
|
||||
Entity,
|
||||
EntityCompanionDefinition,
|
||||
EntityCompanionProvider,
|
||||
EntityConfiguration,
|
||||
EntityConstructionUtils,
|
||||
EntityPrivacyPolicy,
|
||||
EntitySecondaryCacheLoader,
|
||||
IEntityMetricsAdapter,
|
||||
@@ -163,32 +161,6 @@ 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>> {
|
||||
@@ -221,10 +193,6 @@ describe(LocalMemorySecondaryEntityCache, () => {
|
||||
localMemoryTestEntityConfiguration,
|
||||
createTTLCache<LocalMemoryTestEntityFields>(),
|
||||
),
|
||||
EntitySecondaryCacheLoader.getConstructionUtilsForEntityClass(
|
||||
LocalMemoryTestEntity,
|
||||
viewerContext,
|
||||
),
|
||||
LocalMemoryTestEntity.loaderWithAuthorizationResults(viewerContext),
|
||||
);
|
||||
|
||||
@@ -261,10 +229,6 @@ describe(LocalMemorySecondaryEntityCache, () => {
|
||||
localMemoryTestEntityConfiguration,
|
||||
createTTLCache<LocalMemoryTestEntityFields>(),
|
||||
),
|
||||
EntitySecondaryCacheLoader.getConstructionUtilsForEntityClass(
|
||||
LocalMemoryTestEntity,
|
||||
viewerContext,
|
||||
),
|
||||
LocalMemoryTestEntity.loaderWithAuthorizationResults(viewerContext),
|
||||
);
|
||||
|
||||
|
||||
@@ -3,16 +3,6 @@
|
||||
All notable changes to this project will be documented in this file.
|
||||
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
|
||||
|
||||
# [0.57.0](https://github.com/expo/entity/compare/v0.56.0...v0.57.0) (2026-02-28)
|
||||
|
||||
**Note:** Version bump only for package @expo/entity-secondary-cache-redis
|
||||
|
||||
# [0.56.0](https://github.com/expo/entity/compare/v0.55.0...v0.56.0) (2026-02-28)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Reorganize constructionUtils to make non-public ([#454](https://github.com/expo/entity/issues/454)) ([d9377a2](https://github.com/expo/entity/commit/d9377a290b7e0c6803f9e83537eddb7204bc9320)), closes [#410](https://github.com/expo/entity/issues/410)
|
||||
|
||||
# [0.55.0](https://github.com/expo/entity/compare/v0.54.0...v0.55.0) (2026-01-27)
|
||||
|
||||
**Note:** Version bump only for package @expo/entity-secondary-cache-redis
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@expo/entity-secondary-cache-redis",
|
||||
"version": "0.57.0",
|
||||
"version": "0.55.0",
|
||||
"description": "Redis secondary cache for @expo/entity",
|
||||
"files": [
|
||||
"build",
|
||||
|
||||
+1
-31
@@ -1,10 +1,4 @@
|
||||
import {
|
||||
AuthorizationResultBasedEntityLoader,
|
||||
EntityConstructionUtils,
|
||||
EntitySecondaryCacheLoader,
|
||||
mapMapAsync,
|
||||
ViewerContext,
|
||||
} from '@expo/entity';
|
||||
import { EntitySecondaryCacheLoader, mapMapAsync, ViewerContext } from '@expo/entity';
|
||||
import {
|
||||
GenericRedisCacheContext,
|
||||
RedisCacheInvalidationStrategy,
|
||||
@@ -39,28 +33,6 @@ 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>> {
|
||||
@@ -121,7 +93,6 @@ describe(RedisSecondaryEntityCache, () => {
|
||||
genericRedisCacheContext,
|
||||
(loadParams) => `test-key-${loadParams.id}`,
|
||||
),
|
||||
EntitySecondaryCacheLoader.getConstructionUtilsForEntityClass(RedisTestEntity, viewerContext),
|
||||
RedisTestEntity.loaderWithAuthorizationResults(viewerContext),
|
||||
);
|
||||
|
||||
@@ -161,7 +132,6 @@ describe(RedisSecondaryEntityCache, () => {
|
||||
genericRedisCacheContext,
|
||||
(loadParams) => `test-key-${loadParams.id}`,
|
||||
),
|
||||
EntitySecondaryCacheLoader.getConstructionUtilsForEntityClass(RedisTestEntity, viewerContext),
|
||||
RedisTestEntity.loaderWithAuthorizationResults(viewerContext),
|
||||
);
|
||||
|
||||
|
||||
@@ -3,21 +3,6 @@
|
||||
All notable changes to this project will be documented in this file.
|
||||
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
|
||||
|
||||
# [0.57.0](https://github.com/expo/entity/compare/v0.56.0...v0.57.0) (2026-02-28)
|
||||
|
||||
**Note:** Version bump only for package @expo/entity-testing-utils
|
||||
|
||||
# [0.56.0](https://github.com/expo/entity/compare/v0.55.0...v0.56.0) (2026-02-28)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- move EntityPrivacyUtils back into core package ([#429](https://github.com/expo/entity/issues/429)) ([56ec27d](https://github.com/expo/entity/commit/56ec27d5f201d83930a2cc05dfc380a054bff1e1))
|
||||
- refactor install method to instead be free functions ([#441](https://github.com/expo/entity/issues/441)) ([51d632d](https://github.com/expo/entity/commit/51d632da1619dc039d5c41ce6533f63254ec4337)), closes [#410](https://github.com/expo/entity/issues/410)
|
||||
|
||||
### Features
|
||||
|
||||
- Add authorization action to EntityPrivacyPolicyRuleEvaluationContext ([#464](https://github.com/expo/entity/issues/464)) ([e71ae5f](https://github.com/expo/entity/commit/e71ae5fcbae95096e0d6a42a8c960896ab83ccfb))
|
||||
|
||||
# [0.55.0](https://github.com/expo/entity/compare/v0.54.0...v0.55.0) (2026-01-27)
|
||||
|
||||
**Note:** Version bump only for package @expo/entity-testing-utils
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@expo/entity-testing-utils",
|
||||
"version": "0.57.0",
|
||||
"version": "0.55.0",
|
||||
"description": "A package containing utilities for testing applications that use Entity",
|
||||
"files": [
|
||||
"build",
|
||||
@@ -36,7 +36,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@jest/globals": "30.2.0",
|
||||
"@types/lodash": "4.17.24",
|
||||
"@types/lodash": "4.17.23",
|
||||
"@types/node": "24.10.13",
|
||||
"typescript": "5.9.3"
|
||||
}
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import {
|
||||
EntityPrivacyPolicyEvaluationContext,
|
||||
EntityQueryContext,
|
||||
ReadonlyEntity,
|
||||
ViewerContext,
|
||||
PrivacyPolicyRule,
|
||||
RuleEvaluationResult,
|
||||
EntityPrivacyPolicyRuleEvaluationContext,
|
||||
} from '@expo/entity';
|
||||
import { describe, expect, test } from '@jest/globals';
|
||||
|
||||
@@ -17,7 +17,7 @@ export interface Case<
|
||||
> {
|
||||
viewerContext: TViewerContext;
|
||||
queryContext: EntityQueryContext;
|
||||
evaluationContext: EntityPrivacyPolicyRuleEvaluationContext<
|
||||
evaluationContext: EntityPrivacyPolicyEvaluationContext<
|
||||
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<EntityPrivacyPolicyRuleEvaluationContext<any, any, any, any, any>>()),
|
||||
instance(mock<EntityPrivacyPolicyEvaluationContext<any, any, any, any, any>>()),
|
||||
entity: anything(),
|
||||
}),
|
||||
],
|
||||
@@ -35,7 +35,7 @@ describe(describePrivacyPolicyRuleWithAsyncTestCase, () => {
|
||||
viewerContext: instance(mock(ViewerContext)),
|
||||
queryContext: instance(mock(EntityQueryContext)),
|
||||
evaluationContext:
|
||||
instance(mock<EntityPrivacyPolicyRuleEvaluationContext<any, any, any, any, any>>()),
|
||||
instance(mock<EntityPrivacyPolicyEvaluationContext<any, any, any, any, any>>()),
|
||||
entity: anything(),
|
||||
}),
|
||||
],
|
||||
|
||||
@@ -3,26 +3,6 @@
|
||||
All notable changes to this project will be documented in this file.
|
||||
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
|
||||
|
||||
# [0.57.0](https://github.com/expo/entity/compare/v0.56.0...v0.57.0) (2026-02-28)
|
||||
|
||||
**Note:** Version bump only for package @expo/entity
|
||||
|
||||
# [0.56.0](https://github.com/expo/entity/compare/v0.55.0...v0.56.0) (2026-02-28)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- eliminate some unnecessary any casts ([#448](https://github.com/expo/entity/issues/448)) ([cc78029](https://github.com/expo/entity/commit/cc78029117c14510b0934b5e0d19876a45570f8d))
|
||||
- move EntityPrivacyUtils back into core package ([#429](https://github.com/expo/entity/issues/429)) ([56ec27d](https://github.com/expo/entity/commit/56ec27d5f201d83930a2cc05dfc380a054bff1e1))
|
||||
- pass in augmentable classes to installation ([#437](https://github.com/expo/entity/issues/437)) ([f5c728b](https://github.com/expo/entity/commit/f5c728be565e01c0a8ace9ef51f6de8f49184dab)), closes [/github.com/expo/entity/pull/410#pullrequestreview-3765893898](https://github.com//github.com/expo/entity/pull/410/issues/pullrequestreview-3765893898)
|
||||
- refactor install method to instead be free functions ([#441](https://github.com/expo/entity/issues/441)) ([51d632d](https://github.com/expo/entity/commit/51d632da1619dc039d5c41ce6533f63254ec4337)), closes [#410](https://github.com/expo/entity/issues/410)
|
||||
- Reorganize constructionUtils to make non-public ([#454](https://github.com/expo/entity/issues/454)) ([d9377a2](https://github.com/expo/entity/commit/d9377a290b7e0c6803f9e83537eddb7204bc9320)), closes [#410](https://github.com/expo/entity/issues/410)
|
||||
- Type idField in EntityConfiguration as TIDField ([#425](https://github.com/expo/entity/issues/425)) ([be40bcf](https://github.com/expo/entity/commit/be40bcffa6e7db9b7ad75e1a80bbb48ff417c6c5))
|
||||
|
||||
### Features
|
||||
|
||||
- Add authorization action to EntityPrivacyPolicyRuleEvaluationContext ([#464](https://github.com/expo/entity/issues/464)) ([e71ae5f](https://github.com/expo/entity/commit/e71ae5fcbae95096e0d6a42a8c960896ab83ccfb))
|
||||
- Add paginated loader to entity-database-adapter-knex ([#422](https://github.com/expo/entity/issues/422)) ([58deffd](https://github.com/expo/entity/commit/58deffd426b0214073ede608cd47c04db2596d60))
|
||||
|
||||
# [0.55.0](https://github.com/expo/entity/compare/v0.54.0...v0.55.0) (2026-01-27)
|
||||
|
||||
### Features
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@expo/entity",
|
||||
"version": "0.57.0",
|
||||
"version": "0.55.0",
|
||||
"description": "A privacy-first data model",
|
||||
"files": [
|
||||
"build",
|
||||
@@ -34,7 +34,7 @@
|
||||
"devDependencies": {
|
||||
"@jest/globals": "30.2.0",
|
||||
"@types/invariant": "2.2.37",
|
||||
"@types/lodash": "4.17.24",
|
||||
"@types/lodash": "4.17.23",
|
||||
"@types/node": "24.10.13",
|
||||
"lodash": "4.17.23",
|
||||
"ts-mockito": "2.6.1",
|
||||
|
||||
@@ -16,7 +16,7 @@ export class AuthorizationResultBasedEntityAssociationLoader<
|
||||
TIDField extends keyof NonNullable<Pick<TFields, TSelectedFields>>,
|
||||
TViewerContext extends ViewerContext,
|
||||
TEntity extends ReadonlyEntity<TFields, TIDField, TViewerContext, TSelectedFields>,
|
||||
TSelectedFields extends keyof TFields = keyof TFields,
|
||||
TSelectedFields extends keyof TFields,
|
||||
> {
|
||||
constructor(
|
||||
private readonly entity: TEntity,
|
||||
@@ -123,7 +123,10 @@ export class AuthorizationResultBasedEntityAssociationLoader<
|
||||
.getViewerScopedEntityCompanionForClass(associatedEntityClass)
|
||||
.getLoaderFactory()
|
||||
.forLoad(this.queryContext, { previousValue: null, cascadingDeleteCause: null });
|
||||
return await loader.loadManyByFieldEqualingAsync(associatedEntityFieldContainingThisID, thisID);
|
||||
return await loader.loadManyByFieldEqualingAsync(
|
||||
associatedEntityFieldContainingThisID,
|
||||
thisID as any,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -175,7 +178,7 @@ export class AuthorizationResultBasedEntityAssociationLoader<
|
||||
.forLoad(this.queryContext, { previousValue: null, cascadingDeleteCause: null });
|
||||
return await loader.loadByFieldEqualingAsync(
|
||||
associatedEntityLookupByField,
|
||||
associatedFieldValue,
|
||||
associatedFieldValue as any,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -229,7 +232,7 @@ export class AuthorizationResultBasedEntityAssociationLoader<
|
||||
.forLoad(this.queryContext, { previousValue: null, cascadingDeleteCause: null });
|
||||
return await loader.loadManyByFieldEqualingAsync(
|
||||
associatedEntityLookupByField,
|
||||
associatedFieldValue,
|
||||
associatedFieldValue as any,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
EntityConfiguration,
|
||||
} from './EntityConfiguration';
|
||||
import { EntityConstructionUtils } from './EntityConstructionUtils';
|
||||
import { EntityInvalidationUtils } from './EntityInvalidationUtils';
|
||||
import { EntityPrivacyPolicy } from './EntityPrivacyPolicy';
|
||||
import { EntityQueryContext } from './EntityQueryContext';
|
||||
import { ReadonlyEntity } from './ReadonlyEntity';
|
||||
@@ -18,6 +19,7 @@ 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';
|
||||
|
||||
@@ -53,7 +55,16 @@ export class AuthorizationResultBasedEntityLoader<
|
||||
TSelectedFields
|
||||
>,
|
||||
private readonly dataManager: EntityDataManager<TFields, TIDField>,
|
||||
private readonly constructionUtils: EntityConstructionUtils<
|
||||
protected readonly metricsAdapter: IEntityMetricsAdapter,
|
||||
public readonly invalidationUtils: EntityInvalidationUtils<
|
||||
TFields,
|
||||
TIDField,
|
||||
TViewerContext,
|
||||
TEntity,
|
||||
TPrivacyPolicy,
|
||||
TSelectedFields
|
||||
>,
|
||||
public readonly constructionUtils: EntityConstructionUtils<
|
||||
TFields,
|
||||
TIDField,
|
||||
TViewerContext,
|
||||
|
||||
@@ -333,17 +333,8 @@ 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 = constructionUtils.constructEntity({
|
||||
const temporaryEntityForPrivacyCheck = entityLoader.constructionUtils.constructEntity({
|
||||
[this.entityConfiguration.idField]: '00000000-0000-0000-0000-000000000000', // zero UUID
|
||||
...this.fieldsForEntity,
|
||||
} as unknown as TFields);
|
||||
@@ -385,13 +376,14 @@ 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 () => {
|
||||
invalidationUtils.invalidateFieldsForTransaction(queryContext, insertResult);
|
||||
await invalidationUtils.invalidateFieldsAsync(insertResult);
|
||||
entityLoader.invalidationUtils.invalidateFieldsForTransaction(queryContext, insertResult);
|
||||
await entityLoader.invalidationUtils.invalidateFieldsAsync(insertResult);
|
||||
});
|
||||
|
||||
invalidationUtils.invalidateFieldsForTransaction(queryContext, insertResult);
|
||||
entityLoader.invalidationUtils.invalidateFieldsForTransaction(queryContext, insertResult);
|
||||
|
||||
const unauthorizedEntityAfterInsert = constructionUtils.constructEntity(insertResult);
|
||||
const unauthorizedEntityAfterInsert =
|
||||
entityLoader.constructionUtils.constructEntity(insertResult);
|
||||
const newEntity = await enforceAsyncResult(
|
||||
entityLoader.loadByIDAsync(unauthorizedEntityAfterInsert.getID()),
|
||||
);
|
||||
@@ -550,17 +542,10 @@ export class AuthorizationResultBasedUpdateMutator<
|
||||
previousValue: this.originalEntity,
|
||||
cascadingDeleteCause: this.cascadingDeleteCause,
|
||||
});
|
||||
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 entityAboutToBeUpdated = entityLoader.constructionUtils.constructEntity(
|
||||
this.fieldsForEntity,
|
||||
);
|
||||
const authorizeUpdateResult = await asyncResult(
|
||||
this.privacyPolicy.authorizeUpdateAsync(
|
||||
this.viewerContext,
|
||||
@@ -624,22 +609,30 @@ export class AuthorizationResultBasedUpdateMutator<
|
||||
// version of the entity.
|
||||
|
||||
queryContext.appendPostCommitInvalidationCallback(async () => {
|
||||
invalidationUtils.invalidateFieldsForTransaction(
|
||||
entityLoader.invalidationUtils.invalidateFieldsForTransaction(
|
||||
queryContext,
|
||||
this.originalEntity.getAllDatabaseFields(),
|
||||
);
|
||||
invalidationUtils.invalidateFieldsForTransaction(queryContext, this.fieldsForEntity);
|
||||
entityLoader.invalidationUtils.invalidateFieldsForTransaction(
|
||||
queryContext,
|
||||
this.fieldsForEntity,
|
||||
);
|
||||
await Promise.all([
|
||||
invalidationUtils.invalidateFieldsAsync(this.originalEntity.getAllDatabaseFields()),
|
||||
invalidationUtils.invalidateFieldsAsync(this.fieldsForEntity),
|
||||
entityLoader.invalidationUtils.invalidateFieldsAsync(
|
||||
this.originalEntity.getAllDatabaseFields(),
|
||||
),
|
||||
entityLoader.invalidationUtils.invalidateFieldsAsync(this.fieldsForEntity),
|
||||
]);
|
||||
});
|
||||
|
||||
invalidationUtils.invalidateFieldsForTransaction(
|
||||
entityLoader.invalidationUtils.invalidateFieldsForTransaction(
|
||||
queryContext,
|
||||
this.originalEntity.getAllDatabaseFields(),
|
||||
);
|
||||
invalidationUtils.invalidateFieldsForTransaction(queryContext, this.fieldsForEntity);
|
||||
entityLoader.invalidationUtils.invalidateFieldsForTransaction(
|
||||
queryContext,
|
||||
this.fieldsForEntity,
|
||||
);
|
||||
|
||||
const updatedEntity = await enforceAsyncResult(
|
||||
entityLoader.loadByIDAsync(entityAboutToBeUpdated.getID()),
|
||||
@@ -857,18 +850,23 @@ export class AuthorizationResultBasedDeleteMutator<
|
||||
);
|
||||
}
|
||||
|
||||
const invalidationUtils = this.entityLoaderFactory.invalidationUtils();
|
||||
const entityLoader = this.entityLoaderFactory.forLoad(this.viewerContext, queryContext, {
|
||||
previousValue: null,
|
||||
cascadingDeleteCause: this.cascadingDeleteCause,
|
||||
});
|
||||
|
||||
// Invalidate all caches for the entity so that any previously-cached loads
|
||||
// are removed from the caches.
|
||||
queryContext.appendPostCommitInvalidationCallback(async () => {
|
||||
invalidationUtils.invalidateFieldsForTransaction(
|
||||
entityLoader.invalidationUtils.invalidateFieldsForTransaction(
|
||||
queryContext,
|
||||
this.entity.getAllDatabaseFields(),
|
||||
);
|
||||
await invalidationUtils.invalidateFieldsAsync(this.entity.getAllDatabaseFields());
|
||||
await entityLoader.invalidationUtils.invalidateFieldsAsync(
|
||||
this.entity.getAllDatabaseFields(),
|
||||
);
|
||||
});
|
||||
invalidationUtils.invalidateFieldsForTransaction(
|
||||
entityLoader.invalidationUtils.invalidateFieldsForTransaction(
|
||||
queryContext,
|
||||
this.entity.getAllDatabaseFields(),
|
||||
);
|
||||
@@ -986,7 +984,7 @@ export class AuthorizationResultBasedDeleteMutator<
|
||||
.loadManyByFieldEqualingAsync(
|
||||
fieldName,
|
||||
association.associatedEntityLookupByField
|
||||
? entity.getField(association.associatedEntityLookupByField)
|
||||
? entity.getField(association.associatedEntityLookupByField as any)
|
||||
: entity.getID(),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -6,7 +6,7 @@ import { ReadonlyEntity } from './ReadonlyEntity';
|
||||
import { ViewerContext } from './ViewerContext';
|
||||
|
||||
/**
|
||||
* Enforcing entity creator. All creates
|
||||
* Enforcing entity creator. All updates
|
||||
* 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 = keyof TFields,
|
||||
TSelectedFields extends keyof TFields,
|
||||
> {
|
||||
constructor(
|
||||
private readonly entityLoader: AuthorizationResultBasedEntityLoader<
|
||||
|
||||
@@ -32,12 +32,6 @@ 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 {
|
||||
});
|
||||
}
|
||||
|
||||
getQueryContextProviderForDatabaseAdapterFlavor(
|
||||
getQueryContextProviderForDatabaseAdaptorFlavor(
|
||||
databaseAdapterFlavor: DatabaseAdapterFlavor,
|
||||
): EntityQueryContextProvider {
|
||||
const entityDatabaseAdapterFlavor = this.databaseAdapterFlavors.get(databaseAdapterFlavor);
|
||||
invariant(
|
||||
entityDatabaseAdapterFlavor,
|
||||
`No database adapter configuration found for flavor: ${databaseAdapterFlavor}`,
|
||||
`No database adaptor configuration found for flavor: ${databaseAdapterFlavor}`,
|
||||
);
|
||||
|
||||
return entityDatabaseAdapterFlavor.queryContextProvider;
|
||||
@@ -233,7 +233,7 @@ export class EntityCompanionProvider {
|
||||
);
|
||||
invariant(
|
||||
entityDatabaseAdapterFlavor,
|
||||
`No database adapter configuration found for flavor: ${entityConfiguration.databaseAdapterFlavor}`,
|
||||
`No database adaptor configuration found for flavor: ${entityConfiguration.databaseAdapterFlavor}`,
|
||||
);
|
||||
|
||||
const entityCacheAdapterFlavor = this.cacheAdapterFlavors.get(
|
||||
@@ -241,7 +241,7 @@ export class EntityCompanionProvider {
|
||||
);
|
||||
invariant(
|
||||
entityCacheAdapterFlavor,
|
||||
`No cache adapter configuration found for flavor: ${entityConfiguration.cacheAdapterFlavor}`,
|
||||
`No cache adaptor 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?: TAssociatedSelectedFields;
|
||||
associatedEntityLookupByField?: keyof TAssociatedFields;
|
||||
|
||||
/**
|
||||
* What action to perform on the entity at the other end of this edge when the entity on the source end of
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
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';
|
||||
@@ -72,4 +74,34 @@ 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,52 +39,6 @@ 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
|
||||
@@ -108,10 +62,21 @@ export class EntityLoaderFactory<
|
||||
TPrivacyPolicy,
|
||||
TSelectedFields
|
||||
> {
|
||||
const constructionUtils = this.constructionUtils(
|
||||
const invalidationUtils = new EntityInvalidationUtils(
|
||||
this.entityCompanion.entityCompanionDefinition.entityConfiguration,
|
||||
this.entityCompanion.entityCompanionDefinition.entityClass,
|
||||
this.dataManager,
|
||||
this.metricsAdapter,
|
||||
);
|
||||
const constructionUtils = new EntityConstructionUtils(
|
||||
viewerContext,
|
||||
queryContext,
|
||||
privacyPolicyEvaluationContext,
|
||||
this.entityCompanion.entityCompanionDefinition.entityConfiguration,
|
||||
this.entityCompanion.entityCompanionDefinition.entityClass,
|
||||
this.entityCompanion.entityCompanionDefinition.entitySelectedFields,
|
||||
this.entityCompanion.privacyPolicy,
|
||||
this.metricsAdapter,
|
||||
);
|
||||
|
||||
return new AuthorizationResultBasedEntityLoader(
|
||||
@@ -119,6 +84,8 @@ export class EntityLoaderFactory<
|
||||
this.entityCompanion.entityCompanionDefinition.entityConfiguration,
|
||||
this.entityCompanion.entityCompanionDefinition.entityClass,
|
||||
this.dataManager,
|
||||
this.metricsAdapter,
|
||||
invalidationUtils,
|
||||
constructionUtils,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -32,25 +32,6 @@ 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.
|
||||
@@ -142,9 +123,6 @@ 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,
|
||||
@@ -152,10 +130,6 @@ export abstract class EntityPrivacyPolicy<
|
||||
TEntity,
|
||||
TSelectedFields
|
||||
>[] = [];
|
||||
|
||||
/**
|
||||
* List of rules to evaluate for read authorization.
|
||||
*/
|
||||
protected readonly readRules: readonly PrivacyPolicyRule<
|
||||
TFields,
|
||||
TIDField,
|
||||
@@ -163,10 +137,6 @@ export abstract class EntityPrivacyPolicy<
|
||||
TEntity,
|
||||
TSelectedFields
|
||||
>[] = [];
|
||||
|
||||
/**
|
||||
* List of rules to evaluate for update authorization.
|
||||
*/
|
||||
protected readonly updateRules: readonly PrivacyPolicyRule<
|
||||
TFields,
|
||||
TIDField,
|
||||
@@ -174,10 +144,6 @@ export abstract class EntityPrivacyPolicy<
|
||||
TEntity,
|
||||
TSelectedFields
|
||||
>[] = [];
|
||||
|
||||
/**
|
||||
* List of rules to evaluate for delete authorization.
|
||||
*/
|
||||
protected readonly deleteRules: readonly PrivacyPolicyRule<
|
||||
TFields,
|
||||
TIDField,
|
||||
@@ -190,9 +156,6 @@ 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.
|
||||
@@ -241,9 +204,7 @@ 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
|
||||
*/
|
||||
@@ -275,9 +236,7 @@ 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
|
||||
*/
|
||||
@@ -309,9 +268,7 @@ 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
|
||||
*/
|
||||
@@ -479,7 +436,7 @@ export abstract class EntityPrivacyPolicy<
|
||||
const ruleEvaluationResult = await rule.evaluateAsync(
|
||||
viewerContext,
|
||||
queryContext,
|
||||
{ ...evaluationContext, action },
|
||||
evaluationContext,
|
||||
entity,
|
||||
);
|
||||
switch (ruleEvaluationResult) {
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
import { Result } from '@expo/results';
|
||||
|
||||
import { IEntityClass } from './Entity';
|
||||
import { EntityConstructionUtils } from './EntityConstructionUtils';
|
||||
import { AuthorizationResultBasedEntityLoader } from './AuthorizationResultBasedEntityLoader';
|
||||
import { EntityPrivacyPolicy } from './EntityPrivacyPolicy';
|
||||
import { EntityQueryContext } from './EntityQueryContext';
|
||||
import { ReadonlyEntity } from './ReadonlyEntity';
|
||||
import { ViewerContext } from './ViewerContext';
|
||||
import { mapMap } from './utils/collections/maps';
|
||||
@@ -62,7 +60,7 @@ export abstract class EntitySecondaryCacheLoader<
|
||||
> {
|
||||
constructor(
|
||||
private readonly secondaryEntityCache: ISecondaryEntityCache<TFields, TLoadParams>,
|
||||
private readonly constructionUtils: EntityConstructionUtils<
|
||||
protected readonly entityLoader: AuthorizationResultBasedEntityLoader<
|
||||
TFields,
|
||||
TIDField,
|
||||
TViewerContext,
|
||||
@@ -86,9 +84,10 @@ export abstract class EntitySecondaryCacheLoader<
|
||||
);
|
||||
|
||||
// convert value to and from array to reuse complex code
|
||||
const entitiesMap = await this.constructionUtils.constructAndAuthorizeEntitiesAsync(
|
||||
mapMap(loadParamsToFieldObjects, (fieldObject) => (fieldObject ? [fieldObject] : [])),
|
||||
);
|
||||
const entitiesMap =
|
||||
await this.entityLoader.constructionUtils.constructAndAuthorizeEntitiesAsync(
|
||||
mapMap(loadParamsToFieldObjects, (fieldObject) => (fieldObject ? [fieldObject] : [])),
|
||||
);
|
||||
return mapMap(entitiesMap, (fieldObjects) => fieldObjects[0] ?? null);
|
||||
}
|
||||
|
||||
@@ -111,53 +110,4 @@ 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,6 +6,7 @@ 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';
|
||||
@@ -231,7 +232,7 @@ export abstract class ReadonlyEntity<
|
||||
|
||||
/**
|
||||
* Utilities for entity invalidation.
|
||||
* Call these manually to keep entity cache consistent when performing operations outside of the entity framework.
|
||||
* Calling into these should only be necessary in rare cases.
|
||||
*/
|
||||
static invalidationUtils<
|
||||
TMFields extends object,
|
||||
@@ -257,6 +258,10 @@ export abstract class ReadonlyEntity<
|
||||
TMSelectedFields
|
||||
>,
|
||||
viewerContext: TMViewerContext2,
|
||||
queryContext: EntityQueryContext = viewerContext
|
||||
.getViewerScopedEntityCompanionForClass(this)
|
||||
.getQueryContextProvider()
|
||||
.getQueryContext(),
|
||||
): EntityInvalidationUtils<
|
||||
TMFields,
|
||||
TMIDField,
|
||||
@@ -265,9 +270,49 @@ export abstract class ReadonlyEntity<
|
||||
TMPrivacyPolicy,
|
||||
TMSelectedFields
|
||||
> {
|
||||
return viewerContext
|
||||
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
|
||||
.getViewerScopedEntityCompanionForClass(this)
|
||||
.getLoaderFactory()
|
||||
.invalidationUtils();
|
||||
.getQueryContextProvider()
|
||||
.getQueryContext(),
|
||||
): EntityConstructionUtils<
|
||||
TMFields,
|
||||
TMIDField,
|
||||
TMViewerContext,
|
||||
TMEntity,
|
||||
TMPrivacyPolicy,
|
||||
TMSelectedFields
|
||||
> {
|
||||
return new EntityLoader(viewerContext, queryContext, this).constructionUtils();
|
||||
}
|
||||
}
|
||||
@@ -64,30 +64,30 @@ export class ViewerContext {
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the regular (non-transactional) query context for a database adapter flavor.
|
||||
* @param databaseAdapterFlavor - database adapter flavor
|
||||
* Get the regular (non-transactional) query context for a database adaptor flavor.
|
||||
* @param databaseAdaptorFlavor - database adaptor flavor
|
||||
*/
|
||||
getQueryContextForDatabaseAdapterFlavor(
|
||||
databaseAdapterFlavor: DatabaseAdapterFlavor,
|
||||
getQueryContextForDatabaseAdaptorFlavor(
|
||||
databaseAdaptorFlavor: DatabaseAdapterFlavor,
|
||||
): EntityQueryContext {
|
||||
return this.entityCompanionProvider
|
||||
.getQueryContextProviderForDatabaseAdapterFlavor(databaseAdapterFlavor)
|
||||
.getQueryContextProviderForDatabaseAdaptorFlavor(databaseAdaptorFlavor)
|
||||
.getQueryContext();
|
||||
}
|
||||
|
||||
/**
|
||||
* Run a transaction of specified database adapter flavor and execute the provided
|
||||
* Run a transaction of specified database adaptor flavor and execute the provided
|
||||
* transaction-scoped closure within the transaction.
|
||||
* @param databaseAdapterFlavor - databaseAdapterFlavor
|
||||
* @param databaseAdaptorFlavor - databaseAdaptorFlavor
|
||||
* @param transactionScope - async callback to execute within the transaction
|
||||
*/
|
||||
async runInTransactionForDatabaseAdapterFlavorAsync<TResult>(
|
||||
databaseAdapterFlavor: DatabaseAdapterFlavor,
|
||||
async runInTransactionForDatabaseAdaptorFlavorAsync<TResult>(
|
||||
databaseAdaptorFlavor: DatabaseAdapterFlavor,
|
||||
transactionScope: (queryContext: EntityTransactionalQueryContext) => Promise<TResult>,
|
||||
transactionConfig?: TransactionConfig,
|
||||
): Promise<TResult> {
|
||||
return await this.entityCompanionProvider
|
||||
.getQueryContextProviderForDatabaseAdapterFlavor(databaseAdapterFlavor)
|
||||
.getQueryContextProviderForDatabaseAdaptorFlavor(databaseAdaptorFlavor)
|
||||
.getQueryContext()
|
||||
.runInTransactionIfNotInTransactionAsync(transactionScope, transactionConfig);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
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';
|
||||
@@ -36,41 +34,6 @@ 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,6 +7,7 @@ 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';
|
||||
@@ -164,6 +165,12 @@ describe(AuthorizationResultBasedEntityLoader, () => {
|
||||
metricsAdapter,
|
||||
TestEntity.name,
|
||||
);
|
||||
const invalidationUtils = new EntityInvalidationUtils(
|
||||
testEntityConfiguration,
|
||||
TestEntity,
|
||||
dataManager,
|
||||
metricsAdapter,
|
||||
);
|
||||
const constructionUtils = new EntityConstructionUtils(
|
||||
viewerContext,
|
||||
queryContext,
|
||||
@@ -179,6 +186,8 @@ describe(AuthorizationResultBasedEntityLoader, () => {
|
||||
testEntityConfiguration,
|
||||
TestEntity,
|
||||
dataManager,
|
||||
metricsAdapter,
|
||||
invalidationUtils,
|
||||
constructionUtils,
|
||||
);
|
||||
|
||||
|
||||
@@ -90,6 +90,12 @@ describe(AuthorizationResultBasedEntityLoader, () => {
|
||||
instance(mock<IEntityMetricsAdapter>()),
|
||||
TestEntity.name,
|
||||
);
|
||||
const invalidationUtils = new EntityInvalidationUtils(
|
||||
testEntityConfiguration,
|
||||
TestEntity,
|
||||
dataManager,
|
||||
metricsAdapter,
|
||||
);
|
||||
const constructionUtils = new EntityConstructionUtils(
|
||||
viewerContext,
|
||||
queryContext,
|
||||
@@ -105,6 +111,8 @@ describe(AuthorizationResultBasedEntityLoader, () => {
|
||||
testEntityConfiguration,
|
||||
TestEntity,
|
||||
dataManager,
|
||||
metricsAdapter,
|
||||
invalidationUtils,
|
||||
constructionUtils,
|
||||
);
|
||||
|
||||
@@ -232,6 +240,12 @@ describe(AuthorizationResultBasedEntityLoader, () => {
|
||||
instance(mock<IEntityMetricsAdapter>()),
|
||||
TestEntity.name,
|
||||
);
|
||||
const invalidationUtils = new EntityInvalidationUtils(
|
||||
testEntityConfiguration,
|
||||
TestEntity,
|
||||
dataManager,
|
||||
metricsAdapter,
|
||||
);
|
||||
const constructionUtils = new EntityConstructionUtils(
|
||||
viewerContext,
|
||||
queryContext,
|
||||
@@ -247,6 +261,8 @@ describe(AuthorizationResultBasedEntityLoader, () => {
|
||||
testEntityConfiguration,
|
||||
TestEntity,
|
||||
dataManager,
|
||||
metricsAdapter,
|
||||
invalidationUtils,
|
||||
constructionUtils,
|
||||
);
|
||||
|
||||
@@ -369,6 +385,12 @@ describe(AuthorizationResultBasedEntityLoader, () => {
|
||||
instance(mock<IEntityMetricsAdapter>()),
|
||||
TestEntity.name,
|
||||
);
|
||||
const invalidationUtils = new EntityInvalidationUtils(
|
||||
testEntityConfiguration,
|
||||
TestEntity,
|
||||
dataManager,
|
||||
metricsAdapter,
|
||||
);
|
||||
const constructionUtils = new EntityConstructionUtils(
|
||||
viewerContext,
|
||||
queryContext,
|
||||
@@ -384,6 +406,8 @@ describe(AuthorizationResultBasedEntityLoader, () => {
|
||||
testEntityConfiguration,
|
||||
TestEntity,
|
||||
dataManager,
|
||||
metricsAdapter,
|
||||
invalidationUtils,
|
||||
constructionUtils,
|
||||
);
|
||||
const entity = await enforceAsyncResult(entityLoader.loadByIDAsync(id1));
|
||||
@@ -399,7 +423,21 @@ 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);
|
||||
|
||||
@@ -410,10 +448,29 @@ 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 invalidationUtils.invalidateFieldsAsync({
|
||||
await entityLoader.invalidationUtils.invalidateFieldsAsync({
|
||||
customIdField: id1,
|
||||
testIndexedField: 'h1',
|
||||
intField: 5,
|
||||
@@ -465,7 +522,21 @@ 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);
|
||||
|
||||
@@ -489,8 +560,26 @@ describe(AuthorizationResultBasedEntityLoader, () => {
|
||||
dataManagerInstance,
|
||||
metricsAdapter,
|
||||
);
|
||||
|
||||
await invalidationUtils.invalidateEntityAsync(entityInstance);
|
||||
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);
|
||||
|
||||
verify(dataManagerMock.invalidateKeyValuePairsAsync(anything())).once();
|
||||
verify(
|
||||
@@ -535,8 +624,21 @@ 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);
|
||||
|
||||
@@ -561,7 +663,26 @@ describe(AuthorizationResultBasedEntityLoader, () => {
|
||||
dataManagerInstance,
|
||||
metricsAdapter,
|
||||
);
|
||||
invalidationUtils.invalidateEntityForTransaction(queryContext, entityInstance);
|
||||
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);
|
||||
|
||||
verify(
|
||||
dataManagerMock.invalidateKeyValuePairsForTransaction(queryContext, anything()),
|
||||
@@ -660,6 +781,12 @@ describe(AuthorizationResultBasedEntityLoader, () => {
|
||||
const privacyPolicy = instance(privacyPolicyMock);
|
||||
const dataManagerInstance = instance(dataManagerMock);
|
||||
|
||||
const invalidationUtils = new EntityInvalidationUtils(
|
||||
testEntityConfiguration,
|
||||
TestEntity,
|
||||
dataManagerInstance,
|
||||
metricsAdapter,
|
||||
);
|
||||
const constructionUtils = new EntityConstructionUtils(
|
||||
viewerContext,
|
||||
queryContext,
|
||||
@@ -675,6 +802,8 @@ describe(AuthorizationResultBasedEntityLoader, () => {
|
||||
testEntityConfiguration,
|
||||
TestEntity,
|
||||
dataManagerInstance,
|
||||
metricsAdapter,
|
||||
invalidationUtils,
|
||||
constructionUtils,
|
||||
);
|
||||
|
||||
@@ -710,6 +839,12 @@ describe(AuthorizationResultBasedEntityLoader, () => {
|
||||
|
||||
const dataManagerInstance = instance(dataManagerMock);
|
||||
|
||||
const invalidationUtils = new EntityInvalidationUtils(
|
||||
testEntityConfiguration,
|
||||
TestEntity,
|
||||
dataManagerInstance,
|
||||
metricsAdapter,
|
||||
);
|
||||
const constructionUtils = new EntityConstructionUtils(
|
||||
viewerContext,
|
||||
queryContext,
|
||||
@@ -725,6 +860,8 @@ describe(AuthorizationResultBasedEntityLoader, () => {
|
||||
testEntityConfiguration,
|
||||
TestEntity,
|
||||
dataManagerInstance,
|
||||
metricsAdapter,
|
||||
invalidationUtils,
|
||||
constructionUtils,
|
||||
);
|
||||
|
||||
|
||||
@@ -289,7 +289,7 @@ describe(ComposedEntityCacheAdapter, () => {
|
||||
const { cacheAdapter } = makeTestCacheAdapters();
|
||||
const results = await cacheAdapter.loadManyAsync(
|
||||
new SingleFieldHolder<BlahFields, 'id', 'id'>('id'),
|
||||
[] as readonly SingleFieldValueHolder<BlahFields, 'id'>[],
|
||||
[] as any,
|
||||
);
|
||||
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 readonly SingleFieldValueHolder<BlahFields, 'id'>[],
|
||||
[] as any,
|
||||
);
|
||||
expect(results.size).toBe(0);
|
||||
});
|
||||
@@ -430,7 +430,7 @@ describe(ComposedEntityCacheAdapter, () => {
|
||||
|
||||
await cacheAdapter.invalidateManyAsync(
|
||||
new SingleFieldHolder<BlahFields, 'id', 'id'>('id'),
|
||||
[] as readonly SingleFieldValueHolder<BlahFields, 'id'>[],
|
||||
[] as any,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -175,7 +175,7 @@ describe(EntityConfiguration, () => {
|
||||
])('disallows %p as field key', (keyName) => {
|
||||
expect(
|
||||
() =>
|
||||
new EntityConfiguration<{ id: string }, 'id'>({
|
||||
new EntityConfiguration<any, '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, {});
|
||||
const result = await adapter.insertAsync(queryContext, {} as any);
|
||||
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, {})).rejects.toThrow(
|
||||
await expect(adapter.insertAsync(queryContext, {} as any)).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, {})).rejects.toThrow(
|
||||
await expect(adapter.insertAsync(queryContext, {} as any)).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', {});
|
||||
const result = await adapter.updateAsync(queryContext, 'customIdField', 'wat', {} as any);
|
||||
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', {})).rejects.toThrow(
|
||||
EntityDatabaseAdapterEmptyUpdateResultError,
|
||||
);
|
||||
await expect(
|
||||
adapter.updateAsync(queryContext, 'customIdField', 'wat', {} as any),
|
||||
).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', {})).rejects.toThrow(
|
||||
EntityDatabaseAdapterExcessiveUpdateResultError,
|
||||
);
|
||||
await expect(
|
||||
adapter.updateAsync(queryContext, 'customIdField', 'wat', {} as any),
|
||||
).rejects.toThrow(EntityDatabaseAdapterExcessiveUpdateResultError);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ 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';
|
||||
@@ -36,4 +37,14 @@ 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,6 +1511,7 @@ describe(EntityMutatorFactory, () => {
|
||||
>
|
||||
>(EntityConstructionUtils);
|
||||
when(entityConstructionUtilsMock.constructEntity(anything())).thenReturn(fakeEntity);
|
||||
when(entityLoaderMock.constructionUtils).thenReturn(instance(entityConstructionUtilsMock));
|
||||
const entityLoader = instance(entityLoaderMock);
|
||||
|
||||
const entityLoaderFactoryMock =
|
||||
@@ -1531,13 +1532,6 @@ describe(EntityMutatorFactory, () => {
|
||||
anything(),
|
||||
),
|
||||
).thenReturn(entityLoader);
|
||||
when(
|
||||
entityLoaderFactoryMock.constructionUtils(
|
||||
viewerContext,
|
||||
anyOfClass(EntityTransactionalQueryContext),
|
||||
anything(),
|
||||
),
|
||||
).thenReturn(instance(entityConstructionUtilsMock));
|
||||
const entityLoaderFactory = instance(entityLoaderFactoryMock);
|
||||
|
||||
const rejectionError = new Error();
|
||||
@@ -1652,6 +1646,7 @@ describe(EntityMutatorFactory, () => {
|
||||
>
|
||||
>(EntityConstructionUtils);
|
||||
when(entityConstructionUtilsMock.constructEntity(anything())).thenReturn(fakeEntity);
|
||||
when(entityLoaderMock.constructionUtils).thenReturn(instance(entityConstructionUtilsMock));
|
||||
const entityLoader = instance(entityLoaderMock);
|
||||
|
||||
const entityLoaderFactoryMock =
|
||||
@@ -1672,13 +1667,6 @@ describe(EntityMutatorFactory, () => {
|
||||
anything(),
|
||||
),
|
||||
).thenReturn(entityLoader);
|
||||
when(
|
||||
entityLoaderFactoryMock.constructionUtils(
|
||||
viewerContext,
|
||||
anyOfClass(EntityTransactionalQueryContext),
|
||||
anything(),
|
||||
),
|
||||
).thenReturn(instance(entityConstructionUtilsMock));
|
||||
const entityLoaderFactory = instance(entityLoaderFactoryMock);
|
||||
|
||||
const rejectionError = new Error();
|
||||
|
||||
@@ -11,7 +11,6 @@ import {
|
||||
EntityPrivacyPolicyEvaluationContext,
|
||||
EntityPrivacyPolicyEvaluationMode,
|
||||
EntityPrivacyPolicyEvaluator,
|
||||
EntityPrivacyPolicyRuleEvaluationContext,
|
||||
} from '../EntityPrivacyPolicy';
|
||||
import { EntityQueryContext } from '../EntityQueryContext';
|
||||
import { ViewerContext } from '../ViewerContext';
|
||||
@@ -274,47 +273,6 @@ 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 () => {
|
||||
@@ -760,64 +718,4 @@ 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.runInTransactionForDatabaseAdapterFlavorAsync(
|
||||
await viewerContext.runInTransactionForDatabaseAdaptorFlavorAsync(
|
||||
'postgres',
|
||||
async (queryContext) => {
|
||||
queryContext.appendPostCommitCallback(postCommitCallback);
|
||||
@@ -76,7 +76,7 @@ describe(EntityQueryContext, () => {
|
||||
const postCommitCallback = jest.fn(async (): Promise<void> => {});
|
||||
|
||||
await expect(
|
||||
viewerContext.runInTransactionForDatabaseAdapterFlavorAsync(
|
||||
viewerContext.runInTransactionForDatabaseAdaptorFlavorAsync(
|
||||
'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.runInTransactionForDatabaseAdapterFlavorAsync(
|
||||
await viewerContext.runInTransactionForDatabaseAdaptorFlavorAsync(
|
||||
'postgres',
|
||||
async (queryContext) => {
|
||||
queryContext.appendPostCommitCallback(postCommitCallback);
|
||||
@@ -152,13 +152,13 @@ describe(EntityQueryContext, () => {
|
||||
const viewerContext = new ViewerContext(companionProvider);
|
||||
|
||||
const queryContextProvider =
|
||||
companionProvider.getQueryContextProviderForDatabaseAdapterFlavor('postgres');
|
||||
companionProvider.getQueryContextProviderForDatabaseAdaptorFlavor('postgres');
|
||||
const queryContextProviderSpy = jest.spyOn(queryContextProvider, 'runInTransactionAsync');
|
||||
|
||||
const transactionScopeFn = async (): Promise<any> => {};
|
||||
const transactionConfig = { isolationLevel: TransactionIsolationLevel.SERIALIZABLE };
|
||||
|
||||
await viewerContext.runInTransactionForDatabaseAdapterFlavorAsync(
|
||||
await viewerContext.runInTransactionForDatabaseAdaptorFlavorAsync(
|
||||
'postgres',
|
||||
transactionScopeFn,
|
||||
transactionConfig,
|
||||
@@ -171,7 +171,7 @@ describe(EntityQueryContext, () => {
|
||||
const companionProvider = createUnitTestEntityCompanionProvider();
|
||||
const viewerContext = new ViewerContext(companionProvider);
|
||||
|
||||
await viewerContext.runInTransactionForDatabaseAdapterFlavorAsync(
|
||||
await viewerContext.runInTransactionForDatabaseAdaptorFlavorAsync(
|
||||
'postgres',
|
||||
async (queryContext) => {
|
||||
assert(queryContext.isInTransaction());
|
||||
@@ -182,7 +182,7 @@ describe(EntityQueryContext, () => {
|
||||
{ transactionalDataLoaderMode: TransactionalDataLoaderMode.DISABLED },
|
||||
);
|
||||
|
||||
await viewerContext.runInTransactionForDatabaseAdapterFlavorAsync(
|
||||
await viewerContext.runInTransactionForDatabaseAdaptorFlavorAsync(
|
||||
'postgres',
|
||||
async (queryContext) => {
|
||||
assert(queryContext.isInTransaction());
|
||||
@@ -193,7 +193,7 @@ describe(EntityQueryContext, () => {
|
||||
{ transactionalDataLoaderMode: TransactionalDataLoaderMode.ENABLED_BATCH_ONLY },
|
||||
);
|
||||
|
||||
await viewerContext.runInTransactionForDatabaseAdapterFlavorAsync(
|
||||
await viewerContext.runInTransactionForDatabaseAdaptorFlavorAsync(
|
||||
'postgres',
|
||||
async (queryContext) => {
|
||||
assert(queryContext.isInTransaction());
|
||||
@@ -252,7 +252,7 @@ describe(EntityQueryContext, () => {
|
||||
);
|
||||
const viewerContext = new ViewerContext(companionProvider);
|
||||
|
||||
await viewerContext.runInTransactionForDatabaseAdapterFlavorAsync(
|
||||
await viewerContext.runInTransactionForDatabaseAdaptorFlavorAsync(
|
||||
'postgres',
|
||||
async (queryContext) => {
|
||||
assert(queryContext.isInTransaction());
|
||||
@@ -263,7 +263,7 @@ describe(EntityQueryContext, () => {
|
||||
{ transactionalDataLoaderMode: TransactionalDataLoaderMode.DISABLED },
|
||||
);
|
||||
|
||||
await viewerContext.runInTransactionForDatabaseAdapterFlavorAsync(
|
||||
await viewerContext.runInTransactionForDatabaseAdaptorFlavorAsync(
|
||||
'postgres',
|
||||
async (queryContext) => {
|
||||
assert(queryContext.isInTransaction());
|
||||
@@ -274,7 +274,7 @@ describe(EntityQueryContext, () => {
|
||||
{ transactionalDataLoaderMode: TransactionalDataLoaderMode.ENABLED_BATCH_ONLY },
|
||||
);
|
||||
|
||||
await viewerContext.runInTransactionForDatabaseAdapterFlavorAsync(
|
||||
await viewerContext.runInTransactionForDatabaseAdaptorFlavorAsync(
|
||||
'postgres',
|
||||
async (queryContext) => {
|
||||
assert(queryContext.isInTransaction());
|
||||
|
||||
@@ -46,7 +46,7 @@ describe(EntitySecondaryCacheLoader, () => {
|
||||
|
||||
const secondaryCacheLoader = new TestSecondaryRedisCacheLoader(
|
||||
secondaryEntityCache,
|
||||
EntitySecondaryCacheLoader.getConstructionUtilsForEntityClass(SimpleTestEntity, vc1),
|
||||
SimpleTestEntity.loaderWithAuthorizationResults(vc1),
|
||||
);
|
||||
|
||||
await secondaryCacheLoader.loadManyAsync([loadParams]);
|
||||
@@ -70,11 +70,8 @@ describe(EntitySecondaryCacheLoader, () => {
|
||||
const secondaryEntityCache = instance(secondaryEntityCacheMock);
|
||||
|
||||
const loader = SimpleTestEntity.loaderWithAuthorizationResults(vc1);
|
||||
const spiedPrivacyPolicy = spy(loader['constructionUtils']['privacyPolicy']);
|
||||
const secondaryCacheLoader = new TestSecondaryRedisCacheLoader(
|
||||
secondaryEntityCache,
|
||||
EntitySecondaryCacheLoader.getConstructionUtilsForEntityClass(SimpleTestEntity, vc1),
|
||||
);
|
||||
const spiedPrivacyPolicy = spy(loader.constructionUtils['privacyPolicy']);
|
||||
const secondaryCacheLoader = new TestSecondaryRedisCacheLoader(secondaryEntityCache, loader);
|
||||
|
||||
const result = await secondaryCacheLoader.loadManyAsync([loadParams]);
|
||||
expect(result.get(loadParams)?.enforceValue().getID()).toEqual(createdEntity.getID());
|
||||
@@ -101,10 +98,8 @@ describe(EntitySecondaryCacheLoader, () => {
|
||||
const secondaryEntityCacheMock =
|
||||
mock<ISecondaryEntityCache<SimpleTestFields, TestLoadParams>>();
|
||||
const secondaryEntityCache = instance(secondaryEntityCacheMock);
|
||||
const secondaryCacheLoader = new TestSecondaryRedisCacheLoader(
|
||||
secondaryEntityCache,
|
||||
EntitySecondaryCacheLoader.getConstructionUtilsForEntityClass(SimpleTestEntity, vc1),
|
||||
);
|
||||
const loader = SimpleTestEntity.loaderWithAuthorizationResults(vc1);
|
||||
const secondaryCacheLoader = new TestSecondaryRedisCacheLoader(secondaryEntityCache, loader);
|
||||
await secondaryCacheLoader.invalidateManyAsync([loadParams]);
|
||||
|
||||
verify(secondaryEntityCacheMock.invalidateManyAsync(deepEqual([loadParams]))).once();
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
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';
|
||||
@@ -102,28 +100,6 @@ 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>> {
|
||||
@@ -153,7 +129,6 @@ describe(GenericSecondaryEntityCache, () => {
|
||||
new TestGenericCacher(),
|
||||
(params) => `intValue.${params.intValue}`,
|
||||
),
|
||||
EntitySecondaryCacheLoader.getConstructionUtilsForEntityClass(TestEntity, viewerContext),
|
||||
TestEntity.loaderWithAuthorizationResults(viewerContext),
|
||||
);
|
||||
|
||||
@@ -190,7 +165,6 @@ describe(GenericSecondaryEntityCache, () => {
|
||||
new TestGenericCacher(),
|
||||
(params) => `intValue.${params.intValue}`,
|
||||
),
|
||||
EntitySecondaryCacheLoader.getConstructionUtilsForEntityClass(TestEntity, viewerContext),
|
||||
TestEntity.loaderWithAuthorizationResults(viewerContext),
|
||||
);
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ 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';
|
||||
@@ -223,4 +224,14 @@ 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('getQueryContextForDatabaseAdapterFlavor', () => {
|
||||
describe('getQueryContextForDatabaseAdaptorFlavor', () => {
|
||||
it('creates a new regular query context', () => {
|
||||
const companionProvider = createUnitTestEntityCompanionProvider();
|
||||
const viewerContext = new ViewerContext(companionProvider);
|
||||
const queryContext = viewerContext.getQueryContextForDatabaseAdapterFlavor('postgres');
|
||||
const queryContext = viewerContext.getQueryContextForDatabaseAdaptorFlavor('postgres');
|
||||
expect(queryContext).toBeInstanceOf(EntityQueryContext);
|
||||
expect(queryContext.isInTransaction()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('runInTransactionForDatabaseAdapterFlavorAsync', () => {
|
||||
describe('runInTransactionForDatabaseAdaptorFlavorAsync', () => {
|
||||
it('creates a new transactional query context', async () => {
|
||||
const companionProvider = createUnitTestEntityCompanionProvider();
|
||||
const viewerContext = new ViewerContext(companionProvider);
|
||||
const didCreateTransaction =
|
||||
await viewerContext.runInTransactionForDatabaseAdapterFlavorAsync(
|
||||
await viewerContext.runInTransactionForDatabaseAdaptorFlavorAsync(
|
||||
'postgres',
|
||||
async (queryContext) => {
|
||||
return queryContext.isInTransaction();
|
||||
|
||||
@@ -1,8 +1,5 @@
|
||||
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';
|
||||
@@ -17,21 +14,16 @@ describe('EntityError subclasses', () => {
|
||||
});
|
||||
|
||||
it('EntityNotAuthorizedError has correct state and code', () => {
|
||||
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);
|
||||
const mockEntity = { constructor: { name: 'TestEntity' }, toString: () => 'TestEntity' } as any;
|
||||
const mockViewerContext = { toString: () => 'TestViewer' } as any;
|
||||
const error = new EntityNotAuthorizedError(mockEntity, mockViewerContext, 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 error = new EntityInvalidFieldValueError(SimpleTestEntity, 'id', 'badValue');
|
||||
const mockEntityClass = { name: 'TestEntity' } as any;
|
||||
const error = new EntityInvalidFieldValueError(mockEntityClass, 'testField', '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: Partial<TFields> = {};
|
||||
const fields: TFields = {} as any;
|
||||
for (const k in databaseObject) {
|
||||
const val = databaseObject[k];
|
||||
const fieldsKey = entityConfiguration.dbToEntityFieldsKeyMapping.get(k);
|
||||
@@ -64,7 +64,7 @@ export const transformDatabaseObjectToFields = <
|
||||
);
|
||||
}
|
||||
}
|
||||
return fields as Readonly<TFields>;
|
||||
return fields;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -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);
|
||||
const databaseKey = entityConfiguration.entityToDBFieldsKeyMapping.get(k as any);
|
||||
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: Partial<TFields> = {};
|
||||
const fields: TFields = {} as any;
|
||||
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 as Readonly<TFields>;
|
||||
return fields;
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -3,8 +3,6 @@ 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 {
|
||||
/**
|
||||
|
||||
@@ -120,7 +120,7 @@ describe(EntityDataManager, () => {
|
||||
cacheSpy.mockClear();
|
||||
});
|
||||
|
||||
it('loads from a caching adapter', async () => {
|
||||
it('loads from a caching adaptor', async () => {
|
||||
const objects = getObjects();
|
||||
const dataStore = StubDatabaseAdapter.convertFieldObjectsToDataStore(
|
||||
testEntityConfiguration,
|
||||
@@ -1143,7 +1143,7 @@ describe(EntityDataManager, () => {
|
||||
entityDataManager.loadManyEqualingAsync(
|
||||
queryContext,
|
||||
new SingleFieldHolder<TestFields, 'customIdField', 'nullableField'>('nullableField'),
|
||||
[new SingleFieldValueHolder(null)],
|
||||
[new SingleFieldValueHolder(null as any)],
|
||||
),
|
||||
).rejects.toThrow('Invalid load: TestEntity (nullableField = null)');
|
||||
|
||||
@@ -1151,7 +1151,7 @@ describe(EntityDataManager, () => {
|
||||
entityDataManager.loadManyEqualingAsync(
|
||||
queryContext,
|
||||
new SingleFieldHolder<TestFields, 'customIdField', 'nullableField'>('nullableField'),
|
||||
[new SingleFieldValueHolder(undefined)],
|
||||
[new SingleFieldValueHolder(undefined as any)],
|
||||
),
|
||||
).rejects.toThrow('Invalid load: TestEntity (nullableField = undefined)');
|
||||
});
|
||||
@@ -1187,7 +1187,7 @@ describe(EntityDataManager, () => {
|
||||
]),
|
||||
[
|
||||
new CompositeFieldValueHolder({
|
||||
nullableField: null,
|
||||
nullableField: null as any,
|
||||
testIndexedField: 'unique1',
|
||||
}),
|
||||
],
|
||||
@@ -1205,7 +1205,7 @@ describe(EntityDataManager, () => {
|
||||
]),
|
||||
[
|
||||
new CompositeFieldValueHolder({
|
||||
nullableField: undefined,
|
||||
nullableField: undefined as any,
|
||||
testIndexedField: 'unique1',
|
||||
}),
|
||||
],
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { EntityPrivacyPolicyRuleEvaluationContext } from '../EntityPrivacyPolicy';
|
||||
import { EntityPrivacyPolicyEvaluationContext } from '../EntityPrivacyPolicy';
|
||||
import { EntityQueryContext } from '../EntityQueryContext';
|
||||
import { ReadonlyEntity } from '../ReadonlyEntity';
|
||||
import { ViewerContext } from '../ViewerContext';
|
||||
@@ -26,7 +26,7 @@ export class AllowIfAllSubRulesAllowPrivacyPolicyRule<
|
||||
async evaluateAsync(
|
||||
viewerContext: TViewerContext,
|
||||
queryContext: EntityQueryContext,
|
||||
evaluationContext: EntityPrivacyPolicyRuleEvaluationContext<
|
||||
evaluationContext: EntityPrivacyPolicyEvaluationContext<
|
||||
TFields,
|
||||
TIDField,
|
||||
TViewerContext,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { EntityPrivacyPolicyRuleEvaluationContext } from '../EntityPrivacyPolicy';
|
||||
import { EntityPrivacyPolicyEvaluationContext } from '../EntityPrivacyPolicy';
|
||||
import { EntityQueryContext } from '../EntityQueryContext';
|
||||
import { ReadonlyEntity } from '../ReadonlyEntity';
|
||||
import { ViewerContext } from '../ViewerContext';
|
||||
@@ -26,7 +26,7 @@ export class AllowIfAnySubRuleAllowsPrivacyPolicyRule<
|
||||
async evaluateAsync(
|
||||
viewerContext: TViewerContext,
|
||||
queryContext: EntityQueryContext,
|
||||
evaluationContext: EntityPrivacyPolicyRuleEvaluationContext<
|
||||
evaluationContext: EntityPrivacyPolicyEvaluationContext<
|
||||
TFields,
|
||||
TIDField,
|
||||
TViewerContext,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { EntityPrivacyPolicyRuleEvaluationContext } from '../EntityPrivacyPolicy';
|
||||
import { EntityPrivacyPolicyEvaluationContext } from '../EntityPrivacyPolicy';
|
||||
import { EntityQueryContext } from '../EntityQueryContext';
|
||||
import { ReadonlyEntity } from '../ReadonlyEntity';
|
||||
import { ViewerContext } from '../ViewerContext';
|
||||
@@ -29,7 +29,7 @@ export class EvaluateIfEntityFieldPredicatePrivacyPolicyRule<
|
||||
async evaluateAsync(
|
||||
viewerContext: TViewerContext,
|
||||
queryContext: EntityQueryContext,
|
||||
evaluationContext: EntityPrivacyPolicyRuleEvaluationContext<
|
||||
evaluationContext: EntityPrivacyPolicyEvaluationContext<
|
||||
TFields,
|
||||
TIDField,
|
||||
TViewerContext,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { EntityPrivacyPolicyRuleEvaluationContext } from '../EntityPrivacyPolicy';
|
||||
import { EntityPrivacyPolicyEvaluationContext } from '../EntityPrivacyPolicy';
|
||||
import { EntityQueryContext } from '../EntityQueryContext';
|
||||
import { ReadonlyEntity } from '../ReadonlyEntity';
|
||||
import { ViewerContext } from '../ViewerContext';
|
||||
@@ -46,7 +46,7 @@ export abstract class PrivacyPolicyRule<
|
||||
abstract evaluateAsync(
|
||||
viewerContext: TViewerContext,
|
||||
queryContext: EntityQueryContext,
|
||||
evaluationContext: EntityPrivacyPolicyRuleEvaluationContext<
|
||||
evaluationContext: EntityPrivacyPolicyEvaluationContext<
|
||||
TFields,
|
||||
TIDField,
|
||||
TViewerContext,
|
||||
|
||||
+4
-4
@@ -1,6 +1,6 @@
|
||||
import { anything, instance, mock } from 'ts-mockito';
|
||||
|
||||
import { EntityPrivacyPolicyRuleEvaluationContext } from '../../EntityPrivacyPolicy';
|
||||
import { EntityPrivacyPolicyEvaluationContext } from '../../EntityPrivacyPolicy';
|
||||
import { EntityQueryContext } from '../../EntityQueryContext';
|
||||
import { ViewerContext } from '../../ViewerContext';
|
||||
import { describePrivacyPolicyRule } from '../../utils/__testfixtures__/PrivacyPolicyRuleTestUtils';
|
||||
@@ -20,7 +20,7 @@ describePrivacyPolicyRule(
|
||||
viewerContext: instance(mock(ViewerContext)),
|
||||
queryContext: instance(mock(EntityQueryContext)),
|
||||
evaluationContext:
|
||||
instance(mock<EntityPrivacyPolicyRuleEvaluationContext<any, any, any, any, any>>()),
|
||||
instance(mock<EntityPrivacyPolicyEvaluationContext<any, any, any, any, any>>()),
|
||||
entity: anything(),
|
||||
},
|
||||
],
|
||||
@@ -38,7 +38,7 @@ describePrivacyPolicyRule(
|
||||
viewerContext: instance(mock(ViewerContext)),
|
||||
queryContext: instance(mock(EntityQueryContext)),
|
||||
evaluationContext:
|
||||
instance(mock<EntityPrivacyPolicyRuleEvaluationContext<any, any, any, any, any>>()),
|
||||
instance(mock<EntityPrivacyPolicyEvaluationContext<any, any, any, any, any>>()),
|
||||
entity: anything(),
|
||||
},
|
||||
],
|
||||
@@ -56,7 +56,7 @@ describePrivacyPolicyRule(
|
||||
viewerContext: instance(mock(ViewerContext)),
|
||||
queryContext: instance(mock(EntityQueryContext)),
|
||||
evaluationContext:
|
||||
instance(mock<EntityPrivacyPolicyRuleEvaluationContext<any, any, any, any, any>>()),
|
||||
instance(mock<EntityPrivacyPolicyEvaluationContext<any, any, any, any, any>>()),
|
||||
entity: anything(),
|
||||
},
|
||||
],
|
||||
|
||||
+4
-4
@@ -1,6 +1,6 @@
|
||||
import { anything, instance, mock } from 'ts-mockito';
|
||||
|
||||
import { EntityPrivacyPolicyRuleEvaluationContext } from '../../EntityPrivacyPolicy';
|
||||
import { EntityPrivacyPolicyEvaluationContext } from '../../EntityPrivacyPolicy';
|
||||
import { EntityQueryContext } from '../../EntityQueryContext';
|
||||
import { ViewerContext } from '../../ViewerContext';
|
||||
import { describePrivacyPolicyRule } from '../../utils/__testfixtures__/PrivacyPolicyRuleTestUtils';
|
||||
@@ -20,7 +20,7 @@ describePrivacyPolicyRule(
|
||||
viewerContext: instance(mock(ViewerContext)),
|
||||
queryContext: instance(mock(EntityQueryContext)),
|
||||
evaluationContext:
|
||||
instance(mock<EntityPrivacyPolicyRuleEvaluationContext<any, any, any, any, any>>()),
|
||||
instance(mock<EntityPrivacyPolicyEvaluationContext<any, any, any, any, any>>()),
|
||||
entity: anything(),
|
||||
},
|
||||
],
|
||||
@@ -38,7 +38,7 @@ describePrivacyPolicyRule(
|
||||
viewerContext: instance(mock(ViewerContext)),
|
||||
queryContext: instance(mock(EntityQueryContext)),
|
||||
evaluationContext:
|
||||
instance(mock<EntityPrivacyPolicyRuleEvaluationContext<any, any, any, any, any>>()),
|
||||
instance(mock<EntityPrivacyPolicyEvaluationContext<any, any, any, any, any>>()),
|
||||
entity: anything(),
|
||||
},
|
||||
],
|
||||
@@ -56,7 +56,7 @@ describePrivacyPolicyRule(
|
||||
viewerContext: instance(mock(ViewerContext)),
|
||||
queryContext: instance(mock(EntityQueryContext)),
|
||||
evaluationContext:
|
||||
instance(mock<EntityPrivacyPolicyRuleEvaluationContext<any, any, any, any, any>>()),
|
||||
instance(mock<EntityPrivacyPolicyEvaluationContext<any, any, any, any, any>>()),
|
||||
entity: anything(),
|
||||
},
|
||||
],
|
||||
|
||||
+1
-11
@@ -1,4 +1,4 @@
|
||||
import { anything, instance, mock, when } from 'ts-mockito';
|
||||
import { instance, mock, when } from 'ts-mockito';
|
||||
|
||||
import { EntityCompanionDefinition } from '../../EntityCompanionProvider';
|
||||
import { EntityPrivacyPolicy } from '../../EntityPrivacyPolicy';
|
||||
@@ -96,7 +96,6 @@ describePrivacyPolicyRule(
|
||||
viewerContext: instance(mock(ViewerContext)),
|
||||
queryContext: instance(mock(EntityQueryContext)),
|
||||
evaluationContext: {
|
||||
action: anything(),
|
||||
previousValue: instance(childEntityMock),
|
||||
cascadingDeleteCause: {
|
||||
entity: parentEntity,
|
||||
@@ -110,7 +109,6 @@ describePrivacyPolicyRule(
|
||||
viewerContext: instance(mock(ViewerContext)),
|
||||
queryContext: instance(mock(EntityQueryContext)),
|
||||
evaluationContext: {
|
||||
action: anything(),
|
||||
previousValue: instance(childEntityMock),
|
||||
cascadingDeleteCause: {
|
||||
entity: parentEntity,
|
||||
@@ -126,7 +124,6 @@ describePrivacyPolicyRule(
|
||||
viewerContext: instance(mock(ViewerContext)),
|
||||
queryContext: instance(mock(EntityQueryContext)),
|
||||
evaluationContext: {
|
||||
action: anything(),
|
||||
previousValue: null,
|
||||
cascadingDeleteCause: null,
|
||||
},
|
||||
@@ -137,7 +134,6 @@ describePrivacyPolicyRule(
|
||||
viewerContext: instance(mock(ViewerContext)),
|
||||
queryContext: instance(mock(EntityQueryContext)),
|
||||
evaluationContext: {
|
||||
action: anything(),
|
||||
previousValue: null,
|
||||
cascadingDeleteCause: {
|
||||
entity: instance(unrelatedOtherEntityMock),
|
||||
@@ -151,7 +147,6 @@ describePrivacyPolicyRule(
|
||||
viewerContext: instance(mock(ViewerContext)),
|
||||
queryContext: instance(mock(EntityQueryContext)),
|
||||
evaluationContext: {
|
||||
action: anything(),
|
||||
previousValue: null,
|
||||
cascadingDeleteCause: {
|
||||
entity: otherParentEntity,
|
||||
@@ -165,7 +160,6 @@ describePrivacyPolicyRule(
|
||||
viewerContext: instance(mock(ViewerContext)),
|
||||
queryContext: instance(mock(EntityQueryContext)),
|
||||
evaluationContext: {
|
||||
action: anything(),
|
||||
previousValue: null,
|
||||
cascadingDeleteCause: {
|
||||
entity: parentEntity,
|
||||
@@ -179,7 +173,6 @@ describePrivacyPolicyRule(
|
||||
viewerContext: instance(mock(ViewerContext)),
|
||||
queryContext: instance(mock(EntityQueryContext)),
|
||||
evaluationContext: {
|
||||
action: anything(),
|
||||
previousValue: null,
|
||||
cascadingDeleteCause: {
|
||||
entity: parentEntity,
|
||||
@@ -193,7 +186,6 @@ describePrivacyPolicyRule(
|
||||
viewerContext: instance(mock(ViewerContext)),
|
||||
queryContext: instance(mock(EntityQueryContext)),
|
||||
evaluationContext: {
|
||||
action: anything(),
|
||||
previousValue: instance(childEntityDifferentParentMock),
|
||||
cascadingDeleteCause: {
|
||||
entity: parentEntity,
|
||||
@@ -240,7 +232,6 @@ describePrivacyPolicyRule(
|
||||
viewerContext: instance(mock(ViewerContext)),
|
||||
queryContext: instance(mock(EntityQueryContext)),
|
||||
evaluationContext: {
|
||||
action: anything(),
|
||||
previousValue: instance(childEntityWithNameRefMock),
|
||||
cascadingDeleteCause: {
|
||||
entity: parentEntityWithName,
|
||||
@@ -254,7 +245,6 @@ describePrivacyPolicyRule(
|
||||
viewerContext: instance(mock(ViewerContext)),
|
||||
queryContext: instance(mock(EntityQueryContext)),
|
||||
evaluationContext: {
|
||||
action: anything(),
|
||||
previousValue: instance(childEntityWithNameRefMock),
|
||||
cascadingDeleteCause: {
|
||||
entity: parentEntityWithName,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { anything, instance, mock } from 'ts-mockito';
|
||||
|
||||
import { EntityPrivacyPolicyRuleEvaluationContext } from '../../EntityPrivacyPolicy';
|
||||
import { EntityPrivacyPolicyEvaluationContext } from '../../EntityPrivacyPolicy';
|
||||
import { EntityQueryContext } from '../../EntityQueryContext';
|
||||
import { ViewerContext } from '../../ViewerContext';
|
||||
import { describePrivacyPolicyRule } from '../../utils/__testfixtures__/PrivacyPolicyRuleTestUtils';
|
||||
@@ -12,7 +12,7 @@ describePrivacyPolicyRule(new AlwaysAllowPrivacyPolicyRule(), {
|
||||
viewerContext: instance(mock(ViewerContext)),
|
||||
queryContext: instance(mock(EntityQueryContext)),
|
||||
evaluationContext:
|
||||
instance(mock<EntityPrivacyPolicyRuleEvaluationContext<any, any, any, any, any>>()),
|
||||
instance(mock<EntityPrivacyPolicyEvaluationContext<any, any, any, any, any>>()),
|
||||
entity: anything(),
|
||||
},
|
||||
],
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { anything, instance, mock } from 'ts-mockito';
|
||||
|
||||
import { EntityPrivacyPolicyRuleEvaluationContext } from '../../EntityPrivacyPolicy';
|
||||
import { EntityPrivacyPolicyEvaluationContext } from '../../EntityPrivacyPolicy';
|
||||
import { EntityQueryContext } from '../../EntityQueryContext';
|
||||
import { ViewerContext } from '../../ViewerContext';
|
||||
import { describePrivacyPolicyRule } from '../../utils/__testfixtures__/PrivacyPolicyRuleTestUtils';
|
||||
@@ -12,7 +12,7 @@ describePrivacyPolicyRule(new AlwaysDenyPrivacyPolicyRule(), {
|
||||
viewerContext: instance(mock(ViewerContext)),
|
||||
queryContext: instance(mock(EntityQueryContext)),
|
||||
evaluationContext:
|
||||
instance(mock<EntityPrivacyPolicyRuleEvaluationContext<any, any, any, any, any>>()),
|
||||
instance(mock<EntityPrivacyPolicyEvaluationContext<any, any, any, any, any>>()),
|
||||
entity: anything(),
|
||||
},
|
||||
],
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { anything, instance, mock } from 'ts-mockito';
|
||||
|
||||
import { EntityPrivacyPolicyRuleEvaluationContext } from '../../EntityPrivacyPolicy';
|
||||
import { EntityPrivacyPolicyEvaluationContext } from '../../EntityPrivacyPolicy';
|
||||
import { EntityQueryContext } from '../../EntityQueryContext';
|
||||
import { ViewerContext } from '../../ViewerContext';
|
||||
import { describePrivacyPolicyRule } from '../../utils/__testfixtures__/PrivacyPolicyRuleTestUtils';
|
||||
@@ -12,7 +12,7 @@ describePrivacyPolicyRule(new AlwaysSkipPrivacyPolicyRule(), {
|
||||
viewerContext: instance(mock(ViewerContext)),
|
||||
queryContext: instance(mock(EntityQueryContext)),
|
||||
evaluationContext:
|
||||
instance(mock<EntityPrivacyPolicyRuleEvaluationContext<any, any, any, any, any>>()),
|
||||
instance(mock<EntityPrivacyPolicyEvaluationContext<any, any, any, any, any>>()),
|
||||
entity: anything(),
|
||||
},
|
||||
],
|
||||
|
||||
Loaded 100 of 107 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user