Skip to content

Commit 5853cc4

Browse files
committed
Add polymorphic association support
1 parent 4097394 commit 5853cc4

9 files changed

Lines changed: 342 additions & 13 deletions

File tree

README.md

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -238,6 +238,46 @@ FixtureBot.define do
238238
end
239239
```
240240

241+
### Polymorphic associations
242+
243+
Reference records using Rails polymorphic associations:
244+
245+
```ruby
246+
FixtureBot.define do
247+
post :hello_world do
248+
title "Hello World"
249+
author :brad
250+
end
251+
252+
user :alice do
253+
name "Alice"
254+
end
255+
256+
vote :like do
257+
user :alice
258+
votable post(:hello_world) # sets votable_id and votable_type: Post
259+
end
260+
end
261+
```
262+
263+
The `votable post(:hello_world)` syntax sets both `votable_id` (to the post's stable ID) and `votable_type` (to `"Post"`).
264+
265+
FixtureBot auto-detects polymorphic associations from your schema columns. Just include both `_id` and `_type` columns:
266+
267+
```ruby
268+
FixtureBot::Schema.define do
269+
table :posts, singular: :post, columns: [:title, :author_id] do
270+
belongs_to :author, table: :users
271+
end
272+
273+
table :votes, singular: :vote, columns: [:user_id, :votable_id, :votable_type] do
274+
belongs_to :user, table: :users
275+
end
276+
end
277+
```
278+
279+
In Rails, FixtureBot auto-detects polymorphic columns (`_id` + `_type` pairs) from your database schema automatically.
280+
241281
### Implicit vs explicit style
242282

243283
By default, the block is evaluated implicitly. Table methods like `user` and `post` are available directly:

lib/fixturebot/definition.rb

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,8 +21,10 @@ def define_table_method(table)
2121
define_singleton_method(table.singular_name) do |record_name = nil, &block|
2222
if record_name.nil? && block.nil?
2323
Default::Definition.new(table, @defaults[table.name])
24-
elsif record_name
24+
elsif record_name && block
2525
add_row(table, record_name, block)
26+
elsif record_name
27+
add_row(table, record_name, nil)
2628
else
2729
raise ArgumentError, "#{table.singular_name} requires a record name or no arguments"
2830
end
@@ -37,7 +39,8 @@ def add_row(table, record_name, block)
3739
name: record_name,
3840
literal_values: row_def.literal_values,
3941
association_refs: row_def.association_refs,
40-
tag_refs: row_def.tag_refs
42+
tag_refs: row_def.tag_refs,
43+
polymorphic_refs: row_def.polymorphic_refs
4144
)
4245
end
4346
end

lib/fixturebot/rails/schema_loader.rb

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,22 +42,46 @@ def build_table(name)
4242
.reject { |c| framework_column?(c.name) }
4343
.map { |c| c.name.to_sym }
4444

45+
column_names = columns.map(&:to_s)
46+
polymorphic_pairs = detect_polymorphic_columns(column_names)
47+
4548
associations = @connection.foreign_keys(name).map do |fk|
49+
next if polymorphic_pairs.key?(fk.column.to_s)
50+
4651
Schema::BelongsTo.new(
4752
name: association_name(fk.column),
4853
table: fk.to_table.to_sym,
4954
foreign_key: fk.column.to_sym
5055
)
56+
end.compact
57+
58+
polymorphic_associations = polymorphic_pairs.map do |id_col, type_col|
59+
Schema::PolymorphicBelongsTo.new(
60+
name: association_name(id_col),
61+
foreign_key: id_col.to_sym,
62+
type_column: type_col.to_sym
63+
)
5164
end
5265

5366
Schema::Table.new(
5467
name: name.to_sym,
5568
singular_name: singularize(name),
5669
columns: columns,
57-
belongs_to_associations: associations
70+
belongs_to_associations: associations,
71+
polymorphic_belongs_to_associations: polymorphic_associations
5872
)
5973
end
6074

75+
def detect_polymorphic_columns(column_names)
76+
pairs = {}
77+
id_columns = column_names.select { |c| c.end_with?("_id") }
78+
id_columns.each do |id_col|
79+
type_col = id_col.sub(/_id$/, "_type")
80+
pairs[id_col] = type_col if column_names.include?(type_col)
81+
end
82+
pairs
83+
end
84+
6185
def build_join_table(name)
6286
fk_columns = foreign_key_columns(name)
6387

lib/fixturebot/row.rb

Lines changed: 43 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,19 +2,33 @@
22

33
module FixtureBot
44
module Row
5-
Declaration = Data.define(:table, :name, :literal_values, :association_refs, :tag_refs)
5+
TableReference = Data.define(:table_name, :record_name)
6+
Declaration = Data.define(:table, :name, :literal_values, :association_refs, :tag_refs, :polymorphic_refs)
67

78
class Definition
8-
attr_reader :literal_values, :association_refs, :tag_refs
9+
attr_reader :literal_values, :association_refs, :tag_refs, :polymorphic_refs
910

1011
def initialize(table, schema)
1112
@literal_values = {}
1213
@association_refs = {}
1314
@tag_refs = {}
15+
@polymorphic_refs = {}
1416

1517
define_column_methods(table)
1618
define_association_methods(table)
19+
define_polymorphic_methods(table)
1720
define_join_table_methods(table, schema)
21+
define_table_reference_methods(schema, table)
22+
end
23+
24+
def define_table_reference_methods(schema, table)
25+
schema.tables.each_value do |tbl|
26+
next if table.belongs_to_associations.any? { |a| a.name == tbl.singular_name }
27+
next if table.polymorphic_belongs_to_associations.any? { |a| a.name == tbl.singular_name }
28+
define_singleton_method(tbl.singular_name) do |record_name|
29+
TableReference.new(table_name: tbl.name, record_name: record_name)
30+
end
31+
end
1832
end
1933

2034
private
@@ -35,6 +49,14 @@ def define_association_methods(table)
3549
end
3650
end
3751

52+
def define_polymorphic_methods(table)
53+
table.polymorphic_belongs_to_associations.each do |assoc|
54+
define_singleton_method(assoc.name) do |table_ref|
55+
@polymorphic_refs[assoc.name] = table_ref
56+
end
57+
end
58+
end
59+
3860
def define_join_table_methods(table, schema)
3961
schema.join_tables.each_value do |jt|
4062
if jt.left_table == table.name
@@ -69,6 +91,8 @@ def record
6991
result[col] = @row.literal_values[col]
7092
elsif foreign_key_values.key?(col)
7193
result[col] = foreign_key_values[col]
94+
elsif polymorphic_values.key?(col)
95+
result[col] = polymorphic_values[col]
7296
elsif defaulted_values.key?(col)
7397
result[col] = defaulted_values[col]
7498
end
@@ -112,10 +136,27 @@ def foreign_key_values
112136
end
113137
end
114138

139+
def polymorphic_values
140+
@polymorphic_values ||= @row.polymorphic_refs.each_with_object({}) do |(assoc_name, table_ref), hash|
141+
assoc = @table.polymorphic_belongs_to_associations.find { |a| a.name == assoc_name }
142+
hash[assoc.foreign_key] = Key.generate(table_ref.table_name, table_ref.record_name)
143+
hash[assoc.type_column] = classify(table_ref.table_name)
144+
end
145+
end
146+
147+
def classify(table_name)
148+
if defined?(ActiveSupport::Inflector)
149+
ActiveSupport::Inflector.classify(table_name.to_s)
150+
else
151+
table_name.to_s.sub(/s$/, "").capitalize
152+
end
153+
end
154+
115155
def defaulted_values
116156
@defaulted_values ||= @defaults.each_with_object({}) do |(col, block), result|
117157
next if @row.literal_values.key?(col)
118158
next if foreign_key_values.key?(col)
159+
next if polymorphic_values.key?(col)
119160

120161
fixture = Default::Fixture.new(key: @row.name)
121162
context = Default::Context.new(literal_values: @row.literal_values)

lib/fixturebot/schema.rb

Lines changed: 39 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,9 @@
22

33
module FixtureBot
44
class Schema
5-
Table = Data.define(:name, :singular_name, :columns, :belongs_to_associations)
5+
Table = Data.define(:name, :singular_name, :columns, :belongs_to_associations, :polymorphic_belongs_to_associations)
66
BelongsTo = Data.define(:name, :table, :foreign_key)
7+
PolymorphicBelongsTo = Data.define(:name, :foreign_key, :type_column)
78
JoinTable = Data.define(:name, :left_table, :right_table, :left_foreign_key, :right_foreign_key)
89

910
attr_reader :tables, :join_tables
@@ -35,15 +36,33 @@ def initialize(schema)
3536

3637
def table(name, singular:, columns: [], &block)
3738
associations = []
39+
polymorphic_associations = []
3840
if block
39-
table_builder = TableBuilder.new(associations)
41+
table_builder = TableBuilder.new(associations, polymorphic_associations, columns)
4042
table_builder.instance_eval(&block)
4143
end
44+
45+
columns_set = columns.to_set
46+
columns_set.grep(/_id$/).each do |id_col|
47+
type_col = id_col.to_s.sub(/_id$/, "_type").to_sym
48+
if columns_set.include?(type_col)
49+
assoc_name = id_col.to_s.sub(/_id$/, "").to_sym
50+
next if associations.any? { |a| a.name == assoc_name }
51+
next if polymorphic_associations.any? { |a| a.name == assoc_name }
52+
polymorphic_associations << PolymorphicBelongsTo.new(
53+
name: assoc_name,
54+
foreign_key: id_col,
55+
type_column: type_col
56+
)
57+
end
58+
end
59+
4260
@schema.add_table(Table.new(
4361
name: name,
4462
singular_name: singular,
4563
columns: columns,
46-
belongs_to_associations: associations
64+
belongs_to_associations: associations,
65+
polymorphic_belongs_to_associations: polymorphic_associations
4766
))
4867
end
4968

@@ -61,13 +80,27 @@ def join_table(name, left_table, right_table)
6180
end
6281

6382
class TableBuilder
64-
def initialize(associations)
83+
def initialize(associations, polymorphic_associations, columns)
6584
@associations = associations
85+
@polymorphic_associations = polymorphic_associations
86+
@columns = columns
6687
end
6788

68-
def belongs_to(name, table:)
89+
def belongs_to(name, table: nil)
6990
foreign_key = :"#{name}_id"
70-
@associations << BelongsTo.new(name: name, table: table, foreign_key: foreign_key)
91+
type_column = :"#{name}_type"
92+
93+
if table
94+
@associations << BelongsTo.new(name: name, table: table, foreign_key: foreign_key)
95+
elsif @columns.include?(foreign_key) && @columns.include?(type_column)
96+
@polymorphic_associations << PolymorphicBelongsTo.new(
97+
name: name,
98+
foreign_key: foreign_key,
99+
type_column: type_column
100+
)
101+
else
102+
raise ArgumentError, "belongs_to :#{name} requires table: option"
103+
end
71104
end
72105
end
73106
end

playground/blog/fixtures.rb

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,4 +51,19 @@
5151
post :tdd_guide
5252
author :brad
5353
end
54+
55+
vote :hello_like do
56+
user :alice
57+
votable post(:hello_world)
58+
end
59+
60+
vote :hello_upvote do
61+
user :brad
62+
votable post(:hello_world)
63+
end
64+
65+
vote :tdd_upvote do
66+
user :charlie
67+
votable post(:tdd_guide)
68+
end
5469
end

playground/blog/schema.rb

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,10 @@
1010
belongs_to :author, table: :users
1111
end
1212

13+
table :votes, singular: :vote, columns: [:user_id, :votable_id, :votable_type] do
14+
belongs_to :user, table: :users
15+
end
16+
1317
table :tags, singular: :tag, columns: [:name]
1418

1519
join_table :posts_tags, :posts, :tags

spec/fixturebot/schema_loader_spec.rb

Lines changed: 40 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,22 @@
3131
t.integer "post_id", null: false
3232
t.integer "tag_id", null: false
3333
end
34+
35+
create_table "votes", force: :cascade do |t|
36+
t.integer "user_id"
37+
t.integer "votable_id"
38+
t.string "votable_type"
39+
t.timestamps
40+
end
41+
42+
create_table "comments", force: :cascade do |t|
43+
t.text "body"
44+
t.integer "author_id"
45+
t.string "author_type"
46+
t.integer "parent_id"
47+
t.string "parent_type"
48+
t.timestamps
49+
end
3450
end
3551
end
3652

@@ -41,7 +57,7 @@
4157
subject(:schema) { described_class.load }
4258

4359
it "loads regular tables with columns" do
44-
expect(schema.tables.keys).to contain_exactly(:users, :posts, :tags)
60+
expect(schema.tables.keys).to contain_exactly(:users, :posts, :tags, :votes, :comments)
4561
end
4662

4763
it "skips id, created_at, updated_at columns" do
@@ -70,4 +86,27 @@
7086
it "does not include join tables in regular tables" do
7187
expect(schema.tables).not_to have_key(:posts_tags)
7288
end
89+
90+
it "detects polymorphic associations from _id and _type columns" do
91+
polymorphic_assocs = schema.tables[:votes].polymorphic_belongs_to_associations
92+
expect(polymorphic_assocs.size).to eq(1)
93+
94+
votable = polymorphic_assocs.first
95+
expect(votable.name).to eq(:votable)
96+
expect(votable.foreign_key).to eq(:votable_id)
97+
expect(votable.type_column).to eq(:votable_type)
98+
end
99+
100+
it "excludes polymorphic foreign keys from belongs_to associations" do
101+
belongs_to = schema.tables[:votes].belongs_to_associations
102+
expect(belongs_to).to be_empty
103+
end
104+
105+
it "detects multiple polymorphic associations" do
106+
polymorphic_assocs = schema.tables[:comments].polymorphic_belongs_to_associations
107+
expect(polymorphic_assocs.size).to eq(2)
108+
109+
names = polymorphic_assocs.map(&:name)
110+
expect(names).to include(:author, :parent)
111+
end
73112
end

0 commit comments

Comments
 (0)