Skip to content

Commit 2284160

Browse files
authored
Merge pull request #51 from Ardenexal/feat/50-property-metadata-caching
Feat/50 property metadata caching
2 parents be97c55 + 5c41474 commit 2284160

1,417 files changed

Lines changed: 17295 additions & 173401 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.github/workflows/pr.yml

Lines changed: 92 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ jobs:
3333
- name: Install dependencies
3434
run: composer install --prefer-dist --no-progress --no-suggest
3535
- name: Security audit
36-
run: composer audit
36+
run: composer audit --abandoned=report
3737
style:
3838
runs-on: ubuntu-latest
3939
env:
@@ -67,9 +67,6 @@ jobs:
6767
- name: Validate composer files
6868
run: composer validate --strict
6969

70-
- name: Security audit
71-
run: composer audit
72-
7370
unit-tests:
7471
runs-on: ubuntu-latest
7572
env:
@@ -144,8 +141,6 @@ jobs:
144141
- name: Run FHIRPath specification tests
145142
run: composer run test-fhirpath-spec -- --log-junit=./results/fhir-path-tests.xml --coverage-clover ./results/fhir-path-coverage.xml
146143

147-
- name: Security audit
148-
run: composer audit
149144
- name: Upload test results to Codecov
150145
if: ${{ !cancelled() }}
151146
uses: codecov/codecov-action@v5
@@ -313,3 +308,94 @@ jobs:
313308
repo: context.repo.repo,
314309
body: commentBody
315310
});
311+
312+
benchmarks:
313+
runs-on: ubuntu-latest
314+
needs: [ unit-tests, integration-tests ]
315+
permissions:
316+
pull-requests: write
317+
contents: read
318+
319+
steps:
320+
- uses: actions/checkout@v4
321+
322+
- name: Setup PHP
323+
uses: shivammathur/setup-php@v2
324+
with:
325+
php-version: '8.3'
326+
extensions: ctype, iconv, zip, bcmath
327+
328+
- name: Cache Composer packages
329+
uses: actions/cache@v4
330+
with:
331+
path: vendor
332+
key: ${{ runner.os }}-php-8.3-${{ hashFiles('**/composer.lock') }}
333+
restore-keys: |
334+
${{ runner.os }}-php-8.3-
335+
336+
- name: Install dependencies
337+
run: composer install --prefer-dist --no-progress --no-suggest
338+
339+
- name: Run benchmarks
340+
id: bench
341+
run: |
342+
mkdir -p results
343+
php ./vendor/bin/phpbench run bench/ --report=aggregate --no-interaction > results/benchmarks.txt 2>&1 || true
344+
345+
- name: Assert no regression against baseline
346+
id: regression
347+
if: hashFiles('.phpbench/*.xml') != ''
348+
run: |
349+
php ./vendor/bin/phpbench run bench/ \
350+
--report=compare \
351+
--ref=baseline \
352+
--assert="mode(variant.time.avg) < mode(baseline.time.avg) * 1.50" \
353+
--no-interaction \
354+
> results/regression.txt 2>&1
355+
echo "exit_code=$?" >> $GITHUB_OUTPUT
356+
continue-on-error: true
357+
358+
- name: Comment PR with benchmark results
359+
if: ${{ !cancelled() }}
360+
uses: actions/github-script@v7
361+
with:
362+
script: |
363+
const fs = require('fs');
364+
365+
let benchOutput = '';
366+
try {
367+
benchOutput = fs.readFileSync('results/benchmarks.txt', 'utf8');
368+
} catch (e) {
369+
benchOutput = 'Could not read benchmark output.';
370+
}
371+
372+
const baselineExists = '${{ hashFiles('.phpbench/*.xml') }}' !== '';
373+
let regressionSection = '';
374+
375+
if (baselineExists) {
376+
let regressionOutput = '';
377+
try {
378+
regressionOutput = fs.readFileSync('results/regression.txt', 'utf8');
379+
} catch (e) {
380+
regressionOutput = 'Could not read regression output.';
381+
}
382+
const passed = '${{ steps.regression.outputs.exit_code }}' === '0';
383+
const icon = passed ? '✅' : '❌';
384+
const label = passed ? 'Regression check passed' : 'Regression detected (>50% slower than baseline)';
385+
regressionSection = `\n### ${icon} ${label}\n\n<details>\n<summary>Comparison vs baseline</summary>\n\n\`\`\`\n${regressionOutput}\n\`\`\`\n</details>`;
386+
} else {
387+
regressionSection = '\n> No baseline stored yet. Run `composer bench:baseline` and commit `.phpbench/` to enable regression checks.';
388+
}
389+
390+
const commentBody = `## Benchmark Results\n\n<details>\n<summary>Serialization + FHIRPath benchmarks</summary>\n\n\`\`\`\n${benchOutput}\n\`\`\`\n</details>\n${regressionSection}\n\n*Run \`composer bench\` locally for full results.*`;
391+
392+
github.rest.issues.createComment({
393+
issue_number: context.issue.number,
394+
owner: context.repo.owner,
395+
repo: context.repo.repo,
396+
body: commentBody
397+
});
398+
399+
- name: Fail if regression detected
400+
if: steps.regression.outputs.exit_code != '0' && hashFiles('.phpbench/*.xml') != ''
401+
run: exit 1

.phpbench/.gitkeep

Whitespace-only changes.

.phpbench/7ea/3/1e/13525ea0a5ac1d17a64652fd207cfde5a99fc020.xml

Lines changed: 2 additions & 0 deletions
Large diffs are not rendered by default.

CHANGELOG.md

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
# Changelog
2+
3+
All notable changes to this project will be documented in this file.
4+
5+
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6+
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7+
8+
## [Unreleased]
9+
10+
## [0.3] - 2026-03-30
11+
12+
### Added
13+
- implement FHIR metadata cache warmer and configuration support
14+
15+
### Fixed
16+
- standardize formatting and indentation across multiple PHP files
17+
18+
[Unreleased]: https://github.com/Ardenexal/php-fhir-tools/compare/0.3...HEAD
19+
[0.3]: https://github.com/Ardenexal/php-fhir-tools/compare/0.2...0.3

KNOWN_ISSUES.md

Lines changed: 0 additions & 29 deletions
This file was deleted.
Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,143 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Ardenexal\FHIRTools\Benchmarks\FHIRPath;
6+
7+
use Ardenexal\FHIRTools\Component\FHIRPath\Service\FHIRPathService;
8+
use Ardenexal\FHIRTools\Component\Models\R4\DataType\HumanName;
9+
use Ardenexal\FHIRTools\Component\Models\R4\Resource\PatientResource;
10+
use PhpBench\Attributes as Bench;
11+
12+
/**
13+
* Benchmarks for FHIRPath expression evaluation.
14+
*
15+
* Each expression is tested in two modes:
16+
* - Cold cache: the parsed AST is not yet cached — measures lexer + parser + evaluator.
17+
* - Warm cache: the AST is pre-compiled and cached — measures evaluator only.
18+
*/
19+
class FHIRPathEvaluationBench
20+
{
21+
private const EXPR_SIMPLE = 'Patient.name.given';
22+
23+
private const EXPR_FILTER = "Patient.name.where(use = 'official').given";
24+
25+
private const EXPR_DEEP = 'Patient.contact.where(relationship.coding.code = \'N\').name.family';
26+
27+
private const EXPR_CHAIN = 'Patient.name.given.where($this.length() > 3).first()';
28+
29+
private FHIRPathService $service;
30+
31+
private PatientResource $patient;
32+
33+
public function setUp(): void
34+
{
35+
$this->service = new FHIRPathService();
36+
37+
// Build a Patient with contacts so deep-navigation expressions have data to traverse.
38+
$this->patient = new PatientResource(
39+
id: 'bench-fhirpath-001',
40+
name: [
41+
new HumanName(family: 'Smith', given: ['John', 'Michael']),
42+
new HumanName(family: 'Smith', given: ['Johnny']),
43+
],
44+
);
45+
}
46+
47+
/** Compile (prime) the simple expression into the service cache. */
48+
public function primeSimple(): void
49+
{
50+
$this->setUp();
51+
$this->service->compile(self::EXPR_SIMPLE);
52+
}
53+
54+
/** Compile (prime) the filter expression into the service cache. */
55+
public function primeFilter(): void
56+
{
57+
$this->setUp();
58+
$this->service->compile(self::EXPR_FILTER);
59+
}
60+
61+
/** Compile all expressions into the service cache. */
62+
public function primeAll(): void
63+
{
64+
$this->setUp();
65+
$this->service->compile(self::EXPR_SIMPLE);
66+
$this->service->compile(self::EXPR_FILTER);
67+
$this->service->compile(self::EXPR_DEEP);
68+
$this->service->compile(self::EXPR_CHAIN);
69+
}
70+
71+
/** Clear the expression cache before cold benchmarks. */
72+
public function clearCache(): void
73+
{
74+
$this->setUp();
75+
$this->service->clearCache();
76+
}
77+
78+
// -------------------------------------------------------------------------
79+
// Simple path: Patient.name.given
80+
// -------------------------------------------------------------------------
81+
82+
/**
83+
* Evaluate a simple property-navigation path with no cached AST.
84+
*/
85+
#[Bench\BeforeMethods(['clearCache'])]
86+
public function benchSimplePathColdCache(): void
87+
{
88+
$this->service->evaluate(self::EXPR_SIMPLE, $this->patient);
89+
}
90+
91+
/**
92+
* Evaluate a simple property-navigation path with a pre-compiled cached AST.
93+
*/
94+
#[Bench\BeforeMethods(['primeSimple'])]
95+
public function benchSimplePathWarmCache(): void
96+
{
97+
$this->service->evaluate(self::EXPR_SIMPLE, $this->patient);
98+
}
99+
100+
// -------------------------------------------------------------------------
101+
// Filter expression: Patient.name.where(use = 'official').given
102+
// -------------------------------------------------------------------------
103+
104+
/**
105+
* Evaluate a filter expression (where clause) with no cached AST.
106+
*/
107+
#[Bench\BeforeMethods(['clearCache'])]
108+
public function benchComplexFilterColdCache(): void
109+
{
110+
$this->service->evaluate(self::EXPR_FILTER, $this->patient);
111+
}
112+
113+
/**
114+
* Evaluate a filter expression (where clause) with a pre-compiled cached AST.
115+
*/
116+
#[Bench\BeforeMethods(['primeFilter'])]
117+
public function benchComplexFilterWarmCache(): void
118+
{
119+
$this->service->evaluate(self::EXPR_FILTER, $this->patient);
120+
}
121+
122+
// -------------------------------------------------------------------------
123+
// Deep navigation + function chain (always warm — focuses on evaluator cost)
124+
// -------------------------------------------------------------------------
125+
126+
/**
127+
* Evaluate a multi-hop navigation with a nested where() filter (warm cache).
128+
*/
129+
#[Bench\BeforeMethods(['primeAll'])]
130+
public function benchDeepNavigation(): void
131+
{
132+
$this->service->evaluate(self::EXPR_DEEP, $this->patient);
133+
}
134+
135+
/**
136+
* Evaluate a chained function expression (where + length + first) (warm cache).
137+
*/
138+
#[Bench\BeforeMethods(['primeAll'])]
139+
public function benchFunctionChain(): void
140+
{
141+
$this->service->evaluate(self::EXPR_CHAIN, $this->patient);
142+
}
143+
}
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Ardenexal\FHIRTools\Benchmarks\FHIRPath;
6+
7+
use Ardenexal\FHIRTools\Component\FHIRPath\Parser\FHIRPathLexer;
8+
use Ardenexal\FHIRTools\Component\FHIRPath\Parser\FHIRPathParser;
9+
use PhpBench\Attributes as Bench;
10+
11+
/**
12+
* Benchmarks for FHIRPath lexing and parsing (no evaluation).
13+
*
14+
* Isolates the parse phase so regressions in the lexer or parser are visible
15+
* independently of evaluator performance.
16+
*/
17+
#[Bench\BeforeMethods(['setUp'])]
18+
class FHIRPathParsingBench
19+
{
20+
private FHIRPathLexer $lexer;
21+
22+
private FHIRPathParser $parser;
23+
24+
public function setUp(): void
25+
{
26+
$this->lexer = new FHIRPathLexer();
27+
$this->parser = new FHIRPathParser();
28+
}
29+
30+
/**
31+
* Tokenize and parse a simple three-segment property-navigation path.
32+
*/
33+
public function benchParseSimple(): void
34+
{
35+
$tokens = $this->lexer->tokenize('Patient.name.given');
36+
$this->parser->parse($tokens);
37+
}
38+
39+
/**
40+
* Tokenize and parse a complex expression with function calls, boolean operators,
41+
* and chained member access.
42+
*/
43+
public function benchParseComplex(): void
44+
{
45+
$tokens = $this->lexer->tokenize(
46+
"Patient.name.where(use = 'official' and family.exists()).given.first()",
47+
);
48+
$this->parser->parse($tokens);
49+
}
50+
51+
/**
52+
* Tokenize and parse a deeply nested expression mixing arithmetic, comparisons,
53+
* and multiple function calls — representative of real-world CQL-style paths.
54+
*/
55+
public function benchParseDeep(): void
56+
{
57+
$tokens = $this->lexer->tokenize(
58+
'Patient.name.where(use = \'official\').given.where($this.length() > 3 and $this.startsWith(\'Jo\')).first()',
59+
);
60+
$this->parser->parse($tokens);
61+
}
62+
}

0 commit comments

Comments
 (0)