5
4

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

PostgreSQL→Spanner移行後に248テーブルのER図(A5:SQL Mk-2)を効率的に再構築した話

5
Posted at

はじめに

「DBはSpannerに移行完了!……でもER図がPostgreSQL時代のままだ」

そんな状況になっていませんか。我々のチームも同じ問題に直面しました。248テーブルにおよぶスキーマの変更を、A5:SQL Mk-2形式のER図(.a5er)に反映するという作業です。

全テーブルを手動で書き直せば確実ですが、規模を考えると現実的ではありません。この記事では、gcloudコマンドの落とし穴、.a5erファイル固有の罠、そしてAIエージェントを活用したスキーマ差分マージの3点に絞って、実際にやってみた手順と知見を紹介します。

今回の規模感

項目 数値
移行前 PostgreSQL 実テーブル数 382テーブル
うち移行対象外(廃止機能・廃止予定テーブル等) 134テーブル
移行後スキーマ(Cloud Spanner) 248テーブル
旧ER図(移行前)に記載済み 222テーブル
ER図のページ数 5ページ(機能ドメイン別)
最終的に人手入力が必要だったフィールド 9フィールドのみ

PostgreSQL側には382テーブルありましたが、廃止機能や廃止予定のテーブルは移行対象から外れ、Spannerには248テーブルが移行されました。旧ER図には222テーブルが記載されていましたが、これはDB全体をカバーしておらず、すでに廃止扱いになっていたテーブル等が含まれていなかったためです。

「9フィールドだけ」という数字は最後のセクションで説明します。


Part 1: まずSpannerのDDLを取り出す — gcloud コマンドの罠

最初にやることは、Spannerの現在のスキーマをDDLとして取り出すことです。gcloud spanner databases ddl describe を使います。ここで最初の罠があります。

--database フラグは存在しない

Cloud Shellで以下を実行するとエラーになります。

gcloud spanner databases ddl describe --instance=my-instance --database=my-database
ERROR: (gcloud.spanner.databases.ddl.describe) unrecognized arguments: --database=my-database

--database というフラグは存在しません。データベース名は位置引数として渡す必要があります。

# 正しい書き方
gcloud spanner databases ddl describe my-database --instance=my-instance > schema.sql

これで schema.sql に全テーブルのDDLが出力されます。

Cloud Shell からファイルをダウンロードする

Cloud Shell の内部で生成した schema.sql をローカルに落とすには、ターミナル右上の「⋮」メニューから「ファイルをダウンロード」を選択します。ファイルパスに schema.sql(またはフルパス)を入力するとダウンロードできます。

spanner-cli の場合

spanner-cli を使っている場合、gcloud 相当の「全テーブル一括DDL出力」コマンドはありません。SHOW TABLES; でテーブル一覧を取得し、各テーブルに SHOW CREATE TABLE テーブル名; を実行するループをシェルスクリプトで組む必要があります。テーブル数が多い場合は gcloud 経由の方がはるかに楽です。

出力された DDL の冒頭はこのような形式になっています。

CREATE TABLE mail_send_queues (
  id INT64 NOT NULL,
  user_id INT64 NOT NULL,
  delivery_management_status INT64 NOT NULL,
  send_priority INT64,
  created_at TIMESTAMP NOT NULL,
  insert_id STRING(255) NOT NULL,
  updated_at TIMESTAMP NOT NULL,
  update_id STRING(255) NOT NULL,
  deleted_at TIMESTAMP,
  lock_version INT64 NOT NULL,
) PRIMARY KEY(id);

CREATE INDEX idx_search01_mail_send_queues
ON mail_send_queues(send_mail_id, user_id, delivery_management_status, deleted_at);

PostgreSQLと比較すると、bigint → INT64、character varying(255) → STRING(255) など型表記が変わっています。この違いは後で .a5er に反映するときに重要になります。


Part 2: 差分分析 — 248テーブルを4種類に分類する

DDLが手に入ったので、旧ER図(222テーブル記載)とSpannerスキーマ(248テーブル)の差分を取ります。全テーブルを洗い出したところ、以下の4種類に分類できました。

分類 件数 対処方針
物理名が一致する継続テーブル 187 既存の日本語論理名・コメントをそのまま流用
en_ 接頭辞テーブル(英語版) 57 英語版ER図に54件は既ドキュメント済み、3件だけ新規追加
Spanner固有の新規インフラテーブル 4 カラム構成から推測して論理名を起こす
ER図にあるがSpannerに存在しないテーブル 47 ER図から削除(廃止テーブル等)

なお、382あったPostgreSQLのテーブルのうち移行対象外となった134テーブルは、旧ER図にも記載されていなかった(すでに廃止扱い)ものが大半だったため、「旧ER図 → 新ER図」の差分作業としては上記の分類が実態と合致しています。

「継続テーブル」が187件 = 既存資産をそのまま流用できる

最初に恐れていた「全テーブル再ドキュメント」は杞憂でした。物理名が一致する187テーブルの日本語論理名・コメントは、旧ER図からそのまま引き継げます。

重要なのは差分が何かを正確に特定することです。「新しいスキーマに存在するが旧ER図にない」テーブル・カラムを機械的にリストアップし、それだけを人手で確認します。

リネームを追跡する

単純な「削除+新規追加」ではなく、リネームされたカラムが複数ありました。例えば:

旧名(PostgreSQL) 新名(Spanner) 備考
create_at created_at タイムスタンプ系の誤字修正
meta_discription meta_description スペルミス修正
login_lock_flg inactive_lock_flg 意味をより正確に表す名前に変更
input_select_flg input_select_type フラグ→タイプへの意味変更

これらは「新規カラム」として扱うと論理名を一から書く手間が発生します。旧名と新名のマッピングテーブルを持ち、旧コメントを新名に引き継ぐ処理を入れることで、作業量を大幅に削減できます。

英語版テーブルの扱い

en_ 接頭辞が付いた英語版テーブルは57件ありますが、ほとんどは日本語版テーブルの鏡像です。日本語版のER図と英語版のER図が別ファイルになっていたため、最初は「57件全部新規」に見えましたが、英語版ER図を確認したところ54件はすでにドキュメント済みでした。

新規追加が必要なのは以下の3件だけでした:

テーブル 対処
en_goods 対応する日本語版テーブルの内容を流用
en_mail_read_statuses 日本語版 mail_read_statuses の内容を流用
en_my_goods 日本語版 my_goods の内容を流用

Part 3: .a5er ファイルの構造と罠

.a5er ファイルはXMLではなく、独自のテキスト形式です。セクション区切り([Manager]、[Entity]、[Relation])で構成されています。

Field 行のフォーマット

カラム定義は Field= 行で表現されます。

Field="論理名","物理名","型","NOT NULL",PK順,"DEFAULT","コメント",$色,""

実際のサンプル:

Field="メール配信キューid","id","INT64","NOT NULL",0,"","", $00000000,""
Field="配信管理ステータス","delivery_management_status","INT64","NOT NULL",,"0","0:未送信\n1:配信中\n2:配信完了",$00000000,""
Field="削除日時","deleted_at","TIMESTAMP",,,"","", $00000000,""
  • PK順(5番目の項目):主キーカラムは 0(1番目)、1(2番目)…と入る。非PKは空文字
  • コメント内の改行は \n でエスケープ
  • NOT NULL でないカラムは4番目が空文字

PostgreSQL → Spanner の型マッピング

型表記が大幅に変わります。

PostgreSQL Cloud Spanner
bigint INT64
character varying(255) STRING(255)
text STRING(MAX)
boolean BOOL
timestamp without time zone TIMESTAMP
integer INT64
numeric(10,2) NUMERIC

.a5er の Field= 行の型フィールドは自由テキストなので、Spanner 表記をそのまま書けばツール上での表示は問題ありません。

Index 行のフォーマット

インデックスは IndexInfo= と Index= の2行セットで記述します。

IndexInfo="インデックス名",unique_flag,"カラム1,カラム2,...",""
Index=インデックス名=flag,カラム1,カラム2,...

unique_flag / flag の値:0 = 非ユニーク、2 = ユニーク。

Spanner の CREATE INDEX に STORING 句があっても、.a5er にはカバーリングカラムの概念がないため、インデックスキーカラムのみを記録し STORING 句は省略します。

【要注意】Position と ZOrder を忘れると「画面外」に消える

新規テーブルを追加するとき、最も重要なのが Position= と ZOrder= の記述です。

[Entity]
PName=new_table
LName=新規テーブル
...フィールド定義...
Page=その他
Left=1600
Top=1250
Position="その他",1600,1250,260,144
ZOrder=488

Position= と ZOrder= を省略すると、A5:SQL Mk-2 で開いたときにテーブルが画面外(座標(0,0)相当)に配置されます。 見た目上は「テーブルが消えた」ように見え、ページをスクロールしても見つかりません。

実際にこの問題が発生し、「枠組みの外にテーブルがある」と報告を受けました。

画面外問題の解決方法

各ページの既存エンティティの座標範囲を確認し、その範囲内に収まる座標を新規テーブルに設定します。

# ページ「その他」の既存テーブル座標確認の例
# left: 200〜3750, top: 250〜1750

# 新規テーブルの配置(既存テーブルと重ならない空きスペースを選ぶ)
new_tables = [
    ("table_a", "その他", 1600, 1250),
    ("table_b", "その他", 1950, 1250),
    ("table_c", "その他", 1300, 650),
]

全エンティティの Position を一括スキャンして範囲外のものを検出するスクリプトを書くと安全です。

RDBMSType について

.a5er の [Manager] セクションに RDBMSType= があります。PostgreSQL は 7 です。

RDBMSType=7
RDBMSTypeName=PostgreSQL

A5:SQL Mk-2(バージョン2.21.2時点)に Cloud Spanner 専用の RDBMSType 番号は存在しないようです。Field= 行の型はテキストとして記録されるため、型表記を Spanner 形式(INT64、STRING(N) 等)に書き換えてもツール上での表示に問題はありません。RDBMSType=7 のままにして、型の文字列だけ Spanner 表記に切り替えるのが現実的な運用です。


Part 4: Python スクリプトで差分マージを自動化する

248テーブル × 平均10カラム以上を手作業で更新するのは現実的ではありません。Python で自動化します。処理の全体像はこうです。

schema.sql + 旧JP .a5er + 旧EN .a5er
         ↓ Pythonスクリプト
新JP .a5er + 新EN .a5er

処理の流れ

Step 1: schema.sql をパース

import re

def parse_schema(sql_text):
    tables = {}
    # CREATE TABLE ブロックを抽出
    table_pattern = re.compile(
        r'CREATE TABLE (\w+)\s*\((.*?)\)\s*PRIMARY KEY\(([^)]+)\)',
        re.DOTALL
    )
    for m in table_pattern.finditer(sql_text):
        table_name = m.group(1)
        body = m.group(2)
        pk_cols = [c.strip() for c in m.group(3).split(',')]
        # カラム行をパース
        columns = parse_columns(body, pk_cols)
        tables[table_name] = columns
    return tables

Step 2: 旧 .a5er からエンティティブロックを読み込む

def parse_a5er(filepath):
    entities = {}
    with open(filepath, encoding='utf-8') as f:
        content = f.read()
    # [Entity] ブロックを分割
    blocks = re.split(r'\n(?=\[)', content)
    for block in blocks:
        if not block.startswith('[Entity]'):
            continue
        pname = re.search(r'^PName=(.+)$', block, re.MULTILINE).group(1)
        fields = parse_fields(block)   # Field= 行をパースして辞書化
        entities[pname] = {'block': block, 'fields': fields}
    return entities

Step 3: 物理名でマッチングして論理名・コメントを継承

RENAME_MAP = {
    # (テーブル名, 旧カラム名): 新カラム名
    ('page_tip_informations', 'meta_discription'): 'meta_description',
    ('staffs', 'login_lock_flg'): 'inactive_lock_flg',
    # ... 他のリネームマッピング
}

def merge_fields(schema_cols, old_entity_fields, pname):
    new_fields = []
    for col in schema_cols:
        pname_col = col['physical_name']
        # リネームマッピングを逆引き(新名 → 旧名)
        old_pname = get_old_name(pname, pname_col)
        old_field = old_entity_fields.get(old_pname or pname_col)
        if old_field:
            # 既存の論理名・コメントを引き継ぐ
            col['lname'] = old_field['lname']
            col['comment'] = old_field['comment']
        new_fields.append(col)
    return new_fields

Step 4: 削除テーブルを除去、新規テーブルを追加

# スキーマに存在しないテーブルをER図から削除
removed_tables = set(old_entities.keys()) - set(schema_tables.keys())

# Relation ブロックも整合性を保って削除
def filter_relations(content, removed_tables):
    # Entity1 または Entity2 が削除対象ならリレーションも削除
    ...

Step 5: .a5er 形式で出力

def format_field(col):
    pk_ord = str(col['pk_ord']) if col['pk_ord'] is not None else ''
    notnull = '"NOT NULL"' if col['not_null'] else '""'
    return (
        f'Field="{col["lname"]}","{col["physical_name"]}",'
        f'"{col["type"]}",{notnull},{pk_ord},'
        f'"{col["default"]}","{col["comment"]}",$00000000,""'
    )

AIエージェントに何を委任したか

スクリプトの実装自体をAIエージェントに委任しました。特に効果的だったのは次の3点です。

  1. 差分分析(どのテーブルが新規/削除/継続かのカウントと一覧)
  2. リネームマッピングの推定(meta_discription → meta_description などのスペルミス修正系)
  3. 新規インフラテーブルの論理名の仮起こし(カラム構成から用途を推測)

一方、人間が確認した内容は以下の9フィールドに絞られました。

テーブル.カラム 確認内容
goods.tech_external_site_url 外部サイトURL(既存のexternal_site_urlと類似の用途か)
goods_authority_urls.goods_authority_group_id 商品認証グループID(グループ単位のFK追加)
inquiry_send_files.physical_name ストレージ上の実ファイル名(UUID等)
inquiry_send_files.contamination_status ウイルス/マルウェアスキャン結果のステータス
processing_locks.lock_time INT64型のロック時間(秒数またはUNIXタイムスタンプ)
mail_address_whitelists.comment 登録理由等の自由記述欄
page_tip_informations.information_category_id お知らせカテゴリIDへのFK
(新規テーブル)chaos_lock_targets カオステスト用ロック対象テーブル
(新規テーブル)customer_sessions Spannerセッションストア(7日で自動削除)

248テーブル全体の中で、人間が判断した項目はこれだけです。残りは機械的に解決できました。


Part 5: 型マッピングの実装例

.a5er に書き込む前に、PostgreSQL型をSpanner型に変換する関数を用意します。

def pg_to_spanner_type(pg_type):
    """PostgreSQL型をSpanner型表記に変換"""
    pg_type = pg_type.strip().lower()

    if pg_type in ('bigint', 'integer', 'smallint', 'serial', 'bigserial'):
        return 'INT64'
    if pg_type == 'boolean':
        return 'BOOL'
    if pg_type in ('text',):
        return 'STRING(MAX)'
    if pg_type.startswith('character varying') or pg_type.startswith('varchar'):
        m = re.search(r'\((\d+)\)', pg_type)
        n = m.group(1) if m else 'MAX'
        return f'STRING({n})'
    if pg_type.startswith('timestamp'):
        return 'TIMESTAMP'
    if pg_type.startswith('numeric') or pg_type.startswith('decimal'):
        return 'NUMERIC'
    if pg_type in ('real', 'double precision', 'float'):
        return 'FLOAT64'
    if pg_type == 'bytea':
        return 'BYTES(MAX)'
    if pg_type == 'date':
        return 'DATE'
    if pg_type == 'json' or pg_type == 'jsonb':
        return 'JSON'

    # 不明な型はそのまま返す(手動確認を促す)
    print(f"[WARN] unknown type: {pg_type}")
    return pg_type

Spannerではすでに正しい型がDDLに書かれているので、schema.sqlがSpannerのDDLであれば変換不要です。旧PostgreSQLのER図の型を更新するときに使います。


まとめ

今回のアプローチで解決した3つの問題

問題 解決方法
gcloud コマンドのフラグ間違い --database ではなく位置引数で渡す
248テーブルの全更新は手作業不可 物理名でマッチング→論理名・コメントを継承する差分マージ
新規テーブルが画面外に出る Position= と ZOrder= を必ず設定し、座標範囲内に収める

.a5er ファイルでSpannerを扱う際のチェックリスト

  • Field= 行の型を Spanner 表記(INT64、STRING(N)、BOOL 等)に更新した
  • リネームされたカラムは旧コメントを引き継いだ
  • 削除テーブルに関連する [Relation] ブロックも削除した
  • 新規エンティティに Position= と ZOrder= を設定した
  • 新規エンティティの座標が各ページの既存テーブルの範囲内に収まっている
  • RDBMSType は 7(PostgreSQL)のまま残す(Spanner専用IDは未確認)

効率化の核心

248テーブルのうち、187テーブルは物理名一致で自動引き継ぎ、57件の en_ テーブルは54件が既ドキュメント済みでした。本当に人手が必要だったのは、新規ビジネスフィールド9件と新規インフラテーブルの論理名確認だけです。

「大規模なドキュメント更新」も、差分を正確に把握してスコープを絞れば、コアの意思決定作業は驚くほど小さくなります。


おわりに

SpannerへのDB移行は完了しても、ER図の更新は後回しになりがちです。今回の経験から言えるのは、「DDLがあれば差分は機械的に解決できる」 ということです。A5:SQL Mk-2 の .a5er 形式はテキストベースなので、スクリプトで直接操作できます。

同じような状況(DB移行後のドキュメント追いつき作業)で悩んでいる方の参考になれば幸いです。

5
4
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
5
4

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?