Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feature: .union() & .union_all() query clause methods #309

Open
wants to merge 6 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 37 additions & 1 deletion lib/neo4j-core/query.rb
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,10 @@ def inspect
# RETURN clause
# @return [Query]

# @method union *args
# UNION clause
# @return [Query]

# @method create *args
# CREATE clause
# @return [Query]
Expand Down Expand Up @@ -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') }
Expand Down Expand Up @@ -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}})
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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
Expand Down
74 changes: 72 additions & 2 deletions lib/neo4j-core/query_clauses.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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))
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't remember seeing other uses of KW args in the neo4j-core source. I'm not sure if this was personal preference or if you're targeting a version of Ruby pre-KW args

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You're right that they aren't used much. In the past we've supported Ruby 1.9, but we require at least Ruby 2.1 now, so keyword arguments should be fine.

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