Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Gemfile.lock
Original file line number Diff line number Diff line change
Expand Up @@ -214,6 +214,7 @@ GEM
zeitwerk (2.7.4)

PLATFORMS
arm64-darwin-23
arm64-darwin-24
x86_64-linux

Expand Down
33 changes: 33 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,34 @@ FixtureBot.define do
end
```

#### Stable Primary Key Generation

You can always define your own primary keys on a fixture definition, this is helpful if you want to use a known value or are bringing over existing fixtures.

Example:

```ruby
FixtureBot.define do
user :brad do
id 1
name "Brad"
end

post :hello_world do
id "a3aed36c-5506-49b9-b797-2d2cc7ada3e0"
end

# FixtureBot can handle primary key column names other than "id"
tag :ruby do
custom_primary_key_column 200
end
end
```

Otherwise, you can let FixtureBot generate a stable ID for you, using a deterministic algorithm that is consistent across runs.

When generating records, FixtureBot auto-detects the primary key type for each table. It supports `:integer` and `:uuid`, falling back to `:integer` if it cannot detect the type.

### Generators

Generators set default column values. They run for each record that doesn't explicitly set that column. Generators are never created implicitly; columns without a value or generator are omitted from the YAML output (Rails uses the database column default).
Expand Down Expand Up @@ -274,6 +302,11 @@ FixtureBot::Schema.define do
belongs_to :author, table: :users
end

# primary_key_type: sets the key generation strategy (:integer or :uuid)
# primary_key_column: sets the column name (defaults to :id)
table :accounts, singular: :account, columns: [:name],
primary_key_type: :uuid, primary_key_column: :uid

table :tags, singular: :tag, columns: [:name]

join_table :posts_tags, :posts, :tags
Expand Down
15 changes: 14 additions & 1 deletion lib/fixturebot/fixture_set.rb
Original file line number Diff line number Diff line change
Expand Up @@ -10,12 +10,16 @@ def initialize(schema, definition)
schema.tables.each_key { |name| @tables[name] = {} }
schema.join_tables.each_key { |name| @tables[name] = {} }

id_map = collect_hardcoded_ids(definition.rows, schema.tables)
Copy link
Author

@chiperific chiperific Feb 19, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Allows users to hardcode primary keys in fixture files


definition.rows.each do |row|
builder = Row::Builder.new(
row: row,
table: schema.tables[row.table],
defaults: definition.defaults[row.table],
join_tables: schema.join_tables
join_tables: schema.join_tables,
tables: schema.tables,
id_map: id_map
)

@tables[row.table][row.name] = builder.record
Expand All @@ -25,5 +29,14 @@ def initialize(schema, definition)
end
end
end

private

def collect_hardcoded_ids(rows, schema_tables)
rows.each_with_object({}) do |row, map|
pk_col = schema_tables[row.table]&.primary_key_column || :id
map[[row.table, row.name]] = row.literal_values[pk_col] if row.literal_values.key?(pk_col)
end
end
end
end
19 changes: 16 additions & 3 deletions lib/fixturebot/key.rb
Original file line number Diff line number Diff line change
@@ -1,11 +1,24 @@
# frozen_string_literal: true

require "zlib"
require_relative "key/integer"
require_relative "key/uuid"

module FixtureBot
module Key
def self.generate(table_name, record_name)
Zlib.crc32("#{table_name}:#{record_name}") & 0x7FFFFFFF
def self.resolve(value)
case value
when :integer then Integer.new
when :uuid then UUID.new
when Symbol
raise ArgumentError,
"unsupported primary key type: #{value.inspect} (must be :integer, :uuid, or a FixtureBot::Key object that responds to #generate)"
else
unless value.respond_to?(:generate)
raise ArgumentError,
"primary_key_type must be :integer, :uuid, or respond to #generate"
end
value
end
end
end
end
13 changes: 13 additions & 0 deletions lib/fixturebot/key/integer.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
# frozen_string_literal: true

require "zlib"

module FixtureBot
module Key
class Integer
def generate(table_name, record_name)
Zlib.crc32("#{table_name}:#{record_name}") & 0x7FFFFFFF
end
end
end
end
30 changes: 30 additions & 0 deletions lib/fixturebot/key/uuid.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
# frozen_string_literal: true

require "digest/sha1"

module FixtureBot
module Key
class UUID
# RFC 4122 URL namespace UUID, used as the base namespace for UUID v5 generation.
URL_NAMESPACE = "6ba7b811-9dad-11d1-80b4-00c04fd430c8"

def generate(table_name, record_name)
uuid_v5(URL_NAMESPACE, "fixturebot:#{table_name}:#{record_name}")
end

private

def uuid_v5(namespace_uuid, name)
namespace_bytes = [namespace_uuid.tr("-", "")].pack("H32")
hash = Digest::SHA1.digest(namespace_bytes + name.to_s)

bytes = hash.bytes[0, 16]
bytes[6] = (bytes[6] & 0x0F) | 0x50 # Version 5
bytes[8] = (bytes[8] & 0x3F) | 0x80 # RFC 4122 variant

hex = bytes.map { |b| "%02x" % b }.join
"#{hex[0, 8]}-#{hex[8, 4]}-#{hex[12, 4]}-#{hex[16, 4]}-#{hex[20, 12]}"
end
end
end
end
25 changes: 20 additions & 5 deletions lib/fixturebot/rails/schema_loader.rb
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@
module FixtureBot
module Rails
class SchemaLoader

def self.load(connection = ActiveRecord::Base.connection)
new(connection).load
end
Expand Down Expand Up @@ -38,10 +37,14 @@ def build_schema
end

def build_table(name)
pk_name = @connection.primary_key(name)

columns = @connection.columns(name)
.reject { |c| framework_column?(c.name) }
.reject { |c| c.name == pk_name || timestamp_column?(c.name) }
.map { |c| c.name.to_sym }

primary_key_type = detect_primary_key_type(name, pk_name)

associations = @connection.foreign_keys(name).map do |fk|
Schema::BelongsTo.new(
name: association_name(fk.column),
Expand All @@ -54,10 +57,22 @@ def build_table(name)
name: name.to_sym,
singular_name: singularize(name),
columns: columns,
belongs_to_associations: associations
belongs_to_associations: associations,
primary_key_type: primary_key_type,
primary_key_column: (pk_name || "id").to_sym
)
end

def detect_primary_key_type(table_name, pk_name = nil)
pk_name ||= @connection.primary_key(table_name)
return Key::Integer.new unless pk_name

column = @connection.columns(table_name).find { |c| c.name == pk_name }
return Key::Integer.new unless column

(column.type == :uuid) ? Key::UUID.new : Key::Integer.new
end

def build_join_table(name)
fk_columns = foreign_key_columns(name)

Expand All @@ -74,8 +89,8 @@ def user_table_names
@connection.tables - %w[ar_internal_metadata schema_migrations]
end

def framework_column?(name)
%w[id created_at updated_at].include?(name)
def timestamp_column?(name)
%w[created_at updated_at].include?(name)
end

def foreign_key_column?(column)
Expand Down
30 changes: 25 additions & 5 deletions lib/fixturebot/row.rb
Original file line number Diff line number Diff line change
Expand Up @@ -12,13 +12,21 @@ def initialize(table, schema)
@association_refs = {}
@tag_refs = {}

define_primary_key_method(table)
define_column_methods(table)
define_association_methods(table)
define_join_table_methods(table, schema)
end

private

def define_primary_key_method(table)
pk_col = table.primary_key_column
define_singleton_method(pk_col) do |value|
@literal_values[pk_col] = value
end
end

def define_column_methods(table)
table.columns.each do |col|
define_singleton_method(col) do |value|
Expand Down Expand Up @@ -51,19 +59,22 @@ def define_join_table_methods(table, schema)
end

class Builder
def initialize(row:, table:, defaults:, join_tables:)
def initialize(row:, table:, defaults:, join_tables:, tables: {}, id_map: {})
@row = row
@table = table
@defaults = defaults
@join_tables = join_tables
@tables = tables
@id_map = id_map
end

def id
@id ||= Key.generate(@row.table, @row.name)
pk_col = @table.primary_key_column
@id ||= @row.literal_values.fetch(pk_col) { generate_key_for(@table, @row.table, @row.name) }
end

def record
result = { id: id }
result = { @table.primary_key_column => id }
@table.columns.each do |col|
if @row.literal_values.key?(col)
result[col] = @row.literal_values[col]
Expand All @@ -87,8 +98,16 @@ def join_rows

private

def generate_key_for(table_schema, table_name, record_name)
return @id_map[[table_name, record_name]] if @id_map.key?([table_name, record_name])

key = table_schema&.primary_key_type || Key::Integer.new
key.generate(table_name, record_name)
end

def build_join_row(jt, other_table, tag_ref)
other_id = Key.generate(other_table, tag_ref)
other_table_schema = @tables[other_table]
other_id = generate_key_for(other_table_schema, other_table, tag_ref)

if jt.left_table == @row.table
{
Expand All @@ -108,7 +127,8 @@ def build_join_row(jt, other_table, tag_ref)
def foreign_key_values
@foreign_key_values ||= @row.association_refs.each_with_object({}) do |(assoc_name, ref), hash|
assoc = @table.belongs_to_associations.find { |a| a.name == assoc_name }
hash[assoc.foreign_key] = Key.generate(assoc.table, ref)
referenced_table = @tables[assoc.table]
hash[assoc.foreign_key] = generate_key_for(referenced_table, assoc.table, ref)
end
end

Expand Down
13 changes: 10 additions & 3 deletions lib/fixturebot/schema.rb
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,12 @@

module FixtureBot
class Schema
Table = Data.define(:name, :singular_name, :columns, :belongs_to_associations)
Table = Data.define(:name, :singular_name, :columns, :belongs_to_associations, :primary_key_type, :primary_key_column) do
def initialize(name:, singular_name:, columns:, belongs_to_associations:, primary_key_type: Key::Integer.new, primary_key_column: :id)
primary_key_type = Key.resolve(primary_key_type)
super
end
end
BelongsTo = Data.define(:name, :table, :foreign_key)
JoinTable = Data.define(:name, :left_table, :right_table, :left_foreign_key, :right_foreign_key)

Expand Down Expand Up @@ -33,7 +38,7 @@ def initialize(schema)
@schema = schema
end

def table(name, singular:, columns: [], &block)
def table(name, singular:, columns: [], primary_key_type: :integer, primary_key_column: :id, &block)
associations = []
if block
table_builder = TableBuilder.new(associations)
Expand All @@ -43,7 +48,9 @@ def table(name, singular:, columns: [], &block)
name: name,
singular_name: singular,
columns: columns,
belongs_to_associations: associations
belongs_to_associations: associations,
primary_key_type: primary_key_type,
primary_key_column: primary_key_column
))
end

Expand Down
Loading