paraphrase 0.7.0 → 0.8.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -1,13 +1,20 @@
1
1
  PATH
2
2
  remote: /Users/edd_d/Work/paraphrase
3
3
  specs:
4
- paraphrase (0.6.1)
4
+ paraphrase (0.8.0)
5
+ activemodel (>= 3.0, < 4.1)
5
6
  activerecord (>= 3.0, < 4.1)
6
7
  activesupport (>= 3.0, < 4.1)
7
8
 
8
9
  GEM
9
10
  remote: https://rubygems.org/
10
11
  specs:
12
+ actionpack (4.0.2)
13
+ activesupport (= 4.0.2)
14
+ builder (~> 3.1.0)
15
+ erubis (~> 2.7.0)
16
+ rack (~> 1.5.2)
17
+ rack-test (~> 0.6.2)
11
18
  activemodel (4.0.2)
12
19
  activesupport (= 4.0.2)
13
20
  builder (~> 3.1.0)
@@ -31,6 +38,7 @@ GEM
31
38
  builder (3.1.4)
32
39
  coderay (1.1.0)
33
40
  diff-lcs (1.2.5)
41
+ erubis (2.7.0)
34
42
  i18n (0.6.9)
35
43
  method_source (0.8.2)
36
44
  minitest (4.7.5)
@@ -39,6 +47,9 @@ GEM
39
47
  coderay (~> 1.0)
40
48
  method_source (~> 0.8)
41
49
  slop (~> 3.4)
50
+ rack (1.5.2)
51
+ rack-test (0.6.2)
52
+ rack (>= 1.0)
42
53
  rake (0.9.6)
43
54
  redcarpet (2.1.1)
44
55
  rspec (2.14.1)
@@ -60,6 +71,7 @@ PLATFORMS
60
71
  ruby
61
72
 
62
73
  DEPENDENCIES
74
+ actionpack (~> 4.0)
63
75
  activerecord (~> 4.0)
64
76
  activesupport (~> 4.0)
65
77
  appraisal (~> 0.4)
@@ -68,6 +80,6 @@ DEPENDENCIES
68
80
  pry (~> 0.9)
69
81
  rake (~> 0.9.2)
70
82
  redcarpet (~> 2.1.1)
71
- rspec (~> 2.10)
83
+ rspec (~> 2.14)
72
84
  sqlite3 (~> 1.3.6)
73
85
  yard (~> 0.7)
@@ -0,0 +1,30 @@
1
+ require 'active_support/concern'
2
+ require 'active_model/naming'
3
+
4
+ module Paraphrase
5
+ module ActiveModel
6
+ extend ActiveSupport::Concern
7
+
8
+ def to_key
9
+ [:q]
10
+ end
11
+
12
+ def to_model
13
+ self
14
+ end
15
+
16
+ def to_param
17
+ params.to_param
18
+ end
19
+
20
+ def persisted?
21
+ true
22
+ end
23
+
24
+ module ClassMethods
25
+ def model_name
26
+ ::ActiveModel::Name.new(self, nil, 'Q')
27
+ end
28
+ end
29
+ end
30
+ end
@@ -2,88 +2,105 @@ require 'active_support/core_ext/object/blank'
2
2
  require 'active_support/core_ext/class/attribute'
3
3
  require 'active_support/core_ext/module/delegation'
4
4
  require 'active_support/core_ext/string/inflections'
5
+ require 'active_support/core_ext/array/extract_options'
5
6
  require 'active_support/hash_with_indifferent_access'
7
+ require 'paraphrase/active_model'
6
8
 
7
9
  module Paraphrase
8
10
  class Query
9
- # @!attribute [r] mappings
10
- # @return [Array<ScopeMapping>] mappings for query
11
- class_attribute :mappings, :instance_writer => false
12
-
13
- # Delegate enumerable methods to results
14
- delegate :collect, :map, :each, :select, :to_a, :to_ary, :to => :results
11
+ include ActiveModel
12
+ # @!attribute [r] scopes
13
+ # @return [Array<Scope>] scopes for query
14
+ class_attribute :scopes, :_source, instance_writer: false
15
15
 
16
16
  # @!attribute [r] params
17
- # @return [HashWithIndifferentAccess] filters parameters based on keys defined in mappings
17
+ # @return [HashWithIndifferentAccess] filters parameters based on keys defined in scopes
18
18
  #
19
- # @!attribute [r] source
19
+ # @!attribute [r] result
20
20
  # @return [ActiveRecord::Relation]
21
- attr_reader :params, :source
21
+ attr_reader :params, :result
22
22
 
23
- # Set `mappings` on inheritance to ensure they're unique per subclass
23
+ # Set `scopes` on inheritance to ensure they're unique per subclass
24
24
  def self.inherited(klass)
25
- klass.mappings = []
25
+ klass.scopes = []
26
+ end
27
+
28
+ # Specify the `ActiveRecord` source class if not determinable from the name
29
+ # of the `Paraphrase::Query` subclass.
30
+ #
31
+ # @param [String, Symbol] name name of the source class
32
+ def self.source(name)
33
+ self._source = name.to_s
26
34
  end
27
35
 
28
- # Add a {ScopeMapping} instance to {@@mappings .mappings}
36
+ # Add a {Scope} instance to {Query#scopes}. Defines a reader for each key
37
+ # to read from {Query#params}.
29
38
  #
30
- # @see ScopeMapping#initialize
31
- def self.map(name, options)
32
- if mappings.map(&:method_name).include?(name)
33
- raise DuplicateScopeError, "scope :#{name} has already been added"
39
+ # @see Scope#initialize
40
+ def self.map(*keys)
41
+ options = keys.extract_options!
42
+ scope_name = options[:to]
43
+
44
+ if scopes.any? { |scope| scope.name == scope_name }
45
+ raise DuplicateScopeError, "scope :#{scope_name} has already been mapped"
34
46
  end
35
47
 
36
- mappings << ScopeMapping.new(name, options)
48
+ scopes << Scope.new(keys, options)
49
+
50
+ keys.each do |key|
51
+ define_method(key) { params[key] } unless method_defined?(key)
52
+ end
37
53
  end
38
54
 
39
55
  # Filters out parameters irrelevant to the query and sets the base scope
40
56
  # for to begin the chain.
41
57
  #
42
58
  # @param [Hash] params query parameters
43
- # @param [ActiveRecord::Base, ActiveRecord::Relation] source object to
44
- # apply methods to
45
- def initialize(params, class_or_relation)
46
- keys = mappings.map(&:keys).flatten.map(&:to_s)
59
+ # @param [ActiveRecord::Relation] relation object to apply methods to
60
+ def initialize(params = {}, relation = source)
61
+ keys = scopes.map(&:keys).flatten.map(&:to_s)
47
62
 
48
- @params = HashWithIndifferentAccess.new(params)
49
- @params.select! { |key, value| keys.include?(key) && value.present? }
50
- @params.freeze
63
+ @params = params.with_indifferent_access.slice(*keys)
64
+ scrub_params!
51
65
 
52
- @source = class_or_relation
66
+ @result = scopes.inject(relation) do |r, scope|
67
+ scope.chain(self, r)
68
+ end
53
69
  end
54
70
 
55
- # Loops through {#mappings} and apply scope methods to {#source}. If values
56
- # are missing for a required key, an empty array is returned.
57
- #
58
- # @return [ActiveRecord::Relation, Array]
59
- def results
60
- return @results if @results
61
-
62
- ActiveSupport::Notifications.instrument('query.paraphrase', :params => params, :source_name => source.name, :source => source) do
63
- @results = mappings.inject(source) do |query, scope|
64
- query = scope.chain(params, query)
71
+ alias :[] :send
65
72
 
66
- break [] if query.nil?
67
- query
68
- end
73
+ # Return an `ActiveRecord::Relation` corresponding to the source class
74
+ # determined from the `_source` class attribute or the name of the query
75
+ # class.
76
+ #
77
+ # @return [ActiveRecord::Relation]
78
+ def source
79
+ @source ||= begin
80
+ name = _source || self.class.to_s.sub(/Query$/, '')
81
+ name.constantize
69
82
  end
70
83
  end
71
84
 
72
- def respond_to_missing?(name, include_private = false)
73
- super || results.respond_to?(name, include_private)
74
- end
85
+ private
75
86
 
76
- protected
87
+ def scrub_params!
88
+ params.delete_if { |key, value| scrub(value) }
89
+ end
77
90
 
78
- def method_missing(name, *args, &block)
79
- if results.respond_to?(name)
80
- self.class.delegate name, :to => :results
81
- results.send(name, *args, &block)
91
+ def scrub(value)
92
+ value = case value
93
+ when Array
94
+ value.delete_if { |v| scrub(v) }
95
+ when Hash
96
+ value.delete_if { |k, v| scrub(v) }
97
+ when String
98
+ value.strip
82
99
  else
83
- super
100
+ value
84
101
  end
102
+
103
+ value.blank?
85
104
  end
86
105
  end
87
106
  end
88
-
89
- require 'paraphrase/scope_mapping'
@@ -4,8 +4,7 @@ module Paraphrase
4
4
  class Railtie < Rails::Railtie
5
5
  initializer 'paraphrase.extend_active_record' do
6
6
  ActiveSupport.on_load :active_record do
7
- extend Paraphrase::Syntax::Base
8
- ActiveRecord::Relation.send(:include, Paraphrase::Syntax::Relation)
7
+ extend Paraphrase::Syntax
9
8
  end
10
9
  end
11
10
  end
@@ -0,0 +1,56 @@
1
+ require 'active_support/core_ext/object/blank'
2
+ require 'active_support/core_ext/array/wrap'
3
+
4
+ module Paraphrase
5
+ class Scope
6
+ # @!attribute [r] keys
7
+ # @return [Array<Symbol>] param keys to extract
8
+ #
9
+ # @!attribute [r] name
10
+ # @return [Symbol] scope name
11
+ #
12
+ # @!attribute [r] required_keys
13
+ # @return [Array<Symbol>] keys required for query
14
+ attr_reader :keys, :name, :required_keys
15
+
16
+ # @param [Symbol] name name of the scope
17
+ # @param [Hash] options options to configure {Scope Scope} instance
18
+ # @option options [Symbol, Array<Symbol>] :to param key(s) to extract values from
19
+ # @option options [true, Symbol, Array<Symbol>] :require lists all or a
20
+ # subset of param keys as required
21
+ def initialize(keys, options)
22
+ @keys = keys
23
+ @name = options[:to]
24
+
25
+ @required_keys = if options[:whitelist] == true
26
+ []
27
+ else
28
+ @keys - Array.wrap(options[:whitelist])
29
+ end
30
+ end
31
+
32
+ # Sends {#name} to `relation` if `query` has a value for all the
33
+ # {Scope#required_keys}. Passes through `relation` if any
34
+ # values are missing. Detects if the scope takes no arguments to determine
35
+ # if values should be passed to the scope.
36
+ #
37
+ # @param [Paraphrase::Query] query instance of {Query} class
38
+ # @param [ActiveRecord::Relation] relation scope chain
39
+ # @return [ActiveRecord::Relation]
40
+ def chain(query, relation)
41
+ if required_keys.all? { |key| query[key] }
42
+ klass = relation.respond_to?(:klass) ? relation.klass : relation
43
+ arity = klass.method(name).arity
44
+
45
+ if arity == 0
46
+ relation.send(name)
47
+ else
48
+ values = keys.map { |key| query[key] }
49
+ relation.send(name, *values)
50
+ end
51
+ else
52
+ relation
53
+ end
54
+ end
55
+ end
56
+ end
@@ -1,68 +1,23 @@
1
1
  module Paraphrase
2
2
  module Syntax
3
- module Base
4
- def self.extended(klass)
5
- klass.instance_eval do
6
- class_attribute :_paraphraser, :instance_writer => false, :instance_reader => false
7
- end
8
- end
9
-
10
- # Create a {Query} subclass from a block using the `Query` DSL to map
11
- # scopes to param keys
12
- #
13
- # @param [Proc] &block block to to define scope mappings
14
- def register_mapping(&block)
15
- self._paraphraser = Class.new(Query, &block)
16
- end
17
-
18
- # Attempts to find paraphrase class based on class name. Override if
19
- # using a different naming convention.
20
- def paraphraser
21
- self._paraphraser || "#{self.name}Query".constantize
22
- rescue
23
- nil
24
- end
25
-
26
- # Instantiate the {Query} class that is mapped to `self`.
27
- #
28
- # @param [Hash] params query parameters
29
- def paraphrase(params)
30
- self.paraphraser.new(params, self)
3
+ # Attempts to find paraphrase class based on class name. Override if
4
+ # using a different naming convention.
5
+ def paraphraser
6
+ name = "#{self.name}Query"
7
+ name.constantize
8
+ rescue NameError => e
9
+ if e.message =~ /uninitialized constant/
10
+ raise Paraphrase::NoQueryDefined.new("No query class found. #{name} must be defined as a subclass of Paraphrase::Query")
31
11
  end
32
12
  end
33
13
 
34
- module Relation
35
- # Creates a paraphrase {Query query}, supplying `self` as the base for the
36
- # query. Intended for scoping a query from an association:
37
- #
38
- # Given the following models:
39
- #
40
- # ```ruby
41
- # class User < ActiveRecord::Base
42
- # has_many :posts
43
- # end
44
- #
45
- # class Post < ActiveRecord::Base
46
- # belongs_to :user
47
- #
48
- # register_mapping
49
- # map :title_like, :to => :title
50
- # end
51
- # end
52
- # ```
53
- #
54
- # It is possible to do the following:
55
- #
56
- # ```ruby
57
- # user.posts.paraphrase({ :title => 'Game of Thrones Finale' }).to_sql
58
- # # => SELECT `posts`.* FROM `posts` INNER JOIN `users` ON `users`.`post_id` = `posts`.`id` WHERE `posts`.`title LIKE "%Game of Thrones Finale%";
59
- # ```
60
- #
61
- # @param [Hash] params query parameters
62
- # @return [Paraphrase::Query]
63
- def paraphrase(params)
64
- klass.paraphraser.new(params, self)
65
- end
14
+ # Instantiate the {Query} class that is mapped to `self`.
15
+ #
16
+ # @param [Hash] params query parameters
17
+ def paraphrase(params = {})
18
+ paraphraser.new(params, self).result
66
19
  end
67
20
  end
21
+
22
+ class NoQueryDefined < StandardError; end
68
23
  end
@@ -1,3 +1,3 @@
1
1
  module Paraphrase
2
- VERSION = "0.7.0"
2
+ VERSION = "0.8.0"
3
3
  end
data/lib/paraphrase.rb CHANGED
@@ -1,4 +1,5 @@
1
1
  require 'paraphrase/errors'
2
2
  require 'paraphrase/query'
3
+ require 'paraphrase/scope'
3
4
  require 'paraphrase/syntax'
4
5
  require 'paraphrase/rails' if defined?(Rails)
data/paraphrase.gemspec CHANGED
@@ -14,7 +14,7 @@ Gem::Specification.new do |gem|
14
14
  }
15
15
  gem.license = "MIT"
16
16
  gem.authors = ["Eduardo Gutierrez"]
17
- gem.email = "edd_d@mit.edu"
17
+ gem.email = "eduardo@vermonster.com"
18
18
  gem.homepage = "https://github.com/ecbypi/paraphrase"
19
19
 
20
20
  gem.files = `git ls-files`.split($/)
@@ -22,15 +22,28 @@ Gem::Specification.new do |gem|
22
22
  gem.test_files = gem.files.grep(%r{^(test|spec|features)/})
23
23
  gem.require_paths = ['lib']
24
24
 
25
+ gem.required_ruby_version = '>= 1.9.3'
26
+
25
27
  gem.add_dependency 'activerecord', '>= 3.0', '< 4.1'
26
28
  gem.add_dependency 'activesupport', '>= 3.0', '< 4.1'
29
+ gem.add_dependency 'activemodel', '>= 3.0', '< 4.1'
27
30
 
31
+ gem.add_development_dependency 'actionpack', '>= 3.0', '< 4.1'
28
32
  gem.add_development_dependency 'bundler', '~> 1.0'
29
33
  gem.add_development_dependency 'yard', '~> 0.7'
30
- gem.add_development_dependency 'rspec', '~> 2.10'
31
- gem.add_development_dependency 'redcarpet', '~> 2.1.1'
34
+ gem.add_development_dependency 'rspec', '~> 2.14'
32
35
  gem.add_development_dependency 'rake', '~> 0.9.2'
33
- gem.add_development_dependency 'sqlite3', '~> 1.3.6'
34
36
  gem.add_development_dependency 'appraisal', '~> 0.4'
35
37
  gem.add_development_dependency 'pry', '~> 0.9'
38
+
39
+ if RUBY_PLATFORM != 'java'
40
+ gem.add_development_dependency 'redcarpet', '~> 2.1.1'
41
+ end
42
+
43
+ if RUBY_PLATFORM == 'java'
44
+ gem.add_development_dependency 'activerecord-jdbcsqlite3-adapter'
45
+ gem.add_development_dependency 'jdbc-sqlite3'
46
+ else
47
+ gem.add_development_dependency 'sqlite3', '~> 1.3.6'
48
+ end
36
49
  end
@@ -1,83 +1,151 @@
1
1
  require 'spec_helper'
2
+ require 'action_view/test_case'
2
3
 
3
4
  module Paraphrase
4
5
  describe Query do
5
- describe ".map" do
6
- subject { Class.new(Query) }
6
+ class ::PostQuery < Paraphrase::Query
7
+ map :title, to: :titled
8
+ map :is_published, to: :published
9
+ map :authors, to: :by_users
10
+ map :start_date, :end_date, to: :published_between
11
+
12
+ def start_date
13
+ @start_date ||= Time.parse(params[:start_date]) rescue nil
14
+ end
7
15
 
8
- it "adds information to Query.mappings" do
9
- subject.map :name_like, :to => :name
16
+ def end_date
17
+ @end_date ||= Time.parse(params[:end_date]) rescue nil
18
+ end
19
+ end
10
20
 
11
- subject.mappings.should_not be_empty
21
+ describe ".map" do
22
+ it "adds information to Query.scopes" do
23
+ expect(PostQuery.scopes).not_to be_empty
12
24
  end
13
25
 
14
26
  it "raises an error if a scope is added twice" do
15
- subject.map :name_like, :to => :name
27
+ expect { PostQuery.map :name, to: :titled }.to raise_error Paraphrase::DuplicateScopeError
28
+ end
29
+
30
+ it 'defines readers for each key' do
31
+ query = PostQuery.new
16
32
 
17
- expect { subject.map :name_like, :to => :name }.to raise_error Paraphrase::DuplicateScopeError
33
+ expect(query).to respond_to :title
34
+ expect(query).to respond_to :is_published
35
+ expect(query).to respond_to :authors
18
36
  end
19
37
  end
20
38
 
21
- describe "on initialization" do
22
- let(:query) do
39
+ after do
40
+ Post.delete_all
41
+ User.delete_all
42
+ end
43
+
44
+ describe '#source' do
45
+ it 'is determined via query class name' do
46
+ expect(PostQuery.new.result).to eq Post
47
+ end
48
+
49
+ it 'can be manually specified in the class' do
23
50
  klass = Class.new(Query) do
24
- map :name_like, :to => :name
25
- map :email_like, :to => :email
51
+ source :User
26
52
  end
27
53
 
28
- klass.new({ :name => 'name', :nickname => '', :email => '' }, Account)
54
+ expect(klass.new.result).to eq User
55
+ end
56
+ end
57
+
58
+ describe '#[]' do
59
+ it 'retreives values from #params or uses custom reader if defined' do
60
+ query = PostQuery.new(title: 'Morning Joe', start_date: '2010-10-30', end_date: 'foo')
61
+
62
+ expect(query[:title]).to eq 'Morning Joe'
63
+ expect(query[:start_date]).to eq Time.local(2010, 10, 30)
64
+ expect(query[:end_date]).to be_nil
29
65
  end
66
+ end
30
67
 
31
- it "filters out params not specified in mappings" do
32
- query.params.should_not have_key :nickname
33
- query.params.should have_key :name
68
+ describe "#params" do
69
+ it "filters out params not specified in scopes" do
70
+ query = PostQuery.new(nickname: 'bill', title: 'william')
71
+
72
+ expect(query.params).not_to have_key :nickname
73
+ expect(query.params).to have_key :title
34
74
  end
35
75
 
36
76
  it "sets up params with indifferent access" do
37
- query.params.should have_key 'name'
77
+ query = PostQuery.new(title: 'D3 How-To')
78
+ expect(query.params).to have_key 'title'
38
79
  end
39
80
 
40
- it 'filters out blank values' do
41
- query.params.should_not have_key :email
81
+ it 'recursively filters out blank values' do
82
+ query = PostQuery.new(title: { key: ['', { key: [] }, []] }, authors: ['', 'kevin', ['', {}], { key: [' '] }])
83
+
84
+ expect(query.params[:authors]).to eq ['kevin']
85
+ expect(query.params).not_to have_key :title
42
86
  end
43
87
  end
44
88
 
45
- describe "#results" do
46
- let(:klass) do
47
- Class.new(Query) do
48
- map :name_like, :to => :name
49
- map :title_like, :to => :title, :require => true
50
- end
51
- end
89
+ it 'skips scopes if query params are missing' do
90
+ expect(Post).not_to receive(:published_between)
91
+ expect(Post).not_to receive(:titled)
92
+ expect(Post).not_to receive(:by_users)
93
+ expect(Post).to receive(:published)
94
+
95
+ PostQuery.new(
96
+ start_date: Time.local(2010, 10, 30),
97
+ end_date: 'foo',
98
+ is_published: '1',
99
+ authors: [],
100
+ title: ['', {}]
101
+ )
102
+ end
52
103
 
53
- it "loops through scope methods and applies them to source" do
54
- Account.should_receive(:title_like).and_return(Account)
55
- Account.should_receive(:name_like).and_return(Account)
104
+ it 'preserves the original scope used to initialize the query' do
105
+ user = User.create!
106
+ blue_post = Post.create!(user: user, title: 'Blue', published: false)
107
+ red_post = Post.create!(user: user, title: 'Red', published: true)
108
+ green_post = Post.create!(title: 'Red', published: true)
56
109
 
57
- query = klass.new({ :name => 'Jon Snow', :title => 'Wall Watcher'}, Account)
58
- query.results
59
- end
110
+ result = PostQuery.new({ title: 'Red' }, user.posts.published).result
60
111
 
61
- it "returns empty array if inputs were missing and required" do
62
- query = klass.new({}, Account)
63
- query.results.should eq []
64
- end
112
+ expect(result).to include red_post
113
+ expect(result).not_to include blue_post
114
+ expect(result).not_to include green_post
65
115
  end
66
116
 
67
- describe "preserves" do
68
- it "the relation passed in during initialization" do
69
- klass = Class.new(Query) do
70
- map :name_like, :to => :name
117
+ describe 'is action view compliant' do
118
+ it 'by working with form builders' do
119
+ router = ActionDispatch::Routing::RouteSet.new
120
+ router.draw do
121
+ resources :posts
122
+ end
123
+
124
+ controller = ActionView::TestCase::TestController.new
125
+ controller.instance_variable_set(:@_routes, router)
126
+ controller.class_eval { include router.url_helpers }
127
+ controller.view_context.class_eval { include router.url_helpers }
128
+
129
+ query = PostQuery.new(title: 'Red', start_date: '2012-10-01')
130
+
131
+ markup = ""
132
+ controller.view_context.form_for query, url: router.url_helpers.posts_path do |f|
133
+ markup << f.text_field(:title)
134
+ markup << f.date_select(:start_date)
71
135
  end
72
136
 
73
- user = User.create!
74
- Account.create!(user: user)
75
- Account.create!
137
+ expect(markup).to match(/<input.*type="text"/)
138
+ expect(markup).to match(/type="text"/)
139
+ expect(markup).to match(/name="q\[title\]"/)
140
+ expect(markup).to match(/value="Red"/)
76
141
 
77
- query = klass.new({ :name => 'name' }, Account.where(user_id: user.id))
78
- results = query.results
142
+ expect(markup).to match(/<select.*name="q\[start_date\(1i\)/)
143
+ expect(markup).to match(/<select.*name="q\[start_date\(2i\)/)
144
+ expect(markup).to match(/<select.*name="q\[start_date\(3i\)/)
79
145
 
80
- results.to_a.should eq user.accounts.to_a
146
+ expect(markup).to match(/<option.*selected="selected" value="2012"/)
147
+ expect(markup).to match(/<option.*selected="selected" value="10"/)
148
+ expect(markup).to match(/<option.*selected="selected" value="1"/)
81
149
  end
82
150
  end
83
151
  end