diff --git a/.rubocop.yml b/.rubocop.yml index 2cfdd9f9f..1ce0ba322 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -68,5 +68,3 @@ Style/Lambda: # Reason: I'm proud to be part of the double negative Ruby tradition Style/DoubleNegation: Enabled: false - - diff --git a/docs/Querying.rst b/docs/Querying.rst index 5ad2e3ca3..e9a8a8767 100644 --- a/docs/Querying.rst +++ b/docs/Querying.rst @@ -76,7 +76,7 @@ Here we are limiting lessons by the ``start_date`` and ``end_date`` on the relat student.lessons.where(subject: 'Math').rel_where(grade: 85) -Paramaters +Parameters ~~~~~~~~~~ If you need to use a string in where, you should set the parameter manually. @@ -87,6 +87,65 @@ If you need to use a string in where, you should set the parameter manually. .params(age: params[:age], name: params[:name], home_town: params[:home_town]) .pluck(:s) +Variable-length relationships +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +It is possible to specify a variable-length qualifier to apply to relationships when calling association methods. + +.. code-block:: ruby + + student.friends(rel_length: 2) + +This would find the friends of friends of a student. Note that you can still name matched nodes and relationships and use those names to build your query as seen above: + +.. code-block:: ruby + + student.friends(:f, :r, rel_length: 2).where('f.gender = {gender} AND r.since >= {date}').params(gender: 'M', date: 1.month.ago) + + +.. note:: + + You can either pass a single options Hash or provide **both** the node and relationship names along with the optional Hash. + + +There are many ways to provide the length information to generate all the various possibilities Cypher offers: + +.. code-block:: ruby + + # As a Fixnum: + ## Cypher: -[:`FRIENDS`*2]-> + student.friends(rel_length: 2) + + # As a Range: + ## Cypher: -[:`FRIENDS`*1..3]-> + student.friends(rel_length: 1..3) # Get up to 3rd degree friends + + # As a Hash: + ## Cypher: -[:`FRIENDS`*1..3]-> + student.friends(rel_length: {min: 1, max: 3}) + + ## Cypher: -[:`FRIENDS`*0..]-> + student.friends(rel_length: {min: 0}) + + ## Cypher: -[:`FRIENDS`*..3]-> + student.friends(rel_length: {max: 3}) + + # As the :any Symbol: + ## Cypher: -[:`FRIENDS`*]-> + student.friends(rel_length: :any) + + +.. caution:: + By default, "\*..3" is equivalent to "\*1..3" and "\*" is equivalent to "\*1..", but this may change + depending on your Node4j server configuration. Keep that in mind when using variable-length + relationships queries without specifying a minimum value. + + +.. note:: + When using variable-length relationships queries on `has_one` associations, be aware that multiple nodes + could be returned! + + The Query API ------------- @@ -150,7 +209,7 @@ Find or Create By... QueryProxy has a ``find_or_create_by`` method to make the node rel creation process easier. Its usage is simple: .. code-block:: ruby - + a_node.an_association(params_hash) The method has branching logic that attempts to match an existing node and relationship. If the pattern is not found, it tries to find a node of the expected class and create the relationship. If *that* doesn't work, it creates the node, then creates the relationship. The process is wrapped in a transaction to prevent a failure from leaving the database in an inconsistent state. @@ -160,12 +219,12 @@ There are some mild caveats. First, it will not work on associations of class me .. code-block:: ruby student.friends.lessons.find_or_create_by(subject: 'Math') - + Assuming the ``lessons`` association points to a ``Lesson`` model, you would effectively end up with this: .. code-block:: ruby math = Lesson.find_or_create_by(subject: 'Math') student.friends.lessons << math - + ...which is invalid and will result in an error. diff --git a/docs/api/Neo4j.rst b/docs/api/Neo4j.rst index 2e5e6c2aa..7c35583f5 100644 --- a/docs/api/Neo4j.rst +++ b/docs/api/Neo4j.rst @@ -11,20 +11,20 @@ Neo4j :titlesonly: - Neo4j/Config - - Neo4j/Shared - Neo4j/Neo4jrbError Neo4j/RecordNotFound - + Neo4j/Shared - Neo4j/Railtie + Neo4j/Config + + Neo4j/ClassWrapper + Neo4j/Railtie + Neo4j/Paginated Neo4j/Migration @@ -60,18 +60,18 @@ Files - * `lib/neo4j/config.rb:1 `_ + * `lib/neo4j/errors.rb:1 `_ * `lib/neo4j/shared.rb:1 `_ - * `lib/neo4j/errors.rb:1 `_ + * `lib/neo4j/config.rb:1 `_ * `lib/neo4j/version.rb:1 `_ - * `lib/neo4j/railtie.rb:4 `_ - * `lib/neo4j/wrapper.rb:1 `_ + * `lib/neo4j/railtie.rb:4 `_ + * `lib/neo4j/paginated.rb:1 `_ * `lib/neo4j/migration.rb:3 `_ @@ -90,10 +90,10 @@ Files * `lib/neo4j/shared/typecaster.rb:1 `_ - * `lib/neo4j/shared/validations.rb:1 `_ - * `lib/neo4j/active_node/labels.rb:1 `_ + * `lib/neo4j/shared/validations.rb:1 `_ + * `lib/neo4j/active_rel/callbacks.rb:1 `_ * `lib/neo4j/active_node/callbacks.rb:1 `_ @@ -102,10 +102,10 @@ Files * `lib/neo4j/active_rel/validations.rb:1 `_ - * `lib/neo4j/active_node/orm_adapter.rb:3 `_ - * `lib/neo4j/active_node/validations.rb:1 `_ + * `lib/neo4j/active_node/orm_adapter.rb:3 `_ + * `lib/neo4j/active_node/query_methods.rb:1 `_ * `lib/rails/generators/neo4j_generator.rb:5 `_ @@ -120,9 +120,11 @@ Files * `lib/neo4j/active_node/query/query_proxy_enumerable.rb:1 `_ + * `lib/neo4j/active_node/dependent/association_methods.rb:1 `_ + * `lib/neo4j/active_node/dependent/query_proxy_methods.rb:1 `_ - * `lib/neo4j/active_node/dependent/association_methods.rb:1 `_ + * `lib/neo4j/active_node/has_n/association_cypher_methods.rb:1 `_ * `lib/neo4j/active_node/query/query_proxy_find_in_batches.rb:1 `_ diff --git a/docs/api/Neo4j/ActiveNode.rst b/docs/api/Neo4j/ActiveNode.rst index 5f2e4d20d..9663478e7 100644 --- a/docs/api/Neo4j/ActiveNode.rst +++ b/docs/api/Neo4j/ActiveNode.rst @@ -41,18 +41,18 @@ in a new object of that class. ActiveNode/Dependent - ActiveNode/Reflection - ActiveNode/Initialize - ActiveNode/ClassMethods - - ActiveNode/OrmAdapter + ActiveNode/Reflection ActiveNode/IdProperty ActiveNode/Validations + ActiveNode/ClassMethods + + ActiveNode/OrmAdapter + ActiveNode/Persistence ActiveNode/QueryMethods @@ -98,12 +98,12 @@ Files * `lib/neo4j/active_node/reflection.rb:1 `_ - * `lib/neo4j/active_node/orm_adapter.rb:4 `_ - * `lib/neo4j/active_node/id_property.rb:1 `_ * `lib/neo4j/active_node/validations.rb:2 `_ + * `lib/neo4j/active_node/orm_adapter.rb:4 `_ + * `lib/neo4j/active_node/persistence.rb:1 `_ * `lib/neo4j/active_node/query_methods.rb:2 `_ @@ -118,9 +118,11 @@ Files * `lib/neo4j/active_node/query/query_proxy_enumerable.rb:2 `_ + * `lib/neo4j/active_node/dependent/association_methods.rb:2 `_ + * `lib/neo4j/active_node/dependent/query_proxy_methods.rb:2 `_ - * `lib/neo4j/active_node/dependent/association_methods.rb:2 `_ + * `lib/neo4j/active_node/has_n/association_cypher_methods.rb:2 `_ * `lib/neo4j/active_node/query/query_proxy_find_in_batches.rb:2 `_ @@ -258,7 +260,7 @@ Methods def association_proxy(name, options = {}) name = name.to_sym - hash = [name, options.values_at(:node, :rel, :labels)].hash + hash = [name, options.values_at(:node, :rel, :labels, :rel_length)].hash association_proxy_cache_fetch(hash) do if previous_association_proxy = self.instance_variable_get('@association_proxy') result_by_previous_id = previous_association_proxy_results_by_previous_id(previous_association_proxy, name) @@ -978,7 +980,7 @@ Methods .. hidden-code-block:: ruby def valid?(context = nil) - context ||= (new_record? ? :create : :update) + context ||= (new_record? ? :create : :update) super(context) errors.empty? end diff --git a/docs/api/Neo4j/ActiveNode/Dependent.rst b/docs/api/Neo4j/ActiveNode/Dependent.rst index 796b56a5a..4186900e6 100644 --- a/docs/api/Neo4j/ActiveNode/Dependent.rst +++ b/docs/api/Neo4j/ActiveNode/Dependent.rst @@ -15,10 +15,10 @@ Dependent - Dependent/QueryProxyMethods - Dependent/AssociationMethods + Dependent/QueryProxyMethods + @@ -36,10 +36,10 @@ Files * `lib/neo4j/active_node/dependent.rb:3 `_ - * `lib/neo4j/active_node/dependent/query_proxy_methods.rb:3 `_ - * `lib/neo4j/active_node/dependent/association_methods.rb:3 `_ + * `lib/neo4j/active_node/dependent/query_proxy_methods.rb:3 `_ + diff --git a/docs/api/Neo4j/ActiveNode/HasN.rst b/docs/api/Neo4j/ActiveNode/HasN.rst index 9c1851349..9bdc83877 100644 --- a/docs/api/Neo4j/ActiveNode/HasN.rst +++ b/docs/api/Neo4j/ActiveNode/HasN.rst @@ -35,6 +35,8 @@ HasN HasN/Association + HasN/AssociationCypherMethods + @@ -54,6 +56,8 @@ Files * `lib/neo4j/active_node/has_n/association.rb:5 `_ + * `lib/neo4j/active_node/has_n/association_cypher_methods.rb:3 `_ + @@ -72,7 +76,7 @@ Methods def association_proxy(name, options = {}) name = name.to_sym - hash = [name, options.values_at(:node, :rel, :labels)].hash + hash = [name, options.values_at(:node, :rel, :labels, :rel_length)].hash association_proxy_cache_fetch(hash) do if previous_association_proxy = self.instance_variable_get('@association_proxy') result_by_previous_id = previous_association_proxy_results_by_previous_id(previous_association_proxy, name) diff --git a/docs/api/Neo4j/ActiveNode/HasN/Association.rst b/docs/api/Neo4j/ActiveNode/HasN/Association.rst index b7534e64f..2c1c9ba7a 100644 --- a/docs/api/Neo4j/ActiveNode/HasN/Association.rst +++ b/docs/api/Neo4j/ActiveNode/HasN/Association.rst @@ -70,14 +70,6 @@ Association - - - - - - - - @@ -97,6 +89,8 @@ Constants * VALID_ASSOCIATION_OPTION_KEYS + * VALID_REL_LENGTH_SYMBOLS + Files @@ -139,9 +133,14 @@ Methods .. hidden-code-block:: ruby - def arrow_cypher(var = nil, properties = {}, create = false, reverse = false) + def arrow_cypher(var = nil, properties = {}, create = false, reverse = false, length = nil) validate_origin! - direction_cypher(get_relationship_cypher(var, properties, create), create, reverse) + + if create && length.present? + fail(ArgumentError, 'rel_length option cannot be specified when creating a relationship') + end + + direction_cypher(get_relationship_cypher(var, properties, create, length), create, reverse) end @@ -198,6 +197,23 @@ Methods +.. _`Neo4j/ActiveNode/HasN/Association#derive_model_class`: + +**#derive_model_class** + + + .. hidden-code-block:: ruby + + def derive_model_class + return @model_class unless @model_class.nil? + return nil if relationship_class.nil? + dir_class = direction == :in ? :from_class : :to_class + return false if relationship_class.send(dir_class).to_s.to_sym == :any + relationship_class.send(dir_class) + end + + + .. _`Neo4j/ActiveNode/HasN/Association#direction`: **#direction** @@ -252,8 +268,8 @@ Methods .. hidden-code-block:: ruby def inject_classname(properties) - return properties unless @relationship_class - properties[Neo4j::Config.class_name_property] = relationship_class_name if relationship_clazz.cached_class?(true) + return properties unless relationship_class + properties[Neo4j::Config.class_name_property] = relationship_class_name if relationship_class.cached_class?(true) properties end @@ -302,12 +318,12 @@ Methods .. _`Neo4j/ActiveNode/HasN/Association#relationship_class`: **#relationship_class** - Returns the value of attribute relationship_class + .. hidden-code-block:: ruby def relationship_class - @relationship_class + @relationship_class ||= @relationship_class_name && @relationship_class_name.constantize end @@ -315,12 +331,12 @@ Methods .. _`Neo4j/ActiveNode/HasN/Association#relationship_class_name`: **#relationship_class_name** - + Returns the value of attribute relationship_class_name .. hidden-code-block:: ruby def relationship_class_name - @relationship_class_name ||= @relationship_class.respond_to?(:constantize) ? @relationship_class : @relationship_class.name + @relationship_class_name end @@ -333,27 +349,7 @@ Methods .. hidden-code-block:: ruby def relationship_class_type - @relationship_class = @relationship_class.constantize if @relationship_class.class == String || @relationship_class == Symbol - @relationship_class._type.to_sym - end - - - -.. _`Neo4j/ActiveNode/HasN/Association#relationship_clazz`: - -**#relationship_clazz** - - - .. hidden-code-block:: ruby - - def relationship_clazz - @relationship_clazz ||= if @relationship_class.is_a?(String) - @relationship_class.constantize - elsif @relationship_class.is_a?(Symbol) - @relationship_class.to_s.constantize - else - @relationship_class - end + relationship_class._type.to_sym end @@ -367,7 +363,7 @@ Methods def relationship_type(create = false) case - when @relationship_class + when relationship_class relationship_class_type when @relationship_type @relationship_type @@ -405,10 +401,12 @@ Methods .. hidden-code-block:: ruby def target_class_names - @target_class_names ||= if @target_class_option.is_a?(Array) - @target_class_option.map(&:to_s) - elsif @target_class_option - [@target_class_option.to_s] + option = target_class_option(derive_model_class) + + @target_class_names ||= if option.is_a?(Array) + option.map(&:to_s) + elsif option + [option.to_s] end end diff --git a/docs/api/Neo4j/ActiveNode/HasN/AssociationCypherMethods.rst b/docs/api/Neo4j/ActiveNode/HasN/AssociationCypherMethods.rst new file mode 100644 index 000000000..4b9b74524 --- /dev/null +++ b/docs/api/Neo4j/ActiveNode/HasN/AssociationCypherMethods.rst @@ -0,0 +1,88 @@ +AssociationCypherMethods +======================== + + + + + + +.. toctree:: + :maxdepth: 3 + :titlesonly: + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +Constants +--------- + + + + * VALID_REL_LENGTH_SYMBOLS + + + +Files +----- + + + + * `lib/neo4j/active_node/has_n/association_cypher_methods.rb:4 `_ + + + + + +Methods +------- + + + +.. _`Neo4j/ActiveNode/HasN/AssociationCypherMethods#arrow_cypher`: + +**#arrow_cypher** + Return cypher partial query string for the relationship part of a MATCH (arrow / relationship definition) + + .. hidden-code-block:: ruby + + def arrow_cypher(var = nil, properties = {}, create = false, reverse = false, length = nil) + validate_origin! + + if create && length.present? + fail(ArgumentError, 'rel_length option cannot be specified when creating a relationship') + end + + direction_cypher(get_relationship_cypher(var, properties, create, length), create, reverse) + end + + + + + diff --git a/docs/api/Neo4j/ActiveNode/HasN/ClassMethods.rst b/docs/api/Neo4j/ActiveNode/HasN/ClassMethods.rst index c242b3bdb..2f0caabd2 100644 --- a/docs/api/Neo4j/ActiveNode/HasN/ClassMethods.rst +++ b/docs/api/Neo4j/ActiveNode/HasN/ClassMethods.rst @@ -44,6 +44,8 @@ ClassMethods + + diff --git a/docs/api/Neo4j/ActiveNode/Query/QueryProxy.rst b/docs/api/Neo4j/ActiveNode/Query/QueryProxy.rst index 4fe739e15..8bb4e675b 100644 --- a/docs/api/Neo4j/ActiveNode/Query/QueryProxy.rst +++ b/docs/api/Neo4j/ActiveNode/Query/QueryProxy.rst @@ -237,7 +237,7 @@ Methods (arg.is_a?(Integer) || arg.is_a?(String)) ? @model.find_by(@model.id_property_name => arg) : arg end.compact - if @model && other_nodes.any? { |other_node| !other_node.is_a?(@model) } + 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 @@ -598,6 +598,27 @@ Methods +.. _`Neo4j/ActiveNode/Query/QueryProxy#find_or_create_by`: + +**#find_or_create_by** + When called, this method returns a single node that satisfies the match specified in the params hash. + If no existing node is found to satisfy the match, one is created or associated as expected. + + .. hidden-code-block:: ruby + + def find_or_create_by(params) + fail 'Method invalid when called on Class objects' unless source_object + result = self.where(params).first + return result unless result.nil? + Neo4j::Transaction.run do + node = model.find_or_create_by(params) + self << node + return node + end + end + + + .. _`Neo4j/ActiveNode/Query/QueryProxy#first`: **#first** @@ -668,12 +689,6 @@ Methods originated. has_many) that created this object. - * 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 - * source_object: 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. .. hidden-code-block:: ruby @@ -706,8 +721,7 @@ Methods def inspect clear, yellow, cyan = %W(\e[0m \e[33m \e[36m) - #"" - "" + "" end @@ -752,7 +766,7 @@ Methods def limit_value return unless self.query.clause?(:limit) - limit_clause = self.query.send(:clauses).select { |clause| clause.is_a?(Neo4j::Core::QueryClauses::LimitClause) }.first + limit_clause = self.query.send(:clauses).find { |clause| clause.is_a?(Neo4j::Core::QueryClauses::LimitClause) } limit_clause.instance_variable_get(:@arg) end diff --git a/docs/api/Neo4j/ActiveNode/Query/QueryProxyMethods.rst b/docs/api/Neo4j/ActiveNode/Query/QueryProxyMethods.rst index 9d04f022d..2cbf22852 100644 --- a/docs/api/Neo4j/ActiveNode/Query/QueryProxyMethods.rst +++ b/docs/api/Neo4j/ActiveNode/Query/QueryProxyMethods.rst @@ -74,6 +74,8 @@ QueryProxyMethods + + @@ -280,6 +282,27 @@ Methods +.. _`Neo4j/ActiveNode/Query/QueryProxyMethods#find_or_create_by`: + +**#find_or_create_by** + When called, this method returns a single node that satisfies the match specified in the params hash. + If no existing node is found to satisfy the match, one is created or associated as expected. + + .. hidden-code-block:: ruby + + def find_or_create_by(params) + fail 'Method invalid when called on Class objects' unless source_object + result = self.where(params).first + return result unless result.nil? + Neo4j::Transaction.run do + node = model.find_or_create_by(params) + self << node + return node + end + end + + + .. _`Neo4j/ActiveNode/Query/QueryProxyMethods#first`: **#first** @@ -363,7 +386,7 @@ Methods def limit_value return unless self.query.clause?(:limit) - limit_clause = self.query.send(:clauses).select { |clause| clause.is_a?(Neo4j::Core::QueryClauses::LimitClause) }.first + limit_clause = self.query.send(:clauses).find { |clause| clause.is_a?(Neo4j::Core::QueryClauses::LimitClause) } limit_clause.instance_variable_get(:@arg) end diff --git a/docs/api/Neo4j/ActiveNode/Validations.rst b/docs/api/Neo4j/ActiveNode/Validations.rst index 84035b9f3..0cf8f369b 100644 --- a/docs/api/Neo4j/ActiveNode/Validations.rst +++ b/docs/api/Neo4j/ActiveNode/Validations.rst @@ -82,7 +82,7 @@ Methods .. hidden-code-block:: ruby def valid?(context = nil) - context ||= (new_record? ? :create : :update) + context ||= (new_record? ? :create : :update) super(context) errors.empty? end diff --git a/docs/api/Neo4j/ActiveRel/Persistence.rst b/docs/api/Neo4j/ActiveRel/Persistence.rst index 1110a8ca5..8aaf33eb1 100644 --- a/docs/api/Neo4j/ActiveRel/Persistence.rst +++ b/docs/api/Neo4j/ActiveRel/Persistence.rst @@ -39,6 +39,8 @@ Persistence + + diff --git a/docs/api/Neo4j/ActiveRel/Types/ClassMethods.rst b/docs/api/Neo4j/ActiveRel/Types/ClassMethods.rst index 240a2461f..4234d35e7 100644 --- a/docs/api/Neo4j/ActiveRel/Types/ClassMethods.rst +++ b/docs/api/Neo4j/ActiveRel/Types/ClassMethods.rst @@ -54,13 +54,12 @@ Methods .. _`Neo4j/ActiveRel/Types/ClassMethods#_type`: **#_type** - Returns the value of attribute rel_type - attr_reader :rel_type + .. hidden-code-block:: ruby def rel_type - @rel_type + @rel_type || type(namespaced_model_name, true) end @@ -141,12 +140,12 @@ Methods .. _`Neo4j/ActiveRel/Types/ClassMethods#rel_type`: **#rel_type** - Returns the value of attribute rel_type + .. hidden-code-block:: ruby def rel_type - @rel_type + @rel_type || type(namespaced_model_name, true) end @@ -154,13 +153,14 @@ Methods .. _`Neo4j/ActiveRel/Types/ClassMethods#type`: **#type** - + This option is used internally, users will usually ignore it. .. hidden-code-block:: ruby - def type(given_type = namespaced_model_name, auto = false) + def type(given_type = nil, auto = false) + return rel_type if given_type.nil? @rel_type = (auto ? decorated_rel_type(given_type) : given_type).tap do |type| - add_wrapped_class type unless uses_classname? + add_wrapped_class(type) unless uses_classname? end end diff --git a/docs/api/Neo4j/Shared/TypeConverters/JSONConverter.rst b/docs/api/Neo4j/Shared/TypeConverters/JSONConverter.rst index 98811748a..5d356e714 100644 --- a/docs/api/Neo4j/Shared/TypeConverters/JSONConverter.rst +++ b/docs/api/Neo4j/Shared/TypeConverters/JSONConverter.rst @@ -32,7 +32,7 @@ Files - * `lib/neo4j/shared/type_converters.rb:94 `_ + * `lib/neo4j/shared/type_converters.rb:98 `_ diff --git a/docs/api/Neo4j/Shared/TypeConverters/TimeConverter.rst b/docs/api/Neo4j/Shared/TypeConverters/TimeConverter.rst index 90d722d83..8cf38226c 100644 --- a/docs/api/Neo4j/Shared/TypeConverters/TimeConverter.rst +++ b/docs/api/Neo4j/Shared/TypeConverters/TimeConverter.rst @@ -17,6 +17,10 @@ TimeConverter + + + + @@ -43,6 +47,19 @@ Methods +.. _`Neo4j/Shared/TypeConverters/TimeConverter.call`: + +**.call** + + + .. hidden-code-block:: ruby + + def to_ruby(value) + Time.at(value).utc + end + + + .. _`Neo4j/Shared/TypeConverters/TimeConverter.convert_type`: **.convert_type** @@ -56,6 +73,19 @@ Methods +.. _`Neo4j/Shared/TypeConverters/TimeConverter.primitive_type`: + +**.primitive_type** + + + .. hidden-code-block:: ruby + + def primitive_type + Integer + end + + + .. _`Neo4j/Shared/TypeConverters/TimeConverter.to_db`: **.to_db** diff --git a/docs/api/Neo4j/Shared/TypeConverters/YAMLConverter.rst b/docs/api/Neo4j/Shared/TypeConverters/YAMLConverter.rst index 66b01093e..6d64c00d6 100644 --- a/docs/api/Neo4j/Shared/TypeConverters/YAMLConverter.rst +++ b/docs/api/Neo4j/Shared/TypeConverters/YAMLConverter.rst @@ -32,7 +32,7 @@ Files - * `lib/neo4j/shared/type_converters.rb:77 `_ + * `lib/neo4j/shared/type_converters.rb:81 `_ diff --git a/lib/neo4j.rb b/lib/neo4j.rb index c4e6a562c..f1a7eaf89 100644 --- a/lib/neo4j.rb +++ b/lib/neo4j.rb @@ -66,6 +66,7 @@ require 'neo4j/active_node/rels' require 'neo4j/active_node/reflection' require 'neo4j/active_node/has_n' +require 'neo4j/active_node/has_n/association_cypher_methods' require 'neo4j/active_node/has_n/association' require 'neo4j/active_node/query/query_proxy' require 'neo4j/active_node/query' diff --git a/lib/neo4j/active_node/has_n.rb b/lib/neo4j/active_node/has_n.rb index 253fb3e76..35cf4f048 100644 --- a/lib/neo4j/active_node/has_n.rb +++ b/lib/neo4j/active_node/has_n.rb @@ -144,7 +144,7 @@ def association_query_proxy(name, options = {}) def association_proxy(name, options = {}) name = name.to_sym - hash = [name, options.values_at(:node, :rel, :labels)].hash + hash = [name, options.values_at(:node, :rel, :labels, :rel_length)].hash association_proxy_cache_fetch(hash) do if previous_association_proxy = self.instance_variable_get('@association_proxy') result_by_previous_id = previous_association_proxy_results_by_previous_id(previous_association_proxy, name) @@ -312,6 +312,11 @@ def define_has_many_methods(name) define_method(name) do |node = nil, rel = nil, options = {}| return [].freeze unless self._persisted_obj + if node.is_a?(Hash) + options = node + node = nil + end + association_proxy(name, {node: node, rel: rel, source_object: self, labels: options[:labels]}.merge!(options)) end @@ -331,11 +336,7 @@ def define_has_many_setter(name) end def define_has_one_methods(name) - define_method(name) do |node = nil, rel = nil| - return nil unless self._persisted_obj - - association_proxy(name, node: node, rel: rel).first - end + define_has_one_getter(name) define_has_one_setter(name) @@ -344,6 +345,25 @@ def define_has_one_methods(name) end end + def define_has_one_getter(name) + define_method(name) do |node = nil, rel = nil, options = {}| + return nil unless self._persisted_obj + + if node.is_a?(Hash) + options = node + node = nil + end + + # Return all results if a variable-length relationship length was given + results = association_proxy(name, {node: node, rel: rel}.merge!(options)) + if options[:rel_length] && !options[:rel_length].is_a?(Fixnum) + results + else + results.first + end + end + end + def define_has_one_setter(name) define_method("#{name}=") do |other_node| handle_non_persisted_node(other_node) diff --git a/lib/neo4j/active_node/has_n/association.rb b/lib/neo4j/active_node/has_n/association.rb index 4af577fcb..94a433efd 100644 --- a/lib/neo4j/active_node/has_n/association.rb +++ b/lib/neo4j/active_node/has_n/association.rb @@ -6,6 +6,8 @@ module HasN class Association include Neo4j::Shared::RelTypeConverters include Neo4j::ActiveNode::Dependent::AssociationMethods + include Neo4j::ActiveNode::HasN::AssociationCypherMethods + attr_reader :type, :name, :relationship, :direction, :dependent def initialize(type, direction, name, options = {type: nil}) @@ -42,12 +44,6 @@ def target_class_option(model_class) end end - # Return cypher partial query string for the relationship part of a MATCH (arrow / relationship definition) - def arrow_cypher(var = nil, properties = {}, create = false, reverse = false) - validate_origin! - direction_cypher(get_relationship_cypher(var, properties, create), create, reverse) - end - def target_class_names option = target_class_option(derive_model_class) @@ -128,17 +124,6 @@ def association_model_namespace Neo4j::Config.association_model_namespace_string end - def direction_cypher(relationship_cypher, create, reverse = false) - case get_direction(create, reverse) - when :out - "-#{relationship_cypher}->" - when :in - "<-#{relationship_cypher}-" - when :both - "-#{relationship_cypher}-" - end - end - def get_direction(create, reverse = false) dir = (create && @direction == :both) ? :out : @direction if reverse @@ -152,21 +137,6 @@ def get_direction(create, reverse = false) end end - def get_relationship_cypher(var, properties, create) - relationship_type = relationship_type(create) - relationship_name_cypher = ":`#{relationship_type}`" if relationship_type - properties_string = get_properties_string(properties) - - "[#{var}#{relationship_name_cypher}#{properties_string}]" - end - - def get_properties_string(properties) - p = properties.map do |key, value| - "#{key}: #{value.inspect}" - end.join(', ') - p.size == 0 ? '' : " {#{p}}" - end - def origin_association target_class.associations[@origin] end diff --git a/lib/neo4j/active_node/has_n/association_cypher_methods.rb b/lib/neo4j/active_node/has_n/association_cypher_methods.rb new file mode 100644 index 000000000..b42ed2de9 --- /dev/null +++ b/lib/neo4j/active_node/has_n/association_cypher_methods.rb @@ -0,0 +1,108 @@ +module Neo4j + module ActiveNode + module HasN + module AssociationCypherMethods + # Return cypher partial query string for the relationship part of a MATCH (arrow / relationship definition) + def arrow_cypher(var = nil, properties = {}, create = false, reverse = false, length = nil) + validate_origin! + + if create && length.present? + fail(ArgumentError, 'rel_length option cannot be specified when creating a relationship') + end + + direction_cypher(get_relationship_cypher(var, properties, create, length), create, reverse) + end + + private + + def direction_cypher(relationship_cypher, create, reverse = false) + case get_direction(create, reverse) + when :out + "-#{relationship_cypher}->" + when :in + "<-#{relationship_cypher}-" + when :both + "-#{relationship_cypher}-" + end + end + + def get_relationship_cypher(var, properties, create, length) + relationship_type = relationship_type(create) + relationship_name_cypher = ":`#{relationship_type}`" if relationship_type + rel_length_cypher = cypher_for_rel_length(length) + properties_string = get_properties_string(properties) + + "[#{var}#{relationship_name_cypher}#{rel_length_cypher}#{properties_string}]" + end + + def get_properties_string(properties) + p = properties.map do |key, value| + "#{key}: #{value.inspect}" + end.join(', ') + p.size == 0 ? '' : " {#{p}}" + end + + VALID_REL_LENGTH_SYMBOLS = { + any: '*' + } + + def cypher_for_rel_length(length) + return nil if length.blank? + + validate_rel_length!(length) + + case length + when Symbol then VALID_REL_LENGTH_SYMBOLS[length] + when Fixnum then "*#{length}" + when Range then cypher_for_range_rel_length(length) + when Hash then cypher_for_hash_rel_length(length) + end + end + + def cypher_for_range_rel_length(length) + range_end = length.end + range_end = nil if range_end == Float::INFINITY + "*#{length.begin}..#{range_end}" + end + + def cypher_for_hash_rel_length(length) + range_end = length[:max] + range_end = nil if range_end == Float::INFINITY + "*#{length[:min]}..#{range_end}" + end + + def validate_rel_length!(length) + message = rel_length_error_message(length) + fail(ArgumentError, "Invalid value for rel_length (#{length.inspect}): #{message}") if message + true + end + + def rel_length_error_message(length) + case length + when Fixnum then 'cannot be negative' if length < 0 + when Symbol then rel_length_symbol_error_message(length) + when Range then rel_length_range_error_message(length) + when Hash then rel_length_hash_error_message(length) + else 'should be a Symbol, Fixnum, Range or Hash' + end + end + + def rel_length_symbol_error_message(length) + "expecting one of #{VALID_REL_LENGTH_SYMBOLS.keys.inspect}" if !VALID_REL_LENGTH_SYMBOLS.key?(length) + end + + def rel_length_range_error_message(length) + if length.begin > length.end + 'cannot be a decreasing Range' + elsif length.begin < 0 + 'cannot include negative values' + end + end + + def rel_length_hash_error_message(length) + 'Hash keys should be a subset of [:min, :max]' if (length.keys & [:min, :max]) != length.keys + end + end + end + end +end diff --git a/lib/neo4j/active_node/query/query_proxy.rb b/lib/neo4j/active_node/query/query_proxy.rb index 2ea741ca8..b043ba991 100644 --- a/lib/neo4j/active_node/query/query_proxy.rb +++ b/lib/neo4j/active_node/query/query_proxy.rb @@ -25,11 +25,13 @@ class QueryProxy # @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 - # * source_object: The node instance at the start of the QueryProxy chain - # * query_proxy: An existing QueryProxy chain upon which this new object should be built + # @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, Fixnum, Symbol, Hash] :rel_length A Range, a Fixnum, 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 = {}) @@ -278,7 +280,7 @@ def _session end def _association_arrow(properties = {}, create = false) - @association && @association.arrow_cypher(@rel_var, properties, create) + @association && @association.arrow_cypher(@rel_var, properties, create, false, @rel_length) end def _chain_level @@ -312,8 +314,13 @@ def _rel_chain_var private def instance_vars_from_options!(options) - @node_var, @session, @source_object, @starting_query, @optional, @start_object, @query_proxy, @chain_level, @association_labels = - options.values_at(:node, :session, :source_object, :starting_query, :optional, :start_object, :query_proxy, :chain_level, :association_labels) + @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) diff --git a/lib/neo4j/active_node/validations.rb b/lib/neo4j/active_node/validations.rb index 10f5fdd72..dfd659d1c 100644 --- a/lib/neo4j/active_node/validations.rb +++ b/lib/neo4j/active_node/validations.rb @@ -8,7 +8,7 @@ module Validations # @return [Boolean] true if valid def valid?(context = nil) - context ||= (new_record? ? :create : :update) + context ||= (new_record? ? :create : :update) super(context) errors.empty? end diff --git a/spec/e2e/has_many_spec.rb b/spec/e2e/has_many_spec.rb index f3eb1c697..ccefba693 100644 --- a/spec/e2e/has_many_spec.rb +++ b/spec/e2e/has_many_spec.rb @@ -392,6 +392,58 @@ def false_callback(_other) end end + describe 'variable-length relationship query' do + before do + node.knows << friend1 + friend1.knows << friend2 + end + + context 'as Symbol' do + context ':any' do + it 'returns any direct or indirect related node' do + expect(node.knows(:n, :r, rel_length: :any).to_a).to match_array([friend1, friend2]) + end + end + end + + context 'as Fixnum' do + it 'returns related nodes at exactly `length` hops from start node' do + expect(node.knows(:n, :r, rel_length: 1).to_a).to match_array([friend1]) + expect(node.knows(:n, :r, rel_length: 2).to_a).to match_array([friend2]) + end + end + + context 'as Range' do + it 'returns related nodes within given range of hops from start node' do + expect(node.knows(nil, nil, rel_length: (0..3)).to_a).to match_array([node, friend1, friend2]) + expect(node.knows(nil, nil, rel_length: (1..2)).to_a).to match_array([friend1, friend2]) + expect(node.knows(nil, nil, rel_length: (2..5)).to_a).to match_array([friend2]) + end + end + + context 'as Hash' do + it 'returns related nodes within given range specified by :min/:max options' do + expect(node.knows(:n, :r, rel_length: {min: 0, max: 3}).to_a).to match_array([node, friend1, friend2]) + end + + it 'accepts missing :min OR :max as denoting open-ended ranges' do + expect(node.knows(:n, :r, rel_length: {min: 1}).to_a).to match_array([friend1, friend2]) + expect(node.knows(:n, :r, rel_length: {max: 1}).to_a).to match_array([friend1]) + end + end + end + + describe 'association "getter" options' do + before do + node.knows << friend1 + friend1.knows << friend2 + end + + it 'allows passing only a hash of options when naming node/rel is not needed' do + expect(node.knows(rel_length: :any).to_a).to match_array([friend1, friend2]) + end + end + describe 'transactions' do context 'failure' do it 'rolls back <<' do diff --git a/spec/e2e/has_one_spec.rb b/spec/e2e/has_one_spec.rb index 566974a0c..3ff1dbac2 100644 --- a/spec/e2e/has_one_spec.rb +++ b/spec/e2e/has_one_spec.rb @@ -120,6 +120,64 @@ end end + describe 'has_one(:manager).from(:subordinates)' do + before(:each) do + stub_active_node_class('Person') do + has_many :out, :subordinates, type: nil, model_class: self + has_one :in, :manager, model_class: self, origin: :subordinates + end + end + + let(:big_boss) { Person.create } + let(:manager) { Person.create } + let(:employee) { Person.create } + + context 'with variable-length relationships' do + before do + big_boss.subordinates << manager + manager.subordinates << employee + end + + it 'finds the chain of command' do + employee.manager(:p, :r, rel_length: {min: 0}).to_a.should match_array([employee, manager, big_boss]) + end + + it "finds the employee's superiors" do + employee.manager(:p, :r, rel_length: :any).to_a.should match_array([manager, big_boss]) + end + + it 'finds a specific superior in the chain of command' do + employee.manager(:p, :r, rel_length: 1).should eq(manager) + employee.manager(:p, :r, rel_length: 2).should eq(big_boss) + end + + it 'finds parts of the chain of command using a range' do + employee.manager(:p, :r, rel_length: (0..1)).to_a.should match_array([employee, manager]) + end + + it 'finds parts of the chain of command using a hash' do + employee.manager(:p, :r, rel_length: {min: 1, max: 3}).to_a.should match_array([manager, big_boss]) + end + end + end + + describe 'association "getter" options' do + before(:each) do + stub_active_node_class('Person') do + has_many :out, :subordinates, type: nil, model_class: self + has_one :in, :manager, model_class: self, origin: :subordinates + end + end + + let(:manager) { Person.create } + let(:employee) { Person.create } + + it 'allows passing only a hash of options when naming node/rel is not needed' do + manager.subordinates << employee + employee.manager(rel_length: 1).should eq(manager) + end + end + describe 'callbacks' do before(:each) do stub_active_node_class('CallbackUser') do diff --git a/spec/unit/association_spec.rb b/spec/unit/association_spec.rb index e0a18210d..d4605a9e4 100644 --- a/spec/unit/association_spec.rb +++ b/spec/unit/association_spec.rb @@ -54,8 +54,10 @@ class Default let(:var) { nil } let(:properties) { {} } let(:create) { false } + let(:reverse) { false } # TODO: reverse is not tested!? + let(:length) { nil } - subject { association.arrow_cypher(var, properties, create) } + subject { association.arrow_cypher(var, properties, create, reverse, length) } before do class MyRel def self._type @@ -91,7 +93,7 @@ def self._type end end - context 'varable given' do + context 'variable given' do let(:var) { :fooy } it { should == '-[fooy]->' } @@ -126,6 +128,134 @@ def self._type end end end + + context 'relationship length given' do + context 'as Symbol' do + context ':any' do + let(:length) { :any } + + it { should == '-[*]->' } + end + + context 'invalid' do + let(:length) { :none_or_more } + + it 'raises an error' do + expect { subject }.to raise_error ArgumentError, 'Invalid value for rel_length (:none_or_more): expecting one of [:any]' + end + end + end + + context 'as Fixnum' do + context 'positive' do + let(:length) { 42 } + + it { should == '-[*42]->' } + end + + context 'negative' do + let(:length) { -1337 } + + it 'raises an error' do + expect { subject }.to raise_error ArgumentError, 'Invalid value for rel_length (-1337): cannot be negative' + end + end + end + + context 'as Range' do + context 'positive & increasing' do + let(:length) { (2..6) } + + it { should == '-[*2..6]->' } + + context 'with end = Float::INFINITY' do + let(:length) { (2..Float::INFINITY) } + + it { should == '-[*2..]->' } + end + end + + context 'decreasing' do + let(:length) { (6..1) } + + it 'raises an error' do + expect { subject }.to raise_error ArgumentError, 'Invalid value for rel_length (6..1): cannot be a decreasing Range' + end + end + + context 'including negative values' do + let(:length) { (-10..5) } + + it 'raises an error' do + expect { subject }.to raise_error ArgumentError, 'Invalid value for rel_length (-10..5): cannot include negative values' + end + end + end + + context 'as a Hash' do + context 'with :min and :max specified' do + let(:length) { {min: 2, max: 6} } + + it { should == '-[*2..6]->' } + end + + context 'with only :min specified' do + let(:length) { {min: 2} } + + it { should == '-[*2..]->' } + end + + context 'with only :max specified' do + let(:length) { {max: 2} } + + it { should == '-[*..2]->' } + end + + context 'with both :min and :max missing' do + let(:length) { {foo: 2, bar: 3} } + + it 'raises an error' do + expect { subject }.to raise_error ArgumentError, 'Invalid value for rel_length ({:foo=>2, :bar=>3}): Hash keys should be a subset of [:min, :max]' + end + end + end + + context 'as an unsupported type' do + let(:length) { 'any' } + + it 'raises an error' do + expect { subject }.to raise_error ArgumentError, 'Invalid value for rel_length ("any"): should be a Symbol, Fixnum, Range or Hash' + end + end + + context 'with create = true' do + let(:length) { 42 } + let(:create) { true } + + it 'raises an error' do + expect { subject }.to raise_error ArgumentError, 'rel_length option cannot be specified when creating a relationship' + end + end + + context 'with relationship variable given' do + let(:length) { {min: 0} } + let(:var) { :r } + + it { should == '-[r*0..]->' } + + context 'with relationship type given' do + let(:options) { {type: :TYPE} } + + it { should == '-[r:`TYPE`*0..]->' } + + context 'with properties given' do + let(:properties) { {foo: 1, bar: 'test'} } + + it { should == '-[r:`TYPE`*0.. {foo: 1, bar: "test"}]->' } + end + end + end + end end describe '#target_class_names' do