ecoportal-api-graphql 2.2.1 → 3.0.0
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 +405 -9
- data/README.gem.md +53 -0
- data/lib/ecoportal/api/common/graphql/auth_service.rb +1 -1
- data/lib/ecoportal/api/common/graphql/client.rb +38 -0
- data/lib/ecoportal/api/common/graphql/http_client.rb +39 -6
- data/lib/ecoportal/api/graphql/base/page/data_field/image_gallery.rb +159 -11
- data/lib/ecoportal/api/graphql/base/temp_image.rb +28 -0
- data/lib/ecoportal/api/graphql/base.rb +1 -0
- data/lib/ecoportal/api/graphql/builder/template.rb +9 -4
- data/lib/ecoportal/api/graphql/compat/filter_translator.rb +1 -1
- data/lib/ecoportal/api/graphql/file_upload/client.rb +140 -35
- data/lib/ecoportal/api/graphql/fragment/pages/common_page_union.rb +6 -1
- data/lib/ecoportal/api/graphql/input/page/update.rb +110 -7
- data/lib/ecoportal/api/graphql/input/search_conf.rb +1 -1
- data/lib/ecoportal/api/graphql/model/temp_image.rb +10 -0
- data/lib/ecoportal/api/graphql/model/template/binding.rb +60 -0
- data/lib/ecoportal/api/graphql/model/template/command_grouper.rb +107 -0
- data/lib/ecoportal/api/graphql/model/template/command_normalizer.rb +116 -0
- data/lib/ecoportal/api/graphql/model/template/command_synthesis.rb +262 -0
- data/lib/ecoportal/api/graphql/model/template/field.rb +68 -0
- data/lib/ecoportal/api/graphql/model/template/force.rb +65 -0
- data/lib/ecoportal/api/graphql/model/template/helper.rb +32 -0
- data/lib/ecoportal/api/graphql/model/template/instance.rb +202 -0
- data/lib/ecoportal/api/graphql/model/template/node.rb +78 -0
- data/lib/ecoportal/api/graphql/model/template/option.rb +49 -0
- data/lib/ecoportal/api/graphql/model/template/read.rb +164 -0
- data/lib/ecoportal/api/graphql/model/template/section.rb +82 -0
- data/lib/ecoportal/api/graphql/model/template/stage.rb +49 -0
- data/lib/ecoportal/api/graphql/model/template/staged_executor.rb +236 -0
- data/lib/ecoportal/api/graphql/model/template.rb +33 -0
- data/lib/ecoportal/api/graphql/model.rb +1 -0
- data/lib/ecoportal/api/graphql/mutation/file_container/upload.rb +11 -4
- data/lib/ecoportal/api/graphql/mutation/image/upload.rb +88 -0
- data/lib/ecoportal/api/graphql/mutation/image.rb +14 -0
- data/lib/ecoportal/api/graphql/mutation/template/create.rb +35 -3
- data/lib/ecoportal/api/graphql/mutation/template/update.rb +4 -2
- data/lib/ecoportal/api/graphql/mutation.rb +1 -0
- data/lib/ecoportal/api/graphql/payload/images_upload.rb +14 -0
- data/lib/ecoportal/api/graphql/payload.rb +1 -0
- data/lib/ecoportal/api/graphql_version.rb +1 -1
- metadata +35 -2
- data/README.md +0 -24
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
require 'securerandom'
|
|
2
|
+
|
|
1
3
|
module Ecoportal
|
|
2
4
|
module API
|
|
3
5
|
class GraphQL
|
|
@@ -15,7 +17,19 @@ module Ecoportal
|
|
|
15
17
|
# patchVer is always included when present (required for concurrency control).
|
|
16
18
|
#
|
|
17
19
|
# Options:
|
|
18
|
-
# stage_id: ID — target a specific stage
|
|
20
|
+
# stage_id: ID — target a specific stage. Optional even for a
|
|
21
|
+
# PHASED page: when omitted, it is DERIVED from
|
|
22
|
+
# the owning stage of the data fields being
|
|
23
|
+
# updated in this same call (see #derive_stage_id)
|
|
24
|
+
# — a PHASED `updatePage` has been observed to 500
|
|
25
|
+
# without a `stageId` on an Image Gallery / File
|
|
26
|
+
# field write (live-diagnosed, page
|
|
27
|
+
# a customer's phased page), and sending it
|
|
28
|
+
# whenever derivable is always safe even where it
|
|
29
|
+
# turns out not to be strictly required. Pass this
|
|
30
|
+
# explicitly to override the derivation, or to
|
|
31
|
+
# target a stage submit/close-out when no field on
|
|
32
|
+
# that stage is dirty yet.
|
|
19
33
|
# submit: Boolean — submit the stage (triggers task transitions server-side)
|
|
20
34
|
# publish: Boolean — publish a draft page
|
|
21
35
|
# show_hidden_data: Boolean — include hidden data in the mutation response
|
|
@@ -37,17 +51,31 @@ module Ecoportal
|
|
|
37
51
|
# location_ids: [ID] — set page locations (PageInput.locations); the
|
|
38
52
|
# model's locations embed is read_only, so this is
|
|
39
53
|
# the way to write them
|
|
40
|
-
# client_mutation_id: String
|
|
54
|
+
# client_mutation_id: String — correlation id for tracing this call in logs /
|
|
55
|
+
# the server's own activity trail. Defaults to a
|
|
56
|
+
# fresh `SecureRandom.uuid` PER CALL — every
|
|
57
|
+
# mutation this input builds is now traceable by
|
|
58
|
+
# default, not just the ones a caller remembered
|
|
59
|
+
# to tag. Pass an explicit id to correlate several
|
|
60
|
+
# calls under one value, or `''`/`nil` to omit the
|
|
61
|
+
# key entirely. NOTE: this is for CORRELATION only
|
|
62
|
+
# — the server does NOT dedupe mutations on it, so
|
|
63
|
+
# it buys no idempotency; a retried call with the
|
|
64
|
+
# same clientMutationId still re-applies the write.
|
|
65
|
+
# Any idempotency has to be client-side (e.g. this
|
|
66
|
+
# gem's own dirty-tracking not re-sending a field
|
|
67
|
+
# that already round-tripped).
|
|
41
68
|
def from_model(model, stage_id: nil, submit: nil, publish: nil, show_hidden_data: nil,
|
|
42
69
|
task: nil, complete_page_task: false, sign_off: nil, review_notes: nil,
|
|
43
|
-
location_ids: nil, client_mutation_id:
|
|
70
|
+
location_ids: nil, client_mutation_id: SecureRandom.uuid, **_kargs)
|
|
44
71
|
task = resolve_task(task, complete_page_task, sign_off, review_notes)
|
|
45
|
-
require_stage!(stage_id, submit, task)
|
|
46
72
|
page_input = (model.as_update || {}).slice(*PAGE_FIELD_KEYS)
|
|
47
73
|
# Page locations are read_only on the model (excluded from the as_update diff),
|
|
48
74
|
# so set them explicitly here → PageInput.locations = [LocationInput{ id }].
|
|
49
75
|
page_input = page_input.merge(locations: Array(location_ids).map { |lid| { id: lid } }) unless location_ids.nil?
|
|
50
76
|
data_fields = build_data_fields(model)
|
|
77
|
+
stage_id ||= derive_stage_id(model, data_fields)
|
|
78
|
+
require_stage!(stage_id, submit, task)
|
|
51
79
|
|
|
52
80
|
no_changes = page_input.empty? && data_fields.empty?
|
|
53
81
|
no_op = publish.nil? && submit.nil? && stage_id.nil? && task.nil?
|
|
@@ -66,13 +94,88 @@ module Ecoportal
|
|
|
66
94
|
|
|
67
95
|
private
|
|
68
96
|
|
|
97
|
+
# Derives the default `stageId` for a PHASED page update from the STAGE(S) that
|
|
98
|
+
# own the data fields being changed in THIS call (updates/additions/deletions).
|
|
99
|
+
#
|
|
100
|
+
# Platform fact, live-diagnosed against a customer's phased page (the
|
|
101
|
+
# org's own service account): `updatePage` on a PHASED page 500s when `stageId`
|
|
102
|
+
# is missing on an Image Gallery / File field write. The live-verified
|
|
103
|
+
# a customer collection's integration guide (§5) documents a related but
|
|
104
|
+
# narrower nuance — omitting `stageId` is judged against the ROOT page's
|
|
105
|
+
# `PagePolicy#update?` rather than being an unconditional failure, so it is
|
|
106
|
+
# "optional but preferable" for an account whose edit right arrives through
|
|
107
|
+
# page-level administration rather than a stage. Sending it whenever it CAN be
|
|
108
|
+
# derived is correct either way: never wrong for an account that did not need
|
|
109
|
+
# it, and fixes the account/field combination that does. A `BasicPage` has no
|
|
110
|
+
# `#stages` at all — returns `nil` (stageId OMITTED from the input entirely,
|
|
111
|
+
# never sent as an explicit `null`; `build_input`'s nil-guard already drops a
|
|
112
|
+
# nil operation key).
|
|
113
|
+
#
|
|
114
|
+
# Multi-stage calls: if the fields being updated in this ONE call span MORE
|
|
115
|
+
# THAN ONE stage, this raises rather than guessing which stage's id to send (a
|
|
116
|
+
# single `updatePage` input can only carry one `stageId`). The caller must split
|
|
117
|
+
# such a change into one `from_model`/mutation call PER STAGE, in stage order,
|
|
118
|
+
# threading the model's (fresh, server-returned) `patchVer` from each response
|
|
119
|
+
# into the next call — the same pattern the org-side reference implementation
|
|
120
|
+
# (a downstream script repo's `-file-attach` case, `persist_file_updates`/
|
|
121
|
+
# `persist_gallery`) already uses at the call-site level, re-fetching the page
|
|
122
|
+
# between groups. Pass `stage_id:` explicitly to bypass this derivation for any
|
|
123
|
+
# other need (e.g. targeting a stage submit with no dirty field on it yet).
|
|
124
|
+
def derive_stage_id(model, data_fields)
|
|
125
|
+
return nil unless model.respond_to?(:stages)
|
|
126
|
+
|
|
127
|
+
field_ids = changed_field_ids(data_fields)
|
|
128
|
+
return nil if field_ids.empty?
|
|
129
|
+
|
|
130
|
+
owning_stage_ids = Array(model.stages).filter_map do |stage|
|
|
131
|
+
stage.id if Array(stage.stage_field_ids).intersect?(field_ids)
|
|
132
|
+
end.uniq
|
|
133
|
+
|
|
134
|
+
case owning_stage_ids.size
|
|
135
|
+
when 0 then nil
|
|
136
|
+
when 1 then owning_stage_ids.first
|
|
137
|
+
else
|
|
138
|
+
raise ArgumentError,
|
|
139
|
+
"This update's data fields span #{owning_stage_ids.size} different " \
|
|
140
|
+
"stages (#{owning_stage_ids.join(', ')}) — a single updatePage call " \
|
|
141
|
+
'can only target one stageId. Split into one from_model/mutation call ' \
|
|
142
|
+
"per stage, in stage order, re-fetching the page (fresh patchVer) " \
|
|
143
|
+
'between calls, or pass stage_id: explicitly to target just one.'
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
# Every field id touched by this call's dataFields command (updates, additions,
|
|
148
|
+
# deletions) — the raw DataFieldInput hashes are `{ typeKey: { id:, ... } }`, so
|
|
149
|
+
# `id` is read generically off the first (only) value, regardless of field type.
|
|
150
|
+
def changed_field_ids(data_fields)
|
|
151
|
+
ids = Array(data_fields[:updates]).filter_map { |cmd| field_id_from_command(cmd) }
|
|
152
|
+
ids.concat(Array(data_fields[:additions]).filter_map { |cmd| field_id_from_command(cmd) })
|
|
153
|
+
ids.concat(Array(data_fields[:deletions]))
|
|
154
|
+
ids.compact.uniq
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
def field_id_from_command(command)
|
|
158
|
+
return nil unless command.is_a?(Hash)
|
|
159
|
+
|
|
160
|
+
inner = command.values.first
|
|
161
|
+
inner.is_a?(Hash) ? inner[:id] : nil
|
|
162
|
+
end
|
|
163
|
+
|
|
69
164
|
# Platform invariant: a stage submit / sign-off MUST target a specific stage.
|
|
70
165
|
# Page tasks (and the people-field permissions scoped to them) apply to ONE
|
|
71
166
|
# stage; without a stageId the server cannot route the submit to the stage the
|
|
72
167
|
# work was done on. Fail loudly here rather than silently omit stageId (the
|
|
73
|
-
# nil-guard in build_input would otherwise drop it — the exact bug that let
|
|
74
|
-
# customer
|
|
75
|
-
#
|
|
168
|
+
# nil-guard in build_input would otherwise drop it — the exact bug that let an
|
|
169
|
+
# some customer submissions go out stage-less).
|
|
170
|
+
#
|
|
171
|
+
# CORRECTED comment (this used to say "a plain field update ... needs no stage
|
|
172
|
+
# and is unaffected" — wrong for a PHASED page: `updatePage` 500s there without
|
|
173
|
+
# a `stageId` on ANY write, plain field update included; see `#derive_stage_id`,
|
|
174
|
+
# which is called BEFORE this guard and already fills it in from the fields
|
|
175
|
+
# being changed whenever it can). This guard now only fires for the case
|
|
176
|
+
# `#derive_stage_id` genuinely cannot resolve — no dirty field to derive from
|
|
177
|
+
# (a stage-submit-only call) or a `BasicPage` (no stages at all, where a submit
|
|
178
|
+
# makes no sense anyway) — same platform invariant, just reached less often now.
|
|
76
179
|
def require_stage!(stage_id, submit, task)
|
|
77
180
|
return unless stage_id.nil?
|
|
78
181
|
return unless submit || task
|
|
@@ -96,7 +96,7 @@ module Ecoportal
|
|
|
96
96
|
# A register FIELD key carries a `<type>.<hash>` shape (e.g. 'date.zab1bddc3');
|
|
97
97
|
# system/top-level keys (created_at, external_id, state) have no such prefix.
|
|
98
98
|
# Field filters need the nested `membranes.<type>` path — see
|
|
99
|
-
# the repo's internal docs
|
|
99
|
+
# the repo's internal docs
|
|
100
100
|
# ---------------------------------------------------------------------------
|
|
101
101
|
module MembranePath
|
|
102
102
|
module_function
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
module Ecoportal
|
|
2
|
+
module API
|
|
3
|
+
class GraphQL
|
|
4
|
+
module Model
|
|
5
|
+
module Template
|
|
6
|
+
# A force binding — `addBinding`/`editBinding`'s `referenceId` is the field (or field
|
|
7
|
+
# placeholder) the binding points at. `reference_id=` accepts either a raw id/String
|
|
8
|
+
# OR a `Field` (or any object answering `#ref`) so a caller can write
|
|
9
|
+
# `binding.reference_id = some_field` and let the SAME just-created-or-real-id
|
|
10
|
+
# resolution `Field#ref` already does apply here automatically — no separate
|
|
11
|
+
# "resolve the field id" step for the caller to remember.
|
|
12
|
+
class Binding
|
|
13
|
+
include Node
|
|
14
|
+
|
|
15
|
+
PLACEHOLDER_PREFIX = 'bnd'.freeze
|
|
16
|
+
|
|
17
|
+
BINDING_TYPE = 'field'.freeze
|
|
18
|
+
|
|
19
|
+
attr_reader :template, :force, :type
|
|
20
|
+
attr_accessor :name
|
|
21
|
+
|
|
22
|
+
def initialize(template:, force:, id: nil, name: nil, reference_id: nil, type: BINDING_TYPE)
|
|
23
|
+
@template = template
|
|
24
|
+
@force = force
|
|
25
|
+
@id = id
|
|
26
|
+
@name = name
|
|
27
|
+
@type = type
|
|
28
|
+
self.reference_id = reference_id
|
|
29
|
+
@snapshot = snapshot_attrs.freeze
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
attr_writer :reference_id
|
|
33
|
+
|
|
34
|
+
# Resolved at read time: the referenced node's own `#ref` when it is one (a `Field`
|
|
35
|
+
# or anything else in this tree), otherwise the raw value as given.
|
|
36
|
+
def reference_id
|
|
37
|
+
@reference_id.respond_to?(:ref) ? @reference_id.ref : @reference_id
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def dirty?
|
|
41
|
+
!removed? && !new? && snapshot_attrs != @snapshot
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def changed_attributes
|
|
45
|
+
return {} if new? || removed?
|
|
46
|
+
|
|
47
|
+
snapshot_attrs.each_with_object({}) { |(k, v), h| h[k] = v if v != @snapshot[k] }
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
private
|
|
51
|
+
|
|
52
|
+
def snapshot_attrs
|
|
53
|
+
{ name: name, reference_id: reference_id }
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
module Ecoportal
|
|
2
|
+
module API
|
|
3
|
+
class GraphQL
|
|
4
|
+
module Model
|
|
5
|
+
module Template
|
|
6
|
+
# Groups an ordered command batch into chunks no larger than `chunk_size`, never
|
|
7
|
+
# splitting a placeholder-minting command away from any OTHER command in the SAME
|
|
8
|
+
# batch that references that placeholder (a `placeholderId` is only valid within the
|
|
9
|
+
# ONE `executeWorkflowCommands`/`updatePageTemplate` call that mints it —
|
|
10
|
+
# `18_template_editor_save_path.md` section B.1). Connected components (union-find
|
|
11
|
+
# over "which commands share a placeholder token") are packed into chunks in
|
|
12
|
+
# first-appearance order, so a chunk boundary only ever falls BETWEEN independent
|
|
13
|
+
# groups, never inside one.
|
|
14
|
+
module CommandGrouper
|
|
15
|
+
PLACEHOLDER_RE = /\Aph_/
|
|
16
|
+
|
|
17
|
+
class << self
|
|
18
|
+
# @param commands [Array<Hash>] built command hashes (`{ commandKey => input }`).
|
|
19
|
+
# @param chunk_size [Integer]
|
|
20
|
+
# @return [Array<Array<Hash>>] ordered chunks.
|
|
21
|
+
def chunk(commands, chunk_size)
|
|
22
|
+
slice_groups(group(commands), chunk_size)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# Every `ph_...` token referenced ANYWHERE in `commands` (minted or referenced).
|
|
26
|
+
# Public: `StagedExecutor`'s stale-placeholder guard uses this directly.
|
|
27
|
+
def tokens_in(commands)
|
|
28
|
+
commands.flat_map { |c| placeholder_tokens(c) }.uniq
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
private
|
|
32
|
+
|
|
33
|
+
def group(commands)
|
|
34
|
+
parent = {}
|
|
35
|
+
find = find_proc(parent)
|
|
36
|
+
token_owner = {}
|
|
37
|
+
|
|
38
|
+
commands.each_index do |i|
|
|
39
|
+
find.call(i)
|
|
40
|
+
placeholder_tokens(commands[i]).each do |tok|
|
|
41
|
+
token_owner.key?(tok) ? union(parent, find, i, token_owner[tok]) : (token_owner[tok] = i)
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
buckets = Hash.new { |h, k| h[k] = [] }
|
|
46
|
+
commands.each_index { |i| buckets[find.call(i)] << commands[i] }
|
|
47
|
+
buckets.values
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def find_proc(parent)
|
|
51
|
+
find = nil
|
|
52
|
+
find = lambda do |x|
|
|
53
|
+
parent[x] ||= x
|
|
54
|
+
parent[x] = find.call(parent[x]) unless parent[x] == x
|
|
55
|
+
parent[x]
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def union(parent, find, idx_a, idx_b)
|
|
60
|
+
root_a = find.call(idx_a)
|
|
61
|
+
root_b = find.call(idx_b)
|
|
62
|
+
parent[root_a] = root_b unless root_a == root_b
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def slice_groups(groups, chunk_size)
|
|
66
|
+
chunks = []
|
|
67
|
+
current = []
|
|
68
|
+
groups.each do |members|
|
|
69
|
+
guard_group_size!(members, chunk_size)
|
|
70
|
+
if current.size + members.size > chunk_size
|
|
71
|
+
chunks << current
|
|
72
|
+
current = []
|
|
73
|
+
end
|
|
74
|
+
current.concat(members)
|
|
75
|
+
end
|
|
76
|
+
chunks << current unless current.empty?
|
|
77
|
+
chunks
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def guard_group_size!(members, chunk_size)
|
|
81
|
+
return if members.size <= chunk_size
|
|
82
|
+
|
|
83
|
+
tally = members.map { |c| c.keys.first }.tally
|
|
84
|
+
raise "CommandGrouper: a placeholder-connected group of #{members.size} commands " \
|
|
85
|
+
"exceeds chunk_size (#{chunk_size}) and cannot be split without breaking a " \
|
|
86
|
+
"same-batch placeholder reference. Key tally: #{tally}"
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def placeholder_tokens(command)
|
|
90
|
+
command.values.flat_map { |v| scan(v) }
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def scan(value)
|
|
94
|
+
case value
|
|
95
|
+
when String then PLACEHOLDER_RE.match?(value) ? [value] : []
|
|
96
|
+
when Hash then value.values.flat_map { |v| scan(v) }
|
|
97
|
+
when Array then value.flat_map { |v| scan(v) }
|
|
98
|
+
else []
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
end
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
module Ecoportal
|
|
2
|
+
module API
|
|
3
|
+
class GraphQL
|
|
4
|
+
module Model
|
|
5
|
+
module Template
|
|
6
|
+
# Normalises an ordered batch of built `WorkflowCommand` hashes
|
|
7
|
+
# (`[{ commandKey => { ...input } }, ...]`) so that batches produced by DIFFERENT
|
|
8
|
+
# emitters -- each of which mints its own placeholder/id tokens (`Builder::
|
|
9
|
+
# TemplateBuilder`'s `ph_stg_1`, `Diff::CommandSynthesizer`'s `ph_stg_0`,
|
|
10
|
+
# `CommandSynthesis`'s `ph_stg_1`, a self-version replay's real server ids, the downstream
|
|
11
|
+
# writers' caller-chosen tokens) -- can be compared for STRUCTURAL equivalence.
|
|
12
|
+
#
|
|
13
|
+
# Two normalisations, both pure (no mutation of the input):
|
|
14
|
+
#
|
|
15
|
+
# 1. REFERENCE-TOKEN POSITIONING. Every value carried under a key this class
|
|
16
|
+
# recognises as an id/placeholder REFERENCE (see `REFERENCE_KEYS`) is replaced
|
|
17
|
+
# by a positional token `"ph_<n>"`, assigned in FIRST-APPEARANCE order across
|
|
18
|
+
# the whole ordered batch (scanning left to right, top to bottom, depth-first
|
|
19
|
+
# within each command's own Hash). The SAME source value always maps to the
|
|
20
|
+
# SAME token wherever it recurs (so a mint-then-reference pair, e.g. `addStage`'s
|
|
21
|
+
# `placeholderId` and a later `addStageSection`'s `stageId`, stays linked). Only
|
|
22
|
+
# the KEY decides whether a String is a reference -- literal content
|
|
23
|
+
# (`label`/`name`/`value`/`script`/`url`/...) is never touched, so a coincidental
|
|
24
|
+
# value match with a real id never causes a false substitution.
|
|
25
|
+
# 2. KEY ORDER + correlation stripping. Each command's own input Hash (and any
|
|
26
|
+
# nested Hash, e.g. `byType`) is recursively re-sorted by key so two batches that
|
|
27
|
+
# differ only in the Hash literal's construction order compare equal, and any
|
|
28
|
+
# `CORRELATION_KEYS` entry (never part of the compared shape) is dropped.
|
|
29
|
+
#
|
|
30
|
+
# Two normalised batches are STRUCTURALLY EQUIVALENT iff `Normalizer.normalize(a) ==
|
|
31
|
+
# Normalizer.normalize(b)` -- byte-identical once serialised (`to_json`), per the
|
|
32
|
+
# DSL-08 acceptance test.
|
|
33
|
+
class CommandNormalizer
|
|
34
|
+
# Keys whose String value addresses a node (mint or reference), across every
|
|
35
|
+
# `WorkflowCommand` input class this repo ships plus the downstream writers' own commands
|
|
36
|
+
# (`placeholderId`/`stageId`/`sectionId`/`dataFieldId`/`data_field_id`/`optionId`/
|
|
37
|
+
# `forceId`/`referenceId`/`anchorId`/`id`) -- verified against every `VALID_KEYS`
|
|
38
|
+
# list under `lib/ecoportal/api/graphql/input/workflow_command/`. Deliberately a
|
|
39
|
+
# CLOSED, named set (not "every String") so a literal content String (a label, a
|
|
40
|
+
# script, a URL) is never mistaken for a reference.
|
|
41
|
+
REFERENCE_KEYS = %i[
|
|
42
|
+
placeholderId stageId sectionId dataFieldId data_field_id optionId
|
|
43
|
+
forceId referenceId anchorId id
|
|
44
|
+
].freeze
|
|
45
|
+
|
|
46
|
+
# Fields that exist only for request bookkeeping, never part of the compared shape.
|
|
47
|
+
CORRELATION_KEYS = %i[clientMutationId correlationId].freeze
|
|
48
|
+
|
|
49
|
+
# @param commands [Array<Hash>] an ordered batch, each a single-key Hash
|
|
50
|
+
# (`{ commandKey => { ...input } }`).
|
|
51
|
+
# @return [Array<Hash>] the same batch, reference tokens repositioned, keys sorted,
|
|
52
|
+
# correlation-only keys dropped. Deterministic and comparable with `==`.
|
|
53
|
+
def self.normalize(commands)
|
|
54
|
+
new.normalize(commands)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def normalize(commands)
|
|
58
|
+
@tokens = {}
|
|
59
|
+
@next_index = 0
|
|
60
|
+
Array(commands).map { |command| normalize_command(command) }
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
private
|
|
64
|
+
|
|
65
|
+
def normalize_command(command)
|
|
66
|
+
command_key = command.keys.first
|
|
67
|
+
body = command[command_key]
|
|
68
|
+
{ command_key => deep_sort(normalize_hash(body)) }
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def normalize_hash(hash)
|
|
72
|
+
hash.each_with_object({}) do |(key, value), out|
|
|
73
|
+
next if CORRELATION_KEYS.include?(key.to_sym)
|
|
74
|
+
|
|
75
|
+
out[key] = normalize_value(key, value)
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def normalize_value(key, value)
|
|
80
|
+
case value
|
|
81
|
+
when Hash then normalize_hash(value)
|
|
82
|
+
when Array then value.map { |element| normalize_value(key, element) }
|
|
83
|
+
when String then reference_key?(key) ? token_for(value) : value
|
|
84
|
+
else value
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def reference_key?(key)
|
|
89
|
+
REFERENCE_KEYS.include?(key.to_sym)
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# The SAME source value (whatever token/id format the emitter minted) always maps
|
|
93
|
+
# to the SAME positional token -- first appearance decides the number.
|
|
94
|
+
def token_for(raw_value)
|
|
95
|
+
@tokens[raw_value] ||= begin
|
|
96
|
+
@next_index += 1
|
|
97
|
+
"ph_#{@next_index}"
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# Recursively re-sorts every Hash (top-level input and any nested sub-hash, e.g.
|
|
102
|
+
# `byType`) by key, so construction-order differences never cause a false mismatch.
|
|
103
|
+
# Arrays keep their own order (option/command ORDER is part of what is compared).
|
|
104
|
+
def deep_sort(value)
|
|
105
|
+
case value
|
|
106
|
+
when Hash then value.map { |k, v| [k, deep_sort(v)] }.sort_by { |(k, _)| k.to_s }.to_h
|
|
107
|
+
when Array then value.map { |element| deep_sort(element) }
|
|
108
|
+
else value
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
end
|