openphar 0.1.0
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.
- checksums.yaml +7 -0
- data/README.adoc +221 -0
- data/lib/openphar/cli/main.rb +137 -0
- data/lib/openphar/cli.rb +11 -0
- data/lib/openphar/core/slug_generator.rb +107 -0
- data/lib/openphar/core.rb +11 -0
- data/lib/openphar/errors/configuration_error.rb +8 -0
- data/lib/openphar/errors/monograph_not_found_error.rb +15 -0
- data/lib/openphar/errors/parse_error.rb +18 -0
- data/lib/openphar/errors/unknown_format_error.rb +8 -0
- data/lib/openphar/errors/unknown_publisher_error.rb +8 -0
- data/lib/openphar/errors/unknown_type_error.rb +8 -0
- data/lib/openphar/errors/validation_error.rb +15 -0
- data/lib/openphar/errors.rb +17 -0
- data/lib/openphar/exporters/coverage_validator.rb +265 -0
- data/lib/openphar/exporters/neo4j/model_registry.rb +130 -0
- data/lib/openphar/exporters/neo4j/node_builder.rb +84 -0
- data/lib/openphar/exporters/neo4j/property_mapper.rb +154 -0
- data/lib/openphar/exporters/neo4j/relationship_builder.rb +87 -0
- data/lib/openphar/exporters/neo4j.rb +16 -0
- data/lib/openphar/exporters/neo4j_exporter.rb +199 -0
- data/lib/openphar/exporters.rb +15 -0
- data/lib/openphar/linkers/chp/cross_edition_linker.rb +101 -0
- data/lib/openphar/linkers/chp.rb +11 -0
- data/lib/openphar/linkers/cross_publisher_linker.rb +295 -0
- data/lib/openphar/linkers/herbapedia_linker.rb +125 -0
- data/lib/openphar/linkers.rb +13 -0
- data/lib/openphar/migrators/chp/classifier.rb +86 -0
- data/lib/openphar/migrators/chp.rb +15 -0
- data/lib/openphar/migrators/chp_migrator.rb +130 -0
- data/lib/openphar/migrators/jp_migrator.rb +345 -0
- data/lib/openphar/migrators/phint_migrator.rb +426 -0
- data/lib/openphar/migrators.rb +15 -0
- data/lib/openphar/models/ahp.rb +25 -0
- data/lib/openphar/models/amino_acid_monograph.rb +28 -0
- data/lib/openphar/models/api.rb +29 -0
- data/lib/openphar/models/assay_specification.rb +19 -0
- data/lib/openphar/models/base_entity.rb +221 -0
- data/lib/openphar/models/biological_substance_monograph.rb +31 -0
- data/lib/openphar/models/chemical_drug_monograph.rb +39 -0
- data/lib/openphar/models/chp/biologic.rb +28 -0
- data/lib/openphar/models/chp/chemical_preparation.rb +28 -0
- data/lib/openphar/models/chp/chemical_substance.rb +28 -0
- data/lib/openphar/models/chp/general_chapter.rb +30 -0
- data/lib/openphar/models/chp/monograph.rb +116 -0
- data/lib/openphar/models/chp/section.rb +37 -0
- data/lib/openphar/models/chp/tcm_crude_drug.rb +28 -0
- data/lib/openphar/models/chp/tcm_extract.rb +29 -0
- data/lib/openphar/models/chp/tcm_formulation.rb +28 -0
- data/lib/openphar/models/chp.rb +33 -0
- data/lib/openphar/models/crude_drug_monograph.rb +31 -0
- data/lib/openphar/models/edition.rb +41 -0
- data/lib/openphar/models/formulation_monograph.rb +31 -0
- data/lib/openphar/models/hkcmms.rb +26 -0
- data/lib/openphar/models/identification_specification.rb +19 -0
- data/lib/openphar/models/jp/kampo_formula.rb +94 -0
- data/lib/openphar/models/jp.rb +21 -0
- data/lib/openphar/models/limit.rb +54 -0
- data/lib/openphar/models/mineral_substance_monograph.rb +28 -0
- data/lib/openphar/models/monograph.rb +58 -0
- data/lib/openphar/models/ph_int/buffer_solution.rb +38 -0
- data/lib/openphar/models/ph_int/dosage_form_monograph.rb +43 -0
- data/lib/openphar/models/ph_int/radiopharmaceutical_monograph.rb +52 -0
- data/lib/openphar/models/ph_int/reagent.rb +50 -0
- data/lib/openphar/models/ph_int/reference_substance.rb +50 -0
- data/lib/openphar/models/ph_int/test_method.rb +50 -0
- data/lib/openphar/models/ph_int/test_solution.rb +39 -0
- data/lib/openphar/models/ph_int/volumetric_solution.rb +38 -0
- data/lib/openphar/models/ph_int.rb +37 -0
- data/lib/openphar/models/physical_specification.rb +17 -0
- data/lib/openphar/models/publisher.rb +98 -0
- data/lib/openphar/models/purity_specification.rb +16 -0
- data/lib/openphar/models/supplement.rb +31 -0
- data/lib/openphar/models/test_specification.rb +29 -0
- data/lib/openphar/models/thp.rb +24 -0
- data/lib/openphar/models/vitamin_monograph.rb +28 -0
- data/lib/openphar/models.rb +58 -0
- data/lib/openphar/monograph_merger.rb +374 -0
- data/lib/openphar/parsers/base_monograph_parser.rb +108 -0
- data/lib/openphar/parsers/jp_html_parser.rb +92 -0
- data/lib/openphar/parsers/jp_html_parser_base.rb +113 -0
- data/lib/openphar/parsers/jp_ja_html_parser.rb +220 -0
- data/lib/openphar/parsers/phint_json_parser.rb +332 -0
- data/lib/openphar/parsers.rb +21 -0
- data/lib/openphar/registry/publisher_registry.rb +46 -0
- data/lib/openphar/registry/type_registry.rb +147 -0
- data/lib/openphar/registry.rb +24 -0
- data/lib/openphar/repositories/monograph_repository.rb +350 -0
- data/lib/openphar/repositories.rb +11 -0
- data/lib/openphar/transformers/jsonld_transformer.rb +106 -0
- data/lib/openphar/transformers.rb +11 -0
- data/lib/openphar/version.rb +5 -0
- data/lib/openphar.rb +119 -0
- data/ontology/bibliographic/edition.ttl +267 -0
- data/ontology/context/chp.jsonld +69 -0
- data/ontology/context/crude-drugs.jsonld +56 -0
- data/ontology/context/pharmacopoeia.jsonld +631 -0
- data/ontology/core/identification.ttl +456 -0
- data/ontology/core/pharmacopoeia.ttl +3427 -0
- data/ontology/core/substance-form.ttl +302 -0
- data/ontology/core/unit.ttl +493 -0
- data/ontology/publisher/chp/extensions.ttl +186 -0
- data/ontology/publisher/phint/extensions.ttl +327 -0
- data/ontology/quality/test-method.ttl +610 -0
- data/shapes/chp/monograph.ttl +153 -0
- data/shapes/edition-shapes.ttl +230 -0
- data/shapes/monograph-shapes.ttl +1324 -0
- metadata +319 -0
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "set"
|
|
5
|
+
|
|
6
|
+
module Openphar
|
|
7
|
+
module Exporters
|
|
8
|
+
# Validates that model coverage is 100% complete
|
|
9
|
+
#
|
|
10
|
+
# Compares:
|
|
11
|
+
# 1. All fields in JSON-LD files have corresponding model attributes
|
|
12
|
+
# 2. All model attributes can be exported to JSON-LD
|
|
13
|
+
# 3. All model attributes can be exported to Neo4j
|
|
14
|
+
class CoverageValidator
|
|
15
|
+
# Standard JSON-LD keys that are framework-related, not domain data
|
|
16
|
+
STANDARD_KEYS = %w[
|
|
17
|
+
@context @graph @id @type @vocab @base
|
|
18
|
+
].freeze
|
|
19
|
+
|
|
20
|
+
# Mapping from JSON-LD camelCase to model snake_case
|
|
21
|
+
FIELD_MAPPING = {
|
|
22
|
+
"monographId" => "monograph_id",
|
|
23
|
+
"prefLabel" => "pref_label",
|
|
24
|
+
"altLabel" => "alt_label",
|
|
25
|
+
"belongsToEdition" => "belongs_to_edition",
|
|
26
|
+
"effectiveDate" => "effective_date",
|
|
27
|
+
"referencesPreparation" => "references_preparation",
|
|
28
|
+
"referencesTCMProfile" => "references_tcm_profile",
|
|
29
|
+
"referencesAyurvedaProfile" => "references_ayurveda_profile",
|
|
30
|
+
"referencesWesternProfile" => "references_western_profile",
|
|
31
|
+
"referencesPlantSpecies" => "references_plant_species",
|
|
32
|
+
"referencesPlantPart" => "references_plant_part",
|
|
33
|
+
"testSpecification" => "test_specifications",
|
|
34
|
+
"sameSubstanceAs" => "same_substance_as",
|
|
35
|
+
"hasEquivalentIn" => "has_equivalent_in",
|
|
36
|
+
"similarTo" => "similar_to",
|
|
37
|
+
"storageContainer" => "storage_container",
|
|
38
|
+
"storageConditions" => "storage_conditions",
|
|
39
|
+
"botanicalSource" => "botanical_source",
|
|
40
|
+
"macroscopicDescription" => "macroscopic_description",
|
|
41
|
+
"microscopicDescription" => "microscopic_description",
|
|
42
|
+
"molecularFormula" => "molecular_formula",
|
|
43
|
+
"molecularWeight" => "molecular_weight",
|
|
44
|
+
"casNumber" => "cas_number",
|
|
45
|
+
"systematicName" => "systematic_name",
|
|
46
|
+
"inchiKey" => "inchi_key",
|
|
47
|
+
"smiles" => "smiles",
|
|
48
|
+
"chemicalStructure" => "chemical_structure",
|
|
49
|
+
"appearance" => "appearance",
|
|
50
|
+
"solubility" => "solubility",
|
|
51
|
+
"testName" => "test_name",
|
|
52
|
+
"testType" => "test_type",
|
|
53
|
+
"harmonizedMethod" => "harmonized_method",
|
|
54
|
+
"publisherMethod" => "publisher_method",
|
|
55
|
+
"testConditions" => "test_conditions",
|
|
56
|
+
"limitType" => "limit_type",
|
|
57
|
+
"limitValue" => "limit_value",
|
|
58
|
+
"lowerLimit" => "lower_limit",
|
|
59
|
+
"upperLimit" => "upper_limit",
|
|
60
|
+
"limitUnit" => "limit_unit",
|
|
61
|
+
"assayTarget" => "assay_target",
|
|
62
|
+
"assayExpression" => "assay_expression",
|
|
63
|
+
"impurityType" => "impurity_type",
|
|
64
|
+
"physicalProperty" => "physical_property"
|
|
65
|
+
}.freeze
|
|
66
|
+
|
|
67
|
+
# Reverse mapping: snake_case to camelCase
|
|
68
|
+
REVERSE_MAPPING = FIELD_MAPPING.invert
|
|
69
|
+
|
|
70
|
+
attr_reader :results, :missing_fields, :extra_fields
|
|
71
|
+
|
|
72
|
+
def initialize
|
|
73
|
+
@results = {}
|
|
74
|
+
@missing_fields = []
|
|
75
|
+
@extra_fields = []
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# Analyze a JSON-LD file and compare to expected model attributes
|
|
79
|
+
#
|
|
80
|
+
# @param jsonld_file [String] Path to JSON-LD file
|
|
81
|
+
# @param expected_fields [Array<Symbol>] Expected field names from model
|
|
82
|
+
def validate_file(jsonld_file, expected_fields = nil)
|
|
83
|
+
data = JSON.parse(File.read(jsonld_file))
|
|
84
|
+
|
|
85
|
+
# Extract all keys from JSON-LD
|
|
86
|
+
jsonld_keys = extract_all_keys(data).to_a
|
|
87
|
+
domain_keys = jsonld_keys - STANDARD_KEYS
|
|
88
|
+
|
|
89
|
+
# Normalize keys to snake_case for comparison
|
|
90
|
+
normalized_jsonld_keys = domain_keys.map { |k| normalize_key(k) }
|
|
91
|
+
|
|
92
|
+
# Get expected fields from model if not provided
|
|
93
|
+
expected ||= expected_fields || model_expected_fields
|
|
94
|
+
expected_normalized = expected.map(&:to_s)
|
|
95
|
+
|
|
96
|
+
# Find missing fields (in JSON-LD but not in model)
|
|
97
|
+
missing = normalized_jsonld_keys - expected_normalized
|
|
98
|
+
|
|
99
|
+
# Find extra fields (in model but not in JSON-LD)
|
|
100
|
+
extra = expected_normalized - normalized_jsonld_keys
|
|
101
|
+
|
|
102
|
+
@missing_fields.concat(missing)
|
|
103
|
+
@extra_fields.concat(extra)
|
|
104
|
+
|
|
105
|
+
coverage = if normalized_jsonld_keys.any?
|
|
106
|
+
(1 - missing.size.to_f / normalized_jsonld_keys.size) * 100
|
|
107
|
+
else
|
|
108
|
+
100
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
@results[jsonld_file] = {
|
|
112
|
+
jsonld_keys: domain_keys.size,
|
|
113
|
+
expected: expected.size,
|
|
114
|
+
missing: missing,
|
|
115
|
+
extra: extra,
|
|
116
|
+
coverage: coverage
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
{ missing: missing, extra: extra, coverage: coverage }
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
# Normalize a key from JSON-LD to snake_case
|
|
123
|
+
def normalize_key(key)
|
|
124
|
+
# Remove nested path (e.g., "prefLabel.en" -> "prefLabel")
|
|
125
|
+
base_key = key.split(".").first
|
|
126
|
+
|
|
127
|
+
# Map to snake_case if known
|
|
128
|
+
FIELD_MAPPING[base_key] || base_key
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
# Validate all JSON-LD files in a directory
|
|
132
|
+
#
|
|
133
|
+
# @param directory [String] Directory path
|
|
134
|
+
# @param expected_fields [Array<Symbol>] Expected model fields
|
|
135
|
+
def validate_directory(directory, expected_fields = nil)
|
|
136
|
+
Dir.glob(File.join(directory, "**", "*.jsonld")).each do |file|
|
|
137
|
+
puts "Validating: #{file}"
|
|
138
|
+
validate_file(file, expected_fields)
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
summary
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
# Generate report of all unique missing fields across all files
|
|
145
|
+
#
|
|
146
|
+
# @return [Hash] Report of coverage
|
|
147
|
+
def summary
|
|
148
|
+
unique_missing = @missing_fields.uniq
|
|
149
|
+
unique_extra = @extra_fields.uniq
|
|
150
|
+
|
|
151
|
+
total_jsonld_keys = @results.values.sum { |r| r[:jsonld_keys] }
|
|
152
|
+
total_expected = @results.values.sum { |r| r[:expected] }
|
|
153
|
+
|
|
154
|
+
avg_coverage = if @results.any?
|
|
155
|
+
@results.values.map { |r| r[:coverage] }.sum / @results.size
|
|
156
|
+
else
|
|
157
|
+
100
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
{
|
|
161
|
+
files_validated: @results.size,
|
|
162
|
+
total_jsonld_fields: total_jsonld_keys,
|
|
163
|
+
total_expected_fields: total_expected,
|
|
164
|
+
unique_missing_fields: unique_missing,
|
|
165
|
+
unique_extra_fields: unique_extra,
|
|
166
|
+
average_coverage: avg_coverage,
|
|
167
|
+
details: @results
|
|
168
|
+
}
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
# Print detailed report
|
|
172
|
+
def print_report
|
|
173
|
+
puts "=" * 60
|
|
174
|
+
puts "COVERAGE VALIDATION REPORT"
|
|
175
|
+
puts "=" * 60
|
|
176
|
+
|
|
177
|
+
summary = self.summary
|
|
178
|
+
|
|
179
|
+
puts "\nFiles Validated: #{summary[:files_validated]}"
|
|
180
|
+
puts "Total JSON-LD Fields: #{summary[:total_jsonld_fields]}"
|
|
181
|
+
puts "Total Expected Fields: #{summary[:total_expected_fields]}"
|
|
182
|
+
puts "Average Coverage: #{summary[:average_coverage].round(2)}%"
|
|
183
|
+
|
|
184
|
+
if summary[:unique_missing_fields].any?
|
|
185
|
+
puts "\n" + "!" * 60
|
|
186
|
+
puts "MISSING FIELDS (in JSON-LD but not in model):"
|
|
187
|
+
puts "!" * 60
|
|
188
|
+
summary[:unique_missing_fields].each do |field|
|
|
189
|
+
puts " - #{field}"
|
|
190
|
+
end
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
if summary[:unique_extra_fields].any?
|
|
194
|
+
puts "\n" + "+" * 60
|
|
195
|
+
puts "EXTRA FIELDS (in model but not in JSON-LD):"
|
|
196
|
+
puts "+" * 60
|
|
197
|
+
summary[:unique_extra_fields].each do |field|
|
|
198
|
+
puts " + #{field}"
|
|
199
|
+
end
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
puts "\n" + "=" * 60
|
|
203
|
+
puts "COVERAGE BY FILE:"
|
|
204
|
+
puts "=" * 60
|
|
205
|
+
|
|
206
|
+
@results.each do |file, result|
|
|
207
|
+
status = result[:coverage] >= 100 ? "✓" : "✗"
|
|
208
|
+
puts "#{status} #{File.basename(file)}: #{result[:coverage].round(2)}%"
|
|
209
|
+
end
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
private
|
|
213
|
+
|
|
214
|
+
# Extract ALL keys from JSON-LD data (recursive)
|
|
215
|
+
def extract_all_keys(data, prefix = nil)
|
|
216
|
+
keys = Set.new
|
|
217
|
+
|
|
218
|
+
case data
|
|
219
|
+
when Hash
|
|
220
|
+
data.each do |key, value|
|
|
221
|
+
full_key = prefix ? "#{prefix}.#{key}" : key.to_s
|
|
222
|
+
keys << full_key
|
|
223
|
+
|
|
224
|
+
# Recurse into nested structures
|
|
225
|
+
case value
|
|
226
|
+
when Hash
|
|
227
|
+
keys.merge(extract_all_keys(value, full_key))
|
|
228
|
+
when Array
|
|
229
|
+
value.each do |item|
|
|
230
|
+
keys.merge(extract_all_keys(item, full_key)) if item.is_a?(Hash)
|
|
231
|
+
end
|
|
232
|
+
end
|
|
233
|
+
end
|
|
234
|
+
when Array
|
|
235
|
+
data.each do |item|
|
|
236
|
+
keys.merge(extract_all_keys(item, prefix)) if item.is_a?(Hash)
|
|
237
|
+
end
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
keys
|
|
241
|
+
end
|
|
242
|
+
|
|
243
|
+
# Expected fields from our models
|
|
244
|
+
def model_expected_fields
|
|
245
|
+
# This matches the fields in our Lutaml::Model classes
|
|
246
|
+
%i[
|
|
247
|
+
id type monograph_id pref_label alt_label definition publisher
|
|
248
|
+
status belongs_to_edition effective_date version references_preparation
|
|
249
|
+
references_tcm_profile references_ayurveda_profile references_western_profile
|
|
250
|
+
references_plant_species test_specifications same_substance_as
|
|
251
|
+
has_equivalent_in similar_to storage_container storage_conditions
|
|
252
|
+
botanical_source macroscopic_description microscopic_description
|
|
253
|
+
molecular_formula molecular_weight cas_number systematic_name
|
|
254
|
+
inchi inchi_key smiles appearance solubility chemical_structure
|
|
255
|
+
test_name test_type harmonized_method publisher_method test_conditions
|
|
256
|
+
limit_type limit_value lower_limit upper_limit limit_unit
|
|
257
|
+
assay_target assay_expression impurity_type physical_property
|
|
258
|
+
color shape texture odor taste fracture size
|
|
259
|
+
transverse_section powder_characteristics diagnostic_features
|
|
260
|
+
references_plant_part collection_period
|
|
261
|
+
]
|
|
262
|
+
end
|
|
263
|
+
end
|
|
264
|
+
end
|
|
265
|
+
end
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Openphar
|
|
4
|
+
module Exporters
|
|
5
|
+
module Neo4j
|
|
6
|
+
# Registry of all models that can be exported to Neo4j
|
|
7
|
+
#
|
|
8
|
+
# Provides mapping from JSON-LD @type to:
|
|
9
|
+
# - Neo4j label(s)
|
|
10
|
+
# - Ruby model class
|
|
11
|
+
# - Serialization key mappings
|
|
12
|
+
#
|
|
13
|
+
# This registry is the central source of truth for model-to-Neo4j mapping.
|
|
14
|
+
class ModelRegistry
|
|
15
|
+
# Node type mappings: JSON-LD @type => Neo4j labels
|
|
16
|
+
NODE_TYPES = {
|
|
17
|
+
'PharmacopoeiaMonograph' => 'Monograph',
|
|
18
|
+
'CrudeDrugMonograph' => 'Monograph:CrudeDrug',
|
|
19
|
+
'ChemicalDrugMonograph' => 'Monograph:ChemicalDrug',
|
|
20
|
+
'FormulationMonograph' => 'Monograph:Formulation',
|
|
21
|
+
'VitaminMonograph' => 'Monograph:Vitamin',
|
|
22
|
+
'AminoAcidMonograph' => 'Monograph:AminoAcid',
|
|
23
|
+
'MineralSubstanceMonograph' => 'Monograph:Mineral',
|
|
24
|
+
'BiologicalSubstanceMonograph' => 'Monograph:Biological',
|
|
25
|
+
# JP-specific types
|
|
26
|
+
'KampoFormula' => 'Monograph:Formulation:Kampo',
|
|
27
|
+
# Ph.Int.-specific types
|
|
28
|
+
'RadiopharmaceuticalMonograph' => 'Monograph:Radiopharmaceutical',
|
|
29
|
+
'DosageFormMonograph' => 'Monograph:DosageForm',
|
|
30
|
+
'TestMethod' => 'TestMethod',
|
|
31
|
+
'Reagent' => 'Reagent',
|
|
32
|
+
'TestSolution' => 'Reagent:TestSolution',
|
|
33
|
+
'VolumetricSolution' => 'Reagent:VolumetricSolution',
|
|
34
|
+
'BufferSolution' => 'Reagent:BufferSolution',
|
|
35
|
+
'ReferenceSubstance' => 'ReferenceSubstance',
|
|
36
|
+
# Test specifications
|
|
37
|
+
'TestSpecification' => 'TestSpec',
|
|
38
|
+
'AssaySpecification' => 'TestSpec:Assay',
|
|
39
|
+
'PuritySpecification' => 'TestSpec:Purity',
|
|
40
|
+
'IdentificationSpecification' => 'TestSpec:Identity',
|
|
41
|
+
'PhysicalSpecification' => 'TestSpec:Physical',
|
|
42
|
+
# Other
|
|
43
|
+
'Limit' => 'Limit',
|
|
44
|
+
'Edition' => 'Edition',
|
|
45
|
+
'Supplement' => 'Supplement',
|
|
46
|
+
'Publisher' => 'Publisher'
|
|
47
|
+
}.freeze
|
|
48
|
+
|
|
49
|
+
# Relationship types
|
|
50
|
+
RELATIONSHIP_TYPES = {
|
|
51
|
+
'sameSubstanceAs' => 'SAME_SUBSTANCE_AS',
|
|
52
|
+
'hasEquivalentIn' => 'HAS_EQUIVALENT_IN',
|
|
53
|
+
'similarTo' => 'SIMILAR_TO',
|
|
54
|
+
'belongsToEdition' => 'BELONGS_TO_EDITION',
|
|
55
|
+
'hasTestSpec' => 'HAS_TEST_SPEC',
|
|
56
|
+
'referencesPreparation' => 'REFERENCES_PREPARATION',
|
|
57
|
+
'referencesTCMProfile' => 'REFERENCES_TCM_PROFILE'
|
|
58
|
+
}.freeze
|
|
59
|
+
|
|
60
|
+
# Returns Neo4j labels for a given JSON-LD @type
|
|
61
|
+
#
|
|
62
|
+
# @param jsonld_type [String] The @type from JSON-LD
|
|
63
|
+
# @return [String] Neo4j label(s)
|
|
64
|
+
def node_type_for(jsonld_type)
|
|
65
|
+
NODE_TYPES[jsonld_type] || 'Monograph'
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# Returns the Ruby model class for a given JSON-LD @type
|
|
69
|
+
#
|
|
70
|
+
# @param jsonld_type [String] The @type from JSON-LD
|
|
71
|
+
# @return [Class, nil] The model class
|
|
72
|
+
def model_class_for(jsonld_type)
|
|
73
|
+
entry = MODEL_CLASSES[jsonld_type]
|
|
74
|
+
entry&.call
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# Returns the Neo4j relationship type for a given link type
|
|
78
|
+
#
|
|
79
|
+
# @param link_type [String] The relationship type from JSON-LD
|
|
80
|
+
# @return [String] Neo4j relationship type
|
|
81
|
+
def relationship_type_for(link_type)
|
|
82
|
+
RELATIONSHIP_TYPES[link_type] || 'RELATED_TO'
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# Returns all known node types
|
|
86
|
+
#
|
|
87
|
+
# @return [Array<String>] List of Neo4j labels
|
|
88
|
+
def all_node_types
|
|
89
|
+
NODE_TYPES.values.flat_map { |t| t.split(':') }.uniq
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# Checks if a given type is a monograph type
|
|
93
|
+
#
|
|
94
|
+
# @param jsonld_type [String] The @type from JSON-LD
|
|
95
|
+
# @return [Boolean] True if it's a monograph type
|
|
96
|
+
def monograph_type?(jsonld_type)
|
|
97
|
+
NODE_TYPES[jsonld_type]&.include?('Monograph')
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# Model classes indexed by JSON-LD @type
|
|
101
|
+
# Uses lazy loading to avoid circular dependency issues
|
|
102
|
+
MODEL_CLASSES = {
|
|
103
|
+
# Core monograph types
|
|
104
|
+
'CrudeDrugMonograph' => lambda { Openphar::Models::CrudeDrugMonograph },
|
|
105
|
+
'ChemicalDrugMonograph' => lambda { Openphar::Models::ChemicalDrugMonograph },
|
|
106
|
+
'Monograph' => lambda { Openphar::Models::Monograph },
|
|
107
|
+
# Bibliographic
|
|
108
|
+
'Edition' => lambda { Openphar::Models::Edition },
|
|
109
|
+
'Supplement' => lambda { Openphar::Models::Supplement },
|
|
110
|
+
# Test specifications
|
|
111
|
+
'TestSpecification' => lambda { Openphar::Models::TestSpecification },
|
|
112
|
+
'AssaySpecification' => lambda { Openphar::Models::AssaySpecification },
|
|
113
|
+
'PuritySpecification' => lambda { Openphar::Models::PuritySpecification },
|
|
114
|
+
'PhysicalSpecification' => lambda { Openphar::Models::PhysicalSpecification },
|
|
115
|
+
# JP models (in JP namespace)
|
|
116
|
+
'KampoFormula' => lambda { Openphar::Models::JP::KampoFormula },
|
|
117
|
+
# Ph.Int. models (in PhInt namespace)
|
|
118
|
+
'RadiopharmaceuticalMonograph' => lambda { Openphar::Models::PhInt::RadiopharmaceuticalMonograph },
|
|
119
|
+
'DosageFormMonograph' => lambda { Openphar::Models::PhInt::DosageFormMonograph },
|
|
120
|
+
'TestMethod' => lambda { Openphar::Models::PhInt::TestMethod },
|
|
121
|
+
'Reagent' => lambda { Openphar::Models::PhInt::Reagent },
|
|
122
|
+
'TestSolution' => lambda { Openphar::Models::PhInt::TestSolution },
|
|
123
|
+
'VolumetricSolution' => lambda { Openphar::Models::PhInt::VolumetricSolution },
|
|
124
|
+
'BufferSolution' => lambda { Openphar::Models::PhInt::BufferSolution },
|
|
125
|
+
'ReferenceSubstance' => lambda { Openphar::Models::PhInt::ReferenceSubstance }
|
|
126
|
+
}.freeze
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
end
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Openphar
|
|
4
|
+
module Exporters
|
|
5
|
+
module Neo4j
|
|
6
|
+
# Builds Neo4j Cypher nodes from model data
|
|
7
|
+
#
|
|
8
|
+
# Uses the property mapper to convert JSON-LD properties to Neo4j format,
|
|
9
|
+
# and generates either CREATE or MERGE statements based on the use_merge flag.
|
|
10
|
+
class NodeBuilder
|
|
11
|
+
attr_reader :registry, :mapper
|
|
12
|
+
|
|
13
|
+
def initialize(mapper = nil)
|
|
14
|
+
@mapper = mapper || PropertyMapper.new
|
|
15
|
+
@registry = @mapper.instance_variable_get(:@registry) || ModelRegistry.new
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# Builds a Cypher CREATE/MERGE statement for a single node
|
|
19
|
+
#
|
|
20
|
+
# @param data [Hash] The JSON-LD data as a Ruby hash
|
|
21
|
+
# @param use_merge [Boolean] Use MERGE instead of CREATE
|
|
22
|
+
# @return [String] Cypher statement
|
|
23
|
+
def build(data, use_merge: false)
|
|
24
|
+
node_type = registry.node_type_for(data['@type'] || data['type'])
|
|
25
|
+
properties = mapper.map_properties(data)
|
|
26
|
+
node_id = generate_node_id(node_type, properties[:id])
|
|
27
|
+
|
|
28
|
+
if use_merge
|
|
29
|
+
build_merge_statement(node_id, node_type, properties)
|
|
30
|
+
else
|
|
31
|
+
build_create_statement(node_id, node_type, properties)
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Returns the property count for a given data hash
|
|
36
|
+
#
|
|
37
|
+
# @param data [Hash] The JSON-LD data
|
|
38
|
+
# @return [Integer] Number of properties
|
|
39
|
+
def property_count(data)
|
|
40
|
+
mapper.property_count(data)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
private
|
|
44
|
+
|
|
45
|
+
# Builds a CREATE statement (always creates new node)
|
|
46
|
+
def build_create_statement(node_id, node_type, properties)
|
|
47
|
+
props_str = format_properties(properties)
|
|
48
|
+
"CREATE (#{node_id}:#{node_type} {#{props_str}});"
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Builds a MERGE statement (idempotent - creates only if doesn't exist)
|
|
52
|
+
def build_merge_statement(node_id, node_type, properties)
|
|
53
|
+
props_str = format_properties(properties)
|
|
54
|
+
<<~CYPHER.chomp
|
|
55
|
+
MERGE (#{node_id}:#{node_type} {id: #{properties[:id]}})
|
|
56
|
+
SET #{node_id} += {#{props_str}};
|
|
57
|
+
CYPHER
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Formats properties for Cypher
|
|
61
|
+
def format_properties(properties)
|
|
62
|
+
properties.map do |k, v|
|
|
63
|
+
"#{k}: #{v}"
|
|
64
|
+
end.join(', ')
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# Generates a Neo4j-compatible node identifier
|
|
68
|
+
#
|
|
69
|
+
# @param node_type [String] The Neo4j label
|
|
70
|
+
# @param id [String] The unique identifier
|
|
71
|
+
# @return [String] Neo4j node identifier
|
|
72
|
+
def generate_node_id(node_type, id)
|
|
73
|
+
return "n_#{node_type.downcase}" unless id
|
|
74
|
+
|
|
75
|
+
# Create Neo4j-safe ID from the @id value
|
|
76
|
+
id.split('/').last
|
|
77
|
+
.gsub(/[^a-zA-Z0-9]/, '_')
|
|
78
|
+
.downcase
|
|
79
|
+
.then { |s| s.empty? ? "n_#{node_type.downcase}" : "#{s}_" }
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Openphar
|
|
4
|
+
module Exporters
|
|
5
|
+
module Neo4j
|
|
6
|
+
# Maps model attributes to Neo4j-compatible property names
|
|
7
|
+
#
|
|
8
|
+
# Neo4j doesn't allow dots in property names, so this mapper
|
|
9
|
+
# converts camelCase JSON-LD keys to snake_case Neo4j properties.
|
|
10
|
+
#
|
|
11
|
+
# Uses the model registry to understand the structure of each model type.
|
|
12
|
+
class PropertyMapper
|
|
13
|
+
# Property name mapping: JSON-LD key => Neo4j property key
|
|
14
|
+
# Neo4j doesn't allow dots in property names, so we flatten
|
|
15
|
+
PROPERTY_MAPPING = {
|
|
16
|
+
'@id' => :id,
|
|
17
|
+
'@type' => :type,
|
|
18
|
+
'monographId' => :monograph_id,
|
|
19
|
+
'prefLabel' => :pref_label,
|
|
20
|
+
'altLabel' => :alt_label,
|
|
21
|
+
'definition' => :definition,
|
|
22
|
+
'publisher' => :publisher,
|
|
23
|
+
'status' => :status,
|
|
24
|
+
'belongsToEdition' => :edition_id,
|
|
25
|
+
'effectiveDate' => :effective_date,
|
|
26
|
+
'version' => :version,
|
|
27
|
+
'referencesPreparation' => :prep_id,
|
|
28
|
+
'referencesTCMProfile' => :tcm_id,
|
|
29
|
+
'referencesAyurvedaProfile' => :ayurveda_id,
|
|
30
|
+
'referencesWesternProfile' => :western_id,
|
|
31
|
+
'referencesPlantSpecies' => :plant_id,
|
|
32
|
+
'sameSubstanceAs' => :same_as,
|
|
33
|
+
'hasEquivalentIn' => :equiv_in,
|
|
34
|
+
'similarTo' => :similar_to,
|
|
35
|
+
'testSpecification' => :test_specs,
|
|
36
|
+
'storageContainer' => :storage_container,
|
|
37
|
+
'storageConditions' => :storage_conds,
|
|
38
|
+
'botanicalSource' => :botanical_source,
|
|
39
|
+
'macroscopicDescription' => :macro_desc,
|
|
40
|
+
'microscopicDescription' => :micro_desc,
|
|
41
|
+
'molecularFormula' => :mol_formula,
|
|
42
|
+
'molecularWeight' => :mol_weight,
|
|
43
|
+
'casNumber' => :cas_number,
|
|
44
|
+
'testName' => :test_name,
|
|
45
|
+
'testType' => :test_type,
|
|
46
|
+
'harmonizedMethod' => :harm_method,
|
|
47
|
+
'testConditions' => :test_conds,
|
|
48
|
+
'limitType' => :limit_type,
|
|
49
|
+
'limitValue' => :limit_value,
|
|
50
|
+
'lowerLimit' => :lower_limit,
|
|
51
|
+
'upperLimit' => :upper_limit,
|
|
52
|
+
'limitUnit' => :limit_unit,
|
|
53
|
+
'appearance' => :appearance,
|
|
54
|
+
'identification' => :identification,
|
|
55
|
+
'purity' => :purity,
|
|
56
|
+
'assay' => :assay,
|
|
57
|
+
'editionNumber' => :edition_number,
|
|
58
|
+
'publicationDate' => :publication_date,
|
|
59
|
+
'monographCount' => :monograph_count,
|
|
60
|
+
'officialLanguage' => :official_languages,
|
|
61
|
+
'supplementNumber' => :supplement_number,
|
|
62
|
+
# Ph.Int.-specific
|
|
63
|
+
'systematicName' => :systematic_name,
|
|
64
|
+
'inchi' => :inchi,
|
|
65
|
+
'inchiKey' => :inchi_key,
|
|
66
|
+
'smiles' => :smiles,
|
|
67
|
+
'hasRadionuclide' => :radionuclide,
|
|
68
|
+
'halfLife' => :half_life,
|
|
69
|
+
'emissionType' => :emission_type,
|
|
70
|
+
'radiochemicalPurity' => :radiochem_purity,
|
|
71
|
+
'radionuclidicPurity' => :radionucl_purity,
|
|
72
|
+
'photonFraction' => :photon_fraction,
|
|
73
|
+
'solubility' => :solubility,
|
|
74
|
+
'category' => :category,
|
|
75
|
+
'chemicalName' => :chemical_name
|
|
76
|
+
}.freeze
|
|
77
|
+
|
|
78
|
+
def initialize(registry = nil)
|
|
79
|
+
@registry = registry || ModelRegistry.new
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# Maps a JSON-LD key to a Neo4j-compatible property key
|
|
83
|
+
#
|
|
84
|
+
# @param jsonld_key [String] The key from JSON-LD data
|
|
85
|
+
# @return [Symbol] Neo4j-compatible property key
|
|
86
|
+
def map_property(jsonld_key)
|
|
87
|
+
PROPERTY_MAPPING[jsonld_key] || default_mapping(jsonld_key)
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# Maps all properties from JSON-LD data to Neo4j format
|
|
91
|
+
#
|
|
92
|
+
# @param data [Hash] The JSON-LD data as a Ruby hash
|
|
93
|
+
# @return [Hash] Mapped properties with Neo4j-compatible keys
|
|
94
|
+
def map_properties(data)
|
|
95
|
+
props = {}
|
|
96
|
+
|
|
97
|
+
data.each do |key, value|
|
|
98
|
+
neo4j_key = map_property(key)
|
|
99
|
+
props[neo4j_key] = convert_value(value)
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
props
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# Converts a value to Neo4j-compatible format
|
|
106
|
+
#
|
|
107
|
+
# @param value [any] The value to convert
|
|
108
|
+
# @return [String] Neo4j-compatible value representation
|
|
109
|
+
def convert_value(value)
|
|
110
|
+
case value
|
|
111
|
+
when Hash then value.to_json
|
|
112
|
+
when Array then value.to_json
|
|
113
|
+
when String then escape_string(value)
|
|
114
|
+
when nil then 'null'
|
|
115
|
+
when Numeric then value.to_s
|
|
116
|
+
else value.to_s
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
# Returns the property count for analytics
|
|
121
|
+
#
|
|
122
|
+
# @param data [Hash] The JSON-LD data
|
|
123
|
+
# @return [Integer] Number of properties
|
|
124
|
+
def property_count(data)
|
|
125
|
+
data.keys.size
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
private
|
|
129
|
+
|
|
130
|
+
# Default mapping for unknown keys: camelCase to snake_case
|
|
131
|
+
def default_mapping(key)
|
|
132
|
+
key.gsub(/([^A-Z])([A-Z])/, '\1_\2')
|
|
133
|
+
.gsub(/[^a-zA-Z0-9]/, '_')
|
|
134
|
+
.downcase
|
|
135
|
+
.to_sym
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
# Escapes a string for Neo4j Cypher
|
|
139
|
+
def escape_string(str)
|
|
140
|
+
return 'null' if str.nil?
|
|
141
|
+
return str.to_s if str.is_a?(Numeric)
|
|
142
|
+
|
|
143
|
+
escaped = str.to_s
|
|
144
|
+
.gsub('\\', '\\\\')
|
|
145
|
+
.gsub("'", "\\'")
|
|
146
|
+
.gsub("\n", '\\n')
|
|
147
|
+
.gsub("\r", '\\r')
|
|
148
|
+
|
|
149
|
+
"'#{escaped}'"
|
|
150
|
+
end
|
|
151
|
+
end
|
|
152
|
+
end
|
|
153
|
+
end
|
|
154
|
+
end
|