@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
package/src/adversarial.test.ts
CHANGED
|
@@ -1,28 +1,48 @@
|
|
|
1
1
|
import * as fs from "fs";
|
|
2
2
|
import * as path from "path";
|
|
3
3
|
|
|
4
|
-
import {
|
|
5
|
-
import
|
|
6
|
-
|
|
7
|
-
|
|
4
|
+
import { afterAll, beforeAll, describe, expect, test } from "@jest/globals";
|
|
5
|
+
import {
|
|
6
|
+
PlanExpression,
|
|
7
|
+
PlanExpressionValue,
|
|
8
|
+
PlanExpressionVariable,
|
|
9
|
+
} from "@cerbos/core";
|
|
10
|
+
import type { PlanExpressionOperand, PlanResourcesResponse } from "@cerbos/core";
|
|
11
|
+
|
|
12
|
+
import { queryPlanToPrisma, PlanKind, UnsupportedQueryPlanError } from ".";
|
|
13
|
+
import type { PrismaFilter } from ".";
|
|
14
|
+
import {
|
|
15
|
+
assertFuzzVerdict,
|
|
16
|
+
FUZZ_DIR,
|
|
17
|
+
fuzzThrown,
|
|
18
|
+
fuzzVerdict,
|
|
19
|
+
MAPPER,
|
|
20
|
+
MODEL,
|
|
21
|
+
pdpTags,
|
|
22
|
+
planOf,
|
|
23
|
+
readCorpusJson,
|
|
24
|
+
readGolden,
|
|
25
|
+
readGoldens,
|
|
26
|
+
writeFuzzResults,
|
|
27
|
+
} from "./corpus";
|
|
28
|
+
import type { FuzzVerdict, Golden } from "./corpus";
|
|
8
29
|
import { prisma } from "./test-setup.adversarial";
|
|
9
30
|
|
|
10
31
|
/**
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
* check API for each row with attributes mirroring that row exactly.
|
|
32
|
+
* The conformance harness (conformance/README.md, "The harness contract"). It loads the corpus
|
|
33
|
+
* dataset into a real store, translates every recorded golden plan for both pinned PDPs, runs the
|
|
34
|
+
* query through a generated Prisma client, and compares the returned ids with the `allowed` ids the
|
|
35
|
+
* PDP's check() recorded. No PDP runs here. Exceptions live in `conformance-ledger.json`.
|
|
16
36
|
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
37
|
+
* The store is chosen with `ADAPTER_TEST_DB` (`sqlite` by default, `postgres` and `mysql` via
|
|
38
|
+
* testcontainers started in `jest.globalSetup.adversarial.js`), and the Prisma major with
|
|
39
|
+
* `PRISMA_VERSION`. The two are independent dimensions: v6/v7 is an ENGINE matrix, and an engine
|
|
40
|
+
* matrix says nothing about how a provider coerces a fractional threshold against an `Int` column,
|
|
41
|
+
* escapes a LIKE metacharacter, compares a real `timestamp` (cerbos/query-plan-adapters#320), or
|
|
42
|
+
* decides with its COLLATION whether `=` is case-sensitive at all (#340).
|
|
21
43
|
*/
|
|
22
44
|
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
const CONFORMANCE_DIR = path.join(__dirname, "..", "..", "conformance");
|
|
45
|
+
// -- the dataset: conformance/seeds.json + derived-fields.json ----------------------------------
|
|
26
46
|
|
|
27
47
|
interface Tag {
|
|
28
48
|
id: string;
|
|
@@ -31,556 +51,536 @@ interface Tag {
|
|
|
31
51
|
|
|
32
52
|
interface Seed {
|
|
33
53
|
id: string;
|
|
34
|
-
aBool: boolean;
|
|
35
|
-
aString: string;
|
|
36
|
-
aNumber: number;
|
|
54
|
+
aBool: boolean | null;
|
|
55
|
+
aString: string | null;
|
|
56
|
+
aNumber: number | null;
|
|
37
57
|
aOptionalString: string | null;
|
|
58
|
+
aNumberList: (number | null)[];
|
|
59
|
+
aBoolList: (boolean | null)[];
|
|
38
60
|
tags: Tag[];
|
|
39
61
|
subCategoryNames: string[];
|
|
62
|
+
/** The seed whose scalars this row's to-one `parent` carries; null for no parent. */
|
|
63
|
+
parentSeedId: string | null;
|
|
64
|
+
/** The store capabilities the row needs (conformance/README.md, "The harness contract"). */
|
|
65
|
+
requires?: string[];
|
|
40
66
|
}
|
|
41
67
|
|
|
42
|
-
interface
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
action: string;
|
|
50
|
-
shape: string;
|
|
51
|
-
springDataMessage: string;
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
interface AdapterUnsupportedEntry {
|
|
55
|
-
action: string;
|
|
56
|
-
reason: string;
|
|
68
|
+
interface DerivedEntry {
|
|
69
|
+
createdBy: string;
|
|
70
|
+
aDouble: number | null;
|
|
71
|
+
createdAt: string | null;
|
|
72
|
+
updatedAt: string | null;
|
|
73
|
+
scope: string | null;
|
|
74
|
+
labels: (string | null)[];
|
|
57
75
|
}
|
|
58
76
|
|
|
59
|
-
|
|
60
|
-
action: string;
|
|
61
|
-
adapters: string[];
|
|
62
|
-
}
|
|
77
|
+
const STORE_NAMES = ["sqlite", "postgres", "mysql"] as const;
|
|
63
78
|
|
|
64
|
-
|
|
65
|
-
conformance: string[];
|
|
66
|
-
adapterUnsupported?: Record<string, AdapterUnsupportedEntry[]>;
|
|
67
|
-
adapterSupportedExpected?: Record<string, AdapterUnsupportedEntry[]>;
|
|
68
|
-
expectedUnsupported: UnsupportedShape[];
|
|
69
|
-
knownDivergences?: KnownDivergence[];
|
|
70
|
-
}
|
|
79
|
+
type StoreName = (typeof STORE_NAMES)[number];
|
|
71
80
|
|
|
72
|
-
|
|
81
|
+
function selectedStoreName(): StoreName {
|
|
82
|
+
const requested = process.env["ADAPTER_TEST_DB"] ?? "sqlite";
|
|
73
83
|
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
supportedExpected: Set<string>;
|
|
78
|
-
}
|
|
84
|
+
// A typo must fail rather than silently fall back to SQLite (jest.adversarial.config.js rejects
|
|
85
|
+
// it first; the repeat keeps this file reading against a value it trusts).
|
|
86
|
+
const store = STORE_NAMES.find((name) => name === requested);
|
|
79
87
|
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
)
|
|
84
|
-
|
|
85
|
-
const unsupportedActions = new Set(unsupported.map((entry) => entry.action));
|
|
86
|
-
const supportedExpected = new Set(
|
|
87
|
-
(manifest.adapterSupportedExpected?.[adapter] ?? []).map(
|
|
88
|
-
(entry) => entry.action
|
|
89
|
-
)
|
|
90
|
-
);
|
|
91
|
-
const oracleActions = [
|
|
92
|
-
...manifest.conformance.filter(
|
|
93
|
-
(action) => !unsupportedActions.has(action)
|
|
94
|
-
),
|
|
95
|
-
...supportedExpected,
|
|
96
|
-
];
|
|
97
|
-
const throwingActions: ThrowingAction[] = [
|
|
98
|
-
...unsupported.map(
|
|
99
|
-
(entry): ThrowingAction => [entry.action, entry.reason]
|
|
100
|
-
),
|
|
101
|
-
...manifest.expectedUnsupported
|
|
102
|
-
.filter((entry) => !supportedExpected.has(entry.action))
|
|
103
|
-
.map((entry): ThrowingAction => [entry.action, entry.shape]),
|
|
104
|
-
];
|
|
88
|
+
if (store === undefined) {
|
|
89
|
+
throw new Error(
|
|
90
|
+
`Unknown ADAPTER_TEST_DB "${requested}": expected one of ${STORE_NAMES.join(", ")}`
|
|
91
|
+
);
|
|
92
|
+
}
|
|
105
93
|
|
|
106
|
-
return
|
|
107
|
-
oracleActions: [...new Set(oracleActions)].sort(),
|
|
108
|
-
throwingActions: throwingActions.sort(([left], [right]) =>
|
|
109
|
-
left.localeCompare(right)
|
|
110
|
-
),
|
|
111
|
-
supportedExpected,
|
|
112
|
-
};
|
|
94
|
+
return store;
|
|
113
95
|
}
|
|
114
96
|
|
|
115
|
-
const
|
|
116
|
-
|
|
97
|
+
const STORE_NAME = selectedStoreName();
|
|
98
|
+
|
|
99
|
+
// A seed requiring a capability the ledger's `lacks` names for this store is a resource it cannot
|
|
100
|
+
// hold: it is not loaded, and its id is struck from every allowed set.
|
|
101
|
+
const LACKS = Object.entries(
|
|
102
|
+
(
|
|
103
|
+
// SAFETY: validate-corpus.sh checks every conformance-ledger.json against its schema.
|
|
104
|
+
JSON.parse(
|
|
105
|
+
fs.readFileSync(path.join(__dirname, "..", "conformance-ledger.json"), "utf8")
|
|
106
|
+
) as { lacks?: Record<string, { reason: string; stores?: string[] }> }
|
|
107
|
+
).lacks ?? {}
|
|
108
|
+
)
|
|
109
|
+
.filter(([, entry]) => entry.stores?.includes(STORE_NAME) ?? true)
|
|
110
|
+
.map(([capability]) => capability);
|
|
111
|
+
|
|
112
|
+
const UNHELD = new Set(
|
|
113
|
+
(readCorpusJson<{ seeds: Seed[] }>("seeds.json")).seeds
|
|
114
|
+
.filter((seed) => seed.requires?.some((capability) => LACKS.includes(capability)))
|
|
115
|
+
.map((seed) => seed.id)
|
|
117
116
|
);
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
)
|
|
121
|
-
const SEEDS = seedsFile.seeds;
|
|
122
|
-
|
|
123
|
-
const {
|
|
124
|
-
oracleActions: ORACLE_ACTIONS,
|
|
125
|
-
throwingActions: THROWING_ACTIONS,
|
|
126
|
-
supportedExpected: PRISMA_SUPPORTED_EXPECTED,
|
|
127
|
-
} = classifyActionsForAdapter(actionsFile, "prisma");
|
|
128
|
-
const PRISMA_KNOWN_DIVERGENCES = new Set(
|
|
129
|
-
(actionsFile.knownDivergences ?? [])
|
|
130
|
-
.filter((entry) => entry.adapters.includes("prisma"))
|
|
131
|
-
.map((entry) => entry.action)
|
|
132
|
-
);
|
|
133
|
-
const EXPECTED_UNSUPPORTED_ACTIONS = new Set(
|
|
134
|
-
actionsFile.expectedUnsupported.map((entry) => entry.action)
|
|
117
|
+
|
|
118
|
+
const SEEDS = (readCorpusJson<{ seeds: Seed[] }>("seeds.json")).seeds.filter(
|
|
119
|
+
(seed) => !UNHELD.has(seed.id)
|
|
135
120
|
);
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
...PRISMA_KNOWN_DIVERGENCES,
|
|
140
|
-
]);
|
|
141
|
-
|
|
142
|
-
function doubleFor(seed: Seed): number | null {
|
|
143
|
-
switch (seed.id) {
|
|
144
|
-
case "a1":
|
|
145
|
-
return -0.6;
|
|
146
|
-
case "a2":
|
|
147
|
-
return 0.25;
|
|
148
|
-
case "a3":
|
|
149
|
-
return null;
|
|
150
|
-
default:
|
|
151
|
-
return seed.aNumber + 0.3;
|
|
152
|
-
}
|
|
121
|
+
|
|
122
|
+
function held(allowed: string[]): string[] {
|
|
123
|
+
return allowed.filter((id) => !UNHELD.has(id));
|
|
153
124
|
}
|
|
154
125
|
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
return ["Gold"];
|
|
165
|
-
default:
|
|
166
|
-
return [];
|
|
126
|
+
const DERIVED = (
|
|
127
|
+
readCorpusJson<{ derived: Record<string, DerivedEntry> }>("derived-fields.json")
|
|
128
|
+
).derived;
|
|
129
|
+
|
|
130
|
+
function derivedFor(seed: Seed): DerivedEntry {
|
|
131
|
+
const entry = DERIVED[seed.id];
|
|
132
|
+
|
|
133
|
+
if (entry === undefined) {
|
|
134
|
+
throw new Error(`derived-fields.json has no entry for seed "${seed.id}"`);
|
|
167
135
|
}
|
|
168
|
-
}
|
|
169
136
|
|
|
170
|
-
|
|
171
|
-
function isoFor(seed: Seed): string {
|
|
172
|
-
return seed.aNumber >= 2 ? "2024-06-01T00:00:00Z" : "2026-06-01T00:00:00Z";
|
|
137
|
+
return entry;
|
|
173
138
|
}
|
|
174
139
|
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
return "2024-06-01T00:00:00Z";
|
|
185
|
-
case "a5":
|
|
186
|
-
return "2020-03-15T10:30:00.123456Z";
|
|
187
|
-
default:
|
|
188
|
-
return seed.aNumber >= 2
|
|
189
|
-
? "2036-06-06T06:06:06Z"
|
|
190
|
-
: "2021-05-05T05:05:05Z";
|
|
140
|
+
// The real to-one relation (conformance/README.md, "The dataset"): every resource owns a FRESH
|
|
141
|
+
// parent (and inner) row carrying the named seed's scalars, so no two resources share one.
|
|
142
|
+
const SEEDS_BY_ID = new Map(SEEDS.map((seed) => [seed.id, seed]));
|
|
143
|
+
|
|
144
|
+
function parentSeedOf(seed: Seed | undefined): Seed | undefined {
|
|
145
|
+
const id = seed?.parentSeedId;
|
|
146
|
+
|
|
147
|
+
if (id === undefined || id === null) {
|
|
148
|
+
return undefined;
|
|
191
149
|
}
|
|
192
|
-
}
|
|
193
150
|
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
case "a2":
|
|
199
|
-
return "dept.eng";
|
|
200
|
-
case "a3":
|
|
201
|
-
return "dept.eng.platform";
|
|
202
|
-
case "a4":
|
|
203
|
-
return "dept.eng.platform.obs";
|
|
204
|
-
case "a5":
|
|
205
|
-
return "dept.engineering";
|
|
206
|
-
case "a6":
|
|
207
|
-
return "dept.sales";
|
|
208
|
-
case "a8":
|
|
209
|
-
return "";
|
|
210
|
-
case "a9":
|
|
211
|
-
return "50%";
|
|
212
|
-
case "b1":
|
|
213
|
-
return "50%:a_b:x";
|
|
214
|
-
case "b2":
|
|
215
|
-
return "50x:a_b:y";
|
|
216
|
-
case "b3":
|
|
217
|
-
return "50%:aXb:y";
|
|
218
|
-
case "b4":
|
|
219
|
-
return "50%:a_b";
|
|
220
|
-
case "b5":
|
|
221
|
-
return "dept.eng.platform2";
|
|
222
|
-
case "b6":
|
|
223
|
-
return "50%.a_b";
|
|
224
|
-
case "c1":
|
|
225
|
-
return "Dept.Eng";
|
|
226
|
-
case "c2":
|
|
227
|
-
return "dept.eng.";
|
|
228
|
-
case "d1":
|
|
229
|
-
return "[env]:prod:eu";
|
|
230
|
-
case "d2":
|
|
231
|
-
return "e:prod:eu";
|
|
232
|
-
default:
|
|
233
|
-
return null;
|
|
151
|
+
const parent = SEEDS_BY_ID.get(id);
|
|
152
|
+
|
|
153
|
+
if (parent === undefined) {
|
|
154
|
+
throw new Error(`seeds.json: "${seed?.id}" names parent "${id}", which is not a seed id`);
|
|
234
155
|
}
|
|
156
|
+
|
|
157
|
+
return parent;
|
|
235
158
|
}
|
|
236
159
|
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
field: "createdAt",
|
|
247
|
-
valueType: "dateTime",
|
|
248
|
-
},
|
|
249
|
-
"request.resource.attr.owner": {
|
|
250
|
-
field: "aOptionalString",
|
|
251
|
-
nullable: true,
|
|
252
|
-
},
|
|
253
|
-
// obj.inner is not a real nested column — mirrors aString, same trick the spring-data
|
|
254
|
-
// reference harness uses for the p-struct probe.
|
|
255
|
-
"request.resource.attr.obj.inner": { field: "aString" },
|
|
256
|
-
"request.resource.attr.tags": {
|
|
257
|
-
relation: {
|
|
258
|
-
name: "tags",
|
|
259
|
-
type: "many",
|
|
260
|
-
// Model name enables field-to-field comparisons between tag columns; the nullable
|
|
261
|
-
// flag on `name` enables the adapter's three-valued-logic guards for collection
|
|
262
|
-
// macros over elements whose name column is NULL (a missing attribute — a CEL
|
|
263
|
-
// error, hence deny — on the check side).
|
|
264
|
-
model: "AdversarialTag",
|
|
265
|
-
fields: {
|
|
266
|
-
id: { field: "tagId" },
|
|
267
|
-
name: { field: "name", nullable: true },
|
|
268
|
-
},
|
|
269
|
-
},
|
|
270
|
-
},
|
|
271
|
-
"request.resource.attr.tagNames": {
|
|
272
|
-
relation: {
|
|
273
|
-
name: "tags",
|
|
274
|
-
type: "many",
|
|
275
|
-
field: "name",
|
|
276
|
-
fields: { name: { field: "name", nullable: true } },
|
|
277
|
-
},
|
|
278
|
-
},
|
|
279
|
-
"request.resource.attr.categories": {
|
|
280
|
-
relation: {
|
|
281
|
-
name: "categories",
|
|
282
|
-
type: "many",
|
|
283
|
-
fields: {
|
|
284
|
-
name: { field: "name" },
|
|
285
|
-
subCategories: {
|
|
286
|
-
relation: {
|
|
287
|
-
name: "subCategories",
|
|
288
|
-
type: "many",
|
|
289
|
-
fields: {
|
|
290
|
-
name: { field: "name" },
|
|
291
|
-
labels: {
|
|
292
|
-
relation: {
|
|
293
|
-
name: "labels",
|
|
294
|
-
type: "many",
|
|
295
|
-
fields: {
|
|
296
|
-
name: { field: "name", nullable: true },
|
|
297
|
-
},
|
|
298
|
-
},
|
|
299
|
-
},
|
|
300
|
-
},
|
|
301
|
-
},
|
|
302
|
-
},
|
|
303
|
-
},
|
|
304
|
-
},
|
|
305
|
-
},
|
|
306
|
-
// Multi-hop chain probe (W1): mainCategory mirrors the SAME categories/subCategories relation
|
|
307
|
-
// as a single-object dotted chain on the check side (every seed holds at most one category),
|
|
308
|
-
// pinning that the adapter joins through every intermediate hop, never off the root.
|
|
309
|
-
"request.resource.attr.mainCategory": {
|
|
310
|
-
relation: {
|
|
311
|
-
name: "categories",
|
|
312
|
-
type: "many",
|
|
313
|
-
fields: {
|
|
314
|
-
name: { field: "name" },
|
|
315
|
-
subCategories: {
|
|
316
|
-
relation: {
|
|
317
|
-
name: "subCategories",
|
|
318
|
-
type: "many",
|
|
319
|
-
fields: { name: { field: "name" } },
|
|
320
|
-
},
|
|
321
|
-
},
|
|
322
|
-
// subNames: the same 2-hop chain but with a bare `field`, so plain `in` membership
|
|
323
|
-
// compares the flattened tail's name column directly.
|
|
324
|
-
subNames: {
|
|
325
|
-
relation: {
|
|
326
|
-
name: "subCategories",
|
|
327
|
-
type: "many",
|
|
328
|
-
field: "name",
|
|
329
|
-
},
|
|
330
|
-
},
|
|
331
|
-
},
|
|
332
|
-
},
|
|
333
|
-
},
|
|
334
|
-
};
|
|
160
|
+
/** The four scalars one level of the chain stores, as columns. */
|
|
161
|
+
function relationColumns(seed: Seed) {
|
|
162
|
+
return {
|
|
163
|
+
aBool: seed.aBool,
|
|
164
|
+
aString: seed.aString,
|
|
165
|
+
aNumber: seed.aNumber,
|
|
166
|
+
aOptionalString: seed.aOptionalString,
|
|
167
|
+
};
|
|
168
|
+
}
|
|
335
169
|
|
|
336
|
-
|
|
170
|
+
async function seedStore(): Promise<void> {
|
|
171
|
+
await prisma.adversarialInner.deleteMany();
|
|
172
|
+
await prisma.adversarialParent.deleteMany();
|
|
337
173
|
await prisma.adversarialLabel.deleteMany();
|
|
338
174
|
await prisma.adversarialSubCategory.deleteMany();
|
|
339
175
|
await prisma.adversarialCategory.deleteMany();
|
|
340
176
|
await prisma.adversarialTag.deleteMany();
|
|
177
|
+
await prisma.adversarialNumberListElement.deleteMany();
|
|
178
|
+
await prisma.adversarialBoolListElement.deleteMany();
|
|
341
179
|
await prisma.adversarialResource.deleteMany();
|
|
342
180
|
|
|
343
181
|
// Distinct sub-category/category graphs per seed so no rows share relations by accident.
|
|
344
182
|
for (const seed of SEEDS) {
|
|
183
|
+
const derived = derivedFor(seed);
|
|
184
|
+
const parentSeed = parentSeedOf(seed);
|
|
185
|
+
const innerSeed = parentSeedOf(parentSeed);
|
|
345
186
|
await prisma.adversarialResource.create({
|
|
346
187
|
data: {
|
|
347
188
|
id: seed.id,
|
|
348
189
|
aBool: seed.aBool,
|
|
349
190
|
aString: seed.aString,
|
|
350
191
|
aNumber: seed.aNumber,
|
|
351
|
-
aDouble:
|
|
192
|
+
aDouble: derived.aDouble,
|
|
352
193
|
aOptionalString: seed.aOptionalString,
|
|
353
|
-
createdBy:
|
|
354
|
-
scope:
|
|
355
|
-
createdAt:
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
194
|
+
createdBy: derived.createdBy,
|
|
195
|
+
scope: derived.scope,
|
|
196
|
+
createdAt: derived.createdAt,
|
|
197
|
+
updatedAt: derived.updatedAt,
|
|
198
|
+
// One row per element, null elements included: membership is all a filter asks of
|
|
199
|
+
// either list, so the element's position is not stored.
|
|
200
|
+
numberList: { create: seed.aNumberList.map((value) => ({ value })) },
|
|
201
|
+
boolList: { create: seed.aBoolList.map((value) => ({ value })) },
|
|
202
|
+
tags: { create: seed.tags.map((t) => ({ tagId: t.id, name: t.name })) },
|
|
203
|
+
// The to-one chain, one owned row per level. A seed with no parent gets no row at all:
|
|
204
|
+
// Prisma reads an undefined relation as no write.
|
|
205
|
+
parent:
|
|
206
|
+
parentSeed === undefined
|
|
207
|
+
? undefined
|
|
208
|
+
: {
|
|
209
|
+
create: {
|
|
210
|
+
...relationColumns(parentSeed),
|
|
211
|
+
inner: innerSeed === undefined ? undefined : { create: relationColumns(innerSeed) },
|
|
212
|
+
},
|
|
213
|
+
},
|
|
214
|
+
// One category holding every subcategory name (conformance/README.md, "The dataset").
|
|
359
215
|
categories: {
|
|
360
|
-
create:
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
216
|
+
create:
|
|
217
|
+
seed.subCategoryNames.length === 0
|
|
218
|
+
? []
|
|
219
|
+
: [
|
|
220
|
+
{
|
|
221
|
+
name: "business",
|
|
222
|
+
subCategories: {
|
|
223
|
+
create: seed.subCategoryNames.map((subName) => ({
|
|
224
|
+
name: subName,
|
|
225
|
+
labels: { create: derived.labels.map((name) => ({ name })) },
|
|
226
|
+
})),
|
|
227
|
+
},
|
|
368
228
|
},
|
|
369
|
-
|
|
370
|
-
],
|
|
371
|
-
},
|
|
372
|
-
})),
|
|
229
|
+
],
|
|
373
230
|
},
|
|
374
231
|
},
|
|
375
232
|
});
|
|
376
233
|
}
|
|
377
|
-
}
|
|
234
|
+
}
|
|
378
235
|
|
|
379
|
-
|
|
380
|
-
await prisma.$disconnect();
|
|
381
|
-
});
|
|
236
|
+
// -- the store ------------------------------------------------------------------------------------
|
|
382
237
|
|
|
383
|
-
|
|
384
|
-
|
|
238
|
+
/**
|
|
239
|
+
* How each leg asks its own connection which engine answered, and the first word of the answer.
|
|
240
|
+
* Each spelling is a syntax error on the other two engines, so the anti-vacuity test fails in every
|
|
241
|
+
* direction. MySQL's `version()` answers a bare `8.4.x`; `@@version_comment` names the engine.
|
|
242
|
+
*/
|
|
243
|
+
const STORE_BANNERS: Record<StoreName, { query: string; engine: string }> = {
|
|
244
|
+
sqlite: { query: "select 'SQLite ' || sqlite_version() as banner", engine: "SQLite" },
|
|
245
|
+
postgres: { query: "select version() as banner", engine: "PostgreSQL" },
|
|
246
|
+
mysql: { query: "select @@version_comment as banner", engine: "MySQL" },
|
|
247
|
+
};
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* The six adversarial schemas — (Prisma 6, Prisma 7) x (SQLite, PostgreSQL, MySQL) — must hold one
|
|
251
|
+
* data model between them: a column that drifts in one would seed a different row shape on that leg
|
|
252
|
+
* while every assertion here stayed identical.
|
|
253
|
+
*/
|
|
254
|
+
const ADVERSARIAL_SCHEMAS = [
|
|
255
|
+
"schema.adversarial.prisma",
|
|
256
|
+
"schema.adversarial.v6.prisma",
|
|
257
|
+
"schema.adversarial.pg.prisma",
|
|
258
|
+
"schema.adversarial.pg.v6.prisma",
|
|
259
|
+
"schema.adversarial.mysql.prisma",
|
|
260
|
+
"schema.adversarial.mysql.v6.prisma",
|
|
261
|
+
] as const;
|
|
262
|
+
|
|
263
|
+
/** A schema's `model` blocks, without comments, generator/datasource blocks or incidental space. */
|
|
264
|
+
function modelBlocks(schema: string): string {
|
|
265
|
+
const withoutComments = schema
|
|
266
|
+
.split("\n")
|
|
267
|
+
.filter((line) => !line.trimStart().startsWith("//"))
|
|
268
|
+
.join("\n");
|
|
269
|
+
|
|
270
|
+
const models = withoutComments.match(/^model\s[\s\S]*$/m) ?? [];
|
|
271
|
+
|
|
272
|
+
return models.join("\n").replace(/[ \t]+/g, " ").replace(/\n\s*\n/g, "\n").trim();
|
|
385
273
|
}
|
|
386
274
|
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
attr["name"] = tag.name;
|
|
392
|
-
}
|
|
393
|
-
return attr;
|
|
275
|
+
async function selectIds(where: PrismaFilter): Promise<string[]> {
|
|
276
|
+
const rows = await prisma.adversarialResource.findMany({ where, select: { id: true } });
|
|
277
|
+
|
|
278
|
+
return rows.map((row) => row.id).sort();
|
|
394
279
|
}
|
|
395
280
|
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
name === null ? {} : { name }
|
|
414
|
-
),
|
|
415
|
-
},
|
|
416
|
-
],
|
|
417
|
-
})),
|
|
418
|
-
};
|
|
419
|
-
// A DB NULL is a missing attribute on the check side — conditions touching it must deny
|
|
420
|
-
// (CEL error), matching SQL three-valued logic excluding the row.
|
|
421
|
-
if (seed.aOptionalString !== null) {
|
|
422
|
-
attr["aOptionalString"] = seed.aOptionalString;
|
|
423
|
-
}
|
|
424
|
-
const aDouble = doubleFor(seed);
|
|
425
|
-
if (aDouble !== null) {
|
|
426
|
-
attr["aDouble"] = aDouble;
|
|
427
|
-
}
|
|
428
|
-
const scope = scopeFor(seed);
|
|
429
|
-
if (scope !== null) {
|
|
430
|
-
attr["scope"] = scope;
|
|
431
|
-
}
|
|
432
|
-
const createdAt = timestampFor(seed);
|
|
433
|
-
if (createdAt !== null) {
|
|
434
|
-
attr["createdAt"] = createdAt;
|
|
435
|
-
}
|
|
436
|
-
// mainCategory mirrors the row's single category as ONE nested object (the seeder creates
|
|
437
|
-
// at most one category per seed), so direct dotted-chain CEL expressions evaluate cleanly;
|
|
438
|
-
// rows without a category get NO attribute — a CEL missing-attr error (deny), matching the
|
|
439
|
-
// adapter's empty join chain excluding the row.
|
|
440
|
-
if (seed.subCategoryNames.length > 0) {
|
|
441
|
-
attr["mainCategory"] = {
|
|
442
|
-
name: "business",
|
|
443
|
-
subCategories: seed.subCategoryNames.map((name) => ({ name })),
|
|
444
|
-
subNames: seed.subCategoryNames,
|
|
445
|
-
};
|
|
446
|
-
}
|
|
447
|
-
return { kind: seedsFile.resourceKind, id: seed.id, attr };
|
|
281
|
+
// Every attribute the dataset omits when NULL is declared `omitted` in MAPPER. The call-level
|
|
282
|
+
// option covers what is left, the list and relation elements, which the dataset sends as values;
|
|
283
|
+
// the adapter's own default is pinned in translator.test.ts.
|
|
284
|
+
const translatePlan = (queryPlan: PlanResourcesResponse) =>
|
|
285
|
+
queryPlanToPrisma({
|
|
286
|
+
queryPlan,
|
|
287
|
+
mapper: MAPPER,
|
|
288
|
+
model: MODEL,
|
|
289
|
+
nullAttributeRepresentation: "explicit",
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
async function selectAllowed(queryPlan: PlanResourcesResponse): Promise<string[]> {
|
|
293
|
+
const result = translatePlan(queryPlan);
|
|
294
|
+
|
|
295
|
+
if (result.kind === PlanKind.ALWAYS_DENIED) return [];
|
|
296
|
+
|
|
297
|
+
return selectIds(result.kind === PlanKind.CONDITIONAL ? result.filters : {});
|
|
448
298
|
}
|
|
449
299
|
|
|
450
|
-
// --
|
|
300
|
+
// -- the ledger -----------------------------------------------------------------------------------
|
|
451
301
|
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
resource: asCheckResource(seed),
|
|
458
|
-
actions: [action],
|
|
459
|
-
});
|
|
460
|
-
if (result.isAllowed(action)) {
|
|
461
|
-
ids.push(seed.id);
|
|
462
|
-
}
|
|
463
|
-
}
|
|
464
|
-
return ids.sort();
|
|
302
|
+
interface LedgerEntry {
|
|
303
|
+
status: "unsupported" | "divergent";
|
|
304
|
+
reason: string;
|
|
305
|
+
issue?: string;
|
|
306
|
+
pdp?: string[];
|
|
465
307
|
}
|
|
466
308
|
|
|
467
|
-
|
|
309
|
+
const LEDGER = (
|
|
310
|
+
// SAFETY: validate-corpus.sh checks every conformance-ledger.json against its schema.
|
|
311
|
+
JSON.parse(
|
|
312
|
+
fs.readFileSync(path.join(__dirname, "..", "conformance-ledger.json"), "utf8")
|
|
313
|
+
) as { cases: Record<string, LedgerEntry> }
|
|
314
|
+
).cases;
|
|
468
315
|
|
|
469
|
-
|
|
470
|
-
const
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
316
|
+
function ledgerEntry(golden: Golden, tag: string): LedgerEntry | undefined {
|
|
317
|
+
const entry = LEDGER[golden.id];
|
|
318
|
+
|
|
319
|
+
return entry && (entry.pdp === undefined || entry.pdp.includes(tag)) ? entry : undefined;
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
// -- the replay -----------------------------------------------------------------------------------
|
|
323
|
+
|
|
324
|
+
const TAGS = pdpTags();
|
|
325
|
+
const GOLDENS = new Map(TAGS.map((tag) => [tag, readGoldens(tag)]));
|
|
326
|
+
const passed: Record<string, Record<string, number>> = {};
|
|
327
|
+
const totals: Record<string, Record<string, number>> = {};
|
|
328
|
+
const fuzzResults: FuzzVerdict[] = [];
|
|
329
|
+
|
|
330
|
+
/** A fuzz case judged on this store (conformance/README.md, "Fuzzing"). */
|
|
331
|
+
async function fuzzReplay(golden: Golden): Promise<FuzzVerdict> {
|
|
332
|
+
try {
|
|
333
|
+
return fuzzVerdict(golden, held(golden.allowed).sort(), await selectAllowed(await planOf(golden)));
|
|
334
|
+
} catch (error) {
|
|
335
|
+
return fuzzThrown(golden, error, UnsupportedQueryPlanError);
|
|
482
336
|
}
|
|
483
|
-
const where = result.kind === PlanKind.CONDITIONAL ? result.filters : {};
|
|
484
|
-
const rows = await prisma.adversarialResource.findMany({
|
|
485
|
-
where,
|
|
486
|
-
select: { id: true },
|
|
487
|
-
});
|
|
488
|
-
return rows.map((r) => r.id).sort();
|
|
489
337
|
}
|
|
490
338
|
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
prisma: [{ action: promotedAction, reason: "supported by Prisma" }],
|
|
499
|
-
},
|
|
500
|
-
expectedUnsupported: [
|
|
501
|
-
{
|
|
502
|
-
action: promotedAction,
|
|
503
|
-
shape: "synthetic globally unsupported shape",
|
|
504
|
-
springDataMessage: "unsupported",
|
|
505
|
-
},
|
|
506
|
-
],
|
|
507
|
-
},
|
|
508
|
-
"prisma"
|
|
509
|
-
);
|
|
339
|
+
beforeAll(async () => {
|
|
340
|
+
// CEL string matching is case-sensitive, and this adapter lowers contains/startsWith/endsWith to
|
|
341
|
+
// LIKE. On SQLite, LIKE is case-INSENSITIVE for ASCII whatever the column collation; only this
|
|
342
|
+
// pragma changes it (conformance/README.md, "Store configuration is part of conformance").
|
|
343
|
+
if (STORE_NAME === "sqlite") {
|
|
344
|
+
await prisma.$executeRawUnsafe("PRAGMA case_sensitive_like = ON");
|
|
345
|
+
}
|
|
510
346
|
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
347
|
+
await seedStore();
|
|
348
|
+
}, 120_000);
|
|
349
|
+
|
|
350
|
+
afterAll(async () => {
|
|
351
|
+
await prisma.$disconnect();
|
|
352
|
+
writeFuzzResults("prisma", `${STORE_NAME}-v${process.env["PRISMA_VERSION"] ?? "7"}`, fuzzResults);
|
|
353
|
+
|
|
354
|
+
const lines = TAGS.map((tag) => {
|
|
355
|
+
const tiers = Object.keys(totals[tag] ?? {}).sort();
|
|
356
|
+
const skipped = GOLDENS.get(tag)!.filter((golden) => golden.plannerDivergence !== null);
|
|
357
|
+
|
|
358
|
+
return `${tag}: ${tiers.map((tier) => `${tier} ${passed[tag]?.[tier] ?? 0}/${totals[tag]?.[tier]}`).join(", ")} (planner divergences skipped: ${skipped.map((golden) => golden.id).join(", ") || "none"})`;
|
|
515
359
|
});
|
|
516
360
|
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
const throwing = new Set(THROWING_ACTIONS.map(([action]) => action));
|
|
520
|
-
const misclassified = [...MANIFEST_ACTIONS].filter((action) => {
|
|
521
|
-
const classificationCount = [
|
|
522
|
-
oracle.has(action),
|
|
523
|
-
throwing.has(action),
|
|
524
|
-
PRISMA_KNOWN_DIVERGENCES.has(action),
|
|
525
|
-
].filter(Boolean).length;
|
|
526
|
-
return classificationCount !== 1;
|
|
527
|
-
});
|
|
361
|
+
console.log(`conformance (${STORE_NAME}) passed per tier\n${lines.join("\n")}`);
|
|
362
|
+
});
|
|
528
363
|
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
)
|
|
364
|
+
describe(`conformance (${STORE_NAME})`, () => {
|
|
365
|
+
// Every assertion below is identical on every leg, so a PostgreSQL leg that silently fell back to
|
|
366
|
+
// SQLite would pass while proving nothing about PostgreSQL. Ask the connection which engine it is.
|
|
367
|
+
test("executes against the store ADAPTER_TEST_DB selects", async () => {
|
|
368
|
+
const rows = await prisma.$queryRawUnsafe<{ banner: string }[]>(
|
|
369
|
+
STORE_BANNERS[STORE_NAME].query
|
|
370
|
+
);
|
|
371
|
+
|
|
372
|
+
expect(rows[0]?.banner?.split(" ")[0]).toBe(STORE_BANNERS[STORE_NAME].engine);
|
|
536
373
|
});
|
|
537
374
|
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
375
|
+
// Store configuration is part of conformance. Prisma's MySQL migration engine hardcodes
|
|
376
|
+
// `utf8mb4_unicode_ci`, so the tables are converted after `db push`
|
|
377
|
+
// (jest.globalSetup.adversarial.js). Under that default these return the case variant, the
|
|
378
|
+
// accent variant and the wrong-case prefix; asserted by name so a failure reads as "the store is
|
|
379
|
+
// misconfigured" rather than as a translation bug.
|
|
380
|
+
(STORE_NAME === "mysql" ? test : test.skip)(
|
|
381
|
+
"the MySQL leg runs under a byte-exact NO PAD collation",
|
|
382
|
+
async () => {
|
|
383
|
+
expect({
|
|
384
|
+
caseVariant: await selectIds({ aString: "one" }),
|
|
385
|
+
accentFolded: await selectIds({ aString: "hello🚀" }),
|
|
386
|
+
wrongCasePrefix: await selectIds({ aString: { startsWith: "ON" } }),
|
|
387
|
+
trailingSpace: await selectIds({ aString: "one " }),
|
|
388
|
+
}).toEqual({ caseVariant: ["a1"], accentFolded: [], wrongCasePrefix: [], trailingSpace: [] });
|
|
546
389
|
}
|
|
547
390
|
);
|
|
548
391
|
|
|
549
|
-
//
|
|
550
|
-
//
|
|
551
|
-
//
|
|
552
|
-
test.
|
|
553
|
-
"
|
|
554
|
-
async (
|
|
555
|
-
await
|
|
392
|
+
// `PRAGMA case_sensitive_like` holds per CONNECTION, so it must reach every connection a query can
|
|
393
|
+
// land on. Prisma 6 pooled SQLite connections until its schema pinned `connection_limit=1`;
|
|
394
|
+
// concurrent queries are what spread onto the rest.
|
|
395
|
+
(STORE_NAME === "sqlite" ? test : test.skip)(
|
|
396
|
+
"the SQLite leg's LIKE is case-sensitive on every connection",
|
|
397
|
+
async () => {
|
|
398
|
+
const results = await Promise.all(
|
|
399
|
+
Array.from({ length: 32 }, () => selectIds({ aString: { contains: "one" } }))
|
|
400
|
+
);
|
|
401
|
+
|
|
402
|
+
expect(results[0]?.length).toBeGreaterThan(0);
|
|
403
|
+
expect(results.filter((ids) => ids.includes("c1"))).toEqual([]);
|
|
556
404
|
}
|
|
557
405
|
);
|
|
558
406
|
|
|
559
|
-
test("
|
|
560
|
-
const
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
407
|
+
test("every adversarial schema declares the same data model", () => {
|
|
408
|
+
const [reference, ...rest] = ADVERSARIAL_SCHEMAS.map((name) => ({
|
|
409
|
+
name,
|
|
410
|
+
models: modelBlocks(
|
|
411
|
+
fs.readFileSync(path.join(__dirname, "..", "prisma", name), "utf8")
|
|
412
|
+
),
|
|
413
|
+
}));
|
|
414
|
+
|
|
415
|
+
expect(reference?.models).toContain(`model ${MODEL}`);
|
|
416
|
+
|
|
417
|
+
for (const schema of rest) {
|
|
418
|
+
expect(schema).toEqual({ name: schema.name, models: reference?.models });
|
|
419
|
+
}
|
|
420
|
+
});
|
|
421
|
+
|
|
422
|
+
(FUZZ_DIR ? test.skip : test)("the ledger names no case without a golden file", () => {
|
|
423
|
+
const ids = new Set(TAGS.flatMap((tag) => GOLDENS.get(tag)!.map((golden) => golden.id)));
|
|
424
|
+
expect(Object.keys(LEDGER).filter((id) => !ids.has(id))).toEqual([]);
|
|
425
|
+
});
|
|
426
|
+
|
|
427
|
+
// A golden that does not say which PDP recorded it, or whether that PDP diverged, cannot be
|
|
428
|
+
// routed: fail on it rather than let the filter below read a missing field as "no divergence".
|
|
429
|
+
test("every golden file is recorded for its tag and declares plannerDivergence", () => {
|
|
430
|
+
const malformed = TAGS.flatMap((tag) =>
|
|
431
|
+
GOLDENS.get(tag)!
|
|
432
|
+
.filter((golden) => golden.pdp !== tag || golden.plannerDivergence === undefined)
|
|
433
|
+
.map((golden) => `${tag}/${golden.id}`)
|
|
434
|
+
);
|
|
435
|
+
|
|
436
|
+
expect(malformed).toEqual([]);
|
|
575
437
|
});
|
|
576
438
|
|
|
577
|
-
|
|
578
|
-
//
|
|
579
|
-
//
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
439
|
+
for (const tag of TAGS) {
|
|
440
|
+
// A plannerDivergence is a PDP bug no adapter can pass. The generator writes it only into the
|
|
441
|
+
// golden files of the tags it applies to, so a non-null value is the whole skip condition.
|
|
442
|
+
const cases = GOLDENS.get(tag)!.filter((golden) => golden.plannerDivergence === null);
|
|
443
|
+
|
|
444
|
+
test.each(cases.map((golden) => [golden.id, golden] as const))(
|
|
445
|
+
`${tag} %s`,
|
|
446
|
+
async (_id, golden) => {
|
|
447
|
+
if (FUZZ_DIR) {
|
|
448
|
+
const verdict = await fuzzReplay(golden);
|
|
449
|
+
fuzzResults.push(verdict);
|
|
450
|
+
assertFuzzVerdict(verdict);
|
|
451
|
+
|
|
452
|
+
return;
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
const entry = ledgerEntry(golden, tag);
|
|
456
|
+
const queryPlan = await planOf(golden);
|
|
457
|
+
|
|
458
|
+
if (entry?.status === "unsupported") {
|
|
459
|
+
expect(() => translatePlan(queryPlan)).toThrow(UnsupportedQueryPlanError);
|
|
460
|
+
|
|
461
|
+
return;
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
// Both sides sorted the same way, so a divergent entry cannot pass on ordering alone.
|
|
465
|
+
const ids = await selectAllowed(queryPlan);
|
|
466
|
+
const allowed = held(golden.allowed).sort();
|
|
467
|
+
|
|
468
|
+
if (entry?.status === "divergent") {
|
|
469
|
+
expect(ids).not.toEqual(allowed);
|
|
470
|
+
|
|
471
|
+
return;
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
expect(ids).toEqual(allowed);
|
|
475
|
+
(passed[tag] ??= {})[golden.tier] = (passed[tag]?.[golden.tier] ?? 0) + 1;
|
|
476
|
+
}
|
|
477
|
+
);
|
|
478
|
+
|
|
479
|
+
// The denominator is every golden file in the tier, skipped planner divergences included.
|
|
480
|
+
for (const golden of GOLDENS.get(tag)!) {
|
|
481
|
+
(totals[tag] ??= {})[golden.tier] = (totals[tag]?.[golden.tier] ?? 0) + 1;
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
});
|
|
485
|
+
|
|
486
|
+
// -- shapes the corpus does not spell -------------------------------------------------------------
|
|
487
|
+
//
|
|
488
|
+
// Synthesised plans over the same seeded store, for guards that must be a property of the
|
|
489
|
+
// `mainCategory` chain rather than of the few spellings the corpus pins. Every expectation is a
|
|
490
|
+
// recorded check() decision from a golden file, never a hand-computed row list.
|
|
491
|
+
|
|
492
|
+
describe(`the absent-parent guard over the chain (${STORE_NAME})`, () => {
|
|
493
|
+
const allowedFor = (id: string) => held(readGolden(TAGS[0]!, id).allowed);
|
|
494
|
+
|
|
495
|
+
async function synthetic(condition: PlanExpressionOperand): Promise<string[]> {
|
|
496
|
+
const queryPlan: PlanResourcesResponse = {
|
|
497
|
+
kind: PlanKind.CONDITIONAL,
|
|
498
|
+
condition,
|
|
499
|
+
cerbosCallId: "synthetic",
|
|
500
|
+
requestId: "synthetic",
|
|
501
|
+
validationErrors: [],
|
|
502
|
+
metadata: undefined,
|
|
503
|
+
};
|
|
504
|
+
|
|
505
|
+
return selectAllowed(queryPlan);
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
// Every count threshold, not only the two spellings the corpus pins (#316). Each is TRUE for a
|
|
509
|
+
// row with no mainCategory only if the guard leaks: an absent to-one parent is a CEL
|
|
510
|
+
// missing-path error, so the PDP denies it outright.
|
|
511
|
+
test("every count threshold inherits the guard", async () => {
|
|
512
|
+
const size = new PlanExpression("size", [
|
|
513
|
+
new PlanExpressionVariable("request.resource.attr.mainCategory.subCategories"),
|
|
514
|
+
]);
|
|
515
|
+
|
|
516
|
+
const compare = (operator: string, threshold: number) =>
|
|
517
|
+
new PlanExpression(operator, [size, new PlanExpressionValue(threshold)]);
|
|
518
|
+
|
|
519
|
+
const negate = (condition: PlanExpressionOperand) => new PlanExpression("not", [condition]);
|
|
520
|
+
|
|
521
|
+
for (const [label, condition] of [
|
|
522
|
+
["size(chain) == 0", compare("eq", 0)],
|
|
523
|
+
["size(chain) <= 0", compare("le", 0)],
|
|
524
|
+
["size(chain) < 1", compare("lt", 1)],
|
|
525
|
+
["!(size(chain) >= 1)", negate(compare("ge", 1))],
|
|
526
|
+
["!(size(chain) > 0)", negate(compare("gt", 0))],
|
|
527
|
+
] as const) {
|
|
528
|
+
expect([label, await synthetic(condition)]).toEqual([label, []]);
|
|
584
529
|
}
|
|
530
|
+
|
|
531
|
+
// The mirror image, so the loop above cannot pass by denying everything.
|
|
532
|
+
const withParent = allowedFor("relation/size/non-negative-to-one-chain");
|
|
533
|
+
expect(withParent.length).toBeGreaterThan(0);
|
|
534
|
+
expect(await synthetic(negate(compare("eq", 0)))).toEqual(withParent);
|
|
535
|
+
expect(await synthetic(negate(compare("lt", 1)))).toEqual(withParent);
|
|
536
|
+
});
|
|
537
|
+
|
|
538
|
+
// Every ternary condition position over the chain, not only the one the corpus pins (#334).
|
|
539
|
+
test("every ternary condition inherits the guard", async () => {
|
|
540
|
+
const chainIn = new PlanExpression("in", [
|
|
541
|
+
new PlanExpressionValue("finance"),
|
|
542
|
+
new PlanExpressionVariable("request.resource.attr.mainCategory.subNames"),
|
|
543
|
+
]);
|
|
544
|
+
|
|
545
|
+
const TRUE = new PlanExpressionValue(true);
|
|
546
|
+
const FALSE = new PlanExpressionValue(false);
|
|
547
|
+
|
|
548
|
+
const ternary = (
|
|
549
|
+
condition: PlanExpressionOperand,
|
|
550
|
+
thenBranch: PlanExpressionOperand,
|
|
551
|
+
elseBranch: PlanExpressionOperand
|
|
552
|
+
) => new PlanExpression("if", [condition, thenBranch, elseBranch]);
|
|
553
|
+
|
|
554
|
+
// A row with no mainCategory is neither: the condition errors and selects neither branch.
|
|
555
|
+
const conditionTrue = allowedFor("relation/in/to-one-chain");
|
|
556
|
+
const conditionFalse = allowedFor("relation/in/negated-to-one-chain");
|
|
557
|
+
expect(conditionTrue.length).toBeGreaterThan(0);
|
|
558
|
+
expect(conditionFalse.length).toBeGreaterThan(0);
|
|
559
|
+
expect(conditionTrue.length + conditionFalse.length).toBeLessThan(SEEDS.length);
|
|
560
|
+
|
|
561
|
+
expect(await synthetic(ternary(chainIn, FALSE, TRUE))).toEqual(conditionFalse);
|
|
562
|
+
expect(
|
|
563
|
+
await synthetic(ternary(new PlanExpression("not", [chainIn]), TRUE, FALSE))
|
|
564
|
+
).toEqual(conditionFalse);
|
|
565
|
+
expect(
|
|
566
|
+
await synthetic(ternary(new PlanExpression("not", [chainIn]), FALSE, TRUE))
|
|
567
|
+
).toEqual(conditionTrue);
|
|
568
|
+
|
|
569
|
+
// A conjunction condition needs De Morgan: CEL's `&&` absorbs an erroring operand when the
|
|
570
|
+
// other is FALSE, so a parentless row with aBool=false DOES select the else-branch.
|
|
571
|
+
const aBoolFalse = SEEDS.filter((seed) => seed.aBool === false).map((seed) => seed.id);
|
|
572
|
+
expect(aBoolFalse.length).toBeGreaterThan(0);
|
|
573
|
+
expect(
|
|
574
|
+
await synthetic(
|
|
575
|
+
ternary(
|
|
576
|
+
new PlanExpression("and", [
|
|
577
|
+
chainIn,
|
|
578
|
+
new PlanExpressionVariable("request.resource.attr.aBool"),
|
|
579
|
+
]),
|
|
580
|
+
FALSE,
|
|
581
|
+
TRUE
|
|
582
|
+
)
|
|
583
|
+
)
|
|
584
|
+
).toEqual([...new Set([...conditionFalse, ...aBoolFalse])].sort());
|
|
585
585
|
});
|
|
586
586
|
});
|