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.
Files changed (81) hide show
  1. checksums.yaml +4 -4
  2. data/.codeclimate.yml +7 -0
  3. data/.gitattributes +1 -0
  4. data/.gitignore +1 -0
  5. data/.rubocop.yml +17 -0
  6. data/.travis.yml +15 -2
  7. data/CHANGELOG.md +9 -2
  8. data/Gemfile +1 -0
  9. data/README.md +25 -381
  10. data/clowne.gemspec +1 -0
  11. data/docs/.rubocop.yml +12 -0
  12. data/docs/active_record.md +36 -0
  13. data/docs/alternatives.md +26 -0
  14. data/docs/architecture.md +141 -0
  15. data/docs/basic_example.md +66 -0
  16. data/docs/configuration.md +29 -0
  17. data/docs/customization.md +64 -0
  18. data/docs/exclude_association.md +63 -0
  19. data/docs/execution_order.md +14 -0
  20. data/docs/finalize.md +35 -0
  21. data/docs/implicit_cloner.md +36 -0
  22. data/docs/include_association.md +119 -0
  23. data/docs/init_as.md +36 -0
  24. data/docs/inline_configuration.md +38 -0
  25. data/docs/installation.md +16 -0
  26. data/docs/nullify.md +37 -0
  27. data/docs/sequel.md +56 -0
  28. data/docs/supported_adapters.md +13 -0
  29. data/docs/traits.md +28 -0
  30. data/docs/web/.gitignore +11 -0
  31. data/docs/web/core/Footer.js +92 -0
  32. data/docs/web/i18n/en.json +134 -0
  33. data/docs/web/package.json +14 -0
  34. data/docs/web/pages/en/help.js +50 -0
  35. data/docs/web/pages/en/index.js +231 -0
  36. data/docs/web/pages/en/users.js +47 -0
  37. data/docs/web/sidebars.json +30 -0
  38. data/docs/web/siteConfig.js +44 -0
  39. data/docs/web/static/css/custom.css +229 -0
  40. data/docs/web/static/fonts/FiraCode-Medium.woff +0 -0
  41. data/docs/web/static/fonts/FiraCode-Regular.woff +0 -0
  42. data/docs/web/static/fonts/StemText.woff +0 -0
  43. data/docs/web/static/fonts/StemTextBold.woff +0 -0
  44. data/docs/web/static/img/favicon/favicon.ico +0 -0
  45. data/docs/web/yarn.lock +1741 -0
  46. data/gemfiles/activerecord42.gemfile +1 -0
  47. data/gemfiles/jruby.gemfile +2 -0
  48. data/gemfiles/railsmaster.gemfile +1 -0
  49. data/lib/clowne.rb +3 -1
  50. data/lib/clowne/adapters/active_record.rb +3 -12
  51. data/lib/clowne/adapters/active_record/association.rb +1 -1
  52. data/lib/clowne/adapters/active_record/associations/base.rb +8 -48
  53. data/lib/clowne/adapters/active_record/associations/has_one.rb +8 -1
  54. data/lib/clowne/adapters/active_record/associations/noop.rb +4 -1
  55. data/lib/clowne/adapters/active_record/dsl.rb +33 -0
  56. data/lib/clowne/adapters/base.rb +13 -6
  57. data/lib/clowne/adapters/base/association.rb +69 -0
  58. data/lib/clowne/adapters/base/finalize.rb +1 -1
  59. data/lib/clowne/adapters/base/init_as.rb +21 -0
  60. data/lib/clowne/adapters/registry.rb +5 -11
  61. data/lib/clowne/adapters/sequel.rb +25 -0
  62. data/lib/clowne/adapters/sequel/association.rb +47 -0
  63. data/lib/clowne/adapters/sequel/associations.rb +26 -0
  64. data/lib/clowne/adapters/sequel/associations/base.rb +23 -0
  65. data/lib/clowne/adapters/sequel/associations/many_to_many.rb +19 -0
  66. data/lib/clowne/adapters/sequel/associations/noop.rb +16 -0
  67. data/lib/clowne/adapters/sequel/associations/one_to_many.rb +23 -0
  68. data/lib/clowne/adapters/sequel/associations/one_to_one.rb +23 -0
  69. data/lib/clowne/adapters/sequel/copier.rb +23 -0
  70. data/lib/clowne/adapters/sequel/record_wrapper.rb +59 -0
  71. data/lib/clowne/cloner.rb +6 -4
  72. data/lib/clowne/declarations.rb +1 -0
  73. data/lib/clowne/declarations/exclude_association.rb +0 -5
  74. data/lib/clowne/declarations/include_association.rb +0 -2
  75. data/lib/clowne/declarations/init_as.rb +20 -0
  76. data/lib/clowne/declarations/trait.rb +2 -0
  77. data/lib/clowne/ext/orm_ext.rb +21 -0
  78. data/lib/clowne/ext/string_constantize.rb +2 -2
  79. data/lib/clowne/planner.rb +11 -4
  80. data/lib/clowne/version.rb +1 -1
  81. metadata +70 -4
@@ -25,4 +25,5 @@ Gem::Specification.new do |spec|
25
25
  spec.add_development_dependency 'rspec', '~> 3.0'
26
26
  spec.add_development_dependency 'factory_bot', '~> 4.8'
27
27
  spec.add_development_dependency 'rubocop', '~> 0.51'
28
+ spec.add_development_dependency 'rubocop-md', '~> 0.2'
28
29
  end
@@ -0,0 +1,12 @@
1
+ Metrics/LineLength:
2
+ Max: 100
3
+
4
+ Lint/Void:
5
+ Exclude:
6
+ - '*.md'
7
+
8
+ Metrics/AbcSize:
9
+ Enabled: false
10
+
11
+ Metrics/BlockLength:
12
+ Enabled: false
@@ -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
+ ```