Compare commits

..
118 changed files with 4730 additions and 4550 deletions

No files matched your search

+1 -2
View File
@@ -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
View File
@@ -1,8 +1,2 @@
nodeLinker: node-modules
npmMinimalAgeGate: 2d
npmPreapprovedPackages:
- "@expo/*"
yarnPath: .yarn/releases/yarn-4.12.0.cjs
nodeLinker: node-modules
-51
View File
@@ -3,57 +3,6 @@
All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
# [0.58.0](https://github.com/expo/entity/compare/v0.57.0...v0.58.0) (2026-03-02)
### Bug Fixes
- escape all matching portions of parts of cacher cache keys ([#486](https://github.com/expo/entity/issues/486)) ([21f2f6f](https://github.com/expo/entity/commit/21f2f6f461ee771e8b1c1b22dbaf9b275094c0db))
- fix some security review suggestions ([#485](https://github.com/expo/entity/issues/485)) ([e263bd2](https://github.com/expo/entity/commit/e263bd218cda579365766d0918c4961304bd1d9f))
# [0.57.0](https://github.com/expo/entity/compare/v0.56.0...v0.57.0) (2026-02-28)
**Note:** Version bump only for package root
# [0.56.0](https://github.com/expo/entity/compare/v0.55.0...v0.56.0) (2026-02-28)
### Bug Fixes
- Add default for TSelectedFields for application-instantiated types ([#438](https://github.com/expo/entity/issues/438)) ([3d4aa0e](https://github.com/expo/entity/commit/3d4aa0e9efa519a36f3db69bd8f117a993c100fa))
- Apply same viewer context subclass type mechanism to knexLoader and related methods ([#472](https://github.com/expo/entity/issues/472)) ([018a40b](https://github.com/expo/entity/commit/018a40bc01d579ffbf88f308e641921e6fc39097))
- correct discriminated union type for forward/backward pagination ([#474](https://github.com/expo/entity/issues/474)) ([90ae6e2](https://github.com/expo/entity/commit/90ae6e299bda26ac9a80d83e83334d4152c022dd))
- correct pagination behavior for standard pagination with descending ordering clauses ([#477](https://github.com/expo/entity/issues/477)) ([e32c4c2](https://github.com/expo/entity/commit/e32c4c2bcc05f225a556f95c90f1ee0f4e24de74))
- correct precedence parentheses for SQLFragment joins ([#476](https://github.com/expo/entity/issues/476)) ([2c4c649](https://github.com/expo/entity/commit/2c4c649a4bca5bc9ffdc41eb3135b2dfe4db5e57)), closes [/github.com/expo/entity/blob/main/packages/entity-database-adapter-knex/src/internal/EntityKnexDataManager.ts#L303](https://github.com//github.com/expo/entity/blob/main/packages/entity-database-adapter-knex/src/internal/EntityKnexDataManager.ts/issues/L303)
- **deps:** update dependency @apollo/server to v5.4.0 [security] ([#420](https://github.com/expo/entity/issues/420)) ([cd81c5a](https://github.com/expo/entity/commit/cd81c5a5b6be43ef60248eeea4031a883dd1eae8))
- **deps:** update dependency @koa/bodyparser to v6.1.0 ([#405](https://github.com/expo/entity/issues/405)) ([2dda22d](https://github.com/expo/entity/commit/2dda22d761f87851e3fcccc8443597daf8ac42e8))
- **deps:** update dependency @koa/router to v15.3.0 ([#406](https://github.com/expo/entity/issues/406)) ([9e88cf6](https://github.com/expo/entity/commit/9e88cf6aab76902c65994d28de8dab73f1267021))
- **deps:** update dependency graphql to v16.13.0 ([#479](https://github.com/expo/entity/issues/479)) ([976e2c3](https://github.com/expo/entity/commit/976e2c33867da71a2920d050c4086e2af8f6d54d))
- **deps:** update dependency koa to v3.1.2 [security] ([#484](https://github.com/expo/entity/issues/484)) ([b258dad](https://github.com/expo/entity/commit/b258dad2ed69c473f04d57d26910eab2032d142c))
- eliminate some unnecessary any casts ([#448](https://github.com/expo/entity/issues/448)) ([cc78029](https://github.com/expo/entity/commit/cc78029117c14510b0934b5e0d19876a45570f8d))
- explicitly document behavior when cursor entity no longer exists ([#453](https://github.com/expo/entity/issues/453)) ([4dc156c](https://github.com/expo/entity/commit/4dc156c7a8dc50bc9e2802be133b9e258b289f54)), closes [#422](https://github.com/expo/entity/issues/422) [#431](https://github.com/expo/entity/issues/431)
- move EntityPrivacyUtils back into core package ([#429](https://github.com/expo/entity/issues/429)) ([56ec27d](https://github.com/expo/entity/commit/56ec27d5f201d83930a2cc05dfc380a054bff1e1))
- pass in augmentable classes to installation ([#437](https://github.com/expo/entity/issues/437)) ([f5c728b](https://github.com/expo/entity/commit/f5c728be565e01c0a8ace9ef51f6de8f49184dab)), closes [/github.com/expo/entity/pull/410#pullrequestreview-3765893898](https://github.com//github.com/expo/entity/pull/410/issues/pullrequestreview-3765893898)
- refactor install method to instead be free functions ([#441](https://github.com/expo/entity/issues/441)) ([51d632d](https://github.com/expo/entity/commit/51d632da1619dc039d5c41ce6533f63254ec4337)), closes [#410](https://github.com/expo/entity/issues/410)
- Reorganize constructionUtils to make non-public ([#454](https://github.com/expo/entity/issues/454)) ([d9377a2](https://github.com/expo/entity/commit/d9377a290b7e0c6803f9e83537eddb7204bc9320)), closes [#410](https://github.com/expo/entity/issues/410)
- revert change to codemod now that Entity.knexLoader is supported again via subclass ([#443](https://github.com/expo/entity/issues/443)) ([ed6b2e0](https://github.com/expo/entity/commit/ed6b2e05539c77ee2abd2c6da74e2a451967d06e)), closes [#441](https://github.com/expo/entity/issues/441)
- Type idField in EntityConfiguration as TIDField ([#425](https://github.com/expo/entity/issues/425)) ([be40bcf](https://github.com/expo/entity/commit/be40bcffa6e7db9b7ad75e1a80bbb48ff417c6c5))
- update integration test to use BlahEntity.knexLoader syntax ([#444](https://github.com/expo/entity/issues/444)) ([457f58a](https://github.com/expo/entity/commit/457f58ad2c4e1cf09f3132f3b9af5008ba0290c7)), closes [#441](https://github.com/expo/entity/issues/441)
- Use TSelectedFields for knex loader order by method ([#424](https://github.com/expo/entity/issues/424)) ([a9d09f4](https://github.com/expo/entity/commit/a9d09f42b67378d06133bcfb5b99c9afb17aac0d))
### Features
- Add authorization action to EntityPrivacyPolicyRuleEvaluationContext ([#464](https://github.com/expo/entity/issues/464)) ([e71ae5f](https://github.com/expo/entity/commit/e71ae5fcbae95096e0d6a42a8c960896ab83ccfb))
- add codemod for upcoming v0.55.0-v0.56.0 ([#413](https://github.com/expo/entity/issues/413)) ([aba0ce5](https://github.com/expo/entity/commit/aba0ce5b5f38cb7b01956cde14a4714e2a03abb4)), closes [#407](https://github.com/expo/entity/issues/407) [-#410](https://github.com/-/issues/410)
- add entity-database-adapter-knex-testing-utils containing StubPostgresDatabaseAdapter ([#412](https://github.com/expo/entity/issues/412)) ([eeccf59](https://github.com/expo/entity/commit/eeccf594c11bf090aa83bbf71fb4ead8668aec7d))
- add entityField SQL helper ([#481](https://github.com/expo/entity/issues/481)) ([459a5ff](https://github.com/expo/entity/commit/459a5ff85014a8c7396c0856dffacb1491ccb963))
- Add ilike and trigram similarity search to pagination ([#431](https://github.com/expo/entity/issues/431)) ([f72cbca](https://github.com/expo/entity/commit/f72cbcaf5a722e6bb8299644ec32b07f1d3362a9)), closes [#422](https://github.com/expo/entity/issues/422)
- add method to get pagination cursor for single entity ([#475](https://github.com/expo/entity/issues/475)) ([898f71c](https://github.com/expo/entity/commit/898f71ce082c2593585575b60bd6ef2546717124))
- Add paginated loader to entity-database-adapter-knex ([#422](https://github.com/expo/entity/issues/422)) ([58deffd](https://github.com/expo/entity/commit/58deffd426b0214073ede608cd47c04db2596d60))
- Add pagination max page size configuration ([#436](https://github.com/expo/entity/issues/436)) ([3ec4b5f](https://github.com/expo/entity/commit/3ec4b5f0cd53bf18d6bcfa27dd29b70d3f4d5f19))
- add PostgresEntity/ReadonlyPostgresEntity classes that expose knexLoader methods ([#442](https://github.com/expo/entity/issues/442)) ([40b409f](https://github.com/expo/entity/commit/40b409f6f889febf8b90b8837caecac26ff64174)), closes [#441](https://github.com/expo/entity/issues/441)
- Add raw sqlfragment orderBy capability for pagination ([#457](https://github.com/expo/entity/issues/457)) ([0615ffa](https://github.com/expo/entity/commit/0615ffacffbe9e903a9bf03b171c102f0554b992))
- add support for nulls ordering in order by clauses ([#468](https://github.com/expo/entity/issues/468)) ([6c0838d](https://github.com/expo/entity/commit/6c0838dc577a3b6190e3551ee834925d7bd53bc6))
- support nullable search fields with postgres transforms ([#470](https://github.com/expo/entity/issues/470)) ([53b0604](https://github.com/expo/entity/commit/53b0604152b8ca5ad9fc616d18478f93473c7eb0))
# [0.55.0](https://github.com/expo/entity/compare/v0.54.0...v0.55.0) (2026-01-27)
### Bug Fixes
+1 -1
View File
@@ -1,6 +1,6 @@
{
"packages": ["packages/*"],
"npmClient": "yarn",
"version": "0.58.0",
"version": "0.55.0",
"$schema": "node_modules/lerna/schemas/lerna-schema.json"
}
+5 -5
View File
@@ -26,23 +26,23 @@
"@tsconfig/strictest": "2.0.8",
"@types/babel__core": "7.20.5",
"@types/babel__preset-env": "7.10.0",
"@types/node": "24.10.15",
"@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.2",
"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,20 +3,6 @@
All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
# [0.58.0](https://github.com/expo/entity/compare/v0.57.0...v0.58.0) (2026-03-02)
### Bug Fixes
- escape all matching portions of parts of cacher cache keys ([#486](https://github.com/expo/entity/issues/486)) ([21f2f6f](https://github.com/expo/entity/commit/21f2f6f461ee771e8b1c1b22dbaf9b275094c0db))
# [0.57.0](https://github.com/expo/entity/compare/v0.56.0...v0.57.0) (2026-02-28)
**Note:** Version bump only for package @expo/entity-cache-adapter-local-memory
# [0.56.0](https://github.com/expo/entity/compare/v0.55.0...v0.56.0) (2026-02-28)
**Note:** Version bump only for package @expo/entity-cache-adapter-local-memory
# [0.55.0](https://github.com/expo/entity/compare/v0.54.0...v0.55.0) (2026-01-27)
**Note:** Version bump only for package @expo/entity-cache-adapter-local-memory
@@ -1,6 +1,6 @@
{
"name": "@expo/entity-cache-adapter-local-memory",
"version": "0.58.0",
"version": "0.55.0",
"description": "Cross-request local memory cache adapter for @expo/entity",
"files": [
"build",
@@ -122,7 +122,7 @@ export class GenericLocalMemoryCacher<
const delimiter = ':';
const escapedParts = parts.map((part) =>
part.replaceAll('\\', '\\\\').replaceAll(delimiter, `\\${delimiter}`),
part.replace('\\', '\\\\').replace(delimiter, `\\${delimiter}`),
);
return escapedParts.join(delimiter);
}
@@ -3,22 +3,6 @@
All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
# [0.58.0](https://github.com/expo/entity/compare/v0.57.0...v0.58.0) (2026-03-02)
### Bug Fixes
- escape all matching portions of parts of cacher cache keys ([#486](https://github.com/expo/entity/issues/486)) ([21f2f6f](https://github.com/expo/entity/commit/21f2f6f461ee771e8b1c1b22dbaf9b275094c0db))
# [0.57.0](https://github.com/expo/entity/compare/v0.56.0...v0.57.0) (2026-02-28)
**Note:** Version bump only for package @expo/entity-cache-adapter-redis
# [0.56.0](https://github.com/expo/entity/compare/v0.55.0...v0.56.0) (2026-02-28)
### Bug Fixes
- eliminate some unnecessary any casts ([#448](https://github.com/expo/entity/issues/448)) ([cc78029](https://github.com/expo/entity/commit/cc78029117c14510b0934b5e0d19876a45570f8d))
# [0.55.0](https://github.com/expo/entity/compare/v0.54.0...v0.55.0) (2026-01-27)
**Note:** Version bump only for package @expo/entity-cache-adapter-redis
@@ -16,7 +16,7 @@ const genericRedisCacherContext = {
makeKeyFn(...parts: string[]): string {
const delimiter = ':';
const escapedParts = parts.map((part) =>
part.replaceAll('\\', '\\\\').replaceAll(delimiter, `\\${delimiter}`)
part.replace('\\', '\\\\').replace(delimiter, `\\${delimiter}`)
);
return escapedParts.join(delimiter);
},
@@ -1,6 +1,6 @@
{
"name": "@expo/entity-cache-adapter-redis",
"version": "0.58.0",
"version": "0.55.0",
"description": "Redis cache adapter for @expo/entity",
"files": [
"build",
@@ -37,7 +37,7 @@
"@expo/batcher": "1.0.0",
"@expo/entity-testing-utils": "workspace:^",
"@jest/globals": "30.2.0",
"ioredis": "5.10.0",
"ioredis": "5.9.3",
"ts-mockito": "2.6.1",
"typescript": "5.9.3"
}
@@ -215,7 +215,7 @@ export class GenericRedisCacher<
this.context.cacheKeyPrefix,
cacheKeyType,
this.entityConfiguration.tableName,
`v3.${cacheKeyVersion}`,
`v2.${cacheKeyVersion}`,
...parts,
);
}
@@ -80,7 +80,7 @@ describe(GenericRedisCacher, () => {
makeKeyFn(...parts: string[]): string {
const delimiter = ':';
const escapedParts = parts.map((part) =>
part.replaceAll('\\', '\\\\').replaceAll(delimiter, `\\${delimiter}`),
part.replace('\\', '\\\\').replace(delimiter, `\\${delimiter}`),
);
return escapedParts.join(delimiter);
},
@@ -31,7 +31,7 @@ describe(GenericRedisCacher, () => {
makeKeyFn(...parts: string[]): string {
const delimiter = ':';
const escapedParts = parts.map((part) =>
part.replaceAll('\\', '\\\\').replaceAll(delimiter, `\\${delimiter}`),
part.replace('\\', '\\\\').replace(delimiter, `\\${delimiter}`),
);
return escapedParts.join(delimiter);
},
@@ -26,7 +26,7 @@ describe(GenericRedisCacher, () => {
makeKeyFn(...parts: string[]): string {
const delimiter = ':';
const escapedParts = parts.map((part) =>
part.replaceAll('\\', '\\\\').replaceAll(delimiter, `\\${delimiter}`),
part.replace('\\', '\\\\').replace(delimiter, `\\${delimiter}`),
);
return escapedParts.join(delimiter);
},
@@ -23,7 +23,7 @@ describe(GenericRedisCacher, () => {
makeKeyFn(...parts: string[]): string {
const delimiter = ':';
const escapedParts = parts.map((part) =>
part.replaceAll('\\', '\\\\').replaceAll(delimiter, `\\${delimiter}`),
part.replace('\\', '\\\\').replace(delimiter, `\\${delimiter}`),
);
return escapedParts.join(delimiter);
},
@@ -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>();
@@ -212,7 +212,7 @@ describe(GenericRedisCacher, () => {
new SingleFieldValueHolder('wat'),
);
expect(cacheKeys).toHaveLength(1);
expect(cacheKeys[0]).toBe('hello-:single:blah:v3.2:id:wat');
expect(cacheKeys[0]).toBe('hello-:single:blah:v2.2:id:wat');
await genericCacher.invalidateManyAsync(cacheKeys);
verify(mockRedisClient.del(...cacheKeys)).once();
@@ -240,9 +240,9 @@ describe(GenericRedisCacher, () => {
new SingleFieldValueHolder('wat'),
);
expect(cacheKeys).toHaveLength(3);
expect(cacheKeys[0]).toBe('hello-:single:blah:v3.1:id:wat');
expect(cacheKeys[1]).toBe('hello-:single:blah:v3.2:id:wat');
expect(cacheKeys[2]).toBe('hello-:single:blah:v3.3:id:wat');
expect(cacheKeys[0]).toBe('hello-:single:blah:v2.1:id:wat');
expect(cacheKeys[1]).toBe('hello-:single:blah:v2.2:id:wat');
expect(cacheKeys[2]).toBe('hello-:single:blah:v2.3:id:wat');
await genericCacher.invalidateManyAsync(cacheKeys);
verify(mockRedisClient.del(...cacheKeys)).once();
@@ -278,10 +278,10 @@ describe(GenericRedisCacher, () => {
new SingleFieldValueHolder('wat'),
);
expect(cacheKeys).toHaveLength(4);
expect(cacheKeys[0]).toBe('hello-:single:blah:v3.2:id:wat');
expect(cacheKeys[1]).toBe('hello-:single:blah:v3.3:id:wat');
expect(cacheKeys[2]).toBe('hello-:single:blah:v3.4:id:wat');
expect(cacheKeys[3]).toBe('hello-:single:blah:v3.5:id:wat');
expect(cacheKeys[0]).toBe('hello-:single:blah:v2.2:id:wat');
expect(cacheKeys[1]).toBe('hello-:single:blah:v2.3:id:wat');
expect(cacheKeys[2]).toBe('hello-:single:blah:v2.4:id:wat');
expect(cacheKeys[3]).toBe('hello-:single:blah:v2.5:id:wat');
await genericCacher.invalidateManyAsync(cacheKeys);
verify(mockRedisClient.del(...cacheKeys)).once();
-19
View File
@@ -3,25 +3,6 @@
All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
# [0.58.0](https://github.com/expo/entity/compare/v0.57.0...v0.58.0) (2026-03-02)
**Note:** Version bump only for package @expo/entity-codemod
# [0.57.0](https://github.com/expo/entity/compare/v0.56.0...v0.57.0) (2026-02-28)
**Note:** Version bump only for package @expo/entity-codemod
# [0.56.0](https://github.com/expo/entity/compare/v0.55.0...v0.56.0) (2026-02-28)
### Bug Fixes
- refactor install method to instead be free functions ([#441](https://github.com/expo/entity/issues/441)) ([51d632d](https://github.com/expo/entity/commit/51d632da1619dc039d5c41ce6533f63254ec4337)), closes [#410](https://github.com/expo/entity/issues/410)
- revert change to codemod now that Entity.knexLoader is supported again via subclass ([#443](https://github.com/expo/entity/issues/443)) ([ed6b2e0](https://github.com/expo/entity/commit/ed6b2e05539c77ee2abd2c6da74e2a451967d06e)), closes [#441](https://github.com/expo/entity/issues/441)
### Features
- add codemod for upcoming v0.55.0-v0.56.0 ([#413](https://github.com/expo/entity/issues/413)) ([aba0ce5](https://github.com/expo/entity/commit/aba0ce5b5f38cb7b01956cde14a4714e2a03abb4)), closes [#407](https://github.com/expo/entity/issues/407) [-#410](https://github.com/-/issues/410)
# [0.55.0](https://github.com/expo/entity/compare/v0.54.0...v0.55.0) (2026-01-27)
**Note:** Version bump only for package @expo/entity-codemod
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@expo/entity-codemod",
"version": "0.58.0",
"version": "0.55.0",
"description": "jscodeshift codemods for @expo/entity upgrades",
"files": [
"build",
@@ -29,7 +29,7 @@
"devDependencies": {
"@jest/globals": "30.2.0",
"@types/jscodeshift": "17.3.0",
"@types/node": "24.10.15",
"@types/node": "24.10.13",
"jscodeshift": "17.3.0",
"typescript": "5.9.3"
}
@@ -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,27 +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.58.0](https://github.com/expo/entity/compare/v0.57.0...v0.58.0) (2026-03-02)
**Note:** Version bump only for package @expo/entity-database-adapter-knex-testing-utils
# [0.57.0](https://github.com/expo/entity/compare/v0.56.0...v0.57.0) (2026-02-28)
**Note:** Version bump only for package @expo/entity-database-adapter-knex-testing-utils
# [0.56.0](https://github.com/expo/entity/compare/v0.55.0...v0.56.0) (2026-02-28)
### Bug Fixes
- move EntityPrivacyUtils back into core package ([#429](https://github.com/expo/entity/issues/429)) ([56ec27d](https://github.com/expo/entity/commit/56ec27d5f201d83930a2cc05dfc380a054bff1e1))
- pass in augmentable classes to installation ([#437](https://github.com/expo/entity/issues/437)) ([f5c728b](https://github.com/expo/entity/commit/f5c728be565e01c0a8ace9ef51f6de8f49184dab)), closes [/github.com/expo/entity/pull/410#pullrequestreview-3765893898](https://github.com//github.com/expo/entity/pull/410/issues/pullrequestreview-3765893898)
- refactor install method to instead be free functions ([#441](https://github.com/expo/entity/issues/441)) ([51d632d](https://github.com/expo/entity/commit/51d632da1619dc039d5c41ce6533f63254ec4337)), closes [#410](https://github.com/expo/entity/issues/410)
### Features
- add entity-database-adapter-knex-testing-utils containing StubPostgresDatabaseAdapter ([#412](https://github.com/expo/entity/issues/412)) ([eeccf59](https://github.com/expo/entity/commit/eeccf594c11bf090aa83bbf71fb4ead8668aec7d))
- add entityField SQL helper ([#481](https://github.com/expo/entity/issues/481)) ([459a5ff](https://github.com/expo/entity/commit/459a5ff85014a8c7396c0856dffacb1491ccb963))
- Add raw sqlfragment orderBy capability for pagination ([#457](https://github.com/expo/entity/issues/457)) ([0615ffa](https://github.com/expo/entity/commit/0615ffacffbe9e903a9bf03b171c102f0554b992))
- add support for nulls ordering in order by clauses ([#468](https://github.com/expo/entity/issues/468)) ([6c0838d](https://github.com/expo/entity/commit/6c0838dc577a3b6190e3551ee834925d7bd53bc6))
@@ -1,6 +1,6 @@
{
"name": "@expo/entity-database-adapter-knex-testing-utils",
"version": "0.58.0",
"version": "0.55.0",
"description": "Testing utilities for applications using Entity with Knex database adapter",
"files": [
"build",
@@ -38,7 +38,7 @@
"devDependencies": {
"@jest/globals": "30.2.0",
"@types/invariant": "2.2.37",
"@types/node": "24.10.15",
"@types/node": "24.10.13",
"@types/uuid": "10.0.0",
"typescript": "5.9.3"
}
@@ -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');
}
@@ -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,46 +3,6 @@
All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
# [0.58.0](https://github.com/expo/entity/compare/v0.57.0...v0.58.0) (2026-03-02)
### Bug Fixes
- fix some security review suggestions ([#485](https://github.com/expo/entity/issues/485)) ([e263bd2](https://github.com/expo/entity/commit/e263bd218cda579365766d0918c4961304bd1d9f))
# [0.57.0](https://github.com/expo/entity/compare/v0.56.0...v0.57.0) (2026-02-28)
**Note:** Version bump only for package @expo/entity-database-adapter-knex
# [0.56.0](https://github.com/expo/entity/compare/v0.55.0...v0.56.0) (2026-02-28)
### Bug Fixes
- Add default for TSelectedFields for application-instantiated types ([#438](https://github.com/expo/entity/issues/438)) ([3d4aa0e](https://github.com/expo/entity/commit/3d4aa0e9efa519a36f3db69bd8f117a993c100fa))
- Apply same viewer context subclass type mechanism to knexLoader and related methods ([#472](https://github.com/expo/entity/issues/472)) ([018a40b](https://github.com/expo/entity/commit/018a40bc01d579ffbf88f308e641921e6fc39097))
- correct discriminated union type for forward/backward pagination ([#474](https://github.com/expo/entity/issues/474)) ([90ae6e2](https://github.com/expo/entity/commit/90ae6e299bda26ac9a80d83e83334d4152c022dd))
- correct pagination behavior for standard pagination with descending ordering clauses ([#477](https://github.com/expo/entity/issues/477)) ([e32c4c2](https://github.com/expo/entity/commit/e32c4c2bcc05f225a556f95c90f1ee0f4e24de74))
- correct precedence parentheses for SQLFragment joins ([#476](https://github.com/expo/entity/issues/476)) ([2c4c649](https://github.com/expo/entity/commit/2c4c649a4bca5bc9ffdc41eb3135b2dfe4db5e57)), closes [/github.com/expo/entity/blob/main/packages/entity-database-adapter-knex/src/internal/EntityKnexDataManager.ts#L303](https://github.com//github.com/expo/entity/blob/main/packages/entity-database-adapter-knex/src/internal/EntityKnexDataManager.ts/issues/L303)
- eliminate some unnecessary any casts ([#448](https://github.com/expo/entity/issues/448)) ([cc78029](https://github.com/expo/entity/commit/cc78029117c14510b0934b5e0d19876a45570f8d))
- explicitly document behavior when cursor entity no longer exists ([#453](https://github.com/expo/entity/issues/453)) ([4dc156c](https://github.com/expo/entity/commit/4dc156c7a8dc50bc9e2802be133b9e258b289f54)), closes [#422](https://github.com/expo/entity/issues/422) [#431](https://github.com/expo/entity/issues/431)
- move EntityPrivacyUtils back into core package ([#429](https://github.com/expo/entity/issues/429)) ([56ec27d](https://github.com/expo/entity/commit/56ec27d5f201d83930a2cc05dfc380a054bff1e1))
- pass in augmentable classes to installation ([#437](https://github.com/expo/entity/issues/437)) ([f5c728b](https://github.com/expo/entity/commit/f5c728be565e01c0a8ace9ef51f6de8f49184dab)), closes [/github.com/expo/entity/pull/410#pullrequestreview-3765893898](https://github.com//github.com/expo/entity/pull/410/issues/pullrequestreview-3765893898)
- refactor install method to instead be free functions ([#441](https://github.com/expo/entity/issues/441)) ([51d632d](https://github.com/expo/entity/commit/51d632da1619dc039d5c41ce6533f63254ec4337)), closes [#410](https://github.com/expo/entity/issues/410)
- update integration test to use BlahEntity.knexLoader syntax ([#444](https://github.com/expo/entity/issues/444)) ([457f58a](https://github.com/expo/entity/commit/457f58ad2c4e1cf09f3132f3b9af5008ba0290c7)), closes [#441](https://github.com/expo/entity/issues/441)
- Use TSelectedFields for knex loader order by method ([#424](https://github.com/expo/entity/issues/424)) ([a9d09f4](https://github.com/expo/entity/commit/a9d09f42b67378d06133bcfb5b99c9afb17aac0d))
### Features
- add entity-database-adapter-knex-testing-utils containing StubPostgresDatabaseAdapter ([#412](https://github.com/expo/entity/issues/412)) ([eeccf59](https://github.com/expo/entity/commit/eeccf594c11bf090aa83bbf71fb4ead8668aec7d))
- add entityField SQL helper ([#481](https://github.com/expo/entity/issues/481)) ([459a5ff](https://github.com/expo/entity/commit/459a5ff85014a8c7396c0856dffacb1491ccb963))
- Add ilike and trigram similarity search to pagination ([#431](https://github.com/expo/entity/issues/431)) ([f72cbca](https://github.com/expo/entity/commit/f72cbcaf5a722e6bb8299644ec32b07f1d3362a9)), closes [#422](https://github.com/expo/entity/issues/422)
- add method to get pagination cursor for single entity ([#475](https://github.com/expo/entity/issues/475)) ([898f71c](https://github.com/expo/entity/commit/898f71ce082c2593585575b60bd6ef2546717124))
- Add paginated loader to entity-database-adapter-knex ([#422](https://github.com/expo/entity/issues/422)) ([58deffd](https://github.com/expo/entity/commit/58deffd426b0214073ede608cd47c04db2596d60))
- Add pagination max page size configuration ([#436](https://github.com/expo/entity/issues/436)) ([3ec4b5f](https://github.com/expo/entity/commit/3ec4b5f0cd53bf18d6bcfa27dd29b70d3f4d5f19))
- add PostgresEntity/ReadonlyPostgresEntity classes that expose knexLoader methods ([#442](https://github.com/expo/entity/issues/442)) ([40b409f](https://github.com/expo/entity/commit/40b409f6f889febf8b90b8837caecac26ff64174)), closes [#441](https://github.com/expo/entity/issues/441)
- Add raw sqlfragment orderBy capability for pagination ([#457](https://github.com/expo/entity/issues/457)) ([0615ffa](https://github.com/expo/entity/commit/0615ffacffbe9e903a9bf03b171c102f0554b992))
- add support for nulls ordering in order by clauses ([#468](https://github.com/expo/entity/issues/468)) ([6c0838d](https://github.com/expo/entity/commit/6c0838dc577a3b6190e3551ee834925d7bd53bc6))
- support nullable search fields with postgres transforms ([#470](https://github.com/expo/entity/issues/470)) ([53b0604](https://github.com/expo/entity/commit/53b0604152b8ca5ad9fc616d18478f93473c7eb0))
# [0.55.0](https://github.com/expo/entity/compare/v0.54.0...v0.55.0) (2026-01-27)
**Note:** Version bump only for package @expo/entity-database-adapter-knex
@@ -1,6 +1,6 @@
{
"name": "@expo/entity-database-adapter-knex",
"version": "0.58.0",
"version": "0.55.0",
"description": "Knex database adapter for @expo/entity",
"files": [
"build",
@@ -34,7 +34,7 @@
"devDependencies": {
"@expo/entity-testing-utils": "workspace:^",
"@jest/globals": "30.2.0",
"pg": "8.19.0",
"pg": "8.18.0",
"ts-mockito": "2.6.1",
"typescript": "5.9.3"
}
@@ -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,69 +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 to make nullable fields non-null or to make combinations of multiple fields.
*/
export type EntityLoaderSearchFieldSQLFragmentFnSpecification<
TFields extends Record<string, any>,
TSelectedFields extends keyof TFields = keyof TFields,
> = {
> extends EntityLoaderQuerySelectionModifiers<TFields, TSelectedFields> {
/**
* 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>,
@@ -158,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<
@@ -203,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<
@@ -218,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>[];
}
/**
@@ -242,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.
@@ -262,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;
}
/**
@@ -287,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.
*/
@@ -360,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'
@@ -379,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) {
@@ -401,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,
@@ -423,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,
@@ -479,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());
}
}
/**
@@ -517,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,18 +1,13 @@
import {
EntityConfiguration,
FieldTransformer,
FieldTransformerMap,
getDatabaseFieldForEntityField,
} from '@expo/entity';
import { EntityConfiguration, FieldTransformer, FieldTransformerMap } from '@expo/entity';
import { Knex } from 'knex';
import {
BasePostgresEntityDatabaseAdapter,
NullsOrdering,
OrderByOrdering,
TableFieldMultiValueEqualityCondition,
TableFieldSingleValueEqualityCondition,
TableQuerySelectionModifiers,
TableQuerySelectionModifiersWithOrderByFragment,
TableQuerySelectionModifiersWithOrderByRaw,
} from './BasePostgresEntityDatabaseAdapter';
import { JSONArrayField, MaybeJSONArrayField } from './EntityFields';
import { PostgresEntityDatabaseAdapterConfiguration } from './PostgresEntityDatabaseAdapterProvider';
@@ -123,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;
@@ -133,25 +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 orderDirection =
orderBySpecification.order === OrderByOrdering.ASCENDING ? 'ASC' : 'DESC';
const nullsSuffix = orderBySpecification.nulls
? ` NULLS ${orderBySpecification.nulls === NullsOrdering.FIRST ? 'FIRST' : 'LAST'}`
: '';
ret = ret.orderByRaw(
`(${orderBySpecification.columnFragment.sql}) ${orderDirection}${nullsSuffix}`,
orderBySpecification.columnFragment.getKnexBindings((fieldName) =>
getDatabaseFieldForEntityField(this.entityConfiguration, fieldName),
),
);
}
ret = ret.orderBy(orderBySpecification.columnName, orderBySpecification.order);
}
}
@@ -171,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);
@@ -217,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);
}
@@ -1,5 +1,3 @@
import assert from 'assert';
/**
* Supported SQL value types that can be safely parameterized.
* This ensures type safety and prevents passing unsupported types to SQL queries.
@@ -19,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 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;
}
});
}
@@ -57,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),
);
}
/**
@@ -86,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;
@@ -114,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 {
@@ -193,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
@@ -223,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.
@@ -243,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);
}
/**
@@ -262,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;
@@ -288,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 += '?';
@@ -310,23 +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];
type PickStringValueKeys<T> = {
[K in keyof T]: T[K] extends string | null | undefined ? K : never;
}[keyof T];
type JsonSerializable =
| string
| number
| boolean
| null
| undefined
| readonly JsonSerializable[]
| { readonly [key: string]: JsonSerializable };
/**
* Common SQL helper functions for building queries
*/
@@ -336,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[]}`;
},
/**
@@ -370,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}`;
},
/**
@@ -398,233 +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: PickStringValueKeys<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: PickStringValueKeys<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: PickStringValueKeys<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: PickStringValueKeys<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: JsonSerializable,
): SQLFragment<TFields> {
const serialized = JSON.stringify(value);
// JSON.stringify returns undefined for unsupported types, but we also want to allow undefined as a value
assert(
serialized !== undefined || value === undefined,
'jsonContains: value is not JSON-serializable',
);
return sql`${entityField(fieldName)} @> ${serialized}::jsonb`;
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: JsonSerializable,
): SQLFragment<TFields> {
const serialized = JSON.stringify(value);
// JSON.stringify returns undefined for unsupported types, but we also want to allow undefined as a value
assert(
serialized !== undefined || value === undefined,
'jsonContainedBy: value is not JSON-serializable',
);
return sql`${entityField(fieldName)} <@ ${serialized}::jsonb`;
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),
);
}
@@ -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(
@@ -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,376 +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}',
]);
});
it('generates JSON contains for null and undefined values', () => {
const fragmentNull = SQLFragmentHelpers.jsonContains('stringField', null);
const fragmentUndefined = SQLFragmentHelpers.jsonContains('stringField', undefined);
expect(fragmentNull.sql).toBe('?? @> ?::jsonb');
expect(fragmentNull.getKnexBindings(getColumnForField)).toEqual(['string_field', 'null']);
expect(fragmentUndefined.sql).toBe('?? @> ?::jsonb');
expect(fragmentUndefined.getKnexBindings(getColumnForField)).toEqual([
'string_field',
undefined,
]);
});
it('throws when value is not JSON-serializable', () => {
expect(() => SQLFragmentHelpers.jsonContains('stringField', (() => {}) as any)).toThrow(
'jsonContains: value is not JSON-serializable',
);
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"}',
]);
});
it('generates JSON contained by for null and undefined values', () => {
const fragmentNull = SQLFragmentHelpers.jsonContainedBy('stringField', null);
const fragmentUndefined = SQLFragmentHelpers.jsonContainedBy('stringField', undefined);
expect(fragmentNull.sql).toBe('?? <@ ?::jsonb');
expect(fragmentNull.getKnexBindings(getColumnForField)).toEqual(['string_field', 'null']);
expect(fragmentUndefined.sql).toBe('?? <@ ?::jsonb');
expect(fragmentUndefined.getKnexBindings(getColumnForField)).toEqual([
'string_field',
undefined,
]);
});
it('throws when value is not JSON-serializable', () => {
expect(() => SQLFragmentHelpers.jsonContainedBy('stringField', (() => {}) as any)).toThrow(
'jsonContainedBy: value is not JSON-serializable',
);
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([]);
});
});
@@ -773,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([]);
});
});
@@ -799,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([]);
});
});
@@ -825,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']);
});
});
@@ -835,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',
]);
});
});
@@ -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()
-21
View File
@@ -3,27 +3,6 @@
All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
# [0.58.0](https://github.com/expo/entity/compare/v0.57.0...v0.58.0) (2026-03-02)
**Note:** Version bump only for package @expo/entity-example
# [0.57.0](https://github.com/expo/entity/compare/v0.56.0...v0.57.0) (2026-02-28)
**Note:** Version bump only for package @expo/entity-example
# [0.56.0](https://github.com/expo/entity/compare/v0.55.0...v0.56.0) (2026-02-28)
### Bug Fixes
- **deps:** update dependency @apollo/server to v5.4.0 [security] ([#420](https://github.com/expo/entity/issues/420)) ([cd81c5a](https://github.com/expo/entity/commit/cd81c5a5b6be43ef60248eeea4031a883dd1eae8))
- **deps:** update dependency @koa/bodyparser to v6.1.0 ([#405](https://github.com/expo/entity/issues/405)) ([2dda22d](https://github.com/expo/entity/commit/2dda22d761f87851e3fcccc8443597daf8ac42e8))
- **deps:** update dependency @koa/router to v15.3.0 ([#406](https://github.com/expo/entity/issues/406)) ([9e88cf6](https://github.com/expo/entity/commit/9e88cf6aab76902c65994d28de8dab73f1267021))
- **deps:** update dependency graphql to v16.13.0 ([#479](https://github.com/expo/entity/issues/479)) ([976e2c3](https://github.com/expo/entity/commit/976e2c33867da71a2920d050c4086e2af8f6d54d))
- **deps:** update dependency koa to v3.1.2 [security] ([#484](https://github.com/expo/entity/issues/484)) ([b258dad](https://github.com/expo/entity/commit/b258dad2ed69c473f04d57d26910eab2032d142c))
- eliminate some unnecessary any casts ([#448](https://github.com/expo/entity/issues/448)) ([cc78029](https://github.com/expo/entity/commit/cc78029117c14510b0934b5e0d19876a45570f8d))
- move EntityPrivacyUtils back into core package ([#429](https://github.com/expo/entity/issues/429)) ([56ec27d](https://github.com/expo/entity/commit/56ec27d5f201d83930a2cc05dfc380a054bff1e1))
- refactor install method to instead be free functions ([#441](https://github.com/expo/entity/issues/441)) ([51d632d](https://github.com/expo/entity/commit/51d632da1619dc039d5c41ce6533f63254ec4337)), closes [#410](https://github.com/expo/entity/issues/410)
# [0.55.0](https://github.com/expo/entity/compare/v0.54.0...v0.55.0) (2026-01-27)
### Bug Fixes
+4 -4
View File
@@ -2,7 +2,7 @@
"name": "@expo/entity-example",
"type": "module",
"private": true,
"version": "0.58.0",
"version": "0.55.0",
"description": "An example integration of the @expo/entity framework",
"scripts": {
"build": "tsc --build",
@@ -29,15 +29,15 @@
"@koa/bodyparser": "6.1.0",
"@koa/cors": "5.0.0",
"@koa/router": "15.3.0",
"graphql": "16.13.0",
"koa": "3.1.2"
"graphql": "16.12.0",
"koa": "3.1.1"
},
"devDependencies": {
"@jest/globals": "30.2.0",
"@types/koa": "3.0.1",
"@types/koa__cors": "5.0.1",
"@types/koa__router": "12.0.5",
"@types/supertest": "7.2.0",
"@types/supertest": "6.0.3",
"supertest": "7.2.2",
"typescript": "5.9.3"
}
+4 -8
View File
@@ -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,20 +3,6 @@
All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
# [0.58.0](https://github.com/expo/entity/compare/v0.57.0...v0.58.0) (2026-03-02)
### Bug Fixes
- escape all matching portions of parts of cacher cache keys ([#486](https://github.com/expo/entity/issues/486)) ([21f2f6f](https://github.com/expo/entity/commit/21f2f6f461ee771e8b1c1b22dbaf9b275094c0db))
# [0.57.0](https://github.com/expo/entity/compare/v0.56.0...v0.57.0) (2026-02-28)
**Note:** Version bump only for package @expo/entity-full-integration-tests
# [0.56.0](https://github.com/expo/entity/compare/v0.55.0...v0.56.0) (2026-02-28)
**Note:** Version bump only for package @expo/entity-full-integration-tests
# [0.55.0](https://github.com/expo/entity/compare/v0.54.0...v0.55.0) (2026-01-27)
**Note:** Version bump only for package @expo/entity-full-integration-tests
@@ -1,7 +1,7 @@
{
"name": "@expo/entity-full-integration-tests",
"private": true,
"version": "0.58.0",
"version": "0.55.0",
"description": "Full redis and knex integration tests for the entity framework",
"scripts": {
"build": "tsc --build",
@@ -118,7 +118,7 @@ describe('Entity cache inconsistency', () => {
makeKeyFn(...parts: string[]): string {
const delimiter = ':';
const escapedParts = parts.map((part) =>
part.replaceAll('\\', '\\\\').replaceAll(delimiter, `\\${delimiter}`),
part.replace('\\', '\\\\').replace(delimiter, `\\${delimiter}`),
);
return escapedParts.join(delimiter);
},
@@ -181,7 +181,7 @@ describe('Entity cache inconsistency', () => {
openBarrier2!();
})(),
viewerContext.runInTransactionForDatabaseAdapterFlavorAsync(
viewerContext.runInTransactionForDatabaseAdaptorFlavorAsync(
'postgres',
async (queryContext) => {
await TestEntity.updater(entity1, queryContext)
@@ -118,7 +118,7 @@ describe('Lack of entity cache push safety with RedisCacheInvalidationStrategy.C
makeKeyFn(...parts: string[]): string {
const delimiter = ':';
const escapedParts = parts.map((part) =>
part.replaceAll('\\', '\\\\').replaceAll(delimiter, `\\${delimiter}`),
part.replace('\\', '\\\\').replace(delimiter, `\\${delimiter}`),
);
return escapedParts.join(delimiter);
},
@@ -210,7 +210,7 @@ describe('Entity cache push safety with RedisCacheInvalidationStrategy.SURROUNDI
makeKeyFn(...parts: string[]): string {
const delimiter = ':';
const escapedParts = parts.map((part) =>
part.replaceAll('\\', '\\\\').replaceAll(delimiter, `\\${delimiter}`),
part.replace('\\', '\\\\').replace(delimiter, `\\${delimiter}`),
);
return escapedParts.join(delimiter);
},
@@ -56,7 +56,7 @@ describe('EntityMutator.processEntityDeletionForInboundEdgesAsync', () => {
makeKeyFn(...parts: string[]): string {
const delimiter = ':';
const escapedParts = parts.map((part) =>
part.replaceAll('\\', '\\\\').replaceAll(delimiter, `\\${delimiter}`),
part.replace('\\', '\\\\').replace(delimiter, `\\${delimiter}`),
);
return escapedParts.join(delimiter);
},
@@ -107,7 +107,7 @@ describe('Entity integrity', () => {
makeKeyFn(...parts: string[]): string {
const delimiter = ':';
const escapedParts = parts.map((part) =>
part.replaceAll('\\', '\\\\').replaceAll(delimiter, `\\${delimiter}`),
part.replace('\\', '\\\\').replace(delimiter, `\\${delimiter}`),
);
return escapedParts.join(delimiter);
},
@@ -195,7 +195,7 @@ describe('EntityMutator.processEntityDeletionForInboundEdgesAsync', () => {
makeKeyFn(...parts: string[]): string {
const delimiter = ':';
const escapedParts = parts.map((part) =>
part.replaceAll('\\', '\\\\').replaceAll(delimiter, `\\${delimiter}`),
part.replace('\\', '\\\\').replace(delimiter, `\\${delimiter}`),
);
return escapedParts.join(delimiter);
},
@@ -3,18 +3,6 @@
All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
# [0.58.0](https://github.com/expo/entity/compare/v0.57.0...v0.58.0) (2026-03-02)
**Note:** Version bump only for package @expo/entity-ip-address-field
# [0.57.0](https://github.com/expo/entity/compare/v0.56.0...v0.57.0) (2026-02-28)
**Note:** Version bump only for package @expo/entity-ip-address-field
# [0.56.0](https://github.com/expo/entity/compare/v0.55.0...v0.56.0) (2026-02-28)
**Note:** Version bump only for package @expo/entity-ip-address-field
# [0.55.0](https://github.com/expo/entity/compare/v0.54.0...v0.55.0) (2026-01-27)
**Note:** Version bump only for package @expo/entity-ip-address-field
@@ -1,6 +1,6 @@
{
"name": "@expo/entity-ip-address-field",
"version": "0.58.0",
"version": "0.55.0",
"description": "IP address EntityField definitions for @expo/entity",
"files": [
"build",
@@ -3,20 +3,6 @@
All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
# [0.58.0](https://github.com/expo/entity/compare/v0.57.0...v0.58.0) (2026-03-02)
**Note:** Version bump only for package @expo/entity-secondary-cache-local-memory
# [0.57.0](https://github.com/expo/entity/compare/v0.56.0...v0.57.0) (2026-02-28)
**Note:** Version bump only for package @expo/entity-secondary-cache-local-memory
# [0.56.0](https://github.com/expo/entity/compare/v0.55.0...v0.56.0) (2026-02-28)
### Bug Fixes
- Reorganize constructionUtils to make non-public ([#454](https://github.com/expo/entity/issues/454)) ([d9377a2](https://github.com/expo/entity/commit/d9377a290b7e0c6803f9e83537eddb7204bc9320)), closes [#410](https://github.com/expo/entity/issues/410)
# [0.55.0](https://github.com/expo/entity/compare/v0.54.0...v0.55.0) (2026-01-27)
**Note:** Version bump only for package @expo/entity-secondary-cache-local-memory
@@ -1,6 +1,6 @@
{
"name": "@expo/entity-secondary-cache-local-memory",
"version": "0.58.0",
"version": "0.55.0",
"description": "Local memory secondary cache for @expo/entity",
"files": [
"build",
@@ -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,20 +3,6 @@
All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
# [0.58.0](https://github.com/expo/entity/compare/v0.57.0...v0.58.0) (2026-03-02)
**Note:** Version bump only for package @expo/entity-secondary-cache-redis
# [0.57.0](https://github.com/expo/entity/compare/v0.56.0...v0.57.0) (2026-02-28)
**Note:** Version bump only for package @expo/entity-secondary-cache-redis
# [0.56.0](https://github.com/expo/entity/compare/v0.55.0...v0.56.0) (2026-02-28)
### Bug Fixes
- Reorganize constructionUtils to make non-public ([#454](https://github.com/expo/entity/issues/454)) ([d9377a2](https://github.com/expo/entity/commit/d9377a290b7e0c6803f9e83537eddb7204bc9320)), closes [#410](https://github.com/expo/entity/issues/410)
# [0.55.0](https://github.com/expo/entity/compare/v0.54.0...v0.55.0) (2026-01-27)
**Note:** Version bump only for package @expo/entity-secondary-cache-redis
@@ -1,6 +1,6 @@
{
"name": "@expo/entity-secondary-cache-redis",
"version": "0.58.0",
"version": "0.55.0",
"description": "Redis secondary cache for @expo/entity",
"files": [
"build",
@@ -32,7 +32,7 @@
},
"devDependencies": {
"@jest/globals": "30.2.0",
"ioredis": "5.10.0",
"ioredis": "5.9.3",
"nullthrows": "1.1.1",
"typescript": "5.9.3"
}
@@ -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,25 +3,6 @@
All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
# [0.58.0](https://github.com/expo/entity/compare/v0.57.0...v0.58.0) (2026-03-02)
**Note:** Version bump only for package @expo/entity-testing-utils
# [0.57.0](https://github.com/expo/entity/compare/v0.56.0...v0.57.0) (2026-02-28)
**Note:** Version bump only for package @expo/entity-testing-utils
# [0.56.0](https://github.com/expo/entity/compare/v0.55.0...v0.56.0) (2026-02-28)
### Bug Fixes
- move EntityPrivacyUtils back into core package ([#429](https://github.com/expo/entity/issues/429)) ([56ec27d](https://github.com/expo/entity/commit/56ec27d5f201d83930a2cc05dfc380a054bff1e1))
- refactor install method to instead be free functions ([#441](https://github.com/expo/entity/issues/441)) ([51d632d](https://github.com/expo/entity/commit/51d632da1619dc039d5c41ce6533f63254ec4337)), closes [#410](https://github.com/expo/entity/issues/410)
### Features
- Add authorization action to EntityPrivacyPolicyRuleEvaluationContext ([#464](https://github.com/expo/entity/issues/464)) ([e71ae5f](https://github.com/expo/entity/commit/e71ae5fcbae95096e0d6a42a8c960896ab83ccfb))
# [0.55.0](https://github.com/expo/entity/compare/v0.54.0...v0.55.0) (2026-01-27)
**Note:** Version bump only for package @expo/entity-testing-utils
+3 -3
View File
@@ -1,6 +1,6 @@
{
"name": "@expo/entity-testing-utils",
"version": "0.58.0",
"version": "0.55.0",
"description": "A package containing utilities for testing applications that use Entity",
"files": [
"build",
@@ -36,8 +36,8 @@
},
"devDependencies": {
"@jest/globals": "30.2.0",
"@types/lodash": "4.17.24",
"@types/node": "24.10.15",
"@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(),
}),
],
-26
View File
@@ -3,32 +3,6 @@
All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
# [0.58.0](https://github.com/expo/entity/compare/v0.57.0...v0.58.0) (2026-03-02)
### Bug Fixes
- escape all matching portions of parts of cacher cache keys ([#486](https://github.com/expo/entity/issues/486)) ([21f2f6f](https://github.com/expo/entity/commit/21f2f6f461ee771e8b1c1b22dbaf9b275094c0db))
# [0.57.0](https://github.com/expo/entity/compare/v0.56.0...v0.57.0) (2026-02-28)
**Note:** Version bump only for package @expo/entity
# [0.56.0](https://github.com/expo/entity/compare/v0.55.0...v0.56.0) (2026-02-28)
### Bug Fixes
- eliminate some unnecessary any casts ([#448](https://github.com/expo/entity/issues/448)) ([cc78029](https://github.com/expo/entity/commit/cc78029117c14510b0934b5e0d19876a45570f8d))
- move EntityPrivacyUtils back into core package ([#429](https://github.com/expo/entity/issues/429)) ([56ec27d](https://github.com/expo/entity/commit/56ec27d5f201d83930a2cc05dfc380a054bff1e1))
- pass in augmentable classes to installation ([#437](https://github.com/expo/entity/issues/437)) ([f5c728b](https://github.com/expo/entity/commit/f5c728be565e01c0a8ace9ef51f6de8f49184dab)), closes [/github.com/expo/entity/pull/410#pullrequestreview-3765893898](https://github.com//github.com/expo/entity/pull/410/issues/pullrequestreview-3765893898)
- refactor install method to instead be free functions ([#441](https://github.com/expo/entity/issues/441)) ([51d632d](https://github.com/expo/entity/commit/51d632da1619dc039d5c41ce6533f63254ec4337)), closes [#410](https://github.com/expo/entity/issues/410)
- Reorganize constructionUtils to make non-public ([#454](https://github.com/expo/entity/issues/454)) ([d9377a2](https://github.com/expo/entity/commit/d9377a290b7e0c6803f9e83537eddb7204bc9320)), closes [#410](https://github.com/expo/entity/issues/410)
- Type idField in EntityConfiguration as TIDField ([#425](https://github.com/expo/entity/issues/425)) ([be40bcf](https://github.com/expo/entity/commit/be40bcffa6e7db9b7ad75e1a80bbb48ff417c6c5))
### Features
- Add authorization action to EntityPrivacyPolicyRuleEvaluationContext ([#464](https://github.com/expo/entity/issues/464)) ([e71ae5f](https://github.com/expo/entity/commit/e71ae5fcbae95096e0d6a42a8c960896ab83ccfb))
- Add paginated loader to entity-database-adapter-knex ([#422](https://github.com/expo/entity/issues/422)) ([58deffd](https://github.com/expo/entity/commit/58deffd426b0214073ede608cd47c04db2596d60))
# [0.55.0](https://github.com/expo/entity/compare/v0.54.0...v0.55.0) (2026-01-27)
### Features
+3 -3
View File
@@ -1,6 +1,6 @@
{
"name": "@expo/entity",
"version": "0.58.0",
"version": "0.55.0",
"description": "A privacy-first data model",
"files": [
"build",
@@ -34,8 +34,8 @@
"devDependencies": {
"@jest/globals": "30.2.0",
"@types/invariant": "2.2.37",
"@types/lodash": "4.17.24",
"@types/node": "24.10.15",
"@types/lodash": "4.17.23",
"@types/node": "24.10.13",
"lodash": "4.17.23",
"ts-mockito": "2.6.1",
"typescript": "5.9.3",
@@ -16,7 +16,7 @@ export class AuthorizationResultBasedEntityAssociationLoader<
TIDField extends keyof NonNullable<Pick<TFields, TSelectedFields>>,
TViewerContext extends ViewerContext,
TEntity extends ReadonlyEntity<TFields, TIDField, TViewerContext, TSelectedFields>,
TSelectedFields extends keyof TFields = 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<
+1 -1
View File
@@ -24,7 +24,7 @@ export class EnforcingEntityLoader<
TEntity,
TSelectedFields
>,
TSelectedFields extends keyof TFields = keyof TFields,
TSelectedFields extends keyof TFields,
> {
constructor(
private readonly entityLoader: AuthorizationResultBasedEntityLoader<
-6
View File
@@ -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(
+1 -1
View File
@@ -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
+32
View File
@@ -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;
}
}
+14 -47
View File
@@ -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,
);
}
+1 -44
View File
@@ -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 });
}
}
+49 -4
View File
@@ -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();
}
}
+10 -10
View File
@@ -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,
);
@@ -115,7 +115,7 @@ class TestLocalCacheAdapter<
cacheKeyType,
`${this.entityConfiguration.cacheKeyVersion}`,
...parts,
].map((part) => part.replaceAll('\\', '\\\\').replaceAll(delimiter, `\\${delimiter}`));
].map((part) => part.replace('\\', '\\\\').replace(delimiter, `\\${delimiter}`));
return escapedParts.join(delimiter);
}
}
@@ -289,7 +289,7 @@ describe(ComposedEntityCacheAdapter, () => {
const { cacheAdapter } = makeTestCacheAdapters();
const results = await cacheAdapter.loadManyAsync(
new SingleFieldHolder<BlahFields, 'id', 'id'>('id'),
[] as 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 {
/**
Loaded 100 of 118 files, more files were not shown because too many files have changed in this diff. Show more