Skip to content

Commit 66d3f37

Browse files
committed
Use type predicates for ViewDefinition validation
- Replaced JsonValue type alias with proper unvalidated types - Created UnvalidatedViewDefinition, UnvalidatedSelect, and UnvalidatedColumn interfaces - Implemented type guard predicates (isValidViewDefinition, isValidSelect, isValidColumn) - Made validated types extend unvalidated types for proper type narrowing - Added specific types for FHIR resources (FhirResource), test expectations (TestExpectation), and SQL parameters (SqlParameterValue) - Extracted helper methods to reduce cyclomatic complexity Type predicates provide compile-time type safety after runtime validation, following TypeScript best practices for narrowing unknown types.
1 parent 6307b23 commit 66d3f37

File tree

2 files changed

+181
-77
lines changed

2 files changed

+181
-77
lines changed

src/parser.ts

Lines changed: 114 additions & 67 deletions
Original file line numberDiff line numberDiff line change
@@ -2,21 +2,34 @@
22
* Parser for ViewDefinition JSON structures.
33
*/
44

5-
import { JsonValue, TestSuite, ViewDefinition } from "./types.js";
5+
import {
6+
TestSuite,
7+
UnvalidatedColumn,
8+
UnvalidatedSelect,
9+
UnvalidatedViewDefinition,
10+
ViewDefinition,
11+
ViewDefinitionColumn,
12+
ViewDefinitionSelect,
13+
} from "./types.js";
614

715
export class ViewDefinitionParser {
816
/**
917
* Parse a ViewDefinition from JSON.
1018
*/
1119
static parseViewDefinition(json: string | object): ViewDefinition {
12-
const data = typeof json === "string" ? JSON.parse(json) : json;
20+
const data: UnvalidatedViewDefinition =
21+
typeof json === "string" ? JSON.parse(json) : json;
1322

1423
// Only check resourceType if it's present (for backwards compatibility with test cases)
1524
if (data.resourceType && data.resourceType !== "ViewDefinition") {
1625
throw new Error("Invalid resource type. Expected ViewDefinition.");
1726
}
1827

19-
return this.validateViewDefinition(data);
28+
if (this.isValidViewDefinition(data)) {
29+
return data;
30+
}
31+
32+
throw new Error("Invalid ViewDefinition structure.");
2033
}
2134

2235
/**
@@ -33,10 +46,12 @@ export class ViewDefinitionParser {
3346
}
3447

3548
/**
36-
* Validate a ViewDefinition structure.
49+
* Validate and narrow a ViewDefinition structure using type predicate.
3750
*/
38-
private static validateViewDefinition(data: JsonValue): ViewDefinition {
39-
if (!data.resource) {
51+
private static isValidViewDefinition(
52+
data: UnvalidatedViewDefinition,
53+
): data is ViewDefinition {
54+
if (!data.resource || typeof data.resource !== "string") {
4055
throw new Error("ViewDefinition must specify a resource type.");
4156
}
4257

@@ -54,62 +69,46 @@ export class ViewDefinitionParser {
5469
data.status ??= "active";
5570

5671
// Validate select elements
57-
this.validateSelectElements(data.select);
58-
59-
return data as ViewDefinition;
60-
}
61-
62-
/**
63-
* Validate select elements recursively.
64-
*/
65-
private static validateSelectElements(selects: JsonValue[]): void {
66-
for (const select of selects) {
67-
this.validateSelectElement(select);
72+
for (const select of data.select) {
73+
if (!this.isValidSelect(select)) {
74+
return false;
75+
}
6876
}
77+
78+
return true;
6979
}
7080

7181
/**
72-
* Validate a single select element.
82+
* Validate select element using type predicate.
7383
*/
74-
private static validateSelectElement(select: JsonValue): void {
75-
this.validateSelectElementStructure(select);
76-
this.validateSelectElementContent(select);
77-
this.validateSelectElementExpressions(select);
84+
private static isValidSelect(
85+
select: UnvalidatedSelect,
86+
): select is ViewDefinitionSelect {
87+
this.validateSelectStructure(select);
88+
this.validateSelectExpressions(select);
89+
90+
return (
91+
this.validateSelectColumns(select) &&
92+
this.validateNestedSelects(select) &&
93+
this.validateUnionAll(select)
94+
);
7895
}
7996

8097
/**
8198
* Validate select element has required structure.
8299
*/
83-
private static validateSelectElementStructure(select: JsonValue): void {
100+
private static validateSelectStructure(select: UnvalidatedSelect): void {
84101
if (!select.column && !select.select && !select.unionAll) {
85102
throw new Error(
86103
"Select element must have columns, nested selects, or unionAll.",
87104
);
88105
}
89106
}
90107

91-
/**
92-
* Validate select element content (columns and nested elements).
93-
*/
94-
private static validateSelectElementContent(select: JsonValue): void {
95-
if (select.column) {
96-
this.validateColumns(select.column, select);
97-
}
98-
99-
if (select.select) {
100-
this.validateSelectElements(select.select);
101-
}
102-
103-
if (select.unionAll) {
104-
this.validateSelectElements(select.unionAll);
105-
this.validateUnionAllColumns(select.unionAll);
106-
}
107-
}
108-
109108
/**
110109
* Validate forEach and forEachOrNull expressions.
111110
*/
112-
private static validateSelectElementExpressions(select: JsonValue): void {
111+
private static validateSelectExpressions(select: UnvalidatedSelect): void {
113112
if (select.forEach && typeof select.forEach !== "string") {
114113
throw new Error("forEach must be a string FHIRPath expression.");
115114
}
@@ -120,43 +119,86 @@ export class ViewDefinitionParser {
120119
}
121120

122121
/**
123-
* Validate column definitions.
122+
* Validate columns in a select element.
124123
*/
125-
private static validateColumns(
126-
columns: JsonValue[],
127-
selectContext?: JsonValue,
128-
): void {
129-
for (const column of columns) {
130-
if (!column.name || typeof column.name !== "string") {
131-
throw new Error("Column must have a valid name.");
124+
private static validateSelectColumns(select: UnvalidatedSelect): boolean {
125+
if (select.column) {
126+
for (const column of select.column) {
127+
if (!this.isValidColumn(column, select)) {
128+
return false;
129+
}
132130
}
131+
}
132+
return true;
133+
}
133134

134-
if (!column.path || typeof column.path !== "string") {
135-
throw new Error("Column must have a valid FHIRPath expression.");
135+
/**
136+
* Validate nested select elements.
137+
*/
138+
private static validateNestedSelects(select: UnvalidatedSelect): boolean {
139+
if (select.select) {
140+
for (const nestedSelect of select.select) {
141+
if (!this.isValidSelect(nestedSelect)) {
142+
return false;
143+
}
136144
}
145+
}
146+
return true;
147+
}
137148

138-
// Validate column name is database-friendly
139-
if (!/^[a-zA-Z_]\w*$/.test(column.name)) {
140-
throw new Error(
141-
`Column name '${column.name}' is not database-friendly. Use alphanumeric and underscores only.`,
142-
);
149+
/**
150+
* Validate unionAll branches.
151+
*/
152+
private static validateUnionAll(select: UnvalidatedSelect): boolean {
153+
if (select.unionAll) {
154+
for (const unionSelect of select.unionAll) {
155+
if (!this.isValidSelect(unionSelect)) {
156+
return false;
157+
}
143158
}
159+
this.validateUnionAllColumns(select.unionAll);
160+
}
161+
return true;
162+
}
163+
164+
/**
165+
* Validate column using type predicate.
166+
*/
167+
private static isValidColumn(
168+
column: UnvalidatedColumn,
169+
selectContext?: UnvalidatedSelect,
170+
): column is ViewDefinitionColumn {
171+
if (!column.name || typeof column.name !== "string") {
172+
throw new Error("Column must have a valid name.");
173+
}
144174

145-
// Validate collection constraints
146-
this.validateCollectionConstraints(column, selectContext);
175+
if (!column.path || typeof column.path !== "string") {
176+
throw new Error("Column must have a valid FHIRPath expression.");
147177
}
178+
179+
// Validate column name is database-friendly
180+
if (!/^[a-zA-Z_]\w*$/.test(column.name)) {
181+
throw new Error(
182+
`Column name '${column.name}' is not database-friendly. Use alphanumeric and underscores only.`,
183+
);
184+
}
185+
186+
// Validate collection constraints
187+
this.validateCollectionConstraints(column, selectContext);
188+
189+
return true;
148190
}
149191

150192
/**
151193
* Validate collection property constraints.
152194
*/
153195
private static validateCollectionConstraints(
154-
column: JsonValue,
155-
selectContext?: JsonValue,
196+
column: UnvalidatedColumn,
197+
selectContext?: UnvalidatedSelect,
156198
): void {
157199
if (column.collection === false) {
158-
// Check if the path could return multiple values
159-
const path = column.path;
200+
// At this point, path has been validated to be a string in isValidColumn
201+
const path = column.path as string;
160202

161203
// Known array fields in FHIR Patient that could return multiple values
162204
const multiValuedPaths = [
@@ -186,7 +228,9 @@ export class ViewDefinitionParser {
186228
/**
187229
* Validate that all branches of a unionAll have the same columns in the same order.
188230
*/
189-
private static validateUnionAllColumns(unionAllBranches: JsonValue[]): void {
231+
private static validateUnionAllColumns(
232+
unionAllBranches: UnvalidatedSelect[],
233+
): void {
190234
if (unionAllBranches.length < 2) {
191235
return; // Nothing to validate
192236
}
@@ -231,14 +275,17 @@ export class ViewDefinitionParser {
231275
* Handles direct columns, forEach columns, and nested select columns.
232276
*/
233277
private static extractColumnsFromSelect(
234-
select: JsonValue,
278+
select: UnvalidatedSelect,
235279
): Array<{ name: string; type?: string }> {
236280
const columns: Array<{ name: string; type?: string }> = [];
237281

238282
// Direct columns
239283
if (select.column) {
240284
for (const column of select.column) {
241-
columns.push({ name: column.name, type: column.type });
285+
columns.push({
286+
name: column.name as string,
287+
type: column.type as string | undefined,
288+
});
242289
}
243290
}
244291

@@ -272,7 +319,7 @@ export class ViewDefinitionParser {
272319
* Recursively collect column names from select elements.
273320
*/
274321
private static collectColumnNames(
275-
selects: JsonValue[],
322+
selects: ViewDefinitionSelect[],
276323
columns: string[],
277324
): void {
278325
for (const select of selects) {

0 commit comments

Comments
 (0)