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.
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