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..a22af99 100644 --- a/README.md +++ b/README.md @@ -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). @@ -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 diff --git a/lib/fixturebot/fixture_set.rb b/lib/fixturebot/fixture_set.rb index 3213922..67063e0 100644 --- a/lib/fixturebot/fixture_set.rb +++ b/lib/fixturebot/fixture_set.rb @@ -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) + 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 @@ -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 diff --git a/lib/fixturebot/key.rb b/lib/fixturebot/key.rb index fb15628..7907676 100644 --- a/lib/fixturebot/key.rb +++ b/lib/fixturebot/key.rb @@ -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 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 a95ccf9..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,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), @@ -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) @@ -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) diff --git a/lib/fixturebot/row.rb b/lib/fixturebot/row.rb index d0565b4..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,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] @@ -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 { @@ -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 diff --git a/lib/fixturebot/schema.rb b/lib/fixturebot/schema.rb index c9e823d..f045515 100644 --- a/lib/fixturebot/schema.rb +++ b/lib/fixturebot/schema.rb @@ -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) @@ -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) @@ -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 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/rails/schema_loader/mysql_spec.rb b/spec/fixturebot/rails/schema_loader/mysql_spec.rb new file mode 100644 index 0000000..a293ccc --- /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 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 new file mode 100644 index 0000000..5bae1c3 --- /dev/null +++ b/spec/fixturebot/rails/schema_loader/postgresql_spec.rb @@ -0,0 +1,52 @@ +# 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 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 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 new file mode 100644 index 0000000..d4e1ce7 --- /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 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 + before do + allow(connection).to receive(:primary_key).with("users").and_return(nil) + end + + 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/schema_loader_spec.rb b/spec/fixturebot/rails/schema_loader_spec.rb similarity index 72% rename from spec/fixturebot/schema_loader_spec.rb rename to spec/fixturebot/rails/schema_loader_spec.rb index b9a2e02..50f96f8 100644 --- a/spec/fixturebot/schema_loader_spec.rb +++ b/spec/fixturebot/rails/schema_loader_spec.rb @@ -70,4 +70,30 @@ 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 Key::Integer for standard tables" do + schema.tables.each_value do |table| + 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 cc4c558..3d9ef16 100644 --- a/spec/fixturebot_spec.rb +++ b/spec/fixturebot_spec.rb @@ -78,27 +78,8 @@ 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 }) - end - end - - describe FixtureBot::Key do - it "generates deterministic IDs" do - id1 = FixtureBot::Key.generate(:users, :admin) - id2 = FixtureBot::Key.generate(:users, :admin) - expect(id1).to eq(id2) - end - - it "generates positive integers" do - id = FixtureBot::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) - expect(id1).not_to eq(id2) + 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 @@ -143,7 +124,6 @@ expect(result.tables[:users][:alice][:email]).to eq("alice@blog.test") end - end describe "unknown method errors" do @@ -179,4 +159,145 @@ }.to raise_error(NoMethodError) end end + + describe "primary_key_type validation" 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/) + 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