diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..5b49ae5 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,146 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Project Overview + +CQL Studio is an Angular single-page application for developing, testing, and publishing Clinical Quality Language (CQL) and FHIR-based healthcare artifacts. It combines a CQL IDE, FHIR resource management, AI-assisted code generation, and test runner integration. + +## Commands + +```bash +npm run start # Dev server (localhost:4200) +npm run build # Production build +npm run watch # Build in watch mode +npm test # Unit tests via Vitest (services only) +npm run test:e2e # E2E tests via Playwright (headless) +npm run test:e2e:ui # Playwright with interactive UI +``` + +To run a single unit test file: +```bash +npx vitest run src/app/services/path/to/file.spec.ts +``` + +Unit tests are scoped to `src/app/services/**/*.spec.ts` only (see `vitest.config.ts`). + +**Docker:** +```bash +docker build -t hlseven/quality-cql-studio:latest . +docker run -p 4200:80 hlseven/quality-cql-studio +``` + +Runtime environment variables: +- `CQL_STUDIO_RUNNER_BASE_URL` — CQL Tests Runner endpoint (default: `http://localhost:3000`) +- `CQL_STUDIO_FHIR_BASE_URL` — FHIR server endpoint (default: `http://localhost:8080/fhir`) + +## Architecture + +### Tech Stack +- **Angular 21** with TypeScript strict mode, signals-based state management +- **RxJS** for async/reactive operations +- **CodeMirror 6** for CQL/SQL editing with syntax highlighting +- **Bootstrap 5 + Bootswatch Litera** for UI +- **Vitest** (unit) + **Playwright** (E2E) for testing +- **FHIR R4** types via `@types/fhir`; CQL parsing via `@cqframework/cql` + +### Feature Modules (Routes) + +| Route | Component | Purpose | +|-------|-----------|---------| +| `/ide/*` | `cql-ide/` | Full CQL IDE with editors, panels, AI assistant | +| `/results` | `results-viewer/` | Display/analyze CQL test results | +| `/runner` | `runner/` | Configure and execute CQL tests | +| `/terminology` | `terminology/` | Value sets, concept maps, code systems | +| `/measures` | `measure-editor/` | FHIR Measure resource editing | +| `/vsac` | VSAC browser | NLM value set browser | +| `/settings` | `settings/` | Endpoints, themes, preferences | + +### IDE Component Architecture (`src/app/components/cql-ide/`) + +The IDE uses a draggable three-panel layout (left/right/bottom) managed by `ide-state.service`. Each panel contains tab-based editors: CQL editor (CodeMirror), ELM viewer, FHIR resource browser, AI chat, and more. The AI assistant tab integrates with Claude API or local Ollama models via `ai.service`. + +### Service Layer (`src/app/services/`) + +Services are organized by domain — ~60+ total: + +- **CQL**: `cql-execution`, `cql-validation`, `cql-parsing`, `cql-formatter`, `translation` (CQL↔ELM) +- **AI**: `ai.service` (orchestration), `ai-conversation-state`, `ai-planning`, `ai-tool-execution-manager`, `ai-stream-response-handler`, `tool-orchestrator`, `tool-policy` +- **FHIR**: `fhir-client`, `fhir-package-import`, `fhir-package-registry`, `fhir-search`, `terminology` +- **State**: `ide-state` (signals), `ide-tab-registry`, `settings`, `library`, `measure` +- **Utilities**: `toast`, `clipboard`, `file-loader`, `vsac`, `schema-validation` + +### State Management Pattern + +The app uses Angular signals (not NgRx). `ide-state.service` is the central store for IDE panel/editor state. Settings are persisted via `settings.service` to `localStorage`. Deep-linking uses query params defined in `src/app/models/query-params.model.ts`. + +### Data Flow + +``` +Component → Service → HTTP (FHIR server / CQL runner / AI API) + ↓ + Angular Signals / RxJS Observables + ↓ + localStorage / sessionStorage +``` + +Constants for storage keys are in `src/app/constants/session-storage.constants.ts`. + +--- + +## SQL-on-FHIR Feature (Active Development — branch: `feature/sql-on-fhir`) + +This fork adds SQL-on-FHIR support to CQL Studio. The goal is end-to-end evaluation of CQL-based quality measures using SQL instead of a CQL engine, targeting the June CMS Connectathon demo. + +### Tracking Issues + +| Issue | Owner | Status | Description | +| ----- | ----- | ------ | ----------- | +| [#15](https://github.com/cqframework/cql-studio/issues/15) | both | Epic | End-to-end SQL on FHIR support | +| [#16](https://github.com/cqframework/cql-studio/issues/16) | aks129 | **Done (lib)** | Standalone ELM→SQL library | +| [#18](https://github.com/cqframework/cql-studio/issues/18) | aks129 | Open | CMS demo examples (CMS125, CMS130) | +| [#19](https://github.com/cqframework/cql-studio/issues/19) | Preston | Blocked on #16 | CQL Studio Server DB connection | +| [#20](https://github.com/cqframework/cql-studio/issues/20) | Preston | Blocked on #21 | Server boot scripts for SQL views | +| [#21](https://github.com/cqframework/cql-studio/issues/21) | aks129 | Open | SQL-on-FHIR views for HAPI FHIR JPA | +| [#23](https://github.com/cqframework/cql-studio/issues/23) | Preston | Blocked on #16/#19 | UI integration | +| [#24](https://github.com/cqframework/cql-studio/issues/24) | aks129 | Open | Demo video | + +### Standalone Library: `packages/elm-to-sql/` + +Implements Issue #16. Pure ESM TypeScript, zero runtime Node.js dependencies, Apache 2.0. + +```bash +# From packages/elm-to-sql/ +npm install +npm run build # tsc compile +npm test # 24 Jest tests +``` + +**Pipeline:** ELM JSON (from `@cqframework/cql`) → `ElmToSqlTranspiler.transpile()` → SQL WITH CTEs → run via pluggable DB adapter → `generateMeasureReport()` → FHIR MeasureReport + +**Key source files:** + +- [packages/elm-to-sql/src/transpiler/elm-to-sql.ts](packages/elm-to-sql/src/transpiler/elm-to-sql.ts) — core transpiler +- [packages/elm-to-sql/src/types/elm.ts](packages/elm-to-sql/src/types/elm.ts) — HL7 ELM JSON types +- [packages/elm-to-sql/src/views/view-definitions.ts](packages/elm-to-sql/src/views/view-definitions.ts) — FHIR ViewDefinitions + SQL DDL +- [packages/elm-to-sql/src/measure/measure-report.ts](packages/elm-to-sql/src/measure/measure-report.ts) — MeasureReport generator +- [packages/elm-to-sql/FAQ.md](packages/elm-to-sql/FAQ.md) — current support details and known gaps + +### Demo Sequence (CMS125 — Breast Cancer Screening) + +End-to-end flow for the June CMS Connectathon demo: + +1. **Load CQL** — Open CMS125 library in CQL Studio IDE (`/authoring/cql`) +2. **Translate to ELM** — CQL Studio uses `@cqframework/cql` in-browser; ELM JSON appears in ELM tab +3. **Generate SQL** — (pending UI — Issue #23) Pass ELM JSON to `ElmToSqlTranspiler`; generated SQL appears in new SQL tab +4. **Run SQL** — CQL Studio Server executes SQL against PostgreSQL under HAPI FHIR (Issue #19/#20) +5. **View MeasureReport** — `generateMeasureReport()` builds FHIR resource; saved via FHIR API; viewable in Results Viewer +6. **Compare** — Side-by-side CQL engine result vs SQL result showing identical population counts + +### Environment Variables (SQL feature) + +- `CQL_STUDIO_DB_URL` — PostgreSQL connection string for HAPI FHIR's backing database (Issue #19) + +### Getting ELM JSON from CQL Studio + +The `TranslationService` (`src/app/services/translation.service.ts`) currently exposes `translateCqlToElm()` returning ELM XML via `translator.toXml()`. For the SQL tab integration (Issue #23), it needs to also call `translator.toJson()` to get the ELM JSON that `ElmToSqlTranspiler` consumes. Preston owns this wiring (#23). diff --git a/packages/elm-to-sql/.gitignore b/packages/elm-to-sql/.gitignore new file mode 100644 index 0000000..83631f8 --- /dev/null +++ b/packages/elm-to-sql/.gitignore @@ -0,0 +1,3 @@ +dist/ +node_modules/ +*.tsbuildinfo diff --git a/packages/elm-to-sql/FAQ.md b/packages/elm-to-sql/FAQ.md new file mode 100644 index 0000000..39bf05c --- /dev/null +++ b/packages/elm-to-sql/FAQ.md @@ -0,0 +1,275 @@ +# @cqframework/elm-to-sql — FAQ & Current Support State + +This document covers what the library currently handles, known gaps, and how to work around them. Updated as the library evolves toward the June CMS Connectathon demo. + +--- + +## General + +### What does this library do? + +It converts CQL ELM (Expression Logical Model) JSON — the intermediate representation produced by `@cqframework/cql`'s in-browser CQL-to-ELM translator — into SQL queries using the SQL-on-FHIR specification. It also generates FHIR R4 `MeasureReport` resources from SQL population counts. + +### What does it NOT do? + +- It does not parse CQL text. Use `@cqframework/cql` for that. +- It does not execute SQL. You supply your own database connection (PostgreSQL, DuckDB, etc.). +- It does not make FHIR API calls. Your application posts the MeasureReport. +- It does not resolve value sets at runtime. Value set expansion is expected in a `value_set_expansion` table. + +### What SQL dialect does it target? + +Primary target is **PostgreSQL 14+**. The generated SQL uses: +- `tsrange(start, end, '[)')` for interval containment +- `DATE_PART('year', AGE(...))` for age calculations +- `bool_or` / `bool_and` for AnyTrue/AllTrue aggregates +- Standard `EXISTS`, `NOT EXISTS`, `UNION ALL`, `INTERSECT`, `EXCEPT` + +**DuckDB** is largely compatible. Differences: +- DuckDB uses `date_diff('year', birthdate, date)` instead of `DATE_PART('year', AGE(...))` +- DuckDB does not have `tsrange` — use `date BETWEEN start AND end` instead + +A DuckDB dialect option is planned. + +--- + +## ELM Input Format + +### What ELM format does the transpiler accept? + +The library expects ELM **JSON** in the standard HL7 format: + +```json +{ + "library": { + "identifier": { "id": "MyMeasure", "version": "1.0.0" }, + "statements": { + "def": [ + { "name": "Initial Population", "context": "Patient", "expression": { ... } } + ] + } + } +} +``` + +This is the `{ library: ElmLibrary }` wrapper shape. You can also pass the inner `ElmLibrary` directly. + +### CQL Studio currently uses `translator.toXml()` — how do I get ELM JSON? + +The `@cqframework/cql` `CqlTranslator` class exposes both `toXml()` and `toJson()`. CQL Studio's `TranslationService` currently only calls `toXml()`. To use this library from the Angular app: + +```typescript +// In translation.service.ts — add a toJson path: +const elmJson = JSON.parse(translator.toJson()); +const transpiler = new ElmToSqlTranspiler({ ... }); +const { sql } = transpiler.transpile(elmJson); +``` + +This wiring is Preston's responsibility (Issue #23), but you can prototype it locally by modifying `TranslationService.translateCqlToElm()`. + +### Does it handle ELM XML? + +Not directly. Parse XML to the JSON structure first. The JSON format is simpler and the authoritative target. + +--- + +## ELM Node Support + +### Which ELM expression types are supported? + +| Category | Supported types | +| -------- | --------------- | +| Data access | `Retrieve`, `Query` (source, where, return, sort, relationship) | +| References | `ExpressionRef`, `FunctionRef`, `ParameterRef`, `ValueSetRef` | +| Primitives | `Literal` (Integer, Decimal, String, Boolean, Date, DateTime), `Null` | +| Logic | `And`, `Or`, `Not`, `Xor` | +| Comparison | `Equal`, `NotEqual`, `Less`, `Greater`, `LessOrEqual`, `GreaterOrEqual` | +| Arithmetic | `Add`, `Subtract`, `Multiply`, `Divide` | +| Set/interval | `In`, `During`, `IncludedIn`, `Contains`, `Exists` | +| Aggregates | `Count`, `Sum`, `Min`, `Max`, `Avg` | +| Temporal | `DurationBetween`, `Today`, `Now`, `Start`, `End`, `Interval` | +| Control flow | `If`, `Case` | +| Collections | `Union`, `Intersect`, `Except`, `Distinct`, `Flatten`, `First`, `Last`, `List` | +| Functions | `AgeInYearsAt`, `AgeInMonthsAt`, `AgeInDaysAt`, `ToDate`, `ToDateTime`, `ToString`, `ToInteger`, `ToDecimal`, `Coalesce`, `Lower`, `Upper`, `Length`, `Substring` | +| Type ops | `As`, `Convert`, `ToList`, `SingletonFrom` | + +### What is NOT yet supported? + +| Type | Status | Notes | +| ---- | ------ | ----- | +| `Collapse` / `Expand` | Emits warning + NULL | Interval list operations — planned | +| `Message` | Emits warning + NULL | CQL tracing construct — low priority | +| `Ratio` / `Quantity` comparisons | Partial | Unit-aware math not implemented | +| Cross-library `ExpressionRef` | Warning + falls through | Treats as local CTE reference | +| `AnyInValueSet` / `AllInValueSet` | Emits warning + NULL | Planned | +| `Slice` / `IndexOf` | Emits warning + NULL | Rarely used in eCQMs | +| `DurationBetween` with `Week`/`Hour` | Falls to day | PostgreSQL `DATE_PART` limitation | +| `Tuple` expressions | Emits warning + NULL | Complex return types — planned | +| Stratifiers | Not generated | `stratifier` in MeasureReport always empty | +| `DateTime` arithmetic (`+ 1 year`) | Not supported | Use `DurationBetween` instead | + +When an unsupported node is encountered, the transpiler emits a SQL comment (`NULL -- unsupported: TypeName`) and adds an entry to `warnings[]` in the `TranspileResult`. **Always check `warnings` after transpiling.** + +--- + +## Value Sets + +### How are value sets resolved? + +The generated SQL uses a `value_set_expansion` table with at minimum columns `(value_set_id TEXT, code TEXT, system TEXT)`. Example for PostgreSQL: + +```sql +CREATE TABLE value_set_expansion ( + value_set_id TEXT NOT NULL, -- OID or canonical URL from ValueSetDef.id + code TEXT NOT NULL, + system TEXT, + display TEXT, + PRIMARY KEY (value_set_id, code) +); +``` + +Populate this from your terminology server's `$expand` operation or a pre-loaded VSAC extract. + +### The generated SQL uses OIDs — my server uses canonical URLs. What do I do? + +The OID/URL comes directly from the CQL `valueset` declaration (`ElmValueSetDef.id`). Standard eCQM CQL uses VSAC OIDs like `urn:oid:2.16.840.1.113883.3.464.1003.108.12.1018`. Your `value_set_expansion` table's `value_set_id` column should match whatever format appears in the CQL source. + +--- + +## Measurement Period + +### How is `Measurement Period` handled? + +`ParameterRef { name: "Measurement Period" }` is resolved to a PostgreSQL `tsrange` literal using `measurementPeriodStart` and `measurementPeriodEnd` from `TranspilerOptions`. Default is the current calendar year. + +For `During` / `In` comparisons against `Measurement Period`, the generated SQL uses `@>` containment: + +```sql +tsrange('2024-01-01T00:00:00Z', '2024-12-31T23:59:59Z', '[)') @> effective_datetime::timestamptz +``` + +### What if the CQL uses a different parameter name? + +Non-`Measurement Period` `ParameterRef` nodes emit `NULL -- ParameterRef:name` with a warning. For custom parameters, use the `populationDefines` option to control output, and inject literal values in the SQL before execution. + +--- + +## SQL Views + +### What base views does the transpiler expect? + +The transpiler generates SQL that references views like `patient_view`, `observation_view`, `condition_view`, etc. These must exist in your database. The library provides two ways to create them: + +1. **`STANDARD_VIEW_DEFINITIONS`** — array of FHIR `ViewDefinition` resources (JSON) +2. **`generateAllViewsSql()`** — `CREATE OR REPLACE VIEW` SQL script + +For the bundled HAPI FHIR JPA server (the one shipping with CQL Studio), the views target HAPI's internal PostgreSQL schema — that's Issue #21, which will produce a separate boot script. + +### What columns does `patient_view` need? + +Minimum required by the transpiler: + +| Column | Type | Notes | +| ------ | ---- | ----- | +| `id` | text | Patient.id | +| `gender` | text | `male` / `female` / `other` / `unknown` | +| `birthdate` | date | Patient.birthDate | +| `active` | boolean | Patient.active | + +`AgeInYearsAt` uses `p.birthdate` with alias `p` (from the query source alias). + +### What columns does `encounter_view` need? + +| Column | Type | +| ------ | ---- | +| `id` | text | +| `subject_id` | text | +| `status` | text | +| `period_start` | timestamp | +| `period_end` | timestamp | +| `type_code` | text | +| `type_system` | text | + +--- + +## MeasureReport + +### Which population names produce standard eCQM population codes? + +| CQL define name | FHIR code | +| --------------- | --------- | +| `Initial Population` | `initial-population` | +| `Denominator` | `denominator` | +| `Denominator Exclusion` | `denominator-exclusion` | +| `Denominator Exception` | `denominator-exception` | +| `Numerator` | `numerator` | +| `Numerator Exclusion` | `numerator-exclusion` | +| `Measure Population` | `measure-population` | +| `Measure Population Exclusion` | `measure-population-exclusion` | + +Define names that don't match the list above are silently excluded from `group.population[]`. + +### How is measure score calculated? + +`Numerator / (Denominator - Denominator Exclusion - Denominator Exception)`, rounded to 4 decimal places. Returns `null` (no `measureScore` field) if the adjusted denominator is ≤ 0. + +### The report ID changes every run. Is that expected? + +Yes — IDs are generated with `Math.random()`. For idempotent runs, pass `options.id` explicitly. + +--- + +## Testing + +### How do I run the tests? + +```bash +cd packages/elm-to-sql +npm install +npm test +``` + +### What's covered by the test suite? + +The `test/elm-to-sql.test.ts` suite uses a CMS125 Breast Cancer Screening ELM fixture covering: +- Transpilation without errors +- SQL structure (WITH, CTEs, final SELECT) +- Population detection +- `measurementPeriodStart`/`End` options +- Comment suppression +- Bare `ElmLibrary` input (no wrapper) +- `ExpressionRef` → CTE reference +- `ValueSetRef` → `value_set_expansion` lookup +- `AgeInYearsAt` → `DATE_PART` +- `generateMeasureReport` — resource shape, period, populations, score +- `sqlRowToPopulationCounts` — column parsing +- `STANDARD_VIEW_DEFINITIONS` — counts, DDL output + +### How do I add a new ELM fixture? + +Drop a `.elm.json` file in `test/fixtures/` matching the `{ library: ElmLibrary }` shape. You can get real ELM JSON by running `@cqframework/cql`'s translator against your CQL: + +```typescript +const translator = CqlTranslator.fromText(cqlText, libraryManager); +const elmJson = JSON.parse(translator.toJson()); +``` + +--- + +## Roadmap + +| Priority | Item | +| -------- | ---- | +| High | DuckDB dialect option (`dialectOptions: { type: 'duckdb' }`) | +| High | Issue #21 — HAPI FHIR JPA view boot scripts | +| High | Issue #19 — CQL Studio Server DB proxy endpoint | +| Medium | `Collapse`/`Expand` interval operations | +| Medium | `Tuple` return type flattening | +| Medium | Stratifier support in `generateMeasureReport` | +| Medium | `AnyInValueSet` / `AllInValueSet` | +| Low | `DateTime` arithmetic expressions | +| Low | Full cross-library resolution | + +--- + +*Last updated: April 2026. Track progress at [cqframework/cql-studio#15](https://github.com/cqframework/cql-studio/issues/15).* diff --git a/packages/elm-to-sql/LICENSE b/packages/elm-to-sql/LICENSE new file mode 100644 index 0000000..0f8f703 --- /dev/null +++ b/packages/elm-to-sql/LICENSE @@ -0,0 +1,119 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship made available under + the License, as indicated by a copyright notice that is included in + or attached to the work (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other + transformations represent, as a whole, an original work of authorship. + For the purposes of this License, Derivative Works shall not include + works that remain separable from, or merely link (or bind by name) + to the interfaces of, the Work and Derivative Works thereof. + + "Contribution" shall mean, as submitted to the Licensor for inclusion + in the Work by the copyright owner or by an individual or Legal Entity + authorized to submit on behalf to the Licensor, any Derivative Works + of the Work. + + "Contributor" shall mean Licensor and any Legal Entity on behalf of + whom a Contribution has been received by the Licensor and included + within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or Derivative + Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in all Source forms of the Distribution, + all copyright, patent, trademark, and attribution notices + from the Source form of the Work, excluding those notices + that do not pertain to any part of the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file, the contents of + the NOTICE file are part of the Distribution for the Work. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable use in describing the origin of the + Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or agreed + to in writing, Licensor provides the Work (and each Contributor + provides its Contributions) on an "AS IS" BASIS, WITHOUT WARRANTIES + OR CONDITIONS OF ANY KIND, either express or implied, including, + without limitation, any warranties or conditions of TITLE, + NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or exemplary damages of any character arising as a result + of this License or out of the use or inability to use the Work. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may charge a fee for + acceptance of support, warranty, indemnity, or other liability + obligations and/or rights consistent by this License. However, + in accepting such obligations, You may offer only Your own liability. + + END OF TERMS AND CONDITIONS diff --git a/packages/elm-to-sql/README.md b/packages/elm-to-sql/README.md new file mode 100644 index 0000000..eb17abc --- /dev/null +++ b/packages/elm-to-sql/README.md @@ -0,0 +1,201 @@ +# @cqframework/elm-to-sql + +Standalone ESM library for transpiling CQL ELM (Expression Logical Model) to SQL-on-FHIR queries and generating FHIR MeasureReports — with no Node.js runtime dependencies. + +Implements part of the [CQL Studio SQL-on-FHIR epic](https://github.com/cqframework/cql-studio/issues/15). + +## Overview + +``` +CQL source + │ + ▼ (@cqframework/cql — in-browser translator) +ELM JSON + │ + ▼ (this library) +SQL query ──▶ run via pluggable DB adapter ──▶ population counts + │ + ▼ + FHIR MeasureReport +``` + +## Installation + +```bash +npm install @cqframework/elm-to-sql +``` + +## Usage + +### Transpile ELM → SQL + +```typescript +import { ElmToSqlTranspiler } from '@cqframework/elm-to-sql'; + +// elmJson is the output of @cqframework/cql's CqlTranslator.toJson() +// or a manually constructed ELM library wrapper { library: {...} } +const transpiler = new ElmToSqlTranspiler({ + measurementPeriodStart: '2024-01-01T00:00:00Z', + measurementPeriodEnd: '2024-12-31T23:59:59Z', +}); + +const { sql, populations, warnings } = transpiler.transpile(elmJson); +console.log(sql); +// WITH +// Qualifying_Encounters AS ( SELECT * FROM encounter_view WHERE ... ), +// Initial_Population AS ( SELECT * FROM patient_view WHERE ... ), +// ... +// SELECT +// (SELECT COUNT(*) FROM Initial_Population) AS Initial_Population_count, +// (SELECT COUNT(*) FROM Denominator) AS Denominator_count, +// (SELECT COUNT(*) FROM Numerator) AS Numerator_count +``` + +### Execute with a pluggable adapter (PostgreSQL example) + +```typescript +import pg from 'pg'; +import { ElmToSqlTranspiler, sqlRowToPopulationCounts, generateMeasureReport } from '@cqframework/elm-to-sql'; + +const client = new pg.Client({ connectionString: process.env.DATABASE_URL }); +await client.connect(); + +const transpiler = new ElmToSqlTranspiler({ measurementPeriodStart: '2024-01-01T00:00:00Z', measurementPeriodEnd: '2024-12-31T23:59:59Z' }); +const { sql } = transpiler.transpile(elmJson); + +const result = await client.query(sql); +const counts = sqlRowToPopulationCounts(result.rows[0]); + +const report = generateMeasureReport(counts, { + measureUrl: 'http://ecqi.healthit.gov/ecqms/Measure/BreastCancerScreening', + periodStart: '2024-01-01', + periodEnd: '2024-12-31', +}); + +// report is a FHIR MeasureReport — POST it to your FHIR server +``` + +### Execute with DuckDB (in-browser or Node) + +```typescript +import * as duckdb from '@duckdb/duckdb-wasm'; +import { ElmToSqlTranspiler, sqlRowToPopulationCounts } from '@cqframework/elm-to-sql'; + +// ... initialize DuckDB connection ... +const { sql } = new ElmToSqlTranspiler().transpile(elmJson); +const result = await conn.query(sql); +const counts = sqlRowToPopulationCounts(result.toArray()[0]); +``` + +### Generate SQL-on-FHIR ViewDefinitions + +```typescript +import { STANDARD_VIEW_DEFINITIONS, viewDefinitionToSql, generateAllViewsSql } from '@cqframework/elm-to-sql'; + +// Get all standard FHIR resource ViewDefinition resources (JSON) +console.log(STANDARD_VIEW_DEFINITIONS); + +// Get CREATE OR REPLACE VIEW SQL for a specific resource +const { sql } = viewDefinitionToSql(STANDARD_VIEW_DEFINITIONS[0]); + +// Get all views as a single deployable SQL script +const script = generateAllViewsSql(); +await client.query(script); +``` + +### Generate a MeasureReport + +```typescript +import { generateMeasureReport } from '@cqframework/elm-to-sql'; + +const report = generateMeasureReport( + { + 'Initial Population': 150, + 'Denominator': 120, + 'Denominator Exclusion': 5, + 'Numerator': 80, + }, + { + measureUrl: 'http://ecqi.healthit.gov/ecqms/Measure/BreastCancerScreening', + periodStart: '2024-01-01', + periodEnd: '2024-12-31', + type: 'summary', + } +); +// POST report to FHIR server via your app's FHIR client +``` + +## Supported ELM Node Types + +| Type | SQL output | +|------|------------| +| `Retrieve` | `SELECT * FROM {resource}_view [WHERE code IN ...]` | +| `Query` | `SELECT ... FROM ... WHERE ...` with WITH/WITHOUT semi-joins | +| `ExpressionRef` | Reference to a CTE | +| `FunctionRef` | `AgeInYearsAt` → `DATE_PART('year', AGE(...))`, `ToDate`, `ToDateTime`, etc. | +| `ParameterRef` | `Measurement Period` → `tsrange(...)` | +| `ValueSetRef` | `code IN (SELECT code FROM value_set_expansion WHERE value_set_id = ...)` | +| `And`/`Or` | `AND`/`OR` | +| `Equal`/`NotEqual`/`Less`/`Greater`/etc. | Standard SQL operators | +| `In`/`During`/`IncludedIn` | `@>` interval containment or `IN` set | +| `Exists` | `EXISTS (SELECT 1 ...)` | +| `Not` | `NOT (...)` | +| `Count`/`Sum`/`Min`/`Max`/`Avg` | `(SELECT COUNT(*)/SUM(...) FROM ...)` | +| `Union`/`Intersect`/`Except` | `UNION ALL`/`INTERSECT`/`EXCEPT` | +| `If`/`Case` | `CASE WHEN ... THEN ... END` | +| `Interval` | `tsrange(low, high, '[)')` | +| `Literal` | SQL literals with type-appropriate quoting | +| `DurationBetween` | `DATE_PART(precision, AGE(...))` | + +## SQL Assumptions + +- Target dialect: **PostgreSQL 14+** (primary). DuckDB is largely compatible. +- Views must exist as flat SQL-on-FHIR tables (see `STANDARD_VIEW_DEFINITIONS`). +- Value sets are resolved via a `value_set_expansion(value_set_id, code)` table. +- `patient_view` columns: `id`, `gender`, `birthdate`, `active`, ... +- Interval comparisons use PostgreSQL `tsrange` / `@>` operator. + +## API + +### `ElmToSqlTranspiler` + +```typescript +new ElmToSqlTranspiler(options?: TranspilerOptions) +transpile(elm: ElmLibraryWrapper | ElmLibrary): TranspileResult +``` + +**TranspilerOptions** +| Option | Type | Default | Description | +|--------|------|---------|-------------| +| `measurementPeriodStart` | string | Current year Jan 1 | ISO 8601 | +| `measurementPeriodEnd` | string | Current year Dec 31 | ISO 8601 | +| `includeComments` | boolean | `true` | Emit SQL comments | +| `populationDefines` | string[] | auto-detect | Override population define names | + +### `generateMeasureReport(counts, options)` + +Converts population counts to a FHIR R4 MeasureReport. Does not make FHIR API calls. + +### `sqlRowToPopulationCounts(row)` + +Converts a flat SQL result row (`{ Initial_Population_count: 150, ... }`) to a `PopulationCounts` map. + +### `STANDARD_VIEW_DEFINITIONS` + +Array of FHIR `ViewDefinition` resources for Patient, Observation, Condition, Procedure, Encounter, MedicationRequest, DiagnosticReport, Coverage, AllergyIntolerance, Immunization. + +### `generateAllViewsSql()` + +Returns a PostgreSQL-compatible `CREATE OR REPLACE VIEW` script for all standard views. + +## Development + +```bash +npm install +npm run build +npm test +``` + +## License + +Apache 2.0 — see [LICENSE](./LICENSE). diff --git a/packages/elm-to-sql/jest.config.js b/packages/elm-to-sql/jest.config.js new file mode 100644 index 0000000..766b0bb --- /dev/null +++ b/packages/elm-to-sql/jest.config.js @@ -0,0 +1,22 @@ +/** @type {import('jest').Config} */ +export default { + preset: 'ts-jest/presets/default-esm', + testEnvironment: 'node', + extensionsToTreatAsEsm: ['.ts'], + moduleNameMapper: { + '^(\\.{1,2}/.*)\\.js$': '$1', + }, + transform: { + '^.+\\.tsx?$': [ + 'ts-jest', + { + useESM: true, + tsconfig: { + module: 'ES2020', + moduleResolution: 'bundler', + }, + }, + ], + }, + testMatch: ['**/test/**/*.test.ts'], +}; diff --git a/packages/elm-to-sql/package-lock.json b/packages/elm-to-sql/package-lock.json new file mode 100644 index 0000000..cd02c72 --- /dev/null +++ b/packages/elm-to-sql/package-lock.json @@ -0,0 +1,3857 @@ +{ + "name": "@cqframework/elm-to-sql", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "@cqframework/elm-to-sql", + "version": "0.1.0", + "license": "Apache-2.0", + "devDependencies": { + "@types/jest": "^29.5.12", + "jest": "^29.7.0", + "ts-jest": "^29.1.5", + "typescript": "^5.4.5" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", + "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.28.5", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.0.tgz", + "integrity": "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz", + "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-compilation-targets": "^7.28.6", + "@babel/helper-module-transforms": "^7.28.6", + "@babel/helpers": "^7.28.6", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/traverse": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.1", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz", + "integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz", + "integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.28.6", + "@babel/helper-validator-option": "^7.27.1", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", + "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz", + "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz", + "integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.28.6", + "@babel/helper-validator-identifier": "^7.28.5", + "@babel/traverse": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.28.6.tgz", + "integrity": "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", + "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.2.tgz", + "integrity": "sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.28.6", + "@babel/types": "^7.29.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.2.tgz", + "integrity": "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.0" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-syntax-async-generators": { + "version": "7.8.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-async-generators/-/plugin-syntax-async-generators-7.8.4.tgz", + "integrity": "sha512-tycmZxkGfZaxhMRbXlPXuVFpdWlXpir2W4AMhSJgRKzk/eDlIXOhb2LHWoLpDF7TEHylV5zNhykX6KAgHJmTNw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-bigint": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-bigint/-/plugin-syntax-bigint-7.8.3.tgz", + "integrity": "sha512-wnTnFlG+YxQm3vDxpGE57Pj0srRU4sHE/mDkt1qv2YJJSeUAec2ma4WLUnUPeKjyrfntVwe/N6dCXpU+zL3Npg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-class-properties": { + "version": "7.12.13", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-class-properties/-/plugin-syntax-class-properties-7.12.13.tgz", + "integrity": "sha512-fm4idjKla0YahUNgFNLCB0qySdsoPiZP3iQE3rky0mBUtMZ23yDJ9SJdg6dXTSDnulOVqiF3Hgr9nbXvXTQZYA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.12.13" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-class-static-block": { + "version": "7.14.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-class-static-block/-/plugin-syntax-class-static-block-7.14.5.tgz", + "integrity": "sha512-b+YyPmr6ldyNnM6sqYeMWE+bgJcJpO6yS4QD7ymxgH34GBPNDM/THBh8iunyvKIZztiwLH4CJZ0RxTk9emgpjw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.14.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-import-attributes": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-attributes/-/plugin-syntax-import-attributes-7.28.6.tgz", + "integrity": "sha512-jiLC0ma9XkQT3TKJ9uYvlakm66Pamywo+qwL+oL8HJOvc6TWdZXVfhqJr8CCzbSGUAbDOzlGHJC1U+vRfLQDvw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-import-meta": { + "version": "7.10.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-meta/-/plugin-syntax-import-meta-7.10.4.tgz", + "integrity": "sha512-Yqfm+XDx0+Prh3VSeEQCPU81yC+JWZ2pDPFSS4ZdpfZhp4MkFMaDC1UqseovEKwSUpnIL7+vK+Clp7bfh0iD7g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.10.4" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-json-strings": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-json-strings/-/plugin-syntax-json-strings-7.8.3.tgz", + "integrity": "sha512-lY6kdGpWHvjoe2vk4WrAapEuBR69EMxZl+RoGRhrFGNYVK8mOPAW8VfbT/ZgrFbXlDNiiaxQnAtgVCZ6jv30EA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-jsx": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-jsx/-/plugin-syntax-jsx-7.28.6.tgz", + "integrity": "sha512-wgEmr06G6sIpqr8YDwA2dSRTE3bJ+V0IfpzfSY3Lfgd7YWOaAdlykvJi13ZKBt8cZHfgH1IXN+CL656W3uUa4w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-logical-assignment-operators": { + "version": "7.10.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-logical-assignment-operators/-/plugin-syntax-logical-assignment-operators-7.10.4.tgz", + "integrity": "sha512-d8waShlpFDinQ5MtvGU9xDAOzKH47+FFoney2baFIoMr952hKOLp1HR7VszoZvOsV/4+RRszNY7D17ba0te0ig==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.10.4" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-nullish-coalescing-operator": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-nullish-coalescing-operator/-/plugin-syntax-nullish-coalescing-operator-7.8.3.tgz", + "integrity": "sha512-aSff4zPII1u2QD7y+F8oDsz19ew4IGEJg9SVW+bqwpwtfFleiQDMdzA/R+UlWDzfnHFCxxleFT0PMIrR36XLNQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-numeric-separator": { + "version": "7.10.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-numeric-separator/-/plugin-syntax-numeric-separator-7.10.4.tgz", + "integrity": "sha512-9H6YdfkcK/uOnY/K7/aA2xpzaAgkQn37yzWUMRK7OaPOqOpGS1+n0H5hxT9AUw9EsSjPW8SVyMJwYRtWs3X3ug==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.10.4" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-object-rest-spread": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-object-rest-spread/-/plugin-syntax-object-rest-spread-7.8.3.tgz", + "integrity": "sha512-XoqMijGZb9y3y2XskN+P1wUGiVwWZ5JmoDRwx5+3GmEplNyVM2s2Dg8ILFQm8rWM48orGy5YpI5Bl8U1y7ydlA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-optional-catch-binding": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-optional-catch-binding/-/plugin-syntax-optional-catch-binding-7.8.3.tgz", + "integrity": "sha512-6VPD0Pc1lpTqw0aKoeRTMiB+kWhAoT24PA+ksWSBrFtl5SIRVpZlwN3NNPQjehA2E/91FV3RjLWoVTglWcSV3Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-optional-chaining": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-optional-chaining/-/plugin-syntax-optional-chaining-7.8.3.tgz", + "integrity": "sha512-KoK9ErH1MBlCPxV0VANkXW2/dw4vlbGDrFgz8bmUsBGYkFRcbRwMh6cIJubdPrkxRwuGdtCk0v/wPTKbQgBjkg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-private-property-in-object": { + "version": "7.14.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-private-property-in-object/-/plugin-syntax-private-property-in-object-7.14.5.tgz", + "integrity": "sha512-0wVnp9dxJ72ZUJDV27ZfbSj6iHLoytYZmh3rFcxNnvsJF3ktkzLDZPy/mA17HGsaQT3/DQsWYX1f1QGWkCoVUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.14.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-top-level-await": { + "version": "7.14.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-top-level-await/-/plugin-syntax-top-level-await-7.14.5.tgz", + "integrity": "sha512-hx++upLv5U1rgYfwe1xBQUhRmU41NEvpUvrp8jkrSCdvGSnM5/qdRMtylJ6PG5OFkBaHkbTAKTnd3/YyESRHFw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.14.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-typescript": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-typescript/-/plugin-syntax-typescript-7.28.6.tgz", + "integrity": "sha512-+nDNmQye7nlnuuHDboPbGm00Vqg3oO8niRRL27/4LYHUsHYh0zJ1xWOz0uRwNFmM1Avzk8wZbc6rdiYhomzv/A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/template": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", + "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.28.6", + "@babel/parser": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz", + "integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-globals": "^7.28.0", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/types": "^7.29.0", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", + "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@bcoe/v8-coverage": { + "version": "0.2.3", + "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-0.2.3.tgz", + "integrity": "sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@istanbuljs/load-nyc-config": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@istanbuljs/load-nyc-config/-/load-nyc-config-1.1.0.tgz", + "integrity": "sha512-VjeHSlIzpv/NyD3N0YuHfXOPDIixcA1q2ZV98wsMqcYlPmv2n3Yb2lYP9XMElnaFVXg5A7YLTeLu6V84uQDjmQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "camelcase": "^5.3.1", + "find-up": "^4.1.0", + "get-package-type": "^0.1.0", + "js-yaml": "^3.13.1", + "resolve-from": "^5.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@istanbuljs/schema": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.3.tgz", + "integrity": "sha512-ZXRY4jNvVgSVQ8DL3LTcakaAtXwTVUxE81hslsyD2AtoXW/wVob10HkOJ1X/pAlcI7D+2YoZKg5do8G/w6RYgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/@jest/console": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/console/-/console-29.7.0.tgz", + "integrity": "sha512-5Ni4CU7XHQi32IJ398EEP4RrB8eV09sXP2ROqD4bksHrnTree52PsxvX8tpL8LvTZ3pFzXyPbNQReSN41CAhOg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "^29.6.3", + "@types/node": "*", + "chalk": "^4.0.0", + "jest-message-util": "^29.7.0", + "jest-util": "^29.7.0", + "slash": "^3.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/core": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/core/-/core-29.7.0.tgz", + "integrity": "sha512-n7aeXWKMnGtDA48y8TLWJPJmLmmZ642Ceo78cYWEpiD7FzDgmNDV/GCVRorPABdXLJZ/9wzzgZAlHjXjxDHGsg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/console": "^29.7.0", + "@jest/reporters": "^29.7.0", + "@jest/test-result": "^29.7.0", + "@jest/transform": "^29.7.0", + "@jest/types": "^29.6.3", + "@types/node": "*", + "ansi-escapes": "^4.2.1", + "chalk": "^4.0.0", + "ci-info": "^3.2.0", + "exit": "^0.1.2", + "graceful-fs": "^4.2.9", + "jest-changed-files": "^29.7.0", + "jest-config": "^29.7.0", + "jest-haste-map": "^29.7.0", + "jest-message-util": "^29.7.0", + "jest-regex-util": "^29.6.3", + "jest-resolve": "^29.7.0", + "jest-resolve-dependencies": "^29.7.0", + "jest-runner": "^29.7.0", + "jest-runtime": "^29.7.0", + "jest-snapshot": "^29.7.0", + "jest-util": "^29.7.0", + "jest-validate": "^29.7.0", + "jest-watcher": "^29.7.0", + "micromatch": "^4.0.4", + "pretty-format": "^29.7.0", + "slash": "^3.0.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + }, + "peerDependencies": { + "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" + }, + "peerDependenciesMeta": { + "node-notifier": { + "optional": true + } + } + }, + "node_modules/@jest/environment": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/environment/-/environment-29.7.0.tgz", + "integrity": "sha512-aQIfHDq33ExsN4jP1NWGXhxgQ/wixs60gDiKO+XVMd8Mn0NWPWgc34ZQDTb2jKaUWQ7MuwoitXAsN2XVXNMpAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/fake-timers": "^29.7.0", + "@jest/types": "^29.6.3", + "@types/node": "*", + "jest-mock": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/expect": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/expect/-/expect-29.7.0.tgz", + "integrity": "sha512-8uMeAMycttpva3P1lBHB8VciS9V0XAr3GymPpipdyQXbBcuhkLQOSe8E/p92RyAdToS6ZD1tFkX+CkhoECE0dQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "expect": "^29.7.0", + "jest-snapshot": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/expect-utils": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/expect-utils/-/expect-utils-29.7.0.tgz", + "integrity": "sha512-GlsNBWiFQFCVi9QVSx7f5AgMeLxe9YCCs5PuP2O2LdjDAA8Jh9eX7lA1Jq/xdXw3Wb3hyvlFNfZIfcRetSzYcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "jest-get-type": "^29.6.3" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/fake-timers": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/fake-timers/-/fake-timers-29.7.0.tgz", + "integrity": "sha512-q4DH1Ha4TTFPdxLsqDXK1d3+ioSL7yL5oCMJZgDYm6i+6CygW5E5xVr/D1HdsGxjt1ZWSfUAs9OxSB/BNelWrQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "^29.6.3", + "@sinonjs/fake-timers": "^10.0.2", + "@types/node": "*", + "jest-message-util": "^29.7.0", + "jest-mock": "^29.7.0", + "jest-util": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/globals": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/globals/-/globals-29.7.0.tgz", + "integrity": "sha512-mpiz3dutLbkW2MNFubUGUEVLkTGiqW6yLVTA+JbP6fI6J5iL9Y0Nlg8k95pcF8ctKwCS7WVxteBs29hhfAotzQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/environment": "^29.7.0", + "@jest/expect": "^29.7.0", + "@jest/types": "^29.6.3", + "jest-mock": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/reporters": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/reporters/-/reporters-29.7.0.tgz", + "integrity": "sha512-DApq0KJbJOEzAFYjHADNNxAE3KbhxQB1y5Kplb5Waqw6zVbuWatSnMjE5gs8FUgEPmNsnZA3NCWl9NG0ia04Pg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@bcoe/v8-coverage": "^0.2.3", + "@jest/console": "^29.7.0", + "@jest/test-result": "^29.7.0", + "@jest/transform": "^29.7.0", + "@jest/types": "^29.6.3", + "@jridgewell/trace-mapping": "^0.3.18", + "@types/node": "*", + "chalk": "^4.0.0", + "collect-v8-coverage": "^1.0.0", + "exit": "^0.1.2", + "glob": "^7.1.3", + "graceful-fs": "^4.2.9", + "istanbul-lib-coverage": "^3.0.0", + "istanbul-lib-instrument": "^6.0.0", + "istanbul-lib-report": "^3.0.0", + "istanbul-lib-source-maps": "^4.0.0", + "istanbul-reports": "^3.1.3", + "jest-message-util": "^29.7.0", + "jest-util": "^29.7.0", + "jest-worker": "^29.7.0", + "slash": "^3.0.0", + "string-length": "^4.0.1", + "strip-ansi": "^6.0.0", + "v8-to-istanbul": "^9.0.1" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + }, + "peerDependencies": { + "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" + }, + "peerDependenciesMeta": { + "node-notifier": { + "optional": true + } + } + }, + "node_modules/@jest/schemas": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-29.6.3.tgz", + "integrity": "sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@sinclair/typebox": "^0.27.8" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/source-map": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/@jest/source-map/-/source-map-29.6.3.tgz", + "integrity": "sha512-MHjT95QuipcPrpLM+8JMSzFx6eHp5Bm+4XeFDJlwsvVBjmKNiIAvasGK2fxz2WbGRlnvqehFbh07MMa7n3YJnw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.18", + "callsites": "^3.0.0", + "graceful-fs": "^4.2.9" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/test-result": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/test-result/-/test-result-29.7.0.tgz", + "integrity": "sha512-Fdx+tv6x1zlkJPcWXmMDAG2HBnaR9XPSd5aDWQVsfrZmLVT3lU1cwyxLgRmXR9yrq4NBoEm9BMsfgFzTQAbJYA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/console": "^29.7.0", + "@jest/types": "^29.6.3", + "@types/istanbul-lib-coverage": "^2.0.0", + "collect-v8-coverage": "^1.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/test-sequencer": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/test-sequencer/-/test-sequencer-29.7.0.tgz", + "integrity": "sha512-GQwJ5WZVrKnOJuiYiAF52UNUJXgTZx1NHjFSEB0qEMmSZKAkdMoIzw/Cj6x6NF4AvV23AUqDpFzQkN/eYCYTxw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/test-result": "^29.7.0", + "graceful-fs": "^4.2.9", + "jest-haste-map": "^29.7.0", + "slash": "^3.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/transform": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/transform/-/transform-29.7.0.tgz", + "integrity": "sha512-ok/BTPFzFKVMwO5eOHRrvnBVHdRy9IrsrW1GpMaQ9MCnilNLXQKmAX8s1YXDFaai9xJpac2ySzV0YeRRECr2Vw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.11.6", + "@jest/types": "^29.6.3", + "@jridgewell/trace-mapping": "^0.3.18", + "babel-plugin-istanbul": "^6.1.1", + "chalk": "^4.0.0", + "convert-source-map": "^2.0.0", + "fast-json-stable-stringify": "^2.1.0", + "graceful-fs": "^4.2.9", + "jest-haste-map": "^29.7.0", + "jest-regex-util": "^29.6.3", + "jest-util": "^29.7.0", + "micromatch": "^4.0.4", + "pirates": "^4.0.4", + "slash": "^3.0.0", + "write-file-atomic": "^4.0.2" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/types": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/@jest/types/-/types-29.6.3.tgz", + "integrity": "sha512-u3UPsIilWKOM3F9CXtrG8LEJmNxwoCQC/XVj4IKYXvvpx7QIi/Kg1LI5uDmDpKlac62NUtX7eLjRh+jVZcLOzw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/schemas": "^29.6.3", + "@types/istanbul-lib-coverage": "^2.0.0", + "@types/istanbul-reports": "^3.0.0", + "@types/node": "*", + "@types/yargs": "^17.0.8", + "chalk": "^4.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@sinclair/typebox": { + "version": "0.27.10", + "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.10.tgz", + "integrity": "sha512-MTBk/3jGLNB2tVxv6uLlFh1iu64iYOQ2PbdOSK3NW8JZsmlaOh2q6sdtKowBhfw8QFLmYNzTW4/oK4uATIi6ZA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@sinonjs/commons": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@sinonjs/commons/-/commons-3.0.1.tgz", + "integrity": "sha512-K3mCHKQ9sVh8o1C9cxkwxaOmXoAMlDxC1mYyHrjqOWEcBjYr76t96zL2zlj5dUGZ3HSw240X1qgH3Mjf1yJWpQ==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "type-detect": "4.0.8" + } + }, + "node_modules/@sinonjs/fake-timers": { + "version": "10.3.0", + "resolved": "https://registry.npmjs.org/@sinonjs/fake-timers/-/fake-timers-10.3.0.tgz", + "integrity": "sha512-V4BG07kuYSUkTCSBHG8G8TNhM+F19jXFWnQtzj+we8DrkpSBCee9Z3Ms8yiGer/dlmhe35/Xdgyo3/0rQKg7YA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@sinonjs/commons": "^3.0.0" + } + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "node_modules/@types/graceful-fs": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@types/graceful-fs/-/graceful-fs-4.1.9.tgz", + "integrity": "sha512-olP3sd1qOEe5dXTSaFvQG+02VdRXcdytWLAZsAq1PecU8uqQAhkrnbli7DagjtXKW/Bl7YJbUsa8MPcuc8LHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/istanbul-lib-coverage": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.6.tgz", + "integrity": "sha512-2QF/t/auWm0lsy8XtKVPG19v3sSOQlJe/YHZgfjb/KBBHOGSV+J2q/S671rcq9uTBrLAXmZpqJiaQbMT+zNU1w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/istanbul-lib-report": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@types/istanbul-lib-report/-/istanbul-lib-report-3.0.3.tgz", + "integrity": "sha512-NQn7AHQnk/RSLOxrBbGyJM/aVQ+pjj5HCgasFxc0K/KhoATfQ/47AyUl15I2yBUpihjmas+a+VJBOqecrFH+uA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/istanbul-lib-coverage": "*" + } + }, + "node_modules/@types/istanbul-reports": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/istanbul-reports/-/istanbul-reports-3.0.4.tgz", + "integrity": "sha512-pk2B1NWalF9toCRu6gjBzR69syFjP4Od8WRAX+0mmf9lAjCRicLOWc+ZrxZHx/0XRjotgkF9t6iaMJ+aXcOdZQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/istanbul-lib-report": "*" + } + }, + "node_modules/@types/jest": { + "version": "29.5.14", + "resolved": "https://registry.npmjs.org/@types/jest/-/jest-29.5.14.tgz", + "integrity": "sha512-ZN+4sdnLUbo8EVvVc2ao0GFW6oVrQRPn4K2lglySj7APvSrgzxHiNNK99us4WDMi57xxA2yggblIAMNhXOotLQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "expect": "^29.0.0", + "pretty-format": "^29.0.0" + } + }, + "node_modules/@types/node": { + "version": "25.6.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.6.0.tgz", + "integrity": "sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~7.19.0" + } + }, + "node_modules/@types/stack-utils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@types/stack-utils/-/stack-utils-2.0.3.tgz", + "integrity": "sha512-9aEbYZ3TbYMznPdcdr3SmIrLXwC/AKZXQeCf9Pgao5CKb8CyHuEX5jzWPTkvregvhRJHcpRO6BFoGW9ycaOkYw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/yargs": { + "version": "17.0.35", + "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.35.tgz", + "integrity": "sha512-qUHkeCyQFxMXg79wQfTtfndEC+N9ZZg76HJftDJp+qH2tV7Gj4OJi7l+PiWwJ+pWtW8GwSmqsDj/oymhrTWXjg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/yargs-parser": "*" + } + }, + "node_modules/@types/yargs-parser": { + "version": "21.0.3", + "resolved": "https://registry.npmjs.org/@types/yargs-parser/-/yargs-parser-21.0.3.tgz", + "integrity": "sha512-I4q9QU9MQv4oEOz4tAHJtNz1cwuLxn2F3xcc2iV5WdqLPpUnj30aUuxt1mAxYTG+oe8CZMV/+6rU4S4gRDzqtQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/ansi-escapes": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-4.3.2.tgz", + "integrity": "sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "type-fest": "^0.21.3" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/anymatch": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "dev": true, + "license": "ISC", + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/argparse": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", + "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", + "dev": true, + "license": "MIT", + "dependencies": { + "sprintf-js": "~1.0.2" + } + }, + "node_modules/babel-jest": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/babel-jest/-/babel-jest-29.7.0.tgz", + "integrity": "sha512-BrvGY3xZSwEcCzKvKsCi2GgHqDqsYkOP4/by5xCgIwGXQxIEh+8ew3gmrE1y7XRR6LHZIj6yLYnUi/mm2KXKBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/transform": "^29.7.0", + "@types/babel__core": "^7.1.14", + "babel-plugin-istanbul": "^6.1.1", + "babel-preset-jest": "^29.6.3", + "chalk": "^4.0.0", + "graceful-fs": "^4.2.9", + "slash": "^3.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + }, + "peerDependencies": { + "@babel/core": "^7.8.0" + } + }, + "node_modules/babel-plugin-istanbul": { + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/babel-plugin-istanbul/-/babel-plugin-istanbul-6.1.1.tgz", + "integrity": "sha512-Y1IQok9821cC9onCx5otgFfRm7Lm+I+wwxOx738M/WLPZ9Q42m4IG5W0FNX8WLL2gYMZo3JkuXIH2DOpWM+qwA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@babel/helper-plugin-utils": "^7.0.0", + "@istanbuljs/load-nyc-config": "^1.0.0", + "@istanbuljs/schema": "^0.1.2", + "istanbul-lib-instrument": "^5.0.4", + "test-exclude": "^6.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/babel-plugin-istanbul/node_modules/istanbul-lib-instrument": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/istanbul-lib-instrument/-/istanbul-lib-instrument-5.2.1.tgz", + "integrity": "sha512-pzqtp31nLv/XFOzXGuvhCb8qhjmTVo5vjVk19XE4CRlSWz0KoeJ3bw9XsA7nOp9YBf4qHjwBxkDzKcME/J29Yg==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@babel/core": "^7.12.3", + "@babel/parser": "^7.14.7", + "@istanbuljs/schema": "^0.1.2", + "istanbul-lib-coverage": "^3.2.0", + "semver": "^6.3.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/babel-plugin-jest-hoist": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/babel-plugin-jest-hoist/-/babel-plugin-jest-hoist-29.6.3.tgz", + "integrity": "sha512-ESAc/RJvGTFEzRwOTT4+lNDk/GNHMkKbNzsvT0qKRfDyyYTskxB5rnU2njIDYVxXCBHHEI1c0YwHob3WaYujOg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.3.3", + "@babel/types": "^7.3.3", + "@types/babel__core": "^7.1.14", + "@types/babel__traverse": "^7.0.6" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/babel-preset-current-node-syntax": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/babel-preset-current-node-syntax/-/babel-preset-current-node-syntax-1.2.0.tgz", + "integrity": "sha512-E/VlAEzRrsLEb2+dv8yp3bo4scof3l9nR4lrld+Iy5NyVqgVYUJnDAmunkhPMisRI32Qc4iRiz425d8vM++2fg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/plugin-syntax-async-generators": "^7.8.4", + "@babel/plugin-syntax-bigint": "^7.8.3", + "@babel/plugin-syntax-class-properties": "^7.12.13", + "@babel/plugin-syntax-class-static-block": "^7.14.5", + "@babel/plugin-syntax-import-attributes": "^7.24.7", + "@babel/plugin-syntax-import-meta": "^7.10.4", + "@babel/plugin-syntax-json-strings": "^7.8.3", + "@babel/plugin-syntax-logical-assignment-operators": "^7.10.4", + "@babel/plugin-syntax-nullish-coalescing-operator": "^7.8.3", + "@babel/plugin-syntax-numeric-separator": "^7.10.4", + "@babel/plugin-syntax-object-rest-spread": "^7.8.3", + "@babel/plugin-syntax-optional-catch-binding": "^7.8.3", + "@babel/plugin-syntax-optional-chaining": "^7.8.3", + "@babel/plugin-syntax-private-property-in-object": "^7.14.5", + "@babel/plugin-syntax-top-level-await": "^7.14.5" + }, + "peerDependencies": { + "@babel/core": "^7.0.0 || ^8.0.0-0" + } + }, + "node_modules/babel-preset-jest": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/babel-preset-jest/-/babel-preset-jest-29.6.3.tgz", + "integrity": "sha512-0B3bhxR6snWXJZtR/RliHTDPRgn1sNHOR0yVtq/IiQFyuOVjFS+wuio/R4gSNkyYmKmJB4wGZv2NZanmKmTnNA==", + "dev": true, + "license": "MIT", + "dependencies": { + "babel-plugin-jest-hoist": "^29.6.3", + "babel-preset-current-node-syntax": "^1.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/baseline-browser-mapping": { + "version": "2.10.18", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.18.tgz", + "integrity": "sha512-VSnGQAOLtP5mib/DPyg2/t+Tlv65NTBz83BJBJvmLVHHuKJVaDOBvJJykiT5TR++em5nfAySPccDZDa4oSrn8A==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/brace-expansion": { + "version": "1.1.14", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz", + "integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/browserslist": { + "version": "4.28.2", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz", + "integrity": "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.10.12", + "caniuse-lite": "^1.0.30001782", + "electron-to-chromium": "^1.5.328", + "node-releases": "^2.0.36", + "update-browserslist-db": "^1.2.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/bs-logger": { + "version": "0.2.6", + "resolved": "https://registry.npmjs.org/bs-logger/-/bs-logger-0.2.6.tgz", + "integrity": "sha512-pd8DCoxmbgc7hyPKOvxtqNcjYoOsABPQdcCUjGp3d42VR2CX1ORhk2A87oqqu5R1kk+76nsxZupkmyd+MVtCog==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-json-stable-stringify": "2.x" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/bser": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/bser/-/bser-2.1.1.tgz", + "integrity": "sha512-gQxTNE/GAfIIrmHLUE3oJyp5FO6HRBfhjnw4/wMmA63ZGDJnWBmgY/lyQBpnDUkGmAhbSe39tx2d/iTOAfglwQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "node-int64": "^0.4.0" + } + }, + "node_modules/buffer-from": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", + "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/camelcase": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz", + "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001787", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001787.tgz", + "integrity": "sha512-mNcrMN9KeI68u7muanUpEejSLghOKlVhRqS/Za2IeyGllJ9I9otGpR9g3nsw7n4W378TE/LyIteA0+/FOZm4Kg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/char-regex": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/char-regex/-/char-regex-1.0.2.tgz", + "integrity": "sha512-kWWXztvZ5SBQV+eRgKFeh8q5sLuZY2+8WUIzlxWVTg+oGwY14qylx1KbKzHd8P6ZYkAg0xyIDU9JMHhyJMZ1jw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/ci-info": { + "version": "3.9.0", + "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-3.9.0.tgz", + "integrity": "sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/sibiraj-s" + } + ], + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/cjs-module-lexer": { + "version": "1.4.3", + "resolved": "https://registry.npmjs.org/cjs-module-lexer/-/cjs-module-lexer-1.4.3.tgz", + "integrity": "sha512-9z8TZaGM1pfswYeXrUpzPrkx8UnWYdhJclsiYMm6x/w5+nN+8Tf/LnAgfLGQCm59qAOxU8WwHEq2vNwF6i4j+Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/cliui": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", + "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/co": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/co/-/co-4.6.0.tgz", + "integrity": "sha512-QVb0dM5HvG+uaxitm8wONl7jltx8dqhfU33DcqtOZcLSVIKSDDLDi7+0LbAKiyI8hD9u42m2YxXSkMGWThaecQ==", + "dev": true, + "license": "MIT", + "engines": { + "iojs": ">= 1.0.0", + "node": ">= 0.12.0" + } + }, + "node_modules/collect-v8-coverage": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/collect-v8-coverage/-/collect-v8-coverage-1.0.3.tgz", + "integrity": "sha512-1L5aqIkwPfiodaMgQunkF1zRhNqifHBmtbbbxcr6yVxxBnliw4TDOW6NxpO8DJLgJ16OT+Y4ztZqP6p/FtXnAw==", + "dev": true, + "license": "MIT" + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true, + "license": "MIT" + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/create-jest": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/create-jest/-/create-jest-29.7.0.tgz", + "integrity": "sha512-Adz2bdH0Vq3F53KEMJOoftQFutWCukm6J24wbPWRO4k1kMY7gS7ds/uoJkNuV8wDCtWWnuwGcJwpWcih+zEW1Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "^29.6.3", + "chalk": "^4.0.0", + "exit": "^0.1.2", + "graceful-fs": "^4.2.9", + "jest-config": "^29.7.0", + "jest-util": "^29.7.0", + "prompts": "^2.0.1" + }, + "bin": { + "create-jest": "bin/create-jest.js" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/dedent": { + "version": "1.7.2", + "resolved": "https://registry.npmjs.org/dedent/-/dedent-1.7.2.tgz", + "integrity": "sha512-WzMx3mW98SN+zn3hgemf4OzdmyNhhhKz5Ay0pUfQiMQ3e1g+xmTJWp/pKdwKVXhdSkAEGIIzqeuWrL3mV/AXbA==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "babel-plugin-macros": "^3.1.0" + }, + "peerDependenciesMeta": { + "babel-plugin-macros": { + "optional": true + } + } + }, + "node_modules/deepmerge": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", + "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/detect-newline": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/detect-newline/-/detect-newline-3.1.0.tgz", + "integrity": "sha512-TLz+x/vEXm/Y7P7wn1EJFNLxYpUD4TgMosxY6fAVJUnJMbupHBOncxyWUG9OpTaH9EBD7uFI5LfEgmMOc54DsA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/diff-sequences": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/diff-sequences/-/diff-sequences-29.6.3.tgz", + "integrity": "sha512-EjePK1srD3P08o2j4f0ExnylqRs5B9tJjcp9t1krH2qRi8CCdsYfwe9JgSLurFBWwq4uOlipzfk5fHNvwFKr8Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.335", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.335.tgz", + "integrity": "sha512-q9n5T4BR4Xwa2cwbrwcsDJtHD/enpQ5S1xF1IAtdqf5AAgqDFmR/aakqH3ChFdqd/QXJhS3rnnXFtexU7rax6Q==", + "dev": true, + "license": "ISC" + }, + "node_modules/emittery": { + "version": "0.13.1", + "resolved": "https://registry.npmjs.org/emittery/-/emittery-0.13.1.tgz", + "integrity": "sha512-DeWwawk6r5yR9jFgnDKYt4sLS0LmHJJi3ZOnb5/JdbYwj3nW+FxQnHIjhBKz8YLC7oRNPVM9NQ47I3CVx34eqQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sindresorhus/emittery?sponsor=1" + } + }, + "node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/error-ex": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.4.tgz", + "integrity": "sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-arrayish": "^0.2.1" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-string-regexp": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-2.0.0.tgz", + "integrity": "sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/esprima": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", + "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==", + "dev": true, + "license": "BSD-2-Clause", + "bin": { + "esparse": "bin/esparse.js", + "esvalidate": "bin/esvalidate.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/execa": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/execa/-/execa-5.1.1.tgz", + "integrity": "sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cross-spawn": "^7.0.3", + "get-stream": "^6.0.0", + "human-signals": "^2.1.0", + "is-stream": "^2.0.0", + "merge-stream": "^2.0.0", + "npm-run-path": "^4.0.1", + "onetime": "^5.1.2", + "signal-exit": "^3.0.3", + "strip-final-newline": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sindresorhus/execa?sponsor=1" + } + }, + "node_modules/exit": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/exit/-/exit-0.1.2.tgz", + "integrity": "sha512-Zk/eNKV2zbjpKzrsQ+n1G6poVbErQxJ0LBOJXaKZ1EViLzH+hrLu9cdXI4zw9dBQJslwBEpbQ2P1oS7nDxs6jQ==", + "dev": true, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/expect": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/expect/-/expect-29.7.0.tgz", + "integrity": "sha512-2Zks0hf1VLFYI1kbh0I5jP3KHHyCHpkfyHBzsSXRFgl/Bg9mWYfMW8oD+PdMPlEwy5HNsR9JutYy6pMeOh61nw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/expect-utils": "^29.7.0", + "jest-get-type": "^29.6.3", + "jest-matcher-utils": "^29.7.0", + "jest-message-util": "^29.7.0", + "jest-util": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fb-watchman": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/fb-watchman/-/fb-watchman-2.0.2.tgz", + "integrity": "sha512-p5161BqbuCaSnB8jIbzQHOlpgsPmK5rJVDfDKO91Axs5NC1uu3HRQm6wt9cd9/+GtQQIO53JdGXXoyDpTAsgYA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "bser": "2.1.1" + } + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/find-up": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", + "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^5.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/fs.realpath": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", + "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", + "dev": true, + "license": "ISC" + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "dev": true, + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, + "node_modules/get-package-type": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/get-package-type/-/get-package-type-0.1.0.tgz", + "integrity": "sha512-pjzuKtY64GYfWizNAJ0fr9VqttZkNiK2iS430LtIHzjBEr6bX8Am2zm4sW4Ro5wjWW5cAlRL1qAMTcXbjNAO2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/get-stream": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-6.0.1.tgz", + "integrity": "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "dev": true, + "license": "ISC", + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/handlebars": { + "version": "4.7.9", + "resolved": "https://registry.npmjs.org/handlebars/-/handlebars-4.7.9.tgz", + "integrity": "sha512-4E71E0rpOaQuJR2A3xDZ+GM1HyWYv1clR58tC8emQNeQe3RH7MAzSbat+V0wG78LQBo6m6bzSG/L4pBuCsgnUQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "minimist": "^1.2.5", + "neo-async": "^2.6.2", + "source-map": "^0.6.1", + "wordwrap": "^1.0.0" + }, + "bin": { + "handlebars": "bin/handlebars" + }, + "engines": { + "node": ">=0.4.7" + }, + "optionalDependencies": { + "uglify-js": "^3.1.4" + } + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/html-escaper": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", + "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", + "dev": true, + "license": "MIT" + }, + "node_modules/human-signals": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-2.1.0.tgz", + "integrity": "sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=10.17.0" + } + }, + "node_modules/import-local": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/import-local/-/import-local-3.2.0.tgz", + "integrity": "sha512-2SPlun1JUPWoM6t3F0dw0FkCF/jWY8kttcY4f599GLTSjh2OCuuhdTkJQsEcZzBqbXZGKMK2OqW1oZsjtf/gQA==", + "dev": true, + "license": "MIT", + "dependencies": { + "pkg-dir": "^4.2.0", + "resolve-cwd": "^3.0.0" + }, + "bin": { + "import-local-fixture": "fixtures/cli.js" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/inflight": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", + "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", + "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", + "dev": true, + "license": "ISC", + "dependencies": { + "once": "^1.3.0", + "wrappy": "1" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/is-arrayish": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", + "integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==", + "dev": true, + "license": "MIT" + }, + "node_modules/is-core-module": { + "version": "2.16.1", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz", + "integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==", + "dev": true, + "license": "MIT", + "dependencies": { + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-generator-fn": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-generator-fn/-/is-generator-fn-2.1.0.tgz", + "integrity": "sha512-cTIB4yPYL/Grw0EaSzASzg6bBy9gqCofvWN8okThAYIxKJZC+udlRAmGbM0XLeniEJSs8uEgHPGuHSe1XsOLSQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/is-stream": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", + "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/istanbul-lib-coverage": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", + "integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=8" + } + }, + "node_modules/istanbul-lib-instrument": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/istanbul-lib-instrument/-/istanbul-lib-instrument-6.0.3.tgz", + "integrity": "sha512-Vtgk7L/R2JHyyGW07spoFlB8/lpjiOLTjMdms6AFMraYt3BaJauod/NGrfnVG/y4Ix1JEuMRPDPEj2ua+zz1/Q==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@babel/core": "^7.23.9", + "@babel/parser": "^7.23.9", + "@istanbuljs/schema": "^0.1.3", + "istanbul-lib-coverage": "^3.2.0", + "semver": "^7.5.4" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-lib-instrument/node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-lib-report": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz", + "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "istanbul-lib-coverage": "^3.0.0", + "make-dir": "^4.0.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-lib-source-maps": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/istanbul-lib-source-maps/-/istanbul-lib-source-maps-4.0.1.tgz", + "integrity": "sha512-n3s8EwkdFIJCG3BPKBYvskgXGoy88ARzvegkitk60NxRdwltLOTaH7CUiMRXvwYorl0Q712iEjcWB+fK/MrWVw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "debug": "^4.1.1", + "istanbul-lib-coverage": "^3.0.0", + "source-map": "^0.6.1" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-reports": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz", + "integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "html-escaper": "^2.0.0", + "istanbul-lib-report": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/jest": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest/-/jest-29.7.0.tgz", + "integrity": "sha512-NIy3oAFp9shda19hy4HK0HRTWKtPJmGdnvywu01nOqNC2vZg+Z+fvJDxpMQA88eb2I9EcafcdjYgsDthnYTvGw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/core": "^29.7.0", + "@jest/types": "^29.6.3", + "import-local": "^3.0.2", + "jest-cli": "^29.7.0" + }, + "bin": { + "jest": "bin/jest.js" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + }, + "peerDependencies": { + "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" + }, + "peerDependenciesMeta": { + "node-notifier": { + "optional": true + } + } + }, + "node_modules/jest-changed-files": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-changed-files/-/jest-changed-files-29.7.0.tgz", + "integrity": "sha512-fEArFiwf1BpQ+4bXSprcDc3/x4HSzL4al2tozwVpDFpsxALjLYdyiIK4e5Vz66GQJIbXJ82+35PtysofptNX2w==", + "dev": true, + "license": "MIT", + "dependencies": { + "execa": "^5.0.0", + "jest-util": "^29.7.0", + "p-limit": "^3.1.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-circus": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-circus/-/jest-circus-29.7.0.tgz", + "integrity": "sha512-3E1nCMgipcTkCocFwM90XXQab9bS+GMsjdpmPrlelaxwD93Ad8iVEjX/vvHPdLPnFf+L40u+5+iutRdA1N9myw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/environment": "^29.7.0", + "@jest/expect": "^29.7.0", + "@jest/test-result": "^29.7.0", + "@jest/types": "^29.6.3", + "@types/node": "*", + "chalk": "^4.0.0", + "co": "^4.6.0", + "dedent": "^1.0.0", + "is-generator-fn": "^2.0.0", + "jest-each": "^29.7.0", + "jest-matcher-utils": "^29.7.0", + "jest-message-util": "^29.7.0", + "jest-runtime": "^29.7.0", + "jest-snapshot": "^29.7.0", + "jest-util": "^29.7.0", + "p-limit": "^3.1.0", + "pretty-format": "^29.7.0", + "pure-rand": "^6.0.0", + "slash": "^3.0.0", + "stack-utils": "^2.0.3" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-cli": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-cli/-/jest-cli-29.7.0.tgz", + "integrity": "sha512-OVVobw2IubN/GSYsxETi+gOe7Ka59EFMR/twOU3Jb2GnKKeMGJB5SGUUrEz3SFVmJASUdZUzy83sLNNQ2gZslg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/core": "^29.7.0", + "@jest/test-result": "^29.7.0", + "@jest/types": "^29.6.3", + "chalk": "^4.0.0", + "create-jest": "^29.7.0", + "exit": "^0.1.2", + "import-local": "^3.0.2", + "jest-config": "^29.7.0", + "jest-util": "^29.7.0", + "jest-validate": "^29.7.0", + "yargs": "^17.3.1" + }, + "bin": { + "jest": "bin/jest.js" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + }, + "peerDependencies": { + "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" + }, + "peerDependenciesMeta": { + "node-notifier": { + "optional": true + } + } + }, + "node_modules/jest-config": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-config/-/jest-config-29.7.0.tgz", + "integrity": "sha512-uXbpfeQ7R6TZBqI3/TxCU4q4ttk3u0PJeC+E0zbfSoSjq6bJ7buBPxzQPL0ifrkY4DNu4JUdk0ImlBUYi840eQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.11.6", + "@jest/test-sequencer": "^29.7.0", + "@jest/types": "^29.6.3", + "babel-jest": "^29.7.0", + "chalk": "^4.0.0", + "ci-info": "^3.2.0", + "deepmerge": "^4.2.2", + "glob": "^7.1.3", + "graceful-fs": "^4.2.9", + "jest-circus": "^29.7.0", + "jest-environment-node": "^29.7.0", + "jest-get-type": "^29.6.3", + "jest-regex-util": "^29.6.3", + "jest-resolve": "^29.7.0", + "jest-runner": "^29.7.0", + "jest-util": "^29.7.0", + "jest-validate": "^29.7.0", + "micromatch": "^4.0.4", + "parse-json": "^5.2.0", + "pretty-format": "^29.7.0", + "slash": "^3.0.0", + "strip-json-comments": "^3.1.1" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + }, + "peerDependencies": { + "@types/node": "*", + "ts-node": ">=9.0.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "ts-node": { + "optional": true + } + } + }, + "node_modules/jest-diff": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-diff/-/jest-diff-29.7.0.tgz", + "integrity": "sha512-LMIgiIrhigmPrs03JHpxUh2yISK3vLFPkAodPeo0+BuF7wA2FoQbkEg1u8gBYBThncu7e1oEDUfIXVuTqLRUjw==", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "^4.0.0", + "diff-sequences": "^29.6.3", + "jest-get-type": "^29.6.3", + "pretty-format": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-docblock": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-docblock/-/jest-docblock-29.7.0.tgz", + "integrity": "sha512-q617Auw3A612guyaFgsbFeYpNP5t2aoUNLwBUbc/0kD1R4t9ixDbyFTHd1nok4epoVFpr7PmeWHrhvuV3XaJ4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "detect-newline": "^3.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-each": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-each/-/jest-each-29.7.0.tgz", + "integrity": "sha512-gns+Er14+ZrEoC5fhOfYCY1LOHHr0TI+rQUHZS8Ttw2l7gl+80eHc/gFf2Ktkw0+SIACDTeWvpFcv3B04VembQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "^29.6.3", + "chalk": "^4.0.0", + "jest-get-type": "^29.6.3", + "jest-util": "^29.7.0", + "pretty-format": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-environment-node": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-environment-node/-/jest-environment-node-29.7.0.tgz", + "integrity": "sha512-DOSwCRqXirTOyheM+4d5YZOrWcdu0LNZ87ewUoywbcb2XR4wKgqiG8vNeYwhjFMbEkfju7wx2GYH0P2gevGvFw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/environment": "^29.7.0", + "@jest/fake-timers": "^29.7.0", + "@jest/types": "^29.6.3", + "@types/node": "*", + "jest-mock": "^29.7.0", + "jest-util": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-get-type": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/jest-get-type/-/jest-get-type-29.6.3.tgz", + "integrity": "sha512-zrteXnqYxfQh7l5FHyL38jL39di8H8rHoecLH3JNxH3BwOrBsNeabdap5e0I23lD4HHI8W5VFBZqG4Eaq5LNcw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-haste-map": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-haste-map/-/jest-haste-map-29.7.0.tgz", + "integrity": "sha512-fP8u2pyfqx0K1rGn1R9pyE0/KTn+G7PxktWidOBTqFPLYX0b9ksaMFkhK5vrS3DVun09pckLdlx90QthlW7AmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "^29.6.3", + "@types/graceful-fs": "^4.1.3", + "@types/node": "*", + "anymatch": "^3.0.3", + "fb-watchman": "^2.0.0", + "graceful-fs": "^4.2.9", + "jest-regex-util": "^29.6.3", + "jest-util": "^29.7.0", + "jest-worker": "^29.7.0", + "micromatch": "^4.0.4", + "walker": "^1.0.8" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + }, + "optionalDependencies": { + "fsevents": "^2.3.2" + } + }, + "node_modules/jest-leak-detector": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-leak-detector/-/jest-leak-detector-29.7.0.tgz", + "integrity": "sha512-kYA8IJcSYtST2BY9I+SMC32nDpBT3J2NvWJx8+JCuCdl/CR1I4EKUJROiP8XtCcxqgTTBGJNdbB1A8XRKbTetw==", + "dev": true, + "license": "MIT", + "dependencies": { + "jest-get-type": "^29.6.3", + "pretty-format": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-matcher-utils": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-matcher-utils/-/jest-matcher-utils-29.7.0.tgz", + "integrity": "sha512-sBkD+Xi9DtcChsI3L3u0+N0opgPYnCRPtGcQYrgXmR+hmt/fYfWAL0xRXYU8eWOdfuLgBe0YCW3AFtnRLagq/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "^4.0.0", + "jest-diff": "^29.7.0", + "jest-get-type": "^29.6.3", + "pretty-format": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-message-util": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-message-util/-/jest-message-util-29.7.0.tgz", + "integrity": "sha512-GBEV4GRADeP+qtB2+6u61stea8mGcOT4mCtrYISZwfu9/ISHFJ/5zOMXYbpBE9RsS5+Gb63DW4FgmnKJ79Kf6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.12.13", + "@jest/types": "^29.6.3", + "@types/stack-utils": "^2.0.0", + "chalk": "^4.0.0", + "graceful-fs": "^4.2.9", + "micromatch": "^4.0.4", + "pretty-format": "^29.7.0", + "slash": "^3.0.0", + "stack-utils": "^2.0.3" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-mock": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-mock/-/jest-mock-29.7.0.tgz", + "integrity": "sha512-ITOMZn+UkYS4ZFh83xYAOzWStloNzJFO2s8DWrE4lhtGD+AorgnbkiKERe4wQVBydIGPx059g6riW5Btp6Llnw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "^29.6.3", + "@types/node": "*", + "jest-util": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-pnp-resolver": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/jest-pnp-resolver/-/jest-pnp-resolver-1.2.3.tgz", + "integrity": "sha512-+3NpwQEnRoIBtx4fyhblQDPgJI0H1IEIkX7ShLUjPGA7TtUTvI1oiKi3SR4oBR0hQhQR80l4WAe5RrXBwWMA8w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + }, + "peerDependencies": { + "jest-resolve": "*" + }, + "peerDependenciesMeta": { + "jest-resolve": { + "optional": true + } + } + }, + "node_modules/jest-regex-util": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/jest-regex-util/-/jest-regex-util-29.6.3.tgz", + "integrity": "sha512-KJJBsRCyyLNWCNBOvZyRDnAIfUiRJ8v+hOBQYGn8gDyF3UegwiP4gwRR3/SDa42g1YbVycTidUF3rKjyLFDWbg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-resolve": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-resolve/-/jest-resolve-29.7.0.tgz", + "integrity": "sha512-IOVhZSrg+UvVAshDSDtHyFCCBUl/Q3AAJv8iZ6ZjnZ74xzvwuzLXid9IIIPgTnY62SJjfuupMKZsZQRsCvxEgA==", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "^4.0.0", + "graceful-fs": "^4.2.9", + "jest-haste-map": "^29.7.0", + "jest-pnp-resolver": "^1.2.2", + "jest-util": "^29.7.0", + "jest-validate": "^29.7.0", + "resolve": "^1.20.0", + "resolve.exports": "^2.0.0", + "slash": "^3.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-resolve-dependencies": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-resolve-dependencies/-/jest-resolve-dependencies-29.7.0.tgz", + "integrity": "sha512-un0zD/6qxJ+S0et7WxeI3H5XSe9lTBBR7bOHCHXkKR6luG5mwDDlIzVQ0V5cZCuoTgEdcdwzTghYkTWfubi+nA==", + "dev": true, + "license": "MIT", + "dependencies": { + "jest-regex-util": "^29.6.3", + "jest-snapshot": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-runner": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-runner/-/jest-runner-29.7.0.tgz", + "integrity": "sha512-fsc4N6cPCAahybGBfTRcq5wFR6fpLznMg47sY5aDpsoejOcVYFb07AHuSnR0liMcPTgBsA3ZJL6kFOjPdoNipQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/console": "^29.7.0", + "@jest/environment": "^29.7.0", + "@jest/test-result": "^29.7.0", + "@jest/transform": "^29.7.0", + "@jest/types": "^29.6.3", + "@types/node": "*", + "chalk": "^4.0.0", + "emittery": "^0.13.1", + "graceful-fs": "^4.2.9", + "jest-docblock": "^29.7.0", + "jest-environment-node": "^29.7.0", + "jest-haste-map": "^29.7.0", + "jest-leak-detector": "^29.7.0", + "jest-message-util": "^29.7.0", + "jest-resolve": "^29.7.0", + "jest-runtime": "^29.7.0", + "jest-util": "^29.7.0", + "jest-watcher": "^29.7.0", + "jest-worker": "^29.7.0", + "p-limit": "^3.1.0", + "source-map-support": "0.5.13" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-runtime": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-runtime/-/jest-runtime-29.7.0.tgz", + "integrity": "sha512-gUnLjgwdGqW7B4LvOIkbKs9WGbn+QLqRQQ9juC6HndeDiezIwhDP+mhMwHWCEcfQ5RUXa6OPnFF8BJh5xegwwQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/environment": "^29.7.0", + "@jest/fake-timers": "^29.7.0", + "@jest/globals": "^29.7.0", + "@jest/source-map": "^29.6.3", + "@jest/test-result": "^29.7.0", + "@jest/transform": "^29.7.0", + "@jest/types": "^29.6.3", + "@types/node": "*", + "chalk": "^4.0.0", + "cjs-module-lexer": "^1.0.0", + "collect-v8-coverage": "^1.0.0", + "glob": "^7.1.3", + "graceful-fs": "^4.2.9", + "jest-haste-map": "^29.7.0", + "jest-message-util": "^29.7.0", + "jest-mock": "^29.7.0", + "jest-regex-util": "^29.6.3", + "jest-resolve": "^29.7.0", + "jest-snapshot": "^29.7.0", + "jest-util": "^29.7.0", + "slash": "^3.0.0", + "strip-bom": "^4.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-snapshot": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-snapshot/-/jest-snapshot-29.7.0.tgz", + "integrity": "sha512-Rm0BMWtxBcioHr1/OX5YCP8Uov4riHvKPknOGs804Zg9JGZgmIBkbtlxJC/7Z4msKYVbIJtfU+tKb8xlYNfdkw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.11.6", + "@babel/generator": "^7.7.2", + "@babel/plugin-syntax-jsx": "^7.7.2", + "@babel/plugin-syntax-typescript": "^7.7.2", + "@babel/types": "^7.3.3", + "@jest/expect-utils": "^29.7.0", + "@jest/transform": "^29.7.0", + "@jest/types": "^29.6.3", + "babel-preset-current-node-syntax": "^1.0.0", + "chalk": "^4.0.0", + "expect": "^29.7.0", + "graceful-fs": "^4.2.9", + "jest-diff": "^29.7.0", + "jest-get-type": "^29.6.3", + "jest-matcher-utils": "^29.7.0", + "jest-message-util": "^29.7.0", + "jest-util": "^29.7.0", + "natural-compare": "^1.4.0", + "pretty-format": "^29.7.0", + "semver": "^7.5.3" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-snapshot/node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/jest-util": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-29.7.0.tgz", + "integrity": "sha512-z6EbKajIpqGKU56y5KBUgy1dt1ihhQJgWzUlZHArA/+X2ad7Cb5iF+AK1EWVL/Bo7Rz9uurpqw6SiBCefUbCGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "^29.6.3", + "@types/node": "*", + "chalk": "^4.0.0", + "ci-info": "^3.2.0", + "graceful-fs": "^4.2.9", + "picomatch": "^2.2.3" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-validate": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-validate/-/jest-validate-29.7.0.tgz", + "integrity": "sha512-ZB7wHqaRGVw/9hST/OuFUReG7M8vKeq0/J2egIGLdvjHCmYqGARhzXmtgi+gVeZ5uXFF219aOc3Ls2yLg27tkw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "^29.6.3", + "camelcase": "^6.2.0", + "chalk": "^4.0.0", + "jest-get-type": "^29.6.3", + "leven": "^3.1.0", + "pretty-format": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-validate/node_modules/camelcase": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-6.3.0.tgz", + "integrity": "sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/jest-watcher": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-watcher/-/jest-watcher-29.7.0.tgz", + "integrity": "sha512-49Fg7WXkU3Vl2h6LbLtMQ/HyB6rXSIX7SqvBLQmssRBGN9I0PNvPmAmCWSOY6SOvrjhI/F7/bGAv9RtnsPA03g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/test-result": "^29.7.0", + "@jest/types": "^29.6.3", + "@types/node": "*", + "ansi-escapes": "^4.2.1", + "chalk": "^4.0.0", + "emittery": "^0.13.1", + "jest-util": "^29.7.0", + "string-length": "^4.0.1" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-worker": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-29.7.0.tgz", + "integrity": "sha512-eIz2msL/EzL9UFTFFx7jBTkeZfku0yUAyZZZmJ93H2TYEiroIx2PQjEXcwYtYl8zXCxb+PAmA2hLIt/6ZEkPHw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "jest-util": "^29.7.0", + "merge-stream": "^2.0.0", + "supports-color": "^8.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-worker/node_modules/supports-color": { + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", + "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/js-yaml": { + "version": "3.14.2", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.2.tgz", + "integrity": "sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg==", + "dev": true, + "license": "MIT", + "dependencies": { + "argparse": "^1.0.7", + "esprima": "^4.0.0" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json-parse-even-better-errors": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", + "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==", + "dev": true, + "license": "MIT" + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/kleur": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/kleur/-/kleur-3.0.3.tgz", + "integrity": "sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/leven": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/leven/-/leven-3.1.0.tgz", + "integrity": "sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/lines-and-columns": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", + "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", + "dev": true, + "license": "MIT" + }, + "node_modules/locate-path": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", + "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^4.1.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/lodash.memoize": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/lodash.memoize/-/lodash.memoize-4.1.2.tgz", + "integrity": "sha512-t7j+NzmgnQzTAYXcsHYLgimltOV1MXHtlOWf6GjL9Kj8GK5FInw5JotxvbOs+IvV1/Dzo04/fCGfLVs7aXb4Ag==", + "dev": true, + "license": "MIT" + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/make-dir": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", + "integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.5.3" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/make-dir/node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/make-error": { + "version": "1.3.6", + "resolved": "https://registry.npmjs.org/make-error/-/make-error-1.3.6.tgz", + "integrity": "sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==", + "dev": true, + "license": "ISC" + }, + "node_modules/makeerror": { + "version": "1.0.12", + "resolved": "https://registry.npmjs.org/makeerror/-/makeerror-1.0.12.tgz", + "integrity": "sha512-JmqCvUhmt43madlpFzG4BQzG2Z3m6tvQDNKdClZnO3VbIudJYmxsT0FNJMeiB2+JTSlTQTSbU8QdesVmwJcmLg==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tmpl": "1.0.5" + } + }, + "node_modules/merge-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", + "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==", + "dev": true, + "license": "MIT" + }, + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/mimic-fn": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz", + "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, + "license": "MIT" + }, + "node_modules/neo-async": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/neo-async/-/neo-async-2.6.2.tgz", + "integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==", + "dev": true, + "license": "MIT" + }, + "node_modules/node-int64": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/node-int64/-/node-int64-0.4.0.tgz", + "integrity": "sha512-O5lz91xSOeoXP6DulyHfllpq+Eg00MWitZIbtPfoSEvqIHdl5gfcY6hYzDWnj0qD5tz52PI08u9qUvSVeUBeHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/node-releases": { + "version": "2.0.37", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.37.tgz", + "integrity": "sha512-1h5gKZCF+pO/o3Iqt5Jp7wc9rH3eJJ0+nh/CIoiRwjRxde/hAHyLPXYN4V3CqKAbiZPSeJFSWHmJsbkicta0Eg==", + "dev": true, + "license": "MIT" + }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/npm-run-path": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-4.0.1.tgz", + "integrity": "sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "dev": true, + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/onetime": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz", + "integrity": "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "mimic-fn": "^2.1.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", + "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^2.2.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/p-locate/node_modules/p-limit": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", + "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-try": "^2.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-try": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", + "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/parse-json": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz", + "integrity": "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.0.0", + "error-ex": "^1.3.1", + "json-parse-even-better-errors": "^2.3.0", + "lines-and-columns": "^1.1.6" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-is-absolute": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", + "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pirates": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz", + "integrity": "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/pkg-dir": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/pkg-dir/-/pkg-dir-4.2.0.tgz", + "integrity": "sha512-HRDzbaKjC+AOWVXxAU/x54COGeIv9eb+6CkDSQoNTt4XyWoIJvuPsXizxu/Fr23EiekbtZwmh1IcIG/l/a10GQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "find-up": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/pretty-format": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-29.7.0.tgz", + "integrity": "sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/schemas": "^29.6.3", + "ansi-styles": "^5.0.0", + "react-is": "^18.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/pretty-format/node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/prompts": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/prompts/-/prompts-2.4.2.tgz", + "integrity": "sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "kleur": "^3.0.3", + "sisteransi": "^1.0.5" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/pure-rand": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-6.1.0.tgz", + "integrity": "sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA==", + "dev": true, + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/dubzzz" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fast-check" + } + ], + "license": "MIT" + }, + "node_modules/react-is": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", + "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", + "dev": true, + "license": "MIT" + }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/resolve": { + "version": "1.22.12", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz", + "integrity": "sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "is-core-module": "^2.16.1", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/resolve-cwd": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/resolve-cwd/-/resolve-cwd-3.0.0.tgz", + "integrity": "sha512-OrZaX2Mb+rJCpH/6CpSqt9xFVpN++x01XnN2ie9g6P5/3xelLAkXWVADpdz1IHD/KFfEXyE6V0U01OQ3UO2rEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "resolve-from": "^5.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/resolve-from": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", + "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/resolve.exports": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/resolve.exports/-/resolve.exports-2.0.3.tgz", + "integrity": "sha512-OcXjMsGdhL4XnbShKpAcSqPMzQoYkYyhbEaeSko47MjRP9NfEQMhZkXL1DoFlt9LWQn4YttrdnV6X2OiyzBi+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/signal-exit": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/sisteransi": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/sisteransi/-/sisteransi-1.0.5.tgz", + "integrity": "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==", + "dev": true, + "license": "MIT" + }, + "node_modules/slash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", + "integrity": "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/source-map-support": { + "version": "0.5.13", + "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.13.tgz", + "integrity": "sha512-SHSKFHadjVA5oR4PPqhtAVdcBWwRYVd6g6cAXnIbRiIwc2EhPrTuKUBdSLvlEKyIP3GCf89fltvcZiP9MMFA1w==", + "dev": true, + "license": "MIT", + "dependencies": { + "buffer-from": "^1.0.0", + "source-map": "^0.6.0" + } + }, + "node_modules/sprintf-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz", + "integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/stack-utils": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/stack-utils/-/stack-utils-2.0.6.tgz", + "integrity": "sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "escape-string-regexp": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/string-length": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/string-length/-/string-length-4.0.2.tgz", + "integrity": "sha512-+l6rNN5fYHNhZZy41RXsYptCjA2Igmq4EG7kZAYFQI1E1VTXarr6ZPXBg6eq7Y6eK4FEhY6AJlyuFIb/v/S0VQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "char-regex": "^1.0.2", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-bom": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-4.0.0.tgz", + "integrity": "sha512-3xurFv5tEgii33Zi8Jtp55wEIILR9eh34FAW00PZf+JnSsTmV/ioewSgQl97JHvgjoRGwPShsWm+IdrxB35d0w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-final-newline": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-2.0.0.tgz", + "integrity": "sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/strip-json-comments": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", + "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/test-exclude": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-6.0.0.tgz", + "integrity": "sha512-cAGWPIyOHU6zlmg88jwm7VRyXnMN7iV68OGAbYDk/Mh/xC/pzVPlQtY6ngoIH/5/tciuhGfvESU8GrHrcxD56w==", + "dev": true, + "license": "ISC", + "dependencies": { + "@istanbuljs/schema": "^0.1.2", + "glob": "^7.1.4", + "minimatch": "^3.0.4" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/tmpl": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/tmpl/-/tmpl-1.0.5.tgz", + "integrity": "sha512-3f0uOEAQwIqGuWW2MVzYg8fV/QNnc/IpuJNG837rLuczAaLVHslWHZQj4IGiEl5Hs3kkbhwL9Ab7Hrsmuj+Smw==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/ts-jest": { + "version": "29.4.9", + "resolved": "https://registry.npmjs.org/ts-jest/-/ts-jest-29.4.9.tgz", + "integrity": "sha512-LTb9496gYPMCqjeDLdPrKuXtncudeV1yRZnF4Wo5l3SFi0RYEnYRNgMrFIdg+FHvfzjCyQk1cLncWVqiSX+EvQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "bs-logger": "^0.2.6", + "fast-json-stable-stringify": "^2.1.0", + "handlebars": "^4.7.9", + "json5": "^2.2.3", + "lodash.memoize": "^4.1.2", + "make-error": "^1.3.6", + "semver": "^7.7.4", + "type-fest": "^4.41.0", + "yargs-parser": "^21.1.1" + }, + "bin": { + "ts-jest": "cli.js" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || ^18.0.0 || >=20.0.0" + }, + "peerDependencies": { + "@babel/core": ">=7.0.0-beta.0 <8", + "@jest/transform": "^29.0.0 || ^30.0.0", + "@jest/types": "^29.0.0 || ^30.0.0", + "babel-jest": "^29.0.0 || ^30.0.0", + "jest": "^29.0.0 || ^30.0.0", + "jest-util": "^29.0.0 || ^30.0.0", + "typescript": ">=4.3 <7" + }, + "peerDependenciesMeta": { + "@babel/core": { + "optional": true + }, + "@jest/transform": { + "optional": true + }, + "@jest/types": { + "optional": true + }, + "babel-jest": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jest-util": { + "optional": true + } + } + }, + "node_modules/ts-jest/node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/ts-jest/node_modules/type-fest": { + "version": "4.41.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.41.0.tgz", + "integrity": "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/type-detect": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.0.8.tgz", + "integrity": "sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/type-fest": { + "version": "0.21.3", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.21.3.tgz", + "integrity": "sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w==", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/uglify-js": { + "version": "3.19.3", + "resolved": "https://registry.npmjs.org/uglify-js/-/uglify-js-3.19.3.tgz", + "integrity": "sha512-v3Xu+yuwBXisp6QYTcH4UbH+xYJXqnq2m/LtQVWKWzYc1iehYnLixoQDN9FH6/j9/oybfd6W9Ghwkl8+UMKTKQ==", + "dev": true, + "license": "BSD-2-Clause", + "optional": true, + "bin": { + "uglifyjs": "bin/uglifyjs" + }, + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/undici-types": { + "version": "7.19.2", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.19.2.tgz", + "integrity": "sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg==", + "dev": true, + "license": "MIT" + }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/v8-to-istanbul": { + "version": "9.3.0", + "resolved": "https://registry.npmjs.org/v8-to-istanbul/-/v8-to-istanbul-9.3.0.tgz", + "integrity": "sha512-kiGUalWN+rgBJ/1OHZsBtU4rXZOfj/7rKQxULKlIzwzQSvMJUUNgPwJEEh7gU6xEVxC0ahoOBvN2YI8GH6FNgA==", + "dev": true, + "license": "ISC", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.12", + "@types/istanbul-lib-coverage": "^2.0.1", + "convert-source-map": "^2.0.0" + }, + "engines": { + "node": ">=10.12.0" + } + }, + "node_modules/walker": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/walker/-/walker-1.0.8.tgz", + "integrity": "sha512-ts/8E8l5b7kY0vlWLewOkDXMmPdLcVV4GmOQLyxuSswIJsweeFZtAsMF7k1Nszz+TYBQrlYRmzOnr398y1JemQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "makeerror": "1.0.12" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/wordwrap": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/wordwrap/-/wordwrap-1.0.0.tgz", + "integrity": "sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/write-file-atomic": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/write-file-atomic/-/write-file-atomic-4.0.2.tgz", + "integrity": "sha512-7KxauUdBmSdWnmpaGFg+ppNjKF8uNLry8LyzjauQDOVONfFLNKrKvQOxZ/VuTIcS/gge/YNahf5RIIQWTSarlg==", + "dev": true, + "license": "ISC", + "dependencies": { + "imurmurhash": "^0.1.4", + "signal-exit": "^3.0.7" + }, + "engines": { + "node": "^12.13.0 || ^14.15.0 || >=16.0.0" + } + }, + "node_modules/y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=10" + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + }, + "node_modules/yargs": { + "version": "17.7.2", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", + "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "cliui": "^8.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.3", + "y18n": "^5.0.5", + "yargs-parser": "^21.1.1" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/yargs-parser": { + "version": "21.1.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + } + } +} diff --git a/packages/elm-to-sql/package.json b/packages/elm-to-sql/package.json new file mode 100644 index 0000000..ff2540f --- /dev/null +++ b/packages/elm-to-sql/package.json @@ -0,0 +1,42 @@ +{ + "name": "@cqframework/elm-to-sql", + "version": "0.1.0", + "description": "Standalone ESM library for transpiling CQL ELM to SQL-on-FHIR queries and generating FHIR MeasureReports", + "type": "module", + "main": "./dist/index.js", + "module": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "import": "./dist/index.js", + "types": "./dist/index.d.ts" + } + }, + "files": [ + "dist", + "LICENSE", + "README.md" + ], + "scripts": { + "build": "tsc", + "test": "node --experimental-vm-modules node_modules/jest/bin/jest.js", + "prepublishOnly": "npm run build" + }, + "keywords": [ + "cql", + "elm", + "fhir", + "sql-on-fhir", + "measure", + "hl7", + "clinical-quality", + "ecqm" + ], + "license": "Apache-2.0", + "devDependencies": { + "@types/jest": "^29.5.12", + "jest": "^29.7.0", + "ts-jest": "^29.1.5", + "typescript": "^5.4.5" + } +} diff --git a/packages/elm-to-sql/src/index.ts b/packages/elm-to-sql/src/index.ts new file mode 100644 index 0000000..b3d5787 --- /dev/null +++ b/packages/elm-to-sql/src/index.ts @@ -0,0 +1,79 @@ +/** + * @cqframework/elm-to-sql + * + * Standalone ESM library for transpiling CQL ELM (Expression Logical Model) + * to SQL-on-FHIR queries and generating FHIR MeasureReport resources. + * + * Usage: + * import { ElmToSqlTranspiler, generateMeasureReport } from '@cqframework/elm-to-sql'; + * + * const transpiler = new ElmToSqlTranspiler({ measurementPeriodStart: '2024-01-01', measurementPeriodEnd: '2024-12-31' }); + * const { sql, populations, warnings } = transpiler.transpile(elmLibraryJson); + * + * // Execute sql via your own DB adapter, then: + * const report = generateMeasureReport(counts, { measureUrl: '...', periodStart: '2024-01-01', periodEnd: '2024-12-31' }); + */ + +// Core transpiler +export { ElmToSqlTranspiler } from './transpiler/elm-to-sql.js'; +export type { TranspilerOptions, TranspileResult } from './transpiler/elm-to-sql.js'; + +// ELM types — re-exported for consumers building ELM inputs +export type { + ElmLibraryWrapper, + ElmLibrary, + ElmVersionedIdentifier, + ElmExpressionDef, + ElmExpression, + ElmRetrieve, + ElmQuery, + ElmBinaryOp, + ElmUnaryOp, + ElmFunctionRef, + ElmExpressionRef, + ElmLiteral, + ElmProperty, + ElmInterval, + ElmParameterRef, + ElmValueSetRef, + ElmValueSetDef, + ElmCodeSystemDef, +} from './types/elm.js'; +export { stripFhirNamespace, toSqlIdentifier } from './types/elm.js'; + +// MeasureReport generator +export { generateMeasureReport, sqlRowToPopulationCounts } from './measure/measure-report.js'; +export type { + PopulationCounts, + MeasureReportOptions, + FhirMeasureReport, + FhirMeasureReportGroup, + FhirMeasureReportPopulation, +} from './measure/measure-report.js'; + +// SQL-on-FHIR ViewDefinitions +export { + STANDARD_VIEW_DEFINITIONS, + viewDefinitionToSql, + generateAllViewsSql, +} from './views/view-definitions.js'; +export type { + ViewDefinition, + ViewDefinitionSelect, + ViewDefinitionColumn, + SqlViewDefinition, +} from './views/view-definitions.js'; + +// Value set utilities +export { extractValueSets, extractUsedValueSets } from './valueset/value-set-extractor.js'; +export type { ValueSetReference } from './valueset/value-set-extractor.js'; + +export { loadValueSetExpansions } from './valueset/value-set-loader.js'; +export type { ValueSetExpansionRow, ValueSetLoadResult } from './valueset/value-set-loader.js'; + +export { + generateValueSetTableDdl, + generateValueSetInsertSql, + generateValueSetUpsertSql, + generateValueSetSeedScript, +} from './valueset/value-set-sql.js'; diff --git a/packages/elm-to-sql/src/measure/measure-report.ts b/packages/elm-to-sql/src/measure/measure-report.ts new file mode 100644 index 0000000..20ec283 --- /dev/null +++ b/packages/elm-to-sql/src/measure/measure-report.ts @@ -0,0 +1,212 @@ +/** + * FHIR MeasureReport generator. + * + * Converts SQL population count results into a valid FHIR R4 MeasureReport resource. + * The caller is responsible for saving the report to the FHIR server via the API. + * + * Spec: https://www.hl7.org/fhir/measurereport.html + */ + +// ─── Input types ───────────────────────────────────────────────────────────── + +export interface PopulationCounts { + /** key: define name (e.g. "Initial Population"), value: patient count */ + [defineName: string]: number; +} + +export interface MeasureReportOptions { + /** FHIR canonical URL of the Measure resource. Required. */ + measureUrl: string; + /** Measurement period start (ISO 8601 date or dateTime). */ + periodStart: string; + /** Measurement period end (ISO 8601 date or dateTime). */ + periodEnd: string; + /** MeasureReport.type. Default: 'summary'. */ + type?: 'individual' | 'subject-list' | 'summary' | 'data-collection'; + /** MeasureReport.subject — reference to a Group or Patient. */ + subject?: { reference: string }; + /** Optional ID to assign. If omitted a UUID-like value is generated. */ + id?: string; + /** Optional reporter reference (Organization etc.). */ + reporter?: { reference: string }; + /** Optional: ISO date when the report was generated. Default: now. */ + date?: string; + /** Optional: group identifier for the main population group. */ + groupId?: string; +} + +// ─── Minimal FHIR R4 MeasureReport shape ───────────────────────────────────── + +export interface FhirMeasureReport { + resourceType: 'MeasureReport'; + id?: string; + meta?: { profile?: string[] }; + status: 'complete' | 'pending' | 'error'; + type: string; + measure: string; + subject?: { reference: string }; + date?: string; + reporter?: { reference: string }; + period: { start: string; end: string }; + group?: FhirMeasureReportGroup[]; +} + +export interface FhirMeasureReportGroup { + id?: string; + code?: FhirCodeableConcept; + population?: FhirMeasureReportPopulation[]; + measureScore?: FhirQuantity; + stratifier?: FhirMeasureReportStratifier[]; +} + +export interface FhirMeasureReportPopulation { + id?: string; + code: FhirCodeableConcept; + count: number; + subjectResults?: { reference: string }; +} + +export interface FhirMeasureReportStratifier { + id?: string; + code?: FhirCodeableConcept[]; + stratum?: FhirMeasureReportStratum[]; +} + +export interface FhirMeasureReportStratum { + value?: FhirCodeableConcept; + population?: FhirMeasureReportPopulation[]; + measureScore?: FhirQuantity; +} + +export interface FhirCodeableConcept { + coding?: Array<{ system?: string; code?: string; display?: string }>; + text?: string; +} + +export interface FhirQuantity { + value: number; + unit?: string; + system?: string; + code?: string; +} + +// ─── Population code map (eCQM standard) ───────────────────────────────────── + +const POPULATION_CODES: Record = { + 'Initial Population': { code: 'initial-population', display: 'Initial Population' }, + 'Denominator': { code: 'denominator', display: 'Denominator' }, + 'Denominator Exclusion': { code: 'denominator-exclusion', display: 'Denominator Exclusion' }, + 'Denominator Exception': { code: 'denominator-exception', display: 'Denominator Exception' }, + 'Numerator': { code: 'numerator', display: 'Numerator' }, + 'Numerator Exclusion': { code: 'numerator-exclusion', display: 'Numerator Exclusion' }, + 'Measure Population': { code: 'measure-population', display: 'Measure Population' }, + 'Measure Population Exclusion': { code: 'measure-population-exclusion', display: 'Measure Population Exclusion' }, +}; + +const MEASURE_POPULATION_SYSTEM = 'http://terminology.hl7.org/CodeSystem/measure-population'; + +// ─── MeasureReport generator ───────────────────────────────────────────────── + +/** + * Generate a FHIR MeasureReport from SQL population counts. + * + * @param counts Record mapping define names to row counts from SQL + * @param options Measure metadata (URL, period, type, etc.) + */ +export function generateMeasureReport( + counts: PopulationCounts, + options: MeasureReportOptions, +): FhirMeasureReport { + const id = options.id ?? generateId(); + const date = options.date ?? new Date().toISOString(); + const type = options.type ?? 'summary'; + + // Build population entries for known eCQM populations + const populations: FhirMeasureReportPopulation[] = []; + for (const [name, count] of Object.entries(counts)) { + const code = POPULATION_CODES[name]; + if (!code) continue; // Skip non-standard population names + + populations.push({ + code: { + coding: [{ system: MEASURE_POPULATION_SYSTEM, code: code.code, display: code.display }], + text: name, + }, + count, + }); + } + + // Calculate measure score (numerator / (denominator - exclusions)) + const measureScore = calculateMeasureScore(counts); + + const group: FhirMeasureReportGroup = { + ...(options.groupId ? { id: options.groupId } : {}), + population: populations.length > 0 ? populations : undefined, + ...(measureScore !== null ? { measureScore: { value: measureScore } } : {}), + }; + + const report: FhirMeasureReport = { + resourceType: 'MeasureReport', + id, + meta: { + profile: ['http://hl7.org/fhir/us/cqfmeasures/StructureDefinition/summary-measure-report-cqfm'], + }, + status: 'complete', + type, + measure: options.measureUrl, + date, + period: { + start: options.periodStart, + end: options.periodEnd, + }, + group: [group], + }; + + if (options.subject) report.subject = options.subject; + if (options.reporter) report.reporter = options.reporter; + + return report; +} + +// ─── Score calculation ──────────────────────────────────────────────────────── + +function calculateMeasureScore(counts: PopulationCounts): number | null { + const numerator = counts['Numerator'] ?? 0; + const denominator = counts['Denominator'] ?? 0; + const denomExclusion = counts['Denominator Exclusion'] ?? 0; + const denomException = counts['Denominator Exception'] ?? 0; + + const adjustedDenominator = denominator - denomExclusion - denomException; + if (adjustedDenominator <= 0) return null; + + return Math.round((numerator / adjustedDenominator) * 10000) / 10000; // 4 decimal places +} + +// ─── ID generation (no Node crypto needed) ─────────────────────────────────── + +function generateId(): string { + const hex = () => Math.floor(Math.random() * 0x10000).toString(16).padStart(4, '0'); + return `mr-${hex()}${hex()}-${hex()}-${hex()}-${hex()}-${hex()}${hex()}${hex()}`; +} + +// ─── Convenience: extract counts from SQL result rows ───────────────────────── + +/** + * Convert a flat SQL result row (from the final SELECT of a transpiled query) + * into a PopulationCounts map. + * + * Expected column naming convention: `{cte_name}_count`, e.g.: + * Initial_Population_count, Numerator_count, Denominator_count + */ +export function sqlRowToPopulationCounts(row: Record): PopulationCounts { + const counts: PopulationCounts = {}; + for (const [col, val] of Object.entries(row)) { + if (!col.endsWith('_count')) continue; + const defineName = col + .replace(/_count$/, '') + .replace(/_/g, ' ') + .replace(/\b\w/g, c => c.toUpperCase()); + counts[defineName] = typeof val === 'number' ? val : Number(val ?? 0); + } + return counts; +} diff --git a/packages/elm-to-sql/src/transpiler/elm-to-sql.ts b/packages/elm-to-sql/src/transpiler/elm-to-sql.ts new file mode 100644 index 0000000..b9b0308 --- /dev/null +++ b/packages/elm-to-sql/src/transpiler/elm-to-sql.ts @@ -0,0 +1,735 @@ +/** + * ElmToSqlTranspiler + * + * Converts an HL7 ELM JSON Library (as produced by @cqframework/cql) to a + * SQL-on-FHIR query using Common Table Expressions (CTEs). + * + * Pipeline: CQL → ELM (via @cqframework/cql) → SQL (this library) + * + * Each CQL `define` statement becomes a CTE. The final SELECT returns + * population counts suitable for building a FHIR MeasureReport. + */ + +import type { + ElmLibrary, + ElmLibraryWrapper, + ElmExpression, + ElmExpressionDef, + ElmBinaryOp, + ElmUnaryOp, + ElmFunctionRef, + ElmExpressionRef, + ElmLiteral, + ElmProperty, + ElmRetrieve, + ElmQuery, + ElmRelationshipClause, + ElmInterval, + ElmParameterRef, + ElmValueSetRef, + ElmIf, + ElmCase, + ElmAggregate, + ElmStart, + ElmEnd, + ElmDurationBetween, +} from '../types/elm.js'; +import { stripFhirNamespace, toSqlIdentifier } from '../types/elm.js'; + +// ─── Public types ──────────────────────────────────────────────────────────── + +export interface TranspilerOptions { + /** Measurement period start (ISO 8601). Default: current year Jan 1. */ + measurementPeriodStart?: string; + /** Measurement period end (ISO 8601). Default: current year Dec 31. */ + measurementPeriodEnd?: string; + /** Emit SQL comments explaining each CTE. Default: true. */ + includeComments?: boolean; + /** + * Names of define statements to include in the final output SELECT. + * If omitted, the transpiler auto-detects common measure population names. + */ + populationDefines?: string[]; +} + +export interface TranspileResult { + /** The generated SQL string. */ + sql: string; + /** Names of the population CTEs found (Initial Population, Numerator, etc.). */ + populations: string[]; + /** Warnings generated during transpilation. */ + warnings: string[]; +} + +// ─── Well-known population define names (eCQM convention) ──────────────────── + +const POPULATION_NAMES = [ + 'Initial Population', + 'Denominator', + 'Denominator Exclusion', + 'Denominator Exception', + 'Numerator', + 'Numerator Exclusion', + 'Measure Population', + 'Measure Population Exclusion', + 'Measure Observation', + 'Stratification', +]; + +// ─── FHIR resource → SQL view name map ─────────────────────────────────────── + +const RESOURCE_VIEW_MAP: Record = { + Patient: 'patient_view', + Observation: 'observation_view', + Condition: 'condition_view', + Procedure: 'procedure_view', + MedicationRequest: 'medication_request_view', + Encounter: 'encounter_view', + DiagnosticReport: 'diagnostic_report_view', + Coverage: 'coverage_view', + AllergyIntolerance: 'allergy_intolerance_view', + Immunization: 'immunization_view', + DeviceRequest: 'device_request_view', + CommunicationRequest: 'communication_request_view', + ServiceRequest: 'service_request_view', + Claim: 'claim_view', +}; + +// ─── Transpiler ────────────────────────────────────────────────────────────── + +export class ElmToSqlTranspiler { + private opts: Required; + private warnings: string[] = []; + private defines = new Map(); + private valueSets = new Map(); // name → OID/URL + private codeSystems = new Map(); // name → URI + + constructor(options: TranspilerOptions = {}) { + const now = new Date(); + const year = now.getFullYear(); + this.opts = { + measurementPeriodStart: options.measurementPeriodStart ?? `${year}-01-01T00:00:00Z`, + measurementPeriodEnd: options.measurementPeriodEnd ?? `${year}-12-31T23:59:59Z`, + includeComments: options.includeComments ?? true, + populationDefines: options.populationDefines ?? [], + }; + } + + // ─── Public entry point ──────────────────────────────────────────────────── + + transpile(input: ElmLibraryWrapper | ElmLibrary): TranspileResult { + this.warnings = []; + this.defines.clear(); + this.valueSets.clear(); + this.codeSystems.clear(); + + const lib: ElmLibrary = 'library' in input ? input.library : input; + + // Index value sets and code systems for IN-clause generation + for (const vs of lib.valueSets?.def ?? []) { + this.valueSets.set(vs.name, vs.id); + } + for (const cs of lib.codeSystems?.def ?? []) { + this.codeSystems.set(cs.name, cs.id); + } + + // Index all defines + for (const def of lib.statements?.def ?? []) { + this.defines.set(def.name, def); + } + + // Topological sort so CTEs reference only already-defined CTEs + const sorted = this.topologicalSort(lib.statements?.def ?? []); + + const ctes: string[] = []; + const populations: string[] = []; + + for (const def of sorted) { + if (def.accessLevel === 'Private') continue; + const cteSql = this.generateCte(def); + if (cteSql) ctes.push(cteSql); + + if (this.isPopulation(def.name)) { + populations.push(def.name); + } + } + + // Detect populations to expose in the final SELECT + const outputPops = + this.opts.populationDefines.length > 0 + ? this.opts.populationDefines + : populations.length > 0 + ? populations + : this.inferPopulations(sorted); + + const finalSelect = this.generateFinalSelect(outputPops); + + const libId = `${lib.identifier.id}${lib.identifier.version ? ` v${lib.identifier.version}` : ''}`; + const header = this.opts.includeComments + ? `-- SQL-on-FHIR query generated from ELM library: ${libId}\n` + + `-- Measurement Period: ${this.opts.measurementPeriodStart} – ${this.opts.measurementPeriodEnd}\n` + + `-- Generated by @cqframework/elm-to-sql\n\n` + : ''; + + const sql = `${header}WITH\n${ctes.join(',\n\n')}\n\n${finalSelect}`; + + return { sql, populations: outputPops, warnings: [...this.warnings] }; + } + + // ─── CTE generation ──────────────────────────────────────────────────────── + + private generateCte(def: ElmExpressionDef): string { + const cteName = toSqlIdentifier(def.name); + let body: string; + + try { + body = this.exprToSql(def.expression, def.context ?? 'Patient'); + } catch (e) { + const msg = e instanceof Error ? e.message : String(e); + this.warn(`Could not transpile define "${def.name}": ${msg}`); + body = `SELECT NULL AS _unsupported -- ${msg}`; + } + + const comment = this.opts.includeComments ? ` -- define "${def.name}"\n` : ''; + return `${cteName} AS (\n${comment}${this.indent(body)}\n)`; + } + + // ─── Expression dispatch ─────────────────────────────────────────────────── + + private exprToSql(expr: ElmExpression, context: string): string { + switch (expr.type) { + case 'Retrieve': return this.retrieveToSql(expr as ElmRetrieve, context); + case 'Query': return this.queryToSql(expr as ElmQuery, context); + case 'ExpressionRef': return this.expressionRefToSql(expr as ElmExpressionRef); + case 'FunctionRef': return this.functionRefToSql(expr as ElmFunctionRef, context); + case 'ParameterRef': return this.parameterRefToSql(expr as ElmParameterRef); + case 'ValueSetRef': return this.valueSetRefToSql(expr as ElmValueSetRef); + case 'Property': return this.propertyToSql(expr as ElmProperty); + case 'Literal': return this.literalToSql(expr as ElmLiteral); + case 'Null': return 'NULL'; + case 'Interval': return this.intervalToSql(expr as ElmInterval); + case 'If': return this.ifToSql(expr as ElmIf, context); + case 'Case': return this.caseToSql(expr as ElmCase, context); + case 'Start': return `${this.exprToSql((expr as ElmStart).operand, context)}_start`; + case 'End': return `${this.exprToSql((expr as ElmEnd).operand, context)}_end`; + case 'Today': return 'CURRENT_DATE'; + case 'Now': return 'CURRENT_TIMESTAMP'; + case 'Exists': return this.existsToSql((expr as ElmUnaryOp).operand, context); + case 'Not': return `NOT (${this.exprToSqlInline((expr as ElmUnaryOp).operand, context)})`; + case 'IsNull': return `(${this.exprToSqlInline((expr as ElmUnaryOp).operand, context)}) IS NULL`; + case 'IsTrue': return `(${this.exprToSqlInline((expr as ElmUnaryOp).operand, context)}) IS TRUE`; + case 'IsFalse': return `(${this.exprToSqlInline((expr as ElmUnaryOp).operand, context)}) IS FALSE`; + case 'Count': return this.aggregateToSql(expr as ElmAggregate, 'COUNT', context); + case 'Sum': return this.aggregateToSql(expr as ElmAggregate, 'SUM', context); + case 'Min': return this.aggregateToSql(expr as ElmAggregate, 'MIN', context); + case 'Max': return this.aggregateToSql(expr as ElmAggregate, 'MAX', context); + case 'Avg': return this.aggregateToSql(expr as ElmAggregate, 'AVG', context); + case 'DurationBetween': return this.durationBetweenToSql(expr as ElmDurationBetween, context); + case 'And': + case 'Or': + case 'Xor': + return this.booleanOpToSql(expr as ElmBinaryOp, context); + case 'Equal': return this.comparisonToSql(expr as ElmBinaryOp, '=', context); + case 'NotEqual': return this.comparisonToSql(expr as ElmBinaryOp, '<>', context); + case 'Less': return this.comparisonToSql(expr as ElmBinaryOp, '<', context); + case 'Greater': return this.comparisonToSql(expr as ElmBinaryOp, '>', context); + case 'LessOrEqual': return this.comparisonToSql(expr as ElmBinaryOp, '<=', context); + case 'GreaterOrEqual': return this.comparisonToSql(expr as ElmBinaryOp, '>=', context); + case 'In': + case 'IncludedIn': + case 'During': return this.inToSql(expr as ElmBinaryOp, context); + case 'Contains': return this.containsToSql(expr as ElmBinaryOp, context); + case 'Add': return this.arithmeticToSql(expr as ElmBinaryOp, '+', context); + case 'Subtract': return this.arithmeticToSql(expr as ElmBinaryOp, '-', context); + case 'Multiply': return this.arithmeticToSql(expr as ElmBinaryOp, '*', context); + case 'Divide': return this.arithmeticToSql(expr as ElmBinaryOp, '/', context); + case 'Union': return this.setOpToSql('UNION ALL', (expr as { operand: ElmExpression[] }).operand, context); + case 'Intersect': return this.setOpToSql('INTERSECT', (expr as { operand: ElmExpression[] }).operand, context); + case 'Except': return this.setOpToSql('EXCEPT', (expr as { operand: ElmExpression[] }).operand, context); + case 'Distinct': return `SELECT DISTINCT * FROM (${this.exprToSqlInline((expr as unknown as ElmUnaryOp).operand, context)}) _d`; + case 'Flatten': return this.exprToSql((expr as unknown as ElmUnaryOp).operand, context); + case 'As': return this.exprToSql((expr as { operand: ElmExpression }).operand, context); + case 'ToList': return this.exprToSql((expr as ElmUnaryOp).operand, context); + case 'SingletonFrom': return `SELECT * FROM (${this.exprToSqlInline((expr as ElmUnaryOp).operand, context)}) _s LIMIT 1`; + case 'First': return `SELECT * FROM (${this.exprToSqlInline((expr as { source: ElmExpression }).source, context)}) _f LIMIT 1`; + case 'Last': return `SELECT * FROM (${this.exprToSqlInline((expr as { source: ElmExpression }).source, context)}) _l ORDER BY 1 DESC LIMIT 1`; + case 'List': return this.listToSql((expr as { element?: ElmExpression[] }).element ?? [], context); + case 'AnyTrue': return `(SELECT bool_or(val) FROM (${this.exprToSqlInline((expr as ElmUnaryOp).operand, context)}) _a(val))`; + case 'AllTrue': return `(SELECT bool_and(val) FROM (${this.exprToSqlInline((expr as ElmUnaryOp).operand, context)}) _a(val))`; + default: + this.warn(`Unsupported ELM expression type: ${(expr as { type: string }).type}`); + return `NULL -- unsupported: ${(expr as { type: string }).type}`; + } + } + + /** Returns a SQL expression suitable for inlining (subquery or scalar). */ + private exprToSqlInline(expr: ElmExpression, context: string): string { + const s = this.exprToSql(expr, context); + // If it looks like a full SELECT statement, wrap as subquery + if (/^\s*SELECT\b/i.test(s)) return `(${s})`; + return s; + } + + // ─── Retrieve → SELECT FROM view ────────────────────────────────────────── + + private retrieveToSql(expr: ElmRetrieve, _context: string): string { + const resource = stripFhirNamespace(expr.dataType); + const view = RESOURCE_VIEW_MAP[resource] ?? `${resource.toLowerCase()}_view`; + + const lines: string[] = [`SELECT * FROM ${view}`]; + + if (expr.codes) { + const codeFilter = this.codeFilterToSql(expr.codes, resource); + if (codeFilter) lines.push(`WHERE ${codeFilter}`); + } + + return lines.join('\n'); + } + + private codeFilterToSql(codesExpr: ElmExpression, _resource: string): string { + if (codesExpr.type === 'ValueSetRef') { + const ref = codesExpr as ElmValueSetRef; + const oid = this.valueSets.get(ref.name); + // Standard SQL-on-FHIR code column + return oid + ? `code IN (SELECT code FROM value_set_expansion WHERE value_set_id = '${oid}')` + : `code_system IS NOT NULL -- value set: ${ref.name}`; + } + if (codesExpr.type === 'List') { + const list = codesExpr as { element?: ElmExpression[] }; + const codes = (list.element ?? []) + .filter(e => e.type === 'Literal' || (e as unknown as { id?: string }).id !== undefined) + .map(e => { + if (e.type === 'Literal') return `'${(e as ElmLiteral).value}'`; + return `'${(e as unknown as { id?: string }).id ?? ''}'`; + }); + return codes.length > 0 ? `code IN (${codes.join(', ')})` : ''; + } + return ''; + } + + // ─── Query → SELECT/WHERE/JOIN ──────────────────────────────────────────── + + private queryToSql(expr: ElmQuery, context: string): string { + if (expr.source.length === 0) return 'SELECT NULL'; + + const [primarySource, ...additionalSources] = expr.source; + const alias = primarySource.alias; + const fromSql = this.exprToSqlInline(primarySource.expression, context); + + const parts: string[] = []; + + // Build SELECT clause + let selectExpr = '*'; + if (expr.return) { + selectExpr = this.exprToSqlInline(expr.return.expression, context); + if (selectExpr === '*' || /\bSELECT\b/i.test(selectExpr)) { + selectExpr = `${alias}.*`; + } + } + + // Distinct + const distinct = expr.return?.distinct === true ? 'DISTINCT ' : ''; + parts.push(`SELECT ${distinct}${selectExpr}`); + parts.push(`FROM ${fromSql} AS ${alias}`); + + // Additional sources as CROSS JOIN + for (const src of additionalSources) { + const srcSql = this.exprToSqlInline(src.expression, context); + parts.push(`CROSS JOIN ${srcSql} AS ${src.alias}`); + } + + // WITH relationships (semi-joins) + for (const rel of expr.relationship ?? []) { + parts.push(this.relationshipToSql(rel, alias, context)); + } + + // WHERE clause + if (expr.where) { + const whereSql = this.exprToSqlInline(expr.where, context); + parts.push(`WHERE ${whereSql}`); + } + + // Sort + if (expr.sort) { + const orderParts = expr.sort.by.map(b => { + const dir = b.direction === 'desc' ? 'DESC' : 'ASC'; + if (b.type === 'ByColumn' && b.path) return `${b.path} ${dir}`; + if (b.type === 'ByExpression' && b.expression) { + return `${this.exprToSqlInline(b.expression, context)} ${dir}`; + } + return `1 ${dir}`; + }); + if (orderParts.length > 0) parts.push(`ORDER BY ${orderParts.join(', ')}`); + } + + return parts.join('\n'); + } + + private relationshipToSql(rel: ElmRelationshipClause, parentAlias: string, context: string): string { + const relView = this.exprToSqlInline(rel.expression, context); + const suchThat = rel.suchThat + ? `AND ${this.exprToSqlInline(rel.suchThat, context)}` + : ''; + const keyword = rel.type === 'With' ? 'EXISTS' : 'NOT EXISTS'; + return `AND ${keyword} (\n SELECT 1 FROM ${relView} AS ${rel.alias}\n WHERE ${rel.alias}.subject_id = ${parentAlias}.id ${suchThat}\n)`; + } + + // ─── Expression references ───────────────────────────────────────────────── + + private expressionRefToSql(expr: ElmExpressionRef): string { + if (expr.libraryName) { + this.warn(`Cross-library ExpressionRef "${expr.libraryName}.${expr.name}" — treating as local`); + } + return `SELECT * FROM ${toSqlIdentifier(expr.name)}`; + } + + // ─── Function references (AgeInYearsAt, CalculateAgeInYearsAt, etc.) ────── + + private functionRefToSql(expr: ElmFunctionRef, context: string): string { + const fn = expr.name; + const ops = expr.operand ?? []; + + switch (fn) { + case 'AgeInYearsAt': + case 'CalculateAgeInYearsAt': { + const dateArg = ops[0] ? this.exprToSqlInline(ops[0], context) : 'CURRENT_DATE'; + return `DATE_PART('year', AGE(${dateArg}, p.birthdate))`; + } + case 'AgeInMonthsAt': { + const dateArg = ops[0] ? this.exprToSqlInline(ops[0], context) : 'CURRENT_DATE'; + return `(DATE_PART('year', AGE(${dateArg}, p.birthdate)) * 12 + DATE_PART('month', AGE(${dateArg}, p.birthdate)))`; + } + case 'AgeInDaysAt': { + const dateArg = ops[0] ? this.exprToSqlInline(ops[0], context) : 'CURRENT_DATE'; + return `(${dateArg}::date - p.birthdate::date)`; + } + case 'ToDate': + case 'date': + return ops[0] ? `(${this.exprToSqlInline(ops[0], context)})::date` : 'CURRENT_DATE'; + case 'ToDateTime': + case 'datetime': + return ops[0] ? `(${this.exprToSqlInline(ops[0], context)})::timestamp` : 'CURRENT_TIMESTAMP'; + case 'start of': + case 'Start': + return ops[0] ? `${this.exprToSqlInline(ops[0], context)}_start` : 'NULL'; + case 'end of': + case 'End': + return ops[0] ? `${this.exprToSqlInline(ops[0], context)}_end` : 'NULL'; + case 'ToString': + return ops[0] ? `(${this.exprToSqlInline(ops[0], context)})::text` : 'NULL'; + case 'ToInteger': + return ops[0] ? `(${this.exprToSqlInline(ops[0], context)})::integer` : 'NULL'; + case 'ToDecimal': + return ops[0] ? `(${this.exprToSqlInline(ops[0], context)})::decimal` : 'NULL'; + case 'Coalesce': { + const args = ops.map(o => this.exprToSqlInline(o, context)).join(', '); + return `COALESCE(${args})`; + } + case 'Lower': + return ops[0] ? `LOWER(${this.exprToSqlInline(ops[0], context)})` : 'NULL'; + case 'Upper': + return ops[0] ? `UPPER(${this.exprToSqlInline(ops[0], context)})` : 'NULL'; + case 'Length': + return ops[0] ? `LENGTH(${this.exprToSqlInline(ops[0], context)})` : 'NULL'; + case 'Substring': + if (ops.length >= 2) { + const str = this.exprToSqlInline(ops[0], context); + const start = this.exprToSqlInline(ops[1], context); + const len = ops[2] ? `, ${this.exprToSqlInline(ops[2], context)}` : ''; + return `SUBSTRING(${str}, ${start}${len})`; + } + return 'NULL'; + default: + this.warn(`Unsupported FunctionRef: ${fn}`); + return `NULL -- FunctionRef:${fn}`; + } + } + + // ─── Parameter references ────────────────────────────────────────────────── + + private parameterRefToSql(expr: ElmParameterRef): string { + if (expr.name === 'Measurement Period') { + // Return as an interval literal for use in comparisons + return `tsrange('${this.opts.measurementPeriodStart}', '${this.opts.measurementPeriodEnd}', '[)')`; + } + this.warn(`Unresolved ParameterRef: ${expr.name}`); + return `NULL -- ParameterRef:${expr.name}`; + } + + // ─── ValueSet references ────────────────────────────────────────────────── + + private valueSetRefToSql(expr: ElmValueSetRef): string { + const oid = this.valueSets.get(expr.name); + return oid ? `'${oid}'` : `'${expr.name}'`; + } + + // ─── Property access ────────────────────────────────────────────────────── + + private propertyToSql(expr: ElmProperty): string { + const path = this.normalizePath(expr.path); + if (expr.scope) return `${expr.scope}.${path}`; + if (expr.source) { + const src = expr.source; + if (src.type === 'ExpressionRef') return `${toSqlIdentifier((src as ElmExpressionRef).name)}.${path}`; + if (src.type === 'Property') return `${this.propertyToSql(src as ElmProperty)}_${path}`; + } + return path; + } + + /** Map common FHIR/ELM path names to SQL-on-FHIR column names */ + private normalizePath(path: string): string { + const map: Record = { + birthDate: 'birthdate', + gender: 'gender', + id: 'id', + status: 'status', + 'code.coding': 'code', + 'code.coding.code': 'code', + 'code.coding.system': 'code_system', + 'code.text': 'code_text', + 'onset.value': 'onset_datetime', + onsetDateTime: 'onset_datetime', + performedDateTime: 'performed_datetime', + 'effective.value': 'effective_datetime', + effectiveDateTime: 'effective_datetime', + 'value.value': 'value_quantity', + authoredOn: 'authored_on', + 'period.start': 'period_start', + 'period.end': 'period_end', + }; + return map[path] ?? path.replace(/\./g, '_'); + } + + // ─── Literals ──────────────────────────────────────────────────────────── + + private literalToSql(expr: ElmLiteral): string { + const t = expr.valueType?.replace(/.*}/, '') ?? ''; + const v = expr.value; + if (t === 'Boolean') return v === 'true' ? 'TRUE' : 'FALSE'; + if (t === 'Integer' || t === 'Decimal') return String(v); + // Date/DateTime/Time → cast + if (t === 'Date') return `DATE '${v}'`; + if (t === 'DateTime') return `TIMESTAMP '${v}'`; + // Default: string literal + return `'${String(v).replace(/'/g, "''")}'`; + } + + // ─── Interval ───────────────────────────────────────────────────────────── + + private intervalToSql(expr: ElmInterval): string { + const lo = expr.low ? this.exprToSqlInline(expr.low, 'Patient') : 'NULL'; + const hi = expr.high ? this.exprToSqlInline(expr.high, 'Patient') : 'NULL'; + const lBracket = expr.lowClosed !== false ? '[' : '('; + const rBracket = expr.highClosed !== false ? ']' : ')'; + return `tsrange(${lo}, ${hi}, '${lBracket}${rBracket}')`; + } + + // ─── Boolean operators ──────────────────────────────────────────────────── + + private booleanOpToSql(expr: ElmBinaryOp, context: string): string { + const [left, right] = expr.operand; + const l = this.exprToSqlInline(left, context); + const r = this.exprToSqlInline(right, context); + const op = expr.type === 'And' ? 'AND' : expr.type === 'Or' ? 'OR' : 'OR'; // XOR not standard SQL + return `(${l} ${op} ${r})`; + } + + // ─── Comparisons ───────────────────────────────────────────────────────── + + private comparisonToSql(expr: ElmBinaryOp, op: string, context: string): string { + const [left, right] = expr.operand; + const l = this.exprToSqlInline(left, context); + const r = this.exprToSqlInline(right, context); + return `${l} ${op} ${r}`; + } + + // ─── Arithmetic ─────────────────────────────────────────────────────────── + + private arithmeticToSql(expr: ElmBinaryOp, op: string, context: string): string { + const [left, right] = expr.operand; + const l = this.exprToSqlInline(left, context); + const r = this.exprToSqlInline(right, context); + return `(${l} ${op} ${r})`; + } + + // ─── In / During / IncludedIn ───────────────────────────────────────────── + + private inToSql(expr: ElmBinaryOp, context: string): string { + const [left, right] = expr.operand; + const l = this.exprToSqlInline(left, context); + const r = this.exprToSqlInline(right, context); + + // If right side is a tsrange, use @> operator + if (right.type === 'ParameterRef' || right.type === 'Interval') { + return `${r} @> ${l}::timestamptz`; + } + // ValueSet membership + if (right.type === 'ValueSetRef') { + const vs = right as ElmValueSetRef; + const oid = this.valueSets.get(vs.name); + return oid + ? `${l} IN (SELECT code FROM value_set_expansion WHERE value_set_id = '${oid}')` + : `TRUE -- in value set: ${vs.name}`; + } + // List membership + if (right.type === 'List') { + return `${l} IN (${r})`; + } + return `${l} IN (${r})`; + } + + // ─── Contains ──────────────────────────────────────────────────────────── + + private containsToSql(expr: ElmBinaryOp, context: string): string { + // Reverse of In + const [left, right] = expr.operand; + const l = this.exprToSqlInline(left, context); + const r = this.exprToSqlInline(right, context); + return `${l} @> ARRAY[${r}]`; + } + + // ─── Exists ─────────────────────────────────────────────────────────────── + + private existsToSql(operand: ElmExpression, context: string): string { + const inner = this.exprToSqlInline(operand, context); + if (/^\s*SELECT\b/i.test(inner)) return `EXISTS (${inner})`; + return `EXISTS (SELECT 1 FROM (${inner}) _e)`; + } + + // ─── If/Case ───────────────────────────────────────────────────────────── + + private ifToSql(expr: ElmIf, context: string): string { + const cond = this.exprToSqlInline(expr.condition, context); + const then = this.exprToSqlInline(expr.then, context); + const els = this.exprToSqlInline(expr.else, context); + return `CASE WHEN ${cond} THEN ${then} ELSE ${els} END`; + } + + private caseToSql(expr: ElmCase, context: string): string { + const comparand = expr.comparand + ? ` ${this.exprToSqlInline(expr.comparand, context)}` + : ''; + const items = expr.caseItem + .map(ci => { + const w = this.exprToSqlInline(ci.when, context); + const t = this.exprToSqlInline(ci.then, context); + return `WHEN ${w} THEN ${t}`; + }) + .join(' '); + const els = this.exprToSqlInline(expr.else, context); + return `CASE${comparand} ${items} ELSE ${els} END`; + } + + // ─── Aggregates ─────────────────────────────────────────────────────────── + + private aggregateToSql(expr: ElmAggregate, fn: string, context: string): string { + const src = this.exprToSqlInline(expr.source, context); + const col = expr.path ? expr.path : '*'; + return `(SELECT ${fn}(${col}) FROM (${src}) _agg)`; + } + + // ─── DurationBetween ────────────────────────────────────────────────────── + + private durationBetweenToSql(expr: ElmDurationBetween, context: string): string { + const [start, end] = expr.operand; + const s = this.exprToSqlInline(start, context); + const e = this.exprToSqlInline(end, context); + const precision = (expr.precision ?? 'year').toLowerCase(); + const pgPrecision = precision === 'year' ? 'year' : precision === 'month' ? 'month' : 'day'; + return `DATE_PART('${pgPrecision}', AGE(${e}::timestamp, ${s}::timestamp))`; + } + + // ─── Set operations ─────────────────────────────────────────────────────── + + private setOpToSql(op: string, operands: ElmExpression[], context: string): string { + return operands + .map(o => { + const s = this.exprToSql(o, context); + return /^\s*SELECT\b/i.test(s) ? s : `SELECT * FROM (${s}) _u`; + }) + .join(`\n${op}\n`); + } + + // ─── List ───────────────────────────────────────────────────────────────── + + private listToSql(elements: ElmExpression[], context: string): string { + if (elements.length === 0) return 'SELECT NULL LIMIT 0'; + const vals = elements.map(e => `(${this.exprToSqlInline(e, context)})`).join(', '); + return `VALUES ${vals}`; + } + + // ─── Final SELECT ───────────────────────────────────────────────────────── + + private generateFinalSelect(populations: string[]): string { + if (populations.length === 0) { + return 'SELECT COUNT(*) AS patient_count FROM patient_view'; + } + + const cols = populations.map(p => { + const cte = toSqlIdentifier(p); + return ` (SELECT COUNT(*) FROM ${cte}) AS ${cte}_count`; + }); + + return `SELECT\n${cols.join(',\n')}`; + } + + // ─── Helpers ────────────────────────────────────────────────────────────── + + private isPopulation(name: string): boolean { + return POPULATION_NAMES.some(p => p.toLowerCase() === name.toLowerCase()); + } + + private inferPopulations(defs: ElmExpressionDef[]): string[] { + return defs + .filter(d => this.isPopulation(d.name)) + .map(d => d.name); + } + + private topologicalSort(defs: ElmExpressionDef[]): ElmExpressionDef[] { + const nameSet = new Set(defs.map(d => d.name)); + const visited = new Set(); + const result: ElmExpressionDef[] = []; + const defMap = new Map(defs.map(d => [d.name, d])); + + const visit = (name: string) => { + if (visited.has(name)) return; + visited.add(name); + const def = defMap.get(name); + if (!def) return; + // Find dependencies + const deps = this.collectRefs(def.expression, nameSet); + for (const dep of deps) visit(dep); + result.push(def); + }; + + for (const def of defs) visit(def.name); + return result; + } + + private collectRefs(expr: ElmExpression, nameSet: Set): string[] { + const refs: string[] = []; + const walk = (e: ElmExpression) => { + if (!e || typeof e !== 'object') return; + if (e.type === 'ExpressionRef') { + const ref = (e as ElmExpressionRef).name; + if (nameSet.has(ref)) refs.push(ref); + } + for (const val of Object.values(e)) { + if (Array.isArray(val)) val.forEach(v => v && typeof v === 'object' && 'type' in v && walk(v as ElmExpression)); + else if (val && typeof val === 'object' && 'type' in val) walk(val as ElmExpression); + } + }; + walk(expr); + return refs; + } + + private indent(sql: string, spaces = 2): string { + const pad = ' '.repeat(spaces); + return sql.split('\n').map(l => `${pad}${l}`).join('\n'); + } + + private warn(msg: string): void { + this.warnings.push(msg); + } +} diff --git a/packages/elm-to-sql/src/types/elm.ts b/packages/elm-to-sql/src/types/elm.ts new file mode 100644 index 0000000..42340f5 --- /dev/null +++ b/packages/elm-to-sql/src/types/elm.ts @@ -0,0 +1,572 @@ +/** + * HL7 ELM (Expression Logical Model) JSON type definitions. + * + * These match the JSON output of the @cqframework/cql cql-to-elm translator + * (translator.toJson() or the parsed XML form). The ELM spec is at: + * https://cql.hl7.org/elm.html + * + * Top-level shape: { library: ElmLibrary } + */ + +// ─── Library ──────────────────────────────────────────────────────────────── + +export interface ElmLibraryWrapper { + library: ElmLibrary; +} + +export interface ElmLibrary { + identifier: ElmVersionedIdentifier; + schemaIdentifier: ElmVersionedIdentifier; + annotation?: ElmAnnotation[]; + usings?: { def?: ElmUsingDef[] }; + includes?: { def?: ElmIncludeDef[] }; + parameters?: { def?: ElmParameterDef[] }; + codeSystems?: { def?: ElmCodeSystemDef[] }; + valueSets?: { def?: ElmValueSetDef[] }; + codes?: { def?: ElmCodeDef[] }; + concepts?: { def?: ElmConceptDef[] }; + statements?: { def?: ElmExpressionDef[] }; +} + +export interface ElmVersionedIdentifier { + id: string; + system?: string; + version?: string; +} + +export interface ElmAnnotation { + type: string; + s?: { r?: string; t?: string }; +} + +// ─── Definitions ───────────────────────────────────────────────────────────── + +export interface ElmUsingDef { + localIdentifier: string; + uri: string; + version?: string; +} + +export interface ElmIncludeDef { + localIdentifier: string; + path: string; + version?: string; +} + +export interface ElmParameterDef { + name: string; + accessLevel?: 'Public' | 'Private'; + default?: ElmExpression; + parameterTypeSpecifier?: ElmTypeSpecifier; +} + +export interface ElmCodeSystemDef { + name: string; + id: string; + version?: string; + accessLevel?: 'Public' | 'Private'; +} + +export interface ElmValueSetDef { + name: string; + id: string; + version?: string; + accessLevel?: 'Public' | 'Private'; +} + +export interface ElmCodeDef { + name: string; + id: string; + codeSystem: { name: string }; + display?: string; + accessLevel?: 'Public' | 'Private'; +} + +export interface ElmConceptDef { + name: string; + display?: string; + code?: Array<{ name: string }>; + accessLevel?: 'Public' | 'Private'; +} + +export interface ElmExpressionDef { + name: string; + context?: string; + accessLevel?: 'Public' | 'Private'; + expression: ElmExpression; + annotation?: ElmAnnotation[]; +} + +// ─── Type Specifiers ───────────────────────────────────────────────────────── + +export type ElmTypeSpecifier = + | ElmNamedTypeSpecifier + | ElmListTypeSpecifier + | ElmIntervalTypeSpecifier + | ElmTupleTypeSpecifier + | ElmChoiceTypeSpecifier; + +export interface ElmNamedTypeSpecifier { + type: 'NamedTypeSpecifier'; + name: string; + modelName?: string; +} + +export interface ElmListTypeSpecifier { + type: 'ListTypeSpecifier'; + elementType: ElmTypeSpecifier; +} + +export interface ElmIntervalTypeSpecifier { + type: 'IntervalTypeSpecifier'; + pointType: ElmTypeSpecifier; +} + +export interface ElmTupleTypeSpecifier { + type: 'TupleTypeSpecifier'; + element: Array<{ name: string; type: ElmTypeSpecifier }>; +} + +export interface ElmChoiceTypeSpecifier { + type: 'ChoiceTypeSpecifier'; + choice: ElmTypeSpecifier[]; +} + +// ─── Expressions ───────────────────────────────────────────────────────────── + +export type ElmExpression = + | ElmLiteral + | ElmNull + | ElmProperty + | ElmRetrieve + | ElmQuery + | ElmExpressionRef + | ElmFunctionRef + | ElmParameterRef + | ElmValueSetRef + | ElmCodeSystemRef + | ElmBinaryOp + | ElmUnaryOp + | ElmNaryOp + | ElmInterval + | ElmList + | ElmTuple + | ElmIf + | ElmCase + | ElmAs + | ElmConvert + | ElmAggregate + | ElmDate + | ElmDateTime + | ElmTime + | ElmDurationBetween + | ElmDateTimeComponentFrom + | ElmStart + | ElmEnd + | ElmToday + | ElmNow + | ElmCollapse + | ElmExpand + | ElmUnion + | ElmIntersect + | ElmExcept + | ElmDistinct + | ElmFlatte + | ElmFirst + | ElmLast + | ElmIndexOf + | ElmSlice + | ElmSplit + | ElmConcatenate + | ElmMessage; + +// Literals & null +export interface ElmLiteral { + type: 'Literal'; + valueType: string; // '{urn:hl7-org:elm-types:r1}Integer', etc. + value: string; + resultTypeName?: string; +} + +export interface ElmNull { + type: 'Null'; + resultTypeName?: string; +} + +// Property access +export interface ElmProperty { + type: 'Property'; + source?: ElmExpression; + path: string; + scope?: string; + resultTypeName?: string; +} + +// FHIR resource retrieval +export interface ElmRetrieve { + type: 'Retrieve'; + dataType: string; // '{http://hl7.org/fhir}Patient' + templateId?: string; + codeProperty?: string; + codes?: ElmExpression; + dateProperty?: string; + dateRange?: ElmExpression; + resultTypeSpecifier?: ElmTypeSpecifier; +} + +// Query (CQL from/where/return) +export interface ElmQuery { + type: 'Query'; + source: ElmAliasedQuerySource[]; + let?: ElmLetClause[]; + relationship?: ElmRelationshipClause[]; + where?: ElmExpression; + return?: ElmReturnClause; + aggregate?: ElmAggregateClause; + sort?: ElmSortClause; +} + +export interface ElmAliasedQuerySource { + alias: string; + expression: ElmExpression; + resultTypeSpecifier?: ElmTypeSpecifier; +} + +export interface ElmLetClause { + identifier: string; + expression: ElmExpression; +} + +export interface ElmRelationshipClause { + type: 'With' | 'Without'; + alias: string; + expression: ElmExpression; + suchThat?: ElmExpression; +} + +export interface ElmReturnClause { + distinct?: boolean; + expression: ElmExpression; +} + +export interface ElmAggregateClause { + identifier: string; + expression: ElmExpression; + starting?: ElmExpression; + distinct?: boolean; +} + +export interface ElmSortClause { + by: ElmSortByItem[]; +} + +export interface ElmSortByItem { + type: 'ByExpression' | 'ByColumn' | 'ByDirection'; + direction?: 'asc' | 'desc'; + path?: string; + expression?: ElmExpression; +} + +// References +export interface ElmExpressionRef { + type: 'ExpressionRef'; + name: string; + libraryName?: string; + resultTypeName?: string; +} + +export interface ElmFunctionRef { + type: 'FunctionRef'; + name: string; + libraryName?: string; + operand?: ElmExpression[]; + resultTypeName?: string; +} + +export interface ElmParameterRef { + type: 'ParameterRef'; + name: string; + resultTypeName?: string; +} + +export interface ElmValueSetRef { + type: 'ValueSetRef'; + name: string; + libraryName?: string; + preserve?: boolean; +} + +export interface ElmCodeSystemRef { + type: 'CodeSystemRef'; + name: string; + libraryName?: string; +} + +// Binary operators +export type ElmBinaryOpType = + | 'And' | 'Or' | 'Xor' | 'Implies' + | 'Equal' | 'NotEqual' | 'Equivalent' | 'Not' + | 'Less' | 'Greater' | 'LessOrEqual' | 'GreaterOrEqual' + | 'Add' | 'Subtract' | 'Multiply' | 'Divide' | 'Modulo' | 'TruncatedDivide' + | 'In' | 'Contains' | 'ProperIn' | 'ProperContains' + | 'IncludedIn' | 'Includes' | 'ProperIncludedIn' | 'ProperIncludes' + | 'During' | 'Before' | 'After' | 'SameAs' | 'SameOrBefore' | 'SameOrAfter' + | 'Overlaps' | 'OverlapsBefore' | 'OverlapsAfter' + | 'Starts' | 'Ends' | 'Meets' | 'MeetsBefore' | 'MeetsAfter' + | 'Substring' | 'StartsWith' | 'EndsWith' | 'Matches' + | 'Power' | 'Log' + | 'Coalesce'; + +export interface ElmBinaryOp { + type: ElmBinaryOpType; + operand: [ElmExpression, ElmExpression]; + resultTypeName?: string; + precision?: string; +} + +// Unary operators +export type ElmUnaryOpType = + | 'Not' | 'Exists' | 'IsNull' | 'IsTrue' | 'IsFalse' + | 'Negate' | 'Predecessor' | 'Successor' + | 'Abs' | 'Ceiling' | 'Floor' | 'Truncate' | 'Round' + | 'Ln' | 'Exp' + | 'Length' | 'Upper' | 'Lower' | 'PositionOf' + | 'AllTrue' | 'AnyTrue' + | 'Count' | 'Sum' | 'Min' | 'Max' | 'Avg' | 'Median' | 'Mode' | 'StdDev' | 'Variance' + | 'PopulationStdDev' | 'PopulationVariance' | 'GeometricMean' + | 'Width' | 'Size' + | 'IsList' | 'IsInterval' + | 'SingletonFrom' + | 'ToBoolean' | 'ToDate' | 'ToDateTime' | 'ToDecimal' | 'ToInteger' | 'ToLong' | 'ToString' | 'ToTime' | 'ToQuantity' | 'ToConcept' | 'ToRatio' | 'ToList'; + +export interface ElmUnaryOp { + type: ElmUnaryOpType; + operand: ElmExpression; + resultTypeName?: string; + precision?: string; +} + +// N-ary operators +export interface ElmNaryOp { + type: 'Coalesce' | 'Concatenate' | 'Combine'; + operand: ElmExpression[]; + resultTypeName?: string; +} + +// Interval +export interface ElmInterval { + type: 'Interval'; + low?: ElmExpression; + high?: ElmExpression; + lowClosed?: boolean; + highClosed?: boolean; + lowClosedExpression?: ElmExpression; + highClosedExpression?: ElmExpression; +} + +// List +export interface ElmList { + type: 'List'; + element?: ElmExpression[]; + resultTypeSpecifier?: ElmTypeSpecifier; +} + +// Tuple +export interface ElmTuple { + type: 'Tuple'; + element: Array<{ name: string; value: ElmExpression }>; +} + +// If-then-else +export interface ElmIf { + type: 'If'; + condition: ElmExpression; + then: ElmExpression; + else: ElmExpression; +} + +// Case +export interface ElmCase { + type: 'Case'; + comparand?: ElmExpression; + caseItem: Array<{ when: ElmExpression; then: ElmExpression }>; + else: ElmExpression; +} + +// Type casting +export interface ElmAs { + type: 'As'; + operand: ElmExpression; + asType?: string; + asTypeSpecifier?: ElmTypeSpecifier; + strict?: boolean; +} + +export interface ElmConvert { + type: 'Convert'; + operand: ElmExpression; + toType?: string; + toTypeSpecifier?: ElmTypeSpecifier; +} + +// Aggregates (used standalone, not as unary ops) +export interface ElmAggregate { + type: 'Count' | 'Sum' | 'Min' | 'Max' | 'Avg' | 'Median' | 'Mode'; + source: ElmExpression; + path?: string; + resultTypeName?: string; +} + +// Date/time constructors +export interface ElmDate { + type: 'Date'; + year: ElmExpression; + month?: ElmExpression; + day?: ElmExpression; +} + +export interface ElmDateTime { + type: 'DateTime'; + year: ElmExpression; + month?: ElmExpression; + day?: ElmExpression; + hour?: ElmExpression; + minute?: ElmExpression; + second?: ElmExpression; + millisecond?: ElmExpression; + timezoneOffset?: ElmExpression; +} + +export interface ElmTime { + type: 'Time'; + hour: ElmExpression; + minute?: ElmExpression; + second?: ElmExpression; + millisecond?: ElmExpression; +} + +export interface ElmDurationBetween { + type: 'DurationBetween'; + precision: string; + operand: [ElmExpression, ElmExpression]; +} + +export interface ElmDateTimeComponentFrom { + type: 'DateTimeComponentFrom'; + precision: string; + operand: ElmExpression; +} + +export interface ElmStart { + type: 'Start'; + operand: ElmExpression; +} + +export interface ElmEnd { + type: 'End'; + operand: ElmExpression; +} + +export interface ElmToday { + type: 'Today'; +} + +export interface ElmNow { + type: 'Now'; +} + +// Set/list operations +export interface ElmCollapse { + type: 'Collapse'; + operand: ElmExpression[]; +} + +export interface ElmExpand { + type: 'Expand'; + operand: ElmExpression[]; +} + +export interface ElmUnion { + type: 'Union'; + operand: ElmExpression[]; +} + +export interface ElmIntersect { + type: 'Intersect'; + operand: ElmExpression[]; +} + +export interface ElmExcept { + type: 'Except'; + operand: ElmExpression[]; +} + +export interface ElmDistinct { + type: 'Distinct'; + operand: ElmExpression; +} + +export interface ElmFlatte { + type: 'Flatten'; + operand: ElmExpression; +} + +export interface ElmFirst { + type: 'First'; + source: ElmExpression; + orderBy?: string; +} + +export interface ElmLast { + type: 'Last'; + source: ElmExpression; + orderBy?: string; +} + +export interface ElmIndexOf { + type: 'IndexOf'; + source: ElmExpression; + element: ElmExpression; +} + +export interface ElmSlice { + type: 'Slice'; + source: ElmExpression; + startIndex: ElmExpression; + endIndex: ElmExpression; +} + +export interface ElmSplit { + type: 'Split'; + stringToSplit: ElmExpression; + separator: ElmExpression; +} + +export interface ElmConcatenate { + type: 'Concatenate'; + operand: ElmExpression[]; +} + +export interface ElmMessage { + type: 'Message'; + source: ElmExpression; + condition: ElmExpression; + code: ElmExpression; + severity: ElmExpression; + message: ElmExpression; +} + +// ─── Helpers ───────────────────────────────────────────────────────────────── + +/** Strip the FHIR model namespace, e.g. '{http://hl7.org/fhir}Patient' → 'Patient' */ +export function stripFhirNamespace(dataType: string): string { + return dataType.replace(/^\{[^}]+\}/, ''); +} + +/** Normalize a CQL/ELM name to a SQL-safe identifier */ +export function toSqlIdentifier(name: string): string { + return name + .replace(/\s+/g, '_') + .replace(/[^A-Za-z0-9_]/g, '_') + .replace(/^(\d)/, '_$1'); +} diff --git a/packages/elm-to-sql/src/valueset/value-set-extractor.ts b/packages/elm-to-sql/src/valueset/value-set-extractor.ts new file mode 100644 index 0000000..a69048b --- /dev/null +++ b/packages/elm-to-sql/src/valueset/value-set-extractor.ts @@ -0,0 +1,85 @@ +/** + * Value Set Extractor + * + * Reads the `valueSets.def` section of an ELM library and returns a flat list + * of { name, url } references — one entry per `valueset` declaration in the + * original CQL source. + * + * These references are the canonical URLs (or OID URIs) that the transpiler + * embeds in `value_set_expansion` lookup subqueries: + * code IN (SELECT code FROM value_set_expansion WHERE value_set_id = '') + */ + +import type { ElmLibrary, ElmLibraryWrapper, ElmValueSetDef } from '../types/elm.js'; + +// ─── Public types ───────────────────────────────────────────────────────────── + +/** A single value set reference as declared in a CQL/ELM library. */ +export interface ValueSetReference { + /** CQL local name, e.g. "Office Visit" */ + name: string; + /** + * Canonical URL or OID URI, e.g. + * "http://cts.nlm.nih.gov/fhir/ValueSet/2.16.840.1.113883.3.464.1003.101.12.1001" + * + * This is the `value_set_id` column in `value_set_expansion`. + */ + url: string; + /** Optional version constraint from the CQL `valueset` declaration. */ + version?: string; +} + +// ─── Implementation ─────────────────────────────────────────────────────────── + +function resolveLibrary(input: ElmLibraryWrapper | ElmLibrary): ElmLibrary { + return 'library' in input ? (input as ElmLibraryWrapper).library : (input as ElmLibrary); +} + +/** + * Extract all value set references declared in an ELM library. + * + * Returns one entry per `valueset` declaration, in declaration order. + * Duplicate URLs (same set referenced under different aliases) are preserved. + * + * @example + * const refs = extractValueSets(elmJson); + * // [{ name: 'Office Visit', url: 'http://cts.nlm.nih.gov/fhir/ValueSet/...' }, ...] + */ +export function extractValueSets(input: ElmLibraryWrapper | ElmLibrary): ValueSetReference[] { + const lib = resolveLibrary(input); + const defs: ElmValueSetDef[] = lib.valueSets?.def ?? []; + return defs.map(d => ({ + name: d.name, + url: d.id, + ...(d.version ? { version: d.version } : {}), + })); +} + +/** + * Returns only the value set references that are actually used (referenced via + * `ValueSetRef`) in the library's statements. Useful for trimming the list + * before loading — avoids fetching sets declared but not referenced in this + * specific library. + * + * Scans the raw ELM JSON text for `"name":""` patterns under a + * `ValueSetRef` parent. Simple string scan rather than AST walk — fast and + * sufficient for determining presence. + */ +export function extractUsedValueSets(input: ElmLibraryWrapper | ElmLibrary): ValueSetReference[] { + const all = extractValueSets(input); + if (all.length === 0) return []; + + // Serialise statements portion only for the scan + const lib = resolveLibrary(input); + const statementsJson = JSON.stringify(lib.statements ?? {}); + + return all.filter(ref => { + // Look for any ValueSetRef whose name matches this declaration + const escaped = ref.name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + const pattern = new RegExp( + `"type"\\s*:\\s*"ValueSetRef"[^}]*?"name"\\s*:\\s*"${escaped}"` + + `|"name"\\s*:\\s*"${escaped}"[^}]*?"type"\\s*:\\s*"ValueSetRef"`, + ); + return pattern.test(statementsJson); + }); +} diff --git a/packages/elm-to-sql/src/valueset/value-set-loader.ts b/packages/elm-to-sql/src/valueset/value-set-loader.ts new file mode 100644 index 0000000..dcb3a4e --- /dev/null +++ b/packages/elm-to-sql/src/valueset/value-set-loader.ts @@ -0,0 +1,205 @@ +/** + * Value Set Loader + * + * Fetches pre-expanded (or expandable) ValueSet resources from a FHIR R4 + * server and flattens them into rows that match the `value_set_expansion` + * view schema used by the elm-to-sql transpiler. + * + * Design principles: + * - Zero Node.js runtime dependencies — accepts a fetch-compatible function + * so the same code works in browsers, Deno, Bun, and Node 18+. + * - Non-throwing per value set — individual failures are captured in + * `ValueSetLoadResult.error` rather than rejecting the whole batch. + * - Tries the FHIR $expand operation first; falls back to reading the stored + * resource directly (works when the ValueSet is already pre-expanded). + */ + +import type { ValueSetReference } from './value-set-extractor.js'; + +// ─── Public types ───────────────────────────────────────────────────────────── + +/** + * A single row in the `value_set_expansion` table/view. + * Schema matches `scripts/hapi-fhir-sql-on-fhir/views/008_value_set_expansion_view.sql`. + */ +export interface ValueSetExpansionRow { + /** Canonical URL of the ValueSet — matches `value_set_id` in the view. */ + value_set_id: string; + /** FHIR concept code, e.g. "73761001" */ + code: string; + /** Code system URI, e.g. "http://snomed.info/sct" */ + system: string; + /** Human-readable display label (may be undefined for codes without display). */ + display?: string; + /** Code system version (may be undefined). */ + version?: string; +} + +/** Result of loading one value set from the FHIR server. */ +export interface ValueSetLoadResult { + /** CQL local name, e.g. "Office Visit" */ + name: string; + /** Canonical URL used to fetch the value set. */ + url: string; + /** Flattened expansion rows — empty if loading failed. */ + rows: ValueSetExpansionRow[]; + /** Set when loading or parsing failed. The result is still returned (not thrown). */ + error?: string; +} + +// ─── Minimal FHIR ValueSet shape (expansion only) ──────────────────────────── + +interface FhirValueSetContains { + system?: string; + code?: string; + display?: string; + version?: string; + contains?: FhirValueSetContains[]; // nested hierarchy — flattened recursively +} + +interface FhirValueSetExpansion { + total?: number; + contains?: FhirValueSetContains[]; +} + +interface FhirValueSet { + resourceType: 'ValueSet'; + url?: string; + expansion?: FhirValueSetExpansion; +} + +// ─── Implementation ─────────────────────────────────────────────────────────── + +/** + * Recursively flatten `expansion.contains[]`, including nested hierarchies. + * Only entries that have both `code` and `system` are included. + */ +function flattenContains( + items: FhirValueSetContains[], + valueSetId: string, +): ValueSetExpansionRow[] { + const rows: ValueSetExpansionRow[] = []; + for (const item of items) { + if (item.code && item.system) { + rows.push({ + value_set_id: valueSetId, + code: item.code, + system: item.system, + ...(item.display ? { display: item.display } : {}), + ...(item.version ? { version: item.version } : {}), + }); + } + if (item.contains?.length) { + rows.push(...flattenContains(item.contains, valueSetId)); + } + } + return rows; +} + +/** + * Attempt to load a single value set from the FHIR server. + * + * Strategy: + * 1. `GET {base}/ValueSet/$expand?url={encodedUrl}` — asks the server to expand + * 2. If that fails (404 / server doesn't support $expand), fall back to + * `GET {base}/ValueSet?url={encodedUrl}&_format=json` and read the stored + * expansion from the resource body. + */ +async function loadOne( + fhirBaseUrl: string, + ref: ValueSetReference, + fetchFn: typeof fetch, +): Promise { + const base = fhirBaseUrl.replace(/\/+$/, ''); + const encodedUrl = encodeURIComponent(ref.url); + + // Try $expand first + const expandEndpoint = `${base}/ValueSet/$expand?url=${encodedUrl}&_format=json`; + let body: FhirValueSet | null = null; + + try { + const resp = await fetchFn(expandEndpoint, { + headers: { Accept: 'application/fhir+json, application/json' }, + }); + if (resp.ok) { + body = (await resp.json()) as FhirValueSet; + } + } catch { + // network error — fall through to the search fallback + } + + // Fallback: search for the stored ValueSet resource by canonical URL + if (!body?.expansion) { + const searchEndpoint = `${base}/ValueSet?url=${encodedUrl}&_format=json`; + try { + const resp = await fetchFn(searchEndpoint, { + headers: { Accept: 'application/fhir+json, application/json' }, + }); + if (resp.ok) { + const bundle = (await resp.json()) as { + resourceType: string; + entry?: Array<{ resource: FhirValueSet }>; + }; + if (bundle.resourceType === 'Bundle' && bundle.entry?.[0]?.resource) { + body = bundle.entry[0].resource; + } + } + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + return { name: ref.name, url: ref.url, rows: [], error: `Network error: ${msg}` }; + } + } + + if (!body) { + return { name: ref.name, url: ref.url, rows: [], error: 'Not found on FHIR server' }; + } + + if (!body.expansion?.contains?.length) { + return { + name: ref.name, + url: ref.url, + rows: [], + error: 'ValueSet found but has no expansion.contains — ensure it is pre-expanded', + }; + } + + // Use the server's canonical URL if available, otherwise the requested URL + const resolvedId = body.url ?? ref.url; + const rows = flattenContains(body.expansion.contains, resolvedId); + return { name: ref.name, url: ref.url, rows }; +} + +/** + * Load expansions for all value sets referenced by an ELM library. + * + * @param fhirBaseUrl Base URL of the FHIR server, e.g. "http://localhost:8080/fhir" + * @param valueSets References from `extractValueSets()` or `extractUsedValueSets()` + * @param fetchFn Fetch implementation (defaults to `globalThis.fetch`). + * Pass a custom implementation for testing or environments + * without a global fetch. + * @param concurrency Max parallel requests. Default: 5. + * + * @example + * const refs = extractValueSets(elmJson); + * const results = await loadValueSetExpansions('http://localhost:8080/fhir', refs); + * const allRows = results.flatMap(r => r.rows); + */ +export async function loadValueSetExpansions( + fhirBaseUrl: string, + valueSets: ValueSetReference[], + fetchFn: typeof fetch = globalThis.fetch, + concurrency = 5, +): Promise { + const results: ValueSetLoadResult[] = []; + + // Process in batches to avoid overwhelming the server + for (let i = 0; i < valueSets.length; i += concurrency) { + const batch = valueSets.slice(i, i + concurrency); + const batchResults = await Promise.all( + batch.map(ref => loadOne(fhirBaseUrl, ref, fetchFn)), + ); + results.push(...batchResults); + } + + return results; +} diff --git a/packages/elm-to-sql/src/valueset/value-set-sql.ts b/packages/elm-to-sql/src/valueset/value-set-sql.ts new file mode 100644 index 0000000..cfd1f00 --- /dev/null +++ b/packages/elm-to-sql/src/valueset/value-set-sql.ts @@ -0,0 +1,145 @@ +/** + * Value Set SQL Utilities + * + * Generates SQL DDL and DML for populating a standalone `value_set_expansion` + * table with code data loaded via the FHIR API. + * + * Use this when the HAPI FHIR JPA PostgreSQL view + * (`scripts/hapi-fhir-sql-on-fhir/views/008_value_set_expansion_view.sql`) + * is not available — for example when running against a plain PostgreSQL + * database, DuckDB, or any other SQL environment that doesn't have HAPI. + * + * The table schema matches the view contract expected by the transpiler: + * code IN (SELECT code FROM value_set_expansion WHERE value_set_id = '') + */ + +import type { ValueSetExpansionRow } from './value-set-loader.js'; + +// ─── Constants ──────────────────────────────────────────────────────────────── + +const DEFAULT_TABLE = 'value_set_expansion'; + +/** Maximum rows per INSERT statement (avoids hitting parameter limits). */ +const BATCH_SIZE = 500; + +// ─── DDL ───────────────────────────────────────────────────────────────────── + +/** + * Generate `CREATE TABLE IF NOT EXISTS` DDL for a standalone value set + * expansion table. Schema is compatible with the HAPI FHIR JPA view. + * + * @param tableName Table name (default: `value_set_expansion`) + */ +export function generateValueSetTableDdl(tableName = DEFAULT_TABLE): string { + return [ + `-- Value set expansion table`, + `-- Schema matches the value_set_expansion view from scripts/hapi-fhir-sql-on-fhir/`, + `CREATE TABLE IF NOT EXISTS ${tableName} (`, + ` value_set_id TEXT NOT NULL,`, + ` code TEXT NOT NULL,`, + ` system TEXT NOT NULL,`, + ` display TEXT,`, + ` version TEXT,`, + ` PRIMARY KEY (value_set_id, system, code)`, + `);`, + ``, + `CREATE INDEX IF NOT EXISTS idx_${tableName}_vs_id ON ${tableName} (value_set_id);`, + `CREATE INDEX IF NOT EXISTS idx_${tableName}_code ON ${tableName} (code);`, + ].join('\n'); +} + +// ─── DML helpers ───────────────────────────────────────────────────────────── + +function escSql(s: string | undefined): string { + if (s === undefined || s === null) return 'NULL'; + return `'${s.replace(/'/g, "''")}'`; +} + +function rowToValues(row: ValueSetExpansionRow): string { + return ( + `(${escSql(row.value_set_id)}, ${escSql(row.code)}, ` + + `${escSql(row.system)}, ${escSql(row.display)}, ${escSql(row.version)})` + ); +} + +function buildInserts( + rows: ValueSetExpansionRow[], + tableName: string, + conflictClause: string, +): string { + if (rows.length === 0) return `-- No rows to insert into ${tableName}`; + + const stmts: string[] = []; + for (let i = 0; i < rows.length; i += BATCH_SIZE) { + const batch = rows.slice(i, i + BATCH_SIZE); + const values = batch.map(rowToValues).join(',\n '); + stmts.push( + `INSERT INTO ${tableName} (value_set_id, code, system, display, version)\nVALUES\n ${values}${conflictClause};`, + ); + } + return stmts.join('\n\n'); +} + +// ─── Public API ─────────────────────────────────────────────────────────────── + +/** + * Generate `INSERT INTO … VALUES …` SQL for a set of expansion rows. + * Rows are batched into statements of up to 500 rows each. + * Duplicate rows (same primary key) will cause an error — use + * `generateValueSetUpsertSql` for idempotent loads. + * + * @param rows Rows from `loadValueSetExpansions` + * @param tableName Target table (default: `value_set_expansion`) + */ +export function generateValueSetInsertSql( + rows: ValueSetExpansionRow[], + tableName = DEFAULT_TABLE, +): string { + return buildInserts(rows, tableName, ''); +} + +/** + * Generate `INSERT … ON CONFLICT DO NOTHING` SQL — safe to run multiple times. + * Use this for idempotent seeding scripts or server boot sequences. + * + * @param rows Rows from `loadValueSetExpansions` + * @param tableName Target table (default: `value_set_expansion`) + */ +export function generateValueSetUpsertSql( + rows: ValueSetExpansionRow[], + tableName = DEFAULT_TABLE, +): string { + return buildInserts(rows, tableName, '\nON CONFLICT DO NOTHING'); +} + +/** + * Generate a full standalone seed script that: + * 1. Creates the table (if not exists) + * 2. Upserts all expansion rows + * + * Suitable for use in a `psql -f seed.sql` workflow when the HAPI FHIR JPA + * view-based approach is not available. + * + * @param rows All expansion rows (combine `flatMap(r => r.rows)` from loader) + * @param tableName Target table (default: `value_set_expansion`) + */ +export function generateValueSetSeedScript( + rows: ValueSetExpansionRow[], + tableName = DEFAULT_TABLE, +): string { + const uniqueValueSets = [...new Set(rows.map(r => r.value_set_id))]; + const header = [ + `-- Value set expansion seed script`, + `-- Generated by @cqframework/elm-to-sql`, + `-- Value sets: ${uniqueValueSets.length}`, + `-- Total codes: ${rows.length}`, + ``, + `BEGIN;`, + ``, + ].join('\n'); + + const ddl = generateValueSetTableDdl(tableName); + const dml = generateValueSetUpsertSql(rows, tableName); + + return `${header}${ddl}\n\n${dml}\n\nCOMMIT;\n`; +} diff --git a/packages/elm-to-sql/src/views/view-definitions.ts b/packages/elm-to-sql/src/views/view-definitions.ts new file mode 100644 index 0000000..153716a --- /dev/null +++ b/packages/elm-to-sql/src/views/view-definitions.ts @@ -0,0 +1,427 @@ +/** + * SQL-on-FHIR ViewDefinition builders. + * + * These generate both: + * 1. FHIR ViewDefinition resources (JSON) — the HL7 SQL-on-FHIR spec contract + * 2. CREATE VIEW SQL statements — for direct PostgreSQL deployment + * + * The SQL targets the standard SQL-on-FHIR flat-table schema, NOT the raw + * HAPI FHIR JPA internal schema. HAPI views are in Issue #21 (separate script). + * + * Spec: https://build.fhir.org/ig/FHIR/sql-on-fhir-v2/ + */ + +// ─── FHIR ViewDefinition types (SQL-on-FHIR v2) ────────────────────────────── + +export interface ViewDefinition { + resourceType: 'ViewDefinition'; + url?: string; + name: string; + title?: string; + status: 'active' | 'draft' | 'retired'; + description?: string; + resource: string; + select: ViewDefinitionSelect[]; + where?: ViewDefinitionWhere[]; +} + +export interface ViewDefinitionSelect { + column?: ViewDefinitionColumn[]; + select?: ViewDefinitionSelect[]; + forEach?: string; + forEachOrNull?: string; + unionAll?: ViewDefinitionSelect[]; +} + +export interface ViewDefinitionColumn { + name: string; + path: string; + description?: string; + type?: string; + collection?: boolean; +} + +export interface ViewDefinitionWhere { + path: string; + description?: string; +} + +// ─── SQL CREATE VIEW statements ─────────────────────────────────────────────── + +export interface SqlViewDefinition { + viewName: string; + sql: string; + description: string; +} + +// ─── Standard SQL-on-FHIR view definitions ──────────────────────────────────── + +/** + * Standard SQL-on-FHIR view definitions aligned with US Core 6.1 / US CDI v3. + * + * Includes only the resource elements used in CQL measure logic — avoids + * surfacing nested FHIR structures not needed for eCQM evaluation. + * Resources without clinical measure use (Organization, Practitioner, etc.) + * are excluded since they are referenced but not directly queried in eCQM CTEs. + */ +export const STANDARD_VIEW_DEFINITIONS: ViewDefinition[] = [ + patientViewDefinition(), + observationViewDefinition(), + conditionViewDefinition(), + procedureViewDefinition(), + encounterViewDefinition(), + medicationRequestViewDefinition(), + diagnosticReportViewDefinition(), + coverageViewDefinition(), + allergyIntoleranceViewDefinition(), + immunizationViewDefinition(), + serviceRequestViewDefinition(), +]; + +// ─── ViewDefinition factories ───────────────────────────────────────────────── + +function patientViewDefinition(): ViewDefinition { + return { + resourceType: 'ViewDefinition', + name: 'patient_view', + title: 'Patient demographic view', + status: 'active', + description: 'Flattened Patient resource — demographics, identifiers, active status.', + resource: 'Patient', + select: [{ + column: [ + { name: 'id', path: 'id', type: 'id' }, + { name: 'gender', path: 'gender', type: 'code' }, + { name: 'birthdate', path: 'birthDate', type: 'date' }, + { name: 'active', path: 'active', type: 'boolean' }, + { name: 'name_family', path: "name.where(use='official').family", type: 'string' }, + { name: 'name_given', path: "name.where(use='official').given.first()", type: 'string' }, + { name: 'deceased', path: 'deceased.ofType(boolean)', type: 'boolean' }, + { name: 'deceased_datetime', path: 'deceased.ofType(dateTime)', type: 'dateTime' }, + { name: 'race_code', path: "extension.where(url='http://hl7.org/fhir/us/core/StructureDefinition/us-core-race').extension.where(url='ombCategory').value.ofType(Coding).code", type: 'code' }, + { name: 'ethnicity_code', path: "extension.where(url='http://hl7.org/fhir/us/core/StructureDefinition/us-core-ethnicity').extension.where(url='ombCategory').value.ofType(Coding).code", type: 'code' }, + ] + }] + }; +} + +function observationViewDefinition(): ViewDefinition { + return { + resourceType: 'ViewDefinition', + name: 'observation_view', + title: 'Observation clinical view', + status: 'active', + description: 'Flattened Observation — clinical measurements, labs, vital signs.', + resource: 'Observation', + select: [{ + column: [ + { name: 'id', path: 'id', type: 'id' }, + { name: 'subject_id', path: 'subject.getId()', type: 'id' }, + { name: 'status', path: 'status', type: 'code' }, + { name: 'code', path: 'code.coding.first().code', type: 'code' }, + { name: 'code_system', path: 'code.coding.first().system', type: 'uri' }, + { name: 'code_display', path: 'code.coding.first().display', type: 'string' }, + { name: 'code_text', path: 'code.text', type: 'string' }, + { name: 'effective_datetime', path: 'effective.ofType(dateTime)', type: 'dateTime' }, + { name: 'effective_start', path: 'effective.ofType(Period).start', type: 'dateTime' }, + { name: 'effective_end', path: 'effective.ofType(Period).end', type: 'dateTime' }, + { name: 'value_quantity', path: 'value.ofType(Quantity).value', type: 'decimal' }, + { name: 'value_unit', path: 'value.ofType(Quantity).unit', type: 'string' }, + { name: 'value_code', path: 'value.ofType(CodeableConcept).coding.first().code', type: 'code' }, + { name: 'value_string', path: 'value.ofType(string)', type: 'string' }, + { name: 'encounter_id', path: 'encounter.getId()', type: 'id' }, + { name: 'category_code', path: 'category.first().coding.first().code', type: 'code' }, + ] + }] + }; +} + +function conditionViewDefinition(): ViewDefinition { + return { + resourceType: 'ViewDefinition', + name: 'condition_view', + title: 'Condition / problem list view', + status: 'active', + description: 'Flattened Condition resource — diagnoses, problems, health concerns.', + resource: 'Condition', + select: [{ + column: [ + { name: 'id', path: 'id', type: 'id' }, + { name: 'subject_id', path: 'subject.getId()', type: 'id' }, + { name: 'code', path: 'code.coding.first().code', type: 'code' }, + { name: 'code_system', path: 'code.coding.first().system', type: 'uri' }, + { name: 'code_display', path: 'code.coding.first().display', type: 'string' }, + { name: 'code_text', path: 'code.text', type: 'string' }, + { name: 'clinical_status', path: 'clinicalStatus.coding.first().code', type: 'code' }, + { name: 'verification_status', path: 'verificationStatus.coding.first().code', type: 'code' }, + { name: 'onset_datetime', path: 'onset.ofType(dateTime)', type: 'dateTime' }, + { name: 'onset_start', path: 'onset.ofType(Period).start', type: 'dateTime' }, + { name: 'abatement_datetime', path: 'abatement.ofType(dateTime)', type: 'dateTime' }, + { name: 'recorded_date', path: 'recordedDate', type: 'dateTime' }, + { name: 'encounter_id', path: 'encounter.getId()', type: 'id' }, + { name: 'category_code', path: 'category.first().coding.first().code', type: 'code' }, + ] + }] + }; +} + +function procedureViewDefinition(): ViewDefinition { + return { + resourceType: 'ViewDefinition', + name: 'procedure_view', + title: 'Procedure view', + status: 'active', + description: 'Flattened Procedure resource.', + resource: 'Procedure', + select: [{ + column: [ + { name: 'id', path: 'id', type: 'id' }, + { name: 'subject_id', path: 'subject.getId()', type: 'id' }, + { name: 'status', path: 'status', type: 'code' }, + { name: 'code', path: 'code.coding.first().code', type: 'code' }, + { name: 'code_system', path: 'code.coding.first().system', type: 'uri' }, + { name: 'code_display', path: 'code.coding.first().display', type: 'string' }, + { name: 'code_text', path: 'code.text', type: 'string' }, + { name: 'performed_datetime', path: 'performed.ofType(dateTime)', type: 'dateTime' }, + { name: 'performed_start', path: 'performed.ofType(Period).start', type: 'dateTime' }, + { name: 'performed_end', path: 'performed.ofType(Period).end', type: 'dateTime' }, + { name: 'encounter_id', path: 'encounter.getId()', type: 'id' }, + { name: 'category_code', path: 'category.coding.first().code', type: 'code' }, + ] + }] + }; +} + +function encounterViewDefinition(): ViewDefinition { + return { + resourceType: 'ViewDefinition', + name: 'encounter_view', + title: 'Encounter view', + status: 'active', + description: 'Flattened Encounter resource — visits and service delivery.', + resource: 'Encounter', + select: [{ + column: [ + { name: 'id', path: 'id', type: 'id' }, + { name: 'subject_id', path: 'subject.getId()', type: 'id' }, + { name: 'status', path: 'status', type: 'code' }, + { name: 'class_code', path: 'class.code', type: 'code' }, + { name: 'type_code', path: 'type.first().coding.first().code', type: 'code' }, + { name: 'type_system', path: 'type.first().coding.first().system', type: 'uri' }, + { name: 'type_display', path: 'type.first().coding.first().display', type: 'string' }, + { name: 'period_start', path: 'period.start', type: 'dateTime' }, + { name: 'period_end', path: 'period.end', type: 'dateTime' }, + { name: 'service_provider_id', path: 'serviceProvider.getId()', type: 'id' }, + ] + }] + }; +} + +function medicationRequestViewDefinition(): ViewDefinition { + return { + resourceType: 'ViewDefinition', + name: 'medication_request_view', + title: 'MedicationRequest view', + status: 'active', + description: 'Flattened MedicationRequest — prescriptions and medication orders.', + resource: 'MedicationRequest', + select: [{ + column: [ + { name: 'id', path: 'id', type: 'id' }, + { name: 'subject_id', path: 'subject.getId()', type: 'id' }, + { name: 'status', path: 'status', type: 'code' }, + { name: 'intent', path: 'intent', type: 'code' }, + { name: 'medication_code', path: 'medication.ofType(CodeableConcept).coding.first().code', type: 'code' }, + { name: 'medication_system', path: 'medication.ofType(CodeableConcept).coding.first().system', type: 'uri' }, + { name: 'medication_display', path: 'medication.ofType(CodeableConcept).coding.first().display', type: 'string' }, + { name: 'authored_on', path: 'authoredOn', type: 'dateTime' }, + { name: 'encounter_id', path: 'encounter.getId()', type: 'id' }, + { name: 'requester_id', path: 'requester.getId()', type: 'id' }, + ] + }] + }; +} + +function diagnosticReportViewDefinition(): ViewDefinition { + return { + resourceType: 'ViewDefinition', + name: 'diagnostic_report_view', + title: 'DiagnosticReport view', + status: 'active', + description: 'Flattened DiagnosticReport.', + resource: 'DiagnosticReport', + select: [{ + column: [ + { name: 'id', path: 'id', type: 'id' }, + { name: 'subject_id', path: 'subject.getId()', type: 'id' }, + { name: 'status', path: 'status', type: 'code' }, + { name: 'code', path: 'code.coding.first().code', type: 'code' }, + { name: 'code_system', path: 'code.coding.first().system', type: 'uri' }, + { name: 'effective_datetime', path: 'effective.ofType(dateTime)', type: 'dateTime' }, + { name: 'issued', path: 'issued', type: 'dateTime' }, + { name: 'encounter_id', path: 'encounter.getId()', type: 'id' }, + { name: 'category_code', path: 'category.first().coding.first().code', type: 'code' }, + ] + }] + }; +} + +function coverageViewDefinition(): ViewDefinition { + return { + resourceType: 'ViewDefinition', + name: 'coverage_view', + title: 'Coverage / insurance view', + status: 'active', + description: 'Flattened Coverage resource — payer and insurance information.', + resource: 'Coverage', + select: [{ + column: [ + { name: 'id', path: 'id', type: 'id' }, + { name: 'beneficiary_id', path: 'beneficiary.getId()', type: 'id' }, + { name: 'status', path: 'status', type: 'code' }, + { name: 'type_code', path: 'type.coding.first().code', type: 'code' }, + { name: 'payer_id', path: 'payor.first().getId()', type: 'id' }, + { name: 'period_start', path: 'period.start', type: 'dateTime' }, + { name: 'period_end', path: 'period.end', type: 'dateTime' }, + ] + }] + }; +} + +function allergyIntoleranceViewDefinition(): ViewDefinition { + return { + resourceType: 'ViewDefinition', + name: 'allergy_intolerance_view', + title: 'AllergyIntolerance view', + status: 'active', + description: 'Flattened AllergyIntolerance resource.', + resource: 'AllergyIntolerance', + select: [{ + column: [ + { name: 'id', path: 'id', type: 'id' }, + { name: 'patient_id', path: 'patient.getId()', type: 'id' }, + { name: 'clinical_status', path: 'clinicalStatus.coding.first().code', type: 'code' }, + { name: 'verification_status', path: 'verificationStatus.coding.first().code', type: 'code' }, + { name: 'code', path: 'code.coding.first().code', type: 'code' }, + { name: 'code_system', path: 'code.coding.first().system', type: 'uri' }, + { name: 'onset_datetime', path: 'onset.ofType(dateTime)', type: 'dateTime' }, + { name: 'recorded_date', path: 'recordedDate', type: 'dateTime' }, + ] + }] + }; +} + +function immunizationViewDefinition(): ViewDefinition { + return { + resourceType: 'ViewDefinition', + name: 'immunization_view', + title: 'Immunization view', + status: 'active', + description: 'Flattened Immunization resource.', + resource: 'Immunization', + select: [{ + column: [ + { name: 'id', path: 'id', type: 'id' }, + { name: 'patient_id', path: 'patient.getId()', type: 'id' }, + { name: 'status', path: 'status', type: 'code' }, + { name: 'vaccine_code', path: 'vaccineCode.coding.first().code', type: 'code' }, + { name: 'vaccine_system', path: 'vaccineCode.coding.first().system', type: 'uri' }, + { name: 'occurrence_datetime', path: 'occurrence.ofType(dateTime)', type: 'dateTime' }, + { name: 'primary_source', path: 'primarySource', type: 'boolean' }, + { name: 'encounter_id', path: 'encounter.getId()', type: 'id' }, + ] + }] + }; +} + +function serviceRequestViewDefinition(): ViewDefinition { + return { + resourceType: 'ViewDefinition', + name: 'service_request_view', + title: 'ServiceRequest view (US Core 6.1)', + status: 'active', + description: 'Flattened ServiceRequest — referrals, diagnostic orders, and care orders. New in US Core 6.1.', + resource: 'ServiceRequest', + select: [{ + column: [ + { name: 'id', path: 'id', type: 'id' }, + { name: 'subject_id', path: 'subject.getId()', type: 'id' }, + { name: 'status', path: 'status', type: 'code' }, + { name: 'intent', path: 'intent', type: 'code' }, + { name: 'category_code', path: 'category.first().coding.first().code', type: 'code' }, + { name: 'category_system', path: 'category.first().coding.first().system', type: 'uri' }, + { name: 'code', path: 'code.coding.first().code', type: 'code' }, + { name: 'code_system', path: 'code.coding.first().system', type: 'uri' }, + { name: 'code_display', path: 'code.coding.first().display', type: 'string' }, + { name: 'code_text', path: 'code.text', type: 'string' }, + { name: 'occurrence_datetime', path: 'occurrence.ofType(dateTime)', type: 'dateTime' }, + { name: 'occurrence_start', path: 'occurrence.ofType(Period).start', type: 'dateTime' }, + { name: 'occurrence_end', path: 'occurrence.ofType(Period).end', type: 'dateTime' }, + { name: 'authored_on', path: 'authoredOn', type: 'dateTime' }, + { name: 'requester_id', path: 'requester.getId()', type: 'id' }, + { name: 'performer_id', path: 'performer.first().getId()', type: 'id' }, + { name: 'reason_code', path: 'reasonCode.first().coding.first().code', type: 'code' }, + { name: 'do_not_perform', path: 'doNotPerform', type: 'boolean' }, + { name: 'priority', path: 'priority', type: 'code' }, + { name: 'encounter_id', path: 'encounter.getId()', type: 'id' }, + { name: 'insurance_id', path: 'insurance.first().getId()', type: 'id' }, + ] + }] + }; +} + +// ─── SQL DDL generator ──────────────────────────────────────────────────────── + +/** + * Generate PostgreSQL CREATE OR REPLACE VIEW statements from ViewDefinitions. + * These target a FHIR-sourced flat table (e.g. produced by a FHIR-to-parquet ETL + * or the HAPI FHIR JPA views from Issue #21). + */ +export function viewDefinitionToSql(vd: ViewDefinition): SqlViewDefinition { + const cols = extractColumns(vd.select); + const sourcePaths = cols.map(c => ` ${pathToSqlExpr(c.path, vd.resource.toLowerCase())} AS ${c.name}`).join(',\n'); + + const sql = + `-- ViewDefinition: ${vd.name}\n` + + `-- Resource: ${vd.resource}\n` + + (vd.description ? `-- ${vd.description}\n` : '') + + `CREATE OR REPLACE VIEW ${vd.name} AS\nSELECT\n${sourcePaths}\nFROM fhir_${vd.resource.toLowerCase()};`; + + return { viewName: vd.name, sql, description: vd.description ?? vd.title ?? vd.name }; +} + +function extractColumns(selects: ViewDefinitionSelect[]): ViewDefinitionColumn[] { + const cols: ViewDefinitionColumn[] = []; + for (const sel of selects) { + if (sel.column) cols.push(...sel.column); + if (sel.select) cols.push(...extractColumns(sel.select)); + } + return cols; +} + +/** Converts a FHIRPath expression to a rough SQL expression for the DDL. */ +function pathToSqlExpr(path: string, _resource: string): string { + // Simple cases — delegate complex FHIRPath to the runtime (e.g. pg_fhirpath) + if (!path.includes('.') && !path.includes('(')) return path; + // Use jsonb extraction for structured paths — implementation depends on storage + const jsonPath = path + .replace(/\.getId\(\)/, " ->> 'reference'") + .replace(/\.first\(\)/, '[0]') + .replace(/\.ofType\(\w+\)/, '') + .replace(/\.\w+\(\)/g, ''); + return `fhir_extract('${jsonPath}')`; +} + +/** + * Generate all standard SQL view DDL statements as a single script. + * Safe for repeated execution (CREATE OR REPLACE). + */ +export function generateAllViewsSql(): string { + const header = + `-- SQL-on-FHIR Standard Views\n` + + `-- Generated by @cqframework/elm-to-sql\n` + + `-- Safe to re-run: uses CREATE OR REPLACE VIEW\n\n`; + + const views = STANDARD_VIEW_DEFINITIONS.map(vd => viewDefinitionToSql(vd).sql).join('\n\n'); + return header + views; +} diff --git a/packages/elm-to-sql/test/elm-to-sql.test.ts b/packages/elm-to-sql/test/elm-to-sql.test.ts new file mode 100644 index 0000000..e4d6bbb --- /dev/null +++ b/packages/elm-to-sql/test/elm-to-sql.test.ts @@ -0,0 +1,859 @@ +/** + * Tests for ElmToSqlTranspiler and MeasureReport generator. + * + * These test against ELM JSON that mirrors the shape produced by @cqframework/cql. + * The CMS125 fixture covers the most common eCQM patterns: + * Retrieve, Query, ExpressionRef, FunctionRef (AgeInYearsAt), + * ParameterRef (Measurement Period), ValueSetRef, And/Or, During, Equal + */ + +import { readFileSync } from 'fs'; +import { join, dirname } from 'path'; +import { fileURLToPath } from 'url'; +import { ElmToSqlTranspiler } from '../src/transpiler/elm-to-sql.js'; +import { generateMeasureReport, sqlRowToPopulationCounts } from '../src/measure/measure-report.js'; +import { STANDARD_VIEW_DEFINITIONS, viewDefinitionToSql, generateAllViewsSql } from '../src/views/view-definitions.js'; +import { extractValueSets, extractUsedValueSets } from '../src/valueset/value-set-extractor.js'; +import { loadValueSetExpansions } from '../src/valueset/value-set-loader.js'; +import { generateValueSetTableDdl, generateValueSetInsertSql, generateValueSetUpsertSql, generateValueSetSeedScript } from '../src/valueset/value-set-sql.js'; +import type { ElmLibraryWrapper } from '../src/types/elm.js'; +import type { ValueSetExpansionRow } from '../src/valueset/value-set-loader.js'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); + +function loadFixture(name: string): ElmLibraryWrapper { + const path = join(__dirname, 'fixtures', name); + return JSON.parse(readFileSync(path, 'utf8')) as ElmLibraryWrapper; +} + +// ─── ElmToSqlTranspiler ─────────────────────────────────────────────────────── + +describe('ElmToSqlTranspiler', () => { + const fixture = loadFixture('cms125-breast-cancer-screening.elm.json'); + + test('transpiles CMS125 without throwing', () => { + const t = new ElmToSqlTranspiler(); + expect(() => t.transpile(fixture)).not.toThrow(); + }); + + test('produces a SQL string', () => { + const t = new ElmToSqlTranspiler(); + const { sql } = t.transpile(fixture); + expect(typeof sql).toBe('string'); + expect(sql.length).toBeGreaterThan(100); + }); + + test('SQL contains WITH clause', () => { + const t = new ElmToSqlTranspiler(); + const { sql } = t.transpile(fixture); + expect(sql.toUpperCase()).toContain('WITH'); + }); + + test('SQL contains expected population CTEs', () => { + const t = new ElmToSqlTranspiler(); + const { sql, populations } = t.transpile(fixture); + expect(populations).toContain('Initial Population'); + expect(populations).toContain('Denominator'); + expect(populations).toContain('Numerator'); + expect(sql).toContain('Initial_Population'); + expect(sql).toContain('Denominator'); + expect(sql).toContain('Numerator'); + }); + + test('SQL contains final SELECT with _count columns', () => { + const t = new ElmToSqlTranspiler(); + const { sql } = t.transpile(fixture); + expect(sql).toContain('_count'); + }); + + test('respects measurementPeriodStart/End options', () => { + const t = new ElmToSqlTranspiler({ + measurementPeriodStart: '2023-01-01T00:00:00Z', + measurementPeriodEnd: '2023-12-31T23:59:59Z', + }); + const { sql } = t.transpile(fixture); + expect(sql).toContain('2023-01-01'); + expect(sql).toContain('2023-12-31'); + }); + + test('can disable comments', () => { + const t = new ElmToSqlTranspiler({ includeComments: false }); + const { sql } = t.transpile(fixture); + expect(sql).not.toContain('--'); + }); + + test('returns warnings array (may be empty)', () => { + const t = new ElmToSqlTranspiler(); + const { warnings } = t.transpile(fixture); + expect(Array.isArray(warnings)).toBe(true); + }); + + test('accepts ElmLibrary directly (without wrapper)', () => { + const t = new ElmToSqlTranspiler(); + expect(() => t.transpile(fixture.library)).not.toThrow(); + }); + + test('handles ExpressionRef (Denominator = Initial Population)', () => { + const t = new ElmToSqlTranspiler(); + const { sql } = t.transpile(fixture); + // Denominator should SELECT from Initial_Population CTE + expect(sql).toContain('Initial_Population'); + }); + + test('generates ValueSetRef as value_set_expansion lookup', () => { + const t = new ElmToSqlTranspiler(); + const { sql } = t.transpile(fixture); + expect(sql).toContain('value_set_expansion'); + }); + + test('generates AgeInYearsAt as DATE_PART', () => { + const t = new ElmToSqlTranspiler(); + const { sql } = t.transpile(fixture); + expect(sql).toContain("DATE_PART"); + }); +}); + +// ─── MeasureReport generator ────────────────────────────────────────────────── + +describe('generateMeasureReport', () => { + const counts = { + 'Initial Population': 150, + 'Denominator': 120, + 'Denominator Exclusion': 5, + 'Numerator': 80, + }; + + const opts = { + measureUrl: 'http://ecqi.healthit.gov/ecqms/Measure/BreastCancerScreening', + periodStart: '2024-01-01', + periodEnd: '2024-12-31', + }; + + test('returns a valid MeasureReport resource', () => { + const report = generateMeasureReport(counts, opts); + expect(report.resourceType).toBe('MeasureReport'); + expect(report.status).toBe('complete'); + }); + + test('includes measure URL', () => { + const report = generateMeasureReport(counts, opts); + expect(report.measure).toBe(opts.measureUrl); + }); + + test('includes period', () => { + const report = generateMeasureReport(counts, opts); + expect(report.period.start).toBe(opts.periodStart); + expect(report.period.end).toBe(opts.periodEnd); + }); + + test('includes all population groups', () => { + const report = generateMeasureReport(counts, opts); + const group = report.group?.[0]; + expect(group).toBeDefined(); + const pops = group?.population?.map(p => p.code.text) ?? []; + expect(pops).toContain('Initial Population'); + expect(pops).toContain('Denominator'); + expect(pops).toContain('Numerator'); + }); + + test('calculates measure score', () => { + const report = generateMeasureReport(counts, opts); + const score = report.group?.[0]?.measureScore?.value; + // 80 / (120 - 5) = 0.6957... + expect(score).toBeCloseTo(0.6957, 2); + }); + + test('measure score is null when denominator is 0', () => { + const report = generateMeasureReport({ 'Numerator': 5, 'Denominator': 0 }, opts); + expect(report.group?.[0]?.measureScore).toBeUndefined(); + }); +}); + +// ─── sqlRowToPopulationCounts ───────────────────────────────────────────────── + +describe('sqlRowToPopulationCounts', () => { + test('converts SQL result row to PopulationCounts', () => { + const row = { + Initial_Population_count: 150, + Denominator_count: 120, + Numerator_count: 80, + }; + const counts = sqlRowToPopulationCounts(row); + expect(counts['Initial Population']).toBe(150); + expect(counts['Denominator']).toBe(120); + expect(counts['Numerator']).toBe(80); + }); + + test('ignores non-count columns', () => { + const row = { Initial_Population_count: 10, some_other_col: 'foo' }; + const counts = sqlRowToPopulationCounts(row); + expect(Object.keys(counts)).toHaveLength(1); + }); +}); + +// ─── CMS130 ColorectalCancerScreening ──────────────────────────────────────── + +describe('CMS130 ColorectalCancerScreening', () => { + const fixture = loadFixture('cms130-colorectal-cancer-screening.elm.json'); + let sql: string; + let populations: string[]; + let warnings: string[]; + + beforeAll(() => { + const t = new ElmToSqlTranspiler({ + measurementPeriodStart: '2024-01-01T00:00:00Z', + measurementPeriodEnd: '2024-12-31T23:59:59Z', + }); + ({ sql, populations, warnings } = t.transpile(fixture)); + }); + + test('transpiles CMS130 without throwing', () => { + const t = new ElmToSqlTranspiler(); + expect(() => t.transpile(fixture)).not.toThrow(); + }); + + test('produces a non-empty SQL string', () => { + expect(typeof sql).toBe('string'); + expect(sql.length).toBeGreaterThan(200); + }); + + test('SQL contains WITH clause', () => { + expect(sql.toUpperCase()).toContain('WITH'); + }); + + test('identifies all CMS130 populations', () => { + expect(populations).toContain('Initial Population'); + expect(populations).toContain('Denominator'); + expect(populations).toContain('Denominator Exclusion'); + expect(populations).toContain('Numerator'); + }); + + test('SQL contains all population CTE identifiers', () => { + expect(sql).toContain('Initial_Population'); + expect(sql).toContain('Denominator_Exclusion'); + expect(sql).toContain('Numerator'); + }); + + test('SQL contains final SELECT with _count columns', () => { + expect(sql).toContain('_count'); + }); + + test('Union of Denominator Exclusion produces UNION in SQL', () => { + // Denominator Exclusion is a Union of Condition (colon cancer) + Procedure (total colectomy) + expect(sql.toUpperCase()).toContain('UNION'); + }); + + test('Numerator union references all three screening CTEs', () => { + // Numerator = Colonoscopy Within 10 Years UNION FOBT Within 1 Year UNION Flexible Sigmoidoscopy Within 5 Years + expect(sql).toContain('Colonoscopy_Within_10_Years'); + expect(sql).toContain('FOBT_Within_1_Year'); + expect(sql).toContain('Flexible_Sigmoidoscopy_Within_5_Years'); + }); + + test('SQL references condition_view for colon cancer exclusion', () => { + expect(sql).toContain('condition_view'); + }); + + test('SQL references procedure_view for colonoscopy and colectomy', () => { + expect(sql).toContain('procedure_view'); + }); + + test('SQL references observation_view for FOBT', () => { + expect(sql).toContain('observation_view'); + }); + + test('SQL references value_set_expansion for all value sets', () => { + expect(sql).toContain('value_set_expansion'); + }); + + test('SQL contains AgeInYearsAt expression for ages 45-75', () => { + expect(sql).toContain('DATE_PART'); + }); + + test('returns warnings array', () => { + expect(Array.isArray(warnings)).toBe(true); + }); + + test('accepts ElmLibrary directly (without wrapper)', () => { + const t = new ElmToSqlTranspiler(); + expect(() => t.transpile(fixture.library)).not.toThrow(); + }); +}); + +// ─── Value Set Extractor ────────────────────────────────────────────────────── + +describe('extractValueSets', () => { + const cms125 = loadFixture('cms125-breast-cancer-screening.elm.json'); + const cms130 = loadFixture('cms130-colorectal-cancer-screening.elm.json'); + + test('extracts all declared value sets from CMS125', () => { + const refs = extractValueSets(cms125); + expect(refs.length).toBe(3); + const names = refs.map(r => r.name); + expect(names).toContain('Mammography'); + expect(names).toContain('Bilateral Mastectomy'); + expect(names).toContain('Office Visit'); + }); + + test('each CMS125 ref has a non-empty url', () => { + const refs = extractValueSets(cms125); + for (const ref of refs) { + expect(ref.url).toBeTruthy(); + expect(ref.url).toMatch(/^http/); + } + }); + + test('extracts all declared value sets from CMS130', () => { + const refs = extractValueSets(cms130); + expect(refs.length).toBe(7); + const names = refs.map(r => r.name); + expect(names).toContain('Colonoscopy'); + expect(names).toContain('Fecal Occult Blood Test (FOBT)'); + expect(names).toContain('Flexible Sigmoidoscopy'); + expect(names).toContain('Malignant Neoplasm of Colon'); + expect(names).toContain('Total Colectomy'); + }); + + test('accepts ElmLibrary directly (without wrapper)', () => { + const refs = extractValueSets(cms125.library); + expect(refs.length).toBe(3); + }); + + test('returns empty array when library has no value sets', () => { + const refs = extractValueSets({ library: { identifier: { id: 'Empty' }, schemaIdentifier: { id: 'x', version: 'r1' } } }); + expect(refs).toEqual([]); + }); +}); + +describe('extractUsedValueSets', () => { + const cms130 = loadFixture('cms130-colorectal-cancer-screening.elm.json'); + + test('returns only value sets referenced in statements', () => { + const used = extractUsedValueSets(cms130); + // All 7 CMS130 value sets are referenced in its statements + expect(used.length).toBeGreaterThanOrEqual(1); + }); + + test('used subset is not larger than full set', () => { + const all = extractValueSets(cms130); + const used = extractUsedValueSets(cms130); + expect(used.length).toBeLessThanOrEqual(all.length); + }); + + test('each used ref exists in the full declared set', () => { + const all = extractValueSets(cms130); + const used = extractUsedValueSets(cms130); + const allUrls = new Set(all.map(r => r.url)); + for (const ref of used) { + expect(allUrls.has(ref.url)).toBe(true); + } + }); +}); + +// ─── Value Set Loader (with mock fetch) ────────────────────────────────────── + +describe('loadValueSetExpansions', () => { + const sampleExpansion = { + resourceType: 'ValueSet', + url: 'http://cts.nlm.nih.gov/fhir/ValueSet/test-vs', + expansion: { + contains: [ + { system: 'http://snomed.info/sct', code: '12345678', display: 'Test procedure' }, + { system: 'http://snomed.info/sct', code: '87654321', display: 'Another procedure' }, + ], + }, + }; + + function makeFetch(responses: Record): typeof fetch { + return async (input: Parameters[0]) => { + const url = typeof input === 'string' ? input : input.toString(); + for (const [pattern, body] of Object.entries(responses)) { + if (url.includes(pattern)) { + return { ok: true, json: async () => body } as Response; + } + } + return { ok: false, status: 404, json: async () => ({}) } as Response; + }; + } + + test('flattens expansion.contains into rows', async () => { + const mockFetch = makeFetch({ '$expand': sampleExpansion }); + const refs = [{ name: 'Test VS', url: 'http://cts.nlm.nih.gov/fhir/ValueSet/test-vs' }]; + const results = await loadValueSetExpansions('http://fhir.example.com', refs, mockFetch); + expect(results).toHaveLength(1); + expect(results[0].rows).toHaveLength(2); + expect(results[0].error).toBeUndefined(); + }); + + test('row has correct value_set_id, code, and system', async () => { + const mockFetch = makeFetch({ '$expand': sampleExpansion }); + const refs = [{ name: 'Test VS', url: 'http://cts.nlm.nih.gov/fhir/ValueSet/test-vs' }]; + const [result] = await loadValueSetExpansions('http://fhir.example.com', refs, mockFetch); + const row = result.rows[0]; + expect(row.value_set_id).toBe('http://cts.nlm.nih.gov/fhir/ValueSet/test-vs'); + expect(row.code).toBe('12345678'); + expect(row.system).toBe('http://snomed.info/sct'); + expect(row.display).toBe('Test procedure'); + }); + + test('falls back to Bundle search when $expand returns 404', async () => { + const bundleResponse = { + resourceType: 'Bundle', + entry: [{ resource: sampleExpansion }], + }; + const mockFetch = makeFetch({ 'ValueSet?url': bundleResponse }); + const refs = [{ name: 'Test VS', url: 'http://cts.nlm.nih.gov/fhir/ValueSet/test-vs' }]; + const [result] = await loadValueSetExpansions('http://fhir.example.com', refs, mockFetch); + expect(result.rows).toHaveLength(2); + }); + + test('returns error (not throw) for not-found value sets', async () => { + const mockFetch = makeFetch({}); // always 404 + const refs = [{ name: 'Missing VS', url: 'http://example.com/missing' }]; + const [result] = await loadValueSetExpansions('http://fhir.example.com', refs, mockFetch); + expect(result.rows).toHaveLength(0); + expect(result.error).toBeTruthy(); + }); + + test('returns error when ValueSet has no expansion', async () => { + const noExpansion = { resourceType: 'ValueSet', url: 'http://example.com/vs' }; + const mockFetch = makeFetch({ '$expand': noExpansion }); + const refs = [{ name: 'No Expansion', url: 'http://example.com/vs' }]; + const [result] = await loadValueSetExpansions('http://fhir.example.com', refs, mockFetch); + expect(result.rows).toHaveLength(0); + expect(result.error).toMatch(/pre-expanded/i); + }); + + test('loads multiple value sets in parallel', async () => { + const vs1 = { ...sampleExpansion, url: 'http://example.com/vs1' }; + const vs2 = { ...sampleExpansion, url: 'http://example.com/vs2' }; + let callCount = 0; + const mockFetch: typeof fetch = async (input) => { + callCount++; + const url = input.toString(); + const body = url.includes('vs1') ? vs1 : url.includes('vs2') ? vs2 : null; + if (!body) return { ok: false, status: 404, json: async () => ({}) } as Response; + return { ok: true, json: async () => body } as Response; + }; + const refs = [ + { name: 'VS1', url: 'http://example.com/vs1' }, + { name: 'VS2', url: 'http://example.com/vs2' }, + ]; + const results = await loadValueSetExpansions('http://fhir.example.com', refs, mockFetch); + expect(results).toHaveLength(2); + expect(results[0].rows).toHaveLength(2); + expect(results[1].rows).toHaveLength(2); + }); + + test('flattens nested expansion hierarchy', async () => { + const nestedExpansion = { + resourceType: 'ValueSet', + url: 'http://example.com/nested', + expansion: { + contains: [ + { + system: 'http://snomed.info/sct', + code: 'parent', + display: 'Parent', + contains: [ + { system: 'http://snomed.info/sct', code: 'child1', display: 'Child 1' }, + { system: 'http://snomed.info/sct', code: 'child2', display: 'Child 2' }, + ], + }, + ], + }, + }; + const mockFetch = makeFetch({ '$expand': nestedExpansion }); + const refs = [{ name: 'Nested', url: 'http://example.com/nested' }]; + const [result] = await loadValueSetExpansions('http://fhir.example.com', refs, mockFetch); + expect(result.rows).toHaveLength(3); // parent + 2 children + const codes = result.rows.map(r => r.code); + expect(codes).toContain('parent'); + expect(codes).toContain('child1'); + expect(codes).toContain('child2'); + }); +}); + +// ─── Value Set SQL generators ───────────────────────────────────────────────── + +describe('generateValueSetTableDdl', () => { + test('generates CREATE TABLE IF NOT EXISTS', () => { + const ddl = generateValueSetTableDdl(); + expect(ddl).toContain('CREATE TABLE IF NOT EXISTS value_set_expansion'); + }); + + test('includes all required columns', () => { + const ddl = generateValueSetTableDdl(); + expect(ddl).toContain('value_set_id'); + expect(ddl).toContain('code'); + expect(ddl).toContain('system'); + expect(ddl).toContain('display'); + expect(ddl).toContain('version'); + }); + + test('includes PRIMARY KEY on (value_set_id, system, code)', () => { + const ddl = generateValueSetTableDdl(); + expect(ddl).toContain('PRIMARY KEY'); + }); + + test('accepts custom table name', () => { + const ddl = generateValueSetTableDdl('my_vs_table'); + expect(ddl).toContain('CREATE TABLE IF NOT EXISTS my_vs_table'); + expect(ddl).toContain('idx_my_vs_table_vs_id'); + }); +}); + +describe('generateValueSetInsertSql / generateValueSetUpsertSql', () => { + const rows: ValueSetExpansionRow[] = [ + { value_set_id: 'http://example.com/vs', code: 'A001', system: 'http://snomed.info/sct', display: 'Code A' }, + { value_set_id: 'http://example.com/vs', code: 'B002', system: 'http://snomed.info/sct' }, + ]; + + test('INSERT includes all column names', () => { + const sql = generateValueSetInsertSql(rows); + expect(sql).toContain('INSERT INTO value_set_expansion'); + expect(sql).toContain('value_set_id, code, system, display, version'); + }); + + test('INSERT contains the row values', () => { + const sql = generateValueSetInsertSql(rows); + expect(sql).toContain('A001'); + expect(sql).toContain('B002'); + expect(sql).toContain('Code A'); + }); + + test('NULL is emitted for undefined optional fields', () => { + const sql = generateValueSetInsertSql(rows); + expect(sql).toContain('NULL'); + }); + + test('UPSERT appends ON CONFLICT DO NOTHING', () => { + const sql = generateValueSetUpsertSql(rows); + expect(sql).toContain('ON CONFLICT DO NOTHING'); + }); + + test('returns placeholder comment for empty rows', () => { + const sql = generateValueSetInsertSql([]); + expect(sql).toContain('No rows'); + }); + + test('single quotes in values are escaped', () => { + const tricky: ValueSetExpansionRow[] = [ + { value_set_id: "it's/vs", code: 'X', system: 'http://sys', display: "Colon's" }, + ]; + const sql = generateValueSetInsertSql(tricky); + expect(sql).toContain("it''s/vs"); + expect(sql).toContain("Colon''s"); + }); +}); + +describe('generateValueSetSeedScript', () => { + const rows: ValueSetExpansionRow[] = [ + { value_set_id: 'http://example.com/vs', code: 'A001', system: 'http://snomed.info/sct' }, + ]; + + test('seed script includes DDL + DML wrapped in transaction', () => { + const script = generateValueSetSeedScript(rows); + expect(script).toContain('CREATE TABLE IF NOT EXISTS'); + expect(script).toContain('INSERT INTO'); + expect(script).toContain('BEGIN;'); + expect(script).toContain('COMMIT;'); + }); + + test('seed script reports value set and code counts', () => { + const script = generateValueSetSeedScript(rows); + expect(script).toContain('Total codes: 1'); + expect(script).toContain('Value sets: 1'); + }); +}); + +// ─── ViewDefinitions ───────────────────────────────────────────────────────── + +describe('STANDARD_VIEW_DEFINITIONS', () => { + test('contains at least 5 resource views', () => { + expect(STANDARD_VIEW_DEFINITIONS.length).toBeGreaterThanOrEqual(5); + }); + + test('every definition has a name and resource', () => { + for (const vd of STANDARD_VIEW_DEFINITIONS) { + expect(vd.name).toBeTruthy(); + expect(vd.resource).toBeTruthy(); + expect(vd.resourceType).toBe('ViewDefinition'); + } + }); + + test('viewDefinitionToSql produces CREATE OR REPLACE VIEW', () => { + const vd = STANDARD_VIEW_DEFINITIONS[0]; + const { sql } = viewDefinitionToSql(vd); + expect(sql).toContain('CREATE OR REPLACE VIEW'); + expect(sql).toContain(vd.name); + }); + + test('generateAllViewsSql is a non-empty string', () => { + const sql = generateAllViewsSql(); + expect(typeof sql).toBe('string'); + expect(sql.length).toBeGreaterThan(500); + }); +}); + +// ─── US Core 6.1 ViewDefinition column contracts ───────────────────────────── +// These tests lock down the column set that the CQL-to-SQL transpiler and +// the HAPI FHIR JPA SQL views both depend on. A column removal or rename here +// is a breaking change for every generated measure query. + +describe('US Core 6.1 ViewDefinition column contracts', () => { + /** Helper: returns the flat column list for the named view. */ + function cols(viewName: string): string[] { + const vd = STANDARD_VIEW_DEFINITIONS.find(v => v.name === viewName); + if (!vd) return []; + const result: string[] = []; + function walk(selects: import('../src/views/view-definitions.js').ViewDefinitionSelect[]) { + for (const s of selects) { + if (s.column) result.push(...s.column.map(c => c.name)); + if (s.select) walk(s.select); + } + } + walk(vd.select); + return result; + } + + // ── Inventory ────────────────────────────────────────────────────────────── + + test('STANDARD_VIEW_DEFINITIONS has exactly 11 views (US Core 6.1 + base set)', () => { + expect(STANDARD_VIEW_DEFINITIONS).toHaveLength(11); + }); + + test('all expected view names are present', () => { + const names = STANDARD_VIEW_DEFINITIONS.map(v => v.name); + const expected = [ + 'patient_view', + 'observation_view', + 'condition_view', + 'procedure_view', + 'encounter_view', + 'medication_request_view', + 'diagnostic_report_view', + 'coverage_view', + 'allergy_intolerance_view', + 'immunization_view', + 'service_request_view', + ]; + for (const name of expected) { + expect(names).toContain(name); + } + }); + + test('no duplicate view names', () => { + const names = STANDARD_VIEW_DEFINITIONS.map(v => v.name); + expect(new Set(names).size).toBe(names.length); + }); + + test('all views have status active', () => { + for (const vd of STANDARD_VIEW_DEFINITIONS) { + expect(vd.status).toBe('active'); + } + }); + + test('every view has at least one column', () => { + for (const vd of STANDARD_VIEW_DEFINITIONS) { + expect(cols(vd.name).length).toBeGreaterThan(0); + } + }); + + // ── patient_view ─────────────────────────────────────────────────────────── + + test('patient_view: has birthdate for AgeInYearsAt calculations', () => { + expect(cols('patient_view')).toContain('birthdate'); + }); + + test('patient_view: has gender, active, deceased_datetime', () => { + const c = cols('patient_view'); + expect(c).toContain('gender'); + expect(c).toContain('active'); + expect(c).toContain('deceased_datetime'); + }); + + test('patient_view: has US Core race/ethnicity extension columns', () => { + const c = cols('patient_view'); + expect(c).toContain('race_code'); + expect(c).toContain('ethnicity_code'); + }); + + // ── encounter_view ───────────────────────────────────────────────────────── + + test('encounter_view: has period_start / period_end for During interval logic', () => { + const c = cols('encounter_view'); + expect(c).toContain('period_start'); + expect(c).toContain('period_end'); + }); + + test('encounter_view: has type_code and class_code for encounter classification', () => { + const c = cols('encounter_view'); + expect(c).toContain('type_code'); + expect(c).toContain('class_code'); + }); + + // ── condition_view ───────────────────────────────────────────────────────── + + test('condition_view: has clinical_status for active-diagnosis filters', () => { + expect(cols('condition_view')).toContain('clinical_status'); + }); + + test('condition_view: has onset_datetime and abatement_datetime for prevalence periods', () => { + const c = cols('condition_view'); + expect(c).toContain('onset_datetime'); + expect(c).toContain('abatement_datetime'); + }); + + test('condition_view: has code, code_system, category_code', () => { + const c = cols('condition_view'); + expect(c).toContain('code'); + expect(c).toContain('code_system'); + expect(c).toContain('category_code'); + }); + + // ── observation_view ─────────────────────────────────────────────────────── + + test('observation_view: has effective_datetime for temporal interval joins', () => { + expect(cols('observation_view')).toContain('effective_datetime'); + }); + + test('observation_view: has value_code and value_quantity for result evaluation', () => { + const c = cols('observation_view'); + expect(c).toContain('value_code'); + expect(c).toContain('value_quantity'); + }); + + test('observation_view: has category_code for lab/vital-sign partitioning', () => { + expect(cols('observation_view')).toContain('category_code'); + }); + + // ── procedure_view ───────────────────────────────────────────────────────── + + test('procedure_view: has performed_datetime and performed_start for period logic', () => { + const c = cols('procedure_view'); + expect(c).toContain('performed_datetime'); + expect(c).toContain('performed_start'); + }); + + test('procedure_view: has status for completed-procedure filters', () => { + expect(cols('procedure_view')).toContain('status'); + }); + + // ── medication_request_view ──────────────────────────────────────────────── + + test('medication_request_view: has medication_code for formulary lookups', () => { + expect(cols('medication_request_view')).toContain('medication_code'); + }); + + test('medication_request_view: has authored_on for timing logic', () => { + expect(cols('medication_request_view')).toContain('authored_on'); + }); + + // ── diagnostic_report_view ───────────────────────────────────────────────── + + test('diagnostic_report_view: has effective_datetime and category_code', () => { + const c = cols('diagnostic_report_view'); + expect(c).toContain('effective_datetime'); + expect(c).toContain('category_code'); + }); + + // ── coverage_view (US Core 6.1) ──────────────────────────────────────────── + + test('coverage_view: has beneficiary_id for patient linkage', () => { + expect(cols('coverage_view')).toContain('beneficiary_id'); + }); + + test('coverage_view: has payer_id, period_start, period_end', () => { + const c = cols('coverage_view'); + expect(c).toContain('payer_id'); + expect(c).toContain('period_start'); + expect(c).toContain('period_end'); + }); + + // ── allergy_intolerance_view (US Core 6.1) ───────────────────────────────── + + test('allergy_intolerance_view: has clinical_status and verification_status', () => { + const c = cols('allergy_intolerance_view'); + expect(c).toContain('clinical_status'); + expect(c).toContain('verification_status'); + }); + + test('allergy_intolerance_view: has code and code_system', () => { + const c = cols('allergy_intolerance_view'); + expect(c).toContain('code'); + expect(c).toContain('code_system'); + }); + + // ── immunization_view (US Core 6.1) ─────────────────────────────────────── + + test('immunization_view: has vaccine_code for CVX/NDC lookups', () => { + const c = cols('immunization_view'); + expect(c).toContain('vaccine_code'); + expect(c).toContain('vaccine_system'); + }); + + test('immunization_view: has occurrence_datetime for timing joins', () => { + expect(cols('immunization_view')).toContain('occurrence_datetime'); + }); + + test('immunization_view: has primary_source boolean', () => { + expect(cols('immunization_view')).toContain('primary_source'); + }); + + // ── service_request_view (US Core 6.1) ──────────────────────────────────── + + test('service_request_view: has id, subject_id, status, intent', () => { + const c = cols('service_request_view'); + expect(c).toContain('id'); + expect(c).toContain('subject_id'); + expect(c).toContain('status'); + expect(c).toContain('intent'); + }); + + test('service_request_view: has code and code_system for value set joins', () => { + const c = cols('service_request_view'); + expect(c).toContain('code'); + expect(c).toContain('code_system'); + }); + + test('service_request_view: has authored_on for temporal ordering', () => { + expect(cols('service_request_view')).toContain('authored_on'); + }); + + test('service_request_view: has do_not_perform for exclusion logic', () => { + expect(cols('service_request_view')).toContain('do_not_perform'); + }); + + test('service_request_view: has occurrence_datetime and occurrence_start/end', () => { + const c = cols('service_request_view'); + expect(c).toContain('occurrence_datetime'); + expect(c).toContain('occurrence_start'); + expect(c).toContain('occurrence_end'); + }); + + test('service_request_view: has encounter_id and insurance_id', () => { + const c = cols('service_request_view'); + expect(c).toContain('encounter_id'); + expect(c).toContain('insurance_id'); + }); + + test('service_request_view: has 21 columns', () => { + expect(cols('service_request_view')).toHaveLength(21); + }); + + // ── generateAllViewsSql coverage ────────────────────────────────────────── + + test('generateAllViewsSql includes all 11 view names', () => { + const sql = generateAllViewsSql(); + const expected = [ + 'patient_view', 'observation_view', 'condition_view', 'procedure_view', + 'encounter_view', 'medication_request_view', 'diagnostic_report_view', + 'coverage_view', 'allergy_intolerance_view', 'immunization_view', + 'service_request_view', + ]; + for (const name of expected) { + expect(sql).toContain(name); + } + }); +}); diff --git a/packages/elm-to-sql/test/fixtures/cms125-breast-cancer-screening.elm.json b/packages/elm-to-sql/test/fixtures/cms125-breast-cancer-screening.elm.json new file mode 100644 index 0000000..c05182a --- /dev/null +++ b/packages/elm-to-sql/test/fixtures/cms125-breast-cancer-screening.elm.json @@ -0,0 +1,199 @@ +{ + "library": { + "identifier": { "id": "BreastCancerScreening", "version": "11.1.000" }, + "schemaIdentifier": { "id": "urn:hl7-org:elm", "version": "r1" }, + "usings": { + "def": [ + { "localIdentifier": "System", "uri": "urn:hl7-org:elm-types:r1" }, + { "localIdentifier": "FHIR", "uri": "http://hl7.org/fhir", "version": "4.0.1" } + ] + }, + "parameters": { + "def": [ + { + "name": "Measurement Period", + "parameterTypeSpecifier": { "type": "IntervalTypeSpecifier", "pointType": { "type": "NamedTypeSpecifier", "name": "{urn:hl7-org:elm-types:r1}DateTime" } } + } + ] + }, + "valueSets": { + "def": [ + { "name": "Mammography", "id": "http://cts.nlm.nih.gov/fhir/ValueSet/2.16.840.1.113883.3.464.1003.108.12.1018" }, + { "name": "Bilateral Mastectomy", "id": "http://cts.nlm.nih.gov/fhir/ValueSet/2.16.840.1.113883.3.464.1003.198.12.1005" }, + { "name": "Office Visit", "id": "http://cts.nlm.nih.gov/fhir/ValueSet/2.16.840.1.113883.3.464.1003.101.12.1001" } + ] + }, + "statements": { + "def": [ + { + "name": "Patient", + "context": "Patient", + "expression": { + "type": "SingletonFrom", + "operand": { "type": "Retrieve", "dataType": "{http://hl7.org/fhir}Patient" } + } + }, + { + "name": "Qualifying Encounters", + "context": "Patient", + "accessLevel": "Public", + "expression": { + "type": "Query", + "source": [{ + "alias": "E", + "expression": { + "type": "Retrieve", + "dataType": "{http://hl7.org/fhir}Encounter", + "codes": { "type": "ValueSetRef", "name": "Office Visit" } + } + }], + "where": { + "type": "And", + "operand": [ + { + "type": "During", + "operand": [ + { "type": "Property", "path": "period_start", "scope": "E" }, + { "type": "ParameterRef", "name": "Measurement Period" } + ] + }, + { + "type": "Equal", + "operand": [ + { "type": "Property", "path": "status", "scope": "E" }, + { "type": "Literal", "valueType": "{urn:hl7-org:elm-types:r1}String", "value": "finished" } + ] + } + ] + } + } + }, + { + "name": "Initial Population", + "context": "Patient", + "accessLevel": "Public", + "expression": { + "type": "Query", + "source": [{ + "alias": "p", + "expression": { + "type": "Retrieve", + "dataType": "{http://hl7.org/fhir}Patient" + } + }], + "where": { + "type": "And", + "operand": [ + { + "type": "Equal", + "operand": [ + { "type": "Property", "path": "gender", "scope": "p" }, + { "type": "Literal", "valueType": "{urn:hl7-org:elm-types:r1}String", "value": "female" } + ] + }, + { + "type": "And", + "operand": [ + { + "type": "GreaterOrEqual", + "operand": [ + { + "type": "FunctionRef", + "name": "AgeInYearsAt", + "operand": [{ "type": "ParameterRef", "name": "Measurement Period" }] + }, + { "type": "Literal", "valueType": "{urn:hl7-org:elm-types:r1}Integer", "value": "51" } + ] + }, + { + "type": "Less", + "operand": [ + { + "type": "FunctionRef", + "name": "AgeInYearsAt", + "operand": [{ "type": "ParameterRef", "name": "Measurement Period" }] + }, + { "type": "Literal", "valueType": "{urn:hl7-org:elm-types:r1}Integer", "value": "75" } + ] + } + ] + }, + { + "type": "Exists", + "operand": { "type": "ExpressionRef", "name": "Qualifying Encounters" } + } + ] + } + } + }, + { + "name": "Denominator", + "context": "Patient", + "accessLevel": "Public", + "expression": { + "type": "ExpressionRef", + "name": "Initial Population" + } + }, + { + "name": "Denominator Exclusion", + "context": "Patient", + "accessLevel": "Public", + "expression": { + "type": "Query", + "source": [{ + "alias": "proc", + "expression": { + "type": "Retrieve", + "dataType": "{http://hl7.org/fhir}Procedure", + "codes": { "type": "ValueSetRef", "name": "Bilateral Mastectomy" } + } + }], + "where": { + "type": "Equal", + "operand": [ + { "type": "Property", "path": "status", "scope": "proc" }, + { "type": "Literal", "valueType": "{urn:hl7-org:elm-types:r1}String", "value": "completed" } + ] + } + } + }, + { + "name": "Numerator", + "context": "Patient", + "accessLevel": "Public", + "expression": { + "type": "Query", + "source": [{ + "alias": "obs", + "expression": { + "type": "Retrieve", + "dataType": "{http://hl7.org/fhir}Observation", + "codes": { "type": "ValueSetRef", "name": "Mammography" } + } + }], + "where": { + "type": "And", + "operand": [ + { + "type": "During", + "operand": [ + { "type": "Property", "path": "effective_datetime", "scope": "obs" }, + { "type": "ParameterRef", "name": "Measurement Period" } + ] + }, + { + "type": "Equal", + "operand": [ + { "type": "Property", "path": "status", "scope": "obs" }, + { "type": "Literal", "valueType": "{urn:hl7-org:elm-types:r1}String", "value": "final" } + ] + } + ] + } + } + } + ] + } + } +} diff --git a/packages/elm-to-sql/test/fixtures/cms130-colorectal-cancer-screening.elm.json b/packages/elm-to-sql/test/fixtures/cms130-colorectal-cancer-screening.elm.json new file mode 100644 index 0000000..cc4e11d --- /dev/null +++ b/packages/elm-to-sql/test/fixtures/cms130-colorectal-cancer-screening.elm.json @@ -0,0 +1,302 @@ +{ + "library": { + "identifier": { "id": "ColorectalCancerScreening", "version": "9.1.000" }, + "schemaIdentifier": { "id": "urn:hl7-org:elm", "version": "r1" }, + "usings": { + "def": [ + { "localIdentifier": "System", "uri": "urn:hl7-org:elm-types:r1" }, + { "localIdentifier": "FHIR", "uri": "http://hl7.org/fhir", "version": "4.0.1" } + ] + }, + "parameters": { + "def": [ + { + "name": "Measurement Period", + "parameterTypeSpecifier": { + "type": "IntervalTypeSpecifier", + "pointType": { "type": "NamedTypeSpecifier", "name": "{urn:hl7-org:elm-types:r1}DateTime" } + } + } + ] + }, + "valueSets": { + "def": [ + { "name": "Annual Wellness Visit", "id": "http://cts.nlm.nih.gov/fhir/ValueSet/2.16.840.1.113883.3.526.3.1240" }, + { "name": "Colonoscopy", "id": "http://cts.nlm.nih.gov/fhir/ValueSet/2.16.840.1.113883.3.464.1003.108.12.1020" }, + { "name": "Fecal Occult Blood Test (FOBT)", "id": "http://cts.nlm.nih.gov/fhir/ValueSet/2.16.840.1.113883.3.464.1003.198.12.1011" }, + { "name": "Flexible Sigmoidoscopy", "id": "http://cts.nlm.nih.gov/fhir/ValueSet/2.16.840.1.113883.3.464.1003.198.12.1010" }, + { "name": "Malignant Neoplasm of Colon", "id": "http://cts.nlm.nih.gov/fhir/ValueSet/2.16.840.1.113883.3.464.1003.108.12.1001" }, + { "name": "Total Colectomy", "id": "http://cts.nlm.nih.gov/fhir/ValueSet/2.16.840.1.113883.3.464.1003.198.12.1019" }, + { "name": "Office Visit", "id": "http://cts.nlm.nih.gov/fhir/ValueSet/2.16.840.1.113883.3.464.1003.101.12.1001" } + ] + }, + "statements": { + "def": [ + { + "name": "Patient", + "context": "Patient", + "expression": { + "type": "SingletonFrom", + "operand": { "type": "Retrieve", "dataType": "{http://hl7.org/fhir}Patient" } + } + }, + { + "name": "Qualifying Encounters", + "context": "Patient", + "accessLevel": "Public", + "expression": { + "type": "Query", + "source": [{ + "alias": "E", + "expression": { + "type": "Retrieve", + "dataType": "{http://hl7.org/fhir}Encounter", + "codes": { "type": "ValueSetRef", "name": "Office Visit" } + } + }], + "where": { + "type": "And", + "operand": [ + { + "type": "During", + "operand": [ + { "type": "Property", "path": "period_start", "scope": "E" }, + { "type": "ParameterRef", "name": "Measurement Period" } + ] + }, + { + "type": "Equal", + "operand": [ + { "type": "Property", "path": "status", "scope": "E" }, + { "type": "Literal", "valueType": "{urn:hl7-org:elm-types:r1}String", "value": "finished" } + ] + } + ] + } + } + }, + { + "name": "Initial Population", + "context": "Patient", + "accessLevel": "Public", + "expression": { + "type": "Query", + "source": [{ + "alias": "p", + "expression": { "type": "Retrieve", "dataType": "{http://hl7.org/fhir}Patient" } + }], + "where": { + "type": "And", + "operand": [ + { + "type": "GreaterOrEqual", + "operand": [ + { + "type": "FunctionRef", + "name": "AgeInYearsAt", + "operand": [{ "type": "ParameterRef", "name": "Measurement Period" }] + }, + { "type": "Literal", "valueType": "{urn:hl7-org:elm-types:r1}Integer", "value": "45" } + ] + }, + { + "type": "Less", + "operand": [ + { + "type": "FunctionRef", + "name": "AgeInYearsAt", + "operand": [{ "type": "ParameterRef", "name": "Measurement Period" }] + }, + { "type": "Literal", "valueType": "{urn:hl7-org:elm-types:r1}Integer", "value": "76" } + ] + }, + { + "type": "Exists", + "operand": { "type": "ExpressionRef", "name": "Qualifying Encounters" } + } + ] + } + } + }, + { + "name": "Denominator", + "context": "Patient", + "accessLevel": "Public", + "expression": { "type": "ExpressionRef", "name": "Initial Population" } + }, + { + "name": "Denominator Exclusion", + "context": "Patient", + "accessLevel": "Public", + "expression": { + "type": "Union", + "operand": [ + { + "type": "Query", + "source": [{ + "alias": "cond", + "expression": { + "type": "Retrieve", + "dataType": "{http://hl7.org/fhir}Condition", + "codes": { "type": "ValueSetRef", "name": "Malignant Neoplasm of Colon" } + } + }], + "where": { + "type": "Equal", + "operand": [ + { "type": "Property", "path": "clinical_status", "scope": "cond" }, + { "type": "Literal", "valueType": "{urn:hl7-org:elm-types:r1}String", "value": "active" } + ] + } + }, + { + "type": "Query", + "source": [{ + "alias": "proc", + "expression": { + "type": "Retrieve", + "dataType": "{http://hl7.org/fhir}Procedure", + "codes": { "type": "ValueSetRef", "name": "Total Colectomy" } + } + }], + "where": { + "type": "Equal", + "operand": [ + { "type": "Property", "path": "status", "scope": "proc" }, + { "type": "Literal", "valueType": "{urn:hl7-org:elm-types:r1}String", "value": "completed" } + ] + } + } + ] + } + }, + { + "name": "Colonoscopy Within 10 Years", + "context": "Patient", + "accessLevel": "Public", + "expression": { + "type": "Query", + "source": [{ + "alias": "proc", + "expression": { + "type": "Retrieve", + "dataType": "{http://hl7.org/fhir}Procedure", + "codes": { "type": "ValueSetRef", "name": "Colonoscopy" } + } + }], + "where": { + "type": "And", + "operand": [ + { + "type": "Equal", + "operand": [ + { "type": "Property", "path": "status", "scope": "proc" }, + { "type": "Literal", "valueType": "{urn:hl7-org:elm-types:r1}String", "value": "completed" } + ] + }, + { + "type": "GreaterOrEqual", + "operand": [ + { "type": "Property", "path": "performed_datetime", "scope": "proc" }, + { + "type": "FunctionRef", + "name": "ToDateTime", + "operand": [{ "type": "Literal", "valueType": "{urn:hl7-org:elm-types:r1}String", "value": "2015-01-01" }] + } + ] + } + ] + } + } + }, + { + "name": "FOBT Within 1 Year", + "context": "Patient", + "accessLevel": "Public", + "expression": { + "type": "Query", + "source": [{ + "alias": "obs", + "expression": { + "type": "Retrieve", + "dataType": "{http://hl7.org/fhir}Observation", + "codes": { "type": "ValueSetRef", "name": "Fecal Occult Blood Test (FOBT)" } + } + }], + "where": { + "type": "And", + "operand": [ + { + "type": "During", + "operand": [ + { "type": "Property", "path": "effective_datetime", "scope": "obs" }, + { "type": "ParameterRef", "name": "Measurement Period" } + ] + }, + { + "type": "Equal", + "operand": [ + { "type": "Property", "path": "status", "scope": "obs" }, + { "type": "Literal", "valueType": "{urn:hl7-org:elm-types:r1}String", "value": "final" } + ] + } + ] + } + } + }, + { + "name": "Flexible Sigmoidoscopy Within 5 Years", + "context": "Patient", + "accessLevel": "Public", + "expression": { + "type": "Query", + "source": [{ + "alias": "proc", + "expression": { + "type": "Retrieve", + "dataType": "{http://hl7.org/fhir}Procedure", + "codes": { "type": "ValueSetRef", "name": "Flexible Sigmoidoscopy" } + } + }], + "where": { + "type": "And", + "operand": [ + { + "type": "Equal", + "operand": [ + { "type": "Property", "path": "status", "scope": "proc" }, + { "type": "Literal", "valueType": "{urn:hl7-org:elm-types:r1}String", "value": "completed" } + ] + }, + { + "type": "GreaterOrEqual", + "operand": [ + { "type": "Property", "path": "performed_datetime", "scope": "proc" }, + { + "type": "FunctionRef", + "name": "ToDateTime", + "operand": [{ "type": "Literal", "valueType": "{urn:hl7-org:elm-types:r1}String", "value": "2020-01-01" }] + } + ] + } + ] + } + } + }, + { + "name": "Numerator", + "context": "Patient", + "accessLevel": "Public", + "expression": { + "type": "Union", + "operand": [ + { "type": "ExpressionRef", "name": "Colonoscopy Within 10 Years" }, + { "type": "ExpressionRef", "name": "FOBT Within 1 Year" }, + { "type": "ExpressionRef", "name": "Flexible Sigmoidoscopy Within 5 Years" } + ] + } + } + ] + } + } +} diff --git a/packages/elm-to-sql/tsconfig.json b/packages/elm-to-sql/tsconfig.json new file mode 100644 index 0000000..760d568 --- /dev/null +++ b/packages/elm-to-sql/tsconfig.json @@ -0,0 +1,19 @@ +{ + "compilerOptions": { + "target": "ES2020", + "module": "ES2020", + "moduleResolution": "bundler", + "lib": ["ES2020"], + "outDir": "./dist", + "rootDir": "./src", + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "strict": true, + "esModuleInterop": false, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist", "test"] +} diff --git a/scripts/hapi-fhir-sql-on-fhir/README.md b/scripts/hapi-fhir-sql-on-fhir/README.md new file mode 100644 index 0000000..8d4cdc1 --- /dev/null +++ b/scripts/hapi-fhir-sql-on-fhir/README.md @@ -0,0 +1,107 @@ +# HAPI FHIR JPA — SQL-on-FHIR Views + +SQL boot scripts that create SQL-on-FHIR flat views over the HAPI FHIR JPA PostgreSQL backing database. These views satisfy the table contracts expected by `@cqframework/elm-to-sql`. + +Implements [Issue #21](https://github.com/cqframework/cql-studio/issues/21). Preston's server boot code ([Issue #20](https://github.com/cqframework/cql-studio/issues/20)) calls `install.sql` automatically on startup. + +## Requirements + +| Requirement | Version | +| ----------- | ------- | +| PostgreSQL | 12+ (uses `jsonb_path_query_first`, `LATERAL`) | +| HAPI FHIR JPA | 6.x or 7.x | +| Resource encoding | JSON (not JSONC — see note below) | + +## Quick start + +```bash +# Run once, or on every boot — safe to re-run +psql "$DATABASE_URL" -f install.sql +``` + +`DATABASE_URL` should point to the PostgreSQL database underlying your HAPI FHIR JPA server (same DB that HAPI uses). For the CQL Studio bundle, this is set via `CQL_STUDIO_DB_URL`. + +## Views created + +| View | Resource | Description | +| ---- | -------- | ----------- | +| `patient_view` | Patient | Demographics, name, deceased, race/ethnicity | +| `observation_view` | Observation | Clinical measurements, vitals, labs | +| `condition_view` | Condition | Diagnoses, problems, health concerns | +| `procedure_view` | Procedure | Surgical and clinical procedures | +| `encounter_view` | Encounter | Visits and service delivery | +| `medication_request_view` | MedicationRequest | Prescriptions and orders | +| `diagnostic_report_view` | DiagnosticReport | Lab panels and imaging reports | +| `value_set_expansion` | ValueSet | Expanded code lists for IN-clause filtering | + +All views use `CREATE OR REPLACE VIEW` — safe to run repeatedly. + +A `cql_studio_view_version` table tracks installed versions for future migration detection. + +## How it works + +Each view joins two HAPI FHIR JPA tables: + +``` +HFJ_RESOURCE ──(RES_ID / RES_VER)──▶ HFJ_RES_VER + FHIR_ID RES_TEXT_VC (varchar JSON) + RES_TYPE RES_TEXT (bytea JSON) + RES_DELETED_AT RES_ENCODING + RES_UPDATED +``` + +The current non-deleted resource JSON is extracted via LATERAL join using `RES_VER` to match the latest version. PostgreSQL's `->` and `->>` JSON operators then extract individual fields. + +```sql +-- Example: get current resource JSON +COALESCE( + v.RES_TEXT_VC, + CASE WHEN v.RES_ENCODING = 'JSON' THEN convert_from(v.RES_TEXT, 'UTF8') END +)::jsonb +``` + +## JSONC encoding note + +HAPI FHIR can store resources in compressed JSON (`JSONC`). These views only handle `JSON` encoding — compressed resources will return `NULL` for all extracted fields. + +**Check your HAPI encoding:** +```sql +SELECT RES_ENCODING, COUNT(*) FROM HFJ_RES_VER GROUP BY RES_ENCODING; +``` + +If you see `JSONC`, configure HAPI to use plain JSON: +```yaml +# application.yaml +hapi: + fhir: + resource_encoding: JSON +``` + +Alternatively, you can decompress on-the-fly if you install the `pg_decompress` extension (not included here). + +## Value set expansion + +The `value_set_expansion` view reads ValueSet resources already stored in HAPI. ValueSets must be pre-expanded (either uploaded pre-expanded, or expanded via `$expand` and stored back). + +Verify value sets are loaded: +```sql +SELECT value_set_id, COUNT(*) as code_count +FROM value_set_expansion +GROUP BY value_set_id +ORDER BY code_count DESC; +``` + +## Upgrading views + +When this script changes between versions, re-run `install.sql`. `CREATE OR REPLACE VIEW` will update the view definition in place without dropping it. The `cql_studio_view_version` table records the new version and timestamp. + +For breaking column changes (future), individual view files will increment their version constant and the boot code will detect the old version and re-run the affected file. + +## HAPI schema compatibility + +Column names were verified against HAPI FHIR JPA source ([hapifhir/hapi-fhir](https://github.com/hapifhir/hapi-fhir)): + +| Table | Key columns used | +| ----- | ---------------- | +| `HFJ_RESOURCE` | `RES_ID`, `FHIR_ID`, `RES_TYPE`, `RES_VER`, `RES_DELETED_AT`, `RES_UPDATED` | +| `HFJ_RES_VER` | `RES_ID`, `RES_VER`, `RES_TEXT`, `RES_TEXT_VC`, `RES_ENCODING` | diff --git a/scripts/hapi-fhir-sql-on-fhir/data-quality/dq_checks.sql b/scripts/hapi-fhir-sql-on-fhir/data-quality/dq_checks.sql new file mode 100644 index 0000000..ff2f48b --- /dev/null +++ b/scripts/hapi-fhir-sql-on-fhir/data-quality/dq_checks.sql @@ -0,0 +1,546 @@ +-- ============================================================ +-- CQL Studio SQL-on-FHIR — Data Quality Pre-flight Checks +-- Target: HAPI FHIR JPA 6.x / 7.x on PostgreSQL 12+ +-- +-- Usage: +-- psql $DATABASE_URL -f data-quality/dq_checks.sql +-- +-- Run AFTER install.sql and BEFORE executing CQL measure SQL queries. +-- Outputs a structured DQ report via RAISE NOTICE. +-- +-- Severity levels: +-- CRITICAL — will cause measure queries to fail or produce wrong counts +-- WARNING — data gaps that may cause undercounting; investigate before reporting +-- INFO — resource volume counts; useful baseline for regression detection +-- +-- Exit codes: script always completes (never aborts); check NOTICE output. +-- ============================================================ + +DO $$ +DECLARE + -- Counters + v_critical INTEGER := 0; + v_warning INTEGER := 0; + + -- Working variables + v_count INTEGER; + v_pct NUMERIC; + v_rec RECORD; + + -- ─── Inline helper: emit one DQ finding ────────────────────────────────── + -- p_level : 'CRITICAL' | 'WARNING' | 'INFO' + -- p_resource : view name (e.g. 'patient_view') + -- p_check : short check name + -- p_count : number of affected rows (0 = pass) + -- p_detail : human-readable explanation +BEGIN + + RAISE NOTICE ''; + RAISE NOTICE '══════════════════════════════════════════════════════════════════'; + RAISE NOTICE ' CQL Studio SQL-on-FHIR — Data Quality Report'; + RAISE NOTICE ' Run at: %', NOW(); + RAISE NOTICE '══════════════════════════════════════════════════════════════════'; + + -- ══════════════════════════════════════════════════════════════════════════ + -- SECTION 1 — INFRASTRUCTURE: views installed + -- ══════════════════════════════════════════════════════════════════════════ + RAISE NOTICE ''; + RAISE NOTICE '── Section 1: Installed views ────────────────────────────────────'; + + FOR v_rec IN + SELECT view_name, installed_ver, updated_at + FROM cql_studio_view_version + ORDER BY view_name + LOOP + RAISE NOTICE ' [OK] %-40s v%s', v_rec.view_name, v_rec.installed_ver; + END LOOP; + + -- ══════════════════════════════════════════════════════════════════════════ + -- SECTION 2 — RESOURCE VOLUMES (INFO) + -- ══════════════════════════════════════════════════════════════════════════ + RAISE NOTICE ''; + RAISE NOTICE '── Section 2: Resource volumes ───────────────────────────────────'; + + SELECT COUNT(*) INTO v_count FROM patient_view; + RAISE NOTICE ' [INFO] patient_view %s rows', v_count; + SELECT COUNT(*) INTO v_count FROM encounter_view; + RAISE NOTICE ' [INFO] encounter_view %s rows', v_count; + SELECT COUNT(*) INTO v_count FROM condition_view; + RAISE NOTICE ' [INFO] condition_view %s rows', v_count; + SELECT COUNT(*) INTO v_count FROM observation_view; + RAISE NOTICE ' [INFO] observation_view %s rows', v_count; + SELECT COUNT(*) INTO v_count FROM procedure_view; + RAISE NOTICE ' [INFO] procedure_view %s rows', v_count; + SELECT COUNT(*) INTO v_count FROM medication_request_view; + RAISE NOTICE ' [INFO] medication_request_view %s rows', v_count; + SELECT COUNT(*) INTO v_count FROM diagnostic_report_view; + RAISE NOTICE ' [INFO] diagnostic_report_view %s rows', v_count; + SELECT COUNT(*) INTO v_count FROM immunization_view; + RAISE NOTICE ' [INFO] immunization_view %s rows', v_count; + SELECT COUNT(*) INTO v_count FROM coverage_view; + RAISE NOTICE ' [INFO] coverage_view %s rows', v_count; + SELECT COUNT(*) INTO v_count FROM allergy_intolerance_view; + RAISE NOTICE ' [INFO] allergy_intolerance_view %s rows', v_count; + SELECT COUNT(*) INTO v_count FROM service_request_view; + RAISE NOTICE ' [INFO] service_request_view %s rows', v_count; + SELECT COUNT(*) INTO v_count FROM value_set_expansion; + RAISE NOTICE ' [INFO] value_set_expansion %s codes across %s value sets', + v_count, + (SELECT COUNT(DISTINCT value_set_id) FROM value_set_expansion); + + -- ══════════════════════════════════════════════════════════════════════════ + -- SECTION 3 — PATIENT INTEGRITY + -- ══════════════════════════════════════════════════════════════════════════ + RAISE NOTICE ''; + RAISE NOTICE '── Section 3: Patient integrity ──────────────────────────────────'; + + -- CRITICAL: NULL birthDate → AgeInYearsAt() will return NULL → age filter skips patient + SELECT COUNT(*) INTO v_count FROM patient_view WHERE birthdate IS NULL; + IF v_count > 0 THEN + v_critical := v_critical + 1; + RAISE NOTICE ' [CRITICAL] patient_view: % patient(s) with NULL birthdate — AgeInYearsAt() will return NULL, those patients will be excluded from all age-filtered measures', v_count; + ELSE + RAISE NOTICE ' [OK] patient_view: all patients have birthdate'; + END IF; + + -- CRITICAL: future birthdate → age calculation produces negative/wrong values + SELECT COUNT(*) INTO v_count FROM patient_view WHERE birthdate > CURRENT_DATE; + IF v_count > 0 THEN + v_critical := v_critical + 1; + RAISE NOTICE ' [CRITICAL] patient_view: % patient(s) with future birthdate (%)', v_count, 'may be test data or bad ETL'; + ELSE + RAISE NOTICE ' [OK] patient_view: no future birthdates'; + END IF; + + -- WARNING: implausible age > 150 years + SELECT COUNT(*) INTO v_count FROM patient_view + WHERE birthdate < CURRENT_DATE - INTERVAL '150 years'; + IF v_count > 0 THEN + v_warning := v_warning + 1; + RAISE NOTICE ' [WARNING] patient_view: % patient(s) with birthdate > 150 years ago — likely bad data', v_count; + ELSE + RAISE NOTICE ' [OK] patient_view: no implausible birthdates (> 150 years)'; + END IF; + + -- WARNING: invalid gender codes (R4 allows: male | female | other | unknown) + SELECT COUNT(*) INTO v_count FROM patient_view + WHERE gender NOT IN ('male','female','other','unknown') OR gender IS NULL; + IF v_count > 0 THEN + v_warning := v_warning + 1; + RAISE NOTICE ' [WARNING] patient_view: % patient(s) with NULL or non-standard gender code', v_count; + ELSE + RAISE NOTICE ' [OK] patient_view: all gender codes valid (male/female/other/unknown)'; + END IF; + + -- INFO: deceased patients + SELECT COUNT(*) INTO v_count FROM patient_view WHERE deceased = TRUE; + RAISE NOTICE ' [INFO] patient_view: % deceased patient(s)', v_count; + + -- ══════════════════════════════════════════════════════════════════════════ + -- SECTION 4 — ENCOUNTER INTEGRITY + -- ══════════════════════════════════════════════════════════════════════════ + RAISE NOTICE ''; + RAISE NOTICE '── Section 4: Encounter integrity ────────────────────────────────'; + + -- CRITICAL: NULL period_start → During / In Period check will always fail + SELECT COUNT(*) INTO v_count FROM encounter_view WHERE period_start IS NULL; + IF v_count > 0 THEN + v_critical := v_critical + 1; + RAISE NOTICE ' [CRITICAL] encounter_view: % encounter(s) with NULL period_start — timing-based filters will exclude these', v_count; + ELSE + RAISE NOTICE ' [OK] encounter_view: all encounters have period_start'; + END IF; + + -- WARNING: period_end before period_start (inverted interval) + SELECT COUNT(*) INTO v_count FROM encounter_view + WHERE period_end IS NOT NULL AND period_start IS NOT NULL + AND period_end < period_start; + IF v_count > 0 THEN + v_warning := v_warning + 1; + RAISE NOTICE ' [WARNING] encounter_view: % encounter(s) with period_end < period_start (inverted interval)', v_count; + ELSE + RAISE NOTICE ' [OK] encounter_view: no inverted period intervals'; + END IF; + + -- WARNING: NULL subject_id (orphaned encounter — no patient link) + SELECT COUNT(*) INTO v_count FROM encounter_view WHERE subject_id IS NULL OR subject_id = ''; + IF v_count > 0 THEN + v_warning := v_warning + 1; + RAISE NOTICE ' [WARNING] encounter_view: % encounter(s) with no subject reference', v_count; + ELSE + RAISE NOTICE ' [OK] encounter_view: all encounters have subject reference'; + END IF; + + -- CRITICAL: encounter subject_id references a patient not in patient_view + SELECT COUNT(*) INTO v_count + FROM encounter_view e + WHERE NOT EXISTS (SELECT 1 FROM patient_view p WHERE p.id = e.subject_id); + IF v_count > 0 THEN + v_critical := v_critical + 1; + RAISE NOTICE ' [CRITICAL] encounter_view: % encounter(s) reference a patient_id not found in patient_view — broken reference', v_count; + ELSE + RAISE NOTICE ' [OK] encounter_view: all encounter subjects exist in patient_view'; + END IF; + + -- INFO: encounter status breakdown + RAISE NOTICE ' [INFO] encounter_view status distribution:'; + FOR v_rec IN + SELECT status, COUNT(*) AS n FROM encounter_view GROUP BY status ORDER BY n DESC + LOOP + RAISE NOTICE ' %-25s %s', v_rec.status, v_rec.n; + END LOOP; + + -- ══════════════════════════════════════════════════════════════════════════ + -- SECTION 5 — CONDITION INTEGRITY + -- ══════════════════════════════════════════════════════════════════════════ + RAISE NOTICE ''; + RAISE NOTICE '── Section 5: Condition integrity ────────────────────────────────'; + + -- CRITICAL: NULL code → value set lookup code IN (SELECT code FROM ...) will never match + SELECT COUNT(*) INTO v_count FROM condition_view WHERE code IS NULL; + IF v_count > 0 THEN + v_critical := v_critical + 1; + RAISE NOTICE ' [CRITICAL] condition_view: % condition(s) with NULL code — value set membership checks will not match these', v_count; + ELSE + RAISE NOTICE ' [OK] condition_view: all conditions have a code'; + END IF; + + -- WARNING: NULL clinical_status (e.g. can''t filter active/inactive) + SELECT COUNT(*) INTO v_count FROM condition_view WHERE clinical_status IS NULL; + IF v_count > 0 THEN + v_warning := v_warning + 1; + RAISE NOTICE ' [WARNING] condition_view: % condition(s) with NULL clinical_status', v_count; + ELSE + RAISE NOTICE ' [OK] condition_view: all conditions have clinical_status'; + END IF; + + -- INFO: condition clinical status breakdown + RAISE NOTICE ' [INFO] condition_view clinical_status distribution:'; + FOR v_rec IN + SELECT clinical_status, COUNT(*) AS n FROM condition_view + GROUP BY clinical_status ORDER BY n DESC + LOOP + RAISE NOTICE ' %-25s %s', COALESCE(v_rec.clinical_status,'(null)'), v_rec.n; + END LOOP; + + -- ══════════════════════════════════════════════════════════════════════════ + -- SECTION 6 — OBSERVATION INTEGRITY + -- ══════════════════════════════════════════════════════════════════════════ + RAISE NOTICE ''; + RAISE NOTICE '── Section 6: Observation integrity ─────────────────────────────'; + + -- CRITICAL: NULL effective_datetime → During / timing checks fail + SELECT COUNT(*) INTO v_count FROM observation_view + WHERE effective_datetime IS NULL AND effective_start IS NULL; + IF v_count > 0 THEN + v_critical := v_critical + 1; + RAISE NOTICE ' [CRITICAL] observation_view: % observation(s) with no effective date (datetime or period) — will be excluded from all time-window checks', v_count; + ELSE + RAISE NOTICE ' [OK] observation_view: all observations have an effective date'; + END IF; + + -- CRITICAL: NULL code → value set membership will never match + SELECT COUNT(*) INTO v_count FROM observation_view WHERE code IS NULL; + IF v_count > 0 THEN + v_critical := v_critical + 1; + RAISE NOTICE ' [CRITICAL] observation_view: % observation(s) with NULL code', v_count; + ELSE + RAISE NOTICE ' [OK] observation_view: all observations have a code'; + END IF; + + -- WARNING: status is not ''final'' or ''amended'' (may want to exclude preliminary/entered-in-error) + SELECT COUNT(*) INTO v_count FROM observation_view + WHERE status NOT IN ('final','amended','corrected') OR status IS NULL; + IF v_count > 0 THEN + v_warning := v_warning + 1; + RAISE NOTICE ' [WARNING] observation_view: % observation(s) with non-final status (preliminary/entered-in-error/unknown) — CQL measures typically filter status = ''final''', v_count; + ELSE + RAISE NOTICE ' [OK] observation_view: all observations have final/amended/corrected status'; + END IF; + + -- INFO: observation category breakdown + RAISE NOTICE ' [INFO] observation_view category_code distribution (top 10):'; + FOR v_rec IN + SELECT category_code, COUNT(*) AS n FROM observation_view + GROUP BY category_code ORDER BY n DESC LIMIT 10 + LOOP + RAISE NOTICE ' %-30s %s', COALESCE(v_rec.category_code,'(null)'), v_rec.n; + END LOOP; + + -- ══════════════════════════════════════════════════════════════════════════ + -- SECTION 7 — PROCEDURE INTEGRITY + -- ══════════════════════════════════════════════════════════════════════════ + RAISE NOTICE ''; + RAISE NOTICE '── Section 7: Procedure integrity ────────────────────────────────'; + + -- CRITICAL: NULL performed date → timing checks will fail + SELECT COUNT(*) INTO v_count FROM procedure_view + WHERE performed_datetime IS NULL AND performed_start IS NULL; + IF v_count > 0 THEN + v_critical := v_critical + 1; + RAISE NOTICE ' [CRITICAL] procedure_view: % procedure(s) with no performed date — will be excluded from all time-window checks', v_count; + ELSE + RAISE NOTICE ' [OK] procedure_view: all procedures have a performed date'; + END IF; + + -- CRITICAL: NULL code → value set membership will not match + SELECT COUNT(*) INTO v_count FROM procedure_view WHERE code IS NULL; + IF v_count > 0 THEN + v_critical := v_critical + 1; + RAISE NOTICE ' [CRITICAL] procedure_view: % procedure(s) with NULL code', v_count; + ELSE + RAISE NOTICE ' [OK] procedure_view: all procedures have a code'; + END IF; + + -- WARNING: procedure status not ''completed'' + SELECT COUNT(*) INTO v_count FROM procedure_view + WHERE status NOT IN ('completed','in-progress') OR status IS NULL; + IF v_count > 0 THEN + v_warning := v_warning + 1; + RAISE NOTICE ' [WARNING] procedure_view: % procedure(s) with non-completed status', v_count; + END IF; + + -- ══════════════════════════════════════════════════════════════════════════ + -- SECTION 8 — MEDICATION REQUEST INTEGRITY + -- ══════════════════════════════════════════════════════════════════════════ + RAISE NOTICE ''; + RAISE NOTICE '── Section 8: MedicationRequest integrity ────────────────────────'; + + -- WARNING: NULL authored_on → can''t place order in measurement period + SELECT COUNT(*) INTO v_count FROM medication_request_view WHERE authored_on IS NULL; + IF v_count > 0 THEN + v_warning := v_warning + 1; + RAISE NOTICE ' [WARNING] medication_request_view: % request(s) with NULL authored_on', v_count; + ELSE + RAISE NOTICE ' [OK] medication_request_view: all requests have authored_on'; + END IF; + + -- CRITICAL: NULL medication_code (can''t match value sets) + SELECT COUNT(*) INTO v_count FROM medication_request_view WHERE medication_code IS NULL; + IF v_count > 0 THEN + v_critical := v_critical + 1; + RAISE NOTICE ' [CRITICAL] medication_request_view: % request(s) with NULL medication_code — may use medicationReference (not flattened)', v_count; + ELSE + RAISE NOTICE ' [OK] medication_request_view: all requests have medication_code'; + END IF; + + -- ══════════════════════════════════════════════════════════════════════════ + -- SECTION 9 — DIAGNOSTIC REPORT INTEGRITY + -- ══════════════════════════════════════════════════════════════════════════ + RAISE NOTICE ''; + RAISE NOTICE '── Section 9: DiagnosticReport integrity ─────────────────────────'; + + SELECT COUNT(*) INTO v_count FROM diagnostic_report_view + WHERE effective_datetime IS NULL AND issued IS NULL; + IF v_count > 0 THEN + v_warning := v_warning + 1; + RAISE NOTICE ' [WARNING] diagnostic_report_view: % report(s) with no date (effective or issued)', v_count; + ELSE + RAISE NOTICE ' [OK] diagnostic_report_view: all reports have a date'; + END IF; + + SELECT COUNT(*) INTO v_count FROM diagnostic_report_view WHERE code IS NULL; + IF v_count > 0 THEN + v_critical := v_critical + 1; + RAISE NOTICE ' [CRITICAL] diagnostic_report_view: % report(s) with NULL code', v_count; + ELSE + RAISE NOTICE ' [OK] diagnostic_report_view: all reports have a code'; + END IF; + + -- ══════════════════════════════════════════════════════════════════════════ + -- SECTION 10 — COVERAGE INTEGRITY + -- ══════════════════════════════════════════════════════════════════════════ + RAISE NOTICE ''; + RAISE NOTICE '── Section 10: Coverage integrity ────────────────────────────────'; + + SELECT COUNT(*) INTO v_count FROM coverage_view WHERE beneficiary_id IS NULL OR beneficiary_id = ''; + IF v_count > 0 THEN + v_critical := v_critical + 1; + RAISE NOTICE ' [CRITICAL] coverage_view: % coverage(s) with no beneficiary_id', v_count; + ELSE + RAISE NOTICE ' [OK] coverage_view: all coverages have beneficiary_id'; + END IF; + + SELECT COUNT(*) INTO v_count FROM coverage_view WHERE payor_id IS NULL AND payor_identifier IS NULL; + IF v_count > 0 THEN + v_warning := v_warning + 1; + RAISE NOTICE ' [WARNING] coverage_view: % coverage(s) with no payor (id or identifier)', v_count; + ELSE + RAISE NOTICE ' [OK] coverage_view: all coverages have a payor'; + END IF; + + -- ══════════════════════════════════════════════════════════════════════════ + -- SECTION 11 — IMMUNIZATION INTEGRITY + -- ══════════════════════════════════════════════════════════════════════════ + RAISE NOTICE ''; + RAISE NOTICE '── Section 11: Immunization integrity ────────────────────────────'; + + SELECT COUNT(*) INTO v_count FROM immunization_view WHERE vaccine_code IS NULL; + IF v_count > 0 THEN + v_critical := v_critical + 1; + RAISE NOTICE ' [CRITICAL] immunization_view: % immunization(s) with NULL vaccine_code', v_count; + ELSE + RAISE NOTICE ' [OK] immunization_view: all immunizations have a vaccine_code'; + END IF; + + SELECT COUNT(*) INTO v_count FROM immunization_view WHERE occurrence_datetime IS NULL AND occurrence_string IS NULL; + IF v_count > 0 THEN + v_warning := v_warning + 1; + RAISE NOTICE ' [WARNING] immunization_view: % immunization(s) with no occurrence date', v_count; + ELSE + RAISE NOTICE ' [OK] immunization_view: all immunizations have an occurrence'; + END IF; + + -- INFO: not-done immunizations (important for exclusion measures) + SELECT COUNT(*) INTO v_count FROM immunization_view WHERE status = 'not-done'; + RAISE NOTICE ' [INFO] immunization_view: % ''not-done'' immunization(s)', v_count; + + -- ══════════════════════════════════════════════════════════════════════════ + -- SECTION 12 — VALUE SET EXPANSION COVERAGE + -- ══════════════════════════════════════════════════════════════════════════ + RAISE NOTICE ''; + RAISE NOTICE '── Section 12: Value set expansion ───────────────────────────────'; + + -- Overall expansion health + SELECT COUNT(DISTINCT value_set_id) INTO v_count FROM value_set_expansion; + RAISE NOTICE ' [INFO] % distinct value set(s) loaded in value_set_expansion', v_count; + + IF v_count = 0 THEN + v_critical := v_critical + 1; + RAISE NOTICE ' [CRITICAL] value_set_expansion is empty — ALL value set membership checks will return no matches'; + END IF; + + -- Value sets with very few codes (potential partial expansion) + RAISE NOTICE ' [INFO] Value sets with < 5 codes (possible partial expansions):'; + FOR v_rec IN + SELECT value_set_id, COUNT(*) AS n + FROM value_set_expansion + GROUP BY value_set_id + HAVING COUNT(*) < 5 + ORDER BY n, value_set_id + LIMIT 20 + LOOP + v_warning := v_warning + 1; + RAISE NOTICE ' [WARNING] value_set_expansion: % — only % code(s)', v_rec.value_set_id, v_rec.n; + END LOOP; + + -- Value sets with extremely large expansions (potential performance concern) + FOR v_rec IN + SELECT value_set_id, COUNT(*) AS n + FROM value_set_expansion + GROUP BY value_set_id + HAVING COUNT(*) > 10000 + ORDER BY n DESC + LOOP + RAISE NOTICE ' [INFO] value_set_expansion: % — % codes (large set, may affect query performance)', v_rec.value_set_id, v_rec.n; + END LOOP; + + -- ══════════════════════════════════════════════════════════════════════════ + -- SECTION 13 — DATE RANGE SANITY + -- ══════════════════════════════════════════════════════════════════════════ + RAISE NOTICE ''; + RAISE NOTICE '── Section 13: Date range sanity ─────────────────────────────────'; + + -- Check for any encounter dates far in the future (> 1 year ahead — likely test/bad data) + SELECT COUNT(*) INTO v_count FROM encounter_view + WHERE period_start > CURRENT_DATE + INTERVAL '1 year'; + IF v_count > 0 THEN + v_warning := v_warning + 1; + RAISE NOTICE ' [WARNING] encounter_view: % encounter(s) with period_start > 1 year in the future', v_count; + ELSE + RAISE NOTICE ' [OK] encounter_view: no unreasonably future encounter dates'; + END IF; + + -- Check for observations dated before 1900 + SELECT COUNT(*) INTO v_count FROM observation_view + WHERE effective_datetime < '1900-01-01'::timestamp; + IF v_count > 0 THEN + v_warning := v_warning + 1; + RAISE NOTICE ' [WARNING] observation_view: % observation(s) with effective_datetime before 1900', v_count; + END IF; + + -- Procedures before 1900 + SELECT COUNT(*) INTO v_count FROM procedure_view + WHERE performed_datetime < '1900-01-01'::timestamp; + IF v_count > 0 THEN + v_warning := v_warning + 1; + RAISE NOTICE ' [WARNING] procedure_view: % procedure(s) with performed_datetime before 1900', v_count; + END IF; + + -- ══════════════════════════════════════════════════════════════════════════ + -- SECTION 14 — CODE SYSTEM COVERAGE (spot check) + -- ══════════════════════════════════════════════════════════════════════════ + RAISE NOTICE ''; + RAISE NOTICE '── Section 14: Code system spot-check ────────────────────────────'; + + -- Conditions: expect mostly ICD-10-CM or SNOMED + SELECT COUNT(*) INTO v_count FROM condition_view + WHERE code_system NOT IN ( + 'http://hl7.org/fhir/sid/icd-10-cm', + 'http://hl7.org/fhir/sid/icd-9-cm', + 'http://snomed.info/sct', + 'http://www.icd10data.com/icd10pcs' + ) AND code_system IS NOT NULL; + IF v_count > 0 THEN + RAISE NOTICE ' [INFO] condition_view: % condition(s) use a non-standard code system (may be OK, verify)', v_count; + ELSE + RAISE NOTICE ' [OK] condition_view: code systems look standard (ICD-10-CM / SNOMED)'; + END IF; + + -- Procedures: expect CPT, SNOMED, ICD-10-PCS, HCPCS + SELECT COUNT(*) INTO v_count FROM procedure_view + WHERE code_system NOT IN ( + 'http://www.ama-assn.org/go/cpt', + 'http://snomed.info/sct', + 'http://www.icd10data.com/icd10pcs', + 'https://www.cms.gov/Medicare/Coding/HCPCSReleaseCodeSets' + ) AND code_system IS NOT NULL; + IF v_count > 0 THEN + RAISE NOTICE ' [INFO] procedure_view: % procedure(s) use a non-CPT/SNOMED/ICD-10-PCS code system', v_count; + ELSE + RAISE NOTICE ' [OK] procedure_view: code systems look standard (CPT / SNOMED / ICD-10-PCS)'; + END IF; + + -- Observations: expect LOINC for lab/vital + SELECT COUNT(*) INTO v_count FROM observation_view + WHERE category_code IN ('laboratory','vital-signs') + AND code_system != 'http://loinc.org' + AND code_system IS NOT NULL; + IF v_count > 0 THEN + v_warning := v_warning + 1; + RAISE NOTICE ' [WARNING] observation_view: % lab/vital-sign observation(s) NOT using LOINC — value set matching may fail if value sets expect LOINC codes', v_count; + ELSE + RAISE NOTICE ' [OK] observation_view: lab/vital-sign observations use LOINC'; + END IF; + + -- Immunizations: expect CVX system + SELECT COUNT(*) INTO v_count FROM immunization_view + WHERE vaccine_system != 'http://hl7.org/fhir/sid/cvx' + AND vaccine_system IS NOT NULL; + IF v_count > 0 THEN + v_warning := v_warning + 1; + RAISE NOTICE ' [WARNING] immunization_view: % immunization(s) NOT using CVX vaccine codes — immunization measures use CVX by default', v_count; + ELSE + RAISE NOTICE ' [OK] immunization_view: immunizations use CVX vaccine codes'; + END IF; + + -- ══════════════════════════════════════════════════════════════════════════ + -- SUMMARY + -- ══════════════════════════════════════════════════════════════════════════ + RAISE NOTICE ''; + RAISE NOTICE '══════════════════════════════════════════════════════════════════'; + RAISE NOTICE ' SUMMARY'; + RAISE NOTICE ' CRITICAL issues: % (measure results will be wrong)', v_critical; + RAISE NOTICE ' WARNING issues: % (measure results may be incomplete)', v_warning; + RAISE NOTICE '══════════════════════════════════════════════════════════════════'; + + IF v_critical > 0 THEN + RAISE NOTICE ' ACTION REQUIRED: resolve CRITICAL issues before running measures.'; + ELSIF v_warning > 0 THEN + RAISE NOTICE ' REVIEW WARNINGS: investigate before reporting measure results.'; + ELSE + RAISE NOTICE ' All checks passed — data looks ready for measure execution.'; + END IF; + RAISE NOTICE ''; + +END $$; diff --git a/scripts/hapi-fhir-sql-on-fhir/install.sql b/scripts/hapi-fhir-sql-on-fhir/install.sql new file mode 100644 index 0000000..6335e81 --- /dev/null +++ b/scripts/hapi-fhir-sql-on-fhir/install.sql @@ -0,0 +1,68 @@ +-- ============================================================ +-- CQL Studio SQL-on-FHIR Views — Boot Install Script +-- Target: HAPI FHIR JPA 6.x / 7.x on PostgreSQL 12+ +-- +-- Usage: +-- psql $DATABASE_URL -f install.sql +-- or called programmatically by CQL Studio Server on boot (Issue #20). +-- +-- Properties: +-- - Idempotent: safe to run on every server boot (CREATE OR REPLACE VIEW) +-- - Version-aware: tracks installed versions in cql_studio_view_version +-- - Non-destructive: never drops existing views or data +-- - Transactional: entire script runs in one transaction +-- +-- HAPI FHIR JPA schema assumptions: +-- HFJ_RESOURCE — resource registry (columns: RES_ID, FHIR_ID, RES_TYPE, +-- RES_VER, RES_DELETED_AT, RES_UPDATED) +-- HFJ_RES_VER — versioned resource content (columns: RES_ID, RES_VER, +-- RES_TEXT bytea, RES_TEXT_VC varchar, RES_ENCODING) +-- +-- JSONC (compressed) resources in RES_TEXT are NOT supported by these views. +-- HAPI uses JSON encoding by default; switch HAPI's encoder to JSON if views +-- return NULLs for all resource fields. +-- ============================================================ + +BEGIN; + +-- ── 1. Version tracking infrastructure ─────────────────────────────────────── +\ir views/000_schema_version.sql + +-- ── 2. Core resource views ─────────────────────────────────────────────────── +\ir views/001_patient_view.sql +\ir views/002_observation_view.sql +\ir views/003_condition_view.sql +\ir views/004_procedure_view.sql +\ir views/005_encounter_view.sql +\ir views/006_medication_request_view.sql +\ir views/007_diagnostic_report_view.sql + +-- ── 3. US Core 6.1 supplemental views ──────────────────────────────────────── +\ir views/009_coverage_view.sql +\ir views/010_allergy_intolerance_view.sql +\ir views/011_immunization_view.sql +\ir views/012_service_request_view.sql + +-- ── 4. Terminology support ──────────────────────────────────────────────────── +\ir views/008_value_set_expansion_view.sql + +-- ── 4. Summary ──────────────────────────────────────────────────────────────── +DO $$ +DECLARE + v_count INTEGER; + v_rec RECORD; +BEGIN + SELECT COUNT(*) INTO v_count FROM cql_studio_view_version; + RAISE NOTICE '=== CQL Studio SQL-on-FHIR views installed ==='; + RAISE NOTICE '% view(s) registered:', v_count; + FOR v_rec IN + SELECT view_name, installed_ver, updated_at + FROM cql_studio_view_version + ORDER BY view_name + LOOP + RAISE NOTICE ' %-35s v%s (%s)', v_rec.view_name, v_rec.installed_ver, v_rec.updated_at; + END LOOP; + RAISE NOTICE '==============================================='; +END $$; + +COMMIT; diff --git a/scripts/hapi-fhir-sql-on-fhir/test/run_tests.sql b/scripts/hapi-fhir-sql-on-fhir/test/run_tests.sql new file mode 100644 index 0000000..c8ec578 --- /dev/null +++ b/scripts/hapi-fhir-sql-on-fhir/test/run_tests.sql @@ -0,0 +1,784 @@ +-- ============================================================ +-- CQL Studio SQL-on-FHIR — Integration Test Suite +-- +-- Usage (from scripts/hapi-fhir-sql-on-fhir/ directory): +-- psql $DATABASE_URL -f test/run_tests.sql +-- +-- Design: +-- • Creates an isolated 'cql_test' schema with minimal mock +-- HFJ_RESOURCE / HFJ_RES_VER tables (only the columns the +-- views use), then installs ALL view SQL files via \ir. +-- • Inserts synthetic FHIR R4 JSON covering every view column +-- and edge case (nullable paths, polymorphic types, extensions). +-- • Asserts expected column values via RAISE WARNING on failure. +-- • Always ROLLBACKs — all DDL is transactional in PostgreSQL, +-- so no schema or data persists after the script finishes. +-- • Exit: RAISE EXCEPTION if any assertion fails (psql exits 3). +-- +-- Prerequisites: PostgreSQL 12+, no existing 'cql_test' schema. +-- ============================================================ + +BEGIN; + +-- ── 1. Isolated test schema ─────────────────────────────────────────────────── +DROP SCHEMA IF EXISTS cql_test CASCADE; +CREATE SCHEMA cql_test; +SET search_path TO cql_test, public; + +RAISE NOTICE '── Creating mock HAPI FHIR JPA tables in cql_test schema ────────────'; + +-- ── 2. Mock HAPI FHIR JPA tables ───────────────────────────────────────────── +-- Only the columns accessed by the view SQL files. +CREATE TABLE HFJ_RESOURCE ( + RES_ID BIGINT NOT NULL, + FHIR_ID TEXT NOT NULL, + RES_TYPE TEXT NOT NULL, + RES_VER BIGINT NOT NULL DEFAULT 1, + RES_DELETED_AT TIMESTAMP, + RES_UPDATED TIMESTAMP NOT NULL DEFAULT NOW() +); + +CREATE TABLE HFJ_RES_VER ( + RES_ID BIGINT NOT NULL, + RES_VER BIGINT NOT NULL, + RES_ENCODING TEXT NOT NULL DEFAULT 'JSON', + RES_TEXT_VC TEXT, + RES_TEXT BYTEA +); + +-- ── 3. Version tracking infrastructure (inlined — mirrors 000_schema_version) ─ +CREATE TABLE cql_studio_view_version ( + view_name TEXT NOT NULL PRIMARY KEY, + installed_ver INTEGER NOT NULL, + description TEXT, + updated_at TIMESTAMP NOT NULL DEFAULT NOW() +); + +CREATE OR REPLACE FUNCTION cql_studio_set_view_version( + p_view_name TEXT, p_ver INTEGER, p_desc TEXT DEFAULT NULL +) RETURNS VOID LANGUAGE plpgsql AS $$ +BEGIN + INSERT INTO cql_studio_view_version (view_name, installed_ver, description, updated_at) + VALUES (p_view_name, p_ver, p_desc, NOW()) + ON CONFLICT (view_name) DO UPDATE + SET installed_ver = EXCLUDED.installed_ver, + description = EXCLUDED.description, + updated_at = NOW(); +END $$; + +-- ── 4. Install all views (search_path routes them into cql_test schema) ─────── +RAISE NOTICE '── Installing views ──────────────────────────────────────────────────'; +\ir ../views/001_patient_view.sql +\ir ../views/002_observation_view.sql +\ir ../views/003_condition_view.sql +\ir ../views/004_procedure_view.sql +\ir ../views/005_encounter_view.sql +\ir ../views/006_medication_request_view.sql +\ir ../views/007_diagnostic_report_view.sql +\ir ../views/008_value_set_expansion_view.sql +\ir ../views/009_coverage_view.sql +\ir ../views/010_allergy_intolerance_view.sql +\ir ../views/011_immunization_view.sql +\ir ../views/012_service_request_view.sql + +-- ── 5. Insert synthetic test data ───────────────────────────────────────────── +RAISE NOTICE '── Inserting test fixtures ───────────────────────────────────────────'; + +-- Resource registry (RES_ID is explicit here since the table has no sequence) +INSERT INTO HFJ_RESOURCE (RES_ID, FHIR_ID, RES_TYPE, RES_VER, RES_UPDATED) VALUES + ( 1, 'test-pt-001', 'Patient', 1, '2024-01-01 00:00:00'), + ( 2, 'test-pt-002', 'Patient', 1, '2024-01-01 00:00:00'), + ( 3, 'test-enc-001', 'Encounter', 1, '2024-03-15 00:00:00'), + ( 4, 'test-cond-001', 'Condition', 1, '2024-01-15 00:00:00'), + ( 5, 'test-obs-001', 'Observation', 1, '2024-03-15 00:00:00'), + ( 6, 'test-obs-002', 'Observation', 1, '2024-03-15 00:00:00'), + ( 7, 'test-proc-001', 'Procedure', 1, '2024-03-15 00:00:00'), + ( 8, 'test-proc-002', 'Procedure', 1, '2024-02-01 00:00:00'), + ( 9, 'test-med-001', 'MedicationRequest', 1, '2024-03-15 00:00:00'), + (10, 'test-med-002', 'MedicationRequest', 1, '2024-03-15 00:00:00'), + (11, 'test-dr-001', 'DiagnosticReport', 1, '2024-03-15 00:00:00'), + (12, 'test-cov-001', 'Coverage', 1, '2024-01-01 00:00:00'), + (13, 'test-ai-001', 'AllergyIntolerance', 1, '2020-03-15 00:00:00'), + (14, 'test-imm-001', 'Immunization', 1, '2024-10-15 00:00:00'), + (15, 'test-imm-002', 'Immunization', 1, '2024-10-15 00:00:00'), + (16, 'test-sr-001', 'ServiceRequest', 1, '2024-03-15 00:00:00'), + (17, 'test-vs-001', 'ValueSet', 1, '2024-01-01 00:00:00'); + +-- Resource content (FHIR R4 JSON covering all view columns + edge cases) +INSERT INTO HFJ_RES_VER (RES_ID, RES_VER, RES_ENCODING, RES_TEXT_VC) VALUES + +-- ── Patient 001: female, active, US Core race+ethnicity, official name ──────── +(1, 1, 'JSON', $JSON$ +{ + "resourceType": "Patient", + "id": "test-pt-001", + "active": true, + "gender": "female", + "birthDate": "1975-06-15", + "name": [ + { "use": "official", "family": "Testovic", "given": ["Ana"] }, + { "use": "nickname", "family": "T", "given": ["Annabelle"] } + ], + "extension": [ + { + "url": "http://hl7.org/fhir/us/core/StructureDefinition/us-core-race", + "extension": [ + { "url": "ombCategory", + "valueCoding": { "system": "urn:oid:2.16.840.1.113883.6.238", + "code": "2106-3", "display": "White" } } + ] + }, + { + "url": "http://hl7.org/fhir/us/core/StructureDefinition/us-core-ethnicity", + "extension": [ + { "url": "ombCategory", + "valueCoding": { "system": "urn:oid:2.16.840.1.113883.6.238", + "code": "2186-5", "display": "Not Hispanic or Latino" } } + ] + } + ] +} +$JSON$), + +-- ── Patient 002: male, deceasedDateTime ─────────────────────────────────────── +(2, 1, 'JSON', $JSON$ +{ + "resourceType": "Patient", + "id": "test-pt-002", + "gender": "male", + "birthDate": "1950-01-15", + "deceasedDateTime": "2024-03-01T09:00:00Z" +} +$JSON$), + +-- ── Encounter 001: finished, AMB, Office Visit, with period ─────────────────── +(3, 1, 'JSON', $JSON$ +{ + "resourceType": "Encounter", + "id": "test-enc-001", + "status": "finished", + "class": { "system": "http://terminology.hl7.org/CodeSystem/v3-ActCode", + "code": "AMB", "display": "ambulatory" }, + "type": [{ "coding": [{ "system": "http://snomed.info/sct", + "code": "185349003", + "display": "Encounter for check up" }] }], + "subject": { "reference": "Patient/test-pt-001" }, + "period": { "start": "2024-03-15T10:00:00Z", "end": "2024-03-15T11:00:00Z" }, + "serviceProvider": { "reference": "Organization/test-org-001" } +} +$JSON$), + +-- ── Condition 001: active, confirmed, ICD-10 Z12.11, problem-list-item ──────── +(4, 1, 'JSON', $JSON$ +{ + "resourceType": "Condition", + "id": "test-cond-001", + "clinicalStatus": { "coding": [{ "system": "http://terminology.hl7.org/CodeSystem/condition-clinical", + "code": "active" }] }, + "verificationStatus": { "coding": [{ "system": "http://terminology.hl7.org/CodeSystem/condition-ver-status", + "code": "confirmed" }] }, + "category": [{ "coding": [{ "system": "http://terminology.hl7.org/CodeSystem/condition-category", + "code": "problem-list-item" }] }], + "code": { + "coding": [{ "system": "http://hl7.org/fhir/sid/icd-10-cm", + "code": "Z12.11", + "display": "Encounter for screening for malignant neoplasm of colon" }], + "text": "Colorectal cancer screening" + }, + "subject": { "reference": "Patient/test-pt-001" }, + "encounter": { "reference": "Encounter/test-enc-001" }, + "onsetDateTime": "2024-01-10", + "recordedDate": "2024-01-15" +} +$JSON$), + +-- ── Observation 001: final lab, effectiveDateTime, valueCodeableConcept ─────── +(5, 1, 'JSON', $JSON$ +{ + "resourceType": "Observation", + "id": "test-obs-001", + "status": "final", + "category": [{ "coding": [{ "system": "http://terminology.hl7.org/CodeSystem/observation-category", + "code": "laboratory" }] }], + "code": { "coding": [{ "system": "http://loinc.org", + "code": "14563-1", + "display": "Hemoglobin [Mass/volume] in Arterial blood" }], + "text": "Hemoglobin" }, + "subject": { "reference": "Patient/test-pt-001" }, + "encounter": { "reference": "Encounter/test-enc-001" }, + "effectiveDateTime": "2024-03-15T10:30:00Z", + "valueCodeableConcept": { "coding": [{ "system": "http://snomed.info/sct", + "code": "260373001", + "display": "Detected" }] } +} +$JSON$), + +-- ── Observation 002: vital-signs, effectivePeriod, valueQuantity ────────────── +(6, 1, 'JSON', $JSON$ +{ + "resourceType": "Observation", + "id": "test-obs-002", + "status": "final", + "category": [{ "coding": [{ "system": "http://terminology.hl7.org/CodeSystem/observation-category", + "code": "vital-signs" }] }], + "code": { "coding": [{ "system": "http://loinc.org", + "code": "39156-5", + "display": "Body mass index (BMI) [Ratio]" }] }, + "subject": { "reference": "Patient/test-pt-001" }, + "effectivePeriod": { "start": "2024-03-15T10:00:00Z", "end": "2024-03-15T10:05:00Z" }, + "valueQuantity": { "value": 24.5, "unit": "kg/m2", + "system": "http://unitsofmeasure.org", "code": "kg/m2" } +} +$JSON$), + +-- ── Procedure 001: completed, CPT, performedDateTime ───────────────────────── +(7, 1, 'JSON', $JSON$ +{ + "resourceType": "Procedure", + "id": "test-proc-001", + "status": "completed", + "category": { "coding": [{ "system": "http://snomed.info/sct", + "code": "103693007", "display": "Diagnostic procedure" }] }, + "code": { "coding": [{ "system": "http://www.ama-assn.org/go/cpt", + "code": "45378", + "display": "Colonoscopy, flexible; diagnostic" }], + "text": "Colonoscopy" }, + "subject": { "reference": "Patient/test-pt-001" }, + "encounter": { "reference": "Encounter/test-enc-001" }, + "performedDateTime": "2024-03-15T10:30:00Z" +} +$JSON$), + +-- ── Procedure 002: completed, SNOMED, performedPeriod ──────────────────────── +(8, 1, 'JSON', $JSON$ +{ + "resourceType": "Procedure", + "id": "test-proc-002", + "status": "completed", + "code": { "coding": [{ "system": "http://snomed.info/sct", + "code": "80146002", "display": "Appendectomy" }] }, + "subject": { "reference": "Patient/test-pt-001" }, + "performedPeriod": { "start": "2024-02-01T08:00:00Z", "end": "2024-02-01T09:30:00Z" } +} +$JSON$), + +-- ── MedicationRequest 001: medicationCodeableConcept (inline code) ──────────── +(9, 1, 'JSON', $JSON$ +{ + "resourceType": "MedicationRequest", + "id": "test-med-001", + "status": "active", + "intent": "order", + "medicationCodeableConcept": { + "coding": [{ "system": "http://www.nlm.nih.gov/research/umls/rxnorm", + "code": "1049502", + "display": "12 HR Oxycodone Hydrochloride 80 MG Extended Release Oral Tablet" }] + }, + "subject": { "reference": "Patient/test-pt-001" }, + "encounter": { "reference": "Encounter/test-enc-001" }, + "authoredOn": "2024-03-15", + "requester": { "reference": "Practitioner/test-prac-001" } +} +$JSON$), + +-- ── MedicationRequest 002: medicationReference (external Medication resource) ─ +(10, 1, 'JSON', $JSON$ +{ + "resourceType": "MedicationRequest", + "id": "test-med-002", + "status": "active", + "intent": "order", + "medicationReference": { "reference": "Medication/test-medication-001" }, + "subject": { "reference": "Patient/test-pt-001" }, + "authoredOn": "2024-03-16" +} +$JSON$), + +-- ── DiagnosticReport 001: final lab, effectiveDateTime ─────────────────────── +(11, 1, 'JSON', $JSON$ +{ + "resourceType": "DiagnosticReport", + "id": "test-dr-001", + "status": "final", + "category": [{ "coding": [{ "system": "http://terminology.hl7.org/CodeSystem/v2-0074", + "code": "LAB", "display": "Laboratory" }] }], + "code": { "coding": [{ "system": "http://loinc.org", + "code": "58410-2", + "display": "Complete blood count panel - Blood by Automated count" }] }, + "subject": { "reference": "Patient/test-pt-001" }, + "encounter": { "reference": "Encounter/test-enc-001" }, + "effectiveDateTime": "2024-03-15T11:00:00Z", + "issued": "2024-03-15T12:00:00.000Z" +} +$JSON$), + +-- ── Coverage 001: active, SUBSIDIZ type, self relationship, plan class ──────── +(12, 1, 'JSON', $JSON$ +{ + "resourceType": "Coverage", + "id": "test-cov-001", + "status": "active", + "type": { "coding": [{ "system": "http://terminology.hl7.org/CodeSystem/v3-ActCode", + "code": "SUBSIDIZ", "display": "Subsidized" }] }, + "subscriber": { "reference": "Patient/test-pt-001" }, + "subscriberId": "1EG4-TE5-MK72", + "beneficiary": { "reference": "Patient/test-pt-001" }, + "relationship": { "coding": [{ "system": "http://terminology.hl7.org/CodeSystem/subscriber-relationship", + "code": "self", "display": "Self" }] }, + "period": { "start": "2024-01-01", "end": "2024-12-31" }, + "payor": [{ "identifier": { "system": "http://hl7.org/fhir/sid/us-npi", + "value": "1234567890" } }], + "class": [{ "type": { "coding": [{ "system": "http://terminology.hl7.org/CodeSystem/coverage-class", + "code": "plan", "display": "Plan" }] }, + "value": "B37FC", + "name": "Full Coverage Plan" }], + "order": 1 +} +$JSON$), + +-- ── AllergyIntolerance 001: active, allergy, medication, high, severe reaction ─ +(13, 1, 'JSON', $JSON$ +{ + "resourceType": "AllergyIntolerance", + "id": "test-ai-001", + "clinicalStatus": { "coding": [{ "system": "http://terminology.hl7.org/CodeSystem/allergyintolerance-clinical", + "code": "active" }] }, + "verificationStatus": { "coding": [{ "system": "http://terminology.hl7.org/CodeSystem/allergyintolerance-verification", + "code": "confirmed" }] }, + "type": "allergy", + "category": ["medication"], + "criticality": "high", + "code": { "coding": [{ "system": "http://www.nlm.nih.gov/research/umls/rxnorm", + "code": "7980", "display": "Penicillin" }], + "text": "Penicillin" }, + "patient": { "reference": "Patient/test-pt-001" }, + "onsetDateTime": "2020-03-01", + "reaction": [{ + "manifestation": [{ "coding": [{ "system": "http://snomed.info/sct", + "code": "271807003", + "display": "Eruption of skin" }] }], + "severity": "severe" + }], + "recordedDate": "2020-03-15" +} +$JSON$), + +-- ── Immunization 001: completed, CVX 140, occurrenceDateTime, lot, site ─────── +(14, 1, 'JSON', $JSON$ +{ + "resourceType": "Immunization", + "id": "test-imm-001", + "status": "completed", + "vaccineCode": { "coding": [{ "system": "http://hl7.org/fhir/sid/cvx", + "code": "140", + "display": "Influenza, seasonal, injectable, preservative free" }] }, + "patient": { "reference": "Patient/test-pt-001" }, + "encounter": { "reference": "Encounter/test-enc-001" }, + "occurrenceDateTime": "2024-10-15T10:00:00Z", + "primarySource": true, + "lotNumber": "LOT2024A", + "expirationDate": "2025-03-01", + "site": { "coding": [{ "system": "http://terminology.hl7.org/CodeSystem/v3-ActSite", + "code": "LA", "display": "left arm" }] }, + "route": { "coding": [{ "system": "http://terminology.hl7.org/CodeSystem/v3-RouteOfAdministration", + "code": "IM", "display": "Injection, intramuscular" }] }, + "doseQuantity": { "value": 0.5, "unit": "mL" } +} +$JSON$), + +-- ── Immunization 002: not-done, statusReason, occurrenceString ──────────────── +(15, 1, 'JSON', $JSON$ +{ + "resourceType": "Immunization", + "id": "test-imm-002", + "status": "not-done", + "statusReason": { "coding": [{ "system": "http://terminology.hl7.org/CodeSystem/v3-ActReason", + "code": "MEDPREC", + "display": "Medical Precaution" }] }, + "vaccineCode": { "coding": [{ "system": "http://hl7.org/fhir/sid/cvx", + "code": "140", + "display": "Influenza vaccine" }] }, + "patient": { "reference": "Patient/test-pt-001" }, + "occurrenceString": "October 2024", + "primarySource": false +} +$JSON$), + +-- ── ServiceRequest 001: active order, colonoscopy, US Core 6.1 fields ───────── +(16, 1, 'JSON', $JSON$ +{ + "resourceType": "ServiceRequest", + "id": "test-sr-001", + "status": "active", + "intent": "order", + "category": [{ "coding": [{ "system": "http://snomed.info/sct", + "code": "108252007", + "display": "Laboratory procedure" }] }], + "code": { "coding": [{ "system": "http://www.ama-assn.org/go/cpt", + "code": "45378", + "display": "Colonoscopy, flexible; diagnostic" }], + "text": "Colonoscopy" }, + "subject": { "reference": "Patient/test-pt-001" }, + "encounter": { "reference": "Encounter/test-enc-001" }, + "occurrenceDateTime": "2024-04-01T09:00:00Z", + "authoredOn": "2024-03-15T10:00:00Z", + "requester": { "reference": "Practitioner/test-prac-001" }, + "performer": [{ "reference": "Practitioner/test-prac-001" }], + "reasonCode": [{ "coding": [{ "system": "http://snomed.info/sct", + "code": "44273001", + "display": "Screening for cancer" }] }], + "doNotPerform": false, + "priority": "routine", + "insurance": [{ "reference": "Coverage/test-cov-001" }] +} +$JSON$), + +-- ── ValueSet 001: pre-expanded, 3 CPT colonoscopy codes ────────────────────── +(17, 1, 'JSON', $JSON$ +{ + "resourceType": "ValueSet", + "id": "test-vs-001", + "url": "http://example.org/test/colonoscopy", + "expansion": { + "contains": [ + { "system": "http://www.ama-assn.org/go/cpt", "code": "45378", + "display": "Colonoscopy, flexible; diagnostic" }, + { "system": "http://www.ama-assn.org/go/cpt", "code": "44388", + "display": "Colonoscopy through stoma; diagnostic" }, + { "system": "http://www.ama-assn.org/go/cpt", "code": "44393", + "display": "Colonoscopy with ablation" } + ] + } +} +$JSON$); + +-- ── 6. Assertions ───────────────────────────────────────────────────────────── +RAISE NOTICE '── Running view assertions ───────────────────────────────────────────'; + +DO $$ +DECLARE + v_pass INTEGER := 0; + v_fail INTEGER := 0; + v_txt TEXT; + v_num NUMERIC; + v_bool BOOLEAN; + v_date DATE; + v_ts TIMESTAMP; + + -- Helper: assert TEXT equality + PROCEDURE assert_eq(p_test TEXT, p_got TEXT, p_want TEXT) AS $$ + BEGIN + IF p_got IS DISTINCT FROM p_want THEN + RAISE WARNING '[FAIL] %: expected %, got %', p_test, COALESCE(p_want,'(null)'), COALESCE(p_got,'(null)'); + v_fail := v_fail + 1; + ELSE + RAISE NOTICE ' [PASS] %', p_test; + v_pass := v_pass + 1; + END IF; + END $$; + + PROCEDURE assert_true(p_test TEXT, p_val BOOLEAN) AS $$ + BEGIN + IF NOT COALESCE(p_val, FALSE) THEN + RAISE WARNING '[FAIL] %', p_test; + v_fail := v_fail + 1; + ELSE + RAISE NOTICE ' [PASS] %', p_test; + v_pass := v_pass + 1; + END IF; + END $$; + + PROCEDURE assert_null(p_test TEXT, p_val TEXT) AS $$ + BEGIN + IF p_val IS NOT NULL THEN + RAISE WARNING '[FAIL] % — expected NULL, got %', p_test, p_val; + v_fail := v_fail + 1; + ELSE + RAISE NOTICE ' [PASS] %', p_test; + v_pass := v_pass + 1; + END IF; + END $$; + +BEGIN + RAISE NOTICE ''; + RAISE NOTICE '┌─ patient_view ─────────────────────────────────────────────────'; + + -- Demographics + SELECT gender INTO v_txt FROM patient_view WHERE id = 'test-pt-001'; + CALL assert_eq('patient_view.gender', v_txt, 'female'); + SELECT birthdate::text INTO v_txt FROM patient_view WHERE id = 'test-pt-001'; + CALL assert_eq('patient_view.birthdate', v_txt, '1975-06-15'); + SELECT active::text INTO v_txt FROM patient_view WHERE id = 'test-pt-001'; + CALL assert_eq('patient_view.active', v_txt, 'true'); + -- Official name (should prefer use='official') + SELECT name_family INTO v_txt FROM patient_view WHERE id = 'test-pt-001'; + CALL assert_eq('patient_view.name_family (official preferred)', v_txt, 'Testovic'); + SELECT name_given INTO v_txt FROM patient_view WHERE id = 'test-pt-001'; + CALL assert_eq('patient_view.name_given', v_txt, 'Ana'); + -- US Core extensions + SELECT race_code INTO v_txt FROM patient_view WHERE id = 'test-pt-001'; + CALL assert_eq('patient_view.race_code', v_txt, '2106-3'); + SELECT ethnicity_code INTO v_txt FROM patient_view WHERE id = 'test-pt-001'; + CALL assert_eq('patient_view.ethnicity_code', v_txt, '2186-5'); + -- Deceased + SELECT deceased::text INTO v_txt FROM patient_view WHERE id = 'test-pt-001'; + CALL assert_eq('patient_view.deceased (alive → false)', v_txt, 'false'); + SELECT deceased::text INTO v_txt FROM patient_view WHERE id = 'test-pt-002'; + CALL assert_eq('patient_view.deceased (deceasedDateTime → true)', v_txt, 'true'); + SELECT deceased_datetime::date::text INTO v_txt FROM patient_view WHERE id = 'test-pt-002'; + CALL assert_eq('patient_view.deceased_datetime', v_txt, '2024-03-01'); + + RAISE NOTICE '├─ encounter_view ───────────────────────────────────────────────'; + SELECT status INTO v_txt FROM encounter_view WHERE id = 'test-enc-001'; + CALL assert_eq('encounter_view.status', v_txt, 'finished'); + SELECT class_code INTO v_txt FROM encounter_view WHERE id = 'test-enc-001'; + CALL assert_eq('encounter_view.class_code', v_txt, 'AMB'); + SELECT type_code INTO v_txt FROM encounter_view WHERE id = 'test-enc-001'; + CALL assert_eq('encounter_view.type_code', v_txt, '185349003'); + SELECT subject_id INTO v_txt FROM encounter_view WHERE id = 'test-enc-001'; + CALL assert_eq('encounter_view.subject_id', v_txt, 'test-pt-001'); + SELECT period_start::date::text INTO v_txt FROM encounter_view WHERE id = 'test-enc-001'; + CALL assert_eq('encounter_view.period_start', v_txt, '2024-03-15'); + SELECT period_end::date::text INTO v_txt FROM encounter_view WHERE id = 'test-enc-001'; + CALL assert_eq('encounter_view.period_end', v_txt, '2024-03-15'); + SELECT service_provider_id INTO v_txt FROM encounter_view WHERE id = 'test-enc-001'; + CALL assert_eq('encounter_view.service_provider_id', v_txt, 'test-org-001'); + + RAISE NOTICE '├─ condition_view ───────────────────────────────────────────────'; + SELECT clinical_status INTO v_txt FROM condition_view WHERE id = 'test-cond-001'; + CALL assert_eq('condition_view.clinical_status', v_txt, 'active'); + SELECT verification_status INTO v_txt FROM condition_view WHERE id = 'test-cond-001'; + CALL assert_eq('condition_view.verification_status', v_txt, 'confirmed'); + SELECT code INTO v_txt FROM condition_view WHERE id = 'test-cond-001'; + CALL assert_eq('condition_view.code', v_txt, 'Z12.11'); + SELECT code_system INTO v_txt FROM condition_view WHERE id = 'test-cond-001'; + CALL assert_eq('condition_view.code_system', v_txt, 'http://hl7.org/fhir/sid/icd-10-cm'); + SELECT category_code INTO v_txt FROM condition_view WHERE id = 'test-cond-001'; + CALL assert_eq('condition_view.category_code', v_txt, 'problem-list-item'); + SELECT onset_datetime::date::text INTO v_txt FROM condition_view WHERE id = 'test-cond-001'; + CALL assert_eq('condition_view.onset_datetime', v_txt, '2024-01-10'); + SELECT encounter_id INTO v_txt FROM condition_view WHERE id = 'test-cond-001'; + CALL assert_eq('condition_view.encounter_id', v_txt, 'test-enc-001'); + + RAISE NOTICE '├─ observation_view ─────────────────────────────────────────────'; + -- obs-001: effectiveDateTime, valueCodeableConcept + SELECT status INTO v_txt FROM observation_view WHERE id = 'test-obs-001'; + CALL assert_eq('observation_view.status', v_txt, 'final'); + SELECT category_code INTO v_txt FROM observation_view WHERE id = 'test-obs-001'; + CALL assert_eq('observation_view.category_code', v_txt, 'laboratory'); + SELECT code INTO v_txt FROM observation_view WHERE id = 'test-obs-001'; + CALL assert_eq('observation_view.code', v_txt, '14563-1'); + SELECT effective_datetime::date::text INTO v_txt FROM observation_view WHERE id = 'test-obs-001'; + CALL assert_eq('observation_view.effective_datetime (dateTime)', v_txt, '2024-03-15'); + SELECT value_code INTO v_txt FROM observation_view WHERE id = 'test-obs-001'; + CALL assert_eq('observation_view.value_code', v_txt, '260373001'); + -- obs-002: effectivePeriod, valueQuantity + SELECT effective_datetime::date::text INTO v_txt FROM observation_view WHERE id = 'test-obs-002'; + CALL assert_eq('observation_view.effective_datetime (period→start)', v_txt, '2024-03-15'); + SELECT effective_start::date::text INTO v_txt FROM observation_view WHERE id = 'test-obs-002'; + CALL assert_eq('observation_view.effective_start', v_txt, '2024-03-15'); + SELECT value_quantity::text INTO v_txt FROM observation_view WHERE id = 'test-obs-002'; + CALL assert_eq('observation_view.value_quantity', v_txt, '24.5'); + SELECT value_unit INTO v_txt FROM observation_view WHERE id = 'test-obs-002'; + CALL assert_eq('observation_view.value_unit', v_txt, 'kg/m2'); + -- obs-001: value_quantity must be NULL (no valueQuantity in that resource) + SELECT value_quantity::text INTO v_txt FROM observation_view WHERE id = 'test-obs-001'; + CALL assert_null('observation_view.value_quantity NULL for non-Quantity obs', v_txt); + + RAISE NOTICE '├─ procedure_view ───────────────────────────────────────────────'; + -- proc-001: performedDateTime + SELECT status INTO v_txt FROM procedure_view WHERE id = 'test-proc-001'; + CALL assert_eq('procedure_view.status', v_txt, 'completed'); + SELECT code INTO v_txt FROM procedure_view WHERE id = 'test-proc-001'; + CALL assert_eq('procedure_view.code', v_txt, '45378'); + SELECT performed_datetime::date::text INTO v_txt FROM procedure_view WHERE id = 'test-proc-001'; + CALL assert_eq('procedure_view.performed_datetime (dateTime)', v_txt, '2024-03-15'); + SELECT category_code INTO v_txt FROM procedure_view WHERE id = 'test-proc-001'; + CALL assert_eq('procedure_view.category_code', v_txt, '103693007'); + SELECT encounter_id INTO v_txt FROM procedure_view WHERE id = 'test-proc-001'; + CALL assert_eq('procedure_view.encounter_id', v_txt, 'test-enc-001'); + -- proc-002: performedPeriod + SELECT performed_datetime::date::text INTO v_txt FROM procedure_view WHERE id = 'test-proc-002'; + CALL assert_eq('procedure_view.performed_datetime (period→start)', v_txt, '2024-02-01'); + SELECT performed_start::date::text INTO v_txt FROM procedure_view WHERE id = 'test-proc-002'; + CALL assert_eq('procedure_view.performed_start', v_txt, '2024-02-01'); + SELECT performed_end::date::text INTO v_txt FROM procedure_view WHERE id = 'test-proc-002'; + CALL assert_eq('procedure_view.performed_end', v_txt, '2024-02-01'); + + RAISE NOTICE '├─ medication_request_view ──────────────────────────────────────'; + -- med-001: medicationCodeableConcept + SELECT status INTO v_txt FROM medication_request_view WHERE id = 'test-med-001'; + CALL assert_eq('medication_request_view.status', v_txt, 'active'); + SELECT medication_code INTO v_txt FROM medication_request_view WHERE id = 'test-med-001'; + CALL assert_eq('medication_request_view.medication_code', v_txt, '1049502'); + SELECT authored_on::date::text INTO v_txt FROM medication_request_view WHERE id = 'test-med-001'; + CALL assert_eq('medication_request_view.authored_on', v_txt, '2024-03-15'); + SELECT encounter_id INTO v_txt FROM medication_request_view WHERE id = 'test-med-001'; + CALL assert_eq('medication_request_view.encounter_id', v_txt, 'test-enc-001'); + -- med-002: medicationReference (code should be NULL, ref_id populated) + SELECT medication_code INTO v_txt FROM medication_request_view WHERE id = 'test-med-002'; + CALL assert_null('medication_request_view.medication_code NULL for medicationReference', v_txt); + SELECT medication_ref_id INTO v_txt FROM medication_request_view WHERE id = 'test-med-002'; + CALL assert_eq('medication_request_view.medication_ref_id', v_txt, 'test-medication-001'); + + RAISE NOTICE '├─ diagnostic_report_view ──────────────────────────────────────'; + SELECT status INTO v_txt FROM diagnostic_report_view WHERE id = 'test-dr-001'; + CALL assert_eq('diagnostic_report_view.status', v_txt, 'final'); + SELECT code INTO v_txt FROM diagnostic_report_view WHERE id = 'test-dr-001'; + CALL assert_eq('diagnostic_report_view.code', v_txt, '58410-2'); + SELECT category_code INTO v_txt FROM diagnostic_report_view WHERE id = 'test-dr-001'; + CALL assert_eq('diagnostic_report_view.category_code', v_txt, 'LAB'); + SELECT effective_datetime::date::text INTO v_txt FROM diagnostic_report_view WHERE id = 'test-dr-001'; + CALL assert_eq('diagnostic_report_view.effective_datetime', v_txt, '2024-03-15'); + SELECT issued::date::text INTO v_txt FROM diagnostic_report_view WHERE id = 'test-dr-001'; + CALL assert_eq('diagnostic_report_view.issued', v_txt, '2024-03-15'); + SELECT encounter_id INTO v_txt FROM diagnostic_report_view WHERE id = 'test-dr-001'; + CALL assert_eq('diagnostic_report_view.encounter_id', v_txt, 'test-enc-001'); + + RAISE NOTICE '├─ coverage_view ────────────────────────────────────────────────'; + SELECT status INTO v_txt FROM coverage_view WHERE id = 'test-cov-001'; + CALL assert_eq('coverage_view.status', v_txt, 'active'); + SELECT beneficiary_id INTO v_txt FROM coverage_view WHERE id = 'test-cov-001'; + CALL assert_eq('coverage_view.beneficiary_id', v_txt, 'test-pt-001'); + SELECT type_code INTO v_txt FROM coverage_view WHERE id = 'test-cov-001'; + CALL assert_eq('coverage_view.type_code', v_txt, 'SUBSIDIZ'); + SELECT relationship_code INTO v_txt FROM coverage_view WHERE id = 'test-cov-001'; + CALL assert_eq('coverage_view.relationship_code', v_txt, 'self'); + SELECT subscriber_id_value INTO v_txt FROM coverage_view WHERE id = 'test-cov-001'; + CALL assert_eq('coverage_view.subscriber_id_value', v_txt, '1EG4-TE5-MK72'); + SELECT period_start::text INTO v_txt FROM coverage_view WHERE id = 'test-cov-001'; + CALL assert_eq('coverage_view.period_start', v_txt, '2024-01-01'); + SELECT period_end::text INTO v_txt FROM coverage_view WHERE id = 'test-cov-001'; + CALL assert_eq('coverage_view.period_end', v_txt, '2024-12-31'); + SELECT class_type_code INTO v_txt FROM coverage_view WHERE id = 'test-cov-001'; + CALL assert_eq('coverage_view.class_type_code', v_txt, 'plan'); + SELECT class_value INTO v_txt FROM coverage_view WHERE id = 'test-cov-001'; + CALL assert_eq('coverage_view.class_value', v_txt, 'B37FC'); + SELECT class_name INTO v_txt FROM coverage_view WHERE id = 'test-cov-001'; + CALL assert_eq('coverage_view.class_name', v_txt, 'Full Coverage Plan'); + SELECT priority_order::text INTO v_txt FROM coverage_view WHERE id = 'test-cov-001'; + CALL assert_eq('coverage_view.priority_order', v_txt, '1'); + -- payor via identifier (no reference ID) + SELECT payor_identifier INTO v_txt FROM coverage_view WHERE id = 'test-cov-001'; + CALL assert_eq('coverage_view.payor_identifier', v_txt, '1234567890'); + + RAISE NOTICE '├─ allergy_intolerance_view ─────────────────────────────────────'; + SELECT clinical_status INTO v_txt FROM allergy_intolerance_view WHERE id = 'test-ai-001'; + CALL assert_eq('allergy_intolerance_view.clinical_status', v_txt, 'active'); + SELECT verification_status INTO v_txt FROM allergy_intolerance_view WHERE id = 'test-ai-001'; + CALL assert_eq('allergy_intolerance_view.verification_status', v_txt, 'confirmed'); + SELECT type INTO v_txt FROM allergy_intolerance_view WHERE id = 'test-ai-001'; + CALL assert_eq('allergy_intolerance_view.type', v_txt, 'allergy'); + SELECT criticality INTO v_txt FROM allergy_intolerance_view WHERE id = 'test-ai-001'; + CALL assert_eq('allergy_intolerance_view.criticality', v_txt, 'high'); + SELECT code INTO v_txt FROM allergy_intolerance_view WHERE id = 'test-ai-001'; + CALL assert_eq('allergy_intolerance_view.code', v_txt, '7980'); + SELECT patient_id INTO v_txt FROM allergy_intolerance_view WHERE id = 'test-ai-001'; + CALL assert_eq('allergy_intolerance_view.patient_id', v_txt, 'test-pt-001'); + SELECT onset_datetime::date::text INTO v_txt FROM allergy_intolerance_view WHERE id = 'test-ai-001'; + CALL assert_eq('allergy_intolerance_view.onset_datetime', v_txt, '2020-03-01'); + SELECT reaction_code INTO v_txt FROM allergy_intolerance_view WHERE id = 'test-ai-001'; + CALL assert_eq('allergy_intolerance_view.reaction_code', v_txt, '271807003'); + SELECT reaction_severity INTO v_txt FROM allergy_intolerance_view WHERE id = 'test-ai-001'; + CALL assert_eq('allergy_intolerance_view.reaction_severity', v_txt, 'severe'); + SELECT recorded_date::text INTO v_txt FROM allergy_intolerance_view WHERE id = 'test-ai-001'; + CALL assert_eq('allergy_intolerance_view.recorded_date', v_txt, '2020-03-15'); + + RAISE NOTICE '├─ immunization_view ────────────────────────────────────────────'; + -- imm-001: completed, CVX, occurrenceDateTime, lot + SELECT status INTO v_txt FROM immunization_view WHERE id = 'test-imm-001'; + CALL assert_eq('immunization_view.status (completed)', v_txt, 'completed'); + SELECT vaccine_code INTO v_txt FROM immunization_view WHERE id = 'test-imm-001'; + CALL assert_eq('immunization_view.vaccine_code', v_txt, '140'); + SELECT vaccine_system INTO v_txt FROM immunization_view WHERE id = 'test-imm-001'; + CALL assert_eq('immunization_view.vaccine_system (CVX)', v_txt, 'http://hl7.org/fhir/sid/cvx'); + SELECT occurrence_datetime::date::text INTO v_txt FROM immunization_view WHERE id = 'test-imm-001'; + CALL assert_eq('immunization_view.occurrence_datetime', v_txt, '2024-10-15'); + SELECT primary_source::text INTO v_txt FROM immunization_view WHERE id = 'test-imm-001'; + CALL assert_eq('immunization_view.primary_source', v_txt, 'true'); + SELECT lot_number INTO v_txt FROM immunization_view WHERE id = 'test-imm-001'; + CALL assert_eq('immunization_view.lot_number', v_txt, 'LOT2024A'); + SELECT site_code INTO v_txt FROM immunization_view WHERE id = 'test-imm-001'; + CALL assert_eq('immunization_view.site_code', v_txt, 'LA'); + SELECT route_code INTO v_txt FROM immunization_view WHERE id = 'test-imm-001'; + CALL assert_eq('immunization_view.route_code', v_txt, 'IM'); + SELECT encounter_id INTO v_txt FROM immunization_view WHERE id = 'test-imm-001'; + CALL assert_eq('immunization_view.encounter_id', v_txt, 'test-enc-001'); + -- imm-002: not-done, statusReason, occurrenceString + SELECT status INTO v_txt FROM immunization_view WHERE id = 'test-imm-002'; + CALL assert_eq('immunization_view.status (not-done)', v_txt, 'not-done'); + SELECT status_reason_code INTO v_txt FROM immunization_view WHERE id = 'test-imm-002'; + CALL assert_eq('immunization_view.status_reason_code', v_txt, 'MEDPREC'); + SELECT occurrence_string INTO v_txt FROM immunization_view WHERE id = 'test-imm-002'; + CALL assert_eq('immunization_view.occurrence_string', v_txt, 'October 2024'); + SELECT occurrence_datetime::text INTO v_txt FROM immunization_view WHERE id = 'test-imm-002'; + CALL assert_null('immunization_view.occurrence_datetime NULL for occurrenceString', v_txt); + + RAISE NOTICE '├─ service_request_view (US Core 6.1) ───────────────────────────'; + SELECT status INTO v_txt FROM service_request_view WHERE id = 'test-sr-001'; + CALL assert_eq('service_request_view.status', v_txt, 'active'); + SELECT intent INTO v_txt FROM service_request_view WHERE id = 'test-sr-001'; + CALL assert_eq('service_request_view.intent', v_txt, 'order'); + SELECT code INTO v_txt FROM service_request_view WHERE id = 'test-sr-001'; + CALL assert_eq('service_request_view.code', v_txt, '45378'); + SELECT code_system INTO v_txt FROM service_request_view WHERE id = 'test-sr-001'; + CALL assert_eq('service_request_view.code_system', v_txt, 'http://www.ama-assn.org/go/cpt'); + SELECT category_code INTO v_txt FROM service_request_view WHERE id = 'test-sr-001'; + CALL assert_eq('service_request_view.category_code', v_txt, '108252007'); + SELECT subject_id INTO v_txt FROM service_request_view WHERE id = 'test-sr-001'; + CALL assert_eq('service_request_view.subject_id', v_txt, 'test-pt-001'); + SELECT encounter_id INTO v_txt FROM service_request_view WHERE id = 'test-sr-001'; + CALL assert_eq('service_request_view.encounter_id', v_txt, 'test-enc-001'); + SELECT occurrence_datetime::date::text INTO v_txt FROM service_request_view WHERE id = 'test-sr-001'; + CALL assert_eq('service_request_view.occurrence_datetime', v_txt, '2024-04-01'); + SELECT authored_on::date::text INTO v_txt FROM service_request_view WHERE id = 'test-sr-001'; + CALL assert_eq('service_request_view.authored_on', v_txt, '2024-03-15'); + SELECT reason_code INTO v_txt FROM service_request_view WHERE id = 'test-sr-001'; + CALL assert_eq('service_request_view.reason_code', v_txt, '44273001'); + SELECT do_not_perform::text INTO v_txt FROM service_request_view WHERE id = 'test-sr-001'; + CALL assert_eq('service_request_view.do_not_perform', v_txt, 'false'); + SELECT priority INTO v_txt FROM service_request_view WHERE id = 'test-sr-001'; + CALL assert_eq('service_request_view.priority', v_txt, 'routine'); + SELECT insurance_id INTO v_txt FROM service_request_view WHERE id = 'test-sr-001'; + CALL assert_eq('service_request_view.insurance_id', v_txt, 'test-cov-001'); + SELECT performer_id INTO v_txt FROM service_request_view WHERE id = 'test-sr-001'; + CALL assert_eq('service_request_view.performer_id', v_txt, 'test-prac-001'); + + RAISE NOTICE '├─ value_set_expansion ──────────────────────────────────────────'; + SELECT COUNT(*)::text INTO v_txt FROM value_set_expansion + WHERE value_set_id = 'http://example.org/test/colonoscopy'; + CALL assert_eq('value_set_expansion: 3 codes for test VS', v_txt, '3'); + SELECT code INTO v_txt FROM value_set_expansion + WHERE value_set_id = 'http://example.org/test/colonoscopy' AND code = '45378'; + CALL assert_eq('value_set_expansion: CPT 45378 present', v_txt, '45378'); + -- Verify the URL comes from the ValueSet.url field (not FHIR_ID) + SELECT COUNT(DISTINCT value_set_id)::text INTO v_txt FROM value_set_expansion + WHERE value_set_id = 'http://example.org/test/colonoscopy'; + CALL assert_eq('value_set_expansion: canonical URL used as value_set_id', v_txt, '1'); + + RAISE NOTICE '├─ deleted resource excluded ────────────────────────────────────'; + -- Insert a deleted patient; it must NOT appear in patient_view + INSERT INTO HFJ_RESOURCE (RES_ID, FHIR_ID, RES_TYPE, RES_VER, RES_UPDATED, RES_DELETED_AT) + VALUES (99, 'test-pt-deleted', 'Patient', 1, NOW(), NOW()); + INSERT INTO HFJ_RES_VER (RES_ID, RES_VER, RES_ENCODING, RES_TEXT_VC) + VALUES (99, 1, 'JSON', '{"resourceType":"Patient","id":"test-pt-deleted","gender":"female","birthDate":"1990-01-01"}'); + SELECT COUNT(*)::text INTO v_txt FROM patient_view WHERE id = 'test-pt-deleted'; + CALL assert_eq('patient_view: deleted resource excluded (count=0)', v_txt, '0'); + + RAISE NOTICE '└─ Summary ──────────────────────────────────────────────────────'; + RAISE NOTICE ''; + RAISE NOTICE ' Passed: % Failed: % Total: %', v_pass, v_fail, v_pass + v_fail; + RAISE NOTICE ''; + + IF v_fail > 0 THEN + RAISE EXCEPTION '% test(s) FAILED — see WARNING messages above', v_fail; + END IF; + +END $$; + +-- ── 7. Teardown ─────────────────────────────────────────────────────────────── +-- ROLLBACK undoes the CREATE SCHEMA, all tables, views, and inserted data. +-- No persistent changes are made to the database. +ROLLBACK; + +\echo 'All SQL view tests passed. Schema rolled back — no persistent changes.' diff --git a/scripts/hapi-fhir-sql-on-fhir/views/000_schema_version.sql b/scripts/hapi-fhir-sql-on-fhir/views/000_schema_version.sql new file mode 100644 index 0000000..3e3addf --- /dev/null +++ b/scripts/hapi-fhir-sql-on-fhir/views/000_schema_version.sql @@ -0,0 +1,48 @@ +-- ============================================================ +-- CQL Studio SQL-on-FHIR View Schema Version Tracking +-- Safe to run multiple times (IF NOT EXISTS). +-- Preston's server boot code (Issue #20) runs this on startup. +-- ============================================================ + +CREATE TABLE IF NOT EXISTS cql_studio_view_version ( + view_name VARCHAR(100) NOT NULL, + installed_ver INTEGER NOT NULL DEFAULT 0, + installed_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + hapi_schema_ver VARCHAR(20) NULL, -- detected HAPI FHIR version at install time + notes TEXT NULL, + CONSTRAINT pk_cql_studio_view_version PRIMARY KEY (view_name) +); + +-- Helper: upsert a version record (call after creating/replacing each view) +CREATE OR REPLACE FUNCTION cql_studio_set_view_version( + p_view_name VARCHAR(100), + p_version INTEGER, + p_notes TEXT DEFAULT NULL +) RETURNS VOID AS $$ +BEGIN + INSERT INTO cql_studio_view_version (view_name, installed_ver, installed_at, updated_at, notes) + VALUES (p_view_name, p_version, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, p_notes) + ON CONFLICT (view_name) DO UPDATE + SET installed_ver = p_version, + updated_at = CURRENT_TIMESTAMP, + notes = COALESCE(p_notes, cql_studio_view_version.notes); +END; +$$ LANGUAGE plpgsql; + +-- Helper: check if a view needs installing/upgrading +-- Returns TRUE if install is needed (not present or version < p_min_version) +CREATE OR REPLACE FUNCTION cql_studio_needs_install( + p_view_name VARCHAR(100), + p_min_version INTEGER +) RETURNS BOOLEAN AS $$ +DECLARE + v_current INTEGER; +BEGIN + SELECT installed_ver INTO v_current + FROM cql_studio_view_version + WHERE view_name = p_view_name; + + RETURN v_current IS NULL OR v_current < p_min_version; +END; +$$ LANGUAGE plpgsql; diff --git a/scripts/hapi-fhir-sql-on-fhir/views/001_patient_view.sql b/scripts/hapi-fhir-sql-on-fhir/views/001_patient_view.sql new file mode 100644 index 0000000..de65ae2 --- /dev/null +++ b/scripts/hapi-fhir-sql-on-fhir/views/001_patient_view.sql @@ -0,0 +1,75 @@ +-- ============================================================ +-- SQL-on-FHIR Patient View over HAPI FHIR JPA +-- Version: 1 +-- Resource: Patient (FHIR R4) +-- +-- Columns match patient_view expected by @cqframework/elm-to-sql. +-- Requires: PostgreSQL 12+, HAPI FHIR JPA 6.x / 7.x +-- ============================================================ + +CREATE OR REPLACE VIEW patient_view AS +SELECT + r.FHIR_ID AS id, + + -- Demographics + rv.res_json ->>'gender' AS gender, + (rv.res_json ->>'birthDate')::date AS birthdate, + (rv.res_json ->>'active')::boolean AS active, + + -- Name (official, or first) + COALESCE( + (SELECT n->>'family' + FROM jsonb_array_elements(rv.res_json->'name') n + WHERE n->>'use' = 'official' LIMIT 1), + rv.res_json->'name'->0->>'family' + ) AS name_family, + + COALESCE( + (SELECT n->'given'->>0 + FROM jsonb_array_elements(rv.res_json->'name') n + WHERE n->>'use' = 'official' LIMIT 1), + rv.res_json->'name'->0->'given'->>0 + ) AS name_given, + + -- Deceased + CASE + WHEN rv.res_json ? 'deceasedBoolean' THEN (rv.res_json->>'deceasedBoolean')::boolean + WHEN rv.res_json ? 'deceasedDateTime' THEN TRUE + ELSE FALSE + END AS deceased, + + CASE WHEN rv.res_json ? 'deceasedDateTime' + THEN (rv.res_json->>'deceasedDateTime')::timestamp + ELSE NULL + END AS deceased_datetime, + + -- US Core Race (OMB category code) — requires PostgreSQL 12+ jsonb_path_query_first + jsonb_path_query_first( + rv.res_json, + '$.extension[*] ? (@.url == "http://hl7.org/fhir/us/core/StructureDefinition/us-core-race") + .extension[*] ? (@.url == "ombCategory").valueCoding.code' + ) #>> '{}' AS race_code, + + -- US Core Ethnicity (OMB category code) + jsonb_path_query_first( + rv.res_json, + '$.extension[*] ? (@.url == "http://hl7.org/fhir/us/core/StructureDefinition/us-core-ethnicity") + .extension[*] ? (@.url == "ombCategory").valueCoding.code' + ) #>> '{}' AS ethnicity_code, + + r.RES_UPDATED AS last_updated + +FROM HFJ_RESOURCE r +CROSS JOIN LATERAL ( + SELECT COALESCE( + v.RES_TEXT_VC, + CASE WHEN v.RES_ENCODING = 'JSON' THEN convert_from(v.RES_TEXT, 'UTF8') END + )::jsonb AS res_json + FROM HFJ_RES_VER v + WHERE v.RES_ID = r.RES_ID + AND v.RES_VER = r.RES_VER +) rv +WHERE r.RES_DELETED_AT IS NULL + AND r.RES_TYPE = 'Patient'; + +SELECT cql_studio_set_view_version('patient_view', 1, 'Initial SQL-on-FHIR patient view'); diff --git a/scripts/hapi-fhir-sql-on-fhir/views/002_observation_view.sql b/scripts/hapi-fhir-sql-on-fhir/views/002_observation_view.sql new file mode 100644 index 0000000..d7ebcce --- /dev/null +++ b/scripts/hapi-fhir-sql-on-fhir/views/002_observation_view.sql @@ -0,0 +1,85 @@ +-- ============================================================ +-- SQL-on-FHIR Observation View over HAPI FHIR JPA +-- Version: 1 +-- Resource: Observation (FHIR R4) +-- ============================================================ + +CREATE OR REPLACE VIEW observation_view AS +SELECT + r.FHIR_ID AS id, + + -- Subject reference (strip 'Patient/' prefix) + SPLIT_PART(rv.res_json->'subject'->>'reference', '/', 2) AS subject_id, + + -- Status + rv.res_json->>'status' AS status, + + -- Category (first) + rv.res_json->'category'->0->'coding'->0->>'code' AS category_code, + rv.res_json->'category'->0->'coding'->0->>'system' AS category_system, + + -- Code (first coding) + rv.res_json->'code'->'coding'->0->>'code' AS code, + rv.res_json->'code'->'coding'->0->>'system' AS code_system, + rv.res_json->'code'->'coding'->0->>'display' AS code_display, + rv.res_json->'code'->>'text' AS code_text, + + -- Effective (dateTime or Period) + CASE + WHEN rv.res_json ? 'effectiveDateTime' + THEN (rv.res_json->>'effectiveDateTime')::timestamp + WHEN rv.res_json ? 'effectivePeriod' + THEN (rv.res_json->'effectivePeriod'->>'start')::timestamp + ELSE NULL + END AS effective_datetime, + + CASE WHEN rv.res_json ? 'effectivePeriod' + THEN (rv.res_json->'effectivePeriod'->>'start')::timestamp + ELSE NULL + END AS effective_start, + + CASE WHEN rv.res_json ? 'effectivePeriod' + THEN (rv.res_json->'effectivePeriod'->>'end')::timestamp + ELSE NULL + END AS effective_end, + + -- Value (Quantity, CodeableConcept, string, boolean) + CASE WHEN rv.res_json ? 'valueQuantity' + THEN (rv.res_json->'valueQuantity'->>'value')::decimal + ELSE NULL + END AS value_quantity, + + CASE WHEN rv.res_json ? 'valueQuantity' + THEN rv.res_json->'valueQuantity'->>'unit' + ELSE NULL + END AS value_unit, + + CASE WHEN rv.res_json ? 'valueCodeableConcept' + THEN rv.res_json->'valueCodeableConcept'->'coding'->0->>'code' + ELSE NULL + END AS value_code, + + CASE WHEN rv.res_json ? 'valueString' + THEN rv.res_json->>'valueString' + ELSE NULL + END AS value_string, + + -- Encounter reference + SPLIT_PART(rv.res_json->'encounter'->>'reference', '/', 2) AS encounter_id, + + r.RES_UPDATED AS last_updated + +FROM HFJ_RESOURCE r +CROSS JOIN LATERAL ( + SELECT COALESCE( + v.RES_TEXT_VC, + CASE WHEN v.RES_ENCODING = 'JSON' THEN convert_from(v.RES_TEXT, 'UTF8') END + )::jsonb AS res_json + FROM HFJ_RES_VER v + WHERE v.RES_ID = r.RES_ID + AND v.RES_VER = r.RES_VER +) rv +WHERE r.RES_DELETED_AT IS NULL + AND r.RES_TYPE = 'Observation'; + +SELECT cql_studio_set_view_version('observation_view', 1, 'Initial SQL-on-FHIR observation view'); diff --git a/scripts/hapi-fhir-sql-on-fhir/views/003_condition_view.sql b/scripts/hapi-fhir-sql-on-fhir/views/003_condition_view.sql new file mode 100644 index 0000000..59ee26b --- /dev/null +++ b/scripts/hapi-fhir-sql-on-fhir/views/003_condition_view.sql @@ -0,0 +1,72 @@ +-- ============================================================ +-- SQL-on-FHIR Condition View over HAPI FHIR JPA +-- Version: 1 +-- Resource: Condition (FHIR R4) +-- ============================================================ + +CREATE OR REPLACE VIEW condition_view AS +SELECT + r.FHIR_ID AS id, + SPLIT_PART(rv.res_json->'subject'->>'reference', '/', 2) AS subject_id, + + -- Code (first coding) + rv.res_json->'code'->'coding'->0->>'code' AS code, + rv.res_json->'code'->'coding'->0->>'system' AS code_system, + rv.res_json->'code'->'coding'->0->>'display' AS code_display, + rv.res_json->'code'->>'text' AS code_text, + + -- Clinical / Verification status + rv.res_json->'clinicalStatus'->'coding'->0->>'code' AS clinical_status, + rv.res_json->'verificationStatus'->'coding'->0->>'code' AS verification_status, + + -- Category (first) + rv.res_json->'category'->0->'coding'->0->>'code' AS category_code, + + -- Onset (dateTime or Period start) + CASE + WHEN rv.res_json ? 'onsetDateTime' + THEN (rv.res_json->>'onsetDateTime')::timestamp + WHEN rv.res_json ? 'onsetPeriod' + THEN (rv.res_json->'onsetPeriod'->>'start')::timestamp + ELSE NULL + END AS onset_datetime, + + CASE WHEN rv.res_json ? 'onsetPeriod' + THEN (rv.res_json->'onsetPeriod'->>'start')::timestamp + ELSE NULL + END AS onset_start, + + -- Abatement + CASE + WHEN rv.res_json ? 'abatementDateTime' + THEN (rv.res_json->>'abatementDateTime')::timestamp + WHEN rv.res_json ? 'abatementPeriod' + THEN (rv.res_json->'abatementPeriod'->>'start')::timestamp + ELSE NULL + END AS abatement_datetime, + + -- Recorded date + CASE WHEN rv.res_json ? 'recordedDate' + THEN (rv.res_json->>'recordedDate')::timestamp + ELSE NULL + END AS recorded_date, + + -- Encounter reference + SPLIT_PART(rv.res_json->'encounter'->>'reference', '/', 2) AS encounter_id, + + r.RES_UPDATED AS last_updated + +FROM HFJ_RESOURCE r +CROSS JOIN LATERAL ( + SELECT COALESCE( + v.RES_TEXT_VC, + CASE WHEN v.RES_ENCODING = 'JSON' THEN convert_from(v.RES_TEXT, 'UTF8') END + )::jsonb AS res_json + FROM HFJ_RES_VER v + WHERE v.RES_ID = r.RES_ID + AND v.RES_VER = r.RES_VER +) rv +WHERE r.RES_DELETED_AT IS NULL + AND r.RES_TYPE = 'Condition'; + +SELECT cql_studio_set_view_version('condition_view', 1, 'Initial SQL-on-FHIR condition view'); diff --git a/scripts/hapi-fhir-sql-on-fhir/views/004_procedure_view.sql b/scripts/hapi-fhir-sql-on-fhir/views/004_procedure_view.sql new file mode 100644 index 0000000..9de18e8 --- /dev/null +++ b/scripts/hapi-fhir-sql-on-fhir/views/004_procedure_view.sql @@ -0,0 +1,60 @@ +-- ============================================================ +-- SQL-on-FHIR Procedure View over HAPI FHIR JPA +-- Version: 1 +-- Resource: Procedure (FHIR R4) +-- ============================================================ + +CREATE OR REPLACE VIEW procedure_view AS +SELECT + r.FHIR_ID AS id, + SPLIT_PART(rv.res_json->'subject'->>'reference', '/', 2) AS subject_id, + + rv.res_json->>'status' AS status, + + -- Code + rv.res_json->'code'->'coding'->0->>'code' AS code, + rv.res_json->'code'->'coding'->0->>'system' AS code_system, + rv.res_json->'code'->'coding'->0->>'display' AS code_display, + rv.res_json->'code'->>'text' AS code_text, + + -- Category + rv.res_json->'category'->'coding'->0->>'code' AS category_code, + + -- Performed (dateTime or Period) + CASE + WHEN rv.res_json ? 'performedDateTime' + THEN (rv.res_json->>'performedDateTime')::timestamp + WHEN rv.res_json ? 'performedPeriod' + THEN (rv.res_json->'performedPeriod'->>'start')::timestamp + ELSE NULL + END AS performed_datetime, + + CASE WHEN rv.res_json ? 'performedPeriod' + THEN (rv.res_json->'performedPeriod'->>'start')::timestamp + ELSE NULL + END AS performed_start, + + CASE WHEN rv.res_json ? 'performedPeriod' + THEN (rv.res_json->'performedPeriod'->>'end')::timestamp + ELSE NULL + END AS performed_end, + + -- Encounter reference + SPLIT_PART(rv.res_json->'encounter'->>'reference', '/', 2) AS encounter_id, + + r.RES_UPDATED AS last_updated + +FROM HFJ_RESOURCE r +CROSS JOIN LATERAL ( + SELECT COALESCE( + v.RES_TEXT_VC, + CASE WHEN v.RES_ENCODING = 'JSON' THEN convert_from(v.RES_TEXT, 'UTF8') END + )::jsonb AS res_json + FROM HFJ_RES_VER v + WHERE v.RES_ID = r.RES_ID + AND v.RES_VER = r.RES_VER +) rv +WHERE r.RES_DELETED_AT IS NULL + AND r.RES_TYPE = 'Procedure'; + +SELECT cql_studio_set_view_version('procedure_view', 1, 'Initial SQL-on-FHIR procedure view'); diff --git a/scripts/hapi-fhir-sql-on-fhir/views/005_encounter_view.sql b/scripts/hapi-fhir-sql-on-fhir/views/005_encounter_view.sql new file mode 100644 index 0000000..aa16246 --- /dev/null +++ b/scripts/hapi-fhir-sql-on-fhir/views/005_encounter_view.sql @@ -0,0 +1,48 @@ +-- ============================================================ +-- SQL-on-FHIR Encounter View over HAPI FHIR JPA +-- Version: 1 +-- Resource: Encounter (FHIR R4) +-- ============================================================ + +CREATE OR REPLACE VIEW encounter_view AS +SELECT + r.FHIR_ID AS id, + SPLIT_PART(rv.res_json->'subject'->>'reference', '/', 2) AS subject_id, + + rv.res_json->>'status' AS status, + + -- Class (Coding, not CodeableConcept in R4) + rv.res_json->'class'->>'code' AS class_code, + rv.res_json->'class'->>'system' AS class_system, + + -- Type (first) + rv.res_json->'type'->0->'coding'->0->>'code' AS type_code, + rv.res_json->'type'->0->'coding'->0->>'system' AS type_system, + rv.res_json->'type'->0->'coding'->0->>'display' AS type_display, + + -- Service type + rv.res_json->'serviceType'->'coding'->0->>'code' AS service_type_code, + + -- Period + (rv.res_json->'period'->>'start')::timestamp AS period_start, + (rv.res_json->'period'->>'end')::timestamp AS period_end, + + -- Service provider (Organization ref) + SPLIT_PART(rv.res_json->'serviceProvider'->>'reference', '/', 2) AS service_provider_id, + + r.RES_UPDATED AS last_updated + +FROM HFJ_RESOURCE r +CROSS JOIN LATERAL ( + SELECT COALESCE( + v.RES_TEXT_VC, + CASE WHEN v.RES_ENCODING = 'JSON' THEN convert_from(v.RES_TEXT, 'UTF8') END + )::jsonb AS res_json + FROM HFJ_RES_VER v + WHERE v.RES_ID = r.RES_ID + AND v.RES_VER = r.RES_VER +) rv +WHERE r.RES_DELETED_AT IS NULL + AND r.RES_TYPE = 'Encounter'; + +SELECT cql_studio_set_view_version('encounter_view', 1, 'Initial SQL-on-FHIR encounter view'); diff --git a/scripts/hapi-fhir-sql-on-fhir/views/006_medication_request_view.sql b/scripts/hapi-fhir-sql-on-fhir/views/006_medication_request_view.sql new file mode 100644 index 0000000..b94a24e --- /dev/null +++ b/scripts/hapi-fhir-sql-on-fhir/views/006_medication_request_view.sql @@ -0,0 +1,67 @@ +-- ============================================================ +-- SQL-on-FHIR MedicationRequest View over HAPI FHIR JPA +-- Version: 1 +-- Resource: MedicationRequest (FHIR R4) +-- ============================================================ + +CREATE OR REPLACE VIEW medication_request_view AS +SELECT + r.FHIR_ID AS id, + SPLIT_PART(rv.res_json->'subject'->>'reference', '/', 2) AS subject_id, + + rv.res_json->>'status' AS status, + rv.res_json->>'intent' AS intent, + + -- Medication — CodeableConcept (inline) or Reference + CASE + WHEN rv.res_json ? 'medicationCodeableConcept' + THEN rv.res_json->'medicationCodeableConcept'->'coding'->0->>'code' + ELSE NULL + END AS medication_code, + + CASE + WHEN rv.res_json ? 'medicationCodeableConcept' + THEN rv.res_json->'medicationCodeableConcept'->'coding'->0->>'system' + ELSE NULL + END AS medication_system, + + CASE + WHEN rv.res_json ? 'medicationCodeableConcept' + THEN rv.res_json->'medicationCodeableConcept'->'coding'->0->>'display' + ELSE NULL + END AS medication_display, + + CASE + WHEN rv.res_json ? 'medicationReference' + THEN SPLIT_PART(rv.res_json->'medicationReference'->>'reference', '/', 2) + ELSE NULL + END AS medication_ref_id, + + -- Authored on + CASE WHEN rv.res_json ? 'authoredOn' + THEN (rv.res_json->>'authoredOn')::timestamp + ELSE NULL + END AS authored_on, + + -- Encounter reference + SPLIT_PART(rv.res_json->'encounter'->>'reference', '/', 2) AS encounter_id, + + -- Requester reference + SPLIT_PART(rv.res_json->'requester'->>'reference', '/', 2) AS requester_id, + + r.RES_UPDATED AS last_updated + +FROM HFJ_RESOURCE r +CROSS JOIN LATERAL ( + SELECT COALESCE( + v.RES_TEXT_VC, + CASE WHEN v.RES_ENCODING = 'JSON' THEN convert_from(v.RES_TEXT, 'UTF8') END + )::jsonb AS res_json + FROM HFJ_RES_VER v + WHERE v.RES_ID = r.RES_ID + AND v.RES_VER = r.RES_VER +) rv +WHERE r.RES_DELETED_AT IS NULL + AND r.RES_TYPE = 'MedicationRequest'; + +SELECT cql_studio_set_view_version('medication_request_view', 1, 'Initial SQL-on-FHIR medication_request view'); diff --git a/scripts/hapi-fhir-sql-on-fhir/views/007_diagnostic_report_view.sql b/scripts/hapi-fhir-sql-on-fhir/views/007_diagnostic_report_view.sql new file mode 100644 index 0000000..64360e5 --- /dev/null +++ b/scripts/hapi-fhir-sql-on-fhir/views/007_diagnostic_report_view.sql @@ -0,0 +1,56 @@ +-- ============================================================ +-- SQL-on-FHIR DiagnosticReport View over HAPI FHIR JPA +-- Version: 1 +-- Resource: DiagnosticReport (FHIR R4) +-- ============================================================ + +CREATE OR REPLACE VIEW diagnostic_report_view AS +SELECT + r.FHIR_ID AS id, + SPLIT_PART(rv.res_json->'subject'->>'reference', '/', 2) AS subject_id, + + rv.res_json->>'status' AS status, + + -- Category (first) + rv.res_json->'category'->0->'coding'->0->>'code' AS category_code, + rv.res_json->'category'->0->'coding'->0->>'system' AS category_system, + + -- Code + rv.res_json->'code'->'coding'->0->>'code' AS code, + rv.res_json->'code'->'coding'->0->>'system' AS code_system, + rv.res_json->'code'->'coding'->0->>'display' AS code_display, + + -- Effective + CASE + WHEN rv.res_json ? 'effectiveDateTime' + THEN (rv.res_json->>'effectiveDateTime')::timestamp + WHEN rv.res_json ? 'effectivePeriod' + THEN (rv.res_json->'effectivePeriod'->>'start')::timestamp + ELSE NULL + END AS effective_datetime, + + -- Issued + CASE WHEN rv.res_json ? 'issued' + THEN (rv.res_json->>'issued')::timestamp + ELSE NULL + END AS issued, + + -- Encounter reference + SPLIT_PART(rv.res_json->'encounter'->>'reference', '/', 2) AS encounter_id, + + r.RES_UPDATED AS last_updated + +FROM HFJ_RESOURCE r +CROSS JOIN LATERAL ( + SELECT COALESCE( + v.RES_TEXT_VC, + CASE WHEN v.RES_ENCODING = 'JSON' THEN convert_from(v.RES_TEXT, 'UTF8') END + )::jsonb AS res_json + FROM HFJ_RES_VER v + WHERE v.RES_ID = r.RES_ID + AND v.RES_VER = r.RES_VER +) rv +WHERE r.RES_DELETED_AT IS NULL + AND r.RES_TYPE = 'DiagnosticReport'; + +SELECT cql_studio_set_view_version('diagnostic_report_view', 1, 'Initial SQL-on-FHIR diagnostic_report view'); diff --git a/scripts/hapi-fhir-sql-on-fhir/views/008_value_set_expansion_view.sql b/scripts/hapi-fhir-sql-on-fhir/views/008_value_set_expansion_view.sql new file mode 100644 index 0000000..9b2aaa7 --- /dev/null +++ b/scripts/hapi-fhir-sql-on-fhir/views/008_value_set_expansion_view.sql @@ -0,0 +1,50 @@ +-- ============================================================ +-- value_set_expansion View over HAPI FHIR JPA +-- Version: 1 +-- +-- The elm-to-sql transpiler generates queries like: +-- code IN (SELECT code FROM value_set_expansion WHERE value_set_id = '...') +-- +-- This view satisfies that contract by reading expanded ValueSet resources +-- stored in HAPI FHIR JPA and flattening their expansion.contains[]. +-- +-- IMPORTANT: ValueSets must already be expanded in HAPI (either loaded +-- pre-expanded or expanded via $expand and stored). HAPI caches expansions +-- in HFJ_RES_VER for ValueSet resources tagged as expanded. +-- ============================================================ + +CREATE OR REPLACE VIEW value_set_expansion AS +WITH vs_resources AS ( + SELECT + r.FHIR_ID, + COALESCE( + v.RES_TEXT_VC, + CASE WHEN v.RES_ENCODING = 'JSON' THEN convert_from(v.RES_TEXT, 'UTF8') END + )::jsonb AS res_json + FROM HFJ_RESOURCE r + JOIN HFJ_RES_VER v ON v.RES_ID = r.RES_ID AND v.RES_VER = r.RES_VER + WHERE r.RES_DELETED_AT IS NULL + AND r.RES_TYPE = 'ValueSet' + AND COALESCE(v.RES_TEXT_VC, '') LIKE '%"expansion"%' -- quick filter before JSON parse +), +url_extract AS ( + SELECT + -- Prefer the canonical url field, fall back to FHIR_ID + COALESCE(res_json->>'url', FHIR_ID) AS value_set_id, + res_json AS vs_json + FROM vs_resources +) +SELECT + u.value_set_id, + (contain->>'code') AS code, + (contain->>'system') AS system, + (contain->>'display') AS display, + (contain->>'version') AS version +FROM url_extract u +CROSS JOIN LATERAL jsonb_array_elements( + u.vs_json->'expansion'->'contains' +) AS contain +WHERE (contain->>'code') IS NOT NULL; + +SELECT cql_studio_set_view_version('value_set_expansion', 1, + 'ValueSet expansion view — requires pre-expanded ValueSets stored in HAPI'); diff --git a/scripts/hapi-fhir-sql-on-fhir/views/009_coverage_view.sql b/scripts/hapi-fhir-sql-on-fhir/views/009_coverage_view.sql new file mode 100644 index 0000000..7aaa3a9 --- /dev/null +++ b/scripts/hapi-fhir-sql-on-fhir/views/009_coverage_view.sql @@ -0,0 +1,74 @@ +-- ============================================================ +-- SQL-on-FHIR Coverage View over HAPI FHIR JPA +-- Version: 1 +-- Resource: Coverage (FHIR R4 / US Core 6.1) +-- +-- US Core 6.1 MustSupport elements included: +-- status, type, subscriber, subscriberId, beneficiary, +-- relationship, period, payor, class (plan/group) +-- +-- Useful for CQL measures that filter by payer, insurance type, +-- or coverage period (e.g. Medicare/Medicaid membership). +-- ============================================================ + +CREATE OR REPLACE VIEW coverage_view AS +SELECT + r.FHIR_ID AS id, + + -- Beneficiary (patient) + SPLIT_PART(rv.res_json->'beneficiary'->>'reference', '/', 2) AS beneficiary_id, + + rv.res_json->>'status' AS status, + + -- Coverage type (Medicare Part A/B/C/D, Medicaid, Commercial, etc.) + rv.res_json->'type'->'coding'->0->>'code' AS type_code, + rv.res_json->'type'->'coding'->0->>'system' AS type_system, + rv.res_json->'type'->'coding'->0->>'display' AS type_display, + + -- Subscriber (may differ from beneficiary for dependents) + SPLIT_PART(rv.res_json->'subscriber'->>'reference', '/', 2) AS subscriber_id, + rv.res_json->>'subscriberId' AS subscriber_id_value, + + -- Relationship to subscriber (self, spouse, child, etc.) + rv.res_json->'relationship'->'coding'->0->>'code' AS relationship_code, + + -- Coverage period + CASE WHEN rv.res_json->'period' ? 'start' + THEN (rv.res_json->'period'->>'start')::date + ELSE NULL + END AS period_start, + CASE WHEN rv.res_json->'period' ? 'end' + THEN (rv.res_json->'period'->>'end')::date + ELSE NULL + END AS period_end, + + -- Payor — first entry (usually Organization for commercial/Medicare) + SPLIT_PART(rv.res_json->'payor'->0->>'reference', '/', 2) AS payor_id, + -- Inline payor identifier (when payor is an identifier, not a reference) + rv.res_json->'payor'->0->'identifier'->>'value' AS payor_identifier, + + -- Coverage class — plan/group grouping (first class entry) + rv.res_json->'class'->0->'type'->'coding'->0->>'code' AS class_type_code, + rv.res_json->'class'->0->>'value' AS class_value, + rv.res_json->'class'->0->>'name' AS class_name, + + -- Order/priority (lower = higher priority when multiple coverages exist) + (rv.res_json->>'order')::integer AS priority_order, + + r.RES_UPDATED AS last_updated + +FROM HFJ_RESOURCE r +CROSS JOIN LATERAL ( + SELECT COALESCE( + v.RES_TEXT_VC, + CASE WHEN v.RES_ENCODING = 'JSON' THEN convert_from(v.RES_TEXT, 'UTF8') END + )::jsonb AS res_json + FROM HFJ_RES_VER v + WHERE v.RES_ID = r.RES_ID + AND v.RES_VER = r.RES_VER +) rv +WHERE r.RES_DELETED_AT IS NULL + AND r.RES_TYPE = 'Coverage'; + +SELECT cql_studio_set_view_version('coverage_view', 1, + 'US Core 6.1 Coverage view — payer, type, period, class (plan)'); diff --git a/scripts/hapi-fhir-sql-on-fhir/views/010_allergy_intolerance_view.sql b/scripts/hapi-fhir-sql-on-fhir/views/010_allergy_intolerance_view.sql new file mode 100644 index 0000000..79c57ab --- /dev/null +++ b/scripts/hapi-fhir-sql-on-fhir/views/010_allergy_intolerance_view.sql @@ -0,0 +1,67 @@ +-- ============================================================ +-- SQL-on-FHIR AllergyIntolerance View over HAPI FHIR JPA +-- Version: 1 +-- Resource: AllergyIntolerance (FHIR R4 / US Core 6.1) +-- +-- US Core 6.1 MustSupport elements included: +-- clinicalStatus, verificationStatus, type, category, +-- criticality, code, patient, onset, reaction (first) +-- ============================================================ + +CREATE OR REPLACE VIEW allergy_intolerance_view AS +SELECT + r.FHIR_ID AS id, + + -- Patient reference + SPLIT_PART(rv.res_json->'patient'->>'reference', '/', 2) AS patient_id, + + -- Status fields + rv.res_json->'clinicalStatus'->'coding'->0->>'code' AS clinical_status, + rv.res_json->'verificationStatus'->'coding'->0->>'code' AS verification_status, + + -- Type (allergy | intolerance) and categories + rv.res_json->>'type' AS type, + -- First category (food | medication | environment | biologic) + rv.res_json->'category'->>'0' AS category, + + rv.res_json->>'criticality' AS criticality, + + -- Substance / allergen code + rv.res_json->'code'->'coding'->0->>'code' AS code, + rv.res_json->'code'->'coding'->0->>'system' AS code_system, + rv.res_json->'code'->'coding'->0->>'display' AS code_display, + rv.res_json->'code'->>'text' AS code_text, + + -- Onset + CASE + WHEN rv.res_json ? 'onsetDateTime' + THEN (rv.res_json->>'onsetDateTime')::timestamp + WHEN rv.res_json ? 'onsetPeriod' + THEN (rv.res_json->'onsetPeriod'->>'start')::timestamp + ELSE NULL + END AS onset_datetime, + + -- First reaction: first manifestation code + severity + rv.res_json->'reaction'->0->'manifestation'->0->'coding'->0->>'code' AS reaction_code, + rv.res_json->'reaction'->0->'manifestation'->0->'coding'->0->>'system' AS reaction_system, + rv.res_json->'reaction'->0->>'severity' AS reaction_severity, + + (rv.res_json->>'recordedDate')::date AS recorded_date, + + r.RES_UPDATED AS last_updated + +FROM HFJ_RESOURCE r +CROSS JOIN LATERAL ( + SELECT COALESCE( + v.RES_TEXT_VC, + CASE WHEN v.RES_ENCODING = 'JSON' THEN convert_from(v.RES_TEXT, 'UTF8') END + )::jsonb AS res_json + FROM HFJ_RES_VER v + WHERE v.RES_ID = r.RES_ID + AND v.RES_VER = r.RES_VER +) rv +WHERE r.RES_DELETED_AT IS NULL + AND r.RES_TYPE = 'AllergyIntolerance'; + +SELECT cql_studio_set_view_version('allergy_intolerance_view', 1, + 'US Core 6.1 AllergyIntolerance view — substance, clinical status, reaction'); diff --git a/scripts/hapi-fhir-sql-on-fhir/views/011_immunization_view.sql b/scripts/hapi-fhir-sql-on-fhir/views/011_immunization_view.sql new file mode 100644 index 0000000..c6aea4e --- /dev/null +++ b/scripts/hapi-fhir-sql-on-fhir/views/011_immunization_view.sql @@ -0,0 +1,80 @@ +-- ============================================================ +-- SQL-on-FHIR Immunization View over HAPI FHIR JPA +-- Version: 1 +-- Resource: Immunization (FHIR R4 / US Core 6.1) +-- +-- US Core 6.1 MustSupport elements included: +-- status, statusReason, vaccineCode (CVX), patient, +-- occurrence, primarySource, site, route, encounter +-- +-- vaccineCode uses CVX codes (http://hl7.org/fhir/sid/cvx) +-- for vaccine type identification in immunization measures. +-- ============================================================ + +CREATE OR REPLACE VIEW immunization_view AS +SELECT + r.FHIR_ID AS id, + + -- Patient reference + SPLIT_PART(rv.res_json->'patient'->>'reference', '/', 2) AS patient_id, + + -- Status: completed | entered-in-error | not-done + rv.res_json->>'status' AS status, + + -- Status reason (explains not-done immunizations — important for exclusions) + rv.res_json->'statusReason'->'coding'->0->>'code' AS status_reason_code, + rv.res_json->'statusReason'->'coding'->0->>'system' AS status_reason_system, + rv.res_json->'statusReason'->'coding'->0->>'display' AS status_reason_display, + + -- Vaccine code (CVX primary system for US Core) + rv.res_json->'vaccineCode'->'coding'->0->>'code' AS vaccine_code, + rv.res_json->'vaccineCode'->'coding'->0->>'system' AS vaccine_system, + rv.res_json->'vaccineCode'->'coding'->0->>'display' AS vaccine_display, + rv.res_json->'vaccineCode'->>'text' AS vaccine_text, + + -- Occurrence — dateTime or string (e.g. "2020" for approximate) + CASE + WHEN rv.res_json ? 'occurrenceDateTime' + THEN (rv.res_json->>'occurrenceDateTime')::timestamp + ELSE NULL + END AS occurrence_datetime, + -- Preserve the string form for approximate/historical records + CASE + WHEN rv.res_json ? 'occurrenceString' THEN rv.res_json->>'occurrenceString' + ELSE NULL + END AS occurrence_string, + + -- Data provenance + (rv.res_json->>'primarySource')::boolean AS primary_source, + + -- Administration details + rv.res_json->'site'->'coding'->0->>'code' AS site_code, + rv.res_json->'route'->'coding'->0->>'code' AS route_code, + rv.res_json->'doseQuantity'->>'value' AS dose_quantity, + rv.res_json->'doseQuantity'->>'unit' AS dose_unit, + + -- Lot / manufacturer + rv.res_json->>'lotNumber' AS lot_number, + (rv.res_json->>'expirationDate')::date AS expiration_date, + SPLIT_PART(rv.res_json->'manufacturer'->>'reference', '/', 2) AS manufacturer_id, + + -- Encounter reference + SPLIT_PART(rv.res_json->'encounter'->>'reference', '/', 2) AS encounter_id, + + r.RES_UPDATED AS last_updated + +FROM HFJ_RESOURCE r +CROSS JOIN LATERAL ( + SELECT COALESCE( + v.RES_TEXT_VC, + CASE WHEN v.RES_ENCODING = 'JSON' THEN convert_from(v.RES_TEXT, 'UTF8') END + )::jsonb AS res_json + FROM HFJ_RES_VER v + WHERE v.RES_ID = r.RES_ID + AND v.RES_VER = r.RES_VER +) rv +WHERE r.RES_DELETED_AT IS NULL + AND r.RES_TYPE = 'Immunization'; + +SELECT cql_studio_set_view_version('immunization_view', 1, + 'US Core 6.1 Immunization view — CVX vaccine codes, occurrence, status/reason'); diff --git a/scripts/hapi-fhir-sql-on-fhir/views/012_service_request_view.sql b/scripts/hapi-fhir-sql-on-fhir/views/012_service_request_view.sql new file mode 100644 index 0000000..6015534 --- /dev/null +++ b/scripts/hapi-fhir-sql-on-fhir/views/012_service_request_view.sql @@ -0,0 +1,93 @@ +-- ============================================================ +-- SQL-on-FHIR ServiceRequest View over HAPI FHIR JPA +-- Version: 1 +-- Resource: ServiceRequest (FHIR R4 / US Core 6.1) +-- +-- US Core 6.1 introduced explicit MustSupport for ServiceRequest, +-- used for referrals, diagnostic orders, and care orders in eCQMs. +-- +-- US Core 6.1 MustSupport elements included: +-- status, intent, category, code, subject, occurrence, +-- authoredOn, requester, performer, reasonCode, encounter +-- ============================================================ + +CREATE OR REPLACE VIEW service_request_view AS +SELECT + r.FHIR_ID AS id, + + -- Subject (patient) + SPLIT_PART(rv.res_json->'subject'->>'reference', '/', 2) AS subject_id, + + -- Workflow state + rv.res_json->>'status' AS status, + rv.res_json->>'intent' AS intent, + + -- Category — first entry (e.g. 108252007 = Laboratory procedure) + rv.res_json->'category'->0->'coding'->0->>'code' AS category_code, + rv.res_json->'category'->0->'coding'->0->>'system' AS category_system, + rv.res_json->'category'->0->'coding'->0->>'display' AS category_display, + + -- Ordered item / procedure code + rv.res_json->'code'->'coding'->0->>'code' AS code, + rv.res_json->'code'->'coding'->0->>'system' AS code_system, + rv.res_json->'code'->'coding'->0->>'display' AS code_display, + rv.res_json->'code'->>'text' AS code_text, + + -- Occurrence — when to perform + CASE + WHEN rv.res_json ? 'occurrenceDateTime' + THEN (rv.res_json->>'occurrenceDateTime')::timestamp + WHEN rv.res_json ? 'occurrencePeriod' + THEN (rv.res_json->'occurrencePeriod'->>'start')::timestamp + ELSE NULL + END AS occurrence_datetime, + CASE WHEN rv.res_json ? 'occurrencePeriod' + THEN (rv.res_json->'occurrencePeriod'->>'end')::timestamp + ELSE NULL + END AS occurrence_end, + + -- Authored date (when the order was placed) + (rv.res_json->>'authoredOn')::timestamp AS authored_on, + + -- Requester (ordering provider) + SPLIT_PART(rv.res_json->'requester'->>'reference', '/', 2) AS requester_id, + + -- Performer (first — who should fulfill the order) + SPLIT_PART(rv.res_json->'performer'->0->>'reference', '/', 2) AS performer_id, + + -- Reason (clinical indication — first code) + rv.res_json->'reasonCode'->0->'coding'->0->>'code' AS reason_code, + rv.res_json->'reasonCode'->0->'coding'->0->>'system' AS reason_system, + + -- Body site (first — for anatomical location) + rv.res_json->'bodySite'->0->'coding'->0->>'code' AS body_site_code, + + -- Do not perform flag (order NOT to do something — important for exclusion logic) + (rv.res_json->>'doNotPerform')::boolean AS do_not_perform, + + -- Priority (routine | urgent | asap | stat) + rv.res_json->>'priority' AS priority, + + -- Encounter context + SPLIT_PART(rv.res_json->'encounter'->>'reference', '/', 2) AS encounter_id, + + -- Insurance reference (first Coverage) + SPLIT_PART(rv.res_json->'insurance'->0->>'reference', '/', 2) AS insurance_id, + + r.RES_UPDATED AS last_updated + +FROM HFJ_RESOURCE r +CROSS JOIN LATERAL ( + SELECT COALESCE( + v.RES_TEXT_VC, + CASE WHEN v.RES_ENCODING = 'JSON' THEN convert_from(v.RES_TEXT, 'UTF8') END + )::jsonb AS res_json + FROM HFJ_RES_VER v + WHERE v.RES_ID = r.RES_ID + AND v.RES_VER = r.RES_VER +) rv +WHERE r.RES_DELETED_AT IS NULL + AND r.RES_TYPE = 'ServiceRequest'; + +SELECT cql_studio_set_view_version('service_request_view', 1, + 'US Core 6.1 ServiceRequest view — referrals, orders, diagnostic requests');