Skip to content
Open
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
30 changes: 30 additions & 0 deletions docs/docs/models-and-databases/reference/query-set.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
68 changes: 68 additions & 0 deletions spec/marten/db/model/querying_spec.cr
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
109 changes: 109 additions & 0 deletions spec/marten/db/query/set_spec.cr
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
34 changes: 34 additions & 0 deletions src/marten/db/model/querying.cr
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
40 changes: 40 additions & 0 deletions src/marten/db/query/set.cr
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Copy link
Member

Choose a reason for hiding this comment

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

I guess it could be interesting to have an #update_or_create! variant of the method where we call #save! so that an exception is raised if the created/updated record is invalid.

record
rescue Errors::RecordNotFound
update_attributes = defaults.nil? ? updates : defaults.not_nil!
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 we be using both the updates AND defaults values here if defaults is set? Technically, the method should create new records with defaults if they do not exist yet, but all the records should end up having the updates values after the method is called (regardless of whether they got created/updated).

Suggested change
update_attributes = defaults.nil? ? updates : defaults.not_nil!
update_attributes = defaults.nil? ? updates : defaults.not_nil!.merge(updates)

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
Expand Down
Loading