pgrel 0.1.1 → 0.3.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
- SHA1:
3
- metadata.gz: 9b99f0c11a5e624145d178ed3bdb97dd9345ccad
4
- data.tar.gz: 7db4f0a1086111e10ded6e6f9af923179ddc2012
2
+ SHA256:
3
+ metadata.gz: 686f59a1c868b3ee3268ab06137c35dcd2f8e9fed9136bbbec2a27622401d301
4
+ data.tar.gz: a381b46c2a07d0193a5764d099e25f731ac90c17f2f9bef11ca600ac08f10c1c
5
5
  SHA512:
6
- metadata.gz: 4ef4b9230787c49c3ccb1b24d68113cf90b57c0ae8281e6814eb6d01c01925fc04031ee3f02e1cc09abf60386b2d3c12f6b2f46674f6b75ccc27cd34d3a08619
7
- data.tar.gz: 8aaeaab12dd78aac0ad21a49b68af1a5efb83aeb261e0cfd431f24662b5f8b0dc88dcc4b93c8115b3a0d4999ddf3ca842c32d75f92270ebab7113eac31b71acf
6
+ metadata.gz: 92b7300c636eb9f985a7a9b5b9467d1480b92ca38997d73cce0105915f1f2d50f7df5724b5b9e0c5c36e32dd5ea3999803145caa0b51a0561ba40d17603606f8
7
+ data.tar.gz: 7b7153c935e88f6084463e58639e9c297fb44dcc1d8c538a8e17e5cb3f6f836837ca7a8dbefd479dbf22ac5f4fd8e9a3400d47985a9c35b30f65238821cd0bc8
@@ -0,0 +1,31 @@
1
+ # Change log
2
+
3
+ ## master
4
+
5
+ ## 0.3.1 (2020-12-18)
6
+
7
+ - Update internal query building for Rails 6.1 compatibility. ([@zokioki][])
8
+
9
+ ## 0.3.0 (2019-28-01)
10
+
11
+ - Rename `#value` method to `#overlap_values`.
12
+ - Rename `#values` method to `#contains_values`.
13
+ - Improve `#contains_values` method:
14
+ eliminate multiple `avals` calls for Hstore and multiple `array_agg` calls for Jsonb.
15
+
16
+ See https://github.com/palkan/pgrel/pull/9. ([@StanisLove][])
17
+
18
+ - Quote store name in queries. ([@palkan][])
19
+
20
+ Previously, we didn't quote store name in queries which could led
21
+ to ambiguity conflicts when joining tables.
22
+ Now it fixed.
23
+
24
+ ## 0.2.0 (2018-06-15)
25
+
26
+ - Add `#update_store` methods. ([@StanisLove][])
27
+
28
+ See https://github.com/palkan/pgrel/pull/5.
29
+
30
+ [@palkan]: https://github.com/palkan
31
+ [@StanisLove]: https://github.com/StanisLove
@@ -1,4 +1,4 @@
1
- Copyright 2015 palkan
1
+ Copyright 2015-2020 palkan
2
2
 
3
3
  Permission is hereby granted, free of charge, to any person obtaining
4
4
  a copy of this software and associated documentation files (the
data/README.md CHANGED
@@ -1,34 +1,41 @@
1
- [![Build Status](https://travis-ci.org/palkan/pgrel.svg?branch=master)](https://travis-ci.org/palkan/pgrel)
1
+ [![Gem Version](https://badge.fury.io/rb/pgrel.svg)](https://rubygems.org/gems/pgrel)
2
+ ![Build](https://github.com/palkan/pgrel/workflows/Build/badge.svg)
2
3
 
3
4
  ## Pgrel
4
5
 
5
6
  ActiveRecord extension for querying hstore, array and jsonb.
6
7
 
7
- Compatible with **Rails** >= 4.0 (and even with upcoming **Rails 5**!).
8
-
9
- ### General
10
-
11
- The functionality is based on ActiveRecord `WhereChain`.
12
- To start querying call `where(:store_name)` and chain it with store-specific call (see below).
8
+ Compatible with **Rails** >= 4.2 (including **Rails 6**).
13
9
 
14
10
  #### Install
15
11
 
16
12
  In your Gemfile:
17
13
 
18
14
  ```ruby
19
- gem "pgrel", "~>0.1"
15
+ gem "pgrel", "~> 0.3"
20
16
  ```
21
17
 
22
18
  ### HStore
23
19
 
20
+ #### Querying
21
+
22
+ The functionality is based on ActiveRecord `WhereChain`.
23
+ To start querying call `where(:store_name)` and chain it with store-specific call (see below).
24
+
24
25
  Query by key value:
25
26
 
26
27
  ```ruby
27
28
  Hstore.where.store(:tags, a: 1, b: 2)
28
- #=> select * from hstores where tags->'a' = '1' and tags->'b' = '2'
29
+ #=> select * from hstores where tags @> '"a"=>"1","b"=>"2"'
29
30
 
30
31
  Hstore.where.store(:tags, a: [1, 2])
31
- #=> select * from hstores where tags->'a' in ('1', '2')
32
+ #=> select * from hstores where (tags @> '"a"=>"1"' or tags @> '"a"=>"2"')
33
+
34
+ Hstore.where.store(:tags, :a)
35
+ #=> select * from hstores where (tags @> '"a"=>NULL')
36
+
37
+ Hstore.where.store(:tags, { a: 1 }, { b: 2 })
38
+ #=> select * from hstores where (tags @> '"a"=>"1" or tags @> "b"=>"2"')
32
39
  ```
33
40
 
34
41
  Keys existence:
@@ -47,6 +54,18 @@ Hstore.where.store(:tags).any('a', 'b')
47
54
  #=> select * from hstores where tags ?| array['a', 'b']
48
55
  ```
49
56
 
57
+ Values existence:
58
+
59
+ ```ruby
60
+ # Retrieve items that have value '1' OR '2'
61
+ Hstore.where.store(:tags).overlap_values(1, 2)
62
+ #=> select * from hstores where (avals(tags) && ARRAY['1', '2'])
63
+
64
+ # Retrieve items that have values '1' AND '2'
65
+ Hstore.where.store(:tags).contains_values(1, 2)
66
+ #=> select * from hstores where (avals(tags) @> ARRAY['1', '2'])
67
+ ```
68
+
50
69
  Containment:
51
70
 
52
71
  ```ruby
@@ -57,18 +76,42 @@ Hstore.where.store(:tags).contained(a: 1, b: 2)
57
76
  #=> select * from hstores where tags <@ '\"a\"=>\"1\", \"b\"=>\"2\"'
58
77
  ```
59
78
 
79
+ #### Update
80
+
81
+ Is implemented through `ActiveRecord::Store::FlexibleHstore` and `ActiveRecord::Store::FlexibleJsonb`
82
+ objects. You can get them by sending `update_store(store_name)` to relation or class.
83
+
84
+ Add key, value pairs:
85
+
86
+ ```ruby
87
+ Hstore.update_store(:tags).merge(new_key: 1, one_more: 2)
88
+ Hstore.update_store(:tags).merge([[:new_key, 1], [:one_more, 2]])
89
+ ```
90
+
91
+ Delete keys:
92
+
93
+ ```ruby
94
+ Hstore.update_store(:tags).delete_keys(:a, :b)
95
+ ```
96
+
97
+ Delete key, value pairs:
98
+
99
+ ```ruby
100
+ Hstore.update_store(:tags).delete_pairs(a: 1, b: 2)
101
+ ```
102
+
60
103
  ### JSONB
61
104
 
62
- All queries for Hstore also available for JSONB.
105
+ All queries and updates for Hstore also available for JSONB.
63
106
 
64
- **NOTE**. Querying by array value always resolves to `IN (...)` statement.
107
+ **NOTE**. Querying by array value always resolves to `(... or ...)` statement.
65
108
  Thus it's impossible to query json array value, e.g.:
66
109
 
67
110
  ```ruby
68
111
  Model.create!(tags: {main: ['a', 'b']})
69
112
 
70
113
  Model.where.store(:tags, main: ['a', 'b']).empty? == true
71
- #=> select * from models where tags->'main' in ('a', 'b')
114
+ #=> select * from models where (tags @> '{\"main\":\"a\"}' or tags @> '{\"main\":\"b\"}')
72
115
  ```
73
116
 
74
117
  Path query:
@@ -1,2 +1,4 @@
1
- require 'pgrel/version'
2
- require 'pgrel/active_record'
1
+ # frozen_string_literal: true
2
+
3
+ require "pgrel/version"
4
+ require "pgrel/active_record"
@@ -1,2 +1,9 @@
1
- require 'active_record'
2
- require 'pgrel/active_record/query_methods'
1
+ # frozen_string_literal: true
2
+
3
+ require "active_record"
4
+ require "pgrel/active_record/relation"
5
+ require "pgrel/active_record/store/flexible_store"
6
+ require "pgrel/active_record/store/flexible_hstore"
7
+ require "pgrel/active_record/store/flexible_jsonb"
8
+ require "pgrel/active_record/querying"
9
+ require "pgrel/active_record/query_methods"
@@ -1,8 +1,10 @@
1
- require 'active_record/relation'
2
- require 'pgrel/active_record/store_chain'
3
- require 'pgrel/active_record/store_chain/array_chain'
4
- require 'pgrel/active_record/store_chain/hstore_chain'
5
- require 'pgrel/active_record/store_chain/jsonb_chain'
1
+ # frozen_string_literal: true
2
+
3
+ require "active_record/relation"
4
+ require "pgrel/active_record/store_chain"
5
+ require "pgrel/active_record/store_chain/array_chain"
6
+ require "pgrel/active_record/store_chain/hstore_chain"
7
+ require "pgrel/active_record/store_chain/jsonb_chain"
6
8
 
7
9
  module ActiveRecord
8
10
  module QueryMethods
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveRecord
4
+ module Querying
5
+ delegate :update_store, to: :all
6
+ end
7
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveRecord
4
+ class Relation
5
+ def update_store(store_name)
6
+ raise ArgumentError, "Empty store name to update" if store_name.blank?
7
+ type = type_for_attribute(store_name.to_s).type
8
+ raise TypeConflictError, store_type_error_msg(type) if %i[hstore jsonb].exclude?(type)
9
+ klass = "ActiveRecord::Store::Flexible#{type.capitalize}".constantize
10
+ klass.new(self, store_name)
11
+ end
12
+
13
+ private
14
+
15
+ def store_type_error_msg(type)
16
+ "Column type is not a known store: #{type}"
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveRecord
4
+ module Store
5
+ class FlexibleHstore < ActiveRecord::Store::FlexibleStore
6
+ def delete_keys(*keys)
7
+ keys = keys.flatten.map(&:to_s)
8
+ relation.update_all(["#{store_name} = delete(#{store_name}, ARRAY[:keys])", keys: keys])
9
+ end
10
+
11
+ def merge(pairs)
12
+ relation.update_all(["#{store_name} = hstore(#{store_name}) || hstore(ARRAY[:keys])",
13
+ keys: pairs.to_a.flatten.map(&:to_s)])
14
+ end
15
+
16
+ def delete_pairs(pairs)
17
+ relation.update_all(
18
+ ["#{store_name} = delete(#{store_name}, hstore(ARRAY[:keys], ARRAY[:values]))",
19
+ keys: pairs.keys.map(&:to_s), values: pairs.values.map(&:to_s)]
20
+ )
21
+ end
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveRecord
4
+ module Store
5
+ class FlexibleJsonb < ActiveRecord::Store::FlexibleStore
6
+ def delete_keys(*keys)
7
+ keys.flatten!
8
+ query = +"#{store_name} = #{store_name}"
9
+ keys.length.times { query.concat(" - ?") }
10
+ relation.update_all([query, *keys])
11
+ end
12
+
13
+ def merge(pairs)
14
+ relation.update_all(["#{store_name} = #{store_name} || ?::jsonb", pairs.to_json])
15
+ end
16
+
17
+ def delete_pairs(pairs)
18
+ keys = pairs.keys
19
+ pairs = pairs.map { |k, v| {k => v} }
20
+ @relation = relation.where.store(store_name, *pairs)
21
+ delete_keys(keys)
22
+ end
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveRecord
4
+ module Store
5
+ class FlexibleStore
6
+ attr_reader :relation, :store_name
7
+
8
+ def initialize(relation, store_name)
9
+ @relation, @store_name = relation, store_name
10
+ end
11
+ end
12
+ end
13
+ end
@@ -1,5 +1,4 @@
1
- # Check rails version
2
- RAILS_5 = ActiveRecord.version.release >= Gem::Version.new("5")
1
+ # frozen_string_literal: true
3
2
 
4
3
  module ActiveRecord
5
4
  module QueryMethods
@@ -7,10 +6,13 @@ module ActiveRecord
7
6
  # Provides _containment_ queries methods.
8
7
  # Provides basic methods.
9
8
  class StoreChain
9
+ attr_reader :store_name, :quoted_store_name
10
+
10
11
  def initialize(scope, store_name)
11
12
  @scope = scope
12
13
  @store_name = store_name
13
14
  @inverted = false
15
+ @quoted_store_name = "#{@scope.klass.quoted_table_name}.#{@scope.klass.connection.quote_column_name(store_name)}"
14
16
  end
15
17
 
16
18
  # Whether the store contains provided store
@@ -22,7 +24,7 @@ module ActiveRecord
22
24
  # data = {a: 1}
23
25
  # Model.store(:store).contains(data).all #=> [Model(name: 'first', ...)]
24
26
  def contains(opts)
25
- update_scope "#{@store_name} @> #{type_cast(opts)}"
27
+ update_scope contains_clause(opts)
26
28
  end
27
29
 
28
30
  # Whether the store is contained within provided store
@@ -34,7 +36,7 @@ module ActiveRecord
34
36
  # data = {b: 1, c: 2}
35
37
  # Model.store(:store).contains(data).all #=> [Model(name: 'first', ...)]
36
38
  def contained(opts)
37
- update_scope "#{@store_name} <@ #{type_cast(opts)}"
39
+ update_scope "#{quoted_store_name} <@ #{type_cast(opts)}"
38
40
  end
39
41
 
40
42
  # Add negation to condition.
@@ -52,37 +54,56 @@ module ActiveRecord
52
54
  self
53
55
  end
54
56
 
55
- if RAILS_5
56
- protected
57
+ # Query by store values.
58
+ # Supports array values.
59
+ #
60
+ # NOTE: This method uses "@>" (contains) operator with logic (AND/OR)
61
+ # and not uses "->" (value-by-key). The use of "contains" operator allows us to
62
+ # use GIN index effectively.
63
+ #
64
+ # Example
65
+ # Model.create!(name: 'first', store: {b: 1, c: 2})
66
+ # Model.create!(name: 'second', store: {b: 2, c: 3})
67
+ #
68
+ # Model.store(:store, c: 2).all #=> [Model(name: 'first', ...)]
69
+ # #=> (SQL) select * from ... where store @> '"c"=>"2"'::hstore
70
+ #
71
+ # Model.store(:store, b: [1, 2]).size #=> 2
72
+ # #=> (SQL) select * from ... where (store @> '"c"=>"1"'::hstore) or
73
+ # (store @> '"c"=>"2"'::hstore)
74
+ def where(*opts)
75
+ opts.map! { |opt| opt.is_a?(Hash) ? opt : [opt] }
76
+
77
+ update_scope(
78
+ opts.map do |opt|
79
+ opt.map do |k, v|
80
+ case v
81
+ when Array
82
+ "(#{build_or_contains(k, v)})"
83
+ else
84
+ contains_clause(k => v)
85
+ end
86
+ end.join(" and ")
87
+ end.join(" or ")
88
+ )
89
+ @scope
90
+ end
91
+
92
+ protected
57
93
 
94
+ if ActiveRecord.version.release >= Gem::Version.new("5")
58
95
  def update_scope(*opts)
59
- where_clause = @scope.send(:where_clause_factory).build(opts, {})
96
+ where_clause = build_where_clause(opts)
60
97
  @scope.where_clause += @inverted ? where_clause.invert : where_clause
61
98
  @scope
62
99
  end
63
100
 
64
101
  def type_cast(value)
65
102
  ActiveRecord::Base.connection.quote(
66
- @scope.table.type_cast_for_database(@store_name, value)
103
+ @scope.klass.type_caster.type_cast_for_database(@store_name, value)
67
104
  )
68
105
  end
69
-
70
- def where_with_prefix(prefix, opts)
71
- where_clause = @scope.send(:where_clause_factory).build(opts, {})
72
- predicates = where_clause.ast.children.map do |rel|
73
- rel.left = to_sql_literal(prefix, rel.left)
74
- rel
75
- end
76
- where_clause = ActiveRecord::Relation::WhereClause.new(
77
- predicates,
78
- where_clause.binds
79
- )
80
- @scope.where_clause += @inverted ? where_clause.invert : where_clause
81
- @scope
82
- end
83
106
  else
84
- protected
85
-
86
107
  def update_scope(*opts)
87
108
  where_clause = @scope.send(:build_where, opts).map do |rel|
88
109
  @inverted ? invert_arel(rel) : rel
@@ -98,15 +119,6 @@ module ActiveRecord
98
119
  )
99
120
  end
100
121
 
101
- def where_with_prefix(prefix, opts)
102
- where_value = @scope.send(:build_where, opts).map do |rel|
103
- rel.left = to_sql_literal(prefix, rel.left)
104
- @inverted ? invert_arel(rel) : rel
105
- end
106
- @scope.where_values += where_value
107
- @scope
108
- end
109
-
110
122
  def invert_arel(rel)
111
123
  case rel
112
124
  when Arel::Nodes::In
@@ -120,6 +132,24 @@ module ActiveRecord
120
132
  end
121
133
  end
122
134
  end
135
+
136
+ private
137
+
138
+ def contains_clause(opts)
139
+ "#{quoted_store_name} @> #{type_cast(opts)}"
140
+ end
141
+
142
+ def build_or_contains(k, vals)
143
+ vals.map { |v| contains_clause(k => v) }.join(" or ")
144
+ end
145
+
146
+ def build_where_clause(opts)
147
+ if ActiveRecord.version.release >= Gem::Version.new("6.1.0")
148
+ @scope.send(:build_where_clause, opts)
149
+ else
150
+ @scope.send(:where_clause_factory).build(opts, [])
151
+ end
152
+ end
123
153
  end
124
154
 
125
155
  # Base class for key-value types of stores (hstore, jsonb)
@@ -133,7 +163,7 @@ module ActiveRecord
133
163
  # # Get all records which have key 'a' in store 'store'
134
164
  # Model.store(:store).key('a').all #=> [Model(name: 'first', ...)]
135
165
  def key(key)
136
- update_scope "#{@store_name} ? :key", key: key.to_s
166
+ update_scope "#{quoted_store_name} ? :key", key: key.to_s
137
167
  end
138
168
 
139
169
  # Several keys existence
@@ -145,7 +175,7 @@ module ActiveRecord
145
175
  # Model.store(:store).keys('a','b').all #=> [Model(name: 'first', ...)]
146
176
  def keys(*keys)
147
177
  update_scope(
148
- "#{@store_name} ?& ARRAY[:keys]",
178
+ "#{quoted_store_name} ?& ARRAY[:keys]",
149
179
  keys: keys.flatten.map(&:to_s)
150
180
  )
151
181
  end
@@ -159,7 +189,7 @@ module ActiveRecord
159
189
  # Model.store(:store).keys('a','b').count #=> 2
160
190
  def any(*keys)
161
191
  update_scope(
162
- "#{@store_name} ?| ARRAY[:keys]",
192
+ "#{quoted_store_name} ?| ARRAY[:keys]",
163
193
  keys: keys.flatten.map(&:to_s)
164
194
  )
165
195
  end
@@ -171,6 +201,48 @@ module ActiveRecord
171
201
  "#{prefix}'#{node.name}'"
172
202
  )
173
203
  end
204
+
205
+ if ActiveRecord.version.release >= Gem::Version.new("5")
206
+ def where_with_prefix(prefix, opts)
207
+ where_clause = build_where_clause(opts)
208
+ where_clause_ast = where_clause.ast
209
+
210
+ # Converting `HomogenousIn` node to `In` type allows us to set its `left`
211
+ # to sql literal as with other node types (`HomogenousIn` does not support this).
212
+ if defined?(Arel::Nodes::HomogeneousIn) && where_clause_ast.is_a?(Arel::Nodes::HomogeneousIn)
213
+ where_clause_ast = Arel::Nodes::In.new(where_clause_ast.left, where_clause_ast.right)
214
+ end
215
+
216
+ predicates = if where_clause_ast.is_a?(Arel::Nodes::And)
217
+ where_clause.ast.children.map do |rel|
218
+ rel.left = to_sql_literal(prefix, rel.left)
219
+ rel
220
+ end
221
+ else
222
+ where_clause_ast.left = to_sql_literal(prefix, where_clause_ast.left)
223
+ [where_clause_ast]
224
+ end
225
+
226
+ params = if ActiveRecord.version.release >= Gem::Version.new("5.2.0")
227
+ [predicates]
228
+ else
229
+ [predicates, where_clause.binds]
230
+ end
231
+
232
+ where_clause = ActiveRecord::Relation::WhereClause.new(*params)
233
+ @scope.where_clause += @inverted ? where_clause.invert : where_clause
234
+ @scope
235
+ end
236
+ else
237
+ def where_with_prefix(prefix, opts)
238
+ where_value = @scope.send(:build_where, opts).map do |rel|
239
+ rel.left = to_sql_literal(prefix, rel.left)
240
+ @inverted ? invert_arel(rel) : rel
241
+ end
242
+ @scope.where_values += where_value
243
+ @scope
244
+ end
245
+ end
174
246
  end
175
247
  end
176
248
  end