fixturebot-rails 0.2.0 → 0.4.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 47f7ad238dac229d37e2bdef254074b14d31657e57775db19f47cbddc06ffe16
4
- data.tar.gz: e8c506013cc891b6f2f2d93e05d044c53ee5687ffa7cb628a7bf472f34fa4056
3
+ metadata.gz: 8c83c05f009409eb651e9e464fcc70bad3b3abeb377943eef42bfff31b29f9ac
4
+ data.tar.gz: 79973f2828cb2dcb83c4f238ad1023681c0a241d0f7ca1daab13ad1627685f64
5
5
  SHA512:
6
- metadata.gz: f801adcf5885114ba66a13a9d7d472b908c9b6ed7de49426c007a70efced6293ba12683253b9cbe7bb38cceaf97902f2642acea3e44ec8522d74da21a57038bb
7
- data.tar.gz: c6972d1aed753deed6d109126302ac2267cf99dddb4e5ff45f981bb63753831292e5a44f2d743f11a5a9680f9b088152234ccef8184f1c07ecb0b33de9ee0dfa
6
+ metadata.gz: b3c5cfd9eeb975bb9209a2a3e37ca98d7a00225b6c63f638c3f4bdc2a44ff2782eebfcc4616a327d1134d3bcf3dcbbc0a5a661918ab1f4b6fefdffc4ebefed96
7
+ data.tar.gz: 3ea33f5090363d4ee7c8be75720e7fa1a8364c7665304824071d08fd2e5912be7f0d293f9255c7c69f61a0bafc9c06d9ca600954e33d6adb321dba1a748b10eb
data/CHANGELOG.md CHANGED
@@ -1,5 +1,24 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.3.0
4
+
5
+ ### Added
6
+
7
+ - **UUID primary keys.** Tables with UUID primary keys are auto-detected in Rails. For manual schemas, pass `key: FixtureBot::Key::Uuid`. Generates deterministic UUID v5 values.
8
+ - **Polymorphic associations.** Use `votable post(:hello)` to set both `votable_id` and `votable_type: "Post"`. Auto-detected in Rails from `_id`/`_type` column pairs without foreign key constraints. For manual schemas, declare with `polymorphic :votable`.
9
+ - **Custom key strategies.** Any module or object responding to `generate(table_name, record_name)` can be passed as `key:` on a table definition.
10
+ - **Custom primary key columns.** Tables using a column other than `id` as the primary key are auto-detected in Rails. For manual schemas, pass `primary_key: :uid`.
11
+ - **Hardcoded IDs.** Set `id 42` in a record block to override the generated key. Foreign key references resolve to the hardcoded value.
12
+
13
+ ### Internals
14
+
15
+ - Key generation strategies (`Key::Integer`, `Key::Uuid`) are stateless modules instead of a single `Key.generate` method.
16
+ - All association types share a unified `resolve(ref, keys)` interface.
17
+ - `KeyRegistry` pre-computes primary key values for all records.
18
+ - `Default::Generator` wraps generator blocks instead of passing raw procs.
19
+ - Foreign key naming conventions (`_id`, `_type`) live as class methods on `BelongsTo` and `PolymorphicBelongsTo`.
20
+ - File-based `define` uses thread-local state instead of module-level `@pending_blocks`.
21
+
3
22
  ## 0.2.0
4
23
 
5
24
  ### Breaking changes
data/README.md CHANGED
@@ -9,6 +9,8 @@ FixtureBot lets you define your test data in a Ruby DSL and compiles it into sta
9
9
  - **Ruby DSL** for defining records, associations, and join tables
10
10
  - **Generators** for filling in required columns (like email) across all records
11
11
  - **Stable IDs** so foreign keys are consistent and diffs are clean across runs
12
+ - **UUID support** for tables with UUID primary keys
13
+ - **Polymorphic associations** with automatic type column resolution
12
14
  - **Schema auto-detection** from your Rails database (no manual column lists)
13
15
  - **Auto-generates** before your test suite runs (RSpec and Minitest)
14
16
 
@@ -175,7 +177,13 @@ FixtureBot.define do
175
177
  end
176
178
  ```
177
179
 
178
- The generator block receives a `fixture` object as a block parameter with access to `fixture.key` (the record's symbol name). Bare methods inside the block refer to column values set on the record.
180
+ The generator block receives a `fixture` object with a `key` method (the record's symbol name). Bare methods inside the block refer to column values set on the record:
181
+
182
+ ```ruby
183
+ FixtureBot.define do
184
+ user.display_name { |fixture| "#{fixture.key} (#{email})" }
185
+ end
186
+ ```
179
187
 
180
188
  When a generator covers all the columns you need, records don't need a block at all:
181
189
 
@@ -225,6 +233,46 @@ FixtureBot.define do
225
233
  end
226
234
  ```
227
235
 
236
+ ### Polymorphic associations
237
+
238
+ For polymorphic `belongs_to`, reference the target using its table helper to set both `_id` and `_type` columns:
239
+
240
+ ```ruby
241
+ FixtureBot.define do
242
+ post :hello do
243
+ title "Hello"
244
+ end
245
+
246
+ comment :nice do
247
+ body "Nice post"
248
+ end
249
+
250
+ vote :upvote_post do
251
+ votable post(:hello) # sets votable_id and votable_type: "Post"
252
+ end
253
+
254
+ vote :upvote_comment do
255
+ votable comment(:nice) # sets votable_id and votable_type: "Comment"
256
+ end
257
+ end
258
+ ```
259
+
260
+ In the manual schema, declare polymorphic associations with `polymorphic`:
261
+
262
+ ```ruby
263
+ FixtureBot::Schema.define do
264
+ table :posts, singular: :post, columns: [:title]
265
+ table :comments, singular: :comment, columns: [:body]
266
+
267
+ table :votes, singular: :vote, columns: [:votable_id, :votable_type, :voter_id] do
268
+ polymorphic :votable
269
+ belongs_to :voter, table: :users
270
+ end
271
+ end
272
+ ```
273
+
274
+ In Rails, polymorphic associations are auto-detected from `_id`/`_type` column pairs that don't have a foreign key constraint.
275
+
228
276
  ### Join tables (HABTM)
229
277
 
230
278
  Reference multiple records for join table associations:
@@ -238,6 +286,100 @@ FixtureBot.define do
238
286
  end
239
287
  ```
240
288
 
289
+ ### Hardcoded IDs
290
+
291
+ By default, FixtureBot generates stable IDs deterministically from the table and record name. You can override this with an explicit value:
292
+
293
+ ```ruby
294
+ FixtureBot.define do
295
+ user :admin do
296
+ id 42
297
+ name "Admin"
298
+ end
299
+
300
+ post :hello do
301
+ title "Hello"
302
+ author :admin # author_id resolves to 42
303
+ end
304
+ end
305
+ ```
306
+
307
+ ### UUID primary keys
308
+
309
+ Tables with UUID primary keys work automatically in Rails (detected from the column type). In the manual schema, pass `key: FixtureBot::Key::Uuid`:
310
+
311
+ ```ruby
312
+ FixtureBot::Schema.define do
313
+ table :projects, singular: :project, columns: [:name], key: FixtureBot::Key::Uuid
314
+ end
315
+ ```
316
+
317
+ FixtureBot generates deterministic UUID v5 values, so the output is stable across runs.
318
+
319
+ You can also provide your own key strategy — any object (or module) that responds to `generate(table_name, record_name)`. For example, Stripe-style prefixed IDs:
320
+
321
+ ```ruby
322
+ module PrefixedKey
323
+ module_function
324
+
325
+ def generate(table_name, record_name)
326
+ hash = Zlib.crc32("#{table_name}:#{record_name}").to_s(36)
327
+ "#{table_name.to_s.chomp('s')}_#{hash}"
328
+ end
329
+ end
330
+
331
+ FixtureBot::Schema.define do
332
+ table :customers, singular: :customer, columns: [:name], key: PrefixedKey
333
+ # => customer_id: "customer_1a2b3c"
334
+ end
335
+ ```
336
+
337
+ ### Custom primary key column
338
+
339
+ If your table uses a column other than `id` as the primary key:
340
+
341
+ ```ruby
342
+ FixtureBot::Schema.define do
343
+ table :users, singular: :user, columns: [:name], primary_key: :uid
344
+ end
345
+ ```
346
+
347
+ In Rails, this is auto-detected from the database.
348
+
349
+ ### Multiple files
350
+
351
+ For larger apps, split fixtures across multiple files using `FixtureBot.require`:
352
+
353
+ ```ruby
354
+ # spec/fixtures.rb
355
+ FixtureBot.require "spec/fixtures/**/*.rb"
356
+
357
+ FixtureBot.define do
358
+ user.email { |fixture| "#{fixture.key}@example.com" }
359
+ end
360
+ ```
361
+
362
+ ```ruby
363
+ # spec/fixtures/users.rb
364
+ FixtureBot.define do
365
+ user :brad do
366
+ name "Brad"
367
+ end
368
+ end
369
+ ```
370
+
371
+ ```ruby
372
+ # spec/fixtures/posts.rb
373
+ FixtureBot.define do
374
+ post :hello do
375
+ title "Hello"
376
+ author :brad
377
+ end
378
+ end
379
+ ```
380
+
381
+ Each file calls `FixtureBot.define` with its own block. Files are loaded in alphabetical order. References across files work because everything is resolved after all files are loaded.
382
+
241
383
  ### Implicit vs explicit style
242
384
 
243
385
  By default, the block is evaluated implicitly. Table methods like `user` and `post` are available directly:
@@ -280,6 +422,40 @@ FixtureBot::Schema.define do
280
422
  end
281
423
  ```
282
424
 
425
+ ## Migrating from FactoryBot
426
+
427
+ FixtureBot provides `build`, `create`, `attributes_for`, and related methods that mirror FactoryBot's API. The key difference is that you pass both a table name and a fixture name instead of just a factory name:
428
+
429
+ ```ruby
430
+ # FactoryBot # FixtureBot
431
+ build(:user) # build(:user, :brad)
432
+ create(:user) # create(:user, :brad)
433
+ build(:user, name: "X") # build(:user, :brad, name: "X")
434
+ attributes_for(:user) # attributes_for(:user, :brad)
435
+ build_list(:user, 3) # build_list(:user, :brad, :alice, :bob)
436
+ create_list(:user, 3) # create_list(:user, :brad, :alice, :bob)
437
+ build_pair(:user) # build_pair(:user, :brad, :alice)
438
+ create_pair(:user) # create_pair(:user, :brad, :alice)
439
+ build_stubbed(:user) # build_stubbed(:user, :brad)
440
+ ```
441
+
442
+ ### Method reference
443
+
444
+ | Method | Behavior |
445
+ |---|---|
446
+ | `build(:user, :brad, **attrs)` | Duplicates the fixture, applies overrides. Returns unpersisted. |
447
+ | `create(:user, :brad, **attrs)` | Without overrides: returns the fixture (already persisted). With overrides: `build` + `save!`. |
448
+ | `build_stubbed(:user, :brad, **attrs)` | Like `build` but retains `id`. Looks persisted without touching DB. |
449
+ | `attributes_for(:user, :brad, **attrs)` | Returns attributes hash, strips `id`/`created_at`/`updated_at`. |
450
+ | `build_list(:user, :brad, :alice, **attrs)` | Maps each name through `build`. |
451
+ | `create_list(:user, :brad, :alice, **attrs)` | Maps each name through `create`. |
452
+ | `build_pair(:user, :brad, :alice, **attrs)` | Alias for `build_list` with 2 names. |
453
+ | `create_pair(:user, :brad, :alice, **attrs)` | Alias for `create_list` with 2 names. |
454
+ | `build_stubbed_list(:user, :brad, :alice, **attrs)` | Maps each name through `build_stubbed`. |
455
+ | `build_stubbed_pair(:user, :brad, :alice, **attrs)` | Alias for `build_stubbed_list` with 2 names. |
456
+
457
+ These methods are automatically available in your tests when you require `fixturebot/rspec` or `fixturebot/minitest`. They call the standard Rails fixture accessors under the hood, so `build(:user, :brad)` is equivalent to `users(:brad).dup`.
458
+
283
459
  ## Prior art
284
460
 
285
461
  ### [Rails fixtures](https://guides.rubyonrails.org/testing.html#the-low-down-on-fixtures)
@@ -10,14 +10,7 @@ module FixtureBot
10
10
 
11
11
  desc "compile DIR", "Compile DIR/schema.rb and DIR/fixtures.rb to YAML fixture files"
12
12
  def compile(dir)
13
- schema_path = File.join(dir, "schema.rb")
14
- fixtures_path = File.join(dir, "fixtures.rb")
15
-
16
- raise Thor::Error, "Schema file not found: #{schema_path}" unless File.exist?(schema_path)
17
- raise Thor::Error, "Fixtures file not found: #{fixtures_path}" unless File.exist?(fixtures_path)
18
-
19
- schema = eval(File.read(schema_path), binding, schema_path, 1)
20
- fixture_set = FixtureBot.define_from_file(schema, fixtures_path)
13
+ fixture_set = load_fixture_set(dir)
21
14
 
22
15
  output_dir = File.join(dir, "fixtures")
23
16
  Compiler.new(fixture_set).compile(output_dir)
@@ -31,15 +24,8 @@ module FixtureBot
31
24
 
32
25
  desc "show DIR", "Compile DIR/schema.rb and DIR/fixtures.rb, then print YAML to stdout"
33
26
  def show(dir)
34
- schema_path = File.join(dir, "schema.rb")
35
- fixtures_path = File.join(dir, "fixtures.rb")
36
-
37
- raise Thor::Error, "Schema file not found: #{schema_path}" unless File.exist?(schema_path)
38
- raise Thor::Error, "Fixtures file not found: #{fixtures_path}" unless File.exist?(fixtures_path)
39
-
40
- schema = eval(File.read(schema_path), binding, schema_path, 1)
41
- fixture_set = FixtureBot.define_from_file(schema, fixtures_path)
42
- compiler = FixtureBot::Compiler.new(fixture_set)
27
+ fixture_set = load_fixture_set(dir)
28
+ compiler = Compiler.new(fixture_set)
43
29
 
44
30
  fixture_set.tables.each do |table_name, records|
45
31
  next if records.empty?
@@ -53,5 +39,18 @@ module FixtureBot
53
39
  def version
54
40
  say "fixturebot #{FixtureBot::VERSION}"
55
41
  end
42
+
43
+ private
44
+
45
+ def load_fixture_set(dir)
46
+ schema_path = File.join(dir, "schema.rb")
47
+ fixtures_path = File.join(dir, "fixtures.rb")
48
+
49
+ raise Thor::Error, "Schema file not found: #{schema_path}" unless File.exist?(schema_path)
50
+ raise Thor::Error, "Fixtures file not found: #{fixtures_path}" unless File.exist?(fixtures_path)
51
+
52
+ schema = eval(File.read(schema_path), binding, schema_path, 1)
53
+ FixtureBot.define_from_file(schema, fixtures_path)
54
+ end
56
55
  end
57
56
  end
@@ -2,8 +2,6 @@
2
2
 
3
3
  module FixtureBot
4
4
  module Default
5
- Fixture = Data.define(:key)
6
-
7
5
  class Definition
8
6
  def initialize(table, defaults)
9
7
  @defaults = defaults
@@ -16,22 +14,39 @@ module FixtureBot
16
14
  table.columns.each do |col|
17
15
  define_singleton_method(col) do |&block|
18
16
  raise ArgumentError, "#{col} requires a block" unless block
19
- @defaults[col] = block
17
+ @defaults[col] = Generator.new(block)
20
18
  end
21
19
  end
22
20
  end
23
21
  end
24
22
 
25
- class Context
26
- def initialize(literal_values: {})
27
- define_literal_value_methods(literal_values)
23
+ Fixture = Data.define(:key)
24
+
25
+ class Generator
26
+ def initialize(block)
27
+ @block = block
28
+ end
29
+
30
+ def generate(record_name, literal_values)
31
+ context = Context.new(literal_values)
32
+ context.instance_exec(Fixture.new(key: record_name), &@block)
28
33
  end
29
34
 
30
35
  private
31
36
 
32
- def define_literal_value_methods(literal_values)
33
- literal_values.each do |col, val|
34
- define_singleton_method(col) { val }
37
+ Context = Struct.new(:literal_values) do
38
+ private
39
+
40
+ def method_missing(name, *)
41
+ if literal_values.key?(name)
42
+ literal_values[name]
43
+ else
44
+ super
45
+ end
46
+ end
47
+
48
+ def respond_to_missing?(name, *)
49
+ literal_values.key?(name) || super
35
50
  end
36
51
  end
37
52
  end
@@ -33,7 +33,7 @@ module FixtureBot
33
33
  row_def = Row::Definition.new(table, @schema)
34
34
  row_def.instance_eval(&block) if block
35
35
  @rows << Row::Declaration.new(
36
- table: table.name,
36
+ table_name: table.name,
37
37
  name: record_name,
38
38
  literal_values: row_def.literal_values,
39
39
  association_refs: row_def.association_refs,
@@ -10,18 +10,21 @@ module FixtureBot
10
10
  schema.tables.each_key { |name| @tables[name] = {} }
11
11
  schema.join_tables.each_key { |name| @tables[name] = {} }
12
12
 
13
+ keys = KeyRegistry.new(definition.rows, schema.tables)
14
+
13
15
  definition.rows.each do |row|
14
16
  builder = Row::Builder.new(
15
17
  row: row,
16
- table: schema.tables[row.table],
17
- defaults: definition.defaults[row.table],
18
- join_tables: schema.join_tables
18
+ table: schema.tables[row.table_name],
19
+ defaults: definition.defaults[row.table_name],
20
+ join_tables: schema.join_tables,
21
+ keys: keys
19
22
  )
20
23
 
21
- @tables[row.table][row.name] = builder.record
24
+ @tables[row.table_name][row.name] = builder.record
22
25
 
23
26
  builder.join_rows.each do |join_row|
24
- @tables[join_row[:join_table]][join_row[:key]] = join_row[:row]
27
+ @tables[join_row.join_table][join_row.key] = join_row.row
25
28
  end
26
29
  end
27
30
  end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "zlib"
4
+
5
+ module FixtureBot
6
+ module Key
7
+ module Integer
8
+ module_function
9
+
10
+ def generate(table_name, record_name)
11
+ Zlib.crc32("#{table_name}:#{record_name}") & 0x7FFFFFFF
12
+ end
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest/sha1"
4
+
5
+ module FixtureBot
6
+ module Key
7
+ module Uuid
8
+ NAMESPACE = "fixturebot"
9
+
10
+ module_function
11
+
12
+ def generate(table_name, record_name)
13
+ digest = Digest::SHA1.digest("#{NAMESPACE}:#{table_name}:#{record_name}")
14
+ # Set version 5 (bits 4-7 of byte 6)
15
+ digest[6] = ((digest[6].ord & 0x0f) | 0x50).chr
16
+ # Set variant (bits 6-7 of byte 8)
17
+ digest[8] = ((digest[8].ord & 0x3f) | 0x80).chr
18
+
19
+ hex = digest[0, 16].unpack1("H*")
20
+ "#{hex[0, 8]}-#{hex[8, 4]}-#{hex[12, 4]}-#{hex[16, 4]}-#{hex[20, 12]}"
21
+ end
22
+ end
23
+ end
24
+ end
@@ -1,11 +1,13 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "zlib"
3
+ require_relative "key/integer"
4
+ require_relative "key/uuid"
4
5
 
5
6
  module FixtureBot
6
7
  module Key
8
+ # Backward compatibility
7
9
  def self.generate(table_name, record_name)
8
- Zlib.crc32("#{table_name}:#{record_name}") & 0x7FFFFFFF
10
+ Integer.generate(table_name, record_name)
9
11
  end
10
12
  end
11
13
  end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FixtureBot
4
+ class KeyRegistry
5
+ def initialize(rows, schema_tables)
6
+ @values = {}
7
+
8
+ rows.each do |row|
9
+ table = schema_tables[row.table_name]
10
+ pk_col = table.primary_key
11
+ @values[[row.table_name, row.name]] = if row.literal_values.key?(pk_col)
12
+ row.literal_values[pk_col]
13
+ else
14
+ table.key.generate(row.table_name, row.name)
15
+ end
16
+ end
17
+ end
18
+
19
+ def resolve(table_name, record_name)
20
+ @values.fetch([table_name, record_name])
21
+ end
22
+ end
23
+ end
@@ -1,5 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "fixturebot/rails"
4
+ require "fixturebot/syntax"
4
5
 
5
6
  FixtureBot::Rails.compile
7
+
8
+ ActiveSupport::TestCase.include FixtureBot::Syntax::Methods
@@ -38,11 +38,15 @@ module FixtureBot
38
38
  end
39
39
 
40
40
  def build_table(name)
41
+ pk_column_name = @connection.primary_key(name) || "id"
42
+ pk_column = @connection.columns(name).find { |c| c.name == pk_column_name }
43
+ key = pk_column && pk_column.type == :uuid ? Key::Uuid : Key::Integer
44
+
41
45
  columns = @connection.columns(name)
42
- .reject { |c| framework_column?(c.name) }
46
+ .reject { |c| c.name == pk_column_name || timestamp_column?(c.name) }
43
47
  .map { |c| c.name.to_sym }
44
48
 
45
- associations = @connection.foreign_keys(name).map do |fk|
49
+ fk_associations = @connection.foreign_keys(name).map do |fk|
46
50
  Schema::BelongsTo.new(
47
51
  name: association_name(fk.column),
48
52
  table: fk.to_table.to_sym,
@@ -50,11 +54,17 @@ module FixtureBot
50
54
  )
51
55
  end
52
56
 
57
+ polymorphic_associations = detect_polymorphic_associations(name, columns, fk_associations)
58
+
59
+ associations = fk_associations + polymorphic_associations
60
+
53
61
  Schema::Table.new(
54
62
  name: name.to_sym,
55
63
  singular_name: singularize(name),
56
64
  columns: columns,
57
- belongs_to_associations: associations
65
+ associations: associations,
66
+ key: key,
67
+ primary_key: pk_column_name.to_sym
58
68
  )
59
69
  end
60
70
 
@@ -74,8 +84,8 @@ module FixtureBot
74
84
  @connection.tables - %w[ar_internal_metadata schema_migrations]
75
85
  end
76
86
 
77
- def framework_column?(name)
78
- %w[id created_at updated_at].include?(name)
87
+ def timestamp_column?(name)
88
+ %w[created_at updated_at].include?(name)
79
89
  end
80
90
 
81
91
  def foreign_key_column?(column)
@@ -110,6 +120,25 @@ module FixtureBot
110
120
  foreign_key_columns(name).size == 2
111
121
  end
112
122
  end
123
+
124
+ def detect_polymorphic_associations(table_name, columns, fk_associations)
125
+ fk_column_names = fk_associations.map { |a| a.foreign_key }
126
+ id_columns = columns.select { |c| c.to_s.end_with?("_id") }
127
+
128
+ id_columns.filter_map do |id_col|
129
+ next if fk_column_names.include?(id_col)
130
+
131
+ name = id_col.to_s.sub(/_id$/, "").to_sym
132
+ type_col = Schema::PolymorphicBelongsTo.foreign_type(name)
133
+ next unless columns.include?(type_col)
134
+
135
+ Schema::PolymorphicBelongsTo.new(
136
+ name: name,
137
+ foreign_key: id_col,
138
+ foreign_type: type_col
139
+ )
140
+ end
141
+ end
113
142
  end
114
143
  end
115
144
  end
@@ -2,7 +2,10 @@
2
2
 
3
3
  module FixtureBot
4
4
  module Row
5
- Declaration = Data.define(:table, :name, :literal_values, :association_refs, :tag_refs)
5
+ TableReference = Data.define(:table_name, :record_name)
6
+ TagRef = Data.define(:other_table, :refs)
7
+ JoinRow = Data.define(:key, :join_table, :row)
8
+ Declaration = Data.define(:table_name, :name, :literal_values, :association_refs, :tag_refs)
6
9
 
7
10
  class Definition
8
11
  attr_reader :literal_values, :association_refs, :tag_refs
@@ -13,8 +16,10 @@ module FixtureBot
13
16
  @tag_refs = {}
14
17
 
15
18
  define_column_methods(table)
19
+ define_primary_key_method(table)
16
20
  define_association_methods(table)
17
21
  define_join_table_methods(table, schema)
22
+ define_table_reference_methods(schema, table)
18
23
  end
19
24
 
20
25
  private
@@ -27,8 +32,15 @@ module FixtureBot
27
32
  end
28
33
  end
29
34
 
35
+ def define_primary_key_method(table)
36
+ pk_col = table.primary_key
37
+ define_singleton_method(pk_col) do |value|
38
+ @literal_values[pk_col] = value
39
+ end unless respond_to?(pk_col)
40
+ end
41
+
30
42
  def define_association_methods(table)
31
- table.belongs_to_associations.each do |assoc|
43
+ table.associations.each do |assoc|
32
44
  define_singleton_method(assoc.name) do |ref|
33
45
  @association_refs[assoc.name] = ref
34
46
  end
@@ -39,48 +51,58 @@ module FixtureBot
39
51
  schema.join_tables.each_value do |jt|
40
52
  if jt.left_table == table.name
41
53
  define_singleton_method(jt.right_table) do |*refs|
42
- @tag_refs[jt.name] = { table: jt.right_table, refs: refs }
54
+ @tag_refs[jt.name] = TagRef.new(other_table: jt.right_table, refs: refs)
43
55
  end
44
56
  elsif jt.right_table == table.name
45
57
  define_singleton_method(jt.left_table) do |*refs|
46
- @tag_refs[jt.name] = { table: jt.left_table, refs: refs }
58
+ @tag_refs[jt.name] = TagRef.new(other_table: jt.left_table, refs: refs)
47
59
  end
48
60
  end
49
61
  end
50
62
  end
63
+
64
+ def define_table_reference_methods(schema, table)
65
+ schema.tables.each_value do |t|
66
+ method_name = t.singular_name
67
+ next if respond_to?(method_name)
68
+ define_singleton_method(method_name) do |record_name|
69
+ TableReference.new(table_name: t.name, record_name: record_name)
70
+ end
71
+ end
72
+ end
51
73
  end
52
74
 
53
75
  class Builder
54
- def initialize(row:, table:, defaults:, join_tables:)
76
+ def initialize(row:, table:, defaults:, join_tables:, keys:)
55
77
  @row = row
56
78
  @table = table
57
79
  @defaults = defaults
58
80
  @join_tables = join_tables
81
+ @keys = keys
59
82
  end
60
83
 
61
- def id
62
- @id ||= Key.generate(@row.table, @row.name)
84
+ def primary_key_value
85
+ @primary_key_value ||= @keys.resolve(@row.table_name, @row.name)
63
86
  end
64
87
 
65
88
  def record
66
- result = { id: id }
89
+ values = {}
90
+ .merge!(defaulted_values)
91
+ .merge!(association_values)
92
+ .merge!(@row.literal_values)
93
+
94
+ result = { @table.primary_key => primary_key_value }
67
95
  @table.columns.each do |col|
68
- if @row.literal_values.key?(col)
69
- result[col] = @row.literal_values[col]
70
- elsif foreign_key_values.key?(col)
71
- result[col] = foreign_key_values[col]
72
- elsif defaulted_values.key?(col)
73
- result[col] = defaulted_values[col]
74
- end
96
+ result[col] = values[col] if values.key?(col)
75
97
  end
76
98
  result
77
99
  end
78
100
 
79
101
  def join_rows
80
- @row.tag_refs.flat_map do |join_table_name, tag_info|
102
+ @row.tag_refs.flat_map do |join_table_name, tag_ref|
81
103
  jt = @join_tables[join_table_name]
82
- tag_info[:refs].map do |tag_ref|
83
- build_join_row(jt, tag_info[:table], tag_ref)
104
+ tag_ref.refs.map do |ref|
105
+ build_join_row(jt, tag_ref.other_table, ref)
84
106
  end
85
107
  end
86
108
  end
@@ -88,38 +110,36 @@ module FixtureBot
88
110
  private
89
111
 
90
112
  def build_join_row(jt, other_table, tag_ref)
91
- other_id = Key.generate(other_table, tag_ref)
113
+ other_id = @keys.resolve(other_table, tag_ref)
92
114
 
93
- if jt.left_table == @row.table
94
- {
115
+ if jt.left_table == @row.table_name
116
+ JoinRow.new(
95
117
  key: :"#{@row.name}_#{tag_ref}",
96
118
  join_table: jt.name,
97
- row: { jt.left_foreign_key => id, jt.right_foreign_key => other_id }
98
- }
119
+ row: { jt.left_foreign_key => primary_key_value, jt.right_foreign_key => other_id }
120
+ )
99
121
  else
100
- {
122
+ JoinRow.new(
101
123
  key: :"#{tag_ref}_#{@row.name}",
102
124
  join_table: jt.name,
103
- row: { jt.left_foreign_key => other_id, jt.right_foreign_key => id }
104
- }
125
+ row: { jt.left_foreign_key => other_id, jt.right_foreign_key => primary_key_value }
126
+ )
105
127
  end
106
128
  end
107
129
 
108
- def foreign_key_values
109
- @foreign_key_values ||= @row.association_refs.each_with_object({}) do |(assoc_name, ref), hash|
110
- assoc = @table.belongs_to_associations.find { |a| a.name == assoc_name }
111
- hash[assoc.foreign_key] = Key.generate(assoc.table, ref)
130
+ def association_values
131
+ @association_values ||= @row.association_refs.each_with_object({}) do |(assoc_name, ref), hash|
132
+ assoc = @table.associations.find { |a| a.name == assoc_name }
133
+ hash.merge!(assoc.resolve(ref, @keys))
112
134
  end
113
135
  end
114
136
 
115
137
  def defaulted_values
116
- @defaulted_values ||= @defaults.each_with_object({}) do |(col, block), result|
138
+ @defaulted_values ||= @defaults.each_with_object({}) do |(col, generator), result|
117
139
  next if @row.literal_values.key?(col)
118
- next if foreign_key_values.key?(col)
140
+ next if association_values.key?(col)
119
141
 
120
- fixture = Default::Fixture.new(key: @row.name)
121
- context = Default::Context.new(literal_values: @row.literal_values)
122
- result[col] = context.instance_exec(fixture, &block)
142
+ result[col] = generator.generate(@row.name, @row.literal_values)
123
143
  end
124
144
  end
125
145
  end
@@ -1,9 +1,12 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "fixturebot/rails"
4
+ require "fixturebot/syntax"
4
5
 
5
6
  RSpec.configure do |config|
6
7
  config.before(:suite) do
7
8
  FixtureBot::Rails.compile
8
9
  end
10
+
11
+ config.include FixtureBot::Syntax::Methods
9
12
  end
@@ -2,8 +2,46 @@
2
2
 
3
3
  module FixtureBot
4
4
  class Schema
5
- Table = Data.define(:name, :singular_name, :columns, :belongs_to_associations)
6
- BelongsTo = Data.define(:name, :table, :foreign_key)
5
+ Table = Data.define(:name, :singular_name, :columns, :associations, :key, :primary_key) do
6
+ def initialize(name:, singular_name:, columns:, associations: [], key: Key::Integer, primary_key: :id)
7
+ super
8
+ end
9
+
10
+ def belongs_to_associations
11
+ associations.reject(&:polymorphic?)
12
+ end
13
+
14
+ def polymorphic_associations
15
+ associations.select(&:polymorphic?)
16
+ end
17
+ end
18
+
19
+ BelongsTo = Data.define(:name, :table, :foreign_key) do
20
+ def self.foreign_key(name) = :"#{name}_id"
21
+
22
+ def polymorphic? = false
23
+
24
+ def resolve(ref, keys)
25
+ { foreign_key => keys.resolve(table, ref) }
26
+ end
27
+ end
28
+
29
+ PolymorphicBelongsTo = Data.define(:name, :foreign_key, :foreign_type) do
30
+ def self.foreign_key(name) = :"#{name}_id"
31
+ def self.foreign_type(name) = :"#{name}_type"
32
+
33
+ def polymorphic? = true
34
+
35
+ def resolve(table_ref, keys)
36
+ require "active_support/inflector" unless defined?(ActiveSupport::Inflector)
37
+
38
+ {
39
+ foreign_key => keys.resolve(table_ref.table_name, table_ref.record_name),
40
+ foreign_type => ActiveSupport::Inflector.classify(table_ref.table_name.to_s)
41
+ }
42
+ end
43
+ end
44
+
7
45
  JoinTable = Data.define(:name, :left_table, :right_table, :left_foreign_key, :right_foreign_key)
8
46
 
9
47
  attr_reader :tables, :join_tables
@@ -33,7 +71,7 @@ module FixtureBot
33
71
  @schema = schema
34
72
  end
35
73
 
36
- def table(name, singular:, columns: [], &block)
74
+ def table(name, singular:, columns: [], key: Key::Integer, primary_key: :id, &block)
37
75
  associations = []
38
76
  if block
39
77
  table_builder = TableBuilder.new(associations)
@@ -43,7 +81,9 @@ module FixtureBot
43
81
  name: name,
44
82
  singular_name: singular,
45
83
  columns: columns,
46
- belongs_to_associations: associations
84
+ associations: associations,
85
+ key: key,
86
+ primary_key: primary_key
47
87
  ))
48
88
  end
49
89
 
@@ -54,8 +94,8 @@ module FixtureBot
54
94
  name: name,
55
95
  left_table: left_table,
56
96
  right_table: right_table,
57
- left_foreign_key: :"#{left_singular}_id",
58
- right_foreign_key: :"#{right_singular}_id"
97
+ left_foreign_key: BelongsTo.foreign_key(left_singular),
98
+ right_foreign_key: BelongsTo.foreign_key(right_singular)
59
99
  ))
60
100
  end
61
101
  end
@@ -66,8 +106,15 @@ module FixtureBot
66
106
  end
67
107
 
68
108
  def belongs_to(name, table:)
69
- foreign_key = :"#{name}_id"
70
- @associations << BelongsTo.new(name: name, table: table, foreign_key: foreign_key)
109
+ @associations << BelongsTo.new(name: name, table: table, foreign_key: BelongsTo.foreign_key(name))
110
+ end
111
+
112
+ def polymorphic(name)
113
+ @associations << PolymorphicBelongsTo.new(
114
+ name: name,
115
+ foreign_key: PolymorphicBelongsTo.foreign_key(name),
116
+ foreign_type: PolymorphicBelongsTo.foreign_type(name)
117
+ )
71
118
  end
72
119
  end
73
120
  end
@@ -0,0 +1,63 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_support/core_ext/string/inflections"
4
+ require "active_support/core_ext/hash/keys"
5
+
6
+ module FixtureBot
7
+ module Syntax
8
+ module Methods
9
+ def build(table_name, fixture_name, **attributes)
10
+ record = send(table_name.to_s.pluralize, fixture_name).dup
11
+ record.assign_attributes(attributes) if attributes.any?
12
+ record
13
+ end
14
+
15
+ def create(table_name, fixture_name, **attributes)
16
+ if attributes.any?
17
+ build(table_name, fixture_name, **attributes).tap(&:save!)
18
+ else
19
+ send(table_name.to_s.pluralize, fixture_name)
20
+ end
21
+ end
22
+
23
+ def build_stubbed(table_name, fixture_name, **attributes)
24
+ source = send(table_name.to_s.pluralize, fixture_name)
25
+ attrs = source.attributes
26
+ attrs.merge!(attributes.stringify_keys) if attributes.any?
27
+ source.class.instantiate(attrs)
28
+ end
29
+
30
+ def attributes_for(table_name, fixture_name, **attributes)
31
+ send(table_name.to_s.pluralize, fixture_name)
32
+ .attributes
33
+ .symbolize_keys
34
+ .except(:id, :created_at, :updated_at)
35
+ .merge(attributes)
36
+ end
37
+
38
+ def build_list(table_name, *fixture_names, **attributes)
39
+ fixture_names.map { |name| build(table_name, name, **attributes) }
40
+ end
41
+
42
+ def create_list(table_name, *fixture_names, **attributes)
43
+ fixture_names.map { |name| create(table_name, name, **attributes) }
44
+ end
45
+
46
+ def build_pair(table_name, *fixture_names, **attributes)
47
+ build_list(table_name, *fixture_names, **attributes)
48
+ end
49
+
50
+ def create_pair(table_name, *fixture_names, **attributes)
51
+ create_list(table_name, *fixture_names, **attributes)
52
+ end
53
+
54
+ def build_stubbed_list(table_name, *fixture_names, **attributes)
55
+ fixture_names.map { |name| build_stubbed(table_name, name, **attributes) }
56
+ end
57
+
58
+ def build_stubbed_pair(table_name, *fixture_names, **attributes)
59
+ build_stubbed_list(table_name, *fixture_names, **attributes)
60
+ end
61
+ end
62
+ end
63
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module FixtureBot
4
- VERSION = "0.2.0"
4
+ VERSION = "0.4.0"
5
5
  end
data/lib/fixturebot.rb CHANGED
@@ -6,6 +6,7 @@ require_relative "fixturebot/key"
6
6
  require_relative "fixturebot/default"
7
7
  require_relative "fixturebot/row"
8
8
  require_relative "fixturebot/definition"
9
+ require_relative "fixturebot/key_registry"
9
10
  require_relative "fixturebot/fixture_set"
10
11
  require_relative "fixturebot/compiler"
11
12
  require_relative "fixturebot/cli"
@@ -14,28 +15,31 @@ module FixtureBot
14
15
  class Error < StandardError; end
15
16
 
16
17
  # Programmatic API: FixtureBot.define(schema) { ... }
17
- # File API (no schema): FixtureBot.define { ... } — registers block for define_from_file
18
+ # File API (no schema): FixtureBot.define { ... } — evaluated against current_definition if set
18
19
  def self.define(schema = nil, &block)
19
20
  if schema
20
21
  definition = Definition.new(schema)
21
22
  evaluate_block(definition, block)
22
23
  FixtureSet.new(schema, definition)
23
- else
24
- @pending_blocks ||= []
25
- @pending_blocks << block
24
+ elsif Thread.current[:fixturebot_definition]
25
+ evaluate_block(Thread.current[:fixturebot_definition], block)
26
26
  nil
27
+ else
28
+ raise Error, "FixtureBot.define without a schema must be called from within define_from_file"
27
29
  end
28
30
  end
29
31
 
30
- def self.define_from_file(schema, fixtures_path)
31
- @pending_blocks = []
32
- content = File.read(fixtures_path)
33
- eval(content, TOPLEVEL_BINDING, fixtures_path, 1)
32
+ def self.require(glob)
33
+ Dir.glob(glob).sort.each { |f| load f }
34
+ end
34
35
 
36
+ def self.define_from_file(schema, fixtures_path)
35
37
  definition = Definition.new(schema)
36
- @pending_blocks.each { |blk| evaluate_block(definition, blk) }
37
- @pending_blocks = nil
38
+ Thread.current[:fixturebot_definition] = definition
39
+ eval(File.read(fixtures_path), TOPLEVEL_BINDING, fixtures_path, 1)
38
40
  FixtureSet.new(schema, definition)
41
+ ensure
42
+ Thread.current[:fixturebot_definition] = nil
39
43
  end
40
44
 
41
45
  def self.evaluate_block(definition, block)
@@ -1,3 +1,5 @@
1
+ FixtureBot.require "<%= rspec? ? "spec" : "test" %>/fixtures/**/*.rb"
2
+
1
3
  FixtureBot.define do
2
4
  # Define your fixtures here. Example:
3
5
  #
@@ -51,4 +51,19 @@ FixtureBot.define do
51
51
  post :tdd_guide
52
52
  author :brad
53
53
  end
54
+
55
+ vote :alice_likes_hello do
56
+ votable post(:hello_world)
57
+ voter :alice
58
+ end
59
+
60
+ vote :brad_likes_tdd do
61
+ votable post(:tdd_guide)
62
+ voter :brad
63
+ end
64
+
65
+ vote :charlie_likes_great_post do
66
+ votable comment(:great_post)
67
+ voter :charlie
68
+ end
54
69
  end
@@ -13,4 +13,9 @@ FixtureBot::Schema.define do
13
13
  table :tags, singular: :tag, columns: [:name]
14
14
 
15
15
  join_table :posts_tags, :posts, :tags
16
+
17
+ table :votes, singular: :vote, columns: [:votable_id, :votable_type, :voter_id] do
18
+ polymorphic :votable
19
+ belongs_to :voter, table: :users
20
+ end
16
21
  end
metadata CHANGED
@@ -1,13 +1,13 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: fixturebot-rails
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.0
4
+ version: 0.4.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Brad Gessler
8
8
  bindir: exe
9
9
  cert_chain: []
10
- date: 2026-02-09 00:00:00.000000000 Z
10
+ date: 2026-03-11 00:00:00.000000000 Z
11
11
  dependencies:
12
12
  - !ruby/object:Gem::Dependency
13
13
  name: activerecord
@@ -74,6 +74,9 @@ files:
74
74
  - lib/fixturebot/definition.rb
75
75
  - lib/fixturebot/fixture_set.rb
76
76
  - lib/fixturebot/key.rb
77
+ - lib/fixturebot/key/integer.rb
78
+ - lib/fixturebot/key/uuid.rb
79
+ - lib/fixturebot/key_registry.rb
77
80
  - lib/fixturebot/minitest.rb
78
81
  - lib/fixturebot/rails.rb
79
82
  - lib/fixturebot/rails/cli.rb
@@ -82,6 +85,7 @@ files:
82
85
  - lib/fixturebot/row.rb
83
86
  - lib/fixturebot/rspec.rb
84
87
  - lib/fixturebot/schema.rb
88
+ - lib/fixturebot/syntax.rb
85
89
  - lib/fixturebot/version.rb
86
90
  - lib/generators/fixturebot/install_generator.rb
87
91
  - lib/generators/fixturebot/templates/fixtures.rb.tt