Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -8,3 +8,4 @@
/tmp/
/.history/
.idea
/gemfiles/*.lock
5 changes: 5 additions & 0 deletions .rubocop.yml
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
AllCops:
NewCops: disable
TargetRubyVersion: 2.7

Layout/LineLength:
Max: 140
Expand All @@ -15,3 +16,7 @@ Metrics/AbcSize:

Style/Documentation:
Enabled: False

Metrics/BlockLength:
Exclude:
- 'spec/**/*'
41 changes: 0 additions & 41 deletions Gemfile.lock
Original file line number Diff line number Diff line change
Expand Up @@ -25,52 +25,24 @@ GEM
minitest (>= 5.1)
mutex_m
tzinfo (~> 2.0)
addressable (2.8.0)
public_suffix (>= 2.0.2, < 5.0)
ast (2.4.2)
backports (3.6.8)
base64 (0.2.0)
bigdecimal (3.1.6)
concurrent-ruby (1.2.3)
connection_pool (2.4.1)
diff-lcs (1.5.0)
drb (2.2.0)
ruby2_keywords
ethon (0.8.1)
ffi (>= 1.3.0)
faraday (0.9.2)
multipart-post (>= 1.2, < 3)
faraday_middleware (0.10.0)
faraday (>= 0.7.4, < 0.10)
ffi (1.13.1)
gh (0.14.0)
addressable
backports
faraday (~> 0.8)
multi_json (~> 1.0)
net-http-persistent (>= 2.7)
net-http-pipeline
highline (1.7.8)
i18n (1.14.1)
concurrent-ruby (~> 1.0)
json (2.3.1)
launchy (2.4.3)
addressable (~> 2.3)
minitest (5.22.2)
multi_json (1.11.2)
multipart-post (2.0.0)
mutex_m (0.2.0)
mysql-binuuid-rails (1.3.0)
activerecord (>= 5)
net-http-persistent (2.9.4)
net-http-pipeline (1.0.1)
parallel (1.22.1)
parser (3.1.2.0)
ast (~> 2.4.1)
public_suffix (4.0.7)
pusher-client (0.6.2)
json
websocket (~> 1.0)
rainbow (3.1.1)
rake (13.0.6)
regexp_parser (2.5.0)
Expand Down Expand Up @@ -103,22 +75,10 @@ GEM
ruby-progressbar (1.11.0)
ruby2_keywords (0.0.5)
timeout (0.4.1)
travis (1.8.2)
backports
faraday (~> 0.9)
faraday_middleware (~> 0.9, >= 0.9.1)
gh (~> 0.13)
highline (~> 1.6)
launchy (~> 2.1)
pusher-client (~> 0.4)
typhoeus (~> 0.6, >= 0.6.8)
typhoeus (0.8.0)
ethon (>= 0.8.0)
tzinfo (2.0.6)
concurrent-ruby (~> 1.0)
unicode-display_width (2.2.0)
uuidtools (3.0.0)
websocket (1.2.2)

PLATFORMS
ruby
Expand All @@ -128,7 +88,6 @@ DEPENDENCIES
rake (~> 13.0)
rspec (~> 3.0)
rubocop
travis (~> 1.8, >= 1.8.2)
uuidable!

BUNDLED WITH
Expand Down
48 changes: 48 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,10 +46,58 @@ class CreateProjects < ActiveRecord::Migration
end
```

## UUID storage

New UUID columns store 16 bytes (`varbinary(16)`). Versions before 1.0 stored the 36-character text form (`varbinary(36)`). Models read both as UUID strings (16-byte columns are recognized by `uuid` in their name), and upgrading the gem does not change existing columns.

Choose the format of a new column with `storage:`:

```ruby
t.uuid storage: :text
add_uuid_column :projects, :parent_uuid, storage: :binary
```

An app that keeps its pre-1.0 columns can make text the default, so that new columns match the existing ones:

```ruby
# config/initializers/uuidable.rb
Uuidable.default_storage = :text
```

Columns compared in SQL, for example in a join, must use the same storage: a 16-byte UUID never equals its 36-character text form.

## Converting pre-1.0 columns

Converting existing columns is optional. On MySQL 8, include the helpers in a migration:

```ruby
class ConvertProjectUuids < ActiveRecord::Migration[7.2]
include Uuidable::V1MigrationHelpers

def change
# Every 36-byte column with `uuid` in its name, or only the listed ones:
uuidable_migrate_uuid_columns_to_v1 :projects
uuidable_migrate_uuid_columns_to_v1 :tasks, { 'project_uuid' => {} }
end
end
```

The original values stay in `*__old` columns, which models keep writing while they exist, so the migration can be rolled back. When you no longer need that, drop them with `uuidable_drop_all_pre_v1_uuid_columns!`; this cannot be undone.

- Each step is a single `ALTER TABLE`. If the conversion fails, for example on a value that is not a UUID or on two UUIDs that differ only in case under a unique index, the table is left unchanged.
- Rows written during the conversion are converted too, but adding the converted columns rebuilds the table and blocks writes meanwhile. Restart the application afterwards: running processes keep the old column types.
- Columns that are part of a primary or foreign key are refused; drop the key first.
- A rollback is refused if rows changed without the model callbacks, for example with `update_all`, because the `*__old` columns miss those changes. Inserts that skip callbacks, such as `insert_all`, fail while a `NOT NULL` `*__old` column exists.

## Development

After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake spec` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment.

Specs that need a database are skipped unless `UUIDABLE_DATABASE_URL` points to a MySQL 8 database whose name ends in `_test`. The specs create and drop their own tables. To run them against a specific Active Record version:

BUNDLE_GEMFILE=gemfiles/activerecord.gemfile ACTIVE_RECORD_VERSION='~> 8.1.0' \
UUIDABLE_DATABASE_URL=mysql2://root@127.0.0.1:3306/uuidable_test bundle exec rake

To install this gem onto your local machine, run `bundle exec rake install`. To release a new version, update the version number in `version.rb`, and then run `bundle exec rake release`, which will create a git tag for the version, push git commits and tags, and push the `.gem` file to [rubygems.org](https://rubygems.org).

## Contributing
Expand Down
8 changes: 8 additions & 0 deletions gemfiles/activerecord.gemfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
# frozen_string_literal: true

# Runs the suite against one Active Record version and a MySQL database:
# BUNDLE_GEMFILE=gemfiles/activerecord.gemfile ACTIVE_RECORD_VERSION='~> 8.1.0' bundle exec rake
eval_gemfile File.expand_path('../Gemfile', __dir__)

gem 'activerecord', ENV.fetch('ACTIVE_RECORD_VERSION')
gem 'mysql2', '~> 0.5'

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Should fix: on Ruby 3.4 with Active Record 6.1 or 7.0 require 'active_record' fails with cannot load such file -- mutex_m, while the README says the suite runs against any supported version. Conditional mutex_m, bigdecimal, base64 and drb for AR < 7.1 make the gemfile work on current Ruby.

52 changes: 38 additions & 14 deletions lib/uuidable/active_record.rb
Original file line number Diff line number Diff line change
Expand Up @@ -17,24 +17,48 @@ def find(*args)
end
end

# ClassMethods
module ClassMethods
include Finder
# Reads 16-byte UUID columns as UUID strings. The types are installed when the
# model loads its schema, so defining a model needs no database connection.
module SchemaTypes
# Rails 7.2 started asking the model for the type of each column.
def self.type_for_column_hook?
::ActiveRecord::ModelSchema::ClassMethods.private_method_defined?(:type_for_column)
end

def uuidable(as_param: true) # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
# Configure all uuid columns for MySQL. Database may not be connected (i.e. on assets precompile), so we must supress errors.
conn_config = respond_to?(:connection_db_config) ? connection_db_config.configuration_hash : connection_config
private

if conn_config[:adapter].include?('mysql') && connection.data_source_exists?(table_name)
begin
columns.select { |c| c.type == :binary && c.limit == 16 && c.name.include?('uuid') }.each do |column|
attribute column.name.to_sym, MySQLBinUUID::Type.new
end
def load_schema!
super
install_uuid_types unless SchemaTypes.type_for_column_hook?
include V1ModelMigration if columns_hash.each_key.any? { |name| name.include?(V1MigrationHelpers::OLD_POSTFIX) }

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Should fix: V1ModelMigration is included whenever any column name contains __old, not only UUID columns. Its before_validation then overwrites every *__old attribute with the value of the same-named column without the suffix: with a notes__old = 'legacy' column next to notes, a save! turns it into 'edited'. uuidable_drop_all_pre_v1_uuid_columns! was tightened to include?('uuid') in this diff; the same guard belongs here and in the callback's attribute loop.

end

include V1ModelMigration if columns.any? { |c| c.name.include?(V1MigrationHelpers::OLD_POSTFIX) }
rescue ::ActiveRecord::ConnectionNotEstablished, Mysql2::Error::ConnectionError, ::ActiveRecord::NoDatabaseError # rubocop:disable Lint/SuppressedException
end
def type_for_column(connection, column)
uuid_column?(connection, column) ? MySQLBinUUID::Type.new : super
end

# Before Rails 7.2: replace the schema types, keeping types declared with `attribute`.
def install_uuid_types
columns_hash.each_value do |column|
next if !uuid_column?(connection, column) || attributes_to_define_after_schema_loads.key?(column.name)

define_attribute(column.name, MySQLBinUUID::Type.new, default: column.default, user_provided_default: false)
end
end

def uuid_column?(connection, column)
column.type == :binary && column.limit == 16 && column.name.include?('uuid') &&
connection.adapter_name.downcase.include?('mysql')
end
end

# ClassMethods
module ClassMethods
include Finder

def uuidable(as_param: true) # rubocop:disable Metrics/AbcSize, Metrics/PerceivedComplexity
singleton_class.prepend(SchemaTypes) unless singleton_class.include?(SchemaTypes)
reload_schema_from_cache if schema_loaded?

after_initialize do
self.uuid = Uuidable.generate_uuid if attributes.keys.include?('uuid') && uuid.blank?
Expand Down
55 changes: 49 additions & 6 deletions lib/uuidable/migration.rb
Original file line number Diff line number Diff line change
Expand Up @@ -5,29 +5,72 @@ module Uuidable
COLUMN_TYPE = :binary
COLUMN_OPTIONS = { limit: 16, null: false }.freeze
INDEX_OPTIONS = { unique: true }.freeze
STORAGE_LIMITS = { binary: 16, text: 36 }.freeze

class << self
# Name of a new UUID column, given positionally or with the pre-1.0 `column_name:` option.
def column_name(positional, keyword)
if positional && keyword && positional.to_s != keyword.to_s
raise ArgumentError, "UUID column named twice: #{positional.inspect} and column_name: #{keyword.inspect}"
end

positional || keyword || COLUMN_NAME
end

# Storage for new UUID columns that set neither `storage:` nor `limit:`.
# Apps upgrading from 0.x can set :text so new columns match their 36-byte ones.
def default_storage

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Nit: default_storage is read when a migration runs, so the migration history stops being reproducible once the setting changes: a bare t.uuid gives 16 bytes in production and 36 bytes in a database rebuilt from scratch. The README should at least say the setting has to be in place before the first migration run on 1.x.

@default_storage || :binary
end

def default_storage=(storage)
storage_limit(storage)
@default_storage = storage
end

# Column options for a new UUID column, resolving `storage:` into a limit.
def column_options(options)
options = options.dup
storage = options.delete(:storage)
limit = storage_limit(storage || default_storage)
if storage && options.key?(:limit) && options[:limit] != limit
raise ArgumentError, "storage: #{storage.inspect} conflicts with limit: #{options[:limit].inspect}"
end

COLUMN_OPTIONS.merge(limit: limit).merge(options)
end

private

def storage_limit(storage)
STORAGE_LIMITS.fetch(storage) do
raise ArgumentError, "UUID storage must be one of #{STORAGE_LIMITS.keys.inspect}, got #{storage.inspect}"
end
end
end

# Module adds method to table definition
module TableDefinition
def uuid(column_name = COLUMN_NAME, **opts)
def uuid(column_name = nil, **opts)
index_opts = opts.delete(:index)
index_opts = {} if index_opts.nil?

column_name ||= opts.delete(:column_name)
column_name = Uuidable.column_name(column_name, opts.delete(:column_name))

column column_name, COLUMN_TYPE, **COLUMN_OPTIONS.merge(opts)
column column_name, COLUMN_TYPE, **Uuidable.column_options(opts)
index column_name, **INDEX_OPTIONS.merge(index_opts) if index_opts
end
end

# Module adds method to alter table migration
module Migration
def add_uuid_column(table_name, column_name = COLUMN_NAME, **opts)
def add_uuid_column(table_name, column_name = nil, **opts)
index_opts = opts.delete(:index)
index_opts = {} if index_opts == true

column_name ||= opts.delete(:column_name)
column_name = Uuidable.column_name(column_name, opts.delete(:column_name))

add_column table_name, column_name, COLUMN_TYPE, **COLUMN_OPTIONS.merge(opts)
add_column table_name, column_name, COLUMN_TYPE, **Uuidable.column_options(opts)

add_uuid_index(table_name, index_opts.merge(column_name: column_name)) if index_opts
end
Expand Down
Loading