Compare commits

...
Author SHA1 Message Date
Will Schurman b9db8a4717 v0.56.0 2026-02-27 17:27:31 -08:00
Will Schurman 🦦 459a5ff850 feat: add entityField SQL helper (#481)
# Why

Asked claude to do an API usability review and it noted that forcing the user to context-switch between entity fields and database fields for SQLFragments was an unfortunate pattern.

# How

This PR provides a SQLFragment helper, `entityField<TFields>('fieldName')` which does the conversion for the user so they don't need to look up the underlying database field.

Secondarily, this provides type safety to ensure SQLFragments for one entity aren't used for SQLFragments of another entity (unless types overlap via shared config).

# Test Plan

Add feature and tests and examples.
2026-02-27 15:44:23 -08:00
Will Schurman 🦦 beda973c0c chore: raw -> unsafeRaw (#480)
# Why

Asked claude to do a security review of entity-database-adapter-knex and renaming `raw` to `unsafeRaw` was one suggestion.

# How

Rename.

# Test Plan

`yarn tsc`
2026-02-27 15:40:10 -08:00
renovate[bot] b258dad2ed fix(deps): update dependency koa to v3.1.2 [security] (#484) 2026-02-27 09:28:08 -08:00
renovate[bot] 976e2c3386 fix(deps): update dependency graphql to v16.13.0 (#479) 2026-02-27 09:02:48 -08:00
Will Schurman 🦦 e32c4c2bcc fix: correct pagination behavior for standard pagination with descending ordering clauses (#477)
# Why

This bug was subtle but important, and can be demonstrated best by looking at the new test case added `performs forward pagination with descending order`.

The bug was that cursor comparison must be derived from both direction but also ASC/DESC of the order bys.

Example:
```
obj1(t=1)
obj2(t=2)
obj3(t=3)
obj4(t=4)

> loadPage(first: 2, orderBy: t DESC)
obj4
obj3

> loadPage(first: 2, orderBy: t DESC, after: obj3)
```

the issue is that the cursor comparison for this second load would be:
```
(t) > (obj3.t)
```
which yields only obj4, which is incorrect.

# How

The fix is to determine cursor comparator by both the order by direction and the pagination direction, thus making the cursor from the example above:
```
(t) < (obj3.t)
```

The second part of this is to require all orderBys to have the same ordering for pagination, otherwise this new assumption breaks since cursor comparison then would be unable to be done with a single comparator. While there are ways we can handle different order by directions, we'd have to not use a cursor which adds a massive amount of complexity.

A small other fix was to set the `id` tiebreaker column order to the same as the other orders.

# Test Plan

Add new test case.
2026-02-26 18:35:23 -08:00
Will Schurman 🦦 2c4c649a4b fix: correct precedence parentheses for SQLFragment joins (#476)
# Why

There's a bug in where clause construction, which used SQLFragmentHelpers.and: https://github.com/expo/entity/blob/main/packages/entity-database-adapter-knex/src/internal/EntityKnexDataManager.ts#L303

The bug is that given something like:
```
where: sql`account_id = ${accountId}`,
searchWhere: sql`something ILIKE term OR similarity(term, ...) OR ...`
```
it would produce
```
WHERE account_id = ${accountId} AND something ILIKE term OR similarity(term, ...) OR ...
```

which has the incorrect parentheses and therefore incorrect precedence.

This exposed more bugs in the precedence in `or`, `and`, `join`, etc methods.

# How

The fix is multifold:
- Make the public `join` method only be used for comma-separation joining. Rename it to `joinWithCommaSeparator`.
- Wrap each group in `or` and `and` in parentheses to ensure correct precedence.
    ```
    > SQLFragment.or(sql`account_id = ${accountId}`, sql`app_id = ${appId}`
    sql`(account_id = ${accountId}) OR (app_id = ${appId})`
    ```

# Test Plan

Run existing tests to ensure nothing breaks, update unit tests and inspect output SQL statements to ensure they are correct parentheses. Especially the `builds complex queries with multiple helpers` test case.
2026-02-26 18:31:23 -08:00
Will Schurman 🦦 898f71ce08 feat: add method to get pagination cursor for single entity (#475)
# Why

In a decent number of tests in the Expo server application, it's useful to test pagination from a specific point. It may also be useful for production code in an thus far unbeknownst way.

To do this, one must be able to generate the cursor given an entity. This PR adds such a method.

# How

Add the method that creates a cursor.

# Test Plan

Run the new test.
2026-02-26 18:27:23 -08:00
Will Schurman 🦦 90ae6e299b fix: correct discriminated union type for forward/backward pagination (#474)
# Why

When testing this downstream, I noticed that it was allowed in types to specify first & before / last & after which aren't supported in the pagination.

# How

Fix the types by being explicit about the other fields.

# Test Plan

`yarn tsc`
2026-02-26 18:23:23 -08:00
Will Schurman 🦦 53b0604152 feat: support nullable search fields with postgres transforms (#470)
# Why

#468 added nulls support for regular order bys, but explicitly disabled it for search pagination due to it adding quite a lot of complexity. It also fixed a bug where using a nullable field in pagination in general would break the cursor condition and omit rows (any tuple containing NULL would be dropped due to postgres behavior).

But applications still need to be able to search fields that are nullable, they just need to provide a mechanism for dictating how to cast them as non-nullable. This PR provides such a mechanism.

# How

Allow the user to specify a function to craft the search field SQLFragment from nullable fields. This is required to be a somewhat awkward shape since the library needs to retain control over the field name due to table aliasing in the cursor.

# Test Plan

Run new tests.
2026-02-26 18:19:23 -08:00
Will Schurman 🦦 018a40bc01 fix: Apply same viewer context subclass type mechanism to knexLoader and related methods (#472)
# Why

When these were installed extension functions, we could get away without the `TMViewerContext2` technique. But now that these are loader methods on the `PostgresEntity` subclass, we need this again (it's the same as Entity and ReadonlyEntity).

This type allows the typechecker to behave correctly when a subclass of ViewerContext is used.

# How

Add generic the same as ReadonlyEntity/Entity.

# Test Plan

`yarn tsc` in application that uses ViewerContext subclass and knexLoader.
2026-02-26 18:15:19 -08:00
renovate[bot] 6849468d15 chore(deps): update postgres:18 docker digest to 5558651 (#478) 2026-02-26 17:08:34 -08:00
renovate[bot] c60f4cd455 chore(deps): update dependency @types/lodash to v4.17.24 (#471) 2026-02-26 14:10:28 -08:00
renovate[bot] 820fe88217 chore(deps): update postgres:18 docker digest to a688ae5 (#473) 2026-02-26 14:10:03 -08:00
Will Schurman 🦦 6c0838dc57 feat: add support for nulls ordering in order by clauses (#468) 2026-02-25 15:15:15 -08:00
renovate[bot] 5c11b598c4 chore(deps): update redis docker digest to 5cb00b0 (#467) 2026-02-24 18:47:25 -08:00
Will Schurman 🦦 c946630a82 chore: deprecate loadManyByRawWhereClause (#460)
# Why

Now that `loadManyBySQL` supports all the features of this method (including raw order by), deprecate it.

# How

Mark as deprecated in docblock.

# Test Plan

`yarn tsc`
2026-02-24 16:16:23 -08:00
Will Schurman 🦦 15adeb88cf feat!: convert all raw order bys to use same mechanism and syntax (#459)
# Why

#457 added a new mechanism for specifying ORDER BY clauses using SQLFragment that aligns a lot better with the loader APIs than having separate `orderByRaw`/`orderByFragment` fields in the query modifiers.

# How

Replace all with the new consistent order by in order field, update code to handle that.

One neat side-effect is that we no longer need the hard-to-follow direction-specific ordering in deep methods like `buildTrigramExactMatchPriority` since now we can have those just produce a SQLFragment and reverse the ordering the same as all the other pagination methods.

# Test Plan

Run all tests.
2026-02-24 16:12:25 -08:00
Will Schurman 🦦 9940fcbed2 chore: SQLFragment.toDebugString -> getDebugString (#458)
# Why

Small change to the debug method for SQLFragment. Noticed claude made it a `get toDebugString`, which doesn't quite fit within the patterns in this repo.

# How

Rename to `getDebugString()` method for consistency.

# Test Plan

`yarn tsc`
2026-02-24 16:08:23 -08:00
Will Schurman 🦦 0615ffacff feat: Add raw sqlfragment orderBy capability for pagination (#457)
# Why

One missing piece from pagination is the ability to order by a SQL fragment. This is useful for cases like needing to apply postgres transforms or coalescing to the columns in the order by clause.

Note that this, in combination with `loadBySQL` will completely replace the need for `loadManyByRawWhereClause` (including it's orderByRaw variant), as well as `orderByFragment` in the new pagination code, which is done in a PR upstack.

# How

Make order by specifications either be able to use SQLFragment to specify the field selection but not the ASC/DESC since the framework still needs to retain control of those for pagination purposes.

# Test Plan

Run new added tests.
2026-02-24 16:04:31 -08:00
renovate[bot] 5f3f31bb7c chore(deps): update postgres:18 docker digest to 9b5bd94 (#466) 2026-02-24 16:03:20 -08:00
renovate[bot] 097ac65fc4 chore(deps): update redis docker digest to db01931 (#465) 2026-02-24 16:03:06 -08:00
Will Schurman 🦦 e71ae5fcba feat: Add authorization action to EntityPrivacyPolicyRuleEvaluationContext (#464) 2026-02-24 15:55:28 -08:00
renovate[bot] 3065f360db chore(deps): update node.js to v24.14.0 (#463) 2026-02-24 11:46:37 -08:00
renovate[bot] c3fe15e655 chore(deps): update redis docker digest to dc123d5 (#461) 2026-02-23 17:02:33 -08:00
renovate[bot] 2d41777be0 chore(deps): update dependency eslint to v9.39.3 (#456) 2026-02-23 07:40:49 -08:00
renovate[bot] 45da844a5c chore(deps): lock file maintenance (#455) 2026-02-23 07:36:40 -08:00
Will Schurman 🦦 4dc156c7a8 fix: explicitly document behavior when cursor entity no longer exists (#453)
# Why

The one downside of using id-based cursor pagination and subqueries (added in #422, #431) is that it risks the row referenced by the cursor being deleted between pagination requests. It's an edge case though to be clear. Encoding the full cursor alleviates this, but has it's own downsides since trigram similarity isn't encodable and neither are things like Date objects. It is still preferable to use ID-based cursors for all pagination.

But it becomes the library's responsibility to explicitly document what the behavior is when a row referenced by the cursor is no longer present. This PR does this.

# How

When the cursor row is no longer present, the tuple evaluates to `NULL`, which produces a empty page.

The alternative to this behavior is to run an id check query ahead of the pagination query, and throw an error if it doesn't exist, or even return the first page of data if it doesn't exist. Both of these are less optimal since they cause unexpected behavior during results consumption.

So we keep the behavior as is and explicitly document it.

# Test Plan

Run new tests.
2026-02-17 14:56:23 -08:00
Will Schurman 🦦 cc78029117 fix: eliminate some unnecessary any casts (#448)
# Why

Did a brief audit of `any` casts in the library and removed ones that are no longer needed or could be replaced with a better concrete type.

# How

Grep for `as any`, fix.

# Test Plan

`yarn tsc`
2026-02-17 14:52:24 -08:00
Will Schurman 🦦 517d57b5df fix!: change adaptor -> adapter for consistency (#452)
# Why

As part of #451, claude noticed that we used both american and british english variants of "adapter"/"adaptor". This makes them consistent by making them all "adapter".

# How

Update name. Technically this is a breaking change but the nature of the library allows `tsc` to catch it in all application code that uses the library.

# Test Plan

`yarn tsc`
2026-02-17 14:48:25 -08:00
Will Schurman 🦦 c933db1f8a chore: docblock improvements (#451)
# Why

Asked claude to audit the docblocks for incorrect or missing info. This is what it mentioned as being good improvements.

# How

Update docblocks.

# Test Plan

Proofread.
2026-02-17 14:44:24 -08:00
Will Schurman 🦦 d9377a290b fix: Reorganize constructionUtils to make non-public (#454)
# Why

In #410 we split out `EntityLoaderUtils` into two classes:
- EntityInvalidationUtils - functions for invalidating entity caches from application code when underlying data is mutated outside of the entity framework
- EntityConstructorUtils - functions for constructing and authorizing entities.

Originally, these were both public due to them being the same class. Now, with them separated, we can better restrict access to entity construction which should only be done within the framework itself.

# How

Make constructor utils private everywhere except EntityLoaderFactory, which is deep enough in the abstraction that it's not immediately usable by top-level entity APIs (static methods on Entity/ReadonlyEntity, loader classes).

# Test Plan

`yarn tsc`, CI
2026-02-17 14:40:57 -08:00
Will Schurman 🦦 457f58ad2c fix: update integration test to use BlahEntity.knexLoader syntax (#444)
# Why

Part 4 of #441. This reverts the test changes to use the better pattern, though both work equivalently and are both tested.

# How

Update tests.

# Test Plan

Run tests
2026-02-16 13:26:21 -08:00
Will Schurman 🦦 ed6b2e0553 fix: revert change to codemod now that Entity.knexLoader is supported again via subclass (#443)
# Why

Part 3 of the refactor from #441.

This reverts the codemod back to use the `BlahEntity.knexLoader(vc)` syntax.

# How

`git revert`

# Test Plan

Run the tests in codemod.
2026-02-16 13:22:22 -08:00
Will Schurman 🦦 40b409f6f8 feat: add PostgresEntity/ReadonlyPostgresEntity classes that expose knexLoader methods (#442)
# Why

This is part 2 of the refactor described in #441.

It adds subclasses of Entity and ReadonlyEntity that supply the static `knexLoader` methods to keep the ergonomics of the extension but without the monkey-patching.

# How

Add subclasses, add test.

# Test Plan

Run test.
2026-02-16 13:18:22 -08:00
Will Schurman 🦦 51d632da16 fix: refactor install method to instead be free functions (#441)
# Why

Asked claude extended thinking to analyze the `installExtensions` monkey-patching strategy we went with in  #410 and compare it to other typescript ORM libraries.

It said essentially that it was ok, but most modern libraries choose not to do it that way, and instead recommended "free functions" (TIL about that term) to instantiate the loaders. (`knexLoader(BlahEntity, vc)`)

Working with it even more, I came up with an even better method on top of free functions to keep the `BlahEntity.knexLoader(vc)` syntax. The strategy is to:
1. Convert to free functions (this PR).
2. Add `PostgresEntity`/`ReadonlyPostgresEntity` subclasses that application entities can extend that implement the static extension methods.
3. Revert the codemod change from this PR to keep the codemod to prefer the `BlahEntity.knexLoader(vc)` syntax since most applications should use the subclasses (though it's not strictly necessary if they prefer the free function at callsites).
4. Revert the codemod in the integration tests to express better syntax pattern.

# How

Claude did most of this, but broadly speaking the strategy is to have the free functions, and then instead of patching the prototypes/classes for singleton caching, use a WeakMap where the core package singleton is the key and the value is the new knex-specific object.

# Test Plan

Run all tests.
2026-02-16 13:14:21 -08:00
Will Schurman 🦦 3d4aa0e9ef fix: Add default for TSelectedFields for application-instantiated types (#438)
# Why

When testing an upgrade to this package in the Expo server application, noticed that this was a minor annoyance since TSelectedFields has a default in all other public API methods.

# How

Add default to be consistent.

# Test Plan

`yarn tsc`
2026-02-16 13:10:22 -08:00
Will Schurman 🦦 f5c728be56 fix: pass in augmentable classes to installation (#437)
# Why

An idea proposed by @ide in https://github.com/expo/entity/pull/410#pullrequestreview-3765893898.

This makes it more testable and clearer about what objects installations are "allowed" to augment.

# How

Pass in the classes explicitly.

# Test Plan

`yarn tsc`
2026-02-16 13:06:21 -08:00
renovate[bot] 285f22c461 chore(deps): update dependency typedoc to v0.28.17 (#449) 2026-02-16 13:03:44 -08:00
Will Schurman 🦦 3ec4b5f0cd feat: Add pagination max page size configuration (#436)
# Why

Currently, the Entity pagination system allows clients to request arbitrarily large page sizes using the `first` or `last` parameters in pagination queries. This can lead to:
- Performance degradation when clients request excessively large pages
- Potential memory issues on the server
- Unpredictable query response times
- Difficulty in capacity planning and resource allocation

By adding configurable max page size limits, we can:
- Protect backend services from resource exhaustion
- Ensure predictable performance characteristics
- Provide better control over API usage patterns
- Maintain backward compatibility (no limit by default)

# How

The implementation adds a `paginationMaxPageSize` configuration option to the PostgresEntityDatabaseAdapterProvider that flows through the adapter hierarchy:

1. **Configuration Interface**: Added `PostgresEntityDatabaseAdapterConfiguration` with an optional `paginationMaxPageSize` field
2. **Provider Level**: `PostgresEntityDatabaseAdapterProvider` constructor now accepts the configuration object
3. **Adapter Level**:
   - `BasePostgresEntityDatabaseAdapter` defines a `paginationMaxPageSize` getter (defaults to `undefined`)
   - `PostgresEntityDatabaseAdapter` overrides it to return the configured value
4. **Validation**: `EntityKnexDataManager.loadPageAsync` validates `first`/`last` parameters against the max page size before executing queries

The validation throws descriptive errors when limits are exceeded:
- `"first must not exceed maximum page size of {limit}"`
- `"last must not exceed maximum page size of {limit}"`

Usage example:
```typescript
const provider = new PostgresEntityDatabaseAdapterProvider({
  paginationMaxPageSize: 100
});
```

# Test Plan

Added comprehensive unit tests in `EntityKnexDataManager-test.ts`.
2026-02-16 13:02:21 -08:00
Will Schurman 🦦 f72cbcaf5a feat: Add ilike and trigram similarity search to pagination (#431)
# Why

This PR adds search capabilities to the existing cursor-based pagination system added in #422 which only supported orderBy ordering.

This adds:
1. Case-insensitive pattern matching (ILIKE) - Useful for basic text search across entity fields
2. Trigram similarity search - Provides fuzzy matching capabilities for more advanced search use cases like handling typos or finding similar text

This enables building user-facing search features while maintaining the benefits of cursor-based pagination (stable results, efficient queries).

# How

The implementation introduces a unified PaginationSpecification that supports three strategies:

  1. Standard pagination - The existing orderBy-based pagination
  2. ILIKE search - Pattern matching with automatic wildcard escaping
  3. Trigram search - PostgreSQL trigram similarity with configurable threshold

  Key implementation details:
  - Search terms are properly parameterized to prevent SQL injection
  - ILIKE special characters (%, _, \) are escaped to prevent pattern injection
  - Results maintain stable cursor-based pagination with proper ordering:
    - ILIKE: Ordered by search fields, then ID
    - Trigram: Ordered by exact match priority, similarity score, optional extra fields, then ID

# Test Plan

  The PR includes comprehensive test coverage:

  - Unit tests for both AuthorizationResultBasedKnexEntityLoader and
  EnforcingKnexEntityLoader
  - Integration tests covering:
    - Forward/backward pagination with search
    - Multi-field search
    - Case-insensitive matching
    - Trigram similarity with various thresholds
    - Cursor stability across pages
    - Edge cases (empty results, special characters)
    - Combined with WHERE clauses
    - Security validation ensuring proper escaping and parameterization

All existing tests pass, confirming backward compatibility for standard pagination when using the new API.
2026-02-16 12:58:24 -08:00
renovate[bot] fb720611f0 chore(deps): lock file maintenance (#447) 2026-02-15 23:33:12 -08:00
135 changed files with 7250 additions and 5311 deletions

No files matched your search

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