Rails 7.1.x JSON API Introduction with Nested data

JSON API Versioning in Rails 7.1.x

I was recently asked about Rails API applications and wanted to explore the options (based on the Flintstones characters).

The end goal is to return one or many records in the following format - without an N+1 query.

{ "id":8,
  "first_name":"Wilma",
  "last_name":"Flintstone",
  "given_name":"Slaghoople",
  "nick_name":null,
  "gender":"female",
  "person_jobs": [
    { "start_date":"1980-01-01",
      "end_date":"1989-12-31",
      "job": {
        "role":"news reporter",
        "company": {
          "id":2,
          "name":"Bedrock Daily News"
    }}},
    { "start_date":"1990-01-01",
      "end_date":null,
      "job":{
        "role":"caterer",
        "company":{
          "id":6,
          "name":"Betty and Wilma Catering"
    }}},
    ...
  ]
}

This code can be found at: https://github.com/btihen-dev/rails_api_intro

Getting Started

I will create a basic application - then start the api exploration. I will use: bin/rails new bedrock -T instead of bin/rails new bedrock -T --api because I will also use this same base code to play with hotwire. I am also going to use the database postgresql to then later add the AGE postgres plugin to model the relationships using a graph data-structure. Likewise, I will be using esbuild for the hotwire features - but this can easily be omitted.

rails new api_intro -T --main --database=postgresql --javascript=esbuild --css=tailwind
cd api_intro
bin/rails db:create

git add .
git commit -m "initial commit"

Base Model - People

I’ll add people with scaffolding

bin/rails g scaffold Person nick_name first_name \
                     last_name given_name gender

Let’s update the migration to make the first & last name required as well as gender & lets ensure we cant add the same person twice with a unique index on first_name last_name 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 :nick_name
      t.string :given_name
      t.string :gender, null: false

      t.timestamps
    end

    add_index :people, %i[first_name last_name gender],
              unique: true
  end
end

Let’s update the model to trim extra leading and trailing spaces and enforce the same DB options as at the DB level:

class Person < ApplicationRecord
  normalizes :first_name, :nick_name, :last_name, :given_name,
             with: ->(value) { value.strip }

  validates :first_name,
            uniqueness: { scope: :last_name,
			                    message: "first- and last-name already exists" }
  validates :first_name, presence: true
  validates :last_name, presence: true
  validates :gender, inclusion: { in: %w[male female non-binary] }
end

now migrate and add a people seed (see the appendix for seed data)

bin/rails db:migrate
bin/rails db:seed

let’s test:

bin/rails c

Person.first

Person.all

Let’s snapshot assuming this works and shows reasonable data.

git add .
git commit -m "Add person model"

NAMESPACE JSON API

Now that we have a basic setup that works lets setup the API code.

I like to namespace to allow for easy versioning.

So let’s configure the namespace in our routing so it now looks like

Rails.application.routes.draw do
	resources :people

	namespace :api do
	  namespace :v0 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 "people#index"
end

Let’s start by building a JSON only api controller with:

mkdir app/controllers/api
cat << EOF > app/controllers/api/json_controller.rb
module Api
  class JsonController < ActionController::API
  end
end
EOF

Now lets build an api v0 person controller that inherits from the aoi only controller

mkdir app/controllers/api/v0

cat << EOF > app/controllers/api/v0/people_controller.rb
module Api
  module V0
    class PeopleController < JsonController
      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, status: :ok, location: @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, :nick_name, :given_name, :gender)
      end
    end
  end
end
EOF

Let’s test:

bin/rails s -p 3030

curl http://localhost:3030/api/v0/people/1.json

{
  "id":1,
  "first_name":"Zeke",
  "last_name":"Flintstone",
  "given_name":null,
  "nick_name":null,
  "gender":"male",
  "created_at":"2024-03-29T21:35:46.032Z",
  "updated_at":"2024-03-29T21:35:46.032Z"
}


curl http://localhost:3030/api/v0/people.json

#this returns way to much info:
[
  { "id":1,
    "first_name":"Zeke",
    "last_name":"Flintstone",
    "given_name":null,
    "nick_name":null,
    "gender":"male",
    "created_at":"2024-03-29T21:35:46.032Z",
    "updated_at":"2024-03-29T21:35:46.032Z" },
  { "id":2,
    "first_name":"Jed",
    "last_name":"Flintstone",
    "given_name":null,
    "nick_name":null,
    "gender":"male",
    "created_at":"2024-03-29T21:35:46.055Z",
    "updated_at":"2024-03-29T21:35:46.055Z" }
    ...
]

assuming this works is as expected let’s snapshot our work:

git add .
git commit -m "namespaced a basic person api that returns everything"

JSON V1 (restrict data returned)

Let’s make a breaking change to our api and only return fields we wish and not all fields. So we will make a v1 and update the routes with:

Rails.application.routes.draw do
	resources :people

	namespace :api do
	  namespace :v0 do
		  resources :people
	  end
	  namespace :v1 do
		  resources :people
	  end
	end

  get "up" => "rails/health#show", as: :rails_health_check

  root "people#index"
end

Let’s return only: id, first_name, last_name, and gender by changing how we return data to use: render json: @people.as_json(only: [:id, :first_name, :last_name, :gender, :given_last_name])

or we can use:

render json: @people.as_json(except: [:created_at, :updated_at])

so now our controller would look like:

mkdir app/controllers/api/v1

cat << EOF > app/controllers/api/v1/people_controller.rb
# returns only specific fields
module Api
  module V1
    class PeopleController < JsonController
      before_action :set_person, only: %i[ show update destroy ]

      # GET /people
      def index
        @people = Person.all

        render json: @people.as_json(
          except: [:created_at, :updated_at]
        )
      end

      # GET /people/1
      def show
        render json: @person.as_json(
          only: [:id, :nick_name, :first_name, :last_name, :given_name, :gender]
        )
      end

      # POST /people
      def create
        @person = Person.new(person_params)

        if @person.save
          render json: @person.as_json(
            except: [:created_at, :updated_at]
          ), 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.as_json(
            only: [:id, :nick_name, :first_name, :last_name, :given_name, :gender]
          ), status: :ok, location: @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, :nick_name, :given_name, :gender)
      end
    end
  end
end
EOF

Let’s test:

bin/rails s -p 3030

curl http://localhost:3030/api/v1/people/1.json

{
  "id":1,
  "first_name":"Zeke",
  "last_name":"Flintstone",
  "given_name":null,
  "nick_name":null,
  "gender":"male",
}

curl http://localhost:3030/api/v1/people.json

[
  { "id":1,
    "first_name":"Zeke",
    "last_name":"Flintstone",
    "given_name":null,
    "nick_name":null,
    "gender":"male",},
  { "id":2,
    "first_name":"Jed",
    "last_name":"Flintstone",
    "given_name":null,
    "nick_name":null,
    "gender":"male"}
    ...
]

Assuming this works, lets snapshot here:

git add .
git commit -m "added v1 aoi - restricts data fields shared"

Refactor

This is good, but every time we change our return or the model we need to make adjustments everywhere – lets centralize our data send with a method like:

      private

      def render_formatted_people(ar_query, options = {})
        render json: ar_query.as_json(
          only: [:id, :first_name, :last_name, :gender, :given_last_name]
        ), **options
      end

or alternatively

      private

      def render_formatted_people(ar_query, options = {})
        render json: ar_query.as_json(
          except: [ :updated_at, :created_at ]
        ), **options
      end

so now we can rewrite the controller like:

module Api
  module V1
    class PeopleController < JsonController
      before_action :set_person, only: %i[ show update destroy ]

      # GET /people
      def index
        @people = Person.all

        render_json_person(@people)
      end

      # GET /people/1
      def show
        render_json_person(@person)
      end

      # POST /people
      def create
        @person = Person.new(person_params)

        if @person.save
          options = { status: :created, location: @person }
          render_json_person(@person, options)
        else
          render json: @person.errors, status: :unprocessable_entity
        end
      end

      # PATCH/PUT /people/1
      def update
        if @person.update(person_params)
          options = { status: :ok, location: @person }
          render_json_person(@person, options)
        else
          render json: @person.errors, status: :unprocessable_entity
        end
      end

      # DELETE /people/1
      def destroy
        @person.destroy!
      end

      private

      def render_json_person(ar_query, options = {})
        render json: ar_query.as_json(
          except: [ :updated_at, :created_at ]
        ), **options
      end

      # 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, :nick_name, :given_name, :gender)
      end
    end
  end
end

Let’s do a quick test to see that everything still works:

curl http://localhost:3030/api/v1/people.json

Here is are the commands to test all the actions:

index:

curl http://localhost:3030/api/v1/people.json
# or
curl -X GET http://localhost:3030/api/v1/people \
     -H "Accept: application/json"

show:

curl http://localhost:3030/api/v1/people/1.json
# or
curl -X GET http://localhost:3030/api/v1/people/1 \
     -H "Accept: application/json"

delete:

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"}'

now we can snapshot our refactoring:

git add .
git commit -m "refactored v1 to easily managed return values"

Nested Associations

add companies

It’s nice to list users, but we are also interested in additional information about our people - like their jobs and the associated companies they work for.

lets add these to the code.

first will will add the companies:

bin/rails g scaffold Company name

let’s make the company name required and unique in the database:

class CreateCompanies < ActiveRecord::Migration[7.2]
  def change
    create_table :companies do |t|
      t.string :name, null: false

      t.timestamps
    end

    add_index :companies, :name, unique: true
  end
end

let’s normalize the name field, and add the validations to our database to reflect the database restrictions

class Company < ApplicationRecord
  normalizes :name,  with: ->(value) { value.strip }

  validates :name, presence: true
  validates :name, uniqueness: true
end

be sure the companies act as expected - you can add the company seed data to the database. The easiest was to add more data is just to recreate the DB and reseed your data.

bin/rails db:drop
bin/rails db:create
bin/rails db:migrate
bin/rails db:seed

let’s test:

bin/rails c

Company.first

Company.all

assuming everything works as expected:

git add .
git commit -m "add company information"

add Jobs

a job is associated with a company and a role so we can generate our code with:

bin/rails g scaffold Job role company:references

now let’s update the migration to prevent the same job from being added twice (we will now do a unique index on 2 columns) with add_index :jobs, %i[role company_id], unique: true and ensure we have a role with t.string :role, null: false so now the migration looks like:

class CreateJobs < ActiveRecord::Migration[7.2]
  def change
    create_table :jobs do |t|
      t.string :role, null: false
      t.references :company, null: false, foreign_key: true, index: true

      t.timestamps
    end

    add_index :jobs, %i[role company_id], unique: true
  end
end

again let’s update the job model to reflect our database structure by adding belongs_to :company and removing leading and trailing empty spaces in the role field with normalizes :role, with: ->(value) { value.strip } finally adding the 2 column uniqueness and presence validations.

class Job < ApplicationRecord
  belongs_to :company

  normalizes :role, with: ->(value) { value.strip }

  validates :company, presence: true
  validates :role, presence: true
  validates :role,
            uniqueness: { scope: :company_id,
                          message: "role and company already exists" }
end

we should now also update company to reflect that it can have many jobs with has_many :jobs so now the class looks like.

class Company < ApplicationRecord
  has_many :jobs

  normalizes :name,  with: ->(value) { value.strip }

  validates :name, presence: true
  validates :name, uniqueness: true
end

Let’s update the seeds file and run our migrations.

bin/rails db:drop
bin/rails db:create
bin/rails db:migrate
bin/rails db:seed

let’s test (especially the associations):

bin/rails c

Company.first
# #<Company:0x0000000122e58ad0
#  id: 1,
#  name: "San Cemente",
#  created_at: Sun, 31 Mar 2024 15:32:31.512139000 UTC +00:00,
#  updated_at: Sun, 31 Mar 2024 15:32:31.512139000 UTC +00:00>

Company.first.jobs
# [#<Job:0x0000000122e5b7d0
#   id: 1,
#   role: "owner",
#   company_id: 1,
#   created_at: Sun, 31 Mar 2024 15:32:31.693088000 UTC +00:00,
#   updated_at: Sun, 31 Mar 2024 15:32:31.693088000 UTC +00:00>]

Let’s test the reverse too:

bin/rails c

Job.last
# #<Job:0x0000000104e43720
#  id: 21,
#  role: "The Grand Poobah",
#  company_id: 15,
#  created_at: Sun, 31 Mar 2024 15:32:31.877515000 UTC +00:00,
#  updated_at: Sun, 31 Mar 2024 15:32:31.877515000 UTC +00:00>

Job.last.company
# #<Company:0x0000000131d28480
#  id: 15,
#  name: "Water Buffalo Lodge",
#  created_at: Sun, 31 Mar 2024 15:32:31.637401000 UTC +00:00,
#  updated_at: Sun, 31 Mar 2024 15:32:31.637401000 UTC +00:00>

cool, its looking good - let’s snapshot before ew

git add .
git commit -m "added jobs in association with companies"

PersonJobs

now we want to associate people with jobs (and thereby also companies)

so we will add a person_jobs join table (with start and end dates - since some characters take on different jobs) using:

bin/rails g model PersonJob start_date:date end_date:date \
                  person:references job:references

let’s ensure that a PersonJob always has a start_date and each person can only have one job with given start_date (Barney for example switches back and forth between being a police officer and a dino-crane operator so he may have multiple entries for each job - with differing start dates) so this migration with its 3 column constraint looks like:

class CreatePersonJobs < ActiveRecord::Migration[7.2]
  def change
    create_table :person_jobs do |t|
	    t.date :start_date, null: false
		  t.date :end_date
      t.references :person, null: false, foreign_key: true, index: true
      t.references :job, null: false, foreign_key: true, index: true

      t.timestamps
    end

    add_index :person_jobs, %i[person_id job_id start_date], unique: true
  end
end

so now let’s ensure our new model reflects the database - with the following code. The logic and code changes should look familiar.

class PersonJob < ApplicationRecord
  belongs_to :person
  belongs_to :job

  validates :job, presence: true
  validates :person, presence: true
  validates :start_date, presence: true
  validates :person,
            uniqueness: { scope: [:job, :start_date],
                          message: "person and job with start_date already exists" }
end

now let’s update the person model so that we can find out who has which jobs at which companies with a bunch of has_many & has_many through statements. Now the model should look like:

class Person < ApplicationRecord
  has_many :person_jobs, dependent: :destroy
  has_many :jobs, through: :person_jobs
  has_many :companies, through: :jobs

  normalizes :first_name, :nick_name, :last_name, :given_name,
             with: ->(value) { value.strip }

  validates :first_name,
            uniqueness: { scope: :last_name,
                          message: "first_name and last_name already exists" }
  validates :first_name, presence: true
  validates :last_name, presence: true
  validates :gender, presence: true
  validates :gender, inclusion: { in: %w[male female] }
end

if we want to do similar queries with companies to know who works for a company than we need to update the company model with has_many through statements as well.

class Company < ApplicationRecord
  has_many :jobs, dependent: :destroy
  has_many :person_jobs, through: :jobs
  has_many :people, through: :person_jobs

  normalizes :name,  with: ->(value) { value.strip }

  validates :name, presence: true
  validates :name, uniqueness: true
end

and similarly we can add has_many and has_many_through in jobs to know about the people working at the company.

class Job < ApplicationRecord
  belongs_to :company

  has_many :person_jobs, dependent: :destroy
  has_many :people, through: :person_jobs

  normalizes :role, :title, :company, with: ->(value) { value.strip }

  validates :company, presence: true
  validates :role, presence: true
  validates :role,
            uniqueness: { scope: :company_id,
                          message: "role and company already exists" }
end

seed the data for PersonJob and test all the relations.

bin/rails db:drop
bin/rails db:create
bin/rails db:migrate
bin/rails db:seed

let’s test (especially the associations):

bin/rails c

# Test People Relationships
Person.first.jobs
# [#<Job:0x0000000120351760
#   id: 1,
#   role: "owner",
#   company_id: 1,
#   created_at: Sun, 31 Mar 2024 15:48:33.169108000 UTC +00:00,
#   updated_at: Sun, 31 Mar 2024 15:48:33.169108000 UTC +00:00>]

Person.first.companies
# [#<Company:0x000000011e785158
#   id: 1,
#   name: "San Cemente",
#   created_at: Sun, 31 Mar 2024 15:48:33.006684000 UTC +00:00,
#   updated_at: Sun, 31 Mar 2024 15:48:33.006684000 UTC +00:00>]

# Test Jobs Relationships
Job.first.people
# [#<Person:0x00000001015db900
#   id: 1,
#   first_name: "Zeke",
#   last_name: "Flintstone",
#   nick_name: nil,
#   given_name: nil,
#   gender: "male",
#   created_at: Sun, 31 Mar 2024 15:48:32.620435000 UTC +00:00,
#   updated_at: Sun, 31 Mar 2024 15:48:32.620435000 UTC +00:00>]

Job.first.company
# #<Company:0x000000012086dfc8
#  id: 1,
#  name: "San Cemente",
#  created_at: Sun, 31 Mar 2024 15:48:33.006684000 UTC +00:00,
#  updated_at: Sun, 31 Mar 2024 15:48:33.006684000 UTC +00:00>

# Test Company Relationships
Company.first.jobs
# [#<Job:0x0000000120844ec0
#   id: 1,
#   role: "owner",
#   company_id: 1,
#   created_at: Sun, 31 Mar 2024 15:48:33.169108000 UTC +00:00,
#   updated_at: Sun, 31 Mar 2024 15:48:33.169108000 UTC +00:00>]

Company.first.people
# [#<Person:0x000000011e8ad0d0
#   id: 1,
#   first_name: "Zeke",
#   last_name: "Flintstone",
#   nick_name: nil,
#   given_name: nil,
#   gender: "male",
#   created_at: Sun, 31 Mar 2024 15:48:32.620435000 UTC +00:00,
#   updated_at: Sun, 31 Mar 2024 15:48:32.620435000 UTC +00:00>]

Cool now that everything works let’s snapshot again.

git add .
git commit -m "add PersonJob and associations"

JSON V2 - nested data

now that we have our base application and relationships - lets make another breaking change and update the data structure and return nested Job and Company data.

Let’s add our new route:

Rails.application.routes.draw do
  resources :jobs
  resources :companies
	resources :people

	namespace :api do
	  namespace :v0 do
		  resources :people
	  end
	  namespace :v1 do
		  resources :people
	  end
	  namespace :v2 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 "people#index"
end

now of course we will need our new controller - to do this we need to adjust our method render_json_person(ar_query, options = {}) so that it returns our nested data - we do this with the include statement so now we will change and a nice mix of only and except to control what data is actually returned.

      def render_json_person(ar_query, options = {})
        render json: ar_query.as_json(
          except: [ :updated_at, :created_at ]
        ), **options
      end

to (now we have some nice examples of only, include and except):

      def render_json_person(ar_query, options = {})
        render json: model.as_json(
          include: {
            person_jobs: {
              only: [ :start_date, :end_date ],
              include: {
                job: {
                  only: [ :role ],
                  include: {
                    company: { only: [ :id, :name ] }
                  }
                }
              }
            }
          },
          except: [ :updated_at, :created_at ]
        ), **options
      end

notice we have nested includes - this is perfectly valid.

NOTE: we haven’t adjusted our queries yet (we will refactor shortly), first let’s ensure we return the wanted data.

Now our newest controller should look like:

mkdir app/controllers/api/v2

cat << EOF > app/controllers/api/v2/people_controller.rb
# returns nested jobs and companies for each person.
module Api
  module V2
    class PeopleController < JsonController
      before_action :set_person, only: %i[ show update destroy ]

      # GET /people
      def index
        @people = Person.all

        render_json_person(@people)
      end

      # GET /people/1
      def show
        render_json_person(@person)
      end

      # POST /people
      def create
        @person = Person.new(person_params)

        if @person.save
          options = { status: :created, location: @person }
          render_json_person(@person, options)
        else
          render json: @person.errors, status: :unprocessable_entity
        end
      end

      # PATCH/PUT /people/1
      def update
        if @person.update(person_params)
          options = { status: :ok, location: @person }
          render_json_person(@person, options)
        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, :nick_name, :given_name, :gender)
      end

      def render_json_person(ar_query, options = {})
        render json: ar_query.as_json(
          include: {
            person_jobs: {
              only: [ :start_date, :end_date ],
              include: {
                job: {
                  only: [ :role ],
                  include: {
                    company: { only: [ :id, :name ] }
                  }
                }
              }
            }
          },
          except: [ :updated_at, :created_at ]
        ), **options
      end
    end
  end
end
EOF

now when we test show with:

curl http://localhost:3030/api/v2/people/8.json

we should get:

{ "id":8,
  "first_name":"Wilma",
  "last_name":"Flintstone",
  "given_name":"Slaghoople",
  "nick_name":null,
  "gender":"female",
  "person_jobs": [
    { "start_date":"1980-01-01",
      "end_date":"1989-12-31",
      "job": {
        "role":"news reporter",
        "company": {
          "id":2,
          "name":"Bedrock Daily News"
    }}},
    { "start_date":"1990-01-01",
      "end_date":null,
      "job":{
        "role":"caterer",
        "company":{
          "id":6,
          "name":"Betty and Wilma Catering"
    }}},
    ...
  ]
}

cool let’s snapshot this:

git add .
git commit -m "add JSON v2 people with nested job and company data"

Refactor: fix N+1 Queries

when we query index with:

curl http://localhost:3030/api/v2/people.json

we notice we have a problem we need many queries to return all the people, their jobs and workplaces. Here is an abbreviated log from such a request (with over 100 queries to process our results).

Started GET "/api/v2/people.json" for ::1 at 2024-03-31 19:38:46 +0200
  ActiveRecord::SchemaMigration Load (3.5ms)  SELECT "schema_migrations"."version" FROM "schema_migrations" ORDER BY "schema_migrations"."version" ASC
Processing by Api::V2::PeopleController#index as JSON
  Person Load (73.3ms)  SELECT "people".* FROM "people"
  ↳ app/controllers/api/v2/people_controller.rb:64:in `render_json_person'
  PersonJob Load (2.4ms)  SELECT "person_jobs".* FROM "person_jobs" WHERE "person_jobs"."person_id" = $1  [["person_id", 1]]
  ↳ app/controllers/api/v2/people_controller.rb:64:in `render_json_person'
  Job Load (1.2ms)  SELECT "jobs".* FROM "jobs" WHERE "jobs"."id" = $1 LIMIT $2  [["id", 1], ["LIMIT", 1]]
  ↳ app/controllers/api/v2/people_controller.rb:64:in `render_json_person'
  Company Load (0.5ms)  SELECT "companies".* FROM "companies" WHERE "companies"."id" = $1 LIMIT $2  [["id", 1], ["LIMIT", 1]]
  ↳ app/controllers/api/v2/people_controller.rb:64:in `render_json_person'
  PersonJob Load (0.7ms)  SELECT "person_jobs".* FROM "person_jobs" WHERE "person_jobs"."person_id" = $1  [["person_id", 2]]
  ↳ app/controllers/api/v2/people_controller.rb:64:in `render_json_person'
  Job Load (0.7ms)  SELECT "jobs".* FROM "jobs" WHERE "jobs"."id" = $1 LIMIT $2  [["id", 2], ["LIMIT", 1]]
  ↳ app/controllers/api/v2/people_controller.rb:64:in `render_json_person'
  Company Load (0.7ms)  SELECT "companies".* FROM "companies" WHERE "companies"."id" = $1 LIMIT $2  [["id", 13], ["LIMIT", 1]]
  ↳ app/controllers/api/v2/people_controller.rb:64:in `render_json_person'
  PersonJob Load (0.5ms)  SELECT "person_jobs".* FROM "person_jobs" WHERE "person_jobs"."person_id" = $1  [["person_id", 3]]
  ↳ app/controllers/api/v2/people_controller.rb:64:in `render_json_person'
  Job Load (0.6ms)  SELECT "jobs".* FROM "jobs" WHERE "jobs"."id" = $1 LIMIT $2  [["id", 4], ["LIMIT", 1]]
  ↳ app/controllers/api/v2/people_controller.rb:64:in `render_json_person'
  Company Load (0.5ms)  SELECT "companies".* FROM "companies" WHERE "companies"."id" = $1 LIMIT $2  [["id", 12], ["LIMIT", 1]]
  ↳ app/controllers/api/v2/people_controller.rb:64:in `render_json_person'
  Job Load (0.5ms)  SELECT "jobs".* FROM "jobs" WHERE "jobs"."id" = $1 LIMIT $2  [["id", 3], ["LIMIT", 1]]
  ↳ app/controllers/api/v2/people_controller.rb:64:in `render_json_person'
  Company Load (0.4ms)  SELECT "companies".* FROM "companies" WHERE "companies"."id" = $1 LIMIT $2  [["id", 8], ["LIMIT", 1]]
  ↳ app/controllers/api/v2/people_controller.rb:64:in

  ...

    PersonJob Load (2.2ms)  SELECT "person_jobs".* FROM "person_jobs" WHERE "person_jobs"."person_id" = $1  [["person_id", 48]]
  ↳ app/controllers/api/v2/people_controller.rb:64:in `render_json_person'
  Job Load (1.8ms)  SELECT "jobs".* FROM "jobs" WHERE "jobs"."id" = $1 LIMIT $2  [["id", 19], ["LIMIT", 1]]
  ↳ app/controllers/api/v2/people_controller.rb:64:in `render_json_person'
  Company Load (1.3ms)  SELECT "companies".* FROM "companies" WHERE "companies"."id" = $1 LIMIT $2  [["id", 4], ["LIMIT", 1]]
  ↳ app/controllers/api/v2/people_controller.rb:64:in `render_json_person'
  PersonJob Load (1.6ms)  SELECT "person_jobs".* FROM "person_jobs" WHERE "person_jobs"."person_id" = $1  [["person_id", 49]]
  ↳ app/controllers/api/v2/people_controller.rb:64:in `render_json_person'
  Job Load (3.9ms)  SELECT "jobs".* FROM "jobs" WHERE "jobs"."id" = $1 LIMIT $2  [["id", 20], ["LIMIT", 1]]
  ↳ app/controllers/api/v2/people_controller.rb:64:in `render_json_person'
  CACHE Company Load (0.0ms)  SELECT "companies".* FROM "companies" WHERE "companies"."id" = $1 LIMIT $2  [["id", 2], ["LIMIT", 1]]
  ↳ app/controllers/api/v2/people_controller.rb:64:in `render_json_person'
  PersonJob Load (1.1ms)  SELECT "person_jobs".* FROM "person_jobs" WHERE "person_jobs"."person_id" = $1  [["person_id", 50]]
  ↳ app/controllers/api/v2/people_controller.rb:64:in `render_json_person'
  PersonJob Load (1.0ms)  SELECT "person_jobs".* FROM "person_jobs" WHERE "person_jobs"."person_id" = $1  [["person_id", 51]]
  ↳ app/controllers/api/v2/people_controller.rb:64:in `render_json_person'
  PersonJob Load (1.6ms)  SELECT "person_jobs".* FROM "person_jobs" WHERE "person_jobs"."person_id" = $1  [["person_id", 52]]
  ↳ app/controllers/api/v2/people_controller.rb:64:in `render_json_person'
  Job Load (1.9ms)  SELECT "jobs".* FROM "jobs" WHERE "jobs"."id" = $1 LIMIT $2  [["id", 21], ["LIMIT", 1]]
  ↳ app/controllers/api/v2/people_controller.rb:64:in `render_json_person'
  Company Load (1.1ms)  SELECT "companies".* FROM "companies" WHERE "companies"."id" = $1 LIMIT $2  [["id", 15], ["LIMIT", 1]]
  ↳ app/controllers/api/v2/people_controller.rb:64:in `render_json_person'

Completed 200 OK in 435ms (Views: 1.9ms | ActiveRecord: 262.0ms | Allocations: 129846)

When the number of queries grows for each record called - this is called an N+1 query and are very inefficient and slow with large amounts of data. Notice this request took 435ms to return to us.

We can fix (refactor our queries with includes) this will let the database return associated data with a fixed number of queries - that doesn’t grow as we return more data. To do this lets change:

@people = Person.all to @people = Person.includes(person_jobs: [ job: :company ]).all this is a concise way of writing:

@people = Person.includes(:person_jobs) # return nested `person_jobs` data for `has_many :person_jobs`
                .includes(person_jobs: :job) # return nested `jobs` for `has_many :jobs, through: :person_jobs
                .includes(person_jobs: [ job: :company ]) # return nested `companies` for `has_many :companies, through: :jobs
                .all

less critical but we can also rewrite: @person = Person.find_by(params[:id]) to

@person = Person.includes(person_jobs: [ job: :company ])
                .where(id: params[:id])
                .limit(1).first

In fact, I like to make this into a method so that the base query is always the same I do this by adding person_query and rewriting index and set_person to use the person query:

      # GET /people
      def index
        @people = person_query.all

        render_json_person(@people)
      end

      private

      def person_query
        Person.includes(person_jobs: [ job: :company ])
      end

      # Use callbacks to share common setup or constraints between actions.
      def set_person
        @person = person_query.where(id: params[:id])
                              .limit(1).first
      end
      # ...

so refactored the controller (that avoids n+1) now looks like:

module Api
  module V2
    class PeopleController < JsonController
      before_action :set_person, only: %i[ show update destroy ]

      # GET /people
      def index
        @people = person_query.all

        render_json_person(@people)
      end

      # GET /people/1
      def show
        render_json_person(@person)
      end

      # POST /people
      def create
        @person = Person.new(person_params)

        if @person.save
          options = { status: :created, location: @person }
          render_json_person(@person, options)
        else
          render json: @person.errors, status: :unprocessable_entity
        end
      end

      # PATCH/PUT /people/1
      def update
        if @person.update(person_params)
          options = { status: :ok, location: @person }
          render_json_person(@person, options)
        else
          render json: @person.errors, status: :unprocessable_entity
        end
      end

      # DELETE /people/1
      def destroy
        @person.destroy!
      end

      private

      def person_query
        Person.includes(person_jobs: [ job: :company ])
      end

      # Use callbacks to share common setup or constraints between actions.
      def set_person
        @person = person_query.where(id: params[:id])
                              .limit(1).first
      end

      # Only allow a list of trusted parameters through.
      def person_params
        params.require(:person)
              .permit(:first_name, :last_name, :nick_name, :given_name, :gender)
      end

      def render_json_person(ar_query, options = {})
        render json: ar_query.as_json(
          include: {
            person_jobs: {
              only: [ :start_date, :end_date ],
              include: {
                job: {
                  only: [ :role ],
                  include: {
                    company: { only: [ :id, :name ] }
                  }
                }
              }
            }
          },
          except: [ :updated_at, :created_at ]
        ), **options
      end
    end
  end
end

now when we test index again with:

we see significant improvements:

  • queries from over 100 down to 4 (that won’t grow)
  • response time from 450ms down to 250ms (that won’t grow much as we add records)

Here is the log/proof.

Started GET "/api/v2/people.json" for ::1 at 2024-03-31 13:07:50 +0200
Processing by Api::V2::PeopleController#index as JSON
  Person Load (4.1ms)  SELECT "people".* FROM "people"
  ↳ app/controllers/api/v3/people_controller.rb:14:in `index'
  PersonJob Load (0.9ms)  SELECT "person_jobs".* FROM "person_jobs" WHERE "person_jobs"."person_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, $28, $29, $30, $31, $32, $33, $34, $35, $36, $37, $38, $39, $40, $41, $42, $43, $44, $45, $46, $47, $48, $49, $50, $51, $52)  [["person_id", 1], ["person_id", 2], ["person_id", 3], ["person_id", 4], ["person_id", 5], ["person_id", 6], ["person_id", 7], ["person_id", 8], ["person_id", 9], ["person_id", 10], ["person_id", 11], ["person_id", 12], ["person_id", 13], ["person_id", 14], ["person_id", 15], ["person_id", 16], ["person_id", 17], ["person_id", 18], ["person_id", 19], ["person_id", 20], ["person_id", 21], ["person_id", 22], ["person_id", 23], ["person_id", 24], ["person_id", 25], ["person_id", 26], ["person_id", 27], ["person_id", 28], ["person_id", 29], ["person_id", 30], ["person_id", 31], ["person_id", 32], ["person_id", 33], ["person_id", 34], ["person_id", 35], ["person_id", 36], ["person_id", 37], ["person_id", 38], ["person_id", 39], ["person_id", 40], ["person_id", 41], ["person_id", 42], ["person_id", 43], ["person_id", 44], ["person_id", 45], ["person_id", 46], ["person_id", 47], ["person_id", 48], ["person_id", 49], ["person_id", 50], ["person_id", 51], ["person_id", 52]]
  ↳ app/controllers/api/v2/people_controller.rb:14:in `index'
  Job Load (1.4ms)  SELECT "jobs".* FROM "jobs" WHERE "jobs"."id" IN ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21)  [["id", 1], ["id", 2], ["id", 4], ["id", 3], ["id", 5], ["id", 6], ["id", 8], ["id", 10], ["id", 7], ["id", 12], ["id", 11], ["id", 16], ["id", 9], ["id", 17], ["id", 15], ["id", 13], ["id", 14], ["id", 18], ["id", 19], ["id", 20], ["id", 21]]
  ↳ app/controllers/api/v2/people_controller.rb:14:in `index'
  Company Load (1.3ms)  SELECT "companies".* FROM "companies" WHERE "companies"."id" IN ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15)  [["id", 1], ["id", 13], ["id", 12], ["id", 8], ["id", 2], ["id", 5], ["id", 6], ["id", 14], ["id", 7], ["id", 10], ["id", 3], ["id", 9], ["id", 11], ["id", 4], ["id", 15]]
  ↳ app/controllers/api/v2/people_controller.rb:14:in `index'
Completed 200 OK in 260ms (Views: 10.8ms | ActiveRecord: 182.1ms | Allocations: 59705)

cool lets wrap this up:

git add .
git commit -m "fixed n+1 query when returning nested person data"

API - V3 (virtual fields)

Sometimes we need to return calculated or other virtual attributes. We will show two approaches (Ruby methods and SQL calculations)

Using Ruby Methods

We want to include the full_name, and usual_name - both calculated fields using ruby.

Let’s add our new route for our new return:

Rails.application.routes.draw do
  resources :jobs
  resources :companies
	resources :people

	namespace :api do
	  namespace :v0 do
		  resources :people
	  end
	  namespace :v1 do
		  resources :people
	  end
	  namespace :v2 do
		  resources :people
	  end
	  namespace :v3 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 "people#index"
end

let’s add the following methods to the people model so now it would look like:

class Person < ApplicationRecord
  has_many :person_jobs, dependent: :destroy
  has_many :jobs, through: :person_jobs
  has_many :companies, through: :jobs

  normalizes :first_name, :nick_name, :last_name, :given_name,
             with: ->(value) { value.strip }

  validates :first_name,
            uniqueness: { scope: :last_name,
                          message: "first- and last-name already exists" }
  validates :first_name, presence: true
  validates :last_name, presence: true
  validates :gender, inclusion: { in: %w[male female non-binary] }

  def full_name = "#{first_name} #{last_name}"
  def usual_name = "#{nick_name || first_name} #{last_name}"
end

This will return a person, formal_name and common_name and we only return given_name still as a normal attribute

This will necessitate updating our json_rendering too - by adding:

methods: [ :usual_name, :full_name ],

to the controller’s render_json_person method:

So now the method would look like:

def render_json_person(ar_query, options = {})
  render json: ar_query.as_json(
    methods: [ :usual_name, :full_name ],
    only: [ :given_name ],
    include: {
      person_jobs: {
        only: [ :start_date, :end_date ],
        include: {
          job: {
            only: [ :role ],
            include: {
              company: { only: [ :id, :name ] }
            }
          }
        }
      }
    }
  ), **options
end

Now that we know what we want to do let’s build the controller:

mkdir app/controllers/api/v3

cat << EOF > app/controllers/api/v3/people_controller.rb
# returns nested jobs and companies for each person.
module Api
  module V3
    class PeopleController < JsonController
      before_action :set_person, only: %i[ show update destroy ]

      # GET /people
      def index
        @people = person_query.all

        render_json_person(@people)
      end

      # GET /people/1
      def show
        render_json_person(@person)
      end

      # POST /people
      def create
        @person = Person.new(person_params)

        if @person.save
          options = { status: :created, location: @person }
          render_json_person(@person, options)
        else
          render json: @person.errors, status: :unprocessable_entity
        end
      end

      # PATCH/PUT /people/1
      def update
        if @person.update(person_params)
          options = { status: :ok, location: @person }
          render_json_person(@person, options)
        else
          render json: @person.errors, status: :unprocessable_entity
        end
      end

      # DELETE /people/1
      def destroy
        @person.destroy!
      end

      private

      def person_query = Person.includes(person_jobs: [job: :company])

      # Use callbacks to share common setup or constraints between actions.
      def set_person
        @person = person_query.where(id: params[:id])
                              .limit(1).first
      end

      # Only allow a list of trusted parameters through.
      def person_params
        params.require(:person)
              .permit(:first_name, :last_name, :nick_name, :given_name, :gender)
      end

      def render_json_person(ar_query, options = {})
        render json: ar_query.as_json(
          methods: [ :usual_name, :full_name ],
          only: [ :given_name ],
          include: {
            person_jobs: {
              only: [ :start_date, :end_date ],
              include: {
                job: {
                  only: [ :role ],
                  include: {
                    company: { only: [ :id, :name ] }
                  }
                }
              }
            }
          }
        ), **options
      end
    end
  end
end
EOF

lets test:

curl http://localhost:3030/api/v3/people/7.json

{ "given_name":null,
  "usual_name":"Fred Flintstone",
  "full_name":"Fredrick Jay Flintstone",
  "person_jobs": [
    { "start_date":"1980-01-01",
      "end_date":null,
      "job": {
        "role":"crane operator",
        "company": {
          "id":5,
          "name":"Bedrock \u0026 Gravel Quarry Company"
        }
      }
    }
  ]
}

Cool this works let’s be sure we still have an efficient query for index:

curl http://localhost:3030/api/v3/people.json

let’s look at the rails log:

Started GET "/api/v3/people.json" for ::1 at 2024-03-31 19:23:07 +0200
  ActiveRecord::SchemaMigration Load (3.4ms)  SELECT "schema_migrations"."version" FROM "schema_migrations" ORDER BY "schema_migrations"."version" ASC
Processing by Api::V3::PeopleController#index as JSON
  Person Load (85.5ms)  SELECT "people".* FROM "people"
  ↳ app/controllers/api/v3/people_controller.rb:63:in `render_json_person'
  PersonJob Load (2.0ms)  SELECT "person_jobs".* FROM "person_jobs" WHERE "person_jobs"."person_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, $28, $29, $30, $31, $32, $33, $34, $35, $36, $37, $38, $39, $40, $41, $42, $43, $44, $45, $46, $47, $48, $49, $50, $51, $52)  [["person_id", 1], ["person_id", 2], ["person_id", 3], ["person_id", 4], ["person_id", 5], ["person_id", 6], ["person_id", 7], ["person_id", 8], ["person_id", 9], ["person_id", 10], ["person_id", 11], ["person_id", 12], ["person_id", 13], ["person_id", 14], ["person_id", 15], ["person_id", 16], ["person_id", 17], ["person_id", 18], ["person_id", 19], ["person_id", 20], ["person_id", 21], ["person_id", 22], ["person_id", 23], ["person_id", 24], ["person_id", 25], ["person_id", 26], ["person_id", 27], ["person_id", 28], ["person_id", 29], ["person_id", 30], ["person_id", 31], ["person_id", 32], ["person_id", 33], ["person_id", 34], ["person_id", 35], ["person_id", 36], ["person_id", 37], ["person_id", 38], ["person_id", 39], ["person_id", 40], ["person_id", 41], ["person_id", 42], ["person_id", 43], ["person_id", 44], ["person_id", 45], ["person_id", 46], ["person_id", 47], ["person_id", 48], ["person_id", 49], ["person_id", 50], ["person_id", 51], ["person_id", 52]]
  ↳ app/controllers/api/v3/people_controller.rb:63:in `render_json_person'
  Job Load (2.1ms)  SELECT "jobs".* FROM "jobs" WHERE "jobs"."id" IN ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21)  [["id", 1], ["id", 2], ["id", 4], ["id", 3], ["id", 5], ["id", 6], ["id", 8], ["id", 10], ["id", 7], ["id", 12], ["id", 11], ["id", 16], ["id", 9], ["id", 17], ["id", 15], ["id", 13], ["id", 14], ["id", 18], ["id", 19], ["id", 20], ["id", 21]]
  ↳ app/controllers/api/v3/people_controller.rb:63:in `render_json_person'
  Company Load (0.8ms)  SELECT "companies".* FROM "companies" WHERE "companies"."id" IN ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15)  [["id", 1], ["id", 13], ["id", 8], ["id", 12], ["id", 2], ["id", 5], ["id", 14], ["id", 6], ["id", 9], ["id", 11], ["id", 3], ["id", 7], ["id", 10], ["id", 4], ["id", 15]]
  ↳ app/controllers/api/v3/people_controller.rb:63:in `render_json_person'
Completed 200 OK in 232ms (Views: 1.3ms | ActiveRecord: 167.9ms | Allocations: 59603)

cool this seems to work and is quite straightforward.

Let’s snapshot this:

git add .
git commit -m "add ruby calculated values to our response"

Using SQL Calculations

Let’s see if we can fix this slow response by using SQL select to return pre-calculated and included values to our model.

With SQL we will return formal_name and common_name

Let’s add a new route for our newest breaking change:

Rails.application.routes.draw do
  resources :jobs
  resources :companies
	resources :people

	namespace :api do
	  namespace :v0 do
		  resources :people
	  end
	  namespace :v1 do
		  resources :people
	  end
	  namespace :v2 do
		  resources :people
	  end
	  namespace :v3 do
		  resources :people
	  end
	  namespace :v4 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 "people#index"
end

We will we will add our virtual calculated attributes lets add:

  • formal_name (first name and last name) CONCAT(people.first_name, ' ', people.last_name) AS formal_name
  • common_name (nick_name if available otherwise first_name and last_name)
    CASE
      WHEN people.nick_name IS NOT NULL AND people.nick_name != '' THEN
        CONCAT(people.nick_name, ' ', people.last_name)
      ELSE
        CONCAT(people.first_name, ' ', people.last_name)
    END AS common_name
    

To do this we can use:

def person_query
  Person.includes(person_jobs: [job: :company])
        .select(%{people.*,
                 CONCAT(people.first_name, ' ', people.last_name) AS formal_name,
                 CASE
                   WHEN people.nick_name IS NOT NULL AND people.nick_name != '' THEN
                     CONCAT(people.nick_name, ' ', people.last_name)
                   ELSE
                     CONCAT(people.first_name, ' ', people.last_name)
                 END AS common_name})
end

This will return a person, formal_name and common_name and we only return given_name still as a normal attribute

This will necessitate updating our json_rendering too - by adding methods: [:common_name, :formal_name]

So now the method would look like:

def render_json_person(ar_query, options = {})
  render json: ar_query.as_json(
    methods: [:common_name, :formal_name],
    only: [ :given_name ],
    include: {
      person_jobs: {
        only: [ :start_date, :end_date ],
        include: {
          job: {
            only: [ :role ],
            include: {
              company: { only: [ :id, :name ] }
            }
          }
        }
      }
    }
  ), **options
end

Now that we know what we want to do let’s build the controller:

mkdir app/controllers/api/v4

cat << EOF > app/controllers/api/v4/people_controller.rb
# returns nested jobs and companies for each person.
module Api
  module V4
    class PeopleController < JsonController
      before_action :set_person, only: %i[ show update destroy ]

      # GET /people
      def index
        @people = person_query.all

        render_json_person(@people)
      end

      # GET /people/1
      def show
        render_json_person(@person)
      end

      # POST /people
      def create
        @person = Person.new(person_params)

        if @person.save
          options = { status: :created, location: @person }
          render_json_person(@person, options)
        else
          render json: @person.errors, status: :unprocessable_entity
        end
      end

      # PATCH/PUT /people/1
      def update
        if @person.update(person_params)
          options = { status: :ok, location: @person }
          render_json_person(@person, options)
        else
          render json: @person.errors, status: :unprocessable_entity
        end
      end

      # DELETE /people/1
      def destroy
        @person.destroy!
      end

      private

      def person_query
        Person.includes(person_jobs: [job: :company])
              .select(%{people.*,
                      CONCAT(people.first_name, ' ', people.last_name) AS formal_name,
                      CASE
                        WHEN people.nick_name IS NOT NULL AND people.nick_name != '' THEN
                          CONCAT(people.nick_name, ' ', people.last_name)
                        ELSE
                          CONCAT(people.first_name, ' ', people.last_name)
                      END AS common_name})
      end

      # Use callbacks to share common setup or constraints between actions.
      def set_person
        @person = person_query.where(id: params[:id])
                              .limit(1).first
      end

      # Only allow a list of trusted parameters through.
      def person_params
        params.require(:person)
              .permit(:first_name, :last_name, :nick_name, :given_name, :gender)
      end

      def render_json_person(ar_query, options = {})
        render json: ar_query.as_json(
          methods: [:common_name, :formal_name],
          only: [ :given_name ],
          include: {
            person_jobs: {
              only: [ :start_date, :end_date ],
              include: {
                job: {
                  only: [ :role ],
                  include: {
                    company: { only: [ :id, :name ] }
                  }
                }
              }
            }
          }
        ), **options
      end
    end
  end
end
EOF

lets test:

curl http://localhost:3030/api/v3/people/7.json

{ "given_name":null,
  "common_name":"Fred Flintstone",
  "formal_name":"Fredrick Jay Flintstone",
  "person_jobs": [
    { "start_date":"1980-01-01",
      "end_date":null,
      "job": {
        "role":"crane operator",
        "company": {
          "id":5,
          "name":"Bedrock \u0026 Gravel Quarry Company"
        }
      }
    }
  ]
}

Cool this works let’s be sure we still have an efficient query for index:

curl http://localhost:3030/api/v4/people.json

let’s look at the rails log:

Started GET "/api/v4/people.json" for ::1 at 2024-03-31 19:50:10 +0200
  ActiveRecord::SchemaMigration Load (4.0ms)  SELECT "schema_migrations"."version" FROM "schema_migrations" ORDER BY "schema_migrations"."version" ASC
Processing by Api::V4::PeopleController#index as JSON
  Person Load (4.0ms)  SELECT people.*,
                  CONCAT(people.first_name, ' ', people.last_name) AS formal_name,
                  CASE
                    WHEN people.nick_name IS NOT NULL AND people.nick_name != '' THEN
                      CONCAT(people.nick_name, ' ', people.last_name)
                    ELSE
                      CONCAT(people.first_name, ' ', people.last_name)
                  END AS common_name FROM "people"
  ↳ app/controllers/api/v4/people_controller.rb:74:in `render_json_person'
  PersonJob Load (1.5ms)  SELECT "person_jobs".* FROM "person_jobs" WHERE "person_jobs"."person_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, $28, $29, $30, $31, $32, $33, $34, $35, $36, $37, $38, $39, $40, $41, $42, $43, $44, $45, $46, $47, $48, $49, $50, $51, $52)  [["person_id", 1], ["person_id", 2], ["person_id", 3], ["person_id", 4], ["person_id", 5], ["person_id", 6], ["person_id", 7], ["person_id", 8], ["person_id", 9], ["person_id", 10], ["person_id", 11], ["person_id", 12], ["person_id", 13], ["person_id", 14], ["person_id", 15], ["person_id", 16], ["person_id", 17], ["person_id", 18], ["person_id", 19], ["person_id", 20], ["person_id", 21], ["person_id", 22], ["person_id", 23], ["person_id", 24], ["person_id", 25], ["person_id", 26], ["person_id", 27], ["person_id", 28], ["person_id", 29], ["person_id", 30], ["person_id", 31], ["person_id", 32], ["person_id", 33], ["person_id", 34], ["person_id", 35], ["person_id", 36], ["person_id", 37], ["person_id", 38], ["person_id", 39], ["person_id", 40], ["person_id", 41], ["person_id", 42], ["person_id", 43], ["person_id", 44], ["person_id", 45], ["person_id", 46], ["person_id", 47], ["person_id", 48], ["person_id", 49], ["person_id", 50], ["person_id", 51], ["person_id", 52]]
  ↳ app/controllers/api/v4/people_controller.rb:74:in `render_json_person'
  Job Load (2.2ms)  SELECT "jobs".* FROM "jobs" WHERE "jobs"."id" IN ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21)  [["id", 1], ["id", 2], ["id", 4], ["id", 3], ["id", 5], ["id", 6], ["id", 8], ["id", 10], ["id", 7], ["id", 12], ["id", 11], ["id", 16], ["id", 9], ["id", 17], ["id", 15], ["id", 13], ["id", 14], ["id", 18], ["id", 19], ["id", 20], ["id", 21]]
  ↳ app/controllers/api/v4/people_controller.rb:74:in `render_json_person'
  Company Load (1.0ms)  SELECT "companies".* FROM "companies" WHERE "companies"."id" IN ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15)  [["id", 1], ["id", 13], ["id", 8], ["id", 12], ["id", 2], ["id", 5], ["id", 14], ["id", 6], ["id", 9], ["id", 11], ["id", 3], ["id", 7], ["id", 10], ["id", 4], ["id", 15]]
  ↳ app/controllers/api/v4/people_controller.rb:74:in `render_json_person'
Completed 200 OK in 240ms (Views: 10.6ms | ActiveRecord: 85.6ms | Allocations: 60733)

The overall response time is about the same: 240ms.

The good thing with the more involved approach is that we have reduced ActiveRecord time from 168ms to 86ms. with very large database returns this may provide a performance boost, but not really in this case, but it is still good to know.

Let’s snapshot this:

git add .
git commit -m "add SQL calculated values to our response"

Further DB optimization

Another approach for further speed improvements would be to return everything as an attribute, avoid creating objects altogether and rendering all the attributes manually.

As this approach is very non-rails and takes considerable effort, this should be reserved for when it is really needed.

Other APIs (Job and Company)

Note: you can follow the same logic to write controllers for Jobs and Companies.

Resources

APPENDIX

In rails we can seed sample data using the file: db/seeds.rb The data found for this sample project was found from the following links.

# db/seeds.rb

# Family Tree and jobs
# https://www.youtube.com/watch?app=desktop&v=AHWVVm_wd0s
#
# Flintstone characters and pets
# https://en.wikipedia.org/wiki/The_Flintstones
# https://www.ranker.com/list/all-the-flintstones-characters/reference

People Seed

## PEOPLE
#########
## Flintstone family
# agriculture (hillbilly)
# San Cemente Owner
zeke = Person.create!(first_name: 'Zeke', last_name: 'Flintstone', gender: 'male')
# agriculture (hillbilly)
jed = Person.create!(first_name: 'Jed', last_name: 'Flintstone', gender: 'male')

# soldier / pilot
rocky = Person.create!(first_name: 'Rockbottom', nick_name: 'Rocky', last_name: 'Flintstone', gender: 'male')

# rich uncle
giggles = Person.create!(first_name: 'Jay Giggles', nick_name: 'Uncle Giggles', last_name: 'Flintstone', gender: 'male')

# freeway traffic reporter
pops = Person.create!(first_name: 'Ed Pops', nick_name: 'Pops', last_name: 'Flintstone', gender: 'male')
# homemaker
edna = Person.create!(first_name: 'Edna', last_name: 'Flintstone', given_name: 'Hardrock', gender: 'female')

# married to wilma
# son of pops & edna (crane operator at 'Slate Rock & Gravel Company')
fred = Person.create!(first_name: 'Fredrick Jay', nick_name: 'Fred', last_name: 'Flintstone', gender: 'male')
# married to fred
# reporter & caterer & homemaker
wilma = Person.create!(first_name: 'Wilma', last_name: 'Flintstone', given_name: 'Slaghoople', gender: 'female')

# daughter of fred & wilma, married to bamm-bamm
# advertising executive
pebbles = Person.create!(first_name: 'Pebbles Wilma', nick_name: 'Pebbles', last_name: 'Rubble', given_name: 'Flintstone', gender: 'female')
# adopted brother to pebbles
stoney = Person.create!(first_name: 'Stoney', last_name: 'Flintstone', gender: 'male')


## Hardrock family
# father to Edna, Tex, Jemina (married to Lucile)
james = Person.create!(first_name: 'James', last_name: 'Hardrock', gender: 'male')
# mother to Edna, Tex, Jemina (married to James)
lucile = Person.create!(first_name: 'Lucile', last_name: 'Hardrock', given_name: 'von Stone', gender: 'female')

# sister to Tex & Edna
jemina = Person.create!(first_name: 'Jemina', last_name: 'Hardrock', gender: 'female')

# texrock rangers & rancher (town: texrock)
# brother to Edna
tex = Person.create!(first_name: 'Tex', last_name: 'Hardrock', gender: 'male')

# daughter of tex
mary = Person.create!(first_name: 'Mary Lou', last_name: 'Hardrock', gender: 'female')
# son of tex (ranch owner)
tumbleweed = Person.create!(first_name: 'Tumbleweed', last_name: 'Hardrock', gender: 'male')

## Slaghoople family
# father to Wilma, married to Pearl
ricky = Person.create!(first_name: 'Richard', nick_name: 'Ricky', last_name: 'Slaghoople', gender: 'male')
pearl = Person.create!(first_name: 'Pearl', last_name: 'Slaghoople', gender: 'female')

# wilma's sister
mica = Person.create!(first_name: 'Mica', last_name: 'Slaghoople', gender: 'female')
# wilma's sister
mickey = Person.create!(first_name: 'Michael', nick_name: 'Mickey', last_name: 'Slaghoople', gender: 'female')
# wilma's brother
michael = Person.create!(first_name: 'Jerry', last_name: 'Slaghoople', gender: 'male')

## McBricker family
brick = Person.create!(first_name: 'Brick', last_name: 'McBricker', gender: 'male')
jean = Person.create!(first_name: 'Jean', last_name: 'McBricker', gender: 'female')

# betty's bother (child of brick & jean)
# HS Basketball player
brad = Person.create!(first_name: 'Brad', last_name: 'McBricker', gender: 'male')


## Slate family
# flo's brother (lives in granite town)
# manager of 'Bedrock & Gravel Quarry Company'
mr_slate = Person.create!(first_name: 'George', nick_name: 'Mr.', last_name: 'Slate', gender: 'male')
# married to mr. slate
mrs_slate = Person.create!(first_name: 'Mrs.', last_name: 'Slate', gender: 'female')

# child of mr. slate & mrs. slate
eugene = Person.create!(first_name: 'Eugene', last_name: 'Slate', gender: 'male')
# child of mr. slate & mrs. slate
bessie = Person.create!(first_name: 'Bessie', last_name: 'Slate', gender: 'female')
# bessie's child (son)
eddie = Person.create!(first_name: 'Edward', nick_name: 'Eddie', last_name: 'Slate', gender: 'male')


## Rubble family
# married to flo
# used car salesman
bob = Person.create!(first_name: 'Robert', nick_name: 'Bob', last_name: 'Rubble', gender: 'male')
# married to bob (homemaker)
flo = Person.create!(first_name: 'Florence', nick_name: 'Flo', last_name: 'Rubble', given_name: 'Slate', gender: 'female')

# barney's brother (younger)
dusty = Person.create!(first_name: 'Dusty', last_name: 'Rubble', gender: 'male')

# married to betty (child of bob & flo)
# police officer & crane operator at 'Slate Rock & Gravel Company'
barney = Person.create!(first_name: 'Bernard Matthew', nick_name: 'Barney', last_name: 'Rubble', gender: 'male')
# married to barney, child of brick & jean
# reporter & caterer & homemaker
betty = Person.create!(first_name: 'Elizabeth Jean', nick_name: 'Betty', last_name: 'Rubble', given_name: 'McBricker', gender: 'female')

# adopted son of barney & betty (married to pebbles)
# auto mechanic, then screenwriter
bamm = Person.create!(first_name: 'Bamm-Bamm', last_name: 'Rubble', gender: 'male')
# son of bamm-bamm & pebbles
chip = Person.create!(first_name: 'Charleston Frederick', nick_name: 'Chip', last_name: 'Rubble', gender: 'male')
# daughter of bamm-bamm & pebbles
roxy = Person.create!(first_name: 'Roxann Elisabeth', nick_name: 'Roxy', last_name: 'Rubble', gender: 'female')


## The Gruesomes – A creepy but friendly family, who move in next door to the Flintstones in later seasons.
# Uncle Ghastly – The uncle of Gobby from Creepella's side of the family, who is mostly shown as a large furry hand with claws emerging from a door, a well, or a wall. His shadow was also seen in their debut episode. He wasn't named until his second appearance, which is also the only time he is heard speaking, as he is heard laughing from a well.
ghastly = Person.create!(first_name: 'Ghastly', last_name: 'Gruesome', gender: 'male')
# Weirdly Gruesome – The patriarch of the Gruesome family, who works as a reality-show host.
# reality host
weirdly = Person.create!(first_name: 'Weirdly', last_name: 'Gruesome', gender: 'male')
# Creepella Gruesome – Weirdly's tall wife.
creepella = Person.create!(first_name: 'Creepella', last_name: 'Gruesome', gender: 'female')
# Goblin "Gobby" Gruesome – Weirdly and Creepella's son.
gobby = Person.create!(first_name: 'Goblin', nick_name: 'Gobby', last_name: 'Gruesome', gender: 'male')


## The Hatrocks – A family of hillbillies, who feuded with the Flintstones' Arkanstone branch similarly to the Hatfield–McCoy feud. Fred and Barney reignite a feud with them in "The Bedrock Hillbillies", when Fred inherits San Cemente from his late great-great-uncle Zeke Flintstone and they fight over who made Zeke's portrait. The Hatrocks later return in "The Hatrocks and the Gruesomes", where they bunk with the Flintstones during their trip to Bedrock World's Fair and their antics start to annoy them as they guilt-trip Fred into extending their stay. It is also revealed that they dislike bug music. and the Flintstones, the Rubbles, and the Gruesomes are able to drive them away by performing the Four Insects song "She Said Yeah Yeah Yeah".[a] After learning that the Bedrock World's Fair would feature the Four Insects performing, they fled back to Arkanstone.
# Granny Hatrock – The mother of Jethro and grandmother of Zack and Slab.
granny = Person.create!(first_name: 'Granny', last_name: 'Hatrock', gender: 'female')
# Jethro Hatrock – The patriarch of the Hatrock Family. He had brown hair in "The Hatrocks and the Flintstones" and taupe-gray hair in "The Hatrocks and the Gruesomes".
jethro = Person.create!(first_name: 'Jethro', last_name: 'Hatrock', gender: 'male')
# Gravella Hatrock – Jethro's wife.
gravella = Person.create!(first_name: 'Gravella', last_name: 'Hatrock', gender: 'female')
# Zack Hatrock – Jethro and Gravella's oldest son.
zack = Person.create!(first_name: 'Zack', last_name: 'Hatrock', gender: 'male')
# Slab Hatrock – The youngest son of Jethro and Gravella.
slab = Person.create!(first_name: 'Slab', last_name: 'Hatrock', gender: 'male')
# Benji Hatrock – Jethro's son-in-law.
benji = Person.create!(first_name: 'Benji', last_name: 'Hatrock', gender: 'male')

## others
# Friend to Barney & Fred (fire chief)
joe = Person.create!(first_name: 'Joseph', nick_name: 'Joe', last_name: 'Rockhead', gender: 'male')

# paperboy (town: bedrock)
arnold = Person.create!(first_name: 'Arnold', last_name: 'Granite', gender: 'male')

stoney = Person.create!(first_name: 'Stoney', last_name: 'Curtis', gender: 'male')
perry = Person.create!(first_name: 'Perry', last_name: 'Masonry', gender: 'male')

# Sam Slagheap – The Grand Poobah of the Water Buffalo Lodge.
sam = Person.create!(first_name: 'Samuel', nick_name: 'Sam', last_name: 'Slagheap', gender: 'male')

Company Seeds

## Companies
san_cemente = Company.create!(name: 'San Cemente')
bedrock_news = Company.create!(name: 'Bedrock Daily News')
bedrock_police = Company.create!(name: 'Bedrock Police Department')
bedrock_fire = Company.create!(name: 'Bedrock Fire Department')
bedrock_quarry = Company.create!(name: 'Bedrock & Gravel Quarry Company')
betty_wilma_catering = Company.create!(name: 'Betty & Wilma Catering')
texrock_ranch = Company.create!(name: 'Texrock Ranch')
teradactyl = Company.create!(name: 'Teradactyl Flights')
auto_repair = Company.create!(name: 'Bedrock Auto Repair')
used_cars = Company.create!(name: 'Bedrock Used Cars')
bedrock_entetainment = Company.create!(name: 'Bedrock Entertainment')
bedrock_army = Company.create!(name: 'Bedrock Army')
independent = Company.create!(name: 'Independent')
advertising = Company.create!(name: 'Bedrock Advertising')
buffalo_lodge = Company.create!(name: 'Water Buffalo Lodge')

Jobs Seeds

## Jobs
## San Cemente Owner
cemente = Job.create!(role: 'owner', company: san_cemente)
# agriculture
farmer = Job.create!(role: 'farmer', company: independent)
# pilot
pilot = Job.create!(role: 'pilot', company: teradactyl)
# soldier
soldier = Job.create!(role: 'soldier', company: bedrock_army)
# wealthy
wealth = Job.create!(role: 'independently wealthy', company: independent)
# reporter
traffic = Job.create!(role: 'traffice reporter', company: bedrock_news)
reporter = Job.create!(role: 'news reporter', company: bedrock_news)
# homemaker
homemaker = Job.create!(role: 'homemaker', company: independent)
# mining company manager
manager = Job.create!(role: 'manager', company: bedrock_quarry)
# crane operator
crane = Job.create!(role: 'crane operator', company: bedrock_quarry)
# advertising executive
advertising = Job.create!(role: 'advertising executive', company: advertising)
# caterer
caterer = Job.create!(role: 'caterer', company: betty_wilma_catering)
# auto mechanic
mechanic = Job.create!(role: 'auto mechanic', company: auto_repair)
# screenwriter
screenwriter = Job.create!(role: 'screenwriter', company: bedrock_entetainment)
# police officer
police = Job.create!(role: 'police officer', company: bedrock_police)
# rancher
rancher = Job.create!(role: 'rancher', company: texrock_ranch)
# used car salesman
salesman = Job.create!(role: 'used car salesman', company: used_cars)
# reality show host
host = Job.create!(role: 'reality show host', company: bedrock_entetainment)
# fire chief
fire_chief = Job.create!(role: 'fire chief', company: bedrock_fire)
# paperboy
paper_delivery = Job.create!(role: 'paperboy', company: bedrock_news)
# Grand Poobah
grand_poobah = Job.create!(role: 'The Grand Poobah', company: buffalo_lodge)

PersonJobs Seeds

## Person Jobs
# zeke - San Cemente Owner
PersonJob.create!(person: zeke, job: cemente, start_date: Date.new(1980, 1, 1))
# jed - farmer
PersonJob.create!(person: jed, job: farmer, start_date: Date.new(1980, 1, 1))
# rocky - ww1 soldier
PersonJob.create!(person: rocky, job: soldier, start_date: Date.new(1980, 1, 1), end_date: Date.new(1985, 12, 31))
# rocy - pilot after war
PersonJob.create!(person: rocky, job: pilot, start_date: Date.new(1986, 1, 1))

# giggles rich uncle
PersonJob.create!(person: giggles, job: wealth, start_date: Date.new(1980, 1, 1))

# pops - freeway traffic reporter
PersonJob.create!(person: pops, job: traffic, start_date: Date.new(1980, 1, 1))
# edna - homemaker
PersonJob.create!(person: edna, job: homemaker, start_date: Date.new(1980, 1, 1))

# fred - crane operator
PersonJob.create!(person: fred, job: crane, start_date: Date.new(1980, 1, 1))
# married to fred
# wilma - reporter & caterer & homemaker
PersonJob.create!(person: wilma, job: reporter, start_date: Date.new(1980, 1, 1), end_date: Date.new(1989, 12, 31))
PersonJob.create!(person: wilma, job: caterer, start_date: Date.new(1990, 1, 1))
PersonJob.create!(person: wilma, job: homemaker, start_date: Date.new(1980, 1, 1))

# pebbles - advertising executive
PersonJob.create!(person: pebbles, job: advertising, start_date: Date.new(1995, 1, 1))

# texrock rangers & rancher (town: texrock)
PersonJob.create!(person: tex, job: rancher, start_date: Date.new(2080, 1, 1))

# mr_slate - manager
PersonJob.create!(person: mr_slate, job: manager, start_date: Date.new(1980, 1, 1))


## Rubble family
# bob - used car salesman
PersonJob.create!(person: bob, job: salesman, start_date: Date.new(1980, 1, 1))
# flo - (homemaker)
PersonJob.create!(person: flo, job: homemaker, start_date: Date.new(1980, 1, 1))

# police officer & crane operator at 'Slate Rock & Gravel Company'
PersonJob.create!(person: barney, job: police, start_date: Date.new(1980, 1, 1), end_date: Date.new(1989, 12, 31))
PersonJob.create!(person: barney, job: crane, start_date: Date.new(1990, 1, 1))

# betty - reporter & caterer & homemaker
PersonJob.create!(person: betty, job: reporter, start_date: Date.new(1980, 1, 1), end_date: Date.new(1989, 12, 31))
PersonJob.create!(person: betty, job: caterer, start_date: Date.new(1990, 1, 1))
PersonJob.create!(person: betty, job: homemaker, start_date: Date.new(1980, 1, 1))

# bamm-bamm - auto mechanic, then screenwriter
PersonJob.create!(person: bamm, job: mechanic, start_date: Date.new(1995, 1, 1), end_date: Date.new(1999, 12, 31))
PersonJob.create!(person: bamm, job: screenwriter, start_date: Date.new(2000, 1, 1))


## The Gruesomes
# weirdly - reality host
PersonJob.create!(person: weirdly, job: host, start_date: Date.new(1990, 1, 1))

## others
# joe - fire chief)
PersonJob.create!(person: joe, job: fire_chief, start_date: Date.new(1980, 1, 1))

# paperboy (town: bedrock)
PersonJob.create!(person: arnold, job: paper_delivery, start_date: Date.new(1980, 1, 1))

# Sam Slagheap – The Grand Poobah of the Water Buffalo Lodge.
PersonJob.create!(person: sam, job: grand_poobah, start_date: Date.new(1985, 1, 1))
Bill Tihen
Bill Tihen
Developer, Data Enthusiast, Educator and Nature’s Friend

very curious – known to explore knownledge and nature