Class: YamlExporter::Nodes::ManyBase

Inherits:
Object
  • Object
show all
Defined in:
lib/yaml_exporter/nodes/many_base.rb

Overview

Shared base for many flavors that take a block:

  • ManyPositional (no find_by, no through)
  • ManyFindBy (find_by, no through)
  • ManyThrough (through + find_by)

Holds the block's sub-structure and the common accessors. Subclasses override how each YAML entry maps to an existing-or-new child record. ManyReference is NOT a subclass — it has no block and different semantics.

Target-class resolution is deferred: we store the owner class and the association name, and resolve the reflected class on demand. Eager resolution breaks anonymous ActiveRecord classes whose inverse associations (often referenced via string class_name:) can't be resolved at declaration time.

Direct Known Subclasses

ManyFindBy, ManyPositional, ManyThrough

Instance Attribute Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(name:, owner_class:, find_by: nil, positioned_by: nil, &block) ⇒ ManyBase

Returns a new instance of ManyBase.

Raises:

  • (ArgumentError)


23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
# File 'lib/yaml_exporter/nodes/many_base.rb', line 23

def initialize(name:, owner_class:, find_by: nil, positioned_by: nil, &block)
  raise ArgumentError, "`many #{name.inspect}`: block required" unless block

  @name = name.to_sym
  @owner_class = owner_class
  @find_by = find_by&.to_sym
  @positioned_by = positioned_by&.to_sym

  reflection = owner_class.reflect_on_association(@name)
  unless reflection
    raise ArgumentError,
          "`many #{name.inspect}`: #{owner_class} has no association `#{name}`"
  end

  # Pass a lazy class resolver into the inner Builder: attribute-only
  # blocks never trigger it.
  @sub_structure = Builder.new(-> { entry_class }, &block).build

  validate_positioned_by!
end

Instance Attribute Details

#find_byObject (readonly)

Returns the value of attribute find_by.



21
22
23
# File 'lib/yaml_exporter/nodes/many_base.rb', line 21

def find_by
  @find_by
end

#nameObject (readonly)

Returns the value of attribute name.



21
22
23
# File 'lib/yaml_exporter/nodes/many_base.rb', line 21

def name
  @name
end

#owner_classObject (readonly)

Returns the value of attribute owner_class.



21
22
23
# File 'lib/yaml_exporter/nodes/many_base.rb', line 21

def owner_class
  @owner_class
end

#positioned_byObject (readonly)

Returns the value of attribute positioned_by.



21
22
23
# File 'lib/yaml_exporter/nodes/many_base.rb', line 21

def positioned_by
  @positioned_by
end

#sub_structureObject (readonly)

Returns the value of attribute sub_structure.



21
22
23
# File 'lib/yaml_exporter/nodes/many_base.rb', line 21

def sub_structure
  @sub_structure
end

Instance Method Details

#build_child(parent) ⇒ Object

Build a new child instance bound to parent. We delegate to the collection's .build, which is the PK-agnostic path: AR fills in the FK (and any polymorphic *_type column) by consulting the association's association_primary_key, so composite/custom primary keys keep working. Subclasses may override for join-table semantics.



131
132
133
# File 'lib/yaml_exporter/nodes/many_base.rb', line 131

def build_child(parent)
  parent.public_send(@name).build
end

#current_entries(parent) ⇒ Object

Return the child records currently associated with parent. Order is whatever the association's default scope produces — if you care (positional semantics), set -> { order(...) } on the has_many. ManyFindBy / ManyThrough match by column or association object, so order doesn't affect their correctness.



122
123
124
# File 'lib/yaml_exporter/nodes/many_base.rb', line 122

def current_entries(parent)
  parent.public_send(@name).to_a
end

#default_export_order(records) ⇒ Object

Subclasses override to express their "natural" order (find_by for reference-style flavors). Default uses the record's primary key so we don't bake in :id.



158
159
160
# File 'lib/yaml_exporter/nodes/many_base.rb', line 158

def default_export_order(records)
  records.sort_by { |r| r.to_key || [] }
end

#destroy_missing(existing, kept) ⇒ Object

Destroy children from existing that weren't touched this round. kept is a compare_by_identity Set of Ruby objects; no PK columns are consulted.



138
139
140
141
142
# File 'lib/yaml_exporter/nodes/many_base.rb', line 138

def destroy_missing(existing, kept)
  existing.each do |child|
    child.destroy! unless kept.include?(child)
  end
end

#entry_classObject

Class against which the block is evaluated. For vanilla many ... do end this is the target class. ManyThrough overrides to return the join class (the block's attributes attach to the join row).



59
60
61
# File 'lib/yaml_exporter/nodes/many_base.rb', line 59

def entry_class
  target_class
end

#export(parent, exporter:) ⇒ Object



103
104
105
106
107
# File 'lib/yaml_exporter/nodes/many_base.rb', line 103

def export(parent, exporter:)
  records = sort_for_export(Array(parent.public_send(@name)))
  list = records.map { |child| exporter.build_hash(child, @sub_structure) }
  [@name.to_s, list]
end

#extra_entry_keysObject

Additional entry-level keys the subclass considers legal but that aren't declared in the block (e.g. the find_by discriminator).



164
165
166
# File 'lib/yaml_exporter/nodes/many_base.rb', line 164

def extra_entry_keys
  []
end

#extra_entry_schemaObject

Schema fragment for those extra entry keys. Default: infer the type from the entry_class (the class the block is evaluated against) — correct for ManyFindBy (entry_class == target_class). ManyThrough overrides because its extra key lives on target_class, not the join. TypeInference gracefully falls back to 'string' for unknown columns.



173
174
175
176
177
# File 'lib/yaml_exporter/nodes/many_base.rb', line 173

def extra_entry_schema
  extra_entry_keys.each_with_object({}) do |k, acc|
    acc[k.to_sym] = { type: TypeInference.schema_type_for(entry_class, k) }
  end
end

#find_or_build_child(_parent, _entry, _index, existing:) ⇒ Object

Child lookup/construction for a single YAML entry. Must return a record (new or existing) that will be passed through Importer#apply. Abstract here; subclasses must implement.

Raises:

  • (NotImplementedError)


182
183
184
# File 'lib/yaml_exporter/nodes/many_base.rb', line 182

def find_or_build_child(_parent, _entry, _index, existing:) # rubocop:disable Lint/UnusedMethodArgument
  raise NotImplementedError, "#{self.class} must implement #find_or_build_child"
end

#import(parent, data, path:, importer:) ⇒ Object



63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
# File 'lib/yaml_exporter/nodes/many_base.rb', line 63

def import(parent, data, path:, importer:)
  entries = data.key?(@name.to_s) ? data[@name.to_s] : nil
  entries = [] if entries.nil?

  unless entries.is_a?(Array)
    raise UnknownAttributeError,
          "#{list_path(path)}: expected a list, got #{entries.class}"
  end

  parent.association(@name).reset
  existing = current_entries(parent)

  # Object-identity set: `find_or_build_child` returns the exact same
  # Ruby object from `existing` when it matches, so we don't have to
  # invent a primary-key-based identifier (keeps this working for
  # composite PKs, custom primary keys, and other non-id models).
  kept = Set.new.compare_by_identity

  entries.each_with_index do |entry, index|
    unless entry.is_a?(Hash)
      raise UnknownAttributeError,
            "#{entry_path(path, index)}: expected a mapping, got #{entry.class}"
    end

    child = find_or_build_child(parent, entry, index, existing: existing)
    # positioned_by is DSL-owned: the column value is derived from the
    # array index (1-based), never read from the YAML entry. We set it
    # on the child before #apply so the pre-save phase persists it in
    # the same round-trip as the block's attributes.
    child.public_send("#{@positioned_by}=", index + 1) if @positioned_by

    importer.apply(child, @sub_structure, entry,
                   path: entry_path(path, index),
                   extra_keys: extra_entry_keys)
    kept << child
  end

  destroy_missing(existing, kept)
end

#phaseObject



44
45
46
# File 'lib/yaml_exporter/nodes/many_base.rb', line 44

def phase
  :post_save
end

#schema_fragmentObject



109
110
111
112
113
# File 'lib/yaml_exporter/nodes/many_base.rb', line 109

def schema_fragment
  props = @sub_structure.nodes.each_with_object({}) { |n, acc| acc.merge!(n.schema_fragment) }
  extra_entry_schema.each { |k, v| props[k.to_sym] = v }
  { @name => { type: 'array', items: { type: 'object', properties: props } } }
end

#sort_for_export(records) ⇒ Object

Used by Exporter to produce a stable list order. positioned_by, when present, wins over every other ordering rule. The to_key fallback is PK-agnostic (returns [pk_value] for surrogate-id rows and the composite array for composite-PK rows); nil (unsaved) folds to [] and leaves Ruby's stable sort to preserve incoming order.



149
150
151
152
153
# File 'lib/yaml_exporter/nodes/many_base.rb', line 149

def sort_for_export(records)
  return records.sort_by { |r| [positioned_value(r), r.to_key || []] } if @positioned_by

  default_export_order(records)
end

#target_classObject



52
53
54
# File 'lib/yaml_exporter/nodes/many_base.rb', line 52

def target_class
  @target_class ||= @owner_class.reflect_on_association(@name).klass
end

#yaml_keysObject



48
49
50
# File 'lib/yaml_exporter/nodes/many_base.rb', line 48

def yaml_keys
  [@name.to_s]
end