tina4ruby 3.13.110 → 3.13.111
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/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/version.rb +1 -1
- data/lib/tina4.rb +1 -0
- metadata +5 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: fc71b0c82d6e81e389c825ceaea7a266203979977c2215af5efe677c2aabc57d
|
|
4
|
+
data.tar.gz: 9022b81347b8d00a6c95c6f24edeef3e28d528018a6b8a0afd446b3da877d77e
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 1690d686f268e05963e12078a6097bb02e3c920cef1a0a9e063da928bca30b37a58f507a7ab151be65a64bed2e482b9c61c902b2eb84e14e835c04a5e5b966ec
|
|
7
|
+
data.tar.gz: 32929f5560047f02f29e0f75a4585704fc6ccdf9b74bcce3abb664c8a5fb5b55ba2c78c930e0449f7e8cba1c700b200c2294e863c70186d76b9cc80520e220a1
|
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# ArangoDB graph adapter — the document/AQL engine behind the same surface.
|
|
4
|
+
#
|
|
5
|
+
# Arango is a document store, not a labelled-property graph, so the portable core
|
|
6
|
+
# maps onto ONE vertex collection + ONE edge collection: a node's +labels+ and an
|
|
7
|
+
# edge's +type+ live as document fields (+_labels+ / +_type+), ids are Arango
|
|
8
|
+
# +_id+ handles (e.g. +tina4_nodes/123+), and traversal uses AQL
|
|
9
|
+
# +FOR v IN 1..N OUTBOUND …+. Raw +query+/+execute+ take AQL directly.
|
|
10
|
+
#
|
|
11
|
+
# The transport is a small pure-Ruby client over Arango's REST AQL cursor
|
|
12
|
+
# endpoint (+POST /_api/cursor+) built on stdlib +Net::HTTP+ — NO third-party gem.
|
|
13
|
+
# This is a REAL driver hitting a REAL engine (proven live on the lab, no mocks),
|
|
14
|
+
# and it keeps the framework core zero-dependency: the community Arango gems are
|
|
15
|
+
# thin wrappers over the same REST API, and the AQL cursor surface is exactly what
|
|
16
|
+
# the graph layer needs.
|
|
17
|
+
|
|
18
|
+
require "net/http"
|
|
19
|
+
require "json"
|
|
20
|
+
require "uri"
|
|
21
|
+
|
|
22
|
+
module Tina4
|
|
23
|
+
module Drivers
|
|
24
|
+
class ArangoGraphDriver < Tina4::GraphAdapter
|
|
25
|
+
VERTEX_COLLECTION = "tina4_nodes"
|
|
26
|
+
EDGE_COLLECTION = "tina4_edges"
|
|
27
|
+
# Arango collection types: 2 = document, 3 = edge (verified live — the edge
|
|
28
|
+
# collection MUST be type 3 for AABB traversal to work).
|
|
29
|
+
TYPE_DOCUMENT = 2
|
|
30
|
+
TYPE_EDGE = 3
|
|
31
|
+
RESERVED = %w[_id _key _rev _from _to _labels _type].freeze
|
|
32
|
+
|
|
33
|
+
# A connect/transport failure (unreachable host, refused, timeout).
|
|
34
|
+
class ArangoConnectError < StandardError; end
|
|
35
|
+
# A request/AQL failure (bad query, engine error, HTTP error status).
|
|
36
|
+
class ArangoError < StandardError; end
|
|
37
|
+
|
|
38
|
+
def initialize(graph_url, username: "", password: "")
|
|
39
|
+
@url = graph_url
|
|
40
|
+
@last_error = nil
|
|
41
|
+
@timeout = Tina4.resolve_graph_connect_timeout
|
|
42
|
+
scheme = graph_url.use_tls ? "https" : "http"
|
|
43
|
+
@base = "#{scheme}://#{graph_url.host}:#{graph_url.port}"
|
|
44
|
+
@database = graph_url.graph || "_system"
|
|
45
|
+
@user = graph_url.username
|
|
46
|
+
@user = username unless @user && !@user.empty?
|
|
47
|
+
@user = "root" if @user.nil? || @user.empty?
|
|
48
|
+
@password = graph_url.password
|
|
49
|
+
@password = password unless @password && !@password.empty?
|
|
50
|
+
ensure_collections
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# -- raw pass-through (native AQL) -----------------------------------
|
|
54
|
+
|
|
55
|
+
def query(text, params = nil)
|
|
56
|
+
rows = aql(text, params)
|
|
57
|
+
columns = rows[0].is_a?(Hash) ? rows[0].keys : []
|
|
58
|
+
Tina4::GraphResult.new(records: rows, columns: columns)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def execute(text, params = nil)
|
|
62
|
+
query(text, params)
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# -- portable node/edge/traverse core (AQL) --------------------------
|
|
66
|
+
|
|
67
|
+
def add_node(label, properties = nil)
|
|
68
|
+
doc = (properties || {}).dup
|
|
69
|
+
doc["_labels"] = [label]
|
|
70
|
+
rows = aql("INSERT @doc INTO #{VERTEX_COLLECTION} RETURN NEW", { "doc" => doc })
|
|
71
|
+
node_from_doc(rows[0])
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def add_edge(from_id, to_id, type, properties = nil)
|
|
75
|
+
doc = (properties || {}).dup
|
|
76
|
+
doc["_from"] = from_id
|
|
77
|
+
doc["_to"] = to_id
|
|
78
|
+
doc["_type"] = type
|
|
79
|
+
rows = aql("INSERT @doc INTO #{EDGE_COLLECTION} RETURN NEW", { "doc" => doc })
|
|
80
|
+
return nil if rows.empty?
|
|
81
|
+
|
|
82
|
+
row = rows[0]
|
|
83
|
+
Tina4::GraphEdge.new(
|
|
84
|
+
id: row["_id"], type: row["_type"], from: row["_from"], to: row["_to"],
|
|
85
|
+
properties: clean_props(row)
|
|
86
|
+
)
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def get_node(node_id)
|
|
90
|
+
rows = aql("RETURN DOCUMENT(@id)", { "id" => node_id })
|
|
91
|
+
return nil if rows.empty? || rows[0].nil?
|
|
92
|
+
|
|
93
|
+
node_from_doc(rows[0])
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def update_node(node_id, properties)
|
|
97
|
+
rows = aql(
|
|
98
|
+
"UPDATE PARSE_IDENTIFIER(@id).key WITH @props IN #{VERTEX_COLLECTION} RETURN NEW",
|
|
99
|
+
{ "id" => node_id, "props" => properties || {} }
|
|
100
|
+
)
|
|
101
|
+
node_from_doc(rows[0])
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def delete_node(node_id)
|
|
105
|
+
# Remove the node and any edges touching it, so a re-read is a clean miss.
|
|
106
|
+
aql(
|
|
107
|
+
"FOR e IN #{EDGE_COLLECTION} FILTER e._from == @id OR e._to == @id " \
|
|
108
|
+
"REMOVE e IN #{EDGE_COLLECTION}", { "id" => node_id }
|
|
109
|
+
)
|
|
110
|
+
aql("REMOVE PARSE_IDENTIFIER(@id).key IN #{VERTEX_COLLECTION}", { "id" => node_id })
|
|
111
|
+
true
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def neighbors(node_id, direction: "both", edge_type: nil, limit: 100)
|
|
115
|
+
rows = aql(traversal_query(1, direction, edge_type),
|
|
116
|
+
traversal_bind(node_id, limit, edge_type))
|
|
117
|
+
rows.map { |doc| node_from_doc(doc) }
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def traverse(start_id, depth: 1, direction: "both", edge_type: nil, limit: 1000)
|
|
121
|
+
rows = aql(traversal_query(depth.to_i, direction, edge_type),
|
|
122
|
+
traversal_bind(start_id, limit, edge_type))
|
|
123
|
+
rows.map { |doc| node_from_doc(doc) }
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def close
|
|
127
|
+
# Net::HTTP opens a fresh connection per request here — nothing to hold open.
|
|
128
|
+
true
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
private
|
|
132
|
+
|
|
133
|
+
def traversal_query(depth, direction, edge_type)
|
|
134
|
+
dir = { "out" => "OUTBOUND", "in" => "INBOUND", "both" => "ANY" }.fetch(direction, "ANY")
|
|
135
|
+
type_filter = edge_type ? "FILTER e._type == @etype " : ""
|
|
136
|
+
# LIMIT BEFORE RETURN — the whole point of the depth-bounded traversal.
|
|
137
|
+
"FOR v, e IN 1..#{depth} #{dir} @start #{EDGE_COLLECTION} " \
|
|
138
|
+
"#{type_filter}LIMIT @limit RETURN DISTINCT v"
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
def traversal_bind(start_id, limit, edge_type)
|
|
142
|
+
bind = { "start" => start_id, "limit" => limit.to_i }
|
|
143
|
+
bind["etype"] = edge_type if edge_type
|
|
144
|
+
bind
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
# -- AQL cursor transport --------------------------------------------
|
|
148
|
+
|
|
149
|
+
# Run one AQL statement, paging the cursor to completion, returning the
|
|
150
|
+
# full result array. Wraps transport errors as GraphConnectTimeout and AQL
|
|
151
|
+
# errors as GraphError.
|
|
152
|
+
def aql(query, bind = nil)
|
|
153
|
+
body = { "query" => query }
|
|
154
|
+
body["bindVars"] = bind if bind && !bind.empty?
|
|
155
|
+
response = request("POST", "/_db/#{@database}/_api/cursor", body)
|
|
156
|
+
results = Array(response["result"])
|
|
157
|
+
cursor_id = response["id"]
|
|
158
|
+
while response["hasMore"]
|
|
159
|
+
response = request("PUT", "/_db/#{@database}/_api/cursor/#{cursor_id}", nil)
|
|
160
|
+
results.concat(Array(response["result"]))
|
|
161
|
+
end
|
|
162
|
+
results
|
|
163
|
+
rescue ArangoConnectError => e
|
|
164
|
+
@last_error = e.message
|
|
165
|
+
raise Tina4::GraphConnectTimeout,
|
|
166
|
+
"Graph connect to #{@url.host}:#{@url.port} failed " \
|
|
167
|
+
"(TINA4_GRAPH_CONNECT_TIMEOUT). Raise TINA4_GRAPH_CONNECT_TIMEOUT if the server is " \
|
|
168
|
+
"simply slow, or set it to 0 to wait indefinitely. Cause: #{e.message}"
|
|
169
|
+
rescue ArangoError => e
|
|
170
|
+
@last_error = e.message
|
|
171
|
+
raise Tina4::GraphError, e.message
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
def ensure_collections
|
|
175
|
+
create_collection(VERTEX_COLLECTION, TYPE_DOCUMENT) unless collection?(VERTEX_COLLECTION)
|
|
176
|
+
create_collection(EDGE_COLLECTION, TYPE_EDGE) unless collection?(EDGE_COLLECTION)
|
|
177
|
+
rescue ArangoConnectError => e
|
|
178
|
+
@last_error = e.message
|
|
179
|
+
raise Tina4::GraphConnectTimeout,
|
|
180
|
+
"Graph connect to #{@url.host}:#{@url.port} failed " \
|
|
181
|
+
"(TINA4_GRAPH_CONNECT_TIMEOUT). Cause: #{e.message}"
|
|
182
|
+
rescue ArangoError => e
|
|
183
|
+
@last_error = e.message
|
|
184
|
+
raise Tina4::GraphError, e.message
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
def collection?(name)
|
|
188
|
+
request("GET", "/_db/#{@database}/_api/collection/#{name}", nil)
|
|
189
|
+
true
|
|
190
|
+
rescue ArangoError
|
|
191
|
+
false
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
def create_collection(name, type)
|
|
195
|
+
request("POST", "/_db/#{@database}/_api/collection", { "name" => name, "type" => type })
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
# Issue one HTTP request, parse the JSON body, raise on an error status.
|
|
199
|
+
def request(method, path, body)
|
|
200
|
+
uri = URI.parse("#{@base}#{path}")
|
|
201
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
202
|
+
http.use_ssl = uri.scheme == "https"
|
|
203
|
+
if @timeout
|
|
204
|
+
http.open_timeout = @timeout
|
|
205
|
+
http.read_timeout = @timeout
|
|
206
|
+
end
|
|
207
|
+
klass = { "GET" => Net::HTTP::Get, "POST" => Net::HTTP::Post,
|
|
208
|
+
"PUT" => Net::HTTP::Put, "DELETE" => Net::HTTP::Delete }.fetch(method)
|
|
209
|
+
req = klass.new(uri.request_uri)
|
|
210
|
+
req.basic_auth(@user, @password.to_s)
|
|
211
|
+
if body
|
|
212
|
+
req["Content-Type"] = "application/json"
|
|
213
|
+
req.body = JSON.generate(body)
|
|
214
|
+
end
|
|
215
|
+
response = http.request(req)
|
|
216
|
+
parse_response(response)
|
|
217
|
+
rescue Errno::ETIMEDOUT, Errno::EHOSTUNREACH, Errno::ECONNREFUSED,
|
|
218
|
+
Errno::ENETUNREACH, Net::OpenTimeout, Net::ReadTimeout,
|
|
219
|
+
SocketError, Timeout::Error => e
|
|
220
|
+
raise ArangoConnectError, e.message
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
def parse_response(response)
|
|
224
|
+
parsed = response.body && !response.body.empty? ? JSON.parse(response.body) : {}
|
|
225
|
+
code = response.code.to_i
|
|
226
|
+
if code >= 400 || parsed["error"] == true
|
|
227
|
+
message = parsed["errorMessage"] || parsed["error"] || "HTTP #{response.code}"
|
|
228
|
+
raise ArangoError, "ArangoDB error #{parsed['errorNum'] || code}: #{message}"
|
|
229
|
+
end
|
|
230
|
+
parsed
|
|
231
|
+
rescue JSON::ParserError => e
|
|
232
|
+
raise ArangoError, "invalid ArangoDB response: #{e.message}"
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
def node_from_doc(doc)
|
|
236
|
+
return nil if doc.nil?
|
|
237
|
+
|
|
238
|
+
Tina4::GraphNode.new(
|
|
239
|
+
id: doc["_id"], labels: doc["_labels"] || [], properties: clean_props(doc)
|
|
240
|
+
)
|
|
241
|
+
end
|
|
242
|
+
|
|
243
|
+
def clean_props(doc)
|
|
244
|
+
doc.reject { |key, _| RESERVED.include?(key) }
|
|
245
|
+
end
|
|
246
|
+
end
|
|
247
|
+
end
|
|
248
|
+
end
|