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