diff --git a/docs/docs/models-and-databases/callbacks.md b/docs/docs/models-and-databases/callbacks.md index 690a6be1..3f6db936 100644 --- a/docs/docs/models-and-databases/callbacks.md +++ b/docs/docs/models-and-databases/callbacks.md @@ -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. diff --git a/docs/docs/models-and-databases/introduction.md b/docs/docs/models-and-databases/introduction.md index 29435546..af23d210 100644 --- a/docs/docs/models-and-databases/introduction.md +++ b/docs/docs/models-and-databases/introduction.md @@ -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: diff --git a/spec/marten/db/model/persistence_spec.cr b/spec/marten/db/model/persistence_spec.cr index b3845cc3..36fe4407 100644 --- a/spec/marten/db/model/persistence_spec.cr +++ b/spec/marten/db/model/persistence_spec.cr @@ -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) diff --git a/src/marten/db/model/persistence.cr b/src/marten/db/model/persistence.cr index e05f0e53..d68868a1 100644 --- a/src/marten/db/model/persistence.cr +++ b/src/marten/db/model/persistence.cr @@ -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 + 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