Skip to content

Commit

Permalink
Merge pull request #31 from elardo/add-after-clone-callback
Browse files Browse the repository at this point in the history
Add after_clone callback
  • Loading branch information
ssnickolay authored Mar 20, 2019
2 parents 0c099de + fb9dc5f commit 50cd2e2
Show file tree
Hide file tree
Showing 14 changed files with 235 additions and 8 deletions.
8 changes: 6 additions & 2 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
# Change log

## 1.1.0 (2019-03-09)
## 1.1.0 (2019-03-20)

- Added opporotunity to include belongs_to association for active_record adapter. ([@madding][])

- Add `after_clone` declaration. ([@elardo][])
- Add opporotunity to include belongs_to association for active_record adapter. ([@madding][])

## 1.0.0 (2019-02-26)

Expand Down Expand Up @@ -40,3 +42,5 @@ See [migration guide](https://clowne.evilmartians.io/docs/from_v02_to_v10.html)

[@palkan]: https://github.com/palkan
[@ssnickolay]: https://github.com/ssnickolay
[@elardo]: https://github.com/elardo
[@madding]: https://github.com/madding
56 changes: 56 additions & 0 deletions docs/after_clone.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
---
id: after_clone
title: After Clone
---

The `after_clone` callbacks can help you to make additional operations on cloned record, like checking it with some business logic or actualizing cloned record attributes, before it will be saved to the database. Also it can help to avoid unneeded usage of [`after_persist`](after_persist.md) callbacks, and additional queries to database.

Examples:

```ruby
class User < ActiveRecord::Base
# create_table :users do |t|
# t.string :login
# t.integer :draft_count
# end

has_many :posts # all user's posts
end

class Post < ActiveRecord::Base
# create_table :posts do |t|
# t.integer :user_id
# t.boolean :is_draft
# end

scope :draft, -> { where is_draft: true }
end

class UserCloner < Clowne::Cloner
# clone user and his posts, which is drafts
include_association :posts, scope: :draft

after_clone do |_origin, clone, **|
# actualize user attribute
clone.draft_count = clone.posts.count
end
end
```

`after_clone` runs when you call `Operation#to_record` or [`Operation#persist`]('operation.md) (or `Operation#persist!`)

```ruby
# prepare data
user = User.create
3.times { Post.create(user: user, is_draft: false) }
2.times { Post.create(user: user, is_draft: true) }

operation = UserCloner.call(user)
# => <#Clowne::Utils::Operation ...>

clone = operation.to_record
# => <#User id: nil, draft_count: 2 ...>

clone.draft_count == user.posts.draft.count
# => true
```
1 change: 1 addition & 0 deletions docs/overview.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,5 +20,6 @@ All built-in adapters have the same order and what happens when you call `Operat
- [`clone associations`](include_association.md)
- [`nullify`](nullify.md) attributes
- run [`finalize`](finalize.md) blocks. _The order of [`finalize`](finalize.md) blocks is the order they've been written._
- run [`after_clone`](after_clone.md) callbacks
- __SAVE CLONED RECORD__
- run [`after_persist`](after_persist.md) callbacks
1 change: 1 addition & 0 deletions docs/web/i18n/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
"previous": "Previous",
"tagline": "A flexible gem for cloning your models",
"active_record": "Active Record",
"after_clone": "After Clone",
"after_persist": "After Persist",
"alternatives": "Motivation & Alternatives",
"architecture": "Architecture",
Expand Down
1 change: 1 addition & 0 deletions docs/web/sidebars.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
"exclude_association",
"nullify",
"finalize",
"after_clone",
"after_persist",
"init_as",
"traits",
Expand Down
8 changes: 7 additions & 1 deletion lib/clowne/adapters/base.rb
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
require 'clowne/resolvers/nullify'
require 'clowne/resolvers/finalize'
require 'clowne/resolvers/after_persist'
require 'clowne/resolvers/after_clone'

module Clowne
module Adapters
Expand Down Expand Up @@ -70,6 +71,11 @@ def init_record(record)
)

Clowne::Adapters::Base.register_resolver(
:after_persist, Clowne::Resolvers::AfterPersist,
:after_clone, Clowne::Resolvers::AfterClone,
after: :finalize
)

Clowne::Adapters::Base.register_resolver(
:after_persist, Clowne::Resolvers::AfterPersist,
after: :after_clone
)
4 changes: 3 additions & 1 deletion lib/clowne/adapters/sequel/operation.rb
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,9 @@ def hash
def to_record
return @_record if defined?(@_record)

@_record = @records[key(@clone)].to_model
@_record = @records[key(@clone)].to_model.tap do
run_after_clone
end
end
end
end
Expand Down
1 change: 1 addition & 0 deletions lib/clowne/declarations.rb
Original file line number Diff line number Diff line change
Expand Up @@ -31,3 +31,4 @@ def add(id, declaration = nil)
require 'clowne/declarations/nullify'
require 'clowne/declarations/trait'
require 'clowne/declarations/after_persist'
require 'clowne/declarations/after_clone'
21 changes: 21 additions & 0 deletions lib/clowne/declarations/after_clone.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
# frozen_string_literal: true

module Clowne
module Declarations
class AfterClone < Base # :nodoc: all
attr_reader :block

def initialize
raise ArgumentError, 'Block is required for after_clone' unless block_given?

@block = Proc.new
end

def compile(plan)
plan.add(:after_clone, self)
end
end
end
end

Clowne::Declarations.add :after_clone, Clowne::Declarations::AfterClone
17 changes: 17 additions & 0 deletions lib/clowne/resolvers/after_clone.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
# frozen_string_literal: true

module Clowne
class Resolvers
module AfterClone # :nodoc: all
def self.call(source, record, declaration, params:, **_options)
operation = Clowne::Utils::Operation.current
operation.add_after_clone(
proc do
declaration.block.call(source, record, params)
end
)
record
end
end
end
end
20 changes: 16 additions & 4 deletions lib/clowne/utils/operation.rb
Original file line number Diff line number Diff line change
Expand Up @@ -35,20 +35,28 @@ def clear!
attr_reader :mapper

def initialize(mapper)
@blocks = []
@after_clone_blocks = []
@after_persist_blocks = []
@mapper = mapper
end

def add_after_persist(block)
@blocks.unshift(block)
@after_persist_blocks.unshift(block)
end

def add_after_clone(block)
@after_clone_blocks.unshift(block)
end

def add_mapping(origin, clone)
@mapper.add(origin, clone)
end

def to_record
@clone
return @_record if defined?(@_record)

run_after_clone
@_record = @clone
end

def persist!
Expand Down Expand Up @@ -76,7 +84,11 @@ def save!
end

def run_after_persist
@blocks.each(&:call)
@after_persist_blocks.each(&:call)
end

def run_after_clone
@after_clone_blocks.each(&:call)
end
end
end
Expand Down
32 changes: 32 additions & 0 deletions spec/clowne/integrations/after_clone_spec.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
describe 'Pre processing', :cleanup, adapter: :active_record, transactional: :active_record do
before(:all) do
module AR
class TopicCloner < Clowne::Cloner
include_association :posts

after_clone do |_origin, clone, **|
raise Clowne::UnprocessableSourceError, 'Topic has no posts!' if clone.posts.empty?
end
end
end
end

after(:all) do
AR.send(:remove_const, 'TopicCloner')
end

let!(:topic) { create(:topic) }

describe 'The main idea of "after clone" feature is a possibility
to make some additional work or checks on cloned record before
persisting it.' do

subject(:operation) { AR::TopicCloner.call(topic) }

it 'raises error' do
expect do
operation.persist
end.to raise_error(Clowne::UnprocessableSourceError)
end
end
end
32 changes: 32 additions & 0 deletions spec/clowne/integrations/sequel_after_clone_spec.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
describe 'Sequel Post Processing', :cleanup, adapter: :sequel, transactional: :sequel do
before(:all) do
module Sequel
class TopicCloner < Clowne::Cloner
include_association :posts

after_clone do |_origin, clone, **|
raise Clowne::UnprocessableSourceError, 'Topic has no posts!' if clone.posts.empty?
end
end
end
end

after(:all) do
Sequel.send(:remove_const, 'TopicCloner')
end

let!(:topic) { create('sequel:topic') }

describe 'The main idea of "after clone" feature is a possibility
to make some additional work or checks on cloned record before
persisting it.' do

subject(:operation) { Sequel::TopicCloner.call(topic) }

it 'raises error' do
expect do
operation.to_record
end.to raise_error(Clowne::UnprocessableSourceError)
end
end
end
41 changes: 41 additions & 0 deletions spec/clowne/resolvers/after_clone_spec.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
describe Clowne::Resolvers::AfterClone do
let(:declaration) { Clowne::Declarations::AfterClone.new(&block) }

describe '.call' do
let(:source) { AR::User.create(email: '[email protected]') }
let(:params) { {} }
let(:block) do
proc do |_source, record|
record.email = '[email protected]'
end
end

subject(:result) do
record = AR::User.new
operation = Clowne::Utils::Operation.wrap do
described_class.call(source, record, declaration, params: params)
end
operation.persist
operation.to_record
end

it 'execute after_clone block' do
expect(result).to be_a(AR::User)
expect(result.email).to eq('[email protected]')
end

context 'with params' do
let(:params) { { email: '[email protected]' } }
let(:block) do
proc do |_source, record, params|
record.email = params[:email]
end
end

it 'execute after_clone block with params' do
expect(result).to be_a(AR::User)
expect(result.email).to eq('[email protected]')
end
end
end
end

0 comments on commit 50cd2e2

Please sign in to comment.