lookup_by 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
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