activerecord-exclusive-arc 0.2.2 → 0.3.1

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: b3824d9259213c59c55ee5287cc9bd3b8e76924be405a269c6752434811c2599
4
- data.tar.gz: 2e8b6ad42b5b4cad774e7ef56e21c54fa9a0e8223fce05762ecb60cf791b4b87
3
+ metadata.gz: 2fa6d4449542465ef3cc8420e9428a6faee1b4a91c547f5df660aeb615c15638
4
+ data.tar.gz: f945d9a76c78658e9c3a7a357f9c46202377ccd11488ec1cd286dfb467dd8eb2
5
5
  SHA512:
6
- metadata.gz: 28d0736dfd28b87e367ab62d9ac1c39cc556bc76d999b390a4b6220b88ac566bcac66c6fda36b0237a5442180b44602c72e6ef837ed99a4f281b0e42da4aac81
7
- data.tar.gz: e62da6627153c7853dde9bba360ec654ea28baf088bb4db6261fc77dd88fc78d1bf4b5f5d2c2d777682d0233e7f6ab72567ed1790e8813670b4964ec69a063a8
6
+ metadata.gz: 8c829330cc1049ffddf223122cab10681e454f13184ea0ae7218bb55ed8f3b90e3ede9e140907917b2402f7a5740b0857ffe5c71dd0ed798a9a82cc364c30438
7
+ data.tar.gz: 140aea768b0e93d5e7cf22b7c0c6517a7ce19ecc64e48bce405501ed06adcc47072a8f789bccabb20fb9b0fb366b2928d5b14573bff9bed91245a4262fb360af
data/Gemfile CHANGED
@@ -1,11 +1,14 @@
1
1
  source "https://rubygems.org"
2
2
 
3
3
  gem "debug"
4
- gem "minitest"
5
- gem "rails", "~> #{ENV.fetch("RAILS_VERSION", "7.0")}"
6
- gem "rake"
4
+ if (rails_version = ENV["RAILS_VERSION"])
5
+ gem "rails", "~> #{rails_version}.0"
6
+ else
7
+ gem "rails"
8
+ end
7
9
  gem "pg"
8
10
  gem "sqlite3"
9
11
  gem "standard", "~> 1.26"
12
+ gem "mysql2"
10
13
 
11
14
  gemspec
data/README.md CHANGED
@@ -1,31 +1,22 @@
1
- ### What does it do?
1
+ ## 💫 `activerecord-exclusive-arc` 💫
2
2
 
3
- It allows an ActiveRecord model to exclusively belong to one of any number of different types of ActiveRecord
4
- models.
3
+ A RubyGem that allows an ActiveRecord model to exclusively belong to one of any number of different
4
+ types of ActiveRecord models.
5
5
 
6
- ### Doesn’t Rails already provide this?
6
+ ### Doesn’t Rails already provide a way to do this?
7
7
 
8
- It does, but there are decent arguments against the default Rails way of doing polymorphism. Consider the
9
- fact that the Ruby class name is stored in the database as a string. If you want to change the name of the
10
- Ruby class used for such reasons, you must also update the database strings that represent it. The bleeding
11
- of application-layer definitions into the database may become a liability.
12
-
13
- Another common argument concerns referential integrity. _Foreign Key Constraints_ are a common mechanism to
14
- ensure primary keys of tables can be reliably used as foreign keys on others. This becomes harder to enforce
15
- when a column that represents a Ruby class is one of the components required for unique identification.
16
-
17
- There are also quality of life considerations, such as not being able to eager-load the `belongs_to ...
18
- polymorphic: true` relationship and the fact that polymorphic indexes require multiple columns.
8
+ Yes but [here’s a post about why this exists](https://waymondo.com/posts/are-exclusive-arcs-evil/).
19
9
 
20
10
  ### So how does this work?
21
11
 
22
- It reduces the boilerplate of managing a _Polymorphic Assication_ modeled as a pattern called an _Exclusive
23
- Arc_. This maps nicely to a database constraint, a set of optional `belongs_to` relationships, some
24
- polymorphic methods, and an `ActiveRecord` validation for good measure.
12
+ It reduces the boilerplate of managing a _Polymorphic Assication_ modeled as a pattern called an
13
+ _Exclusive Arc_, where each potential polymorphic reference has its own foreign key. This maps
14
+ nicely to a set of optional `belongs_to` relationships, some polymorphic convenience methods, and a
15
+ database check constraint with a matching `ActiveRecord` validation.
25
16
 
26
17
  ## How to use
27
18
 
28
- Firstly, in your `Gemfile`:
19
+ Firstly, add the gem to your `Gemfile` and `bundle install`:
29
20
 
30
21
  ```ruby
31
22
  gem "activerecord-exclusive-arc"
@@ -37,8 +28,8 @@ The feature set of this gem is offered via a Rails generator command:
37
28
  bin/rails g exclusive_arc <Model> <arc> <belongs_to1> <belongs_to2> ...
38
29
  ```
39
30
 
40
- This assumes you already have a `<Model>`. The `<arc>` is the name of the polymorphic association you want to
41
- establish that may either be a `<belongs_to1>`, `<belongs_to2>`, etc. Say we ran:
31
+ This assumes you already have a `<Model>`. The `<arc>` is the name of the polymorphic association
32
+ you want to establish that may either be a `<belongs_to1>`, `<belongs_to2>`, etc. Say we ran:
42
33
 
43
34
  ```
44
35
  bin/rails g exclusive_arc Comment commentable post comment
@@ -84,25 +75,26 @@ class Comment < ApplicationRecord
84
75
  end
85
76
  ```
86
77
 
87
- Continuing with our example, the generator command would also produce a migration that looks like this:
78
+ Continuing with our example, the generator command would also produce a migration that looks like
79
+ this:
88
80
 
89
81
  ```ruby
90
- class CommentCommentableExclusiveArc < ActiveRecord::Migration[7.0]
82
+ class CommentCommentableExclusiveArcPostComment < ActiveRecord::Migration[7.0]
91
83
  def change
92
84
  add_reference :comments, :post, foreign_key: true, index: {where: "post_id IS NOT NULL"}
93
85
  add_reference :comments, :comment, foreign_key: true, index: {where: "comment_id IS NOT NULL"}
94
86
  add_check_constraint(
95
87
  :comments,
96
88
  "(CASE WHEN post_id IS NULL THEN 0 ELSE 1 END + CASE WHEN comment_id IS NULL THEN 0 ELSE 1 END) = 1",
97
- name: :commentable
89
+ name: "commentable"
98
90
  )
99
91
  end
100
92
  end
101
93
  ```
102
94
 
103
- The check constraint ensures `ActiveRecord` validations can’t be bypassed to break the fabeled rule - "There
104
- Can Only Be One™️". Traditional foreign key constraints can be used and the partial indexes provide improved
105
- lookup performance for each individual polymorphic assoication.
95
+ The check constraint ensures `ActiveRecord` validations can’t be bypassed to break the fabeled
96
+ rule - "There Can Only Be One". Traditional foreign key constraints can be used and the partial
97
+ indexes provide improved lookup performance for each individual polymorphic assoication.
106
98
 
107
99
  ### Exclusive Arc Options
108
100
 
@@ -114,30 +106,61 @@ Usage:
114
106
  rails generate exclusive_arc NAME [arc belongs_to1 belongs_to2 ...] [options]
115
107
 
116
108
  Options:
117
- [--skip-namespace], [--no-skip-namespace] # Skip namespace (affects only isolated engines)
118
- [--skip-collision-check], [--no-skip-collision-check] # Skip collision check
119
109
  [--optional], [--no-optional] # Exclusive arc is optional
120
110
  [--skip-foreign-key-constraints], [--no-skip-foreign-key-constraints] # Skip foreign key constraints
121
111
  [--skip-foreign-key-indexes], [--no-skip-foreign-key-indexes] # Skip foreign key partial indexes
122
112
  [--skip-check-constraint], [--no-skip-check-constraint] # Skip check constraint
123
113
 
124
- Runtime options:
125
- -f, [--force] # Overwrite files that already exist
126
- -p, [--pretend], [--no-pretend] # Run but do not make any changes
127
- -q, [--quiet], [--no-quiet] # Suppress status output
128
- -s, [--skip], [--no-skip] # Skip files that already exist
129
-
130
114
  Adds an Exclusive Arc to an ActiveRecord model and generates the migration for it
131
115
  ```
132
116
 
133
- Notably, if you want to make an Exclusive Arc optional, you can use the `--optional` flag. This will adjust
134
- the definition in your `ActiveRecord` model and loosen both the validation and database check constraint so
135
- that there can be 0 or 1 foreign keys set for the polymorphic association.
117
+ Notably, if you want to make an Exclusive Arc optional, you can use the `--optional` flag. This will
118
+ adjust the definition in your `ActiveRecord` model and loosen both the validation and database check
119
+ constraint so that there can be 0 or 1 foreign keys set for the polymorphic reference.
120
+
121
+ ### Updating an existing exclusive arc
122
+
123
+ If you need to add an additional polymorphic option to an existing exclusive arc, you can simply run
124
+ the generator command again with the additional target. Existing references will be skipped and the
125
+ check constraint will be removed and re-added in a reversible manner.
126
+
127
+ ```
128
+ bin/rails g exclusive_arc Comment commentable post comment page
129
+ ```
130
+
131
+ ``` ruby
132
+ class CommentCommentableExclusiveArcPostCommentPage < ActiveRecord::Migration[7.0]
133
+ def change
134
+ add_reference :comments, :page, foreign_key: true, index: {where: "page_id IS NOT NULL"}
135
+ remove_check_constraint(
136
+ :comments,
137
+ "(CASE WHEN post_id IS NULL THEN 0 ELSE 1 END + CASE WHEN comment_id IS NULL THEN 0 ELSE 1 END) = 1",
138
+ name: "commentable"
139
+ )
140
+ add_check_constraint(
141
+ :comments,
142
+ "(CASE WHEN post_id IS NULL THEN 0 ELSE 1 END + CASE WHEN comment_id IS NULL THEN 0 ELSE 1 END + CASE WHEN page_id IS NULL THEN 0 ELSE 1 END) = 1",
143
+ name: "commentable"
144
+ )
145
+ end
146
+ end
147
+ ```
148
+
149
+ The registration in the model will be updated as well.
150
+
151
+ ``` ruby
152
+ class Comment < ApplicationRecord
153
+ include ExclusiveArc::Model
154
+ has_exclusive_arc :commentable, [:post, :comment, :page]
155
+ end
156
+ ```
136
157
 
137
158
  ### Compatibility
138
159
 
139
- Currently `activerecord-exclusive-arc` is tested against a matrix of Ruby 2.7 and 3.2, Rails 6.1 and 7.0, and
140
- `postgresql` and `sqlite3` database adapters.
160
+ Currently `activerecord-exclusive-arc` is tested against a matrix of:
161
+ * Ruby 2.7 and 3.3
162
+ * Rails 6.1, 7.0, 7.1
163
+ * `postgresql`, `sqlite3`, and `mysql2` database adapters
141
164
 
142
165
  ### Contributing
143
166
 
@@ -15,7 +15,9 @@ module ExclusiveArc
15
15
  next if reflections[option.to_s]
16
16
 
17
17
  belongs_to(option, optional: true)
18
+ validate :"validate_#{arc}"
18
19
  end
20
+
19
21
  exclusive_arcs[arc] = Definition.new(
20
22
  reflections: reflections.slice(*belong_tos.map(&:to_s)),
21
23
  options: args[2] || {}
@@ -39,9 +41,11 @@ module ExclusiveArc
39
41
  assign_exclusive_arc(:#{arc}, polymorphic)
40
42
  @#{arc} = polymorphic
41
43
  end
42
- RUBY
43
44
 
44
- validate :validate_exclusive_arcs
45
+ def validate_#{arc}
46
+ validate_exclusive_arc(:#{arc})
47
+ end
48
+ RUBY
45
49
  end
46
50
  end
47
51
 
@@ -54,12 +58,11 @@ module ExclusiveArc
54
58
  assign_attributes attributes
55
59
  end
56
60
 
57
- def validate_exclusive_arcs
58
- exclusive_arcs.each do |(arc, definition)|
59
- foreign_key_count = definition.reflections.keys.count { |name| !!public_send(name) }
60
- valid = definition.options[:optional] ? foreign_key_count.in?([0, 1]) : foreign_key_count == 1
61
- errors.add(arc, :arc_not_exclusive) unless valid
62
- end
61
+ def validate_exclusive_arc(arc)
62
+ definition = exclusive_arcs.fetch(arc)
63
+ foreign_key_count = definition.reflections.keys.count { |name| !!public_send(name) }
64
+ valid = definition.options[:optional] ? foreign_key_count.in?([0, 1]) : foreign_key_count == 1
65
+ errors.add(arc, :arc_not_exclusive) unless valid
63
66
  end
64
67
  end
65
68
  end
@@ -1,3 +1,3 @@
1
1
  module ExclusiveArc
2
- VERSION = "0.2.2"
2
+ VERSION = "0.3.1"
3
3
  end
@@ -31,15 +31,20 @@ class ExclusiveArcGenerator < ActiveRecord::Generators::Base
31
31
  model_file_path,
32
32
  class_name.demodulize,
33
33
  <<~RB
34
- #{indents}has_exclusive_arc #{model_exclusive_arcs}
34
+ #{indents}include ExclusiveArc::Model
35
35
  RB
36
36
  )
37
- inject_into_class(
37
+ gsub_file(
38
38
  model_file_path,
39
- class_name.demodulize,
40
- <<~RB
41
- #{indents}include ExclusiveArc::Model
39
+ /has_exclusive_arc :#{arc}(.*)$/,
40
+ ""
41
+ )
42
+ inject_into_file(
43
+ model_file_path,
44
+ <<~RB,
45
+ #{indents}has_exclusive_arc #{model_exclusive_arcs}
42
46
  RB
47
+ after: "include ExclusiveArc::Model\n"
43
48
  )
44
49
  end
45
50
 
@@ -52,8 +57,8 @@ class ExclusiveArcGenerator < ActiveRecord::Generators::Base
52
57
 
53
58
  def add_references
54
59
  belong_tos.map do |reference|
55
- add_reference(reference)
56
- end.join("\n")
60
+ add_reference(reference) unless column_exists?(reference)
61
+ end.compact.join("\n")
57
62
  end
58
63
 
59
64
  def add_reference(reference)
@@ -78,11 +83,22 @@ class ExclusiveArcGenerator < ActiveRecord::Generators::Base
78
83
  end
79
84
 
80
85
  def migration_file_name
81
- "#{class_name.delete(":").underscore}_#{arc}_exclusive_arc.rb"
86
+ "#{class_name.delete(":").underscore}_#{arc}_exclusive_arc_#{belong_tos.map(&:underscore).join("_")}.rb"
82
87
  end
83
88
 
84
89
  def migration_class_name
85
- [class_name.delete(":").singularize, arc.classify, "ExclusiveArc"].join
90
+ (
91
+ [class_name.delete(":").singularize, arc.classify, "ExclusiveArc"] |
92
+ belong_tos.map(&:classify)
93
+ ).join
94
+ end
95
+
96
+ def existing_check_constraint
97
+ @existing_check_constraint ||=
98
+ class_name.constantize.connection.check_constraints(class_name.constantize.table_name)
99
+ .find { |constraint| constraint.name == arc }
100
+ rescue
101
+ nil
86
102
  end
87
103
 
88
104
  def reference_type(reference)
@@ -98,19 +114,41 @@ class ExclusiveArcGenerator < ActiveRecord::Generators::Base
98
114
  reference.tableize
99
115
  end
100
116
 
117
+ def column_exists?(reference)
118
+ foreign_key = foreign_key_name(reference)
119
+ class_name.constantize.column_names.include?(foreign_key)
120
+ rescue
121
+ false
122
+ end
123
+
101
124
  def foreign_key_name(reference)
102
125
  class_name.constantize.reflections[reference].foreign_key
103
126
  rescue
104
127
  "#{reference}_id"
105
128
  end
106
129
 
130
+ def remove_check_constraint
131
+ return unless class_name.constantize.connection.supports_check_constraints?
132
+ return if options[:skip_check_constraint]
133
+ return unless existing_check_constraint&.expression
134
+
135
+ <<-RUBY.chomp
136
+ remove_check_constraint(
137
+ :#{table_name},
138
+ "#{existing_check_constraint.expression.squish}",
139
+ name: "#{arc}"
140
+ )
141
+ RUBY
142
+ end
143
+
107
144
  def add_check_constraint
145
+ return unless class_name.constantize.connection.supports_check_constraints?
108
146
  return if options[:skip_check_constraint]
109
- <<-RUBY
147
+ <<-RUBY.chomp
110
148
  add_check_constraint(
111
149
  :#{table_name},
112
150
  "#{check_constraint}",
113
- name: :#{arc}
151
+ name: "#{arc}"
114
152
  )
115
153
  RUBY
116
154
  end
@@ -1,6 +1,7 @@
1
1
  class <%= migration_class_name %> < ActiveRecord::Migration[<%= ActiveRecord::Migration.current_version %>]
2
2
  def change
3
3
  <%= add_references %>
4
+ <%= remove_check_constraint %>
4
5
  <%= add_check_constraint %>
5
6
  end
6
7
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: activerecord-exclusive-arc
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.2
4
+ version: 0.3.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - justin talbott
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2023-04-10 00:00:00.000000000 Z
11
+ date: 2024-01-20 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: activerecord
@@ -89,7 +89,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
89
89
  - !ruby/object:Gem::Version
90
90
  version: '0'
91
91
  requirements: []
92
- rubygems_version: 3.4.10
92
+ rubygems_version: 3.5.4
93
93
  signing_key:
94
94
  specification_version: 4
95
95
  summary: An ActiveRecord extension for polymorphic exclusive arc relationships