activerecord-slotted_counters 0.1.4 → 0.3.0

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: efdd024fe0a5944a7cbcaa4e6990a05b74b80634affb59d3c0a30a0a3b08c52e
4
- data.tar.gz: 70e2607be4d82c909002d01856d76b6663307b1ccd1204015db6e160a33c6193
3
+ metadata.gz: 2a233ed9bbeebe36d70a84c813b01d07cf74057709b907d491ac71e688bfc44c
4
+ data.tar.gz: c93306db04ae60233c9fb0ec6b8a28f1ee75f5bf22e34f503ae462f6858baa07
5
5
  SHA512:
6
- metadata.gz: 5b5a4570082a3cf8264472ce8ccc59d50c52621323937cdfddc48cddfbbca01020e7e76f70457e98eeb5a5f32b960979f3315abafd880873788eb5ccd78e8a78
7
- data.tar.gz: e221d1d149adbe60e4b7a60f76aced3a9d3f1c16f7e37950aad54ba44e9a7828a92e7a411be51bd0a91ecfe5827716e09080b491f375d0fe7f0cb18781e9981b
6
+ metadata.gz: ce0bb7f7cdb8fe0c48de169c2e8e94d5977e74cb3840bc4aafbaf181a866e12eb7b06d4ffef603d0768d21ec4b16f7092f2884dd0c1bf1b76d99cd61e71c9353
7
+ data.tar.gz: 93d093f88f9cae152096dbc7c3e906f4a9c39a2e50db21befeef4b9ab756515bea3149ffa6fdf7391aa2dc16d2e816f9a0819567d25992f517936b872391c135
data/CHANGELOG.md CHANGED
@@ -2,6 +2,16 @@
2
2
 
3
3
  ## master
4
4
 
5
+ ## 0.3.0 (2025-03-05)
6
+
7
+ - Rails 8 support. ([@palkan][])
8
+
9
+ ## 0.2.0 (2023-10-27)
10
+
11
+ - Add Mysql support [#18](https://github.com/evilmartians/activerecord-slotted_counters/pull/18) ([@prog-supdex][])
12
+
13
+ - Add SQLite support [#17](https://github.com/evilmartians/activerecord-slotted_counters/pull/17) ([@prog-supdex][])
14
+
5
15
  ## 0.1.4 (2023-04-19)
6
16
 
7
17
  - Fix "can't modify frozen String" for the pg adapter (ruby 2.7) [#15](https://github.com/evilmartians/activerecord-slotted_counters/pull/15) ([@LukinEgor][])
@@ -25,3 +35,4 @@
25
35
  [@palkan]: https://github.com/palkan
26
36
  [@LukinEgor]: https://github.com/LukinEgor
27
37
  [@danielwestendorf]: https://github.com/danielwestendorf
38
+ [@prog-supdex]: https://github.com/prog-supdex
data/README.md CHANGED
@@ -22,9 +22,10 @@ Add to your project:
22
22
  gem "activerecord-slotted_counters"
23
23
  ```
24
24
 
25
- ### Supported Ruby versions
25
+ ### Requirements
26
26
 
27
- - Ruby (MRI) >= 2.7.0
27
+ - Ruby >= 2.7.0
28
+ - Rails 6+
28
29
 
29
30
  ## Usage
30
31
 
@@ -67,7 +68,7 @@ user.comments_count #=> select * from slotted_counters where ...
67
68
  user.comments_count #=> no sql
68
69
  ```
69
70
 
70
- If you want to want preload counters for multiple records, you can use a convenient `#with_slotted_counters` method:
71
+ If you want to preload counters for multiple records, you can use a convenient `#with_slotted_counters` method:
71
72
 
72
73
  ```ruby
73
74
  User.all.with_slotted_counters(:comments).find_each do
@@ -77,10 +78,6 @@ end
77
78
 
78
79
  Using `counter_cache: true` on `belongs_to` associations also works as expected.
79
80
 
80
- ## Limitations / TODO
81
-
82
- - Gem supports only PostgreSQL for Rails 6
83
-
84
81
  ## Contributing
85
82
 
86
83
  Bug reports and pull requests are welcome on GitHub at [https://github.com/evilmartians/activerecord-slotted_counters](https://github.com/evilmartians/activerecord-slotted_counters).
@@ -0,0 +1,83 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveRecordSlottedCounters
4
+ module Adapters
5
+ class MysqlUpsert
6
+ attr_reader :klass
7
+
8
+ def initialize(klass, **)
9
+ @klass = klass
10
+ end
11
+
12
+ def apply?(current_adapter_name)
13
+ return false unless defined?(ActiveRecord::ConnectionAdapters::Mysql2Adapter)
14
+
15
+ current_adapter_name == ActiveRecord::ConnectionAdapters::Mysql2Adapter::ADAPTER_NAME
16
+ end
17
+
18
+ def bulk_insert(attributes, on_duplicate: nil, **)
19
+ raise ArgumentError, "Values must not be empty" if attributes.empty?
20
+
21
+ keys = attributes.first.keys + klass.all_timestamp_attributes_in_model
22
+
23
+ current_time = klass.current_time_from_proper_timezone
24
+ data = attributes.map { |attr| attr.values + [current_time, current_time] }
25
+
26
+ columns = columns_for_attributes(keys)
27
+
28
+ fields_str = quote_column_names(columns)
29
+ values_str = quote_many_records(columns, data)
30
+
31
+ sql = <<~SQL
32
+ INSERT INTO #{klass.quoted_table_name}
33
+ (#{fields_str})
34
+ VALUES #{values_str}
35
+ SQL
36
+
37
+ if on_duplicate.present?
38
+ sql += " ON DUPLICATE KEY UPDATE #{on_duplicate};"
39
+ end
40
+
41
+ # insert/update and return amount of updated rows
42
+ klass.connection.update(sql)
43
+ end
44
+
45
+ def wrap_column_name(value)
46
+ "VALUES(#{value})"
47
+ end
48
+
49
+ private
50
+
51
+ def columns_for_attributes(attributes)
52
+ attributes.map do |attribute|
53
+ klass.column_for_attribute(attribute)
54
+ end
55
+ end
56
+
57
+ def quote_column_names(columns, table_name: false)
58
+ columns.map do |column|
59
+ column_name = klass.connection.quote_column_name(column.name)
60
+
61
+ if table_name
62
+ "#{klass.quoted_table_name}.#{column_name}"
63
+ else
64
+ column_name
65
+ end
66
+ end.join(",")
67
+ end
68
+
69
+ def quote_record(columns, record_values)
70
+ values_str = record_values.each_with_index.map do |value, i|
71
+ type = klass.connection.lookup_cast_type_from_column(columns[i])
72
+ klass.connection.quote(type.serialize(value))
73
+ end.join(",")
74
+
75
+ "(#{values_str})"
76
+ end
77
+
78
+ def quote_many_records(columns, data)
79
+ data.map { |values| quote_record(columns, values) }.join(",")
80
+ end
81
+ end
82
+ end
83
+ end
@@ -5,12 +5,15 @@ module ActiveRecordSlottedCounters
5
5
  class PgUpsert
6
6
  attr_reader :klass
7
7
 
8
- def initialize(klass)
8
+ def initialize(klass, **)
9
9
  @klass = klass
10
10
  end
11
11
 
12
- def apply?
13
- ActiveRecord::VERSION::MAJOR < 7 && klass.connection.adapter_name == ActiveRecord::ConnectionAdapters::PostgreSQLAdapter::ADAPTER_NAME
12
+ def apply?(current_adapter_name)
13
+ return false if ActiveRecord::VERSION::MAJOR >= 7
14
+ return false unless defined?(ActiveRecord::ConnectionAdapters::PostgreSQLAdapter)
15
+
16
+ current_adapter_name == ActiveRecord::ConnectionAdapters::PostgreSQLAdapter::ADAPTER_NAME
14
17
  end
15
18
 
16
19
  def bulk_insert(attributes, on_duplicate: nil, unique_by: nil)
@@ -46,7 +49,11 @@ module ActiveRecordSlottedCounters
46
49
 
47
50
  sql += " RETURNING \"id\""
48
51
 
49
- klass.connection.exec_query(sql)
52
+ klass.connection.exec_query(sql).rows.count
53
+ end
54
+
55
+ def wrap_column_name(value)
56
+ "EXCLUDED.#{value}"
50
57
  end
51
58
 
52
59
  private
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveRecordSlottedCounters
4
+ module Adapters
5
+ class Rails7Upsert < RailsUpsert
6
+ def apply?(_)
7
+ ActiveRecord::VERSION::MAJOR == 7 && ActiveRecord::VERSION::MINOR < 2
8
+ end
9
+
10
+ def bulk_insert(attributes, on_duplicate: nil, unique_by: nil)
11
+ opts = {on_duplicate: on_duplicate, unique_by: unique_by}
12
+ opts.delete(:unique_by) unless supports_insert_conflict_target
13
+
14
+ # We have to manually call #update here to return the number of affected rows.
15
+ # In Rails <7.2, connection is obtained internally.
16
+ ActiveRecord::InsertAll.new(klass, attributes, **opts).then do |inserter|
17
+ inserter.send(:connection).update(inserter.send(:to_sql))
18
+ end
19
+ end
20
+ end
21
+ end
22
+ end
@@ -3,18 +3,34 @@
3
3
  module ActiveRecordSlottedCounters
4
4
  module Adapters
5
5
  class RailsUpsert
6
- attr_reader :klass
6
+ attr_reader :klass, :supports_insert_conflict_target
7
7
 
8
- def initialize(klass)
8
+ def initialize(klass, supports_insert_conflict_target: false)
9
9
  @klass = klass
10
+ @supports_insert_conflict_target = supports_insert_conflict_target
10
11
  end
11
12
 
12
- def apply?
13
+ def apply?(_)
13
14
  ActiveRecord::VERSION::MAJOR >= 7
14
15
  end
15
16
 
16
17
  def bulk_insert(attributes, on_duplicate: nil, unique_by: nil)
17
- klass.upsert_all(attributes, on_duplicate: on_duplicate, unique_by: unique_by)
18
+ opts = {on_duplicate: on_duplicate, unique_by: unique_by}
19
+ opts.delete(:unique_by) unless supports_insert_conflict_target
20
+
21
+ klass.with_connection do |c|
22
+ # We have to manually call #update here to return the number of affected rows
23
+ c.update(ActiveRecord::InsertAll.new(klass.all, c, attributes, **opts).send(:to_sql))
24
+ end
25
+ end
26
+
27
+ def wrap_column_name(value)
28
+ # This is mysql
29
+ if !supports_insert_conflict_target
30
+ "VALUES(#{value})"
31
+ else
32
+ "EXCLUDED.#{value}"
33
+ end
18
34
  end
19
35
  end
20
36
  end
@@ -0,0 +1,107 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveRecordSlottedCounters
4
+ module Adapters
5
+ class SqliteUpsert
6
+ attr_reader :klass
7
+
8
+ def initialize(klass, **)
9
+ @klass = klass
10
+ end
11
+
12
+ def apply?(current_adapter_name)
13
+ return false if ActiveRecord::VERSION::MAJOR >= 7
14
+ return false unless defined?(ActiveRecord::ConnectionAdapters::SQLite3Adapter)
15
+
16
+ current_adapter_name == ActiveRecord::ConnectionAdapters::SQLite3Adapter::ADAPTER_NAME
17
+ end
18
+
19
+ def bulk_insert(attributes, on_duplicate: nil, unique_by: nil)
20
+ raise ArgumentError, "Values must not be empty" if attributes.empty?
21
+
22
+ keys = attributes.first.keys + klass.all_timestamp_attributes_in_model
23
+
24
+ current_time = klass.current_time_from_proper_timezone
25
+ data = attributes.map { |attr| attr.values + [current_time, current_time] }
26
+
27
+ columns = columns_for_attributes(keys)
28
+
29
+ fields_str = quote_column_names(columns)
30
+ values_str = quote_many_records(columns, data)
31
+
32
+ sql = <<~SQL
33
+ INSERT INTO #{klass.quoted_table_name}
34
+ (#{fields_str})
35
+ VALUES #{values_str}
36
+ SQL
37
+
38
+ if unique_by.present?
39
+ index = unique_indexes.find { |i| i.name.to_sym == unique_by }
40
+ columns = columns_for_attributes(index.columns)
41
+ fields = quote_column_names(columns)
42
+
43
+ sql += " ON CONFLICT (#{fields})"
44
+ end
45
+
46
+ if on_duplicate.present?
47
+ sql += " DO UPDATE SET #{on_duplicate}"
48
+ end
49
+
50
+ sql += " RETURNING \"id\""
51
+
52
+ klass.connection.exec_query(sql).rows.count
53
+ end
54
+
55
+ def wrap_column_name(value)
56
+ "EXCLUDED.#{value}"
57
+ end
58
+
59
+ private
60
+
61
+ def unique_indexes
62
+ klass.connection.schema_cache.indexes(klass.table_name).select(&:unique)
63
+ end
64
+
65
+ def columns_for_attributes(attributes)
66
+ attributes.map do |attribute|
67
+ klass.column_for_attribute(attribute)
68
+ end
69
+ end
70
+
71
+ def quote_column_names(columns, table_name: false)
72
+ columns.map do |column|
73
+ column_name = klass.connection.quote_column_name(column.name)
74
+ if table_name
75
+ "#{klass.quoted_table_name}.#{column_name}"
76
+ else
77
+ column_name
78
+ end
79
+ end.join(",")
80
+ end
81
+
82
+ def quote_record(columns, record_values)
83
+ values_str = record_values.each_with_index.map do |value, i|
84
+ type = klass.connection.lookup_cast_type_from_column(columns[i])
85
+ klass.connection.quote(type.serialize(value))
86
+ end.join(",")
87
+ "(#{values_str})"
88
+ end
89
+
90
+ def quote_many_records(columns, data)
91
+ data.map { |values| quote_record(columns, values) }.join(",")
92
+ end
93
+
94
+ def postgresql_connection?(adapter_name)
95
+ return false unless defined?(ActiveRecord::ConnectionAdapters::PostgreSQLAdapter)
96
+
97
+ adapter_name == ActiveRecord::ConnectionAdapters::PostgreSQLAdapter::ADAPTER_NAME
98
+ end
99
+
100
+ def sqlite_connection?(adapter_name)
101
+ return false unless defined?(ActiveRecord::ConnectionAdapters::SQLite3Adapter)
102
+
103
+ adapter_name == ActiveRecord::ConnectionAdapters::SQLite3Adapter::ADAPTER_NAME
104
+ end
105
+ end
106
+ end
107
+ end
@@ -4,7 +4,10 @@ require "active_support"
4
4
  require "activerecord_slotted_counters/utils"
5
5
 
6
6
  require "activerecord_slotted_counters/adapters/rails_upsert"
7
+ require "activerecord_slotted_counters/adapters/rails7_upsert"
7
8
  require "activerecord_slotted_counters/adapters/pg_upsert"
9
+ require "activerecord_slotted_counters/adapters/sqlite_upsert"
10
+ require "activerecord_slotted_counters/adapters/mysql_upsert"
8
11
 
9
12
  module ActiveRecordSlottedCounters
10
13
  class SlottedCounter < ::ActiveRecord::Base
@@ -24,7 +27,8 @@ module ActiveRecordSlottedCounters
24
27
 
25
28
  class << self
26
29
  def bulk_insert(attributes)
27
- on_duplicate_clause = "count = slotted_counters.count + excluded.count"
30
+ on_duplicate_clause =
31
+ "count = slotted_counters.count + #{slotted_counter_db_adapter.wrap_column_name("count")}"
28
32
 
29
33
  slotted_counter_db_adapter.bulk_insert(
30
34
  attributes,
@@ -41,15 +45,20 @@ module ActiveRecordSlottedCounters
41
45
 
42
46
  def set_slotted_counter_db_adapter
43
47
  available_adapters = [
48
+ ActiveRecordSlottedCounters::Adapters::Rails7Upsert,
44
49
  ActiveRecordSlottedCounters::Adapters::RailsUpsert,
50
+ ActiveRecordSlottedCounters::Adapters::MysqlUpsert,
51
+ ActiveRecordSlottedCounters::Adapters::SqliteUpsert,
45
52
  ActiveRecordSlottedCounters::Adapters::PgUpsert
46
53
  ]
47
54
 
55
+ current_adapter_name = connection.adapter_name
56
+
48
57
  adapter = available_adapters
49
- .map { |adapter| adapter.new(self) }
50
- .detect { |adapter| adapter.apply? }
58
+ .map { |adapter| adapter.new(self, supports_insert_conflict_target: connection.supports_insert_conflict_target?) }
59
+ .detect { |adapter| adapter.apply?(current_adapter_name) }
51
60
 
52
- raise NotSupportedAdapter.new(connection.adapter_name) if adapter.nil?
61
+ raise NotSupportedAdapter.new(current_adapter_name) if adapter.nil?
53
62
 
54
63
  adapter
55
64
  end
@@ -205,9 +214,7 @@ module ActiveRecordSlottedCounters
205
214
  def insert_counters_records(ids, counters)
206
215
  counters_params = prepare_slotted_counters_params(ids, counters)
207
216
 
208
- result = ActiveRecordSlottedCounters::SlottedCounter.bulk_insert(counters_params)
209
-
210
- result.rows.count
217
+ ActiveRecordSlottedCounters::SlottedCounter.bulk_insert(counters_params)
211
218
  end
212
219
 
213
220
  def remove_counters_records(ids, counter_name)
@@ -219,7 +226,7 @@ module ActiveRecordSlottedCounters
219
226
  end
220
227
 
221
228
  def touch_attributes(ids, touch)
222
- scope = where(id: ids)
229
+ scope = unscoped.where(id: ids)
223
230
  return scope.touch_all if touch == true
224
231
 
225
232
  scope.touch_all(touch)
@@ -7,11 +7,11 @@ module ActiveRecordSlottedCounters
7
7
  private
8
8
 
9
9
  def slotted_counter_association_name(counter_type)
10
- "#{counter_type}_slotted_counters".to_sym
10
+ :"#{counter_type}_slotted_counters"
11
11
  end
12
12
 
13
13
  def slotted_counter_name(counter_type)
14
- "#{counter_type}_count".to_sym
14
+ :"#{counter_type}_count"
15
15
  end
16
16
 
17
17
  # TODO refactoring
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module ActiveRecordSlottedCounters # :nodoc:
4
- VERSION = "0.1.4"
4
+ VERSION = "0.3.0"
5
5
  end
metadata CHANGED
@@ -1,15 +1,15 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: activerecord-slotted_counters
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.4
4
+ version: 0.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Egor Lukin
8
8
  - Vladimir Dementyev
9
- autorequire:
9
+ autorequire:
10
10
  bindir: bin
11
11
  cert_chain: []
12
- date: 2023-04-20 00:00:00.000000000 Z
12
+ date: 2025-03-06 00:00:00.000000000 Z
13
13
  dependencies:
14
14
  - !ruby/object:Gem::Dependency
15
15
  name: activerecord
@@ -67,34 +67,6 @@ dependencies:
67
67
  - - ">="
68
68
  - !ruby/object:Gem::Version
69
69
  version: '3.9'
70
- - !ruby/object:Gem::Dependency
71
- name: pg
72
- requirement: !ruby/object:Gem::Requirement
73
- requirements:
74
- - - ">="
75
- - !ruby/object:Gem::Version
76
- version: '1.4'
77
- type: :development
78
- prerelease: false
79
- version_requirements: !ruby/object:Gem::Requirement
80
- requirements:
81
- - - ">="
82
- - !ruby/object:Gem::Version
83
- version: '1.4'
84
- - !ruby/object:Gem::Dependency
85
- name: sqlite3
86
- requirement: !ruby/object:Gem::Requirement
87
- requirements:
88
- - - ">="
89
- - !ruby/object:Gem::Version
90
- version: '0'
91
- type: :development
92
- prerelease: false
93
- version_requirements: !ruby/object:Gem::Requirement
94
- requirements:
95
- - - ">="
96
- - !ruby/object:Gem::Version
97
- version: '0'
98
70
  - !ruby/object:Gem::Dependency
99
71
  name: rspec-sqlimit
100
72
  requirement: !ruby/object:Gem::Requirement
@@ -120,8 +92,11 @@ files:
120
92
  - LICENSE.txt
121
93
  - README.md
122
94
  - lib/activerecord-slotted_counters.rb
95
+ - lib/activerecord_slotted_counters/adapters/mysql_upsert.rb
123
96
  - lib/activerecord_slotted_counters/adapters/pg_upsert.rb
97
+ - lib/activerecord_slotted_counters/adapters/rails7_upsert.rb
124
98
  - lib/activerecord_slotted_counters/adapters/rails_upsert.rb
99
+ - lib/activerecord_slotted_counters/adapters/sqlite_upsert.rb
125
100
  - lib/activerecord_slotted_counters/has_slotted_counter.rb
126
101
  - lib/activerecord_slotted_counters/railtie.rb
127
102
  - lib/activerecord_slotted_counters/utils.rb
@@ -137,7 +112,7 @@ metadata:
137
112
  documentation_uri: http://github.com/evilmartians/activerecord-slotted_counters
138
113
  homepage_uri: http://github.com/evilmartians/activerecord-slotted_counters
139
114
  source_code_uri: http://github.com/evilmartians/activerecord-slotted_counters
140
- post_install_message:
115
+ post_install_message:
141
116
  rdoc_options: []
142
117
  require_paths:
143
118
  - lib
@@ -152,8 +127,8 @@ required_rubygems_version: !ruby/object:Gem::Requirement
152
127
  - !ruby/object:Gem::Version
153
128
  version: '0'
154
129
  requirements: []
155
- rubygems_version: 3.3.3
156
- signing_key:
130
+ rubygems_version: 3.4.19
131
+ signing_key:
157
132
  specification_version: 4
158
133
  summary: Active Record slotted counters support
159
134
  test_files: []