parelation 0.0.1

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.
@@ -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