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.
- checksums.yaml +4 -4
- data/.github/workflows/gem-push.yml +20 -29
- data/.github/workflows/test.yml +27 -0
- data/AGENTS.md +34 -0
- data/CHANGELOG.md +56 -0
- data/Gemfile.lock +106 -0
- data/README.md +785 -37
- data/RELEASING.md +45 -0
- data/Rakefile +14 -0
- data/lib/yaml_exporter/builder.rb +103 -0
- data/lib/yaml_exporter/exporter.rb +139 -0
- data/lib/yaml_exporter/importer.rb +86 -0
- data/lib/yaml_exporter/nodes/attribute.rb +70 -0
- data/lib/yaml_exporter/nodes/many_base.rb +215 -0
- data/lib/yaml_exporter/nodes/many_find_by.rb +70 -0
- data/lib/yaml_exporter/nodes/many_positional.rb +14 -0
- data/lib/yaml_exporter/nodes/many_reference.rb +99 -0
- data/lib/yaml_exporter/nodes/many_through.rb +195 -0
- data/lib/yaml_exporter/nodes/of_resolution.rb +84 -0
- data/lib/yaml_exporter/nodes/one_owned.rb +96 -0
- data/lib/yaml_exporter/nodes/one_reference.rb +72 -0
- data/lib/yaml_exporter/nodes/one_reference_of.rb +85 -0
- data/lib/yaml_exporter/schema.rb +24 -0
- data/lib/yaml_exporter/structure.rb +25 -0
- data/lib/yaml_exporter/type_inference.rb +64 -0
- data/lib/yaml_exporter/version.rb +5 -0
- data/lib/yaml_exporter.rb +54 -8
- data/script/demo_quiz.rb +78 -0
- data/yaml_exporter.gemspec +8 -9
- metadata +59 -13
- data/lib/yaml_serializable/structure_builder.rb +0 -30
- data/lib/yaml_serializable/version.rb +0 -3
- data/lib/yaml_serializable/yaml_exporter.rb +0 -196
|
@@ -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
|