fhirpath-rb 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (69) hide show
  1. checksums.yaml +7 -0
  2. data/.rspec +3 -0
  3. data/.rubocop.yml +25 -0
  4. data/CLAUDE.md +59 -0
  5. data/LICENSE.txt +21 -0
  6. data/README.md +43 -0
  7. data/Rakefile +12 -0
  8. data/ext/fhir_path_parser/extconf.rb +40 -0
  9. data/lib/fhirpath/engine/evaluators/expressions.rb +59 -0
  10. data/lib/fhirpath/engine/evaluators/literals.rb +70 -0
  11. data/lib/fhirpath/engine/evaluators/member_invocation.rb +109 -0
  12. data/lib/fhirpath/engine/evaluators.rb +130 -0
  13. data/lib/fhirpath/engine/invocations/aggregate.rb +48 -0
  14. data/lib/fhirpath/engine/invocations/collections.rb +32 -0
  15. data/lib/fhirpath/engine/invocations/combining.rb +30 -0
  16. data/lib/fhirpath/engine/invocations/datetime.rb +34 -0
  17. data/lib/fhirpath/engine/invocations/equality.rb +95 -0
  18. data/lib/fhirpath/engine/invocations/equivalence.rb +138 -0
  19. data/lib/fhirpath/engine/invocations/existence.rb +117 -0
  20. data/lib/fhirpath/engine/invocations/filtering.rb +107 -0
  21. data/lib/fhirpath/engine/invocations/inequality.rb +92 -0
  22. data/lib/fhirpath/engine/invocations/logic.rb +74 -0
  23. data/lib/fhirpath/engine/invocations/math.rb +109 -0
  24. data/lib/fhirpath/engine/invocations/math_functions.rb +121 -0
  25. data/lib/fhirpath/engine/invocations/misc.rb +144 -0
  26. data/lib/fhirpath/engine/invocations/misc_converts_to.rb +91 -0
  27. data/lib/fhirpath/engine/invocations/navigation.rb +102 -0
  28. data/lib/fhirpath/engine/invocations/registry_strings_and_math.rb +48 -0
  29. data/lib/fhirpath/engine/invocations/strings.rb +139 -0
  30. data/lib/fhirpath/engine/invocations/strings_encoding.rb +68 -0
  31. data/lib/fhirpath/engine/invocations/subsetting.rb +15 -0
  32. data/lib/fhirpath/engine/invocations/types.rb +36 -0
  33. data/lib/fhirpath/engine/invocations.rb +118 -0
  34. data/lib/fhirpath/engine/nodes/fp_date_time.rb +140 -0
  35. data/lib/fhirpath/engine/nodes/fp_date_time_arithmetic.rb +112 -0
  36. data/lib/fhirpath/engine/nodes/fp_quantity.rb +142 -0
  37. data/lib/fhirpath/engine/nodes/fp_quantity_deep_equal.rb +67 -0
  38. data/lib/fhirpath/engine/nodes/fp_time.rb +139 -0
  39. data/lib/fhirpath/engine/nodes/fp_time_arithmetic.rb +111 -0
  40. data/lib/fhirpath/engine/nodes/resource_node.rb +66 -0
  41. data/lib/fhirpath/engine/nodes/type_info.rb +92 -0
  42. data/lib/fhirpath/engine/param_resolution.rb +74 -0
  43. data/lib/fhirpath/engine/util.rb +117 -0
  44. data/lib/fhirpath/engine.rb +100 -0
  45. data/lib/fhirpath/models/dstu2/choiceTypePaths.json +1041 -0
  46. data/lib/fhirpath/models/dstu2/path2Type.json +4728 -0
  47. data/lib/fhirpath/models/dstu2/pathsDefinedElsewhere.json +21 -0
  48. data/lib/fhirpath/models/dstu2/type2Parent.json +141 -0
  49. data/lib/fhirpath/models/r4/choiceTypePaths.json +1365 -0
  50. data/lib/fhirpath/models/r4/path2Type.json +7707 -0
  51. data/lib/fhirpath/models/r4/pathsDefinedElsewhere.json +57 -0
  52. data/lib/fhirpath/models/r4/type2Parent.json +211 -0
  53. data/lib/fhirpath/models/r5/choiceTypePaths.json +1883 -0
  54. data/lib/fhirpath/models/r5/path2Type.json +9653 -0
  55. data/lib/fhirpath/models/r5/pathsDefinedElsewhere.json +80 -0
  56. data/lib/fhirpath/models/r5/type2Parent.json +234 -0
  57. data/lib/fhirpath/models/stu3/choiceTypePaths.json +991 -0
  58. data/lib/fhirpath/models/stu3/path2Type.json +5729 -0
  59. data/lib/fhirpath/models/stu3/pathsDefinedElsewhere.json +37 -0
  60. data/lib/fhirpath/models/stu3/type2Parent.json +173 -0
  61. data/lib/fhirpath/models.rb +25 -0
  62. data/lib/fhirpath/parser/FHIRPath.g4 +172 -0
  63. data/lib/fhirpath/parser/ast_builder.rb +51 -0
  64. data/lib/fhirpath/parser.rb +21 -0
  65. data/lib/fhirpath/version.rb +5 -0
  66. data/lib/fhirpath.rb +117 -0
  67. data/rakelib/parser.rake +146 -0
  68. data/sig/fhirpath.rbs +14 -0
  69. metadata +168 -0
@@ -0,0 +1,37 @@
1
+ {
2
+ "Bundle.entry.link": "Bundle.link",
3
+ "CapabilityStatement.rest.searchParam": "CapabilityStatement.rest.resource.searchParam",
4
+ "ClaimResponse.addItem.adjudication": "ClaimResponse.item.adjudication",
5
+ "ClaimResponse.addItem.detail.adjudication": "ClaimResponse.item.adjudication",
6
+ "ClaimResponse.item.detail.adjudication": "ClaimResponse.item.adjudication",
7
+ "ClaimResponse.item.detail.subDetail.adjudication": "ClaimResponse.item.adjudication",
8
+ "CodeSystem.concept.concept": "CodeSystem.concept",
9
+ "Composition.section.section": "Composition.section",
10
+ "ConceptMap.group.element.target.product": "ConceptMap.group.element.target.dependsOn",
11
+ "Contract.term.group": "Contract.term",
12
+ "ExplanationOfBenefit.addItem.adjudication": "ExplanationOfBenefit.item.adjudication",
13
+ "ExplanationOfBenefit.addItem.detail.adjudication": "ExplanationOfBenefit.item.adjudication",
14
+ "ExplanationOfBenefit.item.detail.adjudication": "ExplanationOfBenefit.item.adjudication",
15
+ "ExplanationOfBenefit.item.detail.subDetail.adjudication": "ExplanationOfBenefit.item.adjudication",
16
+ "GraphDefinition.link.target.link": "GraphDefinition.link",
17
+ "ImplementationGuide.page.page": "ImplementationGuide.page",
18
+ "Observation.component.referenceRange": "Observation.referenceRange",
19
+ "OperationDefinition.parameter.part": "OperationDefinition.parameter",
20
+ "Parameters.parameter.part": "Parameters.parameter",
21
+ "PlanDefinition.action.action": "PlanDefinition.action",
22
+ "Provenance.entity.agent": "Provenance.agent",
23
+ "Questionnaire.item.item": "Questionnaire.item",
24
+ "QuestionnaireResponse.item.answer.item": "QuestionnaireResponse.item",
25
+ "QuestionnaireResponse.item.item": "QuestionnaireResponse.item",
26
+ "RequestGroup.action.action": "RequestGroup.action",
27
+ "StructureMap.group.rule.rule": "StructureMap.group.rule",
28
+ "TestReport.teardown.action.operation": "TestReport.setup.action.operation",
29
+ "TestReport.test.action.assert": "TestReport.setup.action.assert",
30
+ "TestReport.test.action.operation": "TestReport.setup.action.operation",
31
+ "TestScript.teardown.action.operation": "TestScript.setup.action.operation",
32
+ "TestScript.test.action.assert": "TestScript.setup.action.assert",
33
+ "TestScript.test.action.operation": "TestScript.setup.action.operation",
34
+ "ValueSet.compose.exclude": "ValueSet.compose.include",
35
+ "ValueSet.expansion.contains.contains": "ValueSet.expansion.contains",
36
+ "ValueSet.expansion.contains.designation": "ValueSet.compose.include.concept.designation"
37
+ }
@@ -0,0 +1,173 @@
1
+ {
2
+ "Account": "DomainResource",
3
+ "ActivityDefinition": "DomainResource",
4
+ "Address": "Element",
5
+ "AdverseEvent": "DomainResource",
6
+ "Age": "Quantity",
7
+ "AllergyIntolerance": "DomainResource",
8
+ "Annotation": "Element",
9
+ "Appointment": "DomainResource",
10
+ "AppointmentResponse": "DomainResource",
11
+ "Attachment": "Element",
12
+ "AuditEvent": "DomainResource",
13
+ "BackboneElement": "Element",
14
+ "Basic": "DomainResource",
15
+ "Binary": "Resource",
16
+ "BodySite": "DomainResource",
17
+ "Bundle": "Resource",
18
+ "CapabilityStatement": "DomainResource",
19
+ "CarePlan": "DomainResource",
20
+ "CareTeam": "DomainResource",
21
+ "ChargeItem": "DomainResource",
22
+ "Claim": "DomainResource",
23
+ "ClaimResponse": "DomainResource",
24
+ "ClinicalImpression": "DomainResource",
25
+ "CodeSystem": "DomainResource",
26
+ "CodeableConcept": "Element",
27
+ "Coding": "Element",
28
+ "Communication": "DomainResource",
29
+ "CommunicationRequest": "DomainResource",
30
+ "CompartmentDefinition": "DomainResource",
31
+ "Composition": "DomainResource",
32
+ "ConceptMap": "DomainResource",
33
+ "Condition": "DomainResource",
34
+ "Consent": "DomainResource",
35
+ "ContactDetail": "Element",
36
+ "ContactPoint": "Element",
37
+ "Contract": "DomainResource",
38
+ "Contributor": "Element",
39
+ "Count": "Quantity",
40
+ "Coverage": "DomainResource",
41
+ "DataElement": "DomainResource",
42
+ "DataRequirement": "Element",
43
+ "DetectedIssue": "DomainResource",
44
+ "Device": "DomainResource",
45
+ "DeviceComponent": "DomainResource",
46
+ "DeviceMetric": "DomainResource",
47
+ "DeviceRequest": "DomainResource",
48
+ "DeviceUseStatement": "DomainResource",
49
+ "DiagnosticReport": "DomainResource",
50
+ "Distance": "Quantity",
51
+ "DocumentManifest": "DomainResource",
52
+ "DocumentReference": "DomainResource",
53
+ "DomainResource": "Resource",
54
+ "Dosage": "Element",
55
+ "Duration": "Quantity",
56
+ "ElementDefinition": "Element",
57
+ "EligibilityRequest": "DomainResource",
58
+ "EligibilityResponse": "DomainResource",
59
+ "Encounter": "DomainResource",
60
+ "Endpoint": "DomainResource",
61
+ "EnrollmentRequest": "DomainResource",
62
+ "EnrollmentResponse": "DomainResource",
63
+ "EpisodeOfCare": "DomainResource",
64
+ "ExpansionProfile": "DomainResource",
65
+ "ExplanationOfBenefit": "DomainResource",
66
+ "Extension": "Element",
67
+ "FamilyMemberHistory": "DomainResource",
68
+ "Flag": "DomainResource",
69
+ "Goal": "DomainResource",
70
+ "GraphDefinition": "DomainResource",
71
+ "Group": "DomainResource",
72
+ "GuidanceResponse": "DomainResource",
73
+ "HealthcareService": "DomainResource",
74
+ "HumanName": "Element",
75
+ "Identifier": "Element",
76
+ "ImagingManifest": "DomainResource",
77
+ "ImagingStudy": "DomainResource",
78
+ "Immunization": "DomainResource",
79
+ "ImmunizationRecommendation": "DomainResource",
80
+ "ImplementationGuide": "DomainResource",
81
+ "Library": "DomainResource",
82
+ "Linkage": "DomainResource",
83
+ "List": "DomainResource",
84
+ "Location": "DomainResource",
85
+ "Measure": "DomainResource",
86
+ "MeasureReport": "DomainResource",
87
+ "Media": "DomainResource",
88
+ "Medication": "DomainResource",
89
+ "MedicationAdministration": "DomainResource",
90
+ "MedicationDispense": "DomainResource",
91
+ "MedicationRequest": "DomainResource",
92
+ "MedicationStatement": "DomainResource",
93
+ "MessageDefinition": "DomainResource",
94
+ "MessageHeader": "DomainResource",
95
+ "Meta": "Element",
96
+ "Money": "Quantity",
97
+ "NamingSystem": "DomainResource",
98
+ "Narrative": "Element",
99
+ "NutritionOrder": "DomainResource",
100
+ "Observation": "DomainResource",
101
+ "OperationDefinition": "DomainResource",
102
+ "OperationOutcome": "DomainResource",
103
+ "Organization": "DomainResource",
104
+ "ParameterDefinition": "Element",
105
+ "Parameters": "Resource",
106
+ "Patient": "DomainResource",
107
+ "PaymentNotice": "DomainResource",
108
+ "PaymentReconciliation": "DomainResource",
109
+ "Period": "Element",
110
+ "Person": "DomainResource",
111
+ "PlanDefinition": "DomainResource",
112
+ "Practitioner": "DomainResource",
113
+ "PractitionerRole": "DomainResource",
114
+ "Procedure": "DomainResource",
115
+ "ProcedureRequest": "DomainResource",
116
+ "ProcessRequest": "DomainResource",
117
+ "ProcessResponse": "DomainResource",
118
+ "Provenance": "DomainResource",
119
+ "Quantity": "Element",
120
+ "Questionnaire": "DomainResource",
121
+ "QuestionnaireResponse": "DomainResource",
122
+ "Range": "Element",
123
+ "Ratio": "Element",
124
+ "Reference": "Element",
125
+ "ReferralRequest": "DomainResource",
126
+ "RelatedArtifact": "Element",
127
+ "RelatedPerson": "DomainResource",
128
+ "RequestGroup": "DomainResource",
129
+ "ResearchStudy": "DomainResource",
130
+ "ResearchSubject": "DomainResource",
131
+ "RiskAssessment": "DomainResource",
132
+ "SampledData": "Element",
133
+ "Schedule": "DomainResource",
134
+ "SearchParameter": "DomainResource",
135
+ "Sequence": "DomainResource",
136
+ "ServiceDefinition": "DomainResource",
137
+ "Signature": "Element",
138
+ "SimpleQuantity": "Quantity",
139
+ "Slot": "DomainResource",
140
+ "Specimen": "DomainResource",
141
+ "StructureDefinition": "DomainResource",
142
+ "StructureMap": "DomainResource",
143
+ "Subscription": "DomainResource",
144
+ "Substance": "DomainResource",
145
+ "SupplyDelivery": "DomainResource",
146
+ "SupplyRequest": "DomainResource",
147
+ "Task": "DomainResource",
148
+ "TestReport": "DomainResource",
149
+ "TestScript": "DomainResource",
150
+ "Timing": "Element",
151
+ "TriggerDefinition": "Element",
152
+ "UsageContext": "Element",
153
+ "ValueSet": "DomainResource",
154
+ "VisionPrescription": "DomainResource",
155
+ "base64Binary": "Element",
156
+ "boolean": "Element",
157
+ "code": "string",
158
+ "date": "Element",
159
+ "dateTime": "Element",
160
+ "decimal": "Element",
161
+ "id": "string",
162
+ "instant": "Element",
163
+ "integer": "Element",
164
+ "markdown": "string",
165
+ "oid": "uri",
166
+ "positiveInt": "integer",
167
+ "string": "Element",
168
+ "time": "Element",
169
+ "unsignedInt": "integer",
170
+ "uri": "Element",
171
+ "uuid": "uri",
172
+ "xhtml": "Element"
173
+ }
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Fhirpath
6
+ # Ruby port of fhirpath-py's fhirpathpy/models/__init__.py: loads the per-FHIR-version model
7
+ # data (choice-type expansion, path-to-type mapping, ...) used to disambiguate polymorphic
8
+ # fields like `Patient.deceased[x]`. Each subdirectory here (r4, r5, stu3, dstu2) holds the
9
+ # JSON files copied verbatim from fhirpath-py's model data for that version.
10
+ module Models
11
+ DIR = File.join(__dir__, "models")
12
+
13
+ REGISTRY = Dir.children(DIR).sort.each_with_object({}) do |version, models|
14
+ next unless File.directory?(File.join(DIR, version))
15
+
16
+ models[version] = Dir.glob(File.join(DIR, version, "*.json")).each_with_object({}) do |path, data|
17
+ data[File.basename(path, ".json")] = JSON.parse(File.read(path))
18
+ end
19
+ end.freeze
20
+
21
+ def self.[](name)
22
+ REGISTRY[name]
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,172 @@
1
+ grammar FHIRPath;
2
+
3
+ // Grammar rules
4
+ // [FHIRPath](http://hl7.org/fhirpath/N1) Normative Release
5
+
6
+ //prog: line (line)*;
7
+ //line: ID ( '(' expr ')') ':' expr '\r'? '\n';
8
+ entireExpression
9
+ : expression EOF
10
+ ;
11
+
12
+ expression
13
+ : term #termExpression
14
+ | expression '.' invocation #invocationExpression
15
+ | expression '[' expression ']' #indexerExpression
16
+ | ('+' | '-') expression #polarityExpression
17
+ | expression ('*' | '/' | 'div' | 'mod') expression #multiplicativeExpression
18
+ | expression ('+' | '-' | '&') expression #additiveExpression
19
+ | expression '|' expression #unionExpression
20
+ | expression ('<=' | '<' | '>' | '>=') expression #inequalityExpression
21
+ | expression ('is' | 'as') typeSpecifier #typeExpression
22
+ | expression ('=' | '~' | '!=' | '!~') expression #equalityExpression
23
+ | expression ('in' | 'contains') expression #membershipExpression
24
+ | expression 'and' expression #andExpression
25
+ | expression ('or' | 'xor') expression #orExpression
26
+ | expression 'implies' expression #impliesExpression
27
+ //| (IDENTIFIER)? '=>' expression #lambdaExpression
28
+ ;
29
+
30
+ term
31
+ : invocation #invocationTerm
32
+ | literal #literalTerm
33
+ | externalConstant #externalConstantTerm
34
+ | '(' expression ')' #parenthesizedTerm
35
+ ;
36
+
37
+ literal
38
+ : '{' '}' #nullLiteral
39
+ | ('true' | 'false') #booleanLiteral
40
+ | STRING #stringLiteral
41
+ | NUMBER #numberLiteral
42
+ | DATETIME #dateTimeLiteral
43
+ | TIME #timeLiteral
44
+ | quantity #quantityLiteral
45
+ ;
46
+
47
+ externalConstant
48
+ : '%' ( identifier | STRING )
49
+ ;
50
+
51
+ invocation // Terms that can be used after the function/member invocation '.'
52
+ : identifier #memberInvocation
53
+ | functn #functionInvocation
54
+ | '$this' #thisInvocation
55
+ | '$index' #indexInvocation
56
+ | '$total' #totalInvocation
57
+ ;
58
+
59
+ functn
60
+ : identifier '(' paramList? ')'
61
+ ;
62
+
63
+ paramList
64
+ : expression (',' expression)*
65
+ ;
66
+
67
+ quantity
68
+ : NUMBER unit?
69
+ ;
70
+
71
+ unit
72
+ : dateTimePrecision
73
+ | pluralDateTimePrecision
74
+ | STRING // UCUM syntax for units of measure
75
+ ;
76
+
77
+ dateTimePrecision
78
+ : 'year' | 'month' | 'week' | 'day' | 'hour' | 'minute' | 'second' | 'millisecond'
79
+ ;
80
+
81
+ pluralDateTimePrecision
82
+ : 'years' | 'months' | 'weeks' | 'days' | 'hours' | 'minutes' | 'seconds' | 'milliseconds'
83
+ ;
84
+
85
+ typeSpecifier
86
+ : qualifiedIdentifier
87
+ ;
88
+
89
+ qualifiedIdentifier
90
+ : identifier ('.' identifier)*
91
+ ;
92
+
93
+ identifier
94
+ : IDENTIFIER
95
+ | DELIMITEDIDENTIFIER
96
+ | 'as'
97
+ | 'is'
98
+ | 'contains'
99
+ | 'in'
100
+ ;
101
+
102
+
103
+ /****************************************************************
104
+ Lexical rules
105
+ *****************************************************************/
106
+
107
+ DATETIME
108
+ : '@'
109
+ [0-9][0-9][0-9][0-9] // year
110
+ (
111
+ '-'[0-9][0-9] // month
112
+ (
113
+ '-'[0-9][0-9] // day
114
+ (
115
+ 'T' TIMEFORMAT
116
+ )?
117
+ )?
118
+ )?
119
+ 'Z'? // UTC specifier
120
+ ;
121
+
122
+ TIME
123
+ : '@' 'T' TIMEFORMAT
124
+ ;
125
+
126
+ fragment TIMEFORMAT
127
+ :
128
+ [0-9][0-9] (':'[0-9][0-9] (':'[0-9][0-9] ('.'[0-9]+)?)?)?
129
+ ('Z' | ('+' | '-') [0-9][0-9]':'[0-9][0-9])? // timezone
130
+ ;
131
+
132
+ IDENTIFIER
133
+ : ([A-Za-z] | '_')([A-Za-z0-9] | '_')* // Added _ to support CQL (FHIR could constrain it out)
134
+ ;
135
+
136
+ DELIMITEDIDENTIFIER
137
+ : '`' (ESC | ~[\\`])* '`'
138
+ ;
139
+
140
+ STRING
141
+ : '\'' (ESC | ~['])* '\''
142
+ ;
143
+
144
+ // Also allows leading zeroes now (just like CQL and XSD)
145
+ NUMBER
146
+ : [0-9]+('.' [0-9]+)?
147
+ ;
148
+
149
+ // Pipe whitespace to the HIDDEN channel to support retrieving source text through the parser.
150
+ WS
151
+ : [ \r\n\t]+ -> channel(HIDDEN)
152
+ ;
153
+
154
+ COMMENT
155
+ : '/*' .*? '*/' -> channel(HIDDEN)
156
+ ;
157
+
158
+ LINE_COMMENT
159
+ : '//' ~[\r\n]* -> channel(HIDDEN)
160
+ ;
161
+
162
+ fragment ESC
163
+ : '\\' ([`'\\/fnrt] | UNICODE) // allow \`, \', \\, \/, \f, etc. and \uXXX
164
+ ;
165
+
166
+ fragment UNICODE
167
+ : 'u' HEX HEX HEX HEX
168
+ ;
169
+
170
+ fragment HEX
171
+ : [0-9a-fA-F]
172
+ ;
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Fhirpath
4
+ module Parser
5
+ # Walks the native ANTLR4 parse tree (see Fhirpath::Parser.parse) and builds the same
6
+ # generic AST shape as fhirpath-py's ASTPathListener: each node is a Hash with "type",
7
+ # optional "text", "terminalNodeText" (direct terminal children's text), and optional
8
+ # "children" (present only when there's at least one non-terminal child).
9
+ module AstBuilder
10
+ # Port of ASTPathListener.py's has_node_type_text: which node types carry source text.
11
+ TEXT_NODE_TYPES = %w[
12
+ LiteralTerm
13
+ Identifier
14
+ TypeSpecifier
15
+ InvocationExpression
16
+ TermExpression
17
+ ].freeze
18
+
19
+ class << self
20
+ def build(ctx)
21
+ type = node_type(ctx)
22
+ node = { "type" => type, "terminalNodeText" => terminal_text(ctx) }
23
+ node["text"] = ctx.text if text_node?(type)
24
+
25
+ children = rule_children(ctx).map { |child| build(child) }
26
+ node["children"] = children unless children.empty?
27
+
28
+ node
29
+ end
30
+
31
+ private
32
+
33
+ def terminal_text(ctx)
34
+ ctx.children.select { |child| child.is_a?(::FHIRPathParser::TerminalNodeImpl) }.map(&:text)
35
+ end
36
+
37
+ def rule_children(ctx)
38
+ ctx.children.reject { |child| child.is_a?(::FHIRPathParser::TerminalNodeImpl) }
39
+ end
40
+
41
+ def node_type(ctx)
42
+ ctx.type_name.split("::").last.delete_suffix("Context")
43
+ end
44
+
45
+ def text_node?(type)
46
+ type.end_with?("Literal") || TEXT_NODE_TYPES.include?(type)
47
+ end
48
+ end
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../../ext/fhir_path_parser/fhir_path_parser"
4
+ require_relative "parser/ast_builder"
5
+
6
+ module Fhirpath
7
+ # Ruby port of fhirpath-py's fhirpathpy/parser package. Parses a FHIRPath expression string
8
+ # into the same generic AST shape fhirpath-py produces (see AstBuilder), using a native
9
+ # ANTLR4 parser generated from FHIRPath.g4 via the antlr4-native gem — run
10
+ # `rake parser:setup compile` once before requiring this file (see rakelib/parser.rake).
11
+ module Parser
12
+ def self.parse(expression)
13
+ parser = FHIRPathParser::Parser.parse(expression)
14
+ root = parser.expression
15
+
16
+ raise Fhirpath::Error, "Failed to parse #{expression.inspect}" if parser.syntax_error_count.positive?
17
+
18
+ { "children" => [AstBuilder.build(root)] }
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Fhirpath
4
+ VERSION = "0.1.1"
5
+ end
data/lib/fhirpath.rb ADDED
@@ -0,0 +1,117 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fhir_models"
4
+
5
+ require_relative "fhirpath/version"
6
+ require_relative "fhirpath/parser"
7
+ require_relative "fhirpath/engine"
8
+ require_relative "fhirpath/models"
9
+
10
+ # Ruby implementation of FHIRPath (https://hl7.org/fhirpath/), a port of fhirpath-py
11
+ # (https://github.com/beda-software/fhirpath-py).
12
+ module Fhirpath
13
+ class Error < StandardError; end
14
+
15
+ # `path` may be a plain FHIRPath expression string, or (mirroring fhirpath-py's evaluate) a
16
+ # {"expression" => ..., "base" => ...} Hash: "base" tags the resource with the FHIRPath it
17
+ # would have been reached at (e.g. "QuestionnaireResponse.item") without actually navigating
18
+ # there, so model-based navigation (e.g. choice-type resolution) below that point works when
19
+ # evaluating just a fragment of a resource.
20
+ def self.evaluate(resource, path, context = {}, model = nil, options = {})
21
+ if path.is_a?(::Hash)
22
+ node = Parser.parse(path["expression"])
23
+ resource = Engine::Nodes::ResourceNode.create_node(resource, path["base"]) if path["base"]
24
+ else
25
+ node = Parser.parse(path)
26
+ end
27
+
28
+ apply_parsed_path(resource, node, context, model, options)
29
+ end
30
+
31
+ def self.compile(path, model = nil, options = {})
32
+ node = Parser.parse(path)
33
+ ->(resource, context = nil) { apply_parsed_path(resource, node, context || {}, model, options) }
34
+ end
35
+
36
+ # Ruby port of fhirpath-py's compile_as_array (fhirpathpy/__init__.py): like `compile`, but
37
+ # the returned callable accepts a fhir_models `input_type` instance instead of a plain Hash,
38
+ # and wraps each result item as an `output_type` instance instead of returning raw data.
39
+ def self.compile_as_array(expression, input_type, output_type, model = nil)
40
+ path_fn = compile(expression, model)
41
+ lambda do |resource, context = nil|
42
+ data = path_fn.call(prepare_data(resource, input_type), context)
43
+ format_result(data, output_type, array: true)
44
+ end
45
+ end
46
+
47
+ def self.compile_as_first(expression, input_type, output_type, model = nil)
48
+ path_fn = compile(expression, model)
49
+ lambda do |resource, context = nil|
50
+ data = path_fn.call(prepare_data(resource, input_type), context)
51
+ format_result(data, output_type, array: false)
52
+ end
53
+ end
54
+
55
+ def self.prepare_data(resource, input_type)
56
+ raise Error, "Resource type is #{resource.class}, expected #{input_type}" unless resource.is_a?(input_type)
57
+
58
+ return resource if resource.is_a?(::Hash)
59
+ return resource.to_hash if resource.respond_to?(:to_hash)
60
+
61
+ raise Error, "Don't know how to work with type #{resource.class}"
62
+ end
63
+ private_class_method :prepare_data
64
+
65
+ def self.format_result(result, output_type, array:)
66
+ formatted = result.map { |item| format_item(item, output_type) }
67
+ return formatted if array
68
+
69
+ formatted.first
70
+ end
71
+ private_class_method :format_result
72
+
73
+ def self.format_item(item, output_type)
74
+ return item if item.is_a?(output_type)
75
+ return output_type.new(item) if item.is_a?(::Hash) && output_type.is_a?(::Class) && output_type <= ::FHIR::Model
76
+
77
+ raise Error, "Expected result to be #{output_type}, but got #{item.class}"
78
+ end
79
+ private_class_method :format_item
80
+
81
+ # Ruby port of fhirpath-py's apply_parsed_path (fhirpathpy/__init__.py): builds the
82
+ # evaluation context and resolves the AST, then unwraps internal ResourceNode wrappers back
83
+ # into plain data.
84
+ def self.apply_parsed_path(resource, node, context, model, _options)
85
+ data_root = Engine::Util.arraify(resource)
86
+ ctx = {
87
+ root: data_root,
88
+ vars: { "context" => resource, "resource" => resource,
89
+ "ucum" => "http://unitsofmeasure.org" }.merge(context || {}),
90
+ model: model.is_a?(::String) ? Models[model] : model,
91
+ user_invocation_table: {}
92
+ }
93
+
94
+ visit(Engine.do_eval(ctx, data_root, node["children"][0]))
95
+ end
96
+ private_class_method :apply_parsed_path
97
+
98
+ # Resolves ResourceNode instances back into plain data, dropping internal
99
+ # primitive-extension-only placeholders (`{"extension" => [...]}`) along the way.
100
+ def self.visit(node)
101
+ data = Engine::Util.get_data(node)
102
+
103
+ return visit_array(data) if data.is_a?(::Array)
104
+ return data.transform_values { |value| visit(value) } if data.is_a?(::Hash)
105
+
106
+ data
107
+ end
108
+ private_class_method :visit
109
+
110
+ def self.visit_array(items)
111
+ items.each_with_object([]) do |item, acc|
112
+ visited = visit(item)
113
+ acc << visited unless visited.is_a?(::Hash) && visited.keys == ["extension"]
114
+ end
115
+ end
116
+ private_class_method :visit_array
117
+ end