@cerbos/orm-prisma 3.3.0 → 4.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.
Files changed (44) hide show
  1. package/README.md +83 -6
  2. package/lib/index.d.ts +29 -2
  3. package/lib/index.d.ts.map +1 -1
  4. package/lib/index.js +1459 -114
  5. package/lib/index.js.map +1 -1
  6. package/package.json +19 -13
  7. package/src/adversarial.test.ts +586 -0
  8. package/src/generated/prisma/commonInputTypes.ts +26 -0
  9. package/src/generated/prisma/internal/class.ts +9 -9
  10. package/src/generated/prisma/internal/prismaNamespace.ts +89 -19
  11. package/src/generated/prisma/internal/prismaNamespaceBrowser.ts +2 -1
  12. package/src/generated/prisma/models/Category.ts +1 -1
  13. package/src/generated/prisma/models/Label.ts +1 -1
  14. package/src/generated/prisma/models/NestedResource.ts +1 -1
  15. package/src/generated/prisma/models/NextLevelNestedResource.ts +1 -1
  16. package/src/generated/prisma/models/Resource.ts +62 -2
  17. package/src/generated/prisma/models/SubCategory.ts +1 -1
  18. package/src/generated/prisma/models/Tag.ts +1 -1
  19. package/src/generated/prisma/models/User.ts +1 -1
  20. package/src/generated/prisma-adversarial/browser.ts +44 -0
  21. package/src/generated/prisma-adversarial/client.ts +66 -0
  22. package/src/generated/prisma-adversarial/commonInputTypes.ts +352 -0
  23. package/src/generated/prisma-adversarial/enums.ts +15 -0
  24. package/src/generated/prisma-adversarial/internal/class.ts +244 -0
  25. package/src/generated/prisma-adversarial/internal/prismaNamespace.ts +1156 -0
  26. package/src/generated/prisma-adversarial/internal/prismaNamespaceBrowser.ts +140 -0
  27. package/src/generated/prisma-adversarial/models/AdversarialCategory.ts +1401 -0
  28. package/src/generated/prisma-adversarial/models/AdversarialLabel.ts +1272 -0
  29. package/src/generated/prisma-adversarial/models/AdversarialResource.ts +1665 -0
  30. package/src/generated/prisma-adversarial/models/AdversarialSubCategory.ts +1401 -0
  31. package/src/generated/prisma-adversarial/models/AdversarialTag.ts +1307 -0
  32. package/src/generated/prisma-adversarial/models.ts +16 -0
  33. package/src/generated/prisma-adversarial-v6/client.d.ts +1 -0
  34. package/src/generated/prisma-adversarial-v6/default.d.ts +1 -0
  35. package/src/generated/prisma-adversarial-v6/edge.d.ts +1 -0
  36. package/src/generated/prisma-adversarial-v6/index.d.ts +8640 -0
  37. package/src/generated/prisma-adversarial-v6/runtime/index-browser.d.ts +370 -0
  38. package/src/generated/prisma-adversarial-v6/runtime/library.d.ts +3982 -0
  39. package/src/generated/prisma-adversarial-v6/wasm.d.ts +1 -0
  40. package/src/generated/prisma-v6/index.d.ts +90 -3
  41. package/src/index.test.ts +3199 -636
  42. package/src/index.ts +2055 -148
  43. package/src/test-setup.adversarial.v6.ts +5 -0
  44. package/src/test-setup.adversarial.v7.ts +12 -0
package/src/index.test.ts CHANGED
@@ -176,6 +176,7 @@ const fixtureResources: Prisma.ResourceCreateInput[] = [
176
176
  aNumber: 1,
177
177
  aString: "string",
178
178
  aOptionalString: "optionalString",
179
+ aOptionalBool: true,
179
180
  createdBy: {
180
181
  connect: {
181
182
  id: "user1",
@@ -207,6 +208,7 @@ const fixtureResources: Prisma.ResourceCreateInput[] = [
207
208
  aBool: false,
208
209
  aNumber: 2,
209
210
  aString: "string2",
211
+ aOptionalBool: false,
210
212
  createdBy: {
211
213
  connect: {
212
214
  id: "user2",
@@ -450,6 +452,109 @@ describe("Field Operations", () => {
450
452
  );
451
453
  });
452
454
 
455
+ test("conditional - eq - bool false", async () => {
456
+ const queryPlan = await cerbos.planResources({
457
+ principal: { id: "user1", roles: ["USER"] },
458
+ resource: { kind: "resource" },
459
+ action: "equal-bool-false",
460
+ });
461
+
462
+ expect(queryPlan.kind).toEqual(PlanKind.CONDITIONAL);
463
+ expect((queryPlan as PlanResourcesConditionalResponse).condition).toEqual(
464
+ {
465
+ operator: "eq",
466
+ operands: [{ name: "request.resource.attr.aBool" }, { value: false }],
467
+ }
468
+ );
469
+
470
+ const result = queryPlanToPrisma({
471
+ queryPlan,
472
+ mapper: {
473
+ "request.resource.attr.aBool": { field: "aBool" },
474
+ },
475
+ });
476
+
477
+ expect(result).toStrictEqual({
478
+ kind: PlanKind.CONDITIONAL,
479
+ filters: { aBool: { equals: false } },
480
+ });
481
+
482
+ if (result.kind !== PlanKind.CONDITIONAL) {
483
+ throw new Error("Expected CONDITIONAL result");
484
+ }
485
+
486
+ const query = await prisma.resource.findMany({
487
+ where: { ...result.filters },
488
+ });
489
+ expect(query.map((r) => r.id)).toEqual(
490
+ fixtureResources.filter((a) => a.aBool === false).map((r) => r.id)
491
+ );
492
+ });
493
+
494
+ test("conditional - eq - field to field", async () => {
495
+ // TODO: field-to-field comparison support is a follow-up — Prisma adapter
496
+ // currently only handles field-to-value. This test pins the plan shape and
497
+ // asserts the adapter throws so behaviour is locked in.
498
+ const queryPlan = await cerbos.planResources({
499
+ principal: { id: "user1", roles: ["USER"] },
500
+ resource: { kind: "resource" },
501
+ action: "equal-field-to-field",
502
+ });
503
+
504
+ expect(queryPlan.kind).toEqual(PlanKind.CONDITIONAL);
505
+ expect((queryPlan as PlanResourcesConditionalResponse).condition).toEqual(
506
+ {
507
+ operator: "eq",
508
+ operands: [
509
+ { name: "request.resource.attr.aString" },
510
+ { name: "request.resource.attr.id" },
511
+ ],
512
+ }
513
+ );
514
+
515
+ // Without the model name there is no container for a Prisma field reference.
516
+ expect(() =>
517
+ queryPlanToPrisma({
518
+ queryPlan,
519
+ mapper: {
520
+ "request.resource.attr.aString": { field: "aString" },
521
+ "request.resource.attr.id": { field: "id" },
522
+ },
523
+ })
524
+ ).toThrow(
525
+ "Field-to-field comparison requires the `model` option (the Prisma model name) to build a field reference"
526
+ );
527
+
528
+ // With the model name, the comparison compiles to a Prisma field reference.
529
+ const result = queryPlanToPrisma({
530
+ queryPlan,
531
+ mapper: {
532
+ "request.resource.attr.aString": { field: "aString" },
533
+ "request.resource.attr.id": { field: "id" },
534
+ },
535
+ model: "Resource",
536
+ });
537
+ expect(result).toStrictEqual({
538
+ kind: PlanKind.CONDITIONAL,
539
+ filters: {
540
+ aString: { equals: { _ref: "id", _container: "Resource" } },
541
+ },
542
+ });
543
+
544
+ if (result.kind !== PlanKind.CONDITIONAL) {
545
+ throw new Error("Expected CONDITIONAL result");
546
+ }
547
+ const query = await prisma.resource.findMany({
548
+ where: { ...result.filters },
549
+ });
550
+ expect(query.map((r) => r.id).sort()).toEqual(
551
+ fixtureResources
552
+ .filter((r) => r.aString === r.id)
553
+ .map((r) => r.id)
554
+ .sort()
555
+ );
556
+ });
557
+
453
558
  test("conditional - ne", async () => {
454
559
  const queryPlan = await cerbos.planResources({
455
560
  principal: { id: "user1", roles: ["USER"] },
@@ -1363,6 +1468,51 @@ describe("Field Operations", () => {
1363
1468
  fixtureResources.filter((a) => a.aOptionalString).map((r) => r.id)
1364
1469
  );
1365
1470
  });
1471
+
1472
+ test("conditional - isNotSet", async () => {
1473
+ const queryPlan = await cerbos.planResources({
1474
+ principal: { id: "user1", roles: ["USER"] },
1475
+ resource: { kind: "resource" },
1476
+ action: "is-not-set",
1477
+ });
1478
+
1479
+ expect(queryPlan.kind).toEqual(PlanKind.CONDITIONAL);
1480
+ expect((queryPlan as PlanResourcesConditionalResponse).condition).toEqual(
1481
+ {
1482
+ operator: "eq",
1483
+ operands: [
1484
+ { name: "request.resource.attr.aOptionalString" },
1485
+ { value: null },
1486
+ ],
1487
+ }
1488
+ );
1489
+
1490
+ const result = queryPlanToPrisma({
1491
+ queryPlan,
1492
+ mapper: {
1493
+ "request.resource.attr.aOptionalString": { field: "aOptionalString" },
1494
+ },
1495
+ });
1496
+
1497
+ expect(result).toStrictEqual({
1498
+ kind: PlanKind.CONDITIONAL,
1499
+ filters: { aOptionalString: { equals: null } },
1500
+ });
1501
+
1502
+ if (result.kind !== PlanKind.CONDITIONAL) {
1503
+ throw new Error("Expected CONDITIONAL result");
1504
+ }
1505
+
1506
+ const query = await prisma.resource.findMany({
1507
+ where: { ...result.filters },
1508
+ });
1509
+ expect(query.map((r) => r.id).sort()).toEqual(
1510
+ fixtureResources
1511
+ .filter((a) => a.aOptionalString == null)
1512
+ .map((r) => r.id)
1513
+ .sort()
1514
+ );
1515
+ });
1366
1516
  });
1367
1517
  });
1368
1518
 
@@ -1417,6 +1567,53 @@ describe("Collection Operations", () => {
1417
1567
  );
1418
1568
  });
1419
1569
 
1570
+ test("conditional - in - number list", async () => {
1571
+ const queryPlan = await cerbos.planResources({
1572
+ principal: { id: "user1", roles: ["USER"] },
1573
+ resource: { kind: "resource" },
1574
+ action: "in-number",
1575
+ });
1576
+
1577
+ expect(queryPlan.kind).toEqual(PlanKind.CONDITIONAL);
1578
+ expect((queryPlan as PlanResourcesConditionalResponse).condition).toEqual(
1579
+ {
1580
+ operator: "in",
1581
+ operands: [
1582
+ { name: "request.resource.attr.aNumber" },
1583
+ { value: [1, 2, 3] },
1584
+ ],
1585
+ }
1586
+ );
1587
+
1588
+ const result = queryPlanToPrisma({
1589
+ queryPlan,
1590
+ mapper: {
1591
+ "request.resource.attr.aNumber": { field: "aNumber" },
1592
+ },
1593
+ });
1594
+
1595
+ expect(result).toStrictEqual({
1596
+ kind: PlanKind.CONDITIONAL,
1597
+ filters: {
1598
+ aNumber: { in: [1, 2, 3] },
1599
+ },
1600
+ });
1601
+
1602
+ if (result.kind !== PlanKind.CONDITIONAL) {
1603
+ throw new Error("Expected CONDITIONAL result");
1604
+ }
1605
+
1606
+ const query = await prisma.resource.findMany({
1607
+ where: { ...result.filters },
1608
+ });
1609
+ expect(query.map((r) => r.id).sort()).toEqual(
1610
+ fixtureResources
1611
+ .filter((r) => [1, 2, 3].includes(r.aNumber as number))
1612
+ .map((r) => r.id)
1613
+ .sort()
1614
+ );
1615
+ });
1616
+
1420
1617
  test("conditional - in - scalar value", async () => {
1421
1618
  const queryPlan = createConditionalPlan({
1422
1619
  operator: "in",
@@ -1999,62 +2196,24 @@ describe("Collection Operations", () => {
1999
2196
  }
2000
2197
  );
2001
2198
 
2002
- const result = queryPlanToPrisma({
2003
- queryPlan,
2004
- mapper: {
2005
- "request.resource.attr.tags": {
2006
- relation: {
2007
- name: "tags",
2008
- type: "many",
2009
- },
2010
- },
2011
- },
2012
- });
2013
-
2014
- expect(result).toStrictEqual({
2015
- kind: PlanKind.CONDITIONAL,
2016
- filters: {
2017
- tags: {
2018
- some: {
2019
- name: { equals: "public" },
2020
- },
2021
- },
2022
- AND: [
2023
- {
2024
- tags: {
2025
- every: {
2026
- OR: [
2027
- { name: { equals: "public" } },
2028
- { NOT: { name: { equals: "public" } } },
2029
- ],
2030
- },
2199
+ // exists_one means EXACTLY one matching element, which requires counting matches —
2200
+ // inexpressible in Prisma's where shape. The previous translation silently degraded
2201
+ // to a plain exists (its `every: OR[P, NOT P]` clause was a tautology), returning
2202
+ // wrong rows whenever a resource had two or more matches; it now fails loudly.
2203
+ expect(() =>
2204
+ queryPlanToPrisma({
2205
+ queryPlan,
2206
+ mapper: {
2207
+ "request.resource.attr.tags": {
2208
+ relation: {
2209
+ name: "tags",
2210
+ type: "many",
2031
2211
  },
2032
2212
  },
2033
- ],
2034
- },
2035
- });
2036
-
2037
- if (result.kind !== PlanKind.CONDITIONAL) {
2038
- throw new Error("Expected CONDITIONAL result");
2039
- }
2040
-
2041
- const query = await prisma.resource.findMany({
2042
- where: { ...result.filters },
2043
- });
2044
-
2045
- expect(query.map((r) => r.id)).toEqual(
2046
- fixtureResources
2047
- .filter(
2048
- (a) =>
2049
- Array.isArray(a.tags?.connect) &&
2050
- a.tags?.connect
2051
- .map((t) => {
2052
- return fixtureTags.find((f) => f.id === t.id);
2053
- })
2054
- .filter((t) => t?.id === "tag1" && t?.name === "public")
2055
- .length > 0
2056
- )
2057
- .map((r) => r.id)
2213
+ },
2214
+ })
2215
+ ).toThrow(
2216
+ "exists_one requires counting matching elements, which Prisma where-filters cannot express"
2058
2217
  );
2059
2218
  });
2060
2219
 
@@ -2276,30 +2435,229 @@ describe("Collection Operations", () => {
2276
2435
  .map((r) => r.id)
2277
2436
  );
2278
2437
  });
2279
- });
2280
- });
2281
-
2282
- // Relations
2283
- describe("Relations", () => {
2284
- describe("Simple Relations", () => {
2285
- describe("One-to-One", () => {
2286
- test("conditional - relation is", async () => {
2287
- const queryPlan = await cerbos.planResources({
2288
- principal: { id: "user1", roles: ["USER"] },
2289
- resource: { kind: "resource" },
2290
- action: "relation-is",
2291
- });
2292
2438
 
2293
- expect(queryPlan.kind).toEqual(PlanKind.CONDITIONAL);
2294
-
2295
- const conditions = (queryPlan as PlanResourcesConditionalResponse)
2296
- .condition;
2439
+ test("conditional - all with multi-clause lambda body (all-nested)", async () => {
2440
+ const queryPlan = await cerbos.planResources({
2441
+ principal: { id: "user1", roles: ["USER"] },
2442
+ resource: { kind: "resource" },
2443
+ action: "all-nested",
2444
+ });
2297
2445
 
2298
- expect(conditions).toEqual({
2299
- operator: "eq",
2446
+ expect(queryPlan.kind).toEqual(PlanKind.CONDITIONAL);
2447
+ expect((queryPlan as PlanResourcesConditionalResponse).condition).toEqual(
2448
+ {
2449
+ operator: "all",
2300
2450
  operands: [
2301
- { name: "request.resource.attr.createdBy" },
2302
- { value: "user1" },
2451
+ { name: "request.resource.attr.tags" },
2452
+ {
2453
+ operator: "lambda",
2454
+ operands: [
2455
+ {
2456
+ operator: "and",
2457
+ operands: [
2458
+ {
2459
+ operator: "eq",
2460
+ operands: [
2461
+ { name: "tag.name" },
2462
+ { value: "public" },
2463
+ ],
2464
+ },
2465
+ {
2466
+ operator: "ne",
2467
+ operands: [
2468
+ { name: "tag.id" },
2469
+ { value: "tag1" },
2470
+ ],
2471
+ },
2472
+ ],
2473
+ },
2474
+ { name: "tag" },
2475
+ ],
2476
+ },
2477
+ ],
2478
+ }
2479
+ );
2480
+
2481
+ const result = queryPlanToPrisma({
2482
+ queryPlan,
2483
+ mapper: {
2484
+ "request.resource.attr.tags": {
2485
+ relation: {
2486
+ name: "tags",
2487
+ type: "many",
2488
+ },
2489
+ },
2490
+ },
2491
+ });
2492
+
2493
+ expect(result).toStrictEqual({
2494
+ kind: PlanKind.CONDITIONAL,
2495
+ filters: {
2496
+ tags: {
2497
+ every: {
2498
+ AND: [
2499
+ { name: { equals: "public" } },
2500
+ { id: { not: "tag1" } },
2501
+ ],
2502
+ },
2503
+ },
2504
+ },
2505
+ });
2506
+
2507
+ if (result.kind !== PlanKind.CONDITIONAL) {
2508
+ throw new Error("Expected CONDITIONAL result");
2509
+ }
2510
+
2511
+ const query = await prisma.resource.findMany({
2512
+ where: { ...result.filters },
2513
+ });
2514
+
2515
+ expect(query.map((r) => r.id).sort()).toEqual(
2516
+ fixtureResources
2517
+ .filter((r) => {
2518
+ const connected = Array.isArray(r.tags?.connect)
2519
+ ? r.tags!.connect.map((c) =>
2520
+ fixtureTags.find((ft) => ft.id === c.id)
2521
+ )
2522
+ : [];
2523
+ return connected.every(
2524
+ (t) => t?.name === "public" && t?.id !== "tag1"
2525
+ );
2526
+ })
2527
+ .map((r) => r.id)
2528
+ .sort()
2529
+ );
2530
+ });
2531
+
2532
+ test("conditional - map() compared to literal list throws (map-compared)", async () => {
2533
+ // prisma does not support comparing `map(...)` directly to a list
2534
+ // literal — `hasIntersection(map(...), [...])` is the supported form.
2535
+ const queryPlan = await cerbos.planResources({
2536
+ principal: { id: "user1", roles: ["USER"] },
2537
+ resource: { kind: "resource" },
2538
+ action: "map-compared",
2539
+ });
2540
+
2541
+ expect(queryPlan.kind).toEqual(PlanKind.CONDITIONAL);
2542
+ expect((queryPlan as PlanResourcesConditionalResponse).condition).toEqual(
2543
+ {
2544
+ operator: "eq",
2545
+ operands: [
2546
+ {
2547
+ operator: "map",
2548
+ operands: [
2549
+ { name: "request.resource.attr.tags" },
2550
+ {
2551
+ operator: "lambda",
2552
+ operands: [{ name: "t.id" }, { name: "t" }],
2553
+ },
2554
+ ],
2555
+ },
2556
+ { value: ["tag1", "tag2"] },
2557
+ ],
2558
+ }
2559
+ );
2560
+
2561
+ expect(() =>
2562
+ queryPlanToPrisma({
2563
+ queryPlan,
2564
+ mapper: {
2565
+ "request.resource.attr.tags": {
2566
+ relation: {
2567
+ name: "tags",
2568
+ type: "many",
2569
+ fields: {
2570
+ id: { field: "id" },
2571
+ },
2572
+ },
2573
+ },
2574
+ },
2575
+ })
2576
+ ).toThrow(/map\(\.\.\.\) to a value is not supported/);
2577
+ });
2578
+
2579
+ test("conditional - size(filter(...)) > 0 throws (filter-count-gt)", async () => {
2580
+ // TODO(#232): prisma adapter supports size(R.attr.collection) > N but
2581
+ // does not yet unwrap size(filter(R.attr.collection, lambda)) > N.
2582
+ // Locks in current behavior.
2583
+ const queryPlan = await cerbos.planResources({
2584
+ principal: { id: "user1", roles: ["USER"] },
2585
+ resource: { kind: "resource" },
2586
+ action: "filter-count-gt",
2587
+ });
2588
+
2589
+ expect(queryPlan.kind).toEqual(PlanKind.CONDITIONAL);
2590
+ expect((queryPlan as PlanResourcesConditionalResponse).condition).toEqual(
2591
+ {
2592
+ operator: "gt",
2593
+ operands: [
2594
+ {
2595
+ operator: "size",
2596
+ operands: [
2597
+ {
2598
+ operator: "filter",
2599
+ operands: [
2600
+ { name: "request.resource.attr.tags" },
2601
+ {
2602
+ operator: "lambda",
2603
+ operands: [
2604
+ {
2605
+ operator: "eq",
2606
+ operands: [
2607
+ { name: "t.name" },
2608
+ { value: "public" },
2609
+ ],
2610
+ },
2611
+ { name: "t" },
2612
+ ],
2613
+ },
2614
+ ],
2615
+ },
2616
+ ],
2617
+ },
2618
+ { value: 0 },
2619
+ ],
2620
+ }
2621
+ );
2622
+
2623
+ expect(() =>
2624
+ queryPlanToPrisma({
2625
+ queryPlan,
2626
+ mapper: {
2627
+ "request.resource.attr.tags": {
2628
+ relation: {
2629
+ name: "tags",
2630
+ type: "many",
2631
+ },
2632
+ },
2633
+ },
2634
+ })
2635
+ ).toThrow();
2636
+ });
2637
+ });
2638
+ });
2639
+
2640
+ // Relations
2641
+ describe("Relations", () => {
2642
+ describe("Simple Relations", () => {
2643
+ describe("One-to-One", () => {
2644
+ test("conditional - relation is", async () => {
2645
+ const queryPlan = await cerbos.planResources({
2646
+ principal: { id: "user1", roles: ["USER"] },
2647
+ resource: { kind: "resource" },
2648
+ action: "relation-is",
2649
+ });
2650
+
2651
+ expect(queryPlan.kind).toEqual(PlanKind.CONDITIONAL);
2652
+
2653
+ const conditions = (queryPlan as PlanResourcesConditionalResponse)
2654
+ .condition;
2655
+
2656
+ expect(conditions).toEqual({
2657
+ operator: "eq",
2658
+ operands: [
2659
+ { name: "request.resource.attr.createdBy" },
2660
+ { value: "user1" },
2303
2661
  ],
2304
2662
  });
2305
2663
 
@@ -4100,6 +4458,95 @@ describe("Complex Operations", () => {
4100
4458
  );
4101
4459
  });
4102
4460
 
4461
+ test("conditional - or - leaf and exists", async () => {
4462
+ const queryPlan = await cerbos.planResources({
4463
+ principal: { id: "user1", roles: ["USER"] },
4464
+ resource: { kind: "resource" },
4465
+ action: "or-leaf-exists",
4466
+ });
4467
+
4468
+ expect(queryPlan.kind).toEqual(PlanKind.CONDITIONAL);
4469
+ expect((queryPlan as PlanResourcesConditionalResponse).condition).toEqual(
4470
+ {
4471
+ operator: "or",
4472
+ operands: [
4473
+ {
4474
+ operator: "eq",
4475
+ operands: [
4476
+ { name: "request.resource.attr.aBool" },
4477
+ { value: true },
4478
+ ],
4479
+ },
4480
+ {
4481
+ operator: "exists",
4482
+ operands: [
4483
+ { name: "request.resource.attr.tags" },
4484
+ {
4485
+ operator: "lambda",
4486
+ operands: [
4487
+ {
4488
+ operator: "eq",
4489
+ operands: [
4490
+ { name: "t.name" },
4491
+ { value: "public" },
4492
+ ],
4493
+ },
4494
+ { name: "t" },
4495
+ ],
4496
+ },
4497
+ ],
4498
+ },
4499
+ ],
4500
+ }
4501
+ );
4502
+
4503
+ const result = queryPlanToPrisma({
4504
+ queryPlan,
4505
+ mapper: {
4506
+ "request.resource.attr.aBool": { field: "aBool" },
4507
+ "request.resource.attr.tags": {
4508
+ relation: {
4509
+ name: "tags",
4510
+ type: "many",
4511
+ },
4512
+ },
4513
+ },
4514
+ });
4515
+
4516
+ expect(result).toStrictEqual({
4517
+ kind: PlanKind.CONDITIONAL,
4518
+ filters: {
4519
+ OR: [
4520
+ { aBool: { equals: true } },
4521
+ { tags: { some: { name: { equals: "public" } } } },
4522
+ ],
4523
+ },
4524
+ });
4525
+
4526
+ if (result.kind !== PlanKind.CONDITIONAL) {
4527
+ throw new Error("Expected CONDITIONAL result");
4528
+ }
4529
+
4530
+ const query = await prisma.resource.findMany({
4531
+ where: { ...result.filters },
4532
+ });
4533
+ expect(query.map((r) => r.id).sort()).toEqual(
4534
+ fixtureResources
4535
+ .filter((r) => {
4536
+ if (r.aBool) return true;
4537
+ const tagRefs = Array.isArray(r.tags?.connect)
4538
+ ? r.tags.connect
4539
+ : [];
4540
+ return tagRefs.some((tagRef) => {
4541
+ const tag = fixtureTags.find((t) => t.id === tagRef.id);
4542
+ return tag?.name === "public";
4543
+ });
4544
+ })
4545
+ .map((r) => r.id)
4546
+ .sort()
4547
+ );
4548
+ });
4549
+
4103
4550
  test("conditional - relation multiple all", async () => {
4104
4551
  const queryPlan = await cerbos.planResources({
4105
4552
  principal: { id: "user1", roles: ["USER"] },
@@ -4394,54 +4841,58 @@ describe("Complex Operations", () => {
4394
4841
  });
4395
4842
  });
4396
4843
 
4397
- describe("Collection Operations", () => {
4398
- test("conditional - exists on nested collection", async () => {
4844
+ describe("Negation Operations (DeMorgan)", () => {
4845
+ test("conditional - not-and", async () => {
4399
4846
  const queryPlan = await cerbos.planResources({
4400
4847
  principal: { id: "user1", roles: ["USER"] },
4401
4848
  resource: { kind: "resource" },
4402
- action: "exists-nested-collection",
4849
+ action: "not-and",
4403
4850
  });
4404
4851
 
4852
+ expect(queryPlan.kind).toEqual(PlanKind.CONDITIONAL);
4853
+ expect((queryPlan as PlanResourcesConditionalResponse).condition).toEqual(
4854
+ {
4855
+ operator: "not",
4856
+ operands: [
4857
+ {
4858
+ operator: "and",
4859
+ operands: [
4860
+ {
4861
+ operator: "eq",
4862
+ operands: [
4863
+ { name: "request.resource.attr.aBool" },
4864
+ { value: true },
4865
+ ],
4866
+ },
4867
+ {
4868
+ operator: "ne",
4869
+ operands: [
4870
+ { name: "request.resource.attr.aString" },
4871
+ { value: "string" },
4872
+ ],
4873
+ },
4874
+ ],
4875
+ },
4876
+ ],
4877
+ }
4878
+ );
4879
+
4405
4880
  const result = queryPlanToPrisma({
4406
4881
  queryPlan,
4407
4882
  mapper: {
4408
- "request.resource.attr.categories": {
4409
- relation: {
4410
- name: "categories",
4411
- type: "many",
4412
- fields: {
4413
- name: { field: "name" },
4414
- subCategories: {
4415
- relation: {
4416
- name: "subCategories",
4417
- type: "many",
4418
- fields: {
4419
- name: { field: "name" },
4420
- },
4421
- },
4422
- },
4423
- },
4424
- },
4425
- },
4883
+ "request.resource.attr.aBool": { field: "aBool" },
4884
+ "request.resource.attr.aString": { field: "aString" },
4426
4885
  },
4427
4886
  });
4428
4887
 
4429
4888
  expect(result).toStrictEqual({
4430
4889
  kind: PlanKind.CONDITIONAL,
4431
4890
  filters: {
4432
- categories: {
4433
- some: {
4434
- AND: [
4435
- { name: { equals: "business" } },
4436
- {
4437
- subCategories: {
4438
- some: {
4439
- name: { equals: "finance" },
4440
- },
4441
- },
4442
- },
4443
- ],
4444
- },
4891
+ NOT: {
4892
+ AND: [
4893
+ { aBool: { equals: true } },
4894
+ { aString: { not: "string" } },
4895
+ ],
4445
4896
  },
4446
4897
  },
4447
4898
  });
@@ -4453,181 +4904,296 @@ describe("Complex Operations", () => {
4453
4904
  const query = await prisma.resource.findMany({
4454
4905
  where: { ...result.filters },
4455
4906
  });
4456
-
4457
4907
  expect(query.map((r) => r.id)).toEqual(
4458
4908
  fixtureResources
4459
- .filter((r) => {
4460
- const categoryConnections = r.categories
4461
- ?.connect as Prisma.CategoryWhereUniqueInput[];
4462
- return categoryConnections?.some((categoryConnection) => {
4463
- const category = fixtureCategories.find(
4464
- (fc) => fc.id === categoryConnection.id
4465
- );
4466
- return (
4467
- category?.name === "business" &&
4468
- (
4469
- category?.subCategories
4470
- ?.connect as Prisma.SubCategoryWhereUniqueInput[]
4471
- )?.some(
4472
- (sc) =>
4473
- fixtureSubCategories.find((fsc) => fsc.id === sc.id)
4474
- ?.name === "finance"
4475
- )
4476
- );
4477
- });
4478
- })
4909
+ .filter((r) => !(r.aBool && r.aString != "string"))
4479
4910
  .map((r) => r.id)
4480
4911
  );
4481
4912
  });
4482
4913
 
4483
- test("isSet with nested relation", async () => {
4914
+ test("conditional - not-or", async () => {
4484
4915
  const queryPlan = await cerbos.planResources({
4485
4916
  principal: { id: "user1", roles: ["USER"] },
4486
4917
  resource: { kind: "resource" },
4487
- action: "is-set-nested",
4918
+ action: "not-or",
4488
4919
  });
4489
4920
 
4921
+ expect(queryPlan.kind).toEqual(PlanKind.CONDITIONAL);
4922
+ expect((queryPlan as PlanResourcesConditionalResponse).condition).toEqual(
4923
+ {
4924
+ operator: "not",
4925
+ operands: [
4926
+ {
4927
+ operator: "or",
4928
+ operands: [
4929
+ {
4930
+ operator: "eq",
4931
+ operands: [
4932
+ { name: "request.resource.attr.aBool" },
4933
+ { value: true },
4934
+ ],
4935
+ },
4936
+ {
4937
+ operator: "ne",
4938
+ operands: [
4939
+ { name: "request.resource.attr.aString" },
4940
+ { value: "string" },
4941
+ ],
4942
+ },
4943
+ ],
4944
+ },
4945
+ ],
4946
+ }
4947
+ );
4948
+
4490
4949
  const result = queryPlanToPrisma({
4491
4950
  queryPlan,
4492
4951
  mapper: {
4493
- "request.resource.attr.nested.aOptionalString": {
4494
- relation: {
4495
- name: "nested",
4496
- type: "one",
4497
- fields: {
4498
- aOptionalString: { field: "aOptionalString" },
4499
- },
4500
- },
4501
- },
4952
+ "request.resource.attr.aBool": { field: "aBool" },
4953
+ "request.resource.attr.aString": { field: "aString" },
4502
4954
  },
4503
4955
  });
4504
4956
 
4505
4957
  expect(result).toStrictEqual({
4506
4958
  kind: PlanKind.CONDITIONAL,
4507
4959
  filters: {
4508
- nested: {
4509
- is: {
4510
- aOptionalString: { not: null },
4511
- },
4960
+ NOT: {
4961
+ OR: [
4962
+ { aBool: { equals: true } },
4963
+ { aString: { not: "string" } },
4964
+ ],
4512
4965
  },
4513
4966
  },
4514
4967
  });
4968
+
4969
+ if (result.kind !== PlanKind.CONDITIONAL) {
4970
+ throw new Error("Expected CONDITIONAL result");
4971
+ }
4972
+
4973
+ const query = await prisma.resource.findMany({
4974
+ where: { ...result.filters },
4975
+ });
4976
+ expect(query.map((r) => r.id)).toEqual(
4977
+ fixtureResources
4978
+ .filter((r) => !(r.aBool || r.aString != "string"))
4979
+ .map((r) => r.id)
4980
+ );
4515
4981
  });
4516
4982
 
4517
- test("hasIntersection with nested collection", async () => {
4983
+ test("conditional - not-gt", async () => {
4518
4984
  const queryPlan = await cerbos.planResources({
4519
4985
  principal: { id: "user1", roles: ["USER"] },
4520
4986
  resource: { kind: "resource" },
4521
- action: "has-intersection-nested",
4987
+ action: "not-gt",
4522
4988
  });
4523
4989
 
4990
+ expect(queryPlan.kind).toEqual(PlanKind.CONDITIONAL);
4991
+ expect((queryPlan as PlanResourcesConditionalResponse).condition).toEqual(
4992
+ {
4993
+ operator: "not",
4994
+ operands: [
4995
+ {
4996
+ operator: "gt",
4997
+ operands: [
4998
+ { name: "request.resource.attr.aNumber" },
4999
+ { value: 1 },
5000
+ ],
5001
+ },
5002
+ ],
5003
+ }
5004
+ );
5005
+
4524
5006
  const result = queryPlanToPrisma({
4525
5007
  queryPlan,
4526
5008
  mapper: {
4527
- "request.resource.attr.categories": {
4528
- relation: {
4529
- name: "categories",
4530
- type: "many",
4531
- fields: {
4532
- subCategories: {
4533
- relation: {
4534
- name: "subCategories",
4535
- type: "many",
4536
- fields: {
4537
- name: { field: "name" },
4538
- },
4539
- },
4540
- },
4541
- },
4542
- },
4543
- },
5009
+ "request.resource.attr.aNumber": { field: "aNumber" },
4544
5010
  },
4545
5011
  });
4546
5012
 
4547
5013
  expect(result).toStrictEqual({
4548
5014
  kind: PlanKind.CONDITIONAL,
4549
5015
  filters: {
4550
- categories: {
4551
- some: {
4552
- subCategories: {
4553
- some: {
4554
- name: { in: ["finance", "tech"] },
4555
- },
4556
- },
4557
- },
4558
- },
5016
+ NOT: { aNumber: { gt: 1 } },
4559
5017
  },
4560
5018
  });
5019
+
5020
+ if (result.kind !== PlanKind.CONDITIONAL) {
5021
+ throw new Error("Expected CONDITIONAL result");
5022
+ }
5023
+
5024
+ const query = await prisma.resource.findMany({
5025
+ where: { ...result.filters },
5026
+ });
5027
+ expect(query.map((r) => r.id)).toEqual(
5028
+ fixtureResources
5029
+ .filter((r) => !(r.aNumber > 1))
5030
+ .map((r) => r.id)
5031
+ );
4561
5032
  });
4562
- });
4563
5033
 
4564
- describe("Deep Nesting", () => {
4565
- test("filter on deeply nested relation", async () => {
5034
+ test("conditional - not-lt", async () => {
4566
5035
  const queryPlan = await cerbos.planResources({
4567
5036
  principal: { id: "user1", roles: ["USER"] },
4568
5037
  resource: { kind: "resource" },
4569
- action: "filter-deeply-nested",
5038
+ action: "not-lt",
4570
5039
  });
4571
5040
 
5041
+ expect(queryPlan.kind).toEqual(PlanKind.CONDITIONAL);
5042
+ expect((queryPlan as PlanResourcesConditionalResponse).condition).toEqual(
5043
+ {
5044
+ operator: "not",
5045
+ operands: [
5046
+ {
5047
+ operator: "lt",
5048
+ operands: [
5049
+ { name: "request.resource.attr.aNumber" },
5050
+ { value: 2 },
5051
+ ],
5052
+ },
5053
+ ],
5054
+ }
5055
+ );
5056
+
4572
5057
  const result = queryPlanToPrisma({
4573
5058
  queryPlan,
4574
5059
  mapper: {
4575
- "request.resource.attr.categories": {
4576
- relation: {
4577
- name: "categories",
4578
- type: "many",
4579
- fields: {
4580
- subCategories: {
4581
- relation: {
4582
- name: "subCategories",
4583
- type: "many",
4584
- fields: {
4585
- labels: {
4586
- relation: {
4587
- name: "labels",
4588
- type: "many",
4589
- fields: {
4590
- name: { field: "name" },
4591
- },
4592
- },
4593
- },
4594
- },
4595
- },
4596
- },
4597
- },
4598
- },
4599
- },
5060
+ "request.resource.attr.aNumber": { field: "aNumber" },
4600
5061
  },
4601
5062
  });
4602
5063
 
4603
5064
  expect(result).toStrictEqual({
4604
5065
  kind: PlanKind.CONDITIONAL,
4605
5066
  filters: {
4606
- categories: {
4607
- some: {
4608
- subCategories: {
4609
- some: {
4610
- labels: {
4611
- some: {
4612
- name: { equals: "important" },
4613
- },
4614
- },
4615
- },
4616
- },
4617
- },
4618
- },
5067
+ NOT: { aNumber: { lt: 2 } },
4619
5068
  },
4620
5069
  });
4621
- });
4622
5070
 
4623
- test("map on deeply nested relation", async () => {
4624
- const queryPlan = await cerbos.planResources({
4625
- principal: { id: "user1", roles: ["USER"] },
4626
- resource: { kind: "resource" },
4627
- action: "map-deeply-nested",
4628
- });
5071
+ if (result.kind !== PlanKind.CONDITIONAL) {
5072
+ throw new Error("Expected CONDITIONAL result");
5073
+ }
4629
5074
 
4630
- const result = queryPlanToPrisma({
5075
+ const query = await prisma.resource.findMany({
5076
+ where: { ...result.filters },
5077
+ });
5078
+ expect(query.map((r) => r.id)).toEqual(
5079
+ fixtureResources
5080
+ .filter((r) => !(r.aNumber < 2))
5081
+ .map((r) => r.id)
5082
+ );
5083
+ });
5084
+
5085
+ test("conditional - not-contains", async () => {
5086
+ const queryPlan = await cerbos.planResources({
5087
+ principal: { id: "user1", roles: ["USER"] },
5088
+ resource: { kind: "resource" },
5089
+ action: "not-contains",
5090
+ });
5091
+
5092
+ expect(queryPlan.kind).toEqual(PlanKind.CONDITIONAL);
5093
+ expect((queryPlan as PlanResourcesConditionalResponse).condition).toEqual(
5094
+ {
5095
+ operator: "not",
5096
+ operands: [
5097
+ {
5098
+ operator: "contains",
5099
+ operands: [
5100
+ { name: "request.resource.attr.aString" },
5101
+ { value: "str" },
5102
+ ],
5103
+ },
5104
+ ],
5105
+ }
5106
+ );
5107
+
5108
+ const result = queryPlanToPrisma({
5109
+ queryPlan,
5110
+ mapper: {
5111
+ "request.resource.attr.aString": { field: "aString" },
5112
+ },
5113
+ });
5114
+
5115
+ expect(result).toStrictEqual({
5116
+ kind: PlanKind.CONDITIONAL,
5117
+ filters: {
5118
+ NOT: { aString: { contains: "str" } },
5119
+ },
5120
+ });
5121
+
5122
+ if (result.kind !== PlanKind.CONDITIONAL) {
5123
+ throw new Error("Expected CONDITIONAL result");
5124
+ }
5125
+
5126
+ const query = await prisma.resource.findMany({
5127
+ where: { ...result.filters },
5128
+ });
5129
+ expect(query.map((r) => r.id)).toEqual(
5130
+ fixtureResources
5131
+ .filter((r) => !r.aString.includes("str"))
5132
+ .map((r) => r.id)
5133
+ );
5134
+ });
5135
+
5136
+ test("conditional - not-starts-with", async () => {
5137
+ const queryPlan = await cerbos.planResources({
5138
+ principal: { id: "user1", roles: ["USER"] },
5139
+ resource: { kind: "resource" },
5140
+ action: "not-starts-with",
5141
+ });
5142
+
5143
+ expect(queryPlan.kind).toEqual(PlanKind.CONDITIONAL);
5144
+ expect((queryPlan as PlanResourcesConditionalResponse).condition).toEqual(
5145
+ {
5146
+ operator: "not",
5147
+ operands: [
5148
+ {
5149
+ operator: "startsWith",
5150
+ operands: [
5151
+ { name: "request.resource.attr.aString" },
5152
+ { value: "str" },
5153
+ ],
5154
+ },
5155
+ ],
5156
+ }
5157
+ );
5158
+
5159
+ const result = queryPlanToPrisma({
5160
+ queryPlan,
5161
+ mapper: {
5162
+ "request.resource.attr.aString": { field: "aString" },
5163
+ },
5164
+ });
5165
+
5166
+ expect(result).toStrictEqual({
5167
+ kind: PlanKind.CONDITIONAL,
5168
+ filters: {
5169
+ NOT: { aString: { startsWith: "str" } },
5170
+ },
5171
+ });
5172
+
5173
+ if (result.kind !== PlanKind.CONDITIONAL) {
5174
+ throw new Error("Expected CONDITIONAL result");
5175
+ }
5176
+
5177
+ const query = await prisma.resource.findMany({
5178
+ where: { ...result.filters },
5179
+ });
5180
+ expect(query.map((r) => r.id)).toEqual(
5181
+ fixtureResources
5182
+ .filter((r) => !r.aString.startsWith("str"))
5183
+ .map((r) => r.id)
5184
+ );
5185
+ });
5186
+ });
5187
+
5188
+ describe("Collection Operations", () => {
5189
+ test("conditional - exists on nested collection", async () => {
5190
+ const queryPlan = await cerbos.planResources({
5191
+ principal: { id: "user1", roles: ["USER"] },
5192
+ resource: { kind: "resource" },
5193
+ action: "exists-nested-collection",
5194
+ });
5195
+
5196
+ const result = queryPlanToPrisma({
4631
5197
  queryPlan,
4632
5198
  mapper: {
4633
5199
  "request.resource.attr.categories": {
@@ -4635,6 +5201,7 @@ describe("Complex Operations", () => {
4635
5201
  name: "categories",
4636
5202
  type: "many",
4637
5203
  fields: {
5204
+ name: { field: "name" },
4638
5205
  subCategories: {
4639
5206
  relation: {
4640
5207
  name: "subCategories",
@@ -4655,87 +5222,311 @@ describe("Complex Operations", () => {
4655
5222
  filters: {
4656
5223
  categories: {
4657
5224
  some: {
4658
- subCategories: {
4659
- some: {
4660
- name: { in: ["finance", "tech"] },
5225
+ AND: [
5226
+ { name: { equals: "business" } },
5227
+ {
5228
+ subCategories: {
5229
+ some: {
5230
+ name: { equals: "finance" },
5231
+ },
5232
+ },
4661
5233
  },
4662
- },
5234
+ ],
4663
5235
  },
4664
5236
  },
4665
5237
  },
4666
5238
  });
4667
- });
4668
- });
4669
- });
4670
-
4671
- // Error Handling
4672
- describe("Error Cases", () => {
4673
- test("throws error for invalid query plan", () => {
4674
- const invalidQueryPlan = {
4675
- kind: "INVALID_KIND" as PlanKind,
4676
- };
4677
5239
 
4678
- expect(() =>
4679
- queryPlanToPrisma({
4680
- queryPlan: invalidQueryPlan as unknown as PlanResourcesResponse,
4681
- mapper: {},
4682
- })
4683
- ).toThrow("Invalid query plan.");
4684
- });
5240
+ if (result.kind !== PlanKind.CONDITIONAL) {
5241
+ throw new Error("Expected CONDITIONAL result");
5242
+ }
4685
5243
 
4686
- test("throws error for invalid expression structure", () => {
4687
- const invalidQueryPlan = {
4688
- kind: PlanKind.CONDITIONAL,
4689
- condition: {
4690
- // Missing operator and operands
4691
- },
4692
- };
5244
+ const query = await prisma.resource.findMany({
5245
+ where: { ...result.filters },
5246
+ });
4693
5247
 
4694
- expect(() =>
4695
- queryPlanToPrisma({
4696
- queryPlan: invalidQueryPlan as unknown as PlanResourcesResponse,
4697
- mapper: {},
4698
- })
4699
- ).toThrow("Invalid Cerbos expression structure");
4700
- });
5248
+ expect(query.map((r) => r.id)).toEqual(
5249
+ fixtureResources
5250
+ .filter((r) => {
5251
+ const categoryConnections = r.categories
5252
+ ?.connect as Prisma.CategoryWhereUniqueInput[];
5253
+ return categoryConnections?.some((categoryConnection) => {
5254
+ const category = fixtureCategories.find(
5255
+ (fc) => fc.id === categoryConnection.id
5256
+ );
5257
+ return (
5258
+ category?.name === "business" &&
5259
+ (
5260
+ category?.subCategories
5261
+ ?.connect as Prisma.SubCategoryWhereUniqueInput[]
5262
+ )?.some(
5263
+ (sc) =>
5264
+ fixtureSubCategories.find((fsc) => fsc.id === sc.id)
5265
+ ?.name === "finance"
5266
+ )
5267
+ );
5268
+ });
5269
+ })
5270
+ .map((r) => r.id)
5271
+ );
5272
+ });
4701
5273
 
4702
- test("throws error for unsupported operator", () => {
4703
- const invalidQueryPlan = {
4704
- kind: PlanKind.CONDITIONAL,
4705
- condition: {
4706
- operator: "unsupported",
4707
- operands: [],
4708
- },
4709
- };
5274
+ test("isSet with nested relation", async () => {
5275
+ const queryPlan = await cerbos.planResources({
5276
+ principal: { id: "user1", roles: ["USER"] },
5277
+ resource: { kind: "resource" },
5278
+ action: "is-set-nested",
5279
+ });
4710
5280
 
4711
- expect(() =>
4712
- queryPlanToPrisma({
4713
- queryPlan: invalidQueryPlan as unknown as PlanResourcesResponse,
4714
- mapper: {},
4715
- })
4716
- ).toThrow("Unsupported operator: unsupported");
4717
- });
5281
+ const result = queryPlanToPrisma({
5282
+ queryPlan,
5283
+ mapper: {
5284
+ "request.resource.attr.nested.aOptionalString": {
5285
+ relation: {
5286
+ name: "nested",
5287
+ type: "one",
5288
+ fields: {
5289
+ aOptionalString: { field: "aOptionalString" },
5290
+ },
5291
+ },
5292
+ },
5293
+ },
5294
+ });
4718
5295
 
4719
- test("throws error for invalid operand structure", () => {
4720
- const invalidQueryPlan = {
4721
- kind: PlanKind.CONDITIONAL,
4722
- condition: {
4723
- operator: "eq",
4724
- operands: [
4725
- {}, // Invalid operand without name or value
4726
- { value: "test" },
4727
- ],
4728
- },
4729
- };
5296
+ expect(result).toStrictEqual({
5297
+ kind: PlanKind.CONDITIONAL,
5298
+ filters: {
5299
+ nested: {
5300
+ is: {
5301
+ aOptionalString: { not: null },
5302
+ },
5303
+ },
5304
+ },
5305
+ });
5306
+ });
4730
5307
 
4731
- expect(() =>
4732
- queryPlanToPrisma({
4733
- queryPlan: invalidQueryPlan as unknown as PlanResourcesResponse,
4734
- mapper: {},
4735
- })
4736
- ).toThrow("No valid left operand found");
4737
- });
4738
- });
5308
+ test("hasIntersection with nested collection", async () => {
5309
+ const queryPlan = await cerbos.planResources({
5310
+ principal: { id: "user1", roles: ["USER"] },
5311
+ resource: { kind: "resource" },
5312
+ action: "has-intersection-nested",
5313
+ });
5314
+
5315
+ const result = queryPlanToPrisma({
5316
+ queryPlan,
5317
+ mapper: {
5318
+ "request.resource.attr.categories": {
5319
+ relation: {
5320
+ name: "categories",
5321
+ type: "many",
5322
+ fields: {
5323
+ subCategories: {
5324
+ relation: {
5325
+ name: "subCategories",
5326
+ type: "many",
5327
+ fields: {
5328
+ name: { field: "name" },
5329
+ },
5330
+ },
5331
+ },
5332
+ },
5333
+ },
5334
+ },
5335
+ },
5336
+ });
5337
+
5338
+ expect(result).toStrictEqual({
5339
+ kind: PlanKind.CONDITIONAL,
5340
+ filters: {
5341
+ categories: {
5342
+ some: {
5343
+ subCategories: {
5344
+ some: {
5345
+ name: { in: ["finance", "tech"] },
5346
+ },
5347
+ },
5348
+ },
5349
+ },
5350
+ },
5351
+ });
5352
+ });
5353
+ });
5354
+
5355
+ describe("Deep Nesting", () => {
5356
+ test("filter on deeply nested relation", async () => {
5357
+ const queryPlan = await cerbos.planResources({
5358
+ principal: { id: "user1", roles: ["USER"] },
5359
+ resource: { kind: "resource" },
5360
+ action: "filter-deeply-nested",
5361
+ });
5362
+
5363
+ const result = queryPlanToPrisma({
5364
+ queryPlan,
5365
+ mapper: {
5366
+ "request.resource.attr.categories": {
5367
+ relation: {
5368
+ name: "categories",
5369
+ type: "many",
5370
+ fields: {
5371
+ subCategories: {
5372
+ relation: {
5373
+ name: "subCategories",
5374
+ type: "many",
5375
+ fields: {
5376
+ labels: {
5377
+ relation: {
5378
+ name: "labels",
5379
+ type: "many",
5380
+ fields: {
5381
+ name: { field: "name" },
5382
+ },
5383
+ },
5384
+ },
5385
+ },
5386
+ },
5387
+ },
5388
+ },
5389
+ },
5390
+ },
5391
+ },
5392
+ });
5393
+
5394
+ expect(result).toStrictEqual({
5395
+ kind: PlanKind.CONDITIONAL,
5396
+ filters: {
5397
+ categories: {
5398
+ some: {
5399
+ subCategories: {
5400
+ some: {
5401
+ labels: {
5402
+ some: {
5403
+ name: { equals: "important" },
5404
+ },
5405
+ },
5406
+ },
5407
+ },
5408
+ },
5409
+ },
5410
+ },
5411
+ });
5412
+ });
5413
+
5414
+ test("map on deeply nested relation", async () => {
5415
+ const queryPlan = await cerbos.planResources({
5416
+ principal: { id: "user1", roles: ["USER"] },
5417
+ resource: { kind: "resource" },
5418
+ action: "map-deeply-nested",
5419
+ });
5420
+
5421
+ const result = queryPlanToPrisma({
5422
+ queryPlan,
5423
+ mapper: {
5424
+ "request.resource.attr.categories": {
5425
+ relation: {
5426
+ name: "categories",
5427
+ type: "many",
5428
+ fields: {
5429
+ subCategories: {
5430
+ relation: {
5431
+ name: "subCategories",
5432
+ type: "many",
5433
+ fields: {
5434
+ name: { field: "name" },
5435
+ },
5436
+ },
5437
+ },
5438
+ },
5439
+ },
5440
+ },
5441
+ },
5442
+ });
5443
+
5444
+ expect(result).toStrictEqual({
5445
+ kind: PlanKind.CONDITIONAL,
5446
+ filters: {
5447
+ categories: {
5448
+ some: {
5449
+ subCategories: {
5450
+ some: {
5451
+ name: { in: ["finance", "tech"] },
5452
+ },
5453
+ },
5454
+ },
5455
+ },
5456
+ },
5457
+ });
5458
+ });
5459
+ });
5460
+ });
5461
+
5462
+ // Error Handling
5463
+ describe("Error Cases", () => {
5464
+ test("throws error for invalid query plan", () => {
5465
+ const invalidQueryPlan = {
5466
+ kind: "INVALID_KIND" as PlanKind,
5467
+ };
5468
+
5469
+ expect(() =>
5470
+ queryPlanToPrisma({
5471
+ queryPlan: invalidQueryPlan as unknown as PlanResourcesResponse,
5472
+ mapper: {},
5473
+ })
5474
+ ).toThrow("Invalid query plan.");
5475
+ });
5476
+
5477
+ test("throws error for invalid expression structure", () => {
5478
+ const invalidQueryPlan = {
5479
+ kind: PlanKind.CONDITIONAL,
5480
+ condition: {
5481
+ // Missing operator and operands
5482
+ },
5483
+ };
5484
+
5485
+ expect(() =>
5486
+ queryPlanToPrisma({
5487
+ queryPlan: invalidQueryPlan as unknown as PlanResourcesResponse,
5488
+ mapper: {},
5489
+ })
5490
+ ).toThrow("Invalid Cerbos expression structure");
5491
+ });
5492
+
5493
+ test("throws error for unsupported operator", () => {
5494
+ const invalidQueryPlan = {
5495
+ kind: PlanKind.CONDITIONAL,
5496
+ condition: {
5497
+ operator: "unsupported",
5498
+ operands: [],
5499
+ },
5500
+ };
5501
+
5502
+ expect(() =>
5503
+ queryPlanToPrisma({
5504
+ queryPlan: invalidQueryPlan as unknown as PlanResourcesResponse,
5505
+ mapper: {},
5506
+ })
5507
+ ).toThrow("Unsupported operator: unsupported");
5508
+ });
5509
+
5510
+ test("throws error for invalid operand structure", () => {
5511
+ const invalidQueryPlan = {
5512
+ kind: PlanKind.CONDITIONAL,
5513
+ condition: {
5514
+ operator: "eq",
5515
+ operands: [
5516
+ {}, // Invalid operand without name or value
5517
+ { value: "test" },
5518
+ ],
5519
+ },
5520
+ };
5521
+
5522
+ expect(() =>
5523
+ queryPlanToPrisma({
5524
+ queryPlan: invalidQueryPlan as unknown as PlanResourcesResponse,
5525
+ mapper: {},
5526
+ })
5527
+ ).toThrow("No valid left operand found");
5528
+ });
5529
+ });
4739
5530
 
4740
5531
  // Utility Tests
4741
5532
  describe("Mapper Functions", () => {
@@ -5769,542 +6560,898 @@ describe("Add Operator", () => {
5769
6560
  });
5770
6561
  });
5771
6562
 
5772
- // Issue #155: hierarchy overlaps operator
5773
- // overlaps = one hierarchy is a prefix of the other (segment-wise comparison)
5774
- describe("Hierarchy Overlaps", () => {
5775
- test("overlaps - equal length hierarchies, field must match", () => {
5776
- // #given hierarchy("projects:123", ":").overlaps(hierarchy(["projects", R.id]))
5777
- // Left segments: ["projects", "123"], Right segments: ["projects", R.id]
5778
- // Same length: prefix check both directions requires all segments match
5779
- // segments[0]: "projects" == "projects" ✓
5780
- // segments[1]: "123" == R.id → R.id == "123"
5781
- const plan = createConditionalPlan({
5782
- operator: "overlaps",
6563
+ // Coverage for additional CEL operators emitted by the planner. Linear arithmetic with a
6564
+ // constant side is solved to a plain column comparison (Prisma's where shape cannot express
6565
+ // computed columns); genuinely inexpressible shapes (mod, regex, list indexing, arithmetic
6566
+ // on both sides) throw the adapter's "Unsupported operator: <op>" error so callers can
6567
+ // detect them.
6568
+ describe("Arithmetic Operators", () => {
6569
+ test("arith-add (gt with field+value add) solves for the column", async () => {
6570
+ const queryPlan = await cerbos.planResources({
6571
+ principal: { id: "user1", roles: ["USER"] },
6572
+ resource: { kind: "resource" },
6573
+ action: "arith-add",
6574
+ });
6575
+
6576
+ expect(queryPlan.kind).toEqual(PlanKind.CONDITIONAL);
6577
+ expect((queryPlan as PlanResourcesConditionalResponse).condition).toEqual({
6578
+ operator: "gt",
5783
6579
  operands: [
5784
6580
  {
5785
- operator: "hierarchy",
5786
- operands: [{ value: "projects:123" }, { value: ":" }],
5787
- },
5788
- {
5789
- operator: "hierarchy",
6581
+ operator: "add",
5790
6582
  operands: [
5791
- {
5792
- operator: "list",
5793
- operands: [
5794
- { value: "projects" },
5795
- { name: "request.resource.id" },
5796
- ],
5797
- },
6583
+ { name: "request.resource.attr.aNumber" },
6584
+ { value: 1 },
5798
6585
  ],
5799
6586
  },
6587
+ { value: 2 },
5800
6588
  ],
5801
6589
  });
5802
6590
 
5803
- // #when
6591
+ // aNumber + 1 > 2 ⇔ aNumber > 1
5804
6592
  const result = queryPlanToPrisma({
5805
- queryPlan: plan,
6593
+ queryPlan,
5806
6594
  mapper: {
5807
- "request.resource.id": { field: "id" },
6595
+ "request.resource.attr.aNumber": { field: "aNumber" },
5808
6596
  },
5809
6597
  });
5810
-
5811
- // #then
5812
- expect(result).toEqual({
6598
+ expect(result).toStrictEqual({
5813
6599
  kind: PlanKind.CONDITIONAL,
5814
- filters: { id: { equals: "123" } },
6600
+ filters: { aNumber: { gt: 1 } },
6601
+ });
6602
+
6603
+ if (result.kind !== PlanKind.CONDITIONAL) {
6604
+ throw new Error("Expected CONDITIONAL result");
6605
+ }
6606
+ const query = await prisma.resource.findMany({
6607
+ where: { ...result.filters },
5815
6608
  });
6609
+ expect(query.map((r) => r.id).sort()).toEqual(
6610
+ fixtureResources
6611
+ .filter((r) => r.aNumber + 1 > 2)
6612
+ .map((r) => r.id)
6613
+ .sort()
6614
+ );
5816
6615
  });
5817
6616
 
5818
- test("overlaps - left shorter than right, left is always prefix", () => {
5819
- // #given hierarchy("admin", ":").overlaps(hierarchy(["admin", R.attr.scope]))
5820
- // Left segments: ["admin"] (length 1), Right segments: ["admin", R.attr.scope] (length 2)
5821
- // Left prefix of right: "admin" == "admin" ✓ → always true
5822
- const plan = createConditionalPlan({
5823
- operator: "overlaps",
6617
+ test("arith-sub solves for the column", async () => {
6618
+ const queryPlan = await cerbos.planResources({
6619
+ principal: { id: "user1", roles: ["USER"] },
6620
+ resource: { kind: "resource" },
6621
+ action: "arith-sub",
6622
+ });
6623
+
6624
+ expect(queryPlan.kind).toEqual(PlanKind.CONDITIONAL);
6625
+ expect((queryPlan as PlanResourcesConditionalResponse).condition).toEqual({
6626
+ operator: "lt",
5824
6627
  operands: [
5825
6628
  {
5826
- operator: "hierarchy",
5827
- operands: [{ value: "admin" }, { value: ":" }],
5828
- },
5829
- {
5830
- operator: "hierarchy",
6629
+ operator: "sub",
5831
6630
  operands: [
5832
- {
5833
- operator: "list",
5834
- operands: [
5835
- { value: "admin" },
5836
- { name: "request.resource.attr.scope" },
5837
- ],
5838
- },
6631
+ { name: "request.resource.attr.aNumber" },
6632
+ { value: 1 },
5839
6633
  ],
5840
6634
  },
6635
+ { value: 2 },
5841
6636
  ],
5842
6637
  });
5843
6638
 
5844
- // #when
6639
+ // aNumber - 1 < 2 ⇔ aNumber < 3
5845
6640
  const result = queryPlanToPrisma({
5846
- queryPlan: plan,
6641
+ queryPlan,
5847
6642
  mapper: {
5848
- "request.resource.attr.scope": { field: "scope" },
6643
+ "request.resource.attr.aNumber": { field: "aNumber" },
5849
6644
  },
5850
6645
  });
5851
-
5852
- // #then always true
5853
- expect(result).toEqual({
6646
+ expect(result).toStrictEqual({
5854
6647
  kind: PlanKind.CONDITIONAL,
5855
- filters: {},
6648
+ filters: { aNumber: { lt: 3 } },
6649
+ });
6650
+
6651
+ if (result.kind !== PlanKind.CONDITIONAL) {
6652
+ throw new Error("Expected CONDITIONAL result");
6653
+ }
6654
+ const query = await prisma.resource.findMany({
6655
+ where: { ...result.filters },
5856
6656
  });
6657
+ expect(query.map((r) => r.id).sort()).toEqual(
6658
+ fixtureResources
6659
+ .filter((r) => r.aNumber - 1 < 2)
6660
+ .map((r) => r.id)
6661
+ .sort()
6662
+ );
5857
6663
  });
5858
6664
 
5859
- test("overlaps - left longer, right is prefix when field matches", () => {
5860
- // #given hierarchy("dept:engineering:frontend", ":").overlaps(hierarchy(["dept", R.attr.department]))
5861
- // Left segments: ["dept", "engineering", "frontend"] (3)
5862
- // Right segments: ["dept", R.attr.department] (2)
5863
- // Left prefix of right: len 3 > len 2 → impossible
5864
- // Right prefix of left: "dept" == "dept" ✓, R.attr.department == "engineering"
5865
- const plan = createConditionalPlan({
5866
- operator: "overlaps",
6665
+ test("arith-mult solves for the column", async () => {
6666
+ const queryPlan = await cerbos.planResources({
6667
+ principal: { id: "user1", roles: ["USER"] },
6668
+ resource: { kind: "resource" },
6669
+ action: "arith-mult",
6670
+ });
6671
+
6672
+ expect(queryPlan.kind).toEqual(PlanKind.CONDITIONAL);
6673
+ expect((queryPlan as PlanResourcesConditionalResponse).condition).toEqual({
6674
+ operator: "gt",
5867
6675
  operands: [
5868
6676
  {
5869
- operator: "hierarchy",
5870
- operands: [
5871
- { value: "dept:engineering:frontend" },
5872
- { value: ":" },
5873
- ],
5874
- },
5875
- {
5876
- operator: "hierarchy",
6677
+ operator: "mult",
5877
6678
  operands: [
5878
- {
5879
- operator: "list",
5880
- operands: [
5881
- { value: "dept" },
5882
- { name: "request.resource.attr.department" },
5883
- ],
5884
- },
6679
+ { name: "request.resource.attr.aNumber" },
6680
+ { value: 2 },
5885
6681
  ],
5886
6682
  },
6683
+ { value: 2 },
5887
6684
  ],
5888
6685
  });
5889
6686
 
5890
- // #when
6687
+ // aNumber * 2 > 2 ⇔ aNumber > 1
5891
6688
  const result = queryPlanToPrisma({
5892
- queryPlan: plan,
6689
+ queryPlan,
5893
6690
  mapper: {
5894
- "request.resource.attr.department": { field: "department" },
6691
+ "request.resource.attr.aNumber": { field: "aNumber" },
5895
6692
  },
5896
6693
  });
5897
-
5898
- // #then
5899
- expect(result).toEqual({
6694
+ expect(result).toStrictEqual({
5900
6695
  kind: PlanKind.CONDITIONAL,
5901
- filters: { department: { equals: "engineering" } },
6696
+ filters: { aNumber: { gt: 1 } },
6697
+ });
6698
+
6699
+ if (result.kind !== PlanKind.CONDITIONAL) {
6700
+ throw new Error("Expected CONDITIONAL result");
6701
+ }
6702
+ const query = await prisma.resource.findMany({
6703
+ where: { ...result.filters },
5902
6704
  });
6705
+ expect(query.map((r) => r.id).sort()).toEqual(
6706
+ fixtureResources
6707
+ .filter((r) => r.aNumber * 2 > 2)
6708
+ .map((r) => r.id)
6709
+ .sort()
6710
+ );
5903
6711
  });
5904
6712
 
5905
- test("overlaps - different roots, impossible", () => {
5906
- // #given hierarchy("org:acme:team1", ":").overlaps(hierarchy(["company", R.attr.team]))
5907
- // Left: ["org", "acme", "team1"], Right: ["company", R.attr.team]
5908
- // Both directions fail at segment[0]: "org" ≠ "company"
5909
- const plan = createConditionalPlan({
5910
- operator: "overlaps",
6713
+ test("arith-div solves for the column", async () => {
6714
+ const queryPlan = await cerbos.planResources({
6715
+ principal: { id: "user1", roles: ["USER"] },
6716
+ resource: { kind: "resource" },
6717
+ action: "arith-div",
6718
+ });
6719
+
6720
+ expect(queryPlan.kind).toEqual(PlanKind.CONDITIONAL);
6721
+ expect((queryPlan as PlanResourcesConditionalResponse).condition).toEqual({
6722
+ operator: "gt",
5911
6723
  operands: [
5912
6724
  {
5913
- operator: "hierarchy",
5914
- operands: [{ value: "org:acme:team1" }, { value: ":" }],
5915
- },
5916
- {
5917
- operator: "hierarchy",
6725
+ operator: "div",
5918
6726
  operands: [
5919
- {
5920
- operator: "list",
5921
- operands: [
5922
- { value: "company" },
5923
- { name: "request.resource.attr.team" },
5924
- ],
5925
- },
6727
+ { name: "request.resource.attr.aNumber" },
6728
+ { value: 2 },
5926
6729
  ],
5927
6730
  },
6731
+ { value: 0 },
5928
6732
  ],
5929
6733
  });
5930
6734
 
5931
- // #when
6735
+ // aNumber / 2 > 0 ⇔ aNumber > 0
5932
6736
  const result = queryPlanToPrisma({
5933
- queryPlan: plan,
6737
+ queryPlan,
5934
6738
  mapper: {
5935
- "request.resource.attr.team": { field: "team" },
6739
+ "request.resource.attr.aNumber": { field: "aNumber" },
5936
6740
  },
5937
6741
  });
5938
-
5939
- // #then impossible
5940
- expect(result).toEqual({
6742
+ expect(result).toStrictEqual({
5941
6743
  kind: PlanKind.CONDITIONAL,
5942
- filters: {
5943
- team: { in: [] },
5944
- },
6744
+ filters: { aNumber: { gt: 0 } },
6745
+ });
6746
+
6747
+ if (result.kind !== PlanKind.CONDITIONAL) {
6748
+ throw new Error("Expected CONDITIONAL result");
6749
+ }
6750
+ const query = await prisma.resource.findMany({
6751
+ where: { ...result.filters },
5945
6752
  });
6753
+ expect(query.map((r) => r.id).sort()).toEqual(
6754
+ fixtureResources
6755
+ .filter((r) => r.aNumber / 2 > 0)
6756
+ .map((r) => r.id)
6757
+ .sort()
6758
+ );
5946
6759
  });
5947
6760
 
5948
- test("overlaps - equal length with field in middle, field must match", () => {
5949
- // #given hierarchy("region:us:west", ":").overlaps(hierarchy(["region", "us", R.attr.zone]))
5950
- // Left: ["region", "us", "west"], Right: ["region", "us", R.attr.zone]
5951
- // Same length, prefix check: "region"=="region" ✓, "us"=="us" ✓, "west"==R.attr.zone
5952
- const plan = createConditionalPlan({
5953
- operator: "overlaps",
6761
+ test("arith-mod is unsupported", async () => {
6762
+ const queryPlan = await cerbos.planResources({
6763
+ principal: { id: "user1", roles: ["USER"] },
6764
+ resource: { kind: "resource" },
6765
+ action: "arith-mod",
6766
+ });
6767
+
6768
+ expect(queryPlan.kind).toEqual(PlanKind.CONDITIONAL);
6769
+ expect((queryPlan as PlanResourcesConditionalResponse).condition).toEqual({
6770
+ operator: "eq",
5954
6771
  operands: [
5955
6772
  {
5956
- operator: "hierarchy",
5957
- operands: [{ value: "region:us:west" }, { value: ":" }],
5958
- },
5959
- {
5960
- operator: "hierarchy",
6773
+ operator: "mod",
5961
6774
  operands: [
5962
6775
  {
5963
- operator: "list",
5964
- operands: [
5965
- { value: "region" },
5966
- { value: "us" },
5967
- { name: "request.resource.attr.zone" },
5968
- ],
6776
+ operator: "int",
6777
+ operands: [{ name: "request.resource.attr.aNumber" }],
5969
6778
  },
6779
+ { value: 2 },
5970
6780
  ],
5971
6781
  },
6782
+ { value: 0 },
5972
6783
  ],
5973
6784
  });
5974
6785
 
5975
- // #when
5976
- const result = queryPlanToPrisma({
5977
- queryPlan: plan,
5978
- mapper: {
5979
- "request.resource.attr.zone": { field: "zone" },
5980
- },
6786
+ expect(() =>
6787
+ queryPlanToPrisma({
6788
+ queryPlan,
6789
+ mapper: {
6790
+ "request.resource.attr.aNumber": { field: "aNumber" },
6791
+ },
6792
+ })
6793
+ ).toThrow("Unsupported operator: mod");
6794
+ });
6795
+ });
6796
+
6797
+ describe("Regex Operator", () => {
6798
+ test("matches-regex is unsupported", async () => {
6799
+ const queryPlan = await cerbos.planResources({
6800
+ principal: { id: "user1", roles: ["USER"] },
6801
+ resource: { kind: "resource" },
6802
+ action: "matches-regex",
5981
6803
  });
5982
6804
 
5983
- // #then
5984
- expect(result).toEqual({
5985
- kind: PlanKind.CONDITIONAL,
5986
- filters: { zone: { equals: "west" } },
6805
+ expect(queryPlan.kind).toEqual(PlanKind.CONDITIONAL);
6806
+ expect((queryPlan as PlanResourcesConditionalResponse).condition).toEqual({
6807
+ operator: "matches",
6808
+ operands: [
6809
+ { name: "request.resource.attr.aString" },
6810
+ { value: "^str.*" },
6811
+ ],
5987
6812
  });
6813
+
6814
+ expect(() =>
6815
+ queryPlanToPrisma({
6816
+ queryPlan,
6817
+ mapper: {
6818
+ "request.resource.attr.aString": { field: "aString" },
6819
+ },
6820
+ })
6821
+ ).toThrow("Unsupported operator: matches");
5988
6822
  });
6823
+ });
5989
6824
 
5990
- test("overlaps - single-field right hierarchy as prefix of left", () => {
5991
- // #given hierarchy("team:alpha", ":").overlaps(hierarchy([R.attr.group]))
5992
- // Left: ["team", "alpha"] (2), Right: [R.attr.group] (1)
5993
- // Left prefix of right: len 2 > len 1 → impossible
5994
- // Right prefix of left: R.attr.group == "team" (first segment)
5995
- const plan = createConditionalPlan({
5996
- operator: "overlaps",
6825
+ describe("List Index Operator", () => {
6826
+ test("index-list is unsupported", async () => {
6827
+ const queryPlan = await cerbos.planResources({
6828
+ principal: { id: "user1", roles: ["USER"] },
6829
+ resource: { kind: "resource" },
6830
+ action: "index-list",
6831
+ });
6832
+
6833
+ expect(queryPlan.kind).toEqual(PlanKind.CONDITIONAL);
6834
+ expect((queryPlan as PlanResourcesConditionalResponse).condition).toEqual({
6835
+ operator: "eq",
5997
6836
  operands: [
5998
6837
  {
5999
- operator: "hierarchy",
6000
- operands: [{ value: "team:alpha" }, { value: ":" }],
6001
- },
6002
- {
6003
- operator: "hierarchy",
6838
+ operator: "index",
6004
6839
  operands: [
6005
- {
6006
- operator: "list",
6007
- operands: [{ name: "request.resource.attr.group" }],
6008
- },
6840
+ { name: "request.resource.attr.ownedBy" },
6841
+ { value: 0 },
6009
6842
  ],
6010
6843
  },
6844
+ { value: "user1" },
6011
6845
  ],
6012
6846
  });
6013
6847
 
6014
- // #when
6015
- const result = queryPlanToPrisma({
6016
- queryPlan: plan,
6017
- mapper: {
6018
- "request.resource.attr.group": { field: "group" },
6019
- },
6020
- });
6848
+ expect(() =>
6849
+ queryPlanToPrisma({
6850
+ queryPlan,
6851
+ mapper: {
6852
+ "request.resource.attr.ownedBy": {
6853
+ relation: { name: "ownedBy", type: "many", field: "id" },
6854
+ },
6855
+ },
6856
+ })
6857
+ ).toThrow("Unsupported operator: index");
6858
+ });
6859
+ });
6021
6860
 
6022
- // #then
6023
- expect(result).toEqual({
6024
- kind: PlanKind.CONDITIONAL,
6025
- filters: { group: { equals: "team" } },
6861
+ describe("Type Conversion Operators", () => {
6862
+ test("convert-string is unsupported", async () => {
6863
+ const queryPlan = await cerbos.planResources({
6864
+ principal: { id: "user1", roles: ["USER"] },
6865
+ resource: { kind: "resource" },
6866
+ action: "convert-string",
6026
6867
  });
6027
- });
6028
6868
 
6029
- test("overlaps - hierarchy(dotted string) without delimiter", () => {
6030
- // #given hierarchy("a.b.c").overlaps(hierarchy(["a", "b", R.attr.zone]))
6031
- // hierarchy("a.b.c") uses default "." delimiter → ["a", "b", "c"]
6032
- const plan = createConditionalPlan({
6033
- operator: "overlaps",
6869
+ expect(queryPlan.kind).toEqual(PlanKind.CONDITIONAL);
6870
+ expect((queryPlan as PlanResourcesConditionalResponse).condition).toEqual({
6871
+ operator: "eq",
6034
6872
  operands: [
6035
6873
  {
6036
- operator: "hierarchy",
6037
- operands: [{ value: "a.b.c" }],
6038
- },
6039
- {
6040
- operator: "hierarchy",
6041
- operands: [
6042
- {
6043
- operator: "list",
6044
- operands: [
6045
- { value: "a" },
6046
- { value: "b" },
6047
- { name: "request.resource.attr.zone" },
6048
- ],
6049
- },
6050
- ],
6874
+ operator: "string",
6875
+ operands: [{ name: "request.resource.attr.aNumber" }],
6051
6876
  },
6877
+ { value: "1" },
6052
6878
  ],
6053
6879
  });
6054
6880
 
6055
- // #when
6056
- const result = queryPlanToPrisma({
6057
- queryPlan: plan,
6058
- mapper: { "request.resource.attr.zone": { field: "zone" } },
6881
+ expect(() =>
6882
+ queryPlanToPrisma({
6883
+ queryPlan,
6884
+ mapper: {
6885
+ "request.resource.attr.aNumber": { field: "aNumber" },
6886
+ },
6887
+ })
6888
+ ).toThrow("Unsupported operator: string");
6889
+ });
6890
+
6891
+ test("convert-double is unsupported", async () => {
6892
+ const queryPlan = await cerbos.planResources({
6893
+ principal: { id: "user1", roles: ["USER"] },
6894
+ resource: { kind: "resource" },
6895
+ action: "convert-double",
6059
6896
  });
6060
6897
 
6061
- // #then
6062
- expect(result).toEqual({
6063
- kind: PlanKind.CONDITIONAL,
6064
- filters: { zone: { equals: "c" } },
6898
+ expect(queryPlan.kind).toEqual(PlanKind.CONDITIONAL);
6899
+ expect((queryPlan as PlanResourcesConditionalResponse).condition).toEqual({
6900
+ operator: "gt",
6901
+ operands: [
6902
+ {
6903
+ operator: "double",
6904
+ operands: [{ name: "request.resource.attr.aNumber" }],
6905
+ },
6906
+ { value: 1.5 },
6907
+ ],
6065
6908
  });
6909
+
6910
+ expect(() =>
6911
+ queryPlanToPrisma({
6912
+ queryPlan,
6913
+ mapper: {
6914
+ "request.resource.attr.aNumber": { field: "aNumber" },
6915
+ },
6916
+ })
6917
+ ).toThrow("Unsupported operator: double");
6066
6918
  });
6067
6919
 
6068
- test("overlaps via cerbos integration", async () => {
6920
+ test("convert-int is unsupported", async () => {
6069
6921
  const queryPlan = await cerbos.planResources({
6070
- principal: {
6071
- id: "user1",
6072
- roles: ["USER"],
6073
- attr: { context: "projects:123" },
6074
- },
6922
+ principal: { id: "user1", roles: ["USER"] },
6075
6923
  resource: { kind: "resource" },
6076
- action: "hierarchy-overlaps",
6924
+ action: "convert-int",
6077
6925
  });
6078
6926
 
6079
- expect(queryPlan.kind).toBe(PlanKind.CONDITIONAL);
6080
-
6081
- const result = queryPlanToPrisma({
6082
- queryPlan,
6083
- mapper: {
6084
- "request.resource.id": { field: "id" },
6085
- },
6927
+ expect(queryPlan.kind).toEqual(PlanKind.CONDITIONAL);
6928
+ expect((queryPlan as PlanResourcesConditionalResponse).condition).toEqual({
6929
+ operator: "gt",
6930
+ operands: [
6931
+ {
6932
+ operator: "int",
6933
+ operands: [{ name: "request.resource.attr.aString" }],
6934
+ },
6935
+ { value: 0 },
6936
+ ],
6086
6937
  });
6087
6938
 
6088
- // hierarchy("projects:123", ":") segments = ["projects", "123"]
6089
- // hierarchy(["projects", R.id]) segments = ["projects", R.id]
6090
- // Same length, segments[1]: "123" == R.id → R.id == "123"
6091
- expect(result).toEqual({
6092
- kind: PlanKind.CONDITIONAL,
6093
- filters: { id: { equals: "123" } },
6094
- });
6939
+ expect(() =>
6940
+ queryPlanToPrisma({
6941
+ queryPlan,
6942
+ mapper: {
6943
+ "request.resource.attr.aString": { field: "aString" },
6944
+ },
6945
+ })
6946
+ ).toThrow("Unsupported operator: int");
6095
6947
  });
6096
6948
  });
6097
6949
 
6098
- const hierarchyMapper = {
6099
- "request.resource.attr.scope": { field: "scope" },
6100
- "request.resource.attr.path": { field: "path" },
6101
- };
6950
+ describe("Ternary Operator", () => {
6951
+ const mapper = {
6952
+ "request.resource.attr.aBool": { field: "aBool" },
6953
+ "request.resource.attr.aNumber": { field: "aNumber" },
6954
+ "request.resource.attr.aString": { field: "aString" },
6955
+ "request.resource.attr.aOptionalString": { field: "aOptionalString" },
6956
+ "request.resource.attr.aOptionalBool": { field: "aOptionalBool" },
6957
+ };
6102
6958
 
6103
- // ancestorOf = first hierarchy is a strict prefix of second
6104
- // hierarchy("a.b").ancestorOf(hierarchy("a.b.c.d")) == true
6105
- describe("Hierarchy ancestorOf", () => {
6106
- test("ancestorOf - constant is ancestor, field is descendant", () => {
6107
- // #given hierarchy("a.b.c").ancestorOf(hierarchy(R.attr.scope))
6108
- const plan = createConditionalPlan({
6109
- operator: "ancestorOf",
6959
+ async function matchingResourceIds(
6960
+ queryPlan: PlanResourcesResponse
6961
+ ): Promise<string[]> {
6962
+ const result = queryPlanToPrisma({ queryPlan, mapper });
6963
+ if (result.kind !== PlanKind.CONDITIONAL) {
6964
+ throw new Error("Expected CONDITIONAL result");
6965
+ }
6966
+ const resources = await prisma.resource.findMany({
6967
+ where: { ...result.filters },
6968
+ });
6969
+ return resources.map((resource) => resource.id).sort();
6970
+ }
6971
+
6972
+ test("rewrites a comparison-wrapped ternary from the PDP", async () => {
6973
+ const queryPlan = await cerbos.planResources({
6974
+ principal: { id: "user1", roles: ["USER"] },
6975
+ resource: { kind: "resource" },
6976
+ action: "ternary",
6977
+ });
6978
+
6979
+ expect(queryPlan.kind).toEqual(PlanKind.CONDITIONAL);
6980
+ expect((queryPlan as PlanResourcesConditionalResponse).condition).toEqual({
6981
+ operator: "gt",
6110
6982
  operands: [
6111
6983
  {
6112
- operator: "hierarchy",
6113
- operands: [{ value: "a.b.c" }],
6114
- },
6115
- {
6116
- operator: "hierarchy",
6117
- operands: [{ name: "request.resource.attr.scope" }],
6984
+ operator: "if",
6985
+ operands: [
6986
+ { name: "request.resource.attr.aBool" },
6987
+ { name: "request.resource.attr.aNumber" },
6988
+ { value: 0 },
6989
+ ],
6118
6990
  },
6991
+ { value: 0 },
6119
6992
  ],
6120
6993
  });
6121
6994
 
6122
- // #when
6123
- const result = queryPlanToPrisma({ queryPlan: plan, mapper: hierarchyMapper });
6124
-
6125
- // #then - R.attr.scope must start with "a.b.c." (ancestor means strict prefix)
6126
- expect(result).toEqual({
6995
+ expect(queryPlanToPrisma({ queryPlan, mapper })).toStrictEqual({
6127
6996
  kind: PlanKind.CONDITIONAL,
6128
- filters: { scope: { startsWith: "a.b.c." } },
6997
+ filters: {
6998
+ OR: [
6999
+ {
7000
+ AND: [
7001
+ { aBool: { equals: true } },
7002
+ { aNumber: { gt: 0 } },
7003
+ ],
7004
+ },
7005
+ {
7006
+ AND: [
7007
+ { aBool: { equals: true } },
7008
+ { aBool: { equals: false } },
7009
+ ],
7010
+ },
7011
+ ],
7012
+ },
6129
7013
  });
7014
+ expect(await matchingResourceIds(queryPlan)).toEqual(["resource1"]);
6130
7015
  });
6131
7016
 
6132
- test("ancestorOf - field is ancestor of constant", () => {
6133
- // #given hierarchy(R.attr.scope).ancestorOf(hierarchy("a.b.c.d"))
6134
- // R.attr.scope must be a prefix of "a.b.c.d"
6135
- // Possible prefixes: "a", "a.b", "a.b.c"
6136
- const plan = createConditionalPlan({
6137
- operator: "ancestorOf",
7017
+ test("rewrites a ternary used directly as a boolean predicate", async () => {
7018
+ const queryPlan = createConditionalPlan({
7019
+ operator: "if",
6138
7020
  operands: [
7021
+ { name: "request.resource.attr.aBool" },
6139
7022
  {
6140
- operator: "hierarchy",
6141
- operands: [{ name: "request.resource.attr.scope" }],
7023
+ operator: "gt",
7024
+ operands: [
7025
+ { name: "request.resource.attr.aNumber" },
7026
+ { value: 0 },
7027
+ ],
6142
7028
  },
6143
7029
  {
6144
- operator: "hierarchy",
6145
- operands: [{ value: "a.b.c.d" }],
7030
+ operator: "eq",
7031
+ operands: [
7032
+ { name: "request.resource.attr.aString" },
7033
+ { value: "string3" },
7034
+ ],
6146
7035
  },
6147
7036
  ],
6148
7037
  });
6149
7038
 
6150
- // #when
6151
- const result = queryPlanToPrisma({ queryPlan: plan, mapper: hierarchyMapper });
6152
-
6153
- // #then - scope must be one of the strict prefixes of "a.b.c.d"
6154
- expect(result).toEqual({
6155
- kind: PlanKind.CONDITIONAL,
6156
- filters: { scope: { in: ["a", "a.b", "a.b.c"] } },
6157
- });
7039
+ expect(await matchingResourceIds(queryPlan)).toEqual([
7040
+ "resource1",
7041
+ "resource3",
7042
+ ]);
6158
7043
  });
6159
7044
 
6160
- test("ancestorOf - single segment constant ancestor", () => {
6161
- // #given hierarchy("root").ancestorOf(hierarchy(R.attr.scope))
6162
- const plan = createConditionalPlan({
6163
- operator: "ancestorOf",
7045
+ test("recursively rewrites nested ternary branches", async () => {
7046
+ const queryPlan = createConditionalPlan({
7047
+ operator: "gt",
6164
7048
  operands: [
6165
7049
  {
6166
- operator: "hierarchy",
6167
- operands: [{ value: "root" }],
7050
+ operator: "if",
7051
+ operands: [
7052
+ { name: "request.resource.attr.aBool" },
7053
+ {
7054
+ operator: "if",
7055
+ operands: [
7056
+ {
7057
+ operator: "eq",
7058
+ operands: [
7059
+ { name: "request.resource.attr.aString" },
7060
+ { value: "string" },
7061
+ ],
7062
+ },
7063
+ { name: "request.resource.attr.aNumber" },
7064
+ { value: 0 },
7065
+ ],
7066
+ },
7067
+ { value: 0 },
7068
+ ],
6168
7069
  },
7070
+ { value: 0 },
7071
+ ],
7072
+ });
7073
+
7074
+ expect(await matchingResourceIds(queryPlan)).toEqual(["resource1"]);
7075
+ });
7076
+
7077
+ test("mirrors a value-first comparison around a ternary", async () => {
7078
+ const queryPlan = createConditionalPlan({
7079
+ operator: "lt",
7080
+ operands: [
7081
+ { value: 0 },
6169
7082
  {
6170
- operator: "hierarchy",
6171
- operands: [{ name: "request.resource.attr.scope" }],
7083
+ operator: "if",
7084
+ operands: [
7085
+ { name: "request.resource.attr.aBool" },
7086
+ { name: "request.resource.attr.aNumber" },
7087
+ { value: -1 },
7088
+ ],
6172
7089
  },
6173
7090
  ],
6174
7091
  });
6175
7092
 
6176
- // #when
6177
- const result = queryPlanToPrisma({ queryPlan: plan, mapper: hierarchyMapper });
6178
-
6179
- // #then
6180
- expect(result).toEqual({
7093
+ const result = queryPlanToPrisma({ queryPlan, mapper });
7094
+ expect(result).toStrictEqual({
6181
7095
  kind: PlanKind.CONDITIONAL,
6182
- filters: { scope: { startsWith: "root." } },
7096
+ filters: {
7097
+ OR: [
7098
+ {
7099
+ AND: [
7100
+ { aBool: { equals: true } },
7101
+ { aNumber: { gt: 0 } },
7102
+ ],
7103
+ },
7104
+ {
7105
+ AND: [
7106
+ { aBool: { equals: true } },
7107
+ { aBool: { equals: false } },
7108
+ ],
7109
+ },
7110
+ ],
7111
+ },
6183
7112
  });
7113
+ expect(await matchingResourceIds(queryPlan)).toEqual(["resource1"]);
6184
7114
  });
6185
7115
 
6186
- test("ancestorOf - with custom delimiter", () => {
6187
- // #given hierarchy(P.attr.scope, ":").ancestorOf(hierarchy(R.attr.path, ":"))
6188
- // P.attr.scope is resolved at plan time → constant "org:team"
6189
- const plan = createConditionalPlan({
6190
- operator: "ancestorOf",
7116
+ test("keeps a nullable condition unknown under outer negation", async () => {
7117
+ const queryPlan = createConditionalPlan({
7118
+ operator: "not",
6191
7119
  operands: [
6192
7120
  {
6193
- operator: "hierarchy",
6194
- operands: [{ value: "org:team" }, { value: ":" }],
6195
- },
6196
- {
6197
- operator: "hierarchy",
6198
- operands: [{ name: "request.resource.attr.path" }, { value: ":" }],
7121
+ operator: "if",
7122
+ operands: [
7123
+ { name: "request.resource.attr.aOptionalBool" },
7124
+ { value: false },
7125
+ { value: false },
7126
+ ],
6199
7127
  },
6200
7128
  ],
6201
7129
  });
6202
7130
 
6203
- // #when
6204
- const result = queryPlanToPrisma({ queryPlan: plan, mapper: hierarchyMapper });
6205
-
6206
- // #then - R.attr.path must start with "org:team:" (using the ":" delimiter)
6207
- expect(result).toEqual({
6208
- kind: PlanKind.CONDITIONAL,
6209
- filters: { path: { startsWith: "org:team:" } },
6210
- });
7131
+ expect(await matchingResourceIds(queryPlan)).toEqual([
7132
+ "resource1",
7133
+ "resource2",
7134
+ ]);
6211
7135
  });
6212
7136
 
6213
- test("ancestorOf via cerbos integration", async () => {
6214
- const plan = await cerbos.planResources({
6215
- principal: {
6216
- id: "user1",
6217
- roles: ["USER"],
6218
- attr: { scope: "a.b.c" },
7137
+ test("negates a relation-backed boolean condition as a whole", () => {
7138
+ const queryPlan = createConditionalPlan({
7139
+ operator: "if",
7140
+ operands: [
7141
+ { name: "request.resource.attr.ownedBy.aBool" },
7142
+ { value: false },
7143
+ { value: true },
7144
+ ],
7145
+ });
7146
+
7147
+ expect(
7148
+ queryPlanToPrisma({
7149
+ queryPlan,
7150
+ mapper: {
7151
+ "request.resource.attr.ownedBy": {
7152
+ relation: {
7153
+ name: "ownedBy",
7154
+ type: "many",
7155
+ fields: { aBool: { field: "aBool" } },
7156
+ },
7157
+ },
7158
+ },
7159
+ })
7160
+ ).toStrictEqual({
7161
+ kind: PlanKind.CONDITIONAL,
7162
+ filters: {
7163
+ OR: [
7164
+ {
7165
+ NOT: {
7166
+ ownedBy: {
7167
+ some: { aBool: { equals: true } },
7168
+ },
7169
+ },
7170
+ },
7171
+ {
7172
+ AND: [
7173
+ {
7174
+ ownedBy: {
7175
+ some: { aBool: { equals: true } },
7176
+ },
7177
+ },
7178
+ {
7179
+ NOT: {
7180
+ ownedBy: {
7181
+ some: { aBool: { equals: true } },
7182
+ },
7183
+ },
7184
+ },
7185
+ ],
7186
+ },
7187
+ ],
6219
7188
  },
7189
+ });
7190
+ });
7191
+
7192
+ test("rejects malformed and non-boolean ternary conditions", () => {
7193
+ expect(() =>
7194
+ queryPlanToPrisma({
7195
+ queryPlan: createConditionalPlan({
7196
+ operator: "if",
7197
+ operands: [
7198
+ { name: "request.resource.attr.aBool" },
7199
+ { value: true },
7200
+ ],
7201
+ }),
7202
+ mapper,
7203
+ })
7204
+ ).toThrow(
7205
+ "if (ternary) requires exactly 3 operands (condition, then, else), got 2"
7206
+ );
7207
+
7208
+ expect(() =>
7209
+ queryPlanToPrisma({
7210
+ queryPlan: createConditionalPlan({
7211
+ operator: "if",
7212
+ operands: [{ value: null }, { value: true }, { value: false }],
7213
+ }),
7214
+ mapper,
7215
+ })
7216
+ ).toThrow("if (ternary) condition must be a boolean expression");
7217
+
7218
+ expect(() =>
7219
+ queryPlanToPrisma({
7220
+ queryPlan: createConditionalPlan({
7221
+ operator: "eq",
7222
+ operands: [
7223
+ {
7224
+ operator: "if",
7225
+ operands: [
7226
+ { name: "request.resource.attr.aBool" },
7227
+ { value: true },
7228
+ { value: false },
7229
+ ],
7230
+ },
7231
+ { value: true },
7232
+ { value: false },
7233
+ ],
7234
+ }),
7235
+ mapper,
7236
+ })
7237
+ ).toThrow("eq with a ternary requires exactly 2 operands, got 3");
7238
+ });
7239
+
7240
+ test("handles planner-normalized constant conditions", async () => {
7241
+ const bareTrue = createConditionalPlan({
7242
+ operator: "if",
7243
+ operands: [{ value: true }, { value: true }, { value: false }],
7244
+ });
7245
+ const bareFalse = createConditionalPlan({
7246
+ operator: "if",
7247
+ operands: [{ value: true }, { value: false }, { value: true }],
7248
+ });
7249
+ const comparisonFalse = createConditionalPlan({
7250
+ operator: "gt",
7251
+ operands: [
7252
+ {
7253
+ operator: "if",
7254
+ operands: [{ value: true }, { value: 0 }, { value: 1 }],
7255
+ },
7256
+ { value: 0 },
7257
+ ],
7258
+ });
7259
+
7260
+ expect(await matchingResourceIds(bareTrue)).toEqual([
7261
+ "resource1",
7262
+ "resource2",
7263
+ "resource3",
7264
+ ]);
7265
+ expect(() => queryPlanToPrisma({ queryPlan: bareFalse, mapper })).toThrow(
7266
+ "A constant-false conditional predicate must be folded by the Cerbos planner"
7267
+ );
7268
+ expect(() =>
7269
+ queryPlanToPrisma({ queryPlan: comparisonFalse, mapper })
7270
+ ).toThrow(
7271
+ "A constant-false conditional predicate must be folded by the Cerbos planner"
7272
+ );
7273
+ });
7274
+
7275
+ test("orders folded strings by Unicode code point", async () => {
7276
+ const queryPlan = createConditionalPlan({
7277
+ operator: "lt",
7278
+ operands: [
7279
+ {
7280
+ operator: "if",
7281
+ operands: [
7282
+ { name: "request.resource.attr.aBool" },
7283
+ { value: "\u{10000}" },
7284
+ { value: "\u{10000}" },
7285
+ ],
7286
+ },
7287
+ { value: "\uE000" },
7288
+ ],
7289
+ });
7290
+
7291
+ expect(await matchingResourceIds(queryPlan)).toEqual([]);
7292
+ });
7293
+ });
7294
+
7295
+ describe("Size on String", () => {
7296
+ test("string-size is unsupported (size() on a scalar string has no relation mapping)", async () => {
7297
+ const queryPlan = await cerbos.planResources({
7298
+ principal: { id: "user1", roles: ["USER"] },
6220
7299
  resource: { kind: "resource" },
6221
- action: "hierarchy-ancestor-of",
7300
+ action: "string-size",
7301
+ });
7302
+
7303
+ expect(queryPlan.kind).toEqual(PlanKind.CONDITIONAL);
7304
+ expect((queryPlan as PlanResourcesConditionalResponse).condition).toEqual({
7305
+ operator: "gt",
7306
+ operands: [
7307
+ {
7308
+ operator: "size",
7309
+ operands: [{ name: "request.resource.attr.aString" }],
7310
+ },
7311
+ { value: 0 },
7312
+ ],
7313
+ });
7314
+
7315
+ // size(scalar_string) cannot be expressed in Prisma's where shape and the
7316
+ // adapter's size handler requires a relation mapping. A scalar string
7317
+ // field is not a relation, so this throws.
7318
+ expect(() =>
7319
+ queryPlanToPrisma({
7320
+ queryPlan,
7321
+ mapper: {
7322
+ "request.resource.attr.aString": { field: "aString" },
7323
+ },
7324
+ })
7325
+ ).toThrow("size operator requires a relation mapping");
7326
+ });
7327
+ });
7328
+
7329
+ describe("Empty Collection", () => {
7330
+ test("empty-collection (size(relation) == 0) maps to relation.none filter", async () => {
7331
+ const queryPlan = await cerbos.planResources({
7332
+ principal: { id: "user1", roles: ["USER"] },
7333
+ resource: { kind: "resource" },
7334
+ action: "empty-collection",
7335
+ });
7336
+
7337
+ expect(queryPlan.kind).toEqual(PlanKind.CONDITIONAL);
7338
+ expect((queryPlan as PlanResourcesConditionalResponse).condition).toEqual({
7339
+ operator: "eq",
7340
+ operands: [
7341
+ {
7342
+ operator: "size",
7343
+ operands: [{ name: "request.resource.attr.tags" }],
7344
+ },
7345
+ { value: 0 },
7346
+ ],
6222
7347
  });
6223
7348
 
6224
7349
  const result = queryPlanToPrisma({
6225
- queryPlan: plan,
7350
+ queryPlan,
6226
7351
  mapper: {
6227
- "request.resource.attr.scope": { field: "scope" },
7352
+ "request.resource.attr.tags": {
7353
+ relation: { name: "tags", type: "many" },
7354
+ },
6228
7355
  },
6229
7356
  });
6230
7357
 
6231
- // hierarchy("a.b.c").ancestorOf(hierarchy(R.attr.scope))
6232
- // "a.b.c" is ancestor of R.attr.scope → R.attr.scope starts with "a.b.c."
6233
- expect(result).toEqual({
7358
+ expect(result).toStrictEqual({
6234
7359
  kind: PlanKind.CONDITIONAL,
6235
- filters: { scope: { startsWith: "a.b.c." } },
7360
+ filters: { tags: { none: {} } },
7361
+ });
7362
+
7363
+ if (result.kind !== PlanKind.CONDITIONAL) {
7364
+ throw new Error("Expected CONDITIONAL result");
7365
+ }
7366
+
7367
+ const query = await prisma.resource.findMany({
7368
+ where: { ...result.filters },
6236
7369
  });
7370
+
7371
+ expect(query.map((r) => r.id)).toEqual(
7372
+ fixtureResources
7373
+ .filter((r) => {
7374
+ if (!r.tags?.connect) return true;
7375
+ return (r.tags.connect as { id: string }[]).length === 0;
7376
+ })
7377
+ .map((r) => r.id)
7378
+ );
6237
7379
  });
6238
7380
  });
6239
7381
 
6240
- // descendentOf = mirror of ancestorOf
6241
- // hierarchy("a.b.c.d").descendentOf(hierarchy("a.b")) == true
6242
- describe("Hierarchy descendentOf", () => {
6243
- test("descendentOf - field is descendant of constant", () => {
6244
- // #given hierarchy(R.attr.scope).descendentOf(hierarchy("a.b"))
6245
- // R.attr.scope must be a descendant of "a.b" → "a.b" is a prefix of R.attr.scope
7382
+ // Issue #155: hierarchy overlaps operator
7383
+ // overlaps = one hierarchy is a prefix of the other (segment-wise comparison)
7384
+ describe("Hierarchy Overlaps", () => {
7385
+ test("overlaps - equal length hierarchies, field must match", () => {
7386
+ // #given hierarchy("projects:123", ":").overlaps(hierarchy(["projects", R.id]))
7387
+ // Left segments: ["projects", "123"], Right segments: ["projects", R.id]
7388
+ // Same length: prefix check both directions requires all segments match
7389
+ // segments[0]: "projects" == "projects" ✓
7390
+ // segments[1]: "123" == R.id → R.id == "123"
6246
7391
  const plan = createConditionalPlan({
6247
- operator: "descendentOf",
7392
+ operator: "overlaps",
6248
7393
  operands: [
6249
7394
  {
6250
7395
  operator: "hierarchy",
6251
- operands: [{ name: "request.resource.attr.scope" }],
7396
+ operands: [{ value: "projects:123" }, { value: ":" }],
6252
7397
  },
6253
7398
  {
6254
7399
  operator: "hierarchy",
6255
- operands: [{ value: "a.b" }],
7400
+ operands: [
7401
+ {
7402
+ operator: "list",
7403
+ operands: [
7404
+ { value: "projects" },
7405
+ { name: "request.resource.id" },
7406
+ ],
7407
+ },
7408
+ ],
6256
7409
  },
6257
7410
  ],
6258
7411
  });
6259
7412
 
6260
7413
  // #when
6261
- const result = queryPlanToPrisma({ queryPlan: plan, mapper: hierarchyMapper });
7414
+ const result = queryPlanToPrisma({
7415
+ queryPlan: plan,
7416
+ mapper: {
7417
+ "request.resource.id": { field: "id" },
7418
+ },
7419
+ });
6262
7420
 
6263
- // #then - scope must start with "a.b." (descendant = strict, must be longer)
7421
+ // #then
6264
7422
  expect(result).toEqual({
6265
7423
  kind: PlanKind.CONDITIONAL,
6266
- filters: { scope: { startsWith: "a.b." } },
7424
+ filters: { id: { equals: "123" } },
6267
7425
  });
6268
7426
  });
6269
7427
 
6270
- test("descendentOf - constant is descendant of field", () => {
6271
- // #given hierarchy("a.b.c.d").descendentOf(hierarchy(R.attr.scope))
6272
- // "a.b.c.d" descends from R.attr.scope → R.attr.scope is a prefix of "a.b.c.d"
7428
+ test("overlaps - left shorter than right, left is always prefix", () => {
7429
+ // #given hierarchy("admin", ":").overlaps(hierarchy(["admin", R.attr.scope]))
7430
+ // Left segments: ["admin"] (length 1), Right segments: ["admin", R.attr.scope] (length 2)
7431
+ // Left prefix of right: "admin" == "admin" ✓ → always true
6273
7432
  const plan = createConditionalPlan({
6274
- operator: "descendentOf",
7433
+ operator: "overlaps",
6275
7434
  operands: [
6276
7435
  {
6277
7436
  operator: "hierarchy",
6278
- operands: [{ value: "a.b.c.d" }],
7437
+ operands: [{ value: "admin" }, { value: ":" }],
6279
7438
  },
6280
7439
  {
6281
7440
  operator: "hierarchy",
6282
- operands: [{ name: "request.resource.attr.scope" }],
7441
+ operands: [
7442
+ {
7443
+ operator: "list",
7444
+ operands: [
7445
+ { value: "admin" },
7446
+ { name: "request.resource.attr.scope" },
7447
+ ],
7448
+ },
7449
+ ],
6283
7450
  },
6284
7451
  ],
6285
7452
  });
6286
7453
 
6287
7454
  // #when
6288
- const result = queryPlanToPrisma({ queryPlan: plan, mapper: hierarchyMapper });
6289
-
6290
- // #then - scope must be one of the strict prefixes of "a.b.c.d"
6291
- expect(result).toEqual({
6292
- kind: PlanKind.CONDITIONAL,
6293
- filters: { scope: { in: ["a", "a.b", "a.b.c"] } },
6294
- });
6295
- });
6296
-
6297
- test("descendentOf via cerbos integration", async () => {
6298
- const plan = await cerbos.planResources({
6299
- principal: {
6300
- id: "user1",
6301
- roles: ["USER"],
6302
- attr: { scope: "a.b.c" },
6303
- },
6304
- resource: { kind: "resource" },
6305
- action: "hierarchy-descendent-of",
6306
- });
6307
-
6308
7455
  const result = queryPlanToPrisma({
6309
7456
  queryPlan: plan,
6310
7457
  mapper: {
@@ -6312,11 +7459,1427 @@ describe("Hierarchy descendentOf", () => {
6312
7459
  },
6313
7460
  });
6314
7461
 
6315
- // hierarchy(R.attr.scope).descendentOf(hierarchy("a.b.c"))
6316
- // R.attr.scope descends from "a.b.c" → R.attr.scope starts with "a.b.c."
7462
+ // #then always true
6317
7463
  expect(result).toEqual({
6318
7464
  kind: PlanKind.CONDITIONAL,
6319
- filters: { scope: { startsWith: "a.b.c." } },
7465
+ filters: {},
6320
7466
  });
6321
7467
  });
7468
+
7469
+ test("overlaps - left longer, right is prefix when field matches", () => {
7470
+ // #given hierarchy("dept:engineering:frontend", ":").overlaps(hierarchy(["dept", R.attr.department]))
7471
+ // Left segments: ["dept", "engineering", "frontend"] (3)
7472
+ // Right segments: ["dept", R.attr.department] (2)
7473
+ // Left prefix of right: len 3 > len 2 → impossible
7474
+ // Right prefix of left: "dept" == "dept" ✓, R.attr.department == "engineering"
7475
+ const plan = createConditionalPlan({
7476
+ operator: "overlaps",
7477
+ operands: [
7478
+ {
7479
+ operator: "hierarchy",
7480
+ operands: [
7481
+ { value: "dept:engineering:frontend" },
7482
+ { value: ":" },
7483
+ ],
7484
+ },
7485
+ {
7486
+ operator: "hierarchy",
7487
+ operands: [
7488
+ {
7489
+ operator: "list",
7490
+ operands: [
7491
+ { value: "dept" },
7492
+ { name: "request.resource.attr.department" },
7493
+ ],
7494
+ },
7495
+ ],
7496
+ },
7497
+ ],
7498
+ });
7499
+
7500
+ // #when
7501
+ const result = queryPlanToPrisma({
7502
+ queryPlan: plan,
7503
+ mapper: {
7504
+ "request.resource.attr.department": { field: "department" },
7505
+ },
7506
+ });
7507
+
7508
+ // #then
7509
+ expect(result).toEqual({
7510
+ kind: PlanKind.CONDITIONAL,
7511
+ filters: { department: { equals: "engineering" } },
7512
+ });
7513
+ });
7514
+
7515
+ test("overlaps - different roots, impossible", () => {
7516
+ // #given hierarchy("org:acme:team1", ":").overlaps(hierarchy(["company", R.attr.team]))
7517
+ // Left: ["org", "acme", "team1"], Right: ["company", R.attr.team]
7518
+ // Both directions fail at segment[0]: "org" ≠ "company"
7519
+ const plan = createConditionalPlan({
7520
+ operator: "overlaps",
7521
+ operands: [
7522
+ {
7523
+ operator: "hierarchy",
7524
+ operands: [{ value: "org:acme:team1" }, { value: ":" }],
7525
+ },
7526
+ {
7527
+ operator: "hierarchy",
7528
+ operands: [
7529
+ {
7530
+ operator: "list",
7531
+ operands: [
7532
+ { value: "company" },
7533
+ { name: "request.resource.attr.team" },
7534
+ ],
7535
+ },
7536
+ ],
7537
+ },
7538
+ ],
7539
+ });
7540
+
7541
+ // #when
7542
+ const result = queryPlanToPrisma({
7543
+ queryPlan: plan,
7544
+ mapper: {
7545
+ "request.resource.attr.team": { field: "team" },
7546
+ },
7547
+ });
7548
+
7549
+ // #then impossible
7550
+ expect(result).toEqual({
7551
+ kind: PlanKind.CONDITIONAL,
7552
+ filters: {
7553
+ team: { in: [] },
7554
+ },
7555
+ });
7556
+ });
7557
+
7558
+ test("overlaps - equal length with field in middle, field must match", () => {
7559
+ // #given hierarchy("region:us:west", ":").overlaps(hierarchy(["region", "us", R.attr.zone]))
7560
+ // Left: ["region", "us", "west"], Right: ["region", "us", R.attr.zone]
7561
+ // Same length, prefix check: "region"=="region" ✓, "us"=="us" ✓, "west"==R.attr.zone
7562
+ const plan = createConditionalPlan({
7563
+ operator: "overlaps",
7564
+ operands: [
7565
+ {
7566
+ operator: "hierarchy",
7567
+ operands: [{ value: "region:us:west" }, { value: ":" }],
7568
+ },
7569
+ {
7570
+ operator: "hierarchy",
7571
+ operands: [
7572
+ {
7573
+ operator: "list",
7574
+ operands: [
7575
+ { value: "region" },
7576
+ { value: "us" },
7577
+ { name: "request.resource.attr.zone" },
7578
+ ],
7579
+ },
7580
+ ],
7581
+ },
7582
+ ],
7583
+ });
7584
+
7585
+ // #when
7586
+ const result = queryPlanToPrisma({
7587
+ queryPlan: plan,
7588
+ mapper: {
7589
+ "request.resource.attr.zone": { field: "zone" },
7590
+ },
7591
+ });
7592
+
7593
+ // #then
7594
+ expect(result).toEqual({
7595
+ kind: PlanKind.CONDITIONAL,
7596
+ filters: { zone: { equals: "west" } },
7597
+ });
7598
+ });
7599
+
7600
+ test("overlaps - single-field right hierarchy as prefix of left", () => {
7601
+ // #given hierarchy("team:alpha", ":").overlaps(hierarchy([R.attr.group]))
7602
+ // Left: ["team", "alpha"] (2), Right: [R.attr.group] (1)
7603
+ // Left prefix of right: len 2 > len 1 → impossible
7604
+ // Right prefix of left: R.attr.group == "team" (first segment)
7605
+ const plan = createConditionalPlan({
7606
+ operator: "overlaps",
7607
+ operands: [
7608
+ {
7609
+ operator: "hierarchy",
7610
+ operands: [{ value: "team:alpha" }, { value: ":" }],
7611
+ },
7612
+ {
7613
+ operator: "hierarchy",
7614
+ operands: [
7615
+ {
7616
+ operator: "list",
7617
+ operands: [{ name: "request.resource.attr.group" }],
7618
+ },
7619
+ ],
7620
+ },
7621
+ ],
7622
+ });
7623
+
7624
+ // #when
7625
+ const result = queryPlanToPrisma({
7626
+ queryPlan: plan,
7627
+ mapper: {
7628
+ "request.resource.attr.group": { field: "group" },
7629
+ },
7630
+ });
7631
+
7632
+ // #then
7633
+ expect(result).toEqual({
7634
+ kind: PlanKind.CONDITIONAL,
7635
+ filters: { group: { equals: "team" } },
7636
+ });
7637
+ });
7638
+
7639
+ test("overlaps - hierarchy(dotted string) without delimiter", () => {
7640
+ // #given hierarchy("a.b.c").overlaps(hierarchy(["a", "b", R.attr.zone]))
7641
+ // hierarchy("a.b.c") uses default "." delimiter → ["a", "b", "c"]
7642
+ const plan = createConditionalPlan({
7643
+ operator: "overlaps",
7644
+ operands: [
7645
+ {
7646
+ operator: "hierarchy",
7647
+ operands: [{ value: "a.b.c" }],
7648
+ },
7649
+ {
7650
+ operator: "hierarchy",
7651
+ operands: [
7652
+ {
7653
+ operator: "list",
7654
+ operands: [
7655
+ { value: "a" },
7656
+ { value: "b" },
7657
+ { name: "request.resource.attr.zone" },
7658
+ ],
7659
+ },
7660
+ ],
7661
+ },
7662
+ ],
7663
+ });
7664
+
7665
+ // #when
7666
+ const result = queryPlanToPrisma({
7667
+ queryPlan: plan,
7668
+ mapper: { "request.resource.attr.zone": { field: "zone" } },
7669
+ });
7670
+
7671
+ // #then
7672
+ expect(result).toEqual({
7673
+ kind: PlanKind.CONDITIONAL,
7674
+ filters: { zone: { equals: "c" } },
7675
+ });
7676
+ });
7677
+
7678
+ test("overlaps via cerbos integration", async () => {
7679
+ const queryPlan = await cerbos.planResources({
7680
+ principal: {
7681
+ id: "user1",
7682
+ roles: ["USER"],
7683
+ attr: { context: "projects:123" },
7684
+ },
7685
+ resource: { kind: "resource" },
7686
+ action: "hierarchy-overlaps",
7687
+ });
7688
+
7689
+ expect(queryPlan.kind).toBe(PlanKind.CONDITIONAL);
7690
+
7691
+ const result = queryPlanToPrisma({
7692
+ queryPlan,
7693
+ mapper: {
7694
+ "request.resource.id": { field: "id" },
7695
+ },
7696
+ });
7697
+
7698
+ // hierarchy("projects:123", ":") segments = ["projects", "123"]
7699
+ // hierarchy(["projects", R.id]) segments = ["projects", R.id]
7700
+ // Same length, segments[1]: "123" == R.id → R.id == "123"
7701
+ expect(result).toEqual({
7702
+ kind: PlanKind.CONDITIONAL,
7703
+ filters: { id: { equals: "123" } },
7704
+ });
7705
+ });
7706
+ });
7707
+
7708
+ const hierarchyMapper = {
7709
+ "request.resource.attr.scope": { field: "scope" },
7710
+ "request.resource.attr.path": { field: "path" },
7711
+ };
7712
+
7713
+ // ancestorOf = first hierarchy is a strict prefix of second
7714
+ // hierarchy("a.b").ancestorOf(hierarchy("a.b.c.d")) == true
7715
+ describe("Hierarchy ancestorOf", () => {
7716
+ test("ancestorOf - constant is ancestor, field is descendant", () => {
7717
+ // #given hierarchy("a.b.c").ancestorOf(hierarchy(R.attr.scope))
7718
+ const plan = createConditionalPlan({
7719
+ operator: "ancestorOf",
7720
+ operands: [
7721
+ {
7722
+ operator: "hierarchy",
7723
+ operands: [{ value: "a.b.c" }],
7724
+ },
7725
+ {
7726
+ operator: "hierarchy",
7727
+ operands: [{ name: "request.resource.attr.scope" }],
7728
+ },
7729
+ ],
7730
+ });
7731
+
7732
+ // #when
7733
+ const result = queryPlanToPrisma({ queryPlan: plan, mapper: hierarchyMapper });
7734
+
7735
+ // #then - R.attr.scope must start with "a.b.c." (ancestor means strict prefix)
7736
+ expect(result).toEqual({
7737
+ kind: PlanKind.CONDITIONAL,
7738
+ filters: { scope: { startsWith: "a.b.c." } },
7739
+ });
7740
+ });
7741
+
7742
+ test("ancestorOf - field is ancestor of constant", () => {
7743
+ // #given hierarchy(R.attr.scope).ancestorOf(hierarchy("a.b.c.d"))
7744
+ // R.attr.scope must be a prefix of "a.b.c.d"
7745
+ // Possible prefixes: "a", "a.b", "a.b.c"
7746
+ const plan = createConditionalPlan({
7747
+ operator: "ancestorOf",
7748
+ operands: [
7749
+ {
7750
+ operator: "hierarchy",
7751
+ operands: [{ name: "request.resource.attr.scope" }],
7752
+ },
7753
+ {
7754
+ operator: "hierarchy",
7755
+ operands: [{ value: "a.b.c.d" }],
7756
+ },
7757
+ ],
7758
+ });
7759
+
7760
+ // #when
7761
+ const result = queryPlanToPrisma({ queryPlan: plan, mapper: hierarchyMapper });
7762
+
7763
+ // #then - scope must be one of the strict prefixes of "a.b.c.d"
7764
+ expect(result).toEqual({
7765
+ kind: PlanKind.CONDITIONAL,
7766
+ filters: { scope: { in: ["a", "a.b", "a.b.c"] } },
7767
+ });
7768
+ });
7769
+
7770
+ test("ancestorOf - single segment constant ancestor", () => {
7771
+ // #given hierarchy("root").ancestorOf(hierarchy(R.attr.scope))
7772
+ const plan = createConditionalPlan({
7773
+ operator: "ancestorOf",
7774
+ operands: [
7775
+ {
7776
+ operator: "hierarchy",
7777
+ operands: [{ value: "root" }],
7778
+ },
7779
+ {
7780
+ operator: "hierarchy",
7781
+ operands: [{ name: "request.resource.attr.scope" }],
7782
+ },
7783
+ ],
7784
+ });
7785
+
7786
+ // #when
7787
+ const result = queryPlanToPrisma({ queryPlan: plan, mapper: hierarchyMapper });
7788
+
7789
+ // #then
7790
+ expect(result).toEqual({
7791
+ kind: PlanKind.CONDITIONAL,
7792
+ filters: { scope: { startsWith: "root." } },
7793
+ });
7794
+ });
7795
+
7796
+ test("ancestorOf - with custom delimiter", () => {
7797
+ // #given hierarchy(P.attr.scope, ":").ancestorOf(hierarchy(R.attr.path, ":"))
7798
+ // P.attr.scope is resolved at plan time → constant "org:team"
7799
+ const plan = createConditionalPlan({
7800
+ operator: "ancestorOf",
7801
+ operands: [
7802
+ {
7803
+ operator: "hierarchy",
7804
+ operands: [{ value: "org:team" }, { value: ":" }],
7805
+ },
7806
+ {
7807
+ operator: "hierarchy",
7808
+ operands: [{ name: "request.resource.attr.path" }, { value: ":" }],
7809
+ },
7810
+ ],
7811
+ });
7812
+
7813
+ // #when
7814
+ const result = queryPlanToPrisma({ queryPlan: plan, mapper: hierarchyMapper });
7815
+
7816
+ // #then - R.attr.path must start with "org:team:" (using the ":" delimiter)
7817
+ expect(result).toEqual({
7818
+ kind: PlanKind.CONDITIONAL,
7819
+ filters: { path: { startsWith: "org:team:" } },
7820
+ });
7821
+ });
7822
+
7823
+ test("ancestorOf via cerbos integration", async () => {
7824
+ const plan = await cerbos.planResources({
7825
+ principal: {
7826
+ id: "user1",
7827
+ roles: ["USER"],
7828
+ attr: { scope: "a.b.c" },
7829
+ },
7830
+ resource: { kind: "resource" },
7831
+ action: "hierarchy-ancestor-of",
7832
+ });
7833
+
7834
+ const result = queryPlanToPrisma({
7835
+ queryPlan: plan,
7836
+ mapper: {
7837
+ "request.resource.attr.scope": { field: "scope" },
7838
+ },
7839
+ });
7840
+
7841
+ // hierarchy("a.b.c").ancestorOf(hierarchy(R.attr.scope))
7842
+ // "a.b.c" is ancestor of R.attr.scope → R.attr.scope starts with "a.b.c."
7843
+ expect(result).toEqual({
7844
+ kind: PlanKind.CONDITIONAL,
7845
+ filters: { scope: { startsWith: "a.b.c." } },
7846
+ });
7847
+ });
7848
+ });
7849
+
7850
+ // descendentOf = mirror of ancestorOf
7851
+ // hierarchy("a.b.c.d").descendentOf(hierarchy("a.b")) == true
7852
+ describe("Hierarchy descendentOf", () => {
7853
+ test("descendentOf - field is descendant of constant", () => {
7854
+ // #given hierarchy(R.attr.scope).descendentOf(hierarchy("a.b"))
7855
+ // R.attr.scope must be a descendant of "a.b" → "a.b" is a prefix of R.attr.scope
7856
+ const plan = createConditionalPlan({
7857
+ operator: "descendentOf",
7858
+ operands: [
7859
+ {
7860
+ operator: "hierarchy",
7861
+ operands: [{ name: "request.resource.attr.scope" }],
7862
+ },
7863
+ {
7864
+ operator: "hierarchy",
7865
+ operands: [{ value: "a.b" }],
7866
+ },
7867
+ ],
7868
+ });
7869
+
7870
+ // #when
7871
+ const result = queryPlanToPrisma({ queryPlan: plan, mapper: hierarchyMapper });
7872
+
7873
+ // #then - scope must start with "a.b." (descendant = strict, must be longer)
7874
+ expect(result).toEqual({
7875
+ kind: PlanKind.CONDITIONAL,
7876
+ filters: { scope: { startsWith: "a.b." } },
7877
+ });
7878
+ });
7879
+
7880
+ test("descendentOf - constant is descendant of field", () => {
7881
+ // #given hierarchy("a.b.c.d").descendentOf(hierarchy(R.attr.scope))
7882
+ // "a.b.c.d" descends from R.attr.scope → R.attr.scope is a prefix of "a.b.c.d"
7883
+ const plan = createConditionalPlan({
7884
+ operator: "descendentOf",
7885
+ operands: [
7886
+ {
7887
+ operator: "hierarchy",
7888
+ operands: [{ value: "a.b.c.d" }],
7889
+ },
7890
+ {
7891
+ operator: "hierarchy",
7892
+ operands: [{ name: "request.resource.attr.scope" }],
7893
+ },
7894
+ ],
7895
+ });
7896
+
7897
+ // #when
7898
+ const result = queryPlanToPrisma({ queryPlan: plan, mapper: hierarchyMapper });
7899
+
7900
+ // #then - scope must be one of the strict prefixes of "a.b.c.d"
7901
+ expect(result).toEqual({
7902
+ kind: PlanKind.CONDITIONAL,
7903
+ filters: { scope: { in: ["a", "a.b", "a.b.c"] } },
7904
+ });
7905
+ });
7906
+
7907
+ test("descendentOf via cerbos integration", async () => {
7908
+ const plan = await cerbos.planResources({
7909
+ principal: {
7910
+ id: "user1",
7911
+ roles: ["USER"],
7912
+ attr: { scope: "a.b.c" },
7913
+ },
7914
+ resource: { kind: "resource" },
7915
+ action: "hierarchy-descendent-of",
7916
+ });
7917
+
7918
+ const result = queryPlanToPrisma({
7919
+ queryPlan: plan,
7920
+ mapper: {
7921
+ "request.resource.attr.scope": { field: "scope" },
7922
+ },
7923
+ });
7924
+
7925
+ // hierarchy(R.attr.scope).descendentOf(hierarchy("a.b.c"))
7926
+ // R.attr.scope descends from "a.b.c" → R.attr.scope starts with "a.b.c."
7927
+ expect(result).toEqual({
7928
+ kind: PlanKind.CONDITIONAL,
7929
+ filters: { scope: { startsWith: "a.b.c." } },
7930
+ });
7931
+ });
7932
+ });
7933
+
7934
+ // Every hierarchy relationship that narrows a column with a prefix emits a Prisma
7935
+ // `startsWith`, which compiles to `LIKE` with no `ESCAPE` clause: `%` and `_` in the
7936
+ // constant hierarchy would match as wildcards and admit rows the PDP denies. `[` is unsafe
7937
+ // even where an `ESCAPE` clause exists, because SQL Server opens a character class on it.
7938
+ // Both prefix-emitting paths (ancestorOf/descendentOf and overlaps) must throw.
7939
+ describe("Hierarchy prefix LIKE metacharacters", () => {
7940
+ const likeMetacharacterError =
7941
+ "Cannot translate hierarchy prefix matching with LIKE metacharacters (%, _ or [): " +
7942
+ "Prisma emits LIKE without an ESCAPE clause, and [ opens a character class on SQL Server even with one";
7943
+
7944
+ const metacharacterHierarchies: [string, string][] = [
7945
+ ["percent", "50%:a"],
7946
+ ["underscore", "50_:a"],
7947
+ ["bracket", "[env]:prod"],
7948
+ ];
7949
+
7950
+ function hierarchyOf(
7951
+ operand: PlanExpressionOperand,
7952
+ delimiter?: string
7953
+ ): PlanExpressionOperand {
7954
+ return {
7955
+ operator: "hierarchy",
7956
+ operands: delimiter === undefined ? [operand] : [operand, { value: delimiter }],
7957
+ };
7958
+ }
7959
+
7960
+ const scopeHierarchy = (delimiter?: string): PlanExpressionOperand =>
7961
+ hierarchyOf({ name: "request.resource.attr.scope" }, delimiter);
7962
+ const constantHierarchy = (
7963
+ raw: string,
7964
+ delimiter?: string
7965
+ ): PlanExpressionOperand => hierarchyOf({ value: raw }, delimiter);
7966
+
7967
+ const translate = (condition: PlanExpressionOperand): QueryPlanToPrismaResult =>
7968
+ queryPlanToPrisma({
7969
+ queryPlan: createConditionalPlan(condition),
7970
+ mapper: hierarchyMapper,
7971
+ });
7972
+
7973
+ describe.each(metacharacterHierarchies)(
7974
+ "constant hierarchy containing a %s",
7975
+ (_name, raw) => {
7976
+ test("overlaps - field-first throws", () => {
7977
+ expect(() =>
7978
+ translate({
7979
+ operator: "overlaps",
7980
+ operands: [scopeHierarchy(":"), constantHierarchy(raw, ":")],
7981
+ })
7982
+ ).toThrow(likeMetacharacterError);
7983
+ });
7984
+
7985
+ test("overlaps - constant-first throws", () => {
7986
+ expect(() =>
7987
+ translate({
7988
+ operator: "overlaps",
7989
+ operands: [constantHierarchy(raw, ":"), scopeHierarchy(":")],
7990
+ })
7991
+ ).toThrow(likeMetacharacterError);
7992
+ });
7993
+
7994
+ test("ancestorOf - constant ancestor of field descendant throws", () => {
7995
+ expect(() =>
7996
+ translate({
7997
+ operator: "ancestorOf",
7998
+ operands: [constantHierarchy(raw, ":"), scopeHierarchy(":")],
7999
+ })
8000
+ ).toThrow(likeMetacharacterError);
8001
+ });
8002
+
8003
+ test("descendentOf - field descendant of constant ancestor throws", () => {
8004
+ expect(() =>
8005
+ translate({
8006
+ operator: "descendentOf",
8007
+ operands: [scopeHierarchy(":"), constantHierarchy(raw, ":")],
8008
+ })
8009
+ ).toThrow(likeMetacharacterError);
8010
+ });
8011
+ }
8012
+ );
8013
+
8014
+ // The guard must not widen into a blanket rejection of hierarchy prefixes: pin the exact
8015
+ // filter emitted for a metacharacter-free hierarchy.
8016
+ describe("metacharacter-free hierarchy still translates", () => {
8017
+ const overlapsFilter = {
8018
+ OR: [
8019
+ { scope: { in: ["dept"] } },
8020
+ { scope: { equals: "dept.eng" } },
8021
+ { scope: { startsWith: "dept.eng." } },
8022
+ ],
8023
+ };
8024
+
8025
+ test("overlaps - field-first", () => {
8026
+ expect(
8027
+ translate({
8028
+ operator: "overlaps",
8029
+ operands: [scopeHierarchy(), constantHierarchy("dept.eng")],
8030
+ })
8031
+ ).toEqual({ kind: PlanKind.CONDITIONAL, filters: overlapsFilter });
8032
+ });
8033
+
8034
+ test("overlaps - constant-first", () => {
8035
+ expect(
8036
+ translate({
8037
+ operator: "overlaps",
8038
+ operands: [constantHierarchy("dept.eng"), scopeHierarchy()],
8039
+ })
8040
+ ).toEqual({ kind: PlanKind.CONDITIONAL, filters: overlapsFilter });
8041
+ });
8042
+
8043
+ test("ancestorOf - constant ancestor of field descendant", () => {
8044
+ expect(
8045
+ translate({
8046
+ operator: "ancestorOf",
8047
+ operands: [constantHierarchy("dept.eng"), scopeHierarchy()],
8048
+ })
8049
+ ).toEqual({
8050
+ kind: PlanKind.CONDITIONAL,
8051
+ filters: { scope: { startsWith: "dept.eng." } },
8052
+ });
8053
+ });
8054
+
8055
+ test("descendentOf - field descendant of constant ancestor", () => {
8056
+ expect(
8057
+ translate({
8058
+ operator: "descendentOf",
8059
+ operands: [scopeHierarchy(), constantHierarchy("dept.eng")],
8060
+ })
8061
+ ).toEqual({
8062
+ kind: PlanKind.CONDITIONAL,
8063
+ filters: { scope: { startsWith: "dept.eng." } },
8064
+ });
8065
+ });
8066
+ });
8067
+ });
8068
+
8069
+ // The planner preserves policy source order, so a constant can be the FIRST operand of a
8070
+ // comparison (`1 < R.attr.aNumber`). Directional operators must be mirrored when the value
8071
+ // precedes the field, or the filter is silently inverted. See #256.
8072
+ describe("Value-First Operand Order", () => {
8073
+ test("conditional - lt with value first (1 < aNumber means aNumber > 1)", async () => {
8074
+ const queryPlan = await cerbos.planResources({
8075
+ principal: { id: "user1", roles: ["USER"] },
8076
+ resource: { kind: "resource" },
8077
+ action: "value-first-lt",
8078
+ });
8079
+
8080
+ expect(queryPlan.kind).toEqual(PlanKind.CONDITIONAL);
8081
+ expect((queryPlan as PlanResourcesConditionalResponse).condition).toEqual({
8082
+ operator: "lt",
8083
+ operands: [{ value: 1 }, { name: "request.resource.attr.aNumber" }],
8084
+ });
8085
+
8086
+ const result = queryPlanToPrisma({
8087
+ queryPlan,
8088
+ mapper: {
8089
+ "request.resource.attr.aNumber": { field: "aNumber" },
8090
+ },
8091
+ });
8092
+
8093
+ expect(result).toStrictEqual({
8094
+ kind: PlanKind.CONDITIONAL,
8095
+ filters: { aNumber: { gt: 1 } },
8096
+ });
8097
+
8098
+ if (result.kind !== PlanKind.CONDITIONAL) {
8099
+ throw new Error("Expected CONDITIONAL result");
8100
+ }
8101
+
8102
+ const query = await prisma.resource.findMany({
8103
+ where: { ...result.filters },
8104
+ });
8105
+ expect(query.map((r) => r.id).sort()).toEqual(
8106
+ fixtureResources
8107
+ .filter((r) => r.aNumber > 1)
8108
+ .map((r) => r.id)
8109
+ .sort()
8110
+ );
8111
+ });
8112
+
8113
+ test("conditional - size with value first (0 < size(ownedBy) means non-empty)", async () => {
8114
+ const queryPlan = await cerbos.planResources({
8115
+ principal: { id: "user1", roles: ["USER"] },
8116
+ resource: { kind: "resource" },
8117
+ action: "value-first-size",
8118
+ });
8119
+
8120
+ expect(queryPlan.kind).toEqual(PlanKind.CONDITIONAL);
8121
+
8122
+ const result = queryPlanToPrisma({
8123
+ queryPlan,
8124
+ mapper: {
8125
+ "request.resource.attr.ownedBy": {
8126
+ relation: { name: "ownedBy", type: "many", field: "id" },
8127
+ },
8128
+ },
8129
+ });
8130
+
8131
+ expect(result).toStrictEqual({
8132
+ kind: PlanKind.CONDITIONAL,
8133
+ filters: { ownedBy: { some: {} } },
8134
+ });
8135
+
8136
+ if (result.kind !== PlanKind.CONDITIONAL) {
8137
+ throw new Error("Expected CONDITIONAL result");
8138
+ }
8139
+
8140
+ const query = await prisma.resource.findMany({
8141
+ where: { ...result.filters },
8142
+ });
8143
+ expect(query.map((r) => r.id).sort()).toEqual(
8144
+ fixtureResources
8145
+ .filter(
8146
+ (r) =>
8147
+ Array.isArray(r.ownedBy?.connect) &&
8148
+ (r.ownedBy.connect as { id: string }[]).length > 0
8149
+ )
8150
+ .map((r) => r.id)
8151
+ .sort()
8152
+ );
8153
+ });
8154
+
8155
+ test("conditional - hasIntersection with value first (constant list is operand 0)", async () => {
8156
+ const queryPlan = await cerbos.planResources({
8157
+ principal: { id: "user1", roles: ["USER"] },
8158
+ resource: { kind: "resource" },
8159
+ action: "value-first-intersect",
8160
+ });
8161
+
8162
+ expect(queryPlan.kind).toEqual(PlanKind.CONDITIONAL);
8163
+ expect((queryPlan as PlanResourcesConditionalResponse).condition).toEqual({
8164
+ operator: "hasIntersection",
8165
+ operands: [
8166
+ { value: ["user1", "userX"] },
8167
+ { name: "request.resource.attr.ownedBy" },
8168
+ ],
8169
+ });
8170
+
8171
+ const result = queryPlanToPrisma({
8172
+ queryPlan,
8173
+ mapper: {
8174
+ "request.resource.attr.ownedBy": {
8175
+ relation: { name: "ownedBy", type: "many", field: "id" },
8176
+ },
8177
+ },
8178
+ });
8179
+
8180
+ expect(result).toStrictEqual({
8181
+ kind: PlanKind.CONDITIONAL,
8182
+ filters: {
8183
+ ownedBy: { some: { id: { in: ["user1", "userX"] } } },
8184
+ },
8185
+ });
8186
+
8187
+ if (result.kind !== PlanKind.CONDITIONAL) {
8188
+ throw new Error("Expected CONDITIONAL result");
8189
+ }
8190
+
8191
+ const query = await prisma.resource.findMany({
8192
+ where: { ...result.filters },
8193
+ });
8194
+ expect(query.map((r) => r.id).sort()).toEqual(
8195
+ fixtureResources
8196
+ .filter(
8197
+ (r) =>
8198
+ Array.isArray(r.ownedBy?.connect) &&
8199
+ (r.ownedBy.connect as { id: string }[]).some((o) =>
8200
+ ["user1", "userX"].includes(o.id)
8201
+ )
8202
+ )
8203
+ .map((r) => r.id)
8204
+ .sort()
8205
+ );
8206
+ });
8207
+ });
8208
+
8209
+ describe("Known-Value Collections (planner unroll cliff)", () => {
8210
+ // The planner unrolls exists/all over a known collection (e.g. a folded
8211
+ // principal attribute) into an or/and chain at <= 10 elements
8212
+ // (cerbos/cerbos#2570, #2817) and emits the lambda with a literal value-list
8213
+ // collection above that. These tests straddle the 10-item cliff so both wire
8214
+ // shapes stay exercised regardless of the PDP version behind the sidecar.
8215
+ describe("live plans across the 10-item threshold", () => {
8216
+ const buildTeams = (size: number): string[] => {
8217
+ const teams = ["string", "string3"];
8218
+ while (teams.length < size) {
8219
+ teams.push(`filler-${teams.length}`);
8220
+ }
8221
+ return teams;
8222
+ };
8223
+
8224
+ // Supported PDPs are >= 0.54, where both macros unroll at <= 10 elements and
8225
+ // ship the value-list lambda above that. Pin the wire shape so each leg
8226
+ // provably exercises its side of the cliff — if a future planner moves the
8227
+ // threshold, this fails loudly instead of silently testing one shape only.
8228
+ const expectShape = (
8229
+ queryPlan: PlanResourcesResponse,
8230
+ size: number,
8231
+ unrolledOperator: string,
8232
+ macroOperator: string
8233
+ ): void => {
8234
+ const condition = (queryPlan as PlanResourcesConditionalResponse)
8235
+ .condition;
8236
+ expect((condition as PlanExpression).operator).toEqual(
8237
+ size <= 10 ? unrolledOperator : macroOperator
8238
+ );
8239
+ };
8240
+
8241
+ describe.each([9, 10, 11])("with %i-element principal collection", (size) => {
8242
+ test("conditional - principal-exists", async () => {
8243
+ const teams = buildTeams(size);
8244
+ const queryPlan = await cerbos.planResources({
8245
+ principal: { id: "user1", roles: ["USER"], attr: { teams } },
8246
+ resource: { kind: "resource" },
8247
+ action: "principal-exists",
8248
+ });
8249
+
8250
+ expect(queryPlan.kind).toEqual(PlanKind.CONDITIONAL);
8251
+ expectShape(queryPlan, size, "or", "exists");
8252
+
8253
+ const result = queryPlanToPrisma({
8254
+ queryPlan,
8255
+ mapper: {
8256
+ "request.resource.attr.aString": { field: "aString" },
8257
+ },
8258
+ });
8259
+
8260
+ if (result.kind !== PlanKind.CONDITIONAL) {
8261
+ throw new Error("Expected CONDITIONAL result");
8262
+ }
8263
+
8264
+ const query = await prisma.resource.findMany({
8265
+ where: { ...result.filters },
8266
+ });
8267
+ expect(query.map((r) => r.id).sort()).toEqual(
8268
+ fixtureResources
8269
+ .filter((r) => teams.includes(r.aString))
8270
+ .map((r) => r.id)
8271
+ .sort()
8272
+ );
8273
+ });
8274
+
8275
+ test("conditional - principal-all", async () => {
8276
+ const teams = buildTeams(size);
8277
+ const queryPlan = await cerbos.planResources({
8278
+ principal: { id: "user1", roles: ["USER"], attr: { teams } },
8279
+ resource: { kind: "resource" },
8280
+ action: "principal-all",
8281
+ });
8282
+
8283
+ expect(queryPlan.kind).toEqual(PlanKind.CONDITIONAL);
8284
+ expectShape(queryPlan, size, "and", "all");
8285
+
8286
+ const result = queryPlanToPrisma({
8287
+ queryPlan,
8288
+ mapper: {
8289
+ "request.resource.attr.aString": { field: "aString" },
8290
+ },
8291
+ });
8292
+
8293
+ if (result.kind !== PlanKind.CONDITIONAL) {
8294
+ throw new Error("Expected CONDITIONAL result");
8295
+ }
8296
+
8297
+ const query = await prisma.resource.findMany({
8298
+ where: { ...result.filters },
8299
+ });
8300
+ expect(query.map((r) => r.id).sort()).toEqual(
8301
+ fixtureResources
8302
+ .filter((r) => !teams.includes(r.aString))
8303
+ .map((r) => r.id)
8304
+ .sort()
8305
+ );
8306
+ });
8307
+ });
8308
+ });
8309
+
8310
+ describe("value-list lambda fold", () => {
8311
+ const valueListPlan = (
8312
+ operator: string,
8313
+ elements: unknown[],
8314
+ body: PlanExpressionOperand,
8315
+ variable = "t"
8316
+ ): PlanResourcesConditionalResponse =>
8317
+ createConditionalPlan({
8318
+ operator,
8319
+ operands: [
8320
+ { value: elements },
8321
+ { operator: "lambda", operands: [body, { name: variable }] },
8322
+ ],
8323
+ } as PlanExpressionOperand);
8324
+
8325
+ test("exists over a value list folds to OR of the substituted body", () => {
8326
+ const result = queryPlanToPrisma({
8327
+ queryPlan: valueListPlan("exists", ["a", "b", "c"], {
8328
+ operator: "eq",
8329
+ operands: [{ name: "request.resource.attr.aString" }, { name: "t" }],
8330
+ }),
8331
+ mapper: { "request.resource.attr.aString": { field: "aString" } },
8332
+ });
8333
+
8334
+ expect(result).toStrictEqual({
8335
+ kind: PlanKind.CONDITIONAL,
8336
+ filters: {
8337
+ OR: [
8338
+ { aString: { equals: "a" } },
8339
+ { aString: { equals: "b" } },
8340
+ { aString: { equals: "c" } },
8341
+ ],
8342
+ },
8343
+ });
8344
+ });
8345
+
8346
+ test("all over a value list folds to AND of the substituted body", () => {
8347
+ const result = queryPlanToPrisma({
8348
+ queryPlan: valueListPlan("all", ["a", "b"], {
8349
+ operator: "ne",
8350
+ operands: [{ name: "request.resource.attr.aString" }, { name: "t" }],
8351
+ }),
8352
+ mapper: { "request.resource.attr.aString": { field: "aString" } },
8353
+ });
8354
+
8355
+ expect(result).toStrictEqual({
8356
+ kind: PlanKind.CONDITIONAL,
8357
+ filters: {
8358
+ AND: [{ aString: { not: "a" } }, { aString: { not: "b" } }],
8359
+ },
8360
+ });
8361
+ });
8362
+
8363
+ test("substitutes variable path references into element fields", () => {
8364
+ const result = queryPlanToPrisma({
8365
+ queryPlan: valueListPlan(
8366
+ "exists",
8367
+ [{ name: "alpha", meta: { rank: 1 } }, { name: "beta", meta: { rank: 2 } }],
8368
+ {
8369
+ operator: "eq",
8370
+ operands: [{ name: "request.resource.attr.aString" }, { name: "t.name" }],
8371
+ }
8372
+ ),
8373
+ mapper: { "request.resource.attr.aString": { field: "aString" } },
8374
+ });
8375
+
8376
+ expect(result).toStrictEqual({
8377
+ kind: PlanKind.CONDITIONAL,
8378
+ filters: {
8379
+ OR: [{ aString: { equals: "alpha" } }, { aString: { equals: "beta" } }],
8380
+ },
8381
+ });
8382
+ });
8383
+
8384
+ test("empty value list yields CEL identity semantics", () => {
8385
+ const body: PlanExpressionOperand = {
8386
+ operator: "eq",
8387
+ operands: [{ name: "request.resource.attr.aString" }, { name: "t" }],
8388
+ };
8389
+ const mapper = { "request.resource.attr.aString": { field: "aString" } };
8390
+
8391
+ // exists over [] is false — Prisma matches nothing for OR: []
8392
+ expect(
8393
+ queryPlanToPrisma({ queryPlan: valueListPlan("exists", [], body), mapper })
8394
+ ).toStrictEqual({ kind: PlanKind.CONDITIONAL, filters: { OR: [] } });
8395
+
8396
+ // all over [] is true — Prisma matches everything for AND: []
8397
+ expect(
8398
+ queryPlanToPrisma({ queryPlan: valueListPlan("all", [], body), mapper })
8399
+ ).toStrictEqual({ kind: PlanKind.CONDITIONAL, filters: { AND: [] } });
8400
+ });
8401
+
8402
+ test("nested lambda rebinding the variable shadows the outer substitution", () => {
8403
+ // Outer t is substituted; the inner exists rebinds t over a relation, so
8404
+ // its body must keep referencing the inner variable untouched.
8405
+ const result = queryPlanToPrisma({
8406
+ queryPlan: valueListPlan("exists", ["public", "private"], {
8407
+ operator: "exists",
8408
+ operands: [
8409
+ { name: "request.resource.attr.tags" },
8410
+ {
8411
+ operator: "lambda",
8412
+ operands: [
8413
+ {
8414
+ operator: "eq",
8415
+ operands: [{ name: "t.name" }, { value: "fixed" }],
8416
+ },
8417
+ { name: "t" },
8418
+ ],
8419
+ },
8420
+ ],
8421
+ }),
8422
+ mapper: {
8423
+ "request.resource.attr.tags": {
8424
+ relation: { name: "tags", type: "many" },
8425
+ },
8426
+ },
8427
+ });
8428
+
8429
+ expect(result).toStrictEqual({
8430
+ kind: PlanKind.CONDITIONAL,
8431
+ filters: {
8432
+ OR: [
8433
+ { tags: { some: { name: { equals: "fixed" } } } },
8434
+ { tags: { some: { name: { equals: "fixed" } } } },
8435
+ ],
8436
+ },
8437
+ });
8438
+ });
8439
+
8440
+ test("throws when a variable path is missing on an element", () => {
8441
+ expect(() =>
8442
+ queryPlanToPrisma({
8443
+ queryPlan: valueListPlan("exists", [{ name: "alpha" }], {
8444
+ operator: "eq",
8445
+ operands: [
8446
+ { name: "request.resource.attr.aString" },
8447
+ { name: "t.missing" },
8448
+ ],
8449
+ }),
8450
+ mapper: { "request.resource.attr.aString": { field: "aString" } },
8451
+ })
8452
+ ).toThrow('Cannot resolve "t.missing"');
8453
+ });
8454
+
8455
+ test("throws for exists_one over a value list", () => {
8456
+ expect(() =>
8457
+ queryPlanToPrisma({
8458
+ queryPlan: valueListPlan("exists_one", ["a"], {
8459
+ operator: "eq",
8460
+ operands: [{ name: "request.resource.attr.aString" }, { name: "t" }],
8461
+ }),
8462
+ mapper: { "request.resource.attr.aString": { field: "aString" } },
8463
+ })
8464
+ ).toThrow("exists_one over a literal collection value is not supported");
8465
+ });
8466
+
8467
+ test("throws for a non-list collection value", () => {
8468
+ expect(() =>
8469
+ queryPlanToPrisma({
8470
+ queryPlan: valueListPlan("exists", ["ignored"], {
8471
+ operator: "eq",
8472
+ operands: [{ name: "request.resource.attr.aString" }, { name: "t" }],
8473
+ }),
8474
+ mapper: { "request.resource.attr.aString": { field: "aString" } },
8475
+ })
8476
+ ).not.toThrow();
8477
+
8478
+ const plan = createConditionalPlan({
8479
+ operator: "exists",
8480
+ operands: [
8481
+ { value: { not: "a list" } },
8482
+ {
8483
+ operator: "lambda",
8484
+ operands: [
8485
+ {
8486
+ operator: "eq",
8487
+ operands: [
8488
+ { name: "request.resource.attr.aString" },
8489
+ { name: "t" },
8490
+ ],
8491
+ },
8492
+ { name: "t" },
8493
+ ],
8494
+ },
8495
+ ],
8496
+ } as PlanExpressionOperand);
8497
+ expect(() =>
8498
+ queryPlanToPrisma({
8499
+ queryPlan: plan,
8500
+ mapper: { "request.resource.attr.aString": { field: "aString" } },
8501
+ })
8502
+ ).toThrow("exists over a literal collection requires a list value");
8503
+ });
8504
+ });
8505
+ });
8506
+
8507
+ describe("Promoted adversarial planner shapes", () => {
8508
+ const valueListMacro = (
8509
+ operator: "exists" | "all",
8510
+ elements: string[]
8511
+ ): PlanExpressionOperand => ({
8512
+ operator,
8513
+ operands: [
8514
+ { value: elements },
8515
+ {
8516
+ operator: "lambda",
8517
+ operands: [
8518
+ {
8519
+ operator: "eq",
8520
+ operands: [
8521
+ { name: "request.resource.attr.aString" },
8522
+ { name: "team" },
8523
+ ],
8524
+ },
8525
+ { name: "team" },
8526
+ ],
8527
+ },
8528
+ ],
8529
+ });
8530
+
8531
+ test("negated exists over a literal collection folds to AND of negated bodies (#294)", () => {
8532
+ const result = queryPlanToPrisma({
8533
+ queryPlan: createConditionalPlan({
8534
+ operator: "not",
8535
+ operands: [valueListMacro("exists", ["a", "b"])],
8536
+ }),
8537
+ mapper: {
8538
+ "request.resource.attr.aString": { field: "aString" },
8539
+ },
8540
+ });
8541
+
8542
+ expect(result).toStrictEqual({
8543
+ kind: PlanKind.CONDITIONAL,
8544
+ filters: {
8545
+ AND: [
8546
+ { NOT: { aString: { equals: "a" } } },
8547
+ { NOT: { aString: { equals: "b" } } },
8548
+ ],
8549
+ },
8550
+ });
8551
+ });
8552
+
8553
+ test("negated all over a literal collection folds to OR of negated bodies (#294)", () => {
8554
+ const result = queryPlanToPrisma({
8555
+ queryPlan: createConditionalPlan({
8556
+ operator: "not",
8557
+ operands: [valueListMacro("all", ["a", "b"])],
8558
+ }),
8559
+ mapper: {
8560
+ "request.resource.attr.aString": { field: "aString" },
8561
+ },
8562
+ });
8563
+
8564
+ expect(result).toStrictEqual({
8565
+ kind: PlanKind.CONDITIONAL,
8566
+ filters: {
8567
+ OR: [
8568
+ { NOT: { aString: { equals: "a" } } },
8569
+ { NOT: { aString: { equals: "b" } } },
8570
+ ],
8571
+ },
8572
+ });
8573
+ });
8574
+
8575
+ test("constant NaN ordering in a ternary branch is always false", () => {
8576
+ const result = queryPlanToPrisma({
8577
+ queryPlan: createConditionalPlan({
8578
+ operator: "gt",
8579
+ operands: [
8580
+ {
8581
+ operator: "if",
8582
+ operands: [
8583
+ { name: "request.resource.attr.aBool" },
8584
+ { value: 1 },
8585
+ {
8586
+ operator: "div",
8587
+ operands: [{ value: 0 }, { value: 0 }],
8588
+ },
8589
+ ],
8590
+ },
8591
+ { value: 0.5 },
8592
+ ],
8593
+ }),
8594
+ mapper: { "request.resource.attr.aBool": { field: "aBool" } },
8595
+ });
8596
+
8597
+ expect(result).toStrictEqual({
8598
+ kind: PlanKind.CONDITIONAL,
8599
+ filters: {
8600
+ OR: [
8601
+ { aBool: { equals: true } },
8602
+ {
8603
+ AND: [
8604
+ { aBool: { equals: true } },
8605
+ { aBool: { equals: false } },
8606
+ ],
8607
+ },
8608
+ ],
8609
+ },
8610
+ });
8611
+ });
8612
+
8613
+ test("value-first timestamp comparison mirrors and normalizes the instant", () => {
8614
+ const result = queryPlanToPrisma({
8615
+ queryPlan: createConditionalPlan({
8616
+ operator: "gt",
8617
+ operands: [
8618
+ {
8619
+ operator: "timestamp",
8620
+ operands: [{ value: "2024-06-01T02:00:00+02:00" }],
8621
+ },
8622
+ {
8623
+ operator: "timestamp",
8624
+ operands: [{ name: "request.resource.attr.createdAt" }],
8625
+ },
8626
+ ],
8627
+ }),
8628
+ mapper: {
8629
+ "request.resource.attr.createdAt": {
8630
+ field: "createdAt",
8631
+ valueType: "dateTime",
8632
+ },
8633
+ },
8634
+ });
8635
+
8636
+ expect(result).toStrictEqual({
8637
+ kind: PlanKind.CONDITIONAL,
8638
+ filters: {
8639
+ createdAt: { lt: "2024-06-01T00:00:00.000Z" },
8640
+ },
8641
+ });
8642
+ });
8643
+
8644
+ test("timestamp over an untyped string mapping fails closed", () => {
8645
+ expect(() =>
8646
+ queryPlanToPrisma({
8647
+ queryPlan: createConditionalPlan({
8648
+ operator: "lt",
8649
+ operands: [
8650
+ {
8651
+ operator: "timestamp",
8652
+ operands: [{ name: "request.resource.attr.createdBy" }],
8653
+ },
8654
+ {
8655
+ operator: "timestamp",
8656
+ operands: [{ value: "2025-01-01T00:00:00Z" }],
8657
+ },
8658
+ ],
8659
+ }),
8660
+ mapper: {
8661
+ "request.resource.attr.createdBy": { field: "createdBy" },
8662
+ },
8663
+ })
8664
+ ).toThrow('must be mapped with valueType: "dateTime"');
8665
+ });
8666
+
8667
+ test.each([
8668
+ "2024-01-01",
8669
+ "0000-01-01T00:00:00Z",
8670
+ "2024-02-30T00:00:00Z",
8671
+ "2024-01-01T00:00:00.1234Z",
8672
+ "9999-12-31T23:00:00-02:00",
8673
+ ])("timestamp literal %s fails closed", (value) => {
8674
+ expect(() =>
8675
+ queryPlanToPrisma({
8676
+ queryPlan: createConditionalPlan({
8677
+ operator: "eq",
8678
+ operands: [
8679
+ {
8680
+ operator: "timestamp",
8681
+ operands: [{ name: "request.resource.attr.createdAt" }],
8682
+ },
8683
+ { operator: "timestamp", operands: [{ value }] },
8684
+ ],
8685
+ }),
8686
+ mapper: {
8687
+ "request.resource.attr.createdAt": {
8688
+ field: "createdAt",
8689
+ valueType: "dateTime",
8690
+ },
8691
+ },
8692
+ })
8693
+ ).toThrow(/RFC 3339|millisecond|instant range/);
8694
+ });
8695
+
8696
+ test("timestamp accepts excess fractional digits only when they are zero", () => {
8697
+ const result = queryPlanToPrisma({
8698
+ queryPlan: createConditionalPlan({
8699
+ operator: "eq",
8700
+ operands: [
8701
+ {
8702
+ operator: "timestamp",
8703
+ operands: [{ name: "request.resource.attr.createdAt" }],
8704
+ },
8705
+ {
8706
+ operator: "timestamp",
8707
+ operands: [{ value: "2024-01-01T00:00:00.123000Z" }],
8708
+ },
8709
+ ],
8710
+ }),
8711
+ mapper: {
8712
+ "request.resource.attr.createdAt": {
8713
+ field: "createdAt",
8714
+ valueType: "dateTime",
8715
+ },
8716
+ },
8717
+ });
8718
+
8719
+ expect(result).toStrictEqual({
8720
+ kind: PlanKind.CONDITIONAL,
8721
+ filters: { createdAt: { equals: "2024-01-01T00:00:00.123Z" } },
8722
+ });
8723
+ });
8724
+
8725
+ test("membership list containing null becomes an explicit null disjunct", () => {
8726
+ const result = queryPlanToPrisma({
8727
+ queryPlan: createConditionalPlan({
8728
+ operator: "in",
8729
+ operands: [
8730
+ { name: "request.resource.attr.owner" },
8731
+ { value: ["x", "one_two", null] },
8732
+ ],
8733
+ }),
8734
+ mapper: {
8735
+ "request.resource.attr.owner": { field: "aOptionalString" },
8736
+ },
8737
+ });
8738
+
8739
+ expect(result).toStrictEqual({
8740
+ kind: PlanKind.CONDITIONAL,
8741
+ filters: {
8742
+ OR: [
8743
+ { aOptionalString: { in: ["x", "one_two"] } },
8744
+ { aOptionalString: null },
8745
+ ],
8746
+ },
8747
+ });
8748
+ });
8749
+
8750
+ test("fractional addition equality fails closed instead of algebraically solving", () => {
8751
+ expect(() =>
8752
+ queryPlanToPrisma({
8753
+ queryPlan: createConditionalPlan({
8754
+ operator: "eq",
8755
+ operands: [
8756
+ {
8757
+ operator: "add",
8758
+ operands: [
8759
+ { name: "request.resource.attr.aDouble" },
8760
+ { value: 0.7 },
8761
+ ],
8762
+ },
8763
+ { value: 0.1 },
8764
+ ],
8765
+ }),
8766
+ mapper: {
8767
+ "request.resource.attr.aDouble": { field: "aDouble" },
8768
+ },
8769
+ })
8770
+ ).toThrow(
8771
+ "solving IEEE-754 addition into a plain Prisma column comparison is not reversible"
8772
+ );
8773
+ });
8774
+
8775
+ test("hierarchy prefix containing LIKE metacharacters fails closed", () => {
8776
+ expect(() =>
8777
+ queryPlanToPrisma({
8778
+ queryPlan: createConditionalPlan({
8779
+ operator: "descendentOf",
8780
+ operands: [
8781
+ {
8782
+ operator: "hierarchy",
8783
+ operands: [
8784
+ { name: "request.resource.attr.scope" },
8785
+ { value: ":" },
8786
+ ],
8787
+ },
8788
+ {
8789
+ operator: "hierarchy",
8790
+ operands: [{ value: "50%:a_b" }, { value: ":" }],
8791
+ },
8792
+ ],
8793
+ }),
8794
+ mapper: { "request.resource.attr.scope": { field: "scope" } },
8795
+ })
8796
+ ).toThrow("Prisma emits LIKE without an ESCAPE clause");
8797
+ });
8798
+
8799
+ test("nested nullable collection errors propagate through negated exists", () => {
8800
+ const result = queryPlanToPrisma({
8801
+ queryPlan: createConditionalPlan({
8802
+ operator: "not",
8803
+ operands: [
8804
+ {
8805
+ operator: "exists",
8806
+ operands: [
8807
+ { name: "request.resource.attr.categories" },
8808
+ {
8809
+ operator: "lambda",
8810
+ operands: [
8811
+ {
8812
+ operator: "exists",
8813
+ operands: [
8814
+ { name: "category.subCategories" },
8815
+ {
8816
+ operator: "lambda",
8817
+ operands: [
8818
+ {
8819
+ operator: "exists",
8820
+ operands: [
8821
+ { name: "subCategory.labels" },
8822
+ {
8823
+ operator: "lambda",
8824
+ operands: [
8825
+ {
8826
+ operator: "eq",
8827
+ operands: [
8828
+ { name: "label.name" },
8829
+ { value: "gold" },
8830
+ ],
8831
+ },
8832
+ { name: "label" },
8833
+ ],
8834
+ },
8835
+ ],
8836
+ },
8837
+ { name: "subCategory" },
8838
+ ],
8839
+ },
8840
+ ],
8841
+ },
8842
+ { name: "category" },
8843
+ ],
8844
+ },
8845
+ ],
8846
+ },
8847
+ ],
8848
+ }),
8849
+ mapper: {
8850
+ "request.resource.attr.categories": {
8851
+ relation: {
8852
+ name: "categories",
8853
+ type: "many",
8854
+ fields: {
8855
+ subCategories: {
8856
+ relation: {
8857
+ name: "subCategories",
8858
+ type: "many",
8859
+ fields: {
8860
+ labels: {
8861
+ relation: {
8862
+ name: "labels",
8863
+ type: "many",
8864
+ fields: {
8865
+ name: { field: "name", nullable: true },
8866
+ },
8867
+ },
8868
+ },
8869
+ },
8870
+ },
8871
+ },
8872
+ },
8873
+ },
8874
+ },
8875
+ },
8876
+ });
8877
+
8878
+ expect(result.kind).toBe(PlanKind.CONDITIONAL);
8879
+ if (result.kind !== PlanKind.CONDITIONAL) {
8880
+ throw new Error("Expected CONDITIONAL result");
8881
+ }
8882
+ expect(JSON.stringify(result.filters)).toContain('"name":null');
8883
+ expect(JSON.stringify(result.filters)).toContain('"NOT":{"categories"');
8884
+ });
6322
8885
  });