quby-compiler 0.2.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 (107) hide show
  1. checksums.yaml +7 -0
  2. data/.gitignore +13 -0
  3. data/.gitlab-ci.yml +5 -0
  4. data/.rspec +3 -0
  5. data/.ruby-version +1 -0
  6. data/CODE_OF_CONDUCT.md +74 -0
  7. data/Dockerfile +11 -0
  8. data/Gemfile +8 -0
  9. data/Gemfile.lock +133 -0
  10. data/LICENSE.txt +21 -0
  11. data/README.md +44 -0
  12. data/Rakefile +6 -0
  13. data/bin/console +14 -0
  14. data/bin/rspec +29 -0
  15. data/bin/setup +8 -0
  16. data/config/locales/de.yml +58 -0
  17. data/config/locales/en.yml +57 -0
  18. data/config/locales/nl.yml +57 -0
  19. data/config/locales/rails-i18n/README.md +4 -0
  20. data/config/locales/rails-i18n/de.yml +223 -0
  21. data/config/locales/rails-i18n/en.yml +216 -0
  22. data/config/locales/rails-i18n/nl.yml +214 -0
  23. data/exe/quby-compile +56 -0
  24. data/lib/quby/array_attribute_valid_validator.rb +15 -0
  25. data/lib/quby/attribute_valid_validator.rb +14 -0
  26. data/lib/quby/compiler.rb +50 -0
  27. data/lib/quby/compiler/dsl.rb +29 -0
  28. data/lib/quby/compiler/dsl/base.rb +20 -0
  29. data/lib/quby/compiler/dsl/calls_custom_methods.rb +29 -0
  30. data/lib/quby/compiler/dsl/charting/bar_chart_builder.rb +14 -0
  31. data/lib/quby/compiler/dsl/charting/chart_builder.rb +95 -0
  32. data/lib/quby/compiler/dsl/charting/line_chart_builder.rb +34 -0
  33. data/lib/quby/compiler/dsl/charting/overview_chart_builder.rb +31 -0
  34. data/lib/quby/compiler/dsl/charting/radar_chart_builder.rb +14 -0
  35. data/lib/quby/compiler/dsl/helpers.rb +53 -0
  36. data/lib/quby/compiler/dsl/panel_builder.rb +80 -0
  37. data/lib/quby/compiler/dsl/question_builder.rb +40 -0
  38. data/lib/quby/compiler/dsl/questionnaire_builder.rb +279 -0
  39. data/lib/quby/compiler/dsl/questions/base.rb +180 -0
  40. data/lib/quby/compiler/dsl/questions/checkbox_question_builder.rb +20 -0
  41. data/lib/quby/compiler/dsl/questions/date_question_builder.rb +18 -0
  42. data/lib/quby/compiler/dsl/questions/deprecated_question_builder.rb +18 -0
  43. data/lib/quby/compiler/dsl/questions/float_question_builder.rb +21 -0
  44. data/lib/quby/compiler/dsl/questions/integer_question_builder.rb +21 -0
  45. data/lib/quby/compiler/dsl/questions/radio_question_builder.rb +20 -0
  46. data/lib/quby/compiler/dsl/questions/select_question_builder.rb +18 -0
  47. data/lib/quby/compiler/dsl/questions/string_question_builder.rb +20 -0
  48. data/lib/quby/compiler/dsl/questions/text_question_builder.rb +22 -0
  49. data/lib/quby/compiler/dsl/score_builder.rb +22 -0
  50. data/lib/quby/compiler/dsl/score_schema_builder.rb +53 -0
  51. data/lib/quby/compiler/dsl/standardized_panel_generators.rb +33 -0
  52. data/lib/quby/compiler/dsl/table_builder.rb +48 -0
  53. data/lib/quby/compiler/entities.rb +38 -0
  54. data/lib/quby/compiler/entities/charting/bar_chart.rb +17 -0
  55. data/lib/quby/compiler/entities/charting/chart.rb +101 -0
  56. data/lib/quby/compiler/entities/charting/charts.rb +42 -0
  57. data/lib/quby/compiler/entities/charting/line_chart.rb +38 -0
  58. data/lib/quby/compiler/entities/charting/overview_chart.rb +20 -0
  59. data/lib/quby/compiler/entities/charting/plottable.rb +20 -0
  60. data/lib/quby/compiler/entities/charting/radar_chart.rb +17 -0
  61. data/lib/quby/compiler/entities/definition.rb +26 -0
  62. data/lib/quby/compiler/entities/fields.rb +119 -0
  63. data/lib/quby/compiler/entities/flag.rb +55 -0
  64. data/lib/quby/compiler/entities/item.rb +40 -0
  65. data/lib/quby/compiler/entities/lookup_tables.rb +71 -0
  66. data/lib/quby/compiler/entities/outcome_table.rb +31 -0
  67. data/lib/quby/compiler/entities/panel.rb +82 -0
  68. data/lib/quby/compiler/entities/question.rb +365 -0
  69. data/lib/quby/compiler/entities/question_option.rb +96 -0
  70. data/lib/quby/compiler/entities/questionnaire.rb +440 -0
  71. data/lib/quby/compiler/entities/questions/checkbox_question.rb +82 -0
  72. data/lib/quby/compiler/entities/questions/date_question.rb +84 -0
  73. data/lib/quby/compiler/entities/questions/deprecated_question.rb +19 -0
  74. data/lib/quby/compiler/entities/questions/float_question.rb +15 -0
  75. data/lib/quby/compiler/entities/questions/integer_question.rb +15 -0
  76. data/lib/quby/compiler/entities/questions/radio_question.rb +19 -0
  77. data/lib/quby/compiler/entities/questions/select_question.rb +19 -0
  78. data/lib/quby/compiler/entities/questions/string_question.rb +15 -0
  79. data/lib/quby/compiler/entities/questions/text_question.rb +15 -0
  80. data/lib/quby/compiler/entities/score_calculation.rb +35 -0
  81. data/lib/quby/compiler/entities/score_schema.rb +25 -0
  82. data/lib/quby/compiler/entities/subscore_schema.rb +23 -0
  83. data/lib/quby/compiler/entities/table.rb +143 -0
  84. data/lib/quby/compiler/entities/text.rb +71 -0
  85. data/lib/quby/compiler/entities/textvar.rb +23 -0
  86. data/lib/quby/compiler/entities/validation.rb +17 -0
  87. data/lib/quby/compiler/entities/version.rb +23 -0
  88. data/lib/quby/compiler/entities/visibility_rule.rb +71 -0
  89. data/lib/quby/compiler/instance.rb +72 -0
  90. data/lib/quby/compiler/output.rb +13 -0
  91. data/lib/quby/compiler/outputs.rb +4 -0
  92. data/lib/quby/compiler/outputs/quby_frontend_v1_serializer.rb +362 -0
  93. data/lib/quby/compiler/outputs/quby_frontend_v2_serializer.rb +15 -0
  94. data/lib/quby/compiler/outputs/roqua_serializer.rb +108 -0
  95. data/lib/quby/compiler/outputs/seed_serializer.rb +34 -0
  96. data/lib/quby/compiler/services/definition_validator.rb +330 -0
  97. data/lib/quby/compiler/services/quby_proxy.rb +405 -0
  98. data/lib/quby/compiler/services/seed_diff.rb +116 -0
  99. data/lib/quby/compiler/services/text_transformation.rb +30 -0
  100. data/lib/quby/compiler/version.rb +5 -0
  101. data/lib/quby/markdown_parser.rb +38 -0
  102. data/lib/quby/range_categories.rb +38 -0
  103. data/lib/quby/settings.rb +86 -0
  104. data/lib/quby/text_transformation.rb +26 -0
  105. data/lib/quby/type_validator.rb +12 -0
  106. data/quby-compiler.gemspec +39 -0
  107. metadata +277 -0
@@ -0,0 +1,405 @@
1
+ # rubocop:disable all
2
+ require "active_support/all"
3
+ require 'roqua/core_ext/enumerable/sort_by_alphanum'
4
+ require 'quby/compiler/services/seed_diff'
5
+
6
+ module Quby
7
+ module Compiler
8
+ module Services
9
+ class QubyProxy
10
+ HEADERS = { value: "Score",
11
+ interpretation: "Interpretatie",
12
+ clin_interp: "Klinisch",
13
+ norm: "Norm",
14
+ tscore: "T-Score",
15
+ dimensie: "Dimensie",
16
+ mean: "Gemiddelde" }
17
+
18
+ attr_reader :questionnaire, :options
19
+
20
+ def initialize(questionnaire, options)
21
+ @questionnaire = questionnaire
22
+ @options = options
23
+ end
24
+
25
+ def generate(seed)
26
+ question_titles = generate_question_titles
27
+ d_qtypes = {}
28
+ vars = []
29
+ @hidden_questions = {} # hash containing questions hidden by other questions
30
+
31
+ for question in questions_flat
32
+ if question.hidden && question.type != :check_box
33
+ d_qtypes[question.key.to_s] = { depends: :present } unless options[:without_depends]
34
+ end
35
+ unless question.hidden && (question.type == :check_box || question.type == :hidden)
36
+ vars << question.key.to_s
37
+ end
38
+
39
+ case question.type
40
+ when :radio, :scale
41
+ handle_scale(question, question_titles, d_qtypes, vars)
42
+ when :select
43
+ d_qtypes[question.key.to_s] = { type: :discrete }
44
+ for option in question.options
45
+ d_qtypes[question.key.to_s][option.value.to_s] = strip_p_tag(option.context_free_description || "") unless option.placeholder
46
+ end
47
+ update_hidden_questions_for(question)
48
+ when :check_box
49
+ d_qtypes[question.key.to_s] = { type: :check_box }
50
+ question.options.each do |option|
51
+ next if option.inner_title
52
+ vars << option.key.to_s
53
+ if question.hidden
54
+ question_titles[option.key.to_s] = strip_tags question.context_free_title
55
+ end
56
+ value = 1
57
+ option_type = { type: :discrete }
58
+ option_type[value.to_s] = (option.context_free_description || "")
59
+ option_type[:depends] = { values: [value, value.to_s].uniq, variable: option.key.to_s } unless options[:without_depends]
60
+ d_qtypes[option.key.to_s] = option_type
61
+ values = [value, value.to_s].uniq
62
+ handle_subquestions(question, question_titles, d_qtypes, vars, option, values, option.key.to_s)
63
+ end
64
+ update_hidden_questions_for(question, for_checkbox: true)
65
+ when :textarea
66
+ d_qtypes[question.key.to_s] = { type: :text_field }
67
+ when :string, :integer, :float
68
+ handle_textfield(question, d_qtypes)
69
+ when :date
70
+ d_qtypes[question.key.to_s] = question.components.each_with_object({ type: :date }) do |component, hash|
71
+ key = question.send("#{component}_key")
72
+ vars << key.to_s
73
+ hash[component] = key.to_s
74
+ end
75
+ when :hidden
76
+ if question.options.blank? # string
77
+ question_titles[question.key.to_s] = strip_tags question.context_free_title
78
+ vars << question.key.to_s unless vars.include? question.key.to_s
79
+ d_qtypes[question.key.to_s] = { type: :text }
80
+ d_qtypes[question.key.to_s][:depends] = :present unless options[:without_depends]
81
+ else
82
+ no_keys = true
83
+ values = []
84
+ question.options.each do |option|
85
+ if option.value # scale or radio
86
+ vars << question.key.to_s unless vars.include? question.key.to_s
87
+ next if option.inner_title
88
+ d_qtypes[question.key.to_s] ||= { type: :scale }
89
+ values << option.value.to_s
90
+ d_qtypes[question.key.to_s][option.value.to_s] = strip_p_tag(option.context_free_description || "")
91
+ # TODO: missing sub-questions
92
+ else # check_box
93
+ d_qtypes[question.key.to_s] ||= { type: :check_box }
94
+ no_keys = false
95
+ question_titles[option.key.to_s] = strip_tags question.context_free_title
96
+ vars << option.key.to_s
97
+ value = option.value || 1
98
+ option_type = { type: :discrete }
99
+ option_type[value.to_s] = (option.context_free_description || "")
100
+ option_type[:depends] = { values: [value, value.to_s].uniq, variable: option.key.to_s } unless options[:without_depends]
101
+ d_qtypes[option.key.to_s] = option_type
102
+ # TODO: missing sub-questions
103
+ end
104
+ end
105
+ if no_keys # scale or radio
106
+ d_qtypes[question.key.to_s][:depends] = { values: values, variable: question.key.to_s } unless options[:without_depends]
107
+ question_titles[question.key.to_s] = strip_tags question.context_free_title
108
+ end
109
+ end
110
+ else
111
+ fail "WARNING: Unimplemented type #{question.type}."
112
+ end
113
+
114
+ update_dqtypes_depends(d_qtypes, question, options)
115
+ end
116
+
117
+ seed["quests"] = sort_nested_hash(question_titles)
118
+ seed["d_qtypes"] = sort_nested_hash(d_qtypes)
119
+ seed["name"] = questionnaire.title
120
+ seed["short_description"] = questionnaire.short_description unless questionnaire.short_description.blank?
121
+ seed["description"] = questionnaire.description unless questionnaire.description.blank?
122
+
123
+ # this approach preserves the order of vars as much as possible, adding new vars to the end of the list
124
+ old_vars = (seed["vars"]&.split(",") || []).map(&:to_s)
125
+ new_vars = vars.map(&:to_s)
126
+ seed["vars"] = ((old_vars & new_vars) | new_vars).join(",")
127
+
128
+ scores = process_scores
129
+
130
+ seed["properties"] ||= {}
131
+ # headers outcome (humanized)
132
+ seed["properties"][:score_headers] = scores[:headers]
133
+ # headers data-export
134
+ seed["properties"][:score_keys] = scores[:keys]
135
+ # score names outcome (humanized)
136
+ seed["properties"][:score_labels] = scores[:labels]
137
+
138
+ seed["properties"].merge!(@options[:properties]) if @options.key?(:properties)
139
+ seed["properties"] = sort_nested_hash(seed["properties"])
140
+
141
+ data = {"key" => seed["key"] || options[:roqua_key] || questionnaire.key, "remote_id" => questionnaire.key}
142
+ attrs = %w(name vars quests d_qtypes properties short_description)
143
+ attrs.sort.each do |name|
144
+ data[name] = seed[name]
145
+ end
146
+
147
+ data
148
+ end
149
+
150
+ def update_hidden_questions_for(question, for_checkbox: false)
151
+ shows = question.options.each_with_object({}) do |option, shows|
152
+ next if option.inner_title
153
+ for key in option.shows_questions
154
+ skey = key.to_s
155
+ if for_checkbox
156
+ # is another checkbox option already showing the target question?
157
+ if shows.key?(skey)
158
+ # then set the target's depends on :present, since we cannot represent depending on multiple variables
159
+ shows[skey] = :present
160
+ else
161
+ shows[skey] = { values: ["1", 1], variable: option.key.to_s }
162
+ end
163
+ else
164
+ shows[skey] ||= { values: [], variable: question.key.to_s }
165
+ shows[skey][:values] |= [option.value.to_s, option.value]
166
+ end
167
+ end
168
+ end
169
+ for skey, show in shows
170
+ # if a different question is already showing the same question, we cannot register a dependency on both questions
171
+ # (the 'variable' attribute accepts only 1 key). Thus it is better to show the question based on presence of
172
+ # an answer instead of on the depended question's answers.
173
+ if @hidden_questions.has_key?(skey)
174
+ @hidden_questions[skey] = :present
175
+ else
176
+ @hidden_questions[skey] = show
177
+ end
178
+ end
179
+ end
180
+
181
+ def update_dqtypes_depends(d_qtypes, question, options)
182
+ if hidden = @hidden_questions[question.key.to_s]
183
+ d_qtypes[question.key.to_s][:depends] ||= hidden unless options[:without_depends]
184
+ end
185
+ end
186
+
187
+ def generate_question_titles
188
+ question_titles = {}
189
+
190
+ for question in questions_flat
191
+ unless question.hidden && (question.type == :check_box || question.type == :hidden)
192
+ title = question.context_free_title || question.description || ""
193
+ question_titles[question.key.to_s] = strip_tags(title)
194
+ end
195
+ end
196
+
197
+ question_titles
198
+ end
199
+
200
+ def questions_flat
201
+ @questions_flat ||= questionnaire.panels.map do |panel|
202
+ panel.items.select { |item| item.is_a? Quby::Compiler::Entities::Question }
203
+ end.flatten.compact
204
+ end
205
+
206
+ def handle_subquestions(question, quests, d_qtypes, vars, option, values, key)
207
+ option.questions.each do |quest|
208
+ if quest.presentation == :next_to_title && ![:string, :integer, :float].include?(quest.type)
209
+ fail "unsupported title question type"
210
+ end
211
+ case quest.type
212
+ when :string, :integer, :float
213
+ subquestion(question, quests, d_qtypes, vars, quest, values, key)
214
+ when :textarea
215
+ sub_textfield(question, quests, d_qtypes, vars, quest, values, key)
216
+ when :radio
217
+ sub_radio(question, quests, d_qtypes, vars, quest, values, key)
218
+ when :date
219
+ sub_date(question, quests, d_qtypes, vars, quest, values, key)
220
+ else
221
+ fail "Unimplemented type #{quest.type} for sub_question"
222
+ end
223
+ end
224
+ end
225
+
226
+ def subquestion(question, quests, d_qtypes, vars, quest, values, key)
227
+ d_qtypes[quest.key.to_s] = { type: :text }
228
+ unless options[:without_depends]
229
+ if quest.presentation == :next_to_title
230
+ # make title questons dependent on themselves so we don't have to dig into quby's depends relations
231
+ # which sometimes refer to some of the parent's options, but not always the correct ones
232
+ d_qtypes[quest.key.to_s][:depends] = :present
233
+ else
234
+ d_qtypes[quest.key.to_s][:depends] = { values: values, variable: key }
235
+ end
236
+ end
237
+ d_qtypes[quest.key.to_s][:label] = quest.unit unless quest.unit.blank?
238
+ quests[quest.key.to_s] = strip_tags(quest.context_free_title || "")
239
+ vars << quest.key.to_s
240
+ end
241
+
242
+ def sub_textfield(question, quests, d_qtypes, vars, quest, values, key)
243
+ d_qtypes[quest.key.to_s] = { type: :text_field }
244
+ d_qtypes[quest.key.to_s][:depends] = { values: values, variable: key } unless options[:without_depends]
245
+ quests[quest.key.to_s] = strip_tags(quest.context_free_title || "")
246
+ vars << quest.key.to_s
247
+ end
248
+
249
+ def sub_radio(question, quests, d_qtypes, vars, quest, values, key)
250
+ d_qtypes[quest.key.to_s] = { type: :scale }
251
+ d_qtypes[quest.key.to_s][:depends] = { values: values, variable: key } unless options[:without_depends]
252
+ quests[quest.key.to_s] = strip_tags(quest.context_free_title || "")
253
+ for option in quest.options
254
+ next if option.inner_title
255
+ d_qtypes[quest.key.to_s][option.value.to_s] = strip_p_tag(option.context_free_description || "")
256
+ end
257
+ vars << quest.key.to_s
258
+ update_hidden_questions_for(quest)
259
+ end
260
+
261
+ def sub_date(question, quests, d_qtypes, vars, quest, values, key)
262
+ d_qtypes[quest.key.to_s] = quest.components.each_with_object({ type: :date }) do |component, hash|
263
+ key = quest.send("#{component}_key")
264
+ vars << key
265
+ hash[component] = key.to_s
266
+ end
267
+ quests[quest.key.to_s] = strip_tags(quest.context_free_title || "")
268
+ end
269
+
270
+ def handle_scale(question, quests, d_qtypes, vars)
271
+ d_qtypes[question.key.to_s] = { type: :scale }
272
+ values = []
273
+ update_hidden_questions_for(question)
274
+ for option in question.options
275
+ next if option.inner_title
276
+ d_qtypes[question.key.to_s][option.value.to_s] = strip_p_tag(option.context_free_description || "")
277
+ values << option.value.to_s
278
+ key = question.key.to_s
279
+ handle_subquestions(question, quests, d_qtypes, vars, option, [option.value.to_s], key)
280
+ end
281
+ end
282
+
283
+ def handle_textfield(question, d_qtypes)
284
+ d_qtypes[question.key.to_s] = { type: :text }
285
+ d_qtypes[question.key.to_s][:label] = question.unit unless question.unit.blank?
286
+ end
287
+
288
+ def strip_p_tag(text)
289
+ text.gsub(/^<p>(.*)<\/p>\n?$/, "\\1")
290
+ end
291
+
292
+ def process_scores
293
+ scores_from_schemas
294
+ end
295
+
296
+ def scores_from_schemas
297
+ score_headers = [] # headers outcome (humanized name for subscores)
298
+ score_keys = [] # headers data-export (not all of it, just the score_subscore part, shortened)
299
+ score_labels = [] # score names outcome (humanized name for score as a whole)
300
+
301
+ questionnaire.score_schemas.values.each do |score_schema|
302
+ score_labels << score_schema.label
303
+ score_keys << score_schema.subscore_schemas.map do |subschema|
304
+ hash = {
305
+ key: subschema.key,
306
+ header: subschema.export_key.to_s # a shortened key used as PART OF the csv export column headers
307
+ }
308
+ if subschema.only_for_export
309
+ hash.merge(hidden: true)
310
+ else
311
+ hash
312
+ end
313
+ end
314
+
315
+ headers = score_schema.subscore_schemas.map(&:label)
316
+ score_headers += headers - score_headers
317
+ end
318
+
319
+ {
320
+ headers: score_headers,
321
+ keys: score_keys,
322
+ labels: score_labels
323
+ }
324
+ end
325
+
326
+ class ShortenKeysUniq
327
+ def initialize
328
+ @seen_results = []
329
+ end
330
+
331
+ def shorten_one(key)
332
+ key = key.to_s
333
+ limit = 2
334
+ shortened_key = nil
335
+ loop do
336
+ shortened_key = key[0..limit]
337
+ if key[limit] == "_"
338
+ limit += 1
339
+ next
340
+ end
341
+ break unless @seen_results.include?(shortened_key)
342
+ raise "duplicate key, #{key}" if shortened_key.length == key.length
343
+ limit += 1
344
+ end
345
+
346
+ @seen_results << shortened_key
347
+ shortened_key
348
+ end
349
+
350
+ def shorten_two(first_key, second_key)
351
+ first_key = first_key.to_s
352
+ second_key = second_key.to_s
353
+ first_limit = [2, first_key.length - 1].min
354
+ second_limit = 0
355
+ shortened_key = nil
356
+ loop do
357
+ shortened_key = "#{first_key[0..first_limit]}_#{second_key[0..second_limit]}"
358
+ if first_key[first_limit] == "_"
359
+ first_limit += 1
360
+ next
361
+ end
362
+ if second_key[second_limit] == "_"
363
+ second_limit += 1
364
+ next
365
+ end
366
+ break unless @seen_results.include?(shortened_key)
367
+ raise "duplicate key, #{first_key}_#{second_key}" if first_limit == (first_key.length - 1) &&
368
+ second_limit == (second_key.length - 1)
369
+
370
+ if second_limit == (second_key.length - 1)
371
+ first_limit += 1
372
+ second_limit = 0
373
+ else
374
+ second_limit += 1
375
+ end
376
+ end
377
+
378
+ @seen_results << shortened_key
379
+ shortened_key
380
+ end
381
+ end
382
+
383
+ def sort_nested_hash(obj)
384
+ case obj
385
+ when Hash
386
+ obj.transform_values { |v| sort_nested_hash(v) }
387
+ .sort_by_alphanum { |k, _v| k.to_s }
388
+ .to_h
389
+ when Array
390
+ obj.map { |v| sort_nested_hash(v) }
391
+ else
392
+ obj
393
+ end
394
+ end
395
+
396
+
397
+ private
398
+
399
+ def self.keys_for_score(score)
400
+ score.map { |subscore| subscore[:key] }
401
+ end
402
+ end
403
+ end
404
+ end
405
+ end
@@ -0,0 +1,116 @@
1
+ module Quby
2
+ module Compiler
3
+ module Services
4
+ class SeedDiff
5
+ def format_patch(reference, candidate, path:)
6
+ @reference_for_debugging = reference
7
+ @candidate_for_debugging = candidate
8
+ format_polymorph_patch(reference, candidate, path: path)
9
+ end
10
+
11
+ def apply_patch(object, patch)
12
+ return object if patch.nil?
13
+ apply_polymorphic(object, patch, path: "")
14
+ end
15
+
16
+ private
17
+
18
+ def format_polymorph_patch(reference, candidate, path:)
19
+ if reference.class != candidate.class && !candidate.nil? && !depends_relation?(path)
20
+ raise "Incompatible types at #{path}: #{reference.class} and #{candidate.class}"
21
+ end
22
+
23
+ case reference
24
+ when Hash
25
+ case candidate
26
+ when Hash
27
+ format_hash_patch(reference, candidate, path: path)
28
+ else
29
+ reference
30
+ end
31
+ when Array
32
+ format_array_patch(reference, candidate, path: path)
33
+ when String, Symbol, true, false
34
+ reference
35
+ else
36
+ raise "Cannot patch #{reference.class} yet."
37
+ end
38
+ end
39
+
40
+ def format_hash_patch(reference, candidate, path:)
41
+ return reference if candidate.nil?
42
+
43
+ patch = {}
44
+
45
+ reference.keys.each do |key|
46
+ if reference[key] != candidate[key]
47
+ patch[key] = format_polymorph_patch(reference[key], candidate[key], path: path + ".#{key}")
48
+ end
49
+ end
50
+
51
+ patch
52
+ end
53
+
54
+ def format_array_patch(reference, candidate, path:)
55
+ return reference if candidate.nil?
56
+
57
+ patch = { __patch_type__: "array" }
58
+
59
+ reference.each_with_index do |reference_elm, idx|
60
+ if reference_elm != candidate[idx]
61
+ patch[idx] = reference_elm
62
+ end
63
+ end
64
+
65
+ if candidate.size > reference.size
66
+ patch[:__patch_trim__] = reference.size
67
+ end
68
+
69
+ patch
70
+ end
71
+
72
+ def apply_polymorphic(object, patch, path:)
73
+ if patch.is_a?(Hash) && patch[:__patch_type__] == "array"
74
+ apply_array(object, patch, path: path)
75
+ elsif patch.is_a?(Hash)
76
+ apply_hash(object, patch, path: path)
77
+ else
78
+ patch
79
+ end
80
+ rescue Exception => e
81
+ puts "path: #{path}"
82
+ raise
83
+ end
84
+
85
+ def apply_hash(object, patch, path:)
86
+ object = {} unless object.is_a?(Hash)
87
+
88
+ patch.each do |key, value|
89
+ object[key] = apply_polymorphic(object[key], value, path: path + ".#{key}")
90
+ end
91
+
92
+ object
93
+ end
94
+
95
+ def apply_array(object, patch, path:)
96
+ object ||= []
97
+
98
+ patch.each do |key, val|
99
+ next if key == :__patch_type__ || key == :__patch_trim__
100
+ object[key] = val
101
+ end
102
+
103
+ if patch[:__patch_trim__]
104
+ object = object.slice(0, patch[:__patch_trim__])
105
+ end
106
+
107
+ object
108
+ end
109
+
110
+ def depends_relation?(path)
111
+ path.end_with?(".depends")
112
+ end
113
+ end
114
+ end
115
+ end
116
+ end