@polyprism/php-shared 0.2.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.
@@ -0,0 +1,231 @@
1
+ import { renderPhpDoc } from "./phpdoc.js";
2
+ function renderPhpJsonType(opts) {
3
+ const issues = [];
4
+ const pushIssue = (severity, message) => {
5
+ issues.push({ severity, context: opts.diagnosticContext, message });
6
+ };
7
+ const parsed = parseTopLevelObject(opts.typeExpression);
8
+ if (!parsed) {
9
+ pushIssue(
10
+ "warning",
11
+ `@json type expression for "${opts.typeName}" is not a parseable object literal \u2014 the PHP emitter only generates classes from top-level \`{ ... }\` shapes. The field falls back to \`mixed\`. Use \`@type("\\\\App\\\\YourType")\` to point at a hand-written PHP class instead.`
12
+ );
13
+ return { source: "", issues };
14
+ }
15
+ const phpFields = [];
16
+ for (const prop of parsed) {
17
+ const mapping = translateTsTypeToPhp(prop.type);
18
+ for (const w of mapping.warnings) {
19
+ pushIssue("warning", `@json field "${opts.typeName}.${prop.name}": ${w}`);
20
+ }
21
+ const phpDoc = mapping.phpDocType ? ` /**
22
+ * @var ${mapping.phpDocType}
23
+ */
24
+ ` : "";
25
+ const nullableForOptional = prop.optional && !alreadyNullable(mapping.phpType) ? `?${mapping.phpType}` : mapping.phpType;
26
+ const defaultExpr = prop.optional ? " = null" : "";
27
+ const readonlyPrefix = opts.declarationStyle === "class" ? "readonly " : "";
28
+ phpFields.push(
29
+ `${phpDoc} public ${readonlyPrefix}${nullableForOptional} $${prop.name}${defaultExpr},`
30
+ );
31
+ }
32
+ const required = phpFields.filter((line) => !line.includes(" = null,"));
33
+ const optional = phpFields.filter((line) => line.includes(" = null,"));
34
+ const promotedBlock = phpFields.length > 0 ? `
35
+ ${[...required, ...optional].join("\n")}
36
+ ` : "";
37
+ const headerDoc = renderPhpDoc(
38
+ {
39
+ hide: false,
40
+ deprecated: null,
41
+ json: null,
42
+ type: null,
43
+ name: null,
44
+ normalise: null,
45
+ coerce: null,
46
+ noCoerce: false,
47
+ documentation: `Generated value object for a Prisma Json field. Construct from a decoded JSON payload \u2014 e.g. \`new ${opts.typeName}(...$payload)\` or by explicit named arguments.`,
48
+ rawAnnotations: [],
49
+ parseIssues: []
50
+ },
51
+ { indent: 0 }
52
+ );
53
+ const classDecl = opts.declarationStyle === "readonly" ? `final readonly class ${opts.typeName}` : `final class ${opts.typeName}`;
54
+ const source = [
55
+ "<?php",
56
+ "",
57
+ "declare(strict_types=1);",
58
+ "",
59
+ `namespace ${opts.namespace};`,
60
+ "",
61
+ `${headerDoc}${classDecl}
62
+ {
63
+ public function __construct(${promotedBlock}) {}
64
+ }`,
65
+ ""
66
+ ].join("\n");
67
+ return { source, issues };
68
+ }
69
+ function parseTopLevelObject(expr) {
70
+ const trimmed = expr.trim();
71
+ if (!trimmed.startsWith("{") || !trimmed.endsWith("}")) return null;
72
+ const inner = trimmed.slice(1, -1).trim();
73
+ if (inner.length === 0) return [];
74
+ const parts = splitTopLevel(inner);
75
+ const props = [];
76
+ for (const part of parts) {
77
+ const parsed = parseProperty(part);
78
+ if (!parsed) return null;
79
+ props.push(parsed);
80
+ }
81
+ return props;
82
+ }
83
+ function parseProperty(part) {
84
+ const colonIdx = findUnnestedChar(part, ":");
85
+ if (colonIdx < 0) return null;
86
+ const nameRaw = part.slice(0, colonIdx).trim();
87
+ const typeRaw = part.slice(colonIdx + 1).trim();
88
+ if (nameRaw.length === 0 || typeRaw.length === 0) return null;
89
+ let name = nameRaw;
90
+ let optional = false;
91
+ if (name.endsWith("?")) {
92
+ optional = true;
93
+ name = name.slice(0, -1).trim();
94
+ }
95
+ if (!/^[A-Za-z_][\w]*$/.test(name)) return null;
96
+ return { name, optional, type: typeRaw };
97
+ }
98
+ function translateTsTypeToPhp(tsType) {
99
+ const trimmed = tsType.trim();
100
+ if (trimmed.endsWith("[]")) {
101
+ const elementType = trimmed.slice(0, -2).trim();
102
+ const elementMapping = translateTsTypeToPhp(elementType);
103
+ if (elementMapping.warnings.length > 0) {
104
+ return {
105
+ phpType: "array",
106
+ phpDocType: "array<int, mixed>",
107
+ warnings: elementMapping.warnings
108
+ };
109
+ }
110
+ return {
111
+ phpType: "array",
112
+ phpDocType: `array<int, ${elementMapping.phpDocType ?? elementMapping.phpType}>`,
113
+ warnings: []
114
+ };
115
+ }
116
+ if (trimmed.startsWith("{")) {
117
+ const nestedProps = parseTopLevelObject(trimmed);
118
+ if (!nestedProps) {
119
+ return {
120
+ phpType: "mixed",
121
+ phpDocType: null,
122
+ warnings: [`nested object shape "${trimmed}" could not be parsed; falling back to mixed.`]
123
+ };
124
+ }
125
+ const shapeParts = [];
126
+ const collectedWarnings = [];
127
+ for (const np of nestedProps) {
128
+ const nm = translateTsTypeToPhp(np.type);
129
+ collectedWarnings.push(...nm.warnings);
130
+ const optionalMarker = np.optional ? "?" : "";
131
+ const phpDocInner = nm.phpDocType ?? nm.phpType;
132
+ shapeParts.push(`${np.name}${optionalMarker}: ${phpDocInner}`);
133
+ }
134
+ return {
135
+ phpType: "array",
136
+ phpDocType: `array{${shapeParts.join(", ")}}`,
137
+ warnings: collectedWarnings
138
+ };
139
+ }
140
+ switch (trimmed) {
141
+ case "string":
142
+ return { phpType: "string", phpDocType: null, warnings: [] };
143
+ case "number":
144
+ return { phpType: "float", phpDocType: null, warnings: [] };
145
+ case "boolean":
146
+ return { phpType: "bool", phpDocType: null, warnings: [] };
147
+ case "null":
148
+ return { phpType: "null", phpDocType: null, warnings: [] };
149
+ case "unknown":
150
+ case "any":
151
+ return { phpType: "mixed", phpDocType: null, warnings: [] };
152
+ }
153
+ return {
154
+ phpType: "mixed",
155
+ phpDocType: null,
156
+ warnings: [
157
+ `TS type "${trimmed}" is not in the PHP @json supported subset (primitives, nested objects, arrays of primitives, optional markers). Falling back to mixed. For richer typing, use \`@type("\\\\App\\\\YourType")\` to point at a hand-written PHP class.`
158
+ ]
159
+ };
160
+ }
161
+ function alreadyNullable(phpType) {
162
+ return phpType.startsWith("?") || phpType === "mixed" || phpType === "null";
163
+ }
164
+ function findUnnestedChar(s, char) {
165
+ let depth = 0;
166
+ let inString = null;
167
+ let isEscaped = false;
168
+ for (let i = 0; i < s.length; i++) {
169
+ const ch = s[i];
170
+ if (isEscaped) {
171
+ isEscaped = false;
172
+ continue;
173
+ }
174
+ if (ch === "\\") {
175
+ isEscaped = true;
176
+ continue;
177
+ }
178
+ if (inString) {
179
+ if (ch === inString) inString = null;
180
+ continue;
181
+ }
182
+ if (ch === '"' || ch === "'" || ch === "`") {
183
+ inString = ch;
184
+ continue;
185
+ }
186
+ if (ch === "{" || ch === "(" || ch === "[" || ch === "<") depth++;
187
+ else if (ch === "}" || ch === ")" || ch === "]" || ch === ">") depth--;
188
+ else if (depth === 0 && ch === char) return i;
189
+ }
190
+ return -1;
191
+ }
192
+ function splitTopLevel(inner) {
193
+ const result = [];
194
+ let depth = 0;
195
+ let inString = null;
196
+ let isEscaped = false;
197
+ let start = 0;
198
+ for (let i = 0; i < inner.length; i++) {
199
+ const ch = inner[i];
200
+ if (isEscaped) {
201
+ isEscaped = false;
202
+ continue;
203
+ }
204
+ if (ch === "\\") {
205
+ isEscaped = true;
206
+ continue;
207
+ }
208
+ if (inString) {
209
+ if (ch === inString) inString = null;
210
+ continue;
211
+ }
212
+ if (ch === '"' || ch === "'" || ch === "`") {
213
+ inString = ch;
214
+ continue;
215
+ }
216
+ if (ch === "{" || ch === "(" || ch === "[" || ch === "<") depth++;
217
+ else if (ch === "}" || ch === ")" || ch === "]" || ch === ">") depth--;
218
+ else if ((ch === "," || ch === ";") && depth === 0) {
219
+ const part = inner.slice(start, i).trim();
220
+ if (part.length > 0) result.push(part);
221
+ start = i + 1;
222
+ }
223
+ }
224
+ const last = inner.slice(start).trim();
225
+ if (last.length > 0) result.push(last);
226
+ return result;
227
+ }
228
+ export {
229
+ renderPhpJsonType
230
+ };
231
+ //# sourceMappingURL=render-json-type.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/render-json-type.ts"],"sourcesContent":["// Renders a PHP `final readonly class` for an inline `@json(...)` annotation.\n//\n// The two inline forms (anonymous and named) carry a TypeScript-shaped\n// expression string in the IR (e.g. `{ street: string, city: string,\n// addr?: { line1: string } }`). This module parses a small subset of\n// that syntax and emits a corresponding PHP readonly value class.\n//\n// The supported subset is deliberately tight. Anything outside it falls\n// back to `mixed` with a warning Diagnostic so the user can either restate\n// the shape in supported terms or use `@type(\"\\\\App\\\\YourType\")` to point\n// at a hand-written PHP class.\n//\n// Supported:\n// - Flat objects: `{ a: string, b: int }`\n// - Optional fields: `{ a?: string }` → PHP `?string = null`\n// - Nested objects: `{ a: { b: string } }` → PHPDoc array{b: string}\n// - Arrays of primitives: `{ tags: string[] }` → PHPDoc array<int, string>\n// - Primitives: string, number, boolean, null, unknown, any\n//\n// Not supported (warn + fallback to `mixed`):\n// - Unions: `string | number`\n// - Generics: `Record<K, V>`, `Map<K, V>`, etc.\n// - Discriminated unions, tuples, intersection types\n// - Identifier references: `MyOtherType` inside a JSON expression\n// (use a separate @json(MyOtherType) or @type override instead)\n//\n// Nested objects intentionally do NOT spawn separate sub-classes. We use\n// PHPStan/Psalm-readable `array{...}` shape annotations on a PHP `array`\n// property instead. Reasoning: spawning `BillingAddressLocation`,\n// `BillingAddressLocationCoords`, etc., would proliferate classes that\n// don't really earn their keep, and PHPStan-readable array shapes give\n// the same static-analysis story without the file explosion.\n\nimport type { Diagnostic } from \"./diagnostics.js\";\nimport { renderPhpDoc } from \"./phpdoc.js\";\n\nexport interface RenderJsonTypeOptions {\n /** Top-level class name, e.g. \"BillingAddress\". */\n readonly typeName: string;\n /** Raw TS expression from the @json annotation, e.g. `{ street: string, city: string }`. */\n readonly typeExpression: string;\n /** PHP namespace the generated class lives in, e.g. `\"Generated\\\\JsonTypes\"`. */\n readonly namespace: string;\n /**\n * Context string for diagnostics — typically `\"Model.field\"` so users can\n * find the schema source of an unsupported-shape warning.\n */\n readonly diagnosticContext: string;\n /**\n * Determines the readonly syntax used:\n * - `\"class\"`: emit `final class Foo` with per-property `public readonly`\n * (works on PHP 8.1). Matches the floor of the `php-class` generator.\n * - `\"readonly\"`: emit `final readonly class Foo` (class-level modifier,\n * PHP 8.2+). Matches the floor of the `php-readonly` generator.\n * Semantically both produce a value object whose properties can't be\n * reassigned after construction; only the syntax differs.\n */\n readonly declarationStyle: \"class\" | \"readonly\";\n}\n\nexport interface RenderJsonTypeResult {\n readonly source: string;\n readonly issues: readonly Diagnostic[];\n}\n\nexport function renderPhpJsonType(opts: RenderJsonTypeOptions): RenderJsonTypeResult {\n const issues: Diagnostic[] = [];\n const pushIssue = (severity: \"warning\" | \"error\", message: string): void => {\n issues.push({ severity, context: opts.diagnosticContext, message });\n };\n\n const parsed = parseTopLevelObject(opts.typeExpression);\n if (!parsed) {\n pushIssue(\n \"warning\",\n `@json type expression for \"${opts.typeName}\" is not a parseable object literal — ` +\n \"the PHP emitter only generates classes from top-level `{ ... }` shapes. \" +\n 'The field falls back to `mixed`. Use `@type(\"\\\\\\\\App\\\\\\\\YourType\")` to point ' +\n \"at a hand-written PHP class instead.\",\n );\n return { source: \"\", issues };\n }\n\n const phpFields: string[] = [];\n for (const prop of parsed) {\n const mapping = translateTsTypeToPhp(prop.type);\n for (const w of mapping.warnings) {\n pushIssue(\"warning\", `@json field \"${opts.typeName}.${prop.name}\": ${w}`);\n }\n\n // Required-first / optional-second ordering happens at the parent class\n // level (render-model.ts) too, but for JSON value classes we keep the\n // simpler \"trust the user's ordering\" stance — JSON object property\n // order is rarely load-bearing and re-sorting it would surprise users\n // who match the constructor to the original JSON shape.\n // 8-space indent matches the constructor parameter indent below, so\n // the PHPDoc block aligns visually with the property it documents.\n const phpDoc = mapping.phpDocType\n ? ` /**\\n * @var ${mapping.phpDocType}\\n */\\n`\n : \"\";\n\n const nullableForOptional =\n prop.optional && !alreadyNullable(mapping.phpType) ? `?${mapping.phpType}` : mapping.phpType;\n const defaultExpr = prop.optional ? \" = null\" : \"\";\n // For `php-class` mode (PHP 8.1 floor), stamp `readonly` on each\n // property individually since the class-level `readonly` modifier is\n // PHP 8.2-only. For `php-readonly` mode, the class-level modifier\n // already covers every property — adding per-property `readonly`\n // there would be redundant and is rejected by PHP 8.2+.\n const readonlyPrefix = opts.declarationStyle === \"class\" ? \"readonly \" : \"\";\n phpFields.push(\n `${phpDoc} public ${readonlyPrefix}${nullableForOptional} $${prop.name}${defaultExpr},`,\n );\n }\n\n // PHP 8.4-deprecation safe: optional (nullable + null default) come after\n // required, preserving JSON-property order within each group.\n const required = phpFields.filter((line) => !line.includes(\" = null,\"));\n const optional = phpFields.filter((line) => line.includes(\" = null,\"));\n const promotedBlock =\n phpFields.length > 0 ? `\\n${[...required, ...optional].join(\"\\n\")}\\n ` : \"\";\n\n const headerDoc = renderPhpDoc(\n {\n hide: false,\n deprecated: null,\n json: null,\n type: null,\n name: null,\n normalise: null,\n coerce: null,\n noCoerce: false,\n documentation: `Generated value object for a Prisma Json field. Construct from a decoded JSON payload — e.g. \\`new ${opts.typeName}(...$payload)\\` or by explicit named arguments.`,\n rawAnnotations: [],\n parseIssues: [],\n },\n { indent: 0 },\n );\n\n // Class declaration keyword: `final class` + per-property readonly works\n // on PHP 8.1+ and matches the `php-class` floor; `final readonly class`\n // is the cleaner 8.2+ form and matches `php-readonly`. The semantics are\n // identical from the caller's perspective: every property is set in the\n // constructor and never reassigned.\n const classDecl =\n opts.declarationStyle === \"readonly\"\n ? `final readonly class ${opts.typeName}`\n : `final class ${opts.typeName}`;\n\n const source = [\n \"<?php\",\n \"\",\n \"declare(strict_types=1);\",\n \"\",\n `namespace ${opts.namespace};`,\n \"\",\n `${headerDoc}${classDecl}\\n{\\n public function __construct(${promotedBlock}) {}\\n}`,\n \"\",\n ].join(\"\\n\");\n\n return { source, issues };\n}\n\n// ---------- parser ----------\n\ninterface ParsedProperty {\n readonly name: string;\n readonly optional: boolean;\n /** Raw TS type expression text, trimmed. */\n readonly type: string;\n}\n\n/**\n * Parse `{ name: type, ... }` into a flat property list. Returns null if\n * the input isn't shaped like a top-level object literal.\n */\nfunction parseTopLevelObject(expr: string): ParsedProperty[] | null {\n const trimmed = expr.trim();\n if (!trimmed.startsWith(\"{\") || !trimmed.endsWith(\"}\")) return null;\n\n const inner = trimmed.slice(1, -1).trim();\n if (inner.length === 0) return [];\n\n const parts = splitTopLevel(inner);\n const props: ParsedProperty[] = [];\n for (const part of parts) {\n const parsed = parseProperty(part);\n if (!parsed) return null;\n props.push(parsed);\n }\n return props;\n}\n\n/** Parse `name(?)?: type` into the structured form. */\nfunction parseProperty(part: string): ParsedProperty | null {\n // The property name runs up to the first `:` or `?:`. Names can be\n // identifiers; quoted-string keys (`\"foo\": ...`) aren't supported in v0.\n const colonIdx = findUnnestedChar(part, \":\");\n if (colonIdx < 0) return null;\n\n const nameRaw = part.slice(0, colonIdx).trim();\n const typeRaw = part.slice(colonIdx + 1).trim();\n if (nameRaw.length === 0 || typeRaw.length === 0) return null;\n\n let name = nameRaw;\n let optional = false;\n if (name.endsWith(\"?\")) {\n optional = true;\n name = name.slice(0, -1).trim();\n }\n // PHP variable names can start with `[A-Za-z_]` and continue with `[\\w]`.\n // TS allows `$` in identifiers, but `public string $$foo` is a PHP parse\n // error — silently passing `$`-prefixed names through would write a\n // broken file the consumer only discovers when their PHP autoload trips.\n // Reject here so the parent emits a \"not a parseable object literal\"\n // warning and the Json field falls back to `mixed`.\n if (!/^[A-Za-z_][\\w]*$/.test(name)) return null;\n\n return { name, optional, type: typeRaw };\n}\n\ninterface PhpTypeMapping {\n readonly phpType: string;\n /** PHPDoc `@var ...` text when richer typing is needed (lists, nested shapes). */\n readonly phpDocType: string | null;\n readonly warnings: readonly string[];\n}\n\n/**\n * Translate a TS type expression to PHP. Returns `mixed` + a warning for\n * anything outside the supported subset, never throws.\n */\nfunction translateTsTypeToPhp(tsType: string): PhpTypeMapping {\n const trimmed = tsType.trim();\n\n // Trailing `[]` denotes an array. Strip and recurse.\n if (trimmed.endsWith(\"[]\")) {\n const elementType = trimmed.slice(0, -2).trim();\n // Only support primitive element types in v0 — arrays of nested objects\n // would need nested PHPDoc array<int, array{...}> which is supportable\n // but increases the v0 surface area beyond what users have asked for.\n const elementMapping = translateTsTypeToPhp(elementType);\n if (elementMapping.warnings.length > 0) {\n return {\n phpType: \"array\",\n phpDocType: \"array<int, mixed>\",\n warnings: elementMapping.warnings,\n };\n }\n return {\n phpType: \"array\",\n phpDocType: `array<int, ${elementMapping.phpDocType ?? elementMapping.phpType}>`,\n warnings: [],\n };\n }\n\n // Nested object shape — emit as PHP `array` with PHPDoc array{...} hint.\n if (trimmed.startsWith(\"{\")) {\n const nestedProps = parseTopLevelObject(trimmed);\n if (!nestedProps) {\n return {\n phpType: \"mixed\",\n phpDocType: null,\n warnings: [`nested object shape \"${trimmed}\" could not be parsed; falling back to mixed.`],\n };\n }\n const shapeParts: string[] = [];\n const collectedWarnings: string[] = [];\n for (const np of nestedProps) {\n const nm = translateTsTypeToPhp(np.type);\n collectedWarnings.push(...nm.warnings);\n const optionalMarker = np.optional ? \"?\" : \"\";\n const phpDocInner = nm.phpDocType ?? nm.phpType;\n shapeParts.push(`${np.name}${optionalMarker}: ${phpDocInner}`);\n }\n return {\n phpType: \"array\",\n phpDocType: `array{${shapeParts.join(\", \")}}`,\n warnings: collectedWarnings,\n };\n }\n\n // Primitives + a couple of TS-specific \"absorb-anything\" types.\n switch (trimmed) {\n case \"string\":\n return { phpType: \"string\", phpDocType: null, warnings: [] };\n case \"number\":\n // TS `number` covers both int and float. PHP's `float` accepts both\n // (ints widen automatically), so it's the safest single-type mapping.\n // Document this choice in the README.\n return { phpType: \"float\", phpDocType: null, warnings: [] };\n case \"boolean\":\n return { phpType: \"bool\", phpDocType: null, warnings: [] };\n case \"null\":\n // Rare standalone — usually appears in a union. If a field's type\n // is literally just `null`, PHP's null type works.\n return { phpType: \"null\", phpDocType: null, warnings: [] };\n case \"unknown\":\n case \"any\":\n return { phpType: \"mixed\", phpDocType: null, warnings: [] };\n }\n\n // Anything else (unions, generics, identifiers, tuples) → fallback +\n // warning so the user knows their type lost fidelity.\n return {\n phpType: \"mixed\",\n phpDocType: null,\n warnings: [\n `TS type \"${trimmed}\" is not in the PHP @json supported subset (primitives, ` +\n \"nested objects, arrays of primitives, optional markers). Falling back to mixed. \" +\n 'For richer typing, use `@type(\"\\\\\\\\App\\\\\\\\YourType\")` to point at a hand-written PHP class.',\n ],\n };\n}\n\nfunction alreadyNullable(phpType: string): boolean {\n return phpType.startsWith(\"?\") || phpType === \"mixed\" || phpType === \"null\";\n}\n\n// ---------- balanced-bracket utilities ----------\n//\n// These mirror the helpers in @polyprism/core's format-type.ts. They're\n// duplicated rather than imported because format-type's helpers aren't\n// exported, and tracking a cross-package contract for a 40-line walker\n// isn't worth the coupling.\n\nfunction findUnnestedChar(s: string, char: string): number {\n let depth = 0;\n let inString: '\"' | \"'\" | \"`\" | null = null;\n let isEscaped = false;\n for (let i = 0; i < s.length; i++) {\n const ch = s[i]!;\n if (isEscaped) {\n isEscaped = false;\n continue;\n }\n if (ch === \"\\\\\") {\n isEscaped = true;\n continue;\n }\n if (inString) {\n if (ch === inString) inString = null;\n continue;\n }\n if (ch === '\"' || ch === \"'\" || ch === \"`\") {\n inString = ch;\n continue;\n }\n if (ch === \"{\" || ch === \"(\" || ch === \"[\" || ch === \"<\") depth++;\n else if (ch === \"}\" || ch === \")\" || ch === \"]\" || ch === \">\") depth--;\n else if (depth === 0 && ch === char) return i;\n }\n return -1;\n}\n\n/**\n * Split a top-level property list by `,` or `;`. Both separators are\n * accepted because TS allows either inside object types and interface\n * member lists; users writing inline @json shapes sometimes paste from\n * existing TS declarations that use semicolons.\n */\nfunction splitTopLevel(inner: string): string[] {\n const result: string[] = [];\n let depth = 0;\n let inString: '\"' | \"'\" | \"`\" | null = null;\n let isEscaped = false;\n let start = 0;\n\n for (let i = 0; i < inner.length; i++) {\n const ch = inner[i]!;\n if (isEscaped) {\n isEscaped = false;\n continue;\n }\n if (ch === \"\\\\\") {\n isEscaped = true;\n continue;\n }\n if (inString) {\n if (ch === inString) inString = null;\n continue;\n }\n if (ch === '\"' || ch === \"'\" || ch === \"`\") {\n inString = ch;\n continue;\n }\n if (ch === \"{\" || ch === \"(\" || ch === \"[\" || ch === \"<\") depth++;\n else if (ch === \"}\" || ch === \")\" || ch === \"]\" || ch === \">\") depth--;\n else if ((ch === \",\" || ch === \";\") && depth === 0) {\n const part = inner.slice(start, i).trim();\n if (part.length > 0) result.push(part);\n start = i + 1;\n }\n }\n const last = inner.slice(start).trim();\n if (last.length > 0) result.push(last);\n return result;\n}\n"],"mappings":"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","names":[]}
@@ -0,0 +1,25 @@
1
+ import { ModelDef, PolyPrismIR, PolyPrismConfig } from '@polyprism/core';
2
+ import { Diagnostic } from './diagnostics.js';
3
+
4
+ type PhpDeclarationStyle = "class" | "readonly";
5
+ interface RenderPhpModelOptions {
6
+ readonly model: ModelDef;
7
+ readonly ir: PolyPrismIR;
8
+ readonly config: PolyPrismConfig;
9
+ readonly declarationStyle: PhpDeclarationStyle;
10
+ /** Root namespace for model classes, e.g. `"Generated\\Models"`. */
11
+ readonly modelsNamespace: string;
12
+ /** Root namespace for enum classes, e.g. `"Generated\\Enums"`. */
13
+ readonly enumsNamespace: string;
14
+ /** Root namespace for generated JSON value classes, e.g. `"Generated\\JsonTypes"`. */
15
+ readonly jsonTypesNamespace: string;
16
+ /** Names of JSON value classes that were successfully generated this run. */
17
+ readonly jsonTypeClassNames: ReadonlySet<string>;
18
+ }
19
+ interface RenderPhpModelResult {
20
+ readonly source: string;
21
+ readonly issues: readonly Diagnostic[];
22
+ }
23
+ declare function renderPhpModel(opts: RenderPhpModelOptions): RenderPhpModelResult;
24
+
25
+ export { type PhpDeclarationStyle, type RenderPhpModelOptions, type RenderPhpModelResult, renderPhpModel };
@@ -0,0 +1,158 @@
1
+ import { resolveFieldIdent, resolveTypeIdent } from "@polyprism/core";
2
+ import { buildNativeTypeTag, renderPhpDoc } from "./phpdoc.js";
3
+ import { mapFieldPhpType } from "./type-mapper.js";
4
+ import { UseCollector } from "./use-collector.js";
5
+ function renderPhpModel(opts) {
6
+ const {
7
+ model,
8
+ ir,
9
+ config,
10
+ declarationStyle,
11
+ modelsNamespace,
12
+ enumsNamespace,
13
+ jsonTypesNamespace,
14
+ jsonTypeClassNames
15
+ } = opts;
16
+ const issues = [];
17
+ const collectDiagnostic = (d) => {
18
+ issues.push(d);
19
+ };
20
+ const enumFqnLookup = new Map(
21
+ ir.enums.map((e) => [
22
+ e.name,
23
+ `${enumsNamespace}\\${resolveTypeIdent({
24
+ schemaName: e.name,
25
+ override: e.annotations.name,
26
+ convention: config.naming.typeNaming
27
+ })}`
28
+ ])
29
+ );
30
+ const modelFqnLookup = new Map(
31
+ ir.models.map((m) => [
32
+ m.name,
33
+ `${modelsNamespace}\\${resolveTypeIdent({
34
+ schemaName: m.name,
35
+ override: m.annotations.name,
36
+ convention: config.naming.typeNaming
37
+ })}`
38
+ ])
39
+ );
40
+ const selfIdent = resolveTypeIdent({
41
+ schemaName: model.name,
42
+ override: model.annotations.name,
43
+ convention: config.naming.typeNaming
44
+ });
45
+ const selfFqn = `${modelsNamespace}\\${selfIdent}`;
46
+ const uses = new UseCollector(modelsNamespace);
47
+ const entries = [];
48
+ for (const field of model.fields) {
49
+ if (field.annotations.hide) continue;
50
+ const fieldIdent = resolveFieldIdent({
51
+ schemaName: field.name,
52
+ override: field.annotations.name,
53
+ convention: config.naming.fieldNaming
54
+ });
55
+ const mapping = mapFieldPhpType({
56
+ field,
57
+ modelSchemaName: model.name,
58
+ uses,
59
+ enumFqnLookup,
60
+ modelFqnLookup,
61
+ selfModelFqn: selfFqn,
62
+ jsonTypesNamespace,
63
+ jsonTypeClassNames,
64
+ onDiagnostic: collectDiagnostic
65
+ });
66
+ const defaultExpr = formatPhpDefault(field, enumFqnLookup, uses);
67
+ const propertyDoc = renderPhpDoc(field.annotations, {
68
+ indent: 8,
69
+ extraTags: collectFieldExtraTags(field, mapping.listElementDoc)
70
+ });
71
+ const propLine = defaultExpr === null ? ` public ${mapping.signatureType} $${fieldIdent}` : ` public ${mapping.signatureType} $${fieldIdent} = ${defaultExpr}`;
72
+ entries.push({ line: `${propertyDoc}${propLine},`, hasDefault: defaultExpr !== null });
73
+ }
74
+ const promotedLines = [
75
+ ...entries.filter((e) => !e.hasDefault).map((e) => e.line),
76
+ ...entries.filter((e) => e.hasDefault).map((e) => e.line)
77
+ ];
78
+ const promotedBlock = promotedLines.length > 0 ? `
79
+ ${promotedLines.join("\n")}
80
+ ` : "";
81
+ const usesBlock = uses.render();
82
+ const headerDoc = renderPhpDoc(model.annotations, { indent: 0 });
83
+ const classKeywords = declarationStyle === "readonly" ? "final readonly class" : "final class";
84
+ const source = [
85
+ "<?php",
86
+ "",
87
+ "declare(strict_types=1);",
88
+ "",
89
+ `namespace ${modelsNamespace};`,
90
+ "",
91
+ usesBlock + `${headerDoc}${classKeywords} ${selfIdent}
92
+ {
93
+ public function __construct(${promotedBlock}) {}
94
+ }`,
95
+ ""
96
+ ].join("\n");
97
+ return { source, issues };
98
+ }
99
+ function collectFieldExtraTags(field, listElementDoc) {
100
+ const tags = [];
101
+ if (listElementDoc !== null) {
102
+ tags.push(`@var array<int, ${listElementDoc}>`);
103
+ }
104
+ const nativeTag = buildNativeTypeTag(field.nativeType);
105
+ if (nativeTag) tags.push(nativeTag);
106
+ return tags;
107
+ }
108
+ function formatPhpDefault(field, enumFqnLookup, uses) {
109
+ if (field.isList) return "[]";
110
+ if (!field.isRequired && !field.hasDefaultValue) return "null";
111
+ if (!field.hasDefaultValue || !field.default) return null;
112
+ const d = field.default;
113
+ if (d.kind === "literal") {
114
+ return formatLiteralDefault(field, d.value, enumFqnLookup, uses);
115
+ }
116
+ if (d.kind === "list") return "[]";
117
+ if (d.name === "now") return "new \\DateTimeImmutable()";
118
+ return null;
119
+ }
120
+ function formatLiteralDefault(field, value, enumFqnLookup, uses) {
121
+ if (value === null) return "null";
122
+ if (typeof value === "string") {
123
+ if (field.type.kind === "scalar" && field.type.scalar === "String") {
124
+ return phpSingleQuoteString(value);
125
+ }
126
+ if (field.type.kind === "enum") {
127
+ const enumFqn = enumFqnLookup.get(field.type.enumName);
128
+ if (!enumFqn) return null;
129
+ const shortName = uses.add(enumFqn);
130
+ return `${shortName}::${value}`;
131
+ }
132
+ return null;
133
+ }
134
+ if (typeof value === "number") {
135
+ if (field.type.kind === "scalar" && field.type.scalar === "Int") {
136
+ return String(value);
137
+ }
138
+ if (field.type.kind === "scalar" && field.type.scalar === "Float") {
139
+ return Number.isInteger(value) ? `${value}.0` : String(value);
140
+ }
141
+ return null;
142
+ }
143
+ if (typeof value === "boolean") {
144
+ if (field.type.kind === "scalar" && field.type.scalar === "Boolean") {
145
+ return value ? "true" : "false";
146
+ }
147
+ return null;
148
+ }
149
+ return null;
150
+ }
151
+ function phpSingleQuoteString(value) {
152
+ const escaped = value.replace(/\\/g, "\\\\").replace(/'/g, "\\'");
153
+ return `'${escaped}'`;
154
+ }
155
+ export {
156
+ renderPhpModel
157
+ };
158
+ //# sourceMappingURL=render-model.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/render-model.ts"],"sourcesContent":["// Renders one Prisma model as a PHP 8.1+ class.\n//\n// Two declaration styles:\n// - \"class\" → `final class User` (PHP 8.1+)\n// Public typed properties via constructor property promotion.\n// Mutable; the caller can assign `$user->email = 'x';`.\n// - \"readonly\" → `final readonly class User` (PHP 8.2+)\n// Same shape, but every property is read-only after the\n// constructor returns. Idiomatic for value objects and\n// DTOs that should never mutate after hydration.\n//\n// Both styles use constructor property promotion — the canonical PHP 8\n// shorthand that combines the parameter list with the property\n// declarations:\n//\n// public function __construct(\n// public string $id,\n// public ?string $name = null,\n// public int $points = 0,\n// ) {}\n//\n// What this DOESN'T do (intentional, v0 scope):\n// - No setters with @coerce / @normalise — those are property-hook\n// features that need PHP 8.4 and a Composer-published runtime. They'll\n// ship as `@polyprism/php-domain-class` in a later release.\n// - No `from(array): static` factory — until we have a v0 user with a\n// concrete need, the constructor is enough; users hydrate from arrays\n// with `new User(...$row)` or spread arguments at the call site.\n// - No `toArray()` / JSON serialisation helper — `json_encode($user)`\n// already produces the right shape for public-property classes; the\n// opaque-property cases live in php-domain-class anyway.\n\nimport type { FieldDef, ModelDef, PolyPrismConfig, PolyPrismIR } from \"@polyprism/core\";\nimport { resolveFieldIdent, resolveTypeIdent } from \"@polyprism/core\";\n\nimport type { Diagnostic } from \"./diagnostics.js\";\nimport { buildNativeTypeTag, renderPhpDoc } from \"./phpdoc.js\";\nimport { mapFieldPhpType } from \"./type-mapper.js\";\nimport { UseCollector } from \"./use-collector.js\";\n\nexport type PhpDeclarationStyle = \"class\" | \"readonly\";\n\nexport interface RenderPhpModelOptions {\n readonly model: ModelDef;\n readonly ir: PolyPrismIR;\n readonly config: PolyPrismConfig;\n readonly declarationStyle: PhpDeclarationStyle;\n /** Root namespace for model classes, e.g. `\"Generated\\\\Models\"`. */\n readonly modelsNamespace: string;\n /** Root namespace for enum classes, e.g. `\"Generated\\\\Enums\"`. */\n readonly enumsNamespace: string;\n /** Root namespace for generated JSON value classes, e.g. `\"Generated\\\\JsonTypes\"`. */\n readonly jsonTypesNamespace: string;\n /** Names of JSON value classes that were successfully generated this run. */\n readonly jsonTypeClassNames: ReadonlySet<string>;\n}\n\nexport interface RenderPhpModelResult {\n readonly source: string;\n readonly issues: readonly Diagnostic[];\n}\n\nexport function renderPhpModel(opts: RenderPhpModelOptions): RenderPhpModelResult {\n const {\n model,\n ir,\n config,\n declarationStyle,\n modelsNamespace,\n enumsNamespace,\n jsonTypesNamespace,\n jsonTypeClassNames,\n } = opts;\n const issues: Diagnostic[] = [];\n const collectDiagnostic = (d: Diagnostic): void => {\n issues.push(d);\n };\n\n // Pre-resolve PHP class identifiers + FQNs for all enums and models. The\n // type-mapper consults these maps; only the FQN form is registered with\n // the use collector if a cross-namespace reference is needed.\n const enumFqnLookup = new Map<string, string>(\n ir.enums.map((e) => [\n e.name,\n `${enumsNamespace}\\\\${resolveTypeIdent({\n schemaName: e.name,\n override: e.annotations.name,\n convention: config.naming.typeNaming,\n })}`,\n ]),\n );\n const modelFqnLookup = new Map<string, string>(\n ir.models.map((m) => [\n m.name,\n `${modelsNamespace}\\\\${resolveTypeIdent({\n schemaName: m.name,\n override: m.annotations.name,\n convention: config.naming.typeNaming,\n })}`,\n ]),\n );\n\n const selfIdent = resolveTypeIdent({\n schemaName: model.name,\n override: model.annotations.name,\n convention: config.naming.typeNaming,\n });\n const selfFqn = `${modelsNamespace}\\\\${selfIdent}`;\n\n const uses = new UseCollector(modelsNamespace);\n\n // Two-pass: build each field's promoted-property line, then assemble. We\n // need to know the full set of `use` statements before the file header\n // can be rendered, and the type mapper is what registers them.\n //\n // PHP 8.4 deprecates optional parameters declared before required ones\n // (the implicit-required-promotion warning). To stay idiomatic and\n // warning-free, we render required params first then optional ones,\n // preserving schema order WITHIN each group. Named-argument callers\n // are unaffected; positional-argument callers get a stable required-\n // first ordering.\n type LineEntry = { line: string; hasDefault: boolean };\n const entries: LineEntry[] = [];\n for (const field of model.fields) {\n if (field.annotations.hide) continue;\n\n const fieldIdent = resolveFieldIdent({\n schemaName: field.name,\n override: field.annotations.name,\n convention: config.naming.fieldNaming,\n });\n\n const mapping = mapFieldPhpType({\n field,\n modelSchemaName: model.name,\n uses,\n enumFqnLookup,\n modelFqnLookup,\n selfModelFqn: selfFqn,\n jsonTypesNamespace,\n jsonTypeClassNames,\n onDiagnostic: collectDiagnostic,\n });\n\n const defaultExpr = formatPhpDefault(field, enumFqnLookup, uses);\n\n const propertyDoc = renderPhpDoc(field.annotations, {\n indent: 8,\n extraTags: collectFieldExtraTags(field, mapping.listElementDoc),\n });\n\n // The `readonly` keyword could land either on every property OR on the\n // class. We pick class-level for the \"readonly\" style — single source\n // of truth, less line noise — so the per-property emit is identical\n // between the two styles.\n const propLine =\n defaultExpr === null\n ? ` public ${mapping.signatureType} $${fieldIdent}`\n : ` public ${mapping.signatureType} $${fieldIdent} = ${defaultExpr}`;\n entries.push({ line: `${propertyDoc}${propLine},`, hasDefault: defaultExpr !== null });\n }\n\n // Stable partition: required (no default) keeps schema order, then\n // optional (has default) keeps schema order. Array.prototype.filter\n // visits elements in index order, so each filtered subarray naturally\n // preserves the relative order of items in `entries`.\n const promotedLines = [\n ...entries.filter((e) => !e.hasDefault).map((e) => e.line),\n ...entries.filter((e) => e.hasDefault).map((e) => e.line),\n ];\n\n // Promoted properties go between the constructor parens. Even with no\n // visible fields PHP wants a balanced `()` — `final class { __construct() {} }`\n // is valid but useless.\n const promotedBlock = promotedLines.length > 0 ? `\\n${promotedLines.join(\"\\n\")}\\n ` : \"\";\n\n const usesBlock = uses.render();\n const headerDoc = renderPhpDoc(model.annotations, { indent: 0 });\n\n const classKeywords = declarationStyle === \"readonly\" ? \"final readonly class\" : \"final class\";\n\n const source = [\n \"<?php\",\n \"\",\n \"declare(strict_types=1);\",\n \"\",\n `namespace ${modelsNamespace};`,\n \"\",\n usesBlock +\n `${headerDoc}${classKeywords} ${selfIdent}\\n{\\n public function __construct(${promotedBlock}) {}\\n}`,\n \"\",\n ].join(\"\\n\");\n\n return { source, issues };\n}\n\nfunction collectFieldExtraTags(field: FieldDef, listElementDoc: string | null): string[] {\n const tags: string[] = [];\n // List PHPDoc: `@var array<int, Type>` — PHPStan-shaped narrowing for arrays.\n if (listElementDoc !== null) {\n tags.push(`@var array<int, ${listElementDoc}>`);\n }\n const nativeTag = buildNativeTypeTag(field.nativeType);\n if (nativeTag) tags.push(nativeTag);\n return tags;\n}\n\n/**\n * Returns a PHP expression for the field's constructor default, or null if\n * the field requires a constructor argument (no representable default).\n *\n * Mirrors the ts-shared default-handling rules:\n * - Lists default to `[]`.\n * - Nullable scalars without a Prisma default get `null`.\n * - Literal defaults emit only when the value's runtime type matches the\n * field's scalar — guards against the \"Int 90 on a DateTime field\" footgun.\n * - `now()` becomes `new \\DateTimeImmutable()`.\n * - Other function defaults (cuid/uuid/autoincrement) → null; the field\n * becomes a required constructor argument.\n */\nfunction formatPhpDefault(\n field: FieldDef,\n enumFqnLookup: ReadonlyMap<string, string>,\n uses: UseCollector,\n): string | null {\n if (field.isList) return \"[]\";\n\n if (!field.isRequired && !field.hasDefaultValue) return \"null\";\n\n if (!field.hasDefaultValue || !field.default) return null;\n\n const d = field.default;\n\n if (d.kind === \"literal\") {\n return formatLiteralDefault(field, d.value, enumFqnLookup, uses);\n }\n\n if (d.kind === \"list\") return \"[]\";\n\n // d.kind === \"function\" — only `now()` has a PHP-representable value.\n if (d.name === \"now\") return \"new \\\\DateTimeImmutable()\";\n\n return null;\n}\n\nfunction formatLiteralDefault(\n field: FieldDef,\n value: string | number | boolean | null,\n enumFqnLookup: ReadonlyMap<string, string>,\n uses: UseCollector,\n): string | null {\n if (value === null) return \"null\";\n\n if (typeof value === \"string\") {\n if (field.type.kind === \"scalar\" && field.type.scalar === \"String\") {\n return phpSingleQuoteString(value);\n }\n if (field.type.kind === \"enum\") {\n const enumFqn = enumFqnLookup.get(field.type.enumName);\n if (!enumFqn) return null;\n const shortName = uses.add(enumFqn);\n return `${shortName}::${value}`;\n }\n // String literal on a non-String/non-enum scalar is the \"Int 90 →\n // DateTime\" class of footgun. Refuse to fabricate a value.\n return null;\n }\n\n if (typeof value === \"number\") {\n if (field.type.kind === \"scalar\" && field.type.scalar === \"Int\") {\n return String(value);\n }\n if (field.type.kind === \"scalar\" && field.type.scalar === \"Float\") {\n // Preserve the \"this is a float literal\" intent that the schema\n // author expressed. Prisma's DMMF coerces `@default(1.0)` to the JS\n // number `1`, so `String(1)` would emit `1` and lose the decimal\n // point. PHP accepts `int` → `float` widening at the type level,\n // but `1.0` reads more honestly in the generated source for a\n // float-typed property.\n return Number.isInteger(value) ? `${value}.0` : String(value);\n }\n // Numeric defaults on BigInt / Decimal / DateTime need wrapping that\n // doesn't fit neatly inline in a PHP constructor param default. Skip;\n // the field becomes a required constructor arg.\n return null;\n }\n\n if (typeof value === \"boolean\") {\n if (field.type.kind === \"scalar\" && field.type.scalar === \"Boolean\") {\n return value ? \"true\" : \"false\";\n }\n return null;\n }\n\n return null;\n}\n\n/**\n * Render a PHP single-quoted string literal. Single quotes don't process\n * escapes other than `\\\\` and `\\'`, so the encoder only needs to escape\n * those two characters.\n */\nfunction phpSingleQuoteString(value: string): string {\n const escaped = value.replace(/\\\\/g, \"\\\\\\\\\").replace(/'/g, \"\\\\'\");\n return `'${escaped}'`;\n}\n"],"mappings":"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","names":[]}
@@ -0,0 +1,35 @@
1
+ import { FieldDef } from '@polyprism/core';
2
+ import { Diagnostic } from './diagnostics.js';
3
+ import { UseCollector } from './use-collector.js';
4
+
5
+ interface PhpTypeMapperOptions {
6
+ readonly field: FieldDef;
7
+ readonly modelSchemaName: string;
8
+ readonly uses: UseCollector;
9
+ readonly enumFqnLookup: ReadonlyMap<string, string>;
10
+ readonly modelFqnLookup: ReadonlyMap<string, string>;
11
+ /** FQN of the currently rendered model — relation fields back at this skip a `use`. */
12
+ readonly selfModelFqn: string;
13
+ /** Root namespace of generated JSON value classes, e.g. `"Generated\\JsonTypes"`. */
14
+ readonly jsonTypesNamespace: string;
15
+ /**
16
+ * Names of JSON value classes that emit-models successfully generated
17
+ * for inline @json forms. Inline-form Json fields resolve to one of
18
+ * these names (registering a `use`); inline shapes that failed parser
19
+ * validation aren't in this set, so the field falls back to `mixed`
20
+ * — the emit-models loop already produced a Diagnostic for the
21
+ * unparseable shape, and double-warning would be noisy.
22
+ */
23
+ readonly jsonTypeClassNames: ReadonlySet<string>;
24
+ /** Optional sink for warnings raised during mapping (unsupported @json, etc). */
25
+ readonly onDiagnostic?: (d: Diagnostic) => void;
26
+ }
27
+ interface PhpTypeMapping {
28
+ /** The type as it appears in the constructor signature (e.g. `?string`, `array`, `\DateTimeImmutable`). */
29
+ readonly signatureType: string;
30
+ /** The element type for PHPDoc `@var array<int, X>` hints, or null when not a list. */
31
+ readonly listElementDoc: string | null;
32
+ }
33
+ declare function mapFieldPhpType(opts: PhpTypeMapperOptions): PhpTypeMapping;
34
+
35
+ export { type PhpTypeMapperOptions, type PhpTypeMapping, mapFieldPhpType };
@@ -0,0 +1,95 @@
1
+ import { autoNameInlineJson } from "@polyprism/core";
2
+ function mapFieldPhpType(opts) {
3
+ const baseType = mapBasePhpType(opts);
4
+ return wrapNullability(baseType, opts.field);
5
+ }
6
+ function mapBasePhpType(opts) {
7
+ const { field } = opts;
8
+ if (field.annotations.type) {
9
+ const overrideType = field.annotations.type.typeName;
10
+ return overrideType.startsWith("?") ? overrideType.slice(1).trimStart() : overrideType;
11
+ }
12
+ if (field.type.kind === "scalar" && field.type.scalar === "Json" && field.annotations.json) {
13
+ const json = field.annotations.json;
14
+ const fieldPath = `${opts.modelSchemaName}.${field.name}`;
15
+ if (json.kind === "inline-anonymous" || json.kind === "inline-named") {
16
+ const className = json.kind === "inline-anonymous" ? autoNameInlineJson(opts.modelSchemaName, field.name) : json.typeName;
17
+ if (opts.jsonTypeClassNames.has(className)) {
18
+ return opts.uses.add(`${opts.jsonTypesNamespace}\\${className}`);
19
+ }
20
+ return "mixed";
21
+ }
22
+ const reason = json.kind === "bare" ? `the bare \`@json(${json.typeName})\` form trusts the user to have imported ${json.typeName} in TypeScript, which doesn't translate to PHP autoloading. Use \`@type("\\\\Vendor\\\\YourType")\` to point at a hand-written PHP class.` : `the with-path \`@json(${json.typeName} from "...")\` form refers to a TypeScript source file, which doesn't translate to PHP. Use \`@type("\\\\Vendor\\\\YourType")\` to point at a hand-written PHP class.`;
23
+ opts.onDiagnostic?.({
24
+ severity: "warning",
25
+ context: fieldPath,
26
+ message: `PHP emitter cannot resolve this @json form: ${reason} Falling back to \`mixed\`.`
27
+ });
28
+ return "mixed";
29
+ }
30
+ switch (field.type.kind) {
31
+ case "scalar":
32
+ return mapScalar(field.type.scalar);
33
+ case "enum":
34
+ return resolveEnumShort(field.type.enumName, opts);
35
+ case "relation":
36
+ return resolveRelationShort(field.type.modelName, opts);
37
+ case "unsupported":
38
+ return "mixed";
39
+ }
40
+ }
41
+ function mapScalar(scalar) {
42
+ switch (scalar) {
43
+ case "String":
44
+ return "string";
45
+ case "Boolean":
46
+ return "bool";
47
+ case "Int":
48
+ return "int";
49
+ case "Float":
50
+ return "float";
51
+ case "BigInt":
52
+ return "int";
53
+ case "Decimal":
54
+ return "string";
55
+ case "DateTime":
56
+ return "\\DateTimeImmutable";
57
+ case "Json":
58
+ return "mixed";
59
+ case "Bytes":
60
+ return "string";
61
+ }
62
+ }
63
+ function resolveEnumShort(enumName, opts) {
64
+ const fqn = opts.enumFqnLookup.get(enumName);
65
+ if (!fqn) return enumName;
66
+ return opts.uses.add(fqn);
67
+ }
68
+ function resolveRelationShort(modelName, opts) {
69
+ const fqn = opts.modelFqnLookup.get(modelName);
70
+ if (!fqn) return modelName;
71
+ if (fqn === opts.selfModelFqn) {
72
+ return shortNameOf(fqn);
73
+ }
74
+ return opts.uses.add(fqn);
75
+ }
76
+ function shortNameOf(fqn) {
77
+ const i = fqn.lastIndexOf("\\");
78
+ return i === -1 ? fqn : fqn.slice(i + 1);
79
+ }
80
+ function wrapNullability(baseType, field) {
81
+ if (field.isList) {
82
+ return { signatureType: "array", listElementDoc: baseType };
83
+ }
84
+ if (!field.isRequired) {
85
+ if (baseType === "mixed") {
86
+ return { signatureType: baseType, listElementDoc: null };
87
+ }
88
+ return { signatureType: `?${baseType}`, listElementDoc: null };
89
+ }
90
+ return { signatureType: baseType, listElementDoc: null };
91
+ }
92
+ export {
93
+ mapFieldPhpType
94
+ };
95
+ //# sourceMappingURL=type-mapper.js.map