diff --git a/Gemfile b/Gemfile index 965df16..e66b026 100644 --- a/Gemfile +++ b/Gemfile @@ -5,10 +5,15 @@ source 'https://rubygems.org' # Specify your gem's dependencies in errgonomic.gemspec gemspec -gem 'rake', '~> 13.0', group: :development -gem 'rspec', '~> 3.0', group: :development -gem 'rubocop', group: :development -gem 'rubocop-yard', group: :development -gem 'solargraph', group: :development +group :development do + 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", group: :development +# gem "standard", "~> 1.3" diff --git a/Gemfile.lock b/Gemfile.lock index ca280a2..2ce428d 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -7,11 +7,36 @@ PATH GEM remote: https://rubygems.org/ specs: + activemodel (8.0.2) + activesupport (= 8.0.2) + activerecord (8.0.2) + activemodel (= 8.0.2) + activesupport (= 8.0.2) + timeout (>= 0.4.0) + activesupport (8.0.2) + base64 + benchmark (>= 0.3) + bigdecimal + concurrent-ruby (~> 1.0, >= 1.3.1) + connection_pool (>= 2.2.5) + drb + i18n (>= 1.6, < 2) + logger (>= 1.4.2) + minitest (>= 5.1) + securerandom (>= 0.3) + tzinfo (~> 2.0, >= 2.0.5) + uri (>= 0.13.1) ast (2.4.2) backport (1.2.0) + base64 (0.2.0) benchmark (0.4.0) + bigdecimal (3.1.9) concurrent-ruby (1.3.5) + connection_pool (2.5.1) diff-lcs (1.6.0) + drb (2.2.1) + i18n (1.14.7) + concurrent-ruby (~> 1.0) jaro_winkler (1.6.0) json (2.10.1) kramdown (2.5.1) @@ -69,6 +94,7 @@ GEM rubocop (~> 1.21) yard ruby-progressbar (1.13.0) + securerandom (0.4.1) solargraph (0.52.0) backport (~> 1.2) benchmark @@ -88,11 +114,17 @@ GEM tilt (~> 2.0) yard (~> 0.9, >= 0.9.24) yard-solargraph (~> 0.1) + sqlite3 (2.6.0) + mini_portile2 (~> 2.8.0) thor (1.3.2) tilt (2.6.0) + timeout (0.4.3) + tzinfo (2.0.6) + concurrent-ruby (~> 1.0) unicode-display_width (3.1.4) unicode-emoji (~> 4.0, >= 4.0.4) unicode-emoji (4.0.4) + uri (1.0.3) yard (0.9.37) yard-doctest (0.1.17) minitest @@ -104,12 +136,15 @@ PLATFORMS ruby DEPENDENCIES + activerecord errgonomic! + minitest rake (~> 13.0) rspec (~> 3.0) rubocop rubocop-yard solargraph + sqlite3 yard (~> 0.9) yard-doctest (~> 0.1) diff --git a/gemset.nix b/gemset.nix index 22b520a..fdb1e5f 100644 --- a/gemset.nix +++ b/gemset.nix @@ -1,4 +1,37 @@ { + activemodel = { + dependencies = ["activesupport"]; + groups = ["default" "development"]; + platforms = []; + source = { + remotes = ["https://rubygems.org"]; + sha256 = "0v35y2jzqlfy1wnrzlzj2cxylhnz09vykaa1l2dnkq7sl5zzpq8a"; + type = "gem"; + }; + version = "8.0.2"; + }; + activerecord = { + dependencies = ["activemodel" "activesupport" "timeout"]; + groups = ["development"]; + platforms = []; + source = { + remotes = ["https://rubygems.org"]; + sha256 = "02nrya34qviawdkssyahb3mg08kqdc461b320a6ikr245jwp0d3r"; + type = "gem"; + }; + version = "8.0.2"; + }; + activesupport = { + dependencies = ["base64" "benchmark" "bigdecimal" "concurrent-ruby" "connection_pool" "drb" "i18n" "logger" "minitest" "securerandom" "tzinfo" "uri"]; + groups = ["default" "development"]; + platforms = []; + source = { + remotes = ["https://rubygems.org"]; + sha256 = "0pm40y64wfc50a9sj87kxvil2102rmpdcbv82zf0r40vlgdwsrc5"; + type = "gem"; + }; + version = "8.0.2"; + }; ast = { groups = ["default"]; platforms = []; @@ -19,6 +52,16 @@ }; version = "1.2.0"; }; + base64 = { + groups = ["default" "development"]; + platforms = []; + source = { + remotes = ["https://rubygems.org"]; + sha256 = "01qml0yilb9basf7is2614skjp8384h2pycfx86cr8023arfj98g"; + type = "gem"; + }; + version = "0.2.0"; + }; benchmark = { groups = ["default"]; platforms = []; @@ -29,6 +72,16 @@ }; version = "0.4.0"; }; + bigdecimal = { + groups = ["default" "development"]; + platforms = []; + source = { + remotes = ["https://rubygems.org"]; + sha256 = "1k6qzammv9r6b2cw3siasaik18i6wjc5m0gw5nfdc6jj64h79z1g"; + type = "gem"; + }; + version = "3.1.9"; + }; concurrent-ruby = { groups = ["default"]; platforms = []; @@ -39,6 +92,16 @@ }; version = "1.3.5"; }; + connection_pool = { + groups = ["default" "development"]; + platforms = []; + source = { + remotes = ["https://rubygems.org"]; + sha256 = "171yzazg2wccbc545hwsx85sba91k7k1imir3c88385mlj82m05f"; + type = "gem"; + }; + version = "2.5.1"; + }; diff-lcs = { groups = ["default"]; platforms = []; @@ -49,6 +112,16 @@ }; version = "1.6.0"; }; + drb = { + groups = ["default" "development"]; + platforms = []; + source = { + remotes = ["https://rubygems.org"]; + sha256 = "0h5kbj9hvg5hb3c7l425zpds0vb42phvln2knab8nmazg2zp5m79"; + type = "gem"; + }; + version = "2.2.1"; + }; errgonomic = { dependencies = ["concurrent-ruby"]; groups = ["default"]; @@ -59,6 +132,17 @@ }; version = "0.2.0"; }; + i18n = { + dependencies = ["concurrent-ruby"]; + groups = ["default" "development"]; + platforms = []; + source = { + remotes = ["https://rubygems.org"]; + sha256 = "03sx3ahz1v5kbqjwxj48msw3maplpp2iyzs22l4jrzrqh4zmgfnf"; + type = "gem"; + }; + version = "1.14.7"; + }; jaro_winkler = { groups = ["default"]; platforms = []; @@ -362,6 +446,16 @@ }; version = "1.13.0"; }; + securerandom = { + groups = ["default" "development"]; + platforms = []; + source = { + remotes = ["https://rubygems.org"]; + sha256 = "1cd0iriqfsf1z91qg271sm88xjnfd92b832z49p1nd542ka96lfc"; + type = "gem"; + }; + version = "0.4.1"; + }; solargraph = { dependencies = ["backport" "benchmark" "diff-lcs" "jaro_winkler" "kramdown" "kramdown-parser-gfm" "logger" "observer" "ostruct" "parser" "rbs" "reverse_markdown" "rubocop" "thor" "tilt" "yard" "yard-solargraph"]; groups = ["default"]; @@ -373,6 +467,17 @@ }; version = "0.52.0"; }; + sqlite3 = { + dependencies = ["mini_portile2"]; + groups = ["development"]; + platforms = []; + source = { + remotes = ["https://rubygems.org"]; + sha256 = "0573vgz5ck0hqr8h132ln0hczx53m21h4w42n1p75rj837qjbim1"; + type = "gem"; + }; + version = "2.6.0"; + }; thor = { groups = ["default"]; platforms = []; @@ -393,6 +498,27 @@ }; version = "2.6.0"; }; + timeout = { + groups = ["default" "development"]; + platforms = []; + source = { + remotes = ["https://rubygems.org"]; + sha256 = "03p31w5ghqfsbz5mcjzvwgkw3h9lbvbknqvrdliy8pxmn9wz02cm"; + type = "gem"; + }; + version = "0.4.3"; + }; + tzinfo = { + dependencies = ["concurrent-ruby"]; + groups = ["default" "development"]; + platforms = []; + source = { + remotes = ["https://rubygems.org"]; + sha256 = "16w2g84dzaf3z13gxyzlzbf748kylk5bdgg3n1ipvkvvqy685bwd"; + type = "gem"; + }; + version = "2.0.6"; + }; unicode-display_width = { dependencies = ["unicode-emoji"]; groups = ["default"]; @@ -414,6 +540,16 @@ }; version = "4.0.4"; }; + uri = { + groups = ["default" "development"]; + platforms = []; + source = { + remotes = ["https://rubygems.org"]; + sha256 = "04bhfvc25b07jaiaf62yrach7khhr5jlr5bx6nygg8pf11329wp9"; + type = "gem"; + }; + version = "1.0.3"; + }; yard = { groups = ["development"]; platforms = []; diff --git a/lib/errgonomic.rb b/lib/errgonomic.rb index 348e7df..f9de496 100644 --- a/lib/errgonomic.rb +++ b/lib/errgonomic.rb @@ -9,6 +9,9 @@ require_relative 'errgonomic/option' require_relative 'errgonomic/result' +# Rails fu +require_relative 'errgonomic/rails' if defined?(Rails::Railtie) + # 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 diff --git a/lib/errgonomic/rails.rb b/lib/errgonomic/rails.rb new file mode 100644 index 0000000..494cf39 --- /dev/null +++ b/lib/errgonomic/rails.rb @@ -0,0 +1,40 @@ +require_relative 'option' +require_relative 'rails/active_record_optional' +require_relative 'rails/active_record_delegate_optional' + +module Errgonomic + # Rails specific functionality to integrate Errgonomic with minimum fuss. + module Rails + # We provide helper class methods, like `delegate_optional`, + # which need to be included into ActiveRecord::Base before any models are + # evaluated. + def self.setup_before + ActiveRecord::Base.include(Errgonomic::Rails::ActiveRecordDelegateOptional) + end + + # Wrapping optional associations requires that we include the module after + # the class is first evaluated, so that it can define its associations for + # later reflection. + def self.setup_after + ActiveRecord::Base.descendants.each do |model| + model.include Errgonomic::Rails::ActiveRecordOptional if model.table_name + end + end + end +end + +# TODO: Implement a Railtie to hook in the setup_before and setup_after at the +# appropriate times for the Rails application lifecycle, in dev and prod. +# +# if defined?(Rails::Railtie) +# module Errgonomic::Rails +# class Railtie < Rails::Railtie +# initializer 'errgonomic.rails.setup_before' do +# Errgonomic::Rails.setup_before +# end +# initializer 'errgonomic.rails.setup_after' do +# Errgonomic::Rails.setup_after +# end +# end +# end +# end diff --git a/lib/errgonomic/rails/active_record_delegate_optional.rb b/lib/errgonomic/rails/active_record_delegate_optional.rb new file mode 100644 index 0000000..5a384ac --- /dev/null +++ b/lib/errgonomic/rails/active_record_delegate_optional.rb @@ -0,0 +1,22 @@ +module Errgonomic + module Rails + module ActiveRecordDelegateOptional + extend ActiveSupport::Concern + + class_methods do + def delegate_optional(*methods, to: nil, prefix: nil, private: nil) + return if to.nil? + + methods.each do |method_name| + prefixed_method_name = prefix == true ? "#{to}_#{method_name}" : method_name + class_eval <<-RUBY, __FILE__, __LINE__ + 1 + def #{prefixed_method_name} + #{to}.map { |obj| obj.send(:#{method_name}) } + end + RUBY + end + end + end + end + end +end diff --git a/lib/errgonomic/rails/active_record_optional.rb b/lib/errgonomic/rails/active_record_optional.rb new file mode 100644 index 0000000..7bc2792 --- /dev/null +++ b/lib/errgonomic/rails/active_record_optional.rb @@ -0,0 +1,92 @@ +# frozen_string_literal: true + +module Errgonomic + module Rails + # Concern to make ActiveRecord optional attributes and associations return an Option. + # + module ActiveRecordOptional + extend ActiveSupport::Concern + included do + # ::Rails.logger.debug('ActiveRecordOptional') + optional_associations = reflect_on_all_associations(:belongs_to) + .select { |r| r.options[:optional] } + .map(&:name) + optional_attributes = column_names + .select { |n| column_for_attribute(n).null } + (optional_attributes + optional_associations).each do |name| + # Rails.logger.debug("#{self.name}: #{name}: optional") + class_eval <<-RUBY, __FILE__, __LINE__ + 1 + def #{name} + raise "stack too deep" if caller.length > 1024 + val = super + val.nil? ? Errgonomic::Option::None.new : Errgonomic::Option::Some.new(val) + end + RUBY + end + end + end + end +end + +# do we need this since we alias present below? +class SomeValidator < ActiveModel::EachValidator + def validate_each(record, attribute, value) + record.errors.add(attribute, 'is invalid') unless value.some? + end +end + +module Errgonomic + module Option + class Any + alias some? present? + alias none? blank? + end + + 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}" + end + end + + class None + def nil? + true + end + + def to_s + raise 'Cannot convert None to String - please use Option API to safely work with internal value' + end + end + end +end + +module ActiveRecordOptionShim + def type_cast(value) + case value + when Errgonomic::Option::Some + super(value.unwrap!) + when Errgonomic::Option::None + super(nil) + else + super + end + end +end + +ActiveRecord::ConnectionAdapters::Quoting.prepend(ActiveRecordOptionShim) + +class NilClass + def to_option + None() + end +end + +class Object + def to_option + Some(self) + end +end diff --git a/test/rails_test.rb b/test/rails_test.rb new file mode 100644 index 0000000..041c671 --- /dev/null +++ b/test/rails_test.rb @@ -0,0 +1,79 @@ +require 'active_record' +require 'minitest/autorun' +require 'logger' + +require_relative '../lib/errgonomic/rails' + +ActiveRecord::Base.establish_connection(adapter: 'sqlite3', database: ':memory:') +ActiveRecord::Base.logger = Logger.new(File::NULL) + +# Book reviews with various optional attributes and associations +ActiveRecord::Schema.define do + create_table 'authors', force: :cascade do |t| + t.string :name, null: false + t.text :bio + t.timestamps + end + + create_table 'books', force: :cascade do |t| + t.string :title, null: false + t.string :isbn + t.date :published_at + t.references :author + t.references :genre + t.timestamps + end + + create_table 'genres', force: :cascade do |t| + t.string :name, null: false + t.references :parent, foreign_key: { to_table: :genres } + t.timestamps + end +end + +# Before classes are loaded we need to define helper methods like `delegate_optional` +Errgonomic::Rails.setup_before + +class Author < ActiveRecord::Base + has_many :books +end + +class Book < ActiveRecord::Base + has_many :reviews + has_many :reviewers, through: :reviews, source: :user + belongs_to :author, optional: true + + delegate_optional :name, to: :author, prefix: true +end + +class Genre < ActiveRecord::Base + has_many :books + belongs_to :parent, class_name: 'Genre', optional: true +end + +# Optional associations have to be defined after the model is evaluated so we +# can reflect on those associations. +Errgonomic::Rails.setup_after + +class BugTest < Minitest::Test + def test_optional_attributes + author = Author.create!(name: 'Cixin Liu') + assert author.name.present? + assert author.bio.none? + book = author.books.create!(title: 'The Three-Body Problem') + assert book.isbn.none? + end + + def test_optional_associations + author = Author.create!(name: 'Cixin Liu') + book = author.books.create!(title: 'The Dark Forest') + assert book.author.some? + end + + def test_delegate_optional + author = Author.create!(name: 'Cixin Liu') + book = author.books.create!(title: 'Death\'s End') + assert book.author_name.some? + assert_equal author.name, book.author_name.unwrap! + end +end