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.
Files changed (108) hide show
  1. checksums.yaml +7 -0
  2. data/README.adoc +221 -0
  3. data/lib/openphar/cli/main.rb +137 -0
  4. data/lib/openphar/cli.rb +11 -0
  5. data/lib/openphar/core/slug_generator.rb +107 -0
  6. data/lib/openphar/core.rb +11 -0
  7. data/lib/openphar/errors/configuration_error.rb +8 -0
  8. data/lib/openphar/errors/monograph_not_found_error.rb +15 -0
  9. data/lib/openphar/errors/parse_error.rb +18 -0
  10. data/lib/openphar/errors/unknown_format_error.rb +8 -0
  11. data/lib/openphar/errors/unknown_publisher_error.rb +8 -0
  12. data/lib/openphar/errors/unknown_type_error.rb +8 -0
  13. data/lib/openphar/errors/validation_error.rb +15 -0
  14. data/lib/openphar/errors.rb +17 -0
  15. data/lib/openphar/exporters/coverage_validator.rb +265 -0
  16. data/lib/openphar/exporters/neo4j/model_registry.rb +130 -0
  17. data/lib/openphar/exporters/neo4j/node_builder.rb +84 -0
  18. data/lib/openphar/exporters/neo4j/property_mapper.rb +154 -0
  19. data/lib/openphar/exporters/neo4j/relationship_builder.rb +87 -0
  20. data/lib/openphar/exporters/neo4j.rb +16 -0
  21. data/lib/openphar/exporters/neo4j_exporter.rb +199 -0
  22. data/lib/openphar/exporters.rb +15 -0
  23. data/lib/openphar/linkers/chp/cross_edition_linker.rb +101 -0
  24. data/lib/openphar/linkers/chp.rb +11 -0
  25. data/lib/openphar/linkers/cross_publisher_linker.rb +295 -0
  26. data/lib/openphar/linkers/herbapedia_linker.rb +125 -0
  27. data/lib/openphar/linkers.rb +13 -0
  28. data/lib/openphar/migrators/chp/classifier.rb +86 -0
  29. data/lib/openphar/migrators/chp.rb +15 -0
  30. data/lib/openphar/migrators/chp_migrator.rb +130 -0
  31. data/lib/openphar/migrators/jp_migrator.rb +345 -0
  32. data/lib/openphar/migrators/phint_migrator.rb +426 -0
  33. data/lib/openphar/migrators.rb +15 -0
  34. data/lib/openphar/models/ahp.rb +25 -0
  35. data/lib/openphar/models/amino_acid_monograph.rb +28 -0
  36. data/lib/openphar/models/api.rb +29 -0
  37. data/lib/openphar/models/assay_specification.rb +19 -0
  38. data/lib/openphar/models/base_entity.rb +221 -0
  39. data/lib/openphar/models/biological_substance_monograph.rb +31 -0
  40. data/lib/openphar/models/chemical_drug_monograph.rb +39 -0
  41. data/lib/openphar/models/chp/biologic.rb +28 -0
  42. data/lib/openphar/models/chp/chemical_preparation.rb +28 -0
  43. data/lib/openphar/models/chp/chemical_substance.rb +28 -0
  44. data/lib/openphar/models/chp/general_chapter.rb +30 -0
  45. data/lib/openphar/models/chp/monograph.rb +116 -0
  46. data/lib/openphar/models/chp/section.rb +37 -0
  47. data/lib/openphar/models/chp/tcm_crude_drug.rb +28 -0
  48. data/lib/openphar/models/chp/tcm_extract.rb +29 -0
  49. data/lib/openphar/models/chp/tcm_formulation.rb +28 -0
  50. data/lib/openphar/models/chp.rb +33 -0
  51. data/lib/openphar/models/crude_drug_monograph.rb +31 -0
  52. data/lib/openphar/models/edition.rb +41 -0
  53. data/lib/openphar/models/formulation_monograph.rb +31 -0
  54. data/lib/openphar/models/hkcmms.rb +26 -0
  55. data/lib/openphar/models/identification_specification.rb +19 -0
  56. data/lib/openphar/models/jp/kampo_formula.rb +94 -0
  57. data/lib/openphar/models/jp.rb +21 -0
  58. data/lib/openphar/models/limit.rb +54 -0
  59. data/lib/openphar/models/mineral_substance_monograph.rb +28 -0
  60. data/lib/openphar/models/monograph.rb +58 -0
  61. data/lib/openphar/models/ph_int/buffer_solution.rb +38 -0
  62. data/lib/openphar/models/ph_int/dosage_form_monograph.rb +43 -0
  63. data/lib/openphar/models/ph_int/radiopharmaceutical_monograph.rb +52 -0
  64. data/lib/openphar/models/ph_int/reagent.rb +50 -0
  65. data/lib/openphar/models/ph_int/reference_substance.rb +50 -0
  66. data/lib/openphar/models/ph_int/test_method.rb +50 -0
  67. data/lib/openphar/models/ph_int/test_solution.rb +39 -0
  68. data/lib/openphar/models/ph_int/volumetric_solution.rb +38 -0
  69. data/lib/openphar/models/ph_int.rb +37 -0
  70. data/lib/openphar/models/physical_specification.rb +17 -0
  71. data/lib/openphar/models/publisher.rb +98 -0
  72. data/lib/openphar/models/purity_specification.rb +16 -0
  73. data/lib/openphar/models/supplement.rb +31 -0
  74. data/lib/openphar/models/test_specification.rb +29 -0
  75. data/lib/openphar/models/thp.rb +24 -0
  76. data/lib/openphar/models/vitamin_monograph.rb +28 -0
  77. data/lib/openphar/models.rb +58 -0
  78. data/lib/openphar/monograph_merger.rb +374 -0
  79. data/lib/openphar/parsers/base_monograph_parser.rb +108 -0
  80. data/lib/openphar/parsers/jp_html_parser.rb +92 -0
  81. data/lib/openphar/parsers/jp_html_parser_base.rb +113 -0
  82. data/lib/openphar/parsers/jp_ja_html_parser.rb +220 -0
  83. data/lib/openphar/parsers/phint_json_parser.rb +332 -0
  84. data/lib/openphar/parsers.rb +21 -0
  85. data/lib/openphar/registry/publisher_registry.rb +46 -0
  86. data/lib/openphar/registry/type_registry.rb +147 -0
  87. data/lib/openphar/registry.rb +24 -0
  88. data/lib/openphar/repositories/monograph_repository.rb +350 -0
  89. data/lib/openphar/repositories.rb +11 -0
  90. data/lib/openphar/transformers/jsonld_transformer.rb +106 -0
  91. data/lib/openphar/transformers.rb +11 -0
  92. data/lib/openphar/version.rb +5 -0
  93. data/lib/openphar.rb +119 -0
  94. data/ontology/bibliographic/edition.ttl +267 -0
  95. data/ontology/context/chp.jsonld +69 -0
  96. data/ontology/context/crude-drugs.jsonld +56 -0
  97. data/ontology/context/pharmacopoeia.jsonld +631 -0
  98. data/ontology/core/identification.ttl +456 -0
  99. data/ontology/core/pharmacopoeia.ttl +3427 -0
  100. data/ontology/core/substance-form.ttl +302 -0
  101. data/ontology/core/unit.ttl +493 -0
  102. data/ontology/publisher/chp/extensions.ttl +186 -0
  103. data/ontology/publisher/phint/extensions.ttl +327 -0
  104. data/ontology/quality/test-method.ttl +610 -0
  105. data/shapes/chp/monograph.ttl +153 -0
  106. data/shapes/edition-shapes.ttl +230 -0
  107. data/shapes/monograph-shapes.ttl +1324 -0
  108. 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