eco-helpers 3.2.14 → 3.2.23

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 (63) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +221 -0
  3. data/lib/eco/api/usecases/default/pages.rb +30 -0
  4. data/lib/eco/api/usecases/graphql/compat/ooze_redirect/dirty_array.rb +22 -0
  5. data/lib/eco/api/usecases/graphql/compat/ooze_redirect/field_patches.rb +241 -0
  6. data/lib/eco/api/usecases/graphql/compat/ooze_redirect/force_compat.rb +73 -0
  7. data/lib/eco/api/usecases/graphql/compat/ooze_redirect.rb +223 -0
  8. data/lib/eco/api/usecases/graphql/compat/parity/comparison.rb +70 -0
  9. data/lib/eco/api/usecases/graphql/compat/parity/harness.rb +102 -0
  10. data/lib/eco/api/usecases/graphql/compat/parity/run_result.rb +96 -0
  11. data/lib/eco/api/usecases/graphql/compat.rb +11 -0
  12. data/lib/eco/api/usecases/graphql/helpers/location/command/end_points/optimizations.rb +4 -4
  13. data/lib/eco/api/usecases/graphql/helpers/pages/copying.rb +71 -0
  14. data/lib/eco/api/usecases/graphql/helpers/pages/creatable.rb +78 -0
  15. data/lib/eco/api/usecases/graphql/helpers/pages/filters.rb +114 -0
  16. data/lib/eco/api/usecases/graphql/helpers/pages/ooze_handlers.rb +112 -0
  17. data/lib/eco/api/usecases/graphql/helpers/pages/rescuable.rb +52 -0
  18. data/lib/eco/api/usecases/graphql/helpers/pages/shortcuts.rb +186 -0
  19. data/lib/eco/api/usecases/graphql/helpers/pages/typed_fields_pairing.rb +303 -0
  20. data/lib/eco/api/usecases/graphql/helpers/pages.rb +21 -0
  21. data/lib/eco/api/usecases/graphql/helpers.rb +1 -0
  22. data/lib/eco/api/usecases/graphql/samples/location/command/service/tree_update.rb +1 -1
  23. data/lib/eco/api/usecases/graphql/samples/pages/org_page/base.rb +41 -0
  24. data/lib/eco/api/usecases/graphql/samples/pages/org_page/dsl.rb +8 -0
  25. data/lib/eco/api/usecases/graphql/samples/pages/org_page.rb +7 -0
  26. data/lib/eco/api/usecases/graphql/samples/pages/page/base.rb +148 -0
  27. data/lib/eco/api/usecases/graphql/samples/pages/page/dsl.rb +38 -0
  28. data/lib/eco/api/usecases/graphql/samples/pages/page.rb +7 -0
  29. data/lib/eco/api/usecases/graphql/samples/pages/register/base.rb +181 -0
  30. data/lib/eco/api/usecases/graphql/samples/pages/register/migration_case.rb +132 -0
  31. data/lib/eco/api/usecases/graphql/samples/pages/register/target_oozes_update_case.rb +163 -0
  32. data/lib/eco/api/usecases/graphql/samples/pages/register.rb +8 -0
  33. data/lib/eco/api/usecases/graphql/samples/pages/template/base.rb +70 -0
  34. data/lib/eco/api/usecases/graphql/samples/pages/template/command_emitter.rb +139 -0
  35. data/lib/eco/api/usecases/graphql/samples/pages/template/csv_build/builder.rb +126 -0
  36. data/lib/eco/api/usecases/graphql/samples/pages/template/csv_build/format_map.rb +108 -0
  37. data/lib/eco/api/usecases/graphql/samples/pages/template/csv_build/parser.rb +98 -0
  38. data/lib/eco/api/usecases/graphql/samples/pages/template/csv_build.rb +17 -0
  39. data/lib/eco/api/usecases/graphql/samples/pages/template/deploy/applier.rb +141 -0
  40. data/lib/eco/api/usecases/graphql/samples/pages/template/deploy/drift_report.rb +104 -0
  41. data/lib/eco/api/usecases/graphql/samples/pages/template/deploy/loop.rb +155 -0
  42. data/lib/eco/api/usecases/graphql/samples/pages/template/deploy/recording_executor.rb +58 -0
  43. data/lib/eco/api/usecases/graphql/samples/pages/template/deploy/sync_readiness.rb +178 -0
  44. data/lib/eco/api/usecases/graphql/samples/pages/template/deploy/verifier.rb +141 -0
  45. data/lib/eco/api/usecases/graphql/samples/pages/template/deploy.rb +21 -0
  46. data/lib/eco/api/usecases/graphql/samples/pages/template.rb +11 -0
  47. data/lib/eco/api/usecases/graphql/samples/pages.rb +9 -0
  48. data/lib/eco/api/usecases/graphql/samples.rb +1 -0
  49. data/lib/eco/api/usecases/graphql.rb +1 -0
  50. data/lib/eco/api/usecases/ooze_samples/ooze_base_case.rb +4 -0
  51. data/lib/eco/api/usecases/ooze_samples/register_update_case.rb +13 -3
  52. data/lib/eco/version.rb +1 -1
  53. metadata +47 -15
  54. data/.gitignore +0 -23
  55. data/.idea/.gitignore +0 -10
  56. data/.markdownlint.json +0 -4
  57. data/.rspec +0 -3
  58. data/.rubocop.yml +0 -103
  59. data/.ruby-version +0 -1
  60. data/.yardopts +0 -10
  61. data/Gemfile +0 -8
  62. data/Rakefile +0 -38
  63. data/eco-helpers.gemspec +0 -63
@@ -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 the ecoportal-api-graphql repo's internal docs.
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,41 @@
1
+ module Eco::API::UseCases::GraphQL::Samples::Pages
2
+ # Base class for **org-wide** page processing use cases.
3
+ #
4
+ # Identical to Page::Base but with no default register scope — search_conf
5
+ # starts empty (all org pages). Subclass adds filters via super.
6
+ #
7
+ # == When to use which
8
+ #
9
+ # Page::Base — one register, most scripts ("go through all pages in Reg X")
10
+ # OrgPage::Base — entire org (cross-register audits, archive sweeps, bulk ops)
11
+ #
12
+ # == Subclass interface
13
+ #
14
+ # class Custom::UseCase::ArchiveStaleDrafts < Eco::API::UseCases::GraphQL::Samples::Pages::OrgPage::Base
15
+ # name 'archive-stale-drafts'
16
+ # batch_size 100
17
+ #
18
+ # def search_conf
19
+ # super.filter(state_is(:draft))
20
+ # .filter(updated_since('2025-01-01'))
21
+ # end
22
+ #
23
+ # def process_page(page)
24
+ # return skip('already archived') if page.archived?
25
+ # log(:info) { "Archiving: #{page.name}" }
26
+ # graphql.page.archive(input: { id: page.id }) unless simulate?
27
+ # end
28
+ # end
29
+ class OrgPage::Base < Page::Base
30
+ name 'graphql-org-page-base'
31
+ type :other
32
+
33
+ require_relative 'dsl'
34
+ include Eco::API::UseCases::GraphQL::Samples::Pages::OrgPage::DSL
35
+
36
+ # No default register scope. Subclass adds filters via super.
37
+ def search_conf
38
+ sc.new
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,8 @@
1
+ module Eco::API::UseCases::GraphQL::Samples::Pages::OrgPage
2
+ # DSL concern for org-wide page use cases.
3
+ # Includes Page::DSL — all SearchConf helpers are available.
4
+ # Add org-page-specific helpers here as the pattern grows.
5
+ module DSL
6
+ include Eco::API::UseCases::GraphQL::Samples::Pages::Page::DSL
7
+ end
8
+ end
@@ -0,0 +1,7 @@
1
+ module Eco::API::UseCases::GraphQL::Samples::Pages
2
+ module OrgPage
3
+ end
4
+ end
5
+
6
+ require_relative 'org_page/dsl'
7
+ require_relative 'org_page/base'
@@ -0,0 +1,148 @@
1
+ module Eco::API::UseCases::GraphQL::Samples::Pages
2
+ # Base class for **register-scoped** page processing use cases.
3
+ #
4
+ # Provides the iteration + KPI scaffolding mirroring RegisterUpdateCase
5
+ # from OozeSamples, but working natively with the GraphQL stack.
6
+ #
7
+ # == Subclass interface
8
+ #
9
+ # class Custom::UseCase::UpdateRiskRating < Eco::API::UseCases::GraphQL::Samples::Pages::Page::Base
10
+ # name 'update-risk-rating'
11
+ # register_id 'REG_ABC123'
12
+ # batch_size 50
13
+ #
14
+ # # Add filters on top of the default register scope.
15
+ # def search_conf
16
+ # super.filter(state_is(:active))
17
+ # .filter(updated_since(options.dig(:filters, :from).to_s))
18
+ # end
19
+ #
20
+ # # Transform or act on each page; call update_page(page) to persist.
21
+ # def process_page(page)
22
+ # field = page.components.get_by_name('Risk Level')
23
+ # return skip('no risk field') unless field
24
+ # page.name = "[HIGH] #{page.name}" if field.value == 'High'
25
+ # update_page(page)
26
+ # end
27
+ # end
28
+ #
29
+ # == What the base provides
30
+ # * process_page(page) — override point (required)
31
+ # * search_conf — override point (optional; default scopes to register_id)
32
+ # * update_page(page) — from_model + simulate? guard + error tracking
33
+ # * skip(reason) — record a non-error skip
34
+ # * each_page — cursor-paginated org search
35
+ # * KPI tracking — total / processed / updated / skipped / failed
36
+ class Page::Base < Eco::API::UseCases::GraphQL::Base
37
+ name 'graphql-page-base'
38
+ type :other
39
+
40
+ require_relative 'dsl'
41
+ include Eco::API::UseCases::GraphQL::Samples::Pages::Page::DSL
42
+
43
+ class << self
44
+ def batch_size(size = nil)
45
+ @batch_size ||= 50
46
+ return @batch_size unless size
47
+ @batch_size = size
48
+ end
49
+
50
+ def register_id(id = nil)
51
+ @register_id = id if id
52
+ @register_id
53
+ end
54
+ end
55
+
56
+ attr_reader :total_pages, :processed_pages, :updated_pages
57
+ attr_reader :failed_pages, :skipped_pages
58
+
59
+ # Entry point — called by GraphQL::Base#main.
60
+ def process
61
+ init_kpis
62
+ each_page do |page|
63
+ process_page(page)
64
+ @processed_pages += 1
65
+ end
66
+ log_kpis
67
+ end
68
+
69
+ # == Subclass override points ==============================================
70
+
71
+ # Transform or act on one page. Call update_page(page) to persist changes.
72
+ def process_page(_page)
73
+ raise NotImplementedError, "Implement #process_page in #{self.class}"
74
+ end
75
+
76
+ # Return a SearchConf for the page search. Override to add filters.
77
+ # Default: scoped to register_id when set, otherwise all pages.
78
+ def search_conf
79
+ conf = sc.new
80
+ reg = self.class.register_id
81
+ conf = conf.filter(in_register(reg)) if reg
82
+ conf
83
+ end
84
+
85
+ protected
86
+
87
+ # Record a skip (not an error — the page was intentionally not processed).
88
+ def skip(reason = nil)
89
+ @skipped_pages += 1
90
+ log(:debug) { "Skipped#{reason ? ": #{reason}" : ''}" }
91
+ end
92
+
93
+ # Persist a page. Wraps Input::Page::Update.from_model, respects simulate?,
94
+ # and tracks KPIs. Returns nil on simulate or no-change; Response otherwise.
95
+ def update_page(page, **kargs)
96
+ if simulate?
97
+ @skipped_pages += 1
98
+ log(:debug) { "Simulate — would update page #{page.id}" }
99
+ return nil
100
+ end
101
+ response = graphql.pages.update(page, **kargs)
102
+ if response.nil?
103
+ @skipped_pages += 1
104
+ elsif response.success?
105
+ @updated_pages += 1
106
+ else
107
+ @failed_pages += 1
108
+ log(:error) { "Failed to update page #{page.id}: #{response.body}" }
109
+ end
110
+ response
111
+ end
112
+
113
+ # Cursor-paginated iteration over pages matching search_conf.
114
+ # Uses org search (currentOrganization.pages) — returns full PageUnion
115
+ # with patchVer and field IDs (required for update workflows).
116
+ def each_page(conf: search_conf, first: self.class.batch_size, &block)
117
+ graphql.pages.each(search_conf: conf.to_h, first: first) do |page|
118
+ @total_pages += 1
119
+ yield page
120
+ end
121
+ end
122
+
123
+ private
124
+
125
+ def init_kpis
126
+ @total_pages = 0
127
+ @processed_pages = 0
128
+ @updated_pages = 0
129
+ @failed_pages = 0
130
+ @skipped_pages = 0
131
+ end
132
+
133
+ def log_kpis
134
+ log(:info) { kpis_message }
135
+ end
136
+
137
+ def kpis_message
138
+ [
139
+ 'Run end:',
140
+ " * Pages found: #{total_pages}",
141
+ " * Pages processed: #{processed_pages}",
142
+ " * Updated: #{updated_pages}",
143
+ " * Skipped: #{skipped_pages}",
144
+ " * Failed: #{failed_pages}"
145
+ ].join("\n")
146
+ end
147
+ end
148
+ end
@@ -0,0 +1,38 @@
1
+ module Eco::API::UseCases::GraphQL::Samples::Pages::Page
2
+ # DSL concern — included in Page::Base and available to all Page subclasses.
3
+ # Provides the SearchConf accessor and common filter shorthand methods.
4
+ #
5
+ # Include this in any class that needs SearchConf DSL access without
6
+ # inheriting the full Page::Base iteration scaffolding.
7
+ module DSL
8
+ private
9
+
10
+ # Lazy accessor for the SearchConf builder class.
11
+ # Resolved at runtime so the ecoportal-api-graphql gem is guaranteed to be loaded.
12
+ # Subclasses can also define a local constant for brevity:
13
+ # SearchConf = Ecoportal::API::GraphQL::Input::SearchConf
14
+ def sc
15
+ Ecoportal::API::GraphQL::Input::SearchConf
16
+ end
17
+
18
+ # Convenience: build a register-scoped filter object.
19
+ def in_register(*ids)
20
+ sc.in_register(*ids)
21
+ end
22
+
23
+ # Convenience: build a state equality filter.
24
+ def state_is(value)
25
+ sc[:state].is(value)
26
+ end
27
+
28
+ # Convenience: build an externalId equality filter.
29
+ def external_id_eq(value)
30
+ sc[:external_id].eq(value)
31
+ end
32
+
33
+ # Convenience: build an updated_at since filter.
34
+ def updated_since(iso8601, time_zone: 'UTC')
35
+ sc[:updated_at].since(iso8601, time_zone: time_zone)
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,7 @@
1
+ module Eco::API::UseCases::GraphQL::Samples::Pages
2
+ module Page
3
+ end
4
+ end
5
+
6
+ require_relative 'page/dsl'
7
+ require_relative 'page/base'