rails_age 0.6.4 → 0.7.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 +4 -4
- data/CHANGELOG.md +27 -23
- data/README.md +412 -92
- data/lib/apache_age/edge.rb +63 -0
- data/lib/apache_age/entities/class_methods.rb +69 -60
- data/lib/apache_age/entities/common_methods.rb +26 -0
- data/lib/apache_age/entities/edge.rb +0 -20
- data/lib/apache_age/entities/entity.rb +54 -0
- data/lib/apache_age/entities/node.rb +0 -5
- data/lib/apache_age/entities/path.rb +76 -14
- data/lib/apache_age/entities/query_builder.rb +41 -36
- data/lib/apache_age/node.rb +61 -18
- data/lib/apache_age/path.rb +258 -0
- data/lib/rails_age/version.rb +1 -1
- data/lib/rails_age.rb +2 -2
- metadata +3 -6
data/lib/apache_age/node.rb
CHANGED
@@ -12,24 +12,67 @@ module ApacheAge
|
|
12
12
|
info.blank? ? "#{label} (#{id})" : "#{info} (#{label})"
|
13
13
|
end
|
14
14
|
|
15
|
-
|
16
|
-
|
17
|
-
|
18
|
-
|
19
|
-
|
20
|
-
|
21
|
-
|
22
|
-
|
23
|
-
|
24
|
-
|
25
|
-
|
26
|
-
|
27
|
-
|
28
|
-
|
29
|
-
|
30
|
-
|
31
|
-
|
32
|
-
|
15
|
+
class << self
|
16
|
+
def age_type = 'vertex'
|
17
|
+
|
18
|
+
def ensure_query_builder!
|
19
|
+
@query_builder ||= ApacheAge::Entities::QueryBuilder.new(@node_class || self)
|
20
|
+
end
|
21
|
+
|
22
|
+
def cypher(node_class: nil, graph_name: nil)
|
23
|
+
@node_class = node_class || self
|
24
|
+
@query_builder = ApacheAge::Entities::QueryBuilder.new(@node_class, graph_name:)
|
25
|
+
self
|
26
|
+
end
|
27
|
+
|
28
|
+
def match(match_string)
|
29
|
+
ensure_query_builder!
|
30
|
+
@query_builder.match(match_string)
|
31
|
+
self
|
32
|
+
end
|
33
|
+
|
34
|
+
def where(*args)
|
35
|
+
ensure_query_builder!
|
36
|
+
@query_builder.where(*args)
|
37
|
+
self
|
38
|
+
end
|
39
|
+
|
40
|
+
def order(ordering)
|
41
|
+
ensure_query_builder!
|
42
|
+
@query_builder.order(ordering)
|
43
|
+
self
|
44
|
+
end
|
45
|
+
|
46
|
+
def limit(limit_value)
|
47
|
+
ensure_query_builder!
|
48
|
+
@query_builder.limit(limit_value)
|
49
|
+
self
|
50
|
+
end
|
51
|
+
|
52
|
+
def return(*variables)
|
53
|
+
ensure_query_builder!
|
54
|
+
@query_builder.return(*variables)
|
55
|
+
self
|
56
|
+
end
|
57
|
+
|
58
|
+
def execute
|
59
|
+
ensure_query_builder!
|
60
|
+
@query_builder.execute
|
61
|
+
end
|
62
|
+
|
63
|
+
def first
|
64
|
+
ensure_query_builder!
|
65
|
+
@query_builder.first
|
66
|
+
end
|
67
|
+
|
68
|
+
def all
|
69
|
+
ensure_query_builder!
|
70
|
+
@query_builder.all
|
71
|
+
end
|
72
|
+
|
73
|
+
def to_sql
|
74
|
+
ensure_query_builder!
|
75
|
+
@query_builder.to_sql
|
33
76
|
end
|
34
77
|
end
|
35
78
|
end
|
data/lib/apache_age/path.rb
CHANGED
@@ -1,4 +1,262 @@
|
|
1
|
+
# Query Paths
|
2
|
+
# DSL - When all edges are of the same type
|
3
|
+
# - Path.cypher(path_edge: HasChild, path_length: "1..5")
|
4
|
+
# .where(start_node: {first_name: 'Zeke'})
|
5
|
+
# .where('end_node.last_name CONTAINS ?', 'Flintstone')
|
6
|
+
# .limit(3)
|
7
|
+
# with full control of the matching paths
|
8
|
+
# JUST FATHER LINEAGE (where can't handle edge path properties - not one element and get error:
|
9
|
+
# `ERROR: array index must resolve to an integer value`
|
10
|
+
# so instead match as an edge property instead as shown below
|
11
|
+
# - Path.cypher(path_edge: HasChild, path_length: "1..5", path_properties: {guardian_role: 'father'})
|
12
|
+
# .where(start_node: {first_name: 'Zeke'})
|
13
|
+
# .where('end_node.last_name =~ ?', 'Flintstone')
|
14
|
+
# .limit(3)
|
15
|
+
# - Path
|
16
|
+
# .match('(start_node)-[HasChild*1..5 {guardian_role: 'father'}]->(end_node)')
|
17
|
+
# .where(start_node: {first_name: 'Zeke'})
|
18
|
+
# .where('end_node.last_name =~ ?', 'Flintstone')
|
19
|
+
# .limit(3)
|
20
|
+
#
|
21
|
+
# # DSL RESULTS:
|
22
|
+
# [
|
23
|
+
# [
|
24
|
+
# Person.find(844424930131969), # Zeke Flintstone
|
25
|
+
# Edge.find(1407374883553281), # HasChild(mother)
|
26
|
+
# Person.find(844424930131971) # Rockbottom Flintstone
|
27
|
+
# ],
|
28
|
+
# [
|
29
|
+
# Person.find(844424930131969), # Zeke Flintstone
|
30
|
+
# Edge.find(1407374883553281), # HasChild(mother)
|
31
|
+
# Person.find(844424930131971), # Rockbottom Flintstone
|
32
|
+
# Edge.find(1407374883553284), # HasChild(falther)
|
33
|
+
# Person.find(844424930131975) # Giggles Flintstone
|
34
|
+
# ],
|
35
|
+
# [
|
36
|
+
# Person.find(844424930131969), # Zeke Flintstone
|
37
|
+
# Edge.find(1407374883553281), # HasChild(mother)
|
38
|
+
# Person.find(844424930131971), # Rockbottom Flintstone
|
39
|
+
# Edge.find(1407374883553283), # HasChild(father)
|
40
|
+
# Person.find(844424930131974) # Ed Flintstone
|
41
|
+
# ]
|
42
|
+
# ]
|
43
|
+
# SQL:
|
44
|
+
# - SELECT *
|
45
|
+
# FROM cypher('age_schema', $$
|
46
|
+
# MATCH path = (start_node)-[HasChild*1..5]->(end_node)
|
47
|
+
# WHERE start_node.first_name = 'Zeke' AND end_node.last_name CONTAINS 'Flintstone'
|
48
|
+
# RETURN path
|
49
|
+
# LIMIT 3
|
50
|
+
# $$) AS (path agtype);
|
51
|
+
#
|
52
|
+
# SQL:
|
53
|
+
# SELECT *
|
54
|
+
# FROM cypher('age_schema', $$
|
55
|
+
# MATCH path = (start_node)-[HasChild*1..5 {guardian_role: 'father'}]->(end_node)
|
56
|
+
# WHERE start_node.first_name = "Jed" AND end_node.last_name =~ 'Flintstone'
|
57
|
+
# RETURN path
|
58
|
+
# LIMIT 3
|
59
|
+
# $$) AS (path agtype);
|
60
|
+
#
|
61
|
+
# SQL RESULTS:
|
62
|
+
# [
|
63
|
+
# {"id": 844424930131969, "label": "Person", "properties": {"gender": "female", "last_name": "Flintstone", "first_name": "Zeke"}}::vertex,
|
64
|
+
# {"id": 1407374883553281, "label": "HasChild", "end_id": 844424930131971, "start_id": 844424930131969, "properties": {"guardian_role": "mother"}}::edge,
|
65
|
+
# {"id": 844424930131971, "label": "Person", "properties": {"gender": "male", "last_name": "Flintstone", "first_name": "Rockbottom"}}::vertex
|
66
|
+
# ]::path
|
67
|
+
# [
|
68
|
+
# {"id": 844424930131969, "label": "Person", "properties": {"gender": "female", "last_name": "Flintstone", "first_name": "Zeke"}}::vertex,
|
69
|
+
# {"id": 1407374883553281, "label": "HasChild", "end_id": 844424930131971, "start_id": 844424930131969, "properties": {"guardian_role": "mother"}}::edge,
|
70
|
+
# {"id": 844424930131971, "label": "Person", "properties": {"gender": "male", "last_name": "Flintstone", "first_name": "Rockbottom"}}::vertex,
|
71
|
+
# {"id": 1407374883553284, "label": "HasChild", "end_id": 844424930131975, "start_id": 844424930131971, "properties": {"guardian_role": "father"}}::edge,
|
72
|
+
# {"id": 844424930131975, "label": "Person", "properties": {"gender": "male", "last_name": "Flintstone", "first_name": "Giggles"}}::vertex
|
73
|
+
# ]::path
|
74
|
+
# [
|
75
|
+
# {"id": 844424930131969, "label": "Person", "properties": {"gender": "female", "last_name": "Flintstone", "first_name": "Zeke"}}::vertex,
|
76
|
+
# {"id": 1407374883553281, "label": "HasChild", "end_id": 844424930131971, "start_id": 844424930131969, "properties": {"guardian_role": "mother"}}::edge,
|
77
|
+
# {"id": 844424930131971, "label": "Person", "properties": {"gender": "male", "last_name": "Flintstone", "first_name": "Rockbottom"}}::vertex, {"id": 1407374883553283, "label": "HasChild", "end_id": 844424930131974, "start_id": 844424930131971, "properties": {"guardian_role": "father"}}::edge,
|
78
|
+
# {"id": 844424930131974, "label": "Person", "properties": {"gender": "male", "last_name": "Flintstone", "first_name": "Ed"}}::vertex
|
79
|
+
# ]::path
|
80
|
+
# (3 rows)
|
81
|
+
|
1
82
|
module ApacheAge
|
2
83
|
class Path
|
84
|
+
include ApacheAge::Entities::Path
|
85
|
+
|
86
|
+
attr_reader :query_builder, :path_edge, :path_length, :path_properties, :start_node_filter, :end_node_filter
|
87
|
+
|
88
|
+
class << self
|
89
|
+
def age_type = 'path'
|
90
|
+
|
91
|
+
# Convert a path result or collection of path results to hashes
|
92
|
+
# This handles both a single path (array of nodes/edges) and multiple paths
|
93
|
+
def path_to_h(path_result)
|
94
|
+
if path_result.first.is_a?(Array)
|
95
|
+
# It's a collection of paths
|
96
|
+
path_result.map { |path| path.map(&:to_h) }
|
97
|
+
else
|
98
|
+
# It's a single path
|
99
|
+
path_result.map(&:to_h)
|
100
|
+
end
|
101
|
+
end
|
102
|
+
|
103
|
+
# Convert a path result to rich hashes with additional context information
|
104
|
+
# This handles both a single path (array of nodes/edges) and multiple paths
|
105
|
+
def path_to_rich_h(path_result)
|
106
|
+
if path_result.first.is_a?(Array)
|
107
|
+
# It's a collection of paths
|
108
|
+
path_result.map { |path| path.map(&:to_rich_h) }
|
109
|
+
else
|
110
|
+
# It's a single path
|
111
|
+
path_result.map(&:to_rich_h)
|
112
|
+
end
|
113
|
+
end
|
114
|
+
|
115
|
+
def ensure_query_builder!
|
116
|
+
@query_builder ||= ApacheAge::Entities::QueryBuilder.new(self)
|
117
|
+
end
|
118
|
+
|
119
|
+
def cypher(path_edge: nil, path_length: nil, path_properties: {}, start_node_filter: nil, end_node_filter: nil, graph_name: nil)
|
120
|
+
unless path_edge.nil? || path_edge.ancestors.include?(ApacheAge::Entities::Edge)
|
121
|
+
raise ArgumentError, 'Path edge must be a valid edge class'
|
122
|
+
end
|
123
|
+
|
124
|
+
@path_edge = path_edge ? path_edge.name.gsub('::', '__') : nil
|
125
|
+
@path_length = path_length ? "*#{path_length}" : "*"
|
126
|
+
@path_properties = path_properties.blank? ? nil : " {#{path_properties.map { |k, v| "#{k}: '#{v}'" }.join(', ')}}"
|
127
|
+
|
128
|
+
# Format node filters for the match clause if provided
|
129
|
+
start_filter = format_node_filter(start_node_filter)
|
130
|
+
end_filter = format_node_filter(end_node_filter)
|
131
|
+
|
132
|
+
@has_edge_ordering = false
|
133
|
+
@match_clause = "path = (start_node#{start_filter})-[#{@path_edge}#{@path_length}#{@path_properties}]->(end_node#{end_filter})"
|
134
|
+
@query_builder =
|
135
|
+
ApacheAge::Entities::QueryBuilder.new(
|
136
|
+
self,
|
137
|
+
graph_name:,
|
138
|
+
return_clause: 'path',
|
139
|
+
match_clause: @match_clause
|
140
|
+
)
|
141
|
+
self
|
142
|
+
end
|
143
|
+
|
144
|
+
def match_clause
|
145
|
+
# Use the edge variable if we're ordering by edge properties
|
146
|
+
if @has_edge_ordering
|
147
|
+
"path = (start_node)-[edge:#{@path_edge}#{@path_length}#{@path_properties}]->(end_node)"
|
148
|
+
else
|
149
|
+
"path = (start_node)-[#{@path_edge}#{@path_length}#{@path_properties}]->(end_node)"
|
150
|
+
end
|
151
|
+
end
|
152
|
+
|
153
|
+
def match(match_string)
|
154
|
+
ensure_query_builder!
|
155
|
+
@query_builder.match(match_string)
|
156
|
+
self
|
157
|
+
end
|
158
|
+
|
159
|
+
# Delegate additional methods like `where`, `limit`, etc., to `QueryBuilder`
|
160
|
+
def where(*args)
|
161
|
+
ensure_query_builder!
|
162
|
+
@query_builder.where(*args)
|
163
|
+
self
|
164
|
+
end
|
165
|
+
|
166
|
+
# order is important to put last (but before limit)
|
167
|
+
# Path.cypher(...).where(...).order("start_node.first_name ASC").limit(5)
|
168
|
+
def order(ordering)
|
169
|
+
ensure_query_builder!
|
170
|
+
|
171
|
+
# Check if we're ordering by edge properties
|
172
|
+
if ordering.is_a?(Hash) && ordering.key?(:edge)
|
173
|
+
# Update match clause to include edge variable
|
174
|
+
@match_clause = "path = (start_node)-[edge:#{@path_edge}#{@path_length}#{@path_properties}]->(end_node)"
|
175
|
+
@query_builder.match_clause = @match_clause
|
176
|
+
end
|
177
|
+
|
178
|
+
@query_builder.order(ordering)
|
179
|
+
self
|
180
|
+
end
|
181
|
+
|
182
|
+
def limit(limit_value)
|
183
|
+
ensure_query_builder!
|
184
|
+
@query_builder.limit(limit_value)
|
185
|
+
self
|
186
|
+
end
|
187
|
+
|
188
|
+
def return(*variables)
|
189
|
+
ensure_query_builder!
|
190
|
+
@query_builder.return(*variables)
|
191
|
+
self
|
192
|
+
end
|
193
|
+
|
194
|
+
# Executes the query and returns results
|
195
|
+
def execute
|
196
|
+
ensure_query_builder!
|
197
|
+
@query_builder.execute
|
198
|
+
end
|
199
|
+
|
200
|
+
def first
|
201
|
+
ensure_query_builder!
|
202
|
+
@query_builder.first
|
203
|
+
end
|
204
|
+
|
205
|
+
def all
|
206
|
+
ensure_query_builder!
|
207
|
+
@query_builder.all
|
208
|
+
end
|
209
|
+
|
210
|
+
# Build the final SQL query
|
211
|
+
def to_sql
|
212
|
+
ensure_query_builder!
|
213
|
+
@query_builder.to_sql
|
214
|
+
end
|
215
|
+
|
216
|
+
private
|
217
|
+
|
218
|
+
# Format node filter hash into Cypher node property syntax {key: 'value', ...}
|
219
|
+
# Returns a properly formatted string for insertion into a MATCH clause
|
220
|
+
def format_node_filter(filter)
|
221
|
+
return '' if filter.nil? || !filter.is_a?(Hash) || filter.empty?
|
222
|
+
|
223
|
+
properties = filter.map do |key, value|
|
224
|
+
formatted_value = value.is_a?(String) ? "'#{value}'" : value
|
225
|
+
"#{key}: #{formatted_value}"
|
226
|
+
end.join(', ')
|
227
|
+
|
228
|
+
" {#{properties}}"
|
229
|
+
end
|
230
|
+
|
231
|
+
# PathResult is a custom Array subclass that adds to_rich_h convenience method
|
232
|
+
class PathResult < Array
|
233
|
+
def to_rich_h
|
234
|
+
map(&:to_rich_h)
|
235
|
+
end
|
236
|
+
end
|
237
|
+
|
238
|
+
# private
|
239
|
+
|
240
|
+
def execute_where(cypher_sql)
|
241
|
+
age_results = ActiveRecord::Base.connection.execute(cypher_sql)
|
242
|
+
return [] if age_results.values.count.zero?
|
243
|
+
|
244
|
+
age_results.values.map do |row|
|
245
|
+
path_data = row.first.split('::path').first
|
246
|
+
# [1..-2] - removes leading and trailing brackets
|
247
|
+
elements = path_data[1..-2].split(/(?<=\}::vertex,)|(?<=\}::edge,)/)
|
248
|
+
# elements.map do |element|
|
249
|
+
path_elements = elements.map do |element|
|
250
|
+
path_hash = JSON.parse(element.sub("::vertex,", "").sub("::vertex", "").sub("::edge,", "").sub("::edge", "").strip)
|
251
|
+
path_klass = path_hash['label'].gsub('__', '::').constantize
|
252
|
+
path_attribs = path_hash.except('label', 'properties').merge(path_hash['properties']).symbolize_keys
|
253
|
+
path_klass.new(**path_attribs)
|
254
|
+
end
|
255
|
+
|
256
|
+
# Wrap the result in our custom PathResult class
|
257
|
+
PathResult.new(path_elements)
|
258
|
+
end
|
259
|
+
end
|
260
|
+
end
|
3
261
|
end
|
4
262
|
end
|
data/lib/rails_age/version.rb
CHANGED
data/lib/rails_age.rb
CHANGED
@@ -13,10 +13,10 @@ module ApacheAge
|
|
13
13
|
require 'apache_age/entities/entity'
|
14
14
|
require 'apache_age/entities/node'
|
15
15
|
require 'apache_age/entities/edge'
|
16
|
-
|
16
|
+
require 'apache_age/entities/path'
|
17
17
|
require 'apache_age/node'
|
18
18
|
require 'apache_age/edge'
|
19
|
-
|
19
|
+
require 'apache_age/path'
|
20
20
|
require 'apache_age/validators/expected_node_type'
|
21
21
|
require 'apache_age/validators/unique_node'
|
22
22
|
require 'apache_age/validators/unique_edge'
|
metadata
CHANGED
@@ -1,14 +1,13 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: rails_age
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version: 0.
|
4
|
+
version: 0.7.0
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- Bill Tihen
|
8
|
-
autorequire:
|
9
8
|
bindir: bin
|
10
9
|
cert_chain: []
|
11
|
-
date:
|
10
|
+
date: 1980-01-02 00:00:00.000000000 Z
|
12
11
|
dependencies:
|
13
12
|
- !ruby/object:Gem::Dependency
|
14
13
|
name: rails
|
@@ -171,7 +170,6 @@ metadata:
|
|
171
170
|
homepage_uri: https://github.com/marpori/rails_age
|
172
171
|
source_code_uri: https://github.com/marpori/rails_age/blob/main
|
173
172
|
changelog_uri: https://github.com/marpori/rails_age/blob/main/CHANGELOG.md
|
174
|
-
post_install_message:
|
175
173
|
rdoc_options: []
|
176
174
|
require_paths:
|
177
175
|
- lib
|
@@ -186,8 +184,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
186
184
|
- !ruby/object:Gem::Version
|
187
185
|
version: '0'
|
188
186
|
requirements: []
|
189
|
-
rubygems_version: 3.
|
190
|
-
signing_key:
|
187
|
+
rubygems_version: 3.6.9
|
191
188
|
specification_version: 4
|
192
189
|
summary: Apache AGE plugin for Rails 7.x
|
193
190
|
test_files: []
|