yaml_exporter 0.1.0 → 0.2.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.
@@ -0,0 +1,70 @@
1
+ # frozen_string_literal: true
2
+
3
+ module YamlExporter
4
+ module Nodes
5
+ # `many :assoc, find_by: :column do ... end` — identity is the find_by
6
+ # column value. Existing rows are matched by key; duplicates in the
7
+ # incoming YAML raise DuplicateKeyError.
8
+ class ManyFindBy < ManyBase
9
+ def find_or_build_child(parent, entry, _index, existing:)
10
+ key = entry[@find_by.to_s]
11
+ match = existing.find { |c| c.public_send(@find_by) == key }
12
+ return match if match
13
+
14
+ build_child(parent).tap do |child|
15
+ child.public_send("#{@find_by}=", key)
16
+ end
17
+ end
18
+
19
+ def default_export_order(records)
20
+ records.sort_by { |r| r.public_send(@find_by).to_s }
21
+ end
22
+
23
+ def extra_entry_keys
24
+ [@find_by.to_s]
25
+ end
26
+
27
+ # `extra_entry_schema` is inherited from ManyBase — entry_class here is
28
+ # the target class, which is exactly where @find_by lives, so the
29
+ # default TypeInference lookup produces the right type.
30
+
31
+ # Re-emit the find_by column first in each entry so round-trips stay
32
+ # stable (and readers see the discriminator up top).
33
+ def export(parent, exporter:)
34
+ records = sort_for_export(Array(parent.public_send(@name)))
35
+ list = records.map do |child|
36
+ hash = { @find_by.to_s => child.public_send(@find_by) }
37
+ hash.merge!(exporter.build_hash(child, @sub_structure))
38
+ hash
39
+ end
40
+ [@name.to_s, list]
41
+ end
42
+
43
+ private
44
+
45
+ # Guarantees duplicates-in-YAML fail loudly instead of silently
46
+ # overwriting each other by index.
47
+ def reject_duplicates!(entries, path)
48
+ seen = {}
49
+ entries.each_with_index do |entry, index|
50
+ key = entry[@find_by.to_s]
51
+ if seen.key?(key)
52
+ raise DuplicateKeyError,
53
+ "#{list_path(path)}: duplicate #{@find_by} #{key.inspect} " \
54
+ "(entries at index #{seen[key]} and #{index})"
55
+ end
56
+ seen[key] = index
57
+ end
58
+ end
59
+
60
+ public
61
+
62
+ # Wrap the shared import to insert duplicate detection up front.
63
+ def import(parent, data, path:, importer:)
64
+ entries = data.key?(@name.to_s) ? data[@name.to_s] : nil
65
+ reject_duplicates!(entries, path) if entries.is_a?(Array)
66
+ super
67
+ end
68
+ end
69
+ end
70
+ end
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ module YamlExporter
4
+ module Nodes
5
+ # `many :assoc do ... end` — identity is positional (array index). The
6
+ # documented footgun: reordering the YAML list rewrites existing rows
7
+ # in place by index instead of swapping ids.
8
+ class ManyPositional < ManyBase
9
+ def find_or_build_child(parent, _entry, index, existing:)
10
+ existing[index] || build_child(parent)
11
+ end
12
+ end
13
+ end
14
+ end
@@ -0,0 +1,73 @@
1
+ # frozen_string_literal: true
2
+
3
+ module YamlExporter
4
+ module Nodes
5
+ # `many :assoc, find_by: :column` — standalone reference list (HABTM or
6
+ # has_many). No block, no entry attributes: each YAML string resolves to
7
+ # an existing target record via `find_by`. Target records are never
8
+ # created or mutated; only the association is updated.
9
+ #
10
+ # Phase: :post_save (join rows / FK need parent.id).
11
+ class ManyReference
12
+ attr_reader :name, :owner_class, :find_by
13
+
14
+ def initialize(name:, owner_class:, find_by:)
15
+ @name = name.to_sym
16
+ @owner_class = owner_class
17
+ @find_by = find_by.to_sym
18
+ reflection = owner_class.reflect_on_association(@name)
19
+ unless reflection
20
+ raise ArgumentError,
21
+ "`many #{name.inspect}`: #{owner_class} has no association `#{name}`"
22
+ end
23
+ end
24
+
25
+ def phase
26
+ :post_save
27
+ end
28
+
29
+ def yaml_keys
30
+ [@name.to_s]
31
+ end
32
+
33
+ def target_class
34
+ @target_class ||= @owner_class.reflect_on_association(@name).klass
35
+ end
36
+
37
+ def import(record, data, path:, importer:)
38
+ raw = data.key?(@name.to_s) ? data[@name.to_s] : nil
39
+ values = Array(raw)
40
+
41
+ unless values.all? { |v| v.is_a?(String) || v.is_a?(Symbol) || v.is_a?(Numeric) }
42
+ raise UnknownAttributeError,
43
+ "#{describe_path(path)}: expected a list of #{@find_by} values, got #{raw.inspect}"
44
+ end
45
+
46
+ targets = values.map do |value|
47
+ target_class.find_by(@find_by => value) ||
48
+ (raise ActiveRecord::RecordNotFound,
49
+ "no #{target_class} with #{@find_by}=#{value.inspect}")
50
+ end
51
+
52
+ record.public_send("#{@name}=", targets)
53
+ end
54
+
55
+ def export(record, exporter:)
56
+ targets = Array(record.public_send(@name))
57
+ keys = targets.map { |t| t.public_send(@find_by) }.sort
58
+ [@name.to_s, keys]
59
+ end
60
+
61
+ def schema_fragment
62
+ item_type = TypeInference.schema_type_for(target_class, @find_by)
63
+ { @name => { type: 'array', items: { type: item_type } } }
64
+ end
65
+
66
+ private
67
+
68
+ def describe_path(path)
69
+ path.empty? ? @name.to_s : "#{path}.#{@name}"
70
+ end
71
+ end
72
+ end
73
+ end
@@ -0,0 +1,176 @@
1
+ # frozen_string_literal: true
2
+
3
+ module YamlExporter
4
+ module Nodes
5
+ # `many :assoc, through: :join, find_by: :column do ... end`.
6
+ #
7
+ # With a block, its attributes (and any positioned_by column) attach to
8
+ # the JOIN row, not the target record. find_by resolves the target
9
+ # globally.
10
+ #
11
+ # With NO block at all the association is a bare reference list: each
12
+ # entry is just the target's find_by value (a string), exactly like
13
+ # ManyReference, but routed through a join model. The join rows are still
14
+ # created/destroyed by the DSL, and positioned_by: — when given — derives
15
+ # the join's position column from the YAML order. An *empty* block is NOT
16
+ # the reference flavor: passing a block (empty or not) opts into the
17
+ # hash-shaped entries, same as the other `many` flavors.
18
+ class ManyThrough < ManyBase
19
+ attr_reader :through
20
+
21
+ def initialize(name:, owner_class:, through:, find_by:, positioned_by: nil, &block)
22
+ # Whether a block was passed (even an empty one) decides the YAML
23
+ # shape: block → hash entries, no block → bare reference list.
24
+ @reference_list = block.nil?
25
+ block ||= proc {}
26
+
27
+ @through = through.to_sym
28
+ join_reflection = owner_class.reflect_on_association(@through)
29
+ unless join_reflection
30
+ raise ArgumentError,
31
+ "`many #{name.inspect}`: #{owner_class} has no join association `#{through}`"
32
+ end
33
+
34
+ super(name: name, owner_class: owner_class, find_by: find_by,
35
+ positioned_by: positioned_by, &block)
36
+ end
37
+
38
+ # No block was passed → the YAML is a flat list of find_by values
39
+ # rather than a list of hashes. Mirrors ManyReference, but join rows
40
+ # (and their positioned_by column) are still managed by the DSL.
41
+ def reference_list?
42
+ @reference_list
43
+ end
44
+
45
+ def join_class
46
+ @join_class ||= @owner_class.reflect_on_association(@through).klass
47
+ end
48
+
49
+ # Block attributes live on the join row.
50
+ def entry_class
51
+ join_class
52
+ end
53
+
54
+ # Existing "children" for destroy-missing bookkeeping are the join
55
+ # rows, not the targets: removing a reviewer must drop the join row,
56
+ # never the Reviewer itself. Order follows the through association's
57
+ # default scope (matching is by source-association object, not index,
58
+ # so order doesn't affect correctness here).
59
+ def current_entries(parent)
60
+ parent.association(@through).reset
61
+ parent.public_send(@through).to_a
62
+ end
63
+
64
+ def find_or_build_child(parent, entry, _index, existing:)
65
+ key = entry[@find_by.to_s]
66
+ target = target_class.find_by(@find_by => key)
67
+ unless target
68
+ raise ActiveRecord::RecordNotFound,
69
+ "no #{target_class} with #{@find_by}=#{key.inspect}"
70
+ end
71
+
72
+ # Match existing join rows by comparing the source association
73
+ # object (AR `==` compares primary keys internally, so this works
74
+ # for surrogate ids and composite PKs alike — no `target.id` needed).
75
+ assoc = source_association_name
76
+ match = existing.find { |join| join.public_send(assoc) == target }
77
+ return match if match
78
+
79
+ # `parent.<through>.build` sets the through-side FK for us (respects
80
+ # `association_primary_key`); then we assign the target via its own
81
+ # association setter rather than poking `*_id` columns.
82
+ parent.public_send(@through).build.tap do |join|
83
+ join.public_send("#{assoc}=", target)
84
+ end
85
+ end
86
+
87
+ def default_export_order(records)
88
+ records.sort_by do |join|
89
+ target = join.public_send(source_association_name)
90
+ target.public_send(@find_by).to_s
91
+ end
92
+ end
93
+
94
+ # The collection the Exporter should walk is the join rows, since
95
+ # block attributes belong to those.
96
+ def export(parent, exporter:)
97
+ records = sort_for_export(Array(parent.public_send(@through)))
98
+ if reference_list?
99
+ keys = records.map { |join| join.public_send(source_association_name).public_send(@find_by) }
100
+ return [@name.to_s, keys]
101
+ end
102
+
103
+ list = records.map do |join|
104
+ target = join.public_send(source_association_name)
105
+ hash = { @find_by.to_s => target.public_send(@find_by) }
106
+ hash.merge!(exporter.build_hash(join, @sub_structure))
107
+ hash
108
+ end
109
+ [@name.to_s, list]
110
+ end
111
+
112
+ # Bare reference-list import: entries are find_by values, not hashes.
113
+ # The block-driven flow in ManyBase#import only applies when there are
114
+ # block attributes to write onto the join row.
115
+ def import(parent, data, path:, importer:)
116
+ return super unless reference_list?
117
+
118
+ raw = data.key?(@name.to_s) ? data[@name.to_s] : nil
119
+ values = raw.nil? ? [] : raw
120
+
121
+ unless values.is_a?(Array)
122
+ raise UnknownAttributeError,
123
+ "#{list_path(path)}: expected a list, got #{values.class}"
124
+ end
125
+ unless values.all? { |v| v.is_a?(String) || v.is_a?(Symbol) || v.is_a?(Numeric) }
126
+ raise UnknownAttributeError,
127
+ "#{list_path(path)}: expected a list of #{@find_by} values, got #{raw.inspect}"
128
+ end
129
+
130
+ parent.association(@through).reset
131
+ existing = current_entries(parent)
132
+ kept = Set.new.compare_by_identity
133
+
134
+ values.each_with_index do |value, index|
135
+ join = find_or_build_child(parent, { @find_by.to_s => value }, index, existing: existing)
136
+ # positioned_by is DSL-owned: derived from the 1-based array index,
137
+ # mirroring ManyBase#import.
138
+ join.public_send("#{@positioned_by}=", index + 1) if @positioned_by
139
+ join.save!
140
+ kept << join
141
+ end
142
+
143
+ destroy_missing(existing, kept)
144
+ end
145
+
146
+ def extra_entry_keys
147
+ [@find_by.to_s]
148
+ end
149
+
150
+ # find_by is a column on the *target* (e.g. Reviewer.slug), not on the
151
+ # join row — so resolve the type against target_class.
152
+ def extra_entry_schema
153
+ { @find_by => { type: TypeInference.schema_type_for(target_class, @find_by) } }
154
+ end
155
+
156
+ # A bare reference list is a flat array of find_by values; the
157
+ # block-driven object-array schema from ManyBase doesn't apply.
158
+ def schema_fragment
159
+ return super unless reference_list?
160
+
161
+ item_type = TypeInference.schema_type_for(target_class, @find_by)
162
+ { @name => { type: 'array', items: { type: item_type } } }
163
+ end
164
+
165
+ private
166
+
167
+ def source_reflection
168
+ @source_reflection ||= @owner_class.reflect_on_association(@name).source_reflection
169
+ end
170
+
171
+ def source_association_name
172
+ source_reflection.name
173
+ end
174
+ end
175
+ end
176
+ end
@@ -0,0 +1,96 @@
1
+ # frozen_string_literal: true
2
+
3
+ module YamlExporter
4
+ module Nodes
5
+ # `one :assoc do ... end` — owned has_one child. The current record owns
6
+ # the child row; missing/null in YAML means "destroy the child".
7
+ #
8
+ # Phase: :post_save (the child carries `parent_id`, so parent must be
9
+ # persisted first).
10
+ class OneOwned
11
+ attr_reader :name, :owner_class, :sub_structure
12
+
13
+ def initialize(name:, owner_class:, &block)
14
+ raise ArgumentError, "`one #{name.inspect}`: block required" unless block
15
+
16
+ @name = name.to_sym
17
+ @owner_class = owner_class
18
+ reflection = owner_class.reflect_on_association(@name)
19
+ unless reflection
20
+ raise ArgumentError,
21
+ "`one #{name.inspect}`: #{owner_class} has no association `#{name}`"
22
+ end
23
+
24
+ @sub_structure = Builder.new(-> { target_class }, &block).build
25
+ end
26
+
27
+ def phase
28
+ :post_save
29
+ end
30
+
31
+ def yaml_keys
32
+ [@name.to_s]
33
+ end
34
+
35
+ def target_class
36
+ @target_class ||= @owner_class.reflect_on_association(@name).klass
37
+ end
38
+
39
+ def import(record, data, path:, importer:)
40
+ child_data = data.key?(@name.to_s) ? data[@name.to_s] : nil
41
+
42
+ if child_data.nil?
43
+ destroy_existing(record)
44
+ return
45
+ end
46
+
47
+ child = find_or_build_child(record)
48
+ importer.apply(child, @sub_structure, child_data, path: child_path(path))
49
+ cache_target(record, child)
50
+ end
51
+
52
+ def export(record, exporter:)
53
+ child = record.public_send(@name)
54
+ return [@name.to_s, nil] if child.nil?
55
+
56
+ [@name.to_s, exporter.build_hash(child, @sub_structure)]
57
+ end
58
+
59
+ def schema_fragment
60
+ { @name => Schema.generate(@sub_structure) }
61
+ end
62
+
63
+ private
64
+
65
+ def find_or_build_child(parent)
66
+ # Don't trust the in-memory association cache — we may have been
67
+ # re-imported into a parent whose assoc target is stale.
68
+ parent.association(@name).reset
69
+ existing = parent.public_send(@name)
70
+ return existing if existing
71
+
72
+ # `build_<assoc>` sets the FK via AR's reflection, which consults
73
+ # `association_primary_key` — PK-agnostic (works for composite /
74
+ # custom primary keys and polymorphic `*_type` columns).
75
+ parent.public_send("build_#{@name}")
76
+ end
77
+
78
+ def destroy_existing(parent)
79
+ parent.association(@name).reset
80
+ existing = parent.public_send(@name)
81
+ existing&.destroy!
82
+ cache_target(parent, nil)
83
+ end
84
+
85
+ def cache_target(parent, target)
86
+ assoc = parent.association(@name)
87
+ assoc.target = target
88
+ assoc.loaded!
89
+ end
90
+
91
+ def child_path(parent_path)
92
+ parent_path.empty? ? @name.to_s : "#{parent_path}.#{@name}"
93
+ end
94
+ end
95
+ end
96
+ end
@@ -0,0 +1,72 @@
1
+ # frozen_string_literal: true
2
+
3
+ module YamlExporter
4
+ module Nodes
5
+ # `one :assoc, find_by: :column` — belongs_to reference. The FK column
6
+ # lives on the current record; the target is externally managed.
7
+ #
8
+ # Phase: :pre_save (sets the FK column on `record` before `record.save!`).
9
+ class OneReference
10
+ attr_reader :name, :owner_class, :find_by
11
+
12
+ def initialize(name:, owner_class:, find_by:)
13
+ @name = name.to_sym
14
+ @owner_class = owner_class
15
+ @find_by = find_by.to_sym
16
+ reflection = owner_class.reflect_on_association(@name)
17
+ unless reflection
18
+ raise ArgumentError,
19
+ "`one #{name.inspect}`: #{owner_class} has no association `#{name}`"
20
+ end
21
+ end
22
+
23
+ def phase
24
+ :pre_save
25
+ end
26
+
27
+ def yaml_keys
28
+ [@name.to_s]
29
+ end
30
+
31
+ def target_class
32
+ @target_class ||= @owner_class.reflect_on_association(@name).klass
33
+ end
34
+
35
+ # Assign via the association setter (not the raw FK column) so that:
36
+ # - Rails uses the target's `association_primary_key`, not a hardcoded
37
+ # :id (composite/custom primary keys keep working).
38
+ # - The in-memory association cache on `record` is updated, so a
39
+ # subsequent `record.publisher` returns the freshly-assigned target
40
+ # instead of a stale one.
41
+ # - Polymorphic `*_type` columns would be set too, if we ever grow
42
+ # that feature.
43
+ def import(record, data, path:, importer:)
44
+ value = data.key?(@name.to_s) ? data[@name.to_s] : nil
45
+
46
+ if value.nil?
47
+ record.public_send("#{@name}=", nil)
48
+ return
49
+ end
50
+
51
+ target = target_class.find_by(@find_by => value)
52
+ unless target
53
+ raise ActiveRecord::RecordNotFound,
54
+ "no #{target_class} with #{@find_by}=#{value.inspect}"
55
+ end
56
+
57
+ record.public_send("#{@name}=", target)
58
+ end
59
+
60
+ def export(record, exporter:)
61
+ target = record.public_send(@name)
62
+ return [@name.to_s, nil] if target.nil?
63
+
64
+ [@name.to_s, target.public_send(@find_by)]
65
+ end
66
+
67
+ def schema_fragment
68
+ { @name => { type: TypeInference.schema_type_for(target_class, @find_by) } }
69
+ end
70
+ end
71
+ end
72
+ end
@@ -0,0 +1,128 @@
1
+ # frozen_string_literal: true
2
+
3
+ module YamlExporter
4
+ module Nodes
5
+ # `one :assoc, find_by: :col, of: :nested_assoc`
6
+ #
7
+ # Identifies the target record indirectly: the YAML value is looked up on
8
+ # a related model (`of:`) rather than on the target itself.
9
+ #
10
+ # Example: Book `belongs_to :responsible_editor` (CorporateUser), and
11
+ # CorporateUser `belongs_to :user` (User with a slug). Declaring
12
+ # `one :responsible_editor, find_by: :slug, of: :user`
13
+ # stores the User's slug in the YAML and resolves the CorporateUser on
14
+ # import by reversing the FK.
15
+ #
16
+ # Only 1:[0,1] `of:` associations are permitted (belongs_to or has_one).
17
+ # Phase: :pre_save (sets the FK before record.save!).
18
+ class OneReferenceOf
19
+ attr_reader :name, :owner_class, :find_by, :of
20
+
21
+ def initialize(name:, owner_class:, find_by:, of:)
22
+ @name = name.to_sym
23
+ @owner_class = owner_class
24
+ @find_by = find_by.to_sym
25
+ @of = of.to_sym
26
+
27
+ reflection = owner_class.reflect_on_association(@name)
28
+ unless reflection
29
+ raise ArgumentError,
30
+ "`one #{name.inspect}`: #{owner_class} has no association `#{name}`"
31
+ end
32
+
33
+ of_ref = target_class.reflect_on_association(@of)
34
+ unless of_ref
35
+ raise ArgumentError,
36
+ "`one #{name.inspect}, of: #{of.inspect}`: #{target_class} has no association `#{of}`"
37
+ end
38
+
39
+ unless singular_association?(of_ref)
40
+ raise ArgumentError,
41
+ "`one #{name.inspect}, of: #{of.inspect}`: `of:` must be a 1:[0,1] association " \
42
+ "(belongs_to or has_one); got #{of_ref.macro}"
43
+ end
44
+ end
45
+
46
+ def phase
47
+ :pre_save
48
+ end
49
+
50
+ def yaml_keys
51
+ [@name.to_s]
52
+ end
53
+
54
+ def target_class
55
+ @target_class ||= @owner_class.reflect_on_association(@name).klass
56
+ end
57
+
58
+ def import(record, data, path:, importer:)
59
+ value = data.key?(@name.to_s) ? data[@name.to_s] : nil
60
+
61
+ if value.nil?
62
+ record.public_send("#{@name}=", nil)
63
+ return
64
+ end
65
+
66
+ through = through_class.find_by(@find_by => value)
67
+ unless through
68
+ raise ActiveRecord::RecordNotFound,
69
+ "no #{through_class} with #{@find_by}=#{value.inspect}"
70
+ end
71
+
72
+ target = find_target_via(through)
73
+ unless target
74
+ raise ActiveRecord::RecordNotFound,
75
+ "no #{target_class} linked to #{through_class} #{@find_by}=#{value.inspect} via `#{@of}`"
76
+ end
77
+
78
+ record.public_send("#{@name}=", target)
79
+ end
80
+
81
+ def export(record, exporter:)
82
+ target = record.public_send(@name)
83
+ return [@name.to_s, nil] if target.nil?
84
+
85
+ through = target.public_send(@of)
86
+ return [@name.to_s, nil] if through.nil?
87
+
88
+ [@name.to_s, through.public_send(@find_by)]
89
+ end
90
+
91
+ def schema_fragment
92
+ { @name => { type: TypeInference.schema_type_for(through_class, @find_by) } }
93
+ end
94
+
95
+ private
96
+
97
+ def of_reflection
98
+ @of_reflection ||= target_class.reflect_on_association(@of)
99
+ end
100
+
101
+ def through_class
102
+ @through_class ||= of_reflection.klass
103
+ end
104
+
105
+ # Navigate from a resolved `through` record back to the target.
106
+ #
107
+ # belongs_to (:user on CorporateUser): FK is on the target.
108
+ # target_class.find_by(user_id: through.id)
109
+ #
110
+ # has_one (:profile on CorporateUser): FK is on the through record.
111
+ # target_class.find_by(id: through.corporate_user_id)
112
+ def find_target_via(through)
113
+ if of_reflection.is_a?(ActiveRecord::Reflection::BelongsToReflection)
114
+ pk_val = through.public_send(of_reflection.association_primary_key)
115
+ target_class.find_by(of_reflection.foreign_key => pk_val)
116
+ else # has_one
117
+ fk_val = through.public_send(of_reflection.foreign_key)
118
+ target_class.find_by(of_reflection.association_primary_key => fk_val)
119
+ end
120
+ end
121
+
122
+ def singular_association?(reflection)
123
+ reflection.is_a?(ActiveRecord::Reflection::BelongsToReflection) ||
124
+ reflection.is_a?(ActiveRecord::Reflection::HasOneReflection)
125
+ end
126
+ end
127
+ end
128
+ end
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ module YamlExporter
4
+ # Builds a JSON-schema-ish hash describing a structure. Keys are symbols
5
+ # so callers can use `schema[:properties][:title]` ergonomically.
6
+ class Schema
7
+ def self.generate(structure)
8
+ new(structure).generate
9
+ end
10
+
11
+ def initialize(structure)
12
+ @structure = structure
13
+ end
14
+
15
+ def generate
16
+ {
17
+ type: 'object',
18
+ properties: @structure.nodes.each_with_object({}) do |node, acc|
19
+ acc.merge!(node.schema_fragment)
20
+ end
21
+ }
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ module YamlExporter
4
+ # Immutable representation of a `yaml_structure do ... end` block.
5
+ #
6
+ # * `klass` — the ActiveRecord class this structure is attached to.
7
+ # Resolved lazily: the constructor accepts either a class or a callable,
8
+ # and defers to first access. This matters for inner structures whose
9
+ # class might be resolved via a reflection that isn't safe to compute
10
+ # at declaration time (anonymous parent classes, etc.).
11
+ # * `nodes` — ordered list of node objects. Order matches declaration and
12
+ # drives export key order.
13
+ class Structure
14
+ attr_reader :nodes
15
+
16
+ def initialize(klass:, nodes:)
17
+ @klass_resolver = klass.respond_to?(:call) ? klass : -> { klass }
18
+ @nodes = nodes
19
+ end
20
+
21
+ def klass
22
+ @klass ||= @klass_resolver.call
23
+ end
24
+ end
25
+ end