Compare commits

..
Author SHA1 Message Date
Will Schurman 7dab49a36d v0.58.0 2026-03-02 12:04:25 -08:00
Will Schurman 🦦 21f2f6f461 fix: escape all matching portions of parts of cacher cache keys (#486) 2026-03-02 10:02:17 -08:00
renovate[bot] 4126dcd8f2 chore(deps): lock file maintenance (#493) 2026-03-02 08:04:18 -08:00
renovate[bot] 6889178e60 chore(deps): update dependency ioredis to v5.10.0 (#494) 2026-03-02 07:54:41 -08:00
renovate[bot] a0d3d30265 chore(deps): update dependency @types/node to v24.10.15 (#491) 2026-03-02 07:54:05 -08:00
renovate[bot] d0825c0446 chore(deps): update redis docker digest to 1c054d5 (#492) 2026-03-02 07:51:59 -08:00
renovate[bot] e8440f11c0 chore(deps): update dependency eslint-plugin-tsdoc to v0.5.2 (#488) 2026-03-02 07:51:43 -08:00
renovate[bot] baa1674629 chore(deps): update dependency @types/supertest to v7 (#490) 2026-03-02 07:51:24 -08:00
renovate[bot] 25520c68e8 chore(deps): update dependency pg to v8.19.0 (#489) 2026-03-02 07:50:48 -08:00
renovate[bot] 905e8522b3 chore(deps): update postgres:18 docker digest to 69e8582 (#487) 2026-03-02 07:50:20 -08:00
Will Schurman 🦦 e263bd218c fix: fix some security review suggestions (#485) 2026-02-27 17:59:51 -08:00
Will Schurman e876cfb27b v0.57.0 2026-02-27 17:30:00 -08:00
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
118 changed files with 4545 additions and 4725 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
+51
View File
@@ -3,6 +3,57 @@
All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
# [0.58.0](https://github.com/expo/entity/compare/v0.57.0...v0.58.0) (2026-03-02)
### Bug Fixes
- escape all matching portions of parts of cacher cache keys ([#486](https://github.com/expo/entity/issues/486)) ([21f2f6f](https://github.com/expo/entity/commit/21f2f6f461ee771e8b1c1b22dbaf9b275094c0db))
- fix some security review suggestions ([#485](https://github.com/expo/entity/issues/485)) ([e263bd2](https://github.com/expo/entity/commit/e263bd218cda579365766d0918c4961304bd1d9f))
# [0.57.0](https://github.com/expo/entity/compare/v0.56.0...v0.57.0) (2026-02-28)
**Note:** Version bump only for package root
# [0.56.0](https://github.com/expo/entity/compare/v0.55.0...v0.56.0) (2026-02-28)
### Bug Fixes
- Add default for TSelectedFields for application-instantiated types ([#438](https://github.com/expo/entity/issues/438)) ([3d4aa0e](https://github.com/expo/entity/commit/3d4aa0e9efa519a36f3db69bd8f117a993c100fa))
- Apply same viewer context subclass type mechanism to knexLoader and related methods ([#472](https://github.com/expo/entity/issues/472)) ([018a40b](https://github.com/expo/entity/commit/018a40bc01d579ffbf88f308e641921e6fc39097))
- correct discriminated union type for forward/backward pagination ([#474](https://github.com/expo/entity/issues/474)) ([90ae6e2](https://github.com/expo/entity/commit/90ae6e299bda26ac9a80d83e83334d4152c022dd))
- correct pagination behavior for standard pagination with descending ordering clauses ([#477](https://github.com/expo/entity/issues/477)) ([e32c4c2](https://github.com/expo/entity/commit/e32c4c2bcc05f225a556f95c90f1ee0f4e24de74))
- correct precedence parentheses for SQLFragment joins ([#476](https://github.com/expo/entity/issues/476)) ([2c4c649](https://github.com/expo/entity/commit/2c4c649a4bca5bc9ffdc41eb3135b2dfe4db5e57)), closes [/github.com/expo/entity/blob/main/packages/entity-database-adapter-knex/src/internal/EntityKnexDataManager.ts#L303](https://github.com//github.com/expo/entity/blob/main/packages/entity-database-adapter-knex/src/internal/EntityKnexDataManager.ts/issues/L303)
- **deps:** update dependency @apollo/server to v5.4.0 [security] ([#420](https://github.com/expo/entity/issues/420)) ([cd81c5a](https://github.com/expo/entity/commit/cd81c5a5b6be43ef60248eeea4031a883dd1eae8))
- **deps:** update dependency @koa/bodyparser to v6.1.0 ([#405](https://github.com/expo/entity/issues/405)) ([2dda22d](https://github.com/expo/entity/commit/2dda22d761f87851e3fcccc8443597daf8ac42e8))
- **deps:** update dependency @koa/router to v15.3.0 ([#406](https://github.com/expo/entity/issues/406)) ([9e88cf6](https://github.com/expo/entity/commit/9e88cf6aab76902c65994d28de8dab73f1267021))
- **deps:** update dependency graphql to v16.13.0 ([#479](https://github.com/expo/entity/issues/479)) ([976e2c3](https://github.com/expo/entity/commit/976e2c33867da71a2920d050c4086e2af8f6d54d))
- **deps:** update dependency koa to v3.1.2 [security] ([#484](https://github.com/expo/entity/issues/484)) ([b258dad](https://github.com/expo/entity/commit/b258dad2ed69c473f04d57d26910eab2032d142c))
- eliminate some unnecessary any casts ([#448](https://github.com/expo/entity/issues/448)) ([cc78029](https://github.com/expo/entity/commit/cc78029117c14510b0934b5e0d19876a45570f8d))
- explicitly document behavior when cursor entity no longer exists ([#453](https://github.com/expo/entity/issues/453)) ([4dc156c](https://github.com/expo/entity/commit/4dc156c7a8dc50bc9e2802be133b9e258b289f54)), closes [#422](https://github.com/expo/entity/issues/422) [#431](https://github.com/expo/entity/issues/431)
- move EntityPrivacyUtils back into core package ([#429](https://github.com/expo/entity/issues/429)) ([56ec27d](https://github.com/expo/entity/commit/56ec27d5f201d83930a2cc05dfc380a054bff1e1))
- pass in augmentable classes to installation ([#437](https://github.com/expo/entity/issues/437)) ([f5c728b](https://github.com/expo/entity/commit/f5c728be565e01c0a8ace9ef51f6de8f49184dab)), closes [/github.com/expo/entity/pull/410#pullrequestreview-3765893898](https://github.com//github.com/expo/entity/pull/410/issues/pullrequestreview-3765893898)
- refactor install method to instead be free functions ([#441](https://github.com/expo/entity/issues/441)) ([51d632d](https://github.com/expo/entity/commit/51d632da1619dc039d5c41ce6533f63254ec4337)), closes [#410](https://github.com/expo/entity/issues/410)
- Reorganize constructionUtils to make non-public ([#454](https://github.com/expo/entity/issues/454)) ([d9377a2](https://github.com/expo/entity/commit/d9377a290b7e0c6803f9e83537eddb7204bc9320)), closes [#410](https://github.com/expo/entity/issues/410)
- revert change to codemod now that Entity.knexLoader is supported again via subclass ([#443](https://github.com/expo/entity/issues/443)) ([ed6b2e0](https://github.com/expo/entity/commit/ed6b2e05539c77ee2abd2c6da74e2a451967d06e)), closes [#441](https://github.com/expo/entity/issues/441)
- Type idField in EntityConfiguration as TIDField ([#425](https://github.com/expo/entity/issues/425)) ([be40bcf](https://github.com/expo/entity/commit/be40bcffa6e7db9b7ad75e1a80bbb48ff417c6c5))
- update integration test to use BlahEntity.knexLoader syntax ([#444](https://github.com/expo/entity/issues/444)) ([457f58a](https://github.com/expo/entity/commit/457f58ad2c4e1cf09f3132f3b9af5008ba0290c7)), closes [#441](https://github.com/expo/entity/issues/441)
- Use TSelectedFields for knex loader order by method ([#424](https://github.com/expo/entity/issues/424)) ([a9d09f4](https://github.com/expo/entity/commit/a9d09f42b67378d06133bcfb5b99c9afb17aac0d))
### Features
- Add authorization action to EntityPrivacyPolicyRuleEvaluationContext ([#464](https://github.com/expo/entity/issues/464)) ([e71ae5f](https://github.com/expo/entity/commit/e71ae5fcbae95096e0d6a42a8c960896ab83ccfb))
- add codemod for upcoming v0.55.0-v0.56.0 ([#413](https://github.com/expo/entity/issues/413)) ([aba0ce5](https://github.com/expo/entity/commit/aba0ce5b5f38cb7b01956cde14a4714e2a03abb4)), closes [#407](https://github.com/expo/entity/issues/407) [-#410](https://github.com/-/issues/410)
- add entity-database-adapter-knex-testing-utils containing StubPostgresDatabaseAdapter ([#412](https://github.com/expo/entity/issues/412)) ([eeccf59](https://github.com/expo/entity/commit/eeccf594c11bf090aa83bbf71fb4ead8668aec7d))
- add entityField SQL helper ([#481](https://github.com/expo/entity/issues/481)) ([459a5ff](https://github.com/expo/entity/commit/459a5ff85014a8c7396c0856dffacb1491ccb963))
- Add ilike and trigram similarity search to pagination ([#431](https://github.com/expo/entity/issues/431)) ([f72cbca](https://github.com/expo/entity/commit/f72cbcaf5a722e6bb8299644ec32b07f1d3362a9)), closes [#422](https://github.com/expo/entity/issues/422)
- add method to get pagination cursor for single entity ([#475](https://github.com/expo/entity/issues/475)) ([898f71c](https://github.com/expo/entity/commit/898f71ce082c2593585575b60bd6ef2546717124))
- Add paginated loader to entity-database-adapter-knex ([#422](https://github.com/expo/entity/issues/422)) ([58deffd](https://github.com/expo/entity/commit/58deffd426b0214073ede608cd47c04db2596d60))
- Add pagination max page size configuration ([#436](https://github.com/expo/entity/issues/436)) ([3ec4b5f](https://github.com/expo/entity/commit/3ec4b5f0cd53bf18d6bcfa27dd29b70d3f4d5f19))
- add PostgresEntity/ReadonlyPostgresEntity classes that expose knexLoader methods ([#442](https://github.com/expo/entity/issues/442)) ([40b409f](https://github.com/expo/entity/commit/40b409f6f889febf8b90b8837caecac26ff64174)), closes [#441](https://github.com/expo/entity/issues/441)
- Add raw sqlfragment orderBy capability for pagination ([#457](https://github.com/expo/entity/issues/457)) ([0615ffa](https://github.com/expo/entity/commit/0615ffacffbe9e903a9bf03b171c102f0554b992))
- add support for nulls ordering in order by clauses ([#468](https://github.com/expo/entity/issues/468)) ([6c0838d](https://github.com/expo/entity/commit/6c0838dc577a3b6190e3551ee834925d7bd53bc6))
- support nullable search fields with postgres transforms ([#470](https://github.com/expo/entity/issues/470)) ([53b0604](https://github.com/expo/entity/commit/53b0604152b8ca5ad9fc616d18478f93473c7eb0))
# [0.55.0](https://github.com/expo/entity/compare/v0.54.0...v0.55.0) (2026-01-27)
### Bug Fixes
+1 -1
View File
@@ -1,6 +1,6 @@
{
"packages": ["packages/*"],
"npmClient": "yarn",
"version": "0.55.0",
"version": "0.58.0",
"$schema": "node_modules/lerna/schemas/lerna-schema.json"
}
+5 -5
View File
@@ -26,23 +26,23 @@
"@tsconfig/strictest": "2.0.8",
"@types/babel__core": "7.20.5",
"@types/babel__preset-env": "7.10.0",
"@types/node": "24.10.13",
"@types/node": "24.10.15",
"ctix": "2.7.2",
"eslint": "9.39.2",
"eslint": "9.39.3",
"eslint-config-universe": "15.0.3",
"eslint-plugin-tsdoc": "0.5.0",
"eslint-plugin-tsdoc": "0.5.2",
"jest": "30.2.0",
"lerna": "9.0.4",
"prettier": "3.8.1",
"prettier-plugin-organize-imports": "4.3.0",
"typedoc": "0.28.16",
"typedoc": "0.28.17",
"typescript": "5.9.3"
},
"resolutions": {
"collect-v8-coverage": "^1.0.3"
},
"volta": {
"node": "24.13.1"
"node": "24.14.0"
},
"packageManager": "[email protected]"
}
@@ -3,6 +3,20 @@
All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
# [0.58.0](https://github.com/expo/entity/compare/v0.57.0...v0.58.0) (2026-03-02)
### Bug Fixes
- escape all matching portions of parts of cacher cache keys ([#486](https://github.com/expo/entity/issues/486)) ([21f2f6f](https://github.com/expo/entity/commit/21f2f6f461ee771e8b1c1b22dbaf9b275094c0db))
# [0.57.0](https://github.com/expo/entity/compare/v0.56.0...v0.57.0) (2026-02-28)
**Note:** Version bump only for package @expo/entity-cache-adapter-local-memory
# [0.56.0](https://github.com/expo/entity/compare/v0.55.0...v0.56.0) (2026-02-28)
**Note:** Version bump only for package @expo/entity-cache-adapter-local-memory
# [0.55.0](https://github.com/expo/entity/compare/v0.54.0...v0.55.0) (2026-01-27)
**Note:** Version bump only for package @expo/entity-cache-adapter-local-memory
@@ -1,6 +1,6 @@
{
"name": "@expo/entity-cache-adapter-local-memory",
"version": "0.55.0",
"version": "0.58.0",
"description": "Cross-request local memory cache adapter for @expo/entity",
"files": [
"build",
@@ -122,7 +122,7 @@ export class GenericLocalMemoryCacher<
const delimiter = ':';
const escapedParts = parts.map((part) =>
part.replace('\\', '\\\\').replace(delimiter, `\\${delimiter}`),
part.replaceAll('\\', '\\\\').replaceAll(delimiter, `\\${delimiter}`),
);
return escapedParts.join(delimiter);
}
@@ -3,6 +3,22 @@
All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
# [0.58.0](https://github.com/expo/entity/compare/v0.57.0...v0.58.0) (2026-03-02)
### Bug Fixes
- escape all matching portions of parts of cacher cache keys ([#486](https://github.com/expo/entity/issues/486)) ([21f2f6f](https://github.com/expo/entity/commit/21f2f6f461ee771e8b1c1b22dbaf9b275094c0db))
# [0.57.0](https://github.com/expo/entity/compare/v0.56.0...v0.57.0) (2026-02-28)
**Note:** Version bump only for package @expo/entity-cache-adapter-redis
# [0.56.0](https://github.com/expo/entity/compare/v0.55.0...v0.56.0) (2026-02-28)
### Bug Fixes
- eliminate some unnecessary any casts ([#448](https://github.com/expo/entity/issues/448)) ([cc78029](https://github.com/expo/entity/commit/cc78029117c14510b0934b5e0d19876a45570f8d))
# [0.55.0](https://github.com/expo/entity/compare/v0.54.0...v0.55.0) (2026-01-27)
**Note:** Version bump only for package @expo/entity-cache-adapter-redis
@@ -16,7 +16,7 @@ const genericRedisCacherContext = {
makeKeyFn(...parts: string[]): string {
const delimiter = ':';
const escapedParts = parts.map((part) =>
part.replace('\\', '\\\\').replace(delimiter, `\\${delimiter}`)
part.replaceAll('\\', '\\\\').replaceAll(delimiter, `\\${delimiter}`)
);
return escapedParts.join(delimiter);
},
@@ -1,6 +1,6 @@
{
"name": "@expo/entity-cache-adapter-redis",
"version": "0.55.0",
"version": "0.58.0",
"description": "Redis cache adapter for @expo/entity",
"files": [
"build",
@@ -37,7 +37,7 @@
"@expo/batcher": "1.0.0",
"@expo/entity-testing-utils": "workspace:^",
"@jest/globals": "30.2.0",
"ioredis": "5.9.3",
"ioredis": "5.10.0",
"ts-mockito": "2.6.1",
"typescript": "5.9.3"
}
@@ -215,7 +215,7 @@ export class GenericRedisCacher<
this.context.cacheKeyPrefix,
cacheKeyType,
this.entityConfiguration.tableName,
`v2.${cacheKeyVersion}`,
`v3.${cacheKeyVersion}`,
...parts,
);
}
@@ -80,7 +80,7 @@ describe(GenericRedisCacher, () => {
makeKeyFn(...parts: string[]): string {
const delimiter = ':';
const escapedParts = parts.map((part) =>
part.replace('\\', '\\\\').replace(delimiter, `\\${delimiter}`),
part.replaceAll('\\', '\\\\').replaceAll(delimiter, `\\${delimiter}`),
);
return escapedParts.join(delimiter);
},
@@ -31,7 +31,7 @@ describe(GenericRedisCacher, () => {
makeKeyFn(...parts: string[]): string {
const delimiter = ':';
const escapedParts = parts.map((part) =>
part.replace('\\', '\\\\').replace(delimiter, `\\${delimiter}`),
part.replaceAll('\\', '\\\\').replaceAll(delimiter, `\\${delimiter}`),
);
return escapedParts.join(delimiter);
},
@@ -26,7 +26,7 @@ describe(GenericRedisCacher, () => {
makeKeyFn(...parts: string[]): string {
const delimiter = ':';
const escapedParts = parts.map((part) =>
part.replace('\\', '\\\\').replace(delimiter, `\\${delimiter}`),
part.replaceAll('\\', '\\\\').replaceAll(delimiter, `\\${delimiter}`),
);
return escapedParts.join(delimiter);
},
@@ -23,7 +23,7 @@ describe(GenericRedisCacher, () => {
makeKeyFn(...parts: string[]): string {
const delimiter = ':';
const escapedParts = parts.map((part) =>
part.replace('\\', '\\\\').replace(delimiter, `\\${delimiter}`),
part.replaceAll('\\', '\\\\').replaceAll(delimiter, `\\${delimiter}`),
);
return escapedParts.join(delimiter);
},
@@ -109,7 +109,7 @@ describe(GenericRedisCacher, () => {
return pipeline;
},
);
when(mockPipeline.exec()).thenResolve({} as any);
when(mockPipeline.exec()).thenResolve([]);
const pipeline = instance(mockPipeline);
const mockRedisClient = mock<Redis>();
@@ -154,7 +154,7 @@ describe(GenericRedisCacher, () => {
return pipeline;
},
);
when(mockPipeline.exec()).thenResolve({} as any);
when(mockPipeline.exec()).thenResolve([]);
const pipeline = instance(mockPipeline);
const mockRedisClient = mock<Redis>();
@@ -212,7 +212,7 @@ describe(GenericRedisCacher, () => {
new SingleFieldValueHolder('wat'),
);
expect(cacheKeys).toHaveLength(1);
expect(cacheKeys[0]).toBe('hello-:single:blah:v2.2:id:wat');
expect(cacheKeys[0]).toBe('hello-:single:blah:v3.2:id:wat');
await genericCacher.invalidateManyAsync(cacheKeys);
verify(mockRedisClient.del(...cacheKeys)).once();
@@ -240,9 +240,9 @@ describe(GenericRedisCacher, () => {
new SingleFieldValueHolder('wat'),
);
expect(cacheKeys).toHaveLength(3);
expect(cacheKeys[0]).toBe('hello-:single:blah:v2.1:id:wat');
expect(cacheKeys[1]).toBe('hello-:single:blah:v2.2:id:wat');
expect(cacheKeys[2]).toBe('hello-:single:blah:v2.3:id:wat');
expect(cacheKeys[0]).toBe('hello-:single:blah:v3.1:id:wat');
expect(cacheKeys[1]).toBe('hello-:single:blah:v3.2:id:wat');
expect(cacheKeys[2]).toBe('hello-:single:blah:v3.3:id:wat');
await genericCacher.invalidateManyAsync(cacheKeys);
verify(mockRedisClient.del(...cacheKeys)).once();
@@ -278,10 +278,10 @@ describe(GenericRedisCacher, () => {
new SingleFieldValueHolder('wat'),
);
expect(cacheKeys).toHaveLength(4);
expect(cacheKeys[0]).toBe('hello-:single:blah:v2.2:id:wat');
expect(cacheKeys[1]).toBe('hello-:single:blah:v2.3:id:wat');
expect(cacheKeys[2]).toBe('hello-:single:blah:v2.4:id:wat');
expect(cacheKeys[3]).toBe('hello-:single:blah:v2.5:id:wat');
expect(cacheKeys[0]).toBe('hello-:single:blah:v3.2:id:wat');
expect(cacheKeys[1]).toBe('hello-:single:blah:v3.3:id:wat');
expect(cacheKeys[2]).toBe('hello-:single:blah:v3.4:id:wat');
expect(cacheKeys[3]).toBe('hello-:single:blah:v3.5:id:wat');
await genericCacher.invalidateManyAsync(cacheKeys);
verify(mockRedisClient.del(...cacheKeys)).once();
+19
View File
@@ -3,6 +3,25 @@
All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
# [0.58.0](https://github.com/expo/entity/compare/v0.57.0...v0.58.0) (2026-03-02)
**Note:** Version bump only for package @expo/entity-codemod
# [0.57.0](https://github.com/expo/entity/compare/v0.56.0...v0.57.0) (2026-02-28)
**Note:** Version bump only for package @expo/entity-codemod
# [0.56.0](https://github.com/expo/entity/compare/v0.55.0...v0.56.0) (2026-02-28)
### Bug Fixes
- refactor install method to instead be free functions ([#441](https://github.com/expo/entity/issues/441)) ([51d632d](https://github.com/expo/entity/commit/51d632da1619dc039d5c41ce6533f63254ec4337)), closes [#410](https://github.com/expo/entity/issues/410)
- revert change to codemod now that Entity.knexLoader is supported again via subclass ([#443](https://github.com/expo/entity/issues/443)) ([ed6b2e0](https://github.com/expo/entity/commit/ed6b2e05539c77ee2abd2c6da74e2a451967d06e)), closes [#441](https://github.com/expo/entity/issues/441)
### Features
- add codemod for upcoming v0.55.0-v0.56.0 ([#413](https://github.com/expo/entity/issues/413)) ([aba0ce5](https://github.com/expo/entity/commit/aba0ce5b5f38cb7b01956cde14a4714e2a03abb4)), closes [#407](https://github.com/expo/entity/issues/407) [-#410](https://github.com/-/issues/410)
# [0.55.0](https://github.com/expo/entity/compare/v0.54.0...v0.55.0) (2026-01-27)
**Note:** Version bump only for package @expo/entity-codemod
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@expo/entity-codemod",
"version": "0.55.0",
"version": "0.58.0",
"description": "jscodeshift codemods for @expo/entity upgrades",
"files": [
"build",
@@ -29,7 +29,7 @@
"devDependencies": {
"@jest/globals": "30.2.0",
"@types/jscodeshift": "17.3.0",
"@types/node": "24.10.13",
"@types/node": "24.10.15",
"jscodeshift": "17.3.0",
"typescript": "5.9.3"
}
@@ -12,13 +12,9 @@ function isKnexSpecificMethodUsed(j: API['jscodeshift'], node: any): boolean {
const parent = node.parent;
// Check if the loader call is directly chained with a knex method
if (parent && parent.value.type === 'MemberExpression' && parent.value.object === node.value) {
if (parent?.value.type === 'MemberExpression' && parent.value.object === node.value) {
const grandParent = parent.parent;
if (
grandParent &&
grandParent.value.type === 'CallExpression' &&
grandParent.value.callee === parent.value
) {
if (grandParent?.value.type === 'CallExpression' && grandParent.value.callee === parent.value) {
if (
parent.value.property.type === 'Identifier' &&
KNEX_SPECIFIC_METHODS.includes(parent.value.property.name)
@@ -29,7 +25,7 @@ function isKnexSpecificMethodUsed(j: API['jscodeshift'], node: any): boolean {
}
// Check if the loader is assigned to a variable and then used with knex methods
if (parent && parent.value.type === 'VariableDeclarator' && parent.value.init === node.value) {
if (parent?.value.type === 'VariableDeclarator' && parent.value.init === node.value) {
const variableName = parent.value.id.name;
const scope = parent.scope;
@@ -54,9 +50,9 @@ function isKnexSpecificMethodUsed(j: API['jscodeshift'], node: any): boolean {
}
// Check await expressions
if (parent && parent.value.type === 'AwaitExpression' && parent.value.argument === node.value) {
if (parent?.value.type === 'AwaitExpression' && parent.value.argument === node.value) {
const awaitParent = parent.parent;
if (awaitParent && awaitParent.value.type === 'VariableDeclarator') {
if (awaitParent?.value.type === 'VariableDeclarator') {
const variableName = awaitParent.value.id.name;
const scope = awaitParent.scope;
@@ -106,7 +102,7 @@ function transformLoaderToKnexLoader(j: API['jscodeshift'], root: Collection<any
// Typically entity names start with uppercase letter
if (loaderCallee.object.type === 'Identifier') {
const firstChar = loaderCallee.object.name[0];
if (firstChar && firstChar === firstChar.toUpperCase()) {
if (firstChar === firstChar?.toUpperCase()) {
// Check if this loader uses knex-specific methods
if (isKnexSpecificMethodUsed(j, path)) {
// Rename loader to knexLoader
@@ -145,7 +141,7 @@ function transformLoaderWithAuthorizationResultsToKnexLoaderWithAuthorizationRes
// Typically entity names start with uppercase letter
if (loaderCallee.object.type === 'Identifier') {
const firstChar = loaderCallee.object.name[0];
if (firstChar && firstChar === firstChar.toUpperCase()) {
if (firstChar === firstChar?.toUpperCase()) {
// Check if this loader uses knex-specific methods
if (isKnexSpecificMethodUsed(j, path)) {
// Rename loaderWithAuthorizationResults to knexLoaderWithAuthorizationResults
@@ -0,0 +1,27 @@
# Change Log
All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
# [0.58.0](https://github.com/expo/entity/compare/v0.57.0...v0.58.0) (2026-03-02)
**Note:** Version bump only for package @expo/entity-database-adapter-knex-testing-utils
# [0.57.0](https://github.com/expo/entity/compare/v0.56.0...v0.57.0) (2026-02-28)
**Note:** Version bump only for package @expo/entity-database-adapter-knex-testing-utils
# [0.56.0](https://github.com/expo/entity/compare/v0.55.0...v0.56.0) (2026-02-28)
### Bug Fixes
- move EntityPrivacyUtils back into core package ([#429](https://github.com/expo/entity/issues/429)) ([56ec27d](https://github.com/expo/entity/commit/56ec27d5f201d83930a2cc05dfc380a054bff1e1))
- pass in augmentable classes to installation ([#437](https://github.com/expo/entity/issues/437)) ([f5c728b](https://github.com/expo/entity/commit/f5c728be565e01c0a8ace9ef51f6de8f49184dab)), closes [/github.com/expo/entity/pull/410#pullrequestreview-3765893898](https://github.com//github.com/expo/entity/pull/410/issues/pullrequestreview-3765893898)
- refactor install method to instead be free functions ([#441](https://github.com/expo/entity/issues/441)) ([51d632d](https://github.com/expo/entity/commit/51d632da1619dc039d5c41ce6533f63254ec4337)), closes [#410](https://github.com/expo/entity/issues/410)
### Features
- add entity-database-adapter-knex-testing-utils containing StubPostgresDatabaseAdapter ([#412](https://github.com/expo/entity/issues/412)) ([eeccf59](https://github.com/expo/entity/commit/eeccf594c11bf090aa83bbf71fb4ead8668aec7d))
- add entityField SQL helper ([#481](https://github.com/expo/entity/issues/481)) ([459a5ff](https://github.com/expo/entity/commit/459a5ff85014a8c7396c0856dffacb1491ccb963))
- Add raw sqlfragment orderBy capability for pagination ([#457](https://github.com/expo/entity/issues/457)) ([0615ffa](https://github.com/expo/entity/commit/0615ffacffbe9e903a9bf03b171c102f0554b992))
- add support for nulls ordering in order by clauses ([#468](https://github.com/expo/entity/issues/468)) ([6c0838d](https://github.com/expo/entity/commit/6c0838dc577a3b6190e3551ee834925d7bd53bc6))
@@ -1,6 +1,6 @@
{
"name": "@expo/entity-database-adapter-knex-testing-utils",
"version": "0.55.0",
"version": "0.58.0",
"description": "Testing utilities for applications using Entity with Knex database adapter",
"files": [
"build",
@@ -38,7 +38,7 @@
"devDependencies": {
"@jest/globals": "30.2.0",
"@types/invariant": "2.2.37",
"@types/node": "24.10.13",
"@types/node": "24.10.15",
"@types/uuid": "10.0.0",
"typescript": "5.9.3"
}
@@ -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');
}
@@ -5,7 +5,7 @@ import {
SingleFieldHolder,
SingleFieldValueHolder,
} from '@expo/entity';
import { OrderByOrdering, sql } from '@expo/entity-database-adapter-knex';
import { NullsOrdering, OrderByOrdering, sql } from '@expo/entity-database-adapter-knex';
import { describe, expect, it, jest } from '@jest/globals';
import { instance, mock } from 'ts-mockito';
import { validate, version } from 'uuid';
@@ -744,14 +744,15 @@ describe(StubPostgresDatabaseAdapter, () => {
});
describe('compareByOrderBys', () => {
describe('comparison', () => {
describe('comparison with default nulls ordering', () => {
it.each([
// Default nulls ordering: NULLS FIRST for DESC, NULLS LAST for ASC
// nulls compare with 0
[OrderByOrdering.DESCENDING, null, 0, -1],
[OrderByOrdering.ASCENDING, null, 0, 1],
[OrderByOrdering.DESCENDING, 0, null, 1],
[OrderByOrdering.ASCENDING, 0, null, -1],
// nulls compare with nulls
// nulls compare with nulls (fall through to next order by, which is empty, so 0)
[OrderByOrdering.DESCENDING, null, null, 0],
[OrderByOrdering.ASCENDING, null, null, 0],
// nulls compare with -1
@@ -771,6 +772,7 @@ describe('compareByOrderBys', () => {
{
columnName: 'hello',
order,
nulls: undefined,
},
],
{
@@ -782,6 +784,21 @@ describe('compareByOrderBys', () => {
),
).toEqual(expectedResult);
});
it('throws for SQL fragment order by', () => {
expect(() =>
StubPostgresDatabaseAdapter['compareByOrderBys'](
[
{
columnFragment: sql`some_function(col)`,
order: OrderByOrdering.ASCENDING,
nulls: undefined,
},
],
{ hello: 'a' },
{ hello: 'b' },
),
).toThrow('SQL fragment order by not supported for StubDatabaseAdapter');
});
it('works for empty', () => {
expect(
StubPostgresDatabaseAdapter['compareByOrderBys'](
@@ -796,29 +813,104 @@ describe('compareByOrderBys', () => {
).toEqual(0);
});
});
describe('comparison with explicit nulls ordering', () => {
it.each([
// ASC NULLS FIRST: nulls come before non-nulls
[OrderByOrdering.ASCENDING, NullsOrdering.FIRST, null, 0, -1],
[OrderByOrdering.ASCENDING, NullsOrdering.FIRST, 0, null, 1],
// ASC NULLS LAST: nulls come after non-nulls (same as default ASC)
[OrderByOrdering.ASCENDING, NullsOrdering.LAST, null, 0, 1],
[OrderByOrdering.ASCENDING, NullsOrdering.LAST, 0, null, -1],
// DESC NULLS FIRST: nulls come before non-nulls (same as default DESC)
[OrderByOrdering.DESCENDING, NullsOrdering.FIRST, null, 0, -1],
[OrderByOrdering.DESCENDING, NullsOrdering.FIRST, 0, null, 1],
// DESC NULLS LAST: nulls come after non-nulls
[OrderByOrdering.DESCENDING, NullsOrdering.LAST, null, 0, 1],
[OrderByOrdering.DESCENDING, NullsOrdering.LAST, 0, null, -1],
// Non-null values should not be affected by nulls ordering
[OrderByOrdering.ASCENDING, NullsOrdering.FIRST, 'a', 'b', -1],
[OrderByOrdering.ASCENDING, NullsOrdering.LAST, 'a', 'b', -1],
[OrderByOrdering.DESCENDING, NullsOrdering.FIRST, 'a', 'b', 1],
[OrderByOrdering.DESCENDING, NullsOrdering.LAST, 'a', 'b', 1],
// Both null should fall through to next order by (which is empty, so 0)
[OrderByOrdering.ASCENDING, NullsOrdering.FIRST, null, null, 0],
[OrderByOrdering.DESCENDING, NullsOrdering.LAST, null, null, 0],
])('case (%p; nulls %p; %p; %p)', (order, nulls, v1, v2, expectedResult) => {
expect(
StubPostgresDatabaseAdapter['compareByOrderBys'](
[
{
columnName: 'hello',
order,
nulls,
},
],
{
hello: v1,
},
{
hello: v2,
},
),
).toEqual(expectedResult);
});
});
describe('recursing', () => {
expect(
StubPostgresDatabaseAdapter['compareByOrderBys'](
[
it('recurses to secondary order by on tie', () => {
expect(
StubPostgresDatabaseAdapter['compareByOrderBys'](
[
{
columnName: 'hello',
order: OrderByOrdering.ASCENDING,
nulls: undefined,
},
{
columnName: 'world',
order: OrderByOrdering.ASCENDING,
nulls: undefined,
},
],
{
columnName: 'hello',
order: OrderByOrdering.ASCENDING,
hello: 'a',
world: 1,
},
{
columnName: 'world',
order: OrderByOrdering.ASCENDING,
hello: 'a',
world: 2,
},
],
{
hello: 'a',
world: 1,
},
{
hello: 'a',
world: 2,
},
),
).toEqual(-1);
),
).toEqual(-1);
});
it('recurses to secondary order by when both values are null', () => {
expect(
StubPostgresDatabaseAdapter['compareByOrderBys'](
[
{
columnName: 'hello',
order: OrderByOrdering.ASCENDING,
nulls: undefined,
},
{
columnName: 'world',
order: OrderByOrdering.ASCENDING,
nulls: undefined,
},
],
{
hello: null,
world: 1,
},
{
hello: null,
world: 2,
},
),
).toEqual(-1);
});
});
});
@@ -3,6 +3,46 @@
All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
# [0.58.0](https://github.com/expo/entity/compare/v0.57.0...v0.58.0) (2026-03-02)
### Bug Fixes
- fix some security review suggestions ([#485](https://github.com/expo/entity/issues/485)) ([e263bd2](https://github.com/expo/entity/commit/e263bd218cda579365766d0918c4961304bd1d9f))
# [0.57.0](https://github.com/expo/entity/compare/v0.56.0...v0.57.0) (2026-02-28)
**Note:** Version bump only for package @expo/entity-database-adapter-knex
# [0.56.0](https://github.com/expo/entity/compare/v0.55.0...v0.56.0) (2026-02-28)
### Bug Fixes
- Add default for TSelectedFields for application-instantiated types ([#438](https://github.com/expo/entity/issues/438)) ([3d4aa0e](https://github.com/expo/entity/commit/3d4aa0e9efa519a36f3db69bd8f117a993c100fa))
- Apply same viewer context subclass type mechanism to knexLoader and related methods ([#472](https://github.com/expo/entity/issues/472)) ([018a40b](https://github.com/expo/entity/commit/018a40bc01d579ffbf88f308e641921e6fc39097))
- correct discriminated union type for forward/backward pagination ([#474](https://github.com/expo/entity/issues/474)) ([90ae6e2](https://github.com/expo/entity/commit/90ae6e299bda26ac9a80d83e83334d4152c022dd))
- correct pagination behavior for standard pagination with descending ordering clauses ([#477](https://github.com/expo/entity/issues/477)) ([e32c4c2](https://github.com/expo/entity/commit/e32c4c2bcc05f225a556f95c90f1ee0f4e24de74))
- correct precedence parentheses for SQLFragment joins ([#476](https://github.com/expo/entity/issues/476)) ([2c4c649](https://github.com/expo/entity/commit/2c4c649a4bca5bc9ffdc41eb3135b2dfe4db5e57)), closes [/github.com/expo/entity/blob/main/packages/entity-database-adapter-knex/src/internal/EntityKnexDataManager.ts#L303](https://github.com//github.com/expo/entity/blob/main/packages/entity-database-adapter-knex/src/internal/EntityKnexDataManager.ts/issues/L303)
- eliminate some unnecessary any casts ([#448](https://github.com/expo/entity/issues/448)) ([cc78029](https://github.com/expo/entity/commit/cc78029117c14510b0934b5e0d19876a45570f8d))
- explicitly document behavior when cursor entity no longer exists ([#453](https://github.com/expo/entity/issues/453)) ([4dc156c](https://github.com/expo/entity/commit/4dc156c7a8dc50bc9e2802be133b9e258b289f54)), closes [#422](https://github.com/expo/entity/issues/422) [#431](https://github.com/expo/entity/issues/431)
- move EntityPrivacyUtils back into core package ([#429](https://github.com/expo/entity/issues/429)) ([56ec27d](https://github.com/expo/entity/commit/56ec27d5f201d83930a2cc05dfc380a054bff1e1))
- pass in augmentable classes to installation ([#437](https://github.com/expo/entity/issues/437)) ([f5c728b](https://github.com/expo/entity/commit/f5c728be565e01c0a8ace9ef51f6de8f49184dab)), closes [/github.com/expo/entity/pull/410#pullrequestreview-3765893898](https://github.com//github.com/expo/entity/pull/410/issues/pullrequestreview-3765893898)
- refactor install method to instead be free functions ([#441](https://github.com/expo/entity/issues/441)) ([51d632d](https://github.com/expo/entity/commit/51d632da1619dc039d5c41ce6533f63254ec4337)), closes [#410](https://github.com/expo/entity/issues/410)
- update integration test to use BlahEntity.knexLoader syntax ([#444](https://github.com/expo/entity/issues/444)) ([457f58a](https://github.com/expo/entity/commit/457f58ad2c4e1cf09f3132f3b9af5008ba0290c7)), closes [#441](https://github.com/expo/entity/issues/441)
- Use TSelectedFields for knex loader order by method ([#424](https://github.com/expo/entity/issues/424)) ([a9d09f4](https://github.com/expo/entity/commit/a9d09f42b67378d06133bcfb5b99c9afb17aac0d))
### Features
- add entity-database-adapter-knex-testing-utils containing StubPostgresDatabaseAdapter ([#412](https://github.com/expo/entity/issues/412)) ([eeccf59](https://github.com/expo/entity/commit/eeccf594c11bf090aa83bbf71fb4ead8668aec7d))
- add entityField SQL helper ([#481](https://github.com/expo/entity/issues/481)) ([459a5ff](https://github.com/expo/entity/commit/459a5ff85014a8c7396c0856dffacb1491ccb963))
- Add ilike and trigram similarity search to pagination ([#431](https://github.com/expo/entity/issues/431)) ([f72cbca](https://github.com/expo/entity/commit/f72cbcaf5a722e6bb8299644ec32b07f1d3362a9)), closes [#422](https://github.com/expo/entity/issues/422)
- add method to get pagination cursor for single entity ([#475](https://github.com/expo/entity/issues/475)) ([898f71c](https://github.com/expo/entity/commit/898f71ce082c2593585575b60bd6ef2546717124))
- Add paginated loader to entity-database-adapter-knex ([#422](https://github.com/expo/entity/issues/422)) ([58deffd](https://github.com/expo/entity/commit/58deffd426b0214073ede608cd47c04db2596d60))
- Add pagination max page size configuration ([#436](https://github.com/expo/entity/issues/436)) ([3ec4b5f](https://github.com/expo/entity/commit/3ec4b5f0cd53bf18d6bcfa27dd29b70d3f4d5f19))
- add PostgresEntity/ReadonlyPostgresEntity classes that expose knexLoader methods ([#442](https://github.com/expo/entity/issues/442)) ([40b409f](https://github.com/expo/entity/commit/40b409f6f889febf8b90b8837caecac26ff64174)), closes [#441](https://github.com/expo/entity/issues/441)
- Add raw sqlfragment orderBy capability for pagination ([#457](https://github.com/expo/entity/issues/457)) ([0615ffa](https://github.com/expo/entity/commit/0615ffacffbe9e903a9bf03b171c102f0554b992))
- add support for nulls ordering in order by clauses ([#468](https://github.com/expo/entity/issues/468)) ([6c0838d](https://github.com/expo/entity/commit/6c0838dc577a3b6190e3551ee834925d7bd53bc6))
- support nullable search fields with postgres transforms ([#470](https://github.com/expo/entity/issues/470)) ([53b0604](https://github.com/expo/entity/commit/53b0604152b8ca5ad9fc616d18478f93473c7eb0))
# [0.55.0](https://github.com/expo/entity/compare/v0.54.0...v0.55.0) (2026-01-27)
**Note:** Version bump only for package @expo/entity-database-adapter-knex
@@ -1,6 +1,6 @@
{
"name": "@expo/entity-database-adapter-knex",
"version": "0.55.0",
"version": "0.58.0",
"description": "Knex database adapter for @expo/entity",
"files": [
"build",
@@ -34,7 +34,7 @@
"devDependencies": {
"@expo/entity-testing-utils": "workspace:^",
"@jest/globals": "30.2.0",
"pg": "8.18.0",
"pg": "8.19.0",
"ts-mockito": "2.6.1",
"typescript": "5.9.3"
}
@@ -11,6 +11,7 @@ import { Result } from '@expo/results';
import {
FieldEqualityCondition,
isSingleValueFieldEqualityCondition,
NullsOrdering,
OrderByOrdering,
} from './BasePostgresEntityDatabaseAdapter';
import { BaseSQLQueryBuilder } from './BaseSQLQueryBuilder';
@@ -18,21 +19,47 @@ import { PaginationStrategy } from './PaginationStrategy';
import { SQLFragment } from './SQLOperator';
import type { Connection, PageInfo } from './internal/EntityKnexDataManager';
import { EntityKnexDataManager } from './internal/EntityKnexDataManager';
import { NonNullableKeys } from './internal/utilityTypes';
export interface EntityLoaderOrderByClause<
TFields extends Record<string, any>,
TSelectedFields extends keyof TFields = keyof TFields,
> {
/**
* The field name to order by.
*/
fieldName: TSelectedFields;
export type EntityLoaderBaseOrderByClause = {
/**
* The OrderByOrdering to order by.
*/
order: OrderByOrdering;
}
/**
* Optional nulls ordering for this order by clause. If not provided, no specific nulls ordering is applied.
*/
nulls?: NullsOrdering | undefined;
};
export type EntityLoaderFieldNameOrderByClause<
TFields extends Record<string, any>,
TSelectedFields extends keyof TFields = keyof TFields,
> = EntityLoaderBaseOrderByClause & {
/**
* The field name to order by.
*/
fieldName: TSelectedFields;
};
export type EntityLoaderFieldFragmentOrderByClause<
TFields extends Record<string, any>,
TSelectedFields extends keyof TFields = keyof TFields,
> = EntityLoaderBaseOrderByClause & {
/**
* The SQL fragment to order by, which can reference selected fields. Example: `COALESCE(NULLIF(display_name, ''), split_part(full_name, '/', 2))`.
* May not contain ASC or DESC, as ordering direction is controlled by the `order` property.
*/
fieldFragment: SQLFragment<Pick<TFields, TSelectedFields>>;
};
export type EntityLoaderOrderByClause<
TFields extends Record<string, any>,
TSelectedFields extends keyof TFields = keyof TFields,
> =
| EntityLoaderFieldNameOrderByClause<TFields, TSelectedFields>
| EntityLoaderFieldFragmentOrderByClause<TFields, TSelectedFields>;
/**
* SQL modifiers that only affect the selection but not the projection.
@@ -57,25 +84,69 @@ export interface EntityLoaderQuerySelectionModifiers<
limit?: number;
}
export interface EntityLoaderQuerySelectionModifiersWithOrderByRaw<
/**
* Function to be used for constructing search fields based on field names.
* The function takes a field name and returns a SQLFragment that can be used in the search field portion.
*/
export type EntityLoaderFieldNameConstructorFn<
TFields extends Record<string, any>,
TSelectedFields extends keyof TFields = keyof TFields,
> extends EntityLoaderQuerySelectionModifiers<TFields, TSelectedFields> {
/**
* Order the entities by a raw SQL `ORDER BY` clause.
*/
orderByRaw?: string;
}
> = (fieldName: TSelectedFields) => SQLFragment<TFields>;
export interface EntityLoaderQuerySelectionModifiersWithOrderByFragment<
/**
* Specification for a search field that is a manually constructed SQLFragment. Useful for complex search fields that require
* transformations to make nullable fields non-null or to make combinations of multiple fields.
*/
export type EntityLoaderSearchFieldSQLFragmentFnSpecification<
TFields extends Record<string, any>,
TSelectedFields extends keyof TFields = keyof TFields,
> extends EntityLoaderQuerySelectionModifiers<TFields, TSelectedFields> {
> = {
/**
* Order the entities by a SQL fragment `ORDER BY` clause.
* Function that constructs the SQLFragment for the search field.
*
* @param getFragmentForFieldName - A helper function that takes a field name and returns a SQLFragment for that field, which should be used
* to construct the final SQLFragment for the search field.
* @returns a SQLFragment representing the search field, which must reference selected fields through the provided helper function.
*
* @example
* For example, to construct a search field that searches by display name (nullable column) with fallback to full name,
* the fieldConstructor function could be implemented as follows:
* ```
* fieldConstructor: (getFragmentForFieldName) => {
* const displayNameFragment = getFragmentForFieldName('display_name');
* const fullNameFragment = getFragmentForFieldName('full_name');
* return sql`COALESCE(NULLIF(${displayNameFragment}, ''), split_part(${fullNameFragment}, '/', 2))`;
* }
* ```
*/
orderByFragment?: SQLFragment;
}
fieldConstructor: (
/**
* Helper function to get a SQLFragment for a given field name, which should be used to construct the final SQLFragment for the search field.
*/
getFragmentForFieldName: EntityLoaderFieldNameConstructorFn<TFields, TSelectedFields>,
) => SQLFragment<Pick<TFields, TSelectedFields>>;
};
/**
* Set of selected fields that are non-nullable, which can be used for search fields that require non-nullable fields.
* To use a nullable field as a search field, use an EntityLoaderSearchFieldSQLFragmentFnSpecification with appropriate SQL
* to handle null values, such as `COALESCE(field_name, '')` to treat nulls as empty strings for searching.
*/
export type NonNullableSelectedFields<
TFields extends Record<string, any>,
TSelectedFields extends keyof TFields,
> = TSelectedFields & NonNullableKeys<TFields>;
/**
* Specification for a search field that can be either a simple selected field name (which must be non-nullable) or a manually constructed
* SQLFragment using EntityLoaderSearchFieldSQLFragmentFnSpecification.
*/
export type EntityLoaderSearchFieldSpecification<
TFields extends Record<string, any>,
TSelectedFields extends keyof TFields = keyof TFields,
> =
| NonNullableSelectedFields<TFields, TSelectedFields>
| EntityLoaderSearchFieldSQLFragmentFnSpecification<TFields, TSelectedFields>;
interface SearchSpecificationBase<
TFields extends Record<string, any>,
@@ -87,9 +158,9 @@ interface SearchSpecificationBase<
term: string;
/**
* The fields to search within. Must be a non-empty array.
* The fields to search within.
*/
fields: TSelectedFields[];
fields: readonly EntityLoaderSearchFieldSpecification<TFields, TSelectedFields>[];
}
interface ILikeSearchSpecification<
@@ -132,7 +203,7 @@ interface TrigramSearchSpecification<
* These fields are independent of search fields and can be used to provide meaningful
* ordering when multiple results have the same similarity score.
*/
extraOrderByFields?: TSelectedFields[];
extraOrderByFields?: readonly EntityLoaderSearchFieldSpecification<TFields, TSelectedFields>[];
}
interface StandardPaginationSpecification<
@@ -147,7 +218,7 @@ interface StandardPaginationSpecification<
/**
* Order the entities by specified columns and orders. If the ID field is not included, it will be automatically added for stable pagination.
*/
orderBy: EntityLoaderOrderByClause<TFields, TSelectedFields>[];
orderBy: readonly EntityLoaderOrderByClause<TFields, TSelectedFields>[];
}
/**
@@ -171,7 +242,7 @@ interface EntityLoaderBaseUnifiedPaginationArgs<
/**
* SQLFragment representing the WHERE clause to filter the entities being paginated.
*/
where?: SQLFragment;
where?: SQLFragment<Pick<TFields, TSelectedFields>>;
/**
* Pagination specification determining how to order and paginate results.
@@ -191,10 +262,22 @@ export interface EntityLoaderForwardUnifiedPaginationArgs<
*/
first: number;
/**
* The number of entities to return starting from the entity before the cursor. Must be a positive integer.
* Not allowed with forward pagination.
*/
last?: never;
/**
* The cursor to paginate after for forward pagination.
*/
after?: string;
/**
* The cursor to paginate before for backward pagination.
* Not allowed with forward pagination.
*/
before?: never;
}
/**
@@ -204,11 +287,23 @@ export interface EntityLoaderBackwardUnifiedPaginationArgs<
TFields extends Record<string, any>,
TSelectedFields extends keyof TFields = keyof TFields,
> extends EntityLoaderBaseUnifiedPaginationArgs<TFields, TSelectedFields> {
/**
* The number of entities to return starting from the entity after the cursor. Must be a positive integer.
* Not allowed with backward pagination.
*/
first?: never;
/**
* The number of entities to return starting from the entity before the cursor. Must be a positive integer.
*/
last: number;
/**
* The cursor to paginate after for forward pagination.
* Not allowed with backward pagination.
*/
after?: never;
/**
* The cursor to paginate before for backward pagination.
*/
@@ -265,7 +360,7 @@ export class AuthorizationResultBasedKnexEntityLoader<
* UnauthorizedError
*/
async loadFirstByFieldEqualityConjunctionAsync<N extends keyof Pick<TFields, TSelectedFields>>(
fieldEqualityOperands: FieldEqualityCondition<TFields, N>[],
fieldEqualityOperands: readonly FieldEqualityCondition<TFields, N>[],
querySelectionModifiers: Omit<
EntityLoaderQuerySelectionModifiers<TFields, TSelectedFields>,
'limit'
@@ -284,7 +379,7 @@ export class AuthorizationResultBasedKnexEntityLoader<
* @returns array of entity results that match the query, where result error can be UnauthorizedError
*/
async loadManyByFieldEqualityConjunctionAsync<N extends keyof Pick<TFields, TSelectedFields>>(
fieldEqualityOperands: FieldEqualityCondition<TFields, N>[],
fieldEqualityOperands: readonly FieldEqualityCondition<TFields, N>[],
querySelectionModifiers: EntityLoaderQuerySelectionModifiers<TFields, TSelectedFields> = {},
): Promise<readonly Result<TEntity>[]> {
for (const fieldEqualityOperand of fieldEqualityOperands) {
@@ -306,14 +401,13 @@ export class AuthorizationResultBasedKnexEntityLoader<
* Authorization-result-based version of the EnforcingKnexEntityLoader method by the same name.
* @returns array of entity results that match the query, where result error can be UnauthorizedError
* @throws Error when rawWhereClause or bindings are invalid
*
* @deprecated Use loadManyBySQL instead for safer value bindings and more flexible query building.
*/
async loadManyByRawWhereClauseAsync(
rawWhereClause: string,
bindings: any[] | object,
querySelectionModifiers: EntityLoaderQuerySelectionModifiersWithOrderByRaw<
TFields,
TSelectedFields
> = {},
querySelectionModifiers: EntityLoaderQuerySelectionModifiers<TFields, TSelectedFields> = {},
): Promise<readonly Result<TEntity>[]> {
const fieldObjects = await this.knexDataManager.loadManyByRawWhereClauseAsync(
this.queryContext,
@@ -329,11 +423,8 @@ export class AuthorizationResultBasedKnexEntityLoader<
* @returns SQL query builder for building and executing SQL queries that when executed returns entity results where result error can be UnauthorizedError.
*/
loadManyBySQL(
fragment: SQLFragment,
modifiers: EntityLoaderQuerySelectionModifiersWithOrderByFragment<
TFields,
TSelectedFields
> = {},
fragment: SQLFragment<Pick<TFields, TSelectedFields>>,
modifiers: EntityLoaderQuerySelectionModifiers<TFields, TSelectedFields> = {},
): AuthorizationResultBasedSQLQueryBuilder<
TFields,
TIDField,
@@ -388,6 +479,14 @@ export class AuthorizationResultBasedKnexEntityLoader<
pageInfo,
};
}
/**
* Authorization-result-based version of the EnforcingKnexEntityLoader method by the same name.
* @returns The pagination cursor for the given entity.
*/
getPaginationCursorForEntity(entity: TEntity): string {
return this.knexDataManager.getCursorForEntityID(entity.getID());
}
}
/**
@@ -418,8 +517,8 @@ export class AuthorizationResultBasedSQLQueryBuilder<
TSelectedFields
>,
private readonly queryContext: EntityQueryContext,
sqlFragment: SQLFragment,
modifiers: EntityLoaderQuerySelectionModifiersWithOrderByFragment<TFields, TSelectedFields>,
sqlFragment: SQLFragment<Pick<TFields, TSelectedFields>>,
modifiers: EntityLoaderQuerySelectionModifiers<TFields, TSelectedFields>,
) {
super(sqlFragment, modifiers);
}
@@ -58,6 +58,11 @@ export interface TableFieldMultiValueEqualityCondition {
tableValues: readonly any[];
}
export enum NullsOrdering {
FIRST = 'first',
LAST = 'last',
}
/**
* Ordering options for `orderBy` clauses.
*/
@@ -75,17 +80,41 @@ export enum OrderByOrdering {
DESCENDING = 'desc',
}
export interface PostgresOrderByClause<TFields extends Record<string, any>> {
/**
* The field name to order by.
*/
fieldName: keyof TFields;
export type PostgresOrderByClause<TFields extends Record<string, any>> =
| {
/**
* The field name to order by.
*/
fieldName: keyof TFields;
/**
* The OrderByOrdering to order by.
*/
order: OrderByOrdering;
}
/**
* The OrderByOrdering to order by.
*/
order: OrderByOrdering;
/**
* Optional nulls ordering. If not provided, the database default is used
* (NULLS LAST for ASC, NULLS FIRST for DESC in PostgreSQL).
*/
nulls?: NullsOrdering | undefined;
}
| {
/**
* A raw SQL fragment to order by. May not contain ASC or DESC, as ordering direction is determined by the `order` property.
*/
fieldFragment: SQLFragment<TFields>;
/**
* The OrderByOrdering to order by.
*/
order: OrderByOrdering;
/**
* Optional nulls ordering. If not provided, the database default is used
* (NULLS LAST for ASC, NULLS FIRST for DESC in PostgreSQL).
*/
nulls?: NullsOrdering | undefined;
};
/**
* SQL modifiers that only affect the selection but not the projection.
@@ -107,44 +136,24 @@ export interface PostgresQuerySelectionModifiers<TFields extends Record<string,
limit?: number;
}
export interface PostgresQuerySelectionModifiersWithOrderByRaw<
TFields extends Record<string, any>,
> extends PostgresQuerySelectionModifiers<TFields> {
/**
* Order the entities by a raw SQL `ORDER BY` clause.
*/
orderByRaw?: string;
}
export type TableOrderByClause<TFields extends Record<string, any>> =
| {
columnName: string;
order: OrderByOrdering;
nulls: NullsOrdering | undefined;
}
| {
columnFragment: SQLFragment<TFields>;
order: OrderByOrdering;
nulls: NullsOrdering | undefined;
};
export interface PostgresQuerySelectionModifiersWithOrderByFragment<
TFields extends Record<string, any>,
> extends PostgresQuerySelectionModifiers<TFields> {
/**
* Order the entities by a SQL fragment `ORDER BY` clause.
*/
orderByFragment?: SQLFragment;
}
export interface TableQuerySelectionModifiers {
orderBy:
| {
columnName: string;
order: OrderByOrdering;
}[]
| undefined;
export interface TableQuerySelectionModifiers<TFields extends Record<string, any>> {
orderBy: TableOrderByClause<TFields>[] | undefined;
offset: number | undefined;
limit: number | undefined;
}
export interface TableQuerySelectionModifiersWithOrderByRaw extends TableQuerySelectionModifiers {
orderByRaw: string | undefined;
orderByRawBindings?: readonly any[];
}
export interface TableQuerySelectionModifiersWithOrderByFragment extends TableQuerySelectionModifiers {
orderByFragment: SQLFragment | undefined;
}
export abstract class BasePostgresEntityDatabaseAdapter<
TFields extends Record<string, any>,
TIDField extends keyof TFields,
@@ -167,7 +176,7 @@ export abstract class BasePostgresEntityDatabaseAdapter<
*/
async fetchManyByFieldEqualityConjunctionAsync<N extends keyof TFields>(
queryContext: EntityQueryContext,
fieldEqualityOperands: FieldEqualityCondition<TFields, N>[],
fieldEqualityOperands: readonly FieldEqualityCondition<TFields, N>[],
querySelectionModifiers: PostgresQuerySelectionModifiers<TFields>,
): Promise<readonly Readonly<TFields>[]> {
const tableFieldSingleValueOperands: TableFieldSingleValueEqualityCondition[] = [];
@@ -204,7 +213,7 @@ export abstract class BasePostgresEntityDatabaseAdapter<
tableName: string,
tableFieldSingleValueEqualityOperands: TableFieldSingleValueEqualityCondition[],
tableFieldMultiValueEqualityOperands: TableFieldMultiValueEqualityCondition[],
querySelectionModifiers: TableQuerySelectionModifiers,
querySelectionModifiers: TableQuerySelectionModifiers<TFields>,
): Promise<object[]>;
/**
@@ -220,14 +229,14 @@ export abstract class BasePostgresEntityDatabaseAdapter<
queryContext: EntityQueryContext,
rawWhereClause: string,
bindings: any[] | object,
querySelectionModifiers: PostgresQuerySelectionModifiersWithOrderByRaw<TFields>,
querySelectionModifiers: PostgresQuerySelectionModifiers<TFields>,
): Promise<readonly Readonly<TFields>[]> {
const results = await this.fetchManyByRawWhereClauseInternalAsync(
queryContext.getQueryInterface(),
this.entityConfiguration.tableName,
rawWhereClause,
bindings,
this.convertToTableQueryModifiersWithOrderByRaw(querySelectionModifiers),
this.convertToTableQueryModifiers(querySelectionModifiers),
);
return results.map((result) =>
@@ -240,7 +249,7 @@ export abstract class BasePostgresEntityDatabaseAdapter<
tableName: string,
rawWhereClause: string,
bindings: object | any[],
querySelectionModifiers: TableQuerySelectionModifiersWithOrderByRaw,
querySelectionModifiers: TableQuerySelectionModifiers<TFields>,
): Promise<object[]>;
/**
@@ -253,14 +262,14 @@ export abstract class BasePostgresEntityDatabaseAdapter<
*/
async fetchManyBySQLFragmentAsync(
queryContext: EntityQueryContext,
sqlFragment: SQLFragment,
querySelectionModifiers: PostgresQuerySelectionModifiersWithOrderByFragment<TFields>,
sqlFragment: SQLFragment<TFields>,
querySelectionModifiers: PostgresQuerySelectionModifiers<TFields>,
): Promise<readonly Readonly<TFields>[]> {
const results = await this.fetchManyBySQLFragmentInternalAsync(
queryContext.getQueryInterface(),
this.entityConfiguration.tableName,
sqlFragment,
this.convertToTableQueryModifiersWithOrderByFragment(querySelectionModifiers),
this.convertToTableQueryModifiers(querySelectionModifiers),
);
return results.map((result) =>
@@ -271,42 +280,35 @@ export abstract class BasePostgresEntityDatabaseAdapter<
protected abstract fetchManyBySQLFragmentInternalAsync(
queryInterface: Knex,
tableName: string,
sqlFragment: SQLFragment,
querySelectionModifiers: TableQuerySelectionModifiersWithOrderByFragment,
sqlFragment: SQLFragment<TFields>,
querySelectionModifiers: TableQuerySelectionModifiers<TFields>,
): Promise<object[]>;
private convertToTableQueryModifiersWithOrderByRaw(
querySelectionModifiers: PostgresQuerySelectionModifiersWithOrderByRaw<TFields>,
): TableQuerySelectionModifiersWithOrderByRaw {
return {
...this.convertToTableQueryModifiers(querySelectionModifiers),
orderByRaw: querySelectionModifiers.orderByRaw,
};
}
private convertToTableQueryModifiersWithOrderByFragment(
querySelectionModifiers: PostgresQuerySelectionModifiersWithOrderByFragment<TFields>,
): TableQuerySelectionModifiersWithOrderByFragment {
return {
...this.convertToTableQueryModifiers(querySelectionModifiers),
orderByFragment: querySelectionModifiers.orderByFragment,
};
}
private convertToTableQueryModifiers(
querySelectionModifiers: PostgresQuerySelectionModifiers<TFields>,
): TableQuerySelectionModifiers {
): TableQuerySelectionModifiers<TFields> {
const orderBy = querySelectionModifiers.orderBy;
return {
orderBy:
orderBy !== undefined
? orderBy.map((orderBySpecification) => ({
columnName: getDatabaseFieldForEntityField(
this.entityConfiguration,
orderBySpecification.fieldName,
),
order: orderBySpecification.order,
}))
? orderBy.map((orderBySpecification): TableOrderByClause<TFields> => {
if ('fieldName' in orderBySpecification) {
return {
columnName: getDatabaseFieldForEntityField(
this.entityConfiguration,
orderBySpecification.fieldName,
),
order: orderBySpecification.order,
nulls: orderBySpecification.nulls,
};
} else {
return {
columnFragment: orderBySpecification.fieldFragment,
order: orderBySpecification.order,
nulls: orderBySpecification.nulls,
};
}
})
: undefined,
offset: querySelectionModifiers.offset,
limit: querySelectionModifiers.limit,
@@ -1,8 +1,8 @@
import {
EntityLoaderOrderByClause,
EntityLoaderQuerySelectionModifiersWithOrderByFragment,
EntityLoaderQuerySelectionModifiers,
} from './AuthorizationResultBasedKnexEntityLoader';
import { OrderByOrdering } from './BasePostgresEntityDatabaseAdapter';
import { NullsOrdering, OrderByOrdering } from './BasePostgresEntityDatabaseAdapter';
import { SQLFragment } from './SQLOperator';
/**
@@ -16,12 +16,11 @@ export abstract class BaseSQLQueryBuilder<
private executed = false;
constructor(
private readonly sqlFragment: SQLFragment,
private readonly sqlFragment: SQLFragment<Pick<TFields, TSelectedFields>>,
private readonly modifiers: {
limit?: number;
offset?: number;
orderBy?: readonly EntityLoaderOrderByClause<TFields, TSelectedFields>[];
orderByFragment?: SQLFragment;
},
) {}
@@ -44,8 +43,12 @@ export abstract class BaseSQLQueryBuilder<
/**
* Order by a field. Can be called multiple times to add multiple order bys.
*/
orderBy(fieldName: TSelectedFields, order: OrderByOrdering = OrderByOrdering.ASCENDING): this {
this.modifiers.orderBy = [...(this.modifiers.orderBy ?? []), { fieldName, order }];
orderBy(
fieldName: TSelectedFields,
order: OrderByOrdering = OrderByOrdering.ASCENDING,
nulls: NullsOrdering | undefined = undefined,
): this {
this.modifiers.orderBy = [...(this.modifiers.orderBy ?? []), { fieldName, order, nulls }];
return this;
}
@@ -55,38 +58,41 @@ export abstract class BaseSQLQueryBuilder<
*
* @example
* ```ts
* import { sql, raw } from '@expo/entity-database-adapter-knex';
*
* // Safe parameterized ordering
* .orderBySQL(sql`CASE WHEN priority = ${1} THEN 0 ELSE 1 END, created_at DESC`)
*
* // Dynamic column ordering
* const sortColumn = 'name';
* .orderBySQL(sql`${raw(sortColumn)} DESC NULLS LAST`)
*
* // Complex expressions
* .orderBySQL(sql`array_length(tags, 1) DESC, score * ${multiplier} ASC`)
* query.orderByFragment(
* sql`(data->>'createdAt')::timestamp`,
* OrderByOrdering.DESCENDING,
* );
* // Generates ORDER BY clause that orders by the createdAt field in the JSONB data column, cast to a timestamp, in descending order.
* // Note that the SQL fragment is parameterized, so it is safe from SQL injection.
* // The generated SQL would look like: ORDER BY (data->>'createdAt')::timestamp DESC
* ```
*
* @param fragment - The SQL fragment to order by. Must not include the ASC/DESC keyword, as ordering direction is determined by the `order` parameter.
* @param order - The ordering direction (ascending or descending). Defaults to ascending.
*/
orderBySQL(fragment: SQLFragment): this {
this.modifiers.orderByFragment = fragment;
orderBySQL(
fragment: SQLFragment<Pick<TFields, TSelectedFields>>,
order: OrderByOrdering = OrderByOrdering.ASCENDING,
nulls: NullsOrdering | undefined = undefined,
): this {
this.modifiers.orderBy = [
...(this.modifiers.orderBy ?? []),
{ fieldFragment: fragment, order, nulls },
];
return this;
}
/**
* Get the current modifiers as QuerySelectionModifiersWithOrderByFragment<TFields>
*/
protected getModifiers(): EntityLoaderQuerySelectionModifiersWithOrderByFragment<
TFields,
TSelectedFields
> {
protected getModifiers(): EntityLoaderQuerySelectionModifiers<TFields, TSelectedFields> {
return this.modifiers;
}
/**
* Get the SQL fragment
*/
protected getSQLFragment(): SQLFragment {
protected getSQLFragment(): SQLFragment<Pick<TFields, TSelectedFields>> {
return this.sqlFragment;
}
@@ -11,8 +11,6 @@ import {
AuthorizationResultBasedKnexEntityLoader,
EntityLoaderLoadPageArgs,
EntityLoaderQuerySelectionModifiers,
EntityLoaderQuerySelectionModifiersWithOrderByFragment,
EntityLoaderQuerySelectionModifiersWithOrderByRaw,
} from './AuthorizationResultBasedKnexEntityLoader';
import { FieldEqualityCondition } from './BasePostgresEntityDatabaseAdapter';
import { BaseSQLQueryBuilder } from './BaseSQLQueryBuilder';
@@ -130,19 +128,17 @@ export class EnforcingKnexEntityLoader<
* ```
* @param rawWhereClause - SQL WHERE clause. Interpolated values should be specified as ?-placeholders or :key_name
* @param bindings - values to bind to the placeholders in the WHERE clause
* @param querySelectionModifiers - limit, offset, and orderBy for the query. If orderBy is specified
* as orderByRaw, specify as string orderBy SQL clause with uncheckd literal values or ?-placeholders
* @param querySelectionModifiers - limit, offset, and orderBy for the query.
* @returns entities matching the WHERE clause
* @throws EntityNotAuthorizedError when viewer is not authorized to view one or more of the returned entities
* @throws Error when rawWhereClause or bindings are invalid
*
* @deprecated Use loadManyBySQL instead for safer value bindings and more flexible query building.
*/
async loadManyByRawWhereClauseAsync(
rawWhereClause: string,
bindings: any[] | object,
querySelectionModifiers: EntityLoaderQuerySelectionModifiersWithOrderByRaw<
TFields,
TSelectedFields
> = {},
querySelectionModifiers: EntityLoaderQuerySelectionModifiers<TFields, TSelectedFields> = {},
): Promise<readonly TEntity[]> {
const entityResults = await this.knexEntityLoader.loadManyByRawWhereClauseAsync(
rawWhereClause,
@@ -172,11 +168,8 @@ export class EnforcingKnexEntityLoader<
* ```
*/
loadManyBySQL(
fragment: SQLFragment,
modifiers: EntityLoaderQuerySelectionModifiersWithOrderByFragment<
TFields,
TSelectedFields
> = {},
fragment: SQLFragment<Pick<TFields, TSelectedFields>>,
modifiers: EntityLoaderQuerySelectionModifiers<TFields, TSelectedFields> = {},
): EnforcingSQLQueryBuilder<
TFields,
TIDField,
@@ -217,6 +210,18 @@ export class EnforcingKnexEntityLoader<
pageInfo: pageResult.pageInfo,
};
}
/**
* Get cursor for a given entity that matches what loadPageAsync would produce.
* Useful for constructing pagination cursors for entities returned from other loader methods that can then be passed to loadPageAsync for pagination.
* Most commonly used for testing pagination behavior.
*
* @param entity - The entity to get the pagination cursor for.
* @returns The pagination cursor for the given entity.
*/
getPaginationCursorForEntity(entity: TEntity): string {
return this.knexEntityLoader.getPaginationCursorForEntity(entity);
}
}
/**
@@ -246,8 +251,8 @@ export class EnforcingSQLQueryBuilder<
TPrivacyPolicy,
TSelectedFields
>,
sqlFragment: SQLFragment,
modifiers: EntityLoaderQuerySelectionModifiersWithOrderByFragment<TFields, TSelectedFields>,
sqlFragment: SQLFragment<Pick<TFields, TSelectedFields>>,
modifiers: EntityLoaderQuerySelectionModifiers<TFields, TSelectedFields>,
) {
super(sqlFragment, modifiers);
}
@@ -35,6 +35,7 @@ export abstract class PostgresEntity<
TMFields extends object,
TMIDField extends keyof NonNullable<Pick<TMFields, TMSelectedFields>>,
TMViewerContext extends ViewerContext,
TMViewerContext2 extends TMViewerContext,
TMEntity extends ReadonlyEntity<TMFields, TMIDField, TMViewerContext, TMSelectedFields>,
TMPrivacyPolicy extends EntityPrivacyPolicy<
TMFields,
@@ -53,7 +54,7 @@ export abstract class PostgresEntity<
TMPrivacyPolicy,
TMSelectedFields
>,
viewerContext: TMViewerContext,
viewerContext: TMViewerContext2,
queryContext: EntityQueryContext = viewerContext
.getViewerScopedEntityCompanionForClass(this)
.getQueryContextProvider()
@@ -80,6 +81,7 @@ export abstract class PostgresEntity<
TMFields extends object,
TMIDField extends keyof NonNullable<Pick<TMFields, TMSelectedFields>>,
TMViewerContext extends ViewerContext,
TMViewerContext2 extends TMViewerContext,
TMEntity extends ReadonlyEntity<TMFields, TMIDField, TMViewerContext, TMSelectedFields>,
TMPrivacyPolicy extends EntityPrivacyPolicy<
TMFields,
@@ -98,7 +100,7 @@ export abstract class PostgresEntity<
TMPrivacyPolicy,
TMSelectedFields
>,
viewerContext: TMViewerContext,
viewerContext: TMViewerContext2,
queryContext: EntityQueryContext = viewerContext
.getViewerScopedEntityCompanionForClass(this)
.getQueryContextProvider()
@@ -1,13 +1,18 @@
import { EntityConfiguration, FieldTransformer, FieldTransformerMap } from '@expo/entity';
import {
EntityConfiguration,
FieldTransformer,
FieldTransformerMap,
getDatabaseFieldForEntityField,
} from '@expo/entity';
import { Knex } from 'knex';
import {
BasePostgresEntityDatabaseAdapter,
NullsOrdering,
OrderByOrdering,
TableFieldMultiValueEqualityCondition,
TableFieldSingleValueEqualityCondition,
TableQuerySelectionModifiers,
TableQuerySelectionModifiersWithOrderByFragment,
TableQuerySelectionModifiersWithOrderByRaw,
} from './BasePostgresEntityDatabaseAdapter';
import { JSONArrayField, MaybeJSONArrayField } from './EntityFields';
import { PostgresEntityDatabaseAdapterConfiguration } from './PostgresEntityDatabaseAdapterProvider';
@@ -118,28 +123,9 @@ export class PostgresEntityDatabaseAdapter<
return results[0] ?? null;
}
private applyQueryModifiersToQueryOrderByRaw(
query: Knex.QueryBuilder,
querySelectionModifiers: TableQuerySelectionModifiersWithOrderByRaw,
): Knex.QueryBuilder {
const { orderByRaw } = querySelectionModifiers;
// orderByRaw takes precedence over orderBy - they are mutually exclusive
if (orderByRaw !== undefined) {
// Apply only orderByRaw (offset/limit still applied, but not orderBy)
return this.applyQueryModifiersToQuery(query, {
...querySelectionModifiers,
orderBy: undefined, // Explicitly exclude orderBy when orderByRaw is present
}).orderByRaw(orderByRaw);
} else {
// Apply regular orderBy (and offset/limit)
return this.applyQueryModifiersToQuery(query, querySelectionModifiers);
}
}
private applyQueryModifiersToQuery(
query: Knex.QueryBuilder,
querySelectionModifiers: TableQuerySelectionModifiers,
querySelectionModifiers: TableQuerySelectionModifiers<TFields>,
): Knex.QueryBuilder {
const { orderBy, offset, limit } = querySelectionModifiers;
@@ -147,7 +133,25 @@ export class PostgresEntityDatabaseAdapter<
if (orderBy !== undefined) {
for (const orderBySpecification of orderBy) {
ret = ret.orderBy(orderBySpecification.columnName, orderBySpecification.order);
if ('columnName' in orderBySpecification) {
ret = ret.orderBy(
orderBySpecification.columnName,
orderBySpecification.order,
orderBySpecification.nulls,
);
} else {
const orderDirection =
orderBySpecification.order === OrderByOrdering.ASCENDING ? 'ASC' : 'DESC';
const nullsSuffix = orderBySpecification.nulls
? ` NULLS ${orderBySpecification.nulls === NullsOrdering.FIRST ? 'FIRST' : 'LAST'}`
: '';
ret = ret.orderByRaw(
`(${orderBySpecification.columnFragment.sql}) ${orderDirection}${nullsSuffix}`,
orderBySpecification.columnFragment.getKnexBindings((fieldName) =>
getDatabaseFieldForEntityField(this.entityConfiguration, fieldName),
),
);
}
}
}
@@ -167,7 +171,7 @@ export class PostgresEntityDatabaseAdapter<
tableName: string,
tableFieldSingleValueEqualityOperands: TableFieldSingleValueEqualityCondition[],
tableFieldMultiValueEqualityOperands: TableFieldMultiValueEqualityCondition[],
querySelectionModifiers: TableQuerySelectionModifiers,
querySelectionModifiers: TableQuerySelectionModifiers<TFields>,
): Promise<object[]> {
let query = queryInterface.select().from(tableName);
@@ -213,40 +217,29 @@ export class PostgresEntityDatabaseAdapter<
tableName: string,
rawWhereClause: string,
bindings: object | any[],
querySelectionModifiers: TableQuerySelectionModifiersWithOrderByRaw,
querySelectionModifiers: TableQuerySelectionModifiers<TFields>,
): Promise<object[]> {
let query = queryInterface
.select()
.from(tableName)
.whereRaw(rawWhereClause, bindings as any);
query = this.applyQueryModifiersToQueryOrderByRaw(query, querySelectionModifiers);
let query = queryInterface.select().from(tableName).whereRaw(rawWhereClause, bindings);
query = this.applyQueryModifiersToQuery(query, querySelectionModifiers);
return await wrapNativePostgresCallAsync(() => query);
}
protected async fetchManyBySQLFragmentInternalAsync(
queryInterface: Knex,
tableName: string,
sqlFragment: SQLFragment,
querySelectionModifiers: TableQuerySelectionModifiersWithOrderByFragment,
sqlFragment: SQLFragment<TFields>,
querySelectionModifiers: TableQuerySelectionModifiers<TFields>,
): Promise<object[]> {
let query = queryInterface
.select()
.from(tableName)
.whereRaw(sqlFragment.sql, sqlFragment.getKnexBindings());
// Apply order by modifiers
// orderByFragment takes precedence over orderBy - they are mutually exclusive
const { orderByFragment } = querySelectionModifiers;
if (orderByFragment !== undefined) {
// Apply only orderByFragment (offset/limit still applied, but not orderBy)
query = this.applyQueryModifiersToQuery(query, {
...querySelectionModifiers,
orderBy: undefined, // Explicitly exclude orderBy when orderByFragment is present
}).orderByRaw(orderByFragment.sql, orderByFragment.getKnexBindings());
} else {
// Apply regular orderBy (and offset/limit)
query = this.applyQueryModifiersToQuery(query, querySelectionModifiers);
}
.whereRaw(
sqlFragment.sql,
sqlFragment.getKnexBindings((fieldName) =>
getDatabaseFieldForEntityField(this.entityConfiguration, fieldName),
),
);
query = this.applyQueryModifiersToQuery(query, querySelectionModifiers);
return await wrapNativePostgresCallAsync(() => query);
}
@@ -1,3 +1,5 @@
import assert from 'assert';
/**
* Supported SQL value types that can be safely parameterized.
* This ensures type safety and prevents passing unsupported types to SQL queries.
@@ -17,29 +19,37 @@ export type SupportedSQLValue =
/**
* Types of bindings that can be used in SQL queries.
*/
export type SQLBinding =
export type SQLBinding<TFields extends Record<string, any>> =
| { type: 'value'; value: SupportedSQLValue }
| { type: 'identifier'; name: string };
| { type: 'identifier'; name: string }
| { type: 'entityField'; fieldName: keyof TFields };
/**
* SQL Fragment class that safely handles parameterized queries.
*/
export class SQLFragment {
export class SQLFragment<TFields extends Record<string, any>> {
constructor(
public readonly sql: string,
public readonly bindings: readonly SQLBinding[],
public readonly bindings: readonly SQLBinding<TFields>[],
) {}
/**
* Get bindings in the format expected by Knex.
* Knex expects a flat array where both identifiers and values are mixed in order.
*
* @param getColumnForField - function that resolves an entity field name to its database column name
*/
getKnexBindings(): readonly (string | SupportedSQLValue)[] {
getKnexBindings(
getColumnForField: (fieldName: keyof TFields) => string,
): readonly SupportedSQLValue[] {
return this.bindings.map((b) => {
if (b.type === 'identifier') {
return b.name;
} else {
return b.value;
switch (b.type) {
case 'entityField':
return getColumnForField(b.fieldName);
case 'identifier':
return b.name;
case 'value':
return b.value;
}
});
}
@@ -47,20 +57,21 @@ export class SQLFragment {
/**
* Combine SQL fragments
*/
append(other: SQLFragment): SQLFragment {
return SQLFragment.join([this, other], ' ');
append(other: SQLFragment<TFields>): SQLFragment<TFields> {
return joinSQLFragments([this, other], ' ');
}
/**
* Join multiple SQL fragments into a single fragment with a separator.
* Join multiple SQL fragments with a comma separator.
* Useful for combining column lists, value lists, etc.
*
* @param fragments - Array of SQL fragments to join
* @param separator - Separator string (default: ', ')
* @returns - A new SQLFragment with the fragments joined by a comma and space
*/
static join(fragments: SQLFragment[], separator = ', '): SQLFragment {
return new SQLFragment(
fragments.map((f) => f.sql).join(separator),
fragments.flatMap((f) => f.bindings),
);
static joinWithCommaSeparator<TFields extends Record<string, any>>(
...fragments: readonly SQLFragment<TFields>[]
): SQLFragment<TFields> {
return joinSQLFragments(fragments, ', ');
}
/**
@@ -75,15 +86,17 @@ export class SQLFragment {
* // Generates: "SELECT * FROM users WHERE age > ? ORDER BY name"
* ```
*/
static concat(...fragments: SQLFragment[]): SQLFragment {
return SQLFragment.join(fragments, ' ');
static concat<TFields extends Record<string, any>>(
...fragments: readonly SQLFragment<TFields>[]
): SQLFragment<TFields> {
return joinSQLFragments(fragments, ' ');
}
/**
* Get a debug representation of the query with values inline
* WARNING: This is for debugging only. Never execute the returned string directly.
*/
get toDebugString(): string {
getDebugString(): string {
let debugString = this.sql;
let bindingIndex = 0;
@@ -101,6 +114,9 @@ export class SQLFragment {
if (match === '??' && binding.type === 'identifier') {
// For identifiers, show them quoted as they would appear
return `"${binding.name.replace(/"/g, '""')}"`;
} else if (match === '??' && binding.type === 'entityField') {
// For entity fields, show the entity field name as the identifier for debugging
return `"${binding.fieldName.toString()}"`;
} else if (match === '?' && binding.type === 'value') {
return SQLFragment.formatDebugValue(binding.value);
} else {
@@ -177,17 +193,24 @@ export class SQLIdentifier {
constructor(public readonly name: string) {}
}
/**
* Helper for referencing entity fields that can be used in SQL queries. This allows for type-safe references to fields of an entity
* and does automatic translation to DB field names.
*/
export class SQLEntityField<TFields extends Record<string, any>> {
constructor(public readonly fieldName: keyof TFields) {}
}
/**
* Helper for raw SQL that should not be parameterized
* WARNING: Only use this with trusted input to avoid SQL injection
*/
export class SQLRaw {
export class SQLUnsafeRaw {
constructor(public readonly rawSql: string) {}
}
/**
* Create a SQL identifier (table/column name) that will be escaped by Knex using ??.
* The escaping is delegated to Knex which will handle it based on the database type.
*
* @example
* ```ts
@@ -200,6 +223,18 @@ export function identifier(name: string): SQLIdentifier {
return new SQLIdentifier(name);
}
/**
* Create a reference to an entity field that can be used in SQL queries. This allows for type-safe references to fields of an entity
* and does automatic translation to DB field names and will be escaped by Knex using ??.
*
* @param fieldName - The entity field name to reference.
*/
export function entityField<TFields extends Record<string, any>>(
fieldName: keyof TFields,
): SQLEntityField<TFields> {
return new SQLEntityField(fieldName);
}
/**
* Insert raw SQL that will not be parameterized
* WARNING: This bypasses SQL injection protection. Only use with trusted input.
@@ -208,14 +243,14 @@ export function identifier(name: string): SQLIdentifier {
* ```ts
* // Dynamic column names
* const sortColumn = 'created_at';
* const query = sql`ORDER BY ${raw(sortColumn)} DESC`;
* const query = sql`ORDER BY ${unsafeRaw(sortColumn)} DESC`;
*
* // Dynamic SQL expressions
* const query = sql`WHERE ${raw('EXTRACT(year FROM created_at)')} = ${2024}`;
* const query = sql`WHERE ${unsafeRaw('EXTRACT(year FROM created_at)')} = ${2024}`;
* ```
*/
export function raw(sqlString: string): SQLRaw {
return new SQLRaw(sqlString);
export function unsafeRaw(sqlString: string): SQLUnsafeRaw {
return new SQLUnsafeRaw(sqlString);
}
/**
@@ -227,12 +262,18 @@ export function raw(sqlString: string): SQLRaw {
* const query = sql`age >= ${age} AND status = ${'active'}`;
* ```
*/
export function sql(
export function sql<TFields extends Record<string, any>>(
strings: TemplateStringsArray,
...values: readonly (SupportedSQLValue | SQLFragment | SQLIdentifier | SQLRaw)[]
): SQLFragment {
...values: readonly (
| SupportedSQLValue
| SQLFragment<TFields>
| SQLIdentifier
| SQLUnsafeRaw
| SQLEntityField<TFields>
)[]
): SQLFragment<TFields> {
let sqlString = '';
const bindings: SQLBinding[] = [];
const bindings: SQLBinding<TFields>[] = [];
strings.forEach((string, i) => {
sqlString += string;
@@ -247,13 +288,17 @@ export function sql(
// Handle identifiers (table/column names) with ?? placeholder
sqlString += '??';
bindings.push({ type: 'identifier', name: value.name });
} else if (value instanceof SQLRaw) {
} else if (value instanceof SQLEntityField) {
// Handle entity field references by treating them as identifiers
sqlString += '??';
bindings.push({ type: 'entityField', fieldName: value.fieldName });
} else if (value instanceof SQLUnsafeRaw) {
// Handle raw SQL (WARNING: no parameterization)
sqlString += value.rawSql;
} else if (Array.isArray(value)) {
// Handle IN clauses
sqlString += `(${value.map(() => '?').join(', ')})`;
bindings.push(...value.map((v) => ({ type: 'value' as const, value: v })));
bindings.push(...value.map((v): SQLBinding<TFields> => ({ type: 'value', value: v })));
} else {
// Regular value binding
sqlString += '?';
@@ -265,6 +310,23 @@ export function sql(
return new SQLFragment(sqlString, bindings);
}
type PickSupportedSQLValueKeys<T> = {
[K in keyof T]: T[K] extends SupportedSQLValue ? K : never;
}[keyof T];
type PickStringValueKeys<T> = {
[K in keyof T]: T[K] extends string | null | undefined ? K : never;
}[keyof T];
type JsonSerializable =
| string
| number
| boolean
| null
| undefined
| readonly JsonSerializable[]
| { readonly [key: string]: JsonSerializable };
/**
* Common SQL helper functions for building queries
*/
@@ -274,28 +336,33 @@ export const SQLFragmentHelpers = {
*
* @example
* ```ts
* const query = SQLFragmentHelpers.inArray('status', ['active', 'pending']);
* // Generates: ?? IN (?, ?) with bindings ['status', 'active', 'pending']
* const query = SQLFragmentHelpers.inArray<MyFields, 'id'>('status', ['active', 'pending']);
* // Generates: ?? IN (?, ?) with entityField binding for 'status' and value bindings
* ```
*/
inArray<T extends SupportedSQLValue>(column: string, values: readonly T[]): SQLFragment {
inArray<TFields extends Record<string, any>, N extends PickSupportedSQLValueKeys<TFields>>(
fieldName: N,
values: readonly TFields[N][],
): SQLFragment<TFields> {
if (values.length === 0) {
// Handle empty array case - always false
return sql`1 = 0`;
}
// The array is already correctly typed, just needs to be seen as SupportedSQLValue for the template
return sql`${identifier(column)} IN ${values as readonly SupportedSQLValue[]}`;
return sql`${entityField(fieldName)} IN ${values}`;
},
/**
* NOT IN clause helper
*/
notInArray<T extends SupportedSQLValue>(column: string, values: readonly T[]): SQLFragment {
notInArray<TFields extends Record<string, any>, N extends PickSupportedSQLValueKeys<TFields>>(
fieldName: N,
values: readonly TFields[N][],
): SQLFragment<TFields> {
if (values.length === 0) {
// Handle empty array case - always true
return sql`1 = 1`;
}
return sql`${identifier(column)} NOT IN ${values as readonly SupportedSQLValue[]}`;
return sql`${entityField(fieldName)} NOT IN ${values}`;
},
/**
@@ -303,19 +370,27 @@ export const SQLFragmentHelpers = {
*
* @example
* ```ts
* const query = SQLFragmentHelpers.between('age', 18, 65);
* // Generates: "age" BETWEEN ? AND ? with values [18, 65]
* const query = SQLFragmentHelpers.between<MyFields, 'id'>('age', 18, 65);
* // Generates: ?? BETWEEN ? AND ? with entityField binding for 'age' and value bindings
* ```
*/
between<T extends string | number | Date>(column: string, min: T, max: T): SQLFragment {
return sql`${identifier(column)} BETWEEN ${min} AND ${max}`;
between<TFields extends Record<string, any>, N extends PickSupportedSQLValueKeys<TFields>>(
fieldName: N,
min: TFields[N],
max: TFields[N],
): SQLFragment<TFields> {
return sql`${entityField(fieldName)} BETWEEN ${min} AND ${max}`;
},
/**
* NOT BETWEEN helper
*/
notBetween<T extends string | number | Date>(column: string, min: T, max: T): SQLFragment {
return sql`${identifier(column)} NOT BETWEEN ${min} AND ${max}`;
notBetween<TFields extends Record<string, any>, N extends PickSupportedSQLValueKeys<TFields>>(
fieldName: N,
min: TFields[N],
max: TFields[N],
): SQLFragment<TFields> {
return sql`${entityField(fieldName)} NOT BETWEEN ${min} AND ${max}`;
},
/**
@@ -323,156 +398,233 @@ export const SQLFragmentHelpers = {
*
* @example
* ```ts
* const query = SQLFragmentHelpers.like('name', '%John%');
* // Generates: "name" LIKE ? with value '%John%'
* const query = SQLFragmentHelpers.like<MyFields, 'id'>('name', '%John%');
* // Generates: ?? LIKE ? with entityField binding for 'name' and value binding
* ```
*/
like(column: string, pattern: string): SQLFragment {
return sql`${identifier(column)} LIKE ${pattern}`;
like<TFields extends Record<string, any>>(
fieldName: PickStringValueKeys<TFields>,
pattern: string,
): SQLFragment<TFields> {
return sql`${entityField(fieldName)} LIKE ${pattern}`;
},
/**
* NOT LIKE helper
*/
notLike(column: string, pattern: string): SQLFragment {
return sql`${identifier(column)} NOT LIKE ${pattern}`;
notLike<TFields extends Record<string, any>>(
fieldName: PickStringValueKeys<TFields>,
pattern: string,
): SQLFragment<TFields> {
return sql`${entityField(fieldName)} NOT LIKE ${pattern}`;
},
/**
* ILIKE helper for case-insensitive matching
*/
ilike(column: string, pattern: string): SQLFragment {
return sql`${identifier(column)} ILIKE ${pattern}`;
ilike<TFields extends Record<string, any>>(
fieldName: PickStringValueKeys<TFields>,
pattern: string,
): SQLFragment<TFields> {
return sql`${entityField(fieldName)} ILIKE ${pattern}`;
},
/**
* NOT ILIKE helper for case-insensitive non-matching
*/
notIlike(column: string, pattern: string): SQLFragment {
return sql`${identifier(column)} NOT ILIKE ${pattern}`;
notIlike<TFields extends Record<string, any>>(
fieldName: PickStringValueKeys<TFields>,
pattern: string,
): SQLFragment<TFields> {
return sql`${entityField(fieldName)} NOT ILIKE ${pattern}`;
},
/**
* NULL check helper
*/
isNull(column: string): SQLFragment {
return sql`${identifier(column)} IS NULL`;
isNull<TFields extends Record<string, any>>(fieldName: keyof TFields): SQLFragment<TFields> {
return sql`${entityField(fieldName)} IS NULL`;
},
/**
* NOT NULL check helper
*/
isNotNull(column: string): SQLFragment {
return sql`${identifier(column)} IS NOT NULL`;
isNotNull<TFields extends Record<string, any>>(fieldName: keyof TFields): SQLFragment<TFields> {
return sql`${entityField(fieldName)} IS NOT NULL`;
},
/**
* Single-equals-equality operator
*/
eq(column: string, value: SupportedSQLValue): SQLFragment {
eq<TFields extends Record<string, any>, N extends PickSupportedSQLValueKeys<TFields>>(
fieldName: N,
value: TFields[N],
): SQLFragment<TFields> {
if (value === null || value === undefined) {
return SQLFragmentHelpers.isNull(column);
return SQLFragmentHelpers.isNull(fieldName);
}
return sql`${identifier(column)} = ${value}`;
return sql`${entityField(fieldName)} = ${value}`;
},
/**
* Single-equals-inequality operator
*/
neq(column: string, value: SupportedSQLValue): SQLFragment {
neq<TFields extends Record<string, any>, N extends PickSupportedSQLValueKeys<TFields>>(
fieldName: N,
value: TFields[N],
): SQLFragment<TFields> {
if (value === null || value === undefined) {
return SQLFragmentHelpers.isNotNull(column);
return SQLFragmentHelpers.isNotNull(fieldName);
}
return sql`${identifier(column)} != ${value}`;
return sql`${entityField(fieldName)} != ${value}`;
},
/**
* Greater-than comparison operator
*/
gt(column: string, value: SupportedSQLValue): SQLFragment {
return sql`${identifier(column)} > ${value}`;
gt<TFields extends Record<string, any>, N extends PickSupportedSQLValueKeys<TFields>>(
fieldName: N,
value: TFields[N],
): SQLFragment<TFields> {
return sql`${entityField(fieldName)} > ${value}`;
},
/**
* Greater-than-or-equal-to comparison operator
*/
gte(column: string, value: SupportedSQLValue): SQLFragment {
return sql`${identifier(column)} >= ${value}`;
gte<TFields extends Record<string, any>, N extends PickSupportedSQLValueKeys<TFields>>(
fieldName: N,
value: TFields[N],
): SQLFragment<TFields> {
return sql`${entityField(fieldName)} >= ${value}`;
},
/**
* Less-than comparison operator
*/
lt(column: string, value: SupportedSQLValue): SQLFragment {
return sql`${identifier(column)} < ${value}`;
lt<TFields extends Record<string, any>, N extends PickSupportedSQLValueKeys<TFields>>(
fieldName: N,
value: TFields[N],
): SQLFragment<TFields> {
return sql`${entityField(fieldName)} < ${value}`;
},
/**
* Less-than-or-equal-to comparison operator
*/
lte(column: string, value: SupportedSQLValue): SQLFragment {
return sql`${identifier(column)} <= ${value}`;
lte<TFields extends Record<string, any>, N extends PickSupportedSQLValueKeys<TFields>>(
fieldName: N,
value: TFields[N],
): SQLFragment<TFields> {
return sql`${entityField(fieldName)} <= ${value}`;
},
/**
* JSON contains operator (\@\>)
*/
jsonContains(column: string, value: unknown): SQLFragment {
return sql`${identifier(column)} @> ${JSON.stringify(value)}::jsonb`;
jsonContains<TFields extends Record<string, any>>(
fieldName: keyof TFields,
value: JsonSerializable,
): SQLFragment<TFields> {
const serialized = JSON.stringify(value);
// JSON.stringify returns undefined for unsupported types, but we also want to allow undefined as a value
assert(
serialized !== undefined || value === undefined,
'jsonContains: value is not JSON-serializable',
);
return sql`${entityField(fieldName)} @> ${serialized}::jsonb`;
},
/**
* JSON contained by operator (\<\@\)
*/
jsonContainedBy(column: string, value: unknown): SQLFragment {
return sql`${identifier(column)} <@ ${JSON.stringify(value)}::jsonb`;
jsonContainedBy<TFields extends Record<string, any>>(
fieldName: keyof TFields,
value: JsonSerializable,
): SQLFragment<TFields> {
const serialized = JSON.stringify(value);
// JSON.stringify returns undefined for unsupported types, but we also want to allow undefined as a value
assert(
serialized !== undefined || value === undefined,
'jsonContainedBy: value is not JSON-serializable',
);
return sql`${entityField(fieldName)} <@ ${serialized}::jsonb`;
},
/**
* JSON path extraction helper (-\>)
*/
jsonPath(column: string, path: string): SQLFragment {
return new SQLFragment(`"${column}"->'${path}'`, []);
jsonPath<TFields extends Record<string, any>>(
fieldName: keyof TFields,
path: string,
): SQLFragment<TFields> {
return sql`${entityField(fieldName)}->${path}`;
},
/**
* JSON path text extraction helper (-\>\>)
*/
jsonPathText(column: string, path: string): SQLFragment {
return new SQLFragment(`"${column}"->>'${path}'`, []);
jsonPathText<TFields extends Record<string, any>>(
fieldName: keyof TFields,
path: string,
): SQLFragment<TFields> {
return sql`${entityField(fieldName)}->>${path}`;
},
/**
* Logical AND of multiple fragments
*/
and(...conditions: SQLFragment[]): SQLFragment {
and<TFields extends Record<string, any>>(
...conditions: readonly SQLFragment<TFields>[]
): SQLFragment<TFields> {
if (conditions.length === 0) {
return sql`1 = 1`;
}
return SQLFragment.join(conditions, ' AND ');
return joinSQLFragments(
conditions.map((c) => SQLFragmentHelpers.group(c)),
' AND ',
);
},
/**
* Logical OR of multiple fragments
*/
or(...conditions: SQLFragment[]): SQLFragment {
or<TFields extends Record<string, any>>(
...conditions: readonly SQLFragment<TFields>[]
): SQLFragment<TFields> {
if (conditions.length === 0) {
return sql`1 = 0`;
}
return SQLFragment.join(conditions, ' OR ');
return joinSQLFragments(
conditions.map((c) => SQLFragmentHelpers.group(c)),
' OR ',
);
},
/**
* Logical NOT of a fragment
*/
not(condition: SQLFragment): SQLFragment {
not<TFields extends Record<string, any>>(condition: SQLFragment<TFields>): SQLFragment<TFields> {
return new SQLFragment('NOT (' + condition.sql + ')', condition.bindings);
},
/**
* Parentheses helper for grouping conditions
*/
group(condition: SQLFragment): SQLFragment {
group<TFields extends Record<string, any>>(
condition: SQLFragment<TFields>,
): SQLFragment<TFields> {
return new SQLFragment('(' + condition.sql + ')', condition.bindings);
},
};
// Internal helper function to join SQL fragments with a specified separator
function joinSQLFragments<TFields extends Record<string, any>>(
fragments: readonly SQLFragment<TFields>[],
separator: string,
): SQLFragment<TFields> {
return new SQLFragment(
fragments.map((f) => f.sql).join(separator),
fragments.flatMap((f) => f.bindings),
);
}
@@ -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) => {
@@ -17,9 +17,10 @@ import { Knex } from 'knex';
import { BigIntField, JSONArrayField, MaybeJSONArrayField } from '../EntityFields';
import { PostgresEntity } from '../PostgresEntity';
type PostgresTestEntityFields = {
export type PostgresTestEntityFields = {
id: string;
name: string | null;
label: string;
hasADog: boolean | null;
hasACat: boolean | null;
stringArray: string[] | null;
@@ -60,6 +61,7 @@ export class PostgresTestEntity extends PostgresEntity<
await knex.schema.createTable(tableName, (table) => {
table.uuid('id').defaultTo(knex.raw('gen_random_uuid()')).primary();
table.string('name');
table.string('label').notNullable().defaultTo('');
table.boolean('has_a_dog');
table.boolean('has_a_cat');
table.specificType('string_array', 'text[]');
@@ -138,6 +140,9 @@ export const postgresTestEntityConfiguration = new EntityConfiguration<
name: new StringField({
columnName: 'name',
}),
label: new StringField({
columnName: 'label',
}),
hasADog: new BooleanField({
columnName: 'has_a_dog',
}),
@@ -4,7 +4,7 @@ import { BigIntField, JSONArrayField, MaybeJSONArrayField } from '../EntityField
describeFieldTestCase(
new JSONArrayField({ columnName: 'wat' }),
[[[1, 2]] as any, [['hello']] as any],
[[[1, 2]], [['hello']]],
[1, 'hello'],
);
describeFieldTestCase(
@@ -1,13 +1,20 @@
import { getDatabaseFieldForEntityField } from '@expo/entity';
import { describe, expect, it } from '@jest/globals';
import {
entityField,
identifier,
raw,
unsafeRaw,
sql,
SQLEntityField,
SQLFragment,
SQLFragmentHelpers,
SQLIdentifier,
} from '../SQLOperator';
import { TestFields, testEntityConfiguration } from './fixtures/TestEntity';
const getColumnForField = (fieldName: string): string =>
getDatabaseFieldForEntityField(testEntityConfiguration, fieldName as keyof TestFields);
describe('SQLOperator', () => {
describe('sql template literal', () => {
@@ -17,7 +24,7 @@ describe('SQLOperator', () => {
const fragment = sql`age >= ${age} AND status = ${status}`;
expect(fragment.sql).toBe('age >= ? AND status = ?');
expect(fragment.getKnexBindings()).toEqual([18, 'active']);
expect(fragment.getKnexBindings(getColumnForField)).toEqual([18, 'active']);
});
it('handles nested SQL fragments', () => {
@@ -26,7 +33,7 @@ describe('SQLOperator', () => {
const combined = sql`${condition1} AND ${condition2}`;
expect(combined.sql).toBe('age >= ? AND status = ?');
expect(combined.getKnexBindings()).toEqual([18, 'active']);
expect(combined.getKnexBindings(getColumnForField)).toEqual([18, 'active']);
});
it('handles SQL identifiers', () => {
@@ -34,7 +41,7 @@ describe('SQLOperator', () => {
const fragment = sql`${identifier(columnName)} = ${'John'}`;
expect(fragment.sql).toBe('?? = ?');
expect(fragment.getKnexBindings()).toEqual(['user_name', 'John']);
expect(fragment.getKnexBindings(getColumnForField)).toEqual(['user_name', 'John']);
});
it('handles arrays for IN clauses', () => {
@@ -42,58 +49,62 @@ describe('SQLOperator', () => {
const fragment = sql`status IN ${values}`;
expect(fragment.sql).toBe('status IN (?, ?, ?)');
expect(fragment.getKnexBindings()).toEqual(['active', 'pending', 'approved']);
expect(fragment.getKnexBindings(getColumnForField)).toEqual([
'active',
'pending',
'approved',
]);
});
it('handles null values', () => {
const fragment = sql`field = ${null}`;
expect(fragment.sql).toBe('field = ?');
expect(fragment.getKnexBindings()).toEqual([null]);
expect(fragment.getKnexBindings(getColumnForField)).toEqual([null]);
});
it('handles empty strings', () => {
const fragment = sql`field = ${''}`;
expect(fragment.sql).toBe('field = ?');
expect(fragment.getKnexBindings()).toEqual(['']);
expect(fragment.getKnexBindings(getColumnForField)).toEqual(['']);
});
it('handles numbers including zero', () => {
const fragment = sql`count = ${0} OR count = ${42}`;
expect(fragment.sql).toBe('count = ? OR count = ?');
expect(fragment.getKnexBindings()).toEqual([0, 42]);
expect(fragment.getKnexBindings(getColumnForField)).toEqual([0, 42]);
});
it('handles boolean values', () => {
const fragment = sql`active = ${true} AND deleted = ${false}`;
expect(fragment.sql).toBe('active = ? AND deleted = ?');
expect(fragment.getKnexBindings()).toEqual([true, false]);
expect(fragment.getKnexBindings(getColumnForField)).toEqual([true, false]);
});
it('handles raw SQL', () => {
const columnName = 'created_at';
const fragment = sql`ORDER BY ${raw(columnName)} DESC`;
const fragment = sql`ORDER BY ${unsafeRaw(columnName)} DESC`;
expect(fragment.sql).toBe('ORDER BY created_at DESC');
expect(fragment.getKnexBindings()).toEqual([]);
expect(fragment.getKnexBindings(getColumnForField)).toEqual([]);
});
it('handles complex raw SQL expressions', () => {
const fragment = sql`WHERE ${raw('EXTRACT(year FROM created_at)')} = ${2024}`;
const fragment = sql`WHERE ${unsafeRaw('EXTRACT(year FROM created_at)')} = ${2024}`;
expect(fragment.sql).toBe('WHERE EXTRACT(year FROM created_at) = ?');
expect(fragment.getKnexBindings()).toEqual([2024]);
expect(fragment.getKnexBindings(getColumnForField)).toEqual([2024]);
});
it('combines raw SQL with regular parameters', () => {
const sortColumn = 'name';
const fragment = sql`SELECT * FROM users WHERE age > ${18} ORDER BY ${raw(sortColumn)} ${raw('DESC')}`;
const fragment = sql`SELECT * FROM users WHERE age > ${18} ORDER BY ${unsafeRaw(sortColumn)} ${unsafeRaw('DESC')}`;
expect(fragment.sql).toBe('SELECT * FROM users WHERE age > ? ORDER BY name DESC');
expect(fragment.getKnexBindings()).toEqual([18]);
expect(fragment.getKnexBindings(getColumnForField)).toEqual([18]);
});
});
@@ -105,52 +116,32 @@ describe('SQLOperator', () => {
const combined = fragment1.append(fragment2);
expect(combined.sql).toBe('age >= ? status = ?');
expect(combined.getKnexBindings()).toEqual([18, 'active']);
expect(combined.getKnexBindings(getColumnForField)).toEqual([18, 'active']);
});
});
describe(SQLFragment.join, () => {
it('joins fragments with custom separator', () => {
const fragments = [
new SQLFragment('name = ?', [{ type: 'value', value: 'Alice' }]),
new SQLFragment('age = ?', [{ type: 'value', value: 30 }]),
new SQLFragment('city = ?', [{ type: 'value', value: 'NYC' }]),
];
const joined = SQLFragment.join(fragments, ' AND ');
expect(joined.sql).toBe('name = ? AND age = ? AND city = ?');
expect(joined.getKnexBindings()).toEqual(['Alice', 30, 'NYC']);
});
describe(SQLFragment.joinWithCommaSeparator, () => {
it('handles empty array in join', () => {
const joined = SQLFragment.join([]);
const joined = SQLFragment.joinWithCommaSeparator();
expect(joined.sql).toBe('');
expect(joined.getKnexBindings()).toEqual([]);
expect(joined.getKnexBindings(getColumnForField)).toEqual([]);
});
it('joins SQL fragments with default separator', () => {
it('joins SQL fragments with comma', () => {
const columns = [sql`name`, sql`age`, sql`email`];
const joined = SQLFragment.join(columns);
const joined = SQLFragment.joinWithCommaSeparator(...columns);
expect(joined.sql).toBe('name, age, email');
expect(joined.getKnexBindings()).toEqual([]);
});
it('joins SQL fragments with custom separator', () => {
const conditions = [sql`age > ${18}`, sql`status = ${'active'}`, sql`verified = ${true}`];
const joined = SQLFragment.join(conditions, ' AND ');
expect(joined.sql).toBe('age > ? AND status = ? AND verified = ?');
expect(joined.getKnexBindings()).toEqual([18, 'active', true]);
expect(joined.getKnexBindings(getColumnForField)).toEqual([]);
});
it('handles single fragment', () => {
const single = [sql`name = ${'Alice'}`];
const joined = SQLFragment.join(single);
const joined = SQLFragment.joinWithCommaSeparator(...single);
expect(joined.sql).toBe('name = ?');
expect(joined.getKnexBindings()).toEqual(['Alice']);
expect(joined.getKnexBindings(getColumnForField)).toEqual(['Alice']);
});
});
@@ -163,7 +154,7 @@ describe('SQLOperator', () => {
const concatenated = SQLFragment.concat(select, where, orderBy);
expect(concatenated.sql).toBe('SELECT * FROM users WHERE age > ? ORDER BY name');
expect(concatenated.getKnexBindings()).toEqual([18]);
expect(concatenated.getKnexBindings(getColumnForField)).toEqual([18]);
});
it('handles single fragment in concat', () => {
@@ -171,27 +162,27 @@ describe('SQLOperator', () => {
const concatenated = SQLFragment.concat(fragment);
expect(concatenated.sql).toBe('SELECT * FROM users');
expect(concatenated.getKnexBindings()).toEqual([]);
expect(concatenated.getKnexBindings(getColumnForField)).toEqual([]);
});
it('handles empty concat', () => {
const concatenated = SQLFragment.concat();
expect(concatenated.sql).toBe('');
expect(concatenated.getKnexBindings()).toEqual([]);
expect(concatenated.getKnexBindings(getColumnForField)).toEqual([]);
});
it('supports dynamic query building with concat', () => {
// Build a query dynamically
const fragments: SQLFragment[] = [sql`SELECT * FROM products`];
const fragments: SQLFragment<Record<string, any>>[] = [sql`SELECT * FROM products`];
// Conditionally add WHERE clause
const filters: SQLFragment[] = [];
const filters: SQLFragment<Record<string, any>>[] = [];
filters.push(sql`price > ${100}`);
filters.push(sql`category = ${'electronics'}`);
if (filters.length > 0) {
fragments.push(sql`WHERE ${SQLFragment.join(filters, ' AND ')}`);
fragments.push(sql`WHERE ${SQLFragmentHelpers.and(...filters)}`);
}
// Add ORDER BY
@@ -203,13 +194,13 @@ describe('SQLOperator', () => {
const query = SQLFragment.concat(...fragments);
expect(query.sql).toBe(
'SELECT * FROM products WHERE price > ? AND category = ? ORDER BY created_at DESC LIMIT ?',
'SELECT * FROM products WHERE (price > ?) AND (category = ?) ORDER BY created_at DESC LIMIT ?',
);
expect(query.getKnexBindings()).toEqual([100, 'electronics', 10]);
expect(query.getKnexBindings(getColumnForField)).toEqual([100, 'electronics', 10]);
});
});
describe('toDebugString', () => {
describe('getDebugString', () => {
it('generates debug text with values inline', () => {
const fragment = new SQLFragment(
'SELECT * FROM users WHERE name = ? AND age > ? AND active = ? AND created_at > ?',
@@ -221,7 +212,7 @@ describe('SQLOperator', () => {
],
);
const text = fragment.toDebugString;
const text = fragment.getDebugString();
expect(text).toContain("'Alice'");
expect(text).toContain('18');
expect(text).toContain('TRUE');
@@ -235,13 +226,13 @@ describe('SQLOperator', () => {
{ type: 'value', value: { key: 'value' } },
]);
const text = fragment.toDebugString;
const text = fragment.getDebugString();
expect(text).toContain('NULL');
expect(text).toContain("O''Reilly"); // SQL escaped single quote
expect(text).toContain(`'{"key":"value"}'::jsonb`);
});
it('handles all SupportedSQLValue types in toDebugString', () => {
it('handles all SupportedSQLValue types in getDebugString', () => {
const fragment = new SQLFragment('INSERT INTO test VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)', [
{ type: 'value', value: 'string' },
{ type: 'value', value: 123 },
@@ -254,18 +245,18 @@ describe('SQLOperator', () => {
{ type: 'value', value: [1, 2, 3] },
]);
const text = fragment.toDebugString;
const text = fragment.getDebugString();
expect(text).toBe(
"INSERT INTO test VALUES ('string', 123, TRUE, NULL, NULL, '2024-01-01T00:00:00.000Z', '\\x68656c6c6f', 999, ARRAY[1, 2, 3])",
);
});
it('handles nested arrays in toDebugString', () => {
it('handles nested arrays in getDebugString', () => {
const fragment = new SQLFragment('SELECT * FROM test WHERE tags = ?', [
{ type: 'value', value: ['tag1', 'tag2', null] },
]);
const text = fragment.toDebugString;
const text = fragment.getDebugString();
expect(text).toBe("SELECT * FROM test WHERE tags = ARRAY['tag1', 'tag2', NULL]");
});
@@ -274,23 +265,23 @@ describe('SQLOperator', () => {
{ type: 'value', value: 'value1' },
]);
const text = fragment.toDebugString;
const text = fragment.getDebugString();
expect(text).toBe("SELECT * FROM test WHERE field1 = 'value1' AND field2 = ?");
});
it('handles non-SupportedSQLValue types gracefully', () => {
const fragment = new SQLFragment('SELECT * FROM test WHERE field = ? AND field2 = ?', [
{ type: 'value', value: new Error('wat') as any },
{ type: 'value', value: new Error('wat') },
{ type: 'value', value: Object.create(null) },
]);
const text = fragment.toDebugString;
const text = fragment.getDebugString();
expect(text).toBe(
`SELECT * FROM test WHERE field = UnsupportedSQLValue[Error: wat] AND field2 = '{}'::jsonb`,
);
});
it('handles identifiers in toDebugString', () => {
it('handles identifiers in getDebugString', () => {
const fragment = new SQLFragment('SELECT ?? FROM ?? WHERE ?? = ?', [
{ type: 'identifier', name: 'user_name' },
{ type: 'identifier', name: 'users' },
@@ -298,17 +289,29 @@ describe('SQLOperator', () => {
{ type: 'value', value: 'active' },
]);
const text = fragment.toDebugString;
const text = fragment.getDebugString();
expect(text).toBe('SELECT "user_name" FROM "users" WHERE "status" = \'active\'');
});
it('handles entity fields in getDebugString', () => {
const fragment = new SQLFragment('SELECT ?? FROM ?? WHERE ?? = ?', [
{ type: 'entityField', fieldName: 'string_field' },
{ type: 'identifier', name: 'test' },
{ type: 'entityField', fieldName: 'number_field' },
{ type: 'value', value: 42 },
]);
const text = fragment.getDebugString();
expect(text).toBe('SELECT "string_field" FROM "test" WHERE "number_field" = 42');
});
it('handles undefined bindings gracefully', () => {
const fragment = new SQLFragment('SELECT * FROM users WHERE id = ? AND name = ?', [
{ type: 'value', value: 1 },
undefined as any, // Simulate an edge case with undefined binding
]);
const text = fragment.toDebugString;
const text = fragment.getDebugString();
expect(text).toBe('SELECT * FROM users WHERE id = 1 AND name = ?');
});
@@ -319,7 +322,7 @@ describe('SQLOperator', () => {
{ type: 'value', value: 'test' },
]);
const text = fragment.toDebugString;
const text = fragment.getDebugString();
// When a binding is null, it leaves the placeholder unchanged and doesn't advance the index
expect(text).toBe('SELECT * FROM "users" WHERE ?? = ?');
});
@@ -330,7 +333,7 @@ describe('SQLOperator', () => {
{ type: 'value', value: 1 },
]);
const text = fragment.toDebugString;
const text = fragment.getDebugString();
// Mismatched binding type leaves the placeholder unchanged
expect(text).toBe('SELECT * FROM ?? WHERE id = 1');
});
@@ -341,7 +344,7 @@ describe('SQLOperator', () => {
{ type: 'value', value: 'active' },
]);
const text = fragment.toDebugString;
const text = fragment.getDebugString();
// Mismatched binding type leaves the placeholder unchanged
expect(text).toBe("SELECT * FROM users WHERE ? = 'active'");
});
@@ -383,7 +386,7 @@ describe('SQLOperator', () => {
const columnName = 'user"data';
const fragment = sql`SELECT ${identifier(columnName)} FROM users`;
expect(fragment.sql).toBe('SELECT ?? FROM users');
expect(fragment.getKnexBindings()).toEqual(['user"data']);
expect(fragment.getKnexBindings(getColumnForField)).toEqual(['user"data']);
});
it('delegates escaping to Knex for SQL injection prevention', () => {
@@ -391,258 +394,376 @@ describe('SQLOperator', () => {
const fragment = sql`SELECT * FROM ${identifier(maliciousName)}`;
// The identifier is passed as a binding to Knex which will escape it
expect(fragment.sql).toBe('SELECT * FROM ??');
expect(fragment.getKnexBindings()).toEqual(['id"; DELETE FROM users WHERE "1"="1']);
expect(fragment.getKnexBindings(getColumnForField)).toEqual([
'id"; DELETE FROM users WHERE "1"="1',
]);
});
});
describe(SQLEntityField, () => {
it('stores the entity field name', () => {
const field = entityField<TestFields>('stringField');
expect(field.fieldName).toBe('stringField');
});
it('uses ?? placeholder in SQL fragments', () => {
const fragment = sql`SELECT ${entityField<TestFields>('stringField')} FROM users`;
expect(fragment.sql).toBe('SELECT ?? FROM users');
expect(fragment.getKnexBindings(getColumnForField)).toEqual(['string_field']);
});
it('translates entity field name to database column name via getKnexBindings', () => {
const fragment = sql`WHERE ${entityField<TestFields>('intField')} = ${42}`;
expect(fragment.sql).toBe('WHERE ?? = ?');
expect(fragment.getKnexBindings(getColumnForField)).toEqual(['number_field', 42]);
});
it('translates the id field correctly', () => {
const fragment = sql`WHERE ${entityField<TestFields>('customIdField')} = ${'some-id'}`;
expect(fragment.sql).toBe('WHERE ?? = ?');
expect(fragment.getKnexBindings(getColumnForField)).toEqual(['custom_id', 'some-id']);
});
it('works alongside identifiers and values', () => {
const fragment = sql`SELECT ${identifier('table_name')}.${entityField<TestFields>('stringField')} WHERE ${entityField<TestFields>('intField')} > ${10}`;
expect(fragment.sql).toBe('SELECT ??.?? WHERE ?? > ?');
expect(fragment.getKnexBindings(getColumnForField)).toEqual([
'table_name',
'string_field',
'number_field',
10,
]);
});
it('works with multiple entity fields', () => {
const fragment = sql`SELECT ${entityField<TestFields>('stringField')}, ${entityField<TestFields>('intField')}, ${entityField<TestFields>('dateField')} FROM test`;
expect(fragment.sql).toBe('SELECT ??, ??, ?? FROM test');
expect(fragment.getKnexBindings(getColumnForField)).toEqual([
'string_field',
'number_field',
'date_field',
]);
});
it('works in nested SQL fragments', () => {
const inner = sql`${entityField<TestFields>('stringField')} = ${'hello'}`;
const outer = sql`SELECT * FROM test WHERE ${inner}`;
expect(outer.sql).toBe('SELECT * FROM test WHERE ?? = ?');
expect(outer.getKnexBindings(getColumnForField)).toEqual(['string_field', 'hello']);
});
});
describe('SQLFragmentHelpers', () => {
describe(SQLFragmentHelpers.inArray, () => {
it('generates IN clause with values', () => {
const fragment = SQLFragmentHelpers.inArray('status', ['active', 'pending']);
const fragment = SQLFragmentHelpers.inArray('stringField', ['active', 'pending']);
expect(fragment.sql).toBe('?? IN (?, ?)');
expect(fragment.getKnexBindings()).toEqual(['status', 'active', 'pending']);
expect(fragment.getKnexBindings(getColumnForField)).toEqual([
'string_field',
'active',
'pending',
]);
});
it('handles empty array', () => {
const fragment = SQLFragmentHelpers.inArray('status', []);
const fragment = SQLFragmentHelpers.inArray('stringField', []);
expect(fragment.sql).toBe('1 = 0'); // Always false
expect(fragment.getKnexBindings()).toEqual([]);
expect(fragment.getKnexBindings(getColumnForField)).toEqual([]);
});
});
describe(SQLFragmentHelpers.notInArray, () => {
it('generates NOT IN clause with values', () => {
const fragment = SQLFragmentHelpers.notInArray('status', ['deleted', 'archived']);
const fragment = SQLFragmentHelpers.notInArray('stringField', ['deleted', 'archived']);
expect(fragment.sql).toBe('?? NOT IN (?, ?)');
expect(fragment.getKnexBindings()).toEqual(['status', 'deleted', 'archived']);
expect(fragment.getKnexBindings(getColumnForField)).toEqual([
'string_field',
'deleted',
'archived',
]);
});
it('handles empty array', () => {
const fragment = SQLFragmentHelpers.notInArray('status', []);
const fragment = SQLFragmentHelpers.notInArray('stringField', []);
expect(fragment.sql).toBe('1 = 1'); // Always true
expect(fragment.getKnexBindings()).toEqual([]);
expect(fragment.getKnexBindings(getColumnForField)).toEqual([]);
});
});
describe(SQLFragmentHelpers.between, () => {
it('generates BETWEEN clause with numbers', () => {
const fragment = SQLFragmentHelpers.between('age', 18, 65);
const fragment = SQLFragmentHelpers.between('intField', 18, 65);
expect(fragment.sql).toBe('?? BETWEEN ? AND ?');
expect(fragment.getKnexBindings()).toEqual(['age', 18, 65]);
expect(fragment.getKnexBindings(getColumnForField)).toEqual(['number_field', 18, 65]);
});
it('generates BETWEEN clause with dates', () => {
const date1 = new Date('2024-01-01');
const date2 = new Date('2024-12-31');
const fragment = SQLFragmentHelpers.between('created_at', date1, date2);
const fragment = SQLFragmentHelpers.between('dateField', date1, date2);
expect(fragment.sql).toBe('?? BETWEEN ? AND ?');
expect(fragment.getKnexBindings()).toEqual(['created_at', date1, date2]);
expect(fragment.getKnexBindings(getColumnForField)).toEqual(['date_field', date1, date2]);
});
it('generates BETWEEN clause with strings', () => {
const fragment = SQLFragmentHelpers.between('name', 'A', 'Z');
const fragment = SQLFragmentHelpers.between('stringField', 'A', 'Z');
expect(fragment.sql).toBe('?? BETWEEN ? AND ?');
expect(fragment.getKnexBindings()).toEqual(['name', 'A', 'Z']);
expect(fragment.getKnexBindings(getColumnForField)).toEqual(['string_field', 'A', 'Z']);
});
});
describe(SQLFragmentHelpers.notBetween, () => {
it('generates NOT BETWEEN clause with numbers', () => {
const fragment = SQLFragmentHelpers.notBetween('age', 18, 65);
const fragment = SQLFragmentHelpers.notBetween('intField', 18, 65);
expect(fragment.sql).toBe('?? NOT BETWEEN ? AND ?');
expect(fragment.getKnexBindings()).toEqual(['age', 18, 65]);
expect(fragment.getKnexBindings(getColumnForField)).toEqual(['number_field', 18, 65]);
});
it('generates NOT BETWEEN clause with dates', () => {
const date1 = new Date('2024-01-01');
const date2 = new Date('2024-12-31');
const fragment = SQLFragmentHelpers.notBetween('created_at', date1, date2);
const fragment = SQLFragmentHelpers.notBetween('dateField', date1, date2);
expect(fragment.sql).toBe('?? NOT BETWEEN ? AND ?');
expect(fragment.getKnexBindings()).toEqual(['created_at', date1, date2]);
expect(fragment.getKnexBindings(getColumnForField)).toEqual(['date_field', date1, date2]);
});
});
describe(SQLFragmentHelpers.like, () => {
it('generates LIKE clause', () => {
const fragment = SQLFragmentHelpers.like('name', '%John%');
const fragment = SQLFragmentHelpers.like('stringField', '%John%');
expect(fragment.sql).toBe('?? LIKE ?');
expect(fragment.getKnexBindings()).toEqual(['name', '%John%']);
expect(fragment.getKnexBindings(getColumnForField)).toEqual(['string_field', '%John%']);
});
});
describe(SQLFragmentHelpers.notLike, () => {
it('generates NOT LIKE clause', () => {
const fragment = SQLFragmentHelpers.notLike('name', '%test%');
const fragment = SQLFragmentHelpers.notLike('stringField', '%test%');
expect(fragment.sql).toBe('?? NOT LIKE ?');
expect(fragment.getKnexBindings()).toEqual(['name', '%test%']);
expect(fragment.getKnexBindings(getColumnForField)).toEqual(['string_field', '%test%']);
});
});
describe(SQLFragmentHelpers.ilike, () => {
it('generates ILIKE clause for case-insensitive matching', () => {
const fragment = SQLFragmentHelpers.ilike('email', '%@example.com');
const fragment = SQLFragmentHelpers.ilike('testIndexedField', '%@example.com');
expect(fragment.sql).toBe('?? ILIKE ?');
expect(fragment.getKnexBindings()).toEqual(['email', '%@example.com']);
expect(fragment.getKnexBindings(getColumnForField)).toEqual([
'test_index',
'%@example.com',
]);
});
});
describe(SQLFragmentHelpers.notIlike, () => {
it('generates NOT ILIKE clause for case-insensitive non-matching', () => {
const fragment = SQLFragmentHelpers.notIlike('email', '%@spam.com');
const fragment = SQLFragmentHelpers.notIlike('testIndexedField', '%@spam.com');
expect(fragment.sql).toBe('?? NOT ILIKE ?');
expect(fragment.getKnexBindings()).toEqual(['email', '%@spam.com']);
expect(fragment.getKnexBindings(getColumnForField)).toEqual(['test_index', '%@spam.com']);
});
});
describe(SQLFragmentHelpers.isNull, () => {
it('generates IS NULL', () => {
const fragment = SQLFragmentHelpers.isNull('deleted_at');
const fragment = SQLFragmentHelpers.isNull('nullableField');
expect(fragment.sql).toBe('?? IS NULL');
expect(fragment.getKnexBindings()).toEqual(['deleted_at']);
expect(fragment.getKnexBindings(getColumnForField)).toEqual(['nullable_field']);
});
});
describe(SQLFragmentHelpers.isNotNull, () => {
it('generates IS NOT NULL', () => {
const fragment = SQLFragmentHelpers.isNotNull('email');
const fragment = SQLFragmentHelpers.isNotNull('testIndexedField');
expect(fragment.sql).toBe('?? IS NOT NULL');
expect(fragment.getKnexBindings()).toEqual(['email']);
expect(fragment.getKnexBindings(getColumnForField)).toEqual(['test_index']);
});
});
describe(SQLFragmentHelpers.eq, () => {
it('generates equality check', () => {
const fragment = SQLFragmentHelpers.eq('status', 'active');
const fragment = SQLFragmentHelpers.eq('stringField', 'active');
expect(fragment.sql).toBe('?? = ?');
expect(fragment.getKnexBindings()).toEqual(['status', 'active']);
expect(fragment.getKnexBindings(getColumnForField)).toEqual(['string_field', 'active']);
});
it('handles null in equality check', () => {
const fragment = SQLFragmentHelpers.eq('field', null);
const fragment = SQLFragmentHelpers.eq('nullableField', null);
expect(fragment.sql).toBe('?? IS NULL');
expect(fragment.getKnexBindings()).toEqual(['field']);
expect(fragment.getKnexBindings(getColumnForField)).toEqual(['nullable_field']);
});
it('handles undefined in equality check', () => {
const fragment = SQLFragmentHelpers.eq('field', undefined);
const fragment = SQLFragmentHelpers.eq('nullableField', undefined);
expect(fragment.sql).toBe('?? IS NULL');
expect(fragment.getKnexBindings()).toEqual(['field']);
expect(fragment.getKnexBindings(getColumnForField)).toEqual(['nullable_field']);
});
});
describe(SQLFragmentHelpers.neq, () => {
it('generates inequality check', () => {
const fragment = SQLFragmentHelpers.neq('status', 'deleted');
const fragment = SQLFragmentHelpers.neq('stringField', 'deleted');
expect(fragment.sql).toBe('?? != ?');
expect(fragment.getKnexBindings()).toEqual(['status', 'deleted']);
expect(fragment.getKnexBindings(getColumnForField)).toEqual(['string_field', 'deleted']);
});
it('handles null in inequality check', () => {
const fragment = SQLFragmentHelpers.neq('field', null);
const fragment = SQLFragmentHelpers.neq('nullableField', null);
expect(fragment.sql).toBe('?? IS NOT NULL');
expect(fragment.getKnexBindings()).toEqual(['field']);
expect(fragment.getKnexBindings(getColumnForField)).toEqual(['nullable_field']);
});
it('handles undefined in inequality check', () => {
const fragment = SQLFragmentHelpers.neq('field', undefined);
const fragment = SQLFragmentHelpers.neq('nullableField', undefined);
expect(fragment.sql).toBe('?? IS NOT NULL');
expect(fragment.getKnexBindings()).toEqual(['field']);
expect(fragment.getKnexBindings(getColumnForField)).toEqual(['nullable_field']);
});
});
describe(SQLFragmentHelpers.gt, () => {
it('generates greater than', () => {
const fragment = SQLFragmentHelpers.gt('age', 18);
const fragment = SQLFragmentHelpers.gt('intField', 18);
expect(fragment.sql).toBe('?? > ?');
expect(fragment.getKnexBindings()).toEqual(['age', 18]);
expect(fragment.getKnexBindings(getColumnForField)).toEqual(['number_field', 18]);
});
});
describe(SQLFragmentHelpers.gte, () => {
it('generates greater than or equal', () => {
const fragment = SQLFragmentHelpers.gte('age', 18);
const fragment = SQLFragmentHelpers.gte('intField', 18);
expect(fragment.sql).toBe('?? >= ?');
expect(fragment.getKnexBindings()).toEqual(['age', 18]);
expect(fragment.getKnexBindings(getColumnForField)).toEqual(['number_field', 18]);
});
});
describe(SQLFragmentHelpers.lt, () => {
it('generates less than', () => {
const fragment = SQLFragmentHelpers.lt('age', 65);
const fragment = SQLFragmentHelpers.lt('intField', 65);
expect(fragment.sql).toBe('?? < ?');
expect(fragment.getKnexBindings()).toEqual(['age', 65]);
expect(fragment.getKnexBindings(getColumnForField)).toEqual(['number_field', 65]);
});
});
describe(SQLFragmentHelpers.lte, () => {
it('generates less than or equal', () => {
const fragment = SQLFragmentHelpers.lte('age', 65);
const fragment = SQLFragmentHelpers.lte('intField', 65);
expect(fragment.sql).toBe('?? <= ?');
expect(fragment.getKnexBindings()).toEqual(['age', 65]);
expect(fragment.getKnexBindings(getColumnForField)).toEqual(['number_field', 65]);
});
});
describe(SQLFragmentHelpers.jsonContains, () => {
it('generates JSON contains', () => {
const fragment = SQLFragmentHelpers.jsonContains('metadata', { premium: true });
const fragment = SQLFragmentHelpers.jsonContains('stringField', { premium: true });
expect(fragment.sql).toBe('?? @> ?::jsonb');
expect(fragment.getKnexBindings()).toEqual(['metadata', '{"premium":true}']);
expect(fragment.getKnexBindings(getColumnForField)).toEqual([
'string_field',
'{"premium":true}',
]);
});
it('generates JSON contains for null and undefined values', () => {
const fragmentNull = SQLFragmentHelpers.jsonContains('stringField', null);
const fragmentUndefined = SQLFragmentHelpers.jsonContains('stringField', undefined);
expect(fragmentNull.sql).toBe('?? @> ?::jsonb');
expect(fragmentNull.getKnexBindings(getColumnForField)).toEqual(['string_field', 'null']);
expect(fragmentUndefined.sql).toBe('?? @> ?::jsonb');
expect(fragmentUndefined.getKnexBindings(getColumnForField)).toEqual([
'string_field',
undefined,
]);
});
it('throws when value is not JSON-serializable', () => {
expect(() => SQLFragmentHelpers.jsonContains('stringField', (() => {}) as any)).toThrow(
'jsonContains: value is not JSON-serializable',
);
});
});
describe(SQLFragmentHelpers.jsonContainedBy, () => {
it('generates JSON contained by', () => {
const fragment = SQLFragmentHelpers.jsonContainedBy('settings', {
const fragment = SQLFragmentHelpers.jsonContainedBy('stringField', {
theme: 'dark',
lang: 'en',
});
expect(fragment.sql).toBe('?? <@ ?::jsonb');
expect(fragment.getKnexBindings()).toEqual(['settings', '{"theme":"dark","lang":"en"}']);
expect(fragment.getKnexBindings(getColumnForField)).toEqual([
'string_field',
'{"theme":"dark","lang":"en"}',
]);
});
it('generates JSON contained by for null and undefined values', () => {
const fragmentNull = SQLFragmentHelpers.jsonContainedBy('stringField', null);
const fragmentUndefined = SQLFragmentHelpers.jsonContainedBy('stringField', undefined);
expect(fragmentNull.sql).toBe('?? <@ ?::jsonb');
expect(fragmentNull.getKnexBindings(getColumnForField)).toEqual(['string_field', 'null']);
expect(fragmentUndefined.sql).toBe('?? <@ ?::jsonb');
expect(fragmentUndefined.getKnexBindings(getColumnForField)).toEqual([
'string_field',
undefined,
]);
});
it('throws when value is not JSON-serializable', () => {
expect(() => SQLFragmentHelpers.jsonContainedBy('stringField', (() => {}) as any)).toThrow(
'jsonContainedBy: value is not JSON-serializable',
);
});
});
describe(SQLFragmentHelpers.jsonPath, () => {
it('generates JSON path access', () => {
const fragment = SQLFragmentHelpers.jsonPath('data', 'user');
const fragment = SQLFragmentHelpers.jsonPath('stringField', 'user');
expect(fragment.sql).toBe(`"data"->'user'`);
expect(fragment.getKnexBindings()).toEqual([]);
expect(fragment.sql).toBe(`??->?`);
expect(fragment.getKnexBindings(getColumnForField)).toEqual(['string_field', 'user']);
});
});
describe(SQLFragmentHelpers.jsonPathText, () => {
it('generates JSON path text access', () => {
const fragment = SQLFragmentHelpers.jsonPathText('data', 'email');
const fragment = SQLFragmentHelpers.jsonPathText('stringField', 'email');
expect(fragment.sql).toBe(`"data"->>'email'`);
expect(fragment.getKnexBindings()).toEqual([]);
expect(fragment.sql).toBe(`??->>?`);
expect(fragment.getKnexBindings(getColumnForField)).toEqual(['string_field', 'email']);
});
});
@@ -652,23 +773,23 @@ describe('SQLOperator', () => {
const cond2 = sql`status = ${'active'}`;
const fragment = SQLFragmentHelpers.and(cond1, cond2);
expect(fragment.sql).toBe('age >= ? AND status = ?');
expect(fragment.getKnexBindings()).toEqual([18, 'active']);
expect(fragment.sql).toBe('(age >= ?) AND (status = ?)');
expect(fragment.getKnexBindings(getColumnForField)).toEqual([18, 'active']);
});
it('handles single condition in AND', () => {
const cond = sql`age >= ${18}`;
const fragment = SQLFragmentHelpers.and(cond);
expect(fragment.sql).toBe('age >= ?');
expect(fragment.getKnexBindings()).toEqual([18]);
expect(fragment.sql).toBe('(age >= ?)');
expect(fragment.getKnexBindings(getColumnForField)).toEqual([18]);
});
it('handles empty conditions in AND', () => {
const fragment = SQLFragmentHelpers.and();
expect(fragment.sql).toBe('1 = 1');
expect(fragment.getKnexBindings()).toEqual([]);
expect(fragment.getKnexBindings(getColumnForField)).toEqual([]);
});
});
@@ -678,23 +799,23 @@ describe('SQLOperator', () => {
const cond2 = sql`status = ${'pending'}`;
const fragment = SQLFragmentHelpers.or(cond1, cond2);
expect(fragment.sql).toBe('status = ? OR status = ?');
expect(fragment.getKnexBindings()).toEqual(['active', 'pending']);
expect(fragment.sql).toBe('(status = ?) OR (status = ?)');
expect(fragment.getKnexBindings(getColumnForField)).toEqual(['active', 'pending']);
});
it('handles single condition in OR', () => {
const cond = sql`status = ${'active'}`;
const fragment = SQLFragmentHelpers.or(cond);
expect(fragment.sql).toBe('status = ?');
expect(fragment.getKnexBindings()).toEqual(['active']);
expect(fragment.sql).toBe('(status = ?)');
expect(fragment.getKnexBindings(getColumnForField)).toEqual(['active']);
});
it('handles empty conditions in OR', () => {
const fragment = SQLFragmentHelpers.or();
expect(fragment.sql).toBe('1 = 0');
expect(fragment.getKnexBindings()).toEqual([]);
expect(fragment.getKnexBindings(getColumnForField)).toEqual([]);
});
});
@@ -704,7 +825,7 @@ describe('SQLOperator', () => {
const fragment = SQLFragmentHelpers.not(cond);
expect(fragment.sql).toBe('NOT (status = ?)');
expect(fragment.getKnexBindings()).toEqual(['deleted']);
expect(fragment.getKnexBindings(getColumnForField)).toEqual(['deleted']);
});
});
@@ -714,32 +835,35 @@ describe('SQLOperator', () => {
const fragment = SQLFragmentHelpers.group(cond);
expect(fragment.sql).toBe('(age >= ? AND age <= ?)');
expect(fragment.getKnexBindings()).toEqual([18, 65]);
expect(fragment.getKnexBindings(getColumnForField)).toEqual([18, 65]);
});
});
describe('complex combinations', () => {
it('builds complex queries with multiple helpers', () => {
const { and, or, between, inArray, isNotNull, group } = SQLFragmentHelpers;
const fragment = and(
between('age', 18, 65),
group(or(inArray('status', ['active', 'premium']), sql`role = ${'admin'}`)),
isNotNull('email'),
const fragment = SQLFragmentHelpers.and(
SQLFragmentHelpers.between('intField', 18, 65),
SQLFragmentHelpers.group(
SQLFragmentHelpers.or(
SQLFragmentHelpers.inArray('stringField', ['active', 'premium']),
sql`role = ${'admin'}`,
),
),
SQLFragmentHelpers.isNotNull('testIndexedField'),
);
expect(fragment.sql).toBe(
'?? BETWEEN ? AND ? AND (?? IN (?, ?) OR role = ?) AND ?? IS NOT NULL',
'(?? BETWEEN ? AND ?) AND (((?? IN (?, ?)) OR (role = ?))) AND (?? IS NOT NULL)',
);
expect(fragment.getKnexBindings()).toEqual([
'age',
expect(fragment.getKnexBindings(getColumnForField)).toEqual([
'number_field',
18,
65,
'status',
'string_field',
'active',
'premium',
'admin',
'email',
'test_index',
]);
});
});
@@ -13,11 +13,12 @@ import { v7 as uuidv7 } from 'uuid';
import {
BasePostgresEntityDatabaseAdapter,
NullsOrdering,
OrderByOrdering,
TableFieldMultiValueEqualityCondition,
TableFieldSingleValueEqualityCondition,
TableOrderByClause,
TableQuerySelectionModifiers,
TableQuerySelectionModifiersWithOrderByFragment,
} from '../../BasePostgresEntityDatabaseAdapter';
import { SQLFragment } from '../../SQLOperator';
@@ -105,11 +106,8 @@ export class StubPostgresDatabaseAdapter<
return results[0] ?? null;
}
private static compareByOrderBys(
orderBys: {
columnName: string;
order: OrderByOrdering;
}[],
private static compareByOrderBys<TFields extends Record<string, any>>(
orderBys: TableOrderByClause<TFields>[],
objectA: { [key: string]: any },
objectB: { [key: string]: any },
): 0 | 1 | -1 {
@@ -118,19 +116,30 @@ export class StubPostgresDatabaseAdapter<
}
const currentOrderBy = orderBys[0]!;
if (!('columnName' in currentOrderBy)) {
throw new Error('SQL fragment order by not supported for StubDatabaseAdapter');
}
const aField = objectA[currentOrderBy.columnName];
const bField = objectB[currentOrderBy.columnName];
// Determine effective nulls ordering:
// - If explicitly set, use that
// - Otherwise use PostgreSQL defaults: NULLS LAST for ASC, NULLS FIRST for DESC
const nullsFirst =
currentOrderBy.nulls !== undefined
? currentOrderBy.nulls === NullsOrdering.FIRST
: currentOrderBy.order === OrderByOrdering.DESCENDING;
if (aField === null && bField === null) {
return this.compareByOrderBys(orderBys.slice(1), objectA, objectB);
} else if (aField === null) {
return nullsFirst ? -1 : 1;
} else if (bField === null) {
return nullsFirst ? 1 : -1;
}
switch (currentOrderBy.order) {
case OrderByOrdering.DESCENDING: {
// simulate NULLS FIRST for DESC
if (aField === null && bField === null) {
return 0;
} else if (aField === null) {
return -1;
} else if (bField === null) {
return 1;
}
return aField > bField
? -1
: aField < bField
@@ -138,15 +147,6 @@ export class StubPostgresDatabaseAdapter<
: this.compareByOrderBys(orderBys.slice(1), objectA, objectB);
}
case OrderByOrdering.ASCENDING: {
// simulate NULLS LAST for ASC
if (aField === null && bField === null) {
return 0;
} else if (bField === null) {
return -1;
} else if (aField === null) {
return 1;
}
return bField > aField
? -1
: bField < aField
@@ -161,7 +161,7 @@ export class StubPostgresDatabaseAdapter<
tableName: string,
tableFieldSingleValueEqualityOperands: TableFieldSingleValueEqualityCondition[],
tableFieldMultiValueEqualityOperands: TableFieldMultiValueEqualityCondition[],
querySelectionModifiers: TableQuerySelectionModifiers,
querySelectionModifiers: TableQuerySelectionModifiers<TFields>,
): Promise<object[]> {
let filteredObjects = this.getObjectCollectionForTable(tableName);
for (const { tableField, tableValue } of tableFieldSingleValueEqualityOperands) {
@@ -197,7 +197,7 @@ export class StubPostgresDatabaseAdapter<
_tableName: string,
_rawWhereClause: string,
_bindings: object | any[],
_querySelectionModifiers: TableQuerySelectionModifiers,
_querySelectionModifiers: TableQuerySelectionModifiers<TFields>,
): Promise<object[]> {
throw new Error('Raw WHERE clauses not supported for StubDatabaseAdapter');
}
@@ -205,8 +205,8 @@ export class StubPostgresDatabaseAdapter<
protected fetchManyBySQLFragmentInternalAsync(
_queryInterface: any,
_tableName: string,
_sqlFragment: SQLFragment,
_querySelectionModifiers: TableQuerySelectionModifiersWithOrderByFragment,
_sqlFragment: SQLFragment<TFields>,
_querySelectionModifiers: TableQuerySelectionModifiers<TFields>,
): Promise<object[]> {
throw new Error('SQL fragments not supported for StubDatabaseAdapter');
}
@@ -22,4 +22,5 @@ export * from './errors/wrapNativePostgresCallAsync';
export * from './internal/EntityKnexDataManager';
export * from './internal/getKnexDataManager';
export * from './internal/getKnexEntityLoaderFactory';
export * from './internal/utilityTypes';
export * from './internal/weakMaps';
@@ -12,23 +12,46 @@ import assert from 'assert';
import {
BasePostgresEntityDatabaseAdapter,
FieldEqualityCondition,
NullsOrdering,
OrderByOrdering,
PostgresOrderByClause,
PostgresQuerySelectionModifiers,
PostgresQuerySelectionModifiersWithOrderByFragment,
PostgresQuerySelectionModifiersWithOrderByRaw,
} from '../BasePostgresEntityDatabaseAdapter';
import { PaginationStrategy } from '../PaginationStrategy';
import { SQLFragment, SQLFragmentHelpers, identifier, raw, sql } from '../SQLOperator';
import { SQLFragment, SQLFragmentHelpers, identifier, unsafeRaw, sql } from '../SQLOperator';
import { DistributiveOmit, NonNullableKeys } from './utilityTypes';
interface DataManagerStandardSpecification<TFields extends Record<string, any>> {
strategy: PaginationStrategy.STANDARD;
orderBy: PostgresOrderByClause<TFields>[];
orderBy: readonly PostgresOrderByClause<TFields>[];
}
type DataManagerFieldNameConstructorFn<TFields extends Record<string, any>> = (
fieldName: keyof TFields,
) => SQLFragment<TFields>;
type DataManagerSearchFieldSQLFragmentFnSpecification<TFields extends Record<string, any>> = {
fieldConstructor: (
getFragmentForFieldName: DataManagerFieldNameConstructorFn<TFields>,
) => SQLFragment<TFields>;
};
function isDataManagerSearchFieldSQLFragmentFnSpecification<TFields extends Record<string, any>>(
obj:
| keyof TFields
| SQLFragment<TFields>
| DataManagerSearchFieldSQLFragmentFnSpecification<TFields>,
): obj is DataManagerSearchFieldSQLFragmentFnSpecification<TFields> {
return typeof obj === 'object' && obj !== null && 'fieldConstructor' in obj;
}
type DataManagerSearchFieldSpecification<TFields extends Record<string, any>> =
| NonNullableKeys<TFields>
| DataManagerSearchFieldSQLFragmentFnSpecification<TFields>;
interface DataManagerSearchSpecificationBase<TFields extends Record<string, any>> {
term: string;
fields: (keyof TFields)[];
fields: readonly DataManagerSearchFieldSpecification<TFields>[];
}
interface DataManagerILikeSearchSpecification<
@@ -42,7 +65,7 @@ interface DataManagerTrigramSearchSpecification<
> extends DataManagerSearchSpecificationBase<TFields> {
strategy: PaginationStrategy.TRIGRAM_SEARCH;
threshold: number;
extraOrderByFields?: (keyof TFields)[];
extraOrderByFields?: readonly DataManagerSearchFieldSpecification<TFields>[];
}
type DataManagerSearchSpecification<TFields extends Record<string, any>> =
@@ -54,7 +77,7 @@ type DataManagerPaginationSpecification<TFields extends Record<string, any>> =
| DataManagerSearchSpecification<TFields>;
interface BaseUnifiedPaginationArgs<TFields extends Record<string, any>> {
where?: SQLFragment;
where?: SQLFragment<TFields>;
pagination: DataManagerPaginationSpecification<TFields>;
}
@@ -62,14 +85,18 @@ interface ForwardUnifiedPaginationArgs<
TFields extends Record<string, any>,
> extends BaseUnifiedPaginationArgs<TFields> {
first: number;
last?: never;
before?: never;
after?: string;
}
interface BackwardUnifiedPaginationArgs<
TFields extends Record<string, any>,
> extends BaseUnifiedPaginationArgs<TFields> {
first?: never;
last: number;
before?: string;
after?: never;
}
type LoadPageArgs<TFields extends Record<string, any>> =
@@ -98,7 +125,7 @@ export interface PageInfo {
* Relay-style Connection type
*/
export interface Connection<TNode> {
edges: Edge<TNode>[];
edges: readonly Edge<TNode>[];
pageInfo: PageInfo;
}
@@ -110,15 +137,13 @@ enum PaginationDirection {
const CURSOR_ROW_TABLE_ALIAS = 'cursor_row';
interface PaginationProvider<TFields extends Record<string, any>, TIDField extends keyof TFields> {
whereClause: SQLFragment | undefined;
buildOrderBy: (direction: PaginationDirection) => {
clauses?: PostgresOrderByClause<TFields>[];
fragment?: SQLFragment | undefined;
};
whereClause: SQLFragment<TFields> | undefined;
buildOrderBy: (direction: PaginationDirection) => readonly PostgresOrderByClause<TFields>[];
buildCursorCondition: (
decodedCursorId: TFields[TIDField],
direction: PaginationDirection,
) => SQLFragment;
orderByClauses: readonly PostgresOrderByClause<TFields>[],
) => SQLFragment<TFields>;
}
/**
@@ -149,9 +174,11 @@ export class EntityKnexDataManager<
*/
async loadManyByFieldEqualityConjunctionAsync<N extends keyof TFields>(
queryContext: EntityQueryContext,
fieldEqualityOperands: FieldEqualityCondition<TFields, N>[],
fieldEqualityOperands: readonly FieldEqualityCondition<TFields, N>[],
querySelectionModifiers: PostgresQuerySelectionModifiers<TFields>,
): Promise<readonly Readonly<TFields>[]> {
EntityKnexDataManager.validateOrderByClauses(querySelectionModifiers.orderBy);
return await timeAndLogLoadEventAsync(
this.metricsAdapter,
EntityMetricsLoadType.LOAD_MANY_EQUALITY_CONJUNCTION,
@@ -178,9 +205,11 @@ export class EntityKnexDataManager<
async loadManyByRawWhereClauseAsync(
queryContext: EntityQueryContext,
rawWhereClause: string,
bindings: any[] | object,
querySelectionModifiers: PostgresQuerySelectionModifiersWithOrderByRaw<TFields>,
bindings: readonly any[] | object,
querySelectionModifiers: PostgresQuerySelectionModifiers<TFields>,
): Promise<readonly Readonly<TFields>[]> {
EntityKnexDataManager.validateOrderByClauses(querySelectionModifiers.orderBy);
return await timeAndLogLoadEventAsync(
this.metricsAdapter,
EntityMetricsLoadType.LOAD_MANY_RAW,
@@ -198,9 +227,11 @@ export class EntityKnexDataManager<
async loadManyBySQLFragmentAsync(
queryContext: EntityQueryContext,
sqlFragment: SQLFragment,
querySelectionModifiers: PostgresQuerySelectionModifiersWithOrderByFragment<TFields>,
sqlFragment: SQLFragment<TFields>,
querySelectionModifiers: PostgresQuerySelectionModifiers<TFields>,
): Promise<readonly Readonly<TFields>[]> {
EntityKnexDataManager.validateOrderByClauses(querySelectionModifiers.orderBy);
return await timeAndLogLoadEventAsync(
this.metricsAdapter,
EntityMetricsLoadType.LOAD_MANY_SQL,
@@ -218,6 +249,15 @@ export class EntityKnexDataManager<
/**
* Load a page of objects using cursor-based pagination with unified pagination specification.
*
* @remarks
*
* This method implements cursor-based pagination using the seek method for efficient pagination even on large datasets
* given appropriate indexes. Cursors are opaque and encode the necessary information to fetch the next page based on the
* specified pagination strategy (standard, ILIKE search, or trigram search). For this implementation in particular,
* the cursor encodes the ID of the last entity in the page to ensure correct pagination for all strategies, even in cases
* where multiple rows have the same value for all fields other than the ID. If the entity referenced by a cursor has been
* deleted, the load will return an empty page with `hasNextPage: false`.
*
* @param queryContext - query context in which to perform the load
* @param args - pagination arguments including pagination and first/after or last/before
* @returns connection with edges containing field objects and page info
@@ -230,18 +270,22 @@ export class EntityKnexDataManager<
if (pagination.strategy === PaginationStrategy.STANDARD) {
// Standard pagination
EntityKnexDataManager.validateOrderByClauses(pagination.orderBy);
EntityKnexDataManager.validateOrderByClausesHaveConsistentDirection(pagination.orderBy);
const idField = this.entityConfiguration.idField;
const augmentedOrderByClauses = this.augmentOrderByIfNecessary(pagination.orderBy, idField);
const fieldsToUseInPostgresTupleCursor = augmentedOrderByClauses.map(
(order) => order.fieldName,
);
const fieldsToUseInPostgresTupleCursor: readonly (keyof TFields | SQLFragment<TFields>)[] =
augmentedOrderByClauses.map((order) =>
'fieldName' in order ? order.fieldName : order.fieldFragment,
);
// Create strategy for regular pagination
const strategy: PaginationProvider<TFields, TIDField> = {
whereClause: where,
buildOrderBy: (direction) => {
// For backward pagination, we flip the ORDER BY direction to fetch records
buildOrderBy: (direction: PaginationDirection) => {
// For backward pagination, we flip the ORDER BY and NULLS direction to fetch records
// in reverse order. This allows us to use a simple "less than" cursor comparison
// instead of complex SQL. We'll reverse the results array later to restore
// the original requested order.
@@ -249,20 +293,30 @@ export class EntityKnexDataManager<
// 1. Flip to name DESC to get the last items first
// 2. Apply cursor with < comparison
// 3. Reverse the final array to present items in name ASC order
const clauses =
direction === PaginationDirection.FORWARD
? augmentedOrderByClauses
: augmentedOrderByClauses.map((clause) => ({
fieldName: clause.fieldName,
return direction === PaginationDirection.FORWARD
? augmentedOrderByClauses
: augmentedOrderByClauses.map(
(clause): PostgresOrderByClause<TFields> => ({
...clause,
order:
clause.order === OrderByOrdering.ASCENDING
? OrderByOrdering.DESCENDING
: OrderByOrdering.ASCENDING,
}));
return { clauses };
nulls: clause.nulls
? EntityKnexDataManager.flipNullsOrderingSpread(clause.nulls)
: undefined,
}),
);
},
buildCursorCondition: (decodedCursorId, _direction, orderByClauses) => {
// all clauses are guaranteed to have the same order due to validation, so we can just look at the first one for effective ordering
const effectiveOrdering = orderByClauses[0]?.order ?? OrderByOrdering.ASCENDING;
return this.buildCursorCondition(
decodedCursorId,
fieldsToUseInPostgresTupleCursor,
effectiveOrdering,
);
},
buildCursorCondition: (decodedCursorId, direction) =>
this.buildCursorCondition(decodedCursorId, fieldsToUseInPostgresTupleCursor, direction),
};
return await this.loadPageInternalAsync(queryContext, args, strategy);
@@ -274,12 +328,7 @@ export class EntityKnexDataManager<
assert(search.term.length > 0, 'Search term must be a non-empty string');
assert(search.fields.length > 0, 'Search fields must be a non-empty array');
const direction =
'first' in args ? PaginationDirection.FORWARD : PaginationDirection.BACKWARD;
const { searchWhere, searchOrderByFragment } = this.buildSearchConditionAndOrderBy(
search,
direction,
);
const { searchWhere, searchOrderByClauses } = this.buildSearchConditionAndOrderBy(search);
// Combine WHERE conditions: base where + search where
const whereClause =
@@ -295,8 +344,18 @@ export class EntityKnexDataManager<
// Create strategy for search pagination
const strategy: PaginationProvider<TFields, TIDField> = {
whereClause,
buildOrderBy: () => {
return { fragment: searchOrderByFragment };
buildOrderBy: (direction) => {
return direction === PaginationDirection.FORWARD
? searchOrderByClauses
: searchOrderByClauses.map(
(clause): DistributiveOmit<PostgresOrderByClause<TFields>, 'nulls'> => ({
...clause,
order:
clause.order === OrderByOrdering.ASCENDING
? OrderByOrdering.DESCENDING
: OrderByOrdering.ASCENDING,
}),
);
},
buildCursorCondition: (decodedCursorId, direction) =>
search.strategy === PaginationStrategy.TRIGRAM_SEARCH
@@ -304,7 +363,10 @@ export class EntityKnexDataManager<
: this.buildCursorCondition(
decodedCursorId,
fieldsToUseInPostgresTupleCursor,
direction,
// ILIKE search always orders ASC, so effective ordering matches direction
direction === PaginationDirection.FORWARD
? OrderByOrdering.ASCENDING
: OrderByOrdering.DESCENDING,
),
};
@@ -312,6 +374,10 @@ export class EntityKnexDataManager<
}
}
getCursorForEntityID(entityID: TFields[TIDField]): string {
return this.encodeOpaqueCursor(entityID);
}
/**
* Internal method for loading a page with cursor-based pagination.
* Shared logic for both regular and search pagination.
@@ -325,7 +391,7 @@ export class EntityKnexDataManager<
// Validate pagination arguments
const maxPageSize = this.databaseAdapter.paginationMaxPageSize;
const isForward = 'first' in args;
const isForward = 'first' in args && args.first !== undefined;
if (isForward) {
assert(
Number.isInteger(args.first) && args.first > 0,
@@ -357,21 +423,23 @@ export class EntityKnexDataManager<
// Decode cursor
const decodedExternalCursorEntityID = cursor ? this.decodeOpaqueCursor(cursor) : null;
// Get ordering from strategy
const orderByClauses = paginationProvider.buildOrderBy(direction);
// Build WHERE clause with cursor condition
const baseWhere = paginationProvider.whereClause;
const cursorCondition = decodedExternalCursorEntityID
? paginationProvider.buildCursorCondition(decodedExternalCursorEntityID, direction)
? paginationProvider.buildCursorCondition(
decodedExternalCursorEntityID,
direction,
orderByClauses,
)
: null;
const whereClause = this.combineWhereConditions(baseWhere, cursorCondition);
// Get ordering from strategy
const { clauses: orderByClauses, fragment: orderByFragment } =
paginationProvider.buildOrderBy(direction);
// Determine query modifiers
const queryModifiers: PostgresQuerySelectionModifiersWithOrderByFragment<TFields> = {
...(orderByFragment !== undefined && { orderByFragment }),
const queryModifiers: PostgresQuerySelectionModifiers<TFields> = {
...(orderByClauses !== undefined && { orderBy: orderByClauses }),
limit: limit + 1, // Fetch data with limit + 1 to check for more pages
};
@@ -414,9 +482,9 @@ export class EntityKnexDataManager<
}
private combineWhereConditions(
baseWhere: SQLFragment | undefined,
cursorCondition: SQLFragment | null,
): SQLFragment {
baseWhere: SQLFragment<TFields> | undefined,
cursorCondition: SQLFragment<TFields> | null,
): SQLFragment<TFields> {
const conditions = [baseWhere, cursorCondition].filter((it) => !!it);
if (conditions.length === 0) {
return sql`1 = 1`;
@@ -431,19 +499,63 @@ export class EntityKnexDataManager<
}
private augmentOrderByIfNecessary(
orderBy: PostgresOrderByClause<TFields>[] | undefined,
orderBy: readonly PostgresOrderByClause<TFields>[] | undefined,
idField: TIDField,
): PostgresOrderByClause<TFields>[] {
): readonly PostgresOrderByClause<TFields>[] {
const clauses = orderBy ?? [];
// Always ensure ID is included for stability and cursor correctness
const hasId = clauses.some((spec) => spec.fieldName === idField);
// Always ensure ID is included for stability and cursor correctness. Note that this may add a redundant order by
// if ID is already included as a fragment, but that is preferable to the risk of incorrect pagination behavior.
const hasId = clauses.some((spec) => 'fieldName' in spec && spec.fieldName === idField);
if (!hasId) {
return [...clauses, { fieldName: idField, order: OrderByOrdering.ASCENDING }];
const lastClauseOrder =
clauses.length > 0 ? clauses[clauses.length - 1]!.order : OrderByOrdering.ASCENDING;
return [...clauses, { fieldName: idField, order: lastClauseOrder }];
}
return clauses;
}
private static flipNullsOrderingSpread(
nulls: NullsOrdering | undefined,
): NullsOrdering | undefined {
return nulls === NullsOrdering.FIRST ? NullsOrdering.LAST : NullsOrdering.FIRST;
}
private static validateOrderByClauses<TFields extends Record<string, any>>(
orderBy: readonly PostgresOrderByClause<TFields>[] | undefined,
): void {
if (orderBy === undefined) {
return;
}
for (const clause of orderBy) {
if ('fieldFragment' in clause) {
const trimmedSql = clause.fieldFragment.sql.trimEnd();
assert(
!/\b(ASC|DESC)\s*$/i.test(trimmedSql),
'fieldFragment must not contain ASC or DESC at the end. Use the order property to specify ordering direction.',
);
}
}
}
/**
* Cursor-based pagination uses Postgres tuple comparison (e.g., (a, b) \> (x, y)) which
* applies a single comparison direction to all columns. Mixed ordering directions would
* produce incorrect pagination results.
*/
private static validateOrderByClausesHaveConsistentDirection<TFields extends Record<string, any>>(
orderBy: readonly PostgresOrderByClause<TFields>[] | undefined,
): void {
if (orderBy === undefined || orderBy.length <= 1) {
return;
}
const firstOrder = orderBy[0]!.order;
assert(
orderBy.every((clause) => clause.order === firstOrder),
'All orderBy clauses must have the same ordering direction. Mixed ordering directions are not supported with cursor-based pagination.',
);
}
private encodeOpaqueCursor(idField: TFields[TIDField]): string {
return Buffer.from(JSON.stringify({ id: idField })).toString('base64url');
}
@@ -469,15 +581,47 @@ export class EntityKnexDataManager<
return parsedCursor.id;
}
private resolveSearchFieldToSQLFragment(
field:
| keyof TFields
| SQLFragment<TFields>
| DataManagerSearchFieldSQLFragmentFnSpecification<TFields>,
tableAlias?: typeof CURSOR_ROW_TABLE_ALIAS,
): SQLFragment<TFields> {
if (field instanceof SQLFragment) {
return field;
}
if (isDataManagerSearchFieldSQLFragmentFnSpecification<TFields>(field)) {
return field.fieldConstructor((fieldName) => {
const dbField = getDatabaseFieldForEntityField(this.entityConfiguration, fieldName);
return tableAlias
? sql`${unsafeRaw(tableAlias)}.${identifier(dbField)}`
: sql`${identifier(dbField)}`;
});
}
const dbField = getDatabaseFieldForEntityField(this.entityConfiguration, field);
return tableAlias
? sql`${unsafeRaw(tableAlias)}.${identifier(dbField)}`
: sql`${identifier(dbField)}`;
}
private buildCursorCondition(
decodedExternalCursorEntityID: TFields[TIDField],
fieldsToUseInPostgresTupleCursor: readonly (keyof TFields)[],
direction: PaginationDirection,
): SQLFragment {
fieldsToUseInPostgresTupleCursor: readonly (
| keyof TFields
| SQLFragment<TFields>
| DataManagerSearchFieldSQLFragmentFnSpecification<TFields>
)[],
effectiveOrdering: OrderByOrdering,
): SQLFragment<TFields> {
// We build a tuple comparison for fieldsToUseInPostgresTupleCursor fields of the
// entity identified by the external cursor to ensure correct pagination behavior
// even in cases where multiple rows have the same value all fields other than id.
const operator = direction === PaginationDirection.FORWARD ? '>' : '<';
// If the cursor entity has been deleted, the subquery returns no rows and the
// comparison evaluates to NULL, filtering out all results (empty page).
const operator = effectiveOrdering === OrderByOrdering.ASCENDING ? '>' : '<';
const idField = getDatabaseFieldForEntityField(
this.entityConfiguration,
@@ -485,77 +629,73 @@ export class EntityKnexDataManager<
);
const tableName = this.entityConfiguration.tableName;
const postgresCursorFieldIdentifiers = fieldsToUseInPostgresTupleCursor.map((f) => {
const dbField = getDatabaseFieldForEntityField(this.entityConfiguration, f);
return sql`${identifier(dbField)}`;
});
const postgresCursorFieldIdentifiers = fieldsToUseInPostgresTupleCursor.map((f) =>
this.resolveSearchFieldToSQLFragment(f),
);
// Build left side of comparison (current row's computed values)
const leftSide = SQLFragment.join(postgresCursorFieldIdentifiers, ', ');
const leftSide = SQLFragment.joinWithCommaSeparator(...postgresCursorFieldIdentifiers);
// Build right side using subquery to get computed values for cursor entity
const postgresCursorRowFieldIdentifiers = fieldsToUseInPostgresTupleCursor.map((f) => {
const dbField = getDatabaseFieldForEntityField(this.entityConfiguration, f);
return sql`${raw(CURSOR_ROW_TABLE_ALIAS)}.${identifier(dbField)}`;
});
// Build right side using subquery to get computed values for cursor entity.
// For field names, qualify with the cursor row alias. For SQL fragments,
// use as-is since unqualified column names resolve to the only table in the subquery.
const postgresCursorRowFieldIdentifiers = fieldsToUseInPostgresTupleCursor.map((f) =>
this.resolveSearchFieldToSQLFragment(f, CURSOR_ROW_TABLE_ALIAS),
);
// Build SELECT fields for subquery
const rightSideSubquery = sql`
SELECT ${SQLFragment.join(postgresCursorRowFieldIdentifiers, ', ')}
FROM ${identifier(tableName)} AS ${raw(CURSOR_ROW_TABLE_ALIAS)}
WHERE ${raw(CURSOR_ROW_TABLE_ALIAS)}.${identifier(idField)} = ${decodedExternalCursorEntityID}
SELECT ${SQLFragment.joinWithCommaSeparator(...postgresCursorRowFieldIdentifiers)}
FROM ${identifier(tableName)} AS ${unsafeRaw(CURSOR_ROW_TABLE_ALIAS)}
WHERE ${unsafeRaw(CURSOR_ROW_TABLE_ALIAS)}.${identifier(idField)} = ${decodedExternalCursorEntityID}
`;
return sql`(${leftSide}) ${raw(operator)} (${rightSideSubquery})`;
return sql`(${leftSide}) ${unsafeRaw(operator)} (${rightSideSubquery})`;
}
private buildILikeConditions(
search: DataManagerSearchSpecification<TFields>,
tableAlias?: typeof CURSOR_ROW_TABLE_ALIAS,
): SQLFragment[] {
): readonly SQLFragment<TFields>[] {
return search.fields.map((field) => {
const dbField = getDatabaseFieldForEntityField(this.entityConfiguration, field);
const fieldIdentifier = tableAlias
? sql`${raw(tableAlias)}.${identifier(dbField)}`
: sql`${identifier(dbField)}`;
return sql`${fieldIdentifier} ILIKE ${'%' + EntityKnexDataManager.escapeILikePattern(search.term) + '%'}`;
const fieldFragment = this.resolveSearchFieldToSQLFragment(field, tableAlias);
return sql`${fieldFragment} ILIKE ${'%' + EntityKnexDataManager.escapeILikePattern(search.term) + '%'}`;
});
}
private buildTrigramSimilarityExpressions(
search: DataManagerSearchSpecification<TFields>,
tableAlias?: typeof CURSOR_ROW_TABLE_ALIAS,
): SQLFragment[] {
): readonly SQLFragment<TFields>[] {
return search.fields.map((field) => {
const dbField = getDatabaseFieldForEntityField(this.entityConfiguration, field);
const fieldIdentifier = tableAlias
? sql`${raw(tableAlias)}.${identifier(dbField)}`
: sql`${identifier(dbField)}`;
return sql`similarity(${fieldIdentifier}, ${search.term})`;
const fieldFragment = this.resolveSearchFieldToSQLFragment(field, tableAlias);
return sql`similarity(${fieldFragment}, ${search.term})`;
});
}
private buildTrigramExactMatchCaseExpression(
search: DataManagerSearchSpecification<TFields>,
tableAlias?: typeof CURSOR_ROW_TABLE_ALIAS,
): SQLFragment {
): SQLFragment<TFields> {
const ilikeConditions = this.buildILikeConditions(search, tableAlias);
return sql`CASE WHEN ${SQLFragment.join(ilikeConditions, ' OR ')} THEN 1 ELSE 0 END`;
return sql`CASE WHEN ${SQLFragmentHelpers.or(...ilikeConditions)} THEN 1 ELSE 0 END`;
}
private buildTrigramSimilarityGreatestExpression(
search: DataManagerSearchSpecification<TFields>,
tableAlias?: typeof CURSOR_ROW_TABLE_ALIAS,
): SQLFragment {
): SQLFragment<TFields> {
const similarityExprs = this.buildTrigramSimilarityExpressions(search, tableAlias);
return sql`GREATEST(${SQLFragment.join(similarityExprs, ', ')})`;
return sql`GREATEST(${SQLFragment.joinWithCommaSeparator(...similarityExprs)})`;
}
private buildTrigramCursorCondition(
search: DataManagerTrigramSearchSpecification<TFields>,
decodedExternalCursorEntityID: TFields[TIDField],
direction: PaginationDirection,
): SQLFragment {
// For TRIGRAM search, we compute the similarity values using a subquery, similar to normal cursor
): SQLFragment<TFields> {
// For TRIGRAM search, we compute the similarity values using a subquery, similar to normal cursor.
// If the cursor entity has been deleted, the subquery returns no rows and the
// comparison evaluates to NULL, filtering out all results (empty page).
const operator = direction === PaginationDirection.FORWARD ? '<' : '>';
const idField = getDatabaseFieldForEntityField(
this.entityConfiguration,
@@ -568,15 +708,14 @@ export class EntityKnexDataManager<
// Build extra order by fields
const extraOrderByFields = search.extraOrderByFields;
const extraFields =
extraOrderByFields?.map((f) => {
const dbField = getDatabaseFieldForEntityField(this.entityConfiguration, f);
return sql`${identifier(dbField)}`;
}) ?? [];
extraOrderByFields?.map((f) => this.resolveSearchFieldToSQLFragment(f)) ?? [];
// Build left side of comparison (current row's computed values)
const leftSide = SQLFragment.join(
[exactMatchExpr, similarityExpr, ...extraFields, sql`${identifier(idField)}`],
', ',
const leftSide = SQLFragment.joinWithCommaSeparator(
exactMatchExpr,
similarityExpr,
...extraFields,
sql`${identifier(idField)}`,
);
// Build right side using subquery to get computed values for cursor entity
@@ -592,50 +731,52 @@ export class EntityKnexDataManager<
);
const cursorExtraFields =
extraOrderByFields?.map((f) => {
const dbField = getDatabaseFieldForEntityField(this.entityConfiguration, f);
return sql`${raw(CURSOR_ROW_TABLE_ALIAS)}.${identifier(dbField)}`;
}) ?? [];
extraOrderByFields?.map((f) =>
this.resolveSearchFieldToSQLFragment(f, CURSOR_ROW_TABLE_ALIAS),
) ?? [];
// Build SELECT fields for subquery
const selectFields = [
const selectFields: SQLFragment<TFields>[] = [
cursorExactMatchExpr,
cursorSimilarityExpr,
...cursorExtraFields,
sql`${raw(CURSOR_ROW_TABLE_ALIAS)}.${identifier(idField)}`,
sql`${unsafeRaw(CURSOR_ROW_TABLE_ALIAS)}.${identifier(idField)}`,
];
const rightSideSubquery = sql`
SELECT ${SQLFragment.join(selectFields, ', ')}
FROM ${identifier(this.entityConfiguration.tableName)} AS ${raw(CURSOR_ROW_TABLE_ALIAS)}
WHERE ${raw(CURSOR_ROW_TABLE_ALIAS)}.${identifier(idField)} = ${decodedExternalCursorEntityID}
SELECT ${SQLFragment.joinWithCommaSeparator(...selectFields)}
FROM ${identifier(this.entityConfiguration.tableName)} AS ${unsafeRaw(CURSOR_ROW_TABLE_ALIAS)}
WHERE ${unsafeRaw(CURSOR_ROW_TABLE_ALIAS)}.${identifier(idField)} = ${decodedExternalCursorEntityID}
`;
return sql`(${leftSide}) ${raw(operator)} (${rightSideSubquery})`;
return sql`(${leftSide}) ${unsafeRaw(operator)} (${rightSideSubquery})`;
}
private buildSearchConditionAndOrderBy(
search: DataManagerSearchSpecification<TFields>,
direction: PaginationDirection,
): {
searchWhere: SQLFragment;
searchOrderByFragment: SQLFragment | undefined;
private buildSearchConditionAndOrderBy(search: DataManagerSearchSpecification<TFields>): {
searchWhere: SQLFragment<TFields>;
searchOrderByClauses: readonly DistributiveOmit<PostgresOrderByClause<TFields>, 'nulls'>[];
} {
switch (search.strategy) {
case PaginationStrategy.ILIKE_SEARCH: {
const conditions = this.buildILikeConditions(search);
// Order by search fields + ID to match cursor fields
const orderByFields = [...search.fields, this.entityConfiguration.idField].map((field) => {
const dbField = getDatabaseFieldForEntityField(this.entityConfiguration, field);
return sql`${identifier(dbField)} ${raw(
direction === PaginationDirection.FORWARD ? 'ASC' : 'DESC',
)}`;
});
const searchOrderByClauses: PostgresOrderByClause<TFields>[] = [
...search.fields.map(
(field): PostgresOrderByClause<TFields> => ({
fieldFragment: this.resolveSearchFieldToSQLFragment(field),
order: OrderByOrdering.ASCENDING,
}),
),
{
fieldName: this.entityConfiguration.idField,
order: OrderByOrdering.ASCENDING,
},
];
return {
searchWhere: conditions.length > 0 ? SQLFragment.join(conditions, ' OR ') : sql`1 = 0`,
searchOrderByFragment: SQLFragment.join(orderByFields, ', '),
searchWhere: conditions.length > 0 ? SQLFragmentHelpers.or(...conditions) : sql`1 = 0`,
searchOrderByClauses,
};
}
@@ -654,79 +795,37 @@ export class EntityKnexDataManager<
// Combine exact matches (ILIKE) with similarity
const allConditions = [...ilikeConditions, ...conditions];
// Build ORDER BY components
const exactMatchPriority = this.buildTrigramExactMatchPriority(ilikeConditions, direction);
const similarityRanking = this.buildTrigramSimilarityRanking(search, direction);
const tieBreakers = this.buildTrigramTieBreakers(search, direction);
// Build ORDER BY clauses for trigram search:
// 1. Exact matches first (ILIKE)
// 2. Then by similarity score
// 3. Then by extra fields and ID field for stability
const searchOrderByClauses: DistributiveOmit<PostgresOrderByClause<TFields>, 'nulls'>[] = [
{
fieldFragment: this.buildTrigramExactMatchCaseExpression(search),
order: OrderByOrdering.DESCENDING,
},
{
fieldFragment: this.buildTrigramSimilarityGreatestExpression(search),
order: OrderByOrdering.DESCENDING,
},
...(search.extraOrderByFields ?? []).map((field) => ({
fieldFragment: this.resolveSearchFieldToSQLFragment(field),
order: OrderByOrdering.DESCENDING,
})),
{
fieldName: this.entityConfiguration.idField,
order: OrderByOrdering.DESCENDING,
},
];
return {
searchWhere: SQLFragment.join(allConditions, ' OR '),
// For trigram search, order by relevance with direction-aware ordering
// 1. Exact matches first (ILIKE)
// 2. Then by similarity score
// 3. Then by extra fields and ID field for stability
searchOrderByFragment: SQLFragment.join(
[exactMatchPriority, similarityRanking, tieBreakers],
', ',
),
searchWhere: SQLFragmentHelpers.or(...allConditions),
searchOrderByClauses,
};
}
}
}
/**
* Builds the exact match priority component of the ORDER BY clause for trigram search.
* Exact matches (via ILIKE) are prioritized over similarity matches.
*/
private buildTrigramExactMatchPriority(
ilikeConditions: SQLFragment[],
direction: PaginationDirection,
): SQLFragment {
const sortOrder = direction === PaginationDirection.FORWARD ? 'DESC' : 'ASC';
const exactMatchCondition = SQLFragment.join(ilikeConditions, ' OR ');
return sql`CASE WHEN ${exactMatchCondition} THEN 1 ELSE 0 END ${raw(sortOrder)}`;
}
/**
* Builds the similarity score ranking component of the ORDER BY clause.
* Uses the highest similarity score across all search fields.
*/
private buildTrigramSimilarityRanking(
search: DataManagerSearchSpecification<TFields>,
direction: PaginationDirection,
): SQLFragment {
const sortOrder = direction === PaginationDirection.FORWARD ? 'DESC' : 'ASC';
const similarityGreatestExpr = this.buildTrigramSimilarityGreatestExpression(search);
return sql`${similarityGreatestExpr} ${raw(sortOrder)}`;
}
/**
* Builds the tie-breaker fields component of the ORDER BY clause.
* Includes extra order-by fields (if specified) and always includes the ID field for stability.
*/
private buildTrigramTieBreakers(
search: DataManagerTrigramSearchSpecification<TFields>,
direction: PaginationDirection,
): SQLFragment {
const idField = getDatabaseFieldForEntityField(
this.entityConfiguration,
this.entityConfiguration.idField,
);
const extraOrderByFields = search.extraOrderByFields?.map((field) =>
getDatabaseFieldForEntityField(this.entityConfiguration, field),
);
const sortOrder = direction === PaginationDirection.FORWARD ? 'DESC' : 'ASC';
const allTieBreakerFields = [...(extraOrderByFields ?? []), idField];
const tieBreakerClauses = allTieBreakerFields.map(
(field) => sql`${identifier(field)} ${raw(sortOrder)}`,
);
return SQLFragment.join(tieBreakerClauses, ', ');
}
private static escapeILikePattern(term: string): string {
return term.replace(/[%_\\]/g, '\\$&');
}
@@ -19,6 +19,7 @@ export function getKnexEntityLoaderFactory<
TFields extends Record<string, any>,
TIDField extends keyof NonNullable<Pick<TFields, TSelectedFields>>,
TViewerContext extends ViewerContext,
TViewerContext2 extends TViewerContext,
TEntity extends ReadonlyEntity<TFields, TIDField, TViewerContext, TSelectedFields>,
TPrivacyPolicy extends EntityPrivacyPolicy<
TFields,
@@ -37,7 +38,7 @@ export function getKnexEntityLoaderFactory<
TPrivacyPolicy,
TSelectedFields
>,
viewerContext: TViewerContext,
viewerContext: TViewerContext2,
): KnexEntityLoaderFactory<
TFields,
TIDField,
@@ -0,0 +1,11 @@
/* c8 ignore start - types only */
export type NonNullableKeys<T> = {
[K in keyof T]: T[K] extends NonNullable<T[K]> ? K : never;
}[keyof T];
export type PickNonNullable<T> = Pick<T, NonNullableKeys<T>>;
export type DistributiveOmit<T, K extends keyof any> = T extends any ? Omit<T, K> : never;
/* c8 ignore stop - types only */
@@ -20,6 +20,7 @@ export function knexLoader<
TMFields extends object,
TMIDField extends keyof NonNullable<Pick<TMFields, TMSelectedFields>>,
TMViewerContext extends ViewerContext,
TMViewerContext2 extends TMViewerContext,
TMEntity extends ReadonlyEntity<TMFields, TMIDField, TMViewerContext, TMSelectedFields>,
TMPrivacyPolicy extends EntityPrivacyPolicy<
TMFields,
@@ -38,7 +39,7 @@ export function knexLoader<
TMPrivacyPolicy,
TMSelectedFields
>,
viewerContext: TMViewerContext,
viewerContext: TMViewerContext2,
queryContext: EntityQueryContext = viewerContext
.getViewerScopedEntityCompanionForClass(entityClass)
.getQueryContextProvider()
@@ -69,6 +70,7 @@ export function knexLoaderWithAuthorizationResults<
TMFields extends object,
TMIDField extends keyof NonNullable<Pick<TMFields, TMSelectedFields>>,
TMViewerContext extends ViewerContext,
TMViewerContext2 extends TMViewerContext,
TMEntity extends ReadonlyEntity<TMFields, TMIDField, TMViewerContext, TMSelectedFields>,
TMPrivacyPolicy extends EntityPrivacyPolicy<
TMFields,
@@ -87,7 +89,7 @@ export function knexLoaderWithAuthorizationResults<
TMPrivacyPolicy,
TMSelectedFields
>,
viewerContext: TMViewerContext,
viewerContext: TMViewerContext2,
queryContext: EntityQueryContext = viewerContext
.getViewerScopedEntityCompanionForClass(entityClass)
.getQueryContextProvider()
+21
View File
@@ -3,6 +3,27 @@
All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
# [0.58.0](https://github.com/expo/entity/compare/v0.57.0...v0.58.0) (2026-03-02)
**Note:** Version bump only for package @expo/entity-example
# [0.57.0](https://github.com/expo/entity/compare/v0.56.0...v0.57.0) (2026-02-28)
**Note:** Version bump only for package @expo/entity-example
# [0.56.0](https://github.com/expo/entity/compare/v0.55.0...v0.56.0) (2026-02-28)
### Bug Fixes
- **deps:** update dependency @apollo/server to v5.4.0 [security] ([#420](https://github.com/expo/entity/issues/420)) ([cd81c5a](https://github.com/expo/entity/commit/cd81c5a5b6be43ef60248eeea4031a883dd1eae8))
- **deps:** update dependency @koa/bodyparser to v6.1.0 ([#405](https://github.com/expo/entity/issues/405)) ([2dda22d](https://github.com/expo/entity/commit/2dda22d761f87851e3fcccc8443597daf8ac42e8))
- **deps:** update dependency @koa/router to v15.3.0 ([#406](https://github.com/expo/entity/issues/406)) ([9e88cf6](https://github.com/expo/entity/commit/9e88cf6aab76902c65994d28de8dab73f1267021))
- **deps:** update dependency graphql to v16.13.0 ([#479](https://github.com/expo/entity/issues/479)) ([976e2c3](https://github.com/expo/entity/commit/976e2c33867da71a2920d050c4086e2af8f6d54d))
- **deps:** update dependency koa to v3.1.2 [security] ([#484](https://github.com/expo/entity/issues/484)) ([b258dad](https://github.com/expo/entity/commit/b258dad2ed69c473f04d57d26910eab2032d142c))
- eliminate some unnecessary any casts ([#448](https://github.com/expo/entity/issues/448)) ([cc78029](https://github.com/expo/entity/commit/cc78029117c14510b0934b5e0d19876a45570f8d))
- move EntityPrivacyUtils back into core package ([#429](https://github.com/expo/entity/issues/429)) ([56ec27d](https://github.com/expo/entity/commit/56ec27d5f201d83930a2cc05dfc380a054bff1e1))
- refactor install method to instead be free functions ([#441](https://github.com/expo/entity/issues/441)) ([51d632d](https://github.com/expo/entity/commit/51d632da1619dc039d5c41ce6533f63254ec4337)), closes [#410](https://github.com/expo/entity/issues/410)
# [0.55.0](https://github.com/expo/entity/compare/v0.54.0...v0.55.0) (2026-01-27)
### Bug Fixes
+4 -4
View File
@@ -2,7 +2,7 @@
"name": "@expo/entity-example",
"type": "module",
"private": true,
"version": "0.55.0",
"version": "0.58.0",
"description": "An example integration of the @expo/entity framework",
"scripts": {
"build": "tsc --build",
@@ -29,15 +29,15 @@
"@koa/bodyparser": "6.1.0",
"@koa/cors": "5.0.0",
"@koa/router": "15.3.0",
"graphql": "16.12.0",
"koa": "3.1.1"
"graphql": "16.13.0",
"koa": "3.1.2"
},
"devDependencies": {
"@jest/globals": "30.2.0",
"@types/koa": "3.0.1",
"@types/koa__cors": "5.0.1",
"@types/koa__router": "12.0.5",
"@types/supertest": "6.0.3",
"@types/supertest": "7.2.0",
"supertest": "7.2.2",
"typescript": "5.9.3"
}
+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,20 @@
All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
# [0.58.0](https://github.com/expo/entity/compare/v0.57.0...v0.58.0) (2026-03-02)
### Bug Fixes
- escape all matching portions of parts of cacher cache keys ([#486](https://github.com/expo/entity/issues/486)) ([21f2f6f](https://github.com/expo/entity/commit/21f2f6f461ee771e8b1c1b22dbaf9b275094c0db))
# [0.57.0](https://github.com/expo/entity/compare/v0.56.0...v0.57.0) (2026-02-28)
**Note:** Version bump only for package @expo/entity-full-integration-tests
# [0.56.0](https://github.com/expo/entity/compare/v0.55.0...v0.56.0) (2026-02-28)
**Note:** Version bump only for package @expo/entity-full-integration-tests
# [0.55.0](https://github.com/expo/entity/compare/v0.54.0...v0.55.0) (2026-01-27)
**Note:** Version bump only for package @expo/entity-full-integration-tests
@@ -1,7 +1,7 @@
{
"name": "@expo/entity-full-integration-tests",
"private": true,
"version": "0.55.0",
"version": "0.58.0",
"description": "Full redis and knex integration tests for the entity framework",
"scripts": {
"build": "tsc --build",
@@ -118,7 +118,7 @@ describe('Entity cache inconsistency', () => {
makeKeyFn(...parts: string[]): string {
const delimiter = ':';
const escapedParts = parts.map((part) =>
part.replace('\\', '\\\\').replace(delimiter, `\\${delimiter}`),
part.replaceAll('\\', '\\\\').replaceAll(delimiter, `\\${delimiter}`),
);
return escapedParts.join(delimiter);
},
@@ -181,7 +181,7 @@ describe('Entity cache inconsistency', () => {
openBarrier2!();
})(),
viewerContext.runInTransactionForDatabaseAdaptorFlavorAsync(
viewerContext.runInTransactionForDatabaseAdapterFlavorAsync(
'postgres',
async (queryContext) => {
await TestEntity.updater(entity1, queryContext)
@@ -118,7 +118,7 @@ describe('Lack of entity cache push safety with RedisCacheInvalidationStrategy.C
makeKeyFn(...parts: string[]): string {
const delimiter = ':';
const escapedParts = parts.map((part) =>
part.replace('\\', '\\\\').replace(delimiter, `\\${delimiter}`),
part.replaceAll('\\', '\\\\').replaceAll(delimiter, `\\${delimiter}`),
);
return escapedParts.join(delimiter);
},
@@ -210,7 +210,7 @@ describe('Entity cache push safety with RedisCacheInvalidationStrategy.SURROUNDI
makeKeyFn(...parts: string[]): string {
const delimiter = ':';
const escapedParts = parts.map((part) =>
part.replace('\\', '\\\\').replace(delimiter, `\\${delimiter}`),
part.replaceAll('\\', '\\\\').replaceAll(delimiter, `\\${delimiter}`),
);
return escapedParts.join(delimiter);
},
@@ -56,7 +56,7 @@ describe('EntityMutator.processEntityDeletionForInboundEdgesAsync', () => {
makeKeyFn(...parts: string[]): string {
const delimiter = ':';
const escapedParts = parts.map((part) =>
part.replace('\\', '\\\\').replace(delimiter, `\\${delimiter}`),
part.replaceAll('\\', '\\\\').replaceAll(delimiter, `\\${delimiter}`),
);
return escapedParts.join(delimiter);
},
@@ -107,7 +107,7 @@ describe('Entity integrity', () => {
makeKeyFn(...parts: string[]): string {
const delimiter = ':';
const escapedParts = parts.map((part) =>
part.replace('\\', '\\\\').replace(delimiter, `\\${delimiter}`),
part.replaceAll('\\', '\\\\').replaceAll(delimiter, `\\${delimiter}`),
);
return escapedParts.join(delimiter);
},
@@ -195,7 +195,7 @@ describe('EntityMutator.processEntityDeletionForInboundEdgesAsync', () => {
makeKeyFn(...parts: string[]): string {
const delimiter = ':';
const escapedParts = parts.map((part) =>
part.replace('\\', '\\\\').replace(delimiter, `\\${delimiter}`),
part.replaceAll('\\', '\\\\').replaceAll(delimiter, `\\${delimiter}`),
);
return escapedParts.join(delimiter);
},
@@ -3,6 +3,18 @@
All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
# [0.58.0](https://github.com/expo/entity/compare/v0.57.0...v0.58.0) (2026-03-02)
**Note:** Version bump only for package @expo/entity-ip-address-field
# [0.57.0](https://github.com/expo/entity/compare/v0.56.0...v0.57.0) (2026-02-28)
**Note:** Version bump only for package @expo/entity-ip-address-field
# [0.56.0](https://github.com/expo/entity/compare/v0.55.0...v0.56.0) (2026-02-28)
**Note:** Version bump only for package @expo/entity-ip-address-field
# [0.55.0](https://github.com/expo/entity/compare/v0.54.0...v0.55.0) (2026-01-27)
**Note:** Version bump only for package @expo/entity-ip-address-field
@@ -1,6 +1,6 @@
{
"name": "@expo/entity-ip-address-field",
"version": "0.55.0",
"version": "0.58.0",
"description": "IP address EntityField definitions for @expo/entity",
"files": [
"build",
@@ -3,6 +3,20 @@
All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
# [0.58.0](https://github.com/expo/entity/compare/v0.57.0...v0.58.0) (2026-03-02)
**Note:** Version bump only for package @expo/entity-secondary-cache-local-memory
# [0.57.0](https://github.com/expo/entity/compare/v0.56.0...v0.57.0) (2026-02-28)
**Note:** Version bump only for package @expo/entity-secondary-cache-local-memory
# [0.56.0](https://github.com/expo/entity/compare/v0.55.0...v0.56.0) (2026-02-28)
### Bug Fixes
- Reorganize constructionUtils to make non-public ([#454](https://github.com/expo/entity/issues/454)) ([d9377a2](https://github.com/expo/entity/commit/d9377a290b7e0c6803f9e83537eddb7204bc9320)), closes [#410](https://github.com/expo/entity/issues/410)
# [0.55.0](https://github.com/expo/entity/compare/v0.54.0...v0.55.0) (2026-01-27)
**Note:** Version bump only for package @expo/entity-secondary-cache-local-memory
@@ -1,6 +1,6 @@
{
"name": "@expo/entity-secondary-cache-local-memory",
"version": "0.55.0",
"version": "0.58.0",
"description": "Local memory secondary cache for @expo/entity",
"files": [
"build",
@@ -1,9 +1,11 @@
import {
AlwaysAllowPrivacyPolicyRule,
AuthorizationResultBasedEntityLoader,
Entity,
EntityCompanionDefinition,
EntityCompanionProvider,
EntityConfiguration,
EntityConstructionUtils,
EntityPrivacyPolicy,
EntitySecondaryCacheLoader,
IEntityMetricsAdapter,
@@ -161,6 +163,32 @@ class TestSecondaryLocalMemoryCacheLoader extends EntitySecondaryCacheLoader<
> {
public databaseLoadCount = 0;
constructor(
secondaryEntityCache: LocalMemorySecondaryEntityCache<
LocalMemoryTestEntityFields,
'id',
TestLoadParams
>,
constructionUtils: EntityConstructionUtils<
LocalMemoryTestEntityFields,
'id',
TestViewerContext,
LocalMemoryTestEntity,
LocalMemoryTestEntityPrivacyPolicy,
keyof LocalMemoryTestEntityFields
>,
private readonly entityLoader: AuthorizationResultBasedEntityLoader<
LocalMemoryTestEntityFields,
'id',
TestViewerContext,
LocalMemoryTestEntity,
LocalMemoryTestEntityPrivacyPolicy,
keyof LocalMemoryTestEntityFields
>,
) {
super(secondaryEntityCache, constructionUtils);
}
protected async fetchObjectsFromDatabaseAsync(
loadParamsArray: readonly Readonly<TestLoadParams>[],
): Promise<ReadonlyMap<Readonly<TestLoadParams>, Readonly<LocalMemoryTestEntityFields> | null>> {
@@ -193,6 +221,10 @@ describe(LocalMemorySecondaryEntityCache, () => {
localMemoryTestEntityConfiguration,
createTTLCache<LocalMemoryTestEntityFields>(),
),
EntitySecondaryCacheLoader.getConstructionUtilsForEntityClass(
LocalMemoryTestEntity,
viewerContext,
),
LocalMemoryTestEntity.loaderWithAuthorizationResults(viewerContext),
);
@@ -229,6 +261,10 @@ describe(LocalMemorySecondaryEntityCache, () => {
localMemoryTestEntityConfiguration,
createTTLCache<LocalMemoryTestEntityFields>(),
),
EntitySecondaryCacheLoader.getConstructionUtilsForEntityClass(
LocalMemoryTestEntity,
viewerContext,
),
LocalMemoryTestEntity.loaderWithAuthorizationResults(viewerContext),
);
@@ -3,6 +3,20 @@
All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
# [0.58.0](https://github.com/expo/entity/compare/v0.57.0...v0.58.0) (2026-03-02)
**Note:** Version bump only for package @expo/entity-secondary-cache-redis
# [0.57.0](https://github.com/expo/entity/compare/v0.56.0...v0.57.0) (2026-02-28)
**Note:** Version bump only for package @expo/entity-secondary-cache-redis
# [0.56.0](https://github.com/expo/entity/compare/v0.55.0...v0.56.0) (2026-02-28)
### Bug Fixes
- Reorganize constructionUtils to make non-public ([#454](https://github.com/expo/entity/issues/454)) ([d9377a2](https://github.com/expo/entity/commit/d9377a290b7e0c6803f9e83537eddb7204bc9320)), closes [#410](https://github.com/expo/entity/issues/410)
# [0.55.0](https://github.com/expo/entity/compare/v0.54.0...v0.55.0) (2026-01-27)
**Note:** Version bump only for package @expo/entity-secondary-cache-redis
@@ -1,6 +1,6 @@
{
"name": "@expo/entity-secondary-cache-redis",
"version": "0.55.0",
"version": "0.58.0",
"description": "Redis secondary cache for @expo/entity",
"files": [
"build",
@@ -32,7 +32,7 @@
},
"devDependencies": {
"@jest/globals": "30.2.0",
"ioredis": "5.9.3",
"ioredis": "5.10.0",
"nullthrows": "1.1.1",
"typescript": "5.9.3"
}
@@ -1,4 +1,10 @@
import { EntitySecondaryCacheLoader, mapMapAsync, ViewerContext } from '@expo/entity';
import {
AuthorizationResultBasedEntityLoader,
EntityConstructionUtils,
EntitySecondaryCacheLoader,
mapMapAsync,
ViewerContext,
} from '@expo/entity';
import {
GenericRedisCacheContext,
RedisCacheInvalidationStrategy,
@@ -33,6 +39,28 @@ class TestSecondaryRedisCacheLoader extends EntitySecondaryCacheLoader<
> {
public databaseLoadCount = 0;
constructor(
secondaryEntityCache: RedisSecondaryEntityCache<RedisTestEntityFields, 'id', TestLoadParams>,
constructionUtils: EntityConstructionUtils<
RedisTestEntityFields,
'id',
TestViewerContext,
RedisTestEntity,
RedisTestEntityPrivacyPolicy,
keyof RedisTestEntityFields
>,
private readonly entityLoader: AuthorizationResultBasedEntityLoader<
RedisTestEntityFields,
'id',
TestViewerContext,
RedisTestEntity,
RedisTestEntityPrivacyPolicy,
keyof RedisTestEntityFields
>,
) {
super(secondaryEntityCache, constructionUtils);
}
protected async fetchObjectsFromDatabaseAsync(
loadParamsArray: readonly Readonly<TestLoadParams>[],
): Promise<ReadonlyMap<Readonly<TestLoadParams>, Readonly<RedisTestEntityFields> | null>> {
@@ -93,6 +121,7 @@ describe(RedisSecondaryEntityCache, () => {
genericRedisCacheContext,
(loadParams) => `test-key-${loadParams.id}`,
),
EntitySecondaryCacheLoader.getConstructionUtilsForEntityClass(RedisTestEntity, viewerContext),
RedisTestEntity.loaderWithAuthorizationResults(viewerContext),
);
@@ -132,6 +161,7 @@ describe(RedisSecondaryEntityCache, () => {
genericRedisCacheContext,
(loadParams) => `test-key-${loadParams.id}`,
),
EntitySecondaryCacheLoader.getConstructionUtilsForEntityClass(RedisTestEntity, viewerContext),
RedisTestEntity.loaderWithAuthorizationResults(viewerContext),
);
@@ -3,6 +3,25 @@
All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
# [0.58.0](https://github.com/expo/entity/compare/v0.57.0...v0.58.0) (2026-03-02)
**Note:** Version bump only for package @expo/entity-testing-utils
# [0.57.0](https://github.com/expo/entity/compare/v0.56.0...v0.57.0) (2026-02-28)
**Note:** Version bump only for package @expo/entity-testing-utils
# [0.56.0](https://github.com/expo/entity/compare/v0.55.0...v0.56.0) (2026-02-28)
### Bug Fixes
- move EntityPrivacyUtils back into core package ([#429](https://github.com/expo/entity/issues/429)) ([56ec27d](https://github.com/expo/entity/commit/56ec27d5f201d83930a2cc05dfc380a054bff1e1))
- refactor install method to instead be free functions ([#441](https://github.com/expo/entity/issues/441)) ([51d632d](https://github.com/expo/entity/commit/51d632da1619dc039d5c41ce6533f63254ec4337)), closes [#410](https://github.com/expo/entity/issues/410)
### Features
- Add authorization action to EntityPrivacyPolicyRuleEvaluationContext ([#464](https://github.com/expo/entity/issues/464)) ([e71ae5f](https://github.com/expo/entity/commit/e71ae5fcbae95096e0d6a42a8c960896ab83ccfb))
# [0.55.0](https://github.com/expo/entity/compare/v0.54.0...v0.55.0) (2026-01-27)
**Note:** Version bump only for package @expo/entity-testing-utils
+3 -3
View File
@@ -1,6 +1,6 @@
{
"name": "@expo/entity-testing-utils",
"version": "0.55.0",
"version": "0.58.0",
"description": "A package containing utilities for testing applications that use Entity",
"files": [
"build",
@@ -36,8 +36,8 @@
},
"devDependencies": {
"@jest/globals": "30.2.0",
"@types/lodash": "4.17.23",
"@types/node": "24.10.13",
"@types/lodash": "4.17.24",
"@types/node": "24.10.15",
"typescript": "5.9.3"
}
}
@@ -1,10 +1,10 @@
import {
EntityPrivacyPolicyEvaluationContext,
EntityQueryContext,
ReadonlyEntity,
ViewerContext,
PrivacyPolicyRule,
RuleEvaluationResult,
EntityPrivacyPolicyRuleEvaluationContext,
} from '@expo/entity';
import { describe, expect, test } from '@jest/globals';
@@ -17,7 +17,7 @@ export interface Case<
> {
viewerContext: TViewerContext;
queryContext: EntityQueryContext;
evaluationContext: EntityPrivacyPolicyEvaluationContext<
evaluationContext: EntityPrivacyPolicyRuleEvaluationContext<
TFields,
TIDField,
TViewerContext,
@@ -1,9 +1,9 @@
import {
EntityPrivacyPolicyEvaluationContext,
EntityQueryContext,
ViewerContext,
AlwaysAllowPrivacyPolicyRule,
AlwaysDenyPrivacyPolicyRule,
EntityPrivacyPolicyRuleEvaluationContext,
} from '@expo/entity';
import { describe } from '@jest/globals';
import { anything, instance, mock } from 'ts-mockito';
@@ -20,7 +20,7 @@ describe(describePrivacyPolicyRuleWithAsyncTestCase, () => {
viewerContext: instance(mock(ViewerContext)),
queryContext: instance(mock(EntityQueryContext)),
evaluationContext:
instance(mock<EntityPrivacyPolicyEvaluationContext<any, any, any, any, any>>()),
instance(mock<EntityPrivacyPolicyRuleEvaluationContext<any, any, any, any, any>>()),
entity: anything(),
}),
],
@@ -35,7 +35,7 @@ describe(describePrivacyPolicyRuleWithAsyncTestCase, () => {
viewerContext: instance(mock(ViewerContext)),
queryContext: instance(mock(EntityQueryContext)),
evaluationContext:
instance(mock<EntityPrivacyPolicyEvaluationContext<any, any, any, any, any>>()),
instance(mock<EntityPrivacyPolicyRuleEvaluationContext<any, any, any, any, any>>()),
entity: anything(),
}),
],
+26
View File
@@ -3,6 +3,32 @@
All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
# [0.58.0](https://github.com/expo/entity/compare/v0.57.0...v0.58.0) (2026-03-02)
### Bug Fixes
- escape all matching portions of parts of cacher cache keys ([#486](https://github.com/expo/entity/issues/486)) ([21f2f6f](https://github.com/expo/entity/commit/21f2f6f461ee771e8b1c1b22dbaf9b275094c0db))
# [0.57.0](https://github.com/expo/entity/compare/v0.56.0...v0.57.0) (2026-02-28)
**Note:** Version bump only for package @expo/entity
# [0.56.0](https://github.com/expo/entity/compare/v0.55.0...v0.56.0) (2026-02-28)
### Bug Fixes
- eliminate some unnecessary any casts ([#448](https://github.com/expo/entity/issues/448)) ([cc78029](https://github.com/expo/entity/commit/cc78029117c14510b0934b5e0d19876a45570f8d))
- move EntityPrivacyUtils back into core package ([#429](https://github.com/expo/entity/issues/429)) ([56ec27d](https://github.com/expo/entity/commit/56ec27d5f201d83930a2cc05dfc380a054bff1e1))
- pass in augmentable classes to installation ([#437](https://github.com/expo/entity/issues/437)) ([f5c728b](https://github.com/expo/entity/commit/f5c728be565e01c0a8ace9ef51f6de8f49184dab)), closes [/github.com/expo/entity/pull/410#pullrequestreview-3765893898](https://github.com//github.com/expo/entity/pull/410/issues/pullrequestreview-3765893898)
- refactor install method to instead be free functions ([#441](https://github.com/expo/entity/issues/441)) ([51d632d](https://github.com/expo/entity/commit/51d632da1619dc039d5c41ce6533f63254ec4337)), closes [#410](https://github.com/expo/entity/issues/410)
- Reorganize constructionUtils to make non-public ([#454](https://github.com/expo/entity/issues/454)) ([d9377a2](https://github.com/expo/entity/commit/d9377a290b7e0c6803f9e83537eddb7204bc9320)), closes [#410](https://github.com/expo/entity/issues/410)
- Type idField in EntityConfiguration as TIDField ([#425](https://github.com/expo/entity/issues/425)) ([be40bcf](https://github.com/expo/entity/commit/be40bcffa6e7db9b7ad75e1a80bbb48ff417c6c5))
### Features
- Add authorization action to EntityPrivacyPolicyRuleEvaluationContext ([#464](https://github.com/expo/entity/issues/464)) ([e71ae5f](https://github.com/expo/entity/commit/e71ae5fcbae95096e0d6a42a8c960896ab83ccfb))
- Add paginated loader to entity-database-adapter-knex ([#422](https://github.com/expo/entity/issues/422)) ([58deffd](https://github.com/expo/entity/commit/58deffd426b0214073ede608cd47c04db2596d60))
# [0.55.0](https://github.com/expo/entity/compare/v0.54.0...v0.55.0) (2026-01-27)
### Features
+3 -3
View File
@@ -1,6 +1,6 @@
{
"name": "@expo/entity",
"version": "0.55.0",
"version": "0.58.0",
"description": "A privacy-first data model",
"files": [
"build",
@@ -34,8 +34,8 @@
"devDependencies": {
"@jest/globals": "30.2.0",
"@types/invariant": "2.2.37",
"@types/lodash": "4.17.23",
"@types/node": "24.10.13",
"@types/lodash": "4.17.24",
"@types/node": "24.10.15",
"lodash": "4.17.23",
"ts-mockito": "2.6.1",
"typescript": "5.9.3",
@@ -16,7 +16,7 @@ export class AuthorizationResultBasedEntityAssociationLoader<
TIDField extends keyof NonNullable<Pick<TFields, TSelectedFields>>,
TViewerContext extends ViewerContext,
TEntity extends ReadonlyEntity<TFields, TIDField, TViewerContext, TSelectedFields>,
TSelectedFields extends keyof TFields,
TSelectedFields extends keyof TFields = keyof TFields,
> {
constructor(
private readonly entity: TEntity,
@@ -123,10 +123,7 @@ export class AuthorizationResultBasedEntityAssociationLoader<
.getViewerScopedEntityCompanionForClass(associatedEntityClass)
.getLoaderFactory()
.forLoad(this.queryContext, { previousValue: null, cascadingDeleteCause: null });
return await loader.loadManyByFieldEqualingAsync(
associatedEntityFieldContainingThisID,
thisID as any,
);
return await loader.loadManyByFieldEqualingAsync(associatedEntityFieldContainingThisID, thisID);
}
/**
@@ -178,7 +175,7 @@ export class AuthorizationResultBasedEntityAssociationLoader<
.forLoad(this.queryContext, { previousValue: null, cascadingDeleteCause: null });
return await loader.loadByFieldEqualingAsync(
associatedEntityLookupByField,
associatedFieldValue as any,
associatedFieldValue,
);
}
@@ -232,7 +229,7 @@ export class AuthorizationResultBasedEntityAssociationLoader<
.forLoad(this.queryContext, { previousValue: null, cascadingDeleteCause: null });
return await loader.loadManyByFieldEqualingAsync(
associatedEntityLookupByField,
associatedFieldValue as any,
associatedFieldValue,
);
}
@@ -9,7 +9,6 @@ import {
EntityConfiguration,
} from './EntityConfiguration';
import { EntityConstructionUtils } from './EntityConstructionUtils';
import { EntityInvalidationUtils } from './EntityInvalidationUtils';
import { EntityPrivacyPolicy } from './EntityPrivacyPolicy';
import { EntityQueryContext } from './EntityQueryContext';
import { ReadonlyEntity } from './ReadonlyEntity';
@@ -19,7 +18,6 @@ import { CompositeFieldHolder, CompositeFieldValueHolder } from './internal/Comp
import { CompositeFieldValueMap } from './internal/CompositeFieldValueMap';
import { EntityDataManager } from './internal/EntityDataManager';
import { SingleFieldHolder, SingleFieldValueHolder } from './internal/SingleFieldHolder';
import { IEntityMetricsAdapter } from './metrics/IEntityMetricsAdapter';
import { mapKeys, mapMap } from './utils/collections/maps';
import { areSetsEqual } from './utils/collections/sets';
@@ -55,16 +53,7 @@ export class AuthorizationResultBasedEntityLoader<
TSelectedFields
>,
private readonly dataManager: EntityDataManager<TFields, TIDField>,
protected readonly metricsAdapter: IEntityMetricsAdapter,
public readonly invalidationUtils: EntityInvalidationUtils<
TFields,
TIDField,
TViewerContext,
TEntity,
TPrivacyPolicy,
TSelectedFields
>,
public readonly constructionUtils: EntityConstructionUtils<
private readonly constructionUtils: EntityConstructionUtils<
TFields,
TIDField,
TViewerContext,
@@ -333,8 +333,17 @@ export class AuthorizationResultBasedCreateMutator<
previousValue: null,
cascadingDeleteCause: null,
});
const invalidationUtils = this.entityLoaderFactory.invalidationUtils();
const constructionUtils = this.entityLoaderFactory.constructionUtils(
this.viewerContext,
queryContext,
{
previousValue: null,
cascadingDeleteCause: null,
},
);
const temporaryEntityForPrivacyCheck = entityLoader.constructionUtils.constructEntity({
const temporaryEntityForPrivacyCheck = constructionUtils.constructEntity({
[this.entityConfiguration.idField]: '00000000-0000-0000-0000-000000000000', // zero UUID
...this.fieldsForEntity,
} as unknown as TFields);
@@ -376,14 +385,13 @@ export class AuthorizationResultBasedCreateMutator<
// Invalidate all caches for the new entity so that any previously-negatively-cached loads
// are removed from the caches.
queryContext.appendPostCommitInvalidationCallback(async () => {
entityLoader.invalidationUtils.invalidateFieldsForTransaction(queryContext, insertResult);
await entityLoader.invalidationUtils.invalidateFieldsAsync(insertResult);
invalidationUtils.invalidateFieldsForTransaction(queryContext, insertResult);
await invalidationUtils.invalidateFieldsAsync(insertResult);
});
entityLoader.invalidationUtils.invalidateFieldsForTransaction(queryContext, insertResult);
invalidationUtils.invalidateFieldsForTransaction(queryContext, insertResult);
const unauthorizedEntityAfterInsert =
entityLoader.constructionUtils.constructEntity(insertResult);
const unauthorizedEntityAfterInsert = constructionUtils.constructEntity(insertResult);
const newEntity = await enforceAsyncResult(
entityLoader.loadByIDAsync(unauthorizedEntityAfterInsert.getID()),
);
@@ -542,10 +550,17 @@ export class AuthorizationResultBasedUpdateMutator<
previousValue: this.originalEntity,
cascadingDeleteCause: this.cascadingDeleteCause,
});
const entityAboutToBeUpdated = entityLoader.constructionUtils.constructEntity(
this.fieldsForEntity,
const invalidationUtils = this.entityLoaderFactory.invalidationUtils();
const constructionUtils = this.entityLoaderFactory.constructionUtils(
this.viewerContext,
queryContext,
{
previousValue: this.originalEntity,
cascadingDeleteCause: this.cascadingDeleteCause,
},
);
const entityAboutToBeUpdated = constructionUtils.constructEntity(this.fieldsForEntity);
const authorizeUpdateResult = await asyncResult(
this.privacyPolicy.authorizeUpdateAsync(
this.viewerContext,
@@ -609,30 +624,22 @@ export class AuthorizationResultBasedUpdateMutator<
// version of the entity.
queryContext.appendPostCommitInvalidationCallback(async () => {
entityLoader.invalidationUtils.invalidateFieldsForTransaction(
invalidationUtils.invalidateFieldsForTransaction(
queryContext,
this.originalEntity.getAllDatabaseFields(),
);
entityLoader.invalidationUtils.invalidateFieldsForTransaction(
queryContext,
this.fieldsForEntity,
);
invalidationUtils.invalidateFieldsForTransaction(queryContext, this.fieldsForEntity);
await Promise.all([
entityLoader.invalidationUtils.invalidateFieldsAsync(
this.originalEntity.getAllDatabaseFields(),
),
entityLoader.invalidationUtils.invalidateFieldsAsync(this.fieldsForEntity),
invalidationUtils.invalidateFieldsAsync(this.originalEntity.getAllDatabaseFields()),
invalidationUtils.invalidateFieldsAsync(this.fieldsForEntity),
]);
});
entityLoader.invalidationUtils.invalidateFieldsForTransaction(
invalidationUtils.invalidateFieldsForTransaction(
queryContext,
this.originalEntity.getAllDatabaseFields(),
);
entityLoader.invalidationUtils.invalidateFieldsForTransaction(
queryContext,
this.fieldsForEntity,
);
invalidationUtils.invalidateFieldsForTransaction(queryContext, this.fieldsForEntity);
const updatedEntity = await enforceAsyncResult(
entityLoader.loadByIDAsync(entityAboutToBeUpdated.getID()),
@@ -850,23 +857,18 @@ export class AuthorizationResultBasedDeleteMutator<
);
}
const entityLoader = this.entityLoaderFactory.forLoad(this.viewerContext, queryContext, {
previousValue: null,
cascadingDeleteCause: this.cascadingDeleteCause,
});
const invalidationUtils = this.entityLoaderFactory.invalidationUtils();
// Invalidate all caches for the entity so that any previously-cached loads
// are removed from the caches.
queryContext.appendPostCommitInvalidationCallback(async () => {
entityLoader.invalidationUtils.invalidateFieldsForTransaction(
invalidationUtils.invalidateFieldsForTransaction(
queryContext,
this.entity.getAllDatabaseFields(),
);
await entityLoader.invalidationUtils.invalidateFieldsAsync(
this.entity.getAllDatabaseFields(),
);
await invalidationUtils.invalidateFieldsAsync(this.entity.getAllDatabaseFields());
});
entityLoader.invalidationUtils.invalidateFieldsForTransaction(
invalidationUtils.invalidateFieldsForTransaction(
queryContext,
this.entity.getAllDatabaseFields(),
);
@@ -984,7 +986,7 @@ export class AuthorizationResultBasedDeleteMutator<
.loadManyByFieldEqualingAsync(
fieldName,
association.associatedEntityLookupByField
? entity.getField(association.associatedEntityLookupByField as any)
? entity.getField(association.associatedEntityLookupByField)
: entity.getID(),
),
);
@@ -6,7 +6,7 @@ import { ReadonlyEntity } from './ReadonlyEntity';
import { ViewerContext } from './ViewerContext';
/**
* Enforcing entity creator. All updates
* Enforcing entity creator. All creates
* through this creator will throw if authorization is not successful.
*/
export class EnforcingEntityCreator<
+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>,
@@ -208,13 +208,13 @@ export class EntityCompanionProvider {
});
}
getQueryContextProviderForDatabaseAdaptorFlavor(
getQueryContextProviderForDatabaseAdapterFlavor(
databaseAdapterFlavor: DatabaseAdapterFlavor,
): EntityQueryContextProvider {
const entityDatabaseAdapterFlavor = this.databaseAdapterFlavors.get(databaseAdapterFlavor);
invariant(
entityDatabaseAdapterFlavor,
`No database adaptor configuration found for flavor: ${databaseAdapterFlavor}`,
`No database adapter configuration found for flavor: ${databaseAdapterFlavor}`,
);
return entityDatabaseAdapterFlavor.queryContextProvider;
@@ -233,7 +233,7 @@ export class EntityCompanionProvider {
);
invariant(
entityDatabaseAdapterFlavor,
`No database adaptor configuration found for flavor: ${entityConfiguration.databaseAdapterFlavor}`,
`No database adapter configuration found for flavor: ${entityConfiguration.databaseAdapterFlavor}`,
);
const entityCacheAdapterFlavor = this.cacheAdapterFlavors.get(
@@ -241,7 +241,7 @@ export class EntityCompanionProvider {
);
invariant(
entityCacheAdapterFlavor,
`No cache adaptor configuration found for flavor: ${entityConfiguration.cacheAdapterFlavor}`,
`No cache adapter configuration found for flavor: ${entityConfiguration.cacheAdapterFlavor}`,
);
return new EntityTableDataCoordinator(
+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 });
}
}
+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,
);
@@ -90,12 +90,6 @@ describe(AuthorizationResultBasedEntityLoader, () => {
instance(mock<IEntityMetricsAdapter>()),
TestEntity.name,
);
const invalidationUtils = new EntityInvalidationUtils(
testEntityConfiguration,
TestEntity,
dataManager,
metricsAdapter,
);
const constructionUtils = new EntityConstructionUtils(
viewerContext,
queryContext,
@@ -111,8 +105,6 @@ describe(AuthorizationResultBasedEntityLoader, () => {
testEntityConfiguration,
TestEntity,
dataManager,
metricsAdapter,
invalidationUtils,
constructionUtils,
);
@@ -240,12 +232,6 @@ describe(AuthorizationResultBasedEntityLoader, () => {
instance(mock<IEntityMetricsAdapter>()),
TestEntity.name,
);
const invalidationUtils = new EntityInvalidationUtils(
testEntityConfiguration,
TestEntity,
dataManager,
metricsAdapter,
);
const constructionUtils = new EntityConstructionUtils(
viewerContext,
queryContext,
@@ -261,8 +247,6 @@ describe(AuthorizationResultBasedEntityLoader, () => {
testEntityConfiguration,
TestEntity,
dataManager,
metricsAdapter,
invalidationUtils,
constructionUtils,
);
@@ -385,12 +369,6 @@ describe(AuthorizationResultBasedEntityLoader, () => {
instance(mock<IEntityMetricsAdapter>()),
TestEntity.name,
);
const invalidationUtils = new EntityInvalidationUtils(
testEntityConfiguration,
TestEntity,
dataManager,
metricsAdapter,
);
const constructionUtils = new EntityConstructionUtils(
viewerContext,
queryContext,
@@ -406,8 +384,6 @@ describe(AuthorizationResultBasedEntityLoader, () => {
testEntityConfiguration,
TestEntity,
dataManager,
metricsAdapter,
invalidationUtils,
constructionUtils,
);
const entity = await enforceAsyncResult(entityLoader.loadByIDAsync(id1));
@@ -423,21 +399,7 @@ describe(AuthorizationResultBasedEntityLoader, () => {
});
it('invalidates upon invalidate one', async () => {
const viewerContext = instance(mock(ViewerContext));
const privacyPolicyEvaluationContext =
instance(
mock<
EntityPrivacyPolicyEvaluationContext<
TestFields,
'customIdField',
ViewerContext,
TestEntity
>
>(),
);
const metricsAdapter = instance(mock<IEntityMetricsAdapter>());
const queryContext = new StubQueryContextProvider().getQueryContext();
const privacyPolicy = instance(mock(TestEntityPrivacyPolicy));
const dataManagerMock = mock<EntityDataManager<TestFields, 'customIdField'>>();
const dataManagerInstance = instance(dataManagerMock);
@@ -448,29 +410,10 @@ describe(AuthorizationResultBasedEntityLoader, () => {
dataManagerInstance,
metricsAdapter,
);
const constructionUtils = new EntityConstructionUtils(
viewerContext,
queryContext,
privacyPolicyEvaluationContext,
testEntityConfiguration,
TestEntity,
/* entitySelectedFields */ undefined,
privacyPolicy,
metricsAdapter,
);
const entityLoader = new AuthorizationResultBasedEntityLoader(
queryContext,
testEntityConfiguration,
TestEntity,
dataManagerInstance,
metricsAdapter,
invalidationUtils,
constructionUtils,
);
const date = new Date();
await entityLoader.invalidationUtils.invalidateFieldsAsync({
await invalidationUtils.invalidateFieldsAsync({
customIdField: id1,
testIndexedField: 'h1',
intField: 5,
@@ -522,21 +465,7 @@ describe(AuthorizationResultBasedEntityLoader, () => {
});
it('invalidates upon invalidate by entity', async () => {
const viewerContext = instance(mock(ViewerContext));
const privacyPolicyEvaluationContext =
instance(
mock<
EntityPrivacyPolicyEvaluationContext<
TestFields,
'customIdField',
ViewerContext,
TestEntity
>
>(),
);
const metricsAdapter = instance(mock<IEntityMetricsAdapter>());
const queryContext = new StubQueryContextProvider().getQueryContext();
const privacyPolicy = instance(mock(TestEntityPrivacyPolicy));
const dataManagerMock = mock<EntityDataManager<TestFields, 'customIdField'>>();
const dataManagerInstance = instance(dataManagerMock);
@@ -560,26 +489,8 @@ describe(AuthorizationResultBasedEntityLoader, () => {
dataManagerInstance,
metricsAdapter,
);
const constructionUtils = new EntityConstructionUtils(
viewerContext,
queryContext,
privacyPolicyEvaluationContext,
testEntityConfiguration,
TestEntity,
/* entitySelectedFields */ undefined,
privacyPolicy,
metricsAdapter,
);
const entityLoader = new AuthorizationResultBasedEntityLoader(
queryContext,
testEntityConfiguration,
TestEntity,
dataManagerInstance,
metricsAdapter,
invalidationUtils,
constructionUtils,
);
await entityLoader.invalidationUtils.invalidateEntityAsync(entityInstance);
await invalidationUtils.invalidateEntityAsync(entityInstance);
verify(dataManagerMock.invalidateKeyValuePairsAsync(anything())).once();
verify(
@@ -624,21 +535,8 @@ describe(AuthorizationResultBasedEntityLoader, () => {
});
it('invalidates upon invalidate by entity within transaction', async () => {
const viewerContext = instance(mock(ViewerContext));
const privacyPolicyEvaluationContext =
instance(
mock<
EntityPrivacyPolicyEvaluationContext<
TestFields,
'customIdField',
ViewerContext,
TestEntity
>
>(),
);
const metricsAdapter = instance(mock<IEntityMetricsAdapter>());
const privacyPolicy = instance(mock(TestEntityPrivacyPolicy));
const dataManagerMock = mock<EntityDataManager<TestFields, 'customIdField'>>();
const dataManagerInstance = instance(dataManagerMock);
@@ -663,26 +561,7 @@ describe(AuthorizationResultBasedEntityLoader, () => {
dataManagerInstance,
metricsAdapter,
);
const constructionUtils = new EntityConstructionUtils(
viewerContext,
queryContext,
privacyPolicyEvaluationContext,
testEntityConfiguration,
TestEntity,
/* entitySelectedFields */ undefined,
privacyPolicy,
metricsAdapter,
);
const entityLoader = new AuthorizationResultBasedEntityLoader(
queryContext,
testEntityConfiguration,
TestEntity,
dataManagerInstance,
metricsAdapter,
invalidationUtils,
constructionUtils,
);
entityLoader.invalidationUtils.invalidateEntityForTransaction(queryContext, entityInstance);
invalidationUtils.invalidateEntityForTransaction(queryContext, entityInstance);
verify(
dataManagerMock.invalidateKeyValuePairsForTransaction(queryContext, anything()),
@@ -781,12 +660,6 @@ describe(AuthorizationResultBasedEntityLoader, () => {
const privacyPolicy = instance(privacyPolicyMock);
const dataManagerInstance = instance(dataManagerMock);
const invalidationUtils = new EntityInvalidationUtils(
testEntityConfiguration,
TestEntity,
dataManagerInstance,
metricsAdapter,
);
const constructionUtils = new EntityConstructionUtils(
viewerContext,
queryContext,
@@ -802,8 +675,6 @@ describe(AuthorizationResultBasedEntityLoader, () => {
testEntityConfiguration,
TestEntity,
dataManagerInstance,
metricsAdapter,
invalidationUtils,
constructionUtils,
);
@@ -839,12 +710,6 @@ describe(AuthorizationResultBasedEntityLoader, () => {
const dataManagerInstance = instance(dataManagerMock);
const invalidationUtils = new EntityInvalidationUtils(
testEntityConfiguration,
TestEntity,
dataManagerInstance,
metricsAdapter,
);
const constructionUtils = new EntityConstructionUtils(
viewerContext,
queryContext,
@@ -860,8 +725,6 @@ describe(AuthorizationResultBasedEntityLoader, () => {
testEntityConfiguration,
TestEntity,
dataManagerInstance,
metricsAdapter,
invalidationUtils,
constructionUtils,
);
@@ -115,7 +115,7 @@ class TestLocalCacheAdapter<
cacheKeyType,
`${this.entityConfiguration.cacheKeyVersion}`,
...parts,
].map((part) => part.replace('\\', '\\\\').replace(delimiter, `\\${delimiter}`));
].map((part) => part.replaceAll('\\', '\\\\').replaceAll(delimiter, `\\${delimiter}`));
return escapedParts.join(delimiter);
}
}
@@ -289,7 +289,7 @@ describe(ComposedEntityCacheAdapter, () => {
const { cacheAdapter } = makeTestCacheAdapters();
const results = await cacheAdapter.loadManyAsync(
new SingleFieldHolder<BlahFields, 'id', 'id'>('id'),
[] as any,
[] as readonly SingleFieldValueHolder<BlahFields, 'id'>[],
);
expect(results.size).toBe(0);
});
@@ -298,7 +298,7 @@ describe(ComposedEntityCacheAdapter, () => {
const cacheAdapter = new ComposedEntityCacheAdapter<BlahFields, 'id'>([]);
const results = await cacheAdapter.loadManyAsync(
new SingleFieldHolder<BlahFields, 'id', 'id'>('id'),
[] as any,
[] as readonly SingleFieldValueHolder<BlahFields, 'id'>[],
);
expect(results.size).toBe(0);
});
@@ -430,7 +430,7 @@ describe(ComposedEntityCacheAdapter, () => {
await cacheAdapter.invalidateManyAsync(
new SingleFieldHolder<BlahFields, 'id', 'id'>('id'),
[] as any,
[] as readonly SingleFieldValueHolder<BlahFields, 'id'>[],
);
});
});
@@ -175,7 +175,7 @@ describe(EntityConfiguration, () => {
])('disallows %p as field key', (keyName) => {
expect(
() =>
new EntityConfiguration<any, 'id'>({
new EntityConfiguration<{ id: string }, 'id'>({
idField: 'id',
tableName: 'blah_table',
schema: {
@@ -186,7 +186,7 @@ describe(EntityConfiguration, () => {
[keyName]: new StringField({
columnName: 'any',
}),
} as any,
},
databaseAdapterFlavor: 'postgres',
cacheAdapterFlavor: 'redis',
}),
@@ -228,14 +228,14 @@ describe(EntityDatabaseAdapter, () => {
it('transforms object', async () => {
const queryContext = instance(mock(EntityQueryContext));
const adapter = new TestEntityDatabaseAdapter({ insertResults: [{ string_field: 'hello' }] });
const result = await adapter.insertAsync(queryContext, {} as any);
const result = await adapter.insertAsync(queryContext, {});
expect(result).toEqual({ stringField: 'hello' });
});
it('throws when insert result count zero', async () => {
const queryContext = instance(mock(EntityQueryContext));
const adapter = new TestEntityDatabaseAdapter({ insertResults: [] });
await expect(adapter.insertAsync(queryContext, {} as any)).rejects.toThrow(
await expect(adapter.insertAsync(queryContext, {})).rejects.toThrow(
EntityDatabaseAdapterEmptyInsertResultError,
);
});
@@ -245,7 +245,7 @@ describe(EntityDatabaseAdapter, () => {
const adapter = new TestEntityDatabaseAdapter({
insertResults: [{ string_field: 'hello' }, { string_field: 'hello2' }],
});
await expect(adapter.insertAsync(queryContext, {} as any)).rejects.toThrow(
await expect(adapter.insertAsync(queryContext, {})).rejects.toThrow(
EntityDatabaseAdapterExcessiveInsertResultError,
);
});
@@ -255,16 +255,16 @@ describe(EntityDatabaseAdapter, () => {
it('transforms object', async () => {
const queryContext = instance(mock(EntityQueryContext));
const adapter = new TestEntityDatabaseAdapter({ updateResults: [{ string_field: 'hello' }] });
const result = await adapter.updateAsync(queryContext, 'customIdField', 'wat', {} as any);
const result = await adapter.updateAsync(queryContext, 'customIdField', 'wat', {});
expect(result).toEqual({ stringField: 'hello' });
});
it('throws when update result count zero', async () => {
const queryContext = instance(mock(EntityQueryContext));
const adapter = new TestEntityDatabaseAdapter({ updateResults: [] });
await expect(
adapter.updateAsync(queryContext, 'customIdField', 'wat', {} as any),
).rejects.toThrow(EntityDatabaseAdapterEmptyUpdateResultError);
await expect(adapter.updateAsync(queryContext, 'customIdField', 'wat', {})).rejects.toThrow(
EntityDatabaseAdapterEmptyUpdateResultError,
);
});
it('throws when update result count greater than 1', async () => {
@@ -272,9 +272,9 @@ describe(EntityDatabaseAdapter, () => {
const adapter = new TestEntityDatabaseAdapter({
updateResults: [{ string_field: 'hello' }, { string_field: 'hello2' }],
});
await expect(
adapter.updateAsync(queryContext, 'customIdField', 'wat', {} as any),
).rejects.toThrow(EntityDatabaseAdapterExcessiveUpdateResultError);
await expect(adapter.updateAsync(queryContext, 'customIdField', 'wat', {})).rejects.toThrow(
EntityDatabaseAdapterExcessiveUpdateResultError,
);
});
});
@@ -2,7 +2,6 @@ import { describe, expect, it } from '@jest/globals';
import { AuthorizationResultBasedEntityLoader } from '../AuthorizationResultBasedEntityLoader';
import { EnforcingEntityLoader } from '../EnforcingEntityLoader';
import { EntityConstructionUtils } from '../EntityConstructionUtils';
import { EntityInvalidationUtils } from '../EntityInvalidationUtils';
import { EntityLoader } from '../EntityLoader';
import { ViewerContext } from '../ViewerContext';
@@ -37,14 +36,4 @@ describe(EntityLoader, () => {
);
});
});
describe('constructionUtils', () => {
it('returns a instance of EntityConstructionUtils', async () => {
const companionProvider = createUnitTestEntityCompanionProvider();
const viewerContext = new ViewerContext(companionProvider);
expect(SimpleTestEntity.constructionUtils(viewerContext)).toBeInstanceOf(
EntityConstructionUtils,
);
});
});
});
@@ -1511,7 +1511,6 @@ describe(EntityMutatorFactory, () => {
>
>(EntityConstructionUtils);
when(entityConstructionUtilsMock.constructEntity(anything())).thenReturn(fakeEntity);
when(entityLoaderMock.constructionUtils).thenReturn(instance(entityConstructionUtilsMock));
const entityLoader = instance(entityLoaderMock);
const entityLoaderFactoryMock =
@@ -1532,6 +1531,13 @@ describe(EntityMutatorFactory, () => {
anything(),
),
).thenReturn(entityLoader);
when(
entityLoaderFactoryMock.constructionUtils(
viewerContext,
anyOfClass(EntityTransactionalQueryContext),
anything(),
),
).thenReturn(instance(entityConstructionUtilsMock));
const entityLoaderFactory = instance(entityLoaderFactoryMock);
const rejectionError = new Error();
@@ -1646,7 +1652,6 @@ describe(EntityMutatorFactory, () => {
>
>(EntityConstructionUtils);
when(entityConstructionUtilsMock.constructEntity(anything())).thenReturn(fakeEntity);
when(entityLoaderMock.constructionUtils).thenReturn(instance(entityConstructionUtilsMock));
const entityLoader = instance(entityLoaderMock);
const entityLoaderFactoryMock =
@@ -1667,6 +1672,13 @@ describe(EntityMutatorFactory, () => {
anything(),
),
).thenReturn(entityLoader);
when(
entityLoaderFactoryMock.constructionUtils(
viewerContext,
anyOfClass(EntityTransactionalQueryContext),
anything(),
),
).thenReturn(instance(entityConstructionUtilsMock));
const entityLoaderFactory = instance(entityLoaderFactoryMock);
const rejectionError = new Error();
@@ -11,6 +11,7 @@ import {
EntityPrivacyPolicyEvaluationContext,
EntityPrivacyPolicyEvaluationMode,
EntityPrivacyPolicyEvaluator,
EntityPrivacyPolicyRuleEvaluationContext,
} from '../EntityPrivacyPolicy';
import { EntityQueryContext } from '../EntityQueryContext';
import { ViewerContext } from '../ViewerContext';
@@ -273,6 +274,47 @@ class EmptyPolicy extends EntityPrivacyPolicy<BlahFields, 'id', ViewerContext, B
protected override readonly deleteRules = [];
}
class ContextCapturingRule extends PrivacyPolicyRule<BlahFields, 'id', ViewerContext, BlahEntity> {
public capturedContext: EntityPrivacyPolicyRuleEvaluationContext<
BlahFields,
'id',
ViewerContext,
BlahEntity
> | null = null;
async evaluateAsync(
_viewerContext: ViewerContext,
_queryContext: EntityQueryContext,
evaluationContext: EntityPrivacyPolicyRuleEvaluationContext<
BlahFields,
'id',
ViewerContext,
BlahEntity
>,
_entity: BlahEntity,
): Promise<RuleEvaluationResult> {
this.capturedContext = evaluationContext;
return RuleEvaluationResult.ALLOW;
}
}
class ContextCapturingPolicy extends EntityPrivacyPolicy<
BlahFields,
'id',
ViewerContext,
BlahEntity
> {
public readonly createRule = new ContextCapturingRule();
public readonly readRule = new ContextCapturingRule();
public readonly updateRule = new ContextCapturingRule();
public readonly deleteRule = new ContextCapturingRule();
protected override readonly createRules = [this.createRule];
protected override readonly readRules = [this.readRule];
protected override readonly updateRules = [this.updateRule];
protected override readonly deleteRules = [this.deleteRule];
}
describe(EntityPrivacyPolicy, () => {
describe(EntityPrivacyPolicyEvaluationMode.ENFORCE.toString(), () => {
it('throws EntityNotAuthorizedError when deny', async () => {
@@ -718,4 +760,64 @@ describe(EntityPrivacyPolicy, () => {
verify(metricsAdapterMock.logAuthorizationEvent(anything())).never();
});
});
describe('EntityPrivacyPolicyRuleEvaluationContext', () => {
it('passes correct authorization action to rules for each CRUD operation', async () => {
const viewerContext = instance(mock(ViewerContext));
const queryContext = instance(mock(EntityQueryContext));
const privacyPolicyEvaluationContext: EntityPrivacyPolicyEvaluationContext<
BlahFields,
'id',
ViewerContext,
BlahEntity
> = {
previousValue: null,
cascadingDeleteCause: null,
};
const metricsAdapter = instance(mock<IEntityMetricsAdapter>());
const entity = new BlahEntity({
viewerContext,
id: '1',
databaseFields: { id: '1' },
selectedFields: { id: '1' },
});
const policy = new ContextCapturingPolicy();
await policy.authorizeCreateAsync(
viewerContext,
queryContext,
privacyPolicyEvaluationContext,
entity,
metricsAdapter,
);
expect(policy.createRule.capturedContext?.action).toBe(EntityAuthorizationAction.CREATE);
await policy.authorizeReadAsync(
viewerContext,
queryContext,
privacyPolicyEvaluationContext,
entity,
metricsAdapter,
);
expect(policy.readRule.capturedContext?.action).toBe(EntityAuthorizationAction.READ);
await policy.authorizeUpdateAsync(
viewerContext,
queryContext,
privacyPolicyEvaluationContext,
entity,
metricsAdapter,
);
expect(policy.updateRule.capturedContext?.action).toBe(EntityAuthorizationAction.UPDATE);
await policy.authorizeDeleteAsync(
viewerContext,
queryContext,
privacyPolicyEvaluationContext,
entity,
metricsAdapter,
);
expect(policy.deleteRule.capturedContext?.action).toBe(EntityAuthorizationAction.DELETE);
});
});
});
@@ -49,7 +49,7 @@ describe(EntityQueryContext, () => {
);
});
await viewerContext.runInTransactionForDatabaseAdaptorFlavorAsync(
await viewerContext.runInTransactionForDatabaseAdapterFlavorAsync(
'postgres',
async (queryContext) => {
queryContext.appendPostCommitCallback(postCommitCallback);
@@ -76,7 +76,7 @@ describe(EntityQueryContext, () => {
const postCommitCallback = jest.fn(async (): Promise<void> => {});
await expect(
viewerContext.runInTransactionForDatabaseAdaptorFlavorAsync(
viewerContext.runInTransactionForDatabaseAdapterFlavorAsync(
'postgres',
async (queryContext) => {
queryContext.appendPostCommitCallback(postCommitCallback);
@@ -105,7 +105,7 @@ describe(EntityQueryContext, () => {
const postCommitCallback = jest.fn(async (): Promise<void> => {});
const postCommitNestedCallback = jest.fn(async (): Promise<void> => {});
await viewerContext.runInTransactionForDatabaseAdaptorFlavorAsync(
await viewerContext.runInTransactionForDatabaseAdapterFlavorAsync(
'postgres',
async (queryContext) => {
queryContext.appendPostCommitCallback(postCommitCallback);
@@ -152,13 +152,13 @@ describe(EntityQueryContext, () => {
const viewerContext = new ViewerContext(companionProvider);
const queryContextProvider =
companionProvider.getQueryContextProviderForDatabaseAdaptorFlavor('postgres');
companionProvider.getQueryContextProviderForDatabaseAdapterFlavor('postgres');
const queryContextProviderSpy = jest.spyOn(queryContextProvider, 'runInTransactionAsync');
const transactionScopeFn = async (): Promise<any> => {};
const transactionConfig = { isolationLevel: TransactionIsolationLevel.SERIALIZABLE };
await viewerContext.runInTransactionForDatabaseAdaptorFlavorAsync(
await viewerContext.runInTransactionForDatabaseAdapterFlavorAsync(
'postgres',
transactionScopeFn,
transactionConfig,
@@ -171,7 +171,7 @@ describe(EntityQueryContext, () => {
const companionProvider = createUnitTestEntityCompanionProvider();
const viewerContext = new ViewerContext(companionProvider);
await viewerContext.runInTransactionForDatabaseAdaptorFlavorAsync(
await viewerContext.runInTransactionForDatabaseAdapterFlavorAsync(
'postgres',
async (queryContext) => {
assert(queryContext.isInTransaction());
@@ -182,7 +182,7 @@ describe(EntityQueryContext, () => {
{ transactionalDataLoaderMode: TransactionalDataLoaderMode.DISABLED },
);
await viewerContext.runInTransactionForDatabaseAdaptorFlavorAsync(
await viewerContext.runInTransactionForDatabaseAdapterFlavorAsync(
'postgres',
async (queryContext) => {
assert(queryContext.isInTransaction());
@@ -193,7 +193,7 @@ describe(EntityQueryContext, () => {
{ transactionalDataLoaderMode: TransactionalDataLoaderMode.ENABLED_BATCH_ONLY },
);
await viewerContext.runInTransactionForDatabaseAdaptorFlavorAsync(
await viewerContext.runInTransactionForDatabaseAdapterFlavorAsync(
'postgres',
async (queryContext) => {
assert(queryContext.isInTransaction());
@@ -252,7 +252,7 @@ describe(EntityQueryContext, () => {
);
const viewerContext = new ViewerContext(companionProvider);
await viewerContext.runInTransactionForDatabaseAdaptorFlavorAsync(
await viewerContext.runInTransactionForDatabaseAdapterFlavorAsync(
'postgres',
async (queryContext) => {
assert(queryContext.isInTransaction());
@@ -263,7 +263,7 @@ describe(EntityQueryContext, () => {
{ transactionalDataLoaderMode: TransactionalDataLoaderMode.DISABLED },
);
await viewerContext.runInTransactionForDatabaseAdaptorFlavorAsync(
await viewerContext.runInTransactionForDatabaseAdapterFlavorAsync(
'postgres',
async (queryContext) => {
assert(queryContext.isInTransaction());
@@ -274,7 +274,7 @@ describe(EntityQueryContext, () => {
{ transactionalDataLoaderMode: TransactionalDataLoaderMode.ENABLED_BATCH_ONLY },
);
await viewerContext.runInTransactionForDatabaseAdaptorFlavorAsync(
await viewerContext.runInTransactionForDatabaseAdapterFlavorAsync(
'postgres',
async (queryContext) => {
assert(queryContext.isInTransaction());
@@ -46,7 +46,7 @@ describe(EntitySecondaryCacheLoader, () => {
const secondaryCacheLoader = new TestSecondaryRedisCacheLoader(
secondaryEntityCache,
SimpleTestEntity.loaderWithAuthorizationResults(vc1),
EntitySecondaryCacheLoader.getConstructionUtilsForEntityClass(SimpleTestEntity, vc1),
);
await secondaryCacheLoader.loadManyAsync([loadParams]);
@@ -70,8 +70,11 @@ describe(EntitySecondaryCacheLoader, () => {
const secondaryEntityCache = instance(secondaryEntityCacheMock);
const loader = SimpleTestEntity.loaderWithAuthorizationResults(vc1);
const spiedPrivacyPolicy = spy(loader.constructionUtils['privacyPolicy']);
const secondaryCacheLoader = new TestSecondaryRedisCacheLoader(secondaryEntityCache, loader);
const spiedPrivacyPolicy = spy(loader['constructionUtils']['privacyPolicy']);
const secondaryCacheLoader = new TestSecondaryRedisCacheLoader(
secondaryEntityCache,
EntitySecondaryCacheLoader.getConstructionUtilsForEntityClass(SimpleTestEntity, vc1),
);
const result = await secondaryCacheLoader.loadManyAsync([loadParams]);
expect(result.get(loadParams)?.enforceValue().getID()).toEqual(createdEntity.getID());
@@ -98,8 +101,10 @@ describe(EntitySecondaryCacheLoader, () => {
const secondaryEntityCacheMock =
mock<ISecondaryEntityCache<SimpleTestFields, TestLoadParams>>();
const secondaryEntityCache = instance(secondaryEntityCacheMock);
const loader = SimpleTestEntity.loaderWithAuthorizationResults(vc1);
const secondaryCacheLoader = new TestSecondaryRedisCacheLoader(secondaryEntityCache, loader);
const secondaryCacheLoader = new TestSecondaryRedisCacheLoader(
secondaryEntityCache,
EntitySecondaryCacheLoader.getConstructionUtilsForEntityClass(SimpleTestEntity, vc1),
);
await secondaryCacheLoader.invalidateManyAsync([loadParams]);
verify(secondaryEntityCacheMock.invalidateManyAsync(deepEqual([loadParams]))).once();
@@ -1,6 +1,8 @@
import { describe, it, expect } from '@jest/globals';
import nullthrows from 'nullthrows';
import { AuthorizationResultBasedEntityLoader } from '../AuthorizationResultBasedEntityLoader';
import { EntityConstructionUtils } from '../EntityConstructionUtils';
import { EntitySecondaryCacheLoader } from '../EntitySecondaryCacheLoader';
import { GenericSecondaryEntityCache } from '../GenericSecondaryEntityCache';
import { IEntityGenericCacher } from '../IEntityGenericCacher';
@@ -100,6 +102,28 @@ class TestSecondaryCacheLoader extends EntitySecondaryCacheLoader<
> {
public databaseLoadCount = 0;
constructor(
secondaryEntityCache: TestSecondaryEntityCache<TestFields, 'customIdField', TestLoadParams>,
constructionUtils: EntityConstructionUtils<
TestFields,
'customIdField',
ViewerContext,
TestEntity,
TestEntityPrivacyPolicy,
keyof TestFields
>,
private readonly entityLoader: AuthorizationResultBasedEntityLoader<
TestFields,
'customIdField',
ViewerContext,
TestEntity,
TestEntityPrivacyPolicy,
keyof TestFields
>,
) {
super(secondaryEntityCache, constructionUtils);
}
protected override async fetchObjectsFromDatabaseAsync(
loadParamsArray: readonly Readonly<TestLoadParams>[],
): Promise<ReadonlyMap<Readonly<Readonly<TestLoadParams>>, Readonly<TestFields> | null>> {
@@ -129,6 +153,7 @@ describe(GenericSecondaryEntityCache, () => {
new TestGenericCacher(),
(params) => `intValue.${params.intValue}`,
),
EntitySecondaryCacheLoader.getConstructionUtilsForEntityClass(TestEntity, viewerContext),
TestEntity.loaderWithAuthorizationResults(viewerContext),
);
@@ -165,6 +190,7 @@ describe(GenericSecondaryEntityCache, () => {
new TestGenericCacher(),
(params) => `intValue.${params.intValue}`,
),
EntitySecondaryCacheLoader.getConstructionUtilsForEntityClass(TestEntity, viewerContext),
TestEntity.loaderWithAuthorizationResults(viewerContext),
);
@@ -5,7 +5,6 @@ import { AuthorizationResultBasedEntityAssociationLoader } from '../Authorizatio
import { AuthorizationResultBasedEntityLoader } from '../AuthorizationResultBasedEntityLoader';
import { EnforcingEntityAssociationLoader } from '../EnforcingEntityAssociationLoader';
import { EnforcingEntityLoader } from '../EnforcingEntityLoader';
import { EntityConstructionUtils } from '../EntityConstructionUtils';
import { EntityInvalidationUtils } from '../EntityInvalidationUtils';
import { ReadonlyEntity } from '../ReadonlyEntity';
import { ViewerContext } from '../ViewerContext';
@@ -224,14 +223,4 @@ describe(ReadonlyEntity, () => {
);
});
});
describe('constructionUtils', () => {
it('creates a new EntityConstructionUtils', async () => {
const companionProvider = createUnitTestEntityCompanionProvider();
const viewerContext = new ViewerContext(companionProvider);
expect(SimpleTestEntity.constructionUtils(viewerContext)).toBeInstanceOf(
EntityConstructionUtils,
);
});
});
});
@@ -5,22 +5,22 @@ import { ViewerContext } from '../ViewerContext';
import { createUnitTestEntityCompanionProvider } from '../utils/__testfixtures__/createUnitTestEntityCompanionProvider';
describe(ViewerContext, () => {
describe('getQueryContextForDatabaseAdaptorFlavor', () => {
describe('getQueryContextForDatabaseAdapterFlavor', () => {
it('creates a new regular query context', () => {
const companionProvider = createUnitTestEntityCompanionProvider();
const viewerContext = new ViewerContext(companionProvider);
const queryContext = viewerContext.getQueryContextForDatabaseAdaptorFlavor('postgres');
const queryContext = viewerContext.getQueryContextForDatabaseAdapterFlavor('postgres');
expect(queryContext).toBeInstanceOf(EntityQueryContext);
expect(queryContext.isInTransaction()).toBe(false);
});
});
describe('runInTransactionForDatabaseAdaptorFlavorAsync', () => {
describe('runInTransactionForDatabaseAdapterFlavorAsync', () => {
it('creates a new transactional query context', async () => {
const companionProvider = createUnitTestEntityCompanionProvider();
const viewerContext = new ViewerContext(companionProvider);
const didCreateTransaction =
await viewerContext.runInTransactionForDatabaseAdaptorFlavorAsync(
await viewerContext.runInTransactionForDatabaseAdapterFlavorAsync(
'postgres',
async (queryContext) => {
return queryContext.isInTransaction();
@@ -1,5 +1,8 @@
import { describe, expect, it } from '@jest/globals';
import { instance, mock } from 'ts-mockito';
import { ViewerContext } from '../../ViewerContext';
import { SimpleTestEntity } from '../../utils/__testfixtures__/SimpleTestEntity';
import { EntityCacheAdapterTransientError } from '../EntityCacheAdapterError';
import { EntityErrorCode, EntityErrorState } from '../EntityError';
import { EntityInvalidFieldValueError } from '../EntityInvalidFieldValueError';
@@ -14,16 +17,21 @@ describe('EntityError subclasses', () => {
});
it('EntityNotAuthorizedError has correct state and code', () => {
const mockEntity = { constructor: { name: 'TestEntity' }, toString: () => 'TestEntity' } as any;
const mockViewerContext = { toString: () => 'TestViewer' } as any;
const error = new EntityNotAuthorizedError(mockEntity, mockViewerContext, 0, 0);
const viewerContext = instance(mock(ViewerContext));
const data = { id: '1' };
const testEntity = new SimpleTestEntity({
viewerContext,
id: 'what',
databaseFields: data,
selectedFields: data,
});
const error = new EntityNotAuthorizedError(testEntity, viewerContext, 0, 0);
expect(error.state).toBe(EntityErrorState.PERMANENT);
expect(error.code).toBe(EntityErrorCode.ERR_ENTITY_NOT_AUTHORIZED);
});
it('EntityInvalidFieldValueError has correct state and code', () => {
const mockEntityClass = { name: 'TestEntity' } as any;
const error = new EntityInvalidFieldValueError(mockEntityClass, 'testField', 'badValue');
const error = new EntityInvalidFieldValueError(SimpleTestEntity, 'id', 'badValue');
expect(error.state).toBe(EntityErrorState.PERMANENT);
expect(error.code).toBe(EntityErrorCode.ERR_ENTITY_INVALID_FIELD_VALUE);
});
@@ -51,7 +51,7 @@ export const transformDatabaseObjectToFields = <
fieldTransformerMap: FieldTransformerMap,
databaseObject: { [key: string]: any },
): Readonly<TFields> => {
const fields: TFields = {} as any;
const fields: Partial<TFields> = {};
for (const k in databaseObject) {
const val = databaseObject[k];
const fieldsKey = entityConfiguration.dbToEntityFieldsKeyMapping.get(k);
@@ -64,7 +64,7 @@ export const transformDatabaseObjectToFields = <
);
}
}
return fields;
return fields as Readonly<TFields>;
};
/**
@@ -81,7 +81,7 @@ export const transformFieldsToDatabaseObject = <
const databaseObject: { [key: string]: any } = {};
for (const k in fields) {
const val = fields[k]!;
const databaseKey = entityConfiguration.entityToDBFieldsKeyMapping.get(k as any);
const databaseKey = entityConfiguration.entityToDBFieldsKeyMapping.get(k);
invariant(databaseKey, `must be database key for field: ${k}`);
databaseObject[databaseKey] = maybeTransformFieldValueToDatabaseValue(
entityConfiguration,
@@ -104,7 +104,7 @@ export const transformCacheObjectToFields = <
fieldTransformerMap: FieldTransformerMap,
cacheObject: { [key: string]: any },
): Readonly<TFields> => {
const fields: TFields = {} as any;
const fields: Partial<TFields> = {};
for (const fieldsKey in cacheObject) {
const val = cacheObject[fieldsKey]!;
fields[fieldsKey as keyof TFields] = maybeTransformCacheValueToFieldValue(
@@ -114,7 +114,7 @@ export const transformCacheObjectToFields = <
val,
);
}
return fields;
return fields as Readonly<TFields>;
};
/**
@@ -3,6 +3,8 @@ import { ISerializable, SerializableKeyMap } from '../utils/collections/Serializ
/**
* Load method type identifier of a load key. Used for keying data loaders and identification in metrics.
*
* @internal
*/
export enum EntityLoadMethodType {
/**
Loaded 100 of 118 files, more files were not shown because too many files have changed in this diff. Show more