yaml_exporter 0.1.0 → 0.2.1

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,99 @@
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
+ # With `of:` the value is resolved indirectly through a 1:[0,1]
11
+ # association on the target (see OfResolution), so the YAML lists the
12
+ # find_by value of a *related* model (e.g. the user slug behind each
13
+ # target) rather than a column on the target itself.
14
+ #
15
+ # Phase: :post_save (join rows / FK need parent.id).
16
+ class ManyReference
17
+ include OfResolution
18
+
19
+ attr_reader :name, :owner_class, :find_by, :of
20
+
21
+ def initialize(name:, owner_class:, find_by:, of: nil)
22
+ @name = name.to_sym
23
+ @owner_class = owner_class
24
+ @find_by = find_by.to_sym
25
+ @of = of&.to_sym
26
+ reflection = owner_class.reflect_on_association(@name)
27
+ unless reflection
28
+ raise ArgumentError,
29
+ "`many #{name.inspect}`: #{owner_class} has no association `#{name}`"
30
+ end
31
+
32
+ validate_of_reflection!("many #{name.inspect}") if @of
33
+ end
34
+
35
+ def phase
36
+ :post_save
37
+ end
38
+
39
+ def yaml_keys
40
+ [@name.to_s]
41
+ end
42
+
43
+ def target_class
44
+ @target_class ||= @owner_class.reflect_on_association(@name).klass
45
+ end
46
+
47
+ def import(record, data, path:, importer:)
48
+ raw = data.key?(@name.to_s) ? data[@name.to_s] : nil
49
+ values = Array(raw)
50
+
51
+ unless values.all? { |v| v.is_a?(String) || v.is_a?(Symbol) || v.is_a?(Numeric) }
52
+ raise UnknownAttributeError,
53
+ "#{describe_path(path)}: expected a list of #{@find_by} values, got #{raw.inspect}"
54
+ end
55
+
56
+ targets = values.map do |value|
57
+ resolve_target(value) ||
58
+ (raise ActiveRecord::RecordNotFound, not_found_message(value))
59
+ end
60
+
61
+ record.public_send("#{@name}=", targets)
62
+ end
63
+
64
+ def export(record, exporter:)
65
+ targets = Array(record.public_send(@name))
66
+ keys = targets.map { |t| key_for(t) }.compact.sort
67
+ [@name.to_s, keys]
68
+ end
69
+
70
+ def schema_fragment
71
+ key_class = @of ? of_class : target_class
72
+ item_type = TypeInference.schema_type_for(key_class, @find_by)
73
+ { @name => { type: 'array', items: { type: item_type } } }
74
+ end
75
+
76
+ private
77
+
78
+ def resolve_target(value)
79
+ @of ? resolve_target_by_of(value) : target_class.find_by(@find_by => value)
80
+ end
81
+
82
+ def key_for(target)
83
+ @of ? of_value_for(target) : target.public_send(@find_by)
84
+ end
85
+
86
+ def not_found_message(value)
87
+ if @of
88
+ "no #{target_class} for #{of_class} with #{@find_by}=#{value.inspect} via `#{@of}`"
89
+ else
90
+ "no #{target_class} with #{@find_by}=#{value.inspect}"
91
+ end
92
+ end
93
+
94
+ def describe_path(path)
95
+ path.empty? ? @name.to_s : "#{path}.#{@name}"
96
+ end
97
+ end
98
+ end
99
+ end
@@ -0,0 +1,195 @@
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
+ include OfResolution
20
+
21
+ attr_reader :through, :of
22
+
23
+ def initialize(name:, owner_class:, through:, find_by:, positioned_by: nil, of: nil, &block)
24
+ # Whether a block was passed (even an empty one) decides the YAML
25
+ # shape: block → hash entries, no block → bare reference list.
26
+ @reference_list = block.nil?
27
+ block ||= proc {}
28
+
29
+ @through = through.to_sym
30
+ @of = of&.to_sym
31
+ join_reflection = owner_class.reflect_on_association(@through)
32
+ unless join_reflection
33
+ raise ArgumentError,
34
+ "`many #{name.inspect}`: #{owner_class} has no join association `#{through}`"
35
+ end
36
+
37
+ super(name: name, owner_class: owner_class, find_by: find_by,
38
+ positioned_by: positioned_by, &block)
39
+
40
+ validate_of_reflection!("many #{name.inspect}") if @of
41
+ end
42
+
43
+ # No block was passed → the YAML is a flat list of find_by values
44
+ # rather than a list of hashes. Mirrors ManyReference, but join rows
45
+ # (and their positioned_by column) are still managed by the DSL.
46
+ def reference_list?
47
+ @reference_list
48
+ end
49
+
50
+ def join_class
51
+ @join_class ||= @owner_class.reflect_on_association(@through).klass
52
+ end
53
+
54
+ # Block attributes live on the join row.
55
+ def entry_class
56
+ join_class
57
+ end
58
+
59
+ # Existing "children" for destroy-missing bookkeeping are the join
60
+ # rows, not the targets: removing a reviewer must drop the join row,
61
+ # never the Reviewer itself. Order follows the through association's
62
+ # default scope (matching is by source-association object, not index,
63
+ # so order doesn't affect correctness here).
64
+ def current_entries(parent)
65
+ parent.association(@through).reset
66
+ parent.public_send(@through).to_a
67
+ end
68
+
69
+ def find_or_build_child(parent, entry, _index, existing:)
70
+ key = entry[@find_by.to_s]
71
+ target = @of ? resolve_target_by_of(key) : target_class.find_by(@find_by => key)
72
+ unless target
73
+ raise ActiveRecord::RecordNotFound, not_found_message(key)
74
+ end
75
+
76
+ # Match existing join rows by comparing the source association
77
+ # object (AR `==` compares primary keys internally, so this works
78
+ # for surrogate ids and composite PKs alike — no `target.id` needed).
79
+ assoc = source_association_name
80
+ match = existing.find { |join| join.public_send(assoc) == target }
81
+ return match if match
82
+
83
+ # `parent.<through>.build` sets the through-side FK for us (respects
84
+ # `association_primary_key`); then we assign the target via its own
85
+ # association setter rather than poking `*_id` columns.
86
+ parent.public_send(@through).build.tap do |join|
87
+ join.public_send("#{assoc}=", target)
88
+ end
89
+ end
90
+
91
+ def default_export_order(records)
92
+ records.sort_by do |join|
93
+ target = join.public_send(source_association_name)
94
+ key_for(target).to_s
95
+ end
96
+ end
97
+
98
+ # The collection the Exporter should walk is the join rows, since
99
+ # block attributes belong to those.
100
+ def export(parent, exporter:)
101
+ records = sort_for_export(Array(parent.public_send(@through)))
102
+ if reference_list?
103
+ keys = records.map { |join| key_for(join.public_send(source_association_name)) }
104
+ return [@name.to_s, keys]
105
+ end
106
+
107
+ list = records.map do |join|
108
+ target = join.public_send(source_association_name)
109
+ hash = { @find_by.to_s => target.public_send(@find_by) }
110
+ hash.merge!(exporter.build_hash(join, @sub_structure))
111
+ hash
112
+ end
113
+ [@name.to_s, list]
114
+ end
115
+
116
+ # Bare reference-list import: entries are find_by values, not hashes.
117
+ # The block-driven flow in ManyBase#import only applies when there are
118
+ # block attributes to write onto the join row.
119
+ def import(parent, data, path:, importer:)
120
+ return super unless reference_list?
121
+
122
+ raw = data.key?(@name.to_s) ? data[@name.to_s] : nil
123
+ values = raw.nil? ? [] : raw
124
+
125
+ unless values.is_a?(Array)
126
+ raise UnknownAttributeError,
127
+ "#{list_path(path)}: expected a list, got #{values.class}"
128
+ end
129
+ unless values.all? { |v| v.is_a?(String) || v.is_a?(Symbol) || v.is_a?(Numeric) }
130
+ raise UnknownAttributeError,
131
+ "#{list_path(path)}: expected a list of #{@find_by} values, got #{raw.inspect}"
132
+ end
133
+
134
+ parent.association(@through).reset
135
+ existing = current_entries(parent)
136
+ kept = Set.new.compare_by_identity
137
+
138
+ values.each_with_index do |value, index|
139
+ join = find_or_build_child(parent, { @find_by.to_s => value }, index, existing: existing)
140
+ # positioned_by is DSL-owned: derived from the 1-based array index,
141
+ # mirroring ManyBase#import.
142
+ join.public_send("#{@positioned_by}=", index + 1) if @positioned_by
143
+ join.save!
144
+ kept << join
145
+ end
146
+
147
+ destroy_missing(existing, kept)
148
+ end
149
+
150
+ def extra_entry_keys
151
+ [@find_by.to_s]
152
+ end
153
+
154
+ # find_by is a column on the *target* (e.g. Reviewer.slug), not on the
155
+ # join row — so resolve the type against target_class.
156
+ def extra_entry_schema
157
+ { @find_by => { type: TypeInference.schema_type_for(target_class, @find_by) } }
158
+ end
159
+
160
+ # A bare reference list is a flat array of find_by values; the
161
+ # block-driven object-array schema from ManyBase doesn't apply.
162
+ def schema_fragment
163
+ return super unless reference_list?
164
+
165
+ key_class = @of ? of_class : target_class
166
+ item_type = TypeInference.schema_type_for(key_class, @find_by)
167
+ { @name => { type: 'array', items: { type: item_type } } }
168
+ end
169
+
170
+ private
171
+
172
+ # The YAML value for a resolved target: with `of:` it lives on a related
173
+ # model; otherwise it's the find_by column on the target itself.
174
+ def key_for(target)
175
+ @of ? of_value_for(target) : target.public_send(@find_by)
176
+ end
177
+
178
+ def not_found_message(key)
179
+ if @of
180
+ "no #{target_class} for #{of_class} with #{@find_by}=#{key.inspect} via `#{@of}`"
181
+ else
182
+ "no #{target_class} with #{@find_by}=#{key.inspect}"
183
+ end
184
+ end
185
+
186
+ def source_reflection
187
+ @source_reflection ||= @owner_class.reflect_on_association(@name).source_reflection
188
+ end
189
+
190
+ def source_association_name
191
+ source_reflection.name
192
+ end
193
+ end
194
+ end
195
+ end
@@ -0,0 +1,84 @@
1
+ # frozen_string_literal: true
2
+
3
+ module YamlExporter
4
+ module Nodes
5
+ # Shared `of:` navigation for reference nodes that identify their target
6
+ # indirectly: the YAML value names a column on a *related* model
7
+ # (`of:`) reachable from the target, not on the target itself.
8
+ #
9
+ # Example: a reviewer target is a CorporateUser, and
10
+ # CorporateUser `belongs_to :user` (a User with a slug). Declaring
11
+ # `find_by: :slug, of: :user` stores the User's slug in the YAML and, on
12
+ # import, resolves the User first and then reverses `of:` back to the
13
+ # CorporateUser.
14
+ #
15
+ # Mixed into OneReferenceOf, ManyReference and ManyThrough. The host must
16
+ # provide `target_class` and set `@find_by` and `@of` (both symbols).
17
+ module OfResolution
18
+ # The related model that actually carries the find_by column
19
+ # (CorporateUser.user -> User).
20
+ def of_reflection
21
+ @of_reflection ||= target_class.reflect_on_association(@of)
22
+ end
23
+
24
+ # The class of the related model (CorporateUser.user -> User).
25
+ def of_class
26
+ @of_class ||= of_reflection.klass
27
+ end
28
+
29
+ # Resolve a single YAML value to a target record: find the related
30
+ # record by find_by, then reverse `of:` back to the target. Returns nil
31
+ # if either lookup misses.
32
+ def resolve_target_by_of(value)
33
+ related = of_class.find_by(@find_by => value)
34
+ return nil unless related
35
+
36
+ find_target_via(related)
37
+ end
38
+
39
+ # Navigate from a resolved related record back to the target.
40
+ #
41
+ # belongs_to (:user on CorporateUser): FK is on the target.
42
+ # target_class.find_by(user_id: related.id)
43
+ # has_one (:profile on CorporateUser): FK is on the related record.
44
+ # target_class.find_by(id: related.corporate_user_id)
45
+ def find_target_via(related)
46
+ if of_reflection.is_a?(ActiveRecord::Reflection::BelongsToReflection)
47
+ pk_val = related.public_send(of_reflection.association_primary_key)
48
+ target_class.find_by(of_reflection.foreign_key => pk_val)
49
+ else # has_one
50
+ fk_val = related.public_send(of_reflection.foreign_key)
51
+ target_class.find_by(of_reflection.association_primary_key => fk_val)
52
+ end
53
+ end
54
+
55
+ # The YAML value for a target: the find_by column on its `of:` relation.
56
+ # nil when the target has no related record (can't be represented).
57
+ def of_value_for(target)
58
+ related = target.public_send(@of)
59
+ related&.public_send(@find_by)
60
+ end
61
+
62
+ # Declaration-time check: `of:` must name a 1:[0,1] association on the
63
+ # target. Raises ArgumentError at class load otherwise.
64
+ def validate_of_reflection!(node_label)
65
+ of_ref = target_class.reflect_on_association(@of)
66
+ unless of_ref
67
+ raise ArgumentError,
68
+ "`#{node_label}, of: #{@of.inspect}`: #{target_class} has no association `#{@of}`"
69
+ end
70
+
71
+ return if singular_of_association?(of_ref)
72
+
73
+ raise ArgumentError,
74
+ "`#{node_label}, of: #{@of.inspect}`: `of:` must be a 1:[0,1] association " \
75
+ "(belongs_to or has_one); got #{of_ref.macro}"
76
+ end
77
+
78
+ def singular_of_association?(reflection)
79
+ reflection.is_a?(ActiveRecord::Reflection::BelongsToReflection) ||
80
+ reflection.is_a?(ActiveRecord::Reflection::HasOneReflection)
81
+ end
82
+ end
83
+ end
84
+ 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