eco-helpers 3.2.16 → 3.2.17

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 (39) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +117 -0
  3. data/eco-helpers.gemspec +2 -2
  4. data/lib/eco/api/usecases/graphql/compat/parity/comparison.rb +70 -0
  5. data/lib/eco/api/usecases/graphql/compat/parity/harness.rb +102 -0
  6. data/lib/eco/api/usecases/graphql/compat/parity/run_result.rb +96 -0
  7. data/lib/eco/api/usecases/graphql/compat.rb +5 -0
  8. data/lib/eco/api/usecases/graphql/helpers/location/command/end_points/optimizations.rb +4 -4
  9. data/lib/eco/api/usecases/graphql/helpers/pages/copying.rb +71 -0
  10. data/lib/eco/api/usecases/graphql/helpers/pages/creatable.rb +78 -0
  11. data/lib/eco/api/usecases/graphql/helpers/pages/filters.rb +114 -0
  12. data/lib/eco/api/usecases/graphql/helpers/pages/ooze_handlers.rb +112 -0
  13. data/lib/eco/api/usecases/graphql/helpers/pages/rescuable.rb +52 -0
  14. data/lib/eco/api/usecases/graphql/helpers/pages/shortcuts.rb +186 -0
  15. data/lib/eco/api/usecases/graphql/helpers/pages/typed_fields_pairing.rb +303 -0
  16. data/lib/eco/api/usecases/graphql/helpers/pages.rb +21 -0
  17. data/lib/eco/api/usecases/graphql/helpers.rb +1 -0
  18. data/lib/eco/api/usecases/graphql/samples/location/command/service/tree_update.rb +1 -1
  19. data/lib/eco/api/usecases/graphql/samples/pages/register/base.rb +181 -0
  20. data/lib/eco/api/usecases/graphql/samples/pages/register/migration_case.rb +132 -0
  21. data/lib/eco/api/usecases/graphql/samples/pages/register/target_oozes_update_case.rb +163 -0
  22. data/lib/eco/api/usecases/graphql/samples/pages/register.rb +8 -0
  23. data/lib/eco/api/usecases/graphql/samples/pages/template/base.rb +70 -0
  24. data/lib/eco/api/usecases/graphql/samples/pages/template/command_emitter.rb +139 -0
  25. data/lib/eco/api/usecases/graphql/samples/pages/template/csv_build/builder.rb +126 -0
  26. data/lib/eco/api/usecases/graphql/samples/pages/template/csv_build/format_map.rb +108 -0
  27. data/lib/eco/api/usecases/graphql/samples/pages/template/csv_build/parser.rb +98 -0
  28. data/lib/eco/api/usecases/graphql/samples/pages/template/csv_build.rb +17 -0
  29. data/lib/eco/api/usecases/graphql/samples/pages/template/deploy/applier.rb +141 -0
  30. data/lib/eco/api/usecases/graphql/samples/pages/template/deploy/drift_report.rb +104 -0
  31. data/lib/eco/api/usecases/graphql/samples/pages/template/deploy/loop.rb +155 -0
  32. data/lib/eco/api/usecases/graphql/samples/pages/template/deploy/recording_executor.rb +58 -0
  33. data/lib/eco/api/usecases/graphql/samples/pages/template/deploy/sync_readiness.rb +178 -0
  34. data/lib/eco/api/usecases/graphql/samples/pages/template/deploy/verifier.rb +141 -0
  35. data/lib/eco/api/usecases/graphql/samples/pages/template/deploy.rb +21 -0
  36. data/lib/eco/api/usecases/graphql/samples/pages/template.rb +11 -0
  37. data/lib/eco/api/usecases/graphql/samples/pages.rb +2 -0
  38. data/lib/eco/version.rb +1 -1
  39. metadata +34 -5
@@ -0,0 +1,303 @@
1
+ module Eco::API::UseCases::GraphQL::Helpers
2
+ module Pages
3
+ # Native GraphQL re-expression of
4
+ # OozeSamples::HelpersMigration::TypedFieldsPairing.
5
+ #
6
+ # It pairs fields of the SAME type with the SAME label name across two page
7
+ # models (a source `src` and a destination `dst`). This is the cross-template
8
+ # field-pairing used by migration cases — DISTINCT from the value-merge
9
+ # `OozeHandlers#merge_values` (which combines two values of one already-paired
10
+ # field).
11
+ #
12
+ # It runs two screenings:
13
+ # (1) exact label match,
14
+ # (2) then the rest, case-insensitive a-z only, content between brackets removed.
15
+ #
16
+ # Other features (ported verbatim from the v2 "World Wonder of 2022"):
17
+ # * tracks src / dst fields that were NOT paired
18
+ # * lets you `resolve` fields paired by other means out of the unpaired sets
19
+ # * tracks src fields with MULTIPLE candidate dst fields (`src_dst_multi`)
20
+ #
21
+ # == What differs from the v2 class
22
+ #
23
+ # Fields are grouped and compared by the GraphQL DataField TYPE STRING (the
24
+ # `__typename`-derived value in `Ecoportal::API::GraphQL::Base::Page::DataField::TYPE_MAP`,
25
+ # e.g. 'PlainText', 'Select', 'Date') rather than the v2 snake_case component type
26
+ # (`'plain_text'`, `'select'`, `'date'`). Same-type grouping still guarantees a
27
+ # pair only forms between fields of identical type. `EXCLUDED_TYPES` therefore
28
+ # lists the GraphQL type strings for the never-pair types (charts / tag fields).
29
+ #
30
+ # `ximport?` detects an import-marker field via its `#tooltip` (unchanged), and
31
+ # `object_reference` / `same_string?` come from the native `Shortcuts`.
32
+ class TypedFieldsPairing
33
+ include Eco::API::UseCases::GraphQL::Helpers::Pages::Shortcuts
34
+
35
+ # GraphQL DataField type strings that must never be paired (no meaningful,
36
+ # copyable content). Mirrors the v2 EXCLUDED_TYPES
37
+ # (%w[tag_field chart frequency_rate_chart]) in GraphQL terms — GraphQL has no
38
+ # distinct chart data-field types, so TagField is the practical excluded type;
39
+ # any additional non-content types can be added here.
40
+ EXCLUDED_TYPES = %w[TagField Chart FrequencyRateChart].freeze
41
+
42
+ attr_reader :src, :dst
43
+ attr_reader :src_unpaired, :dst_unpaired
44
+ attr_reader :src_dst_pairs, :src_dst_multi
45
+
46
+ def initialize(source, destination, src_excluded: nil, dst_excluded: nil,
47
+ exclude_ximport: false)
48
+ @src = source
49
+ @dst = destination
50
+ @src_excluded = src_excluded || []
51
+ @dst_excluded = dst_excluded || []
52
+ @exclude_ximport = exclude_ximport
53
+
54
+ reset!
55
+ process(exact: true)
56
+ process
57
+ end
58
+
59
+ def each_pair
60
+ src_dst_pairs.each do |(src_field, dst_field)|
61
+ yield(src_field, dst_field) if block_given?
62
+ end
63
+ end
64
+
65
+ def report_src_unpaired(only_present: true, only_indexed: false)
66
+ return if src_unpaired.empty?
67
+
68
+ msg = "Source entry with unpaired fields (#{object_reference(src)}):\n"
69
+
70
+ src_filtered = src_unpaired.values.flatten.reject do |src_fld|
71
+ only_present && field_empty?(src_fld)
72
+ end.reject do |src_fld|
73
+ only_indexed && src_fld.deindex
74
+ end
75
+
76
+ return if src_filtered.empty?
77
+
78
+ msg << src_filtered.map do |src_fld|
79
+ " * #{object_reference(src_fld)}"
80
+ end.join("\n")
81
+ end
82
+
83
+ def report_dst_unpaired(only_required: false, exclude_ximport: true)
84
+ return if dst_unpaired.empty?
85
+
86
+ msg = "Destination entry with unpaired fields (#{object_reference(src)}):\n"
87
+
88
+ dst_filtered = dst_unpaired.values.flatten.reject do |dst_fld|
89
+ only_required && !dst_fld.required
90
+ end.reject do |dst_fld|
91
+ exclude_ximport && ximport?(dst_fld)
92
+ end
93
+
94
+ return if dst_filtered.empty?
95
+
96
+ msg << dst_filtered.map do |dst_fld|
97
+ " * #{object_reference(dst_fld)}"
98
+ end.join("\n")
99
+ end
100
+
101
+ def report_multi_pairs(only_present: true, only_indexed: false, dst_exclude_ximport: true)
102
+ return nil if src_dst_multi.empty?
103
+
104
+ ''.tap do |msg|
105
+ msg << "Source fields in (#{object_reference(src)}) paired with multiple destination fields:\n"
106
+ msg_flds = ''
107
+ src_dst_multi.each_value do |pairs|
108
+ pairs.each do |(src_fld, dst_flds)|
109
+ next if (only_present && field_empty?(src_fld)) || (only_indexed && src_fld.deindex)
110
+
111
+ dst_filtered = dst_flds.reject do |dst_fld|
112
+ dst_exclude_ximport && ximport?(dst_fld)
113
+ end
114
+ next if dst_filtered.empty?
115
+
116
+ msg_flds << " * #{object_reference(src_fld)}\n"
117
+ msg_flds << dst_filtered.map do |dst_fld|
118
+ " * #{object_reference(dst_fld)}"
119
+ end.compact.join("\n")
120
+ end
121
+ end
122
+
123
+ return nil if msg_flds.empty?
124
+
125
+ msg << msg_flds
126
+ end
127
+ end
128
+
129
+ # Remove from unpaired tracking those `flds`.
130
+ def resolve(*flds)
131
+ flds.each do |fld|
132
+ next if unpaired_delete?(fld, src_unpaired)
133
+
134
+ unpaired_delete?(fld, dst_unpaired)
135
+ end
136
+
137
+ multi_delete(*flds)
138
+ end
139
+
140
+ def some_multi?
141
+ !src_dst_multi.empty?
142
+ end
143
+
144
+ private
145
+
146
+ # When comparing, use only a-z letters.
147
+ def only_letters?
148
+ true
149
+ end
150
+
151
+ # When comparing, remove anything between brackets from the field label name.
152
+ def non_bracked?
153
+ true
154
+ end
155
+
156
+ # If `false`, it compares with all lower case.
157
+ def exact?
158
+ !(only_letters? || non_bracked?)
159
+ end
160
+
161
+ def process(exact: false)
162
+ src_unpaired.each do |type, src_pend|
163
+ next unless (dst_pend = dst_unpaired[type]) && !dst_pend.empty?
164
+
165
+ src_pend.dup.each do |src_fld|
166
+ dst_candidates = dst_pend.select do |dst_fld|
167
+ str_eq?(src_fld.label, dst_fld.label, exact: exact)
168
+ end
169
+ next if dst_candidates.empty?
170
+
171
+ if dst_candidates.one?
172
+ pair_add(src_fld, dst_candidates.first)
173
+ else
174
+ multi_add(type, src_fld, *dst_candidates)
175
+ end
176
+ end
177
+ end
178
+ end
179
+
180
+ def str_eq?(str_1, str_2, exact: false)
181
+ same_string?(str_1, str_2, **str_eq_params, exact: exact)
182
+ end
183
+
184
+ def str_eq_params
185
+ {
186
+ exact: exact?,
187
+ mild: only_letters?,
188
+ ignore: false
189
+ }.tap do |params|
190
+ params.merge!(ignore: bracked_regex) if non_bracked?
191
+ end
192
+ end
193
+
194
+ def pair_add(src_fld, dst_fld)
195
+ resolve(src_fld, dst_fld)
196
+ src_dst_pairs << [src_fld, dst_fld]
197
+ end
198
+
199
+ # Removes a field from a (src or dst) unpaired set.
200
+ # @return [Boolean] true when the field was present and removed.
201
+ def unpaired_delete?(fld, unpaired)
202
+ type = fld.type
203
+ return false unless unpaired.key?(type)
204
+ return false unless (flds = unpaired[type]).delete(fld)
205
+
206
+ unpaired.delete(type) if flds.empty?
207
+ true
208
+ end
209
+
210
+ def multi_add(type, src_fld, *dst_flds)
211
+ (src_dst_multi[type] ||= []) << (pair = [src_fld, dst_flds])
212
+ @src_multi[src_fld] = pair
213
+
214
+ dst_flds.each do |dst_fld|
215
+ (@dst_multi[dst_fld] ||= []) << pair
216
+ end
217
+ end
218
+
219
+ def multi_delete(*flds)
220
+ flds.each do |fld|
221
+ next unless (type_pairs = src_dst_multi[fld.type])
222
+
223
+ if (pairs = @dst_multi.delete(fld))
224
+ pairs.each do |pair|
225
+ src_fld = pair.first
226
+ dst_flds = pair.last
227
+ dst_flds.delete(fld)
228
+ if dst_flds.empty?
229
+ @src_multi.delete(src_fld)
230
+ type_pairs.delete(pair)
231
+ end
232
+ end
233
+ elsif @src_multi.key?(fld)
234
+ pair = @src_multi.delete(fld)
235
+ type_pairs.delete(pair)
236
+
237
+ pair.last.each do |dst_fld|
238
+ pairs = @dst_multi[dst_fld]
239
+ pairs.delete(pair)
240
+ @dst_multi.delete(dst_fld) if pairs.empty?
241
+ end
242
+ end
243
+
244
+ src_dst_multi.delete(fld.type) if type_pairs.empty?
245
+ end
246
+ end
247
+
248
+ def by_type(components)
249
+ components.to_a.group_by(&:type).tap do |out|
250
+ EXCLUDED_TYPES.each {|type| out.delete(type)}
251
+ end
252
+ end
253
+
254
+ def src_components
255
+ flds = components_of(src)
256
+ flds = flds.reject {|fld| ximport?(fld)} if @exclude_ximport
257
+ flds - @src_excluded
258
+ end
259
+
260
+ def dst_components
261
+ flds = components_of(dst)
262
+ flds = flds.reject {|fld| ximport?(fld)} if @exclude_ximport
263
+ flds - @dst_excluded
264
+ end
265
+
266
+ # Duck-typed access to a page model's data fields. Native GraphQL page
267
+ # models expose `#components` (a SectionCollection-backed collection); the
268
+ # v2 class relied on the same method name.
269
+ def components_of(entry)
270
+ entry.components.to_a
271
+ end
272
+
273
+ def ximport?(fld)
274
+ return false unless fld.respond_to?(:tooltip)
275
+
276
+ fld.tooltip.to_s.include?('ximport')
277
+ end
278
+
279
+ # A field is "empty" when it carries no content. Native DataField types do
280
+ # not universally expose `#empty?`; fall back to `#value` emptiness so the
281
+ # report filters behave like the v2 ones.
282
+ def field_empty?(fld)
283
+ return fld.empty? if fld.respond_to?(:empty?)
284
+ return fld.value.to_s.strip.empty? if fld.respond_to?(:value)
285
+
286
+ false
287
+ end
288
+
289
+ def reset!
290
+ @src_unpaired = by_type(src_components)
291
+ @dst_unpaired = by_type(dst_components)
292
+ @src_dst_pairs = []
293
+ reset_multi!
294
+ end
295
+
296
+ def reset_multi!
297
+ @src_dst_multi = {}
298
+ @src_multi = {}
299
+ @dst_multi = {}
300
+ end
301
+ end
302
+ end
303
+ end
@@ -0,0 +1,21 @@
1
+ module Eco::API::UseCases::GraphQL::Helpers
2
+ # Native GraphQL page-processing helper substrate.
3
+ #
4
+ # These mirror the *pure* parts of Eco::API::UseCases::OozeSamples::Helpers::* but drop the
5
+ # Ecoportal::API::V2 coupling: string/date utilities are ported verbatim, while the few
6
+ # methods that inspected V2 model types (object_reference, field_key_name) are re-expressed
7
+ # with duck-typing so they work against the GraphQL page/section/field models.
8
+ #
9
+ # This is Phase 1 of the ooze -> native GraphQL migration (build the shared substrate before
10
+ # any case). See ecoportal-api-graphql/.ai-assistance/projects/ooze-graphql-native-migration/.
11
+ module Pages
12
+ end
13
+ end
14
+
15
+ require_relative 'pages/shortcuts'
16
+ require_relative 'pages/filters'
17
+ require_relative 'pages/ooze_handlers'
18
+ require_relative 'pages/rescuable'
19
+ require_relative 'pages/creatable'
20
+ require_relative 'pages/typed_fields_pairing'
21
+ require_relative 'pages/copying'
@@ -6,3 +6,4 @@ end
6
6
  require_relative 'helpers/base'
7
7
  require_relative 'helpers/location'
8
8
  require_relative 'helpers/contractors'
9
+ require_relative 'helpers/pages'
@@ -57,7 +57,7 @@ class Eco::API::UseCases::GraphQL::Samples::Location
57
57
  digest_msgs << "\n#{exception.patch_full_message(trace_count: 3)}" if exception
58
58
 
59
59
  session.mail(subject: subject, body: digest_msgs.join).tap do
60
- options.deep_merge!({worfklow: {no_email: true}})
60
+ options.deep_merge!({workflow: {no_email: true}})
61
61
  end
62
62
  end
63
63
  end
@@ -0,0 +1,181 @@
1
+ module Eco::API::UseCases::GraphQL::Samples::Pages
2
+ # Native GraphQL re-expression of OozeSamples::RegisterUpdateCase.
3
+ #
4
+ # Register-scoped page processing: iterate the entries of a register in
5
+ # cursor-paginated batches, hand each page to `process_page` (a.k.a.
6
+ # `process_ooze` for naming parity with the v2 case), and track KPI counters
7
+ # over the whole run. Supports creating new entries via the native Creatable.
8
+ #
9
+ # This reproduces the SHAPE of RegisterUpdateCase (batched search, dedup-by-id,
10
+ # search/retrieved/updated/created/failed counters, a dry-run preview) natively,
11
+ # without the Ecoportal::API::V2 queue/enqueue coupling: the GraphQL update path
12
+ # is per-page (graphql.pages.update) rather than a batched patch queue, so the
13
+ # v2 batch_queue / enqueue / queue_shift machinery is intentionally dropped.
14
+ #
15
+ # == Subclass interface
16
+ #
17
+ # class Custom::UseCase::CloseOldEntries < Eco::API::UseCases::GraphQL::Samples::Pages::Register::Base
18
+ # name 'close-old-entries'
19
+ # register_id 'REG_ABC123'
20
+ # batch_size 25
21
+ #
22
+ # def process_page(page)
23
+ # return skip('not stale') unless stale?(page)
24
+ # page.name = "[CLOSED] #{page.name}"
25
+ # update_page(page)
26
+ # end
27
+ # end
28
+ #
29
+ # `register_id` may also come from `options[:source][:register_id]` (v2 parity),
30
+ # which takes precedence over the class-level declaration when present.
31
+ class Register::Base < Page::Base
32
+ include Eco::API::UseCases::GraphQL::Helpers::Pages::Shortcuts
33
+ include Eco::API::UseCases::GraphQL::Helpers::Pages::OozeHandlers
34
+ include Eco::API::UseCases::GraphQL::Helpers::Pages::Rescuable
35
+ include Eco::API::UseCases::GraphQL::Helpers::Pages::Creatable
36
+
37
+ name 'graphql-register-base'
38
+ type :other
39
+
40
+ class << self
41
+ def batch_size(size = nil)
42
+ @batch_size ||= 25
43
+ return @batch_size unless size
44
+
45
+ @batch_size = size
46
+ end
47
+ end
48
+
49
+ attr_reader :total_search_pages, :dupped_search_pages
50
+ attr_reader :retrieved_pages, :page_result_ids
51
+ attr_reader :created_pages, :page_create_attempts
52
+
53
+ # Entry point — called by GraphQL::Base#main.
54
+ def process
55
+ init_kpis
56
+ results_preview
57
+ each_page do |page|
58
+ process_page(page)
59
+ @processed_pages += 1
60
+ end
61
+ log_kpis
62
+ end
63
+
64
+ # == Subclass override points ==============================================
65
+
66
+ # Transform or act on one page. Call update_page(page) to persist changes.
67
+ # Alias `process_ooze` is provided for naming parity with the v2 case.
68
+ def process_page(_page)
69
+ raise NotImplementedError, "Implement #process_page in #{self.class}"
70
+ end
71
+
72
+ # Naming-parity alias for the v2 case. Forwards (rather than alias_method) so a
73
+ # subclass overriding #process_page is honoured when called as #process_ooze.
74
+ def process_ooze(page)
75
+ process_page(page)
76
+ end
77
+
78
+ # Return the register id to scope the search to.
79
+ # `options[:source][:register_id]` (v2 parity) wins over the class declaration.
80
+ def register_id
81
+ options.dig(:source, :register_id) || self.class.register_id
82
+ end
83
+
84
+ # Return a SearchConf for the page search. Override to add filters.
85
+ # Default: scoped to register_id (raises if none configured — a register case
86
+ # without a register would silently scan the whole org).
87
+ def search_conf
88
+ reg = register_id
89
+ raise ArgumentError, 'No register_id configured (class register_id or options[:source][:register_id])' unless reg
90
+
91
+ sc.new.filter(in_register(reg))
92
+ end
93
+
94
+ protected
95
+
96
+ # Typed value merge for a GraphQL data field. Convenience over OozeHandlers#merge_values
97
+ # that reads the field's own #type so a subclass need not pass it explicitly.
98
+ #
99
+ # fld = page.components.get_by_name('Notes')
100
+ # fld.value = merge_field_values(fld, incoming_notes)
101
+ #
102
+ # @param field [#type] a GraphQL DataField (or anything responding to #type).
103
+ # @param append [Object] the value to merge onto the field's current value.
104
+ # @param origin [Object] the base value; defaults to the field's current #value.
105
+ # @param delimiter [String] delimiter for :delimiter-strategy types.
106
+ # @return [Object] the merged value.
107
+ def merge_field_values(field, append, origin: nil, delimiter: "\n")
108
+ base = origin.nil? && field.respond_to?(:value) ? field.value : origin
109
+ merge_values(base, append, type: field, delimiter: delimiter)
110
+ end
111
+
112
+ # Launches the actual creation of the entry (hook driven by Creatable).
113
+ # Respects simulate? for dry-run; on live create, bumps the created counter.
114
+ # @return [Ecoportal::API::GraphQL::Compat::PageReference, false]
115
+ def create_ooze(draft, template_id: draft&.template_id)
116
+ @page_create_attempts += 1
117
+ if simulate?
118
+ dry_run_feedback(draft)
119
+ return false
120
+ end
121
+
122
+ graphql.pages.create(draft, from: template_id).tap do |result|
123
+ @created_pages += 1 if result.respond_to?(:page_id) && result.page_id
124
+ end
125
+ end
126
+
127
+ # Cursor-paginated iteration over the register's pages. Counts total search
128
+ # results and flags duplicated page ids (dedup-by-id, v2 parity).
129
+ def each_page(conf: search_conf, first: self.class.batch_size)
130
+ graphql.pages.each(search_conf: conf.to_h, first: first) do |page|
131
+ @total_search_pages += 1
132
+ if page_result_ids[page.id]
133
+ @dupped_search_pages += 1
134
+ else
135
+ page_result_ids[page.id] = true
136
+ @total_pages += 1
137
+ end
138
+ @retrieved_pages += 1
139
+ yield page
140
+ end
141
+ end
142
+
143
+ private
144
+
145
+ # Dry-run preview of what a create WOULD send. Faithful but scoped: the
146
+ # GraphQL cursor search offers no cheap "total before filtering" count like the
147
+ # v2 registers.search(only_first:), so there is no up-front count prompt here.
148
+ def dry_run_feedback(draft)
149
+ log(:info) { "Simulate — would create page from template #{draft&.template_id}" }
150
+ nil
151
+ end
152
+
153
+ def results_preview
154
+ log(:info) { "Processing register '#{register_id}' in batches of #{self.class.batch_size}" }
155
+ end
156
+
157
+ def init_kpis
158
+ super
159
+ @total_search_pages = 0
160
+ @dupped_search_pages = 0
161
+ @retrieved_pages = 0
162
+ @page_result_ids = {}
163
+ @created_pages = 0
164
+ @page_create_attempts = 0
165
+ end
166
+
167
+ def kpis_message
168
+ [
169
+ 'Run end:',
170
+ " * Search results: #{total_search_pages}",
171
+ " * Duplicated: #{dupped_search_pages}",
172
+ " * Retrieved: #{retrieved_pages}",
173
+ " * Processed: #{processed_pages}",
174
+ " * Updated: #{updated_pages} (of #{total_pages} unique)",
175
+ " * Created: #{created_pages} (of #{page_create_attempts} attempts)",
176
+ " * Skipped: #{skipped_pages}",
177
+ " * Failed: #{failed_pages}"
178
+ ].join("\n")
179
+ end
180
+ end
181
+ end
@@ -0,0 +1,132 @@
1
+ module Eco::API::UseCases::GraphQL::Samples::Pages
2
+ # Native GraphQL re-expression of OozeSamples::RegisterMigrationCase.
3
+ #
4
+ # Migrates the entries of a register into NEW entries of a (possibly different)
5
+ # template, copying data across by PAIRING typed fields between the source page
6
+ # and the freshly drafted destination page. Field pairing is delegated to the
7
+ # native `Helpers::Pages::TypedFieldsPairing` (label + type matching over
8
+ # GraphQL DataField types); the actual per-field content copy dispatches on the
9
+ # GraphQL DataField TYPE STRING (mirroring how OozeHandlers#merge_values ports
10
+ # the v2 type-class dispatch).
11
+ #
12
+ # It reproduces the SHAPE of RegisterMigrationCase natively:
13
+ # * iterate the source register (inherited from Register::Base#each_page)
14
+ # * draft a new entry of TEMPLATE_ID (via the native Creatable)
15
+ # * pair the source fields to the draft fields and copy content
16
+ # * run the unpaired / multi-pair reports (data-loss visibility)
17
+ # * let a subclass finalise the draft via #custom_processing
18
+ #
19
+ # As with the rest of the native samples layer this is NON-BREAKING and
20
+ # additive: no OozeSamples name is flipped and the legacy
21
+ # HelpersMigration::TypedFieldsPairing / RegisterMigrationCase are untouched.
22
+ #
23
+ # == What differs from the v2 case
24
+ # * dispatch is on GraphQL DataField type strings (PlainText/Select/...) not
25
+ # V2 component classes / snake_case types
26
+ # * the per-page persistence is the native create path (Creatable#create_ooze →
27
+ # graphql.pages.create), not the v2 batch queue
28
+ # * the v2 JSON `field_maps` / `copy_hooked_fields` mapping layer is NOT ported
29
+ # here (that consumes the v2 `with_fields`/regex-hook helpers); this scaffold
30
+ # covers the generic typed pairing. Mapped-field hooks are a later increment.
31
+ #
32
+ # == Subclass interface
33
+ #
34
+ # class Custom::UseCase::MigrateTraining < Eco::API::UseCases::GraphQL::Samples::Pages::Register::MigrationCase
35
+ # name 'migrate-training'
36
+ # register_id 'REG_SOURCE' # source register to read from
37
+ # TEMPLATE_ID = 'TPL_DEST' # destination template to create entries of
38
+ #
39
+ # # Optional: finalise the draft after generic pairing has run.
40
+ # def custom_processing(draft, source, fields_tracker: nil)
41
+ # draft.name = source.name
42
+ # end
43
+ # end
44
+ #
45
+ # Expects `options[:source][:register_id]` (or class `register_id`) for the
46
+ # source scope, and a destination template id via the `TEMPLATE_ID` constant
47
+ # (overridable through #destination_template_id).
48
+ class Register::MigrationCase < Register::Base
49
+ include Eco::API::UseCases::GraphQL::Helpers::Pages::Copying
50
+
51
+ name 'graphql-register-migration-case'
52
+ type :other
53
+
54
+ # Destination template constant (subclasses set this).
55
+ TEMPLATE_ID = nil
56
+
57
+ # whether or not it should try to pair src <-> dst ximport fields
58
+ EXCLUDE_XIMPORT = false
59
+ # whether or not it should warn on unpaired destination ximport fields
60
+ REPORT_DST_XIMPORT = true
61
+ # whether or not it should warn on unpaired source fields that are empty (no data loss)
62
+ REPORT_SRC_EMPTY = false
63
+ # whether or not it should warn on unpaired source fields that are deindexed
64
+ REPORT_SRC_DEINDEX = false
65
+
66
+ # One source entry → one new destination entry.
67
+ #
68
+ # Drafts a destination page of `destination_template_id`, pairs the source
69
+ # fields to the draft's fields, copies content across, runs the reports, and
70
+ # (optionally) hands the draft to #custom_processing for finalising.
71
+ def process_page(source)
72
+ draft_reference = object_reference(source)
73
+ creating_new_page(draft_reference, template_id: destination_template_id) do |draft|
74
+ pairing_tracking = copy_pairings(source, draft)
75
+
76
+ if respond_to?(:custom_processing, true)
77
+ custom_processing(draft, source, fields_tracker: pairing_tracking)
78
+ elsif block_given?
79
+ yield(draft, source, fields_tracker: pairing_tracking)
80
+ end
81
+
82
+ report_pairing(pairing_tracking)
83
+ end
84
+ end
85
+
86
+ protected
87
+
88
+ # The destination template to create entries of. Override or set TEMPLATE_ID.
89
+ def destination_template_id
90
+ tpl = self.class::TEMPLATE_ID
91
+ raise ArgumentError, "No destination template configured (set #{self.class}::TEMPLATE_ID)" unless tpl
92
+
93
+ tpl
94
+ end
95
+
96
+ private
97
+
98
+ # Pair the generic typed fields and copy their content. Returns the tracker so
99
+ # the caller can inspect unpaired / multi-paired fields.
100
+ def copy_pairings(src, dst)
101
+ after_copy = respond_to?(:paired_fields_post_callback, true) ? method(:paired_fields_post_callback) : nil
102
+
103
+ copy_generic_paired_fields(
104
+ src,
105
+ dst,
106
+ exclude_ximport: self.class::EXCLUDE_XIMPORT,
107
+ &after_copy
108
+ )
109
+ end
110
+
111
+ def report_pairing(tracker)
112
+ msg = tracker.report_src_unpaired(
113
+ only_present: !self.class::REPORT_SRC_EMPTY,
114
+ only_indexed: !self.class::REPORT_SRC_DEINDEX
115
+ )
116
+ log(:warn) { msg } if msg
117
+
118
+ msg = tracker.report_multi_pairs(
119
+ only_present: !self.class::REPORT_SRC_EMPTY,
120
+ only_indexed: !self.class::REPORT_SRC_DEINDEX,
121
+ dst_exclude_ximport: !self.class::REPORT_DST_XIMPORT
122
+ )
123
+ log(:warn) { msg } if msg
124
+
125
+ msg = tracker.report_dst_unpaired(
126
+ only_required: false,
127
+ exclude_ximport: !self.class::REPORT_DST_XIMPORT
128
+ )
129
+ log(:warn) { msg } if msg
130
+ end
131
+ end
132
+ end