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 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,3 @@
1
+ module TypesenseModel
2
+ VERSION = "0.2.0"
3
+ 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: []