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 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