clowne 0.1.0.pre1 → 1.2.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (108) hide show
  1. checksums.yaml +5 -5
  2. data/.codeclimate.yml +7 -0
  3. data/.gitattributes +1 -0
  4. data/.gitignore +1 -0
  5. data/.rubocop.yml +16 -33
  6. data/.travis.yml +14 -10
  7. data/CHANGELOG.md +39 -2
  8. data/Gemfile +11 -6
  9. data/README.md +48 -384
  10. data/Rakefile +3 -3
  11. data/clowne.gemspec +16 -8
  12. data/docs/.nojekyll +0 -0
  13. data/docs/.rubocop.yml +18 -0
  14. data/docs/CNAME +1 -0
  15. data/docs/README.md +131 -0
  16. data/docs/_sidebar.md +25 -0
  17. data/docs/active_record.md +33 -0
  18. data/docs/after_clone.md +53 -0
  19. data/docs/after_persist.md +77 -0
  20. data/docs/architecture.md +138 -0
  21. data/docs/assets/docsify.min.js +1 -0
  22. data/docs/assets/prism-ruby.min.js +1 -0
  23. data/docs/assets/styles.css +348 -0
  24. data/docs/assets/vue.css +1 -0
  25. data/docs/clone_mapper.md +59 -0
  26. data/docs/customization.md +63 -0
  27. data/docs/exclude_association.md +61 -0
  28. data/docs/finalize.md +31 -0
  29. data/docs/from_v02_to_v1.md +83 -0
  30. data/docs/getting_started.md +171 -0
  31. data/docs/implicit_cloner.md +33 -0
  32. data/docs/include_association.md +133 -0
  33. data/docs/index.html +29 -0
  34. data/docs/init_as.md +40 -0
  35. data/docs/inline_configuration.md +37 -0
  36. data/docs/nullify.md +33 -0
  37. data/docs/operation.md +55 -0
  38. data/docs/parameters.md +112 -0
  39. data/docs/sequel.md +50 -0
  40. data/docs/supported_adapters.md +10 -0
  41. data/docs/testing.md +194 -0
  42. data/docs/traits.md +25 -0
  43. data/gemfiles/activerecord42.gemfile +7 -4
  44. data/gemfiles/jruby.gemfile +8 -4
  45. data/gemfiles/railsmaster.gemfile +8 -5
  46. data/lib/clowne.rb +12 -7
  47. data/lib/clowne/adapters/active_record.rb +6 -16
  48. data/lib/clowne/adapters/active_record/associations.rb +8 -6
  49. data/lib/clowne/adapters/active_record/associations/base.rb +5 -49
  50. data/lib/clowne/adapters/active_record/associations/belongs_to.rb +29 -0
  51. data/lib/clowne/adapters/active_record/associations/has_one.rb +9 -1
  52. data/lib/clowne/adapters/active_record/associations/noop.rb +4 -1
  53. data/lib/clowne/adapters/active_record/dsl.rb +33 -0
  54. data/lib/clowne/adapters/active_record/resolvers/association.rb +38 -0
  55. data/lib/clowne/adapters/base.rb +53 -41
  56. data/lib/clowne/adapters/base/association.rb +78 -0
  57. data/lib/clowne/adapters/registry.rb +54 -11
  58. data/lib/clowne/adapters/sequel.rb +29 -0
  59. data/lib/clowne/adapters/sequel/associations.rb +26 -0
  60. data/lib/clowne/adapters/sequel/associations/base.rb +27 -0
  61. data/lib/clowne/adapters/sequel/associations/many_to_many.rb +23 -0
  62. data/lib/clowne/adapters/sequel/associations/noop.rb +16 -0
  63. data/lib/clowne/adapters/sequel/associations/one_to_many.rb +28 -0
  64. data/lib/clowne/adapters/sequel/associations/one_to_one.rb +28 -0
  65. data/lib/clowne/adapters/sequel/copier.rb +23 -0
  66. data/lib/clowne/adapters/sequel/operation.rb +35 -0
  67. data/lib/clowne/adapters/sequel/record_wrapper.rb +43 -0
  68. data/lib/clowne/adapters/sequel/resolvers/after_persist.rb +22 -0
  69. data/lib/clowne/adapters/sequel/resolvers/association.rb +51 -0
  70. data/lib/clowne/adapters/sequel/specifications/after_persist_does_not_support.rb +15 -0
  71. data/lib/clowne/cloner.rb +50 -20
  72. data/lib/clowne/declarations.rb +15 -11
  73. data/lib/clowne/declarations/after_clone.rb +21 -0
  74. data/lib/clowne/declarations/after_persist.rb +21 -0
  75. data/lib/clowne/declarations/base.rb +13 -0
  76. data/lib/clowne/declarations/exclude_association.rb +1 -6
  77. data/lib/clowne/declarations/finalize.rb +3 -2
  78. data/lib/clowne/declarations/include_association.rb +16 -4
  79. data/lib/clowne/declarations/init_as.rb +21 -0
  80. data/lib/clowne/declarations/nullify.rb +3 -2
  81. data/lib/clowne/declarations/trait.rb +3 -0
  82. data/lib/clowne/dsl.rb +9 -0
  83. data/lib/clowne/ext/lambda_as_proc.rb +17 -0
  84. data/lib/clowne/ext/orm_ext.rb +21 -0
  85. data/lib/clowne/ext/record_key.rb +12 -0
  86. data/lib/clowne/ext/string_constantize.rb +10 -4
  87. data/lib/clowne/ext/yield_self_then.rb +25 -0
  88. data/lib/clowne/planner.rb +27 -7
  89. data/lib/clowne/resolvers/after_clone.rb +17 -0
  90. data/lib/clowne/resolvers/after_persist.rb +18 -0
  91. data/lib/clowne/resolvers/finalize.rb +12 -0
  92. data/lib/clowne/resolvers/init_as.rb +13 -0
  93. data/lib/clowne/resolvers/nullify.rb +15 -0
  94. data/lib/clowne/rspec.rb +5 -0
  95. data/lib/clowne/rspec/clone_association.rb +99 -0
  96. data/lib/clowne/rspec/clone_associations.rb +26 -0
  97. data/lib/clowne/rspec/helpers.rb +35 -0
  98. data/lib/clowne/utils/clone_mapper.rb +26 -0
  99. data/lib/clowne/utils/operation.rb +95 -0
  100. data/lib/clowne/utils/options.rb +39 -0
  101. data/lib/clowne/utils/params.rb +64 -0
  102. data/lib/clowne/utils/plan.rb +90 -0
  103. data/lib/clowne/version.rb +1 -1
  104. metadata +140 -20
  105. data/lib/clowne/adapters/active_record/association.rb +0 -34
  106. data/lib/clowne/adapters/base/finalize.rb +0 -19
  107. data/lib/clowne/adapters/base/nullify.rb +0 -19
  108. data/lib/clowne/plan.rb +0 -81
@@ -0,0 +1,61 @@
1
+ # Exclude Association
2
+
3
+ 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)).
4
+
5
+ Nevertheless, sometimes you might want to exclude already added associations (when inheriting a cloner or using [traits](traits.md)).
6
+
7
+ Consider an example:
8
+
9
+ ```ruby
10
+ class UserCloner < Clowne::Cloner
11
+ include_association :posts
12
+
13
+ trait :without_posts do
14
+ exclude_association :posts
15
+ end
16
+ end
17
+
18
+ # copy user and posts
19
+ clone = UserCloner.call(user).to_record
20
+ clone.posts.count == user.posts.count
21
+ # => true
22
+
23
+ # copy only user
24
+ clone2 = UserCloner.call(user, traits: :without_posts).to_record
25
+ clone2.posts
26
+ # => []
27
+ ```
28
+
29
+ **NOTE**: once excluded association cannot be re-included, e.g. the following cloner:
30
+
31
+ ```ruby
32
+ class UserCloner < Clowne::Cloner
33
+ exclude_association :comments
34
+
35
+ trait :with_comments do
36
+ # That wouldn't work
37
+ include_association :comments
38
+ end
39
+ end
40
+
41
+ clone = UserCloner.call(user, traits: :with_comments).to_record
42
+ clone.comments.empty?
43
+ # => true
44
+ ```
45
+
46
+ Why so? That allows us to have a deterministic cloning plan when combining multiple traits
47
+ (or inheriting cloners).
48
+
49
+ ## Exclude multiple associations
50
+
51
+ It's possible to exclude multiple associations at once the same way as [`include_associations`](include_association.md):
52
+
53
+ ```ruby
54
+ class UserCloner < Clowne::Cloner
55
+ include_associations :accounts, :posts, :comments
56
+
57
+ trait :without_posts do
58
+ exclude_associations :posts, :comments
59
+ end
60
+ end
61
+ ```
@@ -0,0 +1,31 @@
1
+ # Finalization
2
+
3
+ To apply custom transformations to the cloned record, you can use the `finalize` declaration:
4
+
5
+ ```ruby
6
+ class UserCloner < Clowne::Cloner
7
+ finalize do |_source, record, _params|
8
+ record.name = "This is copy!"
9
+ end
10
+
11
+ trait :change_email do
12
+ finalize do |_source, record, params|
13
+ record.email = params[:email]
14
+ end
15
+ end
16
+ end
17
+
18
+ cloned = UserCloner.call(user).to_record
19
+ cloned.name
20
+ # => 'This is copy!'
21
+ cloned.email == "clone@example.com"
22
+ # => false
23
+
24
+ cloned2 = UserCloner.call(user, traits: :change_email).to_record
25
+ cloned2.name
26
+ # => 'This is copy!'
27
+ cloned2.email
28
+ # => 'clone@example.com'
29
+ ```
30
+
31
+ Finalization blocks are called at the end of the [cloning process](getting_started?id=execution-order).
@@ -0,0 +1,83 @@
1
+ # From v0.2.x to v1.0.0
2
+
3
+ The breaking change of v1.0 is the return of a unified [`result object`](operation.md) for all adapters.
4
+
5
+ ## ActiveRecord
6
+
7
+ ### Update code to work with [`Operation`](operation.md)
8
+
9
+ ```ruby
10
+ # Before
11
+ clone = UserCloner.call(user)
12
+ # => <#User id: nil, ...>
13
+ clone.save!
14
+ # => true
15
+
16
+ # After
17
+ clone = UserCloner.call(user)
18
+ # => <#Clowne::Utils::Operation ...>
19
+ clone = clone.to_record
20
+ # => <#User id: 2, ...>
21
+ clone.save!
22
+ # => true
23
+
24
+ # After (even better because of using full functionality)
25
+ operation = UserCloner.call(user)
26
+ # => <#Clowne::Utils::Operation ...>
27
+ operation.persist!
28
+ # => true
29
+ clone = operation.to_record
30
+ # => <#User id: 2, ...>
31
+ clone.persisted?
32
+ # => true
33
+ ```
34
+
35
+ ### Move post-processing cloning logic into [`after_persist`](after_persist.md) callback (if you have it)
36
+
37
+ *Notice: `after_persist` supported only with [`active_record`](active_record.md) adapter.*
38
+
39
+ ```ruby
40
+ # Before
41
+ clone = UserCloner.call(user)
42
+ clone.save!
43
+ # do something with persisted clone
44
+
45
+ # After
46
+ class UserCloner < Clowne::Cloner
47
+ # ...
48
+ after_persist do |origin, clone, **|
49
+ # do something with persisted clone
50
+ end
51
+ end
52
+
53
+ clone = UserCloner.call(user).tap(&:persist).to_record
54
+ ```
55
+ ## Sequel
56
+
57
+ ### Use `to_record` instead of `to_model`
58
+
59
+ ```ruby
60
+ # Before
61
+ record_wrapper = UserCloner.call(user)
62
+ clone = record_wrapper.to_model
63
+ clone.new?
64
+ # => true
65
+
66
+ # After
67
+ operation = UserCloner.call(user)
68
+ clone = operation.to_record
69
+ clone.new?
70
+ # => true
71
+ ```
72
+
73
+ ### Use `operation#persist` instead of converting to model and calling `#save`
74
+
75
+ ```ruby
76
+ # Before
77
+ record_wrapper = UserCloner.call(user)
78
+ clone = record_wrapper.to_model
79
+ clone.save
80
+
81
+ # After
82
+ clone = UserCloner.call(user).tap(&:persist).to_record
83
+ ```
@@ -0,0 +1,171 @@
1
+ # Getting Started
2
+
3
+ ## Installation
4
+
5
+ To install Clowne with RubyGems:
6
+
7
+ ```ruby
8
+ gem install clowne
9
+ ```
10
+
11
+ Or add this line to your application's Gemfile:
12
+
13
+ ```ruby
14
+ gem "clowne"
15
+ ```
16
+
17
+ ## Configuration
18
+
19
+ Basic cloner implementation looks like:
20
+
21
+ ```ruby
22
+ class SomeCloner < Clowne::Cloner
23
+ adapter :active_record # or adapter Clowne::Adapters::ActiveRecord
24
+ # some implementation ...
25
+ end
26
+ ```
27
+
28
+ You can configure the default adapter for cloners:
29
+
30
+ ```ruby
31
+ # put to initializer
32
+ # e.g. config/initializers/clowne.rb
33
+ Clowne.default_adapter = :active_record
34
+ ```
35
+
36
+ and skip explicit adapter declaration
37
+
38
+ ```ruby
39
+ class SomeCloner < Clowne::Cloner
40
+ # some implementation ...
41
+ end
42
+ ```
43
+ See the list of [available adapters](supported_adapters.md).
44
+
45
+ ## Basic Example
46
+
47
+ Assume that you have the following model:
48
+
49
+ ```ruby
50
+ class User < ActiveRecord::Base
51
+ # create_table :users do |t|
52
+ # t.string :login
53
+ # t.string :email
54
+ # t.timestamps null: false
55
+ # end
56
+
57
+ has_one :profile
58
+ has_many :posts
59
+ end
60
+
61
+ class Profile < ActiveRecord::Base
62
+ # create_table :profiles do |t|
63
+ # t.string :name
64
+ # end
65
+ end
66
+
67
+ class Post < ActiveRecord::Base
68
+ # create_table :posts
69
+ end
70
+ ```
71
+
72
+ Let's declare our cloners first:
73
+
74
+ ```ruby
75
+ class UserCloner < Clowne::Cloner
76
+ adapter :active_record
77
+
78
+ include_association :profile, clone_with: SpecialProfileCloner
79
+ include_association :posts
80
+
81
+ nullify :login
82
+
83
+ # params here is an arbitrary Hash passed into cloner
84
+ finalize do |_source, record, params|
85
+ record.email = params[:email]
86
+ end
87
+ end
88
+
89
+ class SpecialProfileCloner < Clowne::Cloner
90
+ adapter :active_record
91
+
92
+ nullify :name
93
+ end
94
+ ```
95
+
96
+ Now you can use `UserCloner` to clone existing records:
97
+
98
+ ```ruby
99
+ user = User.last
100
+ # => <#User id: 1, login: 'clown', email: 'clown@circus.example.com'>
101
+
102
+ operation = UserCloner.call(user, email: "fake@example.com")
103
+ # => <#Clowne::Utils::Operation...>
104
+
105
+ operation.to_record
106
+ # => <#User id: nil, login: nil, email: 'fake@example.com'>
107
+
108
+ operation.persist!
109
+ # => true
110
+
111
+ cloned = operation.to_record
112
+ # => <#User id: 2, login: nil, email: 'fake@example.com'>
113
+
114
+ cloned.login
115
+ # => nil
116
+ cloned.email
117
+ # => "fake@example.com"
118
+
119
+ # associations:
120
+ cloned.posts.count == user.posts.count
121
+ # => true
122
+ cloned.profile.name
123
+ # => nil
124
+ ```
125
+
126
+ ## Overview
127
+
128
+ In [the basic example](#basic-example), you can see that Clowne consists of flexible DSL which is used in a class inherited of `Clowne::Cloner`.
129
+
130
+ You can combinate this DSL via [`traits`](traits.md) and make a cloning plan which exactly you want.
131
+
132
+ **We strongly recommend [`write tests`](testing.md) to cover resulting cloner logic**
133
+
134
+ Cloner class returns [`Operation`](operation.md) instance as a result of cloning. The operation provides methods to save cloned record. You can wrap this call to a transaction if it is necessary.
135
+
136
+ ### Execution Order
137
+
138
+ The order of cloning actions depends on the adapter (i.e., could be customized).
139
+
140
+ All built-in adapters have the same order and what happens when you call `Operation#persist`:
141
+ - init clone (see [`init_as`](init_as.md)) (empty by default)
142
+ - [`clone associations`](include_association.md)
143
+ - [`nullify`](nullify.md) attributes
144
+ - run [`finalize`](finalize.md) blocks. _The order of [`finalize`](finalize.md) blocks is the order they've been written._
145
+ - run [`after_clone`](after_clone.md) callbacks
146
+ - __SAVE CLONED RECORD__
147
+ - run [`after_persist`](after_persist.md) callbacks
148
+
149
+ ## Motivation & Alternatives
150
+
151
+ ### Why did we decide to build our own cloning gem instead of using the existing solutions?
152
+
153
+ First, the existing solutions turned out not to be stable and flexible enough for us.
154
+
155
+ Secondly, they are Rails-only (or, more precisely, ActiveRecord-only).
156
+
157
+ Nevertheless, thanks to [amoeba](https://github.com/amoeba-rb/amoeba) and [deep_cloneable](https://github.com/moiristo/deep_cloneable) for inspiration.
158
+
159
+ For ActiveRecord we support amoeba-like [in-model configuration](active_record.md) and you can add missing DSL declarations yourself [easily](customization.md).
160
+
161
+ We also provide an ability to specify cloning [configuration in-place](inline_configuration.md) like `deep_clonable` does.
162
+
163
+ So, we took the best of these too and brought to the outside-of-Rails world.
164
+
165
+ ### Why build a gem to clone models at all?
166
+
167
+ 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.
168
+
169
+ The things become even worse when you deal with STI models and different cloning contexts.
170
+
171
+ That's why we decided to build a specific cloning tool.
@@ -0,0 +1,33 @@
1
+ # Implicit Cloner
2
+
3
+ When [cloning associations](include_association.md) Clowne tries to infer an appropriate cloner class for the records (unless `clone_with` specified).
4
+
5
+ It relies on the naming convention: `MyModel` -> `MyModelCloner`.
6
+
7
+ Consider an example:
8
+
9
+ ```ruby
10
+ class User < ActiveRecord::Base
11
+ has_one :profile
12
+ end
13
+
14
+ class UserCloner < Clowne::Cloner
15
+ include_association :profile
16
+ end
17
+
18
+ class ProfileCloner < Clowne::Cloner
19
+ finalize do |source, record|
20
+ record.name = "Clone of #{source.name}"
21
+ end
22
+ end
23
+
24
+ user = User.last
25
+ user.profile.name
26
+ #=> "Bimbo"
27
+
28
+ cloned = UserCloner.call(user).to_record
29
+ cloned.profile.name
30
+ # => "Clone of Bimbo"
31
+ ```
32
+
33
+ **NOTE:** when using [in-model cloner](active_record.md) for ActiveRecord it is used by default.
@@ -0,0 +1,133 @@
1
+ # Include Association
2
+
3
+ Use this declaration to clone model's associations:
4
+
5
+ ```ruby
6
+ class User < ActiveRecord::Base
7
+ has_one :profile
8
+ end
9
+
10
+ class UserCloner < Clowne::Cloner
11
+ include_association :profile
12
+ end
13
+ ```
14
+
15
+ Looks pretty simple, right? But that's not all we may offer you! :)
16
+
17
+ The declaration supports additional arguments:
18
+
19
+ ```ruby
20
+ include_association name, scope, options
21
+ ```
22
+
23
+ ### Supported Associations
24
+
25
+ Adapter |1:1 |*:1 | 1:M | M:M |
26
+ ------------------------------------------|------------|------------|-------------|-------------------------|
27
+ [Active Record](active_record) | has_one | belongs_to | has_many | has_and_belongs_to|
28
+ [Sequel](sequel) | one_to_one | - | one_to_many | many_to_many |
29
+
30
+ ## Scope
31
+
32
+ Scope can be a:
33
+ - `Symbol` - named scope.
34
+ - `Proc` - custom scope (supports parameters).
35
+
36
+ Example:
37
+
38
+ ```ruby
39
+ class User < ActiveRecord::Base
40
+ has_many :accounts
41
+ has_many :posts
42
+ end
43
+
44
+ class Account < ActiveRecord::Base
45
+ scope :active, -> { where(active: true) }
46
+ end
47
+
48
+ class Post < ActiveRecord::Base
49
+ # t.string :status
50
+ end
51
+
52
+ class UserCloner < Clowne::Cloner
53
+ include_association :accounts, :active
54
+ include_association :posts, ->(params) { where(state: params[:state]) }
55
+ end
56
+
57
+ # Clone only draft posts
58
+ UserCloner.call(user, state: :draft).to_record
59
+ # => <#User id: nil, ... >
60
+ ```
61
+
62
+ ## Options
63
+
64
+ The following options are available:
65
+ - `:clone_with` - use custom cloner\*
66
+ - `:traits` - define special traits.
67
+
68
+ \* **NOTE:** the same cloner class would be used for **all children**
69
+
70
+ Example:
71
+
72
+ ```ruby
73
+ class User < ActiveRecord::Base
74
+ has_many :posts
75
+ end
76
+
77
+ class Post < ActiveRecord::Base
78
+ # t.string :title
79
+ has_many :tags
80
+ end
81
+ ```
82
+
83
+ ```ruby
84
+ class PostSpecialCloner < Clowne::Cloner
85
+ nullify :title
86
+
87
+ trait :with_tags do
88
+ include_association :tags
89
+ end
90
+ end
91
+
92
+ class UserCloner < Clowne::Cloner
93
+ adapter :active_record
94
+
95
+ include_association :posts, clone_with: PostSpecialCloner
96
+ # or clone user's posts with tags!
97
+ # include_association :posts, clone_with: PostSpecialCloner, traits: :with_tags
98
+ end
99
+
100
+ UserCloner.call(user).to_record
101
+ # => <#User id: nil, ... >
102
+ ```
103
+
104
+ **NOTE**: if custom cloner is not defined, Clowne tries to infer the [implicit cloner](implicit_cloner.md).
105
+
106
+ ## Nested parameters
107
+
108
+ Follow to [documentation page](parameters.md).
109
+
110
+ ## Include multiple associations
111
+
112
+ You can include multiple associations at once too:
113
+
114
+ ```ruby
115
+ class User < ActiveRecord::Base
116
+ has_many :accounts
117
+ has_many :posts
118
+ end
119
+
120
+ class UserCloner < Clowne::Cloner
121
+ adapter :active_record
122
+
123
+ include_associations :accounts, :posts
124
+ end
125
+ ```
126
+
127
+ **NOTE:** in that case, it's not possible to provide custom scopes and options.
128
+
129
+ ### Belongs To association
130
+
131
+ You can include belongs_to association, but will do it carefully.
132
+ If you have loop by relations in your models, when you clone it will raise SystemStackError.
133
+ Check this [test](https://github.com/palkan/clowne/blob/master/spec/clowne/integrations/active_record_belongs_to_spec.rb) for instance.