module Neo4j module ActiveNode module Query class QueryProxy include Enumerable include Neo4j::ActiveNode::Query::QueryProxyMethods include Neo4j::ActiveNode::Query::QueryProxyFindInBatches # The most recent node to start a QueryProxy chain. # Will be nil when using QueryProxy chains on class methods. attr_reader :caller, :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: # * node_var: A string or symbol to be used by Cypher within its query string as an identifier # * rel_var: Same as above but pertaining to a relationship identifier # * session: The session to be used for this query # * caller: The node instance at the start of the QueryProxy chain # * 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 @node_var = options[:node] @rel_var = options[:rel] || _rel_chain_var @session = options[:session] @caller = options[:caller] @chain = [] @starting_query = options[:starting_query] @optional = options[:optional] @params = options[:query_proxy] ? options[:query_proxy].instance_variable_get('@params') : {} end # 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. def identity @node_var || _result_string end alias_method :node_identity, :identity # The relationship identifier most recently used by the QueryProxy chain. def rel_identity @rel_var end # Executes the query against the database if the results are not already present in a node's association cache. This method is # shared by each, each_rel, and each_with_rel. # @param [String,Symbol] node The string or symbol of the node to return from the database. # @param [String,Symbol] rel The string or symbol of a relationship to return from the database. def enumerable_query(node, rel = nil) pluck_this = rel.nil? ? [node] : [node, rel] return self.pluck(*pluck_this) if @association.nil? || caller.nil? cypher_string = self.to_cypher_with_params(pluck_this) association_collection = caller.association_instance_get(cypher_string, @association) if association_collection.nil? association_collection = self.pluck(*pluck_this) caller.association_instance_set(cypher_string, association_collection, @association) unless association_collection.empty? end association_collection end # Just like every other each but it allows for optional params to support the versions that also return relationships. # The node and rel params are typically used by those other methods but there's nothing stopping you from # using `your_node.each(true, true)` instead of `your_node.each_with_rel`. # @return [Enumerable] An enumerable containing some combination of nodes and rels. def each(node = true, rel = nil, &block) if node && rel enumerable_query(identity, @rel_var).each { |obj, rel| yield obj, rel } else pluck_this = !rel ? identity : @rel_var enumerable_query(pluck_this).each { |obj| yield obj } end end # When called at the end of a QueryProxy chain, it will return the resultant relationship objects intead of nodes. # For example, to return the relationship between a given student and their lessons: # student.lessons.each_rel do |rel| # @return [Enumerable] An enumerable containing any number of applicable relationship objects. def each_rel(&block) block_given? ? each(false, true, &block) : to_enum(:each, false, true) end # When called at the end of a QueryProxy chain, it will return the nodes and relationships of the last link. # For example, to return a lesson and each relationship to a given student: # student.lessons.each_with_rel do |lesson, rel| def each_with_rel(&block) block_given? ? each(true, true, &block) : to_enum(:each, true, true) end # Does exactly what you would hope. Without it, comparing `bobby.lessons == sandy.lessons` would evaluate to false because it # would be comparing the QueryProxy objects, not the lessons themselves. def ==(value) self.to_a == value end METHODS = %w[where rel_where order skip limit] METHODS.each do |method| module_eval(%Q{ def #{method}(*args) build_deeper_query_proxy(:#{method}, args) end}, __FILE__, __LINE__) end # Since there is a rel_where method, it seems only natural for there to be node_where alias_method :node_where, :where alias_method :offset, :skip alias_method :order_by, :order # For getting variables which have been defined as part of the association chain def pluck(*args) self.query.pluck(*args) end def params(params) self.dup.tap do |new_query| new_query._add_params(params) end 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. # student.lessons.query_as(:l).with('your cypher here...') def query_as(var) query = if @association chain_var = _association_chain_var label_string = @model && ":`#{@model.mapped_label_name}`" (_association_query_start(chain_var) & _query_model_as(var)).send(_match_type, "#{chain_var}#{_association_arrow}(#{var}#{label_string})") else starting_query ? (starting_query & _query_model_as(var)) : _query_model_as(var) end # Build a query chain via the chain, return the result @chain.inject(query.params(@params)) do |query, (method, arg)| query.send(method, arg.respond_to?(:call) ? arg.call(var) : arg) end end # Scope all queries to the current scope. # # 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 # Cypher string for the QueryProxy's query. This will not include params. For the full output, see to_cypher_with_params. def to_cypher query.to_cypher end # 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(other_node, {}) self 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) raise "Can only create associations on associations" unless @association other_nodes = [other_nodes].flatten properties = @association.inject_classname(properties) other_nodes = other_nodes.map do |other_node| case other_node when Integer, String @model.find(other_node) else other_node end end.compact raise ArgumentError, "Node must be of the association's class when model is specified" if @model && other_nodes.any? {|other_node| !other_node.is_a?(@model) } other_nodes.each do |other_node| #Neo4j::Transaction.run do other_node.save if not other_node.persisted? return false if @association.perform_callback(@options[:start_object], other_node, :before) == false start_object = @options[:start_object] start_object.clear_association_cache _session.query(context: @options[:context]) .match("(start#{match_string(start_object)}), (end#{match_string(other_node)})").where("ID(start) = {start_id} AND ID(end) = {end_id}") .params(start_id: start_object.neo_id, end_id: other_node.neo_id) .create("start#{_association_arrow(properties, true)}end").exec @association.perform_callback(@options[:start_object], other_node, :after) #end end end def read_attribute_for_serialization(*args) to_a.map {|o| o.read_attribute_for_serialization(*args) } end # 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) args[2] = self if @model.has_association?(method_name) || @model.has_scope?(method_name) scoping { @model.public_send(method_name, *args, &block) } else super end end def optional? @optional == true end attr_reader :context attr_reader :node_var protected # 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) match_arg = if @model label = @model.respond_to?(:mapped_label_name) ? @model.mapped_label_name : @model { var => label } else var end _session.query(context: @context).send(_match_type, match_arg) end # TODO: Refactor this. Too much happening here. def _result_string if self.association "result_#{self.association.name}".to_sym elsif self.model "result_#{self.model.name.tr!(':', '')}".to_sym else :result end end def _session @session || (@model && @model.neo4j_session) end def _association_arrow(properties = {}, create = false) @association && @association.arrow_cypher(@rel_var, properties, create) end def _chain_level if query_proxy = @options[:query_proxy] query_proxy._chain_level + 1 else 1 end end def _association_chain_var if start_object = @options[:start_object] :"#{start_object.class.name.gsub('::', '_').downcase}#{start_object.neo_id}" elsif query_proxy = @options[:query_proxy] query_proxy.node_var || :"node#{_chain_level}" else raise "Crazy error" # TODO: Better error end end def _association_query_start(var) if start_object = @options[:start_object] start_object.query_as(var) elsif query_proxy = @options[:query_proxy] query_proxy.query_as(var) else raise "Crazy error" # TODO: Better error end end def _rel_chain_var :"rel#{_chain_level - 1}" end def _match_type @optional ? :optional_match : :match end attr_writer :context private def build_deeper_query_proxy(method, args) self.dup.tap do |new_query| args.each do |arg| new_query._add_links(links_for_arg(method, arg)) end end end def links_for_arg(method, arg) method_to_call = "links_for_#{method}_arg" default = [[method, arg]] self.send(method_to_call, arg) || default rescue NoMethodError default end def links_for_where_arg(arg) node_num = 1 result = [] if arg.is_a?(Hash) arg.each do |key, value| if @model && @model.has_association?(key) neo_id = value.try(:neo_id) || value raise ArgumentError, "Invalid value for '#{key}' condition" if not neo_id.is_a?(Integer) n_string = "n#{node_num}" dir = @model.associations[key].direction arrow = dir == :out ? '-->' : '<--' result << [:match, ->(v) { "#{v}#{arrow}(#{n_string})" }] result << [:where, ->(v) { {"ID(#{n_string})" => neo_id.to_i} }] node_num += 1 else result << [:where, ->(v) { {v => {key => value}}}] end end elsif arg.is_a?(String) result << [:where, arg] end result end alias_method :links_for_node_where_arg, :links_for_where_arg # We don't accept strings here. If you want to use a string, just use where. def links_for_rel_where_arg(arg) arg.each_with_object([]) do |(key, value), result| result << [:where, ->(v) {{ rel_identity => { key => value }}}] end end def links_for_order_arg(arg) [[:order, ->(v) { arg.is_a?(String) ? arg : {v => arg} }]] end def match_string(node) ":`#{node.class.mapped_label_name}`" if node.class.respond_to?(:mapped_label_name) end end end end end