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.
- checksums.yaml +4 -4
- data/lib/tina4/dev_admin.rb +10 -0
- data/lib/tina4/drivers/arango_graph_driver.rb +248 -0
- data/lib/tina4/drivers/bolt_graph_driver.rb +566 -0
- data/lib/tina4/drivers/ultipa_graph_driver.rb +186 -0
- data/lib/tina4/graph.rb +330 -0
- data/lib/tina4/rack_app.rb +186 -169
- data/lib/tina4/version.rb +1 -1
- data/lib/tina4.rb +1 -0
- metadata +5 -1
|
@@ -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
|
data/lib/tina4/graph.rb
ADDED
|
@@ -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
|