diff --git a/Gemfile.lock b/Gemfile.lock index 628658c..d1aa239 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -214,6 +214,7 @@ GEM zeitwerk (2.7.4) PLATFORMS + arm64-darwin-23 arm64-darwin-24 x86_64-linux diff --git a/README.md b/README.md index 74c5bed..c1d1733 100644 --- a/README.md +++ b/README.md @@ -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). diff --git a/lib/fixturebot/fixture_set.rb b/lib/fixturebot/fixture_set.rb index 3213922..136a5d4 100644 --- a/lib/fixturebot/fixture_set.rb +++ b/lib/fixturebot/fixture_set.rb @@ -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 ) @tables[row.table][row.name] = builder.record diff --git a/lib/fixturebot/key.rb b/lib/fixturebot/key.rb index fb15628..f3dade5 100644 --- a/lib/fixturebot/key.rb +++ b/lib/fixturebot/key.rb @@ -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" + 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) + + # 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 diff --git a/lib/fixturebot/rails/schema_loader.rb b/lib/fixturebot/rails/schema_loader.rb index a95ccf9..036f51b 100644 --- a/lib/fixturebot/rails/schema_loader.rb +++ b/lib/fixturebot/rails/schema_loader.rb @@ -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), @@ -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 + end + def build_join_table(name) fk_columns = foreign_key_columns(name) diff --git a/lib/fixturebot/row.rb b/lib/fixturebot/row.rb index d0565b4..8f1cc45 100644 --- a/lib/fixturebot/row.rb +++ b/lib/fixturebot/row.rb @@ -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 @@ -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 { @@ -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 diff --git a/lib/fixturebot/schema.rb b/lib/fixturebot/schema.rb index c9e823d..78cfa8d 100644 --- a/lib/fixturebot/schema.rb +++ b/lib/fixturebot/schema.rb @@ -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) @@ -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) @@ -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 diff --git a/spec/fixturebot/rails/schema_loader/mysql_spec.rb b/spec/fixturebot/rails/schema_loader/mysql_spec.rb new file mode 100644 index 0000000..4a6d8c0 --- /dev/null +++ b/spec/fixturebot/rails/schema_loader/mysql_spec.rb @@ -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 diff --git a/spec/fixturebot/rails/schema_loader/postgresql_spec.rb b/spec/fixturebot/rails/schema_loader/postgresql_spec.rb new file mode 100644 index 0000000..03c0c6c --- /dev/null +++ b/spec/fixturebot/rails/schema_loader/postgresql_spec.rb @@ -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 diff --git a/spec/fixturebot/rails/schema_loader/sqlite3_spec.rb b/spec/fixturebot/rails/schema_loader/sqlite3_spec.rb new file mode 100644 index 0000000..53a4617 --- /dev/null +++ b/spec/fixturebot/rails/schema_loader/sqlite3_spec.rb @@ -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 diff --git a/spec/fixturebot/schema_loader_spec.rb b/spec/fixturebot/rails/schema_loader_spec.rb similarity index 92% rename from spec/fixturebot/schema_loader_spec.rb rename to spec/fixturebot/rails/schema_loader_spec.rb index b9a2e02..f0a5ea2 100644 --- a/spec/fixturebot/schema_loader_spec.rb +++ b/spec/fixturebot/rails/schema_loader_spec.rb @@ -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 diff --git a/spec/fixturebot_spec.rb b/spec/fixturebot_spec.rb index cc4c558..1f618df 100644 --- a/spec/fixturebot_spec.rb +++ b/spec/fixturebot_spec.rb @@ -102,6 +102,123 @@ end end + describe FixtureBot::Key, ".generate_uuid" do + it "generates deterministic UUIDs" do + uuid1 = FixtureBot::Key.generate_uuid(:users, :admin) + uuid2 = FixtureBot::Key.generate_uuid(:users, :admin) + expect(uuid1).to eq(uuid2) + end + + it "generates valid UUID v5 format" do + uuid = FixtureBot::Key.generate_uuid(:users, :admin) + expect(uuid).to match(/\A[0-9a-f]{8}-[0-9a-f]{4}-5[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}\z/) + end + + it "generates different UUIDs for different records" do + uuid1 = FixtureBot::Key.generate_uuid(:users, :admin) + uuid2 = FixtureBot::Key.generate_uuid(:users, :reader) + expect(uuid1).not_to eq(uuid2) + end + + it "generates different UUIDs for same name in different tables" do + uuid1 = FixtureBot::Key.generate_uuid(:users, :admin) + uuid2 = FixtureBot::Key.generate_uuid(:posts, :admin) + expect(uuid1).not_to eq(uuid2) + end + end + + describe "UUID primary key support" do + let(:schema) do + FixtureBot::Schema.define do + table :users, singular: :user, columns: [:name, :email], primary_key_type: :uuid + table :posts, singular: :post, columns: [:title, :author_id], primary_key_type: :uuid do + belongs_to :author, table: :users + end + table :tags, singular: :tag, columns: [:name], primary_key_type: :uuid + join_table :posts_tags, :posts, :tags + end + end + + let(:result) do + FixtureBot.define(schema) do + user :admin do + name "Brad" + email "brad@blog.test" + end + + post :hello_world do + title "Hello world" + author :admin + tags :ruby + end + + tag :ruby do + name "ruby" + end + end + end + + it "generates UUID primary keys" do + uuid = result.tables[:users][:admin][:id] + expect(uuid).to match(/\A[0-9a-f]{8}-[0-9a-f]{4}-5[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}\z/) + end + + it "generates UUID foreign keys for belongs_to" do + admin_uuid = result.tables[:users][:admin][:id] + post_author_id = result.tables[:posts][:hello_world][:author_id] + expect(post_author_id).to eq(admin_uuid) + end + + it "generates UUID foreign keys in join tables" do + post_uuid = result.tables[:posts][:hello_world][:id] + tag_uuid = result.tables[:tags][:ruby][:id] + join = result.tables[:posts_tags][:hello_world_ruby] + + expect(join[:post_id]).to eq(post_uuid) + expect(join[:tag_id]).to eq(tag_uuid) + end + end + + describe "mixed integer and UUID primary keys" do + let(:schema) do + FixtureBot::Schema.define do + 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 + end + end + + let(:result) do + FixtureBot.define(schema) do + tenant :acme do + name "Acme Corp" + end + + post :hello do + title "Hello" + tenant :acme + end + end + end + + it "generates integer ID for integer table" do + post_id = result.tables[:posts][:hello][:id] + expect(post_id).to be_a(Integer) + end + + it "generates UUID for UUID table" do + tenant_id = result.tables[:tenants][:acme][:id] + expect(tenant_id).to match(/\A[0-9a-f]{8}-/) + end + + it "uses UUID when referencing a UUID table from an integer table" do + tenant_uuid = result.tables[:tenants][:acme][:id] + post_tenant_id = result.tables[:posts][:hello][:tenant_id] + expect(post_tenant_id).to eq(tenant_uuid) + end + end + describe "generators" do let(:schema) do FixtureBot::Schema.define do @@ -179,4 +296,14 @@ }.to raise_error(NoMethodError) end end + + describe "primary_key_type validation" do + it "raises ArgumentError for unsupported primary_key_type" do + expect { + FixtureBot::Schema.define do + table :users, singular: :user, columns: [:name], primary_key_type: :bigint + end + }.to raise_error(ArgumentError, /unsupported primary_key_type: :bigint/) + end + end end