activerecord-turbopuffer-adapter 0.1.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 +7 -0
- data/LICENSE.txt +21 -0
- data/README.md +157 -0
- data/lib/active_record/connection_adapters/turbopuffer_adapter.rb +356 -0
- data/lib/activerecord-turbopuffer-adapter.rb +1 -0
- data/lib/arel/nodes/glob.rb +15 -0
- data/lib/arel/nodes/rank_by_node.rb +15 -0
- data/lib/arel/visitors/turbopuffer.rb +280 -0
- data/lib/turbopuffer/active_record/glob.rb +12 -0
- data/lib/turbopuffer/active_record/railtie.rb +19 -0
- data/lib/turbopuffer/active_record/schema/attribute.rb +30 -0
- data/lib/turbopuffer/active_record/schema/model.rb +101 -0
- data/lib/turbopuffer/active_record/schema.rb +13 -0
- data/lib/turbopuffer/active_record/type/array.rb +30 -0
- data/lib/turbopuffer/active_record/type/date_time.rb +19 -0
- data/lib/turbopuffer/active_record/type/unsigned_integer.rb +12 -0
- data/lib/turbopuffer/active_record/type/vector.rb +30 -0
- data/lib/turbopuffer/active_record/type.rb +11 -0
- data/lib/turbopuffer/active_record/version.rb +5 -0
- data/lib/turbopuffer/active_record.rb +20 -0
- metadata +112 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 89fff3a49b190acd9a305bcd7be04cf0cc5c4880c1c3043ce51925c7a4dafee3
|
|
4
|
+
data.tar.gz: c03353b5f1b93e25c54b8b70dd3da248beb56aaf8b018b797c1b0aa0a6721f16
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: baf2d0855805fb1b4e42839a92221c8983af5d372370a9b2ae86885748716aed1b2c91f4e0c5f6e99afd5c4b20bca172ec3c81464eda9d428723eac07d257926
|
|
7
|
+
data.tar.gz: 02faeb7bde3748592e604d5fc568e134918a509301b116bbada20cc3f0fa0afb951a7fb247d26607987175eb0539d2d73c771924e584052cd10c52c7608f1c1f
|
data/LICENSE.txt
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
The MIT License (MIT)
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Richard Monette
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in
|
|
13
|
+
all copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
|
21
|
+
THE SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
# activerecord-turbopuffer-adapter
|
|
2
|
+
|
|
3
|
+
[](https://github.com/richardmonette/activerecord-turbopuffer-adapter/actions/workflows/ci.yml)
|
|
4
|
+
|
|
5
|
+
activerecord-turbopuffer-adapter is an unofficial, fan made Ruby on Rails ActiveRecord database adapter for turbopuffer. If you are looking for the official turbopuffer Ruby gem see: https://github.com/turbopuffer/turbopuffer-ruby
|
|
6
|
+
|
|
7
|
+
The purpose of this gem is to provide Rails developers a familiar ActiveRecord style interface to turbopuffer.
|
|
8
|
+
|
|
9
|
+
## Installation
|
|
10
|
+
|
|
11
|
+
To use this gem, install via Bundler by adding the following to your application's Gemfile:
|
|
12
|
+
|
|
13
|
+
```ruby
|
|
14
|
+
gem 'activerecord-turbopuffer-adapter'
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
## Usage
|
|
18
|
+
|
|
19
|
+
### Configuration
|
|
20
|
+
|
|
21
|
+
In `config/database.yml` define a turbopuffer adapter:
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
```yaml
|
|
25
|
+
development:
|
|
26
|
+
adapter: turbopuffer
|
|
27
|
+
region: gcp-us-central1
|
|
28
|
+
api_key: <%= ENV["TURBOPUFFER_API_KEY"] %>
|
|
29
|
+
database_tasks: false
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
Optionally, you can define `namespace_prefix`, which is useful for separating namespaces for production and development environments.
|
|
33
|
+
|
|
34
|
+
### Defining a model
|
|
35
|
+
|
|
36
|
+
```ruby
|
|
37
|
+
class Document < ApplicationRecord
|
|
38
|
+
turbopuffer_attribute "id", "uuid", not_null: 1
|
|
39
|
+
turbopuffer_attribute "title", "string", filterable: true
|
|
40
|
+
turbopuffer_attribute "body", "string", full_text_search: true
|
|
41
|
+
turbopuffer_attribute "published", "bool", filterable: true
|
|
42
|
+
turbopuffer_attribute "embedding", "[1536]f32", ann: true
|
|
43
|
+
end
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
By default the namespace is the model's table_name, but it can be customized with `self.table_name = "..."` Currently, ids are always UUIDv7 (which sort chronologically, to support pagination.)
|
|
47
|
+
|
|
48
|
+
The distance metric applies to all vector columns in a namespace and defaults to `cosine_distance`. To use `euclidean_squared` instead, declare it in the model:
|
|
49
|
+
|
|
50
|
+
```ruby
|
|
51
|
+
class Document < ApplicationRecord
|
|
52
|
+
turbopuffer_distance_metric "euclidean_squared"
|
|
53
|
+
|
|
54
|
+
turbopuffer_attribute "id", "uuid", not_null: 1
|
|
55
|
+
turbopuffer_attribute "embedding", "[1536]f32", ann: true
|
|
56
|
+
end
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
### Creating records
|
|
60
|
+
|
|
61
|
+
```ruby
|
|
62
|
+
Document.create!(title: "Hello", body: "...", published: true)
|
|
63
|
+
|
|
64
|
+
doc = Document.new(title: "Draft")
|
|
65
|
+
doc.save!
|
|
66
|
+
|
|
67
|
+
doc.update!(published: true)
|
|
68
|
+
doc.destroy
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
> Note that inserts are treated as upserts, such that writing a row whose id already exists is effectively treated as an update.
|
|
72
|
+
|
|
73
|
+
> Note that transactions are not supported, interacting with that portion of the ActiveRecord API is no-op
|
|
74
|
+
|
|
75
|
+
To avoid N+1s you can use insert_all/upsert_all.
|
|
76
|
+
|
|
77
|
+
```ruby
|
|
78
|
+
Document.insert_all(
|
|
79
|
+
documents.map { |doc| { title: doc.title, body: doc.body, body_embedding: doc.embedding } }
|
|
80
|
+
)
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
### Querying
|
|
84
|
+
|
|
85
|
+
```ruby
|
|
86
|
+
Document.where(published: true)
|
|
87
|
+
Document.where(id: ["a", "b"])
|
|
88
|
+
Document.where.not(id: ["a", "b"])
|
|
89
|
+
Document.where(created_at: 1.week.ago..)
|
|
90
|
+
|
|
91
|
+
Document.where(title: /^walrus/i)
|
|
92
|
+
Document.where(title: Document.glob("walrus*"))
|
|
93
|
+
|
|
94
|
+
Document.where(tags: "walrus")
|
|
95
|
+
Document.where(tags: ["walrus", "narwhal"])
|
|
96
|
+
Document.where.not(tags: "walrus")
|
|
97
|
+
Document.where(tags: nil)
|
|
98
|
+
Document.where(scores: 90..)
|
|
99
|
+
|
|
100
|
+
Document.order(:title).limit(20)
|
|
101
|
+
Document.group(:title).count
|
|
102
|
+
Document.count
|
|
103
|
+
Document.find("018f...")
|
|
104
|
+
|
|
105
|
+
Document.rank_by("vector", "ANN", query_vector).limit(10)
|
|
106
|
+
Document.rank_by("text", "BM25", "quick walrus").limit(10)
|
|
107
|
+
|
|
108
|
+
Document
|
|
109
|
+
.where(published: true)
|
|
110
|
+
.rank_by(["Sum", [
|
|
111
|
+
["Product", 2, ["category", "BM25", "mammal"]],
|
|
112
|
+
["text", "BM25", "quick walrus"],
|
|
113
|
+
]])
|
|
114
|
+
.limit(10)
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
> 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
|
|
118
|
+
|
|
119
|
+
### Using alongside Postgres
|
|
120
|
+
|
|
121
|
+
While something of a lark, aspirationally the idea of this gem is to make turbopuffer conveniently usable as the primary db in a Rails app. In practice, however, using turbopuffer alongside a traditional primary db (such as postgres) is a supported, potentially more practical solution.
|
|
122
|
+
|
|
123
|
+
A dual db approach can be setup as follows:
|
|
124
|
+
|
|
125
|
+
```yaml
|
|
126
|
+
development:
|
|
127
|
+
primary:
|
|
128
|
+
adapter: postgresql
|
|
129
|
+
database: myapp_development
|
|
130
|
+
turbopuffer:
|
|
131
|
+
adapter: turbopuffer
|
|
132
|
+
region: gcp-us-central1
|
|
133
|
+
api_key: <%= ENV["TURBOPUFFER_API_KEY"] %>
|
|
134
|
+
namespace_prefix: myapp-development
|
|
135
|
+
database_tasks: false
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
```ruby
|
|
139
|
+
class TurbopufferRecord < ApplicationRecord
|
|
140
|
+
self.abstract_class = true
|
|
141
|
+
|
|
142
|
+
connects_to database: { writing: :turbopuffer, reading: :turbopuffer }
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
class Document < TurbopufferRecord
|
|
146
|
+
turbopuffer_attribute "id", "uuid", not_null: 1
|
|
147
|
+
turbopuffer_attribute "body", "string", full_text_search: true
|
|
148
|
+
end
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
## Contributing
|
|
152
|
+
|
|
153
|
+
Bug reports and pull requests are welcome on GitHub at https://github.com/richardmonette/activerecord-turbopuffer-adapter. As this is an unofficial gem, please do not report bugs upstream.
|
|
154
|
+
|
|
155
|
+
## License
|
|
156
|
+
|
|
157
|
+
The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
|
|
@@ -0,0 +1,356 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "turbopuffer"
|
|
4
|
+
|
|
5
|
+
require "arel/visitors/turbopuffer"
|
|
6
|
+
require "turbopuffer/active_record/schema"
|
|
7
|
+
require "turbopuffer/active_record/type"
|
|
8
|
+
|
|
9
|
+
module ActiveRecord
|
|
10
|
+
module ConnectionAdapters
|
|
11
|
+
class CachedQuery < ActiveRecord::StatementCache::Query
|
|
12
|
+
def initialize(ast, visitor)
|
|
13
|
+
@ast, @visitor, @retryable = ast, visitor, true
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def sql_for(binds, connection)
|
|
17
|
+
@visitor.compile(@ast, bound_values: binds).first
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
class TurbopufferAdapter < AbstractAdapter
|
|
22
|
+
ADAPTER_NAME = "Turbopuffer"
|
|
23
|
+
|
|
24
|
+
class << self
|
|
25
|
+
private
|
|
26
|
+
|
|
27
|
+
def initialize_type_map(m)
|
|
28
|
+
m.register_type "string", Type::String.new
|
|
29
|
+
m.register_type "uuid", Type::String.new
|
|
30
|
+
m.register_type "int", Type::Integer.new(limit: 8)
|
|
31
|
+
m.register_type "uint", ::Turbopuffer::ActiveRecord::Type::UnsignedInteger.new(limit: 8)
|
|
32
|
+
m.register_type "float", Type::Float.new
|
|
33
|
+
m.register_type "bool", Type::Boolean.new
|
|
34
|
+
m.register_type "datetime", ::Turbopuffer::ActiveRecord::Type::DateTime.new
|
|
35
|
+
m.register_type(%r{\A\[\].+\z}) do |type|
|
|
36
|
+
::Turbopuffer::ActiveRecord::Type::Array.new(m.lookup(type.delete_prefix("[]")))
|
|
37
|
+
end
|
|
38
|
+
m.register_type(%r{\A\[\d+\]f(?:16|32)\z}) do |type|
|
|
39
|
+
::Turbopuffer::ActiveRecord::Type::Vector.new(type[/\d+/].to_i)
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
TYPE_MAP = Type::TypeMap.new.tap { |m| initialize_type_map(m) }
|
|
45
|
+
EXTENDED_TYPE_MAPS = Concurrent::Map.new
|
|
46
|
+
|
|
47
|
+
def column_definitions(table_name)
|
|
48
|
+
::Turbopuffer::ActiveRecord::Schema::Model.for_table(table_name).turbopuffer_attributes
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def tables
|
|
52
|
+
::Turbopuffer::ActiveRecord::Schema::Model.namespaces.map(&:table_name).uniq
|
|
53
|
+
end
|
|
54
|
+
def views = []
|
|
55
|
+
def data_sources = tables
|
|
56
|
+
def table_exists?(name) = tables.include?(name.to_s)
|
|
57
|
+
def data_source_exists?(name) = table_exists?(name)
|
|
58
|
+
|
|
59
|
+
def supports_insert_on_duplicate_skip? = true
|
|
60
|
+
def supports_insert_on_duplicate_update? = true
|
|
61
|
+
|
|
62
|
+
def supports_savepoints? = false
|
|
63
|
+
def supports_ddl_transactions? = false
|
|
64
|
+
def supports_transaction_isolation? = false
|
|
65
|
+
def supports_lazy_transactions? = false
|
|
66
|
+
def supports_restart_db_transaction? = false
|
|
67
|
+
def supports_advisory_locks? = false
|
|
68
|
+
|
|
69
|
+
def begin_db_transaction = nil
|
|
70
|
+
def commit_db_transaction = nil
|
|
71
|
+
def exec_rollback_db_transaction = nil
|
|
72
|
+
def create_savepoint(name = nil) = nil
|
|
73
|
+
def exec_rollback_to_savepoint(name = nil) = nil
|
|
74
|
+
def release_savepoint(name = nil) = nil
|
|
75
|
+
|
|
76
|
+
def begin_isolated_db_transaction(isolation)
|
|
77
|
+
raise ActiveRecord::TransactionIsolationError, "Turbopuffer does not support isolation levels"
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def primary_key(table_name)
|
|
81
|
+
"id"
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def default_insert_value(column)
|
|
85
|
+
nil
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def self.quote_column_name(name) = name.to_s
|
|
89
|
+
|
|
90
|
+
def view_exists?(name) = false
|
|
91
|
+
|
|
92
|
+
def write_query?(sql)
|
|
93
|
+
query = sql.is_a?(Array) ? sql.first : sql
|
|
94
|
+
query.op != :select
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def type_cast(value)
|
|
98
|
+
case value
|
|
99
|
+
when Array
|
|
100
|
+
value
|
|
101
|
+
else
|
|
102
|
+
super
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def new_column_from_field(table_name, field, definitions)
|
|
107
|
+
Column.new(field.name, lookup_cast_type(field.type), nil, nil, field.notnull.to_i == 0)
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def build_insert_sql(insert)
|
|
111
|
+
columns = insert.keys_including_timestamps.to_a
|
|
112
|
+
rows, _binds = insert.values_list
|
|
113
|
+
|
|
114
|
+
upsert_rows = rows.map do |row|
|
|
115
|
+
attributes = columns.zip(row).to_h
|
|
116
|
+
attributes["id"] ||= SecureRandom.uuid_v7
|
|
117
|
+
attributes
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
Arel::Visitors::TurbopufferQuery.new(
|
|
121
|
+
op: :insert,
|
|
122
|
+
namespace: insert.model.table_name,
|
|
123
|
+
upsert_rows: upsert_rows
|
|
124
|
+
)
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def high_precision_current_timestamp
|
|
128
|
+
::Time.current
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def cacheable_query(klass, arel)
|
|
132
|
+
_discarded, binds = visitor.compile(arel.ast, collecting_binds: true) # only needed to build the BindMap
|
|
133
|
+
[ CachedQuery.new(arel.ast, visitor), binds ]
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
TurbopufferResult = Struct.new(:fields, :rows, :affected_rows, keyword_init: true) do
|
|
137
|
+
def self.affected(count) = new(fields: [], rows: [], affected_rows: count)
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def turbopuffer_insert(namespace, query)
|
|
141
|
+
table_name = query.namespace
|
|
142
|
+
model = ::Turbopuffer::ActiveRecord::Schema::Model.for_table(table_name)
|
|
143
|
+
|
|
144
|
+
tpuf_insert_args = {
|
|
145
|
+
upsert_rows: query.upsert_rows,
|
|
146
|
+
schema: model.turbopuffer_schema_hash
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
if model.turbopuffer_attributes.any?(&:ann)
|
|
150
|
+
tpuf_insert_args[:distance_metric] = model.turbopuffer_distance_metric
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
tpuf_result = namespace.write(tpuf_insert_args)
|
|
154
|
+
|
|
155
|
+
fields = column_definitions(table_name).map(&:name)
|
|
156
|
+
rows = query.upsert_rows.map { |r| fields.map { |f| r[f] } }
|
|
157
|
+
|
|
158
|
+
TurbopufferResult.new(fields: fields, rows: rows, affected_rows: tpuf_result.rows_affected)
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
def turbopuffer_update(namespace, query)
|
|
162
|
+
patch = query.upsert_rows.to_h
|
|
163
|
+
pending = pending_patch_filter(patch)
|
|
164
|
+
filters = query.filters ? [ "And", [ query.filters, pending ] ] : pending
|
|
165
|
+
|
|
166
|
+
affected = write_in_batches(
|
|
167
|
+
namespace,
|
|
168
|
+
patch_by_filter: { filters: filters, patch: patch.transform_keys(&:to_sym) },
|
|
169
|
+
patch_by_filter_allow_partial: true
|
|
170
|
+
)
|
|
171
|
+
|
|
172
|
+
TurbopufferResult.affected(affected)
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
def write_in_batches(namespace, args)
|
|
176
|
+
affected = 0
|
|
177
|
+
|
|
178
|
+
loop do
|
|
179
|
+
tpuf_result = namespace.write(args)
|
|
180
|
+
affected += tpuf_result.rows_affected
|
|
181
|
+
|
|
182
|
+
break unless tpuf_result.rows_remaining
|
|
183
|
+
|
|
184
|
+
if tpuf_result.rows_affected.zero?
|
|
185
|
+
raise ActiveRecord::StatementInvalid, "write made no progress: rows still match #{args.inspect}"
|
|
186
|
+
end
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
affected
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
def pending_patch_filter(patch)
|
|
193
|
+
conditions = patch.flat_map do |attribute, value|
|
|
194
|
+
if value.nil?
|
|
195
|
+
[ [ attribute, "NotEq", nil ] ]
|
|
196
|
+
else
|
|
197
|
+
[ [ attribute, "NotEq", value ], [ attribute, "Eq", nil ] ]
|
|
198
|
+
end
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
[ "Or", conditions ]
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
def turbopuffer_delete(namespace, query)
|
|
205
|
+
attribute, operator, value = query.filters
|
|
206
|
+
|
|
207
|
+
affected = if query.filters.nil?
|
|
208
|
+
turbopuffer_delete_namespace(namespace)
|
|
209
|
+
elsif attribute == "id" && operator == "Eq"
|
|
210
|
+
namespace.write(deletes: [ value ]).rows_affected
|
|
211
|
+
elsif attribute == "id" && operator == "In"
|
|
212
|
+
namespace.write(deletes: value).rows_affected
|
|
213
|
+
else
|
|
214
|
+
write_in_batches(namespace, delete_by_filter: query.filters, delete_by_filter_allow_partial: true)
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
TurbopufferResult.affected(affected)
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
def turbopuffer_delete_namespace(namespace)
|
|
221
|
+
count = namespace.query(aggregate_by: { count: [ "Count" ] }).aggregations[:count]
|
|
222
|
+
namespace.delete_all
|
|
223
|
+
count
|
|
224
|
+
rescue Turbopuffer::Errors::NotFoundError
|
|
225
|
+
0
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
def turbopuffer_aggregate(namespace, query)
|
|
229
|
+
aggregate_alias, aggregate = query.aggregate_by
|
|
230
|
+
|
|
231
|
+
tpuf_query_args = {
|
|
232
|
+
aggregate_by: { aggregate_alias => aggregate }
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
tpuf_query_args[:filters] = query.filters if query.filters
|
|
236
|
+
|
|
237
|
+
if query.group_by.present?
|
|
238
|
+
tpuf_query_args[:group_by] = query.group_by
|
|
239
|
+
tpuf_query_args[:top_k] = query.top_k if query.top_k.present?
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
result = namespace.query(tpuf_query_args)
|
|
243
|
+
|
|
244
|
+
if query.group_by.present?
|
|
245
|
+
fields = query.group_by + [ aggregate_alias ]
|
|
246
|
+
rows = result.aggregation_groups.map(&:to_h).map { |g| fields.map { |f| g[f.to_sym] } }
|
|
247
|
+
|
|
248
|
+
TurbopufferResult.new(fields:, rows:, affected_rows: 0)
|
|
249
|
+
else
|
|
250
|
+
count = result.aggregations[aggregate_alias.to_sym]
|
|
251
|
+
|
|
252
|
+
TurbopufferResult.new(fields: [ aggregate_alias ], rows: [ [ count ] ], affected_rows: 0)
|
|
253
|
+
end
|
|
254
|
+
rescue Turbopuffer::Errors::NotFoundError
|
|
255
|
+
TurbopufferResult.affected(0)
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
def turbopuffer_select(namespace, query)
|
|
259
|
+
fields = if query.include_attributes == [ "*" ]
|
|
260
|
+
column_definitions(query.namespace).map(&:name)
|
|
261
|
+
else
|
|
262
|
+
query.include_attributes
|
|
263
|
+
end
|
|
264
|
+
|
|
265
|
+
tpuf_query_args = {
|
|
266
|
+
include_attributes: fields,
|
|
267
|
+
top_k: query.top_k.present? ? query.top_k : 10_000
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
tpuf_query_args[:rank_by] = query.rank_by.first if query.rank_by.present?
|
|
271
|
+
tpuf_query_args[:filters] = query.filters if query.filters
|
|
272
|
+
|
|
273
|
+
tpuf_result = namespace.query(tpuf_query_args)
|
|
274
|
+
|
|
275
|
+
rows = tpuf_result.rows.map(&:to_h).map { |r| fields.map { |f| r[f.to_sym] } }
|
|
276
|
+
|
|
277
|
+
TurbopufferResult.new(fields:, rows:, affected_rows: 0)
|
|
278
|
+
rescue Turbopuffer::Errors::NotFoundError
|
|
279
|
+
TurbopufferResult.affected(0)
|
|
280
|
+
end
|
|
281
|
+
|
|
282
|
+
def perform_query(raw_connection, sql, binds, type_casted_binds, prepare:, notification_payload:, batch:)
|
|
283
|
+
tpuf_query = sql.is_a?(Array) ? sql.first : sql
|
|
284
|
+
|
|
285
|
+
namespace_name = [ @config[:namespace_prefix], tpuf_query.namespace ].compact_blank.join("-")
|
|
286
|
+
namespace = raw_connection.namespace(namespace_name)
|
|
287
|
+
|
|
288
|
+
result = case tpuf_query.op
|
|
289
|
+
when :insert then turbopuffer_insert(namespace, tpuf_query)
|
|
290
|
+
when :update then turbopuffer_update(namespace, tpuf_query)
|
|
291
|
+
when :delete then turbopuffer_delete(namespace, tpuf_query)
|
|
292
|
+
when :select
|
|
293
|
+
if tpuf_query.aggregate_by
|
|
294
|
+
turbopuffer_aggregate(namespace, tpuf_query)
|
|
295
|
+
else
|
|
296
|
+
turbopuffer_select(namespace, tpuf_query)
|
|
297
|
+
end
|
|
298
|
+
end
|
|
299
|
+
|
|
300
|
+
notification_payload[:row_count] = result.rows.size
|
|
301
|
+
result
|
|
302
|
+
end
|
|
303
|
+
|
|
304
|
+
def arel_visitor
|
|
305
|
+
Arel::Visitors::Turbopuffer.new
|
|
306
|
+
end
|
|
307
|
+
|
|
308
|
+
def cast_result(result)
|
|
309
|
+
if result.fields.empty?
|
|
310
|
+
ActiveRecord::Result.empty(affected_rows: result.affected_rows)
|
|
311
|
+
else
|
|
312
|
+
ActiveRecord::Result.new(result.fields, result.rows, affected_rows: result.affected_rows)
|
|
313
|
+
end
|
|
314
|
+
end
|
|
315
|
+
|
|
316
|
+
def prefetch_primary_key?(table_name = nil)
|
|
317
|
+
true
|
|
318
|
+
end
|
|
319
|
+
|
|
320
|
+
def next_sequence_value(sequence_name)
|
|
321
|
+
SecureRandom.uuid_v7
|
|
322
|
+
end
|
|
323
|
+
|
|
324
|
+
def affected_rows(result)
|
|
325
|
+
result.affected_rows
|
|
326
|
+
end
|
|
327
|
+
|
|
328
|
+
def get_full_version
|
|
329
|
+
Turbopuffer::VERSION
|
|
330
|
+
end
|
|
331
|
+
|
|
332
|
+
def active?
|
|
333
|
+
connected?
|
|
334
|
+
end
|
|
335
|
+
|
|
336
|
+
def connect
|
|
337
|
+
@raw_connection = Turbopuffer::Client.new(
|
|
338
|
+
region: @config[:region],
|
|
339
|
+
api_key: @config[:api_key]
|
|
340
|
+
)
|
|
341
|
+
end
|
|
342
|
+
|
|
343
|
+
def disconnect!
|
|
344
|
+
@raw_connection = nil
|
|
345
|
+
end
|
|
346
|
+
|
|
347
|
+
def requires_reloading? = false
|
|
348
|
+
|
|
349
|
+
private
|
|
350
|
+
|
|
351
|
+
def reconnect
|
|
352
|
+
connect
|
|
353
|
+
end
|
|
354
|
+
end
|
|
355
|
+
end
|
|
356
|
+
end
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
require "turbopuffer/active_record"
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
module Arel::Nodes
|
|
2
|
+
class Glob < Arel::Nodes::Binary
|
|
3
|
+
attr_reader :case_sensitive
|
|
4
|
+
|
|
5
|
+
def initialize(left, right, case_sensitive: true)
|
|
6
|
+
super(left, right)
|
|
7
|
+
@case_sensitive = case_sensitive
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def hash = [ self.class, left, right, case_sensitive ].hash
|
|
11
|
+
|
|
12
|
+
def eql?(other) = super && case_sensitive == other.case_sensitive
|
|
13
|
+
alias == eql?
|
|
14
|
+
end
|
|
15
|
+
end
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
module Arel::Nodes
|
|
2
|
+
class RankByNode < Arel::Nodes::Unary
|
|
3
|
+
attr_reader :expression
|
|
4
|
+
|
|
5
|
+
def initialize(*expression)
|
|
6
|
+
@expression = expression.size == 1 ? expression.first : expression
|
|
7
|
+
super(nil)
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def hash = [ self.class, expression ].hash
|
|
11
|
+
|
|
12
|
+
def eql?(other) = self.class == other.class && expression == other.expression
|
|
13
|
+
alias == eql?
|
|
14
|
+
end
|
|
15
|
+
end
|
|
@@ -0,0 +1,280 @@
|
|
|
1
|
+
module Arel::Visitors
|
|
2
|
+
class TurbopufferQuery
|
|
3
|
+
def to_h
|
|
4
|
+
{
|
|
5
|
+
op: @op,
|
|
6
|
+
namespace: @namespace,
|
|
7
|
+
filters: @filters,
|
|
8
|
+
group_by: @group_by,
|
|
9
|
+
top_k: @top_k,
|
|
10
|
+
rank_by: @rank_by,
|
|
11
|
+
include_attributes: @include_attributes,
|
|
12
|
+
aggregate_by: @aggregate_by,
|
|
13
|
+
upsert_rows: @upsert_rows
|
|
14
|
+
}
|
|
15
|
+
end
|
|
16
|
+
def to_s = JSON.generate(to_h)
|
|
17
|
+
alias inspect to_s
|
|
18
|
+
def hash = to_h.hash
|
|
19
|
+
def eql?(other) = other.is_a?(self.class) && to_h == other.to_h
|
|
20
|
+
alias == eql?
|
|
21
|
+
|
|
22
|
+
attr_reader :op, :namespace, :filters, :top_k, :rank_by, :include_attributes, :upsert_rows, :aggregate_by, :group_by
|
|
23
|
+
|
|
24
|
+
def initialize(op:, namespace:, filters: nil, top_k: nil, rank_by: nil,
|
|
25
|
+
include_attributes: nil, upsert_rows: nil, aggregate_by: nil, group_by: nil)
|
|
26
|
+
@op = op
|
|
27
|
+
@namespace = namespace
|
|
28
|
+
@filters = filters
|
|
29
|
+
@top_k = top_k
|
|
30
|
+
@rank_by = rank_by
|
|
31
|
+
@include_attributes = include_attributes
|
|
32
|
+
@aggregate_by = aggregate_by
|
|
33
|
+
@group_by = group_by
|
|
34
|
+
@upsert_rows = upsert_rows
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
class Turbopuffer < Arel::Visitors::Visitor
|
|
39
|
+
def initialize
|
|
40
|
+
super
|
|
41
|
+
@binds = []
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
attr_accessor :binds
|
|
45
|
+
|
|
46
|
+
def compile(node, _collector = nil, bound_values: nil, collecting_binds: false)
|
|
47
|
+
@binds = []
|
|
48
|
+
@bound_values = bound_values
|
|
49
|
+
@collecting_binds = collecting_binds
|
|
50
|
+
[ visit(node), @binds ]
|
|
51
|
+
ensure
|
|
52
|
+
@bound_values = nil
|
|
53
|
+
@collecting_binds = false
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
private
|
|
57
|
+
|
|
58
|
+
def conjoin(filters)
|
|
59
|
+
case filters.size
|
|
60
|
+
when 0 then nil
|
|
61
|
+
when 1 then filters.first
|
|
62
|
+
else [ "And", filters ]
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def aggregate_node(projection)
|
|
67
|
+
projection.is_a?(Arel::Nodes::As) ? projection.left : projection
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def aggregate?(projection)
|
|
71
|
+
aggregate_node(projection).is_a?(Arel::Nodes::Function)
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def counted_attribute(projection)
|
|
75
|
+
node = aggregate_node(projection)
|
|
76
|
+
return unless node.is_a?(Arel::Nodes::Count)
|
|
77
|
+
|
|
78
|
+
expression = node.expressions.first
|
|
79
|
+
expression if expression.is_a?(Arel::Attributes::Attribute)
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def aggregate_for(o)
|
|
83
|
+
raise NotImplementedError, "distinct is not implemented yet" if o.distinct
|
|
84
|
+
|
|
85
|
+
case o
|
|
86
|
+
when Arel::Nodes::Count then [ "Count" ]
|
|
87
|
+
when Arel::Nodes::Sum then [ "Sum", visit(o.expressions.first) ]
|
|
88
|
+
else
|
|
89
|
+
raise NotImplementedError, "#{o.class.name.demodulize} is not supported, turbopuffer aggregates are Count and Sum"
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def visit_Arel_Nodes_SelectStatement(o)
|
|
94
|
+
raise NotImplementedError, "offset is not supported, filter on a sortable attribute for cursor pagination" if o.offset
|
|
95
|
+
# https://turbopuffer.com/docs/query#ordering-by-attributes: "Ordering by
|
|
96
|
+
# multiple attributes isn't yet implemented."
|
|
97
|
+
raise NotImplementedError, "one ranking per query, sort in Ruby after loading" if o.orders.size > 1
|
|
98
|
+
|
|
99
|
+
core = o.cores.last
|
|
100
|
+
|
|
101
|
+
raise NotImplementedError, "distinct is not implemented yet" if core.set_quantifier
|
|
102
|
+
raise NotImplementedError, "having is not implemented yet" if core.havings.any?
|
|
103
|
+
|
|
104
|
+
aggregates, attributes = core.projections.partition { |p| aggregate?(p) }
|
|
105
|
+
aggregate = aggregates.first
|
|
106
|
+
|
|
107
|
+
filters = core.wheres.map { |w| visit(w) }
|
|
108
|
+
counted = aggregate && counted_attribute(aggregate)
|
|
109
|
+
filters << [ visit(counted), "NotEq", nil ] if counted
|
|
110
|
+
|
|
111
|
+
TurbopufferQuery.new(
|
|
112
|
+
op: :select,
|
|
113
|
+
namespace: visit(core.source),
|
|
114
|
+
filters: conjoin(filters),
|
|
115
|
+
top_k: o.limit && visit(o.limit),
|
|
116
|
+
rank_by: o.orders.map { |ord| visit(ord) },
|
|
117
|
+
include_attributes: attributes.flat_map { |p| visit(p) },
|
|
118
|
+
group_by: core.groups.map { |g| visit(g) },
|
|
119
|
+
aggregate_by: aggregate && visit(aggregate)
|
|
120
|
+
)
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def visit_Arel_Nodes_InsertStatement(o)
|
|
124
|
+
raise NotImplementedError, "INSERT ... SELECT" if o.select
|
|
125
|
+
|
|
126
|
+
columns = o.columns.map { |c| visit(c) }
|
|
127
|
+
rows = o.values ? visit(o.values) : [ [] ]
|
|
128
|
+
|
|
129
|
+
TurbopufferQuery.new(
|
|
130
|
+
op: :insert,
|
|
131
|
+
namespace: visit(o.relation),
|
|
132
|
+
upsert_rows: rows.map { |row| columns.zip(row).to_h }
|
|
133
|
+
)
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def visit_Arel_Nodes_UpdateStatement(o)
|
|
137
|
+
rows = o.values ? visit(o.values) : [ [] ]
|
|
138
|
+
|
|
139
|
+
TurbopufferQuery.new(
|
|
140
|
+
op: :update,
|
|
141
|
+
namespace: visit(o.relation),
|
|
142
|
+
filters: conjoin(o.wheres.map { |w| visit(w) }),
|
|
143
|
+
upsert_rows: rows
|
|
144
|
+
)
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def visit_Arel_Nodes_DeleteStatement(o)
|
|
148
|
+
TurbopufferQuery.new(
|
|
149
|
+
op: :delete,
|
|
150
|
+
namespace: visit(o.relation),
|
|
151
|
+
filters: conjoin(o.wheres.map { |w| visit(w) })
|
|
152
|
+
)
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
def visit_ActiveModel_Attribute(o)
|
|
156
|
+
@binds << o
|
|
157
|
+
|
|
158
|
+
attribute = @bound_values ? @bound_values[@binds.size - 1] : o
|
|
159
|
+
|
|
160
|
+
if ActiveRecord::StatementCache::Substitute === attribute.value_before_type_cast
|
|
161
|
+
return nil if @collecting_binds
|
|
162
|
+
raise "unbound statement-cache placeholder for #{o.name.inspect}"
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
attribute.value_for_database
|
|
166
|
+
end
|
|
167
|
+
alias visit_ActiveRecord_Relation_QueryAttribute visit_ActiveModel_Attribute
|
|
168
|
+
|
|
169
|
+
def visit_Arel_Nodes_BindParam(o)
|
|
170
|
+
visit(o.value)
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
def visit_Arel_Nodes_ValuesList(o)
|
|
174
|
+
o.rows.map { |row| row.map { |v| visit(v) } }
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
def visit_Arel_Nodes_JoinSource(o)
|
|
178
|
+
raise NotImplementedError, "turbopuffer has no joins" if o.right.any?
|
|
179
|
+
visit(o.left)
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
def visit_Arel_Table(o) = o.name
|
|
183
|
+
|
|
184
|
+
# filters
|
|
185
|
+
def visit_Arel_Nodes_And(o) = [ "And", o.children.map { |c| visit(c) } ]
|
|
186
|
+
def visit_Arel_Nodes_Or(o) = [ "Or", [ visit(o.left), visit(o.right) ] ]
|
|
187
|
+
def visit_Arel_Nodes_Grouping(o) = visit(o.expr)
|
|
188
|
+
|
|
189
|
+
def visit_Arel_Nodes_UnqualifiedColumn(o)
|
|
190
|
+
visit o.expr
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
def visit_Arel_Nodes_Assignment(o)
|
|
194
|
+
[ visit(o.left), visit(o.right) ]
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
ARRAY_OPERATORS = {
|
|
198
|
+
"Eq" => "Contains", "NotEq" => "NotContains",
|
|
199
|
+
"In" => "ContainsAny", "NotIn" => "NotContainsAny",
|
|
200
|
+
"Lt" => "AnyLt", "Lte" => "AnyLte", "Gt" => "AnyGt", "Gte" => "AnyGte"
|
|
201
|
+
}.freeze
|
|
202
|
+
|
|
203
|
+
def comparison(node, operator, value = visit(node.right))
|
|
204
|
+
operator = ARRAY_OPERATORS.fetch(operator) if array_attribute?(node.left) && !value.nil?
|
|
205
|
+
|
|
206
|
+
[ visit(node.left), operator, value ]
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
def array_attribute?(node)
|
|
210
|
+
node.is_a?(Arel::Attributes::Attribute) &&
|
|
211
|
+
node.able_to_type_cast? &&
|
|
212
|
+
node.type_caster.is_a?(::Turbopuffer::ActiveRecord::Type::Array)
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
def visit_Arel_Nodes_Equality(o) = comparison(o, "Eq")
|
|
216
|
+
def visit_Arel_Nodes_NotEqual(o) = comparison(o, "NotEq")
|
|
217
|
+
def visit_Arel_Nodes_GreaterThan(o) = comparison(o, "Gt")
|
|
218
|
+
def visit_Arel_Nodes_GreaterThanOrEqual(o) = comparison(o, "Gte")
|
|
219
|
+
def visit_Arel_Nodes_LessThan(o) = comparison(o, "Lt")
|
|
220
|
+
def visit_Arel_Nodes_LessThanOrEqual(o) = comparison(o, "Lte")
|
|
221
|
+
def visit_Arel_Nodes_In(o) = comparison(o, "In")
|
|
222
|
+
|
|
223
|
+
def visit_Arel_Nodes_HomogeneousIn(o)
|
|
224
|
+
comparison(o, o.type == :in ? "In" : "NotIn", o.casted_values)
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
def visit_Arel_Nodes_Between(o)
|
|
228
|
+
low, high = o.right.children.map { |bound| visit(bound) }
|
|
229
|
+
|
|
230
|
+
[ "And", [ comparison(o, "Gte", low), comparison(o, "Lte", high) ] ]
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
def visit_Arel_Nodes_Not(o) = [ "Not", visit(o.expr) ]
|
|
234
|
+
|
|
235
|
+
def visit_Arel_Nodes_Regexp(o)
|
|
236
|
+
pattern = visit(o.right)
|
|
237
|
+
|
|
238
|
+
[ visit(o.left), "Regex", o.case_sensitive ? pattern : "(?i)#{pattern}" ]
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
def visit_Arel_Nodes_NotRegexp(o) = [ "Not", visit_Arel_Nodes_Regexp(o) ]
|
|
242
|
+
|
|
243
|
+
def visit_Arel_Nodes_Glob(o) = [ visit(o.left), o.case_sensitive ? "Glob" : "IGlob", visit(o.right) ]
|
|
244
|
+
|
|
245
|
+
def visit_Arel_Nodes_Count(o) = [ "count_all", aggregate_for(o) ]
|
|
246
|
+
def visit_Arel_Nodes_Sum(o) = [ "sum_#{visit(o.expressions.first)}", aggregate_for(o) ]
|
|
247
|
+
def visit_Arel_Nodes_Function(o) = aggregate_for(o)
|
|
248
|
+
|
|
249
|
+
def visit_Arel_Nodes_As(o)
|
|
250
|
+
aggregate?(o) ? [ o.right.to_s, aggregate_for(o.left) ] : visit(o.left)
|
|
251
|
+
end
|
|
252
|
+
|
|
253
|
+
def visit_Arel_Nodes_Group(o) = visit(o.expr)
|
|
254
|
+
|
|
255
|
+
# leaves — this is where binds get resolved
|
|
256
|
+
def visit_Arel_Attributes_Attribute(o) = o.name.to_s
|
|
257
|
+
def visit_Arel_Nodes_Casted(o) = o.value_for_database
|
|
258
|
+
def visit_Arel_Nodes_Quoted(o) = o.expr
|
|
259
|
+
def visit_Arel_Nodes_Limit(o) = visit(o.expr)
|
|
260
|
+
def visit_Arel_Nodes_Ascending(o) = [ visit(o.expr), "asc" ]
|
|
261
|
+
def visit_Arel_Nodes_Descending(o) = [ visit(o.expr), "desc" ]
|
|
262
|
+
|
|
263
|
+
def visit_Arel_Nodes_RankByNode(o) = o.expression
|
|
264
|
+
|
|
265
|
+
def visit_Arel_Nodes_SqlLiteral(o)
|
|
266
|
+
return "id" if o == ActiveRecord::FinderMethods::ONE_AS_ONE
|
|
267
|
+
|
|
268
|
+
raise NotImplementedError, "raw SQL is not supported: #{o}"
|
|
269
|
+
end
|
|
270
|
+
def visit_Arel_Nodes_BoundSqlLiteral(o) = raise(NotImplementedError, "raw SQL is not supported: #{o.sql_with_placeholders}")
|
|
271
|
+
|
|
272
|
+
def visit_Array(o) = o.map { |x| visit(x) }
|
|
273
|
+
def visit_Integer(o) = o
|
|
274
|
+
def visit_Float(o) = o
|
|
275
|
+
def visit_String(o) = o
|
|
276
|
+
def visit_TrueClass(o) = o
|
|
277
|
+
def visit_FalseClass(o) = o
|
|
278
|
+
def visit_NilClass(_) = nil
|
|
279
|
+
end
|
|
280
|
+
end
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
require "rails/railtie"
|
|
2
|
+
|
|
3
|
+
module Turbopuffer
|
|
4
|
+
module ActiveRecord
|
|
5
|
+
class Railtie < ::Rails::Railtie
|
|
6
|
+
initializer "turbopuffer.register_adapter", before: "active_record.initialize_database" do
|
|
7
|
+
::Turbopuffer::ActiveRecord.register_adapter!
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
initializer "turbopuffer.model_extensions" do
|
|
11
|
+
ActiveSupport.on_load(:active_record) do
|
|
12
|
+
require "turbopuffer/active_record/schema"
|
|
13
|
+
|
|
14
|
+
include ::Turbopuffer::ActiveRecord::Schema::Model
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
module Turbopuffer
|
|
2
|
+
module ActiveRecord
|
|
3
|
+
module Schema
|
|
4
|
+
class Attribute
|
|
5
|
+
TYPES = %r{\A(?:\[\])?(?:string|uuid|int|uint|float|bool|datetime)\z|\A\[\d+\]f(?:16|32)\z}
|
|
6
|
+
|
|
7
|
+
attr_reader :name, :type, :filterable, :full_text_search, :ann, :glob, :regex, :notnull
|
|
8
|
+
|
|
9
|
+
def initialize(name, type, not_null: 0, filterable: false, full_text_search: false,
|
|
10
|
+
ann: false, glob: false, regex: false)
|
|
11
|
+
type = type.to_s
|
|
12
|
+
|
|
13
|
+
unless TYPES.match?(type)
|
|
14
|
+
raise ArgumentError, "unknown turbopuffer type #{type.inspect} for attribute #{name.inspect}"
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
@name = name
|
|
18
|
+
@type = type
|
|
19
|
+
@filterable = filterable
|
|
20
|
+
@full_text_search = full_text_search
|
|
21
|
+
@ann = ann
|
|
22
|
+
@glob = glob
|
|
23
|
+
@regex = regex
|
|
24
|
+
|
|
25
|
+
@notnull = not_null
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
module Turbopuffer
|
|
2
|
+
module ActiveRecord
|
|
3
|
+
module Schema
|
|
4
|
+
module Model
|
|
5
|
+
extend ActiveSupport::Concern
|
|
6
|
+
|
|
7
|
+
DISTANCE_METRICS = [ "cosine_distance", "euclidean_squared" ].freeze
|
|
8
|
+
|
|
9
|
+
included do
|
|
10
|
+
class_attribute :turbopuffer_attributes, instance_accessor: false, default: [].freeze
|
|
11
|
+
class_attribute :_turbopuffer_distance_metric, instance_accessor: false, default: "cosine_distance"
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
class << self
|
|
15
|
+
def namespaces
|
|
16
|
+
::ActiveRecord::Base.descendants.select do |model|
|
|
17
|
+
model.respond_to?(:turbopuffer_namespace?) &&
|
|
18
|
+
!model.abstract_class? &&
|
|
19
|
+
model.turbopuffer_namespace?
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def for_table(table_name)
|
|
24
|
+
model = namespaces.find { |m| m.table_name == table_name }
|
|
25
|
+
|
|
26
|
+
unless model
|
|
27
|
+
raise UnknownNamespace,
|
|
28
|
+
"No turbopuffer namespace registered for #{table_name.inspect}. " \
|
|
29
|
+
"Declare attributes in the model with `turbopuffer_attribute`. " \
|
|
30
|
+
"Registered: #{namespaces.map(&:table_name).sort.join(", ")}"
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
model
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
class_methods do
|
|
38
|
+
def turbopuffer_attribute(name, type, not_null: 0, filterable: false,
|
|
39
|
+
full_text_search: false, ann: false, glob: false, regex: false)
|
|
40
|
+
attribute = ::Turbopuffer::ActiveRecord::Schema::Attribute.new(
|
|
41
|
+
name.to_s, type, not_null:, filterable:, full_text_search:, ann:, glob:, regex:
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
self.turbopuffer_attributes =
|
|
45
|
+
(turbopuffer_attributes.reject { |a| a.name == attribute.name } + [ attribute ]).freeze
|
|
46
|
+
|
|
47
|
+
attribute
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def turbopuffer_distance_metric(value = nil)
|
|
51
|
+
return _turbopuffer_distance_metric if value.nil?
|
|
52
|
+
|
|
53
|
+
unless DISTANCE_METRICS.include?(value)
|
|
54
|
+
raise ArgumentError,
|
|
55
|
+
"distance metric must be one of #{DISTANCE_METRICS.join(", ")}, got #{value.inspect}"
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
self._turbopuffer_distance_metric = value
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def rank_by(*expression)
|
|
62
|
+
expression = expression.first if expression.size == 1
|
|
63
|
+
order(::Arel::Nodes::RankByNode.new(expression))
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def glob(pattern, case_sensitive: true)
|
|
67
|
+
::Turbopuffer::ActiveRecord::Glob.new(pattern, case_sensitive:)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def predicate_builder
|
|
71
|
+
@predicate_builder ||= super.tap do |builder|
|
|
72
|
+
builder.register_handler(::Regexp, lambda { |attribute, regexp|
|
|
73
|
+
::Arel::Nodes::Regexp.new(attribute, ::Arel::Nodes.build_quoted(regexp.source), !regexp.casefold?)
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
builder.register_handler(::Turbopuffer::ActiveRecord::Glob, lambda { |attribute, glob|
|
|
77
|
+
::Arel::Nodes::Glob.new(attribute, ::Arel::Nodes.build_quoted(glob.pattern), case_sensitive: glob.case_sensitive)
|
|
78
|
+
})
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def turbopuffer_namespace? = turbopuffer_attributes.any?
|
|
83
|
+
|
|
84
|
+
def turbopuffer_schema_hash
|
|
85
|
+
turbopuffer_attributes.each_with_object({}) do |attribute, schema|
|
|
86
|
+
attrs = { type: attribute.type }
|
|
87
|
+
|
|
88
|
+
attrs[:filterable] = true if attribute.filterable
|
|
89
|
+
attrs[:full_text_search] = true if attribute.full_text_search
|
|
90
|
+
attrs[:ann] = true if attribute.ann
|
|
91
|
+
attrs[:glob] = true if attribute.glob
|
|
92
|
+
attrs[:regex] = true if attribute.regex
|
|
93
|
+
|
|
94
|
+
schema[attribute.name] = attrs
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
end
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
require "arel/nodes/glob"
|
|
2
|
+
require "arel/nodes/rank_by_node"
|
|
3
|
+
require "turbopuffer/active_record/glob"
|
|
4
|
+
require "turbopuffer/active_record/schema/attribute"
|
|
5
|
+
require "turbopuffer/active_record/schema/model"
|
|
6
|
+
|
|
7
|
+
module Turbopuffer
|
|
8
|
+
module ActiveRecord
|
|
9
|
+
module Schema
|
|
10
|
+
class UnknownNamespace < StandardError; end
|
|
11
|
+
end
|
|
12
|
+
end
|
|
13
|
+
end
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
module Turbopuffer
|
|
2
|
+
module ActiveRecord
|
|
3
|
+
module Type
|
|
4
|
+
class Array < ::ActiveModel::Type::Value
|
|
5
|
+
include ::ActiveModel::Type::Helpers::Mutable
|
|
6
|
+
|
|
7
|
+
def initialize(element_type)
|
|
8
|
+
@element_type = element_type
|
|
9
|
+
super()
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def serialize(value)
|
|
13
|
+
case value
|
|
14
|
+
when nil then nil
|
|
15
|
+
when ::Array then value.map { |element| @element_type.serialize(element) }
|
|
16
|
+
else @element_type.serialize(value)
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def deserialize(value)
|
|
21
|
+
case value
|
|
22
|
+
when nil then nil
|
|
23
|
+
when ::Array then value.map { |element| @element_type.deserialize(element) }
|
|
24
|
+
else @element_type.deserialize(value)
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
module Turbopuffer
|
|
2
|
+
module ActiveRecord
|
|
3
|
+
module Type
|
|
4
|
+
class DateTime < ::ActiveRecord::Type::DateTime
|
|
5
|
+
def serialize(value)
|
|
6
|
+
casted = cast(value)
|
|
7
|
+
|
|
8
|
+
if casted.respond_to?(:utc)
|
|
9
|
+
casted.utc.iso8601
|
|
10
|
+
elsif casted.is_a?(::Date)
|
|
11
|
+
::Time.utc(casted.year, casted.month, casted.day).iso8601
|
|
12
|
+
else
|
|
13
|
+
casted
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
module Turbopuffer
|
|
2
|
+
module ActiveRecord
|
|
3
|
+
module Type
|
|
4
|
+
class Vector < ::ActiveModel::Type::Value
|
|
5
|
+
include ::ActiveModel::Type::Helpers::Mutable
|
|
6
|
+
|
|
7
|
+
def initialize(dimensions)
|
|
8
|
+
@dimensions = dimensions
|
|
9
|
+
super()
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def serialize(value)
|
|
13
|
+
return if value.nil?
|
|
14
|
+
|
|
15
|
+
vector = ::Array.wrap(value).map { |component| Float(component) }
|
|
16
|
+
|
|
17
|
+
unless vector.size == @dimensions
|
|
18
|
+
raise ArgumentError, "expected a vector of #{@dimensions} dimensions, got #{vector.size}"
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
vector
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def deserialize(value)
|
|
25
|
+
value
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
require "turbopuffer/active_record/type/date_time"
|
|
2
|
+
require "turbopuffer/active_record/type/unsigned_integer"
|
|
3
|
+
require "turbopuffer/active_record/type/array"
|
|
4
|
+
require "turbopuffer/active_record/type/vector"
|
|
5
|
+
|
|
6
|
+
module Turbopuffer
|
|
7
|
+
module ActiveRecord
|
|
8
|
+
module Type
|
|
9
|
+
end
|
|
10
|
+
end
|
|
11
|
+
end
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
require "turbopuffer/active_record/version"
|
|
2
|
+
|
|
3
|
+
module Turbopuffer
|
|
4
|
+
module ActiveRecord
|
|
5
|
+
class Error < StandardError; end
|
|
6
|
+
|
|
7
|
+
# Teaches Active Record that `adapter: turbopuffer` in database.yml maps to
|
|
8
|
+
# our adapter. The railtie calls this during boot; test harnesses and
|
|
9
|
+
# non-Rails consumers call it themselves.
|
|
10
|
+
def self.register_adapter!
|
|
11
|
+
::ActiveRecord::ConnectionAdapters.register(
|
|
12
|
+
"turbopuffer",
|
|
13
|
+
"ActiveRecord::ConnectionAdapters::TurbopufferAdapter",
|
|
14
|
+
"active_record/connection_adapters/turbopuffer_adapter"
|
|
15
|
+
)
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
require "turbopuffer/active_record/railtie" if defined?(::Rails::Railtie)
|
metadata
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: activerecord-turbopuffer-adapter
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- Richard Monette
|
|
8
|
+
bindir: bin
|
|
9
|
+
cert_chain: []
|
|
10
|
+
date: 2026-09-18 00:00:00.000000000 Z
|
|
11
|
+
dependencies:
|
|
12
|
+
- !ruby/object:Gem::Dependency
|
|
13
|
+
name: activerecord
|
|
14
|
+
requirement: !ruby/object:Gem::Requirement
|
|
15
|
+
requirements:
|
|
16
|
+
- - "~>"
|
|
17
|
+
- !ruby/object:Gem::Version
|
|
18
|
+
version: '8.1'
|
|
19
|
+
type: :runtime
|
|
20
|
+
prerelease: false
|
|
21
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
22
|
+
requirements:
|
|
23
|
+
- - "~>"
|
|
24
|
+
- !ruby/object:Gem::Version
|
|
25
|
+
version: '8.1'
|
|
26
|
+
- !ruby/object:Gem::Dependency
|
|
27
|
+
name: railties
|
|
28
|
+
requirement: !ruby/object:Gem::Requirement
|
|
29
|
+
requirements:
|
|
30
|
+
- - "~>"
|
|
31
|
+
- !ruby/object:Gem::Version
|
|
32
|
+
version: '8.1'
|
|
33
|
+
type: :runtime
|
|
34
|
+
prerelease: false
|
|
35
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
36
|
+
requirements:
|
|
37
|
+
- - "~>"
|
|
38
|
+
- !ruby/object:Gem::Version
|
|
39
|
+
version: '8.1'
|
|
40
|
+
- !ruby/object:Gem::Dependency
|
|
41
|
+
name: turbopuffer
|
|
42
|
+
requirement: !ruby/object:Gem::Requirement
|
|
43
|
+
requirements:
|
|
44
|
+
- - ">="
|
|
45
|
+
- !ruby/object:Gem::Version
|
|
46
|
+
version: '2.4'
|
|
47
|
+
- - "<"
|
|
48
|
+
- !ruby/object:Gem::Version
|
|
49
|
+
version: '3'
|
|
50
|
+
type: :runtime
|
|
51
|
+
prerelease: false
|
|
52
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
53
|
+
requirements:
|
|
54
|
+
- - ">="
|
|
55
|
+
- !ruby/object:Gem::Version
|
|
56
|
+
version: '2.4'
|
|
57
|
+
- - "<"
|
|
58
|
+
- !ruby/object:Gem::Version
|
|
59
|
+
version: '3'
|
|
60
|
+
description: Interact with turbopuffer through a native feeling Active Record database
|
|
61
|
+
adapter
|
|
62
|
+
email: richard.monette@gmail.com
|
|
63
|
+
executables: []
|
|
64
|
+
extensions: []
|
|
65
|
+
extra_rdoc_files:
|
|
66
|
+
- README.md
|
|
67
|
+
files:
|
|
68
|
+
- LICENSE.txt
|
|
69
|
+
- README.md
|
|
70
|
+
- lib/active_record/connection_adapters/turbopuffer_adapter.rb
|
|
71
|
+
- lib/activerecord-turbopuffer-adapter.rb
|
|
72
|
+
- lib/arel/nodes/glob.rb
|
|
73
|
+
- lib/arel/nodes/rank_by_node.rb
|
|
74
|
+
- lib/arel/visitors/turbopuffer.rb
|
|
75
|
+
- lib/turbopuffer/active_record.rb
|
|
76
|
+
- lib/turbopuffer/active_record/glob.rb
|
|
77
|
+
- lib/turbopuffer/active_record/railtie.rb
|
|
78
|
+
- lib/turbopuffer/active_record/schema.rb
|
|
79
|
+
- lib/turbopuffer/active_record/schema/attribute.rb
|
|
80
|
+
- lib/turbopuffer/active_record/schema/model.rb
|
|
81
|
+
- lib/turbopuffer/active_record/type.rb
|
|
82
|
+
- lib/turbopuffer/active_record/type/array.rb
|
|
83
|
+
- lib/turbopuffer/active_record/type/date_time.rb
|
|
84
|
+
- lib/turbopuffer/active_record/type/unsigned_integer.rb
|
|
85
|
+
- lib/turbopuffer/active_record/type/vector.rb
|
|
86
|
+
- lib/turbopuffer/active_record/version.rb
|
|
87
|
+
homepage: https://github.com/richardmonette/activerecord-turbopuffer-adapter
|
|
88
|
+
licenses:
|
|
89
|
+
- MIT
|
|
90
|
+
metadata:
|
|
91
|
+
allowed_push_host: https://rubygems.org
|
|
92
|
+
source_code_uri: https://github.com/richardmonette/activerecord-turbopuffer-adapter
|
|
93
|
+
changelog_uri: https://github.com/richardmonette/activerecord-turbopuffer-adapter/blob/main/CHANGELOG.md
|
|
94
|
+
rubygems_mfa_required: 'true'
|
|
95
|
+
rdoc_options: []
|
|
96
|
+
require_paths:
|
|
97
|
+
- lib
|
|
98
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
99
|
+
requirements:
|
|
100
|
+
- - ">="
|
|
101
|
+
- !ruby/object:Gem::Version
|
|
102
|
+
version: 3.3.0
|
|
103
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
104
|
+
requirements:
|
|
105
|
+
- - ">="
|
|
106
|
+
- !ruby/object:Gem::Version
|
|
107
|
+
version: '0'
|
|
108
|
+
requirements: []
|
|
109
|
+
rubygems_version: 3.6.2
|
|
110
|
+
specification_version: 4
|
|
111
|
+
summary: Active Record adapter for turbopuffer
|
|
112
|
+
test_files: []
|