typesensual 0.1.0 → 0.3.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: 9231d4598d045c7089f290474867722966f4c4e720cc34a8de0ef960b5ac650a
4
- data.tar.gz: 6b9400c0f282f3df2b3bec0f310f453b3383ddd05846894a1390176db60b206e
3
+ metadata.gz: 16c9d2bb0a45de749680a833abf874d3f42da4190ea66b88e5d1f19a8f2d902c
4
+ data.tar.gz: d4f9dc200a97e182929b8cdc6fbb48edc1da69e6444137e2f7f670197742fa2a
5
5
  SHA512:
6
- metadata.gz: 7ebec05f05e05762734582d0b5080844a567002313aff70b4009642da0aeb20874fbe7a12e9b523dcc2f702bf0b98c24342f3f23dcf751582b42399f21542114
7
- data.tar.gz: 709890ec4af5c9da8b680910c865d4900d0eae090cd9cc471bfb50c0860e486854cbcb01ee934aac1bfa124da6722771b2154d2a80b7a911416876ff9598257e
6
+ metadata.gz: 7d3130f6ac4d1b5d2722c4a6020fab73dc6ce1b91b7095e0f91de2d50723a60c5edb5434f651f874182c868a22e4015b12aa0eed097270562bc2bd291640a2af
7
+ data.tar.gz: c1fc489fab6f2c1fc29f57a0662883115b37b1790a02eab0db6193be53fbf4174de8e464b937f42b54ee2c3098d57abc8dc529f5d081728cb63861c75b302242
data/Gemfile.lock CHANGED
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- typesensual (0.1.0)
4
+ typesensual (0.3.0)
5
5
  activesupport (>= 6.1.5)
6
6
  paint (>= 2.0.0)
7
7
  typesense (>= 0.13.0)
@@ -9,7 +9,7 @@ PATH
9
9
  GEM
10
10
  remote: https://rubygems.org/
11
11
  specs:
12
- activesupport (7.0.5)
12
+ activesupport (7.0.7)
13
13
  concurrent-ruby (~> 1.0, >= 1.0.2)
14
14
  i18n (>= 1.6, < 2)
15
15
  minitest (>= 5.1)
@@ -22,11 +22,11 @@ GEM
22
22
  ethon (0.16.0)
23
23
  ffi (>= 1.15.0)
24
24
  ffi (1.15.5)
25
- i18n (1.13.0)
25
+ i18n (1.14.1)
26
26
  concurrent-ruby (~> 1.0)
27
27
  json (2.6.3)
28
- minitest (5.18.0)
29
- oj (3.14.3)
28
+ minitest (5.19.0)
29
+ oj (3.16.0)
30
30
  paint (2.3.0)
31
31
  parallel (1.23.0)
32
32
  parser (3.2.2.1)
@@ -79,7 +79,7 @@ GEM
79
79
  simplecov_json_formatter (~> 0.1)
80
80
  simplecov-html (0.12.3)
81
81
  simplecov_json_formatter (0.1.4)
82
- typesense (0.14.1)
82
+ typesense (0.15.0)
83
83
  oj (~> 3.11)
84
84
  typhoeus (~> 1.4)
85
85
  typhoeus (1.4.0)
@@ -91,8 +91,10 @@ GEM
91
91
  yard (0.9.34)
92
92
 
93
93
  PLATFORMS
94
+ ruby
94
95
  x86_64-darwin-19
95
96
  x86_64-darwin-22
97
+ x86_64-linux
96
98
 
97
99
  DEPENDENCIES
98
100
  commonmarker
data/README.md CHANGED
@@ -50,6 +50,14 @@ Typesensual.configure do |config|
50
50
  end
51
51
  ```
52
52
 
53
+ Alternatively you can configure with env variables:
54
+
55
+ ```env
56
+ TYPESENSUAL_NODES=http://node1:8108,http://node2:8108,http://node3:8108
57
+ TYPESENSUAL_API_KEY=xyz
58
+ TYPESENSUAL_ENV=test
59
+ ```
60
+
53
61
  ### Creating your first index
54
62
 
55
63
  Once the client is configured, you can create your first index. This is done by creating a subclass
@@ -57,7 +65,7 @@ of `Typesensual::Index` and defining your schema and how to load the data. For e
57
65
  following index might be used to index movies from an ActiveRecord model:
58
66
 
59
67
  ```ruby
60
- # app/indices/movie_index.rb
68
+ # app/indices/movies_index.rb
61
69
  class MoviesIndex < Typesensual::Index
62
70
  # The schema of the collection
63
71
  schema do
@@ -70,24 +78,7 @@ class MoviesIndex < Typesensual::Index
70
78
  field 'genres', type: 'string[]', facet: true
71
79
  end
72
80
 
73
- def index_one(id)
74
- movie = Movie.find(id).includes(:genres)
75
-
76
- {
77
- id: movie.id,
78
- title: movie.title,
79
- release_date: {
80
- year: movie.release_date.year,
81
- month: movie.release_date.month,
82
- day: movie.release_date.day
83
- },
84
- average_rating: movie.average_rating,
85
- user_count: movie.user_count,
86
- genres: movie.genres.map(&:name)
87
- }
88
- end
89
-
90
- def index_many(ids)
81
+ def index(ids)
91
82
  Movies.where(id: ids).includes(:genres).find_each do |movie|
92
83
  yield {
93
84
  id: movie.id,
@@ -106,6 +97,20 @@ class MoviesIndex < Typesensual::Index
106
97
  end
107
98
  ```
108
99
 
100
+ ### Integrating with your model
101
+
102
+ If you use ActiveRecord, there's a set of premade callbacks you can use:
103
+
104
+ ```ruby
105
+ class Movie < ApplicationRecord
106
+ after_commit MoviesIndex.ar_callbacks, on: %i[create update destroy]
107
+ end
108
+ ```
109
+
110
+ You're free to use these callbacks as-is, or you can use them as a starting point for your own
111
+ integration. They're just calling `MoviesIndex.index_one` and `MoviesIndex.remove_one` under the
112
+ hood, so you can do the same in your own callbacks or outside of ActiveRecord.
113
+
109
114
  ### Loading data into your index
110
115
 
111
116
  Once you have defined your index, you can load data into it and update the alias to point to the
@@ -1,9 +1,11 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'typesensual/rake_helper'
4
+
3
5
  namespace :typesensual do
4
6
  desc 'List typesensual indices and their collections'
5
7
  task list: :environment do
6
- Typesensual::RakeHelper.list_collections
8
+ Typesensual::RakeHelper.list
7
9
  end
8
10
 
9
11
  desc 'Update the alias for an index'
@@ -21,4 +23,12 @@ namespace :typesensual do
21
23
  model: args[:model]
22
24
  )
23
25
  end
26
+
27
+ desc 'Delete a version of an index'
28
+ task :drop_version, %i[index version] => :environment do |_, args|
29
+ Typesensual::RakeHelper.drop_version(
30
+ index: args[:index],
31
+ version: args[:version]
32
+ )
33
+ end
24
34
  end
@@ -2,9 +2,8 @@
2
2
 
3
3
  class Typesensual
4
4
  class Callbacks
5
- def initialize(index, should_update: ->(_record) { true })
5
+ def initialize(index)
6
6
  @index = index
7
- @should_update = should_update
8
7
  end
9
8
 
10
9
  def after_create_commit(record)
@@ -12,7 +11,7 @@ class Typesensual
12
11
  end
13
12
 
14
13
  def after_update_commit(record)
15
- @should_update.call(record) && @index.index_one(record.id)
14
+ @index.index_one(record.id)
16
15
  end
17
16
 
18
17
  def after_destroy_commit(record)
@@ -181,9 +181,22 @@ class Typesensual
181
181
  typesense_collection.documents[id.to_s].delete
182
182
  end
183
183
 
184
+ # Remove multiple documents from typesense based on a filter
185
+ #
186
+ # @param filter_by [String] the filter to use to remove documents
187
+ # @return [void]
188
+ def remove_many!(filter_by:)
189
+ typesense_collection.documents.delete(filter_by: filter_by)
190
+ end
191
+
192
+ # Search for documents in typesense
193
+ #
194
+ # @param query [String] the query to search for
195
+ # @param query_by [String] the fields to search by
196
+ # @return [Search] the search object
184
197
  def search(query:, query_by:)
185
198
  Search.new(
186
- collection: typesense_collection,
199
+ collection: self,
187
200
  query: query,
188
201
  query_by: query_by
189
202
  )
@@ -2,21 +2,31 @@
2
2
 
3
3
  class Typesensual
4
4
  class Config
5
- attr_accessor :nodes, :api_key
6
- attr_writer :env, :client
5
+ attr_writer :env, :client, :nodes, :api_key
7
6
 
8
7
  def initialize(&block)
9
8
  yield self if block
10
9
  end
11
10
 
12
11
  def env
13
- @env ||= (defined?(Rails) ? Rails.env : nil)
12
+ @env ||= ENV.fetch('TYPESENSUAL_ENV', (defined?(Rails) ? Rails.env : nil))
14
13
  end
15
14
 
16
15
  def client
17
16
  @client ||= Typesense::Client.new(connection_options)
18
17
  end
19
18
 
19
+ def nodes
20
+ @nodes ||= ENV['TYPESENSUAL_NODES']&.split(',')&.map do |node|
21
+ node_uri = URI.parse(node)
22
+ { port: node_uri.port, host: node_uri.host, protocol: node_uri.scheme }
23
+ end
24
+ end
25
+
26
+ def api_key
27
+ @api_key ||= ENV.fetch('TYPESENSUAL_API_KEY', nil)
28
+ end
29
+
20
30
  private
21
31
 
22
32
  def connection_options
@@ -43,7 +43,7 @@ class Typesensual
43
43
  end
44
44
 
45
45
  def to_h
46
- @field.to_h.merge!(
46
+ @field.to_h.merge(
47
47
  'name' => name,
48
48
  'locale' => locale
49
49
  ).compact!
@@ -21,6 +21,16 @@ class Typesensual
21
21
  class Index
22
22
  include StateHelpers
23
23
 
24
+ class << self
25
+ delegate :search, to: :collection
26
+ end
27
+
28
+ def self.inherited(subclass)
29
+ super
30
+ # Copy the schema from the parent class to the subclass
31
+ subclass.instance_variable_set(:@schema, @schema&.dup)
32
+ end
33
+
24
34
  # Get or set the name for this index
25
35
  #
26
36
  # @overload index_name(value)
@@ -94,7 +104,9 @@ class Typesensual
94
104
  #
95
105
  # See {Schema} for more information
96
106
  def self.schema(&block)
97
- @schema = Typesensual::Schema.new(&block)
107
+ @schema ||= Typesensual::Schema.new
108
+ @schema.instance_eval(&block) if block
109
+ @schema
98
110
  end
99
111
 
100
112
  # Updates the alias to point to the given collection name
@@ -122,28 +134,25 @@ class Typesensual
122
134
  update_alias!(collection)
123
135
  end
124
136
 
125
- # The method to implement to index *one* record.
126
- #
127
- # @return [Hash] the document to upsert in Typesense
128
- def index_one(_id); end
129
-
130
137
  def self.index_one(id, collection: self.collection)
131
- collection.insert_one!(new.index_one(id))
138
+ new.index([id]) do |record|
139
+ collection.insert_one!(record)
140
+ end
132
141
  end
133
142
 
134
143
  # The method to implement to index *many* records
135
- # Unlike {#index_one}, this method should yield successive records to index
144
+ # This method should yield successive records to index
136
145
  #
137
146
  # @yield [Hash] a document to upsert in Typesense
138
- def index_many(ids)
147
+ def index(ids)
139
148
  ids.each do |id|
140
- yield index_one(id)
149
+ yield({ id: id })
141
150
  end
142
151
  end
143
152
 
144
153
  def self.index_many(ids, collection: self.collection, batch_size: 100)
145
154
  collection.insert_many!(
146
- new.enum_for(:index_many, ids),
155
+ new.enum_for(:index, ids),
147
156
  batch_size: batch_size
148
157
  )
149
158
  end
@@ -98,6 +98,25 @@ class Typesensual
98
98
  created_at: new_coll.created_at.strftime('%Y-%m-%d %H:%M:%S')
99
99
  )
100
100
  end
101
+
102
+ # Drop a version of an index
103
+ #
104
+ # @param index [String] The name of the index to remove a version from
105
+ # @param version [String] The version to remove
106
+ # @example
107
+ # rake typesensual:drop_version[FooIndex,1]
108
+ def drop_version(index:, version:, output: $stdout)
109
+ index = index.safe_constantize
110
+ collection = index.collection_for(version: version)
111
+
112
+ collection.delete!
113
+
114
+ output.printf(
115
+ "==> Dropped version %<version>s of %<index>s\n",
116
+ version: version,
117
+ index: index.name
118
+ )
119
+ end
101
120
  end
102
121
  end
103
122
  end
@@ -4,8 +4,21 @@ require 'typesensual/field'
4
4
 
5
5
  class Typesensual
6
6
  class Schema
7
+ # Duplicate fields from the original
8
+ def initialize_copy(original)
9
+ %w[
10
+ @fields
11
+ @token_separators
12
+ @symbols_to_index
13
+ @default_sorting_field
14
+ @enable_nested_fields
15
+ ].each do |var|
16
+ instance_variable_set(var, original.instance_variable_get(var).dup)
17
+ end
18
+ end
19
+
7
20
  def initialize(&block)
8
- instance_eval(&block)
21
+ instance_eval(&block) unless block.nil?
9
22
  end
10
23
 
11
24
  def field(name, type: 'auto', locale: nil, facet: nil, index: nil, optional: nil)
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ class Typesensual
4
+ class Search
5
+ class GroupedHit
6
+ # {
7
+ # "group_key": [
8
+ # "420",
9
+ # "69"
10
+ # ],
11
+ # "hits": {
12
+ # ...
13
+ # },
14
+ # "found": 3
15
+ # }
16
+ # @param group [Hash] the Typesense hit hash
17
+ # * `group_key` [Array<any>] the grouping keys
18
+ # * `hits` [Hash] the Hits for the group
19
+ # * `found` [Integer] the number of hits in the group
20
+ def initialize(group)
21
+ @group = group
22
+ end
23
+
24
+ def count
25
+ @group['found']
26
+ end
27
+
28
+ def hits
29
+ @group['hits'].map { |hit| Hit.new(hit) }
30
+ end
31
+
32
+ def group_key
33
+ @group['group_key']
34
+ end
35
+ end
36
+ end
37
+ end
@@ -19,7 +19,7 @@ class Typesensual
19
19
  # },
20
20
  # "text_match": 130916
21
21
  # }
22
- # @param collection [Hash] the Typesense hit hash
22
+ # @param hit [Hash] the Typesense hit hash
23
23
  # * `highlights` [Array<Hash>] the highlights for the hit
24
24
  # * `document` [Hash] the matching document
25
25
  # * `text_match` [Integer] the text matching score
@@ -11,6 +11,10 @@ class Typesensual
11
11
  @results['hits'].map { |hit| Hit.new(hit) }
12
12
  end
13
13
 
14
+ def grouped_hits
15
+ @results['grouped_hits'].map { |hit| GroupedHit.new(hit) }
16
+ end
17
+
14
18
  def count
15
19
  @results['found']
16
20
  end
@@ -1,10 +1,13 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'typesensual/search/hit'
4
+ require 'typesensual/search/grouped_hit'
4
5
  require 'typesensual/search/results'
5
6
 
6
7
  class Typesensual
7
8
  class Search
9
+ include StateHelpers
10
+
8
11
  # Initialize a new search object for a collection
9
12
  #
10
13
  # @param collection [Typesensual::Collection] the Typesensual collection object
@@ -20,6 +23,7 @@ class Typesensual
20
23
  @facet_query = []
21
24
  @include_fields = []
22
25
  @exclude_fields = []
26
+ @group_by = []
23
27
  @params = {}
24
28
 
25
29
  @collection = collection
@@ -106,6 +110,11 @@ class Typesensual
106
110
  self
107
111
  end
108
112
 
113
+ def group_by(*fields)
114
+ @group_by += fields.map(&:to_s)
115
+ self
116
+ end
117
+
109
118
  # Set additional parameters to pass to the search
110
119
  # @param values [Hash] the parameters to set
111
120
  def set(values)
@@ -114,6 +123,7 @@ class Typesensual
114
123
  end
115
124
 
116
125
  # Generate the query document
126
+ # @return [Hash] the query document
117
127
  def query
118
128
  {
119
129
  collection: @collection.name,
@@ -125,13 +135,59 @@ class Typesensual
125
135
  facet_by: @facet_by&.join(','),
126
136
  facet_query: @facet_query&.join(','),
127
137
  include_fields: @include_fields&.join(','),
128
- exclude_fields: @exclude_fields&.join(',')
138
+ exclude_fields: @exclude_fields&.join(','),
139
+ group_by: @group_by&.join(',')
129
140
  }.merge(@params).reject { |_, v| v.blank? }
130
141
  end
131
142
 
132
143
  # Load the results from the search query
144
+ # @return [Typesensual::Search::Results] the results of the search
133
145
  def load
134
146
  Results.new(@collection.typesense_collection.documents.search(query))
135
147
  end
148
+
149
+ # Perform multiple searches in one request. There are two variants of this method, one which
150
+ # takes a list of anonymous queries and one which takes a hash of named queries. Named queries
151
+ # will probably be more readable for more than a couple of queries, but anonymous queries can be
152
+ # destructured directly.
153
+ #
154
+ # Both versions accept either a Search instance or a hash of search parameters.
155
+ #
156
+ # @overload multi(*searches)
157
+ # Perform an array of search queries in a single request. The return values are guaranteed to
158
+ # be in the same order as the provided searches.
159
+ #
160
+ # @param searches [<Typesensual::Search, Hash>] the searches to perform
161
+ # @return [<Typesensual::Search::Results>] the results of the searches
162
+ #
163
+ # @overload multi(searches)
164
+ # Perform multiple named search queries in a single request. The results will be keyed by the
165
+ # same names as the provided searches.
166
+ #
167
+ # @param searches [{Object => Typesensual::Search, Hash>] the searches to perform
168
+ # @return [{Object => Typesensual::Search::Results}] the results of the searches
169
+ def self.multi(*searches)
170
+ # If we have one argument and it's a hash, we're doing named searches
171
+ if searches.count == 1 && searches.first.is_a?(Hash)
172
+ keys = searches.first.keys
173
+ searches = searches.first.values
174
+ end
175
+
176
+ results = client.multi_search.perform({
177
+ searches: searches.flatten.map(&:query)
178
+ })
179
+
180
+ # Wrap our results in Result objects
181
+ wrapped_results = results['results'].map do |result|
182
+ Results.new(result)
183
+ end
184
+
185
+ # If we're doing named searches, re-key the results
186
+ if keys
187
+ keys.zip(wrapped_results).to_h
188
+ else
189
+ wrapped_results
190
+ end
191
+ end
136
192
  end
137
193
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  class Typesensual
4
- VERSION = '0.1.0'
4
+ VERSION = '0.3.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.1.0
4
+ version: 0.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Emma Lejeck
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2023-07-23 00:00:00.000000000 Z
11
+ date: 2023-08-21 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: activesupport
@@ -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/grouped_hit.rb
83
84
  - lib/typesensual/search/hit.rb
84
85
  - lib/typesensual/search/results.rb
85
86
  - lib/typesensual/state_helpers.rb