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.
- 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 +45 -0
- data/Gemfile.lock +106 -0
- data/README.md +772 -37
- data/RELEASING.md +45 -0
- data/Rakefile +14 -0
- data/lib/yaml_exporter/builder.rb +93 -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 +73 -0
- data/lib/yaml_exporter/nodes/many_through.rb +176 -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 +128 -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 +53 -8
- data/script/demo_quiz.rb +78 -0
- data/yaml_exporter.gemspec +8 -9
- metadata +58 -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,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
|