simple-sql 0.5.25 → 0.5.26
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +4 -4
- data/.rubocop.yml +1 -0
- data/VERSION +1 -1
- data/lib/simple/sql/config.rb +1 -1
- data/lib/simple/sql/connection/insert.rb +24 -1
- data/lib/simple/sql/connection/scope.rb +13 -8
- data/lib/simple/sql/connection/scope/search.rb +149 -0
- data/lib/simple/sql/connection/scope/shorthand.rb +4 -0
- data/lib/simple/sql/connection/scope/{filters.rb → where.rb} +9 -9
- data/spec/simple/sql/search_spec.rb +55 -0
- metadata +6 -3
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: fab09e2181d6826802076b03cf71b0c742862ab1bf58e730403eee0eed1bb1c6
|
4
|
+
data.tar.gz: 4f64d6025f76fec82d72f83cc936072cb092fa51f5fa0ad4ea37da1e70b045a3
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: e8d383d973a2564e5e841e3820c24dc8aa9438c1ce3629f9508538e53c9775ef9ec16e8b7979933d1fed09bdc9389c71b2ce31122347f096f0e604bba2b20184
|
7
|
+
data.tar.gz: 8a8caf53d1e973c8d762e725e7a7f91ea2fc5295d092c024db4227bda1c92976b0600f1f62d80d1d98794b5f35dc0eea3639ef887dbb2506b1cfdf20b8d7a055
|
data/.rubocop.yml
CHANGED
data/VERSION
CHANGED
@@ -1 +1 @@
|
|
1
|
-
0.5.
|
1
|
+
0.5.26
|
data/lib/simple/sql/config.rb
CHANGED
@@ -56,7 +56,7 @@ module Simple::SQL::Config
|
|
56
56
|
def load_activerecord_base_configuration(path:, env:)
|
57
57
|
require "yaml"
|
58
58
|
database_config = YAML.load_file(path)
|
59
|
-
env
|
59
|
+
env ||= ENV["RAILS_ENV"] || ENV["RACK_ENV"] || "development"
|
60
60
|
|
61
61
|
database_config[env] ||
|
62
62
|
database_config["defaults"] ||
|
@@ -42,6 +42,7 @@ class Simple::SQL::Connection
|
|
42
42
|
raise ArgumentError, "Cannot insert a record without attributes" if columns.empty?
|
43
43
|
|
44
44
|
@connection = connection
|
45
|
+
@table_name = table_name
|
45
46
|
@columns = columns
|
46
47
|
@into = into
|
47
48
|
|
@@ -64,9 +65,31 @@ class Simple::SQL::Connection
|
|
64
65
|
def insert(records:)
|
65
66
|
@connection.transaction do
|
66
67
|
records.map do |record|
|
67
|
-
|
68
|
+
values = record.values_at(*@columns)
|
69
|
+
encode_json_values!(values)
|
70
|
+
|
71
|
+
@connection.ask @sql, *values, into: @into
|
68
72
|
end
|
69
73
|
end
|
70
74
|
end
|
75
|
+
|
76
|
+
def encode_json_values!(values)
|
77
|
+
indices_of_json_columns.each { |idx| values[idx] = json_encode(values[idx]) }
|
78
|
+
end
|
79
|
+
|
80
|
+
# determine indices of columns that are JSON(B).
|
81
|
+
def indices_of_json_columns
|
82
|
+
return @indices_of_json_columns if @indices_of_json_columns
|
83
|
+
|
84
|
+
column_types = @connection.reflection.column_types(@table_name)
|
85
|
+
@indices_of_json_columns = @columns.each_with_index
|
86
|
+
.select { |column_name, _idx| %w(json jsonb).include?(column_types[column_name]) }
|
87
|
+
.map { |_column_name, idx| idx }
|
88
|
+
end
|
89
|
+
|
90
|
+
def json_encode(value)
|
91
|
+
return value unless value.is_a?(Hash) || value.is_a?(Array)
|
92
|
+
JSON.generate(value)
|
93
|
+
end
|
71
94
|
end
|
72
95
|
end
|
@@ -1,7 +1,8 @@
|
|
1
1
|
# rubocop:disable Style/MultipleComparison
|
2
2
|
|
3
3
|
require_relative "scope/shorthand"
|
4
|
-
require_relative "scope/
|
4
|
+
require_relative "scope/where"
|
5
|
+
require_relative "scope/search"
|
5
6
|
require_relative "scope/order"
|
6
7
|
require_relative "scope/pagination"
|
7
8
|
require_relative "scope/count"
|
@@ -34,14 +35,17 @@ class Simple::SQL::Connection::Scope
|
|
34
35
|
attr_reader :args
|
35
36
|
attr_reader :per, :page
|
36
37
|
|
38
|
+
# when initialized with a Hash: contains table name
|
39
|
+
attr_accessor :table_name
|
40
|
+
|
37
41
|
def initialize(sql, args = [], connection:) # :nodoc:
|
38
42
|
expect! sql => [String, Hash]
|
39
43
|
|
40
44
|
@connection = connection
|
41
45
|
|
42
|
-
@sql
|
43
|
-
@args
|
44
|
-
@
|
46
|
+
@sql = nil
|
47
|
+
@args = args
|
48
|
+
@where = []
|
45
49
|
|
46
50
|
case sql
|
47
51
|
when String then @sql = sql
|
@@ -60,10 +64,10 @@ class Simple::SQL::Connection::Scope
|
|
60
64
|
|
61
65
|
# -- set table and select -------------------------------------------------
|
62
66
|
|
63
|
-
|
67
|
+
@table_name = hsh[:table] || raise(ArgumentError, "Missing :table option")
|
64
68
|
select = hsh[:select] || "*"
|
65
69
|
|
66
|
-
@sql = "SELECT #{Array(select).join(', ')} FROM #{
|
70
|
+
@sql = "SELECT #{Array(select).join(', ')} FROM #{table_name}"
|
67
71
|
|
68
72
|
# -- apply conditions, if any ---------------------------------------------
|
69
73
|
|
@@ -73,8 +77,9 @@ class Simple::SQL::Connection::Scope
|
|
73
77
|
|
74
78
|
def duplicate
|
75
79
|
dupe = SELF.new(@sql, connection: @connection)
|
80
|
+
dupe.instance_variable_set :@table_name, @table_name.dup
|
76
81
|
dupe.instance_variable_set :@args, @args.dup
|
77
|
-
dupe.instance_variable_set :@
|
82
|
+
dupe.instance_variable_set :@where, @where.dup
|
78
83
|
dupe.instance_variable_set :@per, @per
|
79
84
|
dupe.instance_variable_set :@page, @page
|
80
85
|
dupe.instance_variable_set :@order_by_fragment, @order_by_fragment
|
@@ -89,7 +94,7 @@ class Simple::SQL::Connection::Scope
|
|
89
94
|
raise ArgumentError unless pagination == :auto || pagination == false
|
90
95
|
|
91
96
|
sql = @sql
|
92
|
-
sql =
|
97
|
+
sql = apply_where(sql)
|
93
98
|
sql = apply_order_and_limit(sql)
|
94
99
|
sql = apply_pagination(sql, pagination: pagination)
|
95
100
|
|
@@ -0,0 +1,149 @@
|
|
1
|
+
# rubocop:disable Metrics/AbcSize
|
2
|
+
class Simple::SQL::Connection::Scope
|
3
|
+
def search(filters, dynamic_column: nil, table_name: nil)
|
4
|
+
table_name ||= self.table_name
|
5
|
+
raise "Cannot run search without a table_name setting. Please set table_name on this scope." unless table_name
|
6
|
+
|
7
|
+
column_types = connection.reflection.column_types(table_name)
|
8
|
+
dynamic_column ||= column_types.detect { |_column_name, column_type| column_type == "jsonb" }.first
|
9
|
+
|
10
|
+
Search.search(self, filters, dynamic_column: dynamic_column, column_types: column_types)
|
11
|
+
end
|
12
|
+
end
|
13
|
+
|
14
|
+
module Simple::SQL::Connection::Scope::Search
|
15
|
+
extend self
|
16
|
+
|
17
|
+
ID_REGEXP = /\A[_a-zA-Z][_a-zA-Z0-9]*\z/
|
18
|
+
|
19
|
+
# apply the filter hash onto the passed in scope. This matches all filters
|
20
|
+
# with a name which is matching a column name against the column values.
|
21
|
+
# It matches every other filter value against an entry in the
|
22
|
+
# dynamic_filter column.
|
23
|
+
def search(scope, filters, dynamic_column:, column_types:)
|
24
|
+
expect! filters => [nil, Hash]
|
25
|
+
expect! dynamic_column => ID_REGEXP
|
26
|
+
|
27
|
+
filters.each_key do |key|
|
28
|
+
expect! key => [Symbol, String]
|
29
|
+
expect! key.to_s => ID_REGEXP
|
30
|
+
end
|
31
|
+
|
32
|
+
return scope if filters.nil? || filters.empty?
|
33
|
+
|
34
|
+
# some filters try to match against existing columns, some try to match
|
35
|
+
# against the "tags" JSONB column - and they result in different where
|
36
|
+
# clauses in the Simple::SQL scope ("value = <match>" or "tags->>value = <match>").
|
37
|
+
static_filters, dynamic_filters = filters.partition { |key, _| static_filter?(column_types, key) }
|
38
|
+
|
39
|
+
scope = apply_static_filters(scope, static_filters, column_types: column_types)
|
40
|
+
scope = apply_dynamic_filters(scope, dynamic_filters, dynamic_column: dynamic_column)
|
41
|
+
scope
|
42
|
+
end
|
43
|
+
|
44
|
+
private
|
45
|
+
|
46
|
+
def static_filter?(column_types, key)
|
47
|
+
column_types.key?(key)
|
48
|
+
end
|
49
|
+
|
50
|
+
def array_product(ary, *other_arys)
|
51
|
+
ary.product(*other_arys)
|
52
|
+
end
|
53
|
+
|
54
|
+
# -- apply static filters ---------------------------------------------------
|
55
|
+
|
56
|
+
def apply_static_filters(scope, filters, column_types:)
|
57
|
+
return scope if filters.empty?
|
58
|
+
|
59
|
+
filters.inject(scope) do |scp, (k, v)|
|
60
|
+
scp.where k => resolve_static_matches(v, column_type: column_types.fetch(k))
|
61
|
+
end
|
62
|
+
end
|
63
|
+
|
64
|
+
def resolve_static_matches(value, column_type:)
|
65
|
+
if value.is_a?(Array)
|
66
|
+
return value.map { |v| resolve_static_matches(v, column_type: column_type) }
|
67
|
+
end
|
68
|
+
|
69
|
+
case column_type
|
70
|
+
when "bigint" then Integer(value)
|
71
|
+
when "integer" then Integer(value)
|
72
|
+
else value.to_s
|
73
|
+
end
|
74
|
+
end
|
75
|
+
|
76
|
+
# -- apply dynamic filters --------------------------------------------------
|
77
|
+
|
78
|
+
def apply_dynamic_filters(scope, filters, dynamic_column:)
|
79
|
+
# we translate a condition of "foo => []" into a SQL fragment like this:
|
80
|
+
#
|
81
|
+
# NOT (column ? 'foo') OR column @> '{ "foo" : null }'::jsonb)"
|
82
|
+
#
|
83
|
+
# i.e. we check for non-existing columns and columns that exist but have a
|
84
|
+
# value of null (i.e. column->'key' IS NULL).
|
85
|
+
empty_filters, filters = filters.partition { |_key, value| value.nil? || value.empty? }
|
86
|
+
empty_filters.each do |key, _|
|
87
|
+
scope = scope.where("(NOT #{dynamic_column} ? '#{key}' OR #{dynamic_column} @> '#{::JSON.generate(key => nil)}'::jsonb)")
|
88
|
+
end
|
89
|
+
|
90
|
+
return scope if filters.empty?
|
91
|
+
|
92
|
+
keys = []
|
93
|
+
value_arrays = []
|
94
|
+
|
95
|
+
# collect keys and value_arrays for each filter
|
96
|
+
filters.each do |key, value|
|
97
|
+
keys << key
|
98
|
+
# note that resolve_dynamic_matches always returns an array.
|
99
|
+
value_arrays << resolve_dynamic_matches(value, key: key)
|
100
|
+
end
|
101
|
+
|
102
|
+
# We create a product of all potential value combinations. This is to support
|
103
|
+
# search combinations combining multiple values in multiple attributes; like
|
104
|
+
# this:
|
105
|
+
#
|
106
|
+
# search(foo: %w(bar baz), ids: [1, 2, 3])
|
107
|
+
#
|
108
|
+
# which is implemented as "(foo='bar' AND ids=1) OR (foo='bar' AND ids=2) ..."
|
109
|
+
#
|
110
|
+
# I hope we figure out a smart way to use Postgres' JSONB index support to
|
111
|
+
# not have to do that.
|
112
|
+
#
|
113
|
+
# Note: a shorter way to do this would be
|
114
|
+
#
|
115
|
+
# "(foo='bar' OR foo='baz') AND (ids=1 OR ids=2 .. )"
|
116
|
+
#
|
117
|
+
# However, in that case EXPLAIN suggests a more complicated query plan, which
|
118
|
+
# suggests that a query might execute faster if there is only a single condition
|
119
|
+
# using the JSONB index on each JSONB column.
|
120
|
+
product = array_product(*value_arrays)
|
121
|
+
|
122
|
+
# convert each individual combination in the product into a JSONB search
|
123
|
+
# condition.
|
124
|
+
sql_fragment_parts = product.map do |values|
|
125
|
+
match = Hash[keys.zip(values)]
|
126
|
+
"#{dynamic_column} @> '#{::JSON.generate(match)}'::jsonb"
|
127
|
+
end
|
128
|
+
|
129
|
+
# combine all search conditions with a SQL OR.
|
130
|
+
return scope if sql_fragment_parts.empty?
|
131
|
+
|
132
|
+
sql_fragment = "\n\t" + sql_fragment_parts.join(" OR\n\t") + "\n"
|
133
|
+
scope.where(sql_fragment)
|
134
|
+
end
|
135
|
+
|
136
|
+
# convert value into an array of matches for dynamic attributes. This array
|
137
|
+
# might contain integers and strings: We treat each number as a string, and
|
138
|
+
# each string which looks like an integer as a number. Consequently searching
|
139
|
+
# for "123" in a dynamic attribute would match on 123 *and* on "123".
|
140
|
+
def resolve_dynamic_matches(value, key:)
|
141
|
+
_ = key
|
142
|
+
|
143
|
+
Array(value).each_with_object([]) do |v, ary|
|
144
|
+
ary << v
|
145
|
+
ary << Integer(v) if v.is_a?(String) && v =~ /\A-?\d+\z/
|
146
|
+
ary << v.to_s unless v.is_a?(String)
|
147
|
+
end.uniq
|
148
|
+
end
|
149
|
+
end
|
@@ -41,12 +41,12 @@ class Simple::SQL::Connection::Scope
|
|
41
41
|
end
|
42
42
|
|
43
43
|
def where_sql!(sql_fragment)
|
44
|
-
@
|
44
|
+
@where << sql_fragment
|
45
45
|
end
|
46
46
|
|
47
47
|
def where_sql_with_argument!(sql_fragment, arg, placeholder:)
|
48
48
|
@args << arg
|
49
|
-
@
|
49
|
+
@where << sql_fragment.gsub(placeholder, "$#{@args.length}")
|
50
50
|
end
|
51
51
|
|
52
52
|
def where_hash!(hsh, jsonb:)
|
@@ -86,7 +86,7 @@ class Simple::SQL::Connection::Scope
|
|
86
86
|
|
87
87
|
def where_jsonb_condition!(column, hsh)
|
88
88
|
hsh.each do |key, value|
|
89
|
-
@
|
89
|
+
@where << jsonb_condition(column, key, value)
|
90
90
|
end
|
91
91
|
end
|
92
92
|
|
@@ -95,16 +95,16 @@ class Simple::SQL::Connection::Scope
|
|
95
95
|
|
96
96
|
case value
|
97
97
|
when Array
|
98
|
-
@
|
98
|
+
@where << "#{key} = ANY($#{@args.length})"
|
99
99
|
else
|
100
|
-
@
|
100
|
+
@where << "#{key} = $#{@args.length}"
|
101
101
|
end
|
102
102
|
end
|
103
103
|
|
104
|
-
def
|
105
|
-
|
106
|
-
return sql if
|
104
|
+
def apply_where(sql)
|
105
|
+
where = @where.compact
|
106
|
+
return sql if where.empty?
|
107
107
|
|
108
|
-
"#{sql} WHERE (" +
|
108
|
+
"#{sql} WHERE (" + where.join(") AND (") + ")"
|
109
109
|
end
|
110
110
|
end
|
@@ -0,0 +1,55 @@
|
|
1
|
+
require "spec_helper"
|
2
|
+
|
3
|
+
describe "Simple::SQL.search" do
|
4
|
+
let!(:users) do
|
5
|
+
1.upto(10).map { |i|
|
6
|
+
metadata = { user_id_squared: i * i, id_string: "user-#{i}", even_str: i.even? ? "yes" : "no" }
|
7
|
+
metadata[:odd] = true if i.odd?
|
8
|
+
metadata[:always_odd] = i.odd? ? true : nil # always_even is always set, in contrast to odd
|
9
|
+
create(:user, role_id: i, metadata: metadata)
|
10
|
+
}
|
11
|
+
end
|
12
|
+
|
13
|
+
let(:scope) do
|
14
|
+
scope = SQL.scope("SELECT * FROM users")
|
15
|
+
scope.table_name = "users"
|
16
|
+
scope
|
17
|
+
end
|
18
|
+
|
19
|
+
def search(*args)
|
20
|
+
scope.search(*args).all
|
21
|
+
end
|
22
|
+
|
23
|
+
it "filters by one dynamic attribute and one match" do
|
24
|
+
expect(search(even_str: "yes").map(&:id)).to contain_exactly(2,4,6,8,10)
|
25
|
+
end
|
26
|
+
|
27
|
+
it "filters by one dynamic attribute and multiple matches" do
|
28
|
+
expect(search(user_id_squared: [1, 3, 9]).map(&:id)).to contain_exactly(1,3)
|
29
|
+
end
|
30
|
+
|
31
|
+
it "filters by unknown dynamic attribute" do
|
32
|
+
expect(search(no_such_str: "yes").map(&:id)).to contain_exactly()
|
33
|
+
end
|
34
|
+
|
35
|
+
it "converts strings to integers" do
|
36
|
+
expect(search(user_id_squared: [1, "4", "9"]).map(&:id)).to contain_exactly(1,2,3)
|
37
|
+
end
|
38
|
+
|
39
|
+
it "filters by multiple dynamic attributes" do
|
40
|
+
expect(search(user_id_squared: [1, "4", "9"], even_str: "yes").map(&:id)).to contain_exactly(2)
|
41
|
+
end
|
42
|
+
|
43
|
+
it "filters by multiple mixed attributes" do
|
44
|
+
expect(search(id: [1, "2", "3", 4], even_str: "yes").map(&:id)).to contain_exactly(2, 4)
|
45
|
+
end
|
46
|
+
|
47
|
+
it "filters for non-existing dynamic attributes" do
|
48
|
+
expect(search(odd: []).map(&:id)).to contain_exactly(2, 4, 6, 8, 10)
|
49
|
+
expect(search(odd: nil).map(&:id)).to contain_exactly(2, 4, 6, 8, 10)
|
50
|
+
end
|
51
|
+
|
52
|
+
it "filters for non-existing dynamic attributes" do
|
53
|
+
expect(search(always_odd: nil).map(&:id)).to contain_exactly(2, 4, 6, 8, 10)
|
54
|
+
end
|
55
|
+
end
|
metadata
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: simple-sql
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version: 0.5.
|
4
|
+
version: 0.5.26
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- radiospiel
|
@@ -9,7 +9,7 @@ authors:
|
|
9
9
|
autorequire:
|
10
10
|
bindir: bin
|
11
11
|
cert_chain: []
|
12
|
-
date: 2020-01-
|
12
|
+
date: 2020-01-21 00:00:00.000000000 Z
|
13
13
|
dependencies:
|
14
14
|
- !ruby/object:Gem::Dependency
|
15
15
|
name: pg_array_parser
|
@@ -197,10 +197,11 @@ files:
|
|
197
197
|
- lib/simple/sql/connection/scope.rb
|
198
198
|
- lib/simple/sql/connection/scope/count.rb
|
199
199
|
- lib/simple/sql/connection/scope/count_by_groups.rb
|
200
|
-
- lib/simple/sql/connection/scope/filters.rb
|
201
200
|
- lib/simple/sql/connection/scope/order.rb
|
202
201
|
- lib/simple/sql/connection/scope/pagination.rb
|
202
|
+
- lib/simple/sql/connection/scope/search.rb
|
203
203
|
- lib/simple/sql/connection/scope/shorthand.rb
|
204
|
+
- lib/simple/sql/connection/scope/where.rb
|
204
205
|
- lib/simple/sql/connection/type_info.rb
|
205
206
|
- lib/simple/sql/connection_manager.rb
|
206
207
|
- lib/simple/sql/formatting.rb
|
@@ -242,6 +243,7 @@ files:
|
|
242
243
|
- spec/simple/sql/reflection_spec.rb
|
243
244
|
- spec/simple/sql/result_count_spec.rb
|
244
245
|
- spec/simple/sql/scope_spec.rb
|
246
|
+
- spec/simple/sql/search_spec.rb
|
245
247
|
- spec/simple/sql/version_spec.rb
|
246
248
|
- spec/simple/sql_locked_spec.rb
|
247
249
|
- spec/spec_helper.rb
|
@@ -293,6 +295,7 @@ test_files:
|
|
293
295
|
- spec/simple/sql/reflection_spec.rb
|
294
296
|
- spec/simple/sql/result_count_spec.rb
|
295
297
|
- spec/simple/sql/scope_spec.rb
|
298
|
+
- spec/simple/sql/search_spec.rb
|
296
299
|
- spec/simple/sql/version_spec.rb
|
297
300
|
- spec/simple/sql_locked_spec.rb
|
298
301
|
- spec/spec_helper.rb
|