rails_age 0.6.3 → 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 +28 -25
- 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 +86 -131
- 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
@@ -1,6 +1,6 @@
|
|
1
1
|
# query =
|
2
|
-
# Person
|
3
|
-
# cypher('age_schema')
|
2
|
+
# Person
|
3
|
+
# .cypher('age_schema')
|
4
4
|
# .match("(a:Person), (b:Person)")
|
5
5
|
# .where("a.name = 'Node A'", "b.name = 'Node B'")
|
6
6
|
# .return("a.name", "b.name")
|
@@ -15,64 +15,48 @@ module ApacheAge
|
|
15
15
|
module Entities
|
16
16
|
class QueryBuilder
|
17
17
|
attr_accessor :where_clauses, :order_clause, :limit_clause, :model_class, :match_clause,
|
18
|
-
:graph_name, :return_clause, :return_names, :return_variables
|
19
|
-
|
20
|
-
|
18
|
+
:graph_name, :return_clause, :return_names, :return_variables,
|
19
|
+
:path_edge_name, :path_length, :path_properties
|
20
|
+
|
21
|
+
def initialize(
|
22
|
+
model_class, return_clause: nil, match_clause: nil, graph_name: nil
|
23
|
+
# model_class, path_edge: nil, path_length: nil, path_properties: nil, return_clause: nil, match_clause: nil, graph_name: nil
|
24
|
+
)
|
25
|
+
# @path_edge = path_length
|
26
|
+
# @path_length = path_length
|
27
|
+
# @path_properties = path_properties
|
21
28
|
@model_class = model_class
|
22
29
|
@where_clauses = []
|
23
|
-
@
|
24
|
-
@
|
30
|
+
@return_clause = return_clause ? return_clause : 'find'
|
31
|
+
@return_names = [@return_clause]
|
25
32
|
@return_variables = []
|
26
33
|
@order_clause = nil
|
27
34
|
@limit_clause = nil
|
28
|
-
@match_clause = model_class.match_clause
|
35
|
+
@match_clause = match_clause ? match_clause : model_class.match_clause
|
29
36
|
@graph_name = graph_name || model_class.age_graph
|
30
37
|
end
|
31
38
|
|
32
|
-
# def cypher(graph_name = 'age_schema')
|
33
|
-
# return self if graph_name.blank?
|
34
|
-
|
35
|
-
# @graph_name = graph_name
|
36
|
-
# self
|
37
|
-
# end
|
38
|
-
|
39
39
|
def match(match_string)
|
40
40
|
@match_clause = match_string
|
41
41
|
self
|
42
42
|
end
|
43
43
|
|
44
|
-
# # TODO: need to handle string inputs too: instead of: \
|
45
|
-
# # "id(find) = #{id}" & "find.name = #{name}"
|
46
|
-
# # we can have: "id(find) = ?", id & "find.name = ?", name
|
47
|
-
# # ActiveRecord::Base.sanitize_sql([query_string, v])
|
48
44
|
def where(*args)
|
49
45
|
return self if args.blank?
|
50
46
|
|
51
47
|
@where_clauses <<
|
52
|
-
# not able to sanitize the query string in this case
|
53
|
-
# ["first_name = 'Barney'"]
|
48
|
+
# not able to sanitize the query string in this case: `["first_name = 'Barney'"]`
|
54
49
|
if args.length == 1 && args.first.is_a?(String)
|
55
|
-
|
56
|
-
|
57
|
-
"id(find) = ?"
|
58
|
-
elsif string_query.include?('id(') || string_query.include?('find.')
|
59
|
-
string_query
|
60
|
-
else
|
61
|
-
"find.#{string_query}"
|
62
|
-
end
|
50
|
+
raw_query_string = args.first
|
51
|
+
transform_cypher_sql(raw_query_string)
|
63
52
|
|
64
53
|
# Handling & sanitizing parameterized string queries
|
65
54
|
elsif args.length > 1 && args.first.is_a?(String)
|
66
55
|
raw_query_string = args.first
|
67
|
-
|
68
|
-
|
69
|
-
"id(find) = ?"
|
70
|
-
elsif raw_query_string.include?('id(') || raw_query_string.include?('find.')
|
71
|
-
raw_query_string
|
72
|
-
else
|
73
|
-
"find.#{raw_query_string}"
|
74
|
-
end
|
56
|
+
# Replace `id = ?` with `id(find) = ?` and `first_name = ?` with `find.first_name = ?`
|
57
|
+
query_string = transform_cypher_sql(raw_query_string)
|
75
58
|
values = args[1..-1]
|
59
|
+
# sanitize sql input values
|
76
60
|
ActiveRecord::Base.sanitize_sql_array([query_string, *values])
|
77
61
|
|
78
62
|
# Hashes are sanitized in the model class
|
@@ -93,71 +77,11 @@ module ApacheAge
|
|
93
77
|
self
|
94
78
|
end
|
95
79
|
|
96
|
-
# # where is sanitized in the model class with hash values
|
97
|
-
# def where(attributes)
|
98
|
-
# return self if attributes.blank?
|
99
|
-
|
100
|
-
# @where_clauses <<
|
101
|
-
# if attributes.is_a?(String)
|
102
|
-
# puts "HANDLE PURE STRING QUERIES"
|
103
|
-
# if attributes.include?('id(') || attributes.include?('find.')
|
104
|
-
# attributes
|
105
|
-
# else
|
106
|
-
# "find.#{attributes}"
|
107
|
-
# end
|
108
|
-
# else
|
109
|
-
# puts "HANDLE HASHES"
|
110
|
-
# pp attributes
|
111
|
-
# edge_keys = [:start_id, :start_node, :end_id, :end_node]
|
112
|
-
# if edge_keys.any? { |key| attributes.include?(key) }
|
113
|
-
# puts "HANDLE EDGE CLAUSES"
|
114
|
-
# model_class.send(:where_edge_clause, attributes)
|
115
|
-
# else
|
116
|
-
# puts "HANDLE NODE CLAUSES"
|
117
|
-
# model_class.send(:where_node_clause, attributes)
|
118
|
-
# end
|
119
|
-
# end
|
120
|
-
|
121
|
-
# self
|
122
|
-
# end
|
123
|
-
|
124
|
-
# # Pre-sanitize where statements
|
125
|
-
# # def where(*args)
|
126
|
-
# # return self if args.blank?
|
127
|
-
|
128
|
-
# # # Handling parameterized query strings with values
|
129
|
-
# # if args.length == 1 && args.first.is_a?(Hash)
|
130
|
-
# # # If a hash of attributes is provided, use the existing logic
|
131
|
-
# # attributes = args.first
|
132
|
-
# # edge_keys = [:start_id, :start_node, :end_id, :end_node]
|
133
|
-
# # if edge_keys.any? { |key| attributes.include?(key) }
|
134
|
-
# # @where_clauses << model_class.send(:where_edge_clause, attributes)
|
135
|
-
# # else
|
136
|
-
# # @where_clauses << model_class.send(:where_node_clause, attributes)
|
137
|
-
# # end
|
138
|
-
# # elsif args.length > 1 && args.first.is_a?(String)
|
139
|
-
# # # If a query string with placeholders and values is provided
|
140
|
-
# # query_string = args.first
|
141
|
-
# # values = args[1..-1]
|
142
|
-
# # sanitized_query = ActiveRecord::Base.send(:sanitize_sql_array, [query_string, *values])
|
143
|
-
# # @where_clauses << sanitized_query
|
144
|
-
# # elsif args.length == 1 && args.first.is_a?(String)
|
145
|
-
# # # If a single string is provided, use it directly (assuming it is already sanitized or trusted)
|
146
|
-
# # @where_clauses << args.first
|
147
|
-
# # else
|
148
|
-
# # raise ArgumentError, "Invalid arguments for `where` method"
|
149
|
-
# # end
|
150
|
-
|
151
|
-
# # self
|
152
|
-
# # end
|
153
|
-
|
154
80
|
# New return method
|
155
81
|
def return(*variables)
|
156
82
|
return self if variables.blank?
|
157
83
|
|
158
84
|
@return_variables = variables
|
159
|
-
# @return_names = variables.empty? ? ['find'] : variables
|
160
|
-
# @return_clause = variables.empty? ? 'find' : "find.#{variables.join(', find.')}"
|
161
85
|
self
|
162
86
|
end
|
163
87
|
|
@@ -199,31 +123,36 @@ module ApacheAge
|
|
199
123
|
|
200
124
|
private
|
201
125
|
|
202
|
-
#
|
126
|
+
# Handle ordering criteria for paths
|
203
127
|
def parse_ordering(ordering)
|
204
128
|
if ordering.is_a?(Hash)
|
205
|
-
ordering
|
206
|
-
|
207
|
-
|
208
|
-
|
129
|
+
ordering.map do |key, value|
|
130
|
+
if key == :length
|
131
|
+
# Special case for path length
|
132
|
+
"length(path) #{value.to_s.upcase}"
|
133
|
+
elsif key == :start_node || key == :end_node
|
134
|
+
# Node property ordering
|
135
|
+
if value.is_a?(Hash)
|
136
|
+
property = value.keys.first
|
137
|
+
direction = value.values.first.to_s.upcase
|
138
|
+
"#{key}.#{property} #{direction}"
|
139
|
+
else
|
140
|
+
"#{key} #{value.to_s.upcase}"
|
141
|
+
end
|
142
|
+
else
|
143
|
+
# Default case
|
144
|
+
"find.#{key} #{ActiveRecord::Base.sanitize_sql_like(value.to_s)}"
|
145
|
+
end
|
146
|
+
end.join(', ')
|
209
147
|
elsif ordering.is_a?(Symbol)
|
210
|
-
|
148
|
+
# Default for symbol
|
149
|
+
"find.#{ordering}"
|
211
150
|
elsif ordering.is_a?(String)
|
151
|
+
# Pass strings through as-is
|
212
152
|
ordering
|
213
153
|
elsif ordering.is_a?(Array)
|
214
|
-
|
215
|
-
|
216
|
-
order
|
217
|
-
.map { |k, v| "find.#{k} #{ActiveRecord::Base.sanitize_sql_like(v.to_s)}" }
|
218
|
-
.join(', ')
|
219
|
-
elsif order.is_a?(Symbol)
|
220
|
-
"find.#{order}"
|
221
|
-
elsif order.is_a?(String)
|
222
|
-
order
|
223
|
-
else
|
224
|
-
raise ArgumentError, 'Array elements must be a string, symbol, or hash'
|
225
|
-
end
|
226
|
-
end.join(', ')
|
154
|
+
# Process arrays recursively
|
155
|
+
ordering.map { |order| parse_ordering(order) }.join(', ')
|
227
156
|
else
|
228
157
|
raise ArgumentError, 'Ordering must be a string, symbol, hash, or array'
|
229
158
|
end
|
@@ -243,22 +172,48 @@ module ApacheAge
|
|
243
172
|
$$) AS (#{return_names.join(' agtype, ')} agtype);
|
244
173
|
SQL
|
245
174
|
end
|
246
|
-
# def build_query(_extra_clause = nil)
|
247
|
-
# sanitized_where_sql = where_clauses.any? ? "WHERE #{where_clauses.map { |clause| ActiveRecord::Base.sanitize_sql_like(clause) }.join(' AND ')}" : ''
|
248
|
-
# sanitized_order_by = order_clause.present? ? ActiveRecord::Base.sanitize_sql_like(order_clause) : ''
|
249
|
-
# sanitized_limit_clause = limit_clause.present? ? ActiveRecord::Base.sanitize_sql_like(limit_clause) : ''
|
250
175
|
|
251
|
-
|
252
|
-
|
253
|
-
|
254
|
-
|
255
|
-
|
256
|
-
|
257
|
-
|
258
|
-
|
259
|
-
|
260
|
-
|
261
|
-
|
176
|
+
def transform_cypher_sql(raw_sql_string)
|
177
|
+
# Define the logical operators and order multi-word operators first to avoid partial splits
|
178
|
+
operators = ['=', '>', '<', '<>', '>=', '<=', '=~', 'ENDS WITH', 'CONTAINS', 'STARTS WITH', 'IN', 'IS NULL', 'IS NOT NULL']
|
179
|
+
separators = ["AND NOT", "OR NOT", "AND", "OR", "NOT"]
|
180
|
+
|
181
|
+
# Combine the operators and separators into a regex pattern
|
182
|
+
pattern = /(#{(operators + separators).map { |s| Regexp.escape(s) }.join('|')})/
|
183
|
+
|
184
|
+
# Split the raw_sql_string string based on the pattern for operators and separators
|
185
|
+
parts = raw_sql_string.split(pattern)
|
186
|
+
|
187
|
+
# Process each part to identify and transform the attributes
|
188
|
+
transformed_parts = parts.map do |part|
|
189
|
+
# Skip transformation if part is one of the logical operators or separators
|
190
|
+
next part if operators.include?(part.strip) || separators.include?(part.strip)
|
191
|
+
|
192
|
+
# if string contains a dot or is an integer (plus or minus), skip transformation
|
193
|
+
if part.include?(".") || !!(part.strip =~ /\A-?\d+\z/)
|
194
|
+
part # Keep parts with prefixes as they are
|
195
|
+
elsif part =~ /\s*(\w+)\s*$/
|
196
|
+
attribute = $1
|
197
|
+
if attribute == 'end_id'
|
198
|
+
"id(end_node)"
|
199
|
+
elsif attribute == 'start_id'
|
200
|
+
"id(start_node)"
|
201
|
+
elsif attribute == 'id'
|
202
|
+
"id(find)"
|
203
|
+
# attributes must start with a letter
|
204
|
+
elsif attribute =~ /^[a-z]\w*$/
|
205
|
+
"find.#{attribute}"
|
206
|
+
else
|
207
|
+
attribute
|
208
|
+
end
|
209
|
+
else
|
210
|
+
part
|
211
|
+
end
|
212
|
+
end
|
213
|
+
|
214
|
+
# Reassemble the string with the transformed parts
|
215
|
+
transformed_parts.join(" ").gsub(/\s+/, ' ').strip
|
216
|
+
end
|
262
217
|
end
|
263
218
|
end
|
264
219
|
end
|
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'
|