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,223 @@
1
+ module Eco::API::UseCases::GraphQL::Compat
2
+ # Transparently redirects an OozeSamples-based use case from the APIv2 ooze
3
+ # path to the GraphQL compat layer. Include it in the case class and nothing
4
+ # else needs to change in the script.
5
+ #
6
+ # ## What it redirects
7
+ #
8
+ # - Page iteration: `with_each_entry` fetches via `graphql.pages.get`
9
+ # - `api_v2` / `apiv2`: returns the graphql compat client
10
+ # - `stage(name)`: resolves via `target.stages[name]` (GraphQL stage model)
11
+ # - `with_fields(ooz, label:, type:)`: reads from `ooz.components` (GraphQL)
12
+ # - `update_ooze(ooz)`: saves via `graphql.pages.update`
13
+ # - `target.submit!` / `target.sign_off!`: captured and forwarded to `graphql.pages.update`
14
+ #
15
+ # ## What it patches (one-time, guarded)
16
+ #
17
+ # - V2 type classes (`PlainTextField`, `SelectionField`, etc.) recognise
18
+ # GraphQL instances in `case/when` dispatch via `===`
19
+ # - GraphQL `People` field: `people_ids <<` triggers dirty tracking
20
+ # - GraphQL `CrossReference` field: `add`, `clear`, `reference_ids`
21
+ # - GraphQL `Select` field: `select`, `deselect`, `values`, `options`
22
+ # (options returns Struct objects with `.name` / `.value`)
23
+ # - All GraphQL fields: `ooze` stub returns a minimal object for warning messages
24
+ # - `Interface::BasePage`: `submit!(stage_id:)` queues a submit-only flag (completes
25
+ # the fill-in task); `sign_off!(stage_id:)` queues sign-off. Tasks are sequential
26
+ # (fill-in → review): sign-off on a not-yet-submitted stage submits + signs off in
27
+ # one go (completePageTask{ signOff: true }). `submit!(force: true)` maps to
28
+ # completePageTask{ forcedComplete: true } (admin/superuser; skips the required-field check).
29
+ #
30
+ # ## Per-run debug logging
31
+ #
32
+ # Each type of redirection logs once per run (not per page) at `:debug` level.
33
+ # Filter with `ECOPORTAL_LOG_LEVEL=debug` or the session logger config.
34
+ #
35
+ # ## Usage
36
+ #
37
+ # class Custom::UseCase::TOOCSCoding < Eco::API::UseCases::OozeSamples::TargetOozesUpdateCase
38
+ # include Eco::API::UseCases::GraphQL::Compat::OozeRedirect
39
+ # # ... rest of class unchanged ...
40
+ # end
41
+ #
42
+ # ## Limitations
43
+ #
44
+ # - Force / binding operations (`target.forces`, `force.bindings`) are not
45
+ # available in GraphQL — those calls will raise NoMethodError.
46
+ # - Field property setters (`fld.label=`, `fld.required=`) are template
47
+ # mutations; they are not supported and will raise NoMethodError.
48
+ # - `page_result.membranes` (on PreviewPage search results) is not supported
49
+ # directly; use register search via `graphql.registers.search` instead.
50
+ #
51
+ # ## TODO: Force / binding support (blocked on GraphQL endpoint)
52
+ #
53
+ # Engineering is working on exposing "legacy forces" via the GraphQL API.
54
+ # Once that endpoint is available, extend this module with a `ForceCompat`
55
+ # sub-module (same include pattern — nothing changes in end scripts) covering:
56
+ #
57
+ # target.forces.get_by_name(name) → query forces by name on a page
58
+ # force.bindings.get_by_name(name) → field bindings on a force
59
+ # force.bindings.add(field, name:) → add a binding
60
+ # force.bindings.delete!(binding) → remove a binding
61
+ # force.custom_script → read the LISP script
62
+ # force.custom_script = new_script → write the LISP script
63
+ # force.script → raw script content (alias)
64
+ #
65
+ # Affected cases currently blocked: roughly half of all ooze cases in the internal script repos'
66
+ # survey (per-customer breakdown kept in the internal migration notes, not in this gem).
67
+ #
68
+ # Implementation sketch (to be built when the endpoint lands):
69
+ #
70
+ # module ForceCompat
71
+ # # Patch GraphQL BasePage to respond to .forces
72
+ # # Returns a proxy whose #get_by_name queries the GraphQL force endpoint
73
+ # # and returns ForceProxy objects wrapping the response.
74
+ # # ForceProxy exposes #bindings (BindingsProxy), #custom_script, #script.
75
+ # # BindingsProxy exposes #get_by_name, #add, #delete!.
76
+ # end
77
+ #
78
+ # OozeRedirect.include(ForceCompat) # or extend OozeRedirect::FieldPatches
79
+ module OozeRedirect
80
+ require_relative 'ooze_redirect/dirty_array'
81
+ require_relative 'ooze_redirect/field_patches'
82
+ require_relative 'ooze_redirect/force_compat'
83
+
84
+ def self.included(base)
85
+ FieldPatches.apply!
86
+ # Provide `graphql` (lazy, memoized session.api(version: :graphql)) regardless of
87
+ # the host's base class. OozeSamples cases (cans/toocs) don't include the GraphQL
88
+ # helpers that Custom::UseCase paths pick up, yet Infrastructure#api_v2 /
89
+ # #with_each_entry / #update_ooze all call `graphql` — without this they raise
90
+ # NameError: undefined `graphql`.
91
+ base.include(Eco::API::UseCases::GraphQL::Helpers::Base::GraphQLEnv)
92
+ # Prepend Infrastructure first so it sits below ForceCompat in the MRO.
93
+ # With Ruby's prepend, last-prepended wins — so ForceCompat::Infrastructure
94
+ # must be prepended AFTER Infrastructure to be first in the lookup chain:
95
+ # MRO: ForceCompat::Infrastructure → Infrastructure → base class
96
+ base.prepend(Infrastructure)
97
+ base.prepend(ForceCompat::Infrastructure) if force_support?
98
+ end
99
+
100
+ # Returns true when the installed ecoportal-api-graphql gem exposes
101
+ # Query::PageWithForces (the forces-aware page query).
102
+ # Forces are NOT usable on GraphQL yet: Query::PageWithForces is a WIP whose query
103
+ # currently fails schema validation (selections on PageUnion; `id` on DataFieldBinding/
104
+ # SectionBinding; unused ForceFields) and the backend forces endpoint is still in
105
+ # progress. The class merely being *defined* is not a readiness signal — activating
106
+ # ForceCompat on that basis makes EVERY OozeRedirect case fetch via the broken force
107
+ # query (e.g. toocs, which doesn't even use forces). Keep OFF until forces actually
108
+ # work; then restore a real readiness check.
109
+ def self.force_support?
110
+ false
111
+ end
112
+
113
+ # Method overrides that replace the v2 infrastructure with GraphQL calls.
114
+ # Prepended onto the including class so they take priority over inherited methods.
115
+ module Infrastructure
116
+ # NOTE: with_each_entry / update_ooze / process_ooze are intentionally NOT overridden.
117
+ # The base OozeSamples loop (RegisterUpdateCase / TargetOozesUpdateCase) already does the
118
+ # right thing once api_v2 is redirected to GraphQL: it counts KPIs (search/retrieved/
119
+ # updated/created), dedups, queues, and — crucially — runs dry_run_feedback in simulate
120
+ # to PRINT the pending diff. Fetches go through ooze(id) → apiv2.pages.get (redirected
121
+ # below), and saves go through update_oozes → update_ooze → apiv2.pages.update. A captured
122
+ # target.submit!/sign_off! rides along on that single update via Input::Page::Update
123
+ # .from_model (it reads the page's _compat_* flags). Overriding the loop here is what
124
+ # silently dropped all of that — don't reintroduce it.
125
+
126
+ # --- API client redirect ------------------------------------------------
127
+
128
+ # Return the GraphQL compat client in place of the v2 client.
129
+ # Handles direct api_v2.pages.* calls in scripts that don't use OozeSamples
130
+ # iteration (e.g. get_new, create, update called directly).
131
+ def api_v2
132
+ warn_once(:api_v2, 'api_v2 → graphql compat layer')
133
+ graphql
134
+ end
135
+
136
+ alias apiv2 api_v2
137
+
138
+ # --- Stage access -------------------------------------------------------
139
+
140
+ # Resolve a stage by name from the current GraphQL page.
141
+ # Replaces the v2 stage fetch from OozeBaseCase.
142
+ def stage(id_name, ooze: target)
143
+ warn_once(:stage, "stage('#{id_name}') → StageView over target.stages[name]")
144
+ stg = ooze.stages[id_name.to_s]
145
+ return nil unless stg
146
+
147
+ # Return a StageView (v2 "page-at-a-stage" semantics): #id / #name / #state /
148
+ # #submit! / #as_update / #dirty? delegate to the PAGE, while #components and
149
+ # #sections are scoped to this stage. Scripts rely on stage.id == page.id
150
+ # (e.g. with_row(stage.id), keyed by page id); the raw Base::Page::Phased::Stage#id
151
+ # is the STAGE's own id and breaks that. Field objects stay the page's canonical
152
+ # ones, so mutations via stage.components still reach pages.update.
153
+ Ecoportal::API::GraphQL::Compat::StageView.new(ooze, stg.id)
154
+ rescue NoMethodError
155
+ log(:warn) { "[OozeRedirect] stages not available on #{ooze.class}" }
156
+ nil
157
+ end
158
+
159
+ # --- Field access -------------------------------------------------------
160
+
161
+ # Redirect field lookups from v2 component access to GraphQL components.
162
+ # Supports the same type: and label: keyword arguments.
163
+ def with_fields(ooz = target, type: nil, label: nil) # rubocop:disable Metrics/MethodLength
164
+ warn_once(:with_fields, 'with_fields → ooz.components (GraphQL)')
165
+ collection = ooz.respond_to?(:components) ? ooz.components : nil
166
+
167
+ unless collection
168
+ log(:warn) { "[OozeRedirect] No components on #{ooz.class}" }
169
+ return []
170
+ end
171
+
172
+ if label && type
173
+ field = collection.get_by_name(label)
174
+ field && type_match?(field, type) ? [field] : []
175
+ elsif label
176
+ [collection.get_by_name(label)].compact
177
+ elsif type
178
+ collection.get_by_type(type)
179
+ else
180
+ collection.to_a
181
+ end
182
+ end
183
+
184
+ # --- Dirty check --------------------------------------------------------
185
+
186
+ # v2 dirty?(ooze) reads patch_doc(ooze)['page']; the GraphQL compat get_body wraps the
187
+ # model's as_update under 'page' (nil when unchanged), so the base dirty? works too —
188
+ # but delegate to the model's real #dirty? for clarity AND treat a captured submit!/
189
+ # sign_off! as dirty, so the base update_ooze still fires for a page that only needs
190
+ # submitting (no field changes). The submit/sign-off itself is carried on that single
191
+ # update by Input::Page::Update.from_model (reads the _compat_* flags).
192
+ def dirty?(object)
193
+ return false unless object
194
+
195
+ pending = (object.respond_to?(:_compat_submit?) && object._compat_submit?) ||
196
+ (object.respond_to?(:_compat_sign_off?) && object._compat_sign_off?)
197
+ return true if pending
198
+ return object.dirty? if object.respond_to?(:dirty?)
199
+
200
+ super
201
+ end
202
+
203
+ private
204
+
205
+ # Log a debug message exactly once per use-case run per operation type.
206
+ def warn_once(op_key, message)
207
+ @_warned_ops ||= Set.new
208
+ return if @_warned_ops.include?(op_key)
209
+
210
+ @_warned_ops << op_key
211
+ log(:debug) { "[OozeRedirect] #{message}" }
212
+ end
213
+
214
+ # Check whether a GraphQL field matches a v2-style type string.
215
+ # v2 uses e.g. "select", "people", "cross_reference"; normalise both sides.
216
+ def type_match?(field, type_key)
217
+ target_norm = type_key.to_s.downcase.delete('_')
218
+ field_norm = field.type.to_s.downcase.delete('_')
219
+ target_norm == field_norm
220
+ end
221
+ end
222
+ end
223
+ end
@@ -0,0 +1,70 @@
1
+ module Eco::API::UseCases::GraphQL::Compat
2
+ module Parity
3
+ # The result of comparing a legacy RunResult (A) against a native RunResult (B).
4
+ #
5
+ # Pure value object: given two RunResults it computes the KPI deltas, the set of
6
+ # page ids only one side touched, and the per-page payload diffs. `equivalent?`
7
+ # is the go/no-go signal the migration flips a case name on.
8
+ class Comparison
9
+ attr_reader :a, :b
10
+
11
+ def initialize(run_a, run_b)
12
+ @a = run_a
13
+ @b = run_b
14
+ end
15
+
16
+ # True when the two runs are indistinguishable on the canonical KPI set and on
17
+ # every per-page update payload.
18
+ def equivalent?
19
+ kpi_diff.empty? && only_in_a.empty? && only_in_b.empty? && payload_diffs.empty?
20
+ end
21
+
22
+ # Canonical KPI names whose values differ, => [a_value, b_value].
23
+ def kpi_diff
24
+ keys = a.kpis.keys | b.kpis.keys
25
+ keys.each_with_object({}) do |key, out|
26
+ av = a.kpis[key].to_i
27
+ bv = b.kpis[key].to_i
28
+ out[key] = [av, bv] if av != bv
29
+ end
30
+ end
31
+
32
+ # Page ids only the legacy run acted on.
33
+ def only_in_a
34
+ a.page_ids - b.page_ids
35
+ end
36
+
37
+ # Page ids only the native run acted on.
38
+ def only_in_b
39
+ b.page_ids - a.page_ids
40
+ end
41
+
42
+ # Per-page payload diffs for the ids BOTH runs touched, => [a_payload, b_payload].
43
+ def payload_diffs
44
+ (a.page_ids & b.page_ids).each_with_object({}) do |id, out|
45
+ ap = a.updates[id]
46
+ bp = b.updates[id]
47
+ out[id] = [ap, bp] if ap != bp
48
+ end
49
+ end
50
+
51
+ # Human-readable report of the divergences (empty when equivalent).
52
+ def report # rubocop:disable Metrics/AbcSize
53
+ return "PARITY OK — #{a.label} ≡ #{b.label}" if equivalent?
54
+
55
+ lines = ["PARITY MISMATCH — #{a.label} vs #{b.label}:"]
56
+ kpi_diff.each do |key, (av, bv)|
57
+ lines << " * KPI #{key}: #{a.label}=#{av} #{b.label}=#{bv}"
58
+ end
59
+ lines << " * only in #{a.label}: #{only_in_a.join(', ')}" unless only_in_a.empty?
60
+ lines << " * only in #{b.label}: #{only_in_b.join(', ')}" unless only_in_b.empty?
61
+ payload_diffs.each do |id, (ap, bp)|
62
+ lines << " * payload #{id}:"
63
+ lines << " #{a.label}: #{ap.inspect}"
64
+ lines << " #{b.label}: #{bp.inspect}"
65
+ end
66
+ lines.join("\n")
67
+ end
68
+ end
69
+ end
70
+ end
@@ -0,0 +1,102 @@
1
+ module Eco::API::UseCases::GraphQL::Compat
2
+ module Parity
3
+ # A/B parity harness for the ooze -> native GraphQL strangler-fig migration.
4
+ #
5
+ # Runs the SAME logical register-processing case through the two code paths —
6
+ # A: legacy (v2 / OozeRedirect shim), B: native GraphQL — and asserts that the
7
+ # observable outcome (KPI counters + per-page update payloads) is equivalent.
8
+ #
9
+ # The comparison logic (RunResult / Comparison) is PURE and offline-runnable.
10
+ # The harness itself is a thin orchestrator: you hand it two callables, each of
11
+ # which runs its side and returns a RunResult. In a live run each callable would
12
+ # execute the case in `-simulate` mode against the same test org, using a
13
+ # Recorder to capture what each side WOULD write (no data is mutated). Wiring the
14
+ # two live runs needs credentials + a test org; the structural machinery here is
15
+ # verified offline.
16
+ #
17
+ # == Usage (live)
18
+ #
19
+ # harness = Parity::Harness.new(
20
+ # legacy: -> { Parity::Harness.record('legacy') { |rec| run_legacy_case(rec) } },
21
+ # native: -> { Parity::Harness.record('native') { |rec| run_native_case(rec) } }
22
+ # )
23
+ # comparison = harness.run
24
+ # raise comparison.report unless comparison.equivalent?
25
+ #
26
+ # == Usage (offline / spec)
27
+ #
28
+ # a = Parity::RunResult.new(label: 'legacy', kpis: {...}, updates: {...})
29
+ # b = Parity::RunResult.new(label: 'native', kpis: {...}, updates: {...})
30
+ # Parity::Harness.new(legacy: -> { a }, native: -> { b }).run.equivalent?
31
+ class Harness
32
+ # @param legacy [#call] returns the legacy-path RunResult.
33
+ # @param native [#call] returns the native-path RunResult.
34
+ def initialize(legacy:, native:)
35
+ @legacy = legacy
36
+ @native = native
37
+ end
38
+
39
+ # Execute both sides and compare. Returns a Parity::Comparison.
40
+ def run
41
+ a = @legacy.call
42
+ b = @native.call
43
+ assert_run_result!(a, 'legacy')
44
+ assert_run_result!(b, 'native')
45
+ a.compare_to(b)
46
+ end
47
+
48
+ # Capture the observable outcome of a case run into a RunResult.
49
+ #
50
+ # Yields a Recorder. The caller runs the case, feeding each page's would-be
51
+ # update payload to `recorder.record_update(page_id, payload)` (typically wired
52
+ # by stubbing the case's `update_page` to call `graphql.pages.get_body(page)`
53
+ # instead of persisting). After the run, pass the case's KPI hash to
54
+ # `recorder.kpis = case.kpis_hash` (or set counters individually).
55
+ #
56
+ # @param label [String] 'legacy' or 'native'.
57
+ # @yieldparam recorder [Recorder]
58
+ # @return [RunResult]
59
+ def self.record(label)
60
+ recorder = Recorder.new(label)
61
+ yield(recorder) if block_given?
62
+ recorder.to_run_result
63
+ end
64
+
65
+ private
66
+
67
+ def assert_run_result!(obj, side)
68
+ return if obj.is_a?(RunResult)
69
+
70
+ raise TypeError, "The #{side} callable must return a Parity::RunResult, got #{obj.class}"
71
+ end
72
+
73
+ # Collects a run's KPIs + per-page update payloads, then bakes a RunResult.
74
+ class Recorder
75
+ attr_accessor :kpis
76
+
77
+ def initialize(label)
78
+ @label = label
79
+ @kpis = {}
80
+ @updates = {}
81
+ end
82
+
83
+ # Record what a run WOULD send for `page_id`. Later ids for the same page
84
+ # overwrite (the last state wins — matching a real save of the final page).
85
+ def record_update(page_id, payload)
86
+ @updates[page_id.to_s] = payload
87
+ self
88
+ end
89
+
90
+ # Set a single canonical/native KPI counter by name.
91
+ def kpi(name, value)
92
+ @kpis[name.to_sym] = value
93
+ self
94
+ end
95
+
96
+ def to_run_result
97
+ RunResult.new(label: @label, kpis: @kpis, updates: @updates)
98
+ end
99
+ end
100
+ end
101
+ end
102
+ end
@@ -0,0 +1,96 @@
1
+ module Eco::API::UseCases::GraphQL::Compat
2
+ module Parity
3
+ # A normalised, comparable snapshot of ONE use-case run.
4
+ #
5
+ # The A/B parity harness reduces a run — whether it went through the legacy
6
+ # (v2 / OozeRedirect) path or the native GraphQL path — to just its OBSERVABLE
7
+ # outcome:
8
+ # * `kpis` — the KPI counter hash (search/retrieved/updated/created/… )
9
+ # * `updates` — the per-page update payload the run WOULD send, keyed by page id
10
+ #
11
+ # Two runs are "equivalent" when, after normalisation, both are identical. This
12
+ # is the unit the strangler-fig migration is gated on: flip a case name only once
13
+ # its native RunResult matches the legacy RunResult over the same input.
14
+ #
15
+ # Everything here is PURE (no session, no network) so equivalence can be asserted
16
+ # offline in specs; the live capture step (feeding real runs in) is the only part
17
+ # that needs credentials.
18
+ class RunResult
19
+ attr_reader :label, :kpis, :updates
20
+
21
+ # @param label [String] human tag for this side ('legacy' / 'native').
22
+ # @param kpis [Hash] KPI counters. Keys are symbolised; values coerced to Integer.
23
+ # @param updates [Hash{String=>Object}] page_id => update payload (any Hash-ish).
24
+ def initialize(label:, kpis: {}, updates: {})
25
+ @label = label.to_s
26
+ @kpis = normalize_kpis(kpis)
27
+ @updates = normalize_updates(updates)
28
+ end
29
+
30
+ # The set of page ids this run acted on.
31
+ def page_ids
32
+ updates.keys
33
+ end
34
+
35
+ # Compare against another RunResult, returning a Comparison value object.
36
+ def compare_to(other)
37
+ Comparison.new(self, other)
38
+ end
39
+
40
+ # Convenience: are the two runs equivalent?
41
+ def equivalent_to?(other)
42
+ compare_to(other).equivalent?
43
+ end
44
+
45
+ # Merge/normalise into a plain hash (handy for golden-file capture).
46
+ def to_h
47
+ { label: label, kpis: kpis, updates: updates }
48
+ end
49
+
50
+ # KPI names differ between the legacy and native cases (e.g. `updated_oozes`
51
+ # vs `updated_pages`). We compare on a CANONICAL subset of counters that both
52
+ # sides expose the same way, mapping each side's names onto the canon.
53
+ CANON_KPI_ALIASES = {
54
+ search: %i[total_search_oozes total_search_pages search],
55
+ duplicated: %i[dupped_search_oozes dupped_search_pages duplicated],
56
+ retrieved: %i[retrieved_oozes retrieved_pages retrieved],
57
+ updated: %i[updated_oozes updated_pages updated],
58
+ created: %i[created_oozes created_pages created],
59
+ skipped: %i[skipped_pages skipped],
60
+ failed: %i[failed_update_oozes failed_pages failed]
61
+ }.freeze
62
+
63
+ private
64
+
65
+ def normalize_kpis(raw)
66
+ source = raw.each_with_object({}) do |(k, v), out|
67
+ out[k.to_sym] = v
68
+ end
69
+
70
+ CANON_KPI_ALIASES.each_with_object({}) do |(canon, aliases), out|
71
+ key = aliases.find {|a| source.key?(a)}
72
+ out[canon] = key ? source[key].to_i : 0
73
+ end
74
+ end
75
+
76
+ # Normalise each update payload so a superficial key-order / string-vs-symbol
77
+ # difference between the two paths does not read as a real divergence.
78
+ def normalize_updates(raw)
79
+ raw.each_with_object({}) do |(id, payload), out|
80
+ out[id.to_s] = deep_normalize(payload)
81
+ end
82
+ end
83
+
84
+ def deep_normalize(value)
85
+ case value
86
+ when Hash
87
+ value.map {|k, v| [k.to_s, deep_normalize(v)]}.sort_by(&:first).to_h
88
+ when Array
89
+ value.map {|v| deep_normalize(v)}
90
+ else
91
+ value
92
+ end
93
+ end
94
+ end
95
+ end
96
+ end
@@ -0,0 +1,11 @@
1
+ module Eco::API::UseCases::GraphQL
2
+ module Compat
3
+ module Parity
4
+ end
5
+ end
6
+ end
7
+
8
+ require_relative 'compat/ooze_redirect'
9
+ require_relative 'compat/parity/run_result'
10
+ require_relative 'compat/parity/comparison'
11
+ require_relative 'compat/parity/harness'
@@ -29,7 +29,7 @@ module Eco::API::UseCases::GraphQL::Helpers::Location
29
29
  messages
30
30
  }
31
31
  draft { # rubocop:disable Style/BlockDelimiters
32
- ___Ecoportal__API__GraphQL__Fragment__LocationDraft
32
+ spread :LocationDraft
33
33
  }
34
34
  }
35
35
  end
@@ -44,7 +44,7 @@ module Eco::API::UseCases::GraphQL::Helpers::Location
44
44
  messages
45
45
  }
46
46
  draft { # rubocop:disable Style/BlockDelimiters
47
- ___Ecoportal__API__GraphQL__Fragment__LocationDraft
47
+ spread :LocationDraft
48
48
  }
49
49
  }
50
50
  end
@@ -54,7 +54,7 @@ module Eco::API::UseCases::GraphQL::Helpers::Location
54
54
  clientMutationId
55
55
  ok
56
56
  error { # rubocop:disable Style/BlockDelimiters
57
- ___Ecoportal__API__GraphQL__Fragment__LocationsError
57
+ spread :LocationsError
58
58
  }
59
59
  errors { # rubocop:disable Style/BlockDelimiters
60
60
  details
@@ -69,7 +69,7 @@ module Eco::API::UseCases::GraphQL::Helpers::Location
69
69
  __typename
70
70
  }
71
71
  error { # rubocop:disable Style/BlockDelimiters
72
- ___Ecoportal__API__GraphQL__Fragment__LocationsError
72
+ spread :LocationsError
73
73
  }
74
74
  }
75
75
  }
@@ -0,0 +1,71 @@
1
+ module Eco::API::UseCases::GraphQL::Helpers
2
+ module Pages
3
+ # Native GraphQL re-expression of OozeSamples::HelpersMigration::Copying.
4
+ #
5
+ # Provides the "pair source fields to destination fields and copy their
6
+ # content across" flow used by the native migration case. Pairing is delegated
7
+ # to `TypedFieldsPairing`; the per-field content copy (`copy_field_content`)
8
+ # dispatches on the GraphQL DataField TYPE STRING (PlainText/Select/Date/...),
9
+ # mirroring how OozeHandlers#merge_values ports the v2 type-class dispatch.
10
+ #
11
+ # Only the generic-typed-pairing flow is ported (the v2 regex/JSON hooked-field
12
+ # mapping — `copy_hooked_fields` / `with_src_dst_field_pair` — is a later
13
+ # increment and is intentionally omitted here).
14
+ module Copying
15
+ include Eco::Language::AuxiliarLogger
16
+ include Eco::API::UseCases::GraphQL::Helpers::Pages::Shortcuts
17
+
18
+ def session
19
+ defined?(super) ? super : @session
20
+ end
21
+
22
+ # Pair the same-type/same-label fields of `src` and `dst`, and copy the
23
+ # content of each pair from source to destination.
24
+ #
25
+ # @param src [Object] the source page model.
26
+ # @param dst [Object] the destination (draft) page model.
27
+ # @param src_excluded [Array] source fields to exclude from pairing.
28
+ # @param dst_excluded [Array] destination fields to exclude from pairing.
29
+ # @param exclude_ximport [Boolean] skip ximport-marked fields.
30
+ # @yield [src_fld, dst_fld, pairing] optional post-copy callback per pair.
31
+ # @return [TypedFieldsPairing] the tracker (unpaired / multi-pair inspection).
32
+ def copy_generic_paired_fields(src, dst, src_excluded: [], dst_excluded: [], exclude_ximport: false)
33
+ TypedFieldsPairing.new(
34
+ src,
35
+ dst,
36
+ src_excluded: src_excluded,
37
+ dst_excluded: dst_excluded,
38
+ exclude_ximport: exclude_ximport
39
+ ).tap do |pairing|
40
+ pairing.each_pair do |src_fld, dst_fld|
41
+ copy_field_content(src_fld, dst_fld)
42
+ yield(src_fld, dst_fld, pairing) if block_given?
43
+ end
44
+ end
45
+ end
46
+
47
+ # Copy the content of `src` into `dst`, dispatching on the GraphQL DataField
48
+ # TYPE STRING. `src` and `dst` are guaranteed to be the same type by the
49
+ # pairing. Types with no meaningful/copyable content (or whose native setter
50
+ # is not yet supported) are left untouched.
51
+ #
52
+ # @param src [#type] the source data field.
53
+ # @param dst [#type] the destination data field.
54
+ # @return [Object] result of the per-type copy (or a "won't copy" marker).
55
+ def copy_field_content(src, dst)
56
+ case src.type.to_s
57
+ when 'PlainText', 'Number', 'Date', 'Gauge'
58
+ dst.value = src.value
59
+ when 'RichText'
60
+ dst.content = src.content
61
+ when 'Select'
62
+ dst.select_option(*src.selected_options.map { |opt| opt['id'] })
63
+ when 'CrossReference'
64
+ dst.page_ids = src.page_ids
65
+ else
66
+ "won't copy"
67
+ end
68
+ end
69
+ end
70
+ end
71
+ end