activerecord-exclusive-arc 0.2.2 → 0.3.1

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.
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