typesensual 0.5.1 → 1.0.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: aee0fc94194e6a777f0f6a9d0b9173e36e38b5f350f63f57264c340ca4657511
4
- data.tar.gz: f9fb3bf716b3302fa2270f57f77b35ac7853f7bddbc3a9f6c77591e1612f013b
3
+ metadata.gz: d9fa2afbbada09440e9505cc8f26a67214ae8400489174d396afc9d10b2c2c5a
4
+ data.tar.gz: 92efdf121fd63031670750d0b3fc200343df494d6a7192f298d267d635aef2a7
5
5
  SHA512:
6
- metadata.gz: 5a01af5c11923882043b88458916c7636089861d046d940d9f384a12a742bd6d3f45c124e2f42620911e2b334b5ebc35bfc25a54d46dc1c6e051cd857025726b
7
- data.tar.gz: 5e314e0c77449547ecf5dabf9d75aff547999a35533fc7b328f64b3dea6c3d63bb87795f4dc6b2cb99481e6b06b9fe115165db4727484436bb577da47dd3c729
6
+ metadata.gz: f9994f3f96fb7b84fe5360cb676f9c4cc049f1e3265d7be1f336cb2a673872672a899d71ff6f3402efffbbc277baf03923c70041b0e290f853b05fbe7f504e03
7
+ data.tar.gz: a67ae00d0e893f3fa57f70838b893959c25447efaf33e7d735b2d818407beec71b96e3aacb1cc62f8846a0bfa342a89706827d3a08e87fa2a102e2118ef8f6f7
data/Gemfile.lock CHANGED
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- typesensual (0.5.1)
4
+ typesensual (1.0.0)
5
5
  activesupport (>= 6.1.5)
6
6
  paint (>= 2.0.0)
7
7
  typesense (>= 0.13.0)
@@ -15,18 +15,20 @@ GEM
15
15
  minitest (>= 5.1)
16
16
  tzinfo (~> 2.0)
17
17
  ast (2.4.2)
18
+ bigdecimal (3.1.7)
18
19
  commonmarker (0.23.10)
19
- concurrent-ruby (1.2.2)
20
+ concurrent-ruby (1.2.3)
20
21
  diff-lcs (1.5.0)
21
22
  docile (1.4.0)
22
23
  ethon (0.16.0)
23
24
  ffi (>= 1.15.0)
24
- ffi (1.15.5)
25
+ ffi (1.16.3)
25
26
  i18n (1.14.1)
26
27
  concurrent-ruby (~> 1.0)
27
28
  json (2.6.3)
28
- minitest (5.20.0)
29
- oj (3.16.1)
29
+ minitest (5.22.3)
30
+ oj (3.16.3)
31
+ bigdecimal (>= 3.0)
30
32
  paint (2.3.0)
31
33
  parallel (1.23.0)
32
34
  parser (3.2.2.1)
data/README.md CHANGED
@@ -117,13 +117,8 @@ Once you have defined your index, you can load data into it and update the alias
117
117
  indexed data. Typesensual provides rake tasks for this purpose if you use ActiveRecord:
118
118
 
119
119
  ```console
120
- $ bundle exec rake typesensual:load[MoviesIndex,Movie]
121
- ==> Indexing Movie into MoviesIndex (Version 1690076097)
122
-
123
- $ bundle exec rake typesensual:update_alias[MoviesIndex,1690076097]
124
- ==> Alias for MoviesIndex
125
- Old: None (N/A)
126
- New: 1690076097 (2023-05-07 18:01:37)
120
+ $ bundle exec rake typesensual:reindex[MoviesIndex,Movie]
121
+ ==> Reindexing Movie into MoviesIndex (Version 1690076097)
127
122
  ```
128
123
 
129
124
  Otherwise you can do similar to the following:
@@ -24,6 +24,14 @@ namespace :typesensual do
24
24
  )
25
25
  end
26
26
 
27
+ desc 'Index all records from a model into a new version then update the alias of the index'
28
+ task :reindex, %i[index model] => :environment do |_, args|
29
+ Typesensual::RakeHelper.reindex(
30
+ index: args[:index],
31
+ model: args[:model]
32
+ )
33
+ end
34
+
27
35
  desc 'Delete a version of an index'
28
36
  task :drop_version, %i[index version] => :environment do |_, args|
29
37
  Typesensual::RakeHelper.drop_version(
@@ -32,6 +32,7 @@ class Typesensual
32
32
  # * `num_documents` [Integer] the number of documents in the collection
33
33
  # * `symbols_to_index` [String] the symbols to index
34
34
  # * `token_separators` [String] the token separators
35
+ #
35
36
  # @overload initialize(name)
36
37
  # Initialize a new collection, loading info from Typesense
37
38
  #
@@ -96,8 +96,6 @@ class Typesensual
96
96
  # @return [Collection] the collection that the alias points to
97
97
  def self.collection
98
98
  @collection ||= Collection.new(alias_name)
99
- rescue Typesense::Error::ObjectNotFound
100
- nil
101
99
  end
102
100
 
103
101
  # Define the schema for the collection
@@ -68,6 +68,35 @@ class Typesensual
68
68
  end
69
69
  end
70
70
 
71
+ # Index all records from a model into a new collection, then update the alias to point to it.
72
+ #
73
+ # @param index [String] The name of the index to index into
74
+ # @param model [String] The name of the model to index from
75
+ # @example
76
+ # take typesensual:reindex[FooIndex,Foo]
77
+ def reindex(index:, model:, output: $stdout)
78
+ index = index.safe_constantize
79
+ model = model.safe_constantize
80
+
81
+ collection = index.create!
82
+ output.printf(
83
+ Paint["==> Reindexing %<model>s into %<index>s (Version %<version>s)\n", :bold],
84
+ model: model.name,
85
+ index: index.name,
86
+ version: collection.version
87
+ )
88
+ failures = index.index_many(
89
+ model.ids,
90
+ collection: collection
91
+ )
92
+
93
+ index.update_alias!(collection)
94
+
95
+ failures.each do |failure|
96
+ output.puts(failure.to_json)
97
+ end
98
+ end
99
+
71
100
  # Update the alias for an index to point to a specific version
72
101
  #
73
102
  # @param index [String] The name of the index to update
@@ -76,7 +105,11 @@ class Typesensual
76
105
  # rake typesensual:update_alias[FooIndex,1]
77
106
  def update_alias(index:, version:, output: $stdout)
78
107
  index = index.safe_constantize
79
- old_coll = index.collection
108
+ old_coll = begin
109
+ index.collection
110
+ rescue Typesense::Error::ObjectNotFound
111
+ nil
112
+ end
80
113
  new_coll = index.collection_for(version: version)
81
114
 
82
115
  unless new_coll
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ class Typesensual
4
+ class Search
5
+ # Represents a facet returned with search results
6
+ class Facet
7
+ attr_reader :key
8
+
9
+ def initialize(key, facet)
10
+ @key = key
11
+ @facet = facet
12
+ end
13
+
14
+ def count
15
+ @facet['count']
16
+ end
17
+
18
+ def value
19
+ @facet['value']
20
+ end
21
+
22
+ def highlighted
23
+ @facet['highlighted']
24
+ end
25
+ end
26
+ end
27
+ end
@@ -3,8 +3,9 @@
3
3
  class Typesensual
4
4
  class Search
5
5
  class Results
6
- def initialize(results)
6
+ def initialize(results, search:)
7
7
  @results = results
8
+ @search = search
8
9
  end
9
10
 
10
11
  def hits
@@ -43,12 +44,22 @@ class Typesensual
43
44
  current_page + 1 unless last_page?
44
45
  end
45
46
 
47
+ def per_page
48
+ @results['request_params']['per_page'].to_i
49
+ end
50
+
46
51
  def search_time_ms
47
52
  @results['search_time_ms']
48
53
  end
49
54
 
50
55
  def total_pages
51
- (@results['found'] / @results['per_page'].to_f).ceil
56
+ (@results['found'] / per_page.to_f).ceil
57
+ end
58
+
59
+ def facets
60
+ @search.facet_keys.zip(@results['facet_counts']).to_h do |(key, facet)|
61
+ [key, Facet.new(key, facet)]
62
+ end
52
63
  end
53
64
  end
54
65
  end
@@ -1,5 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'typesensual/search/facet'
3
4
  require 'typesensual/search/hit'
4
5
  require 'typesensual/search/grouped_hit'
5
6
  require 'typesensual/search/results'
@@ -8,6 +9,11 @@ class Typesensual
8
9
  class Search
9
10
  include StateHelpers
10
11
 
12
+ # The keys of all the facets (used for re-keying the facets in the results)
13
+ # @return [Array<String>]
14
+ # @!visibility private
15
+ attr_reader :facet_keys
16
+
11
17
  # Initialize a new search object for a collection
12
18
  #
13
19
  # @param collection [Typesensual::Collection] the Typesensual collection object
@@ -21,6 +27,9 @@ class Typesensual
21
27
  @sort_by = []
22
28
  @facet_by = []
23
29
  @facet_query = []
30
+ # this is just used by the Results class to determine names for facets
31
+ @facet_keys = []
32
+ @facet_return_parent = []
24
33
  @include_fields = []
25
34
  @exclude_fields = []
26
35
  @group_by = []
@@ -41,12 +50,14 @@ class Typesensual
41
50
 
42
51
  # Set the number of results to return per page
43
52
  # @param count [Integer] the number of results to return per page
53
+ # @return [self]
44
54
  def per(count)
45
55
  set(per_page: count)
46
56
  end
47
57
 
48
58
  # Set the page number to return
49
59
  # @param number [Integer] the page number to return
60
+ # @return [self]
50
61
  def page(number)
51
62
  set(page: number)
52
63
  end
@@ -55,6 +66,7 @@ class Typesensual
55
66
  # @param filter [String, Symbol, Hash<String, Symbol>] the filter to add. If a hash is
56
67
  # provided, the keys are the fields and the values are the values to filter by. If a
57
68
  # string is provided, it is added directly as a filter. All filters are ANDed together.
69
+ # @return [self]
58
70
  def filter(filter)
59
71
  if filter.is_a?(Hash)
60
72
  @filter_by += filter.map { |key, value| "#{key}:#{value}" }
@@ -70,6 +82,7 @@ class Typesensual
70
82
  # @param value [String, Symbol, Hash<String, Symbol>] the sort to add to the search. If
71
83
  # a hash is provided, the keys are the fields and the values are the directions to sort.
72
84
  # If a string is provided, it is added directly as a sort.
85
+ # @return [self]
73
86
  def sort(value)
74
87
  if value.is_a?(Hash)
75
88
  @sort_by += value.map { |key, direction| "#{key}:#{direction}" }
@@ -79,20 +92,108 @@ class Typesensual
79
92
  self
80
93
  end
81
94
 
82
- # Add a field to facet to the seach
83
- # @param facets [String, Symbol, Array<String, Symbol>, Hash<String, Symbol>] the fields to
84
- # facet by. If a hash is provided, the keys are the fields and the values are strings to
85
- # query each facet. If an Array is provided, the values are fields to facet by. If a string
86
- # is provided, it is added directly as a facet.
95
+ # Add a field to facet to the search
96
+ # @return [self]
97
+ # @overload facet(facets: Symbol, String, Array)
98
+ # Basic faceting with just a list of fields
99
+ # @param facets [String, Symbol, Array<String, Symbol>] the fields to facet on
100
+ # @example Facet by type
101
+ # # Generates `facet_by=type`
102
+ # .facet(:type)
103
+ # @example Facet by type and year
104
+ # # Generates `facet_by=type,year`
105
+ # .facet(['type', 'year'])
106
+ #
107
+ # @overload facet(facets: Hash)
108
+ # Advanced faceting with sort, ranges, return_parent, and query
109
+ # @param facets [Hash{String, Symbol => String}, Hash{String, Symbol => Hash}] the fields to
110
+ # facet by and their configuration. If a string is passed as the value, it is used to query
111
+ # the facet. If a hash is passed, each value is used to configure the facet using the
112
+ # following options:
113
+ #
114
+ # * **`facets[key][:sort]`** (`String, Symbol, Hash{String, Symbol => Symbol}`) — the
115
+ # field to sort by, and the direction to sort it in. If you pass a string or symbol, it is
116
+ # used as the sort direction for alphabetical sorting. Typesense only supports sorting by
117
+ # a single field, and passing more than one field will raise an ArgumentError.
118
+ # * **`facets[key][:ranges]`** (`Hash{String, Symbol => Range, Array}`) — the ranges to
119
+ # facet by, for numerical facets. For each key, you can pass a Range or an Array with two
120
+ # elements. Ranges MUST be end-exclusive and Arrays MUST have two elements, or else an
121
+ # ArgumentError will be raised.
122
+ # * **`facets[key][:return_parent]`** (`Boolean`) — if true, the parent object of the field
123
+ # will be returned, which can be useful for nested fields.
124
+ # * **`facets[key][:query]`** (`String`) — the query to facet by, equivalent to passing a
125
+ # string instead of a hash.
126
+ # @example Facet by category, sorted by category size, and return the parent
127
+ # # Generates `facet_by=categories.id(categories.size:desc)&facet_return_parent=categories.id`
128
+ # .facet('categories.id' => { sort: { 'categories.size' => :desc }, return_parent: true })
129
+ # @example Facet by decade
130
+ # # Generates `facet_by=year(1990s:[1990,2000],2000s:[2000,2010])`
131
+ # .facet('year' => { ranges: { '1990s' => 1990...2000, '2000s' => [2000, 2010] })
87
132
  def facet(facets)
88
133
  if facets.is_a?(Hash)
89
134
  facets.each do |key, value|
90
- @facet_by << key.to_s
91
- @facet_query << "#{key}:#{value}" if value
135
+ @facet_keys << key.to_s
136
+ if value.is_a?(String)
137
+ # Basic facet searching with a string query
138
+ @facet_by << key.to_s
139
+ @facet_query << "#{key}:#{value}" if value
140
+ elsif value.is_a?(Hash)
141
+ # Advanced faceting with sort, ranges, return_parent, and query
142
+ facet_string = key.to_s
143
+ facet_params = {}
144
+
145
+ # Sort parameters
146
+ case value[:sort]
147
+ when Hash
148
+ raise ArgumentError, 'Facet sort_by must have one key' if value[:sort].count != 1
149
+ sort_key, sort_direction = value[:sort].first
150
+ facet_params[:sort_by] = "#{sort_key}:#{sort_direction}"
151
+ when Symbol
152
+ facet_params[:sort_by] = "_alpha:#{value[:sort]}"
153
+ when String
154
+ facet_params[:sort_by] = value[:sort]
155
+ when nil
156
+ nil
157
+ else
158
+ raise ArgumentError, 'Facet sort_by must be a Hash, Symbol, or String'
159
+ end
160
+
161
+ # Range parameters
162
+ if value[:ranges].is_a?(Hash)
163
+ ranges = value[:ranges].transform_values do |range|
164
+ case range
165
+ when Range
166
+ raise ArgumentError, 'Facet ranges must exclude end' unless range.exclude_end?
167
+ "[#{range.begin},#{range.end}]"
168
+ when Array
169
+ raise ArgumentError, 'Facet ranges must have two elements' unless range.count == 2
170
+ "[#{range.first},#{range.last}]"
171
+ else
172
+ raise ArgumentError, 'Facet ranges must be a Range or Array'
173
+ end
174
+ end
175
+ facet_params.merge!(ranges)
176
+ elsif !value[:ranges].nil?
177
+ raise ArgumentError, 'Facet ranges must be a Hash'
178
+ end
179
+
180
+ # Format facet params
181
+ unless facet_params.empty?
182
+ facet_string += "(#{facet_params.map { |k, v| "#{k}:#{v}" }.join(',')})"
183
+ end
184
+
185
+ @facet_return_parent << key.to_s if value[:return_parent]
186
+ @facet_query << "#{key}:#{value[:query]}" if value[:query]
187
+ @facet_by << facet_string
188
+ else
189
+ @facet_by << key.to_s
190
+ end
92
191
  end
93
192
  elsif facets.is_a?(Array)
193
+ @facet_keys += facets.map(&:to_s)
94
194
  @facet_by += facets.map(&:to_s)
95
195
  else
196
+ @facet_keys << facets.to_s
96
197
  @facet_by << facets.to_s
97
198
  end
98
199
  self
@@ -100,6 +201,7 @@ class Typesensual
100
201
 
101
202
  # Add fields to include in the search result documents
102
203
  # @param fields [String, Symbol, Array<String, Symbol>] the fields to include
204
+ # @return [self]
103
205
  def include_fields(*fields)
104
206
  @include_fields += fields.map(&:to_s)
105
207
  self
@@ -107,11 +209,15 @@ class Typesensual
107
209
 
108
210
  # Add fields to exclude from the search result documents
109
211
  # @param fields [String, Symbol, Array<String, Symbol>] the fields to exclude
212
+ # @return [self]
110
213
  def exclude_fields(*fields)
111
214
  @exclude_fields += fields.map(&:to_s)
112
215
  self
113
216
  end
114
217
 
218
+ # Add fields to group the search results by
219
+ # @param fields [String, Symbol, Array<String, Symbol>] the fields to group by
220
+ # @return [self]
115
221
  def group_by(*fields)
116
222
  @group_by += fields.map(&:to_s)
117
223
  self
@@ -119,6 +225,7 @@ class Typesensual
119
225
 
120
226
  # Set additional parameters to pass to the search
121
227
  # @param values [Hash] the parameters to set
228
+ # @return [self]
122
229
  def set(values)
123
230
  @params.merge!(values)
124
231
  self
@@ -135,6 +242,7 @@ class Typesensual
135
242
  query_by_weights: @query_by_weights&.join(','),
136
243
  sort_by: @sort_by&.join(','),
137
244
  facet_by: @facet_by&.join(','),
245
+ facet_return_parent: @facet_return_parent&.join(','),
138
246
  facet_query: @facet_query&.join(','),
139
247
  include_fields: @include_fields&.join(','),
140
248
  exclude_fields: @exclude_fields&.join(','),
@@ -145,7 +253,9 @@ class Typesensual
145
253
  # Load the results from the search query
146
254
  # @return [Typesensual::Search::Results] the results of the search
147
255
  def load
148
- Results.new(@collection.typesense_collection.documents.search(query))
256
+ result = self.class.multi(self).first
257
+ raise result if result.is_a?(StandardError)
258
+ result
149
259
  end
150
260
 
151
261
  # Perform multiple searches in one request. There are two variants of this method, one which
@@ -181,7 +291,9 @@ class Typesensual
181
291
 
182
292
  # Wrap our results in Result objects
183
293
  wrapped_results = results['results'].map do |result|
184
- Results.new(result)
294
+ exception = exception_for(result) if result['code']
295
+
296
+ exception || Results.new(result, search: self)
185
297
  end
186
298
 
187
299
  # If we're doing named searches, re-key the results
@@ -191,5 +303,19 @@ class Typesensual
191
303
  wrapped_results
192
304
  end
193
305
  end
306
+
307
+ # Roughly duplicates the logic from Typesense::ApiCall#custom_exception_klass_for
308
+ private_class_method def self.exception_for(result)
309
+ case result['code']
310
+ when 400 then Typesense::Error::RequestMalformed.new(result['message'])
311
+ when 401 then Typesense::Error::RequestUnauthorized.new(result['message'])
312
+ when 404 then Typesense::Error::ObjectNotFound.new(result['message'])
313
+ when 409 then Typesense::Error::ObjectAlreadyExists.new(result['message'])
314
+ when 422 then Typesense::Error::ObjectUnprocessable.new(result['message'])
315
+ when 500..599 then Typesense::Error::ServerError.new(result['message'])
316
+ when 100..399, nil then nil
317
+ else Typesense::Error.new(result['message'])
318
+ end
319
+ end
194
320
  end
195
321
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  class Typesensual
4
- VERSION = '0.5.1'
4
+ VERSION = '1.0.0'
5
5
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: typesensual
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.5.1
4
+ version: 1.0.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Emma Lejeck
8
- autorequire:
8
+ autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2023-09-10 00:00:00.000000000 Z
11
+ date: 2024-10-06 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: activesupport
@@ -52,9 +52,9 @@ dependencies:
52
52
  - - ">="
53
53
  - !ruby/object:Gem::Version
54
54
  version: 0.13.0
55
- description:
55
+ description:
56
56
  email:
57
- - nuck@kitsu.io
57
+ - nuck@kitsu.app
58
58
  executables: []
59
59
  extensions: []
60
60
  extra_rdoc_files: []
@@ -80,6 +80,7 @@ files:
80
80
  - lib/typesensual/rake_helper.rb
81
81
  - lib/typesensual/schema.rb
82
82
  - lib/typesensual/search.rb
83
+ - lib/typesensual/search/facet.rb
83
84
  - lib/typesensual/search/grouped_hit.rb
84
85
  - lib/typesensual/search/hit.rb
85
86
  - lib/typesensual/search/results.rb
@@ -93,7 +94,7 @@ metadata:
93
94
  source_code_uri: https://github.com/hummingbird-me/typesensual
94
95
  changelog_uri: https://github.com/hummingbird-me/typesensual/blob/main/CHANGELOG.md
95
96
  rubygems_mfa_required: 'true'
96
- post_install_message:
97
+ post_install_message:
97
98
  rdoc_options: []
98
99
  require_paths:
99
100
  - lib
@@ -109,7 +110,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
109
110
  version: '0'
110
111
  requirements: []
111
112
  rubygems_version: 3.3.26
112
- signing_key:
113
+ signing_key:
113
114
  specification_version: 4
114
115
  summary: A simple, sensual wrapper around Typesense for Ruby
115
116
  test_files: []