diff --git a/CHANGELOG.md b/CHANGELOG.md index 1748552..a29f89d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/) a ### Added - The ability to configure the random generator for the gem via `KSUID.configure`. This allows you to set up random generation to the specifications you need, whether that is for speed or for security. +- A plugin for Sequel to support KSUID fields. You can include the plugin via `plugin :ksuid` within a `Sequel::Model` class. By default, the field will be saved as a string-serialized field; if you prefer binary KSUIDs, you can pass the `binary: true` option. You can also wrap the KSUID field in a typed value with `wrap: true`. ### Changed diff --git a/Gemfile b/Gemfile index b949585..9e4a0cb 100644 --- a/Gemfile +++ b/Gemfile @@ -32,5 +32,8 @@ group :ci do end group :test do + gem 'jdbc-sqlite3', platforms: %i[jruby] gem 'rspec', '~> 3.6' + gem 'sequel' + gem 'sqlite3', platforms: %i[mri mingw x64_mingw] end diff --git a/README.md b/README.md index 3bb3070..6a74995 100644 --- a/README.md +++ b/README.md @@ -100,6 +100,44 @@ KSUID.configure do |config| end ``` +### Using with Sequel + +When using Sequel, you can enable a plugin to turn a field into an auto-generated KSUID. Because Sequel favors being explicit, nearly everything about the plugin is opt-in. The only default behavior is as follows: + +1. The default column name is `ksuid` +2. The field is automatically generated prior to validation + +There are two steps to use KSUIDs within Sequel. First, you will need to add a column to your model for the KSUID. By default, the plugin uses string serialization for its field, which looks like this: + +```ruby +DB.create_table(:events) do + String :my_field_name +end +``` + +If you wish to use a binary-serialized column, you can use the `blob` method: + +```ruby +DB.create_table(:events) do + blob :ksuid +end +``` + +To use the KSUID plugin, activate it within your model: + +```ruby +class Event < Sequel::Model(:events) + plugin :ksuid +end +``` + +During this activation, there are a few options you can choose to enable: + +* `binary: true` - If you prefer binary KSUIDs, you can switch from the default string serialization by specifying that you want it to be a binary field. +* `field: ` - By default, the column is named `ksuid`. If you want to specify a different name, you can use the `field` option to name it what you like. +* `force: true` - Typically, you will want to generate a KSUID when you're saving a record. If you want to ensure this happens, you can force the plugin to overwrite the field when doing the first save for a record. Note that the plugin will overwrite a manually set value in this mode. +* `wrap: true` - By default, the plugin will return your KSUID in its string- (or binary-) serialized form instead of as the KSUID type. If you want to wrap the accessors for the field to make them use the KSUID type, you can tell the plugin to wrap the field. + ## Contributing So you’re interested in contributing to KSUID? Check out our [contributing guidelines](CONTRIBUTING.md) for more information on how to do that. diff --git a/config.reek b/config.reek index 7b6875a..01acbbf 100644 --- a/config.reek +++ b/config.reek @@ -2,6 +2,7 @@ ManualDispatch: exclude: - "KSUID::Configuration#assert_generator_is_callable" + - "Sequel::Plugins::Ksuid::InstanceMethods#set_ksuid" UncommunicativeModuleName: exclude: diff --git a/lib/ksuid.rb b/lib/ksuid.rb index 2f20080..c475436 100644 --- a/lib/ksuid.rb +++ b/lib/ksuid.rb @@ -3,6 +3,7 @@ require_relative 'ksuid/configuration' require_relative 'ksuid/type' require_relative 'ksuid/version' +require_relative 'sequel/plugins/ksuid' if defined?(Sequel) # The K-Sortable Unique IDentifier (KSUID) # @@ -75,6 +76,28 @@ module KSUID # @return [String] MAX_STRING_ENCODED = 'aWgEPTl1tmebfsQzFP4bxwgy80V' + # Converts a KSUID-compatible value into an actual KSUID + # + # @api public + # + # @example Converts a base 62 KSUID string into a KSUID + # KSUID.call('15Ew2nYeRDscBipuJicYjl970D1') + # + # @param ksuid [String, Array, KSUID::Type] the KSUID-compatible value + # @return [KSUID::Type] the converted KSUID + # @raise [ArgumentError] if the value is not KSUID-compatible + def self.call(ksuid) + return unless ksuid + + case ksuid + when KSUID::Type then ksuid + when Array then KSUID.from_bytes(ksuid) + when String then cast_string(ksuid) + else + raise ArgumentError, "Cannot convert #{ksuid.inspect} to KSUID" + end + end + # The configuration for creating new KSUIDs # # @api private @@ -167,4 +190,19 @@ def self.max def self.new(payload: nil, time: Time.now) Type.new(payload: payload, time: time) end + + # Casts a string into a KSUID + # + # @api private + # + # @param ksuid [String] the string to convert into a KSUID + # @return [KSUID::Type] the converted KSUID + def self.cast_string(ksuid) + if Base62.compatible?(ksuid) + KSUID.from_base62(ksuid) + else + KSUID.from_bytes(ksuid) + end + end + private_class_method :cast_string end diff --git a/lib/ksuid/base62.rb b/lib/ksuid/base62.rb index d76d9e7..8a57dd1 100644 --- a/lib/ksuid/base62.rb +++ b/lib/ksuid/base62.rb @@ -20,6 +20,21 @@ module Base62 # @api private BASE = CHARSET.size + # Checks whether a string is a base 62-compatible string + # + # @api public + # + # @example Checks a KSUID for base 62 compatibility + # KSUID::Base62.compatible?("15Ew2nYeRDscBipuJicYjl970D1") #=> true + # + # @param string [String] the string to check for compatibility + # @return [Boolean] + def self.compatible?(string) + return false unless string.to_s == string + + string.each_char.all? { |char| CHARSET.include?(char) } + end + # Decodes a base 62-encoded string into an integer # # @api public diff --git a/lib/sequel/plugins/ksuid.rb b/lib/sequel/plugins/ksuid.rb new file mode 100644 index 0000000..7d103f6 --- /dev/null +++ b/lib/sequel/plugins/ksuid.rb @@ -0,0 +1,236 @@ +# frozen_string_literal: true + +module Sequel # :nodoc: + module Plugins # :nodoc: + # Adds KSUID support to the Sequel ORM + # + # @api public + # + # @example Creates a model with a standard, string-based KSUID + # connection_string = 'sqlite:/' + # connection_string = 'jdbc:sqlite::memory:' if RUBY_ENGINE == 'jruby' + # DB = Sequel.connect(connection_string) + # + # DB.create_table!(:events) do + # Integer :id + # String :ksuid + # end + # + # class Event < Sequel::Model(:events) + # plugin :ksuid + # end + # + # @example Creates a model with a customized KSUID field + # connection_string = 'sqlite:/' + # connection_string = 'jdbc:sqlite::memory:' if RUBY_ENGINE == 'jruby' + # DB = Sequel.connect(connection_string) + # + # DB.create_table!(:events) do + # Integer :id + # String :correlation_id + # end + # + # class Event < Sequel::Model(:events) + # plugin :ksuid, field: :correlation_id + # end + # + # @example Creates a model that always overwrites the KSUID on save + # connection_string = 'sqlite:/' + # connection_string = 'jdbc:sqlite::memory:' if RUBY_ENGINE == 'jruby' + # DB = Sequel.connect(connection_string) + # + # DB.create_table!(:events) do + # Integer :id + # String :ksuid + # end + # + # class Event < Sequel::Model(:events) + # plugin :ksuid, force: true + # end + # + # @example Creates a model with a binary-encoded KSUID + # connection_string = 'sqlite:/' + # connection_string = 'jdbc:sqlite::memory:' if RUBY_ENGINE == 'jruby' + # DB = Sequel.connect(connection_string) + # + # DB.create_table!(:events) do + # Integer :id + # blob :ksuid + # end + # + # class Event < Sequel::Model(:events) + # plugin :ksuid, binary: true + # end + module Ksuid + # Configures the plugin by setting available options + # + # @api private + # + # @param model [Sequel::Model] the model to configure + # @param options [Hash] the hash of available options + # @option options [Boolean] :binary encode the KSUID as a binary string + # @option options [Boolean] :field the field to use as a KSUID + # @option options [Boolean] :force overwrite the field on save + # @option options [Boolean] :wrap wraps the KSUID into a KSUID type + # @return [void] + def self.configure(model, options = OPTS) + model.instance_exec do + extract_configuration(options) + define_ksuid_accessor + end + end + + # Class methods that are extended onto an enabling model class + # + # @api private + module ClassMethods + # The field that is enabled with KSUID handling + # + # @api private + # + # @return [Symbol] + attr_reader :ksuid_field + + # Checks whether the KSUID should be binary encoded + # + # @api private + # + # @return [Boolean] + def ksuid_binary? + @ksuid_binary + end + + # Defines an accessor for the KSUID that converts it into a KSUID + # + # @api private + # + # @return [void] + def define_ksuid_accessor + return unless @ksuid_wrap + + define_ksuid_getter + define_ksuid_setter + end + + # Defines a getter for the KSUID that converts it into a KSUID + # + # @api private + # + # @return [void] + def define_ksuid_getter + define_method(@ksuid_field) do + KSUID.call(super()) + end + end + + # Defines a setter for the KSUID that converts the value properly + # + # @api private + # + # @return [void] + def define_ksuid_setter + define_method("#{@ksuid_field}=") do |ksuid| + ksuid = KSUID.call(ksuid) + + if self.class.ksuid_binary? + super(ksuid.to_bytes) + else + super(ksuid.to_s) + end + end + end + + # Extracts all configuration options from the configure step + # + # @api private + # + # @return [void] + def extract_configuration(options) + @ksuid_binary = options.fetch(:binary, false) + @ksuid_field = options.fetch(:field, :ksuid) + @ksuid_overwrite = options.fetch(:force, false) + @ksuid_wrap = options.fetch(:wrap, false) + end + + # Checks whether the KSUID should be overwritten upon save + # + # @api private + # + # @return [Boolean] + def ksuid_overwrite? + @ksuid_overwrite + end + + # Checks whether the model should wrap its KSUID field in a type + # + # @api private + # + # @return [Boolean] + def ksuid_wrap? + @ksuid_wrap + end + + Plugins.inherited_instance_variables( + self, + :@ksuid_binary => nil, + :@ksuid_field => nil, + :@ksuid_overwrite => nil, + :@ksuid_wrap => nil + ) + end + + # Instance methods that are included in an enabling model class + # + # @api private + module InstanceMethods + # Generates a KSUID for the field before validation + # + # @api private + # + # @return [void] + def before_validation + set_ksuid if new? + super + end + + private + + # A hook method for generating a new KSUID + # + # @api private + # + # @return [String] a binary or base 62-encoded string + def create_ksuid + ksuid = KSUID.new + + if self.class.ksuid_binary? + ksuid.to_bytes + else + ksuid.to_s + end + end + + # Initializes the KSUID field when it is not set, or overwrites it if enabled + # + # Note: The disabled Rubocop rule is to allow the method to follow + # Sequel conventions. + # + # @api private + # + # @param ksuid [String] the normal string or byte string of the KSUID + # @return [void] + # rubocop:disable Naming/AccessorMethodName + def set_ksuid(ksuid = create_ksuid) + field = model.ksuid_field + setter = :"#{field}=" + + return unless respond_to?(field) && + respond_to?(setter) && + (model.ksuid_overwrite? || !get_column_value(field)) + + set_column_value(setter, ksuid) + end + end + end + end +end diff --git a/spec/base62_spec.rb b/spec/base62_spec.rb index 8e3289e..1df28f8 100644 --- a/spec/base62_spec.rb +++ b/spec/base62_spec.rb @@ -1,6 +1,23 @@ # frozen_string_literal: true RSpec.describe KSUID::Base62 do + describe '.compatible?' do + it 'recognizes base 62-encoded strings' do + expect(described_class.compatible?(KSUID.new.to_s)).to eq(true) + end + + it 'does not recognize binary strings' do + expect(described_class.compatible?(KSUID.new.to_bytes)).to eq(false) + end + + it 'does not recognize other things' do + expect(described_class.compatible?(1)).to eq(false) + expect(described_class.compatible?(nil)).to eq(false) + expect(described_class.compatible?([])).to eq(false) + expect(described_class.compatible?({})).to eq(false) + end + end + describe '#decode' do it 'decodes base 62 numbers that may or may not be zero-padded' do %w[awesomesauce 00000000awesomesauce].each do |encoded| diff --git a/spec/doctest_helper.rb b/spec/doctest_helper.rb index c7022c9..b77a00e 100644 --- a/spec/doctest_helper.rb +++ b/spec/doctest_helper.rb @@ -1,3 +1,5 @@ # frozen_string_literal: true +require 'sequel' +require 'sqlite3' unless RUBY_ENGINE == 'jruby' require 'ksuid' diff --git a/spec/ksuid_spec.rb b/spec/ksuid_spec.rb index 820e668..ef2f1ed 100644 --- a/spec/ksuid_spec.rb +++ b/spec/ksuid_spec.rb @@ -12,4 +12,48 @@ expect(KSUID.config.random_generator).to eq(generator) end + + describe '.call' do + it 'returns KSUIDs in tact' do + ksuid = KSUID.new + + result = KSUID.call(ksuid) + + expect(result).to eq(ksuid) + end + + it 'converts byte strings to KSUIDs' do + ksuid = KSUID.new + + result = KSUID.call(ksuid.to_bytes) + + expect(result).to eq(ksuid) + end + + it 'converts byte arrays to KSUIDs' do + ksuid = KSUID.new + + result = KSUID.call(ksuid.__send__(:uid)) + + expect(result).to eq(ksuid) + end + + it 'converts base 62 strings to KSUIDs' do + ksuid = KSUID.new + + result = KSUID.call(ksuid.to_s) + + expect(result).to eq(ksuid) + end + + it 'returns nil if passed nil' do + result = KSUID.call(nil) + + expect(result).to be_nil + end + + it 'raise an ArgumentError upon an unknown value' do + expect { KSUID.call(1) }.to raise_error(ArgumentError) + end + end end diff --git a/spec/sequel_spec.rb b/spec/sequel_spec.rb new file mode 100644 index 0000000..3ae25bf --- /dev/null +++ b/spec/sequel_spec.rb @@ -0,0 +1,211 @@ +# frozen_string_literal: true + +require 'sequel' +require 'sequel/plugins/ksuid' + +class << Sequel::Model + # :reek:Attribute: + attr_writer :db_schema + + alias orig_columns columns + + # :reek:TooManyStatements: + def columns(*columns) + return super if columns.empty? + + define_method(:columns) { columns } + @dataset.send(:columns=, columns) if @dataset + def_column_accessor(*columns) + @columns = columns + @db_schema = {} + + columns.each { |column| @db_schema[column] = {} } + end +end + +Sequel::Model.use_transactions = false +db = Sequel.mock(fetch: { id: 1, x: 1 }, numrows: 1, autoid: ->(_sql) { 10 }) + +def db.schema(*) + [[:id, { primary_key: true }]] +end + +def db.reset + sqls +end + +def db.supports_schema_parsing? + true +end + +Sequel::Model.db = DB = db + +RSpec.describe Sequel::Plugins::Ksuid do + let(:alt_ksuid) { '15FZ1XE5JbMLkbeIznyRnUgkuKe' } + let(:ksuid) { 'aWgEPTl1tmebfsQzFP4bxwgy80V' } + + # Sequel raises many warnings that are outside of the scope of our gem. In + # order to prevent this output, we silence them around each one of these + # tests. + around do |example| + original = $VERBOSE + $VERBOSE = nil + example.run + $VERBOSE = original + end + + let(:klass) do + Class.new(Sequel::Model(:events)) do + columns :id, :ksuid + plugin :ksuid + + def _save_refresh(*); end + + define_method(:create_ksuid) { KSUID.max.to_s } + + db.reset + end + end + + it 'handles validations on the KSUID field for new objects' do + klass.plugin :ksuid, force: true + instance = klass.new + + # :reek:DuplicateMethodCall + def instance.validate + errors.add(model.ksuid_field, 'not present') unless send(model.ksuid_field) + end + + expect(instance).to be_valid + end + + it 'sets the KSUID field when skipping validations' do + klass.plugin :ksuid + + klass.new.save(validate: false) + + expect(klass.db.sqls).to eq(["INSERT INTO events (ksuid) VALUES ('#{ksuid}')"]) + end + + it 'sets the KSUID field on creation' do + instance = klass.create + + expect(klass.db.sqls).to eq(["INSERT INTO events (ksuid) VALUES ('#{ksuid}')"]) + expect(instance.ksuid).to eq(ksuid) + end + + it 'allows specifying the KSUID field via the :field option' do + klass = + Class.new(Sequel::Model(:events)) do + columns :id, :k + plugin :ksuid, field: :k + def _save_refresh(*); end + end + + instance = klass.create + + expect(klass.db.sqls).to eq(["INSERT INTO events (k) VALUES ('#{instance.k}')"]) + end + + it 'does not raise an error if the model does not have the KSUID column' do + klass.columns :id, :x + klass.send(:undef_method, :ksuid) + + klass.create(x: 2) + klass.load(id: 1, x: 2).save + + expect(klass.db.sqls).to( + eq(['INSERT INTO events (x) VALUES (2)', 'UPDATE events SET x = 2 WHERE (id = 1)']) + ) + end + + it 'overwrites an existing KSUID if the :force option is used' do + klass.plugin :ksuid, force: true + + instance = klass.create(ksuid: alt_ksuid) + + expect(klass.db.sqls).to eq(["INSERT INTO events (ksuid) VALUES ('#{ksuid}')"]) + expect(instance.ksuid).to eq(ksuid) + end + + it 'works with subclasses' do + new_klass = Class.new(klass) + + instance = new_klass.create + + expect(instance.ksuid).to eq(ksuid) + expect(new_klass.db.sqls).to eq(["INSERT INTO events (ksuid) VALUES ('#{ksuid}')"]) + + second_instance = new_klass.create(ksuid: alt_ksuid) + + expect(second_instance.ksuid).to eq(alt_ksuid) + + new_klass.class_eval do + columns :id, :k + plugin :ksuid, field: :k, force: true + end + + second_klass = Class.new(new_klass) + second_klass.db.reset + + instance = second_klass.create + + expect(instance.k).to eq(ksuid) + expect(second_klass.db.sqls).to eq(["INSERT INTO events (k) VALUES ('#{ksuid}')"]) + end + + it 'generates a binary KSUID when told to do so' do + klass = + Class.new(Sequel::Model(:events)) do + columns :id, :ksuid + plugin :ksuid, binary: true + def _save_refresh(*); end + end + + instance = klass.create + + expect(instance.ksuid).not_to be_nil + expect(KSUID::Base62.compatible?(instance.ksuid)).to eq(false) + expect(klass.db.sqls).to( + eq(["INSERT INTO events (ksuid) VALUES ('#{instance.ksuid}')"]) + ) + end + + it 'converts the KSUID field into a KSUID when told to do so' do + klass = + Class.new(Sequel::Model(:events)) do + columns :id, :ksuid + plugin :ksuid, wrap: true + def _save_refresh(*); end + end + + instance = klass.create + + expect(instance.ksuid).to be_a(KSUID::Type) + + instance.ksuid = KSUID.new.to_bytes + instance.save + + expect(instance.ksuid).to be_a(KSUID::Type) + end + + describe '.ksuid_field' do + it 'introspects the KSUID field' do + expect(klass.ksuid_field).to eq(:ksuid) + + klass.plugin :ksuid, field: :alt_ksuid + + expect(klass.ksuid_field).to eq(:alt_ksuid) + end + end + + describe '.ksuid_overwrite?' do + it 'introspects the overwriting ability' do + expect(klass.ksuid_overwrite?).to eq(false) + + klass.plugin :ksuid, force: true + + expect(klass.ksuid_overwrite?).to eq(true) + end + end +end