acts-as-joinable 0.0.1.5
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.
- 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
|
+
|