require 'active_graph/core/query_clauses' require 'active_graph/core/query_find_in_batches' require 'active_support/notifications' module ActiveGraph module Core # Allows for generation of cypher queries via ruby method calls (inspired by ActiveRecord / arel syntax) # # Can be used to express cypher queries in ruby nicely, or to more easily generate queries programatically. # # Also, queries can be passed around an application to progressively build a query across different concerns # # See also the following link for full cypher language documentation: # http://docs.neo4j.org/chunked/milestone/cypher-query-lang.html class Query include ActiveGraph::Core::QueryClauses include ActiveGraph::Core::QueryFindInBatches DEFINED_CLAUSES = {} attr_accessor :clauses class Parameters def initialize(hash = nil) @parameters = (hash || {}) end def to_hash @parameters end def copy self.class.new(@parameters.dup) end def add_param(key, value) free_param_key(key).tap do |k| @parameters[k.freeze] = value end end def remove_param(key) @parameters.delete(key.to_sym) end def add_params(params) params.map do |key, value| add_param(key, value) end end private def free_param_key(key) k = key.to_sym return k if !@parameters.key?(k) i = 2 i += 1 while @parameters.key?("#{key}#{i}".to_sym) "#{key}#{i}".to_sym end end class << self attr_accessor :pretty_cypher end def initialize(options = {}) @driver = options[:driver] @options = options @clauses = [] @_params = {} @params = Parameters.new end def inspect "#" end # @method start *args # START clause # @return [Query] # @method match *args # MATCH clause # @return [Query] # @method optional_match *args # OPTIONAL MATCH clause # @return [Query] # @method using *args # USING clause # @return [Query] # @method where *args # WHERE clause # @return [Query] # @method with *args # WITH clause # @return [Query] # @method with_distinct *args # WITH clause with DISTINCT specified # @return [Query] # @method order *args # ORDER BY clause # @return [Query] # @method limit *args # LIMIT clause # @return [Query] # @method skip *args # SKIP clause # @return [Query] # @method set *args # SET clause # @return [Query] # @method remove *args # REMOVE clause # @return [Query] # @method unwind *args # UNWIND clause # @return [Query] # @method return *args # RETURN clause # @return [Query] # @method create *args # CREATE clause # @return [Query] # @method create_unique *args # CREATE UNIQUE clause # @return [Query] # @method merge *args # MERGE clause # @return [Query] # @method on_create_set *args # ON CREATE SET clause # @return [Query] # @method on_match_set *args # ON MATCH SET clause # @return [Query] # @method delete *args # DELETE clause # @return [Query] # @method detach_delete *args # DETACH DELETE clause # @return [Query] METHODS = %w[start match optional_match call using where create create_unique merge set on_create_set on_match_set remove unwind delete detach_delete with with_distinct return order skip limit] # rubocop:disable Metrics/LineLength BREAK_METHODS = %(with with_distinct call) CLAUSIFY_CLAUSE = proc { |method| const_get(method.to_s.split('_').map(&:capitalize).join + 'Clause') } CLAUSES = METHODS.map(&CLAUSIFY_CLAUSE) METHODS.each_with_index do |clause, i| clause_class = CLAUSES[i] DEFINED_CLAUSES[clause.to_sym] = clause_class define_method(clause) do |*args| result = build_deeper_query(clause_class, args) BREAK_METHODS.include?(clause) ? result.break : result end end alias offset skip alias order_by order # Clears out previous order clauses and allows only for those specified by args def reorder(*args) query = copy query.remove_clause_class(OrderClause) query.order(*args) end # Works the same as the #where method, but the clause is surrounded by a # Cypher NOT() function def where_not(*args) build_deeper_query(WhereClause, args, not: true) end # Works the same as the #set method, but when given a nested array it will set properties rather than setting entire objects # @example # # Creates a query representing the cypher: MATCH (n:Person) SET n.age = 19 # Query.new.match(n: :Person).set_props(n: {age: 19}) def set_props(*args) # rubocop:disable Naming/AccessorMethodName build_deeper_query(SetClause, args, set_props: true) end # Allows what's been built of the query so far to be frozen and the rest built anew. Can be called multiple times in a string of method calls # @example # # Creates a query representing the cypher: MATCH (q:Person), r:Car MATCH (p: Person)-->q # Query.new.match(q: Person).match('r:Car').break.match('(p: Person)-->q') def break build_deeper_query(nil) end # Allows for the specification of values for params specified in query # @example # # Creates a query representing the cypher: MATCH (q: Person {id: $id}) # # Calls to params don't affect the cypher query generated, but the params will be # # Passed down when the query is made # Query.new.match('(q: Person {id: $id})').params(id: 12) # def params(args) copy.tap { |new_query| new_query.instance_variable_get('@params'.freeze).add_params(args) } end def unwrapped @_unwrapped_obj = true self end def unwrapped? !!@_unwrapped_obj end def response return @response if @response @response = ActiveGraph::Transaction.query(self, transaction: Transaction.root, wrap_level: (:core_entity if unwrapped?)) end def raise_if_cypher_error!(response) response.raise_cypher_error if response.respond_to?(:error?) && response.error? end def match_nodes(hash, optional_match = false) hash.inject(self) do |query, (variable, node_object)| neo_id = (node_object.respond_to?(:neo_id) ? node_object.neo_id : node_object) match_method = optional_match ? :optional_match : :match query.send(match_method, variable).where(variable => {neo_id: neo_id}) end end def optional_match_nodes(hash) match_nodes(hash, true) end include Enumerable def count(var = nil) v = var.nil? ? '*' : var pluck("count(#{v})").first end def each response.each { |object| yield object } end # @method to_a # Class is Enumerable. Each yield is a Hash with the key matching the variable returned and the value being the value for that key from the response # @return [Array] # @raise [ActiveGraph::Server::CypherResponse::ResponseError] Raises errors from neo4j server # Executes a query without returning the result # @return [Boolean] true if successful # @raise [ActiveGraph::Server::CypherResponse::ResponseError] Raises errors from neo4j server def exec response true end # Return the specified columns as an array. # If one column is specified, a one-dimensional array is returned with the values of that column # If two columns are specified, a n-dimensional array is returned with the values of those columns # # @example # Query.new.match(n: :Person).return(p: :name}.pluck(p: :name) # => Array of names # @example # Query.new.match(n: :Person).return(p: :name}.pluck('p, DISTINCT p.name') # => Array of [node, name] pairs # def pluck(*columns) fail ArgumentError, 'No columns specified for Query#pluck' if columns.size.zero? query = return_query(columns) columns = query.response.columns if columns.size == 1 column = columns[0] query.map { |row| row[column] } else query.map { |row| columns.map { |column| row[column] } } end end def return_query(columns) query = copy query.remove_clause_class(ReturnClause) query.return(*columns) end # Returns a CYPHER query string from the object query representation # @example # Query.new.match(p: :Person).where(p: {age: 30}) # => "MATCH (p:Person) WHERE p.age = 30 # # @return [String] Resulting cypher query string EMPTY = ' ' NEWLINE = "\n" def to_cypher(options = {}) join_string = options[:pretty] ? NEWLINE : EMPTY cypher_string = partitioned_clauses.map do |clauses| clauses_by_class = clauses.group_by(&:class) cypher_parts = CLAUSES.map do |clause_class| clause_class.to_cypher(clauses, options[:pretty]) if clauses = clauses_by_class[clause_class] end.compact cypher_parts.join(join_string).tap(&:strip!) end.join(join_string) cypher_string = "CYPHER #{@options[:parser]} #{cypher_string}" if @options[:parser] cypher_string.tap(&:strip!) end alias cypher to_cypher def pretty_cypher to_cypher(pretty: true) end def context @options[:context] end def parameters to_cypher merge_params end def partitioned_clauses @partitioned_clauses ||= PartitionedClauses.new(@clauses) end def print_cypher puts to_cypher(pretty: true).gsub(/\e[^m]+m/, '') end # Returns a CYPHER query specifying the union of the callee object's query and the argument's query # # @example # # Generates cypher: MATCH (n:Person) UNION MATCH (o:Person) WHERE o.age = 10 # q = ActiveGraph::Core::Query.new.match(o: :Person).where(o: {age: 10}) # result = ActiveGraph::Core::Query.new.match(n: :Person).union_cypher(q) # # @param other [Query] Second half of UNION # @param options [Hash] Specify {all: true} to use UNION ALL # @return [String] Resulting UNION cypher query string def union_cypher(other, options = {}) "#{to_cypher} UNION#{options[:all] ? ' ALL' : ''} #{other.to_cypher}" end def &(other) self.class.new(driver: @driver).tap do |new_query| new_query.options = options.merge(other.options) new_query.clauses = clauses + other.clauses end.params(other._params) end def copy dup.tap do |query| to_cypher query.instance_variable_set('@params'.freeze, @params.copy) query.instance_variable_set('@partitioned_clauses'.freeze, nil) query.instance_variable_set('@response'.freeze, nil) end end def clause?(method) clause_class = DEFINED_CLAUSES[method] || CLAUSIFY_CLAUSE.call(method) clauses.any? { |clause| clause.is_a?(clause_class) } end protected attr_accessor :driver, :options, :_params def add_clauses(clauses) @clauses += clauses end def remove_clause_class(clause_class) @clauses = @clauses.reject { |clause| clause.is_a?(clause_class) } end private def build_deeper_query(clause_class, args = {}, options = {}) copy.tap do |new_query| new_query.add_clauses [nil] if [nil, WithClause].include?(clause_class) new_query.add_clauses clause_class.from_args(args, new_query.instance_variable_get('@params'.freeze), options) if clause_class end end class PartitionedClauses def initialize(clauses) @clauses = clauses @partitioning = [[]] end include Enumerable def each generate_partitioning! @partitioning.each { |partition| yield partition } end def generate_partitioning! @partitioning = [[]] @clauses.each do |clause| if clause.nil? && !fresh_partition? @partitioning << [] elsif clause_is_order_or_limit_directly_following_with_or_order?(clause) second_to_last << clause elsif clause_is_with_following_order_or_limit?(clause) second_to_last << clause second_to_last.sort_by! { |c| c.is_a?(::ActiveGraph::Core::QueryClauses::OrderClause) ? 1 : 0 } else @partitioning.last << clause end end end private def fresh_partition? @partitioning.last == [] end def second_to_last @partitioning[-2] end def clause_is_order_or_limit_directly_following_with_or_order?(clause) self.class.clause_is_order_or_limit?(clause) && @partitioning[-2] && @partitioning[-1].empty? && (@partitioning[-2].last.is_a?(::ActiveGraph::Core::QueryClauses::WithClause) || @partitioning[-2].last.is_a?(::ActiveGraph::Core::QueryClauses::OrderClause)) end def clause_is_with_following_order_or_limit?(clause) clause.is_a?(::ActiveGraph::Core::QueryClauses::WithClause) && @partitioning[-2] && @partitioning[-2].any? { |c| self.class.clause_is_order_or_limit?(c) } end class << self def clause_is_order_or_limit?(clause) clause.is_a?(::ActiveGraph::Core::QueryClauses::OrderClause) || clause.is_a?(::ActiveGraph::Core::QueryClauses::LimitClause) end end end # SHOULD BE DEPRECATED def merge_params @merge_params_base ||= @clauses.compact.inject({}) { |params, clause| params.merge!(clause.params) } @params.to_hash.merge(@merge_params_base) end end end end