diff --git a/app/models/concerns/uuid_primary_key.rb b/app/models/concerns/uuid_primary_key.rb index 6f5ddf9a9..c905263ef 100644 --- a/app/models/concerns/uuid_primary_key.rb +++ b/app/models/concerns/uuid_primary_key.rb @@ -10,60 +10,6 @@ module UuidPrimaryKey uuid_to_base36(SecureRandom.uuid_v7) end - def self.generate_fixture_uuid(label) - # Generate deterministic UUIDv7 for fixtures that sorts by fixture ID - # This allows .first/.last to work as expected in tests - # Use the same CRC32 algorithm as Rails' default fixture ID generation - # so that UUIDs sort in the same order as integer IDs - fixture_int = Zlib.crc32("fixtures/#{label}") % (2**30 - 1) - - # Use fixture_int as second offset from a fixed base time (1 year before 2025-01-01) - # This ensures all fixtures are in the past, and new test records are newest - base_time = Time.utc(2024, 1, 1, 0, 0, 0) - timestamp = base_time + fixture_int.seconds - - uuid_v7_with_timestamp(timestamp, label) - end - - def self.uuid_v7_with_timestamp(time, seed_string) - # Generate UUIDv7 with custom timestamp and deterministic random bits - # Format: 48-bit timestamp_ms | 12-bit random | 4-bit version | 62-bit random - - timestamp_ms = (time.to_f * 1000).to_i - - # 48-bit timestamp (milliseconds since epoch) - bytes = [] - bytes[0] = (timestamp_ms >> 40) & 0xff - bytes[1] = (timestamp_ms >> 32) & 0xff - bytes[2] = (timestamp_ms >> 24) & 0xff - bytes[3] = (timestamp_ms >> 16) & 0xff - bytes[4] = (timestamp_ms >> 8) & 0xff - bytes[5] = timestamp_ms & 0xff - - # Derive deterministic "random" bits from seed_string - hash = Digest::MD5.hexdigest(seed_string) - - # 12-bit random + 4-bit version (0111 for v7) - rand_a = hash[0...3].to_i(16) & 0xfff - bytes[6] = ((rand_a >> 8) & 0x0f) | 0x70 # version 7 - bytes[7] = rand_a & 0xff - - # 2-bit variant (10) + 62-bit random - rand_b = hash[3...19].to_i(16) & ((2**62) - 1) - bytes[8] = ((rand_b >> 56) & 0x3f) | 0x80 # variant 10 - bytes[9] = (rand_b >> 48) & 0xff - bytes[10] = (rand_b >> 40) & 0xff - bytes[11] = (rand_b >> 32) & 0xff - bytes[12] = (rand_b >> 24) & 0xff - bytes[13] = (rand_b >> 16) & 0xff - bytes[14] = (rand_b >> 8) & 0xff - bytes[15] = rand_b & 0xff - - # Format as UUID string - uuid = "%02x%02x%02x%02x-%02x%02x-%02x%02x-%02x%02x-%02x%02x%02x%02x%02x%02x" % bytes - uuid_to_base36(uuid) - end - def self.uuid_to_base36(uuid) # Convert standard UUID format to base36 (lowercase), padded to 25 chars uuid.delete('-').to_i(16).to_s(36).rjust(25, '0') diff --git a/test/test_helper.rb b/test/test_helper.rb index 90eab177e..14eddd534 100644 --- a/test/test_helper.rb +++ b/test/test_helper.rb @@ -82,7 +82,63 @@ module FixturesTestHelper # Rails passes :string for varchar columns, so handle both :uuid and :string return super(label, column_type) unless column_type.in?([:uuid, :string]) - UuidPrimaryKey.generate_fixture_uuid(label) + generate_fixture_uuid(label) + end + + private + + def generate_fixture_uuid(label) + # Generate deterministic UUIDv7 for fixtures that sorts by fixture ID + # This allows .first/.last to work as expected in tests + # Use the same CRC32 algorithm as Rails' default fixture ID generation + # so that UUIDs sort in the same order as integer IDs + fixture_int = Zlib.crc32("fixtures/#{label}") % (2**30 - 1) + + # Use fixture_int as second offset from a fixed base time (1 year before 2025-01-01) + # This ensures all fixtures are in the past, and new test records are newest + base_time = Time.utc(2024, 1, 1, 0, 0, 0) + timestamp = base_time + fixture_int.seconds + + uuid_v7_with_timestamp(timestamp, label) + end + + def uuid_v7_with_timestamp(time, seed_string) + # Generate UUIDv7 with custom timestamp and deterministic random bits + # Format: 48-bit timestamp_ms | 12-bit random | 4-bit version | 62-bit random + + timestamp_ms = (time.to_f * 1000).to_i + + # 48-bit timestamp (milliseconds since epoch) + bytes = [] + bytes[0] = (timestamp_ms >> 40) & 0xff + bytes[1] = (timestamp_ms >> 32) & 0xff + bytes[2] = (timestamp_ms >> 24) & 0xff + bytes[3] = (timestamp_ms >> 16) & 0xff + bytes[4] = (timestamp_ms >> 8) & 0xff + bytes[5] = timestamp_ms & 0xff + + # Derive deterministic "random" bits from seed_string + hash = Digest::MD5.hexdigest(seed_string) + + # 12-bit random + 4-bit version (0111 for v7) + rand_a = hash[0...3].to_i(16) & 0xfff + bytes[6] = ((rand_a >> 8) & 0x0f) | 0x70 # version 7 + bytes[7] = rand_a & 0xff + + # 2-bit variant (10) + 62-bit random + rand_b = hash[3...19].to_i(16) & ((2**62) - 1) + bytes[8] = ((rand_b >> 56) & 0x3f) | 0x80 # variant 10 + bytes[9] = (rand_b >> 48) & 0xff + bytes[10] = (rand_b >> 40) & 0xff + bytes[11] = (rand_b >> 32) & 0xff + bytes[12] = (rand_b >> 24) & 0xff + bytes[13] = (rand_b >> 16) & 0xff + bytes[14] = (rand_b >> 8) & 0xff + bytes[15] = rand_b & 0xff + + # Format as UUID string + uuid = "%02x%02x%02x%02x-%02x%02x-%02x%02x-%02x%02x-%02x%02x%02x%02x%02x%02x" % bytes + UuidPrimaryKey.uuid_to_base36(uuid) end end end