Skip to content

Commit a788dde

Browse files
authored
Merge pull request #22 from typesense/upsert-and-updates
Add support for upserts and update
2 parents 3dcb991 + ede8ed1 commit a788dde

9 files changed

Lines changed: 262 additions & 114 deletions

File tree

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ Tests are also a good place to know how the the library works internally: [spec]
3333

3434
| Typesense Server | typesense-ruby |
3535
|------------------|----------------|
36-
| \>= v0.15.1 | \>= v0.8.0 |
36+
| \>= v0.16.0 | \>= v0.8.0 |
3737
| \>= v0.15.0 | \>= v0.7.0 |
3838
| \>= v0.12.1 | \>= v0.5.0 |
3939
| \>= v0.12.0 | \>= v0.4.0 |

examples/client_initialization.rb

Lines changed: 16 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -38,26 +38,26 @@
3838
host: 'localhost',
3939
port: 8108,
4040
protocol: 'http'
41-
},
42-
# Uncomment if starting a 3-node cluster, using Option 2 under Setup instructions above
43-
{
44-
host: 'localhost',
45-
port: 7108,
46-
protocol: 'http'
47-
},
48-
{
49-
host: 'localhost',
50-
port: 9108,
51-
protocol: 'http'
5241
}
42+
# Uncomment if starting a 3-node cluster, using Option 2 under Setup instructions above
43+
# {
44+
# host: 'localhost',
45+
# port: 7108,
46+
# protocol: 'http'
47+
# },
48+
# {
49+
# host: 'localhost',
50+
# port: 9108,
51+
# protocol: 'http'
52+
# }
5353
],
5454
# If this optional key is specified, requests are always sent to this node first if it is healthy
5555
# before falling back on the nodes mentioned in the `nodes` key. This is useful when running a distributed set of search clusters.
56-
'nearest_node': {
57-
'host': 'localhost',
58-
'port': '8108',
59-
'protocol': 'http'
60-
},
56+
# 'nearest_node': {
57+
# 'host': 'localhost',
58+
# 'port': '8108',
59+
# 'protocol': 'http'
60+
# },
6161
api_key: 'xyz',
6262
num_retries: 10,
6363
healthcheck_interval_seconds: 1,

examples/collections_and_documents.rb

Lines changed: 47 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -167,6 +167,10 @@
167167
# "num_employees" => 5215
168168
# }
169169

170+
# You can also upsert a document, which will update the document if it already exists or create a new one if it doesn't exist
171+
document = @typesense.collections['companies'].documents.upsert(document)
172+
ap document
173+
170174
##
171175
# Retrieve a document
172176
sleep 0.5 # Give Typesense cluster a few hundred ms to create the document on all nodes, before reading it right after (eventually consistent)
@@ -180,6 +184,25 @@
180184
# "num_employees" => 5215
181185
# }
182186

187+
##
188+
# Update a document. Unlike upsert, update will error out if the doc doesn't already exist.
189+
document = @typesense.collections['companies'].documents['124'].update(
190+
'id' => 1,
191+
'num_employees' => 5500
192+
)
193+
ap document
194+
195+
# {
196+
# "id" => "124",
197+
# "num_employees" => 5500
198+
# }
199+
200+
# This should error out, since document 145 doesn't exist
201+
# document = @typesense.collections['companies'].documents['145'].update(
202+
# 'num_employees' => 5500
203+
# )
204+
# ap document
205+
183206
##
184207
# Delete a document
185208
# Deleting a document, returns the document after deletion
@@ -208,11 +231,33 @@
208231
'country' => 'France'
209232
}
210233
]
211-
ap @typesense.collections['companies'].documents.create_many(documents)
234+
ap @typesense.collections['companies'].documents.import(documents)
212235

213-
## If you already have documents in JSONL format, you can also use #import instead, to avoid the JSON parsing overhead:
236+
## If you already have documents in JSONL format, you can also pass it directly to #import, to avoid the JSON parsing overhead:
214237
# @typesense.collections['companies'].documents.import(documents_in_jsonl_format)
215238

239+
## You can bulk upsert documents, by adding an upsert action option to #import
240+
documents << {
241+
'id' => '126',
242+
'company_name' => 'Stark Industries 2',
243+
'num_employees' => 200,
244+
'country' => 'USA'
245+
}
246+
ap @typesense.collections['companies'].documents.import(documents, action: :upsert)
247+
248+
## You can bulk update documents, by adding an update action option to #import
249+
# `action: update` will throw an error if the document doesn't already exist
250+
# This document will error out, since id: 1200 doesn't exist
251+
documents << {
252+
'id' => '1200',
253+
'country' => 'USA'
254+
}
255+
documents << {
256+
'id' => '126',
257+
'num_employees' => 300
258+
}
259+
ap @typesense.collections['companies'].documents.import(documents, action: :update)
260+
216261
##
217262
# Export all documents in a collection in JSON Lines format
218263
# We use JSON Lines format for performance reasons. You can choose to parse selected lines as needed, by splitting on \n.

lib/typesense/api_call.rb

Lines changed: 36 additions & 67 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@ module Typesense
77
class ApiCall
88
API_KEY_HEADER_NAME = 'X-TYPESENSE-API-KEY'
99

10+
attr_reader :logger
11+
1012
def initialize(configuration)
1113
@configuration = configuration
1214

@@ -24,45 +26,40 @@ def initialize(configuration)
2426
@current_node_index = -1
2527
end
2628

27-
def post(endpoint, parameters = {})
28-
headers, query, body = split_post_put_parameters(parameters)
29-
29+
def post(endpoint, body_parameters = {}, query_parameters = {})
3030
perform_request :post,
3131
endpoint,
32-
headers,
33-
params: query,
34-
body: body
32+
query_parameters: query_parameters,
33+
body_parameters: body_parameters
3534
end
3635

37-
def put(endpoint, parameters = {})
38-
headers, query, body = split_post_put_parameters(parameters)
36+
def patch(endpoint, body_parameters = {}, query_parameters = {})
37+
perform_request :patch,
38+
endpoint,
39+
query_parameters: query_parameters,
40+
body_parameters: body_parameters
41+
end
3942

43+
def put(endpoint, body_parameters = {}, query_parameters = {})
4044
perform_request :put,
4145
endpoint,
42-
headers,
43-
params: query,
44-
body: body
46+
query_parameters: query_parameters,
47+
body_parameters: body_parameters
4548
end
4649

47-
def get(endpoint, parameters = {})
48-
headers, query = extract_headers_and_query_from(parameters)
49-
50+
def get(endpoint, query_parameters = {})
5051
perform_request :get,
5152
endpoint,
52-
headers,
53-
params: query
53+
query_parameters: query_parameters
5454
end
5555

56-
def delete(endpoint, parameters = {})
57-
headers, query = extract_headers_and_query_from(parameters)
58-
56+
def delete(endpoint, query_parameters = {})
5957
perform_request :delete,
6058
endpoint,
61-
headers,
62-
params: query
59+
query_parameters: query_parameters
6360
end
6461

65-
def perform_request(method, endpoint, headers = {}, options = {})
62+
def perform_request(method, endpoint, query_parameters: nil, body_parameters: nil, additional_headers: {})
6663
@configuration.validate!
6764
last_exception = nil
6865
@logger.debug "Performing #{method.to_s.upcase} request: #{endpoint}"
@@ -72,15 +69,23 @@ def perform_request(method, endpoint, headers = {}, options = {})
7269
@logger.debug "Attempting #{method.to_s.upcase} request Try ##{num_tries} to Node #{node[:index]}"
7370

7471
begin
75-
response = Typhoeus::Request.new(uri_for(endpoint, node),
76-
{
77-
method: method,
78-
headers: default_headers.merge(headers),
79-
timeout: @connection_timeout_seconds
80-
}.merge(options)).run
72+
request_options = {
73+
method: method,
74+
timeout: @connection_timeout_seconds,
75+
headers: default_headers.merge(additional_headers)
76+
}
77+
request_options.merge!(params: query_parameters) unless query_parameters.nil?
78+
79+
unless body_parameters.nil?
80+
body = body_parameters
81+
body = Oj.dump(body_parameters) if request_options[:headers]['Content-Type'] == 'application/json'
82+
request_options.merge!(body: body)
83+
end
84+
85+
response = Typhoeus::Request.new(uri_for(endpoint, node), request_options).run
8186
set_node_healthcheck(node, is_healthy: true) if response.code >= 1 && response.code <= 499
8287

83-
@logger.debug "Request to Node #{node[:index]} was successfully made (at the network layer). Response Code was #{response.code}."
88+
@logger.debug "Request #{method}:#{uri_for(endpoint, node)} to Node #{node[:index]} was successfully made (at the network layer). Response Code was #{response.code}."
8489

8590
parsed_response = if response.headers && (response.headers['content-type'] || '').include?('application/json')
8691
Oj.load(response.body)
@@ -101,7 +106,7 @@ def perform_request(method, endpoint, headers = {}, options = {})
101106
# other languages that might not support the same construct.
102107
set_node_healthcheck(node, is_healthy: false)
103108
last_exception = e
104-
@logger.warn "Request to Node #{node[:index]} failed due to \"#{e.class}: #{e.message}\""
109+
@logger.warn "Request #{method}:#{uri_for(endpoint, node)} to Node #{node[:index]} failed due to \"#{e.class}: #{e.message}\""
105110
@logger.warn "Sleeping for #{@retry_interval_seconds}s and then retrying request..."
106111
sleep @retry_interval_seconds
107112
end
@@ -112,43 +117,6 @@ def perform_request(method, endpoint, headers = {}, options = {})
112117

113118
private
114119

115-
def split_post_put_parameters(parameters)
116-
if json_request?(parameters)
117-
headers = { 'Content-Type' => 'application/json' }
118-
query = parameters[:query]
119-
body = Oj.dump(sanitize_parameters(parameters))
120-
else
121-
headers = {}
122-
query = parameters[:query]
123-
body = parameters[:body]
124-
end
125-
[headers, query, body]
126-
end
127-
128-
def extract_headers_and_query_from(parameters)
129-
if json_request?(parameters)
130-
headers = { 'Content-Type' => 'application/json' }
131-
query = sanitize_parameters(parameters)
132-
else
133-
headers = {}
134-
query = parameters[:query]
135-
end
136-
[headers, query]
137-
end
138-
139-
def json_request?(parameters)
140-
parameters[:as_json].nil? ? true : parameters[:as_json]
141-
end
142-
143-
def sanitize_parameters(parameters)
144-
sanitized_parameters = parameters.dup
145-
sanitized_parameters.delete(:as_json)
146-
sanitized_parameters.delete(:body)
147-
sanitized_parameters.delete(:query)
148-
149-
sanitized_parameters
150-
end
151-
152120
def uri_for(endpoint, node)
153121
"#{node[:protocol]}://#{node[:host]}:#{node[:port]}#{endpoint}"
154122
end
@@ -231,6 +199,7 @@ def custom_exception_klass_for(response)
231199

232200
def default_headers
233201
{
202+
'Content-Type' => 'application/json',
234203
API_KEY_HEADER_NAME.to_s => @api_key,
235204
'User-Agent' => 'Typesense Ruby Client'
236205
}

lib/typesense/document.rb

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,10 @@ def delete
1616
@api_call.delete(endpoint_path)
1717
end
1818

19+
def update(partial_document)
20+
@api_call.patch(endpoint_path, partial_document)
21+
end
22+
1923
private
2024

2125
def endpoint_path

lib/typesense/documents.rb

Lines changed: 32 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -16,17 +16,40 @@ def create(document)
1616
@api_call.post(endpoint_path, document)
1717
end
1818

19-
def create_many(documents)
20-
documents_in_jsonl_format = documents.map { |document| Oj.dump(document) }.join("\n")
21-
results_in_jsonl_format = import(documents_in_jsonl_format)
22-
results_in_jsonl_format.split("\n").map { |r| Oj.load(r) }
19+
def upsert(document)
20+
@api_call.post(endpoint_path, document, action: :upsert)
2321
end
2422

25-
def import(documents_in_jsonl_format, query_parameters = {})
26-
@api_call.post(endpoint_path('import'),
27-
as_json: false,
28-
query: query_parameters,
29-
body: documents_in_jsonl_format)
23+
def update(document)
24+
@api_call.post(endpoint_path, document, action: :update)
25+
end
26+
27+
def create_many(documents, options = {})
28+
@api_call.logger.warn('#create_many is deprecated and will be removed in a future version. Use #import instead, which now takes both an array of documents or a JSONL string of documents')
29+
import(documents, options)
30+
end
31+
32+
# @param [Array,String] documents An array of document hashes or a JSONL string of documents.
33+
def import(documents, options = {})
34+
documents_in_jsonl_format = if documents.is_a?(Array)
35+
documents.map { |document| Oj.dump(document) }.join("\n")
36+
else
37+
documents
38+
end
39+
40+
results_in_jsonl_format = @api_call.perform_request(
41+
'post',
42+
endpoint_path('import'),
43+
query_parameters: options,
44+
body_parameters: documents_in_jsonl_format,
45+
additional_headers: { 'Content-Type' => 'text/plain' }
46+
)
47+
48+
if documents.is_a?(Array)
49+
results_in_jsonl_format.split("\n").map { |r| Oj.load(r) }
50+
else
51+
results_in_jsonl_format
52+
end
3053
end
3154

3255
def export

spec/typesense/document_spec.rb

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,27 @@
5757
end
5858
end
5959

60+
describe '#update' do
61+
it 'updates the specified document' do
62+
partial_document = {
63+
'id' => '124',
64+
'num_employees' => 5200
65+
}
66+
stub_request(:patch, Typesense::ApiCall.new(typesense.configuration).send(:uri_for, '/collections/companies/documents/124', typesense.configuration.nodes[0]))
67+
.with(
68+
headers: {
69+
'Content-Type' => 'application/json',
70+
'X-Typesense-Api-Key' => typesense.configuration.api_key
71+
}
72+
)
73+
.to_return(status: 200, body: JSON.dump(partial_document), headers: { 'Content-Type': 'application/json' })
74+
75+
result = document_124.update(partial_document)
76+
77+
expect(result).to eq(partial_document)
78+
end
79+
end
80+
6081
describe '#delete' do
6182
it 'deletes the specified document' do
6283
stub_request(:delete, Typesense::ApiCall.new(typesense.configuration).send(:uri_for, '/collections/companies/documents/124', typesense.configuration.nodes[0]))

0 commit comments

Comments
 (0)