PostgreSQLのメモ

共有メモリ不足でエラーが発生する場合は次のように--shm-size
を指定して起動すればOK。
docker run --shm-size=1g -d --name ps -e POSTGRES_DB=test -e POSTGRES_USER=user -e POSTGRES_PASSWORD=xxxx -p 5432:5432 -v $(pwd)/tmp:/tmp postgres:latest

起動したサーバーにアクセスするには次のようにする。
docker exec -it ps psql -U user test

FastAPIでCRUDサンプルを作る
ChatAPIと対話しながらREST APIでCRUDを実現するサンプルを作ってみた。
まずテーブルを作成する。
CREATE TABLE items (
id SERIAL PRIMARY KEY,
name VARCHAR NOT NULL,
description VARCHAR,
price INTEGER NOT NULL
);
PostgreSQLはDockerで動かすが、psycopg2
がインストールできなかったので、ローカル側にもPostgreSQLをインストールした。なお、PythonはDockerではなく、venvで仮想化している。
brew install postgresql
インストールしたモジュールは次の通り。
anyio==3.6.2
asyncpg==0.27.0
click==8.1.3
databases==0.7.0
fastapi==0.95.1
greenlet==2.0.2
h11==0.14.0
idna==3.4
psycopg2==2.9.6
pydantic==1.10.7
sniffio==1.3.0
SQLAlchemy==1.4.47
starlette==0.26.1
typing_extensions==4.5.0
uvicorn==0.21.1
モジュールはmodels.py
, crud.py
, main.py
の3つ。
from pydantic import BaseModel
from sqlalchemy import create_engine, MetaData, Column, Integer, String
from sqlalchemy.ext.declarative import declarative_base
metadata = MetaData()
Base = declarative_base(metadata=metadata)
class Item(Base):
__tablename__ = "items"
id = Column(Integer, primary_key=True, index=True)
name = Column(String, index=True)
description = Column(String)
price = Column(Integer)
class ItemCreate(BaseModel):
name: str
description: str
price: int
from sqlalchemy.orm import Session
from models import Item, ItemCreate
def get_item(db: Session, item_id: int):
return db.query(Item).filter(Item.id == item_id).first()
def create_item(db: Session, item: ItemCreate):
db_item = Item(**item.dict())
db.add(db_item)
db.commit()
db.refresh(db_item)
return ItemCreate(**db_item.__dict__)
def update_item(db: Session, item_id: int, item: ItemCreate):
db_item = db.query(Item).filter(Item.id == item_id).first()
if not db_item:
return None
db_item.name = item.name
db_item.description = item.description
db_item.price = item.price
db.commit()
db.refresh(db_item)
return db_item
def delete_item(db: Session, item_id: int):
db_item = db.query(Item).filter(Item.id == item_id).first()
if not db_item:
return None
db.delete(db_item)
db.commit()
return db_item
from fastapi import FastAPI, HTTPException, Depends
from sqlalchemy.orm import Session, sessionmaker
from sqlalchemy import create_engine
from databases import Database
from models import Base, ItemCreate
from crud import get_item, create_item, update_item, delete_item
app = FastAPI()
DATABASE_URL = "postgresql://user:xxxx@localhost/test"
database = Database(DATABASE_URL)
@app.on_event("startup")
async def startup():
await database.connect()
@app.on_event("shutdown")
async def shutdown():
await database.disconnect()
engine = create_engine(DATABASE_URL)
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
Base.metadata.create_all(bind=engine)
def get_db():
db = SessionLocal()
try:
yield db
finally:
db.close()
@app.get("/items/{item_id}", response_model=ItemCreate)
def read_item(item_id: int, db: Session = Depends(get_db)):
db_item = get_item(db, item_id=item_id)
if db_item is None:
raise HTTPException(status_code=404, detail="Item not found")
return ItemCreate(**db_item.__dict__)
@app.post("/items/", response_model=ItemCreate)
def create_item_endpoint(item: ItemCreate, db: Session = Depends(get_db)):
return create_item(db=db, item=item)
@app.put("/items/{item_id}", response_model=ItemCreate)
def update_item_endpoint(item_id: int, item: ItemCreate, db: Session = Depends(get_db)):
updated_item = update_item(db, item_id=item_id, item=item)
if updated_item is None:
raise HTTPException(status_code=404, detail="Item not found")
return ItemCreate(**updated_item.__dict__)
@app.delete("/items/{item_id}", response_model=ItemCreate)
def delete_item_endpoint(item_id: int, db: Session = Depends(get_db)):
deleted_item = delete_item(db, item_id=item_id)
if deleted_item is None:
raise HTTPException(status_code=404, detail="Item not found")
return ItemCreate(**deleted_item.__dict__)
実行は次のコマンドで。
uvicorn main:app --reload
動作確認のcurlコマンド。
作成:
curl -X POST "http://localhost:8000/items/" -H "accept: application/json" -H "Content-Type: application/json" -d '{"name": "example", "description": "An example item", "price": 42}'
参照:
curl -X GET "http://localhost:8000/items/1" -H "accept: application/json"
更新:
curl -X PUT "http://localhost:8000/items/1" -H "accept: application/json" -H "Content-Type: application/json" -d '{"name": "updated_example", "description": "An updated example item", "price": 99}'
削除:
curl -X DELETE "http://localhost:8000/items/1" -H "accept: application/json"

ChatGPTに聞きながら売上データを分析してみた。
知りたかったこと:
- どんな属性の顧客がどれだけ売上を上げているか(枝番ごとの売上集計)
- 契約期間と売上の相関(契約期間ごとの売上集計)
前提:
- 元データはFileMaker上にある
- FileMakerで上述の集計をするのはしんどいのでPostgreSQLに最低限必要なデータだけインポートする
SQLは苦手だが、1時間ほどでできてしまった。すごいなChatGPT。
ChatGPTとのやり取りは以下の通り。
あなた:
PostgreSQLで、次の項目を持つテーブルを作成するSQLを書いていただけますか?
項目: 部門ID、請求年、税抜売上額
なお、部門IDは8桁のテキストです。
ChatGPT:
もちろん、以下のSQL文で指定された条件に基づいてテーブルを作成できます。
CREATE TABLE sales_data (
department_id VARCHAR(8) NOT NULL,
invoice_year INT NOT NULL,
sales_without_tax DECIMAL(15, 2) NOT NULL
);
このSQL文では、新しいテーブルの名前をsales_dataとし、department_id、invoice_year、およびsales_without_taxという3つのカラムを作成しています。department_idカラムは8桁のテキスト(VARCHAR(8))であり、invoice_yearカラムは整数(INT)、sales_without_taxカラムは小数(DECIMAL(15, 2))で表されます。また、すべてのカラムにNOT NULL制約が適用されているため、データを追加する際には必ず値を入力する必要があります。
あなた:
カラム名は日本語でお願いします。
ChatGPT:
もちろん、カラム名を日本語に変更したSQL文は以下の通りです。
CREATE TABLE 売上データ (
部門ID VARCHAR(8) NOT NULL,
請求年 INT NOT NULL,
税抜売上額 DECIMAL(15, 2) NOT NULL
);
ただし、日本語のカラム名を使用することは一般的ではなく、英語のカラム名を使用することが推奨されています。英語のカラム名を使用すると、コードの可読性が向上し、国際化や多言語対応が容易になります。そのため、実際の開発環境では英語のカラム名を使用することをお勧めします。
あなた:
税抜売上額は整数でお願いします。
ChatGPT:
もちろんです。税抜売上額のデータ型を整数に変更したSQL文は以下の通りです。
CREATE TABLE sales_data (
department_id VARCHAR(8) NOT NULL,
invoice_year INT NOT NULL,
sales_without_tax INT NOT NULL
);
ここで、sales_without_taxカラムのデータ型をINTに変更しました。これで、税抜売上額は整数で格納されます。
あなた:
カラム名は日本語でお願いします。
ChatGPT:
失礼しました。カラム名を日本語にし、税抜売上額を整数にしたSQL文は以下の通りです。
CREATE TABLE 売上データ (
部門ID VARCHAR(8) NOT NULL,
請求年 INT NOT NULL,
税抜売上額 INT NOT NULL
);
ここでは、税抜売上額カラムのデータ型を整数(INT)に変更しました。ただし、前述の通り、日本語のカラム名を使用することは一般的ではなく、英語のカラム名を使用することが推奨されています。実際の開発環境では英語のカラム名を使用することをお勧めします。
あなた:
ありがとうございます。このテーブルを最終版とします。念の為、SQL文を再表示してください。
ChatGPT:
了解しました。最終版のテーブル作成のSQL文は以下の通りです。
CREATE TABLE 売上データ (
部門ID VARCHAR(8) NOT NULL,
請求年 INT NOT NULL,
税抜売上額 INT NOT NULL
);
このSQL文により、部門ID、請求年、および税抜売上額という3つの日本語カラム名を持つ売上データテーブルが作成されます。部門IDは8桁のテキスト、請求年は整数、税抜売上額は整数で格納されます。また、すべてのカラムにはNOT NULL制約が適用されています。
あなた:
ありがとうございます。次は同様にして以下の項目を持つテーブルを作成するSQL文を作成してください。
項目: 部門ID,
ChatGPT:
項目が不完全なようです。部門ID以外に必要なカラムとそのデータ型を教えて
あなた:
ありがとうございます。次は同様に、以下の項目を持つテーブルを作成するSQL文を作成してください。テーブル名は顧客情報とします。
項目: 部門ID、枝番、初回契約日
ChatGPT:
テーブル名を「顧客情報」とし、指定された項目を持つテーブルを作成するSQL文は以下の通りです。
CREATE TABLE 顧客情報 (
部門ID VARCHAR(8) NOT NULL,
枝番 INT NOT NULL,
初回契約日 DATE NOT NULL
);
このSQL文では、顧客情報テーブルに部門ID、枝番、および初回契約日という3つのカラムが作成されます。部門IDカラムは8桁のテキスト(VARCHAR(8))、枝番カラムは整数(INT)、初回契約日カラムは日付(DATE)で格納されます。また、すべてのカラムにはNOT NULL制約が適用されています。
あなた:
タブ区切りの「請求データ.tab」というファイルを上記売上データにインポートする方法を教えて下さい。
ChatGPT:
タブ区切りの「請求データ.tab」というファイルを、先ほど作成した「売上データ」テーブルにインポートするには、PostgreSQLのCOPYコマンドを使用します。以下のコマンドを実行する前に、請求データ.tabファイルがPostgreSQLサーバーにアクセスできる場所に置いておいてください。
COPY 売上データ (部門ID, 請求年, 税抜売上額)
FROM '/path/to/請求データ.tab'
DELIMITER E'\t'
CSV HEADER;
このコマンドでは、COPYを使って売上データテーブルにデータをインポートしています。部門ID、請求年、税抜売上額の順にカラムが並んでいることを指定しています。
FROM句には、インポートするファイルの絶対パスを指定してください。/path/to/請求データ.tabは、実際のファイルパスに置き換えてください。
DELIMITER E'\t'は、タブ文字を区切り文字として指定しています。CSV HEADERは、ファイルの最初の行にカラム名が含まれていることを示しています。このオプションが指定されている場合、最初の行はデータとしてインポートされず、カラム名として解釈されます。
このコマンドを実行するには、PostgreSQLサーバーへの適切な権限が必要です。権限がない場合は、データベース管理者に連絡して権限を付与してもらうか、代わりにpsqlクライアントツールの\copyコマンドを使用してインポートを試みてください。
あなた:
すみません、顧客情報の枝番を3文字のテキストに変更してください。
ChatGPT:
もちろんです。顧客情報テーブルの「枝番」カラムを3文字のテキストに変更したSQL文は以下の通りです。
CREATE TABLE 顧客情報 (
部門ID VARCHAR(8) NOT NULL,
枝番 VARCHAR(3) NOT NULL,
初回契約日 DATE NOT NULL
);
ここで、「枝番」カラムのデータ型を3文字のテキスト(VARCHAR(3))に変更しました。このテーブル定義では、部門IDは8桁のテキスト、枝番は3文字のテキスト、初回契約日は日付で格納されます。また、すべてのカラムにはNOT NULL制約が適用されています。
あなた:
ありがとうございます。請求年毎かつ枝番毎で税抜売上額を集計したいのですが、どのようなSQLを書けばよいですか?
ChatGPT:
「売上データ」テーブルと「顧客情報」テーブルを結合し、請求年と枝番ごとに税抜売上額を集計するには、以下のようなSQL文を使用します。
SELECT
b.請求年,
c.枝番,
SUM(b.税抜売上額) AS 合計税抜売上額
FROM
売上データ b
JOIN
顧客情報 c ON b.部門ID = c.部門ID
GROUP BY
b.請求年, c.枝番
ORDER BY
b.請求年, c.枝番;
このSQL文では、まず売上データテーブル(bというエイリアスを使用)と顧客情報テーブル(cというエイリアスを使用)を、部門IDカラムを使って結合しています。その後、GROUP BY句で請求年と枝番ごとにグループ化し、SUM関数で税抜売上額を集計しています。最後に、ORDER BY句で請求年と枝番の順に結果をソートしています。
あなた:
ありがとうございます。この結果をエクスポートするにはどうやればよいですか?
ChatGPT:
結果をエクスポートするには、PostgreSQLの\copyコマンドを使って、結果をCSV形式またはタブ区切りのテキスト形式で出力できます。以下の手順でエクスポートを行ってください。
まず、PostgreSQLのコマンドラインツールであるpsqlを使用してデータベースに接続します。その後、以下のような\copyコマンドを実行して、集計結果をファイルにエクスポートします。
CSV形式でエクスポートする場合:
\copy (SELECT b.請求年, c.枝番, SUM(b.税抜売上額) AS 合計税抜売上額 FROM 売上データ b JOIN 顧客情報 c ON b.部門ID = c.部門ID GROUP BY b.請求年, c.枝番 ORDER BY b.請求年, c.枝番) TO '/path/to/output.csv' WITH CSV HEADER;
タブ区切りのテキスト形式でエクスポートする場合:
\copy (SELECT b.請求年, c.枝番, SUM(b.税抜売上額) AS 合計税抜売上額 FROM 売上データ b JOIN 顧客情報 c ON b.部門ID = c.部門ID GROUP BY b.請求年, c.枝番 ORDER BY b.請求年, c.枝番) TO '/path/to/output.tsv' WITH DELIMITER E'\t' CSV HEADER;
/path/to/output.csvまたは/path/to/output.tsvは、実際の出力ファイルのパスに置き換えてください。WITH CSV HEADERオプションは、出力ファイルの最初の行にカラム名を含めることを指定しています。
注意:ファイルへの書き込み権限が必要です。書き込み権限がない場合は、ディレクトリのアクセス権限を変更するか、別のディレクトリに出力してください。
あなた:
ありがとうございます。次は契約期間と請求年ごとの税抜売上額の集計をしたいです。ただし、契約期間とは初回契約日の年部分と請求年の差とします。
ChatGPT:
契約期間と請求年ごとの税抜売上額の集計を行うには、以下のSQL文を使用します。
SELECT
EXTRACT(YEAR FROM b.請求年) - EXTRACT(YEAR FROM c.初回契約日) AS 契約期間,
b.請求年,
SUM(b.税抜売上額) AS 合計税抜売上額
FROM
売上データ b
JOIN
顧客情報 c ON b.部門ID = c.部門ID
GROUP BY
契約期間, b.請求年
ORDER BY
契約期間, b.請求年;
このSQL文では、EXTRACT関数を使って請求年と初回契約日の年部分を取得し、契約期間を計算しています。その後、GROUP BY句で契約期間と請求年ごとにグループ化し、SUM関数で税抜売上額を集計しています。最後に、ORDER BY句で契約期間と請求年の順に結果をソートしています。
あなた:
ありがとうございます。請求年はintegerなのでEXTRACTは不要ではないでしょうか?
ChatGPT:
おっしゃる通りです。すみません、私の説明が間違っていました。請求年はinteger型なので、EXTRACT関数は不要です。以下が修正されたSQL文です。
SELECT
b.請求年 - EXTRACT(YEAR FROM c.初回契約日)::integer AS 契約期間,
b.請求年,
SUM(b.税抜売上額) AS 合計税抜売上額
FROM
売上データ b
JOIN
顧客情報 c ON b.部門ID = c.部門ID
GROUP BY
契約期間, b.請求年
ORDER BY
契約期間, b.請求年;
ここで、請求年から初回契約日の年部分を引くだけで契約期間を計算しています。EXTRACT関数は初回契約日の年部分を取得するためだけに使用し、その後integer型にキャストしています。
あなた:
ありがとうございます。表示は請求年を先頭に移動してください。ソートも請求年を先頭にしてください。
ChatGPT:
請求年を先頭に表示し、ソートも請求年を先頭にするには、以下のSQL文を使用します。
SELECT
b.請求年,
b.請求年 - EXTRACT(YEAR FROM c.初回契約日)::integer AS 契約期間,
SUM(b.税抜売上額) AS 合計税抜売上額
FROM
売上データ b
JOIN
顧客情報 c ON b.部門ID = c.部門ID
GROUP BY
b.請求年, 契約期間
ORDER BY
b.請求年, 契約期間;
このSQL文では、SELECT句とORDER BY句で請求年を先頭にしています。結果は、請求年を第一ソートキー、契約期間を第二ソートキーとしてソートされます。
あなた:
ありがとうございます。完璧です。それではこの結果をCSVで出力してください。
ChatGPT:
結果をCSV形式でエクスポートするには、PostgreSQLのコマンドラインツールであるpsqlを使用してデータベースに接続します。その後、以下の\copyコマンドを実行して、集計結果をCSV形式でファイルに出力します。
\copy (SELECT b.請求年, b.請求年 - EXTRACT(YEAR FROM c.初回契約日)::integer AS 契約期間, SUM(b.税抜売上額) AS 合計税抜売上額 FROM 売上データ b JOIN 顧客情報 c ON b.部門ID = c.部門ID GROUP BY b.請求年, 契約期間 ORDER BY b.請求年, 契約期間) TO '/path/to/output.csv' WITH CSV HEADER;
/path/to/output.csvは、実際の出力ファイルのパスに置き換えてください。WITH CSV HEADERオプションは、出力ファイルの最初の行にカラム名を含めることを指定しています。
注意:ファイルへの書き込み権限が必要です。書き込み権限がない場合は、ディレクトリのアクセス権限を変更するか、別のディレクトリに出力してください。

明細データを分析して、物価上昇率を算出してみた。
算出の手順は次の通り。
- 月ごとに商品ごとの標準単価の平均値を算出する
- 前月の標準単価の平均値との差分を算出する(値上がりしたらプラス、値下がりしたらマイナスの値になるのがポイント)
- 平均値の差分を合計する(これで年間の標準単価の変動分が取得できる)
- 年間の標準単価の最大値と最小値を算出する
- 標準単価の変動分がプラス(値上がり)の場合は、最小値に対する割合を算出
- 標準単価の変動分がマイナス(値下がり)の場合は、最大値に対する割合を算出
- 商品ごとの年間総売上に価格変動割合をかける(これで価格変動の影響を受けた売上金額を取得できる)
- 価格変動を受けた売上金額を総売上で割り、0.5をかけた値を物価上昇率とする
- 0.5をかけたのは、値上げ時期によって売上へもたらす影響が異なるため
SELECT
SUM(t1.総売上) AS 総売上,
SUM(t1.総売上 * t1.割合) AS 変動分,
SUM(t1.総売上 * t1.割合) / SUM(t1.総売上) * 50 AS 物価上昇率
FROM (
SELECT
t.お申込番号,
SUM(t.総売上) AS 総売上,
MAX(t.税込標準単価(お客様)) AS 最大値,
MIN(t.税込標準単価(お客様)) AS 最小値,
CASE
WHEN SUM(t.平均値差分) > 0 THEN SUM(t.平均値差分) / MIN(t.税込標準単価(お客様))
WHEN SUM(t.平均値差分) < 0 THEN SUM(t.平均値差分) / MAX(t.税込標準単価(お客様))
ELSE 0
END AS 割合
FROM (
SELECT
お申込番号,
税込標準単価(お客様),
SUM(税込売上金額(お客様)) AS 総売上,
DATE_TRUNC('MONTH', 売上日) AS 月,
AVG(税込標準単価(お客様)) - LAG(AVG(税込標準単価(お客様))) OVER (PARTITION BY お申込番号 ORDER BY DATE_TRUNC('MONTH', 売上日)) AS 平均値差分
FROM
明細情報
WHERE
値引・経費区分 = '' AND
売上日 >= '2023-01-01' AND
売上日 <= '2023-12-31'
GROUP BY
お申込番号,
税込標準単価(お客様),
DATE_TRUNC('MONTH', 売上日)
) AS t
GROUP BY
お申込番号
) AS t1;

上記のSQLは値上げの時期が偏らず、一様なタイミングで実施されたという仮定にもとづいていた。もう少し厳密に算出したいので、SQLを次のように修正した。
WITH A AS (
SELECT
お申込番号,
DATE_TRUNC('MONTH', 売上日) AS 月,
AVG(税込標準単価(お客様)) AS 平均単価,
SUM(税込売上金額(お客様)) AS 売上,
AVG(税込標準単価(お客様)) - LAG(AVG(税込標準単価(お客様))) OVER (PARTITION BY お申込番号 ORDER BY DATE_TRUNC('MONTH', 売上日)) AS 平均値差分
FROM
明細情報
WHERE
値引・経費区分 = '' AND
売上日 >= '2023-01-01' AND
売上日 <= '2023-12-31'
GROUP BY
お申込番号,
DATE_TRUNC('MONTH', 売上日)
),
B AS (
SELECT
お申込番号,
MAX(平均単価) AS 最大値,
MIN(平均単価) AS 最小値,
SUM(平均値差分) AS 累積変動幅
FROM
A
GROUP BY
お申込番号
),
C AS (
SELECT
A.売上,
CASE
WHEN B.累積変動幅 > 0 THEN ( A.平均単価 - B.最小値 ) / B.最小値
WHEN B.累積変動幅 < 0 THEN ( A.平均単価 - B.最大値 ) / B.最大値
ELSE 0
END AS 変動率
FROM
A
JOIN B
ON A.お申込番号 = B.お申込番号
)
SELECT
SUM(売上) AS 総売上,
SUM(売上 * 変動率) AS 変動分売上,
SUM(売上 * 変動率) / SUM(売上) * 100 AS 物価上昇率
FROM
C;
月ごとに変動率を計算しているのがポイント。ここでいう変動率とは、累積変動幅がプラス(値上げ)の場合は最小値に対する変動幅の割合、累積変動幅がマイナス(値下げ)の場合は最大値に対する変動幅の割合を指す。
月ごとの売上に月ごとの変動率を掛ければ、価格変動分の売上が求まる。これを総売上で割れば、かなり厳密な物価上昇率が得られる。
実際に両方のSQLの結果を比較したところ、後者の物価上昇率の方が大きくなった。したがって、値上げは上半期に偏って多く実施されたことが判明した。

値上げされた商品の件数は次のSQLで調べられる。
最後の不等式の向きを逆にすれば値下げされた商品の件数になる。
WITH A AS (
SELECT
お申込番号,
税込標準単価(お客様),
DATE_TRUNC('MONTH', 売上日) AS 月,
AVG(税込標準単価(お客様)) - LAG(AVG(税込標準単価(お客様))) OVER (PARTITION BY お申込番号 ORDER BY DATE_TRUNC('MONTH', 売上日)) AS 平均値差分
FROM
明細情報
GROUP BY
お申込番号,
税込標準単価(お客様),
DATE_TRUNC('MONTH', 売上日)
),
B AS (
SELECT
お申込番号,
MAX(税込標準単価(お客様)) AS 最大値,
MIN(税込標準単価(お客様)) AS 最小値,
SUM(平均値差分) AS 累積変動幅
FROM
A
GROUP BY
お申込番号
)
SELECT
COUNT(B.お申込番号)
FROM
B
WHERE
B.累積変動幅 > 0;