paraphrase 0.7.0 → 0.8.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.
@@ -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