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 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: []