prisma-guard 1.26.2 → 1.27.1
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 +44 -1
- package/dist/runtime/index.cjs +73 -5
- package/dist/runtime/index.cjs.map +1 -1
- package/dist/runtime/index.js +73 -5
- package/dist/runtime/index.js.map +1 -1
- package/package.json +1 -1
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
|
package/dist/runtime/index.cjs
CHANGED
|
@@ -990,6 +990,66 @@ 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
|
+
const merged2 = { equals: existing };
|
|
1032
|
+
for (const [op, value] of Object.entries(forcedValue)) {
|
|
1033
|
+
merged2[op] = deepClone(value);
|
|
1034
|
+
}
|
|
1035
|
+
result[field] = merged2;
|
|
1036
|
+
return true;
|
|
1037
|
+
}
|
|
1038
|
+
const merged = { ...existing };
|
|
1039
|
+
for (const [op, value] of Object.entries(forcedValue)) {
|
|
1040
|
+
if (op in merged) {
|
|
1041
|
+
if (!forcedScalarsEqual(merged[op], value)) {
|
|
1042
|
+
throw new ShapeError(
|
|
1043
|
+
`Conflicting where values for "${field}.${op}": client provided a different value than the forced shape`
|
|
1044
|
+
);
|
|
1045
|
+
}
|
|
1046
|
+
continue;
|
|
1047
|
+
}
|
|
1048
|
+
merged[op] = deepClone(value);
|
|
1049
|
+
}
|
|
1050
|
+
result[field] = merged;
|
|
1051
|
+
return true;
|
|
1052
|
+
}
|
|
993
1053
|
function mergeWhereForced(where, forced) {
|
|
994
1054
|
if (!hasWhereForced(forced))
|
|
995
1055
|
return where ?? {};
|
|
@@ -1007,11 +1067,19 @@ function mergeWhereForced(where, forced) {
|
|
|
1007
1067
|
}
|
|
1008
1068
|
}
|
|
1009
1069
|
if (Object.keys(forced.conditions).length > 0) {
|
|
1010
|
-
const
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1070
|
+
const remaining = {};
|
|
1071
|
+
for (const [field, forcedValue] of Object.entries(forced.conditions)) {
|
|
1072
|
+
const inlined = tryInlineForcedField(result, field, forcedValue);
|
|
1073
|
+
if (!inlined) {
|
|
1074
|
+
remaining[field] = deepClone(forcedValue);
|
|
1075
|
+
}
|
|
1076
|
+
}
|
|
1077
|
+
if (Object.keys(remaining).length > 0) {
|
|
1078
|
+
if (Object.keys(result).length === 0) {
|
|
1079
|
+
result = remaining;
|
|
1080
|
+
} else {
|
|
1081
|
+
result = { AND: [result, remaining] };
|
|
1082
|
+
}
|
|
1015
1083
|
}
|
|
1016
1084
|
}
|
|
1017
1085
|
return result;
|