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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 808175e637d54e64be95571d633d728ee6c8710e214dc379f9104b7624cd21e3
4
- data.tar.gz: 108b164f85d746a20aa630175b63500149146a69fc88a43ef628bddd7d0e6584
3
+ metadata.gz: fab09e2181d6826802076b03cf71b0c742862ab1bf58e730403eee0eed1bb1c6
4
+ data.tar.gz: 4f64d6025f76fec82d72f83cc936072cb092fa51f5fa0ad4ea37da1e70b045a3
5
5
  SHA512:
6
- metadata.gz: ff51e82f0ec865164d8ca570ee693902ea036aa5fca789ba51830f57146c4dadc8fe3d4c65f8c9ca6cfc578629c8b1e39932877b7470f06b196e31c4a935e259
7
- data.tar.gz: da0146769d4360bcbe10b2ca6019918839d4d532d4cfe74f944e4b8a5367bcc007d222d90eaaec4e897d4cce3e0b8c846f143bf9ee0817306279550d029eac00
6
+ metadata.gz: e8d383d973a2564e5e841e3820c24dc8aa9438c1ce3629f9508538e53c9775ef9ec16e8b7979933d1fed09bdc9389c71b2ce31122347f096f0e604bba2b20184
7
+ data.tar.gz: 8a8caf53d1e973c8d762e725e7a7f91ea2fc5295d092c024db4227bda1c92976b0600f1f62d80d1d98794b5f35dc0eea3639ef887dbb2506b1cfdf20b8d7a055
@@ -4,6 +4,7 @@ AllCops:
4
4
  - 'spec/**/*'
5
5
  - 'test/**/*'
6
6
  - 'bin/**/*'
7
+ - 'scripts/*'
7
8
  - 'tasks/release.rake'
8
9
  - '*.gemspec'
9
10
  - 'Gemfile'
data/VERSION CHANGED
@@ -1 +1 @@
1
- 0.5.25
1
+ 0.5.26
@@ -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 = ENV["RAILS_ENV"] || ENV["RACK_ENV"] || "development"
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
- @connection.ask @sql, *record.values_at(*@columns), into: @into
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/filters"
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 = nil
43
- @args = args
44
- @filters = []
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
- table = hsh[:table] || raise(ArgumentError, "Missing :table option")
67
+ @table_name = hsh[:table] || raise(ArgumentError, "Missing :table option")
64
68
  select = hsh[:select] || "*"
65
69
 
66
- @sql = "SELECT #{Array(select).join(', ')} FROM #{table}"
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 :@filters, @filters.dup
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 = apply_filters(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
@@ -6,4 +6,8 @@ class Simple::SQL::Connection::Scope
6
6
  def first(into: :struct)
7
7
  connection.ask(self, into: into)
8
8
  end
9
+
10
+ def print(*args, io: STDOUT, width: :auto)
11
+ connection.print(self, *args, io: io, width: width)
12
+ end
9
13
  end
@@ -41,12 +41,12 @@ class Simple::SQL::Connection::Scope
41
41
  end
42
42
 
43
43
  def where_sql!(sql_fragment)
44
- @filters << sql_fragment
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
- @filters << sql_fragment.gsub(placeholder, "$#{@args.length}")
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
- @filters << jsonb_condition(column, key, value)
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
- @filters << "#{key} = ANY($#{@args.length})"
98
+ @where << "#{key} = ANY($#{@args.length})"
99
99
  else
100
- @filters << "#{key} = $#{@args.length}"
100
+ @where << "#{key} = $#{@args.length}"
101
101
  end
102
102
  end
103
103
 
104
- def apply_filters(sql)
105
- active_filters = @filters.compact
106
- return sql if active_filters.empty?
104
+ def apply_where(sql)
105
+ where = @where.compact
106
+ return sql if where.empty?
107
107
 
108
- "#{sql} WHERE (" + active_filters.join(") AND (") + ")"
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.25
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-08 00:00:00.000000000 Z
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