Compare commits

...
Author SHA1 Message Date
Will Schurman 8b010df1d2 fix: update integration test to use BlahEntity.knexLoader syntax 2026-02-15 16:05:55 -08:00
Will Schurman 40502d6044 fix: revert change to codemod now that Entity.knexLoader is supported again via subclass 2026-02-15 16:05:54 -08:00
Will Schurman 701c768d1a feat: add PostgresEntity/ReadonlyPostgresEntity classes that expose knexLoader methods 2026-02-15 16:05:54 -08:00
Will Schurman 429ae055b2 fix: refactor install method to instead be free functions 2026-02-15 16:05:54 -08:00
Will Schurman 2a319a4275 fix: Add default for TSelectedFields for application-instantiated types 2026-02-15 16:05:54 -08:00
Will Schurman 34057b8ed8 fix: pass in augmentable classes to installation 2026-02-15 16:05:54 -08:00
Will Schurman 105989b09c feat: Add pagination max page size configuration 2026-02-15 16:05:54 -08:00
Will Schurman 89f4ba6e07 feat: Add ilike and trigram similarity search to pagination 2026-02-15 16:05:54 -08:00
renovate[bot] 7b8a49c203 chore(deps): update dependency ioredis to v5.9.3 (#445) 2026-02-15 12:09:59 -08:00
renovate[bot] d97707a213 chore(deps): update dependency @types/node to v24.10.13 (#439) 2026-02-13 13:14:27 -08:00
renovate[bot] ced36081b8 chore(deps): update dependency lerna to v9.0.4 (#440) 2026-02-13 13:13:43 -08:00
renovate[bot] b511981a69 chore(deps): update postgres:18 docker digest to b6b4d0b (#435) 2026-02-12 18:03:05 -08:00
Will Schurman 🦦 58deffd426 feat: Add paginated loader to entity-database-adapter-knex (#422) 2026-02-12 11:49:06 -08:00
Will Schurman 🦦 56ec27d5f2 fix: move EntityPrivacyUtils back into core package (#429) 2026-02-11 19:20:26 -08:00
Will Schurman 🦦 be40bcffa6 fix: Type idField in EntityConfiguration as TIDField (#425)
# Why

Noticed this was mis-typed in a later PR, hoisted into this one.

# How

Update type of EntityConfiguration.idField to be `TIDField`, which is the most correct and precise type for the field. This will help later on when adding pagination ID field stuff.

The other changes to tests are fixes since these previously were underspecified and changing the field type exposed these missing types. (claude informed me of this)

# Test Plan

`yarn tsc`
2026-02-11 17:30:23 -08:00
Will Schurman 🦦 a9d09f42b6 fix: Use TSelectedFields for knex loader order by method (#424)
# Why

Noticed while creating an upcoming pagination PR that loaders should use `TSelectedFields` for `orderBy` clauses. This was an accidental omission when the selected fields concept was added.

Succinctly, one should only be able to order entities by their fields rather than by all their underlying table's fields.

# How

Create new orderBy clause type that lives at the loader level. It is fully compatible (subset or equal to) the database orderBy clause, so calling the data manager methods directly with the arguments is by design.

# Test Plan

`yarn tsc`
2026-02-11 17:26:14 -08:00
renovate[bot] a9980b2785 chore(deps): update dependency @types/node to v24.10.12 (#434) 2026-02-11 07:39:14 -08:00
renovate[bot] 7600c7e7d8 chore(deps): update redis docker digest to 7b6fb55 (#433) 2026-02-10 14:35:24 -08:00
renovate[bot] 31e226e22a chore(deps): update node.js to v24.13.1 (#432) 2026-02-10 09:50:20 -08:00
renovate[bot] 66297676e4 chore(deps): update dependency @types/node to v24.10.11 (#428) 2026-02-09 16:14:17 -08:00
Will Schurman 🦦 273d1df8ba feat!: Add sql template and loader method (#414)
# Why

This is the first new feature afforded by the changes in #407.

It adds a feature that is fairly common amongst typescript ORMs: sql via tagged templates for safe value interpolation into SQL statements.

# How

Quite a lot of ORMs have this:
- https://github.com/drizzle-team/drizzle-orm
- https://github.com/mikro-orm/mikro-orm
- https://github.com/blakeembrey/sql-template-tag
- https://github.com/andywer/squid
- https://github.com/Seb-C/kiss-orm

This adds support for it to entity knex.

See tests for examples, especially `PostgresEntityIntegration-test.ts`.

The idea is that this will replace `loadManyByRawWhereClauseAsync` over the course of a few versions.

# Test Plan

Full test coverage.
2026-02-09 16:00:24 -08:00
Will Schurman 🦦 aba0ce5b5f feat: add codemod for upcoming v0.55.0-v0.56.0 (#413)
# Why

This adds a codemod for #407-#410 changes.

# How

Replace `.loader` calls with `.knexLoader` where applicable.

# Test Plan

Run the test.
2026-02-09 15:56:23 -08:00
Will Schurman 🦦 eeccf594c1 feat: add entity-database-adapter-knex-testing-utils containing StubPostgresDatabaseAdapter (#412)
# Why

As of moving raw and conjunction loading into the `entity-database-adapter-knex` package, the stub database adapater is no longer as useful in applications that use the package (it used to support conjunction loading synthetically for unit tests).

# How

This PR adds a new package, `entity-database-adapter-knex-testing-utils` that provides what StubDatabaseAdapter used to be (it has conjunction loading).

The idea is that applications that use `entity-database-adapter-knex` will use this package for their stub database needs and unit test companion provider.

# Test Plan

Full test coverage.
2026-02-09 15:52:23 -08:00
Will Schurman 🦦 e76264c613 chore: add CLAUDE.md and disallow test.skip (#411)
# Why

This commits the claude prompts I have been using to pair program with claude while building this stack.

I also noticed that claude loved to add `test.skip` to make things pass, which now is disallowed as of this PR.

# How

Commit the file.

# Test Plan

yarn lint
2026-02-09 15:48:23 -08:00
Will Schurman 🦦 6fce3a7604 feat!: move knex loaders into entity-database-adapter-knex package (#410)
# Why

Part 3 of #407.

# How

This is the main PR of the stack. It creates a mechanism for "installing" the extension methods on required objects, and then installs them and tests them.

The goal is to make it better self-contained about what is core in the library and guaranteed to be efficient (dataloader'd and cached) and what is not (raw SQL and SQLFragment loads in a future PR).

This is becoming more critical in a time of agentic coding where having a discrete set of loaders that can be linted separately and instruct agents to run `EXPLAIN ANALYZE` in raw cases.

# Test Plan

Full test coverage.
2026-02-09 15:44:24 -08:00
Will Schurman 🦦 567bb61172 chore: move knex-specific loader logic out of EntityDataManager (#408)
# Why

Part 2 of #407.

# How

Refactor methods into a separate class for future move out of main package.

# Test Plan

Full test coverage.
2026-02-09 15:40:23 -08:00
Will Schurman 🦦 2590b95734 feat!: Move knex-specific loader methods to knexLoader (#407)
# Why

This is a long stack, but the goal is to make a place to put postgres-specific loading logic to avoid polluting the core dataloader-based library with logic specific to knex/postgres. And potentially even postgres-specific mutation logic but that's way down the road.

The best place for this stuff is in the `entity-database-adapter-knex` library. Therefore, the strategy is (in PR order):
1. Move existing knex/postgres-specific loader methods to a new loader, `knexLoader`. I'm also open to calling this a "Postgres Loader" since the `entity-database-adapter-knex` package already uses postgres-specific queries (and more are added here since this stack eventually produces raw queries) within knex even though technically knex is database agnostic.
1. Move knex-specific logic out of EntityDataManager, move it to its own EntityKnexDataManager.
1. Move these two now-separated pieces into the `entity-database-adapter-knex` package. This requires creating a "plugin" system for database adapters, where when they are included their methods are added to the prototype so that seamless loading continues to work.
1. Add a codemod for these three pieces.
1. Add the first new feature to entity loading, load by tagged template string, which creates a better/safer way to express raw queries for loading entities.

Future other things that could be added here (after thorough thought on authorization) are:
- Pagination
- Create, update, delete by tagged template string
- Batch creation
- Upsert (maybe)

# For Reviewers

The most critical things to assess are:
- "install" method in #410
- sql tagged template API usability in #414

# How

This is part 1. It refactors the loaders by moving `loadManyByFieldEqualityConjunctionAsync`, `loadFirstByFieldEqualityConjunctionAsync`, and `loadManyByRawWhereClauseAsync` to a separate loader.

The new pattern for these is:
```
await PostgresTestEntity.knexLoader(vc1).loadManyByRawWhereClauseAsync(...)
```

# Test Plan

This has full coverage in new tests.
2026-02-09 15:36:48 -08:00
renovate[bot] bc74562bf9 chore(deps): update dependency @types/node to v24.10.11 (#426) 2026-02-08 13:15:10 -08:00
renovate[bot] e37284f54a chore(deps): update dependency @types/node to v24.10.10 (#423) 2026-02-06 12:02:32 -08:00
renovate[bot] 064431924b chore(deps): update postgres:18 docker digest to 1090bc3 (#421) 2026-02-05 09:37:35 -08:00
renovate[bot] cd81c5a5b6 fix(deps): update dependency @apollo/server to v5.4.0 [security] (#420) 2026-02-04 15:41:34 -08:00
renovate[bot] 7b6ff09c68 chore(deps): update redis docker digest to c22af04 (#418) 2026-02-03 14:29:45 -08:00
renovate[bot] ad6c81def2 chore(deps): update babel monorepo to v7.29.0 (#419) 2026-02-03 14:29:23 -08:00
renovate[bot] 2791d9a05f chore(deps): update postgres:18 docker digest to 3bb77a0 (#416) 2026-02-03 09:05:05 -08:00
renovate[bot] c28e89e15c chore(deps): update redis docker digest to e59abdb (#417) 2026-02-03 09:04:53 -08:00
renovate[bot] 9a9374b173 chore(deps): update dependency pg to v8.18.0 (#415) 2026-02-02 15:19:43 -08:00
renovate[bot] 2dda22d761 fix(deps): update dependency @koa/bodyparser to v6.1.0 (#405) 2026-01-28 07:36:17 -08:00
renovate[bot] 9e88cf6aab fix(deps): update dependency @koa/router to v15.3.0 (#406) 2026-01-27 21:36:30 -08:00
119 changed files with 12194 additions and 2438 deletions

No files matched your search

+30
View File
@@ -0,0 +1,30 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Project Overview
This is Entity, a privacy-aware data layer for defining, caching, and authorizing access to application data models.
## Essential Development Commands
### Dependencies
- `yarn add` - Add a dependency. Run this only if necessary and ask before running.
### Building and Linting
- `yarn tsc` - Typecheck the code
- `yarn lint` - Run ESLint on source code
- `yarn ctix` - Build barrel index.ts files
### Testing
- `yarn test` - Run unit tests
- `yarn integration` - Run integration tests against dockerized environment
### Commit messages
- All PRs use conventional commits for their titles: https://www.conventionalcommits.org/
## Code conventions
- Backwards compatibility is a non-goal of refactorings of this library. Consumers rely upon typescript to know what needs to be migrated, so all breaking changes should be detectable via running typescript post-entity-version-upgrade in a consumer application that makes use of this package.
- Do not use jest's `test.skip`
- Prefer not to use dynamic imports for now
- Whenever technically correct, use typescript types for something instead of `any`
+13 -10
View File
@@ -7,44 +7,47 @@
{
"project": "packages/entity/tsconfig.json",
"output": "packages/entity/src",
"exclude": [
"**/__testfixtures__/**",
"**/__tests__/**"
]
"exclude": [ "**/__testfixtures__/**", "**/__tests__/**" ]
},
{
"project": "packages/entity-cache-adapter-local-memory/tsconfig.json",
"output": "packages/entity-cache-adapter-local-memory/src",
"exclude": [ "**/__testfixtures__/**" ]
"exclude": [ "**/__testfixtures__/**", "**/__tests__/**" ]
},
{
"project": "packages/entity-cache-adapter-redis/tsconfig.json",
"output": "packages/entity-cache-adapter-redis/src",
"exclude": [ "**/__testfixtures__/**" ]
"exclude": [ "**/__testfixtures__/**", "**/__tests__/**" ]
},
{
"project": "packages/entity-database-adapter-knex/tsconfig.json",
"output": "packages/entity-database-adapter-knex/src",
"exclude": [ "**/__testfixtures__/**" ]
"exclude": [ "**/__testfixtures__/**", "**/__tests__/**" ]
},
{
"project": "packages/entity-ip-address-field/tsconfig.json",
"output": "packages/entity-ip-address-field/src",
"exclude": [ "**/__testfixtures__/**", "**/__tests__/**" ]
},
{
"project": "packages/entity-secondary-cache-local-memory/tsconfig.json",
"output": "packages/entity-secondary-cache-local-memory/src",
"exclude": [ "**/__tests__/**" ]
"exclude": [ "**/__testfixtures__/**", "**/__tests__/**" ]
},
{
"project": "packages/entity-secondary-cache-redis/tsconfig.json",
"output": "packages/entity-secondary-cache-redis/src",
"exclude": [ "**/__testfixtures__/**" ]
"exclude": [ "**/__testfixtures__/**", "**/__tests__/**" ]
},
{
"project": "packages/entity-testing-utils/tsconfig.json",
"output": "packages/entity-testing-utils/src",
"exclude": [ "**/__testfixtures__/**" ]
"exclude": [ "**/__testfixtures__/**", "**/__tests__/**" ]
},
{
"project": "packages/entity-database-adapter-knex-testing-utils/tsconfig.json",
"output": "packages/entity-database-adapter-knex-testing-utils/src",
"exclude": [ "**/__testfixtures__/**", "**/__tests__/**" ]
}
]
}
+15
View File
@@ -46,6 +46,21 @@ module.exports = defineConfig([
property: 'only',
message: 'describe.only should not be committed to main.',
},
{
object: 'it',
property: 'skip',
message: 'it.skip should not be committed to main.',
},
{
object: 'test',
property: 'skip',
message: 'test.skip should not be committed to main.',
},
{
object: 'describe',
property: 'skip',
message: 'describe.skip should not be committed to main.',
},
],
},
},
+5 -5
View File
@@ -19,20 +19,20 @@
"typedoc": "yarn build && typedoc"
},
"devDependencies": {
"@babel/core": "7.28.6",
"@babel/preset-env": "7.28.6",
"@babel/core": "7.29.0",
"@babel/preset-env": "7.29.0",
"@babel/preset-typescript": "7.28.5",
"@tsconfig/node24": "24.0.3",
"@tsconfig/strictest": "2.0.8",
"@types/babel__core": "7.20.5",
"@types/babel__preset-env": "7.10.0",
"@types/node": "24.10.9",
"@types/node": "24.10.13",
"ctix": "2.7.2",
"eslint": "9.39.2",
"eslint-config-universe": "15.0.3",
"eslint-plugin-tsdoc": "0.5.0",
"jest": "30.2.0",
"lerna": "9.0.3",
"lerna": "9.0.4",
"prettier": "3.8.1",
"prettier-plugin-organize-imports": "4.3.0",
"typedoc": "0.28.16",
@@ -42,7 +42,7 @@
"collect-v8-coverage": "^1.0.3"
},
"volta": {
"node": "24.13.0"
"node": "24.13.1"
},
"packageManager": "[email protected]"
}
@@ -1,5 +1,6 @@
import {
CacheStatus,
GenericEntityCacheAdapter,
IEntityGenericCacher,
SingleFieldHolder,
SingleFieldValueHolder,
@@ -23,12 +24,11 @@ describe(GenericLocalMemoryCacher, () => {
it('has correct caching behavior', async () => {
const entityCompanionProvider = createLocalMemoryTestEntityCompanionProvider();
const viewerContext = new ViewerContext(entityCompanionProvider);
const genericCacher = viewerContext.entityCompanionProvider.getCompanionForEntity(
LocalMemoryTestEntity,
)['tableDataCoordinator']['cacheAdapter']['genericCacher'] as IEntityGenericCacher<
LocalMemoryTestEntityFields,
'id'
>;
const genericCacher = (
viewerContext.entityCompanionProvider.getCompanionForEntity(LocalMemoryTestEntity)[
'tableDataCoordinator'
]['cacheAdapter'] as GenericEntityCacheAdapter<LocalMemoryTestEntityFields, 'id'>
)['genericCacher'];
const date = new Date();
const entity1Created = await LocalMemoryTestEntity.creator(viewerContext)
@@ -105,7 +105,7 @@ describe(GenericLocalMemoryCacher, () => {
expect(entityNonExistentResult2.ok).toBe(false);
// invalidate from cache to ensure it invalidates correctly
await LocalMemoryTestEntity.loaderUtils(viewerContext).invalidateFieldsAsync(
await LocalMemoryTestEntity.invalidationUtils(viewerContext).invalidateFieldsAsync(
entity1.getAllFields(),
);
const keys = genericCacher.makeCacheKeysForInvalidation(
@@ -121,12 +121,11 @@ describe(GenericLocalMemoryCacher, () => {
it('respects the parameters of a noop cache', async () => {
const entityCompanionProvider = createNoOpLocalMemoryIntegrationTestEntityCompanionProvider();
const viewerContext = new ViewerContext(entityCompanionProvider);
const genericCacher = viewerContext.entityCompanionProvider.getCompanionForEntity(
LocalMemoryTestEntity,
)['tableDataCoordinator']['cacheAdapter']['genericCacher'] as IEntityGenericCacher<
LocalMemoryTestEntityFields,
'id'
>;
const genericCacher = (
viewerContext.entityCompanionProvider.getCompanionForEntity(LocalMemoryTestEntity)[
'tableDataCoordinator'
]['cacheAdapter'] as GenericEntityCacheAdapter<LocalMemoryTestEntityFields, 'id'>
)['genericCacher'];
const cacheKeyMaker = genericCacher['makeCacheKeyForStorage'].bind(genericCacher);
const date = new Date();
@@ -37,7 +37,7 @@
"@expo/batcher": "1.0.0",
"@expo/entity-testing-utils": "workspace:^",
"@jest/globals": "30.2.0",
"ioredis": "5.9.2",
"ioredis": "5.9.3",
"ts-mockito": "2.6.1",
"typescript": "5.9.3"
}
@@ -1,6 +1,6 @@
import { Batcher } from '@expo/batcher';
import {
IEntityGenericCacher,
GenericEntityCacheAdapter,
SingleFieldHolder,
SingleFieldValueHolder,
ViewerContext,
@@ -109,12 +109,11 @@ describe(GenericRedisCacher, () => {
const mgetSpy = jest.spyOn(redis, 'mget');
const genericCacher = viewerContext.entityCompanionProvider.getCompanionForEntity(
RedisTestEntity,
)['tableDataCoordinator']['cacheAdapter']['genericCacher'] as IEntityGenericCacher<
RedisTestEntityFields,
'id'
>;
const genericCacher = (
viewerContext.entityCompanionProvider.getCompanionForEntity(RedisTestEntity)[
'tableDataCoordinator'
]['cacheAdapter'] as GenericEntityCacheAdapter<RedisTestEntityFields, 'id'>
)['genericCacher'];
const entity1Created = await RedisTestEntity.creator(viewerContext)
.setField('name', 'blah')
@@ -187,7 +186,9 @@ describe(GenericRedisCacher, () => {
expect(entityNonExistentResult2.ok).toBe(false);
// invalidate from cache to ensure it invalidates correctly in both caches
await RedisTestEntity.loaderUtils(viewerContext).invalidateFieldsAsync(entity1.getAllFields());
await RedisTestEntity.invalidationUtils(viewerContext).invalidateFieldsAsync(
entity1.getAllFields(),
);
await expect(redis.get(cacheKeyEntity1)).resolves.toBeNull();
await expect(redis.get(cacheKeyEntity1NameField)).resolves.toBeNull();
});
@@ -1,7 +1,7 @@
import {
CompositeFieldHolder,
CompositeFieldValueHolder,
IEntityGenericCacher,
GenericEntityCacheAdapter,
SingleFieldHolder,
SingleFieldValueHolder,
ViewerContext,
@@ -55,12 +55,11 @@ describe(GenericRedisCacher, () => {
const viewerContext = new TestViewerContext(
createRedisIntegrationTestEntityCompanionProvider(genericRedisCacheContext),
);
const genericCacher = viewerContext.entityCompanionProvider.getCompanionForEntity(
RedisTestEntity,
)['tableDataCoordinator']['cacheAdapter']['genericCacher'] as IEntityGenericCacher<
RedisTestEntityFields,
'id'
>;
const genericCacher = (
viewerContext.entityCompanionProvider.getCompanionForEntity(RedisTestEntity)[
'tableDataCoordinator'
]['cacheAdapter'] as GenericEntityCacheAdapter<RedisTestEntityFields, 'id'>
)['genericCacher'];
const entity1Created = await RedisTestEntity.creator(viewerContext)
.setField('name', 'blah')
@@ -144,7 +143,9 @@ describe(GenericRedisCacher, () => {
expect(entityNonExistentCompositeResult2).toBe(null);
// invalidate from cache to ensure it invalidates correctly
await RedisTestEntity.loaderUtils(viewerContext).invalidateFieldsAsync(entity1.getAllFields());
await RedisTestEntity.invalidationUtils(viewerContext).invalidateFieldsAsync(
entity1.getAllFields(),
);
const cachedValueNullKeys = genericCacher.makeCacheKeysForInvalidation(
new SingleFieldHolder('id'),
new SingleFieldValueHolder(entity1.getID()),
+1 -1
View File
@@ -29,7 +29,7 @@
"devDependencies": {
"@jest/globals": "30.2.0",
"@types/jscodeshift": "17.3.0",
"@types/node": "24.10.9",
"@types/node": "24.10.13",
"jscodeshift": "17.3.0",
"typescript": "5.9.3"
}
@@ -0,0 +1,37 @@
import { ViewerContext } from '@expo/entity';
import { UserEntity } from './entities/UserEntity';
import { PostEntity } from './entities/PostEntity';
async function loadUser(viewerContext: ViewerContext) {
// Basic loader calls - only transformed when using knex-specific methods
const userLoader = UserEntity.loader(viewerContext);
const postLoader = PostEntity.loader(viewerContext);
// These use knex-specific methods, so they should be transformed
const posts = await postLoader.loadManyByFieldEqualityConjunctionAsync([
{ fieldName: 'status', fieldValue: 'published' }
]);
const firstPost = await postLoader.loadFirstByFieldEqualityConjunctionAsync([
{ fieldName: 'id', fieldValue: '123' }
]);
// Loader with authorization results - only transformed when using knex methods
const userLoaderWithAuth = UserEntity.loaderWithAuthorizationResults(viewerContext);
const rawResults = await userLoaderWithAuth.loadManyByRawWhereClauseAsync('age > ?', [18]);
// Loader that doesn't use knex methods - should NOT be transformed
const standardLoader = PostEntity.loader(viewerContext);
const post = await standardLoader.loadByIDAsync('456');
// Should not transform instance methods or other properties
const user = await userLoader.loadByIDAsync('123');
const userLoadMethod = user.loader; // This should not be transformed
// Should not transform lowercase object methods
const customLoader = {
loader: (ctx: any) => ctx,
};
customLoader.loader(viewerContext);
return user;
}
@@ -0,0 +1,37 @@
import { ViewerContext } from '@expo/entity';
import { UserEntity } from './entities/UserEntity';
import { PostEntity } from './entities/PostEntity';
async function loadUser(viewerContext: ViewerContext) {
// Basic loader calls - only transformed when using knex-specific methods
const userLoader = UserEntity.loader(viewerContext);
const postLoader = PostEntity.knexLoader(viewerContext);
// These use knex-specific methods, so they should be transformed
const posts = await postLoader.loadManyByFieldEqualityConjunctionAsync([
{ fieldName: 'status', fieldValue: 'published' }
]);
const firstPost = await postLoader.loadFirstByFieldEqualityConjunctionAsync([
{ fieldName: 'id', fieldValue: '123' }
]);
// Loader with authorization results - only transformed when using knex methods
const userLoaderWithAuth = UserEntity.knexLoaderWithAuthorizationResults(viewerContext);
const rawResults = await userLoaderWithAuth.loadManyByRawWhereClauseAsync('age > ?', [18]);
// Loader that doesn't use knex methods - should NOT be transformed
const standardLoader = PostEntity.loader(viewerContext);
const post = await standardLoader.loadByIDAsync('456');
// Should not transform instance methods or other properties
const user = await userLoader.loadByIDAsync('123');
const userLoadMethod = user.loader; // This should not be transformed
// Should not transform lowercase object methods
const customLoader = {
loader: (ctx: any) => ctx,
};
customLoader.loader(viewerContext);
return user;
}
@@ -0,0 +1,33 @@
import { ViewerContext } from '@expo/entity';
import { CommentEntity } from './entities/CommentEntity';
// Chained calls
const loadComments = async (viewerContext: ViewerContext) => {
// Direct chaining with knex-specific method
const comments = await CommentEntity.loader(viewerContext)
.loadManyByFieldEqualityConjunctionAsync([
{ fieldName: 'postId', fieldValue: '123' }
]);
// Direct chaining with regular method - should NOT be transformed
const singleComment = await CommentEntity
.loader(viewerContext)
.loadByIDAsync('456');
// With authorization results and knex method
const commentsWithAuth = await CommentEntity
.loaderWithAuthorizationResults(viewerContext)
.loadManyByRawWhereClauseAsync('postId = ?', ['456']);
// Edge cases - these should NOT be transformed
const anotherEntity = {
loader: (ctx: any) => ctx, // This is not an entity class
};
anotherEntity.loader(viewerContext); // Should NOT be transformed (lowercase object)
// Complex chaining with regular method - should NOT be transformed
return CommentEntity
.loader(viewerContext)
.withAuthenticationResults()
.loadByIDAsync('789');
};
@@ -0,0 +1,33 @@
import { ViewerContext } from '@expo/entity';
import { CommentEntity } from './entities/CommentEntity';
// Chained calls
const loadComments = async (viewerContext: ViewerContext) => {
// Direct chaining with knex-specific method
const comments = await CommentEntity.knexLoader(viewerContext)
.loadManyByFieldEqualityConjunctionAsync([
{ fieldName: 'postId', fieldValue: '123' }
]);
// Direct chaining with regular method - should NOT be transformed
const singleComment = await CommentEntity
.loader(viewerContext)
.loadByIDAsync('456');
// With authorization results and knex method
const commentsWithAuth = await CommentEntity
.knexLoaderWithAuthorizationResults(viewerContext)
.loadManyByRawWhereClauseAsync('postId = ?', ['456']);
// Edge cases - these should NOT be transformed
const anotherEntity = {
loader: (ctx: any) => ctx, // This is not an entity class
};
anotherEntity.loader(viewerContext); // Should NOT be transformed (lowercase object)
// Complex chaining with regular method - should NOT be transformed
return CommentEntity
.loader(viewerContext)
.withAuthenticationResults()
.loadByIDAsync('789');
};
@@ -0,0 +1,17 @@
import { jest } from '@jest/globals';
import { readdirSync } from 'fs';
import { join } from 'path';
jest.autoMockOff();
const defineTest = require('jscodeshift/dist/testUtils').defineTest;
const fixtureDir = 'v0.55.0-v0.56.0';
const fixtureDirPath = join(__dirname, '..', '__testfixtures__', fixtureDir);
const fixtures = readdirSync(fixtureDirPath)
.filter((file) => file.endsWith('.input.ts'))
.map((file) => file.replace('.input.ts', ''));
for (const fixture of fixtures) {
const prefix = `${fixtureDir}/${fixture}`;
defineTest(__dirname, 'v0.55.0-v0.56.0', null, prefix, { parser: 'ts' });
}
@@ -0,0 +1,169 @@
import { API, Collection, FileInfo, Options } from 'jscodeshift';
const KNEX_SPECIFIC_METHODS = [
'loadFirstByFieldEqualityConjunctionAsync',
'loadManyByFieldEqualityConjunctionAsync',
'loadManyByRawWhereClauseAsync',
];
function isKnexSpecificMethodUsed(j: API['jscodeshift'], node: any): boolean {
// Check if this loader call is followed by a knex-specific method
// We need to traverse the AST to find usages of the loader result
const parent = node.parent;
// Check if the loader call is directly chained with a knex method
if (parent && parent.value.type === 'MemberExpression' && parent.value.object === node.value) {
const grandParent = parent.parent;
if (
grandParent &&
grandParent.value.type === 'CallExpression' &&
grandParent.value.callee === parent.value
) {
if (
parent.value.property.type === 'Identifier' &&
KNEX_SPECIFIC_METHODS.includes(parent.value.property.name)
) {
return true;
}
}
}
// Check if the loader is assigned to a variable and then used with knex methods
if (parent && parent.value.type === 'VariableDeclarator' && parent.value.init === node.value) {
const variableName = parent.value.id.name;
const scope = parent.scope;
// Find all references to this variable in the same scope
const references = j(scope.path)
.find(j.Identifier, { name: variableName })
.filter((path) => {
// Check if this identifier is used as object in member expression
const parentNode = path.parent.value;
if (parentNode.type === 'MemberExpression' && parentNode.object === path.value) {
const prop = parentNode.property;
if (prop.type === 'Identifier' && KNEX_SPECIFIC_METHODS.includes(prop.name)) {
return true;
}
}
return false;
});
if (references.size() > 0) {
return true;
}
}
// Check await expressions
if (parent && parent.value.type === 'AwaitExpression' && parent.value.argument === node.value) {
const awaitParent = parent.parent;
if (awaitParent && awaitParent.value.type === 'VariableDeclarator') {
const variableName = awaitParent.value.id.name;
const scope = awaitParent.scope;
// Find all references to this variable in the same scope
const references = j(scope.path)
.find(j.Identifier, { name: variableName })
.filter((path) => {
const parentNode = path.parent.value;
if (parentNode.type === 'MemberExpression' && parentNode.object === path.value) {
const prop = parentNode.property;
if (prop.type === 'Identifier' && KNEX_SPECIFIC_METHODS.includes(prop.name)) {
return true;
}
}
return false;
});
if (references.size() > 0) {
return true;
}
}
}
return false;
}
function transformLoaderToKnexLoader(j: API['jscodeshift'], root: Collection<any>): void {
// Find all entity expressions of the form `Entity.loader(viewerContext)`
root
.find(j.CallExpression, {
callee: {
type: 'MemberExpression',
property: {
name: 'loader',
},
},
})
.forEach((path) => {
const loaderCallExpression = path.node; // Entity.loader(viewerContext)
const loaderCallee = loaderCallExpression.callee; // Entity.loader
if (loaderCallee.type !== 'MemberExpression') {
return;
}
// Make sure this is a static method call on an entity (not on an instance)
// Typically entity names start with uppercase letter
if (loaderCallee.object.type === 'Identifier') {
const firstChar = loaderCallee.object.name[0];
if (firstChar && firstChar === firstChar.toUpperCase()) {
// Check if this loader uses knex-specific methods
if (isKnexSpecificMethodUsed(j, path)) {
// Rename loader to knexLoader
if (loaderCallee.property.type === 'Identifier') {
loaderCallee.property.name = 'knexLoader';
}
}
}
}
});
}
function transformLoaderWithAuthorizationResultsToKnexLoaderWithAuthorizationResults(
j: API['jscodeshift'],
root: Collection<any>,
): void {
// Find all entity expressions of the form `Entity.loaderWithAuthorizationResults(viewerContext)`
root
.find(j.CallExpression, {
callee: {
type: 'MemberExpression',
property: {
name: 'loaderWithAuthorizationResults',
},
},
})
.forEach((path) => {
const loaderCallExpression = path.node; // Entity.loaderWithAuthorizationResults(viewerContext)
const loaderCallee = loaderCallExpression.callee; // Entity.loaderWithAuthorizationResults
if (loaderCallee.type !== 'MemberExpression') {
return;
}
// Make sure this is a static method call on an entity (not on an instance)
// Typically entity names start with uppercase letter
if (loaderCallee.object.type === 'Identifier') {
const firstChar = loaderCallee.object.name[0];
if (firstChar && firstChar === firstChar.toUpperCase()) {
// Check if this loader uses knex-specific methods
if (isKnexSpecificMethodUsed(j, path)) {
// Rename loaderWithAuthorizationResults to knexLoaderWithAuthorizationResults
if (loaderCallee.property.type === 'Identifier') {
loaderCallee.property.name = 'knexLoaderWithAuthorizationResults';
}
}
}
}
});
}
export default function transformer(file: FileInfo, api: API, _options: Options): string {
const j = api.jscodeshift;
const root = j.withParser('ts')(file.source);
transformLoaderToKnexLoader(j, root);
transformLoaderWithAuthorizationResultsToKnexLoaderWithAuthorizationResults(j, root);
return root.toSource();
}
@@ -0,0 +1,3 @@
# @expo/entity-database-adapter-knex-testing-utils
Testing utilities for applications using Entity with Knex database adapter.
@@ -0,0 +1,45 @@
{
"name": "@expo/entity-database-adapter-knex-testing-utils",
"version": "0.55.0",
"description": "Testing utilities for applications using Entity with Knex database adapter",
"files": [
"build",
"!*.tsbuildinfo",
"!__*",
"src"
],
"main": "build/src/index.js",
"types": "build/src/index.d.ts",
"scripts": {
"build": "tsc --build",
"prepack": "rm -rf build && yarn build",
"clean": "yarn build --clean",
"lint": "yarn run --top-level eslint src",
"lint-fix": "yarn run lint --fix",
"test": "yarn test:all --rootDir $(pwd)"
},
"engines": {
"node": ">=16"
},
"keywords": [
"entity"
],
"author": "Expo",
"license": "MIT",
"dependencies": {
"@expo/entity": "workspace:^",
"@expo/entity-database-adapter-knex": "workspace:^",
"invariant": "^2.2.4",
"uuid": "^13.0.0"
},
"peerDependencies": {
"@jest/globals": "*"
},
"devDependencies": {
"@jest/globals": "30.2.0",
"@types/invariant": "2.2.37",
"@types/node": "24.10.13",
"@types/uuid": "10.0.0",
"typescript": "5.9.3"
}
}
@@ -0,0 +1,301 @@
import {
computeIfAbsent,
EntityConfiguration,
FieldTransformerMap,
getDatabaseFieldForEntityField,
IntField,
mapMap,
StringField,
transformFieldsToDatabaseObject,
} from '@expo/entity';
import {
BasePostgresEntityDatabaseAdapter,
OrderByOrdering,
SQLFragment,
TableFieldMultiValueEqualityCondition,
TableFieldSingleValueEqualityCondition,
TableQuerySelectionModifiers,
TableQuerySelectionModifiersWithOrderByFragment,
} from '@expo/entity-database-adapter-knex';
import invariant from 'invariant';
import { v7 as uuidv7 } from 'uuid';
export class StubPostgresDatabaseAdapter<
TFields extends Record<string, any>,
TIDField extends keyof TFields,
> extends BasePostgresEntityDatabaseAdapter<TFields, TIDField> {
constructor(
private readonly entityConfiguration2: EntityConfiguration<TFields, TIDField>,
private readonly dataStore: Map<string, Readonly<{ [key: string]: any }>[]>,
) {
super(entityConfiguration2);
}
public static convertFieldObjectsToDataStore<
TFields extends Record<string, any>,
TIDField extends keyof TFields,
>(
entityConfiguration: EntityConfiguration<TFields, TIDField>,
dataStore: Map<string, Readonly<TFields>[]>,
): Map<string, Readonly<{ [key: string]: any }>[]> {
return mapMap(dataStore, (objectsForTable) =>
objectsForTable.map((objectForTable) =>
transformFieldsToDatabaseObject(entityConfiguration, new Map(), objectForTable),
),
);
}
public getObjectCollectionForTable(tableName: string): { [key: string]: any }[] {
return computeIfAbsent(this.dataStore, tableName, () => []);
}
protected getFieldTransformerMap(): FieldTransformerMap {
return new Map();
}
private static uniqBy<T>(a: T[], keyExtractor: (k: T) => string): T[] {
const seen = new Set();
return a.filter((item) => {
const k = keyExtractor(item);
if (seen.has(k)) {
return false;
}
seen.add(k);
return true;
});
}
protected async fetchManyWhereInternalAsync(
_queryInterface: any,
tableName: string,
tableColumns: readonly string[],
tableTuples: (readonly any[])[],
): Promise<object[]> {
const objectCollection = this.getObjectCollectionForTable(tableName);
const results = StubPostgresDatabaseAdapter.uniqBy(tableTuples, (tuple) =>
tuple.join(':'),
).reduce(
(acc, tableTuple) => {
return acc.concat(
objectCollection.filter((obj) => {
return tableColumns.every((tableColumn, index) => {
return obj[tableColumn] === tableTuple[index];
});
}),
);
},
[] as { [key: string]: any }[],
);
return [...results];
}
protected async fetchOneWhereInternalAsync(
queryInterface: any,
tableName: string,
tableColumns: readonly string[],
tableTuple: readonly any[],
): Promise<object | null> {
const results = await this.fetchManyWhereInternalAsync(
queryInterface,
tableName,
tableColumns,
[tableTuple],
);
return results[0] ?? null;
}
private static compareByOrderBys(
orderBys: {
columnName: string;
order: OrderByOrdering;
}[],
objectA: { [key: string]: any },
objectB: { [key: string]: any },
): 0 | 1 | -1 {
if (orderBys.length === 0) {
return 0;
}
const currentOrderBy = orderBys[0]!;
const aField = objectA[currentOrderBy.columnName];
const bField = objectB[currentOrderBy.columnName];
switch (currentOrderBy.order) {
case OrderByOrdering.DESCENDING: {
// simulate NULLS FIRST for DESC
if (aField === null && bField === null) {
return 0;
} else if (aField === null) {
return -1;
} else if (bField === null) {
return 1;
}
return aField > bField
? -1
: aField < bField
? 1
: this.compareByOrderBys(orderBys.slice(1), objectA, objectB);
}
case OrderByOrdering.ASCENDING: {
// simulate NULLS LAST for ASC
if (aField === null && bField === null) {
return 0;
} else if (bField === null) {
return -1;
} else if (aField === null) {
return 1;
}
return bField > aField
? -1
: bField < aField
? 1
: this.compareByOrderBys(orderBys.slice(1), objectA, objectB);
}
}
}
protected async fetchManyByFieldEqualityConjunctionInternalAsync(
_queryInterface: any,
tableName: string,
tableFieldSingleValueEqualityOperands: TableFieldSingleValueEqualityCondition[],
tableFieldMultiValueEqualityOperands: TableFieldMultiValueEqualityCondition[],
querySelectionModifiers: TableQuerySelectionModifiers,
): Promise<object[]> {
let filteredObjects = this.getObjectCollectionForTable(tableName);
for (const { tableField, tableValue } of tableFieldSingleValueEqualityOperands) {
filteredObjects = filteredObjects.filter((obj) => obj[tableField] === tableValue);
}
for (const { tableField, tableValues } of tableFieldMultiValueEqualityOperands) {
filteredObjects = filteredObjects.filter((obj) => tableValues.includes(obj[tableField]));
}
const orderBy = querySelectionModifiers.orderBy;
if (orderBy !== undefined) {
filteredObjects = filteredObjects.sort((a, b) =>
StubPostgresDatabaseAdapter.compareByOrderBys(orderBy, a, b),
);
}
const offset = querySelectionModifiers.offset;
if (offset !== undefined) {
filteredObjects = filteredObjects.slice(offset);
}
const limit = querySelectionModifiers.limit;
if (limit !== undefined) {
filteredObjects = filteredObjects.slice(0, 0 + limit);
}
return filteredObjects;
}
protected fetchManyByRawWhereClauseInternalAsync(
_queryInterface: any,
_tableName: string,
_rawWhereClause: string,
_bindings: object | any[],
_querySelectionModifiers: TableQuerySelectionModifiers,
): Promise<object[]> {
throw new Error('Raw WHERE clauses not supported for StubDatabaseAdapter');
}
protected fetchManyBySQLFragmentInternalAsync(
_queryInterface: any,
_tableName: string,
_sqlFragment: SQLFragment,
_querySelectionModifiers: TableQuerySelectionModifiersWithOrderByFragment,
): Promise<object[]> {
throw new Error('SQL fragments not supported for StubDatabaseAdapter');
}
private generateRandomID(): any {
const idSchemaField = this.entityConfiguration2.schema.get(this.entityConfiguration2.idField);
invariant(
idSchemaField,
`No schema field found for ${String(this.entityConfiguration2.idField)}`,
);
if (idSchemaField instanceof StringField) {
return uuidv7();
} else if (idSchemaField instanceof IntField) {
return Math.floor(Math.random() * Number.MAX_SAFE_INTEGER);
} else {
throw new Error(
`Unsupported ID type for StubPostgresDatabaseAdapter: ${idSchemaField.constructor.name}`,
);
}
}
protected async insertInternalAsync(
_queryInterface: any,
tableName: string,
object: object,
): Promise<object[]> {
const objectCollection = this.getObjectCollectionForTable(tableName);
const idField = getDatabaseFieldForEntityField(
this.entityConfiguration2,
this.entityConfiguration2.idField,
);
const objectToInsert = {
[idField]: this.generateRandomID(),
...object,
};
objectCollection.push(objectToInsert);
return [objectToInsert];
}
protected async updateInternalAsync(
_queryInterface: any,
tableName: string,
tableIdField: string,
id: any,
object: object,
): Promise<object[]> {
// SQL does not support empty updates, mirror behavior here for better test simulation
if (Object.keys(object).length === 0) {
throw new Error(`Empty update (${tableIdField} = ${id})`);
}
const objectCollection = this.getObjectCollectionForTable(tableName);
const objectIndex = objectCollection.findIndex((obj) => {
return obj[tableIdField] === id;
});
// SQL updates to a nonexistent row succeed but affect 0 rows,
// mirror that behavior here for better test simulation
if (objectIndex < 0) {
return [];
}
objectCollection[objectIndex] = {
...objectCollection[objectIndex],
...object,
};
return [objectCollection[objectIndex]];
}
protected async deleteInternalAsync(
_queryInterface: any,
tableName: string,
tableIdField: string,
id: any,
): Promise<number> {
const objectCollection = this.getObjectCollectionForTable(tableName);
const objectIndex = objectCollection.findIndex((obj) => {
return obj[tableIdField] === id;
});
// SQL deletes to a nonexistent row succeed and affect 0 rows,
// mirror that behavior here for better test simulation
if (objectIndex < 0) {
return 0;
}
objectCollection.splice(objectIndex, 1);
return 1;
}
}
@@ -0,0 +1,17 @@
import {
EntityConfiguration,
EntityDatabaseAdapter,
IEntityDatabaseAdapterProvider,
} from '@expo/entity';
import { StubPostgresDatabaseAdapter } from './StubPostgresDatabaseAdapter';
export class StubPostgresDatabaseAdapterProvider implements IEntityDatabaseAdapterProvider {
private readonly objectCollection = new Map();
getDatabaseAdapter<TFields extends Record<string, any>, TIDField extends keyof TFields>(
entityConfiguration: EntityConfiguration<TFields, TIDField>,
): EntityDatabaseAdapter<TFields, TIDField> {
return new StubPostgresDatabaseAdapter(entityConfiguration, this.objectCollection);
}
}
@@ -0,0 +1,62 @@
import {
AlwaysAllowPrivacyPolicyRule,
DateField,
Entity,
EntityCompanionDefinition,
EntityConfiguration,
EntityPrivacyPolicy,
ViewerContext,
} from '@expo/entity';
export type DateIDTestFields = {
id: Date;
};
export const dateIDTestEntityConfiguration = new EntityConfiguration<DateIDTestFields, 'id'>({
idField: 'id',
tableName: 'simple_test_entity_should_not_write_to_db',
schema: {
id: new DateField({
columnName: 'custom_id',
cache: true,
}),
},
databaseAdapterFlavor: 'postgres',
cacheAdapterFlavor: 'redis',
});
export class DateIDTestEntityPrivacyPolicy extends EntityPrivacyPolicy<
DateIDTestFields,
'id',
ViewerContext,
DateIDTestEntity
> {
protected override readonly readRules = [
new AlwaysAllowPrivacyPolicyRule<DateIDTestFields, 'id', ViewerContext, DateIDTestEntity>(),
];
protected override readonly createRules = [
new AlwaysAllowPrivacyPolicyRule<DateIDTestFields, 'id', ViewerContext, DateIDTestEntity>(),
];
protected override readonly updateRules = [
new AlwaysAllowPrivacyPolicyRule<DateIDTestFields, 'id', ViewerContext, DateIDTestEntity>(),
];
protected override readonly deleteRules = [
new AlwaysAllowPrivacyPolicyRule<DateIDTestFields, 'id', ViewerContext, DateIDTestEntity>(),
];
}
export class DateIDTestEntity extends Entity<DateIDTestFields, 'id', ViewerContext> {
static defineCompanionDefinition(): EntityCompanionDefinition<
DateIDTestFields,
'id',
ViewerContext,
DateIDTestEntity,
DateIDTestEntityPrivacyPolicy
> {
return {
entityClass: DateIDTestEntity,
entityConfiguration: dateIDTestEntityConfiguration,
privacyPolicyClass: DateIDTestEntityPrivacyPolicy,
};
}
}
@@ -0,0 +1,95 @@
import {
AlwaysAllowPrivacyPolicyRule,
Entity,
EntityCompanionDefinition,
EntityConfiguration,
EntityPrivacyPolicy,
UUIDField,
ViewerContext,
} from '@expo/entity';
export type SimpleTestFields = {
id: string;
};
export type SimpleTestFieldSelection = keyof SimpleTestFields;
export const simpleTestEntityConfiguration = new EntityConfiguration<SimpleTestFields, 'id'>({
idField: 'id',
tableName: 'simple_test_entity_should_not_write_to_db',
schema: {
id: new UUIDField({
columnName: 'custom_id',
cache: true,
}),
},
databaseAdapterFlavor: 'postgres',
cacheAdapterFlavor: 'redis',
});
export class SimpleTestEntityPrivacyPolicy extends EntityPrivacyPolicy<
SimpleTestFields,
'id',
ViewerContext,
SimpleTestEntity,
SimpleTestFieldSelection
> {
protected override readonly readRules = [
new AlwaysAllowPrivacyPolicyRule<
SimpleTestFields,
'id',
ViewerContext,
SimpleTestEntity,
SimpleTestFieldSelection
>(),
];
protected override readonly createRules = [
new AlwaysAllowPrivacyPolicyRule<
SimpleTestFields,
'id',
ViewerContext,
SimpleTestEntity,
SimpleTestFieldSelection
>(),
];
protected override readonly updateRules = [
new AlwaysAllowPrivacyPolicyRule<
SimpleTestFields,
'id',
ViewerContext,
SimpleTestEntity,
SimpleTestFieldSelection
>(),
];
protected override readonly deleteRules = [
new AlwaysAllowPrivacyPolicyRule<
SimpleTestFields,
'id',
ViewerContext,
SimpleTestEntity,
SimpleTestFieldSelection
>(),
];
}
export class SimpleTestEntity extends Entity<
SimpleTestFields,
'id',
ViewerContext,
SimpleTestFieldSelection
> {
static defineCompanionDefinition(): EntityCompanionDefinition<
SimpleTestFields,
'id',
ViewerContext,
SimpleTestEntity,
SimpleTestEntityPrivacyPolicy,
SimpleTestFieldSelection
> {
return {
entityClass: SimpleTestEntity,
entityConfiguration: simpleTestEntityConfiguration,
privacyPolicyClass: SimpleTestEntityPrivacyPolicy,
};
}
}
@@ -0,0 +1,130 @@
import {
AlwaysAllowPrivacyPolicyRule,
DateField,
Entity,
EntityCompanionDefinition,
EntityConfiguration,
EntityPrivacyPolicy,
IntField,
StringField,
UUIDField,
ViewerContext,
} from '@expo/entity';
import { result, Result } from '@expo/results';
export type TestFields = {
customIdField: string;
testIndexedField: string;
stringField: string;
intField: number;
dateField: Date;
nullableField: string | null;
};
export const testEntityConfiguration = new EntityConfiguration<TestFields, 'customIdField'>({
idField: 'customIdField',
tableName: 'test_entity_should_not_write_to_db',
schema: {
customIdField: new UUIDField({
columnName: 'custom_id',
cache: true,
}),
testIndexedField: new StringField({
columnName: 'test_index',
cache: true,
}),
stringField: new StringField({
columnName: 'string_field',
}),
intField: new IntField({
columnName: 'number_field',
}),
dateField: new DateField({
columnName: 'date_field',
}),
nullableField: new StringField({
columnName: 'nullable_field',
}),
},
databaseAdapterFlavor: 'postgres',
cacheAdapterFlavor: 'redis',
compositeFieldDefinitions: [
{ compositeField: ['stringField', 'intField'], cache: false },
{ compositeField: ['stringField', 'testIndexedField'], cache: true },
{ compositeField: ['nullableField', 'testIndexedField'], cache: true },
],
});
export class TestEntityPrivacyPolicy extends EntityPrivacyPolicy<
TestFields,
'customIdField',
ViewerContext,
TestEntity
> {
protected override readonly readRules = [
new AlwaysAllowPrivacyPolicyRule<TestFields, 'customIdField', ViewerContext, TestEntity>(),
];
protected override readonly createRules = [
new AlwaysAllowPrivacyPolicyRule<TestFields, 'customIdField', ViewerContext, TestEntity>(),
];
protected override readonly updateRules = [
new AlwaysAllowPrivacyPolicyRule<TestFields, 'customIdField', ViewerContext, TestEntity>(),
];
protected override readonly deleteRules = [
new AlwaysAllowPrivacyPolicyRule<TestFields, 'customIdField', ViewerContext, TestEntity>(),
];
}
export class TestEntity extends Entity<TestFields, 'customIdField', ViewerContext> {
static defineCompanionDefinition(): EntityCompanionDefinition<
TestFields,
'customIdField',
ViewerContext,
TestEntity,
TestEntityPrivacyPolicy
> {
return {
entityClass: TestEntity,
entityConfiguration: testEntityConfiguration,
privacyPolicyClass: TestEntityPrivacyPolicy,
};
}
getBlah(): string {
return 'Hello World!';
}
static async helloAsync(
viewerContext: ViewerContext,
testValue: string,
): Promise<Result<TestEntity>> {
const fields = {
customIdField: testValue,
testIndexedField: 'hello',
stringField: 'hello',
intField: 1,
dateField: new Date(),
nullableField: null,
};
return result(
new TestEntity({
viewerContext,
id: testValue,
databaseFields: fields,
selectedFields: fields,
}),
);
}
static async returnErrorAsync(_viewerContext: ViewerContext): Promise<Result<TestEntity>> {
return result(new Error('return entity'));
}
static async throwErrorAsync(_viewerContext: ViewerContext): Promise<Result<TestEntity>> {
throw new Error('threw entity');
}
static async nonResultAsync(_viewerContext: ViewerContext, testValue: string): Promise<string> {
return testValue;
}
}
@@ -0,0 +1,62 @@
import {
AlwaysAllowPrivacyPolicyRule,
Entity,
EntityCompanionDefinition,
EntityConfiguration,
EntityPrivacyPolicy,
IntField,
ViewerContext,
} from '@expo/entity';
export type NumberKeyFields = {
id: number;
};
export const numberKeyEntityConfiguration = new EntityConfiguration<NumberKeyFields, 'id'>({
idField: 'id',
tableName: 'simple_test_entity_should_not_write_to_db',
schema: {
id: new IntField({
columnName: 'custom_id',
cache: false,
}),
},
databaseAdapterFlavor: 'postgres',
cacheAdapterFlavor: 'redis',
});
export class NumberKeyPrivacyPolicy extends EntityPrivacyPolicy<
NumberKeyFields,
'id',
ViewerContext,
NumberKeyEntity
> {
protected override readonly readRules = [
new AlwaysAllowPrivacyPolicyRule<NumberKeyFields, 'id', ViewerContext, NumberKeyEntity>(),
];
protected override readonly createRules = [
new AlwaysAllowPrivacyPolicyRule<NumberKeyFields, 'id', ViewerContext, NumberKeyEntity>(),
];
protected override readonly updateRules = [
new AlwaysAllowPrivacyPolicyRule<NumberKeyFields, 'id', ViewerContext, NumberKeyEntity>(),
];
protected override readonly deleteRules = [
new AlwaysAllowPrivacyPolicyRule<NumberKeyFields, 'id', ViewerContext, NumberKeyEntity>(),
];
}
export class NumberKeyEntity extends Entity<NumberKeyFields, 'id', ViewerContext> {
static defineCompanionDefinition(): EntityCompanionDefinition<
NumberKeyFields,
'id',
ViewerContext,
NumberKeyEntity,
NumberKeyPrivacyPolicy
> {
return {
entityClass: NumberKeyEntity,
entityConfiguration: numberKeyEntityConfiguration,
privacyPolicyClass: NumberKeyPrivacyPolicy,
};
}
}
@@ -0,0 +1,33 @@
import { expect, it } from '@jest/globals';
import { readFile } from 'fs/promises';
import path from 'path';
it.each([
'StubPostgresDatabaseAdapter',
'StubPostgresDatabaseAdapterProvider',
'createUnitTestPostgresEntityCompanionProvider',
])('%s is the same as in @expo/entity-database-adapter-knex', async (fileName) => {
// These stub adapters need to be shared for testing, but we can't have them in the main
// entity-database-adapter-knex package since they would be exposed in production.
// Therefore, we duplicate them and ensure they stay in sync.
const fileContentsFromEntityDatabaseAdapterKnex = await readFile(
path.resolve(
__dirname,
`../../../entity-database-adapter-knex/src/__tests__/fixtures/${fileName}.ts`,
),
'utf-8',
);
const fileContentsFromTestingUtils = await readFile(
path.resolve(__dirname, `../${fileName}.ts`),
'utf-8',
);
const trimmedFiles = [
fileContentsFromEntityDatabaseAdapterKnex,
fileContentsFromTestingUtils,
].map((file) => file.substring(file.indexOf('export')));
expect(trimmedFiles[0]?.length).toBeGreaterThan(0);
expect(trimmedFiles[0]).toEqual(trimmedFiles[1]);
});
@@ -0,0 +1,841 @@
import {
CompositeFieldHolder,
CompositeFieldValueHolder,
EntityQueryContext,
SingleFieldHolder,
SingleFieldValueHolder,
} from '@expo/entity';
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';
import { StubPostgresDatabaseAdapter } from '../StubPostgresDatabaseAdapter';
import {
DateIDTestFields,
dateIDTestEntityConfiguration,
} from '../__testfixtures__/DateIDTestEntity';
import {
SimpleTestFields,
simpleTestEntityConfiguration,
} from '../__testfixtures__/SimpleTestEntity';
import { TestFields, testEntityConfiguration } from '../__testfixtures__/TestEntity';
import {
NumberKeyFields,
numberKeyEntityConfiguration,
} from '../__testfixtures__/TestEntityNumberKey';
// uuid keeps state internally for v7 generation, so we fix the time for all tests for consistent test results
const expectedTime = new Date('2024-06-03T20:16:33.761Z');
jest.useFakeTimers({
now: expectedTime,
});
describe(StubPostgresDatabaseAdapter, () => {
describe('fetchManyWhereAsync', () => {
it('fetches many where single', async () => {
const queryContext = instance(mock(EntityQueryContext));
const databaseAdapter = new StubPostgresDatabaseAdapter<TestFields, 'customIdField'>(
testEntityConfiguration,
StubPostgresDatabaseAdapter.convertFieldObjectsToDataStore(
testEntityConfiguration,
new Map([
[
testEntityConfiguration.tableName,
[
{
customIdField: 'hello',
testIndexedField: 'h1',
intField: 5,
stringField: 'huh',
dateField: new Date(),
nullableField: null,
},
{
customIdField: 'world',
testIndexedField: 'h2',
intField: 3,
stringField: 'wat',
dateField: new Date(),
nullableField: null,
},
],
],
]),
),
);
const results = await databaseAdapter.fetchManyWhereAsync(
queryContext,
new SingleFieldHolder('stringField'),
[new SingleFieldValueHolder('huh')],
);
expect(results.get(new SingleFieldValueHolder('huh'))).toHaveLength(1);
});
it('fetches many where composite', async () => {
const queryContext = instance(mock(EntityQueryContext));
const databaseAdapter = new StubPostgresDatabaseAdapter<TestFields, 'customIdField'>(
testEntityConfiguration,
StubPostgresDatabaseAdapter.convertFieldObjectsToDataStore(
testEntityConfiguration,
new Map([
[
testEntityConfiguration.tableName,
[
{
customIdField: 'hello',
testIndexedField: 'h1',
intField: 5,
stringField: 'huh',
dateField: new Date(),
nullableField: null,
},
{
customIdField: 'world',
testIndexedField: 'h2',
intField: 3,
stringField: 'wat',
dateField: new Date(),
nullableField: null,
},
],
],
]),
),
);
const results = await databaseAdapter.fetchManyWhereAsync(
queryContext,
new CompositeFieldHolder<TestFields, 'customIdField'>(['stringField', 'intField']),
[new CompositeFieldValueHolder({ stringField: 'huh', intField: 5 })],
);
expect(
results.get(new CompositeFieldValueHolder({ stringField: 'huh', intField: 5 })),
).toHaveLength(1);
const results2 = await databaseAdapter.fetchManyWhereAsync(
queryContext,
new CompositeFieldHolder<TestFields, 'customIdField'>(['stringField', 'intField']),
[new CompositeFieldValueHolder({ stringField: 'not-in-db', intField: 5 })],
);
expect(
results2.get(new CompositeFieldValueHolder({ stringField: 'not-in-db', intField: 5 })),
).toHaveLength(0);
});
it('handles duplicate tuples and filters them out', async () => {
const queryContext = instance(mock(EntityQueryContext));
const databaseAdapter = new StubPostgresDatabaseAdapter<TestFields, 'customIdField'>(
testEntityConfiguration,
StubPostgresDatabaseAdapter.convertFieldObjectsToDataStore(
testEntityConfiguration,
new Map([
[
testEntityConfiguration.tableName,
[
{
customIdField: 'id1',
testIndexedField: 'h1',
intField: 5,
stringField: 'test1',
dateField: new Date(),
nullableField: null,
},
{
customIdField: 'id2',
testIndexedField: 'h2',
intField: 10,
stringField: 'test2',
dateField: new Date(),
nullableField: null,
},
],
],
]),
),
);
// Fetch with duplicate field values - this will cause uniqBy to filter duplicates
const results = await databaseAdapter.fetchManyWhereAsync(
queryContext,
new SingleFieldHolder('customIdField'),
[
new SingleFieldValueHolder('id1'),
new SingleFieldValueHolder('id2'),
new SingleFieldValueHolder('id1'),
new SingleFieldValueHolder('id2'),
],
);
// Should only get 2 unique results despite passing 4 values (2 duplicates)
expect(results.get(new SingleFieldValueHolder('id1'))).toHaveLength(1);
expect(results.get(new SingleFieldValueHolder('id2'))).toHaveLength(1);
// Verify the actual objects returned
const id1Results = results.get(new SingleFieldValueHolder('id1'));
expect(id1Results?.[0]).toMatchObject({ customIdField: 'id1', stringField: 'test1' });
const id2Results = results.get(new SingleFieldValueHolder('id2'));
expect(id2Results?.[0]).toMatchObject({ customIdField: 'id2', stringField: 'test2' });
});
});
describe('fetchOneWhereAsync', () => {
it('fetches one where single', async () => {
const queryContext = instance(mock(EntityQueryContext));
const databaseAdapter = new StubPostgresDatabaseAdapter<TestFields, 'customIdField'>(
testEntityConfiguration,
StubPostgresDatabaseAdapter.convertFieldObjectsToDataStore(
testEntityConfiguration,
new Map([
[
testEntityConfiguration.tableName,
[
{
customIdField: 'hello',
testIndexedField: 'h1',
intField: 5,
stringField: 'huh',
dateField: new Date(),
nullableField: null,
},
{
customIdField: 'world',
testIndexedField: 'h2',
intField: 3,
stringField: 'huh',
dateField: new Date(),
nullableField: null,
},
],
],
]),
),
);
const result = await databaseAdapter.fetchOneWhereAsync(
queryContext,
new SingleFieldHolder('stringField'),
new SingleFieldValueHolder('huh'),
);
expect(result).toMatchObject({
stringField: 'huh',
});
});
it('returns null when no record found', async () => {
const queryContext = instance(mock(EntityQueryContext));
const databaseAdapter = new StubPostgresDatabaseAdapter<TestFields, 'customIdField'>(
testEntityConfiguration,
new Map(),
);
const result = await databaseAdapter.fetchOneWhereAsync(
queryContext,
new SingleFieldHolder('stringField'),
new SingleFieldValueHolder('huh'),
);
expect(result).toBeNull();
});
it('fetches one where composite', async () => {
const queryContext = instance(mock(EntityQueryContext));
const databaseAdapter = new StubPostgresDatabaseAdapter<TestFields, 'customIdField'>(
testEntityConfiguration,
StubPostgresDatabaseAdapter.convertFieldObjectsToDataStore(
testEntityConfiguration,
new Map([
[
testEntityConfiguration.tableName,
[
{
customIdField: 'hello',
testIndexedField: 'h1',
intField: 5,
stringField: 'huh',
dateField: new Date(),
nullableField: null,
},
{
customIdField: 'world',
testIndexedField: 'h2',
intField: 5,
stringField: 'huh',
dateField: new Date(),
nullableField: null,
},
],
],
]),
),
);
const result = await databaseAdapter.fetchOneWhereAsync(
queryContext,
new CompositeFieldHolder<TestFields, 'customIdField'>(['stringField', 'intField']),
new CompositeFieldValueHolder({ stringField: 'huh', intField: 5 }),
);
expect(result).toMatchObject({
stringField: 'huh',
intField: 5,
});
});
});
describe('fetchManyByFieldEqualityConjunctionAsync', () => {
it('supports conjuntions and query modifiers', async () => {
const queryContext = instance(mock(EntityQueryContext));
const databaseAdapter = new StubPostgresDatabaseAdapter<TestFields, 'customIdField'>(
testEntityConfiguration,
StubPostgresDatabaseAdapter.convertFieldObjectsToDataStore(
testEntityConfiguration,
new Map([
[
testEntityConfiguration.tableName,
[
{
customIdField: 'hello',
testIndexedField: 'h1',
intField: 3,
stringField: 'a',
dateField: new Date(),
nullableField: null,
},
{
customIdField: 'world',
testIndexedField: 'h2',
intField: 3,
stringField: 'b',
dateField: new Date(),
nullableField: null,
},
{
customIdField: 'world',
testIndexedField: 'h2',
intField: 3,
stringField: 'c',
dateField: new Date(),
nullableField: null,
},
],
],
]),
),
);
const results = await databaseAdapter.fetchManyByFieldEqualityConjunctionAsync(
queryContext,
[
{
fieldName: 'customIdField',
fieldValues: ['hello', 'world'],
},
{
fieldName: 'intField',
fieldValue: 3,
},
],
{
limit: 2,
offset: 1,
orderBy: [
{
fieldName: 'stringField',
order: OrderByOrdering.DESCENDING,
},
],
},
);
expect(results).toHaveLength(2);
expect(results.map((e) => e.stringField)).toEqual(['b', 'a']);
});
it('supports multiple order bys', async () => {
const queryContext = instance(mock(EntityQueryContext));
const databaseAdapter = new StubPostgresDatabaseAdapter<TestFields, 'customIdField'>(
testEntityConfiguration,
StubPostgresDatabaseAdapter.convertFieldObjectsToDataStore(
testEntityConfiguration,
new Map([
[
testEntityConfiguration.tableName,
[
{
customIdField: 'hello',
testIndexedField: 'h1',
intField: 3,
stringField: 'a',
dateField: new Date(),
nullableField: null,
},
{
customIdField: 'world',
testIndexedField: 'h2',
intField: 3,
stringField: 'b',
dateField: new Date(),
nullableField: null,
},
{
customIdField: 'world',
testIndexedField: 'h2',
intField: 3,
stringField: 'c',
dateField: new Date(),
nullableField: null,
},
],
],
]),
),
);
const results = await databaseAdapter.fetchManyByFieldEqualityConjunctionAsync(
queryContext,
[
{
fieldName: 'intField',
fieldValue: 3,
},
],
{
orderBy: [
{
fieldName: 'intField',
order: OrderByOrdering.DESCENDING,
},
{
fieldName: 'stringField',
order: OrderByOrdering.DESCENDING,
},
],
},
);
expect(results).toHaveLength(3);
expect(results.map((e) => e.stringField)).toEqual(['c', 'b', 'a']);
});
it('supports null field values', async () => {
const queryContext = instance(mock(EntityQueryContext));
const databaseAdapter = new StubPostgresDatabaseAdapter<TestFields, 'customIdField'>(
testEntityConfiguration,
StubPostgresDatabaseAdapter.convertFieldObjectsToDataStore(
testEntityConfiguration,
new Map([
[
testEntityConfiguration.tableName,
[
{
customIdField: '1',
testIndexedField: 'h1',
intField: 1,
stringField: 'a',
dateField: new Date(),
nullableField: 'a',
},
{
customIdField: '2',
testIndexedField: 'h2',
intField: 2,
stringField: 'a',
dateField: new Date(),
nullableField: 'b',
},
{
customIdField: '3',
testIndexedField: 'h3',
intField: 3,
stringField: 'a',
dateField: new Date(),
nullableField: null,
},
{
customIdField: '4',
testIndexedField: 'h4',
intField: 4,
stringField: 'b',
dateField: new Date(),
nullableField: null,
},
],
],
]),
),
);
const results = await databaseAdapter.fetchManyByFieldEqualityConjunctionAsync(
queryContext,
[{ fieldName: 'nullableField', fieldValue: null }],
{},
);
expect(results).toHaveLength(2);
expect(results[0]!.nullableField).toBeNull();
const results2 = await databaseAdapter.fetchManyByFieldEqualityConjunctionAsync(
queryContext,
[
{ fieldName: 'nullableField', fieldValues: ['a', null] },
{ fieldName: 'stringField', fieldValue: 'a' },
],
{
orderBy: [
{
fieldName: 'nullableField',
order: OrderByOrdering.DESCENDING,
},
],
},
);
expect(results2).toHaveLength(2);
expect(results2.map((e) => e.nullableField)).toEqual([null, 'a']);
});
});
describe('fetchManyByRawWhereClauseAsync', () => {
it('throws because it is unsupported', async () => {
const queryContext = instance(mock(EntityQueryContext));
const databaseAdapter = new StubPostgresDatabaseAdapter<TestFields, 'customIdField'>(
testEntityConfiguration,
new Map(),
);
await expect(
databaseAdapter.fetchManyByRawWhereClauseAsync(queryContext, '', [], {}),
).rejects.toThrow();
});
});
describe('fetchManyBySQLFragmentAsync', () => {
it('throws because it is unsupported', async () => {
const queryContext = instance(mock(EntityQueryContext));
const databaseAdapter = new StubPostgresDatabaseAdapter<TestFields, 'customIdField'>(
testEntityConfiguration,
new Map(),
);
await expect(
databaseAdapter.fetchManyBySQLFragmentAsync(queryContext, sql``, {}),
).rejects.toThrow();
});
});
describe('insertAsync', () => {
it('inserts a record', async () => {
const queryContext = instance(mock(EntityQueryContext));
const databaseAdapter = new StubPostgresDatabaseAdapter<TestFields, 'customIdField'>(
testEntityConfiguration,
new Map(),
);
const result = await databaseAdapter.insertAsync(queryContext, {
stringField: 'hello',
});
expect(result).toMatchObject({
stringField: 'hello',
});
expect(
databaseAdapter.getObjectCollectionForTable(testEntityConfiguration.tableName),
).toHaveLength(1);
});
it('inserts a record with valid v7 id', async () => {
const queryContext = instance(mock(EntityQueryContext));
const databaseAdapter = new StubPostgresDatabaseAdapter<TestFields, 'customIdField'>(
testEntityConfiguration,
new Map(),
);
const result = await databaseAdapter.insertAsync(queryContext, {
stringField: 'hello',
});
const ts = getTimeFromUUIDv7(result.customIdField);
expect(ts).toEqual(expectedTime);
});
});
describe('updateAsync', () => {
it('updates a record', async () => {
const queryContext = instance(mock(EntityQueryContext));
const databaseAdapter = new StubPostgresDatabaseAdapter<TestFields, 'customIdField'>(
testEntityConfiguration,
StubPostgresDatabaseAdapter.convertFieldObjectsToDataStore(
testEntityConfiguration,
new Map([
[
testEntityConfiguration.tableName,
[
{
customIdField: 'hello',
testIndexedField: 'h1',
intField: 3,
stringField: 'a',
dateField: new Date(),
nullableField: null,
},
],
],
]),
),
);
const result = await databaseAdapter.updateAsync(queryContext, 'customIdField', 'hello', {
stringField: 'b',
});
expect(result).toMatchObject({
stringField: 'b',
testIndexedField: 'h1',
});
});
it('throws error when empty update to match common DBMS behavior', async () => {
const queryContext = instance(mock(EntityQueryContext));
const databaseAdapter = new StubPostgresDatabaseAdapter<TestFields, 'customIdField'>(
testEntityConfiguration,
StubPostgresDatabaseAdapter.convertFieldObjectsToDataStore(
testEntityConfiguration,
new Map([
[
testEntityConfiguration.tableName,
[
{
customIdField: 'hello',
testIndexedField: 'h1',
intField: 3,
stringField: 'a',
dateField: new Date(),
nullableField: null,
},
],
],
]),
),
);
await expect(
databaseAdapter.updateAsync(queryContext, 'customIdField', 'hello', {}),
).rejects.toThrow(`Empty update (custom_id = hello)`);
});
it('throws error when updating nonexistent record', async () => {
const queryContext = instance(mock(EntityQueryContext));
const databaseAdapter = new StubPostgresDatabaseAdapter<TestFields, 'customIdField'>(
testEntityConfiguration,
StubPostgresDatabaseAdapter.convertFieldObjectsToDataStore(
testEntityConfiguration,
new Map([
[
testEntityConfiguration.tableName,
[
{
customIdField: 'existing-id',
testIndexedField: 'h1',
intField: 3,
stringField: 'a',
dateField: new Date(),
nullableField: null,
},
],
],
]),
),
);
// Try to update a record that doesn't exist
await expect(
databaseAdapter.updateAsync(
queryContext,
'customIdField',
'nonexistent-id', // This ID doesn't exist in the data store
{ stringField: 'updated' },
),
).rejects.toThrow('Empty results from database adapter update');
});
});
describe('deleteAsync', () => {
it('deletes an object', async () => {
const queryContext = instance(mock(EntityQueryContext));
const databaseAdapter = new StubPostgresDatabaseAdapter<TestFields, 'customIdField'>(
testEntityConfiguration,
StubPostgresDatabaseAdapter.convertFieldObjectsToDataStore(
testEntityConfiguration,
new Map([
[
testEntityConfiguration.tableName,
[
{
customIdField: 'hello',
testIndexedField: 'h1',
intField: 3,
stringField: 'a',
dateField: new Date(),
nullableField: null,
},
],
],
]),
),
);
await databaseAdapter.deleteAsync(queryContext, 'customIdField', 'hello');
expect(
databaseAdapter.getObjectCollectionForTable(testEntityConfiguration.tableName),
).toHaveLength(0);
});
it('handles deletion of nonexistent record gracefully', async () => {
const queryContext = instance(mock(EntityQueryContext));
const databaseAdapter = new StubPostgresDatabaseAdapter<TestFields, 'customIdField'>(
testEntityConfiguration,
StubPostgresDatabaseAdapter.convertFieldObjectsToDataStore(
testEntityConfiguration,
new Map([
[
testEntityConfiguration.tableName,
[
{
customIdField: 'existing-id',
testIndexedField: 'h1',
intField: 3,
stringField: 'a',
dateField: new Date(),
nullableField: null,
},
],
],
]),
),
);
// Delete a record that doesn't exist
// Unlike update, delete doesn't throw an error when the record doesn't exist
await databaseAdapter.deleteAsync(
queryContext,
'customIdField',
'nonexistent-id', // This ID doesn't exist in the data store
);
});
});
it('supports string and number IDs', async () => {
const queryContext = instance(mock(EntityQueryContext));
const databaseAdapter1 = new StubPostgresDatabaseAdapter<SimpleTestFields, 'id'>(
simpleTestEntityConfiguration,
new Map(),
);
const insertedObject1 = await databaseAdapter1.insertAsync(queryContext, {});
expect(typeof insertedObject1.id).toBe('string');
const databaseAdapter2 = new StubPostgresDatabaseAdapter<NumberKeyFields, 'id'>(
numberKeyEntityConfiguration,
new Map(),
);
const insertedObject2 = await databaseAdapter2.insertAsync(queryContext, {});
expect(typeof insertedObject2.id).toBe('number');
const databaseAdapter3 = new StubPostgresDatabaseAdapter<DateIDTestFields, 'id'>(
dateIDTestEntityConfiguration,
new Map(),
);
await expect(databaseAdapter3.insertAsync(queryContext, {})).rejects.toThrow(
'Unsupported ID type for StubPostgresDatabaseAdapter: DateField',
);
});
});
describe('compareByOrderBys', () => {
describe('comparison', () => {
it.each([
// 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
[OrderByOrdering.DESCENDING, null, null, 0],
[OrderByOrdering.ASCENDING, null, null, 0],
// nulls compare with -1
[OrderByOrdering.DESCENDING, null, -1, -1],
[OrderByOrdering.ASCENDING, null, -1, 1],
[OrderByOrdering.DESCENDING, -1, null, 1],
[OrderByOrdering.ASCENDING, -1, null, -1],
// basic compares
[OrderByOrdering.ASCENDING, 'a', 'b', -1],
[OrderByOrdering.ASCENDING, 'b', 'a', 1],
[OrderByOrdering.DESCENDING, 'a', 'b', 1],
[OrderByOrdering.DESCENDING, 'b', 'a', -1],
])('case (%p; %p; %p)', (order, v1, v2, expectedResult) => {
expect(
StubPostgresDatabaseAdapter['compareByOrderBys'](
[
{
columnName: 'hello',
order,
},
],
{
hello: v1,
},
{
hello: v2,
},
),
).toEqual(expectedResult);
});
it('works for empty', () => {
expect(
StubPostgresDatabaseAdapter['compareByOrderBys'](
[],
{
hello: 'test',
},
{
hello: 'blah',
},
),
).toEqual(0);
});
});
describe('recursing', () => {
expect(
StubPostgresDatabaseAdapter['compareByOrderBys'](
[
{
columnName: 'hello',
order: OrderByOrdering.ASCENDING,
},
{
columnName: 'world',
order: OrderByOrdering.ASCENDING,
},
],
{
hello: 'a',
world: 1,
},
{
hello: 'a',
world: 2,
},
),
).toEqual(-1);
});
});
/**
* Returns the Date object encoded in the first 48 bits of the given UUIDv7.
* @throws TypeError if the UUID is not version 7
*/
function getTimeFromUUIDv7(uuid: string): Date {
if (!(validate(uuid) && version(uuid) === 7)) {
throw new TypeError(`Invalid UUID: ${uuid}`);
}
// The first 48 bits = 12 hex characters of the UUID encode the timestamp in big endian
const hexCharacters = uuid.replaceAll('-', '').split('', 12);
const milliseconds = hexCharacters.reduce(
(milliseconds, character) => milliseconds * 16 + parseInt(character, 16),
0,
);
return new Date(milliseconds);
}
@@ -0,0 +1,16 @@
import { EntityCompanionProvider, ViewerContext } from '@expo/entity';
import { describe, expect, it } from '@jest/globals';
import { TestEntity } from '../__testfixtures__/TestEntity';
import { createUnitTestPostgresEntityCompanionProvider } from '../createUnitTestPostgresEntityCompanionProvider';
describe(createUnitTestPostgresEntityCompanionProvider, () => {
it('creates a new EntityCompanionProvider', async () => {
const companionProvider = createUnitTestPostgresEntityCompanionProvider();
expect(companionProvider).toBeInstanceOf(EntityCompanionProvider);
const viewerContext = new ViewerContext(companionProvider);
await expect(TestEntity.creator(viewerContext).createAsync()).resolves.toBeInstanceOf(
TestEntity,
);
});
});
@@ -0,0 +1,42 @@
import {
EntityCompanionProvider,
IEntityMetricsAdapter,
NoOpEntityMetricsAdapter,
} from '@expo/entity';
import {
InMemoryFullCacheStubCacheAdapterProvider,
StubQueryContextProvider,
} from '@expo/entity-testing-utils';
import { StubPostgresDatabaseAdapterProvider } from './StubPostgresDatabaseAdapterProvider';
const queryContextProvider = new StubQueryContextProvider();
/**
* Entity companion provider for use in knex unit tests. All database and cache implementations
* are replaced with in-memory simulations.
*/
export const createUnitTestPostgresEntityCompanionProvider = (
metricsAdapter: IEntityMetricsAdapter = new NoOpEntityMetricsAdapter(),
): EntityCompanionProvider => {
return new EntityCompanionProvider(
metricsAdapter,
new Map([
[
'postgres',
{
adapterProvider: new StubPostgresDatabaseAdapterProvider(),
queryContextProvider,
},
],
]),
new Map([
[
'redis',
{
cacheAdapterProvider: new InMemoryFullCacheStubCacheAdapterProvider(),
},
],
]),
);
};
@@ -0,0 +1,9 @@
/* eslint-disable tsdoc/syntax */
/**
* @packageDocumentation
* @module @expo/entity-database-adapter-knex-testing-utils
*/
export * from './createUnitTestPostgresEntityCompanionProvider';
export * from './StubPostgresDatabaseAdapter';
export * from './StubPostgresDatabaseAdapterProvider';
@@ -0,0 +1,8 @@
{
"extends": "../../tsconfig.base.json",
"include": ["src/**/*"],
"references": [
{ "path": "../entity" },
{ "path": "../entity-database-adapter-knex" }
]
}
@@ -34,7 +34,8 @@
"devDependencies": {
"@expo/entity-testing-utils": "workspace:^",
"@jest/globals": "30.2.0",
"pg": "8.17.2",
"pg": "8.18.0",
"ts-mockito": "2.6.1",
"typescript": "5.9.3"
}
}
@@ -0,0 +1,438 @@
import {
EntityConstructionUtils,
EntityPrivacyPolicy,
EntityQueryContext,
ReadonlyEntity,
ViewerContext,
IEntityMetricsAdapter,
} from '@expo/entity';
import { Result } from '@expo/results';
import {
FieldEqualityCondition,
isSingleValueFieldEqualityCondition,
OrderByOrdering,
} from './BasePostgresEntityDatabaseAdapter';
import { BaseSQLQueryBuilder } from './BaseSQLQueryBuilder';
import { PaginationStrategy } from './PaginationStrategy';
import { SQLFragment } from './SQLOperator';
import type { Connection, PageInfo } from './internal/EntityKnexDataManager';
import { EntityKnexDataManager } from './internal/EntityKnexDataManager';
export interface EntityLoaderOrderByClause<
TFields extends Record<string, any>,
TSelectedFields extends keyof TFields = keyof TFields,
> {
/**
* The field name to order by.
*/
fieldName: TSelectedFields;
/**
* The OrderByOrdering to order by.
*/
order: OrderByOrdering;
}
/**
* SQL modifiers that only affect the selection but not the projection.
*/
export interface EntityLoaderQuerySelectionModifiers<
TFields extends Record<string, any>,
TSelectedFields extends keyof TFields = keyof TFields,
> {
/**
* Order the entities by specified columns and orders.
*/
orderBy?: readonly EntityLoaderOrderByClause<TFields, TSelectedFields>[];
/**
* Skip the specified number of entities queried before returning.
*/
offset?: number;
/**
* Limit the number of entities returned.
*/
limit?: number;
}
export interface EntityLoaderQuerySelectionModifiersWithOrderByRaw<
TFields extends Record<string, any>,
TSelectedFields extends keyof TFields = keyof TFields,
> extends EntityLoaderQuerySelectionModifiers<TFields, TSelectedFields> {
/**
* Order the entities by a raw SQL `ORDER BY` clause.
*/
orderByRaw?: string;
}
export interface EntityLoaderQuerySelectionModifiersWithOrderByFragment<
TFields extends Record<string, any>,
TSelectedFields extends keyof TFields = keyof TFields,
> extends EntityLoaderQuerySelectionModifiers<TFields, TSelectedFields> {
/**
* Order the entities by a SQL fragment `ORDER BY` clause.
*/
orderByFragment?: SQLFragment;
}
interface SearchSpecificationBase<
TFields extends Record<string, any>,
TSelectedFields extends keyof TFields = keyof TFields,
> {
/**
* The search term to search for. Must be a non-empty string.
*/
term: string;
/**
* The fields to search within. Must be a non-empty array.
*/
fields: TSelectedFields[];
}
interface ILikeSearchSpecification<
TFields extends Record<string, any>,
TSelectedFields extends keyof TFields = keyof TFields,
> extends SearchSpecificationBase<TFields, TSelectedFields> {
/**
* Case-insensitive pattern matching search using SQL ILIKE operator.
* Results are ordered by the fields being searched within in the order specified, then by ID for tie-breaking and stable pagination.
*/
strategy: PaginationStrategy.ILIKE_SEARCH;
}
interface TrigramSearchSpecification<
TFields extends Record<string, any>,
TSelectedFields extends keyof TFields = keyof TFields,
> extends SearchSpecificationBase<TFields, TSelectedFields> {
/**
* Similarity search using PostgreSQL trigram similarity. Results are ordered by exact match priority, then by similarity score, then by specified extra order by fields if provided, then by ID for tie-breaking and stable pagination.
* Note that trigram similarity search can be significantly slower than ILIKE search, especially on large datasets without appropriate indexes, and results may not be as relevant as more advanced full-text search solutions.
* It is recommended to use this strategy only when ILIKE search does not meet the application's needs and to ensure appropriate database indexing for performance.
*/
strategy: PaginationStrategy.TRIGRAM_SEARCH;
/**
* Similarity threshold for trigram matching.
* Must be between 0 and 1, where:
* - 0 matches everything
* - 1 requires exact match
*
* Recommended threshold values:
* - 0.3: Loose matching, allows more variation (default PostgreSQL similarity threshold)
* - 0.4-0.5: Moderate matching, good balance for most use cases
* - 0.6+: Strict matching, requires high similarity
*/
threshold: number;
/**
* Optional additional fields to order by after similarity score and before ID for tie-breaking.
* These fields are independent of search fields and can be used to provide meaningful
* ordering when multiple results have the same similarity score.
*/
extraOrderByFields?: TSelectedFields[];
}
interface StandardPaginationSpecification<
TFields extends Record<string, any>,
TSelectedFields extends keyof TFields = keyof TFields,
> {
/**
* Standard pagination without search. Results are ordered by the specified orderBy fields.
*/
strategy: PaginationStrategy.STANDARD;
/**
* Order the entities by specified columns and orders. If the ID field is not included, it will be automatically added for stable pagination.
*/
orderBy: EntityLoaderOrderByClause<TFields, TSelectedFields>[];
}
/**
* Pagination specification for SQL-based pagination (with or without search).
*/
export type PaginationSpecification<
TFields extends Record<string, any>,
TSelectedFields extends keyof TFields = keyof TFields,
> =
| StandardPaginationSpecification<TFields, TSelectedFields>
| ILikeSearchSpecification<TFields, TSelectedFields>
| TrigramSearchSpecification<TFields, TSelectedFields>;
/**
* Base unified pagination arguments
*/
interface EntityLoaderBaseUnifiedPaginationArgs<
TFields extends Record<string, any>,
TSelectedFields extends keyof TFields = keyof TFields,
> {
/**
* SQLFragment representing the WHERE clause to filter the entities being paginated.
*/
where?: SQLFragment;
/**
* Pagination specification determining how to order and paginate results.
*/
pagination: PaginationSpecification<TFields, TSelectedFields>;
}
/**
* Forward unified pagination arguments
*/
export interface EntityLoaderForwardUnifiedPaginationArgs<
TFields extends Record<string, any>,
TSelectedFields extends keyof TFields = keyof TFields,
> extends EntityLoaderBaseUnifiedPaginationArgs<TFields, TSelectedFields> {
/**
* The number of entities to return starting from the entity after the cursor. Must be a positive integer.
*/
first: number;
/**
* The cursor to paginate after for forward pagination.
*/
after?: string;
}
/**
* Backward unified pagination arguments
*/
export interface EntityLoaderBackwardUnifiedPaginationArgs<
TFields extends Record<string, any>,
TSelectedFields extends keyof TFields = keyof TFields,
> extends EntityLoaderBaseUnifiedPaginationArgs<TFields, TSelectedFields> {
/**
* The number of entities to return starting from the entity before the cursor. Must be a positive integer.
*/
last: number;
/**
* The cursor to paginate before for backward pagination.
*/
before?: string;
}
/**
* Load page pagination arguments, which can be either forward or backward unified pagination arguments.
*/
export type EntityLoaderLoadPageArgs<
TFields extends Record<string, any>,
TSelectedFields extends keyof TFields = keyof TFields,
> =
| EntityLoaderForwardUnifiedPaginationArgs<TFields, TSelectedFields>
| EntityLoaderBackwardUnifiedPaginationArgs<TFields, TSelectedFields>;
/**
* Authorization-result-based knex entity loader for non-data-loader-based load methods.
* All loads through this loader are results (or null for some loader methods), where an
* unsuccessful result means an authorization error or entity construction error occurred.
* Other errors are thrown.
*/
export class AuthorizationResultBasedKnexEntityLoader<
TFields extends Record<string, any>,
TIDField extends keyof NonNullable<Pick<TFields, TSelectedFields>>,
TViewerContext extends ViewerContext,
TEntity extends ReadonlyEntity<TFields, TIDField, TViewerContext, TSelectedFields>,
TPrivacyPolicy extends EntityPrivacyPolicy<
TFields,
TIDField,
TViewerContext,
TEntity,
TSelectedFields
>,
TSelectedFields extends keyof TFields = keyof TFields,
> {
constructor(
private readonly queryContext: EntityQueryContext,
private readonly knexDataManager: EntityKnexDataManager<TFields, TIDField>,
protected readonly metricsAdapter: IEntityMetricsAdapter,
private readonly constructionUtils: EntityConstructionUtils<
TFields,
TIDField,
TViewerContext,
TEntity,
TPrivacyPolicy,
TSelectedFields
>,
) {}
/**
* Authorization-result-based version of the EnforcingKnexEntityLoader method by the same name.
* @returns the first entity results that matches the query, where result error can be
* UnauthorizedError
*/
async loadFirstByFieldEqualityConjunctionAsync<N extends keyof Pick<TFields, TSelectedFields>>(
fieldEqualityOperands: FieldEqualityCondition<TFields, N>[],
querySelectionModifiers: Omit<
EntityLoaderQuerySelectionModifiers<TFields, TSelectedFields>,
'limit'
> &
Required<Pick<EntityLoaderQuerySelectionModifiers<TFields, TSelectedFields>, 'orderBy'>>,
): Promise<Result<TEntity> | null> {
const results = await this.loadManyByFieldEqualityConjunctionAsync(fieldEqualityOperands, {
...querySelectionModifiers,
limit: 1,
});
return results[0] ?? null;
}
/**
* Authorization-result-based version of the EnforcingKnexEntityLoader method by the same name.
* @returns array of entity results that match the query, where result error can be UnauthorizedError
*/
async loadManyByFieldEqualityConjunctionAsync<N extends keyof Pick<TFields, TSelectedFields>>(
fieldEqualityOperands: FieldEqualityCondition<TFields, N>[],
querySelectionModifiers: EntityLoaderQuerySelectionModifiers<TFields, TSelectedFields> = {},
): Promise<readonly Result<TEntity>[]> {
for (const fieldEqualityOperand of fieldEqualityOperands) {
const fieldValues = isSingleValueFieldEqualityCondition(fieldEqualityOperand)
? [fieldEqualityOperand.fieldValue]
: fieldEqualityOperand.fieldValues;
this.constructionUtils.validateFieldAndValues(fieldEqualityOperand.fieldName, fieldValues);
}
const fieldObjects = await this.knexDataManager.loadManyByFieldEqualityConjunctionAsync(
this.queryContext,
fieldEqualityOperands,
querySelectionModifiers,
);
return await this.constructionUtils.constructAndAuthorizeEntitiesArrayAsync(fieldObjects);
}
/**
* Authorization-result-based version of the EnforcingKnexEntityLoader method by the same name.
* @returns array of entity results that match the query, where result error can be UnauthorizedError
* @throws Error when rawWhereClause or bindings are invalid
*/
async loadManyByRawWhereClauseAsync(
rawWhereClause: string,
bindings: any[] | object,
querySelectionModifiers: EntityLoaderQuerySelectionModifiersWithOrderByRaw<
TFields,
TSelectedFields
> = {},
): Promise<readonly Result<TEntity>[]> {
const fieldObjects = await this.knexDataManager.loadManyByRawWhereClauseAsync(
this.queryContext,
rawWhereClause,
bindings,
querySelectionModifiers,
);
return await this.constructionUtils.constructAndAuthorizeEntitiesArrayAsync(fieldObjects);
}
/**
* Authorization-result-based version of the EnforcingKnexEntityLoader method by the same name.
* @returns SQL query builder for building and executing SQL queries that when executed returns entity results where result error can be UnauthorizedError.
*/
loadManyBySQL(
fragment: SQLFragment,
modifiers: EntityLoaderQuerySelectionModifiersWithOrderByFragment<
TFields,
TSelectedFields
> = {},
): AuthorizationResultBasedSQLQueryBuilder<
TFields,
TIDField,
TViewerContext,
TEntity,
TPrivacyPolicy,
TSelectedFields
> {
return new AuthorizationResultBasedSQLQueryBuilder(
this.knexDataManager,
this.constructionUtils,
this.queryContext,
fragment,
modifiers,
);
}
/**
* Load a page of entities with Relay-style cursor pagination using a unified pagination specification.
* Only returns successfully authorized entities for cursor stability; failed authorization results are filtered out.
*
* @returns Connection with only successfully authorized entities
*/
async loadPageAsync(
args: EntityLoaderLoadPageArgs<TFields, TSelectedFields>,
): Promise<Connection<TEntity>> {
const pageResult = await this.knexDataManager.loadPageAsync(this.queryContext, args);
const edgeResults = await Promise.all(
pageResult.edges.map(async (edge) => {
const entityResult = await this.constructionUtils.constructAndAuthorizeEntityAsync(
edge.node,
);
if (!entityResult.ok) {
return null;
}
return {
...edge,
node: entityResult.value,
};
}),
);
const edges = edgeResults.filter((edge) => edge !== null);
const pageInfo: PageInfo = {
...pageResult.pageInfo,
startCursor: edges[0]?.cursor ?? null,
endCursor: edges[edges.length - 1]?.cursor ?? null,
};
return {
edges,
pageInfo,
};
}
}
/**
* SQL query builder implementation for AuthorizationResultBasedKnexEntityLoader.
*/
export class AuthorizationResultBasedSQLQueryBuilder<
TFields extends Record<string, any>,
TIDField extends keyof NonNullable<Pick<TFields, TSelectedFields>>,
TViewerContext extends ViewerContext,
TEntity extends ReadonlyEntity<TFields, TIDField, TViewerContext, TSelectedFields>,
TPrivacyPolicy extends EntityPrivacyPolicy<
TFields,
TIDField,
TViewerContext,
TEntity,
TSelectedFields
>,
TSelectedFields extends keyof TFields,
> extends BaseSQLQueryBuilder<TFields, TSelectedFields, Result<TEntity>> {
constructor(
private readonly knexDataManager: EntityKnexDataManager<TFields, TIDField>,
private readonly constructionUtils: EntityConstructionUtils<
TFields,
TIDField,
TViewerContext,
TEntity,
TPrivacyPolicy,
TSelectedFields
>,
private readonly queryContext: EntityQueryContext,
sqlFragment: SQLFragment,
modifiers: EntityLoaderQuerySelectionModifiersWithOrderByFragment<TFields, TSelectedFields>,
) {
super(sqlFragment, modifiers);
}
/**
* Execute the query and return results.
*/
async executeInternalAsync(): Promise<readonly Result<TEntity>[]> {
const fieldObjects = await this.knexDataManager.loadManyBySQLFragmentAsync(
this.queryContext,
this.getSQLFragment(),
this.getModifiers(),
);
return await this.constructionUtils.constructAndAuthorizeEntitiesArrayAsync(fieldObjects);
}
}
@@ -0,0 +1,315 @@
import {
EntityDatabaseAdapter,
EntityQueryContext,
getDatabaseFieldForEntityField,
transformDatabaseObjectToFields,
} from '@expo/entity';
import { Knex } from 'knex';
import { SQLFragment } from './SQLOperator';
/**
* Equality operand that is used for selecting entities with a field with a single value.
*/
export interface SingleValueFieldEqualityCondition<
TFields extends Record<string, any>,
N extends keyof TFields = keyof TFields,
> {
fieldName: N;
fieldValue: TFields[N];
}
/**
* Equality operand that is used for selecting entities with a field matching one of multiple values.
*/
export interface MultiValueFieldEqualityCondition<
TFields extends Record<string, any>,
N extends keyof TFields = keyof TFields,
> {
fieldName: N;
fieldValues: readonly TFields[N][];
}
/**
* A single equality operand for use in a selection clause.
* See EntityLoader.loadManyByFieldEqualityConjunctionAsync documentation for examples.
*/
export type FieldEqualityCondition<
TFields extends Record<string, any>,
N extends keyof TFields = keyof TFields,
> = SingleValueFieldEqualityCondition<TFields, N> | MultiValueFieldEqualityCondition<TFields, N>;
export function isSingleValueFieldEqualityCondition<
TFields extends Record<string, any>,
N extends keyof TFields = keyof TFields,
>(
condition: FieldEqualityCondition<TFields, N>,
): condition is SingleValueFieldEqualityCondition<TFields, N> {
return (condition as SingleValueFieldEqualityCondition<TFields, N>).fieldValue !== undefined;
}
export interface TableFieldSingleValueEqualityCondition {
tableField: string;
tableValue: any;
}
export interface TableFieldMultiValueEqualityCondition {
tableField: string;
tableValues: readonly any[];
}
/**
* Ordering options for `orderBy` clauses.
*/
export enum OrderByOrdering {
/**
* Ascending order (lowest to highest).
* Ascending order puts smaller values first, where "smaller" is defined in terms of the %3C operator.
*/
ASCENDING = 'asc',
/**
* Descending order (highest to lowest).
* Descending order puts larger values first, where "larger" is defined in terms of the %3E operator.
*/
DESCENDING = 'desc',
}
export interface PostgresOrderByClause<TFields extends Record<string, any>> {
/**
* The field name to order by.
*/
fieldName: keyof TFields;
/**
* The OrderByOrdering to order by.
*/
order: OrderByOrdering;
}
/**
* SQL modifiers that only affect the selection but not the projection.
*/
export interface PostgresQuerySelectionModifiers<TFields extends Record<string, any>> {
/**
* Order the entities by specified columns and orders.
*/
orderBy?: readonly PostgresOrderByClause<TFields>[];
/**
* Skip the specified number of entities queried before returning.
*/
offset?: number;
/**
* Limit the number of entities returned.
*/
limit?: number;
}
export interface PostgresQuerySelectionModifiersWithOrderByRaw<
TFields extends Record<string, any>,
> extends PostgresQuerySelectionModifiers<TFields> {
/**
* Order the entities by a raw SQL `ORDER BY` clause.
*/
orderByRaw?: string;
}
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,
> extends EntityDatabaseAdapter<TFields, TIDField> {
/**
* Get the maximum page size for pagination.
* @returns maximum page size if configured, undefined otherwise
*/
get paginationMaxPageSize(): number | undefined {
return undefined;
}
/**
* Fetch many objects matching the conjunction of where clauses constructed from
* specified field equality operands.
*
* @param queryContext - query context with which to perform the fetch
* @param fieldEqualityOperands - list of field equality where clause operand specifications
* @param querySelectionModifiers - limit, offset, orderBy, and orderByRaw for the query
* @returns array of objects matching the query
*/
async fetchManyByFieldEqualityConjunctionAsync<N extends keyof TFields>(
queryContext: EntityQueryContext,
fieldEqualityOperands: FieldEqualityCondition<TFields, N>[],
querySelectionModifiers: PostgresQuerySelectionModifiers<TFields>,
): Promise<readonly Readonly<TFields>[]> {
const tableFieldSingleValueOperands: TableFieldSingleValueEqualityCondition[] = [];
const tableFieldMultipleValueOperands: TableFieldMultiValueEqualityCondition[] = [];
for (const operand of fieldEqualityOperands) {
if (isSingleValueFieldEqualityCondition(operand)) {
tableFieldSingleValueOperands.push({
tableField: getDatabaseFieldForEntityField(this.entityConfiguration, operand.fieldName),
tableValue: operand.fieldValue,
});
} else {
tableFieldMultipleValueOperands.push({
tableField: getDatabaseFieldForEntityField(this.entityConfiguration, operand.fieldName),
tableValues: operand.fieldValues,
});
}
}
const results = await this.fetchManyByFieldEqualityConjunctionInternalAsync(
queryContext.getQueryInterface(),
this.entityConfiguration.tableName,
tableFieldSingleValueOperands,
tableFieldMultipleValueOperands,
this.convertToTableQueryModifiers(querySelectionModifiers),
);
return results.map((result) =>
transformDatabaseObjectToFields(this.entityConfiguration, this.fieldTransformerMap, result),
);
}
protected abstract fetchManyByFieldEqualityConjunctionInternalAsync(
queryInterface: Knex,
tableName: string,
tableFieldSingleValueEqualityOperands: TableFieldSingleValueEqualityCondition[],
tableFieldMultiValueEqualityOperands: TableFieldMultiValueEqualityCondition[],
querySelectionModifiers: TableQuerySelectionModifiers,
): Promise<object[]>;
/**
* Fetch many objects matching the raw WHERE clause.
*
* @param queryContext - query context with which to perform the fetch
* @param rawWhereClause - parameterized SQL WHERE clause with positional binding placeholders or named binding placeholders
* @param bindings - array of positional bindings or object of named bindings
* @param querySelectionModifiers - limit, offset, and orderBy for the query
* @returns array of objects matching the query
*/
async fetchManyByRawWhereClauseAsync(
queryContext: EntityQueryContext,
rawWhereClause: string,
bindings: any[] | object,
querySelectionModifiers: PostgresQuerySelectionModifiersWithOrderByRaw<TFields>,
): Promise<readonly Readonly<TFields>[]> {
const results = await this.fetchManyByRawWhereClauseInternalAsync(
queryContext.getQueryInterface(),
this.entityConfiguration.tableName,
rawWhereClause,
bindings,
this.convertToTableQueryModifiersWithOrderByRaw(querySelectionModifiers),
);
return results.map((result) =>
transformDatabaseObjectToFields(this.entityConfiguration, this.fieldTransformerMap, result),
);
}
protected abstract fetchManyByRawWhereClauseInternalAsync(
queryInterface: Knex,
tableName: string,
rawWhereClause: string,
bindings: object | any[],
querySelectionModifiers: TableQuerySelectionModifiersWithOrderByRaw,
): Promise<object[]>;
/**
* Fetch many objects matching the SQL fragment.
*
* @param queryContext - query context with which to perform the fetch
* @param sqlFragment - SQLFragment for the WHERE clause of the query
* @param querySelectionModifiers - limit, offset, and orderByFragment for the query
* @returns array of objects matching the query
*/
async fetchManyBySQLFragmentAsync(
queryContext: EntityQueryContext,
sqlFragment: SQLFragment,
querySelectionModifiers: PostgresQuerySelectionModifiersWithOrderByFragment<TFields>,
): Promise<readonly Readonly<TFields>[]> {
const results = await this.fetchManyBySQLFragmentInternalAsync(
queryContext.getQueryInterface(),
this.entityConfiguration.tableName,
sqlFragment,
this.convertToTableQueryModifiersWithOrderByFragment(querySelectionModifiers),
);
return results.map((result) =>
transformDatabaseObjectToFields(this.entityConfiguration, this.fieldTransformerMap, result),
);
}
protected abstract fetchManyBySQLFragmentInternalAsync(
queryInterface: Knex,
tableName: string,
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 {
const orderBy = querySelectionModifiers.orderBy;
return {
orderBy:
orderBy !== undefined
? orderBy.map((orderBySpecification) => ({
columnName: getDatabaseFieldForEntityField(
this.entityConfiguration,
orderBySpecification.fieldName,
),
order: orderBySpecification.order,
}))
: undefined,
offset: querySelectionModifiers.offset,
limit: querySelectionModifiers.limit,
};
}
}
@@ -0,0 +1,108 @@
import {
EntityLoaderOrderByClause,
EntityLoaderQuerySelectionModifiersWithOrderByFragment,
} from './AuthorizationResultBasedKnexEntityLoader';
import { OrderByOrdering } from './BasePostgresEntityDatabaseAdapter';
import { SQLFragment } from './SQLOperator';
/**
* Base SQL query builder that provides common functionality for building SQL queries.
*/
export abstract class BaseSQLQueryBuilder<
TFields extends Record<string, any>,
TSelectedFields extends keyof TFields,
TResultType,
> {
private executed = false;
constructor(
private readonly sqlFragment: SQLFragment,
private readonly modifiers: {
limit?: number;
offset?: number;
orderBy?: readonly EntityLoaderOrderByClause<TFields, TSelectedFields>[];
orderByFragment?: SQLFragment;
},
) {}
/**
* Limit the number of results
*/
limit(n: number): this {
this.modifiers.limit = n;
return this;
}
/**
* Skip a number of results
*/
offset(n: number): this {
this.modifiers.offset = n;
return this;
}
/**
* Order by a field. Can be called multiple times to add multiple order bys.
*/
orderBy(fieldName: TSelectedFields, order: OrderByOrdering = OrderByOrdering.ASCENDING): this {
this.modifiers.orderBy = [...(this.modifiers.orderBy ?? []), { fieldName, order }];
return this;
}
/**
* Order by a SQL fragment expression.
* Provides type-safe, parameterized ORDER BY clauses
*
* @example
* ```ts
* import { sql, raw } from '@expo/entity-database-adapter-knex';
*
* // Safe parameterized ordering
* .orderBySQL(sql`CASE WHEN priority = ${1} THEN 0 ELSE 1 END, created_at DESC`)
*
* // Dynamic column ordering
* const sortColumn = 'name';
* .orderBySQL(sql`${raw(sortColumn)} DESC NULLS LAST`)
*
* // Complex expressions
* .orderBySQL(sql`array_length(tags, 1) DESC, score * ${multiplier} ASC`)
* ```
*/
orderBySQL(fragment: SQLFragment): this {
this.modifiers.orderByFragment = fragment;
return this;
}
/**
* Get the current modifiers as QuerySelectionModifiersWithOrderByFragment<TFields>
*/
protected getModifiers(): EntityLoaderQuerySelectionModifiersWithOrderByFragment<
TFields,
TSelectedFields
> {
return this.modifiers;
}
/**
* Get the SQL fragment
*/
protected getSQLFragment(): SQLFragment {
return this.sqlFragment;
}
/**
* Execute the query and return results.
* Implementation depends on the specific loader type.
*/
public async executeAsync(): Promise<readonly TResultType[]> {
if (this.executed) {
throw new Error(
'Query has already been executed. Create a new query builder to execute again.',
);
}
this.executed = true;
return await this.executeInternalAsync();
}
protected abstract executeInternalAsync(): Promise<readonly TResultType[]>;
}
@@ -0,0 +1,266 @@
import {
EntityConstructionUtils,
EntityPrivacyPolicy,
EntityQueryContext,
IEntityMetricsAdapter,
ReadonlyEntity,
ViewerContext,
} from '@expo/entity';
import {
AuthorizationResultBasedKnexEntityLoader,
EntityLoaderLoadPageArgs,
EntityLoaderQuerySelectionModifiers,
EntityLoaderQuerySelectionModifiersWithOrderByFragment,
EntityLoaderQuerySelectionModifiersWithOrderByRaw,
} from './AuthorizationResultBasedKnexEntityLoader';
import { FieldEqualityCondition } from './BasePostgresEntityDatabaseAdapter';
import { BaseSQLQueryBuilder } from './BaseSQLQueryBuilder';
import { SQLFragment } from './SQLOperator';
import type { Connection, EntityKnexDataManager } from './internal/EntityKnexDataManager';
/**
* Enforcing knex entity loader for non-data-loader-based load methods.
* All loads through this loader will throw if the load is not successful.
*/
export class EnforcingKnexEntityLoader<
TFields extends Record<string, any>,
TIDField extends keyof NonNullable<Pick<TFields, TSelectedFields>>,
TViewerContext extends ViewerContext,
TEntity extends ReadonlyEntity<TFields, TIDField, TViewerContext, TSelectedFields>,
TPrivacyPolicy extends EntityPrivacyPolicy<
TFields,
TIDField,
TViewerContext,
TEntity,
TSelectedFields
>,
TSelectedFields extends keyof TFields,
> {
constructor(
private readonly knexEntityLoader: AuthorizationResultBasedKnexEntityLoader<
TFields,
TIDField,
TViewerContext,
TEntity,
TPrivacyPolicy,
TSelectedFields
>,
private readonly queryContext: EntityQueryContext,
private readonly knexDataManager: EntityKnexDataManager<TFields, TIDField>,
protected readonly metricsAdapter: IEntityMetricsAdapter,
private readonly constructionUtils: EntityConstructionUtils<
TFields,
TIDField,
TViewerContext,
TEntity,
TPrivacyPolicy,
TSelectedFields
>,
) {}
/**
* Load the first entity matching the conjunction of field equality operands and
* query modifiers.
*
* This is a convenience method for {@link loadManyByFieldEqualityConjunctionAsync}. However, the
* orderBy query modifier is required to ensure consistent results if more than one entity matches
* the filters.
*
* @throws EntityNotAuthorizedError if viewer is not authorized to view the entity
* @returns the first entity matching the filters, or null if none match
*/
async loadFirstByFieldEqualityConjunctionAsync<N extends keyof Pick<TFields, TSelectedFields>>(
fieldEqualityOperands: FieldEqualityCondition<TFields, N>[],
querySelectionModifiers: Omit<
EntityLoaderQuerySelectionModifiers<TFields, TSelectedFields>,
'limit'
> &
Required<Pick<EntityLoaderQuerySelectionModifiers<TFields, TSelectedFields>, 'orderBy'>>,
): Promise<TEntity | null> {
const entityResult = await this.knexEntityLoader.loadFirstByFieldEqualityConjunctionAsync(
fieldEqualityOperands,
querySelectionModifiers,
);
return entityResult?.enforceValue() ?? null;
}
/**
* Load entities matching the conjunction of field equality operands and
* query modifiers.
*
* Typically this is used for complex queries that cannot be expressed through simpler
* convenience methods such as {@link loadManyByFieldEqualingAsync}.
*
* @throws EntityNotAuthorizedError if viewer is not authorized to view the entity
* @returns entities matching the filters
*/
async loadManyByFieldEqualityConjunctionAsync<N extends keyof Pick<TFields, TSelectedFields>>(
fieldEqualityOperands: FieldEqualityCondition<TFields, N>[],
querySelectionModifiers: EntityLoaderQuerySelectionModifiers<TFields, TSelectedFields> = {},
): Promise<readonly TEntity[]> {
const entityResults = await this.knexEntityLoader.loadManyByFieldEqualityConjunctionAsync(
fieldEqualityOperands,
querySelectionModifiers,
);
return entityResults.map((result) => result.enforceValue());
}
/**
* Load entities with a raw SQL WHERE clause.
*
* @example
* Load entities with SQL function
* ```typescript
* const entitiesWithJsonKey = await ExampleEntity.loader(vc)
* .loadManyByRawWhereClauseAsync(
* "json_column->>'key_name' = ?",
* ['value'],
* );
* ```
*
* @example
* Load entities with tuple matching
* ```typescript
* const entities = await ExampleEntity.loader(vc)
* .loadManyByRawWhereClauseAsync(
* '(column_1, column_2) IN ((?, ?), (?, ?))',
* [value1, value2, value3, value4],
* );
* ```
* @param rawWhereClause - SQL WHERE clause. Interpolated values should be specified as ?-placeholders or :key_name
* @param bindings - values to bind to the placeholders in the WHERE clause
* @param querySelectionModifiers - limit, offset, and orderBy for the query. If orderBy is specified
* as orderByRaw, specify as string orderBy SQL clause with uncheckd literal values or ?-placeholders
* @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
*/
async loadManyByRawWhereClauseAsync(
rawWhereClause: string,
bindings: any[] | object,
querySelectionModifiers: EntityLoaderQuerySelectionModifiersWithOrderByRaw<
TFields,
TSelectedFields
> = {},
): Promise<readonly TEntity[]> {
const entityResults = await this.knexEntityLoader.loadManyByRawWhereClauseAsync(
rawWhereClause,
bindings,
querySelectionModifiers,
);
return entityResults.map((result) => result.enforceValue());
}
/**
* Load entities using a SQL query builder. When executed, all queries will enforce authorization and throw if not authorized.
*
* @example
* ```ts
* const entities = await ExampleEntity.loader(vc)
* .loadManyBySQL(sql`age >= ${18} AND status = ${'active'}`)
* .orderBy('createdAt', 'DESC')
* .limit(10)
* .executeAsync();
*
* const { between, inArray } = SQLFragmentHelpers;
* const filtered = await ExampleEntity.loader(vc)
* .loadManyBySQL(
* sql`${between('age', 18, 65)} AND ${inArray('role', ['admin', 'moderator'])}`
* )
* .executeAsync();
* ```
*/
loadManyBySQL(
fragment: SQLFragment,
modifiers: EntityLoaderQuerySelectionModifiersWithOrderByFragment<
TFields,
TSelectedFields
> = {},
): EnforcingSQLQueryBuilder<
TFields,
TIDField,
TViewerContext,
TEntity,
TPrivacyPolicy,
TSelectedFields
> {
return new EnforcingSQLQueryBuilder(this.knexEntityLoader, fragment, modifiers);
}
/**
* Load a page of entities with Relay-style cursor pagination.
*
* @param args - Pagination arguments with pagination and either first/after or last/before
* @returns a page of entities matching the pagination arguments
* @throws EntityNotAuthorizedError if viewer is not authorized to view any returned entity
*/
async loadPageAsync(
args: EntityLoaderLoadPageArgs<TFields, TSelectedFields>,
): Promise<Connection<TEntity>> {
const pageResult = await this.knexDataManager.loadPageAsync(this.queryContext, args);
const edges = await Promise.all(
pageResult.edges.map(async (edge) => {
const entityResult = await this.constructionUtils.constructAndAuthorizeEntityAsync(
edge.node,
);
const entity = entityResult.enforceValue();
return {
...edge,
node: entity,
};
}),
);
return {
edges,
pageInfo: pageResult.pageInfo,
};
}
}
/**
* SQL query builder for EnforcingKnexEntityLoader.
* Provides a fluent API for building and executing SQL queries with enforced authorization.
*/
export class EnforcingSQLQueryBuilder<
TFields extends Record<string, any>,
TIDField extends keyof NonNullable<Pick<TFields, TSelectedFields>>,
TViewerContext extends ViewerContext,
TEntity extends ReadonlyEntity<TFields, TIDField, TViewerContext, TSelectedFields>,
TPrivacyPolicy extends EntityPrivacyPolicy<
TFields,
TIDField,
TViewerContext,
TEntity,
TSelectedFields
>,
TSelectedFields extends keyof TFields,
> extends BaseSQLQueryBuilder<TFields, TSelectedFields, TEntity> {
constructor(
private readonly knexEntityLoader: AuthorizationResultBasedKnexEntityLoader<
TFields,
TIDField,
TViewerContext,
TEntity,
TPrivacyPolicy,
TSelectedFields
>,
sqlFragment: SQLFragment,
modifiers: EntityLoaderQuerySelectionModifiersWithOrderByFragment<TFields, TSelectedFields>,
) {
super(sqlFragment, modifiers);
}
/**
* Execute the query.
* @returns entities matching the query
* @throws EntityNotAuthorizedError if viewer is not authorized to view any entity
*/
async executeInternalAsync(): Promise<readonly TEntity[]> {
const entityResults = await this.knexEntityLoader
.loadManyBySQL(this.getSQLFragment(), this.getModifiers())
.executeAsync();
return entityResults.map((result) => result.enforceValue());
}
}
@@ -0,0 +1,130 @@
import {
EntityCompanion,
EntityConstructionUtils,
EntityPrivacyPolicy,
EntityPrivacyPolicyEvaluationContext,
EntityQueryContext,
ReadonlyEntity,
ViewerContext,
IEntityMetricsAdapter,
} from '@expo/entity';
import { AuthorizationResultBasedKnexEntityLoader } from './AuthorizationResultBasedKnexEntityLoader';
import { EnforcingKnexEntityLoader } from './EnforcingKnexEntityLoader';
import { EntityKnexDataManager } from './internal/EntityKnexDataManager';
/**
* The primary entry point for loading entities via knex queries (non-data-loader methods).
*/
export class KnexEntityLoaderFactory<
TFields extends Record<string, any>,
TIDField extends keyof NonNullable<Pick<TFields, TSelectedFields>>,
TViewerContext extends ViewerContext,
TEntity extends ReadonlyEntity<TFields, TIDField, TViewerContext, TSelectedFields>,
TPrivacyPolicy extends EntityPrivacyPolicy<
TFields,
TIDField,
TViewerContext,
TEntity,
TSelectedFields
>,
TSelectedFields extends keyof TFields,
> {
constructor(
private readonly entityCompanion: EntityCompanion<
TFields,
TIDField,
TViewerContext,
TEntity,
TPrivacyPolicy,
TSelectedFields
>,
private readonly knexDataManager: EntityKnexDataManager<TFields, TIDField>,
protected readonly metricsAdapter: IEntityMetricsAdapter,
) {}
/**
* Vend knex loader for loading an entity in a given query context.
* @param viewerContext - viewer context of loading user
* @param queryContext - query context in which to perform the load
*/
forLoad(
viewerContext: TViewerContext,
queryContext: EntityQueryContext,
privacyPolicyEvaluationContext: EntityPrivacyPolicyEvaluationContext<
TFields,
TIDField,
TViewerContext,
TEntity,
TSelectedFields
>,
): AuthorizationResultBasedKnexEntityLoader<
TFields,
TIDField,
TViewerContext,
TEntity,
TPrivacyPolicy,
TSelectedFields
> {
const constructionUtils = new EntityConstructionUtils(
viewerContext,
queryContext,
privacyPolicyEvaluationContext,
this.entityCompanion.entityCompanionDefinition.entityConfiguration,
this.entityCompanion.entityCompanionDefinition.entityClass,
this.entityCompanion.entityCompanionDefinition.entitySelectedFields,
this.entityCompanion.privacyPolicy,
this.metricsAdapter,
);
return new AuthorizationResultBasedKnexEntityLoader(
queryContext,
this.knexDataManager,
this.metricsAdapter,
constructionUtils,
);
}
/**
* Vend enforcing knex loader for loading an entity in a given query context.
* @param viewerContext - viewer context of loading user
* @param queryContext - query context in which to perform the load
*/
forLoadEnforcing(
viewerContext: TViewerContext,
queryContext: EntityQueryContext,
privacyPolicyEvaluationContext: EntityPrivacyPolicyEvaluationContext<
TFields,
TIDField,
TViewerContext,
TEntity,
TSelectedFields
>,
): EnforcingKnexEntityLoader<
TFields,
TIDField,
TViewerContext,
TEntity,
TPrivacyPolicy,
TSelectedFields
> {
const constructionUtils = new EntityConstructionUtils(
viewerContext,
queryContext,
privacyPolicyEvaluationContext,
this.entityCompanion.entityCompanionDefinition.entityConfiguration,
this.entityCompanion.entityCompanionDefinition.entityClass,
this.entityCompanion.entityCompanionDefinition.entitySelectedFields,
this.entityCompanion.privacyPolicy,
this.metricsAdapter,
);
return new EnforcingKnexEntityLoader(
this.forLoad(viewerContext, queryContext, privacyPolicyEvaluationContext),
queryContext,
this.knexDataManager,
this.metricsAdapter,
constructionUtils,
);
}
}
@@ -0,0 +1,32 @@
/**
* Search strategy for SQL-based pagination.
*/
export enum PaginationStrategy {
/**
* Standard pagination with ORDER BY. Results are ordered by the specified orderBy fields, with ID field automatically included for stable pagination if not already present.
*/
STANDARD = 'standard',
/**
* Case-insensitive pattern matching search using SQL ILIKE operator.
* Results are ordered by the fields being searched within in the order specified, then by ID for tie-breaking and stable pagination.
*/
ILIKE_SEARCH = 'ilike-search',
/**
* Similarity search using PostgreSQL trigram similarity. Results are ordered by exact match priority, then by similarity score, then by specified extra order by fields if provided, then by ID for tie-breaking and stable pagination.
*
* Performance considerations:
* - Trigram search can be significantly slower than ILIKE search, especially on large datasets without appropriate indexes.
* - Consider using ILIKE search for smaller datasets or when exact substring matching is sufficient
* - For larger datasets, ensure proper indexing or consider dedicated full-text search solutions.
* - For optimal performance, create GIN or GIST indexes on searchable columns:
* ```sql
* CREATE EXTENSION IF NOT EXISTS pg_trgm;
* CREATE INDEX idx_table_field_trigram ON table_name USING gin(field_name gin_trgm_ops);
* -- Or for multiple columns:
* CREATE INDEX idx_table_search ON table_name USING gin((field1 || ' ' || field2) gin_trgm_ops);
* ```
*/
TRIGRAM_SEARCH = 'trigram',
}
@@ -0,0 +1,116 @@
import {
Entity,
EntityPrivacyPolicy,
EntityQueryContext,
IEntityClass,
ReadonlyEntity,
ViewerContext,
} from '@expo/entity';
import { AuthorizationResultBasedKnexEntityLoader } from './AuthorizationResultBasedKnexEntityLoader';
import { EnforcingKnexEntityLoader } from './EnforcingKnexEntityLoader';
import {
knexLoader as knexLoaderFn,
knexLoaderWithAuthorizationResults as knexLoaderWithAuthorizationResultsFn,
} from './knexLoader';
/**
* Abstract base class for mutable entities backed by Postgres.
* Provides `knexLoader` and `knexLoaderWithAuthorizationResults` as inherited static methods,
* in addition to the mutation methods inherited from `Entity`.
*/
export abstract class PostgresEntity<
TFields extends Record<string, any>,
TIDField extends keyof NonNullable<Pick<TFields, TSelectedFields>>,
TViewerContext extends ViewerContext,
TSelectedFields extends keyof TFields = keyof TFields,
> extends Entity<TFields, TIDField, TViewerContext, TSelectedFields> {
/**
* Vend knex loader for loading entities via knex-specific methods in a given query context.
*
* @param viewerContext - viewer context of loading user
* @param queryContext - query context in which to perform the load
*/
static knexLoader<
TMFields extends object,
TMIDField extends keyof NonNullable<Pick<TMFields, TMSelectedFields>>,
TMViewerContext extends ViewerContext,
TMEntity extends ReadonlyEntity<TMFields, TMIDField, TMViewerContext, TMSelectedFields>,
TMPrivacyPolicy extends EntityPrivacyPolicy<
TMFields,
TMIDField,
TMViewerContext,
TMEntity,
TMSelectedFields
>,
TMSelectedFields extends keyof TMFields = keyof TMFields,
>(
this: IEntityClass<
TMFields,
TMIDField,
TMViewerContext,
TMEntity,
TMPrivacyPolicy,
TMSelectedFields
>,
viewerContext: TMViewerContext,
queryContext: EntityQueryContext = viewerContext
.getViewerScopedEntityCompanionForClass(this)
.getQueryContextProvider()
.getQueryContext(),
): EnforcingKnexEntityLoader<
TMFields,
TMIDField,
TMViewerContext,
TMEntity,
TMPrivacyPolicy,
TMSelectedFields
> {
return knexLoaderFn(this, viewerContext, queryContext);
}
/**
* Vend knex loader for loading entities via knex-specific methods in a given query context.
* Returns authorization results instead of throwing on authorization errors.
*
* @param viewerContext - viewer context of loading user
* @param queryContext - query context in which to perform the load
*/
static knexLoaderWithAuthorizationResults<
TMFields extends object,
TMIDField extends keyof NonNullable<Pick<TMFields, TMSelectedFields>>,
TMViewerContext extends ViewerContext,
TMEntity extends ReadonlyEntity<TMFields, TMIDField, TMViewerContext, TMSelectedFields>,
TMPrivacyPolicy extends EntityPrivacyPolicy<
TMFields,
TMIDField,
TMViewerContext,
TMEntity,
TMSelectedFields
>,
TMSelectedFields extends keyof TMFields = keyof TMFields,
>(
this: IEntityClass<
TMFields,
TMIDField,
TMViewerContext,
TMEntity,
TMPrivacyPolicy,
TMSelectedFields
>,
viewerContext: TMViewerContext,
queryContext: EntityQueryContext = viewerContext
.getViewerScopedEntityCompanionForClass(this)
.getQueryContextProvider()
.getQueryContext(),
): AuthorizationResultBasedKnexEntityLoader<
TMFields,
TMIDField,
TMViewerContext,
TMEntity,
TMPrivacyPolicy,
TMSelectedFields
> {
return knexLoaderWithAuthorizationResultsFn(this, viewerContext, queryContext);
}
}
@@ -1,21 +1,34 @@
import { EntityConfiguration, FieldTransformer, FieldTransformerMap } from '@expo/entity';
import { Knex } from 'knex';
import {
EntityDatabaseAdapter,
FieldTransformer,
FieldTransformerMap,
BasePostgresEntityDatabaseAdapter,
TableFieldMultiValueEqualityCondition,
TableFieldSingleValueEqualityCondition,
TableQuerySelectionModifiers,
TableQuerySelectionModifiersWithOrderByFragment,
TableQuerySelectionModifiersWithOrderByRaw,
} from '@expo/entity';
import { Knex } from 'knex';
} from './BasePostgresEntityDatabaseAdapter';
import { JSONArrayField, MaybeJSONArrayField } from './EntityFields';
import { PostgresEntityDatabaseAdapterConfiguration } from './PostgresEntityDatabaseAdapterProvider';
import { SQLFragment } from './SQLOperator';
import { wrapNativePostgresCallAsync } from './errors/wrapNativePostgresCallAsync';
export class PostgresEntityDatabaseAdapter<
TFields extends Record<string, any>,
TIDField extends keyof TFields,
> extends EntityDatabaseAdapter<TFields, TIDField> {
> extends BasePostgresEntityDatabaseAdapter<TFields, TIDField> {
constructor(
entityConfiguration: EntityConfiguration<TFields, TIDField>,
private readonly adapterConfiguration: PostgresEntityDatabaseAdapterConfiguration = {},
) {
super(entityConfiguration);
}
override get paginationMaxPageSize(): number | undefined {
return this.adapterConfiguration.paginationMaxPageSize;
}
protected getFieldTransformerMap(): FieldTransformerMap {
return new Map<string, FieldTransformer<any>>([
[
@@ -86,18 +99,42 @@ export class PostgresEntityDatabaseAdapter<
);
}
protected async fetchOneWhereInternalAsync(
queryInterface: Knex,
tableName: string,
tableColumns: readonly string[],
tableTuple: readonly any[],
): Promise<object | null> {
const results = await this.fetchManyByFieldEqualityConjunctionInternalAsync(
queryInterface,
tableName,
tableColumns.map((column, index) => ({
tableField: column,
tableValue: tableTuple[index],
})),
[],
{ limit: 1, orderBy: undefined, offset: undefined },
);
return results[0] ?? null;
}
private applyQueryModifiersToQueryOrderByRaw(
query: Knex.QueryBuilder,
querySelectionModifiers: TableQuerySelectionModifiersWithOrderByRaw,
): Knex.QueryBuilder {
let ret = this.applyQueryModifiersToQuery(query, querySelectionModifiers);
const { orderByRaw } = querySelectionModifiers;
if (orderByRaw !== undefined) {
ret = ret.orderByRaw(orderByRaw);
}
return ret;
// 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(
@@ -186,6 +223,33 @@ export class PostgresEntityDatabaseAdapter<
return await wrapNativePostgresCallAsync(() => query);
}
protected async fetchManyBySQLFragmentInternalAsync(
queryInterface: Knex,
tableName: string,
sqlFragment: SQLFragment,
querySelectionModifiers: TableQuerySelectionModifiersWithOrderByFragment,
): Promise<object[]> {
let query = queryInterface
.select()
.from(tableName)
.whereRaw(sqlFragment.sql, sqlFragment.getKnexBindings());
// Apply order by modifiers
// orderByFragment takes precedence over orderBy - they are mutually exclusive
const { orderByFragment } = querySelectionModifiers;
if (orderByFragment !== undefined) {
// Apply only orderByFragment (offset/limit still applied, but not orderBy)
query = this.applyQueryModifiersToQuery(query, {
...querySelectionModifiers,
orderBy: undefined, // Explicitly exclude orderBy when orderByFragment is present
}).orderByRaw(orderByFragment.sql, orderByFragment.getKnexBindings());
} else {
// Apply regular orderBy (and offset/limit)
query = this.applyQueryModifiersToQuery(query, querySelectionModifiers);
}
return await wrapNativePostgresCallAsync(() => query);
}
protected async insertInternalAsync(
queryInterface: Knex,
tableName: string,
@@ -6,10 +6,20 @@ import {
import { PostgresEntityDatabaseAdapter } from './PostgresEntityDatabaseAdapter';
export interface PostgresEntityDatabaseAdapterConfiguration {
/**
* Maximum page size for pagination (first/last parameters).
* If not specified, no limit is enforced.
*/
paginationMaxPageSize?: number;
}
export class PostgresEntityDatabaseAdapterProvider implements IEntityDatabaseAdapterProvider {
constructor(private readonly configuration: PostgresEntityDatabaseAdapterConfiguration = {}) {}
getDatabaseAdapter<TFields extends Record<string, any>, TIDField extends keyof TFields>(
entityConfiguration: EntityConfiguration<TFields, TIDField>,
): EntityDatabaseAdapter<TFields, TIDField> {
return new PostgresEntityDatabaseAdapter(entityConfiguration);
return new PostgresEntityDatabaseAdapter(entityConfiguration, this.configuration);
}
}
@@ -0,0 +1,115 @@
import {
EntityPrivacyPolicy,
EntityQueryContext,
IEntityClass,
ReadonlyEntity,
ViewerContext,
} from '@expo/entity';
import { AuthorizationResultBasedKnexEntityLoader } from './AuthorizationResultBasedKnexEntityLoader';
import { EnforcingKnexEntityLoader } from './EnforcingKnexEntityLoader';
import {
knexLoader as knexLoaderFn,
knexLoaderWithAuthorizationResults as knexLoaderWithAuthorizationResultsFn,
} from './knexLoader';
/**
* Abstract base class for readonly entities backed by Postgres.
* Provides `knexLoader` and `knexLoaderWithAuthorizationResults` as inherited static methods.
*
* Entities that should not be mutated (e.g., representing SQL views or immutable tables)
* can extend this class instead of `ReadonlyEntity` to get knex loader ergonomics.
*/
export abstract class ReadonlyPostgresEntity<
TFields extends Record<string, any>,
TIDField extends keyof NonNullable<Pick<TFields, TSelectedFields>>,
TViewerContext extends ViewerContext,
TSelectedFields extends keyof TFields = keyof TFields,
> extends ReadonlyEntity<TFields, TIDField, TViewerContext, TSelectedFields> {
/**
* Vend knex loader for loading entities via non-data-loader methods in a given query context.
* @param viewerContext - viewer context of loading user
* @param queryContext - query context in which to perform the load
*/
static knexLoader<
TMFields extends object,
TMIDField extends keyof NonNullable<Pick<TMFields, TMSelectedFields>>,
TMViewerContext extends ViewerContext,
TMEntity extends ReadonlyEntity<TMFields, TMIDField, TMViewerContext, TMSelectedFields>,
TMPrivacyPolicy extends EntityPrivacyPolicy<
TMFields,
TMIDField,
TMViewerContext,
TMEntity,
TMSelectedFields
>,
TMSelectedFields extends keyof TMFields = keyof TMFields,
>(
this: IEntityClass<
TMFields,
TMIDField,
TMViewerContext,
TMEntity,
TMPrivacyPolicy,
TMSelectedFields
>,
viewerContext: TMViewerContext,
queryContext: EntityQueryContext = viewerContext
.getViewerScopedEntityCompanionForClass(this)
.getQueryContextProvider()
.getQueryContext(),
): EnforcingKnexEntityLoader<
TMFields,
TMIDField,
TMViewerContext,
TMEntity,
TMPrivacyPolicy,
TMSelectedFields
> {
return knexLoaderFn(this, viewerContext, queryContext);
}
/**
* Vend knex loader for loading entities via non-data-loader methods in a given query context.
* Returns authorization results instead of throwing on authorization errors.
* @param viewerContext - viewer context of loading user
* @param queryContext - query context in which to perform the load
*/
static knexLoaderWithAuthorizationResults<
TMFields extends object,
TMIDField extends keyof NonNullable<Pick<TMFields, TMSelectedFields>>,
TMViewerContext extends ViewerContext,
TMEntity extends ReadonlyEntity<TMFields, TMIDField, TMViewerContext, TMSelectedFields>,
TMPrivacyPolicy extends EntityPrivacyPolicy<
TMFields,
TMIDField,
TMViewerContext,
TMEntity,
TMSelectedFields
>,
TMSelectedFields extends keyof TMFields = keyof TMFields,
>(
this: IEntityClass<
TMFields,
TMIDField,
TMViewerContext,
TMEntity,
TMPrivacyPolicy,
TMSelectedFields
>,
viewerContext: TMViewerContext,
queryContext: EntityQueryContext = viewerContext
.getViewerScopedEntityCompanionForClass(this)
.getQueryContextProvider()
.getQueryContext(),
): AuthorizationResultBasedKnexEntityLoader<
TMFields,
TMIDField,
TMViewerContext,
TMEntity,
TMPrivacyPolicy,
TMSelectedFields
> {
return knexLoaderWithAuthorizationResultsFn(this, viewerContext, queryContext);
}
}
@@ -0,0 +1,478 @@
/**
* Supported SQL value types that can be safely parameterized.
* This ensures type safety and prevents passing unsupported types to SQL queries.
*/
export type SupportedSQLValue =
| string
| number
| boolean
| null
| Date
| Buffer
| bigint
| undefined // Will be treated as NULL
| readonly SupportedSQLValue[] // For IN clauses and array types
| Readonly<{ [key: string]: unknown }>; // For JSON/JSONB columns
/**
* Types of bindings that can be used in SQL queries.
*/
export type SQLBinding =
| { type: 'value'; value: SupportedSQLValue }
| { type: 'identifier'; name: string };
/**
* SQL Fragment class that safely handles parameterized queries.
*/
export class SQLFragment {
constructor(
public readonly sql: string,
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.
*/
getKnexBindings(): readonly (string | SupportedSQLValue)[] {
return this.bindings.map((b) => {
if (b.type === 'identifier') {
return b.name;
} else {
return b.value;
}
});
}
/**
* Combine SQL fragments
*/
append(other: SQLFragment): SQLFragment {
return SQLFragment.join([this, other], ' ');
}
/**
* Join multiple SQL fragments into a single fragment with a separator.
* @param fragments - Array of SQL fragments to join
* @param separator - Separator string (default: ', ')
*/
static join(fragments: SQLFragment[], separator = ', '): SQLFragment {
return new SQLFragment(
fragments.map((f) => f.sql).join(separator),
fragments.flatMap((f) => f.bindings),
);
}
/**
* Concatenate multiple SQL fragments with space separator.
* Useful for combining SQL clauses like WHERE, ORDER BY, etc.
*
* @example
* ```ts
* const where = sql`WHERE age > ${18}`;
* const orderBy = sql`ORDER BY name`;
* const query = SQLFragment.concat(sql`SELECT * FROM users`, where, orderBy);
* // Generates: "SELECT * FROM users WHERE age > ? ORDER BY name"
* ```
*/
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.
*/
get toDebugString(): string {
let debugString = this.sql;
let bindingIndex = 0;
// Replace ?? and ? placeholders with actual values for debugging
debugString = debugString.replace(/\?\?|\?/g, (match) => {
if (bindingIndex >= this.bindings.length) {
return match;
}
const binding = this.bindings[bindingIndex];
if (!binding) {
return match;
}
bindingIndex++;
if (match === '??' && binding.type === 'identifier') {
// For identifiers, show them quoted as they would appear
return `"${binding.name.replace(/"/g, '""')}"`;
} else if (match === '?' && binding.type === 'value') {
return SQLFragment.formatDebugValue(binding.value);
} else {
// Mismatch between placeholder type and binding type
return match;
}
});
return debugString;
}
/**
* Format a value for debug output based on its type.
* Handles all SupportedSQLValue types.
*/
private static formatDebugValue(value: SupportedSQLValue): string {
// Handle null and undefined
if (value === null || value === undefined) {
return 'NULL';
}
// Handle primitives
if (typeof value === 'string') {
return `'${value.replace(/'/g, "''")}'`;
}
if (typeof value === 'number' || typeof value === 'bigint') {
return String(value);
}
if (typeof value === 'boolean') {
return value ? 'TRUE' : 'FALSE';
}
// Handle Date
if (value instanceof Date) {
return `'${value.toISOString()}'`;
}
// Handle Buffer
if (Buffer.isBuffer(value)) {
return `'\\x${value.toString('hex')}'`;
}
// Handle arrays (for IN clauses or array columns)
if (Array.isArray(value)) {
return `ARRAY[${value.map((v) => this.formatDebugValue(v)).join(', ')}]`;
}
// Handle objects (for JSON/JSONB columns)
if (typeof value === 'object' && SQLFragment.isPlainObjectForDebug(value)) {
return `'${JSON.stringify(value).replace(/'/g, "''")}'::jsonb`;
}
// Fallback (should never reach here with SupportedSQLValue but because this is used
// for debugging, there might be other values that we want to know about)
return `UnsupportedSQLValue[${String(value)}]`;
}
private static isPlainObjectForDebug(obj: object): boolean {
const proto = Object.getPrototypeOf(obj);
// Ensure it doesn't have a custom prototype (like a class would)
if (proto === null) {
return true; // Created via Object.create(null)
}
// Check if constructor is the base Object function
return proto.constructor === Object;
}
}
/**
* Helper for SQL identifiers (table/column names).
* Stores the raw identifier name to be escaped by Knex using ?? placeholder.
*/
export class SQLIdentifier {
constructor(public readonly name: string) {}
}
/**
* Helper for raw SQL that should not be parameterized
* WARNING: Only use this with trusted input to avoid SQL injection
*/
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
* identifier('users') // Will be escaped as "users" in PostgreSQL
* identifier('my"table') // Will be escaped as "my""table" in PostgreSQL
* identifier('column"; DROP TABLE users; --') // Will be safely escaped
* ```
*/
export function identifier(name: string): SQLIdentifier {
return new SQLIdentifier(name);
}
/**
* Insert raw SQL that will not be parameterized
* WARNING: This bypasses SQL injection protection. Only use with trusted input.
*
* @example
* ```ts
* // Dynamic column names
* const sortColumn = 'created_at';
* const query = sql`ORDER BY ${raw(sortColumn)} DESC`;
*
* // Dynamic SQL expressions
* const query = sql`WHERE ${raw('EXTRACT(year FROM created_at)')} = ${2024}`;
* ```
*/
export function raw(sqlString: string): SQLRaw {
return new SQLRaw(sqlString);
}
/**
* Tagged template literal function for SQL queries
*
* @example
* ```ts
* const age = 18;
* const query = sql`age >= ${age} AND status = ${'active'}`;
* ```
*/
export function sql(
strings: TemplateStringsArray,
...values: readonly (SupportedSQLValue | SQLFragment | SQLIdentifier | SQLRaw)[]
): SQLFragment {
let sqlString = '';
const bindings: SQLBinding[] = [];
strings.forEach((string, i) => {
sqlString += string;
if (i < values.length) {
const value = values[i];
if (value instanceof SQLFragment) {
// Handle nested SQL fragments
sqlString += value.sql;
bindings.push(...value.bindings);
} else if (value instanceof SQLIdentifier) {
// Handle identifiers (table/column names) with ?? placeholder
sqlString += '??';
bindings.push({ type: 'identifier', name: value.name });
} 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) => ({ type: 'value' as const, value: v })));
} else {
// Regular value binding
sqlString += '?';
bindings.push({ type: 'value', value });
}
}
});
return new SQLFragment(sqlString, bindings);
}
/**
* Common SQL helper functions for building queries
*/
export const SQLFragmentHelpers = {
/**
* IN clause helper
*
* @example
* ```ts
* const query = SQLFragmentHelpers.inArray('status', ['active', 'pending']);
* // Generates: ?? IN (?, ?) with bindings ['status', 'active', 'pending']
* ```
*/
inArray<T extends SupportedSQLValue>(column: string, values: readonly T[]): SQLFragment {
if (values.length === 0) {
// Handle empty array case - always false
return sql`1 = 0`;
}
// The array is already correctly typed, just needs to be seen as SupportedSQLValue for the template
return sql`${identifier(column)} IN ${values as readonly SupportedSQLValue[]}`;
},
/**
* NOT IN clause helper
*/
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`${identifier(column)} NOT IN ${values as readonly SupportedSQLValue[]}`;
},
/**
* BETWEEN helper
*
* @example
* ```ts
* const query = SQLFragmentHelpers.between('age', 18, 65);
* // Generates: "age" BETWEEN ? AND ? with values [18, 65]
* ```
*/
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<T extends string | number | Date>(column: string, min: T, max: T): SQLFragment {
return sql`${identifier(column)} NOT BETWEEN ${min} AND ${max}`;
},
/**
* LIKE helper with automatic escaping
*
* @example
* ```ts
* const query = SQLFragmentHelpers.like('name', '%John%');
* // Generates: "name" LIKE ? with value '%John%'
* ```
*/
like(column: string, pattern: string): SQLFragment {
return sql`${identifier(column)} LIKE ${pattern}`;
},
/**
* NOT LIKE helper
*/
notLike(column: string, pattern: string): SQLFragment {
return sql`${identifier(column)} NOT LIKE ${pattern}`;
},
/**
* ILIKE helper for case-insensitive matching
*/
ilike(column: string, pattern: string): SQLFragment {
return sql`${identifier(column)} ILIKE ${pattern}`;
},
/**
* NOT ILIKE helper for case-insensitive non-matching
*/
notIlike(column: string, pattern: string): SQLFragment {
return sql`${identifier(column)} NOT ILIKE ${pattern}`;
},
/**
* NULL check helper
*/
isNull(column: string): SQLFragment {
return sql`${identifier(column)} IS NULL`;
},
/**
* NOT NULL check helper
*/
isNotNull(column: string): SQLFragment {
return sql`${identifier(column)} IS NOT NULL`;
},
/**
* Single-equals-equality operator
*/
eq(column: string, value: SupportedSQLValue): SQLFragment {
if (value === null || value === undefined) {
return SQLFragmentHelpers.isNull(column);
}
return sql`${identifier(column)} = ${value}`;
},
/**
* Single-equals-inequality operator
*/
neq(column: string, value: SupportedSQLValue): SQLFragment {
if (value === null || value === undefined) {
return SQLFragmentHelpers.isNotNull(column);
}
return sql`${identifier(column)} != ${value}`;
},
/**
* Greater-than comparison operator
*/
gt(column: string, value: SupportedSQLValue): SQLFragment {
return sql`${identifier(column)} > ${value}`;
},
/**
* Greater-than-or-equal-to comparison operator
*/
gte(column: string, value: SupportedSQLValue): SQLFragment {
return sql`${identifier(column)} >= ${value}`;
},
/**
* Less-than comparison operator
*/
lt(column: string, value: SupportedSQLValue): SQLFragment {
return sql`${identifier(column)} < ${value}`;
},
/**
* Less-than-or-equal-to comparison operator
*/
lte(column: string, value: SupportedSQLValue): SQLFragment {
return sql`${identifier(column)} <= ${value}`;
},
/**
* JSON contains operator (\@\>)
*/
jsonContains(column: string, value: unknown): SQLFragment {
return sql`${identifier(column)} @> ${JSON.stringify(value)}::jsonb`;
},
/**
* JSON contained by operator (\<\@\)
*/
jsonContainedBy(column: string, value: unknown): SQLFragment {
return sql`${identifier(column)} <@ ${JSON.stringify(value)}::jsonb`;
},
/**
* JSON path extraction helper (-\>)
*/
jsonPath(column: string, path: string): SQLFragment {
return new SQLFragment(`"${column}"->'${path}'`, []);
},
/**
* JSON path text extraction helper (-\>\>)
*/
jsonPathText(column: string, path: string): SQLFragment {
return new SQLFragment(`"${column}"->>'${path}'`, []);
},
/**
* Logical AND of multiple fragments
*/
and(...conditions: SQLFragment[]): SQLFragment {
if (conditions.length === 0) {
return sql`1 = 1`;
}
return SQLFragment.join(conditions, ' AND ');
},
/**
* Logical OR of multiple fragments
*/
or(...conditions: SQLFragment[]): SQLFragment {
if (conditions.length === 0) {
return sql`1 = 0`;
}
return SQLFragment.join(conditions, ' OR ');
},
/**
* Logical NOT of a fragment
*/
not(condition: SQLFragment): SQLFragment {
return new SQLFragment('NOT (' + condition.sql + ')', condition.bindings);
},
/**
* Parentheses helper for grouping conditions
*/
group(condition: SQLFragment): SQLFragment {
return new SQLFragment('(' + condition.sql + ')', condition.bindings);
},
};
@@ -2,7 +2,6 @@ import {
AlwaysAllowPrivacyPolicyRule,
BooleanField,
DateField,
Entity,
EntityCompanionDefinition,
EntityConfiguration,
EntityPrivacyPolicy,
@@ -16,6 +15,7 @@ import {
import { Knex } from 'knex';
import { BigIntField, JSONArrayField, MaybeJSONArrayField } from '../EntityFields';
import { PostgresEntity } from '../PostgresEntity';
type PostgresTestEntityFields = {
id: string;
@@ -31,9 +31,14 @@ type PostgresTestEntityFields = {
maybeJsonArrayField: string[] | { hello: string } | null;
bigintField: string | null;
binaryField: Buffer | null;
createdAt: Date;
};
export class PostgresTestEntity extends Entity<PostgresTestEntityFields, 'id', ViewerContext> {
export class PostgresTestEntity extends PostgresEntity<
PostgresTestEntityFields,
'id',
ViewerContext
> {
static defineCompanionDefinition(): EntityCompanionDefinition<
PostgresTestEntityFields,
'id',
@@ -64,6 +69,7 @@ export class PostgresTestEntity extends Entity<PostgresTestEntityFields, 'id', V
table.jsonb('maybe_json_array_field');
table.bigint('bigint_field');
table.binary('binary_field');
table.dateTime('created_at', { useTz: true }).defaultTo(knex.fn.now());
});
}
await knex.into(tableName).truncate();
@@ -159,6 +165,9 @@ export const postgresTestEntityConfiguration = new EntityConfiguration<
binaryField: new BufferField({
columnName: 'binary_field',
}),
createdAt: new DateField({
columnName: 'created_at',
}),
},
databaseAdapterFlavor: 'postgres',
cacheAdapterFlavor: 'redis',
@@ -0,0 +1,160 @@
import { EntityQueryContext, FieldTransformerMap } from '@expo/entity';
import { describe, expect, it } from '@jest/globals';
import { instance, mock } from 'ts-mockito';
import {
BasePostgresEntityDatabaseAdapter,
TableFieldMultiValueEqualityCondition,
TableFieldSingleValueEqualityCondition,
} from '../BasePostgresEntityDatabaseAdapter';
import { testEntityConfiguration, TestFields } from './fixtures/TestEntity';
class TestEntityDatabaseAdapter extends BasePostgresEntityDatabaseAdapter<
TestFields,
'customIdField'
> {
private readonly fetchResults: object[];
private readonly fetchOneResult: object | null;
private readonly insertResults: object[];
private readonly updateResults: object[];
private readonly fetchEqualityConditionResults: object[];
private readonly fetchRawWhereResults: object[];
private readonly fetchSQLFragmentResults: object[];
private readonly deleteCount: number;
constructor({
fetchResults = [],
fetchOneResult = null,
insertResults = [],
updateResults = [],
fetchEqualityConditionResults = [],
fetchRawWhereResults = [],
fetchSQLFragmentResults = [],
deleteCount = 0,
}: {
fetchResults?: object[];
fetchOneResult?: object | null;
insertResults?: object[];
updateResults?: object[];
fetchEqualityConditionResults?: object[];
fetchRawWhereResults?: object[];
fetchSQLFragmentResults?: object[];
deleteCount?: number;
}) {
super(testEntityConfiguration);
this.fetchResults = fetchResults;
this.fetchOneResult = fetchOneResult;
this.insertResults = insertResults;
this.updateResults = updateResults;
this.fetchEqualityConditionResults = fetchEqualityConditionResults;
this.fetchRawWhereResults = fetchRawWhereResults;
this.fetchSQLFragmentResults = fetchSQLFragmentResults;
this.deleteCount = deleteCount;
}
protected getFieldTransformerMap(): FieldTransformerMap {
return new Map();
}
protected async fetchManyWhereInternalAsync(
_queryInterface: any,
_tableName: string,
_tableColumns: readonly string[],
_tableTuples: (readonly any[])[],
): Promise<object[]> {
return this.fetchResults;
}
protected async fetchOneWhereInternalAsync(
_queryInterface: any,
_tableName: string,
_tableColumns: readonly string[],
_tableTuple: readonly any[],
): Promise<object | null> {
return this.fetchOneResult;
}
protected async fetchManyByRawWhereClauseInternalAsync(
_queryInterface: any,
_tableName: string,
_rawWhereClause: string,
_bindings: object | any[],
): Promise<object[]> {
return this.fetchRawWhereResults;
}
protected async fetchManyBySQLFragmentInternalAsync(
_queryInterface: any,
_tableName: string,
_sqlFragment: any,
): Promise<object[]> {
return this.fetchSQLFragmentResults;
}
protected async fetchManyByFieldEqualityConjunctionInternalAsync(
_queryInterface: any,
_tableName: string,
_tableFieldSingleValueEqualityOperands: TableFieldSingleValueEqualityCondition[],
_tableFieldMultiValueEqualityOperands: TableFieldMultiValueEqualityCondition[],
): Promise<object[]> {
return this.fetchEqualityConditionResults;
}
protected async insertInternalAsync(
_queryInterface: any,
_tableName: string,
_object: object,
): Promise<object[]> {
return this.insertResults;
}
protected async updateInternalAsync(
_queryInterface: any,
_tableName: string,
_tableIdField: string,
_id: any,
_object: object,
): Promise<object[]> {
return this.updateResults;
}
protected async deleteInternalAsync(
_queryInterface: any,
_tableName: string,
_tableIdField: string,
_id: any,
): Promise<number> {
return this.deleteCount;
}
}
describe(BasePostgresEntityDatabaseAdapter, () => {
describe('get paginationMaxPageSize', () => {
it('returns the default paginationMaxPageSize (undefined)', () => {
const adapter = new TestEntityDatabaseAdapter({});
expect(adapter.paginationMaxPageSize).toBe(undefined);
});
});
describe('fetchManyByFieldEqualityConjunction', () => {
it('transforms object', async () => {
const queryContext = instance(mock(EntityQueryContext));
const adapter = new TestEntityDatabaseAdapter({
fetchEqualityConditionResults: [{ string_field: 'hello' }],
});
const results = await adapter.fetchManyByFieldEqualityConjunctionAsync(queryContext, [], {});
expect(results).toEqual([{ stringField: 'hello' }]);
});
});
describe('fetchManyWithRawWhereClause', () => {
it('transforms object', async () => {
const queryContext = instance(mock(EntityQueryContext));
const adapter = new TestEntityDatabaseAdapter({
fetchRawWhereResults: [{ string_field: 'hello' }],
});
const results = await adapter.fetchManyByRawWhereClauseAsync(queryContext, 'hello', [], {});
expect(results).toEqual([{ stringField: 'hello' }]);
});
});
});
@@ -0,0 +1,384 @@
import { EntityConstructionUtils, EntityQueryContext, IEntityMetricsAdapter } from '@expo/entity';
import { result } from '@expo/results';
import { describe, expect, it } from '@jest/globals';
import { anything, instance, mock, when } from 'ts-mockito';
import {
AuthorizationResultBasedKnexEntityLoader,
AuthorizationResultBasedSQLQueryBuilder,
} from '../AuthorizationResultBasedKnexEntityLoader';
import { EnforcingKnexEntityLoader } from '../EnforcingKnexEntityLoader';
import { PaginationStrategy } from '../PaginationStrategy';
import { sql } from '../SQLOperator';
import { EntityKnexDataManager } from '../internal/EntityKnexDataManager';
describe(EnforcingKnexEntityLoader, () => {
describe('loadFirstByFieldEqualityConjunction', () => {
it('throws when result is unsuccessful', async () => {
const nonEnforcingKnexEntityLoaderMock = mock(
AuthorizationResultBasedKnexEntityLoader<any, any, any, any, any, any>,
);
const rejection = new Error();
when(
nonEnforcingKnexEntityLoaderMock.loadFirstByFieldEqualityConjunctionAsync(
anything(),
anything(),
),
).thenResolve(result(rejection));
const nonEnforcingKnexEntityLoader = instance(nonEnforcingKnexEntityLoaderMock);
const enforcingKnexEntityLoader = new EnforcingKnexEntityLoader(
nonEnforcingKnexEntityLoader,
instance(mock(EntityQueryContext)),
instance(mock(EntityKnexDataManager)),
instance(mock<IEntityMetricsAdapter>()),
instance(mock(EntityConstructionUtils)),
);
await expect(
enforcingKnexEntityLoader.loadFirstByFieldEqualityConjunctionAsync(anything(), anything()),
).rejects.toThrow(rejection);
});
it('returns value when result is successful', async () => {
const nonEnforcingKnexEntityLoaderMock = mock(
AuthorizationResultBasedKnexEntityLoader<any, any, any, any, any, any>,
);
const resolved = {};
when(
nonEnforcingKnexEntityLoaderMock.loadFirstByFieldEqualityConjunctionAsync(
anything(),
anything(),
),
).thenResolve(result(resolved));
const nonEnforcingKnexEntityLoader = instance(nonEnforcingKnexEntityLoaderMock);
const enforcingKnexEntityLoader = new EnforcingKnexEntityLoader(
nonEnforcingKnexEntityLoader,
instance(mock(EntityQueryContext)),
instance(mock(EntityKnexDataManager)),
instance(mock<IEntityMetricsAdapter>()),
instance(mock(EntityConstructionUtils)),
);
await expect(
enforcingKnexEntityLoader.loadFirstByFieldEqualityConjunctionAsync(anything(), anything()),
).resolves.toEqual(resolved);
});
it('returns null when the query is successful but no rows match', async () => {
const nonEnforcingKnexEntityLoaderMock = mock(
AuthorizationResultBasedKnexEntityLoader<any, any, any, any, any, any>,
);
when(
nonEnforcingKnexEntityLoaderMock.loadFirstByFieldEqualityConjunctionAsync(
anything(),
anything(),
),
).thenResolve(null);
const nonEnforcingKnexEntityLoader = instance(nonEnforcingKnexEntityLoaderMock);
const enforcingKnexEntityLoader = new EnforcingKnexEntityLoader(
nonEnforcingKnexEntityLoader,
instance(mock(EntityQueryContext)),
instance(mock(EntityKnexDataManager)),
instance(mock<IEntityMetricsAdapter>()),
instance(mock(EntityConstructionUtils)),
);
await expect(
enforcingKnexEntityLoader.loadFirstByFieldEqualityConjunctionAsync(anything(), anything()),
).resolves.toBeNull();
});
});
describe('loadManyByFieldEqualityConjunction', () => {
it('throws when result is unsuccessful', async () => {
const nonEnforcingKnexEntityLoaderMock = mock(
AuthorizationResultBasedKnexEntityLoader<any, any, any, any, any, any>,
);
const rejection = new Error();
when(
nonEnforcingKnexEntityLoaderMock.loadManyByFieldEqualityConjunctionAsync(
anything(),
anything(),
),
).thenResolve([result(rejection)]);
const nonEnforcingKnexEntityLoader = instance(nonEnforcingKnexEntityLoaderMock);
const enforcingKnexEntityLoader = new EnforcingKnexEntityLoader(
nonEnforcingKnexEntityLoader,
instance(mock(EntityQueryContext)),
instance(mock(EntityKnexDataManager)),
instance(mock<IEntityMetricsAdapter>()),
instance(mock(EntityConstructionUtils)),
);
await expect(
enforcingKnexEntityLoader.loadManyByFieldEqualityConjunctionAsync(anything(), anything()),
).rejects.toThrow(rejection);
});
it('returns value when result is successful', async () => {
const nonEnforcingKnexEntityLoaderMock = mock(
AuthorizationResultBasedKnexEntityLoader<any, any, any, any, any, any>,
);
const resolved = {};
when(
nonEnforcingKnexEntityLoaderMock.loadManyByFieldEqualityConjunctionAsync(
anything(),
anything(),
),
).thenResolve([result(resolved)]);
const nonEnforcingKnexEntityLoader = instance(nonEnforcingKnexEntityLoaderMock);
const enforcingKnexEntityLoader = new EnforcingKnexEntityLoader(
nonEnforcingKnexEntityLoader,
instance(mock(EntityQueryContext)),
instance(mock(EntityKnexDataManager)),
instance(mock<IEntityMetricsAdapter>()),
instance(mock(EntityConstructionUtils)),
);
await expect(
enforcingKnexEntityLoader.loadManyByFieldEqualityConjunctionAsync(anything(), anything()),
).resolves.toEqual([resolved]);
});
});
describe('loadManyByRawWhereClause', () => {
it('throws when result is unsuccessful', async () => {
const nonEnforcingKnexEntityLoaderMock = mock(
AuthorizationResultBasedKnexEntityLoader<any, any, any, any, any, any>,
);
const rejection = new Error();
when(
nonEnforcingKnexEntityLoaderMock.loadManyByRawWhereClauseAsync(
anything(),
anything(),
anything(),
),
).thenResolve([result(rejection)]);
const nonEnforcingKnexEntityLoader = instance(nonEnforcingKnexEntityLoaderMock);
const enforcingKnexEntityLoader = new EnforcingKnexEntityLoader(
nonEnforcingKnexEntityLoader,
instance(mock(EntityQueryContext)),
instance(mock(EntityKnexDataManager)),
instance(mock<IEntityMetricsAdapter>()),
instance(mock(EntityConstructionUtils)),
);
await expect(
enforcingKnexEntityLoader.loadManyByRawWhereClauseAsync(anything(), anything(), anything()),
).rejects.toThrow(rejection);
});
it('returns value when result is successful', async () => {
const nonEnforcingKnexEntityLoaderMock = mock(
AuthorizationResultBasedKnexEntityLoader<any, any, any, any, any, any>,
);
const resolved = {};
when(
nonEnforcingKnexEntityLoaderMock.loadManyByRawWhereClauseAsync(
anything(),
anything(),
anything(),
),
).thenResolve([result(resolved)]);
const nonEnforcingKnexEntityLoader = instance(nonEnforcingKnexEntityLoaderMock);
const enforcingKnexEntityLoader = new EnforcingKnexEntityLoader(
nonEnforcingKnexEntityLoader,
instance(mock(EntityQueryContext)),
instance(mock(EntityKnexDataManager)),
instance(mock<IEntityMetricsAdapter>()),
instance(mock(EntityConstructionUtils)),
);
await expect(
enforcingKnexEntityLoader.loadManyByRawWhereClauseAsync(anything(), anything(), anything()),
).resolves.toEqual([resolved]);
});
});
describe('loadManyBySQL', () => {
it('throws when result is unsuccessful', async () => {
const nonEnforcingKnexEntityLoaderMock = mock(
AuthorizationResultBasedKnexEntityLoader<any, any, any, any, any, any>,
);
const rejection = new Error('Authorization failed');
const queryBuilderMock = mock(
AuthorizationResultBasedSQLQueryBuilder<any, any, any, any, any, any>,
);
when(queryBuilderMock.executeAsync()).thenResolve([result(rejection)]);
const queryBuilder = instance(queryBuilderMock);
when(nonEnforcingKnexEntityLoaderMock.loadManyBySQL(anything(), anything())).thenReturn(
queryBuilder,
);
const nonEnforcingKnexEntityLoader = instance(nonEnforcingKnexEntityLoaderMock);
const enforcingKnexEntityLoader = new EnforcingKnexEntityLoader(
nonEnforcingKnexEntityLoader,
instance(mock(EntityQueryContext)),
instance(mock(EntityKnexDataManager)),
instance(mock<IEntityMetricsAdapter>()),
instance(mock(EntityConstructionUtils)),
);
const enforcingQueryBuilder = enforcingKnexEntityLoader.loadManyBySQL(sql`1=1`);
await expect(enforcingQueryBuilder.executeAsync()).rejects.toThrow(rejection);
});
it('returns value when result is successful', async () => {
const nonEnforcingKnexEntityLoaderMock = mock(
AuthorizationResultBasedKnexEntityLoader<any, any, any, any, any, any>,
);
const entity1 = { id: '1', name: 'Entity 1' };
const entity2 = { id: '2', name: 'Entity 2' };
const queryBuilderMock = mock(
AuthorizationResultBasedSQLQueryBuilder<any, any, any, any, any, any>,
);
when(queryBuilderMock.executeAsync()).thenResolve([result(entity1), result(entity2)]);
const queryBuilder = instance(queryBuilderMock);
when(nonEnforcingKnexEntityLoaderMock.loadManyBySQL(anything(), anything())).thenReturn(
queryBuilder,
);
const nonEnforcingKnexEntityLoader = instance(nonEnforcingKnexEntityLoaderMock);
const enforcingKnexEntityLoader = new EnforcingKnexEntityLoader(
nonEnforcingKnexEntityLoader,
instance(mock(EntityQueryContext)),
instance(mock(EntityKnexDataManager)),
instance(mock<IEntityMetricsAdapter>()),
instance(mock(EntityConstructionUtils)),
);
const enforcingQueryBuilder = enforcingKnexEntityLoader.loadManyBySQL(sql`1=1`);
await expect(enforcingQueryBuilder.executeAsync()).resolves.toEqual([entity1, entity2]);
});
});
describe('loadPageAsync', () => {
it('throws when result is unsuccessful', async () => {
const queryContext = instance(mock(EntityQueryContext));
const knexDataManagerMock = mock<EntityKnexDataManager<any, any>>(EntityKnexDataManager);
const constructionUtilsMock =
mock<EntityConstructionUtils<any, any, any, any, any, any>>(EntityConstructionUtils);
const rejection = new Error('Entity not authorized');
// Mock the data manager to return a connection with field objects
when(knexDataManagerMock.loadPageAsync(anything(), anything())).thenResolve({
edges: [
{
cursor: 'cursor1',
node: { id: '1', name: 'Entity 1' },
},
],
pageInfo: {
hasNextPage: false,
hasPreviousPage: false,
startCursor: 'cursor1',
endCursor: 'cursor1',
},
});
// Mock constructionUtils to throw when constructing entities
when(constructionUtilsMock.constructAndAuthorizeEntityAsync(anything())).thenResolve(
result(rejection),
);
const enforcingKnexEntityLoader = new EnforcingKnexEntityLoader(
instance(mock(AuthorizationResultBasedKnexEntityLoader<any, any, any, any, any, any>)),
queryContext,
instance(knexDataManagerMock),
instance(mock<IEntityMetricsAdapter>()),
instance(constructionUtilsMock),
);
await expect(
enforcingKnexEntityLoader.loadPageAsync({
first: 10,
pagination: {
strategy: PaginationStrategy.STANDARD,
orderBy: [],
},
}),
).rejects.toThrow(rejection);
});
it('returns value when result is successful', async () => {
const queryContext = instance(mock(EntityQueryContext));
const knexDataManagerMock = mock<EntityKnexDataManager<any, any>>(EntityKnexDataManager);
const constructionUtilsMock =
mock<EntityConstructionUtils<any, any, any, any, any, any>>(EntityConstructionUtils);
const entity1 = { id: '1', name: 'Entity 1', getID: () => '1' };
const entity2 = { id: '2', name: 'Entity 2', getID: () => '2' };
when(knexDataManagerMock.loadPageAsync(anything(), anything())).thenResolve({
edges: [
{
cursor: 'cursor1',
node: { id: '1', name: 'Entity 1' },
},
{
cursor: 'cursor2',
node: { id: '2', name: 'Entity 2' },
},
],
pageInfo: {
hasNextPage: true,
hasPreviousPage: false,
startCursor: 'cursor1',
endCursor: 'cursor2',
},
});
when(constructionUtilsMock.constructAndAuthorizeEntityAsync(anything())).thenCall(
async (fieldObject: any) => {
if (fieldObject.id === '1') {
return result(entity1);
} else if (fieldObject.id === '2') {
return result(entity2);
}
throw new Error('Unexpected field object');
},
);
const enforcingKnexEntityLoader = new EnforcingKnexEntityLoader(
instance(mock(AuthorizationResultBasedKnexEntityLoader<any, any, any, any, any, any>)),
queryContext,
instance(knexDataManagerMock),
instance(mock<IEntityMetricsAdapter>()),
instance(constructionUtilsMock),
);
const connection = await enforcingKnexEntityLoader.loadPageAsync({
first: 10,
pagination: {
strategy: PaginationStrategy.STANDARD,
orderBy: [],
},
});
expect(connection.edges).toHaveLength(2);
expect(connection.edges[0]).toEqual({
cursor: 'cursor1',
node: entity1,
});
expect(connection.edges[1]).toEqual({
cursor: 'cursor2',
node: entity2,
});
expect(connection.pageInfo).toEqual({
hasNextPage: true,
hasPreviousPage: false,
startCursor: 'cursor1',
endCursor: 'cursor2',
});
});
});
it('has the same method names as AuthorizationResultBasedKnexEntityLoader', () => {
const enforcingKnexLoaderProperties = Object.getOwnPropertyNames(
EnforcingKnexEntityLoader.prototype,
);
const nonEnforcingKnexLoaderProperties = Object.getOwnPropertyNames(
AuthorizationResultBasedKnexEntityLoader.prototype,
);
// The knex loaders don't have the internal validation methods that regular loaders have,
// so we just check that all methods match without any exclusions
expect(enforcingKnexLoaderProperties).toEqual(nonEnforcingKnexLoaderProperties);
});
});
@@ -0,0 +1,172 @@
import {
AlwaysAllowPrivacyPolicyRule,
EntityCompanionDefinition,
EntityConfiguration,
EntityPrivacyPolicy,
StringField,
UUIDField,
ViewerContext,
} from '@expo/entity';
import { describe, expect, it } from '@jest/globals';
import { AuthorizationResultBasedKnexEntityLoader } from '../AuthorizationResultBasedKnexEntityLoader';
import { EnforcingKnexEntityLoader } from '../EnforcingKnexEntityLoader';
import { PostgresEntity } from '../PostgresEntity';
import { ReadonlyPostgresEntity } from '../ReadonlyPostgresEntity';
import { createUnitTestPostgresEntityCompanionProvider } from './fixtures/createUnitTestPostgresEntityCompanionProvider';
type TestPostgresFields = {
id: string;
name: string;
};
const testPostgresEntityConfiguration = new EntityConfiguration<TestPostgresFields, 'id'>({
idField: 'id',
tableName: 'postgres_entity_test',
schema: {
id: new UUIDField({ columnName: 'id', cache: true }),
name: new StringField({ columnName: 'name' }),
},
databaseAdapterFlavor: 'postgres',
cacheAdapterFlavor: 'redis',
});
class TestPostgresEntityPrivacyPolicy extends EntityPrivacyPolicy<
TestPostgresFields,
'id',
ViewerContext,
TestPostgresEntity
> {
protected override readonly readRules = [
new AlwaysAllowPrivacyPolicyRule<TestPostgresFields, 'id', ViewerContext, TestPostgresEntity>(),
];
protected override readonly createRules = [
new AlwaysAllowPrivacyPolicyRule<TestPostgresFields, 'id', ViewerContext, TestPostgresEntity>(),
];
protected override readonly updateRules = [
new AlwaysAllowPrivacyPolicyRule<TestPostgresFields, 'id', ViewerContext, TestPostgresEntity>(),
];
protected override readonly deleteRules = [
new AlwaysAllowPrivacyPolicyRule<TestPostgresFields, 'id', ViewerContext, TestPostgresEntity>(),
];
}
class TestPostgresEntity extends PostgresEntity<TestPostgresFields, 'id', ViewerContext> {
static defineCompanionDefinition(): EntityCompanionDefinition<
TestPostgresFields,
'id',
ViewerContext,
TestPostgresEntity,
TestPostgresEntityPrivacyPolicy
> {
return {
entityClass: TestPostgresEntity,
entityConfiguration: testPostgresEntityConfiguration,
privacyPolicyClass: TestPostgresEntityPrivacyPolicy,
};
}
}
class TestReadonlyPostgresEntityPrivacyPolicy extends EntityPrivacyPolicy<
TestPostgresFields,
'id',
ViewerContext,
TestReadonlyPostgresEntity
> {
protected override readonly readRules = [
new AlwaysAllowPrivacyPolicyRule<
TestPostgresFields,
'id',
ViewerContext,
TestReadonlyPostgresEntity
>(),
];
protected override readonly createRules = [
new AlwaysAllowPrivacyPolicyRule<
TestPostgresFields,
'id',
ViewerContext,
TestReadonlyPostgresEntity
>(),
];
protected override readonly updateRules = [
new AlwaysAllowPrivacyPolicyRule<
TestPostgresFields,
'id',
ViewerContext,
TestReadonlyPostgresEntity
>(),
];
protected override readonly deleteRules = [
new AlwaysAllowPrivacyPolicyRule<
TestPostgresFields,
'id',
ViewerContext,
TestReadonlyPostgresEntity
>(),
];
}
class TestReadonlyPostgresEntity extends ReadonlyPostgresEntity<
TestPostgresFields,
'id',
ViewerContext
> {
static defineCompanionDefinition(): EntityCompanionDefinition<
TestPostgresFields,
'id',
ViewerContext,
TestReadonlyPostgresEntity,
TestReadonlyPostgresEntityPrivacyPolicy
> {
return {
entityClass: TestReadonlyPostgresEntity,
entityConfiguration: testPostgresEntityConfiguration,
privacyPolicyClass: TestReadonlyPostgresEntityPrivacyPolicy,
};
}
}
describe(PostgresEntity, () => {
describe('knexLoader', () => {
it('creates a new EnforcingKnexEntityLoader', () => {
const companionProvider = createUnitTestPostgresEntityCompanionProvider();
const viewerContext = new ViewerContext(companionProvider);
expect(TestPostgresEntity.knexLoader(viewerContext)).toBeInstanceOf(
EnforcingKnexEntityLoader,
);
});
});
describe('knexLoaderWithAuthorizationResults', () => {
it('creates a new AuthorizationResultBasedKnexEntityLoader', () => {
const companionProvider = createUnitTestPostgresEntityCompanionProvider();
const viewerContext = new ViewerContext(companionProvider);
expect(TestPostgresEntity.knexLoaderWithAuthorizationResults(viewerContext)).toBeInstanceOf(
AuthorizationResultBasedKnexEntityLoader,
);
});
});
});
describe(ReadonlyPostgresEntity, () => {
describe('knexLoader', () => {
it('creates a new EnforcingKnexEntityLoader', () => {
const companionProvider = createUnitTestPostgresEntityCompanionProvider();
const viewerContext = new ViewerContext(companionProvider);
expect(TestReadonlyPostgresEntity.knexLoader(viewerContext)).toBeInstanceOf(
EnforcingKnexEntityLoader,
);
});
});
describe('knexLoaderWithAuthorizationResults', () => {
it('creates a new AuthorizationResultBasedKnexEntityLoader', () => {
const companionProvider = createUnitTestPostgresEntityCompanionProvider();
const viewerContext = new ViewerContext(companionProvider);
expect(
TestReadonlyPostgresEntity.knexLoaderWithAuthorizationResults(viewerContext),
).toBeInstanceOf(AuthorizationResultBasedKnexEntityLoader);
});
});
});
@@ -0,0 +1,32 @@
import { ViewerContext } from '@expo/entity';
import { describe, expect, it } from '@jest/globals';
import { AuthorizationResultBasedKnexEntityLoader } from '../AuthorizationResultBasedKnexEntityLoader';
import { EnforcingKnexEntityLoader } from '../EnforcingKnexEntityLoader';
import { knexLoader, knexLoaderWithAuthorizationResults } from '../knexLoader';
import { TestEntity } from './fixtures/TestEntity';
import { createUnitTestPostgresEntityCompanionProvider } from './fixtures/createUnitTestPostgresEntityCompanionProvider';
describe('knexLoader', () => {
describe('knexLoader', () => {
it('creates a new EnforcingKnexEntityLoader', async () => {
const companionProvider = createUnitTestPostgresEntityCompanionProvider();
const viewerContext = new ViewerContext(companionProvider);
expect(knexLoader(TestEntity, viewerContext)).toBeInstanceOf(EnforcingKnexEntityLoader);
expect(TestEntity.knexLoader(viewerContext)).toBeInstanceOf(EnforcingKnexEntityLoader);
});
});
describe('knexLoaderWithAuthorizationResults', () => {
it('creates a new AuthorizationResultBasedKnexEntityLoader', async () => {
const companionProvider = createUnitTestPostgresEntityCompanionProvider();
const viewerContext = new ViewerContext(companionProvider);
expect(knexLoaderWithAuthorizationResults(TestEntity, viewerContext)).toBeInstanceOf(
AuthorizationResultBasedKnexEntityLoader,
);
expect(TestEntity.knexLoaderWithAuthorizationResults(viewerContext)).toBeInstanceOf(
AuthorizationResultBasedKnexEntityLoader,
);
});
});
});
@@ -0,0 +1,747 @@
import { describe, expect, it } from '@jest/globals';
import {
identifier,
raw,
sql,
SQLFragment,
SQLFragmentHelpers,
SQLIdentifier,
} from '../SQLOperator';
describe('SQLOperator', () => {
describe('sql template literal', () => {
it('handles basic parameterized queries', () => {
const age = 18;
const status = 'active';
const fragment = sql`age >= ${age} AND status = ${status}`;
expect(fragment.sql).toBe('age >= ? AND status = ?');
expect(fragment.getKnexBindings()).toEqual([18, 'active']);
});
it('handles nested SQL fragments', () => {
const condition1 = sql`age >= ${18}`;
const condition2 = sql`status = ${'active'}`;
const combined = sql`${condition1} AND ${condition2}`;
expect(combined.sql).toBe('age >= ? AND status = ?');
expect(combined.getKnexBindings()).toEqual([18, 'active']);
});
it('handles SQL identifiers', () => {
const columnName = 'user_name';
const fragment = sql`${identifier(columnName)} = ${'John'}`;
expect(fragment.sql).toBe('?? = ?');
expect(fragment.getKnexBindings()).toEqual(['user_name', 'John']);
});
it('handles arrays for IN clauses', () => {
const values = ['active', 'pending', 'approved'];
const fragment = sql`status IN ${values}`;
expect(fragment.sql).toBe('status IN (?, ?, ?)');
expect(fragment.getKnexBindings()).toEqual(['active', 'pending', 'approved']);
});
it('handles null values', () => {
const fragment = sql`field = ${null}`;
expect(fragment.sql).toBe('field = ?');
expect(fragment.getKnexBindings()).toEqual([null]);
});
it('handles empty strings', () => {
const fragment = sql`field = ${''}`;
expect(fragment.sql).toBe('field = ?');
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()).toEqual([0, 42]);
});
it('handles boolean values', () => {
const fragment = sql`active = ${true} AND deleted = ${false}`;
expect(fragment.sql).toBe('active = ? AND deleted = ?');
expect(fragment.getKnexBindings()).toEqual([true, false]);
});
it('handles raw SQL', () => {
const columnName = 'created_at';
const fragment = sql`ORDER BY ${raw(columnName)} DESC`;
expect(fragment.sql).toBe('ORDER BY created_at DESC');
expect(fragment.getKnexBindings()).toEqual([]);
});
it('handles complex raw SQL expressions', () => {
const fragment = sql`WHERE ${raw('EXTRACT(year FROM created_at)')} = ${2024}`;
expect(fragment.sql).toBe('WHERE EXTRACT(year FROM created_at) = ?');
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 ${raw(sortColumn)} ${raw('DESC')}`;
expect(fragment.sql).toBe('SELECT * FROM users WHERE age > ? ORDER BY name DESC');
expect(fragment.getKnexBindings()).toEqual([18]);
});
});
describe(SQLFragment, () => {
describe(SQLFragment.prototype.append, () => {
it('appends fragments correctly', () => {
const fragment1 = new SQLFragment('age >= ?', [{ type: 'value', value: 18 }]);
const fragment2 = new SQLFragment('status = ?', [{ type: 'value', value: 'active' }]);
const combined = fragment1.append(fragment2);
expect(combined.sql).toBe('age >= ? status = ?');
expect(combined.getKnexBindings()).toEqual([18, 'active']);
});
});
describe(SQLFragment.join, () => {
it('joins fragments with custom separator', () => {
const fragments = [
new SQLFragment('name = ?', [{ type: 'value', value: 'Alice' }]),
new SQLFragment('age = ?', [{ type: 'value', value: 30 }]),
new SQLFragment('city = ?', [{ type: 'value', value: 'NYC' }]),
];
const joined = SQLFragment.join(fragments, ' AND ');
expect(joined.sql).toBe('name = ? AND age = ? AND city = ?');
expect(joined.getKnexBindings()).toEqual(['Alice', 30, 'NYC']);
});
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.join(columns);
expect(joined.sql).toBe('name, age, email');
expect(joined.getKnexBindings()).toEqual([]);
});
it('joins SQL fragments with custom separator', () => {
const conditions = [sql`age > ${18}`, sql`status = ${'active'}`, sql`verified = ${true}`];
const joined = SQLFragment.join(conditions, ' AND ');
expect(joined.sql).toBe('age > ? AND status = ? AND verified = ?');
expect(joined.getKnexBindings()).toEqual([18, 'active', true]);
});
it('handles single fragment', () => {
const single = [sql`name = ${'Alice'}`];
const joined = SQLFragment.join(single);
expect(joined.sql).toBe('name = ?');
expect(joined.getKnexBindings()).toEqual(['Alice']);
});
});
describe(SQLFragment.concat, () => {
it('concatenates fragments with space separator', () => {
const select = new SQLFragment('SELECT * FROM users', []);
const where = new SQLFragment('WHERE age > ?', [{ type: 'value', value: 18 }]);
const orderBy = new SQLFragment('ORDER BY name', []);
const concatenated = SQLFragment.concat(select, where, orderBy);
expect(concatenated.sql).toBe('SELECT * FROM users WHERE age > ? ORDER BY name');
expect(concatenated.getKnexBindings()).toEqual([18]);
});
it('handles single fragment in concat', () => {
const fragment = new SQLFragment('SELECT * FROM users', []);
const concatenated = SQLFragment.concat(fragment);
expect(concatenated.sql).toBe('SELECT * FROM users');
expect(concatenated.getKnexBindings()).toEqual([]);
});
it('handles empty concat', () => {
const concatenated = SQLFragment.concat();
expect(concatenated.sql).toBe('');
expect(concatenated.getKnexBindings()).toEqual([]);
});
it('supports dynamic query building with concat', () => {
// Build a query dynamically
const fragments: SQLFragment[] = [sql`SELECT * FROM products`];
// Conditionally add WHERE clause
const filters: SQLFragment[] = [];
filters.push(sql`price > ${100}`);
filters.push(sql`category = ${'electronics'}`);
if (filters.length > 0) {
fragments.push(sql`WHERE ${SQLFragment.join(filters, ' AND ')}`);
}
// Add ORDER BY
fragments.push(sql`ORDER BY created_at DESC`);
// Add LIMIT
fragments.push(sql`LIMIT ${10}`);
const query = SQLFragment.concat(...fragments);
expect(query.sql).toBe(
'SELECT * FROM products WHERE price > ? AND category = ? ORDER BY created_at DESC LIMIT ?',
);
expect(query.getKnexBindings()).toEqual([100, 'electronics', 10]);
});
});
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 > ?',
[
{ type: 'value', value: 'Alice' },
{ type: 'value', value: 18 },
{ type: 'value', value: true },
{ type: 'value', value: new Date('2024-01-01') },
],
);
const text = fragment.toDebugString;
expect(text).toContain("'Alice'");
expect(text).toContain('18');
expect(text).toContain('TRUE');
expect(text).toContain('2024-01-01');
});
it('handles null and special characters in text', () => {
const fragment = new SQLFragment('name = ? AND email = ? AND data = ?', [
{ type: 'value', value: null },
{ type: 'value', value: "O'Reilly" },
{ type: 'value', value: { key: 'value' } },
]);
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 toDebugString', () => {
const fragment = new SQLFragment('INSERT INTO test VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)', [
{ type: 'value', value: 'string' },
{ type: 'value', value: 123 },
{ type: 'value', value: true },
{ type: 'value', value: null },
{ type: 'value', value: undefined },
{ type: 'value', value: new Date('2024-01-01T00:00:00.000Z') },
{ type: 'value', value: Buffer.from('hello') },
{ type: 'value', value: BigInt(999) },
{ type: 'value', value: [1, 2, 3] },
]);
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 toDebugString', () => {
const fragment = new SQLFragment('SELECT * FROM test WHERE tags = ?', [
{ type: 'value', value: ['tag1', 'tag2', null] },
]);
const text = fragment.toDebugString;
expect(text).toBe("SELECT * FROM test WHERE tags = ARRAY['tag1', 'tag2', NULL]");
});
it('handles mismatched placeholders and values gracefully', () => {
const fragment = new SQLFragment('SELECT * FROM test WHERE field1 = ? AND field2 = ?', [
{ type: 'value', value: 'value1' },
]);
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') as any },
{ type: 'value', value: Object.create(null) },
]);
const text = fragment.toDebugString;
expect(text).toBe(
`SELECT * FROM test WHERE field = UnsupportedSQLValue[Error: wat] AND field2 = '{}'::jsonb`,
);
});
it('handles identifiers in toDebugString', () => {
const fragment = new SQLFragment('SELECT ?? FROM ?? WHERE ?? = ?', [
{ type: 'identifier', name: 'user_name' },
{ type: 'identifier', name: 'users' },
{ type: 'identifier', name: 'status' },
{ type: 'value', value: 'active' },
]);
const text = fragment.toDebugString;
expect(text).toBe('SELECT "user_name" FROM "users" WHERE "status" = \'active\'');
});
it('handles undefined bindings gracefully', () => {
const fragment = new SQLFragment('SELECT * FROM users WHERE id = ? AND name = ?', [
{ type: 'value', value: 1 },
undefined as any, // Simulate an edge case with undefined binding
]);
const text = fragment.toDebugString;
expect(text).toBe('SELECT * FROM users WHERE id = 1 AND name = ?');
});
it('handles null bindings gracefully', () => {
const fragment = new SQLFragment('SELECT * FROM ?? WHERE ?? = ?', [
{ type: 'identifier', name: 'users' },
null as any, // Simulate an edge case with null binding
{ type: 'value', value: 'test' },
]);
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 ?? = ?');
});
it('handles mismatch between identifier placeholder and value binding', () => {
const fragment = new SQLFragment('SELECT * FROM ?? WHERE id = ?', [
{ type: 'value', value: 'users' }, // Wrong type - should be identifier
{ type: 'value', value: 1 },
]);
const text = fragment.toDebugString;
// Mismatched binding type leaves the placeholder unchanged
expect(text).toBe('SELECT * FROM ?? WHERE id = 1');
});
it('handles mismatch between value placeholder and identifier binding', () => {
const fragment = new SQLFragment('SELECT * FROM users WHERE ? = ?', [
{ type: 'identifier', name: 'status' }, // Wrong type - should be value
{ type: 'value', value: 'active' },
]);
const text = fragment.toDebugString;
// Mismatched binding type leaves the placeholder unchanged
expect(text).toBe("SELECT * FROM users WHERE ? = 'active'");
});
});
});
describe(SQLIdentifier, () => {
it('stores raw identifier names', () => {
const id = identifier('user_name');
expect(id.name).toBe('user_name');
});
it('stores identifier with quotes unchanged', () => {
const id = identifier('table"name');
expect(id.name).toBe('table"name');
});
it('stores identifier with multiple quotes unchanged', () => {
const id = identifier('my"special"column');
expect(id.name).toBe('my"special"column');
});
it('stores potential SQL injection attempts unchanged', () => {
const id = identifier('col"; DROP TABLE users; --');
expect(id.name).toBe('col"; DROP TABLE users; --');
});
it('handles empty string identifier', () => {
const id = identifier('');
expect(id.name).toBe('');
});
it('handles identifier with only quotes', () => {
const id = identifier('"""');
expect(id.name).toBe('"""');
});
it('uses ?? placeholder in SQL fragments', () => {
const columnName = 'user"data';
const fragment = sql`SELECT ${identifier(columnName)} FROM users`;
expect(fragment.sql).toBe('SELECT ?? FROM users');
expect(fragment.getKnexBindings()).toEqual(['user"data']);
});
it('delegates escaping to Knex for SQL injection prevention', () => {
const maliciousName = 'id"; DELETE FROM users WHERE "1"="1';
const fragment = sql`SELECT * FROM ${identifier(maliciousName)}`;
// The identifier is passed as a binding to Knex which will escape it
expect(fragment.sql).toBe('SELECT * FROM ??');
expect(fragment.getKnexBindings()).toEqual(['id"; DELETE FROM users WHERE "1"="1']);
});
});
describe('SQLFragmentHelpers', () => {
describe(SQLFragmentHelpers.inArray, () => {
it('generates IN clause with values', () => {
const fragment = SQLFragmentHelpers.inArray('status', ['active', 'pending']);
expect(fragment.sql).toBe('?? IN (?, ?)');
expect(fragment.getKnexBindings()).toEqual(['status', 'active', 'pending']);
});
it('handles empty array', () => {
const fragment = SQLFragmentHelpers.inArray('status', []);
expect(fragment.sql).toBe('1 = 0'); // Always false
expect(fragment.getKnexBindings()).toEqual([]);
});
});
describe(SQLFragmentHelpers.notInArray, () => {
it('generates NOT IN clause with values', () => {
const fragment = SQLFragmentHelpers.notInArray('status', ['deleted', 'archived']);
expect(fragment.sql).toBe('?? NOT IN (?, ?)');
expect(fragment.getKnexBindings()).toEqual(['status', 'deleted', 'archived']);
});
it('handles empty array', () => {
const fragment = SQLFragmentHelpers.notInArray('status', []);
expect(fragment.sql).toBe('1 = 1'); // Always true
expect(fragment.getKnexBindings()).toEqual([]);
});
});
describe(SQLFragmentHelpers.between, () => {
it('generates BETWEEN clause with numbers', () => {
const fragment = SQLFragmentHelpers.between('age', 18, 65);
expect(fragment.sql).toBe('?? BETWEEN ? AND ?');
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('created_at', date1, date2);
expect(fragment.sql).toBe('?? BETWEEN ? AND ?');
expect(fragment.getKnexBindings()).toEqual(['created_at', date1, date2]);
});
it('generates BETWEEN clause with strings', () => {
const fragment = SQLFragmentHelpers.between('name', 'A', 'Z');
expect(fragment.sql).toBe('?? BETWEEN ? AND ?');
expect(fragment.getKnexBindings()).toEqual(['name', 'A', 'Z']);
});
});
describe(SQLFragmentHelpers.notBetween, () => {
it('generates NOT BETWEEN clause with numbers', () => {
const fragment = SQLFragmentHelpers.notBetween('age', 18, 65);
expect(fragment.sql).toBe('?? NOT BETWEEN ? AND ?');
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('created_at', date1, date2);
expect(fragment.sql).toBe('?? NOT BETWEEN ? AND ?');
expect(fragment.getKnexBindings()).toEqual(['created_at', date1, date2]);
});
});
describe(SQLFragmentHelpers.like, () => {
it('generates LIKE clause', () => {
const fragment = SQLFragmentHelpers.like('name', '%John%');
expect(fragment.sql).toBe('?? LIKE ?');
expect(fragment.getKnexBindings()).toEqual(['name', '%John%']);
});
});
describe(SQLFragmentHelpers.notLike, () => {
it('generates NOT LIKE clause', () => {
const fragment = SQLFragmentHelpers.notLike('name', '%test%');
expect(fragment.sql).toBe('?? NOT LIKE ?');
expect(fragment.getKnexBindings()).toEqual(['name', '%test%']);
});
});
describe(SQLFragmentHelpers.ilike, () => {
it('generates ILIKE clause for case-insensitive matching', () => {
const fragment = SQLFragmentHelpers.ilike('email', '%@example.com');
expect(fragment.sql).toBe('?? ILIKE ?');
expect(fragment.getKnexBindings()).toEqual(['email', '%@example.com']);
});
});
describe(SQLFragmentHelpers.notIlike, () => {
it('generates NOT ILIKE clause for case-insensitive non-matching', () => {
const fragment = SQLFragmentHelpers.notIlike('email', '%@spam.com');
expect(fragment.sql).toBe('?? NOT ILIKE ?');
expect(fragment.getKnexBindings()).toEqual(['email', '%@spam.com']);
});
});
describe(SQLFragmentHelpers.isNull, () => {
it('generates IS NULL', () => {
const fragment = SQLFragmentHelpers.isNull('deleted_at');
expect(fragment.sql).toBe('?? IS NULL');
expect(fragment.getKnexBindings()).toEqual(['deleted_at']);
});
});
describe(SQLFragmentHelpers.isNotNull, () => {
it('generates IS NOT NULL', () => {
const fragment = SQLFragmentHelpers.isNotNull('email');
expect(fragment.sql).toBe('?? IS NOT NULL');
expect(fragment.getKnexBindings()).toEqual(['email']);
});
});
describe(SQLFragmentHelpers.eq, () => {
it('generates equality check', () => {
const fragment = SQLFragmentHelpers.eq('status', 'active');
expect(fragment.sql).toBe('?? = ?');
expect(fragment.getKnexBindings()).toEqual(['status', 'active']);
});
it('handles null in equality check', () => {
const fragment = SQLFragmentHelpers.eq('field', null);
expect(fragment.sql).toBe('?? IS NULL');
expect(fragment.getKnexBindings()).toEqual(['field']);
});
it('handles undefined in equality check', () => {
const fragment = SQLFragmentHelpers.eq('field', undefined);
expect(fragment.sql).toBe('?? IS NULL');
expect(fragment.getKnexBindings()).toEqual(['field']);
});
});
describe(SQLFragmentHelpers.neq, () => {
it('generates inequality check', () => {
const fragment = SQLFragmentHelpers.neq('status', 'deleted');
expect(fragment.sql).toBe('?? != ?');
expect(fragment.getKnexBindings()).toEqual(['status', 'deleted']);
});
it('handles null in inequality check', () => {
const fragment = SQLFragmentHelpers.neq('field', null);
expect(fragment.sql).toBe('?? IS NOT NULL');
expect(fragment.getKnexBindings()).toEqual(['field']);
});
it('handles undefined in inequality check', () => {
const fragment = SQLFragmentHelpers.neq('field', undefined);
expect(fragment.sql).toBe('?? IS NOT NULL');
expect(fragment.getKnexBindings()).toEqual(['field']);
});
});
describe(SQLFragmentHelpers.gt, () => {
it('generates greater than', () => {
const fragment = SQLFragmentHelpers.gt('age', 18);
expect(fragment.sql).toBe('?? > ?');
expect(fragment.getKnexBindings()).toEqual(['age', 18]);
});
});
describe(SQLFragmentHelpers.gte, () => {
it('generates greater than or equal', () => {
const fragment = SQLFragmentHelpers.gte('age', 18);
expect(fragment.sql).toBe('?? >= ?');
expect(fragment.getKnexBindings()).toEqual(['age', 18]);
});
});
describe(SQLFragmentHelpers.lt, () => {
it('generates less than', () => {
const fragment = SQLFragmentHelpers.lt('age', 65);
expect(fragment.sql).toBe('?? < ?');
expect(fragment.getKnexBindings()).toEqual(['age', 65]);
});
});
describe(SQLFragmentHelpers.lte, () => {
it('generates less than or equal', () => {
const fragment = SQLFragmentHelpers.lte('age', 65);
expect(fragment.sql).toBe('?? <= ?');
expect(fragment.getKnexBindings()).toEqual(['age', 65]);
});
});
describe(SQLFragmentHelpers.jsonContains, () => {
it('generates JSON contains', () => {
const fragment = SQLFragmentHelpers.jsonContains('metadata', { premium: true });
expect(fragment.sql).toBe('?? @> ?::jsonb');
expect(fragment.getKnexBindings()).toEqual(['metadata', '{"premium":true}']);
});
});
describe(SQLFragmentHelpers.jsonContainedBy, () => {
it('generates JSON contained by', () => {
const fragment = SQLFragmentHelpers.jsonContainedBy('settings', {
theme: 'dark',
lang: 'en',
});
expect(fragment.sql).toBe('?? <@ ?::jsonb');
expect(fragment.getKnexBindings()).toEqual(['settings', '{"theme":"dark","lang":"en"}']);
});
});
describe(SQLFragmentHelpers.jsonPath, () => {
it('generates JSON path access', () => {
const fragment = SQLFragmentHelpers.jsonPath('data', 'user');
expect(fragment.sql).toBe(`"data"->'user'`);
expect(fragment.getKnexBindings()).toEqual([]);
});
});
describe(SQLFragmentHelpers.jsonPathText, () => {
it('generates JSON path text access', () => {
const fragment = SQLFragmentHelpers.jsonPathText('data', 'email');
expect(fragment.sql).toBe(`"data"->>'email'`);
expect(fragment.getKnexBindings()).toEqual([]);
});
});
describe(SQLFragmentHelpers.and, () => {
it('combines conditions with AND', () => {
const cond1 = sql`age >= ${18}`;
const cond2 = sql`status = ${'active'}`;
const fragment = SQLFragmentHelpers.and(cond1, cond2);
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()).toEqual([18]);
});
it('handles empty conditions in AND', () => {
const fragment = SQLFragmentHelpers.and();
expect(fragment.sql).toBe('1 = 1');
expect(fragment.getKnexBindings()).toEqual([]);
});
});
describe(SQLFragmentHelpers.or, () => {
it('combines conditions with OR', () => {
const cond1 = sql`status = ${'active'}`;
const cond2 = sql`status = ${'pending'}`;
const fragment = SQLFragmentHelpers.or(cond1, cond2);
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()).toEqual(['active']);
});
it('handles empty conditions in OR', () => {
const fragment = SQLFragmentHelpers.or();
expect(fragment.sql).toBe('1 = 0');
expect(fragment.getKnexBindings()).toEqual([]);
});
});
describe(SQLFragmentHelpers.not, () => {
it('negates conditions with NOT', () => {
const cond = sql`status = ${'deleted'}`;
const fragment = SQLFragmentHelpers.not(cond);
expect(fragment.sql).toBe('NOT (status = ?)');
expect(fragment.getKnexBindings()).toEqual(['deleted']);
});
});
describe(SQLFragmentHelpers.group, () => {
it('groups conditions with parentheses', () => {
const cond = sql`age >= ${18} AND age <= ${65}`;
const fragment = SQLFragmentHelpers.group(cond);
expect(fragment.sql).toBe('(age >= ? AND age <= ?)');
expect(fragment.getKnexBindings()).toEqual([18, 65]);
});
});
describe('complex combinations', () => {
it('builds complex queries with multiple helpers', () => {
const { and, or, between, inArray, isNotNull, group } = SQLFragmentHelpers;
const fragment = and(
between('age', 18, 65),
group(or(inArray('status', ['active', 'premium']), sql`role = ${'admin'}`)),
isNotNull('email'),
);
expect(fragment.sql).toBe(
'?? BETWEEN ? AND ? AND (?? IN (?, ?) OR role = ?) AND ?? IS NOT NULL',
);
expect(fragment.getKnexBindings()).toEqual([
'age',
18,
65,
'status',
'active',
'premium',
'admin',
'email',
]);
});
});
});
});
@@ -0,0 +1,302 @@
import {
computeIfAbsent,
EntityConfiguration,
FieldTransformerMap,
getDatabaseFieldForEntityField,
IntField,
mapMap,
StringField,
transformFieldsToDatabaseObject,
} from '@expo/entity';
import invariant from 'invariant';
import { v7 as uuidv7 } from 'uuid';
import {
BasePostgresEntityDatabaseAdapter,
OrderByOrdering,
TableFieldMultiValueEqualityCondition,
TableFieldSingleValueEqualityCondition,
TableQuerySelectionModifiers,
TableQuerySelectionModifiersWithOrderByFragment,
} from '../../BasePostgresEntityDatabaseAdapter';
import { SQLFragment } from '../../SQLOperator';
export class StubPostgresDatabaseAdapter<
TFields extends Record<string, any>,
TIDField extends keyof TFields,
> extends BasePostgresEntityDatabaseAdapter<TFields, TIDField> {
constructor(
private readonly entityConfiguration2: EntityConfiguration<TFields, TIDField>,
private readonly dataStore: Map<string, Readonly<{ [key: string]: any }>[]>,
) {
super(entityConfiguration2);
}
public static convertFieldObjectsToDataStore<
TFields extends Record<string, any>,
TIDField extends keyof TFields,
>(
entityConfiguration: EntityConfiguration<TFields, TIDField>,
dataStore: Map<string, Readonly<TFields>[]>,
): Map<string, Readonly<{ [key: string]: any }>[]> {
return mapMap(dataStore, (objectsForTable) =>
objectsForTable.map((objectForTable) =>
transformFieldsToDatabaseObject(entityConfiguration, new Map(), objectForTable),
),
);
}
public getObjectCollectionForTable(tableName: string): { [key: string]: any }[] {
return computeIfAbsent(this.dataStore, tableName, () => []);
}
protected getFieldTransformerMap(): FieldTransformerMap {
return new Map();
}
private static uniqBy<T>(a: T[], keyExtractor: (k: T) => string): T[] {
const seen = new Set();
return a.filter((item) => {
const k = keyExtractor(item);
if (seen.has(k)) {
return false;
}
seen.add(k);
return true;
});
}
protected async fetchManyWhereInternalAsync(
_queryInterface: any,
tableName: string,
tableColumns: readonly string[],
tableTuples: (readonly any[])[],
): Promise<object[]> {
const objectCollection = this.getObjectCollectionForTable(tableName);
const results = StubPostgresDatabaseAdapter.uniqBy(tableTuples, (tuple) =>
tuple.join(':'),
).reduce(
(acc, tableTuple) => {
return acc.concat(
objectCollection.filter((obj) => {
return tableColumns.every((tableColumn, index) => {
return obj[tableColumn] === tableTuple[index];
});
}),
);
},
[] as { [key: string]: any }[],
);
return [...results];
}
protected async fetchOneWhereInternalAsync(
queryInterface: any,
tableName: string,
tableColumns: readonly string[],
tableTuple: readonly any[],
): Promise<object | null> {
const results = await this.fetchManyWhereInternalAsync(
queryInterface,
tableName,
tableColumns,
[tableTuple],
);
return results[0] ?? null;
}
private static compareByOrderBys(
orderBys: {
columnName: string;
order: OrderByOrdering;
}[],
objectA: { [key: string]: any },
objectB: { [key: string]: any },
): 0 | 1 | -1 {
if (orderBys.length === 0) {
return 0;
}
const currentOrderBy = orderBys[0]!;
const aField = objectA[currentOrderBy.columnName];
const bField = objectB[currentOrderBy.columnName];
switch (currentOrderBy.order) {
case OrderByOrdering.DESCENDING: {
// simulate NULLS FIRST for DESC
if (aField === null && bField === null) {
return 0;
} else if (aField === null) {
return -1;
} else if (bField === null) {
return 1;
}
return aField > bField
? -1
: aField < bField
? 1
: this.compareByOrderBys(orderBys.slice(1), objectA, objectB);
}
case OrderByOrdering.ASCENDING: {
// simulate NULLS LAST for ASC
if (aField === null && bField === null) {
return 0;
} else if (bField === null) {
return -1;
} else if (aField === null) {
return 1;
}
return bField > aField
? -1
: bField < aField
? 1
: this.compareByOrderBys(orderBys.slice(1), objectA, objectB);
}
}
}
protected async fetchManyByFieldEqualityConjunctionInternalAsync(
_queryInterface: any,
tableName: string,
tableFieldSingleValueEqualityOperands: TableFieldSingleValueEqualityCondition[],
tableFieldMultiValueEqualityOperands: TableFieldMultiValueEqualityCondition[],
querySelectionModifiers: TableQuerySelectionModifiers,
): Promise<object[]> {
let filteredObjects = this.getObjectCollectionForTable(tableName);
for (const { tableField, tableValue } of tableFieldSingleValueEqualityOperands) {
filteredObjects = filteredObjects.filter((obj) => obj[tableField] === tableValue);
}
for (const { tableField, tableValues } of tableFieldMultiValueEqualityOperands) {
filteredObjects = filteredObjects.filter((obj) => tableValues.includes(obj[tableField]));
}
const orderBy = querySelectionModifiers.orderBy;
if (orderBy !== undefined) {
filteredObjects = filteredObjects.sort((a, b) =>
StubPostgresDatabaseAdapter.compareByOrderBys(orderBy, a, b),
);
}
const offset = querySelectionModifiers.offset;
if (offset !== undefined) {
filteredObjects = filteredObjects.slice(offset);
}
const limit = querySelectionModifiers.limit;
if (limit !== undefined) {
filteredObjects = filteredObjects.slice(0, 0 + limit);
}
return filteredObjects;
}
protected fetchManyByRawWhereClauseInternalAsync(
_queryInterface: any,
_tableName: string,
_rawWhereClause: string,
_bindings: object | any[],
_querySelectionModifiers: TableQuerySelectionModifiers,
): Promise<object[]> {
throw new Error('Raw WHERE clauses not supported for StubDatabaseAdapter');
}
protected fetchManyBySQLFragmentInternalAsync(
_queryInterface: any,
_tableName: string,
_sqlFragment: SQLFragment,
_querySelectionModifiers: TableQuerySelectionModifiersWithOrderByFragment,
): Promise<object[]> {
throw new Error('SQL fragments not supported for StubDatabaseAdapter');
}
private generateRandomID(): any {
const idSchemaField = this.entityConfiguration2.schema.get(this.entityConfiguration2.idField);
invariant(
idSchemaField,
`No schema field found for ${String(this.entityConfiguration2.idField)}`,
);
if (idSchemaField instanceof StringField) {
return uuidv7();
} else if (idSchemaField instanceof IntField) {
return Math.floor(Math.random() * Number.MAX_SAFE_INTEGER);
} else {
throw new Error(
`Unsupported ID type for StubPostgresDatabaseAdapter: ${idSchemaField.constructor.name}`,
);
}
}
protected async insertInternalAsync(
_queryInterface: any,
tableName: string,
object: object,
): Promise<object[]> {
const objectCollection = this.getObjectCollectionForTable(tableName);
const idField = getDatabaseFieldForEntityField(
this.entityConfiguration2,
this.entityConfiguration2.idField,
);
const objectToInsert = {
[idField]: this.generateRandomID(),
...object,
};
objectCollection.push(objectToInsert);
return [objectToInsert];
}
protected async updateInternalAsync(
_queryInterface: any,
tableName: string,
tableIdField: string,
id: any,
object: object,
): Promise<object[]> {
// SQL does not support empty updates, mirror behavior here for better test simulation
if (Object.keys(object).length === 0) {
throw new Error(`Empty update (${tableIdField} = ${id})`);
}
const objectCollection = this.getObjectCollectionForTable(tableName);
const objectIndex = objectCollection.findIndex((obj) => {
return obj[tableIdField] === id;
});
// SQL updates to a nonexistent row succeed but affect 0 rows,
// mirror that behavior here for better test simulation
if (objectIndex < 0) {
return [];
}
objectCollection[objectIndex] = {
...objectCollection[objectIndex],
...object,
};
return [objectCollection[objectIndex]];
}
protected async deleteInternalAsync(
_queryInterface: any,
tableName: string,
tableIdField: string,
id: any,
): Promise<number> {
const objectCollection = this.getObjectCollectionForTable(tableName);
const objectIndex = objectCollection.findIndex((obj) => {
return obj[tableIdField] === id;
});
// SQL deletes to a nonexistent row succeed and affect 0 rows,
// mirror that behavior here for better test simulation
if (objectIndex < 0) {
return 0;
}
objectCollection.splice(objectIndex, 1);
return 1;
}
}
@@ -0,0 +1,17 @@
import {
EntityConfiguration,
EntityDatabaseAdapter,
IEntityDatabaseAdapterProvider,
} from '@expo/entity';
import { StubPostgresDatabaseAdapter } from './StubPostgresDatabaseAdapter';
export class StubPostgresDatabaseAdapterProvider implements IEntityDatabaseAdapterProvider {
private readonly objectCollection = new Map();
getDatabaseAdapter<TFields extends Record<string, any>, TIDField extends keyof TFields>(
entityConfiguration: EntityConfiguration<TFields, TIDField>,
): EntityDatabaseAdapter<TFields, TIDField> {
return new StubPostgresDatabaseAdapter(entityConfiguration, this.objectCollection);
}
}
@@ -0,0 +1,131 @@
import {
EntityCompanionDefinition,
EntityConfiguration,
DateField,
IntField,
StringField,
UUIDField,
EntityPrivacyPolicy,
ViewerContext,
AlwaysAllowPrivacyPolicyRule,
} from '@expo/entity';
import { result, Result } from '@expo/results';
import { PostgresEntity } from '../../PostgresEntity';
export type TestFields = {
customIdField: string;
testIndexedField: string;
stringField: string;
intField: number;
dateField: Date;
nullableField: string | null;
};
export const testEntityConfiguration = new EntityConfiguration<TestFields, 'customIdField'>({
idField: 'customIdField',
tableName: 'test_entity_should_not_write_to_db',
schema: {
customIdField: new UUIDField({
columnName: 'custom_id',
cache: true,
}),
testIndexedField: new StringField({
columnName: 'test_index',
cache: true,
}),
stringField: new StringField({
columnName: 'string_field',
}),
intField: new IntField({
columnName: 'number_field',
}),
dateField: new DateField({
columnName: 'date_field',
}),
nullableField: new StringField({
columnName: 'nullable_field',
}),
},
databaseAdapterFlavor: 'postgres',
cacheAdapterFlavor: 'redis',
compositeFieldDefinitions: [
{ compositeField: ['stringField', 'intField'], cache: false },
{ compositeField: ['stringField', 'testIndexedField'], cache: true },
{ compositeField: ['nullableField', 'testIndexedField'], cache: true },
],
});
export class TestEntityPrivacyPolicy extends EntityPrivacyPolicy<
TestFields,
'customIdField',
ViewerContext,
TestEntity
> {
protected override readonly readRules = [
new AlwaysAllowPrivacyPolicyRule<TestFields, 'customIdField', ViewerContext, TestEntity>(),
];
protected override readonly createRules = [
new AlwaysAllowPrivacyPolicyRule<TestFields, 'customIdField', ViewerContext, TestEntity>(),
];
protected override readonly updateRules = [
new AlwaysAllowPrivacyPolicyRule<TestFields, 'customIdField', ViewerContext, TestEntity>(),
];
protected override readonly deleteRules = [
new AlwaysAllowPrivacyPolicyRule<TestFields, 'customIdField', ViewerContext, TestEntity>(),
];
}
export class TestEntity extends PostgresEntity<TestFields, 'customIdField', ViewerContext> {
static defineCompanionDefinition(): EntityCompanionDefinition<
TestFields,
'customIdField',
ViewerContext,
TestEntity,
TestEntityPrivacyPolicy
> {
return {
entityClass: TestEntity,
entityConfiguration: testEntityConfiguration,
privacyPolicyClass: TestEntityPrivacyPolicy,
};
}
getBlah(): string {
return 'Hello World!';
}
static async helloAsync(
viewerContext: ViewerContext,
testValue: string,
): Promise<Result<TestEntity>> {
const fields = {
customIdField: testValue,
testIndexedField: 'hello',
stringField: 'hello',
intField: 1,
dateField: new Date(),
nullableField: null,
};
return result(
new TestEntity({
viewerContext,
id: testValue,
databaseFields: fields,
selectedFields: fields,
}),
);
}
static async returnErrorAsync(_viewerContext: ViewerContext): Promise<Result<TestEntity>> {
return result(new Error('return entity'));
}
static async throwErrorAsync(_viewerContext: ViewerContext): Promise<Result<TestEntity>> {
throw new Error('threw entity');
}
static async nonResultAsync(_viewerContext: ViewerContext, testValue: string): Promise<string> {
return testValue;
}
}
@@ -0,0 +1,107 @@
import {
EntityCompanionDefinition,
EntityConfiguration,
EntityPrivacyPolicy,
ViewerContext,
EntityPrivacyPolicyEvaluationContext,
EntityQueryContext,
UUIDField,
StringField,
DateField,
IntField,
RuleEvaluationResult,
} from '@expo/entity';
import { PostgresEntity } from '../../PostgresEntity';
export interface TestPaginationFields {
id: string;
name: string;
status: string;
createdAt: Date;
score: number;
}
export const testPaginationEntityConfiguration = new EntityConfiguration<
TestPaginationFields,
'id'
>({
idField: 'id',
tableName: 'test_pagination_entities',
schema: {
id: new UUIDField({
columnName: 'id',
cache: true,
}),
name: new StringField({
columnName: 'name',
}),
status: new StringField({
columnName: 'status',
}),
createdAt: new DateField({
columnName: 'created_at',
}),
score: new IntField({
columnName: 'score',
}),
},
databaseAdapterFlavor: 'postgres',
cacheAdapterFlavor: 'redis',
});
/**
* Privacy policy that conditionally fails authorization based on the 'status' field.
* Entities with status 'unauthorized' will fail authorization.
*/
export class TestPaginationPrivacyPolicy extends EntityPrivacyPolicy<
TestPaginationFields,
'id',
ViewerContext,
TestPaginationEntity
> {
protected override readonly readRules = [
{
async evaluateAsync(
_viewerContext: ViewerContext,
_queryContext: EntityQueryContext,
_evaluationContext: EntityPrivacyPolicyEvaluationContext<
TestPaginationFields,
'id',
ViewerContext,
TestPaginationEntity
>,
entity: TestPaginationEntity,
): Promise<RuleEvaluationResult> {
// Fail authorization for entities with status 'unauthorized'
return entity.getField('status') === 'unauthorized'
? RuleEvaluationResult.DENY
: RuleEvaluationResult.ALLOW;
},
},
];
protected override readonly createRules = [];
protected override readonly updateRules = [];
protected override readonly deleteRules = [];
}
export class TestPaginationEntity extends PostgresEntity<
TestPaginationFields,
'id',
ViewerContext
> {
static defineCompanionDefinition(): EntityCompanionDefinition<
TestPaginationFields,
'id',
ViewerContext,
TestPaginationEntity,
TestPaginationPrivacyPolicy
> {
return {
entityClass: TestPaginationEntity,
entityConfiguration: testPaginationEntityConfiguration,
privacyPolicyClass: TestPaginationPrivacyPolicy,
};
}
}
@@ -0,0 +1,42 @@
import {
EntityCompanionProvider,
IEntityMetricsAdapter,
NoOpEntityMetricsAdapter,
} from '@expo/entity';
import {
InMemoryFullCacheStubCacheAdapterProvider,
StubQueryContextProvider,
} from '@expo/entity-testing-utils';
import { StubPostgresDatabaseAdapterProvider } from './StubPostgresDatabaseAdapterProvider';
const queryContextProvider = new StubQueryContextProvider();
/**
* Entity companion provider for use in knex unit tests. All database and cache implementations
* are replaced with in-memory simulations.
*/
export const createUnitTestPostgresEntityCompanionProvider = (
metricsAdapter: IEntityMetricsAdapter = new NoOpEntityMetricsAdapter(),
): EntityCompanionProvider => {
return new EntityCompanionProvider(
metricsAdapter,
new Map([
[
'postgres',
{
adapterProvider: new StubPostgresDatabaseAdapterProvider(),
queryContextProvider,
},
],
]),
new Map([
[
'redis',
{
cacheAdapterProvider: new InMemoryFullCacheStubCacheAdapterProvider(),
},
],
]),
);
};
@@ -4,8 +4,22 @@
* @module @expo/entity-database-adapter-knex
*/
export * from './AuthorizationResultBasedKnexEntityLoader';
export * from './BasePostgresEntityDatabaseAdapter';
export * from './BaseSQLQueryBuilder';
export * from './EnforcingKnexEntityLoader';
export * from './EntityFields';
export * from './KnexEntityLoaderFactory';
export * from './knexLoader';
export * from './PaginationStrategy';
export * from './PostgresEntity';
export * from './PostgresEntityDatabaseAdapter';
export * from './PostgresEntityDatabaseAdapterProvider';
export * from './PostgresEntityQueryContextProvider';
export * from './ReadonlyPostgresEntity';
export * from './SQLOperator';
export * from './errors/wrapNativePostgresCallAsync';
export * from './internal/EntityKnexDataManager';
export * from './internal/getKnexDataManager';
export * from './internal/getKnexEntityLoaderFactory';
export * from './internal/weakMaps';
@@ -0,0 +1,733 @@
import {
EntityQueryContext,
timeAndLogLoadEventAsync,
EntityMetricsLoadType,
IEntityMetricsAdapter,
EntityConfiguration,
getDatabaseFieldForEntityField,
EntityDatabaseAdapterPaginationCursorInvalidError,
} from '@expo/entity';
import assert from 'assert';
import {
BasePostgresEntityDatabaseAdapter,
FieldEqualityCondition,
OrderByOrdering,
PostgresOrderByClause,
PostgresQuerySelectionModifiers,
PostgresQuerySelectionModifiersWithOrderByFragment,
PostgresQuerySelectionModifiersWithOrderByRaw,
} from '../BasePostgresEntityDatabaseAdapter';
import { PaginationStrategy } from '../PaginationStrategy';
import { SQLFragment, SQLFragmentHelpers, identifier, raw, sql } from '../SQLOperator';
interface DataManagerStandardSpecification<TFields extends Record<string, any>> {
strategy: PaginationStrategy.STANDARD;
orderBy: PostgresOrderByClause<TFields>[];
}
interface DataManagerSearchSpecificationBase<TFields extends Record<string, any>> {
term: string;
fields: (keyof TFields)[];
}
interface DataManagerILikeSearchSpecification<
TFields extends Record<string, any>,
> extends DataManagerSearchSpecificationBase<TFields> {
strategy: PaginationStrategy.ILIKE_SEARCH;
}
interface DataManagerTrigramSearchSpecification<
TFields extends Record<string, any>,
> extends DataManagerSearchSpecificationBase<TFields> {
strategy: PaginationStrategy.TRIGRAM_SEARCH;
threshold: number;
extraOrderByFields?: (keyof TFields)[];
}
type DataManagerSearchSpecification<TFields extends Record<string, any>> =
| DataManagerILikeSearchSpecification<TFields>
| DataManagerTrigramSearchSpecification<TFields>;
type DataManagerPaginationSpecification<TFields extends Record<string, any>> =
| DataManagerStandardSpecification<TFields>
| DataManagerSearchSpecification<TFields>;
interface BaseUnifiedPaginationArgs<TFields extends Record<string, any>> {
where?: SQLFragment;
pagination: DataManagerPaginationSpecification<TFields>;
}
interface ForwardUnifiedPaginationArgs<
TFields extends Record<string, any>,
> extends BaseUnifiedPaginationArgs<TFields> {
first: number;
after?: string;
}
interface BackwardUnifiedPaginationArgs<
TFields extends Record<string, any>,
> extends BaseUnifiedPaginationArgs<TFields> {
last: number;
before?: string;
}
type LoadPageArgs<TFields extends Record<string, any>> =
| ForwardUnifiedPaginationArgs<TFields>
| BackwardUnifiedPaginationArgs<TFields>;
/**
* Edge in a connection
*/
export interface Edge<TNode> {
cursor: string;
node: TNode;
}
/**
* Page information for pagination
*/
export interface PageInfo {
hasNextPage: boolean;
hasPreviousPage: boolean;
startCursor: string | null;
endCursor: string | null;
}
/**
* Relay-style Connection type
*/
export interface Connection<TNode> {
edges: Edge<TNode>[];
pageInfo: PageInfo;
}
enum PaginationDirection {
FORWARD = 'forward',
BACKWARD = 'backward',
}
const CURSOR_ROW_TABLE_ALIAS = 'cursor_row';
interface PaginationProvider<TFields extends Record<string, any>, TIDField extends keyof TFields> {
whereClause: SQLFragment | undefined;
buildOrderBy: (direction: PaginationDirection) => {
clauses?: PostgresOrderByClause<TFields>[];
fragment?: SQLFragment | undefined;
};
buildCursorCondition: (
decodedCursorId: TFields[TIDField],
direction: PaginationDirection,
) => SQLFragment;
}
/**
* A knex data manager is responsible for handling non-dataloader-based
* database operations.
*
* @internal
*/
export class EntityKnexDataManager<
TFields extends Record<string, any>,
TIDField extends keyof TFields,
> {
constructor(
private readonly entityConfiguration: EntityConfiguration<TFields, TIDField>,
private readonly databaseAdapter: BasePostgresEntityDatabaseAdapter<TFields, TIDField>,
private readonly metricsAdapter: IEntityMetricsAdapter,
private readonly entityClassName: string,
) {}
/**
* Loads many objects matching the conjunction of where clauses constructed from
* specified field equality operands.
*
* @param queryContext - query context in which to perform the load
* @param fieldEqualityOperands - list of field equality where clause operand specifications
* @param querySelectionModifiers - limit, offset, and orderBy for the query
* @returns array of objects matching the query
*/
async loadManyByFieldEqualityConjunctionAsync<N extends keyof TFields>(
queryContext: EntityQueryContext,
fieldEqualityOperands: FieldEqualityCondition<TFields, N>[],
querySelectionModifiers: PostgresQuerySelectionModifiers<TFields>,
): Promise<readonly Readonly<TFields>[]> {
return await timeAndLogLoadEventAsync(
this.metricsAdapter,
EntityMetricsLoadType.LOAD_MANY_EQUALITY_CONJUNCTION,
this.entityClassName,
queryContext,
)(
this.databaseAdapter.fetchManyByFieldEqualityConjunctionAsync(
queryContext,
fieldEqualityOperands,
querySelectionModifiers,
),
);
}
/**
* Loads many objects matching the raw WHERE clause.
*
* @param queryContext - query context in which to perform the load
* @param rawWhereClause - parameterized SQL WHERE clause with positional binding placeholders or named binding placeholders
* @param bindings - array of positional bindings or object of named bindings
* @param querySelectionModifiers - limit, offset, orderBy, and orderByRaw for the query
* @returns array of objects matching the query
*/
async loadManyByRawWhereClauseAsync(
queryContext: EntityQueryContext,
rawWhereClause: string,
bindings: any[] | object,
querySelectionModifiers: PostgresQuerySelectionModifiersWithOrderByRaw<TFields>,
): Promise<readonly Readonly<TFields>[]> {
return await timeAndLogLoadEventAsync(
this.metricsAdapter,
EntityMetricsLoadType.LOAD_MANY_RAW,
this.entityClassName,
queryContext,
)(
this.databaseAdapter.fetchManyByRawWhereClauseAsync(
queryContext,
rawWhereClause,
bindings,
querySelectionModifiers,
),
);
}
async loadManyBySQLFragmentAsync(
queryContext: EntityQueryContext,
sqlFragment: SQLFragment,
querySelectionModifiers: PostgresQuerySelectionModifiersWithOrderByFragment<TFields>,
): Promise<readonly Readonly<TFields>[]> {
return await timeAndLogLoadEventAsync(
this.metricsAdapter,
EntityMetricsLoadType.LOAD_MANY_SQL,
this.entityClassName,
queryContext,
)(
this.databaseAdapter.fetchManyBySQLFragmentAsync(
queryContext,
sqlFragment,
querySelectionModifiers,
),
);
}
/**
* Load a page of objects using cursor-based pagination with unified pagination specification.
*
* @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
*/
async loadPageAsync(
queryContext: EntityQueryContext,
args: LoadPageArgs<TFields>,
): Promise<Connection<Readonly<TFields>>> {
const { where, pagination } = args;
if (pagination.strategy === PaginationStrategy.STANDARD) {
// Standard pagination
const idField = this.entityConfiguration.idField;
const augmentedOrderByClauses = this.augmentOrderByIfNecessary(pagination.orderBy, idField);
const fieldsToUseInPostgresTupleCursor = augmentedOrderByClauses.map(
(order) => order.fieldName,
);
// Create strategy for regular pagination
const strategy: PaginationProvider<TFields, TIDField> = {
whereClause: where,
buildOrderBy: (direction) => {
// For backward pagination, we flip the ORDER BY direction to fetch records
// in reverse order. This allows us to use a simple "less than" cursor comparison
// instead of complex SQL. We'll reverse the results array later to restore
// the original requested order.
// Example: If user wants last 3 items ordered by name ASC, we:
// 1. Flip to name DESC to get the last items first
// 2. Apply cursor with < comparison
// 3. Reverse the final array to present items in name ASC order
const clauses =
direction === PaginationDirection.FORWARD
? augmentedOrderByClauses
: augmentedOrderByClauses.map((clause) => ({
fieldName: clause.fieldName,
order:
clause.order === OrderByOrdering.ASCENDING
? OrderByOrdering.DESCENDING
: OrderByOrdering.ASCENDING,
}));
return { clauses };
},
buildCursorCondition: (decodedCursorId, direction) =>
this.buildCursorCondition(decodedCursorId, fieldsToUseInPostgresTupleCursor, direction),
};
return await this.loadPageInternalAsync(queryContext, args, strategy);
} else {
// Search pagination (ILIKE or TRIGRAM)
const search = pagination;
// Validate search parameters
assert(search.term.length > 0, 'Search term must be a non-empty string');
assert(search.fields.length > 0, 'Search fields must be a non-empty array');
const direction =
'first' in args ? PaginationDirection.FORWARD : PaginationDirection.BACKWARD;
const { searchWhere, searchOrderByFragment } = this.buildSearchConditionAndOrderBy(
search,
direction,
);
// Combine WHERE conditions: base where + search where
const whereClause =
where && searchWhere ? SQLFragmentHelpers.and(where, searchWhere) : (where ?? searchWhere);
const fieldsToUseInPostgresTupleCursor =
search.strategy === PaginationStrategy.TRIGRAM_SEARCH
? // For trigram search, cursor includes extra order by fields (if specified) + ID to ensure stable ordering that matches ORDER BY clause
[...(search.extraOrderByFields ?? []), this.entityConfiguration.idField]
: // For ILIKE search, cursor includes search fields + ID to ensure stable ordering that matches ORDER BY clause
[...search.fields, this.entityConfiguration.idField];
// Create strategy for search pagination
const strategy: PaginationProvider<TFields, TIDField> = {
whereClause,
buildOrderBy: () => {
return { fragment: searchOrderByFragment };
},
buildCursorCondition: (decodedCursorId, direction) =>
search.strategy === PaginationStrategy.TRIGRAM_SEARCH
? this.buildTrigramCursorCondition(search, decodedCursorId, direction)
: this.buildCursorCondition(
decodedCursorId,
fieldsToUseInPostgresTupleCursor,
direction,
),
};
return await this.loadPageInternalAsync(queryContext, args, strategy);
}
}
/**
* Internal method for loading a page with cursor-based pagination.
* Shared logic for both regular and search pagination.
*/
private async loadPageInternalAsync(
queryContext: EntityQueryContext,
args: LoadPageArgs<TFields>,
paginationProvider: PaginationProvider<TFields, TIDField>,
): Promise<Connection<Readonly<TFields>>> {
const idField = this.entityConfiguration.idField;
// Validate pagination arguments
const maxPageSize = this.databaseAdapter.paginationMaxPageSize;
const isForward = 'first' in args;
if (isForward) {
assert(
Number.isInteger(args.first) && args.first > 0,
'first must be an integer greater than 0',
);
if (maxPageSize !== undefined) {
assert(
args.first <= maxPageSize,
`first must not exceed maximum page size of ${maxPageSize}`,
);
}
} else {
assert(
Number.isInteger(args.last) && args.last > 0,
'last must be an integer greater than 0',
);
if (maxPageSize !== undefined) {
assert(
args.last <= maxPageSize,
`last must not exceed maximum page size of ${maxPageSize}`,
);
}
}
const direction = isForward ? PaginationDirection.FORWARD : PaginationDirection.BACKWARD;
const limit = isForward ? args.first : args.last;
const cursor = isForward ? args.after : args.before;
// Decode cursor
const decodedExternalCursorEntityID = cursor ? this.decodeOpaqueCursor(cursor) : null;
// Build WHERE clause with cursor condition
const baseWhere = paginationProvider.whereClause;
const cursorCondition = decodedExternalCursorEntityID
? 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: PostgresQuerySelectionModifiersWithOrderByFragment<TFields> = {
...(orderByFragment !== undefined && { orderByFragment }),
...(orderByClauses !== undefined && { orderBy: orderByClauses }),
limit: limit + 1, // Fetch data with limit + 1 to check for more pages
};
const fieldObjects = await timeAndLogLoadEventAsync(
this.metricsAdapter,
EntityMetricsLoadType.LOAD_PAGE,
this.entityClassName,
queryContext,
)(this.databaseAdapter.fetchManyBySQLFragmentAsync(queryContext, whereClause, queryModifiers));
// Process results
const hasMore = fieldObjects.length > limit;
const pageFieldObjects = hasMore ? fieldObjects.slice(0, limit) : [...fieldObjects];
if (direction === PaginationDirection.BACKWARD) {
// Restore the original requested order by reversing the results.
// We fetched with flipped ORDER BY for efficient cursor comparison,
// so now we reverse to match the order the user expects.
pageFieldObjects.reverse();
}
// Build edges with cursors
const edges = pageFieldObjects.map((fieldObject) => ({
node: fieldObject,
cursor: this.encodeOpaqueCursor(fieldObject[idField]),
}));
const pageInfo: PageInfo = {
hasNextPage: direction === PaginationDirection.FORWARD ? hasMore : false,
hasPreviousPage: direction === PaginationDirection.BACKWARD ? hasMore : false,
startCursor: edges[0]?.cursor ?? null,
endCursor: edges[edges.length - 1]?.cursor ?? null,
};
return {
edges,
pageInfo,
};
}
private combineWhereConditions(
baseWhere: SQLFragment | undefined,
cursorCondition: SQLFragment | null,
): SQLFragment {
const conditions = [baseWhere, cursorCondition].filter((it) => !!it);
if (conditions.length === 0) {
return sql`1 = 1`;
}
if (conditions.length === 1) {
return conditions[0]!;
}
// Wrap baseWhere in parens if combining with cursor condition
// We know we have exactly 2 conditions at this point
const [first, second] = conditions;
return sql`(${first}) AND ${second}`;
}
private augmentOrderByIfNecessary(
orderBy: PostgresOrderByClause<TFields>[] | undefined,
idField: TIDField,
): PostgresOrderByClause<TFields>[] {
const clauses = orderBy ?? [];
// Always ensure ID is included for stability and cursor correctness
const hasId = clauses.some((spec) => spec.fieldName === idField);
if (!hasId) {
return [...clauses, { fieldName: idField, order: OrderByOrdering.ASCENDING }];
}
return clauses;
}
private encodeOpaqueCursor(idField: TFields[TIDField]): string {
return Buffer.from(JSON.stringify({ id: idField })).toString('base64url');
}
private decodeOpaqueCursor(cursor: string): TFields[TIDField] {
let parsedCursor: any;
try {
const decoded = Buffer.from(cursor, 'base64url').toString();
parsedCursor = JSON.parse(decoded);
} catch (e) {
throw new EntityDatabaseAdapterPaginationCursorInvalidError(
`Failed to decode cursor`,
e instanceof Error ? e : undefined,
);
}
if (!('id' in parsedCursor)) {
throw new EntityDatabaseAdapterPaginationCursorInvalidError(
`Cursor is missing required 'id' field. Parsed cursor: ${JSON.stringify(parsedCursor)}`,
);
}
return parsedCursor.id;
}
private buildCursorCondition(
decodedExternalCursorEntityID: TFields[TIDField],
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.
const operator = direction === PaginationDirection.FORWARD ? '>' : '<';
const idField = getDatabaseFieldForEntityField(
this.entityConfiguration,
this.entityConfiguration.idField,
);
const tableName = this.entityConfiguration.tableName;
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.join(postgresCursorFieldIdentifiers, ', ');
// Build right side using subquery to get computed values for cursor entity
const postgresCursorRowFieldIdentifiers = fieldsToUseInPostgresTupleCursor.map((f) => {
const dbField = getDatabaseFieldForEntityField(this.entityConfiguration, f);
return sql`${raw(CURSOR_ROW_TABLE_ALIAS)}.${identifier(dbField)}`;
});
// Build SELECT fields for subquery
const rightSideSubquery = sql`
SELECT ${SQLFragment.join(postgresCursorRowFieldIdentifiers, ', ')}
FROM ${identifier(tableName)} AS ${raw(CURSOR_ROW_TABLE_ALIAS)}
WHERE ${raw(CURSOR_ROW_TABLE_ALIAS)}.${identifier(idField)} = ${decodedExternalCursorEntityID}
`;
return sql`(${leftSide}) ${raw(operator)} (${rightSideSubquery})`;
}
private buildILikeConditions(
search: DataManagerSearchSpecification<TFields>,
tableAlias?: typeof CURSOR_ROW_TABLE_ALIAS,
): SQLFragment[] {
return search.fields.map((field) => {
const dbField = getDatabaseFieldForEntityField(this.entityConfiguration, field);
const fieldIdentifier = tableAlias
? sql`${raw(tableAlias)}.${identifier(dbField)}`
: sql`${identifier(dbField)}`;
return sql`${fieldIdentifier} ILIKE ${'%' + EntityKnexDataManager.escapeILikePattern(search.term) + '%'}`;
});
}
private buildTrigramSimilarityExpressions(
search: DataManagerSearchSpecification<TFields>,
tableAlias?: typeof CURSOR_ROW_TABLE_ALIAS,
): SQLFragment[] {
return search.fields.map((field) => {
const dbField = getDatabaseFieldForEntityField(this.entityConfiguration, field);
const fieldIdentifier = tableAlias
? sql`${raw(tableAlias)}.${identifier(dbField)}`
: sql`${identifier(dbField)}`;
return sql`similarity(${fieldIdentifier}, ${search.term})`;
});
}
private buildTrigramExactMatchCaseExpression(
search: DataManagerSearchSpecification<TFields>,
tableAlias?: typeof CURSOR_ROW_TABLE_ALIAS,
): SQLFragment {
const ilikeConditions = this.buildILikeConditions(search, tableAlias);
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 {
const similarityExprs = this.buildTrigramSimilarityExpressions(search, tableAlias);
return sql`GREATEST(${SQLFragment.join(similarityExprs, ', ')})`;
}
private buildTrigramCursorCondition(
search: DataManagerTrigramSearchSpecification<TFields>,
decodedExternalCursorEntityID: TFields[TIDField],
direction: PaginationDirection,
): SQLFragment {
// For TRIGRAM search, we compute the similarity values using a subquery, similar to normal cursor
const operator = direction === PaginationDirection.FORWARD ? '<' : '>';
const idField = getDatabaseFieldForEntityField(
this.entityConfiguration,
this.entityConfiguration.idField,
);
const exactMatchExpr = this.buildTrigramExactMatchCaseExpression(search);
const similarityExpr = this.buildTrigramSimilarityGreatestExpression(search);
// Build extra order by fields
const extraOrderByFields = search.extraOrderByFields;
const extraFields =
extraOrderByFields?.map((f) => {
const dbField = getDatabaseFieldForEntityField(this.entityConfiguration, f);
return sql`${identifier(dbField)}`;
}) ?? [];
// Build left side of comparison (current row's computed values)
const leftSide = SQLFragment.join(
[exactMatchExpr, similarityExpr, ...extraFields, sql`${identifier(idField)}`],
', ',
);
// Build right side using subquery to get computed values for cursor entity
// We need to rebuild the same expressions for the cursor row
const cursorExactMatchExpr = this.buildTrigramExactMatchCaseExpression(
search,
CURSOR_ROW_TABLE_ALIAS,
);
const cursorSimilarityExpr = this.buildTrigramSimilarityGreatestExpression(
search,
CURSOR_ROW_TABLE_ALIAS,
);
const cursorExtraFields =
extraOrderByFields?.map((f) => {
const dbField = getDatabaseFieldForEntityField(this.entityConfiguration, f);
return sql`${raw(CURSOR_ROW_TABLE_ALIAS)}.${identifier(dbField)}`;
}) ?? [];
// Build SELECT fields for subquery
const selectFields = [
cursorExactMatchExpr,
cursorSimilarityExpr,
...cursorExtraFields,
sql`${raw(CURSOR_ROW_TABLE_ALIAS)}.${identifier(idField)}`,
];
const rightSideSubquery = sql`
SELECT ${SQLFragment.join(selectFields, ', ')}
FROM ${identifier(this.entityConfiguration.tableName)} AS ${raw(CURSOR_ROW_TABLE_ALIAS)}
WHERE ${raw(CURSOR_ROW_TABLE_ALIAS)}.${identifier(idField)} = ${decodedExternalCursorEntityID}
`;
return sql`(${leftSide}) ${raw(operator)} (${rightSideSubquery})`;
}
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 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 ? SQLFragment.join(conditions, ' OR ') : sql`1 = 0`,
searchOrderByFragment: SQLFragment.join(orderByFields, ', '),
};
}
case PaginationStrategy.TRIGRAM_SEARCH: {
// PostgreSQL trigram similarity
const ilikeConditions = this.buildILikeConditions(search);
const similarityExprs = this.buildTrigramSimilarityExpressions(search);
assert(
search.threshold >= 0 && search.threshold <= 1,
`Trigram similarity threshold must be between 0 and 1, got ${search.threshold}`,
);
const conditions = similarityExprs.map((expr) => sql`${expr} > ${search.threshold}`);
// Combine exact matches (ILIKE) with similarity
const allConditions = [...ilikeConditions, ...conditions];
// Build ORDER BY components
const exactMatchPriority = this.buildTrigramExactMatchPriority(ilikeConditions, direction);
const similarityRanking = this.buildTrigramSimilarityRanking(search, direction);
const tieBreakers = this.buildTrigramTieBreakers(search, direction);
return {
searchWhere: SQLFragment.join(allConditions, ' OR '),
// For trigram search, order by relevance with direction-aware ordering
// 1. Exact matches first (ILIKE)
// 2. Then by similarity score
// 3. Then by extra fields and ID field for stability
searchOrderByFragment: SQLFragment.join(
[exactMatchPriority, similarityRanking, tieBreakers],
', ',
),
};
}
}
}
/**
* 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, '\\$&');
}
}
@@ -0,0 +1,378 @@
import {
EntityMetricsLoadType,
EntityQueryContext,
IEntityMetricsAdapter,
NoOpEntityMetricsAdapter,
} from '@expo/entity';
import { StubQueryContextProvider } from '@expo/entity-testing-utils';
import { describe, expect, it } from '@jest/globals';
import {
anyNumber,
anyString,
anything,
deepEqual,
instance,
mock,
resetCalls,
verify,
when,
} from 'ts-mockito';
import { OrderByOrdering } from '../../BasePostgresEntityDatabaseAdapter';
import { PaginationStrategy } from '../../PaginationStrategy';
import { PostgresEntityDatabaseAdapter } from '../../PostgresEntityDatabaseAdapter';
import {
TestEntity,
TestFields,
testEntityConfiguration,
} from '../../__tests__/fixtures/TestEntity';
import { EntityKnexDataManager } from '../EntityKnexDataManager';
describe(EntityKnexDataManager, () => {
it('loads by field equality conjunction and does not cache', async () => {
const queryContext = instance(mock<EntityQueryContext>());
const databaseAdapterMock = mock<PostgresEntityDatabaseAdapter<TestFields, 'customIdField'>>(
PostgresEntityDatabaseAdapter,
);
when(
databaseAdapterMock.fetchManyByFieldEqualityConjunctionAsync(
queryContext,
anything(),
anything(),
),
).thenResolve([
{
customIdField: '1',
testIndexedField: 'unique1',
stringField: 'hello',
intField: 1,
dateField: new Date(),
nullableField: null,
},
{
customIdField: '2',
testIndexedField: 'unique2',
stringField: 'hello',
intField: 1,
dateField: new Date(),
nullableField: null,
},
]);
const entityDataManager = new EntityKnexDataManager(
testEntityConfiguration,
instance(databaseAdapterMock),
new NoOpEntityMetricsAdapter(),
TestEntity.name,
);
const entityDatas = await entityDataManager.loadManyByFieldEqualityConjunctionAsync(
queryContext,
[
{
fieldName: 'stringField',
fieldValue: 'hello',
},
{
fieldName: 'intField',
fieldValue: 1,
},
],
{},
);
expect(entityDatas).toHaveLength(2);
verify(
databaseAdapterMock.fetchManyByFieldEqualityConjunctionAsync(
queryContext,
anything(),
anything(),
),
).once();
});
describe('metrics', () => {
it('records metrics appropriately outside of transactions', async () => {
const metricsAdapterMock = mock<IEntityMetricsAdapter>();
const metricsAdapter = instance(metricsAdapterMock);
const queryContext = new StubQueryContextProvider().getQueryContext();
const databaseAdapterMock = mock<PostgresEntityDatabaseAdapter<TestFields, 'customIdField'>>(
PostgresEntityDatabaseAdapter,
);
when(
databaseAdapterMock.fetchManyByFieldEqualityConjunctionAsync(
anything(),
anything(),
anything(),
),
).thenResolve([
{
customIdField: '1',
testIndexedField: 'unique1',
stringField: 'hello',
intField: 1,
dateField: new Date(),
nullableField: null,
},
]);
when(
databaseAdapterMock.fetchManyByRawWhereClauseAsync(
anything(),
anyString(),
anything(),
anything(),
),
).thenResolve([]);
const entityDataManager = new EntityKnexDataManager(
testEntityConfiguration,
instance(databaseAdapterMock),
metricsAdapter,
TestEntity.name,
);
await entityDataManager.loadManyByFieldEqualityConjunctionAsync(
queryContext,
[
{
fieldName: 'testIndexedField',
fieldValue: 'unique1',
},
],
{},
);
verify(
metricsAdapterMock.logDataManagerLoadEvent(
deepEqual({
type: EntityMetricsLoadType.LOAD_MANY_EQUALITY_CONJUNCTION,
isInTransaction: false,
entityClassName: TestEntity.name,
duration: anyNumber(),
count: 1,
}),
),
).once();
resetCalls(metricsAdapterMock);
await entityDataManager.loadManyByRawWhereClauseAsync(queryContext, '', [], {});
verify(
metricsAdapterMock.logDataManagerLoadEvent(
deepEqual({
type: EntityMetricsLoadType.LOAD_MANY_RAW,
isInTransaction: false,
entityClassName: TestEntity.name,
duration: anyNumber(),
count: 0,
}),
),
).once();
verify(metricsAdapterMock.incrementDataManagerLoadCount(anything())).never();
});
it('records metrics appropriately inside of transactions', async () => {
const metricsAdapterMock = mock<IEntityMetricsAdapter>();
const metricsAdapter = instance(metricsAdapterMock);
const databaseAdapterMock = mock<PostgresEntityDatabaseAdapter<TestFields, 'customIdField'>>(
PostgresEntityDatabaseAdapter,
);
when(
databaseAdapterMock.fetchManyByFieldEqualityConjunctionAsync(
anything(),
anything(),
anything(),
),
).thenResolve([
{
customIdField: '1',
testIndexedField: 'unique1',
stringField: 'hello',
intField: 1,
dateField: new Date(),
nullableField: null,
},
]);
when(
databaseAdapterMock.fetchManyByRawWhereClauseAsync(
anything(),
anyString(),
anything(),
anything(),
),
).thenResolve([]);
const entityDataManager = new EntityKnexDataManager(
testEntityConfiguration,
instance(databaseAdapterMock),
metricsAdapter,
TestEntity.name,
);
await new StubQueryContextProvider().runInTransactionAsync(async (queryContext) => {
await entityDataManager.loadManyByFieldEqualityConjunctionAsync(
queryContext,
[
{
fieldName: 'testIndexedField',
fieldValue: 'unique1',
},
],
{},
);
verify(
metricsAdapterMock.logDataManagerLoadEvent(
deepEqual({
type: EntityMetricsLoadType.LOAD_MANY_EQUALITY_CONJUNCTION,
isInTransaction: true,
entityClassName: TestEntity.name,
duration: anyNumber(),
count: 1,
}),
),
).once();
resetCalls(metricsAdapterMock);
await entityDataManager.loadManyByRawWhereClauseAsync(queryContext, '', [], {});
verify(
metricsAdapterMock.logDataManagerLoadEvent(
deepEqual({
type: EntityMetricsLoadType.LOAD_MANY_RAW,
isInTransaction: true,
entityClassName: TestEntity.name,
duration: anyNumber(),
count: 0,
}),
),
).once();
verify(metricsAdapterMock.incrementDataManagerLoadCount(anything())).never();
});
});
});
describe('pagination', () => {
describe('max page size validation', () => {
it('should throw when first exceeds maxPageSize', async () => {
const queryContext = instance(mock<EntityQueryContext>());
const databaseAdapterMock = mock<
PostgresEntityDatabaseAdapter<TestFields, 'customIdField'>
>(PostgresEntityDatabaseAdapter);
// Configure the adapter to return a maxPageSize of 100
when(databaseAdapterMock.paginationMaxPageSize).thenReturn(100);
const entityDataManager = new EntityKnexDataManager(
testEntityConfiguration,
instance(databaseAdapterMock),
new NoOpEntityMetricsAdapter(),
TestEntity.name,
);
await expect(
entityDataManager.loadPageAsync(queryContext, {
first: 101,
pagination: {
strategy: PaginationStrategy.STANDARD,
orderBy: [{ fieldName: 'customIdField', order: OrderByOrdering.ASCENDING }],
},
}),
).rejects.toThrow('first must not exceed maximum page size of 100');
});
it('should throw when last exceeds maxPageSize', async () => {
const queryContext = instance(mock<EntityQueryContext>());
const databaseAdapterMock = mock<
PostgresEntityDatabaseAdapter<TestFields, 'customIdField'>
>(PostgresEntityDatabaseAdapter);
// Configure the adapter to return a maxPageSize of 100
when(databaseAdapterMock.paginationMaxPageSize).thenReturn(100);
const entityDataManager = new EntityKnexDataManager(
testEntityConfiguration,
instance(databaseAdapterMock),
new NoOpEntityMetricsAdapter(),
TestEntity.name,
);
await expect(
entityDataManager.loadPageAsync(queryContext, {
last: 101,
pagination: {
strategy: PaginationStrategy.STANDARD,
orderBy: [{ fieldName: 'customIdField', order: OrderByOrdering.ASCENDING }],
},
}),
).rejects.toThrow('last must not exceed maximum page size of 100');
});
it('should allow first/last within maxPageSize', async () => {
const queryContext = instance(mock<EntityQueryContext>());
const databaseAdapterMock = mock<
PostgresEntityDatabaseAdapter<TestFields, 'customIdField'>
>(PostgresEntityDatabaseAdapter);
// Configure the adapter to return a maxPageSize of 100
when(databaseAdapterMock.paginationMaxPageSize).thenReturn(100);
when(
databaseAdapterMock.fetchManyBySQLFragmentAsync(queryContext, anything(), anything()),
).thenResolve([]);
const entityDataManager = new EntityKnexDataManager(
testEntityConfiguration,
instance(databaseAdapterMock),
new NoOpEntityMetricsAdapter(),
TestEntity.name,
);
// This should not throw
const result = await entityDataManager.loadPageAsync(queryContext, {
first: 100,
pagination: {
strategy: PaginationStrategy.STANDARD,
orderBy: [{ fieldName: 'customIdField', order: OrderByOrdering.ASCENDING }],
},
});
expect(result.edges).toEqual([]);
});
it('should allow pagination when maxPageSize is not configured', async () => {
const queryContext = instance(mock<EntityQueryContext>());
const databaseAdapterMock = mock<
PostgresEntityDatabaseAdapter<TestFields, 'customIdField'>
>(PostgresEntityDatabaseAdapter);
// Configure the adapter to return undefined for maxPageSize
when(databaseAdapterMock.paginationMaxPageSize).thenReturn(undefined);
when(
databaseAdapterMock.fetchManyBySQLFragmentAsync(queryContext, anything(), anything()),
).thenResolve([]);
const entityDataManager = new EntityKnexDataManager(
testEntityConfiguration,
instance(databaseAdapterMock),
new NoOpEntityMetricsAdapter(),
TestEntity.name,
);
// This should not throw even with a large page size
const result = await entityDataManager.loadPageAsync(queryContext, {
first: 10000,
pagination: {
strategy: PaginationStrategy.STANDARD,
orderBy: [{ fieldName: 'customIdField', order: OrderByOrdering.ASCENDING }],
},
});
expect(result.edges).toEqual([]);
});
});
});
});
@@ -0,0 +1,25 @@
import { describe, expect, it } from '@jest/globals';
import { computeIfAbsentInWeakMap } from '../weakMaps';
describe(computeIfAbsentInWeakMap, () => {
it('computes a value when absent', () => {
const map = new WeakMap<object, string>();
const key = {};
const blah = computeIfAbsentInWeakMap(map, key, () => 'world');
expect(blah).toEqual(map.get(key));
});
it('does not compute a value when already present', () => {
const map = new WeakMap<object, string>();
const key = {};
map.set(key, 'world');
let didCompute = false;
const blah = computeIfAbsentInWeakMap(map, key, () => {
didCompute = true;
return 'world2';
});
expect(blah).toEqual(map.get(key));
expect(didCompute).toBe(false);
});
});
@@ -0,0 +1,43 @@
import { EntityDatabaseAdapter, EntityTableDataCoordinator } from '@expo/entity';
import assert from 'assert';
import { BasePostgresEntityDatabaseAdapter } from '../BasePostgresEntityDatabaseAdapter';
import { EntityKnexDataManager } from './EntityKnexDataManager';
import { computeIfAbsentInWeakMap } from './weakMaps';
const knexDataManagerCache = new WeakMap<
EntityTableDataCoordinator<any, any>,
EntityKnexDataManager<any, any>
>();
export function getKnexDataManager<
TFields extends Record<string, any>,
TIDField extends keyof TFields,
>(
tableDataCoordinator: EntityTableDataCoordinator<TFields, TIDField>,
): EntityKnexDataManager<TFields, TIDField> {
return computeIfAbsentInWeakMap(
knexDataManagerCache,
tableDataCoordinator,
(coordinator) =>
new EntityKnexDataManager(
coordinator.entityConfiguration,
requireBasePostgresAdapter(coordinator.databaseAdapter),
coordinator.metricsAdapter,
coordinator.entityClassName,
),
);
}
function requireBasePostgresAdapter<
TFields extends Record<string, any>,
TIDField extends keyof TFields,
>(
databaseAdapter: EntityDatabaseAdapter<TFields, TIDField>,
): BasePostgresEntityDatabaseAdapter<TFields, TIDField> {
assert(
databaseAdapter instanceof BasePostgresEntityDatabaseAdapter,
`Cannot create KnexDataManager for EntityTableDataCoordinator with non-Postgres database adapter.`,
);
return databaseAdapter;
}
@@ -0,0 +1,59 @@
import {
EntityCompanion,
EntityPrivacyPolicy,
IEntityClass,
ReadonlyEntity,
ViewerContext,
} from '@expo/entity';
import { KnexEntityLoaderFactory } from '../KnexEntityLoaderFactory';
import { getKnexDataManager } from './getKnexDataManager';
import { computeIfAbsentInWeakMap } from './weakMaps';
const knexEntityLoaderFactoryCache = new WeakMap<
EntityCompanion<any, any, any, any, any, any>,
KnexEntityLoaderFactory<any, any, any, any, any, any>
>();
export function getKnexEntityLoaderFactory<
TFields extends Record<string, any>,
TIDField extends keyof NonNullable<Pick<TFields, TSelectedFields>>,
TViewerContext extends ViewerContext,
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,
): KnexEntityLoaderFactory<
TFields,
TIDField,
TViewerContext,
TEntity,
TPrivacyPolicy,
TSelectedFields
> {
return computeIfAbsentInWeakMap(
knexEntityLoaderFactoryCache,
viewerContext.entityCompanionProvider.getCompanionForEntity(entityClass),
(companion) =>
new KnexEntityLoaderFactory(
companion,
getKnexDataManager(companion.tableDataCoordinator),
companion.metricsAdapter,
),
);
}
@@ -0,0 +1,19 @@
/**
* If the specified key is not already associated with a value in this weak map, computes
* its value using the given mapping function and enters it into this map.
*
* @param map - map from which to get the key's value or compute and associate
* @param key - key for which to get the value or with which the computed value is to be associated
* @param mappingFunction - function to compute a value for key
*/
export const computeIfAbsentInWeakMap = <K extends WeakKey, V>(
map: WeakMap<K, V>,
key: K,
mappingFunction: (key: K) => V,
): V => {
if (!map.has(key)) {
const value = mappingFunction(key);
map.set(key, value);
}
return map.get(key)!;
};
@@ -0,0 +1,108 @@
import {
EntityPrivacyPolicy,
EntityQueryContext,
IEntityClass,
ReadonlyEntity,
ViewerContext,
} from '@expo/entity';
import { AuthorizationResultBasedKnexEntityLoader } from './AuthorizationResultBasedKnexEntityLoader';
import { EnforcingKnexEntityLoader } from './EnforcingKnexEntityLoader';
import { getKnexEntityLoaderFactory } from './internal/getKnexEntityLoaderFactory';
/**
* Vend knex loader for loading entities via non-data-loader methods in a given query context.
* @param entityClass - entity class to load
* @param viewerContext - viewer context of loading user
* @param queryContext - query context in which to perform the load
*/
export function knexLoader<
TMFields extends object,
TMIDField extends keyof NonNullable<Pick<TMFields, TMSelectedFields>>,
TMViewerContext extends ViewerContext,
TMEntity extends ReadonlyEntity<TMFields, TMIDField, TMViewerContext, TMSelectedFields>,
TMPrivacyPolicy extends EntityPrivacyPolicy<
TMFields,
TMIDField,
TMViewerContext,
TMEntity,
TMSelectedFields
>,
TMSelectedFields extends keyof TMFields = keyof TMFields,
>(
entityClass: IEntityClass<
TMFields,
TMIDField,
TMViewerContext,
TMEntity,
TMPrivacyPolicy,
TMSelectedFields
>,
viewerContext: TMViewerContext,
queryContext: EntityQueryContext = viewerContext
.getViewerScopedEntityCompanionForClass(entityClass)
.getQueryContextProvider()
.getQueryContext(),
): EnforcingKnexEntityLoader<
TMFields,
TMIDField,
TMViewerContext,
TMEntity,
TMPrivacyPolicy,
TMSelectedFields
> {
return getKnexEntityLoaderFactory(entityClass, viewerContext).forLoadEnforcing(
viewerContext,
queryContext,
{ previousValue: null, cascadingDeleteCause: null },
);
}
/**
* Vend knex loader for loading entities via non-data-loader methods in a given query context.
* Returns authorization results instead of throwing on authorization errors.
* @param entityClass - entity class to load
* @param viewerContext - viewer context of loading user
* @param queryContext - query context in which to perform the load
*/
export function knexLoaderWithAuthorizationResults<
TMFields extends object,
TMIDField extends keyof NonNullable<Pick<TMFields, TMSelectedFields>>,
TMViewerContext extends ViewerContext,
TMEntity extends ReadonlyEntity<TMFields, TMIDField, TMViewerContext, TMSelectedFields>,
TMPrivacyPolicy extends EntityPrivacyPolicy<
TMFields,
TMIDField,
TMViewerContext,
TMEntity,
TMSelectedFields
>,
TMSelectedFields extends keyof TMFields = keyof TMFields,
>(
entityClass: IEntityClass<
TMFields,
TMIDField,
TMViewerContext,
TMEntity,
TMPrivacyPolicy,
TMSelectedFields
>,
viewerContext: TMViewerContext,
queryContext: EntityQueryContext = viewerContext
.getViewerScopedEntityCompanionForClass(entityClass)
.getQueryContextProvider()
.getQueryContext(),
): AuthorizationResultBasedKnexEntityLoader<
TMFields,
TMIDField,
TMViewerContext,
TMEntity,
TMPrivacyPolicy,
TMSelectedFields
> {
return getKnexEntityLoaderFactory(entityClass, viewerContext).forLoad(
viewerContext,
queryContext,
{ previousValue: null, cascadingDeleteCause: null },
);
}
+3 -3
View File
@@ -22,13 +22,13 @@
"author": "Expo",
"license": "MIT",
"dependencies": {
"@apollo/server": "5.3.0",
"@apollo/server": "5.4.0",
"@as-integrations/koa": "1.1.1",
"@expo/entity": "workspace:^",
"@expo/entity-testing-utils": "workspace:^",
"@koa/bodyparser": "6.0.0",
"@koa/bodyparser": "6.1.0",
"@koa/cors": "5.0.0",
"@koa/router": "15.2.0",
"@koa/router": "15.3.0",
"graphql": "16.12.0",
"koa": "3.1.1"
},
@@ -3,10 +3,6 @@ import {
EntityDatabaseAdapter,
type FieldTransformerMap,
type IEntityDatabaseAdapterProvider,
OrderByOrdering,
type TableFieldMultiValueEqualityCondition,
type TableFieldSingleValueEqualityCondition,
type TableQuerySelectionModifiers,
getDatabaseFieldForEntityField,
} from '@expo/entity';
import { v4 as uuidv4 } from 'uuid';
@@ -51,81 +47,19 @@ class InMemoryDatabaseAdapter<
return [...results];
}
private static compareByOrderBys(
orderBys: {
columnName: string;
order: OrderByOrdering;
}[],
objectA: { [key: string]: any },
objectB: { [key: string]: any },
): 0 | 1 | -1 {
if (orderBys.length === 0) {
return 0;
}
const currentOrderBy = orderBys[0]!;
const aField = objectA[currentOrderBy.columnName];
const bField = objectB[currentOrderBy.columnName];
switch (currentOrderBy.order) {
case OrderByOrdering.DESCENDING:
return aField > bField
? -1
: aField < bField
? 1
: this.compareByOrderBys(orderBys.slice(1), objectA, objectB);
case OrderByOrdering.ASCENDING:
return bField > aField
? -1
: bField < aField
? 1
: this.compareByOrderBys(orderBys.slice(1), objectA, objectB);
}
}
protected async fetchManyByFieldEqualityConjunctionInternalAsync(
_queryInterface: any,
_tableName: string,
tableFieldSingleValueEqualityOperands: TableFieldSingleValueEqualityCondition[],
tableFieldMultiValueEqualityOperands: TableFieldMultiValueEqualityCondition[],
querySelectionModifiers: TableQuerySelectionModifiers,
): Promise<object[]> {
let filteredObjects = dbObjects;
for (const { tableField, tableValue } of tableFieldSingleValueEqualityOperands) {
filteredObjects = filteredObjects.filter((obj) => obj[tableField] === tableValue);
}
for (const { tableField, tableValues } of tableFieldMultiValueEqualityOperands) {
filteredObjects = filteredObjects.filter((obj) => tableValues.includes(obj[tableField]));
}
const orderBy = querySelectionModifiers.orderBy;
if (orderBy !== undefined) {
filteredObjects = filteredObjects.sort((a, b) =>
InMemoryDatabaseAdapter.compareByOrderBys(orderBy, a, b),
);
}
const offset = querySelectionModifiers.offset;
if (offset !== undefined) {
filteredObjects = filteredObjects.slice(offset);
}
const limit = querySelectionModifiers.limit;
if (limit !== undefined) {
filteredObjects = filteredObjects.slice(0, 0 + limit);
}
return filteredObjects;
}
protected fetchManyByRawWhereClauseInternalAsync(
_queryInterface: any,
_tableName: string,
_rawWhereClause: string,
_bindings: object | any[],
_querySelectionModifiers: TableQuerySelectionModifiers,
): Promise<object[]> {
throw new Error('Raw WHERE clauses not supported for InMemoryDatabaseAdapter');
protected override async fetchOneWhereInternalAsync(
queryInterface: any,
tableName: string,
tableColumns: readonly string[],
tableTuple: readonly any[],
): Promise<object | null> {
const results = await this.fetchManyWhereInternalAsync(
queryInterface,
tableName,
tableColumns,
[tableTuple],
);
return results[0] ?? null;
}
protected async insertInternalAsync(
@@ -54,7 +54,7 @@ router.post('/', async (ctx) => {
return;
}
const { title, body } = ctx.request.body;
const { title, body } = ctx.request.body as any;
const createResult = await NoteEntity.creatorWithAuthorizationResults(viewerContext)
.setField('userID', viewerContext.userID)
@@ -73,7 +73,7 @@ router.post('/', async (ctx) => {
router.put('/:id', async (ctx) => {
const viewerContext = ctx.state.viewerContext;
const { title, body } = ctx.request.body;
const { title, body } = ctx.request.body as any;
const noteLoadResult = await NoteEntity.loaderWithAuthorizationResults(
viewerContext,
@@ -172,11 +172,7 @@ class TestSecondaryLocalMemoryCacheLoader extends EntitySecondaryCacheLoader<
return null;
}
return nullthrows(
(
await this.entityLoader.loadManyByFieldEqualityConjunctionAsync([
{ fieldName: 'id', fieldValue: loadParams.id },
])
)[0],
(await this.entityLoader.loadManyByFieldEqualingAsync('id', loadParams.id))[0],
)
.enforceValue()
.getAllFields();
@@ -32,7 +32,7 @@
},
"devDependencies": {
"@jest/globals": "30.2.0",
"ioredis": "5.9.2",
"ioredis": "5.9.3",
"nullthrows": "1.1.1",
"typescript": "5.9.3"
}
@@ -44,11 +44,7 @@ class TestSecondaryRedisCacheLoader extends EntitySecondaryCacheLoader<
return null;
}
return nullthrows(
(
await this.entityLoader.loadManyByFieldEqualityConjunctionAsync([
{ fieldName: 'id', fieldValue: loadParams.id },
])
)[0],
(await this.entityLoader.loadManyByFieldEqualingAsync('id', loadParams.id))[0],
)
.enforceValue()
.getAllFields();
+1 -1
View File
@@ -37,7 +37,7 @@
"devDependencies": {
"@jest/globals": "30.2.0",
"@types/lodash": "4.17.23",
"@types/node": "24.10.9",
"@types/node": "24.10.13",
"typescript": "5.9.3"
}
}
@@ -3,11 +3,7 @@ import {
EntityDatabaseAdapter,
FieldTransformerMap,
IntField,
OrderByOrdering,
StringField,
TableFieldMultiValueEqualityCondition,
TableFieldSingleValueEqualityCondition,
TableQuerySelectionModifiers,
computeIfAbsent,
getDatabaseFieldForEntityField,
mapMap,
@@ -83,101 +79,19 @@ export class StubDatabaseAdapter<
return [...results];
}
private static compareByOrderBys(
orderBys: {
columnName: string;
order: OrderByOrdering;
}[],
objectA: { [key: string]: any },
objectB: { [key: string]: any },
): 0 | 1 | -1 {
if (orderBys.length === 0) {
return 0;
}
const currentOrderBy = orderBys[0]!;
const aField = objectA[currentOrderBy.columnName];
const bField = objectB[currentOrderBy.columnName];
switch (currentOrderBy.order) {
case OrderByOrdering.DESCENDING: {
// simulate NULLS FIRST for DESC
if (aField === null && bField === null) {
return 0;
} else if (aField === null) {
return -1;
} else if (bField === null) {
return 1;
}
return aField > bField
? -1
: aField < bField
? 1
: this.compareByOrderBys(orderBys.slice(1), objectA, objectB);
}
case OrderByOrdering.ASCENDING: {
// simulate NULLS LAST for ASC
if (aField === null && bField === null) {
return 0;
} else if (bField === null) {
return -1;
} else if (aField === null) {
return 1;
}
return bField > aField
? -1
: bField < aField
? 1
: this.compareByOrderBys(orderBys.slice(1), objectA, objectB);
}
}
}
protected async fetchManyByFieldEqualityConjunctionInternalAsync(
_queryInterface: any,
protected async fetchOneWhereInternalAsync(
queryInterface: any,
tableName: string,
tableFieldSingleValueEqualityOperands: TableFieldSingleValueEqualityCondition[],
tableFieldMultiValueEqualityOperands: TableFieldMultiValueEqualityCondition[],
querySelectionModifiers: TableQuerySelectionModifiers,
): Promise<object[]> {
let filteredObjects = this.getObjectCollectionForTable(tableName);
for (const { tableField, tableValue } of tableFieldSingleValueEqualityOperands) {
filteredObjects = filteredObjects.filter((obj) => obj[tableField] === tableValue);
}
for (const { tableField, tableValues } of tableFieldMultiValueEqualityOperands) {
filteredObjects = filteredObjects.filter((obj) => tableValues.includes(obj[tableField]));
}
const orderBy = querySelectionModifiers.orderBy;
if (orderBy !== undefined) {
filteredObjects = filteredObjects.sort((a, b) =>
StubDatabaseAdapter.compareByOrderBys(orderBy, a, b),
);
}
const offset = querySelectionModifiers.offset;
if (offset !== undefined) {
filteredObjects = filteredObjects.slice(offset);
}
const limit = querySelectionModifiers.limit;
if (limit !== undefined) {
filteredObjects = filteredObjects.slice(0, 0 + limit);
}
return filteredObjects;
}
protected fetchManyByRawWhereClauseInternalAsync(
_queryInterface: any,
_tableName: string,
_rawWhereClause: string,
_bindings: object | any[],
_querySelectionModifiers: TableQuerySelectionModifiers,
): Promise<object[]> {
throw new Error('Raw WHERE clauses not supported for StubDatabaseAdapter');
tableColumns: readonly string[],
tableTuple: readonly any[],
): Promise<object | null> {
const results = await this.fetchManyWhereInternalAsync(
queryInterface,
tableName,
tableColumns,
[tableTuple],
);
return results[0] ?? null;
}
private generateRandomID(): any {
@@ -2,7 +2,6 @@ import {
CompositeFieldHolder,
CompositeFieldValueHolder,
EntityQueryContext,
OrderByOrdering,
SingleFieldHolder,
SingleFieldValueHolder,
} from '@expo/entity';
@@ -125,8 +124,8 @@ describe(StubDatabaseAdapter, () => {
});
});
describe('fetchManyByFieldEqualityConjunctionAsync', () => {
it('supports conjuntions and query modifiers', async () => {
describe('fetchOneWhereAsync', () => {
it('fetches one where single', async () => {
const queryContext = instance(mock(EntityQueryContext));
const databaseAdapter = new StubDatabaseAdapter<TestFields, 'customIdField'>(
testEntityConfiguration,
@@ -139,8 +138,8 @@ describe(StubDatabaseAdapter, () => {
{
customIdField: 'hello',
testIndexedField: 'h1',
intField: 3,
stringField: 'a',
intField: 5,
stringField: 'huh',
dateField: new Date(),
nullableField: null,
},
@@ -148,15 +147,7 @@ describe(StubDatabaseAdapter, () => {
customIdField: 'world',
testIndexedField: 'h2',
intField: 3,
stringField: 'b',
dateField: new Date(),
nullableField: null,
},
{
customIdField: 'world',
testIndexedField: 'h2',
intField: 3,
stringField: 'c',
stringField: 'huh',
dateField: new Date(),
nullableField: null,
},
@@ -166,186 +157,72 @@ describe(StubDatabaseAdapter, () => {
),
);
const results = await databaseAdapter.fetchManyByFieldEqualityConjunctionAsync(
const result = await databaseAdapter.fetchOneWhereAsync(
queryContext,
[
{
fieldName: 'customIdField',
fieldValues: ['hello', 'world'],
},
{
fieldName: 'intField',
fieldValue: 3,
},
],
{
limit: 2,
offset: 1,
orderBy: [
{
fieldName: 'stringField',
order: OrderByOrdering.DESCENDING,
},
],
},
new SingleFieldHolder('stringField'),
new SingleFieldValueHolder('huh'),
);
expect(results).toHaveLength(2);
expect(results.map((e) => e.stringField)).toEqual(['b', 'a']);
expect(result).toMatchObject({
stringField: 'huh',
});
});
it('supports multiple order bys', async () => {
const queryContext = instance(mock(EntityQueryContext));
const databaseAdapter = new StubDatabaseAdapter<TestFields, 'customIdField'>(
testEntityConfiguration,
StubDatabaseAdapter.convertFieldObjectsToDataStore(
testEntityConfiguration,
new Map([
[
testEntityConfiguration.tableName,
[
{
customIdField: 'hello',
testIndexedField: 'h1',
intField: 3,
stringField: 'a',
dateField: new Date(),
nullableField: null,
},
{
customIdField: 'world',
testIndexedField: 'h2',
intField: 3,
stringField: 'b',
dateField: new Date(),
nullableField: null,
},
{
customIdField: 'world',
testIndexedField: 'h2',
intField: 3,
stringField: 'c',
dateField: new Date(),
nullableField: null,
},
],
],
]),
),
);
const results = await databaseAdapter.fetchManyByFieldEqualityConjunctionAsync(
queryContext,
[
{
fieldName: 'intField',
fieldValue: 3,
},
],
{
orderBy: [
{
fieldName: 'intField',
order: OrderByOrdering.DESCENDING,
},
{
fieldName: 'stringField',
order: OrderByOrdering.DESCENDING,
},
],
},
);
expect(results).toHaveLength(3);
expect(results.map((e) => e.stringField)).toEqual(['c', 'b', 'a']);
});
it('supports null field values', async () => {
const queryContext = instance(mock(EntityQueryContext));
const databaseAdapter = new StubDatabaseAdapter<TestFields, 'customIdField'>(
testEntityConfiguration,
StubDatabaseAdapter.convertFieldObjectsToDataStore(
testEntityConfiguration,
new Map([
[
testEntityConfiguration.tableName,
[
{
customIdField: '1',
testIndexedField: 'h1',
intField: 1,
stringField: 'a',
dateField: new Date(),
nullableField: 'a',
},
{
customIdField: '2',
testIndexedField: 'h2',
intField: 2,
stringField: 'a',
dateField: new Date(),
nullableField: 'b',
},
{
customIdField: '3',
testIndexedField: 'h3',
intField: 3,
stringField: 'a',
dateField: new Date(),
nullableField: null,
},
{
customIdField: '4',
testIndexedField: 'h4',
intField: 4,
stringField: 'b',
dateField: new Date(),
nullableField: null,
},
],
],
]),
),
);
const results = await databaseAdapter.fetchManyByFieldEqualityConjunctionAsync(
queryContext,
[{ fieldName: 'nullableField', fieldValue: null }],
{},
);
expect(results).toHaveLength(2);
expect(results[0]!.nullableField).toBeNull();
const results2 = await databaseAdapter.fetchManyByFieldEqualityConjunctionAsync(
queryContext,
[
{ fieldName: 'nullableField', fieldValues: ['a', null] },
{ fieldName: 'stringField', fieldValue: 'a' },
],
{
orderBy: [
{
fieldName: 'nullableField',
order: OrderByOrdering.DESCENDING,
},
],
},
);
expect(results2).toHaveLength(2);
expect(results2.map((e) => e.nullableField)).toEqual([null, 'a']);
});
});
describe('fetchManyByRawWhereClauseAsync', () => {
it('throws because it is unsupported', async () => {
it('returns null when no record found', async () => {
const queryContext = instance(mock(EntityQueryContext));
const databaseAdapter = new StubDatabaseAdapter<TestFields, 'customIdField'>(
testEntityConfiguration,
new Map(),
);
await expect(
databaseAdapter.fetchManyByRawWhereClauseAsync(queryContext, '', [], {}),
).rejects.toThrow();
const result = await databaseAdapter.fetchOneWhereAsync(
queryContext,
new SingleFieldHolder('stringField'),
new SingleFieldValueHolder('huh'),
);
expect(result).toBeNull();
});
it('fetches one where composite', async () => {
const queryContext = instance(mock(EntityQueryContext));
const databaseAdapter = new StubDatabaseAdapter<TestFields, 'customIdField'>(
testEntityConfiguration,
StubDatabaseAdapter.convertFieldObjectsToDataStore(
testEntityConfiguration,
new Map([
[
testEntityConfiguration.tableName,
[
{
customIdField: 'hello',
testIndexedField: 'h1',
intField: 5,
stringField: 'huh',
dateField: new Date(),
nullableField: null,
},
{
customIdField: 'world',
testIndexedField: 'h2',
intField: 5,
stringField: 'huh',
dateField: new Date(),
nullableField: null,
},
],
],
]),
),
);
const result = await databaseAdapter.fetchOneWhereAsync(
queryContext,
new CompositeFieldHolder<TestFields, 'customIdField'>(['stringField', 'intField']),
new CompositeFieldValueHolder({ stringField: 'huh', intField: 5 }),
);
expect(result).toMatchObject({
stringField: 'huh',
intField: 5,
});
});
});
@@ -502,90 +379,6 @@ describe(StubDatabaseAdapter, () => {
'Unsupported ID type for StubDatabaseAdapter: DateField',
);
});
describe('compareByOrderBys', () => {
describe('comparison', () => {
it.each([
// 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
[OrderByOrdering.DESCENDING, null, null, 0],
[OrderByOrdering.ASCENDING, null, null, 0],
// nulls compare with -1
[OrderByOrdering.DESCENDING, null, -1, -1],
[OrderByOrdering.ASCENDING, null, -1, 1],
[OrderByOrdering.DESCENDING, -1, null, 1],
[OrderByOrdering.ASCENDING, -1, null, -1],
// basic compares
[OrderByOrdering.ASCENDING, 'a', 'b', -1],
[OrderByOrdering.ASCENDING, 'b', 'a', 1],
[OrderByOrdering.DESCENDING, 'a', 'b', 1],
[OrderByOrdering.DESCENDING, 'b', 'a', -1],
])('case (%p; %p; %p)', (order, v1, v2, expectedResult) => {
expect(
StubDatabaseAdapter['compareByOrderBys'](
[
{
columnName: 'hello',
order,
},
],
{
hello: v1,
},
{
hello: v2,
},
),
).toEqual(expectedResult);
});
it('works for empty', () => {
expect(
StubDatabaseAdapter['compareByOrderBys'](
[],
{
hello: 'test',
},
{
hello: 'blah',
},
),
).toEqual(0);
});
});
describe('recursing', () => {
expect(
StubDatabaseAdapter['compareByOrderBys'](
[
{
columnName: 'hello',
order: OrderByOrdering.ASCENDING,
},
{
columnName: 'world',
order: OrderByOrdering.ASCENDING,
},
],
{
hello: 'a',
world: 1,
},
{
hello: 'a',
world: 2,
},
),
).toEqual(-1);
});
});
});
/**
+1 -1
View File
@@ -35,7 +35,7 @@
"@jest/globals": "30.2.0",
"@types/invariant": "2.2.37",
"@types/lodash": "4.17.23",
"@types/node": "24.10.9",
"@types/node": "24.10.13",
"lodash": "4.17.23",
"ts-mockito": "2.6.1",
"typescript": "5.9.3",
@@ -8,18 +8,12 @@ import {
EntityCompositeFieldValue,
EntityConfiguration,
} from './EntityConfiguration';
import {
FieldEqualityCondition,
isSingleValueFieldEqualityCondition,
QuerySelectionModifiers,
QuerySelectionModifiersWithOrderByRaw,
} from './EntityDatabaseAdapter';
import { EntityLoaderUtils } from './EntityLoaderUtils';
import { EntityConstructionUtils } from './EntityConstructionUtils';
import { EntityInvalidationUtils } from './EntityInvalidationUtils';
import { EntityPrivacyPolicy } from './EntityPrivacyPolicy';
import { EntityQueryContext } from './EntityQueryContext';
import { ReadonlyEntity } from './ReadonlyEntity';
import { ViewerContext } from './ViewerContext';
import { EntityInvalidFieldValueError } from './errors/EntityInvalidFieldValueError';
import { EntityNotFoundError } from './errors/EntityNotFoundError';
import { CompositeFieldHolder, CompositeFieldValueHolder } from './internal/CompositeFieldHolder';
import { CompositeFieldValueMap } from './internal/CompositeFieldValueMap';
@@ -62,7 +56,15 @@ export class AuthorizationResultBasedEntityLoader<
>,
private readonly dataManager: EntityDataManager<TFields, TIDField>,
protected readonly metricsAdapter: IEntityMetricsAdapter,
public readonly utils: EntityLoaderUtils<
public readonly invalidationUtils: EntityInvalidationUtils<
TFields,
TIDField,
TViewerContext,
TEntity,
TPrivacyPolicy,
TSelectedFields
>,
public readonly constructionUtils: EntityConstructionUtils<
TFields,
TIDField,
TViewerContext,
@@ -96,7 +98,9 @@ export class AuthorizationResultBasedEntityLoader<
loadValuesToFieldObjects,
(loadValue) => loadValue.fieldValue,
);
return await this.utils.constructAndAuthorizeEntitiesAsync(fieldValuesToFieldObjects);
return await this.constructionUtils.constructAndAuthorizeEntitiesAsync(
fieldValuesToFieldObjects,
);
}
/**
@@ -141,6 +145,41 @@ export class AuthorizationResultBasedEntityLoader<
return entityResultsForFieldValue;
}
/**
* Load one entity where fieldName equals fieldValue, or null if no entity exists matching the condition.
* Not cached or coalesced, and not guaranteed to be deterministic if multiple entities match the condition.
*
* Only used when evaluating EntityEdgeDeletionAuthorizationInferenceBehavior.ONE_IMPLIES_ALL.
*
* @param fieldName - entity field being queried
* @param fieldValue - fieldName field value being queried
* @returns entity result matching the query for fieldValue. Returns null if no entity matches the query.
* @throws EntityNotAuthorizedError when viewer is not authorized to view the returned entity
*
* @internal
*/
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore -- this method is used in EntityPrivacyUtils, but is not intended to be part of the public API of this class, so it is marked as private.
private async loadOneByFieldEqualingAsync<N extends keyof Pick<TFields, TSelectedFields>>(
fieldName: N,
fieldValue: NonNullable<TFields[N]>,
): Promise<Result<TEntity> | null> {
const { loadKey, loadValue } = this.validateFieldAndValueAndConvertToHolders(
fieldName,
fieldValue,
);
const result = await this.dataManager.loadOneEqualingAsync(
this.queryContext,
loadKey,
loadValue,
);
if (!result) {
return null;
}
return await this.constructionUtils.constructAndAuthorizeEntityAsync(result);
}
/**
* Authorization-result-based version of the EnforcingEntityLoader method by the same name.
* @returns array of entity results that match the query for compositeFieldValue, where result error can be UnauthorizedError
@@ -265,79 +304,6 @@ export class AuthorizationResultBasedEntityLoader<
});
}
/**
* Authorization-result-based version of the EnforcingEntityLoader method by the same name.
* @returns the first entity results that matches the query, where result error can be
* UnauthorizedError
*/
async loadFirstByFieldEqualityConjunctionAsync<N extends keyof Pick<TFields, TSelectedFields>>(
fieldEqualityOperands: FieldEqualityCondition<TFields, N>[],
querySelectionModifiers: Omit<QuerySelectionModifiers<TFields>, 'limit'> &
Required<Pick<QuerySelectionModifiers<TFields>, 'orderBy'>>,
): Promise<Result<TEntity> | null> {
const results = await this.loadManyByFieldEqualityConjunctionAsync(fieldEqualityOperands, {
...querySelectionModifiers,
limit: 1,
});
return results[0] ?? null;
}
/**
* Authorization-result-based version of the EnforcingEntityLoader method by the same name.
* @returns array of entity results that match the query, where result error can be UnauthorizedError
*/
async loadManyByFieldEqualityConjunctionAsync<N extends keyof Pick<TFields, TSelectedFields>>(
fieldEqualityOperands: FieldEqualityCondition<TFields, N>[],
querySelectionModifiers: QuerySelectionModifiers<TFields> = {},
): Promise<readonly Result<TEntity>[]> {
for (const fieldEqualityOperand of fieldEqualityOperands) {
const fieldValues = isSingleValueFieldEqualityCondition(fieldEqualityOperand)
? [fieldEqualityOperand.fieldValue]
: fieldEqualityOperand.fieldValues;
this.validateFieldAndValues(fieldEqualityOperand.fieldName, fieldValues);
}
const fieldObjects = await this.dataManager.loadManyByFieldEqualityConjunctionAsync(
this.queryContext,
fieldEqualityOperands,
querySelectionModifiers,
);
return await this.utils.constructAndAuthorizeEntitiesArrayAsync(fieldObjects);
}
/**
* Authorization-result-based version of the EnforcingEntityLoader 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
*/
async loadManyByRawWhereClauseAsync(
rawWhereClause: string,
bindings: any[] | object,
querySelectionModifiers: QuerySelectionModifiersWithOrderByRaw<TFields> = {},
): Promise<readonly Result<TEntity>[]> {
const fieldObjects = await this.dataManager.loadManyByRawWhereClauseAsync(
this.queryContext,
rawWhereClause,
bindings,
querySelectionModifiers,
);
return await this.utils.constructAndAuthorizeEntitiesArrayAsync(fieldObjects);
}
private validateFieldAndValues<N extends keyof Pick<TFields, TSelectedFields>>(
fieldName: N,
fieldValues: readonly TFields[N][],
): void {
const fieldDefinition = this.entityConfiguration.schema.get(fieldName);
invariant(fieldDefinition, `must have field definition for field = ${String(fieldName)}`);
for (const fieldValue of fieldValues) {
const isInputValid = fieldDefinition.validateInputValue(fieldValue);
if (!isInputValid) {
throw new EntityInvalidFieldValueError(this.entityClass, fieldName, fieldValue);
}
}
}
private validateFieldAndValuesAndConvertToHolders<N extends keyof Pick<TFields, TSelectedFields>>(
fieldName: N,
fieldValues: readonly NonNullable<TFields[N]>[],
@@ -345,7 +311,7 @@ export class AuthorizationResultBasedEntityLoader<
loadKey: SingleFieldHolder<TFields, TIDField, N>;
loadValues: readonly SingleFieldValueHolder<TFields, N>[];
} {
this.validateFieldAndValues(fieldName, fieldValues);
this.constructionUtils.validateFieldAndValues(fieldName, fieldValues);
return {
loadKey: new SingleFieldHolder<TFields, TIDField, N>(fieldName),
@@ -355,6 +321,21 @@ export class AuthorizationResultBasedEntityLoader<
};
}
private validateFieldAndValueAndConvertToHolders<N extends keyof Pick<TFields, TSelectedFields>>(
fieldName: N,
fieldValue: NonNullable<TFields[N]>,
): {
loadKey: SingleFieldHolder<TFields, TIDField, N>;
loadValue: SingleFieldValueHolder<TFields, N>;
} {
this.constructionUtils.validateFieldAndValues(fieldName, [fieldValue]);
return {
loadKey: new SingleFieldHolder<TFields, TIDField, N>(fieldName),
loadValue: new SingleFieldValueHolder<TFields, N>(fieldValue),
};
}
private validateCompositeFieldAndValuesAndConvertToHolders<
N extends EntityCompositeField<Pick<TFields, TSelectedFields>>,
>(
@@ -388,7 +369,7 @@ export class AuthorizationResultBasedEntityLoader<
);
for (const field of compositeField) {
const fieldValue = compositeFieldValueHolder.compositeFieldValue[field];
this.validateFieldAndValues(field, [fieldValue]);
this.constructionUtils.validateFieldAndValues(field, [fieldValue]);
}
}
@@ -408,7 +389,7 @@ export class AuthorizationResultBasedEntityLoader<
Array.from(map.entries()).map(async ([compositeFieldValueHolder, fieldObjects]) => {
return [
compositeFieldValueHolder,
await this.utils.constructAndAuthorizeEntitiesArrayAsync(fieldObjects),
await this.constructionUtils.constructAndAuthorizeEntitiesArrayAsync(fieldObjects),
];
}),
),
@@ -334,7 +334,7 @@ export class AuthorizationResultBasedCreateMutator<
cascadingDeleteCause: null,
});
const temporaryEntityForPrivacyCheck = entityLoader.utils.constructEntity({
const temporaryEntityForPrivacyCheck = entityLoader.constructionUtils.constructEntity({
[this.entityConfiguration.idField]: '00000000-0000-0000-0000-000000000000', // zero UUID
...this.fieldsForEntity,
} as unknown as TFields);
@@ -376,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 () => {
entityLoader.utils.invalidateFieldsForTransaction(queryContext, insertResult);
await entityLoader.utils.invalidateFieldsAsync(insertResult);
entityLoader.invalidationUtils.invalidateFieldsForTransaction(queryContext, insertResult);
await entityLoader.invalidationUtils.invalidateFieldsAsync(insertResult);
});
entityLoader.utils.invalidateFieldsForTransaction(queryContext, insertResult);
entityLoader.invalidationUtils.invalidateFieldsForTransaction(queryContext, insertResult);
const unauthorizedEntityAfterInsert = entityLoader.utils.constructEntity(insertResult);
const unauthorizedEntityAfterInsert =
entityLoader.constructionUtils.constructEntity(insertResult);
const newEntity = await enforceAsyncResult(
entityLoader.loadByIDAsync(unauthorizedEntityAfterInsert.getID()),
);
@@ -542,7 +543,9 @@ export class AuthorizationResultBasedUpdateMutator<
cascadingDeleteCause: this.cascadingDeleteCause,
});
const entityAboutToBeUpdated = entityLoader.utils.constructEntity(this.fieldsForEntity);
const entityAboutToBeUpdated = entityLoader.constructionUtils.constructEntity(
this.fieldsForEntity,
);
const authorizeUpdateResult = await asyncResult(
this.privacyPolicy.authorizeUpdateAsync(
this.viewerContext,
@@ -606,22 +609,30 @@ export class AuthorizationResultBasedUpdateMutator<
// version of the entity.
queryContext.appendPostCommitInvalidationCallback(async () => {
entityLoader.utils.invalidateFieldsForTransaction(
entityLoader.invalidationUtils.invalidateFieldsForTransaction(
queryContext,
this.originalEntity.getAllDatabaseFields(),
);
entityLoader.utils.invalidateFieldsForTransaction(queryContext, this.fieldsForEntity);
entityLoader.invalidationUtils.invalidateFieldsForTransaction(
queryContext,
this.fieldsForEntity,
);
await Promise.all([
entityLoader.utils.invalidateFieldsAsync(this.originalEntity.getAllDatabaseFields()),
entityLoader.utils.invalidateFieldsAsync(this.fieldsForEntity),
entityLoader.invalidationUtils.invalidateFieldsAsync(
this.originalEntity.getAllDatabaseFields(),
),
entityLoader.invalidationUtils.invalidateFieldsAsync(this.fieldsForEntity),
]);
});
entityLoader.utils.invalidateFieldsForTransaction(
entityLoader.invalidationUtils.invalidateFieldsForTransaction(
queryContext,
this.originalEntity.getAllDatabaseFields(),
);
entityLoader.utils.invalidateFieldsForTransaction(queryContext, this.fieldsForEntity);
entityLoader.invalidationUtils.invalidateFieldsForTransaction(
queryContext,
this.fieldsForEntity,
);
const updatedEntity = await enforceAsyncResult(
entityLoader.loadByIDAsync(entityAboutToBeUpdated.getID()),
@@ -847,13 +858,15 @@ export class AuthorizationResultBasedDeleteMutator<
// Invalidate all caches for the entity so that any previously-cached loads
// are removed from the caches.
queryContext.appendPostCommitInvalidationCallback(async () => {
entityLoader.utils.invalidateFieldsForTransaction(
entityLoader.invalidationUtils.invalidateFieldsForTransaction(
queryContext,
this.entity.getAllDatabaseFields(),
);
await entityLoader.utils.invalidateFieldsAsync(this.entity.getAllDatabaseFields());
await entityLoader.invalidationUtils.invalidateFieldsAsync(
this.entity.getAllDatabaseFields(),
);
});
entityLoader.utils.invalidateFieldsForTransaction(
entityLoader.invalidationUtils.invalidateFieldsForTransaction(
queryContext,
this.entity.getAllDatabaseFields(),
);
@@ -1,10 +1,5 @@
import { AuthorizationResultBasedEntityLoader } from './AuthorizationResultBasedEntityLoader';
import { EntityCompositeField, EntityCompositeFieldValue } from './EntityConfiguration';
import {
FieldEqualityCondition,
QuerySelectionModifiers,
QuerySelectionModifiersWithOrderByRaw,
} from './EntityDatabaseAdapter';
import { EntityPrivacyPolicy } from './EntityPrivacyPolicy';
import { ReadonlyEntity } from './ReadonlyEntity';
import { ViewerContext } from './ViewerContext';
@@ -219,93 +214,4 @@ export class EnforcingEntityLoader<
const entityResults = await this.entityLoader.loadManyByIDsNullableAsync(ids);
return mapMap(entityResults, (result) => result?.enforceValue() ?? null);
}
/**
* Loads the first entity matching the selection constructed from the conjunction of specified
* operands, or null if no matching entity exists. Entities loaded using this method are not
* batched or cached.
*
* This is a convenience method for {@link loadManyByFieldEqualityConjunctionAsync}. However, the
* `orderBy` option must be specified to define what "first" means. If ordering doesn't matter,
* explicitly pass in an empty array.
*
* @param fieldEqualityOperands - list of field equality selection operand specifications
* @param querySelectionModifiers - orderBy and optional offset for the query
* @returns the first entity that matches the query or null if no entity matches the query
* @throws EntityNotAuthorizedError when viewer is not authorized to view the returned entity
*/
async loadFirstByFieldEqualityConjunctionAsync<N extends keyof Pick<TFields, TSelectedFields>>(
fieldEqualityOperands: FieldEqualityCondition<TFields, N>[],
querySelectionModifiers: Omit<QuerySelectionModifiers<TFields>, 'limit'> &
Required<Pick<QuerySelectionModifiers<TFields>, 'orderBy'>>,
): Promise<TEntity | null> {
const entityResult = await this.entityLoader.loadFirstByFieldEqualityConjunctionAsync(
fieldEqualityOperands,
querySelectionModifiers,
);
return entityResult ? entityResult.enforceValue() : null;
}
/**
* Loads many entities matching the selection constructed from the conjunction of specified operands.
* Entities loaded using this method are not batched or cached.
*
* @example
* fieldEqualityOperands:
* `[{fieldName: 'hello', fieldValue: 1}, {fieldName: 'world', fieldValues: [2, 3]}]`
* Entities returned with a SQL EntityDatabaseAdapter:
* `WHERE hello = 1 AND world = ANY({2, 3})`
*
* @param fieldEqualityOperands - list of field equality selection operand specifications
* @param querySelectionModifiers - limit, offset, and orderBy for the query
* @returns array of entities that match the query
* @throws EntityNotAuthorizedError when viewer is not authorized to view one or more of the returned entities
*/
async loadManyByFieldEqualityConjunctionAsync<N extends keyof Pick<TFields, TSelectedFields>>(
fieldEqualityOperands: FieldEqualityCondition<TFields, N>[],
querySelectionModifiers: QuerySelectionModifiers<TFields> = {},
): Promise<readonly TEntity[]> {
const entityResults = await this.entityLoader.loadManyByFieldEqualityConjunctionAsync(
fieldEqualityOperands,
querySelectionModifiers,
);
return entityResults.map((result) => result.enforceValue());
}
/**
* Loads many entities matching the raw WHERE clause. Corresponds to the knex `whereRaw` argument format.
*
* @remarks
* Important notes:
* - Fields in clause are database column names instead of transformed entity field names.
* - Entities loaded using this method are not batched or cached.
* - Not all database adapters implement the ability to execute this method of fetching entities.
*
* @example
* rawWhereClause: `id = ?`
* bindings: `[1]`
* Entites returned `WHERE id = 1`
*
* http://knexjs.org/#Builder-whereRaw
* http://knexjs.org/#Raw-Bindings
*
* @param rawWhereClause - parameterized SQL WHERE clause with positional binding placeholders or named binding placeholders
* @param bindings - array of positional bindings or object of named bindings
* @param querySelectionModifiers - limit, offset, orderBy, and orderByRaw for the query
* @returns array of entities that match the query
* @throws EntityNotAuthorizedError when viewer is not authorized to view one or more of the returned entities
* @throws Error when rawWhereClause or bindings are invalid
*/
async loadManyByRawWhereClauseAsync(
rawWhereClause: string,
bindings: any[] | object,
querySelectionModifiers: QuerySelectionModifiersWithOrderByRaw<TFields> = {},
): Promise<readonly TEntity[]> {
const entityResults = await this.entityLoader.loadManyByRawWhereClauseAsync(
rawWhereClause,
bindings,
querySelectionModifiers,
);
return entityResults.map((result) => result.enforceValue());
}
}
+2 -2
View File
@@ -59,8 +59,8 @@ export class EntityCompanion<
TPrivacyPolicy,
TSelectedFields
>,
private readonly tableDataCoordinator: EntityTableDataCoordinator<TFields, TIDField>,
private readonly metricsAdapter: IEntityMetricsAdapter,
public readonly tableDataCoordinator: EntityTableDataCoordinator<TFields, TIDField>,
public readonly metricsAdapter: IEntityMetricsAdapter,
) {
this.privacyPolicy = new entityCompanionDefinition.privacyPolicyClass();
this.entityLoaderFactory = new EntityLoaderFactory<
+8 -5
View File
@@ -67,7 +67,9 @@ export class CompositeFieldInfo<
keyDefinition.compositeField.length === new Set(keyDefinition.compositeField).size,
'Composite field must have unique sub-fields',
);
const compositeFieldHolder = new CompositeFieldHolder(keyDefinition.compositeField);
const compositeFieldHolder = new CompositeFieldHolder<TFields, TIDField>(
keyDefinition.compositeField,
);
return [
compositeFieldHolder.serialize(),
{ compositeFieldHolder, cache: keyDefinition.cache ?? false },
@@ -79,8 +81,9 @@ export class CompositeFieldInfo<
public getCompositeFieldHolderForCompositeField(
compositeField: EntityCompositeField<TFields>,
): CompositeFieldHolder<TFields, TIDField> | undefined {
return this.compositeFieldInfoMap.get(new CompositeFieldHolder(compositeField).serialize())
?.compositeFieldHolder;
return this.compositeFieldInfoMap.get(
new CompositeFieldHolder<TFields, TIDField>(compositeField).serialize(),
)?.compositeFieldHolder;
}
public getAllCompositeFieldHolders(): readonly CompositeFieldHolder<TFields, TIDField>[] {
@@ -89,7 +92,7 @@ export class CompositeFieldInfo<
public canCacheCompositeField(compositeField: EntityCompositeField<TFields>): boolean {
const compositeFieldInfo = this.compositeFieldInfoMap.get(
new CompositeFieldHolder(compositeField).serialize(),
new CompositeFieldHolder<TFields, TIDField>(compositeField).serialize(),
);
invariant(
compositeFieldInfo,
@@ -107,7 +110,7 @@ export class EntityConfiguration<
TFields extends Record<string, any>,
TIDField extends keyof TFields,
> {
readonly idField: keyof TFields;
readonly idField: TIDField;
readonly tableName: string;
readonly cacheableKeys: ReadonlySet<keyof TFields>;
readonly compositeFieldInfo: CompositeFieldInfo<TFields, TIDField>;
@@ -0,0 +1,168 @@
import { Result, asyncResult, result } from '@expo/results';
import invariant from 'invariant';
import nullthrows from 'nullthrows';
import { IEntityClass } from './Entity';
import { EntityConfiguration } from './EntityConfiguration';
import { EntityPrivacyPolicy, EntityPrivacyPolicyEvaluationContext } from './EntityPrivacyPolicy';
import { EntityQueryContext } from './EntityQueryContext';
import { ReadonlyEntity } from './ReadonlyEntity';
import { ViewerContext } from './ViewerContext';
import { pick } from './entityUtils';
import { EntityInvalidFieldValueError } from './errors/EntityInvalidFieldValueError';
import { IEntityMetricsAdapter } from './metrics/IEntityMetricsAdapter';
import { mapMapAsync } from './utils/collections/maps';
/**
* Common entity loader utilities for entity construction and authorization.
* Methods are exposed publicly since in rare cases they may need to be called manually.
*/
export class EntityConstructionUtils<
TFields extends Record<string, any>,
TIDField extends keyof NonNullable<Pick<TFields, TSelectedFields>>,
TViewerContext extends ViewerContext,
TEntity extends ReadonlyEntity<TFields, TIDField, TViewerContext, TSelectedFields>,
TPrivacyPolicy extends EntityPrivacyPolicy<
TFields,
TIDField,
TViewerContext,
TEntity,
TSelectedFields
>,
TSelectedFields extends keyof TFields,
> {
constructor(
private readonly viewerContext: TViewerContext,
private readonly queryContext: EntityQueryContext,
private readonly privacyPolicyEvaluationContext: EntityPrivacyPolicyEvaluationContext<
TFields,
TIDField,
TViewerContext,
TEntity,
TSelectedFields
>,
private readonly entityConfiguration: EntityConfiguration<TFields, TIDField>,
private readonly entityClass: IEntityClass<
TFields,
TIDField,
TViewerContext,
TEntity,
TPrivacyPolicy,
TSelectedFields
>,
private readonly entitySelectedFields: TSelectedFields[] | undefined,
private readonly privacyPolicy: TPrivacyPolicy,
protected readonly metricsAdapter: IEntityMetricsAdapter,
) {}
/**
* Construct an entity from a fields object (applying field selection if applicable),
* checking that the ID field is specified.
*
* @param fieldsObject - fields object
*/
public constructEntity(fieldsObject: TFields): TEntity {
const idField = this.entityConfiguration.idField;
const id = nullthrows(fieldsObject[idField], 'must provide ID to create an entity');
const entitySelectedFields =
this.entitySelectedFields ?? Array.from(this.entityConfiguration.schema.keys());
const selectedFields = pick(fieldsObject, entitySelectedFields);
return new this.entityClass({
viewerContext: this.viewerContext,
id: id as TFields[TIDField],
databaseFields: fieldsObject,
selectedFields,
});
}
/**
* Construct and authorize entities from fields map, returning error results for entities that fail
* to construct or fail to authorize.
*
* @param map - map from an arbitrary key type to an array of entity field objects
*/
public async constructAndAuthorizeEntitiesAsync<K>(
map: ReadonlyMap<K, readonly Readonly<TFields>[]>,
): Promise<ReadonlyMap<K, readonly Result<TEntity>[]>> {
return await mapMapAsync(map, async (fieldObjects) => {
return await this.constructAndAuthorizeEntitiesArrayAsync(fieldObjects);
});
}
/**
* Construct and authorize entities from field objects array, returning error results for entities that fail
* to construct or fail to authorize.
*
* @param fieldObjects - array of field objects
*/
public async constructAndAuthorizeEntitiesArrayAsync(
fieldObjects: readonly Readonly<TFields>[],
): Promise<readonly Result<TEntity>[]> {
const uncheckedEntityResults = this.tryConstructEntities(fieldObjects);
return await Promise.all(
uncheckedEntityResults.map((uncheckedEntityResult) =>
this.authorizeEntityResultAsync(uncheckedEntityResult),
),
);
}
private async authorizeEntityResultAsync(
uncheckedEntityResult: Result<TEntity>,
): Promise<Result<TEntity>> {
if (!uncheckedEntityResult.ok) {
return uncheckedEntityResult;
}
return await asyncResult(
this.privacyPolicy.authorizeReadAsync(
this.viewerContext,
this.queryContext,
this.privacyPolicyEvaluationContext,
uncheckedEntityResult.value,
this.metricsAdapter,
),
);
}
public async constructAndAuthorizeEntityAsync(
fieldsObject: Readonly<TFields>,
): Promise<Result<TEntity>> {
const uncheckedEntityResult = this.tryConstructEntity(fieldsObject);
return await this.authorizeEntityResultAsync(uncheckedEntityResult);
}
/**
* Validate that field values are valid according to the field's validation function.
*
* @param fieldName - field name to validate
* @param fieldValues - field values to validate
* @throws EntityInvalidFieldValueError when a field value is invalid
*/
public validateFieldAndValues<N extends keyof Pick<TFields, TSelectedFields>>(
fieldName: N,
fieldValues: readonly TFields[N][],
): void {
const fieldDefinition = this.entityConfiguration.schema.get(fieldName);
invariant(fieldDefinition, `must have field definition for field = ${String(fieldName)}`);
for (const fieldValue of fieldValues) {
const isInputValid = fieldDefinition.validateInputValue(fieldValue);
if (!isInputValid) {
throw new EntityInvalidFieldValueError(this.entityClass, fieldName, fieldValue);
}
}
}
private tryConstructEntities(fieldsObjects: readonly TFields[]): readonly Result<TEntity>[] {
return fieldsObjects.map((fieldsObject) => this.tryConstructEntity(fieldsObject));
}
private tryConstructEntity(fieldsObject: TFields): Result<TEntity> {
try {
return result(this.constructEntity(fieldsObject));
} catch (e) {
if (!(e instanceof Error)) {
throw e;
}
return result(e);
}
}
}
+36 -226
View File
@@ -17,127 +17,6 @@ import {
} from './internal/EntityFieldTransformationUtils';
import { IEntityLoadKey, IEntityLoadValue } from './internal/EntityLoadInterfaces';
/**
* Equality operand that is used for selecting entities with a field with a single value.
*/
export interface SingleValueFieldEqualityCondition<
TFields extends Record<string, any>,
N extends keyof TFields = keyof TFields,
> {
fieldName: N;
fieldValue: TFields[N];
}
/**
* Equality operand that is used for selecting entities with a field matching one of multiple values.
*/
export interface MultiValueFieldEqualityCondition<
TFields extends Record<string, any>,
N extends keyof TFields = keyof TFields,
> {
fieldName: N;
fieldValues: readonly TFields[N][];
}
/**
* A single equality operand for use in a selection clause.
* See EntityLoader.loadManyByFieldEqualityConjunctionAsync documentation for examples.
*/
export type FieldEqualityCondition<
TFields extends Record<string, any>,
N extends keyof TFields = keyof TFields,
> = SingleValueFieldEqualityCondition<TFields, N> | MultiValueFieldEqualityCondition<TFields, N>;
export function isSingleValueFieldEqualityCondition<
TFields extends Record<string, any>,
N extends keyof TFields = keyof TFields,
>(
condition: FieldEqualityCondition<TFields, N>,
): condition is SingleValueFieldEqualityCondition<TFields, N> {
return (condition as SingleValueFieldEqualityCondition<TFields, N>).fieldValue !== undefined;
}
export interface TableFieldSingleValueEqualityCondition {
tableField: string;
tableValue: any;
}
export interface TableFieldMultiValueEqualityCondition {
tableField: string;
tableValues: readonly any[];
}
/**
* Ordering options for `orderBy` clauses.
*/
export enum OrderByOrdering {
/**
* Ascending order (lowest to highest).
* Ascending order puts smaller values first, where "smaller" is defined in terms of the %3C operator.
*/
ASCENDING = 'asc',
/**
* Descending order (highest to lowest).
* Descending order puts larger values first, where "larger" is defined in terms of the %3E operator.
*/
DESCENDING = 'desc',
}
/**
* SQL modifiers that only affect the selection but not the projection.
*/
export interface QuerySelectionModifiers<TFields extends Record<string, any>> {
/**
* Order the entities by specified columns and orders.
*/
orderBy?: {
/**
* The field name to order by.
*/
fieldName: keyof TFields;
/**
* The OrderByOrdering to order by.
*/
order: OrderByOrdering;
}[];
/**
* Skip the specified number of entities queried before returning.
*/
offset?: number;
/**
* Limit the number of entities returned.
*/
limit?: number;
}
export interface QuerySelectionModifiersWithOrderByRaw<
TFields extends Record<string, any>,
> extends QuerySelectionModifiers<TFields> {
/**
* Order the entities by a raw SQL `ORDER BY` clause.
*/
orderByRaw?: string;
}
export interface TableQuerySelectionModifiers {
orderBy:
| {
columnName: string;
order: OrderByOrdering;
}[]
| undefined;
offset: number | undefined;
limit: number | undefined;
}
export interface TableQuerySelectionModifiersWithOrderByRaw extends TableQuerySelectionModifiers {
orderByRaw: string | undefined;
}
/**
* A database adapter is an interface by which entity objects can be
* fetched, inserted, updated, and deleted from a database. This base class
@@ -148,9 +27,9 @@ export abstract class EntityDatabaseAdapter<
TFields extends Record<string, any>,
TIDField extends keyof TFields,
> {
private readonly fieldTransformerMap: FieldTransformerMap;
protected readonly fieldTransformerMap: FieldTransformerMap;
constructor(private readonly entityConfiguration: EntityConfiguration<TFields, TIDField>) {
constructor(protected readonly entityConfiguration: EntityConfiguration<TFields, TIDField>) {
this.fieldTransformerMap = this.getFieldTransformerMap();
}
@@ -222,91 +101,51 @@ export abstract class EntityDatabaseAdapter<
): Promise<object[]>;
/**
* Fetch many objects matching the conjunction of where clauses constructed from
* specified field equality operands.
* Fetch one objects where key is equal to value, null if no matching object exists.
* Returned object is not guaranteed to be deterministic. Most concrete implementations will implement this
* with a "first" or "limit 1" query.
*
* @param queryContext - query context with which to perform the fetch
* @param fieldEqualityOperands - list of field equality where clause operand specifications
* @param querySelectionModifiers - limit, offset, orderBy, and orderByRaw for the query
* @returns array of objects matching the query
* @param key - load key being queried
* @param values - load value being queried
* @returns object that matches the query for the value
*/
async fetchManyByFieldEqualityConjunctionAsync<N extends keyof TFields>(
async fetchOneWhereAsync<
TLoadKey extends IEntityLoadKey<TFields, TIDField, TSerializedLoadValue, TLoadValue>,
TSerializedLoadValue,
TLoadValue extends IEntityLoadValue<TSerializedLoadValue>,
>(
queryContext: EntityQueryContext,
fieldEqualityOperands: FieldEqualityCondition<TFields, N>[],
querySelectionModifiers: QuerySelectionModifiers<TFields>,
): Promise<readonly Readonly<TFields>[]> {
const tableFieldSingleValueOperands: TableFieldSingleValueEqualityCondition[] = [];
const tableFieldMultipleValueOperands: TableFieldMultiValueEqualityCondition[] = [];
for (const operand of fieldEqualityOperands) {
if (isSingleValueFieldEqualityCondition(operand)) {
tableFieldSingleValueOperands.push({
tableField: getDatabaseFieldForEntityField(this.entityConfiguration, operand.fieldName),
tableValue: operand.fieldValue,
});
} else {
tableFieldMultipleValueOperands.push({
tableField: getDatabaseFieldForEntityField(this.entityConfiguration, operand.fieldName),
tableValues: operand.fieldValues,
});
}
key: TLoadKey,
value: TLoadValue,
): Promise<Readonly<TFields> | null> {
const keyDatabaseColumns = key.getDatabaseColumns(this.entityConfiguration);
const valueDatabaseValue = key.getDatabaseValues(value);
const result = await this.fetchOneWhereInternalAsync(
queryContext.getQueryInterface(),
this.entityConfiguration.tableName,
keyDatabaseColumns,
valueDatabaseValue,
);
if (!result) {
return null;
}
const results = await this.fetchManyByFieldEqualityConjunctionInternalAsync(
queryContext.getQueryInterface(),
this.entityConfiguration.tableName,
tableFieldSingleValueOperands,
tableFieldMultipleValueOperands,
this.convertToTableQueryModifiers(querySelectionModifiers),
);
return results.map((result) =>
transformDatabaseObjectToFields(this.entityConfiguration, this.fieldTransformerMap, result),
return transformDatabaseObjectToFields(
this.entityConfiguration,
this.fieldTransformerMap,
result,
);
}
protected abstract fetchManyByFieldEqualityConjunctionInternalAsync(
protected abstract fetchOneWhereInternalAsync(
queryInterface: any,
tableName: string,
tableFieldSingleValueEqualityOperands: TableFieldSingleValueEqualityCondition[],
tableFieldMultiValueEqualityOperands: TableFieldMultiValueEqualityCondition[],
querySelectionModifiers: TableQuerySelectionModifiers,
): Promise<object[]>;
/**
* Fetch many objects matching the raw WHERE clause.
*
* @param queryContext - query context with which to perform the fetch
* @param rawWhereClause - parameterized SQL WHERE clause with positional binding placeholders or named binding placeholders
* @param bindings - array of positional bindings or object of named bindings
* @param querySelectionModifiers - limit, offset, and orderBy for the query
* @returns array of objects matching the query
*/
async fetchManyByRawWhereClauseAsync(
queryContext: EntityQueryContext,
rawWhereClause: string,
bindings: any[] | object,
querySelectionModifiers: QuerySelectionModifiersWithOrderByRaw<TFields>,
): Promise<readonly Readonly<TFields>[]> {
const results = await this.fetchManyByRawWhereClauseInternalAsync(
queryContext.getQueryInterface(),
this.entityConfiguration.tableName,
rawWhereClause,
bindings,
this.convertToTableQueryModifiersWithOrderByRaw(querySelectionModifiers),
);
return results.map((result) =>
transformDatabaseObjectToFields(this.entityConfiguration, this.fieldTransformerMap, result),
);
}
protected abstract fetchManyByRawWhereClauseInternalAsync(
queryInterface: any,
tableName: string,
rawWhereClause: string,
bindings: any[] | object,
querySelectionModifiers: TableQuerySelectionModifiersWithOrderByRaw,
): Promise<object[]>;
tableColumns: readonly string[],
tableTuple: readonly any[],
): Promise<object | null>;
/**
* Insert an object.
@@ -446,33 +285,4 @@ export abstract class EntityDatabaseAdapter<
tableIdField: string,
id: any,
): Promise<number>;
private convertToTableQueryModifiersWithOrderByRaw(
querySelectionModifiers: QuerySelectionModifiersWithOrderByRaw<TFields>,
): TableQuerySelectionModifiersWithOrderByRaw {
return {
...this.convertToTableQueryModifiers(querySelectionModifiers),
orderByRaw: querySelectionModifiers.orderByRaw,
};
}
private convertToTableQueryModifiers(
querySelectionModifiers: QuerySelectionModifiers<TFields>,
): TableQuerySelectionModifiers {
const orderBy = querySelectionModifiers.orderBy;
return {
orderBy:
orderBy !== undefined
? orderBy.map((orderBySpecification) => ({
columnName: getDatabaseFieldForEntityField(
this.entityConfiguration,
orderBySpecification.fieldName,
),
order: orderBySpecification.order,
}))
: undefined,
offset: querySelectionModifiers.offset,
limit: querySelectionModifiers.limit,
};
}
}
@@ -1,24 +1,19 @@
import { Result, asyncResult, result } from '@expo/results';
import nullthrows from 'nullthrows';
import { IEntityClass } from './Entity';
import { EntityConfiguration } from './EntityConfiguration';
import { EntityPrivacyPolicy, EntityPrivacyPolicyEvaluationContext } from './EntityPrivacyPolicy';
import { EntityQueryContext, EntityTransactionalQueryContext } from './EntityQueryContext';
import { EntityPrivacyPolicy } from './EntityPrivacyPolicy';
import { EntityTransactionalQueryContext } from './EntityQueryContext';
import { ReadonlyEntity } from './ReadonlyEntity';
import { ViewerContext } from './ViewerContext';
import { pick } from './entityUtils';
import { EntityDataManager } from './internal/EntityDataManager';
import { LoadPair } from './internal/EntityLoadInterfaces';
import { SingleFieldHolder, SingleFieldValueHolder } from './internal/SingleFieldHolder';
import { IEntityMetricsAdapter } from './metrics/IEntityMetricsAdapter';
import { mapMapAsync } from './utils/collections/maps';
/**
* Entity loader utilities for things like invalidation, entity construction, and authorization.
* Entity invalidation utilities.
* Methods are exposed publicly since in rare cases they may need to be called manually.
*/
export class EntityLoaderUtils<
export class EntityInvalidationUtils<
TFields extends Record<string, any>,
TIDField extends keyof NonNullable<Pick<TFields, TSelectedFields>>,
TViewerContext extends ViewerContext,
@@ -33,17 +28,8 @@ export class EntityLoaderUtils<
TSelectedFields extends keyof TFields,
> {
constructor(
private readonly viewerContext: TViewerContext,
private readonly queryContext: EntityQueryContext,
private readonly privacyPolicyEvaluationContext: EntityPrivacyPolicyEvaluationContext<
TFields,
TIDField,
TViewerContext,
TEntity,
TSelectedFields
>,
private readonly entityConfiguration: EntityConfiguration<TFields, TIDField>,
private readonly entityClass: IEntityClass<
_entityClass: IEntityClass<
TFields,
TIDField,
TViewerContext,
@@ -51,8 +37,6 @@ export class EntityLoaderUtils<
TPrivacyPolicy,
TSelectedFields
>,
private readonly entitySelectedFields: TSelectedFields[] | undefined,
private readonly privacyPolicy: TPrivacyPolicy,
private readonly dataManager: EntityDataManager<TFields, TIDField>,
protected readonly metricsAdapter: IEntityMetricsAdapter,
) {}
@@ -128,79 +112,4 @@ export class EntityLoaderUtils<
): void {
this.invalidateFieldsForTransaction(queryContext, entity.getAllDatabaseFields());
}
/**
* Construct an entity from a fields object (applying field selection if applicable),
* checking that the ID field is specified.
*
* @param fieldsObject - fields object
*/
public constructEntity(fieldsObject: TFields): TEntity {
const idField = this.entityConfiguration.idField;
const id = nullthrows(fieldsObject[idField], 'must provide ID to create an entity');
const entitySelectedFields =
this.entitySelectedFields ?? Array.from(this.entityConfiguration.schema.keys());
const selectedFields = pick(fieldsObject, entitySelectedFields);
return new this.entityClass({
viewerContext: this.viewerContext,
id: id as TFields[TIDField],
databaseFields: fieldsObject,
selectedFields,
});
}
/**
* Construct and authorize entities from fields map, returning error results for entities that fail
* to construct or fail to authorize.
*
* @param map - map from an arbitrary key type to an array of entity field objects
*/
public async constructAndAuthorizeEntitiesAsync<K>(
map: ReadonlyMap<K, readonly Readonly<TFields>[]>,
): Promise<ReadonlyMap<K, readonly Result<TEntity>[]>> {
return await mapMapAsync(map, async (fieldObjects) => {
return await this.constructAndAuthorizeEntitiesArrayAsync(fieldObjects);
});
}
/**
* Construct and authorize entities from field objects array, returning error results for entities that fail
* to construct or fail to authorize.
*
* @param fieldObjects - array of field objects
*/
public async constructAndAuthorizeEntitiesArrayAsync(
fieldObjects: readonly Readonly<TFields>[],
): Promise<readonly Result<TEntity>[]> {
const uncheckedEntityResults = this.tryConstructEntities(fieldObjects);
return await Promise.all(
uncheckedEntityResults.map(async (uncheckedEntityResult) => {
if (!uncheckedEntityResult.ok) {
return uncheckedEntityResult;
}
return await asyncResult(
this.privacyPolicy.authorizeReadAsync(
this.viewerContext,
this.queryContext,
this.privacyPolicyEvaluationContext,
uncheckedEntityResult.value,
this.metricsAdapter,
),
);
}),
);
}
private tryConstructEntities(fieldsObjects: readonly TFields[]): readonly Result<TEntity>[] {
return fieldsObjects.map((fieldsObject) => {
try {
return result(this.constructEntity(fieldsObject));
} catch (e) {
if (!(e instanceof Error)) {
throw e;
}
return result(e);
}
});
}
}
+20 -4
View File
@@ -1,7 +1,8 @@
import { AuthorizationResultBasedEntityLoader } from './AuthorizationResultBasedEntityLoader';
import { EnforcingEntityLoader } from './EnforcingEntityLoader';
import { IEntityClass } from './Entity';
import { EntityLoaderUtils } from './EntityLoaderUtils';
import { EntityConstructionUtils } from './EntityConstructionUtils';
import { EntityInvalidationUtils } from './EntityInvalidationUtils';
import { EntityPrivacyPolicy } from './EntityPrivacyPolicy';
import { EntityQueryContext } from './EntityQueryContext';
import { ReadonlyEntity } from './ReadonlyEntity';
@@ -75,10 +76,10 @@ export class EntityLoader<
}
/**
* Entity loader utilities for things like cache invalidation, entity construction, and authorization.
* Entity cache invalidation utilities.
* Calling into these should only be necessary in rare cases.
*/
public utils(): EntityLoaderUtils<
public invalidationUtils(): EntityInvalidationUtils<
TFields,
TIDField,
TViewerContext,
@@ -86,6 +87,21 @@ export class EntityLoader<
TPrivacyPolicy,
TSelectedFields
> {
return this.withAuthorizationResults().utils;
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;
}
}
+11 -4
View File
@@ -1,6 +1,7 @@
import { AuthorizationResultBasedEntityLoader } from './AuthorizationResultBasedEntityLoader';
import { EntityCompanion } from './EntityCompanion';
import { EntityLoaderUtils } from './EntityLoaderUtils';
import { EntityConstructionUtils } from './EntityConstructionUtils';
import { EntityInvalidationUtils } from './EntityInvalidationUtils';
import { EntityPrivacyPolicy, EntityPrivacyPolicyEvaluationContext } from './EntityPrivacyPolicy';
import { EntityQueryContext } from './EntityQueryContext';
import { ReadonlyEntity } from './ReadonlyEntity';
@@ -61,7 +62,13 @@ export class EntityLoaderFactory<
TPrivacyPolicy,
TSelectedFields
> {
const utils = new EntityLoaderUtils(
const invalidationUtils = new EntityInvalidationUtils(
this.entityCompanion.entityCompanionDefinition.entityConfiguration,
this.entityCompanion.entityCompanionDefinition.entityClass,
this.dataManager,
this.metricsAdapter,
);
const constructionUtils = new EntityConstructionUtils(
viewerContext,
queryContext,
privacyPolicyEvaluationContext,
@@ -69,7 +76,6 @@ export class EntityLoaderFactory<
this.entityCompanion.entityCompanionDefinition.entityClass,
this.entityCompanion.entityCompanionDefinition.entitySelectedFields,
this.entityCompanion.privacyPolicy,
this.dataManager,
this.metricsAdapter,
);
@@ -79,7 +85,8 @@ export class EntityLoaderFactory<
this.entityCompanion.entityCompanionDefinition.entityClass,
this.dataManager,
this.metricsAdapter,
utils,
invalidationUtils,
constructionUtils,
);
}
}
@@ -84,9 +84,10 @@ export abstract class EntitySecondaryCacheLoader<
);
// convert value to and from array to reuse complex code
const entitiesMap = await this.entityLoader.utils.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);
}
+50 -7
View File
@@ -6,8 +6,9 @@ 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 { EntityLoaderUtils } from './EntityLoaderUtils';
import { EntityPrivacyPolicy } from './EntityPrivacyPolicy';
import { EntityQueryContext } from './EntityQueryContext';
import { ViewerContext } from './ViewerContext';
@@ -230,11 +231,10 @@ export abstract class ReadonlyEntity<
}
/**
* Vend loader for loading an entity in a given query context.
* @param viewerContext - viewer context of loading user
* @param queryContext - query context in which to perform the load
* Utilities for entity invalidation.
* Calling into these should only be necessary in rare cases.
*/
static loaderUtils<
static invalidationUtils<
TMFields extends object,
TMIDField extends keyof NonNullable<Pick<TMFields, TMSelectedFields>>,
TMViewerContext extends ViewerContext,
@@ -262,7 +262,7 @@ export abstract class ReadonlyEntity<
.getViewerScopedEntityCompanionForClass(this)
.getQueryContextProvider()
.getQueryContext(),
): EntityLoaderUtils<
): EntityInvalidationUtils<
TMFields,
TMIDField,
TMViewerContext,
@@ -270,6 +270,49 @@ export abstract class ReadonlyEntity<
TMPrivacyPolicy,
TMSelectedFields
> {
return new EntityLoader(viewerContext, queryContext, this).utils();
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)
.getQueryContextProvider()
.getQueryContext(),
): EntityConstructionUtils<
TMFields,
TMIDField,
TMViewerContext,
TMEntity,
TMPrivacyPolicy,
TMSelectedFields
> {
return new EntityLoader(viewerContext, queryContext, this).constructionUtils();
}
}
@@ -34,7 +34,7 @@ export class ViewerScopedEntityCompanion<
TPrivacyPolicy,
TSelectedFields
>,
private readonly viewerContext: TViewerContext,
public readonly viewerContext: TViewerContext,
) {}
/**
@@ -5,8 +5,9 @@ import { AuthorizationResultBasedEntityLoader } from '../AuthorizationResultBase
import { Entity } from '../Entity';
import { EntityCompanionDefinition } from '../EntityCompanionProvider';
import { EntityConfiguration } from '../EntityConfiguration';
import { EntityConstructionUtils } from '../EntityConstructionUtils';
import { StringField } from '../EntityFields';
import { EntityLoaderUtils } from '../EntityLoaderUtils';
import { EntityInvalidationUtils } from '../EntityInvalidationUtils';
import { EntityPrivacyPolicy, EntityPrivacyPolicyEvaluationContext } from '../EntityPrivacyPolicy';
import { ViewerContext } from '../ViewerContext';
import { EntityDataManager } from '../internal/EntityDataManager';
@@ -164,7 +165,13 @@ describe(AuthorizationResultBasedEntityLoader, () => {
metricsAdapter,
TestEntity.name,
);
const utils = new EntityLoaderUtils(
const invalidationUtils = new EntityInvalidationUtils(
testEntityConfiguration,
TestEntity,
dataManager,
metricsAdapter,
);
const constructionUtils = new EntityConstructionUtils(
viewerContext,
queryContext,
privacyPolicyEvaluationContext,
@@ -172,7 +179,6 @@ describe(AuthorizationResultBasedEntityLoader, () => {
TestEntity,
/* entitySelectedFields */ undefined,
privacyPolicy,
dataManager,
metricsAdapter,
);
const entityLoader = new AuthorizationResultBasedEntityLoader(
@@ -181,7 +187,8 @@ describe(AuthorizationResultBasedEntityLoader, () => {
TestEntity,
dataManager,
metricsAdapter,
utils,
invalidationUtils,
constructionUtils,
);
let capturedThrownThing1: any;
@@ -4,8 +4,8 @@ import { anyOfClass, anything, instance, mock, spy, verify, when } from 'ts-mock
import { v4 as uuidv4 } from 'uuid';
import { AuthorizationResultBasedEntityLoader } from '../AuthorizationResultBasedEntityLoader';
import { OrderByOrdering } from '../EntityDatabaseAdapter';
import { EntityLoaderUtils } from '../EntityLoaderUtils';
import { EntityConstructionUtils } from '../EntityConstructionUtils';
import { EntityInvalidationUtils } from '../EntityInvalidationUtils';
import { EntityPrivacyPolicyEvaluationContext } from '../EntityPrivacyPolicy';
import { ViewerContext } from '../ViewerContext';
import { enforceResultsAsync } from '../entityUtils';
@@ -90,7 +90,13 @@ describe(AuthorizationResultBasedEntityLoader, () => {
instance(mock<IEntityMetricsAdapter>()),
TestEntity.name,
);
const utils = new EntityLoaderUtils(
const invalidationUtils = new EntityInvalidationUtils(
testEntityConfiguration,
TestEntity,
dataManager,
metricsAdapter,
);
const constructionUtils = new EntityConstructionUtils(
viewerContext,
queryContext,
privacyPolicyEvaluationContext,
@@ -98,7 +104,6 @@ describe(AuthorizationResultBasedEntityLoader, () => {
TestEntity,
/* entitySelectedFields */ undefined,
privacyPolicy,
dataManager,
metricsAdapter,
);
const entityLoader = new AuthorizationResultBasedEntityLoader(
@@ -107,7 +112,8 @@ describe(AuthorizationResultBasedEntityLoader, () => {
TestEntity,
dataManager,
metricsAdapter,
utils,
invalidationUtils,
constructionUtils,
);
const entity = await enforceAsyncResult(entityLoader.loadByIDAsync(id1));
@@ -234,7 +240,13 @@ describe(AuthorizationResultBasedEntityLoader, () => {
instance(mock<IEntityMetricsAdapter>()),
TestEntity.name,
);
const utils = new EntityLoaderUtils(
const invalidationUtils = new EntityInvalidationUtils(
testEntityConfiguration,
TestEntity,
dataManager,
metricsAdapter,
);
const constructionUtils = new EntityConstructionUtils(
viewerContext,
queryContext,
privacyPolicyEvaluationContext,
@@ -242,7 +254,6 @@ describe(AuthorizationResultBasedEntityLoader, () => {
TestEntity,
/* entitySelectedFields */ undefined,
privacyPolicy,
dataManager,
metricsAdapter,
);
const entityLoader = new AuthorizationResultBasedEntityLoader(
@@ -251,7 +262,8 @@ describe(AuthorizationResultBasedEntityLoader, () => {
TestEntity,
dataManager,
metricsAdapter,
utils,
invalidationUtils,
constructionUtils,
);
const entities = await enforceResultsAsync(
@@ -322,311 +334,6 @@ describe(AuthorizationResultBasedEntityLoader, () => {
).toEqual([id3]);
});
it('loads entities with loadManyByFieldEqualityConjunction', async () => {
const privacyPolicy = new TestEntityPrivacyPolicy();
const spiedPrivacyPolicy = spy(privacyPolicy);
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 id1 = uuidv4();
const id2 = uuidv4();
const id3 = uuidv4();
const databaseAdapter = new StubDatabaseAdapter<TestFields, 'customIdField'>(
testEntityConfiguration,
StubDatabaseAdapter.convertFieldObjectsToDataStore(
testEntityConfiguration,
new Map([
[
testEntityConfiguration.tableName,
[
{
customIdField: id1,
stringField: 'huh',
intField: 4,
testIndexedField: '4',
dateField: new Date(),
nullableField: null,
},
{
customIdField: id2,
stringField: 'huh',
intField: 4,
testIndexedField: '5',
dateField: new Date(),
nullableField: null,
},
{
customIdField: id3,
stringField: 'huh2',
intField: 4,
testIndexedField: '6',
dateField: new Date(),
nullableField: null,
},
],
],
]),
),
);
const cacheAdapterProvider = new NoCacheStubCacheAdapterProvider();
const cacheAdapter = cacheAdapterProvider.getCacheAdapter(testEntityConfiguration);
const entityCache = new ReadThroughEntityCache(testEntityConfiguration, cacheAdapter);
const dataManager = new EntityDataManager(
databaseAdapter,
entityCache,
new StubQueryContextProvider(),
instance(mock<IEntityMetricsAdapter>()),
TestEntity.name,
);
const utils = new EntityLoaderUtils(
viewerContext,
queryContext,
privacyPolicyEvaluationContext,
testEntityConfiguration,
TestEntity,
/* entitySelectedFields */ undefined,
privacyPolicy,
dataManager,
metricsAdapter,
);
const entityLoader = new AuthorizationResultBasedEntityLoader(
queryContext,
testEntityConfiguration,
TestEntity,
dataManager,
metricsAdapter,
utils,
);
const entityResults = await enforceResultsAsync(
entityLoader.loadManyByFieldEqualityConjunctionAsync([
{
fieldName: 'stringField',
fieldValue: 'huh',
},
{
fieldName: 'intField',
fieldValues: [4],
},
]),
);
expect(entityResults).toHaveLength(2);
verify(
spiedPrivacyPolicy.authorizeReadAsync(
viewerContext,
queryContext,
privacyPolicyEvaluationContext,
anyOfClass(TestEntity),
anything(),
),
).twice();
await expect(
entityLoader.loadManyByFieldEqualityConjunctionAsync([
{ fieldName: 'customIdField', fieldValue: 'not-a-uuid' },
]),
).rejects.toThrow('Entity field not valid: TestEntity (customIdField = not-a-uuid)');
});
it('loads entities with loadFirstByFieldEqualityConjunction', async () => {
const privacyPolicy = new TestEntityPrivacyPolicy();
const spiedPrivacyPolicy = spy(privacyPolicy);
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 id1 = uuidv4();
const id2 = uuidv4();
const id3 = uuidv4();
const databaseAdapter = new StubDatabaseAdapter<TestFields, 'customIdField'>(
testEntityConfiguration,
StubDatabaseAdapter.convertFieldObjectsToDataStore(
testEntityConfiguration,
new Map([
[
testEntityConfiguration.tableName,
[
{
customIdField: id1,
stringField: 'huh',
intField: 4,
testIndexedField: '4',
dateField: new Date(),
nullableField: null,
},
{
customIdField: id2,
stringField: 'huh',
intField: 4,
testIndexedField: '5',
dateField: new Date(),
nullableField: null,
},
{
customIdField: id3,
stringField: 'huh2',
intField: 4,
testIndexedField: '6',
dateField: new Date(),
nullableField: null,
},
],
],
]),
),
);
const cacheAdapterProvider = new NoCacheStubCacheAdapterProvider();
const cacheAdapter = cacheAdapterProvider.getCacheAdapter(testEntityConfiguration);
const entityCache = new ReadThroughEntityCache(testEntityConfiguration, cacheAdapter);
const dataManager = new EntityDataManager(
databaseAdapter,
entityCache,
new StubQueryContextProvider(),
instance(mock<IEntityMetricsAdapter>()),
TestEntity.name,
);
const utils = new EntityLoaderUtils(
viewerContext,
queryContext,
privacyPolicyEvaluationContext,
testEntityConfiguration,
TestEntity,
/* entitySelectedFields */ undefined,
privacyPolicy,
dataManager,
metricsAdapter,
);
const entityLoader = new AuthorizationResultBasedEntityLoader(
queryContext,
testEntityConfiguration,
TestEntity,
dataManager,
metricsAdapter,
utils,
);
const result = await entityLoader.loadFirstByFieldEqualityConjunctionAsync(
[
{
fieldName: 'stringField',
fieldValue: 'huh',
},
{
fieldName: 'intField',
fieldValue: 4,
},
],
{ orderBy: [{ fieldName: 'testIndexedField', order: OrderByOrdering.DESCENDING }] },
);
expect(result).not.toBeNull();
expect(result!.ok).toBe(true);
expect(result!.enforceValue().getField('testIndexedField')).toEqual('5');
verify(
spiedPrivacyPolicy.authorizeReadAsync(
viewerContext,
queryContext,
privacyPolicyEvaluationContext,
anyOfClass(TestEntity),
anything(),
),
).once();
});
it('loads entities with loadManyByRawWhereClauseAsync', async () => {
const privacyPolicy = new TestEntityPrivacyPolicy();
const spiedPrivacyPolicy = spy(privacyPolicy);
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 dataManagerMock = mock<EntityDataManager<TestFields, 'customIdField'>>(EntityDataManager);
when(
dataManagerMock.loadManyByRawWhereClauseAsync(
queryContext,
anything(),
anything(),
anything(),
),
).thenResolve([
{
customIdField: 'id',
stringField: 'huh',
intField: 4,
testIndexedField: '4',
dateField: new Date(),
nullableField: null,
},
]);
const dataManager = instance(dataManagerMock);
const utils = new EntityLoaderUtils(
viewerContext,
queryContext,
privacyPolicyEvaluationContext,
testEntityConfiguration,
TestEntity,
/* entitySelectedFields */ undefined,
privacyPolicy,
dataManager,
metricsAdapter,
);
const entityLoader = new AuthorizationResultBasedEntityLoader(
queryContext,
testEntityConfiguration,
TestEntity,
dataManager,
metricsAdapter,
utils,
);
const result = await entityLoader.loadManyByRawWhereClauseAsync('id = ?', [1], {
orderBy: [{ fieldName: 'testIndexedField', order: OrderByOrdering.DESCENDING }],
});
expect(result).toHaveLength(1);
expect(result[0]).not.toBeNull();
expect(result[0]!.ok).toBe(true);
expect(result[0]!.enforceValue().getField('testIndexedField')).toEqual('4');
verify(
spiedPrivacyPolicy.authorizeReadAsync(
viewerContext,
queryContext,
privacyPolicyEvaluationContext,
anyOfClass(TestEntity),
anything(),
),
).once();
});
it('authorizes loaded entities', async () => {
const privacyPolicy = new TestEntityPrivacyPolicy();
const spiedPrivacyPolicy = spy(privacyPolicy);
@@ -678,7 +385,13 @@ describe(AuthorizationResultBasedEntityLoader, () => {
instance(mock<IEntityMetricsAdapter>()),
TestEntity.name,
);
const utils = new EntityLoaderUtils(
const invalidationUtils = new EntityInvalidationUtils(
testEntityConfiguration,
TestEntity,
dataManager,
metricsAdapter,
);
const constructionUtils = new EntityConstructionUtils(
viewerContext,
queryContext,
privacyPolicyEvaluationContext,
@@ -686,7 +399,6 @@ describe(AuthorizationResultBasedEntityLoader, () => {
TestEntity,
/* entitySelectedFields */ undefined,
privacyPolicy,
dataManager,
metricsAdapter,
);
const entityLoader = new AuthorizationResultBasedEntityLoader(
@@ -695,7 +407,8 @@ describe(AuthorizationResultBasedEntityLoader, () => {
TestEntity,
dataManager,
metricsAdapter,
utils,
invalidationUtils,
constructionUtils,
);
const entity = await enforceAsyncResult(entityLoader.loadByIDAsync(id1));
verify(
@@ -729,7 +442,13 @@ describe(AuthorizationResultBasedEntityLoader, () => {
const dataManagerInstance = instance(dataManagerMock);
const id1 = uuidv4();
const utils = new EntityLoaderUtils(
const invalidationUtils = new EntityInvalidationUtils(
testEntityConfiguration,
TestEntity,
dataManagerInstance,
metricsAdapter,
);
const constructionUtils = new EntityConstructionUtils(
viewerContext,
queryContext,
privacyPolicyEvaluationContext,
@@ -737,7 +456,6 @@ describe(AuthorizationResultBasedEntityLoader, () => {
TestEntity,
/* entitySelectedFields */ undefined,
privacyPolicy,
dataManagerInstance,
metricsAdapter,
);
const entityLoader = new AuthorizationResultBasedEntityLoader(
@@ -746,12 +464,13 @@ describe(AuthorizationResultBasedEntityLoader, () => {
TestEntity,
dataManagerInstance,
metricsAdapter,
utils,
invalidationUtils,
constructionUtils,
);
const date = new Date();
await entityLoader.utils.invalidateFieldsAsync({
await entityLoader.invalidationUtils.invalidateFieldsAsync({
customIdField: id1,
testIndexedField: 'h1',
intField: 5,
@@ -787,11 +506,14 @@ describe(AuthorizationResultBasedEntityLoader, () => {
new SingleFieldValueHolder<TestFields, 'dateField'>(date),
],
[
new CompositeFieldHolder(['stringField', 'intField']),
new CompositeFieldHolder<TestFields, 'customIdField'>(['stringField', 'intField']),
new CompositeFieldValueHolder({ stringField: 'huh', intField: 5 }),
],
[
new CompositeFieldHolder(['stringField', 'testIndexedField']),
new CompositeFieldHolder<TestFields, 'customIdField'>([
'stringField',
'testIndexedField',
]),
new CompositeFieldValueHolder({ stringField: 'huh', testIndexedField: 'h1' }),
],
]),
@@ -832,7 +554,13 @@ describe(AuthorizationResultBasedEntityLoader, () => {
});
const entityInstance = instance(entityMock);
const utils = new EntityLoaderUtils(
const invalidationUtils = new EntityInvalidationUtils(
testEntityConfiguration,
TestEntity,
dataManagerInstance,
metricsAdapter,
);
const constructionUtils = new EntityConstructionUtils(
viewerContext,
queryContext,
privacyPolicyEvaluationContext,
@@ -840,7 +568,6 @@ describe(AuthorizationResultBasedEntityLoader, () => {
TestEntity,
/* entitySelectedFields */ undefined,
privacyPolicy,
dataManagerInstance,
metricsAdapter,
);
const entityLoader = new AuthorizationResultBasedEntityLoader(
@@ -849,9 +576,10 @@ describe(AuthorizationResultBasedEntityLoader, () => {
TestEntity,
dataManagerInstance,
metricsAdapter,
utils,
invalidationUtils,
constructionUtils,
);
await entityLoader.utils.invalidateEntityAsync(entityInstance);
await entityLoader.invalidationUtils.invalidateEntityAsync(entityInstance);
verify(dataManagerMock.invalidateKeyValuePairsAsync(anything())).once();
verify(
@@ -880,11 +608,14 @@ describe(AuthorizationResultBasedEntityLoader, () => {
new SingleFieldValueHolder<TestFields, 'dateField'>(date),
],
[
new CompositeFieldHolder(['stringField', 'intField']),
new CompositeFieldHolder<TestFields, 'customIdField'>(['stringField', 'intField']),
new CompositeFieldValueHolder({ stringField: 'huh', intField: 5 }),
],
[
new CompositeFieldHolder(['stringField', 'testIndexedField']),
new CompositeFieldHolder<TestFields, 'customIdField'>([
'stringField',
'testIndexedField',
]),
new CompositeFieldValueHolder({ stringField: 'huh', testIndexedField: 'h1' }),
],
]),
@@ -926,7 +657,13 @@ describe(AuthorizationResultBasedEntityLoader, () => {
const entityInstance = instance(entityMock);
await new StubQueryContextProvider().runInTransactionAsync(async (queryContext) => {
const utils = new EntityLoaderUtils(
const invalidationUtils = new EntityInvalidationUtils(
testEntityConfiguration,
TestEntity,
dataManagerInstance,
metricsAdapter,
);
const constructionUtils = new EntityConstructionUtils(
viewerContext,
queryContext,
privacyPolicyEvaluationContext,
@@ -934,7 +671,6 @@ describe(AuthorizationResultBasedEntityLoader, () => {
TestEntity,
/* entitySelectedFields */ undefined,
privacyPolicy,
dataManagerInstance,
metricsAdapter,
);
const entityLoader = new AuthorizationResultBasedEntityLoader(
@@ -943,9 +679,10 @@ describe(AuthorizationResultBasedEntityLoader, () => {
TestEntity,
dataManagerInstance,
metricsAdapter,
utils,
invalidationUtils,
constructionUtils,
);
entityLoader.utils.invalidateEntityForTransaction(queryContext, entityInstance);
entityLoader.invalidationUtils.invalidateEntityForTransaction(queryContext, entityInstance);
verify(
dataManagerMock.invalidateKeyValuePairsForTransaction(queryContext, anything()),
@@ -977,11 +714,14 @@ describe(AuthorizationResultBasedEntityLoader, () => {
new SingleFieldValueHolder<TestFields, 'dateField'>(date),
],
[
new CompositeFieldHolder(['stringField', 'intField']),
new CompositeFieldHolder<TestFields, 'customIdField'>(['stringField', 'intField']),
new CompositeFieldValueHolder({ stringField: 'huh', intField: 5 }),
],
[
new CompositeFieldHolder(['stringField', 'testIndexedField']),
new CompositeFieldHolder<TestFields, 'customIdField'>([
'stringField',
'testIndexedField',
]),
new CompositeFieldValueHolder({ stringField: 'huh', testIndexedField: 'h1' }),
],
]),
@@ -1041,7 +781,13 @@ describe(AuthorizationResultBasedEntityLoader, () => {
const privacyPolicy = instance(privacyPolicyMock);
const dataManagerInstance = instance(dataManagerMock);
const utils = new EntityLoaderUtils(
const invalidationUtils = new EntityInvalidationUtils(
testEntityConfiguration,
TestEntity,
dataManagerInstance,
metricsAdapter,
);
const constructionUtils = new EntityConstructionUtils(
viewerContext,
queryContext,
privacyPolicyEvaluationContext,
@@ -1049,7 +795,6 @@ describe(AuthorizationResultBasedEntityLoader, () => {
TestEntity,
/* entitySelectedFields */ undefined,
privacyPolicy,
dataManagerInstance,
metricsAdapter,
);
const entityLoader = new AuthorizationResultBasedEntityLoader(
@@ -1058,7 +803,8 @@ describe(AuthorizationResultBasedEntityLoader, () => {
TestEntity,
dataManagerInstance,
metricsAdapter,
utils,
invalidationUtils,
constructionUtils,
);
const entityResult = await entityLoader.loadByIDAsync(id1);
@@ -1093,7 +839,13 @@ describe(AuthorizationResultBasedEntityLoader, () => {
const dataManagerInstance = instance(dataManagerMock);
const utils = new EntityLoaderUtils(
const invalidationUtils = new EntityInvalidationUtils(
testEntityConfiguration,
TestEntity,
dataManagerInstance,
metricsAdapter,
);
const constructionUtils = new EntityConstructionUtils(
viewerContext,
queryContext,
privacyPolicyEvaluationContext,
@@ -1101,7 +853,6 @@ describe(AuthorizationResultBasedEntityLoader, () => {
TestEntity,
/* entitySelectedFields */ undefined,
privacyPolicy,
dataManagerInstance,
metricsAdapter,
);
const entityLoader = new AuthorizationResultBasedEntityLoader(
@@ -1110,7 +861,8 @@ describe(AuthorizationResultBasedEntityLoader, () => {
TestEntity,
dataManagerInstance,
metricsAdapter,
utils,
invalidationUtils,
constructionUtils,
);
const loadByValue = uuidv4();
@@ -439,139 +439,6 @@ describe(EnforcingEntityLoader, () => {
});
});
describe('loadFirstByFieldEqualityConjunction', () => {
it('throws when result is unsuccessful', async () => {
const nonEnforcingEntityLoaderMock = mock<
AuthorizationResultBasedEntityLoader<any, any, any, any, any, any>
>(AuthorizationResultBasedEntityLoader);
const rejection = new Error();
when(
nonEnforcingEntityLoaderMock.loadFirstByFieldEqualityConjunctionAsync(
anything(),
anything(),
),
).thenResolve(result(rejection));
const nonEnforcingEntityLoader = instance(nonEnforcingEntityLoaderMock);
const enforcingEntityLoader = new EnforcingEntityLoader(nonEnforcingEntityLoader);
await expect(
enforcingEntityLoader.loadFirstByFieldEqualityConjunctionAsync(anything(), anything()),
).rejects.toThrow(rejection);
});
it('returns value when result is successful', async () => {
const nonEnforcingEntityLoaderMock = mock<
AuthorizationResultBasedEntityLoader<any, any, any, any, any, any>
>(AuthorizationResultBasedEntityLoader);
const resolved = {};
when(
nonEnforcingEntityLoaderMock.loadFirstByFieldEqualityConjunctionAsync(
anything(),
anything(),
),
).thenResolve(result(resolved));
const nonEnforcingEntityLoader = instance(nonEnforcingEntityLoaderMock);
const enforcingEntityLoader = new EnforcingEntityLoader(nonEnforcingEntityLoader);
await expect(
enforcingEntityLoader.loadFirstByFieldEqualityConjunctionAsync(anything(), anything()),
).resolves.toEqual(resolved);
});
it('returns null when the query is successful but no rows match', async () => {
const nonEnforcingEntityLoaderMock = mock<
AuthorizationResultBasedEntityLoader<any, any, any, any, any, any>
>(AuthorizationResultBasedEntityLoader);
when(
nonEnforcingEntityLoaderMock.loadFirstByFieldEqualityConjunctionAsync(
anything(),
anything(),
),
).thenResolve(null);
const nonEnforcingEntityLoader = instance(nonEnforcingEntityLoaderMock);
const enforcingEntityLoader = new EnforcingEntityLoader(nonEnforcingEntityLoader);
await expect(
enforcingEntityLoader.loadFirstByFieldEqualityConjunctionAsync(anything(), anything()),
).resolves.toBeNull();
});
});
describe('loadManyByFieldEqualityConjunction', () => {
it('throws when result is unsuccessful', async () => {
const nonEnforcingEntityLoaderMock = mock<
AuthorizationResultBasedEntityLoader<any, any, any, any, any, any>
>(AuthorizationResultBasedEntityLoader);
const rejection = new Error();
when(
nonEnforcingEntityLoaderMock.loadManyByFieldEqualityConjunctionAsync(
anything(),
anything(),
),
).thenResolve([result(rejection)]);
const nonEnforcingEntityLoader = instance(nonEnforcingEntityLoaderMock);
const enforcingEntityLoader = new EnforcingEntityLoader(nonEnforcingEntityLoader);
await expect(
enforcingEntityLoader.loadManyByFieldEqualityConjunctionAsync(anything(), anything()),
).rejects.toThrow(rejection);
});
it('returns value when result is successful', async () => {
const nonEnforcingEntityLoaderMock = mock<
AuthorizationResultBasedEntityLoader<any, any, any, any, any, any>
>(AuthorizationResultBasedEntityLoader);
const resolved = {};
when(
nonEnforcingEntityLoaderMock.loadManyByFieldEqualityConjunctionAsync(
anything(),
anything(),
),
).thenResolve([result(resolved)]);
const nonEnforcingEntityLoader = instance(nonEnforcingEntityLoaderMock);
const enforcingEntityLoader = new EnforcingEntityLoader(nonEnforcingEntityLoader);
await expect(
enforcingEntityLoader.loadManyByFieldEqualityConjunctionAsync(anything(), anything()),
).resolves.toEqual([resolved]);
});
});
describe('loadManyByRawWhereClause', () => {
it('throws when result is unsuccessful', async () => {
const nonEnforcingEntityLoaderMock = mock<
AuthorizationResultBasedEntityLoader<any, any, any, any, any, any>
>(AuthorizationResultBasedEntityLoader);
const rejection = new Error();
when(
nonEnforcingEntityLoaderMock.loadManyByRawWhereClauseAsync(
anything(),
anything(),
anything(),
),
).thenResolve([result(rejection)]);
const nonEnforcingEntityLoader = instance(nonEnforcingEntityLoaderMock);
const enforcingEntityLoader = new EnforcingEntityLoader(nonEnforcingEntityLoader);
await expect(
enforcingEntityLoader.loadManyByRawWhereClauseAsync(anything(), anything(), anything()),
).rejects.toThrow(rejection);
});
it('returns value when result is successful', async () => {
const nonEnforcingEntityLoaderMock = mock<
AuthorizationResultBasedEntityLoader<any, any, any, any, any, any>
>(AuthorizationResultBasedEntityLoader);
const resolved = {};
when(
nonEnforcingEntityLoaderMock.loadManyByRawWhereClauseAsync(
anything(),
anything(),
anything(),
),
).thenResolve([result(resolved)]);
const nonEnforcingEntityLoader = instance(nonEnforcingEntityLoaderMock);
const enforcingEntityLoader = new EnforcingEntityLoader(nonEnforcingEntityLoader);
await expect(
enforcingEntityLoader.loadManyByRawWhereClauseAsync(anything(), anything(), anything()),
).resolves.toEqual([resolved]);
});
});
it('has the same method names as EntityLoader', () => {
const enforcingLoaderProperties = Object.getOwnPropertyNames(EnforcingEntityLoader.prototype);
const nonEnforcingLoaderProperties = Object.getOwnPropertyNames(
@@ -580,7 +447,8 @@ describe(EnforcingEntityLoader, () => {
// ensure known differences still exist for sanity check
const knownLoaderOnlyDifferences = [
'validateFieldAndValues',
'loadOneByFieldEqualingAsync', // private method used in EntityPrivacyUtils that is not intended to be part of the public API of AuthorizationResultBasedEntityLoader, and has no equivalent in EnforcingEntityLoader
'validateFieldAndValueAndConvertToHolders',
'validateFieldAndValuesAndConvertToHolders',
'validateCompositeFieldAndValuesAndConvertToHolders',
'constructAndAuthorizeEntitiesFromCompositeFieldValueHolderMapAsync',
@@ -78,4 +78,22 @@ describe(EntityCompanion, () => {
afterCommit: [localTriggers!.afterCommit![0]],
});
});
it('returns correct metrics adapter', () => {
const entityCompanionProvider = instance(mock<EntityCompanionProvider>());
const tableDataCoordinatorMock = mock<EntityTableDataCoordinator<TestMTFields, 'id'>>();
when(tableDataCoordinatorMock.entityConfiguration).thenReturn(testEntityMTConfiguration);
const metricsAdapterMock = mock<IEntityMetricsAdapter>();
const metricsAdapterInstance = instance(metricsAdapterMock);
const companion = new EntityCompanion(
entityCompanionProvider,
TestEntityWithMutationTriggers.defineCompanionDefinition(),
instance(tableDataCoordinatorMock),
metricsAdapterInstance,
);
expect(companion.getMetricsAdapter()).toBe(metricsAdapterInstance);
});
});
@@ -46,8 +46,8 @@ describe(EntityConfiguration, () => {
expect(blahEntityConfiguration.databaseAdapterFlavor).toEqual('postgres');
expect(blahEntityConfiguration.cacheAdapterFlavor).toEqual('redis');
expect(blahEntityConfiguration.compositeFieldInfo.getAllCompositeFieldHolders()).toEqual([
new CompositeFieldHolder(['id', 'cacheable']),
new CompositeFieldHolder(['id', 'uniqueButNotCacheable']),
new CompositeFieldHolder<BlahT, 'id'>(['id', 'cacheable']),
new CompositeFieldHolder<BlahT, 'id'>(['id', 'uniqueButNotCacheable']),
]);
});
@@ -1,11 +1,7 @@
import { describe, expect, it } from '@jest/globals';
import { instance, mock } from 'ts-mockito';
import {
EntityDatabaseAdapter,
TableFieldMultiValueEqualityCondition,
TableFieldSingleValueEqualityCondition,
} from '../EntityDatabaseAdapter';
import { EntityDatabaseAdapter } from '../EntityDatabaseAdapter';
import { EntityQueryContext } from '../EntityQueryContext';
import {
EntityDatabaseAdapterEmptyInsertResultError,
@@ -21,33 +17,29 @@ import { TestFields, testEntityConfiguration } from '../utils/__testfixtures__/T
class TestEntityDatabaseAdapter extends EntityDatabaseAdapter<TestFields, 'customIdField'> {
private readonly fetchResults: object[];
private readonly fetchOneResult: object | null;
private readonly insertResults: object[];
private readonly updateResults: object[];
private readonly fetchEqualityConditionResults: object[];
private readonly fetchRawWhereResults: object[];
private readonly deleteCount: number;
constructor({
fetchResults = [],
fetchOneResult = null,
insertResults = [],
updateResults = [],
fetchEqualityConditionResults = [],
fetchRawWhereResults = [],
deleteCount = 0,
}: {
fetchResults?: object[];
fetchOneResult?: object | null;
insertResults?: object[];
updateResults?: object[];
fetchEqualityConditionResults?: object[];
fetchRawWhereResults?: object[];
deleteCount?: number;
}) {
super(testEntityConfiguration);
this.fetchResults = fetchResults;
this.fetchOneResult = fetchOneResult;
this.insertResults = insertResults;
this.updateResults = updateResults;
this.fetchEqualityConditionResults = fetchEqualityConditionResults;
this.fetchRawWhereResults = fetchRawWhereResults;
this.deleteCount = deleteCount;
}
@@ -64,22 +56,13 @@ class TestEntityDatabaseAdapter extends EntityDatabaseAdapter<TestFields, 'custo
return this.fetchResults;
}
protected async fetchManyByRawWhereClauseInternalAsync(
protected async fetchOneWhereInternalAsync(
_queryInterface: any,
_tableName: string,
_rawWhereClause: string,
_bindings: object | any[],
): Promise<object[]> {
return this.fetchRawWhereResults;
}
protected async fetchManyByFieldEqualityConjunctionInternalAsync(
_queryInterface: any,
_tableName: string,
_tableFieldSingleValueEqualityOperands: TableFieldSingleValueEqualityCondition[],
_tableFieldMultiValueEqualityOperands: TableFieldMultiValueEqualityCondition[],
): Promise<object[]> {
return this.fetchEqualityConditionResults;
_tableColumns: readonly string[],
_tableTuple: readonly any[],
): Promise<object | null> {
return this.fetchOneResult;
}
protected async insertInternalAsync(
@@ -117,7 +100,7 @@ describe(EntityDatabaseAdapter, () => {
const adapter = new TestEntityDatabaseAdapter({ fetchResults: [{ string_field: 'hello' }] });
const result = await adapter.fetchManyWhereAsync(
queryContext,
new SingleFieldHolder('stringField'),
new SingleFieldHolder<TestFields, 'customIdField', 'stringField'>('stringField'),
[new SingleFieldValueHolder('hello')],
);
expect(result.get(new SingleFieldValueHolder('hello'))).toEqual([{ stringField: 'hello' }]);
@@ -130,7 +113,7 @@ describe(EntityDatabaseAdapter, () => {
});
const result = await adapter.fetchManyWhereAsync(
queryContext,
new SingleFieldHolder('stringField'),
new SingleFieldHolder<TestFields, 'customIdField', 'stringField'>('stringField'),
[new SingleFieldValueHolder('hello'), new SingleFieldValueHolder('wat')],
);
expect(result.get(new SingleFieldValueHolder('hello'))).toEqual([{ stringField: 'hello' }]);
@@ -170,7 +153,7 @@ describe(EntityDatabaseAdapter, () => {
const adapter = new TestEntityDatabaseAdapter({});
const result = await adapter.fetchManyWhereAsync(
queryContext,
new SingleFieldHolder('stringField'),
new SingleFieldHolder<TestFields, 'customIdField', 'stringField'>('stringField'),
[new SingleFieldValueHolder('what'), new SingleFieldValueHolder('who')],
);
expect(Array.from(result.keys()).map((v) => v.fieldValue)).toEqual(['what', 'who']);
@@ -182,9 +165,11 @@ describe(EntityDatabaseAdapter, () => {
fetchResults: [{ string_field: null }],
});
await expect(
adapter.fetchManyWhereAsync(queryContext, new SingleFieldHolder('stringField'), [
new SingleFieldValueHolder('hello'),
]),
adapter.fetchManyWhereAsync(
queryContext,
new SingleFieldHolder<TestFields, 'customIdField', 'stringField'>('stringField'),
[new SingleFieldValueHolder('hello')],
),
).rejects.toThrow(
'One or more fields from the object is invalid for key SingleField(stringField); {"stringField":null}. This may indicate a faulty database adapter implementation.',
);
@@ -196,9 +181,11 @@ describe(EntityDatabaseAdapter, () => {
fetchResults: [{ string_field: undefined }],
});
await expect(
adapter.fetchManyWhereAsync(queryContext, new SingleFieldHolder('stringField'), [
new SingleFieldValueHolder('hello'),
]),
adapter.fetchManyWhereAsync(
queryContext,
new SingleFieldHolder<TestFields, 'customIdField', 'stringField'>('stringField'),
[new SingleFieldValueHolder('hello')],
),
).rejects.toThrow(
'One or more fields from the object is invalid for key SingleField(stringField); {}. This may indicate a faulty database adapter implementation.',
);
@@ -237,28 +224,6 @@ describe(EntityDatabaseAdapter, () => {
});
});
describe('fetchManyByFieldEqualityConjunction', () => {
it('transforms object', async () => {
const queryContext = instance(mock(EntityQueryContext));
const adapter = new TestEntityDatabaseAdapter({
fetchEqualityConditionResults: [{ string_field: 'hello' }],
});
const results = await adapter.fetchManyByFieldEqualityConjunctionAsync(queryContext, [], {});
expect(results).toEqual([{ stringField: 'hello' }]);
});
});
describe('fetchManyWithRawWhereClause', () => {
it('transforms object', async () => {
const queryContext = instance(mock(EntityQueryContext));
const adapter = new TestEntityDatabaseAdapter({
fetchRawWhereResults: [{ string_field: 'hello' }],
});
const results = await adapter.fetchManyByRawWhereClauseAsync(queryContext, 'hello', [], {});
expect(results).toEqual([{ stringField: 'hello' }]);
});
});
describe('insertAsync', () => {
it('transforms object', async () => {
const queryContext = instance(mock(EntityQueryContext));
@@ -772,7 +772,7 @@ describe('EntityMutator.processEntityDeletionForInboundEdgesAsync', () => {
'id'
>;
const childCachedBefore = await childCacheAdapter.loadManyAsync(
new SingleFieldHolder('parent_id'),
new SingleFieldHolder<ChildFields, 'id', 'parent_id'>('parent_id'),
[new SingleFieldValueHolder(parent.getID())],
);
expect(childCachedBefore.get(new SingleFieldValueHolder(parent.getID()))?.status).toEqual(
@@ -783,9 +783,9 @@ describe('EntityMutator.processEntityDeletionForInboundEdgesAsync', () => {
GrandChildEntity,
)['entityCompanion']['tableDataCoordinator'][
'cacheAdapter'
] as InMemoryFullCacheStubCacheAdapter<ChildFields, 'id'>;
] as InMemoryFullCacheStubCacheAdapter<GrandChildFields, 'id'>;
const grandChildCachedBefore = await grandChildCacheAdapter.loadManyAsync(
new SingleFieldHolder('parent_id'),
new SingleFieldHolder<GrandChildFields, 'id', 'parent_id'>('parent_id'),
[new SingleFieldValueHolder(child.getID())],
);
expect(grandChildCachedBefore.get(new SingleFieldValueHolder(child.getID()))?.status).toEqual(
@@ -797,7 +797,7 @@ describe('EntityMutator.processEntityDeletionForInboundEdgesAsync', () => {
privacyPolicyEvaluationRecords.shouldRecord = false;
const childCachedAfter = await childCacheAdapter.loadManyAsync(
new SingleFieldHolder('parent_id'),
new SingleFieldHolder<ChildFields, 'id', 'parent_id'>('parent_id'),
[new SingleFieldValueHolder(parent.getID())],
);
expect(childCachedAfter.get(new SingleFieldValueHolder(parent.getID()))?.status).toEqual(
@@ -805,7 +805,7 @@ describe('EntityMutator.processEntityDeletionForInboundEdgesAsync', () => {
);
const grandChildCachedAfter = await grandChildCacheAdapter.loadManyAsync(
new SingleFieldHolder('parent_id'),
new SingleFieldHolder<GrandChildFields, 'id', 'parent_id'>('parent_id'),
[new SingleFieldValueHolder(child.getID())],
);
expect(grandChildCachedAfter.get(new SingleFieldValueHolder(child.getID()))?.status).toEqual(
@@ -922,7 +922,7 @@ describe('EntityMutator.processEntityDeletionForInboundEdgesAsync', () => {
'id'
>;
const childCachedBefore = await childCacheAdapter.loadManyAsync(
new SingleFieldHolder('parent_id'),
new SingleFieldHolder<ChildFields, 'id', 'parent_id'>('parent_id'),
[new SingleFieldValueHolder(parent.getID())],
);
expect(childCachedBefore.get(new SingleFieldValueHolder(parent.getID()))?.status).toEqual(
@@ -933,9 +933,9 @@ describe('EntityMutator.processEntityDeletionForInboundEdgesAsync', () => {
GrandChildEntity,
)['entityCompanion']['tableDataCoordinator'][
'cacheAdapter'
] as InMemoryFullCacheStubCacheAdapter<ChildFields, 'id'>;
] as InMemoryFullCacheStubCacheAdapter<GrandChildFields, 'id'>;
const grandChildCachedBefore = await grandChildCacheAdapter.loadManyAsync(
new SingleFieldHolder('parent_id'),
new SingleFieldHolder<GrandChildFields, 'id', 'parent_id'>('parent_id'),
[new SingleFieldValueHolder(child.getID())],
);
expect(grandChildCachedBefore.get(new SingleFieldValueHolder(child.getID()))?.status).toEqual(
@@ -947,7 +947,7 @@ describe('EntityMutator.processEntityDeletionForInboundEdgesAsync', () => {
privacyPolicyEvaluationRecords.shouldRecord = false;
const childCachedAfter = await childCacheAdapter.loadManyAsync(
new SingleFieldHolder('parent_id'),
new SingleFieldHolder<ChildFields, 'id', 'parent_id'>('parent_id'),
[new SingleFieldValueHolder(parent.getID())],
);
expect(childCachedAfter.get(new SingleFieldValueHolder(parent.getID()))?.status).toEqual(
@@ -955,7 +955,7 @@ describe('EntityMutator.processEntityDeletionForInboundEdgesAsync', () => {
);
const grandChildCachedAfter = await grandChildCacheAdapter.loadManyAsync(
new SingleFieldHolder('parent_id'),
new SingleFieldHolder<GrandChildFields, 'id', 'parent_id'>('parent_id'),
[new SingleFieldValueHolder(child.getID())],
);
expect(grandChildCachedAfter.get(new SingleFieldValueHolder(child.getID()))?.status).toEqual(
@@ -2,8 +2,9 @@ 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 { EntityLoaderUtils } from '../EntityLoaderUtils';
import { ViewerContext } from '../ViewerContext';
import { SimpleTestEntity } from '../utils/__testfixtures__/SimpleTestEntity';
import { createUnitTestEntityCompanionProvider } from '../utils/__testfixtures__/createUnitTestEntityCompanionProvider';
@@ -27,11 +28,23 @@ describe(EntityLoader, () => {
});
});
describe('utils', () => {
it('returns a instance of EntityLoaderUtils', async () => {
describe('invalidationUtils', () => {
it('returns a instance of EntityInvalidationUtils', async () => {
const companionProvider = createUnitTestEntityCompanionProvider();
const viewerContext = new ViewerContext(companionProvider);
expect(SimpleTestEntity.loaderUtils(viewerContext)).toBeInstanceOf(EntityLoaderUtils);
expect(SimpleTestEntity.invalidationUtils(viewerContext)).toBeInstanceOf(
EntityInvalidationUtils,
);
});
});
describe('constructionUtils', () => {
it('returns a instance of EntityConstructionUtils', async () => {
const companionProvider = createUnitTestEntityCompanionProvider();
const viewerContext = new ViewerContext(companionProvider);
expect(SimpleTestEntity.constructionUtils(viewerContext)).toBeInstanceOf(
EntityConstructionUtils,
);
});
});
});
@@ -16,9 +16,9 @@ import { v4 as uuidv4 } from 'uuid';
import { AuthorizationResultBasedEntityLoader } from '../AuthorizationResultBasedEntityLoader';
import { EntityCompanionProvider } from '../EntityCompanionProvider';
import { EntityConfiguration } from '../EntityConfiguration';
import { EntityConstructionUtils } from '../EntityConstructionUtils';
import { EntityDatabaseAdapter } from '../EntityDatabaseAdapter';
import { EntityLoaderFactory } from '../EntityLoaderFactory';
import { EntityLoaderUtils } from '../EntityLoaderUtils';
import {
EntityCascadingDeletionInfo,
EntityMutationType,
@@ -370,10 +370,10 @@ const createEntityMutatorFactory = (
),
);
const customStubDatabaseAdapterProvider: IEntityDatabaseAdapterProvider = {
getDatabaseAdapter<TFields extends Record<'customIdField', any>>(
_entityConfiguration: EntityConfiguration<TFields, 'customIdField'>,
): EntityDatabaseAdapter<TFields, 'customIdField'> {
return databaseAdapter as any as EntityDatabaseAdapter<TFields, 'customIdField'>;
getDatabaseAdapter<TFields extends Record<string, any>, TIDField extends keyof TFields>(
_entityConfiguration: EntityConfiguration<TFields, TIDField>,
): EntityDatabaseAdapter<TFields, TIDField> {
return databaseAdapter as any as EntityDatabaseAdapter<TFields, TIDField>;
},
};
const metricsAdapter = new NoOpEntityMetricsAdapter();
@@ -1499,9 +1499,9 @@ describe(EntityMutatorFactory, () => {
keyof SimpleTestFields
>
>(AuthorizationResultBasedEntityLoader);
const entityLoaderUtilsMock =
const entityConstructionUtilsMock =
mock<
EntityLoaderUtils<
EntityConstructionUtils<
SimpleTestFields,
'id',
ViewerContext,
@@ -1509,9 +1509,9 @@ describe(EntityMutatorFactory, () => {
SimpleTestEntityPrivacyPolicy,
keyof SimpleTestFields
>
>(EntityLoaderUtils);
when(entityLoaderUtilsMock.constructEntity(anything())).thenReturn(fakeEntity);
when(entityLoaderMock.utils).thenReturn(instance(entityLoaderUtilsMock));
>(EntityConstructionUtils);
when(entityConstructionUtilsMock.constructEntity(anything())).thenReturn(fakeEntity);
when(entityLoaderMock.constructionUtils).thenReturn(instance(entityConstructionUtilsMock));
const entityLoader = instance(entityLoaderMock);
const entityLoaderFactoryMock =
@@ -1634,9 +1634,9 @@ describe(EntityMutatorFactory, () => {
keyof SimpleTestFields
>
>(AuthorizationResultBasedEntityLoader);
const entityLoaderUtilsMock =
const entityConstructionUtilsMock =
mock<
EntityLoaderUtils<
EntityConstructionUtils<
SimpleTestFields,
'id',
ViewerContext,
@@ -1644,9 +1644,9 @@ describe(EntityMutatorFactory, () => {
SimpleTestEntityPrivacyPolicy,
keyof SimpleTestFields
>
>(EntityLoaderUtils);
when(entityLoaderUtilsMock.constructEntity(anything())).thenReturn(fakeEntity);
when(entityLoaderMock.utils).thenReturn(instance(entityLoaderUtilsMock));
>(EntityConstructionUtils);
when(entityConstructionUtilsMock.constructEntity(anything())).thenReturn(fakeEntity);
when(entityLoaderMock.constructionUtils).thenReturn(instance(entityConstructionUtilsMock));
const entityLoader = instance(entityLoaderMock);
const entityLoaderFactoryMock =
@@ -70,7 +70,7 @@ describe(EntitySecondaryCacheLoader, () => {
const secondaryEntityCache = instance(secondaryEntityCacheMock);
const loader = SimpleTestEntity.loaderWithAuthorizationResults(vc1);
const spiedPrivacyPolicy = spy(loader.utils['privacyPolicy']);
const spiedPrivacyPolicy = spy(loader.constructionUtils['privacyPolicy']);
const secondaryCacheLoader = new TestSecondaryRedisCacheLoader(secondaryEntityCache, loader);
const result = await secondaryCacheLoader.loadManyAsync([loadParams]);
@@ -250,7 +250,7 @@ describe('EntityEdgeDeletionBehavior.CASCADE_DELETE_INVALIDATE_CACHE', () => {
'cacheAdapter'
] as InMemoryFullCacheStubCacheAdapter<CategoryFields, 'id'>;
const subCategoryCachedBefore = await categoryCacheAdapter.loadManyAsync(
new SingleFieldHolder('parent_category_id'),
new SingleFieldHolder<CategoryFields, 'id', 'parent_category_id'>('parent_category_id'),
[new SingleFieldValueHolder(parentCategory.getID())],
);
expect(
@@ -258,7 +258,7 @@ describe('EntityEdgeDeletionBehavior.CASCADE_DELETE_INVALIDATE_CACHE', () => {
).toEqual(CacheStatus.HIT);
const subSubCategoryCachedBefore = await categoryCacheAdapter.loadManyAsync(
new SingleFieldHolder('parent_category_id'),
new SingleFieldHolder<CategoryFields, 'id', 'parent_category_id'>('parent_category_id'),
[new SingleFieldValueHolder(subCategory.getID())],
);
expect(
@@ -268,7 +268,7 @@ describe('EntityEdgeDeletionBehavior.CASCADE_DELETE_INVALIDATE_CACHE', () => {
await CategoryEntity.deleter(parentCategory).deleteAsync();
const subCategoryCachedAfter = await categoryCacheAdapter.loadManyAsync(
new SingleFieldHolder('parent_category_id'),
new SingleFieldHolder<CategoryFields, 'id', 'parent_category_id'>('parent_category_id'),
[new SingleFieldValueHolder(parentCategory.getID())],
);
expect(
@@ -276,7 +276,7 @@ describe('EntityEdgeDeletionBehavior.CASCADE_DELETE_INVALIDATE_CACHE', () => {
).toEqual(CacheStatus.MISS);
const subSubCategoryCachedAfter = await categoryCacheAdapter.loadManyAsync(
new SingleFieldHolder('parent_category_id'),
new SingleFieldHolder<CategoryFields, 'id', 'parent_category_id'>('parent_category_id'),
[new SingleFieldValueHolder(subCategory.getID())],
);
expect(
@@ -329,7 +329,7 @@ describe('EntityEdgeDeletionBehavior.CASCADE_DELETE_INVALIDATE_CACHE', () => {
'cacheAdapter'
] as InMemoryFullCacheStubCacheAdapter<CategoryFields, 'id'>;
const categoriesCachedBefore = await categoryCacheAdapter.loadManyAsync(
new SingleFieldHolder('parent_category_id'),
new SingleFieldHolder<CategoryFields, 'id', 'parent_category_id'>('parent_category_id'),
[
new SingleFieldValueHolder(categoryA.getID()),
new SingleFieldValueHolder(categoryB.getID()),
@@ -345,7 +345,7 @@ describe('EntityEdgeDeletionBehavior.CASCADE_DELETE_INVALIDATE_CACHE', () => {
await CategoryEntity.deleter(categoryA).deleteAsync();
const categoriesCachedAfter = await categoryCacheAdapter.loadManyAsync(
new SingleFieldHolder('parent_category_id'),
new SingleFieldHolder<CategoryFields, 'id', 'parent_category_id'>('parent_category_id'),
[
new SingleFieldValueHolder(categoryA.getID()),
new SingleFieldValueHolder(categoryB.getID()),
@@ -22,7 +22,7 @@ describe(GenericEntityCacheAdapter, () => {
const mockGenericCacher = mock<IEntityGenericCacher<BlahFields, 'id'>>();
when(
mockGenericCacher.makeCacheKeyForStorage(
deepEqualEntityAware(new SingleFieldHolder('id')),
deepEqualEntityAware(new SingleFieldHolder<BlahFields, 'id', 'id'>('id')),
anything(),
),
).thenCall((fieldHolder, fieldValueHolder) => {
@@ -38,11 +38,14 @@ describe(GenericEntityCacheAdapter, () => {
const cacheAdapter = new GenericEntityCacheAdapter(instance(mockGenericCacher));
const results = await cacheAdapter.loadManyAsync(new SingleFieldHolder('id'), [
new SingleFieldValueHolder('wat'),
new SingleFieldValueHolder('who'),
new SingleFieldValueHolder('why'),
]);
const results = await cacheAdapter.loadManyAsync(
new SingleFieldHolder<BlahFields, 'id', 'id'>('id'),
[
new SingleFieldValueHolder('wat'),
new SingleFieldValueHolder('who'),
new SingleFieldValueHolder('why'),
],
);
expect(results.get(new SingleFieldValueHolder('wat'))).toMatchObject({
status: CacheStatus.HIT,
item: { id: 'wat' },
@@ -74,7 +77,7 @@ describe(GenericEntityCacheAdapter, () => {
const mockGenericCacher = mock<IEntityGenericCacher<BlahFields, 'id'>>();
when(
mockGenericCacher.makeCacheKeyForStorage(
deepEqualEntityAware(new SingleFieldHolder('id')),
deepEqualEntityAware(new SingleFieldHolder<BlahFields, 'id', 'id'>('id')),
anything(),
),
).thenCall((fieldHolder, fieldValueHolder) => {
@@ -85,7 +88,7 @@ describe(GenericEntityCacheAdapter, () => {
const cacheAdapter = new GenericEntityCacheAdapter(instance(mockGenericCacher));
await expect(
cacheAdapter.loadManyAsync(new SingleFieldHolder('id'), [
cacheAdapter.loadManyAsync(new SingleFieldHolder<BlahFields, 'id', 'id'>('id'), [
new SingleFieldValueHolder('wat'),
]),
).rejects.toThrow(expectedError);
@@ -99,7 +102,7 @@ describe(GenericEntityCacheAdapter, () => {
const mockGenericCacher = mock<IEntityGenericCacher<BlahFields, 'id'>>();
when(
mockGenericCacher.makeCacheKeyForStorage(
deepEqualEntityAware(new SingleFieldHolder('id')),
deepEqualEntityAware(new SingleFieldHolder<BlahFields, 'id', 'id'>('id')),
anything(),
),
).thenCall((fieldHolder, fieldValueHolder) => {
@@ -108,7 +111,7 @@ describe(GenericEntityCacheAdapter, () => {
const cacheAdapter = new GenericEntityCacheAdapter(instance(mockGenericCacher));
await cacheAdapter.cacheManyAsync(
new SingleFieldHolder('id'),
new SingleFieldHolder<BlahFields, 'id', 'id'>('id'),
new Map([[new SingleFieldValueHolder('wat'), { id: 'wat' }]]),
);
@@ -123,7 +126,7 @@ describe(GenericEntityCacheAdapter, () => {
const mockGenericCacher = mock<IEntityGenericCacher<BlahFields, 'id'>>();
when(
mockGenericCacher.makeCacheKeyForStorage(
deepEqualEntityAware(new SingleFieldHolder('id')),
deepEqualEntityAware(new SingleFieldHolder<BlahFields, 'id', 'id'>('id')),
anything(),
),
).thenCall((fieldHolder, fieldValueHolder) => {
@@ -131,7 +134,7 @@ describe(GenericEntityCacheAdapter, () => {
});
const cacheAdapter = new GenericEntityCacheAdapter(instance(mockGenericCacher));
await cacheAdapter.cacheDBMissesAsync(new SingleFieldHolder('id'), [
await cacheAdapter.cacheDBMissesAsync(new SingleFieldHolder<BlahFields, 'id', 'id'>('id'), [
new SingleFieldValueHolder('wat'),
]);
@@ -144,7 +147,7 @@ describe(GenericEntityCacheAdapter, () => {
const mockGenericCacher = mock<IEntityGenericCacher<BlahFields, 'id'>>();
when(
mockGenericCacher.makeCacheKeysForInvalidation(
deepEqualEntityAware(new SingleFieldHolder('id')),
deepEqualEntityAware(new SingleFieldHolder<BlahFields, 'id', 'id'>('id')),
anything(),
),
).thenCall((fieldHolder, fieldValueHolder) => {
@@ -152,7 +155,7 @@ describe(GenericEntityCacheAdapter, () => {
});
const cacheAdapter = new GenericEntityCacheAdapter(instance(mockGenericCacher));
await cacheAdapter.invalidateManyAsync(new SingleFieldHolder('id'), [
await cacheAdapter.invalidateManyAsync(new SingleFieldHolder<BlahFields, 'id', 'id'>('id'), [
new SingleFieldValueHolder('wat'),
]);
@@ -163,7 +166,7 @@ describe(GenericEntityCacheAdapter, () => {
const mockGenericCacher = mock<IEntityGenericCacher<BlahFields, 'id'>>();
when(
mockGenericCacher.makeCacheKeysForInvalidation(
deepEqualEntityAware(new SingleFieldHolder('id')),
deepEqualEntityAware(new SingleFieldHolder<BlahFields, 'id', 'id'>('id')),
anything(),
),
).thenCall((fieldHolder, fieldValueHolder) => {
@@ -108,11 +108,7 @@ class TestSecondaryCacheLoader extends EntitySecondaryCacheLoader<
const emptyMap = new Map(loadParamsArray.map((p) => [p, null]));
return await mapMapAsync(emptyMap, async (_value, loadParams) => {
return (
(
await this.entityLoader.loadManyByFieldEqualityConjunctionAsync([
{ fieldName: 'intField', fieldValue: loadParams.intValue },
])
)[0]
(await this.entityLoader.loadManyByFieldEqualingAsync('intField', loadParams.intValue))[0]
?.enforceValue()
?.getAllFields() ?? null
);
@@ -5,7 +5,8 @@ import { AuthorizationResultBasedEntityAssociationLoader } from '../Authorizatio
import { AuthorizationResultBasedEntityLoader } from '../AuthorizationResultBasedEntityLoader';
import { EnforcingEntityAssociationLoader } from '../EnforcingEntityAssociationLoader';
import { EnforcingEntityLoader } from '../EnforcingEntityLoader';
import { EntityLoaderUtils } from '../EntityLoaderUtils';
import { EntityConstructionUtils } from '../EntityConstructionUtils';
import { EntityInvalidationUtils } from '../EntityInvalidationUtils';
import { ReadonlyEntity } from '../ReadonlyEntity';
import { ViewerContext } from '../ViewerContext';
import { SimpleTestEntity } from '../utils/__testfixtures__/SimpleTestEntity';
@@ -214,11 +215,23 @@ describe(ReadonlyEntity, () => {
});
});
describe('loaderUtils', () => {
it('creates a new EntityLoaderUtils', async () => {
describe('invalidationUtils', () => {
it('creates a new EntityInvalidationUtils', async () => {
const companionProvider = createUnitTestEntityCompanionProvider();
const viewerContext = new ViewerContext(companionProvider);
expect(SimpleTestEntity.loaderUtils(viewerContext)).toBeInstanceOf(EntityLoaderUtils);
expect(SimpleTestEntity.invalidationUtils(viewerContext)).toBeInstanceOf(
EntityInvalidationUtils,
);
});
});
describe('constructionUtils', () => {
it('creates a new EntityConstructionUtils', async () => {
const companionProvider = createUnitTestEntityCompanionProvider();
const viewerContext = new ViewerContext(companionProvider);
expect(SimpleTestEntity.constructionUtils(viewerContext)).toBeInstanceOf(
EntityConstructionUtils,
);
});
});
});
@@ -33,5 +33,6 @@ describe(ViewerScopedEntityCompanion, () => {
expect(viewerScopedEntityCompanion.getMutatorFactory()).toBeInstanceOf(
ViewerScopedEntityMutatorFactory,
);
expect(viewerScopedEntityCompanion.getMetricsAdapter()).toBeDefined();
});
});
Loaded 100 of 119 files, more files were not shown because too many files have changed in this diff. Show more