typesense_model 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/MIT-LICENSE +21 -0
- data/README.md +208 -0
- data/lib/typesense_model/active_record_extension.rb +126 -0
- data/lib/typesense_model/base.rb +245 -0
- data/lib/typesense_model/configuration.rb +24 -0
- data/lib/typesense_model/schema.rb +39 -0
- data/lib/typesense_model/search.rb +95 -0
- data/lib/typesense_model/version.rb +3 -0
- data/lib/typesense_model.rb +25 -0
- metadata +109 -0
checksums.yaml
ADDED
@@ -0,0 +1,7 @@
|
|
1
|
+
---
|
2
|
+
SHA256:
|
3
|
+
metadata.gz: '0396e88e835a9892f58864d4598ec27ece27f4fc749b31df76639d4c71fbd34d'
|
4
|
+
data.tar.gz: 81028cc019492513b25c0628cbbd960693698fc6ba46add32e2f35f36c440d2b
|
5
|
+
SHA512:
|
6
|
+
metadata.gz: 7a056ab185ce24fa77e3419d9ec3bf3584dedafffd7a268fc0602fb785b8efea3c352e44d5831f013d0533535235246fa2d371c0a5548e8abee5372660931fbd
|
7
|
+
data.tar.gz: 82a7412801f57a83b001e6f4f8ff3a6d1e0ee40ecbd225424baa149d2968f60d80b66cbeb6be0a5715eee4a1ad66440c097617df64966e0c69e6f0ce11ac3506
|
data/MIT-LICENSE
ADDED
@@ -0,0 +1,21 @@
|
|
1
|
+
MIT License
|
2
|
+
|
3
|
+
Copyright (c) 2025 Ruby Dev SRL
|
4
|
+
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
7
|
+
in the Software without restriction, including without limitation the rights
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
10
|
+
furnished to do so, subject to the following conditions:
|
11
|
+
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
13
|
+
copies or substantial portions of the Software.
|
14
|
+
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
21
|
+
SOFTWARE.
|
data/README.md
ADDED
@@ -0,0 +1,208 @@
|
|
1
|
+
# TypesenseModel
|
2
|
+
|
3
|
+
A Ruby gem that provides seamless Typesense integration for ActiveRecord models with automatic syncing and search capabilities.
|
4
|
+
|
5
|
+
## Installation
|
6
|
+
|
7
|
+
Add to your Gemfile:
|
8
|
+
```ruby
|
9
|
+
gem 'typesense_model'
|
10
|
+
```
|
11
|
+
|
12
|
+
Or install directly:
|
13
|
+
```bash
|
14
|
+
$ gem install typesense_model
|
15
|
+
```
|
16
|
+
|
17
|
+
## Configuration
|
18
|
+
|
19
|
+
Initialize Typesense connection in your Rails application:
|
20
|
+
|
21
|
+
```ruby
|
22
|
+
# config/initializers/typesense.rb
|
23
|
+
TypesenseModel.configure do |config|
|
24
|
+
config.api_key = 'your_api_key'
|
25
|
+
config.host = 'localhost'
|
26
|
+
config.port = 8108
|
27
|
+
config.protocol = 'http'
|
28
|
+
end
|
29
|
+
```
|
30
|
+
|
31
|
+
## ActiveRecord Integration
|
32
|
+
|
33
|
+
The gem automatically extends ActiveRecord models with Typesense capabilities. Simply add `uses_typesense` to any model:
|
34
|
+
|
35
|
+
```ruby
|
36
|
+
class Product < ApplicationRecord
|
37
|
+
uses_typesense collection: 'products' do
|
38
|
+
field :id, :string
|
39
|
+
field :name, :string
|
40
|
+
field :description, :string, optional: true, index: false
|
41
|
+
field :price, :float, sort: true
|
42
|
+
field :categories, "string[]", optional: true, facet: true
|
43
|
+
field :tags, "string[]", optional: true, facet: true
|
44
|
+
field :brand, :string, facet: true
|
45
|
+
field :in_stock, :bool, facet: true
|
46
|
+
field :created_at, :int64, sort: true
|
47
|
+
field :updated_at, :int64, sort: true
|
48
|
+
end
|
49
|
+
|
50
|
+
# Optional: Custom JSON serialization for Typesense
|
51
|
+
def as_json_typesense
|
52
|
+
{
|
53
|
+
id: id,
|
54
|
+
name: name,
|
55
|
+
description: description,
|
56
|
+
price: price,
|
57
|
+
categories: categories,
|
58
|
+
tags: tags,
|
59
|
+
brand: brand,
|
60
|
+
in_stock: in_stock,
|
61
|
+
created_at: created_at.to_i,
|
62
|
+
updated_at: updated_at.to_i
|
63
|
+
}
|
64
|
+
end
|
65
|
+
end
|
66
|
+
```
|
67
|
+
|
68
|
+
## Features
|
69
|
+
|
70
|
+
### Automatic Syncing
|
71
|
+
When you save or destroy ActiveRecord records, they automatically sync to Typesense:
|
72
|
+
|
73
|
+
```ruby
|
74
|
+
# Create a product - automatically synced to Typesense
|
75
|
+
product = Product.create!(
|
76
|
+
name: "iPhone 15",
|
77
|
+
price: 999.99,
|
78
|
+
categories: ["Electronics", "Phones"],
|
79
|
+
brand: "Apple",
|
80
|
+
in_stock: true
|
81
|
+
)
|
82
|
+
|
83
|
+
# Update a product - automatically synced to Typesense
|
84
|
+
product.update!(price: 899.99)
|
85
|
+
|
86
|
+
# Destroy a product - automatically removed from Typesense
|
87
|
+
product.destroy!
|
88
|
+
```
|
89
|
+
|
90
|
+
### Search Capabilities
|
91
|
+
Search your ActiveRecord models using Typesense:
|
92
|
+
|
93
|
+
```ruby
|
94
|
+
# Basic search
|
95
|
+
results = Product.search("iPhone")
|
96
|
+
|
97
|
+
# Advanced search with filters and sorting
|
98
|
+
results = Product.search("smartphone",
|
99
|
+
filter_by: "brand:Apple && price:< 1000",
|
100
|
+
sort_by: "price:asc",
|
101
|
+
per_page: 20,
|
102
|
+
page: 1
|
103
|
+
)
|
104
|
+
|
105
|
+
# Search with facets
|
106
|
+
results = Product.search("phone")
|
107
|
+
results.facet_values("brand") # Get brand facet counts
|
108
|
+
results.facet_values("categories") # Get category facet counts
|
109
|
+
```
|
110
|
+
|
111
|
+
### Access Typesense Documents
|
112
|
+
Get the Typesense document for any ActiveRecord instance:
|
113
|
+
|
114
|
+
```ruby
|
115
|
+
product = Product.find(123)
|
116
|
+
typesense_doc = product.typesense_model
|
117
|
+
# Returns a TypesenseModel::Base instance with the Typesense document data
|
118
|
+
```
|
119
|
+
|
120
|
+
## Schema Options
|
121
|
+
|
122
|
+
Field options available in the schema definition:
|
123
|
+
|
124
|
+
- `optional: true` - Field is optional
|
125
|
+
- `index: false` - Field is not indexed (not searchable)
|
126
|
+
- `facet: true` - Field can be used for faceted search
|
127
|
+
- `sort: true` - Field can be used for sorting
|
128
|
+
- `default_sort: true` - Field is the default sorting field
|
129
|
+
|
130
|
+
## Custom JSON Serialization
|
131
|
+
|
132
|
+
You can customize how your model data is serialized for Typesense:
|
133
|
+
|
134
|
+
```ruby
|
135
|
+
class Product < ApplicationRecord
|
136
|
+
uses_typesense collection: 'products', model_json: :to_typesense_hash do
|
137
|
+
# schema definition
|
138
|
+
end
|
139
|
+
|
140
|
+
def to_typesense_hash
|
141
|
+
{
|
142
|
+
id: id,
|
143
|
+
name: name,
|
144
|
+
price: price,
|
145
|
+
# Add computed fields
|
146
|
+
searchable_text: "#{name} #{description} #{brand}".downcase,
|
147
|
+
price_range: case price
|
148
|
+
when 0..100 then "budget"
|
149
|
+
when 101..500 then "mid-range"
|
150
|
+
else "premium"
|
151
|
+
end
|
152
|
+
}
|
153
|
+
end
|
154
|
+
end
|
155
|
+
```
|
156
|
+
|
157
|
+
Or use a Proc for dynamic serialization:
|
158
|
+
|
159
|
+
```ruby
|
160
|
+
class Product < ApplicationRecord
|
161
|
+
uses_typesense collection: 'products',
|
162
|
+
model_json: ->(record) { record.as_json.merge(computed_field: record.compute_something) } do
|
163
|
+
# schema definition
|
164
|
+
end
|
165
|
+
end
|
166
|
+
```
|
167
|
+
|
168
|
+
## Collection Management
|
169
|
+
|
170
|
+
Create and manage Typesense collections:
|
171
|
+
|
172
|
+
```ruby
|
173
|
+
# Create the collection in Typesense
|
174
|
+
Product.search("") # This will create the collection if it doesn't exist
|
175
|
+
|
176
|
+
# Or explicitly create/update
|
177
|
+
proxy = TypesenseModel::ActiveRecordExtension::TypesenseProxy.for(Product)
|
178
|
+
proxy.create_collection
|
179
|
+
proxy.update_collection
|
180
|
+
proxy.delete_collection
|
181
|
+
```
|
182
|
+
|
183
|
+
## Import Existing Data
|
184
|
+
|
185
|
+
Import existing ActiveRecord records to Typesense without N+1 queries:
|
186
|
+
|
187
|
+
```ruby
|
188
|
+
# Basic import (default batch_size: 1000)
|
189
|
+
results = Product.import_all_to_typesense
|
190
|
+
|
191
|
+
# With preloads to avoid N+1
|
192
|
+
results = Product.import_all_to_typesense(preloads: [:brand, :images])
|
193
|
+
|
194
|
+
# With custom transformer and options
|
195
|
+
results = Product.import_all_to_typesense(
|
196
|
+
preloads: { variants: [:prices, :stock_items] },
|
197
|
+
transform: :to_typesense_hash,
|
198
|
+
batch_size: 2000,
|
199
|
+
import_options: { action: 'upsert' }
|
200
|
+
)
|
201
|
+
|
202
|
+
# results => { success: 500, failed: 0, errors: [...] }
|
203
|
+
```
|
204
|
+
|
205
|
+
## License
|
206
|
+
|
207
|
+
Available as open source under the MIT License.
|
208
|
+
Copyright (c) 2025 Ruby Dev SRL
|
@@ -0,0 +1,126 @@
|
|
1
|
+
module TypesenseModel
|
2
|
+
module ActiveRecordExtension
|
3
|
+
def self.included(base)
|
4
|
+
base.extend ClassMethods
|
5
|
+
end
|
6
|
+
|
7
|
+
module ClassMethods
|
8
|
+
# Usage: uses_typesense collection: 'plugs', model_json: :as_json, schema: ->(s) { s.field :id, :string }
|
9
|
+
def uses_typesense(collection: nil, model_json: :as_json_typesense, schema: nil, &block)
|
10
|
+
@_typesense_collection_name = collection || name.underscore.pluralize
|
11
|
+
@_typesense_model_json_method = model_json
|
12
|
+
|
13
|
+
if schema
|
14
|
+
@_typesense_schema = Schema.new
|
15
|
+
schema.call(@_typesense_schema)
|
16
|
+
elsif block_given?
|
17
|
+
@_typesense_schema = Schema.new
|
18
|
+
@_typesense_schema.instance_eval(&block)
|
19
|
+
end
|
20
|
+
|
21
|
+
define_singleton_method(:typesense_collection_name) do
|
22
|
+
@_typesense_collection_name
|
23
|
+
end
|
24
|
+
|
25
|
+
define_singleton_method(:typesense_schema) do
|
26
|
+
@_typesense_schema
|
27
|
+
end
|
28
|
+
|
29
|
+
define_singleton_method(:typesense_model_json_method) do
|
30
|
+
@_typesense_model_json_method
|
31
|
+
end
|
32
|
+
|
33
|
+
define_singleton_method(:search) do |query, options = {}|
|
34
|
+
proxy = TypesenseProxy.for(self)
|
35
|
+
proxy.search(query, options)
|
36
|
+
end
|
37
|
+
|
38
|
+
define_method(:typesense_model) do
|
39
|
+
TypesenseProxy.for(self.class).find(id)
|
40
|
+
end
|
41
|
+
|
42
|
+
# Add callbacks for automatic syncing
|
43
|
+
after_save :sync_to_typesense
|
44
|
+
after_destroy :remove_from_typesense
|
45
|
+
end
|
46
|
+
|
47
|
+
# Import all records of this AR model into Typesense
|
48
|
+
# @param batch_size [Integer] number of records per batch
|
49
|
+
# @param transform [Symbol, Proc, nil] method or proc to generate document JSON
|
50
|
+
# @param preloads [Array, Symbol, Hash, nil] associations to preload to avoid N+1
|
51
|
+
# @param import_options [Hash] options passed to Typesense import
|
52
|
+
# @return [Hash] { success: Integer, failed: Integer }
|
53
|
+
def import_all_to_typesense(batch_size: 1000, transform: nil, preloads: nil, import_options: {})
|
54
|
+
proxy = TypesenseProxy.for(self)
|
55
|
+
# Ensure collection exists and schema is up-to-date before import
|
56
|
+
proxy.create_collection unless proxy.collection_exists?
|
57
|
+
transformer = transform || (respond_to?(:typesense_model_json_method) ? typesense_model_json_method : :as_json_typesense)
|
58
|
+
proxy.import_from_model(self, batch_size, transformer, preloads, import_options)
|
59
|
+
end
|
60
|
+
end
|
61
|
+
|
62
|
+
# Instance methods for callbacks
|
63
|
+
def sync_to_typesense
|
64
|
+
return unless self.class.respond_to?(:typesense_model_json_method)
|
65
|
+
|
66
|
+
json_method = self.class.typesense_model_json_method
|
67
|
+
document_data = if json_method.is_a?(Proc)
|
68
|
+
json_method.call(self)
|
69
|
+
else
|
70
|
+
respond_to?(json_method) ? send(json_method) : as_json_typesense
|
71
|
+
end
|
72
|
+
|
73
|
+
proxy = TypesenseProxy.for(self.class)
|
74
|
+
sanitized = proxy.send(:sanitize_document, stringify_keys(document_data))
|
75
|
+
proxy.client.collections[proxy.collection_name].documents.upsert(sanitized)
|
76
|
+
rescue => e
|
77
|
+
Rails.logger.error "Failed to sync #{self.class.name}##{id} to Typesense: #{e.message}" if defined?(Rails)
|
78
|
+
end
|
79
|
+
|
80
|
+
def remove_from_typesense
|
81
|
+
return unless self.class.respond_to?(:typesense_model_json_method)
|
82
|
+
|
83
|
+
proxy = TypesenseProxy.for(self.class)
|
84
|
+
proxy.client.collections[proxy.collection_name].documents[id].delete
|
85
|
+
rescue => e
|
86
|
+
Rails.logger.error "Failed to remove #{self.class.name}##{id} from Typesense: #{e.message}" if defined?(Rails)
|
87
|
+
end
|
88
|
+
|
89
|
+
# Default JSON method for Typesense
|
90
|
+
def as_json_typesense
|
91
|
+
as_json
|
92
|
+
end
|
93
|
+
|
94
|
+
private
|
95
|
+
|
96
|
+
def stringify_keys(hash)
|
97
|
+
return hash unless hash.is_a?(Hash)
|
98
|
+
hash.each_with_object({}) { |(k, v), h| h[k.to_s] = v }
|
99
|
+
end
|
100
|
+
|
101
|
+
# Simple adapter that maps an AR model class into a TypesenseModel::Base-like class
|
102
|
+
class TypesenseProxy < TypesenseModel::Base
|
103
|
+
class << self
|
104
|
+
def for(ar_class)
|
105
|
+
@ar_class = ar_class
|
106
|
+
collection_name(ar_class.respond_to?(:typesense_collection_name) ? ar_class.typesense_collection_name : ar_class.name.underscore.pluralize)
|
107
|
+
|
108
|
+
if ar_class.respond_to?(:typesense_schema) && ar_class.typesense_schema
|
109
|
+
@_schema_definition = ar_class.typesense_schema
|
110
|
+
end
|
111
|
+
|
112
|
+
self
|
113
|
+
end
|
114
|
+
|
115
|
+
def ar_class
|
116
|
+
@ar_class
|
117
|
+
end
|
118
|
+
|
119
|
+
def client
|
120
|
+
TypesenseModel.configuration.client
|
121
|
+
end
|
122
|
+
end
|
123
|
+
end
|
124
|
+
end
|
125
|
+
end
|
126
|
+
|
@@ -0,0 +1,245 @@
|
|
1
|
+
module TypesenseModel
|
2
|
+
class Base
|
3
|
+
class << self
|
4
|
+
attr_accessor :_collection_name, :_schema_definition
|
5
|
+
|
6
|
+
def collection_name(name = nil)
|
7
|
+
if name
|
8
|
+
@_collection_name = name
|
9
|
+
else
|
10
|
+
@_collection_name ||= self.name.underscore.pluralize
|
11
|
+
end
|
12
|
+
end
|
13
|
+
|
14
|
+
def define_schema(&block)
|
15
|
+
@_schema_definition = Schema.new
|
16
|
+
@_schema_definition.instance_eval(&block)
|
17
|
+
end
|
18
|
+
|
19
|
+
def schema_definition
|
20
|
+
@_schema_definition
|
21
|
+
end
|
22
|
+
|
23
|
+
def create(attributes = {})
|
24
|
+
new(attributes).save
|
25
|
+
end
|
26
|
+
|
27
|
+
def find(id)
|
28
|
+
response = client.collections[collection_name].documents[id].retrieve
|
29
|
+
new(response)
|
30
|
+
rescue Typesense::Error::ObjectNotFound
|
31
|
+
nil
|
32
|
+
end
|
33
|
+
|
34
|
+
def search(query, options = {})
|
35
|
+
Search.new(self, query, options).execute
|
36
|
+
end
|
37
|
+
|
38
|
+
# Create the collection in Typesense
|
39
|
+
def create_collection(force = false)
|
40
|
+
delete_collection if force
|
41
|
+
return if collection_exists?
|
42
|
+
|
43
|
+
schema = schema_definition.to_hash.merge(
|
44
|
+
name: collection_name
|
45
|
+
)
|
46
|
+
|
47
|
+
client.collections.create(schema)
|
48
|
+
end
|
49
|
+
|
50
|
+
# Delete the collection from Typesense
|
51
|
+
def delete_collection
|
52
|
+
client.collections[collection_name].delete if collection_exists?
|
53
|
+
end
|
54
|
+
|
55
|
+
# Check if collection exists
|
56
|
+
def collection_exists?
|
57
|
+
client.collections[collection_name].retrieve
|
58
|
+
true
|
59
|
+
rescue Typesense::Error::ObjectNotFound
|
60
|
+
false
|
61
|
+
end
|
62
|
+
|
63
|
+
# Update the collection schema in Typesense
|
64
|
+
def update_collection
|
65
|
+
return create_collection unless collection_exists?
|
66
|
+
|
67
|
+
# Typesense only allows updating `fields` (and `metadata`).
|
68
|
+
# Do not send `name` or `default_sorting_field` on update.
|
69
|
+
# Exclude the implicit `id` field from updates (Typesense does not allow altering it)
|
70
|
+
updated_fields = (schema_definition.to_hash[:fields] || []).reject { |f| f[:name] == 'id' || f['name'] == 'id' }
|
71
|
+
|
72
|
+
update_payload = { fields: updated_fields }.compact
|
73
|
+
|
74
|
+
client.collections[collection_name].update(update_payload)
|
75
|
+
end
|
76
|
+
|
77
|
+
# Create or update collection
|
78
|
+
def create_or_update_collection
|
79
|
+
collection_exists? ? update_collection : create_collection
|
80
|
+
end
|
81
|
+
|
82
|
+
# Retrieve collection details
|
83
|
+
def retrieve_collection
|
84
|
+
return nil unless collection_exists?
|
85
|
+
client.collections[collection_name].retrieve
|
86
|
+
end
|
87
|
+
|
88
|
+
# Get collection stats
|
89
|
+
def collection_stats
|
90
|
+
return nil unless collection_exists?
|
91
|
+
client.collections[collection_name].stats
|
92
|
+
end
|
93
|
+
|
94
|
+
# Get number of documents in collection
|
95
|
+
def count
|
96
|
+
collection_stats&.dig('num_documents') || 0
|
97
|
+
end
|
98
|
+
|
99
|
+
# Import multiple records
|
100
|
+
# @return [Hash] { success: Integer, failed: Integer }
|
101
|
+
def import(documents, options = {})
|
102
|
+
sanitized_documents = Array(documents).map { |doc| sanitize_document(doc) }
|
103
|
+
|
104
|
+
response = client.collections[collection_name]
|
105
|
+
.documents
|
106
|
+
.import(sanitized_documents, options)
|
107
|
+
|
108
|
+
results = response.each_with_object({ success: 0, failed: 0, errors: [] }) do |result, counts|
|
109
|
+
if result['success']
|
110
|
+
counts[:success] += 1
|
111
|
+
else
|
112
|
+
counts[:failed] += 1
|
113
|
+
counts[:errors] << {
|
114
|
+
code: result['code'],
|
115
|
+
error: result['error'],
|
116
|
+
document: result['document']
|
117
|
+
}
|
118
|
+
end
|
119
|
+
end
|
120
|
+
|
121
|
+
results
|
122
|
+
end
|
123
|
+
|
124
|
+
# Import records from an ActiveRecord model
|
125
|
+
# @param model_class [Class] The ActiveRecord model class to import from
|
126
|
+
# @param batch_size [Integer] Number of records to fetch per batch
|
127
|
+
# @param transform_method [Symbol, Proc] Method or Proc to transform records
|
128
|
+
# @param preloads [Array, Symbol, Hash, nil] Associations to preload to avoid N+1
|
129
|
+
# @param import_options [Hash] Options to pass to the import method
|
130
|
+
# @return [Hash] { success: Integer, failed: Integer }
|
131
|
+
def import_from_model(model_class, batch_size, transform_method = :as_json, preloads = nil, import_options = {})
|
132
|
+
total_results = { success: 0, failed: 0 }
|
133
|
+
|
134
|
+
transformer = transform_method.is_a?(Proc) ? transform_method : ->(record) { record.send(transform_method) }
|
135
|
+
|
136
|
+
relation = model_class.all
|
137
|
+
relation = relation.preload(preloads) if preloads
|
138
|
+
|
139
|
+
relation.find_in_batches(batch_size: batch_size) do |batch|
|
140
|
+
documents = batch.map(&transformer)
|
141
|
+
results = import(documents, import_options)
|
142
|
+
|
143
|
+
total_results[:success] += results[:success]
|
144
|
+
total_results[:failed] += results[:failed]
|
145
|
+
if results[:errors].is_a?(Array) && results[:errors].any?
|
146
|
+
(total_results[:errors] ||= []).concat(results[:errors])
|
147
|
+
end
|
148
|
+
end
|
149
|
+
|
150
|
+
total_results
|
151
|
+
end
|
152
|
+
|
153
|
+
# Delete a record by ID
|
154
|
+
def delete(id)
|
155
|
+
client.collections[collection_name]
|
156
|
+
.documents[id]
|
157
|
+
.delete
|
158
|
+
rescue Typesense::Error::ObjectNotFound
|
159
|
+
false
|
160
|
+
end
|
161
|
+
|
162
|
+
# Delete multiple records by query
|
163
|
+
def delete_by(filter_by)
|
164
|
+
client.collections[collection_name]
|
165
|
+
.documents
|
166
|
+
.delete({ filter_by: filter_by })
|
167
|
+
end
|
168
|
+
|
169
|
+
private
|
170
|
+
|
171
|
+
def client
|
172
|
+
TypesenseModel.configuration.client
|
173
|
+
end
|
174
|
+
|
175
|
+
# Keep only fields defined in schema (plus 'id'), and coerce id to string
|
176
|
+
def sanitize_document(document)
|
177
|
+
return document unless schema_definition
|
178
|
+
|
179
|
+
allowed = schema_definition.fields.map { |f| f[:name] } + ['id']
|
180
|
+
sanitized = document.select { |k, _| allowed.include?(k.to_s) }
|
181
|
+
sanitized['id'] = sanitized['id'].to_s if sanitized.key?('id')
|
182
|
+
sanitized
|
183
|
+
end
|
184
|
+
end
|
185
|
+
|
186
|
+
attr_accessor :attributes
|
187
|
+
|
188
|
+
def initialize(attributes = {})
|
189
|
+
@attributes = attributes.transform_keys(&:to_s)
|
190
|
+
end
|
191
|
+
|
192
|
+
def save
|
193
|
+
response = self.class.send(:client).collections[self.class.collection_name].documents.upsert(attributes)
|
194
|
+
|
195
|
+
@attributes = response.transform_keys(&:to_s)
|
196
|
+
self
|
197
|
+
end
|
198
|
+
|
199
|
+
def id
|
200
|
+
attributes['id']
|
201
|
+
end
|
202
|
+
|
203
|
+
def method_missing(method_name, *args)
|
204
|
+
attribute_name = method_name.to_s
|
205
|
+
|
206
|
+
# Handle setters (e.g., name=)
|
207
|
+
if attribute_name.end_with?('=')
|
208
|
+
attribute_name = attribute_name.chop # Remove the '=' from the end
|
209
|
+
return set_attribute(attribute_name, args.first)
|
210
|
+
end
|
211
|
+
|
212
|
+
# Handle getters (e.g., name)
|
213
|
+
if attributes.key?(attribute_name)
|
214
|
+
return attributes[attribute_name]
|
215
|
+
end
|
216
|
+
|
217
|
+
nil
|
218
|
+
end
|
219
|
+
|
220
|
+
def respond_to_missing?(method_name, include_private = false)
|
221
|
+
attribute_name = method_name.to_s
|
222
|
+
return true if attribute_name.end_with?('=') && attributes.key?(attribute_name.chop)
|
223
|
+
return true if attributes.key?(attribute_name)
|
224
|
+
super
|
225
|
+
end
|
226
|
+
|
227
|
+
private
|
228
|
+
|
229
|
+
def set_attribute(name, value)
|
230
|
+
attributes[name.to_s] = value
|
231
|
+
end
|
232
|
+
|
233
|
+
def client
|
234
|
+
self.class.send(:client)
|
235
|
+
end
|
236
|
+
|
237
|
+
# Instance method to delete the current record
|
238
|
+
def delete
|
239
|
+
return false unless id
|
240
|
+
|
241
|
+
response = self.class.delete(id)
|
242
|
+
!response.nil?
|
243
|
+
end
|
244
|
+
end
|
245
|
+
end
|
@@ -0,0 +1,24 @@
|
|
1
|
+
module TypesenseModel
|
2
|
+
class Configuration
|
3
|
+
attr_accessor :api_key, :host, :port, :protocol
|
4
|
+
|
5
|
+
def initialize
|
6
|
+
@api_key = nil
|
7
|
+
@host = 'localhost'
|
8
|
+
@port = 8108
|
9
|
+
@protocol = 'http'
|
10
|
+
end
|
11
|
+
|
12
|
+
def client
|
13
|
+
@client ||= Typesense::Client.new(
|
14
|
+
api_key: api_key,
|
15
|
+
nodes: [{
|
16
|
+
host: host,
|
17
|
+
port: port,
|
18
|
+
protocol: protocol
|
19
|
+
}],
|
20
|
+
connection_timeout_seconds: 5
|
21
|
+
)
|
22
|
+
end
|
23
|
+
end
|
24
|
+
end
|
@@ -0,0 +1,39 @@
|
|
1
|
+
module TypesenseModel
|
2
|
+
class Schema
|
3
|
+
attr_reader :fields, :collection_name, :default_sorting_field
|
4
|
+
|
5
|
+
def initialize(collection_name = nil)
|
6
|
+
@fields = []
|
7
|
+
@collection_name = collection_name
|
8
|
+
@default_sorting_field = nil
|
9
|
+
end
|
10
|
+
|
11
|
+
def field(name, type, options = {})
|
12
|
+
@fields << {
|
13
|
+
name: name.to_s,
|
14
|
+
type: type.to_s,
|
15
|
+
facet: options[:facet] || false,
|
16
|
+
optional: options[:optional] || false,
|
17
|
+
index: options[:index].nil? ? true : options[:index],
|
18
|
+
sort: options[:sort] || false
|
19
|
+
}
|
20
|
+
|
21
|
+
# Set as default sorting field if specified
|
22
|
+
@default_sorting_field = name.to_s if options[:default_sort]
|
23
|
+
end
|
24
|
+
|
25
|
+
def to_hash
|
26
|
+
{
|
27
|
+
name: @collection_name,
|
28
|
+
fields: @fields,
|
29
|
+
default_sorting_field: @default_sorting_field
|
30
|
+
}.compact
|
31
|
+
end
|
32
|
+
|
33
|
+
private
|
34
|
+
|
35
|
+
def default_sorting_field
|
36
|
+
@fields.find { |f| f[:name] == 'id' }&.dig(:name)
|
37
|
+
end
|
38
|
+
end
|
39
|
+
end
|
@@ -0,0 +1,95 @@
|
|
1
|
+
module TypesenseModel
|
2
|
+
class Search
|
3
|
+
def initialize(model_class, query, options = {})
|
4
|
+
@model_class = model_class
|
5
|
+
@query = query
|
6
|
+
@options = options
|
7
|
+
end
|
8
|
+
|
9
|
+
def execute
|
10
|
+
search_parameters = {
|
11
|
+
q: @query,
|
12
|
+
query_by: @options[:query_by] || default_queryable_fields,
|
13
|
+
per_page: @options[:per_page] || 10,
|
14
|
+
page: @options[:page] || 1
|
15
|
+
}.merge(@options.except(:query_by, :per_page, :page))
|
16
|
+
|
17
|
+
response = @model_class.send(:client)
|
18
|
+
.collections[@model_class.collection_name]
|
19
|
+
.documents
|
20
|
+
.search(search_parameters)
|
21
|
+
|
22
|
+
SearchResults.new(response, @model_class)
|
23
|
+
end
|
24
|
+
|
25
|
+
private
|
26
|
+
|
27
|
+
def default_queryable_fields
|
28
|
+
@model_class.schema_definition.fields
|
29
|
+
.select { |f| f[:index] }
|
30
|
+
.select { |f| f[:type] == 'string' }
|
31
|
+
.reject { |f| f[:name] == 'id' }
|
32
|
+
.map { |f| f[:name] }
|
33
|
+
.join(',')
|
34
|
+
end
|
35
|
+
end
|
36
|
+
|
37
|
+
class SearchResults
|
38
|
+
include Enumerable
|
39
|
+
|
40
|
+
attr_reader :raw_response
|
41
|
+
|
42
|
+
def initialize(response, model_class)
|
43
|
+
@raw_response = response
|
44
|
+
@model_class = model_class
|
45
|
+
end
|
46
|
+
|
47
|
+
def each(&block)
|
48
|
+
hits.each do |hit|
|
49
|
+
yield @model_class.new(hit['document'])
|
50
|
+
end
|
51
|
+
end
|
52
|
+
|
53
|
+
def map(&block)
|
54
|
+
hits.map do |hit|
|
55
|
+
block.call(@model_class.new(hit['document']))
|
56
|
+
end
|
57
|
+
end
|
58
|
+
|
59
|
+
def hits
|
60
|
+
@raw_response['hits'] || []
|
61
|
+
end
|
62
|
+
|
63
|
+
def size
|
64
|
+
total_hits
|
65
|
+
end
|
66
|
+
|
67
|
+
def total_hits
|
68
|
+
@raw_response['found'] || 0
|
69
|
+
end
|
70
|
+
# PAGY COMPATIBILITY
|
71
|
+
def count(_)
|
72
|
+
total_hits
|
73
|
+
end
|
74
|
+
def offset(_)
|
75
|
+
self
|
76
|
+
end
|
77
|
+
def limit(_)
|
78
|
+
self
|
79
|
+
end
|
80
|
+
|
81
|
+
def facets
|
82
|
+
@raw_response['facet_counts'] || []
|
83
|
+
end
|
84
|
+
|
85
|
+
# Get a specific facet by field name
|
86
|
+
def facet(field_name)
|
87
|
+
facets.find { |f| f['field_name'] == field_name.to_s }
|
88
|
+
end
|
89
|
+
|
90
|
+
# Get facet values for a specific field
|
91
|
+
def facet_values(field_name)
|
92
|
+
facet(field_name)&.fetch('counts', []) || []
|
93
|
+
end
|
94
|
+
end
|
95
|
+
end
|
@@ -0,0 +1,25 @@
|
|
1
|
+
require "typesense"
|
2
|
+
require "typesense_model/version"
|
3
|
+
require "typesense_model/base"
|
4
|
+
require "typesense_model/search"
|
5
|
+
require "typesense_model/schema"
|
6
|
+
require "typesense_model/configuration"
|
7
|
+
require "typesense_model/active_record_extension"
|
8
|
+
|
9
|
+
module TypesenseModel
|
10
|
+
class Error < StandardError; end
|
11
|
+
|
12
|
+
class << self
|
13
|
+
attr_accessor :configuration
|
14
|
+
end
|
15
|
+
|
16
|
+
def self.configure
|
17
|
+
self.configuration ||= Configuration.new
|
18
|
+
yield(configuration) if block_given?
|
19
|
+
end
|
20
|
+
end
|
21
|
+
|
22
|
+
# Auto-include into ActiveRecord if available
|
23
|
+
if defined?(ActiveRecord::Base)
|
24
|
+
ActiveRecord::Base.include(TypesenseModel::ActiveRecordExtension)
|
25
|
+
end
|
metadata
ADDED
@@ -0,0 +1,109 @@
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
2
|
+
name: typesense_model
|
3
|
+
version: !ruby/object:Gem::Version
|
4
|
+
version: 0.2.0
|
5
|
+
platform: ruby
|
6
|
+
authors:
|
7
|
+
- Emanuel Comsa
|
8
|
+
bindir: bin
|
9
|
+
cert_chain: []
|
10
|
+
date: 1980-01-02 00:00:00.000000000 Z
|
11
|
+
dependencies:
|
12
|
+
- !ruby/object:Gem::Dependency
|
13
|
+
name: typesense
|
14
|
+
requirement: !ruby/object:Gem::Requirement
|
15
|
+
requirements:
|
16
|
+
- - "~>"
|
17
|
+
- !ruby/object:Gem::Version
|
18
|
+
version: 2.1.0
|
19
|
+
type: :runtime
|
20
|
+
prerelease: false
|
21
|
+
version_requirements: !ruby/object:Gem::Requirement
|
22
|
+
requirements:
|
23
|
+
- - "~>"
|
24
|
+
- !ruby/object:Gem::Version
|
25
|
+
version: 2.1.0
|
26
|
+
- !ruby/object:Gem::Dependency
|
27
|
+
name: activesupport
|
28
|
+
requirement: !ruby/object:Gem::Requirement
|
29
|
+
requirements:
|
30
|
+
- - ">="
|
31
|
+
- !ruby/object:Gem::Version
|
32
|
+
version: '5.0'
|
33
|
+
type: :runtime
|
34
|
+
prerelease: false
|
35
|
+
version_requirements: !ruby/object:Gem::Requirement
|
36
|
+
requirements:
|
37
|
+
- - ">="
|
38
|
+
- !ruby/object:Gem::Version
|
39
|
+
version: '5.0'
|
40
|
+
- !ruby/object:Gem::Dependency
|
41
|
+
name: rake
|
42
|
+
requirement: !ruby/object:Gem::Requirement
|
43
|
+
requirements:
|
44
|
+
- - "~>"
|
45
|
+
- !ruby/object:Gem::Version
|
46
|
+
version: '13.0'
|
47
|
+
type: :development
|
48
|
+
prerelease: false
|
49
|
+
version_requirements: !ruby/object:Gem::Requirement
|
50
|
+
requirements:
|
51
|
+
- - "~>"
|
52
|
+
- !ruby/object:Gem::Version
|
53
|
+
version: '13.0'
|
54
|
+
- !ruby/object:Gem::Dependency
|
55
|
+
name: rspec
|
56
|
+
requirement: !ruby/object:Gem::Requirement
|
57
|
+
requirements:
|
58
|
+
- - "~>"
|
59
|
+
- !ruby/object:Gem::Version
|
60
|
+
version: '3.0'
|
61
|
+
type: :development
|
62
|
+
prerelease: false
|
63
|
+
version_requirements: !ruby/object:Gem::Requirement
|
64
|
+
requirements:
|
65
|
+
- - "~>"
|
66
|
+
- !ruby/object:Gem::Version
|
67
|
+
version: '3.0'
|
68
|
+
description: A Ruby gem that provides an ActiveModel-like interface for working with
|
69
|
+
Typesense search engine
|
70
|
+
email:
|
71
|
+
- office@rubydev.ro
|
72
|
+
executables: []
|
73
|
+
extensions: []
|
74
|
+
extra_rdoc_files: []
|
75
|
+
files:
|
76
|
+
- MIT-LICENSE
|
77
|
+
- README.md
|
78
|
+
- lib/typesense_model.rb
|
79
|
+
- lib/typesense_model/active_record_extension.rb
|
80
|
+
- lib/typesense_model/base.rb
|
81
|
+
- lib/typesense_model/configuration.rb
|
82
|
+
- lib/typesense_model/schema.rb
|
83
|
+
- lib/typesense_model/search.rb
|
84
|
+
- lib/typesense_model/version.rb
|
85
|
+
homepage: https://github.com/rubydevro/typesense_model
|
86
|
+
licenses:
|
87
|
+
- MIT
|
88
|
+
metadata:
|
89
|
+
homepage_uri: https://www.rubydev.ro
|
90
|
+
source_code_uri: https://github.com/rubydevro/typesense_model
|
91
|
+
changelog_uri: https://github.com/rubydevro/typesense_model/blob/main/CHANGELOG.md
|
92
|
+
rdoc_options: []
|
93
|
+
require_paths:
|
94
|
+
- lib
|
95
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
96
|
+
requirements:
|
97
|
+
- - ">="
|
98
|
+
- !ruby/object:Gem::Version
|
99
|
+
version: 2.6.0
|
100
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
101
|
+
requirements:
|
102
|
+
- - ">="
|
103
|
+
- !ruby/object:Gem::Version
|
104
|
+
version: '0'
|
105
|
+
requirements: []
|
106
|
+
rubygems_version: 3.6.7
|
107
|
+
specification_version: 4
|
108
|
+
summary: ActiveModel-like interface for Typesense
|
109
|
+
test_files: []
|