diff --git a/docs/docs/models-and-databases/reference/query-set.md b/docs/docs/models-and-databases/reference/query-set.md index 7e363ed8..4020eff2 100644 --- a/docs/docs/models-and-databases/reference/query-set.md +++ b/docs/docs/models-and-databases/reference/query-set.md @@ -846,6 +846,36 @@ Tag.filter(name__startswith: "r").to_sql The outputted SQL will vary depending on the database backend in use. ::: +### `update_or_create` + +Updates the model record matching the given set of filters, or creates a new one if no one is found. + +Model fields that uniquely identify a record should be used here. This method first attempts to retrieve a record that +matches the specified filters. If it exists, the record is updated using the attributes provided in the required +`updates` argument: + +```crystal +user = User.all.update_or_create( + updates: {first_name: "Jane"}, + username: "abc" +) +``` + +If no matching record is found, a new one is created using the attributes defined in `updates`. If additional attributes +should only be used when creating new records, a `defaults` argument can be provided (these attributes will then be used +instead of `updates` when creating the record): + +```crystal +user = User.all.update_or_create( + updates: {first_name: "Jane"}, + defaults: {first_name: "Jane", is_admin: true}, + username: "abc" +) +``` + +In order to ensure data consistency, this method will raise a +`Marten::DB::Errors::MultipleRecordsFound` exception if multiple records match the specified set of filters. + ### `update` Updates all the records matched by the current query set with the passed values. diff --git a/spec/marten/db/model/querying_spec.cr b/spec/marten/db/model/querying_spec.cr index ed4758d6..37fe6011 100644 --- a/spec/marten/db/model/querying_spec.cr +++ b/spec/marten/db/model/querying_spec.cr @@ -1713,6 +1713,74 @@ describe Marten::DB::Model::Querying do end end + describe "::update_or_create" do + with_installed_apps Marten::DB::Model::QueryingSpec::App + + it "updates the record matched by the specified arguments" do + tag = Marten::DB::Model::QueryingSpec::Tag.create!(name: "crystal", is_active: true) + + updated_tag = Marten::DB::Model::QueryingSpec::Tag.update_or_create( + updates: {is_active: false}, + defaults: {name: "crystal", is_active: true}, + name: "crystal" + ) + + updated_tag.should eq tag + + tag.reload + tag.is_active.should be_false + end + + it "creates a record using the specified updates if no record is found" do + Marten::DB::Model::QueryingSpec::Tag.create!(name: "crystal", is_active: true) + + new_tag = Marten::DB::Model::QueryingSpec::Tag.update_or_create( + updates: {name: "newtag", is_active: true}, + name: "newtag" + ) + new_tag.persisted?.should be_true + new_tag.name.should eq "newtag" + new_tag.is_active.should be_true + + Marten::DB::Model::QueryingSpec::Tag.all.size.should eq 2 + end + + it "uses defaults when creating a new record if they are provided" do + new_tag = Marten::DB::Model::QueryingSpec::Tag.update_or_create( + updates: {name: "unused", is_active: true}, + defaults: {name: "newtag", is_active: false}, + name: "newtag" + ) + + new_tag.persisted?.should be_true + new_tag.is_active.should be_false + new_tag.name.should eq "newtag" + end + + it "does not use lookup filters when creating a new record" do + new_tag = Marten::DB::Model::QueryingSpec::Tag.update_or_create( + updates: {name: "filtered"}, + is_active: false + ) + + new_tag.persisted?.should be_true + new_tag.name.should eq "filtered" + new_tag.is_active.should be_true + end + + it "raises MultipleRecordsFound if the filters match multiple records" do + Marten::DB::Model::QueryingSpec::Tag.create!(name: "crystal", is_active: true) + Marten::DB::Model::QueryingSpec::Tag.create!(name: "crystal", is_active: false) + + expect_raises(Marten::DB::Errors::MultipleRecordsFound) do + Marten::DB::Model::QueryingSpec::Tag.update_or_create( + updates: {is_active: true}, + name: "crystal" + ) + end + end + end + describe "::using" do before_each do TestUser.using(:other).create!(username: "jd1", email: "jd1@example.com", first_name: "John", last_name: "Doe") diff --git a/spec/marten/db/query/set_spec.cr b/spec/marten/db/query/set_spec.cr index f85fe083..04f63cf0 100644 --- a/spec/marten/db/query/set_spec.cr +++ b/spec/marten/db/query/set_spec.cr @@ -3718,6 +3718,115 @@ describe Marten::DB::Query::Set do end end + describe "#update_or_create" do + it "updates the record matched by the specified arguments" do + user = TestUser.create!( + username: "abc", + email: "abc@example.com", + first_name: "John", + last_name: "Doe" + ) + + qset = Marten::DB::Query::Set(TestUser).new + + updated_user = qset.update_or_create( + updates: {first_name: "Jane"}, + defaults: {first_name: "Jack", is_admin: true}, + username: "abc" + ) + + updated_user.should eq user + + user.reload + user.first_name.should eq "Jane" + user.is_admin.should be_falsey + end + + it "creates a record using the specified updates if no record is found" do + qset = Marten::DB::Query::Set(TestUser).new + + new_user = qset.update_or_create( + updates: { + username: "newuser", + email: "newuser@example.com", + first_name: "John", + last_name: "Doe", + }, + username: "newuser", + email: "newuser@example.com" + ) + + new_user.persisted?.should be_true + new_user.username.should eq "newuser" + new_user.first_name.should eq "John" + new_user.last_name.should eq "Doe" + end + + it "uses defaults when creating a new record if they are provided" do + qset = Marten::DB::Query::Set(TestUser).new + + new_user = qset.update_or_create( + updates: {first_name: "John", last_name: "Doe"}, + defaults: { + username: "default-user", + email: "default@example.com", + first_name: "Johnny", + last_name: "Doe", + is_admin: true, + }, + username: "default-user", + email: "default@example.com" + ) + + new_user.persisted?.should be_true + new_user.first_name.should eq "Johnny" + new_user.is_admin.should be_true + end + + it "does not use lookup filters when creating a new record" do + qset = Marten::DB::Query::Set(TestUser).new + + new_user = qset.update_or_create( + updates: { + username: "filter-user", + email: "filter@example.com", + first_name: "John", + last_name: "Doe", + }, + username: "filter-user", + is_admin: true + ) + + new_user.persisted?.should be_true + new_user.username.should eq "filter-user" + new_user.is_admin.should be_falsey + end + + it "raises MultipleRecordsFound if the filters match multiple records" do + TestUser.create!( + username: "abc", + email: "abc@example.com", + first_name: "John", + last_name: "Doe" + ) + TestUser.create!( + username: "def", + email: "def@example.com", + first_name: "John", + last_name: "Smith" + ) + + qset = Marten::DB::Query::Set(TestUser).new + + expect_raises(Marten::DB::Errors::MultipleRecordsFound) do + qset.update_or_create( + updates: {first_name: "Jane"}, + first_name: "John" + ) + end + end + end + describe "#using" do it "allows to switch to another DB connection expressed as a symbol" do tag_1 = Tag.create!(name: "ruby", is_active: true) diff --git a/src/marten/db/model/querying.cr b/src/marten/db/model/querying.cr index f7b5ddd6..72505003 100644 --- a/src/marten/db/model/querying.cr +++ b/src/marten/db/model/querying.cr @@ -971,6 +971,40 @@ module Marten default_queryset.update(kwargs.to_h) end + # Updates a model record matching the given filters or creates a new one if no one is found. + # + # This method first attempts to retrieve a record that matches the specified filters. If it exists, + # the record is updated using the attributes provided via the required `updates` argument. + # If no matching record is found, a new one is created using the attributes defined in `updates`: + # + # ``` + # person = Person.update_or_create(updates: {first_name: "Bob"}, first_name: "John", last_name: "Doe") + # ``` + # + # If additional attributes should only be used when creating new records, a `defaults` argument can be + # provided (these attributes will then be used instead of `updates` when creating the record). + # + # ``` + # person = Person.update_or_create( + # updates: {first_name: "Bob"}, + # defaults: {first_name: "Bob", active: true}, + # first_name: "John", + # last_name: "Doe" + # ) + # ``` + # + # In order to ensure data consistency, this method will raise a `Marten::DB::Errors::MultipleRecordsFound` + # exception if multiple records match the specified set of filters. + def update_or_create( + *, + updates : Hash | NamedTuple, + defaults : Hash | NamedTuple | Nil = nil, + **kwargs, + ) + arguments = kwargs.merge({updates: updates, defaults: defaults}) + default_queryset.update_or_create(**arguments) + end + # Returns a queryset that will be evaluated using the specified database. # # A valid database alias must be used here (it must correspond to an ID of a database configured in the diff --git a/src/marten/db/query/set.cr b/src/marten/db/query/set.cr index 5149bb89..c0ed5d3b 100644 --- a/src/marten/db/query/set.cr +++ b/src/marten/db/query/set.cr @@ -1577,6 +1577,46 @@ module Marten update(kwargs.to_h) end + # Updates a model record matching the given filters or creates a new one if no one is found. + # + # This method first attempts to retrieve a record that matches the specified filters. If it exists, the record + # is updated using the attributes provided via the required `updates` argument. If no matching record is found, + # a new one is created using the `updates` attributes: + # + # ``` + # person = Person.all.update_or_create(updates: {first_name: "Bob"}, first_name: "John", last_name: "Doe") + # ``` + # + # If additional attributes should only be used when creating new records, a `defaults` argument can be provided: + # + # ``` + # person = Person.all.update_or_create( + # updates: {first_name: "Bob"}, + # defaults: {first_name: "Bob", active: true}, + # first_name: "John", + # last_name: "Doe" + # ) + # ``` + # + # In order to ensure data consistency, this method will raise a `Marten::DB::Errors::MultipleRecordsFound` + # exception if multiple records match the specified set of filters. + def update_or_create( + *, + updates : Hash | NamedTuple, + defaults : Hash | NamedTuple | Nil = nil, + **kwargs, + ) + record = get!(Node.new(**kwargs)) + record.set_field_values(updates) + record.save(using: @query.using) + record + rescue Errors::RecordNotFound + update_attributes = defaults.nil? ? updates : defaults.not_nil! + create do |new_record| + new_record.set_field_values(update_attributes) + end + end + # Allows to define which database alias should be used when evaluating the query set. def using(db : Nil | String | Symbol) qs = clone