Rails self-referential association join conditions - ruby-on-rails

I have the following database schema and models, which constitute a self-referential association where a Phrase has many translations of other Phrases, through the Translation association:
Schema:
ActiveRecord::Schema.define(version: 20151003213732) do
enable_extension "plpgsql"
create_table "phrases", force: :cascade do |t|
t.integer "language"
t.string "text"
end
create_table "translations", force: :cascade do |t|
t.integer "source_id"
t.integer "destination_id"
end
end
Phrase:
class Phrase < ActiveRecord::Base
has_many :translations,
class_name: "Translation",
foreign_key: "source_id"
has_many :destination_phrases,
through: :translations
has_many :inverse_translations,
class_name: "Translation",
foreign_key: "destination_id"
has_many :source_phrases,
through: :inverse_translations
enum language: [:eng, :spa, ...]
end
Translation:
class Translation < ActiveRecord::Base
belongs_to :source_phrase,
class_name: "Phrase",
foreign_key: "source_id"
belongs_to :destination_phrase,
class_name: "Phrase",
foreign_key: "destination_id"
end
Now, I want to run queries based on source language and destination language. For example, I want to find English phrases and their respective Spanish translations. Currently I am querying phrases based on source language, but then I have to filter out the results using the select method on the destination language. What I have looks like the following:
#translations = Translation.includes(:source_phrase, :destination_phrase)
.where(phrases: {language: #source_language})
# Select only destination phrases where the language matches
#translations = #translations.select {
|t| t.destination_phrase.language == #destination_language
}
I want to eliminate the select call because this should definitely be possible in ActiveRecord. The select would be replaced by an additional parameter in the model's where query, but I cant figure out how to specify it.
It should look something like this:
#translations =
Translation.includes(:source_phrase, :destination_phrase)
.where([source_phrase: {language: #source_language},
destination_phrase: {language: #destination_language}])
However, ActiveRecord thinks (rightfully) that source_phrase and destination_phrase in the where clause are table names. So the table name still has to be phrases, but when it is I can't specify the join condition for both joins, just the first.
How can I specify 2 separate join conditions on a self-referential association which both access the same attribute on the same model (language on the Phrase model)?

My Phrase model turned out to be defined incorrectly, which was the first problem. I've revised it as so:
Phrase:
class Phrase < ActiveRecord::Base
has_many :translations,
class_name: "Translation",
foreign_key: "source_id"
has_many :source_phrases, # this changed
through: :translations
has_many :inverse_translations,
class_name: "Translation",
foreign_key: "destination_id"
has_many :destination_phrases, # this changed
through: :inverse_translations
enum language: [:eng, :spa, ...]
end
Translation has remained the same:
class Translation < ActiveRecord::Base
belongs_to :source_phrase,
class_name: "Phrase",
foreign_key: "source_id"
belongs_to :destination_phrase,
class_name: "Phrase",
foreign_key: "destination_id"
end
Now I am able to perform either of the following queries to get the correct results:
Translation
.includes(:source_phrase => :source_phrases, :destination_phrase => :destination_phrases)
.where(source_phrases_phrases: {language: 0}, destination_phrases_phrases: {language: 2})
or
Translation
.joins(:source_phrase => :source_phrases, :destination_phrase => :destination_phrases)
.where(source_phrases_phrases: {language: 0}, destination_phrases_phrases: {language: 2})
.distinct
The answer was in specifying the joined association (in this case source_phrases and destination_phrases in Phrase), which could then be used as different table names in the where clause.
I should note that I use source_phrases_phrases and destination_phrases_phrases in the where clause because Rails seems to expect the table name (phrases) to be appended to the associations (source_phrases and destination_phrases). It makes for a few ugly queries, but perhaps I could name my associations in the Phrase model better...
It also seems that from the EXPLAIN output (way too long to put here), the joins version is about 25% faster with 6,000 phrases and 12,000 translations. It also exponentially gets faster the larger those tables grow.
Lesson learned? Self-referential associations are a pain. I certainly hope that this helps someone in the future.

Related

How can i make rails models belongs_to using multiple foreign key

recently I have a migration that adds a user_id column to the watch_events tables. and thus I want to change the watch_event models to handle belongs_to but with multiple approach
create_table 'users', force: :cascade do |t|
t.integer 'id'
t.integer 'customer_id'
end
create_table 'watch_events', force: :cascade do |t|
t.integer 'customer_id'
t.integer 'user_id'
end
previously
class WatchEvent < ApplicationRecord
belongs_to :user, foreign_key: :customer_id, primary_key: :customer_id
end
what I want:
if watch_event.customer_id is present, i want to use belongs_to :user, foreign_key: :customer_id, primary_key: :customer_id
if watch_event.customer_id is not present, i want to use normal belongs_to :user
how can I achieve this on the watch_event model?
I do not think that Rails supports 'fallback foreign keys' on associations. However, you can write a simple wrapper for your problem. First, relate your WatchEvent class twice to the user model, using your two keys and two 'internal' association names (:user_1 and :user_2). Then, add a 'virtual association reader' (user) and a 'virtual association setter' (user=(new_user)):
class WatchEvent < ApplicationRecord
belongs_to :user_1,
class_name: 'User',
foreign_key: :customer_id
belongs_to :user_2,
class_name: 'User',
foreign_key: :user_id
def user
user_1 || user_2
end
def user=(new_user)
self.user_1 = new_user
end
end
With this solution, the requirements "use customer_id to find user" and "use user_id as fallback foreign key if customer_id is nil or doesn't yield a result" is satisfied. It happens in the association reader method user. When there is a reader method, you'll need a setter method, which is user=(). Feel free to design the setter's internals as required, mine is just a suggestion.
BTW: You may need to re-add the declaration of the foreign primary_key. I omitted that for clarity.
If I understand your question correctly, then what you are looking for is a Polymorphic association.
If you see the code below, what it basically does is create two columns in the watch_events table, watcher_type and watcher_id. And the belongs_to :watcher then uses the watcher_type column to identify which model it should associate to,
create_table 'watch_events', force: :cascade do |t|
t.references 'watcher', polymorphic: true, null: false
end
class WatchEvent < ApplicationRecord
belongs_to :watcher, polymorphic: true
end

ActiveRecord double belongs_to doesn't include the foreign relation

In Rails 3.2, I have a dictionary with words and references, named "gotowords" which store the word they belong to in word_id and the word they make reference to in reference_id (ie. gotofrom in the models):
create_table "words", :force => true do |t|
t.string "word"
t.text "definition"
end
create_table "gotowords", :force => true do |t|
t.integer "word_id"
t.integer "reference_id"
end
With the models:
class Word < ActiveRecord::Base
has_many :gotowords
has_many :gotofroms, class_name: "Gotoword", foreign_key: "reference_id"
end
class Gotoword < ActiveRecord::Base
belongs_to :word
belongs_to :gotofrom, class_name: "Word", foreign_key: "id"
end
The following query works, but makes another query for each gotofroms.word which is apparently not included:
#words = Word.includes(:gotowords, :gotofroms)
I cannot (for now) refactor like this answer suggests, as the application is pretty huge and it would have too many consequences. That said, I can live with the supplemental query, but it bugs me... Adding inverse_of as is doesn't solve the problem:
has_many :gotowords, inverse_of: :word
has_many :gotofroms, class_name: "Gotoword", foreign_key: "reference_id", inverse_of: :gotofrom
Is there a solution to include Word twice in that configuration?
Try using preload. It works a bit different, but might still help to eliminate duplicated db queries:
#words = Word.preload(:gotowords, :gotofroms)

Complex many-to-many relation in Rails

I have a model Place.
For instance, place can be a city, a district, a region, or a country, but I think one model will be enough, because there is no big difference between city and region for my purposes.
And places can belong to each other, e.g. a region can have many cities, a country can have many regions etc.
But my problem is that it's kind of historical project and a city can belong to many regions. For example, from 1800 to 1900 a city belonged to one historical region (that doesn't exist now) and from 1900 till now - to another. And it's important to store those regions as different places despite of they can have similar geographical borders but only names are different.
I guess it's many-to-many, but maybe someone can give a better idea?
And if it's many-to-many, how can I get a chain of parent places in one query to make just simple string like "Place, Parent place 1, Parent place 2, Parent place 3", e.g. "San Francisco, California, USA"?
Here is my code:
create_table :places do |t|
t.string :name
t.timestamps null: false
end
create_table :place_relations, id: false do |t|
t.integer :sub_place_id
t.integer :parent_place_id
t.timestamps null: false
end
class Place < ActiveRecord::Base
has_and_belongs_to_many :parent_places,
class_name: "Place",
join_table: "place_relations",
association_foreign_key: "parent_place_id"
has_and_belongs_to_many :sub_places,
class_name: "Place",
join_table: "place_relations",
association_foreign_key: 'sub_place_id'
end
Please, don't hesitate to give me some ideas about it!
This is the first solution that popped in my mind, and there may be many other ways to do it, but I believe this may arguable be the cleanest.
You've got the right general idea, but all you need is a slight modification to the join table. Essentially you'll use has_many... through relationships instead, so that you can append some kind of time frame discriminator.
In my examples, I'm using a datetime field to indicate from what point the association is relevant. In combination with a default scope to order the results by the time discriminator (called effective_from in my examples), you can easily select the "current" parents and children of a place without additional effort, or select historical data using a single date comparison in the where clause. Note that you do not need to handle the time frame discrimination as I did, it is merely to demonstrate the concept. Modify as needed.
class PlaceRelation < ActiveRecord::Base
default_scope { order "effective_from DESC" }
belongs_to :parent, class_name: "Place"
belongs_to :child, class_name: "Place"
end
class Place < ActiveRecord::Base
has_many :parent_places, class_name: "PlaceRelation", foreign_key: "child_id"
has_many :child_places, class_name: "PlaceRelation", foreign_key: "parent_id"
has_many :parents, through: :parent_places, source: :parent
has_many :children, through: :child_places, source: :child
end
and the migration for the place_relations table should look like this:
class CreatePlaceRelations < ActiveRecord::Migration
def change
create_table :place_relations do |t|
t.integer :parent_id
t.integer :child_id
t.datetime :effective_from
t.timestamps
end
end
end
So if we create a couple of "top level" country-places:
country1 = Place.create(name: "USA")
country2 = Place.create(name: "New California Republic")
and a state place
state = Place.create("California")
and a city
city = Place.create("San Francisco")
and finally tie them all together:
state.parent_places.create(parent_id: country1.id, effective_from: DateTime.now - 1.year)
state.parent_places.create(parent_id: country2.id, effective_from: DateTime.now)
city.parent_places(parent_id: state.id, effective_from: DateTime.now)
Then you would have a city ("San Francisco") which belongs to the state "California", which historically belonged to the country "USA", and later "New California Republic".
Additionally, if you would like to build a string containing the place's name and all its "parents", you could do it "recursively" like so:
def full_name(name_string = [])
name_string << self.name
parent = self.parents.first
if parent.present?
return parent.full_name name_string
else
return name_string.join(", ")
end
end
Which, in the case of our city "San Francisco", should result in "San Francisco, California, New California Republic", given the ordering of the effective_from field.
This makes a association of direct many to many relation with another model without intervening that model . But you can use more advanced stuff if you want like Polymorphic Association .
For More Information please visit Rails Guide : Polymorphic Association

Rails JOINS with a twist

I have been trying and failing for 2 days now :) to get a list of ideas (posts basically) with likes. Order Desc preferably.
I have scaffolded ideas and users which work fine.
Likes (socialization gem) gives me the headache.
I can add likes and retrieve them. And I can also find out how many likes a specific idea has: idea.likers(User).count
and find out whether a user likes a specific idea: user.likes?(idea)
But I can't do agregates because of the non-standard field names which prohibit me from making a JOIN.
create_table "likes", force: :cascade do |t|
t.string "liker_type"
t.integer "liker_id" (this is/should be user_id)
t.string "likeable_type"
t.integer "likeable_id" (this is/should be idea_id)
t.datetime "created_at"
end
add_index "likes", ["likeable_id", "likeable_type"], name: "fk_likeables"
add_index "likes", ["liker_id", "liker_type"], name: "fk_likes"
Models:
like.rb - empty
user.rb - acts_as_liker
idea.rb - acts_as_likeable
Is there a way to join likes and ideas eg somehow matching liker_id to user_id? Or shall I rename the fields in the table (liker_id to user_id and likeable_id to idea_id)...? And also add these:
like.rb
belongs_to :user
belongs_to :idea
idea.rb
has_many :likes, dependent: :destroy
user.rb
has_many :likes, dependent: :destroy
Thanks in advance!
To specify a different column as foreign key which gets used in joins, you could add foreign_key: ... option to belongs_to as follows:
# app/models/like.rb
belongs_to :user, foreign_key: :liker_id
belongs_to :idea, foreign_key: :likeable_id
See referenced documentation on belongs_to.
You can also specify join conditions yourself as follows:
Idea.joins('inner join likes on ideas.id = likes.likeable_id').where(...)

How should I approach "has many through" relationships with Single Table Inheritance (STI) in Rails 4.0

I have a parent class called Place.
# Parent class for Country, City and District
class Place < ActiveRecord::Base
end
class Country < Place
has_many :cities, foreign_key: "parent_id"
has_many :districts, through: :cities
end
class City < Place
belongs_to :country, foreign_key: "parent_id"
has_many :districts, foreign_key: "parent_id"
end
class District < Place
belongs_to :city, foreign_key: 'parent_id'
has_one :country, through: :city
end
The schema:
create_table "places", force: true do |t|
t.string "name"
t.string "type"
t.integer "parent_id"
t.datetime "created_at"
t.datetime "updated_at"
end
add_index "places", ["parent_id"], name: "index_places_on_parent_id"
add_index "places", ["type"], name: "index_places_on_type"
The following works as expected:
#country.cities # => Returns all of the cities that belong to this country
#city.districts # => Returns all of the districts that belong to this city
But this does not work as I thought it would:
#country.districts # => Does not return all of the districts belonging to cities in this country
Can anybody explain how I should approach has many through with STIs?
Update
Here's the output SQL query from #country.districts
SELECT "places".* FROM "places" INNER JOIN "places" "cities_districts_join" ON "places"."parent_id" = "cities_districts_join"."id" WHERE "places"."type" IN ('City') AND "places"."type" IN ('District') AND "cities_districts_join"."parent_id" = ? [["parent_id", 1]]
I think the problem is that it's using the same join table for both relations, but I'm not sure if there's a "Rails way" to change the name of the joins table (elegantly)
This is a challenging case for ActiveRecord. It needs to infer that columns in the self-join needed to find districts are STI instances. Apparently it's not smart enough to get this right. Since the only table is places, it's not much of a surprise that it generates this query:
SELECT "places".* FROM "places"
INNER JOIN "places" "cities_districts_join"
ON "places"."parent_id" = "cities_districts_join"."id"
WHERE "places"."type" IN ('City') <<<<< ERROR HERE
AND "places"."type" IN ('District')
AND "cities_districts_join"."parent_id" = ?
As you can see the type check must fail since one string can't be both City and District. All would work if the first clause in the WHERE were instead
WHERE "cities_districts_join"."type" IN ('City')
I tried several options on the relations (thought :class_name might do it), but no joy.
You can work around this limitation with SQL. Delete the has_many ... through in the Country class and replace with
def districts
District.find_by_sql(['SELECT * from places AS city
INNER JOIN places AS district
ON district.parent_id = city.id
WHERE city.parent_id = ?', id])
end
Or maybe someone else will see a more elegant way. If not, you might consider posting this as an issue in Rails development. It's an interesting case.
I think you need to change the inheritance of your models.
class Country < Place
class City < Country
class District < City
And then remove the
has_one :country through: :city
line.
Scroll down to find info about STI
http://api.rubyonrails.org/classes/ActiveRecord/Base.html

Resources