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

Active label API Proposals #1458

Open
wants to merge 3 commits into
base: active_label
Choose a base branch
from
Open
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
297 changes: 241 additions & 56 deletions docs/ActiveLabel.rst
Original file line number Diff line number Diff line change
@@ -1,135 +1,320 @@
ActiveLabel
===========

``ActiveNode`` provides an ability to define inheritance of models which also gives subclasess the labels of their parent models. In Ruby, however, inheritence of classes is not sufficient. Sometimes is makes more sense to be able to build a module which defines behavior (or "concerns") which could be applied to any model. This is what ``ActiveLabel`` provides.

``ActiveLabel`` modules can be defined in two ways:

* Default: Where the module's behavior is always defined on the ``ActiveNode`` model and the model's nodes always have a corresponding label in Neo4j
* Optional: Where the module's behavior is defined on the class only when the model's nodes have a corresponding label in Neo4j
As you build out your application's models, you likely will want to share code between them.
Neo4jrb's ``ActiveNode`` module supports class inheritance, allowing you to create "submodels" which
inherit the methods and labels of their ``ActiveNode`` parents while also adding their own submodel specific
label & methods. This code sharing strategy should be familiar to anyone coming from the ActiveRecord world.

Sometime's however, inheritance is not always appropriate. Sometimes what you want to do is conditionally add
a module of functionality to an ActiveNode model, but only if a specific label is present on the node.
For an example of when this is needed, look at the Neo4j's example movie database (https://neo4j.com/developer/movie-database/).
In this example, a Person node is sometimes an Actor, sometimes a Director, sometimes a User, and sometimes a
combination of Actor, Director, and/or User.

Multiple inheritance such as this is not possible with ``ActiveNode`` (or with ActiveRecord). This
is where ``ActiveLabel`` comes to the rescue! ``ActiveLabel`` allows you to create a Ruby module which is only
applied to an ``ActiveNode`` model when a specific label is attached to an instance of the model. Using our
example movie database from above, you could create an ``ActiveLabel`` module which only adds Actor methods and
properties to an instance of Person if a Person node also has an Actor label. Or only adds InShowbusiness methods
and properties to an instance of Person if a Person node has `either` Actor or Director labels.

``ActiveLabel`` can fully replace ``ActiveNode`` inheritence, but it involves a different way of thinking
then what many ActiveRecord developers might be used to. If you're just starting out with Neo4jrb, you might find
it easiest to stick with the "ActiveRecord" like workflow provided by ``ActiveNode`` and inheritence.
But as you get more comfortable with Neo4j's flexibility and
polymorphism, you'll might find that ``ActiveLabel`` is the better option for many tasks.

.. code-block:: ruby

class Person
include Neo4j::ActiveNode
include Actor
include Director
include User

property :name, type: String

label :HasAddress
label :Destroyable
end

class Organization
class Movie
include Neo4j::ActiveNode

property :title, type: String
property :name, type: String
end

label :HasAddress
label :Destroyable
.. code-block:: ruby

module Actor
include Neo4j::ActiveLabel
include InShowbusiness

has_many :out, :acts_in, type: :ACTS_IN, model_class: :Movie
end

module Director
include Neo4j::ActiveLabel
include InShowbusiness

.. code-block:: ruby
has_many :out, :directed, type: :DIRECTED, model_class: :Movie
end

module InShowbusiness
include Neo4j::ActiveLabel

class Address
property :line1, type: String
property :line2, type: String
property :country, type: String
property :postal_code, type: String
self.associated_labels = [:Actor, :Director]
self.associated_labels_matcher = :any

property :biography
property :lastModified
property :version
end

module HasAddress
module User
include Neo4j::ActiveLabel

included do
has_one :out, :address, type: :HAS_ADDRESS
end
property :login
property :password
property :roles

module InstanceMethods
def distance_from(has_address_object)
address.distance_from(has_address_object.address)
def administrate_stuff
puts "administrate stuff"
end
end
end


Creating
--------

``ActiveLabel`` modules are defined by creating a standard ruby module with ``include Neo4j::ActiveLabel``.
By convention, the ``ActiveLabel`` module will be associated with a label equal to the module name. For example,
in the example above, the ``Actor`` ``ActiveLabel`` module is associated with the ``:Actor`` label. You can
customize the label(s) which an ``ActiveLabel`` module is associated with using ``self.associated_labels =``. You must also
Copy link
Contributor Author

@jorroll jorroll Dec 29, 2017

Choose a reason for hiding this comment

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

This may just come down to personal preference. I originally wrote

in the example above, the Actor ActiveLabel module follows the :Actor label. You can customize the label(s) which an ActiveLabel module follows using self.associated_labels =.

But I found the follows metaphor to be less intuitive for me than the associated metaphor, which resulted in the change to self.associated_labels =.

I don't feel strongly about this though, so if others find follows more intuitive I'm all for changing it back.

include an ``ActiveLabel`` module in an ``ActiveNode`` class if you want the class to respond to the ``ActiveLabel``.

``ActiveLabel`` modules have several parts:

.. code-block:: ruby

module Destroyable
include Neo4j::ActiveLabel
module Actor
include Neo4j::ActiveLabel # adds ActiveLabel functionality to the Actor module

follows_label :Destroyed
# ``ActiveLabel`` modules can have associations and properties just like ``ActiveNode`` classes
property :popularity
has_one :out, :friend, type: :FRIEND, model_class: :Person

included do
property :destroyed_at, type: DateTime
# When a node is retrieved from the database, it is mapped to an ``ActiveNode`` class and a new
# instance of that class is created. We'll call this created object obj A.
# If obj A's class includes this ``ActiveLabel``, and, additionally, obj A has the label associated
# with this ``ActiveLabel``, then this included block will be evaluated
# within the context of obj A.
end

module InstanceMethods
def destroy
destroyed_at = Time.now
# After obj A has been found and initialized, before the included block is evaluated, obj A will
# be extended with these InstanceMethods (e.g. obj.extend(InstanceMethods))

super
def act
puts "I acted!"
end
end

module ClassMethods
def destroyed_recently
all.where("#{identity}.destroyed_at > ?", 1.week.ago)
# Similar to ``ActiveSupport::Concern``, when this ``ActiveLabel`` module is included in an
# ``ActiveNode`` class, the class will be extended with these singleton methods (e.g. Person.extend(ClassMethods))

def actor_popularity_scale
puts "5 stars = excellent. 1 star = poor."
end
end
end

``ActiveLabel`` modules only describe functionality that is tied to a label. Actually adding that label to instances of a class
is a seperate step. If you'd like to add a label to specific instances of a class, you can use standard ``neo4j-core`` methods
``add_label()`` or ``remove_label``. You can also use special helper methods that ``ActiveLabel`` adds to a class when it is
included in a class

Creating
--------
.. code-block:: ruby

# Initializes a Person with additional Actor label
Person.actor.new

# Creates a Person with additional Actor label
Person.actor.create

# Creates a Person with additional Actor AND Director labels
Person.actor.director.create
Copy link
Contributor Author

Choose a reason for hiding this comment

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

I find this api to be more expressive (and concise) than

person = Person.create
person.add_label(:Actor)

The above is also two DB queries, where (Person.actor.create) could be one DB query.

Copy link
Contributor Author

@jorroll jorroll Dec 29, 2017

Choose a reason for hiding this comment

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

This being said, I'm wondering what happens given the following

module Hollywood
  include Neo4j::ActiveLabel

  self.associated_labels = [:Actor, :Director]
  self.associated_labels_matcher = :any
end

class Person
  include Neo4j::ActiveNode
  include Hollywood
end

person = Person.hollywood.create

What labels does person have? It could be that we, optionally, let the dev define their own label helper methods. Something like

module Hollywood
  include Neo4j::ActiveLabel

  self.associated_labels = [:Actor, :Director]
  self.associated_labels_matcher = :any

  class_label_maker :actor, :Actor
  class_label_maker :director, :Director
end

actor = Person.actor.create
director = Person.director.create

Below, I added a comment suggesting that self.associated_labels accept a two dimensional array instead of self.associated_labels_matcher. What would happen if
self.associated_labels = [[:Actor, :Director], :Animal]?

I'm thinking that, if self.associated_labels = [], then devs must define their own class label makers. Or maybe we could simply always make devs define their own class label makers, and make it an ActiveNode method.

module InShowbusiness
  include Neo4j::ActiveLabel

  self.associated_labels = [[:Actor, :Director], :Animal]

  class_label_maker :hollywood, [:Actor, :Director]
  class_label_maker :actor, :Actor
  class_label_maker :director, :Director
  class_label_maker :animal, :Animal
end

Copy link
Member

Choose a reason for hiding this comment

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

@thefliik again you are bringing up something I had on my mind for a very long time. My idea was to do all this natively with ruby without any changes to the DSL or the api methods.
If each Label maps to an ActiveLabel module to create a node with a set of labels you would construct an object of a class which includes all the ActiveLabels you are interested in. When you read a node from the database and the node's combination of Labels does not correspond to any class you would return an object of an anonymous class which has all the corresponding ActiveLabels included. This feels very natural and intuitive and completely in harmony with the ruby language and how neo4j labels work.

Copy link
Contributor Author

@jorroll jorroll Feb 17, 2018

Choose a reason for hiding this comment

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

@klobuczek Interesting. I think it's important for me to say that this proposal only adds to the existing API, and, even if this were implemented, anyone could continue to use the neo4j gem exactly as they have been without changes.

That being said, your suggestion sounds pretty similar to mine, except that you are getting rid of individual ActiveNode classes and simply using one, anonymous class. Are you suggesting this because you think it would be more flexible?

One downside of your approach, is that (and correct me if I'm wrong), but in your approach a specific label (e.g. :Person) could only be association with one ActiveLabel module, as oppose to my API in which the :Person label could have totally different meanings depending on the ActiveNode class of the object. It also seems like my approach would be easier to integrate into existing applications (simply mixin an ActiveLabel module to your existing ActiveNode class). I could also envision many devs, coming from an ActiveRecord background, ignoring the ActiveLabel functionality in the beginning and building out their app just using ActiveNode (ActiveNode is much more similar, conceptually, to ActiveRecord, after all). At some point in the future when they got more comfortable with Neo4j's polymorphism, they could start mixing ActiveLabel modules into their classes.

I guess what I'm trying to say, is that both strategies are basically the same: neo4jrb gets a node from the database and then wraps that node in a class (either an ActiveNode class or anonymous ActiveNode class) based on the labels the node has. Then, based on which of those labels are determined to be ActiveLabels, the object gets extended with one or more modules. Why remove standard ActiveNode classes from this process?

Copy link
Member

@klobuczek klobuczek Feb 20, 2018

Choose a reason for hiding this comment

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

@thefliik Please note that ActiveNode is not a class but a module, so this is already different from active record, but does offer a huge advantage. What I am suggesting is to not be required to define classes including ActiveNode with properties and associations (which would be standard way like today), but allow defining just modules (including ActiveNode or ActiveLabel or similar,) which you can combine to classes, but which also could live alone if an unknown combination of labels is retrieved from the database. This is the only case where I would return anonymous class. This would work nicely for creation and retrieval. I have not thought through the promotion (adding a label to existing node) and demotion (removing a label), which does not seem to have a native counterpart in ruby.


If you'd like to `always` add one or more additional labels to instances of a class, you can use the ``ActiveNode`` ``label`` method

If an ``ActiveLabel`` does not declare ``follows_label``, creating a node will attach the corresponding label. Otherwise you must trigger the attachment of the label:
.. code-block:: ruby

class Person
include Neo4j::ActiveNode
include Actor
include Director
include User

# ``label :Actor, optional: true`` automatically adds the label ``:Actor``
# to every instance of the Person class. The :Actor label is technically
# optional, even though it is always added, because a node will still be mapped
# to the Person class even if you manually remove the :Actor label from it.
label :Actor, optional: true

# If you call the ``label`` method without the ``optional: true`` argument,
# then nodes will only be mapped to the Person class if the label is
# also present on the node. (i.e. removing the :User label from a node will
# mean that that node is no longer considered a Person)
label :User
Copy link
Contributor Author

@jorroll jorroll Dec 29, 2017

Choose a reason for hiding this comment

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

Note, if the Person class has label :User (or label :User, optional: true), then there would be no difference between Person.create and Person.user.create. This should probably be explicitly mentioned.

However there WOULD be a difference between label :User and label :User, optional: true when calling Person.all vs Person.user.all. If label :User, optional: true, then Person.all would match on :Person while Person.user.all would match on :Person:User. If label :User then both Person.all and Person.user.all would match on :Person:User.

This also makes me see the implementation difficulties of a Hollywood ActiveLabel module with self.associated_labels = []. If self.associated_labels = [:Actor, :Director] and self.associated_labels_matcher = :all, then Person.hollywood.all would match on :Person:Actor:Director. But if self.associated_labels_matcher = :any then Person.hollywood.all would match on ~

match("#{identity}:Person").where("#{identity}:Actor OR #{identity}:Director")

end

To dry up your code, you can include ``ActiveLabel B`` inside ``ActiveLabel A``. This ensures that when you include
``ActiveLabel A`` in a module you also always include ``Activelabel B``

.. code-block:: ruby

# Node gets both `Person` and `HasAddress` labels
person = Person.create
module Hollywood
include Neo4j::ActiveLabel

self.associated_labels = [:Actor, :Director]
self.associated_labels_matcher = :any

property :name
end

module Actor
include Neo4j::ActiveLabel
include Hollywood
end

module Director
include Neo4j::ActiveLabel
include Hollywood
end

# `Destroyed' label is added. `mark_destroyed` method is automatically defined via `follows_label` definition
person.label_as_destroyed
Helper methods
~~~~~~~~~~~~~~

# `Destroyed' label is removed
person.label_as_not_destroyed
Including an ``ActiveLabel`` module in a class will `automatically` add a few helper methods to the class and class instances.
For example, using the ``Actor`` ``ActiveLabel`` module:

1. You can call ``person.actor?`` which will return true if the obj has the label associated with the ``Actor`` ``ActiveLabel``.
2. You can call ``Person.actor.new`` or ``Person.actor.create`` to initialize / create a new ``Person`` instance with the additional ``Actor`` label.
3. You can call ``Person.actor.all`` or ``Person.actor.first`` to return all ``Person`` nodes with the ``Actor`` label. In fact, calling ``Person.actor`` simply adds a label scope, which can be combined with any custom scopes you have (e.g. ``Person.most_popular`` -> ``Person.actor.most_popular``)

Querying
--------

``ActiveLabel`` allows your Ruby module to act like a model class. However, since you can add a label to any module, you can query for nodes across modules:
Querying for ``ActiveLabel``s is easy, and can allow you to query across classes.

.. code-block:: ruby

Destroyable.all

HasAddress.as(:obj).address.where(postal_code: '12345').pluck('DISTINCT obj')
# This returns all nodes which have the Actor label
Actor.all

By default this returns all nodes for all models where the ``ActiveLabel`` module is defined. If ``follows_label`` is declared, this returns just those nodes which have the label.
# This returns all nodes with the Director label which have a directed association to
# a node with the title "Star Wars"
# This works because the ``Director`` ``ActiveLabel`` defines a ``directed`` association
Director.as(:dir).directed.where(title: 'Star Wars').pluck('DISTINCT dir')

By defining the ``follows_label``, some methods are automatically provided to allow you to filter and interrogate:
Including an ``ActiveLabel`` module in a class will `automatically` add a few helper methods to the class and class instances.

.. code-block:: ruby

Person.labeled_as_destroyed
Person.actor.all

Person.actor.first

Person.first.labeled_as_destroyed?
Calling ``Person.actor`` simply adds a label scope, which can be combined with any custom scopes you have (e.g.
``Person.most_popular`` -> ``Person.actor.most_popular``

Associations
~~~~~~~~~~~~

You can even create associations to traverse to labels:
You can create associations with ActiveLabels:

.. code-block:: ruby

class Organization
class Movie
include Neo4j::ActiveNode

has_many :out, :addressables, type: :HAS_ADDRESSABLE_OBJECT, label_class: :HasAddress
has_many :in, :actors, type: :ACTS_IN, label_module: :Actor

# `model_class` acts as a filter to the `label_class` argument. Both `model_class` and `label_class` can be arrays
has_many :out, :addressable_people, type: :HAS_ADDRESSABLE_OBJECT, label_class: :HasAddress, model_class: :Person
# `label_module` acts as a filter to the `model_class` argument.
# Both `model_class` and `label_module` can be arrays
has_many :in, :human_actors, type: :ACTS_IN, label_module: :Actor, model_class: :Person
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Where you had "model_class" acts as a filter... I changed it to "label_module" acts as a filter because the class is what is instantiated and so seems like the more "primary" descriptor (in my mind). Put another way, ActiveLabel is marking up ActiveNode. Also I changed from "label_class" to "label_module", because the labels are modules rather than classes.

end

If you want more control over your association, you can use the ``node_labels:`` option instead

.. code-block:: ruby

class Movie
include Neo4j::ActiveNode

# The node_labels option accepts a two dimentional array. Each array in the node_labels
# array includes a set of labels that the association will match against. In the example
# below, the ``actors`` association only includes nodes which have either ``:Actor:Person``
# OR ``:Actor:Animal`` labels and have an ``<-[:ACTS_IN]-`` relation to a ``Movie`` node
has_many :in, :actors, type: :ACTS_IN, node_labels: [[:Actor, :Person], [:Actor, :Animal]]

# Other valid params for the node_labels option are
has_many :in, :actors, type: :ACTS_IN, node_labels: [[:Actor, :Person], :Actor]

# or
has_many :in, :actors, type: :ACTS_IN, node_labels: :Actor
end

Note, while the ``label_module`` option requires its params to resolve to ``ActiveLabel`` modules, the ``node_labels``
option doesn't. The ``node_labels`` option simply matches against the specified labels.

Multiple Conditions
-------------------

Sometimes you may wish for ``ActiveLabel`` code to be associated with an array of labels, rather than a single label.
Perhaps the code triggers if `any` label in the array is present, or perhaps it only triggers if `all` labels in the
array are present.

.. code-block:: ruby

module Hollywood
include Neo4j::ActiveLabel

self.associated_labels = [:Actor, :Director]
self.associated_labels_matcher = :any

# OR

self.associated_labels = [:Actor, :Director]
self.associated_labels_matcher = :all

end

By default, ``self.associated_labels_matcher == :any``
Copy link
Contributor Author

@jorroll jorroll Dec 29, 2017

Choose a reason for hiding this comment

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

Including one ActiveLabel module in other ActiveLabel modules would be the primary way of sharing code between ActiveLabel modules rather than using a concern. To make this work, a dev would specify that the ActiveLabel module containing the shared code had multiple associated labels and would trigger if any of those labels were present. I realized that sharing code using a concern wouldn't work, because there isn't a good (any?) way of specifying that the concern's code should only apply to certain instances of a class (you can't do obj.include(TheConcern)).

Also, if you had an ActiveLabel module ("SharedModule") that was sharing code between multiple other ActiveLabel modules, and that shared module had an association ("has_many :out, :cars"), doing something like SharedModule.all.cars will work properly with the above code sharing strategy.


included_if block
~~~~~~~~~~~~~~~~~

Sometimes conditional functionality is limited to one class, and is simple enough that a full ``ActiveLabel`` module seems like
overkill. You can make use of ``ActiveNode``'s ``included_if_any`` and ``included_if_all`` methods to specify blocks of code that only
run if `any` or `all` of the specified labels are present on a node (note, these methods resolve their params to labels,
rather than ``ActiveLabel`` modules. This means that you can match against an optional label which does not have an associated ``ActiveLabel``
module).

.. code-block:: ruby

class Person
include Neo4j::ActiveLabel

# only run if a Person node also has the Actor or Director labels
included_if_any :Actor, :Director do
property :medium_ego
end

# only run if a Person node also has the Actor AND Director labels
included_if_all :Actor, :Director do
property :large_ego
end
Copy link
Contributor Author

Choose a reason for hiding this comment

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

I'm thinking that maybe this should be refactored as just included_if() do. If an argument is an array, then each label in the array must be present. So the above examples could be refactored as:

included_if :Actor, :Director do
  property :medium_ego
end

included_if [:Actor, :Director] do
  property :large_ego
end

This format would also be more powerful and allow

included_if [:Actor, Director], :Animal do
  property :name
end

Copy link
Contributor Author

Choose a reason for hiding this comment

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

For that matter, the self.associated_labels = option could similarly be changed to accept a 2 dimensional array and eliminate self.associated_labels_matcher =

Copy link
Contributor Author

@jorroll jorroll Dec 29, 2017

Choose a reason for hiding this comment

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

Another issue with any potential included_if() do block: I'm not sure how the following should be handled

class Person
  include Neo4j::ActiveNode
  scope :actors, ->{ where("#{identity}:Actor") }

  included_if :Actor do
    has_many :out, :acts_in, type: :ACTS_IN, model_class: :Movie
  end
end

In the above, devs could

movie = Movie.create
actor = Person.create
actor.add_label :Actor

actor.acts_in << movie

But I could see a dev also wanting to do something like Person.actors.acts_in, which they couldn't do because the has_many :out, :actors block would never be evaluated on Person (only on instances of person).

Maybe this is fine, and would simply be a known limitation of included_if() blocks. Devs could still use the blocks to add properties, define methods, or call custom methods. Or maybe there's another option.

I definitely view anything like included_if() as a "nice to have" feature. Maybe it would be more trouble than its worth.

Another note: ActiveLabel will, I imagine, make devs want the ability to filter an association proxy by label. Something like Person.all.has_label(:Actor).acts_in.

If someone was using an ActiveLabel module they should be able to Person.actor.acts_in.

end