Skip to content

Commit

Permalink
API: added a lookup based on class name
Browse files Browse the repository at this point in the history
## Usage

```ruby
class Scope
  extend Magic::Lookup

  def self.name_for object_class
    object_class.name
        .delete_suffix('Model')
        .concat('Scope')
  end
end

class MyScope < Scope
end

Scope.for MyModel    # => MyScope
Scope.for OtherModel # => nil
```
  • Loading branch information
Alexander-Senko committed Oct 10, 2024
1 parent fa1cdbc commit 362d208
Show file tree
Hide file tree
Showing 4 changed files with 163 additions and 9 deletions.
28 changes: 26 additions & 2 deletions lib/magic/lookup.rb
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,33 @@

require_relative 'lookup/version'

require 'memery'

module Magic
module Lookup
class Error < StandardError; end
# Your code goes here...
include Memery

memoize def for object_class
descendants = self.descendants # cache
.reverse # most specific first

object_class.ancestors
.lazy # optimization
.filter(&:name)
.map { name_for _1 }
.filter_map do |class_name|
descendants.find { _1.name == class_name }
end
.first
end

def name_for(object_class) = raise NotImplementedError

def descendants
[
*subclasses,
*subclasses.flat_map(&__method__),
]
end
end
end
2 changes: 2 additions & 0 deletions magic-lookup.gemspec
Original file line number Diff line number Diff line change
Expand Up @@ -28,4 +28,6 @@ Gem::Specification.new do |spec|
end

spec.required_ruby_version = '~> 3.1'

spec.add_dependency 'memery'
end
6 changes: 5 additions & 1 deletion sig/magic/lookup.rbs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,10 @@ module Magic
VERSION: String
AUTHORS: Array[Gem::Author]

# See the writing guide of rbs: https://github.com/ruby/rbs#guides
def for: (Class) -> Class?

def name_for: (Module) -> String

def descendants: -> Array[Class]
end
end
136 changes: 130 additions & 6 deletions spec/magic/lookup_spec.rb
Original file line number Diff line number Diff line change
@@ -1,11 +1,135 @@
# frozen_string_literal: true

RSpec.describe Magic::Lookup do
it 'has a version number' do
expect(Magic::Lookup::VERSION).not_to be nil
end
module Magic
RSpec.describe Lookup do
subject { base_class.new }

let :base_class do
mixin = described_class

Class.new do
extend mixin

def self.name_for(object_class) = "#{object_class}Scope"
end
end

describe '.for', :method do
it 'passes `Module`s to `.name_for`' do
expect(base_class).to receive(:name_for).with(kind_of Module)
.and_call_original.at_least(1).time

subject[Array]
end

context 'without `.name_for` explicitly defined' do
let(:base_class) { Class.new.tap { _1.extend described_class } }

it { expect { subject[Array] }.to raise_error NotImplementedError }
end

context 'without decorators defined' do
its([Array]) { is_expected.to be_nil }
end

context 'without matching decorators defined' do
before { stub_const 'ArrayDecorator', Class.new(base_class) }

its([Array]) { is_expected.to be_nil }
end

context 'when a class with a matching name does not inherit from this one' do
before { stub_const 'ArrayScope', Class.new }

its([Array]) { is_expected.to be_nil }
end

context 'with a matching decorator' do
before { stub_const 'ArrayScope', Class.new(base_class) }

its([Array]) { is_expected.to be ArrayScope }
end

describe 'inheritance' do
before { stub_const 'EnumerableScope', Class.new(base_class) }

its([Array]) { is_expected.to be EnumerableScope }

context 'with several matches' do
before { stub_const 'ArrayScope', Class.new(parent_class) }

context 'when siblings' do
let(:parent_class) { base_class }

its([Array]) { is_expected.to be ArrayScope }
end

context 'when inherited' do
let(:parent_class) { EnumerableScope }

its([Array]) { is_expected.to be ArrayScope }
end
end
end

describe 'namespaces' do
context 'when target class is namespaced' do
context 'when matching class is of the same namespace' do
before { stub_const 'Enumerator::LazyScope', Class.new(base_class) }

its([Enumerator::Lazy]) { is_expected.to be Enumerator::LazyScope }
end

context 'when matching class is of another namespace' do
before { stub_const 'EnumeratorScope', Class.new(base_class) }

its([Enumerator::Lazy]) { is_expected.to be EnumeratorScope }
end
end

context 'when matching classes are namespaced' do
before { def base_class.name_for(object_class) = "Scope::#{object_class}" }

before { stub_const 'Scope::Array', Class.new(base_class) }

its([Array]) { is_expected.to be Scope::Array }

context 'when target class is namespaced' do
context 'when matching class is of the same namespace' do
before { stub_const 'Scope::Enumerator::Lazy', Class.new(base_class) }

its([Enumerator::Lazy]) { is_expected.to be Scope::Enumerator::Lazy }
end

context 'when matching class is of another namespace' do
before { stub_const 'Scope::Enumerator', Class.new(base_class) }

its([Enumerator::Lazy]) { is_expected.to be Scope::Enumerator }
end
end
end
end

describe 'optimizations' do
before { allow(base_class).to receive(:descendants).and_call_original }

it 'caches results' do
2.times { subject[Array] }

expect(base_class).to have_received(:descendants)
.once
end

it 'caches results per class' do
2.times do
subject[Array]
subject[Hash]
end

it 'does something useful' do
expect(false).to eq(true)
expect(base_class).to have_received(:descendants)
.exactly(2).times
end
end
end
end
end

0 comments on commit 362d208

Please sign in to comment.