prisma-ltree 0.6.0 → 0.7.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.
@@ -1,11 +1,45 @@
1
+ import { i as LTREE_CODEC_ID, n as Ltree } from "./codec-types-B0DtEnU6.mjs";
1
2
  import { SqlRuntimeExtensionDescriptor } from "@prisma/orm-family-sql/runtime";
2
3
  import { CodecDescriptorRegistry } from "@prisma/orm-family-sql/relational-core/query-lane-context";
4
+ import { CollectionMethodContributionType, CollectionMethodModelType } from "@prisma/orm-family-sql/relational-core/collection-methods";
3
5
 
4
6
  //#region src/core/registry.d.ts
5
7
  declare const ltreeCodecRegistry: CodecDescriptorRegistry;
6
8
  //#endregion
9
+ //#region src/core/hierarchy-collection-methods.d.ts
10
+ type LtreeFieldNames<Fields> = { [Name in keyof Fields]: Fields[Name] extends {
11
+ readonly many: object;
12
+ } ? never : Fields[Name] extends {
13
+ readonly type: {
14
+ readonly codecId: typeof LTREE_CODEC_ID;
15
+ };
16
+ } ? Name : never }[keyof Fields];
17
+ type HierarchyMethodTable<Model extends CollectionMethodModelType> = { readonly [Name in LtreeFieldNames<Model["fields"]> & string as `${Name}_descendants`]: {
18
+ readonly parameters: readonly [root: Ltree];
19
+ readonly filters: true;
20
+ readonly orders: false;
21
+ } } & { readonly [Name in LtreeFieldNames<Model["fields"]> & string as `${Name}_ancestors`]: {
22
+ readonly parameters: readonly [leaf: Ltree];
23
+ readonly filters: true;
24
+ readonly orders: false;
25
+ } };
26
+ /**
27
+ * One `{field}_descendants` / `{field}_ancestors` method per scalar `ltree`
28
+ * field. Descendants map to `isDescendantOf` (`<@`); ancestors map to
29
+ * `isAncestorOf` (`@>`). Both are inclusive of equality, matching PostgreSQL.
30
+ */
31
+ interface LtreeHierarchyMethods extends CollectionMethodContributionType {
32
+ readonly methods: HierarchyMethodTable<this["model"]>;
33
+ }
34
+ declare module "@prisma/orm-family-sql/relational-core/collection-methods" {
35
+ interface CollectionMethodRegistry {
36
+ readonly "ltree/hierarchy": LtreeHierarchyMethods;
37
+ }
38
+ }
39
+ /** The run-time half of `LtreeHierarchyMethods`. */
40
+ //#endregion
7
41
  //#region src/exports/runtime.d.ts
8
42
  declare const ltreeRuntimeDescriptor: SqlRuntimeExtensionDescriptor<"postgres">;
9
43
  //#endregion
10
- export { ltreeRuntimeDescriptor as default, ltreeCodecRegistry };
44
+ export { type LtreeHierarchyMethods, ltreeRuntimeDescriptor as default, ltreeCodecRegistry };
11
45
  //# sourceMappingURL=runtime.d.mts.map
@@ -1 +1 @@
1
- {"version":3,"file":"runtime.d.mts","names":[],"sources":["../src/core/registry.ts","../src/exports/runtime.ts"],"mappings":";;;;cAIa,kBAAA,EAAoB,uBACe;;;cCD1C,sBAAA,EAAwB,6BAA6B"}
1
+ {"version":3,"file":"runtime.d.mts","names":[],"sources":["../src/core/registry.ts","../src/core/hierarchy-collection-methods.ts","../src/exports/runtime.ts"],"mappings":";;;;;;cAIa,kBAAA,EAAoB,uBACe;;;KCO3C,eAAA,4BACY,MAAA,GAAS,MAAA,CAAO,IAAA;EAAA,SAAyB,IAAA;AAAA,YAEpD,MAAA,CAAO,IAAA;EAAA,SACM,IAAA;IAAA,SAAiB,OAAA,SAAgB,cAAA;EAAA;AAAA,IAE5C,IAAA,iBAEA,MAAA;AAAA,KAEH,oBAAA,eAAmC,yBAAA,wBACpB,eAAA,CAAgB,KAAA,2BAAgC,IAAA;EAAA,SACvD,UAAA,YAAsB,IAAA,EAAM,KAAA;EAAA,SAC5B,OAAA;EAAA,SACA,MAAA;AAAA,0BAGO,eAAA,CAAgB,KAAA,2BAAgC,IAAA;EAAA,SACvD,UAAA,YAAsB,IAAA,EAAM,KAAA;EAAA,SAC5B,OAAA;EAAA,SACA,MAAA;AAAA;;;;;;UASI,qBAAA,SAA8B,gCAAgC;EAAA,SACpE,OAAA,EAAS,oBAAA;AAAA;AAAA;EAAA,UAIR,wBAAA;IAAA,SACC,iBAAA,EAAmB,qBAAqB;EAAA;AAAA;;;;cC1C/C,sBAAA,EAAwB,6BAA6B"}
package/dist/runtime.mjs CHANGED
@@ -1,4 +1,36 @@
1
- import { n as ltreeQueryOperations, r as ltreeCodecRegistry, t as ltreePackMeta } from "./descriptor-meta-Cc6tmP54.mjs";
1
+ import "./constants-Dfknw-h5.mjs";
2
+ import { n as ltreeQueryOperations, r as ltreeCodecRegistry, t as ltreePackMeta } from "./descriptor-meta-DwbFei9y.mjs";
3
+ import { blindCast } from "@prisma/orm-framework/utils/casts";
4
+ import { InternalError } from "@prisma/orm-framework/utils/internal-error";
5
+ //#region src/core/hierarchy-collection-methods.ts
6
+ function fieldOf(model, field) {
7
+ const accessor = blindCast(model)[field];
8
+ if (accessor === void 0) throw new InternalError(`The model accessor has no ltree field "${field}".`);
9
+ return accessor;
10
+ }
11
+ function descendantsMethod(field) {
12
+ return (collection, ...args) => {
13
+ const [root] = blindCast(args);
14
+ return collection.where((model) => fieldOf(model, field).isDescendantOf(root));
15
+ };
16
+ }
17
+ function ancestorsMethod(field) {
18
+ return (collection, ...args) => {
19
+ const [leaf] = blindCast(args);
20
+ return collection.where((model) => fieldOf(model, field).isAncestorOf(leaf));
21
+ };
22
+ }
23
+ function isScalarLtreeField(field) {
24
+ if (typeof field !== "object" || field === null) return false;
25
+ const record = field;
26
+ return record.many === void 0 && record.type?.kind === "scalar" && record.type.codecId === "pg/ltree@1";
27
+ }
28
+ /** The run-time half of `LtreeHierarchyMethods`. */
29
+ const ltreeHierarchyMethods = {
30
+ id: "ltree/hierarchy",
31
+ methodsFor: (model) => Object.fromEntries(Object.entries(model.fields).flatMap(([name, field]) => isScalarLtreeField(field) ? [[`${name}_descendants`, descendantsMethod(name)], [`${name}_ancestors`, ancestorsMethod(name)]] : []))
32
+ };
33
+ //#endregion
2
34
  //#region src/exports/runtime.ts
3
35
  const ltreeRuntimeDescriptor = {
4
36
  kind: "extension",
@@ -10,6 +42,7 @@ const ltreeRuntimeDescriptor = {
10
42
  types: { codecTypes: { codecDescriptors: Array.from(ltreeCodecRegistry.values()) } },
11
43
  codecs: () => Array.from(ltreeCodecRegistry.values()),
12
44
  queryOperations: () => ltreeQueryOperations(),
45
+ collectionMethods: () => [ltreeHierarchyMethods],
13
46
  create() {
14
47
  return {
15
48
  familyId: "sql",
@@ -1 +1 @@
1
- {"version":3,"file":"runtime.mjs","names":[],"sources":["../src/exports/runtime.ts"],"sourcesContent":["import type { SqlRuntimeExtensionDescriptor } from \"@prisma/orm-family-sql/runtime\";\nimport { ltreePackMeta, ltreeQueryOperations } from \"../core/descriptor-meta\";\nimport { ltreeCodecRegistry } from \"../core/registry\";\n\nconst ltreeRuntimeDescriptor: SqlRuntimeExtensionDescriptor<\"postgres\"> = {\n kind: \"extension\" as const,\n id: ltreePackMeta.id,\n version: ltreePackMeta.version,\n dataTypes: ltreePackMeta.dataTypes,\n familyId: \"sql\" as const,\n targetId: \"postgres\" as const,\n types: {\n codecTypes: {\n codecDescriptors: Array.from(ltreeCodecRegistry.values()),\n },\n },\n codecs: () => Array.from(ltreeCodecRegistry.values()),\n queryOperations: () => ltreeQueryOperations(),\n create() {\n return {\n familyId: \"sql\" as const,\n targetId: \"postgres\" as const,\n };\n },\n};\n\nexport { ltreeCodecRegistry };\nexport default ltreeRuntimeDescriptor;\n"],"mappings":";;AAIA,MAAM,yBAAoE;CACxE,MAAM;CACN,IAAI,cAAc;CAClB,SAAS,cAAc;CACvB,WAAW,cAAc;CACzB,UAAU;CACV,UAAU;CACV,OAAO,EACL,YAAY,EACV,kBAAkB,MAAM,KAAK,mBAAmB,OAAO,CAAC,EAC1D,EACF;CACA,cAAc,MAAM,KAAK,mBAAmB,OAAO,CAAC;CACpD,uBAAuB,qBAAqB;CAC5C,SAAS;EACP,OAAO;GACL,UAAU;GACV,UAAU;EACZ;CACF;AACF"}
1
+ {"version":3,"file":"runtime.mjs","names":[],"sources":["../src/core/hierarchy-collection-methods.ts","../src/exports/runtime.ts"],"sourcesContent":["import type {\n CollectionMethodBody,\n CollectionMethodContribution,\n CollectionMethodContributionId,\n CollectionMethodContributionType,\n CollectionMethodModelType,\n} from \"@prisma/orm-family-sql/relational-core/collection-methods\";\nimport { blindCast } from \"@prisma/orm-framework/utils/casts\";\nimport { InternalError } from \"@prisma/orm-framework/utils/internal-error\";\nimport type { Ltree } from \"../types/codec-types\";\nimport { LTREE_CODEC_ID } from \"./constants\";\n\ntype LtreeFieldNames<Fields> = {\n [Name in keyof Fields]: Fields[Name] extends { readonly many: object }\n ? never\n : Fields[Name] extends {\n readonly type: { readonly codecId: typeof LTREE_CODEC_ID };\n }\n ? Name\n : never;\n}[keyof Fields];\n\ntype HierarchyMethodTable<Model extends CollectionMethodModelType> = {\n readonly [Name in LtreeFieldNames<Model[\"fields\"]> & string as `${Name}_descendants`]: {\n readonly parameters: readonly [root: Ltree];\n readonly filters: true;\n readonly orders: false;\n };\n} & {\n readonly [Name in LtreeFieldNames<Model[\"fields\"]> & string as `${Name}_ancestors`]: {\n readonly parameters: readonly [leaf: Ltree];\n readonly filters: true;\n readonly orders: false;\n };\n};\n\n/**\n * One `{field}_descendants` / `{field}_ancestors` method per scalar `ltree`\n * field. Descendants map to `isDescendantOf` (`<@`); ancestors map to\n * `isAncestorOf` (`@>`). Both are inclusive of equality, matching PostgreSQL.\n */\nexport interface LtreeHierarchyMethods extends CollectionMethodContributionType {\n readonly methods: HierarchyMethodTable<this[\"model\"]>;\n}\n\ndeclare module \"@prisma/orm-family-sql/relational-core/collection-methods\" {\n interface CollectionMethodRegistry {\n readonly \"ltree/hierarchy\": LtreeHierarchyMethods;\n }\n}\n\ninterface HierarchyField {\n isDescendantOf(root: Ltree): unknown;\n isAncestorOf(leaf: Ltree): unknown;\n}\n\ntype LtreeFields = Readonly<Record<string, HierarchyField>>;\n\nfunction fieldOf(model: unknown, field: string): HierarchyField {\n const accessor = blindCast<\n LtreeFields,\n \"an ltree field of the ORM model accessor has hierarchy operators\"\n >(model)[field];\n if (accessor === undefined) {\n throw new InternalError(`The model accessor has no ltree field \"${field}\".`);\n }\n return accessor;\n}\n\nfunction descendantsMethod(field: string): CollectionMethodBody {\n return (collection, ...args) => {\n const [root] = blindCast<readonly [Ltree], \"the type half declares a root path\">(args);\n return collection.where((model) => fieldOf(model, field).isDescendantOf(root));\n };\n}\n\nfunction ancestorsMethod(field: string): CollectionMethodBody {\n return (collection, ...args) => {\n const [leaf] = blindCast<readonly [Ltree], \"the type half declares a leaf path\">(args);\n return collection.where((model) => fieldOf(model, field).isAncestorOf(leaf));\n };\n}\n\nfunction isScalarLtreeField(field: unknown): boolean {\n if (typeof field !== \"object\" || field === null) return false;\n const record = field as {\n readonly many?: unknown;\n readonly type?: { readonly kind?: string; readonly codecId?: string };\n };\n return (\n record.many === undefined &&\n record.type?.kind === \"scalar\" &&\n record.type.codecId === LTREE_CODEC_ID\n );\n}\n\n/** The run-time half of `LtreeHierarchyMethods`. */\nexport const ltreeHierarchyMethods: CollectionMethodContribution = {\n id: \"ltree/hierarchy\" satisfies CollectionMethodContributionId,\n methodsFor: (model) =>\n Object.fromEntries(\n Object.entries(model.fields).flatMap(([name, field]) =>\n isScalarLtreeField(field)\n ? [\n [`${name}_descendants`, descendantsMethod(name)],\n [`${name}_ancestors`, ancestorsMethod(name)],\n ]\n : [],\n ),\n ),\n};\n","import type { SqlRuntimeExtensionDescriptor } from \"@prisma/orm-family-sql/runtime\";\nimport { ltreePackMeta, ltreeQueryOperations } from \"../core/descriptor-meta\";\nimport { ltreeHierarchyMethods } from \"../core/hierarchy-collection-methods\";\nimport { ltreeCodecRegistry } from \"../core/registry\";\n\nconst ltreeRuntimeDescriptor: SqlRuntimeExtensionDescriptor<\"postgres\"> = {\n kind: \"extension\" as const,\n id: ltreePackMeta.id,\n version: ltreePackMeta.version,\n dataTypes: ltreePackMeta.dataTypes,\n familyId: \"sql\" as const,\n targetId: \"postgres\" as const,\n types: {\n codecTypes: {\n codecDescriptors: Array.from(ltreeCodecRegistry.values()),\n },\n },\n codecs: () => Array.from(ltreeCodecRegistry.values()),\n queryOperations: () => ltreeQueryOperations(),\n collectionMethods: () => [ltreeHierarchyMethods],\n create() {\n return {\n familyId: \"sql\" as const,\n targetId: \"postgres\" as const,\n };\n },\n};\n\nexport type { LtreeHierarchyMethods } from \"../core/hierarchy-collection-methods\";\nexport { ltreeCodecRegistry };\nexport default ltreeRuntimeDescriptor;\n"],"mappings":";;;;;AA0DA,SAAS,QAAQ,OAAgB,OAA+B;CAC9D,MAAM,WAAW,UAGf,KAAK,EAAE;CACT,IAAI,aAAa,KAAA,GACf,MAAM,IAAI,cAAc,0CAA0C,MAAM,GAAG;CAE7E,OAAO;AACT;AAEA,SAAS,kBAAkB,OAAqC;CAC9D,QAAQ,YAAY,GAAG,SAAS;EAC9B,MAAM,CAAC,QAAQ,UAAkE,IAAI;EACrF,OAAO,WAAW,OAAO,UAAU,QAAQ,OAAO,KAAK,EAAE,eAAe,IAAI,CAAC;CAC/E;AACF;AAEA,SAAS,gBAAgB,OAAqC;CAC5D,QAAQ,YAAY,GAAG,SAAS;EAC9B,MAAM,CAAC,QAAQ,UAAkE,IAAI;EACrF,OAAO,WAAW,OAAO,UAAU,QAAQ,OAAO,KAAK,EAAE,aAAa,IAAI,CAAC;CAC7E;AACF;AAEA,SAAS,mBAAmB,OAAyB;CACnD,IAAI,OAAO,UAAU,YAAY,UAAU,MAAM,OAAO;CACxD,MAAM,SAAS;CAIf,OACE,OAAO,SAAS,KAAA,KAChB,OAAO,MAAM,SAAS,YACtB,OAAO,KAAK,YAAA;AAEhB;;AAGA,MAAa,wBAAsD;CACjE,IAAI;CACJ,aAAa,UACX,OAAO,YACL,OAAO,QAAQ,MAAM,MAAM,EAAE,SAAS,CAAC,MAAM,WAC3C,mBAAmB,KAAK,IACpB,CACE,CAAC,GAAG,KAAK,eAAe,kBAAkB,IAAI,CAAC,GAC/C,CAAC,GAAG,KAAK,aAAa,gBAAgB,IAAI,CAAC,CAC7C,IACA,CAAC,CACP,CACF;AACJ;;;ACzGA,MAAM,yBAAoE;CACxE,MAAM;CACN,IAAI,cAAc;CAClB,SAAS,cAAc;CACvB,WAAW,cAAc;CACzB,UAAU;CACV,UAAU;CACV,OAAO,EACL,YAAY,EACV,kBAAkB,MAAM,KAAK,mBAAmB,OAAO,CAAC,EAC1D,EACF;CACA,cAAc,MAAM,KAAK,mBAAmB,OAAO,CAAC;CACpD,uBAAuB,qBAAqB;CAC5C,yBAAyB,CAAC,qBAAqB;CAC/C,SAAS;EACP,OAAO;GACL,UAAU;GACV,UAAU;EACZ;CACF;AACF"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "prisma-ltree",
3
- "version": "0.6.0",
3
+ "version": "0.7.0",
4
4
  "description": "Prisma 8 extension pack for PostgreSQL's ltree hierarchical tree data type.",
5
5
  "homepage": "https://prisma-ltree.procka.org",
6
6
  "license": "Apache-2.0",
@@ -11,6 +11,7 @@ import type { QueryOperationTypes } from "../types/operation-types";
11
11
  import { ltreeAuthoringTypes } from "./authoring";
12
12
  import { LTREE_ARRAY_CODEC_ID, LTREE_CODEC_ID } from "./constants";
13
13
  import { ltreeDataTypes } from "./data-types";
14
+ import { assertPatternArg } from "./pattern-validation";
14
15
  import { ltreeCodecRegistry } from "./registry";
15
16
 
16
17
  type CodecTypesBase = Record<string, { readonly input: unknown; readonly output: unknown }>;
@@ -57,8 +58,10 @@ export function ltreeQueryOperations<CT extends CodecTypesBase>(): QueryOperatio
57
58
  // Hierarchy operators compare two `ltree` values; the user argument shares the
58
59
  // column's `pg/ltree@1` codec. Pattern-match operators take a query/pattern
59
60
  // string (or `string[]`) that is NOT an ltree — it is bound as text and cast
60
- // in-template to `lquery` / `lquery[]` / `ltxtquery` (the validated-string-param
61
- // approach; verified executable under PGlite).
61
+ // in-template to `lquery` / `lquery[]` / `ltxtquery`. Literal string args get a
62
+ // lightweight structural check first (see `pattern-validation.ts`) so empty /
63
+ // obviously illegal patterns fail with a clear pack error instead of an opaque
64
+ // Postgres cast failure; Expression / ParamRef binds still defer to Postgres.
62
65
  const hierarchyOp = (
63
66
  method: string,
64
67
  operator: string,
@@ -80,11 +83,12 @@ export function ltreeQueryOperations<CT extends CodecTypesBase>(): QueryOperatio
80
83
  const patternOp = (
81
84
  method: string,
82
85
  operator: string,
83
- castType: string,
86
+ castType: "lquery" | "ltxtquery" | "lquery[]",
84
87
  self: CodecExpression<typeof LTREE_CODEC_ID, boolean, CT>,
85
88
  arg: unknown,
86
89
  many = false,
87
90
  ): BoolReturn => {
91
+ assertPatternArg(castType, arg);
88
92
  const selfCodec = codecOf(self);
89
93
  return buildOperation({
90
94
  method,
@@ -118,6 +122,34 @@ export function ltreeQueryOperations<CT extends CodecTypesBase>(): QueryOperatio
118
122
  lowering: { targetFamily: "sql", template },
119
123
  });
120
124
 
125
+ // Boolean operators on an `ltree[]` receiver (ADR-007). Same bind/cast rules as
126
+ // `firstMatchOp`, but the result is `boolean`. These are the operators
127
+ // `gist__ltree_ops` indexes (`<@`, `~`, `@`, `?`), plus `@>` which PostgreSQL
128
+ // ships and the opclass does not index.
129
+ const arrayBoolOp = (
130
+ method: string,
131
+ operator: string,
132
+ self: CodecExpression<typeof LTREE_ARRAY_CODEC_ID, boolean, CT>,
133
+ arg: unknown,
134
+ argCodecId: typeof LTREE_CODEC_ID | typeof TEXT_CODEC_ID,
135
+ castType?: "lquery" | "ltxtquery" | "lquery[]",
136
+ many = false,
137
+ ): BoolReturn => {
138
+ if (castType) assertPatternArg(castType, arg);
139
+ const template = castType
140
+ ? `{{self}} ${operator} ({{arg0}})::${castType}`
141
+ : `{{self}} ${operator} {{arg0}}`;
142
+ return buildOperation({
143
+ method,
144
+ args: [
145
+ toExpr(self, codecOf(self)),
146
+ toExpr(arg, many ? { codecId: argCodecId, many: true } : { codecId: argCodecId }),
147
+ ],
148
+ returns: BOOL_RETURN,
149
+ lowering: { targetFamily: "sql", template },
150
+ });
151
+ };
152
+
121
153
  // Tier 3 — first-match operators on an `ltree[]` receiver (ADR-003).
122
154
  const firstMatchOp = (
123
155
  method: string,
@@ -125,8 +157,9 @@ export function ltreeQueryOperations<CT extends CodecTypesBase>(): QueryOperatio
125
157
  self: CodecExpression<typeof LTREE_ARRAY_CODEC_ID, boolean, CT>,
126
158
  arg: unknown,
127
159
  argCodecId: typeof LTREE_CODEC_ID | typeof TEXT_CODEC_ID,
128
- castType?: string,
160
+ castType?: "lquery" | "ltxtquery",
129
161
  ): LtreeReturn => {
162
+ if (castType) assertPatternArg(castType, arg);
130
163
  const selfCodec = codecOf(self);
131
164
  const template = castType
132
165
  ? `{{self}} ${operator} ({{arg0}})::${castType}`
@@ -281,6 +314,33 @@ export function ltreeQueryOperations<CT extends CodecTypesBase>(): QueryOperatio
281
314
  impl: (self, query) =>
282
315
  firstMatchOp("firstMatchLtxtquery", "?@", self, query, TEXT_CODEC_ID, "ltxtquery"),
283
316
  },
317
+ // Boolean array operators (ADR-007). Distinct names from scalar methods because
318
+ // the operation registry keys by name only. `containsDescendantOf`,
319
+ // `matchesAnyLquery`, `matchesAnyLtxtquery`, and `matchesAnyLqueryArray` are
320
+ // in `gist__ltree_ops`; `containsAncestorOf` is not.
321
+ containsAncestorOf: {
322
+ self: { codecId: LTREE_ARRAY_CODEC_ID },
323
+ impl: (self, other) => arrayBoolOp("containsAncestorOf", "@>", self, other, LTREE_CODEC_ID),
324
+ },
325
+ containsDescendantOf: {
326
+ self: { codecId: LTREE_ARRAY_CODEC_ID },
327
+ impl: (self, other) => arrayBoolOp("containsDescendantOf", "<@", self, other, LTREE_CODEC_ID),
328
+ },
329
+ matchesAnyLquery: {
330
+ self: { codecId: LTREE_ARRAY_CODEC_ID },
331
+ impl: (self, pattern) =>
332
+ arrayBoolOp("matchesAnyLquery", "~", self, pattern, TEXT_CODEC_ID, "lquery"),
333
+ },
334
+ matchesAnyLqueryArray: {
335
+ self: { codecId: LTREE_ARRAY_CODEC_ID },
336
+ impl: (self, patterns) =>
337
+ arrayBoolOp("matchesAnyLqueryArray", "?", self, patterns, TEXT_CODEC_ID, "lquery[]", true),
338
+ },
339
+ matchesAnyLtxtquery: {
340
+ self: { codecId: LTREE_ARRAY_CODEC_ID },
341
+ impl: (self, query) =>
342
+ arrayBoolOp("matchesAnyLtxtquery", "@", self, query, TEXT_CODEC_ID, "ltxtquery"),
343
+ },
284
344
  // `lca(ltree[])` — array-receiver form (ADR-001, ADR-005). Named `lcaAll`
285
345
  // because prisma-next keys operations by name only (`createOperationRegistry`
286
346
  // throws on a duplicate); `lca` is already the variadic scalar method on
@@ -0,0 +1,111 @@
1
+ import type {
2
+ CollectionMethodBody,
3
+ CollectionMethodContribution,
4
+ CollectionMethodContributionId,
5
+ CollectionMethodContributionType,
6
+ CollectionMethodModelType,
7
+ } from "@prisma/orm-family-sql/relational-core/collection-methods";
8
+ import { blindCast } from "@prisma/orm-framework/utils/casts";
9
+ import { InternalError } from "@prisma/orm-framework/utils/internal-error";
10
+ import type { Ltree } from "../types/codec-types";
11
+ import { LTREE_CODEC_ID } from "./constants";
12
+
13
+ type LtreeFieldNames<Fields> = {
14
+ [Name in keyof Fields]: Fields[Name] extends { readonly many: object }
15
+ ? never
16
+ : Fields[Name] extends {
17
+ readonly type: { readonly codecId: typeof LTREE_CODEC_ID };
18
+ }
19
+ ? Name
20
+ : never;
21
+ }[keyof Fields];
22
+
23
+ type HierarchyMethodTable<Model extends CollectionMethodModelType> = {
24
+ readonly [Name in LtreeFieldNames<Model["fields"]> & string as `${Name}_descendants`]: {
25
+ readonly parameters: readonly [root: Ltree];
26
+ readonly filters: true;
27
+ readonly orders: false;
28
+ };
29
+ } & {
30
+ readonly [Name in LtreeFieldNames<Model["fields"]> & string as `${Name}_ancestors`]: {
31
+ readonly parameters: readonly [leaf: Ltree];
32
+ readonly filters: true;
33
+ readonly orders: false;
34
+ };
35
+ };
36
+
37
+ /**
38
+ * One `{field}_descendants` / `{field}_ancestors` method per scalar `ltree`
39
+ * field. Descendants map to `isDescendantOf` (`<@`); ancestors map to
40
+ * `isAncestorOf` (`@>`). Both are inclusive of equality, matching PostgreSQL.
41
+ */
42
+ export interface LtreeHierarchyMethods extends CollectionMethodContributionType {
43
+ readonly methods: HierarchyMethodTable<this["model"]>;
44
+ }
45
+
46
+ declare module "@prisma/orm-family-sql/relational-core/collection-methods" {
47
+ interface CollectionMethodRegistry {
48
+ readonly "ltree/hierarchy": LtreeHierarchyMethods;
49
+ }
50
+ }
51
+
52
+ interface HierarchyField {
53
+ isDescendantOf(root: Ltree): unknown;
54
+ isAncestorOf(leaf: Ltree): unknown;
55
+ }
56
+
57
+ type LtreeFields = Readonly<Record<string, HierarchyField>>;
58
+
59
+ function fieldOf(model: unknown, field: string): HierarchyField {
60
+ const accessor = blindCast<
61
+ LtreeFields,
62
+ "an ltree field of the ORM model accessor has hierarchy operators"
63
+ >(model)[field];
64
+ if (accessor === undefined) {
65
+ throw new InternalError(`The model accessor has no ltree field "${field}".`);
66
+ }
67
+ return accessor;
68
+ }
69
+
70
+ function descendantsMethod(field: string): CollectionMethodBody {
71
+ return (collection, ...args) => {
72
+ const [root] = blindCast<readonly [Ltree], "the type half declares a root path">(args);
73
+ return collection.where((model) => fieldOf(model, field).isDescendantOf(root));
74
+ };
75
+ }
76
+
77
+ function ancestorsMethod(field: string): CollectionMethodBody {
78
+ return (collection, ...args) => {
79
+ const [leaf] = blindCast<readonly [Ltree], "the type half declares a leaf path">(args);
80
+ return collection.where((model) => fieldOf(model, field).isAncestorOf(leaf));
81
+ };
82
+ }
83
+
84
+ function isScalarLtreeField(field: unknown): boolean {
85
+ if (typeof field !== "object" || field === null) return false;
86
+ const record = field as {
87
+ readonly many?: unknown;
88
+ readonly type?: { readonly kind?: string; readonly codecId?: string };
89
+ };
90
+ return (
91
+ record.many === undefined &&
92
+ record.type?.kind === "scalar" &&
93
+ record.type.codecId === LTREE_CODEC_ID
94
+ );
95
+ }
96
+
97
+ /** The run-time half of `LtreeHierarchyMethods`. */
98
+ export const ltreeHierarchyMethods: CollectionMethodContribution = {
99
+ id: "ltree/hierarchy" satisfies CollectionMethodContributionId,
100
+ methodsFor: (model) =>
101
+ Object.fromEntries(
102
+ Object.entries(model.fields).flatMap(([name, field]) =>
103
+ isScalarLtreeField(field)
104
+ ? [
105
+ [`${name}_descendants`, descendantsMethod(name)],
106
+ [`${name}_ancestors`, ancestorsMethod(name)],
107
+ ]
108
+ : [],
109
+ ),
110
+ ),
111
+ };
@@ -0,0 +1,313 @@
1
+ /**
2
+ * Lightweight structural checks for PostgreSQL `lquery` / `ltxtquery` pattern
3
+ * arguments. These catch empty values, empty path segments, unbalanced
4
+ * operators/parentheses, and obviously illegal syntax before the value is cast
5
+ * in SQL — without inventing framework PN-* error codes.
6
+ *
7
+ * Label bodies intentionally allow Unicode letters/numbers (`\p{L}` / `\p{N}`)
8
+ * plus `_` / `-` so locale-dependent labels that Postgres accepts are not
9
+ * rejected here. Full Postgres parsing still happens at the database.
10
+ */
11
+
12
+ /** Word body chars: locale letters/digits, underscore, hyphen. */
13
+ const WORD_CHAR = /[\p{L}\p{N}_-]/u;
14
+
15
+ function isWordChar(ch: string): boolean {
16
+ return WORD_CHAR.test(ch);
17
+ }
18
+
19
+ function isDigit(ch: string): boolean {
20
+ return ch >= "0" && ch <= "9";
21
+ }
22
+
23
+ function fail(kind: "lquery" | "ltxtquery", message: string, value: string): never {
24
+ throw new Error(`${kind} ${message}: "${value}"`);
25
+ }
26
+
27
+ /**
28
+ * Parse an optional `{n}`, `{n,}`, `{,m}`, `{n,m}`, or `{,}` quantifier starting
29
+ * at `i`. Returns the index after the closing `}`, or `i` when no quantifier
30
+ * begins here. Throws on a malformed `{...}`.
31
+ */
32
+ function consumeQuantifier(
33
+ kind: "lquery" | "ltxtquery",
34
+ value: string,
35
+ i: number,
36
+ allow: boolean,
37
+ ): number {
38
+ if (value[i] !== "{") return i;
39
+ if (!allow) {
40
+ fail(kind, `syntax error at character ${i + 1}`, value);
41
+ }
42
+ let j = i + 1;
43
+ let low: number | undefined;
44
+ let high: number | undefined;
45
+ let sawComma = false;
46
+
47
+ const readNumber = (): number | undefined => {
48
+ if (j >= value.length || !isDigit(value[j]!)) return undefined;
49
+ const start = j;
50
+ while (j < value.length && isDigit(value[j]!)) j += 1;
51
+ return Number(value.slice(start, j));
52
+ };
53
+
54
+ low = readNumber();
55
+ if (j < value.length && value[j] === ",") {
56
+ sawComma = true;
57
+ j += 1;
58
+ high = readNumber();
59
+ }
60
+ if (j >= value.length || value[j] !== "}") {
61
+ fail(kind, `syntax error at character ${i + 1}`, value);
62
+ }
63
+ j += 1;
64
+
65
+ // `{}` is invalid; `{,}` (both ends open) is valid in Postgres.
66
+ if (low === undefined && high === undefined && !sawComma) {
67
+ fail(kind, `syntax error at character ${i + 1}`, value);
68
+ }
69
+ if (low !== undefined && high !== undefined && low > high) {
70
+ fail(kind, "syntax error (quantifier low > high)", value);
71
+ }
72
+ return j;
73
+ }
74
+
75
+ /** Consume `@` / `*` / `%` modifiers that follow a non-star word. */
76
+ function consumeModifiers(value: string, i: number): number {
77
+ while (i < value.length) {
78
+ const ch = value[i]!;
79
+ if (ch === "@" || ch === "*" || ch === "%") {
80
+ i += 1;
81
+ continue;
82
+ }
83
+ break;
84
+ }
85
+ return i;
86
+ }
87
+
88
+ function consumeWord(kind: "lquery" | "ltxtquery", value: string, i: number): number {
89
+ if (i >= value.length || !isWordChar(value[i]!)) {
90
+ fail(kind, `syntax error at character ${i + 1}`, value);
91
+ }
92
+ let j = i;
93
+ while (j < value.length && isWordChar(value[j]!)) j += 1;
94
+ return j;
95
+ }
96
+
97
+ /**
98
+ * One lquery level: either `*` + optional quantifier, or an optional `!`
99
+ * followed by `word mods? (| word mods?)*` and an optional trailing quantifier.
100
+ */
101
+ function consumeLqueryLevel(value: string, i: number): number {
102
+ if (value[i] === "*") {
103
+ // Star wildcards cannot take `@`/`%` modifiers; only an optional quantifier.
104
+ return consumeQuantifier("lquery", value, i + 1, true);
105
+ }
106
+
107
+ let j = i;
108
+ if (value[j] === "!") {
109
+ j += 1;
110
+ }
111
+
112
+ j = consumeWord("lquery", value, j);
113
+ j = consumeModifiers(value, j);
114
+
115
+ while (j < value.length && value[j] === "|") {
116
+ j += 1;
117
+ j = consumeWord("lquery", value, j);
118
+ j = consumeModifiers(value, j);
119
+ }
120
+
121
+ return consumeQuantifier("lquery", value, j, true);
122
+ }
123
+
124
+ export function assertValidLquery(value: unknown): asserts value is string {
125
+ if (typeof value !== "string") {
126
+ throw new Error("lquery pattern must be a string");
127
+ }
128
+ if (value.length === 0) {
129
+ throw new Error('lquery pattern must not be empty: ""');
130
+ }
131
+
132
+ let i = 0;
133
+ i = consumeLqueryLevel(value, i);
134
+ while (i < value.length) {
135
+ if (value[i] !== ".") {
136
+ fail("lquery", `syntax error at character ${i + 1}`, value);
137
+ }
138
+ i += 1;
139
+ if (i >= value.length) {
140
+ fail("lquery", "syntax error (trailing '.' / empty label)", value);
141
+ }
142
+ i = consumeLqueryLevel(value, i);
143
+ }
144
+ }
145
+
146
+ export function assertValidLqueryArray(value: unknown): asserts value is readonly string[] {
147
+ if (!Array.isArray(value)) {
148
+ throw new Error("lquery[] patterns must be a string array");
149
+ }
150
+ for (let index = 0; index < value.length; index += 1) {
151
+ const entry = value[index];
152
+ try {
153
+ assertValidLquery(entry);
154
+ } catch (error) {
155
+ const message = error instanceof Error ? error.message : String(error);
156
+ throw new Error(`lquery[] patterns[${index}]: ${message}`);
157
+ }
158
+ }
159
+ }
160
+
161
+ type LtxtToken =
162
+ | { readonly kind: "word"; readonly text: string }
163
+ | { readonly kind: "and" }
164
+ | { readonly kind: "or" }
165
+ | { readonly kind: "not" }
166
+ | { readonly kind: "lparen" }
167
+ | { readonly kind: "rparen" };
168
+
169
+ function tokenizeLtxtquery(value: string): LtxtToken[] {
170
+ const tokens: LtxtToken[] = [];
171
+ let i = 0;
172
+ while (i < value.length) {
173
+ const ch = value[i]!;
174
+ if (ch === " " || ch === "\t" || ch === "\n" || ch === "\r") {
175
+ i += 1;
176
+ continue;
177
+ }
178
+ if (ch === "&") {
179
+ tokens.push({ kind: "and" });
180
+ i += 1;
181
+ continue;
182
+ }
183
+ if (ch === "|") {
184
+ tokens.push({ kind: "or" });
185
+ i += 1;
186
+ continue;
187
+ }
188
+ if (ch === "!") {
189
+ tokens.push({ kind: "not" });
190
+ i += 1;
191
+ continue;
192
+ }
193
+ if (ch === "(") {
194
+ tokens.push({ kind: "lparen" });
195
+ i += 1;
196
+ continue;
197
+ }
198
+ if (ch === ")") {
199
+ tokens.push({ kind: "rparen" });
200
+ i += 1;
201
+ continue;
202
+ }
203
+ if (isWordChar(ch)) {
204
+ const start = i;
205
+ i = consumeWord("ltxtquery", value, i);
206
+ i = consumeModifiers(value, i);
207
+ tokens.push({ kind: "word", text: value.slice(start, i) });
208
+ continue;
209
+ }
210
+ fail("ltxtquery", `syntax error at character ${i + 1}`, value);
211
+ }
212
+ return tokens;
213
+ }
214
+
215
+ /**
216
+ * Recursive-descent check mirroring Postgres ltxtquery operator precedence:
217
+ * OR < AND < NOT < primary (word / parenthesized expr).
218
+ */
219
+ function parseLtxtExpr(tokens: readonly LtxtToken[], value: string): void {
220
+ let pos = 0;
221
+
222
+ const peek = (): LtxtToken | undefined => tokens[pos];
223
+ const take = (): LtxtToken => {
224
+ const tok = tokens[pos];
225
+ if (!tok) fail("ltxtquery", "syntax error (unexpected end)", value);
226
+ pos += 1;
227
+ return tok;
228
+ };
229
+
230
+ const parsePrimary = (): void => {
231
+ const tok = peek();
232
+ if (!tok) fail("ltxtquery", "syntax error (missing operand)", value);
233
+ if (tok.kind === "word") {
234
+ take();
235
+ return;
236
+ }
237
+ if (tok.kind === "lparen") {
238
+ take();
239
+ parseOr();
240
+ const close = take();
241
+ if (close.kind !== "rparen") {
242
+ fail("ltxtquery", "syntax error (expected ')')", value);
243
+ }
244
+ return;
245
+ }
246
+ fail("ltxtquery", "syntax error (missing operand)", value);
247
+ };
248
+
249
+ const parseFactor = (): void => {
250
+ while (peek()?.kind === "not") take();
251
+ parsePrimary();
252
+ };
253
+
254
+ const parseAnd = (): void => {
255
+ parseFactor();
256
+ while (peek()?.kind === "and") {
257
+ take();
258
+ parseFactor();
259
+ }
260
+ };
261
+
262
+ const parseOr = (): void => {
263
+ parseAnd();
264
+ while (peek()?.kind === "or") {
265
+ take();
266
+ parseAnd();
267
+ }
268
+ };
269
+
270
+ parseOr();
271
+ if (pos !== tokens.length) {
272
+ fail("ltxtquery", "syntax error (unexpected token)", value);
273
+ }
274
+ }
275
+
276
+ export function assertValidLtxtquery(value: unknown): asserts value is string {
277
+ if (typeof value !== "string") {
278
+ throw new Error("ltxtquery query must be a string");
279
+ }
280
+ if (value.length === 0) {
281
+ throw new Error('ltxtquery query must not be empty: ""');
282
+ }
283
+ const tokens = tokenizeLtxtquery(value);
284
+ if (tokens.length === 0) {
285
+ fail("ltxtquery", "syntax error (missing operand)", value);
286
+ }
287
+ parseLtxtExpr(tokens, value);
288
+ }
289
+
290
+ /**
291
+ * Validate a pattern argument when the caller passed a raw JS value. Expression
292
+ * / ParamRef objects are left alone so dynamic binds still round-trip to
293
+ * Postgres (which remains the authority for those).
294
+ */
295
+ export function assertPatternArg(
296
+ castType: "lquery" | "ltxtquery" | "lquery[]",
297
+ arg: unknown,
298
+ ): void {
299
+ if (arg !== null && typeof arg === "object" && !Array.isArray(arg)) {
300
+ return;
301
+ }
302
+ switch (castType) {
303
+ case "lquery":
304
+ assertValidLquery(arg);
305
+ return;
306
+ case "ltxtquery":
307
+ assertValidLtxtquery(arg);
308
+ return;
309
+ case "lquery[]":
310
+ assertValidLqueryArray(arg);
311
+ return;
312
+ }
313
+ }