Repository navigation
perf(parser): defer segment field parsing until first access #40
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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) { | ||
|
shadowhand marked this conversation as resolved.
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Robin — 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); | ||
| } | ||
|
|
||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -8,6 +8,7 @@ | |
| use OutOfBoundsException; | ||
| use Override; | ||
| use ReflectionObject; | ||
| use Throwable; | ||
|
|
||
| use function array_map; | ||
| use function array_shift; | ||
|
|
@@ -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; | ||
|
|
@@ -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; | ||
|
|
@@ -51,6 +65,7 @@ public function getName(): string | |
| public function getField(int $number): array | ||
| { | ||
| $this->assertPositiveNumber($number); | ||
| $this->hydrate(); | ||
|
|
||
| return $this->fields[$number - 1] ?? []; | ||
| } | ||
|
|
@@ -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; | ||
|
|
@@ -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()); | ||
| } | ||
|
|
||
| /** | ||
|
|
@@ -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] ?? []); | ||
| } | ||
|
|
@@ -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); | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Robin — 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); | ||
|
|
||
|
|
@@ -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++) { | ||
|
|
@@ -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; | ||
| }); | ||
| } | ||
| } | ||
| 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(); | ||
| } | ||
| } |
| 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)(); | ||
| } | ||
| } |
Uh oh!
There was an error while loading. Please reload this page.