From 0c7294e14d895f2984d2e4f63361d984c4869b10 Mon Sep 17 00:00:00 2001 From: Jeremy Evans Date: Mon, 1 Jul 2024 16:16:40 -0700 Subject: [PATCH] Support MERGE WHEN NOT MATCHED BY SOURCE on PostgreSQL 17+ This adds the following dataset methods on PostgreSQL: * merge_delete_when_not_matched_by_source * merge_update_when_not_matched_by_source * merge_do_nothing_when_not_matched_by_source These are similar to the existing merge_delete, merge_update, and merge_do_nothing_when_matched, except they use WHEN NOT MATCHED BY SOURCE instead of WHEN MATCHED. This replaces the _merge_matched_sql and _merge_not_matched_sql private dataset methods with _merge_do_nothing_sql. --- CHANGELOG | 2 ++ lib/sequel/adapters/shared/postgres.rb | 42 ++++++++++++++++++++++-- lib/sequel/dataset/sql.rb | 13 ++++++++ spec/adapters/postgres_spec.rb | 45 ++++++++++++++++++++++++++ 4 files changed, 100 insertions(+), 2 deletions(-) diff --git a/CHANGELOG b/CHANGELOG index 60d907a8c..89bbddcc5 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -1,5 +1,7 @@ === master +* Support MERGE WHEN NOT MATCHED BY SOURCE on PostgreSQL 17+ (jeremyevans) + * Add stdio_logger extension for a minimal logger that Sequel::Database can use (jeremyevans) * Simplify Database#inspect output to database_type, host, database, and user (jeremyevans) diff --git a/lib/sequel/adapters/shared/postgres.rb b/lib/sequel/adapters/shared/postgres.rb index 661932662..4087ebba2 100644 --- a/lib/sequel/adapters/shared/postgres.rb +++ b/lib/sequel/adapters/shared/postgres.rb @@ -2068,6 +2068,19 @@ def merge(&block) end end + # Return a dataset with a WHEN NOT MATCHED BY SOURCE THEN DELETE clause added to the + # MERGE statement. If a block is passed, treat it as a virtual row and + # use it as additional conditions for the match. + # + # merge_delete_not_matched_by_source + # # WHEN NOT MATCHED BY SOURCE THEN DELETE + # + # merge_delete_not_matched_by_source{a > 30} + # # WHEN NOT MATCHED BY SOURCE AND (a > 30) THEN DELETE + def merge_delete_when_not_matched_by_source(&block) + _merge_when(:type=>:delete_not_matched_by_source, &block) + end + # Return a dataset with a WHEN MATCHED THEN DO NOTHING clause added to the # MERGE statement. If a block is passed, treat it as a virtual row and # use it as additional conditions for the match. @@ -2094,6 +2107,19 @@ def merge_do_nothing_when_not_matched(&block) _merge_when(:type=>:not_matched, &block) end + # Return a dataset with a WHEN NOT MATCHED BY SOURCE THEN DO NOTHING clause added to the + # MERGE BY SOURCE statement. If a block is passed, treat it as a virtual row and + # use it as additional conditions for the match. + # + # merge_do_nothing_when_not_matched_by_source + # # WHEN NOT MATCHED BY SOURCE THEN DO NOTHING + # + # merge_do_nothing_when_not_matched_by_source{a > 30} + # # WHEN NOT MATCHED BY SOURCE AND (a > 30) THEN DO NOTHING + def merge_do_nothing_when_not_matched_by_source(&block) + _merge_when(:type=>:not_matched_by_source, &block) + end + # Support OVERRIDING USER|SYSTEM VALUE for MERGE INSERT. def merge_insert(*values, &block) h = {:type=>:insert, :values=>values} @@ -2103,6 +2129,19 @@ def merge_insert(*values, &block) _merge_when(h, &block) end + # Return a dataset with a WHEN NOT MATCHED BY SOURCE THEN UPDATE clause added to the + # MERGE statement. If a block is passed, treat it as a virtual row and + # use it as additional conditions for the match. + # + # merge_update_not_matched_by_source(i1: Sequel[:i1]+:i2+10, a: Sequel[:a]+:b+20) + # # WHEN NOT MATCHED BY SOURCE THEN UPDATE SET i1 = (i1 + i2 + 10), a = (a + b + 20) + # + # merge_update_not_matched_by_source(i1: :i2){a > 30} + # # WHEN NOT MATCHED BY SOURCE AND (a > 30) THEN UPDATE SET i1 = i2 + def merge_update_when_not_matched_by_source(values, &block) + _merge_when(:type=>:update_not_matched_by_source, :values=>values, &block) + end + # Use OVERRIDING USER VALUE for INSERT statements, so that identity columns # always use the user supplied value, and an error is not raised for identity # columns that are GENERATED ALWAYS. @@ -2305,10 +2344,9 @@ def _merge_insert_sql(sql, data) _insert_values_sql(sql, values) end - def _merge_matched_sql(sql, data) + def _merge_do_nothing_sql(sql, data) sql << " THEN DO NOTHING" end - alias _merge_not_matched_sql _merge_matched_sql # Support MERGE RETURNING on PostgreSQL 17+. def _merge_when_sql(sql) diff --git a/lib/sequel/dataset/sql.rb b/lib/sequel/dataset/sql.rb index 6b9a95d7c..189e0eb88 100644 --- a/lib/sequel/dataset/sql.rb +++ b/lib/sequel/dataset/sql.rb @@ -901,18 +901,31 @@ def _merge_delete_sql(sql, data) MERGE_TYPE_SQL = { :insert => ' WHEN NOT MATCHED', :delete => ' WHEN MATCHED', + :delete_not_matched_by_source => ' WHEN NOT MATCHED BY SOURCE', :update => ' WHEN MATCHED', + :update_not_matched_by_source => ' WHEN NOT MATCHED BY SOURCE', :matched => ' WHEN MATCHED', :not_matched => ' WHEN NOT MATCHED', + :not_matched_by_source => ' WHEN NOT MATCHED BY SOURCE', }.freeze private_constant :MERGE_TYPE_SQL + MERGE_NORMALIZE_TYPE_MAP = { + :delete_not_matched_by_source => :delete, + :update_not_matched_by_source => :update, + :matched => :do_nothing, + :not_matched => :do_nothing, + :not_matched_by_source => :do_nothing, + }.freeze + private_constant :MERGE_NORMALIZE_TYPE_MAP + # Add the WHEN clauses to the MERGE SQL def _merge_when_sql(sql) raise Error, "no WHEN [NOT] MATCHED clauses provided for MERGE" unless merge_when = @opts[:merge_when] merge_when.each do |data| type = data[:type] sql << MERGE_TYPE_SQL[type] + type = MERGE_NORMALIZE_TYPE_MAP[type] || type _merge_when_conditions_sql(sql, data) send(:"_merge_#{type}_sql", sql, data) end diff --git a/spec/adapters/postgres_spec.rb b/spec/adapters/postgres_spec.rb index 9307f30f0..1239f1d32 100644 --- a/spec/adapters/postgres_spec.rb +++ b/spec/adapters/postgres_spec.rb @@ -6030,3 +6030,48 @@ def check(ds) @m1.all.must_equal [] end end if DB.server_version >= 170000 + +describe "MERGE WHEN NOT MATCHED BY SOURCE" do + before(:all) do + @db = DB + @db.create_table!(:m1){Integer :i1; Integer :a} + @db.create_table!(:m2){Integer :i2} + @m1 = @db[:m1] + end + after do + @m1.delete + end + after(:all) do + @db.drop_table?(:m1, :m2) + end + + it "should allow inserts, updates, do nothings, and deletes based on conditions in a single MERGE statement" do + ds = @m1.merge_using(:m2, :i1=>:i2) + + @m1.insert(1, 2) + @m1.all.must_equal [{:i1=>1, :a=>2}] + + ds.merge_do_nothing_when_not_matched_by_source.merge + @m1.all.must_equal [{:i1=>1, :a=>2}] + + ds.merge_update_when_not_matched_by_source(:i1=>Sequel[:i1]+10, :a=>Sequel[:a]+20).merge + @m1.all.must_equal [{:i1=>11, :a=>22}] + + ds.merge_delete_when_not_matched_by_source.merge + @m1.all.must_equal [] + + @m1.insert(1, 100) + @m1.insert(2, 40) + @m1.insert(3, 20) + @m1.insert(4, 5) + + # conditions + ds. + merge_do_nothing_when_not_matched_by_source{a > 50}. + merge_delete_when_not_matched_by_source{a > 30}. + merge_update_when_not_matched_by_source(:a=>Sequel[:a]+20){a > 10}. + merge_update_when_not_matched_by_source(:a=>Sequel[:a]-20). + merge + @m1.order(:i1).all.must_equal [{:i1=>1, :a=>100}, {:i1=>3, :a=>40}, {:i1=>4, :a=>-15}] + end +end if DB.server_version >= 170000