diff --git a/Dockerfile b/Dockerfile index 4ab9c1331..79d90d53f 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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 diff --git a/spec/operations/save_operation_spec.cr b/spec/operations/save_operation_spec.cr index 674ff3930..acacd0da1 100644 --- a/spec/operations/save_operation_spec.cr +++ b/spec/operations/save_operation_spec.cr @@ -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 @@ -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 diff --git a/src/avram/needy_initializer_and_save_methods.cr b/src/avram/needy_initializer_and_save_methods.cr index afc73a006..06d674762 100644 --- a/src/avram/needy_initializer_and_save_methods.cr +++ b/src/avram/needy_initializer_and_save_methods.cr @@ -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 %} @@ -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 diff --git a/src/avram/save_operation.cr b/src/avram/save_operation.cr index 90556810b..40b550f50 100644 --- a/src/avram/save_operation.cr +++ b/src/avram/save_operation.cr @@ -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 @@ -20,6 +21,7 @@ abstract class Avram::SaveOperation(T) include Avram::NestedSaveOperation include Avram::MarkAsFailed include Avram::InheritColumnAttributes + include Avram::Upsert enum SaveStatus Saved @@ -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 @@ -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 @@ -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 diff --git a/src/avram/upsert.cr b/src/avram/upsert.cr new file mode 100644 index 000000000..9d1ae5815 --- /dev/null +++ b/src/avram/upsert.cr @@ -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: "bob@example.com"` exists yet + # SaveUser.upsert!(name: "Bobby", email: "bob@example.com") + # + # # 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: "bob@example.com") + # ``` + # + # ## 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: "bob@example.com") + # + # # Operation is invalid because name is blank + # SaveUser.upsert(name: "", email: "bob@example.com") 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