lookup_by 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. data/.gitignore +11 -0
  2. data/.rvmrc +1 -0
  3. data/.travis.yml +9 -0
  4. data/Gemfile +16 -0
  5. data/MIT-LICENSE +20 -0
  6. data/README.md +230 -0
  7. data/Rakefile +14 -0
  8. data/TODO.md +16 -0
  9. data/lib/lookup_by/association.rb +89 -0
  10. data/lib/lookup_by/cache.rb +145 -0
  11. data/lib/lookup_by/caching/lru.rb +57 -0
  12. data/lib/lookup_by/caching/safe_lru.rb +30 -0
  13. data/lib/lookup_by/cucumber.rb +7 -0
  14. data/lib/lookup_by/hooks/cucumber.rb +9 -0
  15. data/lib/lookup_by/hooks/formtastic.rb +27 -0
  16. data/lib/lookup_by/hooks/simple_form.rb +27 -0
  17. data/lib/lookup_by/lookup.rb +113 -0
  18. data/lib/lookup_by/railtie.rb +20 -0
  19. data/lib/lookup_by/version.rb +3 -0
  20. data/lib/lookup_by.rb +27 -0
  21. data/lookup_by.gemspec +23 -0
  22. data/spec/association_spec.rb +102 -0
  23. data/spec/caching/lru_spec.rb +72 -0
  24. data/spec/dummy/.rspec +1 -0
  25. data/spec/dummy/Rakefile +14 -0
  26. data/spec/dummy/app/models/.gitkeep +0 -0
  27. data/spec/dummy/app/models/account.rb +5 -0
  28. data/spec/dummy/app/models/address.rb +6 -0
  29. data/spec/dummy/app/models/city.rb +5 -0
  30. data/spec/dummy/app/models/email_address.rb +5 -0
  31. data/spec/dummy/app/models/ip_address.rb +5 -0
  32. data/spec/dummy/app/models/postal_code.rb +5 -0
  33. data/spec/dummy/app/models/state.rb +5 -0
  34. data/spec/dummy/app/models/status.rb +9 -0
  35. data/spec/dummy/app/models/street.rb +5 -0
  36. data/spec/dummy/config/application.rb +20 -0
  37. data/spec/dummy/config/boot.rb +10 -0
  38. data/spec/dummy/config/database.yml +52 -0
  39. data/spec/dummy/config/environment.rb +5 -0
  40. data/spec/dummy/config/environments/development.rb +15 -0
  41. data/spec/dummy/config/environments/test.rb +16 -0
  42. data/spec/dummy/config.ru +4 -0
  43. data/spec/dummy/db/migrate/20121019040009_create_tables.rb +23 -0
  44. data/spec/dummy/db/schema.rb +71 -0
  45. data/spec/dummy/lib/missing.rb +3 -0
  46. data/spec/dummy/log/.gitkeep +0 -0
  47. data/spec/dummy/script/rails +6 -0
  48. data/spec/lookup_by_spec.rb +100 -0
  49. data/spec/spec_helper.rb +43 -0
  50. data/spec/support/shared_examples_for_a_lookup.rb +163 -0
  51. metadata +140 -0
@@ -0,0 +1,27 @@
1
+ require "active_support/concern"
2
+
3
+ module LookupBy
4
+ module Hooks
5
+ module SimpleForm
6
+ extend ActiveSupport::Concern
7
+
8
+ included do
9
+ alias_method_chain :input, :lookup
10
+ end
11
+
12
+ def input_with_lookup(method, options = {}, &block)
13
+ klass = object.class
14
+
15
+ if klass.respond_to?(:lookups) && klass.lookups.include?(method.to_sym)
16
+ target = method.to_s.classify.constantize
17
+
18
+ options[:collection] ||= target.pluck(target.lookup.field) if target.lookup.cache_all?
19
+ end
20
+
21
+ input_without_lookup(method, options, &block)
22
+ end
23
+ end
24
+ end
25
+ end
26
+
27
+ ::SimpleForm::FormBuilder.send :include, LookupBy::Hooks::SimpleForm
@@ -0,0 +1,113 @@
1
+ module LookupBy
2
+ module Lookup
3
+ module MacroMethods
4
+ def is_a_lookup?
5
+ is_a? LookupBy::Lookup::ClassMethods
6
+ end
7
+
8
+ def lookup_by(field, options = {})
9
+ options.symbolize_keys!
10
+ options.assert_valid_keys :order, :cache, :normalize, :find, :find_or_create, :raise
11
+
12
+ raise "#{self} already uses lookup_by" if is_a? LookupBy::Lookup::ClassMethods
13
+ raise "#{self} responds_to :[], needed for lookup_by" if respond_to? :[]
14
+ raise "#{self} responds_to :lookup, needed for lookup_by" if respond_to? :lookup
15
+
16
+ extend ClassMethods
17
+
18
+ class_eval do
19
+ include InstanceMethods
20
+
21
+ class << self; attr_reader :lookup; end
22
+
23
+ # validates field, presence: true, uniqueness: true
24
+
25
+ unless field == :name || column_names.include?("name")
26
+ alias_attribute :name, field
27
+
28
+ attr_accessible :name if respond_to?(:accessible_attributes) && accessible_attributes.include?(field)
29
+ end
30
+
31
+ @lookup = Cache.new(self, options.merge(field: field))
32
+ @lookup.reload
33
+ end
34
+ end
35
+ end
36
+
37
+ module ClassMethods
38
+ def all
39
+ return super if @lookup.read_through?
40
+
41
+ @lookup.cache.values
42
+ end
43
+
44
+ def count(column_name = nil, options = {})
45
+ return super if @lookup.read_through?
46
+ return super if column_name
47
+
48
+ @lookup.cache.size
49
+ end
50
+
51
+ def pluck(column_name)
52
+ return super if @lookup.read_through?
53
+
54
+ @lookup.cache.values.map { |o| o.send(column_name) }
55
+ end
56
+
57
+ def [](arg)
58
+ case arg
59
+ when nil, "" then nil
60
+ when String then @lookup.fetch(arg)
61
+ when Symbol then @lookup.fetch(arg.to_s)
62
+ when Fixnum then @lookup.fetch(arg)
63
+ when self then arg
64
+ else raise TypeError, "#{name}[arg]: arg must be a String, Symbol, Fixnum, nil, or #{name}"
65
+ end
66
+ end
67
+ end
68
+
69
+ module InstanceMethods
70
+ def ===(arg)
71
+ case arg
72
+ when Symbol, String, Fixnum, nil
73
+ return self == self.class[arg]
74
+ when Array
75
+ return !!arg.detect { |i| self === i }
76
+ end
77
+
78
+ super
79
+ end
80
+ end
81
+
82
+ module SchemaMethods
83
+ def create_lookup_table(table_name, options = {})
84
+ lookup_column = options[:lookup_column] || table_name.to_s.singularize
85
+ primary_key = options[:primary_key] || table_name.to_s.singularize + "_id"
86
+
87
+ create_table table_name, primary_key: primary_key do |t|
88
+ t.text lookup_column, null: false
89
+
90
+ yield t if block_given?
91
+ end
92
+
93
+ add_index table_name, lookup_column, unique: true
94
+ end
95
+
96
+ def create_lookup_tables(*table_names)
97
+ table_names.each do |table_name|
98
+ create_lookup_table table_name
99
+ end
100
+ end
101
+ end
102
+
103
+ module CommandRecorderMethods
104
+ def create_lookup_table(*args)
105
+ record(:create_lookup_table, args)
106
+ end
107
+
108
+ def invert_create_lookup_table(args)
109
+ [:drop_table, [args.first]]
110
+ end
111
+ end
112
+ end
113
+ end
@@ -0,0 +1,20 @@
1
+ require "active_record/railtie"
2
+
3
+ module LookupBy
4
+ class Railtie < ::Rails::Railtie
5
+ initializer "lookup_by" do
6
+ ActiveSupport.on_load :active_record do
7
+ extend Lookup::MacroMethods
8
+ extend Association::MacroMethods
9
+
10
+ ActiveRecord::ConnectionAdapters::AbstractAdapter.class_eval do
11
+ include Lookup::SchemaMethods
12
+ end
13
+
14
+ ActiveRecord::Migration::CommandRecorder.class_eval do
15
+ include Lookup::CommandRecorderMethods
16
+ end
17
+ end
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,3 @@
1
+ module LookupBy
2
+ VERSION = "0.1.0"
3
+ end
data/lib/lookup_by.rb ADDED
@@ -0,0 +1,27 @@
1
+ require "lookup_by/version"
2
+ require "lookup_by/railtie" if defined? Rails
3
+
4
+ module LookupBy
5
+ class Error < StandardError; end
6
+
7
+ autoload :Association, "lookup_by/association"
8
+ autoload :Cache, "lookup_by/cache"
9
+ autoload :Lookup, "lookup_by/lookup"
10
+
11
+ module Caching
12
+ autoload :LRU, "lookup_by/caching/lru"
13
+ autoload :SafeLRU, "lookup_by/caching/safe_lru"
14
+ end
15
+ end
16
+
17
+ begin
18
+ require "simple_form"
19
+ require "lookup_by/hooks/simple_form"
20
+ rescue LoadError
21
+ end
22
+
23
+ begin
24
+ require "formtastic"
25
+ require "lookup_by/hooks/formtastic"
26
+ rescue LoadError
27
+ end
data/lookup_by.gemspec ADDED
@@ -0,0 +1,23 @@
1
+ # -*- encoding: utf-8 -*-
2
+ $:.push File.expand_path("../lib", __FILE__)
3
+
4
+ require "lookup_by/version"
5
+
6
+ Gem::Specification.new do |gem|
7
+ gem.name = "lookup_by"
8
+ gem.version = LookupBy::VERSION
9
+
10
+ gem.summary = %q(A thread-safe lookup table cache for ActiveRecord)
11
+ gem.description = %q(Use database lookup tables in AR models.)
12
+
13
+ gem.authors = ["Erik Peterson"]
14
+ gem.email = ["erik@enova.com"]
15
+
16
+ gem.homepage = "http://www.github.com/companygardener/lookup_by"
17
+
18
+ gem.executables = `git ls-files -- bin/*`.split("\n").map { |f| File.basename(f) }
19
+ gem.files = `git ls-files`.split("\n")
20
+ gem.test_files = `git ls-files -- {test,spec,features}/*`.split("\n")
21
+
22
+ gem.add_dependency "rails", ">= 3.0.0"
23
+ end
@@ -0,0 +1,102 @@
1
+ require "spec_helper"
2
+ require "lookup_by"
3
+ require "pry"
4
+
5
+ describe ::ActiveRecord::Base do
6
+ describe "macro methods" do
7
+ subject { described_class }
8
+
9
+ it { should respond_to :lookup_for }
10
+ end
11
+
12
+ describe ".lookup_for" do
13
+ subject { Address }
14
+
15
+ it "doesn't clobber methods" do
16
+ class << subject
17
+ public :define_method, :remove_method
18
+ end
19
+
20
+ [:foo, :foo=, :raw_foo, :foo_before_type_cast].each do |method|
21
+ subject.define_method(method) { }
22
+
23
+ expect { subject.lookup_for :foo }.to raise_error LookupBy::Error, /already exists/
24
+
25
+ subject.remove_method(method)
26
+ end
27
+ end
28
+
29
+ it "requires a foreign key" do
30
+ expect { subject.lookup_for :missing }.to raise_error LookupBy::Error, /foreign key/
31
+ end
32
+
33
+ it "rejects unsaved lookup values" do
34
+ expect { subject.new.city = City.new(name: "Toronto") }.to raise_error ArgumentError, /must be saved/
35
+ end
36
+ end
37
+ end
38
+
39
+ describe LookupBy::Association do
40
+ before do
41
+ City.create(name: "New York")
42
+ end
43
+
44
+ after do
45
+ City.delete_all
46
+ end
47
+
48
+ subject { Address.new }
49
+
50
+ context "Address.lookup_for :city, strict: false" do
51
+ it_behaves_like "a lookup for", :city
52
+
53
+ it "accepts Fixnums" do
54
+ subject.city = City.where(city: "New York").first.id
55
+ subject.city.should eq "New York"
56
+ end
57
+
58
+ it "rejects symbols" do
59
+ expect { subject.city = :'New York' }.to raise_error ArgumentError
60
+ end
61
+
62
+ it "returns strings" do
63
+ subject.city = "New York"
64
+ subject.city.should eq "New York"
65
+ end
66
+
67
+ it "allows missing values" do
68
+ subject.city = "Chicago"
69
+ subject.city.should be_nil
70
+ end
71
+ end
72
+
73
+ context "Address.lookup_for :state, symbolize: true" do
74
+ it_behaves_like "a lookup for", :state
75
+
76
+ it "allows symbols" do
77
+ subject.state = :AL
78
+ subject.state.should eq :AL
79
+ end
80
+
81
+ it "returns symbols" do
82
+ subject.state = "AL"
83
+ subject.state.should eq :AL
84
+ end
85
+
86
+ it "rejects missing values" do
87
+ expect { subject.state = "FOO" }.to raise_error LookupBy::Error, /not in the .* lookup cache/
88
+ end
89
+ end
90
+
91
+ context "Address.lookup_for :street" do
92
+ it "accepts write-through values" do
93
+ expect { subject.street = "Dearborn Street" }.to change(Street, :count)
94
+ end
95
+ end
96
+
97
+ context "Missing.lookup_for :city" do
98
+ it "does not raise foreign key error when table hasn't been created" do
99
+ expect { require "missing"; }.to_not raise_error
100
+ end
101
+ end
102
+ end
@@ -0,0 +1,72 @@
1
+ require "spec_helper"
2
+ require "lookup_by/caching/lru"
3
+
4
+ include LookupBy::Caching
5
+
6
+ module LookupBy::Caching
7
+ describe LRU do
8
+ before(:each) do
9
+ @cache = LRU.new(2)
10
+
11
+ @cache[1] = "one"
12
+ @cache[2] = "two"
13
+ end
14
+
15
+ subject { @cache }
16
+
17
+ it "stores entries" do
18
+ @cache[1].should eq "one"
19
+ @cache[2].should eq "two"
20
+ end
21
+
22
+ it "drops oldest" do
23
+ @cache[3] = "three"
24
+
25
+ @cache[1].should be_nil
26
+ end
27
+
28
+ it "keeps gets" do
29
+ @cache[1]
30
+ @cache[3] = "three"
31
+
32
+ @cache[1].should eq "one"
33
+ @cache[2].should be_nil
34
+ @cache[3].should eq "three"
35
+ end
36
+
37
+ it "keeps sets" do
38
+ @cache[1] = "one"
39
+ @cache[3] = "three"
40
+
41
+ @cache[1].should eq "one"
42
+ @cache[2].should be_nil
43
+ @cache[3].should eq "three"
44
+ end
45
+
46
+ it "#clear" do
47
+ cache = LRU.new(2)
48
+
49
+ cache[1] = "one"
50
+ cache.size.should eq 1
51
+ cache.clear
52
+ cache.size.should eq 0
53
+ end
54
+
55
+ specify "#merge" do
56
+ @cache.merge(1 => "change", 3 => "three").should
57
+ eq(1 => "change", 2 => "two", 3 => "three")
58
+ end
59
+
60
+ specify "#merge!" do
61
+ cache = LRU.new(3)
62
+
63
+ cache[1] = "one"
64
+ cache[2] = "two"
65
+
66
+ cache.merge!(1 => "change", 3 => "three")
67
+ cache.should eq(1 => "change", 2 => "two", 3 => "three")
68
+ end
69
+
70
+ its(:to_h) { should eq(1 => "one", 2 => "two") }
71
+ end
72
+ end
data/spec/dummy/.rspec ADDED
@@ -0,0 +1 @@
1
+ --color
@@ -0,0 +1,14 @@
1
+ #!/usr/bin/env rake
2
+ # Add your own tasks in files placed in lib/tasks ending in .rake,
3
+ # for example lib/tasks/capistrano.rake, and they will automatically be available to Rake.
4
+
5
+ require File.expand_path('../config/application', __FILE__)
6
+
7
+ Dummy::Application.load_tasks
8
+
9
+ require "rspec/core/rake_task"
10
+ RSpec::Core::RakeTask.new(:spec) do |config|
11
+ config.pattern = FileList["../**/*_spec.rb"]
12
+ end
13
+
14
+ task :default => :spec
File without changes
@@ -0,0 +1,5 @@
1
+ class Account < ActiveRecord::Base
2
+ attr_accessible :account
3
+
4
+ lookup_by :account, cache: true, find: true
5
+ end
@@ -0,0 +1,6 @@
1
+ class Address < ActiveRecord::Base
2
+ lookup_for :city, strict: false
3
+ lookup_for :state, symbolize: true
4
+ lookup_for :postal_code
5
+ lookup_for :street
6
+ end
@@ -0,0 +1,5 @@
1
+ class City < ActiveRecord::Base
2
+ attr_accessible :city
3
+
4
+ lookup_by :city
5
+ end
@@ -0,0 +1,5 @@
1
+ class EmailAddress < ActiveRecord::Base
2
+ attr_accessible :email_address
3
+
4
+ lookup_by :email_address, find_or_create: true
5
+ end
@@ -0,0 +1,5 @@
1
+ class IpAddress < ActiveRecord::Base
2
+ attr_accessible :ip_address
3
+
4
+ lookup_by :ip_address, cache: 2, find_or_create: true
5
+ end
@@ -0,0 +1,5 @@
1
+ class PostalCode < ActiveRecord::Base
2
+ attr_accessible :postal_code
3
+
4
+ lookup_by :postal_code, cache: 2
5
+ end
@@ -0,0 +1,5 @@
1
+ class State < ActiveRecord::Base
2
+ attr_accessible :state
3
+
4
+ lookup_by :state, cache: true
5
+ end
@@ -0,0 +1,9 @@
1
+ class Status < ActiveRecord::Base
2
+ attr_accessible :status
3
+
4
+ lookup_by :status, normalize: true
5
+
6
+ def status=(arg)
7
+ write_attribute :status, arg.strip if arg.respond_to?(:strip)
8
+ end
9
+ end
@@ -0,0 +1,5 @@
1
+ class Street < ActiveRecord::Base
2
+ attr_accessible :street
3
+
4
+ lookup_by :street, find_or_create: true
5
+ end
@@ -0,0 +1,20 @@
1
+ require File.expand_path('../boot', __FILE__)
2
+
3
+ require "active_record/railtie"
4
+
5
+ Bundler.require
6
+ require "lookup_by"
7
+
8
+ module Dummy
9
+ class Application < Rails::Application
10
+ # Configure the default encoding used in templates for Ruby 1.9.
11
+ config.encoding = "utf-8"
12
+
13
+ # Enforce whitelist mode for mass assignment.
14
+ # This will create an empty whitelist of attributes available for mass-assignment for all models
15
+ # in your app. As such, your models will need to explicitly whitelist or blacklist accessible
16
+ # parameters by using an attr_accessible or attr_protected declaration.
17
+ config.active_record.whitelist_attributes = true
18
+ end
19
+ end
20
+
@@ -0,0 +1,10 @@
1
+ require 'rubygems'
2
+ gemfile = File.expand_path('../../../../Gemfile', __FILE__)
3
+
4
+ if File.exist?(gemfile)
5
+ ENV['BUNDLE_GEMFILE'] = gemfile
6
+ require 'bundler'
7
+ Bundler.setup
8
+ end
9
+
10
+ $:.unshift File.expand_path('../../../../lib', __FILE__)
@@ -0,0 +1,52 @@
1
+ # PostgreSQL. Versions 8.2 and up are supported.
2
+ #
3
+ # Install the pg driver:
4
+ # gem install pg
5
+ # On Mac OS X with macports:
6
+ # gem install pg -- --with-pg-config=/opt/local/lib/postgresql84/bin/pg_config
7
+ # On Windows:
8
+ # gem install pg
9
+ # Choose the win32 build.
10
+ # Install PostgreSQL and put its /bin directory on your path.
11
+ #
12
+ # Configure Using Gemfile
13
+ # gem 'pg'
14
+ #
15
+ development:
16
+ adapter: postgresql
17
+ encoding: unicode
18
+ database: lookup_by_development
19
+ pool: 5
20
+ username: dummy
21
+ password:
22
+
23
+ # Connect on a TCP socket. Omitted by default since the client uses a
24
+ # domain socket that doesn't need configuration. Windows does not have
25
+ # domain sockets, so uncomment these lines.
26
+ #host: localhost
27
+ #port: 5432
28
+
29
+ # Schema search path. The server defaults to $user,public
30
+ #schema_search_path: myapp,sharedapp,public
31
+
32
+ # Minimum log levels, in increasing order:
33
+ # debug5, debug4, debug3, debug2, debug1,
34
+ # log, notice, warning, error, fatal, and panic
35
+ # The server defaults to notice.
36
+ #min_messages: warning
37
+
38
+ # Warning: The database defined as "test" will be erased and
39
+ # re-generated from your development database when you run "rake".
40
+ # Do not set this db to the same as development or production.
41
+ test:
42
+ adapter: postgresql
43
+ database: lookup_by_test
44
+ username: postgres
45
+
46
+ production:
47
+ adapter: postgresql
48
+ encoding: unicode
49
+ database: dummy_production
50
+ pool: 5
51
+ username: dummy
52
+ password:
@@ -0,0 +1,5 @@
1
+ # Load the rails application
2
+ require File.expand_path('../application', __FILE__)
3
+
4
+ # Initialize the rails application
5
+ Dummy::Application.initialize!
@@ -0,0 +1,15 @@
1
+ Dummy::Application.configure do
2
+ # In the development environment your application's code is reloaded on
3
+ # every request. This slows down response time but is perfect for development
4
+ # since you don't have to restart the web server when you make code changes.
5
+ config.cache_classes = false
6
+
7
+ # Log error messages when you accidentally call methods on nil.
8
+ config.whiny_nils = true
9
+
10
+ # Print deprecation notices to the Rails logger
11
+ config.active_support.deprecation = :log
12
+
13
+ # Raise exception on mass assignment protection for Active Record models
14
+ config.active_record.mass_assignment_sanitizer = :strict
15
+ end
@@ -0,0 +1,16 @@
1
+ Dummy::Application.configure do
2
+ # The test environment is used exclusively to run your application's
3
+ # test suite. You never need to work with it otherwise. Remember that
4
+ # your test database is "scratch space" for the test suite and is wiped
5
+ # and recreated between test runs. Don't rely on the data there!
6
+ config.cache_classes = true
7
+
8
+ # Log error messages when you accidentally call methods on nil
9
+ config.whiny_nils = true
10
+
11
+ # Raise exception on mass assignment protection for Active Record models
12
+ config.active_record.mass_assignment_sanitizer = :strict
13
+
14
+ # Print deprecation notices to the stderr
15
+ config.active_support.deprecation = :stderr
16
+ end
@@ -0,0 +1,4 @@
1
+ # This file is used by Rack-based servers to start the application.
2
+
3
+ require ::File.expand_path('../config/environment', __FILE__)
4
+ run Dummy::Application
@@ -0,0 +1,23 @@
1
+ class CreateTables < ActiveRecord::Migration
2
+ def up
3
+ create_lookup_tables :cities, :states, :postal_codes, :streets
4
+
5
+ create_lookup_table :ip_addresses
6
+ create_lookup_table :email_addresses
7
+
8
+ create_lookup_table :accounts
9
+ create_lookup_table :statuses
10
+
11
+ create_table :addresses, primary_key: "address_id" do |t|
12
+ t.belongs_to :city
13
+ t.belongs_to :state
14
+ t.belongs_to :postal_code
15
+ t.belongs_to :street
16
+ end
17
+
18
+ State.create(name: "AL")
19
+ end
20
+
21
+ def down
22
+ end
23
+ end