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,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