From 9ffe51fc230a93d1bc6035adf9bdae1176ee6cc5 Mon Sep 17 00:00:00 2001 From: Matt Kobs Date: Tue, 13 Jan 2026 10:02:13 -0600 Subject: [PATCH 1/3] [skip] Bumped Ruby version to 3.3 (1m) --- .ruby-version | 2 +- mise.toml | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) create mode 100644 mise.toml diff --git a/.ruby-version b/.ruby-version index eca690e..86fb650 100644 --- a/.ruby-version +++ b/.ruby-version @@ -1 +1 @@ -3.0.5 +3.3.7 diff --git a/mise.toml b/mise.toml new file mode 100644 index 0000000..0c4809e --- /dev/null +++ b/mise.toml @@ -0,0 +1,2 @@ +[tools] +ruby = "3.3" From 3596c4aa38c7d676d119b4e5393b7a719487bf79 Mon Sep 17 00:00:00 2001 From: Matt Kobs Date: Tue, 13 Jan 2026 10:02:13 -0600 Subject: [PATCH 2/3] [improvement] Added option to use ROW-level triggers (1h) While STATEMENT-level triggers are better for bulk operations, being vastly more performant when INSERTing or DELETEing large swaths of data, ROW-level triggers can be more performant on individual UPDATE operations, since they can opt _not_ to update the haystack if none of the indexed fields are actually updated. This update gives the option to use one or the other depending on application concerns (though it still defaults to STATEMENT as before). --- lib/hayfork.rb | 4 ++-- lib/hayfork/delete_sql.rb | 6 +++-- lib/hayfork/insert_sql.rb | 8 +++++-- lib/hayfork/statement_builder.rb | 8 +++---- lib/hayfork/triggers.rb | 40 ++++++++++++++++++++++--------- lib/hayfork/update_sql.rb | 17 +++++++++---- test/integration/haystack_test.rb | 25 +++++++++++++++++-- test/test_helper.rb | 5 ++-- test/unit/delete_sql_test.rb | 12 ++++++++++ test/unit/insert_sql_test.rb | 13 ++++++++++ test/unit/triggers_test.rb | 39 ++++++++++++++++++++++++++++++ test/unit/update_sql_test.rb | 36 ++++++++++++++++++++++++++++ 12 files changed, 184 insertions(+), 29 deletions(-) diff --git a/lib/hayfork.rb b/lib/hayfork.rb index 5636730..5650123 100644 --- a/lib/hayfork.rb +++ b/lib/hayfork.rb @@ -24,8 +24,8 @@ module Hayfork class << self attr_accessor :default_weight, :default_dictionary - def maintain(haystack, &block) - triggers = Triggers.new(haystack) + def maintain(haystack, by_row: false, &block) + triggers = Triggers.new(haystack, by_row: by_row) TriggerBuilder.new(triggers).instance_eval(&block) haystack.singleton_class.send(:attr_reader, :triggers) haystack.instance_variable_set :@triggers, triggers diff --git a/lib/hayfork/delete_sql.rb b/lib/hayfork/delete_sql.rb index 9a056b3..924026b 100644 --- a/lib/hayfork/delete_sql.rb +++ b/lib/hayfork/delete_sql.rb @@ -8,9 +8,11 @@ def initialize(haystack, relation, bindings) @bindings = bindings.reject { |binding| binding.key == Hayfork::SEARCH_VECTOR || binding.key == Hayfork::TEXT } end - def to_sql + def to_sql(by_row: false) select_statement = relation.select(bindings.map(&:to_s)) - select_statement = select_statement.from("old_table \"#{relation.table_name}\"") + select_statement = by_row ? + select_statement.from("(SELECT OLD.*) \"#{relation.table_name}\"") : + select_statement.from("old_table \"#{relation.table_name}\"") constraints = bindings.map { |binding| "#{haystack.table_name}.#{binding.key}=x.#{binding.key}" }.join(" AND ") diff --git a/lib/hayfork/insert_sql.rb b/lib/hayfork/insert_sql.rb index 2e4d95a..1542ee5 100644 --- a/lib/hayfork/insert_sql.rb +++ b/lib/hayfork/insert_sql.rb @@ -8,9 +8,13 @@ def initialize(haystack, relation, bindings) @bindings = bindings end - def to_sql(from: true) + def to_sql(from: true, by_row: false) select_statement = relation.select(bindings.map(&:to_s)) - select_statement = select_statement.from("new_table \"#{relation.table_name}\"") if from + if from + select_statement = by_row ? + select_statement.from("(SELECT NEW.*) \"#{relation.table_name}\"") : + select_statement.from("new_table \"#{relation.table_name}\"") + end <<~SQL INSERT INTO #{haystack.table_name} (#{bindings.map(&:key).join(", ")}) SELECT * FROM (#{select_statement.to_sql}) "x" WHERE "x"."#{Hayfork::TEXT}" != ''; diff --git a/lib/hayfork/statement_builder.rb b/lib/hayfork/statement_builder.rb index 9a0b766..e3c631b 100644 --- a/lib/hayfork/statement_builder.rb +++ b/lib/hayfork/statement_builder.rb @@ -38,14 +38,14 @@ def to_insert_sql(**args) statements.map { |statement| " " << statement.insert.to_sql(**args) }.join.strip end - def to_update_sql + def to_update_sql(**args) updates = statements.select(&:may_change_on_update?) return "-- nothing to update" if updates.empty? - updates.map { |statement| " " << statement.update.to_sql.lstrip }.join.strip + updates.map { |statement| " " << statement.update.to_sql(**args).lstrip }.join.strip end - def to_delete_sql - statements.map { |statement| " " << statement.delete.to_sql }.join.strip + def to_delete_sql(**args) + statements.map { |statement| " " << statement.delete.to_sql(**args) }.join.strip end diff --git a/lib/hayfork/triggers.rb b/lib/hayfork/triggers.rb index 4a171c9..1e0bc15 100644 --- a/lib/hayfork/triggers.rb +++ b/lib/hayfork/triggers.rb @@ -2,8 +2,9 @@ module Hayfork class Triggers attr_reader :haystack - def initialize(haystack) + def initialize(haystack, by_row: false) @haystack = haystack + @by_row = by_row @_triggers = [] end @@ -31,6 +32,10 @@ def rebuild ([truncate] + _triggers.map { |args| psql_inserts_for(*args) }).join end + def by_row? + @by_row + end + private attr_reader :_triggers @@ -40,18 +45,35 @@ def psql_create_triggers_for(model, statements, options) CREATE FUNCTION #{name}() RETURNS trigger AS $$ BEGIN IF TG_OP = 'DELETE' THEN - #{statements.to_delete_sql} + #{statements.to_delete_sql(by_row: by_row?)} RETURN OLD; ELSIF TG_OP = 'UPDATE' THEN - #{statements.to_update_sql} + #{statements.to_update_sql(by_row: by_row?)} RETURN NEW; ELSIF TG_OP = 'INSERT' THEN - #{statements.to_insert_sql} + #{statements.to_insert_sql(by_row: by_row?)} RETURN NEW; END IF; RETURN NULL; -- result is ignored since this is an AFTER trigger END; $$ LANGUAGE plpgsql; + #{by_row? ? psql_row_trigger(model, name).strip : psql_statement_triggers(model, name).strip} + SQL + end + + def psql_inserts_for(model, statements, options) + "#{statements.to_insert_sql(from: false, by_row: by_row?)}\n" if options.fetch(:rebuild, true) + end + + def psql_drop_triggers_for(model, options) + name = function_name(model, options) + <<~SQL + DROP FUNCTION IF EXISTS #{name}() CASCADE; + SQL + end + + def psql_statement_triggers(model, name) + <<~SQL CREATE TRIGGER #{name}_insert_trigger AFTER INSERT ON #{model.table_name} REFERENCING NEW TABLE AS new_table FOR EACH STATEMENT EXECUTE PROCEDURE #{name}(); @@ -64,14 +86,10 @@ def psql_create_triggers_for(model, statements, options) SQL end - def psql_inserts_for(model, statements, options) - "#{statements.to_insert_sql(from: false)}\n" if options.fetch(:rebuild, true) - end - - def psql_drop_triggers_for(model, options) - name = function_name(model, options) + def psql_row_trigger(model, name) <<~SQL - DROP FUNCTION IF EXISTS #{name}() CASCADE; + CREATE TRIGGER #{name}_trigger BEFORE INSERT OR UPDATE OR DELETE ON #{model.table_name} + FOR EACH ROW EXECUTE PROCEDURE #{name}(); SQL end diff --git a/lib/hayfork/update_sql.rb b/lib/hayfork/update_sql.rb index d1a2929..8b70980 100644 --- a/lib/hayfork/update_sql.rb +++ b/lib/hayfork/update_sql.rb @@ -11,11 +11,20 @@ def initialize(haystack, relation, bindings) @bindings = bindings end - def to_sql - <<-SQL - #{delete.to_sql.strip} - #{insert.to_sql.strip} + def to_sql(by_row: false) + checks = values_to_check_on_update.map { |field| "OLD.#{field} IS DISTINCT FROM NEW.#{field}" }.join(" OR ") + + sql = [<<~SQL] + #{delete.to_sql(by_row: by_row).strip} + #{insert.to_sql(by_row: by_row).strip} + SQL + + sql.unshift <<~SQL if by_row + IF #{checks} THEN SQL + sql << "END IF;" if by_row + + sql.join("\n") end alias to_s to_sql diff --git a/test/integration/haystack_test.rb b/test/integration/haystack_test.rb index a34acc2..a0efdb0 100644 --- a/test/integration/haystack_test.rb +++ b/test/integration/haystack_test.rb @@ -54,6 +54,27 @@ def teardown end end + context "If we're maintaining books in the haystack by row" do + setup do + triggers(by_row: true) do + foreach Book do |index| + index.insert(:title) + index.insert(:description) + end + end + end + + context "Changing a book's ISBN" do + should "do nothing to the haystack if the ISBN isn't used" do + book = Book.create!(title: "The Chosen", isbn: "0449213447") + + before = Haystack.pluck(:id) + book.update_column :isbn, "9780449213445" + assert_equal before, Haystack.pluck(:id) + end + end + end + context "If we're maintaining a belongs_to relationship" do setup do @@ -152,8 +173,8 @@ def teardown private - def triggers(&block) - triggers = Hayfork.maintain(Haystack, &block) + def triggers(by_row: false, &block) + triggers = Hayfork.maintain(Haystack, by_row: by_row, &block) Haystack.connection.execute triggers.replace end diff --git a/test/test_helper.rb b/test/test_helper.rb index 0f676bd..55a6ef0 100644 --- a/test/test_helper.rb +++ b/test/test_helper.rb @@ -16,13 +16,14 @@ require "support/models/haystack" connection_url = "'postgresql://#{ENV["POSTGRES_USER"]}:#{ENV["POSTGRES_PASSWORD"]}@localhost:5432'" if ENV["POSTGRES_PASSWORD"] -system "psql #{connection_url} -c 'create database hayfork_test'" +system "psql #{connection_url} -c 'create database hayfork_test'" if connection_url +system "psql -U postgres -h localhost -c 'create database hayfork_test'" unless connection_url ActiveRecord::Base.establish_connection( adapter: "postgresql", host: "localhost", database: "hayfork_test", - username: ENV["POSTGRES_USER"], + username: ENV["POSTGRES_USER"] || "postgres", password: ENV["POSTGRES_PASSWORD"], verbosity: "quiet") diff --git a/test/unit/delete_sql_test.rb b/test/unit/delete_sql_test.rb index a0aac72..37d643c 100644 --- a/test/unit/delete_sql_test.rb +++ b/test/unit/delete_sql_test.rb @@ -27,6 +27,18 @@ class DeleteSqlTest < Minitest::Test AND haystack.search_result_id=x.search_result_id; SQL end + + should "DELETE by row if configured as such" do + assert_equal <<~SQL.squish, delete.to_sql(by_row: true).strip + DELETE FROM haystack + USING (SELECT + 'Book'::varchar "search_result_type", + books.id::integer "search_result_id" + FROM (SELECT OLD.*) "books") "x" + WHERE haystack.search_result_type=x.search_result_type + AND haystack.search_result_id=x.search_result_id; + SQL + end end should "incorporate relation's joins and conditions" do diff --git a/test/unit/insert_sql_test.rb b/test/unit/insert_sql_test.rb index c53fdc0..a11b94c 100644 --- a/test/unit/insert_sql_test.rb +++ b/test/unit/insert_sql_test.rb @@ -28,6 +28,19 @@ class InsertSqlTest < Minitest::Test WHERE "x"."text" != ''; SQL end + + should "INSERT by row if configured as such" do + assert_equal <<~SQL.squish, insert.to_sql(by_row: true).strip + INSERT INTO haystack (search_result_type, search_result_id, text, search_vector) + SELECT * FROM (SELECT + 'Book'::varchar "search_result_type", + books.id::integer "search_result_id", + books.title::text "text", + setweight(to_tsvector('hayfork', replace(books.title::varchar, '-', ' ')), 'C') "search_vector" + FROM (SELECT NEW.*) "books") "x" + WHERE "x"."text" != ''; + SQL + end end should "incorporate relation's joins and conditions" do diff --git a/test/unit/triggers_test.rb b/test/unit/triggers_test.rb index cd710d4..916cfb0 100644 --- a/test/unit/triggers_test.rb +++ b/test/unit/triggers_test.rb @@ -109,5 +109,44 @@ class TriggersTest < Minitest::Test end end + context "when configured to use ROW-based triggers" do + setup do + @triggers = Hayfork.maintain(Haystack, by_row: true) do + foreach Book do |index| + index.insert(:title) + end + end + end + + context "#create" do + should "produce the SQL that will create the appropriate triggers for the specified table" do + any_instance_of(Hayfork::Statement) do |statement| + stub(statement).insert.stub!.to_sql.returns("") + stub(statement).update.stub!.to_sql.returns("") + stub(statement).delete.stub!.to_sql.returns("") + stub(statement).may_change_on_update?.returns(true) + end + assert_equal <<~SQL, triggers.create + CREATE FUNCTION maintain_books_in_haystack() RETURNS trigger AS $$ + BEGIN + IF TG_OP = 'DELETE' THEN + + RETURN OLD; + ELSIF TG_OP = 'UPDATE' THEN + + RETURN NEW; + ELSIF TG_OP = 'INSERT' THEN + + RETURN NEW; + END IF; + RETURN NULL; -- result is ignored since this is an AFTER trigger + END; + $$ LANGUAGE plpgsql; + CREATE TRIGGER maintain_books_in_haystack_trigger BEFORE INSERT OR UPDATE OR DELETE ON books + FOR EACH ROW EXECUTE PROCEDURE maintain_books_in_haystack(); + SQL + end + end + end end diff --git a/test/unit/update_sql_test.rb b/test/unit/update_sql_test.rb index d54f625..b9266c8 100644 --- a/test/unit/update_sql_test.rb +++ b/test/unit/update_sql_test.rb @@ -26,6 +26,42 @@ class UpdateSqlTest < Minitest::Test SQL end end + + context "when configured to use row-based triggers" do + setup do + @relation = Book.all + @bindings = [ + Binding(Hayfork::SEARCH_RESULT_TYPE, "Book"), + Binding(Hayfork::SEARCH_RESULT_ID, Book.arel_table[:id]), + Binding(Hayfork::TEXT, Book.arel_table[:title]), + Binding(Hayfork::SEARCH_VECTOR, Book.arel_table[:title]), + Binding("another_field", Book.arel_table[:isbn]), + ] + end + + context "when the model uses attr_readonly" do + setup do + Book.attr_readonly :isbn + fail "isbn should be readonly now" unless Book.readonly_attributes.member?("isbn") + end + + teardown do + Book.readonly_attributes.delete "isbn" + fail "isbn should not be readonly now" if Book.readonly_attributes.member?("isbn") + end + + should "not check values that are readonly" do + stub(update).delete.stub!.to_sql.returns("") + stub(update).insert.stub!.to_sql.returns("") + assert_equal <<~SQL.squish, update.to_sql(by_row: true).squish.strip + IF OLD.title IS DISTINCT FROM NEW.title THEN + + + END IF; + SQL + end + end + end end From 8739f92a4fbc428e9cb6dd69b0c43cb3fc1d3272 Mon Sep 17 00:00:00 2001 From: Matt Kobs Date: Tue, 13 Jan 2026 10:02:13 -0600 Subject: [PATCH 3/3] [skip] Bumped version and updated Changelog for v1.5.0 (3m) --- CHANGELOG.md | 11 ++++++++++- lib/hayfork/version.rb | 2 +- 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 98b194d..65e7cee 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,12 @@ +## v1.5.0 (2026 Jan 13) + +* Added option to have triggers be ROW-level +* Rails 8 support + +## v1.4.0 (2024 Jun 17) + +* Refactored to use STATEMENT-level triggers by default + ## v1.3.0 (2023 Mar 10) * Support for Rails 7.0 @@ -10,6 +19,6 @@ * Support for Rails 6 -## v1.0.0 (2019 Jan 2) +## v1.0.0 (2019 Jan 2) * Initial release diff --git a/lib/hayfork/version.rb b/lib/hayfork/version.rb index 0800d48..f79d214 100644 --- a/lib/hayfork/version.rb +++ b/lib/hayfork/version.rb @@ -1,3 +1,3 @@ module Hayfork - VERSION = "1.4.0" + VERSION = "1.5.0" end