Skip to content
Rails Developer Meetup 2018 Day 1「それPostgreSQLでできるよ」の発表資料とコード
TSQL PLpgSQL JavaScript Shell
Branch: master
Clone or download
Fetching latest commit…
Cannot retrieve the latest commit at this time.
Permalink
Type Name Latest commit message Commit time
Failed to load latest commit information.
.gitignore
NOTE.md
README.md
docker-compose.yml
fujimura.png
hikifune.png
imaz.png
insert_parks.sql
insert_stations.sql
knu.png
machimachi.png
materialized_view.sql
package.json
park_count_ranking.sql
parks.png
setup.sh
shinjuku_polygons.png
shinjuku_polygons_with_name.png
shinookubo.png
st_distance.sql
stations_and_parks.png
tamagawagakuenmae.png
trigger.sql
watch.js
window1.sql
window2.sql
yarn.lock

README.md

title subtitle author date
それPostgreSQLでできるよ
@Rails Developer Meetup 2018 Day 1
Daisuke Fujimura
2018-03-24

この発表について

  • PostgreSQLには便利な機能がたくさんあります。業務の中で「これできないかな?」と思って調べると関数や拡張が用意されていた、ということも多々ありました。ActiveRecordおよび標準SQLを使って仕事をしていると見えてこない「レールの外」のPostgreSQLの世界をご紹介しようと思います。
  • PostgreSQLの便利な機能をそれぞれユースケースを交えて解説します。
  • 原則、AWS RDS、Google Cloud SQL for PosterSQLで使える機能のみ紹介します。
  • 利用したPostgreSQLのバージョンは10.1、PostGISのバージョンは2.4.3です。
  • 発表で登場するコード例は https://github.com/fujimura/railsdm_2018_postgresql にあります。

自己紹介

藤村大介

<img src="./fujimura.png"/ style='width: 60px'>

ご近所SNSマチマチを運営する株式会社マチマチのCTO。Railsは2.2位から仕事で使っている。フロントエンド開発も得意。

  • twitter.com/ffu_
  • fujimuradaisuke.com

SQL歴は足掛け10年程度。PostgreSQL歴は4年程度。内部構造の詳しい知識などはありません。

マチマチについて(1)

<img src="./machimachi.png"/ style='width: 95%; height: 90%'>

マチマチについて(2)

チーム

<img src="./fujimura.png"/ style='width: 60px'> @fujimura <img src="./knu.png"/ style='width: 60px'> @knu <img src="./imaz.png"/ style='width: 60px'> @imaz(フリーランス)

テクノロジー

  • バックエンド:Rails, PostgreSQL
  • フロントエンド:React, Flowtype
  • ネイティブアプリ:これからReact Nativeで作る

エンジニア募集中です!藤村まで気軽にお声かけ下さい。

Window関数(1)

最近MySQLにも入ったWindow関数ですが皆さん使っていますか?データ分析ではよく使いますが、アプリケーション開発ではあまり使わないかもしれません。

Window関数(1)やりたいこと

このテーブルの各行のpositionを重複なしタイムスタンプ順で振りなおしたい

  name | position |         updated_at
------+----------+----------------------------
 A    |        1 | 2018-03-21 22:17:49.215978
 B    |        2 | 2018-03-21 22:17:49.215978
 F    |        3 | 2018-03-23 10:17:49.220138
 C    |        3 | 2018-03-21 22:17:49.215978
 D    |        4 | 2018-03-21 22:17:49.215978
 E    |        5 | 2018-03-21 22:17:49.215978

Window関数(1)そもそもWindow関数とは

Window関数は「現在の行」の情報を使った値を計算する機能です。例えばROW_NUMBERを使うと、特定のカラムでソートした際の現在の行番号がわかります。

例えばさきほどのテーブルにこのクエリを実行すると

SELECT name, position,
       ROW_NUMBER() OVER ( ORDER BY position, updated_at DESC) AS i
FROM categories;

positionupdated_atの順にソートした場合の行番号が取得できます。

 name | position | i |         updated_at
------+----------+---+----------------------------
 A    |        1 | 1 | 2018-03-21 22:17:49.215978
 B    |        2 | 2 | 2018-03-21 22:17:49.215978
 F    |        3 | 3 | 2018-03-21 22:17:49.215978
 C    |        3 | 4 | 2018-03-21 22:17:49.215978
 D    |        4 | 5 | 2018-03-21 22:17:49.215978
 E    |        5 | 6 | 2018-03-21 22:17:49.215978

Window関数(1)positionを振り直す

実際にpositionを振り直すには、下記のSQLを実行します。新たにpositionを振った表をWindow関数を使って用意して、それを元のテーブルと結合してUPDATEしています。

UPDATE categories
SET POSITION = c.row_number
FROM
  (SELECT name,
          ROW_NUMBER() OVER (ORDER BY POSITION, updated_at DESC) AS row_number
   FROM categories) AS c
WHERE c.name = categories.name;
 name | position |         updated_at
------+----------+----------------------------
 A    |        1 | 2018-03-21 22:17:49.215978
 B    |        2 | 2018-03-21 22:17:49.215978
 F    |        3 | 2018-03-23 10:17:49.220138
 C    |        4 | 2018-03-21 22:17:49.215978
 D    |        5 | 2018-03-21 22:17:49.215978
 E    |        6 | 2018-03-21 22:17:49.215978

Window関数(2)やりたいこと

アクセスログから30分以内の連続したアクセスを一つのセッションとして、セッションの一覧を出したい。

Window関数(2)アクセスログのデータ

ユーザーIDとしてid、タイムスタンプとしてtimeを持った簡単なログテーブルがあるとします。

 id |            time
----+----------------------------
  1 | 2018-03-10 06:14:06.265533
  1 | 2018-03-10 07:04:06.265533
  2 | 2018-03-10 07:31:06.265533
  1 | 2018-03-10 07:35:06.265533
  2 | 2018-03-10 08:02:06.265533
  2 | 2018-03-10 08:03:06.265533
  2 | 2018-03-10 08:04:06.265533
  1 | 2018-03-10 08:05:06.265533
  2 | 2018-03-10 08:14:06.265533
  1 | 2018-03-10 08:14:06.265533

Window関数(2)現在の行と前の行の差を取得

LAGで前の行が取得できます。この例ではidごとに前の行のtimeを取得、現在の行のtimeとの差を求めています。

SELECT
  id,
  time as current_row,
  LAG(time) OVER (PARTITION BY id ORDER BY time) as previous_row,
  (time - (LAG(time) OVER (PARTITION BY id ORDER BY time))) / 60 as difference
FROM access_logs
ORDER BY id, current_row ASC
 id |        current_row         |        previous_row        | difference
----+----------------------------+----------------------------+------------
  1 | 2018-03-10 06:14:06.265533 | ¤                          | ¤
  1 | 2018-03-10 07:04:06.265533 | 2018-03-10 06:14:06.265533 | 00:00:50
  1 | 2018-03-10 07:35:06.265533 | 2018-03-10 07:04:06.265533 | 00:00:31
  1 | 2018-03-10 08:05:06.265533 | 2018-03-10 07:35:06.265533 | 00:00:30
  1 | 2018-03-10 08:14:06.265533 | 2018-03-10 08:05:06.265533 | 00:00:09
  2 | 2018-03-10 07:31:06.265533 | ¤                          | ¤
  2 | 2018-03-10 08:02:06.265533 | 2018-03-10 07:31:06.265533 | 00:00:31
  2 | 2018-03-10 08:03:06.265533 | 2018-03-10 08:02:06.265533 | 00:00:01
  2 | 2018-03-10 08:04:06.265533 | 2018-03-10 08:03:06.265533 | 00:00:01
  2 | 2018-03-10 08:14:06.265533 | 2018-03-10 08:04:06.265533 | 00:00:10 

Window関数(2)セッション一覧

さきほどのSQLより、前回との差がNULL(初回アクセス)、前回との差が30分以上(新しいセッション)の行を取得すると、セッション一覧になります。

SELECT
 id, s.time as timestamp
FROM (
  SELECT
    CASE
      WHEN LAG(time) OVER (PARTITION BY id ORDER BY time) IS NULL THEN time
      WHEN EXTRACT(epoch FROM time - (LAG(time) OVER (PARTITION BY id ORDER BY time))) > 60 * 30 THEN time
      ELSE NULL
    END AS time,
    id
  FROM
    access_logs
  ORDER BY
    time ) as s
WHERE
  time IS NOT NULL
  order by timestamp, id

Window関数(2)セッション一覧

 id |        current_row         |        previous_row        | difference
----+----------------------------+----------------------------+------------
  1 | 2018-03-10 06:14:06.265533 | ¤                          | ¤
  1 | 2018-03-10 07:04:06.265533 | 2018-03-10 06:14:06.265533 | 00:00:50
  1 | 2018-03-10 07:35:06.265533 | 2018-03-10 07:04:06.265533 | 00:00:31
  1 | 2018-03-10 08:05:06.265533 | 2018-03-10 07:35:06.265533 | 00:00:30
  1 | 2018-03-10 08:14:06.265533 | 2018-03-10 08:05:06.265533 | 00:00:09
  2 | 2018-03-10 07:31:06.265533 | ¤                          | ¤
  2 | 2018-03-10 08:02:06.265533 | 2018-03-10 07:31:06.265533 | 00:00:31
  2 | 2018-03-10 08:03:06.265533 | 2018-03-10 08:02:06.265533 | 00:00:01
  2 | 2018-03-10 08:04:06.265533 | 2018-03-10 08:03:06.265533 | 00:00:01
  2 | 2018-03-10 08:14:06.265533 | 2018-03-10 08:04:06.265533 | 00:00:10
 id |         timestamp
----+----------------------------
  1 | 2018-03-10 06:14:06.265533
  1 | 2018-03-10 07:04:06.265533
  2 | 2018-03-10 07:31:06.265533
  1 | 2018-03-10 07:35:06.265533
  2 | 2018-03-10 08:02:06.265533

Window関数 Railsでは?

  • ActiveRecord::Connection.execute
  • ActiveRecord::Querying#find_by_sql
  • ActiveRecord::QueryMethods#select

Trigger

PostgreSQLではTriggerという仕組みを使って、行への操作(挿入・更新・削除)があった時に特定の関数を実行することができます。

Trigger - やりたいこと

テーブルの変更履歴をアプリケーション側ではなくデータベース側のみで自動で記録したい。

Trigger - とは?

公式ドキュメントによると、

CREATE TRIGGERは新しいトリガを作成します。 作成したトリガは指定したテーブルまたはビューと関連付けられ、特定のイベントが発生した時に指定した関数function_nameを実行します

https://www.postgresql.jp/document/10/html/sql-createtrigger.html

とのこと。

関数はPL/pgSQLという手続き型プログラミング言語で記述し、コードの中にSQLを書いて行の操作をすることができます。

Trigger - レコードの変更をJSONで保存するトリガーの関数を定義する

下記のトリガーでレコードに更新があった際にchangesというテーブルに変更を保存します。変更前、変更後の行をOLDNEWで参照することができるので、それをrow_to_jsonでJSONに変換して保存しています。

CREATE OR REPLACE FUNCTION audit_changes() RETURNS trigger
    LANGUAGE plpgsql
    AS $$
BEGIN
  IF (TG_OP = 'UPDATE') THEN
    INSERT INTO changes (table_name, operation, old_content, new_content, created_at)
      VALUES (TG_TABLE_NAME, TG_OP, row_to_json(OLD), row_to_json(NEW), now());
    RETURN NEW;
  END IF;
END;
$$;   

Trigger - トリガーをテーブルに適用する

CREATE TRIGGERでトリガーをテーブルに適用します。

CREATE TRIGGER audit_items_changes BEFORE UPDATE ON items FOR EACH ROW EXECUTE PROCEDURE audit_changes();

Trigger - 実行結果

このテーブルを

   name    | type
-----------+-------
 Apple     | Fruit
 Wine      | Drink
 Beer      | Drink
 Chocolate | Food

変更すると

UPDATE items SET name = 'Orange' WHERE name = 'Apple';

履歴が保存されます。

 table_name | operation |    old_content    |    new_content     |         created_at
------------+-----------+-------------------+--------------------+----------------------------
 items      | UPDATE    | {"name": "Apple", | {"name": "Orange", | 2018-03-10 09:27:05.213614
            |           |  "type": "Fruit"} |  "type": "Fruit"}  |

Trigger - Railsでは?

  • マイグレーション内でActiveRecord::Connection.execute

Materialized view

要はキャッシュされたビューです。

Materialized view - やりたいこと

下記のように、「ワイン」テーブルと「ビール」テーブルがあるとします。これを「飲み物」テーブルとして横断して検索したくなりました。

CREATE EXTENSION pgcrypto; -- gen_random_uuid()のために必要

CREATE TABLE wines (
  id uuid DEFAULT gen_random_uuid() NOT NULL,
  name varchar,
  color varchar,
  price integer
);

CREATE TABLE beers (
  id uuid DEFAULT gen_random_uuid() NOT NULL,
  name varchar,
  type varchar,
  price integer
);

Materialized view - そもそもビューとは?

ビューはSELECT文を保存してあたかもテーブルかのように扱える機能です。

Materialized view - とは?

通常のビューは毎回その定義のSELECT文を実行します。マテリアライズド・ビューは通常のビューとは違い、SELECT文の結果が保存されます。

結果が保存されているので通常のビューよりも多くの場合高速ですが、データの更新は手動で行う必要があります。また、インデックスを貼ることも可能です。

Materialized view - テーブル定義

「ワイン」テーブルと「ビール」テーブルをまとめた、「飲み物」マテリアライズド・ビューを定義してみましょう。

Materialized view - テーブル定義(おさらい)

改めて先程の「ワイン」テーブルと「ビール」テーブルの定義です。

CREATE TABLE wines (
  id uuid DEFAULT gen_random_uuid() NOT NULL,
  name varchar,
  color varchar,
  price integer
);

CREATE TABLE beers (
  id uuid DEFAULT gen_random_uuid() NOT NULL,
  name varchar,
  type varchar,
  price integer
);

Materialized view - マテリアライズド・ビューの定義

「ワイン」テーブルと「ビール」テーブルの型を揃えてUNIONしたものを「飲み物」ビューとして定義します。

beverages.idの型はUUIDなので重複がありません。なのでユニークインデックスを貼ることができます。

CREATE MATERIALIZED VIEW beverages AS
  SELECT w.id AS id,
  w.name AS name,
  w.price AS price,
  'wines' AS type
  FROM wines AS w
UNION ALL
  SELECT b.id AS id,
  b.name AS name,
  b.price AS price,
  'beers' AS type
  FROM beers AS b
;

CREATE UNIQUE INDEX index_beverages_id ON beverages USING btree (id);

Materialized view - データ投入、更新

データを投入した後、REFRESH MATERIALIZED VIEWでデータを更新します。ユニークインデックスがあるとCONCURRENTLYオプションを使うことができます。これを指定すると更新中のビューへの読み込みロックが回避できます。

INSERT INTO wines
(name, color, price)
VALUES
('Rotten Highway', 'White', 10000),
('yellow tail Chardonnay', 'White', 1000)
;

INSERT INTO beers
(name, type, price)
VALUES
('Old Rasputin', 'Imperial Stout', 1300),
('Ichiban Shibori', 'Lager', 300)
;

REFRESH MATERIALIZED VIEW CONCURRENTLY beverages;

Materialized view - 飲み物ビューを検索

マテリアライズド・ビューを使って1000円以下の「ビール」と「ワイン」を一度に検索することができました😇

SELECT * FROM beverages
WHERE price <= 1000;  
                  id                  |          name          | price | type
--------------------------------------+------------------------+-------+-------
 1f17edd2-f3df-4cd7-b72e-faff06df6130 | Ichiban Shibori        |   300 | beers
 73afb8eb-9f6f-4e1c-a3ef-5f4ee36ff3af | yellow tail Chardonnay |  1000 | wines

Materialized view - Railsでは?

Materialized view - 補足

  • ディスクスペースが必要になる、リフレッシュが必要、リフレッシュのコストがかかるというデメリットがあります。詳しくは https://www.slideshare.net/SoudaiSone/postgre-sql-54919575 を参照ください。
    • 個人的には 1) リフレッシュが遅れても問題なく 2) 更新頻度が低い 場合のみマテリアライズド・ビュー使用可としています。
  • ソースとなるテーブルの主キー(id)をRailsでよくあるincremental idにするとビューで主キー(的なもの)が作れません。
    • キーが衝突するのでユニークインデックスが貼れないのでリフレッシュ時にCONCURRENTLYオプションが使えず、更新中にソースとなっているテーブルにロックがかかってしまいます。
    • idをUUIDにするとこれを回避できます。
    • ちなみにRails 5からActiveRecordでprimary keyをUUIDにできるようになりました。詳しく http://blog.bigbinary.com/2016/04/04/rails-5-provides-application-config-to-use-UUID-as-primary-key.html を参照ください。

PostGIS

PostGIS - やりたいこと

東京都の近くに公園が多い駅ランキングを出したい。

PostGIS - って何?

地理空間情報を扱うための拡張です。そもそも地理空間情報って何?という問いについては、これからお話しする実例を通してお答えできればと思います。

PostGISが具体的に提供する機能としては、1) ジオメトリカラムの定義 2) ジオメトリ関数 の2つです。

ジオメトリは点、線、面など、空間上を占める何らかの情報のことです。

PostGIS - 例

駅のテーブル(stations、定義はのちほど)を使って、代々木公園駅から距離が近い駅ランキングを出してみましょう。 下記のようにST_Distance_Sphereで二点間の距離を出すことができます。

SELECT s1.name, s2.name,
       ST_Distance_Sphere(s1.geom, s2.geom) AS distance
FROM stations s1
CROSS JOIN stations s2
WHERE s1.name = '代々木公園' AND s1.name <> s2.name
ORDER BY distance
LIMIT 5;
+------------+------------+------------+
| name       | name       | distance   |
|------------+------------+------------|
| 代々木公園 | 代々木上原 | 0.00894604 |
| 代々木公園 | 参宮橋     | 0.0104074  |
| 代々木公園 | 駒場東大前 | 0.0115478  |
| 代々木公園 | 初台       | 0.0123519  |
| 代々木公園 | 神泉       | 0.0127556  |
+------------+------------+------------+

PostGIS - 公園が多い駅ランキングを出すにあたっての戦略

「駅の最寄りエリア」の面を作って、その中にある公園を数えるという方向で挑みます。

<img src="./parks.png"/ style='width: 85%'>

PostGIS - テーブル定義

geometry(Point, 4326)※ という設定で空間上の「点」を表すジオメトリ型のカラムを定義できます。要は緯度経度です。geometry(Polygon, 4326)でポリゴン、つまり「面」を定義できます。

公園(parks)にはgeomとして公園を代表する地点を、駅(stations)にはgeomとして駅の地点、nearbyとして最寄りエリアを定義しました。

CREATE EXTENSION postgis;

CREATE TABLE parks (
  prefecture varchar,
  city varchar,
  name varchar,
  type varchar,
  geom geometry(Point, 4326)
);

CREATE TABLE stations  (
  code integer,
  name varchar,
  prefecture_code integer,
  address varchar,
  geom geometry(Point, 4326),
  nearby geometry(Polygon, 4326)
);

※: 4326って何?と思う方へ: 緯度経度の測り方は何種類もあって、それぞれ測地系と呼ばれています。4326は世界測地系WGS84というよく使われている測地系を表すコードです。

PostGIS - 「駅の最寄りエリア」テーブルを作成

平面上にいくつか点があって、その平面を「どの点に一番近いか」で分割した図がボロノイ図です。これを使うと「最寄りエリア」のポリゴンを作れます。 下記のように駅の地点を使ってボロノイ図を描画し、それを最寄りエリアテーブル(polygons)として保存します。

CREATE TABLE polygons (geom geometry(Polygon, 4326));
CREATE INDEX index_polygons_geom ON polygons USING gist (geom);

INSERT INTO polygons (geom)
SELECT g.geom
FROM
  (
    SELECT (
      -- ST_Dump: 複数のジオメトリを行に展開する。ボロノイ図は面の集合として返されるので、展開が必要
      ST_Dump(
        -- ST_VoronoiPolygons: 複数のジオメトリ(駅の地点の集合)からボロノイ図を描画する
        ST_VoronoiPolygons(
          -- ST_Union: 複数のジオメトリ(ここでは駅の地点)を一つのジオメトリ(駅の地点の集合)にまとめる
          ST_Union(geom)
        )
      )
    ).geom AS geom
    FROM stations
  ) as g;

PostGIS - 「駅の最寄りエリア」テーブルのイメージ

地図上に表示すると、このように区分けができました。がしかし、これだとどこがどの駅がわかりません。 <img src="./shinjuku_polygons.png"/ style='width: 85%'>

PostGIS - 「駅の最寄りエリア」データを駅テーブルに挿入

最寄りエリアにどの駅(の地点)が含まれているかがわかれば、エリアのポリゴンと駅の対応がわかるはず。 PostGISの関数ST_Containsで包含関係を判定できるので、これを使って駅テーブルと駅の最寄りエリアテーブルを結合し、更新します。

UPDATE stations
SET nearby = polygons.geom
FROM polygons
WHERE ST_Contains(polygons.geom, stations.geom);

PostGIS - 「駅の最寄りエリア」と駅の対応ができた!

🎉 🎉 🎉

<img src="./shinjuku_polygons_with_name.png"/ style='width: 85%'>

PostGIS - 近くに公園が多い駅ランキングを計算

さきほど登場したST_Containsを使って、最寄りエリアに入っている公園を数えます。 ジオメトリの面積をST_Areaで求められるので※、それを使って平方キロメートルあたりの公園数をp_sqkmとして計算しています。

SELECT stations.name,
       stations.address,
       COUNT(parks.name) p,
       FLOOR(ST_Area(ST_Transform(stations.nearby, 4326)::geography)) area,
       FLOOR(COUNT(parks.name) / ST_Area(ST_Transform(stations.nearby, 4326)::geography) / (1000 * 1000)) p_sqkm
FROM stations
LEFT OUTER JOIN parks ON st_contains(stations.nearby, parks.geom)
WHERE prefecture_code = 13
GROUP BY stations.address, stations.name, stations.nearby
ORDER BY p_sqkm DESC

※ メートル単位での計算はジオグラフィ型で行う必要があるので、ST_Transformを変換しています。

PostGIS - 近くに公園が多い駅ランキング

まさかの「新大久保」駅がナンバーワンでした!

           name           |               address               |  p  |   area    | p_sqkm
--------------------------+-------------------------------------+-----+-----------+--------
 新大久保                 | 東京都新宿区百人町一丁目10-15       |  12 |    513173 |     23
 京成曳舟                 | 墨田区京島1-39-1                |  13 |    712581 |     18
 大鳥居                   | 大田区西糀谷3-37-18            |  31 |   1638396 |     18
 大森町                   | 大田区大森西3-24-7              |  25 |   1464670 |     17
 千鳥町                   | 大田区千鳥1-20-1                |  14 |    844929 |     16
 松陰神社前               | 世田谷区若林4-21-16            |  10 |    604776 |     16
 志村三丁目               | 板橋区志村3-23-1                |  27 |   1615272 |     16

PostGIS - 新大久保駅の様子

結果をビジュアライズしました。ポケットパークという公園が沢山あります。 <img src="./shinookubo.png"/ style='width: 85%'>

PostGIS - 京成曳舟駅の様子

多いような気もします。 <img src="./hikifune.png"/ style='width: 85%'>

PostGIS - 玉川学園前駅の様子

面積を考慮しない公園の数ではここがトップです。広い。 <img src="./tamagawagakuenmae.png"/ style='width: 85%'>

PostGIS - Railsでは?

ジオメトリ型がマイグレーションで定義できます。

class CreateStations < ActiveRecord::Migration[5.1]
  def change
    create_table :stations do |t|
      t.string :name
      t.string :line_name
      t.st_point :geom

      t.timestamps
    end

    add_index :welfare_facilities, :geom, using: :gist
  end
end

PostGIS - データの出典とクレジット

まとめ

  • PostgreSQLには便利な機能が沢山
  • 少しRailsのレールを外れると面白い世界が広がっている
  • ご利用は計画的に
You can’t perform that action at this time.