[RUBY] Delegated type added in Rails 6.1 and Union types of GraphQL seem to be compatible, so I tried it

My name is @ ham0215 and I'm a manager and backend engineer at VISITS Technologies.

This is the third article on this year's Advent calendar. I was wondering what kind of article to write until just before, but @ woods0918 posted GraphQL article just before, so I also followed the flow and made it a GraphQL article. It was.

I thought that the delegated type added in Rails 6.1 and the Union types of GraphQL seemed to be compatible, so I actually touched it and checked the usability.

What are GraphQL Union types?

GraphQL has a type called Union types. It is listed below on the official website. https://graphql.org/learn/schema/#union-types

Union types can be used to represent a combination of multiple types. In this article, we will use the sample from the official website as it is.

{
  search(text: "an") {
    __typename
    ... on Human {
      name
      height
    }
    ... on Droid {
      name
      primaryFunction
    }
    ... on Starship {
      name
      length
    }
  }
}

When you execute the search query, a list of three types, Human, Droid, and Starship, is returned. The name attribute is common to all types, but it has its own attributes of height for Human, primaryFunction for Droid, and length for Starship.

The execution result is as follows. Since the parameter text:'an' is attached, we are searching for data that contains'an' in name. Human and Starship data are mixed in data (Droid seems to have no data with'an')

{
  "data": {
    "search": [
      {
        "__typename": "Human",
        "name": "Han Solo",
        "height": 1.8
      },
      {
        "__typename": "Human",
        "name": "Leia Organa",
        "height": 1.5
      },
      {
        "__typename": "Starship",
        "name": "TIE Advanced x1",
        "length": 9.2
      }
    ]
  }
}

What is delegated type

It is a function implemented by the following pull request. It seems that DHH has been pulling himself out and has entered Rails 6.1. https://github.com/rails/rails/pull/39341

As for the contents, sample code is also attached to the explanation of pull request, so it is easier to understand if you read it, but in a word, it is a function that makes it easier to express the inheritance relationship of the table. ... but I think it's hard to get an image, so I'll explain using the same example as Union types. To retain the data in this example, you can use one table or multiple tables. I will explain while checking what kind of table structure each will have.

Express in one table

When expressing with one table, I think that it will be the following table.

Field Type Null
id bigint(20) NO
name varchar(255) NO
type tinyint(4) NO
height float YES
primary_function varchar(255) YES
length float YES

With this configuration, it is easy to get good performance because you only have to read this table when searching across types. However, since the attributes (height, primary_function, length) for each type are optional items, for example, in the case of Human type, the height column is indispensable, and it is necessary to secure the control that the others are null. ..

Express in multiple tables

When expressing with one table, the restrictions on the RDB side will inevitably become loose. One way to avoid this is to create a table for each type.

Field Type Null
id bigint(20) NO
name varchar(255) NO
height float NO
Field Type Null
id bigint(20) NO
name varchar(255) NO
primary_function varchar(255) NO
Field Type Null
id bigint(20) NO
name varchar(255) NO
length float NO

Unlike the case of one table, the table is divided for each type, so the type column is unnecessary. In addition, the required attributes (height, primary_function, length) for each type can also be required columns, so it can be controlled by RDB constraints.

There are many merits from the viewpoint of saving data, but when retrieving data, it is necessary to refer to 3 tables. Especially when retrieving mixed data as in the first example, it is necessary to use the UNION clause of SQL, or to acquire the data separately and combine them in the processing on the application side, which may result in poor performance. ..

Good points of one table and multiple tables

If you use the method described in "Representing with multiple tables", you will have a hard time getting data with mixed types. I will make another table to solve this. A table that has a char_type and an id to each table. For example, if char_type is Human, char_id will be humans.id. Since we may search by name, which is a common item, we have added the name attribute to this table. Since it will be double-managed, delete the name from each table.

Field Type Null
id bigint(20) NO
name varchar(255) NO
char_type tinyint(4) NO
char_id bigint(20) NO
Field Type Null
id bigint(20) NO
height float NO
Field Type Null
id bigint(20) NO
primary_function varchar(255) NO
Field Type Null
id bigint(20) NO
length float NO

By creating a table one level higher in this way, you can read this table to get mixed data. You can also use RDB constraints as mandatory constraints for each type.

The function that makes it easy to handle such table structure data from Rails is delegated type.

Implementation

Let's implement it. For the content to be implemented, use the example of the GraphQL official page used so far.

version

The major library versions at the time of writing this article are:

migration Migrate according to the example. Since it is troublesome, I made it into one file, but if you want to do it properly, I think it is better to separate it for each table. The column that stores the type (char_type in this example) contains a character string such as'Human'or'Droid', so define it as a string.

db/migrate/20201215031635_create_table_delegated_type.rb


class CreateTableDelegatedType < ActiveRecord::Migration[6.1]
  def change
    create_table :characters do |t|
      t.string :name, null: false
      t.string :char_type, null: false, limit: 10
      t.bigint :char_id, null: false

      t.timestamps
    end

    create_table :humans do |t|
      t.float :height, null: false

      t.timestamps
    end

    create_table :droids do |t|
      t.string :primary_function, null: false

      t.timestamps
    end

    create_table :starships do |t|
      t.float :length, null: false

      t.timestamps
    end
  end
end

The table is created as shown below.

> desc characters;
+----------------+--------------+------+-----+---------+----------------+
| Field          | Type         | Null | Key | Default | Extra          |
+----------------+--------------+------+-----+---------+----------------+
| id             | bigint(20)   | NO   | PRI | NULL    | auto_increment |
| name           | varchar(255) | NO   |     | NULL    |                |
| char_type      | varchar(10)  | NO   |     | NULL    |                |
| char_id        | bigint(20)   | NO   |     | NULL    |                |
| created_at     | datetime(6)  | NO   |     | NULL    |                |
| updated_at     | datetime(6)  | NO   |     | NULL    |                |
+----------------+--------------+------+-----+---------+----------------+

> desc droids;
+------------------+--------------+------+-----+---------+----------------+
| Field            | Type         | Null | Key | Default | Extra          |
+------------------+--------------+------+-----+---------+----------------+
| id               | bigint(20)   | NO   | PRI | NULL    | auto_increment |
| primary_function | varchar(255) | NO   |     | NULL    |                |
| created_at       | datetime(6)  | NO   |     | NULL    |                |
| updated_at       | datetime(6)  | NO   |     | NULL    |                |
+------------------+--------------+------+-----+---------+----------------+

> desc humans;
+------------+-------------+------+-----+---------+----------------+
| Field      | Type        | Null | Key | Default | Extra          |
+------------+-------------+------+-----+---------+----------------+
| id         | bigint(20)  | NO   | PRI | NULL    | auto_increment |
| height     | float       | NO   |     | NULL    |                |
| created_at | datetime(6) | NO   |     | NULL    |                |
| updated_at | datetime(6) | NO   |     | NULL    |                |
+------------+-------------+------+-----+---------+----------------+

> desc starships;
+------------+-------------+------+-----+---------+----------------+
| Field      | Type        | Null | Key | Default | Extra          |
+------------+-------------+------+-----+---------+----------------+
| id         | bigint(20)  | NO   | PRI | NULL    | auto_increment |
| length     | float       | NO   |     | NULL    |                |
| created_at | datetime(6) | NO   |     | NULL    |                |
| updated_at | datetime(6) | NO   |     | NULL    |                |
+------------+-------------+------+-----+---------+----------------+

models Create a model.

First, define the Character model of the upper hierarchy. Set the required information in delegated_type. :char Define the xxx part of xxx_type and xxx_id used for the relationship with each table. This time, since it is char_type and char_id, : char is set.

types Specify the corresponding model.

dependent Defines the behavior of related tables when characters are deleted. Same as the association settings.

app/models/character.rb


class Character < ApplicationRecord
  delegated_type :char, types: %w[Human Droid Starship], dependent: :destroy
end

Create a module to be included in each type of model. We have defined a relation to the Character model (has_one) and a delegate for the common item (name).

app/models/concerns/char.rb


module Char
  extend ActiveSupport::Concern

  included do
    has_one :character, as: :char, touch: true, dependent: :destroy
    delegate :name, to: :character
  end
end

Make a model corresponding to each type. Include the Char module.

app/models/human.rb


class Human < ApplicationRecord
  #Because it refers to the humen table
  self.table_name = "humans"
  include Char
end

app/models/droid.rb


class Doroid < ApplicationRecord
  include Char
end

app/models/starship.rb


class Starship < ApplicationRecord
  include Char
end

CRUD Now that I've created the model, I tried CRUD to Human.

Create An insert is done into humans and characters. It seems to be executed in the same transaction to maintain consistency. I'm selecting to characters on the way, but I think this is a duplicate check.

irb(main)> Character.create!(name: 'Han Solo', char: Human.new(height: 1.8))
  TRANSACTION (0.3ms)  BEGIN
  Human Create (0.4ms)  INSERT INTO `humans` (`height`, `created_at`, `updated_at`) VALUES (1.8, '2020-12-15 05:26:45.730884', '2020-12-15 05:26:45.730884')
  Character Load (0.5ms)  SELECT `characters`.* FROM `characters` WHERE `characters`.`char_id` = 1 AND `characters`.`char_type` = 'Human' LIMIT 1
  Character Create (0.3ms)  INSERT INTO `characters` (`name`, `char_type`, `char_id`, `created_at`, `updated_at`) VALUES ('Han Solo', 'Human', 1, '2020-12-15 05:26:45.833648', '2020-12-15 05:26:45.833648')
  TRANSACTION (1.8ms)  COMMIT
=> #<Character id: 1, name: "Han Solo", char_type: "Human", char_id: 1, created_at: "2020-12-15 05:26:45.833648000 +0000", updated_at: "2020-12-15 05:26:45.833648000 +0000">

Read

You will be able to access each type from an object in the Character model in the same way as an association. When I specified a different type, nil was returned. You will also be able to use methods that determine the type, such as human ?.

irb(main)> char = Character.first
  Character Load (0.9ms)  SELECT `characters`.* FROM `characters` ORDER BY `characters`.`id` ASC LIMIT 1
=> #<Character id: 1, name: "Han Solo", char_type: "Human", char_id: 1, created_at: "2020-12-15 05:26:45.833648000 +0000", updated_at: "2020-12-15 05:26:45.833648000 +0000">
irb(main)> char.human?
=> true
irb(main)> char.human
  Human Load (0.7ms)  SELECT `humans`.* FROM `humans` WHERE `humans`.`id` = 1 LIMIT 1
=> #<Human id: 1, height: 1.8, created_at: "2020-12-15 05:26:45.730884000 +0000", updated_at: "2020-12-15 05:26:45.730884000 +0000">
irb(main)> char.droid?
=> false
irb(main)> char.droid
=> nil
irb(main)> char.char_name
=> "human"

Update

If you modify the items in the Character model, only the characters table will be updated.

irb(main)> char.name = 'Han Soloooooo'
=> "Han Soloooooo"
irb(main)> char.save!
  TRANSACTION (0.5ms)  BEGIN
  Character Update (2.7ms)  UPDATE `characters` SET `characters`.`name` = 'Han Soloooooo', `characters`.`updated_at` = '2020-12-15 05:42:12.659673' WHERE `characters`.`id` = 1
  TRANSACTION (2.3ms)  COMMIT
=> true

Modifying the Human model item also updates updated_at in the characters table. This is because touch: true is added to has_one of char module rather than the function of delegated type.

irb(main)> char.human.height = 2.1
=> 2.1
irb(main)> char.human.save!
  TRANSACTION (0.3ms)  BEGIN
  Human Update (0.6ms)  UPDATE `humans` SET `humans`.`height` = 2.1, `humans`.`updated_at` = '2020-12-15 05:42:30.095070' WHERE `humans`.`id` = 1
  Character Load (0.9ms)  SELECT `characters`.* FROM `characters` WHERE `characters`.`char_id` = 1 AND `characters`.`char_type` = 'Human' LIMIT 1
  Character Update (0.5ms)  UPDATE `characters` SET `characters`.`updated_at` = '2020-12-15 05:42:30.103572' WHERE `characters`.`id` = 1
  TRANSACTION (2.5ms)  COMMIT
=> true

Delete

Removed the Character model. Since dependent:: destroy is specified, the related Starship model has also been deleted.

irb(main)> a = Character.last
  Character Load (0.5ms)  SELECT `characters`.* FROM `characters` ORDER BY `characters`.`id` DESC LIMIT 1
=> #<Character id: 3, name: "TIE Advanced x1", char_type: "Starship", char_id: 1, created_at: "2020-12-15 05:29:53.110094000 +0000", updated_at: "2020-12-15 05:29:53.110094000 +0000">
irb(main)> a.destroy
  TRANSACTION (0.3ms)  BEGIN
  Character Destroy (1.0ms)  DELETE FROM `characters` WHERE `characters`.`id` = 3
  Starship Load (0.5ms)  SELECT `starships`.* FROM `starships` WHERE `starships`.`id` = 1 LIMIT 1
  Character Load (1.0ms)  SELECT `characters`.* FROM `characters` WHERE `characters`.`char_id` = 1 AND `characters`.`char_type` = 'Starship' LIMIT 1
  Starship Destroy (0.9ms)  DELETE FROM `starships` WHERE `starships`.`id` = 1
  TRANSACTION (3.9ms)  COMMIT
=> #<Character id: 3, name: "TIE Advanced x1", char_type: "Starship", char_id: 1, created_at: "2020-12-15 05:29:53.110094000 +0000", updated_at: "2020-12-15 05:29:53.110094000 +0000">

Removed the Droid model. Since dependent:: destroy is specified, the related Character model has also been deleted.

irb(main)> b = Character.last
  Character Load (0.7ms)  SELECT `characters`.* FROM `characters` ORDER BY `characters`.`id` DESC LIMIT 1
=> #<Character id: 2, name: "C-3PO", char_type: "Droid", char_id: 1, created_at: "2020-12-15 05:29:45.752745000 +0000", updated_at: "2020-12-15 05:29:45.752745000 +0000">
irb(main)> b.droid.destroy
  Droid Load (0.7ms)  SELECT `droids`.* FROM `droids` WHERE `droids`.`id` = 1 LIMIT 1
  TRANSACTION (0.4ms)  BEGIN
  Character Load (0.6ms)  SELECT `characters`.* FROM `characters` WHERE `characters`.`char_id` = 1 AND `characters`.`char_type` = 'Droid' LIMIT 1
  Character Destroy (0.7ms)  DELETE FROM `characters` WHERE `characters`.`id` = 2
  Droid Destroy (0.7ms)  DELETE FROM `droids` WHERE `droids`.`id` = 1
  TRANSACTION (2.2ms)  COMMIT
=> #<Droid id: 1, primary_function: "talk", created_at: "2020-12-15 05:29:45.744823000 +0000", updated_at: "2020-12-15 05:29:45.744823000 +0000">

GraphQL From here, we will implement GraphQL search queries.

First, make a type for each model.

app/graphql/types/human_type.rb


module Types
  class HumanType < BaseObject
    field :id, ID, null: false
    field :name, String, null: false
    field :height, Float, null: false
  end
end

app/graphql/types/droid_type.rb


module Types
  class DroidType < BaseObject
    field :id, ID, null: false
    field :name, String, null: false
    field :primary_function, String, null: false
  end
end

app/graphql/types/starship_type.rb


module Types
  class StarshipType < BaseObject
    field :id, ID, null: false
    field :name, String, null: false
    field :length, Float, null: false
  end
end

Create a Character type that summarizes the above. The Union type is finally here. Specifies the types that appear in possible_types. self.resolve_type returns the object corresponding to the type determination method.

app/graphql/types/character_type.rb


module Types
  class CharacterType < Types::BaseUnion
    possible_types Types::HumanType, Types::DroidType, Types::StarshipType

    def self.resolve_type(object, context)
      if object.human?
        [Types::HumanType, object.human]
      elsif object.droid?
        [Types::DroidType, object.droid]
      elsif object.starship?
        [Types::StarshipType, object.starship]
      end
    end
  end
end

Next is the query type. I often write resolver in another class with query_type.rb, so I define SearchResolver in another class this time as well. When text is specified as in the example, partial match search is performed by name.

app/graphql/resolvers/search_resolver.rb


module Resolvers
  class SearchResolver < BaseResolver
    type Types::CharacterType.connection_type, null: false

    argument :text, String, required: false

    def resolve(text: nil)
      text.nil? ? Character.all : Character.where('name like ?', "%#{text}%")
    end
  end
end

app/graphql/types/query_type.rb


module Types
  class QueryType < Types::BaseObject
    field :search, resolver: Resolvers::SearchResolver
  end
end

Run

Implementation is complete up to this point. Let's run it right away.

First, search all items.

{
  search {
    edges {
      node {
        ... on Human {
          name
          height
        }
        ... on Droid {
          name
          primaryFunction
        }
        ... on Starship {
          name
          length
        }
      }
    }
  }
}

Each type was mixed and returned as shown below.

{
  "data": {
    "search": {
      "edges": [
        {
          "node": {
            "name": "Han Solo",
            "height": 1.8
          }
        },
        {
          "node": {
            "name": "C-3PO",
            "primaryFunction": "talk"
          }
        },
        {
          "node": {
            "name": "TIE Advanced x1",
            "length": 9.2
          }
        },
        {
          "node": {
            "name": "Han Soloooo",
            "height": 1.8
          }
        }
      ]
    }
  }
}

Next, try narrowing down by text.

{
  search(text: "an") {
    edges {
      node {
        ... on Human {
          name
          height
        }
        ... on Droid {
          name
          primaryFunction
        }
        ... on Starship {
          name
          length
        }
      }
    }
  }
}

Only data that properly contains "an" is now returned.

{
  "data": {
    "search": {
      "edges": [
        {
          "node": {
            "name": "Han Solo",
            "height": 1.8
          }
        },
        {
          "node": {
            "name": "TIE Advanced x1",
            "length": 9.2
          }
        }
        {
          "node": {
            "name": "Han Soloooo",
            "height": 1.8
          }
        }
      ]
    }
  }
}

Finally, let's check the query. Select to the corresponding table (humans, droids, starships) is executed one by one. Also, it seems that select to the characters table is working when getting the common item name. It's exactly N + 1 hell ... If this is the case, it will be difficult to use in practice, so I investigated whether it could be read ahead.

  Character Load (0.8ms)  SELECT `characters`.* FROM `characters`
  ↳ app/controllers/graphql_controller.rb:13:in `execute'
  Human Load (0.7ms)  SELECT `humans`.* FROM `humans` WHERE `humans`.`id` = 1 LIMIT 1
  ↳ app/graphql/types/character_type.rb:7:in `resolve_type'
  Character Load (0.7ms)  SELECT `characters`.* FROM `characters` WHERE `characters`.`char_id` = 1 AND `characters`.`char_type` = 'Human' LIMIT 1
  ↳ app/models/concerns/char.rb:6:in `name'
  Droid Load (0.7ms)  SELECT `droids`.* FROM `droids` WHERE `droids`.`id` = 1 LIMIT 1
  ↳ app/graphql/types/character_type.rb:9:in `resolve_type'
  Character Load (0.6ms)  SELECT `characters`.* FROM `characters` WHERE `characters`.`char_id` = 1 AND `characters`.`char_type` = 'Droid' LIMIT 1
  ↳ app/models/concerns/char.rb:6:in `name'
  Starship Load (0.5ms)  SELECT `starships`.* FROM `starships` WHERE `starships`.`id` = 1 LIMIT 1
  ↳ app/graphql/types/character_type.rb:11:in `resolve_type'
  Character Load (0.4ms)  SELECT `characters`.* FROM `characters` WHERE `characters`.`char_id` = 1 AND `characters`.`char_type` = 'Starship' LIMIT 1
  ↳ app/models/concerns/char.rb:6:in `name'
  Human Load (0.5ms)  SELECT `humans`.* FROM `humans` WHERE `humans`.`id` = 2 LIMIT 1
  ↳ app/graphql/types/character_type.rb:7:in `resolve_type'
  Character Load (0.7ms)  SELECT `characters`.* FROM `characters` WHERE `characters`.`char_id` = 2 AND `characters`.`char_type` = 'Human' LIMIT 1
  ↳ app/models/concerns/char.rb:6:in `name'

I tried refactoring to preload when resolving with SearchResolver. At first, I thought that I could just use preload (: char), but since select was issued in the name acquisition process that refers to the Character model from each model, I also added : character.

app/graphql/resolvers/search_resolver.rb


    def resolve(text: nil)
-     text.nil? ? Character.all : Character.where('name like ?', "%#{text}%")
+     chars = Character.all
+     chars = chars.where('name like ?', "%#{text}%") if text
+     chars.preload(char: :character)
    end

It is now acquired collectively as shown below. (It is hard to see because there are many queries, but humans with two data are now acquired at once)

  Character Load (0.5ms)  SELECT `characters`.* FROM `characters`
  ↳ app/controllers/graphql_controller.rb:13:in `execute'
  Human Load (0.7ms)  SELECT `humans`.* FROM `humans` WHERE `humans`.`id` IN (1, 2)
  ↳ app/controllers/graphql_controller.rb:13:in `execute'
  Droid Load (0.3ms)  SELECT `droids`.* FROM `droids` WHERE `droids`.`id` = 1
  ↳ app/controllers/graphql_controller.rb:13:in `execute'
  Starship Load (0.4ms)  SELECT `starships`.* FROM `starships` WHERE `starships`.`id` = 1
  ↳ app/controllers/graphql_controller.rb:13:in `execute'
  Character Load (0.4ms)  SELECT `characters`.* FROM `characters` WHERE `characters`.`char_type` = 'Human' AND `characters`.`char_id` IN (1, 2)
  ↳ app/controllers/graphql_controller.rb:13:in `execute'
  Character Load (0.5ms)  SELECT `characters`.* FROM `characters` WHERE `characters`.`char_type` = 'Droid' AND `characters`.`char_id` = 1
  ↳ app/controllers/graphql_controller.rb:13:in `execute'
  Character Load (0.9ms)  SELECT `characters`.* FROM `characters` WHERE `characters`.`char_type` = 'Starship' AND `characters`.`char_id` = 1
  ↳ app/controllers/graphql_controller.rb:13:in `execute'

Finally

Thank you for reading to the end: bow: Tomorrow is an article by VISITS Engineering Manager @kotala_b. looking forward to! !!

Recommended Posts

Delegated type added in Rails 6.1 and Union types of GraphQL seem to be compatible, so I tried it
I tried to implement Ajax processing of like function in Rails
I tried to verify whether it would be fun to combine "programming" and "hobbies".
I tried to organize the session in Rails
First AWS Lambda (I tried to see what kind of environment it works in)
I introduced Docker to Rails 6, so I summarized it (beginner)
I finished watching The Rose of Versailles, so I tried to reproduce the ending song in Java
I tried to summarize the basics of kotlin and java
I tried to verify this and that of Spring @ Transactional
I tried to convert a string to a LocalDate type in Java
I tried to make a client of RESAS-API in Java
[Rails] Where to be careful in the description of validation
[Rails] I tried to implement "Like function" using rails and js
I tried to make the "Select File" button of the sample application created in the Rails tutorial cool
[Webpacker] Summary of how to install Bootstrap and jQuery in Rails 6.0
I tried to write code like a type declaration in Ruby
How to delete large amounts of data in Rails and concerns
I tried to summarize the methods of Java String and StringBuilder
[Rails] I tried to summarize the passion and functions of the beginners who created the share house search site!
I don't really understand the difference between swift Error and NSError, so I tried to summarize it myself.
I separated the same description as a method under private in Rails controller and implemented it so that it can be called by before_action
What I did when I was addicted to the error "Could not find XXX in any of the sources" when I added a Gem and built it