Rails 7.1.x Exploring Bi-directional Relations
Exploring the challenge of bi-directional relationships without a Graph Database
I am have been exploring Graph-DBs and wanted to try a graph structure in a relational DB. Not trivial!
This code can be found at: https://github.com/btihen-dev/flintstones_api
Getting Started
bin/rails new flintstones_api --api
cd flintstones_api
bin/rails g scaffold Person first_name last_name given_last_name gender
bin/rails g model PersonRelationships role_one role_two \
person_one:references person_two:references
Let’s update the migration to make the first & last name required as well as gender
class CreatePeople < ActiveRecord::Migration[7.1]
def change
create_table :people do |t|
t.string :first_name, null: false
t.string :last_name, null: false
t.string :given_last_name
t.string :gender, null: false
t.timestamps
end
end
end
Let’s create a quick test in our seed file.
zed = Person.create!(first_name: "Zed", last_name: "Flintstone", gender: 'male')
jed = Person.create!(first_name: "Jed", last_name: "Flintstone", gender: 'male')
rock = Person.create!(first_name: "Rockbottom", last_name: "Flintstone", gender: 'male')
now migrate and make seeds
bin/rails db:migrate
bin/rails db:seed
NAMESPACE REST API
Now that we have a basic setup that works lets setup the API code.
I like to namespace to allow for easy versioning.
mkdir app/controllers/api
mkdir app/controllers/api/v1
mv app/controllers/people_controller.rb app/controllers/api/v1/.
now that we have the new location and moved our people_controller
there. Let’s fix it’s namespace using:
module Api
module V1
class PeopleController < ApplicationController
# ...
end
end
end
now it looks like:
module Api
module V1
class PeopleController < ApplicationController
before_action :set_person, only: %i[ show update destroy ]
# GET /people
def index
@people = Person.all
render json: @people
end
# GET /people/1
def show
render json: @person
end
# POST /people
def create
@person = Person.new(person_params)
if @person.save
render json: @person, status: :created, location: @person
else
render json: @person.errors, status: :unprocessable_entity
end
end
# PATCH/PUT /people/1
def update
if @person.update(person_params)
render json: @person
else
render json: @person.errors, status: :unprocessable_entity
end
end
# DELETE /people/1
def destroy
@person.destroy!
end
private
# Use callbacks to share common setup or constraints between actions.
def set_person
@person = Person.find(params[:id])
end
# Only allow a list of trusted parameters through.
def person_params
params.require(:person).permit(:first_name, :last_name)
end
end
end
end
now let’s update our routes to reflect our API namespace in the url path too using:
Rails.application.routes.draw do
namespace :api do
namespace :v1 do
resources :people
end
end
# Reveal health status on /up that returns 200 if the app boots with no exceptions, otherwise 500.
# Can be used by load balancers and uptime monitors to verify that the app is live.
get "up" => "rails/health#show", as: :rails_health_check
# Defines the root path route ("/")
# root "posts#index"
end
Let’s test:
bin/rails s -p 3030
curl http://localhost:3030/api/v1/people.json
#this returns way to much info:
[{"id":1,"first_name":"Zed","last_name":"Flintstone","given_last_name":null,"gender":"male","created_at":"2024-03-16T15:29:19.306Z","updated_at":"2024-03-16T15:29:19.306Z"},
{"id":2,"first_name":"Jed","last_name":"Flintstone","given_last_name":null,"gender":"male","created_at":"2024-03-16T15:29:19.308Z","updated_at":"2024-03-16T15:29:19.308Z"},
{"id":3,"first_name":"Rockbottom","last_name":"Flintstone","given_last_name":null,"gender":"male","created_at":"2024-03-16T15:29:19.317Z","updated_at":"2024-03-16T15:29:19.317Z"},
...
Let’s change the controller to return only: id, first_name, last_name, and gender by changing our select from Person.all
to Person.select(:id, :first_name, :last_name, :gender, :given_last_name).all
or render json: @people.as_json(only: [:id, :first_name, :last_name, :gender, :given_last_name])
.
# app/controllers/api/v1/people_controller.rb
def index
@people = Person.all
render json: @people.as_json(only: [:id, :first_name, :last_name, :gender, :given_last_name])
end
def show
render json: @person.as_json(only: [:id, :first_name, :last_name, :gender, :given_last_name])
end
# ...
For completeness we need to ensure all our critical attributes are listed for the create/update to work (we need :gender
and given_last_name
).
# app/controllers/api/v1/people_controller.rb
private
# Only allow a list of trusted parameters through.
def person_params
params.require(:person).permit(:first_name, :last_name, :gender, :given_last_name)
end
Now let’s try the API index again - now we get a nice tidy response:
# browser style
curl http://localhost:3030/api/v1/people.json
# or API Style
curl http://localhost:3030/api/v1/people -H "Accept: application/json"
[{"id":1,"first_name":"Zed","last_name":"Flintstone","gender":"male"},
{"id":2,"first_name":"Jed","last_name":"Flintstone","gender":"male"},
{"id":3,"first_name":"Rockbottom","last_name":"Flintstone","gender":"male"}
# ...
Now let’s try the show API for person 2 using:
curl -X GET http://localhost:3030/api/v1/people/2.json
{"id":2,"first_name":"Jed","last_name":"Flintstone","gender":"male"}
# or
curl -X GET http://localhost:3030/api/v1/people/2 -H "Accept: application/json"
{"id":2,"first_name":"Jed","last_name":"Flintstone","gender":"male"}
NOTE: Once you allow delete you should also adjust AND TEST the dependent: :destroy
to ensure no hanging relationships in PeopleRelationships! (not yet included)
curl -X DELETE http://localhost:3030/api/v1/people/2 \
-H "Accept: application/json"
Update (patch / put):
curl -X PATCH http://localhost:3030/api/v1/people/2 \
-H "Content-Type: application/json" \
-d '{"first_name": "NewFirstName", "last_name": "NewLastName"}'
Create a new person:
curl -X POST http://localhost:3030/api/v1/people \
-H "Content-Type: application/json" \
-d '{"first_name": "John", "last_name": "Doe"}'
RELATIONSHIPS
It’s nice to list users, but we are also interested in finding how people are related. Let’s set relationships.
bin/rails g model PersonRelationships role_one role_two \
person_one:references person_two:references
since we have two keys referring to the same table we need to include the table name in the migration.
class CreatePersonRelationships < ActiveRecord::Migration[7.1]
def change
create_table :person_relationships do |t|
t.string :role_one
t.string :role_two
t.references :person_one, null: false, foreign_key: { to_table: :people }, index: true
t.references :person_two, null: false, foreign_key: { to_table: :people }, index: true
t.timestamps
end
end
end
let’s update our person model. Given we don’t know if a person is referenced using the first or second entry in the table we need to do a complicated has_many
and we will add a convenience method related_people
.
class Person < ApplicationRecord
has_many :person_relationships_one, class_name: 'PersonRelationship', foreign_key: :person_one_id
has_many :person_relationships_two, class_name: 'PersonRelationship', foreign_key: :person_two_id
# relationships when using `_1.map(&attributes)`
has_many :related_people_one,
-> { select('people.*, person_relationships.role_two AS relationship')
.joins(:person_relationships_one) },
through: :person_relationships_one, source: :person_two
has_many :related_people_two,
-> { select('people.*, person_relationships.role_one AS relationship')
.joins(:person_relationships_two) },
through: :person_relationships_two, source: :person_one
# use `.to_json` or `_1.map(&attributes)` to include the relationship!
def related_people = (related_people_one + related_people_two).uniq
end
given that the names we are using don’t match the class name we need to include it here.
class PersonRelationship < ApplicationRecord
belongs_to :person_one, class_name: 'Person'
belongs_to :person_two, class_name: 'Person'
end
Let’s update the seed file with a few relationships.
zed = Person.create!(first_name: "Zed", last_name: "Flintstone", gender: 'male')
jed = Person.create!(first_name: "Jed", last_name: "Flintstone", gender: 'male')
PersonRelationship.create!(person_one: zed, role_one: "sibling", person_two: jed, role_two: "sibling")
rock = Person.create!(first_name: "Rockbottom", last_name: "Flintstone", gender: 'male')
PersonRelationship.create!(person_one: jed, role_one: "parent", person_two: rock, role_two: "child")
ed = Person.create!(first_name: "Ed", last_name: "Flintstone", gender: 'male')
PersonRelationship.create!(person_one: rock, role_one: "parent", person_two: ed, role_two: "child")
giggles = Person.create!(first_name: "Giggles", last_name: "Flintstone", gender: 'male')
PersonRelationship.create!(person_one: rock, role_one: "parent", person_two: giggles, role_two: "child")
PersonRelationship.create!(person_one: ed, role_one: "sibling", person_two: giggles, role_two: "sibling")
tex = Person.create!(first_name: "Tex", last_name: "Hardrock", gender: 'male')
edna = Person.create!(first_name: "Edna", last_name: "Flintstone", gender: 'female', given_last_name: "Hardrock")
PersonRelationship.create!(person_one: tex, role_one: "sibling", person_two: edna, role_two: "sibling")
PersonRelationship.create!(person_one: ed, role_one: "spouse", person_two: edna, role_two: "spouse")
fred = Person.create!(first_name: "Fred", last_name: "Flintstone", gender: 'male')
PersonRelationship.create!(person_one: ed, role_one: "parent", person_two: fred, role_two: "child")
PersonRelationship.create!(person_one: edna, role_one: "parent", person_two: fred, role_two: "child")
joe = Person.create!(first_name: "Joe", last_name: "Rockhead", gender: 'male')
PersonRelationship.create!(person_one: joe, role_one: "friend", person_two: fred, role_two: "friend")
stoney = Person.create!(first_name: "Stoney", last_name: "Curtis", gender: 'male')
perry = Person.create!(first_name: "Perry", last_name: "Masonry", gender: 'male' )
arnlod = Person.create!(first_name: "Arnold", last_name: "Granite", gender: 'male')
ricky = Person.create!(first_name: "Ricky", last_name: "Slaghoople", gender: 'male')
pearl = Person.create!(first_name: "Pearl", last_name: "Slaghoople", gender: 'female')
PersonRelationship.create!(person_one: ricky, role_one: "spouse", person_two: pearl, role_two: "spouse")
wilma = Person.create!(first_name: "Wilma", last_name: "Flintstone", gender: 'female')
PersonRelationship.create!(person_one: ricky, role_one: "parent", person_two: wilma, role_two: "child")
PersonRelationship.create!(person_one: pearl, role_one: "parent", person_two: wilma, role_two: "child")
PersonRelationship.create!(person_one: wilma, role_one: "spouse", person_two: fred, role_two: "spouse")
pebbles = Person.create!(first_name: "Pebbles", last_name: "Flintstone", gender: "female")
PersonRelationship.create!(person_one: wilma, role_one: "parent", person_two: pebbles, role_two: "child")
PersonRelationship.create!(person_one: fred, role_one: "parent", person_two: pebbles, role_two: "child")
bob = Person.create!(first_name: "Bob", last_name: "Rubble", gender: 'male')
flo = Person.create!(first_name: "Flo Slate", last_name: "Rubble", gender: 'female')
PersonRelationship.create!(person_one: bob, role_one: "spouse", person_two: flo, role_two: "spouse")
barney = Person.create!(first_name: "Barney", last_name: "Rubble", gender: 'male')
PersonRelationship.create!(person_one: bob, role_one: "parent", person_two: barney, role_two: "child")
PersonRelationship.create!(person_one: flo, role_one: "parent", person_two: barney, role_two: "child")
PersonRelationship.create!(person_one: barney, role_one: "friend", person_two: fred, role_two: "friend")
dusty = Person.create!(first_name: "Dusty", last_name: "Rubble", gender: 'male')
PersonRelationship.create!(person_one: bob, role_one: "parent", person_two: dusty, role_two: "child")
PersonRelationship.create!(person_one: flo, role_one: "parent", person_two: dusty, role_two: "child")
brick = Person.create!(first_name: "Brick", last_name: "McBricker", gender: "male")
jean = Person.create!(first_name: "Jean", last_name: "McBricker", gender: "female")
PersonRelationship.create!(person_one: brick, role_one: "spouse", person_two: jean, role_two: "spouse")
betty = Person.create!(first_name: "Betty", last_name: "Rubble", given_last_name: "McBricker", gender: "female")
PersonRelationship.create!(person_one: brick, role_one: "parent", person_two: betty, role_two: "child")
PersonRelationship.create!(person_one: jean, role_one: "parent", person_two: betty, role_two: "child")
PersonRelationship.create!(person_one: betty, role_one: "friend", person_two: wilma, role_two: "spouse")
brad = Person.create!(first_name: "Brad", last_name: "McBricker", gender: "male")
PersonRelationship.create!(person_one: brick, role_one: "parent", person_two: brad, role_two: "child")
PersonRelationship.create!(person_one: jean, role_one: "parent", person_two: brad, role_two: "child")
bamm = Person.create!(first_name: "Bamm-Bamm", last_name: "Rubble", gender: 'male')
PersonRelationship.create!(person_one: betty, role_one: "parent", person_two: bamm, role_two: "child")
PersonRelationship.create!(person_one: barney, role_one: "parent", person_two: bamm, role_two: "child")
PersonRelationship.create!(person_one: pebbles, role_one: "spouse", person_two: bamm, role_two: "spouse")
roxy = Person.create!(first_name: "Roxy", last_name: "Rubble", gender: "female")
PersonRelationship.create!(person_one: betty, role_one: "parent", person_two: roxy, role_two: "child")
PersonRelationship.create!(person_one: barney, role_one: "parent", person_two: roxy, role_two: "child")
chip = Person.create!(first_name: "Chip", last_name: "Rubble", gender: "male")
PersonRelationship.create!(person_one: betty, role_one: "parent", person_two: chip, role_two: "child")
PersonRelationship.create!(person_one: barney, role_one: "parent", person_two: chip, role_two: "child")
given the large change in the seeds file lets just reset the setup
bin/rails db:drop
bin/rails db:setup
Console Test our code:
# jed is the center of our relationships and is associated with both person_one and person_two
jed = Person.find_by(first_name: 'Jed')
jed.to_json
jed.attributes
# returns the relationships (associations in the first column of the join table)
jed.related_people
# we expect to find zed and rockbottom - when we debug using:
jed.related_people_one
jed.related_people_two
# we see only one works
unfortunately, this doesn’t work for jed (although it works for more complicated cases)
fred = Person.find_by(first_name: 'Fred')
fred.related_people # returns the relationships as expected
Let’s try again - this isn’t reliable.
By analyzing Fred’s queries (that worked) - I build a reliable query - so lets rewrite our model using:
class Person < ApplicationRecord
# original with ONE USER
RELATED_IS_SQL = <<-SQL.freeze
SELECT people.*, person_relationships.role_two AS relationship
FROM people
INNER JOIN person_relationships ON people.id = person_relationships.person_two_id
WHERE person_relationships.person_one_id = ?
UNION
SELECT people.*, person_relationships.role_one AS relationship
FROM people
INNER JOIN person_relationships ON people.id = person_relationships.person_one_id
WHERE person_relationships.person_two_id = ?
SQL
# use `.to_json` or `_1.map(&attributes)` to include the relationship!
def related_people = Person.find_by_sql([RELATED_ONE, [id], [id]])
end
Now jed and all people should find all relations using:
jed = Person.find_by(first_name: 'Jed')
jed.related_people
fred = Person.find_by(first_name: 'Fred')
fred.related_people
ok now we can build our relations API!
RELATIONS API
It is generally a good idea to stick with the normal controller calls and create a new resource.
So if we want people and their immediate relations we can make a new controller:
touch app/controllers/api/v1/people_with_relations_controller.rb
let’s decide what we want our API to return - the desired person with relations listed in an array - these ‘related persons should return the relation role
’:
{ "id"=>8,
"first_name"=>"Fred",
"last_name"=>"Flintstone",
"given_last_name" => nil,
"gender" => 'male',
"relations"=> [
{"id"=>16,
"first_name"=>"Pebbles",
"last_name"=>"Flintstone",
"given_last_name"=>nil,
"gender"=>"female",
"relationship"=>"child"},
{"id"=>4,
"first_name"=>"Ed",
"last_name"=>"Flintstone",
"given_last_name"=>nil,
"gender"=>"male",
"relationship"=>"parent"},
{"id"=>7,
"first_name"=>"Edna",
"last_name"=>"Flintstone",
"given_last_name"=>"Hardrock",
"gender"=>"female",
"relationship"=>"parent"},
{"id"=>15,
"first_name"=>"Wilma",
"last_name"=>"Flintstone",
"given_last_name"=>nil,
"gender"=>"female",
"relationship"=>"spouse"},
{"id"=>19,
"first_name"=>"Barney",
"last_name"=>"Rubble",
"given_last_name"=>nil,
"gender"=>"male",
"relationship"=>"friend"}
]
}
lets try some code:
params = {}
params[:id] = 8
person = Person.where(id: params[:id]).select(:id, :first_name, :last_name, :gender).first
relations = person.related_people.map { _1.attributes.except('created_at', 'updated_at') }
@person = person.attributes.except('created_at', 'updated_at').merge(relations: relations)
cool this looks good - lets try in the controller:
# app/controllers/api/v1/people_with_relations_controller.rb
module Api
module V1
class PeopleWithRelationsController < ApplicationController
# GET /people.json
def index
people = Person.select(:id, :first_name, :last_name, :gender).all
@people = people.map do |person|
relations = person.related_people.map { _1.attributes.except('created_at', 'updated_at') }
person.attributes.except('created_at', 'updated_at').merge(related_people: relations)
end
render json: @people
end
# GET /people/1.json
def show
person = Person.where(id: params[:id]).select(:id, :first_name, :last_name, :gender).first
relations = person.related_people.map { _1.attributes.except('created_at', 'updated_at') }
@person = person.attributes.except('created_at', 'updated_at').merge(related_people: relations)
render json: @person
end
end
end
end
bin/rails s -p 3030
curl -X GET http://localhost:3030/api/v1/people_with_relations/8.json
{ "id":8,
"first_name":"Fred",
"last_name":"Flintstone",
"gender":"male",
"related_people": [
{ "id":16,
"first_name":"Pebbles",
"last_name":"Flintstone",
"given_last_name":null,
"gender":"female",
"relationship":"child" },
{ "id":4,
"first_name":"Ed",
"last_name":"Flintstone",
"given_last_name":null,
"gender":"male",
"relationship":"parent" },
{ "id":7,
"first_name":"Edna",
"last_name":"Flintstone",
"given_last_name":"Hardrock",
"gender":"female",
"relationship":"parent" },
{ "id":15,
"first_name":"Wilma",
"last_name":"Flintstone",
"given_last_name":null,
"gender":"female",
"relationship":"spouse" },
{ "id":19,
"first_name":"Barney",
"last_name":"Rubble",
"given_last_name":null,
"gender":"male",
"relationship":"friend" }
]
}%
OK - looks good. Let’s see what the rails log looks like:
Started GET "/api/v1/people_with_relations/8.json" for ::1 at 2024-03-17 13:50:53 +0100
Processing by Api::V1::PeopleWithRelationsController#show as JSON
Parameters: {"id"=>"8"}
Person Load (0.1ms) SELECT "people"."id", "people"."first_name", "people"."last_name", "people"."gender" FROM "people" WHERE "people"."id" = ? ORDER BY "people"."id" ASC LIMIT ? [["id", 8], ["LIMIT", 1]]
↳ app/controllers/api/v1/people_with_relations_controller.rb:25:in 'show'
Person Load (0.1ms) SELECT people.*, person_relationships.role_two AS relationship FROM "people" INNER JOIN "person_relationships" ON "people"."id" = "person_relationships"."person_two_id" INNER JOIN "person_relationships" "person_relationships_ones_people" ON "person_relationships_ones_people"."person_one_id" = "people"."id" WHERE "person_relationships"."person_one_id" = ? [["person_one_id", 8]]
↳ app/models/person.rb:15:in 'related_people'
Person Load (0.1ms) SELECT people.*, person_relationships.role_one AS relationship FROM "people" INNER JOIN "person_relationships" ON "people"."id" = "person_relationships"."person_one_id" INNER JOIN "person_relationships" "person_relationships_twos_people" ON "person_relationships_twos_people"."person_two_id" = "people"."id" WHERE "person_relationships"."person_two_id" = ? [["person_two_id", 8]]
↳ app/models/person.rb:15:in 'related_people'
Completed 200 OK in 4ms (Views: 0.1ms | ActiveRecord: 0.3ms | Allocations: 3019)
Three queires - makes sense. One for the pirmary person, one in the case when our relation is in role_one and another when our relation is in role_two.
OK let’s try index now:
people = Person.select(:id, :first_name, :last_name, :gender).all
@people = people.map do |person|
relations = person.related_people.map { _1.attributes.except('created_at', 'updated_at') }
person.attributes.except('created_at', 'updated_at').merge(related_people: relations)
end
[{"id"=>1, "first_name"=>"Zed", "last_name"=>"Flintstone", "gender"=>"male", :related_people=>[]},
{"id"=>2, "first_name"=>"Jed", "last_name"=>"Flintstone", "gender"=>"male", :related_people=>[]},
{"id"=>3,
"first_name"=>"Rockbottom",
"last_name"=>"Flintstone",
"gender"=>"male",
:related_people=>[{"id"=>4, "first_name"=>"Ed", "last_name"=>"Flintstone", "given_last_name"=>nil, "gender"=>"male", "relationship"=>"child"}]},
{"id"=>4,
"first_name"=>"Ed",
"last_name"=>"Flintstone",
"gender"=>"male",
:related_people=>
[{"id"=>7, "first_name"=>"Edna", "last_name"=>"Flintstone", "given_last_name"=>"Hardrock", "gender"=>"female", "relationship"=>"spouse"},
{"id"=>8, "first_name"=>"Fred", "last_name"=>"Flintstone", "given_last_name"=>nil, "gender"=>"male", "relationship"=>"child"}]},
...
now lets try via the controller
curl -X GET http://localhost:3030/api/v1/people_with_relations.json
[ {"id":1,"first_name":"Zed","last_name":"Flintstone","gender":"male","related_people":[]},
{"id":2,"first_name":"Jed","last_name":"Flintstone","gender":"male","related_people":[]},
{"id":3,"first_name":"Rockbottom","last_name":"Flintstone","gender":"male",
"related_people": [
{ "id":4,
"first_name":"Ed",
"last_name":"Flintstone",
"given_last_name":null,
"gender":"male","relationship":"child" }
]},
{ "id":4,
"first_name":"Ed",
"last_name":"Flintstone",
"gender":"male",
"related_people": [
{ "id":7,
"first_name":"Edna",
"last_name":"Flintstone",
"given_last_name":"Hardrock",
"gender":"female",
"relationship":"spouse"}
]},
...
ok - this looks good lets check the logs:
Started GET "/api/v1/people_with_relations.json" for ::1 at 2024-03-17 14:19:57 +0100
Processing by Api::V1::PeopleWithRelationsController#index as JSON
Person Load (0.6ms) SELECT "people"."id", "people"."first_name", "people"."last_name", "people"."gender" FROM "people"
↳ app/controllers/api/v1/people_with_relations_controller.rb:8:in `map'
Person Load (0.1ms) SELECT people.*, person_relationships.role_two AS relationship FROM "people" INNER JOIN "person_relationships" ON "people"."id" = "person_relationships"."person_two_id" INNER JOIN "person_relationships" "person_relationships_ones_people" ON "person_relationships_ones_people"."person_one_id" = "people"."id" WHERE "person_relationships"."person_one_id" = ? [["person_one_id", 1]]
↳ app/models/person.rb:15:in `related_people'
Person Load (0.0ms) SELECT people.*, person_relationships.role_one AS relationship FROM "people" INNER JOIN "person_relationships" ON "people"."id" = "person_relationships"."person_one_id" INNER JOIN "person_relationships" "person_relationships_twos_people" ON "person_relationships_twos_people"."person_two_id" = "people"."id" WHERE "person_relationships"."person_two_id" = ? [["person_two_id", 1]]
↳ app/models/person.rb:15:in `related_people'
Person Load (0.0ms) SELECT people.*, person_relationships.role_two AS relationship FROM "people" INNER JOIN "person_relationships" ON "people"."id" = "person_relationships"."person_two_id" INNER JOIN "person_relationships" "person_relationships_ones_people" ON "person_relationships_ones_people"."person_one_id" = "people"."id" WHERE "person_relationships"."person_one_id" = ? [["person_one_id", 2]]
↳ app/models/person.rb:15:in `related_people'
Person Load (0.0ms) SELECT people.*, person_relationships.role_one AS relationship FROM "people" INNER JOIN "person_relationships" ON "people"."id" = "person_relationships"."person_one_id" INNER JOIN "person_relationships" "person_relationships_twos_people" ON "person_relationships_twos_people"."person_two_id" = "people"."id" WHERE "person_relationships"."person_two_id" = ? [["person_two_id", 2]]
↳ app/models/person.rb:15:in `related_people'
Person Load (0.1ms) SELECT people.*, person_relationships.role_two AS relationship FROM "people" INNER JOIN "person_relationships" ON "people"."id" = "person_relationships"."person_two_id" INNER JOIN "person_relationships" "person_relationships_ones_people" ON "person_relationships_ones_people"."person_one_id" = "people"."id" WHERE "person_relationships"."person_one_id" = ? [["person_one_id", 3]]
↳ app/models/person.rb:15:in `related_people'
Person Load (0.0ms) SELECT people.*, person_relationships.role_one AS relationship FROM "people" INNER JOIN "person_relationships" ON "people"."id" = "person_relationships"."person_one_id" INNER JOIN "person_relationships" "person_relationships_twos_people" ON "person_relationships_twos_people"."person_two_id" = "people"."id" WHERE "person_relationships"."person_two_id" = ? [["person_two_id", 3]]
↳ app/models/person.rb:15:in `related_people'
Hmm now we have an n+1 - one query to get all the people and two for each person to get relations. (Can we improve his?) This well be very poor performance with lots of records
Fixing N+1
to fix this lets see if we can adjust our SQL to handle many users and sort them by the calling person_id
and we will change our where statements from WHERE person_relationships.person_one_id = ?
to: WHERE person_relationships.person_one_id IN (?)
so lets try:
# app/models/person.rb
def self.related_people_for(person_ids)
person_ids = Array(person_ids)
# our select returns only the values we want from related persons
# (as well as the relationship and the root person)
sql = <<-SQL
SELECT people.id, people.first_name, people.last_name, people.gender, person_relationships.role_two AS relationship, person_relationships.person_one_id AS person_id
FROM people
INNER JOIN person_relationships ON people.id = person_relationships.person_two_id
WHERE person_relationships.person_one_id IN (?)
UNION
SELECT people.id, people.first_name, people.last_name, people.gender, person_relationships.role_one AS relationship, person_relationships.person_two_id AS person_id
FROM people
INNER JOIN person_relationships ON people.id = person_relationships.person_one_id
WHERE person_relationships.person_two_id IN (?)
SQL
# find all our related people
related_people = Person.find_by_sql([sql, id, id])
# group related users by their root_person_id
related_people_hash = related_people.group_by { |rp| rp.person_id }
# convert our related people into a hash
related_people_hash.transform_values! do |related_people_arr|
related_people_arr.map do |related_person|
related_person.attributes.except('created_at', 'updated_at', 'person_id')
end
end
end
let’s build / test some index code:
people = Person.select(:id, :first_name, :last_name, :gender).all
related_people_grouped_hash = Person.related_people_for(people.pluck(:id))
@people = people.map do |person|
person_attribs = person.attributes.except('created_at', 'updated_at')
person_attribs.merge(related_people: related_people_grouped_hash[person.id] || [])
end
JSON.parse @people.to_json
nice gets the correct answer and also only 3 quiries!
lets update our controller
# app/controllers/api/v1/people_with_relations_controller.rb
module Api
module V1
class PeopleWithRelationsController < ApplicationController
# GET /people.json
def index
people = Person.select(:id, :first_name, :last_name, :gender).all
related_people_grouped_hash = Person.related_people_for(people.pluck(:id))
@people = people.map do |person|
person_attribs = person.attributes.except('created_at', 'updated_at')
person_attribs.merge(related_people: related_people_grouped_hash[person.id] || [])
end
render json: @people
end
# GET /people/1.json
def show
# not more efficient than the above, but uses the same logic as index (less to test) :)
person = Person.find(params[:id])
person_attribs = person.attributes.except('created_at', 'updated_at')
related_people_grouped_hash = Person.related_people_for(params[:id])
@person = person_attribs.merge(related_people: related_people_grouped_hash[params[:id].to_i] || [])
render json: @person
end
end
end
end
here is the current model:
# app/models/person.rb
class Person < ApplicationRecord
RELATED_ONE = <<-SQL.freeze
SELECT people.*, person_relationships.role_two AS relationship
FROM people
INNER JOIN person_relationships ON people.id = person_relationships.person_two_id
WHERE person_relationships.person_one_id = ?
UNION
SELECT people.*, person_relationships.role_one AS relationship
FROM people
INNER JOIN person_relationships ON people.id = person_relationships.person_one_id
WHERE person_relationships.person_two_id = ?
SQL
# use `.to_json` or `_1.map(&attributes)` to include the relationship!
def related_people = Person.find_by_sql([RELATED_ONE, [id], [id]])
RELATED_SQL = <<-SQL.freeze
SELECT people.id, people.first_name, people.last_name, people.gender, person_relationships.role_two AS relationship, person_relationships.person_one_id AS person_id
FROM people
INNER JOIN person_relationships ON people.id = person_relationships.person_two_id
WHERE person_relationships.person_one_id IN (?)
UNION
SELECT people.id, people.first_name, people.last_name, people.gender, person_relationships.role_one AS relationship, person_relationships.person_two_id AS person_id
FROM people
INNER JOIN person_relationships ON people.id = person_relationships.person_one_id
WHERE person_relationships.person_two_id IN (?)
SQL
# not useful anymore
# has_many :person_relationships_one, class_name: 'PersonRelationship', foreign_key: :person_one_id
# has_many :person_relationships_two, class_name: 'PersonRelationship', foreign_key: :person_two_id
# # relationships when using `_1.map(&attributes)`
# has_many :related_people_one,
# -> { select('people.*, person_relationships.role_two AS relationship').joins(:person_relationships_one) },
# through: :person_relationships_one, source: :person_two, class_name: 'Person'
# has_many :related_people_two,
# -> { select('people.*, person_relationships.role_one AS relationship').joins(:person_relationships_two) },
# through: :person_relationships_two, source: :person_one, class_name: 'Person'
# # use `.to_json` or `_1.map(&attributes)` to include the relationship!
# def related_people = (related_people_one + related_people_two).uniq
# handles many ids
def self.related_people_for(person_ids)
# ensure we always have an array
person_ids = Array(person_ids)
# query for related people
related_people = Person.find_by_sql([RELATED_SQL, person_ids, person_ids])
# grouping allows us to return a hash with the person_id as the key (neccesary for index controller)
related_people_hash = related_people.group_by(&:person_id)
# convert values to a hash (only including desired values)
related_people_hash.transform_values! do |related_people_arr|
related_people_arr.map do |related_person|
related_person.attributes.except('created_at', 'updated_at', 'person_id')
end
end
end
end
testing the api
lets try show:
curl -X GET http://localhost:3030/api/v1/people_with_relations/8.json
{"id":8,"first_name":"Fred","last_name":"Flintstone","gender":"male",
"related_people":
{"8":[{"id":4,"first_name":"Ed","last_name":"Flintstone","gender":"male","relationship":"parent"},{"id":7,"first_name":"Edna","last_name":"Flintstone","gender":"female","relationship":"parent"},{"id":9,"first_name":"Joe","last_name":"Rockhead","gender":"male","relationship":"friend"},{"id":15,"first_name":"Wilma","last_name":"Flintstone","gender":"female","relationship":"spouse"},{"id":16,"first_name":"Pebbles","last_name":"Flintstone","gender":"female","relationship":"child"},{"id":19,"first_name":"Barney","last_name":"Rubble","gender":"male","relationship":"friend"}]}}%
show logs:
Started GET "/api/v1/people_with_relations/8.json" for ::1 at 2024-03-17 15:31:28 +0100
Processing by Api::V1::PeopleWithRelationsController#show as JSON
Parameters: {"id"=>"8"}
Person Pluck (0.0ms) SELECT "people"."id" FROM "people" WHERE "people"."id" = ? [["id", 8]]
↳ app/controllers/api/v1/people_with_relations_controller.rb:35:in `show'
Person Load (0.1ms) SELECT people.id, people.first_name, people.last_name, people.gender, person_relationships.role_two AS relationship, person_relationships.person_one_id AS person_id
FROM people
INNER JOIN person_relationships ON people.id = person_relationships.person_two_id
WHERE person_relationships.person_one_id IN (8)
UNION
SELECT people.id, people.first_name, people.last_name, people.gender, person_relationships.role_one AS relationship, person_relationships.person_two_id AS person_id
FROM people
INNER JOIN person_relationships ON people.id = person_relationships.person_one_id
WHERE person_relationships.person_two_id IN (8)
↳ app/models/person.rb:68:in `related_people_for'
Person Load (0.1ms) SELECT "people"."id", "people"."first_name", "people"."last_name", "people"."gender" FROM "people" WHERE "people"."id" = ? [["id", 8]]
↳ app/controllers/api/v1/people_with_relations_controller.rb:37:in `map'
Completed 200 OK in 12ms (Views: 0.1ms | ActiveRecord: 1.1ms | Allocations: 7119)
ok still 3 queiries, but should scale!
index
Index logs:
Started GET "/api/v1/people_with_relations.json" for ::1 at 2024-03-17 15:25:50 +0100
Processing by Api::V1::PeopleWithRelationsController#index as JSON
Person Pluck (0.1ms) SELECT "people"."id" FROM "people"
↳ app/controllers/api/v1/people_with_relations_controller.rb:8:in `index'
Person Load (0.3ms) SELECT people.id, people.first_name, people.last_name, people.gender, person_relationships.role_two AS relationship, person_relationships.person_one_id AS person_id
FROM people
INNER JOIN person_relationships ON people.id = person_relationships.person_two_id
WHERE person_relationships.person_one_id IN (1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27)
UNION
SELECT people.id, people.first_name, people.last_name, people.gender, person_relationships.role_one AS relationship, person_relationships.person_two_id AS person_id
FROM people
INNER JOIN person_relationships ON people.id = person_relationships.person_one_id
WHERE person_relationships.person_two_id IN (1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27)
↳ app/models/person.rb:68:in `related_people_for'
Person Load (0.1ms) SELECT "people"."id", "people"."first_name", "people"."last_name", "people"."gender" FROM "people"
↳ app/controllers/api/v1/people_with_relations_controller.rb:10:in `map'
Completed 200 OK in 4ms (Views: 0.3ms | ActiveRecord: 0.4ms | Allocations: 5125)
cool - now we can get all with relations in 3 queires! Now ts performant!
of course with a lot of records index should also limit returns and implement pagination ‘we can do this in v2’ as we grow!
git add .
git commit -m "implement efficient person with relations"
pets
bin/rails g scaffold Pet name species # owner:references:person
bin/rails g model PetPeople pet person
Refactoring
using raw SQL is generally not so desired so we can update our code with:
class Person < ApplicationRecord
def self.with_relations(person_ids)
person_ids = Array(person_ids).map(&:to_i)
query1 = Person.select("people.*, person_relationships.role_one AS relationship, person_relationships.person_two_id AS person_id")
.joins("INNER JOIN person_relationships ON people.id = person_relationships.person_one_id")
.where(person_relationships: { person_two_id: person_ids })
query2 = Person.select("people.*, person_relationships.role_two AS relationship, person_relationships.person_one_id AS person_id")
.joins("INNER JOIN person_relationships ON people.id = person_relationships.person_two_id")
.where(person_relationships: { person_one_id: person_ids })
Person.find_by_sql(query1.to_sql + " UNION " + query2.to_sql)
end
# this is an alternative way to scope instead of using the lambda and a scope statement (returns a hash)
def self.related_people_for(person_ids)
# query for related people
related_people = with_relations(person_ids)
# grouping allows us to return a hash with the person_id as the key (necessary for index controller)
related_people_grouped = related_people.group_by(&:person_id)
# removes unwanted attributes (possibly to later or confogure json to do this)
related_people_grouped.transform_values! do |related_people_arr|
related_people_arr.map do |related_person|
related_person.attributes.except('created_at', 'updated_at', 'person_id')
end
end
end
# this is equivalent to: with_relations(person_ids) -
# for many using scope is clearer and more standard
# (they are both used the same way)
# People.with_related_people(ids)
# People.People.with_related_people(ids)
scope :with_related_people, lambda { |person_ids| c(person_ids) }
# this only works for an instantiated person - this works just like a has_many would
# jed = Person.find_by(first_name: 'Jed')
# jed.related_people
def related_people = with_relations(id)
end
i like using lambdas - so this would also be possible:
class Person < ApplicationRecord
RELATED_PEOPLE =
lambda do |person_ids| RELATED_PEOPLE.call(person_ids)
person_ids = Array(person_ids).map(&:to_i)
query1 = Person.select("people.*, person_relationships.role_one AS relationship, person_relationships.person_two_id AS person_id")
.joins("INNER JOIN person_relationships ON people.id = person_relationships.person_one_id")
.where(person_relationships: { person_two_id: person_ids })
query2 = Person.select("people.*, person_relationships.role_two AS relationship, person_relationships.person_one_id AS person_id")
.joins("INNER JOIN person_relationships ON people.id = person_relationships.person_two_id")
.where(person_relationships: { person_one_id: person_ids })
Person.find_by_sql(query1.to_sql + " UNION " + query2.to_sql)
end
# this is an alternative way to scope instead of using the lambda and a scope statement
def self.related_people_for(person_ids)
# query for related people
# related_people = with_relations(person_ids)
related_people = RELATED_PEOPLE.call(person_ids)
# grouping allows us to return a hash with the person_id as the key (neccesary for index controller)
related_people_grouped = related_people.group_by(&:person_id)
# removes unwanted attributes (possibly to later or confogure json to do this)
related_people_grouped.transform_values! do |related_people_arr|
related_people_arr.map do |related_person|
related_person.attributes.except('created_at', 'updated_at', 'person_id')
end
end
end
scope :with_related_people, lambda { |person_ids| RELATED_PEOPLE.call(person_ids) }
def related_people = RELATED_PEOPLE.call(id)
end