quby-compiler 0.2.1

Sign up to get free protection for your applications and to get access to all the features.
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