prisma-guard 1.26.2 → 1.27.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -695,6 +695,26 @@ where: {
695
695
  }
696
696
  ```
697
697
 
698
+ **Case-insensitive string filtering with `mode`:**
699
+
700
+ String fields support Prisma's `mode` modifier alongside `contains`, `startsWith`, `endsWith`, and `equals`. Use `mode: true` to let the client choose, or force a specific mode in the shape:
701
+ ```ts
702
+ where: {
703
+ // client controls mode
704
+ title: { contains: true, mode: true },
705
+
706
+ // server forces case-insensitive matching
707
+ description: { contains: true, mode: 'insensitive' },
708
+
709
+ // server forces case-insensitive matching using force()
710
+ slug: { contains: true, mode: force('insensitive') },
711
+ }
712
+ ```
713
+
714
+ `mode` is also supported on `Json` fields with the `string_contains`, `string_starts_with`, and `string_ends_with` operators. The shape must include at least one mode-compatible operator alongside `mode` — `{ title: { mode: true } }` alone is rejected.
715
+
716
+ When `mode` is forced (e.g. `mode: 'insensitive'`) and the client provides a compatible operator value (e.g. `{ contains: 'foo' }`), the forced mode is inlined into the same operator object, producing `{ contains: 'foo', mode: 'insensitive' }` in the final Prisma query. This is required for `mode` to actually affect the query — Prisma's `mode` is a modifier that must be co-located with the string operator it modifies.
717
+
698
718
  **Relation existence checks with `is: null` / `isNot: null`:**
699
719
 
700
720
  To-one relation filters support null checks for testing relation existence. In the shape config, use `null` as the operator value to force a null check:
@@ -1951,6 +1971,16 @@ Fields in `orderBy`, `cursor`, `having`, `_count` (object form), `_avg`, `_sum`,
1951
1971
 
1952
1972
  Both `@zod .default(...)` and `@zod .catch(...)` are tracked in the generated `ZOD_DEFAULTS` map. Fields with either directive are exempted from create completeness checks and auto-injected as forced values when omitted from data shapes. The `.catch()` behavior (fallback on parse error) is preserved in create mode by not wrapping the schema with `.optional()`.
1953
1973
 
1974
+ ### `mode` modifier in where filters
1975
+
1976
+ Prisma's `mode` modifier for case-insensitive string filtering is supported on `String` fields (with `contains`, `startsWith`, `endsWith`, `equals`) and `Json` fields (with `string_contains`, `string_starts_with`, `string_ends_with`). The shape syntax is `{ field: { contains: true, mode: true } }` for client-controlled mode or `{ field: { contains: true, mode: 'insensitive' } }` for forced mode.
1977
+
1978
+ When mode is forced, prisma-guard inlines the forced `mode` value into the same operator object as the client-provided string operator, producing `{ field: { contains: 'foo', mode: 'insensitive' } }` in the final query. This co-location is necessary because Prisma's `mode` is a modifier on `StringFilter` — a sibling AND clause carrying only `mode` would have no operator to modify and would be silently ignored.
1979
+
1980
+ The same inline-merge behavior applies to any other forced operator on a field where the client provides a different operator on the same field. Forced operators that conflict with the client's value on the same op key (e.g. forced `{ equals: 'x' }` plus client `{ equals: 'y' }`) are rejected with `ShapeError` at merge time. Forced conditions on fields the client did not touch fall back to AND-wrapping, unchanged from previous behavior.
1981
+
1982
+ A shape with `{ field: { mode: true } }` alone (no compatible string operator) is rejected with `ShapeError`.
1983
+
1954
1984
  ---
1955
1985
 
1956
1986
  ## Advanced: SQL-backed runtimes
@@ -2047,6 +2077,15 @@ Caller routing is resolved before method execution: the explicit `caller` argume
2047
2077
 
2048
2078
  The context function is validated on every code path that consumes it — scope injection, caller resolution, and dynamic shape evaluation all enforce the plain-object contract and throw `PolicyError` for invalid returns. Additionally, if a context key matches a known scope root but has a non-primitive value, `PolicyError` is thrown immediately rather than silently dropping the scope.
2049
2079
 
2080
+ ### Forced where merge strategy
2081
+
2082
+ When a where shape includes forced conditions, prisma-guard merges them into the client's validated where in two ways:
2083
+
2084
+ 1. **Inline merge** — if a forced field's value is a plain operator object and the client also provided an operator object for the same field, the forced operator keys are merged into the client's operator object. This is required for modifiers like `mode` that must co-locate with the operator they modify. Conflicts on the same op key (different values) throw `ShapeError`.
2085
+ 2. **AND-wrap** — forced fields not present in the client's where, or where the value types don't allow inline merging, are placed in a separate AND branch.
2086
+
2087
+ If all forced fields inline successfully, the result is a flat object with no synthetic `AND` wrapper. Forced conditions inside combinators are still lifted to top-level AND constraints, following the same merge logic.
2088
+
2050
2089
  ### The `force()` helper
2051
2090
 
2052
2091
  The `force()` function is exported from `prisma-guard` and creates a wrapper object with an internal symbol marker. At runtime, shape processing checks for this marker to distinguish forced values from the `true` sentinel. The wrapper is unwrapped before Zod validation, so the forced value is validated against the field's schema like any other literal. The symbol is not enumerable and does not interfere with serialization or inspection of shape objects.
@@ -2129,6 +2168,8 @@ For upsert, `create` and `update` data schemas are cached independently under na
2129
2168
  | context function returns non-object | error always (PolicyError) |
2130
2169
  | conflicting forced where values | error always (ShapeError) |
2131
2170
  | invalid context function return | error always (PolicyError) |
2171
+ | `mode` modifier without compatible op | error always (ShapeError) |
2172
+ | forced operator conflicts with client value | error always (ShapeError) |
2132
2173
  | `@zod .default()`/`.catch()` field omitted from shape | auto-injected as forced value |
2133
2174
  | read shape with select/include, client omits | auto-applied as default projection |
2134
2175
  | mutation shape with select/include, client omits | full payload unless enforceProjection enabled |
@@ -2232,6 +2273,8 @@ Node 22
2232
2273
  | Empty projection shape rejection | yes | n/a |
2233
2274
  | Forced where conflict detection | yes | n/a |
2234
2275
  | Forced boolean values via `force()` | yes | n/a |
2276
+ | Case-insensitive string filtering (`mode`) | yes | manual |
2277
+ | Forced operator inline merge | yes | n/a |
2235
2278
  | Strict Decimal mode | opt-in | n/a |
2236
2279
  | `@zod .default()`/`.catch()` auto-injection | yes | n/a |
2237
2280
  | Nested write scope enforcement | no (top-level only) | no |
@@ -2252,4 +2295,4 @@ Possible future improvements:
2252
2295
 
2253
2296
  ## License
2254
2297
 
2255
- MIT
2298
+ MIT
@@ -990,6 +990,60 @@ var EMPTY_WHERE_FORCED = {
990
990
  function hasWhereForced(f) {
991
991
  return Object.keys(f.conditions).length > 0 || Object.keys(f.relations).length > 0;
992
992
  }
993
+ function forcedScalarsEqual(a, b) {
994
+ if (a === b)
995
+ return true;
996
+ if (a === null || b === null)
997
+ return false;
998
+ if (typeof a !== typeof b)
999
+ return false;
1000
+ if (a instanceof Date && b instanceof Date) {
1001
+ return a.getTime() === b.getTime();
1002
+ }
1003
+ if (a instanceof RegExp && b instanceof RegExp) {
1004
+ return a.source === b.source && a.flags === b.flags;
1005
+ }
1006
+ if (Array.isArray(a)) {
1007
+ if (!Array.isArray(b))
1008
+ return false;
1009
+ if (a.length !== b.length)
1010
+ return false;
1011
+ return a.every((v, i) => forcedScalarsEqual(v, b[i]));
1012
+ }
1013
+ if (isPlainObject(a) && isPlainObject(b)) {
1014
+ const aKeys = Object.keys(a);
1015
+ const bKeys = Object.keys(b);
1016
+ if (aKeys.length !== bKeys.length)
1017
+ return false;
1018
+ return aKeys.every(
1019
+ (k) => k in b && forcedScalarsEqual(a[k], b[k])
1020
+ );
1021
+ }
1022
+ return false;
1023
+ }
1024
+ function tryInlineForcedField(result, field, forcedValue) {
1025
+ if (!(field in result))
1026
+ return false;
1027
+ if (!isPlainObject(forcedValue))
1028
+ return false;
1029
+ const existing = result[field];
1030
+ if (!isPlainObject(existing))
1031
+ return false;
1032
+ const merged = { ...existing };
1033
+ for (const [op, value] of Object.entries(forcedValue)) {
1034
+ if (op in merged) {
1035
+ if (!forcedScalarsEqual(merged[op], value)) {
1036
+ throw new ShapeError(
1037
+ `Conflicting where values for "${field}.${op}": client provided a different value than the forced shape`
1038
+ );
1039
+ }
1040
+ continue;
1041
+ }
1042
+ merged[op] = deepClone(value);
1043
+ }
1044
+ result[field] = merged;
1045
+ return true;
1046
+ }
993
1047
  function mergeWhereForced(where, forced) {
994
1048
  if (!hasWhereForced(forced))
995
1049
  return where ?? {};
@@ -1007,11 +1061,19 @@ function mergeWhereForced(where, forced) {
1007
1061
  }
1008
1062
  }
1009
1063
  if (Object.keys(forced.conditions).length > 0) {
1010
- const scalarClone = deepClone(forced.conditions);
1011
- if (Object.keys(result).length === 0) {
1012
- result = scalarClone;
1013
- } else {
1014
- result = { AND: [result, scalarClone] };
1064
+ const remaining = {};
1065
+ for (const [field, forcedValue] of Object.entries(forced.conditions)) {
1066
+ const inlined = tryInlineForcedField(result, field, forcedValue);
1067
+ if (!inlined) {
1068
+ remaining[field] = deepClone(forcedValue);
1069
+ }
1070
+ }
1071
+ if (Object.keys(remaining).length > 0) {
1072
+ if (Object.keys(result).length === 0) {
1073
+ result = remaining;
1074
+ } else {
1075
+ result = { AND: [result, remaining] };
1076
+ }
1015
1077
  }
1016
1078
  }
1017
1079
  return result;