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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +221 -0
- data/lib/eco/api/usecases/default/pages.rb +30 -0
- data/lib/eco/api/usecases/graphql/compat/ooze_redirect/dirty_array.rb +22 -0
- data/lib/eco/api/usecases/graphql/compat/ooze_redirect/field_patches.rb +241 -0
- data/lib/eco/api/usecases/graphql/compat/ooze_redirect/force_compat.rb +73 -0
- data/lib/eco/api/usecases/graphql/compat/ooze_redirect.rb +223 -0
- data/lib/eco/api/usecases/graphql/compat/parity/comparison.rb +70 -0
- data/lib/eco/api/usecases/graphql/compat/parity/harness.rb +102 -0
- data/lib/eco/api/usecases/graphql/compat/parity/run_result.rb +96 -0
- data/lib/eco/api/usecases/graphql/compat.rb +11 -0
- data/lib/eco/api/usecases/graphql/helpers/location/command/end_points/optimizations.rb +4 -4
- data/lib/eco/api/usecases/graphql/helpers/pages/copying.rb +71 -0
- data/lib/eco/api/usecases/graphql/helpers/pages/creatable.rb +78 -0
- data/lib/eco/api/usecases/graphql/helpers/pages/filters.rb +114 -0
- data/lib/eco/api/usecases/graphql/helpers/pages/ooze_handlers.rb +112 -0
- data/lib/eco/api/usecases/graphql/helpers/pages/rescuable.rb +52 -0
- data/lib/eco/api/usecases/graphql/helpers/pages/shortcuts.rb +186 -0
- data/lib/eco/api/usecases/graphql/helpers/pages/typed_fields_pairing.rb +303 -0
- data/lib/eco/api/usecases/graphql/helpers/pages.rb +21 -0
- data/lib/eco/api/usecases/graphql/helpers.rb +1 -0
- data/lib/eco/api/usecases/graphql/samples/location/command/service/tree_update.rb +1 -1
- data/lib/eco/api/usecases/graphql/samples/pages/org_page/base.rb +41 -0
- data/lib/eco/api/usecases/graphql/samples/pages/org_page/dsl.rb +8 -0
- data/lib/eco/api/usecases/graphql/samples/pages/org_page.rb +7 -0
- data/lib/eco/api/usecases/graphql/samples/pages/page/base.rb +148 -0
- data/lib/eco/api/usecases/graphql/samples/pages/page/dsl.rb +38 -0
- data/lib/eco/api/usecases/graphql/samples/pages/page.rb +7 -0
- data/lib/eco/api/usecases/graphql/samples/pages/register/base.rb +181 -0
- data/lib/eco/api/usecases/graphql/samples/pages/register/migration_case.rb +132 -0
- data/lib/eco/api/usecases/graphql/samples/pages/register/target_oozes_update_case.rb +163 -0
- data/lib/eco/api/usecases/graphql/samples/pages/register.rb +8 -0
- data/lib/eco/api/usecases/graphql/samples/pages/template/base.rb +70 -0
- data/lib/eco/api/usecases/graphql/samples/pages/template/command_emitter.rb +139 -0
- data/lib/eco/api/usecases/graphql/samples/pages/template/csv_build/builder.rb +126 -0
- data/lib/eco/api/usecases/graphql/samples/pages/template/csv_build/format_map.rb +108 -0
- data/lib/eco/api/usecases/graphql/samples/pages/template/csv_build/parser.rb +98 -0
- data/lib/eco/api/usecases/graphql/samples/pages/template/csv_build.rb +17 -0
- data/lib/eco/api/usecases/graphql/samples/pages/template/deploy/applier.rb +141 -0
- data/lib/eco/api/usecases/graphql/samples/pages/template/deploy/drift_report.rb +104 -0
- data/lib/eco/api/usecases/graphql/samples/pages/template/deploy/loop.rb +155 -0
- data/lib/eco/api/usecases/graphql/samples/pages/template/deploy/recording_executor.rb +58 -0
- data/lib/eco/api/usecases/graphql/samples/pages/template/deploy/sync_readiness.rb +178 -0
- data/lib/eco/api/usecases/graphql/samples/pages/template/deploy/verifier.rb +141 -0
- data/lib/eco/api/usecases/graphql/samples/pages/template/deploy.rb +21 -0
- data/lib/eco/api/usecases/graphql/samples/pages/template.rb +11 -0
- data/lib/eco/api/usecases/graphql/samples/pages.rb +9 -0
- data/lib/eco/api/usecases/graphql/samples.rb +1 -0
- data/lib/eco/api/usecases/graphql.rb +1 -0
- data/lib/eco/api/usecases/ooze_samples/ooze_base_case.rb +4 -0
- data/lib/eco/api/usecases/ooze_samples/register_update_case.rb +13 -3
- data/lib/eco/version.rb +1 -1
- metadata +47 -15
- data/.gitignore +0 -23
- data/.idea/.gitignore +0 -10
- data/.markdownlint.json +0 -4
- data/.rspec +0 -3
- data/.rubocop.yml +0 -103
- data/.ruby-version +0 -1
- data/.yardopts +0 -10
- data/Gemfile +0 -8
- data/Rakefile +0 -38
- 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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|