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 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
+ &nbsp;&nbsp;<span>&times;</span>&nbsp;&nbsp;
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> &bull;
25
+ <a href="#type-support">Type Support</a> &bull;
26
+ <a href="https://www.ultipa.com/docs">Ultipa Docs</a> &bull;
27
+ <a href="PROTOCOL.md">Protocol</a> &bull;
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
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Tina4Ultipa
4
+ VERSION = "0.1.0"
5
+ end
@@ -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: []