activerecord-exclusive-arc 0.1.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/Gemfile +11 -0
- data/README.md +138 -0
- data/Rakefile +11 -0
- data/lib/activerecord-exclusive-arc.rb +1 -0
- data/lib/exclusive_arc/definition.rb +10 -0
- data/lib/exclusive_arc/model.rb +52 -0
- data/lib/exclusive_arc/version.rb +3 -0
- data/lib/exclusive_arc.rb +4 -0
- data/lib/generators/exclusive_arc_generator.rb +91 -0
- data/lib/generators/templates/migration.rb.erb +10 -0
- metadata +116 -0
checksums.yaml
ADDED
@@ -0,0 +1,7 @@
|
|
1
|
+
---
|
2
|
+
SHA256:
|
3
|
+
metadata.gz: 5dbceaa69bcb956d89d1c636209771640242741ae2e06f57a038f474f64825d8
|
4
|
+
data.tar.gz: a8319a8c41ea99543fc8d52387adb35aa6a56b264adfdfa39a25b8b6d603f774
|
5
|
+
SHA512:
|
6
|
+
metadata.gz: c0c2ab91655256c9226c3775d2e6ee1c05d733089110728ade4aa39416e23f15a7a048f6e2189fc23aa9aac93783180ac8eabca57f404808957a044375c9ee8d
|
7
|
+
data.tar.gz: ca725edf128c121af9e4a5e8880b8a8895ce8eefabf0ae9175d7c0054a1c7f38475f8b8e47ede79cd31db64fc63a697e97c28525c63062a21b148e042eaefadf
|
data/Gemfile
ADDED
data/README.md
ADDED
@@ -0,0 +1,138 @@
|
|
1
|
+
### What does it do?
|
2
|
+
|
3
|
+
It allows an ActiveRecord model to exclusively belong to one of any number of different types of ActiveRecord
|
4
|
+
models.
|
5
|
+
|
6
|
+
### Doesn’t Rails already provide this?
|
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.
|
19
|
+
|
20
|
+
### So how does this work?
|
21
|
+
|
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.
|
25
|
+
|
26
|
+
## How to use
|
27
|
+
|
28
|
+
Firstly, in your `Gemfile`:
|
29
|
+
|
30
|
+
```ruby
|
31
|
+
gem "activerecord-exclusive-arc"
|
32
|
+
```
|
33
|
+
|
34
|
+
The feature set of this gem is offered via a Rails generator command:
|
35
|
+
|
36
|
+
```
|
37
|
+
bin/rails g exclusive_arc <Model> <arc> <belongs_to1> <belongs_to2> ...
|
38
|
+
```
|
39
|
+
|
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:
|
42
|
+
|
43
|
+
```
|
44
|
+
bin/rails g exclusive_arc Comment commentable post comment
|
45
|
+
```
|
46
|
+
|
47
|
+
This will inject code into your `Comment` Model:
|
48
|
+
|
49
|
+
```ruby
|
50
|
+
class Comment < ApplicationRecord
|
51
|
+
include ExclusiveArc::Model
|
52
|
+
exclusive_arc :commentable, [:post, :comment]
|
53
|
+
end
|
54
|
+
```
|
55
|
+
|
56
|
+
At a high-level, this essentially transpiles to the following:
|
57
|
+
|
58
|
+
```ruby
|
59
|
+
class Comment < ApplicationRecord
|
60
|
+
belongs_to :post, optional: true
|
61
|
+
belongs_to :comment, optional: true
|
62
|
+
validate :post_or_comment_present?
|
63
|
+
|
64
|
+
def commentable
|
65
|
+
@commentable ||= (post || comment)
|
66
|
+
end
|
67
|
+
|
68
|
+
def commentable=(post_or_comment)
|
69
|
+
@commentable = post_or_comment
|
70
|
+
end
|
71
|
+
end
|
72
|
+
```
|
73
|
+
|
74
|
+
It's a bit more involved than that, but it demonstrates the essense of the API as an `ActiveRecord` user.
|
75
|
+
|
76
|
+
Continuing with our example, the generator command would also produce a migration that looks like this:
|
77
|
+
|
78
|
+
```ruby
|
79
|
+
class CommentCommentableExclusiveArc < ActiveRecord::Migration[7.0]
|
80
|
+
def change
|
81
|
+
add_reference :comments, :post, foreign_key: true, index: {where: "post_id IS NOT NULL"}
|
82
|
+
add_reference :comments, :comment, foreign_key: true, index: {where: "comment_id IS NOT NULL"}
|
83
|
+
add_check_constraint(
|
84
|
+
:comments,
|
85
|
+
"(CASE WHEN post_id IS NULL THEN 0 ELSE 1 END + CASE WHEN comment_id IS NULL THEN 0 ELSE 1 END) = 1",
|
86
|
+
name: :commentable
|
87
|
+
)
|
88
|
+
end
|
89
|
+
end
|
90
|
+
```
|
91
|
+
|
92
|
+
The check constraint ensures `ActiveRecord` validations can’t be bypassed to break the fabeled rule - "There
|
93
|
+
Can Only Be One™️". Traditional foreign key constraints can be used and the partial indexes provide improved
|
94
|
+
lookup performance for each individual polymorphic assoication.
|
95
|
+
|
96
|
+
### Exclusive Arc Options
|
97
|
+
|
98
|
+
Some options are available to the generator command. You can see them with:
|
99
|
+
|
100
|
+
```
|
101
|
+
$ bin/rails g exclusive_arc --help
|
102
|
+
Usage:
|
103
|
+
rails generate exclusive_arc NAME [arc belongs_to1 belongs_to2 ...] [options]
|
104
|
+
|
105
|
+
Options:
|
106
|
+
[--skip-namespace], [--no-skip-namespace] # Skip namespace (affects only isolated engines)
|
107
|
+
[--skip-collision-check], [--no-skip-collision-check] # Skip collision check
|
108
|
+
[--optional], [--no-optional] # Exclusive arc is optional
|
109
|
+
[--skip-foreign-key-constraints], [--no-skip-foreign-key-constraints] # Skip foreign key constraints
|
110
|
+
[--skip-foreign-key-indexes], [--no-skip-foreign-key-indexes] # Skip foreign key partial indexes
|
111
|
+
[--skip-check-constraint], [--no-skip-check-constraint] # Skip check constraint
|
112
|
+
|
113
|
+
Runtime options:
|
114
|
+
-f, [--force] # Overwrite files that already exist
|
115
|
+
-p, [--pretend], [--no-pretend] # Run but do not make any changes
|
116
|
+
-q, [--quiet], [--no-quiet] # Suppress status output
|
117
|
+
-s, [--skip], [--no-skip] # Skip files that already exist
|
118
|
+
|
119
|
+
Adds an Exclusive Arc to an ActiveRecord model and generates the migration for it
|
120
|
+
```
|
121
|
+
|
122
|
+
Notably, if you want to make an Exclusive Arc optional, you can use the `--optional` flag. This will adjust
|
123
|
+
the definition in your `ActiveRecord` model and loosen both the validation and database check constraint so
|
124
|
+
that there can be 0 or 1 foreign keys set for the polymorphic association.
|
125
|
+
|
126
|
+
### Compatibility
|
127
|
+
|
128
|
+
Currently `activerecord-exclusive-arc` is tested against a matrix of Ruby 2.7 and 3.2, Rails 6.1 and 7.0, and
|
129
|
+
`postgresql` and `sqlite3` database adapters.
|
130
|
+
|
131
|
+
### Contributing
|
132
|
+
|
133
|
+
Bug reports and pull requests are welcome on GitHub at https://github.com/waymondo/activerecord-exclusive-arc.
|
134
|
+
|
135
|
+
### License
|
136
|
+
|
137
|
+
The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
|
138
|
+
|
data/Rakefile
ADDED
@@ -0,0 +1 @@
|
|
1
|
+
require "exclusive_arc"
|
@@ -0,0 +1,52 @@
|
|
1
|
+
module ExclusiveArc
|
2
|
+
module Model
|
3
|
+
extend ActiveSupport::Concern
|
4
|
+
|
5
|
+
included do
|
6
|
+
class_attribute :exclusive_arcs, default: {}
|
7
|
+
delegate :exclusive_arcs, to: :class
|
8
|
+
end
|
9
|
+
|
10
|
+
class_methods do
|
11
|
+
def exclusive_arc(*args)
|
12
|
+
arcs = args[0].is_a?(Hash) ? args[0] : {args[0] => args[1]}
|
13
|
+
options = args[2] || {}
|
14
|
+
arcs.each do |(name, belong_tos)|
|
15
|
+
belong_tos.map { |option| belongs_to(option, optional: true) }
|
16
|
+
exclusive_arcs[name] = Definition.new(
|
17
|
+
reflections: reflections.slice(*belong_tos.map(&:to_s)),
|
18
|
+
options: options
|
19
|
+
)
|
20
|
+
|
21
|
+
class_eval <<-RUBY, __FILE__, __LINE__ + 1
|
22
|
+
def #{name}
|
23
|
+
#{belong_tos.join(" || ")}
|
24
|
+
end
|
25
|
+
|
26
|
+
def #{name}=(polymorphic)
|
27
|
+
assign_exclusive_arc(:#{name}, polymorphic)
|
28
|
+
end
|
29
|
+
RUBY
|
30
|
+
end
|
31
|
+
|
32
|
+
validate :validate_exclusive_arcs
|
33
|
+
end
|
34
|
+
end
|
35
|
+
|
36
|
+
private
|
37
|
+
|
38
|
+
def assign_exclusive_arc(arc, polymorphic)
|
39
|
+
exclusive_arcs.fetch(arc).reflections.each do |name, reflection|
|
40
|
+
public_send("#{name}=", polymorphic.is_a?(reflection.klass) ? polymorphic : nil)
|
41
|
+
end
|
42
|
+
end
|
43
|
+
|
44
|
+
def validate_exclusive_arcs
|
45
|
+
exclusive_arcs.each do |(arc, definition)|
|
46
|
+
foreign_key_count = definition.reflections.keys.count { |name| !!public_send(name) }
|
47
|
+
valid = definition.options[:optional] ? foreign_key_count.in?([0, 1]) : foreign_key_count == 1
|
48
|
+
errors.add(arc, :arc_not_exclusive) unless valid
|
49
|
+
end
|
50
|
+
end
|
51
|
+
end
|
52
|
+
end
|
@@ -0,0 +1,91 @@
|
|
1
|
+
require "rails/generators"
|
2
|
+
require "rails/generators/active_record/migration/migration_generator"
|
3
|
+
|
4
|
+
class ExclusiveArcGenerator < ActiveRecord::Generators::Base
|
5
|
+
source_root File.expand_path("templates", __dir__)
|
6
|
+
desc "Adds an Exclusive Arc to an ActiveRecord model and generates the migration for it"
|
7
|
+
|
8
|
+
argument :arguments, type: :array, default: [], banner: "arc belongs_to1 belongs_to2 ..."
|
9
|
+
class_option :optional, type: :boolean, default: false, desc: "Exclusive arc is optional"
|
10
|
+
class_option :skip_foreign_key_constraints, type: :boolean, default: false, desc: "Skip foreign key constraints"
|
11
|
+
class_option :skip_foreign_key_indexes, type: :boolean, default: false, desc: "Skip foreign key partial indexes"
|
12
|
+
class_option :skip_check_constraint, type: :boolean, default: false, desc: "Skip check constraint"
|
13
|
+
|
14
|
+
Error = Class.new(StandardError)
|
15
|
+
|
16
|
+
def initialize(*args)
|
17
|
+
raise Error, "must supply a Model, arc, and at least two belong_tos" if args[0].size <= 3
|
18
|
+
super
|
19
|
+
end
|
20
|
+
|
21
|
+
def create_exclusive_arc_migration
|
22
|
+
migration_template(
|
23
|
+
"migration.rb.erb",
|
24
|
+
"db/migrate/#{migration_file_name}"
|
25
|
+
)
|
26
|
+
end
|
27
|
+
|
28
|
+
def inject_exclusive_arc_into_model
|
29
|
+
indents = " " * (class_name.scan("::").count + 1)
|
30
|
+
inject_into_class(
|
31
|
+
model_file_path,
|
32
|
+
class_name.demodulize,
|
33
|
+
<<~RB
|
34
|
+
#{indents}include ExclusiveArc::Model
|
35
|
+
#{indents}exclusive_arc #{model_exclusive_arcs}
|
36
|
+
RB
|
37
|
+
)
|
38
|
+
end
|
39
|
+
|
40
|
+
no_tasks do
|
41
|
+
def model_exclusive_arcs
|
42
|
+
string = ":#{arc}, [#{belong_tos.map { |reference| ":#{reference}" }.join(", ")}]"
|
43
|
+
string += ", optional: true" if options[:optional]
|
44
|
+
string
|
45
|
+
end
|
46
|
+
|
47
|
+
def add_reference(reference)
|
48
|
+
string = "add_reference :#{table_name}, :#{reference}"
|
49
|
+
type = reference_type(reference)
|
50
|
+
string += ", type: :#{type}" unless /int/.match?(type.downcase)
|
51
|
+
string += ", foreign_key: true" unless options[:skip_foreign_key_constraints]
|
52
|
+
string += ", index: {where: \"#{reference}_id IS NOT NULL\"}" unless options[:skip_foreign_key_indexes]
|
53
|
+
string
|
54
|
+
end
|
55
|
+
|
56
|
+
def migration_file_name
|
57
|
+
"#{class_name.delete(":").underscore}_#{arc}_exclusive_arc.rb"
|
58
|
+
end
|
59
|
+
|
60
|
+
def migration_class_name
|
61
|
+
[class_name.delete(":").singularize, arc.classify, "ExclusiveArc"].join
|
62
|
+
end
|
63
|
+
|
64
|
+
def reference_type(reference)
|
65
|
+
klass = reference.singularize.classify.constantize
|
66
|
+
klass.columns.find { |col| col.name == klass.primary_key }.sql_type
|
67
|
+
rescue
|
68
|
+
"bigint"
|
69
|
+
end
|
70
|
+
|
71
|
+
def check_constraint
|
72
|
+
reference_checks = belong_tos.map do |reference|
|
73
|
+
"CASE WHEN #{reference}_id IS NULL THEN 0 ELSE 1 END"
|
74
|
+
end
|
75
|
+
condition = options[:optional] ? "<= 1" : "= 1"
|
76
|
+
"(#{reference_checks.join(" + ")}) #{condition}"
|
77
|
+
end
|
78
|
+
|
79
|
+
def arc
|
80
|
+
arguments[0]
|
81
|
+
end
|
82
|
+
|
83
|
+
def belong_tos
|
84
|
+
@belong_tos ||= arguments.slice(1, arguments.length - 1)
|
85
|
+
end
|
86
|
+
|
87
|
+
def model_file_path
|
88
|
+
File.join("app", "models", "#{file_path}.rb")
|
89
|
+
end
|
90
|
+
end
|
91
|
+
end
|
@@ -0,0 +1,10 @@
|
|
1
|
+
class <%= migration_class_name %> < ActiveRecord::Migration[<%= ActiveRecord::Migration.current_version %>]
|
2
|
+
def change
|
3
|
+
<% belong_tos.each do |reference| %><%= add_reference(reference) %>
|
4
|
+
<% end %><% unless options[:skip_check_constraint] %>add_check_constraint(
|
5
|
+
:<%= table_name %>,
|
6
|
+
"<%= check_constraint %>",
|
7
|
+
name: :<%= arc %>
|
8
|
+
)<% end %>
|
9
|
+
end
|
10
|
+
end
|
metadata
ADDED
@@ -0,0 +1,116 @@
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
2
|
+
name: activerecord-exclusive-arc
|
3
|
+
version: !ruby/object:Gem::Version
|
4
|
+
version: 0.1.0
|
5
|
+
platform: ruby
|
6
|
+
authors:
|
7
|
+
- justin talbott
|
8
|
+
autorequire:
|
9
|
+
bindir: exe
|
10
|
+
cert_chain: []
|
11
|
+
date: 2023-04-08 00:00:00.000000000 Z
|
12
|
+
dependencies:
|
13
|
+
- !ruby/object:Gem::Dependency
|
14
|
+
name: activerecord
|
15
|
+
requirement: !ruby/object:Gem::Requirement
|
16
|
+
requirements:
|
17
|
+
- - ">="
|
18
|
+
- !ruby/object:Gem::Version
|
19
|
+
version: '6.1'
|
20
|
+
- - "<"
|
21
|
+
- !ruby/object:Gem::Version
|
22
|
+
version: '8'
|
23
|
+
type: :runtime
|
24
|
+
prerelease: false
|
25
|
+
version_requirements: !ruby/object:Gem::Requirement
|
26
|
+
requirements:
|
27
|
+
- - ">="
|
28
|
+
- !ruby/object:Gem::Version
|
29
|
+
version: '6.1'
|
30
|
+
- - "<"
|
31
|
+
- !ruby/object:Gem::Version
|
32
|
+
version: '8'
|
33
|
+
- !ruby/object:Gem::Dependency
|
34
|
+
name: activesupport
|
35
|
+
requirement: !ruby/object:Gem::Requirement
|
36
|
+
requirements:
|
37
|
+
- - ">="
|
38
|
+
- !ruby/object:Gem::Version
|
39
|
+
version: '6.1'
|
40
|
+
- - "<"
|
41
|
+
- !ruby/object:Gem::Version
|
42
|
+
version: '8'
|
43
|
+
type: :runtime
|
44
|
+
prerelease: false
|
45
|
+
version_requirements: !ruby/object:Gem::Requirement
|
46
|
+
requirements:
|
47
|
+
- - ">="
|
48
|
+
- !ruby/object:Gem::Version
|
49
|
+
version: '6.1'
|
50
|
+
- - "<"
|
51
|
+
- !ruby/object:Gem::Version
|
52
|
+
version: '8'
|
53
|
+
- !ruby/object:Gem::Dependency
|
54
|
+
name: railties
|
55
|
+
requirement: !ruby/object:Gem::Requirement
|
56
|
+
requirements:
|
57
|
+
- - ">="
|
58
|
+
- !ruby/object:Gem::Version
|
59
|
+
version: '6.1'
|
60
|
+
- - "<"
|
61
|
+
- !ruby/object:Gem::Version
|
62
|
+
version: '8'
|
63
|
+
type: :runtime
|
64
|
+
prerelease: false
|
65
|
+
version_requirements: !ruby/object:Gem::Requirement
|
66
|
+
requirements:
|
67
|
+
- - ">="
|
68
|
+
- !ruby/object:Gem::Version
|
69
|
+
version: '6.1'
|
70
|
+
- - "<"
|
71
|
+
- !ruby/object:Gem::Version
|
72
|
+
version: '8'
|
73
|
+
description:
|
74
|
+
email:
|
75
|
+
- gmail@justintalbott.com
|
76
|
+
executables: []
|
77
|
+
extensions: []
|
78
|
+
extra_rdoc_files: []
|
79
|
+
files:
|
80
|
+
- Gemfile
|
81
|
+
- README.md
|
82
|
+
- Rakefile
|
83
|
+
- lib/activerecord-exclusive-arc.rb
|
84
|
+
- lib/exclusive_arc.rb
|
85
|
+
- lib/exclusive_arc/definition.rb
|
86
|
+
- lib/exclusive_arc/model.rb
|
87
|
+
- lib/exclusive_arc/version.rb
|
88
|
+
- lib/generators/exclusive_arc_generator.rb
|
89
|
+
- lib/generators/templates/migration.rb.erb
|
90
|
+
homepage: https://github.com/waymondo/exclusive-arc
|
91
|
+
licenses:
|
92
|
+
- MIT
|
93
|
+
metadata:
|
94
|
+
homepage_uri: https://github.com/waymondo/exclusive-arc
|
95
|
+
source_code_uri: https://github.com/waymondo/exclusive-arc
|
96
|
+
rubygems_mfa_required: 'true'
|
97
|
+
post_install_message:
|
98
|
+
rdoc_options: []
|
99
|
+
require_paths:
|
100
|
+
- lib
|
101
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
102
|
+
requirements:
|
103
|
+
- - ">="
|
104
|
+
- !ruby/object:Gem::Version
|
105
|
+
version: 2.7.0
|
106
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
107
|
+
requirements:
|
108
|
+
- - ">="
|
109
|
+
- !ruby/object:Gem::Version
|
110
|
+
version: '0'
|
111
|
+
requirements: []
|
112
|
+
rubygems_version: 3.4.10
|
113
|
+
signing_key:
|
114
|
+
specification_version: 4
|
115
|
+
summary: An ActiveRecord extension for polymorphic exclusive arc relationships
|
116
|
+
test_files: []
|