libsql-activerecord2 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/LICENSE +21 -0
- data/README.md +160 -0
- data/lib/active_record/connection_adapters/libsql_adapter.rb +409 -0
- data/lib/libsql_activerecord/railtie.rb +17 -0
- data/lib/libsql_activerecord/version.rb +5 -0
- data/lib/libsql_activerecord.rb +6 -0
- metadata +74 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 6c9161b429a7ede7bc2e3786e30e97df71a47a939fa927ac322866b13f8ec3fe
|
|
4
|
+
data.tar.gz: 9261dfb43874b4b505cb01f601e6d953c352ac667e319bcad38fd74eab2e09a5
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: c24e01dcb01d607411d7bf87938585da1ee2e2294d48a4cf3368ccda337834684459c0acc1873c2fde2619620ec2bc9d0eb450d34a534f26e4d142f2989afd59
|
|
7
|
+
data.tar.gz: 6d8d747de0df0609763adb9e6e8af65a9ed34ef87259ef319a91b32188a763fd051f68e6b074d0fd3b06e8c826c3ae4a3d760b5f380bce8ab761fce6c550659d
|
data/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Speria
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
# libsql-activerecord2
|
|
2
|
+
|
|
3
|
+
ActiveRecord adapter for [libSQL](https://libsql.org/) / [Turso](https://turso.tech/), built on the [libsql2](https://github.com/speria-jp/libsql-ruby2) gem.
|
|
4
|
+
|
|
5
|
+
## Installation
|
|
6
|
+
|
|
7
|
+
Add to your Gemfile:
|
|
8
|
+
|
|
9
|
+
```ruby
|
|
10
|
+
gem "libsql-activerecord2"
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
Then run:
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
bundle install
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
## Configuration
|
|
20
|
+
|
|
21
|
+
Configure the `libsql` adapter in `config/database.yml` (Rails) or via `ActiveRecord::Base.establish_connection`.
|
|
22
|
+
|
|
23
|
+
### Local File
|
|
24
|
+
|
|
25
|
+
```yaml
|
|
26
|
+
development:
|
|
27
|
+
adapter: libsql
|
|
28
|
+
database: db/development.sqlite3
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
### In-Memory
|
|
32
|
+
|
|
33
|
+
```yaml
|
|
34
|
+
test:
|
|
35
|
+
adapter: libsql
|
|
36
|
+
database: ":memory:"
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
### Remote (Turso)
|
|
40
|
+
|
|
41
|
+
```yaml
|
|
42
|
+
production:
|
|
43
|
+
adapter: libsql
|
|
44
|
+
database: libsql://your-db.turso.io
|
|
45
|
+
token: <%= ENV["TURSO_AUTH_TOKEN"] %>
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
### Embedded Replica
|
|
49
|
+
|
|
50
|
+
Keeps a local replica that syncs with the remote database — ideal for read-heavy workloads with low latency.
|
|
51
|
+
|
|
52
|
+
```yaml
|
|
53
|
+
production:
|
|
54
|
+
adapter: libsql
|
|
55
|
+
database: libsql://your-db.turso.io
|
|
56
|
+
token: <%= ENV["TURSO_AUTH_TOKEN"] %>
|
|
57
|
+
replica_path: db/local_replica.sqlite3
|
|
58
|
+
read_your_writes: true # default: true
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
To manually trigger a sync:
|
|
62
|
+
|
|
63
|
+
```ruby
|
|
64
|
+
ActiveRecord::Base.connection.sync
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
## Usage
|
|
68
|
+
|
|
69
|
+
Once configured, use ActiveRecord as usual:
|
|
70
|
+
|
|
71
|
+
```ruby
|
|
72
|
+
class User < ApplicationRecord
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
User.create!(name: "Alice", email: "alice@example.com")
|
|
76
|
+
User.where(name: "Alice").first
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
### Without Rails
|
|
80
|
+
|
|
81
|
+
```ruby
|
|
82
|
+
require "libsql_activerecord"
|
|
83
|
+
|
|
84
|
+
ActiveRecord::Base.establish_connection(
|
|
85
|
+
adapter: "libsql",
|
|
86
|
+
database: ":memory:"
|
|
87
|
+
)
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
## Supported Features
|
|
91
|
+
|
|
92
|
+
| Feature | Status |
|
|
93
|
+
|---------|--------|
|
|
94
|
+
| Migrations | Yes |
|
|
95
|
+
| Primary keys | Yes |
|
|
96
|
+
| Savepoints (nested transactions) | Yes |
|
|
97
|
+
| Foreign keys | Yes |
|
|
98
|
+
| JSON columns | Yes |
|
|
99
|
+
| DDL transactions | No (planned) |
|
|
100
|
+
| EXPLAIN | No (planned) |
|
|
101
|
+
| Prepared statement cache | No (planned) |
|
|
102
|
+
| INSERT RETURNING | No (planned) |
|
|
103
|
+
|
|
104
|
+
### Connection Modes
|
|
105
|
+
|
|
106
|
+
| Mode | Supported |
|
|
107
|
+
|------|-----------|
|
|
108
|
+
| Local file | Yes |
|
|
109
|
+
| In-memory | Yes |
|
|
110
|
+
| Remote (Turso) | Yes |
|
|
111
|
+
| Embedded replica | Yes |
|
|
112
|
+
|
|
113
|
+
### Type Mapping
|
|
114
|
+
|
|
115
|
+
| ActiveRecord Type | SQL Type | SQLite Affinity |
|
|
116
|
+
|-------------------|----------|-----------------|
|
|
117
|
+
| `:primary_key` | `INTEGER PRIMARY KEY AUTOINCREMENT` | INTEGER |
|
|
118
|
+
| `:string` / `:text` | `TEXT` | TEXT |
|
|
119
|
+
| `:integer` | `INTEGER` | INTEGER |
|
|
120
|
+
| `:float` / `:decimal` | `REAL` | REAL |
|
|
121
|
+
| `:datetime` / `:timestamp` | `DATETIME` | NUMERIC |
|
|
122
|
+
| `:date` | `DATE` | NUMERIC |
|
|
123
|
+
| `:time` | `TIME` | NUMERIC |
|
|
124
|
+
| `:binary` | `BLOB` | BLOB |
|
|
125
|
+
| `:boolean` | `BOOLEAN` | NUMERIC |
|
|
126
|
+
| `:json` | `TEXT` | TEXT |
|
|
127
|
+
|
|
128
|
+
### Exception Mapping
|
|
129
|
+
|
|
130
|
+
| libSQL Error | ActiveRecord Exception |
|
|
131
|
+
|-------------|----------------------|
|
|
132
|
+
| `NOT NULL constraint failed` | `ActiveRecord::NotNullViolation` |
|
|
133
|
+
| `UNIQUE constraint failed` | `ActiveRecord::RecordNotUnique` |
|
|
134
|
+
| `FOREIGN KEY constraint failed` | `ActiveRecord::InvalidForeignKey` |
|
|
135
|
+
| Other `Libsql::Error` | `ActiveRecord::StatementInvalid` |
|
|
136
|
+
|
|
137
|
+
## Fork Safety
|
|
138
|
+
|
|
139
|
+
The adapter supports forking web servers (Puma, Unicorn). The `discard!` method cleans up the Rust runtime safely, and new connections are established in child processes automatically.
|
|
140
|
+
|
|
141
|
+
## Requirements
|
|
142
|
+
|
|
143
|
+
- Ruby >= 3.4
|
|
144
|
+
- ActiveRecord >= 8.0
|
|
145
|
+
- [libsql2](https://github.com/speria-jp/libsql-ruby2) >= 0.1.5
|
|
146
|
+
|
|
147
|
+
## Development
|
|
148
|
+
|
|
149
|
+
```bash
|
|
150
|
+
bundle install
|
|
151
|
+
bundle exec rspec # Unit tests
|
|
152
|
+
|
|
153
|
+
# Integration tests (per AR version)
|
|
154
|
+
cd integration_tests/8.0 && bundle install && bundle exec rspec
|
|
155
|
+
cd integration_tests/8.1 && bundle install && bundle exec rspec
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
## License
|
|
159
|
+
|
|
160
|
+
Released under the [MIT License](LICENSE).
|
|
@@ -0,0 +1,409 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "active_record"
|
|
4
|
+
require "active_record/connection_adapters/abstract_adapter"
|
|
5
|
+
require "libsql2"
|
|
6
|
+
|
|
7
|
+
module ActiveRecord
|
|
8
|
+
module ConnectionAdapters
|
|
9
|
+
class LibsqlAdapter < AbstractAdapter
|
|
10
|
+
ADAPTER_NAME = "Libsql"
|
|
11
|
+
|
|
12
|
+
# --- Read query detection (PRAGMA treated as read) ---
|
|
13
|
+
|
|
14
|
+
READ_QUERY = AbstractAdapter.build_read_query_regexp(:pragma)
|
|
15
|
+
private_constant :READ_QUERY
|
|
16
|
+
|
|
17
|
+
# --- FOR UPDATE / FOR SHARE stripping ---
|
|
18
|
+
|
|
19
|
+
FOR_UPDATE_PATTERN = %r{\s+FOR\s+(?:UPDATE|SHARE)(?:\s+SKIP\s+LOCKED)?(?:\s+OF\s+\S+)?\s*(?=/\*|$)}i
|
|
20
|
+
private_constant :FOR_UPDATE_PATTERN
|
|
21
|
+
|
|
22
|
+
# --- Native database types ---
|
|
23
|
+
|
|
24
|
+
NATIVE_DATABASE_TYPES = {
|
|
25
|
+
primary_key: "INTEGER PRIMARY KEY AUTOINCREMENT",
|
|
26
|
+
string: { name: "TEXT" },
|
|
27
|
+
text: { name: "TEXT" },
|
|
28
|
+
integer: { name: "INTEGER" },
|
|
29
|
+
float: { name: "REAL" },
|
|
30
|
+
decimal: { name: "REAL" },
|
|
31
|
+
datetime: { name: "DATETIME" },
|
|
32
|
+
timestamp: { name: "DATETIME" },
|
|
33
|
+
time: { name: "TIME" },
|
|
34
|
+
date: { name: "DATE" },
|
|
35
|
+
binary: { name: "BLOB" },
|
|
36
|
+
boolean: { name: "BOOLEAN" },
|
|
37
|
+
json: { name: "TEXT" }
|
|
38
|
+
}.freeze
|
|
39
|
+
|
|
40
|
+
# --- Column.new branching for AR 8.0 / 8.1 ---
|
|
41
|
+
|
|
42
|
+
COLUMN_BUILDER =
|
|
43
|
+
if ActiveRecord::VERSION::MAJOR > 8 ||
|
|
44
|
+
(ActiveRecord::VERSION::MAJOR == 8 && ActiveRecord::VERSION::MINOR >= 1)
|
|
45
|
+
# AR 8.1+: Column.new(name, cast_type, default, sql_type_metadata, null, default_function, **kwargs)
|
|
46
|
+
lambda { |name, cast_type, default, sql_type_md, null, default_function, **kwargs|
|
|
47
|
+
Column.new(name, cast_type, default, sql_type_md, null, default_function, **kwargs)
|
|
48
|
+
}
|
|
49
|
+
else
|
|
50
|
+
# AR 8.0: Column.new(name, default, sql_type_metadata, null, default_function, **kwargs)
|
|
51
|
+
lambda { |name, _cast_type, default, sql_type_md, null, default_function, **kwargs|
|
|
52
|
+
Column.new(name, default, sql_type_md, null, default_function, **kwargs)
|
|
53
|
+
}
|
|
54
|
+
end
|
|
55
|
+
private_constant :COLUMN_BUILDER
|
|
56
|
+
|
|
57
|
+
# --- AR 8.1 detection ---
|
|
58
|
+
|
|
59
|
+
AR_8_1_OR_LATER = ActiveRecord::VERSION::MAJOR > 8 ||
|
|
60
|
+
(ActiveRecord::VERSION::MAJOR == 8 && ActiveRecord::VERSION::MINOR >= 1)
|
|
61
|
+
private_constant :AR_8_1_OR_LATER
|
|
62
|
+
|
|
63
|
+
# --- Quoting (class methods required by AR 8.0+) ---
|
|
64
|
+
|
|
65
|
+
module Quoting
|
|
66
|
+
module ClassMethods
|
|
67
|
+
def quote_column_name(name)
|
|
68
|
+
@quoted_column_names ||= {}
|
|
69
|
+
@quoted_column_names[name] ||= %("#{name.to_s.gsub('"', '""')}").freeze
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
include Quoting
|
|
75
|
+
extend Quoting::ClassMethods
|
|
76
|
+
|
|
77
|
+
def self.quote_table_name(name)
|
|
78
|
+
@quoted_table_names ||= {}
|
|
79
|
+
@quoted_table_names[name] ||= %("#{name.to_s.gsub('"', '""').gsub('.', '"."')}").freeze
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def quote_table_name(name)
|
|
83
|
+
self.class.quote_table_name(name)
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def quoted_true = "1"
|
|
87
|
+
def quoted_false = "0"
|
|
88
|
+
def unquoted_true = 1
|
|
89
|
+
def unquoted_false = 0
|
|
90
|
+
|
|
91
|
+
# --- Feature flags ---
|
|
92
|
+
|
|
93
|
+
def supports_migrations? = true
|
|
94
|
+
def supports_primary_key? = true
|
|
95
|
+
def supports_savepoints? = true
|
|
96
|
+
def supports_foreign_keys? = true
|
|
97
|
+
def supports_json? = true
|
|
98
|
+
def supports_ddl_transactions? = false
|
|
99
|
+
def supports_explain? = false
|
|
100
|
+
def supports_lazy_transactions? = false
|
|
101
|
+
def supports_insert_returning? = false
|
|
102
|
+
|
|
103
|
+
def native_database_types
|
|
104
|
+
NATIVE_DATABASE_TYPES
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# --- Connection lifecycle ---
|
|
108
|
+
|
|
109
|
+
def initialize(...)
|
|
110
|
+
super
|
|
111
|
+
@raw_database = nil
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def connect
|
|
115
|
+
@raw_database, @raw_connection = build_libsql_connection
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def active?
|
|
119
|
+
return false unless @raw_connection
|
|
120
|
+
|
|
121
|
+
@raw_connection.query("SELECT 1")
|
|
122
|
+
true
|
|
123
|
+
rescue ::Libsql::Error, ::Libsql::ClosedError
|
|
124
|
+
false
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def connected?
|
|
128
|
+
!@raw_connection.nil?
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def disconnect!
|
|
132
|
+
super
|
|
133
|
+
@raw_connection&.close
|
|
134
|
+
@raw_database&.close
|
|
135
|
+
@raw_connection = nil
|
|
136
|
+
@raw_database = nil
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
# Clean up Rust runtime after fork (Puma / Unicorn safety).
|
|
140
|
+
def discard!
|
|
141
|
+
@raw_database&.discard!
|
|
142
|
+
@raw_connection = nil
|
|
143
|
+
@raw_database = nil
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
# Sync embedded replica with remote.
|
|
147
|
+
def sync
|
|
148
|
+
@raw_database&.sync
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
# --- Query execution pipeline ---
|
|
152
|
+
|
|
153
|
+
def write_query?(sql)
|
|
154
|
+
!READ_QUERY.match?(sql)
|
|
155
|
+
rescue ArgumentError
|
|
156
|
+
!READ_QUERY.match?(sql.b)
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
def perform_query(raw_connection, sql, _binds, type_casted_binds,
|
|
160
|
+
prepare:, notification_payload:, batch: false)
|
|
161
|
+
sanitized = sanitize_for_update(sql)
|
|
162
|
+
params = type_casted_binds || []
|
|
163
|
+
|
|
164
|
+
result =
|
|
165
|
+
if batch
|
|
166
|
+
raw_connection.execute_batch(sanitized)
|
|
167
|
+
build_empty_result(affected: 0)
|
|
168
|
+
elsif write_query?(sanitized)
|
|
169
|
+
affected = raw_connection.execute(sanitized, params)
|
|
170
|
+
@last_inserted_id = raw_connection.last_insert_rowid
|
|
171
|
+
build_empty_result(affected: affected)
|
|
172
|
+
else
|
|
173
|
+
rows_obj = raw_connection.query(sanitized, params)
|
|
174
|
+
build_read_result(rows_obj, raw_connection)
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
notification_payload[:row_count] = result&.length || 0
|
|
178
|
+
notification_payload[:affected_rows] = affected_rows(result) if AR_8_1_OR_LATER
|
|
179
|
+
|
|
180
|
+
result
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
def cast_result(result)
|
|
184
|
+
result
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
def affected_rows(result)
|
|
188
|
+
if result.respond_to?(:affected_rows)
|
|
189
|
+
result.affected_rows
|
|
190
|
+
else
|
|
191
|
+
@last_affected_rows
|
|
192
|
+
end
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
def last_inserted_id(_result)
|
|
196
|
+
@last_inserted_id
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
# --- Transactions ---
|
|
200
|
+
|
|
201
|
+
def begin_db_transaction
|
|
202
|
+
@raw_connection.execute("BEGIN")
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
def commit_db_transaction
|
|
206
|
+
@raw_connection.execute("COMMIT")
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
def exec_rollback_db_transaction
|
|
210
|
+
@raw_connection.execute("ROLLBACK")
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
def create_savepoint(name = current_savepoint_name)
|
|
214
|
+
@raw_connection.execute("SAVEPOINT #{quote_column_name(name)}")
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
def release_savepoint(name = current_savepoint_name)
|
|
218
|
+
@raw_connection.execute("RELEASE SAVEPOINT #{quote_column_name(name)}")
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
def exec_rollback_to_savepoint(name = current_savepoint_name)
|
|
222
|
+
@raw_connection.execute("ROLLBACK TO SAVEPOINT #{quote_column_name(name)}")
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
# --- Schema introspection ---
|
|
226
|
+
|
|
227
|
+
def tables
|
|
228
|
+
query = "SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%' ORDER BY name"
|
|
229
|
+
result = @raw_connection.query(query)
|
|
230
|
+
result.to_a.map { |row| row["name"] }
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
def table_exists?(table_name)
|
|
234
|
+
tables.include?(table_name.to_s)
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
def views
|
|
238
|
+
query = "SELECT name FROM sqlite_master WHERE type='view' AND name NOT LIKE 'sqlite_%' ORDER BY name"
|
|
239
|
+
result = @raw_connection.query(query)
|
|
240
|
+
result.to_a.map { |row| row["name"] }
|
|
241
|
+
end
|
|
242
|
+
|
|
243
|
+
def column_definitions(table_name)
|
|
244
|
+
result = @raw_connection.query("PRAGMA table_info(#{quote_table_name(table_name)})")
|
|
245
|
+
result.to_a
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
def new_column_from_field(_table_name, field, _definitions)
|
|
249
|
+
default_value = field["dflt_value"]
|
|
250
|
+
|
|
251
|
+
# Unquote string defaults
|
|
252
|
+
default_value = default_value[1..-2] if default_value&.start_with?("'") && default_value.end_with?("'")
|
|
253
|
+
|
|
254
|
+
type = field["type"]
|
|
255
|
+
type_metadata = fetch_type_metadata(type)
|
|
256
|
+
cast_type = lookup_cast_type(type)
|
|
257
|
+
null = field["notnull"].to_i.zero?
|
|
258
|
+
default_function = nil
|
|
259
|
+
|
|
260
|
+
COLUMN_BUILDER.call(
|
|
261
|
+
field["name"], cast_type, default_value, type_metadata, null, default_function
|
|
262
|
+
)
|
|
263
|
+
end
|
|
264
|
+
|
|
265
|
+
def primary_keys(table_name)
|
|
266
|
+
result = @raw_connection.query("PRAGMA table_info(#{quote_table_name(table_name)})")
|
|
267
|
+
result.to_a
|
|
268
|
+
.select { |row| row["pk"].to_i.positive? }
|
|
269
|
+
.sort_by { |row| row["pk"].to_i }
|
|
270
|
+
.map { |row| row["name"] }
|
|
271
|
+
end
|
|
272
|
+
|
|
273
|
+
def indexes(table_name)
|
|
274
|
+
index_list = @raw_connection.query("PRAGMA index_list(#{quote_table_name(table_name)})").to_a
|
|
275
|
+
|
|
276
|
+
index_list.filter_map do |idx|
|
|
277
|
+
next if idx["name"].start_with?("sqlite_")
|
|
278
|
+
|
|
279
|
+
columns = @raw_connection.query("PRAGMA index_info(#{quote_table_name(idx['name'])})").to_a
|
|
280
|
+
column_names = columns.sort_by { |c| c["seqno"].to_i }.map { |c| c["name"] }
|
|
281
|
+
|
|
282
|
+
IndexDefinition.new(
|
|
283
|
+
table_name,
|
|
284
|
+
idx["name"],
|
|
285
|
+
idx["unique"].to_i != 0,
|
|
286
|
+
column_names
|
|
287
|
+
)
|
|
288
|
+
end
|
|
289
|
+
end
|
|
290
|
+
|
|
291
|
+
# --- Schema DDL ---
|
|
292
|
+
|
|
293
|
+
def rename_table(table_name, new_name, **)
|
|
294
|
+
execute("ALTER TABLE #{quote_table_name(table_name)} RENAME TO #{quote_table_name(new_name)}")
|
|
295
|
+
end
|
|
296
|
+
|
|
297
|
+
def rename_column(table_name, column_name, new_column_name, **)
|
|
298
|
+
execute(
|
|
299
|
+
"ALTER TABLE #{quote_table_name(table_name)} " \
|
|
300
|
+
"RENAME COLUMN #{quote_column_name(column_name)} TO #{quote_column_name(new_column_name)}"
|
|
301
|
+
)
|
|
302
|
+
end
|
|
303
|
+
|
|
304
|
+
# --- Exception translation ---
|
|
305
|
+
|
|
306
|
+
def translate_exception(exception, message:, sql:, binds:)
|
|
307
|
+
# Libsql::Error inherits from RuntimeError, so the default
|
|
308
|
+
# translate_exception would return it as-is. We must handle
|
|
309
|
+
# it explicitly to convert to AR exception classes.
|
|
310
|
+
msg = exception.message
|
|
311
|
+
if /NOT NULL constraint failed/i.match?(msg)
|
|
312
|
+
NotNullViolation.new(message, sql: sql, binds: binds, connection_pool: @pool)
|
|
313
|
+
elsif /UNIQUE constraint failed/i.match?(msg)
|
|
314
|
+
RecordNotUnique.new(message, sql: sql, binds: binds, connection_pool: @pool)
|
|
315
|
+
elsif /FOREIGN KEY constraint failed/i.match?(msg)
|
|
316
|
+
InvalidForeignKey.new(message, sql: sql, binds: binds, connection_pool: @pool)
|
|
317
|
+
elsif exception.is_a?(::Libsql::Error)
|
|
318
|
+
StatementInvalid.new(message, sql: sql, binds: binds, connection_pool: @pool)
|
|
319
|
+
else
|
|
320
|
+
super
|
|
321
|
+
end
|
|
322
|
+
end
|
|
323
|
+
|
|
324
|
+
# --- Type map (class method, required by AR 8.0+) ---
|
|
325
|
+
|
|
326
|
+
class << self
|
|
327
|
+
private
|
|
328
|
+
|
|
329
|
+
def initialize_type_map(m)
|
|
330
|
+
super
|
|
331
|
+
# SQLite INTEGER is always 64-bit signed; override AR default (limit: 4)
|
|
332
|
+
m.register_type(/^integer/i, Type::Integer.new(limit: 8))
|
|
333
|
+
# REAL is not registered by AbstractAdapter
|
|
334
|
+
m.register_type(/^real/i, Type::Float.new)
|
|
335
|
+
end
|
|
336
|
+
end
|
|
337
|
+
|
|
338
|
+
TYPE_MAP = Type::TypeMap.new.tap { |m| initialize_type_map(m) }
|
|
339
|
+
EXTENDED_TYPE_MAPS = Concurrent::Map.new
|
|
340
|
+
|
|
341
|
+
private
|
|
342
|
+
|
|
343
|
+
# --- Connection building ---
|
|
344
|
+
|
|
345
|
+
def build_libsql_connection
|
|
346
|
+
database_url = @config[:database] || raise(ArgumentError, "libsql adapter requires :database")
|
|
347
|
+
token = @config[:token] || ""
|
|
348
|
+
replica_path = @config[:replica_path]
|
|
349
|
+
|
|
350
|
+
db = if replica_path
|
|
351
|
+
::Libsql::Database.open(
|
|
352
|
+
database_url.to_s,
|
|
353
|
+
auth_token: token.to_s,
|
|
354
|
+
local_path: replica_path.to_s,
|
|
355
|
+
read_your_writes: @config.fetch(:read_your_writes, true)
|
|
356
|
+
)
|
|
357
|
+
elsif database_url.to_s.start_with?("libsql://", "https://")
|
|
358
|
+
::Libsql::Database.open(database_url.to_s, auth_token: token.to_s)
|
|
359
|
+
else
|
|
360
|
+
::Libsql::Database.open(database_url.to_s)
|
|
361
|
+
end
|
|
362
|
+
|
|
363
|
+
[db, db.connect]
|
|
364
|
+
end
|
|
365
|
+
|
|
366
|
+
# Reconnect implementation called by AbstractAdapter#reconnect!
|
|
367
|
+
def reconnect
|
|
368
|
+
disconnect!
|
|
369
|
+
@raw_database, @raw_connection = build_libsql_connection
|
|
370
|
+
end
|
|
371
|
+
|
|
372
|
+
# --- Query helpers ---
|
|
373
|
+
|
|
374
|
+
def sanitize_for_update(sql)
|
|
375
|
+
sql.gsub(FOR_UPDATE_PATTERN, " ")
|
|
376
|
+
end
|
|
377
|
+
|
|
378
|
+
def build_read_result(rows_obj, raw_connection)
|
|
379
|
+
columns = rows_obj.columns
|
|
380
|
+
rows = rows_obj.to_a.map { |row| columns.map { |c| row[c] } }
|
|
381
|
+
affected = raw_connection.changes
|
|
382
|
+
|
|
383
|
+
if AR_8_1_OR_LATER
|
|
384
|
+
ActiveRecord::Result.new(columns, rows, affected_rows: affected)
|
|
385
|
+
else
|
|
386
|
+
@last_affected_rows = affected
|
|
387
|
+
ActiveRecord::Result.new(columns, rows)
|
|
388
|
+
end
|
|
389
|
+
end
|
|
390
|
+
|
|
391
|
+
def build_empty_result(affected:)
|
|
392
|
+
if AR_8_1_OR_LATER
|
|
393
|
+
ActiveRecord::Result.empty(affected_rows: affected)
|
|
394
|
+
else
|
|
395
|
+
@last_affected_rows = affected
|
|
396
|
+
ActiveRecord::Result.empty
|
|
397
|
+
end
|
|
398
|
+
end
|
|
399
|
+
end
|
|
400
|
+
end
|
|
401
|
+
end
|
|
402
|
+
|
|
403
|
+
# Register adapter directly (works with and without Rails).
|
|
404
|
+
# When loaded via Railtie the duplicate register is harmless.
|
|
405
|
+
ActiveRecord::ConnectionAdapters.register(
|
|
406
|
+
"libsql",
|
|
407
|
+
"ActiveRecord::ConnectionAdapters::LibsqlAdapter",
|
|
408
|
+
"active_record/connection_adapters/libsql_adapter"
|
|
409
|
+
)
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rails/railtie"
|
|
4
|
+
|
|
5
|
+
module LibsqlActiverecord
|
|
6
|
+
class Railtie < Rails::Railtie
|
|
7
|
+
initializer "libsql_activerecord.register_adapter" do
|
|
8
|
+
ActiveSupport.on_load(:active_record) do
|
|
9
|
+
ActiveRecord::ConnectionAdapters.register(
|
|
10
|
+
"libsql",
|
|
11
|
+
"ActiveRecord::ConnectionAdapters::LibsqlAdapter",
|
|
12
|
+
"active_record/connection_adapters/libsql_adapter"
|
|
13
|
+
)
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
metadata
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: libsql-activerecord2
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- Speria
|
|
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
|
+
type: :runtime
|
|
20
|
+
prerelease: false
|
|
21
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
22
|
+
requirements:
|
|
23
|
+
- - ">="
|
|
24
|
+
- !ruby/object:Gem::Version
|
|
25
|
+
version: '8.0'
|
|
26
|
+
- !ruby/object:Gem::Dependency
|
|
27
|
+
name: libsql2
|
|
28
|
+
requirement: !ruby/object:Gem::Requirement
|
|
29
|
+
requirements:
|
|
30
|
+
- - ">="
|
|
31
|
+
- !ruby/object:Gem::Version
|
|
32
|
+
version: 0.1.5
|
|
33
|
+
type: :runtime
|
|
34
|
+
prerelease: false
|
|
35
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
36
|
+
requirements:
|
|
37
|
+
- - ">="
|
|
38
|
+
- !ruby/object:Gem::Version
|
|
39
|
+
version: 0.1.5
|
|
40
|
+
description: ActiveRecord connection adapter for libSQL databases (local, remote,
|
|
41
|
+
and embedded replica modes), built on the libsql2 gem.
|
|
42
|
+
executables: []
|
|
43
|
+
extensions: []
|
|
44
|
+
extra_rdoc_files: []
|
|
45
|
+
files:
|
|
46
|
+
- LICENSE
|
|
47
|
+
- README.md
|
|
48
|
+
- lib/active_record/connection_adapters/libsql_adapter.rb
|
|
49
|
+
- lib/libsql_activerecord.rb
|
|
50
|
+
- lib/libsql_activerecord/railtie.rb
|
|
51
|
+
- lib/libsql_activerecord/version.rb
|
|
52
|
+
homepage: https://github.com/speria-jp/libsql-activerecord2
|
|
53
|
+
licenses:
|
|
54
|
+
- MIT
|
|
55
|
+
metadata:
|
|
56
|
+
rubygems_mfa_required: 'true'
|
|
57
|
+
rdoc_options: []
|
|
58
|
+
require_paths:
|
|
59
|
+
- lib
|
|
60
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
61
|
+
requirements:
|
|
62
|
+
- - ">="
|
|
63
|
+
- !ruby/object:Gem::Version
|
|
64
|
+
version: '3.4'
|
|
65
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
66
|
+
requirements:
|
|
67
|
+
- - ">="
|
|
68
|
+
- !ruby/object:Gem::Version
|
|
69
|
+
version: '0'
|
|
70
|
+
requirements: []
|
|
71
|
+
rubygems_version: 4.0.3
|
|
72
|
+
specification_version: 4
|
|
73
|
+
summary: ActiveRecord adapter for libSQL / Turso
|
|
74
|
+
test_files: []
|