Skip to content

Commit 232a057

Browse files
authored
Merge pull request rails#46331 from Shopify/introduce-AR-query-by-configuration
Allow specifying columns to use in ActiveRecord::Base object queries
2 parents 7a6bcc6 + 415e6b6 commit 232a057

File tree

10 files changed

+165
-8
lines changed

10 files changed

+165
-8
lines changed

activerecord/CHANGELOG.md

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,18 @@
1+
* Allow configuring columns list to be used in SQL queries issued by an `ActiveRecord::Base` object
2+
3+
It is now possible to configure columns list that will be used to build an SQL query clauses when
4+
updating, deleting or reloading an `ActiveRecord::Base` object
5+
6+
```ruby
7+
class Developer < ActiveRecord::Base
8+
query_constraints :company_id, :id
9+
end
10+
developer = Developer.first.update(name: "Bob")
11+
# => UPDATE "developers" SET "name" = 'Bob' WHERE "developers"."company_id" = 1 AND "developers"."id" = 1
12+
```
13+
14+
*Nikita Vasilevsky*
15+
116
* Adds `validate` to foreign keys and check constraints in schema.rb
217

318
Previously, `schema.rb` would not record if `validate: false` had been used when adding a foreign key or check

activerecord/lib/active_record.rb

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,7 @@ module ActiveRecord
6060
autoload :Persistence
6161
autoload :QueryCache
6262
autoload :Querying
63+
autoload :QueryConstraints
6364
autoload :QueryLogs
6465
autoload :ReadonlyAttributes
6566
autoload :RecordInvalid, "active_record/validations"

activerecord/lib/active_record/locking/optimistic.rb

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -91,7 +91,7 @@ def _update_row(attribute_names, attempted_action = "update")
9191
locking_column = self.class.locking_column
9292
lock_attribute_was = @attributes[locking_column]
9393

94-
update_constraints = _primary_key_constraints_hash
94+
update_constraints = _query_constraints_hash
9595
update_constraints[locking_column] = _lock_value_for_database(locking_column)
9696

9797
attribute_names = attribute_names.dup if attribute_names.frozen?
@@ -122,7 +122,7 @@ def destroy_row
122122

123123
locking_column = self.class.locking_column
124124

125-
delete_constraints = _primary_key_constraints_hash
125+
delete_constraints = _query_constraints_hash
126126
delete_constraints[locking_column] = _lock_value_for_database(locking_column)
127127

128128
affected_rows = self.class._delete_record(delete_constraints)

activerecord/lib/active_record/persistence.rb

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ module ActiveRecord
66
# = Active Record \Persistence
77
module Persistence
88
extend ActiveSupport::Concern
9+
include QueryConstraints
910

1011
module ClassMethods
1112
# Creates an object (or multiple objects) and saves it to the database, if validations pass.
@@ -834,7 +835,7 @@ def update_columns(attributes)
834835
verify_readonly_attribute(name) || name
835836
end
836837

837-
update_constraints = _primary_key_constraints_hash
838+
update_constraints = _query_constraints_hash
838839
attributes = attributes.each_with_object({}) do |(k, v), h|
839840
h[k] = @attributes.write_cast_value(k, v)
840841
clear_attribute_change(k)
@@ -1051,8 +1052,12 @@ def apply_scoping?(options)
10511052
(self.class.default_scopes?(all_queries: true) || self.class.global_current_scope)
10521053
end
10531054

1054-
def _primary_key_constraints_hash
1055-
{ @primary_key => id_in_database }
1055+
def _query_constraints_hash
1056+
return { @primary_key => id_in_database } unless self.class.query_constraints_list
1057+
1058+
self.class.query_constraints_list.index_with do |column_name|
1059+
attribute_in_database(column_name)
1060+
end
10561061
end
10571062

10581063
# A hook to be overridden by association modules.
@@ -1064,7 +1069,7 @@ def destroy_row
10641069
end
10651070

10661071
def _delete_row
1067-
self.class._delete_record(_primary_key_constraints_hash)
1072+
self.class._delete_record(_query_constraints_hash)
10681073
end
10691074

10701075
def _touch_row(attribute_names, time)
@@ -1080,7 +1085,7 @@ def _touch_row(attribute_names, time)
10801085
def _update_row(attribute_names, attempted_action = "update")
10811086
self.class._update_record(
10821087
attributes_with_values(attribute_names),
1083-
_primary_key_constraints_hash
1088+
_query_constraints_hash
10841089
)
10851090
end
10861091

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
# frozen_string_literal: true
2+
3+
module ActiveRecord
4+
module QueryConstraints
5+
extend ActiveSupport::Concern
6+
7+
included do
8+
class_attribute :query_constraints_list, instance_writer: false
9+
end
10+
11+
module ClassMethods
12+
# Accepts a list of attribute names to be used in the WHERE clause
13+
# of SELECT / UPDATE / DELETE queries.
14+
#
15+
# class Developer < ActiveRecord::Base
16+
# query_constraints :company_id, :id
17+
# end
18+
#
19+
# developer = Developer.first
20+
# developer.inspect # => #<Developer id: 1, company_id: 1, ...>
21+
#
22+
# developer.update!(name: "Nikita")
23+
# # => UPDATE "developers" SET "name" = 'Nikita' WHERE "developers"."company_id" = 1 AND "developers"."id" = 1
24+
#
25+
# It is possible to update attribute used in the query_by clause:
26+
# developer.update!(company_id: 2)
27+
# # => UPDATE "developers" SET "company_id" = 2 WHERE "developers"."company_id" = 1 AND "developers"."id" = 1
28+
#
29+
# developer.name = "Bob"
30+
# developer.save!
31+
# # => UPDATE "developers" SET "name" = 'Bob' WHERE "developers"."company_id" = 1 AND "developers"."id" = 1
32+
#
33+
# developer.destroy!
34+
# # => DELETE FROM "developers" WHERE "developers"."company_id" = 1 AND "developers"."id" = 1
35+
#
36+
# developer.delete
37+
# # => DELETE FROM "developers" WHERE "developers"."company_id" = 1 AND "developers"."id" = 1
38+
#
39+
# developer.reload
40+
# # => SELECT "developers".* FROM "developers" WHERE "developers"."company_id" = 1 AND "developers"."id" = 1 LIMIT 1
41+
def query_constraints(*columns_list)
42+
self.query_constraints_list = columns_list.map(&:to_s)
43+
end
44+
end
45+
end
46+
end

activerecord/test/cases/persistence_test.rb

Lines changed: 50 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,9 +19,11 @@
1919
require "models/ship"
2020
require "models/admin"
2121
require "models/admin/user"
22+
require "models/clothing_item"
2223

2324
class PersistenceTest < ActiveRecord::TestCase
24-
fixtures :topics, :companies, :developers, :accounts, :minimalistics, :authors, :author_addresses, :posts, :minivans
25+
fixtures :topics, :companies, :developers, :accounts, :minimalistics, :authors, :author_addresses,
26+
:posts, :minivans, :clothing_items
2527

2628
def test_update_many
2729
topic_data = { 1 => { "content" => "1 updated" }, 2 => { "content" => "2 updated" } }
@@ -1339,4 +1341,51 @@ def test_reset_column_information_resets_children
13391341
ActiveRecord::Base.connection.remove_column(:topics, :foo)
13401342
Topic.reset_column_information
13411343
end
1344+
1345+
def test_update_uses_query_constraints_config
1346+
clothing_item = clothing_items(:green_t_shirt)
1347+
sql = capture_sql { clothing_item.update(description: "Lovely green t-shirt") }.first
1348+
assert_match(/WHERE .*clothing_type/, sql)
1349+
assert_match(/WHERE .*color/, sql)
1350+
end
1351+
1352+
def test_save_uses_query_constraints_config
1353+
clothing_item = clothing_items(:green_t_shirt)
1354+
clothing_item.description = "Lovely green t-shirt"
1355+
sql = capture_sql { clothing_item.save }.first
1356+
assert_match(/WHERE .*clothing_type/, sql)
1357+
assert_match(/WHERE .*color/, sql)
1358+
end
1359+
1360+
def test_destroy_uses_query_constraints_config
1361+
clothing_item = clothing_items(:green_t_shirt)
1362+
sql = capture_sql { clothing_item.destroy }.first
1363+
assert_match(/WHERE .*clothing_type/, sql)
1364+
assert_match(/WHERE .*color/, sql)
1365+
end
1366+
1367+
def test_delete_uses_query_constraints_config
1368+
clothing_item = clothing_items(:green_t_shirt)
1369+
sql = capture_sql { clothing_item.delete }.first
1370+
assert_match(/WHERE .*clothing_type/, sql)
1371+
assert_match(/WHERE .*color/, sql)
1372+
end
1373+
1374+
def test_update_attribute_uses_query_constraints_config
1375+
clothing_item = clothing_items(:green_t_shirt)
1376+
sql = capture_sql { clothing_item.update_attribute(:description, "Lovely green t-shirt") }.first
1377+
assert_match(/WHERE .*clothing_type/, sql)
1378+
assert_match(/WHERE .*color/, sql)
1379+
end
1380+
1381+
def test_it_is_possible_to_update_parts_of_the_query_constraints_config
1382+
clothing_item = clothing_items(:green_t_shirt)
1383+
clothing_item.color = "blue"
1384+
clothing_item.description = "Now it's a blue t-shirt"
1385+
sql = capture_sql { clothing_item.save }.first
1386+
assert_match(/WHERE .*clothing_type/, sql)
1387+
assert_match(/WHERE .*color/, sql)
1388+
1389+
assert_equal("blue", ClothingItem.find_by(id: clothing_item.id).color)
1390+
end
13421391
end
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
# frozen_string_literal: true
2+
3+
require "cases/helper"
4+
require "models/clothing_item"
5+
6+
class QueryConstraintsTest < ActiveRecord::TestCase
7+
def test_primary_key_stays_the_same
8+
assert_equal("id", ClothingItem.primary_key)
9+
end
10+
11+
def test_query_constraints_list_is_an_array_of_strings
12+
assert_equal(["clothing_type", "color"], ClothingItem.query_constraints_list)
13+
end
14+
end
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
green_pants:
2+
color: green
3+
clothing_type: pants
4+
description: Cool green pants
5+
6+
green_t_shirt:
7+
color: green
8+
clothing_type: t-shirt
9+
description: Cool green t-shirt
10+
11+
red_t_shirt:
12+
color: red
13+
clothing_type: t-shirt
14+
description: Cool red t-shirt
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
# frozen_string_literal: true
2+
3+
class ClothingItem < ActiveRecord::Base
4+
query_constraints :clothing_type, :color
5+
end

activerecord/test/schema/schema.rb

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -232,6 +232,14 @@
232232
t.references :citation
233233
end
234234

235+
create_table :clothing_items, force: true do |t|
236+
t.string :clothing_type
237+
t.string :color
238+
t.text :description
239+
240+
t.index [:clothing_type, :color], unique: true
241+
end
242+
235243
create_table :clubs, force: true do |t|
236244
t.string :name
237245
t.integer :category_id

0 commit comments

Comments
 (0)