acts-as-joinable 0.0.1.5
Sign up to get free protection for your applications and to get access to all the features.
- data/MIT-LICENSE +20 -0
- data/README.markdown +54 -0
- data/Rakefile +78 -0
- data/init.rb +1 -0
- data/lib/acts-as-joinable.rb +172 -0
- data/rails/init.rb +3 -0
- data/test/lib/_database.rb +33 -0
- data/test/lib/asset.rb +3 -0
- data/test/lib/post.rb +3 -0
- data/test/lib/tag.rb +3 -0
- data/test/test_acts_as_joinable.rb +38 -0
- data/test/test_helper.rb +38 -0
- metadata +79 -0
data/MIT-LICENSE
ADDED
@@ -0,0 +1,20 @@
|
|
1
|
+
Copyright (c) 2010 Lance Pollard (lancejpollard@gmail.com)
|
2
|
+
|
3
|
+
Permission is hereby granted, free of charge, to any person obtaining
|
4
|
+
a copy of this software and associated documentation files (the
|
5
|
+
"Software"), to deal in the Software without restriction, including
|
6
|
+
without limitation the rights to use, copy, modify, merge, publish,
|
7
|
+
distribute, sublicense, and/or sell copies of the Software, and to
|
8
|
+
permit persons to whom the Software is furnished to do so, subject to
|
9
|
+
the following conditions:
|
10
|
+
|
11
|
+
The above copyright notice and this permission notice shall be
|
12
|
+
included in all copies or substantial portions of the Software.
|
13
|
+
|
14
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
15
|
+
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
16
|
+
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
|
17
|
+
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
|
18
|
+
LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
|
19
|
+
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
20
|
+
WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
data/README.markdown
ADDED
@@ -0,0 +1,54 @@
|
|
1
|
+
# ActsAsJoinable
|
2
|
+
|
3
|
+
It extends the functionality of `has_and_belongs_to_many`, conventionalizing common use cases.
|
4
|
+
|
5
|
+
## Usage
|
6
|
+
|
7
|
+
### Install
|
8
|
+
|
9
|
+
sudo gem install acts-as-joinable
|
10
|
+
|
11
|
+
### Add Relationships
|
12
|
+
|
13
|
+
Say you have `Post` and `Asset` models. If each `has_many` of each other `through` some join model, you can write it as such:
|
14
|
+
|
15
|
+
class Post < ActiveRecord::Base
|
16
|
+
acts_as_joinable_on :assets, :layouts, :tags, :slugs
|
17
|
+
end
|
18
|
+
|
19
|
+
class Asset < ActiveRecord::Base
|
20
|
+
acts_as_joinable
|
21
|
+
end
|
22
|
+
|
23
|
+
That is a replacement for the longer (and non-polymorphic):
|
24
|
+
|
25
|
+
class Post < ActiveRecord::Base
|
26
|
+
has_and_belongs_to_many :assets
|
27
|
+
end
|
28
|
+
|
29
|
+
class Asset < ActiveRecord::Base
|
30
|
+
has_and_belongs_to_many :posts
|
31
|
+
end
|
32
|
+
|
33
|
+
## Why
|
34
|
+
|
35
|
+
Many-to-many relationships end up requiring the same features 99% of the time:
|
36
|
+
|
37
|
+
1. Join Table that keeps track of `context`
|
38
|
+
- [ActsAsTaggableOn](http://github.com/mbleigh/acts-as-taggable-on/blob/master/lib/generators/acts_as_taggable_on/migration/templates/active_record/migration.rb)
|
39
|
+
- ActsAsAuthorized
|
40
|
+
- [Preferences](http://github.com/pluginaweek/preferences/blob/master/generators/preferences/templates/001_create_preferences.rb)
|
41
|
+
- [FriendlyId](http://github.com/norman/friendly_id/blob/ca9821192c8c3c4e81a938603151645c7cbe1470/generators/friendly_id/templates/create_slugs.rb)
|
42
|
+
|
43
|
+
It looks like this:
|
44
|
+
|
45
|
+
create_table :relationships do |t|
|
46
|
+
t.references :parent, :polymorphic => true
|
47
|
+
t.references :child, :polymorphic => true
|
48
|
+
t.string :context
|
49
|
+
t.timestamps
|
50
|
+
end
|
51
|
+
|
52
|
+
## Alternatives
|
53
|
+
|
54
|
+
- [ActsAsRelationable](http://github.com/winton/acts_as_relationable)
|
data/Rakefile
ADDED
@@ -0,0 +1,78 @@
|
|
1
|
+
require 'rake'
|
2
|
+
require "rake/rdoctask"
|
3
|
+
require 'rake/gempackagetask'
|
4
|
+
|
5
|
+
spec = Gem::Specification.new do |s|
|
6
|
+
s.name = "acts-as-joinable"
|
7
|
+
s.authors = ["Lance Pollard"]
|
8
|
+
s.version = "0.0.1.5"
|
9
|
+
s.summary = "ActsAsJoinable: DRYing up Many-to-Many Relationships in ActiveRecord"
|
10
|
+
s.homepage = "http://github.com/viatropos/cockpit"
|
11
|
+
s.email = "lancejpollard@gmail.com"
|
12
|
+
s.description = "DRYing up Many-to-Many Relationships in ActiveRecord"
|
13
|
+
s.has_rdoc = false
|
14
|
+
s.rubyforge_project = "acts-as-joinable"
|
15
|
+
s.platform = Gem::Platform::RUBY
|
16
|
+
s.files = %w(README.markdown Rakefile init.rb MIT-LICENSE) + Dir["{lib,rails,test}/**/*"] - Dir["test/tmp"]
|
17
|
+
s.require_path = "lib"
|
18
|
+
end
|
19
|
+
|
20
|
+
Rake::GemPackageTask.new(spec) do |pkg|
|
21
|
+
pkg.gem_spec = spec
|
22
|
+
pkg.package_dir = "pkg"
|
23
|
+
end
|
24
|
+
|
25
|
+
desc 'run unit tests'
|
26
|
+
task :test do
|
27
|
+
Dir["test/**/*"].each do |file|
|
28
|
+
next unless File.basename(file) =~ /test_/
|
29
|
+
next unless File.extname(file) == ".rb"
|
30
|
+
system "ruby #{file}"
|
31
|
+
end
|
32
|
+
end
|
33
|
+
|
34
|
+
desc "Create .gemspec file (useful for github)"
|
35
|
+
task :gemspec do
|
36
|
+
File.open("pkg/#{spec.name}.gemspec", "w") do |f|
|
37
|
+
f.puts spec.to_ruby
|
38
|
+
end
|
39
|
+
end
|
40
|
+
|
41
|
+
desc "Build the gem into the current directory"
|
42
|
+
task :gem => :gemspec do
|
43
|
+
`gem build pkg/#{spec.name}.gemspec`
|
44
|
+
end
|
45
|
+
|
46
|
+
desc "Publish gem to rubygems"
|
47
|
+
task :publish => [:package] do
|
48
|
+
%x[gem push pkg/#{spec.name}-#{spec.version}.gem]
|
49
|
+
end
|
50
|
+
|
51
|
+
desc "Print a list of the files to be put into the gem"
|
52
|
+
task :manifest do
|
53
|
+
File.open("Manifest", "w") do |f|
|
54
|
+
spec.files.each do |file|
|
55
|
+
f.puts file
|
56
|
+
end
|
57
|
+
end
|
58
|
+
end
|
59
|
+
|
60
|
+
desc "Install the gem locally"
|
61
|
+
task :install => [:package] do
|
62
|
+
File.mkdir("pkg") unless File.exists?("pkg")
|
63
|
+
command = "gem install pkg/#{spec.name}-#{spec.version} --no-ri --no-rdoc"
|
64
|
+
command = "sudo #{command}" if ENV["SUDO"] == true
|
65
|
+
sh %{#{command}}
|
66
|
+
end
|
67
|
+
|
68
|
+
desc "Generate the rdoc"
|
69
|
+
Rake::RDocTask.new do |rdoc|
|
70
|
+
files = ["README.markdown", "lib/**/*.rb"]
|
71
|
+
rdoc.rdoc_files.add(files)
|
72
|
+
rdoc.main = "README.markdown"
|
73
|
+
rdoc.title = spec.summary
|
74
|
+
end
|
75
|
+
|
76
|
+
task :yank do
|
77
|
+
`gem yank #{spec.name} -v #{spec.version}`
|
78
|
+
end
|
data/init.rb
ADDED
@@ -0,0 +1 @@
|
|
1
|
+
File.dirname(__FILE__) + "/rails/init.rb"
|
@@ -0,0 +1,172 @@
|
|
1
|
+
module ActsAsJoinable
|
2
|
+
|
3
|
+
def self.models
|
4
|
+
@models ||= Dir[Rails.root + "/app/models/*.rb"].collect { |f| File.basename f, '.rb' }
|
5
|
+
end
|
6
|
+
|
7
|
+
def self.models=(value)
|
8
|
+
@models = value
|
9
|
+
end
|
10
|
+
|
11
|
+
def self.included(base)
|
12
|
+
base.extend ClassMethods
|
13
|
+
end
|
14
|
+
|
15
|
+
module ClassMethods
|
16
|
+
# the parent in the relationship, so to speak
|
17
|
+
def acts_as_joinable_on(*args, &block)
|
18
|
+
if args.empty? # Relationship model
|
19
|
+
belongs_to :parent, :polymorphic => true
|
20
|
+
belongs_to :child, :polymorphic => true
|
21
|
+
|
22
|
+
ActsAsJoinable.models.each do |m|
|
23
|
+
belongs_to "parent_#{m}".intern, :foreign_key => 'parent_id', :class_name => m.camelize
|
24
|
+
belongs_to "child_#{m}".intern, :foreign_key => 'child_id', :class_name => m.camelize
|
25
|
+
end
|
26
|
+
else
|
27
|
+
options = args.extract_options!
|
28
|
+
sql = options[:conditions]
|
29
|
+
table = options[:table]
|
30
|
+
fields = options[:fields] || []
|
31
|
+
fields = [ fields ] unless fields.respond_to?(:flatten)
|
32
|
+
|
33
|
+
has_many :parent_relationships, :class_name => 'ActsAsJoinable::Relationship', :as => :child
|
34
|
+
has_many :child_relationships, :class_name => 'ActsAsJoinable::Relationship', :as => :parent
|
35
|
+
|
36
|
+
args.each do |type|
|
37
|
+
type = type.to_s
|
38
|
+
table = table || type
|
39
|
+
select = "#{table}.*, relationships.id AS relationship_id#{fields.empty? ? '' : ', '}" + fields.collect { |f| "relationships.#{f}" }.join(', ')
|
40
|
+
opts = {
|
41
|
+
:select => select,
|
42
|
+
:conditions => sql,
|
43
|
+
:through => :parent_relationships,
|
44
|
+
:source => :parent,
|
45
|
+
:class_name => type.classify,
|
46
|
+
:source_type => table.classify
|
47
|
+
}
|
48
|
+
|
49
|
+
has_many "parent_#{type}", opts do
|
50
|
+
fields.each do |field|
|
51
|
+
define_method field.to_s.pluralize do |*args|
|
52
|
+
value = args[0] || 1
|
53
|
+
scoped :conditions => [ "relationships.#{field} = ?", value ]
|
54
|
+
end
|
55
|
+
end
|
56
|
+
end
|
57
|
+
|
58
|
+
opts = {
|
59
|
+
:select => select,
|
60
|
+
:conditions => sql,
|
61
|
+
:through => :child_relationships,
|
62
|
+
:source => :child,
|
63
|
+
:class_name => type.classify,
|
64
|
+
:source_type => table.classify
|
65
|
+
}
|
66
|
+
|
67
|
+
has_many "child_#{type}", opts do
|
68
|
+
fields.each do |field|
|
69
|
+
define_method field.to_s.pluralize do |*args|
|
70
|
+
value = args[0] || 1
|
71
|
+
scoped :conditions => [ "relationships.#{field} = ?", value ]
|
72
|
+
end
|
73
|
+
end
|
74
|
+
end
|
75
|
+
|
76
|
+
self.class_eval do
|
77
|
+
# Records reader
|
78
|
+
define_method type do |*args|
|
79
|
+
if (read_attribute(:type) || self.class.to_s) < (args.empty? ? type.classify : args[0].to_s)
|
80
|
+
eval "self.child_#{type}"
|
81
|
+
else
|
82
|
+
eval "self.parent_#{type}"
|
83
|
+
end
|
84
|
+
end
|
85
|
+
end
|
86
|
+
|
87
|
+
fields.each do |field|
|
88
|
+
# Relationship field writer
|
89
|
+
self.class_eval do
|
90
|
+
define_method field.to_s + '=' do |value|
|
91
|
+
modified = read_attribute(:modified_relationship_fields) || []
|
92
|
+
modified << field
|
93
|
+
write_attribute :modified_relationship_fields, modified.uniq
|
94
|
+
write_attribute field, value
|
95
|
+
end
|
96
|
+
end
|
97
|
+
end
|
98
|
+
end
|
99
|
+
unless included_modules.include?(InstanceMethods)
|
100
|
+
extend ClassMethods
|
101
|
+
include InstanceMethods
|
102
|
+
before_save :save_relationship_fields
|
103
|
+
end
|
104
|
+
end
|
105
|
+
end
|
106
|
+
|
107
|
+
# the child in the relationship, so to speak
|
108
|
+
def acts_as_joinable(*args, &block)
|
109
|
+
acts_as_joinable_on(*args, &block)
|
110
|
+
end
|
111
|
+
end
|
112
|
+
|
113
|
+
module InstanceMethods
|
114
|
+
# Before save
|
115
|
+
def save_relationship_fields
|
116
|
+
return unless read_attribute(:relationship_id) && read_attribute(:modified_relationship_fields)
|
117
|
+
r = Relationship.find self.relationship_id
|
118
|
+
read_attribute(:modified_relationship_fields).each do |field|
|
119
|
+
r[field] = self[field]
|
120
|
+
end
|
121
|
+
r.save
|
122
|
+
write_attribute :modified_relationship_fields, nil
|
123
|
+
end
|
124
|
+
|
125
|
+
def get_joining(type, context)
|
126
|
+
get_joinings(type, context).first
|
127
|
+
end
|
128
|
+
|
129
|
+
def get_joinings(type, context)
|
130
|
+
self.joinings.select do |joining|
|
131
|
+
joining.context == context.to_s
|
132
|
+
end
|
133
|
+
end
|
134
|
+
|
135
|
+
def get_joined(type, context)
|
136
|
+
get_joineds(type, context).first
|
137
|
+
end
|
138
|
+
|
139
|
+
def get_joineds(type, context)
|
140
|
+
return [] unless self.joineds and !self.joineds.empty?
|
141
|
+
|
142
|
+
get_joinings(context).collect do |joining|
|
143
|
+
joining.joined
|
144
|
+
end
|
145
|
+
end
|
146
|
+
|
147
|
+
def set_joined(type, context, value)
|
148
|
+
joining = get_joining(context) || Joining.new
|
149
|
+
clazz = get_join_class(type)
|
150
|
+
joining.joined = value.is_a?(clazz) ? value : clazz.find(value)
|
151
|
+
joining.joining = self
|
152
|
+
joining.context = context.to_s
|
153
|
+
joining.save
|
154
|
+
self.send("#{context.to_s}_#{type.to_s}")
|
155
|
+
end
|
156
|
+
|
157
|
+
private
|
158
|
+
def get_join_class(type)
|
159
|
+
if type.is_a?(String) || type.is_a?(Symbol)
|
160
|
+
type.to_s.camelize.constantize
|
161
|
+
elsif type.is_a?(Class)
|
162
|
+
type
|
163
|
+
else
|
164
|
+
type.class
|
165
|
+
end
|
166
|
+
end
|
167
|
+
end
|
168
|
+
end
|
169
|
+
|
170
|
+
ActiveRecord::Base.send(:include, ActsAsJoinable) if defined?(ActiveRecord::Base)
|
171
|
+
|
172
|
+
Dir["#{File.dirname(__FILE__)}/../app/models/*"].each { |c| require c if File.extname(c) == ".rb" }
|
data/rails/init.rb
ADDED
@@ -0,0 +1,33 @@
|
|
1
|
+
begin
|
2
|
+
ActiveRecord::Base.establish_connection(:adapter => "sqlite3", :database => ":memory:")
|
3
|
+
rescue ArgumentError
|
4
|
+
ActiveRecord::Base.establish_connection(:adapter => "sqlite3", :dbfile => ":memory:")
|
5
|
+
end
|
6
|
+
|
7
|
+
ActiveRecord::Base.configurations = true
|
8
|
+
|
9
|
+
ActiveRecord::Schema.define(:version => 1) do
|
10
|
+
|
11
|
+
create_table :posts, :force => true do |t|
|
12
|
+
t.string :title
|
13
|
+
t.timestamps
|
14
|
+
end
|
15
|
+
|
16
|
+
create_table :assets, :force => true do |t|
|
17
|
+
t.string :title
|
18
|
+
t.timestamps
|
19
|
+
end
|
20
|
+
|
21
|
+
create_table :tags, :force => true do |t|
|
22
|
+
t.string :name
|
23
|
+
t.timestamps
|
24
|
+
end
|
25
|
+
|
26
|
+
create_table :relationships do |t|
|
27
|
+
t.references :parent, :polymorphic => true
|
28
|
+
t.references :child, :polymorphic => true
|
29
|
+
t.string :context
|
30
|
+
t.timestamps
|
31
|
+
end
|
32
|
+
|
33
|
+
end
|
data/test/lib/asset.rb
ADDED
data/test/lib/post.rb
ADDED
data/test/lib/tag.rb
ADDED
@@ -0,0 +1,38 @@
|
|
1
|
+
require File.join(File.dirname(__FILE__), 'test_helper')
|
2
|
+
|
3
|
+
class ActsAsJoinableTest < ActiveSupport::TestCase
|
4
|
+
|
5
|
+
context "ActsAsJoinable" do
|
6
|
+
|
7
|
+
setup do
|
8
|
+
create_models(1, 2)
|
9
|
+
end
|
10
|
+
|
11
|
+
should "have 1 of each post, asset, tag to start" do
|
12
|
+
assert_equal 1, Post.count
|
13
|
+
assert_equal 2, Tag.count
|
14
|
+
assert_equal 2, Asset.count
|
15
|
+
end
|
16
|
+
|
17
|
+
context "correct generated methods" do
|
18
|
+
setup do
|
19
|
+
@post = Post.first
|
20
|
+
@asset = Asset.first
|
21
|
+
@tag = Tag.first
|
22
|
+
end
|
23
|
+
|
24
|
+
should "Post should respond_to?(:tags) and respond_to?(:assets)" do
|
25
|
+
puts @post.tags.inspect + " !!"
|
26
|
+
assert @post.respond_to?(:tags)
|
27
|
+
assert @post.respond_to?(:assets)
|
28
|
+
end
|
29
|
+
end
|
30
|
+
|
31
|
+
teardown do
|
32
|
+
Post.detonate
|
33
|
+
Tag.detonate
|
34
|
+
Asset.detonate
|
35
|
+
end
|
36
|
+
|
37
|
+
end
|
38
|
+
end
|
data/test/test_helper.rb
ADDED
@@ -0,0 +1,38 @@
|
|
1
|
+
require "rubygems"
|
2
|
+
require "ruby-debug"
|
3
|
+
gem 'test-unit'
|
4
|
+
require "test/unit"
|
5
|
+
require 'active_support'
|
6
|
+
require 'active_support/test_case'
|
7
|
+
require 'active_record'
|
8
|
+
require 'active_record/fixtures'
|
9
|
+
require 'shoulda'
|
10
|
+
require 'shoulda/active_record'
|
11
|
+
|
12
|
+
this = File.expand_path(File.dirname(__FILE__))
|
13
|
+
|
14
|
+
require File.expand_path(File.join(this, '/../lib/acts-as-joinable'))
|
15
|
+
|
16
|
+
ActsAsJoinable.models = Dir["#{this}/../app/models/*.rb"].collect { |f| File.basename f, '.rb' }
|
17
|
+
|
18
|
+
Dir["#{this}/lib/*"].each { |c| require c if File.extname(c) == ".rb" }
|
19
|
+
|
20
|
+
ActiveRecord::Base.class_eval do
|
21
|
+
def self.detonate
|
22
|
+
all.map(&:destroy)
|
23
|
+
end
|
24
|
+
end
|
25
|
+
|
26
|
+
ActiveSupport::TestCase.class_eval do
|
27
|
+
|
28
|
+
def create_models(parent = 1, child = 2)
|
29
|
+
parent.times do |i|
|
30
|
+
post = Post.create!(:title => "title-#{i.to_s}")
|
31
|
+
child.times do |j|
|
32
|
+
position = (i + 1) * (j + 1)
|
33
|
+
asset = Asset.create!(:title => "asset-title-#{position.to_s}")
|
34
|
+
tag = Tag.create!(:name => "tag-name-#{position.to_s}")
|
35
|
+
end
|
36
|
+
end
|
37
|
+
end
|
38
|
+
end
|
metadata
ADDED
@@ -0,0 +1,79 @@
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
2
|
+
name: acts-as-joinable
|
3
|
+
version: !ruby/object:Gem::Version
|
4
|
+
hash: 65
|
5
|
+
prerelease: false
|
6
|
+
segments:
|
7
|
+
- 0
|
8
|
+
- 0
|
9
|
+
- 1
|
10
|
+
- 5
|
11
|
+
version: 0.0.1.5
|
12
|
+
platform: ruby
|
13
|
+
authors:
|
14
|
+
- Lance Pollard
|
15
|
+
autorequire:
|
16
|
+
bindir: bin
|
17
|
+
cert_chain: []
|
18
|
+
|
19
|
+
date: 2010-06-20 00:00:00 -07:00
|
20
|
+
default_executable:
|
21
|
+
dependencies: []
|
22
|
+
|
23
|
+
description: DRYing up Many-to-Many Relationships in ActiveRecord
|
24
|
+
email: lancejpollard@gmail.com
|
25
|
+
executables: []
|
26
|
+
|
27
|
+
extensions: []
|
28
|
+
|
29
|
+
extra_rdoc_files: []
|
30
|
+
|
31
|
+
files:
|
32
|
+
- README.markdown
|
33
|
+
- Rakefile
|
34
|
+
- init.rb
|
35
|
+
- MIT-LICENSE
|
36
|
+
- lib/acts-as-joinable.rb
|
37
|
+
- rails/init.rb
|
38
|
+
- test/lib/_database.rb
|
39
|
+
- test/lib/asset.rb
|
40
|
+
- test/lib/post.rb
|
41
|
+
- test/lib/tag.rb
|
42
|
+
- test/test_acts_as_joinable.rb
|
43
|
+
- test/test_helper.rb
|
44
|
+
has_rdoc: true
|
45
|
+
homepage: http://github.com/viatropos/cockpit
|
46
|
+
licenses: []
|
47
|
+
|
48
|
+
post_install_message:
|
49
|
+
rdoc_options: []
|
50
|
+
|
51
|
+
require_paths:
|
52
|
+
- lib
|
53
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
54
|
+
none: false
|
55
|
+
requirements:
|
56
|
+
- - ">="
|
57
|
+
- !ruby/object:Gem::Version
|
58
|
+
hash: 3
|
59
|
+
segments:
|
60
|
+
- 0
|
61
|
+
version: "0"
|
62
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
63
|
+
none: false
|
64
|
+
requirements:
|
65
|
+
- - ">="
|
66
|
+
- !ruby/object:Gem::Version
|
67
|
+
hash: 3
|
68
|
+
segments:
|
69
|
+
- 0
|
70
|
+
version: "0"
|
71
|
+
requirements: []
|
72
|
+
|
73
|
+
rubyforge_project: acts-as-joinable
|
74
|
+
rubygems_version: 1.3.7
|
75
|
+
signing_key:
|
76
|
+
specification_version: 3
|
77
|
+
summary: "ActsAsJoinable: DRYing up Many-to-Many Relationships in ActiveRecord"
|
78
|
+
test_files: []
|
79
|
+
|