Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 17 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <sample.json|sample.xml|-> <dir>` 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
Expand Down Expand Up @@ -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
Expand Down
14 changes: 14 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
3 changes: 3 additions & 0 deletions bin/sdo-generate
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
#!/usr/bin/env php
<?php
require __DIR__.'/sdo-generate.php';
135 changes: 135 additions & 0 deletions bin/sdo-generate.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
<?php

declare(strict_types=1);

$candidates = [
$_composer_autoload_path ?? null,
getcwd().'/vendor/autoload.php',
__DIR__.'/../../../autoload.php',
__DIR__.'/../vendor/autoload.php',
];

foreach ($candidates as $autoload) {
if ($autoload !== null && is_file($autoload)) {
require $autoload;
break;
}
}

use Illuminate\Support\Str;
use StdOut\SimpleDataObjects\Support\DataClassGenerator;
use StdOut\SimpleDataObjects\Support\SampleReader;

$options = ['class' => 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 <sample.json|sample.xml|-> <output-dir> [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);
2 changes: 1 addition & 1 deletion composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -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/"
Expand Down
1 change: 1 addition & 0 deletions docs/.vitepress/config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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' },
],
},
{
Expand Down
Loading
Loading