Skip to content

Instantly share code, notes, and snippets.

Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 1 You must be signed in to fork a gist
  • Save cheerfulstoic/3098872af7db8632c792 to your computer and use it in GitHub Desktop.
Save cheerfulstoic/3098872af7db8632c792 to your computer and use it in GitHub Desktop.

Open question for the Neo4j.rb community:

ActiveNode models allow for query chaining like so:

object.foo.bar.baz

This is a powerful way to traverse entities which builds Cypher queries under the covers. It is also currently possible to name your variables for later use like so:

object.foo(:f).bar.baz(:b).pluck(:f, 'collect(b)')

This helps you dive a bit deeper without needing to write Cypher. In fact with this syntax you can also specify a relationship variable and some options:

object.foo(:f, :r).bar(nil, nil, labels: false).baz(:b).pluck(:f, 'collect(b)')

While this is great, giving arguments to associations can often confuse people. I've been playing around with a syntax like the following, which would be equivelent to the above:

object.foo.as(:f, :r).bar.with(labels: false).baz.as(:b).pluck(:f, 'collect(b)')

If we adopt a syntax like this, it could help us simplify a chunk of our code. Obviously it's a bit more typing, but it also has the advantage of being a bit more descriptive. It also shouldn't be too hard (hopefully) to convert an existing project.

One issue that I've noted is that when you have a has_one association, there is one case where this doesn't work automatically:

object.foo.as(:f)

If foo is a has_one association it will return an object instead of returning a proxy, meaning that we can't chain the as. In cases where we're accessing the has_one association further on it the chain, there's no problem because we're expecting that there could be more than one object. An example:

object.has_many_association.foo.as(:f)

To solve this, I've thought of a couple of syntaxes:

object.foo_proxy

object.proxy_for(:foo)

The first one matches the ActiveRecord style of having some methods defined based on the association's name. The second is probably more descriptive, but is more verbose. Maybe that's fine if this is a rare case.

There's also another case where htis syntax is perhaps more awkward:

# With current syntax:
employee.manager(rel_length: 1)

# With new syntax:
employee.manager_proxy.with(rel_length: 1).first

Obviously the new syntax would be longer in this case, but perhaps it's more clear that you're working with a proxy when you explicitly call out for a proxy.

What do you think?

@andrewhavens
Copy link

I think your improved syntax is a nice improvement! I was definitely tripped up when I first saw association(:n, :r), but eventually I got used to it. I like association.as(:n, :r) because it reads more clearly to me.

I understand your concern about being able to call object.foo.as(:f) but I don't see this as a real use case. You would never need to name the node that is being returned like this. I can't think of any example where you wouldn't want to return the node as a model object at the end (with a has_one). In the case where the has_one is chained onto a has_many, then you are working with some sort of association/query proxy thing, right? Rather than a model that has the has_one. So you should still be able to do something like student.teachers.class.as(:c).where('c.foo = ?', bar).first

Same thing with employee.manager(rel_length: 1). If it's a has_one, why would you specify a rel_length?

@cheerfulstoic
Copy link
Author

I think you're mostly right that generally with the has_one you're returning an object, but you actually might want to chain based off of that object and not have it first make a query to get the has_one association object. Like:

# `best_friend` is `has_one`, `friends` is `has_many`
person.best_friend.friends

To chain that, you could do:

# old syntax
person.association_proxy(:best_friend).friends
# proposed syntax
person.best_friend_proxy.friends

Though association_proxy hasn't been explicitly set out as part of the public API, but maybe it should be.

As far as the has one with rel_length, I what if you wanted to find the best friend of your best friend's best friend?

# old syntax
person.best_friend(nil, nil, rel_length: 3)
# proposed syntax
person.best_friend_proxy.with(rel_length: 3)

@andrewhavens
Copy link

I guess I didn't realize you could write a query which finds person.best_friend.friends, but it makes sense that you would want to. So it sounds like you're saying you need to return a has_one association object no matter what because you might want to chain additional associations on it. In this case, how do you determine that you're ready to fetch the object? I would say only when you try to call a method on it that is not part of the chaining syntax.

In the second example, it's not clear to me that those methods of chaining would return what you expect. they are still not clear to me. best_friend_proxy could work, but it would take some getting used to. I guess something like this would be a little more clear to me:

person.best_friend.traverse(depth: 3)

or even this because it's so explicit, even though it's not as concise:

person.best_friend.best_friend.best_friend

@subvertallchris
Copy link

I'm a little attached to the existing way of doing things but I think that's only because I know how it works. It seems like the vast majority of new users have trouble making sense of the syntax and, as Eli pointed out in the Slack chat, it's better than having to do association(nil, nil, options).

My one real concern is that with will be confusing due to Cypher's WITH, but it makes sense in the context of the proposal. I don't have a good alternative so I won't really harp on it but thought I'd throw it out there.

@cheerfulstoic
Copy link
Author

Regarding person.best_friend.traverse(depth: 3) and person.best_friend.best_friend.best_friend, I don't think that there's a good way to do that because by default the association returns an object, not a proxy. So you can't chain other methods onto it.

I can see the traverse(depth: 3) being a good thing (or even traverse(3) and traverse(3..5)). It could be a shortcut to with(rel_length: 3..5). I was thinking that it might make sense to have a shortcut to with(optional: true) as optional too.

I agree about the potential confusion to with. I haven't thought of anything better yet either (though maybe options, it's just not sentency like as is)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment