module Neo4j
module ActiveNode
module Query
# rubocop:disable Metrics/ClassLength
class QueryProxy
# rubocop:enable Metrics/ClassLength
include Neo4j::ActiveNode::Query::QueryProxyEnumerable
include Neo4j::ActiveNode::Query::QueryProxyMethods
include Neo4j::ActiveNode::Query::QueryProxyMethodsOfMassUpdating
include Neo4j::ActiveNode::Query::QueryProxyFindInBatches
include Neo4j::ActiveNode::Query::QueryProxyEagerLoading
include Neo4j::ActiveNode::Dependent::QueryProxyMethods
# The most recent node to start a QueryProxy chain.
# Will be nil when using QueryProxy chains on class methods.
attr_reader :source_object, :association, :model, :starting_query
# QueryProxy is ActiveNode's Cypher DSL. While the name might imply that it creates queries in a general sense,
# it is actually referring to Neo4j::Core::Query, which is a pure Ruby Cypher DSL provided by the neo4j-core gem.
# QueryProxy provides ActiveRecord-like methods for common patterns. When it's not handling CRUD for relationships and queries, it
# provides ActiveNode's association chaining (`student.lessons.teachers.where(age: 30).hobbies`) and enjoys long walks on the
# beach.
#
# It should not ever be necessary to instantiate a new QueryProxy object directly, it always happens as a result of
# calling a method that makes use of it.
#
# @param [Constant] model The class which included ActiveNode (typically a model, hence the name) from which the query
# originated.
# @param [Neo4j::ActiveNode::HasN::Association] association The ActiveNode association (an object created by a has_one or
# has_many) that created this object.
# @param [Hash] options Additional options pertaining to the QueryProxy object. These may include:
# @option options [String, Symbol] :node_var A string or symbol to be used by Cypher within its query string as an identifier
# @option options [String, Symbol] :rel_var Same as above but pertaining to a relationship identifier
# @option options [Range, Integer, Symbol, Hash] :rel_length A Range, a Integer, a Hash or a Symbol to indicate the variable-length/fixed-length
# qualifier of the relationship. See http://neo4jrb.readthedocs.org/en/latest/Querying.html#variable-length-relationships.
# @option options [Neo4j::Session] :session The session to be used for this query
# @option options [Neo4j::ActiveNode] :source_object The node instance at the start of the QueryProxy chain
# @option options [QueryProxy] :query_proxy An existing QueryProxy chain upon which this new object should be built
#
# QueryProxy objects are evaluated lazily.
def initialize(model, association = nil, options = {})
@model = model
@association = association
@context = options.delete(:context)
@options = options
@associations_spec = []
instance_vars_from_options!(options)
@match_type = @optional ? :optional_match : :match
@rel_var = options[:rel] || _rel_chain_var
@chain = []
@params = @query_proxy ? @query_proxy.instance_variable_get('@params') : {}
end
def inspect
formatted_nodes = Neo4j::ActiveNode::NodeListFormatter.new(to_a)
"#"
end
attr_reader :start_object, :query_proxy
# The current node identifier on deck, so to speak. It is the object that will be returned by calling `each` and the last node link
# in the QueryProxy chain.
attr_reader :node_var
def identity
@node_var || _result_string(_chain_level + 1)
end
alias node_identity identity
# The relationship identifier most recently used by the QueryProxy chain.
attr_reader :rel_var
def rel_identity
ActiveSupport::Deprecation.warn 'rel_identity is deprecated and may be removed from future releases, use rel_var instead.', caller
@rel_var
end
def params(params)
new_link.tap { |new_query| new_query._add_params(params) }
end
# Like calling #query_as, but for when you don't care about the variable name
def query
query_as(identity)
end
# Build a Neo4j::Core::Query object for the QueryProxy. This is necessary when you want to take an existing QueryProxy chain
# and work with it from the more powerful (but less friendly) Neo4j::Core::Query.
# @param [String,Symbol] var The identifier to use for node at this link of the QueryProxy chain.
#
# .. code-block:: ruby
#
# student.lessons.query_as(:l).with('your cypher here...')
def query_as(var, with_labels = true)
query_from_chain(chain, base_query(var, with_labels).params(@params), var)
.tap { |query| query.proxy_chain_level = _chain_level }
end
def query_from_chain(chain, base_query, var)
chain.inject(base_query) do |query, link|
args = link.args(var, rel_var)
args.is_a?(Array) ? query.send(link.clause, *args) : query.send(link.clause, args)
end
end
def base_query(var, with_labels = true)
if @association
chain_var = _association_chain_var
(_association_query_start(chain_var) & _query).break.send(@match_type,
"(#{chain_var})#{_association_arrow}(#{var}#{_model_label_string})")
else
starting_query ? starting_query : _query_model_as(var, with_labels)
end
end
# param [TrueClass, FalseClass] with_labels This param is used by certain QueryProxy methods that already have the neo_id and
# therefore do not need labels.
# The @association_labels instance var is set during init and used during association chaining to keep labels out of Cypher queries.
def _model_label_string(with_labels = true)
return if !@model || (!with_labels || @association_labels == false)
@model.mapped_label_names.map { |label_name| ":`#{label_name}`" }.join
end
# Scope all queries to the current scope.
#
# .. code-block:: ruby
#
# Comment.where(post_id: 1).scoping do
# Comment.first
# end
#
# TODO: unscoped
# Please check unscoped if you want to remove all previous scopes (including
# the default_scope) during the execution of a block.
def scoping
previous = @model.current_scope
@model.current_scope = self
yield
ensure
@model.current_scope = previous
end
METHODS = %w(where where_not rel_where rel_where_not rel_order order skip limit)
METHODS.each do |method|
define_method(method) { |*args| build_deeper_query_proxy(method.to_sym, args) }
end
# Since there are rel_where and rel_order methods, it seems only natural for there to be node_where and node_order
alias node_where where
alias node_order order
alias offset skip
alias order_by order
# Cypher string for the QueryProxy's query. This will not include params. For the full output, see to_cypher_with_params.
delegate :to_cypher, to: :query
delegate :print_cypher, to: :query
# Returns a string of the cypher query with return objects and params
# @param [Array] columns array containing symbols of identifiers used in the query
# @return [String]
def to_cypher_with_params(columns = [self.identity])
final_query = query.return_query(columns)
"#{final_query.to_cypher} | params: #{final_query.send(:merge_params)}"
end
# To add a relationship for the node for the association on this QueryProxy
def <<(other_node)
_create_relation_or_defer(other_node)
self
end
# Executes the relation chain specified in the block, while keeping the current scope
#
# @example Load all people that have friends
# Person.all.branch { friends }.to_a # => Returns a list of `Person`
#
# @example Load all people that has old friends
# Person.all.branch { friends.where('age > 70') }.to_a # => Returns a list of `Person`
#
# @yield the block that will be evaluated starting from the current scope
#
# @return [QueryProxy] A new QueryProxy
def branch(&block)
fail LocalJumpError, 'no block given' if block.nil?
# `as(identity)` is here to make sure we get the right variable
# There might be a deeper problem of the variable changing when we
# traverse an association
as(identity).instance_eval(&block).query.proxy_as(self.model, identity).tap do |new_query_proxy|
propagate_context(new_query_proxy)
end
end
def [](index)
# TODO: Maybe for this and other methods, use array if already loaded, otherwise
# use OFFSET and LIMIT 1?
self.to_a[index]
end
def create(other_nodes, properties = {})
fail 'Can only create relationships on associations' if !@association
other_nodes = _nodeify!(*other_nodes)
Neo4j::ActiveBase.run_transaction do
other_nodes.each do |other_node|
if other_node.neo_id
other_node.try(:delete_reverse_relationship, association)
else
other_node.save
end
@start_object.association_proxy_cache.clear
_create_relationship(other_node, properties)
end
end
end
def _nodeify!(*args)
other_nodes = [args].flatten!.map! do |arg|
(arg.is_a?(Integer) || arg.is_a?(String)) ? @model.find_by(id: arg) : arg
end.compact
if @model && other_nodes.any? { |other_node| !other_node.class.mapped_label_names.include?(@model.mapped_label_name) }
fail ArgumentError, "Node must be of the association's class when model is specified"
end
other_nodes
end
def _create_relationship(other_node_or_nodes, properties)
association._create_relationship(@start_object, other_node_or_nodes, properties)
end
def read_attribute_for_serialization(*args)
to_a.map { |o| o.read_attribute_for_serialization(*args) }
end
delegate :to_ary, to: :to_a
# QueryProxy objects act as a representation of a model at the class level so we pass through calls
# This allows us to define class functions for reusable query chaining or for end-of-query aggregation/summarizing
def method_missing(method_name, *args, &block)
if @model && @model.respond_to?(method_name)
scoping { @model.public_send(method_name, *args, &block) }
else
super
end
end
def respond_to_missing?(method_name, include_all = false)
(@model && @model.respond_to?(method_name, include_all)) || super
end
def optional?
@optional == true
end
attr_reader :context
def new_link(node_var = nil)
self.clone.tap do |new_query_proxy|
new_query_proxy.instance_variable_set('@result_cache', nil)
new_query_proxy.instance_variable_set('@node_var', node_var) if node_var
end
end
def unpersisted_start_object?
@start_object && @start_object.new_record?
end
protected
def _create_relation_or_defer(other_node)
if @start_object._persisted_obj
create(other_node, {})
elsif @association
@start_object.defer_create(@association.name, other_node)
else
fail 'Another crazy error!'
end
end
# Methods are underscored to prevent conflict with user class methods
def _add_params(params)
@params = @params.merge(params)
end
def _add_links(links)
@chain += links
end
def _query_model_as(var, with_labels = true)
_query.break.send(@match_type, _match_arg(var, with_labels))
end
# @param [String, Symbol] var The Cypher identifier to use within the match string
# @param [Boolean] with_labels Send "true" to include model labels where possible.
def _match_arg(var, with_labels)
if @model && with_labels != false
labels = @model.respond_to?(:mapped_label_names) ? _model_label_string : @model
{var.to_sym => labels}
else
var.to_sym
end
end
def _query
Neo4j::ActiveBase.new_query(context: @context)
end
def _result_string(index = nil)
"result_#{(association || model).try(:name)}#{index}".downcase.tr(':', '').to_sym
end
def _session
(@session || (@model && @model.neo4j_session)).tap do |session|
fail 'No session found!' if session.nil?
end
end
def _association_arrow(properties = {}, create = false)
@association && @association.arrow_cypher(@rel_var, properties, create, false, @rel_length)
end
def _chain_level
(@query_proxy ? @query_proxy._chain_level : (@chain_level || 0)) + 1
end
def _association_chain_var
fail 'Crazy error' if !(start_object || @query_proxy)
if start_object
:"#{start_object.class.name.gsub('::', '_').downcase}#{start_object.neo_id}"
else
@query_proxy.node_var || :"node#{_chain_level}"
end
end
def _association_query_start(var)
# TODO: Better error
fail 'Crazy error' if !(object = (start_object || @query_proxy))
object.query_as(var)
end
def _rel_chain_var
:"rel#{_chain_level - 1}"
end
attr_writer :context
private
def instance_vars_from_options!(options)
@node_var, @session, @source_object, @starting_query, @optional,
@start_object, @query_proxy, @chain_level, @association_labels,
@rel_length = options.values_at(:node, :session, :source_object, :starting_query, :optional,
:start_object, :query_proxy, :chain_level, :association_labels, :rel_length)
end
def build_deeper_query_proxy(method, args)
new_link.tap do |new_query_proxy|
Link.for_args(@model, method, args, association).each { |link| new_query_proxy._add_links(link) }
end
end
end
end
end
end