Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .ruby-version
Original file line number Diff line number Diff line change
@@ -1 +1 @@
3.0.5
3.3.7
11 changes: 10 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -10,6 +19,6 @@

* Support for Rails 6

## v1.0.0 (2019 Jan 2)
## v1.0.0 (2019 Jan 2)

* Initial release
4 changes: 2 additions & 2 deletions lib/hayfork.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 4 additions & 2 deletions lib/hayfork/delete_sql.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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 ")

Expand Down
8 changes: 6 additions & 2 deletions lib/hayfork/insert_sql.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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}" != '';
Expand Down
8 changes: 4 additions & 4 deletions lib/hayfork/statement_builder.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand Down
40 changes: 29 additions & 11 deletions lib/hayfork/triggers.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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

Expand All @@ -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}();
Expand All @@ -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

Expand Down
17 changes: 13 additions & 4 deletions lib/hayfork/update_sql.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
2 changes: 1 addition & 1 deletion lib/hayfork/version.rb
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
module Hayfork
VERSION = "1.4.0"
VERSION = "1.5.0"
end
2 changes: 2 additions & 0 deletions mise.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
[tools]
ruby = "3.3"
25 changes: 23 additions & 2 deletions test/integration/haystack_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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

Expand Down
5 changes: 3 additions & 2 deletions test/test_helper.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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")

Expand Down
12 changes: 12 additions & 0 deletions test/unit/delete_sql_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
13 changes: 13 additions & 0 deletions test/unit/insert_sql_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
39 changes: 39 additions & 0 deletions test/unit/triggers_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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("<!-- INSERT -->")
stub(statement).update.stub!.to_sql.returns("<!-- UPDATE -->")
stub(statement).delete.stub!.to_sql.returns("<!-- DELETE -->")
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
<!-- DELETE -->
RETURN OLD;
ELSIF TG_OP = 'UPDATE' THEN
<!-- UPDATE -->
RETURN NEW;
ELSIF TG_OP = 'INSERT' THEN
<!-- INSERT -->
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
36 changes: 36 additions & 0 deletions test/unit/update_sql_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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("<!-- DELETE -->")
stub(update).insert.stub!.to_sql.returns("<!-- INSERT -->")
assert_equal <<~SQL.squish, update.to_sql(by_row: true).squish.strip
IF OLD.title IS DISTINCT FROM NEW.title THEN
<!-- DELETE -->
<!-- INSERT -->
END IF;
SQL
end
end
end
end


Expand Down