clowne 0.1.0.pre1 → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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
+ ```