libsql-sequel 0.3.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: 37c836e635f6104c6972774e8cb3dba6397cfbf5b6c958c19f15eaaca4fc1367
4
+ data.tar.gz: adce3fe4850eab4d30b4b303f404520f969390060dded1b89def96675d2f7249
5
+ SHA512:
6
+ metadata.gz: d02c3ee895aa85b2e4bae54743d0fc8b8b0028a8170b9b87212caeab594798b080c52dba582b2fd882a19e23a3aea5989ba5f0b53dc33ba68f7c924cb544b606
7
+ data.tar.gz: 8c777ebc4996b0d96a5e4fd437337347568820f5a8a3adc2569ce69ad603f6b133acb386e6f8c2e59a641789b62f7c8aef780e66234908ba05e28e394276df37
data/LICENSE ADDED
@@ -0,0 +1,28 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Francesco Canessa
4
+
5
+ Adapter conventions and portions adapted from Sequel:
6
+ Copyright (c) 2007-2008 Sharon Rosner
7
+ Copyright (c) 2008-2026 Jeremy Evans and contributors
8
+
9
+ Native API integration informed by libsql-ruby:
10
+ Copyright (c) 2024 Turso (libSQL)
11
+
12
+ Permission is hereby granted, free of charge, to any person obtaining a copy
13
+ of this software and associated documentation files (the "Software"), to
14
+ deal in the Software without restriction, including without limitation the
15
+ rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
16
+ sell copies of the Software, and to permit persons to whom the Software is
17
+ furnished to do so, subject to the following conditions:
18
+
19
+ The above copyright notice and this permission notice shall be included in
20
+ all copies or substantial portions of the Software.
21
+
22
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
23
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
24
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL
25
+ THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
26
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
27
+ FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
28
+ IN THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,144 @@
1
+ # libsql-sequel
2
+
3
+ A modular Ruby adapter for using [Sequel](https://github.com/jeremyevans/sequel) with local libSQL databases, remote Turso databases, and embedded replicas through [libsql-ruby](https://github.com/tursodatabase/libsql-ruby).
4
+
5
+ ```ruby
6
+ require 'libsql-sequel'
7
+
8
+ DB = Sequel.connect(
9
+ adapter: :libsql,
10
+ database: ENV.fetch('TURSO_DATABASE_URL'),
11
+ auth_token: ENV.fetch('TURSO_AUTH_TOKEN')
12
+ )
13
+
14
+ DB.create_table?(:items) do
15
+ primary_key :id
16
+ String :name, null: false
17
+ Integer :quantity, default: 0
18
+ end
19
+
20
+ id = DB[:items].insert(name: 'Hello, Turso', quantity: 3)
21
+ DB[:items].where(id: id).first
22
+ # => {id: 1, name: "Hello, Turso", quantity: 3}
23
+
24
+ DB.disconnect
25
+ ```
26
+
27
+ ## Installation
28
+
29
+ Install from [RubyGems](https://rubygems.org/gems/libsql-sequel):
30
+
31
+ ```ruby
32
+ # Gemfile
33
+ gem 'libsql-sequel', '~> 0.3.0'
34
+ ```
35
+
36
+ Then run `bundle install`. To install without Bundler:
37
+
38
+ ```sh
39
+ gem install libsql-sequel -v 0.3.0
40
+ ```
41
+
42
+ Ruby 3.3+ and Sequel 5.80–5.x are required. The native dependency is **pinned to `turso_libsql` 0.2.0**: 0.2.1 contains native debug output of connection credentials and SQL. Do not widen that dependency without reviewing the replacement library. The adapter uses the bundled native library with scoped corrections for the driver's Ruby wrapper; it does not patch global `Libsql` classes or require `sqlite3` at runtime.
43
+
44
+ The driver bundles Apple Silicon macOS and glibc Linux x86_64/aarch64 libraries. Intel macOS, Windows, musl Linux, and JRuby are not supported by this pinned distribution. Current verification was on Apple Silicon macOS / Ruby 4.0.5. See [compatibility and verification](docs/compatibility.md).
45
+
46
+ ## Connection modes
47
+
48
+ Turso Cloud's `turso://` URLs are also accepted, both as `database:`/`url:`
49
+ options and directly through `Sequel.connect('turso://...', auth_token: ...)`.
50
+ They are normalized to `https://` for the pinned native driver before opening
51
+ a connection. The same credential/query/fragment validation applies. A
52
+ `turso://` database URL is never treated as a local file path.
53
+
54
+ ```ruby
55
+ # Memory: a single pooled connection.
56
+ db = Sequel.connect(adapter: :libsql)
57
+
58
+ # Local file: regular Sequel pooling (default four connections).
59
+ db = Sequel.connect(adapter: :libsql, database: 'app.db', max_connections: 4)
60
+
61
+ # Native libsql:// URLs work directly. https:// and http:// use the hash form.
62
+ db = Sequel.connect(ENV.fetch('TURSO_DATABASE_URL'),
63
+ auth_token: ENV.fetch('TURSO_AUTH_TOKEN'))
64
+
65
+ # Embedded replica: local file plus remote primary URL.
66
+ db = Sequel.connect(
67
+ adapter: :libsql,
68
+ database: ENV.fetch('TURSO_DATABASE_URL'),
69
+ path: 'replica.db',
70
+ auth_token: ENV.fetch('TURSO_AUTH_TOKEN'),
71
+ sync_interval: 1_000,
72
+ read_your_writes: true
73
+ )
74
+ db.sync # => {frame_no: ..., frames_synced: ...}
75
+ ```
76
+
77
+ `url:` can also specify the remote URL; `database:` or `path:` then names the local replica. Memory databases and replicas require `max_connections: 1`. This avoids separate memory databases and multiple replication workers targeting one file. Replica sync cannot run inside a transaction. Embedded replication is implemented but has not been tested against a live Turso primary.
78
+
79
+ Keep tokens in the `auth_token:` option rather than the URL. URLs with credentials, query parameters, or fragments are rejected.
80
+
81
+ For Puma and other forking servers, initialize native connections inside each worker after forking. If the parent has opened connections, call `DB.disconnect` in `before_fork` to release them in their owning process. Calling `DB.disconnect` in a child quietly discards inherited handles without invoking native destructors; inherited queries still raise `Sequel::Error`. Disconnecting does not reset an already initialized Rust runtime, so child cleanup alone does not make preloading the native runtime safe.
82
+
83
+ Local connections enable foreign keys, case-sensitive `LIKE`, and a 5,000 ms SQLite busy timeout by default. `timeout:`, `case_sensitive_like:`, `auto_vacuum:`, `synchronous:`, and `temp_store:` are local-only options. The native remote parser rejects those PRAGMAs; remote connections keep the server's `LIKE` behavior. `timeout:` is a SQLite lock timeout, not an HTTP timeout. `readonly:`, custom regexp functions, `disable_dqs:`, offline-write mode, and custom TLS options are unsupported and rejected.
84
+
85
+ ## Sequel features
86
+
87
+ SQL generation, schema introspection, migrations, models, associations, upserts, and `RETURNING` reuse Sequel's shared SQLite implementation. The adapter scheme is `:libsql`; `database_type` is `:sqlite`.
88
+
89
+ ```ruby
90
+ DB.transaction do
91
+ DB[:items].insert(name: 'committed')
92
+ DB.transaction(savepoint: true) do
93
+ DB[:items].insert(name: 'rolled back')
94
+ raise Sequel::Rollback
95
+ end
96
+ end
97
+
98
+ find_item = DB[:items].where(id: :$id).prepare(:first, :find_item)
99
+ find_item.call(id: 1)
100
+ DB.call(:find_item, id: 1)
101
+
102
+ DB[:items].where(id: :$id).call(:update, {id: 1, quantity: 5}, quantity: :$quantity)
103
+ DB[:items].returning(:id).insert(name: 'returned')
104
+ ```
105
+
106
+ Bound values use native parameters. Named Sequel prepared queries prepare a native statement for each execution and always release it; there is no persistent statement cache. `DB.run` supports SQL batches. Commit and rollback run as SQL so errors propagate to Sequel instead of using the driver's abort-prone native transaction helpers. There are no automatic retries of failed writes.
107
+
108
+ ## Values and result types
109
+
110
+ Integers preserve signed 64-bit values; strings are converted to UTF-8; blobs preserve all bytes. Use `Sequel.blob(bytes)` for binary data. Invalid text encodings and strings that cannot be converted to UTF-8 raise `Sequel::Error`. NUL-containing text is rejected on input because the native C API truncates it. The same upstream limitation affects existing TEXT values containing NUL when reading them; store those values as blobs. Out-of-range bound integers, invalid text, and non-finite bound floats are rejected.
111
+
112
+ The native API does not expose declared result-column types. Simple single-table datasets infer date, datetime, time, decimal, and boolean conversions from Sequel's schema cache, including direct column aliases and prepared queries. Raw SQL, joins, CTEs, compounds, and arbitrary expressions retain native SQLite values. Use explicit hints when necessary:
113
+
114
+ ```ruby
115
+ DB.fetch('SELECT active, created_at FROM items').with_libsql_types(
116
+ active: :boolean,
117
+ created_at: :datetime
118
+ ).all
119
+
120
+ # Supported hints: :boolean, :date, :datetime, :timestamp, :time, :decimal,
121
+ # :integer, :float, :blob, or a callable. NULL always remains nil.
122
+ DB.fetch('SELECT upper(name) AS label FROM items').with_libsql_types(
123
+ label: ->(value) { value.freeze }
124
+ ).all
125
+ ```
126
+
127
+ Use `Sequel::Model` normally for single-table model datasets. Joined/eager-graph results with boolean or temporal columns may need explicit result type hints. Expression aliases never acquire a type merely because they match a table column name. SQLite's own numeric affinity and decimal precision limitations still apply.
128
+
129
+ ## Development and tests
130
+
131
+ ```sh
132
+ bundle install
133
+ bundle exec rake check # syntax, visibility, and warning-enabled tests
134
+ bundle exec rake lint # syntax and method visibility only
135
+ bundle exec rake test # real native local/file databases
136
+ bundle exec rake upstream # upstream SQLite + integration specs
137
+ bundle exec rake remote_local # real native HTTP client, local Hrana server
138
+ ```
139
+
140
+ `upstream` and `remote_local` need the ignored reference checkouts; setup instructions are in [development.md](docs/development.md). `rake` defaults to `check`. Checks follow Sequel's own syntax/warnings/visibility approach; Sequel does not configure RuboCop or a complexity analyzer. See [the spec and code-quality review](docs/testing-review.md).
141
+
142
+ For an explicit test against a **disposable** hosted database, set `LIBSQL_TEST_URL` and `LIBSQL_TEST_AUTH_TOKEN` in your environment, then run `bundle exec rake remote`. It creates randomly named test tables and drops those tables afterward. Hosted authentication and HTTPS remote operations have been validated with a `turso://` URL; embedded replication remains unverified. See [compatibility and verification](docs/compatibility.md) for the tested scope.
143
+
144
+ See [architecture](docs/architecture.md), [compatibility](docs/compatibility.md), and [the local example](examples/local.rb).
@@ -0,0 +1,43 @@
1
+ # Architecture
2
+
3
+ The adapter follows Sequel's small Database/Dataset adapter pattern and delegates SQL dialect behavior to `Sequel::SQLite::DatabaseMethods` and `DatasetMethods`. It does not subclass the sqlite3-backed adapter, avoiding a second native database driver in production.
4
+
5
+ | Component | Responsibility |
6
+ | --- | --- |
7
+ | `lib/sequel/adapters/libsql.rb` | Register `:libsql`, create pooled connections, execute/log SQL, map errors, bind Ruby values, expose replica sync |
8
+ | `lib/sequel/libsql/configuration.rb` | Normalize memory/file/remote/replica options, reject unsupported or conflicting configuration |
9
+ | `lib/sequel/libsql/connection.rb` | Own database/connection handles; scope statements, results, and rows with `ensure` |
10
+ | `lib/sequel/libsql/native.rb` | Use the driver's native library/ABI; correct error, string, blob, and signed-integer handling |
11
+ | `lib/sequel/libsql/dataset.rb` | Map native rows to Sequel hashes, bind query parameters, and quote text |
12
+ | `lib/sequel/libsql/result_types.rb` | Resolve column origins and explicit type hints; reject ambiguous inference |
13
+ | `lib/sequel/libsql/type_converter.rb` | Convert storage values to semantic SQLite types |
14
+
15
+ ## Ownership and concurrency
16
+
17
+ Each Sequel pool slot owns one native Database and Connection. A query owns its Statement, Result, and one Row at a time. Values are copied into Ruby before the row is freed. Native strings/blobs returned by the C API are copied and freed exactly once. Cleanup runs in reverse order on success, errors, early iteration exit, and connection setup failure. A result retained after its block raises rather than touching freed native memory.
18
+
19
+ Potentially blocking initialization, prepare, execute, query, row advance, and sync calls release the Ruby GVL. Sequel serializes access to a given connection; independent pool slots can run concurrently. Memory and embedded-replica pools are restricted to one slot. In the owning process, explicit `disconnect` closes both handles, is idempotent, and leaves Sequel able to reconnect lazily.
20
+
21
+ There are no finalizers: explicit ownership avoids GC freeing a native parent while a child is in use. Application code must disconnect databases it no longer needs. Connections detect queries from a different process and reject them. In a child, `disconnect` only clears inherited references, allowing Sequel to empty its pool without calling unsafe native destructors or affecting the parent's connections. Release parent connections in `before_fork`. The upstream Rust runtime must be initialized in each worker after fork; disconnecting does not reset it, and this is not a fork-safe driver.
22
+
23
+ ## Transactions and prepared queries
24
+
25
+ Sequel reserves one pool connection for the transaction and issues SQL BEGIN, COMMIT, ROLLBACK, and SAVEPOINT commands. The driver's native Transaction object is deliberately unused because its commit/rollback C functions return void and unwrap errors. SQL errors instead pass through a typed `Native::Error`, then Sequel's error classification.
26
+
27
+ Sequel's unnumbered argument mapper generates `?` placeholders and orders/repeats values correctly. Both `.call` and named `.prepare` queries use native binding. Native statements are short-lived, so failed statements cannot leave stale bindings, and DDL never needs to clear a statement cache. This trades additional prepare work (including remote round trips) for predictable resource handling.
28
+
29
+ ## Type information
30
+
31
+ The C API exposes runtime types and column names, but not column declarations/origins. General sqlite3-style automatic result conversion is therefore impossible without a new upstream API. This adapter resolves only known single-table column projections, consulting Sequel's schema cache after the SQL has successfully prepared. It leaves expressions and ambiguous projections unchanged and offers `with_libsql_types` for explicit conversions.
32
+
33
+ Sequel's cached placeholder literalizer optimization is disabled because it replaces projected columns with opaque arguments. Without native declared-type metadata, that made repeated `get` calls lose their conversions after the cache activated. Native bound variables and named prepared statements remain supported; the tradeoff is additional Ruby SQL generation work.
34
+
35
+ Numeric timestamps follow Sequel's SQLite convention: Integer means Unix seconds and Float means Julian day. Numeric TIME values preserve microseconds. BLOB columns containing integer/real/text storage values convert through their string representation, and nonnumeric text in a decimal column remains text, matching Sequel's SQLite adapter.
36
+
37
+ Sequel invalidates its own schema cache for schema DSL changes. External schema changes and arbitrary DDL via `run` follow Sequel's usual cache rules; reload the affected schema or reconnect the Database object when needed. No extra unbounded type cache is introduced.
38
+
39
+ ## Driver boundary
40
+
41
+ The compatibility layer uses `turso_libsql`'s shipped structs and binary functions without altering `Libsql` or `CLibsql` globally. Error messages are copied before freeing their C allocation. Signed integers are recovered from the upstream unsigned declaration. Blob binding is checked before String binding, and text uses UTF-8 byte lengths with a retained input pointer. Slice release uses the correct ABI type. Descriptor strings remain retained for their native database lifetime.
42
+
43
+ The exact 0.2.0 dependency prevents accidental activation of the credential-logging 0.2.1 native release. A future driver upgrade needs local/HTTP contract tests plus native binary review, not just a version bump.
@@ -0,0 +1,38 @@
1
+ # Compatibility and verification
2
+
3
+ ## Verified in this workspace
4
+
5
+ As of 2026-10-31, Apple Silicon macOS, Ruby 4.0.5, `turso_libsql` 0.2.0, bundled SQLite 3.45.1:
6
+
7
+ - Native local/file tests cover CRUD, signed integer boundaries, UTF-8, arbitrary/empty blobs, invalid input, native lifetime cleanup, prepared queries, constraints, transactions/savepoints/commit failure, models, associations, migrations, schema alteration, pool concurrency, reconnect, and type hints.
8
+ - The same project suite passes with Sequel 5.80.0 and 5.109.0.
9
+ - Sequel upstream at `75b425dd1c1f34908426dd70371d14e4afb9c933`: SQLite adapter plus integration run passes (901 tests, six upstream skips). The test sources are unmodified. Some additional groups are never defined for an external adapter: seven SQLite type-conversion cases gated on `adapter_scheme == :sqlite` now have local equivalents. See [the coverage review](testing-review.md).
10
+ - Real native HTTP execution against the official Hrana v3 Python fixture covers prepared Unicode/blob values, affected counts, inserted IDs, constraints, transactions/savepoints, reconnect, schema metadata, empty results, NULL values, models using prepared statements, and INSERT/UPDATE/DELETE RETURNING.
11
+ - The fixture wrapper only normalizes padded/unpadded base64 between Python and Rust. SQL and stream handling stay upstream. This is a protocol integration test, not a hosted Turso test.
12
+ - Hosted Turso remote mode was verified with a `turso://` URL normalized to HTTPS and a real database token, using Sequel 5.109.0. The warning-enabled `test/remote.rb` suite passed all five tests (24 assertions), covering CRUD, prepared bindings, Unicode/blobs, constraints, transactions/savepoints/rollback, reconnect persistence, schema metadata, models, and returning writes. Only randomly named test tables were created and dropped; credentials were not logged.
13
+
14
+ ## Not verified
15
+
16
+ Live embedded replication, sync scheduling/read-your-writes behavior against a primary, encryption, Linux native execution, and Ruby 3.3/3.4 execution have not been tested here. The connection descriptor and sync API support replicas; that does not establish end-to-end replication correctness. Hosted remote checks do not cover token expiry/rotation, custom TLS settings, or production load behavior.
17
+
18
+ ## Known limits
19
+
20
+ - No declared result type metadata from the C API: raw SQL/joins/CTEs/expressions need explicit hints for semantic SQLite types.
21
+ - Existing TEXT values containing NUL are truncated by the upstream C API; the adapter rejects NUL text inputs. BLOB is binary-safe.
22
+ - Remote native SQL parsing rejects local PRAGMAs such as busy_timeout/case_sensitive_like. Remote LIKE behavior follows the server. Local-only options are rejected when a remote URL is configured.
23
+ - No custom regexp functions, read-only opening, loadable extensions, DQS configuration, offline-write sync mode, or configurable HTTP timeout.
24
+ - Memory/replica pools are limited to one connection. Disconnecting a memory database destroys it.
25
+ - Initialize in worker processes after fork; release any parent connections in `before_fork`. Child `disconnect` quietly discards inherited handles, while inherited queries remain unsupported. This cleanup does not reset an inherited native runtime or establish that reconnecting in the child is safe.
26
+ - No automatic retry or idempotency policy for transport failures. If a write response is lost, its outcome can be uncertain.
27
+ - `turso_libsql` 0.2.0 ships only Apple Silicon macOS and glibc Linux x86_64/aarch64 binaries. Other platforms need an independently validated driver build.
28
+ - This targets libSQL and Turso's libSQL-compatible service, not the separate newer `turso` Rust engine API.
29
+
30
+ ## Upstream references
31
+
32
+ - [libsql-ruby](https://github.com/tursodatabase/libsql-ruby): FFI declarations, Ruby wrappers, examples, bundled libraries. Upstream main differs from published 0.2.0 and 0.2.1.
33
+ - [libsql-activerecord](https://github.com/tursodatabase/libsql-activerecord): ORM execution and schema comparison.
34
+ - [Sequel SQLite adapter](https://github.com/jeremyevans/sequel/blob/75b425dd1c1f34908426dd70371d14e4afb9c933/lib/sequel/adapters/sqlite.rb) and shared SQLite modules: integration conventions and dialect behavior.
35
+ - [sqlite3-ruby](https://github.com/sparklemotion/sqlite3-ruby): comparison of value and declared-column metadata APIs.
36
+ - [libsql-c](https://github.com/tursodatabase/libsql-c): native ABI, ownership, descriptors, errors, and parameter conversion. Published 0.2.0-era revision: `cb3a8c2c2c7330e104e4df536189eddb83a8f491`.
37
+ - [libsql-client-ts](https://github.com/tursodatabase/libsql-client-ts/tree/9496c782af413f6380235c7f5d0c97b5371f49b7/testing/hrana-test-server): official protocol fixture.
38
+ - [hrana-client-ts](https://github.com/libsql/hrana-client-ts) and [libSQL](https://github.com/tursodatabase/libsql): HTTP streams, remote SQL behavior, and blob encoding.
@@ -0,0 +1,61 @@
1
+ # Development
2
+
3
+ ```sh
4
+ bundle install
5
+ bundle exec rake check
6
+ ```
7
+
8
+ Tests use real native libSQL databases and clean up temporary files. No network service or credentials are required for the default suite. `sqlite3` is a development dependency for upstream compatibility tests; it is not loaded by the adapter.
9
+
10
+ ## Code checks
11
+
12
+ `rake` defaults to `check`: `lint` followed by the tests. As in Sequel, tests run with `-w` and, on Ruby 3.4+, `-W:strict_unused_block`. There is no RuboCop/Standard configuration or complexity analyzer in Sequel's repository.
13
+
14
+ - `bundle exec rake check_syntax` checks Ruby syntax and parser warnings in project library, test, task, and example files, plus Gemfile/Rakefile/gemspec. It fails on invalid syntax or warnings. Ignored clones, private notes, and installed gems are outside its file list.
15
+ - `bundle exec rake check_visibility` uses Jeremy Evans' `visibility_checker`, also used by Sequel's optional visibility checks. It checks the database, dataset, and prepared dataset; changes originating in the adapter fail the task. Preexisting visibility changes inside Sequel itself are excluded.
16
+ - `bundle exec rake lint` runs both checks.
17
+ - `.github/workflows/ci.yml` runs `rake check` on Linux with Ruby 3.3, 3.4, and 4.0. The workflow configuration has been validated locally; those hosted jobs have not yet run for these changes.
18
+
19
+ Code size was reviewed using Ruby's Prism AST to locate complete methods. No artificial complexity score is enforced. The largest configuration/type-inference methods were split by responsibility; see [testing-review.md](testing-review.md) for the measured sizes and coverage findings.
20
+
21
+ ## Reference sources
22
+
23
+ Reference repositories are checked out under Git-ignored `tmp/upstream/`. The adapter does not load implementation code from them.
24
+
25
+ ```sh
26
+ mkdir -p tmp/upstream
27
+ git clone --depth 1 https://github.com/tursodatabase/libsql-ruby.git tmp/upstream/libsql-ruby
28
+ git clone --depth 1 https://github.com/tursodatabase/libsql-activerecord.git tmp/upstream/libsql-activerecord
29
+ git clone --depth 1 https://github.com/jeremyevans/sequel.git tmp/upstream/sequel
30
+ git clone --depth 1 https://github.com/sparklemotion/sqlite3-ruby.git tmp/upstream/sqlite3-ruby
31
+ git clone --depth 1 https://github.com/tursodatabase/libsql-c.git tmp/upstream/libsql-c
32
+ git clone --depth 1 https://github.com/tursodatabase/libsql-client-ts.git tmp/upstream/libsql-client-ts
33
+ git clone --depth 1 https://github.com/libsql/hrana-client-ts.git tmp/upstream/hrana-client-ts
34
+ git clone --depth 1 --filter=blob:none --sparse https://github.com/tursodatabase/libsql.git tmp/upstream/libsql
35
+ git -C tmp/upstream/libsql sparse-checkout set libsql/src libsql-hrana
36
+ ```
37
+
38
+ `bundle exec rake upstream` runs the cloned Sequel SQLite adapter tests plus its integration suite in the upstream working directory, preserving its migration fixtures. It loads the cloned Sequel itself rather than accidentally loading two Sequel versions into one process. Tests use an in-memory database.
39
+
40
+ ## Native HTTP integration
41
+
42
+ Prepare the official test server in an isolated Python environment:
43
+
44
+ ```sh
45
+ python3.11 -m venv tmp/hrana-venv
46
+ tmp/hrana-venv/bin/pip install -r tmp/upstream/libsql-client-ts/testing/hrana-test-server/requirements.txt
47
+ bundle exec rake remote_local
48
+ ```
49
+
50
+ The fixture needs a free localhost port 8080. It creates a temporary SQLite file, spawns the Ruby tests, then shuts down and removes its database. `test/support/hrana_server.py` fixes only the Python fixture's base64 padding incompatibility with the Rust client. No upstream source is modified.
51
+
52
+ For a disposable hosted database, export `LIBSQL_TEST_URL` and `LIBSQL_TEST_AUTH_TOKEN`, then run `bundle exec rake remote`. Tests create randomly named tables and drop only those tables. Do not point destructive integration fixtures from another project at a production database.
53
+
54
+ ## Packaging
55
+
56
+ ```sh
57
+ mkdir -p pkg
58
+ gem build libsql-sequel.gemspec --output pkg/libsql-sequel-0.3.0.gem
59
+ ```
60
+
61
+ The explicit gem file list includes Ruby library files, public docs, examples, README, and LICENSE. It excludes private notes, cloned repositories, credentials, tests, and temporary databases. Nothing is automatically published.
@@ -0,0 +1,46 @@
1
+ # Spec and code-quality review
2
+
3
+ Reviewed 2026-10-31 against the cloned upstream sources, after the initial adapter commit `0b966b1` was pushed.
4
+
5
+ ## Upstream comparison
6
+
7
+ | Source | Existing coverage | Action here |
8
+ | --- | --- | --- |
9
+ | Sequel SQLite adapter + integration specs | SQL generation, schema changes, constraints, transactions, models, associations, migrations, prepared queries | Continue running unmodified upstream specs with `rake upstream` |
10
+ | Sequel SQLite type-conversion group | Seven cases defined only for `adapter_scheme == :sqlite`; absent from the external-adapter run | Added local equivalents in `test/sqlite_types_test.rb` |
11
+ | Sequel SQLite-specific URI, regexp, temporary-release groups | Target sqlite3-specific APIs or explicitly create `Sequel.sqlite` | Keep native libSQL URL tests and explicit rejection of unsupported regexp options; do not pretend these groups exercise this adapter |
12
+ | libsql-activerecord | No dedicated adapter spec directory or test CI; example Rails scaffold has controller/system CRUD tests and an empty model test | Existing model tests cover local CRUD; added remote model lifecycle with Sequel's prepared-statements plugin |
13
+ | libsql-activerecord execution code | Separates row-returning queries, batch execution, affected counts, and insert IDs | Added remote empty-result metadata, NULL values, and UPDATE/DELETE RETURNING cases |
14
+
15
+ The ActiveRecord adapter cannot be tested by loading its Rails scaffold into this project: that would exercise ActiveRecord's adapter, not the Sequel implementation.
16
+
17
+ ## Bugs exposed by the new specs
18
+
19
+ - Integer and Float timestamps now use Unix seconds and Julian days, respectively, supporting both Time and DateTime output classes.
20
+ - Numeric TIME values now round fractional seconds to microseconds as Sequel does.
21
+ - Numeric/text storage values in a BLOB column now become `Sequel::SQL::Blob` rather than raising or remaining plain strings.
22
+ - Nonnumeric text in a decimal column remains text, consistent with SQLite's flexible storage and Sequel's converter.
23
+ - Repeated `get` calls no longer lose result types once Sequel's placeholder-literalizer cache activates. The adapter disables that optimization because it hides projection origins; native bound/prepared statements still work.
24
+
25
+ ## Linting and size review
26
+
27
+ Sequel has no RuboCop, Standard, Flog, Reek, or cyclomatic-complexity configuration. Its Rakefile supplies `check_syntax`, warning-enabled spec commands, and optional `visibility_checker` integration. Its contribution guide asks contributors to follow surrounding code style rather than prescribing an automatic formatter.
28
+
29
+ This repository now provides `check_syntax`, `check_visibility`, `lint`, and `check` tasks. Syntax and visibility failures were tested with deliberately invalid temporary probes and correctly returned nonzero exit statuses. These checks apply only to project code, not ignored upstream checkouts. No wholesale reformatting was performed.
30
+
31
+ Prism AST source ranges identified `Configuration#initialize` (58 lines) and `Dataset#libsql_types` (50 lines) as the longest methods. Configuration now separates location normalization, URL validation, credentials, replication options, and option validation. `ResultTypes` handles projection origins and `TypeConverter` handles semantic conversions. `Dataset` shrank from 130 to 58 lines. The longest library method is now 24 lines; the largest library file is 178 lines. These are source-size measurements, not cyclomatic scores.
32
+
33
+ ## Verification
34
+
35
+ - `bundle exec rake check upstream remote_local`: 40 local tests / 196 assertions; 901 upstream tests / 7469 assertions / six upstream skips; five real native HTTP tests / 24 assertions. No failures or errors. Current-bundle syntax, warnings, and adapter visibility checks pass.
36
+ - The 40 local tests also pass with Sequel 5.80.0. That older Sequel emits a Ruby 4 URI deprecation warning from its own code; it is not an adapter warning.
37
+ - The CI workflow parses and invokes `rake check`, but its Linux/Ruby matrix has not been executed here.
38
+
39
+ Hosted Turso authentication/TLS, live replica sync/read-your-writes behavior, and encryption still need real service tests. Local descriptor support or mocked native calls would not establish those guarantees, so this review does not claim them.
40
+
41
+ ## Source pointers
42
+
43
+ - [Sequel Rakefile](https://github.com/jeremyevans/sequel/blob/75b425dd1c1f34908426dd70371d14e4afb9c933/Rakefile)
44
+ - [Sequel SQLite specs](https://github.com/jeremyevans/sequel/blob/75b425dd1c1f34908426dd70371d14e4afb9c933/spec/adapters/sqlite_spec.rb)
45
+ - [Sequel contribution guide](https://github.com/jeremyevans/sequel/blob/75b425dd1c1f34908426dd70371d14e4afb9c933/CONTRIBUTING)
46
+ - [libsql-activerecord example tests](https://github.com/tursodatabase/libsql-activerecord/tree/4873624743b046dde6c0188963b22576d8341e3a/examples/railsapp/test)
data/examples/local.rb ADDED
@@ -0,0 +1,19 @@
1
+ # frozen-string-literal: true
2
+
3
+ require 'libsql-sequel'
4
+
5
+ Sequel.connect(:adapter => :libsql) do |db|
6
+ db.create_table(:items) do
7
+ primary_key :id
8
+ String :name, :null => false
9
+ TrueClass :active, :default => true
10
+ end
11
+
12
+ items = db[:items]
13
+ id = items.insert(:name => 'Hello, libSQL')
14
+ find = items.where(:id => :$id).prepare(:first, :find_item)
15
+ p find.call(:id => id)
16
+
17
+ db.transaction(:rollback => :always){items.insert(:name => 'discarded')}
18
+ puts "#{items.count} persisted item"
19
+ end
@@ -0,0 +1,5 @@
1
+ # frozen-string-literal: true
2
+
3
+ require 'sequel'
4
+ require_relative 'sequel/libsql/version'
5
+ require_relative 'sequel/adapters/libsql'
@@ -0,0 +1,178 @@
1
+ # frozen-string-literal: true
2
+
3
+ require 'sequel'
4
+ require 'sequel/adapters/shared/sqlite'
5
+ require_relative '../libsql/connection'
6
+ require_relative '../libsql/configuration'
7
+ require_relative '../libsql/dataset'
8
+
9
+ module Sequel
10
+ module LibSQL
11
+ class Database < Sequel::Database
12
+ include Sequel::SQLite::DatabaseMethods
13
+
14
+ set_adapter_scheme :libsql
15
+
16
+ # libsql://host is a native remote URL. Use a hash for local files.
17
+ def self.uri_to_options(uri)
18
+ {:database => uri.to_s}
19
+ end
20
+ private_class_method :uri_to_options
21
+
22
+ def connect(server)
23
+ options = server_opts(server)
24
+ configuration = Configuration.new(self, options)
25
+ connection = Connection.new(configuration.native_options)
26
+ begin
27
+ pragmas = connection_pragmas
28
+ if configuration.remote?
29
+ # The native remote SQL parser rejects these local-only PRAGMAs.
30
+ pragmas = pragmas.grep(/\APRAGMA foreign_keys = /)
31
+ else
32
+ timeout = Integer(options.fetch(:timeout, 5000))
33
+ raise Sequel::Error, 'timeout must be a nonnegative 32-bit millisecond value' unless (0..2_147_483_647).cover?(timeout)
34
+ pragmas += ["PRAGMA busy_timeout = #{timeout}"]
35
+ end
36
+ pragmas.each do |sql|
37
+ log_connection_yield(sql, connection){connection.execute_batch(sql)}
38
+ end
39
+ connection
40
+ rescue Exception
41
+ connection.close
42
+ raise
43
+ end
44
+ end
45
+
46
+ def disconnect_connection(connection)
47
+ connection.close
48
+ end
49
+
50
+ # Match SQLite's unix-second and Julian-day timestamp representations.
51
+ def to_application_timestamp(value)
52
+ case value
53
+ when Integer
54
+ super(Time.at(value).to_s)
55
+ when Float
56
+ super(DateTime.jd(value).to_s)
57
+ else
58
+ super
59
+ end
60
+ end
61
+
62
+ def execute(sql, opts=OPTS, &block)
63
+ _execute(:select, sql, opts, &block)
64
+ end
65
+
66
+ def execute_dui(sql, opts=OPTS)
67
+ _execute(:update, sql, opts)
68
+ end
69
+
70
+ def execute_insert(sql, opts=OPTS)
71
+ _execute(:insert, sql, opts)
72
+ end
73
+
74
+ # DB.run accepts SQL batches, as in Sequel's SQLite adapter.
75
+ def execute_ddl(sql, opts=OPTS)
76
+ synchronize(opts[:server]) do |connection|
77
+ log_connection_yield(sql, connection){connection.execute_batch(sql)}
78
+ end
79
+ rescue Native::Error => e
80
+ raise_error(e)
81
+ end
82
+
83
+ # Pull remote changes into an embedded replica. Writes use the native
84
+ # read-your-writes setting (enabled by default).
85
+ def sync(opts=OPTS)
86
+ synchronize(opts[:server]) do |connection|
87
+ raise Sequel::Error, 'cannot sync inside a transaction' if in_transaction?(:server => opts[:server])
88
+ connection.sync
89
+ end
90
+ rescue Native::Error => e
91
+ raise_error(e)
92
+ end
93
+
94
+ private
95
+
96
+ def adapter_initialize
97
+ set_integer_booleans
98
+ self.transaction_mode = @opts[:transaction_mode].to_sym if @opts[:transaction_mode]
99
+ end
100
+
101
+ def connection_pool_default_options
102
+ options = super.dup
103
+ options[:max_connections] = 1 if Configuration.new(self, @opts).single_connection?
104
+ options
105
+ end
106
+
107
+ def database_error_classes
108
+ [Native::Error]
109
+ end
110
+
111
+ # Validation sends SELECT NULL, while transaction commands send no rows.
112
+ # The native batch API handles both without leaking a result cursor.
113
+ def connection_execute_method
114
+ :execute_batch
115
+ end
116
+
117
+ def database_specific_error_class(exception, opts)
118
+ case exception.message
119
+ when /libSQL connection is closed|Hrana:.*stream closed/i
120
+ DatabaseDisconnectError
121
+ when /UNIQUE constraint failed|PRIMARY KEY must be unique/i
122
+ UniqueConstraintViolation
123
+ when /NOT NULL constraint failed/i
124
+ NotNullConstraintViolation
125
+ when /FOREIGN KEY constraint failed/i
126
+ ForeignKeyConstraintViolation
127
+ when /CHECK constraint failed/i
128
+ CheckConstraintViolation
129
+ when /cannot store \w+ value in \w+ column|constraint failed/i
130
+ ConstraintViolation
131
+ else
132
+ super
133
+ end
134
+ end
135
+
136
+ def dataset_class_default
137
+ Dataset
138
+ end
139
+
140
+ def _execute(type, sql, opts)
141
+ if sql.is_a?(Symbol)
142
+ sql = prepared_statement(sql).prepared_sql
143
+ end
144
+ arguments = opts.fetch(:arguments, [])
145
+ arguments = if arguments.is_a?(Hash)
146
+ arguments.transform_values{|value| bound_value(value)}
147
+ else
148
+ arguments.map{|value| bound_value(value)}
149
+ end
150
+ synchronize(opts[:server]) do |connection|
151
+ log_connection_yield(sql, connection, opts[:arguments]) do
152
+ if type == :select && block_given?
153
+ connection.query(sql, arguments){|result| yield result}
154
+ else
155
+ changed = connection.execute(sql, arguments)
156
+ type == :insert ? connection.last_insert_id : changed
157
+ end
158
+ end
159
+ end
160
+ rescue Native::Error => e
161
+ raise_error(e)
162
+ end
163
+
164
+ def bound_value(value)
165
+ case value
166
+ when Time, Date, DateTime
167
+ literal(value)[1...-1]
168
+ when BigDecimal
169
+ value.to_s('F')
170
+ when true, false
171
+ integer_booleans ? (value ? 1 : 0) : (value ? 't' : 'f')
172
+ else
173
+ value
174
+ end
175
+ end
176
+ end
177
+ end
178
+ end
@@ -0,0 +1,6 @@
1
+ # frozen-string-literal: true
2
+
3
+ require_relative 'libsql'
4
+
5
+ # Route Sequel.connect('turso://...') through the same adapter and pool.
6
+ Sequel.synchronize { Sequel::ADAPTER_MAP[:turso] = Sequel::LibSQL::Database }
@@ -0,0 +1,106 @@
1
+ # frozen-string-literal: true
2
+
3
+ module Sequel
4
+ module LibSQL
5
+ # Normalize local, remote, and embedded-replica options in one place.
6
+ class Configuration
7
+ attr_reader :native_options
8
+
9
+ def initialize(db, options)
10
+ @db = db
11
+ @options = options
12
+ path, url = location
13
+ validate_url(url)
14
+ # Turso Cloud now supplies turso:// URLs. The pinned native driver
15
+ # predates that alias but supports the equivalent HTTPS endpoint.
16
+ url = url.sub(/\Aturso:/i, 'https:') if url
17
+ @native_options = {:path => path, :url => url}
18
+ add_credentials
19
+ add_replication_options
20
+ validate_options
21
+ rescue URI::InvalidURIError, ArgumentError, TypeError
22
+ raise Sequel::Error, 'invalid libSQL connection configuration'
23
+ end
24
+
25
+ def replica?
26
+ !!(@native_options[:path] && @native_options[:url])
27
+ end
28
+
29
+ def remote?
30
+ !!@native_options[:url]
31
+ end
32
+
33
+ def single_connection?
34
+ replica? || @native_options[:path] == ':memory:'
35
+ end
36
+
37
+ private
38
+
39
+ def location
40
+ database = @options[:database]
41
+ database = nil if database.to_s.empty?
42
+ url = @options[:url]
43
+ if database && /\A(?:turso|libsql|https?):\/\//i.match?(database.to_s)
44
+ raise Sequel::Error, 'specify the remote URL with database or url, not both' if url && url != database
45
+ url = database
46
+ database = nil
47
+ end
48
+ path = @options[:path] || database
49
+ raise Sequel::Error, 'path must not be empty' if path && path.to_s.empty?
50
+ if @options[:path] && database && @options[:path].to_s != database.to_s
51
+ raise Sequel::Error, 'database and path specify different local files'
52
+ end
53
+ path = ':memory:' unless path || url
54
+ [path && path.to_s, url && url.to_s]
55
+ end
56
+
57
+ def validate_url(url)
58
+ if url
59
+ uri = URI.parse(url.to_s)
60
+ unless %w[turso libsql https http].include?(uri.scheme) && uri.host && !uri.userinfo && !uri.fragment && !uri.query
61
+ raise Sequel::Error, 'libSQL URL must use turso, libsql, https, or http with a host and no credentials, query, or fragment'
62
+ end
63
+ elsif @options[:auth_token]
64
+ raise Sequel::Error, 'auth_token requires a remote URL'
65
+ end
66
+ end
67
+
68
+ def add_credentials
69
+ [:auth_token, :encryption_key].each{|key| @native_options[key] = @options[key] if @options.key?(key)}
70
+ # 0.2.0 requires a non-null token pointer even for an unauthenticated
71
+ # local Hrana server. An empty token is the native no-auth convention.
72
+ @native_options[:auth_token] ||= '' if remote?
73
+ if @options.key?(:namespace)
74
+ unless CLibsql::DatabaseDesc.members.include?(:namespace)
75
+ raise Sequel::Error, 'namespace is not supported by this turso_libsql release'
76
+ end
77
+ @native_options[:namespace] = @options[:namespace]
78
+ end
79
+ end
80
+
81
+ def add_replication_options
82
+ @native_options[:read_your_writes] = @db.send(:typecast_value_boolean, @options.fetch(:read_your_writes, true))
83
+ interval = Integer(@options.fetch(:sync_interval, 0))
84
+ raise Sequel::Error, 'sync_interval must be an unsigned 64-bit millisecond interval' unless (0...(1 << 64)).cover?(interval)
85
+ raise Sequel::Error, 'sync_interval requires an embedded replica' if interval > 0 && !replica?
86
+ raise Sequel::Error, 'an embedded replica requires a file path, not :memory:' if replica? && @native_options[:path] == ':memory:'
87
+ @native_options[:sync_interval] = interval
88
+ end
89
+
90
+ def validate_options
91
+ if remote?
92
+ [:timeout, :case_sensitive_like, :auto_vacuum, :synchronous, :temp_store].each do |key|
93
+ raise Sequel::Error, "#{key} is only supported for local libSQL databases" if @options.key?(key)
94
+ end
95
+ end
96
+
97
+ [:readonly, :disable_dqs, :setup_regexp_function, :synced, :webpki].each do |key|
98
+ raise Sequel::Error, "#{key} is not supported by this adapter" if @options.key?(key)
99
+ end
100
+ if single_connection? && Integer(@options.fetch(:max_connections, 1)) != 1
101
+ raise Sequel::Error, 'memory databases and embedded replicas require max_connections: 1'
102
+ end
103
+ end
104
+ end
105
+ end
106
+ end
@@ -0,0 +1,165 @@
1
+ # frozen-string-literal: true
2
+
3
+ require_relative 'native'
4
+
5
+ module Sequel
6
+ module LibSQL
7
+ # Owns a native database and its connection for one Sequel pool slot.
8
+ class Connection
9
+ def initialize(options)
10
+ @pid = Process.pid
11
+ @replica = options[:path] && options[:url]
12
+ desc = CLibsql::DatabaseDesc.new
13
+ @strings = []
14
+ [:path, :url, :auth_token, :encryption_key, :namespace].each do |key|
15
+ next if options[key].nil?
16
+ desc[key] = pointer = FFI::MemoryPointer.from_string(Native.string(options[key]))
17
+ @strings << pointer
18
+ end
19
+ desc[:sync_interval] = options.fetch(:sync_interval, 0)
20
+ desc[:disable_read_your_writes] = !options.fetch(:read_your_writes, true)
21
+ @database = Native.check(Native.database_init(desc))
22
+ begin
23
+ @connection = Native.check(Native.database_connect(@database))
24
+ rescue Exception
25
+ close
26
+ raise
27
+ end
28
+ end
29
+
30
+ def execute(sql, arguments = [])
31
+ with_statement(sql, arguments) do |statement|
32
+ Native.check(Native.statement_execute(statement))[:rows_changed]
33
+ end
34
+ end
35
+
36
+ def execute_batch(sql)
37
+ check_open
38
+ Native.check(Native.connection_batch(@connection, Native.string(sql)))
39
+ nil
40
+ end
41
+
42
+ def query(sql, arguments = [])
43
+ with_statement(sql, arguments) do |statement|
44
+ result = Result.new(Native.check(Native.statement_query(statement)))
45
+ begin
46
+ yield result
47
+ ensure
48
+ result.close
49
+ end
50
+ end
51
+ end
52
+
53
+ def last_insert_id
54
+ check_open
55
+ Native.check(Native.connection_info(@connection))[:last_inserted_id]
56
+ end
57
+
58
+ def sync
59
+ check_open
60
+ raise Sequel::Error, 'sync requires an embedded replica (both path and remote URL)' unless @replica
61
+ result = Native.check(Native.database_sync(@database))
62
+ {:frame_no => result[:frame_no], :frames_synced => result[:frames_synced]}
63
+ end
64
+
65
+ def close
66
+ # Inherited handles belong to the parent; their runtime cannot be used
67
+ # or destroyed in the child. Let Sequel empty the child's pool safely.
68
+ if @pid != Process.pid
69
+ @connection = @database = @strings = nil
70
+ return
71
+ end
72
+ if @connection
73
+ CLibsql.libsql_connection_deinit(@connection)
74
+ @connection = nil
75
+ end
76
+ if @database
77
+ CLibsql.libsql_database_deinit(@database)
78
+ @database = nil
79
+ end
80
+ @strings = nil
81
+ nil
82
+ end
83
+
84
+ private
85
+
86
+ def check_process
87
+ raise Sequel::Error, 'libSQL connections cannot be used after fork; connect inside each worker' unless @pid == Process.pid
88
+ end
89
+
90
+ def check_open
91
+ check_process
92
+ raise Native::Error, 'libSQL connection is closed' unless @connection
93
+ end
94
+
95
+ def with_statement(sql, arguments)
96
+ check_open
97
+ statement = Native.check(Native.connection_prepare(@connection, Native.string(sql)))
98
+ begin
99
+ case arguments
100
+ when Array
101
+ arguments.each do |value|
102
+ Native.bind_value(value){|v| Native.check(CLibsql.libsql_statement_bind_value(statement, v))}
103
+ end
104
+ when Hash
105
+ arguments.each do |name, value|
106
+ name = Native.string(name)
107
+ name = ":#{name}" unless /\A[:@$]/.match?(name)
108
+ Native.bind_value(value){|v| Native.check(CLibsql.libsql_statement_bind_named(statement, name, v))}
109
+ end
110
+ else
111
+ raise Sequel::Error, 'libSQL arguments must be an Array or Hash'
112
+ end
113
+ yield statement
114
+ ensure
115
+ CLibsql.libsql_statement_deinit(statement)
116
+ end
117
+ end
118
+ end
119
+
120
+ # Only lives inside Connection#query; rows are freed even on break or raise.
121
+ class Result
122
+ def initialize(rows)
123
+ @rows = rows
124
+ end
125
+
126
+ def columns
127
+ check_open
128
+ @columns ||= Array.new(Native.rows_column_count(@rows)) do |index|
129
+ Native.read_slice(CLibsql.libsql_rows_column_name(@rows, index), true)
130
+ end
131
+ end
132
+
133
+ def each
134
+ check_open
135
+ loop do
136
+ check_open
137
+ row = Native.check(Native.rows_next(@rows))
138
+ break if row[:inner].null?
139
+ begin
140
+ values = Array.new(columns.length) do |index|
141
+ Native.value(Native.check(CLibsql.libsql_row_value(row, index))[:ok])
142
+ end
143
+ yield values
144
+ ensure
145
+ CLibsql.libsql_row_deinit(row)
146
+ end
147
+ end
148
+ end
149
+
150
+ def close
151
+ if @rows
152
+ CLibsql.libsql_rows_deinit(@rows)
153
+ @rows = nil
154
+ end
155
+ nil
156
+ end
157
+
158
+ private
159
+
160
+ def check_open
161
+ raise Native::Error, 'libSQL result is closed' unless @rows
162
+ end
163
+ end
164
+ end
165
+ end
@@ -0,0 +1,58 @@
1
+ # frozen-string-literal: true
2
+
3
+ require_relative 'result_types'
4
+ require_relative 'type_converter'
5
+
6
+ module Sequel
7
+ module LibSQL
8
+ class Dataset < Sequel::Dataset
9
+ include Sequel::SQLite::DatasetMethods
10
+
11
+ BindArgumentMethods = prepared_statements_module(:bind, Sequel::Dataset::UnnumberedArgumentMapper)
12
+ PreparedStatementMethods = prepared_statements_module(:prepare, BindArgumentMethods)
13
+
14
+ # Explicit conversions for raw SQL, expressions, or ambiguous projections.
15
+ # DB.fetch(sql).with_libsql_types(:active=>:boolean, :created_at=>:datetime)
16
+ def with_libsql_types(types)
17
+ clone(:libsql_types => (@opts[:libsql_types] || OPTS).merge(types).freeze)
18
+ end
19
+
20
+ # Cached literalizers replace selected columns with opaque arguments.
21
+ # Without C decltypes, keep the projection AST for stable conversions.
22
+ def supports_placeholder_literalizer?
23
+ false
24
+ end
25
+
26
+ def fetch_rows(sql)
27
+ execute(sql) do |result|
28
+ columns = result.columns.map{|column| output_identifier(column)}
29
+ types = ResultTypes.new(self).for_columns(columns)
30
+ converter = TypeConverter.new(db)
31
+ self.columns = columns
32
+ result.each do |values|
33
+ row = {}
34
+ columns.each_with_index do |name, index|
35
+ value = values[index]
36
+ row[name] = converter.call(value, types[name])
37
+ end
38
+ yield row
39
+ end
40
+ end
41
+ end
42
+
43
+ private
44
+
45
+ def bound_variable_modules
46
+ [BindArgumentMethods]
47
+ end
48
+
49
+ def prepared_statement_modules
50
+ [PreparedStatementMethods]
51
+ end
52
+
53
+ def literal_string_append(sql, value)
54
+ sql << "'" << Native.string(value).gsub("'", "''") << "'"
55
+ end
56
+ end
57
+ end
58
+ end
@@ -0,0 +1,115 @@
1
+ # frozen-string-literal: true
2
+
3
+ gem 'turso_libsql', '= 0.2.0'
4
+ require 'libsql'
5
+
6
+ module Sequel
7
+ module LibSQL
8
+ # Private compatibility boundary for turso_libsql 0.2.x. Its high-level
9
+ # wrappers have unsafe error handling and incorrect value conversions.
10
+ # Reuse its ABI structs and library, without changing global driver classes.
11
+ module Native
12
+ class Error < StandardError; end
13
+
14
+ extend FFI::Library
15
+ ffi_lib(*CLibsql.ffi_libraries.map(&:name))
16
+
17
+ {
18
+ :database_init => [[CLibsql::DatabaseDesc.by_value], CLibsql::Database.by_value],
19
+ :database_connect => [[CLibsql::Database.by_value], CLibsql::Connection.by_value],
20
+ :database_sync => [[CLibsql::Database.by_value], CLibsql::Sync.by_value],
21
+ :connection_prepare => [[CLibsql::Connection.by_value, :string], CLibsql::Statement.by_value],
22
+ :connection_info => [[CLibsql::Connection.by_value], CLibsql::ConnectionInfo.by_value],
23
+ :connection_batch => [[CLibsql::Connection.by_value, :string], CLibsql::Batch.by_value],
24
+ :statement_execute => [[CLibsql::Statement.by_value], CLibsql::Execute.by_value],
25
+ :statement_query => [[CLibsql::Statement.by_value], CLibsql::Rows.by_value],
26
+ :rows_next => [[CLibsql::Rows.by_value], CLibsql::Row.by_value]
27
+ }.each do |name, (args, result)|
28
+ attach_function name, :"libsql_#{name}", args, result, :blocking => true
29
+ end
30
+
31
+ # The upstream declaration incorrectly uses Row.by_value for this call.
32
+ attach_function :slice_deinit, :libsql_slice_deinit, [CLibsql::Slice.by_value], :void
33
+
34
+ # The released gem and upstream main use different names for this symbol.
35
+ count_function = CLibsql.respond_to?(:libsql_rows_column_count) ? :libsql_rows_column_count : :libsql_rows_column_length
36
+ attach_function :rows_column_count, count_function, [CLibsql::Rows.by_value], :int32
37
+
38
+ def self.check(result)
39
+ return result if result[:err].null?
40
+
41
+ begin
42
+ message = CLibsql.libsql_error_message(result[:err])
43
+ ensure
44
+ CLibsql.libsql_error_deinit(result[:err])
45
+ end
46
+ raise Error, message
47
+ end
48
+
49
+ def self.string(value)
50
+ value = value.to_s.encode(Encoding::UTF_8)
51
+ raise Sequel::Error, 'libSQL text cannot contain NUL bytes; use Sequel.blob for binary data' if value.include?("\0")
52
+ raise Sequel::Error, 'libSQL text must be valid UTF-8' unless value.valid_encoding?
53
+ value
54
+ rescue Encoding::UndefinedConversionError, Encoding::InvalidByteSequenceError
55
+ raise Sequel::Error, 'libSQL text must be convertible to UTF-8; use Sequel.blob for binary data'
56
+ end
57
+
58
+ # C text slices include a terminal NUL; blob slices do not.
59
+ def self.read_slice(slice, text)
60
+ return text ? '' : Sequel.blob('') if slice[:ptr].null?
61
+
62
+ begin
63
+ length = slice[:len]
64
+ value = slice[:ptr].read_string_length(text ? [length - 1, 0].max : length)
65
+ text ? value.force_encoding(Encoding::UTF_8) : Sequel.blob(value)
66
+ ensure
67
+ slice_deinit(slice)
68
+ end
69
+ end
70
+
71
+ def self.value(value)
72
+ case value[:type]
73
+ when :null
74
+ nil
75
+ when :integer
76
+ # The Ruby driver declares uint64, while the C API returns int64.
77
+ integer = value[:value][:integer]
78
+ integer >= (1 << 63) ? integer - (1 << 64) : integer
79
+ when :real
80
+ value[:value][:real]
81
+ when :text, :blob
82
+ read_slice(value[:value][value[:type]], value[:type] == :text)
83
+ else
84
+ raise Error, 'unknown libSQL value type'
85
+ end
86
+ end
87
+
88
+ # Keep the input pointer alive until the native bind has copied its bytes.
89
+ def self.bind_value(value)
90
+ case value
91
+ when nil
92
+ yield CLibsql.libsql_null
93
+ when Integer
94
+ unless (-(1 << 63)...(1 << 63)).cover?(value)
95
+ raise Sequel::Error, 'libSQL integers must fit in signed 64 bits'
96
+ end
97
+ yield CLibsql.libsql_integer(value)
98
+ when Float
99
+ raise Sequel::Error, 'libSQL cannot bind non-finite floats' unless value.finite?
100
+ yield CLibsql.libsql_real(value)
101
+ when SQL::Blob
102
+ pointer = FFI::MemoryPointer.new(:char, [value.bytesize, 1].max)
103
+ pointer.put_bytes(0, value)
104
+ yield CLibsql.libsql_blob(pointer, value.bytesize)
105
+ when String
106
+ value = string(value)
107
+ pointer = FFI::MemoryPointer.from_string(value)
108
+ yield CLibsql.libsql_text(pointer, value.bytesize)
109
+ else
110
+ raise Sequel::Error, "unsupported libSQL bound value type: #{value.class}"
111
+ end
112
+ end
113
+ end
114
+ end
115
+ end
@@ -0,0 +1,91 @@
1
+ # frozen-string-literal: true
2
+
3
+ module Sequel
4
+ module LibSQL
5
+ # Resolve only projections with a known table-column origin. C exposes no
6
+ # declared types, so raw SQL and ambiguous result columns need explicit hints.
7
+ class ResultTypes
8
+ def initialize(dataset)
9
+ @dataset = dataset
10
+ @opts = dataset.opts
11
+ end
12
+
13
+ def for_columns(columns)
14
+ types = inferred_types
15
+ columns.tally.each{|name, count| types.delete(name) if count > 1}
16
+ types.merge(@opts[:libsql_types] || OPTS)
17
+ end
18
+
19
+ private
20
+
21
+ def inferred_types
22
+ if original = @opts[:orig_dataset]
23
+ return self.class.new(original).send(:inferred_types)
24
+ end
25
+ return {} unless table = source_table
26
+ selection = @opts[:returning] || @opts[:select]
27
+ return {} if selection && !selection.empty? && selection.none?{|column| column?(column)}
28
+
29
+ schema = @dataset.db.schema(table).to_h
30
+ return schema.transform_values{|info| info[:type]} if !selection || selection.empty?
31
+ types = {}
32
+ selection.each{|column| add_projection(types, schema, column)}
33
+ types
34
+ end
35
+
36
+ def source_table
37
+ return if [:sql, :join, :with, :compounds].any?{|key| @opts[key]}
38
+ sources = @opts[:from]
39
+ return unless sources && sources.length == 1
40
+ source = sources.first
41
+ source = source.expression if source.is_a?(SQL::AliasedExpression)
42
+ source if identifier?(source) || source.is_a?(String)
43
+ end
44
+
45
+ def add_projection(types, schema, column)
46
+ if column.is_a?(SQL::ColumnAll)
47
+ return schema.each{|name, info| types[name] = info[:type]}
48
+ end
49
+ name, aliaz = column_names(column)
50
+ if name && (info = schema[output_identifier(name)])
51
+ types[output_identifier(aliaz || name)] = info[:type]
52
+ elsif aliaz
53
+ types.delete(output_identifier(aliaz))
54
+ end
55
+ end
56
+
57
+ def column_names(column)
58
+ aliaz = nil
59
+ if column.is_a?(SQL::AliasedExpression)
60
+ aliaz = column.alias
61
+ column = column.expression
62
+ end
63
+ name = case column
64
+ when Symbol
65
+ _, name, symbol_alias = @dataset.send(:split_symbol, column)
66
+ aliaz ||= symbol_alias
67
+ name
68
+ when SQL::Identifier
69
+ column.value
70
+ when SQL::QualifiedIdentifier
71
+ column.column
72
+ end
73
+ [name, aliaz]
74
+ end
75
+
76
+ def column?(column)
77
+ column = column.expression if column.is_a?(SQL::AliasedExpression)
78
+ identifier?(column) || column.is_a?(SQL::ColumnAll)
79
+ end
80
+
81
+ def identifier?(value)
82
+ value.is_a?(Symbol) || value.is_a?(SQL::Identifier) || value.is_a?(SQL::QualifiedIdentifier)
83
+ end
84
+
85
+ def output_identifier(value)
86
+ value = value.value if value.is_a?(SQL::Identifier)
87
+ @dataset.send(:output_identifier, value)
88
+ end
89
+ end
90
+ end
91
+ end
@@ -0,0 +1,53 @@
1
+ # frozen-string-literal: true
2
+
3
+ module Sequel
4
+ module LibSQL
5
+ # Apply SQLite's semantic types after decoding the native storage value.
6
+ class TypeConverter
7
+ FALSE_VALUES = [0, '0', 'false', 'f', 'no', 'n'].freeze
8
+
9
+ def initialize(database)
10
+ @database = database
11
+ end
12
+
13
+ def call(value, type)
14
+ return nil if value.nil?
15
+ return type.call(value) if type.respond_to?(:call)
16
+ case type
17
+ when :boolean
18
+ !FALSE_VALUES.include?(value.is_a?(String) ? value.downcase : value)
19
+ when :date
20
+ value.is_a?(Numeric) ? Date.jd(value.to_i) : Sequel.string_to_date(value)
21
+ when :datetime, :timestamp
22
+ @database.to_application_timestamp(value)
23
+ when :time
24
+ time(value)
25
+ when :decimal
26
+ decimal(value)
27
+ when :integer
28
+ value.to_i
29
+ when :float
30
+ value.to_f
31
+ when :blob
32
+ Sequel.blob(value.to_s)
33
+ else
34
+ value
35
+ end
36
+ end
37
+
38
+ private
39
+
40
+ def time(value)
41
+ return Sequel.string_to_time(value) if value.is_a?(String)
42
+ seconds, fraction = value.divmod(1)
43
+ SQLTime.create(seconds / 3600, (seconds % 3600) / 60, seconds % 60, (fraction * 1_000_000).round)
44
+ end
45
+
46
+ def decimal(value)
47
+ BigDecimal(value.to_s)
48
+ rescue ArgumentError
49
+ value.to_s
50
+ end
51
+ end
52
+ end
53
+ end
@@ -0,0 +1,7 @@
1
+ # frozen-string-literal: true
2
+
3
+ module Sequel
4
+ module LibSQL
5
+ VERSION = '0.3.0'
6
+ end
7
+ end
metadata ADDED
@@ -0,0 +1,90 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: libsql-sequel
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.3.0
5
+ platform: ruby
6
+ authors:
7
+ - Francesco Canessa
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: sequel
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - ">="
17
+ - !ruby/object:Gem::Version
18
+ version: '5.80'
19
+ - - "<"
20
+ - !ruby/object:Gem::Version
21
+ version: '6'
22
+ type: :runtime
23
+ prerelease: false
24
+ version_requirements: !ruby/object:Gem::Requirement
25
+ requirements:
26
+ - - ">="
27
+ - !ruby/object:Gem::Version
28
+ version: '5.80'
29
+ - - "<"
30
+ - !ruby/object:Gem::Version
31
+ version: '6'
32
+ - !ruby/object:Gem::Dependency
33
+ name: turso_libsql
34
+ requirement: !ruby/object:Gem::Requirement
35
+ requirements:
36
+ - - '='
37
+ - !ruby/object:Gem::Version
38
+ version: 0.2.0
39
+ type: :runtime
40
+ prerelease: false
41
+ version_requirements: !ruby/object:Gem::Requirement
42
+ requirements:
43
+ - - '='
44
+ - !ruby/object:Gem::Version
45
+ version: 0.2.0
46
+ description: Sequel SQL, models, migrations, and transactions using the native libSQL
47
+ Ruby driver.
48
+ executables: []
49
+ extensions: []
50
+ extra_rdoc_files: []
51
+ files:
52
+ - LICENSE
53
+ - README.md
54
+ - docs/architecture.md
55
+ - docs/compatibility.md
56
+ - docs/development.md
57
+ - docs/testing-review.md
58
+ - examples/local.rb
59
+ - lib/libsql-sequel.rb
60
+ - lib/sequel/adapters/libsql.rb
61
+ - lib/sequel/adapters/turso.rb
62
+ - lib/sequel/libsql/configuration.rb
63
+ - lib/sequel/libsql/connection.rb
64
+ - lib/sequel/libsql/dataset.rb
65
+ - lib/sequel/libsql/native.rb
66
+ - lib/sequel/libsql/result_types.rb
67
+ - lib/sequel/libsql/type_converter.rb
68
+ - lib/sequel/libsql/version.rb
69
+ homepage: https://github.com/makevoid/libsql-sequel
70
+ licenses:
71
+ - MIT
72
+ metadata: {}
73
+ rdoc_options: []
74
+ require_paths:
75
+ - lib
76
+ required_ruby_version: !ruby/object:Gem::Requirement
77
+ requirements:
78
+ - - ">="
79
+ - !ruby/object:Gem::Version
80
+ version: '3.3'
81
+ required_rubygems_version: !ruby/object:Gem::Requirement
82
+ requirements:
83
+ - - ">="
84
+ - !ruby/object:Gem::Version
85
+ version: '0'
86
+ requirements: []
87
+ rubygems_version: 4.0.10
88
+ specification_version: 4
89
+ summary: A libSQL and Turso adapter for Sequel
90
+ test_files: []