diff --git a/.rubocop.yml b/.rubocop.yml index 2c01aab..dd04f08 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -1 +1,15 @@ -require: rubocop-yard +plugins: rubocop-yard + +Naming/MethodName: + AllowedPatterns: + - '\AOk\Z' + - '\AErr\Z' + - '\ASome\Z' + - '\ANone\Z' + +Metrics/ClassLength: + Max: 150 + +Lint/MissingSuper: + AllowedParentClasses: + - Any diff --git a/Gemfile b/Gemfile index e66b026..463a622 100644 --- a/Gemfile +++ b/Gemfile @@ -6,14 +6,14 @@ source 'https://rubygems.org' gemspec group :development do + gem 'activerecord' + gem 'minitest' gem 'rake', '~> 13.0' gem 'rspec', '~> 3.0' gem 'rubocop' gem 'rubocop-yard' gem 'solargraph' - gem 'activerecord' gem 'sqlite3' - gem 'minitest' end # gem "standard", "~> 1.3" diff --git a/Gemfile.lock b/Gemfile.lock index 837f823..b0eddb4 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,7 +1,7 @@ PATH remote: . specs: - errgonomic (0.4.0) + errgonomic (0.5.0) concurrent-ruby (~> 1.0) GEM diff --git a/errgonomic.gemspec b/errgonomic.gemspec index 22e4b88..cc11fe3 100644 --- a/errgonomic.gemspec +++ b/errgonomic.gemspec @@ -14,7 +14,7 @@ Gem::Specification.new do |spec| spec.email = ['nick@onemorecloud.com'] spec.summary = 'Opinionated, ergonomic error handling for Ruby, inspired by Rails and Rust.' - spec.description = "Let's blend the Rails 'present' and 'blank' conventions with a few patterns from Rust Option types." + spec.description = "Let's blend Rails presence conventions and the Rust Option and Result types." spec.homepage = 'https://omc.io/' spec.license = 'MIT' spec.required_ruby_version = '>= 3.0.0' diff --git a/lib/errgonomic.rb b/lib/errgonomic.rb index 9a170d5..e63334e 100644 --- a/lib/errgonomic.rb +++ b/lib/errgonomic.rb @@ -15,6 +15,9 @@ # Rails fu require_relative 'errgonomic/rails' if defined?(Rails::Railtie) +# Optional enumerables +require_relative 'errgonomic/enumerable' + # Errgonomic adds opinionated abstractions to handle errors in a way that blends # Rust and Ruby ergonomics. This library leans on Rails conventions for some # presence-related methods; when in doubt, make those feel like Rails. It also @@ -27,6 +30,7 @@ class NotPresentError < Error; end class TypeMismatchError < Error; end + # Unwrapping a None or Err raises an exception class UnwrapError < Error def initialize(msg, value) super(msg) diff --git a/lib/errgonomic/core_ext/blank.rb b/lib/errgonomic/core_ext/blank.rb index b784d05..efbc4f2 100644 --- a/lib/errgonomic/core_ext/blank.rb +++ b/lib/errgonomic/core_ext/blank.rb @@ -2,6 +2,7 @@ require 'concurrent/map' +# If we're not already working with blank?, et al, then add them. class Object # An object is blank if it's false, empty, or a whitespace string. # For example, +nil+, '', ' ', [], {}, and +false+ are all blank. @@ -47,6 +48,7 @@ def presence end end +# If we're not working with blank?, et al, then add them. class NilClass # +nil+ is blank: # @@ -62,6 +64,7 @@ def present? # :nodoc: end end +# If we're not working with blank?, et al, then add them. class FalseClass # +false+ is blank: # @@ -77,6 +80,7 @@ def present? # :nodoc: end end +# If we're not working with blank?, et al, then add them. class TrueClass # +true+ is not blank: # @@ -92,6 +96,7 @@ def present? # :nodoc: end end +# If we're not working with blank?, et al, then add them. class Array # An array is blank if it's empty: # @@ -106,6 +111,7 @@ def present? # :nodoc: end end +# If we're not working with blank?, et al, then add them. class Hash # A hash is blank if it's empty: # @@ -120,6 +126,7 @@ def present? # :nodoc: end end +# If we're not working with blank?, et al, then add them. class Symbol # A Symbol is blank if it's empty: # @@ -132,6 +139,7 @@ def present? # :nodoc: end end +# If we're not working with blank?, et al, then add them. class String BLANK_RE = /\A[[:space:]]*\z/ ENCODED_BLANKS = Concurrent::Map.new do |h, enc| diff --git a/lib/errgonomic/enumerable.rb b/lib/errgonomic/enumerable.rb new file mode 100644 index 0000000..4c142f7 --- /dev/null +++ b/lib/errgonomic/enumerable.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +require_relative './enumerable/optional_hash' + +module Errgonomic + # Extend various Enumerable classes to return Option, like Hash + module Enumerable + end +end + +module Enumerable + class Hash + end +end diff --git a/lib/errgonomic/enumerable/optional_hash.rb b/lib/errgonomic/enumerable/optional_hash.rb new file mode 100644 index 0000000..daa29f0 --- /dev/null +++ b/lib/errgonomic/enumerable/optional_hash.rb @@ -0,0 +1,55 @@ +# frozen_string_literal: true + +require_relative '../option' + +module Errgonomic + module Enumerable + # Optional Hash + # + # Implements a hash where fetching returns an +Option+ instead of nil. We + # focus on the #[] operator and #dig function, because #fetch will raise + # KeyError when the key is not present. + # + class OptionalHash < ::Hash + # Retrieve a value with a given key, wrapping the value in an Option. The + # +nil+ value is replaced with a +None+ and anything else is wrapped in + # +Some+. + # + # @example + # h = Errgonomic::Enumerable::OptionalHash.new + # h[:color] = :blue + # h[:color] #=> Some(:blue) + # h[:butwhy] = nil + # h[:butwhy] # => Some(nil) + def [](key) + return None() unless key?(key) + + Some(super(key)) + end + + # Similar to +Hash#dig+ but wrapped in an Option. + # + # @example + # h = Errgonomic::Enumerable::OptionalHash.new + # h[:description] = Errgonomic::Enumerable::OptionalHash.new + # h[:description].unwrap![:short] = "It's a thing" + # h.dig(:description, :short) # => Some("It's a thing") + # h.dig(:description, :long) # => None() + # + # @example + # h = Errgonomic::Enumerable::OptionalHash.new + # h[:description] = { short: { text: "Nested hash" } } + # h.dig(:description, :short, :text) # => Some("Nested hash") + # h.dig(:description, :long) # => None() + def dig(*args) + obj = super(*args) + + return None() if obj.nil? || obj.is_a?(Errgonomic::Option::None) + + obj = obj.unwrap! while obj.is_a?(Errgonomic::Option::Some) + + Some(obj) + end + end + end +end diff --git a/lib/errgonomic/option.rb b/lib/errgonomic/option.rb index 0dbff41..b5c536c 100644 --- a/lib/errgonomic/option.rb +++ b/lib/errgonomic/option.rb @@ -336,7 +336,7 @@ class Some < Any attr_accessor :value def initialize(value) - @value = value + self.value = value end def some? @@ -348,6 +348,7 @@ def none? end end + # Represent nonexistence of a value class None < Any def some? false diff --git a/lib/errgonomic/presence.rb b/lib/errgonomic/presence.rb index 1dd7262..048d7e5 100644 --- a/lib/errgonomic/presence.rb +++ b/lib/errgonomic/presence.rb @@ -5,6 +5,9 @@ # gem a dependency. require_relative './core_ext/blank' unless Object.methods.include?(:blank?) +# Big noisy exceptions based on object presence. Helpful for tests or anywhere +# else one may want to set strong assertions on expected constraints. Like a +# very basic implementation of Option with more Rails-y naming conventions. class Object # Returns the receiver if it is present, otherwise raises a NotPresentError. # This method is useful to enforce strong expectations, where it is preferable @@ -18,7 +21,7 @@ def present_or_raise!(message) self end - alias_method :present_or_raise, :present_or_raise! + alias present_or_raise present_or_raise! # Returns the receiver if it is present, otherwise returns the given value. If # constructing the default value is expensive, consider using @@ -61,7 +64,7 @@ def blank_or_raise!(message) self end - alias_method :blank_or_raise, :blank_or_raise! + alias blank_or_raise blank_or_raise! # Returns the receiver if it is blank, otherwise returns the given value. # diff --git a/lib/errgonomic/rails.rb b/lib/errgonomic/rails.rb index 26594de..3743e48 100644 --- a/lib/errgonomic/rails.rb +++ b/lib/errgonomic/rails.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require_relative 'option' require_relative 'rails/active_record_optional' require_relative 'rails/active_record_delegate_optional' diff --git a/lib/errgonomic/rails/active_record_delegate_optional.rb b/lib/errgonomic/rails/active_record_delegate_optional.rb index 5a384ac..e9ed5fc 100644 --- a/lib/errgonomic/rails/active_record_delegate_optional.rb +++ b/lib/errgonomic/rails/active_record_delegate_optional.rb @@ -1,10 +1,15 @@ +# frozen_string_literal: true + module Errgonomic module Rails + # A Rails Concern to introduce the +delegate_optional+ class method helper module ActiveRecordDelegateOptional extend ActiveSupport::Concern class_methods do - def delegate_optional(*methods, to: nil, prefix: nil, private: nil) + # Like delegate, but for an optional attribute, using the Option #map + # method to return a Some or None as appropriate. + def delegate_optional(*methods, to: nil, prefix: nil, _private: nil) return if to.nil? methods.each do |method_name| diff --git a/lib/errgonomic/rails/active_record_optional.rb b/lib/errgonomic/rails/active_record_optional.rb index 75db1e7..eaee44e 100644 --- a/lib/errgonomic/rails/active_record_optional.rb +++ b/lib/errgonomic/rails/active_record_optional.rb @@ -44,21 +44,28 @@ def validate_each(record, attribute, value) module Errgonomic module Option + # Within the Rails context we want to treat present? and blank? as if they are some? and none? class Any alias some? present? alias none? blank? end + # A few very specific methods that Rails will invoke against the Some return + # value of an attribute. I would prefer not to have to set these, but my + # preference to avoid monkey-patching even deeper parts of Rails is + # stronger. class Some delegate :marked_for_destruction?, to: :value delegate :persisted?, to: :value delegate :touch_later, to: :value def to_s - raise "Attempted to convert Some to String, please use Option API to safely work with internal value -- #{value}" + raise 'Attempted to convert Some to String, ' \ + "please use Option API to safely work with internal value -- #{value}" end end + # Let Rails pretend that a None is nil. This is a monkeypatch level hack. class None def nil? true @@ -71,6 +78,10 @@ def to_s end end +# Automatically unwrap for ActiveRecord type_cast so it can convert values into +# Ruby data types for database stuff. This is another monkeypatch to get out of +# the way of ActiveRecord assumptions and avoid patching deeper bits of +# functionality. module ActiveRecordOptionShim def type_cast(value) case value @@ -86,12 +97,14 @@ def type_cast(value) ActiveRecord::ConnectionAdapters::Quoting.prepend(ActiveRecordOptionShim) +# Support a generic +to_option+ method class NilClass def to_option None() end end +# Support a generic +to_option+ method class Object def to_option Some(self) diff --git a/lib/errgonomic/result.rb b/lib/errgonomic/result.rb index a8c98f3..43d3ba1 100644 --- a/lib/errgonomic/result.rb +++ b/lib/errgonomic/result.rb @@ -277,6 +277,7 @@ def err? end end + # The error side of a result class Err < Any class Arbitrary; end diff --git a/lib/errgonomic/type.rb b/lib/errgonomic/type.rb index 48e4938..fc97a63 100644 --- a/lib/errgonomic/type.rb +++ b/lib/errgonomic/type.rb @@ -1,5 +1,7 @@ # frozen_string_literal: true +# Similar to 'errgonomic/presence' - very noisy assertions based on an object's +# underlying type. Useful for enforcing constraints in tests. class Object # Returns the receiver if it matches the expected type, otherwise raises a TypeMismatchError. # This is useful for enforcing type expectations in method arguments. @@ -56,10 +58,10 @@ def type_or_else(type, &block) # @return [Object] The receiver if it is not of the specified type. # @example # 'hello'.not_type_or_raise!(Integer) #=> "hello" - # 123.not_type_or_raise!(Integer, "We dont want an integer!") #=> raise Errgonomic::TypeMismatchError, "We dont want an integer!" - # 123.not_type_or_raise!(Integer) #=> raise Errgonomic::TypeMismatchError, "Expected anything but Integer but got Integer" + # 123.not_type_or_raise!(Integer, "No gracias!") #=> raise Errgonomic::TypeMismatchError, "No gracias!" + # 123.not_type_or_raise!(Integer) #=> raise Errgonomic::TypeMismatchError, "Expected object not to be Integer" def not_type_or_raise!(type, message = nil) - message ||= "Expected anything but #{type} but got #{self.class}" + message ||= "Expected object not to be #{type}" raise Errgonomic::TypeMismatchError, message if is_a?(type) self diff --git a/lib/errgonomic/version.rb b/lib/errgonomic/version.rb index 33f65a4..24fad33 100644 --- a/lib/errgonomic/version.rb +++ b/lib/errgonomic/version.rb @@ -1,5 +1,5 @@ # frozen_string_literal: true module Errgonomic - VERSION = '0.4.0' + VERSION = '0.5.0' end diff --git a/test/rails_test.rb b/test/rails_test.rb index 041c671..5ff2175 100644 --- a/test/rails_test.rb +++ b/test/rails_test.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require 'active_record' require 'minitest/autorun' require 'logger'