activerecord-exclusive-arc 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.
- 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: []
|