@cerbos/orm-prisma 4.1.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 +598 -603
- 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 +23 -13
- package/lib/index.d.ts.map +1 -1
- package/lib/index.js +34 -2555
- 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 +27 -17
- package/src/adversarial.test.ts +376 -1071
- package/src/collections.ts +738 -0
- package/src/comparison.ts +1157 -0
- package/src/corpus.ts +433 -250
- 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 +10 -0
- package/src/generated/prisma-adversarial/client.ts +10 -0
- package/src/generated/prisma-adversarial/commonInputTypes.ts +116 -127
- package/src/generated/prisma-adversarial/internal/class.ts +24 -4
- package/src/generated/prisma-adversarial/internal/prismaNamespace.ts +174 -3
- package/src/generated/prisma-adversarial/internal/prismaNamespaceBrowser.ts +23 -2
- package/src/generated/prisma-adversarial/models/AdversarialBoolListElement.ts +1272 -0
- package/src/generated/prisma-adversarial/models/AdversarialInner.ts +54 -54
- package/src/generated/prisma-adversarial/models/AdversarialNumberListElement.ts +1316 -0
- package/src/generated/prisma-adversarial/models/AdversarialParent.ts +66 -66
- package/src/generated/prisma-adversarial/models/AdversarialResource.ts +435 -87
- package/src/generated/prisma-adversarial/models.ts +2 -0
- package/src/generated/prisma-adversarial-mysql/browser.ts +64 -0
- package/src/generated/prisma-adversarial-mysql/client.ts +86 -0
- package/src/generated/prisma-adversarial-mysql/commonInputTypes.ts +349 -0
- package/src/generated/prisma-adversarial-mysql/enums.ts +15 -0
- 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/client.d.ts +1 -0
- package/src/generated/prisma-adversarial-mysql-v6/default.d.ts +1 -0
- package/src/generated/prisma-adversarial-mysql-v6/edge.d.ts +1 -0
- package/src/generated/prisma-adversarial-mysql-v6/index.d.ts +13822 -0
- package/src/generated/prisma-adversarial-mysql-v6/runtime/index-browser.d.ts +370 -0
- package/src/generated/prisma-adversarial-mysql-v6/runtime/library.d.ts +3982 -0
- package/src/generated/prisma-adversarial-mysql-v6/wasm.d.ts +1 -0
- package/src/generated/prisma-adversarial-pg/browser.ts +10 -0
- package/src/generated/prisma-adversarial-pg/client.ts +10 -0
- package/src/generated/prisma-adversarial-pg/commonInputTypes.ts +118 -129
- package/src/generated/prisma-adversarial-pg/internal/class.ts +24 -4
- package/src/generated/prisma-adversarial-pg/internal/prismaNamespace.ts +174 -3
- package/src/generated/prisma-adversarial-pg/internal/prismaNamespaceBrowser.ts +23 -2
- package/src/generated/prisma-adversarial-pg/models/AdversarialBoolListElement.ts +1275 -0
- package/src/generated/prisma-adversarial-pg/models/AdversarialInner.ts +54 -54
- package/src/generated/prisma-adversarial-pg/models/AdversarialNumberListElement.ts +1319 -0
- package/src/generated/prisma-adversarial-pg/models/AdversarialParent.ts +66 -66
- package/src/generated/prisma-adversarial-pg/models/AdversarialResource.ts +435 -87
- package/src/generated/prisma-adversarial-pg/models.ts +2 -0
- package/src/generated/prisma-adversarial-pg-v6/index.d.ts +3476 -388
- package/src/generated/prisma-adversarial-v6/index.d.ts +3465 -383
- package/src/hierarchy.ts +405 -0
- package/src/index.ts +66 -3646
- 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 +6 -3
- 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.v7.ts +1 -0
- package/src/timestamp.ts +308 -0
- package/src/translate.ts +419 -0
- package/src/translator.test.ts +497 -1317
- package/src/types.ts +1310 -0
- package/src/value.ts +16 -0
package/src/strings.ts
ADDED
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
// The receiver-sensitive string operators: `contains`, `startsWith`, `endsWith`.
|
|
2
|
+
|
|
3
|
+
import type { PlanExpressionOperand } from "@cerbos/core";
|
|
4
|
+
|
|
5
|
+
import { assertStringField, buildFieldFilter, buildPrefixFilter } from "./fields";
|
|
6
|
+
import type { PrismaFilter } from "./index";
|
|
7
|
+
import {
|
|
8
|
+
isResolvedFieldReference,
|
|
9
|
+
isResolvedValue,
|
|
10
|
+
recordErroringNullElement,
|
|
11
|
+
} from "./mapping";
|
|
12
|
+
import type { TranslationContext } from "./mapping";
|
|
13
|
+
import { assertDefined } from "./plan";
|
|
14
|
+
import { resolveOperand } from "./translate";
|
|
15
|
+
import { UnsupportedQueryPlanError } from "./errors";
|
|
16
|
+
import { isString } from "./narrowing";
|
|
17
|
+
|
|
18
|
+
// Upper bound on the IN-list produced when enumerating a constant receiver's substrings.
|
|
19
|
+
const MAX_ENUMERATED_NEEDLES = 1000;
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Every needle `haystack.<operator>(needle)` is true for: its prefixes for startsWith, its
|
|
23
|
+
* suffixes for endsWith, its substrings for contains — and the empty string for all three.
|
|
24
|
+
*/
|
|
25
|
+
function candidateNeedles(operator: string, haystack: string): Set<string> {
|
|
26
|
+
const candidates = new Set<string>([""]);
|
|
27
|
+
|
|
28
|
+
if (operator === "startsWith") {
|
|
29
|
+
for (let i = 1; i <= haystack.length; i++) {
|
|
30
|
+
candidates.add(haystack.slice(0, i));
|
|
31
|
+
}
|
|
32
|
+
} else if (operator === "endsWith") {
|
|
33
|
+
for (let i = 0; i < haystack.length; i++) {
|
|
34
|
+
candidates.add(haystack.slice(i));
|
|
35
|
+
}
|
|
36
|
+
} else {
|
|
37
|
+
for (let start = 0; start < haystack.length; start++) {
|
|
38
|
+
for (let end = start + 1; end <= haystack.length; end++) {
|
|
39
|
+
candidates.add(haystack.slice(start, end));
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
return candidates;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Helper function to handle string operators (contains, startsWith, endsWith).
|
|
49
|
+
*
|
|
50
|
+
* These operators are receiver-sensitive and the planner preserves policy source order:
|
|
51
|
+
* operand 0 is the receiver (haystack), operand 1 the needle. Swapping them silently
|
|
52
|
+
* inverts the match direction, so unlike symmetric comparisons they are never normalized.
|
|
53
|
+
*/
|
|
54
|
+
export function handleStringOperator(
|
|
55
|
+
operator: string,
|
|
56
|
+
operands: PlanExpressionOperand[],
|
|
57
|
+
context: TranslationContext
|
|
58
|
+
): PrismaFilter {
|
|
59
|
+
if (operands.length !== 2) {
|
|
60
|
+
throw new UnsupportedQueryPlanError(`${operator} requires exactly two operands`);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const receiver = resolveOperand(
|
|
64
|
+
assertDefined(operands[0], `${operator} requires a receiver operand`),
|
|
65
|
+
context
|
|
66
|
+
);
|
|
67
|
+
|
|
68
|
+
const needle = resolveOperand(
|
|
69
|
+
assertDefined(operands[1], `${operator} requires a needle operand`),
|
|
70
|
+
context
|
|
71
|
+
);
|
|
72
|
+
|
|
73
|
+
if (isResolvedFieldReference(receiver)) assertStringField(receiver, operator);
|
|
74
|
+
|
|
75
|
+
if (isResolvedFieldReference(needle)) assertStringField(needle, operator);
|
|
76
|
+
// A null element of a projected list is a value, and a string function over it is an error.
|
|
77
|
+
recordErroringNullElement(context, operands[0]!, receiver);
|
|
78
|
+
recordErroringNullElement(context, operands[1]!, needle);
|
|
79
|
+
|
|
80
|
+
// Column receiver, constant needle: Prisma's LIKE-based filter.
|
|
81
|
+
if (isResolvedFieldReference(receiver) && isResolvedValue(needle)) {
|
|
82
|
+
const { value } = needle;
|
|
83
|
+
|
|
84
|
+
if (!isString(value)) {
|
|
85
|
+
throw new UnsupportedQueryPlanError(`${operator} operator requires string value`);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
if (value.includes("\u0000")) {
|
|
89
|
+
throw new UnsupportedQueryPlanError(
|
|
90
|
+
`${operator} with a needle holding a NUL character: PostgreSQL text cannot hold one, and SQLite's LIKE stops reading the pattern at it, so %NUL% matched every row`,
|
|
91
|
+
);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// A prefix is matched by the range of strings it bounds, so startsWith needs no LIKE escaping
|
|
95
|
+
// (see buildPrefixFilter). Prisma emits LIKE without an ESCAPE clause and does not escape
|
|
96
|
+
// wildcard characters, so a contains or endsWith needle containing % or _ would match as a
|
|
97
|
+
// pattern instead of literally (contains("a_b") wrongly matches "axb"), and a suffix or
|
|
98
|
+
// substring bounds no range. Fail loudly rather than filter wrong.
|
|
99
|
+
//
|
|
100
|
+
// A backslash is the same hazard reached from the other direction, and only an executed
|
|
101
|
+
// PostgreSQL leg surfaces it (cerbos/query-plan-adapters#320): SQLite's LIKE has no escape
|
|
102
|
+
// character at all, so `\` is literal there, while PostgreSQL and MySQL treat it as the
|
|
103
|
+
// DEFAULT escape character even with no ESCAPE clause. `endsWith("\\")` is then a pattern
|
|
104
|
+
// ending in an escape character — a hard error on PostgreSQL — and a `\x` anywhere inside
|
|
105
|
+
// silently drops the backslash, so `contains("a\\b")` matches "ab", a row the PDP denies.
|
|
106
|
+
if (operator === "startsWith") {
|
|
107
|
+
return buildPrefixFilter(receiver, value);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
if (/[%_\\]/.test(value)) {
|
|
111
|
+
throw new UnsupportedQueryPlanError(
|
|
112
|
+
`Cannot translate ${operator} with a needle containing LIKE metacharacters (%, _ or \\): ` +
|
|
113
|
+
"Prisma does not escape wildcards in string filters, and \\ is the default LIKE " +
|
|
114
|
+
"escape character on PostgreSQL and MySQL"
|
|
115
|
+
);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
return buildFieldFilter(receiver, operator, value);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// Constant receiver, column needle (e.g. `"a-b-c".contains(R.attr.x)`): enumerate every
|
|
122
|
+
// candidate needle the constant admits into an exact IN filter. No LIKE, so no escaping
|
|
123
|
+
// hazards; a NULL needle column stays excluded (CEL missing-attribute deny).
|
|
124
|
+
if (isResolvedValue(receiver) && isResolvedFieldReference(needle)) {
|
|
125
|
+
if (!isString(receiver.value)) {
|
|
126
|
+
throw new UnsupportedQueryPlanError(`${operator} operator requires a string receiver`);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const candidates = candidateNeedles(operator, receiver.value);
|
|
130
|
+
|
|
131
|
+
if (candidates.size > MAX_ENUMERATED_NEEDLES) {
|
|
132
|
+
throw new UnsupportedQueryPlanError(
|
|
133
|
+
`Cannot translate ${operator} with a constant receiver of this length: ` +
|
|
134
|
+
`enumerating its ${candidates.size} candidate needles exceeds the ${MAX_ENUMERATED_NEEDLES}-entry limit`
|
|
135
|
+
);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
return buildFieldFilter(needle, "in", [...candidates]);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
if (isResolvedFieldReference(receiver) && isResolvedFieldReference(needle)) {
|
|
142
|
+
// A column-valued needle would need its LIKE metacharacters escaped per row, which
|
|
143
|
+
// Prisma's filters cannot do (and Prisma leaks wildcards from field references).
|
|
144
|
+
throw new UnsupportedQueryPlanError(
|
|
145
|
+
`Cannot translate ${operator} between two columns: Prisma cannot escape LIKE wildcards held in a column`
|
|
146
|
+
);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
throw new UnsupportedQueryPlanError(
|
|
150
|
+
`${operator} between two constants must be folded by the Cerbos planner`
|
|
151
|
+
);
|
|
152
|
+
}
|
package/src/ternary.ts
ADDED
|
@@ -0,0 +1,359 @@
|
|
|
1
|
+
// `cond ? a : b`, in boolean position and as an operand of a comparison. Both lower to the same
|
|
2
|
+
// guarded disjunction, which keeps CEL's error semantics when the condition is UNKNOWN.
|
|
3
|
+
|
|
4
|
+
import type { PlanExpressionOperand, Value } from "@cerbos/core";
|
|
5
|
+
|
|
6
|
+
import { handleRelationalOperator } from "./comparison";
|
|
7
|
+
import { evaluateConstantComparison } from "./evaluate";
|
|
8
|
+
import { rejectConstantFalse } from "./fields";
|
|
9
|
+
import type { PrismaFilter } from "./index";
|
|
10
|
+
import type { TranslationContext } from "./mapping";
|
|
11
|
+
import {
|
|
12
|
+
CERBOS_TO_PRISMA_OPERATOR,
|
|
13
|
+
assertDefined,
|
|
14
|
+
isOperatorOperand,
|
|
15
|
+
isValueOperand,
|
|
16
|
+
normalizeBinaryOperands,
|
|
17
|
+
} from "./plan";
|
|
18
|
+
import {
|
|
19
|
+
buildNegatedFilter,
|
|
20
|
+
buildPrismaFilterFromCerbosExpression,
|
|
21
|
+
} from "./translate";
|
|
22
|
+
import { normalizeRfc3339Milliseconds } from "./timestamp";
|
|
23
|
+
import { UnsupportedQueryPlanError } from "./errors";
|
|
24
|
+
import { isBoolean, isString, lookup } from "./narrowing";
|
|
25
|
+
|
|
26
|
+
/** What one ternary branch contributes: a known constant, or a filter to be guarded. */
|
|
27
|
+
type TernaryBranchPredicate =
|
|
28
|
+
| { kind: "constant"; value: boolean }
|
|
29
|
+
| { kind: "filter"; filter: PrismaFilter };
|
|
30
|
+
|
|
31
|
+
// The comparison that holds exactly when `operator` does not, over two definite operands.
|
|
32
|
+
const COMPLEMENT_OPERATOR = {
|
|
33
|
+
eq: "ne",
|
|
34
|
+
ne: "eq",
|
|
35
|
+
lt: "ge",
|
|
36
|
+
le: "gt",
|
|
37
|
+
gt: "le",
|
|
38
|
+
ge: "lt",
|
|
39
|
+
} satisfies Record<string, string>;
|
|
40
|
+
|
|
41
|
+
type TernaryOperands = {
|
|
42
|
+
condition: PlanExpressionOperand;
|
|
43
|
+
thenBranch: PlanExpressionOperand;
|
|
44
|
+
elseBranch: PlanExpressionOperand;
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
function getTernaryOperands(operands: PlanExpressionOperand[]): TernaryOperands {
|
|
48
|
+
if (operands.length !== 3) {
|
|
49
|
+
throw new UnsupportedQueryPlanError(
|
|
50
|
+
`if (ternary) requires exactly 3 operands (condition, then, else), got ${operands.length}`
|
|
51
|
+
);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
return {
|
|
55
|
+
condition: assertDefined(
|
|
56
|
+
operands[0],
|
|
57
|
+
"if (ternary) requires a condition operand"
|
|
58
|
+
),
|
|
59
|
+
thenBranch: assertDefined(
|
|
60
|
+
operands[1],
|
|
61
|
+
"if (ternary) requires a then operand"
|
|
62
|
+
),
|
|
63
|
+
elseBranch: assertDefined(
|
|
64
|
+
operands[2],
|
|
65
|
+
"if (ternary) requires an else operand"
|
|
66
|
+
),
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function getConstantBooleanCondition(
|
|
71
|
+
condition: PlanExpressionOperand
|
|
72
|
+
): boolean | undefined {
|
|
73
|
+
if (!isValueOperand(condition)) {
|
|
74
|
+
return undefined;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
if (!isBoolean(condition.value)) {
|
|
78
|
+
throw new UnsupportedQueryPlanError("if (ternary) condition must be a boolean expression");
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
return condition.value;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function buildBooleanBranchFilter(
|
|
85
|
+
branch: PlanExpressionOperand,
|
|
86
|
+
context: TranslationContext
|
|
87
|
+
): TernaryBranchPredicate {
|
|
88
|
+
if (!isValueOperand(branch)) {
|
|
89
|
+
return {
|
|
90
|
+
kind: "filter",
|
|
91
|
+
filter: buildPrismaFilterFromCerbosExpression(branch, context),
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
if (!isBoolean(branch.value)) {
|
|
96
|
+
throw new UnsupportedQueryPlanError(
|
|
97
|
+
"if (ternary) branch in boolean position must be a boolean"
|
|
98
|
+
);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
return { kind: "constant", value: branch.value };
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* `(cond AND then) OR (NOT cond AND else) OR (cond AND NOT cond)`, dropping any arm a constant
|
|
106
|
+
* branch makes redundant.
|
|
107
|
+
*
|
|
108
|
+
* "The condition is definitively FALSE" is buildNegatedFilter's contract — three-valued, so a
|
|
109
|
+
* condition that is UNKNOWN (a NULL column, an erroring lambda body, an absent to-one parent)
|
|
110
|
+
* selects NEITHER branch. It delegates rather than spelling a second negation. A private copy is
|
|
111
|
+
* how the ternary missed the hop requirement #315/#316 added: a bare `NOT` over a chain filter is
|
|
112
|
+
* TRUE when the to-one parent is absent, so the else-branch was selected for rows CEL denies
|
|
113
|
+
* outright — the missing path is an evaluation error, not a false condition
|
|
114
|
+
* (cerbos/query-plan-adapters#334). Delegating also gives the ternary the De Morgan push-down,
|
|
115
|
+
* which a chained `and`/`or` condition needs for CEL's error absorption to survive.
|
|
116
|
+
*/
|
|
117
|
+
function buildGuardedTernaryFilter(
|
|
118
|
+
condition: PlanExpressionOperand,
|
|
119
|
+
thenFilter: TernaryBranchPredicate,
|
|
120
|
+
elseFilter: TernaryBranchPredicate,
|
|
121
|
+
context: TranslationContext
|
|
122
|
+
): PrismaFilter {
|
|
123
|
+
const conditionTrue = buildPrismaFilterFromCerbosExpression(
|
|
124
|
+
condition,
|
|
125
|
+
context
|
|
126
|
+
);
|
|
127
|
+
|
|
128
|
+
const conditionFalse = buildNegatedFilter(condition, context);
|
|
129
|
+
const guardedBranches: PrismaFilter[] = [];
|
|
130
|
+
|
|
131
|
+
if (thenFilter.kind === "filter") {
|
|
132
|
+
guardedBranches.push({ AND: [conditionTrue, thenFilter.filter] });
|
|
133
|
+
} else if (thenFilter.value) {
|
|
134
|
+
guardedBranches.push(conditionTrue);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
if (elseFilter.kind === "filter") {
|
|
138
|
+
guardedBranches.push({ AND: [conditionFalse, elseFilter.filter] });
|
|
139
|
+
} else if (elseFilter.value) {
|
|
140
|
+
guardedBranches.push(conditionFalse);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// For a known condition this contradiction is false. If the condition is
|
|
144
|
+
// NULL/error-derived, both sides are SQL UNKNOWN, which keeps the whole
|
|
145
|
+
// ternary UNKNOWN under an outer NOT instead of authorizing the row.
|
|
146
|
+
guardedBranches.push({ AND: [conditionTrue, conditionFalse] });
|
|
147
|
+
|
|
148
|
+
return { OR: guardedBranches };
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function finalizeTernaryBranchPredicate(
|
|
152
|
+
predicate: TernaryBranchPredicate
|
|
153
|
+
): PrismaFilter {
|
|
154
|
+
if (predicate.kind === "filter") {
|
|
155
|
+
return predicate.filter;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
return predicate.value ? {} : rejectConstantFalse();
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
export function handleBooleanTernaryOperator(
|
|
162
|
+
operands: PlanExpressionOperand[],
|
|
163
|
+
context: TranslationContext
|
|
164
|
+
): PrismaFilter {
|
|
165
|
+
const { condition, thenBranch, elseBranch } = getTernaryOperands(operands);
|
|
166
|
+
const constantCondition = getConstantBooleanCondition(condition);
|
|
167
|
+
|
|
168
|
+
if (constantCondition !== undefined) {
|
|
169
|
+
return finalizeTernaryBranchPredicate(
|
|
170
|
+
buildBooleanBranchFilter(
|
|
171
|
+
constantCondition ? thenBranch : elseBranch,
|
|
172
|
+
context
|
|
173
|
+
)
|
|
174
|
+
);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
return buildGuardedTernaryFilter(
|
|
178
|
+
condition,
|
|
179
|
+
buildBooleanBranchFilter(thenBranch, context),
|
|
180
|
+
buildBooleanBranchFilter(elseBranch, context),
|
|
181
|
+
context
|
|
182
|
+
);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/** The comparison with its ternary operand replaced by `branch`, as a branch predicate. */
|
|
186
|
+
// A timestamp() literal compares as its canonical instant, never against a bare string.
|
|
187
|
+
function constantOf(
|
|
188
|
+
operand: PlanExpressionOperand | undefined
|
|
189
|
+
): { value: Value; timestamp: boolean } | undefined {
|
|
190
|
+
if (operand === undefined) return undefined;
|
|
191
|
+
|
|
192
|
+
if (isValueOperand(operand)) return { value: operand.value, timestamp: false };
|
|
193
|
+
|
|
194
|
+
if (
|
|
195
|
+
isOperatorOperand(operand) &&
|
|
196
|
+
operand.operator === "timestamp" &&
|
|
197
|
+
operand.operands.length === 1
|
|
198
|
+
) {
|
|
199
|
+
const literal = operand.operands[0];
|
|
200
|
+
|
|
201
|
+
if (literal !== undefined && isValueOperand(literal) && isString(literal.value)) {
|
|
202
|
+
return { value: normalizeRfc3339Milliseconds(literal.value), timestamp: true };
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
return undefined;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function buildTernaryComparisonBranch(
|
|
210
|
+
operator: string,
|
|
211
|
+
operands: PlanExpressionOperand[],
|
|
212
|
+
ternaryIndex: number,
|
|
213
|
+
branch: PlanExpressionOperand,
|
|
214
|
+
context: TranslationContext,
|
|
215
|
+
negated: boolean
|
|
216
|
+
): TernaryBranchPredicate {
|
|
217
|
+
const substitutedOperands = operands.map((operand, index) =>
|
|
218
|
+
index === ternaryIndex ? branch : operand
|
|
219
|
+
);
|
|
220
|
+
|
|
221
|
+
assertDefined(substitutedOperands[0], `${operator} requires a left operand`);
|
|
222
|
+
assertDefined(substitutedOperands[1], `${operator} requires a right operand`);
|
|
223
|
+
|
|
224
|
+
// Membership is not symmetric: its member stays first even when it is the constant.
|
|
225
|
+
const normalized =
|
|
226
|
+
operator === "in"
|
|
227
|
+
? { operator, operands: substitutedOperands }
|
|
228
|
+
: normalizeBinaryOperands(operator, substitutedOperands);
|
|
229
|
+
|
|
230
|
+
const [normalizedFirst, normalizedSecond] = normalized.operands;
|
|
231
|
+
const first = constantOf(normalizedFirst);
|
|
232
|
+
const second = constantOf(normalizedSecond);
|
|
233
|
+
|
|
234
|
+
if (
|
|
235
|
+
normalized.operands.length === 2 &&
|
|
236
|
+
first !== undefined &&
|
|
237
|
+
second !== undefined &&
|
|
238
|
+
first.timestamp === second.timestamp
|
|
239
|
+
) {
|
|
240
|
+
return {
|
|
241
|
+
kind: "constant",
|
|
242
|
+
value:
|
|
243
|
+
evaluateConstantComparison(
|
|
244
|
+
normalized.operator,
|
|
245
|
+
first.value,
|
|
246
|
+
second.value
|
|
247
|
+
) !== negated,
|
|
248
|
+
};
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
if (!negated) {
|
|
252
|
+
return {
|
|
253
|
+
kind: "filter",
|
|
254
|
+
filter: buildPrismaFilterFromCerbosExpression(normalized, context),
|
|
255
|
+
};
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
const nested = tryHandleTernaryComparison(
|
|
259
|
+
normalized.operator,
|
|
260
|
+
normalized.operands,
|
|
261
|
+
context,
|
|
262
|
+
true
|
|
263
|
+
);
|
|
264
|
+
|
|
265
|
+
if (nested !== null) return { kind: "filter", filter: nested };
|
|
266
|
+
|
|
267
|
+
if (normalized.operator === "in") {
|
|
268
|
+
return {
|
|
269
|
+
kind: "filter",
|
|
270
|
+
filter: buildNegatedFilter(
|
|
271
|
+
{ operator: "in", operands: normalized.operands },
|
|
272
|
+
context
|
|
273
|
+
),
|
|
274
|
+
};
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
// Keep missing-value and relation guards inside the selected branch. Constants
|
|
278
|
+
// are negated above, since !(NaN <= n) is true but NaN > n is false in CEL 0.30.
|
|
279
|
+
return {
|
|
280
|
+
kind: "filter",
|
|
281
|
+
filter: handleRelationalOperator(
|
|
282
|
+
assertDefined(
|
|
283
|
+
lookup(COMPLEMENT_OPERATOR, normalized.operator),
|
|
284
|
+
"Unsupported negated comparison"
|
|
285
|
+
),
|
|
286
|
+
normalized.operands,
|
|
287
|
+
context
|
|
288
|
+
),
|
|
289
|
+
};
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/**
|
|
293
|
+
* A comparison or membership test with a ternary operand (`(c ? a : b) == x`,
|
|
294
|
+
* `x in (c ? a : b)`), translated by distributing the
|
|
295
|
+
* comparison into both branches; or null when `operator` is not a comparison or no operand is a
|
|
296
|
+
* ternary. `negated` builds the filter for the comparison's negation instead.
|
|
297
|
+
*/
|
|
298
|
+
export function tryHandleTernaryComparison(
|
|
299
|
+
operator: string,
|
|
300
|
+
operands: PlanExpressionOperand[],
|
|
301
|
+
context: TranslationContext,
|
|
302
|
+
negated = false
|
|
303
|
+
): PrismaFilter | null {
|
|
304
|
+
if (lookup(CERBOS_TO_PRISMA_OPERATOR, operator) === undefined && operator !== "in") {
|
|
305
|
+
return null;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
const ternaryIndex = operands.findIndex(
|
|
309
|
+
(operand) => isOperatorOperand(operand) && operand.operator === "if"
|
|
310
|
+
);
|
|
311
|
+
|
|
312
|
+
if (ternaryIndex === -1) {
|
|
313
|
+
return null;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
if (operands.length !== 2) {
|
|
317
|
+
throw new UnsupportedQueryPlanError(
|
|
318
|
+
`${operator} with a ternary requires exactly 2 operands, got ${operands.length}`
|
|
319
|
+
);
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
const ternary = assertDefined(
|
|
323
|
+
operands[ternaryIndex],
|
|
324
|
+
"Ternary comparison operand is missing"
|
|
325
|
+
);
|
|
326
|
+
|
|
327
|
+
if (!isOperatorOperand(ternary)) {
|
|
328
|
+
throw new UnsupportedQueryPlanError("Ternary comparison operand must be an expression");
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
const { condition, thenBranch, elseBranch } = getTernaryOperands(
|
|
332
|
+
ternary.operands
|
|
333
|
+
);
|
|
334
|
+
|
|
335
|
+
const branchFor = (branch: PlanExpressionOperand) =>
|
|
336
|
+
buildTernaryComparisonBranch(
|
|
337
|
+
operator,
|
|
338
|
+
operands,
|
|
339
|
+
ternaryIndex,
|
|
340
|
+
branch,
|
|
341
|
+
context,
|
|
342
|
+
negated
|
|
343
|
+
);
|
|
344
|
+
|
|
345
|
+
const constantCondition = getConstantBooleanCondition(condition);
|
|
346
|
+
|
|
347
|
+
if (constantCondition !== undefined) {
|
|
348
|
+
return finalizeTernaryBranchPredicate(
|
|
349
|
+
branchFor(constantCondition ? thenBranch : elseBranch)
|
|
350
|
+
);
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
return buildGuardedTernaryFilter(
|
|
354
|
+
condition,
|
|
355
|
+
branchFor(thenBranch),
|
|
356
|
+
branchFor(elseBranch),
|
|
357
|
+
context
|
|
358
|
+
);
|
|
359
|
+
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* The
|
|
2
|
+
* The connection string a container-backed adversarial leg runs against — PostgreSQL or MySQL.
|
|
3
3
|
*
|
|
4
4
|
* `jest.globalSetup.adversarial.js` starts the container and exports `DATABASE_URL` before any
|
|
5
5
|
* test module loads. Throwing when it is missing is the point: a client that silently connected
|
|
@@ -8,11 +8,14 @@
|
|
|
8
8
|
*/
|
|
9
9
|
export function adversarialDatabaseUrl(): string {
|
|
10
10
|
const url = process.env["DATABASE_URL"];
|
|
11
|
+
|
|
11
12
|
if (!url) {
|
|
12
13
|
throw new Error(
|
|
13
|
-
"DATABASE_URL is not set:
|
|
14
|
-
"jest.globalSetup.adversarial.js, so run it through
|
|
14
|
+
"DATABASE_URL is not set: a container-backed adversarial leg is started by " +
|
|
15
|
+
"jest.globalSetup.adversarial.js, so run it through " +
|
|
16
|
+
"`npm run test:adversarial:postgres` or `npm run test:adversarial:mysql`"
|
|
15
17
|
);
|
|
16
18
|
}
|
|
19
|
+
|
|
17
20
|
return url;
|
|
18
21
|
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { Prisma, PrismaClient } from "./generated/prisma-adversarial-mysql-v6";
|
|
2
|
+
|
|
3
|
+
import { adversarialDatabaseUrl } from "./test-setup.adversarial-url";
|
|
4
|
+
|
|
5
|
+
// Prisma 6 reads the datasource URL from the schema's `env("DATABASE_URL")`, which the jest
|
|
6
|
+
// globalSetup exports once the container is up. Passing it explicitly as well keeps the failure
|
|
7
|
+
// mode loud: `adversarialDatabaseUrl` throws when the variable is missing, rather than letting the
|
|
8
|
+
// client fall back to whatever a developer happens to have in their environment.
|
|
9
|
+
const prisma = new PrismaClient({
|
|
10
|
+
datasources: { db: { url: adversarialDatabaseUrl() } },
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
export { prisma, Prisma };
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { PrismaMariaDb } from "@prisma/adapter-mariadb";
|
|
2
|
+
import { Prisma, PrismaClient } from "./generated/prisma-adversarial-mysql/client";
|
|
3
|
+
|
|
4
|
+
import { adversarialDatabaseUrl } from "./test-setup.adversarial-url";
|
|
5
|
+
|
|
6
|
+
// Prisma 7 reaches a database only through a driver adapter, and `@prisma/adapter-mariadb` is the
|
|
7
|
+
// one Prisma ships for the MySQL provider — the MariaDB connector speaks the MySQL protocol,
|
|
8
|
+
// including MySQL 8.4's caching_sha2_password default.
|
|
9
|
+
const adapter = new PrismaMariaDb(adversarialDatabaseUrl());
|
|
10
|
+
const prisma = new PrismaClient({ adapter });
|
|
11
|
+
|
|
12
|
+
export { prisma, Prisma };
|