Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions docs/docs/models-and-databases/callbacks.md
Original file line number Diff line number Diff line change
Expand Up @@ -110,3 +110,11 @@ after_rollback :do_something_else, on: [:create, :delete] # Will run after rolle
```

The actions supported by the `on` argument are `create`, `update`, `save`, and `delete`.

## Methods that bypass callbacks

Some model methods intentionally bypass callbacks for performance or specific use cases. The following methods do **not** trigger callbacks:

* `#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.

If you need to update records while ensuring that callbacks are executed, use the standard `#save`, `#save!`, `#update`, or `#update!` methods instead.
20 changes: 20 additions & 0 deletions docs/docs/models-and-databases/introduction.md
Original file line number Diff line number Diff line change
Expand Up @@ -355,6 +355,26 @@ Marten also provide the ability to update the records that are targeted by a spe
Article.filter(title: "My article").update(title: "Updated!")
```

#### Updating specific columns

If you need to update only specific columns without running validations or callbacks, you can use the `#update_columns` or `#update_columns!` methods:

```crystal
article = Article.get(id: 42)
article.update_columns(title: "Updated!")
```

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:

```crystal
article = Article.new
article.update_columns!(title: "New article") # Raises Marten::DB::Errors::UnmetSaveCondition
```

:::caution
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.
:::

### Delete

Once a model record has been retrieved from the database, it is possible to delete it by using the `#delete` method:
Expand Down
128 changes: 128 additions & 0 deletions spec/marten/db/model/persistence_spec.cr
Original file line number Diff line number Diff line change
Expand Up @@ -1534,6 +1534,134 @@ describe Marten::DB::Model::Persistence do
end
end

describe "#update_columns" do
it "allows to update an existing object" do
object = TestUser.create!(username: "jd", email: "jd@example.com", first_name: "John", last_name: "Doe")
object.update_columns(values: {username: "test1", email: "test1@example.com"}).should be_true

object.reload
object.username.should eq "test1"
object.email.should eq "test1@example.com"
end

it "allows to update an existing object with keyword arguments" do
object = TestUser.create!(username: "jd", email: "jd@example.com", first_name: "John", last_name: "Doe")
object.update_columns(username: "test1", email: "test1@example.com").should be_true

object.reload
object.username.should eq "test1"
object.email.should eq "test1@example.com"
end

it "allows to update only specific fields without affecting others" do
object = TestUser.create!(username: "jd", email: "jd@example.com", first_name: "John", last_name: "Doe")
object.update_columns(username: "updated_username").should be_true

object.reload
object.username.should eq "updated_username"
object.email.should eq "jd@example.com"
object.first_name.should eq "John"
object.last_name.should eq "Doe"
end

it "updates the in-memory values of the object" do
object = TestUser.create!(username: "jd", email: "jd@example.com", first_name: "John", last_name: "Doe")
object.update_columns(username: "updated").should be_true

object.username.should eq "updated"
end

it "allows to update an existing object with attributes expressed as a hash" do
object = TestUser.create!(username: "jd", email: "jd@example.com", first_name: "John", last_name: "Doe")
object.update_columns({"username" => "test1", "email" => "test1@example.com"}).should be_true

object.reload
object.username.should eq "test1"
object.email.should eq "test1@example.com"
end

it "does not allow to update for a new record" do
object = TestUser.new(first_name: "John", last_name: "Doe")
object.update_columns({"username" => "test1", "email" => "test1@example.com"}).should be_false
end

it "does not allow to update for a destroyed record" do
object = TestUser.create!(username: "jd", email: "jd@example.com", first_name: "John", last_name: "Doe")
object.delete
object.update_columns({"username" => "test1", "email" => "test1@example.com"}).should be_false
end
end

describe "#update_columns!" do
it "allows to update an existing object" do
object = TestUser.create!(username: "jd", email: "jd@example.com", first_name: "John", last_name: "Doe")
object.update_columns!(values: {username: "test1", email: "test1@example.com"}).should be_true

object.reload
object.username.should eq "test1"
object.email.should eq "test1@example.com"
end

it "allows to update an existing object with keyword arguments" do
object = TestUser.create!(username: "jd", email: "jd@example.com", first_name: "John", last_name: "Doe")
object.update_columns!(username: "test1", email: "test1@example.com").should be_true

object.reload
object.username.should eq "test1"
object.email.should eq "test1@example.com"
end

it "allows to update only specific fields without affecting others" do
object = TestUser.create!(username: "jd", email: "jd@example.com", first_name: "John", last_name: "Doe")
object.update_columns!(username: "updated_username").should be_true

object.reload
object.username.should eq "updated_username"
object.email.should eq "jd@example.com"
object.first_name.should eq "John"
object.last_name.should eq "Doe"
end

it "updates the in-memory values of the object" do
object = TestUser.create!(username: "jd", email: "jd@example.com", first_name: "John", last_name: "Doe")
object.update_columns!(username: "updated").should be_true

object.username.should eq "updated"
end

it "allows to update an existing object with attributes expressed as a hash" do
object = TestUser.create!(username: "jd", email: "jd@example.com", first_name: "John", last_name: "Doe")
object.update_columns!({"username" => "test1", "email" => "test1@example.com"}).should be_true

object.reload
object.username.should eq "test1"
object.email.should eq "test1@example.com"
end

it "does not allow to update for a new record" do
object = TestUser.new(username: "jd", email: "jd@example.com", first_name: "John", last_name: "Doe")

expect_raises(
Marten::DB::Errors::UnmetSaveCondition,
"Cannot update columns on a new record"
) do
object.update_columns!(username: "updated")
end
end

it "does not allow to update for a destroyed record" do
object = TestUser.create!(username: "jd", email: "jd@example.com", first_name: "John", last_name: "Doe")
object.delete

expect_raises(
Marten::DB::Errors::UnmetSaveCondition,
"Cannot update columns on a deleted record"
) do
object.update_columns!(username: "updated")
end
end
end

describe "#delete" do
it "allows to delete objects" do
obj_1 = Tag.create!(name: "crystal", is_active: true)
Expand Down
79 changes: 79 additions & 0 deletions src/marten/db/model/persistence.cr
Original file line number Diff line number Diff line change
Expand Up @@ -202,6 +202,85 @@ module Marten
save!
end

# Updates specific columns in the database without running validations or callbacks.
#
# This method allows you to update only the specified columns while leaving other fields unchanged.
# Unlike `#update`, this method bypasses model validations and lifecycle callbacks (such as
# `before_update`, `after_update`, etc.), making it more efficient for partial updates where
# validations and callbacks are not required.
#
# Both the in-memory model instance and the database record are updated. However, this method
# does not reload the record after the update, so any changes made to other fields by database
# triggers or defaults will not be reflected in the model instance.
#
# ```
# user = User.get!(id: 42)
# user.update_columns(last_login: Time.utc) # Updates only last_login
# user.update_columns(username: "jd", email: "jd@example.com") # Updates multiple columns
# ```
def update_columns(**values) : Bool
update_columns(values: values)
end

# :ditto:
def update_columns(values : Hash | NamedTuple) : Bool
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Shouldn't this method return false early if called on a new record?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

fixed in e3ca6a9

return false if !persisted?

set_field_values(values)
fields = local_field_db_values
keys = values.keys.map(&.to_s)
fields.select!(keys)

connection = self.class.connection
connection.update(
self.class.db_table,
fields,
pk_column_name: self.class.pk_field.db_column!,
pk_value: self.class.pk_field.to_db(pk)
)
true
end

# Updates specific columns in the database without running validations or callbacks.
#
# This method provides the same functionality as `#update_columns` but with stricter validation.
# It raises a `Marten::DB::Errors::UnmetSaveCondition` exception if called on a new (unsaved) record,
# ensuring that updates are only performed on persisted records.
#
# Like `#update_columns`, this method bypasses model validations and lifecycle callbacks, making it
# suitable for performance-critical updates where these features are not needed.
#
# ```
# user = User.get!(id: 42)
# user.update_columns!(last_login: Time.utc) # Updates only last_login
#
# new_user = User.new(username: "jd")
# new_user.update_columns!(email: "jd@example.com") # Raises UnmetSaveCondition
# ```
def update_columns!(**values) : Bool
update_columns!(values: values)
end

# :ditto:
def update_columns!(values : Hash | NamedTuple) : Bool
raise Errors::UnmetSaveCondition.new("Cannot update columns on a new record") if new_record?
raise Errors::UnmetSaveCondition.new("Cannot update columns on a deleted record") if deleted?

set_field_values(values)
fields = local_field_db_values
keys = values.keys.map(&.to_s)
fields.select!(keys)

connection = self.class.connection
connection.update(
self.class.db_table,
fields,
pk_column_name: self.class.pk_field.db_column!,
pk_value: self.class.pk_field.to_db(pk)
)
true
end

protected setter new_record

protected def prepare_fields_for_save : Nil
Expand Down
Loading