Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Nested attributes #55

Open
wants to merge 2 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .rubocop_todo.yml
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ Metrics/BlockLength:
# Offense count: 2
# Configuration parameters: CountComments.
Metrics/ClassLength:
Max: 128
Max: 131

# Offense count: 4
Metrics/CyclomaticComplexity:
Expand Down
8 changes: 7 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
* Nested attributes support reference associations.

* References many association destruction works now the same way as embedded many one does. If any object was destroyed or marked for destruction - if will be moved to the destroyed array on `apply_changes` call (on the parent object saving).

* References one and many associations now don't destroy the object if it was marked for destruction, but the association is not autosave. In this case the object will be unlinked from the parent object and moved to the `destroyed` array for the references many association.

* No more `ActiveData.persistence_adapter` method. Define `self.active_data_persistence_adapter` directly in the desired class.

* Represented attributes are not provided by default, to add them, `include ActiveData::Model::Representation`

* `include ActiveData::Model::Associations::Validations` is not included by default anymore, to get `validate_ancestry!`, `valid_ancestry?` and `invalid_ancestry?` methods back you neet to include this module manually.
* `include ActiveData::Model::Associations::Validations` is not included by default anymore, to get `validate_ancestry!`, `valid_ancestry?` and `invalid_ancestry?` methods back you need to include this module manually.
1 change: 1 addition & 0 deletions Gemfile
Original file line number Diff line number Diff line change
Expand Up @@ -5,4 +5,5 @@ gemspec
group :test do
gem 'guard'
gem 'guard-rspec'
gem 'pry'
end
21 changes: 13 additions & 8 deletions lib/active_data/model/associations/nested_attributes.rb
Original file line number Diff line number Diff line change
Expand Up @@ -71,12 +71,8 @@ def self.assign_nested_attributes_for_one_to_one_association(object, association
association = object.association(association_name)
existing_record = association.target
primary_attribute_name = primary_name_for(association.reflection.klass)
if existing_record
primary_attribute = existing_record.attribute(primary_attribute_name)
primary_attribute_value = primary_attribute.typecast(attributes[primary_attribute_name]) if primary_attribute
end

if existing_record && (!primary_attribute || options[:update_only] || existing_record.primary_attribute == primary_attribute_value)
if existing_record && (options[:update_only] || existing_record_matches?(existing_record, primary_attribute_name, attributes))
assign_to_or_mark_for_destruction(existing_record, attributes, options[:allow_destroy]) unless call_reject_if(object, association_name, attributes)
elsif attributes[primary_attribute_name].present?
raise ActiveData::ObjectNotFound.new(object, association_name, attributes[primary_attribute_name])
Expand Down Expand Up @@ -123,9 +119,7 @@ def self.assign_nested_attributes_for_collection_association(object, association
end
else
existing_record = association.target.detect do |record|
primary_attribute_value = record.attribute(primary_attribute_name)
.typecast(attributes[primary_attribute_name])
record.primary_attribute == primary_attribute_value
existing_record_matches?(record, primary_attribute_name, attributes)
end
if existing_record
unless call_reject_if(object, association_name, attributes)
Expand Down Expand Up @@ -189,6 +183,17 @@ def self.unassignable_keys(object)
def self.primary_name_for(klass)
klass < ActiveData::Model ? klass.primary_name : 'id'
end

def self.existing_record_matches?(existing_record, primary_attribute_name, attributes)
if existing_record.is_a?(ActiveData::Model)
primary_attribute = existing_record.attribute(primary_attribute_name)
primary_attribute_value = primary_attribute.typecast(attributes[primary_attribute_name]) if primary_attribute

!primary_attribute || existing_record.primary_attribute == primary_attribute_value
else
attributes[primary_attribute_name].present? && existing_record.send(primary_attribute_name).to_s == attributes[primary_attribute_name].to_s
end
end
end

module ClassMethods
Expand Down
23 changes: 20 additions & 3 deletions lib/active_data/model/associations/references_many.rb
Original file line number Diff line number Diff line change
Expand Up @@ -18,11 +18,25 @@ def create!(attributes = {})
object
end

def destroyed
@destroyed ||= []
end

def apply_changes
target.all? do |object|
@destroyed = []

result = target.all? do |object|
if object
if object.marked_for_destruction? && reflection.autosave?
object.destroy
if object.marked_for_destruction?
@destroyed.push(object)
if reflection.autosave?
object.destroy
else
true
end
elsif object.destroyed?
@destroyed.push(object)
true
elsif object.new_record? || (reflection.autosave? && object.changed?)
persist_object(object)
else
Expand All @@ -32,6 +46,9 @@ def apply_changes
true
end
end

@target -= @destroyed
result
end

def target=(object)
Expand Down
9 changes: 7 additions & 2 deletions lib/active_data/model/associations/references_one.rb
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,13 @@ def create!(attributes = {})

def apply_changes
if target
if target.marked_for_destruction? && reflection.autosave?
target.destroy
if target.marked_for_destruction?
if reflection.autosave?
target.destroy
else
replace(nil)
true
end
elsif target.new_record? || (reflection.autosave? && target.changed?)
persist_object(target)
else
Expand Down
16 changes: 9 additions & 7 deletions spec/lib/active_data/active_record/nested_attributes_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,16 @@
require 'shared/nested_attribute_examples'

describe ActiveData::ActiveRecord::NestedAttributes do
before do
stub_class(:user, ActiveRecord::Base) do
embeds_one :profile
embeds_many :projects
context 'embedded nested attributes' do
before do
stub_class(:user, ActiveRecord::Base) do
embeds_one :profile
embeds_many :projects

accepts_nested_attributes_for :profile, :projects
accepts_nested_attributes_for :profile, :projects
end
end
end

include_examples 'nested attributes'
include_examples 'embedded nested attributes'
end
end
171 changes: 164 additions & 7 deletions spec/lib/active_data/model/associations/nested_attributes_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
require 'shared/nested_attribute_examples'

describe ActiveData::Model::Associations::NestedAttributes do
context '' do
context 'embedded nested attributes' do
before do
stub_model :user do
include ActiveData::Model::Associations
Expand All @@ -19,16 +19,17 @@ def save
end
end

include_examples 'nested attributes'
include_examples 'embedded nested attributes'
end

xcontext 'references_one' do
context 'referenced nested attributes' do
before do
stub_class(:author, ActiveRecord::Base)
stub_class(:user, ActiveRecord::Base)

stub_model :book do
include ActiveData::Model::Associations
include ActiveData::Model::Lifecycle

references_one :author
references_many :users
Expand Down Expand Up @@ -57,7 +58,7 @@ def save
end

context 'existing' do
let(:author) { Author.new(name: 'Author') }
let(:author) { Author.create!(name: 'Author') }
let(:book) { Book.new author: author }

specify { expect { book.author_attributes = {id: 42, name: 'Author'} }.to raise_error ActiveData::ObjectNotFound }
Expand Down Expand Up @@ -106,10 +107,166 @@ def save
end
end
end
end

context 'references_many' do
let(:book) { Book.new }
context 'references_many' do
let(:book) { Book.new }

specify { expect { book.users_attributes = {} }.not_to change { book.users } }
specify do
expect { book.users_attributes = [{email: 'User 1'}, {email: 'User 2'}] }
.to change { book.users.map(&:email) }.to(['User 1', 'User 2'])
end
specify do
expect { book.users_attributes = {1 => {email: 'User 1'}, 2 => {email: 'User 2'}} }
.to change { book.users.map(&:email) }.to(['User 1', 'User 2'])
end
specify do
expect { book.users_attributes = [{id: 33, email: 'User 1'}, {email: 'User 2'}] }
.to raise_error ActiveData::ObjectNotFound
end
specify do
expect { book.users_attributes = [{email: ''}, {email: 'User 2'}] }
.to change { book.users.map(&:email) }.to(['', 'User 2'])
end

context ':limit' do
before { Book.accepts_nested_attributes_for :users, limit: 1 }

specify do
expect { book.users_attributes = [{email: 'User 1'}] }
.to change { book.users.map(&:email) }.to(['User 1'])
end
specify do
expect { book.users_attributes = [{email: 'User 1'}, {email: 'User 2'}] }
.to raise_error ActiveData::TooManyObjects
end
end

context ':reject_if' do
context do
before { Book.accepts_nested_attributes_for :users, reject_if: :all_blank }
specify do
expect { book.users_attributes = [{email: ''}, {email: 'User 2'}] }
.to change { book.users.map(&:email) }.to(['User 2'])
end
end

context do
before { Book.accepts_nested_attributes_for :users, reject_if: ->(attributes) { attributes['email'].blank? } }
specify do
expect { book.users_attributes = [{email: ''}, {email: 'User 2'}] }
.to change { book.users.map(&:email) }.to(['User 2'])
end
end

context do
before { Book.accepts_nested_attributes_for :users, reject_if: ->(attributes) { attributes['foobar'].blank? } }
specify do
expect { book.users_attributes = [{email: ''}, {email: 'User 2'}] }
.not_to change { book.users }
end
end
end

context 'existing' do
let(:users) { Array.new(2) { |i| User.create!(email: "User #{i.next}").tap { |pr| pr.id = 42 + i } } }
let(:book) { Book.new users: users }

specify do
expect do
book.users_attributes = [
{id: users.first.id, email: 'User 3'},
{email: 'User 4'}
]
end.to change { book.users.map(&:email) }.to(['User 3', 'User 2', 'User 4'])
end
specify do
expect do
book.users_attributes = [
{id: users.first.id, email: 'User 3'},
{id: 33, email: 'User 4'}
]
end.to raise_error ActiveData::ObjectNotFound
end
specify do
expect do
book.users_attributes = [
{id: users.first.id, email: 'User 3'},
{id: 33, email: 'User 4', _destroy: 1}
]
end.to raise_error ActiveData::ObjectNotFound
end
specify do
expect do
book.users_attributes = {
1 => {id: users.first.id, email: 'User 3'},
2 => {email: 'User 4'}
}
end.to change { book.users.map(&:email) }.to(['User 3', 'User 2', 'User 4'])
end
specify do
expect do
book.users_attributes = [
{id: users.first.id, email: 'User 3', _destroy: '1'},
{email: 'User 4', _destroy: '1'}
]
end.to change { book.users.map(&:email) }.to(['User 3', 'User 2'])
end
specify do
expect do
book.users_attributes = [
{id: users.first.id, email: 'User 3', _destroy: '1'},
{email: 'User 4', _destroy: '1'}
]
book.save { true }
end.to change { book.users.map(&:email) }.to(['User 3', 'User 2'])
end

context ':allow_destroy' do
before { Book.accepts_nested_attributes_for :users, allow_destroy: true }

specify do
expect do
book.users_attributes = [
{id: users.first.id, email: 'User 3', _destroy: '1'},
{email: 'User 4', _destroy: '1'}
]
end.to change { book.users.map(&:email) }.to(['User 3', 'User 2'])
end
specify do
expect do
book.users_attributes = [
{id: users.first.id, email: 'User 3', _destroy: '1'},
{email: 'User 4', _destroy: '1'}
]
book.save { true }
end.to change { book.users.map(&:email) }.to(['User 2'])
end
end

context ':update_only' do
before { Book.accepts_nested_attributes_for :users, update_only: true }

specify do
expect do
book.users_attributes = [
{id: users.first.id, email: 'User 3'},
{email: 'User 4'}
]
end.to change { book.users.map(&:email) }.to(['User 3', 'User 2'])
end

specify do
expect do
book.users_attributes = [
{id: users.last.id, email: 'User 3'},
{id: users.first.id.pred, email: 'User 0'}
]
end.to raise_error ActiveData::ObjectNotFound
end
end
end
end
end
end

Expand Down
Loading