activerecord-turso 0.1.0 → 0.2.1
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 +70 -20
- data/lib/active_record/connection_adapters/turso_adapter/connection_management.rb +13 -12
- data/lib/active_record/connection_adapters/turso_adapter/database_statements.rb +37 -78
- data/lib/active_record/connection_adapters/turso_adapter/error_translation.rb +14 -6
- data/lib/active_record/connection_adapters/turso_adapter/schema_statements.rb +11 -0
- data/lib/active_record/connection_adapters/turso_adapter/statement_pool.rb +18 -0
- data/lib/active_record/connection_adapters/turso_adapter/transaction_management.rb +39 -9
- data/lib/active_record/connection_adapters/turso_adapter.rb +18 -5
- data/lib/activerecord-turso/version.rb +5 -0
- data/lib/turso/ar/connection.rb +97 -14
- metadata +18 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 04f8ecc726fa450fadcf8b819a04663b3035bed1c1b5f592d362ec07664d2f14
|
|
4
|
+
data.tar.gz: 4ce85117e6f562ead6d2defeaaa5c70fccc2c62492d9916c798e56bcf32acfca
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 6d18e249a927642d705959341439129523cb3f2763ab699a8c2dfbc1f38d8f52b3b831a96f140a95518f3e932a9da348184af605525d354a54893419f332c0aa
|
|
7
|
+
data.tar.gz: 8b1cec4617634414e11928453aeed21e2c382fe4a36357ca435b601f337f8b9ca3fb732f035db02ebd8a0fe8f7dfc35db961c384faffd2cf8937ea612ec6363e
|
data/README.md
CHANGED
|
@@ -1,23 +1,27 @@
|
|
|
1
|
-
#
|
|
1
|
+
# Activerecord-turso
|
|
2
2
|
|
|
3
|
-
ActiveRecord adapter for [Turso](https://turso.tech) (SQLite compatible).
|
|
3
|
+
ActiveRecord adapter for [Turso](https://turso.tech) (SQLite compatible db).
|
|
4
4
|
|
|
5
5
|
## Status
|
|
6
6
|
|
|
7
|
-
This adapter is under active development. It can run basic ActiveRecord operations against Turso today, but several production features are still being hardened. See [Limitations and Risks](#limitations-and-risks) before using it in production.
|
|
7
|
+
This adapter is under **active** development. It can run basic ActiveRecord operations against Turso today, but several production features are still being hardened. See [Limitations and Risks](#limitations-and-risks) before using it in production.
|
|
8
8
|
|
|
9
9
|
## Requirements
|
|
10
10
|
|
|
11
|
-
- Ruby >= 3.
|
|
11
|
+
- Ruby >= 3.2
|
|
12
12
|
- ActiveRecord >= 8.0, < 8.2
|
|
13
|
-
- The `turso` Ruby gem
|
|
13
|
+
- The `turso` Ruby gem
|
|
14
|
+
|
|
15
|
+
## Runtime dependencies
|
|
16
|
+
|
|
17
|
+
This adapter uses the `turso` gem and does **not** require the `sqlite3` Ruby gem at runtime.
|
|
14
18
|
|
|
15
19
|
## Installation
|
|
16
20
|
|
|
17
21
|
Add to your Gemfile:
|
|
18
22
|
|
|
19
23
|
```ruby
|
|
20
|
-
gem "activerecord-turso"
|
|
24
|
+
gem "activerecord-turso"
|
|
21
25
|
```
|
|
22
26
|
|
|
23
27
|
Then configure `database.yml`:
|
|
@@ -47,7 +51,23 @@ end
|
|
|
47
51
|
Post.create!(title: "Hello", body: "World", published: true)
|
|
48
52
|
```
|
|
49
53
|
|
|
50
|
-
|
|
54
|
+
## Recommended production configuration
|
|
55
|
+
|
|
56
|
+
```yaml
|
|
57
|
+
production:
|
|
58
|
+
adapter: turso
|
|
59
|
+
database: db/production.sqlite3
|
|
60
|
+
journal_mode: wal
|
|
61
|
+
pool: 5
|
|
62
|
+
timeout: 5000
|
|
63
|
+
busy_timeout: 5000
|
|
64
|
+
query_timeout: 30000
|
|
65
|
+
experimental_features: "index_method"
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
### MVCC / BEGIN CONCURRENT (experimental)
|
|
69
|
+
|
|
70
|
+
⚠️ **Experimental.** Do not use in production unless you understand the caveats below. Normal ActiveRecord model persistence (`create!`, `save!`, `update!`, `touch`) is **not supported** inside `transaction(concurrent: true)`.
|
|
51
71
|
|
|
52
72
|
Turso supports `BEGIN CONCURRENT` for optimistic, multi-writer transactions. To opt in, pass `concurrent: true` to `transaction`:
|
|
53
73
|
|
|
@@ -94,13 +114,24 @@ class AddFtsToPosts < ActiveRecord::Migration[8.1]
|
|
|
94
114
|
end
|
|
95
115
|
```
|
|
96
116
|
|
|
97
|
-
Query with
|
|
117
|
+
Query with the adapter helpers:
|
|
98
118
|
|
|
99
|
-
```
|
|
100
|
-
|
|
119
|
+
```ruby
|
|
120
|
+
match = ActiveRecord::Base.connection.fts_match(:posts, [:title, :body], "database")
|
|
121
|
+
Post.where(match)
|
|
122
|
+
|
|
123
|
+
score = ActiveRecord::Base.connection.fts_score(:posts, [:title, :body], "database")
|
|
124
|
+
Post.select(:id, :title, score.as("rank")).where(match).order("rank ASC")
|
|
101
125
|
```
|
|
102
126
|
|
|
103
|
-
Note: `fts5` virtual tables are not supported. Use `USING fts` indexes instead.
|
|
127
|
+
Note: `fts5` virtual tables are not supported. Use `USING fts` indexes instead. This requires the `index_method` experimental feature:
|
|
128
|
+
|
|
129
|
+
```yaml
|
|
130
|
+
production:
|
|
131
|
+
adapter: turso
|
|
132
|
+
database: db/production.sqlite3
|
|
133
|
+
experimental_features: "index_method"
|
|
134
|
+
```
|
|
104
135
|
|
|
105
136
|
### Concurrent transactions
|
|
106
137
|
|
|
@@ -112,19 +143,22 @@ development:
|
|
|
112
143
|
database: db/dev.sqlite3
|
|
113
144
|
journal_mode: mvcc
|
|
114
145
|
busy_timeout: 5000
|
|
146
|
+
|
|
147
|
+
experimental_features: "index_method"
|
|
115
148
|
```
|
|
116
149
|
|
|
117
150
|
Use `concurrent: true` inside the same pinned connection:
|
|
118
151
|
|
|
119
152
|
```ruby
|
|
120
153
|
ActiveRecord::Base.connection.transaction(concurrent: true) do
|
|
121
|
-
# read-modify-write
|
|
122
154
|
end
|
|
123
155
|
```
|
|
124
156
|
|
|
125
157
|
Caveats:
|
|
126
158
|
- The connection must stay checked out for the whole transaction; avoid work that yields the ActiveRecord connection back to the pool.
|
|
127
159
|
- `busy_timeout` is used for lock waits, not MVCC snapshot conflicts. Snapshot conflicts retry with bounded backoff.
|
|
160
|
+
- Normal ActiveRecord model persistence (`create!`, `save!`, `update!`, `touch`) is **not supported** inside a concurrent transaction because ActiveRecord opens its own internal transaction for each model change. Use raw SQL (`execute`, `exec_query`) or bulk operations (`insert_all`, `update_all`, `update_columns`) instead.
|
|
161
|
+
- FTS custom index modules are not supported in MVCC mode.
|
|
128
162
|
|
|
129
163
|
## Limitations and Risks
|
|
130
164
|
|
|
@@ -138,31 +172,35 @@ The adapter builds column type maps from `column_decltype` metadata. This works
|
|
|
138
172
|
|
|
139
173
|
The adapter's batch execution path splits multi-statement SQL on semicolons. This means SQL containing semicolons inside string literals, triggers, or stored expressions may be split incorrectly. Avoid relying on multi-statement strings other than simple schema dumps.
|
|
140
174
|
|
|
141
|
-
### 3. MVCC requires opt-in and has
|
|
175
|
+
### 3. MVCC requires opt-in and has ActiveRecord compatibility caveats
|
|
142
176
|
|
|
143
177
|
`BEGIN CONCURRENT` is powerful but breaks ActiveRecord's default assumptions:
|
|
144
178
|
|
|
145
179
|
- ActiveRecord expects transactions to commit unless the database returns an error. With `BEGIN CONCURRENT`, the commit can fail with a snapshot conflict and must be retried.
|
|
146
180
|
- The retry loop must run on the same connection. Rails' connection pool is not MVCC-aware and may return the connection to the pool between retries.
|
|
147
181
|
- Do not combine concurrent transactions with pessimistic locking (`lock!`, `with_lock`, `lock_version`).
|
|
182
|
+
- Normal model persistence (`create!`, `save!`, `update!`, `touch`) is unsupported inside a concurrent transaction because ActiveRecord opens an internal transaction for each model change.
|
|
148
183
|
|
|
149
|
-
Only use `transaction(concurrent: true)` after testing it under your app's concurrency patterns.
|
|
184
|
+
Only use `transaction(concurrent: true)` after testing it under your app's concurrency patterns, and prefer raw SQL or bulk operations inside the block.
|
|
150
185
|
|
|
151
|
-
### 4. Prepared statement cache is
|
|
186
|
+
### 4. Prepared statement cache is enabled with a bounded pool
|
|
152
187
|
|
|
153
|
-
The adapter
|
|
188
|
+
The adapter uses a bounded statement pool with a default limit inherited from Rails (typically `1000`). Statements are evicted with an LRU policy and finalized against the underlying Turso connection. Set `statement_limit` in `database.yml` to tune the pool size.
|
|
154
189
|
|
|
155
190
|
### 5. ActiveRecord 8.0 support is CI-tested, not locally tested
|
|
156
191
|
|
|
157
192
|
Only ActiveRecord 8.1 is installed in the primary development environment. ActiveRecord 8.0 compatibility is validated through CI. If you run into 8.0-specific issues, please report them.
|
|
158
193
|
|
|
159
|
-
### 6.
|
|
194
|
+
### 6. INSERT RETURNING is disabled (not supported by Turso)
|
|
195
|
+
|
|
196
|
+
The underlying Turso SQLite build does not support `INSERT ... RETURNING` syntax. The adapter reports `supports_insert_returning?` as `false`, so ActiveRecord falls back to `last_insert_rowid()` for retrieving inserted IDs.
|
|
197
|
+
|
|
198
|
+
### 7. Some SQLite-specific features are unsupported or conservatively flagged
|
|
160
199
|
|
|
161
200
|
- Transaction isolation levels other than the default are reported as unsupported (`supports_transaction_isolation?` returns `false`) because Turso remote connections do not provide shared-cache read-uncommitted semantics.
|
|
162
|
-
- `insert_returning` is enabled only when the reported SQLite version is `>= 3.35.0`.
|
|
163
201
|
- `insert_on_conflict` is enabled only when the reported SQLite version is `>= 3.24.0`.
|
|
164
202
|
|
|
165
|
-
###
|
|
203
|
+
### 8. `execute_batch` in the underlying bindings is a Ruby-side fallback
|
|
166
204
|
|
|
167
205
|
The `turso` gem provides `DB#execute_batch` as a convenience that splits and executes statements one by one. It does not use a native batch API, so it carries the same semicolon-splitting risk as item 2 above.
|
|
168
206
|
|
|
@@ -175,7 +213,19 @@ bundle install
|
|
|
175
213
|
bundle exec rake test
|
|
176
214
|
```
|
|
177
215
|
|
|
178
|
-
|
|
216
|
+
Run with MVCC mode:
|
|
217
|
+
|
|
218
|
+
```bash
|
|
219
|
+
TURSO_TEST_JOURNAL_MODE=mvcc bundle exec rake test
|
|
220
|
+
```
|
|
221
|
+
|
|
222
|
+
Run with generated-columns experimental feature:
|
|
223
|
+
|
|
224
|
+
```bash
|
|
225
|
+
TURSO_TEST_EXPERIMENTAL_FEATURES=generated_columns bundle exec rake test
|
|
226
|
+
```
|
|
227
|
+
|
|
228
|
+
The CI workflow in `.github/workflows/ci.yml` runs all three configurations automatically.
|
|
179
229
|
|
|
180
230
|
## License
|
|
181
231
|
|
|
@@ -9,14 +9,16 @@ module ActiveRecord
|
|
|
9
9
|
end
|
|
10
10
|
|
|
11
11
|
module ClassMethods
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
12
|
+
def new_client(config)
|
|
13
|
+
db_config = config.symbolize_keys.merge(:timeout => config[:timeout] || 5000)
|
|
14
|
+
db_config[:experimental_features] = Array(db_config[:experimental_features])
|
|
15
|
+
::Turso::AR::Connection.new(db_config)
|
|
16
|
+
end
|
|
16
17
|
end
|
|
17
18
|
|
|
18
19
|
def active?
|
|
19
|
-
|
|
20
|
+
return false unless @raw_connection
|
|
21
|
+
!@raw_connection.closed?
|
|
20
22
|
rescue ::Turso::Error
|
|
21
23
|
false
|
|
22
24
|
end
|
|
@@ -52,19 +54,18 @@ module ActiveRecord
|
|
|
52
54
|
execute("PRAGMA journal_mode = #{mode}")
|
|
53
55
|
if @config[:journal_mode].to_s.downcase == "mvcc"
|
|
54
56
|
@mvcc_enabled = true
|
|
57
|
+
ActiveRecord::Base.logger&.warn(
|
|
58
|
+
"Turso journal_mode: mvcc is experimental. Concurrent transactions require careful connection handling."
|
|
59
|
+
)
|
|
55
60
|
end
|
|
56
61
|
end
|
|
57
62
|
|
|
58
63
|
execute("PRAGMA foreign_keys = ON")
|
|
59
64
|
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
end
|
|
65
|
+
timeout = @config[:busy_timeout] || @config[:timeout] || 5000
|
|
66
|
+
@raw_connection.busy_timeout = timeout
|
|
67
|
+
|
|
64
68
|
|
|
65
|
-
if @config[:query_timeout]
|
|
66
|
-
@raw_connection.query_timeout = @config[:query_timeout]
|
|
67
|
-
end
|
|
68
69
|
end
|
|
69
70
|
end
|
|
70
71
|
end
|
|
@@ -6,42 +6,7 @@ module ActiveRecord
|
|
|
6
6
|
module DatabaseStatements
|
|
7
7
|
def execute(sql, name = nil)
|
|
8
8
|
materialize_transactions
|
|
9
|
-
raw_execute(sql, name)
|
|
10
|
-
end
|
|
11
|
-
|
|
12
|
-
def exec_query(sql, name = "SQL", binds = [], prepare: false)
|
|
13
|
-
materialize_transactions
|
|
14
|
-
|
|
15
|
-
type_casted_binds = type_casted_binds(binds)
|
|
16
|
-
log(sql, name, binds, type_casted_binds) do
|
|
17
|
-
with_raw_connection do |conn|
|
|
18
|
-
stmt = conn.prepare(sql)
|
|
19
|
-
stmt.bind_positional(type_casted_binds) unless type_casted_binds.empty?
|
|
20
|
-
result = build_result(stmt)
|
|
21
|
-
stmt.finalize
|
|
22
|
-
result
|
|
23
|
-
end
|
|
24
|
-
end
|
|
25
|
-
end
|
|
26
|
-
|
|
27
|
-
def exec_insert(sql, name = nil, binds = [], pk = nil, sequence_name = nil, returning = nil)
|
|
28
|
-
if returning && supports_insert_returning?
|
|
29
|
-
return exec_query(sql, name, binds)
|
|
30
|
-
end
|
|
31
|
-
|
|
32
|
-
materialize_transactions
|
|
33
|
-
|
|
34
|
-
type_casted_binds = type_casted_binds(binds)
|
|
35
|
-
log(sql, name, binds, type_casted_binds) do
|
|
36
|
-
with_raw_connection do |conn|
|
|
37
|
-
stmt = conn.prepare(sql)
|
|
38
|
-
stmt.bind_positional(type_casted_binds) unless type_casted_binds.empty?
|
|
39
|
-
stmt.execute
|
|
40
|
-
last_id = pk ? last_inserted_id(sql) : nil
|
|
41
|
-
stmt.finalize
|
|
42
|
-
last_id
|
|
43
|
-
end
|
|
44
|
-
end
|
|
9
|
+
raw_execute(sql, name)&.to_a
|
|
45
10
|
end
|
|
46
11
|
|
|
47
12
|
def last_inserted_id(sql)
|
|
@@ -60,22 +25,13 @@ module ActiveRecord
|
|
|
60
25
|
binds
|
|
61
26
|
end
|
|
62
27
|
|
|
63
|
-
def select_rows(arel, name = nil)
|
|
28
|
+
def select_rows(arel, name = nil, binds = [], _options = {})
|
|
64
29
|
arel = arel_from_relation(arel)
|
|
65
|
-
sql, binds = to_sql_and_binds(arel)
|
|
30
|
+
sql, binds = to_sql_and_binds(arel, binds)
|
|
66
31
|
type_casted_binds = type_casted_binds(binds)
|
|
67
32
|
|
|
68
33
|
log(sql, name, binds, type_casted_binds) do
|
|
69
|
-
|
|
70
|
-
stmt = conn.prepare(sql)
|
|
71
|
-
stmt.bind_positional(type_casted_binds) unless type_casted_binds.empty?
|
|
72
|
-
rows = []
|
|
73
|
-
while stmt.step == 1
|
|
74
|
-
rows << stmt.row.to_a
|
|
75
|
-
end
|
|
76
|
-
stmt.finalize
|
|
77
|
-
rows
|
|
78
|
-
end
|
|
34
|
+
exec_query(sql, name, binds, prepare: false).rows
|
|
79
35
|
end
|
|
80
36
|
end
|
|
81
37
|
|
|
@@ -85,22 +41,36 @@ module ActiveRecord
|
|
|
85
41
|
return ActiveRecord::Result.empty(affected_rows: 0)
|
|
86
42
|
end
|
|
87
43
|
|
|
88
|
-
if
|
|
89
|
-
raw_connection.
|
|
90
|
-
affected_rows = raw_connection.changes
|
|
91
|
-
verified!
|
|
92
|
-
notification_payload[:affected_rows] = affected_rows
|
|
93
|
-
notification_payload[:row_count] = 0
|
|
94
|
-
ActiveRecord::Result.empty(affected_rows: affected_rows)
|
|
44
|
+
stmt = if prepare
|
|
45
|
+
@statements[sql] ||= raw_connection.prepare(sql)
|
|
95
46
|
else
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
47
|
+
raw_connection.prepare(sql)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
stmt.reset if prepare
|
|
51
|
+
stmt.bind(*type_casted_binds) unless type_casted_binds.empty?
|
|
52
|
+
|
|
53
|
+
begin
|
|
54
|
+
if write_query?(sql) && !sql.match?(/\bRETURNING\b/i)
|
|
55
|
+
result = stmt.run
|
|
56
|
+
affected_rows = result[:changes]
|
|
57
|
+
verified!
|
|
58
|
+
notification_payload[:affected_rows] = affected_rows
|
|
59
|
+
notification_payload[:row_count] = 0
|
|
60
|
+
ActiveRecord::Result.empty(affected_rows: affected_rows)
|
|
61
|
+
else
|
|
62
|
+
columns_info = stmt.columns
|
|
63
|
+
columns = columns_info.map { |c| c[:name] }
|
|
64
|
+
rows = stmt.all.map(&:to_a)
|
|
65
|
+
affected_rows = raw_connection.changes
|
|
66
|
+
verified!
|
|
67
|
+
notification_payload[:affected_rows] = affected_rows
|
|
68
|
+
notification_payload[:row_count] = rows.length
|
|
69
|
+
type_map = build_type_map(stmt)
|
|
70
|
+
ActiveRecord::Result.new(columns, rows, type_map, affected_rows: affected_rows)
|
|
71
|
+
end
|
|
72
|
+
ensure
|
|
73
|
+
stmt.close unless prepare
|
|
104
74
|
end
|
|
105
75
|
end
|
|
106
76
|
|
|
@@ -109,11 +79,11 @@ module ActiveRecord
|
|
|
109
79
|
old_defer_foreign_keys = query_value("PRAGMA defer_foreign_keys")
|
|
110
80
|
|
|
111
81
|
begin
|
|
112
|
-
execute("PRAGMA defer_foreign_keys = ON")
|
|
82
|
+
execute("PRAGMA defer_foreign_keys = ON") unless old_defer_foreign_keys.nil?
|
|
113
83
|
execute("PRAGMA foreign_keys = OFF")
|
|
114
84
|
yield
|
|
115
85
|
ensure
|
|
116
|
-
|
|
86
|
+
unless old_defer_foreign_keys.nil?
|
|
117
87
|
execute("PRAGMA defer_foreign_keys = #{old_defer_foreign_keys}")
|
|
118
88
|
end
|
|
119
89
|
execute("PRAGMA foreign_keys = #{old_foreign_keys}")
|
|
@@ -122,21 +92,10 @@ module ActiveRecord
|
|
|
122
92
|
|
|
123
93
|
private
|
|
124
94
|
|
|
125
|
-
def build_result(stmt)
|
|
126
|
-
columns = (0...stmt.column_count).map { |i| stmt.column_name(i) }
|
|
127
|
-
type_map = build_type_map(stmt)
|
|
128
|
-
rows = []
|
|
129
|
-
while stmt.step == 1
|
|
130
|
-
rows << stmt.row.to_a
|
|
131
|
-
end
|
|
132
|
-
ActiveRecord::Result.new(columns, rows, type_map)
|
|
133
|
-
end
|
|
134
|
-
|
|
135
95
|
def build_type_map(stmt)
|
|
136
96
|
type_map = {}
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
decltype = stmt.respond_to?(:column_decltype) ? stmt.column_decltype(i) : nil
|
|
97
|
+
stmt.columns.each_with_index do |col, i|
|
|
98
|
+
decltype = col[:type]
|
|
140
99
|
next unless decltype
|
|
141
100
|
type_map[i] = decltype_to_type(decltype)
|
|
142
101
|
end
|
|
@@ -7,15 +7,23 @@ module ActiveRecord
|
|
|
7
7
|
def translate_exception(exception, message:, sql:, binds:)
|
|
8
8
|
cause = exception.cause
|
|
9
9
|
case cause
|
|
10
|
-
when ::Turso::
|
|
10
|
+
when ::Turso::ConstraintException
|
|
11
11
|
translate_constraint_error(message, sql, binds)
|
|
12
|
-
when ::Turso::
|
|
12
|
+
when ::Turso::NotADatabaseException
|
|
13
13
|
ActiveRecord::NoDatabaseError.new(message, sql: sql, binds: binds)
|
|
14
|
-
when ::Turso::
|
|
14
|
+
when ::Turso::BusySnapshotException
|
|
15
15
|
ActiveRecord::SerializationFailure.new(message, sql: sql, binds: binds)
|
|
16
|
-
when ::Turso::
|
|
16
|
+
when ::Turso::BusyException
|
|
17
17
|
ActiveRecord::Deadlocked.new(message, sql: sql, binds: binds)
|
|
18
|
-
when ::Turso::
|
|
18
|
+
when ::Turso::ReadonlyException
|
|
19
|
+
ActiveRecord::ReadOnlyRecord.new(message, sql: sql, binds: binds)
|
|
20
|
+
when ::Turso::IoException, ::Turso::CorruptException
|
|
21
|
+
ActiveRecord::StatementInvalid.new(message, sql: sql, binds: binds)
|
|
22
|
+
when ::Turso::DatabaseFullException
|
|
23
|
+
ActiveRecord::StatementInvalid.new(message, sql: sql, binds: binds)
|
|
24
|
+
when ::Turso::InterruptException
|
|
25
|
+
ActiveRecord::QueryCanceled.new(message, sql: sql, binds: binds)
|
|
26
|
+
when ::Turso::MisuseException
|
|
19
27
|
ActiveRecord::StatementInvalid.new(message, sql: sql, binds: binds)
|
|
20
28
|
else
|
|
21
29
|
super
|
|
@@ -26,7 +34,7 @@ module ActiveRecord
|
|
|
26
34
|
|
|
27
35
|
def translate_constraint_error(message, sql, binds)
|
|
28
36
|
case message
|
|
29
|
-
when /foreign key constraint/i
|
|
37
|
+
when /foreign key constraint|foreign key mismatch/i
|
|
30
38
|
ActiveRecord::InvalidForeignKey.new(message, sql: sql, binds: binds)
|
|
31
39
|
when /unique constraint|primary key/i
|
|
32
40
|
ActiveRecord::RecordNotUnique.new(message, sql: sql, binds: binds)
|
|
@@ -11,6 +11,7 @@ module ActiveRecord
|
|
|
11
11
|
AND name NOT LIKE 'sqlite_%'
|
|
12
12
|
AND name NOT LIKE 'fts_dir_%'
|
|
13
13
|
AND name NOT LIKE 'sqlite_fts_%'
|
|
14
|
+
AND name NOT LIKE '__turso_internal_%'
|
|
14
15
|
SQL
|
|
15
16
|
end
|
|
16
17
|
|
|
@@ -75,6 +76,16 @@ module ActiveRecord
|
|
|
75
76
|
SQL
|
|
76
77
|
end
|
|
77
78
|
|
|
79
|
+
def fts_match(_table_name, columns, query)
|
|
80
|
+
cols = Array(columns).map { |c| quote_table_name(c) }.join(", ")
|
|
81
|
+
Arel.sql("fts_match(#{cols}, #{quote(query)})")
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def fts_score(_table_name, columns, query)
|
|
85
|
+
cols = Array(columns).map { |c| quote_table_name(c) }.join(", ")
|
|
86
|
+
Arel.sql("fts_score(#{cols}, #{quote(query)})")
|
|
87
|
+
end
|
|
88
|
+
|
|
78
89
|
private
|
|
79
90
|
|
|
80
91
|
def options_to_fts_options(options)
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ActiveRecord
|
|
4
|
+
module ConnectionAdapters
|
|
5
|
+
class TursoAdapter < SQLite3Adapter
|
|
6
|
+
class StatementPool < ConnectionAdapters::StatementPool
|
|
7
|
+
alias reset clear
|
|
8
|
+
|
|
9
|
+
private
|
|
10
|
+
|
|
11
|
+
def dealloc(stmt)
|
|
12
|
+
stmt.close
|
|
13
|
+
rescue ::Turso::Error
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
@@ -34,6 +34,10 @@ module ActiveRecord
|
|
|
34
34
|
|
|
35
35
|
def transaction(requires_new: nil, isolation: nil, joinable: true, **options, &block)
|
|
36
36
|
if options.delete(:concurrent)
|
|
37
|
+
ActiveRecord::Base.logger&.warn(
|
|
38
|
+
"transaction(concurrent: true) is experimental and may break ActiveRecord model persistence. " \
|
|
39
|
+
"See adapter documentation."
|
|
40
|
+
)
|
|
37
41
|
transaction_with_mvcc(options, &block)
|
|
38
42
|
else
|
|
39
43
|
super
|
|
@@ -43,35 +47,61 @@ module ActiveRecord
|
|
|
43
47
|
private
|
|
44
48
|
|
|
45
49
|
def transaction_with_mvcc(options, &block)
|
|
50
|
+
connect if @raw_connection.nil?
|
|
51
|
+
|
|
46
52
|
unless @mvcc_enabled
|
|
47
53
|
raise ActiveRecord::AdapterError,
|
|
48
54
|
"transaction(concurrent: true) requires journal_mode: 'mvcc' in database.yml"
|
|
49
55
|
end
|
|
50
56
|
|
|
57
|
+
if options[:requires_new] == false
|
|
58
|
+
raise ActiveRecord::AdapterError,
|
|
59
|
+
"transaction(concurrent: true) is incompatible with nested transactions"
|
|
60
|
+
end
|
|
61
|
+
|
|
51
62
|
max_retries = @config.fetch(:concurrent_retry_limit, 50)
|
|
52
63
|
base_delay_ms = @config.fetch(:concurrent_retry_base_ms, 2)
|
|
53
64
|
retries = 0
|
|
65
|
+
pinned_connection_id = @raw_connection.object_id
|
|
54
66
|
|
|
55
67
|
loop do
|
|
56
68
|
begin
|
|
57
|
-
|
|
58
|
-
yield
|
|
59
|
-
|
|
60
|
-
return
|
|
69
|
+
@raw_connection.execute("BEGIN CONCURRENT")
|
|
70
|
+
result = yield
|
|
71
|
+
@raw_connection.execute("COMMIT")
|
|
72
|
+
return result
|
|
61
73
|
rescue ActiveRecord::StatementInvalid => e
|
|
62
|
-
|
|
74
|
+
rollback_if_active
|
|
63
75
|
raise unless concurrent_conflict?(e) && retries < max_retries
|
|
64
76
|
|
|
65
77
|
retries += 1
|
|
66
|
-
|
|
78
|
+
verified!
|
|
79
|
+
backoff(base_delay_ms, retries)
|
|
80
|
+
|
|
81
|
+
unless @raw_connection.object_id == pinned_connection_id
|
|
82
|
+
raise ActiveRecord::AdapterError,
|
|
83
|
+
"MVCC retry detected a different database connection. " \
|
|
84
|
+
"transaction(concurrent: true) must run on a pinned connection."
|
|
85
|
+
end
|
|
67
86
|
end
|
|
68
87
|
end
|
|
69
88
|
end
|
|
70
89
|
|
|
71
90
|
def concurrent_conflict?(exception)
|
|
72
|
-
exception.cause
|
|
73
|
-
|
|
74
|
-
|
|
91
|
+
cause = exception.cause
|
|
92
|
+
conflict_classes = [::Turso::BusySnapshotException, ::Turso::BusyException]
|
|
93
|
+
|
|
94
|
+
conflict_classes.any? { |klass| cause.is_a?(klass) } ||
|
|
95
|
+
/snapshot conflict|busy snapshot|database is locked|cannot start a transaction within a transaction/i.match?(exception.message)
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def rollback_if_active
|
|
99
|
+
return unless @raw_connection && !@raw_connection.closed?
|
|
100
|
+
@raw_connection.execute("ROLLBACK") rescue nil
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def backoff(base_delay_ms, retries)
|
|
104
|
+
sleep(base_delay_ms * retries / 1000.0)
|
|
75
105
|
end
|
|
76
106
|
|
|
77
107
|
def savepoint_name(name)
|
|
@@ -2,7 +2,9 @@
|
|
|
2
2
|
|
|
3
3
|
require "active_record/connection_adapters/sqlite3_adapter"
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
require_relative "turso_adapter/statement_pool"
|
|
6
|
+
|
|
7
|
+
Dir[File.expand_path("turso_adapter/*.rb", __dir__)].sort.each { |f| require f }
|
|
6
8
|
|
|
7
9
|
module ActiveRecord
|
|
8
10
|
module ConnectionAdapters
|
|
@@ -23,6 +25,10 @@ module ActiveRecord
|
|
|
23
25
|
false
|
|
24
26
|
end
|
|
25
27
|
|
|
28
|
+
def sqlite_version
|
|
29
|
+
@sqlite_version ||= Gem::Version.new(query_value("SELECT sqlite_version()"))
|
|
30
|
+
end
|
|
31
|
+
|
|
26
32
|
def supports_transaction_isolation?
|
|
27
33
|
false
|
|
28
34
|
end
|
|
@@ -35,14 +41,20 @@ module ActiveRecord
|
|
|
35
41
|
true
|
|
36
42
|
end
|
|
37
43
|
|
|
38
|
-
def
|
|
44
|
+
def quote_string(s)
|
|
45
|
+
s.gsub("'", "''")
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def explain(arel, binds = [], _options = [])
|
|
39
49
|
sql = "EXPLAIN QUERY PLAN " + to_sql(arel, binds)
|
|
40
50
|
result = exec_query(sql, "EXPLAIN", binds)
|
|
41
|
-
|
|
51
|
+
result.rows.map do |row|
|
|
52
|
+
row.join(" | ")
|
|
53
|
+
end.join("\n")
|
|
42
54
|
end
|
|
43
55
|
|
|
44
|
-
def
|
|
45
|
-
|
|
56
|
+
def build_statement_pool
|
|
57
|
+
StatementPool.new(self.class.type_cast_config_to_integer(@config[:statement_limit]))
|
|
46
58
|
end
|
|
47
59
|
|
|
48
60
|
def database_file_exists?
|
|
@@ -69,6 +81,7 @@ module ActiveRecord
|
|
|
69
81
|
def initialize_type_map(m)
|
|
70
82
|
super
|
|
71
83
|
m.register_type(%r(boolean)i, Type::Boolean.new)
|
|
84
|
+
m.register_type(%r(json)i, Type::Json.new)
|
|
72
85
|
end
|
|
73
86
|
end
|
|
74
87
|
end
|
data/lib/turso/ar/connection.rb
CHANGED
|
@@ -9,15 +9,21 @@ module Turso
|
|
|
9
9
|
|
|
10
10
|
def_delegators :@db, :close, :closed?, :changes, :total_changes
|
|
11
11
|
|
|
12
|
+
DEFAULT_BUSY_TIMEOUT_MS = 5000
|
|
13
|
+
DEFAULT_QUERY_TIMEOUT_MS = 30_000
|
|
14
|
+
|
|
12
15
|
def initialize(config)
|
|
13
16
|
@config = config
|
|
14
|
-
|
|
15
|
-
busy_timeout: config[:busy_timeout] || config[:timeout],
|
|
16
|
-
query_timeout: config[:query_timeout]
|
|
17
|
+
db_opts = {
|
|
18
|
+
busy_timeout: config[:busy_timeout] || config[:timeout] || DEFAULT_BUSY_TIMEOUT_MS,
|
|
19
|
+
query_timeout: config[:query_timeout] || DEFAULT_QUERY_TIMEOUT_MS
|
|
20
|
+
}
|
|
21
|
+
db_opts[:experimental_features] = config[:experimental_features] if config[:experimental_features]
|
|
22
|
+
@db = ::Turso::Database.new(config[:database].to_s, **db_opts)
|
|
17
23
|
end
|
|
18
24
|
|
|
19
25
|
def last_insert_rowid
|
|
20
|
-
query("SELECT last_insert_rowid()").first&.
|
|
26
|
+
query("SELECT last_insert_rowid()").first&.to_a&.first.to_i
|
|
21
27
|
end
|
|
22
28
|
|
|
23
29
|
def raw_connection
|
|
@@ -33,39 +39,116 @@ module Turso
|
|
|
33
39
|
end
|
|
34
40
|
|
|
35
41
|
def execute(sql, binds = [])
|
|
36
|
-
@db.execute(sql, normalize_binds(binds))
|
|
42
|
+
@db.execute(sql, *normalize_binds(binds))
|
|
37
43
|
nil
|
|
38
44
|
end
|
|
39
45
|
|
|
40
46
|
def query(sql, params = [])
|
|
41
|
-
@db.query(sql, normalize_binds(params))
|
|
47
|
+
@db.query(sql, *normalize_binds(params))
|
|
42
48
|
end
|
|
43
49
|
|
|
44
50
|
def execute_batch(sql)
|
|
45
|
-
sql
|
|
46
|
-
|
|
47
|
-
@db.execute(s) unless s.empty?
|
|
51
|
+
split_batch(sql).each do |stmt|
|
|
52
|
+
@db.execute(stmt)
|
|
48
53
|
end
|
|
49
54
|
end
|
|
50
55
|
|
|
51
56
|
def prepare(sql)
|
|
52
|
-
@db.
|
|
57
|
+
@db.prepare(sql)
|
|
53
58
|
end
|
|
54
59
|
|
|
55
60
|
def busy_timeout=(ms)
|
|
56
61
|
@db.busy_timeout = ms.to_i
|
|
57
62
|
end
|
|
58
63
|
|
|
59
|
-
def query_timeout=(ms)
|
|
60
|
-
@db.query_timeout = ms.to_i
|
|
61
|
-
end
|
|
62
|
-
|
|
63
64
|
def interrupt
|
|
64
65
|
@db.interrupt
|
|
65
66
|
end
|
|
66
67
|
|
|
67
68
|
private
|
|
68
69
|
|
|
70
|
+
def split_batch(sql)
|
|
71
|
+
statements = []
|
|
72
|
+
current = +""
|
|
73
|
+
in_string = false
|
|
74
|
+
in_line_comment = false
|
|
75
|
+
in_block_comment = false
|
|
76
|
+
i = 0
|
|
77
|
+
while i < sql.length
|
|
78
|
+
char = sql[i]
|
|
79
|
+
next_char = sql[i + 1]
|
|
80
|
+
|
|
81
|
+
if in_line_comment
|
|
82
|
+
if char == "\n"
|
|
83
|
+
in_line_comment = false
|
|
84
|
+
end
|
|
85
|
+
i += 1
|
|
86
|
+
next
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
if in_block_comment
|
|
90
|
+
if char == "*" && next_char == "/"
|
|
91
|
+
in_block_comment = false
|
|
92
|
+
i += 2
|
|
93
|
+
else
|
|
94
|
+
i += 1
|
|
95
|
+
end
|
|
96
|
+
next
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
if in_string
|
|
100
|
+
if char == "'" && next_char == "'"
|
|
101
|
+
current << char << next_char
|
|
102
|
+
i += 2
|
|
103
|
+
next
|
|
104
|
+
elsif char == "'"
|
|
105
|
+
in_string = false
|
|
106
|
+
current << char
|
|
107
|
+
i += 1
|
|
108
|
+
next
|
|
109
|
+
end
|
|
110
|
+
current << char
|
|
111
|
+
i += 1
|
|
112
|
+
next
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
case char
|
|
116
|
+
when "'"
|
|
117
|
+
in_string = true
|
|
118
|
+
current << char
|
|
119
|
+
when "-"
|
|
120
|
+
if next_char == "-"
|
|
121
|
+
in_line_comment = true
|
|
122
|
+
i += 2
|
|
123
|
+
next
|
|
124
|
+
end
|
|
125
|
+
current << char
|
|
126
|
+
when "#"
|
|
127
|
+
in_line_comment = true
|
|
128
|
+
i += 1
|
|
129
|
+
next
|
|
130
|
+
when "/"
|
|
131
|
+
if next_char == "*"
|
|
132
|
+
in_block_comment = true
|
|
133
|
+
i += 2
|
|
134
|
+
next
|
|
135
|
+
end
|
|
136
|
+
current << char
|
|
137
|
+
when ";"
|
|
138
|
+
stmt = current.strip
|
|
139
|
+
statements << stmt unless stmt.empty?
|
|
140
|
+
current = +""
|
|
141
|
+
else
|
|
142
|
+
current << char
|
|
143
|
+
end
|
|
144
|
+
i += 1
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
stmt = current.strip
|
|
148
|
+
statements << stmt unless stmt.empty?
|
|
149
|
+
statements
|
|
150
|
+
end
|
|
151
|
+
|
|
69
152
|
def normalize_binds(binds)
|
|
70
153
|
binds.map do |value|
|
|
71
154
|
case value
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: activerecord-turso
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.1
|
|
4
|
+
version: 0.2.1
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Ben D'Angelo
|
|
@@ -43,6 +43,20 @@ dependencies:
|
|
|
43
43
|
- - "~>"
|
|
44
44
|
- !ruby/object:Gem::Version
|
|
45
45
|
version: '0.1'
|
|
46
|
+
- !ruby/object:Gem::Dependency
|
|
47
|
+
name: irb
|
|
48
|
+
requirement: !ruby/object:Gem::Requirement
|
|
49
|
+
requirements:
|
|
50
|
+
- - ">="
|
|
51
|
+
- !ruby/object:Gem::Version
|
|
52
|
+
version: '0'
|
|
53
|
+
type: :development
|
|
54
|
+
prerelease: false
|
|
55
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
56
|
+
requirements:
|
|
57
|
+
- - ">="
|
|
58
|
+
- !ruby/object:Gem::Version
|
|
59
|
+
version: '0'
|
|
46
60
|
executables: []
|
|
47
61
|
extensions: []
|
|
48
62
|
extra_rdoc_files: []
|
|
@@ -53,8 +67,10 @@ files:
|
|
|
53
67
|
- lib/active_record/connection_adapters/turso_adapter/database_statements.rb
|
|
54
68
|
- lib/active_record/connection_adapters/turso_adapter/error_translation.rb
|
|
55
69
|
- lib/active_record/connection_adapters/turso_adapter/schema_statements.rb
|
|
70
|
+
- lib/active_record/connection_adapters/turso_adapter/statement_pool.rb
|
|
56
71
|
- lib/active_record/connection_adapters/turso_adapter/transaction_management.rb
|
|
57
72
|
- lib/activerecord-turso.rb
|
|
73
|
+
- lib/activerecord-turso/version.rb
|
|
58
74
|
- lib/turso/ar/connection.rb
|
|
59
75
|
licenses:
|
|
60
76
|
- MIT
|
|
@@ -66,7 +82,7 @@ required_ruby_version: !ruby/object:Gem::Requirement
|
|
|
66
82
|
requirements:
|
|
67
83
|
- - ">="
|
|
68
84
|
- !ruby/object:Gem::Version
|
|
69
|
-
version: 3.
|
|
85
|
+
version: 3.2.0
|
|
70
86
|
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
71
87
|
requirements:
|
|
72
88
|
- - ">="
|