Zenn
Open9

PostgreSQL 本格入門

ふんまと💩ふんまと💩

■ 日本PostgreSQLユーザ会

https://www.postgresql.jp/document/16/html/index.html

※ The PostgreSQL Global Development Group の和訳

■ PostgresQL の整備
Docker コンテナの作成

Claude3 が出力(https://claude.ai/chat/9c8e2631-4466-4423-a5d1-628f6821730a)

version: '3.8'
services:
  db:
    image: postgres:15
    container_name: postgres_test
    environment:
      POSTGRES_USER: testuser
      POSTGRES_PASSWORD: testpass
      POSTGRES_DB: testdb
    ports:
      - "5432:5432"
    volumes:
      - postgres_data:/var/lib/postgresql/data

volumes:
  postgres_data:

▽ 作ったDBに入り込むコマンド(psql)

docker exec -it postgres_test psql -U testuser -d testdb
ふんまと💩ふんまと💩

■ 言語の整理

  • SQL: DBを操作するための言語。
  • DBEngine: DBを操作するエンジン。SQLを解釈してデータを実際に操作する人。システム。
  • DBMS:DB管理システム。 サーバ型やアプリケーションに組み込むタイプなど。インタフェース。
  • RDB: リレーショナル・データベース。

SQLiteなどは、サーバ型でない典型例。

主なやり取りはここ。(https://claude.ai/chat/e54c727c-14cd-4a37-a535-9a92c75eeb5a)

■ その他
▽ マスタとは?
https://wa3.i-3-i.info/word16817.html

ふんまと💩ふんまと💩

■ Row Level Security の検証
所属する組織が合致する人間のみデータを取得・更新・削除できるようにする。

▽ テーブルの作成

CREATE TABLE documents (
    id SERIAL PRIMARY KEY,
    title TEXT,
    content TEXT,
    org_codes TEXT[] -- 組織コードの配列
);

▽ テストデータの挿入

INSERT INTO documents (title, content, org_codes)
VALUES 
    ('Document 1', 'Content 1', ARRAY['ORG1']),
    ('Document 2', 'Content 2', ARRAY['ORG2']),
    ('Document 3', 'Content 3', ARRAY['ORG3']),
    ('Document 4', 'Content 4', ARRAY['ORG1', 'ORG2']),
    ('Document 5', 'Content 5', ARRAY['ORG1', 'ORG3']),
    ('Document 6', 'Content 6', ARRAY['ORG2', 'ORG3']),
    ('Document 7', 'Content 7', ARRAY['ORG1', 'ORG2', 'ORG3']);

▽ RLSを有効化しポリシーを貼る。

-- RLSの有効化
ALTER TABLE documents ENABLE ROW LEVEL SECURITY;

-- Policy の作成
CREATE POLICY documents_org_policy ON documents
    FOR ALL
    USING (
        -- ユーザーの組織コードが文書の組織コードをすべて満たしている場合にアクセス可能
        string_to_array(current_setting('app.current_org_codes'), ',') @> org_codes
    );

-- 作成した Policy の確認
select * from pg_policies where tablename = 'documents';

■ policy を And条件でつなぎたい時
デフォルトでは、ORでつながってしまうので注意

-- AS RESTRICTIVE をつける
CREATE POLICY documents_org_policy ON documents AS RESTRICTIVE 
    FOR ALL
    USING (
        string_to_array(current_setting('app.current_org_codes'), ',') @> org_codes
    );

■ 注意事項
スーパーユーザはBypassされてしまう。そのため、新規ユーザを作成する必要あり。

スーパーユーザ、およびBYPASSRLS属性のあるロールは、テーブルへのアクセス時に、常に行セキュリティシステムを無視します。 テーブルの所有者も通常は行セキュリティを無視しますが、ALTER TABLE ... FORCE ROW LEVEL SECURITYにより、テーブルの所有者も行セキュリティの対象となることができます。

https://www.postgresql.jp/document/16/html/ddl-rowsecurity.html

ふんまと💩ふんまと💩

■ RLSでの index 検証

  1. 複数のポリシーを設定時にインデックスが正しく働くかの検証
  2. 配列と文字列(セッション変数が配列でテーブル側が文字列)比較ポリシーの検証

▽ テーブルの準備

-- 検証用テーブルの作成
CREATE TABLE saas_users (
    id SERIAL PRIMARY KEY,
    tenant varchar,
    name TEXT,
    email TEXT,
    org varchar,
    date TEXT
);

-- データの大量投入(テナント10種、組織10種の計10万件)
\copy saas_users FROM 'sample_data.csv' with (FORMAT csv, HEADER);

-- rls 有効化 (テーブル所有者を含める)
ALTER TABLE saas_users enable row level security;
ALTER TABLE saas_users force row level security;

▼ 設定した index のパターン
Explain analyze 実行時に利用されたIndexが出現するので、とりあえず全部貼った。
複合がない場合は2つのインデックスがANDで利用されていることが確認できる。

-- index: 単一バージョン
CREATE INDEX idx_org ON saas_users (org);
CREATE INDEX idx_tenant ON saas_users (tenant);
-- index: 複合バージョン
CREATE INDEX idx_tenant_org ON saas_users (tenant, org);
CREATE INDEX idx_org_tenant ON saas_users (org, tenant); -- 最も利用された

データにもよりけりだが、基本的には複合のほうが早いと思う。
※ 必要に応じて検証してください。

▼ Policyのパターン
下記のような話をWEBで見たので、これらを意識した検証を作成

  1. 配列と文字列比較の場合、ポリシーの記述でインデックスが 有効/無効 が変わる
  2. string_to_array が text型を返すのでインデックスが効かない

https://srad.jp/~quabbin/journal/417199/

-- tenant policy
CREATE POLICY  tenant_isolation_policy ON saas_users
    USING (
        (tenant = current_setting('app.current_tenant_id'::text))
    );

-- 1. orgの型がvarchar, text 型の場合
CREATE POLICY org_access_policy ON saas_users  AS RESTRICTIVE 
    USING (
        org = ANY(string_to_array(current_setting('app.allowed_orgs'), ','))
    );

-- 2. orgの型がbpchar 型の場合
CREATE POLICY org_access_policy ON saas_users AS RESTRICTIVE 
    USING (
        org = ANY(cast(string_to_array(current_setting('app.allowed_orgs'), ',') as bpchar[]))
    );

-- 3. サブクエリ (Claude3の初期案)
CREATE POLICY org_access_policy ON saas_users AS RESTRICTIVE 
    USING (
        EXISTS (
            SELECT 1
            FROM unnest(string_to_array(current_setting('app.allowed_orgs'), ',')) allowed_org
            WHERE saas_users.org = allowed_org
        )
    );

結果として、bpchar型とvarchar型による差異はなし。サブクエリは遅いので不採用。
記事は過去バージョンなのでそれが理由かな。結論、varchar-キャストなしのANYで良い。

▽検証用のメモ

-- セッション変数
SET app.current_tenant_id = 'current_tenant';
SET app.allowed_orgs = 'org1,org3';

-- bypass権限のないユーザで実行
EXPLAIN ANALYZE select * from saas_users;

-- べき等性担保
DROP POLICY IF EXISTS org_access_policy ON saas_users;
DROP POLICY IF EXISTS tenant_isolation_policy ON saas_users;
DROP INDEX IF EXISTS idx_org;
DROP INDEX IF EXISTS idx_tenant;
DROP INDEX IF EXISTS idx_tenant_org;
DROP INDEX IF EXISTS idx_org_tenant;
DROP TABLE IF EXISTS saas_users;
ふんまと💩ふんまと💩

■ 比較演算子検証
要件: セッションユーザの権限にない組織コードが少なくとも1つ以上存在する場合に、
セレクトできてはいけない。

検証事項: @> or <@ どっちが正しいか?

test-compare.sql
-- **************************************
-- 0. 事前準備
-- **************************************
\echo '■ 0. テスト準備開始'
-- テスト用のテーブルを作成
CREATE TABLE test_table (
    id serial PRIMARY KEY,
    title text,
    content text,
    org_codes text[]
);

-- 'テーブルにデータを挿入';
INSERT INTO test_table (title, content, org_codes) VALUES 
    ('パターン0', '単一組織', ARRAY['org_0']),
    ('パターン1', '単一組織', ARRAY['org_1']),
    ('パターン2', '単一組織', ARRAY['org_2']),
    ('パターン3', '複数組織(2)', ARRAY['org_0', 'org_1']),
    ('パターン4', '複数組織(2)', ARRAY['org_0', 'org_2']),
    ('パターン5', '複数組織(2)', ARRAY['org_1', 'org_2']),
    ('パターン6', '複数組織(3)', ARRAY['org_0', 'org_1', 'org_2']);

-- RLSを有効化
ALTER TABLE test_table ENABLE ROW LEVEL SECURITY;

-- ポリシーを設定
-- Policy: レコードが複数の組織を持つテーブル (単一ポリシーのため、RESTRICTIVEは除去)
CREATE POLICY test_policy on test_table
    FOR ALL 
    USING ( 
        string_to_array(current_setting('app.session_org_code'), ',') @> org_codes
    );

-- テスト用のロールを作成
CREATE ROLE test_user;
GRANT SELECT ON test_table TO test_user;
\echo ''

-- **************************************
-- 1. テスト開始
-- **************************************
\echo '■ 1. テスト開始'
-- スーパーユーザーとして全データを確認
\echo '⇒ [DB 確認]: スーパーユーザでのテーブルフルスキャン';
SELECT * FROM test_table;

-- test_userロールに切り替え
\echo '⇒ [ロール切り替え]: セッション変数未定義のため error';
SET ROLE test_user;
SELECT * FROM test_table;   -- error になる
\echo ''

-- case 1: 単一権限ユーザ
\echo '⇒ [case1: 単一権限] org_0 を持つ場合';
SET app.session_org_code = 'org_0';
SELECT * FROM test_table;

-- case 2: 複数権限ユーザ
\echo '⇒ [case2: 複数権限] org_0,org_1 を持つ場合';
SET app.session_org_code = 'org_0,org_1';
SELECT * FROM test_table;

-- case3: 全権限ユーザ
\echo '⇒ [case3: 全権限] org_0,org_1,org_2 を持つ場合';
SET app.session_org_code = 'org_0,org_1,org_2';
SELECT * FROM test_table;
\echo ''

-- **************************************
-- 2. 事後処理
-- **************************************
\echo '■ 2. 事後処理'
RESET ROLE; -- スーパーユーザに戻す

DROP TABLE test_table;
DROP ROLE test_user;

(https://claude.ai/chat/3bb6234f-9d6e-498b-b88e-fcaa211603a1)

■ 実行コマンド

cat test-compare.sql | docker exec -i postgres_test psql -U testuser -d testdb
ふんまと💩ふんまと💩

■ SuperUserであることの確認

-- PostgreSQL ユーザのテーブルを閲覧
select * from pg_user where usename = 'username'

-- or current_setting関数を使用
select current_setting('is_superuser');

superuserの場合、RLSは機能しない。そのため、権限確認用のカラムで確認する。(計算させる)


ふんまと💩ふんまと💩

■ CSVによる大量データ投入の方法

  1. Python による CSVデータ作成
  2. psql コマンドによる コピー
    https://claude.ai/chat/ad931a5b-d27b-4e49-b1be-7b9aaf0f9297)

▽ 環境用意

docker-compose.yml
version: '3.8'
services:
  python:
    build:
      context: .
      dockerfile: Dockerfile.python
    volumes:
      - ./src:/app/src
      - ./output:/app/output
Dockerfile.python
FROM python:3.11-slim

WORKDIR /app

COPY requirements.txt .

RUN pip install --no-cache-dir -r requirements.txt

CMD ["python", "./src/main.py"]
requirements.txt
faker==19.3.0
pandas==2.1.0
numpy==1.24.3

▽ DB投入用CSV作成スクリプト

main.py
from faker import Faker
import pandas as pd
import random

fake = Faker()

print("CSV生成を開始します...")

# tenant と org の候補を事前に用意
tenants = [f'Company {i}' for i in range(10)]  # Company 0-9
orgs = [f'org{i}' for i in range(10)]         # org0-9

data = []
for i in range(100000):
    data.append({
        'id': i,
        'tenant': random.choice(tenants),  # ランダムに選択
        'name': fake.name(),
        'email': fake.email(),
        'org': random.choice(orgs),        # ランダムに選択
        'date': fake.date()
    })
    if i % 100 == 0:
        print(f"{i}件のデータを生成しました")

df = pd.DataFrame(data)
output_file = '/app/output/sample_data.csv'
df.to_csv(output_file, index=False)

print(f"CSV生成が完了しました: {output_file}")

▽ 投入用コマンド

\copy target_table FROM 'sample_data.csv' with (FORMAT csv, HEADER);

上記のコマンドを利用することで、ファイルパーミッションを気にせずDBにコピーできる
※ csv ファイルはコンテナが参照できる必要あり

ログインするとコメントできます