From 5a5d5c7da39d964bc9cf5ec4be58be43055ea65f Mon Sep 17 00:00:00 2001 From: yuriizee Date: Fri, 16 Oct 2026 21:47:17 +0300 Subject: [PATCH] Release 2.8.0: generate data object classes from a JSON, XML, array or Eloquent sample - bin/sdo-generate infers one class per object shape: property types, nested DTOs, #[DataCollection] lists and key mapping (#[TransformKeys], #[MapPropertyName], XML attribute/text/element). --optional maps keys missing from some objects to T|Optional for an exact round trip; --namespace defaults to the composer.json PSR-4 map. - DataClassGenerator::fromValue() and write() for any PHP array or object. - make:data --from-json, --from-xml and --from-model --id [--with]: the last generates from a real record with its loaded relations. --- CHANGELOG.md | 18 +- README.md | 14 + bin/sdo-generate | 3 + bin/sdo-generate.php | 135 ++++++++ composer.json | 2 +- docs/.vitepress/config.mjs | 1 + docs/features/code-generation.md | 198 +++++++++++ docs/laravel/service-provider.md | 12 + src/Laravel/Console/MakeDataCommand.php | 71 ++++ src/Support/DataClassGenerator.php | 328 ++++++++++++++++++ src/Support/SampleObject.php | 82 +++++ src/Support/SampleReader.php | 124 +++++++ src/Support/SampleType.php | 67 ++++ tests/DataClassGeneratorTest.php | 425 ++++++++++++++++++++++++ tests/Fixtures/GeneratorLineModel.php | 16 + tests/Fixtures/GeneratorOrderModel.php | 27 ++ tests/MakeDataCommandTest.php | 85 +++++ 17 files changed, 1606 insertions(+), 2 deletions(-) create mode 100755 bin/sdo-generate create mode 100644 bin/sdo-generate.php create mode 100644 docs/features/code-generation.md create mode 100644 src/Support/DataClassGenerator.php create mode 100644 src/Support/SampleObject.php create mode 100644 src/Support/SampleReader.php create mode 100644 src/Support/SampleType.php create mode 100644 tests/DataClassGeneratorTest.php create mode 100644 tests/Fixtures/GeneratorLineModel.php create mode 100644 tests/Fixtures/GeneratorOrderModel.php diff --git a/CHANGELOG.md b/CHANGELOG.md index 37d0b05..2c81e91 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,21 @@ and the project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0. ## [Unreleased] +## [2.8.0] — 2026-10-16 + +### Added +- `vendor/bin/sdo-generate ` generates data + object classes from a sample document: one class per object shape, with + property types, nested DTOs, `#[DataCollection]` lists and key mapping + (`#[TransformKeys]`, `#[MapPropertyName]`, XML attributes/text/elements) + inferred from the values. `--optional` makes keys missing from some objects + `T|Optional`, so the sample round-trips exactly. +- `DataClassGenerator::fromValue()` and `write()` do the same for any PHP + array or object. +- `make:data --from-json`, `--from-xml` and `--from-model=... --id=... + [--with=...]`: the last one generates from a real record, with loaded + relations as nested classes. + ## [2.7.0] — 2026-10-16 ### Added @@ -655,7 +670,8 @@ Initial release. `toResponse()`). - CI pipeline: tests across Laravel 10–13 and a scheduled `composer audit`. -[Unreleased]: https://github.com/std-out/simple-data-objects/compare/v2.7.0...HEAD +[Unreleased]: https://github.com/std-out/simple-data-objects/compare/v2.8.0...HEAD +[2.8.0]: https://github.com/std-out/simple-data-objects/compare/v2.7.0...v2.8.0 [2.7.0]: https://github.com/std-out/simple-data-objects/compare/v2.6.0...v2.7.0 [2.6.0]: https://github.com/std-out/simple-data-objects/compare/v2.5.0...v2.6.0 [2.5.0]: https://github.com/std-out/simple-data-objects/compare/v2.4.0...v2.5.0 diff --git a/README.md b/README.md index 6d446d0..f45a823 100644 --- a/README.md +++ b/README.md @@ -260,6 +260,20 @@ $model->update($data->definedOnly()); --- +### Generating classes from a sample + +Point `sdo-generate` at a real JSON or XML document and it writes the classes — nested DTOs, collections, types and key mapping inferred from the values: + +```sh +vendor/bin/sdo-generate storage/samples/order.json app/Data +curl -s https://api.example.com/orders/1 | vendor/bin/sdo-generate - app/Data --class=OrderData +php artisan make:data OrderData --from-model=Order --id=1042 --with=customer,items +``` + +`DataClassGenerator::fromValue($array, 'OrderData', 'App\\Data')` does the same for any PHP array or object. + +--- + ### Schema generation — JSON Schema & TypeScript `jsonSchema()` walks the same metadata as `from()`/`toArray()` — no extra reflection — into a JSON Schema (draft 2020-12). `TypeScriptGenerator`/`bin/sdo-typescript` build on top of it for `.d.ts` output: diff --git a/bin/sdo-generate b/bin/sdo-generate new file mode 100755 index 0000000..7bcff21 --- /dev/null +++ b/bin/sdo-generate @@ -0,0 +1,3 @@ +#!/usr/bin/env php + null, 'namespace' => null, 'xml-path' => null, 'force' => false, 'dry-run' => false, 'optional' => false]; +$positional = []; + +foreach (array_slice($argv, 1) as $arg) { + if (preg_match('/^--(class|namespace|xml-path)=(.+)$/', $arg, $m) === 1) { + $options[$m[1]] = $m[2]; + } elseif (in_array($arg, ['--force', '--dry-run', '--optional'], true)) { + $options[substr($arg, 2)] = true; + } elseif (str_starts_with($arg, '--')) { + fwrite(STDERR, "Unknown option: {$arg}\n"); + exit(2); + } else { + $positional[] = $arg; + } +} + +if (count($positional) !== 2) { + fwrite(STDERR, "Generates data object classes from a sample JSON or XML document: one class\n"); + fwrite(STDERR, "per object shape, with property types, nested DTOs and collections inferred\n"); + fwrite(STDERR, "from the values. Review the result — a sample only shows what it contains.\n\n"); + fwrite(STDERR, "Usage: sdo-generate [options]\n\n"); + fwrite(STDERR, " - read the sample from STDIN\n"); + fwrite(STDERR, " --class=OrderData root class name (default: from the file or XML element name)\n"); + fwrite(STDERR, " --namespace=App\\Data namespace (default: from the PSR-4 map in ./composer.json)\n"); + fwrite(STDERR, " --xml-path=catalog/offer path to the record element (default: the repeated child\n"); + fwrite(STDERR, " of the root element, or the root element itself)\n"); + fwrite(STDERR, " --optional keys missing from some objects become T|Optional (omitted\n"); + fwrite(STDERR, " again by toArray()) instead of ?T = null\n"); + fwrite(STDERR, " --dry-run print the classes instead of writing them\n"); + fwrite(STDERR, " --force overwrite existing files\n\n"); + fwrite(STDERR, "Examples: vendor/bin/sdo-generate storage/order.json app/Data\n"); + fwrite(STDERR, " curl -s https://api.example.com/orders/1 | vendor/bin/sdo-generate - app/Data --class=OrderData\n"); + exit(2); +} + +[$sample, $outputDir] = $positional; + +$identifier = '/^[A-Za-z_][A-Za-z0-9_]*$/'; +$namespace = $options['namespace'] ?? DataClassGenerator::namespaceFor(getcwd().'/composer.json', $outputDir); + +if ($namespace === null) { + fwrite(STDERR, "ERROR: {$outputDir} is not under a PSR-4 directory of ./composer.json; pass --namespace.\n"); + exit(2); +} + +foreach (explode('\\', $namespace) as $segment) { + if (preg_match($identifier, $segment) !== 1) { + fwrite(STDERR, "ERROR: invalid namespace: {$namespace}\n"); + exit(2); + } +} + +$contents = $sample === '-' ? stream_get_contents(STDIN) : (is_file($sample) ? file_get_contents($sample) : false); + +if ($contents === false) { + fwrite(STDERR, "ERROR: cannot read sample file: {$sample}\n"); + exit(1); +} + +$isXml = strtolower(pathinfo($sample, PATHINFO_EXTENSION)) === 'xml' || str_starts_with(ltrim($contents), '<'); + +if ($isXml && $sample === '-') { + $sample = (string) tempnam(sys_get_temp_dir(), 'sdo'); + file_put_contents($sample, $contents); + register_shutdown_function(static fn () => @unlink($sample)); +} + +try { + if ($isXml) { + [$type, $path] = SampleReader::fromXml($sample, $options['xml-path']); + $default = Str::studly(basename($path)).'Data'; + } else { + $type = SampleReader::fromJson($contents); + $name = $sample === '-' ? 'root' : pathinfo($sample, PATHINFO_FILENAME); + $default = Str::studly($type->list ? Str::singular($name) : $name).'Data'; + } + + $class = $options['class'] ?? $default; + + if (preg_match($identifier, $class) !== 1) { + fwrite(STDERR, "ERROR: invalid class name: {$class}\n"); + exit(2); + } + + $classes = DataClassGenerator::generate($type, $class, $namespace, $isXml, $options['optional']); +} catch (InvalidArgumentException $e) { + fwrite(STDERR, 'ERROR: '.$e->getMessage()."\n"); + exit(1); +} + +if ($options['dry-run']) { + foreach ($classes as $name => $source) { + echo "// {$outputDir}/{$name}.php\n{$source}\n"; + } + + exit(0); +} + +try { + DataClassGenerator::write($classes, $outputDir, $options['force']); +} catch (InvalidArgumentException $e) { + fwrite(STDERR, 'ERROR: '.$e->getMessage()."\n"); + exit(1); +} + +foreach (array_keys($classes) as $name) { + echo " generated {$namespace}\\{$name}\n"; +} + +$hydrate = $isXml ? "{$class}::lazyXml(\$file, '{$path}')" : "{$class}::from(\$data)"; +printf("\n%d class(es) written to %s — hydrate with %s\n", count($classes), $outputDir, $hydrate); + +exit(0); diff --git a/composer.json b/composer.json index 637e6b8..d8228aa 100644 --- a/composer.json +++ b/composer.json @@ -37,7 +37,7 @@ "illuminate/console": "Required to use the sdo:warm, sdo:clear, sdo:typescript, and make:data artisan commands", "livewire/livewire": "Required to implement \\Livewire\\Wireable alongside the WireableData trait" }, - "bin": ["bin/sdo-warm", "bin/sdo-typescript"], + "bin": ["bin/sdo-warm", "bin/sdo-typescript", "bin/sdo-generate"], "autoload": { "psr-4": { "StdOut\\SimpleDataObjects\\": "src/" diff --git a/docs/.vitepress/config.mjs b/docs/.vitepress/config.mjs index 4ff4128..bd98ef1 100644 --- a/docs/.vitepress/config.mjs +++ b/docs/.vitepress/config.mjs @@ -17,6 +17,7 @@ const guide = [ { text: 'Collecting errors', link: '/features/error-accumulation' }, { text: 'Optional values', link: '/features/optional' }, { text: 'Input pipes', link: '/features/pipes' }, + { text: 'Generating classes from a sample', link: '/features/code-generation' }, ], }, { diff --git a/docs/features/code-generation.md b/docs/features/code-generation.md new file mode 100644 index 0000000..e5fdbf3 --- /dev/null +++ b/docs/features/code-generation.md @@ -0,0 +1,198 @@ +# Generating classes from a sample + +Describing a large payload by hand — an order with a customer, addresses, line items and options — is slow and error-prone. Give the generator one real example instead, and it writes the classes: one per object shape, with property types, nested DTOs, collections and key mapping inferred from the values. + +Sources: + +- a JSON file, or JSON piped in on STDIN; +- an XML file (the result is ready for [`lazyXml()`](./xml.md)); +- any PHP array or object — `DataClassGenerator::fromValue()`; +- an Eloquent record with its loaded relations — `make:data --from-model --id`. + +It is a development tool: the output is plain PHP you review and commit. Nothing runs at hydration time. + +## From a JSON file + +```json +{ + "id": 1042, + "order_number": "A-1042", + "total": 199.9, + "tags": ["vip", "gift"], + "customer": {"first_name": "Ada", "address": {"city": "Kyiv"}}, + "items": [ + {"sku": "A1", "price": 10, "options": {"color": "red"}}, + {"sku": "B2", "price": 9.5, "discount": 1.5} + ] +} +``` + +```sh +vendor/bin/sdo-generate storage/samples/order.json app/Data +``` + +``` + generated App\Data\AddressData + generated App\Data\CustomerData + generated App\Data\OptionsData + generated App\Data\ItemData + generated App\Data\OrderData + +5 class(es) written to app/Data — hydrate with OrderData::from($data) +``` + +```php +#[TransformKeys(TransformKeys::SNAKE_CASE)] +class OrderData extends BaseData +{ + public function __construct( + public readonly int $id, + public readonly string $orderNumber, + public readonly float $total, + public readonly array $tags, + public readonly CustomerData $customer, + #[DataCollection(ItemData::class)] + public readonly TypedDataCollection $items, + ) {} +} + +class ItemData extends BaseData +{ + public function __construct( + public readonly string $sku, + public readonly float $price, + public readonly ?OptionsData $options = null, + public readonly ?float $discount = null, + ) {} +} +``` + +The namespace comes from the PSR-4 map in `./composer.json`; pass `--namespace=` for a directory outside it. The root class is named after the file (`orders.json` holding a list gives `OrderData`); pass `--class=` to choose. + +```sh +curl -s https://api.example.com/orders/1 | vendor/bin/sdo-generate - app/Data --class=OrderData +vendor/bin/sdo-generate order.json app/Data --dry-run # print instead of writing +vendor/bin/sdo-generate order.json app/Data --force # overwrite existing files +``` + +## How types are inferred + +Every object of a list is merged, so a list of a hundred orders gives better types than one. + +| Seen in the sample | Generated | +|---|---| +| `1`, `2` | `int` | +| `10` and `9.5` | `float` | +| `1` and `"a"` | `int\|string` | +| a value and `null` | `?string` (nullable, still required) | +| a key missing from some objects | `?float $discount = null`, moved to the end — or `float\|Optional` with `--optional` | +| only `null`, or incompatible values (`{...}` and `5`) | `mixed` | +| `{...}` | a nested class named after the key: `customer` → `CustomerData` | +| `[{...}, {...}]` | `#[DataCollection(ItemData::class)] TypedDataCollection`, singular name: `items` → `ItemData` | +| `[1, 2]`, `[]`, `{}` | `array` | + +Keys: + +- `snake_case` keys throughout get one class-level `#[TransformKeys(TransformKeys::SNAKE_CASE)]` and camelCase properties. +- Any other key that isn't a valid property name gets `#[MapPropertyName]`: `created-at` → `$createdAt`, `2fa` → `$field2fa`. +- `toArray()` gives the original keys back. + +Objects with the same fields share one class. Two different shapes under the same key get distinct names: `AddressData`, then `BillingAddressData`. + +### `--optional`: exact round trip + +By default a key that only some objects have becomes `?T = null`, which is convenient to read but serializes as `null` where the sample had no key. With `--optional` it becomes `T|Optional` instead: a missing key stays missing, and `from($sample)->toArray()` returns the sample unchanged. See [Optional values](./optional.md). + +## From an XML file + +```xml + + + Phone + 999.50 + Acme + a.jpg + b.jpg + Red + L + + … + +``` + +```sh +vendor/bin/sdo-generate feed.xml app/Data +# 3 class(es) written to app/Data — hydrate with OfferData::lazyXml($file, 'catalog/offer') +``` + +```php +class OfferData extends BaseData +{ + public function __construct( + #[XmlAttribute] + public readonly int $id, + #[XmlAttribute] + public readonly bool $available, + public readonly string $name, + public readonly PriceData $price, + #[XmlElement('g:brand')] + public readonly string $gBrand, + #[XmlElement('picture')] + public readonly array $pictures, + #[DataCollection(ParamData::class)] + #[XmlElement('param')] + public readonly TypedDataCollection $params, + ) {} +} + +class PriceData extends BaseData +{ + public function __construct( + #[XmlAttribute] + public readonly string $currency, + #[XmlText] + public readonly float $value, + ) {} +} +``` + +- The record element is the repeated child of the root (`catalog/offer`), or the root itself. Pass `--xml-path=catalog/shop/offers/offer` for deeper feeds. +- Every record is read, streaming, so the sample can be the full feed. +- Text is typed by its content: `3` → `int`, `999.50` → `float`, `true`/`false` → `bool`. Anything else, or a mix, is `string`. An empty element makes the property nullable. +- An element that repeats in any record becomes a list with a plural property name. +- An element with attributes becomes a nested class with `#[XmlAttribute]` properties, plus `#[XmlText]` for its text. + +## From a PHP array or object + +Anything `json_encode()` accepts works: arrays, `JsonSerializable`, Laravel collections and models, API client responses. + +```php +use StdOut\SimpleDataObjects\Support\DataClassGenerator; + +$response = $client->get('/orders/1')->json(); + +$classes = DataClassGenerator::fromValue($response, 'OrderData', 'App\\Data'); +DataClassGenerator::write($classes, app_path('Data')); +``` + +`fromValue()` returns `class name => PHP source` without touching the disk. `write()` refuses to overwrite existing files unless `force: true` is passed. Both take `optional: true` the same way as the CLI. + +## From an Eloquent record (Laravel) + +`make:data --from-model` with `--id` generates from a real record instead of the table columns, so relations become nested classes and collections: + +```sh +php artisan make:data OrderData --from-model=Order --id=1042 --with=customer,items.product +``` + +The record is serialized with `toArray()`, so the model's `$hidden` attributes are left out and `$casts` decide the types. Only relations named in `--with` are loaded. `--from-json`, `--from-xml` (with `--xml-path`), `--optional` and `--force` work the same as the CLI. See [`make:data`](../laravel/service-provider.md#make-data). + +## Review the result + +A sample only shows what it contains: + +- a field that happens to be `null` in every object is typed `mixed`; +- a list that is always empty is a plain `array`; +- an XML element that never repeats in the sample is not a list. + +Pick a sample with filled-in values, or merge several records into a list. Then adjust what the data doesn't say: [`#[Rules]`](../attributes/rules.md), enums, `#[Cast]` for dates and money, [`#[Sensitive]`](../attributes/sensitive.md) for card data. diff --git a/docs/laravel/service-provider.md b/docs/laravel/service-provider.md index de0d9c4..ebb9a92 100644 --- a/docs/laravel/service-provider.md +++ b/docs/laravel/service-provider.md @@ -168,6 +168,18 @@ Column-type mapping is best-effort (a stub generator, not a full ORM introspecti | `bool`, `boolean` | `bool` | `boolean` | | `float`, `double`, `decimal`, `numeric` | `float` | `numeric` | | `date`, `datetime`, `timestamp` | `string` | `date` | + +#### From a record or a sample + +Add `--id` to `--from-model` to generate from a real record instead of the columns — loaded relations (`--with`) become nested classes and collections, `$hidden` attributes are left out: + +```sh +php artisan make:data OrderData --from-model=Order --id=1042 --with=customer,items +php artisan make:data OrderData --from-json=storage/samples/order.json +php artisan make:data OfferData --from-xml=storage/feed.xml --xml-path=catalog/offer +``` + +These write one file per generated class into the same directory. `--optional` turns keys missing from some objects into `T|Optional`, and `--force` overwrites existing files. Type inference is described in [Generating classes from a sample](../features/code-generation.md). | `json`, `jsonb` | `array` | `array` | | anything else | `mixed` | *(none)* | diff --git a/src/Laravel/Console/MakeDataCommand.php b/src/Laravel/Console/MakeDataCommand.php index 38d4968..3adb31e 100644 --- a/src/Laravel/Console/MakeDataCommand.php +++ b/src/Laravel/Console/MakeDataCommand.php @@ -5,8 +5,13 @@ namespace StdOut\SimpleDataObjects\Laravel\Console; use Illuminate\Console\GeneratorCommand; +use Illuminate\Database\Eloquent\Model; +use Illuminate\Database\Eloquent\ModelNotFoundException; use Illuminate\Support\Facades\Schema; use Illuminate\Support\Str; +use InvalidArgumentException; +use StdOut\SimpleDataObjects\Support\DataClassGenerator; +use StdOut\SimpleDataObjects\Support\SampleReader; use Symfony\Component\Console\Input\InputOption; /** @@ -65,6 +70,10 @@ final class MakeDataCommand extends GeneratorCommand public function handle() { + if ($this->option('from-json') !== null || $this->option('from-xml') !== null || $this->option('id') !== null) { + return $this->generateFromSample(); + } + if ($this->option('rules') && $this->option('from-model') === null) { $this->components->warn('--rules has no effect without --from-model; ignoring.'); } @@ -100,6 +109,12 @@ protected function getOptions(): array ['collection', null, InputOption::VALUE_NONE, 'Add a doc-comment pointing at static::collection()'], ['path', null, InputOption::VALUE_OPTIONAL, "Output directory, relative to the app's Models convention (defaults to config('simple-data-objects.paths')[0])"], ['force', 'f', InputOption::VALUE_NONE, 'Create the class even if it already exists'], + ['from-json', null, InputOption::VALUE_REQUIRED, 'Generate the class and its nested classes from a sample JSON file'], + ['from-xml', null, InputOption::VALUE_REQUIRED, 'Generate the class and its nested classes from a sample XML file'], + ['xml-path', null, InputOption::VALUE_REQUIRED, 'Path to the record element in --from-xml (default: the repeated child of the root)'], + ['id', null, InputOption::VALUE_REQUIRED, 'With --from-model: generate from this record instead of the table columns'], + ['with', null, InputOption::VALUE_REQUIRED, 'With --id: comma-separated relations to load and generate nested classes for'], + ['optional', null, InputOption::VALUE_NONE, 'Keys missing from some sample objects become T|Optional instead of ?T = null'], ]; } @@ -113,6 +128,62 @@ protected function buildClass($name): string return str_replace('{{ properties }}', $this->propertiesBlock(), $stub); } + private function generateFromSample(): int + { + $qualified = $this->qualifyClass($this->getNameInput()); + $class = class_basename($qualified); + $namespace = Str::beforeLast($qualified, '\\'); + $optional = (bool) $this->option('optional'); + + try { + $classes = match (true) { + $this->option('from-xml') !== null => DataClassGenerator::generate(SampleReader::fromXml($this->option('from-xml'), $this->option('xml-path'))[0], $class, $namespace, true, $optional), + $this->option('from-json') !== null => DataClassGenerator::generate(SampleReader::fromJson($this->sampleFile($this->option('from-json'))), $class, $namespace, optional: $optional), + default => DataClassGenerator::fromValue($this->record(), $class, $namespace, $optional), + }; + + $written = DataClassGenerator::write($classes, dirname($this->getPath($qualified)), (bool) $this->option('force')); + } catch (InvalidArgumentException|ModelNotFoundException $e) { + $this->components->error($e->getMessage()); + + return self::FAILURE; + } + + foreach ($written as $path) { + $this->components->info("Data object [{$path}] created successfully."); + } + + return self::SUCCESS; + } + + private function sampleFile(string $path): string + { + $contents = is_file($path) ? file_get_contents($path) : false; + + if ($contents === false) { + throw new InvalidArgumentException("Cannot read sample file: {$path}"); + } + + return $contents; + } + + private function record(): Model + { + $model = $this->option('from-model'); + + if ($model === null) { + throw new InvalidArgumentException('--id requires --from-model.'); + } + + $class = class_exists($model) ? $model : $this->qualifyModel($model); + + if (! is_subclass_of($class, Model::class)) { + throw new InvalidArgumentException("Model class [{$class}] does not exist."); + } + + return $class::with(array_filter(explode(',', (string) $this->option('with'))))->findOrFail($this->option('id')); + } + private function relativeDataNamespace(): string { $configured = $this->option('path') ?? (config('simple-data-objects.paths')[0] ?? null); diff --git a/src/Support/DataClassGenerator.php b/src/Support/DataClassGenerator.php new file mode 100644 index 0000000..4a921dd --- /dev/null +++ b/src/Support/DataClassGenerator.php @@ -0,0 +1,328 @@ + class name => source */ + private array $classes = []; + + /** @var array class name => body used to detect identical shapes */ + private array $bodies = []; + + private function __construct( + private readonly string $namespace, + private readonly bool $xml, + private readonly bool $optional, + ) {} + + /** @return array class name => PHP source */ + public static function fromValue(mixed $value, string $class, string $namespace, bool $optional = false): array + { + try { + $json = json_encode($value, JSON_THROW_ON_ERROR | JSON_PRESERVE_ZERO_FRACTION); + } catch (\JsonException $e) { + throw new InvalidArgumentException("The sample cannot be encoded as JSON: {$e->getMessage()}.", 0, $e); + } + + return self::generate(SampleReader::fromJson($json), $class, $namespace, optional: $optional); + } + + /** + * @param array $classes + * @return list the written files + */ + public static function write(array $classes, string $directory, bool $force = false): array + { + $paths = []; + + foreach (array_keys($classes) as $name) { + $paths[$name] = rtrim($directory, '/')."/{$name}.php"; + } + + $existing = array_filter($paths, is_file(...)); + + if ($existing !== [] && ! $force) { + throw new InvalidArgumentException('Already exists, pass --force to overwrite: '.implode(', ', $existing)); + } + + if (! is_dir($directory) && ! @mkdir($directory, 0755, true) && ! is_dir($directory)) { + throw new InvalidArgumentException("Could not create directory: {$directory}"); + } + + foreach ($paths as $name => $path) { + if (@file_put_contents($path, $classes[$name]) === false) { + throw new InvalidArgumentException("Could not write {$path}"); + } + } + + return array_values($paths); + } + + /** + * @internal + * + * @return array class name => PHP source + */ + public static function generate(SampleType $sample, string $class, string $namespace, bool $xml = false, bool $optional = false): array + { + $object = $sample->object ?? ($sample->list ? $sample->items?->object : null); + + if ($object === null || $object->fields === []) { + throw new InvalidArgumentException('The sample must be an object or a list of objects with at least one field.'); + } + + $generator = new self($namespace, $xml, $optional); + $generator->bodies[$class] = ''; + $generator->classFor($object, $class, '', true); + + return $generator->classes; + } + + public static function namespaceFor(string $composerFile, string $directory): ?string + { + $composer = is_file($composerFile) ? json_decode((string) file_get_contents($composerFile), true) : null; + + if (! is_array($composer)) { + return null; + } + + $base = dirname((string) realpath($composerFile)); + $target = rtrim((string) preg_replace('#/(\./)+#', '/', str_starts_with($directory, '/') ? $directory : getcwd().'/'.$directory), '/'); + $best = null; + $bestLength = -1; + + foreach ([...$composer['autoload']['psr-4'] ?? [], ...$composer['autoload-dev']['psr-4'] ?? []] as $prefix => $paths) { + foreach ((array) $paths as $path) { + $root = rtrim($base.'/'.trim((string) $path, '/'), '/'); + + if (($target === $root || str_starts_with($target, $root.'/')) && strlen($root) > $bestLength) { + $rest = trim(substr($target, strlen($root)), '/'); + $best = trim($prefix.str_replace('/', '\\', $rest), '\\'); + $bestLength = strlen($root); + } + } + } + + return $best; + } + + private function classFor(SampleObject $object, string $name, string $parent, bool $root = false): string + { + [$uses, $classAttributes, $properties] = $this->members($object, $name); + $body = implode("\n", [...$uses, ...$classAttributes, ...$properties]); + $stem = substr($name, 0, -4); + + for ($i = 0; ; $i++) { + $candidate = match (true) { + $root || $i === 0 => $name, + $i === 1 => $parent.$name, + default => "{$parent}{$stem}{$i}Data", + }; + + if ($root || ! isset($this->bodies[$candidate])) { + $this->bodies[$candidate] = $body; + $this->classes[$candidate] = $this->render($candidate, $uses, $classAttributes, $properties); + + return $candidate; + } + + if ($this->bodies[$candidate] === $body) { + return $candidate; + } + } + } + + /** @return array{0: list, 1: list, 2: list} use statements, class attributes, constructor parameters */ + private function members(SampleObject $object, string $class): array + { + $uses = ['StdOut\\SimpleDataObjects\\BaseData' => true]; + $parent = substr($class, 0, -4); + $required = []; + $optional = []; + $renamed = []; + $taken = []; + + foreach ($object->fields as $key => $type) { + [$source, $raw] = match (true) { + $this->xml && $key[0] === '@' => ['attribute', substr($key, 1)], + $this->xml && $key === '#text' => ['text', 'value'], + default => ['element', $key], + }; + $repeated = $this->xml && $source === 'element' && $type->list; + $effective = $this->xml && $source === 'element' && ! $type->list ? $type->items : $type; + $property = self::unique(self::propertyName($repeated ? Str::plural($raw) : $raw), $taken); + $attributes = []; + + [$phpType, $nullable] = $this->phpType($effective ?? new SampleType, $raw, $parent, $attributes, $uses); + + if ($this->xml) { + $mapping = match ($source) { + 'attribute' => $raw === $property ? 'XmlAttribute' : 'XmlAttribute('.var_export($raw, true).')', + 'text' => 'XmlText', + default => $raw === $property ? null : 'XmlElement('.var_export($raw, true).')', + }; + + if ($mapping !== null) { + $attributes[] = $mapping; + $uses[self::ATTRIBUTES.strtok($mapping, '(')] = true; + } + } elseif ($raw !== $property) { + $renamed[$property] = $raw; + } + + $absent = $object->presence[$key] < $object->seen && ! ($this->xml && $type->list); + $missing = $absent && $this->optional && $phpType !== 'mixed'; + $defaulted = $absent && ! $missing; + + if ($missing) { + $phpType .= '|Optional'; + $uses['StdOut\\SimpleDataObjects\\Optional'] = true; + } + + if ($defaulted || $nullable) { + $phpType = match (true) { + $phpType === 'mixed' => $phpType, + str_contains($phpType, '|') => $phpType.'|null', + default => '?'.$phpType, + }; + } + + $line = "public readonly {$phpType} \${$property}".($defaulted ? ' = null' : '').','; + $entry = [$property, $attributes, $line]; + + if ($defaulted) { + $optional[] = $entry; + } else { + $required[] = $entry; + } + } + + $classAttributes = []; + $snake = $renamed !== [] && array_all($renamed, static fn (string $raw, string $property): bool => KeyTransformer::apply($property, TransformKeys::SNAKE_CASE) === $raw); + + if ($snake) { + $classAttributes[] = '#[TransformKeys(TransformKeys::SNAKE_CASE)]'; + $uses[self::ATTRIBUTES.'TransformKeys'] = true; + } elseif ($renamed !== []) { + $uses[self::ATTRIBUTES.'MapPropertyName'] = true; + } + + $properties = []; + + foreach ([...$required, ...$optional] as [$property, $attributes, $line]) { + if (! $snake && isset($renamed[$property])) { + array_unshift($attributes, 'MapPropertyName('.var_export($renamed[$property], true).')'); + } + + foreach ($attributes as $attribute) { + $properties[] = " #[{$attribute}]"; + } + + $properties[] = " {$line}"; + } + + $useLines = array_keys($uses); + sort($useLines); + + return [array_map(static fn (string $use): string => "use {$use};", $useLines), $classAttributes, $properties]; + } + + /** + * @param list $attributes + * @param array $uses + * @return array{0: string, 1: bool} type without nullability, whether null was seen + */ + private function phpType(SampleType $type, string $raw, string $parent, array &$attributes, array &$uses): array + { + $kinds = ($type->scalars !== [] ? 1 : 0) + ($type->object !== null ? 1 : 0) + ($type->list ? 1 : 0); + + if ($kinds !== 1) { + return ['mixed', false]; + } + + if ($type->object !== null) { + return $type->object->fields === [] + ? ['array', $type->nullable] + : [$this->classFor($type->object, Str::studly($raw).'Data', $parent), $type->nullable]; + } + + $items = $type->items; + + if ($type->list && $items !== null && $items->object !== null && $items->object->fields !== [] && $items->scalars === [] && ! $items->list) { + $class = $this->classFor($items->object, Str::studly(Str::singular($raw)).'Data', $parent); + $attributes[] = "DataCollection({$class}::class)"; + $uses[self::ATTRIBUTES.'DataCollection'] = true; + $uses['StdOut\\SimpleDataObjects\\TypedDataCollection'] = true; + + return ['TypedDataCollection', $type->nullable]; + } + + if ($type->list) { + return ['array', $type->nullable]; + } + + $scalars = $type->scalars; + + if (isset($scalars['int'], $scalars['float'])) { + unset($scalars['int']); + } + + if ($this->xml && isset($scalars['string'])) { + $scalars = ['string' => true]; + } + + return [implode('|', array_values(array_filter(self::SCALAR_ORDER, static fn (string $scalar): bool => isset($scalars[$scalar])))), $type->nullable]; + } + + private static function propertyName(string $raw): string + { + $name = Str::camel((string) preg_replace('/[^A-Za-z0-9]+/', '_', $raw)); + + return match (true) { + $name === '' => 'field', + ctype_digit($name[0]) => 'field'.$name, + $name === 'this' => 'this_', + default => $name, + }; + } + + /** @param array $taken */ + private static function unique(string $name, array &$taken): string + { + $candidate = $name; + + for ($i = 2; isset($taken[$candidate]); $i++) { + $candidate = $name.$i; + } + + $taken[$candidate] = true; + + return $candidate; + } + + /** + * @param list $uses + * @param list $classAttributes + * @param list $properties + */ + private function render(string $class, array $uses, array $classAttributes, array $properties): string + { + $header = $classAttributes === [] ? '' : implode("\n", $classAttributes)."\n"; + + return "namespace};\n\n" + .implode("\n", $uses)."\n\n" + ."{$header}class {$class} extends BaseData\n{\n public function __construct(\n" + .implode("\n", $properties)."\n ) {}\n}\n"; + } +} diff --git a/src/Support/SampleObject.php b/src/Support/SampleObject.php new file mode 100644 index 0000000..a91fda2 --- /dev/null +++ b/src/Support/SampleObject.php @@ -0,0 +1,82 @@ + */ + public array $fields = []; + + /** @var array */ + public array $presence = []; + + public function add(\stdClass $value): void + { + $this->seen++; + + foreach (get_object_vars($value) as $key => $field) { + $this->field((string) $key)->add($field); + } + } + + public function addElement(DOMElement $element): void + { + $this->seen++; + + foreach ($element->attributes as $attribute) { + $this->field('@'.$attribute->nodeName)->addText($attribute->value); + } + + $children = []; + + foreach ($element->childNodes as $child) { + if ($child instanceof DOMElement) { + $children[$child->nodeName][] = $child; + } + } + + foreach ($children as $name => $elements) { + $field = $this->field($name); + $field->list = $field->list || count($elements) > 1; + $field->items ??= new SampleType; + + foreach ($elements as $child) { + $field->items->addElement($child); + } + } + + $text = self::ownText($element); + + if ($text !== '' || isset($this->fields['#text'])) { + $this->field('#text')->addText($text); + } + } + + private static function ownText(DOMElement $element): string + { + $text = ''; + + foreach ($element->childNodes as $node) { + if ($node instanceof DOMText) { + $text .= $node->data; + } + } + + return trim($text); + } + + private function field(string $key): SampleType + { + $this->presence[$key] = ($this->presence[$key] ?? 0) + 1; + + return $this->fields[$key] ??= new SampleType; + } +} diff --git a/src/Support/SampleReader.php b/src/Support/SampleReader.php new file mode 100644 index 0000000..0aab3bd --- /dev/null +++ b/src/Support/SampleReader.php @@ -0,0 +1,124 @@ +getMessage()}.", 0, $e); + } + + $type = new SampleType; + $type->add($value); + + return $type; + } + + /** @return array{0: SampleType, 1: string} the records and the path they were read from */ + public static function fromXml(string $uri, ?string $path = null): array + { + $path ??= self::recordPath($uri); + $segments = explode('/', trim($path, '/')); + $last = count($segments) - 1; + $level = 0; + $type = new SampleType; + $xml = self::open($uri); + + $more = $xml->read(); + + while ($more) { + if ($xml->nodeType === XMLReader::ELEMENT) { + if ($xml->name !== $segments[$level]) { + $more = $xml->next(); + + continue; + } + + if ($level === $last) { + $element = $xml->expand(); + + if ($element instanceof DOMElement) { + $type->addElement($element); + } + + $more = $xml->next(); + + continue; + } + + if (! $xml->isEmptyElement) { + $level++; + } + } elseif ($xml->nodeType === XMLReader::END_ELEMENT) { + $level--; + } + + $more = $xml->read(); + } + + self::close($xml, $uri); + + if ($type->object === null) { + throw new InvalidArgumentException("No element with attributes or child elements found at \"{$path}\" in {$uri}."); + } + + return [$type, $path]; + } + + private static function recordPath(string $uri): string + { + $xml = self::open($uri); + $root = ''; + $children = []; + $more = $xml->read(); + + while ($more && count($children) < 2) { + if ($xml->nodeType === XMLReader::ELEMENT && $xml->depth === 0) { + $root = $xml->name; + } elseif ($xml->nodeType === XMLReader::ELEMENT && $xml->depth === 1) { + $children[] = $xml->name; + $more = $xml->next(); + + continue; + } + + $more = $xml->read(); + } + + self::close($xml, $uri); + + return count($children) === 2 && $children[0] === $children[1] ? "{$root}/{$children[0]}" : $root; + } + + private static function open(string $uri): XMLReader + { + libxml_clear_errors(); + + try { + return XMLReader::fromUri($uri, null, LIBXML_NONET | LIBXML_NOERROR | LIBXML_NOWARNING); + } catch (\Error $e) { + throw new InvalidArgumentException("Cannot read XML sample {$uri}: {$e->getMessage()}", 0, $e); + } + } + + private static function close(XMLReader $xml, string $uri): void + { + $xml->close(); + $error = libxml_get_last_error(); + + if ($error !== false && $error->level === LIBXML_ERR_FATAL) { + throw new InvalidArgumentException("Invalid XML sample {$uri}: ".trim($error->message)." on line {$error->line}."); + } + } +} diff --git a/src/Support/SampleType.php b/src/Support/SampleType.php new file mode 100644 index 0000000..7985202 --- /dev/null +++ b/src/Support/SampleType.php @@ -0,0 +1,67 @@ + */ + public array $scalars = []; + + public bool $nullable = false; + + public ?SampleObject $object = null; + + public ?SampleType $items = null; + + public bool $list = false; + + public function add(mixed $value): void + { + match (true) { + $value === null => $this->nullable = true, + $value instanceof \stdClass => ($this->object ??= new SampleObject)->add($value), + is_array($value) => $this->addList($value), + default => $this->scalars[get_debug_type($value)] = true, + }; + } + + public function addElement(DOMElement $element): void + { + if (! $element->hasAttributes() && $element->firstElementChild === null) { + $this->addText($element->textContent); + + return; + } + + ($this->object ??= new SampleObject)->addElement($element); + } + + public function addText(string $text): void + { + $text = trim($text); + + match (true) { + $text === '' => $this->nullable = true, + preg_match('/^-?(0|[1-9]\d{0,17})$/', $text) === 1 => $this->scalars['int'] = true, + preg_match('/^-?\d+\.\d+$/', $text) === 1 => $this->scalars['float'] = true, + $text === 'true' || $text === 'false' => $this->scalars['bool'] = true, + default => $this->scalars['string'] = true, + }; + } + + /** @param array $values */ + private function addList(array $values): void + { + $this->list = true; + $this->items ??= new self; + + foreach ($values as $value) { + $this->items->add($value); + } + } +} diff --git a/tests/DataClassGeneratorTest.php b/tests/DataClassGeneratorTest.php new file mode 100644 index 0000000..568bef7 --- /dev/null +++ b/tests/DataClassGeneratorTest.php @@ -0,0 +1,425 @@ +dir = sys_get_temp_dir().'/sdo-generate-'.bin2hex(random_bytes(6)); + mkdir($this->dir); + } + + protected function tearDown(): void + { + array_map(unlink(...), glob($this->dir.'/*') ?: []); + rmdir($this->dir); + } + + /** @param array $classes */ + private function load(array $classes): void + { + foreach ($classes as $name => $source) { + file_put_contents("{$this->dir}/{$name}.php", $source); + require "{$this->dir}/{$name}.php"; + } + } + + private function namespace(): string + { + return 'Generated\\N'.bin2hex(random_bytes(6)); + } + + /** @return array */ + private function fromJson(string $json, string $class, bool $optional = false, ?string $namespace = null): array + { + return DataClassGenerator::generate(SampleReader::fromJson($json), $class, $namespace ?? $this->namespace(), optional: $optional); + } + + private function xmlFile(string $xml): string + { + file_put_contents($path = $this->dir.'/sample.xml', $xml); + + return $path; + } + + private const string ORDER = <<<'JSON' + { + "id": 1042, + "order_number": "A-1042", + "total": 199.9, + "paid": true, + "tags": ["vip", "gift"], + "customer": {"first_name": "Ada", "address": {"city": "Kyiv"}}, + "items": [ + {"sku": "A1", "price": 10, "options": {"color": "red"}}, + {"sku": "B2", "price": 9.5, "discount": 1.5} + ] + } + JSON; + + public function test_json_sample_generates_nested_classes_and_collections(): void + { + $ns = $this->namespace(); + $classes = $this->fromJson(self::ORDER, 'OrderData', namespace: $ns); + + $this->assertSame(['AddressData', 'CustomerData', 'OptionsData', 'ItemData', 'OrderData'], array_keys($classes)); + $this->assertStringContainsString("namespace {$ns};", $classes['OrderData']); + $this->assertStringContainsString("#[TransformKeys(TransformKeys::SNAKE_CASE)]\nclass OrderData extends BaseData", $classes['OrderData']); + $this->assertStringContainsString('public readonly string $orderNumber,', $classes['OrderData']); + $this->assertStringContainsString("#[DataCollection(ItemData::class)]\n public readonly TypedDataCollection \$items,", $classes['OrderData']); + $this->assertStringContainsString('public readonly array $tags,', $classes['OrderData']); + $this->assertStringContainsString("#[TransformKeys(TransformKeys::SNAKE_CASE)]\nclass CustomerData extends BaseData", $classes['CustomerData']); + $this->assertStringContainsString(" public readonly float \$price,\n public readonly ?OptionsData \$options = null,\n public readonly ?float \$discount = null,", $classes['ItemData']); + + $this->load($classes); + $order = ("{$ns}\\OrderData")::from(json_decode(self::ORDER, true)); + + $this->assertSame('A-1042', $order->orderNumber); + $this->assertSame('Kyiv', $order->customer->address->city); + $this->assertSame('red', $order->items->first()->options->color); + $this->assertNull($order->items->all()[1]->options); + $this->assertSame(1.5, $order->items->all()[1]->discount); + } + + public function test_optional_mode_round_trips_the_sample_exactly(): void + { + $ns = $this->namespace(); + $classes = $this->fromJson(self::ORDER, 'OrderData', optional: true, namespace: $ns); + + $this->assertStringContainsString(" public readonly OptionsData|Optional \$options,\n public readonly float|Optional \$discount,", $classes['ItemData']); + $this->assertStringContainsString('use StdOut\\SimpleDataObjects\\Optional;', $classes['ItemData']); + + $this->load($classes); + $sample = json_decode(self::ORDER, true); + + $this->assertSame(json_encode($sample), ("{$ns}\\OrderData")::from($sample)->toJson()); + } + + public function test_types_are_inferred_across_every_object_of_a_list(): void + { + $classes = $this->fromJson('[ + {"n": 1, "m": 1, "s": "a", "o": {"x": 1}, "p": {"x": 1}, "u": null, "e": {}, "l": [1, "a"], "mixed": [{"x": 1}, 2], "q": 1}, + {"n": "a", "m": 1.5, "s": null, "o": null, "p": 5, "u": null, "e": {}, "l": [], "mixed": [], "z": null} + ]', 'RowData', optional: true); + + $row = $classes['RowData']; + + $this->assertStringContainsString('public readonly int|string $n,', $row); + $this->assertStringContainsString('public readonly float $m,', $row); + $this->assertStringContainsString('public readonly ?string $s,', $row); + $this->assertStringContainsString('public readonly ?OData $o,', $row); + $this->assertStringContainsString('public readonly mixed $p,', $row); + $this->assertStringContainsString('public readonly mixed $u,', $row); + $this->assertStringContainsString('public readonly array $e,', $row); + $this->assertStringContainsString('public readonly array $l,', $row); + $this->assertStringContainsString('public readonly array $mixed,', $row); + $this->assertStringContainsString('public readonly int|Optional $q,', $row); + $this->assertStringContainsString('public readonly mixed $z = null,', $row); + } + + public function test_awkward_keys_get_valid_unique_property_names(): void + { + $ns = $this->namespace(); + $sample = '{"user_id": 1, "userId": 2, "2fa": true, "--": "x", "this": "t", "created-at": "now"}'; + $classes = $this->fromJson($sample, 'KeysData', namespace: $ns); + $source = $classes['KeysData']; + + $this->assertStringNotContainsString('TransformKeys', $source); + + foreach (["#[MapPropertyName('user_id')]\n public readonly int \$userId,", "#[MapPropertyName('userId')]\n public readonly int \$userId2,", "#[MapPropertyName('2fa')]\n public readonly bool \$field2fa,", "#[MapPropertyName('--')]\n public readonly string \$field,", "#[MapPropertyName('this')]\n public readonly string \$this_,", "#[MapPropertyName('created-at')]\n public readonly string \$createdAt,"] as $expected) { + $this->assertStringContainsString($expected, $source); + } + + $this->load($classes); + + $this->assertSame(json_decode($sample, true), ("{$ns}\\KeysData")::from(json_decode($sample, true))->toArray()); + } + + public function test_identical_shapes_share_a_class_and_different_ones_are_renamed(): void + { + $classes = $this->fromJson('{ + "billing": {"address": {"city": "K"}}, + "shipping": {"address": {"city": "L"}}, + "a": {"billing": {"address": {"zip": 1}}}, + "b": {"billing": {"address": {"street": "s"}}} + }', 'RootData'); + + $this->assertSame(['AddressData', 'BillingData', 'ShippingData', 'BillingAddressData', 'ABillingData', 'AData', 'BillingAddress2Data', 'BBillingData', 'BData', 'RootData'], array_keys($classes)); + $this->assertStringContainsString('public readonly AddressData $address,', $classes['ShippingData']); + $this->assertStringContainsString('public readonly BillingAddress2Data $address,', $classes['BBillingData']); + } + + public function test_root_class_keeps_its_name_when_a_nested_key_matches_it(): void + { + $classes = $this->fromJson('{"order": {"x": 1}}', 'OrderData'); + + $this->assertSame(['OrderOrderData', 'OrderData'], array_keys($classes)); + $this->assertStringContainsString('public readonly OrderOrderData $order,', $classes['OrderData']); + } + + public function test_sample_must_contain_an_object(): void + { + foreach (['42', '[1, 2]', '{}', '[]'] as $json) { + try { + $this->fromJson($json, 'XData'); + $this->fail("Expected an exception for {$json}"); + } catch (InvalidArgumentException $e) { + $this->assertSame('The sample must be an object or a list of objects with at least one field.', $e->getMessage()); + } + } + } + + public function test_invalid_json_is_reported(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Invalid JSON sample: Syntax error.'); + + SampleReader::fromJson('{"a": '); + } + + private const string CATALOG = <<<'XML' + + + + Phone + 999.50 + 3 + 7 + Acme + a.jpg + b.jpg + Red + L + + + + + Case + 99 + + A7 + Acme + c.jpg + Blue + + + + XML; + + public function test_xml_sample_generates_classes_that_lazy_xml_hydrates(): void + { + $file = $this->xmlFile(self::CATALOG); + [$sample, $path] = SampleReader::fromXml($file); + + $this->assertSame('catalog/offer', $path); + + $ns = $this->namespace(); + $classes = DataClassGenerator::generate($sample, 'OfferData', $ns, xml: true); + $offer = $classes['OfferData']; + + foreach (["#[XmlAttribute]\n public readonly int \$id,", "#[XmlAttribute]\n public readonly bool \$available,", 'public readonly ?int $stock,', 'public readonly string $code,', "#[XmlElement('g:brand')]\n public readonly string \$gBrand,", "#[XmlElement('picture')]\n public readonly array \$pictures,", "#[DataCollection(ParamData::class)]\n #[XmlElement('param')]\n public readonly TypedDataCollection \$params,"] as $expected) { + $this->assertStringContainsString($expected, $offer); + } + + $this->assertStringContainsString("#[XmlText]\n public readonly float \$value,", $classes['PriceData']); + $this->assertStringContainsString("#[XmlText]\n public readonly ?string \$value,", $classes['LabelData']); + + $this->load($classes); + $rows = iterator_to_array(("{$ns}\\OfferData")::lazyXml($file, $path), false); + + $this->assertSame(999.5, $rows[0]->price->value); + $this->assertSame(['a.jpg', 'b.jpg'], $rows[0]->pictures); + $this->assertSame('Blue', $rows[1]->params->first()->value); + $this->assertNull($rows[1]->stock); + $this->assertCount(2, $rows[1]->delivery->options); + $this->assertNull($rows[1]->label->value); + } + + public function test_xml_attribute_named_unlike_its_property_is_mapped(): void + { + [$sample] = SampleReader::fromXml($this->xmlFile('x')); + $classes = DataClassGenerator::generate($sample, 'ItemData', 'App', xml: true); + + $this->assertStringContainsString("#[XmlAttribute('data-id')]\n public readonly int \$dataId,", $classes['ItemData']); + } + + public function test_xml_path_defaults_to_the_root_without_repeated_children(): void + { + $file = $this->xmlFile('ab'); + + $this->assertSame('order', SampleReader::fromXml($file)[1]); + $this->assertSame('/order/', SampleReader::fromXml($file, '/order/')[1]); + + [$sample] = SampleReader::fromXml($this->xmlFile(''), 'r/i'); + $this->assertSame(['@a'], array_keys($sample->object->fields ?? [])); + } + + public function test_xml_errors_are_reported(): void + { + $cases = [ + ['12', null, 'No element with attributes or child elements found at "r/i" in '], + ['', 'r/i', 'Invalid XML sample '], + ['', null, 'Invalid XML sample '], + ['', null, 'Invalid XML sample '], + ]; + + foreach ($cases as [$xml, $path, $message]) { + try { + SampleReader::fromXml($this->xmlFile($xml), $path); + $this->fail("Expected an exception for {$xml}"); + } catch (InvalidArgumentException $e) { + $this->assertStringStartsWith($message, $e->getMessage()); + } + } + + $this->expectExceptionMessage('Cannot read XML sample '); + SampleReader::fromXml(''); + } + + public function test_namespace_is_derived_from_the_psr4_map(): void + { + $composer = $this->dir.'/composer.json'; + file_put_contents($composer, json_encode([ + 'autoload' => ['psr-4' => ['App\\' => 'app/', 'App\\Domain\\' => ['src/domain', 'lib/domain']]], + 'autoload-dev' => ['psr-4' => ['Tests\\' => 'tests/']], + ])); + + $this->assertSame('App\\Data\\Orders', DataClassGenerator::namespaceFor($composer, $this->dir.'/app/Data/Orders/')); + $this->assertSame('App', DataClassGenerator::namespaceFor($composer, $this->dir.'/./app')); + $this->assertSame('App\\Domain\\Data', DataClassGenerator::namespaceFor($composer, $this->dir.'/lib/domain/Data')); + $this->assertSame('Tests\\Fixtures', DataClassGenerator::namespaceFor($composer, $this->dir.'/tests/Fixtures')); + $this->assertNull(DataClassGenerator::namespaceFor($composer, $this->dir.'/application')); + $this->assertNull(DataClassGenerator::namespaceFor($this->dir.'/missing.json', $this->dir.'/app')); + + $cwd = getcwd(); + chdir($this->dir); + + try { + $this->assertSame('App\\Data', DataClassGenerator::namespaceFor($composer, 'app/Data')); + } finally { + chdir((string) $cwd); + } + + file_put_contents($composer, 'not json'); + $this->assertNull(DataClassGenerator::namespaceFor($composer, $this->dir.'/app')); + } + + public function test_from_value_accepts_arrays_and_objects(): void + { + $ns = $this->namespace(); + $value = ['id' => 1, 'price' => 10.0, 'owner' => new \ArrayObject(['name' => 'Ada']), 'tags' => ['a']]; + $classes = DataClassGenerator::fromValue($value, 'ThingData', $ns); + + $this->assertStringContainsString('public readonly float $price,', $classes['ThingData']); + $this->assertStringContainsString('public readonly OwnerData $owner,', $classes['ThingData']); + + $this->load($classes); + $this->assertSame('Ada', ("{$ns}\\ThingData")::from(['id' => 1, 'price' => 10.0, 'owner' => ['name' => 'Ada'], 'tags' => ['a']])->owner->name); + + $this->expectExceptionMessage('The sample cannot be encoded as JSON: '); + DataClassGenerator::fromValue(['bad' => NAN], 'XData', $ns); + } + + public function test_write_creates_files_and_refuses_to_overwrite_without_force(): void + { + $classes = ['AData' => ' 'dir.'/out'; + + $this->assertSame(["{$target}/AData.php", "{$target}/BData.php"], DataClassGenerator::write($classes, $target.'/')); + $this->assertSame('fail('Expected an exception'); + } catch (InvalidArgumentException $e) { + $this->assertSame("Already exists, pass --force to overwrite: {$target}/AData.php, {$target}/BData.php", $e->getMessage()); + } + + DataClassGenerator::write(['AData' => 'assertSame(' 'x'], $target); + $this->fail('Expected an exception'); + } catch (InvalidArgumentException $e) { + $this->assertSame("Could not write {$target}/CData.php", $e->getMessage()); + } finally { + rmdir("{$target}/CData.php"); + rmdir($target); + } + + $this->expectExceptionMessage('Could not create directory: '); + DataClassGenerator::write($classes, $this->xmlFile('').'/nested'); + } + + public function test_rendered_class_is_exact(): void + { + $classes = $this->fromJson('{"items": [{"x": 1}], "user_name": "a", "n": [{"v": 1}, {"v": "a"}, {"v": null}]}', 'OrderData', namespace: 'App\\Data'); + + $this->assertSame(<<<'PHP' + assertStringContainsString('public readonly int|string|null $v,', $classes['NData']); + } + + public function test_xml_text_is_joined_around_child_elements_and_trimmed(): void + { + [$sample] = SampleReader::fromXml($this->xmlFile("

\n 7x\n

8y

")); + + $this->assertSame(['int' => true], $sample->object?->fields['#text']->scalars); + $this->assertSame(2, $sample->object?->seen); + } + + public function test_xml_path_tracks_levels_across_sibling_groups(): void + { + $file = $this->xmlFile(''); + + $this->assertSame(2, SampleReader::fromXml($file, 'r/g/i')[0]->object?->seen); + } + + public function test_a_failed_parse_does_not_leak_into_the_next_one(): void + { + try { + SampleReader::fromXml($this->xmlFile(''), 'r/i'); + } catch (InvalidArgumentException) { + } + + $this->assertSame('r/i', SampleReader::fromXml($this->xmlFile(''))[1]); + } +} diff --git a/tests/Fixtures/GeneratorLineModel.php b/tests/Fixtures/GeneratorLineModel.php new file mode 100644 index 0000000..9f79a77 --- /dev/null +++ b/tests/Fixtures/GeneratorLineModel.php @@ -0,0 +1,16 @@ + 'boolean', 'total' => 'float']; + + /** @return HasMany */ + public function lines(): HasMany + { + return $this->hasMany(GeneratorLineModel::class, 'order_id'); + } +} diff --git a/tests/MakeDataCommandTest.php b/tests/MakeDataCommandTest.php index b33460d..207b1ec 100644 --- a/tests/MakeDataCommandTest.php +++ b/tests/MakeDataCommandTest.php @@ -4,9 +4,11 @@ namespace StdOut\SimpleDataObjects\Tests; +use Illuminate\Database\Schema\Blueprint; use Illuminate\Support\Facades\Artisan; use Illuminate\Support\Facades\File; use Illuminate\Support\Facades\Schema; +use StdOut\SimpleDataObjects\Tests\Fixtures\GeneratorOrderModel; use StdOut\SimpleDataObjects\Tests\Fixtures\TestProductModel; use StdOut\SimpleDataObjects\Tests\Laravel\TestCase; @@ -178,4 +180,87 @@ public function test_path_option_changes_namespace_and_output_directory(): void $this->assertFileExists($path); $this->assertStringContainsString('namespace App\\Dto;', file_get_contents($path)); } + + private function seedOrder(): int + { + Schema::create('generator_orders', function (Blueprint $table): void { + $table->id(); + $table->string('order_number'); + $table->boolean('paid'); + $table->float('total'); + $table->string('secret'); + }); + + Schema::create('generator_lines', function (Blueprint $table): void { + $table->id(); + $table->foreignId('order_id'); + $table->string('sku'); + $table->integer('qty'); + }); + + $order = GeneratorOrderModel::create(['order_number' => 'A-1', 'paid' => true, 'total' => 10.0, 'secret' => 's']); + $order->lines()->createMany([['sku' => 'X', 'qty' => 2], ['sku' => 'Y', 'qty' => 1]]); + + return $order->id; + } + + public function test_from_model_with_id_generates_from_the_record_and_its_loaded_relations(): void + { + $id = $this->seedOrder(); + + $exit = Artisan::call('make:data', ['name' => 'OrderData', '--from-model' => GeneratorOrderModel::class, '--id' => $id, '--with' => 'lines']); + + $this->assertSame(0, $exit); + $this->assertStringContainsString('created successfully', Artisan::output()); + + $order = file_get_contents(app_path('Data/OrderData.php')); + $this->assertStringContainsString('namespace App\\Data;', $order); + $this->assertStringContainsString('#[TransformKeys(TransformKeys::SNAKE_CASE)]', $order); + $this->assertStringContainsString('public readonly bool $paid,', $order); + $this->assertStringContainsString('public readonly float $total,', $order); + $this->assertStringContainsString("#[DataCollection(LineData::class)]\n public readonly TypedDataCollection \$lines,", $order); + $this->assertStringNotContainsString('secret', $order); + $this->assertStringContainsString('public readonly int $orderId,', file_get_contents(app_path('Data/LineData.php'))); + } + + public function test_from_model_with_id_reports_a_missing_record_or_model(): void + { + $this->seedOrder(); + + $this->assertSame(1, Artisan::call('make:data', ['name' => 'OrderData', '--from-model' => GeneratorOrderModel::class, '--id' => 999])); + $this->assertStringContainsString('No query results', Artisan::output()); + + $this->assertSame(1, Artisan::call('make:data', ['name' => 'OrderData', '--from-model' => 'NoSuchModelAtAll', '--id' => 1])); + $this->assertStringContainsString('does not exist', Artisan::output()); + + $this->assertSame(1, Artisan::call('make:data', ['name' => 'OrderData', '--id' => 1])); + $this->assertStringContainsString('--id requires --from-model.', Artisan::output()); + $this->assertFileDoesNotExist(app_path('Data/OrderData.php')); + } + + public function test_from_json_and_from_xml_generate_nested_classes(): void + { + $dir = sys_get_temp_dir().'/sdo-make-'.bin2hex(random_bytes(4)); + mkdir($dir); + file_put_contents("{$dir}/s.json", '{"id": 1, "customer": {"name": "Ada"}, "note": "x"}'); + file_put_contents("{$dir}/s.xml", 'a'); + + try { + $this->assertSame(0, Artisan::call('make:data', ['name' => 'InvoiceData', '--from-json' => "{$dir}/s.json", '--optional' => true])); + $this->assertStringContainsString('public readonly CustomerData $customer,', file_get_contents(app_path('Data/InvoiceData.php'))); + + $this->assertSame(0, Artisan::call('make:data', ['name' => 'FeedItemData', '--from-xml' => "{$dir}/s.xml", '--xml-path' => 'feed/item', '--optional' => true])); + $this->assertStringContainsString('public readonly string|Optional $name,', file_get_contents(app_path('Data/FeedItemData.php'))); + + $this->assertSame(1, Artisan::call('make:data', ['name' => 'InvoiceData', '--from-json' => "{$dir}/s.json"])); + $this->assertStringContainsString('Already exists, pass --force to overwrite', Artisan::output()); + $this->assertSame(0, Artisan::call('make:data', ['name' => 'InvoiceData', '--from-json' => "{$dir}/s.json", '--force' => true])); + + $this->assertSame(1, Artisan::call('make:data', ['name' => 'InvoiceData', '--from-json' => "{$dir}/missing.json"])); + $this->assertStringContainsString('Cannot read sample file', Artisan::output()); + } finally { + array_map(unlink(...), glob("{$dir}/*") ?: []); + rmdir($dir); + } + } }