activerecord-turso 0.1.0 → 0.2.1

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 0c8e05a11274a315a434eb615873a9402e8afd76ff296b8efc030d9bd4d212d9
4
- data.tar.gz: 0fa05a0646df999aba45e14fc31a5e2066ad471964d7501fa3994df9882cbca8
3
+ metadata.gz: 04f8ecc726fa450fadcf8b819a04663b3035bed1c1b5f592d362ec07664d2f14
4
+ data.tar.gz: 4ce85117e6f562ead6d2defeaaa5c70fccc2c62492d9916c798e56bcf32acfca
5
5
  SHA512:
6
- metadata.gz: ee153e58a81fb89d2a1578cbbd4841a5720cac7c942916c1c256c234e3c0726f6bc0a2fb500cbcd7733a357efb43b1da37e9a8774a7d2eecfd98561cf9cdda76
7
- data.tar.gz: 9d5ec6478407aa7c168c29dfbd64b6dc0661e84fbbb1167041ef8b2615fb5ffab899474787c2ceb946430f2aa92bac8684adaf6ffe2b6cf6188a953eb9ced36b
6
+ metadata.gz: 6d18e249a927642d705959341439129523cb3f2763ab699a8c2dfbc1f38d8f52b3b831a96f140a95518f3e932a9da348184af605525d354a54893419f332c0aa
7
+ data.tar.gz: 8b1cec4617634414e11928453aeed21e2c382fe4a36357ca435b601f337f8b9ca3fb732f035db02ebd8a0fe8f7dfc35db961c384faffd2cf8937ea612ec6363e
data/README.md CHANGED
@@ -1,23 +1,27 @@
1
- # activerecord-turso
1
+ # Activerecord-turso
2
2
 
3
- ActiveRecord adapter for [Turso](https://turso.tech) (SQLite compatible).
3
+ ActiveRecord adapter for [Turso](https://turso.tech) (SQLite compatible db).
4
4
 
5
5
  ## Status
6
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.
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
8
 
9
9
  ## Requirements
10
10
 
11
- - Ruby >= 3.0
11
+ - Ruby >= 3.2
12
12
  - ActiveRecord >= 8.0, < 8.2
13
- - The `turso` Ruby gem (local bindings at `~/Projects/turso/bindings/ruby` during development)
13
+ - The `turso` Ruby gem
14
+
15
+ ## Runtime dependencies
16
+
17
+ This adapter uses the `turso` gem and does **not** require the `sqlite3` Ruby gem at runtime.
14
18
 
15
19
  ## Installation
16
20
 
17
21
  Add to your Gemfile:
18
22
 
19
23
  ```ruby
20
- gem "activerecord-turso", path: "~/Projects/activerecord-turso"
24
+ gem "activerecord-turso"
21
25
  ```
22
26
 
23
27
  Then configure `database.yml`:
@@ -47,7 +51,23 @@ end
47
51
  Post.create!(title: "Hello", body: "World", published: true)
48
52
  ```
49
53
 
50
- ### MVCC / BEGIN CONCURRENT
54
+ ## Recommended production configuration
55
+
56
+ ```yaml
57
+ production:
58
+ adapter: turso
59
+ database: db/production.sqlite3
60
+ journal_mode: wal
61
+ pool: 5
62
+ timeout: 5000
63
+ busy_timeout: 5000
64
+ query_timeout: 30000
65
+ experimental_features: "index_method"
66
+ ```
67
+
68
+ ### MVCC / BEGIN CONCURRENT (experimental)
69
+
70
+ ⚠️ **Experimental.** Do not use in production unless you understand the caveats below. Normal ActiveRecord model persistence (`create!`, `save!`, `update!`, `touch`) is **not supported** inside `transaction(concurrent: true)`.
51
71
 
52
72
  Turso supports `BEGIN CONCURRENT` for optimistic, multi-writer transactions. To opt in, pass `concurrent: true` to `transaction`:
53
73
 
@@ -94,13 +114,24 @@ class AddFtsToPosts < ActiveRecord::Migration[8.1]
94
114
  end
95
115
  ```
96
116
 
97
- Query with Tantivy functions:
117
+ Query with the adapter helpers:
98
118
 
99
- ```sql
100
- SELECT * FROM posts WHERE fts_match(title, body, 'database');
119
+ ```ruby
120
+ match = ActiveRecord::Base.connection.fts_match(:posts, [:title, :body], "database")
121
+ Post.where(match)
122
+
123
+ score = ActiveRecord::Base.connection.fts_score(:posts, [:title, :body], "database")
124
+ Post.select(:id, :title, score.as("rank")).where(match).order("rank ASC")
101
125
  ```
102
126
 
103
- Note: `fts5` virtual tables are not supported. Use `USING fts` indexes instead.
127
+ Note: `fts5` virtual tables are not supported. Use `USING fts` indexes instead. This requires the `index_method` experimental feature:
128
+
129
+ ```yaml
130
+ production:
131
+ adapter: turso
132
+ database: db/production.sqlite3
133
+ experimental_features: "index_method"
134
+ ```
104
135
 
105
136
  ### Concurrent transactions
106
137
 
@@ -112,19 +143,22 @@ development:
112
143
  database: db/dev.sqlite3
113
144
  journal_mode: mvcc
114
145
  busy_timeout: 5000
146
+
147
+ experimental_features: "index_method"
115
148
  ```
116
149
 
117
150
  Use `concurrent: true` inside the same pinned connection:
118
151
 
119
152
  ```ruby
120
153
  ActiveRecord::Base.connection.transaction(concurrent: true) do
121
- # read-modify-write
122
154
  end
123
155
  ```
124
156
 
125
157
  Caveats:
126
158
  - The connection must stay checked out for the whole transaction; avoid work that yields the ActiveRecord connection back to the pool.
127
159
  - `busy_timeout` is used for lock waits, not MVCC snapshot conflicts. Snapshot conflicts retry with bounded backoff.
160
+ - Normal ActiveRecord model persistence (`create!`, `save!`, `update!`, `touch`) is **not supported** inside a concurrent transaction because ActiveRecord opens its own internal transaction for each model change. Use raw SQL (`execute`, `exec_query`) or bulk operations (`insert_all`, `update_all`, `update_columns`) instead.
161
+ - FTS custom index modules are not supported in MVCC mode.
128
162
 
129
163
  ## Limitations and Risks
130
164
 
@@ -138,31 +172,35 @@ The adapter builds column type maps from `column_decltype` metadata. This works
138
172
 
139
173
  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
174
 
141
- ### 3. MVCC requires opt-in and has pooling caveats
175
+ ### 3. MVCC requires opt-in and has ActiveRecord compatibility caveats
142
176
 
143
177
  `BEGIN CONCURRENT` is powerful but breaks ActiveRecord's default assumptions:
144
178
 
145
179
  - 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
180
  - 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
181
  - Do not combine concurrent transactions with pessimistic locking (`lock!`, `with_lock`, `lock_version`).
182
+ - Normal model persistence (`create!`, `save!`, `update!`, `touch`) is unsupported inside a concurrent transaction because ActiveRecord opens an internal transaction for each model change.
148
183
 
149
- Only use `transaction(concurrent: true)` after testing it under your app's concurrency patterns.
184
+ Only use `transaction(concurrent: true)` after testing it under your app's concurrency patterns, and prefer raw SQL or bulk operations inside the block.
150
185
 
151
- ### 4. Prepared statement cache is disabled
186
+ ### 4. Prepared statement cache is enabled with a bounded pool
152
187
 
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.
188
+ The adapter uses a bounded statement pool with a default limit inherited from Rails (typically `1000`). Statements are evicted with an LRU policy and finalized against the underlying Turso connection. Set `statement_limit` in `database.yml` to tune the pool size.
154
189
 
155
190
  ### 5. ActiveRecord 8.0 support is CI-tested, not locally tested
156
191
 
157
192
  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
193
 
159
- ### 6. Some SQLite-specific features are unsupported or conservatively flagged
194
+ ### 6. INSERT RETURNING is disabled (not supported by Turso)
195
+
196
+ The underlying Turso SQLite build does not support `INSERT ... RETURNING` syntax. The adapter reports `supports_insert_returning?` as `false`, so ActiveRecord falls back to `last_insert_rowid()` for retrieving inserted IDs.
197
+
198
+ ### 7. Some SQLite-specific features are unsupported or conservatively flagged
160
199
 
161
200
  - 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
201
  - `insert_on_conflict` is enabled only when the reported SQLite version is `>= 3.24.0`.
164
202
 
165
- ### 7. `execute_batch` in the underlying bindings is a Ruby-side fallback
203
+ ### 8. `execute_batch` in the underlying bindings is a Ruby-side fallback
166
204
 
167
205
  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
206
 
@@ -175,7 +213,19 @@ bundle install
175
213
  bundle exec rake test
176
214
  ```
177
215
 
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/`.
216
+ Run with MVCC mode:
217
+
218
+ ```bash
219
+ TURSO_TEST_JOURNAL_MODE=mvcc bundle exec rake test
220
+ ```
221
+
222
+ Run with generated-columns experimental feature:
223
+
224
+ ```bash
225
+ TURSO_TEST_EXPERIMENTAL_FEATURES=generated_columns bundle exec rake test
226
+ ```
227
+
228
+ The CI workflow in `.github/workflows/ci.yml` runs all three configurations automatically.
179
229
 
180
230
  ## License
181
231
 
@@ -9,14 +9,16 @@ module ActiveRecord
9
9
  end
10
10
 
11
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
12
+ def new_client(config)
13
+ db_config = config.symbolize_keys.merge(:timeout => config[:timeout] || 5000)
14
+ db_config[:experimental_features] = Array(db_config[:experimental_features])
15
+ ::Turso::AR::Connection.new(db_config)
16
+ end
16
17
  end
17
18
 
18
19
  def active?
19
- @raw_connection && !@raw_connection.closed?
20
+ return false unless @raw_connection
21
+ !@raw_connection.closed?
20
22
  rescue ::Turso::Error
21
23
  false
22
24
  end
@@ -52,19 +54,18 @@ module ActiveRecord
52
54
  execute("PRAGMA journal_mode = #{mode}")
53
55
  if @config[:journal_mode].to_s.downcase == "mvcc"
54
56
  @mvcc_enabled = true
57
+ ActiveRecord::Base.logger&.warn(
58
+ "Turso journal_mode: mvcc is experimental. Concurrent transactions require careful connection handling."
59
+ )
55
60
  end
56
61
  end
57
62
 
58
63
  execute("PRAGMA foreign_keys = ON")
59
64
 
60
- if @config[:timeout] || @config[:busy_timeout]
61
- timeout = @config[:busy_timeout] || @config[:timeout]
62
- @raw_connection.busy_timeout = timeout
63
- end
65
+ timeout = @config[:busy_timeout] || @config[:timeout] || 5000
66
+ @raw_connection.busy_timeout = timeout
67
+
64
68
 
65
- if @config[:query_timeout]
66
- @raw_connection.query_timeout = @config[:query_timeout]
67
- end
68
69
  end
69
70
  end
70
71
  end
@@ -6,42 +6,7 @@ module ActiveRecord
6
6
  module DatabaseStatements
7
7
  def execute(sql, name = nil)
8
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
9
+ raw_execute(sql, name)&.to_a
45
10
  end
46
11
 
47
12
  def last_inserted_id(sql)
@@ -60,22 +25,13 @@ module ActiveRecord
60
25
  binds
61
26
  end
62
27
 
63
- def select_rows(arel, name = nil)
28
+ def select_rows(arel, name = nil, binds = [], _options = {})
64
29
  arel = arel_from_relation(arel)
65
- sql, binds = to_sql_and_binds(arel)
30
+ sql, binds = to_sql_and_binds(arel, binds)
66
31
  type_casted_binds = type_casted_binds(binds)
67
32
 
68
33
  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
34
+ exec_query(sql, name, binds, prepare: false).rows
79
35
  end
80
36
  end
81
37
 
@@ -85,22 +41,36 @@ module ActiveRecord
85
41
  return ActiveRecord::Result.empty(affected_rows: 0)
86
42
  end
87
43
 
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)
44
+ stmt = if prepare
45
+ @statements[sql] ||= raw_connection.prepare(sql)
95
46
  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)
47
+ raw_connection.prepare(sql)
48
+ end
49
+
50
+ stmt.reset if prepare
51
+ stmt.bind(*type_casted_binds) unless type_casted_binds.empty?
52
+
53
+ begin
54
+ if write_query?(sql) && !sql.match?(/\bRETURNING\b/i)
55
+ result = stmt.run
56
+ affected_rows = result[:changes]
57
+ verified!
58
+ notification_payload[:affected_rows] = affected_rows
59
+ notification_payload[:row_count] = 0
60
+ ActiveRecord::Result.empty(affected_rows: affected_rows)
61
+ else
62
+ columns_info = stmt.columns
63
+ columns = columns_info.map { |c| c[:name] }
64
+ rows = stmt.all.map(&:to_a)
65
+ affected_rows = raw_connection.changes
66
+ verified!
67
+ notification_payload[:affected_rows] = affected_rows
68
+ notification_payload[:row_count] = rows.length
69
+ type_map = build_type_map(stmt)
70
+ ActiveRecord::Result.new(columns, rows, type_map, affected_rows: affected_rows)
71
+ end
72
+ ensure
73
+ stmt.close unless prepare
104
74
  end
105
75
  end
106
76
 
@@ -109,11 +79,11 @@ module ActiveRecord
109
79
  old_defer_foreign_keys = query_value("PRAGMA defer_foreign_keys")
110
80
 
111
81
  begin
112
- execute("PRAGMA defer_foreign_keys = ON")
82
+ execute("PRAGMA defer_foreign_keys = ON") unless old_defer_foreign_keys.nil?
113
83
  execute("PRAGMA foreign_keys = OFF")
114
84
  yield
115
85
  ensure
116
- if old_defer_foreign_keys
86
+ unless old_defer_foreign_keys.nil?
117
87
  execute("PRAGMA defer_foreign_keys = #{old_defer_foreign_keys}")
118
88
  end
119
89
  execute("PRAGMA foreign_keys = #{old_foreign_keys}")
@@ -122,21 +92,10 @@ module ActiveRecord
122
92
 
123
93
  private
124
94
 
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
95
  def build_type_map(stmt)
136
96
  type_map = {}
137
- count = stmt.column_count
138
- count.times do |i|
139
- decltype = stmt.respond_to?(:column_decltype) ? stmt.column_decltype(i) : nil
97
+ stmt.columns.each_with_index do |col, i|
98
+ decltype = col[:type]
140
99
  next unless decltype
141
100
  type_map[i] = decltype_to_type(decltype)
142
101
  end
@@ -7,15 +7,23 @@ module ActiveRecord
7
7
  def translate_exception(exception, message:, sql:, binds:)
8
8
  cause = exception.cause
9
9
  case cause
10
- when ::Turso::ConstraintError
10
+ when ::Turso::ConstraintException
11
11
  translate_constraint_error(message, sql, binds)
12
- when ::Turso::NotADatabaseError
12
+ when ::Turso::NotADatabaseException
13
13
  ActiveRecord::NoDatabaseError.new(message, sql: sql, binds: binds)
14
- when ::Turso::BusySnapshotError
14
+ when ::Turso::BusySnapshotException
15
15
  ActiveRecord::SerializationFailure.new(message, sql: sql, binds: binds)
16
- when ::Turso::BusyError
16
+ when ::Turso::BusyException
17
17
  ActiveRecord::Deadlocked.new(message, sql: sql, binds: binds)
18
- when ::Turso::ReadonlyError
18
+ when ::Turso::ReadonlyException
19
+ ActiveRecord::ReadOnlyRecord.new(message, sql: sql, binds: binds)
20
+ when ::Turso::IoException, ::Turso::CorruptException
21
+ ActiveRecord::StatementInvalid.new(message, sql: sql, binds: binds)
22
+ when ::Turso::DatabaseFullException
23
+ ActiveRecord::StatementInvalid.new(message, sql: sql, binds: binds)
24
+ when ::Turso::InterruptException
25
+ ActiveRecord::QueryCanceled.new(message, sql: sql, binds: binds)
26
+ when ::Turso::MisuseException
19
27
  ActiveRecord::StatementInvalid.new(message, sql: sql, binds: binds)
20
28
  else
21
29
  super
@@ -26,7 +34,7 @@ module ActiveRecord
26
34
 
27
35
  def translate_constraint_error(message, sql, binds)
28
36
  case message
29
- when /foreign key constraint/i
37
+ when /foreign key constraint|foreign key mismatch/i
30
38
  ActiveRecord::InvalidForeignKey.new(message, sql: sql, binds: binds)
31
39
  when /unique constraint|primary key/i
32
40
  ActiveRecord::RecordNotUnique.new(message, sql: sql, binds: binds)
@@ -11,6 +11,7 @@ module ActiveRecord
11
11
  AND name NOT LIKE 'sqlite_%'
12
12
  AND name NOT LIKE 'fts_dir_%'
13
13
  AND name NOT LIKE 'sqlite_fts_%'
14
+ AND name NOT LIKE '__turso_internal_%'
14
15
  SQL
15
16
  end
16
17
 
@@ -75,6 +76,16 @@ module ActiveRecord
75
76
  SQL
76
77
  end
77
78
 
79
+ def fts_match(_table_name, columns, query)
80
+ cols = Array(columns).map { |c| quote_table_name(c) }.join(", ")
81
+ Arel.sql("fts_match(#{cols}, #{quote(query)})")
82
+ end
83
+
84
+ def fts_score(_table_name, columns, query)
85
+ cols = Array(columns).map { |c| quote_table_name(c) }.join(", ")
86
+ Arel.sql("fts_score(#{cols}, #{quote(query)})")
87
+ end
88
+
78
89
  private
79
90
 
80
91
  def options_to_fts_options(options)
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveRecord
4
+ module ConnectionAdapters
5
+ class TursoAdapter < SQLite3Adapter
6
+ class StatementPool < ConnectionAdapters::StatementPool
7
+ alias reset clear
8
+
9
+ private
10
+
11
+ def dealloc(stmt)
12
+ stmt.close
13
+ rescue ::Turso::Error
14
+ end
15
+ end
16
+ end
17
+ end
18
+ end
@@ -34,6 +34,10 @@ module ActiveRecord
34
34
 
35
35
  def transaction(requires_new: nil, isolation: nil, joinable: true, **options, &block)
36
36
  if options.delete(:concurrent)
37
+ ActiveRecord::Base.logger&.warn(
38
+ "transaction(concurrent: true) is experimental and may break ActiveRecord model persistence. " \
39
+ "See adapter documentation."
40
+ )
37
41
  transaction_with_mvcc(options, &block)
38
42
  else
39
43
  super
@@ -43,35 +47,61 @@ module ActiveRecord
43
47
  private
44
48
 
45
49
  def transaction_with_mvcc(options, &block)
50
+ connect if @raw_connection.nil?
51
+
46
52
  unless @mvcc_enabled
47
53
  raise ActiveRecord::AdapterError,
48
54
  "transaction(concurrent: true) requires journal_mode: 'mvcc' in database.yml"
49
55
  end
50
56
 
57
+ if options[:requires_new] == false
58
+ raise ActiveRecord::AdapterError,
59
+ "transaction(concurrent: true) is incompatible with nested transactions"
60
+ end
61
+
51
62
  max_retries = @config.fetch(:concurrent_retry_limit, 50)
52
63
  base_delay_ms = @config.fetch(:concurrent_retry_base_ms, 2)
53
64
  retries = 0
65
+ pinned_connection_id = @raw_connection.object_id
54
66
 
55
67
  loop do
56
68
  begin
57
- raw_execute("BEGIN CONCURRENT", "TRANSACTION")
58
- yield
59
- raw_execute("COMMIT", "TRANSACTION")
60
- return
69
+ @raw_connection.execute("BEGIN CONCURRENT")
70
+ result = yield
71
+ @raw_connection.execute("COMMIT")
72
+ return result
61
73
  rescue ActiveRecord::StatementInvalid => e
62
- raw_execute("ROLLBACK", "TRANSACTION") rescue nil
74
+ rollback_if_active
63
75
  raise unless concurrent_conflict?(e) && retries < max_retries
64
76
 
65
77
  retries += 1
66
- sleep(base_delay_ms * retries / 1000.0)
78
+ verified!
79
+ backoff(base_delay_ms, retries)
80
+
81
+ unless @raw_connection.object_id == pinned_connection_id
82
+ raise ActiveRecord::AdapterError,
83
+ "MVCC retry detected a different database connection. " \
84
+ "transaction(concurrent: true) must run on a pinned connection."
85
+ end
67
86
  end
68
87
  end
69
88
  end
70
89
 
71
90
  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)
91
+ cause = exception.cause
92
+ conflict_classes = [::Turso::BusySnapshotException, ::Turso::BusyException]
93
+
94
+ conflict_classes.any? { |klass| cause.is_a?(klass) } ||
95
+ /snapshot conflict|busy snapshot|database is locked|cannot start a transaction within a transaction/i.match?(exception.message)
96
+ end
97
+
98
+ def rollback_if_active
99
+ return unless @raw_connection && !@raw_connection.closed?
100
+ @raw_connection.execute("ROLLBACK") rescue nil
101
+ end
102
+
103
+ def backoff(base_delay_ms, retries)
104
+ sleep(base_delay_ms * retries / 1000.0)
75
105
  end
76
106
 
77
107
  def savepoint_name(name)
@@ -2,7 +2,9 @@
2
2
 
3
3
  require "active_record/connection_adapters/sqlite3_adapter"
4
4
 
5
- Dir[File.expand_path("turso_adapter/*.rb", __dir__)].each { |f| require f }
5
+ require_relative "turso_adapter/statement_pool"
6
+
7
+ Dir[File.expand_path("turso_adapter/*.rb", __dir__)].sort.each { |f| require f }
6
8
 
7
9
  module ActiveRecord
8
10
  module ConnectionAdapters
@@ -23,6 +25,10 @@ module ActiveRecord
23
25
  false
24
26
  end
25
27
 
28
+ def sqlite_version
29
+ @sqlite_version ||= Gem::Version.new(query_value("SELECT sqlite_version()"))
30
+ end
31
+
26
32
  def supports_transaction_isolation?
27
33
  false
28
34
  end
@@ -35,14 +41,20 @@ module ActiveRecord
35
41
  true
36
42
  end
37
43
 
38
- def explain(arel, binds = [])
44
+ def quote_string(s)
45
+ s.gsub("'", "''")
46
+ end
47
+
48
+ def explain(arel, binds = [], _options = [])
39
49
  sql = "EXPLAIN QUERY PLAN " + to_sql(arel, binds)
40
50
  result = exec_query(sql, "EXPLAIN", binds)
41
- SQLite3::ExplainPrettyPrinter.new.pp(result)
51
+ result.rows.map do |row|
52
+ row.join(" | ")
53
+ end.join("\n")
42
54
  end
43
55
 
44
- def default_prepared_statements
45
- false
56
+ def build_statement_pool
57
+ StatementPool.new(self.class.type_cast_config_to_integer(@config[:statement_limit]))
46
58
  end
47
59
 
48
60
  def database_file_exists?
@@ -69,6 +81,7 @@ module ActiveRecord
69
81
  def initialize_type_map(m)
70
82
  super
71
83
  m.register_type(%r(boolean)i, Type::Boolean.new)
84
+ m.register_type(%r(json)i, Type::Json.new)
72
85
  end
73
86
  end
74
87
  end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveRecordTurso
4
+ VERSION = "0.2.1"
5
+ end
@@ -9,15 +9,21 @@ module Turso
9
9
 
10
10
  def_delegators :@db, :close, :closed?, :changes, :total_changes
11
11
 
12
+ DEFAULT_BUSY_TIMEOUT_MS = 5000
13
+ DEFAULT_QUERY_TIMEOUT_MS = 30_000
14
+
12
15
  def initialize(config)
13
16
  @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
+ db_opts = {
18
+ busy_timeout: config[:busy_timeout] || config[:timeout] || DEFAULT_BUSY_TIMEOUT_MS,
19
+ query_timeout: config[:query_timeout] || DEFAULT_QUERY_TIMEOUT_MS
20
+ }
21
+ db_opts[:experimental_features] = config[:experimental_features] if config[:experimental_features]
22
+ @db = ::Turso::Database.new(config[:database].to_s, **db_opts)
17
23
  end
18
24
 
19
25
  def last_insert_rowid
20
- query("SELECT last_insert_rowid()").first&.values&.first.to_i
26
+ query("SELECT last_insert_rowid()").first&.to_a&.first.to_i
21
27
  end
22
28
 
23
29
  def raw_connection
@@ -33,39 +39,116 @@ module Turso
33
39
  end
34
40
 
35
41
  def execute(sql, binds = [])
36
- @db.execute(sql, normalize_binds(binds))
42
+ @db.execute(sql, *normalize_binds(binds))
37
43
  nil
38
44
  end
39
45
 
40
46
  def query(sql, params = [])
41
- @db.query(sql, normalize_binds(params))
47
+ @db.query(sql, *normalize_binds(params))
42
48
  end
43
49
 
44
50
  def execute_batch(sql)
45
- sql.split(";").each do |stmt|
46
- s = stmt.strip
47
- @db.execute(s) unless s.empty?
51
+ split_batch(sql).each do |stmt|
52
+ @db.execute(stmt)
48
53
  end
49
54
  end
50
55
 
51
56
  def prepare(sql)
52
- @db.instance_variable_get(:@database).connection.prepare(sql)
57
+ @db.prepare(sql)
53
58
  end
54
59
 
55
60
  def busy_timeout=(ms)
56
61
  @db.busy_timeout = ms.to_i
57
62
  end
58
63
 
59
- def query_timeout=(ms)
60
- @db.query_timeout = ms.to_i
61
- end
62
-
63
64
  def interrupt
64
65
  @db.interrupt
65
66
  end
66
67
 
67
68
  private
68
69
 
70
+ def split_batch(sql)
71
+ statements = []
72
+ current = +""
73
+ in_string = false
74
+ in_line_comment = false
75
+ in_block_comment = false
76
+ i = 0
77
+ while i < sql.length
78
+ char = sql[i]
79
+ next_char = sql[i + 1]
80
+
81
+ if in_line_comment
82
+ if char == "\n"
83
+ in_line_comment = false
84
+ end
85
+ i += 1
86
+ next
87
+ end
88
+
89
+ if in_block_comment
90
+ if char == "*" && next_char == "/"
91
+ in_block_comment = false
92
+ i += 2
93
+ else
94
+ i += 1
95
+ end
96
+ next
97
+ end
98
+
99
+ if in_string
100
+ if char == "'" && next_char == "'"
101
+ current << char << next_char
102
+ i += 2
103
+ next
104
+ elsif char == "'"
105
+ in_string = false
106
+ current << char
107
+ i += 1
108
+ next
109
+ end
110
+ current << char
111
+ i += 1
112
+ next
113
+ end
114
+
115
+ case char
116
+ when "'"
117
+ in_string = true
118
+ current << char
119
+ when "-"
120
+ if next_char == "-"
121
+ in_line_comment = true
122
+ i += 2
123
+ next
124
+ end
125
+ current << char
126
+ when "#"
127
+ in_line_comment = true
128
+ i += 1
129
+ next
130
+ when "/"
131
+ if next_char == "*"
132
+ in_block_comment = true
133
+ i += 2
134
+ next
135
+ end
136
+ current << char
137
+ when ";"
138
+ stmt = current.strip
139
+ statements << stmt unless stmt.empty?
140
+ current = +""
141
+ else
142
+ current << char
143
+ end
144
+ i += 1
145
+ end
146
+
147
+ stmt = current.strip
148
+ statements << stmt unless stmt.empty?
149
+ statements
150
+ end
151
+
69
152
  def normalize_binds(binds)
70
153
  binds.map do |value|
71
154
  case value
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: activerecord-turso
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0
4
+ version: 0.2.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ben D'Angelo
@@ -43,6 +43,20 @@ dependencies:
43
43
  - - "~>"
44
44
  - !ruby/object:Gem::Version
45
45
  version: '0.1'
46
+ - !ruby/object:Gem::Dependency
47
+ name: irb
48
+ requirement: !ruby/object:Gem::Requirement
49
+ requirements:
50
+ - - ">="
51
+ - !ruby/object:Gem::Version
52
+ version: '0'
53
+ type: :development
54
+ prerelease: false
55
+ version_requirements: !ruby/object:Gem::Requirement
56
+ requirements:
57
+ - - ">="
58
+ - !ruby/object:Gem::Version
59
+ version: '0'
46
60
  executables: []
47
61
  extensions: []
48
62
  extra_rdoc_files: []
@@ -53,8 +67,10 @@ files:
53
67
  - lib/active_record/connection_adapters/turso_adapter/database_statements.rb
54
68
  - lib/active_record/connection_adapters/turso_adapter/error_translation.rb
55
69
  - lib/active_record/connection_adapters/turso_adapter/schema_statements.rb
70
+ - lib/active_record/connection_adapters/turso_adapter/statement_pool.rb
56
71
  - lib/active_record/connection_adapters/turso_adapter/transaction_management.rb
57
72
  - lib/activerecord-turso.rb
73
+ - lib/activerecord-turso/version.rb
58
74
  - lib/turso/ar/connection.rb
59
75
  licenses:
60
76
  - MIT
@@ -66,7 +82,7 @@ required_ruby_version: !ruby/object:Gem::Requirement
66
82
  requirements:
67
83
  - - ">="
68
84
  - !ruby/object:Gem::Version
69
- version: 3.0.0
85
+ version: 3.2.0
70
86
  required_rubygems_version: !ruby/object:Gem::Requirement
71
87
  requirements:
72
88
  - - ">="