Skip to content

Commit e329248

Browse files
committed
Add lazyXml(): stream large XML files into DTOs one element at a time
BaseData::lazyXml($uri, $path) returns a LazyCollection that reads the elements at a slash-separated path with XMLReader and hydrates each one through the class's compiled hydrator, so only the current element is ever in memory. One element maps to one DTO: a property reads the child element with its own name, and a child with attributes or children of its own is a nested DTO. Three new attributes point a property elsewhere: - #[XmlAttribute] an XML attribute (optionally under another name) - #[XmlElement] a differently named child element - #[XmlText] the element's own text They only affect lazyXml(); array hydration and serialization of the same class are unchanged. int/float/bool properties and int-backed enums are converted from the declared type, repeated elements fill array and #[DataCollection] properties, and #[Flatten] and #[Discriminator] work as usual. Elements the DTO does not declare are skipped without being materialized. Unreadable, malformed or truncated XML throws DataHydrationException and never yields a partially read element. ParameterMeta gains xmlSource, defaulted in __set_state() so existing .meta.php caches keep loading. ext-xmlreader is suggested, not required. Docs: new Streaming XML page, README and collections pointers, and XML results on the performance page.
1 parent 80f57b8 commit e329248

28 files changed

Lines changed: 1106 additions & 2 deletions

‎CHANGELOG.md‎

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,27 @@ All notable changes to `std-out/simple-data-objects` are documented here.
55
The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
66
and the project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
77

8+
## [2.1.0] — Unreleased
9+
10+
### Added
11+
- **`lazyXml()`** — stream a large XML file into DTOs one element at a time:
12+
`OfferData::lazyXml($file, 'catalog/shop/offers/offer')` returns a
13+
`LazyCollection` that never holds more than the current element in memory.
14+
- One element, one DTO: a property reads the child element with its own
15+
name, and a child with structure of its own is a nested DTO.
16+
- **`#[XmlAttribute]`**, **`#[XmlElement('name')]`** and **`#[XmlText]`**
17+
point a property at an XML attribute, a differently named child element,
18+
or the element's own text. They only affect `lazyXml()` — array
19+
hydration and serialization of the same class are unchanged.
20+
- `int`/`float`/`bool` properties and int-backed enums are converted from
21+
the declared type; nested `BaseData`, `#[DataCollection]`, `#[Flatten]`,
22+
`#[Discriminator]` and `array` lists of repeated elements are supported.
23+
- Elements the DTO doesn't declare are skipped without being materialized.
24+
- Malformed or unreadable XML throws `DataHydrationException`.
25+
- Requires `ext-xmlreader` (bundled with PHP by default); nothing changes
26+
for code that doesn't call it.
27+
- **Documentation:** see [Streaming XML](https://std-out.github.io/simple-data-objects/features/xml).
28+
829
## [2.0.0] — 2026-08-09
930

1031
### Added

‎README.md‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -154,6 +154,14 @@ foreach (UserData::lazyCollection($csvRows) as $user) {
154154
}
155155
```
156156

157+
`lazyXml()` does the same straight from a large XML file — the DTO describes the element, and only the current one is ever in memory:
158+
159+
```php
160+
OfferData::lazyXml('feed.xml', 'catalog/shop/offers/offer')
161+
->filter(fn (OfferData $offer) => $offer->available)
162+
->each(fn (OfferData $offer) => $importer->process($offer));
163+
```
164+
157165
### Immutable copies with `with()`
158166

159167
```php

‎composer.json‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,8 @@
2727
},
2828
"suggest": {
2929
"ext-sodium": "Required to use EncryptedCast (bundled with PHP by default)",
30+
"ext-libxml": "Required to use BaseData::lazyXml() (bundled with PHP by default)",
31+
"ext-xmlreader": "Required to use BaseData::lazyXml() (bundled with PHP by default)",
3032
"illuminate/http": "Required to use HasLaravelIntegration::fromRequest() and toResponse()",
3133
"illuminate/database": "Required to use HasLaravelIntegration::fromModel(), IsEloquentCastable, AsDataCollection, or make:data --from-model",
3234
"illuminate/pagination": "Required to use BaseData::paginatedCollection() / PaginatedDataCollection",

‎docs/.vitepress/config.mjs‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,7 @@ export default defineConfig({
9191
{ text: 'Immutable Copies — with()', link: '/features/with' },
9292
{ text: 'Comparison — equals() & diff()', link: '/features/comparison' },
9393
{ text: 'Collections', link: '/features/collections' },
94+
{ text: 'Streaming XML', link: '/features/xml' },
9495
{ text: 'Metadata Cache', link: '/features/cache' },
9596
],
9697
},
@@ -123,6 +124,7 @@ export default defineConfig({
123124
{ text: '#[WhenLoaded]', link: '/attributes/when-loaded' },
124125
{ text: '#[RejectUnknownKeys]', link: '/attributes/reject-unknown-keys' },
125126
{ text: '#[WrapIn]', link: '/attributes/wrap-in' },
127+
{ text: '#[XmlAttribute] / #[XmlElement] / #[XmlText]', link: '/attributes/xml' },
126128
],
127129
},
128130
{

‎docs/attributes/index.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,3 +20,4 @@ All behaviour in Simple Data Objects is declared via PHP attributes on construct
2020
| [`#[WhenLoaded]`](./when-loaded.md) | Parameter or property | Include an Eloquent relation in `fromModel()` only when it's loaded |
2121
| [`#[RejectUnknownKeys]`](./reject-unknown-keys.md) | Class | Strict mode — throw when input contains a key the class doesn't recognize |
2222
| [`#[WrapIn]`](./wrap-in.md) | Class | Wrap `toResponse()`'s payload under a key (`toArray()`/`toJson()` unaffected) |
23+
| [`#[XmlAttribute]` / `#[XmlElement]` / `#[XmlText]`](./xml.md) | Parameter or property | Point a property at an XML attribute, a differently named child element, or the element's text for `lazyXml()` |

‎docs/features/collections.md‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,8 @@ $names = UserData::lazyCollection($csvRowGenerator)
4949

5050
Like `collection()`, already-hydrated instances pass through unchanged.
5151

52+
For large XML files, [`lazyXml()`](./xml.md) streams and hydrates one element at a time straight from the file.
53+
5254
## Nested Collections in DTOs
5355

5456
Use `#[DataCollection(ClassName::class)]` to declare a property as a typed collection:

‎docs/features/xml.md‎

Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
1+
# Streaming XML
2+
3+
`lazyXml()` reads a large XML file **one element at a time** and hydrates each into a DTO as the collection is consumed. The document is never loaded into memory as a whole — peak memory stays flat whether the file holds a hundred nodes or ten million.
4+
5+
```php
6+
OfferData::lazyXml($file, 'catalog/shop/offers/offer')
7+
->filter(fn (OfferData $o) => $o->available)
8+
->chunk(500)
9+
->each(fn ($chunk) => Offer::upsert($chunk->map->toArray()->all(), ['id']));
10+
```
11+
12+
The first argument is anything `XMLReader` can open — a path or a stream wrapper such as `compress.zlib://feed.xml.gz`. The second is the slash-separated path of element names, from the root, to the repeated element.
13+
14+
The result is a regular `Illuminate\Support\LazyCollection` of fully typed `OfferData`, so it can be iterated with `foreach`, and `take()`, `filter()`, `chunk()`, `each()` and friends stop reading the file as soon as they have enough.
15+
16+
Requires `ext-xmlreader` (bundled with PHP by default).
17+
18+
## Describing the structure
19+
20+
**One element, one DTO.** A DTO describes exactly one element — its attributes, its text and its direct children. A child that has structure of its own (attributes or children) is a nested DTO, the same way nested data works everywhere else in this package. A property never reaches into a grandchild.
21+
22+
A property reads the child element with its own name. Three attributes say otherwise:
23+
24+
| Attribute | The property reads |
25+
|---|---|
26+
| *(none)* | the child element named like the property — its text for a scalar, the whole element for a nested DTO |
27+
| `#[XmlAttribute]` | the XML attribute named like the property; `#[XmlAttribute('uom')]` for a different name |
28+
| `#[XmlElement('picture')]` | the child element(s) with that name, when it differs from the property name |
29+
| `#[XmlText]` | the element's own text — for an element that also carries attributes |
30+
31+
They only affect `lazyXml()`: the same class still hydrates from a plain array under its usual keys, and serializes as usual. See the [attribute reference](../attributes/xml.md).
32+
33+
```xml
34+
<offer id="42" available="true">
35+
<name>Kettle</name>
36+
<price currency="UAH">499.90</price>
37+
<picture>https://example.com/a.jpg</picture>
38+
<picture>https://example.com/b.jpg</picture>
39+
<param name="Color">Red</param>
40+
<param name="Volume">1.7 L</param>
41+
<vendor code="BSH"><name>Bosch</name></vendor>
42+
<delivery>
43+
<option cost="80" days="2"/>
44+
<option cost="0" days="7"/>
45+
</delivery>
46+
</offer>
47+
```
48+
49+
```php
50+
class OfferData extends BaseData
51+
{
52+
public function __construct(
53+
#[XmlAttribute]
54+
public readonly int $id,
55+
#[XmlAttribute]
56+
public readonly bool $available,
57+
public readonly string $name,
58+
public readonly PriceData $price,
59+
#[XmlElement('picture')]
60+
public readonly array $pictures,
61+
#[DataCollection(ParamData::class)]
62+
#[XmlElement('param')]
63+
public readonly TypedDataCollection $params,
64+
public readonly ?VendorData $vendor = null,
65+
public readonly ?DeliveryData $delivery = null,
66+
) {}
67+
}
68+
69+
class PriceData extends BaseData
70+
{
71+
public function __construct(
72+
#[XmlText]
73+
public readonly float $amount,
74+
#[XmlAttribute]
75+
public readonly string $currency,
76+
) {}
77+
}
78+
79+
class DeliveryData extends BaseData
80+
{
81+
public function __construct(
82+
#[DataCollection(DeliveryOptionData::class)]
83+
#[XmlElement('option')]
84+
public readonly TypedDataCollection $options,
85+
) {}
86+
}
87+
88+
class DeliveryOptionData extends BaseData
89+
{
90+
public function __construct(
91+
#[XmlAttribute]
92+
public readonly float $cost,
93+
#[XmlAttribute]
94+
public readonly int $days,
95+
) {}
96+
}
97+
98+
class ParamData extends BaseData
99+
{
100+
public function __construct(
101+
#[XmlAttribute]
102+
public readonly string $name,
103+
#[XmlText]
104+
public readonly string $value,
105+
) {}
106+
}
107+
108+
class VendorData extends BaseData
109+
{
110+
public function __construct(
111+
#[XmlAttribute]
112+
public readonly string $code,
113+
public readonly string $name,
114+
) {}
115+
}
116+
```
117+
118+
## Mapping rules
119+
120+
- **Scalars.** XML has only strings, so `int`, `float` and `bool` properties (and int-backed enums) are converted from the declared type — no `#[Cast]` needed. `bool` is true for `true` and `1`. A property with a `#[Cast]` receives the raw string.
121+
- **Empty elements.** `<stock/>` or an empty attribute resolves to `null` when the property is nullable.
122+
- **Repeated elements.** An `array` property collects the text of every matching element; a `#[DataCollection]` property hydrates every matching element. When there are none, a required list is empty rather than a missing-field error.
123+
- **Nested DTOs** read a child element with their own mapping, recursively. A wrapper element such as `<delivery>` above is a DTO of its own holding the collection. `#[Flatten]` reads the nested DTO's fields from the parent element.
124+
- **`#[Discriminator]`** works as usual. The field is read from a child element; to read it from an XML attribute, declare it as an `#[XmlAttribute]` property on the target classes.
125+
- **Undeclared elements are skipped** without being read into memory, so a DTO that needs three fields of a fifty-field element only pays for three. This also means `#[RejectUnknownKeys]` and class-level pipes only ever see declared fields.
126+
- **Namespaced elements** are matched by their qualified name as written in the file: `#[XmlElement('g:price')]`.
127+
- **`#[MapInputName]` and `#[TransformKeys]`** still apply to element names, so a class already mapped for array input needs nothing extra.
128+
129+
## Errors
130+
131+
- A file that cannot be opened, or XML that is malformed or truncated, throws `DataHydrationException`. Elements read before the broken spot are still yielded; a partially read element never is.
132+
- An element that doesn't satisfy the DTO (a missing required field, an invalid enum value) throws when iteration reaches it, like `lazyCollection()`.

‎docs/guide/performance.md‎

Lines changed: 53 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -121,7 +121,7 @@ breadcrumb: false
121121
<span class="bm-note">lower is better</span>
122122
</div>
123123
<p class="bm-note bm-note--block">Rows from a generator, consumed one by one.</p>
124-
<div class="bm-chart bm-chart--last">
124+
<div class="bm-chart">
125125
<div class="bm-row bm-row--tall">
126126
<div class="bm-row-head">
127127
<span class="bm-row-label">lazyCollection()</span>
@@ -139,9 +139,48 @@ breadcrumb: false
139139
</div>
140140
</div>
141141
</div>
142+
<div class="bm-section-head bm-rise">
143+
<h2 class="bm-h2">Streaming XML — 100,000 elements, 52 MB file</h2>
144+
<span class="bm-note">each scenario in its own process</span>
145+
</div>
146+
<p class="bm-note bm-note--block">The alternative has no XML support, so it is measured with what a consumer writes by hand: an XMLReader loop mapping each element to an array (throughput), or loading the document with SimpleXML (memory). The hand-written loop stays as flat on memory as lazyXml() — at a fifth of the speed.</p>
147+
<div class="bm-chart bm-chart--last">
148+
<div class="bm-row">
149+
<div class="bm-row-head">
150+
<span class="bm-row-label">Throughput — lazyXml() vs hand-written XMLReader loop</span>
151+
<span class="bm-row-x">5× faster</span>
152+
</div>
153+
<div class="bm-line">
154+
<span class="bm-series bm-series--us">Simple Data Objects</span>
155+
<span class="bm-track"><span class="bm-fill bm-fill--us" style="width:100%"></span></span>
156+
<span class="bm-value bm-value--us">69K nodes/s</span>
157+
</div>
158+
<div class="bm-line">
159+
<span class="bm-series">Popular alternative</span>
160+
<span class="bm-track"><span class="bm-fill bm-fill--them" style="width:18.5%"></span></span>
161+
<span class="bm-value">13K nodes/s</span>
162+
</div>
163+
</div>
164+
<div class="bm-row bm-row--tall">
165+
<div class="bm-row-head">
166+
<span class="bm-row-label">Peak process memory — lazyXml() vs SimpleXML + collection</span>
167+
<span class="bm-row-x">57× less memory</span>
168+
</div>
169+
<div class="bm-line">
170+
<span class="bm-series bm-series--us">Simple Data Objects</span>
171+
<span class="bm-track"><span class="bm-fill bm-fill--us" style="width:1.8%"></span></span>
172+
<span class="bm-value bm-value--us">19 MB</span>
173+
</div>
174+
<div class="bm-line">
175+
<span class="bm-series">Popular alternative</span>
176+
<span class="bm-track"><span class="bm-fill bm-fill--them" style="width:100%"></span></span>
177+
<span class="bm-value">1,096 MB</span>
178+
</div>
179+
</div>
180+
</div>
142181
<div class="bm-prose">
143182

144-
CPU time per operation follows the same ratios — less CPU burned per request means more headroom per server. The `from()`/`toArray()` hot paths execute [compiled per-class closures](../features/cache.md), and [`lazyCollection()`](../features/collections.md#lazy-collections) keeps peak memory flat on any dataset size.
183+
CPU time per operation follows the same ratios — less CPU burned per request means more headroom per server. The `from()`/`toArray()` hot paths execute [compiled per-class closures](../features/cache.md), and [`lazyCollection()`](../features/collections.md#lazy-collections) keeps peak memory flat on any dataset size. [`lazyXml()`](../features/xml.md) does the same straight from a file: only the fields the DTO declares are read, so the PHP heap stays at about 2.5 MB whether the document holds a hundred elements or a hundred thousand.
145184

146185
</div>
147186
<h2 class="bm-h2 bm-h2--table">The numbers</h2>
@@ -191,6 +230,18 @@ CPU time per operation follows the same ratios — less CPU burned per request m
191230
<td class="bm-td bm-td--num bm-td--muted">~13 MB</td>
192231
<td class="bm-td bm-td--num bm-td--adv">~50×</td>
193232
</tr>
233+
<tr>
234+
<td class="bm-td">XML — 100,000 elements, streamed</td>
235+
<td class="bm-td bm-td--num">~69,000 nodes/s</td>
236+
<td class="bm-td bm-td--num bm-td--muted">~13,000 nodes/s</td>
237+
<td class="bm-td bm-td--num bm-td--adv">~5×</td>
238+
</tr>
239+
<tr>
240+
<td class="bm-td">XML — peak process memory vs SimpleXML</td>
241+
<td class="bm-td bm-td--num">19 MB</td>
242+
<td class="bm-td bm-td--num bm-td--muted">~1,096 MB</td>
243+
<td class="bm-td bm-td--num bm-td--adv">~57×</td>
244+
</tr>
194245
</tbody>
195246
</table>
196247
</div>

‎src/Attributes/XmlAttribute.php‎

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace StdOut\SimpleDataObjects\Attributes;
6+
7+
use Attribute;
8+
9+
#[Attribute(Attribute::TARGET_PARAMETER | Attribute::TARGET_PROPERTY)]
10+
final class XmlAttribute
11+
{
12+
public function __construct(public readonly ?string $name = null) {}
13+
}

‎src/Attributes/XmlElement.php‎

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace StdOut\SimpleDataObjects\Attributes;
6+
7+
use Attribute;
8+
9+
#[Attribute(Attribute::TARGET_PARAMETER | Attribute::TARGET_PROPERTY)]
10+
final class XmlElement
11+
{
12+
public function __construct(public readonly string $name) {}
13+
}

0 commit comments

Comments
 (0)