active_record.cr icon indicating copy to clipboard operation
active_record.cr copied to clipboard

Support Join

Open sdogruyol opened this issue 10 years ago • 18 comments

Seems like currently we can't do a join operation.

@waterlink Will you support it in the upcoming version?

sdogruyol avatar Nov 03 '15 07:11 sdogruyol

Yeah currently functionality is very basic. If you want you can try to create a PR for that. I can guide you through current codebase if you want to do that. Otherwise it is on my to-do list 😀

waterlink avatar Nov 03 '15 11:11 waterlink

I'd really like to try that out :+1:

sdogruyol avatar Nov 04 '15 06:11 sdogruyol

@waterlink how shall i start with this :smile:

sdogruyol avatar Nov 04 '15 13:11 sdogruyol

First we need to decide on the interface. Which args should join receive? What will it return?

waterlink avatar Nov 04 '15 13:11 waterlink

Well i really like AR and would really like the have the same interface as possible :+1:

sdogruyol avatar Nov 04 '15 14:11 sdogruyol

So I imagine something along these lines:

class Model
  # args map directly to 'JOIN #{table_name} ON #{on_query}'
  def join(model_klass, on_query : Hash|Query) : JoinedModels
end

And the usage example is:

Post.join(User, {"posts.author_id" => "users.id"})
# or equivalent
Post.join(User, criteria("posts.author_id") == criteria("users.id"))
    # => JoinedModels < Post, User, ... >

Looks like JoinedModels should have the same querying methods as Model, like:

  • where
  • all

I am not sure what should they return. Array(Post) ? In that case there is no access to fields of other table..

waterlink avatar Nov 04 '15 15:11 waterlink

Maybe every model could have something like this:

posts = Post.join(User, criteria("posts.author_id") == criteria("users.id")).all

post = posts.first
post.title     # => "Hello world post"

user = User.from_joined(post)
user.name     # => "Oleksii"

waterlink avatar Nov 04 '15 15:11 waterlink

Other way to do it is to define all such possible relationships beforehand (aka belongs_to and has_one and has_many):

class Post < ActiveRecord::Model
  belongs_to User
end

posts = Post.join(User, criteria("posts.author_id") == criteria("users.id")).all
post.first.title           # => "Hello world post"
post.first.user.name      # => "Oleksii"

waterlink avatar Nov 04 '15 15:11 waterlink

Though the problem awaits here:

class Post < ActiveRecord::Model
  # at this point you get compile error, since `User` is not defined yet
  belongs_to User
end

class User < ActiveRecord::Model
  has_many Post
end

You get compile error, because User is not defined yet.

waterlink avatar Nov 04 '15 15:11 waterlink

Since it is macro, we can fix it by not using User right away, but rather define new method inside of macro:

macro belongs_to(model)
  {% model_name = model.id.stringify.gsub(/.+:/, "").downcase %}
  def {{model_name.id}}
    {{model.id}}.from_joined(self)
  end
end

waterlink avatar Nov 04 '15 15:11 waterlink

Maybe belongs_to (and friends) could provide optional parameter for name of the method to help user avoid conflicts, like:

macro belongs_to(model, method_name = nil)
  {% unless method_name %}
    {% method_name = model.id.stringify.gsub(/.+:/, "").downcase %}
  {% end %}

  def {{method_name.id}}
    {{model.id}}.from_joined(self)
  end
end

waterlink avatar Nov 04 '15 15:11 waterlink

WDYT @sdogruyol ?

waterlink avatar Nov 04 '15 15:11 waterlink

@waterlink I like this and your way of handling compiler error :+1:

class Post < ActiveRecord::Model
  belongs_to User
end

posts = Post.join(User, criteria("posts.author_id") == criteria("users.id")).all
post.first.title           # => "Hello world post"
post.first.user.name      # => "Oleksii"

sdogruyol avatar Nov 04 '15 17:11 sdogruyol

Ok then. There is a plan for public interface now :+1:

Now on the internals. Currently all the heavy lifting (actual querying) is done by Adapter protocol implementations.

You can see this protocol here: https://github.com/waterlink/active_record.cr/blob/master/src/adapter.cr#L19-L30

Currently, as you can see, adapter is supposed to have knowledge only about one table.

I was thinking about extending it like this:

abstract def self.build(table_name, join_specs : Array(JoinSpec), primary_field, fields, register = true)

where JoinSpec is:

struct JoinSpec
  property table_name :: String
  property on_query :: Query
  def initialize(@table_name, @on_query) end
end

By default, when you are not using join, join_specs is empty [] of JoinSpec.

Adapters, accordingly will add relevant query to make a join, or blow up with an error if it is not supported (some databases don't have concept of join).

waterlink avatar Nov 04 '15 19:11 waterlink

@waterlink Would you mind starting this and let me join on the way :smile:

sdogruyol avatar Nov 08 '15 18:11 sdogruyol

No problem :)

waterlink avatar Nov 08 '15 18:11 waterlink

Hi! Any idea when you'll be able to implement joins? Really would love to port a rb project to cr, but without joins, it will be nearly impossible.

greenbigfrog avatar Feb 06 '17 14:02 greenbigfrog

https://twitter.com/waterlink000/status/831403009724788736

greenbigfrog avatar Feb 15 '17 13:02 greenbigfrog