diff --git a/db/migrations/20260427115611_make_tokens_next_id_unique.cr b/db/migrations/20260427115611_make_tokens_next_id_unique.cr new file mode 100644 index 000000000..9331faa64 --- /dev/null +++ b/db/migrations/20260427115611_make_tokens_next_id_unique.cr @@ -0,0 +1,9 @@ +class MakeTokensNextIdUnique::V20260427115611 < Avram::Migrator::Migration::V1 + def migrate + create_index table_for(Token), :next_id, unique: true + end + + def rollback + drop_index table_for(Token), :next_id, if_exists: true + end +end diff --git a/db/migrations/20260427120304_make_users_name_unique.cr b/db/migrations/20260427120304_make_users_name_unique.cr new file mode 100644 index 000000000..92f8dbce9 --- /dev/null +++ b/db/migrations/20260427120304_make_users_name_unique.cr @@ -0,0 +1,9 @@ +class MakeUsersNameUnique::V20260427120304 < Avram::Migrator::Migration::V1 + def migrate + create_index table_for(Users), [:name, :nickname], unique: true + end + + def rollback + drop_index table_for(Users), [:name, :nickname], if_exists: true + end +end diff --git a/spec/avram/insert_spec.cr b/spec/avram/insert_spec.cr index 85507b3b6..1982474d9 100644 --- a/spec/avram/insert_spec.cr +++ b/spec/avram/insert_spec.cr @@ -5,15 +5,50 @@ describe Avram::Insert do it "inserts with a hash of String" do params = {:first_name => "Paul", :last_name => "Smith"} insert = Avram::Insert.new(table: :users, params: params) - insert.statement.should eq %(insert into users("first_name", "last_name") values($1, $2) returning *) + + insert.statement.should eq %(INSERT INTO users ("first_name", "last_name") \ + VALUES ($1, $2) RETURNING *) + insert.args.should eq ["Paul", "Smith"] end it "inserts with a hash of Nil" do params = {:first_name => nil} insert = Avram::Insert.new(table: :users, params: params) - insert.statement.should eq %(insert into users("first_name") values($1) returning *) + insert.statement.should eq %(INSERT INTO users ("first_name") VALUES ($1) RETURNING *) insert.args.should eq [nil] end end + + describe "upserting" do + it "updates when keys conflict" do + params = {:first_name => "Paul", :last_name => "Smith"} + + insert = Avram::Insert.new( + :users, + params, + conflict_action: :update, + conflict_keys: [:first_name, :last_name], + conflict_params: [:last_name] + ) + + insert.statement.should eq %(INSERT INTO users ("first_name", "last_name") \ + VALUES ($1, $2) ON CONFLICT ("first_name", "last_name") DO UPDATE SET \ + "last_name" = EXCLUDED."last_name" RETURNING *) + end + + it "does nothing when keys conflict" do + params = {:first_name => "Paul", :last_name => "Smith"} + + insert = Avram::Insert.new( + :users, + params, + conflict_action: :nothing, + conflict_keys: [:first_name, :last_name] + ) + + insert.statement.should eq %(INSERT INTO users ("first_name", "last_name") \ + VALUES ($1, $2) ON CONFLICT ("first_name", "last_name") DO NOTHING RETURNING *) + end + end end diff --git a/spec/avram/operations/save_operation_spec.cr b/spec/avram/operations/save_operation_spec.cr index 66f448ee6..753b65a2f 100644 --- a/spec/avram/operations/save_operation_spec.cr +++ b/spec/avram/operations/save_operation_spec.cr @@ -71,11 +71,23 @@ private class ParamKeySaveOperation < ValueColumnModel::SaveOperation end private class UpsertUserOperation < User::SaveOperation + getter? after_commit_called = false + upsert_lookup_columns :name, :nickname + + after_commit do |_user| + @after_commit_called = true + end end private class UpsertToken < Token::SaveOperation + getter? after_save_called = false + upsert_lookup_columns :next_id + + after_save do |_user| + @after_save_called = true + end end private class OverrideDefaults < ModelWithDefaultValues::SaveOperation @@ -217,22 +229,23 @@ describe "Avram::SaveOperation" do 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) + existing_user = UserFactory.create &.name("Rich").nickname("Dolly").age(20) joined_at = Time.utc.at_beginning_of_second UpsertUserOperation.upsert( name: "Rich", - nickname: nil, + nickname: "Dolly", age: 30, joined_at: joined_at ) do |operation, user| - operation.created?.should be_false - operation.updated?.should be_true + operation.upserted?.should be_true + operation.saved?.should be_true + operation.after_commit_called?.should be_true UserQuery.new.select_count.should eq(1) user = user.as(User) user.id.should eq(existing_user.id) user.name.should eq("Rich") - user.nickname.should be_nil + user.nickname.should eq("Dolly") user.age.should eq(30) user.joined_at.should eq(joined_at) end @@ -243,15 +256,15 @@ describe "Avram::SaveOperation" do UpsertToken.upsert(next_id: 4, name: "Secret", scopes: ["red", "blue"]) do |operation, token| operation.valid?.should eq(true) + operation.after_save_called?.should be_true token.should_not eq(nil) token.as(Token).name.should eq("Secret") token.as(Token).scopes.should eq(["red", "blue"]) 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) + it "creates a new record if one doesn't exist" do + existing_user = UserFactory.create &.name("Rich").nickname("Dolly").age(20) joined_at = Time.utc.at_beginning_of_second UpsertUserOperation.upsert( @@ -260,15 +273,16 @@ describe "Avram::SaveOperation" do age: 30, joined_at: joined_at ) do |operation, user| - operation.created?.should be_true - operation.updated?.should be_false + operation.upserted?.should be_true + operation.saved?.should be_true + operation.after_commit_called?.should be_true 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) + existing_user.age.should eq(20) + existing_user.nickname.should eq("Dolly") user = user.as(User) - user.id.should_not eq(user_with_different_nickname.id) + user.id.should_not eq(existing_user.id) user.name.should eq("Rich") user.nickname.should eq("R.") user.age.should eq(30) @@ -277,25 +291,35 @@ describe "Avram::SaveOperation" do end it "allows updating nilable fields to nil" do - UserFactory.create(&.name("test").total_score(100)) - UpsertUserOperation.upsert(name: "test", total_score: nil) do |operation, updated_user| - operation.updated?.should eq(true) - updated_user.should_not eq(nil) - user = updated_user.as(User) - user.name.should eq("test") - user.total_score.should eq(nil) + user = UserFactory.create &.name("test").nickname("sample").total_score(100) + + UpsertUserOperation.upsert( + name: user.name, + nickname: user.nickname, + total_score: nil, + age: user.age, + joined_at: user.joined_at + ) do |operation, saved_user| + operation.upserted?.should be_true + operation.saved?.should be_true + saved_user.should_not be_nil + + saved_user.try do |_user| + _user.name.should eq("test") + _user.total_score.should be_nil + end 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) + existing_user = UserFactory.create &.name("Rich").nickname("Dolly").age(20) joined_at = Time.utc.at_beginning_of_second user = UpsertUserOperation.upsert!( name: "Rich", - nickname: nil, + nickname: "Dolly", age: 30, joined_at: joined_at ) @@ -304,13 +328,13 @@ describe "Avram::SaveOperation" do user = user.as(User) user.id.should eq(existing_user.id) user.name.should eq("Rich") - user.nickname.should be_nil + user.nickname.should eq("Dolly") 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) + existing_user = UserFactory.create &.name("Rich").nickname(nil).age(20) joined_at = Time.utc.at_beginning_of_second user = UpsertUserOperation.upsert!( @@ -322,11 +346,11 @@ describe "Avram::SaveOperation" do 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) + existing_user.age.should eq(20) + existing_user.nickname.should eq(nil) user = user.as(User) - user.id.should_not eq(user_with_different_nickname.id) + user.id.should_not eq(existing_user.id) user.name.should eq("Rich") user.nickname.should eq("R.") user.age.should eq(30) @@ -334,10 +358,18 @@ describe "Avram::SaveOperation" do end it "allows updating nilable fields to nil" do - UserFactory.create(&.name("test").total_score(100)) - user = UpsertUserOperation.upsert!(name: "test", total_score: nil) - user.name.should eq("test") - user.total_score.should eq(nil) + user = UserFactory.create &.name("test").nickname("sample").total_score(100) + + saved_user = UpsertUserOperation.upsert!( + name: user.name, + nickname: user.nickname, + total_score: nil, + age: user.age, + joined_at: user.joined_at + ) + + saved_user.name.should eq("test") + saved_user.total_score.should be_nil end it "raises if the record is invalid" do diff --git a/spec/avram/view_spec.cr b/spec/avram/view_spec.cr index 50b7b0d96..8d17008a9 100644 --- a/spec/avram/view_spec.cr +++ b/spec/avram/view_spec.cr @@ -12,9 +12,9 @@ describe "views" do end it "works without a primary key" do - UserFactory.new.nickname("Johnny").create - UserFactory.new.nickname("Johnny").create - UserFactory.new.nickname("Johnny").create + UserFactory.new.name("Walker").nickname("Johnny").create + UserFactory.new.name("Wilson").nickname("Johnny").create + UserFactory.new.name("Wonker").nickname("Johnny").create nickname_info = NicknameInfo::BaseQuery.first nickname_info.nickname.should eq "Johnny" diff --git a/src/avram/insert.cr b/src/avram/insert.cr index a8d01d1da..353ac52cc 100644 --- a/src/avram/insert.cr +++ b/src/avram/insert.cr @@ -1,18 +1,69 @@ class Avram::Insert alias Params = Hash(Symbol, String) | Hash(Symbol, String?) | Hash(Symbol, Nil) - def initialize(@table : TableName, @params : Params, @column_names : Array(Symbol) = [] of Symbol) + enum ConflictAction + Nothing + Update + + def to_s(io : IO) + case self + in .nothing? + io << "NOTHING" + in .update? + io << "UPDATE" + end + end + end + + def initialize( + @table : TableName, + @params : Params, + @column_names = [] of Symbol, + @conflict_action : ConflictAction? = nil, + @conflict_keys = [] of Symbol, + @conflict_params = [] of Symbol + ) end def statement : String - "insert into #{@table}(#{fields}) values(#{values_placeholders}) returning #{returning}" + String.build do |io| + io << "INSERT INTO " + io << @table + io << " (" + fields(io) + io << ')' + io << " VALUES (" + values_placeholders(io) + io << ')' + + if @conflict_action && !@conflict_keys.empty? + io << " ON CONFLICT (" + conflict_keys(io) + io << ") DO " + io << @conflict_action + + if @conflict_action.try(&.update?) + io << " SET " + excluded_params(io) + end + end + + io << " RETURNING " + returning(io) + end end - private def returning : String + private def returning(io) if @column_names.empty? - "*" + io << '*' else - @column_names.join(", ") { |column| %("#{@table}"."#{column}") } + @column_names.join(io, ", ") do |column, _io| + _io << '"' + _io << @table + _io << %(".") + _io << column + _io << '"' + end end end @@ -20,13 +71,35 @@ class Avram::Insert @params.values end - private def fields : String - @params.keys.join(", ") { |col| %("#{col}") } + private def fields(io) + @params.join(io, ", ") do |(col, _value), _io| + _io << '"' + _io << col + _io << '"' + end end - private def values_placeholders : String + private def values_placeholders(io) @params.values.map_with_index do |_value, index| "$#{index + 1}" - end.join(", ") + end.join(io, ", ") + end + + private def conflict_keys(io) + @conflict_keys.join(io, ", ") do |col, _io| + _io << '"' + _io << col + _io << '"' + end + end + + private def excluded_params(io) + @conflict_params.join(io, ", ") do |key, _io| + _io << '"' + _io << key + _io << %(" = EXCLUDED.") + _io << key + _io << '"' + end end end diff --git a/src/avram/needy_initializer_and_save_methods.cr b/src/avram/needy_initializer_and_save_methods.cr index b191d041b..7e254094a 100644 --- a/src/avram/needy_initializer_and_save_methods.cr +++ b/src/avram/needy_initializer_and_save_methods.cr @@ -197,7 +197,8 @@ module Avram::NeedyInitializerAndSaveMethods @params : Avram::Paramable, {{ needs_method_args.id }} {{ attribute_method_args.id }} - ) + ) + @__type = Type::Update set_attributes({{ attribute_params.id }}) end @@ -206,7 +207,7 @@ module Avram::NeedyInitializerAndSaveMethods {{ needs_method_args.id }} {{ attribute_method_args.id }} ) - @record = nil + @__type = Type::Create set_attributes({{ attribute_params.id }}) end @@ -215,7 +216,9 @@ module Avram::NeedyInitializerAndSaveMethods {{ needs_method_args.id }} {{ attribute_method_args.id }} ) + @__type = Type::Update @params = Avram::Params.new + set_attributes({{ attribute_params.id }}) end @@ -223,16 +226,23 @@ module Avram::NeedyInitializerAndSaveMethods {{ needs_method_args.id }} {{ attribute_method_args.id }} ) - @record = nil + @__type = Type::Create @params = Avram::Params.new + set_attributes({{ attribute_params.id }}) end + # Keeps track of params that were actually passed to the operation + # This is used in the `ON CONFLICT DO UPDATE SET` part of the SQL clause + @upsert_params = [] of Symbol + def set_attributes({{ attribute_method_args.id }}) extract_changes_from_params + {% if @type.constant :COLUMN_ATTRIBUTES %} {% for attribute in COLUMN_ATTRIBUTES.uniq %} unless {{ attribute[:name] }}.is_a? Avram::Nothing + @upsert_params << {{ attribute[:name].symbolize }} self.{{ attribute[:name] }}.value = {{ attribute[:name] }} end {% end %} diff --git a/src/avram/save_operation.cr b/src/avram/save_operation.cr index cd56f2a1b..a8b57e344 100644 --- a/src/avram/save_operation.cr +++ b/src/avram/save_operation.cr @@ -30,6 +30,14 @@ abstract class Avram::SaveOperation(T) Unperformed end + enum Type + Create + Update + Upsert + end + + @__type : Type + macro inherited @@permitted_param_keys = [] of String end @@ -46,14 +54,19 @@ abstract class Avram::SaveOperation(T) end def initialize(@params) + @__type = Type::Create end - def initialize - @params = Avram::Params.new + def self.new + new(Avram::Params.new) end delegate :write_database, :table_name, :primary_key_name, to: T + def type : Type + @__type + end + # A helper method to backfill accessing the database # before they were split in to read/write methods def database : Avram::Database.class @@ -137,11 +150,15 @@ abstract class Avram::SaveOperation(T) end def created? : Bool - saved? && new_record? + type.create? && saved? end def updated? : Bool - saved? && !new_record? + type.update? && saved? + end + + def upserted? : Bool + type.upsert? && saved? end # Return true if the operation has run and the record failed to save @@ -267,7 +284,7 @@ abstract class Avram::SaveOperation(T) # This method should always return `true` for a create or `false` # for an update, independent of the stage we are at in the operation. def new_record? : Bool - {{ T.constant(:PRIMARY_KEY_NAME).id }}.original_value.nil? + type.create? || type.upsert? end private def insert_or_update diff --git a/src/avram/upsert.cr b/src/avram/upsert.cr index 419eb8f01..f87f3f751 100644 --- a/src/avram/upsert.cr +++ b/src/avram/upsert.cr @@ -58,35 +58,54 @@ module Avram::Upsert # 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) + def upsert : Bool + @__type = Type::Upsert if type.create? + save + end - if existing_record - operation = new(existing_record, *args, **named_args) + def upsert! : T + if upsert + record.as(T) + else + raise Avram::InvalidOperationError.new(operation: self) end + end - operation.save! + def self.upsert!(*args, **named_args) : T + operation = new(*args, **named_args) + operation.upsert! end def self.upsert(*args, **named_args) operation = new(*args, **named_args) - existing_record = find_existing_unique_record(operation) - if existing_record - operation = new(existing_record, *args, **named_args) + if operation.upsert + yield operation, operation.record + else + yield operation, nil end - - operation.save - yield operation, operation.record end - def self.find_existing_unique_record(operation) : T? - T::BaseQuery.new + private def insert_sql + return super unless type.upsert? + + conflict_keys = Array(Symbol).new.tap do |columns| {% for attribute in attribute_names %} - .{{ attribute.id }}.nilable_eq(operation.{{ attribute.id }}.value) + columns << {{ attribute.id.symbolize }} {% end %} - .first? + end + + upsert_values = attributes_to_hash(column_attributes).compact! + conflict_params = upsert_values.map(&.[0]).concat(@upsert_params).uniq! + + Avram::Insert.new( + table_name, + upsert_values, + T.column_names, + :update, + conflict_keys, + conflict_params + ) end end