Skip to content
Closed
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
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Changed

- Segment fields are parsed on first read or write. Until a field is written, `serialize()` emits
the original segment line, so a read-only round trip is byte-exact aside from the absent
trailing line ending. A write re-encodes only that segment and still trims its trailing empty
fields. An unread message keeps the raw lines instead of a field graph.
- Parsing no longer builds throwaway structures in the parse walk: a definition's leading segment
names are resolved once per group instead of once per candidate probe. An 111-segment `ADT_A01`
with 100 `NK1` parses in 8.8 ms instead of 11.5 ms under PHP 8.5 with opcache on the CLI; what is
Expand Down
11 changes: 7 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -120,10 +120,13 @@ Any `Message` can be turned back into a wire string with `serialize()`:
$wire = $message->serialize($encoding);
```

`parse()` followed by `serialize()` reproduces the original message, with two deviations from a
byte-for-byte round-trip: trailing empty fields, components, and subcomponents are trimmed (HL7
treats trailing delimiters as optional), and segments are joined by the line ending rather than
terminated by it (no trailing line ending is appended).
`parse()` followed by `serialize()` reproduces the original message byte for byte when no field
has been written. Reading fields does not change that. Segments are joined by the line ending
rather than terminated by it (no trailing line ending is appended).

Writing a field invalidates that segment. It is then serialized from the object model, which
trims trailing empty fields, components, and subcomponents (HL7 treats trailing delimiters as
optional). Segments that were not written stay byte-exact.

### Debugging message structure

Expand Down
16 changes: 14 additions & 2 deletions src/AbstractComposite.php
Original file line number Diff line number Diff line change
Expand Up @@ -12,11 +12,13 @@
use function count;
use function explode;

abstract class AbstractComposite implements Composite
// @mago-expect lint:too-many-methods
abstract class AbstractComposite implements Composite, TracksMutation
{
use CanAssertNumbers;
use CanHoldField;
use CanJoinElements;
use CanTrackMutation;

/** @var list<Type> */
private array $components = [];
Expand Down Expand Up @@ -45,6 +47,13 @@ public function add(TypeDefinition $definition): void
$this->definitions[] = $definition;
}

#[Override]
public function track(DirtyFlag $dirty): void
{
$this->dirty = $dirty;
$this->extra->track($dirty);
}

#[Override]
public function getName(): string
{
Expand Down Expand Up @@ -81,7 +90,7 @@ private function createComponent(int $number): Type
"Component {$this->getName()}.{$number} is not defined",
);

$component = $definition->newInstance();
$component = $definition->newInstance($this->dirty);

// A composite occupying a component slot is nested one level down, so it parses its parts
// as subcomponents. Field-level composites are created by the segment, never here, so
Expand All @@ -102,13 +111,16 @@ public function getExtraComponents(): ExtraComponents
#[Override]
public function clear(): void
{
$this->markDirty();
$this->components = [];
$this->extra->clear();
}

#[Override]
public function parse(Encoding $encoding, string $data): void
{
// Replacing the component list is a write, including a parse that immediately clears.
$this->markDirty();
$this->clear();

if ($data === '') {
Expand Down
6 changes: 3 additions & 3 deletions src/AbstractGroup.php
Original file line number Diff line number Diff line change
Expand Up @@ -243,16 +243,16 @@ private function recoverSegment(Encoding $encoding, SegmentElement $element): vo
$structure = $this->getDefinition($name)->newInstance();

// A group lead out of order is unsafe to re-consume, so only segments recover typed.
if ($structure instanceof Segment) {
$structure->parse($encoding, $element->raw);
if ($structure instanceof AbstractSegment) {
Comment thread
shadowhand marked this conversation as resolved.
Comment thread
shadowhand marked this conversation as resolved.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Robin — ⚠️ MEDIUM · confidence: high

Out-of-order recovery previously retained any structure implementing Segment as its declared type. Restricting this to AbstractSegment sends custom Segment implementations through the GenericSegment fallback, losing their typed behavior and schema when a declared segment appears out of order.

Recommendation: Preserve the Segment interface behavior during recovery; use the segment's parse method for implementations that cannot bind a raw line, or provide a compatible lazy-binding contract.

$structure->bind($encoding, $element->raw);
$this->append($name, $structure);

return;
}
}

$segment = new GenericSegment($element->name);
$segment->parse($encoding, $element->raw);
$segment->bind($encoding, $element->raw);
$this->append($element->name, $segment);
}

Expand Down
12 changes: 11 additions & 1 deletion src/AbstractPrimitive.php
Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,11 @@
use function explode;
use function str_contains;

abstract class AbstractPrimitive implements Primitive
abstract class AbstractPrimitive implements Primitive, TracksMutation
{
use CanJoinElements;
use CanHoldField;
use CanTrackMutation;

private string $value = '';

Expand Down Expand Up @@ -44,15 +45,24 @@ public function getValue(): string
return $this->value;
}

#[Override]
public function track(DirtyFlag $dirty): void
{
$this->dirty = $dirty;
$this->extra->track($dirty);
}

#[Override]
public function setValue(string $value): void
{
$this->markDirty();
$this->value = $value;
}

#[Override]
public function clear(): void
{
$this->markDirty();
$this->value = '';
$this->extra->clear();
}
Expand Down
103 changes: 101 additions & 2 deletions src/AbstractSegment.php
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
use OutOfBoundsException;
use Override;
use ReflectionObject;
use Throwable;

use function array_map;
use function array_shift;
Expand All @@ -16,6 +17,7 @@
use function max;

// @mago-expect lint:too-many-methods
// @mago-expect lint:cyclomatic-complexity
abstract class AbstractSegment implements Segment
{
use CanAssertNumbers;
Expand All @@ -27,6 +29,18 @@ abstract class AbstractSegment implements Segment
/** @var list<TypeDefinition> */
private array $definitions = [];

/**
* Wire line captured at bind time. Null once the segment is written, or when it was built
* by hand and never bound. Serialization emits this verbatim while it is set.
*/
private ?string $raw = null;

private ?Encoding $boundEncoding = null;

private bool $hydrated = false;

private ?DirtyFlag $dirty = null;

public function add(TypeDefinition $definition): void
{
$this->definitions[] = $definition;
Expand All @@ -51,6 +65,7 @@ public function getName(): string
public function getField(int $number): array
{
$this->assertPositiveNumber($number);
$this->hydrate();

return $this->fields[$number - 1] ?? [];
}
Expand All @@ -60,6 +75,9 @@ public function getFieldRepetition(int $number, int $repetition): Type
{
$this->assertPositiveNumber($number);
$this->assertNaturalNumber($repetition);
// Hydrate before the create-if-missing and bounds checks so a bound segment reports the
// wire's repetitions, and so those checks still throw the same messages.
$this->hydrate();

// Field number is 1-based, but array indices are 0-based.
$idx = $number - 1;
Expand All @@ -75,7 +93,7 @@ public function getFieldRepetition(int $number, int $repetition): Type

$this->assertRepetitionAllowed($number, $repetition);

return $this->fields[$idx][$repetition] = $this->getDefinition($number)->newInstance();
return $this->fields[$idx][$repetition] = $this->getDefinition($number)->newInstance($this->dirtyFlag());
}

/**
Expand Down Expand Up @@ -104,6 +122,7 @@ private function assertRepetitionAllowed(int $number, int $repetition): void
public function getLength(int $number): int
{
$this->assertPositiveNumber($number);
$this->hydrate();

return count($this->fields[$number - 1] ?? []);
}
Expand Down Expand Up @@ -149,13 +168,32 @@ public function parseSegments(Encoding $encoding, array $segments, array $additi
"Cannot parse {$this->getName()} at #{$offset}, the segment stream is exhausted",
);

$this->parse($encoding, $element->raw);
$this->bind($encoding, $element->raw);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Robin — ⚠️ MEDIUM · confidence: high

parseSegments previously called the virtual parse() method. Binding instead means that lazy hydration calls populate(), so existing AbstractSegment subclasses that override the public parse() method are silently bypassed when parsed through a message or group.

Recommendation: Provide a migration-compatible hydration hook for custom parsing behavior, or otherwise ensure subclasses overriding parse() continue to participate in the parseSegments path.


return $offset + 1;
}

/**
* Keep the wire line and parse its fields on first read or write.
*/
public function bind(Encoding $encoding, string $data): void
{
$this->raw = $data;
$this->boundEncoding = $encoding;
$this->hydrated = false;
}

#[Override]
public function parse(Encoding $encoding, string $data): void
{
// An explicit parse owns the fields, so a previously bound line must not win serialize().
$this->raw = null;
$this->boundEncoding = null;
$this->hydrated = true;
$this->populate($encoding, $data);
}

protected function populate(Encoding $encoding, string $data): void
{
$fields = explode($encoding->fieldSeparator, $data);

Expand All @@ -178,13 +216,39 @@ public function serializeLines(Encoding $encoding): array
#[Override]
public function serialize(Encoding $encoding): string
{
$raw = $this->unchangedRaw($encoding);

if ($raw !== null) {
return $raw;
}

// Treating the name and the serialized fields as trimmable parts means an empty fields
// string (no non-empty fields) is dropped instead of leaving a dangling field separator.
return $this->joinTrimmed([$this->getName(), $this->serializeFields($encoding, 1)], $encoding->fieldSeparator);
}

/**
* The bound line, when it is still a valid encoding of this segment.
*
* A write drops the line. A different delimiter set cannot reuse it either; the line ending
* is not part of the line and does not invalidate it.
*/
protected function unchangedRaw(Encoding $encoding): ?string
{
$raw = $this->raw;
$bound = $this->boundEncoding;

if ($raw === null || $bound === null || !$bound->sameDelimiters($encoding)) {
return null;
}

return $raw;
}

protected function serializeFields(Encoding $encoding, int $from): string
{
$this->hydrate();

$fields = [];

for ($number = $from; $number <= $this->getFieldCount(); $number++) {
Expand All @@ -200,4 +264,39 @@ protected function serializeFields(Encoding $encoding, int $from): string

return $this->joinTrimmed($fields, $encoding->fieldSeparator);
}

private function hydrate(): void
{
$raw = $this->raw;
$encoding = $this->boundEncoding;

if ($this->hydrated || $raw === null || $encoding === null) {
return;
}

// Drop the line before parsing so field access inside populate cannot re-enter, and so
// the writes parse itself performs do not stick. Restoring afterwards is what makes a
// read keep the byte-exact line. A failed parse restores it too and stays unhydrated,
// so a caught error does not replace the wire line with a half-built segment.
$this->raw = null;

try {
$this->populate($encoding, $raw);
} catch (Throwable $e) {
$this->raw = $raw;
$this->fields = [];

throw $e;
}

$this->raw = $raw;
$this->hydrated = true;
}

private function dirtyFlag(): DirtyFlag
{
return $this->dirty ??= new DirtyFlag(function (): void {
$this->raw = null;
});
}
}
15 changes: 15 additions & 0 deletions src/CanTrackMutation.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
<?php

declare(strict_types=1);

namespace RoundingWell\HL7;

trait CanTrackMutation
{
private ?DirtyFlag $dirty = null;

private function markDirty(): void
{
$this->dirty?->mark();
}
}
28 changes: 28 additions & 0 deletions src/DirtyFlag.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
<?php

declare(strict_types=1);

namespace RoundingWell\HL7;

use Closure;

/**
* Write signal shared by one segment and every value parsed out of it.
*
* Marking runs the segment callback, which drops the bound raw line. Parsing marks as it fills
* fields; the segment restores the line afterwards, so only a later mark — a real write — sticks.
*/
final readonly class DirtyFlag
{
/**
* @param Closure(): void $onDirty
*/
public function __construct(
private Closure $onDirty,
) {}

public function mark(): void
{
($this->onDirty)();
}
}
Loading
Loading