diff --git a/CHANGELOG.md b/CHANGELOG.md index 1748552..8771a94 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. ### 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/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 25e24a8..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) # diff --git a/lib/sequel/plugins/ksuid.rb b/lib/sequel/plugins/ksuid.rb new file mode 100644 index 0000000..0f06033 --- /dev/null +++ b/lib/sequel/plugins/ksuid.rb @@ -0,0 +1,226 @@ +# 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 + @ksuid_binary = options.fetch(:binary, false) + @ksuid_field = options.fetch(:field, :ksuid) + @ksuid_overwrite = options.fetch(:force, false) + @ksuid_wrap = options.fetch(:wrap, false) + + define_ksuid_accessor if @ksuid_wrap + 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 + 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 + + # 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/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/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