├── .gitignore ├── .rspec ├── .travis.yml ├── Gemfile ├── README.md ├── Rakefile ├── bin ├── console └── setup ├── lib ├── safe_finder.rb └── safe_finder │ ├── class_wrapper.rb │ ├── generators │ ├── model_generator.rb │ └── templates │ │ └── models │ │ └── null_object.rb │ ├── null_object.rb │ ├── null_object_generator.rb │ └── version.rb ├── safe_finder.gemspec └── spec ├── class_wrapper_spec.rb ├── generators └── model_generator_spec.rb ├── null_object_generator_spec.rb ├── safe_finder_spec.rb ├── spec_helper.rb └── support └── models.rb /.gitignore: -------------------------------------------------------------------------------- 1 | /.bundle/ 2 | /.yardoc 3 | /Gemfile.lock 4 | /_yardoc/ 5 | /coverage/ 6 | /doc/ 7 | /pkg/ 8 | /spec/reports/ 9 | /tmp/ 10 | *.gem 11 | -------------------------------------------------------------------------------- /.rspec: -------------------------------------------------------------------------------- 1 | --format documentation 2 | --color 3 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: ruby 2 | sudo: false 3 | cache: bundler 4 | rvm: 5 | - 2.0 6 | - 2.1 7 | - 2.2 8 | - ruby-head 9 | 10 | notifications: 11 | email: 12 | recipients: 13 | - stan001212@gmail.com 14 | on_failure: change 15 | on_success: never 16 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source 'https://rubygems.org' 2 | 3 | # Specify your gem's dependencies in safe_finder.gemspec 4 | gemspec 5 | 6 | gem "pry", require: true 7 | gem "codeclimate-test-reporter", group: :test, require: nil 8 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![Build Status](https://travis-ci.org/st0012/SafeFinder.svg)](https://travis-ci.org/st0012/SafeFinder) 2 | [![Code Climate](https://codeclimate.com/github/st0012/SafeFinder/badges/gpa.svg)](https://codeclimate.com/github/st0012/SafeFinder) 3 | [![Test Coverage](https://codeclimate.com/github/st0012/SafeFinder/badges/coverage.svg)](https://codeclimate.com/github/st0012/SafeFinder/coverage) 4 | # SafeFinder 5 | 6 | SafeFinder lets you define a model's `Null Object` through a simple DSL, and returns that when you don't find an instance of that model. 7 | 8 | ## Installation 9 | 10 | Add this line to your application's `Gemfile`: 11 | 12 | ```ruby 13 | gem 'safe_finder' 14 | ``` 15 | 16 | And then execute: 17 | 18 | $ bundle 19 | 20 | Or install it yourself as: 21 | 22 | $ gem install safe_finder 23 | 24 | ## Usage 25 | 26 | ### Basic 27 | Let's say you have a `Post` class, and it has `title` and `content` column. 28 | 29 | First, you need to include `SafeFinder` in your model: 30 | 31 | ```ruby 32 | class Post < ActiveRecord::Base 33 | include SafeFinder 34 | end 35 | ``` 36 | 37 | Now you can query like this, and if it doesn't find anything it returns a `Null Object`: 38 | 39 | ```ruby 40 | # It returns a Null Object 41 | null_object = Post.safely.find_by_title("Not Exists") 42 | 43 | null_object.class # NullPost 44 | null_object.title # nil 45 | null_object.content # nil 46 | ``` 47 | 48 | ### Custom Attributes and Methods 49 | 50 | And you can customize the Null Object's attributes or methods using a DSL: 51 | 52 | ```ruby 53 | class Post < ActiveRecord::Base 54 | include SafeFinder 55 | 56 | safe_attribute :title, "Null" 57 | safe_method :some_method do 58 | "Do Something" 59 | end 60 | end 61 | ``` 62 | 63 | ```ruby 64 | null_object = Post.safely.find_by_title("Not Exists") 65 | null_object.title # "Null" 66 | null_object.some_method # "Do Something" 67 | ``` 68 | 69 | ### Get a Null Object directly 70 | 71 | Just simply use: 72 | ```ruby 73 | Post.null_object # 74 | ``` 75 | 76 | ### Inheritance 77 | 78 | All `Null Object`s inherit `SafeFinder::NullObject`, so you can add it in 79 | 80 | ``` 81 | app/models/safe_finder/null_object.rb 82 | ``` 83 | 84 | by generate it with 85 | 86 | ``` 87 | rails g safe_finder:model 88 | ``` 89 | 90 | and define general methods for every `Null Object` 91 | 92 | ```ruby 93 | module SafeFinder 94 | class NullObject 95 | def hello 96 | "Hello" 97 | end 98 | end 99 | end 100 | ``` 101 | 102 | ```ruby 103 | null_object = Post.null_object 104 | null_object.hello # "Hello" 105 | ``` 106 | 107 | 108 | ## TODOs 109 | 110 | - Add association support, like `user.post` should also returns `Null Object` when it's nil 111 | - More use cases in readme 112 | 113 | ## Development 114 | 115 | After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake spec` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment. 116 | 117 | To install this gem onto your local machine, run `bundle exec rake install`. To release a new version, update the version number in `version.rb`, and then run `bundle exec rake release`, which will create a git tag for the version, push git commits and tags, and push the `.gem` file to [rubygems.org](https://rubygems.org). 118 | 119 | ## Contributing 120 | 121 | Bug reports and pull requests are welcome on GitHub at https://github.com/st0012/SafeFinder. 122 | 123 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | require "bundler/gem_tasks" 2 | require "rspec/core/rake_task" 3 | 4 | RSpec::Core::RakeTask.new(:spec) 5 | 6 | task :default => :spec 7 | -------------------------------------------------------------------------------- /bin/console: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | 3 | require "bundler/setup" 4 | require "safe_finder" 5 | 6 | # You can add fixtures and/or initialization code here to make experimenting 7 | # with your gem easier. You can also use a different console, if you like. 8 | 9 | # (If you use this, don't forget to add pry to your Gemfile!) 10 | # require "pry" 11 | # Pry.start 12 | 13 | require "irb" 14 | IRB.start 15 | -------------------------------------------------------------------------------- /bin/setup: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -euo pipefail 3 | IFS=$'\n\t' 4 | 5 | bundle install 6 | 7 | # Do any other automated setup that you need to do here 8 | -------------------------------------------------------------------------------- /lib/safe_finder.rb: -------------------------------------------------------------------------------- 1 | require "safe_finder/generators/model_generator" 2 | require "safe_finder/null_object_generator" 3 | require "safe_finder/null_object" 4 | require "safe_finder/class_wrapper" 5 | require "safe_finder/version" 6 | 7 | module SafeFinder 8 | def self.included(base) 9 | base.extend(ClassMethods) 10 | base.singleton_class.class_eval do 11 | attr_accessor :null_object_attributes, :null_object_methods 12 | end 13 | base.class_eval do 14 | self.null_object_attributes = {} 15 | self.null_object_methods = {} 16 | end 17 | end 18 | 19 | module ClassMethods 20 | def safely 21 | ClassWrapper.new(self) 22 | end 23 | 24 | def null_object 25 | @null_object ||= SafeFinder::NullObjectGenerator.new(self).generate 26 | end 27 | 28 | def safe_attribute(key, value) 29 | null_object_attributes[key.to_sym] = value 30 | end 31 | 32 | def safe_method(method_name, &block) 33 | null_object_methods[method_name.to_sym] = block 34 | end 35 | end 36 | end 37 | -------------------------------------------------------------------------------- /lib/safe_finder/class_wrapper.rb: -------------------------------------------------------------------------------- 1 | module SafeFinder 2 | class ClassWrapper 3 | attr_reader :wrapped_class 4 | 5 | def initialize(target_class) 6 | @wrapped_class = target_class 7 | end 8 | 9 | def method_missing(name, *arguments, &block) 10 | result = wrapped_class.send(name, *arguments, &block) 11 | result.nil? ? wrapped_class.null_object : result 12 | rescue ActiveRecord::RecordNotFound 13 | wrapped_class.null_object 14 | end 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /lib/safe_finder/generators/model_generator.rb: -------------------------------------------------------------------------------- 1 | require "rails/generators" 2 | 3 | module SafeFinder 4 | class ModelGenerator < Rails::Generators::Base 5 | def self.source_root 6 | File.expand_path(File.join(File.dirname(__FILE__), 'templates')) 7 | end 8 | 9 | def create_model 10 | template "models/null_object.rb", File.join("app/models", "safe_finder", "null_object.rb") 11 | end 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /lib/safe_finder/generators/templates/models/null_object.rb: -------------------------------------------------------------------------------- 1 | module SafeFinder 2 | class NullObject 3 | 4 | end 5 | end 6 | -------------------------------------------------------------------------------- /lib/safe_finder/null_object.rb: -------------------------------------------------------------------------------- 1 | module SafeFinder 2 | class NullObject 3 | 4 | end 5 | end 6 | -------------------------------------------------------------------------------- /lib/safe_finder/null_object_generator.rb: -------------------------------------------------------------------------------- 1 | module SafeFinder 2 | class NullObjectGenerator 3 | attr_reader :original_class, :original_class_name, :null_class, :setted_columns 4 | 5 | def initialize(original_class) 6 | @original_class = original_class 7 | @original_class_name = original_class.to_s 8 | @null_class = create_null_class 9 | @setted_columns = original_class.null_object_attributes 10 | end 11 | 12 | def generate 13 | set_attributes 14 | set_methods 15 | null_class.new 16 | end 17 | 18 | private 19 | 20 | def create_null_class 21 | Object.const_set("Null#{original_class_name}", Class.new(NullObject)) 22 | end 23 | 24 | def get_attributes 25 | original_class.columns.inject({}) do |result, column| 26 | result[column.name] = get_value(column) 27 | result 28 | end 29 | end 30 | 31 | # I want to support default value here. 32 | # But column object's default value is string, and needs some mechanism be to converted into ruby's type 33 | def get_value(column) 34 | if value = setted_columns[column.name.to_sym] 35 | value 36 | end 37 | end 38 | 39 | def set_methods 40 | methods = original_class.null_object_methods 41 | null_class.class_eval do 42 | methods.each do |key, value| 43 | define_method key, value 44 | end 45 | end 46 | end 47 | 48 | def set_attributes 49 | attributes = get_attributes 50 | null_class.class_eval do 51 | attributes.each do |key, value| 52 | define_method key do 53 | value 54 | end 55 | end 56 | end 57 | end 58 | end 59 | end 60 | -------------------------------------------------------------------------------- /lib/safe_finder/version.rb: -------------------------------------------------------------------------------- 1 | module SafeFinder 2 | VERSION = "0.1.1" 3 | end 4 | -------------------------------------------------------------------------------- /safe_finder.gemspec: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | lib = File.expand_path('../lib', __FILE__) 3 | $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib) 4 | require 'safe_finder/version' 5 | 6 | Gem::Specification.new do |spec| 7 | spec.name = "safe_finder" 8 | spec.version = SafeFinder::VERSION 9 | spec.authors = ["Stan Loe"] 10 | spec.email = ["stan001212@gmail.com"] 11 | 12 | spec.summary = %q{SafeFinder lets you define model's null_object through simple DSL, and returns it when you can't find a result.} 13 | spec.description = %q{SafeFinder lets you define model's null_object through simple DSL, and returns it when you can't find a result.} 14 | spec.homepage = "https://github.com/st0012/SafeFinder" 15 | 16 | # Prevent pushing this gem to RubyGems.org by setting 'allowed_push_host', or 17 | # delete this section to allow pushing this gem to any host. 18 | # if spec.respond_to?(:metadata) 19 | # spec.metadata['allowed_push_host'] = "TODO: Set to 'http://mygemserver.com'" 20 | # else 21 | # raise "RubyGems 2.0 or newer is required to protect against public gem pushes." 22 | # end 23 | 24 | spec.files = `git ls-files -z`.split("\x0").reject { |f| f.match(%r{^(test|spec|features)/}) } 25 | spec.bindir = "exe" 26 | spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) } 27 | spec.require_paths = ["lib"] 28 | 29 | spec.add_development_dependency "bundler" 30 | spec.add_development_dependency "database_cleaner" 31 | spec.add_development_dependency "rake", "~> 10.0" 32 | spec.add_development_dependency "rspec" 33 | spec.add_development_dependency "sqlite3" 34 | spec.add_development_dependency "generator_spec" 35 | 36 | spec.add_dependency "rails", ">= 4.0" 37 | spec.add_dependency "activerecord", ">= 4.0" 38 | end 39 | -------------------------------------------------------------------------------- /spec/class_wrapper_spec.rb: -------------------------------------------------------------------------------- 1 | require "spec_helper" 2 | 3 | describe SafeFinder::ClassWrapper do 4 | before do 5 | User.class_eval do 6 | include SafeFinder 7 | end 8 | end 9 | 10 | describe ".safely" do 11 | let(:user) { User.create(email: "test@example.com", name: "Stan") } 12 | describe ".find" do 13 | it "returns result if found" do 14 | result = User.safely.find(user.id) 15 | expect(result).to eq(user) 16 | end 17 | 18 | it "returns null_object if can't find" do 19 | result = User.safely.find(user.id + 1) 20 | expect(result.class).to eq(NullUser) 21 | end 22 | 23 | it "raises exception if not using safely scope" do 24 | expect{ User.find(user.id + 1) }.to raise_error(ActiveRecord::RecordNotFound) 25 | end 26 | end 27 | 28 | describe ".find_by_COLUMN" do 29 | it "returns result if found" do 30 | expect(User.safely.find_by_email(user.email)).to eq(user) 31 | end 32 | 33 | it "returns null_object if not found" do 34 | null_object = User.safely.find_by_email(user.email << "123") 35 | expect(null_object.class).to eq(NullUser) 36 | end 37 | 38 | it "returns nil if not found and without safely scope" do 39 | expect(User.find_by_email(user.email < "123")).to be_nil 40 | end 41 | end 42 | end 43 | end 44 | -------------------------------------------------------------------------------- /spec/generators/model_generator_spec.rb: -------------------------------------------------------------------------------- 1 | require "spec_helper" 2 | 3 | describe SafeFinder::ModelGenerator, type: :generator do 4 | destination File.expand_path("../../../../tmp", __FILE__) 5 | 6 | describe "generate" do 7 | before do 8 | prepare_destination 9 | run_generator 10 | end 11 | it "generates file in right place with right content" do 12 | expect(destination_root).to have_structure { 13 | directory "app" do 14 | directory "models" do 15 | directory "safe_finder" do 16 | file "null_object.rb" do 17 | contains <<"EOF" 18 | module SafeFinder 19 | class NullObject 20 | 21 | end 22 | end 23 | EOF 24 | end 25 | end 26 | end 27 | end 28 | } 29 | end 30 | end 31 | 32 | describe ".create_model" do 33 | it "calls template with right directroy" do 34 | expect(subject).to receive(:template).with("models/null_object.rb", File.join("app/models", "safe_finder", "null_object.rb")) 35 | 36 | subject.create_model 37 | end 38 | end 39 | end 40 | 41 | -------------------------------------------------------------------------------- /spec/null_object_generator_spec.rb: -------------------------------------------------------------------------------- 1 | require "spec_helper" 2 | 3 | describe SafeFinder::NullObjectGenerator do 4 | let(:sample_class) { Post } 5 | subject { SafeFinder::NullObjectGenerator.new(sample_class) } 6 | 7 | before do 8 | Post.class_eval do 9 | include SafeFinder 10 | 11 | safe_attribute :title, "It's null" 12 | safe_attribute :is_published, false 13 | safe_attribute :view_count, 0 14 | safe_method :hello do 15 | "Hello" 16 | end 17 | end 18 | end 19 | 20 | describe ".new" do 21 | it "sets right attributes" do 22 | expect(subject.original_class).to eq(Post) 23 | end 24 | end 25 | 26 | describe "#generate" do 27 | it "generates null object with right class" do 28 | null_object = subject.generate 29 | 30 | expect(null_object.class.to_s).to eq("NullPost") 31 | end 32 | 33 | it "generates null object with right values" do 34 | null_object = subject.generate 35 | 36 | expect(null_object.title).to eq("It's null") 37 | expect(null_object.user_id).to be_nil 38 | expect(null_object.is_published).to be_falsey 39 | expect(null_object.view_count).to eq(0) 40 | expect(null_object.content).to be_nil 41 | end 42 | 43 | it "generates null object with right methods" do 44 | null_object = subject.generate 45 | 46 | expect(null_object.hello).to eq("Hello") 47 | end 48 | end 49 | end 50 | -------------------------------------------------------------------------------- /spec/safe_finder_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | describe SafeFinder do 4 | let(:subject_class) do 5 | class TestClass; end 6 | TestClass 7 | end 8 | 9 | it 'has a version number' do 10 | expect(SafeFinder::VERSION).not_to be nil 11 | end 12 | 13 | before do 14 | subject_class.class_eval do 15 | include SafeFinder 16 | 17 | safe_attribute(:test_attribute, "test value") 18 | safe_method :test_method do 19 | "This is test method" 20 | end 21 | end 22 | end 23 | 24 | describe ".safe_attribute" do 25 | it "sets null_object_attributes" do 26 | expect(subject_class.null_object_attributes[:test_attribute]).to eq("test value") 27 | end 28 | end 29 | 30 | describe ".safe_method" do 31 | it "sets null_object_attributes" do 32 | expect(subject_class.null_object_methods[:test_method].call).to eq("This is test method") 33 | end 34 | end 35 | 36 | describe ".null_object" do 37 | it "returns null_object" do 38 | null_object = Post.null_object 39 | expect(null_object.class).to eq(NullPost) 40 | end 41 | end 42 | end 43 | 44 | -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | $LOAD_PATH.unshift File.expand_path('../../lib', __FILE__) 2 | require "codeclimate-test-reporter" 3 | CodeClimate::TestReporter.start 4 | require "safe_finder" 5 | require "generator_spec" 6 | require "pry" 7 | require "support/models" 8 | require "rspec" 9 | require "database_cleaner" 10 | 11 | RSpec.configure do |config| 12 | 13 | config.before(:suite) do 14 | DatabaseCleaner.strategy = :transaction 15 | DatabaseCleaner.clean_with(:truncation) 16 | end 17 | 18 | config.around(:each) do |example| 19 | DatabaseCleaner.cleaning do 20 | example.run 21 | end 22 | end 23 | 24 | end 25 | -------------------------------------------------------------------------------- /spec/support/models.rb: -------------------------------------------------------------------------------- 1 | require "active_record" 2 | 3 | ActiveRecord::Base.default_timezone = "Taipei" 4 | ActiveRecord::Base.time_zone_aware_attributes = true 5 | 6 | # migrations 7 | ActiveRecord::Base.establish_connection adapter: "sqlite3", database: "tmp/safe_finder" 8 | 9 | ActiveRecord::Base.raise_in_transactional_callbacks = true if ActiveRecord::Base.respond_to?(:raise_in_transactional_callbacks=) 10 | 11 | unless ActiveRecord::Base.connection.table_exists?("posts") || 12 | ActiveRecord::Base.connection.table_exists?("users") 13 | ActiveRecord::Migration.create_table :posts do |t| 14 | t.string :title 15 | t.integer :user_id 16 | t.boolean :is_published, default: false 17 | t.integer :view_count, default:0 18 | t.text :content 19 | t.timestamps null: true 20 | end 21 | 22 | ActiveRecord::Migration.create_table :users do |t| 23 | t.string :name 24 | t.string :email 25 | end 26 | end 27 | class Post < ActiveRecord::Base 28 | end 29 | 30 | class User < ActiveRecord::Base 31 | end 32 | 33 | --------------------------------------------------------------------------------