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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: dbe88ca95c2999749426d72f89dd7d0327a53d5a08bf6a0b2446340d6b17e173
4
- data.tar.gz: c6c1611fe66ea9ca7dff51fde19ce3843249d8b91e955aa21ba500d9fe8efe1a
3
+ metadata.gz: fde17eff7db3a885b55a20550170a189c505ef52afc6bbdfded1f9b5dfcc9a76
4
+ data.tar.gz: 4ee793a06d003d5d69c6d3748c7a74331d6bd4c5db1df6c4ec79fba22f408fea
5
5
  SHA512:
6
- metadata.gz: 4cd6c79fd3b22a79ca2d18a8ed68765360b7e63c9a3f2d4e3bb82d644553bbf7fcd93a05489f58c3285c5967ec3f2e2f522bafa7e4c1c9debf5190b4f626c85c
7
- data.tar.gz: c8f7cdae01828498d93c66d345d34bdbf5cda39ff46ff13ec7126bfe97b42bba3d9c4b2337fbce08640755f87d49d42b9283cec227f9605d6b1af02bb9b1f998
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
- namespace_name = [ @config[:namespace_prefix], tpuf_query.namespace ].compact_blank.join("-")
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
- upsert_rows: nil, aggregate_by: nil, group_by: nil, consistency: nil)
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: :update,
156
- namespace: visit(o.relation),
157
- filters: conjoin(o.wheres.map { |w| visit(w) }),
158
- upsert_rows: 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: :delete,
165
- namespace: visit(o.relation),
166
- filters: conjoin(o.wheres.map { |w| visit(w) })
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
@@ -1,6 +1,7 @@
1
1
  require "arel/nodes/glob"
2
2
  require "arel/nodes/rank_by_node"
3
3
  require "turbopuffer/active_record/glob"
4
+ require "turbopuffer/active_record/namespace_scope"
4
5
  require "turbopuffer/active_record/schema/attribute"
5
6
  require "turbopuffer/active_record/schema/model"
6
7
 
@@ -1,5 +1,5 @@
1
1
  module Turbopuffer
2
2
  module ActiveRecord
3
- VERSION = "0.1.7"
3
+ VERSION = "0.1.9"
4
4
  end
5
5
  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.7
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-06 00:00:00.000000000 Z
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.4'
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.4'
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