diff --git a/lib/neo4j-core/query.rb b/lib/neo4j-core/query.rb index 080e7942..07e08585 100644 --- a/lib/neo4j-core/query.rb +++ b/lib/neo4j-core/query.rb @@ -131,6 +131,10 @@ def inspect # RETURN clause # @return [Query] + # @method union *args + # UNION clause + # @return [Query] + # @method create *args # CREATE clause # @return [Query] @@ -159,7 +163,8 @@ def inspect # 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 return order skip limit] # rubocop:disable Metrics/LineLength + # This ordering of the METHODS will be used when constructing the final cypher 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 return order skip limit union] # rubocop:disable Metrics/LineLength BREAK_METHODS = %(with call) CLAUSIFY_CLAUSE = proc { |method| const_get(method.to_s.split('_').map(&:capitalize).join + 'Clause') } @@ -209,6 +214,11 @@ def break build_deeper_query(nil) end + # UNION ALL cypher clause. Similar to UNION method / clause but doesn't de-duplicate results. See Neo4j docs for more info. + def union_all(*args) + build_deeper_query(UnionClause, args, all: true) + end + # Allows for the specification of values for params specified in query # @example # # Creates a query representing the cypher: MATCH (q: Person {id: {id}}) @@ -322,6 +332,30 @@ def return_query(columns) query = copy query.remove_clause_class(ReturnClause) + # Check for union clauses + clauses_by_class = query.clauses.group_by(&:class) + union_clauses = clauses_by_class[::Neo4j::Core::QueryClauses::UnionClause] + + # If the query object has union clauses, overwrite the return of each union clause + # to return `*columns`. Ignore union clauses which have a string `@arg` + if union_clauses && union_clauses.any? + query.remove_clause_class(UnionClause) + + union_clauses.each do |union_clause| + arg = union_clause.arg + + # If `arg` is a Query object, we can overwrite the return. Else, ignore it and hope + # the dev has specified the correct return + if arg.is_a? Query + arg.remove_clause_class(ReturnClause) + union_clause.arg = arg.return(*columns) + union_clause.reset_value! + end + end + + query.add_clauses(union_clauses) + end + query.return(*columns) end @@ -422,7 +456,9 @@ def remove_clause_class(clause_class) def build_deeper_query(clause_class, args = {}, options = {}) copy.tap do |new_query| + # An empty clause (`[nil]`) indicates a "break" to the "generate_partitioning" method new_query.add_clauses [nil] if [nil, WithClause].include?(clause_class) + # @params stores the query's accumulated ".params()" values new_query.add_clauses clause_class.from_args(args, new_query.instance_variable_get('@params'.freeze), options) if clause_class end end diff --git a/lib/neo4j-core/query_clauses.rb b/lib/neo4j-core/query_clauses.rb index 9032e3fc..c5536bb0 100644 --- a/lib/neo4j-core/query_clauses.rb +++ b/lib/neo4j-core/query_clauses.rb @@ -25,11 +25,12 @@ def initialize(arg, params, options = {}) @param_vars_added = [] end + # Returns the query clause as a cypher string and caches result def value return @value if @value - [String, Symbol, Integer, Hash, NilClass].each do |arg_class| - from_method = "from_#{arg_class.name.downcase}" + [String, Symbol, Integer, Hash, NilClass, Query].each do |arg_class| + from_method = "from_#{arg_class.name.demodulize.downcase}" return @value = send(from_method, @arg) if @arg.is_a?(arg_class) && self.respond_to?(from_method) end @@ -727,6 +728,75 @@ def clause_join end end end + + class UnionClause < Clause + KEYWORD = 'UNION' + + # If `value` is a query object, returns value.to_cypher. Formatting optional + def from_query(value, pretty: false) + from_string(value.to_cypher(pretty: pretty)) + end + + # Returns the query clause as a pretty string, if able. + # Cannot format Union Clauses if @arg is a string + def pretty_value + return from_query(@arg, pretty: true) if @arg.is_a? Query + value + end + + # The query argument stored by the union clause may be mutated if the + # query object result is retreaved by `.pluck()` (see Query#pluck) + # If so, then the cached union clause value should be tossed + def reset_value! + @value = nil + return self + end + + class << self + # Union clauses can only be called with a string or Query argument + def from_args(args, params, options = {}) + arg = args.first + + [from_arg(arg, params, options)] + end + + def from_arg(arg, params, options = {}) + new(arg, params, options) if arg.is_a?(Query) || arg.is_a?(String) + end + + def to_cypher(clauses, pretty = false) + clause_string(clauses, pretty) + end + + def clause_string(clauses, pretty) + strings = clause_strings(clauses, pretty) + stripped_string = strings.join(clause_join) + stripped_string.strip! + end + + # If `.union()` was called with `all: true` option, insert 'UNION ALL' clause + # otherwise insert 'UNION' clause + def clause_strings(clauses, pretty) + clauses.map do |clause| + clause_keyword = if clause.options && clause.options[:all] + "#{keyword} ALL" + else + keyword + end + + if pretty + "#{clause_color}#{clause_keyword}#{ANSI::CLEAR}" + "\n" + clause.pretty_value + "\n" + else + "#{clause_keyword} #{clause.value}" + end + end + end + + def clause_join(options = {}) + '' + end + end + end end end end