activerecord-libsql 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.
@@ -0,0 +1,371 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'active_record'
4
+ require 'active_record/connection_adapters/abstract_adapter'
5
+ require 'turso_libsql/turso_libsql'
6
+
7
+ # AR 7.2+ のアダプター登録 API
8
+ ActiveSupport.on_load(:active_record) do
9
+ ActiveRecord::ConnectionAdapters.register(
10
+ 'turso',
11
+ 'ActiveRecord::ConnectionAdapters::LibsqlAdapter',
12
+ 'active_record/connection_adapters/libsql_adapter'
13
+ )
14
+ end
15
+
16
+ module ActiveRecord
17
+ module ConnectionAdapters
18
+ class LibsqlAdapter < AbstractAdapter
19
+ ADAPTER_NAME = 'Turso'
20
+
21
+ # SQLite 互換の型マッピング(libSQL は SQLite 方言)
22
+ NATIVE_DATABASE_TYPES = {
23
+ primary_key: 'INTEGER PRIMARY KEY AUTOINCREMENT',
24
+ string: { name: 'TEXT' },
25
+ text: { name: 'TEXT' },
26
+ integer: { name: 'INTEGER' },
27
+ float: { name: 'REAL' },
28
+ decimal: { name: 'REAL' },
29
+ datetime: { name: 'TEXT' },
30
+ timestamp: { name: 'TEXT' },
31
+ time: { name: 'TEXT' },
32
+ date: { name: 'TEXT' },
33
+ binary: { name: 'BLOB' },
34
+ boolean: { name: 'INTEGER' },
35
+ json: { name: 'TEXT' }
36
+ }.freeze
37
+
38
+ # SQLite 互換: PRAGMA も読み取りクエリとして扱う
39
+ READ_QUERY = ActiveRecord::ConnectionAdapters::AbstractAdapter.build_read_query_regexp(
40
+ :pragma
41
+ )
42
+ private_constant :READ_QUERY
43
+
44
+ # -----------------------------------------------------------------------
45
+ # Adapter 識別
46
+ # -----------------------------------------------------------------------
47
+
48
+ def adapter_name
49
+ ADAPTER_NAME
50
+ end
51
+
52
+ def supports_migrations?
53
+ true
54
+ end
55
+
56
+ def supports_primary_key?
57
+ true
58
+ end
59
+
60
+ def supports_ddl_transactions?
61
+ false
62
+ end
63
+
64
+ def supports_savepoints?
65
+ false
66
+ end
67
+
68
+ def supports_explain?
69
+ false
70
+ end
71
+
72
+ def supports_lazy_transactions?
73
+ false
74
+ end
75
+
76
+ def write_query?(sql)
77
+ !READ_QUERY.match?(sql)
78
+ rescue ArgumentError
79
+ !READ_QUERY.match?(sql.b)
80
+ end
81
+
82
+ # -----------------------------------------------------------------------
83
+ # 接続管理(AR 8 スタイル)
84
+ # @raw_connection に TursoLibsql::Connection をセットする
85
+ # @raw_database に TursoLibsql::Database を保持する(sync / lifetime 管理)
86
+ # AR の ConnectionPool はスレッドごとに独立した Adapter インスタンスを払い出すため
87
+ # @raw_connection の競合は発生しない
88
+ # -----------------------------------------------------------------------
89
+
90
+ def connect!
91
+ @raw_database, @raw_connection = build_libsql_connection
92
+ super
93
+ end
94
+
95
+ def active?
96
+ return false unless @raw_connection
97
+
98
+ @raw_connection.query('SELECT 1')
99
+ true
100
+ rescue StandardError
101
+ false
102
+ end
103
+
104
+ def reconnect!
105
+ @raw_database, @raw_connection = build_libsql_connection
106
+ super
107
+ end
108
+
109
+ def disconnect!
110
+ @raw_connection = nil
111
+ @raw_database = nil
112
+ super
113
+ end
114
+
115
+ # Embedded Replica モードでリモートから最新フレームを手動同期する。
116
+ # Remote モードでは何もしない(no-op)。
117
+ def sync
118
+ @raw_database&.sync
119
+ end
120
+
121
+ # -----------------------------------------------------------------------
122
+ # AR 8 クエリパイプライン
123
+ # raw_execute → perform_query → cast_result の流れ
124
+ # -----------------------------------------------------------------------
125
+
126
+ # AR 8 が with_raw_connection { |conn| } で呼ぶ中核メソッド
127
+ def perform_query(raw_connection, sql, _binds, type_casted_binds, prepare:, notification_payload:, batch: false)
128
+ # バインドパラメータを SQL に展開する(libsql の ? プレースホルダーに対応)
129
+ expanded_sql = if type_casted_binds&.any?
130
+ i = -1
131
+ sql.gsub('?') do
132
+ i += 1
133
+ quote(type_casted_binds[i])
134
+ end
135
+ else
136
+ sql
137
+ end
138
+
139
+ if read_query?(expanded_sql)
140
+ rows = raw_connection.query(expanded_sql)
141
+ notification_payload[:row_count] = rows.size if notification_payload
142
+ build_result(rows)
143
+ else
144
+ affected = raw_connection.execute(expanded_sql)
145
+ notification_payload[:row_count] = affected if notification_payload
146
+ ActiveRecord::Result.empty(affected_rows: affected.to_i)
147
+ end
148
+ rescue RuntimeError => e
149
+ raise translate_exception(e, message: e.message, sql: expanded_sql, binds: [])
150
+ end
151
+
152
+ # perform_query が返した結果をそのまま使う(すでに ActiveRecord::Result)
153
+ def cast_result(raw_result)
154
+ raw_result
155
+ end
156
+
157
+ def affected_rows(raw_result)
158
+ raw_result.length
159
+ end
160
+
161
+ # -----------------------------------------------------------------------
162
+ # トランザクション
163
+ # -----------------------------------------------------------------------
164
+
165
+ def begin_db_transaction
166
+ @raw_connection&.begin_transaction
167
+ end
168
+
169
+ def commit_db_transaction
170
+ @raw_connection&.commit_transaction
171
+ end
172
+
173
+ def exec_rollback_db_transaction
174
+ @raw_connection&.rollback_transaction
175
+ end
176
+
177
+ # -----------------------------------------------------------------------
178
+ # INSERT 後の id
179
+ # AR 8 は last_inserted_id(result) を呼ぶ
180
+ # -----------------------------------------------------------------------
181
+
182
+ def last_inserted_id(_result)
183
+ @raw_connection&.last_insert_rowid
184
+ end
185
+
186
+ # -----------------------------------------------------------------------
187
+ # スキーマ情報
188
+ # -----------------------------------------------------------------------
189
+
190
+ def native_database_types
191
+ NATIVE_DATABASE_TYPES
192
+ end
193
+
194
+ def tables(_name = nil)
195
+ result = internal_exec_query(
196
+ "SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'",
197
+ 'SCHEMA'
198
+ )
199
+ result.rows.flatten
200
+ end
201
+
202
+ def columns(table_name)
203
+ result = internal_exec_query(
204
+ "PRAGMA table_info(#{quote_table_name(table_name)})",
205
+ 'SCHEMA'
206
+ )
207
+ result.map do |row|
208
+ sql_type = row['type'].to_s
209
+ cast_type = type_map.lookup(sql_type) || Type::Value.new
210
+ sql_type_md = fetch_type_metadata(sql_type)
211
+ # AR 8.1: Column.new(name, cast_type, default, sql_type_metadata, null)
212
+ Column.new(
213
+ row['name'],
214
+ cast_type,
215
+ row['dflt_value'],
216
+ sql_type_md,
217
+ row['notnull'].to_i.zero?
218
+ )
219
+ end
220
+ end
221
+
222
+ def table_exists?(table_name)
223
+ tables.include?(table_name.to_s)
224
+ end
225
+
226
+ # -----------------------------------------------------------------------
227
+ # クォート
228
+ # -----------------------------------------------------------------------
229
+
230
+ def quote_column_name(name)
231
+ %("#{name.to_s.gsub('"', '""')}")
232
+ end
233
+
234
+ def quote_table_name(name)
235
+ %("#{name.to_s.gsub('"', '""')}")
236
+ end
237
+
238
+ def quoted_true
239
+ '1'
240
+ end
241
+
242
+ def quoted_false
243
+ '0'
244
+ end
245
+
246
+ private
247
+
248
+ # libsql の RuntimeError を AR の標準例外に変換する
249
+ def translate_exception(exception, message:, sql:, binds:)
250
+ msg = exception.message
251
+ case msg
252
+ when /NOT NULL constraint failed/i
253
+ ActiveRecord::NotNullViolation.new(message, sql: sql, binds: binds)
254
+ when /UNIQUE constraint failed/i
255
+ ActiveRecord::RecordNotUnique.new(message, sql: sql, binds: binds)
256
+ when /FOREIGN KEY constraint failed/i
257
+ ActiveRecord::InvalidForeignKey.new(message, sql: sql, binds: binds)
258
+ when /no such table/i
259
+ ActiveRecord::StatementInvalid.new(message, sql: sql, binds: binds)
260
+ else
261
+ super
262
+ end
263
+ end
264
+
265
+ # [TursoLibsql::Database, TursoLibsql::Connection] を返す
266
+ def build_libsql_connection
267
+ database_url = @config[:database] || @config[:url]
268
+ raise ArgumentError, 'libsql adapter requires :database (libsql://...)' unless database_url
269
+
270
+ token = @config[:token] || ''
271
+ replica_path = @config[:replica_path]
272
+ sync_interval = (@config[:sync_interval] || 0).to_i
273
+
274
+ db = if replica_path && @config[:offline]
275
+ # Offline write モード:
276
+ # write はローカルに書いてすぐ返す。sync() でまとめてリモートへ反映。
277
+ # ULID + last-write-wins 設計に最適。
278
+ TursoLibsql::Database.new_synced(
279
+ replica_path.to_s,
280
+ database_url.to_s,
281
+ token.to_s,
282
+ sync_interval
283
+ )
284
+ elsif replica_path
285
+ # Embedded Replica モード:
286
+ # read はローカルから。write はリモートへ即送信。
287
+ TursoLibsql::Database.new_remote_replica(
288
+ replica_path.to_s,
289
+ database_url.to_s,
290
+ token.to_s,
291
+ sync_interval
292
+ )
293
+ else
294
+ raise ArgumentError, 'libsql adapter requires :token' if token.empty?
295
+
296
+ TursoLibsql::Database.new_remote(database_url.to_s, token.to_s)
297
+ end
298
+
299
+ [db, db.connect]
300
+ end
301
+
302
+ # PK 取得(PRAGMA table_info の pk カラムを使う)
303
+ def primary_keys(table_name)
304
+ result = internal_exec_query(
305
+ "PRAGMA table_info(#{quote_table_name(table_name)})",
306
+ 'SCHEMA'
307
+ )
308
+ pks = result.select { |row| row['pk'].to_i > 0 }
309
+ pks.sort_by { |row| row['pk'].to_i }.map { |row| row['name'] }
310
+ end
311
+
312
+ # AR が views / data_sources で使う(SQLite 互換実装)
313
+ def data_source_sql(name = nil, type: nil)
314
+ scope = quoted_scope(name, type: type)
315
+ scope[:type] ||= "'table','view'"
316
+
317
+ sql = +"SELECT name FROM pragma_table_list WHERE schema <> 'temp'"
318
+ sql << " AND name NOT IN ('sqlite_sequence', 'sqlite_schema')"
319
+ sql << " AND name = #{scope[:name]}" if scope[:name]
320
+ sql << " AND type IN (#{scope[:type]})"
321
+ sql
322
+ end
323
+
324
+ def quoted_scope(name = nil, type: nil)
325
+ type = case type
326
+ when 'BASE TABLE' then "'table'"
327
+ when 'VIEW' then "'view'"
328
+ when 'VIRTUAL TABLE' then "'virtual'"
329
+ end
330
+ scope = {}
331
+ scope[:name] = quote(name) if name
332
+ scope[:type] = type if type
333
+ scope
334
+ end
335
+
336
+ # SELECT 系クエリかどうかを判定
337
+ def read_query?(sql)
338
+ sql.lstrip.match?(/\A\s*(SELECT|PRAGMA|EXPLAIN|WITH)\b/i)
339
+ end
340
+
341
+ # Array of Hash → ActiveRecord::Result
342
+ def build_result(rows)
343
+ return ActiveRecord::Result.new([], []) if rows.empty?
344
+
345
+ columns = rows.first.keys
346
+ values = rows.map(&:values)
347
+ ActiveRecord::Result.new(columns, values)
348
+ end
349
+
350
+ def initialize_type_map(m = type_map)
351
+ m.register_type(/^integer/i, Type::Integer.new)
352
+ m.register_type(/^real/i, Type::Float.new)
353
+ m.register_type(/^text/i, Type::String.new)
354
+ m.register_type(/^blob/i, Type::Binary.new)
355
+ m.register_type(/^boolean/i, Type::Boolean.new)
356
+ m.register_type(/./, Type::String.new)
357
+ end
358
+
359
+ def fetch_type_metadata(sql_type)
360
+ cast_type = type_map.lookup(sql_type) || Type::Value.new
361
+ SqlTypeMetadata.new(
362
+ sql_type: sql_type,
363
+ type: cast_type.type,
364
+ limit: cast_type.limit,
365
+ precision: cast_type.precision,
366
+ scale: cast_type.scale
367
+ )
368
+ end
369
+ end
370
+ end
371
+ end
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'rails/railtie'
4
+
5
+ module ActiveRecord
6
+ module Libsql
7
+ class Railtie < Rails::Railtie
8
+ rake_tasks do
9
+ load File.expand_path('../../../tasks/turso.rake', __dir__)
10
+ end
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveRecord
4
+ module Libsql
5
+ VERSION = '0.1.0'
6
+ end
7
+ end
@@ -0,0 +1,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'active_record'
4
+ require_relative 'activerecord/libsql/version'
5
+ require_relative 'active_record/connection_adapters/libsql_adapter'
6
+ require_relative 'activerecord/libsql/railtie' if defined?(Rails)
metadata ADDED
@@ -0,0 +1,126 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: activerecord-libsql
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - aileron
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: '7.0'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - ">="
24
+ - !ruby/object:Gem::Version
25
+ version: '7.0'
26
+ - !ruby/object:Gem::Dependency
27
+ name: rb_sys
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - "~>"
31
+ - !ruby/object:Gem::Version
32
+ version: '0.9'
33
+ type: :runtime
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - "~>"
38
+ - !ruby/object:Gem::Version
39
+ version: '0.9'
40
+ - !ruby/object:Gem::Dependency
41
+ name: rake
42
+ requirement: !ruby/object:Gem::Requirement
43
+ requirements:
44
+ - - "~>"
45
+ - !ruby/object:Gem::Version
46
+ version: '13.0'
47
+ type: :development
48
+ prerelease: false
49
+ version_requirements: !ruby/object:Gem::Requirement
50
+ requirements:
51
+ - - "~>"
52
+ - !ruby/object:Gem::Version
53
+ version: '13.0'
54
+ - !ruby/object:Gem::Dependency
55
+ name: rake-compiler
56
+ requirement: !ruby/object:Gem::Requirement
57
+ requirements:
58
+ - - "~>"
59
+ - !ruby/object:Gem::Version
60
+ version: '1.2'
61
+ type: :development
62
+ prerelease: false
63
+ version_requirements: !ruby/object:Gem::Requirement
64
+ requirements:
65
+ - - "~>"
66
+ - !ruby/object:Gem::Version
67
+ version: '1.2'
68
+ - !ruby/object:Gem::Dependency
69
+ name: rspec
70
+ requirement: !ruby/object:Gem::Requirement
71
+ requirements:
72
+ - - "~>"
73
+ - !ruby/object:Gem::Version
74
+ version: '3.0'
75
+ type: :development
76
+ prerelease: false
77
+ version_requirements: !ruby/object:Gem::Requirement
78
+ requirements:
79
+ - - "~>"
80
+ - !ruby/object:Gem::Version
81
+ version: '3.0'
82
+ description: |
83
+ An ActiveRecord adapter for Turso, the edge SQLite database powered by libSQL.
84
+ Uses a native Rust extension (via magnus) to connect directly to Turso via the
85
+ libSQL remote protocol, without requiring any external HTTP client.
86
+ email: []
87
+ executables: []
88
+ extensions:
89
+ - ext/turso_libsql/extconf.rb
90
+ extra_rdoc_files: []
91
+ files:
92
+ - Cargo.lock
93
+ - Cargo.toml
94
+ - README.md
95
+ - activerecord-libsql.gemspec
96
+ - ext/turso_libsql/Cargo.toml
97
+ - ext/turso_libsql/extconf.rb
98
+ - ext/turso_libsql/src/lib.rs
99
+ - lib/active_record/connection_adapters/libsql_adapter.rb
100
+ - lib/activerecord-libsql.rb
101
+ - lib/activerecord/libsql/railtie.rb
102
+ - lib/activerecord/libsql/version.rb
103
+ homepage: https://github.com/aileron-inc/activerecord-libsql
104
+ licenses:
105
+ - MIT
106
+ metadata:
107
+ homepage_uri: https://github.com/aileron-inc/activerecord-libsql
108
+ source_code_uri: https://github.com/aileron-inc/activerecord-libsql
109
+ rdoc_options: []
110
+ require_paths:
111
+ - lib
112
+ required_ruby_version: !ruby/object:Gem::Requirement
113
+ requirements:
114
+ - - ">="
115
+ - !ruby/object:Gem::Version
116
+ version: 3.1.0
117
+ required_rubygems_version: !ruby/object:Gem::Requirement
118
+ requirements:
119
+ - - ">="
120
+ - !ruby/object:Gem::Version
121
+ version: '0'
122
+ requirements: []
123
+ rubygems_version: 4.0.7
124
+ specification_version: 4
125
+ summary: ActiveRecord adapter for Turso (libSQL) database
126
+ test_files: []