Skip to content

Commit cb6cfd2

Browse files
committed
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)
1 parent 7cef445 commit cb6cfd2

File tree

4 files changed

+209
-0
lines changed

4 files changed

+209
-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: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1534,6 +1534,111 @@ 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+
end
1583+
1584+
describe "#update_columns!" do
1585+
it "allows to update an existing object" do
1586+
object = TestUser.create!(username: "jd", email: "jd@example.com", first_name: "John", last_name: "Doe")
1587+
object.update_columns!(values: {username: "test1", email: "test1@example.com"}).should be_true
1588+
1589+
object.reload
1590+
object.username.should eq "test1"
1591+
object.email.should eq "test1@example.com"
1592+
end
1593+
1594+
it "allows to update an existing object with keyword arguments" do
1595+
object = TestUser.create!(username: "jd", email: "jd@example.com", first_name: "John", last_name: "Doe")
1596+
object.update_columns!(username: "test1", email: "test1@example.com").should be_true
1597+
1598+
object.reload
1599+
object.username.should eq "test1"
1600+
object.email.should eq "test1@example.com"
1601+
end
1602+
1603+
it "allows to update only specific fields without affecting others" do
1604+
object = TestUser.create!(username: "jd", email: "jd@example.com", first_name: "John", last_name: "Doe")
1605+
object.update_columns!(username: "updated_username").should be_true
1606+
1607+
object.reload
1608+
object.username.should eq "updated_username"
1609+
object.email.should eq "jd@example.com"
1610+
object.first_name.should eq "John"
1611+
object.last_name.should eq "Doe"
1612+
end
1613+
1614+
it "updates the in-memory values of the object" do
1615+
object = TestUser.create!(username: "jd", email: "jd@example.com", first_name: "John", last_name: "Doe")
1616+
object.update_columns!(username: "updated").should be_true
1617+
1618+
object.username.should eq "updated"
1619+
end
1620+
1621+
it "allows to update an existing object with attributes expressed as a hash" do
1622+
object = TestUser.create!(username: "jd", email: "jd@example.com", first_name: "John", last_name: "Doe")
1623+
object.update_columns!({"username" => "test1", "email" => "test1@example.com"}).should be_true
1624+
1625+
object.reload
1626+
object.username.should eq "test1"
1627+
object.email.should eq "test1@example.com"
1628+
end
1629+
1630+
it "raises an error when trying to update a new record" do
1631+
object = TestUser.new(username: "jd", email: "jd@example.com", first_name: "John", last_name: "Doe")
1632+
1633+
expect_raises(
1634+
Marten::DB::Errors::UnmetSaveCondition,
1635+
"Cannot update columns on a new record"
1636+
) do
1637+
object.update_columns!(username: "updated")
1638+
end
1639+
end
1640+
end
1641+
15371642
describe "#delete" do
15381643
it "allows to delete objects" do
15391644
obj_1 = Tag.create!(name: "crystal", is_active: true)

src/marten/db/model/persistence.cr

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -202,6 +202,82 @@ 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+
set_field_values(values)
228+
fields = local_field_db_values
229+
keys = values.keys.map(&.to_s)
230+
fields.select!(keys)
231+
232+
connection = self.class.connection
233+
connection.update(
234+
self.class.db_table,
235+
fields,
236+
pk_column_name: self.class.pk_field.db_column!,
237+
pk_value: self.class.pk_field.to_db(pk)
238+
)
239+
true
240+
end
241+
242+
# Updates specific columns in the database without running validations or callbacks.
243+
#
244+
# This method provides the same functionality as `#update_columns` but with stricter validation.
245+
# It raises a `Marten::DB::Errors::UnmetSaveCondition` exception if called on a new (unsaved) record,
246+
# ensuring that updates are only performed on persisted records.
247+
#
248+
# Like `#update_columns`, this method bypasses model validations and lifecycle callbacks, making it
249+
# suitable for performance-critical updates where these features are not needed.
250+
#
251+
# ```
252+
# user = User.get!(id: 42)
253+
# user.update_columns!(last_login: Time.utc) # Updates only last_login
254+
#
255+
# new_user = User.new(username: "jd")
256+
# new_user.update_columns!(email: "jd@example.com") # Raises UnmetSaveCondition
257+
# ```
258+
def update_columns!(**values) : Bool
259+
update_columns!(values: values)
260+
end
261+
262+
# :ditto:
263+
def update_columns!(values : Hash | NamedTuple) : Bool
264+
raise Errors::UnmetSaveCondition.new("Cannot update columns on a new record") if new_record?
265+
266+
set_field_values(values)
267+
fields = local_field_db_values
268+
keys = values.keys.map(&.to_s)
269+
fields.select!(keys)
270+
271+
connection = self.class.connection
272+
connection.update(
273+
self.class.db_table,
274+
fields,
275+
pk_column_name: self.class.pk_field.db_column!,
276+
pk_value: self.class.pk_field.to_db(pk)
277+
)
278+
true
279+
end
280+
205281
protected setter new_record
206282

207283
protected def prepare_fields_for_save : Nil

0 commit comments

Comments
 (0)