Skip to content

Commit

Permalink
Upsert with upsert_lookup_columns
Browse files Browse the repository at this point in the history
Closes #298
  • Loading branch information
paulcsmith committed Jul 13, 2021
1 parent 98ed8b7 commit aa8429f
Show file tree
Hide file tree
Showing 5 changed files with 221 additions and 11 deletions.
2 changes: 1 addition & 1 deletion Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ RUN apt-get update && \
rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/*

# Lucky cli
RUN git clone https://github.com/luckyframework/lucky_cli --branch v0.25.0 --depth 1 /usr/local/lucky_cli && \
RUN git clone https://github.com/luckyframework/lucky_cli --branch v0.27.0 --depth 1 /usr/local/lucky_cli && \
cd /usr/local/lucky_cli && \
shards install && \
crystal build src/lucky.cr -o /usr/local/bin/lucky
Expand Down
105 changes: 105 additions & 0 deletions spec/operations/save_operation_spec.cr
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,10 @@ private class ParamKeySaveOperation < ValueColumnModel::SaveOperation
param_key :custom_param
end

private class UpsertUserOperation < User::SaveOperation
upsert_lookup_columns :name, :nickname
end

private class OverrideDefaults < ModelWithDefaultValues::SaveOperation
permit_columns :greeting, :drafted_at, :published_at, :admin, :age, :money
end
Expand Down Expand Up @@ -157,6 +161,107 @@ describe "Avram::SaveOperation" do
operation.changes[:name].should be_nil
end

describe "upsert upsert_lookup_columns" do
describe ".upsert" do
it "updates the existing record if one exists" do
existing_user = UserFactory.create &.name("Rich").nickname(nil).age(20)
joined_at = Time.utc.at_beginning_of_second

UpsertUserOperation.upsert(
name: "Rich",
nickname: nil,
age: 30,
joined_at: joined_at
) do |_operation, user|
UserQuery.new.select_count.should eq(1)
user = user.not_nil!
user.id.should eq(existing_user.id)
user.name.should eq("Rich")
user.nickname.should be_nil
user.age.should eq(30)
user.joined_at.should eq(joined_at)
end
end

it "creates a new record if match one doesn't exist" do
user_with_different_nickname =
UserFactory.create &.name("Rich").nickname(nil).age(20)
joined_at = Time.utc.at_beginning_of_second

UpsertUserOperation.upsert(
name: "Rich",
nickname: "R.",
age: 30,
joined_at: joined_at
) do |_operation, user|
UserQuery.new.select_count.should eq(2)
# Keep existing user the same
user_with_different_nickname.age.should eq(20)
user_with_different_nickname.nickname.should eq(nil)

user = user.not_nil!
user.id.should_not eq(user_with_different_nickname.id)
user.name.should eq("Rich")
user.nickname.should eq("R.")
user.age.should eq(30)
user.joined_at.should eq(joined_at)
end
end
end

describe ".upsert!" do
it "updates the existing record if one exists" do
existing_user = UserFactory.create &.name("Rich").nickname(nil).age(20)
joined_at = Time.utc.at_beginning_of_second

user = UpsertUserOperation.upsert!(
name: "Rich",
nickname: nil,
age: 30,
joined_at: joined_at
)

UserQuery.new.select_count.should eq(1)
user = user.not_nil!
user.id.should eq(existing_user.id)
user.name.should eq("Rich")
user.nickname.should be_nil
user.age.should eq(30)
user.joined_at.should eq(joined_at)
end

it "creates a new record if one doesn't exist" do
user_with_different_nickname = UserFactory.create &.name("Rich").nickname(nil).age(20)
joined_at = Time.utc.at_beginning_of_second

user = UpsertUserOperation.upsert!(
name: "Rich",
nickname: "R.",
age: 30,
joined_at: joined_at
)

UserQuery.new.select_count.should eq(2)
# Keep existing user the same
user_with_different_nickname.age.should eq(20)
user_with_different_nickname.nickname.should eq(nil)

user = user.not_nil!
user.id.should_not eq(user_with_different_nickname.id)
user.name.should eq("Rich")
user.nickname.should eq("R.")
user.age.should eq(30)
user.joined_at.should eq(joined_at)
end

it "raises if the record is invalid" do
expect_raises(Avram::InvalidOperationError) do
UpsertUserOperation.upsert!(name: "")
end
end
end
end

describe "#errors" do
it "includes errors for all operation attributes" do
operation = SaveUser.new
Expand Down
9 changes: 2 additions & 7 deletions src/avram/needy_initializer_and_save_methods.cr
Original file line number Diff line number Diff line change
Expand Up @@ -125,7 +125,6 @@ module Avram::NeedyInitializerAndSaveMethods
if operation.save
yield operation, operation.record
else
operation.published_save_failed_event
yield operation, nil
end
{% end %}
Expand Down Expand Up @@ -165,12 +164,8 @@ module Avram::NeedyInitializerAndSaveMethods
{% if with_bang %}
operation.update!
{% else %}
if operation.save
yield operation, operation.record.not_nil!
else
operation.published_save_failed_event
yield operation, operation.record.not_nil!
end
operation.save
yield operation, operation.record.not_nil!
{% end %}
end
end
Expand Down
12 changes: 9 additions & 3 deletions src/avram/save_operation.cr
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ require "./param_key_override"
require "./inherit_column_attributes"
require "./validations"
require "./operation_errors"
require "./upsert"

abstract class Avram::SaveOperation(T)
include Avram::DefineAttribute
Expand All @@ -20,6 +21,7 @@ abstract class Avram::SaveOperation(T)
include Avram::NestedSaveOperation
include Avram::MarkAsFailed
include Avram::InheritColumnAttributes
include Avram::Upsert

enum SaveStatus
Saved
Expand All @@ -33,7 +35,10 @@ abstract class Avram::SaveOperation(T)

@record : T?
@params : Avram::Paramable
getter :record, :params

# :nodoc:
setter :record
getter :params, :record
property save_status : SaveStatus = SaveStatus::Unperformed

abstract def attributes
Expand All @@ -51,8 +56,7 @@ abstract class Avram::SaveOperation(T)

delegate :database, :table_name, :primary_key_name, to: T

# :nodoc:
def published_save_failed_event
private def publish_save_failed_event
Avram::Events::SaveFailedEvent.publish(
operation_class: self.class.name,
attributes: generic_attributes
Expand Down Expand Up @@ -309,10 +313,12 @@ abstract class Avram::SaveOperation(T)
true
else
mark_as_failed
publish_save_failed_event
false
end
else
mark_as_failed
publish_save_failed_event
false
end
end
Expand Down
104 changes: 104 additions & 0 deletions src/avram/upsert.cr
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
# Adds the ability to "create or update" (upsert) to `Avram::SaveOperation`
#
# This is included in SaveOperations by default. See `upsert_lookup_columns` for usage details.
module Avram::Upsert
# Defines the columns Avram should use when performing an `upsert`
#
# An "upsert" is short for "update or insert", or in Avram terminology a
# "create or update". If the values in an operation conflict with an existing
# record in the database, Avram updates that record. If there is no
# conflicting record, then Avram will create new one.
#
# In Avram, you must define which columns Avram should look at when
# determining if a conflicting record exists. This is done using the macro
# `Avram::Upsert.upsert_lookup_columns`
#
# **Note:** In almost _every_ case the `upsert_lookup_columns` should have a **unique index** defined
# in the database to ensure no conflicting records are created, even from outside Avram.
#
# ## Full Example
#
# ```
# class User < BaseModel
# table do
# column name : String
# column email : String # This column has a unique index
# end
# end
#
# class SaveUser < User::SaveOperation
# # Can be one or more columns. In this case we choose just :email
# upsert_lookup_columns :email
# end
#
# # Will create a new row in the database since no row with
# # `email: "[email protected]"` exists yet
# SaveUser.upsert!(name: "Bobby", email: "[email protected]")
#
# # Will update the name on the row we just created since the email is
# # the same as one in the database
# SaveUser.upsert!(name: "Bob", email: "[email protected]")
# ```
#
# ## Difference between `upsert` and `upsert!`
#
# There is an `upsert` and `upsert!` that work similarly to `create` and `create!`.
# `upsert!` will raise an error if the operation is invalid. Whereas `upsert`
# will yield the operation and the new record if the operation is valid, or
# the operation and `nil` if it is invalid.
#
# ```
# # Will raise because the name is blank
# SaveUser.upsert!(name: "", email: "[email protected]")
#
# # Operation is invalid because name is blank
# SaveUser.upsert(name: "", email: "[email protected]") do |operation, user|
# # `user` is `nil` because the operation is invalid.
# # If the `name` was valid `user` would be the newly created user
# end
# ````
macro upsert_lookup_columns(*attribute_names)
def self.upsert!(*args, **named_args) : T
operation = new(*args, **named_args)
existing_record = find_existing_unique_record(operation)

if existing_record
operation.record = existing_record
end

operation.save!
end

def self.upsert(*args, **named_args)
operation = new(*args, **named_args)
existing_record = find_existing_unique_record(operation)

if existing_record
operation.record = existing_record
end

operation.save
yield operation, operation.record
end

def self.find_existing_unique_record(operation) : T?
T::BaseQuery.new
{% for attribute in attribute_names %}
.{{ attribute.id }}.nilable_eq(operation.{{ attribute.id }}.value)
{% end %}
.first?
end
end

# :nodoc:
macro included
{% for method in ["upsert", "upsert!"] %}
# Performs a create or update depending on if there is a conflicting row in the database.
#
# See `Avram::Upsert.upsert_lookup_columns` for full documentation and examples.
def self.{{ method.id }}(*args, **named_args)
\{% raise "Please use the 'upsert_lookup_columns' macro in #{@type} before using '{{ method.id }}'" %}
end
{% end %}
end
end

0 comments on commit aa8429f

Please sign in to comment.