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.
@@ -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
- def initialize(model_class, graph_name: nil)
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
- @return_names = ['find']
24
- @return_clause = 'find'
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
- string_query = args.first
56
- if string_query.include?('id = ?')
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
- query_string =
68
- if raw_query_string.include?('id = ?')
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
- # TODO: ensure ordering keys are present in the model
126
+ # Handle ordering criteria for paths
203
127
  def parse_ordering(ordering)
204
128
  if ordering.is_a?(Hash)
205
- ordering =
206
- ordering
207
- .map { |k, v| "find.#{k} #{ActiveRecord::Base.sanitize_sql_like(v.to_s)}" }
208
- .join(', ')
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
- ordering = "find.#{ordering}"
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
- ordering = ordering.map do |order|
215
- if order.is_a?(Hash)
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
- # <<-SQL.squish
252
- # SELECT *
253
- # FROM cypher('#{graph_name}', $$
254
- # MATCH #{ActiveRecord::Base.sanitize_sql_like(match_clause)}
255
- # #{sanitized_where_sql}
256
- # RETURN #{ActiveRecord::Base.sanitize_sql_like(return_clause)}
257
- # #{sanitized_order_by}
258
- # #{sanitized_limit_clause}
259
- # $$) AS (#{return_names.map { |name| "#{ActiveRecord::Base.sanitize_sql_like(name)} agtype" }.join(', ')});
260
- # SQL
261
- # end
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
@@ -12,24 +12,67 @@ module ApacheAge
12
12
  info.blank? ? "#{label} (#{id})" : "#{info} (#{label})"
13
13
  end
14
14
 
15
- def self.all
16
- all_nodes_sql = <<~SQL
17
- SELECT *
18
- FROM cypher('age_schema', $$
19
- MATCH (node)
20
- RETURN node
21
- $$) as (node agtype);
22
- SQL
23
- age_results = ActiveRecord::Base.connection.execute(all_nodes_sql)
24
- return [] if age_results.values.count.zero?
25
-
26
- age_results.values.map do |result|
27
- json_string = result.first.split('::').first
28
- hash = JSON.parse(json_string)
29
- attribs = hash.slice('id', 'label').symbolize_keys
30
- attribs[:properties] = hash['properties'].symbolize_keys
31
-
32
- new(**attribs)
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
@@ -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
@@ -1,3 +1,3 @@
1
1
  module RailsAge
2
- VERSION = '0.6.3'
2
+ VERSION = '0.7.0'
3
3
  end
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
- # require 'apache_age/entities/path'
16
+ require 'apache_age/entities/path'
17
17
  require 'apache_age/node'
18
18
  require 'apache_age/edge'
19
- # require 'apache_age/path'
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'