diff --git a/lib/litestack/litesearch/model.rb b/lib/litestack/litesearch/model.rb index 1d68f27..9ce5f33 100644 --- a/lib/litestack/litesearch/model.rb +++ b/lib/litestack/litesearch/model.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + module Litesearch::Model def self.included(klass) klass.include InstanceMethods @@ -35,30 +37,27 @@ def similar(limit = 10) end module ClassMethods - def litesearch # it is possible that this code is running when there is no table created yet - if !defined?(ActiveRecord::Base).nil? && ancestors.include?(ActiveRecord::Base) - unless table_exists? - # capture the schema block - @schema = ::Litesearch::Schema.new - @schema.model_class = self if @schema.respond_to? :model_class - @schema.type :backed - @schema.table table_name.to_sym - yield @schema - @schema.post_init - @schema_not_created = true - after_initialize do - if self.class.instance_variable_get(:@schema_not_created) - self.class.get_connection.search_index(self.class.index_name) do |schema| - @schema.model_class = self.class if @schema.respond_to? :model_class - schema.merge(self.class.instance_variable_get(:@schema)) - end - self.class.instance_variable_set(:@schema_not_created, false) + if !defined?(ActiveRecord::Base).nil? && ancestors.include?(ActiveRecord::Base) && !table_exists? + # capture the schema block + @schema = ::Litesearch::Schema.new + @schema.model_class = self if @schema.respond_to? :model_class + @schema.type :backed + @schema.table table_name.to_sym + yield @schema + @schema.post_init + @schema_not_created = true + after_initialize do + if self.class.instance_variable_get(:@schema_not_created) + self.class.get_connection.search_index(self.class.index_name) do |schema| + @schema.model_class = self.class if @schema.respond_to? :model_class + schema.merge(self.class.instance_variable_get(:@schema)) end + self.class.instance_variable_set(:@schema_not_created, false) end - return nil end + return nil end idx = get_connection.search_index(index_name) do |schema| schema.type :backed @@ -93,8 +92,8 @@ def similar(rowid, limit = 10) conn.results_as_hash = r_a_h result = [] rs.each do |row| - obj = fetch_row(row["rowid"]) - obj.search_rank = row["search_rank"] + obj = fetch_row(row['rowid']) + obj.search_rank = row['search_rank'] result << obj end result @@ -119,12 +118,12 @@ def search_all(term, options = {}) selects << "SELECT '#{name}' AS model, rowid, -rank AS search_rank FROM #{index_name_for_table(klass.table_name)}(:term)" end conn = get_connection - sql = selects.join(" UNION ") << " ORDER BY search_rank DESC LIMIT :limit OFFSET :offset" + sql = selects.join(' UNION ') << ' ORDER BY search_rank DESC LIMIT :limit OFFSET :offset' result = [] rs = conn.query(sql, options) # , options[:limit], options[:offset]) rs.each_hash do |row| - obj = models_hash[row["model"]].fetch_row(row["rowid"]) - obj.search_rank = row["search_rank"] + obj = models_hash[row['model']].fetch_row(row['rowid']) + obj.search_rank = row['search_rank'] result << obj end rs.close @@ -146,7 +145,6 @@ def create_instance(row) end module ActiveRecordSchemaMethods - attr_accessor :model_class def field(name, attributes = {}) @@ -154,25 +152,24 @@ def field(name, attributes = {}) if keys.include?(:action_text) || keys.include?(:rich_text) attributes[:source] = begin "#{ActionText::RichText.table_name}.body" - rescue - "action_text_rich_texts.body" + rescue StandardError + 'action_text_rich_texts.body' end attributes[:reference] = :record_id - attributes[:conditions] = {record_type: model_class.name} + attributes[:conditions] = { record_type: model_class.name } attributes[:target] = nil elsif keys.include? :as attributes[:source] = attributes[:target] unless attributes[:source] attributes[:reference] = "#{attributes[:as]}_id" - attributes[:conditions] = {"#{attributes[:as]}_type": model_class.name} + attributes[:conditions] = { "#{attributes[:as]}_type": model_class.name } attributes[:target] = nil end super(name, attributes) end def allowed_attributes - super + [:polymorphic, :as, :action_text] + super + %i[polymorphic as action_text] end - end module ActiveRecordInstanceMethods @@ -191,7 +188,7 @@ def rowid(id) end def fetch_row(rowid) - find_by("rowid = ?", rowid) + find_by('rowid = ?', rowid) end def search(term) @@ -202,15 +199,32 @@ def search(term) end @schema_not_created = false end - self.select( - "#{table_name}.*" + + # Create a search relation with custom count method + search_relation = self.select( + "#{table_name}.*, -#{index_name}.rank AS search_rank" ).joins( "INNER JOIN #{index_name} ON #{table_name}.rowid = #{index_name}.rowid AND rank != 0 AND #{index_name} MATCH ", Arel.sql("'#{term}'") - ).select( - "-#{index_name}.rank AS search_rank" ).order( Arel.sql("#{index_name}.rank") ) + + # Override count method to handle search results properly + search_relation.define_singleton_method(:count) do |column_name = nil| + if column_name.nil? + # For search results, use a simple count without the complex SELECT + # Use the model class directly to create a new relation + model_class = klass + base_relation = model_class.joins( + "INNER JOIN #{index_name} ON #{table_name}.rowid = #{index_name}.rowid AND rank != 0 AND #{index_name} MATCH ", Arel.sql("'#{term}'") + ) + base_relation.count + else + super(column_name) + end + end + + search_relation end def create_instance(row) @@ -238,7 +252,7 @@ def rowid(id) end def fetch_row(rowid) - self[rowid] # where(Sequel.lit("rowid = ?", rowid)).first + self[rowid] # where(Sequel.lit("rowid = ?", rowid)).first end def get_connection @@ -249,9 +263,9 @@ def search(term) dataset.select( Sequel.lit("#{table_name}.*, -#{index_name}.rank AS search_rank") ).inner_join( - Sequel.lit("#{index_name}(:term) ON #{table_name}.rowid = #{index_name}.rowid AND rank != 0", {term: term}) + Sequel.lit("#{index_name}(:term) ON #{table_name}.rowid = #{index_name}.rowid AND rank != 0", { term: term }) ).order( - Sequel.lit("rank") + Sequel.lit('rank') ) end @@ -259,6 +273,7 @@ def create_instance(row) # we need to convert keys to symbols first! row.keys.each do |k| next if k.is_a? Symbol + row[k.to_sym] = row[k] row.delete(k) end diff --git a/test/test_ar_search.rb b/test/test_ar_search.rb index be149c4..5d8e988 100644 --- a/test/test_ar_search.rb +++ b/test/test_ar_search.rb @@ -1,31 +1,33 @@ -require "minitest/autorun" +# frozen_string_literal: true + +require 'minitest/autorun' # require_relative "../lib/litestack/litedb" -require "active_record" -require "active_record/base" +require 'active_record' +require 'active_record/base' -require_relative "patch_ar_adapter_path" +require_relative 'patch_ar_adapter_path' -require_relative "../lib/active_record/connection_adapters/litedb_adapter" +require_relative '../lib/active_record/connection_adapters/litedb_adapter' ActiveRecord::Base.establish_connection( - adapter: "litedb", - database: ":memory:" + adapter: 'litedb', + database: ':memory:' ) # ActiveRecord::Base.logger = Logger.new(STDOUT) db = ActiveRecord::Base.connection.raw_connection -db.execute("CREATE TABLE authors(id INTEGER PRIMARY KEY, name TEXT, created_at TEXT, updated_at TEXT)") -db.execute("CREATE TABLE publishers(id INTEGER PRIMARY KEY, name TEXT, created_at TEXT, updated_at TEXT)") -db.execute("CREATE TABLE books(id INTEGER PRIMARY KEY, title TEXT, description TEXT, published_on TEXT, author_id INTEGER, publisher_id INTEGER, state TEXT, created_at TEXT, updated_at TEXT, active INTEGER)") -db.execute("CREATE TABLE reviews(id INTEGER PRIMARY KEY, book_id INTEGER)") -db.execute("CREATE TABLE comments(id INTEGER PRIMARY KEY, review_id INTEGER)") +db.execute('CREATE TABLE authors(id INTEGER PRIMARY KEY, name TEXT, created_at TEXT, updated_at TEXT)') +db.execute('CREATE TABLE publishers(id INTEGER PRIMARY KEY, name TEXT, created_at TEXT, updated_at TEXT)') +db.execute('CREATE TABLE books(id INTEGER PRIMARY KEY, title TEXT, description TEXT, published_on TEXT, author_id INTEGER, publisher_id INTEGER, state TEXT, created_at TEXT, updated_at TEXT, active INTEGER)') +db.execute('CREATE TABLE reviews(id INTEGER PRIMARY KEY, book_id INTEGER)') +db.execute('CREATE TABLE comments(id INTEGER PRIMARY KEY, review_id INTEGER)') # simulate action text -db.execute("CREATE TABLE rich_texts(id INTEGER PRIMARY KEY, body TEXT, record_id INTEGER, record_type TEXT, created_at TEXT, updated_at TEXT) ") +db.execute('CREATE TABLE rich_texts(id INTEGER PRIMARY KEY, body TEXT, record_id INTEGER, record_type TEXT, created_at TEXT, updated_at TEXT) ') # custom primary and foreing key columns -db.execute("CREATE TABLE users(user_id TEXT PRIMARY KEY DEFAULT (lower(hex(randomblob(16)))), name TEXT, created_at TEXT, updated_at TEXT)") -db.execute("CREATE TABLE posts(post_id TEXT PRIMARY KEY DEFAULT (lower(hex(randomblob(16)))), author_id TEXT, title TEXT, content TEXT, created_at TEXT, updated_at TEXT)") +db.execute('CREATE TABLE users(user_id TEXT PRIMARY KEY DEFAULT (lower(hex(randomblob(16)))), name TEXT, created_at TEXT, updated_at TEXT)') +db.execute('CREATE TABLE posts(post_id TEXT PRIMARY KEY DEFAULT (lower(hex(randomblob(16)))), author_id TEXT, title TEXT, content TEXT, created_at TEXT, updated_at TEXT)') class ApplicationRecord < ActiveRecord::Base primary_abstract_class @@ -57,12 +59,12 @@ class Book < ApplicationRecord include Litesearch::Model litesearch do |schema| - schema.fields [:description, :state] + schema.fields %i[description state] schema.field :publishing_year, col: :published_on schema.field :title, weight: 10 schema.field :ignored, weight: 0 - schema.field :author, target: "authors.name" - schema.field :publisher, target: "publishers.name", col: :publisher_id + schema.field :author, target: 'authors.name' + schema.field :publisher, target: 'publishers.name', col: :publisher_id schema.filter_column :active schema.tokenizer :porter end @@ -72,7 +74,7 @@ class RichText < ApplicationRecord belongs_to :record, polymorphic: true def self.table_name - "rich_texts" + 'rich_texts' end end @@ -88,7 +90,7 @@ class Review < ApplicationRecord include Litesearch::Model litesearch do |schema| - schema.field :body, target: "rich_texts.body", as: :record + schema.field :body, target: 'rich_texts.body', as: :record end end @@ -113,27 +115,27 @@ class Item < ApplicationRecord end class User < ApplicationRecord - self.primary_key = "user_id" + self.primary_key = 'user_id' has_many :posts, foreign_key: :author_id include Litesearch::Model litesearch do |schema| - schema.fields %w[ name ] - schema.primary_key :user_id + schema.fields %w[name] + schema.primary_key :user_id end end class Post < ApplicationRecord - self.primary_key = "post_id" + self.primary_key = 'post_id' - belongs_to :author, class_name: "User", primary_key: :user_id + belongs_to :author, class_name: 'User', primary_key: :user_id include Litesearch::Model litesearch do |schema| - schema.fields %w[ title content ] - schema.field :author, target: "users.name", primary_key: :user_id - schema.primary_key :post_id + schema.fields %w[title content] + schema.field :author, target: 'users.name', primary_key: :user_id + schema.primary_key :post_id end end @@ -146,33 +148,37 @@ def setup User.delete_all Post.delete_all Book.litesearch do |schema| - schema.fields [:description, :state] + schema.fields %i[description state] schema.field :publishing_year, col: :published_on schema.field :title, weight: 10 schema.field :ignored, weight: 0 - schema.field :author, target: "authors.name" - schema.field :publisher, target: "publishers.name", col: :publisher_id + schema.field :author, target: 'authors.name' + schema.field :publisher, target: 'publishers.name', col: :publisher_id schema.filter_column :active schema.tokenizer :porter end - Publisher.create(name: "Penguin") - Publisher.create(name: "Adams") - Publisher.create(name: "Flashy") - Author.create(name: "Hanna Spiegel") - Author.create(name: "David Antrop") - Author.create(name: "Aly Lotfy") - Author.create(name: "Osama Penguin") - Book.create(title: "In a middle of a night", description: "A tale of sleep", published_on: "2008-10-01", state: "available", active: true, publisher_id: 1, author_id: 1) - Book.create(title: "In a start of a night", description: "A tale of watching TV", published_on: "2006-08-08", state: "available", active: false, publisher_id: 2, author_id: 1) - u = User.create(name: "Gabriel") - Post.create(author: u, title: "Post #1", content: "Whenever you create a table without specifying the WITHOUT ROWID option, you get an implicit auto-increment column called rowid. The rowid column store 64-bit signed integer that uniquely identifies a row in the table.") - Post.create(author: u, title: "Post #2", content: "If a table has the primary key that consists of one column, and that column is defined as INTEGER then this primary key column becomes an alias for the rowid column.") + Publisher.create(name: 'Penguin') + Publisher.create(name: 'Adams') + Publisher.create(name: 'Flashy') + Author.create(name: 'Hanna Spiegel') + Author.create(name: 'David Antrop') + Author.create(name: 'Aly Lotfy') + Author.create(name: 'Osama Penguin') + Book.create(title: 'In a middle of a night', description: 'A tale of sleep', published_on: '2008-10-01', + state: 'available', active: true, publisher_id: 1, author_id: 1) + Book.create(title: 'In a start of a night', description: 'A tale of watching TV', published_on: '2006-08-08', + state: 'available', active: false, publisher_id: 2, author_id: 1) + u = User.create(name: 'Gabriel') + Post.create(author: u, title: 'Post #1', + content: 'Whenever you create a table without specifying the WITHOUT ROWID option, you get an implicit auto-increment column called rowid. The rowid column store 64-bit signed integer that uniquely identifies a row in the table.') + Post.create(author: u, title: 'Post #2', + content: 'If a table has the primary key that consists of one column, and that column is defined as INTEGER then this primary key column becomes an alias for the rowid column.') end def test_polymorphic review = Review.create(book_id: 1) - rt = RichText.create(record: review, body: "a new review") - rs = Review.search("review") + rt = RichText.create(record: review, body: 'a new review') + rs = Review.search('review') assert_equal 1, rs.length rt.destroy review.destroy @@ -180,10 +186,10 @@ def test_polymorphic def test_rich_text review = Review.create(book_id: 1) - rt = RichText.create(record: review, body: "a new review") + rt = RichText.create(record: review, body: 'a new review') comment = Comment.create(review: review) - ct = RichText.create(record: comment, body: "a new comment on the review") - rs = Comment.search("comment") + ct = RichText.create(record: comment, body: 'a new comment on the review') + rs = Comment.search('comment') assert_equal 1, rs.length ct.destroy comment.destroy @@ -192,90 +198,91 @@ def test_rich_text end def test_similar - newbook = Book.create(title: "A night", description: "A tale of watching TV", published_on: "2006-08-08", state: "available", active: true, publisher_id: 2, author_id: 2) + newbook = Book.create(title: 'A night', description: 'A tale of watching TV', published_on: '2006-08-08', + state: 'available', active: true, publisher_id: 2, author_id: 2) book = Book.find 1 books = book.similar assert_equal 1, books.length - assert_equal "A night", books.first.title + assert_equal 'A night', books.first.title newbook.destroy end def test_search - rs = Author.search("Hanna") + rs = Author.search('Hanna') assert_equal 1, rs.length assert_equal Author, rs[0].class end def test_search_custom_primary_key - rs = User.search("gabriel") + rs = User.search('gabriel') assert_equal 1, rs.length assert_equal User, rs[0].class end def test_search_field - rs = Book.search("author: Hanna") + rs = Book.search('author: Hanna') assert_equal 1, rs.length assert_equal Book, rs[0].class end def test_search_field_custom_primary_key - rs = Post.search("author: gabriel") + rs = Post.search('author: gabriel') assert_equal 2, rs.length assert_equal true, [Post, Post] - [rs[0].class, rs[1].class] == [] end def test_search_all - rs = Book.search_all("Hanna", {models: [Author, Book]}) + rs = Book.search_all('Hanna', { models: [Author, Book] }) assert_equal 2, rs.length assert_equal true, [Author, Book] - [rs[0].class, rs[1].class] == [] end def test_search_all_custom_primary_key - rs = Post.search_all("gabriel", {models: [Post, User]}) + rs = Post.search_all('gabriel', { models: [Post, User] }) assert_equal 3, rs.length assert_equal true, [Post, User, Post] - [rs[0].class, rs[1].class, rs[2].class] == [] end def test_modify_schema Book.litesearch do |schema| - schema.fields [:description, :state] + schema.fields %i[description state] schema.field :publishing_year, col: :published_on schema.field :title, weight: 10 schema.field :ignored, weight: 0 - schema.field :author, target: "authors.name" - schema.field :publisher, target: "publishers.name", col: :publisher_id + schema.field :author, target: 'authors.name' + schema.field :publisher, target: 'publishers.name', col: :publisher_id schema.rebuild_on_modify true end - rs = Book.search("night tale") + rs = Book.search('night tale') assert_equal 2, rs.length Book.rebuild_index! - rs = Book.search("night tale") + rs = Book.search('night tale') assert_equal 2, rs.length end def test_modify_schema_rebuild_later Book.litesearch do |schema| - schema.fields [:description, :state] + schema.fields %i[description state] schema.field :publishing_year, col: :published_on schema.field :title, weight: 10 schema.field :ignored, weight: 0 - schema.field :author, target: "authors.name" - schema.field :publisher, target: "publishers.name", col: :publisher_id + schema.field :author, target: 'authors.name' + schema.field :publisher, target: 'publishers.name', col: :publisher_id end - rs = Book.search("night tale") + rs = Book.search('night tale') assert_equal 1, rs.length Book.rebuild_index! - rs = Book.search("night tale") + rs = Book.search('night tale') assert_equal 2, rs.length end def test_update_referenced_column - rs = Book.search("Hanna") + rs = Book.search('Hanna') assert_equal 1, rs.length - Author.find(1).update(name: "Hayat") - rs = Book.search("Hanna") + Author.find(1).update(name: 'Hayat') + rs = Book.search('Hanna') assert_equal 0, rs.length - rs = Book.search("Hayat") + rs = Book.search('Hayat') assert_equal 1, rs.length end @@ -284,20 +291,20 @@ def test_rebuild_on_create schema.field :name schema.rebuild_on_create true end - rs = Publisher.search("Penguin") + rs = Publisher.search('Penguin') assert_equal 1, rs.length end def test_uncreated_table db = ActiveRecord::Base.connection.raw_connection - db.execute("CREATE TABLE items(id INTEGER PRIMARY KEY, name TEXT, created_at TEXT, updated_at TEXT)") - rs = Item.search("some") + db.execute('CREATE TABLE items(id INTEGER PRIMARY KEY, name TEXT, created_at TEXT, updated_at TEXT)') + rs = Item.search('some') assert_equal 0, rs.length - Item.create(name: "some item") - rs = Item.search("some") + Item.create(name: 'some item') + rs = Item.search('some') assert_equal 1, rs.length - Item.create(name: "another item") - rs = Item.search("item") + Item.create(name: 'another item') + rs = Item.search('item') assert_equal 2, rs.length end @@ -306,4 +313,24 @@ def test_ignore_tables # we have created 8 models, one ignore regex for each assert_equal 8, ActiveRecord::SchemaDumper.ignore_tables.count end + + def test_search_count + # Test that COUNT works on search results (this was previously broken) + search_results = Book.search('night') + # Only 1 book should be found because the second book has active: false + # and the schema has filter_column :active + assert_equal 1, search_results.length + + # Test COUNT method on search results + count_result = Book.search('night').count + assert_equal 1, count_result + + # Test COUNT with different search terms + count_result = Book.search('tale').count + assert_equal 1, count_result + + # Test COUNT with no results + count_result = Book.search('nonexistent').count + assert_equal 0, count_result + end end