parelation 0.0.1

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,82 @@
1
+ class Parelation::Criteria::Where::Caster
2
+
3
+ # @return [String] an array of values that are considered true
4
+ #
5
+ TRUTHY_VALUES = ["1", "t", "true"]
6
+
7
+ # @return [String] an array of values that are considered false
8
+ #
9
+ FALSY_VALUES = ["0", "f", "false"]
10
+
11
+ # @return [String]
12
+ #
13
+ attr_reader :field
14
+
15
+ # @return [String]
16
+ #
17
+ attr_reader :value
18
+
19
+ # @return [Class]
20
+ #
21
+ attr_reader :klass
22
+
23
+ # @param field [Symbol] the name of the attribute that needs to be casted
24
+ # @param value [String] the value of the attribute that needs to be casted
25
+ # @param klass [Class] the corresponding field's class
26
+ #
27
+ def initialize(field, value, klass)
28
+ @field = field.to_s
29
+ @value = value
30
+ @klass = klass
31
+ end
32
+
33
+ # @return [String, Boolean, Time, nil]
34
+ #
35
+ def cast
36
+ case type
37
+ when :boolean
38
+ to_boolean
39
+ when :integer
40
+ to_integer
41
+ when :float
42
+ to_float
43
+ when :datetime
44
+ to_time
45
+ else
46
+ value
47
+ end
48
+ end
49
+
50
+ private
51
+
52
+ # @return [Symbol]
53
+ #
54
+ def type
55
+ klass.columns_hash[field].type
56
+ end
57
+
58
+ # @return [true, false, nil]
59
+ #
60
+ def to_boolean
61
+ TRUTHY_VALUES.include?(value) && (return true)
62
+ FALSY_VALUES.include?(value) && (return false)
63
+ end
64
+
65
+ # @return [Integer]
66
+ #
67
+ def to_integer
68
+ value.to_i
69
+ end
70
+
71
+ # @return [Float]
72
+ #
73
+ def to_float
74
+ value.to_f
75
+ end
76
+
77
+ # @return [Time] the parsed time string
78
+ #
79
+ def to_time
80
+ Time.parse(value)
81
+ end
82
+ end
@@ -0,0 +1,67 @@
1
+ class Parelation::Criteria::Where::CriteriaBuilder
2
+
3
+ # @return [Hash]
4
+ #
5
+ attr_reader :value
6
+
7
+ # @return [ActiveRecord::Relation]
8
+ #
9
+ attr_reader :chain
10
+
11
+ # @param value [Hash] the user-provided criteria
12
+ # @param chain [ActiveRecord::Relation]
13
+ #
14
+ def initialize(value, chain)
15
+ @value = value
16
+ @chain = chain
17
+ end
18
+
19
+ # @return [Hash] criteria that can be passed into
20
+ # the +where+ method of an ActiveRecord::Relation chain.
21
+ #
22
+ def build
23
+ value.inject(Hash.new) do |hash, (field, value)|
24
+ values = [value].flatten
25
+
26
+ if values.count > 1
27
+ assign_array(hash, field, values)
28
+ else
29
+ assign_value(hash, field, values)
30
+ end
31
+
32
+ hash
33
+ end
34
+ end
35
+
36
+ private
37
+
38
+ # Assigns each of the provided values to the +hash+ and casts
39
+ # the value to a database-readable value.
40
+ #
41
+ # @param hash [Hash]
42
+ # @param field [Symbol]
43
+ # @param values [Array]
44
+ #
45
+ def assign_array(hash, field, values)
46
+ values.each { |val| (hash[field] ||= []) << cast(field, val) }
47
+ end
48
+
49
+ # Assigns the first value of the provided values array
50
+ # to the +hash+ and casts it to a database-readable value.
51
+ #
52
+ # @param hash [Hash]
53
+ # @param field [Symbol]
54
+ # @param values [Array]
55
+ #
56
+ def assign_value(hash, field, values)
57
+ hash[field] = cast(field, values[0])
58
+ end
59
+
60
+ # @param field [Symbol]
61
+ # @param value [String]
62
+ #
63
+ def cast(field, value)
64
+ Parelation::Criteria::Where::Caster
65
+ .new(field, value, chain.arel_table.engine).cast
66
+ end
67
+ end
@@ -0,0 +1,49 @@
1
+ class Parelation::Criteria::Where::DirectionalQueryApplier
2
+
3
+ # @return [Hash] keyword to operator mappings
4
+ #
5
+ OPERATOR_MAPPING = {
6
+ "where_gt" => ">",
7
+ "where_gte" => ">=",
8
+ "where_lt" => "<",
9
+ "where_lte" => "<="
10
+ }
11
+
12
+ # @return [String]
13
+ #
14
+ attr_reader :operator
15
+
16
+ # @return [Hash]
17
+ #
18
+ attr_reader :criteria
19
+
20
+ # @return [ActiveRecord::Relation]
21
+ #
22
+ attr_reader :chain
23
+
24
+ # @param operator [String] the named operator from the params
25
+ # @param criteria [Hash] the data to build query operations with
26
+ # @param chain [ActiveRecord::Relation] the chain to apply to
27
+ #
28
+ def initialize(operator, criteria, chain)
29
+ @operator = operator
30
+ @criteria = criteria
31
+ @chain = chain
32
+ end
33
+
34
+ # @return [ActiveRecord::Relation] the chain with newly applied operations
35
+ #
36
+ def apply
37
+ criteria.inject(chain) do |chain, (key, value)|
38
+ chain.where(sql, key, value)
39
+ end
40
+ end
41
+
42
+ private
43
+
44
+ # @return [String] the base SQL template to build queries on-top of
45
+ #
46
+ def sql
47
+ %Q{"#{chain.arel_table.name}".? #{OPERATOR_MAPPING[operator]} ?}
48
+ end
49
+ end
@@ -0,0 +1,37 @@
1
+ class Parelation::Criteria::Where
2
+ include Parelation::Criteria
3
+
4
+ require_relative "where/caster"
5
+ require_relative "where/directional_query_applier"
6
+ require_relative "where/criteria_builder"
7
+
8
+ # @param param [String]
9
+ # @return [true, false]
10
+ #
11
+ def self.match?(param)
12
+ !!(param =~ /^(where|where_(not|gt|gte|lt|lte))$/)
13
+ end
14
+
15
+ # @return [ActiveRecord::Relation]
16
+ #
17
+ def call
18
+ criteria.inject(chain) do |chain, (field, value)|
19
+ case param
20
+ when "where"
21
+ chain.where(field => value)
22
+ when "where_not"
23
+ chain.where.not(field => value)
24
+ when "where_gt", "where_gte", "where_lt", "where_lte"
25
+ DirectionalQueryApplier.new(param, criteria, chain).apply
26
+ end
27
+ end
28
+ end
29
+
30
+ private
31
+
32
+ # @return [Hash] containing data used to pass to {#chain}'s +where+ method.
33
+ #
34
+ def criteria
35
+ @criteria ||= CriteriaBuilder.new(value, chain).build
36
+ end
37
+ end
@@ -0,0 +1,24 @@
1
+ module Parelation::Criteria
2
+
3
+ # @return [ActiveRecord::Relation] the current criteria chain
4
+ #
5
+ attr_reader :chain
6
+
7
+ # @return [String] the param param
8
+ #
9
+ attr_reader :param
10
+
11
+ # @return [String] the param value
12
+ #
13
+ attr_reader :value
14
+
15
+ # @param chain [ActiveRecord::Relation]
16
+ # @param param [String]
17
+ # @param value [String, Symbol, Array, Hash]
18
+ #
19
+ def initialize(chain, param, value)
20
+ @chain = chain
21
+ @param = param.clone
22
+ @value = value.clone
23
+ end
24
+ end
@@ -0,0 +1,2 @@
1
+ class Parelation::Errors::Parameter < StandardError
2
+ end
@@ -0,0 +1,12 @@
1
+ module Parelation::Helpers
2
+
3
+ # Shorthand method used in ActionController controllers for converting
4
+ # and applying parameters to ActionController::Relation criteria chains.
5
+ #
6
+ # @param object [ActiveRecord::Relation]
7
+ # @return [ActiveRecord::Relation]
8
+ #
9
+ def parelate(object)
10
+ Parelation::Applier.new(object, params).apply
11
+ end
12
+ end
@@ -0,0 +1,3 @@
1
+ module Parelation
2
+ VERSION = "0.0.1"
3
+ end
data/lib/parelation.rb ADDED
@@ -0,0 +1,19 @@
1
+ module Parelation
2
+ module Errors
3
+ require "parelation/errors/parameter"
4
+ end
5
+
6
+ module Criteria
7
+ require "parelation/criteria/select"
8
+ require "parelation/criteria/limit"
9
+ require "parelation/criteria/offset"
10
+ require "parelation/criteria/order"
11
+ require "parelation/criteria/query"
12
+ require "parelation/criteria/where"
13
+ end
14
+
15
+ require "parelation/applier"
16
+ require "parelation/criteria"
17
+ require "parelation/helpers"
18
+ require "parelation/version"
19
+ end
@@ -0,0 +1,29 @@
1
+ lib = File.expand_path("../lib", __FILE__)
2
+ $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
3
+ require "parelation/version"
4
+
5
+ Gem::Specification.new do |spec|
6
+ spec.name = "parelation"
7
+ spec.version = Parelation::VERSION
8
+ spec.authors = ["Michael van Rooijen"]
9
+ spec.email = ["michael@vanrooijen.io"]
10
+ spec.summary = %q{Translates HTTP parameters to ActiveRecord queries.}
11
+ spec.homepage = "http://meskyanichi.github.io/store_schema/"
12
+ spec.license = "MIT"
13
+
14
+ spec.files = `git ls-files -z`.split("\x0")
15
+ spec.executables = spec.files.grep(%r{^bin/}) { |f| File.basename(f) }
16
+ spec.test_files = spec.files.grep(%r{^(test|spec|features)/})
17
+ spec.require_paths = ["lib"]
18
+
19
+ spec.add_dependency "activerecord", ">= 4.1.0"
20
+ spec.add_development_dependency "bundler"
21
+ spec.add_development_dependency "rake"
22
+ spec.add_development_dependency "rspec"
23
+ spec.add_development_dependency "mocha"
24
+ spec.add_development_dependency "sqlite3"
25
+ spec.add_development_dependency "database_cleaner"
26
+ spec.add_development_dependency "pry"
27
+ spec.add_development_dependency "simplecov"
28
+ spec.add_development_dependency "yard"
29
+ end
@@ -0,0 +1,51 @@
1
+ require "spec_helper"
2
+
3
+ describe Parelation::Applier do
4
+
5
+ let(:klass) { Parelation::Applier }
6
+
7
+ it "should apply the requested criteria" do
8
+ params = {
9
+ "format" => :json,
10
+ "action" => "index",
11
+ "controller" => "api/v1/tickets",
12
+ "select" => ["id", "name", "state", "message"],
13
+ "where" => { state: ["open", "pending"] },
14
+ "where_not" => { state: "closed" },
15
+ "where_gt" => { created_at: "2014-01-01T00:00:00Z" },
16
+ "where_gte" => { updated_at: "2014-01-01T00:00:00Z" },
17
+ "where_lt" => { created_at: "2014-01-01T01:00:00Z" },
18
+ "where_lte" => { updated_at: "2014-01-01T01:00:00Z" },
19
+ "query" => { "ruby on rails" => ["name", "message"] },
20
+ "order" => ["created_at:desc", "name:asc"],
21
+ "limit" => "50",
22
+ "offset" => "100"
23
+ }
24
+
25
+ criteria = klass.new(Project.create.tickets, params).apply
26
+ ar_query = Project.create.tickets
27
+ .select(:id, :name, :state, :message)
28
+ .where(state: ["open", "pending"])
29
+ .where.not(state: "closed")
30
+ .where(%Q{"tickets".'created_at' > ?}, "2014-01-01 00:00:00.000000")
31
+ .where(%Q{"tickets".'updated_at' >= ?}, "2014-01-01 00:00:00.000000")
32
+ .where(%Q{"tickets".'created_at' < ?}, "2014-01-01 01:00:00.000000")
33
+ .where(%Q{"tickets".'updated_at' <= ?}, "2014-01-01 01:00:00.000000")
34
+ .where(
35
+ %Q{"tickets"."name" LIKE ? OR "tickets"."message" LIKE ?},
36
+ "%ruby on rails%", "%ruby on rails%"
37
+ )
38
+ .order(created_at: :desc, name: :asc)
39
+ .limit(50)
40
+ .offset(100)
41
+
42
+ expect(criteria.to_sql).to eq(ar_query.to_sql)
43
+ end
44
+
45
+ it "raise an exception if parameter data is invalid" do
46
+ params = { "order" => ["name"] }
47
+
48
+ expect { klass.new(Ticket.all, params).apply }
49
+ .to raise_error(Parelation::Errors::Parameter, "the order parameter is invalid")
50
+ end
51
+ end
@@ -0,0 +1,21 @@
1
+ require "spec_helper"
2
+
3
+ describe Parelation::Criteria::Limit do
4
+
5
+ let(:klass) { Parelation::Criteria::Limit }
6
+
7
+ it "should match" do
8
+ expect(klass.match?("limit")).to eq(true)
9
+ end
10
+
11
+ it "should not match" do
12
+ expect(klass.match?("query")).to eq(false)
13
+ end
14
+
15
+ it "should add criteria to the chain" do
16
+ criteria = klass.new(Ticket.all, "limit", "40").call
17
+ ar_query = Ticket.limit(40)
18
+
19
+ expect(criteria.to_sql).to eq(ar_query.to_sql)
20
+ end
21
+ end
@@ -0,0 +1,21 @@
1
+ require "spec_helper"
2
+
3
+ describe Parelation::Criteria::Offset do
4
+
5
+ let(:klass) { Parelation::Criteria::Offset }
6
+
7
+ it "should match" do
8
+ expect(klass.match?("offset")).to eq(true)
9
+ end
10
+
11
+ it "should not match" do
12
+ expect(klass.match?("query")).to eq(false)
13
+ end
14
+
15
+ it "should add criteria to the chain" do
16
+ criteria = klass.new(Ticket.all, "offset", "40").call
17
+ ar_query = Ticket.offset(40)
18
+
19
+ expect(criteria.to_sql).to eq(ar_query.to_sql)
20
+ end
21
+ end
@@ -0,0 +1,36 @@
1
+ require "spec_helper"
2
+
3
+ describe Parelation::Criteria::Order do
4
+
5
+ let(:klass) { Parelation::Criteria::Order }
6
+
7
+ it "should match" do
8
+ expect(klass.match?("order")).to eq(true)
9
+ end
10
+
11
+ it "should not match" do
12
+ expect(klass.match?("query")).to eq(false)
13
+ end
14
+
15
+ it "should add acending order criteria to the chain" do
16
+ criteria = klass.new(Ticket.all, "order", "created_at:asc").call
17
+ ar_query = Ticket.order(created_at: :asc)
18
+
19
+ expect(criteria.to_sql).to eq(ar_query.to_sql)
20
+ end
21
+
22
+ it "should add descending order criteria to the chain" do
23
+ criteria = klass.new(Ticket.all, "order", "created_at:desc").call
24
+ ar_query = Ticket.order(created_at: :desc)
25
+
26
+ expect(criteria.to_sql).to eq(ar_query.to_sql)
27
+ end
28
+
29
+ it "should combine multiple asc and desc order criteria" do
30
+ orders = %w[created_at:desc name:asc updated_at:desc message:asc]
31
+ criteria = klass.new(Ticket.all, "order", orders).call
32
+ ar_query = Ticket.order(created_at: :desc, name: :asc, updated_at: :desc, message: :asc)
33
+
34
+ expect(criteria.to_sql).to eq(ar_query.to_sql)
35
+ end
36
+ end
@@ -0,0 +1,31 @@
1
+ require "spec_helper"
2
+
3
+ describe Parelation::Criteria::Query do
4
+
5
+ let(:klass) { Parelation::Criteria::Query }
6
+
7
+ it "should match" do
8
+ expect(klass.match?("query")).to eq(true)
9
+ end
10
+
11
+ it "should not match" do
12
+ expect(klass.match?("not_query")).to eq(false)
13
+ end
14
+
15
+ it "should add single-column criteria to the chain" do
16
+ criteria = klass.new(Ticket.all, "query", { "ruby on rails" => "name" }).call
17
+ ar_query = Ticket.where(%Q{"tickets"."name" LIKE ?}, "%ruby on rails%")
18
+
19
+ expect(criteria.to_sql).to eq(ar_query.to_sql)
20
+ end
21
+
22
+ it "should add multi-column criteria to the chain" do
23
+ criteria = klass.new(Ticket.all, "query", { "ruby on rails" => ["name", "message"] }).call
24
+ ar_query = Ticket.where(
25
+ %Q{"tickets"."name" LIKE ? OR "tickets"."message" LIKE ?},
26
+ "%ruby on rails%", "%ruby on rails%"
27
+ )
28
+
29
+ expect(criteria.to_sql).to eq(ar_query.to_sql)
30
+ end
31
+ end
@@ -0,0 +1,21 @@
1
+ require "spec_helper"
2
+
3
+ describe Parelation::Criteria::Select do
4
+
5
+ let(:klass) { Parelation::Criteria::Select }
6
+
7
+ it "should match" do
8
+ expect(klass.match?("select")).to eq(true)
9
+ end
10
+
11
+ it "should not match" do
12
+ expect(klass.match?("query")).to eq(false)
13
+ end
14
+
15
+ it "should add criteria to the chain" do
16
+ criteria = klass.new(Ticket.all, "select", ["id", "name"]).call
17
+ ar_query = Ticket.all.select(:id, :name)
18
+
19
+ expect(criteria.to_sql).to eq(ar_query.to_sql)
20
+ end
21
+ end
@@ -0,0 +1,157 @@
1
+ require "spec_helper"
2
+
3
+ describe Parelation::Criteria::Where do
4
+
5
+ let(:klass) { Parelation::Criteria::Where }
6
+
7
+ it "should match" do
8
+ operators = %w[where where_not where_gt where_gte where_lt where_lte]
9
+ operators.each { |operator| expect(klass.match?(operator)).to eq(true) }
10
+ end
11
+
12
+ it "should not match" do
13
+ expect(klass.match?("query")).to eq(false)
14
+ end
15
+
16
+ it "should add = criteria to the chain when argument is a value" do
17
+ criteria = klass.new(Ticket.all, "where", state: "open").call
18
+ ar_query = Ticket.where(state: "open")
19
+
20
+ expect(criteria.to_sql).to eq(ar_query.to_sql)
21
+ end
22
+
23
+ it "should add = criteria to the chain when argument is an array" do
24
+ criteria = klass.new(Ticket.all, "where", state: ["open"]).call
25
+ ar_query = Ticket.where(state: "open")
26
+
27
+ expect(criteria.to_sql).to eq(ar_query.to_sql)
28
+ end
29
+
30
+ it "should add IN criteria to the chain when values are many" do
31
+ criteria = klass.new(Ticket.all, "where", state: ["open", "pending"]).call
32
+ ar_query = Ticket.where(state: ["open", "pending"])
33
+
34
+ expect(criteria.to_sql).to eq(ar_query.to_sql)
35
+ end
36
+
37
+ it "should add != criteria to the chain when argument is a value" do
38
+ criteria = klass.new(Ticket.all, "where_not", state: "open").call
39
+ ar_query = Ticket.where.not(state: "open")
40
+
41
+ expect(criteria.to_sql).to eq(ar_query.to_sql)
42
+ end
43
+
44
+ it "should add != criteria to the chain when argument is an array" do
45
+ criteria = klass.new(Ticket.all, "where_not", state: "open").call
46
+ ar_query = Ticket.where.not(state: "open")
47
+
48
+ expect(criteria.to_sql).to eq(ar_query.to_sql)
49
+ end
50
+
51
+ it "should add NOT IN criteria to the chain when values are many" do
52
+ criteria = klass.new(Ticket.all, "where_not", state: ["open", "pending"]).call
53
+ ar_query = Ticket.where.not(state: ["open", "pending"])
54
+
55
+ expect(criteria.to_sql).to eq(ar_query.to_sql)
56
+ end
57
+
58
+ [%w[where_gt >], %w[where_gte >=], %w[where_lt <], %w[where_lte <=]]
59
+ .each do |(operator, symbol)|
60
+
61
+ it "should add #{symbol} criteria to the chain" do
62
+ criteria = klass.new(Ticket.all, operator, created_at: "2014-08-26T19:20:44Z").call
63
+ ar_query = Ticket.where(
64
+ %Q{"tickets".'created_at' #{symbol} ?}, "2014-08-26 19:20:44.000000"
65
+ )
66
+
67
+ expect(criteria.to_sql).to eq(ar_query.to_sql)
68
+ end
69
+
70
+ it "should add multiple #{symbol} criteria to the chain" do
71
+ criteria = klass.new(Ticket.all, operator, created_at: "2014-08-26T19:20:44Z", position: "5").call
72
+ ar_query = Ticket.where(%Q{"tickets".'created_at' #{symbol} ?}, "2014-08-26 19:20:44.000000")
73
+ .where(%Q{"tickets".'position' #{symbol} ?}, 5)
74
+
75
+ expect(criteria.to_sql).to eq(ar_query.to_sql)
76
+ end
77
+ end
78
+
79
+ describe "relations" do
80
+
81
+ before do
82
+ @project1 = Project.create
83
+ @project2 = Project.create
84
+ @ticket1 = @project1.tickets.create
85
+ @ticket2 = @project2.tickets.create
86
+ end
87
+
88
+ it "should yield related resources" do
89
+ tickets = @project1.tickets
90
+ expect(tickets.count).to eq(1)
91
+ expect(tickets.first).to eq(@ticket1)
92
+ end
93
+
94
+ it "should yield results when using the same foreign key" do
95
+ chain = klass.new(@project1.tickets, "where", project_id: @project1.id).call
96
+ expect(chain.count).to eq(1)
97
+ expect(chain.first).to eq(@ticket1)
98
+ end
99
+
100
+ it "should not override foreign key, nor yield results" do
101
+ chain = klass.new(@project1.tickets, "where", project_id: @project2.id).call
102
+ expect(chain.count).to eq(0)
103
+ expect(chain.to_sql).to eq(@project1.tickets.where(project_id: @project2.id).to_sql)
104
+ end
105
+ end
106
+
107
+ describe "casting:boolean" do
108
+
109
+ ["1", "t", "true"].each do |string_bool|
110
+ it "should cast #{string_bool} to true" do
111
+ criteria = klass.new(Ticket.all, "where", resolved: string_bool).call
112
+ ar_query = Ticket.where(resolved: true)
113
+
114
+ expect(criteria.to_sql).to eq(ar_query.to_sql)
115
+ end
116
+ end
117
+
118
+ ["0", "f", "false"].each do |string_bool|
119
+ it "should cast #{string_bool} to false" do
120
+ criteria = klass.new(Ticket.all, "where", resolved: string_bool).call
121
+ ar_query = Ticket.where(resolved: false)
122
+
123
+ expect(criteria.to_sql).to eq(ar_query.to_sql)
124
+ end
125
+ end
126
+ end
127
+
128
+ describe "casting:integer" do
129
+
130
+ it "should cast a string to integer" do
131
+ criteria = klass.new(Ticket.all, "where", position: "5").call
132
+ ar_query = Ticket.where(position: 5)
133
+
134
+ expect(criteria.to_sql).to eq(ar_query.to_sql)
135
+ end
136
+ end
137
+
138
+ describe "casting:float" do
139
+
140
+ it "should cast a string to float" do
141
+ criteria = klass.new(Ticket.all, "where", rating: "2.14").call
142
+ ar_query = Ticket.where(rating: 2.14)
143
+
144
+ expect(criteria.to_sql).to eq(ar_query.to_sql)
145
+ end
146
+ end
147
+
148
+ describe "casting:datetime" do
149
+
150
+ it "should cast a string to time" do
151
+ criteria = klass.new(Ticket.all, "where", created_at: "2014-01-01T00:00:00Z").call
152
+ ar_query = Ticket.where(created_at: '2014-01-01 00:00:00.000000')
153
+
154
+ expect(criteria.to_sql).to eq(ar_query.to_sql)
155
+ end
156
+ end
157
+ end