redgraph 0.1.0 → 0.2.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.
@@ -0,0 +1,105 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Redgraph
4
+ class Graph
5
+ module EdgeMethods
6
+ # Adds an edge. If successul it returns the created object, otherwise false
7
+ #
8
+ def add_edge(edge)
9
+ merge_or_add_edge(edge, :create)
10
+ end
11
+
12
+ # Merges (creates an edge unless one with the same type and properties exists) an edge.
13
+ # If successul it returns the object, otherwise false
14
+ #
15
+ def merge_edge(edge)
16
+ merge_or_add_edge(edge, :merge)
17
+ end
18
+
19
+ # Finds edges. Options:
20
+ #
21
+ # - type
22
+ # - src
23
+ # - dest
24
+ # - properties
25
+ # - order
26
+ # - limit
27
+ # - skip
28
+ #
29
+ def edges(type: nil, src: nil, dest: nil, properties: nil, order: nil, limit: nil, skip: nil)
30
+ _order = if order
31
+ raise MissingAliasPrefixError unless order.include?("edge.")
32
+ "ORDER BY #{order}"
33
+ end
34
+ _limit = "LIMIT #{limit}" if limit
35
+ _skip = "SKIP #{skip}" if skip
36
+
37
+ _where = if src || dest
38
+ clauses = [
39
+ ("ID(src) = #{src.id}" if src),
40
+ ("ID(dest) = #{dest.id}" if dest)
41
+ ].compact.join(" AND ")
42
+ "WHERE #{clauses}"
43
+ end
44
+
45
+ edge = Edge.new(type: type, src: src, dest: dest, properties: properties)
46
+
47
+ cmd = "MATCH #{edge.to_query_string} #{_where}
48
+ RETURN src, edge, dest #{_order} #{_skip} #{_limit}"
49
+ result = _query(cmd)
50
+
51
+ result.resultset.map do |item|
52
+ src = node_from_resultset_item(item["src"])
53
+ dest = node_from_resultset_item(item["dest"])
54
+ edge = edge_from_resultset_item(item["edge"])
55
+
56
+ edge.src = src
57
+ edge.dest = dest
58
+
59
+ edge
60
+ end
61
+ end
62
+
63
+ # Counts edges. Options:
64
+ #
65
+ # - type: filter by type
66
+ # - properties: filter by properties
67
+ #
68
+ def count_edges(type: nil, properties: nil)
69
+ edge = Edge.new(type: type, properties: properties)
70
+
71
+ cmd = "MATCH #{edge.to_query_string} RETURN COUNT(edge)"
72
+ query(cmd).flatten[0]
73
+ end
74
+
75
+
76
+ private
77
+
78
+ def edge_from_resultset_item(item)
79
+ (edge_id, type_id, _src_id, _dest_id, props) = item
80
+ attrs = HashWithIndifferentAccess.new
81
+
82
+ props.each do |(index, type, value)|
83
+ attrs[get_property(index)] = value
84
+ end
85
+
86
+ Edge.new.tap do |edge|
87
+ edge.id = edge_id
88
+ edge.type = get_relationship_type(type_id)
89
+ edge.properties = attrs
90
+ end
91
+ end
92
+
93
+ def merge_or_add_edge(edge, verb = :create)
94
+ verb = verb == :create ? "CREATE" : "MERGE"
95
+ result = _query("MATCH (src), (dest)
96
+ WHERE ID(src) = #{edge.src.id} AND ID(dest) = #{edge.dest.id}
97
+ #{verb} #{edge.to_query_string} RETURN ID(edge)")
98
+ return false if result.stats[:relationships_created] != 1
99
+ id = result.resultset.first["ID(edge)"]
100
+ edge.id = id
101
+ edge
102
+ end
103
+ end
104
+ end
105
+ end
@@ -0,0 +1,104 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Redgraph
4
+ class Graph
5
+ module NodeMethods
6
+ # Adds a node. If successul it returns the created object, otherwise false
7
+ #
8
+ def add_node(node)
9
+ merge_or_add_node(node, :create)
10
+ end
11
+
12
+ # Merges (creates a node unless one with the same label and properties exists). If successul
13
+ # it returns the object, otherwise false
14
+ #
15
+ def merge_node(node)
16
+ merge_or_add_node(node, :merge)
17
+ end
18
+
19
+ def find_node_by_id(id)
20
+ result = _query("MATCH (node) WHERE ID(node) = #{id} RETURN node")
21
+ return nil if result.resultset.empty?
22
+ (node_id, labels, properties) = result.resultset.first["node"]
23
+ attrs = {}
24
+
25
+ properties.each do |(index, type, value)|
26
+ attrs[get_property(index)] = value
27
+ end
28
+ Node.new(label: get_label(labels.first), properties: attrs).tap do |node|
29
+ node.id = node_id
30
+ end
31
+ end
32
+
33
+
34
+ # Returns nodes. Options:
35
+ #
36
+ # - label: filter by label
37
+ # - properties: filter by properties
38
+ # - order: node.name ASC, node.year DESC
39
+ # - limit: number of items
40
+ # - skip: items offset (useful for pagination)
41
+ #
42
+ def nodes(label: nil, properties: nil, order: nil, limit: nil, skip: nil)
43
+ _label = ":`#{label}`" if label
44
+ _order = if order
45
+ raise MissingAliasPrefixError unless order.include?("node.")
46
+ "ORDER BY #{order}"
47
+ end
48
+ _limit = "LIMIT #{limit}" if limit
49
+ _skip = "SKIP #{skip}" if skip
50
+
51
+ node = Node.new(label: label, properties: properties)
52
+
53
+ cmd = "MATCH #{node.to_query_string} RETURN node #{_order} #{_skip} #{_limit}"
54
+
55
+ result = _query(cmd)
56
+
57
+ result.resultset.map do |item|
58
+ node_from_resultset_item(item["node"])
59
+ end
60
+ end
61
+
62
+ # Counts nodes. Options:
63
+ #
64
+ # - label: filter by label
65
+ # - properties: filter by properties
66
+ #
67
+ def count_nodes(label: nil, properties: nil)
68
+ node = Node.new(label: label, properties: properties)
69
+
70
+ cmd = "MATCH #{node.to_query_string} RETURN COUNT(node)"
71
+ # RedisGraph bug: if there are no matches COUNT returns zero rows
72
+ # https://github.com/RedisGraph/RedisGraph/issues/1455
73
+ query(cmd).flatten[0] || 0
74
+ end
75
+
76
+ private
77
+
78
+ # Builds a Node object from the raw data
79
+ #
80
+ def node_from_resultset_item(item)
81
+ (node_id, labels, props) = item
82
+ attrs = HashWithIndifferentAccess.new
83
+
84
+ props.each do |(index, type, value)|
85
+ attrs[get_property(index)] = value
86
+ end
87
+ Node.new(label: get_label(labels.first), properties: attrs).tap do |node|
88
+ node.id = node_id
89
+ end
90
+ end
91
+
92
+ def merge_or_add_node(node, verb = :create)
93
+ verb = verb == :create ? "CREATE" : "MERGE"
94
+ result = _query("#{verb} #{node.to_query_string} RETURN ID(node)")
95
+ # Should we treat this case differently?
96
+ # return false if result.stats[:nodes_created] != 1
97
+ id = result.resultset.first["ID(node)"]
98
+ node.id = id
99
+ node
100
+ end
101
+
102
+ end
103
+ end
104
+ end
data/lib/redgraph/node.rb CHANGED
@@ -2,19 +2,27 @@
2
2
 
3
3
  module Redgraph
4
4
  class Node
5
+ include Util
6
+
5
7
  attr_accessor :id, :label, :properties
6
8
 
7
- def initialize(label:, properties: {})
9
+ def initialize(label: nil, properties: nil, id: nil)
10
+ @id = id
8
11
  @label = label
9
- @properties = properties
12
+ @properties = (properties || {}).with_indifferent_access
10
13
  end
11
14
 
12
15
  def persisted?
13
- !id.nil?
16
+ id.present?
14
17
  end
15
18
 
16
19
  def ==(other)
17
20
  super || other.instance_of?(self.class) && !id.nil? && other.id == id
18
21
  end
22
+
23
+ def to_query_string(item_alias: 'node')
24
+ _label = ":#{label}" if label
25
+ "(#{item_alias}#{_label} #{properties_to_string(properties)})"
26
+ end
19
27
  end
20
28
  end
@@ -0,0 +1,123 @@
1
+ # frozen_string_literal: true
2
+ require_relative 'node_model/class_methods'
3
+
4
+ module Redgraph
5
+ # This mixin allows you to use an interface similar to ActiveRecord
6
+ #
7
+ # class Actor
8
+ # include Redgraph::NodeModel
9
+ #
10
+ # self.graph = Redgraph::Graph.new("movies", url: $REDIS_URL)
11
+ # self.label = "actor" # optional, if missing it will be extracted from the class name
12
+ # attribute :name
13
+ # end
14
+ #
15
+ # You will then be able to
16
+ #
17
+ # john = Actor.find(123)
18
+ # total = Actor.count
19
+ #
20
+ # When you create a record it will automatically set the _type property with the class name.
21
+ # This allows reifying the node into the corresponding NodeModel class.
22
+ #
23
+ module NodeModel
24
+ extend ActiveSupport::Concern
25
+
26
+ included do |base|
27
+ @attribute_names = [:id]
28
+
29
+ attr_accessor :id
30
+ attr_accessor :_type
31
+
32
+ class << self
33
+ attr_reader :attribute_names
34
+ attr_accessor :graph
35
+
36
+ def attribute(name)
37
+ @attribute_names << name
38
+ attr_reader(name)
39
+ end
40
+
41
+ private
42
+
43
+ def inherited(subclass)
44
+ super
45
+ subclass.instance_variable_set(:@attribute_names, @attribute_names.dup)
46
+ subclass.instance_variable_set(:@graph, @graph.dup)
47
+ end
48
+ end
49
+ end
50
+
51
+ def initialize(**args)
52
+ absent_attributes = args.keys.map(&:to_sym) - self.class.attribute_names - [:_type]
53
+
54
+ if absent_attributes.any?
55
+ raise ArgumentError, "Unknown attribute #{absent_attributes}"
56
+ end
57
+
58
+ args.each do |name, value|
59
+ instance_variable_set("@#{name}", value)
60
+ end
61
+ end
62
+
63
+ # The current graph
64
+ #
65
+ def graph
66
+ self.class.graph
67
+ end
68
+
69
+ def label
70
+ self.class.label
71
+ end
72
+
73
+ # Object attributes as a hash
74
+ #
75
+ def attributes
76
+ self.class.attribute_names.to_h { |name| [name, public_send(name)] }
77
+ end
78
+
79
+ def persisted?
80
+ id.present?
81
+ end
82
+
83
+ # Adds the node to the graph
84
+ #
85
+ # - allow_duplicates: if false it will create a node with the same type and properties only if
86
+ # not present
87
+ #
88
+ def add_to_graph(allow_duplicates: true)
89
+ item = allow_duplicates ? graph.add_node(to_node) : graph.merge_node(to_node)
90
+ self.id = item.id
91
+ self
92
+ end
93
+
94
+ # Adds a relation between the node and another node.
95
+ #
96
+ # - type: type of relation
97
+ # - node: the destination node
98
+ # - properties: optional properties hash
99
+ # - allow_duplicates: if false it will create a relation between two nodes with the same type
100
+ # and properties only if not present
101
+ #
102
+ def add_relation(type:, node:, properties: nil, allow_duplicates: true)
103
+ edge = Edge.new(type: type, src: to_node, dest: node.to_node, properties: properties)
104
+ allow_duplicates ? graph.add_edge(edge) : graph.merge_edge(edge)
105
+ end
106
+
107
+ def to_node
108
+ props = attributes.except(:id).merge(_type: self.class.name)
109
+ Redgraph::Node.new(id: id, label: label, properties: props)
110
+ end
111
+
112
+ # Converts a Node object into NodeModel
113
+ #
114
+ def reify_from_node(node)
115
+ self.class.reify_from_node(node)
116
+ end
117
+
118
+ def ==(other)
119
+ attributes == other.attributes && id == other.id
120
+ end
121
+
122
+ end
123
+ end
@@ -0,0 +1,76 @@
1
+ module Redgraph
2
+ module NodeModel
3
+ module ClassMethods
4
+ # Returns an array of nodes. Options:
5
+ #
6
+ # - properties: filter by properties
7
+ # - order: node.name ASC, node.year DESC
8
+ # - limit: number of items
9
+ # - skip: items offset (useful for pagination)
10
+ #
11
+ def all(properties: {}, limit: nil, skip: nil, order: nil)
12
+ graph.nodes(label: label, properties: properties_plus_type(properties),
13
+ limit: limit, skip: skip, order: nil).map do |node|
14
+ reify_from_node(node)
15
+ end
16
+ end
17
+
18
+ # Returns the number of nodes with the current label. Options:
19
+ #
20
+ # - properties: filter by properties
21
+ #
22
+ def count(properties: nil)
23
+ graph.count_nodes(label: label, properties: properties_plus_type(properties))
24
+ end
25
+
26
+ # Finds a node by id. Returns nil if not found
27
+ #
28
+ def find(id)
29
+ node = graph.find_node_by_id(id)
30
+ return unless node
31
+ reify_from_node(node)
32
+ end
33
+
34
+ # Sets the label for this class of nodes. If missing it will be computed from the class name
35
+ def label=(x)
36
+ @label = x
37
+ end
38
+
39
+ # Current label
40
+ #
41
+ def label
42
+ @label ||= default_label
43
+ end
44
+
45
+ # Converts a Node object into NodeModel
46
+ #
47
+ def reify_from_node(node)
48
+ klass = node.properties[:_type].to_s.safe_constantize || self
49
+ klass.new(id: node.id, **node.properties)
50
+ end
51
+
52
+ # Runs a query on the graph, but converts the nodes to the corresponding ActiveModel class
53
+ # if available - otherwise they stay NodeObjects.
54
+ #
55
+ # Returns an array of rows.
56
+ #
57
+ def query(cmd)
58
+ graph.query(cmd).map do |row|
59
+ row.map do |item|
60
+ item.is_a?(Node) ? reify_from_node(item) : item
61
+ end
62
+ end
63
+ end
64
+
65
+ private
66
+
67
+ def default_label
68
+ name.demodulize.underscore
69
+ end
70
+
71
+ def properties_plus_type(properties = {})
72
+ {_type: name}.merge(properties || {})
73
+ end
74
+ end
75
+ end
76
+ end
@@ -10,8 +10,28 @@ module Redgraph
10
10
  # - query stats
11
11
  #
12
12
  class QueryResponse
13
- def initialize(response)
13
+ TYPES = [
14
+ UNKNOWN = 0,
15
+ NULL = 1,
16
+ STRING = 2,
17
+ INTEGER = 3,
18
+ BOOLEAN = 4,
19
+ DOUBLE = 5,
20
+ ARRAY = 6,
21
+ EDGE = 7,
22
+ NODE = 8,
23
+ PATH = 9,
24
+ MAP = 10,
25
+ POINT = 11
26
+ ].freeze
27
+
28
+ def initialize(response, graph)
14
29
  @response = response
30
+ @graph = graph
31
+
32
+ @header_row = @response[0]
33
+ @result_rows = @response[1]
34
+ @query_statistics = @response[2]
15
35
  end
16
36
 
17
37
  def stats
@@ -26,19 +46,76 @@ module Redgraph
26
46
  @resultset ||= parse_resultset
27
47
  end
28
48
 
49
+ # Wraps in custom datatypes if needed
50
+ #
51
+ def rows
52
+ @result_rows.map do |column|
53
+ column.map do |data|
54
+ reify_column_item(data)
55
+ end
56
+
57
+ end
58
+ end
59
+
29
60
  private
30
61
 
62
+ def reify_column_item(data)
63
+ value_type, value = data
64
+
65
+ case value_type
66
+ when STRING, INTEGER, BOOLEAN, DOUBLE then value
67
+ when NODE then reify_node_item(value)
68
+ when EDGE then reify_edge_item(value)
69
+ else
70
+ "other"
71
+ end
72
+ end
73
+
74
+ def reify_node_item(data)
75
+ (node_id, labels, props) = data
76
+
77
+ label = @graph.get_label(labels[0]) # Only one label is currently supported
78
+
79
+ node = Node.new(label: label)
80
+ node.id = node_id
81
+
82
+ props.each do |(prop_id, prop_type, prop_value)|
83
+ prop_name = @graph.get_property(prop_id)
84
+ node.properties[prop_name] = prop_value
85
+ end
86
+
87
+ node
88
+ end
89
+
90
+ def reify_edge_item(data)
91
+ (edge_id, type_id, src_id, dest_id, props) = data
92
+
93
+ type = @graph.get_relationship_type(type_id)
94
+
95
+ edge = Edge.new(type: type)
96
+ edge.id = edge_id
97
+ edge.src_id = src_id
98
+ edge.dest_id = dest_id
99
+
100
+ props.each do |(prop_id, prop_type, prop_value)|
101
+ prop_name = @graph.get_property(prop_id)
102
+ edge.properties[prop_name] = prop_value
103
+ end
104
+
105
+ edge
106
+ end
107
+
31
108
  # The header lists the entities described in the RETURN clause. It is an
32
109
  # array of [ColumnType (enum), name (string)] elements. We can ignore the
33
110
  # enum, it is always 1 (COLUMN_SCALAR).
34
111
  def parse_header
35
- @response[0].map{|item| item[1]}
112
+ @header_row.map{|item| item[1]}
36
113
  end
37
114
 
38
115
  def parse_stats
39
116
  stats = {}
40
117
 
41
- @response[2].each do |item|
118
+ @query_statistics.each do |item|
42
119
  label, value = item.split(":")
43
120
 
44
121
  case label
@@ -58,8 +135,8 @@ module Redgraph
58
135
 
59
136
  # The resultset has one element per entity (as described by the header)
60
137
  def parse_resultset
61
- @response[1].map do |item|
62
- out = {}
138
+ @result_rows.map do |item|
139
+ out = HashWithIndifferentAccess.new
63
140
 
64
141
  item.each.with_index do |(type, value), i|
65
142
  out[entities[i]] = value