sequel-beagle-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 +222 -0
- data/lib/sequel/adapters/beagle-turso.rb +4 -0
- data/lib/sequel/adapters/beagle_turso.rb +628 -0
- data/lib/sequel/beagle_turso/version.rb +5 -0
- data/lib/sequel-beagle-turso.rb +3 -0
- metadata +81 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 306f254c2d0518ae7d6a078c338d81caa68a96fe2bed3864af6f69fe7fdf1918
|
|
4
|
+
data.tar.gz: 60f7e413c5d21e7bbff28b09d99239029248104d98aef821ae7d5cb6c07267f4
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: b890a9ad4fb5741b545e116f1a74af5a105526c1abcce1977924a7099abad3557e4277713473b6bef8eb0f6a6cb71a55447352ab7f7676408b9c572f324cdb72
|
|
7
|
+
data.tar.gz: d864638062b76ed2d885f1fdbef74ad3f76eb4c258c0cca0a6156c5181825f398949123bb7866d351e216b53c1e7791b764c51a13097c5745a6d702c0b31f2eb
|
data/README.md
ADDED
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
# sequel-beagle-turso
|
|
2
|
+
|
|
3
|
+
A [Sequel](https://sequel.jeremyevans.net) adapter for the
|
|
4
|
+
[`beagle-turso`](../beagle-turso) driver, so Sequel, [ROM](https://rom-rb.org)
|
|
5
|
+
and [Hanami](https://hanamirb.org) can run on [Turso](https://turso.tech)'s new
|
|
6
|
+
engine: a local SQLite-compatible replica that syncs to Turso Cloud.
|
|
7
|
+
|
|
8
|
+
SQL generation, schema parsing and transactions come from Sequel's shared
|
|
9
|
+
SQLite support. The adapter replaces the plumbing (connections, execution,
|
|
10
|
+
value conversion) and works around the engine's differences from SQLite.
|
|
11
|
+
|
|
12
|
+
## Install
|
|
13
|
+
|
|
14
|
+
```ruby
|
|
15
|
+
# Gemfile
|
|
16
|
+
gem "sequel-beagle-turso", "~> 0.1"
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
Sequel loads the adapter lazily the first time you connect to a
|
|
20
|
+
`beagle-turso://` URL or pass `adapter: :beagle_turso`. To load it yourself,
|
|
21
|
+
`require "sequel-beagle-turso"`.
|
|
22
|
+
|
|
23
|
+
## Connect
|
|
24
|
+
|
|
25
|
+
```ruby
|
|
26
|
+
require "sequel"
|
|
27
|
+
|
|
28
|
+
DB = Sequel.connect("beagle-turso:///abs/path/app.db") # absolute path
|
|
29
|
+
DB = Sequel.connect("beagle-turso://./app.db") # relative path
|
|
30
|
+
DB = Sequel.connect("beagle-turso:/") # in memory
|
|
31
|
+
DB = Sequel.connect(adapter: :beagle_turso, database: "app.db")
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
**A synced replica.** Pass `remote_url:` and `auth_token:` as options, never in
|
|
35
|
+
the URL. A URL carrying `auth_token` raises, because URLs end up in logs:
|
|
36
|
+
|
|
37
|
+
```ruby
|
|
38
|
+
DB = Sequel.connect(
|
|
39
|
+
"beagle-turso:///abs/path/app.db",
|
|
40
|
+
remote_url: ENV.fetch("TURSO_DATABASE_URL"),
|
|
41
|
+
auth_token: ENV.fetch("TURSO_AUTH_TOKEN")
|
|
42
|
+
)
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
The adapter removes `auth_token` from the `Database`'s options once the replica
|
|
46
|
+
is open. Push and pull go through the replica: `DB.turso_database.push` and
|
|
47
|
+
`DB.turso_database.pull`. See the
|
|
48
|
+
[driver README](../beagle-turso#readme) for the durability model, and for which
|
|
49
|
+
Turso database to use.
|
|
50
|
+
|
|
51
|
+
**Sharing a replica.** If you already have an open `Beagle::Turso::Database`,
|
|
52
|
+
pass it as `turso_database:` and the `Database` uses it instead of opening one.
|
|
53
|
+
The adapter never closes a replica it was handed.
|
|
54
|
+
|
|
55
|
+
**Closing.** `DB.disconnect` closes the pooled connections and leaves the
|
|
56
|
+
replica open. `DB.close_replica` disconnects, then closes the replica that this
|
|
57
|
+
`Database` opened. A synced replica is held by one OS process at a time, so
|
|
58
|
+
close it before something else needs the file.
|
|
59
|
+
|
|
60
|
+
Other options: `timeout:` (ms, default 5000, see Writes), `transaction_mode:`,
|
|
61
|
+
and `bootstrap_if_empty:` (passed to the driver).
|
|
62
|
+
|
|
63
|
+
## Hanami and ROM
|
|
64
|
+
|
|
65
|
+
```ruby
|
|
66
|
+
Hanami.app.configure_provider :db do
|
|
67
|
+
config.gateway(:default) do |gw|
|
|
68
|
+
gw.database_url = "beagle-turso://#{Hanami.app.root.join("db/app.db")}"
|
|
69
|
+
gw.connection_options(remote_url: ENV["TURSO_DATABASE_URL"], auth_token: ENV["TURSO_AUTH_TOKEN"])
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
- ROM's gateway keeps its own copy of the connection options. The adapter
|
|
75
|
+
scrubs the token from the `Database` it builds, but not from that copy, so
|
|
76
|
+
don't log the gateway's options.
|
|
77
|
+
- The `hanami db` commands don't support the `beagle-turso` scheme. Run
|
|
78
|
+
migrations through `ROM::SQL::Migration::Migrator` on the app's gateway, with
|
|
79
|
+
the server stopped (a synced replica is held by one process).
|
|
80
|
+
|
|
81
|
+
## Writes
|
|
82
|
+
|
|
83
|
+
The engine allows one writer at a time and reports contention immediately.
|
|
84
|
+
|
|
85
|
+
- Outside a transaction, and at `BEGIN`, the adapter waits in Ruby (with
|
|
86
|
+
backoff) for up to `timeout:` ms (default 5000), then raises
|
|
87
|
+
`Sequel::DatabaseLockTimeout`.
|
|
88
|
+
- Explicit transactions use `BEGIN IMMEDIATE`, so a transaction waits for the
|
|
89
|
+
write lock at its start instead of failing partway. `transaction_mode:`
|
|
90
|
+
(`:deferred`, `:immediate`, `:exclusive`) changes the default, and
|
|
91
|
+
`db.transaction(mode: ...)` overrides it per transaction.
|
|
92
|
+
- Inside a transaction, contention surfaces as an error (and the transaction
|
|
93
|
+
rolls back) instead of being retried.
|
|
94
|
+
- Don't set `PRAGMA busy_timeout`. Its wait holds the GVL, so the writer that
|
|
95
|
+
holds the lock can't commit.
|
|
96
|
+
- Because of `BEGIN IMMEDIATE`, read-only `db.transaction { }` blocks and
|
|
97
|
+
`Dataset#paged_each` take the write lock and queue behind writers. Pass
|
|
98
|
+
`mode: :deferred` for read-only transactions.
|
|
99
|
+
- Statements run inside a manual `db.run("BEGIN")` aren't seen as a transaction
|
|
100
|
+
by Sequel, so they get the autocommit busy retry. Use `db.transaction`.
|
|
101
|
+
- A `db.run` with several statements (`"INSERT ...; INSERT ..."`) is never
|
|
102
|
+
retried, since a retry could re-run a statement that already committed. If
|
|
103
|
+
the write lock is taken it raises `Sequel::DatabaseLockTimeout` at once, and
|
|
104
|
+
statements before the failing one may already be committed. Run statements
|
|
105
|
+
one at a time, or in a `db.transaction`. (A `;` inside a string literal also
|
|
106
|
+
counts as a second statement.)
|
|
107
|
+
|
|
108
|
+
**Pool size.** The engine allows many connections (32 and more) to one
|
|
109
|
+
replica, but only one writer at a time. `max_connections` defaults to 4. Under
|
|
110
|
+
write-heavy load a smaller pool makes writers queue in Sequel's pool rather
|
|
111
|
+
than busy-retry against each other. An in-memory database defaults to one
|
|
112
|
+
connection.
|
|
113
|
+
|
|
114
|
+
## Fork (Puma, Unicorn, forking job runners)
|
|
115
|
+
|
|
116
|
+
A replica belongs to the process that opened it. After `fork`, the child's
|
|
117
|
+
inherited pooled connections raise `Sequel::DatabaseDisconnectError` and are
|
|
118
|
+
dropped, and opening new ones raises `Sequel::DatabaseConnectionError`
|
|
119
|
+
(`Beagle::Turso::ForkedError`). `DB.disconnect` before forking is not enough,
|
|
120
|
+
because the replica itself can't cross the fork. Create the `Sequel::Database`
|
|
121
|
+
in each process after it forks (for Puma, in `on_worker_boot`), and don't open
|
|
122
|
+
it in the parent. A replica file is locked by one process at a time, so give
|
|
123
|
+
each process its own replica file.
|
|
124
|
+
|
|
125
|
+
## Falcon and fibers
|
|
126
|
+
|
|
127
|
+
Call `Sequel.extension :fiber_concurrency` before connecting, so the pool hands
|
|
128
|
+
out connections per fiber rather than per thread. The driver README's
|
|
129
|
+
[Threads, fibers and fork](../beagle-turso#threads-fibers-and-fork) section
|
|
130
|
+
covers the GVL and Falcon notes, including what runs on Ruby 3.3 and 4.0.
|
|
131
|
+
|
|
132
|
+
## Limitations
|
|
133
|
+
|
|
134
|
+
- The sync engine's own tables (`turso_cdc`, `turso_cdc_version`,
|
|
135
|
+
`turso_sync_last_change_id`) are hidden from `tables` and `views`, so cleaners
|
|
136
|
+
and schema dumpers can't truncate the change log.
|
|
137
|
+
- The engine doesn't support generated columns, `WITHOUT ROWID` tables, fts5 or
|
|
138
|
+
`ATTACH`.
|
|
139
|
+
- Declared-type arguments (`varchar(255)`, `numeric(12,0)`) aren't reported by
|
|
140
|
+
the engine, so `schema` has no `:max_length` for string columns.
|
|
141
|
+
- `LIKE` is always case-insensitive: the engine ignores
|
|
142
|
+
`PRAGMA case_sensitive_like`.
|
|
143
|
+
- `alter_table` operations that rebuild the table fail, and roll back cleanly,
|
|
144
|
+
if another table's trigger references the rebuilt table.
|
|
145
|
+
- Dropping a self-referencing table that has rows works outside a transaction
|
|
146
|
+
only (the adapter retries with foreign keys off). Inside a transaction it
|
|
147
|
+
raises.
|
|
148
|
+
- **Known engine issue: self-referencing foreign keys.** Turso's engine has open bugs with foreign keys that reference their own table
|
|
149
|
+
(such as `parent_id`): a multi-row `DELETE`, `UPDATE` or `DROP` that SQLite accepts can fail with
|
|
150
|
+
`FOREIGN KEY constraint failed`, and `ON DELETE SET NULL` / `CASCADE` can leave
|
|
151
|
+
rows behind or orphaned
|
|
152
|
+
([tursodatabase/turso#8713](https://github.com/tursodatabase/turso/issues/8713),
|
|
153
|
+
[#9279](https://github.com/tursodatabase/turso/issues/9279),
|
|
154
|
+
[#9293](https://github.com/tursodatabase/turso/issues/9293),
|
|
155
|
+
[#9294](https://github.com/tursodatabase/turso/issues/9294)). Foreign keys
|
|
156
|
+
between two different tables are unaffected. Workaround: avoid `ON DELETE`
|
|
157
|
+
actions on self-referencing foreign keys, and delete children before parents,
|
|
158
|
+
one level at a time.
|
|
159
|
+
|
|
160
|
+
- **Known engine issue: insert ids on MVCC replicas.** On a replica in MVCC
|
|
161
|
+
journal mode with change data capture on (every synced replica of a
|
|
162
|
+
`--tursodb` remote), `last_insert_rowid` returns the change-log id, not the
|
|
163
|
+
inserted row's rowid
|
|
164
|
+
([tursodatabase/turso#9551](https://github.com/tursodatabase/turso/issues/9551)).
|
|
165
|
+
The adapter doesn't use it: `Dataset#insert` appends `RETURNING rowid` to the
|
|
166
|
+
statement and returns the last row's rowid (`nil` if nothing was inserted,
|
|
167
|
+
such as `insert_ignore` hitting a duplicate), so ids are correct everywhere.
|
|
168
|
+
Code that calls the driver's `last_insert_rowid` itself is still affected.
|
|
169
|
+
- **`rename_table` is refused on synced replicas.** The sync engine doesn't
|
|
170
|
+
capture `ALTER TABLE ... RENAME TO` or `RENAME COLUMN` for sync, so the remote
|
|
171
|
+
never sees the rename and later writes to the table are lost on push. When
|
|
172
|
+
`turso_database.synced?`, `rename_table` raises `Sequel::Error`. Local-only
|
|
173
|
+
replicas are unaffected. Workaround: create the new table, copy the rows and
|
|
174
|
+
drop the old one, or run the rename against the remote database directly.
|
|
175
|
+
- **Table rebuilds avoid `RENAME`.** Operations that rebuild a table
|
|
176
|
+
(`set_column_type`, `set_column_allow_null`, `set_column_default`, constraint
|
|
177
|
+
changes, adding a unique column) copy the rows to a backup table, drop and
|
|
178
|
+
recreate the table, copy back and drop the backup, so nothing is renamed. On
|
|
179
|
+
a synced replica `drop_column` and `rename_column` go through the same
|
|
180
|
+
rebuild too (the native `DROP COLUMN` loses unpushed rows, and
|
|
181
|
+
`RENAME COLUMN` isn't synced). Rebuilds therefore copy the data twice, and
|
|
182
|
+
**the first push after a rebuild on a synced replica raises a `SyncError`
|
|
183
|
+
once; push again and the data converges** (`Syncer` logs it once and
|
|
184
|
+
retries). Rebuilds (Sequel's stock ones too) have these limits:
|
|
185
|
+
- triggers on the table are lost;
|
|
186
|
+
- the `AUTOINCREMENT` counter restarts from the current max id, so a deleted
|
|
187
|
+
top id can be reused;
|
|
188
|
+
- partial indexes are not recreated;
|
|
189
|
+
- a column's `COLLATE` is dropped;
|
|
190
|
+
- a foreign key's `DEFERRABLE` is dropped (the engine doesn't report it).
|
|
191
|
+
|
|
192
|
+
On a synced replica, `rename_column` also keeps the table's own foreign keys
|
|
193
|
+
(re-added with the new column name, self-references included), but it is
|
|
194
|
+
refused when another table's foreign key references the column: the rebuild
|
|
195
|
+
can't update that reference, and both tables would become unwritable. Rebuild
|
|
196
|
+
the referencing table as well, or apply the rename on the remote and pull.
|
|
197
|
+
`drop_column` of a foreign key column drops that foreign key with it.
|
|
198
|
+
- **Insert return values.** `Dataset#insert` returns the last inserted row's
|
|
199
|
+
rowid. An `ON CONFLICT DO UPDATE` upsert returns the updated row's rowid, and
|
|
200
|
+
an insert that is ignored (`insert_ignore`, `ON CONFLICT DO NOTHING`) returns
|
|
201
|
+
`nil`; SQLite's `last_insert_rowid` would return a stale value there.
|
|
202
|
+
|
|
203
|
+
The full list of Sequel integration tests excluded for engine gaps, with a
|
|
204
|
+
reason for each, is in
|
|
205
|
+
[`spec/integration_known_failures.txt`](spec/integration_known_failures.txt).
|
|
206
|
+
|
|
207
|
+
## Testing
|
|
208
|
+
|
|
209
|
+
```bash
|
|
210
|
+
bundle exec rspec # the adapter's own specs, including a ROM smoke test
|
|
211
|
+
script/sequel-integration # Sequel's own integration suite
|
|
212
|
+
```
|
|
213
|
+
|
|
214
|
+
`script/sequel-integration` clones the Sequel release matching the installed
|
|
215
|
+
version, runs its `spec/integration` suite against this adapter and excludes the
|
|
216
|
+
tests in `spec/integration_known_failures.txt` by exact name. The remote specs
|
|
217
|
+
need `TURSO_DATABASE_URL` and `TURSO_AUTH_TOKEN` for a dedicated test database,
|
|
218
|
+
and skip cleanly without them.
|
|
219
|
+
|
|
220
|
+
## License
|
|
221
|
+
|
|
222
|
+
[MIT](https://opensource.org/licenses/MIT).
|
|
@@ -0,0 +1,628 @@
|
|
|
1
|
+
require "beagle/turso"
|
|
2
|
+
require "bigdecimal"
|
|
3
|
+
require "sequel/adapters/shared/sqlite"
|
|
4
|
+
|
|
5
|
+
module Sequel
|
|
6
|
+
# Sequel adapter for the beagle-turso driver. SQL generation, schema parsing
|
|
7
|
+
# and transactions come from Sequel's shared SQLite support; this file only
|
|
8
|
+
# replaces the plumbing (connections, execution, value conversion) and
|
|
9
|
+
# papers over the engine's differences from SQLite.
|
|
10
|
+
module BeagleTurso
|
|
11
|
+
FALSE_VALUES = (%w[0 false f no n].each(&:freeze) + [0]).freeze
|
|
12
|
+
|
|
13
|
+
blob = Object.new
|
|
14
|
+
def blob.call(s)
|
|
15
|
+
Sequel::SQL::Blob.new(s.to_s)
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
boolean = Object.new
|
|
19
|
+
def boolean.call(s)
|
|
20
|
+
s = s.downcase if s.is_a?(String)
|
|
21
|
+
!FALSE_VALUES.include?(s)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
date = Object.new
|
|
25
|
+
def date.call(s)
|
|
26
|
+
case s
|
|
27
|
+
when String then Sequel.string_to_date(s)
|
|
28
|
+
when Integer then Date.jd(s)
|
|
29
|
+
when Float then Date.jd(s.to_i)
|
|
30
|
+
else raise Sequel::Error, "unhandled type when converting to date: #{s.inspect} (#{s.class.inspect})"
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
integer = Object.new
|
|
35
|
+
def integer.call(s)
|
|
36
|
+
s.to_i
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
float = Object.new
|
|
40
|
+
def float.call(s)
|
|
41
|
+
s.to_f
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
numeric = Object.new
|
|
45
|
+
def numeric.call(s)
|
|
46
|
+
s = s.to_s unless s.is_a?(String)
|
|
47
|
+
BigDecimal(s)
|
|
48
|
+
rescue ArgumentError
|
|
49
|
+
s
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
time = Object.new
|
|
53
|
+
def time.call(s)
|
|
54
|
+
case s
|
|
55
|
+
when String then Sequel.string_to_time(s)
|
|
56
|
+
when Integer then Sequel::SQLTime.create(s / 3600, (s % 3600) / 60, s % 60)
|
|
57
|
+
when Float
|
|
58
|
+
secs, frac = s.divmod(1)
|
|
59
|
+
Sequel::SQLTime.create(secs / 3600, (secs % 3600) / 60, secs % 60, (frac * 1_000_000).round)
|
|
60
|
+
else raise Sequel::Error, "unhandled type when converting to time: #{s.inspect} (#{s.class.inspect})"
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# Declared base type name (lowercase) -> converter. datetime/timestamp are
|
|
65
|
+
# added per Database so Sequel's timezone settings apply.
|
|
66
|
+
SQLITE_TYPES = {}
|
|
67
|
+
{
|
|
68
|
+
%w[date] => date,
|
|
69
|
+
%w[time] => time,
|
|
70
|
+
%w[bit bool boolean] => boolean,
|
|
71
|
+
%w[integer smallint mediumint int bigint] => integer,
|
|
72
|
+
%w[numeric decimal money] => numeric,
|
|
73
|
+
%w[float double real dec fixed] + ["double precision"] => float,
|
|
74
|
+
%w[blob] => blob
|
|
75
|
+
}.each { |names, conv| names.each { |n| SQLITE_TYPES[n] = conv } }
|
|
76
|
+
SQLITE_TYPES.freeze
|
|
77
|
+
|
|
78
|
+
# Thread-safe retry count. Lives in an ivar of the Database, and a frozen
|
|
79
|
+
# Database must still be able to count, so it can't be a plain Integer.
|
|
80
|
+
class BusyCounter
|
|
81
|
+
def initialize
|
|
82
|
+
@mutex = Mutex.new
|
|
83
|
+
@value = 0
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def increment
|
|
87
|
+
@mutex.synchronize { @value += 1 }
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def value
|
|
91
|
+
@mutex.synchronize { @value }
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
private_constant :BusyCounter
|
|
95
|
+
|
|
96
|
+
class Database < Sequel::Database
|
|
97
|
+
include Sequel::SQLite::DatabaseMethods
|
|
98
|
+
|
|
99
|
+
# The driver prefixes engine messages ("query failed: ..."); Sequel's
|
|
100
|
+
# SQLite error regexps (some anchored at \A) expect the bare message.
|
|
101
|
+
ENGINE_PREFIX = /\A(?:open failed|connect failed|query failed|sync failed|database is busy): /
|
|
102
|
+
|
|
103
|
+
# Tables the Turso sync engine creates on a synced replica; never user tables.
|
|
104
|
+
SYNC_ENGINE_TABLES = %w[turso_cdc turso_cdc_version turso_sync_last_change_id].freeze
|
|
105
|
+
|
|
106
|
+
RETURNING_CLAUSE = /\bRETURNING\b/i
|
|
107
|
+
|
|
108
|
+
# String literals, quoted identifiers, and comments: where the word
|
|
109
|
+
# RETURNING is data, not syntax.
|
|
110
|
+
NON_CODE = %r{'(?:[^']|'')*'|"(?:[^"]|"")*"|`(?:[^`]|``)*`|\[[^\]]*\]|--[^\n]*|/\*.*?\*/}m
|
|
111
|
+
|
|
112
|
+
set_adapter_scheme :beagle_turso
|
|
113
|
+
# The URL form: URI schemes can't contain "_" (see adapters/beagle-turso.rb).
|
|
114
|
+
Sequel::ADAPTER_MAP[:"beagle-turso"] = self
|
|
115
|
+
|
|
116
|
+
# Same path handling as Sequel's sqlite adapter: beagle-turso:///abs.db,
|
|
117
|
+
# beagle-turso://./rel.db, beagle-turso:/ (memory).
|
|
118
|
+
def self.uri_to_options(uri)
|
|
119
|
+
database = if uri.host.nil?
|
|
120
|
+
case uri.path
|
|
121
|
+
when "/" then nil
|
|
122
|
+
when nil then uri.opaque
|
|
123
|
+
else uri.path
|
|
124
|
+
end
|
|
125
|
+
else
|
|
126
|
+
"#{uri.host}#{uri.path}"
|
|
127
|
+
end
|
|
128
|
+
{ database: database }
|
|
129
|
+
end
|
|
130
|
+
private_class_method :uri_to_options
|
|
131
|
+
|
|
132
|
+
attr_reader :conversion_procs, :turso_database
|
|
133
|
+
|
|
134
|
+
# One pooled connection. The replica itself is opened once, in
|
|
135
|
+
# adapter_initialize, and shared by every connection.
|
|
136
|
+
def connect(_server)
|
|
137
|
+
conn = @turso_database.connect
|
|
138
|
+
connection_pragmas.each { |s| log_connection_yield(s, conn) { conn.execute_batch(s) } }
|
|
139
|
+
conn
|
|
140
|
+
rescue Beagle::Turso::Error => e
|
|
141
|
+
raise Sequel.convert_exception_class(e, Sequel::DatabaseConnectionError)
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def disconnect_connection(conn)
|
|
145
|
+
conn.close
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
# Closes pooled connections and the replica this Database opened. A
|
|
149
|
+
# replica passed in as turso_database: belongs to the caller and is left
|
|
150
|
+
# open. Idempotent.
|
|
151
|
+
def close_replica
|
|
152
|
+
disconnect
|
|
153
|
+
@turso_database.close if @owns_turso_database && !@turso_database.closed?
|
|
154
|
+
nil
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
# Row-returning SQL. Yields (and returns) [columns, decl_types, rows].
|
|
158
|
+
def execute(sql, opts = OPTS)
|
|
159
|
+
result = _execute(sql, opts) { |conn| conn.query_result_with_types(sql, []) }
|
|
160
|
+
yield result if defined?(yield)
|
|
161
|
+
result
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
def execute_dui(sql, opts = OPTS)
|
|
165
|
+
_execute(sql, opts) { |conn| conn.execute(sql, []) }
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
# execute_batch runs every statement in sql. Retrying a batch after a
|
|
169
|
+
# busy error could re-run a statement that already committed, so only a
|
|
170
|
+
# single statement gets the busy retry; a batch runs once.
|
|
171
|
+
def execute_ddl(sql, opts = OPTS)
|
|
172
|
+
_execute(sql, opts, busy_retry: single_statement?(sql)) { |conn| conn.execute_batch(sql) }
|
|
173
|
+
nil
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
# Returns the rowid of the last row inserted, or nil if none was.
|
|
177
|
+
#
|
|
178
|
+
# Not conn.last_insert_rowid: on a replica in MVCC journal mode with
|
|
179
|
+
# change data capture on (every synced replica of a --tursodb remote)
|
|
180
|
+
# the engine leaves the CDC change id there, so it is wrong
|
|
181
|
+
# (tursodatabase/turso#9551). INSERT ... RETURNING is correct, so the
|
|
182
|
+
# statement gets RETURNING rowid appended. rowid always exists: the
|
|
183
|
+
# engine has no WITHOUT ROWID tables. Several rows (INSERT ... SELECT,
|
|
184
|
+
# multi-row VALUES) return the last one, like SQLite's
|
|
185
|
+
# last_insert_rowid; an insert that changed nothing (OR IGNORE, ON
|
|
186
|
+
# CONFLICT DO NOTHING) returns nil. The logged SQL is the original. RETURNING
|
|
187
|
+
# goes on its own line so a trailing -- comment (sql_comments) can't swallow it.
|
|
188
|
+
#
|
|
189
|
+
# An insert that already has RETURNING is run as is, and its first
|
|
190
|
+
# column in the last row is the result. (Dataset#insert sends those
|
|
191
|
+
# through fetch, and insert_select too, not through here.)
|
|
192
|
+
#
|
|
193
|
+
# Note: insert(select_ds) / INSERT ... SELECT materialises every
|
|
194
|
+
# returned rowid just to take the last one.
|
|
195
|
+
def execute_insert(sql, opts = OPTS)
|
|
196
|
+
run_sql = with_returning_rowid(sql)
|
|
197
|
+
_execute(sql, opts) do |conn|
|
|
198
|
+
_, _, rows = conn.query_result_with_types(run_sql, [])
|
|
199
|
+
rows.last&.first
|
|
200
|
+
end
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
# SQLite stores timestamps as text, integers (unix time) or reals (julian day).
|
|
204
|
+
def to_application_timestamp(s)
|
|
205
|
+
case s
|
|
206
|
+
when String then super
|
|
207
|
+
when Integer then super(Time.at(s).to_s)
|
|
208
|
+
when Float then super(DateTime.jd(s).to_s)
|
|
209
|
+
else raise Sequel::Error, "unhandled type when converting to timestamp: #{s.inspect} (#{s.class.inspect})"
|
|
210
|
+
end
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
# The engine checks a table's self-references row by row when dropping
|
|
214
|
+
# it, so a self-referencing table with rows can't be dropped while
|
|
215
|
+
# foreign keys are on (SQLite drops it). When nothing else could be
|
|
216
|
+
# referencing the rows, drop it with foreign keys briefly off.
|
|
217
|
+
def drop_table(*names)
|
|
218
|
+
opts = names.last.is_a?(Hash) ? names.pop : OPTS
|
|
219
|
+
names.each do |name|
|
|
220
|
+
super(name, opts)
|
|
221
|
+
rescue ForeignKeyConstraintViolation
|
|
222
|
+
raise unless drop_self_referencing_table(name, opts)
|
|
223
|
+
end
|
|
224
|
+
nil
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
# Refused on a synced replica, see refuse_rename_when_synced.
|
|
228
|
+
def rename_table(name, new_name)
|
|
229
|
+
refuse_rename_when_synced("rename_table")
|
|
230
|
+
super
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
# Sequel's check sends SELECT NULL through connection_execute_method
|
|
234
|
+
# (execute_batch), which rejects any statement that returns a row.
|
|
235
|
+
def valid_connection?(conn)
|
|
236
|
+
sql = valid_connection_sql
|
|
237
|
+
log_connection_yield(sql, conn) { conn.query(sql, []) }
|
|
238
|
+
true
|
|
239
|
+
rescue Sequel::DatabaseError, *database_error_classes
|
|
240
|
+
false
|
|
241
|
+
end
|
|
242
|
+
|
|
243
|
+
def freeze
|
|
244
|
+
@conversion_procs.freeze
|
|
245
|
+
super
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
private
|
|
249
|
+
|
|
250
|
+
# True if it dropped the table. PRAGMA foreign_keys is per connection and
|
|
251
|
+
# ignored inside a transaction, so this needs to own the connection.
|
|
252
|
+
def drop_self_referencing_table(name, opts)
|
|
253
|
+
synchronize(opts[:server]) do |conn|
|
|
254
|
+
return false if _trans(conn) || !self_referencing_only?(name)
|
|
255
|
+
|
|
256
|
+
execute_ddl("PRAGMA foreign_keys = 0", opts)
|
|
257
|
+
begin
|
|
258
|
+
execute_ddl(drop_table_sql(name, opts), opts)
|
|
259
|
+
ensure
|
|
260
|
+
execute_ddl("PRAGMA foreign_keys = 1", opts)
|
|
261
|
+
end
|
|
262
|
+
remove_cached_schema(name)
|
|
263
|
+
true
|
|
264
|
+
end
|
|
265
|
+
end
|
|
266
|
+
|
|
267
|
+
# The table references itself, and no other table with rows references it.
|
|
268
|
+
def self_referencing_only?(name)
|
|
269
|
+
table = name.to_s
|
|
270
|
+
return false unless foreign_key_list(name).any? { |fk| fk[:table].to_s == table }
|
|
271
|
+
|
|
272
|
+
tables.reject { |t| t.to_s == table }.none? do |other|
|
|
273
|
+
foreign_key_list(other).any? { |fk| fk[:table].to_s == table } && !from(other).empty?
|
|
274
|
+
end
|
|
275
|
+
end
|
|
276
|
+
|
|
277
|
+
def adapter_initialize
|
|
278
|
+
@conversion_procs = SQLITE_TYPES.dup
|
|
279
|
+
@conversion_procs["datetime"] = @conversion_procs["timestamp"] = method(:to_application_timestamp)
|
|
280
|
+
set_integer_booleans
|
|
281
|
+
reject_url_credentials
|
|
282
|
+
# The engine allows one writer and reports contention immediately; we
|
|
283
|
+
# wait in Ruby (see retry_busy), never with PRAGMA busy_timeout, whose
|
|
284
|
+
# wait holds the GVL so the lock holder can't commit.
|
|
285
|
+
@busy_retries = BusyCounter.new
|
|
286
|
+
@busy_timeout = typecast_value_integer(@opts.fetch(:timeout, 5000)) / 1000.0
|
|
287
|
+
# Take the write lock at BEGIN, so a transaction that can't get it
|
|
288
|
+
# waits there instead of failing mid-way.
|
|
289
|
+
self.transaction_mode = (@opts[:transaction_mode] || :immediate).to_sym
|
|
290
|
+
@turso_database, @owns_turso_database = open_turso_database
|
|
291
|
+
scrub_credentials
|
|
292
|
+
sqlite_version # fail fast, and avoid a surprise first query later
|
|
293
|
+
end
|
|
294
|
+
|
|
295
|
+
# Runs before a replica is opened or adopted, so it covers both paths.
|
|
296
|
+
def reject_url_credentials
|
|
297
|
+
return unless url_has_auth_token?(@opts[:uri].to_s)
|
|
298
|
+
raise Error, "pass auth_token: as a connection option, not in the URL (URLs end up in logs)"
|
|
299
|
+
end
|
|
300
|
+
|
|
301
|
+
def url_has_auth_token?(url)
|
|
302
|
+
query = URI.parse(url).query
|
|
303
|
+
query && URI.decode_www_form(query).any? { |k, _| k.downcase == "auth_token" }
|
|
304
|
+
rescue ArgumentError, URI::Error
|
|
305
|
+
url.match?(/[?&]auth(_|%5f)token(=|&|\z)/i)
|
|
306
|
+
end
|
|
307
|
+
|
|
308
|
+
def open_turso_database
|
|
309
|
+
return [@opts[:turso_database], false] if @opts[:turso_database]
|
|
310
|
+
path = @opts[:database].to_s
|
|
311
|
+
path = ":memory:" if path.empty?
|
|
312
|
+
db = Beagle::Turso::Database.open(
|
|
313
|
+
local_path: path,
|
|
314
|
+
remote_url: @opts[:remote_url],
|
|
315
|
+
auth_token: @opts[:auth_token],
|
|
316
|
+
bootstrap_if_empty: typecast_value_boolean(@opts.fetch(:bootstrap_if_empty, true))
|
|
317
|
+
)
|
|
318
|
+
[db, true]
|
|
319
|
+
rescue Beagle::Turso::Error => e
|
|
320
|
+
raise Sequel.convert_exception_class(e, Sequel::DatabaseConnectionError)
|
|
321
|
+
end
|
|
322
|
+
|
|
323
|
+
# The replica holds the token natively; nothing on this side needs it
|
|
324
|
+
# again, so don't let it sit in opts (inspect, logging, error reports).
|
|
325
|
+
def scrub_credentials
|
|
326
|
+
@opts.delete(:auth_token)
|
|
327
|
+
if @opts[:orig_opts].is_a?(Hash)
|
|
328
|
+
@opts[:orig_opts] = @opts[:orig_opts].reject { |k, _| k.to_s == "auth_token" }
|
|
329
|
+
end
|
|
330
|
+
end
|
|
331
|
+
|
|
332
|
+
# Busy retries so far (for specs).
|
|
333
|
+
def busy_retry_count
|
|
334
|
+
@busy_retries.value
|
|
335
|
+
end
|
|
336
|
+
|
|
337
|
+
def _execute(sql, opts, busy_retry: true)
|
|
338
|
+
synchronize(opts[:server]) do |conn|
|
|
339
|
+
# The retry sits inside the logged block: one log line per statement,
|
|
340
|
+
# at error level only if it finally fails. Inside a transaction a
|
|
341
|
+
# BusyError means the transaction is dead; it must surface (and
|
|
342
|
+
# roll back), not be retried.
|
|
343
|
+
log_connection_yield(sql, conn) { !busy_retry || _trans(conn) ? yield(conn) : retry_busy { yield conn } }
|
|
344
|
+
end
|
|
345
|
+
rescue Beagle::Turso::Error => e
|
|
346
|
+
raise_error(e)
|
|
347
|
+
end
|
|
348
|
+
|
|
349
|
+
# Conservative: a ";" inside a string literal also counts as a second
|
|
350
|
+
# statement, which only costs that statement its busy retry.
|
|
351
|
+
def single_statement?(sql)
|
|
352
|
+
!sql.strip.sub(/;\s*\z/, "").include?(";")
|
|
353
|
+
end
|
|
354
|
+
|
|
355
|
+
# Same as the shared version, but the BEGIN is retried inside its log
|
|
356
|
+
# entry (one line, not one per attempt).
|
|
357
|
+
def begin_new_transaction(conn, opts)
|
|
358
|
+
mode = opts[:mode] || @transaction_mode
|
|
359
|
+
sql = Sequel::SQLite::DatabaseMethods::TRANSACTION_MODE[mode] or
|
|
360
|
+
raise Error, "transaction :mode must be one of: :deferred, :immediate, :exclusive, nil"
|
|
361
|
+
log_connection_yield(sql, conn) { retry_busy { conn.execute_batch(sql) } }
|
|
362
|
+
set_transaction_isolation(conn, opts)
|
|
363
|
+
end
|
|
364
|
+
|
|
365
|
+
# Retries a statement that lost the single-writer race, with capped
|
|
366
|
+
# exponential backoff, for up to timeout: ms. sleep releases the GVL and
|
|
367
|
+
# yields to a fiber scheduler, so the writer holding the lock can finish.
|
|
368
|
+
def retry_busy
|
|
369
|
+
deadline = nil
|
|
370
|
+
delay = 0.002
|
|
371
|
+
begin
|
|
372
|
+
yield
|
|
373
|
+
rescue Beagle::Turso::BusyError => e
|
|
374
|
+
raise if e.message.include?("snapshot is stale")
|
|
375
|
+
|
|
376
|
+
now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
377
|
+
deadline ||= now + @busy_timeout
|
|
378
|
+
raise if now + delay > deadline
|
|
379
|
+
|
|
380
|
+
@busy_retries.increment
|
|
381
|
+
sleep delay
|
|
382
|
+
delay = [delay * 2, 0.1].min
|
|
383
|
+
retry
|
|
384
|
+
end
|
|
385
|
+
end
|
|
386
|
+
|
|
387
|
+
# Sequel runs BEGIN/COMMIT/SAVEPOINT with conn.public_send(method, sql):
|
|
388
|
+
# one argument. Connection#execute takes (sql, params); execute_batch(sql)
|
|
389
|
+
# takes one.
|
|
390
|
+
def connection_execute_method
|
|
391
|
+
:execute_batch
|
|
392
|
+
end
|
|
393
|
+
|
|
394
|
+
def database_error_classes
|
|
395
|
+
[Beagle::Turso::Error]
|
|
396
|
+
end
|
|
397
|
+
|
|
398
|
+
def database_specific_error_class(exception, opts)
|
|
399
|
+
return DatabaseDisconnectError if disconnect_error?(exception, opts)
|
|
400
|
+
|
|
401
|
+
if exception.is_a?(Beagle::Turso::BusyError)
|
|
402
|
+
return exception.message.include?("snapshot is stale") ? SerializationFailure : DatabaseLockTimeout
|
|
403
|
+
end
|
|
404
|
+
|
|
405
|
+
message = exception.message.sub(ENGINE_PREFIX, "")
|
|
406
|
+
database_error_regexps.each { |regexp, klass| return klass if regexp.match?(message) }
|
|
407
|
+
nil
|
|
408
|
+
end
|
|
409
|
+
|
|
410
|
+
# A closed or fork-inherited connection is dead: let the pool drop it.
|
|
411
|
+
def disconnect_error?(exception, opts)
|
|
412
|
+
exception.is_a?(Beagle::Turso::ClosedError) || exception.is_a?(Beagle::Turso::ForkedError) || super
|
|
413
|
+
end
|
|
414
|
+
|
|
415
|
+
# Only reads @opts: Sequel calls this before adapter_initialize.
|
|
416
|
+
def connection_pool_default_options
|
|
417
|
+
o = super
|
|
418
|
+
memory = @opts[:database].to_s.empty? || @opts[:database] == ":memory:"
|
|
419
|
+
memory && !@opts[:turso_database] ? o.merge(max_connections: 1) : o
|
|
420
|
+
end
|
|
421
|
+
|
|
422
|
+
def dataset_class_default
|
|
423
|
+
Dataset
|
|
424
|
+
end
|
|
425
|
+
|
|
426
|
+
# The engine reports `to` as "" (SQLite: NULL) for a foreign key that
|
|
427
|
+
# references the parent's primary key implicitly. Sequel would turn that
|
|
428
|
+
# into a bogus column and break table rebuilds in alter_table.
|
|
429
|
+
def _foreign_key_list_ds(table)
|
|
430
|
+
super.with_row_proc(lambda do |row|
|
|
431
|
+
row[:to] = nil if row[:to] == ""
|
|
432
|
+
row
|
|
433
|
+
end)
|
|
434
|
+
end
|
|
435
|
+
|
|
436
|
+
# For DEFAULT (expr) SQLite reports dflt_value as expr; the engine keeps
|
|
437
|
+
# the parentheses. A default that isn't parenthesised is a literal, and
|
|
438
|
+
# no literal starts with "(", so dropping one outer pair is exact. The
|
|
439
|
+
# engine also writes a negative number as "- 1" (SQLite: "-1"), which
|
|
440
|
+
# Sequel can't parse into a Ruby default.
|
|
441
|
+
def _parse_pragma_ds(table_name, opts)
|
|
442
|
+
super.with_row_proc(lambda do |row|
|
|
443
|
+
d = row[:dflt_value]
|
|
444
|
+
if d.is_a?(String)
|
|
445
|
+
d = d[1...-1] if d.start_with?("(") && d.end_with?(")")
|
|
446
|
+
row[:dflt_value] = d.sub(/\A-\s+(?=\d)/, "-")
|
|
447
|
+
end
|
|
448
|
+
row
|
|
449
|
+
end)
|
|
450
|
+
end
|
|
451
|
+
|
|
452
|
+
# Sequel's version turns foreign keys off, rebuilds in a transaction and
|
|
453
|
+
# turns them back on, each through its own pool checkout. PRAGMA
|
|
454
|
+
# foreign_keys is per connection, so on a warm pool those can land on
|
|
455
|
+
# different connections: the rebuild runs with foreign keys on, and a
|
|
456
|
+
# connection is left with them off. Hold one connection throughout.
|
|
457
|
+
def apply_alter_table(table, ops)
|
|
458
|
+
synchronize { super }
|
|
459
|
+
end
|
|
460
|
+
|
|
461
|
+
# Sequel rebuilds a table by renaming it to a backup, creating the new
|
|
462
|
+
# table, copying and dropping the backup. The engine ignores
|
|
463
|
+
# legacy_alter_table and rewrites other tables' foreign keys (and views)
|
|
464
|
+
# to the backup name, and on a synced replica a RENAME never reaches the
|
|
465
|
+
# remote at all. So rebuild without any RENAME: copy the rows to a backup
|
|
466
|
+
# table made from the new definition, drop the original, recreate it,
|
|
467
|
+
# copy back, drop the backup. Nothing refers to the backup name. (Used
|
|
468
|
+
# for every rebuild, local or synced, so there is one code path.)
|
|
469
|
+
def duplicate_table(table, opts = OPTS)
|
|
470
|
+
rename, create, insert, drop, *rest = super
|
|
471
|
+
qt = quote_schema_table(table)
|
|
472
|
+
bt = drop.to_s.delete_prefix("DROP TABLE ")
|
|
473
|
+
create_prefix = "CREATE TABLE #{qt}("
|
|
474
|
+
insert_prefix = "INSERT INTO #{qt}("
|
|
475
|
+
unless rename == "ALTER TABLE #{qt} RENAME TO #{bt}" && create.start_with?(create_prefix) &&
|
|
476
|
+
insert.start_with?(insert_prefix) && insert.end_with?(" FROM #{bt}")
|
|
477
|
+
raise Error, "unexpected table rebuild SQL from Sequel #{Sequel::VERSION}; cannot rebuild #{qt}"
|
|
478
|
+
end
|
|
479
|
+
|
|
480
|
+
columns = insert.delete_prefix(insert_prefix).delete_suffix(" FROM #{bt}")
|
|
481
|
+
new_cols = columns.split(") SELECT ", 2).first
|
|
482
|
+
[
|
|
483
|
+
"CREATE TABLE #{bt}(#{create.delete_prefix(create_prefix)}",
|
|
484
|
+
"INSERT INTO #{bt}(#{columns} FROM #{qt}",
|
|
485
|
+
"DROP TABLE #{qt}",
|
|
486
|
+
create,
|
|
487
|
+
"INSERT INTO #{qt}(#{new_cols}) SELECT #{new_cols} FROM #{bt}",
|
|
488
|
+
"DROP TABLE #{bt}",
|
|
489
|
+
*rest
|
|
490
|
+
]
|
|
491
|
+
end
|
|
492
|
+
|
|
493
|
+
# AUTOINCREMENT tables get engine bookkeeping tables in sqlite_master.
|
|
494
|
+
def tables_and_views(filter, opts)
|
|
495
|
+
super.reject do |name|
|
|
496
|
+
name = name.to_s
|
|
497
|
+
name.start_with?("__turso_internal_") || SYNC_ENGINE_TABLES.include?(name)
|
|
498
|
+
end
|
|
499
|
+
end
|
|
500
|
+
|
|
501
|
+
# sql with RETURNING rowid added, unless it has a RETURNING clause. The
|
|
502
|
+
# clause goes at the end of the statement: before a trailing ";" and any
|
|
503
|
+
# trailing comment (a "--" comment would otherwise swallow it). Literals,
|
|
504
|
+
# quoted identifiers and comments are masked first, so "RETURNING"
|
|
505
|
+
# inside data doesn't count as a clause.
|
|
506
|
+
def with_returning_rowid(sql)
|
|
507
|
+
# Comments become spaces (they aren't part of the statement), literals
|
|
508
|
+
# and identifiers become "x" (they are).
|
|
509
|
+
masked = sql.gsub(NON_CODE) { |m| (m.start_with?("--", "/*") ? " " : "x") * m.length }
|
|
510
|
+
return sql if RETURNING_CLAUSE.match?(masked)
|
|
511
|
+
|
|
512
|
+
at = masked.sub(/;\s*\z/, "").rstrip.length
|
|
513
|
+
"#{sql[0, at]} RETURNING rowid#{sql[at..]}"
|
|
514
|
+
end
|
|
515
|
+
|
|
516
|
+
# The engine doesn't capture ALTER TABLE ... RENAME TO / RENAME COLUMN
|
|
517
|
+
# for sync (turso_core translate/alter.rs emits no CDC for them), so on
|
|
518
|
+
# a synced replica the remote never sees the rename and every later
|
|
519
|
+
# write to the table is lost on push.
|
|
520
|
+
def refuse_rename_when_synced(what)
|
|
521
|
+
return unless @turso_database.synced?
|
|
522
|
+
|
|
523
|
+
raise Error, "#{what} is refused on a synced replica: the Turso sync engine does not replicate " \
|
|
524
|
+
"ALTER TABLE ... RENAME TO / RENAME COLUMN to the remote, so later writes to the table would be " \
|
|
525
|
+
"lost on push (no upstream fix yet). Create the new table, copy the rows and drop the old one, " \
|
|
526
|
+
"or run the rename against the remote database directly."
|
|
527
|
+
end
|
|
528
|
+
|
|
529
|
+
def alter_table_sql(table, op)
|
|
530
|
+
# On a synced replica, native DROP COLUMN loses unpushed rows and
|
|
531
|
+
# RENAME COLUMN never reaches the remote (the engine doesn't capture
|
|
532
|
+
# them), so both go through the RENAME-free rebuild.
|
|
533
|
+
if @turso_database.synced?
|
|
534
|
+
case op[:op]
|
|
535
|
+
when :drop_column
|
|
536
|
+
ocp = lambda { |oc| oc.delete_if { |c| c.to_s == op[:name].to_s } }
|
|
537
|
+
return duplicate_table(table, old_columns_proc: ocp) { |cols| cols.delete_if { |s| s[:name].to_s == op[:name].to_s } }
|
|
538
|
+
when :rename_column
|
|
539
|
+
return rebuild_renaming_column(table, op)
|
|
540
|
+
end
|
|
541
|
+
end
|
|
542
|
+
super
|
|
543
|
+
end
|
|
544
|
+
|
|
545
|
+
# Column rename as a rebuild. Sequel's fallback renames the column first
|
|
546
|
+
# and then looks up UNIQUE constraints and indexes by the old name, so it
|
|
547
|
+
# loses them. Take them before the rebuild and replay them with the new
|
|
548
|
+
# name: inline/composite UNIQUE (autoindexes) become column or table
|
|
549
|
+
# constraints, every other index mentioning the column is recreated.
|
|
550
|
+
def rebuild_renaming_column(table, op)
|
|
551
|
+
old = op[:name].to_s
|
|
552
|
+
new = op[:new_name]
|
|
553
|
+
rn = ->(cols) { cols.map { |c| c.to_s == old ? new : c } }
|
|
554
|
+
pks = schema(table).select { |_, c| c[:primary_key] }.map { |n, _| n.to_s }
|
|
555
|
+
auto_all = indexes(table, only_autocreated: true)
|
|
556
|
+
auto = auto_all.values.select { |h| h[:unique] && h[:columns].map(&:to_s) != pks }
|
|
557
|
+
single = auto.select { |h| h[:columns].length == 1 }.map { |h| rn.(h[:columns]).first.to_s }
|
|
558
|
+
constraints = auto.select { |h| h[:columns].length > 1 }.map { |h| { type: :unique, columns: rn.(h[:columns]) } }
|
|
559
|
+
refuse_rename_of_referenced_column(table, old, pks)
|
|
560
|
+
# Sequel would copy the foreign keys under the old column names; copy
|
|
561
|
+
# them ourselves with the renamed column. Self-references rename the
|
|
562
|
+
# referenced column (key) too. (The engine doesn't report DEFERRABLE.)
|
|
563
|
+
fks = foreign_key_list(table).map do |fk|
|
|
564
|
+
self_ref = fk[:table].to_s == table.to_s
|
|
565
|
+
fk.merge(type: :foreign_key, columns: rn.(fk[:columns]), key: (self_ref && fk[:key] ? rn.(fk[:key]) : fk[:key]))
|
|
566
|
+
end
|
|
567
|
+
constraints.concat(fks)
|
|
568
|
+
named = indexes(table).select { |n, h| !auto_all.key?(n) && h[:columns].map(&:to_s).include?(old) }
|
|
569
|
+
|
|
570
|
+
ncp = ->(nc) { nc.map! { |c| c.to_s == old ? new : c } }
|
|
571
|
+
sqls = duplicate_table(table, new_columns_proc: ncp, no_unique: true, no_foreign_keys: true, constraints: constraints) do |cols|
|
|
572
|
+
cols.each do |c|
|
|
573
|
+
c[:name] = new if c[:name].to_s == old
|
|
574
|
+
c[:unique] = true if single.include?(c[:name].to_s) && !c[:primary_key]
|
|
575
|
+
end
|
|
576
|
+
end
|
|
577
|
+
sqls + named.map { |name, h| alter_table_sql(table, h.merge(op: :add_index, name: name, columns: rn.(h[:columns]))) }
|
|
578
|
+
end
|
|
579
|
+
|
|
580
|
+
# A rebuild of this table can't update another table's foreign key that
|
|
581
|
+
# names the column, and the stale reference makes both tables unwritable.
|
|
582
|
+
def refuse_rename_of_referenced_column(table, old, pks)
|
|
583
|
+
referencing = tables.reject { |t| t.to_s == table.to_s }.select do |other|
|
|
584
|
+
foreign_key_list(other).any? do |fk|
|
|
585
|
+
fk[:table].to_s == table.to_s && (fk[:key] || pks).map(&:to_s).include?(old)
|
|
586
|
+
end
|
|
587
|
+
end
|
|
588
|
+
return if referencing.empty?
|
|
589
|
+
|
|
590
|
+
raise Error, "rename_column of #{table}.#{old} is refused on a synced replica: " \
|
|
591
|
+
"#{referencing.join(', ')} reference(s) it with a foreign key, and rebuilding #{table} cannot " \
|
|
592
|
+
"update that reference. Rebuild the referencing table(s) as well, or apply the rename on the " \
|
|
593
|
+
"remote database and pull."
|
|
594
|
+
end
|
|
595
|
+
|
|
596
|
+
def support_without_rowid?
|
|
597
|
+
false
|
|
598
|
+
end
|
|
599
|
+
end
|
|
600
|
+
|
|
601
|
+
class Dataset < Sequel::Dataset
|
|
602
|
+
include Sequel::SQLite::DatasetMethods
|
|
603
|
+
|
|
604
|
+
def fetch_rows(sql)
|
|
605
|
+
execute(sql) do |columns, decl_types, rows|
|
|
606
|
+
cps = db.conversion_procs
|
|
607
|
+
cols = columns.each_with_index.map { |c, i| [output_identifier(c), cps[base_type_name(decl_types[i])]] }
|
|
608
|
+
self.columns = cols.map(&:first)
|
|
609
|
+
rows.each do |values|
|
|
610
|
+
row = {}
|
|
611
|
+
cols.each_with_index do |(name, conv), i|
|
|
612
|
+
v = values[i]
|
|
613
|
+
row[name] = conv && !v.nil? ? conv.call(v) : v
|
|
614
|
+
end
|
|
615
|
+
yield row
|
|
616
|
+
end
|
|
617
|
+
end
|
|
618
|
+
end
|
|
619
|
+
|
|
620
|
+
private
|
|
621
|
+
|
|
622
|
+
# "decimal(10,2)" -> "decimal"; nil (expression columns) stays nil.
|
|
623
|
+
def base_type_name(t)
|
|
624
|
+
(t =~ /\A(.*?)\(/ ? Regexp.last_match(1) : t).downcase if t
|
|
625
|
+
end
|
|
626
|
+
end
|
|
627
|
+
end
|
|
628
|
+
end
|
metadata
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: sequel-beagle-turso
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- BeagleSoftwareUK
|
|
8
|
+
autorequire:
|
|
9
|
+
bindir: bin
|
|
10
|
+
cert_chain: []
|
|
11
|
+
date: 2026-10-18 00:00:00.000000000 Z
|
|
12
|
+
dependencies:
|
|
13
|
+
- !ruby/object:Gem::Dependency
|
|
14
|
+
name: sequel
|
|
15
|
+
requirement: !ruby/object:Gem::Requirement
|
|
16
|
+
requirements:
|
|
17
|
+
- - "~>"
|
|
18
|
+
- !ruby/object:Gem::Version
|
|
19
|
+
version: '5.100'
|
|
20
|
+
type: :runtime
|
|
21
|
+
prerelease: false
|
|
22
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
23
|
+
requirements:
|
|
24
|
+
- - "~>"
|
|
25
|
+
- !ruby/object:Gem::Version
|
|
26
|
+
version: '5.100'
|
|
27
|
+
- !ruby/object:Gem::Dependency
|
|
28
|
+
name: beagle-turso
|
|
29
|
+
requirement: !ruby/object:Gem::Requirement
|
|
30
|
+
requirements:
|
|
31
|
+
- - "~>"
|
|
32
|
+
- !ruby/object:Gem::Version
|
|
33
|
+
version: '0.3'
|
|
34
|
+
type: :runtime
|
|
35
|
+
prerelease: false
|
|
36
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
37
|
+
requirements:
|
|
38
|
+
- - "~>"
|
|
39
|
+
- !ruby/object:Gem::Version
|
|
40
|
+
version: '0.3'
|
|
41
|
+
description: |
|
|
42
|
+
A Sequel adapter (URL scheme beagle-turso://) that runs Sequel -- and ROM /
|
|
43
|
+
Hanami repositories on top of it -- on a beagle-turso database: a local
|
|
44
|
+
SQLite-compatible replica that syncs to Turso Cloud. SQL generation, schema
|
|
45
|
+
parsing and transactions come from Sequel's shared SQLite support.
|
|
46
|
+
email:
|
|
47
|
+
executables: []
|
|
48
|
+
extensions: []
|
|
49
|
+
extra_rdoc_files: []
|
|
50
|
+
files:
|
|
51
|
+
- README.md
|
|
52
|
+
- lib/sequel-beagle-turso.rb
|
|
53
|
+
- lib/sequel/adapters/beagle-turso.rb
|
|
54
|
+
- lib/sequel/adapters/beagle_turso.rb
|
|
55
|
+
- lib/sequel/beagle_turso/version.rb
|
|
56
|
+
homepage: https://github.com/BeagleSoftwareUK/beagle-turso
|
|
57
|
+
licenses:
|
|
58
|
+
- MIT
|
|
59
|
+
metadata:
|
|
60
|
+
source_code_uri: https://github.com/BeagleSoftwareUK/beagle-turso
|
|
61
|
+
post_install_message:
|
|
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.3'
|
|
70
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
71
|
+
requirements:
|
|
72
|
+
- - ">="
|
|
73
|
+
- !ruby/object:Gem::Version
|
|
74
|
+
version: '0'
|
|
75
|
+
requirements: []
|
|
76
|
+
rubygems_version: 3.5.22
|
|
77
|
+
signing_key:
|
|
78
|
+
specification_version: 4
|
|
79
|
+
summary: Sequel adapter for Turso's local-first engine, backed by the beagle-turso
|
|
80
|
+
driver.
|
|
81
|
+
test_files: []
|