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
data/RELEASING.md
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# Releasing
|
|
2
|
+
|
|
3
|
+
Releases are automated: **publish a GitHub Release and CI pushes the gem to
|
|
4
|
+
RubyGems** via [Trusted Publishing](https://guides.rubygems.org/trusted-publishing/)
|
|
5
|
+
(OIDC — no API key stored anywhere). See `.github/workflows/gem-push.yml`.
|
|
6
|
+
|
|
7
|
+
## One-time setup
|
|
8
|
+
|
|
9
|
+
On [rubygems.org](https://rubygems.org) → the `yaml_exporter` gem → **Trusted
|
|
10
|
+
Publishers** → *Add* a GitHub Actions publisher:
|
|
11
|
+
|
|
12
|
+
- Repository: `itadventurer/yaml_exporter`
|
|
13
|
+
- Workflow: `gem-push.yml`
|
|
14
|
+
|
|
15
|
+
The gem already exists on RubyGems, so use the normal *Add* flow above (the
|
|
16
|
+
"pending trusted publisher" flow is only for a gem that doesn't exist yet).
|
|
17
|
+
|
|
18
|
+
Once the publisher is added, the old `RUBYGEMS_AUTH_TOKEN` secret is no longer
|
|
19
|
+
used and can be deleted.
|
|
20
|
+
|
|
21
|
+
## Cutting a release
|
|
22
|
+
|
|
23
|
+
1. **Bump the version and close the changelog.** Set the new version in
|
|
24
|
+
`lib/yaml_exporter/version.rb`, and in `CHANGELOG.md` rename the
|
|
25
|
+
`## Unreleased` heading to `## vX.Y.Z`, adding a fresh empty `## Unreleased`
|
|
26
|
+
above it. Open as a normal PR; merge to `main`.
|
|
27
|
+
|
|
28
|
+
2. **Publish the release** for the matching tag — the workflow expects `vX.Y.Z`
|
|
29
|
+
to equal the version in `version.rb` — using that version's changelog section
|
|
30
|
+
as the notes:
|
|
31
|
+
|
|
32
|
+
```bash
|
|
33
|
+
gh release create v0.2.0 --title v0.2.0 --notes "…"
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
(or use the GitHub UI → *Draft a new release* → pick the tag). Publishing
|
|
37
|
+
fires the workflow, which runs the tests, builds the gem, attaches a
|
|
38
|
+
build-provenance attestation, and pushes it.
|
|
39
|
+
|
|
40
|
+
That's it — `gem-push.yml` does the build + push; you never touch credentials.
|
|
41
|
+
|
|
42
|
+
### Versioning
|
|
43
|
+
|
|
44
|
+
[SemVer](https://semver.org): new backward-compatible features → minor (`0.x`),
|
|
45
|
+
bug-fixes → patch. Breaking changes → bump the minor while pre-`1.0`.
|
data/Rakefile
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Defines the `build`/`install`/`release` tasks from the gemspec. The
|
|
4
|
+
# rubygems/release-gem CI action runs `rake release` to push the gem.
|
|
5
|
+
require 'bundler/gem_tasks'
|
|
6
|
+
require 'rake/testtask'
|
|
7
|
+
|
|
8
|
+
Rake::TestTask.new(:test) do |t|
|
|
9
|
+
t.libs << 'test'
|
|
10
|
+
t.pattern = 'test/**/*_test.rb'
|
|
11
|
+
t.warning = false
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
task default: :test
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module YamlExporter
|
|
4
|
+
# Evaluates a `yaml_structure do ... end` block via instance_eval and emits
|
|
5
|
+
# a Structure. All declaration-time argument validation happens here (or
|
|
6
|
+
# inside the constructors of the Node classes it delegates to).
|
|
7
|
+
#
|
|
8
|
+
# Accepts either a class or a callable that resolves to a class. The
|
|
9
|
+
# callable form lets node constructors defer AR reflection lookups until
|
|
10
|
+
# the inner block actually references the class (it doesn't, for
|
|
11
|
+
# attribute-only blocks) — this matters for anonymous ActiveRecord
|
|
12
|
+
# classes.
|
|
13
|
+
class Builder
|
|
14
|
+
def initialize(klass, &block)
|
|
15
|
+
@klass_resolver = klass.respond_to?(:call) ? klass : -> { klass }
|
|
16
|
+
@nodes = []
|
|
17
|
+
instance_eval(&block) if block
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def build
|
|
21
|
+
Structure.new(klass: @klass_resolver, nodes: @nodes.freeze)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# ---- DSL ----------------------------------------------------------
|
|
25
|
+
|
|
26
|
+
def attributes(*names)
|
|
27
|
+
names.each { |n| @nodes << Nodes::Attribute.new(name: n, owner_class: @klass_resolver) }
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def one(name, find_by: nil, of: nil, &block)
|
|
31
|
+
if block && find_by
|
|
32
|
+
raise ArgumentError,
|
|
33
|
+
"`one #{name.inspect}`: cannot combine a block (owned) with find_by: (reference). Pick one."
|
|
34
|
+
end
|
|
35
|
+
if of && !find_by
|
|
36
|
+
raise ArgumentError,
|
|
37
|
+
"`one #{name.inspect}`: of: requires find_by:."
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
if block
|
|
41
|
+
@nodes << Nodes::OneOwned.new(name: name, owner_class: klass, &block)
|
|
42
|
+
elsif find_by && of
|
|
43
|
+
@nodes << Nodes::OneReferenceOf.new(name: name, owner_class: klass, find_by: find_by, of: of)
|
|
44
|
+
elsif find_by
|
|
45
|
+
@nodes << Nodes::OneReference.new(name: name, owner_class: klass, find_by: find_by)
|
|
46
|
+
else
|
|
47
|
+
raise ArgumentError,
|
|
48
|
+
"`one #{name.inspect}`: must pass either find_by: (reference) or a block (owned)."
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def many(name, find_by: nil, through: nil, positioned_by: nil, &block)
|
|
53
|
+
if through
|
|
54
|
+
unless find_by
|
|
55
|
+
raise ArgumentError,
|
|
56
|
+
"`many #{name.inspect}`: through: requires find_by: to resolve the target."
|
|
57
|
+
end
|
|
58
|
+
# A block is optional: with one, its attributes describe the join row;
|
|
59
|
+
# without one, the association is a bare reference list (and
|
|
60
|
+
# positioned_by:, when given, derives the join's position column from
|
|
61
|
+
# the YAML order). The join row itself is always managed by the DSL.
|
|
62
|
+
@nodes << Nodes::ManyThrough.new(
|
|
63
|
+
name: name, owner_class: klass, through: through, find_by: find_by,
|
|
64
|
+
positioned_by: positioned_by, &block
|
|
65
|
+
)
|
|
66
|
+
elsif positioned_by && !block
|
|
67
|
+
raise ArgumentError,
|
|
68
|
+
"`many #{name.inspect}`: positioned_by: requires a block — the column lives on the owned record."
|
|
69
|
+
elsif block && find_by
|
|
70
|
+
@nodes << Nodes::ManyFindBy.new(
|
|
71
|
+
name: name, owner_class: klass, find_by: find_by,
|
|
72
|
+
positioned_by: positioned_by, &block
|
|
73
|
+
)
|
|
74
|
+
elsif block
|
|
75
|
+
@nodes << Nodes::ManyPositional.new(
|
|
76
|
+
name: name, owner_class: klass, positioned_by: positioned_by, &block
|
|
77
|
+
)
|
|
78
|
+
elsif find_by
|
|
79
|
+
@nodes << Nodes::ManyReference.new(name: name, owner_class: klass, find_by: find_by)
|
|
80
|
+
else
|
|
81
|
+
raise ArgumentError,
|
|
82
|
+
"`many #{name.inspect}`: pass either find_by: (reference list), a block (owned), " \
|
|
83
|
+
'or both / through: + find_by: + block.'
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
private
|
|
88
|
+
|
|
89
|
+
def klass
|
|
90
|
+
@klass ||= @klass_resolver.call
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
end
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module YamlExporter
|
|
4
|
+
# Marker subclass: a String that must be emitted as a YAML literal block
|
|
5
|
+
# scalar (`|`). Attribute nodes wrap the values of `text` columns in this so
|
|
6
|
+
# the Exporter's visitor can render them as block scalars regardless of
|
|
7
|
+
# length, while `string`/varchar columns stay inline.
|
|
8
|
+
#
|
|
9
|
+
# Trailing whitespace on a line (a space/tab before a newline) makes
|
|
10
|
+
# libyaml's emitter refuse block style and silently fall back to a
|
|
11
|
+
# double-quoted inline scalar — which is exactly what breaks the "text
|
|
12
|
+
# columns are always block scalars" promise. Such whitespace is virtually
|
|
13
|
+
# always an accidental typo, so we strip it per line on construction. This
|
|
14
|
+
# also normalizes CR (`\r\n`/`\r`), which trips the same fallback.
|
|
15
|
+
class LiteralString < ::String
|
|
16
|
+
def self.new(value)
|
|
17
|
+
super(normalize(value.to_s))
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def self.normalize(value)
|
|
21
|
+
value.split("\n", -1).map(&:rstrip).join("\n")
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# Walks a record + structure and emits the YAML document.
|
|
26
|
+
#
|
|
27
|
+
# Key order follows declaration order of the structure's nodes. List order
|
|
28
|
+
# inside a `many` association is decided by the node itself.
|
|
29
|
+
#
|
|
30
|
+
# `omit_nil` (an export-time option, passed from `yaml_export`) drops keys
|
|
31
|
+
# whose exported value is "empty": nil, or an empty list. This is round-trip
|
|
32
|
+
# safe because import treats a missing key, an explicit `null`, and an empty
|
|
33
|
+
# list identically. An owned child that exists but has only nil attributes
|
|
34
|
+
# exports as `{}` and is kept — omitting it would mean "destroy" on
|
|
35
|
+
# re-import.
|
|
36
|
+
class Exporter
|
|
37
|
+
def initialize(record, structure, omit_nil: true)
|
|
38
|
+
@root_record = record
|
|
39
|
+
@root_structure = structure
|
|
40
|
+
@omit_nil = omit_nil
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def to_yaml
|
|
44
|
+
hash = build_hash(@root_record, @root_structure)
|
|
45
|
+
dump(hash)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Called recursively by nodes that own nested structures.
|
|
49
|
+
def build_hash(record, structure)
|
|
50
|
+
result = {}
|
|
51
|
+
structure.nodes.each do |node|
|
|
52
|
+
key, value = node.export(record, exporter: self)
|
|
53
|
+
next if @omit_nil && omit?(value)
|
|
54
|
+
|
|
55
|
+
result[key] = value
|
|
56
|
+
end
|
|
57
|
+
result
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
private
|
|
61
|
+
|
|
62
|
+
def omit?(value)
|
|
63
|
+
value.nil? || (value.is_a?(Array) && value.empty?)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def dump(hash)
|
|
67
|
+
visitor = BlockScalarTree.create
|
|
68
|
+
visitor << hash
|
|
69
|
+
visitor.restore_astral(visitor.tree.yaml)
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# Renders any LiteralString value as a literal block scalar (`|`). Plain
|
|
73
|
+
# Strings and every other type fall through to Psych's default handling,
|
|
74
|
+
# so the output is byte-identical to `YAML.dump` whenever no LiteralString
|
|
75
|
+
# is present.
|
|
76
|
+
#
|
|
77
|
+
# Astral-plane characters (codepoints >= U+10000 — emoji, CJK Ext B, …)
|
|
78
|
+
# are the one thing that defeats block style: libyaml's emitter treats any
|
|
79
|
+
# 4-byte UTF-8 character as non-printable, and a non-printable character
|
|
80
|
+
# can only be written escaped (`\U0001F4A1`), which exists only in
|
|
81
|
+
# double-quoted style. So a single emoji silently drops the whole value
|
|
82
|
+
# back to an inline quoted scalar. BMP characters (umlauts, accents, ✓, →)
|
|
83
|
+
# are unaffected.
|
|
84
|
+
#
|
|
85
|
+
# To keep block style we swap each astral character out for a Private Use
|
|
86
|
+
# Area sentinel — which libyaml *does* consider printable — before
|
|
87
|
+
# emitting, then swap the real characters back into the finished document.
|
|
88
|
+
# The sentinels never survive in the output, and the round-trip is exact:
|
|
89
|
+
# the parser reads literal astral characters in block scalars without
|
|
90
|
+
# trouble.
|
|
91
|
+
class BlockScalarTree < Psych::Visitors::YAMLTree
|
|
92
|
+
ASTRAL = /[\u{10000}-\u{10FFFF}]/
|
|
93
|
+
# U+E000/U+E001 are Private Use Area: printable to libyaml, and they
|
|
94
|
+
# never carry meaning in real text, so a `<open>digits<close>` token
|
|
95
|
+
# cannot collide with exported content.
|
|
96
|
+
SENTINEL_OPEN = "\u{E000}"
|
|
97
|
+
SENTINEL_CLOSE = "\u{E001}"
|
|
98
|
+
SENTINEL = /#{SENTINEL_OPEN}\d+#{SENTINEL_CLOSE}/.freeze
|
|
99
|
+
|
|
100
|
+
def accept(target)
|
|
101
|
+
if target.is_a?(LiteralString)
|
|
102
|
+
return @emitter.scalar(escape_astral(target.to_s), nil, nil, true, true, Psych::Nodes::Scalar::LITERAL)
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
super
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
# Replaces each astral character with a sentinel token, remembering the
|
|
109
|
+
# reverse mapping so #restore_astral can put the real characters back.
|
|
110
|
+
def escape_astral(text)
|
|
111
|
+
return text unless text.match?(ASTRAL)
|
|
112
|
+
|
|
113
|
+
text.gsub(ASTRAL) do |char|
|
|
114
|
+
forward[char] ||= begin
|
|
115
|
+
token = "#{SENTINEL_OPEN}#{substitutions.size}#{SENTINEL_CLOSE}"
|
|
116
|
+
substitutions[token] = char
|
|
117
|
+
token
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def restore_astral(yaml)
|
|
123
|
+
return yaml if substitutions.empty?
|
|
124
|
+
|
|
125
|
+
yaml.gsub(SENTINEL) { |token| substitutions.fetch(token) }
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
private
|
|
129
|
+
|
|
130
|
+
def substitutions
|
|
131
|
+
@substitutions ||= {}
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def forward
|
|
135
|
+
@forward ||= {}
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
end
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module YamlExporter
|
|
4
|
+
# Drives the import of a YAML document into a tree of ActiveRecord records.
|
|
5
|
+
#
|
|
6
|
+
# Works in a single transaction on the root record's class. For every
|
|
7
|
+
# record it touches (root + any owned child) it runs two phases:
|
|
8
|
+
#
|
|
9
|
+
# 1. pre_save — nodes that mutate columns on *this* record
|
|
10
|
+
# (Attribute, OneReference).
|
|
11
|
+
# 2. save! — the record is now persisted and has an id.
|
|
12
|
+
# 3. post_save — nodes that need parent.id to do their work
|
|
13
|
+
# (OneOwned, ManyPositional, ManyFindBy,
|
|
14
|
+
# ManyReference, ManyThrough).
|
|
15
|
+
#
|
|
16
|
+
# Each node is responsible for its own persistence; this class just
|
|
17
|
+
# orchestrates phases and validates unknown keys.
|
|
18
|
+
class Importer
|
|
19
|
+
def initialize(record, structure)
|
|
20
|
+
@root_record = record
|
|
21
|
+
@root_structure = structure
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def import(yaml_string)
|
|
25
|
+
@root_record.class.transaction do
|
|
26
|
+
data = parse(yaml_string)
|
|
27
|
+
apply(@root_record, @root_structure, data, path: '')
|
|
28
|
+
end
|
|
29
|
+
@root_record
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# Called recursively by nodes that own child records (OneOwned,
|
|
33
|
+
# ManyBase flavors) once they've built or found the child row.
|
|
34
|
+
#
|
|
35
|
+
# `extra_keys` lets the caller (typically a `many` node) declare keys
|
|
36
|
+
# that belong to *it*, not to `structure`. Example: `find_by: :slug`
|
|
37
|
+
# means `slug:` is legal at the entry level but isn't a declared
|
|
38
|
+
# Attribute on the child — it's the discriminator the parent node
|
|
39
|
+
# consumes. The child's own Attribute setters never see it because
|
|
40
|
+
# no Attribute node declares it; it simply passes validation.
|
|
41
|
+
def apply(record, structure, data, path:, extra_keys: [])
|
|
42
|
+
data = {} if data.nil?
|
|
43
|
+
unless data.is_a?(Hash)
|
|
44
|
+
raise UnknownAttributeError, "#{describe_path(path)}: expected a mapping, got #{data.class}"
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
validate_keys!(structure, data, path: path, extra_keys: extra_keys)
|
|
48
|
+
|
|
49
|
+
pre, post = structure.nodes.partition { |n| n.phase == :pre_save }
|
|
50
|
+
pre.each { |n| n.import(record, data, path: path, importer: self) }
|
|
51
|
+
record.save!
|
|
52
|
+
post.each { |n| n.import(record, data, path: path, importer: self) }
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
private
|
|
56
|
+
|
|
57
|
+
def parse(yaml_string)
|
|
58
|
+
data = YAML.safe_load(
|
|
59
|
+
yaml_string,
|
|
60
|
+
permitted_classes: [Date, Time, Symbol],
|
|
61
|
+
aliases: true
|
|
62
|
+
)
|
|
63
|
+
data = {} if data.nil?
|
|
64
|
+
unless data.is_a?(Hash)
|
|
65
|
+
raise UnknownAttributeError, "root: expected a mapping, got #{data.class}"
|
|
66
|
+
end
|
|
67
|
+
data
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def validate_keys!(structure, data, path:, extra_keys: [])
|
|
71
|
+
declared = structure.nodes.flat_map(&:yaml_keys).to_set
|
|
72
|
+
extra_keys.each { |k| declared << k.to_s }
|
|
73
|
+
unknown = data.keys.map(&:to_s).reject { |k| declared.include?(k) }
|
|
74
|
+
return if unknown.empty?
|
|
75
|
+
|
|
76
|
+
location = describe_path(path)
|
|
77
|
+
unknown_list = unknown.map { |k| "'#{k}'" }.join(', ')
|
|
78
|
+
raise UnknownAttributeError,
|
|
79
|
+
"#{location}: unknown key#{unknown.size == 1 ? '' : 's'} #{unknown_list}"
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def describe_path(path)
|
|
83
|
+
path.empty? ? 'root' : path
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module YamlExporter
|
|
4
|
+
module Nodes
|
|
5
|
+
# A single declared column on the current record.
|
|
6
|
+
#
|
|
7
|
+
# Phase: :pre_save (assigns a value on `record` before `record.save!`).
|
|
8
|
+
class Attribute
|
|
9
|
+
attr_reader :name
|
|
10
|
+
|
|
11
|
+
# `owner_class` may be a Class or a 0-arity callable. The callable form
|
|
12
|
+
# matches how the rest of the node tree defers reflection lookups —
|
|
13
|
+
# needed by the anonymous AR classes in dsl_validation_test.
|
|
14
|
+
def initialize(name:, owner_class: nil)
|
|
15
|
+
@name = name.to_sym
|
|
16
|
+
@owner_class_resolver =
|
|
17
|
+
if owner_class.nil?
|
|
18
|
+
nil
|
|
19
|
+
elsif owner_class.respond_to?(:call)
|
|
20
|
+
owner_class
|
|
21
|
+
else
|
|
22
|
+
-> { owner_class }
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def phase
|
|
27
|
+
:pre_save
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def yaml_keys
|
|
31
|
+
[@name.to_s]
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Missing key, explicit null, and provided value all normalize to the
|
|
35
|
+
# same AR setter call — so "no author" and "author: null" mean the
|
|
36
|
+
# same thing downstream.
|
|
37
|
+
def import(record, data, path:, importer:)
|
|
38
|
+
value = data.key?(@name.to_s) ? data[@name.to_s] : nil
|
|
39
|
+
record.public_send("#{@name}=", value)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# `text` columns export as YAML literal block scalars (`|`); we signal
|
|
43
|
+
# that by wrapping the string value in LiteralString. `string`/varchar
|
|
44
|
+
# columns stay inline regardless of length.
|
|
45
|
+
def export(record, exporter:)
|
|
46
|
+
value = record.public_send(@name)
|
|
47
|
+
value = LiteralString.new(value) if value.is_a?(::String) && text_column?
|
|
48
|
+
[@name.to_s, value]
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def schema_fragment
|
|
52
|
+
{ @name => { type: TypeInference.schema_type_for(owner_class, @name) } }
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
private
|
|
56
|
+
|
|
57
|
+
def text_column?
|
|
58
|
+
return @text_column if defined?(@text_column)
|
|
59
|
+
|
|
60
|
+
@text_column = TypeInference.text_column?(owner_class, @name)
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def owner_class
|
|
64
|
+
return nil unless @owner_class_resolver
|
|
65
|
+
|
|
66
|
+
@owner_class ||= @owner_class_resolver.call
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module YamlExporter
|
|
4
|
+
module Nodes
|
|
5
|
+
# Shared base for `many` flavors that take a block:
|
|
6
|
+
# - ManyPositional (no find_by, no through)
|
|
7
|
+
# - ManyFindBy (find_by, no through)
|
|
8
|
+
# - ManyThrough (through + find_by)
|
|
9
|
+
#
|
|
10
|
+
# Holds the block's sub-structure and the common accessors. Subclasses
|
|
11
|
+
# override how each YAML entry maps to an existing-or-new child record.
|
|
12
|
+
# ManyReference is NOT a subclass — it has no block and different
|
|
13
|
+
# semantics.
|
|
14
|
+
#
|
|
15
|
+
# Target-class resolution is deferred: we store the owner class and the
|
|
16
|
+
# association name, and resolve the reflected class on demand. Eager
|
|
17
|
+
# resolution breaks anonymous ActiveRecord classes whose inverse
|
|
18
|
+
# associations (often referenced via string `class_name:`) can't be
|
|
19
|
+
# resolved at declaration time.
|
|
20
|
+
class ManyBase
|
|
21
|
+
attr_reader :name, :owner_class, :find_by, :positioned_by, :sub_structure
|
|
22
|
+
|
|
23
|
+
def initialize(name:, owner_class:, find_by: nil, positioned_by: nil, &block)
|
|
24
|
+
raise ArgumentError, "`many #{name.inspect}`: block required" unless block
|
|
25
|
+
|
|
26
|
+
@name = name.to_sym
|
|
27
|
+
@owner_class = owner_class
|
|
28
|
+
@find_by = find_by&.to_sym
|
|
29
|
+
@positioned_by = positioned_by&.to_sym
|
|
30
|
+
|
|
31
|
+
reflection = owner_class.reflect_on_association(@name)
|
|
32
|
+
unless reflection
|
|
33
|
+
raise ArgumentError,
|
|
34
|
+
"`many #{name.inspect}`: #{owner_class} has no association `#{name}`"
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Pass a lazy class resolver into the inner Builder: attribute-only
|
|
38
|
+
# blocks never trigger it.
|
|
39
|
+
@sub_structure = Builder.new(-> { entry_class }, &block).build
|
|
40
|
+
|
|
41
|
+
validate_positioned_by!
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def phase
|
|
45
|
+
:post_save
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def yaml_keys
|
|
49
|
+
[@name.to_s]
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def target_class
|
|
53
|
+
@target_class ||= @owner_class.reflect_on_association(@name).klass
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Class against which the block is evaluated. For vanilla `many ... do
|
|
57
|
+
# end` this is the target class. ManyThrough overrides to return the
|
|
58
|
+
# join class (the block's attributes attach to the join row).
|
|
59
|
+
def entry_class
|
|
60
|
+
target_class
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def import(parent, data, path:, importer:)
|
|
64
|
+
entries = data.key?(@name.to_s) ? data[@name.to_s] : nil
|
|
65
|
+
entries = [] if entries.nil?
|
|
66
|
+
|
|
67
|
+
unless entries.is_a?(Array)
|
|
68
|
+
raise UnknownAttributeError,
|
|
69
|
+
"#{list_path(path)}: expected a list, got #{entries.class}"
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
parent.association(@name).reset
|
|
73
|
+
existing = current_entries(parent)
|
|
74
|
+
|
|
75
|
+
# Object-identity set: `find_or_build_child` returns the exact same
|
|
76
|
+
# Ruby object from `existing` when it matches, so we don't have to
|
|
77
|
+
# invent a primary-key-based identifier (keeps this working for
|
|
78
|
+
# composite PKs, custom primary keys, and other non-id models).
|
|
79
|
+
kept = Set.new.compare_by_identity
|
|
80
|
+
|
|
81
|
+
entries.each_with_index do |entry, index|
|
|
82
|
+
unless entry.is_a?(Hash)
|
|
83
|
+
raise UnknownAttributeError,
|
|
84
|
+
"#{entry_path(path, index)}: expected a mapping, got #{entry.class}"
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
child = find_or_build_child(parent, entry, index, existing: existing)
|
|
88
|
+
# positioned_by is DSL-owned: the column value is derived from the
|
|
89
|
+
# array index (1-based), never read from the YAML entry. We set it
|
|
90
|
+
# on the child before #apply so the pre-save phase persists it in
|
|
91
|
+
# the same round-trip as the block's attributes.
|
|
92
|
+
child.public_send("#{@positioned_by}=", index + 1) if @positioned_by
|
|
93
|
+
|
|
94
|
+
importer.apply(child, @sub_structure, entry,
|
|
95
|
+
path: entry_path(path, index),
|
|
96
|
+
extra_keys: extra_entry_keys)
|
|
97
|
+
kept << child
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
destroy_missing(existing, kept)
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def export(parent, exporter:)
|
|
104
|
+
records = sort_for_export(Array(parent.public_send(@name)))
|
|
105
|
+
list = records.map { |child| exporter.build_hash(child, @sub_structure) }
|
|
106
|
+
[@name.to_s, list]
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def schema_fragment
|
|
110
|
+
props = @sub_structure.nodes.each_with_object({}) { |n, acc| acc.merge!(n.schema_fragment) }
|
|
111
|
+
extra_entry_schema.each { |k, v| props[k.to_sym] = v }
|
|
112
|
+
{ @name => { type: 'array', items: { type: 'object', properties: props } } }
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# --- Subclass hooks --------------------------------------------------
|
|
116
|
+
|
|
117
|
+
# Return the child records currently associated with `parent`.
|
|
118
|
+
# Order is whatever the association's default scope produces — if
|
|
119
|
+
# you care (positional semantics), set `-> { order(...) }` on the
|
|
120
|
+
# `has_many`. ManyFindBy / ManyThrough match by column or
|
|
121
|
+
# association object, so order doesn't affect their correctness.
|
|
122
|
+
def current_entries(parent)
|
|
123
|
+
parent.public_send(@name).to_a
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
# Build a new child instance bound to `parent`. We delegate to the
|
|
127
|
+
# collection's `.build`, which is the PK-agnostic path: AR fills in the
|
|
128
|
+
# FK (and any polymorphic `*_type` column) by consulting the
|
|
129
|
+
# association's `association_primary_key`, so composite/custom primary
|
|
130
|
+
# keys keep working. Subclasses may override for join-table semantics.
|
|
131
|
+
def build_child(parent)
|
|
132
|
+
parent.public_send(@name).build
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
# Destroy children from `existing` that weren't touched this round.
|
|
136
|
+
# `kept` is a `compare_by_identity` Set of Ruby objects; no PK columns
|
|
137
|
+
# are consulted.
|
|
138
|
+
def destroy_missing(existing, kept)
|
|
139
|
+
existing.each do |child|
|
|
140
|
+
child.destroy! unless kept.include?(child)
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
# Used by Exporter to produce a stable list order. positioned_by,
|
|
145
|
+
# when present, wins over every other ordering rule. The `to_key`
|
|
146
|
+
# fallback is PK-agnostic (returns `[pk_value]` for surrogate-id rows
|
|
147
|
+
# and the composite array for composite-PK rows); nil (unsaved) folds
|
|
148
|
+
# to [] and leaves Ruby's stable sort to preserve incoming order.
|
|
149
|
+
def sort_for_export(records)
|
|
150
|
+
return records.sort_by { |r| [positioned_value(r), r.to_key || []] } if @positioned_by
|
|
151
|
+
|
|
152
|
+
default_export_order(records)
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
# Subclasses override to express their "natural" order (find_by for
|
|
156
|
+
# reference-style flavors). Default uses the record's primary key so
|
|
157
|
+
# we don't bake in `:id`.
|
|
158
|
+
def default_export_order(records)
|
|
159
|
+
records.sort_by { |r| r.to_key || [] }
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
# Additional entry-level keys the subclass considers legal but that
|
|
163
|
+
# aren't declared in the block (e.g. the `find_by` discriminator).
|
|
164
|
+
def extra_entry_keys
|
|
165
|
+
[]
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
# Schema fragment for those extra entry keys. Default: infer the type
|
|
169
|
+
# from the `entry_class` (the class the block is evaluated against) —
|
|
170
|
+
# correct for ManyFindBy (entry_class == target_class). ManyThrough
|
|
171
|
+
# overrides because its extra key lives on target_class, not the join.
|
|
172
|
+
# TypeInference gracefully falls back to 'string' for unknown columns.
|
|
173
|
+
def extra_entry_schema
|
|
174
|
+
extra_entry_keys.each_with_object({}) do |k, acc|
|
|
175
|
+
acc[k.to_sym] = { type: TypeInference.schema_type_for(entry_class, k) }
|
|
176
|
+
end
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
# Child lookup/construction for a single YAML entry. Must return a
|
|
180
|
+
# record (new or existing) that will be passed through Importer#apply.
|
|
181
|
+
# Abstract here; subclasses must implement.
|
|
182
|
+
def find_or_build_child(_parent, _entry, _index, existing:) # rubocop:disable Lint/UnusedMethodArgument
|
|
183
|
+
raise NotImplementedError, "#{self.class} must implement #find_or_build_child"
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
private
|
|
187
|
+
|
|
188
|
+
# Nil positions sort last — treat them as the largest possible index so
|
|
189
|
+
# drifted DB rows still land in a deterministic spot when exporting.
|
|
190
|
+
def positioned_value(record)
|
|
191
|
+
value = record.public_send(@positioned_by)
|
|
192
|
+
value.nil? ? Float::INFINITY : value
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
def list_path(path)
|
|
196
|
+
path.empty? ? @name.to_s : "#{path}.#{@name}"
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
def entry_path(path, index)
|
|
200
|
+
"#{list_path(path)}[#{index}]"
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
def validate_positioned_by!
|
|
204
|
+
return if @positioned_by.nil?
|
|
205
|
+
|
|
206
|
+
declared = @sub_structure.nodes.grep(Nodes::Attribute).map(&:name)
|
|
207
|
+
return unless declared.include?(@positioned_by)
|
|
208
|
+
|
|
209
|
+
raise ArgumentError,
|
|
210
|
+
"`many #{@name.inspect}`: positioned_by column #{@positioned_by.inspect} " \
|
|
211
|
+
"cannot also appear in the block's `attributes` list — the DSL owns it"
|
|
212
|
+
end
|
|
213
|
+
end
|
|
214
|
+
end
|
|
215
|
+
end
|