simple-sql 0.5.25 → 0.5.26
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.
- 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
|