nusadb 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/README.md +144 -0
- data/lib/active_record/connection_adapters/nusadb_adapter.rb +355 -0
- data/lib/nusadb/connection.rb +537 -0
- data/lib/nusadb/protocol.rb +210 -0
- data/lib/nusadb/scram.rb +82 -0
- data/lib/nusadb.rb +23 -0
- metadata +52 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 348237dfc3da33f2065d5b1b25a669f2d3fc0455e5b45711bf72bfebc323db58
|
|
4
|
+
data.tar.gz: d742fe2b9d12081572639b277a58b900597c4ee54f6407b7c164ab6ae6f27872
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 05e1433bcd6c79b1fe9ec1af9599f9f089c2ee2d5b00b622b80396261bda75f3c44b0124c84655b2c8a7bd0718540548a39a4b7e1dfca92bd919af5e59a79972
|
|
7
|
+
data.tar.gz: c30412a527d60798606859ab97055ff29c7008bb108a17c9463f70931b2058948e71281f55fa200479a7d3a64758e085005e212e2e3f5ef0ec1f3c5f842117bf
|
data/README.md
ADDED
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
# nusadb — Ruby driver for NusaDB
|
|
2
|
+
|
|
3
|
+
A pure-Ruby client (standard library only) that speaks the
|
|
4
|
+
[Nusa Wire Protocol](../../docs/wire-protocol.md) (`PROTOCOL_VERSION 1.1`) directly
|
|
5
|
+
over a socket. SCRAM-SHA-256 uses Ruby's bundled OpenSSL. Requires Ruby 2.7+.
|
|
6
|
+
`Result#column_types` reports each column's NusaDB type name (protocol 1.1).
|
|
7
|
+
|
|
8
|
+
## Install
|
|
9
|
+
|
|
10
|
+
```bash
|
|
11
|
+
gem install nusadb
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
Or point `$LOAD_PATH` at `drivers/ruby/lib` and `require 'nusadb'`.
|
|
15
|
+
|
|
16
|
+
## Usage
|
|
17
|
+
|
|
18
|
+
```ruby
|
|
19
|
+
require 'nusadb'
|
|
20
|
+
|
|
21
|
+
conn = NusaDB.connect(host: '127.0.0.1', port: 5678, user: 'nusa-root', database: 'nusadb')
|
|
22
|
+
|
|
23
|
+
conn.query('CREATE TABLE t (id INT NOT NULL, name TEXT)')
|
|
24
|
+
conn.execute('INSERT INTO t VALUES ($1, $2)', [1, 'alice'])
|
|
25
|
+
|
|
26
|
+
result = conn.query('SELECT id, name FROM t WHERE id = $1', [1])
|
|
27
|
+
result.each { |row| puts "#{row['id']} #{row['name']}" } # hash per row
|
|
28
|
+
p result.rows # [[1, "alice"]] (INT -> Integer)
|
|
29
|
+
|
|
30
|
+
conn.close
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
### Value types
|
|
34
|
+
|
|
35
|
+
Each cell decodes to the natural Ruby type for its protocol 1.1 type tag: `BOOL` → `true`/`false`,
|
|
36
|
+
`INT` → `Integer`, `FLOAT` → `Float`, `NUMERIC` → `BigDecimal`, `DATE` → `Date`, `TIMESTAMP`
|
|
37
|
+
(and `TIMESTAMPTZ`) → `Time`, `JSON` → the parsed value, `ARRAY` → `Array` (elements stay strings —
|
|
38
|
+
the wire array tag carries no element type), `BYTEA` → a binary `String`. `TEXT`, `UUID`, `TIME`,
|
|
39
|
+
and `INTERVAL` stay UTF-8 strings. A value that does not parse as its tag falls back to the raw
|
|
40
|
+
string, so an unexpected wire form never raises.
|
|
41
|
+
|
|
42
|
+
### Parameters
|
|
43
|
+
|
|
44
|
+
Placeholders are positional **`$1`, `$2`, …**; pass values as the second argument.
|
|
45
|
+
`nil` is SQL `NULL`. Result cells are strings (`nil` for SQL NULL).
|
|
46
|
+
|
|
47
|
+
### Prepared statements
|
|
48
|
+
|
|
49
|
+
```ruby
|
|
50
|
+
stmt = conn.prepare('INSERT INTO t VALUES ($1, $2)')
|
|
51
|
+
stmt.execute([1, 'a'])
|
|
52
|
+
stmt.execute([2, 'b'])
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
### Batch (bulk insert/update)
|
|
56
|
+
|
|
57
|
+
`conn.execute_many(sql, param_sets)` runs one statement once per parameter set, reusing a single
|
|
58
|
+
prepared statement, and returns an array of per-set affected-row counts. The wire protocol has no
|
|
59
|
+
batch pipeline, so this is N round-trips, not one.
|
|
60
|
+
|
|
61
|
+
```ruby
|
|
62
|
+
counts = conn.execute_many('INSERT INTO t VALUES ($1, $2)', [[1, 'a'], [2, 'b'], [3, 'c']])
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
### Bulk load / export (`COPY`)
|
|
66
|
+
|
|
67
|
+
For high-throughput load/export, `copy_in` / `copy_out` drive the `COPY` sub-protocol — one
|
|
68
|
+
round-trip for the whole dataset. Move bytes in the server's text format (tab-delimited fields, `\N`
|
|
69
|
+
for SQL `NULL`, one row per line); you write the `COPY` statement with any `WITH (...)` options.
|
|
70
|
+
|
|
71
|
+
```ruby
|
|
72
|
+
require 'stringio'
|
|
73
|
+
|
|
74
|
+
# Bulk load from an IO (responds to #read) or a String.
|
|
75
|
+
loaded = conn.copy_in('COPY t (id, name) FROM STDIN', StringIO.new("1\talice\n2\t\\N\n"))
|
|
76
|
+
|
|
77
|
+
# Bulk export into an IO (responds to #write).
|
|
78
|
+
sink = StringIO.new
|
|
79
|
+
exported = conn.copy_out('COPY t TO STDOUT', sink)
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
A `COPY` the server refuses (bad SQL, an RLS-protected table) raises; the connection stays usable.
|
|
83
|
+
|
|
84
|
+
### Authentication
|
|
85
|
+
|
|
86
|
+
For a server started with `--auth-user USER:PASSWORD`, pass `password:`; the driver
|
|
87
|
+
runs SCRAM-SHA-256 and verifies the server signature (mutual auth,
|
|
88
|
+
`OpenSSL.fixed_length_secure_compare`).
|
|
89
|
+
|
|
90
|
+
## ActiveRecord
|
|
91
|
+
|
|
92
|
+
An ActiveRecord adapter ships in
|
|
93
|
+
`lib/active_record/connection_adapters/nusadb_adapter.rb`:
|
|
94
|
+
|
|
95
|
+
```ruby
|
|
96
|
+
require "active_record/connection_adapters/nusadb_adapter"
|
|
97
|
+
|
|
98
|
+
ActiveRecord::Base.establish_connection(
|
|
99
|
+
adapter: "nusadb", host: "127.0.0.1", port: 5678, username: "nusa-root", database: "nusadb")
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
It supports migrations (`create_table`, reflection via `SHOW TABLES`/`SHOW COLUMNS`),
|
|
103
|
+
CRUD, transactions, and the usual query surface — `where`, `limit`/`offset`, `order`,
|
|
104
|
+
`joins`, and aggregates (`count`/`sum`/`minimum`/`maximum`/`average`). Primary keys are
|
|
105
|
+
introspected from `information_schema` (`primary_keys`), so a model over a table with a
|
|
106
|
+
real `PRIMARY KEY` auto-detects its key (single- or composite-column). Values are inlined
|
|
107
|
+
(`prepared_statements` off), so the server sees plain SQL with constant `LIMIT`/`OFFSET`.
|
|
108
|
+
The server has no auto-increment, so assign ids explicitly (or declare a real PK).
|
|
109
|
+
|
|
110
|
+
## Transactions
|
|
111
|
+
|
|
112
|
+
The driver itself runs each statement autocommit; explicit transactions work via the
|
|
113
|
+
adapter (or by sending `BEGIN`/`COMMIT`/`ROLLBACK` as queries), backed by the server's
|
|
114
|
+
transaction support. The ActiveRecord adapter reports `supports_savepoints?`, so nested
|
|
115
|
+
`transaction(requires_new: true)` blocks map onto `SAVEPOINT` / `ROLLBACK TO SAVEPOINT` /
|
|
116
|
+
`RELEASE SAVEPOINT`. With the low-level driver, send those statements as queries.
|
|
117
|
+
|
|
118
|
+
## Notifications (LISTEN/NOTIFY)
|
|
119
|
+
|
|
120
|
+
`listen(channel)` subscribes the connection; a `notify(channel, payload)` from any connection on the
|
|
121
|
+
same database is then delivered asynchronously. `poll(timeout)` waits for the next one (seconds;
|
|
122
|
+
`nil` blocks), or `notifications` drains those buffered during other queries:
|
|
123
|
+
|
|
124
|
+
```ruby
|
|
125
|
+
conn.listen('orders')
|
|
126
|
+
# ... elsewhere: other.notify('orders', '42')
|
|
127
|
+
note = conn.poll(5) # => NusaDB::Notification(pid, channel, payload), or nil on timeout
|
|
128
|
+
puts "#{note.channel} #{note.payload}"
|
|
129
|
+
conn.unlisten('orders')
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
## Test
|
|
133
|
+
|
|
134
|
+
```bash
|
|
135
|
+
cargo build -p nusadb-server
|
|
136
|
+
ruby drivers/ruby/test/test.rb
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
The test boots a real `nusadb-server` (ephemeral port, honouring `CARGO_TARGET_DIR`)
|
|
140
|
+
and covers simple/parameterised/prepared queries, errors, and SCRAM auth.
|
|
141
|
+
|
|
142
|
+
## License
|
|
143
|
+
|
|
144
|
+
Apache-2.0.
|
|
@@ -0,0 +1,355 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# ActiveRecord adapter for NusaDB, built on the pure-Ruby `nusadb` driver.
|
|
4
|
+
#
|
|
5
|
+
# Use it by setting the adapter to "nusadb" in database.yml or establish_connection:
|
|
6
|
+
#
|
|
7
|
+
# ActiveRecord::Base.establish_connection(
|
|
8
|
+
# adapter: "nusadb", host: "127.0.0.1", port: 5678, username: "nusa-root", database: "nusadb")
|
|
9
|
+
#
|
|
10
|
+
# Values are inlined (prepared_statements is off), so the server sees plain SQL with constant
|
|
11
|
+
# LIMIT/OFFSET and no bind markers.
|
|
12
|
+
|
|
13
|
+
# ActiveSupport 7.0 and older reach for ::Logger without requiring it, having long got away with it
|
|
14
|
+
# because concurrent-ruby -- which they load first -- used to require it for them. concurrent-ruby
|
|
15
|
+
# 1.3.5 stopped, so `require "active_record"` on that combination dies with `uninitialized constant
|
|
16
|
+
# ActiveSupport::LoggerThreadSafeLevel::Logger`, and a user on Rails 7.0 with an up-to-date
|
|
17
|
+
# concurrent-ruby cannot load this adapter at all. This file is typically the first thing to require
|
|
18
|
+
# active_record, so this is the place to fix it. A no-op on 7.1+, which requires logger itself.
|
|
19
|
+
require "logger"
|
|
20
|
+
|
|
21
|
+
require "active_record"
|
|
22
|
+
require "active_record/connection_adapters/abstract_adapter"
|
|
23
|
+
require "nusadb"
|
|
24
|
+
|
|
25
|
+
module ActiveRecord
|
|
26
|
+
module ConnectionAdapters
|
|
27
|
+
class NusaDBAdapter < AbstractAdapter
|
|
28
|
+
ADAPTER_NAME = "NusaDB"
|
|
29
|
+
|
|
30
|
+
# ActiveRecord 7.2 rebuilt how an adapter gets its connection, and 8.0 removed the old way:
|
|
31
|
+
#
|
|
32
|
+
# <= 7.1 ConnectionHandling#nusadb_connection opens the connection and hands it to
|
|
33
|
+
# .new(connection, logger, config); the adapter is simply born connected.
|
|
34
|
+
# >= 7.2 the adapter is resolved from a registry and built with .new(config). It starts
|
|
35
|
+
# with no connection at all: the base class calls the private #connect on first use
|
|
36
|
+
# and owns the result as @raw_connection, which every statement must reach through
|
|
37
|
+
# #with_raw_connection so the base can verify, materialize transactions and retry.
|
|
38
|
+
#
|
|
39
|
+
# Both eras are supported, so this constant -- not the ActiveRecord version number -- is what
|
|
40
|
+
# the code below branches on. #with_raw_connection arrived in 7.1, one release before the
|
|
41
|
+
# lazy-connection contract it belongs to, so a 7.1 adapter is connected eagerly and still
|
|
42
|
+
# talks through it; that is harmless, and it keeps the branch to a single question.
|
|
43
|
+
LAZY_CONNECTION = AbstractAdapter.private_method_defined?(:with_raw_connection) ||
|
|
44
|
+
AbstractAdapter.method_defined?(:with_raw_connection)
|
|
45
|
+
|
|
46
|
+
# ActiveRecord 8.1 inserted +cast_type+ as Column's SECOND positional argument -- exactly where
|
|
47
|
+
# +default+ used to sit. The old call still runs, it just hands the default over as the cast
|
|
48
|
+
# type, and the column dies later with `undefined method 'mutable?' for nil`, nowhere near the
|
|
49
|
+
# cause. The parameter list is asked rather than the version number because this is not the
|
|
50
|
+
# 7-vs-8 line one would guess: 8.0 kept the old signature and only 8.1 changed it.
|
|
51
|
+
COLUMN_TAKES_CAST_TYPE = Column.instance_method(:initialize).parameters.any? do |_kind, name|
|
|
52
|
+
name == :cast_type
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
class << self
|
|
56
|
+
# 7.2+ calls this from #connect. The <= 7.1 hook at the bottom of this file calls it too, so
|
|
57
|
+
# a connection is opened the same way whichever era we are running under.
|
|
58
|
+
def new_client(config)
|
|
59
|
+
config = config.symbolize_keys
|
|
60
|
+
NusaDB.connect(
|
|
61
|
+
host: config[:host] || "127.0.0.1",
|
|
62
|
+
port: (config[:port] || 5678).to_i,
|
|
63
|
+
user: config[:username] || config[:user] || "nusa-root",
|
|
64
|
+
database: config[:database] || "nusadb",
|
|
65
|
+
password: config[:password]
|
|
66
|
+
)
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# A connection this adapter has not opened yet is not active -- and saying otherwise is not a
|
|
71
|
+
# white lie on 7.2+, where #active? is what tells the base class whether it still needs to
|
|
72
|
+
# #connect. Claiming true while @raw_connection is nil sends it straight to a NoMethodError.
|
|
73
|
+
def active?
|
|
74
|
+
!current_raw_connection.nil?
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def disconnect!
|
|
78
|
+
super
|
|
79
|
+
current_raw_connection&.close
|
|
80
|
+
@raw_connection = nil if LAZY_CONNECTION
|
|
81
|
+
rescue StandardError
|
|
82
|
+
nil
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def prepared_statements
|
|
86
|
+
false
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def supports_migrations?
|
|
90
|
+
true
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# NusaDB supports savepoints (SAVEPOINT / ROLLBACK TO SAVEPOINT / RELEASE SAVEPOINT), so
|
|
94
|
+
# ActiveRecord can map nested `transaction(requires_new: true)` blocks onto them. The base
|
|
95
|
+
# adapter's create_savepoint / exec_rollback_to_savepoint / release_savepoint emit exactly the
|
|
96
|
+
# statements the server accepts, so enabling the capability is all that is needed.
|
|
97
|
+
def supports_savepoints?
|
|
98
|
+
true
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def native_database_types
|
|
102
|
+
{
|
|
103
|
+
primary_key: "INT",
|
|
104
|
+
string: { name: "TEXT" },
|
|
105
|
+
text: { name: "TEXT" },
|
|
106
|
+
integer: { name: "INT" },
|
|
107
|
+
bigint: { name: "INT" },
|
|
108
|
+
float: { name: "FLOAT" },
|
|
109
|
+
decimal: { name: "NUMERIC" },
|
|
110
|
+
boolean: { name: "BOOL" },
|
|
111
|
+
date: { name: "DATE" },
|
|
112
|
+
datetime: { name: "TIMESTAMP" },
|
|
113
|
+
time: { name: "TIME" }
|
|
114
|
+
}
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
# --- quoting (values are inlined since prepared_statements is off) ---
|
|
118
|
+
|
|
119
|
+
# ActiveRecord 7.2 moved quoting onto the adapter *class* -- ActiveRecord::Base reaches for
|
|
120
|
+
# `adapter_class.quote_column_name` before any connection exists (to quote a model's primary
|
|
121
|
+
# key, say) -- and made the instance methods delegate to it. The class-level default raises
|
|
122
|
+
# NotImplementedError, so an adapter that only defines the instance methods still looks fine
|
|
123
|
+
# until something asks the class, and then dies far from the cause.
|
|
124
|
+
class << self
|
|
125
|
+
def quote_column_name(name)
|
|
126
|
+
%("#{name.to_s.gsub('"', '""')}")
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def quote_table_name(name)
|
|
130
|
+
quote_column_name(name)
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
# Only ' is escaped, and deliberately: the server does not treat \ as an escape character
|
|
134
|
+
# inside a string literal, so doubling backslashes (as the ActiveRecord default does) would
|
|
135
|
+
# store them doubled.
|
|
136
|
+
def quote_string(s)
|
|
137
|
+
s.gsub("'", "''")
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
# <= 7.1 has no class-level quoting to delegate to -- its Quoting module only ever calls the
|
|
142
|
+
# instance methods, whose default raises. Delegating explicitly keeps one implementation
|
|
143
|
+
# reachable from either era.
|
|
144
|
+
def quote_column_name(name)
|
|
145
|
+
self.class.quote_column_name(name)
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def quote_table_name(name)
|
|
149
|
+
self.class.quote_table_name(name)
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
def quote_string(s)
|
|
153
|
+
self.class.quote_string(s)
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
# --- statement execution ---
|
|
157
|
+
|
|
158
|
+
# Every keyword splat below absorbs arguments ActiveRecord grew later and passes to adapters
|
|
159
|
+
# that never asked for them -- `allow_retry:` on #execute, `returning:` on #exec_insert,
|
|
160
|
+
# `async:` on #internal_exec_query (all 7.2+). Without them the caller gets an ArgumentError.
|
|
161
|
+
def execute(sql, name = nil, **_kwargs)
|
|
162
|
+
log(sql, name) { nusa_query(sql) }
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
# #exec_query stopped being the way in. From 7.2 the internal read path (select_all -> select)
|
|
166
|
+
# calls #internal_exec_query, whose base implementation raises NotImplementedError -- so an
|
|
167
|
+
# adapter that overrides only the public #exec_query looks complete, loads fine, and then dies
|
|
168
|
+
# on the first SELECT. This is the real entry point; #exec_query is kept for <= 7.1 (where the
|
|
169
|
+
# base has no #internal_exec_query at all) and for anyone calling it directly.
|
|
170
|
+
def internal_exec_query(sql, name = "SQL", binds = [], prepare: false, **_kwargs)
|
|
171
|
+
result = log(sql, name) { nusa_query(sql) }
|
|
172
|
+
ActiveRecord::Result.new(result.columns, result.rows)
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
def exec_query(sql, name = "SQL", binds = [], prepare: false, **_kwargs)
|
|
176
|
+
internal_exec_query(sql, name, binds)
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
def exec_insert(sql, name = nil, binds = [], pk = nil, sequence_name = nil, **_kwargs)
|
|
180
|
+
internal_exec_query(sql, name, binds)
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
def exec_delete(sql, name = nil, binds = [], **_kwargs)
|
|
184
|
+
log(sql, name) { nusa_query(sql) }.affected
|
|
185
|
+
end
|
|
186
|
+
alias exec_update exec_delete
|
|
187
|
+
|
|
188
|
+
def begin_db_transaction
|
|
189
|
+
execute("BEGIN")
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
def commit_db_transaction
|
|
193
|
+
execute("COMMIT")
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
def exec_rollback_db_transaction
|
|
197
|
+
execute("ROLLBACK")
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
# --- schema introspection ---
|
|
201
|
+
|
|
202
|
+
def tables
|
|
203
|
+
nusa_query("SHOW TABLES").rows.map { |row| row[0] }
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
def data_source_exists?(name)
|
|
207
|
+
tables.include?(name.to_s)
|
|
208
|
+
end
|
|
209
|
+
alias table_exists? data_source_exists?
|
|
210
|
+
|
|
211
|
+
def data_sources
|
|
212
|
+
tables
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
def columns(table_name, _name = nil)
|
|
216
|
+
rows = nusa_query("SHOW COLUMNS FROM #{quote_table_name(table_name)}").rows
|
|
217
|
+
rows.map do |row|
|
|
218
|
+
col_name = row[0]
|
|
219
|
+
sql_type = (row[1] || "text").to_s.downcase
|
|
220
|
+
nullable = row[2].nil? || %w[t true yes 1].include?(row[2].to_s.downcase)
|
|
221
|
+
cast_type = ActiveRecord::Type.lookup(map_type(sql_type))
|
|
222
|
+
metadata = fetch_type_metadata(sql_type, cast_type)
|
|
223
|
+
|
|
224
|
+
# The column has no default either way -- what moves is where +default+ sits in the
|
|
225
|
+
# argument list. See COLUMN_TAKES_CAST_TYPE.
|
|
226
|
+
if COLUMN_TAKES_CAST_TYPE
|
|
227
|
+
Column.new(col_name, cast_type, nil, metadata, nullable)
|
|
228
|
+
else
|
|
229
|
+
Column.new(col_name, nil, metadata, nullable)
|
|
230
|
+
end
|
|
231
|
+
end
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
# The primary-key columns of +table_name+, in key order, so ActiveRecord can auto-detect a
|
|
235
|
+
# model's primary key (single- or composite-column) without an explicit +self.primary_key+.
|
|
236
|
+
# Resolved from +information_schema+ in two steps — the server does not support joining two
|
|
237
|
+
# +information_schema+ tables in one query, so we look up the PRIMARY KEY constraint name and
|
|
238
|
+
# then its columns separately.
|
|
239
|
+
def primary_keys(table_name)
|
|
240
|
+
name = quote(table_name.to_s)
|
|
241
|
+
constraint = nusa_query(
|
|
242
|
+
"SELECT constraint_name FROM information_schema.table_constraints " \
|
|
243
|
+
"WHERE constraint_type = 'PRIMARY KEY' AND table_name = #{name}"
|
|
244
|
+
).rows.first
|
|
245
|
+
return [] if constraint.nil?
|
|
246
|
+
|
|
247
|
+
nusa_query(
|
|
248
|
+
"SELECT column_name FROM information_schema.key_column_usage " \
|
|
249
|
+
"WHERE table_name = #{name} AND constraint_name = #{quote(constraint[0])} " \
|
|
250
|
+
"ORDER BY ordinal_position"
|
|
251
|
+
).rows.map { |row| row[0] }
|
|
252
|
+
end
|
|
253
|
+
|
|
254
|
+
private
|
|
255
|
+
|
|
256
|
+
# Every statement funnels through here. On 7.1+ that means #with_raw_connection, which is not
|
|
257
|
+
# a formality: it is what verifies the connection, materializes a lazy transaction before the
|
|
258
|
+
# statement lands, and retries a query the base class judges retryable. Reaching for
|
|
259
|
+
# @raw_connection directly would quietly bypass all three.
|
|
260
|
+
def nusa_query(sql)
|
|
261
|
+
if LAZY_CONNECTION
|
|
262
|
+
with_raw_connection { |conn| conn.query(sql) }
|
|
263
|
+
else
|
|
264
|
+
@connection.query(sql)
|
|
265
|
+
end
|
|
266
|
+
end
|
|
267
|
+
|
|
268
|
+
# The connection as it stands, WITHOUT opening one. #active? and #disconnect! both need to ask
|
|
269
|
+
# "is there a connection?" without answering "...well, there is now".
|
|
270
|
+
def current_raw_connection
|
|
271
|
+
LAZY_CONNECTION ? @raw_connection : @connection
|
|
272
|
+
end
|
|
273
|
+
|
|
274
|
+
# The base class's own statement machinery, which the public methods above mostly bypass. It
|
|
275
|
+
# is implemented anyway so that any path into it lands on the driver rather than on a
|
|
276
|
+
# NotImplementedError: 7.2 drives statements through #raw_execute, and 8.0 replaced that with
|
|
277
|
+
# #perform_query plus #cast_result / #affected_rows. Keyword splats, because the exact
|
|
278
|
+
# signature moved between those releases.
|
|
279
|
+
def raw_execute(sql, name = nil, *, **)
|
|
280
|
+
log(sql, name) { nusa_query(sql) }
|
|
281
|
+
end
|
|
282
|
+
|
|
283
|
+
def perform_query(raw_connection, sql, *, **)
|
|
284
|
+
raw_connection.query(sql)
|
|
285
|
+
end
|
|
286
|
+
|
|
287
|
+
def cast_result(raw_result)
|
|
288
|
+
ActiveRecord::Result.new(raw_result.columns, raw_result.rows)
|
|
289
|
+
end
|
|
290
|
+
|
|
291
|
+
def affected_rows(raw_result)
|
|
292
|
+
raw_result.affected
|
|
293
|
+
end
|
|
294
|
+
|
|
295
|
+
# 7.2+ only: the base class calls these to open (and re-open) the connection it owns.
|
|
296
|
+
def connect
|
|
297
|
+
@raw_connection = self.class.new_client(@config)
|
|
298
|
+
end
|
|
299
|
+
|
|
300
|
+
def reconnect
|
|
301
|
+
current_raw_connection&.close
|
|
302
|
+
@raw_connection = nil
|
|
303
|
+
connect
|
|
304
|
+
end
|
|
305
|
+
|
|
306
|
+
def fetch_type_metadata(sql_type, cast_type)
|
|
307
|
+
ActiveRecord::ConnectionAdapters::SqlTypeMetadata.new(
|
|
308
|
+
sql_type: sql_type,
|
|
309
|
+
type: cast_type.type,
|
|
310
|
+
limit: nil,
|
|
311
|
+
precision: nil,
|
|
312
|
+
scale: nil
|
|
313
|
+
)
|
|
314
|
+
end
|
|
315
|
+
|
|
316
|
+
def map_type(sql_type)
|
|
317
|
+
case sql_type
|
|
318
|
+
when /int/ then :integer
|
|
319
|
+
when /bool/ then :boolean
|
|
320
|
+
when /float|double|real/ then :float
|
|
321
|
+
when /numeric|decimal/ then :decimal
|
|
322
|
+
when /timestamp|datetime/ then :datetime
|
|
323
|
+
when /date/ then :date
|
|
324
|
+
when /time/ then :time
|
|
325
|
+
else :string
|
|
326
|
+
end
|
|
327
|
+
end
|
|
328
|
+
end
|
|
329
|
+
end
|
|
330
|
+
end
|
|
331
|
+
|
|
332
|
+
# How ActiveRecord finds this adapter when a config says `adapter: "nusadb"`.
|
|
333
|
+
#
|
|
334
|
+
# 7.2+ looks it up in a registry, and 8.0 deleted the old lookup outright: an unregistered adapter
|
|
335
|
+
# now raises AdapterNotFound ("Available adapters are: ...") before any connection is attempted, so
|
|
336
|
+
# registering is not a nicety -- without it the adapter is simply unreachable on Rails 8.
|
|
337
|
+
if ActiveRecord::ConnectionAdapters.respond_to?(:register)
|
|
338
|
+
ActiveRecord::ConnectionAdapters.register(
|
|
339
|
+
"nusadb",
|
|
340
|
+
"ActiveRecord::ConnectionAdapters::NusaDBAdapter",
|
|
341
|
+
"active_record/connection_adapters/nusadb_adapter"
|
|
342
|
+
)
|
|
343
|
+
else
|
|
344
|
+
# <= 7.1 instead calls ConnectionHandling#<adapter>_connection and expects an adapter that is
|
|
345
|
+
# already connected -- hence the eager new_client here, where 7.2+ leaves it to #connect.
|
|
346
|
+
module ActiveRecord
|
|
347
|
+
module ConnectionHandling
|
|
348
|
+
def nusadb_connection(config)
|
|
349
|
+
config = config.symbolize_keys
|
|
350
|
+
conn = ConnectionAdapters::NusaDBAdapter.new_client(config)
|
|
351
|
+
ConnectionAdapters::NusaDBAdapter.new(conn, logger, config)
|
|
352
|
+
end
|
|
353
|
+
end
|
|
354
|
+
end
|
|
355
|
+
end
|