feat(orm): introduce Prism and Orm
This commit is contained in:
1 parent
fcc62dbd2b
commit
e5006ea135
1 file changed
+184
@@ -0,0 +1,184 @@
|
||||
module Prism
|
||||
module Orm
|
||||
# accepts_nested_attributes_for :comments - lets a parent form submit its
|
||||
# own attributes plus a nested collection (or single record, for has_one)
|
||||
# of child attributes in one request, e.g. `post.update(title: 'x',
|
||||
# comments_attributes: [{ body: 'hi' }, { id: 3, _destroy: '1' }])`,
|
||||
# mirroring ActiveRecord's autosave associations.
|
||||
#
|
||||
# Building, validating and persisting happen in two callbacks registered
|
||||
# on the *first* accepts_nested_attributes_for call for a given name:
|
||||
# `before_save` builds every child record, runs its own `valid?`, and
|
||||
# copies any failures onto the parent's own `errors` under the
|
||||
# association name, aborting the whole save (via `throw :abort`, same as
|
||||
# every other before_* hook in this ORM) before anything is written.
|
||||
# Only once that has passed does `after_save` set each child's foreign
|
||||
# key to the now-guaranteed-present parent id and save (or destroy) it -
|
||||
# this has to be `after_save`, not `before_save`, because a brand-new
|
||||
# parent has no id to hand its children until the insert has happened,
|
||||
# and both hooks run inside the same `transaction` block as the parent's
|
||||
# own write (see Model#save), so an exception anywhere in the chain
|
||||
# unwinds the parent's insert/update too instead of leaving orphans.
|
||||
module NestedAttributes
|
||||
class TooManyRecords < StandardError; end
|
||||
|
||||
def self.included(base)
|
||||
base.extend(ClassMethods)
|
||||
end
|
||||
|
||||
module ClassMethods
|
||||
def nested_attributes_options
|
||||
@nested_attributes_options ||= {}
|
||||
end
|
||||
|
||||
def accepts_nested_attributes_for(name, allow_destroy: false, reject_if: nil, limit: nil)
|
||||
name = name.to_sym
|
||||
meta = associations[name]
|
||||
raise ArgumentError, "no association #{name.inspect} to accept nested attributes for" unless meta
|
||||
unless %i[has_many has_one].include?(meta[:type])
|
||||
raise ArgumentError,
|
||||
"accepts_nested_attributes_for only supports has_many/has_one (#{name.inspect} is #{meta[:type].inspect})"
|
||||
end
|
||||
|
||||
nested_attributes_options[name] = {
|
||||
allow_destroy: allow_destroy, reject_if: reject_if, limit: limit, collection: meta[:type] == :has_many
|
||||
}
|
||||
|
||||
define_method("#{name}_attributes=") do |attributes|
|
||||
(@nested_attributes_pending ||= {})[name] = attributes
|
||||
end
|
||||
|
||||
register_nested_attributes_callbacks(name)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def nested_attributes_registered
|
||||
@nested_attributes_registered ||= []
|
||||
end
|
||||
|
||||
def register_nested_attributes_callbacks(name)
|
||||
return if nested_attributes_registered.include?(name)
|
||||
nested_attributes_registered << name
|
||||
|
||||
before_save { process_nested_attributes_before_save(name) }
|
||||
after_save { process_nested_attributes_after_save(name) }
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def process_nested_attributes_before_save(name)
|
||||
pending = @nested_attributes_pending&.delete(name)
|
||||
return unless pending
|
||||
|
||||
options = self.class.nested_attributes_options[name]
|
||||
meta = self.class.associations[name]
|
||||
built = build_nested_records(name, pending, options, meta)
|
||||
(@nested_attributes_built ||= {})[name] = built
|
||||
throw :abort if built.any? { |entry| entry[:invalid] }
|
||||
end
|
||||
|
||||
def process_nested_attributes_after_save(name)
|
||||
built = @nested_attributes_built&.delete(name)
|
||||
return unless built
|
||||
|
||||
meta = self.class.associations[name]
|
||||
built.each do |entry|
|
||||
record = entry[:record]
|
||||
if entry[:destroy]
|
||||
record.destroy if record.persisted?
|
||||
else
|
||||
record[meta[:foreign_key]] = id
|
||||
record[meta[:type_key]] = self.class.name if meta[:type_key]
|
||||
record.save
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def build_nested_records(name, pending, options, meta)
|
||||
target_class = Object.const_get(meta[:class_name])
|
||||
entries = options[:collection] ? normalize_nested_collection(pending) : [pending]
|
||||
|
||||
if options[:limit] && entries.size > options[:limit]
|
||||
raise NestedAttributes::TooManyRecords, "maximum #{options[:limit]} records are allowed for #{name}"
|
||||
end
|
||||
|
||||
entries.each_with_object([]) do |raw_attrs, list|
|
||||
entry = build_one_nested_record(name, raw_attrs, options, meta, target_class)
|
||||
list << entry if entry
|
||||
end
|
||||
end
|
||||
|
||||
def build_one_nested_record(name, raw_attrs, options, meta, target_class)
|
||||
attrs = stringify_nested_keys(raw_attrs)
|
||||
id_value = attrs.delete('id')
|
||||
destroy_flag = nested_truthy?(attrs.delete('_destroy'))
|
||||
|
||||
if id_value.nil? || id_value == ''
|
||||
return nil if reject_nested_record?(options[:reject_if], attrs)
|
||||
record = target_class.new
|
||||
else
|
||||
record = find_existing_nested_record(target_class, meta, id_value)
|
||||
if record.nil?
|
||||
errors.add(name, "references an invalid or inaccessible #{Inflector.underscore(meta[:class_name])}")
|
||||
return { record: nil, destroy: false, invalid: true }
|
||||
end
|
||||
end
|
||||
|
||||
return { record: record, destroy: true, invalid: false } if destroy_flag && options[:allow_destroy]
|
||||
|
||||
record.assign(attrs)
|
||||
if record.valid?
|
||||
{ record: record, destroy: false, invalid: false }
|
||||
else
|
||||
record.errors.full_messages.each { |message| errors.add(name, message) }
|
||||
{ record: record, destroy: false, invalid: true }
|
||||
end
|
||||
end
|
||||
|
||||
# Looks the child up by primary key (Store#find coerces the id, unlike
|
||||
# a plain `where`/`find_by` scan - HTTP params always arrive as
|
||||
# strings, so a raw `find_by(id: "3")` against an Integer-keyed row
|
||||
# would silently miss every time) and only then checks it actually
|
||||
# belongs to this parent, so an id copied from a different parent's
|
||||
# child is rejected rather than quietly hijacked.
|
||||
def find_existing_nested_record(target_class, meta, id_value)
|
||||
return nil if new_record?
|
||||
record = target_class.find(id_value)
|
||||
return nil unless record[meta[:foreign_key]] == id
|
||||
return nil if meta[:type_key] && record[meta[:type_key]] != self.class.name
|
||||
record
|
||||
rescue RecordNotFound
|
||||
nil
|
||||
end
|
||||
|
||||
def normalize_nested_collection(pending)
|
||||
case pending
|
||||
when Hash then pending.values
|
||||
when Array then pending
|
||||
else raise ArgumentError, "expected a Hash or Array of attribute hashes, got #{pending.class}"
|
||||
end
|
||||
end
|
||||
|
||||
def stringify_nested_keys(attrs)
|
||||
attrs.each_with_object({}) { |(key, value), out| out[key.to_s] = value }
|
||||
end
|
||||
|
||||
def nested_truthy?(value)
|
||||
[true, 'true', '1', 1].include?(value)
|
||||
end
|
||||
|
||||
def reject_nested_record?(reject_if, attrs)
|
||||
case reject_if
|
||||
when nil then false
|
||||
when :all_blank
|
||||
attrs.values.all? { |value| value.nil? || (value.respond_to?(:empty?) && value.empty?) }
|
||||
when Symbol then send(reject_if, attrs)
|
||||
when Proc then reject_if.call(attrs)
|
||||
else false
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
Reference in new issue
Block a user