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 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
@@ -0,0 +1,11 @@
1
+ source "https://rubygems.org"
2
+
3
+ gem "debug"
4
+ gem "minitest"
5
+ gem "rails", "~> #{ENV.fetch("RAILS_VERSION", "7.0")}"
6
+ gem "rake"
7
+ gem "pg"
8
+ gem "sqlite3"
9
+ gem "standard", "~> 1.26"
10
+
11
+ gemspec
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,11 @@
1
+ require "bundler/gem_tasks"
2
+ require "rake/testtask"
3
+ require "standard/rake"
4
+
5
+ Rake::TestTask.new(:test) do |t|
6
+ t.libs << "test"
7
+ t.libs << "lib"
8
+ t.test_files = FileList["test/**/*_test.rb"]
9
+ end
10
+
11
+ task default: %i[standard test]
@@ -0,0 +1 @@
1
+ require "exclusive_arc"
@@ -0,0 +1,10 @@
1
+ module ExclusiveArc
2
+ class Definition
3
+ attr_reader :reflections, :options
4
+
5
+ def initialize(reflections:, options:)
6
+ @reflections = reflections
7
+ @options = options
8
+ end
9
+ end
10
+ end
@@ -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,3 @@
1
+ module ExclusiveArc
2
+ VERSION = "0.1.0"
3
+ end
@@ -0,0 +1,4 @@
1
+ require "exclusive_arc/version"
2
+ require "exclusive_arc/definition"
3
+ require "exclusive_arc/model"
4
+ require "generators/exclusive_arc_generator"
@@ -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: []