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.
@@ -1,5 +1,68 @@
1
1
  module ApacheAge
2
2
  class Edge
3
3
  include ApacheAge::Entities::Edge
4
+
5
+ class << self
6
+ def age_type = 'edge'
7
+
8
+ def ensure_query_builder!
9
+ @query_builder ||= ApacheAge::Entities::QueryBuilder.new(self)
10
+ end
11
+
12
+ def cypher(edge_class, graph_name: nil)
13
+ @query_builder = ApacheAge::Entities::QueryBuilder.new(edge_class, graph_name:)
14
+ self
15
+ end
16
+
17
+ def match(match_string)
18
+ ensure_query_builder!
19
+ @query_builder.match(match_string)
20
+ self
21
+ end
22
+
23
+ def where(*args)
24
+ ensure_query_builder!
25
+ @query_builder.where(*args)
26
+ self
27
+ end
28
+
29
+ def order(ordering)
30
+ ensure_query_builder!
31
+ @query_builder.order(ordering)
32
+ self
33
+ end
34
+
35
+ def limit(limit_value)
36
+ ensure_query_builder!
37
+ @query_builder.limit(limit_value)
38
+ self
39
+ end
40
+
41
+ def return(*variables)
42
+ ensure_query_builder!
43
+ @query_builder.return(*variables)
44
+ self
45
+ end
46
+
47
+ def execute
48
+ ensure_query_builder!
49
+ @query_builder.execute
50
+ end
51
+
52
+ def first
53
+ ensure_query_builder!
54
+ @query_builder.first
55
+ end
56
+
57
+ def all
58
+ ensure_query_builder!
59
+ @query_builder.all
60
+ end
61
+
62
+ def to_sql
63
+ ensure_query_builder!
64
+ @query_builder.to_sql
65
+ end
66
+ end
4
67
  end
5
68
  end
@@ -18,42 +18,28 @@ module ApacheAge
18
18
  def find(id) = where(id: id).first
19
19
 
20
20
  def find_by(attributes)
21
- return nil if attributes.reject { |k, v| v.blank? }.empty?
21
+ return nil if attributes.reject { |_k, v| v.blank? }.empty?
22
22
 
23
23
  where(attributes).limit(1).first
24
24
  end
25
25
 
26
26
  # Private stuff
27
27
 
28
- # used? or dead code?
29
- def where_edge(attributes)
30
- where_attribs =
31
- attributes
32
- .compact
33
- .except(:end_id, :start_id, :end_node, :start_node)
34
- .map { |k, v| ActiveRecord::Base.sanitize_sql(["find.#{k} = ?", v]) }
35
- .join(' AND ')
36
- where_attribs = where_attribs.empty? ? nil : where_attribs
37
-
38
- end_id = attributes[:end_id] || attributes[:end_node]&.id
39
- start_id = attributes[:start_id] || attributes[:start_node]&.id
40
- where_end_id = end_id ? ActiveRecord::Base.sanitize_sql(["id(end_node) = ?", end_id]) : nil
41
- where_start_id = start_id ? ActiveRecord::Base.sanitize_sql(["id(start_node) = ?", start_id]) : nil
42
-
43
- where_clause = [where_attribs, where_start_id, where_end_id].compact.join(' AND ')
44
- return nil if where_clause.empty?
45
-
46
- cypher_sql = edge_sql(where_clause)
47
-
48
- execute_where(cypher_sql)
49
- end
50
-
51
28
  def age_graph = 'age_schema'
52
29
  def age_label = name.gsub('::', '__')
53
30
  def age_type = name.constantize.new.age_type
54
31
 
55
32
  def match_clause
56
- age_type == 'vertex' ? "(find:#{age_label})" : "(start_node)-[find:#{age_label}]->(end_node)"
33
+ case age_type
34
+ when 'vertex'
35
+ # this allows us to Query for all nodes or a specific class of nodes
36
+ self == ApacheAge::Node ? '(find)' : "(find:#{age_label})"
37
+ when 'edge'
38
+ # this allows us to Query for all edges or a specific class of edges
39
+ self == ApacheAge::Edge ? '(start_node)-[find]->(end_node)' : "(start_node)-[find:#{age_label}]->(end_node)"
40
+ when 'path'
41
+ "(start_node)-[edge:#{@path_edge.gsub('::', '__')}*#{@path_length} #{path_properties}]->(end_node)"
42
+ end
57
43
  end
58
44
 
59
45
  def execute_sql(cypher_sql) = ActiveRecord::Base.connection.execute(cypher_sql)
@@ -67,47 +53,31 @@ module ApacheAge
67
53
  age_results.values.map do |value|
68
54
  json_data = value.first.split('::').first
69
55
  hash = JSON.parse(json_data)
56
+ # once we have the record we use the label to find the class
57
+ klass = hash['label'].gsub('__', '::').constantize
70
58
  attribs = hash.except('label', 'properties').merge(hash['properties']).symbolize_keys
71
59
 
72
- new(**attribs)
60
+ # knowing the class and attributes we can create a new instance (wether all results are of the same class or not)
61
+ # This allows us to return results for, ApacheAge::Node, ApacheAge::Edge, or any specific type of node or edge
62
+ klass.new(**attribs)
73
63
  end
74
64
  end
75
65
 
76
- def all_sql
77
- <<-SQL
78
- SELECT *
79
- FROM cypher('#{age_graph}', $$
80
- MATCH #{match_clause}
81
- RETURN find
82
- $$) as (#{age_label} agtype);
83
- SQL
84
- end
85
-
86
- def node_sql(where_clause)
87
- <<-SQL
88
- SELECT *
89
- FROM cypher('#{age_graph}', $$
90
- MATCH #{match_clause}
91
- WHERE #{where_clause}
92
- RETURN find
93
- $$) as (#{age_label} agtype);
94
- SQL
95
- end
96
-
97
- def edge_sql(where_clause)
98
- <<-SQL
99
- SELECT *
100
- FROM cypher('#{age_graph}', $$
101
- MATCH #{match_clause}
102
- WHERE #{where_clause}
103
- RETURN find
104
- $$) as (#{age_label} agtype);
105
- SQL
106
- end
107
-
108
66
  private
109
67
 
110
68
  def where_node_clause(attributes)
69
+ # # Make sure we're not treating a simple node attribute query as a start_node hash
70
+ # # This fixes the issue with where(first_name: 'Barney') getting treated as a path query
71
+ # if attributes.key?(:start_node) && attributes[:start_node].is_a?(Hash)
72
+ # # Handle the special case where start_node contains properties
73
+ # start_node_attrs = attributes[:start_node].map do |k, v|
74
+ # query_string = k == :id ? "id(find) = ?" : "find.#{k} = ?"
75
+ # ActiveRecord::Base.sanitize_sql([query_string, v])
76
+ # end.join(' AND ')
77
+ # return start_node_attrs
78
+ # end
79
+
80
+ # Normal case - regular node attributes
111
81
  build_core_where_clause(attributes)
112
82
  end
113
83
 
@@ -144,16 +114,55 @@ module ApacheAge
144
114
  .flatten.compact.join(' AND ')
145
115
  end
146
116
 
117
+ # def where_path_clause(attributes)
118
+ # end
147
119
 
148
120
  def build_core_where_clause(attributes)
149
121
  attributes
150
122
  .compact
151
123
  .map do |k, v|
152
- query_string = k == :id ? "id(find) = #{v}" : "find.#{k} = '#{v}'"
153
- ActiveRecord::Base.sanitize_sql([query_string, v])
124
+ if k == :id
125
+ "id(find) = #{v}"
126
+ else
127
+ # Format the value appropriately based on its type
128
+ formatted_value = format_for_cypher(k, v)
129
+ "find.#{k} = #{formatted_value}"
130
+ end
154
131
  end
155
132
  .join(' AND ')
156
133
  end
134
+
135
+ # Formats a value appropriately for use in a Cypher query based on its type
136
+ def format_for_cypher(attribute_name, value)
137
+ return 'null' if value.nil?
138
+
139
+ # Find the attribute type if possible
140
+ attribute_type = attribute_types[attribute_name.to_s] if respond_to?(:attribute_types)
141
+
142
+ # Format based on Ruby class if no attribute type info is available
143
+ case
144
+ when attribute_type.is_a?(ActiveModel::Type::Boolean) || value == true || value == false
145
+ value.to_s # No quotes for booleans
146
+ when attribute_type.is_a?(ActiveModel::Type::Integer) || value.is_a?(Integer)
147
+ value.to_s # No quotes for integers
148
+ when attribute_type.is_a?(ActiveModel::Type::Float) || attribute_type.is_a?(ActiveModel::Type::Decimal) || value.is_a?(Float) || value.is_a?(BigDecimal)
149
+ value.to_s # No quotes for floats/decimals
150
+ when (attribute_type.is_a?(ActiveModel::Type::Date) && (attribute_type.class != ActiveModel::Type::DateTime)) || value.class == Date
151
+ "'#{value.strftime('%Y-%m-%d')}'" # Format dates as 'YYYY-MM-DD'
152
+ when (attribute_type.is_a?(ActiveModel::Type::DateTime) && (attribute_type.class != ActiveModel::Type::Date)) || value.is_a?(Time) || value.is_a?(DateTime)
153
+ utc_time = value.respond_to?(:utc) ? value.utc : value
154
+ "'#{utc_time.strftime('%Y-%m-%d %H:%M:%S.%6N')}'" # Format datetime (not natively supported in AGE, so formatting is a workaround)
155
+ # when value.is_a?(Array) || value.is_a?(Hash) || value.is_a?(Json)
156
+ # # For JSON data, serialize to JSON string and ensure it's properly quoted
157
+ # "'#{value.to_json.gsub("\'", "\\'")}'"
158
+ # when value.is_a?(ActiveModel::Type::Array) ||value.is_a?(ActiveModel::Type::Hash) || value.is_a?(ActiveModel::Type::Json)
159
+ # # For JSON data, serialize to JSON string and ensure it's properly quoted
160
+ # "'#{value.to_json.gsub("\'", "\\'")}'"
161
+ else
162
+ # Default to string treatment with proper escaping
163
+ "'#{value.to_s.gsub("\'", "\\'")}'"
164
+ end
165
+ end
157
166
  end
158
167
  end
159
168
  end
@@ -24,6 +24,32 @@ module ApacheAge
24
24
  base_h.symbolize_keys
25
25
  end
26
26
 
27
+ # Enhanced hash representation with display information
28
+ # This provides more context without modifying the core attributes
29
+ def to_rich_h
30
+ # Start with the basic id and add the info string
31
+ result = { _meta: "#{age_label} (#{age_type})", id: id }
32
+
33
+ # Group all other attributes under properties
34
+ properties_hash = {}
35
+ attributes.to_hash.except('id').each do |key, value|
36
+ # Skip node objects (we'll handle them specially below)
37
+ next if %w[start_node end_node].include?(key)
38
+
39
+ properties_hash[key.to_sym] = value
40
+ end
41
+
42
+ result[:properties] = properties_hash
43
+
44
+ # Handle nested objects for edges
45
+ if age_type == 'edge'
46
+ result[:start_node] = start_node.to_rich_h if start_node&.respond_to?(:to_rich_h)
47
+ result[:end_node] = end_node.to_rich_h if end_node&.respond_to?(:to_rich_h)
48
+ end
49
+
50
+ result
51
+ end
52
+
27
53
  def update_attributes(attribs)
28
54
  attribs.except(id:).each do |key, value|
29
55
  send("#{key}=", value) if respond_to?("#{key}=")
@@ -47,26 +47,6 @@ module ApacheAge
47
47
  errors.add(:end_node, 'invalid') if end_node && !end_node.valid?
48
48
  end
49
49
 
50
- # Discover attribute class
51
- # name_type = model.class.attribute_types['name']
52
- # age_type = model.class.attribute_types['age']
53
- # company_type = model.class.attribute_types['company']
54
- # # Determine the class from the attribute type (for custom types)
55
- # name_class = name_type.class # This will generally be ActiveModel::Type::String
56
- # age_class = age_type.class # This will generally be ActiveModel::Type::Integer
57
- # # For custom types, you may need to look deeper
58
- # company_class = company_type.cast_type.class
59
-
60
- # AgeSchema::Edges::HasJob.create(
61
- # start_node: fred, end_node: quarry, employee_role: 'Crane Operator'
62
- # )
63
- # SELECT *
64
- # FROM cypher('age_schema', $$
65
- # MATCH (start_vertex:Person), (end_vertex:Company)
66
- # WHERE id(start_vertex) = 1125899906842634 and id(end_vertex) = 844424930131976
67
- # CREATE (start_vertex)-[edge:HasJob {employee_role: 'Crane Operator'}]->(end_vertex)
68
- # RETURN edge
69
- # $$) as (edge agtype);
70
50
  def create_sql
71
51
  self.start_node = start_node.save unless start_node.persisted?
72
52
  self.end_node = end_node.save unless end_node.persisted?
@@ -2,6 +2,10 @@ module ApacheAge
2
2
  module Entities
3
3
  class Entity
4
4
  class << self
5
+ def ensure_query_builder!
6
+ @query_builder ||= ApacheAge::Entities::QueryBuilder.new(self)
7
+ end
8
+
5
9
  def find_by(attributes)
6
10
  where_clause =
7
11
  attributes
@@ -18,6 +22,56 @@ module ApacheAge
18
22
 
19
23
  def find(id) = find_by(id: id)
20
24
 
25
+ def match(match_string)
26
+ ensure_query_builder!
27
+ @query_builder.match(match_string)
28
+ self
29
+ end
30
+
31
+ def where(*args)
32
+ ensure_query_builder!
33
+ @query_builder.where(*args)
34
+ self
35
+ end
36
+
37
+ def order(ordering)
38
+ ensure_query_builder!
39
+ @query_builder.order(ordering)
40
+ self
41
+ end
42
+
43
+ def limit(limit_value)
44
+ ensure_query_builder!
45
+ @query_builder.limit(limit_value)
46
+ self
47
+ end
48
+
49
+ def return(*variables)
50
+ ensure_query_builder!
51
+ @query_builder.return(*variables)
52
+ self
53
+ end
54
+
55
+ def execute
56
+ ensure_query_builder!
57
+ @query_builder.execute
58
+ end
59
+
60
+ def first
61
+ ensure_query_builder!
62
+ @query_builder.first
63
+ end
64
+
65
+ def all
66
+ ensure_query_builder!
67
+ @query_builder.all
68
+ end
69
+
70
+ def to_sql
71
+ ensure_query_builder!
72
+ @query_builder.to_sql
73
+ end
74
+
21
75
  private
22
76
 
23
77
  def age_graph = 'age_schema'
@@ -25,10 +25,6 @@ module ApacheAge
25
25
  # RETURN company
26
26
  # $$) as (Company agtype);
27
27
  def create_sql
28
- # can't use sanitiye without a solution for '_' in the alias name & label
29
- # alias_name = ActiveRecord::Base.sanitize_sql_like(age_alias || age_label.downcase)
30
- # label_name = ActiveRecord::Base.sanitize_sql_like(age_label)
31
-
32
28
  alias_name = age_alias || age_label.downcase
33
29
  sanitized_properties =
34
30
  self
@@ -69,7 +65,6 @@ module ApacheAge
69
65
  $$) as (#{age_label} agtype);
70
66
  SQL
71
67
  end
72
-
73
68
  end
74
69
  end
75
70
  end
@@ -4,23 +4,85 @@ module ApacheAge
4
4
  extend ActiveSupport::Concern
5
5
 
6
6
  included do
7
- include ActiveModel::Model
8
- include ActiveModel::Dirty
9
- include ActiveModel::Attributes
7
+ extend ApacheAge::Entities::ClassMethods
8
+ include ApacheAge::Entities::CommonMethods
9
+ end
10
+
11
+ def age_type = 'path'
10
12
 
11
- attribute :id, :integer
12
- # attribute :label, :string
13
- attribute :end_id, :integer
14
- attribute :start_id, :integer
15
- # override with a specific node type in the defining class
16
- attribute :end_node
17
- attribute :start_node
13
+ def ensure_query_builder!
14
+ @query_builder ||= ApacheAge::Entities::QueryBuilder.new(self.class)
15
+ end
18
16
 
19
- validates :end_node, :start_node, presence: true
20
- validate :validate_nodes
17
+ def match_clause
18
+ "path = (start_node)-[#{path_edge}#{path_length}#{path_properties}]->(end_node)"
19
+ end
21
20
 
22
- extend ApacheAge::Entities::ClassMethods
23
- include ApacheAge::Entities::CommonMethods
21
+ def match(match_string)
22
+ @match_clause = match_string
23
+ self
24
+ end
25
+
26
+ # Delegate additional methods like `where`, `limit`, etc., to `QueryBuilder`
27
+ def where(*args)
28
+ ensure_query_builder!
29
+ @query_builder.where(*args)
30
+ self
31
+ end
32
+
33
+ def order(ordering)
34
+ ensure_query_builder!
35
+ @query_builder.order(ordering)
36
+ self
37
+ end
38
+
39
+ def limit(limit_value)
40
+ ensure_query_builder!
41
+ @query_builder.limit(limit_value)
42
+ self
43
+ end
44
+
45
+ def return(*variables)
46
+ ensure_query_builder!
47
+ @query_builder.return(*variables)
48
+ self
49
+ end
50
+
51
+ # transforms the query and returns results
52
+ def execute
53
+ ensure_query_builder!
54
+ @query_builder.execute
55
+ end
56
+
57
+ def first
58
+ ensure_query_builder!
59
+ @query_builder.first
60
+ end
61
+
62
+ def all
63
+ ensure_query_builder!
64
+ @query_builder.all
65
+ end
66
+
67
+ def to_sql
68
+ ensure_query_builder!
69
+ @query_builder.to_sql
70
+ end
71
+
72
+ private
73
+
74
+ def execute_where(cypher_sql)
75
+ age_results = ActiveRecord::Base.connection.execute(cypher_sql)
76
+ binding.irb
77
+ return [] if age_results.values.count.zero?
78
+
79
+ age_results.values.map do |value|
80
+ json_data = value.first.split('::').first
81
+ hash = JSON.parse(json_data)
82
+ attribs = hash.except('label', 'properties').merge(hash['properties']).symbolize_keys
83
+
84
+ new(**attribs)
85
+ end
24
86
  end
25
87
  end
26
88
  end
@@ -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,28 +15,27 @@ 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
- # TODO: allow for multiple graphs
33
- # def cypher(graph_name = 'age_schema')
34
- # return self if graph_name.blank?
35
-
36
- # @graph_name = graph_name
37
- # self
38
- # end
39
-
40
39
  def match(match_string)
41
40
  @match_clause = match_string
42
41
  self
@@ -124,31 +123,36 @@ module ApacheAge
124
123
 
125
124
  private
126
125
 
127
- # TODO: ensure ordering keys are present in the model
126
+ # Handle ordering criteria for paths
128
127
  def parse_ordering(ordering)
129
128
  if ordering.is_a?(Hash)
130
- ordering =
131
- ordering
132
- .map { |k, v| "find.#{k} #{ActiveRecord::Base.sanitize_sql_like(v.to_s)}" }
133
- .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(', ')
134
147
  elsif ordering.is_a?(Symbol)
135
- ordering = "find.#{ordering}"
148
+ # Default for symbol
149
+ "find.#{ordering}"
136
150
  elsif ordering.is_a?(String)
151
+ # Pass strings through as-is
137
152
  ordering
138
153
  elsif ordering.is_a?(Array)
139
- ordering = ordering.map do |order|
140
- if order.is_a?(Hash)
141
- order
142
- .map { |k, v| "find.#{k} #{ActiveRecord::Base.sanitize_sql_like(v.to_s)}" }
143
- .join(', ')
144
- elsif order.is_a?(Symbol)
145
- "find.#{order}"
146
- elsif order.is_a?(String)
147
- order
148
- else
149
- raise ArgumentError, 'Array elements must be a string, symbol, or hash'
150
- end
151
- end.join(', ')
154
+ # Process arrays recursively
155
+ ordering.map { |order| parse_ordering(order) }.join(', ')
152
156
  else
153
157
  raise ArgumentError, 'Ordering must be a string, symbol, hash, or array'
154
158
  end
@@ -185,7 +189,8 @@ module ApacheAge
185
189
  # Skip transformation if part is one of the logical operators or separators
186
190
  next part if operators.include?(part.strip) || separators.include?(part.strip)
187
191
 
188
- if part.include?(".")
192
+ # if string contains a dot or is an integer (plus or minus), skip transformation
193
+ if part.include?(".") || !!(part.strip =~ /\A-?\d+\z/)
189
194
  part # Keep parts with prefixes as they are
190
195
  elsif part =~ /\s*(\w+)\s*$/
191
196
  attribute = $1