skir-simple-data-objects-generator 0.1.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/LICENSE +21 -0
- package/README.md +370 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +19 -0
- package/dist/composer-autoload.d.ts +2 -0
- package/dist/composer-autoload.js +1 -0
- package/dist/config.d.ts +10 -0
- package/dist/config.js +8 -0
- package/dist/configure-composer.d.ts +6 -0
- package/dist/configure-composer.js +10 -0
- package/dist/generator.d.ts +9 -0
- package/dist/generator.js +12 -0
- package/dist/index.d.ts +12 -0
- package/dist/index.js +15 -0
- package/dist/structural-rules.d.ts +2 -0
- package/dist/structural-rules.js +26 -0
- package/dist/target.d.ts +29 -0
- package/dist/target.js +498 -0
- package/package.json +61 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Maxim Kerstens
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,370 @@
|
|
|
1
|
+
# Skir Simple Data Objects Generator
|
|
2
|
+
|
|
3
|
+
[](https://github.com/php-skir/skir-simple-data-objects-generator/actions/workflows/tests.yml)
|
|
4
|
+
[](https://github.com/php-skir/skir-simple-data-objects-generator/actions/workflows/tests.yml)
|
|
5
|
+
[](https://www.npmjs.com/package/skir-simple-data-objects-generator)
|
|
6
|
+
[](https://nodejs.org/)
|
|
7
|
+
[](LICENSE)
|
|
8
|
+
|
|
9
|
+
Generates [`std-out/simple-data-objects`](https://github.com/std-out/simple-data-objects) DTOs, typed RPC clients, and server procedure contracts from Skir schemas.
|
|
10
|
+
|
|
11
|
+
Generated PHP uses `php-skir/runtime` for Skir wire formats and `std-out/simple-data-objects` for immutable DTO hydration, mapped property names, typed collections, serialization, and validation.
|
|
12
|
+
|
|
13
|
+
## Quick start
|
|
14
|
+
|
|
15
|
+
Prerequisites: PHP 8.4 or newer, Composer 2, and a supported Node.js release.
|
|
16
|
+
|
|
17
|
+
Install Skir and this generator as development dependencies, then install the PHP runtime and DTO library:
|
|
18
|
+
|
|
19
|
+
```bash
|
|
20
|
+
npm install --save-dev skir skir-simple-data-objects-generator
|
|
21
|
+
composer require php-skir/runtime std-out/simple-data-objects
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
Create `skir-src/users.skir`:
|
|
25
|
+
|
|
26
|
+
```skir
|
|
27
|
+
struct User {
|
|
28
|
+
user_id: int32;
|
|
29
|
+
email: string;
|
|
30
|
+
nickname: string?;
|
|
31
|
+
}
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
Add the generator to the root `skir.yml`:
|
|
35
|
+
|
|
36
|
+
```yaml
|
|
37
|
+
generators:
|
|
38
|
+
- mod: skir-simple-data-objects-generator
|
|
39
|
+
outDir: generated/php/skirout
|
|
40
|
+
config:
|
|
41
|
+
namespace: Skir
|
|
42
|
+
validation:
|
|
43
|
+
users.skir:
|
|
44
|
+
User:
|
|
45
|
+
email:
|
|
46
|
+
- email
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
Generate the PHP files and configure Composer autoloading:
|
|
50
|
+
|
|
51
|
+
```bash
|
|
52
|
+
npx skir gen
|
|
53
|
+
npx skir-simple-data-objects-generator configure-composer
|
|
54
|
+
composer dump-autoload
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
The generated `Skir\UserData` class is now ready to use:
|
|
58
|
+
|
|
59
|
+
```php
|
|
60
|
+
use Skir\UserData;
|
|
61
|
+
|
|
62
|
+
$user = UserData::makeFromSkirPayload([
|
|
63
|
+
'user_id' => 400,
|
|
64
|
+
'email' => 'maxim@example.com',
|
|
65
|
+
]);
|
|
66
|
+
|
|
67
|
+
$json = $user->toSkirJson();
|
|
68
|
+
$decoded = UserData::fromSkir($json);
|
|
69
|
+
|
|
70
|
+
$decoded->userId; // 400
|
|
71
|
+
$decoded->nickname; // null
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
The root namespace defaults to `Skir`, so `config.namespace` is optional when that default is suitable.
|
|
75
|
+
|
|
76
|
+
## Generated output is owned by Skir
|
|
77
|
+
|
|
78
|
+
Skir owns the configured `outDir` and may replace or delete anything inside it. Use a dedicated path ending in `/skirout`, do not put handwritten PHP there, and never edit a generated file. Change the `.skir` source or generator configuration and regenerate instead.
|
|
79
|
+
|
|
80
|
+
Source directories below `skir-src` become PHP subnamespaces and output directories. The `.skir` filename itself does not add a namespace segment:
|
|
81
|
+
|
|
82
|
+
```text
|
|
83
|
+
skir-src/admin/users.skir -> generated/php/skirout/Admin/UserData.php
|
|
84
|
+
-> Skir\Admin\UserData
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
Generated struct and enum class names end in `Data`. A Skir field such as `user_id` becomes the PHP property `$userId`, with `#[MapPropertyName('user_id')]` preserving the original name for hydration and `toArray()` output.
|
|
88
|
+
|
|
89
|
+
## Composer configuration
|
|
90
|
+
|
|
91
|
+
Run Skir generation before `configure-composer`: the command verifies that every configured output directory exists before changing `composer.json`.
|
|
92
|
+
|
|
93
|
+
```text
|
|
94
|
+
skir-simple-data-objects-generator configure-composer [--root <dir>] [--mod <module>]
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
In a normal npm project, invoke it through `npx`:
|
|
98
|
+
|
|
99
|
+
```bash
|
|
100
|
+
npx skir-simple-data-objects-generator configure-composer
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
The command reads `skir.yml` and `composer.json`, finds the exact `mod: skir-simple-data-objects-generator` entry, and adds the configured namespace and `outDir` as a Composer PSR-4 mapping. It changes only `composer.json`; run `composer dump-autoload` afterward.
|
|
104
|
+
|
|
105
|
+
Use `--root <dir>` for another project root. Use `--mod <module>` when the configured module is an exact alternative identifier, such as a local file URL:
|
|
106
|
+
|
|
107
|
+
```bash
|
|
108
|
+
npx skir-simple-data-objects-generator configure-composer \
|
|
109
|
+
--root ../api \
|
|
110
|
+
--mod file:///path/to/skir-simple-data-objects-generator/dist/index.js
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
An equivalent existing mapping is left byte-for-byte unchanged. A conflicting mapping, invalid configuration, missing output directory, or path outside the project root fails without modifying `composer.json`.
|
|
114
|
+
|
|
115
|
+
## Generated data objects
|
|
116
|
+
|
|
117
|
+
### Struct APIs
|
|
118
|
+
|
|
119
|
+
Every Skir struct generates a final class extending `StdOut\SimpleDataObjects\BaseData`. In addition to its readonly constructor properties, it exposes:
|
|
120
|
+
|
|
121
|
+
| Method | Purpose |
|
|
122
|
+
| --- | --- |
|
|
123
|
+
| `skirType()` | Return the runtime `Type` descriptor. |
|
|
124
|
+
| `makeFromSkirPayload(array $data)` | Validate raw named Skir data, recursively hydrate nested values, and return the DTO. |
|
|
125
|
+
| `fromSkir(string $json)` | Decode dense Skir JSON and delegate to `makeFromSkirPayload()`. |
|
|
126
|
+
| `toSkirArray()` | Convert DTO values to the named array representation expected by the runtime. |
|
|
127
|
+
| `toSkir()` | Encode the DTO as the dense PHP array representation. |
|
|
128
|
+
| `toSkirJson()` | Encode the DTO as dense Skir JSON. |
|
|
129
|
+
|
|
130
|
+
Generated classes inherit the current `BaseData` APIs, including `from()`, `fromValidated()`, `validate()`, `toArray()`, `toJson()`, `with()`, `equals()`, `diff()`, `only()`, `except()`, `collection()`, and `lazyCollection()`.
|
|
131
|
+
|
|
132
|
+
Use PHP property names with `with()`, but original Skir names in raw payloads and serialized arrays:
|
|
133
|
+
|
|
134
|
+
```php
|
|
135
|
+
$updated = $user->with(userId: 401);
|
|
136
|
+
|
|
137
|
+
$updated->userId; // 401
|
|
138
|
+
$updated->toArray()['user_id']; // 401
|
|
139
|
+
$updated->equals($user); // false
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
`BaseData::from()` hydrates without validation. Use `makeFromSkirPayload()` for raw Skir data: it applies the generated validation contract and performs the Skir-specific recursive conversions before calling `BaseData::from()`.
|
|
143
|
+
|
|
144
|
+
`BaseData::collection()`, `BaseData::lazyCollection()`, and `TypedDataCollection::of()` are also trusted hydration paths. Like `from()`, they do not execute generated `#[Rules]` validation. Construct collections from DTOs or other trusted values:
|
|
145
|
+
|
|
146
|
+
```php
|
|
147
|
+
use Skir\AddressData;
|
|
148
|
+
|
|
149
|
+
$addresses = AddressData::collection([
|
|
150
|
+
AddressData::makeFromSkirPayload([
|
|
151
|
+
'city' => 'Antwerp',
|
|
152
|
+
'postal_codes' => ['2000'],
|
|
153
|
+
]),
|
|
154
|
+
]);
|
|
155
|
+
|
|
156
|
+
foreach ($addresses as $address) {
|
|
157
|
+
echo $address->city;
|
|
158
|
+
}
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
For untrusted raw item arrays, call `AddressData::makeFromSkirPayload()` for each item before constructing a collection. When the collection belongs to a generated parent DTO or RPC request, prefer passing the complete raw parent payload through the parent's `makeFromSkirPayload()` or generated RPC hydration path; those paths validate each nested struct before collection construction.
|
|
162
|
+
|
|
163
|
+
### Struct collections
|
|
164
|
+
|
|
165
|
+
Only a direct array of structs becomes a `TypedDataCollection`:
|
|
166
|
+
|
|
167
|
+
| Skir shape | Generated PHP type |
|
|
168
|
+
| --- | --- |
|
|
169
|
+
| `[Address]` | `TypedDataCollection` with `#[DataCollection(AddressData::class)]` |
|
|
170
|
+
| `[Address]?` | `?TypedDataCollection` with the same collection attribute |
|
|
171
|
+
| `[[Address]]` | `array` |
|
|
172
|
+
| `[Address?]` | `array` |
|
|
173
|
+
| `[string]` or `[SomeEnum]` | `array` |
|
|
174
|
+
|
|
175
|
+
Nested arrays and arrays whose item is optional remain normal PHP arrays, although supported nested structs and enum wrappers are still converted recursively.
|
|
176
|
+
|
|
177
|
+
### Wrapper enums
|
|
178
|
+
|
|
179
|
+
Skir enums generate readonly wrapper classes with a `Data` suffix. Constant variants have parameterless named constructors; payload variants have typed named constructors. Generated enum classes expose `name()`, `payload()`, `skirType()`, `toSkirValue()`, `fromSkirValue()`, `toDenseValue()`, `fromDenseValue()`, `toDenseJson()`, and `fromDenseJson()`.
|
|
180
|
+
|
|
181
|
+
Structs, typed struct collections, nested arrays, optionals, and nested enum wrappers are converted to wire values when stored in a wrapper and converted back to target DTO values when `payload()` is read.
|
|
182
|
+
|
|
183
|
+
## Validation
|
|
184
|
+
|
|
185
|
+
The generator emits `#[Rules]` attributes understood by Simple Data Objects and Laravel's validator. Rules are deterministic: generated structural rules come first, followed by configured string rules in their exact configured order.
|
|
186
|
+
|
|
187
|
+
### Structural rules
|
|
188
|
+
|
|
189
|
+
Every non-optional field starts with `required`. Every optional field starts with `nullable`, so the key may be omitted or its value may be `null`. A safely representable shape rule follows when available:
|
|
190
|
+
|
|
191
|
+
| Skir type | Generated shape rule |
|
|
192
|
+
| --- | --- |
|
|
193
|
+
| `bool` | `boolean` |
|
|
194
|
+
| `int32`, `timestamp` | `integer` |
|
|
195
|
+
| `float32`, `float64` | `numeric` |
|
|
196
|
+
| `string`, `bytes` | `string` |
|
|
197
|
+
| any array | `array` |
|
|
198
|
+
| struct reference | `array` |
|
|
199
|
+
| enum reference, `mixed` | none |
|
|
200
|
+
| `int64`, `hash64` | none |
|
|
201
|
+
|
|
202
|
+
`int64` and `hash64` deliberately receive no narrowing rule because their PHP representation is `int|string`.
|
|
203
|
+
|
|
204
|
+
Array validation is structural only at the field level. The generator emits `array`, but not wildcard item rules such as `field.*`. Array-shaped nested structs are recursively hydrated through their own `makeFromSkirPayload()` method and therefore run their own field rules. Put additional element constraints in custom named rules or at a handwritten application boundary.
|
|
205
|
+
|
|
206
|
+
### Validation and hydration order
|
|
207
|
+
|
|
208
|
+
`makeFromSkirPayload()` processes input in this order:
|
|
209
|
+
|
|
210
|
+
1. Validate the raw parent array with `self::validate($data)`.
|
|
211
|
+
2. Convert mapped names and recursively hydrate nested structs, collections, arrays, and enum wrappers. Each nested struct validates its own raw array before hydration.
|
|
212
|
+
3. Pass the already validated and prepared values to `self::from($payload)`.
|
|
213
|
+
|
|
214
|
+
Generated RPC providers use the same raw-to-target conversion for incoming requests, and generated clients use it for responses. Direct struct requests and every recursively hydrated nested struct are validated before application code receives them.
|
|
215
|
+
|
|
216
|
+
The final `BaseData::from()` call is intentionally a hydration-only operation and does not validate by itself. Calling `SomeData::from($untrustedInput)` directly bypasses the generated raw-first validation sequence.
|
|
217
|
+
|
|
218
|
+
### Validation overlays
|
|
219
|
+
|
|
220
|
+
Configure application-specific rules under `config.validation` in `skir.yml`:
|
|
221
|
+
|
|
222
|
+
```yaml
|
|
223
|
+
generators:
|
|
224
|
+
- mod: skir-simple-data-objects-generator
|
|
225
|
+
outDir: generated/php/skirout
|
|
226
|
+
config:
|
|
227
|
+
namespace: Skir
|
|
228
|
+
validation:
|
|
229
|
+
admin/users.skir:
|
|
230
|
+
"Envelope.Metadata":
|
|
231
|
+
display_name:
|
|
232
|
+
- min:2
|
|
233
|
+
- max:80
|
|
234
|
+
email_address:
|
|
235
|
+
- email:rfc
|
|
236
|
+
- company_email
|
|
237
|
+
```
|
|
238
|
+
|
|
239
|
+
Selectors use the original Skir identities, in this order:
|
|
240
|
+
|
|
241
|
+
1. Module path relative to `skir-src`, such as `admin/users.skir`.
|
|
242
|
+
2. Qualified record name, such as `Envelope.Metadata`.
|
|
243
|
+
3. Original Skir field name, such as `display_name`, not the generated `$displayName` property.
|
|
244
|
+
|
|
245
|
+
Unknown modules, records, or fields fail generation instead of silently dropping a rule. Enum variants, removed fields, and other non-struct payload fields cannot receive validation overlays.
|
|
246
|
+
|
|
247
|
+
Only non-empty string rules can be encoded in `skir.yml`. The generator appends them after structural rules without reordering them. Register every custom named rule before generated DTO validation runs.
|
|
248
|
+
|
|
249
|
+
### Register custom rules in Laravel
|
|
250
|
+
|
|
251
|
+
Laravel's validator is available to `BaseData` through the application container. Register custom named rules during application boot:
|
|
252
|
+
|
|
253
|
+
```php
|
|
254
|
+
namespace App\Providers;
|
|
255
|
+
|
|
256
|
+
use Illuminate\Support\Facades\Validator;
|
|
257
|
+
use Illuminate\Support\ServiceProvider;
|
|
258
|
+
|
|
259
|
+
final class AppServiceProvider extends ServiceProvider
|
|
260
|
+
{
|
|
261
|
+
public function boot(): void
|
|
262
|
+
{
|
|
263
|
+
Validator::extend(
|
|
264
|
+
'company_email',
|
|
265
|
+
static fn (string $attribute, mixed $value): bool =>
|
|
266
|
+
is_string($value) && str_ends_with($value, '@company.test'),
|
|
267
|
+
);
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
```
|
|
271
|
+
|
|
272
|
+
Register the rule before any generated `makeFromSkirPayload()`, `fromSkir()`, or RPC hydration call that may use it.
|
|
273
|
+
|
|
274
|
+
### Register custom rules without Laravel
|
|
275
|
+
|
|
276
|
+
Standalone applications can create and install an Illuminate validator factory:
|
|
277
|
+
|
|
278
|
+
```php
|
|
279
|
+
use Illuminate\Translation\ArrayLoader;
|
|
280
|
+
use Illuminate\Translation\Translator;
|
|
281
|
+
use Illuminate\Validation\Factory as ValidatorFactory;
|
|
282
|
+
use StdOut\SimpleDataObjects\BaseData;
|
|
283
|
+
|
|
284
|
+
$validator = new ValidatorFactory(
|
|
285
|
+
new Translator(new ArrayLoader(), 'en'),
|
|
286
|
+
);
|
|
287
|
+
$validator->extend(
|
|
288
|
+
'company_email',
|
|
289
|
+
static fn (string $attribute, mixed $value): bool =>
|
|
290
|
+
is_string($value) && str_ends_with($value, '@company.test'),
|
|
291
|
+
);
|
|
292
|
+
|
|
293
|
+
BaseData::setValidatorFactory($validator);
|
|
294
|
+
```
|
|
295
|
+
|
|
296
|
+
Install the configured factory before the first generated validation call.
|
|
297
|
+
|
|
298
|
+
### Handwritten validation boundaries
|
|
299
|
+
|
|
300
|
+
Laravel rule objects and closures cannot be represented in `skir.yml`; only strings are portable generator configuration. Keep request-specific authorization, database-aware rules, uploaded-file rules, closure rules, normalization, and other application behavior in a handwritten Laravel Form Request or equivalent application-owned validation boundary.
|
|
301
|
+
|
|
302
|
+
That boundary must live outside `skirout`, because regeneration overwrites generated files. It complements the generated wire-shape and named-rule validation instead of modifying generated DTOs.
|
|
303
|
+
|
|
304
|
+
## RPC clients, procedures, and manifests
|
|
305
|
+
|
|
306
|
+
When a schema contains methods, the generator emits six module-scoped PHP RPC files plus `skir-server-manifest.json`:
|
|
307
|
+
|
|
308
|
+
- `SkirMethods.php` with runtime `MethodDescriptor` instances.
|
|
309
|
+
- A module method enum such as `AdminSkirMethod.php`.
|
|
310
|
+
- `SkirRpcClient.php` with typed request and response conversion.
|
|
311
|
+
- `SkirProcedures.php` and `AbstractSkirProcedures.php` for implementation contracts.
|
|
312
|
+
- `SkirProcedureProvider.php` for server registration.
|
|
313
|
+
- `skir-server-manifest.json` with exact module and method identities and `Data`-suffixed struct and enum classes.
|
|
314
|
+
|
|
315
|
+
Install the client package when using the generated RPC client:
|
|
316
|
+
|
|
317
|
+
```bash
|
|
318
|
+
composer require php-skir/client
|
|
319
|
+
```
|
|
320
|
+
|
|
321
|
+
Install the server package when using generated procedure contracts or providers:
|
|
322
|
+
|
|
323
|
+
```bash
|
|
324
|
+
composer require php-skir/server
|
|
325
|
+
```
|
|
326
|
+
|
|
327
|
+
RPC methods retain target types at the application boundary. Direct struct collections are accepted and returned as `TypedDataCollection`; nested collection shapes remain arrays. The generated client converts requests to raw Skir values and hydrates responses. The generated providers validate and hydrate incoming raw values before calling application code, then convert returned DTOs back to Skir values.
|
|
328
|
+
|
|
329
|
+
This generator emits contracts and conversions only. HTTP transport and server routing remain the responsibility of `php-skir/client`, `php-skir/server`, and the application.
|
|
330
|
+
|
|
331
|
+
## Development and contributing
|
|
332
|
+
|
|
333
|
+
Install dependencies and run the complete package verification sequence:
|
|
334
|
+
|
|
335
|
+
```bash
|
|
336
|
+
npm ci
|
|
337
|
+
npm run typecheck
|
|
338
|
+
npm run build
|
|
339
|
+
npm run pack:dry-run
|
|
340
|
+
SKIR_RUNTIME_PATH=../runtime npm test
|
|
341
|
+
git diff --check
|
|
342
|
+
```
|
|
343
|
+
|
|
344
|
+
The integration tests require PHP 8.4, Composer, and a local checkout of `php-skir/runtime`. Pull requests should keep generated output behavior covered by source assertions and executable PHP integration tests.
|
|
345
|
+
|
|
346
|
+
### Develop against a local generator core
|
|
347
|
+
|
|
348
|
+
Build the core first. Before the initial compatible core release and adapter lockfile commit, one no-save install can install this package's development dependencies and satisfy `@php-skir/generator-core` from the local checkout without creating a lockfile:
|
|
349
|
+
|
|
350
|
+
```bash
|
|
351
|
+
cd ../generator-core
|
|
352
|
+
npm ci
|
|
353
|
+
npm run build
|
|
354
|
+
|
|
355
|
+
cd ../skir-simple-data-objects-generator
|
|
356
|
+
npm install --no-save --package-lock=false ../generator-core
|
|
357
|
+
```
|
|
358
|
+
|
|
359
|
+
Once the compatible core package has been published and this repository has committed its normal `package-lock.json`, use `npm ci` instead of the local-path bootstrap.
|
|
360
|
+
|
|
361
|
+
## Releasing
|
|
362
|
+
|
|
363
|
+
Release the shared packages in dependency order:
|
|
364
|
+
|
|
365
|
+
1. Merge and publish `@php-skir/generator-core` with the required adapter APIs.
|
|
366
|
+
2. Replace the local link with the published compatible core version and commit the resulting `package-lock.json` update.
|
|
367
|
+
3. Run `npm ci`, typecheck, build, package validation, the full test suite, and `git diff --check` again.
|
|
368
|
+
4. Create the Simple Data Objects generator release only after those checks pass.
|
|
369
|
+
|
|
370
|
+
The release workflow uses Node 24 and npm trusted publishing with provenance. Configure this GitHub repository as an npm trusted publisher before publishing; no release or publish action is performed by the test workflow.
|
package/dist/cli.d.ts
ADDED
package/dist/cli.js
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { runConfigureComposerCli } from "@php-skir/generator-core";
|
|
3
|
+
import { GeneratorConfig, GENERATOR_MODULE } from "./config.js";
|
|
4
|
+
async function main() {
|
|
5
|
+
try {
|
|
6
|
+
await runConfigureComposerCli({
|
|
7
|
+
argv: process.argv.slice(2),
|
|
8
|
+
bin: "skir-simple-data-objects-generator",
|
|
9
|
+
module: GENERATOR_MODULE,
|
|
10
|
+
parseConfig: (value) => GeneratorConfig.parse(value),
|
|
11
|
+
namespace: (config) => config.namespace,
|
|
12
|
+
});
|
|
13
|
+
}
|
|
14
|
+
catch (error) {
|
|
15
|
+
process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
|
|
16
|
+
process.exitCode = 1;
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
void main();
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { ComposerAutoloadConflictError, ensureComposerPsr4Mapping, MalformedComposerPsr4PrefixError, } from "@php-skir/generator-core";
|
package/dist/config.d.ts
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { DEFAULT_NAMESPACE, PhpNamespace } from "@php-skir/generator-core";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
export declare const GENERATOR_MODULE = "skir-simple-data-objects-generator";
|
|
4
|
+
export { DEFAULT_NAMESPACE, PhpNamespace };
|
|
5
|
+
export declare const GeneratorConfig: z.ZodObject<{
|
|
6
|
+
namespace: z.ZodDefault<z.ZodString>;
|
|
7
|
+
validation: z.ZodOptional<z.ZodDefault<z.ZodRecord<z.ZodString, z.ZodRecord<z.ZodString, z.ZodRecord<z.ZodString, z.ZodArray<z.ZodString>>>>>>;
|
|
8
|
+
}, z.core.$strict>;
|
|
9
|
+
export type GeneratorConfig = z.infer<typeof GeneratorConfig>;
|
|
10
|
+
export type GeneratorConfigInput = z.input<typeof GeneratorConfig>;
|
package/dist/config.js
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { DEFAULT_NAMESPACE, PhpNamespace, ValidationConfig, } from "@php-skir/generator-core";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
export const GENERATOR_MODULE = "skir-simple-data-objects-generator";
|
|
4
|
+
export { DEFAULT_NAMESPACE, PhpNamespace };
|
|
5
|
+
export const GeneratorConfig = z.strictObject({
|
|
6
|
+
namespace: PhpNamespace.default(DEFAULT_NAMESPACE),
|
|
7
|
+
validation: ValidationConfig.optional(),
|
|
8
|
+
});
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import { type ComposerPsr4MappingResult } from "@php-skir/generator-core";
|
|
2
|
+
export interface ConfigureComposerOptions {
|
|
3
|
+
readonly mod?: string;
|
|
4
|
+
readonly root?: string;
|
|
5
|
+
}
|
|
6
|
+
export declare function configureComposer(options?: ConfigureComposerOptions): Promise<ComposerPsr4MappingResult>;
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { configureComposer as configureCoreComposer, } from "@php-skir/generator-core";
|
|
2
|
+
import { GeneratorConfig, GENERATOR_MODULE } from "./config.js";
|
|
3
|
+
export function configureComposer(options = {}) {
|
|
4
|
+
return configureCoreComposer({
|
|
5
|
+
module: options.mod ?? GENERATOR_MODULE,
|
|
6
|
+
...(options.root === undefined ? {} : { root: options.root }),
|
|
7
|
+
parseConfig: (value) => GeneratorConfig.parse(value),
|
|
8
|
+
namespace: (config) => config.namespace,
|
|
9
|
+
});
|
|
10
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { PHP_FILE_HEADER, type CoreGeneratorInput, type GeneratedFile } from "@php-skir/generator-core";
|
|
2
|
+
import { type GeneratorConfigInput } from "./config.js";
|
|
3
|
+
export { PHP_FILE_HEADER };
|
|
4
|
+
export type PhpGeneratorConfig = GeneratorConfigInput;
|
|
5
|
+
export interface PhpGeneratorInput extends CoreGeneratorInput {
|
|
6
|
+
readonly config?: PhpGeneratorConfig;
|
|
7
|
+
}
|
|
8
|
+
export declare function generateSimpleDataObjectsFiles(input: PhpGeneratorInput): GeneratedFile[];
|
|
9
|
+
export type { GeneratedFile, SkirField, SkirMethod, SkirModule, SkirRecord, SkirRecordLocation, SkirRecordNamePart, SkirToken, SkirType, } from "@php-skir/generator-core";
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { generatePhp, PHP_FILE_HEADER, } from "@php-skir/generator-core";
|
|
2
|
+
import { GeneratorConfig, } from "./config.js";
|
|
3
|
+
import { SimpleDataObjectsTarget } from "./target.js";
|
|
4
|
+
export { PHP_FILE_HEADER };
|
|
5
|
+
export function generateSimpleDataObjectsFiles(input) {
|
|
6
|
+
const { namespace, validation = {} } = GeneratorConfig.parse(input.config ?? {});
|
|
7
|
+
return generatePhp({
|
|
8
|
+
...input,
|
|
9
|
+
namespace,
|
|
10
|
+
adapter: new SimpleDataObjectsTarget(validation),
|
|
11
|
+
});
|
|
12
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { type CodeGenerator } from "skir-internal";
|
|
2
|
+
import { GeneratorConfig } from "./config.js";
|
|
3
|
+
import { generateSimpleDataObjectsFiles } from "./generator.js";
|
|
4
|
+
declare class SimpleDataObjectsGenerator implements CodeGenerator<GeneratorConfig> {
|
|
5
|
+
readonly id = "skir-simple-data-objects-generator";
|
|
6
|
+
readonly configType: CodeGenerator<GeneratorConfig>["configType"];
|
|
7
|
+
generateCode(input: CodeGenerator.Input<GeneratorConfig>): CodeGenerator.Output;
|
|
8
|
+
}
|
|
9
|
+
export declare const GENERATOR: SimpleDataObjectsGenerator;
|
|
10
|
+
export { generateSimpleDataObjectsFiles };
|
|
11
|
+
export { SimpleDataObjectsTarget } from "./target.js";
|
|
12
|
+
export default GENERATOR;
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { GeneratorConfig, GENERATOR_MODULE } from "./config.js";
|
|
2
|
+
import { generateSimpleDataObjectsFiles } from "./generator.js";
|
|
3
|
+
class SimpleDataObjectsGenerator {
|
|
4
|
+
id = GENERATOR_MODULE;
|
|
5
|
+
configType = GeneratorConfig;
|
|
6
|
+
generateCode(input) {
|
|
7
|
+
return {
|
|
8
|
+
files: generateSimpleDataObjectsFiles(input),
|
|
9
|
+
};
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
export const GENERATOR = new SimpleDataObjectsGenerator();
|
|
13
|
+
export { generateSimpleDataObjectsFiles };
|
|
14
|
+
export { SimpleDataObjectsTarget } from "./target.js";
|
|
15
|
+
export default GENERATOR;
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
export function structuralRules(type, optional = type.kind === "optional") {
|
|
2
|
+
const presence = optional ? "nullable" : "required";
|
|
3
|
+
const inner = type.kind === "optional" ? type.inner : type;
|
|
4
|
+
const shape = (() => {
|
|
5
|
+
switch (inner.kind) {
|
|
6
|
+
case "bool":
|
|
7
|
+
return "boolean";
|
|
8
|
+
case "int32":
|
|
9
|
+
case "timestamp":
|
|
10
|
+
return "integer";
|
|
11
|
+
case "float32":
|
|
12
|
+
case "float64":
|
|
13
|
+
return "numeric";
|
|
14
|
+
case "string":
|
|
15
|
+
case "bytes":
|
|
16
|
+
return "string";
|
|
17
|
+
case "array":
|
|
18
|
+
return "array";
|
|
19
|
+
case "record":
|
|
20
|
+
return inner.recordType === "struct" ? "array" : null;
|
|
21
|
+
default:
|
|
22
|
+
return null;
|
|
23
|
+
}
|
|
24
|
+
})();
|
|
25
|
+
return shape === null ? [presence] : [presence, shape];
|
|
26
|
+
}
|
package/dist/target.d.ts
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { type GeneratedFile, type NormalizedMethod, type NormalizedRecord, type NormalizedSchema, type NormalizedType, type PhpTargetAdapter, type RenderContext, type StructRenderRequest, type ValidationConfig } from "@php-skir/generator-core";
|
|
2
|
+
export declare class SimpleDataObjectsTarget implements PhpTargetAdapter {
|
|
3
|
+
private readonly validation;
|
|
4
|
+
readonly id = "skir-simple-data-objects-generator";
|
|
5
|
+
private resolvedValidationRules;
|
|
6
|
+
constructor(validation?: ValidationConfig);
|
|
7
|
+
prepare(schema: NormalizedSchema): void;
|
|
8
|
+
recordClassName(record: NormalizedRecord): string;
|
|
9
|
+
structImports(record: NormalizedRecord): readonly string[];
|
|
10
|
+
enumImports(record: NormalizedRecord): readonly string[];
|
|
11
|
+
rpcImports(methods: readonly NormalizedMethod[]): readonly string[];
|
|
12
|
+
renderStruct({ record, context }: StructRenderRequest): GeneratedFile;
|
|
13
|
+
phpType(type: NormalizedType, context: RenderContext): string;
|
|
14
|
+
toSkirExpression(type: NormalizedType, expression: string, context: RenderContext): string;
|
|
15
|
+
fromSkirExpression(type: NormalizedType, expression: string, context: RenderContext): string;
|
|
16
|
+
clientResponseExpression(type: NormalizedType, expression: string, context: RenderContext): string;
|
|
17
|
+
enumPayloadToSkirExpression(type: NormalizedType, expression: string, context: RenderContext): string;
|
|
18
|
+
enumPayloadFromSkirExpression(type: NormalizedType, expression: string, context: RenderContext): string;
|
|
19
|
+
manifestObjectClass(type: NormalizedType, context: RenderContext): string | null;
|
|
20
|
+
manifestPhpType(type: NormalizedType): string | null;
|
|
21
|
+
private renderConstructor;
|
|
22
|
+
private renderSkirType;
|
|
23
|
+
private renderMakeFromSkirPayload;
|
|
24
|
+
private renderFromSkir;
|
|
25
|
+
private renderToSkirArray;
|
|
26
|
+
private renderToSkir;
|
|
27
|
+
private renderToSkirJson;
|
|
28
|
+
private toSkirValueExpression;
|
|
29
|
+
}
|
package/dist/target.js
ADDED
|
@@ -0,0 +1,498 @@
|
|
|
1
|
+
import { importClass, importClassAs, indent, renderPhpFile, renderUseStatements, resolveValidationRules, toClassName, toPhpNamespaceSegment, toPropertyName, } from "@php-skir/generator-core";
|
|
2
|
+
import { GENERATOR_MODULE } from "./config.js";
|
|
3
|
+
import { structuralRules } from "./structural-rules.js";
|
|
4
|
+
const DENSE_JSON = "Skir\\Runtime\\DenseJson";
|
|
5
|
+
const FIELD = "Skir\\Runtime\\Field";
|
|
6
|
+
const TYPE = "Skir\\Runtime\\Type";
|
|
7
|
+
const DATA_COLLECTION = "StdOut\\SimpleDataObjects\\Attributes\\DataCollection";
|
|
8
|
+
const MAP_PROPERTY_NAME = "StdOut\\SimpleDataObjects\\Attributes\\MapPropertyName";
|
|
9
|
+
const RULES = "StdOut\\SimpleDataObjects\\Attributes\\Rules";
|
|
10
|
+
const BASE_DATA = "StdOut\\SimpleDataObjects\\BaseData";
|
|
11
|
+
const TYPED_DATA_COLLECTION = "StdOut\\SimpleDataObjects\\TypedDataCollection";
|
|
12
|
+
const BASE_STRUCT_IMPORTS = [DENSE_JSON, FIELD, TYPE, BASE_DATA];
|
|
13
|
+
export class SimpleDataObjectsTarget {
|
|
14
|
+
validation;
|
|
15
|
+
id = GENERATOR_MODULE;
|
|
16
|
+
resolvedValidationRules = new Map();
|
|
17
|
+
constructor(validation = {}) {
|
|
18
|
+
this.validation = validation;
|
|
19
|
+
}
|
|
20
|
+
prepare(schema) {
|
|
21
|
+
this.resolvedValidationRules = new Map();
|
|
22
|
+
this.resolvedValidationRules = resolveValidationRules(schema, this.validation);
|
|
23
|
+
}
|
|
24
|
+
recordClassName(record) {
|
|
25
|
+
if (record.phpClassName !== undefined) {
|
|
26
|
+
return record.phpClassName;
|
|
27
|
+
}
|
|
28
|
+
const className = toClassName(record.qualifiedName);
|
|
29
|
+
return className.endsWith("Data") ? className : `${className}Data`;
|
|
30
|
+
}
|
|
31
|
+
structImports(record) {
|
|
32
|
+
const fields = record.fields.filter(isStructField);
|
|
33
|
+
const imports = [...BASE_STRUCT_IMPORTS];
|
|
34
|
+
if (fields.some((field) => toPropertyName(field.name) !== field.name)) {
|
|
35
|
+
imports.push(MAP_PROPERTY_NAME);
|
|
36
|
+
}
|
|
37
|
+
if (fields.length > 0) {
|
|
38
|
+
imports.push(RULES);
|
|
39
|
+
}
|
|
40
|
+
if (fields.some((field) => dataCollectionType(field.type) !== null)) {
|
|
41
|
+
imports.push(DATA_COLLECTION, TYPED_DATA_COLLECTION);
|
|
42
|
+
}
|
|
43
|
+
return imports;
|
|
44
|
+
}
|
|
45
|
+
enumImports(record) {
|
|
46
|
+
if (record.recordType !== "enum") {
|
|
47
|
+
return [];
|
|
48
|
+
}
|
|
49
|
+
const needsTypedDataCollection = record.fields.some((field) => (field.kind === "field"
|
|
50
|
+
&& field.hasPayload
|
|
51
|
+
&& requiresTypedDataCollection(field.type)));
|
|
52
|
+
return needsTypedDataCollection ? [TYPED_DATA_COLLECTION] : [];
|
|
53
|
+
}
|
|
54
|
+
rpcImports(methods) {
|
|
55
|
+
const needsTypedDataCollection = methods.some((method) => (requiresTypedDataCollection(method.requestType)
|
|
56
|
+
|| requiresTypedDataCollection(method.responseType)));
|
|
57
|
+
return needsTypedDataCollection ? [TYPED_DATA_COLLECTION] : [];
|
|
58
|
+
}
|
|
59
|
+
renderStruct({ record, context }) {
|
|
60
|
+
if (record.recordType !== "struct") {
|
|
61
|
+
throw new Error(`Cannot render non-struct record ${record.identity} as a struct.`);
|
|
62
|
+
}
|
|
63
|
+
const className = classNameForRecord(record, context);
|
|
64
|
+
const imports = this.structImports(record);
|
|
65
|
+
const denseJson = importClass(context.imports, DENSE_JSON);
|
|
66
|
+
const fieldClass = importClass(context.imports, FIELD);
|
|
67
|
+
const typeClass = importClass(context.imports, TYPE);
|
|
68
|
+
const baseData = importClass(context.imports, BASE_DATA);
|
|
69
|
+
const fields = record.fields.filter(isStructField);
|
|
70
|
+
const constructor = this.renderConstructor(record, fields, context);
|
|
71
|
+
const members = [
|
|
72
|
+
constructor,
|
|
73
|
+
this.renderSkirType(record, context, fieldClass, typeClass),
|
|
74
|
+
this.renderMakeFromSkirPayload(fields, context),
|
|
75
|
+
this.renderFromSkir(className, denseJson),
|
|
76
|
+
this.renderToSkirArray(fields, context),
|
|
77
|
+
this.renderToSkir(denseJson),
|
|
78
|
+
this.renderToSkirJson(denseJson),
|
|
79
|
+
].filter((member) => member !== null);
|
|
80
|
+
const body = [
|
|
81
|
+
`final class ${className} extends ${baseData}`,
|
|
82
|
+
"{",
|
|
83
|
+
...members.flatMap((member, index) => index === 0
|
|
84
|
+
? [indent(member)]
|
|
85
|
+
: ["", indent(member)]),
|
|
86
|
+
"}",
|
|
87
|
+
].join("\n");
|
|
88
|
+
return {
|
|
89
|
+
path: outputPath(context, `${className}.php`),
|
|
90
|
+
code: renderPhpFile({
|
|
91
|
+
namespace: context.namespace,
|
|
92
|
+
imports: renderUseStatements(context.imports, imports),
|
|
93
|
+
body,
|
|
94
|
+
}),
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
phpType(type, context) {
|
|
98
|
+
if (type.kind === "bool") {
|
|
99
|
+
return "bool";
|
|
100
|
+
}
|
|
101
|
+
if (type.kind === "int32" || type.kind === "timestamp") {
|
|
102
|
+
return "int";
|
|
103
|
+
}
|
|
104
|
+
if (type.kind === "int64" || type.kind === "hash64") {
|
|
105
|
+
return "int|string";
|
|
106
|
+
}
|
|
107
|
+
if (type.kind === "float32" || type.kind === "float64") {
|
|
108
|
+
return "float";
|
|
109
|
+
}
|
|
110
|
+
if (type.kind === "string" || type.kind === "bytes") {
|
|
111
|
+
return "string";
|
|
112
|
+
}
|
|
113
|
+
if (type.kind === "array") {
|
|
114
|
+
return isDirectStructCollection(type)
|
|
115
|
+
? importClass(context.imports, TYPED_DATA_COLLECTION)
|
|
116
|
+
: "array";
|
|
117
|
+
}
|
|
118
|
+
if (type.kind === "optional") {
|
|
119
|
+
return nullablePhpType(this.phpType(type.inner, context));
|
|
120
|
+
}
|
|
121
|
+
if (type.kind === "record") {
|
|
122
|
+
return recordTypeClassName(type, context);
|
|
123
|
+
}
|
|
124
|
+
return "mixed";
|
|
125
|
+
}
|
|
126
|
+
toSkirExpression(type, expression, context) {
|
|
127
|
+
return this.toSkirValueExpression(type, expression, context, true);
|
|
128
|
+
}
|
|
129
|
+
fromSkirExpression(type, expression, context) {
|
|
130
|
+
return valueFromTargetExpression(type, expression, context);
|
|
131
|
+
}
|
|
132
|
+
clientResponseExpression(type, expression, context) {
|
|
133
|
+
return valueFromTargetExpression(type, expression, context);
|
|
134
|
+
}
|
|
135
|
+
enumPayloadToSkirExpression(type, expression, context) {
|
|
136
|
+
return this.toSkirValueExpression(type, expression, context, true);
|
|
137
|
+
}
|
|
138
|
+
enumPayloadFromSkirExpression(type, expression, context) {
|
|
139
|
+
return valueFromTargetExpression(type, expression, context);
|
|
140
|
+
}
|
|
141
|
+
manifestObjectClass(type, context) {
|
|
142
|
+
if (type.kind !== "record") {
|
|
143
|
+
return null;
|
|
144
|
+
}
|
|
145
|
+
const className = context.names.namesByIdentity.get(type.recordIdentity);
|
|
146
|
+
if (className === undefined) {
|
|
147
|
+
throw new Error(`No PHP class name was resolved for record ${type.recordIdentity}.`);
|
|
148
|
+
}
|
|
149
|
+
return canonicalRecordClassName(recordNamespace(type.recordIdentity, context.rootNamespace), className);
|
|
150
|
+
}
|
|
151
|
+
manifestPhpType(type) {
|
|
152
|
+
return isDirectStructCollection(type) ? TYPED_DATA_COLLECTION : null;
|
|
153
|
+
}
|
|
154
|
+
renderConstructor(record, fields, context) {
|
|
155
|
+
if (fields.length === 0) {
|
|
156
|
+
return null;
|
|
157
|
+
}
|
|
158
|
+
const validationRules = this.resolvedValidationRules.get(record.identity) ?? new Map();
|
|
159
|
+
return [
|
|
160
|
+
"public function __construct(",
|
|
161
|
+
...fields.flatMap((field) => [
|
|
162
|
+
...propertyAttributes(field, context, validationRules.get(field.name) ?? []).map((attribute) => ` ${attribute}`),
|
|
163
|
+
` public readonly ${this.phpType(field.type, context)} $${toPropertyName(field.name)},`,
|
|
164
|
+
]),
|
|
165
|
+
") {}",
|
|
166
|
+
].join("\n");
|
|
167
|
+
}
|
|
168
|
+
renderSkirType(record, context, fieldClass, typeClass) {
|
|
169
|
+
const entries = record.fields.map((field) => {
|
|
170
|
+
if (field.kind === "removed") {
|
|
171
|
+
return ` ${fieldClass}::removed(${field.number}),`;
|
|
172
|
+
}
|
|
173
|
+
if (!field.hasPayload) {
|
|
174
|
+
throw new Error(`Struct field ${field.name} in ${record.identity} has no payload type.`);
|
|
175
|
+
}
|
|
176
|
+
return ` ${fieldClass}::value(${phpSingleQuotedLiteral(field.name)}, ${field.number}, ${runtimeTypeExpression(field.type, context, this, typeClass)}),`;
|
|
177
|
+
});
|
|
178
|
+
if (entries.length === 0) {
|
|
179
|
+
return [
|
|
180
|
+
`public static function skirType(): ${typeClass}`,
|
|
181
|
+
"{",
|
|
182
|
+
` return ${typeClass}::struct([]);`,
|
|
183
|
+
"}",
|
|
184
|
+
].join("\n");
|
|
185
|
+
}
|
|
186
|
+
return [
|
|
187
|
+
`public static function skirType(): ${typeClass}`,
|
|
188
|
+
"{",
|
|
189
|
+
` return ${typeClass}::struct([`,
|
|
190
|
+
entries.join("\n"),
|
|
191
|
+
" ]);",
|
|
192
|
+
"}",
|
|
193
|
+
].join("\n");
|
|
194
|
+
}
|
|
195
|
+
renderMakeFromSkirPayload(fields, context) {
|
|
196
|
+
const payload = fields.length === 0
|
|
197
|
+
? [" $payload = [];"]
|
|
198
|
+
: [
|
|
199
|
+
" $payload = [",
|
|
200
|
+
...fields.map((field) => renderPayloadEntry(field, context)),
|
|
201
|
+
" ];",
|
|
202
|
+
];
|
|
203
|
+
return [
|
|
204
|
+
"/** @param array<string, mixed> $data */",
|
|
205
|
+
"public static function makeFromSkirPayload(array $data): self",
|
|
206
|
+
"{",
|
|
207
|
+
" self::validate($data);",
|
|
208
|
+
"",
|
|
209
|
+
...payload,
|
|
210
|
+
"",
|
|
211
|
+
" return self::from($payload);",
|
|
212
|
+
"}",
|
|
213
|
+
].join("\n");
|
|
214
|
+
}
|
|
215
|
+
renderFromSkir(className, denseJson) {
|
|
216
|
+
return [
|
|
217
|
+
`public static function fromSkir(string $json): ${className}`,
|
|
218
|
+
"{",
|
|
219
|
+
` $data = ${denseJson}::fromJson(self::skirType(), $json);`,
|
|
220
|
+
"",
|
|
221
|
+
" return self::makeFromSkirPayload($data);",
|
|
222
|
+
"}",
|
|
223
|
+
].join("\n");
|
|
224
|
+
}
|
|
225
|
+
renderToSkirArray(fields, context) {
|
|
226
|
+
if (fields.length === 0) {
|
|
227
|
+
return [
|
|
228
|
+
"/** @return array<string, mixed> */",
|
|
229
|
+
"public function toSkirArray(): array",
|
|
230
|
+
"{",
|
|
231
|
+
" return [];",
|
|
232
|
+
"}",
|
|
233
|
+
].join("\n");
|
|
234
|
+
}
|
|
235
|
+
return [
|
|
236
|
+
"/** @return array<string, mixed> */",
|
|
237
|
+
"public function toSkirArray(): array",
|
|
238
|
+
"{",
|
|
239
|
+
" return [",
|
|
240
|
+
...fields.map((field) => {
|
|
241
|
+
const expression = this.toSkirExpression(field.type, `$this->${toPropertyName(field.name)}`, context);
|
|
242
|
+
return renderArrayEntry(field.name, expression, 8);
|
|
243
|
+
}),
|
|
244
|
+
" ];",
|
|
245
|
+
"}",
|
|
246
|
+
].join("\n");
|
|
247
|
+
}
|
|
248
|
+
renderToSkir(denseJson) {
|
|
249
|
+
return [
|
|
250
|
+
"/** @return array<int, mixed> */",
|
|
251
|
+
"public function toSkir(): array",
|
|
252
|
+
"{",
|
|
253
|
+
` return ${denseJson}::encode(self::skirType(), $this->toSkirArray());`,
|
|
254
|
+
"}",
|
|
255
|
+
].join("\n");
|
|
256
|
+
}
|
|
257
|
+
renderToSkirJson(denseJson) {
|
|
258
|
+
return [
|
|
259
|
+
"public function toSkirJson(): string",
|
|
260
|
+
"{",
|
|
261
|
+
` return ${denseJson}::toJson(self::skirType(), $this->toSkirArray());`,
|
|
262
|
+
"}",
|
|
263
|
+
].join("\n");
|
|
264
|
+
}
|
|
265
|
+
toSkirValueExpression(type, expression, context, allowTypedCollection) {
|
|
266
|
+
if (type.kind === "record") {
|
|
267
|
+
return type.recordType === "enum"
|
|
268
|
+
? `${expression}->toSkirValue()`
|
|
269
|
+
: `${expression}->toSkirArray()`;
|
|
270
|
+
}
|
|
271
|
+
if (type.kind === "optional") {
|
|
272
|
+
if (isRecursivelyMappedType(type.inner)) {
|
|
273
|
+
return `${expression} === null ? null : ${this.toSkirValueExpression(type.inner, expression, context, allowTypedCollection)}`;
|
|
274
|
+
}
|
|
275
|
+
return expression;
|
|
276
|
+
}
|
|
277
|
+
if (type.kind === "array" && isRecursivelyMappedType(type.item)) {
|
|
278
|
+
const source = allowTypedCollection && isDirectStructCollection(type)
|
|
279
|
+
? `${expression}->all()`
|
|
280
|
+
: expression;
|
|
281
|
+
const itemExpression = this.toSkirValueExpression(type.item, "$item", context, false);
|
|
282
|
+
return renderArrayMap(itemExpression, source, "mixed", "mixed");
|
|
283
|
+
}
|
|
284
|
+
return expression;
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
function propertyAttributes(field, context, customRules) {
|
|
288
|
+
const attributes = [];
|
|
289
|
+
const propertyName = toPropertyName(field.name);
|
|
290
|
+
if (propertyName !== field.name) {
|
|
291
|
+
const mapPropertyName = importClass(context.imports, MAP_PROPERTY_NAME);
|
|
292
|
+
attributes.push(`#[${mapPropertyName}(${phpSingleQuotedLiteral(field.name)})]`);
|
|
293
|
+
}
|
|
294
|
+
const rules = importClass(context.imports, RULES);
|
|
295
|
+
const combinedRules = [...structuralRules(field.type), ...customRules];
|
|
296
|
+
attributes.push(`#[${rules}([${combinedRules.map(phpSingleQuotedLiteral).join(", ")}])]`);
|
|
297
|
+
const collectionType = dataCollectionType(field.type);
|
|
298
|
+
if (collectionType !== null) {
|
|
299
|
+
const dataCollection = importClass(context.imports, DATA_COLLECTION);
|
|
300
|
+
attributes.push(`#[${dataCollection}(${recordTypeClassName(collectionType, context)}::class)]`);
|
|
301
|
+
}
|
|
302
|
+
return attributes;
|
|
303
|
+
}
|
|
304
|
+
function renderPayloadEntry(field, context) {
|
|
305
|
+
const fieldLiteral = phpSingleQuotedLiteral(field.name);
|
|
306
|
+
const directExpression = `$data[${fieldLiteral}]`;
|
|
307
|
+
const expression = valueFromSkirPayloadExpression(field.type, field.type.kind === "optional"
|
|
308
|
+
? `${directExpression} ?? null`
|
|
309
|
+
: directExpression, context);
|
|
310
|
+
return renderArrayEntry(field.name, expression, 8);
|
|
311
|
+
}
|
|
312
|
+
function renderArrayEntry(fieldName, expression, indentation) {
|
|
313
|
+
const padding = " ".repeat(indentation);
|
|
314
|
+
const continuedExpression = expression.replaceAll("\n", `\n${padding}`);
|
|
315
|
+
return `${padding}${phpSingleQuotedLiteral(fieldName)} => ${continuedExpression},`;
|
|
316
|
+
}
|
|
317
|
+
function valueFromSkirPayloadExpression(type, expression, context) {
|
|
318
|
+
if (type.kind === "record") {
|
|
319
|
+
const className = recordTypeClassName(type, context);
|
|
320
|
+
return type.recordType === "enum"
|
|
321
|
+
? `${className}::fromSkirValue(${expression})`
|
|
322
|
+
: `${className}::makeFromSkirPayload(${expression})`;
|
|
323
|
+
}
|
|
324
|
+
if (type.kind === "optional") {
|
|
325
|
+
if (isRecursivelyMappedType(type.inner)) {
|
|
326
|
+
return `(${expression}) === null ? null : ${valueFromSkirPayloadExpression(type.inner, expression, context)}`;
|
|
327
|
+
}
|
|
328
|
+
return expression;
|
|
329
|
+
}
|
|
330
|
+
if (type.kind === "array" && isRecursivelyMappedType(type.item)) {
|
|
331
|
+
const itemExpression = valueFromSkirPayloadExpression(type.item, "$item", context);
|
|
332
|
+
const [inputType, returnType] = hydrationArrowTypes(type.item, context);
|
|
333
|
+
return renderArrayMap(itemExpression, expression, inputType, returnType);
|
|
334
|
+
}
|
|
335
|
+
return expression;
|
|
336
|
+
}
|
|
337
|
+
function valueFromTargetExpression(type, expression, context) {
|
|
338
|
+
if (isDirectStructCollection(type)) {
|
|
339
|
+
const typedDataCollection = importClass(context.imports, TYPED_DATA_COLLECTION);
|
|
340
|
+
const collectionClass = recordTypeClassName(type.item, context);
|
|
341
|
+
const hydratedItems = valueFromSkirPayloadExpression(type, expression, context);
|
|
342
|
+
return `${typedDataCollection}::of(${collectionClass}::class, ${hydratedItems})`;
|
|
343
|
+
}
|
|
344
|
+
if (type.kind === "optional") {
|
|
345
|
+
return `${expression} === null ? null : ${valueFromTargetExpression(type.inner, expression, context)}`;
|
|
346
|
+
}
|
|
347
|
+
return valueFromSkirPayloadExpression(type, expression, context);
|
|
348
|
+
}
|
|
349
|
+
function hydrationArrowTypes(type, context) {
|
|
350
|
+
if (type.kind === "record" && type.recordType === "struct") {
|
|
351
|
+
return ["array", recordTypeClassName(type, context)];
|
|
352
|
+
}
|
|
353
|
+
if (type.kind === "array") {
|
|
354
|
+
return ["array", "array"];
|
|
355
|
+
}
|
|
356
|
+
return ["mixed", "mixed"];
|
|
357
|
+
}
|
|
358
|
+
function renderArrayMap(itemExpression, sourceExpression, inputType, returnType) {
|
|
359
|
+
const continuedItemExpression = itemExpression.replaceAll("\n", "\n ");
|
|
360
|
+
const continuedSourceExpression = sourceExpression.replaceAll("\n", "\n ");
|
|
361
|
+
return [
|
|
362
|
+
"array_map(",
|
|
363
|
+
` static fn (${inputType} $item): ${returnType} => ${continuedItemExpression},`,
|
|
364
|
+
` ${continuedSourceExpression},`,
|
|
365
|
+
")",
|
|
366
|
+
].join("\n");
|
|
367
|
+
}
|
|
368
|
+
function dataCollectionType(type) {
|
|
369
|
+
const unwrappedType = unwrapOptionalType(type);
|
|
370
|
+
if (!isDirectStructCollection(unwrappedType)) {
|
|
371
|
+
return null;
|
|
372
|
+
}
|
|
373
|
+
return unwrappedType.item;
|
|
374
|
+
}
|
|
375
|
+
function isDirectStructCollection(type) {
|
|
376
|
+
return type.kind === "array"
|
|
377
|
+
&& type.item.kind === "record"
|
|
378
|
+
&& type.item.recordType === "struct";
|
|
379
|
+
}
|
|
380
|
+
function requiresTypedDataCollection(type) {
|
|
381
|
+
if (type.kind === "optional") {
|
|
382
|
+
return requiresTypedDataCollection(type.inner);
|
|
383
|
+
}
|
|
384
|
+
return isDirectStructCollection(type);
|
|
385
|
+
}
|
|
386
|
+
function phpSingleQuotedLiteral(value) {
|
|
387
|
+
const expressions = [];
|
|
388
|
+
let segment = "";
|
|
389
|
+
for (const character of value) {
|
|
390
|
+
if (character !== "\n" && character !== "\r") {
|
|
391
|
+
segment += character;
|
|
392
|
+
continue;
|
|
393
|
+
}
|
|
394
|
+
if (segment !== "") {
|
|
395
|
+
expressions.push(escapedPhpStringSegment(segment));
|
|
396
|
+
segment = "";
|
|
397
|
+
}
|
|
398
|
+
expressions.push(character === "\n" ? '"\\n"' : '"\\r"');
|
|
399
|
+
}
|
|
400
|
+
if (segment !== "" || expressions.length === 0) {
|
|
401
|
+
expressions.push(escapedPhpStringSegment(segment));
|
|
402
|
+
}
|
|
403
|
+
return expressions.join(".");
|
|
404
|
+
}
|
|
405
|
+
function escapedPhpStringSegment(value) {
|
|
406
|
+
return `'${value.replaceAll("\\", "\\\\").replaceAll("'", "\\'")}'`;
|
|
407
|
+
}
|
|
408
|
+
function nullablePhpType(type) {
|
|
409
|
+
const unionMembers = type.split("|");
|
|
410
|
+
if (type === "mixed" || type.startsWith("?") || unionMembers.includes("null")) {
|
|
411
|
+
return type;
|
|
412
|
+
}
|
|
413
|
+
return unionMembers.length === 1 ? `?${type}` : `${type}|null`;
|
|
414
|
+
}
|
|
415
|
+
function unwrapOptionalType(type) {
|
|
416
|
+
let unwrappedType = type;
|
|
417
|
+
while (unwrappedType.kind === "optional") {
|
|
418
|
+
unwrappedType = unwrappedType.inner;
|
|
419
|
+
}
|
|
420
|
+
return unwrappedType;
|
|
421
|
+
}
|
|
422
|
+
function isRecursivelyMappedType(type) {
|
|
423
|
+
return type.kind === "record" || type.kind === "optional" || type.kind === "array";
|
|
424
|
+
}
|
|
425
|
+
function isStructField(field) {
|
|
426
|
+
return field.kind === "field" && field.hasPayload;
|
|
427
|
+
}
|
|
428
|
+
function runtimeTypeExpression(type, context, adapter, typeClass) {
|
|
429
|
+
if (type.kind === "array") {
|
|
430
|
+
return `${typeClass}::array(${runtimeTypeExpression(type.item, context, adapter, typeClass)})`;
|
|
431
|
+
}
|
|
432
|
+
if (type.kind === "optional") {
|
|
433
|
+
return `${typeClass}::optional(${runtimeTypeExpression(type.inner, context, adapter, typeClass)})`;
|
|
434
|
+
}
|
|
435
|
+
if (type.kind === "record") {
|
|
436
|
+
return `${adapter.phpType(type, context)}::skirType()`;
|
|
437
|
+
}
|
|
438
|
+
return `${typeClass}::${type.kind}()`;
|
|
439
|
+
}
|
|
440
|
+
function classNameForRecord(record, context) {
|
|
441
|
+
const className = context.names.namesByIdentity.get(record.identity);
|
|
442
|
+
if (className === undefined) {
|
|
443
|
+
throw new Error(`No PHP class name was resolved for struct ${record.identity}.`);
|
|
444
|
+
}
|
|
445
|
+
return className;
|
|
446
|
+
}
|
|
447
|
+
function recordTypeClassName(type, context) {
|
|
448
|
+
const className = context.names.namesByIdentity.get(type.recordIdentity);
|
|
449
|
+
if (className === undefined) {
|
|
450
|
+
throw new Error(`No PHP class name was resolved for record ${type.recordIdentity}.`);
|
|
451
|
+
}
|
|
452
|
+
const namespace = recordNamespace(type.recordIdentity, context.rootNamespace);
|
|
453
|
+
if (namespace === context.namespace) {
|
|
454
|
+
return className;
|
|
455
|
+
}
|
|
456
|
+
const fullyQualifiedClassName = canonicalRecordClassName(namespace, className);
|
|
457
|
+
const isReserved = [...context.imports.reservedNames].some((reservedName) => (reservedName.toLowerCase() === className.toLowerCase()));
|
|
458
|
+
if (isReserved) {
|
|
459
|
+
return `\\${fullyQualifiedClassName}`;
|
|
460
|
+
}
|
|
461
|
+
const existingImport = [...context.imports.imports.entries()]
|
|
462
|
+
.find(([localName]) => localName.toLowerCase() === className.toLowerCase());
|
|
463
|
+
if (existingImport === undefined) {
|
|
464
|
+
return importClassAs(context.imports, fullyQualifiedClassName, className);
|
|
465
|
+
}
|
|
466
|
+
return existingImport[1].toLowerCase() === fullyQualifiedClassName.toLowerCase()
|
|
467
|
+
? existingImport[0]
|
|
468
|
+
: `\\${fullyQualifiedClassName}`;
|
|
469
|
+
}
|
|
470
|
+
function canonicalRecordClassName(namespace, className) {
|
|
471
|
+
const fullyQualifiedClassName = `${namespace}\\${className}`.replace(/^\\+/u, "");
|
|
472
|
+
const parts = fullyQualifiedClassName.split("\\");
|
|
473
|
+
if (parts.some((part) => !/^[A-Za-z_][A-Za-z0-9_]*$/u.test(part))) {
|
|
474
|
+
throw new Error(`Invalid normalized PHP record class name ${fullyQualifiedClassName}.`);
|
|
475
|
+
}
|
|
476
|
+
if (parts.at(-1) !== className) {
|
|
477
|
+
throw new Error(`Invalid normalized PHP record basename ${className}.`);
|
|
478
|
+
}
|
|
479
|
+
return fullyQualifiedClassName;
|
|
480
|
+
}
|
|
481
|
+
function recordNamespace(recordIdentity, rootNamespace) {
|
|
482
|
+
const separatorIndex = recordIdentity.lastIndexOf("::");
|
|
483
|
+
if (separatorIndex === -1) {
|
|
484
|
+
throw new Error(`Invalid normalized record identity ${recordIdentity}.`);
|
|
485
|
+
}
|
|
486
|
+
const modulePath = recordIdentity.slice(0, separatorIndex);
|
|
487
|
+
const namespaceSegments = modulePath
|
|
488
|
+
.split("/")
|
|
489
|
+
.slice(0, -1)
|
|
490
|
+
.map((segment) => toPhpNamespaceSegment(segment))
|
|
491
|
+
.filter((segment) => segment !== "");
|
|
492
|
+
return [rootNamespace, ...namespaceSegments]
|
|
493
|
+
.filter((segment) => segment !== "")
|
|
494
|
+
.join("\\");
|
|
495
|
+
}
|
|
496
|
+
function outputPath(context, fileName) {
|
|
497
|
+
return context.pathPrefix === "" ? fileName : `${context.pathPrefix}/${fileName}`;
|
|
498
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "skir-simple-data-objects-generator",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Skir code generator for std-out/simple-data-objects.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"author": {
|
|
8
|
+
"name": "Maxim Kerstens",
|
|
9
|
+
"email": "maxim.kerstens@gmail.com"
|
|
10
|
+
},
|
|
11
|
+
"homepage": "https://github.com/php-skir/skir-simple-data-objects-generator#readme",
|
|
12
|
+
"repository": {
|
|
13
|
+
"type": "git",
|
|
14
|
+
"url": "git+https://github.com/php-skir/skir-simple-data-objects-generator.git"
|
|
15
|
+
},
|
|
16
|
+
"bugs": {
|
|
17
|
+
"url": "https://github.com/php-skir/skir-simple-data-objects-generator/issues"
|
|
18
|
+
},
|
|
19
|
+
"publishConfig": {
|
|
20
|
+
"access": "public"
|
|
21
|
+
},
|
|
22
|
+
"keywords": [
|
|
23
|
+
"php",
|
|
24
|
+
"rpc",
|
|
25
|
+
"simple-data-objects",
|
|
26
|
+
"skir",
|
|
27
|
+
"skir-generator"
|
|
28
|
+
],
|
|
29
|
+
"exports": {
|
|
30
|
+
".": "./dist/index.js"
|
|
31
|
+
},
|
|
32
|
+
"types": "./dist/index.d.ts",
|
|
33
|
+
"bin": {
|
|
34
|
+
"skir-simple-data-objects-generator": "./dist/cli.js"
|
|
35
|
+
},
|
|
36
|
+
"files": [
|
|
37
|
+
"dist"
|
|
38
|
+
],
|
|
39
|
+
"scripts": {
|
|
40
|
+
"build": "tsc -p tsconfig.json",
|
|
41
|
+
"prepack": "npm run build",
|
|
42
|
+
"pretest:coverage": "npm run build",
|
|
43
|
+
"pretest": "npm run build",
|
|
44
|
+
"pack:dry-run": "npm pack --dry-run --cache .npm-cache",
|
|
45
|
+
"test": "vitest run",
|
|
46
|
+
"test:coverage": "vitest run --coverage --coverage.reporter=json-summary --coverage.include=src/**/*.ts",
|
|
47
|
+
"typecheck": "tsc -p tsconfig.json --noEmit"
|
|
48
|
+
},
|
|
49
|
+
"dependencies": {
|
|
50
|
+
"@php-skir/generator-core": "^0.1.0",
|
|
51
|
+
"skir-internal": "^0.2.21",
|
|
52
|
+
"zod": "^4.0.0"
|
|
53
|
+
},
|
|
54
|
+
"devDependencies": {
|
|
55
|
+
"@types/node": "^22.0.0",
|
|
56
|
+
"@vitest/coverage-v8": "^4.1.10",
|
|
57
|
+
"skir": "^1.2.19",
|
|
58
|
+
"typescript": "^5.9.0",
|
|
59
|
+
"vitest": "^4.0.0"
|
|
60
|
+
}
|
|
61
|
+
}
|