@cerbos/orm-prisma 3.2.1 → 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 (45) hide show
  1. package/README.md +91 -3
  2. package/lib/index.d.ts +29 -2
  3. package/lib/index.d.ts.map +1 -1
  4. package/lib/index.js +1792 -150
  5. package/lib/index.js.map +1 -1
  6. package/package.json +24 -11
  7. package/src/adversarial.test.ts +586 -0
  8. package/src/generated/prisma/client.ts +3 -1
  9. package/src/generated/prisma/commonInputTypes.ts +26 -0
  10. package/src/generated/prisma/internal/class.ts +22 -10
  11. package/src/generated/prisma/internal/prismaNamespace.ts +89 -19
  12. package/src/generated/prisma/internal/prismaNamespaceBrowser.ts +2 -1
  13. package/src/generated/prisma/models/Category.ts +6 -1
  14. package/src/generated/prisma/models/Label.ts +6 -1
  15. package/src/generated/prisma/models/NestedResource.ts +6 -1
  16. package/src/generated/prisma/models/NextLevelNestedResource.ts +6 -1
  17. package/src/generated/prisma/models/Resource.ts +67 -2
  18. package/src/generated/prisma/models/SubCategory.ts +6 -1
  19. package/src/generated/prisma/models/Tag.ts +6 -1
  20. package/src/generated/prisma/models/User.ts +6 -1
  21. package/src/generated/prisma-adversarial/browser.ts +44 -0
  22. package/src/generated/prisma-adversarial/client.ts +66 -0
  23. package/src/generated/prisma-adversarial/commonInputTypes.ts +352 -0
  24. package/src/generated/prisma-adversarial/enums.ts +15 -0
  25. package/src/generated/prisma-adversarial/internal/class.ts +244 -0
  26. package/src/generated/prisma-adversarial/internal/prismaNamespace.ts +1156 -0
  27. package/src/generated/prisma-adversarial/internal/prismaNamespaceBrowser.ts +140 -0
  28. package/src/generated/prisma-adversarial/models/AdversarialCategory.ts +1401 -0
  29. package/src/generated/prisma-adversarial/models/AdversarialLabel.ts +1272 -0
  30. package/src/generated/prisma-adversarial/models/AdversarialResource.ts +1665 -0
  31. package/src/generated/prisma-adversarial/models/AdversarialSubCategory.ts +1401 -0
  32. package/src/generated/prisma-adversarial/models/AdversarialTag.ts +1307 -0
  33. package/src/generated/prisma-adversarial/models.ts +16 -0
  34. package/src/generated/prisma-adversarial-v6/client.d.ts +1 -0
  35. package/src/generated/prisma-adversarial-v6/default.d.ts +1 -0
  36. package/src/generated/prisma-adversarial-v6/edge.d.ts +1 -0
  37. package/src/generated/prisma-adversarial-v6/index.d.ts +8640 -0
  38. package/src/generated/prisma-adversarial-v6/runtime/index-browser.d.ts +370 -0
  39. package/src/generated/prisma-adversarial-v6/runtime/library.d.ts +3982 -0
  40. package/src/generated/prisma-adversarial-v6/wasm.d.ts +1 -0
  41. package/src/generated/prisma-v6/index.d.ts +90 -3
  42. package/src/index.test.ts +3611 -241
  43. package/src/index.ts +2544 -174
  44. package/src/test-setup.adversarial.v6.ts +5 -0
  45. 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,32 +2435,231 @@ 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
-
2293
- expect(queryPlan.kind).toEqual(PlanKind.CONDITIONAL);
2294
2438
 
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" },
2303
- ],
2304
- });
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" },
2661
+ ],
2662
+ });
2305
2663
 
2306
2664
  const result = queryPlanToPrisma({
2307
2665
  queryPlan,
@@ -4100,30 +4458,42 @@ describe("Complex Operations", () => {
4100
4458
  );
4101
4459
  });
4102
4460
 
4103
- test("conditional - relation multiple all", async () => {
4461
+ test("conditional - or - leaf and exists", async () => {
4104
4462
  const queryPlan = await cerbos.planResources({
4105
4463
  principal: { id: "user1", roles: ["USER"] },
4106
4464
  resource: { kind: "resource" },
4107
- action: "relation-multiple-all",
4465
+ action: "or-leaf-exists",
4108
4466
  });
4109
4467
 
4110
4468
  expect(queryPlan.kind).toEqual(PlanKind.CONDITIONAL);
4111
4469
  expect((queryPlan as PlanResourcesConditionalResponse).condition).toEqual(
4112
4470
  {
4113
- operator: "and",
4471
+ operator: "or",
4114
4472
  operands: [
4115
4473
  {
4116
- operator: "gt",
4474
+ operator: "eq",
4117
4475
  operands: [
4118
- { name: "request.resource.attr.nested.aNumber" },
4119
- { value: 1 },
4476
+ { name: "request.resource.attr.aBool" },
4477
+ { value: true },
4120
4478
  ],
4121
4479
  },
4122
4480
  {
4123
- operator: "lt",
4481
+ operator: "exists",
4124
4482
  operands: [
4125
- { name: "request.resource.attr.nested.aNumber" },
4126
- { value: 3 },
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
+ },
4127
4497
  ],
4128
4498
  },
4129
4499
  ],
@@ -4133,10 +4503,11 @@ describe("Complex Operations", () => {
4133
4503
  const result = queryPlanToPrisma({
4134
4504
  queryPlan,
4135
4505
  mapper: {
4136
- "request.resource.attr.nested": {
4506
+ "request.resource.attr.aBool": { field: "aBool" },
4507
+ "request.resource.attr.tags": {
4137
4508
  relation: {
4138
- name: "nested",
4139
- type: "one",
4509
+ name: "tags",
4510
+ type: "many",
4140
4511
  },
4141
4512
  },
4142
4513
  },
@@ -4145,21 +4516,9 @@ describe("Complex Operations", () => {
4145
4516
  expect(result).toStrictEqual({
4146
4517
  kind: PlanKind.CONDITIONAL,
4147
4518
  filters: {
4148
- AND: [
4149
- {
4150
- nested: {
4151
- is: {
4152
- aNumber: { gt: 1 },
4153
- },
4154
- },
4155
- },
4156
- {
4157
- nested: {
4158
- is: {
4159
- aNumber: { lt: 3 },
4160
- },
4161
- },
4162
- },
4519
+ OR: [
4520
+ { aBool: { equals: true } },
4521
+ { tags: { some: { name: { equals: "public" } } } },
4163
4522
  ],
4164
4523
  },
4165
4524
  });
@@ -4171,43 +4530,131 @@ describe("Complex Operations", () => {
4171
4530
  const query = await prisma.resource.findMany({
4172
4531
  where: { ...result.filters },
4173
4532
  });
4174
-
4175
- expect(query.map((r) => r.id)).toEqual(
4533
+ expect(query.map((r) => r.id).sort()).toEqual(
4176
4534
  fixtureResources
4177
4535
  .filter((r) => {
4178
- const nestedResource = fixtureNestedResources.find(
4179
- (n) => n.id === r.nested.connect?.id
4180
- );
4181
- return (
4182
- (nestedResource?.aNumber ?? 0) > 1 &&
4183
- (nestedResource?.aNumber ?? 0) < 3
4184
- );
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
+ });
4185
4544
  })
4186
4545
  .map((r) => r.id)
4546
+ .sort()
4187
4547
  );
4188
4548
  });
4189
4549
 
4190
- test("conditional - relation multiple or", async () => {
4550
+ test("conditional - relation multiple all", async () => {
4191
4551
  const queryPlan = await cerbos.planResources({
4192
4552
  principal: { id: "user1", roles: ["USER"] },
4193
4553
  resource: { kind: "resource" },
4194
- action: "relation-multiple-or",
4554
+ action: "relation-multiple-all",
4195
4555
  });
4196
4556
 
4197
4557
  expect(queryPlan.kind).toEqual(PlanKind.CONDITIONAL);
4198
4558
  expect((queryPlan as PlanResourcesConditionalResponse).condition).toEqual(
4199
4559
  {
4200
- operator: "or",
4560
+ operator: "and",
4201
4561
  operands: [
4202
4562
  {
4203
- operator: "eq",
4563
+ operator: "gt",
4204
4564
  operands: [
4205
- { name: "request.resource.attr.createdBy" },
4206
- { value: "user1" },
4565
+ { name: "request.resource.attr.nested.aNumber" },
4566
+ { value: 1 },
4207
4567
  ],
4208
4568
  },
4209
4569
  {
4210
- operator: "in",
4570
+ operator: "lt",
4571
+ operands: [
4572
+ { name: "request.resource.attr.nested.aNumber" },
4573
+ { value: 3 },
4574
+ ],
4575
+ },
4576
+ ],
4577
+ }
4578
+ );
4579
+
4580
+ const result = queryPlanToPrisma({
4581
+ queryPlan,
4582
+ mapper: {
4583
+ "request.resource.attr.nested": {
4584
+ relation: {
4585
+ name: "nested",
4586
+ type: "one",
4587
+ },
4588
+ },
4589
+ },
4590
+ });
4591
+
4592
+ expect(result).toStrictEqual({
4593
+ kind: PlanKind.CONDITIONAL,
4594
+ filters: {
4595
+ AND: [
4596
+ {
4597
+ nested: {
4598
+ is: {
4599
+ aNumber: { gt: 1 },
4600
+ },
4601
+ },
4602
+ },
4603
+ {
4604
+ nested: {
4605
+ is: {
4606
+ aNumber: { lt: 3 },
4607
+ },
4608
+ },
4609
+ },
4610
+ ],
4611
+ },
4612
+ });
4613
+
4614
+ if (result.kind !== PlanKind.CONDITIONAL) {
4615
+ throw new Error("Expected CONDITIONAL result");
4616
+ }
4617
+
4618
+ const query = await prisma.resource.findMany({
4619
+ where: { ...result.filters },
4620
+ });
4621
+
4622
+ expect(query.map((r) => r.id)).toEqual(
4623
+ fixtureResources
4624
+ .filter((r) => {
4625
+ const nestedResource = fixtureNestedResources.find(
4626
+ (n) => n.id === r.nested.connect?.id
4627
+ );
4628
+ return (
4629
+ (nestedResource?.aNumber ?? 0) > 1 &&
4630
+ (nestedResource?.aNumber ?? 0) < 3
4631
+ );
4632
+ })
4633
+ .map((r) => r.id)
4634
+ );
4635
+ });
4636
+
4637
+ test("conditional - relation multiple or", async () => {
4638
+ const queryPlan = await cerbos.planResources({
4639
+ principal: { id: "user1", roles: ["USER"] },
4640
+ resource: { kind: "resource" },
4641
+ action: "relation-multiple-or",
4642
+ });
4643
+
4644
+ expect(queryPlan.kind).toEqual(PlanKind.CONDITIONAL);
4645
+ expect((queryPlan as PlanResourcesConditionalResponse).condition).toEqual(
4646
+ {
4647
+ operator: "or",
4648
+ operands: [
4649
+ {
4650
+ operator: "eq",
4651
+ operands: [
4652
+ { name: "request.resource.attr.createdBy" },
4653
+ { value: "user1" },
4654
+ ],
4655
+ },
4656
+ {
4657
+ operator: "in",
4211
4658
  operands: [
4212
4659
  { value: "user1" },
4213
4660
  { name: "request.resource.attr.ownedBy" },
@@ -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,120 +4904,293 @@ 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
- },
5016
+ NOT: { aNumber: { gt: 1 } },
5017
+ },
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
+ );
5032
+ });
5033
+
5034
+ test("conditional - not-lt", async () => {
5035
+ const queryPlan = await cerbos.planResources({
5036
+ principal: { id: "user1", roles: ["USER"] },
5037
+ resource: { kind: "resource" },
5038
+ action: "not-lt",
5039
+ });
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
+ ],
4557
5052
  },
4558
- },
5053
+ ],
5054
+ }
5055
+ );
5056
+
5057
+ const result = queryPlanToPrisma({
5058
+ queryPlan,
5059
+ mapper: {
5060
+ "request.resource.attr.aNumber": { field: "aNumber" },
5061
+ },
5062
+ });
5063
+
5064
+ expect(result).toStrictEqual({
5065
+ kind: PlanKind.CONDITIONAL,
5066
+ filters: {
5067
+ NOT: { aNumber: { lt: 2 } },
5068
+ },
5069
+ });
5070
+
5071
+ if (result.kind !== PlanKind.CONDITIONAL) {
5072
+ throw new Error("Expected CONDITIONAL result");
5073
+ }
5074
+
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" } },
4559
5170
  },
4560
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
+ );
4561
5185
  });
4562
5186
  });
4563
5187
 
4564
- describe("Deep Nesting", () => {
4565
- test("filter on deeply nested relation", async () => {
5188
+ describe("Collection Operations", () => {
5189
+ test("conditional - exists on nested collection", async () => {
4566
5190
  const queryPlan = await cerbos.planResources({
4567
5191
  principal: { id: "user1", roles: ["USER"] },
4568
5192
  resource: { kind: "resource" },
4569
- action: "filter-deeply-nested",
5193
+ action: "exists-nested-collection",
4570
5194
  });
4571
5195
 
4572
5196
  const result = queryPlanToPrisma({
@@ -4577,20 +5201,13 @@ describe("Complex Operations", () => {
4577
5201
  name: "categories",
4578
5202
  type: "many",
4579
5203
  fields: {
5204
+ name: { field: "name" },
4580
5205
  subCategories: {
4581
5206
  relation: {
4582
5207
  name: "subCategories",
4583
5208
  type: "many",
4584
5209
  fields: {
4585
- labels: {
4586
- relation: {
4587
- name: "labels",
4588
- type: "many",
4589
- fields: {
4590
- name: { field: "name" },
4591
- },
4592
- },
4593
- },
5210
+ name: { field: "name" },
4594
5211
  },
4595
5212
  },
4596
5213
  },
@@ -4605,26 +5222,94 @@ describe("Complex Operations", () => {
4605
5222
  filters: {
4606
5223
  categories: {
4607
5224
  some: {
4608
- subCategories: {
4609
- some: {
4610
- labels: {
5225
+ AND: [
5226
+ { name: { equals: "business" } },
5227
+ {
5228
+ subCategories: {
4611
5229
  some: {
4612
- name: { equals: "important" },
5230
+ name: { equals: "finance" },
4613
5231
  },
4614
5232
  },
4615
5233
  },
5234
+ ],
5235
+ },
5236
+ },
5237
+ },
5238
+ });
5239
+
5240
+ if (result.kind !== PlanKind.CONDITIONAL) {
5241
+ throw new Error("Expected CONDITIONAL result");
5242
+ }
5243
+
5244
+ const query = await prisma.resource.findMany({
5245
+ where: { ...result.filters },
5246
+ });
5247
+
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
+ });
5273
+
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
+ });
5280
+
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" },
4616
5290
  },
4617
5291
  },
4618
5292
  },
4619
5293
  },
4620
5294
  });
5295
+
5296
+ expect(result).toStrictEqual({
5297
+ kind: PlanKind.CONDITIONAL,
5298
+ filters: {
5299
+ nested: {
5300
+ is: {
5301
+ aOptionalString: { not: null },
5302
+ },
5303
+ },
5304
+ },
5305
+ });
4621
5306
  });
4622
5307
 
4623
- test("map on deeply nested relation", async () => {
5308
+ test("hasIntersection with nested collection", async () => {
4624
5309
  const queryPlan = await cerbos.planResources({
4625
5310
  principal: { id: "user1", roles: ["USER"] },
4626
5311
  resource: { kind: "resource" },
4627
- action: "map-deeply-nested",
5312
+ action: "has-intersection-nested",
4628
5313
  });
4629
5314
 
4630
5315
  const result = queryPlanToPrisma({
@@ -4666,13 +5351,119 @@ describe("Complex Operations", () => {
4666
5351
  });
4667
5352
  });
4668
5353
  });
4669
- });
4670
5354
 
4671
- // Error Handling
4672
- describe("Error Cases", () => {
4673
- test("throws error for invalid query plan", () => {
4674
- const invalidQueryPlan = {
4675
- kind: "INVALID_KIND" as PlanKind,
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,
4676
5467
  };
4677
5468
 
4678
5469
  expect(() =>
@@ -5513,3 +6304,2582 @@ describe("Return Types", () => {
5513
6304
  expect(conditional.kind).toBe(PlanKind.CONDITIONAL);
5514
6305
  });
5515
6306
  });
6307
+
6308
+ // Issue #156: add operator (string concatenation)
6309
+ describe("Add Operator", () => {
6310
+ test("add with two values (constant folding)", () => {
6311
+ // #given eq(field, add("prefix:", "suffix"))
6312
+ const plan = createConditionalPlan({
6313
+ operator: "eq",
6314
+ operands: [
6315
+ { name: "request.resource.attr.aString" },
6316
+ {
6317
+ operator: "add",
6318
+ operands: [{ value: "prefix:" }, { value: "suffix" }],
6319
+ },
6320
+ ],
6321
+ });
6322
+
6323
+ // #when
6324
+ const result = queryPlanToPrisma({
6325
+ queryPlan: plan,
6326
+ mapper: {
6327
+ "request.resource.attr.aString": { field: "aString" },
6328
+ },
6329
+ });
6330
+
6331
+ // #then constant fold: "prefix:" + "suffix" = "prefix:suffix"
6332
+ expect(result).toEqual({
6333
+ kind: PlanKind.CONDITIONAL,
6334
+ filters: { aString: { equals: "prefix:suffix" } },
6335
+ });
6336
+ });
6337
+
6338
+ test("add with value prefix and field reference in eq", () => {
6339
+ // #given eq("projects:123", add("projects:", R.attr.id))
6340
+ // This is the exact plan Cerbos produces for:
6341
+ // P.attr.context == "projects:" + request.resource.attr.id
6342
+ const plan = createConditionalPlan({
6343
+ operator: "eq",
6344
+ operands: [
6345
+ { value: "projects:123" },
6346
+ {
6347
+ operator: "add",
6348
+ operands: [
6349
+ { value: "projects:" },
6350
+ { name: "request.resource.attr.id" },
6351
+ ],
6352
+ },
6353
+ ],
6354
+ });
6355
+
6356
+ // #when
6357
+ const result = queryPlanToPrisma({
6358
+ queryPlan: plan,
6359
+ mapper: {
6360
+ "request.resource.attr.id": { field: "id" },
6361
+ },
6362
+ });
6363
+
6364
+ // #then solve: "projects:123" == "projects:" + id → id == "123"
6365
+ expect(result).toEqual({
6366
+ kind: PlanKind.CONDITIONAL,
6367
+ filters: { id: { equals: "123" } },
6368
+ });
6369
+ });
6370
+
6371
+ test("add with field reference and value suffix in eq", () => {
6372
+ // #given eq("hello_world", add(R.attr.aString, "_world"))
6373
+ const plan = createConditionalPlan({
6374
+ operator: "eq",
6375
+ operands: [
6376
+ { value: "hello_world" },
6377
+ {
6378
+ operator: "add",
6379
+ operands: [
6380
+ { name: "request.resource.attr.aString" },
6381
+ { value: "_world" },
6382
+ ],
6383
+ },
6384
+ ],
6385
+ });
6386
+
6387
+ // #when
6388
+ const result = queryPlanToPrisma({
6389
+ queryPlan: plan,
6390
+ mapper: {
6391
+ "request.resource.attr.aString": { field: "aString" },
6392
+ },
6393
+ });
6394
+
6395
+ // #then solve: "hello_world" == aString + "_world" → aString == "hello"
6396
+ expect(result).toEqual({
6397
+ kind: PlanKind.CONDITIONAL,
6398
+ filters: { aString: { equals: "hello" } },
6399
+ });
6400
+ });
6401
+
6402
+ test("add with value prefix and field reference in ne", () => {
6403
+ // #given ne("projects:123", add("projects:", R.attr.id))
6404
+ const plan = createConditionalPlan({
6405
+ operator: "ne",
6406
+ operands: [
6407
+ { value: "projects:123" },
6408
+ {
6409
+ operator: "add",
6410
+ operands: [
6411
+ { value: "projects:" },
6412
+ { name: "request.resource.attr.id" },
6413
+ ],
6414
+ },
6415
+ ],
6416
+ });
6417
+
6418
+ // #when
6419
+ const result = queryPlanToPrisma({
6420
+ queryPlan: plan,
6421
+ mapper: {
6422
+ "request.resource.attr.id": { field: "id" },
6423
+ },
6424
+ });
6425
+
6426
+ // #then solve: "projects:123" != "projects:" + id → id != "123"
6427
+ expect(result).toEqual({
6428
+ kind: PlanKind.CONDITIONAL,
6429
+ filters: { id: { not: "123" } },
6430
+ });
6431
+ });
6432
+
6433
+ test("add with prefix mismatch yields impossible filter", () => {
6434
+ // #given eq("other:123", add("projects:", R.attr.id))
6435
+ // "other:123" doesn't start with "projects:", so no value of id can match
6436
+ const plan = createConditionalPlan({
6437
+ operator: "eq",
6438
+ operands: [
6439
+ { value: "other:123" },
6440
+ {
6441
+ operator: "add",
6442
+ operands: [
6443
+ { value: "projects:" },
6444
+ { name: "request.resource.attr.id" },
6445
+ ],
6446
+ },
6447
+ ],
6448
+ });
6449
+
6450
+ // #when
6451
+ const result = queryPlanToPrisma({
6452
+ queryPlan: plan,
6453
+ mapper: {
6454
+ "request.resource.attr.id": { field: "id" },
6455
+ },
6456
+ });
6457
+
6458
+ // #then impossible: produces a filter that matches nothing
6459
+ // Use an impossible equality that Prisma won't match
6460
+ expect(result).toEqual({
6461
+ kind: PlanKind.CONDITIONAL,
6462
+ filters: {
6463
+ id: { in: [] },
6464
+ },
6465
+ });
6466
+ });
6467
+
6468
+ test("add with relation mapping", () => {
6469
+ // #given eq("projects:123", add("projects:", R.attr.nested.id))
6470
+ const plan = createConditionalPlan({
6471
+ operator: "eq",
6472
+ operands: [
6473
+ { value: "projects:123" },
6474
+ {
6475
+ operator: "add",
6476
+ operands: [
6477
+ { value: "projects:" },
6478
+ { name: "request.resource.attr.nested.id" },
6479
+ ],
6480
+ },
6481
+ ],
6482
+ });
6483
+
6484
+ // #when
6485
+ const result = queryPlanToPrisma({
6486
+ queryPlan: plan,
6487
+ mapper: {
6488
+ "request.resource.attr.nested": {
6489
+ relation: {
6490
+ name: "nested",
6491
+ type: "one",
6492
+ field: "id",
6493
+ },
6494
+ },
6495
+ },
6496
+ });
6497
+
6498
+ // #then
6499
+ expect(result).toEqual({
6500
+ kind: PlanKind.CONDITIONAL,
6501
+ filters: { nested: { is: { id: { equals: "123" } } } },
6502
+ });
6503
+ });
6504
+
6505
+ test("add via cerbos integration", async () => {
6506
+ const queryPlan = await cerbos.planResources({
6507
+ principal: {
6508
+ id: "user1",
6509
+ roles: ["USER"],
6510
+ attr: { context: "projects:123" },
6511
+ },
6512
+ resource: { kind: "resource" },
6513
+ action: "string-concat-principal",
6514
+ });
6515
+
6516
+ expect(queryPlan.kind).toBe(PlanKind.CONDITIONAL);
6517
+
6518
+ const result = queryPlanToPrisma({
6519
+ queryPlan,
6520
+ mapper: {
6521
+ "request.resource.attr.id": { field: "id" },
6522
+ },
6523
+ });
6524
+
6525
+ expect(result).toEqual({
6526
+ kind: PlanKind.CONDITIONAL,
6527
+ filters: { id: { equals: "123" } },
6528
+ });
6529
+ });
6530
+
6531
+ test("add with numeric values", () => {
6532
+ // #given add with numbers: eq(10, add(3, R.attr.aNumber))
6533
+ const plan = createConditionalPlan({
6534
+ operator: "eq",
6535
+ operands: [
6536
+ { value: 10 },
6537
+ {
6538
+ operator: "add",
6539
+ operands: [
6540
+ { value: 3 },
6541
+ { name: "request.resource.attr.aNumber" },
6542
+ ],
6543
+ },
6544
+ ],
6545
+ });
6546
+
6547
+ // #when
6548
+ const result = queryPlanToPrisma({
6549
+ queryPlan: plan,
6550
+ mapper: {
6551
+ "request.resource.attr.aNumber": { field: "aNumber" },
6552
+ },
6553
+ });
6554
+
6555
+ // #then solve: 10 == 3 + aNumber → aNumber == 7
6556
+ expect(result).toEqual({
6557
+ kind: PlanKind.CONDITIONAL,
6558
+ filters: { aNumber: { equals: 7 } },
6559
+ });
6560
+ });
6561
+ });
6562
+
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",
6579
+ operands: [
6580
+ {
6581
+ operator: "add",
6582
+ operands: [
6583
+ { name: "request.resource.attr.aNumber" },
6584
+ { value: 1 },
6585
+ ],
6586
+ },
6587
+ { value: 2 },
6588
+ ],
6589
+ });
6590
+
6591
+ // aNumber + 1 > 2 ⇔ aNumber > 1
6592
+ const result = queryPlanToPrisma({
6593
+ queryPlan,
6594
+ mapper: {
6595
+ "request.resource.attr.aNumber": { field: "aNumber" },
6596
+ },
6597
+ });
6598
+ expect(result).toStrictEqual({
6599
+ kind: PlanKind.CONDITIONAL,
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 },
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
+ );
6615
+ });
6616
+
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",
6627
+ operands: [
6628
+ {
6629
+ operator: "sub",
6630
+ operands: [
6631
+ { name: "request.resource.attr.aNumber" },
6632
+ { value: 1 },
6633
+ ],
6634
+ },
6635
+ { value: 2 },
6636
+ ],
6637
+ });
6638
+
6639
+ // aNumber - 1 < 2 ⇔ aNumber < 3
6640
+ const result = queryPlanToPrisma({
6641
+ queryPlan,
6642
+ mapper: {
6643
+ "request.resource.attr.aNumber": { field: "aNumber" },
6644
+ },
6645
+ });
6646
+ expect(result).toStrictEqual({
6647
+ kind: PlanKind.CONDITIONAL,
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 },
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
+ );
6663
+ });
6664
+
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",
6675
+ operands: [
6676
+ {
6677
+ operator: "mult",
6678
+ operands: [
6679
+ { name: "request.resource.attr.aNumber" },
6680
+ { value: 2 },
6681
+ ],
6682
+ },
6683
+ { value: 2 },
6684
+ ],
6685
+ });
6686
+
6687
+ // aNumber * 2 > 2 ⇔ aNumber > 1
6688
+ const result = queryPlanToPrisma({
6689
+ queryPlan,
6690
+ mapper: {
6691
+ "request.resource.attr.aNumber": { field: "aNumber" },
6692
+ },
6693
+ });
6694
+ expect(result).toStrictEqual({
6695
+ kind: PlanKind.CONDITIONAL,
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 },
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
+ );
6711
+ });
6712
+
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",
6723
+ operands: [
6724
+ {
6725
+ operator: "div",
6726
+ operands: [
6727
+ { name: "request.resource.attr.aNumber" },
6728
+ { value: 2 },
6729
+ ],
6730
+ },
6731
+ { value: 0 },
6732
+ ],
6733
+ });
6734
+
6735
+ // aNumber / 2 > 0 ⇔ aNumber > 0
6736
+ const result = queryPlanToPrisma({
6737
+ queryPlan,
6738
+ mapper: {
6739
+ "request.resource.attr.aNumber": { field: "aNumber" },
6740
+ },
6741
+ });
6742
+ expect(result).toStrictEqual({
6743
+ kind: PlanKind.CONDITIONAL,
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 },
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
+ );
6759
+ });
6760
+
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",
6771
+ operands: [
6772
+ {
6773
+ operator: "mod",
6774
+ operands: [
6775
+ {
6776
+ operator: "int",
6777
+ operands: [{ name: "request.resource.attr.aNumber" }],
6778
+ },
6779
+ { value: 2 },
6780
+ ],
6781
+ },
6782
+ { value: 0 },
6783
+ ],
6784
+ });
6785
+
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",
6803
+ });
6804
+
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
+ ],
6812
+ });
6813
+
6814
+ expect(() =>
6815
+ queryPlanToPrisma({
6816
+ queryPlan,
6817
+ mapper: {
6818
+ "request.resource.attr.aString": { field: "aString" },
6819
+ },
6820
+ })
6821
+ ).toThrow("Unsupported operator: matches");
6822
+ });
6823
+ });
6824
+
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",
6836
+ operands: [
6837
+ {
6838
+ operator: "index",
6839
+ operands: [
6840
+ { name: "request.resource.attr.ownedBy" },
6841
+ { value: 0 },
6842
+ ],
6843
+ },
6844
+ { value: "user1" },
6845
+ ],
6846
+ });
6847
+
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
+ });
6860
+
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",
6867
+ });
6868
+
6869
+ expect(queryPlan.kind).toEqual(PlanKind.CONDITIONAL);
6870
+ expect((queryPlan as PlanResourcesConditionalResponse).condition).toEqual({
6871
+ operator: "eq",
6872
+ operands: [
6873
+ {
6874
+ operator: "string",
6875
+ operands: [{ name: "request.resource.attr.aNumber" }],
6876
+ },
6877
+ { value: "1" },
6878
+ ],
6879
+ });
6880
+
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",
6896
+ });
6897
+
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
+ ],
6908
+ });
6909
+
6910
+ expect(() =>
6911
+ queryPlanToPrisma({
6912
+ queryPlan,
6913
+ mapper: {
6914
+ "request.resource.attr.aNumber": { field: "aNumber" },
6915
+ },
6916
+ })
6917
+ ).toThrow("Unsupported operator: double");
6918
+ });
6919
+
6920
+ test("convert-int is unsupported", async () => {
6921
+ const queryPlan = await cerbos.planResources({
6922
+ principal: { id: "user1", roles: ["USER"] },
6923
+ resource: { kind: "resource" },
6924
+ action: "convert-int",
6925
+ });
6926
+
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
+ ],
6937
+ });
6938
+
6939
+ expect(() =>
6940
+ queryPlanToPrisma({
6941
+ queryPlan,
6942
+ mapper: {
6943
+ "request.resource.attr.aString": { field: "aString" },
6944
+ },
6945
+ })
6946
+ ).toThrow("Unsupported operator: int");
6947
+ });
6948
+ });
6949
+
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
+ };
6958
+
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",
6982
+ operands: [
6983
+ {
6984
+ operator: "if",
6985
+ operands: [
6986
+ { name: "request.resource.attr.aBool" },
6987
+ { name: "request.resource.attr.aNumber" },
6988
+ { value: 0 },
6989
+ ],
6990
+ },
6991
+ { value: 0 },
6992
+ ],
6993
+ });
6994
+
6995
+ expect(queryPlanToPrisma({ queryPlan, mapper })).toStrictEqual({
6996
+ kind: PlanKind.CONDITIONAL,
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
+ },
7013
+ });
7014
+ expect(await matchingResourceIds(queryPlan)).toEqual(["resource1"]);
7015
+ });
7016
+
7017
+ test("rewrites a ternary used directly as a boolean predicate", async () => {
7018
+ const queryPlan = createConditionalPlan({
7019
+ operator: "if",
7020
+ operands: [
7021
+ { name: "request.resource.attr.aBool" },
7022
+ {
7023
+ operator: "gt",
7024
+ operands: [
7025
+ { name: "request.resource.attr.aNumber" },
7026
+ { value: 0 },
7027
+ ],
7028
+ },
7029
+ {
7030
+ operator: "eq",
7031
+ operands: [
7032
+ { name: "request.resource.attr.aString" },
7033
+ { value: "string3" },
7034
+ ],
7035
+ },
7036
+ ],
7037
+ });
7038
+
7039
+ expect(await matchingResourceIds(queryPlan)).toEqual([
7040
+ "resource1",
7041
+ "resource3",
7042
+ ]);
7043
+ });
7044
+
7045
+ test("recursively rewrites nested ternary branches", async () => {
7046
+ const queryPlan = createConditionalPlan({
7047
+ operator: "gt",
7048
+ operands: [
7049
+ {
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
+ ],
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 },
7082
+ {
7083
+ operator: "if",
7084
+ operands: [
7085
+ { name: "request.resource.attr.aBool" },
7086
+ { name: "request.resource.attr.aNumber" },
7087
+ { value: -1 },
7088
+ ],
7089
+ },
7090
+ ],
7091
+ });
7092
+
7093
+ const result = queryPlanToPrisma({ queryPlan, mapper });
7094
+ expect(result).toStrictEqual({
7095
+ kind: PlanKind.CONDITIONAL,
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
+ },
7112
+ });
7113
+ expect(await matchingResourceIds(queryPlan)).toEqual(["resource1"]);
7114
+ });
7115
+
7116
+ test("keeps a nullable condition unknown under outer negation", async () => {
7117
+ const queryPlan = createConditionalPlan({
7118
+ operator: "not",
7119
+ operands: [
7120
+ {
7121
+ operator: "if",
7122
+ operands: [
7123
+ { name: "request.resource.attr.aOptionalBool" },
7124
+ { value: false },
7125
+ { value: false },
7126
+ ],
7127
+ },
7128
+ ],
7129
+ });
7130
+
7131
+ expect(await matchingResourceIds(queryPlan)).toEqual([
7132
+ "resource1",
7133
+ "resource2",
7134
+ ]);
7135
+ });
7136
+
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
+ ],
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"] },
7299
+ resource: { kind: "resource" },
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
+ ],
7347
+ });
7348
+
7349
+ const result = queryPlanToPrisma({
7350
+ queryPlan,
7351
+ mapper: {
7352
+ "request.resource.attr.tags": {
7353
+ relation: { name: "tags", type: "many" },
7354
+ },
7355
+ },
7356
+ });
7357
+
7358
+ expect(result).toStrictEqual({
7359
+ kind: PlanKind.CONDITIONAL,
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 },
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
+ );
7379
+ });
7380
+ });
7381
+
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"
7391
+ const plan = createConditionalPlan({
7392
+ operator: "overlaps",
7393
+ operands: [
7394
+ {
7395
+ operator: "hierarchy",
7396
+ operands: [{ value: "projects:123" }, { value: ":" }],
7397
+ },
7398
+ {
7399
+ operator: "hierarchy",
7400
+ operands: [
7401
+ {
7402
+ operator: "list",
7403
+ operands: [
7404
+ { value: "projects" },
7405
+ { name: "request.resource.id" },
7406
+ ],
7407
+ },
7408
+ ],
7409
+ },
7410
+ ],
7411
+ });
7412
+
7413
+ // #when
7414
+ const result = queryPlanToPrisma({
7415
+ queryPlan: plan,
7416
+ mapper: {
7417
+ "request.resource.id": { field: "id" },
7418
+ },
7419
+ });
7420
+
7421
+ // #then
7422
+ expect(result).toEqual({
7423
+ kind: PlanKind.CONDITIONAL,
7424
+ filters: { id: { equals: "123" } },
7425
+ });
7426
+ });
7427
+
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
7432
+ const plan = createConditionalPlan({
7433
+ operator: "overlaps",
7434
+ operands: [
7435
+ {
7436
+ operator: "hierarchy",
7437
+ operands: [{ value: "admin" }, { value: ":" }],
7438
+ },
7439
+ {
7440
+ operator: "hierarchy",
7441
+ operands: [
7442
+ {
7443
+ operator: "list",
7444
+ operands: [
7445
+ { value: "admin" },
7446
+ { name: "request.resource.attr.scope" },
7447
+ ],
7448
+ },
7449
+ ],
7450
+ },
7451
+ ],
7452
+ });
7453
+
7454
+ // #when
7455
+ const result = queryPlanToPrisma({
7456
+ queryPlan: plan,
7457
+ mapper: {
7458
+ "request.resource.attr.scope": { field: "scope" },
7459
+ },
7460
+ });
7461
+
7462
+ // #then always true
7463
+ expect(result).toEqual({
7464
+ kind: PlanKind.CONDITIONAL,
7465
+ filters: {},
7466
+ });
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
+ });
8885
+ });