tina4ruby 3.13.110 → 3.13.112

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.
@@ -0,0 +1,186 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Ultipa graph adapter — the portable core built in GQL over tina4-ultipa.
4
+ #
5
+ # Wraps the standalone +tina4-ultipa+ gem (an OPTIONAL dependency — required
6
+ # here, so +require "tina4/graph"+ stays driver-free). The portable
7
+ # node/edge/traverse surface is expressed in Ultipa GQL on top of the driver's
8
+ # +query+/+execute+; raw +query+/+execute+ send GQL straight through.
9
+ #
10
+ # GQL note: the exact statements are verified against the live Ultipa community
11
+ # edition on the lab (no mocks). Ultipa node ids come back as UUID strings; edge
12
+ # ids need EDGE_ID enabled on the graph (a one-time per-graph setting the lab
13
+ # provisions).
14
+
15
+ # The gem is optional: this require is what makes a missing driver surface as the
16
+ # actionable install error in GraphDatabase.create.
17
+ require "tina4_ultipa"
18
+
19
+ module Tina4
20
+ module Drivers
21
+ class UltipaGraphDriver < Tina4::GraphAdapter
22
+ # nil (unbounded) is mapped to a very large finite deadline for the gRPC
23
+ # driver, whose connect deadline is Time.now + connect_timeout (a 0 there
24
+ # would mean "do not wait", not "wait forever").
25
+ UNBOUNDED_CONNECT_TIMEOUT = 315_360_000 # ~10 years
26
+
27
+ def initialize(graph_url, username: "", password: "")
28
+ @url = graph_url
29
+ @last_error = nil
30
+ timeout = Tina4.resolve_graph_connect_timeout
31
+ @client = Tina4Ultipa::Client.new(
32
+ host: graph_url.host,
33
+ port: graph_url.port,
34
+ username: graph_url.username || (username unless username.to_s.empty?),
35
+ password: graph_url.password || (password unless password.to_s.empty?),
36
+ graph: graph_url.graph,
37
+ connect_timeout: timeout.nil? ? UNBOUNDED_CONNECT_TIMEOUT : timeout,
38
+ use_tls: graph_url.use_tls,
39
+ )
40
+ end
41
+
42
+ # -- connection + raw pass-through -----------------------------------
43
+
44
+ def query(text, params = nil)
45
+ result = run(text, params: params, read_only: true)
46
+ Tina4::GraphResult.new(records: result.dicts, columns: result.columns)
47
+ end
48
+
49
+ def execute(text, params = nil)
50
+ result = run(text, params: params, read_only: false)
51
+ Tina4::GraphResult.new(records: result.dicts, columns: result.columns)
52
+ end
53
+
54
+ # -- portable node/edge/traverse core (GQL) --------------------------
55
+
56
+ def add_node(label, properties = nil)
57
+ prop_map, params = prop_clause(properties)
58
+ gql = "INSERT (n:`#{label}` #{prop_map}) " \
59
+ "RETURN id(n) AS id, labels(n) AS labels, properties(n) AS props"
60
+ rows = run(gql, params: params, read_only: false).dicts
61
+ node_from_row(rows[0])
62
+ end
63
+
64
+ def add_edge(from_id, to_id, type, properties = nil)
65
+ # id(e) requires EDGE_ID enabled on the Ultipa graph
66
+ # (`ALTER GRAPH <g> SET EDGE_ID ENABLED`), a one-time per-graph setting.
67
+ prop_map, params = prop_clause(properties)
68
+ params["from_id"] = from_id
69
+ params["to_id"] = to_id
70
+ gql = "MATCH (a), (b) WHERE id(a) = $from_id AND id(b) = $to_id " \
71
+ "INSERT (a)-[e:`#{type}` #{prop_map}]->(b) " \
72
+ "RETURN id(e) AS id, type(e) AS type, id(a) AS f, id(b) AS t, " \
73
+ "properties(e) AS props"
74
+ rows = run(gql, params: params, read_only: false).dicts
75
+ return nil if rows.empty?
76
+
77
+ row = rows[0]
78
+ Tina4::GraphEdge.new(
79
+ id: row["id"], type: row["type"], from: row["f"], to: row["t"],
80
+ properties: row["props"] || {},
81
+ )
82
+ end
83
+
84
+ def get_node(node_id)
85
+ gql = "MATCH (n) WHERE id(n) = $id " \
86
+ "RETURN id(n) AS id, labels(n) AS labels, properties(n) AS props"
87
+ rows = run(gql, params: { "id" => node_id }, read_only: true).dicts
88
+ node_from_row(rows[0])
89
+ end
90
+
91
+ def update_node(node_id, properties)
92
+ properties ||= {}
93
+ sets = properties.keys.map { |key| "n.#{key} = $p_#{key}" }.join(", ")
94
+ params = {}
95
+ properties.each { |key, value| params["p_#{key}"] = value }
96
+ params["id"] = node_id
97
+ gql = "MATCH (n) WHERE id(n) = $id SET #{sets} " \
98
+ "RETURN id(n) AS id, labels(n) AS labels, properties(n) AS props"
99
+ rows = run(gql, params: params, read_only: false).dicts
100
+ node_from_row(rows[0])
101
+ end
102
+
103
+ def delete_node(node_id)
104
+ run("MATCH (n) WHERE id(n) = $id DETACH DELETE n",
105
+ params: { "id" => node_id }, read_only: false)
106
+ true
107
+ end
108
+
109
+ def neighbors(node_id, direction: "both", edge_type: nil, limit: 100)
110
+ edge = edge_type ? ":`#{edge_type}`" : ""
111
+ pattern = case direction
112
+ when "out" then "(n)-[#{edge}]->(m)"
113
+ when "in" then "(n)<-[#{edge}]-(m)"
114
+ else "(n)-[#{edge}]-(m)"
115
+ end
116
+ gql = "MATCH #{pattern} WHERE id(n) = $id " \
117
+ "RETURN DISTINCT id(m) AS id, labels(m) AS labels, properties(m) AS props " \
118
+ "LIMIT #{limit.to_i}"
119
+ rows = run(gql, params: { "id" => node_id }, read_only: true).dicts
120
+ rows.map { |row| node_from_row(row) }
121
+ end
122
+
123
+ def traverse(start_id, depth: 1, direction: "both", edge_type: nil, limit: 1000)
124
+ # Ultipa GQL uses the ISO quantified-path form `-[]->{1,N}`, NOT Cypher's
125
+ # `-[*1..N]->` (which is a parse error on gqldb).
126
+ edge = edge_type ? ":`#{edge_type}`" : ""
127
+ quant = "{1,#{depth.to_i}}"
128
+ pattern = case direction
129
+ when "out" then "(n)-[#{edge}]->#{quant}(m)"
130
+ when "in" then "(n)<-[#{edge}]-#{quant}(m)"
131
+ else "(n)-[#{edge}]-#{quant}(m)"
132
+ end
133
+ gql = "MATCH #{pattern} WHERE id(n) = $start " \
134
+ "RETURN DISTINCT id(m) AS id, labels(m) AS labels, properties(m) AS props " \
135
+ "LIMIT #{limit.to_i}"
136
+ rows = run(gql, params: { "start" => start_id }, read_only: true).dicts
137
+ rows.map { |row| node_from_row(row) }
138
+ end
139
+
140
+ def close
141
+ @client.close
142
+ end
143
+
144
+ private
145
+
146
+ # Run a GQL statement, wrapping the driver's connect/query errors as the
147
+ # framework's GraphConnectTimeout / GraphError. ConnectError is rescued
148
+ # FIRST because it subclasses the driver's Error.
149
+ def run(gql, params: nil, read_only: true)
150
+ @client.query(gql, params: params, read_only: read_only)
151
+ rescue Tina4Ultipa::ConnectError => e
152
+ @last_error = e.message
153
+ raise Tina4::GraphConnectTimeout,
154
+ "Graph connect to #{@url.host}:#{@url.port} timed out " \
155
+ "(TINA4_GRAPH_CONNECT_TIMEOUT). Raise TINA4_GRAPH_CONNECT_TIMEOUT if the " \
156
+ "server is simply slow, or set it to 0 to wait indefinitely. Cause: #{e.message}"
157
+ rescue Tina4Ultipa::Error => e
158
+ @last_error = e.message
159
+ raise Tina4::GraphError, e.message
160
+ end
161
+
162
+ # Build a GQL property map `{k1: $p_k1, ...}` + the param hash for it.
163
+ #
164
+ # Params are BOUND (never interpolated), matching the relational
165
+ # ?-placeholder rule. Keys are namespaced (`p_`) so they never collide with
166
+ # an id param.
167
+ def prop_clause(properties)
168
+ properties ||= {}
169
+ return ["{}", {}] if properties.empty?
170
+
171
+ pairs = properties.keys.map { |key| "#{key}: $p_#{key}" }.join(", ")
172
+ params = {}
173
+ properties.each { |key, value| params["p_#{key}"] = value }
174
+ ["{#{pairs}}", params]
175
+ end
176
+
177
+ def node_from_row(row)
178
+ return nil if row.nil?
179
+
180
+ Tina4::GraphNode.new(
181
+ id: row["id"], labels: row["labels"], properties: row["props"] || {},
182
+ )
183
+ end
184
+ end
185
+ end
186
+ end
@@ -0,0 +1,330 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "uri"
4
+
5
+ # Tina4 graph data layer — a URL-selected graph database, shaped like Database.
6
+ #
7
+ # Feature 139. One factory (+GraphDatabase.create+ / +.from_env+), one portable
8
+ # node/edge/traverse surface plus a raw +query+/+execute+ pass-through, and
9
+ # neutral +GraphNode+ / +GraphEdge+ / +GraphResult+ shapes — the exact mirror of
10
+ # the relational +Database+ layer, for graph engines (Ultipa first). Engine
11
+ # drivers are OPTIONAL and loaded only on first use, so the zero-dependency core
12
+ # is preserved.
13
+ #
14
+ # See ADR-0059 and tina4-documentation/plan/v3/features/139-graph-databases.md.
15
+ module Tina4
16
+ # The graph connect bound — sibling of TINA4_DATABASE_CONNECT_TIMEOUT.
17
+ GRAPH_CONNECT_TIMEOUT_VAR = "TINA4_GRAPH_CONNECT_TIMEOUT"
18
+ DEFAULT_GRAPH_CONNECT_TIMEOUT_SECONDS = 10.0
19
+
20
+ # Seconds a graph connect may block; +nil+ means unbounded (a value <= 0).
21
+ #
22
+ # Mirrors the relational connect-timeout resolver: a value <= 0 disables the
23
+ # bound, a non-number warns and falls back to the default rather than waiting
24
+ # forever.
25
+ def self.resolve_graph_connect_timeout
26
+ raw = (ENV[GRAPH_CONNECT_TIMEOUT_VAR] || "").strip
27
+ return DEFAULT_GRAPH_CONNECT_TIMEOUT_SECONDS if raw.empty?
28
+
29
+ begin
30
+ seconds = Float(raw)
31
+ rescue ArgumentError, TypeError
32
+ Tina4::Log.warning(
33
+ "#{GRAPH_CONNECT_TIMEOUT_VAR}=#{raw.inspect} is not a number of seconds — " \
34
+ "bounding graph connects at the #{DEFAULT_GRAPH_CONNECT_TIMEOUT_SECONDS.to_i}s default instead",
35
+ )
36
+ return DEFAULT_GRAPH_CONNECT_TIMEOUT_SECONDS
37
+ end
38
+ seconds <= 0 ? nil : seconds
39
+ end
40
+
41
+ # A graph operation failed (bad statement, engine error).
42
+ class GraphError < StandardError; end
43
+
44
+ # A graph connect exceeded TINA4_GRAPH_CONNECT_TIMEOUT.
45
+ #
46
+ # A subclass of +GraphError+, so a +rescue GraphError+ catches a connect
47
+ # timeout too — mirroring the driver's ConnectError < Error hierarchy.
48
+ class GraphConnectTimeout < GraphError; end
49
+
50
+ # A parsed graph connection URL — the DatabaseUrl sibling for graph engines.
51
+ #
52
+ # +engine+ is the CANONICAL name the factory selects an adapter by; a scheme
53
+ # alias resolves to its engine here. bolt/neo4j/memgraph all speak Bolt/Cypher
54
+ # and share ONE adapter ("bolt"); the engine label only tunes per-engine
55
+ # defaults.
56
+ class GraphUrl
57
+ SCHEME_ENGINE = {
58
+ "ultipa" => "ultipa",
59
+ "ultipas" => "ultipa", # TLS variant
60
+ "neo4j" => "bolt",
61
+ "neo4j+s" => "bolt",
62
+ "bolt" => "bolt",
63
+ "bolt+s" => "bolt",
64
+ "memgraph" => "bolt",
65
+ "arango" => "arango",
66
+ "arangodb" => "arango",
67
+ }.freeze
68
+
69
+ # Default port per engine when the URL omits one.
70
+ ENGINE_DEFAULT_PORT = {
71
+ "ultipa" => 60_061,
72
+ "bolt" => 7687,
73
+ "arango" => 8529,
74
+ }.freeze
75
+
76
+ attr_reader :raw, :scheme, :engine, :host, :port, :graph, :username,
77
+ :password, :params, :use_tls
78
+
79
+ def initialize(url)
80
+ @raw = url
81
+ parsed = begin
82
+ URI.parse(url.to_s)
83
+ rescue URI::InvalidURIError
84
+ raise ArgumentError, "GraphUrl: Invalid URL format — expected scheme://host:port/graph"
85
+ end
86
+
87
+ scheme = (parsed.scheme || "").downcase
88
+ engine = SCHEME_ENGINE[scheme]
89
+ if engine.nil?
90
+ raise ArgumentError,
91
+ "GraphUrl: Unsupported graph URL scheme '#{scheme}'. Supported: " \
92
+ "#{SCHEME_ENGINE.keys.sort.join(', ')} (e.g. ultipa://host:60061/mygraph)."
93
+ end
94
+
95
+ @scheme = scheme
96
+ @engine = engine
97
+ @host = parsed.host && !parsed.host.empty? ? parsed.host : "localhost"
98
+ @port = parsed.port || ENGINE_DEFAULT_PORT[engine]
99
+ # The path is the graph/database name (one leading slash stripped).
100
+ graph = (parsed.path || "").sub(%r{\A/}, "")
101
+ @graph = graph.empty? ? nil : graph
102
+ @username = parsed.user ? URI.decode_www_form_component(parsed.user) : nil
103
+ @password = parsed.password ? URI.decode_www_form_component(parsed.password) : nil
104
+ @params = URI.decode_www_form(parsed.query || "").to_h
105
+ # TLS if the scheme says so (…s) or ?tls=1.
106
+ @use_tls = scheme.end_with?("s") || %w[1 true].include?(@params["tls"])
107
+ end
108
+
109
+ # host:port/graph — for messages, never carrying credentials.
110
+ def dsn
111
+ target = @port ? "#{@host}:#{@port}" : @host
112
+ @graph ? "#{target}/#{@graph}" : target
113
+ end
114
+
115
+ # Parse the configured URL, or +nil+ when the variable is not set.
116
+ def self.from_env(env_key = "TINA4_GRAPH_URL")
117
+ url = (ENV[env_key] || "").strip
118
+ url.empty? ? nil : new(url)
119
+ end
120
+ end
121
+
122
+ # An engine-neutral vertex: id, labels, properties.
123
+ class GraphNode
124
+ attr_reader :id, :labels, :properties
125
+
126
+ def initialize(id:, labels: nil, properties: nil)
127
+ @id = id
128
+ @labels = Array(labels)
129
+ @properties = properties || {}
130
+ end
131
+
132
+ def to_h
133
+ { "id" => @id, "labels" => @labels, "properties" => @properties }
134
+ end
135
+
136
+ def inspect
137
+ "#<Tina4::GraphNode id=#{@id.inspect} labels=#{@labels.inspect}>"
138
+ end
139
+ end
140
+
141
+ # An engine-neutral edge: id, type, from/to node ids, properties.
142
+ class GraphEdge
143
+ attr_reader :id, :type, :from, :to, :properties
144
+
145
+ def initialize(id:, type:, from:, to:, properties: nil)
146
+ @id = id
147
+ @type = type
148
+ @from = from
149
+ @to = to
150
+ @properties = properties || {}
151
+ end
152
+
153
+ def to_h
154
+ { "id" => @id, "type" => @type, "from" => @from, "to" => @to,
155
+ "properties" => @properties }
156
+ end
157
+
158
+ def inspect
159
+ "#<Tina4::GraphEdge id=#{@id.inspect} type=#{@type.inspect} " \
160
+ "#{@from.inspect}->#{@to.inspect}>"
161
+ end
162
+ end
163
+
164
+ # A raw-query result — records + columns, same shape as DatabaseResult.
165
+ class GraphResult
166
+ include Enumerable
167
+
168
+ attr_reader :records, :columns
169
+
170
+ def initialize(records: nil, columns: nil)
171
+ @records = records || []
172
+ @columns = columns || []
173
+ end
174
+
175
+ def to_a
176
+ @records
177
+ end
178
+
179
+ def scalar
180
+ return nil if @records.empty?
181
+
182
+ first = @records[0]
183
+ if first.is_a?(Hash)
184
+ first.values.first
185
+ elsif first.is_a?(Array)
186
+ first.empty? ? nil : first[0]
187
+ else
188
+ first
189
+ end
190
+ end
191
+
192
+ def each(&block)
193
+ @records.each(&block)
194
+ end
195
+
196
+ def length
197
+ @records.length
198
+ end
199
+ alias size length
200
+ end
201
+
202
+ # The one surface every graph engine implements. Raising stubs — a driver that
203
+ # does not override a method fails LOUDLY, naming itself and the method.
204
+ class GraphAdapter
205
+ # -- portable node/edge/traverse core ---------------------------------
206
+ def add_node(_label, _properties = nil)
207
+ not_implemented(__method__)
208
+ end
209
+
210
+ def add_edge(_from_id, _to_id, _type, _properties = nil)
211
+ not_implemented(__method__)
212
+ end
213
+
214
+ def get_node(_node_id)
215
+ not_implemented(__method__)
216
+ end
217
+
218
+ def update_node(_node_id, _properties)
219
+ not_implemented(__method__)
220
+ end
221
+
222
+ def delete_node(_node_id)
223
+ not_implemented(__method__)
224
+ end
225
+
226
+ def neighbors(_node_id, direction: "both", edge_type: nil, limit: 100)
227
+ not_implemented(__method__)
228
+ end
229
+
230
+ def traverse(_start_id, depth: 1, direction: "both", edge_type: nil, limit: 1000)
231
+ not_implemented(__method__)
232
+ end
233
+
234
+ # -- raw pass-through (engine-native dialect) -------------------------
235
+ def query(_text, _params = nil)
236
+ not_implemented(__method__)
237
+ end
238
+
239
+ def execute(_text, _params = nil)
240
+ not_implemented(__method__)
241
+ end
242
+
243
+ # -- lifecycle --------------------------------------------------------
244
+ def close
245
+ not_implemented(__method__)
246
+ end
247
+
248
+ # Cause of the last failed operation, or +nil+.
249
+ def get_error
250
+ @last_error
251
+ end
252
+
253
+ private
254
+
255
+ def not_implemented(method_name)
256
+ raise NotImplementedError, "#{self.class.name} does not implement #{method_name}"
257
+ end
258
+ end
259
+
260
+ # The URL-selected graph factory — the GraphDatabase sibling of Database.
261
+ class GraphDatabase
262
+ # engine -> { require:, class:, package:, install: }. Selected lazily so
263
+ # requiring +tina4/graph+ pulls in NO engine driver.
264
+ #
265
+ # ultipa wraps the OPTIONAL +tina4-ultipa+ gem (a missing gem surfaces as the
266
+ # actionable install error). bolt (Neo4j/Memgraph) and arango are pure-Ruby
267
+ # over stdlib (+socket+ / +Net::HTTP+) — zero third-party gems, so their
268
+ # +require+ always succeeds and the install line is never reached; it stays
269
+ # declared for a uniform registry shape.
270
+ ENGINE_ADAPTERS = {
271
+ "ultipa" => {
272
+ require: "tina4/drivers/ultipa_graph_driver",
273
+ class: "Tina4::Drivers::UltipaGraphDriver",
274
+ package: "tina4-ultipa",
275
+ install: "gem install tina4-ultipa # or add gem \"tina4-ultipa\" to your Gemfile",
276
+ },
277
+ "bolt" => {
278
+ require: "tina4/drivers/bolt_graph_driver",
279
+ class: "Tina4::Drivers::BoltGraphDriver",
280
+ package: "tina4ruby (built-in, stdlib socket)",
281
+ install: "no install needed — the Bolt driver ships with tina4ruby",
282
+ },
283
+ "arango" => {
284
+ require: "tina4/drivers/arango_graph_driver",
285
+ class: "Tina4::Drivers::ArangoGraphDriver",
286
+ package: "tina4ruby (built-in, stdlib Net::HTTP)",
287
+ install: "no install needed — the ArangoDB driver ships with tina4ruby",
288
+ },
289
+ }
290
+
291
+ # Parse the URL, pick the engine adapter, connect lazily.
292
+ #
293
+ # The engine driver is required only here (first use of that engine); if it
294
+ # is absent the error names the package and the install command.
295
+ def self.create(url, username: "", password: "")
296
+ graph_url = GraphUrl.new(url)
297
+ registration = ENGINE_ADAPTERS[graph_url.engine]
298
+ if registration.nil?
299
+ raise GraphError,
300
+ "No graph adapter for engine '#{graph_url.engine}' yet " \
301
+ "(scheme '#{graph_url.scheme}'). Available: " \
302
+ "#{ENGINE_ADAPTERS.keys.sort.join(', ')}."
303
+ end
304
+
305
+ begin
306
+ require registration[:require]
307
+ rescue LoadError => e
308
+ # The adapter file requires its driver gem at the top; a missing gem
309
+ # surfaces here as an actionable install error, never a bare LoadError.
310
+ raise GraphError,
311
+ "The graph driver for '#{graph_url.engine}' is not installed " \
312
+ "(#{registration[:package]}). Install it with:\n #{registration[:install]}",
313
+ cause: e
314
+ end
315
+
316
+ adapter_class = Object.const_get(registration[:class])
317
+ adapter_class.new(graph_url, username: username, password: password)
318
+ end
319
+
320
+ # Build from TINA4_GRAPH_URL (+ TINA4_GRAPH_USERNAME/_PASSWORD).
321
+ def self.from_env(env_key: "TINA4_GRAPH_URL")
322
+ url = ENV[env_key]
323
+ return nil if url.nil? || url.strip.empty?
324
+
325
+ create(url,
326
+ username: ENV["TINA4_GRAPH_USERNAME"].to_s,
327
+ password: ENV["TINA4_GRAPH_PASSWORD"].to_s)
328
+ end
329
+ end
330
+ end