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.
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
}
]
}
}
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.
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. ..
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. ..
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
.
Let's implement it. For the content to be implemented, use the example of the GraphQL official page used so far.
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
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'
Thank you for reading to the end: bow: Tomorrow is an article by VISITS Engineering Manager @kotala_b. looking forward to! !!
Recommended Posts