clowne 0.1.0.pre1 → 0.1.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +4 -4
- data/.codeclimate.yml +7 -0
- data/.gitattributes +1 -0
- data/.gitignore +1 -0
- data/.rubocop.yml +17 -0
- data/.travis.yml +15 -2
- data/CHANGELOG.md +9 -2
- data/Gemfile +1 -0
- data/README.md +25 -381
- data/clowne.gemspec +1 -0
- data/docs/.rubocop.yml +12 -0
- data/docs/active_record.md +36 -0
- data/docs/alternatives.md +26 -0
- data/docs/architecture.md +141 -0
- data/docs/basic_example.md +66 -0
- data/docs/configuration.md +29 -0
- data/docs/customization.md +64 -0
- data/docs/exclude_association.md +63 -0
- data/docs/execution_order.md +14 -0
- data/docs/finalize.md +35 -0
- data/docs/implicit_cloner.md +36 -0
- data/docs/include_association.md +119 -0
- data/docs/init_as.md +36 -0
- data/docs/inline_configuration.md +38 -0
- data/docs/installation.md +16 -0
- data/docs/nullify.md +37 -0
- data/docs/sequel.md +56 -0
- data/docs/supported_adapters.md +13 -0
- data/docs/traits.md +28 -0
- data/docs/web/.gitignore +11 -0
- data/docs/web/core/Footer.js +92 -0
- data/docs/web/i18n/en.json +134 -0
- data/docs/web/package.json +14 -0
- data/docs/web/pages/en/help.js +50 -0
- data/docs/web/pages/en/index.js +231 -0
- data/docs/web/pages/en/users.js +47 -0
- data/docs/web/sidebars.json +30 -0
- data/docs/web/siteConfig.js +44 -0
- data/docs/web/static/css/custom.css +229 -0
- data/docs/web/static/fonts/FiraCode-Medium.woff +0 -0
- data/docs/web/static/fonts/FiraCode-Regular.woff +0 -0
- data/docs/web/static/fonts/StemText.woff +0 -0
- data/docs/web/static/fonts/StemTextBold.woff +0 -0
- data/docs/web/static/img/favicon/favicon.ico +0 -0
- data/docs/web/yarn.lock +1741 -0
- data/gemfiles/activerecord42.gemfile +1 -0
- data/gemfiles/jruby.gemfile +2 -0
- data/gemfiles/railsmaster.gemfile +1 -0
- data/lib/clowne.rb +3 -1
- data/lib/clowne/adapters/active_record.rb +3 -12
- data/lib/clowne/adapters/active_record/association.rb +1 -1
- data/lib/clowne/adapters/active_record/associations/base.rb +8 -48
- data/lib/clowne/adapters/active_record/associations/has_one.rb +8 -1
- data/lib/clowne/adapters/active_record/associations/noop.rb +4 -1
- data/lib/clowne/adapters/active_record/dsl.rb +33 -0
- data/lib/clowne/adapters/base.rb +13 -6
- data/lib/clowne/adapters/base/association.rb +69 -0
- data/lib/clowne/adapters/base/finalize.rb +1 -1
- data/lib/clowne/adapters/base/init_as.rb +21 -0
- data/lib/clowne/adapters/registry.rb +5 -11
- data/lib/clowne/adapters/sequel.rb +25 -0
- data/lib/clowne/adapters/sequel/association.rb +47 -0
- data/lib/clowne/adapters/sequel/associations.rb +26 -0
- data/lib/clowne/adapters/sequel/associations/base.rb +23 -0
- data/lib/clowne/adapters/sequel/associations/many_to_many.rb +19 -0
- data/lib/clowne/adapters/sequel/associations/noop.rb +16 -0
- data/lib/clowne/adapters/sequel/associations/one_to_many.rb +23 -0
- data/lib/clowne/adapters/sequel/associations/one_to_one.rb +23 -0
- data/lib/clowne/adapters/sequel/copier.rb +23 -0
- data/lib/clowne/adapters/sequel/record_wrapper.rb +59 -0
- data/lib/clowne/cloner.rb +6 -4
- data/lib/clowne/declarations.rb +1 -0
- data/lib/clowne/declarations/exclude_association.rb +0 -5
- data/lib/clowne/declarations/include_association.rb +0 -2
- data/lib/clowne/declarations/init_as.rb +20 -0
- data/lib/clowne/declarations/trait.rb +2 -0
- data/lib/clowne/ext/orm_ext.rb +21 -0
- data/lib/clowne/ext/string_constantize.rb +2 -2
- data/lib/clowne/planner.rb +11 -4
- data/lib/clowne/version.rb +1 -1
- metadata +70 -4
data/clowne.gemspec
CHANGED
data/docs/.rubocop.yml
ADDED
@@ -0,0 +1,36 @@
|
|
1
|
+
---
|
2
|
+
id: active_record
|
3
|
+
title: Active Record
|
4
|
+
---
|
5
|
+
|
6
|
+
Clowne provides an optional ActiveRecord integration which allows you to configure cloners in your models and adds a shortcut to invoke cloners (`#clowne` method).
|
7
|
+
|
8
|
+
To enable this integration, you must require `"clowne/adapters/active_record/dsl"` somewhere in your app, e.g., in the initializer:
|
9
|
+
|
10
|
+
```ruby
|
11
|
+
# config/initializers/clowne.rb
|
12
|
+
require 'clowne/adapters/active_record/dsl'
|
13
|
+
Clowne.default_adapter = :active_record
|
14
|
+
```
|
15
|
+
|
16
|
+
Now you can specify cloning configs in your AR models:
|
17
|
+
|
18
|
+
```ruby
|
19
|
+
class User < ActiveRecord::Base
|
20
|
+
clowne_config do
|
21
|
+
include_associations :profile
|
22
|
+
|
23
|
+
nullify :email
|
24
|
+
|
25
|
+
# whatever available for your cloners,
|
26
|
+
# active_record adapter is set implicitly here
|
27
|
+
end
|
28
|
+
end
|
29
|
+
```
|
30
|
+
|
31
|
+
And then you can clone objects like this:
|
32
|
+
|
33
|
+
```ruby
|
34
|
+
user.clowne(traits: my_traits, **params)
|
35
|
+
# => <#User...
|
36
|
+
```
|
@@ -0,0 +1,26 @@
|
|
1
|
+
---
|
2
|
+
id: alternatives
|
3
|
+
title: Motivation & Alternatives
|
4
|
+
---
|
5
|
+
|
6
|
+
### Why did we decide to build our own cloning gem instead of using the existing solutions?
|
7
|
+
|
8
|
+
First, the existing solutions turned out not to be stable and flexible enough for us.
|
9
|
+
|
10
|
+
Secondly, they are Rails-only (or, more precisely, ActiveRecord-only).
|
11
|
+
|
12
|
+
Nevertheless, thanks to [amoeba](https://github.com/amoeba-rb/amoeba) and [deep_cloneable](https://github.com/moiristo/deep_cloneable) for inspiration.
|
13
|
+
|
14
|
+
For ActiveRecord we support amoeba-like [in-model configuration](active_record.md) and you can add missing DSL declarations yourself [easily](customization.md).
|
15
|
+
|
16
|
+
We also provide an ability to specify cloning [configuration in-place](inline_configuration.md) like `deep_clonable` does.
|
17
|
+
|
18
|
+
So, we took the best of these too and brought to the outside-of-Rails world.
|
19
|
+
|
20
|
+
### Why build a gem to clone models at all?
|
21
|
+
|
22
|
+
That's a good question. Of course, you can write plain old Ruby services do handle the cloning logic. But for complex models hierarchies, this approach has major disadvantages: high code complexity and lack of re-usability.
|
23
|
+
|
24
|
+
The things become even worse when you deal with STI models and different cloning contexts.
|
25
|
+
|
26
|
+
That's why we decided to build a specific cloning tool.
|
@@ -0,0 +1,141 @@
|
|
1
|
+
---
|
2
|
+
id: architecture
|
3
|
+
title: Architecture
|
4
|
+
---
|
5
|
+
|
6
|
+
This post aims to help developers and contributors to understand how Clowne works under the hood.
|
7
|
+
|
8
|
+
There are two main notions we want to talk about: **declaration set** and **cloning plan**.
|
9
|
+
|
10
|
+
## Declaration set
|
11
|
+
|
12
|
+
_Declaration set_ is a result of calling Clowne DSL methods in the cloner class. There is (almost) one-to-one correspondence between configuration calls and declarations.
|
13
|
+
|
14
|
+
Consider an example:
|
15
|
+
|
16
|
+
```ruby
|
17
|
+
class UserCloner < Clowne::Cloner
|
18
|
+
include_association :profile
|
19
|
+
include_association :posts
|
20
|
+
|
21
|
+
nullify :external_id, :slug
|
22
|
+
|
23
|
+
trait :with_social_profile do
|
24
|
+
include_association :social_profiles
|
25
|
+
end
|
26
|
+
end
|
27
|
+
```
|
28
|
+
|
29
|
+
This cloner's declaration set contains 3 declarations. You can access it through `.declarations` method:
|
30
|
+
|
31
|
+
```ruby
|
32
|
+
UserCloner.declarations #=>
|
33
|
+
# [
|
34
|
+
# <#Clowne::Declarations::IncludeAssociation...>,
|
35
|
+
# <#Clowne::Declarations::IncludeAssociation...>,
|
36
|
+
# <#Clowne::Declarations::Nullify...>
|
37
|
+
# ]
|
38
|
+
```
|
39
|
+
|
40
|
+
**NOTE:** `include_associations` and `exclude_associations` methods are just syntactic sugar, so they produce multiple declarations.
|
41
|
+
|
42
|
+
Traits are not included in the declaration set. Moreover, each trait is just a wrapper over a declaration set. You can access traits through `.traits` method:
|
43
|
+
|
44
|
+
```ruby
|
45
|
+
UserCloner.traits #=>
|
46
|
+
# {
|
47
|
+
# with_social_profiles: <#Clowne::Declarations::Trait...>
|
48
|
+
# }
|
49
|
+
|
50
|
+
UserCloner.traits[:with_social_profiles].declarations #=>
|
51
|
+
# [
|
52
|
+
# <#Clowne::Declarations::IncludeAssociation...>
|
53
|
+
# ]
|
54
|
+
```
|
55
|
+
|
56
|
+
## Cloning plan
|
57
|
+
|
58
|
+
Every time you use a cloner the corresponding declaration set (which is the cloner itself declarations combined with all the specified traits declaration sets) is compiled into a _cloning plan_.
|
59
|
+
|
60
|
+
The process is straightforward: starting with an empty `Plan`, we're applying every declaration to it.
|
61
|
+
Every declaration object must implement `#compile(plan)` method, which populates the plan with _actions_. For example:
|
62
|
+
|
63
|
+
```ruby
|
64
|
+
class IncludeAssociation
|
65
|
+
def compile(plan)
|
66
|
+
# add a `name` key with the specified value (self)
|
67
|
+
# to :association set
|
68
|
+
plan.add_to(:association, name, self)
|
69
|
+
end
|
70
|
+
end
|
71
|
+
```
|
72
|
+
|
73
|
+
`Plan` supports 3 types of registers:
|
74
|
+
|
75
|
+
1) Scalars:
|
76
|
+
|
77
|
+
```ruby
|
78
|
+
plan.set(key, value)
|
79
|
+
plan.remove(key)
|
80
|
+
```
|
81
|
+
|
82
|
+
Used by [`init_as`](init_as.md) declaration.
|
83
|
+
|
84
|
+
2) Append-only lists:
|
85
|
+
|
86
|
+
```ruby
|
87
|
+
plan.add(key, value)
|
88
|
+
```
|
89
|
+
|
90
|
+
Used by [`nullify`](nullify.md) and [`finalize`](finalize.md) declarations.
|
91
|
+
|
92
|
+
3) Two-phase set (2P-Set\*):
|
93
|
+
|
94
|
+
```ruby
|
95
|
+
plan.add_to(type, key, value)
|
96
|
+
plan.remove_from(type, key)
|
97
|
+
```
|
98
|
+
|
99
|
+
Used by [`include_association`](include_association.md) and [`exclude_association`](exclude_association.md).
|
100
|
+
|
101
|
+
|
102
|
+
\* Operations over [2P-Set](https://en.wikipedia.org/wiki/Conflict-free_replicated_data_type#2P-Set_(Two-Phase_Set)) (adding/removing) do not depend on the order of execution; we use "remove-wins" semantics, i.e., when a key has been removed, it cannot be re-added.
|
103
|
+
|
104
|
+
Thus, the resulting plan is just a key-value storage.
|
105
|
+
|
106
|
+
## Plan resolving
|
107
|
+
|
108
|
+
The final step in the cloning process is to apply the compiled plan to a record.
|
109
|
+
|
110
|
+
Every adapter registers its own resolvers for each plan _key_ (or _action_). The [order of execution](execution_order.md) is also specified by the adapter.
|
111
|
+
|
112
|
+
For example, you can override `:nullify` resolver to handle associations:
|
113
|
+
|
114
|
+
```ruby
|
115
|
+
module NullifyWithAssociation
|
116
|
+
def self.call(source, record, declaration, _params)
|
117
|
+
reflection = source.class.reflections[declaration.name.to_s]
|
118
|
+
|
119
|
+
# fallback to built-in nullify
|
120
|
+
if reflection.nil?
|
121
|
+
Clowne::Adapters::Base::Nullify.call(source, record, declaration)
|
122
|
+
else
|
123
|
+
association = record.__send__(declaration.name)
|
124
|
+
next record if association.nil?
|
125
|
+
|
126
|
+
association.is_a?(ActiveRecord::Relation) ? association.destroy_all : association.destroy
|
127
|
+
record.association(declaration.name).reset
|
128
|
+
|
129
|
+
# resolver must return cloned record
|
130
|
+
record
|
131
|
+
end
|
132
|
+
end
|
133
|
+
end
|
134
|
+
|
135
|
+
Clowne::Adapters::ActiveRecord.register_resolver(
|
136
|
+
:nullify,
|
137
|
+
NullifyWithAssociation,
|
138
|
+
# specify the order of execution (available options are `prepend`, `before`, `after`)
|
139
|
+
before: :finalize
|
140
|
+
)
|
141
|
+
```
|
@@ -0,0 +1,66 @@
|
|
1
|
+
---
|
2
|
+
id: basic_example
|
3
|
+
title: Basic Example
|
4
|
+
---
|
5
|
+
|
6
|
+
Assume that you have the following model:
|
7
|
+
|
8
|
+
```ruby
|
9
|
+
class User < ActiveRecord::Base
|
10
|
+
# create_table :users do |t|
|
11
|
+
# t.string :login
|
12
|
+
# t.string :email
|
13
|
+
# t.timestamps null: false
|
14
|
+
# end
|
15
|
+
|
16
|
+
has_one :profile
|
17
|
+
has_many :posts
|
18
|
+
end
|
19
|
+
```
|
20
|
+
|
21
|
+
Let's declare our cloners first:
|
22
|
+
|
23
|
+
```ruby
|
24
|
+
class UserCloner < Clowne::Cloner
|
25
|
+
adapter :active_record
|
26
|
+
|
27
|
+
include_association :profile, clone_with: SpecialProfileCloner
|
28
|
+
include_association :posts
|
29
|
+
|
30
|
+
nullify :login
|
31
|
+
|
32
|
+
# params here is an arbitrary Hash passed into cloner
|
33
|
+
finalize do |_source, record, params|
|
34
|
+
record.email = params[:email]
|
35
|
+
end
|
36
|
+
end
|
37
|
+
|
38
|
+
class SpecialProfileCloner < Clowne::Cloner
|
39
|
+
adapter :active_record
|
40
|
+
|
41
|
+
nullify :name
|
42
|
+
end
|
43
|
+
```
|
44
|
+
|
45
|
+
Now you can use `UserCloner` to clone existing records:
|
46
|
+
|
47
|
+
```ruby
|
48
|
+
user = User.last
|
49
|
+
#=> <#User(login: 'clown', email: 'clown@circus.example.com')>
|
50
|
+
|
51
|
+
cloned = UserCloner.call(user, email: 'fake@example.com')
|
52
|
+
cloned.persisted?
|
53
|
+
# => false
|
54
|
+
|
55
|
+
cloned.save!
|
56
|
+
cloned.login
|
57
|
+
# => nil
|
58
|
+
cloned.email
|
59
|
+
# => "fake@example.com"
|
60
|
+
|
61
|
+
# associations:
|
62
|
+
cloned.posts.count == user.posts.count
|
63
|
+
# => true
|
64
|
+
cloned.profile.name
|
65
|
+
# => nil
|
66
|
+
```
|
@@ -0,0 +1,29 @@
|
|
1
|
+
---
|
2
|
+
id: configuration
|
3
|
+
title: Configuration
|
4
|
+
---
|
5
|
+
|
6
|
+
Basic cloner implementation looks like
|
7
|
+
|
8
|
+
```ruby
|
9
|
+
class SomeCloner < Clowne::Cloner
|
10
|
+
adapter :active_record # or adapter Clowne::ActiveRecord::Adapter
|
11
|
+
# some implementation ...
|
12
|
+
end
|
13
|
+
```
|
14
|
+
|
15
|
+
But you can configure the default adapter for cloners:
|
16
|
+
|
17
|
+
```ruby
|
18
|
+
# somewhere in initializers
|
19
|
+
Clowne.default_adapter = :active_record
|
20
|
+
```
|
21
|
+
|
22
|
+
and skip adapter declaration
|
23
|
+
|
24
|
+
```ruby
|
25
|
+
class SomeCloner < Clowne::Cloner
|
26
|
+
# some implementation ...
|
27
|
+
end
|
28
|
+
```
|
29
|
+
See the list of [available adapters](supported_adapters.md).
|
@@ -0,0 +1,64 @@
|
|
1
|
+
---
|
2
|
+
id: customization
|
3
|
+
title: Customization
|
4
|
+
---
|
5
|
+
|
6
|
+
Clowne is built with extensibility in mind. You can create your own DSL commands and resolvers.
|
7
|
+
|
8
|
+
Let's consider an example.
|
9
|
+
|
10
|
+
Suppose that you want to add the `include_all` declaration to automagically include all associations (for ActiveRecord).
|
11
|
+
|
12
|
+
First, you should add a custom declaration:
|
13
|
+
|
14
|
+
```ruby
|
15
|
+
class IncludeAll # :nodoc: all
|
16
|
+
def compile(plan)
|
17
|
+
# Just add all_associations object to plan
|
18
|
+
plan.set(:all_associations, self)
|
19
|
+
end
|
20
|
+
end
|
21
|
+
|
22
|
+
# Register our declrations, i.e. extend DSL
|
23
|
+
Clowne::Declarations.add :include_all, Clowne::Declarations::IncludeAll
|
24
|
+
```
|
25
|
+
|
26
|
+
See more on `plan` in [architecture overview](architecture.md).
|
27
|
+
|
28
|
+
Secondly, register a resolver:
|
29
|
+
|
30
|
+
```ruby
|
31
|
+
class AllAssociations
|
32
|
+
# This method is called when all_associations command is applied.
|
33
|
+
#
|
34
|
+
# source – source record
|
35
|
+
# record – target record (our clone)
|
36
|
+
# declaration – declaration object
|
37
|
+
# params – custom params passed to cloner
|
38
|
+
def call(source, record, declaration, params:)
|
39
|
+
source.class.reflections.each_value do |_name, reflection|
|
40
|
+
# Exclude belongs_to associations
|
41
|
+
next if reflection.macro == :belongs_to
|
42
|
+
# Resolve and apply association cloner
|
43
|
+
cloner_class = Clowne::Adapters::ActiveRecord::Associations.cloner_for(reflection)
|
44
|
+
cloner_class.new(reflection, source, declaration, params).call(record)
|
45
|
+
end
|
46
|
+
record
|
47
|
+
end
|
48
|
+
end
|
49
|
+
|
50
|
+
# Finally, register the resolver
|
51
|
+
Clowne::Adapters::ActiveRecord.register_resolver(
|
52
|
+
:all_associations, AllAssociations
|
53
|
+
)
|
54
|
+
```
|
55
|
+
|
56
|
+
Now you can use it likes this:
|
57
|
+
|
58
|
+
```ruby
|
59
|
+
class UserCloner < Clowne::Cloner
|
60
|
+
adapter :active_record
|
61
|
+
|
62
|
+
include_all
|
63
|
+
end
|
64
|
+
```
|
@@ -0,0 +1,63 @@
|
|
1
|
+
---
|
2
|
+
id: exclude_association
|
3
|
+
title: Exclude Association
|
4
|
+
---
|
5
|
+
|
6
|
+
Clowne doesn't include any association by default and doesn't provide _magic_ `include_all` declaration (although you can [add one by yourself](customization.md)).
|
7
|
+
|
8
|
+
Nevertheless, sometimes you might want to exclude already added associations (when inheriting a cloner or using [traits](traits.md)).
|
9
|
+
|
10
|
+
Consider an example:
|
11
|
+
|
12
|
+
```ruby
|
13
|
+
class UserCloner < Clowne::Cloner
|
14
|
+
include_association :posts
|
15
|
+
|
16
|
+
trait :without_posts do
|
17
|
+
exclude_association :posts
|
18
|
+
end
|
19
|
+
end
|
20
|
+
|
21
|
+
# copy user and posts
|
22
|
+
clone = UserCloner.call(user)
|
23
|
+
clone.posts.count == user.posts.count
|
24
|
+
# => true
|
25
|
+
|
26
|
+
# copy only user
|
27
|
+
clone2 = UserCloner.call(user, traits: :without_posts)
|
28
|
+
clone2.posts
|
29
|
+
# => []
|
30
|
+
```
|
31
|
+
|
32
|
+
**NOTE**: once excluded association cannot be re-included, e.g. the following cloner:
|
33
|
+
|
34
|
+
```ruby
|
35
|
+
class UserCloner < Clowne::Cloner
|
36
|
+
exclude_association :comments
|
37
|
+
|
38
|
+
trait :with_comments do
|
39
|
+
# That wouldn't work
|
40
|
+
include_association :comments
|
41
|
+
end
|
42
|
+
end
|
43
|
+
|
44
|
+
clone = UserCloner.call(user, traits: :with_comments)
|
45
|
+
clone.comments.empty? #=> true
|
46
|
+
```
|
47
|
+
|
48
|
+
Why so? That allows us to have a deterministic cloning plan when combining multiple traits
|
49
|
+
(or inheriting cloners).
|
50
|
+
|
51
|
+
## Exclude multiple associations
|
52
|
+
|
53
|
+
It's possible to exclude multiple associations at once the same way as [`include_associations`](include_association.md):
|
54
|
+
|
55
|
+
```ruby
|
56
|
+
class UserCloner < Clowne::Cloner
|
57
|
+
include_associations :accounts, :posts, :comments
|
58
|
+
|
59
|
+
trait :without_posts do
|
60
|
+
exclude_associations :posts, :comments
|
61
|
+
end
|
62
|
+
end
|
63
|
+
```
|