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 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: []