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 +7 -0
- data/LICENSE +45 -0
- data/LICENSE-ActiveRecord +22 -0
- data/README.md +193 -0
- data/lib/active_record/connection_adapters/quack/legacy_database_statements.rb +37 -0
- data/lib/active_record/connection_adapters/quack/schema_dumper.rb +25 -0
- data/lib/active_record/connection_adapters/quack/schema_statements.rb +143 -0
- data/lib/active_record/connection_adapters/quack/value_type.rb +21 -0
- data/lib/active_record/connection_adapters/quack_adapter.rb +253 -0
- data/lib/active_record/tasks/quack_database_tasks.rb +43 -0
- data/lib/activerecord-quack-adapter.rb +7 -0
- metadata +89 -0
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
|
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: []
|