activerecord-turbopuffer-adapter 0.1.7 → 0.1.9
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 +4 -4
- data/README.md +16 -1
- data/lib/active_record/connection_adapters/turbopuffer_adapter.rb +10 -3
- data/lib/arel/visitors/turbopuffer.rb +35 -13
- data/lib/turbopuffer/active_record/namespace_scope.rb +15 -0
- data/lib/turbopuffer/active_record/schema/model.rb +25 -0
- data/lib/turbopuffer/active_record/schema.rb +1 -0
- data/lib/turbopuffer/active_record/version.rb +1 -1
- data/lib/turbopuffer/active_record.rb +14 -0
- metadata +9 -8
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: fde17eff7db3a885b55a20550170a189c505ef52afc6bbdfded1f9b5dfcc9a76
|
|
4
|
+
data.tar.gz: 4ee793a06d003d5d69c6d3748c7a74331d6bd4c5db1df6c4ec79fba22f408fea
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: b716b0601df52af79a1d6edbba112630504f356fb2e6c7b91ad00ac6e41485809b43849eb58d8399f0238190db6ea3f5bd3b0b490f0658b17e2d138132f24b7c
|
|
7
|
+
data.tar.gz: 7b79a130b932801f07c98ec849cb9838c8c86246c432464b64aa2c9c079d8432146bb189986886169f614f395fb5e1c00a7b5ccc56a76424d4fbe1ca3ef6b290
|
data/README.md
CHANGED
|
@@ -109,6 +109,7 @@ Document.where(tags: nil)
|
|
|
109
109
|
Document.where(scores: 90..)
|
|
110
110
|
|
|
111
111
|
Document.order(:title).limit(20)
|
|
112
|
+
Document.order(:title).limit(20).offset(20)
|
|
112
113
|
Document.group(:title).count
|
|
113
114
|
Document.count
|
|
114
115
|
Document.find("018f...")
|
|
@@ -128,10 +129,24 @@ Document
|
|
|
128
129
|
.limit(10)
|
|
129
130
|
```
|
|
130
131
|
|
|
131
|
-
> Note that turbopuffer has a limit on the maximum number of documents returned (https://turbopuffer.com/docs/query#param-limit), so Document.all.to_a etc. will only return at most 10,000 items
|
|
132
|
+
> Note that turbopuffer has a limit on the maximum number of documents returned (https://turbopuffer.com/docs/query#param-limit), so Document.all.to_a etc. will only return at most 10,000 items, and `limit` plus `offset` must stay within that
|
|
132
133
|
|
|
133
134
|
> Eventual reads are cheaper but may not include writes from the last few seconds. Set a default with `turbopuffer_consistency "eventual"` in a model or `consistency: eventual` in `database.yml`. Strong is the default.
|
|
134
135
|
|
|
136
|
+
### Namespaces per tenant
|
|
137
|
+
|
|
138
|
+
turbopuffer recommends one namespace per tenant: a query can only ever see that tenant's documents, and search covers just the documents that matter rather than everyone's. `namespace` scopes a model to any namespace, and records remember where they were loaded from, so `update!`, `reload` and `destroy` go back to the same place.
|
|
139
|
+
|
|
140
|
+
```ruby
|
|
141
|
+
Document.namespace("documents-#{tenant.id}").create!(title: "Hello")
|
|
142
|
+
Document.namespace("documents-#{tenant.id}").where(published: true)
|
|
143
|
+
|
|
144
|
+
doc = Document.namespace("documents-#{tenant.id}").find(id)
|
|
145
|
+
doc.update!(published: true)
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
The name is used as given (under `namespace_prefix` if one is set). Without `namespace`, a model's table name is its namespace.
|
|
149
|
+
|
|
135
150
|
### Errors
|
|
136
151
|
|
|
137
152
|
Turbopuffer errors are raised as the ActiveRecord exceptions a Rails app already handles.
|
|
@@ -279,9 +279,11 @@ module ActiveRecord
|
|
|
279
279
|
|
|
280
280
|
tpuf_query_args = {
|
|
281
281
|
include_attributes: fields,
|
|
282
|
-
top_k: query.top_k.present? ? query.top_k : 10_000
|
|
282
|
+
top_k: query.top_k.present? ? query.top_k : 10_000 - query.offset.to_i
|
|
283
283
|
}
|
|
284
284
|
|
|
285
|
+
tpuf_query_args[:offset] = query.offset if query.offset
|
|
286
|
+
|
|
285
287
|
tpuf_query_args[:rank_by] = query.rank_by.first if query.rank_by.present?
|
|
286
288
|
tpuf_query_args[:filters] = query.filters if query.filters
|
|
287
289
|
consistency = consistency_for(query)
|
|
@@ -299,8 +301,7 @@ module ActiveRecord
|
|
|
299
301
|
def perform_query(raw_connection, sql, binds, type_casted_binds, prepare:, notification_payload:, batch:)
|
|
300
302
|
tpuf_query = sql.is_a?(Array) ? sql.first : sql
|
|
301
303
|
|
|
302
|
-
|
|
303
|
-
namespace = raw_connection.namespace(namespace_name)
|
|
304
|
+
namespace = raw_connection.namespace(namespace_name_for(tpuf_query))
|
|
304
305
|
|
|
305
306
|
result = case tpuf_query.op
|
|
306
307
|
when :insert then turbopuffer_insert(namespace, tpuf_query)
|
|
@@ -318,6 +319,12 @@ module ActiveRecord
|
|
|
318
319
|
result
|
|
319
320
|
end
|
|
320
321
|
|
|
322
|
+
def namespace_name_for(query)
|
|
323
|
+
override = query.namespace_override || ::Turbopuffer::ActiveRecord.namespace_override
|
|
324
|
+
|
|
325
|
+
[ @config[:namespace_prefix], override || query.namespace ].compact_blank.join("-")
|
|
326
|
+
end
|
|
327
|
+
|
|
321
328
|
def arel_visitor
|
|
322
329
|
Arel::Visitors::Turbopuffer.new
|
|
323
330
|
end
|
|
@@ -11,7 +11,9 @@ module Arel::Visitors
|
|
|
11
11
|
include_attributes: @include_attributes,
|
|
12
12
|
aggregate_by: @aggregate_by,
|
|
13
13
|
upsert_rows: @upsert_rows,
|
|
14
|
-
consistency: @consistency
|
|
14
|
+
consistency: @consistency,
|
|
15
|
+
namespace_override: @namespace_override,
|
|
16
|
+
offset: @offset
|
|
15
17
|
}
|
|
16
18
|
end
|
|
17
19
|
def to_s = JSON.generate(to_h)
|
|
@@ -21,10 +23,10 @@ module Arel::Visitors
|
|
|
21
23
|
alias == eql?
|
|
22
24
|
|
|
23
25
|
attr_reader :op, :namespace, :filters, :top_k, :rank_by, :include_attributes, :upsert_rows, :aggregate_by, :group_by,
|
|
24
|
-
:consistency
|
|
26
|
+
:consistency, :namespace_override, :offset
|
|
25
27
|
|
|
26
|
-
def initialize(op:, namespace:, filters: nil, top_k: nil, rank_by: nil, include_attributes: nil,
|
|
27
|
-
|
|
28
|
+
def initialize(op:, namespace:, filters: nil, top_k: nil, rank_by: nil, include_attributes: nil, upsert_rows: nil,
|
|
29
|
+
aggregate_by: nil, group_by: nil, consistency: nil, namespace_override: nil, offset: nil)
|
|
28
30
|
@op = op
|
|
29
31
|
@namespace = namespace
|
|
30
32
|
@filters = filters
|
|
@@ -35,6 +37,8 @@ module Arel::Visitors
|
|
|
35
37
|
@group_by = group_by
|
|
36
38
|
@upsert_rows = upsert_rows
|
|
37
39
|
@consistency = consistency
|
|
40
|
+
@namespace_override = namespace_override
|
|
41
|
+
@offset = offset
|
|
38
42
|
end
|
|
39
43
|
end
|
|
40
44
|
|
|
@@ -98,7 +102,6 @@ module Arel::Visitors
|
|
|
98
102
|
end
|
|
99
103
|
|
|
100
104
|
def visit_Arel_Nodes_SelectStatement(o)
|
|
101
|
-
raise NotImplementedError, "offset is not supported, filter on a sortable attribute for cursor pagination" if o.offset
|
|
102
105
|
# https://turbopuffer.com/docs/query#ordering-by-attributes: "Ordering by
|
|
103
106
|
# multiple attributes isn't yet implemented."
|
|
104
107
|
raise NotImplementedError, "one ranking per query, sort in Ruby after loading" if o.orders.size > 1
|
|
@@ -111,6 +114,8 @@ module Arel::Visitors
|
|
|
111
114
|
aggregates, attributes = core.projections.partition { |p| aggregate?(p) }
|
|
112
115
|
aggregate = aggregates.first
|
|
113
116
|
|
|
117
|
+
raise NotImplementedError, "offset is not supported on aggregate queries" if o.offset && aggregate
|
|
118
|
+
|
|
114
119
|
filters = core.wheres.map { |w| visit(w) }
|
|
115
120
|
counted = aggregate && counted_attribute(aggregate)
|
|
116
121
|
filters << [ visit(counted), "NotEq", nil ] if counted
|
|
@@ -124,10 +129,20 @@ module Arel::Visitors
|
|
|
124
129
|
include_attributes: attributes.flat_map { |p| visit(p) },
|
|
125
130
|
group_by: core.groups.map { |g| visit(g) },
|
|
126
131
|
aggregate_by: aggregate && visit(aggregate),
|
|
127
|
-
consistency: consistency_hint(core.optimizer_hints)
|
|
132
|
+
consistency: consistency_hint(core.optimizer_hints),
|
|
133
|
+
namespace_override: namespace_annotation(core.comment),
|
|
134
|
+
offset: o.offset && visit(o.offset)
|
|
128
135
|
)
|
|
129
136
|
end
|
|
130
137
|
|
|
138
|
+
def namespace_annotation(comment)
|
|
139
|
+
return unless comment
|
|
140
|
+
|
|
141
|
+
prefix = ::Turbopuffer::ActiveRecord::NAMESPACE_ANNOTATION
|
|
142
|
+
annotation = comment.values.reverse.find { |value| value.start_with?(prefix) }
|
|
143
|
+
annotation&.delete_prefix(prefix)
|
|
144
|
+
end
|
|
145
|
+
|
|
131
146
|
def consistency_hint(hints)
|
|
132
147
|
return unless hints
|
|
133
148
|
|
|
@@ -152,18 +167,20 @@ module Arel::Visitors
|
|
|
152
167
|
rows = o.values ? visit(o.values) : [ [] ]
|
|
153
168
|
|
|
154
169
|
TurbopufferQuery.new(
|
|
155
|
-
op:
|
|
156
|
-
namespace:
|
|
157
|
-
filters:
|
|
158
|
-
upsert_rows:
|
|
170
|
+
op: :update,
|
|
171
|
+
namespace: visit(o.relation),
|
|
172
|
+
filters: conjoin(o.wheres.map { |w| visit(w) }),
|
|
173
|
+
upsert_rows: rows,
|
|
174
|
+
namespace_override: namespace_annotation(o.comment)
|
|
159
175
|
)
|
|
160
176
|
end
|
|
161
177
|
|
|
162
178
|
def visit_Arel_Nodes_DeleteStatement(o)
|
|
163
179
|
TurbopufferQuery.new(
|
|
164
|
-
op:
|
|
165
|
-
namespace:
|
|
166
|
-
filters:
|
|
180
|
+
op: :delete,
|
|
181
|
+
namespace: visit(o.relation),
|
|
182
|
+
filters: conjoin(o.wheres.map { |w| visit(w) }),
|
|
183
|
+
namespace_override: namespace_annotation(o.comment)
|
|
167
184
|
)
|
|
168
185
|
end
|
|
169
186
|
|
|
@@ -194,6 +211,10 @@ module Arel::Visitors
|
|
|
194
211
|
visit(o.left)
|
|
195
212
|
end
|
|
196
213
|
|
|
214
|
+
def visit_Arel_Nodes_TableAlias(o)
|
|
215
|
+
raise NotImplementedError, "subqueries are not supported, count a limited or offset relation in Ruby after loading"
|
|
216
|
+
end
|
|
217
|
+
|
|
197
218
|
def visit_Arel_Table(o) = o.name
|
|
198
219
|
|
|
199
220
|
# filters
|
|
@@ -280,6 +301,7 @@ module Arel::Visitors
|
|
|
280
301
|
def visit_Arel_Nodes_Casted(o) = o.value_for_database
|
|
281
302
|
def visit_Arel_Nodes_Quoted(o) = o.expr
|
|
282
303
|
def visit_Arel_Nodes_Limit(o) = visit(o.expr)
|
|
304
|
+
def visit_Arel_Nodes_Offset(o) = visit(o.expr)
|
|
283
305
|
def visit_Arel_Nodes_Ascending(o) = [ visit(o.expr), "asc" ]
|
|
284
306
|
def visit_Arel_Nodes_Descending(o) = [ visit(o.expr), "desc" ]
|
|
285
307
|
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
module Turbopuffer
|
|
2
|
+
module ActiveRecord
|
|
3
|
+
module NamespaceScope
|
|
4
|
+
def turbopuffer_namespace
|
|
5
|
+
annotation = annotate_values.reverse.find { |value| value.start_with?(NAMESPACE_ANNOTATION) }
|
|
6
|
+
annotation&.delete_prefix(NAMESPACE_ANNOTATION)
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
def exec_queries(...) = ::Turbopuffer::ActiveRecord.with_namespace(turbopuffer_namespace) { super }
|
|
10
|
+
def insert_all(...) = ::Turbopuffer::ActiveRecord.with_namespace(turbopuffer_namespace) { super }
|
|
11
|
+
def insert_all!(...) = ::Turbopuffer::ActiveRecord.with_namespace(turbopuffer_namespace) { super }
|
|
12
|
+
def upsert_all(...) = ::Turbopuffer::ActiveRecord.with_namespace(turbopuffer_namespace) { super }
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
end
|
|
@@ -13,6 +13,27 @@ module Turbopuffer
|
|
|
13
13
|
class_attribute :_turbopuffer_consistency, instance_accessor: false, default: nil
|
|
14
14
|
end
|
|
15
15
|
|
|
16
|
+
def turbopuffer_namespace = @turbopuffer_namespace
|
|
17
|
+
|
|
18
|
+
def update_columns(...) = with_turbopuffer_namespace { super }
|
|
19
|
+
|
|
20
|
+
private
|
|
21
|
+
|
|
22
|
+
def init_internals
|
|
23
|
+
@turbopuffer_namespace = ::Turbopuffer::ActiveRecord.namespace_override ||
|
|
24
|
+
self.class.current_scope.try(:turbopuffer_namespace)
|
|
25
|
+
super
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def _create_record(...) = with_turbopuffer_namespace { super }
|
|
29
|
+
def _update_row(...) = with_turbopuffer_namespace { super }
|
|
30
|
+
def _delete_row(...) = with_turbopuffer_namespace { super }
|
|
31
|
+
def _find_record(...) = with_turbopuffer_namespace { super }
|
|
32
|
+
|
|
33
|
+
def with_turbopuffer_namespace(&block)
|
|
34
|
+
::Turbopuffer::ActiveRecord.with_namespace(@turbopuffer_namespace, &block)
|
|
35
|
+
end
|
|
36
|
+
|
|
16
37
|
class << self
|
|
17
38
|
def namespaces
|
|
18
39
|
::ActiveRecord::Base.descendants.select do |model|
|
|
@@ -82,6 +103,10 @@ module Turbopuffer
|
|
|
82
103
|
select(*turbopuffer_attributes.map(&:name))
|
|
83
104
|
end
|
|
84
105
|
|
|
106
|
+
def namespace(name)
|
|
107
|
+
annotate("#{NAMESPACE_ANNOTATION}#{name}").extending(::Turbopuffer::ActiveRecord::NamespaceScope)
|
|
108
|
+
end
|
|
109
|
+
|
|
85
110
|
def glob(pattern, case_sensitive: true)
|
|
86
111
|
::Turbopuffer::ActiveRecord::Glob.new(pattern, case_sensitive:)
|
|
87
112
|
end
|
|
@@ -4,6 +4,20 @@ module Turbopuffer
|
|
|
4
4
|
module ActiveRecord
|
|
5
5
|
class Error < StandardError; end
|
|
6
6
|
|
|
7
|
+
NAMESPACE_ANNOTATION = "turbopuffer_namespace="
|
|
8
|
+
|
|
9
|
+
def self.namespace_override
|
|
10
|
+
ActiveSupport::IsolatedExecutionState[:turbopuffer_namespace]
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def self.with_namespace(name)
|
|
14
|
+
previous = namespace_override
|
|
15
|
+
ActiveSupport::IsolatedExecutionState[:turbopuffer_namespace] = name
|
|
16
|
+
yield
|
|
17
|
+
ensure
|
|
18
|
+
ActiveSupport::IsolatedExecutionState[:turbopuffer_namespace] = previous
|
|
19
|
+
end
|
|
20
|
+
|
|
7
21
|
# Teaches Active Record that `adapter: turbopuffer` in database.yml maps to
|
|
8
22
|
# our adapter. The railtie calls this during boot; test harnesses and
|
|
9
23
|
# non-Rails consumers call it themselves.
|
metadata
CHANGED
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: activerecord-turbopuffer-adapter
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.1.
|
|
4
|
+
version: 0.1.9
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Richard Monette
|
|
8
8
|
bindir: bin
|
|
9
9
|
cert_chain: []
|
|
10
|
-
date: 2026-10-
|
|
10
|
+
date: 2026-10-11 00:00:00.000000000 Z
|
|
11
11
|
dependencies:
|
|
12
12
|
- !ruby/object:Gem::Dependency
|
|
13
13
|
name: activerecord
|
|
@@ -27,16 +27,16 @@ dependencies:
|
|
|
27
27
|
name: base64
|
|
28
28
|
requirement: !ruby/object:Gem::Requirement
|
|
29
29
|
requirements:
|
|
30
|
-
- - "
|
|
30
|
+
- - "~>"
|
|
31
31
|
- !ruby/object:Gem::Version
|
|
32
|
-
version: '0'
|
|
32
|
+
version: '0.2'
|
|
33
33
|
type: :runtime
|
|
34
34
|
prerelease: false
|
|
35
35
|
version_requirements: !ruby/object:Gem::Requirement
|
|
36
36
|
requirements:
|
|
37
|
-
- - "
|
|
37
|
+
- - "~>"
|
|
38
38
|
- !ruby/object:Gem::Version
|
|
39
|
-
version: '0'
|
|
39
|
+
version: '0.2'
|
|
40
40
|
- !ruby/object:Gem::Dependency
|
|
41
41
|
name: railties
|
|
42
42
|
requirement: !ruby/object:Gem::Requirement
|
|
@@ -57,7 +57,7 @@ dependencies:
|
|
|
57
57
|
requirements:
|
|
58
58
|
- - ">="
|
|
59
59
|
- !ruby/object:Gem::Version
|
|
60
|
-
version: '2.
|
|
60
|
+
version: '2.5'
|
|
61
61
|
- - "<"
|
|
62
62
|
- !ruby/object:Gem::Version
|
|
63
63
|
version: '3'
|
|
@@ -67,7 +67,7 @@ dependencies:
|
|
|
67
67
|
requirements:
|
|
68
68
|
- - ">="
|
|
69
69
|
- !ruby/object:Gem::Version
|
|
70
|
-
version: '2.
|
|
70
|
+
version: '2.5'
|
|
71
71
|
- - "<"
|
|
72
72
|
- !ruby/object:Gem::Version
|
|
73
73
|
version: '3'
|
|
@@ -89,6 +89,7 @@ files:
|
|
|
89
89
|
- lib/arel/visitors/turbopuffer.rb
|
|
90
90
|
- lib/turbopuffer/active_record.rb
|
|
91
91
|
- lib/turbopuffer/active_record/glob.rb
|
|
92
|
+
- lib/turbopuffer/active_record/namespace_scope.rb
|
|
92
93
|
- lib/turbopuffer/active_record/railtie.rb
|
|
93
94
|
- lib/turbopuffer/active_record/schema.rb
|
|
94
95
|
- lib/turbopuffer/active_record/schema/attribute.rb
|