activerecord-turso 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/README.md +182 -0
- data/lib/active_record/connection_adapters/turso_adapter/connection_management.rb +72 -0
- data/lib/active_record/connection_adapters/turso_adapter/database_statements.rb +162 -0
- data/lib/active_record/connection_adapters/turso_adapter/error_translation.rb +40 -0
- data/lib/active_record/connection_adapters/turso_adapter/schema_statements.rb +87 -0
- data/lib/active_record/connection_adapters/turso_adapter/transaction_management.rb +83 -0
- data/lib/active_record/connection_adapters/turso_adapter.rb +75 -0
- data/lib/activerecord-turso.rb +13 -0
- data/lib/turso/ar/connection.rb +80 -0
- metadata +79 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 0c8e05a11274a315a434eb615873a9402e8afd76ff296b8efc030d9bd4d212d9
|
|
4
|
+
data.tar.gz: 0fa05a0646df999aba45e14fc31a5e2066ad471964d7501fa3994df9882cbca8
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: ee153e58a81fb89d2a1578cbbd4841a5720cac7c942916c1c256c234e3c0726f6bc0a2fb500cbcd7733a357efb43b1da37e9a8774a7d2eecfd98561cf9cdda76
|
|
7
|
+
data.tar.gz: 9d5ec6478407aa7c168c29dfbd64b6dc0661e84fbbb1167041ef8b2615fb5ffab899474787c2ceb946430f2aa92bac8684adaf6ffe2b6cf6188a953eb9ced36b
|
data/README.md
ADDED
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
# activerecord-turso
|
|
2
|
+
|
|
3
|
+
ActiveRecord adapter for [Turso](https://turso.tech) (SQLite compatible).
|
|
4
|
+
|
|
5
|
+
## Status
|
|
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.
|
|
8
|
+
|
|
9
|
+
## Requirements
|
|
10
|
+
|
|
11
|
+
- Ruby >= 3.0
|
|
12
|
+
- ActiveRecord >= 8.0, < 8.2
|
|
13
|
+
- The `turso` Ruby gem (local bindings at `~/Projects/turso/bindings/ruby` during development)
|
|
14
|
+
|
|
15
|
+
## Installation
|
|
16
|
+
|
|
17
|
+
Add to your Gemfile:
|
|
18
|
+
|
|
19
|
+
```ruby
|
|
20
|
+
gem "activerecord-turso", path: "~/Projects/activerecord-turso"
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
Then configure `database.yml`:
|
|
24
|
+
|
|
25
|
+
```yaml
|
|
26
|
+
development:
|
|
27
|
+
adapter: turso
|
|
28
|
+
database: "path/to/db.sqlite3"
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
For an in-memory database:
|
|
32
|
+
|
|
33
|
+
```yaml
|
|
34
|
+
test:
|
|
35
|
+
adapter: turso
|
|
36
|
+
database: ":memory:"
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
## Usage
|
|
40
|
+
|
|
41
|
+
Most standard ActiveRecord operations work the same as with the SQLite3 adapter:
|
|
42
|
+
|
|
43
|
+
```ruby
|
|
44
|
+
class Post < ActiveRecord::Base
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
Post.create!(title: "Hello", body: "World", published: true)
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
### MVCC / BEGIN CONCURRENT
|
|
51
|
+
|
|
52
|
+
Turso supports `BEGIN CONCURRENT` for optimistic, multi-writer transactions. To opt in, pass `concurrent: true` to `transaction`:
|
|
53
|
+
|
|
54
|
+
```ruby
|
|
55
|
+
ActiveRecord::Base.transaction(concurrent: true) do
|
|
56
|
+
user.update!(balance: user.balance - 100)
|
|
57
|
+
order.create!(amount: 100)
|
|
58
|
+
end
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
If the commit detects a snapshot conflict, the adapter will automatically retry the block up to a configured limit with exponential backoff.
|
|
62
|
+
|
|
63
|
+
Configure retry behavior in `database.yml`:
|
|
64
|
+
|
|
65
|
+
```yaml
|
|
66
|
+
development:
|
|
67
|
+
adapter: turso
|
|
68
|
+
database: "path/to/db.sqlite3"
|
|
69
|
+
turso_mvcc_max_retries: 50
|
|
70
|
+
turso_mvcc_base_delay_ms: 10
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
**Important caveats:**
|
|
74
|
+
|
|
75
|
+
- `transaction(concurrent: true)` requires the same database connection to be held for the entire retry loop. Rails' connection pool may reap the connection between retries, which can silently break MVCC semantics. Use this only when you understand the pooling behavior of your app.
|
|
76
|
+
- `lock!`, `with_lock`, and `lock_version` are not meaningful under MVCC. Do not use them inside concurrent transactions.
|
|
77
|
+
- If the retry limit is exhausted, the conflict is raised as `ActiveRecord::StatementInvalid`. You must handle it in application code.
|
|
78
|
+
|
|
79
|
+
### Full-text search (Tantivy)
|
|
80
|
+
|
|
81
|
+
Turso provides full-text search through Tantivy. Use `CREATE INDEX ... USING fts`:
|
|
82
|
+
|
|
83
|
+
```sql
|
|
84
|
+
CREATE INDEX fts_posts ON posts USING fts (title, body);
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
From migrations:
|
|
88
|
+
|
|
89
|
+
```ruby
|
|
90
|
+
class AddFtsToPosts < ActiveRecord::Migration[8.1]
|
|
91
|
+
def change
|
|
92
|
+
add_fts_index :posts, [:title, :body], tokenizer: :default
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
Query with Tantivy functions:
|
|
98
|
+
|
|
99
|
+
```sql
|
|
100
|
+
SELECT * FROM posts WHERE fts_match(title, body, 'database');
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
Note: `fts5` virtual tables are not supported. Use `USING fts` indexes instead.
|
|
104
|
+
|
|
105
|
+
### Concurrent transactions
|
|
106
|
+
|
|
107
|
+
Turso supports multi-writer concurrency with MVCC. Enable it in `database.yml`:
|
|
108
|
+
|
|
109
|
+
```yaml
|
|
110
|
+
development:
|
|
111
|
+
adapter: turso
|
|
112
|
+
database: db/dev.sqlite3
|
|
113
|
+
journal_mode: mvcc
|
|
114
|
+
busy_timeout: 5000
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
Use `concurrent: true` inside the same pinned connection:
|
|
118
|
+
|
|
119
|
+
```ruby
|
|
120
|
+
ActiveRecord::Base.connection.transaction(concurrent: true) do
|
|
121
|
+
# read-modify-write
|
|
122
|
+
end
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
Caveats:
|
|
126
|
+
- The connection must stay checked out for the whole transaction; avoid work that yields the ActiveRecord connection back to the pool.
|
|
127
|
+
- `busy_timeout` is used for lock waits, not MVCC snapshot conflicts. Snapshot conflicts retry with bounded backoff.
|
|
128
|
+
|
|
129
|
+
## Limitations and Risks
|
|
130
|
+
|
|
131
|
+
The following limitations apply to the current implementation. Read this section carefully before deploying to production.
|
|
132
|
+
|
|
133
|
+
### 1. Result type metadata uses column declared types
|
|
134
|
+
|
|
135
|
+
The adapter builds column type maps from `column_decltype` metadata. This works for most column definitions but may not capture type information for computed expressions or subquery columns.
|
|
136
|
+
|
|
137
|
+
### 2. Batch SQL execution uses a simple string splitter
|
|
138
|
+
|
|
139
|
+
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
|
+
|
|
141
|
+
### 3. MVCC requires opt-in and has pooling caveats
|
|
142
|
+
|
|
143
|
+
`BEGIN CONCURRENT` is powerful but breaks ActiveRecord's default assumptions:
|
|
144
|
+
|
|
145
|
+
- 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
|
+
- 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
|
+
- Do not combine concurrent transactions with pessimistic locking (`lock!`, `with_lock`, `lock_version`).
|
|
148
|
+
|
|
149
|
+
Only use `transaction(concurrent: true)` after testing it under your app's concurrency patterns.
|
|
150
|
+
|
|
151
|
+
### 4. Prepared statement cache is disabled
|
|
152
|
+
|
|
153
|
+
The adapter returns `false` for `default_prepared_statements` and uses a no-op statement pool. Each query is prepared and finalized individually. This is slower than the upstream SQLite3 adapter for high-volume repeated queries, but avoids correctness issues until the Turso bindings expose stable prepared-statement reuse.
|
|
154
|
+
|
|
155
|
+
### 5. ActiveRecord 8.0 support is CI-tested, not locally tested
|
|
156
|
+
|
|
157
|
+
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
|
+
|
|
159
|
+
### 6. Some SQLite-specific features are unsupported or conservatively flagged
|
|
160
|
+
|
|
161
|
+
- 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
|
+
- `insert_on_conflict` is enabled only when the reported SQLite version is `>= 3.24.0`.
|
|
164
|
+
|
|
165
|
+
### 7. `execute_batch` in the underlying bindings is a Ruby-side fallback
|
|
166
|
+
|
|
167
|
+
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
|
+
|
|
169
|
+
## Development
|
|
170
|
+
|
|
171
|
+
Run the local test suite:
|
|
172
|
+
|
|
173
|
+
```bash
|
|
174
|
+
bundle install
|
|
175
|
+
bundle exec rake test
|
|
176
|
+
```
|
|
177
|
+
|
|
178
|
+
To run against the official Rails Active Record test suite, see the CI workflow in `.github/workflows/test.yml` and the project design doc in `docs/superpowers/specs/`.
|
|
179
|
+
|
|
180
|
+
## License
|
|
181
|
+
|
|
182
|
+
MIT
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ActiveRecord
|
|
4
|
+
module ConnectionAdapters
|
|
5
|
+
class TursoAdapter < SQLite3Adapter
|
|
6
|
+
module ConnectionManagement
|
|
7
|
+
def self.included(base)
|
|
8
|
+
base.extend(ClassMethods)
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
module ClassMethods
|
|
12
|
+
def new_client(config)
|
|
13
|
+
db_config = config.symbolize_keys.merge(:timeout => config[:timeout] || 5000)
|
|
14
|
+
::Turso::AR::Connection.new(db_config)
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def active?
|
|
19
|
+
@raw_connection && !@raw_connection.closed?
|
|
20
|
+
rescue ::Turso::Error
|
|
21
|
+
false
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def reconnect!(**)
|
|
25
|
+
@lock.synchronize do
|
|
26
|
+
disconnect!
|
|
27
|
+
@raw_connection = self.class.new_client(@config)
|
|
28
|
+
configure_connection
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def disconnect!
|
|
33
|
+
@lock.synchronize do
|
|
34
|
+
@raw_connection&.close
|
|
35
|
+
@raw_connection = nil
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def raw_connection
|
|
40
|
+
@lock.synchronize { @raw_connection }
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def jdbc?
|
|
44
|
+
false
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def configure_connection
|
|
48
|
+
@mvcc_enabled = false
|
|
49
|
+
|
|
50
|
+
if @config[:journal_mode]
|
|
51
|
+
mode = @config[:journal_mode].to_s
|
|
52
|
+
execute("PRAGMA journal_mode = #{mode}")
|
|
53
|
+
if @config[:journal_mode].to_s.downcase == "mvcc"
|
|
54
|
+
@mvcc_enabled = true
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
execute("PRAGMA foreign_keys = ON")
|
|
59
|
+
|
|
60
|
+
if @config[:timeout] || @config[:busy_timeout]
|
|
61
|
+
timeout = @config[:busy_timeout] || @config[:timeout]
|
|
62
|
+
@raw_connection.busy_timeout = timeout
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
if @config[:query_timeout]
|
|
66
|
+
@raw_connection.query_timeout = @config[:query_timeout]
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ActiveRecord
|
|
4
|
+
module ConnectionAdapters
|
|
5
|
+
class TursoAdapter < SQLite3Adapter
|
|
6
|
+
module DatabaseStatements
|
|
7
|
+
def execute(sql, name = nil)
|
|
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
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def last_inserted_id(sql)
|
|
48
|
+
@raw_connection.last_insert_rowid
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def returning_column_values(result)
|
|
52
|
+
[@raw_connection.last_insert_rowid]
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def default_timezone
|
|
56
|
+
ActiveRecord.default_timezone
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def combine_named_bind_params(binds)
|
|
60
|
+
binds
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def select_rows(arel, name = nil)
|
|
64
|
+
arel = arel_from_relation(arel)
|
|
65
|
+
sql, binds = to_sql_and_binds(arel)
|
|
66
|
+
type_casted_binds = type_casted_binds(binds)
|
|
67
|
+
|
|
68
|
+
log(sql, name, binds, type_casted_binds) do
|
|
69
|
+
with_raw_connection do |conn|
|
|
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
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def perform_query(raw_connection, sql, binds, type_casted_binds, prepare:, notification_payload:, batch: false)
|
|
83
|
+
if batch
|
|
84
|
+
raw_connection.execute_batch(sql)
|
|
85
|
+
return ActiveRecord::Result.empty(affected_rows: 0)
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
if write_query?(sql)
|
|
89
|
+
raw_connection.execute(sql, type_casted_binds)
|
|
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)
|
|
95
|
+
else
|
|
96
|
+
result = raw_connection.query(sql, type_casted_binds)
|
|
97
|
+
columns = result.column_names
|
|
98
|
+
rows = result.map(&:values)
|
|
99
|
+
affected_rows = raw_connection.changes
|
|
100
|
+
verified!
|
|
101
|
+
notification_payload[:affected_rows] = affected_rows
|
|
102
|
+
notification_payload[:row_count] = rows.length
|
|
103
|
+
ActiveRecord::Result.new(columns, rows, nil, affected_rows: affected_rows)
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def disable_referential_integrity
|
|
108
|
+
old_foreign_keys = query_value("PRAGMA foreign_keys")
|
|
109
|
+
old_defer_foreign_keys = query_value("PRAGMA defer_foreign_keys")
|
|
110
|
+
|
|
111
|
+
begin
|
|
112
|
+
execute("PRAGMA defer_foreign_keys = ON")
|
|
113
|
+
execute("PRAGMA foreign_keys = OFF")
|
|
114
|
+
yield
|
|
115
|
+
ensure
|
|
116
|
+
if old_defer_foreign_keys
|
|
117
|
+
execute("PRAGMA defer_foreign_keys = #{old_defer_foreign_keys}")
|
|
118
|
+
end
|
|
119
|
+
execute("PRAGMA foreign_keys = #{old_foreign_keys}")
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
private
|
|
124
|
+
|
|
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
|
+
def build_type_map(stmt)
|
|
136
|
+
type_map = {}
|
|
137
|
+
count = stmt.column_count
|
|
138
|
+
count.times do |i|
|
|
139
|
+
decltype = stmt.respond_to?(:column_decltype) ? stmt.column_decltype(i) : nil
|
|
140
|
+
next unless decltype
|
|
141
|
+
type_map[i] = decltype_to_type(decltype)
|
|
142
|
+
end
|
|
143
|
+
type_map
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def decltype_to_type(decltype)
|
|
147
|
+
case decltype.to_s.upcase
|
|
148
|
+
when /INT/ then Type::Integer.new
|
|
149
|
+
when /REAL|FLOA|DOUB/ then Type::Float.new
|
|
150
|
+
when /DEC|NUM/ then Type::Decimal.new
|
|
151
|
+
when /BOOL/ then Type::Boolean.new
|
|
152
|
+
when /DATETIME/ then Type::DateTime.new
|
|
153
|
+
when /DATE/ then Type::Date.new
|
|
154
|
+
when /JSON/ then Type::Json.new
|
|
155
|
+
when /BLOB/ then Type::Binary.new
|
|
156
|
+
else Type::String.new
|
|
157
|
+
end
|
|
158
|
+
end
|
|
159
|
+
end
|
|
160
|
+
end
|
|
161
|
+
end
|
|
162
|
+
end
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ActiveRecord
|
|
4
|
+
module ConnectionAdapters
|
|
5
|
+
class TursoAdapter < SQLite3Adapter
|
|
6
|
+
module ErrorTranslation
|
|
7
|
+
def translate_exception(exception, message:, sql:, binds:)
|
|
8
|
+
cause = exception.cause
|
|
9
|
+
case cause
|
|
10
|
+
when ::Turso::ConstraintError
|
|
11
|
+
translate_constraint_error(message, sql, binds)
|
|
12
|
+
when ::Turso::NotADatabaseError
|
|
13
|
+
ActiveRecord::NoDatabaseError.new(message, sql: sql, binds: binds)
|
|
14
|
+
when ::Turso::BusySnapshotError
|
|
15
|
+
ActiveRecord::SerializationFailure.new(message, sql: sql, binds: binds)
|
|
16
|
+
when ::Turso::BusyError
|
|
17
|
+
ActiveRecord::Deadlocked.new(message, sql: sql, binds: binds)
|
|
18
|
+
when ::Turso::ReadonlyError
|
|
19
|
+
ActiveRecord::StatementInvalid.new(message, sql: sql, binds: binds)
|
|
20
|
+
else
|
|
21
|
+
super
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
private
|
|
26
|
+
|
|
27
|
+
def translate_constraint_error(message, sql, binds)
|
|
28
|
+
case message
|
|
29
|
+
when /foreign key constraint/i
|
|
30
|
+
ActiveRecord::InvalidForeignKey.new(message, sql: sql, binds: binds)
|
|
31
|
+
when /unique constraint|primary key/i
|
|
32
|
+
ActiveRecord::RecordNotUnique.new(message, sql: sql, binds: binds)
|
|
33
|
+
else
|
|
34
|
+
ActiveRecord::StatementInvalid.new(message, sql: sql, binds: binds)
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ActiveRecord
|
|
4
|
+
module ConnectionAdapters
|
|
5
|
+
class TursoAdapter < SQLite3Adapter
|
|
6
|
+
module SchemaStatements
|
|
7
|
+
def tables(name = nil)
|
|
8
|
+
query_values(<<~SQL, "SCHEMA")
|
|
9
|
+
SELECT name FROM sqlite_master
|
|
10
|
+
WHERE type IN ('table', 'view')
|
|
11
|
+
AND name NOT LIKE 'sqlite_%'
|
|
12
|
+
AND name NOT LIKE 'fts_dir_%'
|
|
13
|
+
AND name NOT LIKE 'sqlite_fts_%'
|
|
14
|
+
SQL
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def views
|
|
18
|
+
query_values(<<~SQL, "SCHEMA")
|
|
19
|
+
SELECT name FROM sqlite_master WHERE type = 'view'
|
|
20
|
+
SQL
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def virtual_tables
|
|
24
|
+
query_values(<<~SQL, "SCHEMA")
|
|
25
|
+
SELECT name FROM sqlite_master
|
|
26
|
+
WHERE type = 'table' AND sql LIKE '%CREATE VIRTUAL TABLE%'
|
|
27
|
+
SQL
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def indexes(table_name)
|
|
31
|
+
super.reject { |idx| idx.name.to_s.start_with?("fts_dir_") || idx.name.to_s.start_with?("sqlite_fts_") }
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def supports_virtual_tables?
|
|
35
|
+
true
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def create_virtual_table(table_name, type_name, columns_or_options = [], **options)
|
|
39
|
+
type_name = type_name.to_s
|
|
40
|
+
columns = columns_or_options.is_a?(Array) ? columns_or_options : (columns_or_options[:columns] || [])
|
|
41
|
+
options = columns_or_options.is_a?(Hash) ? columns_or_options : options
|
|
42
|
+
options_str = options_to_fts_options(options)
|
|
43
|
+
execute("CREATE VIRTUAL TABLE #{quote_table_name(table_name)} USING #{type_name} (#{columns.join(", ")})#{options_str}")
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def drop_virtual_table(table_name, **options)
|
|
47
|
+
execute("DROP TABLE IF EXISTS #{quote_table_name(table_name)}")
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def add_fts_index(table_name, columns, tokenizer: :default, weights: {})
|
|
51
|
+
columns = Array(columns)
|
|
52
|
+
name = "fts_#{table_name}_#{columns.join('_')}"
|
|
53
|
+
weights_str = weights.map { |col, w| "#{col}=#{w}" }.join(",")
|
|
54
|
+
with_clause = ["tokenizer = '#{tokenizer}'"]
|
|
55
|
+
with_clause << "weights = '#{weights_str}'" unless weights_str.empty?
|
|
56
|
+
|
|
57
|
+
execute(<<~SQL)
|
|
58
|
+
CREATE INDEX #{quote_table_name(name)}
|
|
59
|
+
ON #{quote_table_name(table_name)} USING fts (#{columns.join(", ")})
|
|
60
|
+
WITH (#{with_clause.join(", ")})
|
|
61
|
+
SQL
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def remove_fts_index(table_name, name = nil)
|
|
65
|
+
name ||= "fts_#{table_name}"
|
|
66
|
+
execute("DROP INDEX IF EXISTS #{quote_table_name(name)}")
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def fts_indexes(table_name)
|
|
70
|
+
query_values(<<~SQL, "SCHEMA")
|
|
71
|
+
SELECT name FROM sqlite_master
|
|
72
|
+
WHERE type = 'index'
|
|
73
|
+
AND tbl_name = #{quote(table_name)}
|
|
74
|
+
AND sql LIKE '%USING fts%'
|
|
75
|
+
SQL
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
private
|
|
79
|
+
|
|
80
|
+
def options_to_fts_options(options)
|
|
81
|
+
return "" if options.empty?
|
|
82
|
+
" " + options.map { |k, v| "#{k}=#{v}" }.join(", ")
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
end
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ActiveRecord
|
|
4
|
+
module ConnectionAdapters
|
|
5
|
+
class TursoAdapter < SQLite3Adapter
|
|
6
|
+
module TransactionManagement
|
|
7
|
+
def supports_savepoints?
|
|
8
|
+
true
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def begin_db_transaction
|
|
12
|
+
execute("BEGIN DEFERRED")
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def commit_db_transaction
|
|
16
|
+
execute("COMMIT TRANSACTION")
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def rollback_db_transaction
|
|
20
|
+
execute("ROLLBACK TRANSACTION")
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def create_savepoint(name = current_savepoint_name(true))
|
|
24
|
+
execute("SAVEPOINT #{savepoint_name(name)}")
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def rollback_to_savepoint(name = current_savepoint_name(true))
|
|
28
|
+
execute("ROLLBACK TO SAVEPOINT #{savepoint_name(name)}")
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def release_savepoint(name = current_savepoint_name(true))
|
|
32
|
+
execute("RELEASE SAVEPOINT #{savepoint_name(name)}")
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def transaction(requires_new: nil, isolation: nil, joinable: true, **options, &block)
|
|
36
|
+
if options.delete(:concurrent)
|
|
37
|
+
transaction_with_mvcc(options, &block)
|
|
38
|
+
else
|
|
39
|
+
super
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
private
|
|
44
|
+
|
|
45
|
+
def transaction_with_mvcc(options, &block)
|
|
46
|
+
unless @mvcc_enabled
|
|
47
|
+
raise ActiveRecord::AdapterError,
|
|
48
|
+
"transaction(concurrent: true) requires journal_mode: 'mvcc' in database.yml"
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
max_retries = @config.fetch(:concurrent_retry_limit, 50)
|
|
52
|
+
base_delay_ms = @config.fetch(:concurrent_retry_base_ms, 2)
|
|
53
|
+
retries = 0
|
|
54
|
+
|
|
55
|
+
loop do
|
|
56
|
+
begin
|
|
57
|
+
raw_execute("BEGIN CONCURRENT", "TRANSACTION")
|
|
58
|
+
yield
|
|
59
|
+
raw_execute("COMMIT", "TRANSACTION")
|
|
60
|
+
return
|
|
61
|
+
rescue ActiveRecord::StatementInvalid => e
|
|
62
|
+
raw_execute("ROLLBACK", "TRANSACTION") rescue nil
|
|
63
|
+
raise unless concurrent_conflict?(e) && retries < max_retries
|
|
64
|
+
|
|
65
|
+
retries += 1
|
|
66
|
+
sleep(base_delay_ms * retries / 1000.0)
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def concurrent_conflict?(exception)
|
|
72
|
+
exception.cause.is_a?(::Turso::BusySnapshotError) ||
|
|
73
|
+
exception.cause.is_a?(::Turso::BusyError) ||
|
|
74
|
+
/snapshot conflict|busy snapshot|database is locked/i.match?(exception.message)
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def savepoint_name(name)
|
|
78
|
+
"#{name}_sp"
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "active_record/connection_adapters/sqlite3_adapter"
|
|
4
|
+
|
|
5
|
+
Dir[File.expand_path("turso_adapter/*.rb", __dir__)].each { |f| require f }
|
|
6
|
+
|
|
7
|
+
module ActiveRecord
|
|
8
|
+
module ConnectionAdapters
|
|
9
|
+
class TursoAdapter < SQLite3Adapter
|
|
10
|
+
include ConnectionManagement
|
|
11
|
+
include DatabaseStatements
|
|
12
|
+
include TransactionManagement
|
|
13
|
+
include SchemaStatements
|
|
14
|
+
include ErrorTranslation
|
|
15
|
+
|
|
16
|
+
ADAPTER_NAME = "Turso"
|
|
17
|
+
|
|
18
|
+
def supports_ddl_transactions?
|
|
19
|
+
true
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def supports_insert_returning?
|
|
23
|
+
false
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def supports_transaction_isolation?
|
|
27
|
+
false
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def supports_check_constraint?
|
|
31
|
+
true
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def supports_explain?
|
|
35
|
+
true
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def explain(arel, binds = [])
|
|
39
|
+
sql = "EXPLAIN QUERY PLAN " + to_sql(arel, binds)
|
|
40
|
+
result = exec_query(sql, "EXPLAIN", binds)
|
|
41
|
+
SQLite3::ExplainPrettyPrinter.new.pp(result)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def default_prepared_statements
|
|
45
|
+
false
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def database_file_exists?
|
|
49
|
+
File.exist?(@config[:database])
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def connect
|
|
53
|
+
@raw_connection = self.class.new_client(@connection_parameters)
|
|
54
|
+
configure_connection
|
|
55
|
+
rescue ::Turso::Error => e
|
|
56
|
+
raise ActiveRecord::ConnectionNotEstablished, e.message
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def check_version
|
|
60
|
+
true
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def encoding
|
|
64
|
+
"UTF-8"
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
private
|
|
68
|
+
|
|
69
|
+
def initialize_type_map(m)
|
|
70
|
+
super
|
|
71
|
+
m.register_type(%r(boolean)i, Type::Boolean.new)
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "active_record"
|
|
4
|
+
require "turso"
|
|
5
|
+
|
|
6
|
+
require_relative "turso/ar/connection"
|
|
7
|
+
require_relative "active_record/connection_adapters/turso_adapter"
|
|
8
|
+
|
|
9
|
+
ActiveRecord::ConnectionAdapters.register(
|
|
10
|
+
"turso",
|
|
11
|
+
"ActiveRecord::ConnectionAdapters::TursoAdapter",
|
|
12
|
+
"active_record/connection_adapters/turso_adapter"
|
|
13
|
+
)
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "forwardable"
|
|
4
|
+
|
|
5
|
+
module Turso
|
|
6
|
+
module AR
|
|
7
|
+
class Connection
|
|
8
|
+
extend Forwardable
|
|
9
|
+
|
|
10
|
+
def_delegators :@db, :close, :closed?, :changes, :total_changes
|
|
11
|
+
|
|
12
|
+
def initialize(config)
|
|
13
|
+
@config = config
|
|
14
|
+
@db = ::Turso::DB.new(config[:database].to_s,
|
|
15
|
+
busy_timeout: config[:busy_timeout] || config[:timeout],
|
|
16
|
+
query_timeout: config[:query_timeout])
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def last_insert_rowid
|
|
20
|
+
query("SELECT last_insert_rowid()").first&.values&.first.to_i
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def raw_connection
|
|
24
|
+
@db
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def open?
|
|
28
|
+
!@db.closed?
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def disconnect!
|
|
32
|
+
@db.close unless @db.closed?
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def execute(sql, binds = [])
|
|
36
|
+
@db.execute(sql, normalize_binds(binds))
|
|
37
|
+
nil
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def query(sql, params = [])
|
|
41
|
+
@db.query(sql, normalize_binds(params))
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def execute_batch(sql)
|
|
45
|
+
sql.split(";").each do |stmt|
|
|
46
|
+
s = stmt.strip
|
|
47
|
+
@db.execute(s) unless s.empty?
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def prepare(sql)
|
|
52
|
+
@db.instance_variable_get(:@database).connection.prepare(sql)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def busy_timeout=(ms)
|
|
56
|
+
@db.busy_timeout = ms.to_i
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def query_timeout=(ms)
|
|
60
|
+
@db.query_timeout = ms.to_i
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def interrupt
|
|
64
|
+
@db.interrupt
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
private
|
|
68
|
+
|
|
69
|
+
def normalize_binds(binds)
|
|
70
|
+
binds.map do |value|
|
|
71
|
+
case value
|
|
72
|
+
when true then 1
|
|
73
|
+
when false then 0
|
|
74
|
+
else value
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|
metadata
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: activerecord-turso
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- Ben D'Angelo
|
|
8
|
+
bindir: bin
|
|
9
|
+
cert_chain: []
|
|
10
|
+
date: 1980-01-02 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.0'
|
|
19
|
+
- - "<"
|
|
20
|
+
- !ruby/object:Gem::Version
|
|
21
|
+
version: '8.2'
|
|
22
|
+
type: :runtime
|
|
23
|
+
prerelease: false
|
|
24
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
25
|
+
requirements:
|
|
26
|
+
- - ">="
|
|
27
|
+
- !ruby/object:Gem::Version
|
|
28
|
+
version: '8.0'
|
|
29
|
+
- - "<"
|
|
30
|
+
- !ruby/object:Gem::Version
|
|
31
|
+
version: '8.2'
|
|
32
|
+
- !ruby/object:Gem::Dependency
|
|
33
|
+
name: turso
|
|
34
|
+
requirement: !ruby/object:Gem::Requirement
|
|
35
|
+
requirements:
|
|
36
|
+
- - "~>"
|
|
37
|
+
- !ruby/object:Gem::Version
|
|
38
|
+
version: '0.1'
|
|
39
|
+
type: :runtime
|
|
40
|
+
prerelease: false
|
|
41
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
42
|
+
requirements:
|
|
43
|
+
- - "~>"
|
|
44
|
+
- !ruby/object:Gem::Version
|
|
45
|
+
version: '0.1'
|
|
46
|
+
executables: []
|
|
47
|
+
extensions: []
|
|
48
|
+
extra_rdoc_files: []
|
|
49
|
+
files:
|
|
50
|
+
- README.md
|
|
51
|
+
- lib/active_record/connection_adapters/turso_adapter.rb
|
|
52
|
+
- lib/active_record/connection_adapters/turso_adapter/connection_management.rb
|
|
53
|
+
- lib/active_record/connection_adapters/turso_adapter/database_statements.rb
|
|
54
|
+
- lib/active_record/connection_adapters/turso_adapter/error_translation.rb
|
|
55
|
+
- lib/active_record/connection_adapters/turso_adapter/schema_statements.rb
|
|
56
|
+
- lib/active_record/connection_adapters/turso_adapter/transaction_management.rb
|
|
57
|
+
- lib/activerecord-turso.rb
|
|
58
|
+
- lib/turso/ar/connection.rb
|
|
59
|
+
licenses:
|
|
60
|
+
- MIT
|
|
61
|
+
metadata: {}
|
|
62
|
+
rdoc_options: []
|
|
63
|
+
require_paths:
|
|
64
|
+
- lib
|
|
65
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
66
|
+
requirements:
|
|
67
|
+
- - ">="
|
|
68
|
+
- !ruby/object:Gem::Version
|
|
69
|
+
version: 3.0.0
|
|
70
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
71
|
+
requirements:
|
|
72
|
+
- - ">="
|
|
73
|
+
- !ruby/object:Gem::Version
|
|
74
|
+
version: '0'
|
|
75
|
+
requirements: []
|
|
76
|
+
rubygems_version: 4.0.15
|
|
77
|
+
specification_version: 4
|
|
78
|
+
summary: ActiveRecord adapter for Turso
|
|
79
|
+
test_files: []
|