@polyprism/php-shared 0.2.0 → 0.3.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 +9 -4
- package/dist/coerce-rules.d.ts +38 -0
- package/dist/coerce-rules.js +56 -0
- package/dist/coerce-rules.js.map +1 -0
- package/dist/defaults.d.ts +45 -0
- package/dist/defaults.js +52 -0
- package/dist/defaults.js.map +1 -0
- package/dist/diagnostics.d.ts +1 -16
- package/dist/diagnostics.js +1 -4
- package/dist/diagnostics.js.map +1 -1
- package/dist/emit-models.d.ts +1 -2
- package/dist/emit-models.js +4 -2
- package/dist/emit-models.js.map +1 -1
- package/dist/index.d.ts +6 -3
- package/dist/index.js +4 -0
- package/dist/index.js.map +1 -1
- package/dist/literals.d.ts +30 -0
- package/dist/literals.js +21 -0
- package/dist/literals.js.map +1 -0
- package/dist/phpdoc.d.ts +11 -2
- package/dist/phpdoc.js +10 -0
- package/dist/phpdoc.js.map +1 -1
- package/dist/render-domain-class.d.ts +18 -0
- package/dist/render-domain-class.js +311 -0
- package/dist/render-domain-class.js.map +1 -0
- package/dist/render-json-type.d.ts +1 -1
- package/dist/render-model.d.ts +2 -3
- package/dist/render-model.js +22 -78
- package/dist/render-model.js.map +1 -1
- package/dist/type-mapper.d.ts +1 -2
- package/package.json +3 -3
package/README.md
CHANGED
|
@@ -12,29 +12,34 @@ You don't install `@polyprism/php-shared` directly — each `php-*` pattern pack
|
|
|
12
12
|
|---|---|
|
|
13
13
|
| [`@polyprism/php-class`](https://www.npmjs.com/package/@polyprism/php-class) | `final class User { ... }` — PHP 8.1+, mutable, public typed properties via constructor property promotion |
|
|
14
14
|
| [`@polyprism/php-readonly`](https://www.npmjs.com/package/@polyprism/php-readonly) | `final readonly class User { ... }` — PHP 8.2+, immutable value objects |
|
|
15
|
+
| [`@polyprism/php-domain-class`](https://www.npmjs.com/package/@polyprism/php-domain-class) | `final class User { public string $email { set(...) { ... } } }` — PHP 8.4+, property-hook setters that route `@coerce` / `@normalise` through the [`polyprism/runtime`](https://packagist.org/packages/polyprism/runtime) Composer package |
|
|
15
16
|
|
|
16
17
|
## What lives here
|
|
17
18
|
|
|
18
19
|
The PHP-specific layer between [`@polyprism/core`](https://www.npmjs.com/package/@polyprism/core)'s language-agnostic IR and the per-pattern emitters:
|
|
19
20
|
|
|
20
21
|
- **`emitPhpModels(ctx, opts)`** — the top-level pipeline. Walks the IR, renders enums and models, surfaces any emit-time diagnostics, and writes everything to `<outputDir>/Enums/*.php` + `<outputDir>/Models/*.php`.
|
|
21
|
-
- **`renderPhpModel(opts)`** — emits one model file as a PHP class.
|
|
22
|
+
- **`renderPhpModel(opts)`** — emits one model file as a PHP class. Three declaration styles (`"class"`, `"readonly"`, `"domain-class"`) share the same field-by-field type-mapping and PHPDoc emission; `"domain-class"` delegates to its own renderer (property hooks need a different shape from constructor property promotion).
|
|
23
|
+
- **`renderPhpDomainClass(opts)`** — emits the PHP 8.4 property-hook variant used by `@polyprism/php-domain-class`. Setter pipelines route `@coerce` / `@normalise` through `Polyprism\Runtime\Coerce` and `Polyprism\Runtime\Normalise` (from the Composer-published [`polyprism/runtime`](https://packagist.org/packages/polyprism/runtime) package).
|
|
24
|
+
- **`resolvePhpCoerceDecision`** — PHP-flavoured wrapper around `@polyprism/core`'s language-neutral `resolveCoerceKind`. Returns the widened PHP setter input type (`int|string`, `\DateTimeImmutable|string|int`, etc.) plus the matching runtime method.
|
|
22
25
|
- **`renderPhpEnum(opts)`** — emits a PHP 8.1+ backed enum (`enum Role: string { case ADMIN = 'ADMIN'; }`).
|
|
23
26
|
- **`mapFieldPhpType`** — IR field → PHP type expression. Handles scalars, enums, relations, nullability, lists (with PHPDoc `@var array<int, T>` hints), and resolves `@json` references to generated value classes.
|
|
24
27
|
- **`renderPhpJsonType`** — Parses a TS-shaped inline `@json` expression (a small supported subset) and emits a `final readonly class` for it. Nested objects collapse to PHPDoc `array{...}` shapes rather than spawning sub-classes.
|
|
25
28
|
- **`UseCollector`** — deduped, sorted `use`-statement builder. Skips same-namespace references automatically.
|
|
26
|
-
- **`renderPhpDoc`** — PHPDoc emission for `///` docs, `@deprecated` tags, native-type metadata, and list-element hints.
|
|
29
|
+
- **`renderPhpDoc`** + **`collectFieldExtraTags`** — PHPDoc emission for `///` docs, `@deprecated` tags, native-type metadata, and list-element hints.
|
|
30
|
+
- **`phpSingleQuote`** + **`phpNormaliseOpConstant`** + **`formatPhpDefault`** — small literal-formatting helpers shared between `render-model.ts` and `render-domain-class.ts`.
|
|
27
31
|
|
|
28
32
|
## What's NOT in here (v0 scope)
|
|
29
33
|
|
|
30
|
-
- `@coerce` / `@normalise` / `@noCoerce` annotations are recognised but ignored. They're domain-class concepts that need PHP 8.4 property hooks and a Composer-published runtime helper — that'll ship as a future `@polyprism/php-domain-class`.
|
|
31
34
|
- TS unions / generics / identifier references inside an inline `@json` shape — fall back to `mixed` with a warning. Use `@type("\\App\\YourType")` to point at a hand-written PHP class for richer typing.
|
|
32
35
|
- Bare and with-path `@json` forms (e.g. `@json(SomeType)`, `@json(SomeType from "./path")`) — these rely on TS module imports that don't translate to PHP autoloading. Warn + fall back to `mixed`.
|
|
33
36
|
- Source-position line numbers on diagnostics — DMMF doesn't expose them, so issues carry `Model.field` context strings instead.
|
|
37
|
+
- A `php-interface` emitter for shape-contract interfaces (PHP 8.4 supports abstract property declarations on interfaces, but the idiomatic PHP DTO is a `final readonly class` — deferred until a real user asks).
|
|
38
|
+
- A `php-type` emitter — not possible: PHP has no type-alias syntax at the language level.
|
|
34
39
|
|
|
35
40
|
## Why this is split out from `@polyprism/core`
|
|
36
41
|
|
|
37
|
-
`@polyprism/core` is deliberately language-agnostic — IR, Prisma schema reader, annotation parser, naming resolver. `@polyprism/php-shared` is where PHP-specific concerns live.
|
|
42
|
+
`@polyprism/core` is deliberately language-agnostic — IR, Prisma schema reader, annotation parser, naming resolver, the language-neutral coerce-rules decision matrix, diagnostic surface, and ident-lookup builders. `@polyprism/php-shared` is where PHP-specific concerns live. All three PHP pattern packages share one PHP rendering layer, so they agree on type mapping, use-statement handling, and PHPDoc by construction — not by convention.
|
|
38
43
|
|
|
39
44
|
## Links
|
|
40
45
|
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { CoerceKind, CoerceRulesIssue, FieldDef } from '@polyprism/core';
|
|
2
|
+
|
|
3
|
+
type PhpCoerceKind = CoerceKind;
|
|
4
|
+
type PhpCoerceRulesIssue = CoerceRulesIssue;
|
|
5
|
+
interface PhpCoerceDecision {
|
|
6
|
+
readonly kind: PhpCoerceKind;
|
|
7
|
+
/**
|
|
8
|
+
* The static method on `Polyprism\Runtime\Coerce` to call, or `null` for
|
|
9
|
+
* `strict` (no call) and `coerce-string` (inlined `(string) $value` cast).
|
|
10
|
+
*/
|
|
11
|
+
readonly runtimeMethod: "int" | "float" | "bigint" | "date" | "decimal" | null;
|
|
12
|
+
/**
|
|
13
|
+
* Widened PHP input type for the setter parameter. For `strict` this
|
|
14
|
+
* matches the declared field type; for coerce variants it widens to
|
|
15
|
+
* accept the untrusted-boundary forms.
|
|
16
|
+
*
|
|
17
|
+
* Always non-nullable — nullability is layered on by the renderer (the
|
|
18
|
+
* setter for a nullable field accepts `T|null`).
|
|
19
|
+
*/
|
|
20
|
+
readonly setterInputType: string;
|
|
21
|
+
}
|
|
22
|
+
interface PhpCoerceRulesResult {
|
|
23
|
+
readonly decision: PhpCoerceDecision;
|
|
24
|
+
readonly issues: readonly PhpCoerceRulesIssue[];
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Resolve the PHP setter strategy + any issues for a single field.
|
|
28
|
+
*
|
|
29
|
+
* @param field The FieldDef from the IR
|
|
30
|
+
* @param modelName Schema-level model name (used in field paths for error messages)
|
|
31
|
+
* @param declaredType The PHP type the property's storage slot will declare
|
|
32
|
+
* (output of the type-mapper, stripped of any nullable
|
|
33
|
+
* `?` prefix). Used as the strict input type for the
|
|
34
|
+
* `strict` kind.
|
|
35
|
+
*/
|
|
36
|
+
declare function resolvePhpCoerceDecision(field: FieldDef, modelName: string, declaredType: string): PhpCoerceRulesResult;
|
|
37
|
+
|
|
38
|
+
export { type PhpCoerceDecision, type PhpCoerceKind, type PhpCoerceRulesIssue, type PhpCoerceRulesResult, resolvePhpCoerceDecision };
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import {
|
|
2
|
+
resolveCoerceKind
|
|
3
|
+
} from "@polyprism/core";
|
|
4
|
+
function resolvePhpCoerceDecision(field, modelName, declaredType) {
|
|
5
|
+
const { kind, issues } = resolveCoerceKind(field, modelName);
|
|
6
|
+
return {
|
|
7
|
+
decision: formatPhpDecision(kind, declaredType),
|
|
8
|
+
issues
|
|
9
|
+
};
|
|
10
|
+
}
|
|
11
|
+
function formatPhpDecision(kind, declaredType) {
|
|
12
|
+
switch (kind) {
|
|
13
|
+
case "strict":
|
|
14
|
+
return { kind, runtimeMethod: null, setterInputType: declaredType };
|
|
15
|
+
case "coerce-int":
|
|
16
|
+
return {
|
|
17
|
+
kind,
|
|
18
|
+
runtimeMethod: "int",
|
|
19
|
+
setterInputType: "int|string"
|
|
20
|
+
};
|
|
21
|
+
case "coerce-float":
|
|
22
|
+
return {
|
|
23
|
+
kind,
|
|
24
|
+
runtimeMethod: "float",
|
|
25
|
+
setterInputType: "float|int|string"
|
|
26
|
+
};
|
|
27
|
+
case "coerce-bigint":
|
|
28
|
+
return {
|
|
29
|
+
kind,
|
|
30
|
+
runtimeMethod: "bigint",
|
|
31
|
+
setterInputType: "int|string"
|
|
32
|
+
};
|
|
33
|
+
case "coerce-date":
|
|
34
|
+
return {
|
|
35
|
+
kind,
|
|
36
|
+
runtimeMethod: "date",
|
|
37
|
+
setterInputType: "\\DateTimeImmutable|string|int"
|
|
38
|
+
};
|
|
39
|
+
case "coerce-decimal":
|
|
40
|
+
return {
|
|
41
|
+
kind,
|
|
42
|
+
runtimeMethod: "decimal",
|
|
43
|
+
setterInputType: "string|float|int"
|
|
44
|
+
};
|
|
45
|
+
case "coerce-string":
|
|
46
|
+
return {
|
|
47
|
+
kind,
|
|
48
|
+
runtimeMethod: null,
|
|
49
|
+
setterInputType: "string|int|float|bool"
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
export {
|
|
54
|
+
resolvePhpCoerceDecision
|
|
55
|
+
};
|
|
56
|
+
//# sourceMappingURL=coerce-rules.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/coerce-rules.ts"],"sourcesContent":["// PHP-flavoured wrapper around `@polyprism/core`'s language-neutral\n// `resolveCoerceKind`. The core helper inspects the IR and decides which\n// setter shape applies (strict, coerce-int, coerce-date, etc); this file\n// translates that into the PHP-specific output the renderer needs — the\n// widened PHP type for the setter parameter (`int|string`,\n// `\\DateTimeImmutable|string|int`) and the matching `Polyprism\\Runtime\\Coerce`\n// static method name.\n//\n// Only the php-domain-class renderer consumes this module. php-class and\n// php-readonly continue to ignore `@coerce` / `@noCoerce` / `@normalise`.\n\nimport {\n type CoerceKind,\n type CoerceRulesIssue,\n type FieldDef,\n resolveCoerceKind,\n} from \"@polyprism/core\";\n\n// Backwards-compat aliases so existing imports of these names from\n// `@polyprism/php-shared` continue to resolve.\nexport type PhpCoerceKind = CoerceKind;\nexport type PhpCoerceRulesIssue = CoerceRulesIssue;\n\nexport interface PhpCoerceDecision {\n readonly kind: PhpCoerceKind;\n /**\n * The static method on `Polyprism\\Runtime\\Coerce` to call, or `null` for\n * `strict` (no call) and `coerce-string` (inlined `(string) $value` cast).\n */\n readonly runtimeMethod: \"int\" | \"float\" | \"bigint\" | \"date\" | \"decimal\" | null;\n /**\n * Widened PHP input type for the setter parameter. For `strict` this\n * matches the declared field type; for coerce variants it widens to\n * accept the untrusted-boundary forms.\n *\n * Always non-nullable — nullability is layered on by the renderer (the\n * setter for a nullable field accepts `T|null`).\n */\n readonly setterInputType: string;\n}\n\nexport interface PhpCoerceRulesResult {\n readonly decision: PhpCoerceDecision;\n readonly issues: readonly PhpCoerceRulesIssue[];\n}\n\n/**\n * Resolve the PHP setter strategy + any issues for a single field.\n *\n * @param field The FieldDef from the IR\n * @param modelName Schema-level model name (used in field paths for error messages)\n * @param declaredType The PHP type the property's storage slot will declare\n * (output of the type-mapper, stripped of any nullable\n * `?` prefix). Used as the strict input type for the\n * `strict` kind.\n */\nexport function resolvePhpCoerceDecision(\n field: FieldDef,\n modelName: string,\n declaredType: string,\n): PhpCoerceRulesResult {\n const { kind, issues } = resolveCoerceKind(field, modelName);\n return {\n decision: formatPhpDecision(kind, declaredType),\n issues,\n };\n}\n\nfunction formatPhpDecision(kind: PhpCoerceKind, declaredType: string): PhpCoerceDecision {\n switch (kind) {\n case \"strict\":\n return { kind, runtimeMethod: null, setterInputType: declaredType };\n case \"coerce-int\":\n return {\n kind,\n runtimeMethod: \"int\",\n setterInputType: \"int|string\",\n };\n case \"coerce-float\":\n return {\n kind,\n runtimeMethod: \"float\",\n setterInputType: \"float|int|string\",\n };\n case \"coerce-bigint\":\n return {\n kind,\n runtimeMethod: \"bigint\",\n setterInputType: \"int|string\",\n };\n case \"coerce-date\":\n return {\n kind,\n runtimeMethod: \"date\",\n setterInputType: \"\\\\DateTimeImmutable|string|int\",\n };\n case \"coerce-decimal\":\n return {\n kind,\n runtimeMethod: \"decimal\",\n setterInputType: \"string|float|int\",\n };\n case \"coerce-string\":\n return {\n kind,\n runtimeMethod: null,\n setterInputType: \"string|int|float|bool\",\n };\n }\n}\n"],"mappings":"AAWA;AAAA,EAIE;AAAA,OACK;AAwCA,SAAS,yBACd,OACA,WACA,cACsB;AACtB,QAAM,EAAE,MAAM,OAAO,IAAI,kBAAkB,OAAO,SAAS;AAC3D,SAAO;AAAA,IACL,UAAU,kBAAkB,MAAM,YAAY;AAAA,IAC9C;AAAA,EACF;AACF;AAEA,SAAS,kBAAkB,MAAqB,cAAyC;AACvF,UAAQ,MAAM;AAAA,IACZ,KAAK;AACH,aAAO,EAAE,MAAM,eAAe,MAAM,iBAAiB,aAAa;AAAA,IACpE,KAAK;AACH,aAAO;AAAA,QACL;AAAA,QACA,eAAe;AAAA,QACf,iBAAiB;AAAA,MACnB;AAAA,IACF,KAAK;AACH,aAAO;AAAA,QACL;AAAA,QACA,eAAe;AAAA,QACf,iBAAiB;AAAA,MACnB;AAAA,IACF,KAAK;AACH,aAAO;AAAA,QACL;AAAA,QACA,eAAe;AAAA,QACf,iBAAiB;AAAA,MACnB;AAAA,IACF,KAAK;AACH,aAAO;AAAA,QACL;AAAA,QACA,eAAe;AAAA,QACf,iBAAiB;AAAA,MACnB;AAAA,IACF,KAAK;AACH,aAAO;AAAA,QACL;AAAA,QACA,eAAe;AAAA,QACf,iBAAiB;AAAA,MACnB;AAAA,IACF,KAAK;AACH,aAAO;AAAA,QACL;AAAA,QACA,eAAe;AAAA,QACf,iBAAiB;AAAA,MACnB;AAAA,EACJ;AACF;","names":[]}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { FieldDef } from '@polyprism/core';
|
|
2
|
+
import { UseCollector } from './use-collector.js';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Returns a PHP expression for the field's constructor default, or null if
|
|
6
|
+
* the field requires a constructor argument (no representable default).
|
|
7
|
+
*
|
|
8
|
+
* Mirrors the ts-shared default-handling rules:
|
|
9
|
+
* - Lists default to `[]`.
|
|
10
|
+
* - Nullable scalars without a Prisma default get `null`.
|
|
11
|
+
* - Literal defaults emit only when the value's runtime type matches the
|
|
12
|
+
* field's scalar — guards against the "Int 90 on a DateTime field"
|
|
13
|
+
* footgun.
|
|
14
|
+
* - `now()` becomes `new \DateTimeImmutable()`.
|
|
15
|
+
* - Other function defaults (cuid/uuid/autoincrement) → null; the field
|
|
16
|
+
* becomes a required constructor argument.
|
|
17
|
+
*
|
|
18
|
+
* `enumFqnLookup` + `uses` are taken as parameters so this helper doesn't
|
|
19
|
+
* have to know about renderer-internal state. The use collector is
|
|
20
|
+
* mutated (any enum default registers a `use` for the enum's FQN).
|
|
21
|
+
*/
|
|
22
|
+
declare function formatPhpDefault(field: FieldDef, enumFqnLookup: ReadonlyMap<string, string>, uses: UseCollector): string | null;
|
|
23
|
+
/**
|
|
24
|
+
* Whether a default expression is a compile-time constant (and therefore
|
|
25
|
+
* legal in a PHP property-declaration default).
|
|
26
|
+
*
|
|
27
|
+
* PHP allows runtime expressions like `new Foo()` ONLY in constructor
|
|
28
|
+
* parameter defaults, class constants, and static properties. Regular
|
|
29
|
+
* (non-static) property declarations require compile-time constants
|
|
30
|
+
* (scalars, enum cases, null, arrays of constants, simple arithmetic).
|
|
31
|
+
*
|
|
32
|
+
* For PolyPrism's renderer, the one non-constant expression we emit is
|
|
33
|
+
* `new \DateTimeImmutable()` (the materialisation of `@default(now())`).
|
|
34
|
+
* That has to live on the constructor param only — the property
|
|
35
|
+
* declaration goes without an initializer in that case, and the
|
|
36
|
+
* constructor body's unconditional `$this->prop = $arg;` populates it.
|
|
37
|
+
*
|
|
38
|
+
* The check is intentionally simple: any expression starting with `new `
|
|
39
|
+
* is runtime, everything else is constant. We don't currently emit other
|
|
40
|
+
* runtime shapes (no method calls, no arithmetic across function results),
|
|
41
|
+
* so this is sufficient.
|
|
42
|
+
*/
|
|
43
|
+
declare function isCompileTimeConstantPhpExpr(expr: string): boolean;
|
|
44
|
+
|
|
45
|
+
export { formatPhpDefault, isCompileTimeConstantPhpExpr };
|
package/dist/defaults.js
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { phpSingleQuote } from "./literals.js";
|
|
2
|
+
function formatPhpDefault(field, enumFqnLookup, uses) {
|
|
3
|
+
if (field.isList) return "[]";
|
|
4
|
+
if (!field.isRequired && !field.hasDefaultValue) return "null";
|
|
5
|
+
if (!field.hasDefaultValue || !field.default) return null;
|
|
6
|
+
const d = field.default;
|
|
7
|
+
if (d.kind === "literal") {
|
|
8
|
+
return formatPhpLiteralDefault(field, d.value, enumFqnLookup, uses);
|
|
9
|
+
}
|
|
10
|
+
if (d.kind === "list") return "[]";
|
|
11
|
+
if (d.name === "now") return "new \\DateTimeImmutable()";
|
|
12
|
+
return null;
|
|
13
|
+
}
|
|
14
|
+
function formatPhpLiteralDefault(field, value, enumFqnLookup, uses) {
|
|
15
|
+
if (value === null) return "null";
|
|
16
|
+
if (typeof value === "string") {
|
|
17
|
+
if (field.type.kind === "scalar" && field.type.scalar === "String") {
|
|
18
|
+
return phpSingleQuote(value);
|
|
19
|
+
}
|
|
20
|
+
if (field.type.kind === "enum") {
|
|
21
|
+
const enumFqn = enumFqnLookup.get(field.type.enumName);
|
|
22
|
+
if (!enumFqn) return null;
|
|
23
|
+
const shortName = uses.add(enumFqn);
|
|
24
|
+
return `${shortName}::${value}`;
|
|
25
|
+
}
|
|
26
|
+
return null;
|
|
27
|
+
}
|
|
28
|
+
if (typeof value === "number") {
|
|
29
|
+
if (field.type.kind === "scalar" && field.type.scalar === "Int") {
|
|
30
|
+
return String(value);
|
|
31
|
+
}
|
|
32
|
+
if (field.type.kind === "scalar" && field.type.scalar === "Float") {
|
|
33
|
+
return Number.isInteger(value) ? `${value}.0` : String(value);
|
|
34
|
+
}
|
|
35
|
+
return null;
|
|
36
|
+
}
|
|
37
|
+
if (typeof value === "boolean") {
|
|
38
|
+
if (field.type.kind === "scalar" && field.type.scalar === "Boolean") {
|
|
39
|
+
return value ? "true" : "false";
|
|
40
|
+
}
|
|
41
|
+
return null;
|
|
42
|
+
}
|
|
43
|
+
return null;
|
|
44
|
+
}
|
|
45
|
+
function isCompileTimeConstantPhpExpr(expr) {
|
|
46
|
+
return !expr.startsWith("new ");
|
|
47
|
+
}
|
|
48
|
+
export {
|
|
49
|
+
formatPhpDefault,
|
|
50
|
+
isCompileTimeConstantPhpExpr
|
|
51
|
+
};
|
|
52
|
+
//# sourceMappingURL=defaults.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/defaults.ts"],"sourcesContent":["// PHP default-expression formatting, shared between `render-model.ts`\n// (php-class / php-readonly) and `render-domain-class.ts`.\n//\n// Pre-hoist this lived as byte-identical copies in both renderers — the\n// emit decision tree for \"what is this field's default expression, or\n// null if it has none\" is the same regardless of declaration style.\n// Only the SHAPE in which the default lands changes (php-class puts it\n// on the promoted constructor param; php-domain-class puts it on the\n// property declaration AND the constructor param, splitting where it's\n// safe to emit non-compile-time expressions).\n//\n// The TS family has its own, language-specific equivalents in\n// `@polyprism/ts-shared` — they diverge enough at the leaves (JSON-\n// quoted strings, `new Decimal(n)` / `BigInt(n)` wrappers, no\n// distinction between `now()` and other function defaults) that\n// hoisting the classifier any further isn't worth the abstraction tax.\n\nimport type { FieldDef } from \"@polyprism/core\";\n\nimport { phpSingleQuote } from \"./literals.js\";\nimport type { UseCollector } from \"./use-collector.js\";\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\"\n * footgun.\n * - `now()` becomes `new \\DateTimeImmutable()`.\n * - Other function defaults (cuid/uuid/autoincrement) → null; the field\n * becomes a required constructor argument.\n *\n * `enumFqnLookup` + `uses` are taken as parameters so this helper doesn't\n * have to know about renderer-internal state. The use collector is\n * mutated (any enum default registers a `use` for the enum's FQN).\n */\nexport function 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 formatPhpLiteralDefault(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 formatPhpLiteralDefault(\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 phpSingleQuote(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 * Whether a default expression is a compile-time constant (and therefore\n * legal in a PHP property-declaration default).\n *\n * PHP allows runtime expressions like `new Foo()` ONLY in constructor\n * parameter defaults, class constants, and static properties. Regular\n * (non-static) property declarations require compile-time constants\n * (scalars, enum cases, null, arrays of constants, simple arithmetic).\n *\n * For PolyPrism's renderer, the one non-constant expression we emit is\n * `new \\DateTimeImmutable()` (the materialisation of `@default(now())`).\n * That has to live on the constructor param only — the property\n * declaration goes without an initializer in that case, and the\n * constructor body's unconditional `$this->prop = $arg;` populates it.\n *\n * The check is intentionally simple: any expression starting with `new `\n * is runtime, everything else is constant. We don't currently emit other\n * runtime shapes (no method calls, no arithmetic across function results),\n * so this is sufficient.\n */\nexport function isCompileTimeConstantPhpExpr(expr: string): boolean {\n return !expr.startsWith(\"new \");\n}\n"],"mappings":"AAmBA,SAAS,sBAAsB;AAqBxB,SAAS,iBACd,OACA,eACA,MACe;AACf,MAAI,MAAM,OAAQ,QAAO;AAEzB,MAAI,CAAC,MAAM,cAAc,CAAC,MAAM,gBAAiB,QAAO;AAExD,MAAI,CAAC,MAAM,mBAAmB,CAAC,MAAM,QAAS,QAAO;AAErD,QAAM,IAAI,MAAM;AAEhB,MAAI,EAAE,SAAS,WAAW;AACxB,WAAO,wBAAwB,OAAO,EAAE,OAAO,eAAe,IAAI;AAAA,EACpE;AAEA,MAAI,EAAE,SAAS,OAAQ,QAAO;AAG9B,MAAI,EAAE,SAAS,MAAO,QAAO;AAE7B,SAAO;AACT;AAEA,SAAS,wBACP,OACA,OACA,eACA,MACe;AACf,MAAI,UAAU,KAAM,QAAO;AAE3B,MAAI,OAAO,UAAU,UAAU;AAC7B,QAAI,MAAM,KAAK,SAAS,YAAY,MAAM,KAAK,WAAW,UAAU;AAClE,aAAO,eAAe,KAAK;AAAA,IAC7B;AACA,QAAI,MAAM,KAAK,SAAS,QAAQ;AAC9B,YAAM,UAAU,cAAc,IAAI,MAAM,KAAK,QAAQ;AACrD,UAAI,CAAC,QAAS,QAAO;AACrB,YAAM,YAAY,KAAK,IAAI,OAAO;AAClC,aAAO,GAAG,SAAS,KAAK,KAAK;AAAA,IAC/B;AAGA,WAAO;AAAA,EACT;AAEA,MAAI,OAAO,UAAU,UAAU;AAC7B,QAAI,MAAM,KAAK,SAAS,YAAY,MAAM,KAAK,WAAW,OAAO;AAC/D,aAAO,OAAO,KAAK;AAAA,IACrB;AACA,QAAI,MAAM,KAAK,SAAS,YAAY,MAAM,KAAK,WAAW,SAAS;AAOjE,aAAO,OAAO,UAAU,KAAK,IAAI,GAAG,KAAK,OAAO,OAAO,KAAK;AAAA,IAC9D;AAIA,WAAO;AAAA,EACT;AAEA,MAAI,OAAO,UAAU,WAAW;AAC9B,QAAI,MAAM,KAAK,SAAS,YAAY,MAAM,KAAK,WAAW,WAAW;AACnE,aAAO,QAAQ,SAAS;AAAA,IAC1B;AACA,WAAO;AAAA,EACT;AAEA,SAAO;AACT;AAsBO,SAAS,6BAA6B,MAAuB;AAClE,SAAO,CAAC,KAAK,WAAW,MAAM;AAChC;","names":[]}
|
package/dist/diagnostics.d.ts
CHANGED
|
@@ -1,16 +1 @@
|
|
|
1
|
-
|
|
2
|
-
readonly severity: "error" | "warning";
|
|
3
|
-
/**
|
|
4
|
-
* Where the issue applies. Conventional shapes:
|
|
5
|
-
* - `"User"` — model
|
|
6
|
-
* - `"User.email"` — model field
|
|
7
|
-
* - `"Role"` — enum
|
|
8
|
-
* - `"Role.ADMIN"` — enum value
|
|
9
|
-
*/
|
|
10
|
-
readonly context: string;
|
|
11
|
-
readonly message: string;
|
|
12
|
-
}
|
|
13
|
-
/** Default reporter: writes warnings and errors to stderr with a prefix tag. */
|
|
14
|
-
declare function defaultReportDiagnostic(d: Diagnostic): void;
|
|
15
|
-
|
|
16
|
-
export { type Diagnostic, defaultReportDiagnostic };
|
|
1
|
+
export { Diagnostic, defaultReportDiagnostic } from '@polyprism/core';
|
package/dist/diagnostics.js
CHANGED
|
@@ -1,7 +1,4 @@
|
|
|
1
|
-
|
|
2
|
-
const prefix = d.severity === "error" ? "[error]" : "[warn] ";
|
|
3
|
-
console.error(`PolyPrism ${prefix} ${d.context}: ${d.message}`);
|
|
4
|
-
}
|
|
1
|
+
import { defaultReportDiagnostic } from "@polyprism/core";
|
|
5
2
|
export {
|
|
6
3
|
defaultReportDiagnostic
|
|
7
4
|
};
|
package/dist/diagnostics.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/diagnostics.ts"],"sourcesContent":["// Diagnostic
|
|
1
|
+
{"version":3,"sources":["../src/diagnostics.ts"],"sourcesContent":["// Diagnostic types live in `@polyprism/core` so every emitter family\n// (ts-shared, php-shared, and future targets) shares one surface. This\n// module re-exports them so existing imports of `@polyprism/php-shared`'s\n// Diagnostic continue to resolve without callers having to switch package.\n//\n// See `@polyprism/core`'s `diagnostics/index.ts` for the full type docs.\n\nexport { type Diagnostic, defaultReportDiagnostic } from \"@polyprism/core\";\n"],"mappings":"AAOA,SAA0B,+BAA+B;","names":[]}
|
package/dist/emit-models.d.ts
CHANGED
package/dist/emit-models.js
CHANGED
|
@@ -75,8 +75,10 @@ async function emitPhpModels(ctx, opts) {
|
|
|
75
75
|
// JsonType readonly syntax follows the parent's declaration style —
|
|
76
76
|
// per-property `readonly` for `php-class` (PHP 8.1 floor) so we don't
|
|
77
77
|
// silently emit 8.2-only `final readonly class` syntax from an 8.1-
|
|
78
|
-
// documented package.
|
|
79
|
-
|
|
78
|
+
// documented package. `php-readonly` (8.2+) and `php-domain-class`
|
|
79
|
+
// (8.4+) both clear the 8.2 bar, so they get the cleaner class-level
|
|
80
|
+
// modifier. Same value-class semantics either way.
|
|
81
|
+
declarationStyle: opts.declarationStyle === "class" ? "class" : "readonly"
|
|
80
82
|
});
|
|
81
83
|
for (const issue of issues) emit(issue);
|
|
82
84
|
if (source.length === 0) {
|
package/dist/emit-models.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/emit-models.ts"],"sourcesContent":["// Top-level emit pipeline for the PHP family (php-class, php-readonly).\n//\n// Layout produced on disk (relative to the generator's outputDir):\n//\n// <outputDir>/\n// Models/\n// User.php\n// Order.php\n// Enums/\n// Role.php\n//\n// Each file declares its PSR-4 namespace as the first non-php-tag line,\n// so users can wire the generated directory into composer.json autoload\n// with a single mapping:\n//\n// \"autoload\": {\n// \"psr-4\": {\n// \"Generated\\\\\": \"src/Generated/\"\n// }\n// }\n//\n// Annotations honoured (v0):\n// - @hide — field is omitted from the class body\n// - @deprecated — emits a PHPDoc @deprecated tag\n// - @name — overrides the class / field identifier verbatim\n// - @type — overrides the field's PHP type expression verbatim\n// - @json(...) — inline forms (anonymous + named) generate readonly\n// value classes under JsonTypes/; the Json field's type resolves to the\n// generated class name. Bare and with-path forms emit a warning and\n// fall back to `mixed` (PHP has no equivalent of TS module imports).\n//\n// Annotations recognised but ignored (intentional v0 scope):\n// - @coerce / @normalise / @noCoerce — domain-class concepts that need\n// PHP 8.4 property hooks + a runtime helper. Will land as php-domain-class.\n//\n// Errors propagate the same way the ts-shared pipeline does: per-issue\n// onDiagnostic callback (defaults to stderr), accumulate error-severity\n// count, throw at the end if non-zero.\n\nimport type { AnnotationSet, EnumDef, GeneratorContext, ModelDef } from \"@polyprism/core\";\nimport { autoNameInlineJson, resolveTypeIdent } from \"@polyprism/core\";\n\nimport { type Diagnostic, defaultReportDiagnostic } from \"./diagnostics.js\";\nimport { renderPhpEnum } from \"./render-enum.js\";\nimport { renderPhpJsonType } from \"./render-json-type.js\";\nimport { type PhpDeclarationStyle, renderPhpModel } from \"./render-model.js\";\n\nexport interface EmitPhpModelsOptions {\n readonly declarationStyle: PhpDeclarationStyle;\n /**\n * Root namespace for model classes. Default: `\"Generated\\\\Models\"`.\n * Use a single backslash in source; this is a real PHP namespace string\n * (no escape doubling required at runtime — the doubling shown here is\n * just because backslash is the JS escape character).\n */\n readonly modelsNamespace?: string;\n /** Root namespace for enum classes. Default: `\"Generated\\\\Enums\"`. */\n readonly enumsNamespace?: string;\n /** Root namespace for generated JSON value classes. Default: `\"Generated\\\\JsonTypes\"`. */\n readonly jsonTypesNamespace?: string;\n /** Optional diagnostic sink. Defaults to stderr. */\n readonly onDiagnostic?: (diagnostic: Diagnostic) => void;\n}\n\nconst DEFAULT_MODELS_NAMESPACE = \"Generated\\\\Models\";\nconst DEFAULT_ENUMS_NAMESPACE = \"Generated\\\\Enums\";\nconst DEFAULT_JSON_TYPES_NAMESPACE = \"Generated\\\\JsonTypes\";\n\nexport async function emitPhpModels(\n ctx: GeneratorContext,\n opts: EmitPhpModelsOptions,\n): Promise<void> {\n const report = opts.onDiagnostic ?? defaultReportDiagnostic;\n const modelsNamespace = opts.modelsNamespace ?? DEFAULT_MODELS_NAMESPACE;\n const enumsNamespace = opts.enumsNamespace ?? DEFAULT_ENUMS_NAMESPACE;\n const jsonTypesNamespace = opts.jsonTypesNamespace ?? DEFAULT_JSON_TYPES_NAMESPACE;\n let errorCount = 0;\n\n const emit = (d: Diagnostic): void => {\n if (d.severity === \"error\") errorCount += 1;\n report(d);\n };\n\n // (1) Parser issues — recorded across models, fields, enums, enum values.\n for (const diag of collectParseDiagnostics(ctx.ir)) emit(diag);\n\n // (2) Enums — one file per visible enum, under <outputDir>/Enums/<Name>.php\n for (const enumDef of ctx.ir.enums) {\n if (enumDef.annotations.hide) continue;\n const filename = resolveTypeIdent({\n schemaName: enumDef.name,\n override: enumDef.annotations.name,\n convention: ctx.config.naming.typeNaming,\n });\n const source = renderPhpEnum({\n enumDef,\n naming: ctx.config.naming,\n namespace: enumsNamespace,\n });\n await ctx.writer.write(`Enums/${filename}.php`, source);\n }\n\n // (3) JSON value classes — one file per inline @json shape (forms 3 + 4),\n // under <outputDir>/JsonTypes/<Name>.php. Bare and with-path forms\n // reference user-supplied types and are warned about in the type\n // mapper rather than generating files here.\n const jsonTypesEmitted = new Map<string, string>();\n // Track which field first registered each JsonType name so collision\n // warnings can point at both sides.\n const jsonTypeOrigin = new Map<string, string>();\n for (const model of ctx.ir.models) {\n if (model.annotations.hide) continue;\n for (const field of model.fields) {\n if (field.annotations.hide) continue;\n const json = field.annotations.json;\n if (!json) continue;\n let typeName: string | null = null;\n let typeExpression: string | null = null;\n if (json.kind === \"inline-anonymous\") {\n typeName = autoNameInlineJson(model.name, field.name);\n typeExpression = json.typeExpression;\n } else if (json.kind === \"inline-named\") {\n typeName = json.typeName;\n typeExpression = json.typeExpression;\n }\n if (!typeName || typeExpression === null) continue;\n\n const origin = `${model.name}.${field.name}`;\n const existing = jsonTypesEmitted.get(typeName);\n if (existing !== undefined && existing !== typeExpression) {\n // Two different inline @json shapes resolved to the same class\n // name. Common ways this happens: two inline-anonymous fields\n // whose Model+Field PascalCase to the same identifier, or two\n // inline-named declarations sharing a name with different shapes.\n // Last-write-wins matches the core TS pipeline behaviour, but\n // PHP's nominal typing makes a silent shape mismatch nastier\n // than TS's structural one (the class is what it is at runtime,\n // not what the consumer expected). Warn loudly so the user can\n // disambiguate with @json(<UniqueName> = { ... }).\n emit({\n severity: \"warning\",\n context: origin,\n message:\n `@json auto-naming collision: ${origin} produced JsonType class ` +\n `\"${typeName}\" but a different shape from ${jsonTypeOrigin.get(typeName)} ` +\n \"already registered the same name. The later shape wins; the earlier \" +\n \"field's runtime class will not match its schema-declared shape. \" +\n \"Disambiguate with `@json(<UniqueName> = { ... })`.\",\n });\n }\n jsonTypesEmitted.set(typeName, typeExpression);\n if (!jsonTypeOrigin.has(typeName)) jsonTypeOrigin.set(typeName, origin);\n }\n }\n // Drive the type-mapper off the SUCCESSFUL set (built below), not the\n // intent set above. Otherwise an unparseable expression would still\n // register a `use` for a class that was never written.\n const successfullyEmitted = new Set<string>();\n for (const [typeName, expression] of jsonTypesEmitted) {\n const { source, issues } = renderPhpJsonType({\n typeName,\n typeExpression: expression,\n namespace: jsonTypesNamespace,\n // Best-effort context: there's no single source field for a named\n // shape that's referenced from multiple places, so the JSON type\n // class itself is the locus.\n diagnosticContext: `JsonTypes.${typeName}`,\n // JsonType readonly syntax follows the parent's declaration style —\n // per-property `readonly` for `php-class` (PHP 8.1 floor) so we don't\n // silently emit 8.2-only `final readonly class` syntax from an 8.1-\n // documented package. Same semantics either way.\n declarationStyle: opts.declarationStyle,\n });\n for (const issue of issues) emit(issue);\n if (source.length === 0) {\n // Renderer rejected the expression as unparseable. The warning has\n // already been emitted; the field will fall back to `mixed` when\n // the type-mapper checks `successfullyEmitted`.\n continue;\n }\n await ctx.writer.write(`JsonTypes/${typeName}.php`, source);\n successfullyEmitted.add(typeName);\n }\n\n // (4) Models — one file per visible model, under <outputDir>/Models/<Name>.php\n for (const model of ctx.ir.models) {\n if (model.annotations.hide) continue;\n const filename = resolveTypeIdent({\n schemaName: model.name,\n override: model.annotations.name,\n convention: ctx.config.naming.typeNaming,\n });\n const { source, issues } = renderPhpModel({\n model,\n ir: ctx.ir,\n config: ctx.config,\n declarationStyle: opts.declarationStyle,\n modelsNamespace,\n enumsNamespace,\n jsonTypesNamespace,\n jsonTypeClassNames: successfullyEmitted,\n });\n for (const issue of issues) emit(issue);\n await ctx.writer.write(`Models/${filename}.php`, source);\n }\n\n if (errorCount > 0) {\n throw new Error(\n `PolyPrism: PHP emit failed with ${errorCount} error-severity ` +\n `diagnostic${errorCount === 1 ? \"\" : \"s\"}. See the messages above for details.`,\n );\n }\n}\n\nfunction* collectParseDiagnostics(ir: {\n readonly models: readonly ModelDef[];\n readonly enums: readonly EnumDef[];\n}): Generator<Diagnostic> {\n for (const model of ir.models) {\n yield* parseIssuesFor(model.annotations, model.name);\n for (const field of model.fields) {\n yield* parseIssuesFor(field.annotations, `${model.name}.${field.name}`);\n }\n }\n for (const enumDef of ir.enums) {\n yield* parseIssuesFor(enumDef.annotations, enumDef.name);\n for (const value of enumDef.values) {\n yield* parseIssuesFor(value.annotations, `${enumDef.name}.${value.name}`);\n }\n }\n}\n\nfunction* parseIssuesFor(annotations: AnnotationSet, context: string): Generator<Diagnostic> {\n for (const issue of annotations.parseIssues) {\n yield { severity: issue.severity, context, message: issue.message };\n }\n}\n"],"mappings":"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","names":[]}
|
|
1
|
+
{"version":3,"sources":["../src/emit-models.ts"],"sourcesContent":["// Top-level emit pipeline for the PHP family (php-class, php-readonly).\n//\n// Layout produced on disk (relative to the generator's outputDir):\n//\n// <outputDir>/\n// Models/\n// User.php\n// Order.php\n// Enums/\n// Role.php\n//\n// Each file declares its PSR-4 namespace as the first non-php-tag line,\n// so users can wire the generated directory into composer.json autoload\n// with a single mapping:\n//\n// \"autoload\": {\n// \"psr-4\": {\n// \"Generated\\\\\": \"src/Generated/\"\n// }\n// }\n//\n// Annotations honoured (v0):\n// - @hide — field is omitted from the class body\n// - @deprecated — emits a PHPDoc @deprecated tag\n// - @name — overrides the class / field identifier verbatim\n// - @type — overrides the field's PHP type expression verbatim\n// - @json(...) — inline forms (anonymous + named) generate readonly\n// value classes under JsonTypes/; the Json field's type resolves to the\n// generated class name. Bare and with-path forms emit a warning and\n// fall back to `mixed` (PHP has no equivalent of TS module imports).\n//\n// Annotations recognised but ignored for `class` / `readonly` styles:\n// - @coerce / @normalise / @noCoerce — domain-class concepts that need\n// PHP 8.4 property hooks + the `polyprism/runtime` Composer helper.\n// Honoured by the `domain-class` style (php-domain-class generator).\n//\n// Errors propagate the same way the ts-shared pipeline does: per-issue\n// onDiagnostic callback (defaults to stderr), accumulate error-severity\n// count, throw at the end if non-zero.\n\nimport type { AnnotationSet, EnumDef, GeneratorContext, ModelDef } from \"@polyprism/core\";\nimport { autoNameInlineJson, resolveTypeIdent } from \"@polyprism/core\";\n\nimport { type Diagnostic, defaultReportDiagnostic } from \"./diagnostics.js\";\nimport { renderPhpEnum } from \"./render-enum.js\";\nimport { renderPhpJsonType } from \"./render-json-type.js\";\nimport { type PhpDeclarationStyle, renderPhpModel } from \"./render-model.js\";\n\nexport interface EmitPhpModelsOptions {\n readonly declarationStyle: PhpDeclarationStyle;\n /**\n * Root namespace for model classes. Default: `\"Generated\\\\Models\"`.\n * Use a single backslash in source; this is a real PHP namespace string\n * (no escape doubling required at runtime — the doubling shown here is\n * just because backslash is the JS escape character).\n */\n readonly modelsNamespace?: string;\n /** Root namespace for enum classes. Default: `\"Generated\\\\Enums\"`. */\n readonly enumsNamespace?: string;\n /** Root namespace for generated JSON value classes. Default: `\"Generated\\\\JsonTypes\"`. */\n readonly jsonTypesNamespace?: string;\n /** Optional diagnostic sink. Defaults to stderr. */\n readonly onDiagnostic?: (diagnostic: Diagnostic) => void;\n}\n\nconst DEFAULT_MODELS_NAMESPACE = \"Generated\\\\Models\";\nconst DEFAULT_ENUMS_NAMESPACE = \"Generated\\\\Enums\";\nconst DEFAULT_JSON_TYPES_NAMESPACE = \"Generated\\\\JsonTypes\";\n\nexport async function emitPhpModels(\n ctx: GeneratorContext,\n opts: EmitPhpModelsOptions,\n): Promise<void> {\n const report = opts.onDiagnostic ?? defaultReportDiagnostic;\n const modelsNamespace = opts.modelsNamespace ?? DEFAULT_MODELS_NAMESPACE;\n const enumsNamespace = opts.enumsNamespace ?? DEFAULT_ENUMS_NAMESPACE;\n const jsonTypesNamespace = opts.jsonTypesNamespace ?? DEFAULT_JSON_TYPES_NAMESPACE;\n let errorCount = 0;\n\n const emit = (d: Diagnostic): void => {\n if (d.severity === \"error\") errorCount += 1;\n report(d);\n };\n\n // (1) Parser issues — recorded across models, fields, enums, enum values.\n for (const diag of collectParseDiagnostics(ctx.ir)) emit(diag);\n\n // (2) Enums — one file per visible enum, under <outputDir>/Enums/<Name>.php\n for (const enumDef of ctx.ir.enums) {\n if (enumDef.annotations.hide) continue;\n const filename = resolveTypeIdent({\n schemaName: enumDef.name,\n override: enumDef.annotations.name,\n convention: ctx.config.naming.typeNaming,\n });\n const source = renderPhpEnum({\n enumDef,\n naming: ctx.config.naming,\n namespace: enumsNamespace,\n });\n await ctx.writer.write(`Enums/${filename}.php`, source);\n }\n\n // (3) JSON value classes — one file per inline @json shape (forms 3 + 4),\n // under <outputDir>/JsonTypes/<Name>.php. Bare and with-path forms\n // reference user-supplied types and are warned about in the type\n // mapper rather than generating files here.\n const jsonTypesEmitted = new Map<string, string>();\n // Track which field first registered each JsonType name so collision\n // warnings can point at both sides.\n const jsonTypeOrigin = new Map<string, string>();\n for (const model of ctx.ir.models) {\n if (model.annotations.hide) continue;\n for (const field of model.fields) {\n if (field.annotations.hide) continue;\n const json = field.annotations.json;\n if (!json) continue;\n let typeName: string | null = null;\n let typeExpression: string | null = null;\n if (json.kind === \"inline-anonymous\") {\n typeName = autoNameInlineJson(model.name, field.name);\n typeExpression = json.typeExpression;\n } else if (json.kind === \"inline-named\") {\n typeName = json.typeName;\n typeExpression = json.typeExpression;\n }\n if (!typeName || typeExpression === null) continue;\n\n const origin = `${model.name}.${field.name}`;\n const existing = jsonTypesEmitted.get(typeName);\n if (existing !== undefined && existing !== typeExpression) {\n // Two different inline @json shapes resolved to the same class\n // name. Common ways this happens: two inline-anonymous fields\n // whose Model+Field PascalCase to the same identifier, or two\n // inline-named declarations sharing a name with different shapes.\n // Last-write-wins matches the core TS pipeline behaviour, but\n // PHP's nominal typing makes a silent shape mismatch nastier\n // than TS's structural one (the class is what it is at runtime,\n // not what the consumer expected). Warn loudly so the user can\n // disambiguate with @json(<UniqueName> = { ... }).\n emit({\n severity: \"warning\",\n context: origin,\n message:\n `@json auto-naming collision: ${origin} produced JsonType class ` +\n `\"${typeName}\" but a different shape from ${jsonTypeOrigin.get(typeName)} ` +\n \"already registered the same name. The later shape wins; the earlier \" +\n \"field's runtime class will not match its schema-declared shape. \" +\n \"Disambiguate with `@json(<UniqueName> = { ... })`.\",\n });\n }\n jsonTypesEmitted.set(typeName, typeExpression);\n if (!jsonTypeOrigin.has(typeName)) jsonTypeOrigin.set(typeName, origin);\n }\n }\n // Drive the type-mapper off the SUCCESSFUL set (built below), not the\n // intent set above. Otherwise an unparseable expression would still\n // register a `use` for a class that was never written.\n const successfullyEmitted = new Set<string>();\n for (const [typeName, expression] of jsonTypesEmitted) {\n const { source, issues } = renderPhpJsonType({\n typeName,\n typeExpression: expression,\n namespace: jsonTypesNamespace,\n // Best-effort context: there's no single source field for a named\n // shape that's referenced from multiple places, so the JSON type\n // class itself is the locus.\n diagnosticContext: `JsonTypes.${typeName}`,\n // JsonType readonly syntax follows the parent's declaration style —\n // per-property `readonly` for `php-class` (PHP 8.1 floor) so we don't\n // silently emit 8.2-only `final readonly class` syntax from an 8.1-\n // documented package. `php-readonly` (8.2+) and `php-domain-class`\n // (8.4+) both clear the 8.2 bar, so they get the cleaner class-level\n // modifier. Same value-class semantics either way.\n declarationStyle: opts.declarationStyle === \"class\" ? \"class\" : \"readonly\",\n });\n for (const issue of issues) emit(issue);\n if (source.length === 0) {\n // Renderer rejected the expression as unparseable. The warning has\n // already been emitted; the field will fall back to `mixed` when\n // the type-mapper checks `successfullyEmitted`.\n continue;\n }\n await ctx.writer.write(`JsonTypes/${typeName}.php`, source);\n successfullyEmitted.add(typeName);\n }\n\n // (4) Models — one file per visible model, under <outputDir>/Models/<Name>.php\n for (const model of ctx.ir.models) {\n if (model.annotations.hide) continue;\n const filename = resolveTypeIdent({\n schemaName: model.name,\n override: model.annotations.name,\n convention: ctx.config.naming.typeNaming,\n });\n const { source, issues } = renderPhpModel({\n model,\n ir: ctx.ir,\n config: ctx.config,\n declarationStyle: opts.declarationStyle,\n modelsNamespace,\n enumsNamespace,\n jsonTypesNamespace,\n jsonTypeClassNames: successfullyEmitted,\n });\n for (const issue of issues) emit(issue);\n await ctx.writer.write(`Models/${filename}.php`, source);\n }\n\n if (errorCount > 0) {\n throw new Error(\n `PolyPrism: PHP emit failed with ${errorCount} error-severity ` +\n `diagnostic${errorCount === 1 ? \"\" : \"s\"}. See the messages above for details.`,\n );\n }\n}\n\nfunction* collectParseDiagnostics(ir: {\n readonly models: readonly ModelDef[];\n readonly enums: readonly EnumDef[];\n}): Generator<Diagnostic> {\n for (const model of ir.models) {\n yield* parseIssuesFor(model.annotations, model.name);\n for (const field of model.fields) {\n yield* parseIssuesFor(field.annotations, `${model.name}.${field.name}`);\n }\n }\n for (const enumDef of ir.enums) {\n yield* parseIssuesFor(enumDef.annotations, enumDef.name);\n for (const value of enumDef.values) {\n yield* parseIssuesFor(value.annotations, `${enumDef.name}.${value.name}`);\n }\n }\n}\n\nfunction* parseIssuesFor(annotations: AnnotationSet, context: string): Generator<Diagnostic> {\n for (const issue of annotations.parseIssues) {\n yield { severity: issue.severity, context, message: issue.message };\n }\n}\n"],"mappings":"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","names":[]}
|
package/dist/index.d.ts
CHANGED
|
@@ -1,9 +1,12 @@
|
|
|
1
|
-
export {
|
|
1
|
+
export { PhpCoerceDecision, PhpCoerceKind, PhpCoerceRulesIssue, PhpCoerceRulesResult, resolvePhpCoerceDecision } from './coerce-rules.js';
|
|
2
|
+
export { formatPhpDefault, isCompileTimeConstantPhpExpr } from './defaults.js';
|
|
3
|
+
export { Diagnostic, defaultReportDiagnostic } from '@polyprism/core';
|
|
2
4
|
export { EmitPhpModelsOptions, emitPhpModels } from './emit-models.js';
|
|
3
|
-
export {
|
|
5
|
+
export { phpNormaliseOpConstant, phpSingleQuote } from './literals.js';
|
|
6
|
+
export { RenderPhpDocOptions, buildNativeTypeTag, collectFieldExtraTags, renderPhpDoc } from './phpdoc.js';
|
|
7
|
+
export { RenderPhpDomainClassOptions, RenderPhpDomainClassResult, renderPhpDomainClass } from './render-domain-class.js';
|
|
4
8
|
export { RenderEnumOptions, renderPhpEnum } from './render-enum.js';
|
|
5
9
|
export { RenderJsonTypeOptions, RenderJsonTypeResult, renderPhpJsonType } from './render-json-type.js';
|
|
6
10
|
export { PhpDeclarationStyle, RenderPhpModelOptions, RenderPhpModelResult, renderPhpModel } from './render-model.js';
|
|
7
11
|
export { PhpTypeMapperOptions, PhpTypeMapping, mapFieldPhpType } from './type-mapper.js';
|
|
8
12
|
export { UseCollector } from './use-collector.js';
|
|
9
|
-
import '@polyprism/core';
|
package/dist/index.js
CHANGED
|
@@ -1,6 +1,10 @@
|
|
|
1
|
+
export * from "./coerce-rules.js";
|
|
2
|
+
export * from "./defaults.js";
|
|
1
3
|
export * from "./diagnostics.js";
|
|
2
4
|
export * from "./emit-models.js";
|
|
5
|
+
export * from "./literals.js";
|
|
3
6
|
export * from "./phpdoc.js";
|
|
7
|
+
export * from "./render-domain-class.js";
|
|
4
8
|
export * from "./render-enum.js";
|
|
5
9
|
export * from "./render-json-type.js";
|
|
6
10
|
export * from "./render-model.js";
|
package/dist/index.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/index.ts"],"sourcesContent":["export * from \"./diagnostics.js\";\nexport * from \"./emit-models.js\";\nexport * from \"./phpdoc.js\";\nexport * from \"./render-enum.js\";\nexport * from \"./render-json-type.js\";\nexport * from \"./render-model.js\";\nexport * from \"./type-mapper.js\";\nexport * from \"./use-collector.js\";\n"],"mappings":"AAAA,cAAc;AACd,cAAc;AACd,cAAc;AACd,cAAc;AACd,cAAc;AACd,cAAc;AACd,cAAc;AACd,cAAc;","names":[]}
|
|
1
|
+
{"version":3,"sources":["../src/index.ts"],"sourcesContent":["export * from \"./coerce-rules.js\";\nexport * from \"./defaults.js\";\nexport * from \"./diagnostics.js\";\nexport * from \"./emit-models.js\";\nexport * from \"./literals.js\";\nexport * from \"./phpdoc.js\";\nexport * from \"./render-domain-class.js\";\nexport * from \"./render-enum.js\";\nexport * from \"./render-json-type.js\";\nexport * from \"./render-model.js\";\nexport * from \"./type-mapper.js\";\nexport * from \"./use-collector.js\";\n"],"mappings":"AAAA,cAAc;AACd,cAAc;AACd,cAAc;AACd,cAAc;AACd,cAAc;AACd,cAAc;AACd,cAAc;AACd,cAAc;AACd,cAAc;AACd,cAAc;AACd,cAAc;AACd,cAAc;","names":[]}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { NormaliseOp } from '@polyprism/core';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Render a PHP single-quoted string literal. Single quotes don't process
|
|
5
|
+
* escapes other than `\\` and `\'`, so the encoder only needs to escape
|
|
6
|
+
* those two characters.
|
|
7
|
+
*
|
|
8
|
+
* Single-quoted strings are preferred over double-quoted because PHP's
|
|
9
|
+
* double-quoted strings interpolate `$variable` references — which would
|
|
10
|
+
* be a quiet injection hazard if a schema value ever started with `$`.
|
|
11
|
+
*/
|
|
12
|
+
declare function phpSingleQuote(value: string): string;
|
|
13
|
+
/**
|
|
14
|
+
* Render a `NormaliseOp` identifier as the PHP class constant the
|
|
15
|
+
* `polyprism/runtime` Composer package exposes.
|
|
16
|
+
*
|
|
17
|
+
* The renderer emits the constant reference (`Normalise::TRIM`) rather
|
|
18
|
+
* than the raw op string (`'trim'`) so a typo in this mapping is a
|
|
19
|
+
* compile-time error in the generated PHP rather than a silent no-op at
|
|
20
|
+
* runtime — and so renames on the runtime side are caught at codegen.
|
|
21
|
+
*
|
|
22
|
+
* This is the single source of truth: any new `NormaliseOp` variant has
|
|
23
|
+
* to extend this switch AND ship a matching `Normalise::*` class constant
|
|
24
|
+
* in `packages/runtime-php/src/Normalise.php`. Keeping them adjacent
|
|
25
|
+
* (one in `@polyprism/core`'s `NormaliseOp` union, one in the runtime
|
|
26
|
+
* package, one here) is the load-bearing contract.
|
|
27
|
+
*/
|
|
28
|
+
declare function phpNormaliseOpConstant(op: NormaliseOp): string;
|
|
29
|
+
|
|
30
|
+
export { phpNormaliseOpConstant, phpSingleQuote };
|
package/dist/literals.js
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
function phpSingleQuote(value) {
|
|
2
|
+
const escaped = value.replace(/\\/g, "\\\\").replace(/'/g, "\\'");
|
|
3
|
+
return `'${escaped}'`;
|
|
4
|
+
}
|
|
5
|
+
function phpNormaliseOpConstant(op) {
|
|
6
|
+
switch (op) {
|
|
7
|
+
case "trim":
|
|
8
|
+
return "Normalise::TRIM";
|
|
9
|
+
case "lowercase":
|
|
10
|
+
return "Normalise::LOWERCASE";
|
|
11
|
+
case "uppercase":
|
|
12
|
+
return "Normalise::UPPERCASE";
|
|
13
|
+
case "nullEmptyToNull":
|
|
14
|
+
return "Normalise::NULL_EMPTY_TO_NULL";
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
export {
|
|
18
|
+
phpNormaliseOpConstant,
|
|
19
|
+
phpSingleQuote
|
|
20
|
+
};
|
|
21
|
+
//# sourceMappingURL=literals.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/literals.ts"],"sourcesContent":["// Small helpers for emitting PHP literal expressions safely.\n//\n// Kept in a dedicated module so callers across the PHP family (render-model,\n// render-domain-class, and any future renderers that need to emit user-\n// controlled values into source code) share a single, correct escaper.\n\nimport type { NormaliseOp } from \"@polyprism/core\";\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 *\n * Single-quoted strings are preferred over double-quoted because PHP's\n * double-quoted strings interpolate `$variable` references — which would\n * be a quiet injection hazard if a schema value ever started with `$`.\n */\nexport function phpSingleQuote(value: string): string {\n const escaped = value.replace(/\\\\/g, \"\\\\\\\\\").replace(/'/g, \"\\\\'\");\n return `'${escaped}'`;\n}\n\n/**\n * Render a `NormaliseOp` identifier as the PHP class constant the\n * `polyprism/runtime` Composer package exposes.\n *\n * The renderer emits the constant reference (`Normalise::TRIM`) rather\n * than the raw op string (`'trim'`) so a typo in this mapping is a\n * compile-time error in the generated PHP rather than a silent no-op at\n * runtime — and so renames on the runtime side are caught at codegen.\n *\n * This is the single source of truth: any new `NormaliseOp` variant has\n * to extend this switch AND ship a matching `Normalise::*` class constant\n * in `packages/runtime-php/src/Normalise.php`. Keeping them adjacent\n * (one in `@polyprism/core`'s `NormaliseOp` union, one in the runtime\n * package, one here) is the load-bearing contract.\n */\nexport function phpNormaliseOpConstant(op: NormaliseOp): string {\n switch (op) {\n case \"trim\":\n return \"Normalise::TRIM\";\n case \"lowercase\":\n return \"Normalise::LOWERCASE\";\n case \"uppercase\":\n return \"Normalise::UPPERCASE\";\n case \"nullEmptyToNull\":\n return \"Normalise::NULL_EMPTY_TO_NULL\";\n }\n}\n"],"mappings":"AAiBO,SAAS,eAAe,OAAuB;AACpD,QAAM,UAAU,MAAM,QAAQ,OAAO,MAAM,EAAE,QAAQ,MAAM,KAAK;AAChE,SAAO,IAAI,OAAO;AACpB;AAiBO,SAAS,uBAAuB,IAAyB;AAC9D,UAAQ,IAAI;AAAA,IACV,KAAK;AACH,aAAO;AAAA,IACT,KAAK;AACH,aAAO;AAAA,IACT,KAAK;AACH,aAAO;AAAA,IACT,KAAK;AACH,aAAO;AAAA,EACX;AACF;","names":[]}
|
package/dist/phpdoc.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { NativeType, AnnotationSet } from '@polyprism/core';
|
|
1
|
+
import { NativeType, FieldDef, AnnotationSet } from '@polyprism/core';
|
|
2
2
|
|
|
3
3
|
interface RenderPhpDocOptions {
|
|
4
4
|
/** Number of leading spaces before each `*` line. 0 for top-level, 4 for class members. */
|
|
@@ -12,5 +12,14 @@ declare function renderPhpDoc(annotations: AnnotationSet, opts: RenderPhpDocOpti
|
|
|
12
12
|
* metadata. Returns null if none — caller spreads into extraTags.
|
|
13
13
|
*/
|
|
14
14
|
declare function buildNativeTypeTag(nativeType: NativeType | null): string | null;
|
|
15
|
+
/**
|
|
16
|
+
* Per-field PHPDoc extra-tag set: a `@var array<int, T>` PHPStan-shaped
|
|
17
|
+
* narrowing for list types (because PHP's native `array` doesn't carry an
|
|
18
|
+
* element type), plus any `@db.X(...)` native-type tag the field carries.
|
|
19
|
+
*
|
|
20
|
+
* Shared between `render-model.ts` (php-class / php-readonly) and
|
|
21
|
+
* `render-domain-class.ts` so both renderers emit the same field metadata.
|
|
22
|
+
*/
|
|
23
|
+
declare function collectFieldExtraTags(field: FieldDef, listElementDoc: string | null): string[];
|
|
15
24
|
|
|
16
|
-
export { type RenderPhpDocOptions, buildNativeTypeTag, renderPhpDoc };
|
|
25
|
+
export { type RenderPhpDocOptions, buildNativeTypeTag, collectFieldExtraTags, renderPhpDoc };
|
package/dist/phpdoc.js
CHANGED
|
@@ -25,8 +25,18 @@ function buildNativeTypeTag(nativeType) {
|
|
|
25
25
|
const args = nativeType.args.join(", ");
|
|
26
26
|
return args ? `@db.${nativeType.name}(${args})` : `@db.${nativeType.name}`;
|
|
27
27
|
}
|
|
28
|
+
function collectFieldExtraTags(field, listElementDoc) {
|
|
29
|
+
const tags = [];
|
|
30
|
+
if (listElementDoc !== null) {
|
|
31
|
+
tags.push(`@var array<int, ${listElementDoc}>`);
|
|
32
|
+
}
|
|
33
|
+
const nativeTag = buildNativeTypeTag(field.nativeType);
|
|
34
|
+
if (nativeTag) tags.push(nativeTag);
|
|
35
|
+
return tags;
|
|
36
|
+
}
|
|
28
37
|
export {
|
|
29
38
|
buildNativeTypeTag,
|
|
39
|
+
collectFieldExtraTags,
|
|
30
40
|
renderPhpDoc
|
|
31
41
|
};
|
|
32
42
|
//# sourceMappingURL=phpdoc.js.map
|
package/dist/phpdoc.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/phpdoc.ts"],"sourcesContent":["// PHPDoc block emission for models, fields, and enums.\n//\n// PHPDoc is the de-facto comment format read by IDEs (PhpStorm, VS Code via\n// Intelephense), static analysers (PHPStan, Psalm), and `phpdoc` itself.\n// We emit it for:\n// - `///` documentation lines from the Prisma schema (preserved verbatim\n// as the doc body)\n// - `@deprecated` tags (with optional reason from `@deprecated(\"reason\")`)\n// - `@db.X(precision, scale)` native-type metadata (kept as a tag because\n// PHP has no native Decimal precision generics, same reasoning as the\n// TS family — precision is schema-level info worth preserving)\n// - PHPDoc `@var` hints for list types — PHP's native `array` doesn't\n// carry an element type, so we annotate with `@var array<int, Type>`\n// so static analysers see the intended shape.\n//\n// The output is always either:\n// - empty string (no doc, no tags, no extras → no block)\n// - a multi-line `/** ... */` block ending with a newline\n\nimport type { AnnotationSet, NativeType } from \"@polyprism/core\";\n\nexport interface RenderPhpDocOptions {\n /** Number of leading spaces before each `*` line. 0 for top-level, 4 for class members. */\n readonly indent: number;\n /** Optional extra tag lines, each as `@tag content` without the leading `* `. */\n readonly extraTags?: readonly string[];\n}\n\nexport function renderPhpDoc(annotations: AnnotationSet, opts: RenderPhpDocOptions): string {\n const lines: string[] = [];\n\n if (annotations.documentation) {\n for (const docLine of annotations.documentation.split(\"\\n\")) {\n lines.push(docLine);\n }\n }\n\n if (annotations.deprecated) {\n const reason = annotations.deprecated.reason;\n // PHPDoc `@deprecated` follows the same shape as JSDoc/Javadoc.\n lines.push(reason ? `@deprecated ${reason}` : \"@deprecated\");\n }\n\n for (const tag of opts.extraTags ?? []) {\n lines.push(tag);\n }\n\n if (lines.length === 0) return \"\";\n\n const pad = \" \".repeat(opts.indent);\n const body = lines.map((line) => `${pad} * ${line}`).join(\"\\n\");\n return `${pad}/**\\n${body}\\n${pad} */\\n`;\n}\n\n/**\n * Build the @db.X native-type tag line if the field has Prisma native-type\n * metadata. Returns null if none — caller spreads into extraTags.\n */\nexport function buildNativeTypeTag(nativeType: NativeType | null): string | null {\n if (!nativeType) return null;\n const args = nativeType.args.join(\", \");\n return args ? `@db.${nativeType.name}(${args})` : `@db.${nativeType.name}`;\n}\n"],"mappings":"AA4BO,SAAS,aAAa,aAA4B,MAAmC;AAC1F,QAAM,QAAkB,CAAC;AAEzB,MAAI,YAAY,eAAe;AAC7B,eAAW,WAAW,YAAY,cAAc,MAAM,IAAI,GAAG;AAC3D,YAAM,KAAK,OAAO;AAAA,IACpB;AAAA,EACF;AAEA,MAAI,YAAY,YAAY;AAC1B,UAAM,SAAS,YAAY,WAAW;AAEtC,UAAM,KAAK,SAAS,eAAe,MAAM,KAAK,aAAa;AAAA,EAC7D;AAEA,aAAW,OAAO,KAAK,aAAa,CAAC,GAAG;AACtC,UAAM,KAAK,GAAG;AAAA,EAChB;AAEA,MAAI,MAAM,WAAW,EAAG,QAAO;AAE/B,QAAM,MAAM,IAAI,OAAO,KAAK,MAAM;AAClC,QAAM,OAAO,MAAM,IAAI,CAAC,SAAS,GAAG,GAAG,MAAM,IAAI,EAAE,EAAE,KAAK,IAAI;AAC9D,SAAO,GAAG,GAAG;AAAA,EAAQ,IAAI;AAAA,EAAK,GAAG;AAAA;AACnC;AAMO,SAAS,mBAAmB,YAA8C;AAC/E,MAAI,CAAC,WAAY,QAAO;AACxB,QAAM,OAAO,WAAW,KAAK,KAAK,IAAI;AACtC,SAAO,OAAO,OAAO,WAAW,IAAI,IAAI,IAAI,MAAM,OAAO,WAAW,IAAI;AAC1E;","names":[]}
|
|
1
|
+
{"version":3,"sources":["../src/phpdoc.ts"],"sourcesContent":["// PHPDoc block emission for models, fields, and enums.\n//\n// PHPDoc is the de-facto comment format read by IDEs (PhpStorm, VS Code via\n// Intelephense), static analysers (PHPStan, Psalm), and `phpdoc` itself.\n// We emit it for:\n// - `///` documentation lines from the Prisma schema (preserved verbatim\n// as the doc body)\n// - `@deprecated` tags (with optional reason from `@deprecated(\"reason\")`)\n// - `@db.X(precision, scale)` native-type metadata (kept as a tag because\n// PHP has no native Decimal precision generics, same reasoning as the\n// TS family — precision is schema-level info worth preserving)\n// - PHPDoc `@var` hints for list types — PHP's native `array` doesn't\n// carry an element type, so we annotate with `@var array<int, Type>`\n// so static analysers see the intended shape.\n//\n// The output is always either:\n// - empty string (no doc, no tags, no extras → no block)\n// - a multi-line `/** ... */` block ending with a newline\n\nimport type { AnnotationSet, FieldDef, NativeType } from \"@polyprism/core\";\n\nexport interface RenderPhpDocOptions {\n /** Number of leading spaces before each `*` line. 0 for top-level, 4 for class members. */\n readonly indent: number;\n /** Optional extra tag lines, each as `@tag content` without the leading `* `. */\n readonly extraTags?: readonly string[];\n}\n\nexport function renderPhpDoc(annotations: AnnotationSet, opts: RenderPhpDocOptions): string {\n const lines: string[] = [];\n\n if (annotations.documentation) {\n for (const docLine of annotations.documentation.split(\"\\n\")) {\n lines.push(docLine);\n }\n }\n\n if (annotations.deprecated) {\n const reason = annotations.deprecated.reason;\n // PHPDoc `@deprecated` follows the same shape as JSDoc/Javadoc.\n lines.push(reason ? `@deprecated ${reason}` : \"@deprecated\");\n }\n\n for (const tag of opts.extraTags ?? []) {\n lines.push(tag);\n }\n\n if (lines.length === 0) return \"\";\n\n const pad = \" \".repeat(opts.indent);\n const body = lines.map((line) => `${pad} * ${line}`).join(\"\\n\");\n return `${pad}/**\\n${body}\\n${pad} */\\n`;\n}\n\n/**\n * Build the @db.X native-type tag line if the field has Prisma native-type\n * metadata. Returns null if none — caller spreads into extraTags.\n */\nexport function buildNativeTypeTag(nativeType: NativeType | null): string | null {\n if (!nativeType) return null;\n const args = nativeType.args.join(\", \");\n return args ? `@db.${nativeType.name}(${args})` : `@db.${nativeType.name}`;\n}\n\n/**\n * Per-field PHPDoc extra-tag set: a `@var array<int, T>` PHPStan-shaped\n * narrowing for list types (because PHP's native `array` doesn't carry an\n * element type), plus any `@db.X(...)` native-type tag the field carries.\n *\n * Shared between `render-model.ts` (php-class / php-readonly) and\n * `render-domain-class.ts` so both renderers emit the same field metadata.\n */\nexport function collectFieldExtraTags(field: FieldDef, listElementDoc: string | null): string[] {\n const tags: string[] = [];\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"],"mappings":"AA4BO,SAAS,aAAa,aAA4B,MAAmC;AAC1F,QAAM,QAAkB,CAAC;AAEzB,MAAI,YAAY,eAAe;AAC7B,eAAW,WAAW,YAAY,cAAc,MAAM,IAAI,GAAG;AAC3D,YAAM,KAAK,OAAO;AAAA,IACpB;AAAA,EACF;AAEA,MAAI,YAAY,YAAY;AAC1B,UAAM,SAAS,YAAY,WAAW;AAEtC,UAAM,KAAK,SAAS,eAAe,MAAM,KAAK,aAAa;AAAA,EAC7D;AAEA,aAAW,OAAO,KAAK,aAAa,CAAC,GAAG;AACtC,UAAM,KAAK,GAAG;AAAA,EAChB;AAEA,MAAI,MAAM,WAAW,EAAG,QAAO;AAE/B,QAAM,MAAM,IAAI,OAAO,KAAK,MAAM;AAClC,QAAM,OAAO,MAAM,IAAI,CAAC,SAAS,GAAG,GAAG,MAAM,IAAI,EAAE,EAAE,KAAK,IAAI;AAC9D,SAAO,GAAG,GAAG;AAAA,EAAQ,IAAI;AAAA,EAAK,GAAG;AAAA;AACnC;AAMO,SAAS,mBAAmB,YAA8C;AAC/E,MAAI,CAAC,WAAY,QAAO;AACxB,QAAM,OAAO,WAAW,KAAK,KAAK,IAAI;AACtC,SAAO,OAAO,OAAO,WAAW,IAAI,IAAI,IAAI,MAAM,OAAO,WAAW,IAAI;AAC1E;AAUO,SAAS,sBAAsB,OAAiB,gBAAyC;AAC9F,QAAM,OAAiB,CAAC;AACxB,MAAI,mBAAmB,MAAM;AAC3B,SAAK,KAAK,mBAAmB,cAAc,GAAG;AAAA,EAChD;AACA,QAAM,YAAY,mBAAmB,MAAM,UAAU;AACrD,MAAI,UAAW,MAAK,KAAK,SAAS;AAClC,SAAO;AACT;","names":[]}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { ModelDef, PolyPrismIR, PolyPrismConfig, Diagnostic } from '@polyprism/core';
|
|
2
|
+
|
|
3
|
+
interface RenderPhpDomainClassOptions {
|
|
4
|
+
readonly model: ModelDef;
|
|
5
|
+
readonly ir: PolyPrismIR;
|
|
6
|
+
readonly config: PolyPrismConfig;
|
|
7
|
+
readonly modelsNamespace: string;
|
|
8
|
+
readonly enumsNamespace: string;
|
|
9
|
+
readonly jsonTypesNamespace: string;
|
|
10
|
+
readonly jsonTypeClassNames: ReadonlySet<string>;
|
|
11
|
+
}
|
|
12
|
+
interface RenderPhpDomainClassResult {
|
|
13
|
+
readonly source: string;
|
|
14
|
+
readonly issues: readonly Diagnostic[];
|
|
15
|
+
}
|
|
16
|
+
declare function renderPhpDomainClass(opts: RenderPhpDomainClassOptions): RenderPhpDomainClassResult;
|
|
17
|
+
|
|
18
|
+
export { type RenderPhpDomainClassOptions, type RenderPhpDomainClassResult, renderPhpDomainClass };
|
|
@@ -0,0 +1,311 @@
|
|
|
1
|
+
import {
|
|
2
|
+
buildEnumIdentLookup,
|
|
3
|
+
buildModelIdentLookup,
|
|
4
|
+
resolveFieldIdent,
|
|
5
|
+
resolveTypeIdent
|
|
6
|
+
} from "@polyprism/core";
|
|
7
|
+
import {
|
|
8
|
+
resolvePhpCoerceDecision
|
|
9
|
+
} from "./coerce-rules.js";
|
|
10
|
+
import { formatPhpDefault, isCompileTimeConstantPhpExpr } from "./defaults.js";
|
|
11
|
+
import { phpNormaliseOpConstant, phpSingleQuote } from "./literals.js";
|
|
12
|
+
import { collectFieldExtraTags, renderPhpDoc } from "./phpdoc.js";
|
|
13
|
+
import { mapFieldPhpType } from "./type-mapper.js";
|
|
14
|
+
import { UseCollector } from "./use-collector.js";
|
|
15
|
+
const RUNTIME_NAMESPACE = "Polyprism\\Runtime";
|
|
16
|
+
const COERCE_FQN = `${RUNTIME_NAMESPACE}\\Coerce`;
|
|
17
|
+
const NORMALISE_FQN = `${RUNTIME_NAMESPACE}\\Normalise`;
|
|
18
|
+
function renderPhpDomainClass(opts) {
|
|
19
|
+
const {
|
|
20
|
+
model,
|
|
21
|
+
ir,
|
|
22
|
+
config,
|
|
23
|
+
modelsNamespace,
|
|
24
|
+
enumsNamespace,
|
|
25
|
+
jsonTypesNamespace,
|
|
26
|
+
jsonTypeClassNames
|
|
27
|
+
} = opts;
|
|
28
|
+
const issues = [];
|
|
29
|
+
const collectDiagnostic = (d) => {
|
|
30
|
+
issues.push(d);
|
|
31
|
+
};
|
|
32
|
+
const enumFqnLookup = buildEnumIdentLookup(ir, config, enumsNamespace);
|
|
33
|
+
const modelFqnLookup = buildModelIdentLookup(ir, config, modelsNamespace);
|
|
34
|
+
const selfIdent = resolveTypeIdent({
|
|
35
|
+
schemaName: model.name,
|
|
36
|
+
override: model.annotations.name,
|
|
37
|
+
convention: config.naming.typeNaming
|
|
38
|
+
});
|
|
39
|
+
const selfFqn = `${modelsNamespace}\\${selfIdent}`;
|
|
40
|
+
const uses = new UseCollector(modelsNamespace);
|
|
41
|
+
const plans = [];
|
|
42
|
+
for (const field of model.fields) {
|
|
43
|
+
if (field.annotations.hide) continue;
|
|
44
|
+
const ident = resolveFieldIdent({
|
|
45
|
+
schemaName: field.name,
|
|
46
|
+
override: field.annotations.name,
|
|
47
|
+
convention: config.naming.fieldNaming
|
|
48
|
+
});
|
|
49
|
+
const mapping = mapFieldPhpType({
|
|
50
|
+
field,
|
|
51
|
+
modelSchemaName: model.name,
|
|
52
|
+
uses,
|
|
53
|
+
enumFqnLookup,
|
|
54
|
+
modelFqnLookup,
|
|
55
|
+
selfModelFqn: selfFqn,
|
|
56
|
+
jsonTypesNamespace,
|
|
57
|
+
jsonTypeClassNames,
|
|
58
|
+
onDiagnostic: collectDiagnostic
|
|
59
|
+
});
|
|
60
|
+
const baseType = mapping.signatureType.startsWith("?") ? mapping.signatureType.slice(1) : mapping.signatureType;
|
|
61
|
+
const ruleResult = resolvePhpCoerceDecision(field, model.name, baseType);
|
|
62
|
+
for (const issue of ruleResult.issues) {
|
|
63
|
+
issues.push(toDiagnostic(issue));
|
|
64
|
+
}
|
|
65
|
+
const coerceTargetType = storageTypeFor(ruleResult.decision.kind);
|
|
66
|
+
const effectiveBaseType = coerceTargetType ?? baseType;
|
|
67
|
+
const effectiveSignatureType = field.isList ? mapping.signatureType : field.isRequired || effectiveBaseType === "mixed" ? effectiveBaseType : `?${effectiveBaseType}`;
|
|
68
|
+
const normaliseOps = resolveNormaliseOps(field, model.name, issues);
|
|
69
|
+
for (const parseIssue of field.annotations.parseIssues) {
|
|
70
|
+
issues.push({
|
|
71
|
+
severity: parseIssue.severity,
|
|
72
|
+
context: `${model.name}.${field.name}`,
|
|
73
|
+
message: parseIssue.message
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
const paramDefaultExpr = formatPhpDefault(field, enumFqnLookup, uses);
|
|
77
|
+
const propertyDefaultExpr = paramDefaultExpr !== null && isCompileTimeConstantPhpExpr(paramDefaultExpr) ? paramDefaultExpr : null;
|
|
78
|
+
plans.push({
|
|
79
|
+
field,
|
|
80
|
+
ident,
|
|
81
|
+
signatureType: effectiveSignatureType,
|
|
82
|
+
baseType: effectiveBaseType,
|
|
83
|
+
listElementDoc: mapping.listElementDoc,
|
|
84
|
+
decision: ruleResult.decision,
|
|
85
|
+
normaliseOps,
|
|
86
|
+
paramDefaultExpr,
|
|
87
|
+
propertyDefaultExpr
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
let needCoerce = false;
|
|
91
|
+
let needNormalise = false;
|
|
92
|
+
for (const p of plans) {
|
|
93
|
+
if (p.decision.runtimeMethod !== null) needCoerce = true;
|
|
94
|
+
if (p.normaliseOps.length > 0) needNormalise = true;
|
|
95
|
+
}
|
|
96
|
+
if (needCoerce) uses.add(COERCE_FQN);
|
|
97
|
+
if (needNormalise) uses.add(NORMALISE_FQN);
|
|
98
|
+
const propertyBlocks = plans.map((p) => renderPropertyBlock(p, model.name));
|
|
99
|
+
const paramEntries = plans.map((p) => {
|
|
100
|
+
const paramType = constructorParamType(p);
|
|
101
|
+
const base = ` ${paramType} $${p.ident}`;
|
|
102
|
+
const line = p.paramDefaultExpr !== null ? `${base} = ${p.paramDefaultExpr},` : `${base},`;
|
|
103
|
+
return { line, hasDefault: p.paramDefaultExpr !== null };
|
|
104
|
+
});
|
|
105
|
+
const paramLines = [
|
|
106
|
+
...paramEntries.filter((e) => !e.hasDefault).map((e) => e.line),
|
|
107
|
+
...paramEntries.filter((e) => e.hasDefault).map((e) => e.line)
|
|
108
|
+
];
|
|
109
|
+
const constructorAssignments = plans.map((p) => renderConstructorAssign(p));
|
|
110
|
+
const ctorParamBlock = paramLines.length > 0 ? `
|
|
111
|
+
${paramLines.join("\n")}
|
|
112
|
+
` : "";
|
|
113
|
+
const ctorBodyBlock = constructorAssignments.length > 0 ? `
|
|
114
|
+
${constructorAssignments.join("\n")}
|
|
115
|
+
` : " ";
|
|
116
|
+
const fromMethodBlock = renderFromMethod(plans, selfIdent);
|
|
117
|
+
const usesBlock = uses.render();
|
|
118
|
+
const headerDoc = renderPhpDoc(model.annotations, { indent: 0 });
|
|
119
|
+
const propertiesBody = propertyBlocks.length > 0 ? `
|
|
120
|
+
${propertyBlocks.join("\n\n")}
|
|
121
|
+
|
|
122
|
+
` : "\n";
|
|
123
|
+
const fromMethodTail = fromMethodBlock ? `
|
|
124
|
+
|
|
125
|
+
${fromMethodBlock}` : "";
|
|
126
|
+
const source = [
|
|
127
|
+
"<?php",
|
|
128
|
+
"",
|
|
129
|
+
"declare(strict_types=1);",
|
|
130
|
+
"",
|
|
131
|
+
`namespace ${modelsNamespace};`,
|
|
132
|
+
"",
|
|
133
|
+
`${usesBlock}${headerDoc}final class ${selfIdent}
|
|
134
|
+
{${propertiesBody} public function __construct(${ctorParamBlock}) {${ctorBodyBlock}}${fromMethodTail}
|
|
135
|
+
}`,
|
|
136
|
+
""
|
|
137
|
+
].join("\n");
|
|
138
|
+
return { source, issues };
|
|
139
|
+
}
|
|
140
|
+
function constructorParamType(plan) {
|
|
141
|
+
const { decision, field, signatureType } = plan;
|
|
142
|
+
if (field.isList) return signatureType;
|
|
143
|
+
if (decision.kind === "strict") return signatureType;
|
|
144
|
+
if (field.isRequired) return decision.setterInputType;
|
|
145
|
+
return `${decision.setterInputType}|null`;
|
|
146
|
+
}
|
|
147
|
+
function renderPropertyBlock(plan, modelName) {
|
|
148
|
+
const { ident, signatureType, decision, normaliseOps, propertyDefaultExpr, field } = plan;
|
|
149
|
+
const fieldPath = `${modelName}.${field.name}`;
|
|
150
|
+
const propertyDoc = renderPhpDoc(field.annotations, {
|
|
151
|
+
indent: 4,
|
|
152
|
+
extraTags: collectFieldExtraTags(field, plan.listElementDoc)
|
|
153
|
+
});
|
|
154
|
+
const defaultClause = propertyDefaultExpr !== null ? ` = ${propertyDefaultExpr}` : "";
|
|
155
|
+
const needsHook = decision.kind !== "strict" || normaliseOps.length > 0;
|
|
156
|
+
if (!needsHook) {
|
|
157
|
+
return `${propertyDoc} public ${signatureType} $${ident}${defaultClause};`;
|
|
158
|
+
}
|
|
159
|
+
const setterParamType = setHookParamType(plan);
|
|
160
|
+
const setterBody = renderSetterBody(plan, fieldPath);
|
|
161
|
+
return `${propertyDoc} public ${signatureType} $${ident}${defaultClause} {
|
|
162
|
+
set(${setterParamType} $value) {
|
|
163
|
+
${setterBody} }
|
|
164
|
+
}`;
|
|
165
|
+
}
|
|
166
|
+
function setHookParamType(plan) {
|
|
167
|
+
return constructorParamType(plan);
|
|
168
|
+
}
|
|
169
|
+
function renderSetterBody(plan, fieldPath) {
|
|
170
|
+
const { ident, decision, normaliseOps, field } = plan;
|
|
171
|
+
const nullable = !field.isRequired && !field.isList;
|
|
172
|
+
const stringInput = decision.kind === "strict";
|
|
173
|
+
let valueExpr = "$value";
|
|
174
|
+
let normaliseIsNullSafe = false;
|
|
175
|
+
if (normaliseOps.length > 0) {
|
|
176
|
+
const opsLiteral = renderNormaliseOpsLiteral(normaliseOps);
|
|
177
|
+
if (stringInput) {
|
|
178
|
+
if (nullable) {
|
|
179
|
+
valueExpr = `Normalise::applyNullable($value, ${opsLiteral})`;
|
|
180
|
+
normaliseIsNullSafe = true;
|
|
181
|
+
} else {
|
|
182
|
+
valueExpr = `Normalise::apply($value, ${opsLiteral})`;
|
|
183
|
+
}
|
|
184
|
+
} else {
|
|
185
|
+
valueExpr = `(is_string($value) ? Normalise::apply($value, ${opsLiteral}) : $value)`;
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
const coerceExpr = renderCoerceCall(decision, valueExpr, fieldPath);
|
|
189
|
+
if (nullable && !normaliseIsNullSafe) {
|
|
190
|
+
return ` $this->${ident} = $value === null ? null : ${coerceExpr};
|
|
191
|
+
`;
|
|
192
|
+
}
|
|
193
|
+
return ` $this->${ident} = ${coerceExpr};
|
|
194
|
+
`;
|
|
195
|
+
}
|
|
196
|
+
function renderCoerceCall(decision, valueExpr, fieldPath) {
|
|
197
|
+
switch (decision.kind) {
|
|
198
|
+
case "strict":
|
|
199
|
+
return valueExpr;
|
|
200
|
+
case "coerce-int":
|
|
201
|
+
return `Coerce::int(${valueExpr}, ${phpSingleQuote(fieldPath)})`;
|
|
202
|
+
case "coerce-float":
|
|
203
|
+
return `Coerce::float(${valueExpr}, ${phpSingleQuote(fieldPath)})`;
|
|
204
|
+
case "coerce-bigint":
|
|
205
|
+
return `Coerce::bigint(${valueExpr}, ${phpSingleQuote(fieldPath)})`;
|
|
206
|
+
case "coerce-date":
|
|
207
|
+
return `Coerce::date(${valueExpr}, ${phpSingleQuote(fieldPath)})`;
|
|
208
|
+
case "coerce-decimal":
|
|
209
|
+
return `Coerce::decimal(${valueExpr}, ${phpSingleQuote(fieldPath)})`;
|
|
210
|
+
case "coerce-string":
|
|
211
|
+
return `(string) ${valueExpr}`;
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
function renderNormaliseOpsLiteral(ops) {
|
|
215
|
+
const constants = ops.map(phpNormaliseOpConstant).join(", ");
|
|
216
|
+
return `[${constants}]`;
|
|
217
|
+
}
|
|
218
|
+
function renderConstructorAssign(plan) {
|
|
219
|
+
const { ident } = plan;
|
|
220
|
+
return ` $this->${ident} = $${ident};`;
|
|
221
|
+
}
|
|
222
|
+
function renderFromMethod(plans, selfIdent) {
|
|
223
|
+
if (plans.length === 0) return "";
|
|
224
|
+
const required = plans.filter((p) => p.paramDefaultExpr === null);
|
|
225
|
+
const optional = plans.filter((p) => p.paramDefaultExpr !== null);
|
|
226
|
+
const ordered = [...required, ...optional];
|
|
227
|
+
const argLines = ordered.map((p) => renderFromArg(p, selfIdent));
|
|
228
|
+
return ` /**
|
|
229
|
+
* Hydrate ${selfIdent} from a Record-like array (e.g. a JSON-decoded
|
|
230
|
+
* request body, a Prisma row, a queue message payload). Routes every
|
|
231
|
+
* field through the constructor so property hooks fire \u2014 \`@coerce\` and
|
|
232
|
+
* \`@normalise\` rules apply identically to a direct \`new ${selfIdent}(...)\`
|
|
233
|
+
* call.
|
|
234
|
+
*
|
|
235
|
+
* **Not a validator.** Required fields missing from \`$data\` throw
|
|
236
|
+
* \`\\TypeError\` with the field path. Type-mismatched values (e.g. an
|
|
237
|
+
* array for a typed property) propagate as PHP \`\\TypeError\` from the
|
|
238
|
+
* underlying property hook. Pre-validate untrusted input at the boundary
|
|
239
|
+
* (JSON-schema, attribute validation, etc.) if those failure modes need
|
|
240
|
+
* to be caught with richer context.
|
|
241
|
+
*
|
|
242
|
+
* Unknown keys in \`$data\` are silently dropped.
|
|
243
|
+
*
|
|
244
|
+
* @param array<string, mixed> $data
|
|
245
|
+
*/
|
|
246
|
+
public static function from(array $data): self
|
|
247
|
+
{
|
|
248
|
+
return new self(
|
|
249
|
+
${argLines.join("\n")}
|
|
250
|
+
);
|
|
251
|
+
}`;
|
|
252
|
+
}
|
|
253
|
+
function renderFromArg(plan, selfIdent) {
|
|
254
|
+
const key = plan.ident;
|
|
255
|
+
const keyLiteral = phpSingleQuote(key);
|
|
256
|
+
if (plan.paramDefaultExpr === null) {
|
|
257
|
+
const message = `${selfIdent}::from(): missing required field "${key}"`;
|
|
258
|
+
return ` ${key}: $data[${keyLiteral}] ?? throw new \\TypeError(${phpSingleQuote(message)}),`;
|
|
259
|
+
}
|
|
260
|
+
return ` ${key}: $data[${keyLiteral}] ?? ${plan.paramDefaultExpr},`;
|
|
261
|
+
}
|
|
262
|
+
function resolveNormaliseOps(field, modelName, issues) {
|
|
263
|
+
const ops = field.annotations.normalise;
|
|
264
|
+
if (!ops || ops.length === 0) return [];
|
|
265
|
+
if (field.isList) return [];
|
|
266
|
+
if (field.type.kind !== "scalar" || field.type.scalar !== "String") {
|
|
267
|
+
issues.push({
|
|
268
|
+
severity: "warning",
|
|
269
|
+
context: `${modelName}.${field.name}`,
|
|
270
|
+
message: "@normalise has no effect on non-String fields (silently ignored)."
|
|
271
|
+
});
|
|
272
|
+
return [];
|
|
273
|
+
}
|
|
274
|
+
if (field.isRequired && ops.includes("nullEmptyToNull")) {
|
|
275
|
+
issues.push({
|
|
276
|
+
severity: "error",
|
|
277
|
+
context: `${modelName}.${field.name}`,
|
|
278
|
+
message: "@normalise(nullEmptyToNull) requires the field to be nullable. Mark the field optional in the schema or remove the op."
|
|
279
|
+
});
|
|
280
|
+
return ops.filter((op) => op !== "nullEmptyToNull");
|
|
281
|
+
}
|
|
282
|
+
return ops;
|
|
283
|
+
}
|
|
284
|
+
function toDiagnostic(issue) {
|
|
285
|
+
return {
|
|
286
|
+
severity: issue.severity,
|
|
287
|
+
context: issue.fieldPath,
|
|
288
|
+
message: issue.message
|
|
289
|
+
};
|
|
290
|
+
}
|
|
291
|
+
function storageTypeFor(kind) {
|
|
292
|
+
switch (kind) {
|
|
293
|
+
case "strict":
|
|
294
|
+
return null;
|
|
295
|
+
case "coerce-int":
|
|
296
|
+
case "coerce-bigint":
|
|
297
|
+
return "int";
|
|
298
|
+
case "coerce-float":
|
|
299
|
+
return "float";
|
|
300
|
+
case "coerce-decimal":
|
|
301
|
+
return "string";
|
|
302
|
+
case "coerce-date":
|
|
303
|
+
return "\\DateTimeImmutable";
|
|
304
|
+
case "coerce-string":
|
|
305
|
+
return "string";
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
export {
|
|
309
|
+
renderPhpDomainClass
|
|
310
|
+
};
|
|
311
|
+
//# sourceMappingURL=render-domain-class.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/render-domain-class.ts"],"sourcesContent":["// Renders one Prisma model as a PHP 8.4 domain class with property hooks.\n//\n// Output shape (for one model):\n//\n// <?php\n// declare(strict_types=1);\n// namespace Generated\\Models;\n//\n// use Polyprism\\Runtime\\Coerce;\n// use Polyprism\\Runtime\\Normalise;\n//\n// final class User\n// {\n// public string $email {\n// set(string $value) {\n// $this->email = Normalise::apply($value, [Normalise::TRIM, Normalise::LOWERCASE]);\n// }\n// }\n//\n// public int $points = 0 {\n// set(int|string $value) {\n// $this->points = Coerce::int($value, 'User.points');\n// }\n// }\n//\n// public ?string $name = null;\n//\n// public function __construct(\n// string $email,\n// int|string $points = 0,\n// ?string $name = null,\n// ) {\n// $this->email = $email;\n// $this->points = $points;\n// if ($name !== null) {\n// $this->name = $name;\n// }\n// }\n// }\n//\n// Key shape decisions (and the reasons):\n//\n// - **Properties live OUTSIDE the constructor**, not via property\n// promotion. Property promotion + hooks does not widen the constructor\n// param type — promoted-property assignment bypasses the set hook on\n// PHP 8.4. Tested locally; the constructor param keeps the property's\n// declared type, defeating @coerce. Explicit non-promoted properties +\n// a constructor body that assigns through `$this->prop = $arg` route\n// EVERY initial value through the hook, which is the load-bearing\n// contract for @coerce/@normalise to fire on construction.\n//\n// - **No hook block for pure-strict fields with no normalise** — they\n// emit as plain typed properties (`public ?string $name = null;`).\n// This keeps unannotated fields visually indistinguishable from a\n// hand-written PHP DTO, and avoids paying the hook-dispatch cost when\n// no work would happen.\n//\n// - **Constructor param widens to the SETTER input type**, not the\n// property type — so a caller can pass `'42'` to an `int $points` and\n// the value flows through `Coerce::int` on the way in.\n//\n// - **Required-first then optional**, mirroring php-class. PHP 8.4\n// deprecates optional-before-required positional ordering. Named-\n// argument callers are unaffected by the reorder.\n\nimport type {\n FieldDef,\n ModelDef,\n NormaliseOp,\n PolyPrismConfig,\n PolyPrismIR,\n} from \"@polyprism/core\";\nimport {\n buildEnumIdentLookup,\n buildModelIdentLookup,\n resolveFieldIdent,\n resolveTypeIdent,\n} from \"@polyprism/core\";\n\nimport {\n type PhpCoerceDecision,\n type PhpCoerceRulesIssue,\n resolvePhpCoerceDecision,\n} from \"./coerce-rules.js\";\nimport { formatPhpDefault, isCompileTimeConstantPhpExpr } from \"./defaults.js\";\nimport type { Diagnostic } from \"./diagnostics.js\";\nimport { phpNormaliseOpConstant, phpSingleQuote } from \"./literals.js\";\nimport { collectFieldExtraTags, renderPhpDoc } from \"./phpdoc.js\";\nimport { mapFieldPhpType } from \"./type-mapper.js\";\nimport { UseCollector } from \"./use-collector.js\";\n\nexport interface RenderPhpDomainClassOptions {\n readonly model: ModelDef;\n readonly ir: PolyPrismIR;\n readonly config: PolyPrismConfig;\n readonly modelsNamespace: string;\n readonly enumsNamespace: string;\n readonly jsonTypesNamespace: string;\n readonly jsonTypeClassNames: ReadonlySet<string>;\n}\n\nexport interface RenderPhpDomainClassResult {\n readonly source: string;\n readonly issues: readonly Diagnostic[];\n}\n\nconst RUNTIME_NAMESPACE = \"Polyprism\\\\Runtime\";\nconst COERCE_FQN = `${RUNTIME_NAMESPACE}\\\\Coerce`;\nconst NORMALISE_FQN = `${RUNTIME_NAMESPACE}\\\\Normalise`;\n\ninterface FieldPlan {\n readonly field: FieldDef;\n readonly ident: string;\n /**\n * The property declaration's full type, including `?` for nullable scalars.\n * For strict fields this matches the type-mapper output. For cross-type\n * `@coerce(target)` fields it shifts to the coerce target's canonical PHP\n * type — see `storageBaseType` below for the why.\n */\n readonly signatureType: string;\n /** Base type without the nullable prefix (e.g. `string`, `int`, `\\DateTimeImmutable`). */\n readonly baseType: string;\n /** PHPDoc element type for lists, or null. */\n readonly listElementDoc: string | null;\n readonly decision: PhpCoerceDecision;\n readonly normaliseOps: readonly NormaliseOp[];\n /**\n * Default expression for the **constructor parameter**, or null if none.\n * Always emitted on the param line (PHP allows `new`/runtime expressions\n * in constructor-parameter default position).\n */\n readonly paramDefaultExpr: string | null;\n /**\n * Default expression for the **property declaration**, or null if none.\n * Restricted to compile-time-constant expressions (PHP only accepts those\n * in non-static property defaults). Runtime expressions like\n * `new \\DateTimeImmutable()` are emitted on the constructor param only —\n * the property declaration goes without an initializer, and the\n * constructor body assigns to it on every construction.\n */\n readonly propertyDefaultExpr: string | null;\n}\n\nexport function renderPhpDomainClass(\n opts: RenderPhpDomainClassOptions,\n): RenderPhpDomainClassResult {\n const {\n model,\n ir,\n config,\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 const enumFqnLookup = buildEnumIdentLookup(ir, config, enumsNamespace);\n const modelFqnLookup = buildModelIdentLookup(ir, config, modelsNamespace);\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 const plans: FieldPlan[] = [];\n for (const field of model.fields) {\n if (field.annotations.hide) continue;\n\n const ident = 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 // Stripped of the leading `?` (for nullable scalars) so the coerce-rules\n // decision matrix and setter-input-type composition can layer nullability\n // back on. Lists keep their `array` shape — the renderer skips hook emit\n // for lists anyway.\n const baseType = mapping.signatureType.startsWith(\"?\")\n ? mapping.signatureType.slice(1)\n : mapping.signatureType;\n\n const ruleResult = resolvePhpCoerceDecision(field, model.name, baseType);\n for (const issue of ruleResult.issues) {\n issues.push(toDiagnostic(issue));\n }\n\n // **Storage type vs declared type.** The setter writes the coerce\n // result into `$this->prop`, so the property must be typed to accept\n // whatever the coerce function returns. For default-coerce scalars\n // (Int → coerce-int → int, DateTime → coerce-date → \\DateTimeImmutable,\n // etc.) the type-mapper output already matches. But for cross-type\n // `@coerce(target)` (e.g. `String @coerce(int)`) the type-mapper says\n // `string` while the setter stores `int` — runtime TypeError on\n // assignment. Shift the property type to the coerce target's canonical\n // PHP type to keep the hook contract consistent.\n const coerceTargetType = storageTypeFor(ruleResult.decision.kind);\n const effectiveBaseType = coerceTargetType ?? baseType;\n // `mixed` already includes null in PHP's type system, so `?mixed` is a\n // syntax error. The only path that produces `mixed` here is a Json\n // field with no `@json(...)` annotation — type-mapper's wrapNullability\n // applies the same guard for the constructor-promotion renderer; we\n // re-apply it here because we re-derive the signature from baseType\n // (the coerce-target storage override means we can't just reuse\n // mapping.signatureType verbatim).\n const effectiveSignatureType = field.isList\n ? mapping.signatureType\n : field.isRequired || effectiveBaseType === \"mixed\"\n ? effectiveBaseType\n : `?${effectiveBaseType}`;\n\n const normaliseOps = resolveNormaliseOps(field, model.name, issues);\n\n for (const parseIssue of field.annotations.parseIssues) {\n issues.push({\n severity: parseIssue.severity,\n context: `${model.name}.${field.name}`,\n message: parseIssue.message,\n });\n }\n\n const paramDefaultExpr = formatPhpDefault(field, enumFqnLookup, uses);\n const propertyDefaultExpr =\n paramDefaultExpr !== null && isCompileTimeConstantPhpExpr(paramDefaultExpr)\n ? paramDefaultExpr\n : null;\n\n plans.push({\n field,\n ident,\n signatureType: effectiveSignatureType,\n baseType: effectiveBaseType,\n listElementDoc: mapping.listElementDoc,\n decision: ruleResult.decision,\n normaliseOps,\n paramDefaultExpr,\n propertyDefaultExpr,\n });\n }\n\n // Decide whether we need the runtime use statements. Done after the plan\n // loop so we only add them if any field actually uses them.\n let needCoerce = false;\n let needNormalise = false;\n for (const p of plans) {\n if (p.decision.runtimeMethod !== null) needCoerce = true;\n if (p.normaliseOps.length > 0) needNormalise = true;\n }\n if (needCoerce) uses.add(COERCE_FQN);\n if (needNormalise) uses.add(NORMALISE_FQN);\n\n // ---------- property declarations ----------\n const propertyBlocks = plans.map((p) => renderPropertyBlock(p, model.name));\n\n // ---------- constructor ----------\n type ParamEntry = { line: string; hasDefault: boolean };\n const paramEntries: ParamEntry[] = plans.map((p) => {\n const paramType = constructorParamType(p);\n const base = ` ${paramType} $${p.ident}`;\n const line = p.paramDefaultExpr !== null ? `${base} = ${p.paramDefaultExpr},` : `${base},`;\n return { line, hasDefault: p.paramDefaultExpr !== null };\n });\n // Stable partition: required first (preserve schema order), optional second.\n const paramLines = [\n ...paramEntries.filter((e) => !e.hasDefault).map((e) => e.line),\n ...paramEntries.filter((e) => e.hasDefault).map((e) => e.line),\n ];\n\n const constructorAssignments = plans.map((p) => renderConstructorAssign(p));\n\n const ctorParamBlock = paramLines.length > 0 ? `\\n${paramLines.join(\"\\n\")}\\n ` : \"\";\n const ctorBodyBlock =\n constructorAssignments.length > 0 ? `\\n${constructorAssignments.join(\"\\n\")}\\n ` : \" \";\n\n const fromMethodBlock = renderFromMethod(plans, selfIdent);\n\n // ---------- assemble ----------\n const usesBlock = uses.render();\n const headerDoc = renderPhpDoc(model.annotations, { indent: 0 });\n const propertiesBody = propertyBlocks.length > 0 ? `\\n${propertyBlocks.join(\"\\n\\n\")}\\n\\n` : \"\\n\";\n const fromMethodTail = fromMethodBlock ? `\\n\\n${fromMethodBlock}` : \"\";\n\n const source = [\n \"<?php\",\n \"\",\n \"declare(strict_types=1);\",\n \"\",\n `namespace ${modelsNamespace};`,\n \"\",\n `${usesBlock}${headerDoc}final class ${selfIdent}\\n{` +\n `${propertiesBody}` +\n ` public function __construct(${ctorParamBlock}) {${ctorBodyBlock}}${fromMethodTail}\\n}`,\n \"\",\n ].join(\"\\n\");\n\n return { source, issues };\n}\n\n// ---------- helpers ----------\n\nfunction constructorParamType(plan: FieldPlan): string {\n const { decision, field, signatureType } = plan;\n // Lists keep `array` (per the type-mapper convention).\n if (field.isList) return signatureType;\n // strict path → use the (already-nullable-correct) signatureType verbatim.\n if (decision.kind === \"strict\") return signatureType;\n // Coerce path: widened setter input type, with nullability layered on\n // when the field is nullable.\n if (field.isRequired) return decision.setterInputType;\n return `${decision.setterInputType}|null`;\n}\n\nfunction renderPropertyBlock(plan: FieldPlan, modelName: string): string {\n const { ident, signatureType, decision, normaliseOps, propertyDefaultExpr, field } = plan;\n const fieldPath = `${modelName}.${field.name}`;\n const propertyDoc = renderPhpDoc(field.annotations, {\n indent: 4,\n extraTags: collectFieldExtraTags(field, plan.listElementDoc),\n });\n const defaultClause = propertyDefaultExpr !== null ? ` = ${propertyDefaultExpr}` : \"\";\n\n // No-hook path: emit a plain typed property. This is the common case for\n // String / Boolean / enum / relation / Bytes / Json / list fields without\n // any @normalise annotation. The result looks identical to a hand-written\n // PHP property — no hook-dispatch overhead, no visual noise.\n const needsHook = decision.kind !== \"strict\" || normaliseOps.length > 0;\n if (!needsHook) {\n return `${propertyDoc} public ${signatureType} $${ident}${defaultClause};`;\n }\n\n // Hook path: emit `public T $x [= default] { set(SetterIn $value) { ... } }`.\n const setterParamType = setHookParamType(plan);\n const setterBody = renderSetterBody(plan, fieldPath);\n\n return (\n `${propertyDoc} public ${signatureType} $${ident}${defaultClause} {\\n` +\n ` set(${setterParamType} $value) {\\n` +\n `${setterBody}` +\n ` }\\n` +\n ` }`\n );\n}\n\nfunction setHookParamType(plan: FieldPlan): string {\n // The set hook's param type mirrors the constructor param type: widened\n // for coerce variants, nullable when the field is nullable.\n return constructorParamType(plan);\n}\n\nfunction renderSetterBody(plan: FieldPlan, fieldPath: string): string {\n const { ident, decision, normaliseOps, field } = plan;\n const nullable = !field.isRequired && !field.isList;\n const stringInput = decision.kind === \"strict\";\n\n // (1) normalise — only meaningful for string-shaped inputs.\n let valueExpr = \"$value\";\n let normaliseIsNullSafe = false;\n if (normaliseOps.length > 0) {\n const opsLiteral = renderNormaliseOpsLiteral(normaliseOps);\n if (stringInput) {\n // Pure String field — pick Normalise::apply vs Normalise::applyNullable.\n // applyNullable handles null internally, so no outer null-guard is\n // needed when we've already routed through it.\n if (nullable) {\n valueExpr = `Normalise::applyNullable($value, ${opsLiteral})`;\n normaliseIsNullSafe = true;\n } else {\n valueExpr = `Normalise::apply($value, ${opsLiteral})`;\n }\n } else {\n // Cross-type coerce (e.g. String @coerce(int)). Only normalise when\n // the input is actually a string at runtime.\n valueExpr = `(is_string($value) ? Normalise::apply($value, ${opsLiteral}) : $value)`;\n }\n }\n\n // (2) coerce\n const coerceExpr = renderCoerceCall(decision, valueExpr, fieldPath);\n\n // We're inside a hook (the caller in `renderPropertyBlock` already gated\n // on `needsHook = decision.kind !== \"strict\" || normaliseOps.length > 0`),\n // so by definition there's runtime work to do on non-null input. The only\n // reason to NOT emit the null-guard wrap is when the work itself is\n // null-safe — `Normalise::applyNullable` for pure-string nullable fields.\n // Every other nullable path needs the guard so we don't pass `null` into\n // `Coerce::int` and friends (which would throw TypeError).\n if (nullable && !normaliseIsNullSafe) {\n return ` $this->${ident} = $value === null ? null : ${coerceExpr};\\n`;\n }\n return ` $this->${ident} = ${coerceExpr};\\n`;\n}\n\nfunction renderCoerceCall(\n decision: PhpCoerceDecision,\n valueExpr: string,\n fieldPath: string,\n): string {\n switch (decision.kind) {\n case \"strict\":\n return valueExpr;\n case \"coerce-int\":\n return `Coerce::int(${valueExpr}, ${phpSingleQuote(fieldPath)})`;\n case \"coerce-float\":\n return `Coerce::float(${valueExpr}, ${phpSingleQuote(fieldPath)})`;\n case \"coerce-bigint\":\n return `Coerce::bigint(${valueExpr}, ${phpSingleQuote(fieldPath)})`;\n case \"coerce-date\":\n return `Coerce::date(${valueExpr}, ${phpSingleQuote(fieldPath)})`;\n case \"coerce-decimal\":\n return `Coerce::decimal(${valueExpr}, ${phpSingleQuote(fieldPath)})`;\n case \"coerce-string\":\n return `(string) ${valueExpr}`;\n }\n}\n\nfunction renderNormaliseOpsLiteral(ops: readonly NormaliseOp[]): string {\n // Render as a PHP array of `Normalise::*` class constants — safer than\n // raw strings (a typo in `Normalise::TROM` is a compile-time error\n // rather than a silent no-op at runtime). The op→constant mapping is\n // canonicalised in `./literals.ts` so any new `NormaliseOp` variant\n // has to extend it AND the runtime's class constants together.\n const constants = ops.map(phpNormaliseOpConstant).join(\", \");\n return `[${constants}]`;\n}\n\nfunction renderConstructorAssign(plan: FieldPlan): string {\n const { ident } = plan;\n // Always assign — required-no-default fields have a mandatory param,\n // required-with-default fields inherit the param's default, nullable\n // fields default to `null` on the param. PHP doesn't distinguish \"unset\"\n // from \"null\" for typed nullable props post-construction, so unconditional\n // assignment matches the same shape the property hook contract wants.\n return ` $this->${ident} = $${ident};`;\n}\n\n/**\n * Emit a `static from(array $data): self` factory that hydrates a model\n * from a Record-like array (typical sources: a JSON-decoded request body,\n * a Prisma row returned from `$client->user->findFirst()`, a queue message\n * payload). Routes every field through the constructor so property hooks\n * fire — `@coerce` and `@normalise` rules apply on the way in, just like\n * direct `new User(...)` calls.\n *\n * Argument ordering mirrors the constructor (required-first, optional-\n * second). Required-no-default fields throw `\\TypeError` with a clear\n * message if absent from `$data`. Optional / defaulted fields fall through\n * to their constructor default expression via PHP's `??` operator.\n *\n * Returns an empty string for models with zero visible fields — the\n * factory would be degenerate.\n */\nfunction renderFromMethod(plans: readonly FieldPlan[], selfIdent: string): string {\n if (plans.length === 0) return \"\";\n\n // Same partition as the constructor: required first (preserve schema\n // order), optional second (preserve schema order).\n const required = plans.filter((p) => p.paramDefaultExpr === null);\n const optional = plans.filter((p) => p.paramDefaultExpr !== null);\n const ordered = [...required, ...optional];\n\n const argLines = ordered.map((p) => renderFromArg(p, selfIdent));\n\n return (\n ` /**\\n` +\n ` * Hydrate ${selfIdent} from a Record-like array (e.g. a JSON-decoded\\n` +\n ` * request body, a Prisma row, a queue message payload). Routes every\\n` +\n ` * field through the constructor so property hooks fire — \\`@coerce\\` and\\n` +\n ` * \\`@normalise\\` rules apply identically to a direct \\`new ${selfIdent}(...)\\`\\n` +\n ` * call.\\n` +\n ` *\\n` +\n ` * **Not a validator.** Required fields missing from \\`$data\\` throw\\n` +\n ` * \\`\\\\TypeError\\` with the field path. Type-mismatched values (e.g. an\\n` +\n ` * array for a typed property) propagate as PHP \\`\\\\TypeError\\` from the\\n` +\n ` * underlying property hook. Pre-validate untrusted input at the boundary\\n` +\n ` * (JSON-schema, attribute validation, etc.) if those failure modes need\\n` +\n ` * to be caught with richer context.\\n` +\n ` *\\n` +\n ` * Unknown keys in \\`$data\\` are silently dropped.\\n` +\n ` *\\n` +\n ` * @param array<string, mixed> $data\\n` +\n ` */\\n` +\n ` public static function from(array $data): self\\n` +\n ` {\\n` +\n ` return new self(\\n` +\n `${argLines.join(\"\\n\")}\\n` +\n ` );\\n` +\n ` }`\n );\n}\n\nfunction renderFromArg(plan: FieldPlan, selfIdent: string): string {\n const key = plan.ident;\n const keyLiteral = phpSingleQuote(key);\n\n if (plan.paramDefaultExpr === null) {\n // Required-no-default: throw if missing. PHP 8.0+ supports `throw` as\n // an expression on the right side of `??`, so this stays as one line\n // per field rather than wrapping into a separate guard block.\n const message = `${selfIdent}::from(): missing required field \"${key}\"`;\n return (\n ` ${key}: $data[${keyLiteral}] ` +\n `?? throw new \\\\TypeError(${phpSingleQuote(message)}),`\n );\n }\n // Optional / defaulted: fall through to the constructor's default\n // expression. Note: `??` returns the default on missing key OR explicit\n // null. For nullable fields without a default, the default expression IS\n // `null`, so the behaviour is identical to \"use $data['key'] if present\n // and not null\". For defaulted fields a passed-null collapses to the\n // default — consistent with PHP's `??` semantics throughout the language.\n return ` ${key}: $data[${keyLiteral}] ?? ${plan.paramDefaultExpr},`;\n}\n\nfunction resolveNormaliseOps(\n field: FieldDef,\n modelName: string,\n issues: Diagnostic[],\n): readonly NormaliseOp[] {\n const ops = field.annotations.normalise;\n if (!ops || ops.length === 0) return [];\n\n if (field.isList) return [];\n\n if (field.type.kind !== \"scalar\" || field.type.scalar !== \"String\") {\n issues.push({\n severity: \"warning\",\n context: `${modelName}.${field.name}`,\n message: \"@normalise has no effect on non-String fields (silently ignored).\",\n });\n return [];\n }\n\n if (field.isRequired && ops.includes(\"nullEmptyToNull\")) {\n issues.push({\n severity: \"error\",\n context: `${modelName}.${field.name}`,\n message:\n \"@normalise(nullEmptyToNull) requires the field to be nullable. Mark the field optional in the schema or remove the op.\",\n });\n return ops.filter((op) => op !== \"nullEmptyToNull\");\n }\n\n return ops;\n}\n\nfunction toDiagnostic(issue: PhpCoerceRulesIssue): Diagnostic {\n return {\n severity: issue.severity,\n context: issue.fieldPath,\n message: issue.message,\n };\n}\n\n/**\n * The canonical PHP type the coerce target writes into storage, or `null`\n * for `strict` (storage type follows the type-mapper output verbatim).\n *\n * This is what bridges cross-type `@coerce(target)` to a consistent\n * property declaration: the type-mapper looks at the field's declared\n * Prisma scalar (`String? @coerce(int)` → `?string`), but the setter\n * writes the coerce result (`int`) — so the property must be typed to\n * accept `int`, not `string`. Returning the target's PHP type from this\n * helper lets the renderer override the type-mapper output when needed.\n *\n * Returning `null` for `strict` keeps the type-mapper output authoritative\n * for non-coerce fields — those go through unchanged.\n */\nfunction storageTypeFor(kind: PhpCoerceDecision[\"kind\"]): string | null {\n switch (kind) {\n case \"strict\":\n return null;\n case \"coerce-int\":\n case \"coerce-bigint\":\n return \"int\";\n case \"coerce-float\":\n return \"float\";\n case \"coerce-decimal\":\n return \"string\";\n case \"coerce-date\":\n return \"\\\\DateTimeImmutable\";\n case \"coerce-string\":\n return \"string\";\n }\n}\n"],"mappings":"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","names":[]}
|
package/dist/render-model.d.ts
CHANGED
|
@@ -1,7 +1,6 @@
|
|
|
1
|
-
import { ModelDef, PolyPrismIR, PolyPrismConfig } from '@polyprism/core';
|
|
2
|
-
import { Diagnostic } from './diagnostics.js';
|
|
1
|
+
import { ModelDef, PolyPrismIR, PolyPrismConfig, Diagnostic } from '@polyprism/core';
|
|
3
2
|
|
|
4
|
-
type PhpDeclarationStyle = "class" | "readonly";
|
|
3
|
+
type PhpDeclarationStyle = "class" | "readonly" | "domain-class";
|
|
5
4
|
interface RenderPhpModelOptions {
|
|
6
5
|
readonly model: ModelDef;
|
|
7
6
|
readonly ir: PolyPrismIR;
|
package/dist/render-model.js
CHANGED
|
@@ -1,5 +1,12 @@
|
|
|
1
|
-
import {
|
|
2
|
-
|
|
1
|
+
import {
|
|
2
|
+
buildEnumIdentLookup,
|
|
3
|
+
buildModelIdentLookup,
|
|
4
|
+
resolveFieldIdent,
|
|
5
|
+
resolveTypeIdent
|
|
6
|
+
} from "@polyprism/core";
|
|
7
|
+
import { formatPhpDefault } from "./defaults.js";
|
|
8
|
+
import { collectFieldExtraTags, renderPhpDoc } from "./phpdoc.js";
|
|
9
|
+
import { renderPhpDomainClass } from "./render-domain-class.js";
|
|
3
10
|
import { mapFieldPhpType } from "./type-mapper.js";
|
|
4
11
|
import { UseCollector } from "./use-collector.js";
|
|
5
12
|
function renderPhpModel(opts) {
|
|
@@ -13,30 +20,23 @@ function renderPhpModel(opts) {
|
|
|
13
20
|
jsonTypesNamespace,
|
|
14
21
|
jsonTypeClassNames
|
|
15
22
|
} = opts;
|
|
23
|
+
if (declarationStyle === "domain-class") {
|
|
24
|
+
return renderPhpDomainClass({
|
|
25
|
+
model,
|
|
26
|
+
ir,
|
|
27
|
+
config,
|
|
28
|
+
modelsNamespace,
|
|
29
|
+
enumsNamespace,
|
|
30
|
+
jsonTypesNamespace,
|
|
31
|
+
jsonTypeClassNames
|
|
32
|
+
});
|
|
33
|
+
}
|
|
16
34
|
const issues = [];
|
|
17
35
|
const collectDiagnostic = (d) => {
|
|
18
36
|
issues.push(d);
|
|
19
37
|
};
|
|
20
|
-
const enumFqnLookup =
|
|
21
|
-
|
|
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
|
-
);
|
|
38
|
+
const enumFqnLookup = buildEnumIdentLookup(ir, config, enumsNamespace);
|
|
39
|
+
const modelFqnLookup = buildModelIdentLookup(ir, config, modelsNamespace);
|
|
40
40
|
const selfIdent = resolveTypeIdent({
|
|
41
41
|
schemaName: model.name,
|
|
42
42
|
override: model.annotations.name,
|
|
@@ -96,62 +96,6 @@ ${promotedLines.join("\n")}
|
|
|
96
96
|
].join("\n");
|
|
97
97
|
return { source, issues };
|
|
98
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
99
|
export {
|
|
156
100
|
renderPhpModel
|
|
157
101
|
};
|
package/dist/render-model.js.map
CHANGED
|
@@ -1 +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":[]}
|
|
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 { ModelDef, PolyPrismConfig, PolyPrismIR } from \"@polyprism/core\";\nimport {\n buildEnumIdentLookup,\n buildModelIdentLookup,\n resolveFieldIdent,\n resolveTypeIdent,\n} from \"@polyprism/core\";\n\nimport { formatPhpDefault } from \"./defaults.js\";\nimport type { Diagnostic } from \"./diagnostics.js\";\nimport { collectFieldExtraTags, renderPhpDoc } from \"./phpdoc.js\";\nimport { renderPhpDomainClass } from \"./render-domain-class.js\";\nimport { mapFieldPhpType } from \"./type-mapper.js\";\nimport { UseCollector } from \"./use-collector.js\";\n\nexport type PhpDeclarationStyle = \"class\" | \"readonly\" | \"domain-class\";\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\n // The \"domain-class\" style is a fundamentally different shape (property\n // hooks, no constructor property promotion, runtime helpers) — delegate\n // to its dedicated renderer rather than branching deeply through the\n // promoted-property path below.\n if (declarationStyle === \"domain-class\") {\n return renderPhpDomainClass({\n model,\n ir,\n config,\n modelsNamespace,\n enumsNamespace,\n jsonTypesNamespace,\n jsonTypeClassNames,\n });\n }\n\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 = buildEnumIdentLookup(ir, config, enumsNamespace);\n const modelFqnLookup = buildModelIdentLookup(ir, config, modelsNamespace);\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"],"mappings":"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","names":[]}
|
package/dist/type-mapper.d.ts
CHANGED
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@polyprism/php-shared",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "Shared PHP rendering primitives used by PolyPrism's Prisma 6 & 7 PHP generators (php-class, php-readonly). Pure ESM.",
|
|
3
|
+
"version": "0.3.0",
|
|
4
|
+
"description": "Shared PHP rendering primitives used by PolyPrism's Prisma 6 & 7 PHP generators (php-class, php-readonly, php-domain-class). Pure ESM.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
7
7
|
"author": "Travis Fitzgerald",
|
|
@@ -43,7 +43,7 @@
|
|
|
43
43
|
"polyprism"
|
|
44
44
|
],
|
|
45
45
|
"dependencies": {
|
|
46
|
-
"@polyprism/core": "0.
|
|
46
|
+
"@polyprism/core": "0.3.0"
|
|
47
47
|
},
|
|
48
48
|
"scripts": {
|
|
49
49
|
"build": "tsup",
|