2014-07-23 19 views
15

Istnieją sytuacje, w których ActiveRecord ustawia nazwę tabeli aliasów, jeśli istnieje wiele połączeń z tą samą tabelą. Utknąłem w sytuacji, w której te połączenia zawierają zakresy (używając "scalania").Dołącz do tej samej tabeli dwa razy z warunkami

Mam wiele do wielu relacji:

modele nazwa_tabeli: users

drugie modele nazwa_tabeli: posts

Dołącz nazwa tabeli: access_levels

posta ma wielu użytkowników przez access_levels i na odwrót.

Zarówno model użytkownika i model post akcja taka sama relacja:

has_many :access_levels, -> { merge(AccessLevel.valid) }

Zakres wewnątrz modelu AccessLevel wygląda następująco:

# v1 
    scope :valid, -> { 
    where("(valid_from IS NULL OR valid_from < :now) AND (valid_until IS NULL OR valid_until > :now)", :now => Time.zone.now) 
    } 

    # v2 
    # scope :valid, -> { 
    # where("(#{table_name}.valid_from IS NULL OR #{table_name}.valid_from < :now) AND (#{table_name}.valid_until IS NULL OR #{table_name}.valid_until > :now)", :now => Time.zone.now) 
    # } 

Chciałbym zadzwonić coś takiego:

Post.joins(:access_levels).joins(:users).where (...) 

Aktywny Record tworzy alias dla drugiego sprzężenia ("access_levels_users"). Chcę odwołać się do tej nazwy tabeli wewnątrz "poprawnego" zakresu modelu AccessLevel.

V1 oczywiście generuje PG::AmbiguousColumn -Error. V2 powoduje, że przedrostek obu stanów jest z access_levels., co jest semantycznie błędne.

ten sposób wygenerować zapytania: (uproszczony)

# inside of a policy 
scope = Post. 
    joins(:access_levels). 
    where("access_levels.level" => 1, "access_levels.user_id" => current_user.id) 

# inside of my controller 
scope.joins(:users).select([ 
     Post.arel_table[Arel.star], 
     "hstore(array_agg(users.id::text), array_agg(users.email::text)) user_names" 
     ]).distinct.group("posts.id") 

Wygenerowany zapytanie wygląda następująco (przy użyciu valid zakresu v2 od góry):

SELECT "posts".*, hstore(array_agg(users.id::text), array_agg(users.email::text)) user_names 

    FROM "posts" 
    INNER JOIN "access_levels" ON "access_levels"."post_id" = "posts"."id" AND (("access_levels"."valid_from" IS NULL OR "access_levels"."valid_from" < '2014-07-24 05:38:09.274104') AND ("access_levels"."valid_until" IS NULL OR "access_levels"."valid_until" > '2014-07-24 05:38:09.274132')) 
    INNER JOIN "users" ON "users"."id" = "access_levels"."user_id" 
    INNER JOIN "access_levels" "access_levels_posts" ON "access_levels_posts"."post_id" = "posts"."id" AND (("access_levels"."valid_from" IS NULL OR "access_levels"."valid_from" < '2014-07-24 05:38:09.274675') AND ("access_levels"."valid_until" IS NULL OR "access_levels"."valid_until" > '2014-07-24 05:38:09.274688')) 

    WHERE "posts"."deleted_at" IS NULL AND "access_levels"."level" = 4 AND "access_levels"."user_id" = 1 GROUP BY posts.id 

ActiveRecord ustawia alias propriate "access_levels_posts" dla drugiego sprzężenia tabeli access_levels. Problem polega na tym, że scalony valid -scope prefiksuje kolumnę z "access_levels" zamiast "access_levels_posts". Próbowałem również użyć języka isl do wygenerowania zakresu:

# v3 
scope :valid, -> { 
    where arel_table[:valid_from].eq(nil).or(arel_table[:valid_from].lt(Time.zone.now)).and(
    arel_table[:valid_until].eq(nil).or(arel_table[:valid_until].gt(Time.zone.now)) 
) 
} 

Wynikowe zapytanie pozostaje takie samo.

+0

Twoje pytanie jest trochę mylące, ale myślę, że wiem, co chcesz zrobić. zmień "poprawny" zakres na 'join (: user) .where (" (valid_from IS NULL OR valid_from <: now) ORAZ (valid_until IS NULL OR valid_until>: now) ", teraz: Time.zone.now) .where (users: {active: true, lub: something}) ' – jvnill

Odpowiedz

2

W międzyczasie udało mi się rozwiązać mój własny problem. Opublikuję moje rozwiązanie, aby pomóc innym, którzy mają podobne problemy.

Preambuła: To długa droga do ziemi obietnicy;)

będę konfigurację jak najkrótsze:

# 
# Setup 
# 
class Post < ActiveRecord::Base 
    has_many :access_levels, -> { merge(AccessLevel.valid) } 
    has_many :users, :through => :access_levels 
end 

class AccessLevel < ActiveRecord::Base 
    belongs_to :post 
    belongs_to :user 

    scope :valid, -> { 
    where arel_table[:valid_from].eq(nil).or(arel_table[:valid_from].lt(Time.zone.now)).and(
     arel_table[:valid_until].eq(nil).or(arel_table[:valid_until].gt(Time.zone.now)) 
    ) 
    } 

    enum :level => [:publisher, :subscriber] 
end 

class User < ActiveRecord::Base 
    has_many :access_levels, -> { merge(AccessLevel.valid) } 
    has_many :users, :through => :access_levels 
end 

Oryginalny celem było nazwać coś takiego (w kolejności dodać dalsze warunki itp.):

Post.joins(:users).joins(:access_levels) 

Wynika w złym semantycznie zapytania:

SELECT "posts".* FROM "posts" 
    INNER JOIN "access_levels" 
    ON "access_levels"."post_id" = "posts"."id" 
     AND (("access_levels"."valid_from" IS NULL OR "access_levels"."valid_from" < '2014-09-15 20:42:46.835548') 
     AND ("access_levels"."valid_until" IS NULL OR "access_levels"."valid_until" > '2014-09-15 20:42:46.835688')) 

    INNER JOIN "users" 
    ON "users"."id" = "access_levels"."user_id" 

    INNER JOIN "access_levels" "access_levels_posts" 
    ON "access_levels_posts"."post_id" = "posts"."id" 
     AND (("access_levels"."valid_from" IS NULL OR "access_levels"."valid_from" < '2014-09-15 20:42:46.836090') 
     AND ("access_levels"."valid_until" IS NULL OR "access_levels"."valid_until" > '2014-09-15 20:42:46.836163')) 

Drugi dołączyć korzysta z aliasu - ale warunek nie używa tego aliasu.

Arel na ratunek!

Zbudowałem wszystkie poniższe sprzężenia z gołym arelem, zamiast ufać ActiveRecord. Niestety wydaje się, że połączenie obu nie zawsze działa zgodnie z oczekiwaniami. Ale przynajmniej działa w ten sposób. Używam zewnętrznych połączeń w tym przykładzie, więc i tak musiałbym je sam zbudować. Ponadto wszystkie te zapytania są przechowywane w ramach zasad (przy użyciu Pundit). Są więc łatwe do sprawdzenia i nie ma żadnego kontrolera tłuszczu ani żadnej redundancji. Więc nie mam problemu z dodatkowym kodem.

# 
# Our starting point ;) 
# 
scope = Post 

# 
# Rebuild `scope.joins(:users)` or `scope.joins(:access_levels => :user)` 
# No magic here. 
# 
join = Post.arel_table.join(AccessLevel.arel_table, Arel::Nodes::OuterJoin).on(
    Post.arel_table[:id].eq(AccessLevel.arel_table[:post_id]). 
    and(AccessLevel.valid.where_values) 
).join_sources 
scope = scope.joins(join) 

join = AccessLevel.arel_table.join(User.arel_table, Arel::Nodes::OuterJoin).on(
    AccessLevel.arel_table[:user_id].eq(User.arel_table[:id]) 
).join_sources 

scope = scope.joins(join) 

# 
# Now let's join the access_levels table for a second time while reusing the AccessLevel.valid scope. 
# To accomplish that, we temporarily swap AccessLevel.table_name 
# 
table_alias   = 'al'       # This will be the alias 
temporary_table_name = AccessLevel.table_name   # We want to restore the original table_name later 
AccessLevel.table_name = table_alias     # Set the alias as the table_name 
valid_clause   = AccessLevel.valid.where_values # Store the condition with our temporarily table_name 
AccessLevel.table_name = temporary_table_name   # Restore the original table_name 

# 
# We're now able to use the table_alias combined with our valid_clause 
# 
join = Post.arel_table.join(AccessLevel.arel_table.alias(table_alias), Arel::Nodes::OuterJoin).on(
    Post.arel_table[:id].eq(AccessLevel.arel_table.alias(table_alias)[:post_id]). 
    and(valid_clause) 
).join_sources 

scope = scope.joins(join) 

Po całej krwi, potu i łez, oto nasza wynikające zapytania:

SELECT "posts".* FROM "posts" 
    LEFT OUTER JOIN "access_levels" 
    ON "posts"."id" = "access_levels"."post_id" 
     AND ("access_levels"."valid_from" IS NULL OR "access_levels"."valid_from" < '2014-09-15 20:35:34.420077') 
     AND ("access_levels"."valid_until" IS NULL OR "access_levels"."valid_until" > '2014-09-15 20:35:34.420189') 

    LEFT OUTER JOIN "users" 
    ON "access_levels"."user_id" = "users"."id" 

    LEFT OUTER JOIN "access_levels" "al" 
    ON "posts"."id" = "al"."post_id" 
    AND ("al"."valid_from" IS NULL OR "al"."valid_from" < '2014-09-15 20:35:41.678492') 
    AND ("al"."valid_until" IS NULL OR "al"."valid_until" > '2014-09-15 20:35:41.678603') 

Wszystkie warunki są teraz przy użyciu odpowiedniego aliasu!

6

Po przyjrzeniu się temu problemowi na similar question here, wymyśliłem prostsze i czystsze (na moje oczy) rozwiązanie tego pytania. W tym miejscu wklejałem odpowiednie fragmenty odpowiedzi na drugie pytanie wraz z zakresem.

Chodziło o to, aby znaleźć sposób na dostęp do bieżącego obiektu arel_table, z jego table_aliases, jeśli są one używane, wewnątrz zakresu w momencie jego wykonania. Za pomocą tej tabeli będziesz wiedział, czy zakres jest używany w ramach JOIN, który ma alias tabeli (wiele złączeń w tej samej tabeli), lub jeśli z drugiej strony zakres nie ma aliasu dla nazwy tabeli.

# based on your v2 
scope :valid, -> { 
    where("(#{current_table_from_scope}.valid_from IS NULL OR 
      #{current_table_from_scope}.valid_from < :now) AND 
     (#{current_table_from_scope}.valid_until IS NULL OR 
      #{current_table_from_scope}.valid_until > :now)", 
     :now => Time.zone.now) 
    } 

def self.current_table_from_scope 
    current_table = current_scope.arel.source.left 

    case current_table 
    when Arel::Table 
    current_table.name 
    when Arel::Nodes::TableAlias 
    current_table.right 
    else 
    fail 
    end 
end 

Używam current_scope jako obiekt bazowy szukać tabeli AREL, zamiast wcześniejszych prób wykorzystania self.class.arel_table lub nawet relation.arel_table. Dzwonię pod numer source na ten obiekt, aby uzyskać Arel::SelectManager, który z kolei da ci bieżącą tabelę na #left. W tej chwili są dwie opcje: masz tam Arel::Table (bez aliasu, nazwa tabeli jest na #name) lub, że masz Arel::Nodes::TableAlias z aliasem na jego #right.

Jeśli jesteś zainteresowany, oto niektóre odniesienia użyłem w dół drogi:

+0

Wygląda na naprawdę ładne rozwiązanie - na pewno spróbuję. – jack

3

Natknęłam się na to pytanie, szukając takich rzeczy. Wiem, że to spóźniona odpowiedź, ale jeśli ktoś się tu natknie, może to pomoże. Działa to w Railsach 4.2.2, być może nie można tego zrobić, gdy pytanie zostało wykonane.

Ta odpowiedź została zainspirowana odpowiedzią z @dgilperez, ale nieco uproszczona. Również używając poprawnego zakresu. Tak, oto jest.

class Post < ActiveRecord::Base 
    # the scope of the used association must be used 
    has_many :access_levels, -> { merge(AccessLevel.valid(current_scope)) } 
    has_many :users, :through => :access_levels 
end 

class AccessLevel < ActiveRecord::Base 
    belongs_to :post 
    belongs_to :user 

    # have an optional parameter for another scope than the scope of this class 
    scope :valid, ->(cur_scope = nil) { 
    # 'current_scope.table' is the same as 'current_scope.arel.source.left', 
    # and there is no need to investigate if it's an alias or not. 
    ar_table = cur_scope && cur_scope.table || arel_table 
    now = Time.zone.now 
    where(
     ar_table[:valid_from].eq(nil).or(ar_table[:valid_from].lt(now)).and(
     ar_table[:valid_until].eq(nil).or(ar_table[:valid_until].gt(now))) 
    ) 
    } 

    enum :level => [:publisher, :subscriber] 
end 

class User < ActiveRecord::Base 
    # the scope of the used association must be used 
    has_many :access_levels, -> { merge(AccessLevel.valid(current_scope)) } 
    has_many :users, :through => :access_levels 
end 

i nie ma potrzeby, aby mieć go w dwa przyłącza

Post.joins(:users, :access_levels).first 

widziałem, że również zmieniony za pomocą sprzężenia zewnętrzne, można dostać się:

Post.includes(:users, :access_levels).references(:users, :access_levels).first 

Ale należy pamiętać, używanie includes nie zawsze używa jednego żądania SQL.

+0

To jest świetne! Chciałem nazwę tabeli (ciąg) zamiast rzeczywistej tabeli Arel, więc skończyło się na '(current_scope? Current_scope.table: arel_table) .name' – Nathan

Powiązane problemy