activerecord-quack-adapter 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: 85c40e8e221c6b640fd8989d2cf5255f02057dc71df355e37fd689a81cf519c0
4
+ data.tar.gz: a4df3550a3df4ebf50d1404b131fddcade2710de33440e3c1768f7b8a782f773
5
+ SHA512:
6
+ metadata.gz: ce81bc7fb7a4ea719a14f389f56a70eb3f355f761f198307263c67bddce9d03e4066955ac7dd7e5c86814958a33ee9357482735d538f23f53b6e728e0fbeb339
7
+ data.tar.gz: f185a72163bfaddd2c02217b4f2a67d7f354277ed18fc7fbda3a440c682cf0e38257b8f0bc1c142010c5de64b14df676279c3d6d4bcb8b7870930d7e21507db0
data/LICENSE ADDED
@@ -0,0 +1,45 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 quack-ruby contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
22
+
23
+ Portions adapted from Ruby on Rails:
24
+ Copyright (c) David Heinemeier Hansson
25
+
26
+ Arel originally copyright (c) 2007-2016 Nick Kallen, Bryan Helmkamp, Emilio Tagua, Aaron Patterson
27
+
28
+ Permission is hereby granted, free of charge, to any person obtaining
29
+ a copy of this software and associated documentation files (the
30
+ "Software"), to deal in the Software without restriction, including
31
+ without limitation the rights to use, copy, modify, merge, publish,
32
+ distribute, sublicense, and/or sell copies of the Software, and to
33
+ permit persons to whom the Software is furnished to do so, subject to
34
+ the following conditions:
35
+
36
+ The above copyright notice and this permission notice shall be
37
+ included in all copies or substantial portions of the Software.
38
+
39
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
40
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
41
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
42
+ NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
43
+ LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
44
+ OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
45
+ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
@@ -0,0 +1,22 @@
1
+ Copyright (c) David Heinemeier Hansson
2
+
3
+ Arel originally copyright (c) 2007-2016 Nick Kallen, Bryan Helmkamp, Emilio Tagua, Aaron Patterson
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining
6
+ a copy of this software and associated documentation files (the
7
+ "Software"), to deal in the Software without restriction, including
8
+ without limitation the rights to use, copy, modify, merge, publish,
9
+ distribute, sublicense, and/or sell copies of the Software, and to
10
+ permit persons to whom the Software is furnished to do so, subject to
11
+ the following conditions:
12
+
13
+ The above copyright notice and this permission notice shall be
14
+ included in all copies or substantial portions of the Software.
15
+
16
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
17
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
18
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
19
+ NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
20
+ LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
21
+ OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
22
+ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,193 @@
1
+ # activerecord-quack-adapter
2
+
3
+ Direct Quack protocol adapter for Rails / Active Record 7.2, 8.0 and 8.1,
4
+ backed by [quack-ruby](https://github.com/sidequery/quack-ruby). Connections use
5
+ HTTP(S) rather than ADBC or an embedded DuckDB database.
6
+
7
+ ## Install
8
+
9
+ Add to your Gemfile:
10
+
11
+ ```ruby
12
+ gem "activerecord-quack-adapter", "~> 0.1.0"
13
+ ```
14
+
15
+ The adapter installs `quack-ruby` automatically. Its precompiled gems cover Ruby
16
+ 3.2–3.4 on macOS and Linux glibc, on both Intel/x86-64 and ARM64. The Quack
17
+ extension needs no Cargo or compiler on those combinations. Source installs
18
+ on other platforms require the client's native build prerequisites.
19
+ A Quack server and database must already exist. Supported servers are DuckDB
20
+ 1.5.5 and 2.0.0. Ruby entry points and the `adapter: quack` setting are unchanged.
21
+
22
+ ## Rails configuration and usage
23
+
24
+ A Quack server must already be running. Configure `config/database.yml`:
25
+
26
+ ```yaml
27
+ default: &default
28
+ adapter: quack
29
+ uri: <%= ENV.fetch("QUACK_URI") %>
30
+ auth_token: <%= ENV.fetch("QUACK_TOKEN") %>
31
+ pool: <%= ENV.fetch("RAILS_MAX_THREADS", 5) %>
32
+ timeout: 30 # Seconds per HTTP request, not Rails' pool checkout timeout.
33
+ # ssl_fingerprint: <%= ENV["QUACK_SSL_FINGERPRINT"] %>
34
+ # catalog: analytics
35
+ # schema: main
36
+
37
+ development:
38
+ <<: *default
39
+ test:
40
+ <<: *default
41
+ production:
42
+ <<: *default
43
+ ```
44
+
45
+ `uri` accepts `quack:host:port`, `host:port`, bracketed IPv6, or an HTTP(S) URL.
46
+ Alternatively set `host` and `port`. `database` is accepted as an endpoint alias;
47
+ use `catalog` to select a database attached on the server. Native connection
48
+ options such as `ssl`, headers and protocol-version bounds also work in YAML.
49
+
50
+ For self-signed HTTPS, configure the certificate's SHA-256 `ssl_fingerprint`.
51
+ A pin implies HTTPS and trusts that exact certificate. Without a pin, HTTPS uses
52
+ normal certificate and hostname validation.
53
+
54
+ Models and migrations use the normal Rails interfaces:
55
+
56
+ ```ruby
57
+ class Event < ApplicationRecord
58
+ end
59
+
60
+ class CreateEvents < ActiveRecord::Migration[7.2]
61
+ def change
62
+ create_table :events do |t|
63
+ t.string :name, null: false
64
+ t.bigint :count
65
+ t.decimal :amount, precision: 20, scale: 6
66
+ t.timestamps
67
+ end
68
+ end
69
+ end
70
+
71
+ Event.create!(name: "signup", count: 1)
72
+ Event.where(name: "signup").count
73
+ Event.insert_all!([{name: "signup", count: 2}], returning: %w[id name])
74
+ Event.transaction { Event.where(name: "signup").update_all(count: 3) }
75
+ ```
76
+
77
+ The adapter implements CRUD, bound queries, generated IDs via sequences and
78
+ `RETURNING`, SQL bulk insert/upsert, transactions, table/column/primary-key/index
79
+ inspection, ordinary table/column/index migrations and sequence-aware
80
+ `schema.rb` dumping/loading. Rails owns pooling; each adapter connection owns one
81
+ remote session. There is no second Rust connection pool beneath Active Record.
82
+
83
+ `rails db:migrate` operates on the existing remote database. `db:create` checks
84
+ that the endpoint is accessible and reports it already exists. Provision and
85
+ remove databases on the server; client-side `db:drop`, `db:purge`, `db:reset`, and
86
+ SQL structure dump/load tasks are intentionally unavailable. Use Ruby schema
87
+ format. For an analytics-only secondary connection, use Rails' multiple-database
88
+ configuration with `database_tasks: false` and an abstract model with
89
+ `connects_to`.
90
+
91
+ ## Data types
92
+
93
+ | DuckDB value | Ruby value |
94
+ | --- | --- |
95
+ | Signed/unsigned integers, including 128-bit and BIGNUM | `Integer`, without precision loss |
96
+ | Decimal | `BigDecimal` |
97
+ | Boolean, floating point, text, NULL | `true`/`false`, `Float`, `String`, `nil` |
98
+ | BLOB / other byte payloads | Binary-encoded `String` |
99
+ | Date | Proleptic Gregorian `Date` |
100
+ | Timestamp | `Quack::Timestamp < Time`, retaining SQL units, timezone kind and nanoseconds |
101
+ | Time / time with offset | `Quack::TimeOfDay` (supports `24:00:00`) |
102
+ | Interval | `Quack::Interval(months, days, microseconds)` |
103
+ | Infinite date/timestamp | `Quack::InfiniteTemporal` |
104
+ | List/array, struct | `Array`, string-keyed `Hash` |
105
+ | JSON, UUID, ENUM | `String` (Rails casts JSON to Ruby objects) |
106
+ | MAP | `Quack::Map`, with `entries`, `[]`, `each`, `to_h` |
107
+ | BIT | `Quack::BitString(bits)`, a string of logical 0/1 bits |
108
+ | GEOMETRY | `Quack::Geometry(wkb, crs)`, standard WKB and optional CRS |
109
+ | UNION | `Quack::Union(tag, value, members)`; tag distinguishes a NULL member from SQL NULL |
110
+ | VARIANT | `Quack::Variant`, with a typed `node` tree and logical Ruby `value` |
111
+ | TUPLE / unnamed STRUCT | `Quack::Tuple(values)`, preserving every ordinal field |
112
+
113
+ These logical values support SQL parameters, model assignment and native v1
114
+ append, including values nested in lists, arrays and structs. BIGNUM decoding,
115
+ DuckDB 2.0 type IDs and unnamed field handling are fixed in the packaged crate;
116
+ no separate fork download is needed to build the gem.
117
+
118
+ Read timestamps retain their SQL kind when rebound. Construct one explicitly
119
+ with `Quack::Timestamp.from(time, "TIMESTAMP_NS")` or, on DuckDB 2.0,
120
+ `Quack::Timestamp.from(time, "TIMESTAMPTZ_NS")`. Plain `Time`/`DateTime` values
121
+ use TIMESTAMPTZ when microseconds suffice and TIMESTAMP_NS when nanoseconds are
122
+ needed. Connections initialize `TimeZone = 'UTC'`, including after reconnect.
123
+ Subsequent explicit timezone changes follow DuckDB's normal cast semantics.
124
+ Values beyond a destination's precision are rejected by native append rather
125
+ than silently truncated. Rails writes preserve nine fractional digits.
126
+
127
+ `Quack::Variant.new({"items" => [1, nil, "é"]})` builds a heterogeneous value.
128
+ Use `Quack::Variant.new(value, type: "UBIGINT")` to specify a scalar type.
129
+ `variant.value` returns a Ruby tree; `variant.node` preserves each nested scalar
130
+ type. Assign a new Variant to edit it. DuckDB normalizes a root VARIANT null to
131
+ SQL NULL (`nil`); nulls inside containers remain nested nodes. Geometry uses
132
+ standard WKB rather than a client geometry engine, preserving Z/M, empty values,
133
+ collections and CRS metadata.
134
+
135
+ All storable public type families are exercised on the supported server versions.
136
+ DuckDB 1.5 cannot persist unnamed STRUCT/ROW tuples (even regular SQL INSERT fails);
137
+ their query/parameter roundtrips work, and tuple persistence is tested on 2.0.
138
+ TIMESTAMPTZ_NS is a DuckDB 2.0 type and is unavailable on 1.5. Internal/binder-only
139
+ types and types supplied by optional server extensions are not universal targets.
140
+
141
+ Scalar and logical values support normal create/update/reload, bound queries
142
+ and SQL bulk writes. Lists, structs and nested maps track in-place edits.
143
+ Use explicit SQL migrations for DuckDB types without a Rails migration helper.
144
+
145
+ ## Adapter limits
146
+
147
+ - DuckDB has no savepoints. Joined nested transactions work; `requires_new`
148
+ savepoints and custom isolation levels are unsupported.
149
+ - Foreign-key/check-constraint migration helpers and expression/partial-index
150
+ helpers are unavailable. They raise rather than silently dropping constraints.
151
+ - Schema dumps support the ordinary migration types, including generated-ID
152
+ sequences. Comprehensive complex-type schema dumping is not implemented;
153
+ manage those objects with explicit SQL migrations.
154
+ - Provision/drop remote databases on the server. Client db:drop, db:purge,
155
+ db:reset and SQL structure dump/load tasks are unavailable. Use schema.rb
156
+ for supported migration types.
157
+ - Statements are never replayed after a session/transport failure, including
158
+ through Rails' allow_retry. Handle the failure and reconnect explicitly.
159
+ - Rails owns pooling; one adapter connection owns one remote session. Native
160
+ network waits release the GVL. Create/check out connections after forking.
161
+
162
+ ## Development
163
+
164
+ ```sh
165
+ CARGO="$PWD/script/cached-cargo" bundle install
166
+ script/test
167
+ BUNDLE_GEMFILE=gemfiles/active_record_72.gemfile CARGO="$PWD/script/cached-cargo" bundle install
168
+ BUNDLE_GEMFILE=gemfiles/active_record_72.gemfile script/test
169
+ BUNDLE_GEMFILE=gemfiles/active_record_80.gemfile CARGO="$PWD/script/cached-cargo" bundle install
170
+ BUNDLE_GEMFILE=gemfiles/active_record_80.gemfile script/test
171
+ bundle exec rake package
172
+ ```
173
+
174
+ Tests use uv to start real DuckDB 1.5.5/v1 and 2.0.0/v3 servers and stop them
175
+ after each run. They exercise model/result/bind roundtrips across all storable
176
+ public type families, nested mutation tracking, CRUD, bulk insert/upsert,
177
+ transactions, pooling, metadata, schema migrations/dumps and a Rails app that
178
+ boots from database.yml and serves database-backed POST/GET requests.
179
+
180
+ CI is configured for all three Active Record series on Linux/macOS. The
181
+ client's separate suite covers protocol, TLS, lifecycle and native v1 append.
182
+ The `Release` workflow builds the adapter gem and tests installed packages
183
+ against all three Rails series with Cargo and rustc blocked. Dispatch it from
184
+ main with a patch, minor or major bump to update versions and locks, commit/tag,
185
+ test, publish through RubyGems Trusted Publishing, and create a GitHub Release.
186
+ An optional `release_tag` publishes an existing matching tag without a bump.
187
+ Publish the matching client version first; update the client dependency when
188
+ moving to a new client minor series.
189
+
190
+ ## License
191
+
192
+ MIT. Adapted Active Record quoting/bulk-insert code retains the upstream
193
+ notice in LICENSE-ActiveRecord. Quack's native dependency has its own licenses.
@@ -0,0 +1,37 @@
1
+ module ActiveRecord
2
+ module ConnectionAdapters
3
+ module Quack
4
+ # Rails 7.2 predates the perform_query/cast_result adapter hooks.
5
+ module LegacyDatabaseStatements
6
+ def internal_exec_query(sql, name = "SQL", binds = [], prepare: false, async: false, allow_retry: false)
7
+ sql = transform_query(sql)
8
+ check_if_write_query(sql)
9
+ mark_transaction_written_if_write(sql)
10
+ cast_result(legacy_raw_query(sql, name, binds, prepare: prepare, async: async))
11
+ end
12
+
13
+ def exec_delete(sql, name = "SQL", binds = [])
14
+ sql = transform_query(sql)
15
+ check_if_write_query(sql)
16
+ mark_transaction_written_if_write(sql)
17
+ affected_rows(legacy_raw_query(sql, name, binds))
18
+ end
19
+ alias exec_update exec_delete
20
+
21
+ private
22
+ def raw_execute(sql, name, async: false, allow_retry: false, materialize_transactions: true)
23
+ legacy_raw_query(sql, name, [], async: async, materialize_transactions: materialize_transactions)
24
+ end
25
+
26
+ def legacy_raw_query(sql, name, binds, prepare: false, async: false, materialize_transactions: true)
27
+ casted = type_casted_binds(binds)
28
+ log(sql, name, binds, casted, async: async) do |payload|
29
+ with_raw_connection(allow_retry: false, materialize_transactions: materialize_transactions) do |connection|
30
+ perform_query(connection, sql, binds, casted, prepare: prepare, notification_payload: payload)
31
+ end
32
+ end
33
+ end
34
+ end
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,25 @@
1
+ require "active_record/connection_adapters/abstract/schema_dumper"
2
+
3
+ module ActiveRecord
4
+ module ConnectionAdapters
5
+ module Quack
6
+ class SchemaDumper < ConnectionAdapters::SchemaDumper
7
+ private
8
+ def tables(stream)
9
+ # Sequence-backed primary keys must survive loading schema.rb into a
10
+ # fresh database, including sequences supplied by the application.
11
+ @connection.select_values(<<~SQL, "SCHEMA").each do |sql|
12
+ SELECT sql FROM duckdb_sequences()
13
+ WHERE database_name = current_database() AND schema_name = current_schema()
14
+ ORDER BY sequence_name
15
+ SQL
16
+ statement = sql.sub(/\ACREATE SEQUENCE\b/i, "CREATE SEQUENCE IF NOT EXISTS")
17
+ stream.puts " execute #{statement.inspect}"
18
+ end
19
+ stream.puts
20
+ super
21
+ end
22
+ end
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,143 @@
1
+ module ActiveRecord
2
+ module ConnectionAdapters
3
+ module Quack
4
+ class TableDefinition < ConnectionAdapters::TableDefinition
5
+ def foreign_key(*)
6
+ raise ActiveRecord::StatementInvalid, "Quack foreign-key migration helpers are unsupported; use explicit DuckDB SQL"
7
+ end
8
+
9
+ def check_constraint(*)
10
+ raise ActiveRecord::StatementInvalid, "Quack check-constraint migration helpers are unsupported; use explicit DuckDB SQL"
11
+ end
12
+ end
13
+
14
+ module SchemaStatements
15
+ def add_foreign_key(*)
16
+ raise ActiveRecord::StatementInvalid, "Quack foreign-key migration helpers are unsupported; use explicit DuckDB SQL"
17
+ end
18
+ alias remove_foreign_key add_foreign_key
19
+
20
+ def add_check_constraint(*)
21
+ raise ActiveRecord::StatementInvalid, "Quack check-constraint migration helpers are unsupported; use explicit DuckDB SQL"
22
+ end
23
+ alias remove_check_constraint add_check_constraint
24
+
25
+ def create_table(table_name, id: :primary_key, primary_key: nil, **options, &block)
26
+ if [:primary_key, :bigint, :integer].include?(id) && !primary_key.is_a?(Array) && !options[:as] && !options.key?(:default)
27
+ parts = ::Quack::SQL.identifier_parts(table_name)
28
+ sequence = parts[0...-1] + ["#{parts.last}_#{primary_key || 'id'}_seq"]
29
+ sequence_name = sequence.map { |part| quote_column_name(part) }.join(".")
30
+ execute("CREATE SEQUENCE IF NOT EXISTS #{sequence_name}")
31
+ id = {type: id == :integer ? :integer : :bigint, default: -> { "nextval(#{quote(sequence_name)})" }}
32
+ end
33
+ super(table_name, id: id, primary_key: primary_key, **options, &block)
34
+ end
35
+
36
+ def primary_keys(table_name)
37
+ row = internal_exec_query("SELECT constraint_column_names FROM duckdb_constraints() WHERE #{catalog_scope(table_name)} AND constraint_type = 'PRIMARY KEY'", "SCHEMA").first
38
+ row ? row.fetch("constraint_column_names") : []
39
+ end
40
+
41
+ def indexes(table_name)
42
+ internal_exec_query("SELECT index_name, is_unique, expressions, sql FROM duckdb_indexes() WHERE #{catalog_scope(table_name)}", "SCHEMA").map do |row|
43
+ expressions = row.fetch("sql")[/\bON\b.*?\((.*)\)\s*;?\z/im, 1]
44
+ parts = expressions.to_s.split(/,\s*/)
45
+ simple = parts.all? { |part| part.match?(/\A(?:"(?:[^"]|"")*"|[A-Za-z_][A-Za-z_0-9]*)\z/) }
46
+ columns = simple ? parts.map { |part| part.delete_prefix('"').delete_suffix('"').gsub('""', '"') } : expressions
47
+ IndexDefinition.new(table_name, row.fetch("index_name"), row.fetch("is_unique"), columns)
48
+ end
49
+ end
50
+
51
+ def rename_table(table_name, new_name, **)
52
+ execute("ALTER TABLE #{quote_table_name(table_name)} RENAME TO #{quote_column_name(new_name)}")
53
+ schema_cache.clear_data_source_cache!(table_name.to_s)
54
+ schema_cache.clear_data_source_cache!(new_name.to_s)
55
+ end
56
+
57
+ def change_column_default(table_name, column_name, default_or_changes)
58
+ default = extract_new_default_value(default_or_changes)
59
+ column = column_for(table_name, column_name)
60
+ execute("ALTER TABLE #{quote_table_name(table_name)} ALTER COLUMN #{quote_column_name(column_name)} SET DEFAULT #{quote_default_expression(default, column)}")
61
+ end
62
+
63
+ def change_column_null(table_name, column_name, null, default = nil)
64
+ validate_change_column_null_argument!(null)
65
+ unless null || default.nil?
66
+ execute("UPDATE #{quote_table_name(table_name)} SET #{quote_column_name(column_name)} = #{quote(default)} WHERE #{quote_column_name(column_name)} IS NULL")
67
+ end
68
+ execute("ALTER TABLE #{quote_table_name(table_name)} ALTER COLUMN #{quote_column_name(column_name)} #{null ? 'DROP' : 'SET'} NOT NULL")
69
+ end
70
+
71
+ def change_column(table_name, column_name, type, **options)
72
+ execute("ALTER TABLE #{quote_table_name(table_name)} ALTER COLUMN #{quote_column_name(column_name)} TYPE #{type_to_sql(type, **options)}")
73
+ change_column_default(table_name, column_name, options[:default]) if options.key?(:default)
74
+ change_column_null(table_name, column_name, options[:null]) if options.key?(:null)
75
+ end
76
+
77
+ def rename_column(table_name, column_name, new_column_name)
78
+ execute("ALTER TABLE #{quote_table_name(table_name)} RENAME COLUMN #{quote_column_name(column_name)} TO #{quote_column_name(new_column_name)}")
79
+ end
80
+
81
+ def remove_index(table_name, column_name = nil, **options)
82
+ return if options[:if_exists] && !index_exists?(table_name, column_name, **options)
83
+ name = index_name_for_remove(table_name, column_name, options)
84
+ execute("DROP INDEX #{quote_table_name(name)}")
85
+ end
86
+
87
+ def type_to_sql(type, limit: nil, **options)
88
+ if type.to_s == "integer"
89
+ return {1 => "TINYINT", 2 => "SMALLINT", 4 => "INTEGER", 8 => "BIGINT"}.fetch(limit || 4) { raise ArgumentError, "unsupported integer limit: #{limit}" }
90
+ end
91
+ super(type, limit: limit, **options)
92
+ end
93
+
94
+ def create_schema_dumper(options)
95
+ Quack::SchemaDumper.create(self, options)
96
+ end
97
+
98
+ private
99
+ def create_table_definition(name, **options)
100
+ Quack::TableDefinition.new(self, name, **options)
101
+ end
102
+
103
+ def catalog_scope(name)
104
+ parts = ::Quack::SQL.identifier_parts(name)
105
+ table = parts.pop
106
+ schema = parts.pop
107
+ catalog = parts.pop
108
+ "database_name = #{catalog ? quote(catalog) : 'current_database()'} AND schema_name = #{schema ? quote(schema) : 'current_schema()'} AND table_name = #{quote(table)}"
109
+ end
110
+
111
+ def data_source_sql(name = nil, type: nil)
112
+ parts = name ? ::Quack::SQL.identifier_parts(name) : []
113
+ table, schema, catalog = parts.reverse
114
+ sql = +"SELECT table_name FROM information_schema.tables WHERE table_catalog = #{catalog ? quote(catalog) : 'current_database()'} AND table_schema = #{schema ? quote(schema) : 'current_schema()'}"
115
+ sql << " AND table_name = #{quote(table)}" if table
116
+ sql << " AND table_type = #{quote(type)}" if type
117
+ sql << " ORDER BY table_name"
118
+ end
119
+
120
+ def column_definitions(table_name)
121
+ internal_exec_query("SELECT column_name, data_type, column_default, is_nullable FROM duckdb_columns() WHERE #{catalog_scope(table_name)} ORDER BY column_index", "SCHEMA").to_a
122
+ end
123
+
124
+ def new_column_from_field(_table_name, field, _definitions)
125
+ sql_type = field.fetch("data_type")
126
+ metadata = fetch_type_metadata(sql_type)
127
+ cast_type = lookup_cast_type(sql_type)
128
+ raw_default = field["column_default"]
129
+ literal = raw_default && raw_default.match?(/\A(?:NULL|true|false|[-+]?\d+(?:\.\d+)?|'(?:[^']|'')*'(?:::[\w ]+)?)\z/i)
130
+ default = if literal
131
+ text = raw_default.sub(/::[\w ]+\z/, "")
132
+ text = text[1...-1].gsub("''", "'") if text.start_with?("'")
133
+ raw_default.casecmp?("NULL") ? nil : text
134
+ end
135
+ default_function = raw_default unless literal
136
+ args = [field.fetch("column_name"), default, metadata, field.fetch("is_nullable"), default_function]
137
+ args.insert(1, cast_type) if ActiveRecord.gem_version >= Gem::Version.new("8.1")
138
+ Column.new(*args)
139
+ end
140
+ end
141
+ end
142
+ end
143
+ end
@@ -0,0 +1,21 @@
1
+ module ActiveRecord
2
+ module ConnectionAdapters
3
+ module Quack
4
+ class DateTimeType < Type::DateTime
5
+ def serialize_cast_value(value)
6
+ # Active Support's DateTime#getutc converts calendar fields to Time.
7
+ # Normalize the calendar first so Julian dates keep their instant.
8
+ value = value.new_start(Date::GREGORIAN) if value.is_a?(::DateTime)
9
+ super(value)
10
+ end
11
+ end
12
+
13
+ class ValueType < Type::Value
14
+ def cast(value) = ::Quack::Codec.copy(value)
15
+ alias deserialize cast
16
+ def mutable? = true
17
+ def changed_in_place?(raw_old_value, new_value) = raw_old_value != new_value
18
+ end
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,253 @@
1
+ require "active_record/connection_adapters/abstract_adapter"
2
+ require "quack"
3
+ require_relative "quack/schema_statements"
4
+ require_relative "quack/schema_dumper"
5
+ require_relative "quack/legacy_database_statements"
6
+ require_relative "quack/value_type"
7
+
8
+ module ActiveRecord
9
+ module ConnectionAdapters
10
+ class QuackAdapter < AbstractAdapter
11
+ ADAPTER_NAME = "Quack"
12
+ include Quack::SchemaStatements
13
+ include Quack::LegacyDatabaseStatements if ActiveRecord.gem_version < Gem::Version.new("8.0")
14
+
15
+ NATIVE_DATABASE_TYPES = {
16
+ primary_key: "BIGINT PRIMARY KEY",
17
+ string: {name: "VARCHAR"}, text: {name: "VARCHAR"},
18
+ integer: {name: "INTEGER"}, bigint: {name: "BIGINT"},
19
+ float: {name: "DOUBLE"}, decimal: {name: "DECIMAL"},
20
+ datetime: {name: "TIMESTAMP"}, timestamp: {name: "TIMESTAMP"},
21
+ time: {name: "TIME"}, date: {name: "DATE"},
22
+ binary: {name: "BLOB"}, boolean: {name: "BOOLEAN"},
23
+ json: {name: "JSON"}, uuid: {name: "UUID"}
24
+ }.freeze
25
+
26
+ class << self
27
+ def new_client(config)
28
+ uri = config[:uri] || config[:database]
29
+ unless uri
30
+ host = config.fetch(:host, "localhost")
31
+ host = "[#{host}]" if host.include?(":") && !host.start_with?("[")
32
+ uri = "quack:#{host}:#{config.fetch(:port, 9494)}"
33
+ end
34
+ options = config.stringify_keys.slice(*::Quack::Connection::OPTIONS).symbolize_keys
35
+ ::Quack::Connection.new(uri, **options)
36
+ end
37
+
38
+ def native_database_types = NATIVE_DATABASE_TYPES
39
+ def quote_column_name(name) = ::Quack::SQL.identifier(name)
40
+ def quote_table_name(name) = ::Quack::SQL.identifier_parts(name).map { |part| quote_column_name(part) }.join(".")
41
+
42
+ private
43
+ def initialize_type_map(map)
44
+ super
45
+ map.register_type(/\ATIMESTAMP(?:_S|_MS|_NS|TZ|TZ_NS| WITH TIME ZONE)?\z/i, Quack::DateTimeType.new)
46
+ {"TINYINT" => 1, "SMALLINT" => 2, "INTEGER" => 4, "BIGINT" => 8,
47
+ "UTINYINT" => 2, "USMALLINT" => 4, "UINTEGER" => 8,
48
+ "UBIGINT" => 16, "HUGEINT" => 16, "UHUGEINT" => 32}.each do |name, limit|
49
+ map.register_type(/\A#{name}\z/i, Type::Integer.new(limit: limit))
50
+ end
51
+ map.register_type(/\AUUID\z/i, Type::String.new)
52
+ map.register_type(/(?:\[\d*\]\z|\A(?:STRUCT|TUPLE|MAP|UNION|VARIANT|BIT|GEOMETRY|BIGNUM)\b)/i, Quack::ValueType.new)
53
+ map.register_type(/\AINTERVAL\z/i, Quack::ValueType.new)
54
+ end
55
+ end
56
+ TYPE_MAP = Type::TypeMap.new.tap { |map| initialize_type_map(map) }
57
+
58
+ def supports_ddl_transactions? = true
59
+ def supports_savepoints? = false
60
+ def supports_transaction_isolation? = false
61
+ def supports_lazy_transactions? = true
62
+ def supports_insert_returning? = true
63
+ def supports_insert_on_duplicate_skip? = true
64
+ def supports_insert_on_duplicate_update? = true
65
+ def supports_insert_conflict_target? = true
66
+ def supports_views? = true
67
+ def supports_json? = true
68
+ def supports_common_table_expressions? = true
69
+ def supports_datetime_with_precision? = true
70
+ def supports_explain? = true
71
+ def native_database_types = NATIVE_DATABASE_TYPES
72
+ def connected? = !!@raw_connection&.connected?
73
+
74
+ def active?
75
+ @lock.synchronize do
76
+ return false unless connected?
77
+ @raw_connection.query("SELECT 1")
78
+ verified!
79
+ true
80
+ end
81
+ rescue ::Quack::Error
82
+ false
83
+ end
84
+
85
+ def disconnect!
86
+ @lock.synchronize do
87
+ super
88
+ begin
89
+ @raw_connection&.close
90
+ rescue ::Quack::ConnectionError, ::Quack::ProtocolError, ::Quack::ForkError
91
+ # Retire a failed or inherited connection without replaying SQL.
92
+ ensure
93
+ @raw_connection = nil
94
+ end
95
+ end
96
+ end
97
+
98
+ def quote_column_name(name) = self.class.quote_column_name(name)
99
+ def quote_table_name(name) = self.class.quote_table_name(name)
100
+ def quote_table_name_for_assignment(_table, attr) = quote_column_name(attr)
101
+ def quote_string(text) = text.gsub("'", "''")
102
+ def quoted_true = "TRUE"
103
+ def quoted_false = "FALSE"
104
+ def unquoted_true = true
105
+ def unquoted_false = false
106
+ def quoted_binary(value) = ::Quack::SQL.literal(value.to_s.b)
107
+ # DuckDB resolves the bare CURRENT_TIMESTAMP keyword as a column inside
108
+ # ON CONFLICT's update clause; the equivalent function avoids that.
109
+ def high_precision_current_timestamp = Arel.sql("now()")
110
+
111
+ def quote(value)
112
+ case value
113
+ when String, BigDecimal, Integer, Float
114
+ ::Quack::SQL.literal(value)
115
+ when ::Quack::Interval, ::Quack::TimeOfDay, ::Quack::InfiniteTemporal, Array, Hash
116
+ ::Quack::SQL.literal(value)
117
+ when ::Quack::Map, ::Quack::Union, ::Quack::Variant, ::Quack::Tuple, ::Quack::BitString, ::Quack::Geometry
118
+ ::Quack::SQL.literal(value)
119
+ else super
120
+ end
121
+ end
122
+
123
+ def type_cast(value)
124
+ case value
125
+ when BigDecimal, ::Quack::Interval, ::Quack::TimeOfDay, ::Quack::InfiniteTemporal, Array, Hash,
126
+ ::Quack::Map, ::Quack::Union, ::Quack::Variant, ::Quack::Tuple, ::Quack::BitString, ::Quack::Geometry
127
+ value
128
+ else super
129
+ end
130
+ end
131
+
132
+ def quoted_time(value)
133
+ inner = value.is_a?(Type::Time::Value) ? value.__getobj__ : value
134
+ inner.is_a?(::Quack::TimeOfDay) ? inner.to_s : super
135
+ end
136
+
137
+ def quoted_date(value)
138
+ # AbstractAdapter formats only six fractional digits, even for
139
+ # TIMESTAMP_NS columns. DuckDB accepts all nine from a string bind.
140
+ return super unless value.is_a?(Time) || value.is_a?(DateTime)
141
+ normalized = if value.is_a?(Time)
142
+ default_timezone == :utc ? value.getutc : value.getlocal
143
+ else
144
+ (default_timezone == :utc ? value.new_offset(0) : value).new_start(Date::GREGORIAN)
145
+ end
146
+ normalized.strftime("%Y-%m-%d %H:%M:%S.%N")
147
+ end
148
+
149
+ def begin_db_transaction
150
+ internal_execute("BEGIN TRANSACTION", "TRANSACTION", materialize_transactions: false)
151
+ end
152
+ def commit_db_transaction
153
+ internal_execute("COMMIT", "TRANSACTION", materialize_transactions: false)
154
+ end
155
+ def exec_rollback_db_transaction
156
+ internal_execute("ROLLBACK", "TRANSACTION", materialize_transactions: false)
157
+ end
158
+ def create_savepoint(*)
159
+ raise ActiveRecord::StatementInvalid, "DuckDB does not support savepoints"
160
+ end
161
+
162
+ READ_QUERY = AbstractAdapter.build_read_query_regexp(:describe, :show, :summarize, :explain)
163
+ def write_query?(sql) = !READ_QUERY.match?(sql)
164
+
165
+ # Adapted from Active Record's PostgreSQL adapter (MIT licensed).
166
+ def build_insert_sql(insert)
167
+ sql = +"INSERT #{insert.into} #{insert.values_list}"
168
+ if insert.skip_duplicates?
169
+ sql << " ON CONFLICT #{insert.conflict_target} DO NOTHING"
170
+ elsif insert.update_duplicates?
171
+ sql << " ON CONFLICT #{insert.conflict_target} DO UPDATE SET "
172
+ if insert.raw_update_sql?
173
+ sql << insert.raw_update_sql
174
+ else
175
+ sql << insert.touch_model_timestamps_unless { |column| "#{insert.model.quoted_table_name}.#{column} IS NOT DISTINCT FROM excluded.#{column}" }
176
+ sql << insert.updatable_columns.map { |column| "#{column}=excluded.#{column}" }.join(",")
177
+ end
178
+ end
179
+ sql << " RETURNING #{insert.returning}" if insert.returning
180
+ sql
181
+ end
182
+
183
+ def explain(arel, binds = [], _options = [])
184
+ internal_exec_query("EXPLAIN #{to_sql(arel, binds)}", "EXPLAIN", binds).rows.map(&:last).join("\n")
185
+ end
186
+
187
+ private
188
+ if ActiveRecord.gem_version >= Gem::Version.new("8.0")
189
+ def raw_execute(*args, **options)
190
+ super(*args, **options.merge(allow_retry: false))
191
+ end
192
+ end
193
+ def connect
194
+ @raw_connection = self.class.new_client(@config)
195
+ end
196
+ def reconnect
197
+ disconnect!
198
+ connect
199
+ end
200
+ def get_database_version
201
+ raw_connection.info.fetch("server_version").to_s.delete_prefix("v")
202
+ end
203
+ def configure_connection
204
+ super
205
+ @raw_connection.execute("USE #{quote_table_name(@config[:catalog])}") if @config[:catalog]
206
+ @raw_connection.execute("SET schema = #{quote(@config[:schema])}") if @config[:schema]
207
+ @raw_connection.execute("SET TimeZone = 'UTC'")
208
+ end
209
+ def perform_query(raw_connection, sql, binds, type_casted_binds, prepare:, notification_payload:, batch: false)
210
+ # Binds are substituted with the same quoting rules as unprepared SQL.
211
+ # No Rust pool: the AR pool owns each session and transaction affinity.
212
+ query = ::Quack::SQL.bind(sql, type_casted_binds.empty? ? nil : type_casted_binds.map { |v| v.is_a?(Type::Binary::Data) ? v.to_s.b : v })
213
+ result = raw_connection.query(query)
214
+ verified!
215
+ notification_payload[:row_count] = result.rows.length
216
+ notification_payload[:affected_rows] = result.affected_rows
217
+ result
218
+ end
219
+ def cast_result(result)
220
+ types = {}
221
+ result.column_metadata.each_with_index do |column, index|
222
+ types[index] = types[column.fetch("name")] = result_type(column)
223
+ end
224
+ ActiveRecord::Result.new(result.columns, result.rows, types)
225
+ end
226
+ def affected_rows(result) = result.affected_rows
227
+ def returning_column_values(result) = result.rows.first
228
+ def result_type(column)
229
+ return lookup_cast_type("JSON") if column["alias"]&.casecmp?("JSON")
230
+ id = column.fetch("type")
231
+ if id == "Decimal"
232
+ return lookup_cast_type("DECIMAL(#{column.fetch('precision')},#{column.fetch('scale')})")
233
+ end
234
+ sql_type = {"TimestampTz" => "TIMESTAMP", "TimestampTzNs" => "TIMESTAMP", "TimestampSec" => "TIMESTAMP", "TimestampMs" => "TIMESTAMP", "TimestampNs" => "TIMESTAMP", "Double" => "DOUBLE", "SqlNull" => "NULL"}.fetch(id, id.upcase)
235
+ lookup_cast_type(sql_type)
236
+ end
237
+ def translate_exception(exception, message:, sql:, binds:)
238
+ klass = case exception
239
+ when ::Quack::ConnectionError, ::Quack::ForkError then ActiveRecord::ConnectionFailed
240
+ when ::Quack::TimeoutError then ActiveRecord::AdapterTimeout
241
+ when ::Quack::ServerError
242
+ case exception.message
243
+ when /PRIMARY KEY|UNIQUE constraint|Duplicate key/i then ActiveRecord::RecordNotUnique
244
+ when /NOT NULL constraint/i then ActiveRecord::NotNullViolation
245
+ when /FOREIGN KEY constraint|violates foreign key/i then ActiveRecord::InvalidForeignKey
246
+ when /Transaction conflict|Conflict on (?:update|tuple)/i then ActiveRecord::SerializationFailure
247
+ end
248
+ end
249
+ klass ? klass.new(message, sql: sql, binds: binds, connection_pool: @pool) : super
250
+ end
251
+ end
252
+ end
253
+ end
@@ -0,0 +1,43 @@
1
+ require "active_record/tasks/database_tasks"
2
+
3
+ module ActiveRecord
4
+ module Tasks
5
+ parent = defined?(AbstractTasks) ? AbstractTasks : Object
6
+ class QuackDatabaseTasks < parent
7
+ def self.using_database_configurations? = true
8
+
9
+ def initialize(config, *)
10
+ @configuration_hash = config.respond_to?(:configuration_hash) ? config.configuration_hash : config
11
+ super(config) if defined?(AbstractTasks)
12
+ end
13
+
14
+ def create
15
+ require "active_record/connection_adapters/quack_adapter"
16
+ config = @configuration_hash.symbolize_keys
17
+ connection = ConnectionAdapters::QuackAdapter.new_client(config)
18
+ begin
19
+ connection.execute("USE #{ConnectionAdapters::QuackAdapter.quote_table_name(config[:catalog])}") if config[:catalog]
20
+ connection.execute("SELECT 1")
21
+ ensure
22
+ connection.close
23
+ end
24
+ # The endpoint's database is provisioned by its server operator.
25
+ raise ActiveRecord::DatabaseAlreadyExists
26
+ end
27
+
28
+ def drop
29
+ raise DatabaseNotSupported, "Quack connects to an existing remote database; provision or remove it on the server"
30
+ end
31
+ alias purge drop
32
+
33
+ def structure_dump(*)
34
+ raise DatabaseNotSupported, "Quack supports schema.rb; use schema_format = :ruby"
35
+ end
36
+ alias structure_load structure_dump
37
+ def charset = "UTF-8"
38
+ def collation = nil
39
+ end
40
+
41
+ DatabaseTasks.register_task(/\Aquack\z/, QuackDatabaseTasks)
42
+ end
43
+ end
@@ -0,0 +1,7 @@
1
+ require "active_record"
2
+ require_relative "active_record/tasks/quack_database_tasks"
3
+
4
+ ActiveRecord::ConnectionAdapters.register(
5
+ "quack", "ActiveRecord::ConnectionAdapters::QuackAdapter",
6
+ "active_record/connection_adapters/quack_adapter"
7
+ )
metadata ADDED
@@ -0,0 +1,89 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: activerecord-quack-adapter
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Nico
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2026-10-23 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: quack-ruby
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - "~>"
18
+ - !ruby/object:Gem::Version
19
+ version: 0.1.0
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - "~>"
25
+ - !ruby/object:Gem::Version
26
+ version: 0.1.0
27
+ - !ruby/object:Gem::Dependency
28
+ name: activerecord
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - ">="
32
+ - !ruby/object:Gem::Version
33
+ version: '7.2'
34
+ - - "<"
35
+ - !ruby/object:Gem::Version
36
+ version: '8.2'
37
+ type: :runtime
38
+ prerelease: false
39
+ version_requirements: !ruby/object:Gem::Requirement
40
+ requirements:
41
+ - - ">="
42
+ - !ruby/object:Gem::Version
43
+ version: '7.2'
44
+ - - "<"
45
+ - !ruby/object:Gem::Version
46
+ version: '8.2'
47
+ description:
48
+ email:
49
+ executables: []
50
+ extensions: []
51
+ extra_rdoc_files: []
52
+ files:
53
+ - LICENSE
54
+ - LICENSE-ActiveRecord
55
+ - README.md
56
+ - lib/active_record/connection_adapters/quack/legacy_database_statements.rb
57
+ - lib/active_record/connection_adapters/quack/schema_dumper.rb
58
+ - lib/active_record/connection_adapters/quack/schema_statements.rb
59
+ - lib/active_record/connection_adapters/quack/value_type.rb
60
+ - lib/active_record/connection_adapters/quack_adapter.rb
61
+ - lib/active_record/tasks/quack_database_tasks.rb
62
+ - lib/activerecord-quack-adapter.rb
63
+ homepage: https://github.com/sidequery/activerecord-quack-adapter
64
+ licenses:
65
+ - MIT
66
+ metadata:
67
+ source_code_uri: https://github.com/sidequery/activerecord-quack-adapter
68
+ bug_tracker_uri: https://github.com/sidequery/activerecord-quack-adapter/issues
69
+ rubygems_mfa_required: 'true'
70
+ post_install_message:
71
+ rdoc_options: []
72
+ require_paths:
73
+ - lib
74
+ required_ruby_version: !ruby/object:Gem::Requirement
75
+ requirements:
76
+ - - ">="
77
+ - !ruby/object:Gem::Version
78
+ version: '3.2'
79
+ required_rubygems_version: !ruby/object:Gem::Requirement
80
+ requirements:
81
+ - - ">="
82
+ - !ruby/object:Gem::Version
83
+ version: '0'
84
+ requirements: []
85
+ rubygems_version: 3.5.22
86
+ signing_key:
87
+ specification_version: 4
88
+ summary: Direct Quack protocol adapter for Active Record
89
+ test_files: []