From c392869dde4758ccf9af6aa22ab87cdfa616a6f5 Mon Sep 17 00:00:00 2001 From: Chip Date: Fri, 13 Feb 2026 13:21:45 -0500 Subject: [PATCH 1/5] feat: add support for UUID keys --- Gemfile.lock | 1 + lib/fixturebot/fixture_set.rb | 3 +- lib/fixturebot/key.rb | 26 ++++ lib/fixturebot/rails/schema_loader.rb | 15 ++- lib/fixturebot/row.rb | 19 ++- lib/fixturebot/schema.rb | 17 ++- .../rails/schema_loader/mysql_spec.rb | 44 ++++++ .../rails/schema_loader/postgresql_spec.rb | 51 +++++++ .../rails/schema_loader/sqlite3_spec.rb | 54 ++++++++ .../{ => rails}/schema_loader_spec.rb | 6 + spec/fixturebot_spec.rb | 127 ++++++++++++++++++ 11 files changed, 354 insertions(+), 9 deletions(-) create mode 100644 spec/fixturebot/rails/schema_loader/mysql_spec.rb create mode 100644 spec/fixturebot/rails/schema_loader/postgresql_spec.rb create mode 100644 spec/fixturebot/rails/schema_loader/sqlite3_spec.rb rename spec/fixturebot/{ => rails}/schema_loader_spec.rb (92%) 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/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 From 4a8f458cb8b013c58ad07968385c53a0cfc64909 Mon Sep 17 00:00:00 2001 From: Chip Date: Fri, 13 Feb 2026 14:11:17 -0500 Subject: [PATCH 2/5] update README --- README.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/README.md b/README.md index 74c5bed..6060b72 100644 --- a/README.md +++ b/README.md @@ -165,6 +165,10 @@ FixtureBot.define do end ``` +#### primary key type + +FixtureBot attempts to auto-detect the primary key type of your database. It supports both `:integer` and `:uuid` primary key types and falls 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). From 5b4415597df2edc3b34675d372166b820a1d3e3c Mon Sep 17 00:00:00 2001 From: Chip Date: Fri, 13 Feb 2026 14:22:58 -0500 Subject: [PATCH 3/5] change README language --- README.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 6060b72..c1d1733 100644 --- a/README.md +++ b/README.md @@ -165,9 +165,11 @@ FixtureBot.define do end ``` -#### primary key type +#### Stable Primary Key Generation -FixtureBot attempts to auto-detect the primary key type of your database. It supports both `:integer` and `:uuid` primary key types and falls back to `:integer` if it cannot detect the type. +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 From 23d234227923be2c00d8bfce95d3453090c7a890 Mon Sep 17 00:00:00 2001 From: Chip Date: Thu, 19 Feb 2026 09:49:49 -0500 Subject: [PATCH 4/5] pr feedback --- README.md | 29 ++- lib/fixturebot/fixture_set.rb | 14 +- lib/fixturebot/key.rb | 45 ++-- lib/fixturebot/key/integer.rb | 13 ++ lib/fixturebot/key/uuid.rb | 30 +++ lib/fixturebot/rails/schema_loader.rb | 26 +-- lib/fixturebot/row.rb | 25 ++- lib/fixturebot/schema.rb | 16 +- .../rails/schema_loader/mysql_spec.rb | 4 +- .../rails/schema_loader/postgresql_spec.rb | 9 +- .../rails/schema_loader/sqlite3_spec.rb | 8 +- spec/fixturebot/rails/schema_loader_spec.rb | 24 ++- spec/fixturebot_spec.rb | 196 ++++++++++++++++-- 13 files changed, 347 insertions(+), 92 deletions(-) create mode 100644 lib/fixturebot/key/integer.rb create mode 100644 lib/fixturebot/key/uuid.rb diff --git a/README.md b/README.md index c1d1733..a22af99 100644 --- a/README.md +++ b/README.md @@ -167,7 +167,29 @@ end #### Stable Primary Key Generation -Stable IDs are generated using a deterministic algorithm that is consistent across runs. +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. @@ -280,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 diff --git a/lib/fixturebot/fixture_set.rb b/lib/fixturebot/fixture_set.rb index 136a5d4..67063e0 100644 --- a/lib/fixturebot/fixture_set.rb +++ b/lib/fixturebot/fixture_set.rb @@ -10,13 +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) + 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, - tables: schema.tables + tables: schema.tables, + id_map: id_map ) @tables[row.table][row.name] = builder.record @@ -26,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 diff --git a/lib/fixturebot/key.rb b/lib/fixturebot/key.rb index f3dade5..7907676 100644 --- a/lib/fixturebot/key.rb +++ b/lib/fixturebot/key.rb @@ -1,37 +1,24 @@ # frozen_string_literal: true -require "zlib" -require "digest/sha1" +require_relative "key/integer" +require_relative "key/uuid" 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]}" + 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 - private_class_method :uuid_v5 end end diff --git a/lib/fixturebot/key/integer.rb b/lib/fixturebot/key/integer.rb new file mode 100644 index 0000000..6f68ee6 --- /dev/null +++ b/lib/fixturebot/key/integer.rb @@ -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 diff --git a/lib/fixturebot/key/uuid.rb b/lib/fixturebot/key/uuid.rb new file mode 100644 index 0000000..efcad13 --- /dev/null +++ b/lib/fixturebot/key/uuid.rb @@ -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 diff --git a/lib/fixturebot/rails/schema_loader.rb b/lib/fixturebot/rails/schema_loader.rb index 036f51b..7e2b621 100644 --- a/lib/fixturebot/rails/schema_loader.rb +++ b/lib/fixturebot/rails/schema_loader.rb @@ -5,7 +5,6 @@ module FixtureBot module Rails class SchemaLoader - def self.load(connection = ActiveRecord::Base.connection) new(connection).load end @@ -38,11 +37,13 @@ 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) + primary_key_type = detect_primary_key_type(name, pk_name) associations = @connection.foreign_keys(name).map do |fk| Schema::BelongsTo.new( @@ -57,18 +58,19 @@ def build_table(name) singular_name: singularize(name), columns: columns, belongs_to_associations: associations, - primary_key_type: primary_key_type + primary_key_type: primary_key_type, + primary_key_column: (pk_name || "id").to_sym ) end - def detect_primary_key_type(table_name) - pk = @connection.primary_key(table_name) - return :integer unless pk + 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 } - return :integer unless column + column = @connection.columns(table_name).find { |c| c.name == pk_name } + return Key::Integer.new unless column - column.type == :uuid ? :uuid : :integer + (column.type == :uuid) ? Key::UUID.new : Key::Integer.new end def build_join_table(name) @@ -87,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) diff --git a/lib/fixturebot/row.rb b/lib/fixturebot/row.rb index 8f1cc45..71b080f 100644 --- a/lib/fixturebot/row.rb +++ b/lib/fixturebot/row.rb @@ -12,6 +12,7 @@ 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) @@ -19,6 +20,13 @@ def initialize(table, schema) 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| @@ -51,20 +59,22 @@ def define_join_table_methods(table, schema) end class Builder - def initialize(row:, table:, defaults:, join_tables:, 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 ||= generate_key_for(@table, @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] @@ -89,11 +99,10 @@ 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 + 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) diff --git a/lib/fixturebot/schema.rb b/lib/fixturebot/schema.rb index 78cfa8d..f045515 100644 --- a/lib/fixturebot/schema.rb +++ b/lib/fixturebot/schema.rb @@ -2,14 +2,9 @@ module FixtureBot class Schema - 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 - + 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 @@ -43,7 +38,7 @@ def initialize(schema) @schema = schema end - def table(name, singular:, columns: [], primary_key_type: :integer, &block) + def table(name, singular:, columns: [], primary_key_type: :integer, primary_key_column: :id, &block) associations = [] if block table_builder = TableBuilder.new(associations) @@ -54,7 +49,8 @@ def table(name, singular:, columns: [], primary_key_type: :integer, &block) singular_name: singular, columns: columns, belongs_to_associations: associations, - primary_key_type: primary_key_type + primary_key_type: primary_key_type, + primary_key_column: primary_key_column )) end diff --git a/spec/fixturebot/rails/schema_loader/mysql_spec.rb b/spec/fixturebot/rails/schema_loader/mysql_spec.rb index 4a6d8c0..a293ccc 100644 --- a/spec/fixturebot/rails/schema_loader/mysql_spec.rb +++ b/spec/fixturebot/rails/schema_loader/mysql_spec.rb @@ -38,7 +38,7 @@ 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) + it "returns Integer key because column type is :string, not :uuid" do + expect(schema.tables[:users].primary_key_type).to be_a(FixtureBot::Key::Integer) end end diff --git a/spec/fixturebot/rails/schema_loader/postgresql_spec.rb b/spec/fixturebot/rails/schema_loader/postgresql_spec.rb index 03c0c6c..5bae1c3 100644 --- a/spec/fixturebot/rails/schema_loader/postgresql_spec.rb +++ b/spec/fixturebot/rails/schema_loader/postgresql_spec.rb @@ -41,11 +41,12 @@ 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) + it "detects UUID key when column type is :uuid" do + expect(schema.tables[:users].primary_key_type).to be_a(FixtureBot::Key::UUID) + expect(schema.tables[:users].primary_key_column).to eq(:id) end - it "still detects :integer for other tables" do - expect(schema.tables[:posts].primary_key_type).to eq(:integer) + it "still detects Integer key for other tables" do + expect(schema.tables[:posts].primary_key_type).to be_a(FixtureBot::Key::Integer) end end diff --git a/spec/fixturebot/rails/schema_loader/sqlite3_spec.rb b/spec/fixturebot/rails/schema_loader/sqlite3_spec.rb index 53a4617..d4e1ce7 100644 --- a/spec/fixturebot/rails/schema_loader/sqlite3_spec.rb +++ b/spec/fixturebot/rails/schema_loader/sqlite3_spec.rb @@ -38,8 +38,8 @@ 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) + it "returns Integer key because column type is :string, not :uuid" do + expect(schema.tables[:users].primary_key_type).to be_a(FixtureBot::Key::Integer) end context "when table has no primary key" do @@ -47,8 +47,8 @@ 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) + it "returns Integer key" do + expect(schema.tables[:users].primary_key_type).to be_a(FixtureBot::Key::Integer) end end end diff --git a/spec/fixturebot/rails/schema_loader_spec.rb b/spec/fixturebot/rails/schema_loader_spec.rb index f0a5ea2..50f96f8 100644 --- a/spec/fixturebot/rails/schema_loader_spec.rb +++ b/spec/fixturebot/rails/schema_loader_spec.rb @@ -71,9 +71,29 @@ expect(schema.tables).not_to have_key(:posts_tags) end - it "defaults primary_key_type to :integer for standard tables" do + it "defaults primary_key_type to Key::Integer for standard tables" do schema.tables.each_value do |table| - expect(table.primary_key_type).to eq(:integer) + expect(table.primary_key_type).to be_a(FixtureBot::Key::Integer) + end + end + + it "detects primary_key_column as :id for standard tables" do + schema.tables.each_value do |table| + expect(table.primary_key_column).to eq(:id) + end + end + + context "when a table has a custom primary key column" do + before do + ActiveRecord::Base.connection.execute('CREATE TABLE "accounts" ("account_uid" varchar PRIMARY KEY, "name" varchar)') + end + + it "detects primary_key_column from the database" do + expect(schema.tables[:accounts].primary_key_column).to eq(:account_uid) + end + + it "excludes the custom primary key column from columns" do + expect(schema.tables[:accounts].columns).to eq([:name]) end end end diff --git a/spec/fixturebot_spec.rb b/spec/fixturebot_spec.rb index 1f618df..aad97b8 100644 --- a/spec/fixturebot_spec.rb +++ b/spec/fixturebot_spec.rb @@ -78,55 +78,83 @@ ruby_id = result.tables[:tags][:ruby][:id] rails_id = result.tables[:tags][:rails][:id] - expect(join[:hello_world_ruby]).to eq({ post_id: post_id, tag_id: ruby_id }) - expect(join[:hello_world_rails]).to eq({ post_id: post_id, tag_id: rails_id }) + expect(join[:hello_world_ruby]).to eq({post_id: post_id, tag_id: ruby_id}) + expect(join[:hello_world_rails]).to eq({post_id: post_id, tag_id: rails_id}) end end - describe FixtureBot::Key do + describe FixtureBot::Key::Integer do + subject(:key) { described_class.new } + it "generates deterministic IDs" do - id1 = FixtureBot::Key.generate(:users, :admin) - id2 = FixtureBot::Key.generate(:users, :admin) + id1 = key.generate(:users, :admin) + id2 = key.generate(:users, :admin) expect(id1).to eq(id2) end it "generates positive integers" do - id = FixtureBot::Key.generate(:users, :admin) + id = key.generate(:users, :admin) expect(id).to be > 0 end it "generates different IDs for different records" do - id1 = FixtureBot::Key.generate(:users, :admin) - id2 = FixtureBot::Key.generate(:users, :reader) + id1 = key.generate(:users, :admin) + id2 = key.generate(:users, :reader) expect(id1).not_to eq(id2) end end - describe FixtureBot::Key, ".generate_uuid" do + describe FixtureBot::Key::UUID do + subject(:key) { described_class.new } + it "generates deterministic UUIDs" do - uuid1 = FixtureBot::Key.generate_uuid(:users, :admin) - uuid2 = FixtureBot::Key.generate_uuid(:users, :admin) + uuid1 = key.generate(:users, :admin) + uuid2 = key.generate(:users, :admin) expect(uuid1).to eq(uuid2) end it "generates valid UUID v5 format" do - uuid = FixtureBot::Key.generate_uuid(:users, :admin) + uuid = key.generate(: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) + uuid1 = key.generate(:users, :admin) + uuid2 = key.generate(: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) + uuid1 = key.generate(:users, :admin) + uuid2 = key.generate(:posts, :admin) expect(uuid1).not_to eq(uuid2) end end + describe FixtureBot::Key, ".resolve" do + it "resolves :integer to Key::Integer" do + expect(FixtureBot::Key.resolve(:integer)).to be_a(FixtureBot::Key::Integer) + end + + it "resolves :uuid to Key::UUID" do + expect(FixtureBot::Key.resolve(:uuid)).to be_a(FixtureBot::Key::UUID) + end + + it "passes through objects responding to #generate" do + custom = Object.new + def custom.generate(table_name, record_name) = 1 + expect(FixtureBot::Key.resolve(custom)).to be(custom) + end + + it "raises ArgumentError for unsupported symbols" do + expect { FixtureBot::Key.resolve(:bigint) }.to raise_error(ArgumentError, /unsupported primary key type/) + end + + it "raises ArgumentError for objects not responding to #generate" do + expect { FixtureBot::Key.resolve("not a key") }.to raise_error(ArgumentError, /respond to #generate/) + end + end + describe "UUID primary key support" do let(:schema) do FixtureBot::Schema.define do @@ -260,7 +288,6 @@ expect(result.tables[:users][:alice][:email]).to eq("alice@blog.test") end - end describe "unknown method errors" do @@ -298,12 +325,143 @@ end describe "primary_key_type validation" do - it "raises ArgumentError for unsupported primary_key_type" do + it "raises ArgumentError for unsupported primary key type symbol" 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/) + }.to raise_error(ArgumentError, /unsupported primary key type: :bigint/) + end + + it "raises ArgumentError for objects not responding to #generate" do + not_a_key = Object.new + expect { + FixtureBot::Schema.define do + table :users, singular: :user, columns: [:name], primary_key_type: not_a_key + end + }.to raise_error(ArgumentError, /respond to #generate/) + end + + it "accepts a custom Key strategy object" do + custom = Object.new + def custom.generate(table_name, record_name) = 1 + + schema = FixtureBot::Schema.define do + table :users, singular: :user, columns: [:name], primary_key_type: custom + end + + expect(schema.tables[:users].primary_key_type).to be(custom) + end + end + + describe "hardcoded IDs" do + let(:schema) do + FixtureBot::Schema.define do + table :users, singular: :user, columns: [:name, :email] + table :posts, singular: :post, columns: [:title, :author_id] do + belongs_to :author, table: :users + end + end + end + + it "uses the hardcoded integer id" do + result = FixtureBot.define(schema) do + user :admin do + id 42 + name "Brad" + end + end + + expect(result.tables[:users][:admin][:id]).to eq(42) + end + + it "uses the hardcoded string id" do + result = FixtureBot.define(schema) do + user :admin do + id "550e8400-e29b-41d4-a716-446655440000" + name "Brad" + end + end + + expect(result.tables[:users][:admin][:id]).to eq("550e8400-e29b-41d4-a716-446655440000") + end + + it "falls back to generated id when not hardcoded" do + result = FixtureBot.define(schema) do + user :admin do + name "Brad" + end + end + + expect(result.tables[:users][:admin][:id]).to be_a(Integer) + end + + it "resolves belongs_to references to hardcoded ids" do + result = FixtureBot.define(schema) do + user :admin do + id 42 + name "Brad" + end + + post :hello do + title "Hello" + author :admin + end + end + + expect(result.tables[:posts][:hello][:author_id]).to eq(42) + end + end + + describe "custom primary key column" do + let(:schema) do + FixtureBot::Schema.define do + table :users, singular: :user, columns: [:name], + primary_key_type: :uuid, primary_key_column: :custom_primary_key_col + table :posts, singular: :post, columns: [:title, :author_id] do + belongs_to :author, table: :users + end + end + end + + it "uses the custom column name in record output" do + result = FixtureBot.define(schema) do + user :admin do + name "Brad" + end + end + + admin = result.tables[:users][:admin] + expect(admin).to have_key(:custom_primary_key_col) + expect(admin).not_to have_key(:id) + expect(admin[:custom_primary_key_col]).to match(/\A[0-9a-f]{8}-/) + end + + it "allows hardcoding via the custom column name" do + result = FixtureBot.define(schema) do + user :admin do + custom_primary_key_col "550e8400-e29b-41d4-a716-446655440000" + name "Brad" + end + end + + expect(result.tables[:users][:admin][:custom_primary_key_col]).to eq("550e8400-e29b-41d4-a716-446655440000") + end + + it "resolves belongs_to foreign keys to the hardcoded value" do + result = FixtureBot.define(schema) do + user :admin do + custom_primary_key_col "550e8400-e29b-41d4-a716-446655440000" + name "Brad" + end + + post :hello do + title "Hello" + author :admin + end + end + + expect(result.tables[:posts][:hello][:author_id]).to eq("550e8400-e29b-41d4-a716-446655440000") end end end From 84fa16be3ffb7520b9374af9c50a3e62c5817e43 Mon Sep 17 00:00:00 2001 From: Chip Date: Thu, 19 Feb 2026 09:57:50 -0500 Subject: [PATCH 5/5] move specs into directories --- spec/fixturebot/define_primary_keys_spec.rb | 95 ++++++++++++ spec/fixturebot/key/integer_spec.rb | 22 +++ spec/fixturebot/key/uuid_spec.rb | 28 ++++ spec/fixturebot/key_spec.rb | 25 +++ spec/fixturebot_spec.rb | 164 -------------------- 5 files changed, 170 insertions(+), 164 deletions(-) create mode 100644 spec/fixturebot/define_primary_keys_spec.rb create mode 100644 spec/fixturebot/key/integer_spec.rb create mode 100644 spec/fixturebot/key/uuid_spec.rb create mode 100644 spec/fixturebot/key_spec.rb diff --git a/spec/fixturebot/define_primary_keys_spec.rb b/spec/fixturebot/define_primary_keys_spec.rb new file mode 100644 index 0000000..1f08800 --- /dev/null +++ b/spec/fixturebot/define_primary_keys_spec.rb @@ -0,0 +1,95 @@ +# frozen_string_literal: true + +RSpec.describe FixtureBot do + 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 +end diff --git a/spec/fixturebot/key/integer_spec.rb b/spec/fixturebot/key/integer_spec.rb new file mode 100644 index 0000000..a873da9 --- /dev/null +++ b/spec/fixturebot/key/integer_spec.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +RSpec.describe FixtureBot::Key::Integer do + subject(:key) { described_class.new } + + it "generates deterministic IDs" do + id1 = key.generate(:users, :admin) + id2 = key.generate(:users, :admin) + expect(id1).to eq(id2) + end + + it "generates positive integers" do + id = key.generate(:users, :admin) + expect(id).to be > 0 + end + + it "generates different IDs for different records" do + id1 = key.generate(:users, :admin) + id2 = key.generate(:users, :reader) + expect(id1).not_to eq(id2) + end +end diff --git a/spec/fixturebot/key/uuid_spec.rb b/spec/fixturebot/key/uuid_spec.rb new file mode 100644 index 0000000..bbbb5d4 --- /dev/null +++ b/spec/fixturebot/key/uuid_spec.rb @@ -0,0 +1,28 @@ +# frozen_string_literal: true + +RSpec.describe FixtureBot::Key::UUID do + subject(:key) { described_class.new } + + it "generates deterministic UUIDs" do + uuid1 = key.generate(:users, :admin) + uuid2 = key.generate(:users, :admin) + expect(uuid1).to eq(uuid2) + end + + it "generates valid UUID v5 format" do + uuid = key.generate(: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 = key.generate(:users, :admin) + uuid2 = key.generate(:users, :reader) + expect(uuid1).not_to eq(uuid2) + end + + it "generates different UUIDs for same name in different tables" do + uuid1 = key.generate(:users, :admin) + uuid2 = key.generate(:posts, :admin) + expect(uuid1).not_to eq(uuid2) + end +end diff --git a/spec/fixturebot/key_spec.rb b/spec/fixturebot/key_spec.rb new file mode 100644 index 0000000..b868412 --- /dev/null +++ b/spec/fixturebot/key_spec.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +RSpec.describe FixtureBot::Key, ".resolve" do + it "resolves :integer to Key::Integer" do + expect(FixtureBot::Key.resolve(:integer)).to be_a(FixtureBot::Key::Integer) + end + + it "resolves :uuid to Key::UUID" do + expect(FixtureBot::Key.resolve(:uuid)).to be_a(FixtureBot::Key::UUID) + end + + it "passes through objects responding to #generate" do + custom = Object.new + def custom.generate(table_name, record_name) = 1 + expect(FixtureBot::Key.resolve(custom)).to be(custom) + end + + it "raises ArgumentError for unsupported symbols" do + expect { FixtureBot::Key.resolve(:bigint) }.to raise_error(ArgumentError, /unsupported primary key type/) + end + + it "raises ArgumentError for objects not responding to #generate" do + expect { FixtureBot::Key.resolve("not a key") }.to raise_error(ArgumentError, /respond to #generate/) + end +end diff --git a/spec/fixturebot_spec.rb b/spec/fixturebot_spec.rb index aad97b8..3d9ef16 100644 --- a/spec/fixturebot_spec.rb +++ b/spec/fixturebot_spec.rb @@ -83,170 +83,6 @@ end end - describe FixtureBot::Key::Integer do - subject(:key) { described_class.new } - - it "generates deterministic IDs" do - id1 = key.generate(:users, :admin) - id2 = key.generate(:users, :admin) - expect(id1).to eq(id2) - end - - it "generates positive integers" do - id = key.generate(:users, :admin) - expect(id).to be > 0 - end - - it "generates different IDs for different records" do - id1 = key.generate(:users, :admin) - id2 = key.generate(:users, :reader) - expect(id1).not_to eq(id2) - end - end - - describe FixtureBot::Key::UUID do - subject(:key) { described_class.new } - - it "generates deterministic UUIDs" do - uuid1 = key.generate(:users, :admin) - uuid2 = key.generate(:users, :admin) - expect(uuid1).to eq(uuid2) - end - - it "generates valid UUID v5 format" do - uuid = key.generate(: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 = key.generate(:users, :admin) - uuid2 = key.generate(:users, :reader) - expect(uuid1).not_to eq(uuid2) - end - - it "generates different UUIDs for same name in different tables" do - uuid1 = key.generate(:users, :admin) - uuid2 = key.generate(:posts, :admin) - expect(uuid1).not_to eq(uuid2) - end - end - - describe FixtureBot::Key, ".resolve" do - it "resolves :integer to Key::Integer" do - expect(FixtureBot::Key.resolve(:integer)).to be_a(FixtureBot::Key::Integer) - end - - it "resolves :uuid to Key::UUID" do - expect(FixtureBot::Key.resolve(:uuid)).to be_a(FixtureBot::Key::UUID) - end - - it "passes through objects responding to #generate" do - custom = Object.new - def custom.generate(table_name, record_name) = 1 - expect(FixtureBot::Key.resolve(custom)).to be(custom) - end - - it "raises ArgumentError for unsupported symbols" do - expect { FixtureBot::Key.resolve(:bigint) }.to raise_error(ArgumentError, /unsupported primary key type/) - end - - it "raises ArgumentError for objects not responding to #generate" do - expect { FixtureBot::Key.resolve("not a key") }.to raise_error(ArgumentError, /respond to #generate/) - 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