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
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,12 @@ FixtureBot.define do
end
```

#### Stable Primary Key Generation

Stable IDs are generated 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
3 changes: 2 additions & 1 deletion lib/fixturebot/fixture_set.rb
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,8 @@ def initialize(schema, definition)
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
Copy link
Author

Choose a reason for hiding this comment

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

We now pass the full hash of tables along in a few places, mostly for the sake of join tables.

For example:

table :tenants, singular: :tenant, columns: [:name], primary_key_type: :uuid
table :posts,   singular: :post,   columns: [:title, :tenant_id] do
  belongs_to :tenant, table: :tenants
end

When building a posts row, the builder needs to generate tenant_id. That foreign key must match the tenants table's key type (UUID), not the posts table's type (integer). So it needs to look up @tables[:tenants].primary_key_type to decide.

I don't love it, but I couldn't come up with a cleaner way.

)

@tables[row.table][row.name] = builder.record
Expand Down
26 changes: 26 additions & 0 deletions lib/fixturebot/key.rb
Original file line number Diff line number Diff line change
@@ -1,11 +1,37 @@
# frozen_string_literal: true

require "zlib"
require "digest/sha1"

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

Choose a reason for hiding this comment

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

Using the pre-defined namespace ID for URL was just arbitrary, our use case doesn't fit the classes given.

We append "fixturebot:" to ensure we don't have collisions should another gem use the same

We could create our own "starting point" using UUID v4 to generate a random one to serve as our "seed", but either way we should be collision proof.


def self.generate(table_name, record_name)
Zlib.crc32("#{table_name}:#{record_name}") & 0x7FFFFFFF
end

def self.generate_uuid(table_name, record_name)
uuid_v5(URL_NAMESPACE, "fixturebot:#{table_name}:#{record_name}")
end

def self.uuid_v5(namespace_uuid, name)
# Parse namespace UUID string to 16 bytes
namespace_bytes = [namespace_uuid.tr("-", "")].pack("H32")

# SHA-1 hash of namespace bytes + name (RFC 4122 Section 4.3)
hash = Digest::SHA1.digest(namespace_bytes + name.to_s)
Copy link
Author

Choose a reason for hiding this comment

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

I think not having extra dependencies here is a bonus.


# Take first 16 bytes and set version/variant bits
bytes = hash.bytes[0, 16]
bytes[6] = (bytes[6] & 0x0F) | 0x50 # Version 5
bytes[8] = (bytes[8] & 0x3F) | 0x80 # RFC 4122 variant

# Format as standard UUID string (8-4-4-4-12)
hex = bytes.map { |b| "%02x" % b }.join
"#{hex[0, 8]}-#{hex[8, 4]}-#{hex[12, 4]}-#{hex[16, 4]}-#{hex[20, 12]}"
end
private_class_method :uuid_v5
end
end
15 changes: 14 additions & 1 deletion lib/fixturebot/rails/schema_loader.rb
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,8 @@ def build_table(name)
.reject { |c| framework_column?(c.name) }
.map { |c| c.name.to_sym }

primary_key_type = detect_primary_key_type(name)

associations = @connection.foreign_keys(name).map do |fk|
Schema::BelongsTo.new(
name: association_name(fk.column),
Expand All @@ -54,10 +56,21 @@ 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
)
end

def detect_primary_key_type(table_name)
pk = @connection.primary_key(table_name)
return :integer unless pk

column = @connection.columns(table_name).find { |c| c.name == pk }
return :integer unless column

column.type == :uuid ? :uuid : :integer
Copy link
Author

Choose a reason for hiding this comment

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

I started by looking at the sql_type, but it's possible for a Rails app to use UUIDs even when the database that doesn't have UUIDs natively (e.g. using char(36)), so it seemed better to get the type from Rails then from the db itself.

end

def build_join_table(name)
fk_columns = foreign_key_columns(name)

Expand Down
19 changes: 15 additions & 4 deletions lib/fixturebot/row.rb
Original file line number Diff line number Diff line change
Expand Up @@ -51,15 +51,16 @@ 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: {})
@row = row
@table = table
@defaults = defaults
@join_tables = join_tables
@tables = tables
end

def id
@id ||= Key.generate(@row.table, @row.name)
@id ||= generate_key_for(@table, @row.table, @row.name)
end

def record
Expand Down Expand Up @@ -87,8 +88,17 @@ def join_rows

private

def generate_key_for(table_schema, table_name, record_name)
if table_schema&.primary_key_type == :uuid
Key.generate_uuid(table_name, record_name)
else
Key.generate(table_name, record_name)
end
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 +118,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
17 changes: 14 additions & 3 deletions lib/fixturebot/schema.rb
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,17 @@

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) do
SUPPORTED_PRIMARY_KEY_TYPES = %i[integer uuid].freeze

def initialize(name:, singular_name:, columns:, belongs_to_associations:, primary_key_type: :integer)
unless SUPPORTED_PRIMARY_KEY_TYPES.include?(primary_key_type)
raise ArgumentError, "unsupported primary_key_type: #{primary_key_type.inspect} (must be one of #{SUPPORTED_PRIMARY_KEY_TYPES.join(', ')})"
end

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 +43,7 @@ def initialize(schema)
@schema = schema
end

def table(name, singular:, columns: [], &block)
def table(name, singular:, columns: [], primary_key_type: :integer, &block)
associations = []
if block
table_builder = TableBuilder.new(associations)
Expand All @@ -43,7 +53,8 @@ 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
))
end

Expand Down
44 changes: 44 additions & 0 deletions spec/fixturebot/rails/schema_loader/mysql_spec.rb
Copy link
Author

Choose a reason for hiding this comment

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

I wanted to ensure we covered the Rails-supported databases (at least in theory since we're just using sqlite3 and stubbing out the rest), so I added a new directory and put adapter specific specs in it.

Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
# frozen_string_literal: true

require "fixturebot/rails"

RSpec.describe FixtureBot::Rails::SchemaLoader, "MySQL UUID detection" do
before do
ActiveRecord::Base.establish_connection(adapter: "sqlite3", database: ":memory:")

ActiveRecord::Schema.define(version: 2024_01_01_000000) do
create_table "users", force: :cascade do |t|
t.string "name"
t.timestamps
end
end
end

after do
ActiveRecord::Base.connection_pool.disconnect!
end

let(:connection) { ActiveRecord::Base.connection }

subject(:schema) { described_class.load }

# MySQL has no native uuid column type. UUIDs are typically stored in
# char(36) columns, which the adapter reports as `column.type` `:string`.
# Auto-detection cannot distinguish these from regular string columns, so
# the loader falls back to the :integer strategy.
before do
string_column = instance_double(
ActiveRecord::ConnectionAdapters::Column,
name: "id",
type: :string
)
allow(connection).to receive(:primary_key).and_call_original
allow(connection).to receive(:primary_key).with("users").and_return("id")
allow(connection).to receive(:columns).and_call_original
allow(connection).to receive(:columns).with("users").and_return([string_column])
end

it "returns :integer because column type is :string, not :uuid" do
expect(schema.tables[:users].primary_key_type).to eq(:integer)
end
end
51 changes: 51 additions & 0 deletions spec/fixturebot/rails/schema_loader/postgresql_spec.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
# frozen_string_literal: true

require "fixturebot/rails"

RSpec.describe FixtureBot::Rails::SchemaLoader, "PostgreSQL UUID detection" do
before do
ActiveRecord::Base.establish_connection(adapter: "sqlite3", database: ":memory:")

ActiveRecord::Schema.define(version: 2024_01_01_000000) do
create_table "users", force: :cascade do |t|
t.string "name"
t.timestamps
end

create_table "posts", force: :cascade do |t|
t.string "title"
t.timestamps
end
end
end

after do
ActiveRecord::Base.connection_pool.disconnect!
end

let(:connection) { ActiveRecord::Base.connection }

subject(:schema) { described_class.load }

# PostgreSQL has a native uuid column type. When a table is created with
# `id: :uuid`, the adapter reports `column.type` as `:uuid`.
before do
uuid_column = instance_double(
ActiveRecord::ConnectionAdapters::Column,
name: "id",
type: :uuid
)
allow(connection).to receive(:primary_key).and_call_original
allow(connection).to receive(:primary_key).with("users").and_return("id")
allow(connection).to receive(:columns).and_call_original
allow(connection).to receive(:columns).with("users").and_return([uuid_column])
end

it "detects :uuid when column type is :uuid" do
expect(schema.tables[:users].primary_key_type).to eq(:uuid)
end

it "still detects :integer for other tables" do
expect(schema.tables[:posts].primary_key_type).to eq(:integer)
end
end
54 changes: 54 additions & 0 deletions spec/fixturebot/rails/schema_loader/sqlite3_spec.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
# frozen_string_literal: true

require "fixturebot/rails"

RSpec.describe FixtureBot::Rails::SchemaLoader, "SQLite UUID detection" do
before do
ActiveRecord::Base.establish_connection(adapter: "sqlite3", database: ":memory:")

ActiveRecord::Schema.define(version: 2024_01_01_000000) do
create_table "users", force: :cascade do |t|
t.string "name"
t.timestamps
end
end
end

after do
ActiveRecord::Base.connection_pool.disconnect!
end

let(:connection) { ActiveRecord::Base.connection }

subject(:schema) { described_class.load }

# SQLite has no native uuid column type. UUIDs are typically stored in
# varchar(36) columns, which the adapter reports as `column.type` `:string`.
# Auto-detection cannot distinguish these from regular string columns, so
# the loader falls back to the :integer strategy.
before do
string_column = instance_double(
ActiveRecord::ConnectionAdapters::Column,
name: "id",
type: :string
)
allow(connection).to receive(:primary_key).and_call_original
allow(connection).to receive(:primary_key).with("users").and_return("id")
allow(connection).to receive(:columns).and_call_original
allow(connection).to receive(:columns).with("users").and_return([string_column])
end

it "returns :integer because column type is :string, not :uuid" do
expect(schema.tables[:users].primary_key_type).to eq(:integer)
end

context "when table has no primary key" do
before do
allow(connection).to receive(:primary_key).with("users").and_return(nil)
end

it "returns :integer" do
expect(schema.tables[:users].primary_key_type).to eq(:integer)
end
end
end
Copy link
Author

Choose a reason for hiding this comment

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

Also moved this spec in to the rails dir since that's where it lives in code.

Original file line number Diff line number Diff line change
Expand Up @@ -70,4 +70,10 @@
it "does not include join tables in regular tables" do
expect(schema.tables).not_to have_key(:posts_tags)
end

it "defaults primary_key_type to :integer for standard tables" do
schema.tables.each_value do |table|
expect(table.primary_key_type).to eq(:integer)
end
end
end
Loading