Skip to content

Commit 98a3be0

Browse files
authored
Add update_columns methods to DB::Model for partial updates (#335)
* Add update_columns methods to DB::Model for efficient partial updates Introduce new methods `update_columns` and `update_columns!` that allow updating specific database columns without running validations or callbacks. These methods provide a performance optimization for cases where only a subset of fields needs to be updated. Key features: - Updates only specified columns, leaving others unchanged - Bypasses model validations and lifecycle callbacks - Updates both in-memory values and database records - `update_columns!` raises an error for unsaved records Usage: user.update_columns(username: "new_name") user.update_columns!(email: "new@example.com", last_login: Time.utc) * Return early for new_record and deleted objects on update_columns
1 parent 4befece commit 98a3be0

File tree

4 files changed

+235
-0
lines changed

4 files changed

+235
-0
lines changed

docs/docs/models-and-databases/callbacks.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,3 +110,11 @@ after_rollback :do_something_else, on: [:create, :delete] # Will run after rolle
110110
```
111111

112112
The actions supported by the `on` argument are `create`, `update`, `save`, and `delete`.
113+
114+
## Methods that bypass callbacks
115+
116+
Some model methods intentionally bypass callbacks for performance or specific use cases. The following methods do **not** trigger callbacks:
117+
118+
* `#update_columns` and `#update_columns!` - These methods update specific columns directly in the database without running validations or any lifecycle callbacks. They are useful for performance-critical updates where you want to avoid the overhead of the full save lifecycle.
119+
120+
If you need to update records while ensuring that callbacks are executed, use the standard `#save`, `#save!`, `#update`, or `#update!` methods instead.

docs/docs/models-and-databases/introduction.md

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -355,6 +355,26 @@ Marten also provide the ability to update the records that are targeted by a spe
355355
Article.filter(title: "My article").update(title: "Updated!")
356356
```
357357

358+
#### Updating specific columns
359+
360+
If you need to update only specific columns without running validations or callbacks, you can use the `#update_columns` or `#update_columns!` methods:
361+
362+
```crystal
363+
article = Article.get(id: 42)
364+
article.update_columns(title: "Updated!")
365+
```
366+
367+
These methods are useful when you want to efficiently update a subset of fields without triggering the full save lifecycle. The `#update_columns!` variant will raise an error if called on a new (unsaved) record:
368+
369+
```crystal
370+
article = Article.new
371+
article.update_columns!(title: "New article") # Raises Marten::DB::Errors::UnmetSaveCondition
372+
```
373+
374+
:::caution
375+
The `#update_columns` and `#update_columns!` methods bypass model validations and lifecycle callbacks (such as `before_update`, `after_update`, etc.). Use them with caution and only when you're certain that skipping these checks is safe for your application.
376+
:::
377+
358378
### Delete
359379

360380
Once a model record has been retrieved from the database, it is possible to delete it by using the `#delete` method:

spec/marten/db/model/persistence_spec.cr

Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1534,6 +1534,134 @@ describe Marten::DB::Model::Persistence do
15341534
end
15351535
end
15361536

1537+
describe "#update_columns" do
1538+
it "allows to update an existing object" do
1539+
object = TestUser.create!(username: "jd", email: "jd@example.com", first_name: "John", last_name: "Doe")
1540+
object.update_columns(values: {username: "test1", email: "test1@example.com"}).should be_true
1541+
1542+
object.reload
1543+
object.username.should eq "test1"
1544+
object.email.should eq "test1@example.com"
1545+
end
1546+
1547+
it "allows to update an existing object with keyword arguments" do
1548+
object = TestUser.create!(username: "jd", email: "jd@example.com", first_name: "John", last_name: "Doe")
1549+
object.update_columns(username: "test1", email: "test1@example.com").should be_true
1550+
1551+
object.reload
1552+
object.username.should eq "test1"
1553+
object.email.should eq "test1@example.com"
1554+
end
1555+
1556+
it "allows to update only specific fields without affecting others" do
1557+
object = TestUser.create!(username: "jd", email: "jd@example.com", first_name: "John", last_name: "Doe")
1558+
object.update_columns(username: "updated_username").should be_true
1559+
1560+
object.reload
1561+
object.username.should eq "updated_username"
1562+
object.email.should eq "jd@example.com"
1563+
object.first_name.should eq "John"
1564+
object.last_name.should eq "Doe"
1565+
end
1566+
1567+
it "updates the in-memory values of the object" do
1568+
object = TestUser.create!(username: "jd", email: "jd@example.com", first_name: "John", last_name: "Doe")
1569+
object.update_columns(username: "updated").should be_true
1570+
1571+
object.username.should eq "updated"
1572+
end
1573+
1574+
it "allows to update an existing object with attributes expressed as a hash" do
1575+
object = TestUser.create!(username: "jd", email: "jd@example.com", first_name: "John", last_name: "Doe")
1576+
object.update_columns({"username" => "test1", "email" => "test1@example.com"}).should be_true
1577+
1578+
object.reload
1579+
object.username.should eq "test1"
1580+
object.email.should eq "test1@example.com"
1581+
end
1582+
1583+
it "does not allow to update for a new record" do
1584+
object = TestUser.new(first_name: "John", last_name: "Doe")
1585+
object.update_columns({"username" => "test1", "email" => "test1@example.com"}).should be_false
1586+
end
1587+
1588+
it "does not allow to update for a destroyed record" do
1589+
object = TestUser.create!(username: "jd", email: "jd@example.com", first_name: "John", last_name: "Doe")
1590+
object.delete
1591+
object.update_columns({"username" => "test1", "email" => "test1@example.com"}).should be_false
1592+
end
1593+
end
1594+
1595+
describe "#update_columns!" do
1596+
it "allows to update an existing object" do
1597+
object = TestUser.create!(username: "jd", email: "jd@example.com", first_name: "John", last_name: "Doe")
1598+
object.update_columns!(values: {username: "test1", email: "test1@example.com"}).should be_true
1599+
1600+
object.reload
1601+
object.username.should eq "test1"
1602+
object.email.should eq "test1@example.com"
1603+
end
1604+
1605+
it "allows to update an existing object with keyword arguments" do
1606+
object = TestUser.create!(username: "jd", email: "jd@example.com", first_name: "John", last_name: "Doe")
1607+
object.update_columns!(username: "test1", email: "test1@example.com").should be_true
1608+
1609+
object.reload
1610+
object.username.should eq "test1"
1611+
object.email.should eq "test1@example.com"
1612+
end
1613+
1614+
it "allows to update only specific fields without affecting others" do
1615+
object = TestUser.create!(username: "jd", email: "jd@example.com", first_name: "John", last_name: "Doe")
1616+
object.update_columns!(username: "updated_username").should be_true
1617+
1618+
object.reload
1619+
object.username.should eq "updated_username"
1620+
object.email.should eq "jd@example.com"
1621+
object.first_name.should eq "John"
1622+
object.last_name.should eq "Doe"
1623+
end
1624+
1625+
it "updates the in-memory values of the object" do
1626+
object = TestUser.create!(username: "jd", email: "jd@example.com", first_name: "John", last_name: "Doe")
1627+
object.update_columns!(username: "updated").should be_true
1628+
1629+
object.username.should eq "updated"
1630+
end
1631+
1632+
it "allows to update an existing object with attributes expressed as a hash" do
1633+
object = TestUser.create!(username: "jd", email: "jd@example.com", first_name: "John", last_name: "Doe")
1634+
object.update_columns!({"username" => "test1", "email" => "test1@example.com"}).should be_true
1635+
1636+
object.reload
1637+
object.username.should eq "test1"
1638+
object.email.should eq "test1@example.com"
1639+
end
1640+
1641+
it "does not allow to update for a new record" do
1642+
object = TestUser.new(username: "jd", email: "jd@example.com", first_name: "John", last_name: "Doe")
1643+
1644+
expect_raises(
1645+
Marten::DB::Errors::UnmetSaveCondition,
1646+
"Cannot update columns on a new record"
1647+
) do
1648+
object.update_columns!(username: "updated")
1649+
end
1650+
end
1651+
1652+
it "does not allow to update for a destroyed record" do
1653+
object = TestUser.create!(username: "jd", email: "jd@example.com", first_name: "John", last_name: "Doe")
1654+
object.delete
1655+
1656+
expect_raises(
1657+
Marten::DB::Errors::UnmetSaveCondition,
1658+
"Cannot update columns on a deleted record"
1659+
) do
1660+
object.update_columns!(username: "updated")
1661+
end
1662+
end
1663+
end
1664+
15371665
describe "#delete" do
15381666
it "allows to delete objects" do
15391667
obj_1 = Tag.create!(name: "crystal", is_active: true)

src/marten/db/model/persistence.cr

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -202,6 +202,85 @@ module Marten
202202
save!
203203
end
204204

205+
# Updates specific columns in the database without running validations or callbacks.
206+
#
207+
# This method allows you to update only the specified columns while leaving other fields unchanged.
208+
# Unlike `#update`, this method bypasses model validations and lifecycle callbacks (such as
209+
# `before_update`, `after_update`, etc.), making it more efficient for partial updates where
210+
# validations and callbacks are not required.
211+
#
212+
# Both the in-memory model instance and the database record are updated. However, this method
213+
# does not reload the record after the update, so any changes made to other fields by database
214+
# triggers or defaults will not be reflected in the model instance.
215+
#
216+
# ```
217+
# user = User.get!(id: 42)
218+
# user.update_columns(last_login: Time.utc) # Updates only last_login
219+
# user.update_columns(username: "jd", email: "jd@example.com") # Updates multiple columns
220+
# ```
221+
def update_columns(**values) : Bool
222+
update_columns(values: values)
223+
end
224+
225+
# :ditto:
226+
def update_columns(values : Hash | NamedTuple) : Bool
227+
return false if !persisted?
228+
229+
set_field_values(values)
230+
fields = local_field_db_values
231+
keys = values.keys.map(&.to_s)
232+
fields.select!(keys)
233+
234+
connection = self.class.connection
235+
connection.update(
236+
self.class.db_table,
237+
fields,
238+
pk_column_name: self.class.pk_field.db_column!,
239+
pk_value: self.class.pk_field.to_db(pk)
240+
)
241+
true
242+
end
243+
244+
# Updates specific columns in the database without running validations or callbacks.
245+
#
246+
# This method provides the same functionality as `#update_columns` but with stricter validation.
247+
# It raises a `Marten::DB::Errors::UnmetSaveCondition` exception if called on a new (unsaved) record,
248+
# ensuring that updates are only performed on persisted records.
249+
#
250+
# Like `#update_columns`, this method bypasses model validations and lifecycle callbacks, making it
251+
# suitable for performance-critical updates where these features are not needed.
252+
#
253+
# ```
254+
# user = User.get!(id: 42)
255+
# user.update_columns!(last_login: Time.utc) # Updates only last_login
256+
#
257+
# new_user = User.new(username: "jd")
258+
# new_user.update_columns!(email: "jd@example.com") # Raises UnmetSaveCondition
259+
# ```
260+
def update_columns!(**values) : Bool
261+
update_columns!(values: values)
262+
end
263+
264+
# :ditto:
265+
def update_columns!(values : Hash | NamedTuple) : Bool
266+
raise Errors::UnmetSaveCondition.new("Cannot update columns on a new record") if new_record?
267+
raise Errors::UnmetSaveCondition.new("Cannot update columns on a deleted record") if deleted?
268+
269+
set_field_values(values)
270+
fields = local_field_db_values
271+
keys = values.keys.map(&.to_s)
272+
fields.select!(keys)
273+
274+
connection = self.class.connection
275+
connection.update(
276+
self.class.db_table,
277+
fields,
278+
pk_column_name: self.class.pk_field.db_column!,
279+
pk_value: self.class.pk_field.to_db(pk)
280+
)
281+
true
282+
end
283+
205284
protected setter new_record
206285

207286
protected def prepare_fields_for_save : Nil

0 commit comments

Comments
 (0)