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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: c8235efa0443e28d21c06d1eb124f44b449236694e6d0b59809c0a14bcc17f52
4
- data.tar.gz: a7e7ba7caa51958a2348b9d3aa15b66d2c4ff8e6425aef67e67f21af702ca119
3
+ metadata.gz: fc71b0c82d6e81e389c825ceaea7a266203979977c2215af5efe677c2aabc57d
4
+ data.tar.gz: 9022b81347b8d00a6c95c6f24edeef3e28d528018a6b8a0afd446b3da877d77e
5
5
  SHA512:
6
- metadata.gz: 853a5e2cbe9da35e5145f30aa6bc0ed05a49074078cb1155773eb6dfaadf9249d551d64713cfbc20fd5813e539cbefb97fc9d1b480231af1162e3cb083c96669
7
- data.tar.gz: 6ed8a0845d48bde8ff055423deb29c9ca47495cadff727755f41e616ba552ef8975a79684846dd5b3c7343e783978251eab57dd397865d5ac180c52707396f89
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