skaidb 1.0.3
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/CHANGELOG.md +122 -0
- data/LICENSE +557 -0
- data/README.md +484 -0
- data/docs/api.md +146 -0
- data/docs/getting-started.md +127 -0
- data/docs/pooling.md +47 -0
- data/docs/streaming.md +69 -0
- data/docs/tls.md +59 -0
- data/docs/types.md +72 -0
- data/lib/skaidb.rb +1334 -0
- metadata +78 -0
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
# Getting started
|
|
2
|
+
|
|
3
|
+
## Install
|
|
4
|
+
|
|
5
|
+
The gem is published on RubyGems.org as
|
|
6
|
+
[`skaidb`](https://rubygems.org/gems/skaidb); whichever way it is installed
|
|
7
|
+
the library is loaded with `require "skaidb"`.
|
|
8
|
+
|
|
9
|
+
```sh
|
|
10
|
+
gem install skaidb
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
With Bundler:
|
|
14
|
+
|
|
15
|
+
```ruby
|
|
16
|
+
# Gemfile
|
|
17
|
+
gem "skaidb", "~> 1.0"
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
Or vendor the single file `lib/skaidb.rb` into your project and
|
|
21
|
+
`require_relative` it — it needs nothing beyond Ruby's standard library
|
|
22
|
+
(`socket`, `openssl`, `securerandom`, `bigdecimal`).
|
|
23
|
+
|
|
24
|
+
Ruby 2.7 or newer; CI covers 3.1 through 3.4.
|
|
25
|
+
|
|
26
|
+
## Connect
|
|
27
|
+
|
|
28
|
+
```ruby
|
|
29
|
+
require "skaidb"
|
|
30
|
+
|
|
31
|
+
conn = Skaidb.connect(host: "localhost", port: 7000,
|
|
32
|
+
user: "skaidb", password: "secret",
|
|
33
|
+
database: "app")
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
`connect` opens the TCP connection, runs the four-frame SCRAM-SHA-256
|
|
37
|
+
handshake (verifying the server's signature when a password is given), sends
|
|
38
|
+
the driver's `Hello`, and runs `USE "app"` when `database:` is set. Pass a
|
|
39
|
+
block to have the connection closed when it returns:
|
|
40
|
+
|
|
41
|
+
```ruby
|
|
42
|
+
Skaidb.connect(host: "localhost", user: "skaidb", password: "secret") do |conn|
|
|
43
|
+
# ...
|
|
44
|
+
end
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
For a server with authentication disabled, omit `user:` and `password:`.
|
|
48
|
+
|
|
49
|
+
For a cluster, pass every node as `seeds:` — they are tried in random order
|
|
50
|
+
until one accepts the connection and the handshake:
|
|
51
|
+
|
|
52
|
+
```ruby
|
|
53
|
+
conn = Skaidb.connect(seeds: ["db1", "db2:7000", "db3"], user: "app", password: "secret")
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
## Run statements
|
|
57
|
+
|
|
58
|
+
```ruby
|
|
59
|
+
conn.exec("CREATE TABLE users (PRIMARY KEY (id))")
|
|
60
|
+
|
|
61
|
+
conn.exec_params("INSERT INTO users (id, name, age, tags) VALUES ($1, $2, $3, $4)",
|
|
62
|
+
[1, "Ada", 36, ["math", "eng"]])
|
|
63
|
+
|
|
64
|
+
res = conn.exec_params("SELECT id, name, tags FROM users WHERE age > $1 ORDER BY id", [30])
|
|
65
|
+
res.each { |row| puts "#{row['id']} #{row['name']} #{row['tags'].inspect}" }
|
|
66
|
+
res.ntuples # 1
|
|
67
|
+
res.fields # ["id", "name", "tags"]
|
|
68
|
+
res.rows # [[1, "Ada", ["math", "eng"]]]
|
|
69
|
+
|
|
70
|
+
conn.exec_params("UPDATE users SET age = $1 WHERE id = $2", [37, 1]).cmd_tuples # 1
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
Placeholders are `$1`, `$2`, … and values are sent **typed** through a
|
|
74
|
+
server-side prepared statement: Strings need no escaping, and Arrays and
|
|
75
|
+
Hashes bind natively. See [types.md](types.md) for the full mapping.
|
|
76
|
+
|
|
77
|
+
## Bulk insert
|
|
78
|
+
|
|
79
|
+
```ruby
|
|
80
|
+
rows = (1..10_000).map { |i| [i, "row #{i}"] }
|
|
81
|
+
conn.exec_batch("INSERT INTO t (id, v) VALUES ($1, $2)", rows) # one round-trip
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
## Large results
|
|
85
|
+
|
|
86
|
+
```ruby
|
|
87
|
+
conn.stream("SELECT id, payload FROM events ORDER BY id") do |row|
|
|
88
|
+
process(row)
|
|
89
|
+
end
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
Rows arrive chunk by chunk; the driver holds one chunk at a time. See
|
|
93
|
+
[streaming.md](streaming.md), in particular what happens when you leave the
|
|
94
|
+
block early.
|
|
95
|
+
|
|
96
|
+
## Errors
|
|
97
|
+
|
|
98
|
+
```ruby
|
|
99
|
+
begin
|
|
100
|
+
conn.exec("SELECT nope FROM missing")
|
|
101
|
+
rescue Skaidb::QueryError => e # the server rejected the statement; connection still fine
|
|
102
|
+
warn e.message # table "missing" does not exist
|
|
103
|
+
rescue Skaidb::ConnectionError => e # transport/handshake trouble; next statement re-dials
|
|
104
|
+
warn e.message
|
|
105
|
+
end
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
Both inherit from `Skaidb::Error`.
|
|
109
|
+
|
|
110
|
+
## Close
|
|
111
|
+
|
|
112
|
+
```ruby
|
|
113
|
+
conn.close
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
Or use the block form of `connect`, or a [pool](pooling.md).
|
|
117
|
+
|
|
118
|
+
## Next
|
|
119
|
+
|
|
120
|
+
- [API reference](api.md)
|
|
121
|
+
- [Types](types.md)
|
|
122
|
+
- [TLS](tls.md)
|
|
123
|
+
- [Streaming](streaming.md)
|
|
124
|
+
- [Pooling](pooling.md)
|
|
125
|
+
- Runnable scripts in [`examples/`](../examples/)
|
|
126
|
+
- Server documentation: <https://skaidb.org/docs/> — the wire protocol this
|
|
127
|
+
driver speaks: <https://skaidb.org/docs/PROTOCOL.html>
|
data/docs/pooling.md
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# Connection pool
|
|
2
|
+
|
|
3
|
+
`Skaidb::Pool` keeps a bounded set of idle connections and hands one to each
|
|
4
|
+
caller, so a threaded server or job runner can use one connection per
|
|
5
|
+
thread without dialing and authenticating on every request.
|
|
6
|
+
|
|
7
|
+
```ruby
|
|
8
|
+
pool = Skaidb::Pool.new(seeds: ["db1", "db2", "db3"], user: "app", password: "secret",
|
|
9
|
+
database: "app", maxsize: 8)
|
|
10
|
+
|
|
11
|
+
pool.with do |conn| # checked out; returned when the block ends
|
|
12
|
+
conn.exec_params("SELECT … WHERE id = $1", [id])
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
conn = pool.checkout # the explicit form
|
|
16
|
+
begin
|
|
17
|
+
conn.exec("…")
|
|
18
|
+
ensure
|
|
19
|
+
pool.checkin(conn)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
pool.close # closes every idle connection
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
## Behaviour
|
|
26
|
+
|
|
27
|
+
- Every `Skaidb.connect` keyword passes through (`seeds`, `database`, TLS,
|
|
28
|
+
`consistency`, `timeout`), so pooled connections fail over across seeds
|
|
29
|
+
and run `USE` exactly like single ones.
|
|
30
|
+
- `maxsize` (default 10) bounds the connections kept **idle**, not the number
|
|
31
|
+
checked out. `checkout` never blocks: with no idle connection available it
|
|
32
|
+
dials a new one, and a connection returned while `maxsize` are already idle
|
|
33
|
+
is closed instead of kept.
|
|
34
|
+
- `checkout` and `checkin` both validate with `usable?`: a connection broken
|
|
35
|
+
by a transport error, or left mid-stream by an abandoned Enumerator, is
|
|
36
|
+
closed and dropped rather than handed on. `usable?` is local knowledge
|
|
37
|
+
only — a connection the server closed while it sat idle still looks fine
|
|
38
|
+
and will fail its first statement with `Skaidb::ConnectionError`; that
|
|
39
|
+
failure marks it broken, so `checkin` discards it and the next checkout
|
|
40
|
+
gets a fresh one. Retry the statement in that case if it is safe to.
|
|
41
|
+
- `close` marks the pool closed and closes the idle connections; connections
|
|
42
|
+
checked out at the time are closed when they are returned. `checkout` on a
|
|
43
|
+
closed pool raises `Skaidb::Error`. `maxsize < 1` raises `ArgumentError`.
|
|
44
|
+
- The pool is thread-safe. A single connection should still be used by one
|
|
45
|
+
thread at a time; the pool is what gives each thread its own.
|
|
46
|
+
|
|
47
|
+
See also `examples/pool.rb`.
|
data/docs/streaming.md
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
# Streaming large results
|
|
2
|
+
|
|
3
|
+
`exec` and `exec_params` buffer the whole result in a `Skaidb::Result`. For
|
|
4
|
+
a result that should not be held in memory at once, `stream` runs the
|
|
5
|
+
statement over the protocol's streaming opcode: the server answers with a
|
|
6
|
+
header (the column names), then row chunks, then an end marker, and the
|
|
7
|
+
driver yields rows as each chunk arrives, holding one chunk at a time.
|
|
8
|
+
|
|
9
|
+
```ruby
|
|
10
|
+
conn.stream("SELECT id, payload FROM events ORDER BY id") do |row|
|
|
11
|
+
export(row) # row is a Hash keyed by column name
|
|
12
|
+
end
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
- `stream(sql, consistency: nil)` takes SQL text only — no parameters.
|
|
16
|
+
- With a block it returns `nil` after the last row.
|
|
17
|
+
- Without a block it returns an Enumerator: `conn.stream(sql).first(10)`,
|
|
18
|
+
`.each_slice(1000)`, `.lazy.map { … }`, and the rest of Enumerable work.
|
|
19
|
+
- A non-row statement (an `INSERT`, DDL, `USE`) answers with its ordinary
|
|
20
|
+
result over the same opcode; `stream` returns `nil` without yielding.
|
|
21
|
+
- A server without the streaming opcode raises `Skaidb::QueryError`
|
|
22
|
+
(`server does not support streaming: …`).
|
|
23
|
+
|
|
24
|
+
## Errors mid-stream
|
|
25
|
+
|
|
26
|
+
The server can fail a statement after it has started sending rows (a node
|
|
27
|
+
dying mid-scan, a scan budget tripping). The rows already yielded are valid;
|
|
28
|
+
the block stops and `stream` raises `Skaidb::QueryError` with the server's
|
|
29
|
+
message. The connection stays usable.
|
|
30
|
+
|
|
31
|
+
## The abandon/drain rule
|
|
32
|
+
|
|
33
|
+
The protocol forbids any other request on the connection until the stream
|
|
34
|
+
ends, so the whole exchange runs under the connection's lock: a statement
|
|
35
|
+
from another thread **waits** for the stream to finish rather than
|
|
36
|
+
interleaving with it.
|
|
37
|
+
|
|
38
|
+
Leaving the block early — `break`, `return`, `raise`, or any Enumerable
|
|
39
|
+
method that stops before the end (`first`, `take`, `find`) — unwinds through
|
|
40
|
+
an `ensure` that **drains** the frames the server is still sending, so the
|
|
41
|
+
socket is back at a request boundary and the next statement is clean:
|
|
42
|
+
|
|
43
|
+
```ruby
|
|
44
|
+
first = conn.stream("SELECT id FROM big ORDER BY id").first(3)
|
|
45
|
+
conn.usable? # true
|
|
46
|
+
conn.exec("SELECT 1")
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
Draining is not free and there is no cancel opcode: breaking out of a
|
|
50
|
+
million-row scan still transfers the rest of it before the connection is
|
|
51
|
+
usable again. If you only want a few rows, say so in SQL (`LIMIT`).
|
|
52
|
+
|
|
53
|
+
Two things defeat the drain:
|
|
54
|
+
|
|
55
|
+
- **External iteration** — `e = conn.stream(sql); e.next` — runs the stream
|
|
56
|
+
inside the Enumerator's Fiber. A Fiber abandoned part-way is collected
|
|
57
|
+
without running any `ensure`, so nothing drains and the connection stays
|
|
58
|
+
marked mid-stream: `usable?` is `false` for good and a pool will not hand
|
|
59
|
+
it out again. Iterate with a block or `each`.
|
|
60
|
+
- **A frame that makes no sense mid-stream**, or a dead socket. The driver
|
|
61
|
+
no longer knows where the reply ends, so it marks the connection broken
|
|
62
|
+
instead of guessing; `usable?` turns `false`, and the next statement
|
|
63
|
+
re-dials.
|
|
64
|
+
|
|
65
|
+
There is no read deadline: a peer that is alive but silent blocks the drain,
|
|
66
|
+
holding the lock. `close` does not take the lock, so another thread can break
|
|
67
|
+
such a read by closing the connection.
|
|
68
|
+
|
|
69
|
+
See also `examples/stream.rb`.
|
data/docs/tls.md
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
# TLS
|
|
2
|
+
|
|
3
|
+
The binary protocol runs over plain TCP by default. A server configured with
|
|
4
|
+
`client_tls = required` refuses plaintext outright, so such a cluster is
|
|
5
|
+
only reachable with TLS switched on in the driver.
|
|
6
|
+
|
|
7
|
+
## Enabling it
|
|
8
|
+
|
|
9
|
+
Any one of these three keywords turns TLS on:
|
|
10
|
+
|
|
11
|
+
```ruby
|
|
12
|
+
Skaidb.connect(host: "db1", tls: true) # verify against the system trust store
|
|
13
|
+
Skaidb.connect(host: "db1", tls_ca: "/etc/skaidb/ca.crt") # verify against this CA file only
|
|
14
|
+
Skaidb.connect(host: "db1", tls_insecure: true) # encrypt, verify nothing
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
- `tls: true` uses OpenSSL's default certificate paths — right when the
|
|
18
|
+
server's certificate chains to a public or system-installed CA.
|
|
19
|
+
- `tls_ca:` trusts exactly the certificates in that PEM file. This is the
|
|
20
|
+
usual shape for a cluster with its own CA: hand the driver the CA
|
|
21
|
+
certificate, nothing else.
|
|
22
|
+
- `tls_insecure: true` sets `VERIFY_NONE`. The connection is encrypted but
|
|
23
|
+
the peer is not authenticated, so a man in the middle can present any
|
|
24
|
+
certificate. Development and throw-away environments only.
|
|
25
|
+
|
|
26
|
+
`Skaidb::Pool` passes all of these through to every connection it opens.
|
|
27
|
+
|
|
28
|
+
## The server name
|
|
29
|
+
|
|
30
|
+
The driver sends `tls_server_name` as SNI and, unless `tls_insecure`, checks
|
|
31
|
+
it against the certificate's subject alternative names with
|
|
32
|
+
`post_connection_check`. The default is `"skaidb"`, the DNS SAN skaidb's own
|
|
33
|
+
generated certificates carry — which is usually **not** the address you
|
|
34
|
+
dial. If your certificate names the host instead, say so:
|
|
35
|
+
|
|
36
|
+
```ruby
|
|
37
|
+
Skaidb.connect(host: "db1.internal", tls_ca: "ca.crt", tls_server_name: "db1.internal")
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
A mismatch fails the connect with `Skaidb::ConnectionError` wrapping
|
|
41
|
+
OpenSSL's message.
|
|
42
|
+
|
|
43
|
+
## What runs inside the session
|
|
44
|
+
|
|
45
|
+
The TCP connection is upgraded to TLS *before* the SCRAM handshake, so the
|
|
46
|
+
user name, nonces and proof all travel encrypted; with `seeds:`, each
|
|
47
|
+
endpoint is upgraded and authenticated in turn until one succeeds. The
|
|
48
|
+
driver does not present a client certificate; authentication is SCRAM.
|
|
49
|
+
|
|
50
|
+
## Failures
|
|
51
|
+
|
|
52
|
+
- Wrong CA, expired certificate, name mismatch: `Skaidb::ConnectionError`
|
|
53
|
+
with the OpenSSL message, from `connect` (or from the statement that
|
|
54
|
+
triggered a re-dial).
|
|
55
|
+
- Plain TCP against a `client_tls = required` server: the handshake cannot
|
|
56
|
+
complete, so `connect` raises `Skaidb::ConnectionError`
|
|
57
|
+
(`no reachable endpoint in …`) naming the underlying failure.
|
|
58
|
+
|
|
59
|
+
See also `examples/tls.rb`.
|
data/docs/types.md
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
# Types
|
|
2
|
+
|
|
3
|
+
Results decode from skaidb's typed wire values; parameters encode back to
|
|
4
|
+
them when a statement is prepared, which is the normal path whenever you pass
|
|
5
|
+
values to `exec_params` or `exec_batch`.
|
|
6
|
+
|
|
7
|
+
| skaidb type | Result value | Accepted as a parameter |
|
|
8
|
+
|---|---|---|
|
|
9
|
+
| Null | `nil` | `nil` |
|
|
10
|
+
| Bool | `true` / `false` | `true` / `false` |
|
|
11
|
+
| Int (64-bit) | `Integer` | `Integer` in the signed 64-bit range |
|
|
12
|
+
| Float (64-bit) | `Float` | `Float`, finite (NaN and ±Infinity are refused) |
|
|
13
|
+
| Decimal | `BigDecimal`, exact | `BigDecimal`, finite, mantissa within a signed 128-bit integer |
|
|
14
|
+
| String | `String` (UTF-8) | `String` with any text encoding (sent as UTF-8 bytes); `Symbol` |
|
|
15
|
+
| Bytes | `String` with `Encoding::BINARY` | `String` whose encoding is `Encoding::BINARY` (`ASCII-8BIT`) |
|
|
16
|
+
| Uuid | `String`, canonical lowercase `8-4-4-4-12` | `Skaidb::Uuid` |
|
|
17
|
+
| Timestamp | `Time` in UTC, millisecond precision (may predate 1970) | `Time` (any zone) |
|
|
18
|
+
| Array | `Array` of mapped values | `Array` (elements follow this table) |
|
|
19
|
+
| Document | `Hash` with String keys, in the order the server sends them | `Hash` (keys are converted with `to_s`) |
|
|
20
|
+
|
|
21
|
+
## Notes
|
|
22
|
+
|
|
23
|
+
- **Bytes vs String** is decided by the Ruby String's encoding: `"…".b`,
|
|
24
|
+
`String.new`, `File.binread` and `[…].pack` produce binary Strings and bind
|
|
25
|
+
as Bytes; literals and `File.read` produce text and bind as String. A text
|
|
26
|
+
String is sent as its UTF-8 bytes (it is `force_encoding`'d, not
|
|
27
|
+
transcoded — keep text in UTF-8).
|
|
28
|
+
- **Uuid** results are Strings so they compare and print naturally. To bind
|
|
29
|
+
a value *as* a UUID wrap it: `Skaidb::Uuid.new("6ba7b810-9dad-11d1-80b4-00c04fd430c8")`
|
|
30
|
+
(dashes optional, any case), `Skaidb::Uuid.random`, or
|
|
31
|
+
`Skaidb::Uuid.from_bytes(raw)`. `uuid == "6ba7b810-…"` is true when the
|
|
32
|
+
canonical forms match, so a bound value compares equal to what comes back.
|
|
33
|
+
A plain String parameter binds as String, not Uuid.
|
|
34
|
+
- **Decimal** is `mantissa × 10^-scale` on the wire. `BigDecimal("123.45")`
|
|
35
|
+
is sent as mantissa `12345`, scale `2`; trailing zeros are dropped and a
|
|
36
|
+
positive exponent (`BigDecimal("1e5")`) is folded into the mantissa. A
|
|
37
|
+
mantissa beyond ±2^127 raises `QueryError`.
|
|
38
|
+
- **Document** results keep the key order the server sends; the driver adds
|
|
39
|
+
keys to the `Hash` as they arrive. That order is not the one the document
|
|
40
|
+
was bound in: the server stores a document in canonical form, with keys
|
|
41
|
+
sorted at every nesting level, so `{"zeta" => 1, "alpha" => 2}` comes back
|
|
42
|
+
as `{"alpha" => 2, "zeta" => 1}`. Do not rely on key order.
|
|
43
|
+
- **Timestamp**: a `Time` keeps its instant whatever its zone (local,
|
|
44
|
+
`+05:30`, UTC) and is truncated to the millisecond towards negative
|
|
45
|
+
infinity, so `decode(encode(t)) == t` for any millisecond-precision `Time`.
|
|
46
|
+
Results are always UTC.
|
|
47
|
+
- **Int**: an `Integer` outside `-2^63 … 2^63-1` raises `QueryError` before
|
|
48
|
+
anything is sent. There is no marker to force Float for an integral value;
|
|
49
|
+
bind `3.0`, not `3`, when you mean a Float.
|
|
50
|
+
- **Document** keys become Strings (`{ city: "London" }` is stored with key
|
|
51
|
+
`"city"`); nested Arrays and Hashes are encoded recursively.
|
|
52
|
+
- Any other class — `Date`, `DateTime`, `Rational`, `Set`, `Range`, your own
|
|
53
|
+
objects — raises `QueryError("cannot bind value of type …")`. Convert
|
|
54
|
+
first (`date.to_time`, `rational.to_f`, `set.to_a`).
|
|
55
|
+
|
|
56
|
+
## The client-side fallback
|
|
57
|
+
|
|
58
|
+
When the server refuses to prepare a statement (DDL, `USE`, and every
|
|
59
|
+
statement on a server without the prepare opcode), `exec_params` renders the
|
|
60
|
+
parameters into the SQL text instead:
|
|
61
|
+
|
|
62
|
+
| Ruby value | Literal |
|
|
63
|
+
|---|---|
|
|
64
|
+
| `nil` | `NULL` |
|
|
65
|
+
| `true` / `false` | `TRUE` / `FALSE` |
|
|
66
|
+
| `Integer`, `Float` (finite) | as printed |
|
|
67
|
+
| `BigDecimal` | plain decimal notation (`123.45`) |
|
|
68
|
+
| `String` (text), `Symbol` | single-quoted, `'` doubled |
|
|
69
|
+
| `String` (binary) | the hex digits, single-quoted |
|
|
70
|
+
| `Skaidb::Uuid` | the canonical form, single-quoted |
|
|
71
|
+
| `Time` | Unix milliseconds |
|
|
72
|
+
| `Array`, `Hash` | refused: `QueryError`, with the server's reason for not preparing the statement appended |
|