@cerbos/orm-prisma 4.0.0 → 5.0.0
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/README.md +611 -460
- package/lib/collections.d.ts +30 -0
- package/lib/collections.d.ts.map +1 -0
- package/lib/collections.js +421 -0
- package/lib/collections.js.map +1 -0
- package/lib/comparison.d.ts +24 -0
- package/lib/comparison.d.ts.map +1 -0
- package/lib/comparison.js +731 -0
- package/lib/comparison.js.map +1 -0
- package/lib/errors.d.ts +13 -0
- package/lib/errors.d.ts.map +1 -0
- package/lib/errors.js +17 -0
- package/lib/errors.js.map +1 -0
- package/lib/evaluate.d.ts +5 -0
- package/lib/evaluate.d.ts.map +1 -0
- package/lib/evaluate.js +118 -0
- package/lib/evaluate.js.map +1 -0
- package/lib/fields.d.ts +34 -0
- package/lib/fields.d.ts.map +1 -0
- package/lib/fields.js +253 -0
- package/lib/fields.js.map +1 -0
- package/lib/functions.d.ts +5 -0
- package/lib/functions.d.ts.map +1 -0
- package/lib/functions.js +408 -0
- package/lib/functions.js.map +1 -0
- package/lib/hierarchy.d.ts +6 -0
- package/lib/hierarchy.d.ts.map +1 -0
- package/lib/hierarchy.js +266 -0
- package/lib/hierarchy.js.map +1 -0
- package/lib/index.d.ts +91 -8
- package/lib/index.d.ts.map +1 -1
- package/lib/index.js +34 -2301
- package/lib/index.js.map +1 -1
- package/lib/literals.d.ts +20 -0
- package/lib/literals.d.ts.map +1 -0
- package/lib/literals.js +290 -0
- package/lib/literals.js.map +1 -0
- package/lib/mapping.d.ts +95 -0
- package/lib/mapping.d.ts.map +1 -0
- package/lib/mapping.js +316 -0
- package/lib/mapping.js.map +1 -0
- package/lib/narrowing.d.ts +12 -0
- package/lib/narrowing.d.ts.map +1 -0
- package/lib/narrowing.js +32 -0
- package/lib/narrowing.js.map +1 -0
- package/lib/plan.d.ts +49 -0
- package/lib/plan.d.ts.map +1 -0
- package/lib/plan.js +86 -0
- package/lib/plan.js.map +1 -0
- package/lib/presence.d.ts +25 -0
- package/lib/presence.d.ts.map +1 -0
- package/lib/presence.js +82 -0
- package/lib/presence.js.map +1 -0
- package/lib/regex.d.ts +33 -0
- package/lib/regex.d.ts.map +1 -0
- package/lib/regex.js +480 -0
- package/lib/regex.js.map +1 -0
- package/lib/relations.d.ts +58 -0
- package/lib/relations.d.ts.map +1 -0
- package/lib/relations.js +175 -0
- package/lib/relations.js.map +1 -0
- package/lib/rewrite.d.ts +36 -0
- package/lib/rewrite.d.ts.map +1 -0
- package/lib/rewrite.js +366 -0
- package/lib/rewrite.js.map +1 -0
- package/lib/solve.d.ts +20 -0
- package/lib/solve.d.ts.map +1 -0
- package/lib/solve.js +77 -0
- package/lib/solve.js.map +1 -0
- package/lib/strings.d.ts +12 -0
- package/lib/strings.d.ts.map +1 -0
- package/lib/strings.js +110 -0
- package/lib/strings.js.map +1 -0
- package/lib/ternary.d.ts +12 -0
- package/lib/ternary.d.ts.map +1 -0
- package/lib/ternary.js +192 -0
- package/lib/ternary.js.map +1 -0
- package/lib/timestamp.d.ts +45 -0
- package/lib/timestamp.d.ts.map +1 -0
- package/lib/timestamp.js +225 -0
- package/lib/timestamp.js.map +1 -0
- package/lib/translate.d.ts +20 -0
- package/lib/translate.d.ts.map +1 -0
- package/lib/translate.js +287 -0
- package/lib/translate.js.map +1 -0
- package/lib/types.d.ts +29 -0
- package/lib/types.d.ts.map +1 -0
- package/lib/types.js +980 -0
- package/lib/types.js.map +1 -0
- package/lib/value.d.ts +8 -0
- package/lib/value.d.ts.map +1 -0
- package/lib/value.js +7 -0
- package/lib/value.js.map +1 -0
- package/package.json +31 -18
- package/src/adversarial.test.ts +488 -488
- package/src/collections.ts +738 -0
- package/src/comparison.ts +1157 -0
- package/src/corpus.ts +597 -0
- package/src/errors.ts +12 -0
- package/src/evaluate.ts +170 -0
- package/src/fields.ts +373 -0
- package/src/functions.ts +548 -0
- package/src/generated/prisma-adversarial/browser.ts +20 -0
- package/src/generated/prisma-adversarial/client.ts +20 -0
- package/src/generated/prisma-adversarial/commonInputTypes.ts +116 -127
- package/src/generated/prisma-adversarial/internal/class.ts +44 -4
- package/src/generated/prisma-adversarial/internal/prismaNamespace.ts +350 -3
- package/src/generated/prisma-adversarial/internal/prismaNamespaceBrowser.ts +49 -2
- package/src/generated/prisma-adversarial/models/AdversarialBoolListElement.ts +1272 -0
- package/src/generated/prisma-adversarial/models/AdversarialInner.ts +1369 -0
- package/src/generated/prisma-adversarial/models/AdversarialNumberListElement.ts +1316 -0
- package/src/generated/prisma-adversarial/models/AdversarialParent.ts +1474 -0
- package/src/generated/prisma-adversarial/models/AdversarialResource.ts +547 -75
- package/src/generated/prisma-adversarial/models.ts +4 -0
- package/src/generated/prisma-adversarial-mysql/browser.ts +64 -0
- package/src/generated/{prisma → prisma-adversarial-mysql}/client.ts +23 -18
- package/src/generated/{prisma → prisma-adversarial-mysql}/commonInputTypes.ts +167 -92
- package/src/generated/prisma-adversarial-mysql/internal/class.ts +284 -0
- package/src/generated/prisma-adversarial-mysql/internal/prismaNamespace.ts +1518 -0
- package/src/generated/prisma-adversarial-mysql/internal/prismaNamespaceBrowser.ts +274 -0
- package/src/generated/prisma-adversarial-mysql/models/AdversarialBoolListElement.ts +1157 -0
- package/src/generated/prisma-adversarial-mysql/models/AdversarialCategory.ts +1286 -0
- package/src/generated/prisma-adversarial-mysql/models/AdversarialInner.ts +1247 -0
- package/src/generated/prisma-adversarial-mysql/models/AdversarialLabel.ts +1157 -0
- package/src/generated/prisma-adversarial-mysql/models/AdversarialNumberListElement.ts +1201 -0
- package/src/generated/prisma-adversarial-mysql/models/AdversarialParent.ts +1352 -0
- package/src/generated/prisma-adversarial-mysql/models/AdversarialResource.ts +2021 -0
- package/src/generated/prisma-adversarial-mysql/models/AdversarialSubCategory.ts +1286 -0
- package/src/generated/prisma-adversarial-mysql/models/AdversarialTag.ts +1190 -0
- package/src/generated/prisma-adversarial-mysql/models.ts +20 -0
- package/src/generated/prisma-adversarial-mysql-v6/index.d.ts +13822 -0
- package/src/generated/prisma-adversarial-pg/browser.ts +64 -0
- package/src/generated/prisma-adversarial-pg/client.ts +86 -0
- package/src/generated/prisma-adversarial-pg/commonInputTypes.ts +345 -0
- package/src/generated/prisma-adversarial-pg/enums.ts +15 -0
- package/src/generated/prisma-adversarial-pg/internal/class.ts +284 -0
- package/src/generated/prisma-adversarial-pg/internal/prismaNamespace.ts +1542 -0
- package/src/generated/{prisma → prisma-adversarial-pg}/internal/prismaNamespaceBrowser.ts +72 -43
- package/src/generated/prisma-adversarial-pg/models/AdversarialBoolListElement.ts +1275 -0
- package/src/generated/prisma-adversarial-pg/models/AdversarialCategory.ts +1404 -0
- package/src/generated/prisma-adversarial-pg/models/AdversarialInner.ts +1371 -0
- package/src/generated/prisma-adversarial-pg/models/AdversarialLabel.ts +1275 -0
- package/src/generated/prisma-adversarial-pg/models/AdversarialNumberListElement.ts +1319 -0
- package/src/generated/prisma-adversarial-pg/models/AdversarialParent.ts +1476 -0
- package/src/generated/prisma-adversarial-pg/models/AdversarialResource.ts +2139 -0
- package/src/generated/prisma-adversarial-pg/models/AdversarialSubCategory.ts +1404 -0
- package/src/generated/prisma-adversarial-pg/models/AdversarialTag.ts +1310 -0
- package/src/generated/prisma-adversarial-pg/models.ts +20 -0
- package/src/generated/prisma-adversarial-pg-v6/client.d.ts +1 -0
- package/src/generated/prisma-adversarial-pg-v6/default.d.ts +1 -0
- package/src/generated/prisma-adversarial-pg-v6/edge.d.ts +1 -0
- package/src/generated/prisma-adversarial-pg-v6/index.d.ts +14918 -0
- package/src/generated/prisma-adversarial-pg-v6/runtime/index-browser.d.ts +370 -0
- package/src/generated/prisma-adversarial-pg-v6/runtime/library.d.ts +3982 -0
- package/src/generated/prisma-adversarial-pg-v6/wasm.d.ts +1 -0
- package/src/generated/prisma-adversarial-v6/index.d.ts +6515 -304
- package/src/hierarchy.ts +405 -0
- package/src/index.ts +134 -3325
- package/src/literals.ts +363 -0
- package/src/mapping.ts +474 -0
- package/src/narrowing.ts +33 -0
- package/src/plan.ts +125 -0
- package/src/presence.ts +114 -0
- package/src/regex.ts +596 -0
- package/src/relations.ts +226 -0
- package/src/rewrite.ts +524 -0
- package/src/solve.ts +96 -0
- package/src/strings.ts +152 -0
- package/src/ternary.ts +359 -0
- package/src/test-setup.adversarial-url.ts +21 -0
- package/src/test-setup.adversarial.mysql.v6.ts +13 -0
- package/src/test-setup.adversarial.mysql.v7.ts +12 -0
- package/src/test-setup.adversarial.pg.v6.ts +13 -0
- package/src/test-setup.adversarial.pg.v7.ts +9 -0
- package/src/test-setup.adversarial.v7.ts +1 -0
- package/src/timestamp.ts +308 -0
- package/src/translate.ts +419 -0
- package/src/translator.test.ts +766 -0
- package/src/types.ts +1310 -0
- package/src/value.ts +16 -0
- package/src/generated/prisma/browser.ts +0 -59
- package/src/generated/prisma/internal/class.ts +0 -274
- package/src/generated/prisma/internal/prismaNamespace.ts +0 -1403
- package/src/generated/prisma/models/Category.ts +0 -1412
- package/src/generated/prisma/models/Label.ts +0 -1276
- package/src/generated/prisma/models/NestedResource.ts +0 -1562
- package/src/generated/prisma/models/NextLevelNestedResource.ts +0 -1337
- package/src/generated/prisma/models/Resource.ts +0 -2196
- package/src/generated/prisma/models/SubCategory.ts +0 -1412
- package/src/generated/prisma/models/Tag.ts +0 -1276
- package/src/generated/prisma/models/User.ts +0 -1503
- package/src/generated/prisma/models.ts +0 -19
- package/src/generated/prisma-v6/index.d.ts +0 -13726
- package/src/index.test.ts +0 -8885
- package/src/test-setup.v6.ts +0 -5
- package/src/test-setup.v7.ts +0 -7
- /package/src/generated/{prisma → prisma-adversarial-mysql}/enums.ts +0 -0
- /package/src/generated/{prisma-v6 → prisma-adversarial-mysql-v6}/client.d.ts +0 -0
- /package/src/generated/{prisma-v6 → prisma-adversarial-mysql-v6}/default.d.ts +0 -0
- /package/src/generated/{prisma-v6 → prisma-adversarial-mysql-v6}/edge.d.ts +0 -0
- /package/src/generated/{prisma-v6 → prisma-adversarial-mysql-v6}/runtime/index-browser.d.ts +0 -0
- /package/src/generated/{prisma-v6 → prisma-adversarial-mysql-v6}/runtime/library.d.ts +0 -0
- /package/src/generated/{prisma-v6 → prisma-adversarial-mysql-v6}/wasm.d.ts +0 -0
|
@@ -0,0 +1,766 @@
|
|
|
1
|
+
import { describe, expect, test } from "@jest/globals";
|
|
2
|
+
import type { JsonValue } from "@bufbuild/protobuf";
|
|
3
|
+
import type {
|
|
4
|
+
PlanExpressionOperand,
|
|
5
|
+
PlanResourcesResponse,
|
|
6
|
+
Value,
|
|
7
|
+
} from "@cerbos/core";
|
|
8
|
+
|
|
9
|
+
import { queryPlanToPrisma, PlanKind, UnsupportedQueryPlanError } from ".";
|
|
10
|
+
import type {
|
|
11
|
+
Mapper,
|
|
12
|
+
MapperConfig,
|
|
13
|
+
NullAttributeRepresentation,
|
|
14
|
+
PrismaFilter,
|
|
15
|
+
QueryPlanToPrismaResult,
|
|
16
|
+
} from ".";
|
|
17
|
+
import { MAPPER, MODEL, pdpTags, planOf, readGolden, readGoldens } from "./corpus";
|
|
18
|
+
import { isObject } from "./narrowing";
|
|
19
|
+
|
|
20
|
+
type Relation = NonNullable<MapperConfig["relation"]>;
|
|
21
|
+
|
|
22
|
+
/** `response` with `field` replaced by a value its type rules out: a plan the SDK never decodes. */
|
|
23
|
+
const overwritten = (response: PlanResourcesResponse, field: string, value: Value): PlanResourcesResponse =>
|
|
24
|
+
Object.defineProperty({ ...response }, field, { value, enumerable: true });
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Offline unit tests: what a caller can pass that the conformance corpus cannot vary (mapper forms,
|
|
28
|
+
* `subqueryFilter`, element nullability, `nullAttributeRepresentation`), the refusal type, the
|
|
29
|
+
* timestamp literal contract, and plans the planner cannot produce.
|
|
30
|
+
*
|
|
31
|
+
* Which rows a corpus case returns is the conformance harness's job (`adversarial.test.ts`); nothing
|
|
32
|
+
* here pins the filter emitted for a corpus case. Plans come from the current PDP's golden files,
|
|
33
|
+
* read by case id, rather than being typed by hand.
|
|
34
|
+
*/
|
|
35
|
+
|
|
36
|
+
const CURRENT = pdpTags()[0]!;
|
|
37
|
+
|
|
38
|
+
/** The current PDP's recorded plan for a corpus case, `__NOW_MINUS_24H__` filled as `now`. */
|
|
39
|
+
function planFor(id: string, now?: string): Promise<PlanResourcesResponse> {
|
|
40
|
+
return planOf(readGolden(CURRENT, id), now);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
async function translate(
|
|
44
|
+
id: string,
|
|
45
|
+
options: {
|
|
46
|
+
mapper?: Mapper;
|
|
47
|
+
nullAttributeRepresentation?: NullAttributeRepresentation;
|
|
48
|
+
} = {}
|
|
49
|
+
): Promise<QueryPlanToPrismaResult> {
|
|
50
|
+
return queryPlanToPrisma({
|
|
51
|
+
queryPlan: await planFor(id),
|
|
52
|
+
mapper: options.mapper ?? MAPPER,
|
|
53
|
+
model: MODEL,
|
|
54
|
+
// Varied explicitly below; the adapter's own default is pinned under "nullAttributeRepresentation".
|
|
55
|
+
nullAttributeRepresentation: options.nullAttributeRepresentation ?? "explicit",
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
describe("the refusal type", () => {
|
|
60
|
+
test("an untranslatable shape raises UnsupportedQueryPlanError, which is an Error", async () => {
|
|
61
|
+
let raised: unknown;
|
|
62
|
+
|
|
63
|
+
try {
|
|
64
|
+
await translate("collection/index/first-element-of-string-list");
|
|
65
|
+
} catch (error) {
|
|
66
|
+
raised = error;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
expect(raised).toBeInstanceOf(UnsupportedQueryPlanError);
|
|
70
|
+
expect(raised).toBeInstanceOf(Error);
|
|
71
|
+
expect(raised).toMatchObject({ name: "UnsupportedQueryPlanError" });
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test("mapper misconfiguration is a plain Error, not a refusal", async () => {
|
|
75
|
+
const queryPlan = await planFor("comparison/equals/field-to-field");
|
|
76
|
+
let raised: unknown;
|
|
77
|
+
|
|
78
|
+
try {
|
|
79
|
+
queryPlanToPrisma({ queryPlan, mapper: MAPPER });
|
|
80
|
+
} catch (error) {
|
|
81
|
+
raised = error;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
expect(raised).toBeInstanceOf(Error);
|
|
85
|
+
expect(raised).not.toBeInstanceOf(UnsupportedQueryPlanError);
|
|
86
|
+
expect(raised).toHaveProperty("message", expect.stringContaining("requires the `model` option"));
|
|
87
|
+
});
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
describe("declared scalar types", () => {
|
|
91
|
+
test("an undeclared type preserves the legacy mapper contract", async () => {
|
|
92
|
+
expect(
|
|
93
|
+
await translate("type-mismatch/equals/string-field-against-number-principal", {
|
|
94
|
+
mapper: { "request.resource.attr.aString": { field: "aString" } },
|
|
95
|
+
}),
|
|
96
|
+
).toEqual({
|
|
97
|
+
kind: PlanKind.CONDITIONAL,
|
|
98
|
+
filters: { aString: { equals: 0 } },
|
|
99
|
+
});
|
|
100
|
+
});
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
/** The shared mapper with every per-attribute null declaration stripped. */
|
|
104
|
+
const UNDECLARED: Record<string, MapperConfig> = Object.fromEntries(
|
|
105
|
+
Object.entries(MAPPER).map(([reference, config]) => {
|
|
106
|
+
const { nullAttributeRepresentation: _stripped, ...rest } = config;
|
|
107
|
+
|
|
108
|
+
return [reference, rest];
|
|
109
|
+
})
|
|
110
|
+
);
|
|
111
|
+
|
|
112
|
+
/** Whether any operand anywhere in the wire plan is a literal null, or a list containing one. */
|
|
113
|
+
function carriesNullLiteral(node: JsonValue | undefined): boolean {
|
|
114
|
+
if (Array.isArray(node)) return node.some(carriesNullLiteral);
|
|
115
|
+
|
|
116
|
+
if (!isObject(node)) return false;
|
|
117
|
+
const record = node;
|
|
118
|
+
|
|
119
|
+
if ("value" in record) {
|
|
120
|
+
const value = record["value"];
|
|
121
|
+
|
|
122
|
+
return value === null || (Array.isArray(value) && value.includes(null));
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
return Object.values(record).some((child) =>
|
|
126
|
+
Array.isArray(child) ? child.some(carriesNullLiteral) : carriesNullLiteral(child)
|
|
127
|
+
);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
describe("nullAttributeRepresentation", () => {
|
|
131
|
+
// `== null` against an attribute the caller OMITS when the column is NULL. The two conventions
|
|
132
|
+
// are indistinguishable on the wire — the planner emits the same `eq(attr, null)` either way — so
|
|
133
|
+
// the adapter has to be told. The corpus mapping declares it per attribute; these vary the
|
|
134
|
+
// call-level option and the declaration, which the corpus cannot.
|
|
135
|
+
const MISSING = "null/equals/null-literal-on-missing-attribute";
|
|
136
|
+
const NEVER = { kind: PlanKind.CONDITIONAL, filters: { NOT: { aOptionalString: { startsWith: "" } } } };
|
|
137
|
+
|
|
138
|
+
test("unset: the default is omitted, so == null selects no row", async () => {
|
|
139
|
+
// The default is the convention whose wrong guess fails closed (ADR 0011).
|
|
140
|
+
expect(
|
|
141
|
+
queryPlanToPrisma({ queryPlan: await planFor(MISSING), mapper: UNDECLARED, model: MODEL })
|
|
142
|
+
).toEqual(NEVER);
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
test("explicit: == null is an IS NULL filter", async () => {
|
|
146
|
+
expect(await translate(MISSING, { mapper: UNDECLARED })).toStrictEqual({
|
|
147
|
+
kind: PlanKind.CONDITIONAL,
|
|
148
|
+
filters: { aOptionalString: { equals: null } },
|
|
149
|
+
});
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
test("omitted: the same plan selects no row rather than the NULL ones", async () => {
|
|
153
|
+
// A NULL column sends no attribute, so check() denies on a missing-attribute error while the
|
|
154
|
+
// IS NULL filter would return exactly those rows (#302); a present value is never null, so
|
|
155
|
+
// `== null` is FALSE for it — `NOT LIKE '%'`, which stays UNKNOWN on the NULL rows.
|
|
156
|
+
expect(
|
|
157
|
+
await translate(MISSING, { mapper: UNDECLARED, nullAttributeRepresentation: "omitted" })
|
|
158
|
+
).toEqual(NEVER);
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
test("a per-attribute declaration overrides the call-level option, in both directions", async () => {
|
|
162
|
+
// Declared omitted, called explicit: no IS NULL filter (#308).
|
|
163
|
+
expect(await translate(MISSING)).toEqual(NEVER);
|
|
164
|
+
// Declared explicit (`owner`), called omitted: translated.
|
|
165
|
+
expect(
|
|
166
|
+
(await translate("null/equals/null-literal", { nullAttributeRepresentation: "omitted" })).kind
|
|
167
|
+
).toBe(PlanKind.CONDITIONAL);
|
|
168
|
+
// Strip the declaration and the same call selects no row.
|
|
169
|
+
expect(
|
|
170
|
+
await translate("null/equals/null-literal", {
|
|
171
|
+
mapper: UNDECLARED,
|
|
172
|
+
nullAttributeRepresentation: "omitted",
|
|
173
|
+
})
|
|
174
|
+
).toEqual(NEVER);
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
// The rejection must key off the null OPERAND, not a list of operators: `hasIntersection(tagNames,
|
|
178
|
+
// ["public", null])` carries one in its value list. Enumerating the goldens rather than naming
|
|
179
|
+
// shapes covers a newly added case carrying a null constant automatically.
|
|
180
|
+
// A plan whose null comparison settles passes too: `x == null` is FALSE for a present value and
|
|
181
|
+
// an error for a missing one, rendered with presence tests that are UNKNOWN on NULL. Any
|
|
182
|
+
// NULL-selecting filter needs an IS NULL leaf, i.e. a null literal in the emitted where-input.
|
|
183
|
+
test("no plan carrying a null literal emits an IS NULL filter under call-level omitted", async () => {
|
|
184
|
+
const carrying = readGoldens(CURRENT).filter((golden) => carriesNullLiteral(golden.plan));
|
|
185
|
+
const ids = carrying.map((golden) => golden.id);
|
|
186
|
+
expect(ids).toContain(MISSING);
|
|
187
|
+
expect(ids).toContain("null/has-intersection/literal-list-with-null-element");
|
|
188
|
+
|
|
189
|
+
const plans = await Promise.all(carrying.map((golden) => planOf(golden)));
|
|
190
|
+
|
|
191
|
+
const notRejected = carrying.flatMap((golden, index) => {
|
|
192
|
+
try {
|
|
193
|
+
const result = queryPlanToPrisma({
|
|
194
|
+
queryPlan: plans[index]!,
|
|
195
|
+
mapper: UNDECLARED,
|
|
196
|
+
model: MODEL,
|
|
197
|
+
nullAttributeRepresentation: "omitted",
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
return result.kind === PlanKind.CONDITIONAL && JSON.stringify(result.filters).includes("null")
|
|
201
|
+
? [golden.id]
|
|
202
|
+
: [];
|
|
203
|
+
} catch (error) {
|
|
204
|
+
// A positional read of a list compares an element, not an optionally absent field; the
|
|
205
|
+
// index operator has no Prisma filter form under either representation. A null inside a
|
|
206
|
+
// whole-list literal is an element too, and comparing a list is refused under either, as
|
|
207
|
+
// is an `in` over a computed member. A null arithmetic operand has no CEL overload, so its
|
|
208
|
+
// fold is refused under either.
|
|
209
|
+
const message = String(error);
|
|
210
|
+
|
|
211
|
+
return message.includes("missing-attribute error") ||
|
|
212
|
+
message.includes("Unsupported operator: index") ||
|
|
213
|
+
message.includes("in requires a field member and literal list") ||
|
|
214
|
+
message.includes("Prisma cannot compare list or map elements") ||
|
|
215
|
+
message.includes("in over a scalar is a CEL error") ||
|
|
216
|
+
message.includes("operator requires string or number operands")
|
|
217
|
+
? []
|
|
218
|
+
: [`${golden.id} (${message})`];
|
|
219
|
+
}
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
expect(notRejected).toEqual([]);
|
|
223
|
+
});
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
describe("reentrant function mappers", () => {
|
|
227
|
+
// Function mappers are caller-supplied, so the corpus cannot exercise nested adapter calls. The
|
|
228
|
+
// nested call's "omitted" must not leak into the outer "explicit" translation of `== null`.
|
|
229
|
+
test.each([
|
|
230
|
+
"null/equals/null-literal-on-missing-attribute",
|
|
231
|
+
"collection/all/empty-collection",
|
|
232
|
+
])("keeps %s isolated from a nested translation", async (action) => {
|
|
233
|
+
const expected = await translate(action, { mapper: UNDECLARED });
|
|
234
|
+
const nested = await planFor("arithmetic/add/field-plus-constant");
|
|
235
|
+
let calls = 0;
|
|
236
|
+
|
|
237
|
+
const mapper: Mapper = (key) => {
|
|
238
|
+
calls++;
|
|
239
|
+
queryPlanToPrisma({
|
|
240
|
+
queryPlan: nested,
|
|
241
|
+
mapper: MAPPER,
|
|
242
|
+
model: "NestedModel",
|
|
243
|
+
nullAttributeRepresentation: "omitted",
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
return UNDECLARED[key] ?? { field: key };
|
|
247
|
+
};
|
|
248
|
+
|
|
249
|
+
expect(await translate(action, { mapper })).toStrictEqual(expected);
|
|
250
|
+
expect(calls).toBeGreaterThan(0);
|
|
251
|
+
});
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
test("an unplannable nested map does not register nullable fields on the outer lambda", async () => {
|
|
255
|
+
// CEL cannot reach this branch: Cerbos 0.54.0 rejects
|
|
256
|
+
// R.attr.tags.all(t, R.attr.tags.map(x, x.name)) with
|
|
257
|
+
// "expected type 'bool' but found 'list(dyn)'". This is a hand-crafted plan contract. A list
|
|
258
|
+
// where a boolean is required is a CEL error, so the body settles to false (all() holds only
|
|
259
|
+
// over no tags) before any lambda scope is entered.
|
|
260
|
+
const condition: PlanExpressionOperand = {
|
|
261
|
+
operator: "all",
|
|
262
|
+
operands: [
|
|
263
|
+
{ name: "request.resource.attr.tags" },
|
|
264
|
+
{
|
|
265
|
+
operator: "lambda",
|
|
266
|
+
operands: [
|
|
267
|
+
{
|
|
268
|
+
operator: "map",
|
|
269
|
+
operands: [
|
|
270
|
+
{ name: "request.resource.attr.tags" },
|
|
271
|
+
{
|
|
272
|
+
operator: "lambda",
|
|
273
|
+
operands: [{ name: "x.name" }, { name: "x" }],
|
|
274
|
+
},
|
|
275
|
+
],
|
|
276
|
+
},
|
|
277
|
+
{ name: "t" },
|
|
278
|
+
],
|
|
279
|
+
},
|
|
280
|
+
],
|
|
281
|
+
};
|
|
282
|
+
|
|
283
|
+
const result = queryPlanToPrisma({
|
|
284
|
+
queryPlan: { ...(await planFor("collection/all/empty-collection")), kind: PlanKind.CONDITIONAL, condition },
|
|
285
|
+
mapper: MAPPER,
|
|
286
|
+
model: MODEL,
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
expect(result).toStrictEqual({
|
|
290
|
+
kind: PlanKind.CONDITIONAL,
|
|
291
|
+
filters: { tags: { none: {} } },
|
|
292
|
+
});
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
describe("timestamp literals", () => {
|
|
296
|
+
// The golden records the folded `now() - duration("24h")` literal as `__NOW_MINUS_24H__`, because
|
|
297
|
+
// it differs on every capture. That makes this the one plan whose value the reader chooses — so
|
|
298
|
+
// it is also the one place the whole timestamp boundary can be walked, by substituting the
|
|
299
|
+
// instant and asking what the adapter does with it.
|
|
300
|
+
const at = async (plannedAt: string) =>
|
|
301
|
+
queryPlanToPrisma({
|
|
302
|
+
queryPlan: await planFor("timestamp/less-than/relative-window", plannedAt),
|
|
303
|
+
mapper: MAPPER,
|
|
304
|
+
model: MODEL,
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
test("a nanosecond instant — what the PDP actually folds — compares against the next millisecond", async () => {
|
|
308
|
+
// A DateTime attribute is a whole millisecond, so `a < T` for a T between two milliseconds is
|
|
309
|
+
// `a < ceil(T)`. The corpus loader substitutes a nanosecond instant for the same reason.
|
|
310
|
+
expect(await at("2026-08-11T09:13:39.123456789Z")).toStrictEqual({
|
|
311
|
+
kind: PlanKind.CONDITIONAL,
|
|
312
|
+
filters: { createdAt: { lt: "2026-08-11T09:13:39.124Z" } },
|
|
313
|
+
});
|
|
314
|
+
});
|
|
315
|
+
|
|
316
|
+
test("excess fractional digits are accepted only when they are zero", async () => {
|
|
317
|
+
expect(await at("2026-08-11T09:13:39.123000Z")).toStrictEqual({
|
|
318
|
+
kind: PlanKind.CONDITIONAL,
|
|
319
|
+
filters: { createdAt: { lt: "2026-08-11T09:13:39.123Z" } },
|
|
320
|
+
});
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
// Each of these is refused rather than coerced: a `Date` parsed from a lenient string would
|
|
324
|
+
// compare against the column as some other instant, which is a filter that returns rows the PDP
|
|
325
|
+
// denies rather than an error the caller can see.
|
|
326
|
+
test.each([
|
|
327
|
+
["a date with no time part", "2024-01-01"],
|
|
328
|
+
["a year outside CEL's instant range", "0000-01-01T00:00:00Z"],
|
|
329
|
+
["a day that does not exist", "2024-02-30T00:00:00Z"],
|
|
330
|
+
["an offset that pushes past the maximum instant", "9999-12-31T23:00:00-02:00"],
|
|
331
|
+
])("%s fails closed", async (_label, value) => {
|
|
332
|
+
await expect(at(value)).rejects.toThrow(/RFC 3339|millisecond|instant range/);
|
|
333
|
+
});
|
|
334
|
+
});
|
|
335
|
+
|
|
336
|
+
describe("relation subqueryFilter", () => {
|
|
337
|
+
/**
|
|
338
|
+
* The one mapping hazard the corpus cannot express with a policy action, because the policy is
|
|
339
|
+
* irrelevant to it: the rows the adapter's subquery sees must equal the rows the application
|
|
340
|
+
* put into the resource attributes. Prisma has no schema-level filtered relation — a `where`
|
|
341
|
+
* injected by a client extension rewrites the top-level query, never the nested
|
|
342
|
+
* `some`/`every`/`none` this adapter generates — so a narrowing the application applies to its
|
|
343
|
+
* own reads does not reach the subquery unless the caller declares it. See "Mapping hazards" in
|
|
344
|
+
* conformance/README.md and cerbos/query-plan-adapters#314.
|
|
345
|
+
*/
|
|
346
|
+
const VISIBLE_ONLY: PrismaFilter = { name: { not: "hidden" } };
|
|
347
|
+
|
|
348
|
+
const mapperFor = (subqueryFilter?: PrismaFilter): Mapper => {
|
|
349
|
+
const relation: Relation = {
|
|
350
|
+
name: "tags",
|
|
351
|
+
type: "many",
|
|
352
|
+
// `nullable: false` keeps the element NULL guard (see "relation element nullability"
|
|
353
|
+
// below) out of these filters, so they isolate what the declaration changes.
|
|
354
|
+
fields: { name: { field: "name", nullable: false } },
|
|
355
|
+
};
|
|
356
|
+
|
|
357
|
+
if (subqueryFilter) relation.subqueryFilter = subqueryFilter;
|
|
358
|
+
|
|
359
|
+
return { "request.resource.attr.tags": { relation } };
|
|
360
|
+
};
|
|
361
|
+
|
|
362
|
+
const filtersFor = async (action: string, subqueryFilter?: PrismaFilter) => {
|
|
363
|
+
const result = await translate(action, { mapper: mapperFor(subqueryFilter) });
|
|
364
|
+
|
|
365
|
+
if (result.kind !== PlanKind.CONDITIONAL) {
|
|
366
|
+
throw new Error(`Expected CONDITIONAL result for ${action}`);
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
return result.filters;
|
|
370
|
+
};
|
|
371
|
+
|
|
372
|
+
test("declared: exists() examines only the records the application serialised", async () => {
|
|
373
|
+
expect(await filtersFor("collection/exists/empty-collection", VISIBLE_ONLY)).toStrictEqual({
|
|
374
|
+
tags: {
|
|
375
|
+
some: { AND: [{ name: { not: "hidden" } }, { name: { equals: "public" } }] },
|
|
376
|
+
},
|
|
377
|
+
});
|
|
378
|
+
});
|
|
379
|
+
|
|
380
|
+
test("declared: all() narrows the records examined, not the records required", async () => {
|
|
381
|
+
// `every: AND(visible, P)` would REQUIRE every record to be visible, dropping any row that
|
|
382
|
+
// holds a hidden tag. The rewrite to `none: AND(visible, NOT P)` is what makes the
|
|
383
|
+
// declaration mean "ignore what the application hides" — including the empty-collection case,
|
|
384
|
+
// where CEL's all() is vacuously true and check() agrees because the application sent an
|
|
385
|
+
// empty list for the same reason.
|
|
386
|
+
expect(await filtersFor("collection/all/empty-collection", VISIBLE_ONLY)).toStrictEqual({
|
|
387
|
+
tags: {
|
|
388
|
+
none: {
|
|
389
|
+
AND: [
|
|
390
|
+
{ name: { not: "hidden" } },
|
|
391
|
+
{ NOT: { name: { equals: "public" } } },
|
|
392
|
+
],
|
|
393
|
+
},
|
|
394
|
+
},
|
|
395
|
+
});
|
|
396
|
+
});
|
|
397
|
+
|
|
398
|
+
test("declared: an emptiness check counts only the visible records", async () => {
|
|
399
|
+
expect(await filtersFor("size/equals/negated-collection-zero", VISIBLE_ONLY)).toStrictEqual({
|
|
400
|
+
NOT: { tags: { none: { name: { not: "hidden" } } } },
|
|
401
|
+
});
|
|
402
|
+
});
|
|
403
|
+
});
|
|
404
|
+
|
|
405
|
+
describe("the mapper contract", () => {
|
|
406
|
+
// A mapper is caller-supplied, so these are caller errors rather than policy shapes: no corpus
|
|
407
|
+
// action can produce them, because the corpus fixes one mapper per adapter.
|
|
408
|
+
|
|
409
|
+
test.each([undefined, true, false])(
|
|
410
|
+
"hierarchy segment nullability %s is respected",
|
|
411
|
+
async (nullable) => {
|
|
412
|
+
const result = await translate("hierarchy/overlaps/path-built-from-list-value-first", {
|
|
413
|
+
mapper: { "request.resource.attr.scope": { field: "scope", nullable } },
|
|
414
|
+
});
|
|
415
|
+
|
|
416
|
+
expect(result).toStrictEqual({
|
|
417
|
+
kind: PlanKind.CONDITIONAL,
|
|
418
|
+
filters:
|
|
419
|
+
nullable === false
|
|
420
|
+
? {}
|
|
421
|
+
: {
|
|
422
|
+
AND: [
|
|
423
|
+
{ OR: [{ scope: { equals: "" } }, { scope: { not: "" } }] },
|
|
424
|
+
],
|
|
425
|
+
},
|
|
426
|
+
});
|
|
427
|
+
},
|
|
428
|
+
);
|
|
429
|
+
|
|
430
|
+
test("a function mapper resolves a scalar reference", async () => {
|
|
431
|
+
expect(
|
|
432
|
+
await translate("string/equals/case-sensitive", {
|
|
433
|
+
mapper: (key: string) => ({
|
|
434
|
+
field: key.replace("request.resource.attr.", ""),
|
|
435
|
+
}),
|
|
436
|
+
})
|
|
437
|
+
).toStrictEqual({
|
|
438
|
+
kind: PlanKind.CONDITIONAL,
|
|
439
|
+
filters: { aString: { equals: "one" } },
|
|
440
|
+
});
|
|
441
|
+
});
|
|
442
|
+
|
|
443
|
+
test("a key under a scalar-mapped prefix is refused, not resolved to the prefix's column", async () => {
|
|
444
|
+
// cerbos/query-plan-adapters#689: the plan reads aString beneath a prefix mapped to one column.
|
|
445
|
+
await expect(
|
|
446
|
+
translate("string/equals/case-sensitive", {
|
|
447
|
+
mapper: { "request.resource.attr": { field: "owner" } },
|
|
448
|
+
}),
|
|
449
|
+
).rejects.toThrow(UnsupportedQueryPlanError);
|
|
450
|
+
});
|
|
451
|
+
|
|
452
|
+
test.each(["collection/exists/scalar-list-equals"])(
|
|
453
|
+
"%s resolves a projection supplied through a prefix mapper",
|
|
454
|
+
async (action) => {
|
|
455
|
+
const direct = await translate(action);
|
|
456
|
+
|
|
457
|
+
if (direct.kind !== PlanKind.CONDITIONAL) {
|
|
458
|
+
throw new Error("Expected a conditional projection plan");
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
const mapper: Mapper = {
|
|
462
|
+
"request.resource.attr": {
|
|
463
|
+
relation: {
|
|
464
|
+
name: "categories",
|
|
465
|
+
type: "many",
|
|
466
|
+
fields: {
|
|
467
|
+
tagNames: {
|
|
468
|
+
relation: { name: "tags", type: "many", field: "name" },
|
|
469
|
+
},
|
|
470
|
+
},
|
|
471
|
+
},
|
|
472
|
+
},
|
|
473
|
+
};
|
|
474
|
+
|
|
475
|
+
expect(await translate(action, { mapper })).toStrictEqual({
|
|
476
|
+
kind: PlanKind.CONDITIONAL,
|
|
477
|
+
filters: { categories: { some: direct.filters } },
|
|
478
|
+
});
|
|
479
|
+
},
|
|
480
|
+
);
|
|
481
|
+
|
|
482
|
+
test("negating an overlaps that folds to an unconditional filter is refused", async () => {
|
|
483
|
+
// `nullable: false` on every segment lets `overlaps(hierarchy("dept"), hierarchy(["dept",
|
|
484
|
+
// scope]))` fold to `{}` (asserted above). Prisma evaluates `{ NOT: {} }` as true, so the
|
|
485
|
+
// negation would match every row where CEL's `!true` matches none; the adapter has no
|
|
486
|
+
// field-free false condition to emit instead (cerbos/query-plan-adapters#495).
|
|
487
|
+
const fixture = await planFor("hierarchy/overlaps/path-built-from-list-value-first");
|
|
488
|
+
|
|
489
|
+
if (fixture.kind !== PlanKind.CONDITIONAL) {
|
|
490
|
+
throw new Error("Expected a conditional hierarchy plan");
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
expect(() =>
|
|
494
|
+
queryPlanToPrisma({
|
|
495
|
+
queryPlan: {
|
|
496
|
+
...fixture,
|
|
497
|
+
condition: { operator: "not", operands: [fixture.condition] },
|
|
498
|
+
},
|
|
499
|
+
mapper: { "request.resource.attr.scope": { field: "scope", nullable: false } },
|
|
500
|
+
})
|
|
501
|
+
).toThrow("Cannot negate an unconditional filter");
|
|
502
|
+
});
|
|
503
|
+
|
|
504
|
+
describe("relation element nullability", () => {
|
|
505
|
+
// Omitting the element NULL guard is what over-grants — a negated exists(), an all() or a
|
|
506
|
+
// hasIntersection() over map() would admit rows holding a NULL element that check() denies
|
|
507
|
+
// — so silence means nullable, and only `nullable: false` drops the guard
|
|
508
|
+
// (cerbos/query-plan-adapters#495). The corpus maps one element column per nullability, so
|
|
509
|
+
// it cannot vary the declaration itself.
|
|
510
|
+
const guarded = {
|
|
511
|
+
AND: [
|
|
512
|
+
{ tags: { every: { name: { equals: "public" } } } },
|
|
513
|
+
{ tags: { none: { name: null } } },
|
|
514
|
+
],
|
|
515
|
+
};
|
|
516
|
+
|
|
517
|
+
const unguarded = { tags: { every: { name: { equals: "public" } } } };
|
|
518
|
+
|
|
519
|
+
const allFor = (fields?: Relation["fields"]) => {
|
|
520
|
+
const relation: Relation = { name: "tags", type: "many" };
|
|
521
|
+
|
|
522
|
+
if (fields) relation.fields = fields;
|
|
523
|
+
|
|
524
|
+
return translate("collection/all/empty-collection", {
|
|
525
|
+
mapper: { "request.resource.attr.tags": { relation } },
|
|
526
|
+
});
|
|
527
|
+
};
|
|
528
|
+
|
|
529
|
+
test.each<[string, Relation["fields"], PrismaFilter]>([
|
|
530
|
+
["undeclared", { name: { field: "name" } }, guarded],
|
|
531
|
+
["nullable: true", { name: { field: "name", nullable: true } }, guarded],
|
|
532
|
+
["nullable: false", { name: { field: "name", nullable: false } }, unguarded],
|
|
533
|
+
["an element with no field mapping at all", undefined, guarded],
|
|
534
|
+
])("%s", async (_label, fields, filters) => {
|
|
535
|
+
expect(await allFor(fields)).toStrictEqual({
|
|
536
|
+
kind: PlanKind.CONDITIONAL,
|
|
537
|
+
filters,
|
|
538
|
+
});
|
|
539
|
+
});
|
|
540
|
+
});
|
|
541
|
+
|
|
542
|
+
test("size() against a scalar mapping is refused rather than guessed", async () => {
|
|
543
|
+
await expect(
|
|
544
|
+
translate("size/equals/negated-collection-zero", {
|
|
545
|
+
mapper: { "request.resource.attr.tags": { field: "tags" } },
|
|
546
|
+
})
|
|
547
|
+
).rejects.toThrow("size operator requires a relation mapping");
|
|
548
|
+
});
|
|
549
|
+
});
|
|
550
|
+
|
|
551
|
+
describe("which patterns RE2 rejects", () => {
|
|
552
|
+
// matches() raises an error on a pattern RE2 rejects, which denies every row, so the adapter
|
|
553
|
+
// must agree with RE2 on exactly which patterns those are. The corpus pins the rejected shapes
|
|
554
|
+
// it carries; this pins the one the previous hand-rolled check got wrong: a digit after a
|
|
555
|
+
// backslash is a backreference to RE2 only on its own, and an octal escape when another octal
|
|
556
|
+
// digit follows, so `\11` is a tab.
|
|
557
|
+
const matches = (pattern: string, sibling = false): PlanResourcesResponse => {
|
|
558
|
+
const test: PlanExpressionOperand = {
|
|
559
|
+
operator: "matches",
|
|
560
|
+
operands: [{ name: "request.resource.attr.aString" }, { value: pattern }],
|
|
561
|
+
};
|
|
562
|
+
|
|
563
|
+
const condition: PlanExpressionOperand = sibling
|
|
564
|
+
? {
|
|
565
|
+
operator: "or",
|
|
566
|
+
operands: [test, { operator: "eq", operands: [{ name: "request.resource.attr.aNumber" }, { value: 5 }] }],
|
|
567
|
+
}
|
|
568
|
+
: test;
|
|
569
|
+
|
|
570
|
+
return {
|
|
571
|
+
kind: PlanKind.CONDITIONAL,
|
|
572
|
+
condition,
|
|
573
|
+
cerbosCallId: "",
|
|
574
|
+
requestId: "",
|
|
575
|
+
validationErrors: [],
|
|
576
|
+
metadata: undefined,
|
|
577
|
+
};
|
|
578
|
+
};
|
|
579
|
+
|
|
580
|
+
test("an octal escape is the character it names, not a backreference", () => {
|
|
581
|
+
const result = queryPlanToPrisma({ queryPlan: matches("^\\11"), mapper: MAPPER, model: MODEL });
|
|
582
|
+
expect(result.kind).toBe(PlanKind.CONDITIONAL);
|
|
583
|
+
});
|
|
584
|
+
|
|
585
|
+
test("a backreference is an error on every row, so only its sibling decides", () => {
|
|
586
|
+
const result = queryPlanToPrisma({
|
|
587
|
+
queryPlan: matches("(a)\\1", true),
|
|
588
|
+
mapper: MAPPER,
|
|
589
|
+
model: MODEL,
|
|
590
|
+
});
|
|
591
|
+
|
|
592
|
+
expect(result).toEqual({ kind: PlanKind.CONDITIONAL, filters: { aNumber: { equals: 5 } } });
|
|
593
|
+
expect(queryPlanToPrisma({ queryPlan: matches("\\8"), mapper: MAPPER, model: MODEL })).toEqual({
|
|
594
|
+
kind: PlanKind.ALWAYS_DENIED,
|
|
595
|
+
});
|
|
596
|
+
});
|
|
597
|
+
});
|
|
598
|
+
|
|
599
|
+
describe("plans the planner cannot produce", () => {
|
|
600
|
+
// Input validation on a public function, not policy shapes. Every other assertion in this file
|
|
601
|
+
// reads its plan from a golden file precisely because a typed plan is a belief about the planner —
|
|
602
|
+
// but these are malformed by construction, so there is no golden to read and nothing to
|
|
603
|
+
// believe. They exist so a caller who hands the adapter a hand-rolled or half-decoded plan gets
|
|
604
|
+
// an error rather than a filter.
|
|
605
|
+
//
|
|
606
|
+
// The test for a shape CEL *can* express does not belong here, whatever its plan looks like:
|
|
607
|
+
// it belongs in the corpus, where every adapter is asked about it. Two such shapes were
|
|
608
|
+
// pinned here by hand-built plan before #377 and are now
|
|
609
|
+
// [#394](https://github.com/cerbos/query-plan-adapters/issues/394).
|
|
610
|
+
|
|
611
|
+
const plan = (condition: Value): PlanResourcesResponse =>
|
|
612
|
+
overwritten(
|
|
613
|
+
{
|
|
614
|
+
kind: PlanKind.CONDITIONAL,
|
|
615
|
+
condition: { value: true },
|
|
616
|
+
cerbosCallId: "",
|
|
617
|
+
requestId: "",
|
|
618
|
+
validationErrors: [],
|
|
619
|
+
metadata: undefined,
|
|
620
|
+
},
|
|
621
|
+
"condition",
|
|
622
|
+
condition,
|
|
623
|
+
);
|
|
624
|
+
|
|
625
|
+
test("an unrecognised plan kind", () => {
|
|
626
|
+
expect(() =>
|
|
627
|
+
queryPlanToPrisma({
|
|
628
|
+
queryPlan: overwritten(
|
|
629
|
+
{ kind: PlanKind.ALWAYS_ALLOWED, cerbosCallId: "", requestId: "", validationErrors: [], metadata: undefined },
|
|
630
|
+
"kind",
|
|
631
|
+
"INVALID_KIND"
|
|
632
|
+
),
|
|
633
|
+
mapper: {},
|
|
634
|
+
})
|
|
635
|
+
).toThrow("Invalid query plan.");
|
|
636
|
+
});
|
|
637
|
+
|
|
638
|
+
test("a condition with neither operator nor operands", () => {
|
|
639
|
+
expect(() => queryPlanToPrisma({ queryPlan: plan({}), mapper: {} })).toThrow(
|
|
640
|
+
"Invalid Cerbos expression structure"
|
|
641
|
+
);
|
|
642
|
+
});
|
|
643
|
+
|
|
644
|
+
test("an operator this adapter has never heard of", () => {
|
|
645
|
+
expect(() =>
|
|
646
|
+
queryPlanToPrisma({
|
|
647
|
+
queryPlan: plan({ operator: "unsupported", operands: [] }),
|
|
648
|
+
mapper: {},
|
|
649
|
+
})
|
|
650
|
+
).toThrow("Unsupported operator: unsupported");
|
|
651
|
+
});
|
|
652
|
+
|
|
653
|
+
test("an operand that is neither a variable nor a value", () => {
|
|
654
|
+
expect(() =>
|
|
655
|
+
queryPlanToPrisma({
|
|
656
|
+
queryPlan: plan({ operator: "eq", operands: [{}, { value: "test" }] }),
|
|
657
|
+
mapper: {},
|
|
658
|
+
})
|
|
659
|
+
).toThrow("No valid left operand found");
|
|
660
|
+
});
|
|
661
|
+
|
|
662
|
+
test("a ternary with the wrong arity", () => {
|
|
663
|
+
expect(() =>
|
|
664
|
+
queryPlanToPrisma({
|
|
665
|
+
queryPlan: plan({
|
|
666
|
+
operator: "if",
|
|
667
|
+
operands: [{ name: "request.resource.attr.aBool" }, { value: true }],
|
|
668
|
+
}),
|
|
669
|
+
mapper: MAPPER,
|
|
670
|
+
})
|
|
671
|
+
).toThrow(
|
|
672
|
+
"if (ternary) requires exactly 3 operands (condition, then, else), got 2"
|
|
673
|
+
);
|
|
674
|
+
});
|
|
675
|
+
|
|
676
|
+
test("a ternary whose condition is not a boolean expression", () => {
|
|
677
|
+
expect(() =>
|
|
678
|
+
queryPlanToPrisma({
|
|
679
|
+
queryPlan: plan({
|
|
680
|
+
operator: "if",
|
|
681
|
+
operands: [{ value: null }, { value: true }, { value: false }],
|
|
682
|
+
}),
|
|
683
|
+
mapper: MAPPER,
|
|
684
|
+
})
|
|
685
|
+
).toThrow("if (ternary) condition must be a boolean expression");
|
|
686
|
+
});
|
|
687
|
+
|
|
688
|
+
test("a comparison against a ternary with three operands", () => {
|
|
689
|
+
expect(() =>
|
|
690
|
+
queryPlanToPrisma({
|
|
691
|
+
queryPlan: plan({
|
|
692
|
+
operator: "eq",
|
|
693
|
+
operands: [
|
|
694
|
+
{
|
|
695
|
+
operator: "if",
|
|
696
|
+
operands: [
|
|
697
|
+
{ name: "request.resource.attr.aBool" },
|
|
698
|
+
{ value: true },
|
|
699
|
+
{ value: false },
|
|
700
|
+
],
|
|
701
|
+
},
|
|
702
|
+
{ value: true },
|
|
703
|
+
{ value: false },
|
|
704
|
+
],
|
|
705
|
+
}),
|
|
706
|
+
mapper: MAPPER,
|
|
707
|
+
})
|
|
708
|
+
).toThrow("eq with a ternary requires exactly 2 operands, got 3");
|
|
709
|
+
});
|
|
710
|
+
|
|
711
|
+
test("a macro over a collection value that is not a list", () => {
|
|
712
|
+
expect(() =>
|
|
713
|
+
queryPlanToPrisma({
|
|
714
|
+
queryPlan: plan({
|
|
715
|
+
operator: "exists",
|
|
716
|
+
operands: [
|
|
717
|
+
{ value: "not-a-list" },
|
|
718
|
+
{
|
|
719
|
+
operator: "lambda",
|
|
720
|
+
operands: [
|
|
721
|
+
{
|
|
722
|
+
operator: "eq",
|
|
723
|
+
operands: [
|
|
724
|
+
{ name: "request.resource.attr.aString" },
|
|
725
|
+
{ name: "t" },
|
|
726
|
+
],
|
|
727
|
+
},
|
|
728
|
+
{ name: "t" },
|
|
729
|
+
],
|
|
730
|
+
},
|
|
731
|
+
],
|
|
732
|
+
}),
|
|
733
|
+
mapper: MAPPER,
|
|
734
|
+
})
|
|
735
|
+
).toThrow("exists over a literal collection requires a list value");
|
|
736
|
+
});
|
|
737
|
+
|
|
738
|
+
// An empty conjunction is `{ AND: [] }` in Prisma, which matches every row, and the constant
|
|
739
|
+
// folder would otherwise reduce it to `true` before the translator saw it
|
|
740
|
+
// (cerbos/query-plan-adapters#495). The empty disjunction is refused alongside it: neither is
|
|
741
|
+
// something the planner emits.
|
|
742
|
+
test.each<[string, Value, string]>([
|
|
743
|
+
["an empty and", { operator: "and", operands: [] }, "and requires at least one operand"],
|
|
744
|
+
["an empty or", { operator: "or", operands: [] }, "or requires at least one operand"],
|
|
745
|
+
[
|
|
746
|
+
"an empty and under an or",
|
|
747
|
+
{
|
|
748
|
+
operator: "or",
|
|
749
|
+
operands: [
|
|
750
|
+
{ name: "request.resource.attr.aBool" },
|
|
751
|
+
{ operator: "and", operands: [] },
|
|
752
|
+
],
|
|
753
|
+
},
|
|
754
|
+
"and requires at least one operand",
|
|
755
|
+
],
|
|
756
|
+
[
|
|
757
|
+
"an empty and under a not",
|
|
758
|
+
{ operator: "not", operands: [{ operator: "and", operands: [] }] },
|
|
759
|
+
"and requires at least one operand",
|
|
760
|
+
],
|
|
761
|
+
])("%s", (_label, condition, message) => {
|
|
762
|
+
expect(() =>
|
|
763
|
+
queryPlanToPrisma({ queryPlan: plan(condition), mapper: MAPPER })
|
|
764
|
+
).toThrow(message);
|
|
765
|
+
});
|
|
766
|
+
});
|