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,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
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
module Eco::API::UseCases::GraphQL::Samples::Pages
|
|
2
|
+
# Native GraphQL re-expression of OozeSamples::TargetOozesUpdateCase.
|
|
3
|
+
#
|
|
4
|
+
# Where Register::Base scopes work to a register and iterates its entries via a
|
|
5
|
+
# cursor search, this case takes an explicit list of target page ids from a CSV
|
|
6
|
+
# (`options[:source][:file]`, first column, header dropped) and processes each of
|
|
7
|
+
# those pages. It reproduces the SHAPE of the v2 TargetOozesUpdateCase:
|
|
8
|
+
# * id-batching (`batched_target_ids`)
|
|
9
|
+
# * a preview with duplicate detection + interactive proceed prompt
|
|
10
|
+
# * fetch-per-id (`graphql.pages.get`), dedup-by-id, retrieved/non-retrieved KPIs
|
|
11
|
+
# * hand each fetched page to `process_page` (a.k.a. `process_ooze`)
|
|
12
|
+
#
|
|
13
|
+
# As with Register::Base, the v2 batch_queue / enqueue / queue_shift machinery is
|
|
14
|
+
# intentionally dropped: the GraphQL update path is per-page. `update_page(page)`
|
|
15
|
+
# (from Page::Base) persists changes as the subclass makes them.
|
|
16
|
+
#
|
|
17
|
+
# == Subclass interface
|
|
18
|
+
#
|
|
19
|
+
# class Custom::UseCase::TOOCSCoding < Eco::API::UseCases::GraphQL::Samples::Pages::Register::TargetOozesUpdateCase
|
|
20
|
+
# name 'toocs-coding'
|
|
21
|
+
#
|
|
22
|
+
# def process_page(page)
|
|
23
|
+
# fld = page.components.get_by_name('TOOCS Code')
|
|
24
|
+
# return skip('no field') unless fld
|
|
25
|
+
# fld.value = merge_field_values(fld, options.dig(:source, :value))
|
|
26
|
+
# update_page(page)
|
|
27
|
+
# end
|
|
28
|
+
# end
|
|
29
|
+
#
|
|
30
|
+
# The CSV file is provided via `options[:source][:file]`.
|
|
31
|
+
class Register::TargetOozesUpdateCase < Register::Base
|
|
32
|
+
name 'graphql-target-oozes-update-case'
|
|
33
|
+
type :other
|
|
34
|
+
|
|
35
|
+
attr_reader :non_retrieved_pages
|
|
36
|
+
|
|
37
|
+
# Entry point — mirrors Register::Base#process but iterates target ids rather
|
|
38
|
+
# than a register search.
|
|
39
|
+
def process
|
|
40
|
+
init_kpis
|
|
41
|
+
target_ids_preview
|
|
42
|
+
each_target_page do |page|
|
|
43
|
+
process_page(page)
|
|
44
|
+
@processed_pages += 1
|
|
45
|
+
end
|
|
46
|
+
log_kpis
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
protected
|
|
50
|
+
|
|
51
|
+
# Fetch and iterate each target page by id, in batches. Counts retrieved /
|
|
52
|
+
# non-retrieved and dedups by id (v2 parity).
|
|
53
|
+
def each_target_page
|
|
54
|
+
batched_target_ids do |ids|
|
|
55
|
+
ids.each do |id|
|
|
56
|
+
if page_result_ids[id]
|
|
57
|
+
@dupped_search_pages += 1
|
|
58
|
+
else
|
|
59
|
+
page_result_ids[id] = true
|
|
60
|
+
@total_pages += 1
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
if (page = fetch_page(id))
|
|
64
|
+
@retrieved_pages += 1
|
|
65
|
+
yield page
|
|
66
|
+
else
|
|
67
|
+
@non_retrieved_pages += 1
|
|
68
|
+
log(:warn) { "Could not get page #{id}" }
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# Fetch one page by id via the GraphQL compat client. Wrapped so a bad id does
|
|
75
|
+
# not abort the whole run.
|
|
76
|
+
# @return [Object, nil] the page model, or nil when it could not be fetched.
|
|
77
|
+
def fetch_page(id)
|
|
78
|
+
with_rescue("page #{id}") do
|
|
79
|
+
graphql.pages.get(id)
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
private
|
|
84
|
+
|
|
85
|
+
# Yields the target ids in slices of `batch_size`.
|
|
86
|
+
def batched_target_ids
|
|
87
|
+
msg = "Missing block. It yields in slices of #{self.class.batch_size} ids"
|
|
88
|
+
raise ArgumentError, msg unless block_given?
|
|
89
|
+
|
|
90
|
+
pool = []
|
|
91
|
+
target_ids.each do |id|
|
|
92
|
+
pool << id
|
|
93
|
+
if pool.length >= self.class.batch_size
|
|
94
|
+
yield(pool)
|
|
95
|
+
pool = []
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
yield(pool) unless pool.empty?
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# Preview: total count + duplicate detection + interactive proceed prompt
|
|
102
|
+
# (v2-faithful — matches TargetOozesUpdateCase#target_ids_preview).
|
|
103
|
+
def target_ids_preview
|
|
104
|
+
dups = target_ids.select {|id| target_ids.count(id) > 1}
|
|
105
|
+
@total_search_pages = target_ids.count
|
|
106
|
+
@dupped_search_pages = 0
|
|
107
|
+
|
|
108
|
+
dups_str = dups.any? ? "There are #{dups.count} duplicated ids" : 'No duplicates detected'
|
|
109
|
+
msg = "Total target entries: #{target_ids.count} (#{dups_str})"
|
|
110
|
+
|
|
111
|
+
default_answer = session.config.run_mode_remote? ? 'Y' : 'N'
|
|
112
|
+
|
|
113
|
+
session.prompt_user(
|
|
114
|
+
'Do you want to proceed (y/N):',
|
|
115
|
+
explanation: msg,
|
|
116
|
+
default: default_answer,
|
|
117
|
+
timeout: 10
|
|
118
|
+
) do |res|
|
|
119
|
+
unless res.upcase.start_with?('Y')
|
|
120
|
+
puts '...'
|
|
121
|
+
log(:info) { 'Aborting script...' }
|
|
122
|
+
exit(0)
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
# The list of target page ids (first CSV column, header dropped).
|
|
128
|
+
def target_ids
|
|
129
|
+
@target_ids ||= input_csv.columns.first[1..]
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def input_csv
|
|
133
|
+
@input_csv ||= Eco::CSV.read(options.dig(:source, :file))
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
# #each_page is register-search based (inherited from Register::Base) and is NOT
|
|
137
|
+
# the iteration path here — target ids come from the CSV. Guard against accidental
|
|
138
|
+
# use so a subclass mixing the two gets a clear error rather than a silent org scan.
|
|
139
|
+
def each_page(*)
|
|
140
|
+
raise NotImplementedError,
|
|
141
|
+
"#{self.class} iterates target ids from a CSV — use #each_target_page, not #each_page"
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def init_kpis
|
|
145
|
+
super
|
|
146
|
+
@non_retrieved_pages = 0
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
def kpis_message
|
|
150
|
+
[
|
|
151
|
+
'Run end:',
|
|
152
|
+
" * Target entries: #{total_search_pages}",
|
|
153
|
+
" * Duplicated: #{dupped_search_pages}",
|
|
154
|
+
" * Retrieved: #{retrieved_pages}",
|
|
155
|
+
" * Could not get: #{non_retrieved_pages}",
|
|
156
|
+
" * Processed: #{processed_pages}",
|
|
157
|
+
" * Updated: #{updated_pages} (of #{total_pages} unique)",
|
|
158
|
+
" * Skipped: #{skipped_pages}",
|
|
159
|
+
" * Failed: #{failed_pages}"
|
|
160
|
+
].join("\n")
|
|
161
|
+
end
|
|
162
|
+
end
|
|
163
|
+
end
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
module Eco::API::UseCases::GraphQL::Samples::Pages
|
|
2
|
+
# Build-from-scratch template (workflow) construction use case.
|
|
3
|
+
#
|
|
4
|
+
# A subclass declares the desired template structure in #declare(emitter) using the
|
|
5
|
+
# CommandEmitter DSL; the base turns it into an ordered command batch and applies it via
|
|
6
|
+
# graphql.template.create / update (executeWorkflowCommands). `simulate?` previews the batch
|
|
7
|
+
# without writing. The emitted batch (#desired_commands) is unit-testable with no live client.
|
|
8
|
+
#
|
|
9
|
+
# class Custom::UseCase::BuildIncidentTemplate < Samples::Pages::Template::Base
|
|
10
|
+
# name 'build-incident-template'
|
|
11
|
+
#
|
|
12
|
+
# def declare(t)
|
|
13
|
+
# t.stage(name: 'Report', ordering: 0) do |stage|
|
|
14
|
+
# stage.section(layout: 'content') do |section|
|
|
15
|
+
# section.field(label: 'Description', field_type: 'plain_text')
|
|
16
|
+
# end
|
|
17
|
+
# end
|
|
18
|
+
# end
|
|
19
|
+
# end
|
|
20
|
+
class Template::Base < Eco::API::UseCases::GraphQL::Base
|
|
21
|
+
name 'graphql-template-base'
|
|
22
|
+
type :other
|
|
23
|
+
|
|
24
|
+
require_relative 'command_emitter'
|
|
25
|
+
|
|
26
|
+
def process
|
|
27
|
+
commands = desired_commands
|
|
28
|
+
if simulate?
|
|
29
|
+
log(:info) { preview_message(commands) }
|
|
30
|
+
return nil
|
|
31
|
+
end
|
|
32
|
+
apply(commands)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# == Subclass override point ================================================
|
|
36
|
+
|
|
37
|
+
# Declare the desired template structure onto the given CommandEmitter.
|
|
38
|
+
def declare(_emitter)
|
|
39
|
+
raise NotImplementedError, "Implement #declare(emitter) in #{self.class}"
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# The ordered command batch this template would apply. Pure — no client needed.
|
|
43
|
+
def desired_commands
|
|
44
|
+
emitter = CommandEmitter.new
|
|
45
|
+
declare(emitter)
|
|
46
|
+
emitter.commands
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
protected
|
|
50
|
+
|
|
51
|
+
# Persist a fresh template from the command batch. Override to #update an existing one.
|
|
52
|
+
def apply(commands)
|
|
53
|
+
graphql.template.create(commands: commands)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Update an existing template model with the command batch.
|
|
57
|
+
def apply_update(model, commands)
|
|
58
|
+
graphql.template.update(model, commands: commands)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
private
|
|
62
|
+
|
|
63
|
+
def preview_message(commands)
|
|
64
|
+
[
|
|
65
|
+
"Simulate — would apply #{commands.size} workflow command(s):",
|
|
66
|
+
*commands.map { |c| " * #{c.keys.first}: #{c.values.first.inspect}" }
|
|
67
|
+
].join("\n")
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
module Eco::API::UseCases::GraphQL::Samples::Pages
|
|
2
|
+
module Template
|
|
3
|
+
# Declarative build-from-scratch emitter for template (workflow) structure.
|
|
4
|
+
#
|
|
5
|
+
# Mirrors the *shape* of the location diff->commands engine (samples/location/command), but the
|
|
6
|
+
# id-threading is **placeholder-based**, not post-hoc newId-remap: each new node is given a
|
|
7
|
+
# client-chosen `placeholderId`, and later commands in the SAME batch reference it. The gem's
|
|
8
|
+
# executeWorkflowCommands resolves placeholders intra-batch. See the gem's PHASE0-FINDINGS.md.
|
|
9
|
+
#
|
|
10
|
+
# Commands are emitted in dependency order via depth-first DSL evaluation:
|
|
11
|
+
# addStage -> (per section) addSection, addStageSection -> (per field) addField
|
|
12
|
+
# -> (per option) addSelectFieldOption ; then forces: addForce -> (per binding) addBinding.
|
|
13
|
+
#
|
|
14
|
+
# Usage:
|
|
15
|
+
# emitter = CommandEmitter.new
|
|
16
|
+
# emitter.stage(name: 'Report', ordering: 0) do |stage|
|
|
17
|
+
# stage.section(layout: 'content') do |section|
|
|
18
|
+
# section.field(label: 'Notes', field_type: 'plain_text')
|
|
19
|
+
# section.field(label: 'Risk', field_type: 'select') do |field|
|
|
20
|
+
# field.option(label: 'High', weight: 10)
|
|
21
|
+
# end
|
|
22
|
+
# end
|
|
23
|
+
# end
|
|
24
|
+
# emitter.commands # => ordered Array of FINAL built { commandKey => kwargs } commands
|
|
25
|
+
#
|
|
26
|
+
# The output is fed DIRECTLY to graphql.template.create(commands:) / update(model, commands:).
|
|
27
|
+
# It is already built via WorkflowCommand.build — do NOT re-run it through build_commands
|
|
28
|
+
# (that normaliser is for hand-authored raw specs and is non-idempotent for key-renaming
|
|
29
|
+
# commands such as addSelectFieldOption: data_field_id -> dataFieldId).
|
|
30
|
+
class CommandEmitter
|
|
31
|
+
def initialize
|
|
32
|
+
@commands = []
|
|
33
|
+
@counters = Hash.new(0)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# @return [Array<Hash>] ordered command specs, each { commandKey => kwargs }
|
|
37
|
+
attr_reader :commands
|
|
38
|
+
|
|
39
|
+
# Declare a stage. Yields a StageBuilder for nested sections.
|
|
40
|
+
def stage(name:, ordering: nil, &block)
|
|
41
|
+
placeholder = allocate(:stg)
|
|
42
|
+
emit(:addStage, placeholderId: placeholder, name: name, ordering: ordering)
|
|
43
|
+
StageBuilder.new(self, placeholder).tap { |b| block&.call(b) }
|
|
44
|
+
placeholder
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Declare a force at template level. Yields a ForceBuilder for bindings.
|
|
48
|
+
def force(name:, custom_script: nil, url: nil, content_b64: nil, &block)
|
|
49
|
+
placeholder = allocate(:frc)
|
|
50
|
+
emit(:addForce, placeholderId: placeholder, name: name, customScript: custom_script,
|
|
51
|
+
url: url, contentB64: content_b64)
|
|
52
|
+
ForceBuilder.new(self, placeholder).tap { |b| block&.call(b) }
|
|
53
|
+
placeholder
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# --- internal API used by the nested builders ---------------------------
|
|
57
|
+
|
|
58
|
+
# Allocate the next placeholder id for a node kind (:stg, :sec, :fld, :frc, :bnd).
|
|
59
|
+
def allocate(kind)
|
|
60
|
+
"#{kind}#{@counters[kind] += 1}"
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# Build + append one command via the gem's WorkflowCommand contract.
|
|
64
|
+
# The gem constant is resolved lazily (at call time, not load time) — the gem's GraphQL
|
|
65
|
+
# namespace is not yet loaded while this file is being required.
|
|
66
|
+
def emit(command_key, **kwargs)
|
|
67
|
+
@commands << Ecoportal::API::GraphQL::Input::WorkflowCommand.build(command_key, **kwargs)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# Nested builders — each holds its parent's placeholder and appends in-order.
|
|
71
|
+
|
|
72
|
+
class StageBuilder
|
|
73
|
+
def initialize(emitter, stage_id)
|
|
74
|
+
@emitter = emitter
|
|
75
|
+
@stage_id = stage_id
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# A section is created independently, then linked into this stage.
|
|
79
|
+
def section(layout: 'content', &block)
|
|
80
|
+
section_id = @emitter.allocate(:sec)
|
|
81
|
+
@emitter.emit(:addSection, placeholderId: section_id, layout: layout)
|
|
82
|
+
@emitter.emit(:addStageSection, stageId: @stage_id, sectionId: section_id)
|
|
83
|
+
SectionBuilder.new(@emitter, @stage_id, section_id).tap { |b| block&.call(b) }
|
|
84
|
+
section_id
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
class SectionBuilder
|
|
89
|
+
def initialize(emitter, stage_id, section_id)
|
|
90
|
+
@emitter = emitter
|
|
91
|
+
@stage_id = stage_id
|
|
92
|
+
@section_id = section_id
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# `description` / `required` are accepted for forward-compatibility with the CSV-build
|
|
96
|
+
# identity convention (hidden-field + description). NOTE: the RELEASED gem's addField input
|
|
97
|
+
# (`Input::WorkflowCommand::AddField::VALID_KEYS`) currently slices to
|
|
98
|
+
# placeholderId/fieldType/label/stageId/sectionId/column and compacts everything else away —
|
|
99
|
+
# so today these pass-throughs are DROPPED by the gem contract (harmless no-op). They are wired
|
|
100
|
+
# here so that, once the gem's addField gains a description key, no emitter change is needed.
|
|
101
|
+
def field(label:, field_type:, column: 0, description: nil, required: nil, &block)
|
|
102
|
+
field_id = @emitter.allocate(:fld)
|
|
103
|
+
@emitter.emit(:addField, placeholderId: field_id, fieldType: field_type, label: label,
|
|
104
|
+
stageId: @stage_id, sectionId: @section_id, column: column,
|
|
105
|
+
description: description, required: required)
|
|
106
|
+
FieldBuilder.new(@emitter, field_id).tap { |b| block&.call(b) }
|
|
107
|
+
field_id
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
class FieldBuilder
|
|
112
|
+
def initialize(emitter, field_id)
|
|
113
|
+
@emitter = emitter
|
|
114
|
+
@field_id = field_id
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
# Select-field option. dataFieldId threads the (placeholder) field id.
|
|
118
|
+
def option(label:, weight: nil)
|
|
119
|
+
@emitter.emit(:addSelectFieldOption, data_field_id: @field_id, label: label, weight: weight)
|
|
120
|
+
@field_id
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
class ForceBuilder
|
|
125
|
+
def initialize(emitter, force_id)
|
|
126
|
+
@emitter = emitter
|
|
127
|
+
@force_id = force_id
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def binding(name:, reference_id: nil, type: nil)
|
|
131
|
+
binding_id = @emitter.allocate(:bnd)
|
|
132
|
+
@emitter.emit(:addBinding, placeholderId: binding_id, forceId: @force_id, name: name,
|
|
133
|
+
referenceId: reference_id, type: type)
|
|
134
|
+
binding_id
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
end
|