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/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.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/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 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" 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