tina4-ultipa 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 +21 -0
- data/README.md +197 -0
- data/lib/tina4_ultipa/client.rb +205 -0
- data/lib/tina4_ultipa/codec.rb +391 -0
- data/lib/tina4_ultipa/errors.rb +35 -0
- data/lib/tina4_ultipa/gen/gqldb_pb.rb +23 -0
- data/lib/tina4_ultipa/gen/gqldb_services_pb.rb +36 -0
- data/lib/tina4_ultipa/version.rb +5 -0
- data/lib/tina4_ultipa.rb +16 -0
- data/proto/gqldb.proto +122 -0
- data/tina4-ultipa.gemspec +33 -0
- metadata +85 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 78beafa9836b0d709d9a02d788ca1bd25bed6e75a8d72b59c28fd07f777ea566
|
|
4
|
+
data.tar.gz: f367219bedd06967311975326649f6507bace52a439147866bb4144eb96289e1
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 07db5593de9c6a3e6ece49e205263f962ddbafb00b07831a592999c81e5873057b13f8a7b927c60b166138ad326e412cf84239bda016b7876b1d8d56de7bc850
|
|
7
|
+
data.tar.gz: 22ef4d3cf031368ec8524c082178eb178614cd218b43773938df54f34efa9e25955177bccdd7d7712dcf8563ca8349ba2bc0afebb289b8cf06f8b4ed61b59ea9
|
data/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Tina4 Stack
|
|
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.
|
data/README.md
ADDED
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
<p align="center">
|
|
2
|
+
<img src="https://tina4.com/logo.svg" alt="Tina4" height="88">
|
|
3
|
+
<span>×</span>
|
|
4
|
+
<img src="https://www.ultipa.com/assets/ultipa-logo.svg" alt="Ultipa" height="40">
|
|
5
|
+
</p>
|
|
6
|
+
|
|
7
|
+
<h1 align="center">tina4-ultipa</h1>
|
|
8
|
+
<h3 align="center">The Ultipa graph driver for Ruby</h3>
|
|
9
|
+
|
|
10
|
+
<p align="center">
|
|
11
|
+
A full-fidelity gRPC client for the <a href="https://www.ultipa.com">Ultipa</a>
|
|
12
|
+
graph database — every GQL type decoded, no Tina4 lock-in.
|
|
13
|
+
</p>
|
|
14
|
+
|
|
15
|
+
<p align="center">
|
|
16
|
+
<a href="https://rubygems.org/gems/tina4-ultipa"><img src="https://img.shields.io/gem/v/tina4-ultipa?color=ed5034&label=gem" alt="RubyGems"></a>
|
|
17
|
+
<img src="https://img.shields.io/badge/tests-live%20%C2%B7%20no%20mocks-2f9e44" alt="Tests">
|
|
18
|
+
<img src="https://img.shields.io/badge/license-MIT-blue" alt="MIT">
|
|
19
|
+
<a href="https://www.ultipa.com"><img src="https://img.shields.io/badge/for-Ultipa%20gqldb-ed5034" alt="Ultipa"></a>
|
|
20
|
+
<a href="https://tina4.com"><img src="https://img.shields.io/badge/part%20of-Tina4-ed5034" alt="Tina4"></a>
|
|
21
|
+
</p>
|
|
22
|
+
|
|
23
|
+
<p align="center">
|
|
24
|
+
<a href="#quick-start">Quick Start</a> •
|
|
25
|
+
<a href="#type-support">Type Support</a> •
|
|
26
|
+
<a href="https://www.ultipa.com/docs">Ultipa Docs</a> •
|
|
27
|
+
<a href="PROTOCOL.md">Protocol</a> •
|
|
28
|
+
<a href="https://tina4.com">tina4.com</a>
|
|
29
|
+
</p>
|
|
30
|
+
|
|
31
|
+
---
|
|
32
|
+
|
|
33
|
+
A thin, standalone **gRPC driver for the [Ultipa](https://www.ultipa.com) graph
|
|
34
|
+
database** (`ultipa-gqldb`). It is the Ultipa driver behind
|
|
35
|
+
[Tina4](https://tina4.com)'s graph data layer — but has **no Tina4 dependency** and
|
|
36
|
+
is perfectly usable on its own. Tina4's core stays zero-dependency; this driver is
|
|
37
|
+
an *optional* gem loaded only for `ultipa://` connections, speaking gRPC directly
|
|
38
|
+
(`grpc` + `google-protobuf`).
|
|
39
|
+
|
|
40
|
+
> **Ultipa** is a high-performance graph database with a GQL (ISO/IEC 39075) query
|
|
41
|
+
> surface. Learn more at **[ultipa.com](https://www.ultipa.com)** · docs at
|
|
42
|
+
> **[ultipa.com/docs](https://www.ultipa.com/docs)**.
|
|
43
|
+
|
|
44
|
+
## Why this driver
|
|
45
|
+
|
|
46
|
+
- **Complete value decoding.** Every gqldb `PropertyType` is decoded to a natural
|
|
47
|
+
Ruby value — including the composites and temporals most thin clients skip
|
|
48
|
+
(see [Type support](#type-support)).
|
|
49
|
+
- **Real gRPC, vendored stubs.** Generated protobuf stubs ship inside the gem
|
|
50
|
+
(`lib/tina4_ultipa/gen/`), so there is no reflection round-trip and no codegen
|
|
51
|
+
step at install time.
|
|
52
|
+
- **Fails loud.** A bad statement raises; it never returns a falsy value you might
|
|
53
|
+
miss. An unreachable host raises within your `connect_timeout`.
|
|
54
|
+
- **Cross-language parity.** The same driver exists for
|
|
55
|
+
[Python](https://github.com/tina4stack/ultipa-python),
|
|
56
|
+
[Node.js](https://github.com/tina4stack/ultipa-node),
|
|
57
|
+
[Ruby](https://github.com/tina4stack/ultipa-ruby) and
|
|
58
|
+
[PHP](https://github.com/tina4stack/ultipa-php), decoding byte-for-byte identically.
|
|
59
|
+
|
|
60
|
+
## Install
|
|
61
|
+
|
|
62
|
+
```bash
|
|
63
|
+
gem install tina4-ultipa
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
or in a `Gemfile`:
|
|
67
|
+
|
|
68
|
+
```ruby
|
|
69
|
+
gem "tina4-ultipa"
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
## Quick start
|
|
73
|
+
|
|
74
|
+
```ruby
|
|
75
|
+
require "tina4_ultipa"
|
|
76
|
+
|
|
77
|
+
# from a URL...
|
|
78
|
+
db = Tina4Ultipa::Client.from_url("ultipa://admin:password@localhost:60061/mygraph").connect
|
|
79
|
+
# ...or explicitly
|
|
80
|
+
db = Tina4Ultipa::Client.new(
|
|
81
|
+
host: "localhost", port: 60061,
|
|
82
|
+
username: "admin", password: "password", graph: "mygraph"
|
|
83
|
+
).connect
|
|
84
|
+
|
|
85
|
+
# read — GQL text + optional named params
|
|
86
|
+
db.query("MATCH (n:Person) WHERE n.age > $min RETURN n.name AS name",
|
|
87
|
+
params: { min: 21 }).each do |row|
|
|
88
|
+
puts row["name"]
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# write — dml stats on success, raises on a bad statement
|
|
92
|
+
r = db.execute("INSERT (:Person {name: 'Alice', age: 30})")
|
|
93
|
+
puts r.dml_stats[:inserted_nodes] # 1
|
|
94
|
+
|
|
95
|
+
db.close
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
`query` runs a read, `execute` a write — both take GQL and optional `params`
|
|
99
|
+
and return a `Tina4Ultipa::Result`:
|
|
100
|
+
|
|
101
|
+
| Member | Meaning |
|
|
102
|
+
|---|---|
|
|
103
|
+
| `#columns` | column names |
|
|
104
|
+
| `#rows` | decoded rows (arrays of Ruby values) |
|
|
105
|
+
| `#to_h_rows` / `#dicts` | rows as hashes keyed by column |
|
|
106
|
+
| `#scalar` | first cell of the first row |
|
|
107
|
+
| `#rows_affected`, `#dml_stats`, `#warnings` | write metadata |
|
|
108
|
+
|
|
109
|
+
`Result` is `Enumerable` and yields each row as a hash, so `each`, `map` and
|
|
110
|
+
`select` work directly on a result.
|
|
111
|
+
|
|
112
|
+
Errors: a bad statement raises `Tina4Ultipa::Error`; an unreachable host raises
|
|
113
|
+
`Tina4Ultipa::ConnectError` within `connect_timeout`, naming host, port and elapsed time.
|
|
114
|
+
|
|
115
|
+
## Type support
|
|
116
|
+
|
|
117
|
+
Every gqldb `PropertyType` decodes to a natural Ruby value:
|
|
118
|
+
|
|
119
|
+
| Category | Types | Decodes to |
|
|
120
|
+
|---|---|---|
|
|
121
|
+
| Numeric | int32/uint32/int64/uint64, float, double | `Integer` / `Float` |
|
|
122
|
+
| Text | string, text | `String` |
|
|
123
|
+
| Boolean / null | bool, null, unset | `true`/`false` / `nil` |
|
|
124
|
+
| Binary | **blob** | binary `String` (e.g. an image, round-trips intact) |
|
|
125
|
+
| Decimal | decimal | `String` (precision-preserving) |
|
|
126
|
+
| Temporal | date, local/zoned time, local/zoned datetime, timestamp | ISO-8601 `String` |
|
|
127
|
+
| Interval | year-to-month, day-to-second | `{"months" => …}` / `{"seconds" => …, "nanoseconds" => …}` |
|
|
128
|
+
| Spatial | point, point3d | `{"x", "y"[, "z"], "srid"}` `Hash` |
|
|
129
|
+
| Vector | vector | `Array` of `Float` |
|
|
130
|
+
| Graph | **node**, **edge**, **path** | `Hash` (`_kind` node/edge/path; nodes/edges carry `id`, `labels`/`type`, `properties`, internal `uuid`) |
|
|
131
|
+
| Tabular | list, set, map, record, table | `Array` / `Hash` |
|
|
132
|
+
| Error | error | `{"code", "message"}` |
|
|
133
|
+
|
|
134
|
+
Node/edge hashes include the 8-byte internal-id (`uuid`) trailer emitted by
|
|
135
|
+
gqldb 6.1.147+.
|
|
136
|
+
|
|
137
|
+
## Protocol
|
|
138
|
+
|
|
139
|
+
`ultipa-gqldb` speaks gRPC (default port `60061`, protobuf package `gqldb`). The
|
|
140
|
+
driver authenticates with `SessionService.Login`, passes the returned `session_id`
|
|
141
|
+
on every call as the **`session-id`** metadata header (as an unsigned decimal), then
|
|
142
|
+
runs `QueryService.Gql`. Each value is a `TypedValue{type, bytes}`; all multi-byte
|
|
143
|
+
integers are little-endian. The full byte-level encoding is documented in
|
|
144
|
+
**[PROTOCOL.md](PROTOCOL.md)** — grounded in Ultipa's official gqldb SDK and proto,
|
|
145
|
+
and verified live against gqldb-grpc 6.2.130 CE.
|
|
146
|
+
|
|
147
|
+
## Sample data pack
|
|
148
|
+
|
|
149
|
+
Get a real graph into your community-edition instance in seconds - 10 people (with
|
|
150
|
+
photos), 3 companies, 3 projects, 5 skills and 62 relationships:
|
|
151
|
+
|
|
152
|
+
```bash
|
|
153
|
+
ruby examples/seed.rb ultipa://admin:PASSWORD@HOST:60061
|
|
154
|
+
# graph defaults to "default"; override with TINA4_ULTIPA_GRAPH
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
Then explore it:
|
|
158
|
+
|
|
159
|
+
```sql
|
|
160
|
+
MATCH (n:Person)-[e]->(m) RETURN n, e, m LIMIT 100
|
|
161
|
+
RETURN db.overview()
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
The pack lives in [`examples/`](examples/) - `people.json` describes the graph and
|
|
165
|
+
`sample_data/faces/*.jpg` are 64px portraits of **AI-generated, non-existent people**
|
|
166
|
+
([thispersondoesnotexist.com](https://thispersondoesnotexist.com)) stored as **BLOB**
|
|
167
|
+
properties, so it also demonstrates binary round-tripping through gqldb. Re-running is
|
|
168
|
+
safe - it clears the demo labels (Person/Company/Project/Skill) first.
|
|
169
|
+
|
|
170
|
+
## Testing
|
|
171
|
+
|
|
172
|
+
Real, no-mock tests run against a live server:
|
|
173
|
+
|
|
174
|
+
```bash
|
|
175
|
+
TINA4_TEST_ULTIPA_URL=ultipa://admin:password@host:60061 ruby test/test_driver.rb
|
|
176
|
+
```
|
|
177
|
+
|
|
178
|
+
They cover scalars, node/edge/list/map decoding, the full temporal/spatial/interval
|
|
179
|
+
set, path decoding, and a BLOB image round-trip.
|
|
180
|
+
|
|
181
|
+
## Links
|
|
182
|
+
|
|
183
|
+
- **Ultipa** — website [ultipa.com](https://www.ultipa.com) · docs [ultipa.com/docs](https://www.ultipa.com/docs) · GQL query language reference in the docs
|
|
184
|
+
- **Tina4** — [tina4.com](https://tina4.com) · the framework this driver powers
|
|
185
|
+
- **This driver** — [github.com/tina4stack/ultipa-ruby](https://github.com/tina4stack/ultipa-ruby) · sibling drivers for [Python](https://github.com/tina4stack/ultipa-python), [Node.js](https://github.com/tina4stack/ultipa-node), [PHP](https://github.com/tina4stack/ultipa-php)
|
|
186
|
+
|
|
187
|
+
## License
|
|
188
|
+
|
|
189
|
+
MIT. Note: the Ultipa community-edition server is licensed by Ultipa for personal /
|
|
190
|
+
non-commercial use — review [Ultipa's terms](https://www.ultipa.com) before you
|
|
191
|
+
deploy it. This driver is an independent client and may not be officially supported
|
|
192
|
+
by Ultipa.
|
|
193
|
+
|
|
194
|
+
---
|
|
195
|
+
|
|
196
|
+
Sponsored by [Code Infinity](https://codeinfinity.co.za) · part of the
|
|
197
|
+
[Tina4](https://tina4.com) stack.
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Thin gRPC client for the Ultipa `ultipa-gqldb` server.
|
|
4
|
+
#
|
|
5
|
+
# Auth: SessionService.Login(username, password[, default_graph]) -> session_id,
|
|
6
|
+
# then that id rides every call as the gRPC metadata header `session-id`.
|
|
7
|
+
# Query: QueryService.Gql(GqlRequest) -> GqlResponse. This is the whole surface the
|
|
8
|
+
# Tina4 graph adapter needs; node/edge/traverse are built in GQL on top of query.
|
|
9
|
+
|
|
10
|
+
require "uri"
|
|
11
|
+
require "grpc"
|
|
12
|
+
|
|
13
|
+
# The generated stubs `require 'gqldb_pb'` / `require 'gqldb_services_pb'` by bare
|
|
14
|
+
# name, so put their directory on the load path before requiring them.
|
|
15
|
+
_gen_dir = File.expand_path("gen", __dir__)
|
|
16
|
+
$LOAD_PATH.unshift(_gen_dir) unless $LOAD_PATH.include?(_gen_dir)
|
|
17
|
+
require "gqldb_pb"
|
|
18
|
+
require "gqldb_services_pb"
|
|
19
|
+
|
|
20
|
+
require_relative "codec"
|
|
21
|
+
require_relative "errors"
|
|
22
|
+
|
|
23
|
+
module Tina4Ultipa
|
|
24
|
+
DEFAULT_PORT = 60_061
|
|
25
|
+
|
|
26
|
+
# A gqldb result set - columns + rows, shaped like Tina4's DatabaseResult.
|
|
27
|
+
class Result
|
|
28
|
+
attr_reader :columns, :rows, :row_count, :rows_affected, :warnings,
|
|
29
|
+
:current_graph, :dml_stats
|
|
30
|
+
|
|
31
|
+
include Enumerable
|
|
32
|
+
|
|
33
|
+
def initialize(resp)
|
|
34
|
+
@columns = resp.columns.to_a
|
|
35
|
+
@rows = resp.rows.map { |row| row.values.map { |v| Codec.decode(v) } }
|
|
36
|
+
@row_count = resp.row_count
|
|
37
|
+
@rows_affected = resp.rows_affected
|
|
38
|
+
@warnings = resp.warnings.to_a
|
|
39
|
+
@current_graph = resp.current_graph
|
|
40
|
+
d = resp.dml_stats
|
|
41
|
+
@dml_stats = if d
|
|
42
|
+
{ inserted_nodes: d.inserted_nodes, inserted_edges: d.inserted_edges,
|
|
43
|
+
deleted_nodes: d.deleted_nodes, deleted_edges: d.deleted_edges,
|
|
44
|
+
set_nodes: d.set_nodes, set_edges: d.set_edges }
|
|
45
|
+
else
|
|
46
|
+
{ inserted_nodes: 0, inserted_edges: 0, deleted_nodes: 0,
|
|
47
|
+
deleted_edges: 0, set_nodes: 0, set_edges: 0 }
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Rows as hashes keyed by column name.
|
|
52
|
+
def to_h_rows
|
|
53
|
+
@rows.map { |row| @columns.zip(row).to_h }
|
|
54
|
+
end
|
|
55
|
+
alias dicts to_h_rows
|
|
56
|
+
|
|
57
|
+
# The first cell of the first row, or nil.
|
|
58
|
+
def scalar
|
|
59
|
+
first = @rows[0]
|
|
60
|
+
first && !first.empty? ? first[0] : nil
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def each(&block)
|
|
64
|
+
to_h_rows.each(&block)
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def length
|
|
68
|
+
@rows.length
|
|
69
|
+
end
|
|
70
|
+
alias size length
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
class Client
|
|
74
|
+
attr_reader :host, :port, :username, :graph, :connect_timeout, :use_tls,
|
|
75
|
+
:server_version
|
|
76
|
+
attr_writer :graph
|
|
77
|
+
|
|
78
|
+
def initialize(host:, port: DEFAULT_PORT, username: nil, password: nil,
|
|
79
|
+
graph: nil, connect_timeout: 10, use_tls: false)
|
|
80
|
+
@host = host
|
|
81
|
+
@port = port.to_i
|
|
82
|
+
@username = username
|
|
83
|
+
@password = password
|
|
84
|
+
@graph = graph || ""
|
|
85
|
+
@connect_timeout = connect_timeout.to_f
|
|
86
|
+
@use_tls = use_tls
|
|
87
|
+
@channel = nil
|
|
88
|
+
@session = nil
|
|
89
|
+
@query = nil
|
|
90
|
+
@session_id = nil
|
|
91
|
+
@server_version = nil
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# ultipa://user:pass@host:port/graph?connect_timeout=5&tls=1
|
|
95
|
+
def self.from_url(url, **kw)
|
|
96
|
+
u = URI.parse(url)
|
|
97
|
+
q = URI.decode_www_form(u.query || "").to_h
|
|
98
|
+
path_graph = (u.path || "").sub(%r{\A/}, "")
|
|
99
|
+
new(
|
|
100
|
+
host: u.host,
|
|
101
|
+
port: (u.port || DEFAULT_PORT),
|
|
102
|
+
username: (u.user || q["user"] || kw[:username]),
|
|
103
|
+
password: (u.password || q["password"] || kw[:password]),
|
|
104
|
+
graph: (path_graph.empty? ? kw[:graph] : path_graph),
|
|
105
|
+
connect_timeout: (q["connect_timeout"] || kw[:connect_timeout] || 10).to_f,
|
|
106
|
+
use_tls: (%w[1 true].include?(q["tls"]) || url.start_with?("ultipas://") ||
|
|
107
|
+
kw[:use_tls] || false),
|
|
108
|
+
)
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
# -- connection ---------------------------------------------------------
|
|
112
|
+
def connect
|
|
113
|
+
return self unless @session_id.nil?
|
|
114
|
+
|
|
115
|
+
target = "#{@host}:#{@port}"
|
|
116
|
+
creds = @use_tls ? GRPC::Core::ChannelCredentials.new : :this_channel_is_insecure
|
|
117
|
+
@channel = GRPC::Core::Channel.new(target, nil, creds)
|
|
118
|
+
started = monotonic
|
|
119
|
+
wait_for_ready(@channel, started)
|
|
120
|
+
|
|
121
|
+
@session = Gqldb::SessionService::Stub.new(target, creds, channel_override: @channel)
|
|
122
|
+
@query = Gqldb::QueryService::Stub.new(target, creds, channel_override: @channel)
|
|
123
|
+
begin
|
|
124
|
+
resp = @session.login(
|
|
125
|
+
Gqldb::LoginRequest.new(
|
|
126
|
+
username: @username || "", password: @password || "", default_graph: @graph,
|
|
127
|
+
),
|
|
128
|
+
deadline: Time.now + @connect_timeout,
|
|
129
|
+
)
|
|
130
|
+
rescue GRPC::BadStatus => e
|
|
131
|
+
close
|
|
132
|
+
raise wrap(e, "login failed")
|
|
133
|
+
end
|
|
134
|
+
@session_id = resp.session_id
|
|
135
|
+
@server_version = resp.server_version
|
|
136
|
+
self
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
# -- queries ------------------------------------------------------------
|
|
140
|
+
|
|
141
|
+
# Run a GQL statement and return a Result. Raises on error.
|
|
142
|
+
def query(gql, params: nil, graph: nil, read_only: true, timeout: nil)
|
|
143
|
+
connect
|
|
144
|
+
req = Gqldb::GqlRequest.new(
|
|
145
|
+
gql: gql,
|
|
146
|
+
graph_name: graph.nil? ? @graph : graph,
|
|
147
|
+
parameters: (params || {}).map do |k, v|
|
|
148
|
+
Gqldb::Parameter.new(name: k.to_s, value: Codec.encode(v))
|
|
149
|
+
end,
|
|
150
|
+
session_id: @session_id || 0,
|
|
151
|
+
read_only: read_only,
|
|
152
|
+
timeout: timeout ? timeout.to_i : 0,
|
|
153
|
+
)
|
|
154
|
+
begin
|
|
155
|
+
resp = @query.gql(req, metadata: { "session-id" => @session_id.to_s })
|
|
156
|
+
rescue GRPC::BadStatus => e
|
|
157
|
+
raise wrap(e, "gql failed")
|
|
158
|
+
end
|
|
159
|
+
Result.new(resp)
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
# Run a writing GQL statement (read_only = false).
|
|
163
|
+
def execute(gql, params: nil, graph: nil, timeout: nil)
|
|
164
|
+
query(gql, params: params, graph: graph, read_only: false, timeout: timeout)
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
def close
|
|
168
|
+
@channel.close if @channel.respond_to?(:close)
|
|
169
|
+
rescue StandardError
|
|
170
|
+
# best effort - the channel is being torn down regardless
|
|
171
|
+
ensure
|
|
172
|
+
@channel = @session = @query = @session_id = nil
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
private
|
|
176
|
+
|
|
177
|
+
def monotonic
|
|
178
|
+
Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
def wait_for_ready(channel, started)
|
|
182
|
+
states = GRPC::Core::ConnectivityStates
|
|
183
|
+
deadline = Time.now + @connect_timeout
|
|
184
|
+
state = channel.connectivity_state(true)
|
|
185
|
+
until state == states::READY
|
|
186
|
+
break if Time.now >= deadline
|
|
187
|
+
|
|
188
|
+
channel.watch_connectivity_state(state, deadline)
|
|
189
|
+
state = channel.connectivity_state(true)
|
|
190
|
+
end
|
|
191
|
+
return if state == states::READY
|
|
192
|
+
|
|
193
|
+
elapsed = monotonic - started
|
|
194
|
+
close
|
|
195
|
+
raise ConnectError.new(@host, @port, elapsed)
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
def wrap(rpc_error, prefix)
|
|
199
|
+
detail = rpc_error.respond_to?(:details) ? rpc_error.details : nil
|
|
200
|
+
detail = rpc_error.message if detail.nil? || detail.to_s.empty?
|
|
201
|
+
code = rpc_error.class.name.to_s.split("::").last
|
|
202
|
+
Error.new("#{prefix}: #{detail}", code: code, cause: rpc_error)
|
|
203
|
+
end
|
|
204
|
+
end
|
|
205
|
+
end
|
|
@@ -0,0 +1,391 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
# Encode/decode gqldb TypedValue <-> Ruby values.
|
|
6
|
+
#
|
|
7
|
+
# Grounded against the official gqldb-nodejs SDK decoder and verified live against
|
|
8
|
+
# gqldb-grpc 6.2.130 CE. All multi-byte integers are little-endian. Mirrors the
|
|
9
|
+
# Python reference `_codec.py`, which passes all live tests.
|
|
10
|
+
#
|
|
11
|
+
# Inside composites (node/edge/map/list/table props) each value is a
|
|
12
|
+
# **TypedValueEntry**: `type:u8 · isNull:u8 · dataLen:u32 · data` - a 1-byte type
|
|
13
|
+
# plus a 1-byte is-null (NOT a u16 type); a set is-null yields nil regardless of data.
|
|
14
|
+
|
|
15
|
+
module Tina4Ultipa
|
|
16
|
+
module Codec
|
|
17
|
+
module_function
|
|
18
|
+
|
|
19
|
+
# PropertyType (as the enum symbol google-protobuf hands back) -> pack/unpack
|
|
20
|
+
# directive for the fixed-width little-endian numeric types.
|
|
21
|
+
NUMERIC = {
|
|
22
|
+
PROPERTY_TYPE_INT32: "l<",
|
|
23
|
+
PROPERTY_TYPE_UINT32: "L<",
|
|
24
|
+
PROPERTY_TYPE_INT64: "q<",
|
|
25
|
+
PROPERTY_TYPE_UINT64: "Q<",
|
|
26
|
+
PROPERTY_TYPE_FLOAT: "e",
|
|
27
|
+
PROPERTY_TYPE_DOUBLE: "E",
|
|
28
|
+
}.freeze
|
|
29
|
+
|
|
30
|
+
STRINGY = %i[PROPERTY_TYPE_STRING PROPERTY_TYPE_TEXT].freeze
|
|
31
|
+
NULLISH = %i[PROPERTY_TYPE_NULL PROPERTY_TYPE_UNSET].freeze
|
|
32
|
+
LISTY = %i[PROPERTY_TYPE_LIST PROPERTY_TYPE_SET].freeze
|
|
33
|
+
MAPPY = %i[PROPERTY_TYPE_MAP PROPERTY_TYPE_RECORD].freeze
|
|
34
|
+
LOCAL_DT = %i[PROPERTY_TYPE_DATETIME PROPERTY_TYPE_LOCAL_DATETIME].freeze
|
|
35
|
+
|
|
36
|
+
# A google-protobuf enum field usually reads back as a Symbol, but falls back
|
|
37
|
+
# to the raw Integer for values it does not know. Normalise to the symbol.
|
|
38
|
+
INT_TO_SYM = {
|
|
39
|
+
0 => :PROPERTY_TYPE_UNSET, 1 => :PROPERTY_TYPE_INT32, 2 => :PROPERTY_TYPE_UINT32,
|
|
40
|
+
3 => :PROPERTY_TYPE_INT64, 4 => :PROPERTY_TYPE_UINT64, 5 => :PROPERTY_TYPE_FLOAT,
|
|
41
|
+
6 => :PROPERTY_TYPE_DOUBLE, 7 => :PROPERTY_TYPE_STRING,
|
|
42
|
+
8 => :PROPERTY_TYPE_DATETIME, 9 => :PROPERTY_TYPE_TIMESTAMP, 10 => :PROPERTY_TYPE_TEXT,
|
|
43
|
+
11 => :PROPERTY_TYPE_BLOB, 12 => :PROPERTY_TYPE_POINT, 13 => :PROPERTY_TYPE_DECIMAL,
|
|
44
|
+
14 => :PROPERTY_TYPE_LIST, 15 => :PROPERTY_TYPE_SET, 16 => :PROPERTY_TYPE_MAP,
|
|
45
|
+
17 => :PROPERTY_TYPE_NULL, 18 => :PROPERTY_TYPE_BOOL,
|
|
46
|
+
19 => :PROPERTY_TYPE_LOCAL_DATETIME, 20 => :PROPERTY_TYPE_ZONED_DATETIME,
|
|
47
|
+
21 => :PROPERTY_TYPE_DATE, 22 => :PROPERTY_TYPE_ZONED_TIME,
|
|
48
|
+
23 => :PROPERTY_TYPE_LOCAL_TIME, 24 => :PROPERTY_TYPE_YEAR_TO_MONTH,
|
|
49
|
+
25 => :PROPERTY_TYPE_DAY_TO_SECOND, 26 => :PROPERTY_TYPE_RECORD,
|
|
50
|
+
27 => :PROPERTY_TYPE_POINT3D, 28 => :PROPERTY_TYPE_VECTOR,
|
|
51
|
+
29 => :PROPERTY_TYPE_TABLE, 30 => :PROPERTY_TYPE_PATH, 31 => :PROPERTY_TYPE_ERROR,
|
|
52
|
+
32 => :PROPERTY_TYPE_NODE, 33 => :PROPERTY_TYPE_EDGE
|
|
53
|
+
}.freeze
|
|
54
|
+
|
|
55
|
+
# ── temporal / spatial helpers ──────────────────────────────────────
|
|
56
|
+
|
|
57
|
+
def date(data)
|
|
58
|
+
return nil if data.nil? || data.bytesize < 4
|
|
59
|
+
|
|
60
|
+
y = data.byteslice(0, 2).unpack1("s<") # signed year (BCE round-trips)
|
|
61
|
+
format("%04d-%02d-%02d", y, data.getbyte(2), data.getbyte(3))
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def time_str(hour, minute, sec, nanos)
|
|
65
|
+
base = format("%02d:%02d:%02d", hour, minute, sec)
|
|
66
|
+
return base if nanos.nil? || nanos.zero?
|
|
67
|
+
|
|
68
|
+
base + format(".%09d", nanos).sub(/0+\z/, "").sub(/\.\z/, "")
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def local_time(data)
|
|
72
|
+
return nil if data.nil? || data.bytesize < 8
|
|
73
|
+
|
|
74
|
+
nanos = data.unpack1("@4 L<")
|
|
75
|
+
time_str(data.getbyte(0), data.getbyte(1), data.getbyte(2), nanos)
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def zoned_time(data)
|
|
79
|
+
return nil if data.nil? || data.bytesize < 10
|
|
80
|
+
|
|
81
|
+
nanos = data.unpack1("@4 L<")
|
|
82
|
+
off = data.unpack1("@8 s<")
|
|
83
|
+
time_str(data.getbyte(0), data.getbyte(1), data.getbyte(2), nanos) + offset(off)
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def offset(minutes)
|
|
87
|
+
sign = minutes >= 0 ? "+" : "-"
|
|
88
|
+
m = minutes.abs
|
|
89
|
+
format("%s%02d:%02d", sign, m / 60, m % 60)
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def datetime_frac(base, nanos)
|
|
93
|
+
return base if nanos.nil? || nanos.zero?
|
|
94
|
+
|
|
95
|
+
base + format(".%09d", nanos).sub(/0+\z/, "").sub(/\.\z/, "")
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def local_datetime(data)
|
|
99
|
+
if !data.nil? && data.bytesize >= 11
|
|
100
|
+
y = data.unpack1("s<")
|
|
101
|
+
nanos = data.unpack1("@7 L<")
|
|
102
|
+
base = format("%04d-%02d-%02d %02d:%02d:%02d", y, data.getbyte(2), data.getbyte(3),
|
|
103
|
+
data.getbyte(4), data.getbyte(5), data.getbyte(6))
|
|
104
|
+
return datetime_frac(base, nanos)
|
|
105
|
+
end
|
|
106
|
+
unix(data) # legacy 8-byte unix fallback
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def zoned_datetime(data)
|
|
110
|
+
if !data.nil? && data.bytesize >= 13
|
|
111
|
+
y = data.unpack1("s<")
|
|
112
|
+
nanos = data.unpack1("@7 L<")
|
|
113
|
+
off = data.unpack1("@11 s<")
|
|
114
|
+
base = format("%04d-%02d-%02d %02d:%02d:%02d", y, data.getbyte(2), data.getbyte(3),
|
|
115
|
+
data.getbyte(4), data.getbyte(5), data.getbyte(6))
|
|
116
|
+
return datetime_frac(base, nanos) + offset(off)
|
|
117
|
+
end
|
|
118
|
+
unix(data)
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def timestamp(data)
|
|
122
|
+
if !data.nil? && data.bytesize >= 12
|
|
123
|
+
secs = data.unpack1("q<")
|
|
124
|
+
nanos = data.unpack1("@8 L<")
|
|
125
|
+
begin
|
|
126
|
+
base = Time.at(secs).utc.strftime("%Y-%m-%d %H:%M:%S")
|
|
127
|
+
return datetime_frac(base, nanos)
|
|
128
|
+
rescue StandardError, RangeError
|
|
129
|
+
return secs
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
unix(data)
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
# Legacy 8-byte unix time (seconds/ms/us) -> 'YYYY-MM-DD HH:MM:SS' (UTC).
|
|
136
|
+
def unix(data)
|
|
137
|
+
return nil if data.nil? || data.bytesize < 8
|
|
138
|
+
|
|
139
|
+
v = data.byteslice(0, 8).unpack1("Q<")
|
|
140
|
+
secs = v > 1e14 ? v / 1e6 : (v > 1e11 ? v / 1e3 : v)
|
|
141
|
+
begin
|
|
142
|
+
Time.at(secs).utc.strftime("%Y-%m-%d %H:%M:%S")
|
|
143
|
+
rescue StandardError, RangeError
|
|
144
|
+
v
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def point(data)
|
|
149
|
+
return nil if data.nil? || data.bytesize < 16
|
|
150
|
+
|
|
151
|
+
x = data.unpack1("E")
|
|
152
|
+
y = data.unpack1("@8 E")
|
|
153
|
+
srid = data.bytesize >= 20 ? data.unpack1("@16 L<") : 7203
|
|
154
|
+
{ "_kind" => "point", "x" => x, "y" => y, "srid" => srid }
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
def point3d(data)
|
|
158
|
+
return nil if data.nil? || data.bytesize < 24
|
|
159
|
+
|
|
160
|
+
x = data.unpack1("E")
|
|
161
|
+
y = data.unpack1("@8 E")
|
|
162
|
+
z = data.unpack1("@16 E")
|
|
163
|
+
srid = data.bytesize >= 28 ? data.unpack1("@24 L<") : 9757
|
|
164
|
+
{ "_kind" => "point3d", "x" => x, "y" => y, "z" => z, "srid" => srid }
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
def vector(data)
|
|
168
|
+
return [] if data.nil? || data.bytesize < 4
|
|
169
|
+
|
|
170
|
+
dim = data.unpack1("L<")
|
|
171
|
+
return [] if data.bytesize < 4 + dim * 4
|
|
172
|
+
|
|
173
|
+
Array.new(dim) { |i| data.unpack1("@#{4 + i * 4} e") }
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
def year_to_month(data)
|
|
177
|
+
return { "months" => 0 } if data.nil? || data.bytesize < 4
|
|
178
|
+
|
|
179
|
+
{ "months" => data.unpack1("l<") }
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
def day_to_second(data)
|
|
183
|
+
return { "seconds" => 0, "nanoseconds" => 0 } if data.nil? || data.bytesize < 12
|
|
184
|
+
|
|
185
|
+
{ "seconds" => data.unpack1("q<"), "nanoseconds" => data.unpack1("@8 L<") }
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
def error(data)
|
|
189
|
+
s = (data || "".b).dup.force_encoding("UTF-8")
|
|
190
|
+
obj = JSON.parse(s)
|
|
191
|
+
{ "_kind" => "error", "code" => (obj["code"] || 0), "message" => (obj["message"] || "") }
|
|
192
|
+
rescue StandardError
|
|
193
|
+
{ "_kind" => "error", "code" => 0, "message" => s }
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
# Walks a self-describing binary blob, reading little-endian fixed-width ints,
|
|
197
|
+
# length-prefixed strings, and TypedValueEntry composites. Mirrors Python _Reader.
|
|
198
|
+
class Reader
|
|
199
|
+
attr_accessor :d, :p
|
|
200
|
+
|
|
201
|
+
def initialize(data)
|
|
202
|
+
@d = (data || "".b).dup.force_encoding("BINARY")
|
|
203
|
+
@p = 0
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
def u8
|
|
207
|
+
v = @d.getbyte(@p)
|
|
208
|
+
@p += 1
|
|
209
|
+
v
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
def u16
|
|
213
|
+
v = @d.byteslice(@p, 2).unpack1("S<")
|
|
214
|
+
@p += 2
|
|
215
|
+
v
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
def u32
|
|
219
|
+
v = @d.byteslice(@p, 4).unpack1("L<")
|
|
220
|
+
@p += 4
|
|
221
|
+
v
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
def u64
|
|
225
|
+
v = @d.byteslice(@p, 8).unpack1("Q<")
|
|
226
|
+
@p += 8
|
|
227
|
+
v
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
# uint16 length prefix + that many UTF-8 bytes.
|
|
231
|
+
def str
|
|
232
|
+
n = u16
|
|
233
|
+
v = @d.byteslice(@p, n).dup.force_encoding("UTF-8")
|
|
234
|
+
@p += n
|
|
235
|
+
v
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
# A TypedValueEntry: type u8, isNull u8, len u32, data -> decoded value (nil if null).
|
|
239
|
+
def tve
|
|
240
|
+
t = u8
|
|
241
|
+
is_null = u8
|
|
242
|
+
n = u32
|
|
243
|
+
data = @d.byteslice(@p, n)
|
|
244
|
+
@p += n
|
|
245
|
+
is_null.zero? ? Codec.scalar(t, data) : nil
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
# uint16 count of (uint16 keyLen + key + TypedValueEntry) property pairs.
|
|
249
|
+
def props
|
|
250
|
+
out = {}
|
|
251
|
+
u16.times do
|
|
252
|
+
key = str
|
|
253
|
+
out[key] = tve
|
|
254
|
+
end
|
|
255
|
+
out
|
|
256
|
+
end
|
|
257
|
+
end
|
|
258
|
+
|
|
259
|
+
# ── composites ──────────────────────────────────────────────────────
|
|
260
|
+
|
|
261
|
+
def list(data)
|
|
262
|
+
r = Reader.new(data)
|
|
263
|
+
Array.new(r.u16) { r.tve }
|
|
264
|
+
end
|
|
265
|
+
|
|
266
|
+
def table(data)
|
|
267
|
+
r = Reader.new(data)
|
|
268
|
+
cols = Array.new(r.u16) { r.str }
|
|
269
|
+
rows = Array.new(r.u16) { Array.new(r.u16) { r.tve } }
|
|
270
|
+
{ "_kind" => "table", "columns" => cols, "rows" => rows }
|
|
271
|
+
end
|
|
272
|
+
|
|
273
|
+
# Optional 8-byte little-endian uint64 internal-id trailer (gqldb 6.1.147+).
|
|
274
|
+
def trailer(reader)
|
|
275
|
+
reader.p + 8 <= reader.d.bytesize ? reader.u64.to_s : ""
|
|
276
|
+
end
|
|
277
|
+
|
|
278
|
+
def node(data)
|
|
279
|
+
r = Reader.new(data)
|
|
280
|
+
nid = r.str
|
|
281
|
+
labels = Array.new(r.u16) { r.str }
|
|
282
|
+
props = r.props
|
|
283
|
+
{ "_kind" => "node", "id" => nid, "labels" => labels, "properties" => props,
|
|
284
|
+
"uuid" => trailer(r) }
|
|
285
|
+
end
|
|
286
|
+
|
|
287
|
+
def edge(data)
|
|
288
|
+
r = Reader.new(data)
|
|
289
|
+
eid = r.str
|
|
290
|
+
etype = r.str
|
|
291
|
+
src = r.str
|
|
292
|
+
dst = r.str
|
|
293
|
+
props = r.props
|
|
294
|
+
{ "_kind" => "edge", "id" => eid, "type" => etype, "from" => src, "to" => dst,
|
|
295
|
+
"properties" => props, "uuid" => trailer(r) }
|
|
296
|
+
end
|
|
297
|
+
|
|
298
|
+
def path(data)
|
|
299
|
+
r = Reader.new(data)
|
|
300
|
+
nodes = Array.new(r.u16) do
|
|
301
|
+
n = r.u32
|
|
302
|
+
v = node(r.d.byteslice(r.p, n))
|
|
303
|
+
r.p += n
|
|
304
|
+
v
|
|
305
|
+
end
|
|
306
|
+
edges = Array.new(r.u16) do
|
|
307
|
+
n = r.u32
|
|
308
|
+
v = edge(r.d.byteslice(r.p, n))
|
|
309
|
+
r.p += n
|
|
310
|
+
v
|
|
311
|
+
end
|
|
312
|
+
{ "_kind" => "path", "nodes" => nodes, "edges" => edges }
|
|
313
|
+
end
|
|
314
|
+
|
|
315
|
+
# ── dispatch ────────────────────────────────────────────────────────
|
|
316
|
+
|
|
317
|
+
# Decode a raw property/value payload of a given PropertyType. Used both for the
|
|
318
|
+
# top-level TypedValue and for each value inside a composite.
|
|
319
|
+
def scalar(ptype, data)
|
|
320
|
+
t = ptype
|
|
321
|
+
t = INT_TO_SYM[t] || t if t.is_a?(Integer)
|
|
322
|
+
return nil if NULLISH.include?(t)
|
|
323
|
+
|
|
324
|
+
fmt = NUMERIC[t]
|
|
325
|
+
unless fmt.nil?
|
|
326
|
+
return nil if data.nil? || data.empty?
|
|
327
|
+
|
|
328
|
+
return data.unpack1(fmt)
|
|
329
|
+
end
|
|
330
|
+
return data.dup.force_encoding("UTF-8") if STRINGY.include?(t)
|
|
331
|
+
return !(data.nil? || data.empty? || data.getbyte(0).zero?) if t == :PROPERTY_TYPE_BOOL
|
|
332
|
+
# BLOB: raw bytes as a binary string.
|
|
333
|
+
return (data || "".b).dup.force_encoding("BINARY") if t == :PROPERTY_TYPE_BLOB
|
|
334
|
+
# DECIMAL is a precision-preserving UTF-8 string: return it verbatim, never to_f.
|
|
335
|
+
return data.dup.force_encoding("UTF-8") if t == :PROPERTY_TYPE_DECIMAL
|
|
336
|
+
return date(data) if t == :PROPERTY_TYPE_DATE
|
|
337
|
+
return local_datetime(data) if LOCAL_DT.include?(t)
|
|
338
|
+
return zoned_datetime(data) if t == :PROPERTY_TYPE_ZONED_DATETIME
|
|
339
|
+
return timestamp(data) if t == :PROPERTY_TYPE_TIMESTAMP
|
|
340
|
+
return local_time(data) if t == :PROPERTY_TYPE_LOCAL_TIME
|
|
341
|
+
return zoned_time(data) if t == :PROPERTY_TYPE_ZONED_TIME
|
|
342
|
+
return year_to_month(data) if t == :PROPERTY_TYPE_YEAR_TO_MONTH
|
|
343
|
+
return day_to_second(data) if t == :PROPERTY_TYPE_DAY_TO_SECOND
|
|
344
|
+
return point(data) if t == :PROPERTY_TYPE_POINT
|
|
345
|
+
return point3d(data) if t == :PROPERTY_TYPE_POINT3D
|
|
346
|
+
return vector(data) if t == :PROPERTY_TYPE_VECTOR
|
|
347
|
+
return list(data) if LISTY.include?(t)
|
|
348
|
+
return Reader.new(data).props if MAPPY.include?(t)
|
|
349
|
+
return table(data) if t == :PROPERTY_TYPE_TABLE
|
|
350
|
+
return node(data) if t == :PROPERTY_TYPE_NODE
|
|
351
|
+
return edge(data) if t == :PROPERTY_TYPE_EDGE
|
|
352
|
+
return path(data) if t == :PROPERTY_TYPE_PATH
|
|
353
|
+
return error(data) if t == :PROPERTY_TYPE_ERROR
|
|
354
|
+
|
|
355
|
+
# Unmapped/future types: hand back the raw bytes rather than lose or guess.
|
|
356
|
+
data
|
|
357
|
+
end
|
|
358
|
+
|
|
359
|
+
# gqldb.TypedValue -> a Ruby value (nil for null; Hash for node/edge/...).
|
|
360
|
+
def decode(tv)
|
|
361
|
+
return nil if tv.is_null
|
|
362
|
+
|
|
363
|
+
scalar(tv.type, tv.data)
|
|
364
|
+
end
|
|
365
|
+
|
|
366
|
+
# A Ruby value -> gqldb.TypedValue, for query parameters.
|
|
367
|
+
def encode(value)
|
|
368
|
+
case value
|
|
369
|
+
when nil
|
|
370
|
+
Gqldb::TypedValue.new(type: :PROPERTY_TYPE_NULL, is_null: true)
|
|
371
|
+
when true, false
|
|
372
|
+
Gqldb::TypedValue.new(type: :PROPERTY_TYPE_BOOL, data: (value ? "\x01" : "\x00").b)
|
|
373
|
+
when Integer
|
|
374
|
+
Gqldb::TypedValue.new(type: :PROPERTY_TYPE_INT64, data: [value].pack("q<"))
|
|
375
|
+
when Float
|
|
376
|
+
Gqldb::TypedValue.new(type: :PROPERTY_TYPE_DOUBLE, data: [value].pack("E"))
|
|
377
|
+
when String
|
|
378
|
+
# A binary (ASCII-8BIT) String is the Ruby analogue of Python `bytes`:
|
|
379
|
+
# send it as a BLOB so raw bytes round-trip intact. UTF-8/other -> STRING.
|
|
380
|
+
if value.encoding == Encoding::ASCII_8BIT
|
|
381
|
+
Gqldb::TypedValue.new(type: :PROPERTY_TYPE_BLOB, data: value)
|
|
382
|
+
else
|
|
383
|
+
Gqldb::TypedValue.new(type: :PROPERTY_TYPE_STRING, data: value.b)
|
|
384
|
+
end
|
|
385
|
+
else
|
|
386
|
+
# Anything else -> UTF-8 bytes.
|
|
387
|
+
Gqldb::TypedValue.new(type: :PROPERTY_TYPE_STRING, data: value.to_s.b)
|
|
388
|
+
end
|
|
389
|
+
end
|
|
390
|
+
end
|
|
391
|
+
end
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Tina4Ultipa
|
|
4
|
+
# A gqldb server or protocol error. Writes and bad statements fail LOUD (raise),
|
|
5
|
+
# never a falsy return - the framework's graph layer relies on this.
|
|
6
|
+
# `code` is the gRPC status name when known.
|
|
7
|
+
class Error < StandardError
|
|
8
|
+
attr_reader :code
|
|
9
|
+
|
|
10
|
+
def initialize(message, code: nil, cause: nil)
|
|
11
|
+
super(message)
|
|
12
|
+
@code = code
|
|
13
|
+
@cause_error = cause
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
# The wrapped lower-level exception (gRPC error), when there is one.
|
|
17
|
+
attr_reader :cause_error
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# Could not connect within the timeout. Names host, port and elapsed seconds.
|
|
21
|
+
class ConnectError < Error
|
|
22
|
+
attr_reader :host, :port, :elapsed
|
|
23
|
+
|
|
24
|
+
def initialize(host, port, elapsed, cause: nil)
|
|
25
|
+
super(
|
|
26
|
+
"could not connect to ultipa at #{host}:#{port} within #{format('%.2f', elapsed)}s",
|
|
27
|
+
code: "UNAVAILABLE",
|
|
28
|
+
cause: cause,
|
|
29
|
+
)
|
|
30
|
+
@host = host
|
|
31
|
+
@port = port
|
|
32
|
+
@elapsed = elapsed
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
# Generated by the protocol buffer compiler. DO NOT EDIT!
|
|
3
|
+
# source: gqldb.proto
|
|
4
|
+
|
|
5
|
+
require 'google/protobuf'
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
descriptor_data = "\n\x0bgqldb.proto\x12\x05gqldb\"N\n\nTypedValue\x12!\n\x04type\x18\x01 \x01(\x0e\x32\x13.gqldb.PropertyType\x12\x0c\n\x04\x64\x61ta\x18\x02 \x01(\x0c\x12\x0f\n\x07is_null\x18\x03 \x01(\x08\";\n\tParameter\x12\x0c\n\x04name\x18\x01 \x01(\t\x12 \n\x05value\x18\x02 \x01(\x0b\x32\x11.gqldb.TypedValue\"(\n\x03Row\x12!\n\x06values\x18\x01 \x03(\x0b\x32\x11.gqldb.TypedValue\"\x8e\x01\n\x08\x44mlStats\x12\x16\n\x0einserted_nodes\x18\x01 \x01(\x03\x12\x16\n\x0einserted_edges\x18\x02 \x01(\x03\x12\x15\n\rdeleted_nodes\x18\x03 \x01(\x03\x12\x15\n\rdeleted_edges\x18\x04 \x01(\x03\x12\x11\n\tset_nodes\x18\x05 \x01(\x03\x12\x11\n\tset_edges\x18\x06 \x01(\x03\"I\n\x0cLoginRequest\x12\x10\n\x08username\x18\x01 \x01(\t\x12\x10\n\x08password\x18\x02 \x01(\t\x12\x15\n\rdefault_graph\x18\x03 \x01(\t\"\xfd\x01\n\rLoginResponse\x12\x12\n\nsession_id\x18\x01 \x01(\x04\x12\x16\n\x0eserver_version\x18\x02 \x01(\t\x12\r\n\x05roles\x18\x03 \x03(\t\x12\x12\n\nis_cluster\x18\n \x01(\x08\x12\x12\n\ncluster_id\x18\x0b \x01(\t\x12\x17\n\x0fpartition_count\x18\x0c \x01(\x05\x12\x14\n\x0c\x63\x61pabilities\x18\x14 \x03(\t\x12\x15\n\rcurrent_graph\x18\x15 \x01(\t\x12\x14\n\x0ctime_cost_ns\x18\x16 \x01(\x03\x12\x14\n\x0c\x64isk_cost_ns\x18\x17 \x01(\x03\x12\x17\n\x0f\x63ompute_cost_ns\x18\x18 \x01(\x03\"\xbd\x01\n\nGqlRequest\x12\x0b\n\x03gql\x18\x01 \x01(\t\x12\x12\n\ngraph_name\x18\x02 \x01(\t\x12$\n\nparameters\x18\x03 \x03(\x0b\x32\x10.gqldb.Parameter\x12\x12\n\nsession_id\x18\x04 \x01(\x04\x12\x16\n\x0etransaction_id\x18\x05 \x01(\x04\x12\x0f\n\x07timeout\x18\x06 \x01(\x05\x12\x11\n\tread_only\x18\x07 \x01(\x08\x12\x18\n\x10max_path_results\x18\x08 \x01(\x03\"\x86\x02\n\x0bGqlResponse\x12\x0f\n\x07\x63olumns\x18\x01 \x03(\t\x12\x18\n\x04rows\x18\x02 \x03(\x0b\x32\n.gqldb.Row\x12\x11\n\trow_count\x18\x03 \x01(\x03\x12\x10\n\x08has_more\x18\x04 \x01(\x08\x12\x10\n\x08warnings\x18\x05 \x03(\t\x12\x15\n\rrows_affected\x18\x06 \x01(\x03\x12\x15\n\rcurrent_graph\x18\x07 \x01(\t\x12\x14\n\x0ctime_cost_ns\x18\x08 \x01(\x03\x12\x14\n\x0c\x64isk_cost_ns\x18\t \x01(\x03\x12\x17\n\x0f\x63ompute_cost_ns\x18\n \x01(\x03\x12\"\n\tdml_stats\x18\x0b \x01(\x0b\x32\x0f.gqldb.DmlStats*\x90\x07\n\x0cPropertyType\x12\x17\n\x13PROPERTY_TYPE_UNSET\x10\x00\x12\x17\n\x13PROPERTY_TYPE_INT32\x10\x01\x12\x18\n\x14PROPERTY_TYPE_UINT32\x10\x02\x12\x17\n\x13PROPERTY_TYPE_INT64\x10\x03\x12\x18\n\x14PROPERTY_TYPE_UINT64\x10\x04\x12\x17\n\x13PROPERTY_TYPE_FLOAT\x10\x05\x12\x18\n\x14PROPERTY_TYPE_DOUBLE\x10\x06\x12\x18\n\x14PROPERTY_TYPE_STRING\x10\x07\x12\x1a\n\x16PROPERTY_TYPE_DATETIME\x10\x08\x12\x1b\n\x17PROPERTY_TYPE_TIMESTAMP\x10\t\x12\x16\n\x12PROPERTY_TYPE_TEXT\x10\n\x12\x16\n\x12PROPERTY_TYPE_BLOB\x10\x0b\x12\x17\n\x13PROPERTY_TYPE_POINT\x10\x0c\x12\x19\n\x15PROPERTY_TYPE_DECIMAL\x10\r\x12\x16\n\x12PROPERTY_TYPE_LIST\x10\x0e\x12\x15\n\x11PROPERTY_TYPE_SET\x10\x0f\x12\x15\n\x11PROPERTY_TYPE_MAP\x10\x10\x12\x16\n\x12PROPERTY_TYPE_NULL\x10\x11\x12\x16\n\x12PROPERTY_TYPE_BOOL\x10\x12\x12 \n\x1cPROPERTY_TYPE_LOCAL_DATETIME\x10\x13\x12 \n\x1cPROPERTY_TYPE_ZONED_DATETIME\x10\x14\x12\x16\n\x12PROPERTY_TYPE_DATE\x10\x15\x12\x1c\n\x18PROPERTY_TYPE_ZONED_TIME\x10\x16\x12\x1c\n\x18PROPERTY_TYPE_LOCAL_TIME\x10\x17\x12\x1f\n\x1bPROPERTY_TYPE_YEAR_TO_MONTH\x10\x18\x12\x1f\n\x1bPROPERTY_TYPE_DAY_TO_SECOND\x10\x19\x12\x18\n\x14PROPERTY_TYPE_RECORD\x10\x1a\x12\x19\n\x15PROPERTY_TYPE_POINT3D\x10\x1b\x12\x18\n\x14PROPERTY_TYPE_VECTOR\x10\x1c\x12\x17\n\x13PROPERTY_TYPE_TABLE\x10\x1d\x12\x16\n\x12PROPERTY_TYPE_PATH\x10\x1e\x12\x17\n\x13PROPERTY_TYPE_ERROR\x10\x1f\x12\x16\n\x12PROPERTY_TYPE_NODE\x10 \x12\x16\n\x12PROPERTY_TYPE_EDGE\x10!2D\n\x0eSessionService\x12\x32\n\x05Login\x12\x13.gqldb.LoginRequest\x1a\x14.gqldb.LoginResponse2<\n\x0cQueryService\x12,\n\x03Gql\x12\x11.gqldb.GqlRequest\x1a\x12.gqldb.GqlResponseb\x06proto3"
|
|
9
|
+
|
|
10
|
+
pool = ::Google::Protobuf::DescriptorPool.generated_pool
|
|
11
|
+
pool.add_serialized_file(descriptor_data)
|
|
12
|
+
|
|
13
|
+
module Gqldb
|
|
14
|
+
TypedValue = ::Google::Protobuf::DescriptorPool.generated_pool.lookup("gqldb.TypedValue").msgclass
|
|
15
|
+
Parameter = ::Google::Protobuf::DescriptorPool.generated_pool.lookup("gqldb.Parameter").msgclass
|
|
16
|
+
Row = ::Google::Protobuf::DescriptorPool.generated_pool.lookup("gqldb.Row").msgclass
|
|
17
|
+
DmlStats = ::Google::Protobuf::DescriptorPool.generated_pool.lookup("gqldb.DmlStats").msgclass
|
|
18
|
+
LoginRequest = ::Google::Protobuf::DescriptorPool.generated_pool.lookup("gqldb.LoginRequest").msgclass
|
|
19
|
+
LoginResponse = ::Google::Protobuf::DescriptorPool.generated_pool.lookup("gqldb.LoginResponse").msgclass
|
|
20
|
+
GqlRequest = ::Google::Protobuf::DescriptorPool.generated_pool.lookup("gqldb.GqlRequest").msgclass
|
|
21
|
+
GqlResponse = ::Google::Protobuf::DescriptorPool.generated_pool.lookup("gqldb.GqlResponse").msgclass
|
|
22
|
+
PropertyType = ::Google::Protobuf::DescriptorPool.generated_pool.lookup("gqldb.PropertyType").enummodule
|
|
23
|
+
end
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# Generated by the protocol buffer compiler. DO NOT EDIT!
|
|
2
|
+
# Source: gqldb.proto for package 'gqldb'
|
|
3
|
+
|
|
4
|
+
require 'grpc'
|
|
5
|
+
require 'gqldb_pb'
|
|
6
|
+
|
|
7
|
+
module Gqldb
|
|
8
|
+
module SessionService
|
|
9
|
+
class Service
|
|
10
|
+
|
|
11
|
+
include ::GRPC::GenericService
|
|
12
|
+
|
|
13
|
+
self.marshal_class_method = :encode
|
|
14
|
+
self.unmarshal_class_method = :decode
|
|
15
|
+
self.service_name = 'gqldb.SessionService'
|
|
16
|
+
|
|
17
|
+
rpc :Login, ::Gqldb::LoginRequest, ::Gqldb::LoginResponse
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
Stub = Service.rpc_stub_class
|
|
21
|
+
end
|
|
22
|
+
module QueryService
|
|
23
|
+
class Service
|
|
24
|
+
|
|
25
|
+
include ::GRPC::GenericService
|
|
26
|
+
|
|
27
|
+
self.marshal_class_method = :encode
|
|
28
|
+
self.unmarshal_class_method = :decode
|
|
29
|
+
self.service_name = 'gqldb.QueryService'
|
|
30
|
+
|
|
31
|
+
rpc :Gql, ::Gqldb::GqlRequest, ::Gqldb::GqlResponse
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
Stub = Service.rpc_stub_class
|
|
35
|
+
end
|
|
36
|
+
end
|
data/lib/tina4_ultipa.rb
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# tina4-ultipa - a thin gRPC client for the Ultipa (gqldb) graph database.
|
|
4
|
+
#
|
|
5
|
+
# Used by Tina4's graph data layer, and usable standalone:
|
|
6
|
+
#
|
|
7
|
+
# require "tina4_ultipa"
|
|
8
|
+
# db = Tina4Ultipa::Client.from_url("ultipa://admin:pass@host:60061/mygraph")
|
|
9
|
+
# db.query("MATCH (n) RETURN n LIMIT 10").each { |row| puts row }
|
|
10
|
+
|
|
11
|
+
require_relative "tina4_ultipa/version"
|
|
12
|
+
require_relative "tina4_ultipa/errors"
|
|
13
|
+
require_relative "tina4_ultipa/client"
|
|
14
|
+
|
|
15
|
+
module Tina4Ultipa
|
|
16
|
+
end
|
data/proto/gqldb.proto
ADDED
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
syntax = "proto3";
|
|
2
|
+
|
|
3
|
+
// Minimal subset of the Ultipa `ultipa-gqldb` gRPC surface that this driver
|
|
4
|
+
// uses: authenticate (SessionService.Login) and run GQL (QueryService.Gql).
|
|
5
|
+
// Field numbers/types match the server exactly (grounded via reflection against
|
|
6
|
+
// the community edition, 2026-08-20). proto3 ignores unknown fields, so the
|
|
7
|
+
// server's extra methods/fields we don't list here are harmless.
|
|
8
|
+
package gqldb;
|
|
9
|
+
|
|
10
|
+
enum PropertyType {
|
|
11
|
+
PROPERTY_TYPE_UNSET = 0;
|
|
12
|
+
PROPERTY_TYPE_INT32 = 1;
|
|
13
|
+
PROPERTY_TYPE_UINT32 = 2;
|
|
14
|
+
PROPERTY_TYPE_INT64 = 3;
|
|
15
|
+
PROPERTY_TYPE_UINT64 = 4;
|
|
16
|
+
PROPERTY_TYPE_FLOAT = 5;
|
|
17
|
+
PROPERTY_TYPE_DOUBLE = 6;
|
|
18
|
+
PROPERTY_TYPE_STRING = 7;
|
|
19
|
+
PROPERTY_TYPE_DATETIME = 8;
|
|
20
|
+
PROPERTY_TYPE_TIMESTAMP = 9;
|
|
21
|
+
PROPERTY_TYPE_TEXT = 10;
|
|
22
|
+
PROPERTY_TYPE_BLOB = 11;
|
|
23
|
+
PROPERTY_TYPE_POINT = 12;
|
|
24
|
+
PROPERTY_TYPE_DECIMAL = 13;
|
|
25
|
+
PROPERTY_TYPE_LIST = 14;
|
|
26
|
+
PROPERTY_TYPE_SET = 15;
|
|
27
|
+
PROPERTY_TYPE_MAP = 16;
|
|
28
|
+
PROPERTY_TYPE_NULL = 17;
|
|
29
|
+
PROPERTY_TYPE_BOOL = 18;
|
|
30
|
+
PROPERTY_TYPE_LOCAL_DATETIME = 19;
|
|
31
|
+
PROPERTY_TYPE_ZONED_DATETIME = 20;
|
|
32
|
+
PROPERTY_TYPE_DATE = 21;
|
|
33
|
+
PROPERTY_TYPE_ZONED_TIME = 22;
|
|
34
|
+
PROPERTY_TYPE_LOCAL_TIME = 23;
|
|
35
|
+
PROPERTY_TYPE_YEAR_TO_MONTH = 24;
|
|
36
|
+
PROPERTY_TYPE_DAY_TO_SECOND = 25;
|
|
37
|
+
PROPERTY_TYPE_RECORD = 26;
|
|
38
|
+
PROPERTY_TYPE_POINT3D = 27;
|
|
39
|
+
PROPERTY_TYPE_VECTOR = 28;
|
|
40
|
+
PROPERTY_TYPE_TABLE = 29;
|
|
41
|
+
PROPERTY_TYPE_PATH = 30;
|
|
42
|
+
PROPERTY_TYPE_ERROR = 31;
|
|
43
|
+
PROPERTY_TYPE_NODE = 32;
|
|
44
|
+
PROPERTY_TYPE_EDGE = 33;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
message TypedValue {
|
|
48
|
+
PropertyType type = 1;
|
|
49
|
+
bytes data = 2;
|
|
50
|
+
bool is_null = 3;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
message Parameter {
|
|
54
|
+
string name = 1;
|
|
55
|
+
TypedValue value = 2;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
message Row {
|
|
59
|
+
repeated TypedValue values = 1;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
message DmlStats {
|
|
63
|
+
int64 inserted_nodes = 1;
|
|
64
|
+
int64 inserted_edges = 2;
|
|
65
|
+
int64 deleted_nodes = 3;
|
|
66
|
+
int64 deleted_edges = 4;
|
|
67
|
+
int64 set_nodes = 5;
|
|
68
|
+
int64 set_edges = 6;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
message LoginRequest {
|
|
72
|
+
string username = 1;
|
|
73
|
+
string password = 2;
|
|
74
|
+
string default_graph = 3;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
message LoginResponse {
|
|
78
|
+
uint64 session_id = 1;
|
|
79
|
+
string server_version = 2;
|
|
80
|
+
repeated string roles = 3;
|
|
81
|
+
bool is_cluster = 10;
|
|
82
|
+
string cluster_id = 11;
|
|
83
|
+
int32 partition_count = 12;
|
|
84
|
+
repeated string capabilities = 20;
|
|
85
|
+
string current_graph = 21;
|
|
86
|
+
int64 time_cost_ns = 22;
|
|
87
|
+
int64 disk_cost_ns = 23;
|
|
88
|
+
int64 compute_cost_ns = 24;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
message GqlRequest {
|
|
92
|
+
string gql = 1;
|
|
93
|
+
string graph_name = 2;
|
|
94
|
+
repeated Parameter parameters = 3;
|
|
95
|
+
uint64 session_id = 4;
|
|
96
|
+
uint64 transaction_id = 5;
|
|
97
|
+
int32 timeout = 6;
|
|
98
|
+
bool read_only = 7;
|
|
99
|
+
int64 max_path_results = 8;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
message GqlResponse {
|
|
103
|
+
repeated string columns = 1;
|
|
104
|
+
repeated Row rows = 2;
|
|
105
|
+
int64 row_count = 3;
|
|
106
|
+
bool has_more = 4;
|
|
107
|
+
repeated string warnings = 5;
|
|
108
|
+
int64 rows_affected = 6;
|
|
109
|
+
string current_graph = 7;
|
|
110
|
+
int64 time_cost_ns = 8;
|
|
111
|
+
int64 disk_cost_ns = 9;
|
|
112
|
+
int64 compute_cost_ns = 10;
|
|
113
|
+
DmlStats dml_stats = 11;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
service SessionService {
|
|
117
|
+
rpc Login (LoginRequest) returns (LoginResponse);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
service QueryService {
|
|
121
|
+
rpc Gql (GqlRequest) returns (GqlResponse);
|
|
122
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "lib/tina4_ultipa/version"
|
|
4
|
+
|
|
5
|
+
Gem::Specification.new do |spec|
|
|
6
|
+
spec.name = "tina4-ultipa"
|
|
7
|
+
spec.version = Tina4Ultipa::VERSION
|
|
8
|
+
spec.authors = ["Tina4 Stack"]
|
|
9
|
+
spec.email = ["info@tina4.com"]
|
|
10
|
+
|
|
11
|
+
spec.summary = "Thin gRPC client for the Ultipa graph database (ultipa-gqldb)"
|
|
12
|
+
spec.description = "A thin, standalone gRPC client for the Ultipa graph database " \
|
|
13
|
+
"(ultipa-gqldb) - the Ultipa driver behind Tina4's graph layer, " \
|
|
14
|
+
"usable on its own."
|
|
15
|
+
spec.homepage = "https://github.com/tina4stack/ultipa-ruby"
|
|
16
|
+
spec.license = "MIT"
|
|
17
|
+
spec.required_ruby_version = ">= 3.0"
|
|
18
|
+
|
|
19
|
+
spec.metadata["homepage_uri"] = spec.homepage
|
|
20
|
+
spec.metadata["source_code_uri"] = spec.homepage
|
|
21
|
+
|
|
22
|
+
spec.files = Dir[
|
|
23
|
+
"lib/**/*.rb",
|
|
24
|
+
"proto/*.proto",
|
|
25
|
+
"README.md",
|
|
26
|
+
"LICENSE",
|
|
27
|
+
"tina4-ultipa.gemspec",
|
|
28
|
+
]
|
|
29
|
+
spec.require_paths = ["lib"]
|
|
30
|
+
|
|
31
|
+
spec.add_dependency "google-protobuf", ">= 3.25"
|
|
32
|
+
spec.add_dependency "grpc", ">= 1.60"
|
|
33
|
+
end
|
metadata
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: tina4-ultipa
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- Tina4 Stack
|
|
8
|
+
autorequire:
|
|
9
|
+
bindir: bin
|
|
10
|
+
cert_chain: []
|
|
11
|
+
date: 2026-08-21 00:00:00.000000000 Z
|
|
12
|
+
dependencies:
|
|
13
|
+
- !ruby/object:Gem::Dependency
|
|
14
|
+
name: google-protobuf
|
|
15
|
+
requirement: !ruby/object:Gem::Requirement
|
|
16
|
+
requirements:
|
|
17
|
+
- - ">="
|
|
18
|
+
- !ruby/object:Gem::Version
|
|
19
|
+
version: '3.25'
|
|
20
|
+
type: :runtime
|
|
21
|
+
prerelease: false
|
|
22
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
23
|
+
requirements:
|
|
24
|
+
- - ">="
|
|
25
|
+
- !ruby/object:Gem::Version
|
|
26
|
+
version: '3.25'
|
|
27
|
+
- !ruby/object:Gem::Dependency
|
|
28
|
+
name: grpc
|
|
29
|
+
requirement: !ruby/object:Gem::Requirement
|
|
30
|
+
requirements:
|
|
31
|
+
- - ">="
|
|
32
|
+
- !ruby/object:Gem::Version
|
|
33
|
+
version: '1.60'
|
|
34
|
+
type: :runtime
|
|
35
|
+
prerelease: false
|
|
36
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
37
|
+
requirements:
|
|
38
|
+
- - ">="
|
|
39
|
+
- !ruby/object:Gem::Version
|
|
40
|
+
version: '1.60'
|
|
41
|
+
description: A thin, standalone gRPC client for the Ultipa graph database (ultipa-gqldb)
|
|
42
|
+
- the Ultipa driver behind Tina4's graph layer, usable on its own.
|
|
43
|
+
email:
|
|
44
|
+
- info@tina4.com
|
|
45
|
+
executables: []
|
|
46
|
+
extensions: []
|
|
47
|
+
extra_rdoc_files: []
|
|
48
|
+
files:
|
|
49
|
+
- LICENSE
|
|
50
|
+
- README.md
|
|
51
|
+
- lib/tina4_ultipa.rb
|
|
52
|
+
- lib/tina4_ultipa/client.rb
|
|
53
|
+
- lib/tina4_ultipa/codec.rb
|
|
54
|
+
- lib/tina4_ultipa/errors.rb
|
|
55
|
+
- lib/tina4_ultipa/gen/gqldb_pb.rb
|
|
56
|
+
- lib/tina4_ultipa/gen/gqldb_services_pb.rb
|
|
57
|
+
- lib/tina4_ultipa/version.rb
|
|
58
|
+
- proto/gqldb.proto
|
|
59
|
+
- tina4-ultipa.gemspec
|
|
60
|
+
homepage: https://github.com/tina4stack/ultipa-ruby
|
|
61
|
+
licenses:
|
|
62
|
+
- MIT
|
|
63
|
+
metadata:
|
|
64
|
+
homepage_uri: https://github.com/tina4stack/ultipa-ruby
|
|
65
|
+
source_code_uri: https://github.com/tina4stack/ultipa-ruby
|
|
66
|
+
post_install_message:
|
|
67
|
+
rdoc_options: []
|
|
68
|
+
require_paths:
|
|
69
|
+
- lib
|
|
70
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
71
|
+
requirements:
|
|
72
|
+
- - ">="
|
|
73
|
+
- !ruby/object:Gem::Version
|
|
74
|
+
version: '3.0'
|
|
75
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
76
|
+
requirements:
|
|
77
|
+
- - ">="
|
|
78
|
+
- !ruby/object:Gem::Version
|
|
79
|
+
version: '0'
|
|
80
|
+
requirements: []
|
|
81
|
+
rubygems_version: 3.4.19
|
|
82
|
+
signing_key:
|
|
83
|
+
specification_version: 4
|
|
84
|
+
summary: Thin gRPC client for the Ultipa graph database (ultipa-gqldb)
|
|
85
|
+
test_files: []
|