grafeo-ogm 1.6.0 → 1.7.0-beta.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +79 -0
- package/README.md +183 -0
- package/dist/cjs/compilers/selection.compiler.d.ts +21 -1
- package/dist/cjs/compilers/selection.compiler.d.ts.map +1 -1
- package/dist/cjs/compilers/selection.compiler.js +135 -43
- package/dist/cjs/compilers/selection.compiler.js.map +1 -1
- package/dist/cjs/compilers/where.compiler.d.ts +23 -0
- package/dist/cjs/compilers/where.compiler.d.ts.map +1 -1
- package/dist/cjs/compilers/where.compiler.js +172 -4
- package/dist/cjs/compilers/where.compiler.js.map +1 -1
- package/dist/cjs/execution/executor.d.ts +20 -0
- package/dist/cjs/execution/executor.d.ts.map +1 -1
- package/dist/cjs/execution/executor.js +28 -4
- package/dist/cjs/execution/executor.js.map +1 -1
- package/dist/cjs/index.d.ts +5 -2
- package/dist/cjs/index.d.ts.map +1 -1
- package/dist/cjs/index.js +11 -1
- package/dist/cjs/index.js.map +1 -1
- package/dist/cjs/interface-model.d.ts +43 -1
- package/dist/cjs/interface-model.d.ts.map +1 -1
- package/dist/cjs/interface-model.js +211 -9
- package/dist/cjs/interface-model.js.map +1 -1
- package/dist/cjs/model.d.ts +115 -1
- package/dist/cjs/model.d.ts.map +1 -1
- package/dist/cjs/model.js +263 -38
- package/dist/cjs/model.js.map +1 -1
- package/dist/cjs/ogm.d.ts +115 -4
- package/dist/cjs/ogm.d.ts.map +1 -1
- package/dist/cjs/ogm.js +189 -2
- package/dist/cjs/ogm.js.map +1 -1
- package/dist/cjs/policy/errors.d.ts +22 -0
- package/dist/cjs/policy/errors.d.ts.map +1 -0
- package/dist/cjs/policy/errors.js +23 -0
- package/dist/cjs/policy/errors.js.map +1 -0
- package/dist/cjs/policy/resolver.d.ts +42 -0
- package/dist/cjs/policy/resolver.d.ts.map +1 -0
- package/dist/cjs/policy/resolver.js +116 -0
- package/dist/cjs/policy/resolver.js.map +1 -0
- package/dist/cjs/policy/types.d.ts +182 -0
- package/dist/cjs/policy/types.d.ts.map +1 -0
- package/dist/cjs/policy/types.js +130 -0
- package/dist/cjs/policy/types.js.map +1 -0
- package/dist/esm/compilers/selection.compiler.d.ts +21 -1
- package/dist/esm/compilers/selection.compiler.d.ts.map +1 -1
- package/dist/esm/compilers/selection.compiler.js +135 -43
- package/dist/esm/compilers/selection.compiler.js.map +1 -1
- package/dist/esm/compilers/where.compiler.d.ts +23 -0
- package/dist/esm/compilers/where.compiler.d.ts.map +1 -1
- package/dist/esm/compilers/where.compiler.js +172 -4
- package/dist/esm/compilers/where.compiler.js.map +1 -1
- package/dist/esm/execution/executor.d.ts +20 -0
- package/dist/esm/execution/executor.d.ts.map +1 -1
- package/dist/esm/execution/executor.js +28 -4
- package/dist/esm/execution/executor.js.map +1 -1
- package/dist/esm/index.d.ts +5 -2
- package/dist/esm/index.d.ts.map +1 -1
- package/dist/esm/index.js +4 -1
- package/dist/esm/index.js.map +1 -1
- package/dist/esm/interface-model.d.ts +43 -1
- package/dist/esm/interface-model.d.ts.map +1 -1
- package/dist/esm/interface-model.js +211 -9
- package/dist/esm/interface-model.js.map +1 -1
- package/dist/esm/model.d.ts +115 -1
- package/dist/esm/model.d.ts.map +1 -1
- package/dist/esm/model.js +263 -38
- package/dist/esm/model.js.map +1 -1
- package/dist/esm/ogm.d.ts +115 -4
- package/dist/esm/ogm.d.ts.map +1 -1
- package/dist/esm/ogm.js +187 -1
- package/dist/esm/ogm.js.map +1 -1
- package/dist/esm/policy/errors.d.ts +22 -0
- package/dist/esm/policy/errors.d.ts.map +1 -0
- package/dist/esm/policy/errors.js +19 -0
- package/dist/esm/policy/errors.js.map +1 -0
- package/dist/esm/policy/resolver.d.ts +42 -0
- package/dist/esm/policy/resolver.d.ts.map +1 -0
- package/dist/esm/policy/resolver.js +111 -0
- package/dist/esm/policy/resolver.js.map +1 -0
- package/dist/esm/policy/types.d.ts +182 -0
- package/dist/esm/policy/types.d.ts.map +1 -0
- package/dist/esm/policy/types.js +123 -0
- package/dist/esm/policy/types.js.map +1 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,84 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [1.7.0-beta.1] (2026-05-02)
|
|
4
|
+
|
|
5
|
+
> **BETA — republish of beta.0 with no code changes.** Install with `npm install grafeo-ogm@beta`.
|
|
6
|
+
|
|
7
|
+
### Fixed
|
|
8
|
+
|
|
9
|
+
- **CI publish workflow** now passes `--tag` to `npm publish`, derived from the version's pre-release suffix (`beta`, `alpha`, `rc`, etc.), defaulting to `latest` for stable releases. Required because npm refuses to publish prerelease versions without an explicit dist-tag. v1.7.0-beta.0 was tagged on GitHub but never reached npm because of this; v1.7.0-beta.1 is the first beta artifact actually available on the registry.
|
|
10
|
+
|
|
11
|
+
## [1.7.0-beta.0] (2026-05-02)
|
|
12
|
+
|
|
13
|
+
> **BETA — API may change before 1.7.0 final.** Install with `npm install grafeo-ogm@beta` (after the package is published to the `beta` dist-tag). The shape of the public API is settled, but feedback during the beta window may cause minor adjustments.
|
|
14
|
+
|
|
15
|
+
### Features
|
|
16
|
+
|
|
17
|
+
- **Node-Level Security (NLS).** New optional `policies` config on `OGMConfig` plus `OGM.withContext(ctx)` give you a Postgres-RLS-style filter layer that compiles into the existing WHERE pipeline. Policies return `<Node>Where` partials — every operator, quantifier, connection filter, and nested traversal already supported by `WhereCompiler` is automatically available. Three policy kinds: `override` (compile-time short-circuit; admin path is byte-identical to no-policy), `permissive` (OR'd grants with optional `appliesWhen` compile-time gate), `restrictive` (split into read-side and write-side flavors — see "Contract change" below). Vocabulary-neutral — no hardcoded role strings, you compose your own.
|
|
18
|
+
- **Nested-selection enforcement.** `SelectionCompiler` injects every target type's `'read'` policy into pattern comprehensions, connection edges, and union branches. Policies cannot be bypassed by traversal.
|
|
19
|
+
- **Default-deny baseline.** `policyDefaults.onDeny: 'empty'` (default) emits `WHERE false` when no permissive matches; `'throw'` raises `PolicyDeniedError` before the query runs.
|
|
20
|
+
- **Audit metadata.** Every OGM-emitted query when policies are configured attaches `tx.setMetaData({ ogmPolicySetVersion, ctxFingerprint, modelType, operation, policiesEvaluated, bypassed })`. `ctxFingerprint` is a SHA-256 of the SORTED ctx KEYS only — never values, never anything sensitive.
|
|
21
|
+
- **Escape hatches.** `ogm.unsafe.bypassPolicies()` returns a non-policy-aware OGM (logged via `logger.warn`); per-method `unsafe: { bypassPolicies: true }` skips policies for a single call (also logged).
|
|
22
|
+
- **Interface-aware enforcement.** `InterfaceModel.find()` / `aggregate()` emit a CASE-per-label WHERE that AND-combines each implementer's `'read'` policy with the interface-level policy. Concrete-type policies are NOT bypassed when querying through the interface.
|
|
23
|
+
- **`PolicyDeniedError`** — new public error class extending `OGMError`. Carries `typeName`, `operation`, `reason` (`'no-permissive-matched' | 'restrictive-rejected-input' | 'override-failed-validation'`), and optional `policyName`.
|
|
24
|
+
|
|
25
|
+
### Contract change during beta — read/write restrictive split
|
|
26
|
+
|
|
27
|
+
The pre-fix beta invoked `RestrictivePolicy.when` twice on every write path: once at the application layer with `(ctx, input)` (WITH CHECK semantics) and once at WHERE-compile with `(ctx)` only (row predicate). Side-effecting callbacks fired inconsistently and any predicate that legitimately depended on `input` returned `false` at compile time → `WHERE false` → reads silently blocked.
|
|
28
|
+
|
|
29
|
+
**Fix:** `RestrictivePolicy` is now a discriminated union over `operations`.
|
|
30
|
+
|
|
31
|
+
- **`ReadRestrictivePolicy`** — `operations` includes only `read|delete|aggregate|count`. `when(ctx)` returns a `<Node>Where` partial OR `false`. Compiles into the WHERE clause via `WhereCompiler`. Invoked exactly once per read-side query.
|
|
32
|
+
- **`WriteRestrictivePolicy`** — `operations` includes only `create|update`. `when(ctx, input)` returns a boolean (where-partial returns are no longer accepted; if you need a row filter on `update`, register a `ReadRestrictive` on `read`/`delete`). Runs at the application layer ("WITH CHECK"). Invoked exactly once per write op. The `cypher` escape hatch is not supported on write restrictives.
|
|
33
|
+
- **Mixed-operation arrays are rejected** at construction time with `OGMError`. Split a single `restrictive({ operations: ['read', 'create'], … })` into two restrictives — one per kind.
|
|
34
|
+
|
|
35
|
+
The `restrictive()` constructor uses TypeScript overloads so authoring code gets the correct `when` signature inferred from the literal `operations` tuple. New runtime helpers `isReadRestrictive` / `isWriteRestrictive` are exported for users who need to inspect a policy at runtime.
|
|
36
|
+
|
|
37
|
+
This is a contract change inside the beta window — the API has shifted before `1.7.0` final, which is exactly what the beta dist-tag exists for. No public types were removed; `RestrictivePolicy` remains a public type and resolves to the union of the two new flavors.
|
|
38
|
+
|
|
39
|
+
### Public API additions (additive only)
|
|
40
|
+
|
|
41
|
+
- New exports: `override`, `permissive`, `restrictive`, `isReadRestrictive`, `isWriteRestrictive`, `OGMWithContext`, `PolicyDeniedError`.
|
|
42
|
+
- New types: `Policy`, `OverridePolicy`, `PermissivePolicy`, `RestrictivePolicy`, `ReadRestrictivePolicy`, `WriteRestrictivePolicy`, `PolicyContext`, `PolicyContextBundle`, `Operation`, `OperationOrWildcard`, `ReadOperation`, `WriteOperation`, `PoliciesByModel`, `PolicyDefaults`, `ResolvedPolicies`, `UnsafeOptions`.
|
|
43
|
+
- `OGMConfig` gains optional `policies?` and `policyDefaults?`.
|
|
44
|
+
- `OGM` gains `withContext<C>(ctx: C)` and `unsafe.bypassPolicies()`.
|
|
45
|
+
- Every Model / InterfaceModel method's params bag gains optional `unsafe?: { bypassPolicies?: boolean }`.
|
|
46
|
+
- All existing call signatures remain valid. Calling code that doesn't pass policies must compile and run identically — byte-identical Cypher for non-policy calls.
|
|
47
|
+
|
|
48
|
+
### Install command (beta)
|
|
49
|
+
|
|
50
|
+
```bash
|
|
51
|
+
npm install grafeo-ogm@beta
|
|
52
|
+
# or
|
|
53
|
+
pnpm add grafeo-ogm@beta
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
### Known limits in this beta
|
|
57
|
+
|
|
58
|
+
- **`@cypher` scalar fields inside a policy `where`-partial throw when the policy is injected into nested-selection enforcement.** Refactor the policy to use stored properties or a relationship traversal. (Top-level WHERE on the root model still supports `@cypher` filters.)
|
|
59
|
+
- **`upsert` evaluates create- and update-side policies at the application layer.** MERGE has no WHERE we can stitch into; the WHERE-side enforcement only covers the matching path. Documented limit; full MERGE-aware enforcement is deferred to v1.7.1.
|
|
60
|
+
- **Restrictive read/write split — fixed in this iteration.** The dual-invocation contract bug present in earlier beta builds is resolved. `ReadRestrictivePolicy` (`when(ctx)`) is invoked exactly once at WHERE-compile; `WriteRestrictivePolicy` (`when(ctx, input)`) is invoked exactly once at the application layer. See the "Contract change during beta" section above for the new shape and migration notes.
|
|
61
|
+
- **InterfaceModel CASE-per-label fallback.** When an interface has policies registered but a concrete implementer does not, the implementer's branch falls back to interface-level enforcement only (rather than default-denying the unlisted implementer). The OGM emits a `logger.warn` at construction time when this configuration is detected so it never passes silently. Strict per-implementer default-deny is being evaluated for `1.7.0` final.
|
|
62
|
+
- **AsyncLocalStorage opt-in is deferred to v1.7.1.** Beta is explicit `withContext()` only — create one wrapper per request and discard it.
|
|
63
|
+
- **Index requirement declaration deferred to v1.8.0.** No `requires.indexes` config in this beta.
|
|
64
|
+
- **EXPLAIN-in-test mode deferred to v1.8.0.** No dev hook in beta.
|
|
65
|
+
- **No live Neo4j integration test in this beta.** Mock-driver coverage is extensive (1317 specs incl. byte-identical regression of every v1.6.0 emission and the C1 read/write contract proofs), but a live `pnpm run test:integration` against a real Neo4j is a `1.7.0` final blocker.
|
|
66
|
+
|
|
67
|
+
### Unaffected paths (byte-identical to v1.6.0)
|
|
68
|
+
|
|
69
|
+
- An OGM constructed without `policies` emits identical Cypher to v1.6.0 for every covered operation. Verified in `tests/policy/byte-identical.spec.ts`.
|
|
70
|
+
- An OGM with `policies` but invoked via the bare `OGM.model()` path (no `withContext`) emits identical Cypher to v1.6.0.
|
|
71
|
+
- An override match emits identical Cypher to a no-policy query.
|
|
72
|
+
- `ogm.unsafe.bypassPolicies()` and per-call `unsafe: { bypassPolicies: true }` both emit identical Cypher to v1.6.0.
|
|
73
|
+
|
|
74
|
+
### Generated types
|
|
75
|
+
|
|
76
|
+
- Existing `<Node>Where`, `<Node>CreateInput`, `<Node>UpdateInput` are sufficient for typing policy callbacks. The generator's `ModelMap` already exposed `Where`, `CreateInput`, and `UpdateInput` keys per model so `PoliciesByModel<M, C>` can index into them. **No regeneration required when upgrading from v1.6.0 → v1.7.0-beta.0.**
|
|
77
|
+
|
|
78
|
+
### Migration
|
|
79
|
+
|
|
80
|
+
- v1.6.0 → v1.7.0-beta.0 is purely additive — no changes required unless you opt into NLS.
|
|
81
|
+
|
|
3
82
|
## 1.6.0 (2026-05-01)
|
|
4
83
|
|
|
5
84
|
### Features
|
package/README.md
CHANGED
|
@@ -213,6 +213,24 @@ await ogm.$transaction(async (ctx) => {
|
|
|
213
213
|
|
|
214
214
|
---
|
|
215
215
|
|
|
216
|
+
## Beta features
|
|
217
|
+
|
|
218
|
+
The current beta release exposes one new feature behind opt-in config:
|
|
219
|
+
|
|
220
|
+
- **Node-Level Security (NLS)** — a Postgres-RLS-style filter layer that compiles into the existing WHERE pipeline. See **[Advanced Features → Node-Level Security (Beta)](#node-level-security-beta)**.
|
|
221
|
+
|
|
222
|
+
The beta is purely additive: an OGM constructed without the new `policies` option emits **byte-identical Cypher** to v1.6.0. Install with:
|
|
223
|
+
|
|
224
|
+
```bash
|
|
225
|
+
npm install grafeo-ogm@beta
|
|
226
|
+
# or
|
|
227
|
+
pnpm add grafeo-ogm@beta
|
|
228
|
+
```
|
|
229
|
+
|
|
230
|
+
The shape of the public API is settled, but the beta window is for collecting integration feedback. Minor adjustments may land before `1.7.0` final.
|
|
231
|
+
|
|
232
|
+
---
|
|
233
|
+
|
|
216
234
|
## Features Overview
|
|
217
235
|
|
|
218
236
|
| Feature | Description |
|
|
@@ -918,6 +936,171 @@ These are not OGM bugs — they are Cypher-language constraints. Pattern compreh
|
|
|
918
936
|
|
|
919
937
|
Each scope (`where`, `select`, sort) emits its own `CALL { WITH n; WITH n AS this; <statement> }` block per `@cypher` field reference. A query that references the same `@cypher` field in all three scopes emits three `CALL` blocks. This is functionally correct and parameter-isolated, but worth knowing if your statement is expensive (multi-hop traversals, aggregations over large fan-outs). For frequently-accessed computed values, consider materializing as a stored property updated on write instead.
|
|
920
938
|
|
|
939
|
+
### Node-Level Security (Beta)
|
|
940
|
+
|
|
941
|
+
> **Beta — install with `npm install grafeo-ogm@beta`.**
|
|
942
|
+
|
|
943
|
+
Node-Level Security (NLS) is a per-request filter layer that compiles into the existing WHERE pipeline. Policies return `<Node>Where` partials, so every operator, quantifier, connection filter, and nested traversal already supported by `WhereCompiler` is automatically available inside policies — no new DSL.
|
|
944
|
+
|
|
945
|
+
#### Declaration
|
|
946
|
+
|
|
947
|
+
```typescript
|
|
948
|
+
import { OGM, override, permissive, restrictive } from 'grafeo-ogm';
|
|
949
|
+
|
|
950
|
+
interface RequestCtx {
|
|
951
|
+
userId: string;
|
|
952
|
+
capabilities: string[];
|
|
953
|
+
tierIds: string[];
|
|
954
|
+
}
|
|
955
|
+
|
|
956
|
+
const ogm = new OGM<ModelMap>({
|
|
957
|
+
typeDefs,
|
|
958
|
+
driver,
|
|
959
|
+
policies: {
|
|
960
|
+
Book: [
|
|
961
|
+
// Compile-time short-circuit. When `when(ctx)` returns true, ALL
|
|
962
|
+
// other policies for `Book.read` are dropped — query is byte-
|
|
963
|
+
// identical to a no-policy query. Zero runtime cost for admins.
|
|
964
|
+
override({
|
|
965
|
+
operations: ['*'],
|
|
966
|
+
when: (c: RequestCtx) => c.capabilities.includes('admin'),
|
|
967
|
+
name: 'admin-bypass',
|
|
968
|
+
}),
|
|
969
|
+
|
|
970
|
+
// OR-grant. Multiple permissives compose with OR — any match grants.
|
|
971
|
+
permissive({
|
|
972
|
+
operations: ['read'],
|
|
973
|
+
when: (c: RequestCtx) => ({ ownerId: c.userId }),
|
|
974
|
+
name: 'owner-can-read',
|
|
975
|
+
}),
|
|
976
|
+
|
|
977
|
+
// Read-side restrictive — AND-row predicate. Compiles into the
|
|
978
|
+
// WHERE clause and applies to read|delete|aggregate|count.
|
|
979
|
+
// `when` receives only `ctx` and returns a where-partial (or
|
|
980
|
+
// `false` for a hard deny).
|
|
981
|
+
restrictive({
|
|
982
|
+
operations: ['read'],
|
|
983
|
+
when: (c: RequestCtx) => ({ tierIds_IN: c.tierIds }),
|
|
984
|
+
name: 'tier-isolation',
|
|
985
|
+
}),
|
|
986
|
+
|
|
987
|
+
// Write-side restrictive — Postgres `WITH CHECK` semantics. Runs
|
|
988
|
+
// at the application layer for create|update only; receives both
|
|
989
|
+
// `ctx` and the user-submitted `input`. MUST return a boolean.
|
|
990
|
+
// The OGM rejects mixed read+write `operations` arrays — split a
|
|
991
|
+
// single restrictive into two if you need both read- and write-
|
|
992
|
+
// side coverage.
|
|
993
|
+
restrictive({
|
|
994
|
+
operations: ['create', 'update'],
|
|
995
|
+
when: (c, input) =>
|
|
996
|
+
(input as { ownerId?: string }).ownerId === c.userId,
|
|
997
|
+
name: 'cannot-set-other-owner',
|
|
998
|
+
}),
|
|
999
|
+
],
|
|
1000
|
+
},
|
|
1001
|
+
policyDefaults: {
|
|
1002
|
+
onDeny: 'empty', // Or 'throw' — raises PolicyDeniedError pre-compile.
|
|
1003
|
+
auditMetadata: true,
|
|
1004
|
+
},
|
|
1005
|
+
});
|
|
1006
|
+
|
|
1007
|
+
// Per request:
|
|
1008
|
+
const Books = ogm.withContext({ userId, capabilities, tierIds }).model('Book');
|
|
1009
|
+
const visible = await Books.find({ where: { published_GT: '2024-01-01' } });
|
|
1010
|
+
```
|
|
1011
|
+
|
|
1012
|
+
The Cypher emitted for the call above is roughly:
|
|
1013
|
+
|
|
1014
|
+
```cypher
|
|
1015
|
+
MATCH (n:`Book`)
|
|
1016
|
+
WHERE (n.`published` > $param0) AND ((n.`ownerId` = $param1) AND n.`tierIds` IN $param2)
|
|
1017
|
+
RETURN n { .id, .title, .ownerId, .tierIds, ... }
|
|
1018
|
+
```
|
|
1019
|
+
|
|
1020
|
+
#### Hierarchical ACL example
|
|
1021
|
+
|
|
1022
|
+
Combine permissives to express "owner OR member of an allowed tier":
|
|
1023
|
+
|
|
1024
|
+
```typescript
|
|
1025
|
+
policies: {
|
|
1026
|
+
Book: [
|
|
1027
|
+
permissive({
|
|
1028
|
+
operations: ['read'],
|
|
1029
|
+
when: (c) => ({ ownerId: c.userId }),
|
|
1030
|
+
name: 'owner-can-read',
|
|
1031
|
+
}),
|
|
1032
|
+
permissive({
|
|
1033
|
+
operations: ['read'],
|
|
1034
|
+
// Returns a where-partial that uses the existing _SOME quantifier.
|
|
1035
|
+
when: (c) => ({ tiers_SOME: { id_IN: c.tierIds } }),
|
|
1036
|
+
name: 'tier-can-read',
|
|
1037
|
+
}),
|
|
1038
|
+
],
|
|
1039
|
+
}
|
|
1040
|
+
```
|
|
1041
|
+
|
|
1042
|
+
Both permissives OR together — a row passes if either branch matches.
|
|
1043
|
+
|
|
1044
|
+
#### Interface inheritance
|
|
1045
|
+
|
|
1046
|
+
Policies on an interface AND with policies on every implementing concrete type. Concrete types can only narrow restrictives, never broaden — predictable. `OR` composition for permissives lets concrete types add allow paths their interface didn't anticipate.
|
|
1047
|
+
|
|
1048
|
+
```typescript
|
|
1049
|
+
policies: {
|
|
1050
|
+
Resource: [
|
|
1051
|
+
// Tenant isolation applies to every type that implements Resource.
|
|
1052
|
+
restrictive({ operations: ['read'], when: (c) => ({ tenantId: c.tenantId }) }),
|
|
1053
|
+
],
|
|
1054
|
+
Book: [
|
|
1055
|
+
// Additional grant only for Book — composed via OR with any
|
|
1056
|
+
// Resource-level permissives.
|
|
1057
|
+
permissive({ operations: ['read'], when: (c) => ({ authorId: c.userId }) }),
|
|
1058
|
+
],
|
|
1059
|
+
}
|
|
1060
|
+
```
|
|
1061
|
+
|
|
1062
|
+
#### Enforcement boundary
|
|
1063
|
+
|
|
1064
|
+
> **The OGM enforces policies at the compiler.** Policies do NOT apply to `ogm.$queryRaw`, `ogm.$executeRaw`, or to `@cypher` directive bodies that traverse from a stored field. If you need raw-Cypher enforcement, write the predicate into your raw Cypher manually or layer a Neo4j role/RBAC at the database.
|
|
1065
|
+
|
|
1066
|
+
#### Escape hatches
|
|
1067
|
+
|
|
1068
|
+
```typescript
|
|
1069
|
+
// Disable policies for a single call. Logged via logger.warn.
|
|
1070
|
+
await Books.find({ where: { id: 'b1' }, unsafe: { bypassPolicies: true } });
|
|
1071
|
+
|
|
1072
|
+
// Disable policies for a derived OGM (data migrations, admin scripts).
|
|
1073
|
+
const adminOgm = ogm.unsafe.bypassPolicies();
|
|
1074
|
+
```
|
|
1075
|
+
|
|
1076
|
+
Both paths emit byte-identical Cypher to a no-policy OGM.
|
|
1077
|
+
|
|
1078
|
+
#### Audit metadata
|
|
1079
|
+
|
|
1080
|
+
When `policies` is configured, every OGM-emitted query attaches transaction metadata:
|
|
1081
|
+
|
|
1082
|
+
```typescript
|
|
1083
|
+
{
|
|
1084
|
+
ogmPolicySetVersion: '1.7.0-beta.0',
|
|
1085
|
+
ctxFingerprint: '<sha256 of sorted ctx KEYS — never values>',
|
|
1086
|
+
modelType: 'Book',
|
|
1087
|
+
operation: 'read',
|
|
1088
|
+
policiesEvaluated: ['owner-can-read', 'tier-isolation'],
|
|
1089
|
+
bypassed: false,
|
|
1090
|
+
}
|
|
1091
|
+
```
|
|
1092
|
+
|
|
1093
|
+
Disable via `policyDefaults: { auditMetadata: false }`. The fingerprint is intentionally key-only — no ctx values are leaked.
|
|
1094
|
+
|
|
1095
|
+
#### Beta limits
|
|
1096
|
+
|
|
1097
|
+
- **`@cypher` scalar fields inside a policy `where`-partial throw when the policy is injected into nested-selection enforcement.** Refactor the policy to use stored properties or a relationship traversal.
|
|
1098
|
+
- **`upsert`** evaluates create- and update-side policies at the application layer (MERGE has no WHERE). Documented limit; full MERGE-aware enforcement is deferred to v1.7.1.
|
|
1099
|
+
- **Restrictives are split into read-side and write-side flavors.** A restrictive's `operations` array determines which `when` signature applies. Read-side ops (`read|delete|aggregate|count`) → `when(ctx)` returns a where-partial or boolean. Write-side ops (`create|update`) → `when(ctx, input)` returns a boolean only. Mixed arrays (e.g. `['read', 'create']`) are rejected at construction time — split into two restrictives. Each flavor is invoked exactly once per query (read-side at compile, write-side at the application layer); the dual-invocation contract bug from earlier beta iterations is fixed. Use `isReadRestrictive` / `isWriteRestrictive` if you need to inspect a policy at runtime.
|
|
1100
|
+
- **InterfaceModel CASE-per-label fallback.** Implementers without a registered policy fall back to interface-level enforcement on their branch. The OGM emits a `logger.warn` at construction time when an interface has policies and one of its implementers does not — silence the warning by registering an explicit policy on each implementer.
|
|
1101
|
+
- **AsyncLocalStorage** opt-in is deferred to v1.7.1. Beta is explicit `withContext()` only — create one wrapper per request, discard after.
|
|
1102
|
+
- **Live Neo4j integration tests are not part of the beta.** Mock-driver coverage is extensive but a real-DB suite is a `1.7.0` final blocker.
|
|
1103
|
+
|
|
921
1104
|
### Raw Cypher
|
|
922
1105
|
|
|
923
1106
|
```typescript
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { PolicyContextBundle } from '../policy/types';
|
|
1
2
|
import type { NodeDefinition, SchemaMetadata } from '../schema/types';
|
|
2
3
|
import { CypherFieldScope } from '../utils/cypher-field-projection';
|
|
3
4
|
import type { WhereCompiler } from './where.compiler';
|
|
@@ -70,7 +71,15 @@ export declare class SelectionCompiler {
|
|
|
70
71
|
* throw — this is the case inside nested relationship pattern
|
|
71
72
|
* comprehensions where CALL { ... } subqueries are not allowed.
|
|
72
73
|
*/
|
|
73
|
-
cypherScope?: CypherFieldScope | null
|
|
74
|
+
cypherScope?: CypherFieldScope | null,
|
|
75
|
+
/**
|
|
76
|
+
* Policy context for nested-selection enforcement. When set, every
|
|
77
|
+
* relationship pattern comprehension, connection edge, and union
|
|
78
|
+
* branch resolves the target type's `'read'` policy via
|
|
79
|
+
* `policyContext.resolveForType` and AND-stitches it into the inner
|
|
80
|
+
* WHERE clause. Without this, traversals would bypass policies.
|
|
81
|
+
*/
|
|
82
|
+
policyContext?: PolicyContextBundle | null): string;
|
|
74
83
|
/**
|
|
75
84
|
* Parse a GraphQL selection set string into SelectionNode[].
|
|
76
85
|
*
|
|
@@ -110,6 +119,17 @@ export declare class SelectionCompiler {
|
|
|
110
119
|
* Compile a connection field into a Cypher pattern comprehension with edge properties.
|
|
111
120
|
*/
|
|
112
121
|
private compileConnection;
|
|
122
|
+
/**
|
|
123
|
+
* AND-combine an optional user where (`select.where` / `connectionWhere`)
|
|
124
|
+
* with the target type's `'read'` policy and emit a `WHERE …` fragment
|
|
125
|
+
* (with leading space). Returns `''` when nothing applies.
|
|
126
|
+
*
|
|
127
|
+
* The returned where lives inside a pattern comprehension so any
|
|
128
|
+
* `@cypher` field used by the policy MUST throw — the comprehension
|
|
129
|
+
* cannot host CALL { ... } subqueries. The error message points to the
|
|
130
|
+
* documented limitation.
|
|
131
|
+
*/
|
|
132
|
+
private compileNestedWhere;
|
|
113
133
|
/**
|
|
114
134
|
* Compile a __typename expression for a node.
|
|
115
135
|
* For union targets: picks the label matching a union member name.
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"selection.compiler.d.ts","sourceRoot":"","sources":["../../../src/compilers/selection.compiler.ts"],"names":[],"mappings":"AAUA,OAAO,KAAK,EAAE,cAAc,EAAE,cAAc,EAAE,MAAM,iBAAiB,CAAC;AAEtE,OAAO,EAAE,gBAAgB,EAAE,MAAM,kCAAkC,CAAC;AAOpE,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,kBAAkB,CAAC;AAEtD;;GAEG;AACH,MAAM,WAAW,aAAa;IAC5B,SAAS,EAAE,MAAM,CAAC;IAClB,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,QAAQ,EAAE,OAAO,CAAC;IAClB,cAAc,EAAE,OAAO,CAAC;IACxB,YAAY,EAAE,OAAO,CAAC;IACtB,QAAQ,CAAC,EAAE,aAAa,EAAE,CAAC;IAC3B,gFAAgF;IAChF,iBAAiB,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IAC5C,eAAe,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IAC1C,YAAY,CAAC,EAAE,aAAa,EAAE,CAAC;IAC/B,mFAAmF;IACnF,OAAO,CAAC,EAAE,KAAK,CAAC;QAAE,KAAK,EAAE,MAAM,CAAC;QAAC,SAAS,EAAE,KAAK,GAAG,MAAM,CAAA;KAAE,CAAC,CAAC;IAC9D;;;OAGG;IACH,iBAAiB,CAAC,EAAE,KAAK,CAAC;QACxB,KAAK,EAAE,MAAM,CAAC;QACd,SAAS,EAAE,KAAK,GAAG,MAAM,CAAC;QAC1B,KAAK,EAAE,MAAM,GAAG,MAAM,CAAC;KACxB,CAAC,CAAC;CACJ;AAED;;;;;GAKG;AACH,qBAAa,iBAAiB;IAW1B,OAAO,CAAC,MAAM;IAVhB,OAAO,CAAC,MAAM,CAAC,QAAQ,CAAC,iBAAiB,CAAK;IAC9C;;;;OAIG;IACH,OAAO,CAAC,UAAU,CAAsC;IACxD,OAAO,CAAC,aAAa,CAAC,CAAgB;gBAG5B,MAAM,EAAE,cAAc,EAC9B,aAAa,CAAC,EAAE,aAAa;IAK/B,8EAA8E;IAC9E,UAAU,IAAI,IAAI;IAIlB;;;;;;;;;OASG;IACH,OAAO,CACL,SAAS,EAAE,aAAa,EAAE,EAC1B,OAAO,EAAE,MAAM,EACf,OAAO,EAAE,cAAc,EACvB,QAAQ,GAAE,MAA4C,EACtD,YAAY,GAAE,MAAU,EACxB,MAAM,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAChC,YAAY,CAAC,EAAE;QAAE,KAAK,EAAE,MAAM,CAAA;KAAE;IAChC;;;;;;;OAOG;IACH,WAAW,GAAE,gBAAgB,GAAG,IAAW,
|
|
1
|
+
{"version":3,"file":"selection.compiler.d.ts","sourceRoot":"","sources":["../../../src/compilers/selection.compiler.ts"],"names":[],"mappings":"AAUA,OAAO,KAAK,EAAE,mBAAmB,EAAE,MAAM,iBAAiB,CAAC;AAC3D,OAAO,KAAK,EAAE,cAAc,EAAE,cAAc,EAAE,MAAM,iBAAiB,CAAC;AAEtE,OAAO,EAAE,gBAAgB,EAAE,MAAM,kCAAkC,CAAC;AAOpE,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,kBAAkB,CAAC;AAEtD;;GAEG;AACH,MAAM,WAAW,aAAa;IAC5B,SAAS,EAAE,MAAM,CAAC;IAClB,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,QAAQ,EAAE,OAAO,CAAC;IAClB,cAAc,EAAE,OAAO,CAAC;IACxB,YAAY,EAAE,OAAO,CAAC;IACtB,QAAQ,CAAC,EAAE,aAAa,EAAE,CAAC;IAC3B,gFAAgF;IAChF,iBAAiB,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IAC5C,eAAe,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IAC1C,YAAY,CAAC,EAAE,aAAa,EAAE,CAAC;IAC/B,mFAAmF;IACnF,OAAO,CAAC,EAAE,KAAK,CAAC;QAAE,KAAK,EAAE,MAAM,CAAC;QAAC,SAAS,EAAE,KAAK,GAAG,MAAM,CAAA;KAAE,CAAC,CAAC;IAC9D;;;OAGG;IACH,iBAAiB,CAAC,EAAE,KAAK,CAAC;QACxB,KAAK,EAAE,MAAM,CAAC;QACd,SAAS,EAAE,KAAK,GAAG,MAAM,CAAC;QAC1B,KAAK,EAAE,MAAM,GAAG,MAAM,CAAC;KACxB,CAAC,CAAC;CACJ;AAED;;;;;GAKG;AACH,qBAAa,iBAAiB;IAW1B,OAAO,CAAC,MAAM;IAVhB,OAAO,CAAC,MAAM,CAAC,QAAQ,CAAC,iBAAiB,CAAK;IAC9C;;;;OAIG;IACH,OAAO,CAAC,UAAU,CAAsC;IACxD,OAAO,CAAC,aAAa,CAAC,CAAgB;gBAG5B,MAAM,EAAE,cAAc,EAC9B,aAAa,CAAC,EAAE,aAAa;IAK/B,8EAA8E;IAC9E,UAAU,IAAI,IAAI;IAIlB;;;;;;;;;OASG;IACH,OAAO,CACL,SAAS,EAAE,aAAa,EAAE,EAC1B,OAAO,EAAE,MAAM,EACf,OAAO,EAAE,cAAc,EACvB,QAAQ,GAAE,MAA4C,EACtD,YAAY,GAAE,MAAU,EACxB,MAAM,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAChC,YAAY,CAAC,EAAE;QAAE,KAAK,EAAE,MAAM,CAAA;KAAE;IAChC;;;;;;;OAOG;IACH,WAAW,GAAE,gBAAgB,GAAG,IAAW;IAC3C;;;;;;OAMG;IACH,aAAa,GAAE,mBAAmB,GAAG,IAAW,GAC/C,MAAM;IAsHT;;;;;OAKG;IACH,iBAAiB,CAAC,YAAY,EAAE,MAAM,GAAG,aAAa,EAAE;IA8BxD;;OAEG;IACH,OAAO,CAAC,mBAAmB;IAgD3B;;OAEG;IACH,OAAO,CAAC,uBAAuB;IA0B/B;;OAEG;IACH,OAAO,CAAC,mBAAmB;IA0E3B;;;;;;;;;;;;;;OAcG;IACH,OAAO,CAAC,6BAA6B;IA8ErC;;OAEG;IACH,OAAO,CAAC,iBAAiB;IAiJzB;;;;;;;;;OASG;IACH,OAAO,CAAC,kBAAkB;IA2F1B;;;;OAIG;IACH,OAAO,CAAC,eAAe;IAoBvB;;;OAGG;IACH,OAAO,CAAC,kBAAkB;CAsC3B"}
|
|
@@ -45,7 +45,15 @@ class SelectionCompiler {
|
|
|
45
45
|
* throw — this is the case inside nested relationship pattern
|
|
46
46
|
* comprehensions where CALL { ... } subqueries are not allowed.
|
|
47
47
|
*/
|
|
48
|
-
cypherScope = null
|
|
48
|
+
cypherScope = null,
|
|
49
|
+
/**
|
|
50
|
+
* Policy context for nested-selection enforcement. When set, every
|
|
51
|
+
* relationship pattern comprehension, connection edge, and union
|
|
52
|
+
* branch resolves the target type's `'read'` policy via
|
|
53
|
+
* `policyContext.resolveForType` and AND-stitches it into the inner
|
|
54
|
+
* WHERE clause. Without this, traversals would bypass policies.
|
|
55
|
+
*/
|
|
56
|
+
policyContext = null) {
|
|
49
57
|
if (selection.length === 0)
|
|
50
58
|
return `${nodeVar} { .id }`;
|
|
51
59
|
// Check if this nodeDef is a resolved union type — if so, relationship
|
|
@@ -100,7 +108,7 @@ class SelectionCompiler {
|
|
|
100
108
|
continue;
|
|
101
109
|
}
|
|
102
110
|
if (node.isConnection) {
|
|
103
|
-
const connectionCypher = this.compileConnection(node, nodeVar, nodeDef, maxDepth, currentDepth, params, paramCounter);
|
|
111
|
+
const connectionCypher = this.compileConnection(node, nodeVar, nodeDef, maxDepth, currentDepth, params, paramCounter, policyContext);
|
|
104
112
|
if (connectionCypher)
|
|
105
113
|
parts.push(`${node.fieldName}: ${connectionCypher}`);
|
|
106
114
|
continue;
|
|
@@ -109,12 +117,12 @@ class SelectionCompiler {
|
|
|
109
117
|
// For union targets, relationship fields may have different relationship
|
|
110
118
|
// types across members. Generate CASE WHEN branches per member.
|
|
111
119
|
if (unionMembers && unionMembers.length > 0) {
|
|
112
|
-
const caseCypher = this.compileUnionRelationshipField(node, nodeVar, unionMembers, maxDepth, currentDepth, params, paramCounter);
|
|
120
|
+
const caseCypher = this.compileUnionRelationshipField(node, nodeVar, unionMembers, maxDepth, currentDepth, params, paramCounter, policyContext);
|
|
113
121
|
if (caseCypher)
|
|
114
122
|
parts.push(`${node.fieldName}: ${caseCypher}`);
|
|
115
123
|
continue;
|
|
116
124
|
}
|
|
117
|
-
const relCypher = this.compileRelationship(node, nodeVar, nodeDef, maxDepth, currentDepth, params, paramCounter);
|
|
125
|
+
const relCypher = this.compileRelationship(node, nodeVar, nodeDef, maxDepth, currentDepth, params, paramCounter, policyContext);
|
|
118
126
|
if (relCypher)
|
|
119
127
|
parts.push(`${node.fieldName}: ${relCypher}`);
|
|
120
128
|
continue;
|
|
@@ -220,7 +228,7 @@ class SelectionCompiler {
|
|
|
220
228
|
/**
|
|
221
229
|
* Compile a relationship field into a Cypher pattern comprehension.
|
|
222
230
|
*/
|
|
223
|
-
compileRelationship(node, parentVar, parentDef, maxDepth, currentDepth, params, paramCounter) {
|
|
231
|
+
compileRelationship(node, parentVar, parentDef, maxDepth, currentDepth, params, paramCounter, policyContext = null) {
|
|
224
232
|
if (currentDepth >= maxDepth)
|
|
225
233
|
return null;
|
|
226
234
|
const relDef = parentDef.relationships.get(node.fieldName);
|
|
@@ -238,26 +246,19 @@ class SelectionCompiler {
|
|
|
238
246
|
schema: this.schema,
|
|
239
247
|
});
|
|
240
248
|
const children = node.children ?? [];
|
|
241
|
-
const innerProjection = this.compile(children, childVar, targetDef, maxDepth, currentDepth + 1, params, paramCounter);
|
|
242
|
-
// Compile WHERE clause for relationship filtering (Prisma-like select)
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
throw new errors_1.OGMError(`Filtering nested-select relationship "${node.fieldName}" by an @cypher field is not supported. ` +
|
|
255
|
-
`@cypher fields can only appear in top-level WHERE filters, not inside select.where on relationships.`);
|
|
256
|
-
if (whereResult.cypher) {
|
|
257
|
-
(0, validation_1.mergeParams)(params, whereResult.params);
|
|
258
|
-
whereClause = ` WHERE ${whereResult.cypher}`;
|
|
259
|
-
}
|
|
260
|
-
}
|
|
249
|
+
const innerProjection = this.compile(children, childVar, targetDef, maxDepth, currentDepth + 1, params, paramCounter, null, policyContext);
|
|
250
|
+
// Compile WHERE clause for relationship filtering (Prisma-like select).
|
|
251
|
+
// Combines user `select.where` with the target type's `'read'` policy.
|
|
252
|
+
const whereClause = this.compileNestedWhere({
|
|
253
|
+
node,
|
|
254
|
+
childVar,
|
|
255
|
+
targetDef,
|
|
256
|
+
params,
|
|
257
|
+
paramCounter,
|
|
258
|
+
policyContext,
|
|
259
|
+
contextLabel: `relationship "${node.fieldName}"`,
|
|
260
|
+
userWhere: node.relationshipWhere,
|
|
261
|
+
});
|
|
261
262
|
const comprehension = `[${pattern}${whereClause} | ${innerProjection}]`;
|
|
262
263
|
// Wrap singular relationships with head()
|
|
263
264
|
if (!relDef.isArray)
|
|
@@ -291,7 +292,7 @@ class SelectionCompiler {
|
|
|
291
292
|
* ELSE []
|
|
292
293
|
* END
|
|
293
294
|
*/
|
|
294
|
-
compileUnionRelationshipField(node, nodeVar, unionMembers, maxDepth, currentDepth, params, paramCounter) {
|
|
295
|
+
compileUnionRelationshipField(node, nodeVar, unionMembers, maxDepth, currentDepth, params, paramCounter, policyContext = null) {
|
|
295
296
|
if (currentDepth >= maxDepth)
|
|
296
297
|
return null;
|
|
297
298
|
const branches = [];
|
|
@@ -311,7 +312,7 @@ class SelectionCompiler {
|
|
|
311
312
|
isSingular = true;
|
|
312
313
|
const childVar = `n${currentDepth}`;
|
|
313
314
|
const children = node.children ?? [];
|
|
314
|
-
const innerProjection = this.compile(children, childVar, targetDef, maxDepth, currentDepth + 1, params, paramCounter);
|
|
315
|
+
const innerProjection = this.compile(children, childVar, targetDef, maxDepth, currentDepth + 1, params, paramCounter, null, policyContext);
|
|
315
316
|
const pattern = (0, utils_1.buildRelPattern)({
|
|
316
317
|
sourceVar: nodeVar,
|
|
317
318
|
relDef,
|
|
@@ -319,10 +320,23 @@ class SelectionCompiler {
|
|
|
319
320
|
targetLabel: 'auto',
|
|
320
321
|
schema: this.schema,
|
|
321
322
|
});
|
|
323
|
+
// Inject the union member's `'read'` policy into the comprehension WHERE.
|
|
324
|
+
const memberPolicyWhere = this.compileNestedWhere({
|
|
325
|
+
node,
|
|
326
|
+
childVar,
|
|
327
|
+
targetDef,
|
|
328
|
+
params,
|
|
329
|
+
paramCounter,
|
|
330
|
+
policyContext,
|
|
331
|
+
contextLabel: `union member "${memberName}" of "${node.fieldName}"`,
|
|
332
|
+
// Union branches don't have a user select.where in the
|
|
333
|
+
// SelectionNode model — only policies contribute here.
|
|
334
|
+
userWhere: undefined,
|
|
335
|
+
});
|
|
322
336
|
// Wrap singular relationships with head() to return object instead of array
|
|
323
337
|
const comprehension = isSingular
|
|
324
|
-
? `head([${pattern} | ${innerProjection}])`
|
|
325
|
-
: `[${pattern} | ${innerProjection}]`;
|
|
338
|
+
? `head([${pattern}${memberPolicyWhere} | ${innerProjection}])`
|
|
339
|
+
: `[${pattern}${memberPolicyWhere} | ${innerProjection}]`;
|
|
326
340
|
const memberLabel = (0, validation_1.escapeIdentifier)(memberName);
|
|
327
341
|
branches.push(`WHEN ${nodeVar}:${memberLabel} THEN ${comprehension}`);
|
|
328
342
|
}
|
|
@@ -334,7 +348,7 @@ class SelectionCompiler {
|
|
|
334
348
|
/**
|
|
335
349
|
* Compile a connection field into a Cypher pattern comprehension with edge properties.
|
|
336
350
|
*/
|
|
337
|
-
compileConnection(node, parentVar, parentDef, maxDepth, currentDepth, params, paramCounter) {
|
|
351
|
+
compileConnection(node, parentVar, parentDef, maxDepth, currentDepth, params, paramCounter, policyContext = null) {
|
|
338
352
|
if (currentDepth >= maxDepth)
|
|
339
353
|
return null;
|
|
340
354
|
// Strip 'Connection' suffix to find the relationship field
|
|
@@ -359,7 +373,7 @@ class SelectionCompiler {
|
|
|
359
373
|
const projectionKeys = [];
|
|
360
374
|
// Compile node projection
|
|
361
375
|
if (node.children && node.children.length > 0) {
|
|
362
|
-
const nodeProjection = this.compile(node.children, childVar, targetDef, maxDepth, currentDepth + 1, params, paramCounter);
|
|
376
|
+
const nodeProjection = this.compile(node.children, childVar, targetDef, maxDepth, currentDepth + 1, params, paramCounter, null, policyContext);
|
|
363
377
|
mapParts.push(`node: ${nodeProjection}`);
|
|
364
378
|
projectionKeys.push('node');
|
|
365
379
|
}
|
|
@@ -386,7 +400,8 @@ class SelectionCompiler {
|
|
|
386
400
|
sortKeyTokens.push(direction === 'ASC' ? `'^${syntheticKey}'` : `'${syntheticKey}'`);
|
|
387
401
|
});
|
|
388
402
|
const innerMap = `{ ${mapParts.join(', ')} }`;
|
|
389
|
-
// Add WHERE clause if connectionWhere is present
|
|
403
|
+
// Add WHERE clause if connectionWhere is present, AND-combined with the
|
|
404
|
+
// target type's `'read'` policy.
|
|
390
405
|
let whereClause = '';
|
|
391
406
|
if (node.connectionWhere && Object.keys(node.connectionWhere).length > 0) {
|
|
392
407
|
// Reject unsupported edge WHERE filters with a descriptive error
|
|
@@ -398,23 +413,35 @@ class SelectionCompiler {
|
|
|
398
413
|
? node.connectionWhere.node
|
|
399
414
|
: node.connectionWhere;
|
|
400
415
|
// If we have a full WhereCompiler, delegate to it for complete operator support
|
|
401
|
-
if (this.whereCompiler && targetDef)
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
416
|
+
if (this.whereCompiler && targetDef)
|
|
417
|
+
whereClause = this.compileNestedWhere({
|
|
418
|
+
node,
|
|
419
|
+
childVar,
|
|
420
|
+
targetDef,
|
|
421
|
+
params,
|
|
422
|
+
paramCounter,
|
|
423
|
+
policyContext,
|
|
424
|
+
contextLabel: `connection "${node.fieldName}"`,
|
|
425
|
+
userWhere: nodeWhere,
|
|
426
|
+
});
|
|
412
427
|
else {
|
|
413
428
|
const simpleWhere = this.compileSimpleWhere(node.connectionWhere, childVar, params, paramCounter);
|
|
414
429
|
if (simpleWhere)
|
|
415
430
|
whereClause = ` WHERE ${simpleWhere}`;
|
|
416
431
|
}
|
|
417
432
|
}
|
|
433
|
+
else if (this.whereCompiler && targetDef)
|
|
434
|
+
// No user where but we still need the policy injected if active.
|
|
435
|
+
whereClause = this.compileNestedWhere({
|
|
436
|
+
node,
|
|
437
|
+
childVar,
|
|
438
|
+
targetDef,
|
|
439
|
+
params,
|
|
440
|
+
paramCounter,
|
|
441
|
+
policyContext,
|
|
442
|
+
contextLabel: `connection "${node.fieldName}"`,
|
|
443
|
+
userWhere: undefined,
|
|
444
|
+
});
|
|
418
445
|
if (sortKeyTokens.length === 0)
|
|
419
446
|
return `{ edges: [${pattern}${whereClause} | ${innerMap}] }`;
|
|
420
447
|
// Re-project only the user-facing keys so synthetic __skN scalars don't
|
|
@@ -424,6 +451,71 @@ class SelectionCompiler {
|
|
|
424
451
|
: `{}`;
|
|
425
452
|
return `{ edges: [x IN apoc.coll.sortMulti([${pattern}${whereClause} | ${innerMap}], [${sortKeyTokens.join(', ')}]) | ${outerProjection}] }`;
|
|
426
453
|
}
|
|
454
|
+
/**
|
|
455
|
+
* AND-combine an optional user where (`select.where` / `connectionWhere`)
|
|
456
|
+
* with the target type's `'read'` policy and emit a `WHERE …` fragment
|
|
457
|
+
* (with leading space). Returns `''` when nothing applies.
|
|
458
|
+
*
|
|
459
|
+
* The returned where lives inside a pattern comprehension so any
|
|
460
|
+
* `@cypher` field used by the policy MUST throw — the comprehension
|
|
461
|
+
* cannot host CALL { ... } subqueries. The error message points to the
|
|
462
|
+
* documented limitation.
|
|
463
|
+
*/
|
|
464
|
+
compileNestedWhere(args) {
|
|
465
|
+
const { node, childVar, targetDef, params, paramCounter, policyContext, contextLabel, userWhere, } = args;
|
|
466
|
+
if (!this.whereCompiler || !params || !paramCounter) {
|
|
467
|
+
// Without these, the policy can't be compiled — fall back to user-only
|
|
468
|
+
// (matches v1.6.0 behavior when WhereCompiler isn't wired).
|
|
469
|
+
if (userWhere && Object.keys(userWhere).length > 0)
|
|
470
|
+
// We can't compile via whereCompiler without paramCounter; let
|
|
471
|
+
// the fallback simple where path handle it.
|
|
472
|
+
return '';
|
|
473
|
+
return '';
|
|
474
|
+
}
|
|
475
|
+
// Resolve the target type's read policy (if any).
|
|
476
|
+
const targetPolicy = policyContext
|
|
477
|
+
? policyContext.resolveForType(targetDef.typeName, 'read')
|
|
478
|
+
: null;
|
|
479
|
+
const targetBundle = targetPolicy
|
|
480
|
+
? {
|
|
481
|
+
ctx: policyContext.ctx,
|
|
482
|
+
operation: 'read',
|
|
483
|
+
resolved: targetPolicy,
|
|
484
|
+
resolveForType: policyContext.resolveForType,
|
|
485
|
+
defaults: policyContext.defaults,
|
|
486
|
+
}
|
|
487
|
+
: undefined;
|
|
488
|
+
// Compile combined user where + policy in one pass.
|
|
489
|
+
const compiled = this.whereCompiler.compile(userWhere, childVar, targetDef, paramCounter, targetBundle ? { policyContext: targetBundle } : undefined);
|
|
490
|
+
// Pattern comprehensions cannot host CALL { ... } subqueries.
|
|
491
|
+
if (compiled.preludes && compiled.preludes.length > 0) {
|
|
492
|
+
// Two distinct cases:
|
|
493
|
+
// - user where references @cypher → original v1.6.0 error path
|
|
494
|
+
// - policy references @cypher inside nested selection → new path
|
|
495
|
+
const policyHasCypher = targetBundle &&
|
|
496
|
+
compiled.cypher.length > 0 &&
|
|
497
|
+
// Heuristic: if the user where alone produces no cypher refs but
|
|
498
|
+
// adding policy did, then policy is the source. We use a clearer
|
|
499
|
+
// policy-targeted error since both paths are equivalent for the
|
|
500
|
+
// user.
|
|
501
|
+
true;
|
|
502
|
+
if (policyHasCypher && targetBundle)
|
|
503
|
+
throw new errors_1.OGMError(`Policy on "${targetDef.typeName}" requires @cypher field projection, ` +
|
|
504
|
+
`which is not supported inside nested-selection enforcement (${contextLabel}). ` +
|
|
505
|
+
`Refactor the policy to use stored properties or a relationship traversal.`);
|
|
506
|
+
throw new errors_1.OGMError(`Filtering ${contextLabel} by an @cypher field is not supported. ` +
|
|
507
|
+
`@cypher fields can only appear in top-level WHERE filters, not inside select.where on relationships.`);
|
|
508
|
+
}
|
|
509
|
+
if (compiled.cypher) {
|
|
510
|
+
(0, validation_1.mergeParams)(params, compiled.params);
|
|
511
|
+
return ` WHERE ${compiled.cypher}`;
|
|
512
|
+
}
|
|
513
|
+
// Compile produced no clause but may still have merged params.
|
|
514
|
+
if (compiled.params && Object.keys(compiled.params).length > 0)
|
|
515
|
+
(0, validation_1.mergeParams)(params, compiled.params);
|
|
516
|
+
void node;
|
|
517
|
+
return '';
|
|
518
|
+
}
|
|
427
519
|
/**
|
|
428
520
|
* Compile a __typename expression for a node.
|
|
429
521
|
* For union targets: picks the label matching a union member name.
|