activerecord_batch_update 0.0.3

Sign up to get free protection for your applications and to get access to all the features.
Files changed (3) hide show
  1. checksums.yaml +7 -0
  2. data/lib/activerecord_batch_update.rb +118 -0
  3. metadata +72 -0
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 614292d3e09e070b8d7aff48134d8cee8104dc9f00e96e9deb33b0195bf04858
4
+ data.tar.gz: 2c4f608cea0f5ae569c76df93c3f3aafed9bc0911c13253d7a2cb02b9c24332d
5
+ SHA512:
6
+ metadata.gz: d5be0abbf08a712b5b5a138b15e37928b9b50c138851651877ac56837f18b884e82b226eaa1c96c1dd1198c8c017bd665c3ac57b6c7e78ecbbfb70f8d0f22443
7
+ data.tar.gz: ba349d81455f2908dbcddff5da11c4016b8660e13af081dcdfd4cfd55043c35b73b22bd6e221f95638eb6a23e45a6042ae310e8b38de85f7d73e20bf9a677ea8
@@ -0,0 +1,118 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'active_support'
4
+
5
+ module ActiveRecordBatchUpdate
6
+ extend ::ActiveSupport::Concern
7
+
8
+ # Given an array of records with changes,
9
+ # perform the minimal amount of queries to update
10
+ # all the records without including unchanged attributes
11
+ # in the UPDATE statement.
12
+ #
13
+ # Do this without INSERT ... ON DUPLICATE KEY UPDATE
14
+ # which will re-insert the objects if they were deleted in another thread
15
+
16
+ module ClassMethods
17
+ def batch_update(entries, columns:, batch_size: 100, validate: true)
18
+ columns = column_names if columns == :all
19
+ columns = (Array.wrap(columns).map(&:to_s) + %w[updated_at]).uniq
20
+
21
+ entries = entries.select { columns.intersect?(_1.changed) }
22
+ entries.each { _1.updated_at = Time.current } if has_attribute?('updated_at')
23
+ entries.each(&:validate!) if validate
24
+
25
+ primary_keys = Array.wrap(primary_key).map(&:to_s)
26
+
27
+ updated_count = batch_update_statements(
28
+ entries.map do |entry|
29
+ (primary_keys + (entry.changed & columns)).to_h { [_1, entry.read_attribute(_1)] }
30
+ end,
31
+ update_on: primary_keys,
32
+ batch_size: batch_size
33
+ ).sum do |sql|
34
+ connection.exec_update(sql)
35
+ end
36
+
37
+ connection.clear_query_cache if connection.query_cache_enabled
38
+
39
+ updated_count
40
+ end
41
+
42
+ def batch_update_statements(entries, update_on: :id, batch_size: 100)
43
+ update_on = Array.wrap(update_on).map(&:to_s)
44
+
45
+ entries.map(&:stringify_keys).group_by { _1.keys.sort! }.sort.flat_map do |(keys, items)|
46
+ next [] if keys.empty?
47
+
48
+ where_clause = where_statement(update_on)
49
+ update_clause = update_statement(keys - update_on)
50
+
51
+ items.each_slice(batch_size).map do |slice|
52
+ [
53
+ "WITH \"#{cte_table.name}\" (#{keys.join(', ')})",
54
+ "AS ( #{values_statement(slice, keys)} )",
55
+ update_clause,
56
+ "FROM \"#{cte_table.name}\"",
57
+ "WHERE #{where_clause}"
58
+ ].join(' ')
59
+ end
60
+ end
61
+ end
62
+
63
+ private
64
+
65
+ def cte_table
66
+ @cte_table ||= Arel::Table.new('batch_updates')
67
+ end
68
+
69
+ def values_statement(items, cols)
70
+ first, *rest = items
71
+
72
+ rows = [
73
+ values_list_casted_item(first, cols),
74
+ *rest.map { values_list_other_item(_1, cols) }
75
+ ]
76
+
77
+ "VALUES #{rows.map { "(#{_1.map(&:to_sql).join(', ')})" }.join(', ')}"
78
+ end
79
+
80
+ def values_list_casted_item(item, cols)
81
+ cols.map do |col|
82
+ Arel::Nodes::NamedFunction.new(
83
+ 'CAST',
84
+ [
85
+ Arel::Nodes.build_quoted(item[col], arel_table[col]).as(columns_hash[col].sql_type_metadata.sql_type)
86
+ ]
87
+ )
88
+ end
89
+ end
90
+
91
+ def values_list_other_item(item, cols)
92
+ cols.map do |col|
93
+ Arel::Nodes.build_quoted(item[col], arel_table[col])
94
+ end
95
+ end
96
+
97
+ def update_statement(cols)
98
+ Arel::UpdateManager.new(arel_table).tap do |um|
99
+ um.set(
100
+ cols.map do |col|
101
+ [
102
+ arel_table[col],
103
+ cte_table[col]
104
+ ]
105
+ end
106
+ )
107
+ end.to_sql
108
+ end
109
+
110
+ def where_statement(primary_keys)
111
+ primary_keys.map { arel_table[_1].eq(cte_table[_1]) }.reduce(:and).to_sql
112
+ end
113
+ end
114
+ end
115
+
116
+ ActiveSupport.on_load(:active_record) do
117
+ include(ActiveRecordBatchUpdate)
118
+ end
metadata ADDED
@@ -0,0 +1,72 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: activerecord_batch_update
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.0.3
5
+ platform: ruby
6
+ authors:
7
+ - Quentin de Metz
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2024-10-19 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: activerecord
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - "~>"
18
+ - !ruby/object:Gem::Version
19
+ version: '7.0'
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - "~>"
25
+ - !ruby/object:Gem::Version
26
+ version: '7.0'
27
+ - !ruby/object:Gem::Dependency
28
+ name: activesupport
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - "~>"
32
+ - !ruby/object:Gem::Version
33
+ version: '7.0'
34
+ type: :runtime
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - "~>"
39
+ - !ruby/object:Gem::Version
40
+ version: '7.0'
41
+ description: ''
42
+ email: quentin@pennylane.com
43
+ executables: []
44
+ extensions: []
45
+ extra_rdoc_files: []
46
+ files:
47
+ - lib/activerecord_batch_update.rb
48
+ homepage: https://rubygems.org/gems/activerecord_batch_update
49
+ licenses:
50
+ - MIT
51
+ metadata:
52
+ rubygems_mfa_required: 'true'
53
+ post_install_message:
54
+ rdoc_options: []
55
+ require_paths:
56
+ - lib
57
+ required_ruby_version: !ruby/object:Gem::Requirement
58
+ requirements:
59
+ - - ">="
60
+ - !ruby/object:Gem::Version
61
+ version: 3.3.4
62
+ required_rubygems_version: !ruby/object:Gem::Requirement
63
+ requirements:
64
+ - - ">="
65
+ - !ruby/object:Gem::Version
66
+ version: '0'
67
+ requirements: []
68
+ rubygems_version: 3.5.11
69
+ signing_key:
70
+ specification_version: 4
71
+ summary: Update multiple records with different values in a small number of queries
72
+ test_files: []