第169回 SQLite移行前後の検索結果を突合する:Pythonで作る読み取り回帰テスト

書き込み試験に合格しても、既存の一覧や集計が移行前と同じ結果を返すとは限りません。

今回は、同じスナップショットを起点とする移行前後のSQLiteへ、既存業務を代表する3本のSELECTを実行します。列名、行順、値、SQLiteから返された型を突き合わせ、差分があれば切り替えを止めます。

稼働中のデータベースや、取得時点の異なるファイルは比較対象にしません。

3種類の試験を分ける

試験 確認できること 確認できないこと
構造検査 列、制約、インデックス、スキーマバージョン 既存の検索が同じ結果を返すか
書き込み試験 登録、更新、拒否、ロールバック 一覧や集計の互換性
読み取り回帰テスト 指定した検索の列名、行順、値、型 指定していない検索や、結果自体の業務上の正しさ

新しく追加した列の機能確認など、移行後だけに存在する動作は、移行前との比較ではなく別の期待値テストで確認します。

今回の実行契約

項目 仕様
動作環境 Python 3.10以上、外部ライブラリ不要
入力 同じスナップショットを起点とする、移行前後の独立したSQLiteファイル
入力の状態 書き込みが終了し、隣に-wal、-shm、-journalがないこと
接続 URIのmode=roとPRAGMA query_only=ONを使用する
比較 列名、行数、行順、値、値の型を完全一致で比較する
生成物 新しいJSONレポート。既存ファイルは上書きしない
レポート内容 件数、列名、結果のSHA-256、最初の差分の種類と位置。セル値は記録しない
対象外 性能測定、意図したデータ変換の承認、稼働中DBの比較、未指定クエリの保証

ファイルを読み取り専用で開く指定はmode=roです。query_onlyも設定しますが、これだけでデータベースファイルが完全な読み取り専用になるわけではありません。

複数行を返すクエリでは、一意になる並び順を指定します。同順位が残る場合は主キーまでORDER BYへ追加します。

1.再現用のSQLiteを作る

新しい空のディレクトリを用意します。コマンド例はPOSIX系シェル向けです。

mkdir sqlite-read-regression-demo
cd sqlite-read-regression-demo

次のコードをmake_fixture.pyとして保存します。移行前のbaseline.sqliteを作り、そのファイルを複製してからpriority列を追加したcandidate.sqliteを作ります。データは動作確認用です。

import json
import shutil
import sqlite3
from pathlib import Path

BASELINE = Path('baseline.sqlite')
CANDIDATE = Path('candidate.sqlite')
OUTPUTS = (BASELINE, CANDIDATE)

SCHEMA_V1 = '''
CREATE TABLE review_items (
    batch_id TEXT NOT NULL,
    record_id TEXT NOT NULL,
    proposed_category TEXT NOT NULL,
    status TEXT NOT NULL CHECK (
        status IN ('pending', 'accepted', 'corrected', 'rejected')
    ),
    confirmed_category TEXT,
    PRIMARY KEY (batch_id, record_id)
);
'''

ROWS = (
    ('B001', 'R001', '配送', 'pending', None),
    ('B001', 'R002', '請求', 'accepted', '請求'),
    ('B001', 'R003', '配送', 'corrected', 'その他'),
    ('B002', 'R004', '請求', 'rejected', None),
)


def create_baseline(path):
    with path.open('xb'):
        pass

    connection = sqlite3.connect(path)
    try:
        connection.executescript(SCHEMA_V1)
        connection.executemany(
            '''
            INSERT INTO review_items (
                batch_id,
                record_id,
                proposed_category,
                status,
                confirmed_category
            ) VALUES (?, ?, ?, ?, ?)
            ''',
            ROWS,
        )
        connection.execute('PRAGMA user_version = 1')
        connection.commit()
    except BaseException:
        connection.rollback()
        raise
    finally:
        connection.close()


def copy_database(source, destination):
    with source.open('rb') as source_handle:
        with destination.open('xb') as destination_handle:
            shutil.copyfileobj(source_handle, destination_handle)


def migrate_candidate(path):
    connection = sqlite3.connect(path)
    try:
        connection.execute(
            '''
            ALTER TABLE review_items
            ADD COLUMN priority INTEGER NOT NULL DEFAULT 0
                CHECK (priority BETWEEN 0 AND 3)
            '''
        )
        connection.execute('PRAGMA user_version = 2')
        connection.commit()
    except BaseException:
        connection.rollback()
        raise
    finally:
        connection.close()


def main():
    if any(path.exists() for path in OUTPUTS):
        raise SystemExit('run this fixture in a new empty directory')

    try:
        create_baseline(BASELINE)
        copy_database(BASELINE, CANDIDATE)
        migrate_candidate(CANDIDATE)
    except BaseException:
        for path in OUTPUTS:
            path.unlink(missing_ok=True)
        raise

    print(json.dumps(
        {'files': [str(path) for path in OUTPUTS], 'status': 'created'},
        ensure_ascii=False,
        separators=(',', ':'),
        sort_keys=True,
    ))


if __name__ == '__main__':
    main()

2.読み取り回帰テストを実装する

次のコードをsqlite_read_regression.pyとして保存します。実行するSQLはCASESへ固定し、コマンドラインから任意のSQLを渡せない形にします。

import argparse
import hashlib
import json
import os
import sqlite3
import sys
from pathlib import Path

MAX_ROWS = 10_000
MAX_COLUMNS = 100
MAX_RESULT_BYTES = 20 * 1024 * 1024
PROGRESS_INTERVAL = 1_000
MAX_PROGRESS_CALLS = 20_000

CASES = (
    {
        'name': 'pending_items',
        'sql': '''SELECT batch_id, record_id, proposed_category, status
FROM review_items
WHERE status = ?
ORDER BY batch_id, record_id''',
        'params': ('pending',),
    },
    {
        'name': 'status_counts',
        'sql': '''SELECT status, COUNT(*) AS item_count
FROM review_items
GROUP BY status
ORDER BY status''',
        'params': (),
    },
    {
        'name': 'final_categories',
        'sql': '''SELECT
    batch_id,
    record_id,
    CASE
        WHEN status = 'corrected' THEN confirmed_category
        ELSE proposed_category
    END AS final_category
FROM review_items
WHERE status IN (?, ?)
ORDER BY batch_id, record_id''',
        'params': ('accepted', 'corrected'),
    },
)


class InputError(Exception):
    pass


class CaseRunError(Exception):
    pass


def compact_bytes(value):
    return json.dumps(
        value,
        ensure_ascii=False,
        separators=(',', ':'),
        sort_keys=True,
        allow_nan=False,
    ).encode('utf-8')


def emit(stream, value):
    print(json.dumps(
        value,
        ensure_ascii=False,
        separators=(',', ':'),
        sort_keys=True,
        allow_nan=False,
    ), file=stream, flush=True)


def sha256_file(path):
    digest = hashlib.sha256()
    with path.open('rb') as handle:
        while True:
            chunk = handle.read(1024 * 1024)
            if not chunk:
                break
            digest.update(chunk)
    return digest.hexdigest()


def find_sidecars(entries):
    found = []
    for label, path in entries:
        for suffix in ('-wal', '-shm', '-journal'):
            sidecar = path.with_name(path.name + suffix)
            if sidecar.exists():
                found.append((label, sidecar))
    return found


def validate_inputs(baseline_path, candidate_path, report_path):
    for label, path in (
        ('baseline', baseline_path),
        ('candidate', candidate_path),
    ):
        if not path.is_file():
            raise InputError(f'{label} is not a file: {path}')

    if baseline_path.samefile(candidate_path):
        raise InputError('baseline and candidate must be different files')
    if report_path.exists():
        raise InputError(f'report already exists: {report_path}')
    if not report_path.parent.is_dir():
        raise InputError(f'report directory does not exist: {report_path.parent}')

    names = set()
    for case in CASES:
        name = case['name']
        if name in names:
            raise InputError(f'duplicate case name: {name}')
        names.add(name)

        words = case['sql'].split(None, 1)
        if not words or words[0].upper() != 'SELECT':
            raise InputError(f'case must start with SELECT: {name}')


def open_readonly(path):
    uri = path.resolve().as_uri() + '?mode=ro'
    connection = sqlite3.connect(
        uri,
        uri=True,
        isolation_level=None,
        timeout=5.0,
    )
    try:
        connection.execute('PRAGMA query_only = ON')
        connection.execute('BEGIN')
        connection.execute('SELECT COUNT(*) FROM sqlite_master').fetchone()
        return connection
    except BaseException:
        connection.close()
        raise


def normalize_value(value):
    if value is None:
        return ['null', None]
    if type(value) is int:
        return ['integer', str(value)]
    if type(value) is float:
        return ['real', value.hex()]
    if type(value) is str:
        return ['text', value]
    if isinstance(value, (bytes, bytearray, memoryview)):
        return ['blob', bytes(value).hex()]
    raise CaseRunError(
        f'unsupported SQLite result type: {type(value).__name__}'
    )


def add_digest_piece(digest, piece):
    digest.update(len(piece).to_bytes(8, 'big'))
    digest.update(piece)


def read_result(connection, case):
    calls = 0
    cursor = None

    def progress():
        nonlocal calls
        calls += 1
        return 1 if calls > MAX_PROGRESS_CALLS else 0

    connection.set_progress_handler(progress, PROGRESS_INTERVAL)
    try:
        cursor = connection.execute(case['sql'], case['params'])
        if cursor.description is None:
            raise CaseRunError('query did not return a result set')

        columns = [description[0] for description in cursor.description]
        if len(columns) > MAX_COLUMNS:
            raise CaseRunError('column limit exceeded')

        digest = hashlib.sha256()
        header_piece = compact_bytes(['columns', columns])
        total_bytes = len(header_piece)
        if total_bytes > MAX_RESULT_BYTES:
            raise CaseRunError('result byte limit exceeded')
        add_digest_piece(digest, header_piece)

        rows = []
        while True:
            row = cursor.fetchone()
            if row is None:
                break
            if len(rows) >= MAX_ROWS:
                raise CaseRunError('row limit exceeded')

            normalized = [normalize_value(value) for value in row]
            row_piece = compact_bytes(normalized)
            total_bytes += len(row_piece)
            if total_bytes > MAX_RESULT_BYTES:
                raise CaseRunError('result byte limit exceeded')

            add_digest_piece(digest, row_piece)
            rows.append(normalized)

        return {
            'columns': columns,
            'rows': rows,
            'row_count': len(rows),
            'sha256': digest.hexdigest(),
        }
    except sqlite3.OperationalError as exc:
        if calls > MAX_PROGRESS_CALLS:
            raise CaseRunError('query operation budget exceeded') from exc
        raise
    finally:
        connection.set_progress_handler(None, 0)
        if cursor is not None:
            cursor.close()


def definition_hash(case):
    payload = {
        'params': list(case['params']),
        'sql': case['sql'],
    }
    return hashlib.sha256(compact_bytes(payload)).hexdigest()


def result_summary(result):
    return {
        'columns': result['columns'],
        'row_count': result['row_count'],
        'sha256': result['sha256'],
    }


def compare_case(baseline_connection, candidate_connection, case):
    payload = {
        'definition_sha256': definition_hash(case),
        'name': case['name'],
    }
    results = {}
    errors = {}

    for label, connection in (
        ('baseline', baseline_connection),
        ('candidate', candidate_connection),
    ):
        try:
            results[label] = read_result(connection, case)
        except (sqlite3.Error, CaseRunError, UnicodeError) as exc:
            errors[label] = {
                'message': str(exc),
                'type': type(exc).__name__,
            }

    if errors:
        for label, result in results.items():
            payload[label] = result_summary(result)
        payload['errors'] = errors
        payload['status'] = 'error'
        return payload

    baseline = results['baseline']
    candidate = results['candidate']
    payload['baseline'] = result_summary(baseline)
    payload['candidate'] = result_summary(candidate)

    first_difference = None
    if baseline['columns'] != candidate['columns']:
        common_columns = min(
            len(baseline['columns']),
            len(candidate['columns']),
        )
        column_index = common_columns + 1
        for index in range(common_columns):
            if baseline['columns'][index] != candidate['columns'][index]:
                column_index = index + 1
                break
        first_difference = {
            'column_index': column_index,
            'kind': 'columns',
        }
    else:
        common_rows = min(baseline['row_count'], candidate['row_count'])
        for row_index in range(common_rows):
            baseline_row = baseline['rows'][row_index]
            candidate_row = candidate['rows'][row_index]
            if baseline_row == candidate_row:
                continue

            for cell_index, (baseline_cell, candidate_cell) in enumerate(
                zip(baseline_row, candidate_row)
            ):
                if baseline_cell != candidate_cell:
                    difference_kind = (
                        'type'
                        if baseline_cell[0] != candidate_cell[0]
                        else 'value'
                    )
                    first_difference = {
                        'column': baseline['columns'][cell_index],
                        'column_index': cell_index + 1,
                        'kind': difference_kind,
                        'row_number': row_index + 1,
                    }
                    break
            break

        if first_difference is None and (
            baseline['row_count'] != candidate['row_count']
        ):
            first_difference = {
                'kind': 'row_count',
                'row_number': common_rows + 1,
            }

    if first_difference is None:
        payload['status'] = 'passed'
    else:
        payload['first_difference'] = first_difference
        payload['status'] = 'failed'
    return payload


def write_report(report_path, document):
    payload = json.dumps(
        document,
        ensure_ascii=False,
        indent=2,
        sort_keys=True,
        allow_nan=False,
    )
    created = False
    try:
        with report_path.open('x', encoding='utf-8', newline='') as handle:
            created = True
            handle.write(payload)
            handle.write(chr(10))
            handle.flush()
            os.fsync(handle.fileno())
    except FileExistsError as exc:
        raise InputError(f'report already exists: {report_path}') from exc
    except BaseException:
        if created:
            try:
                report_path.unlink()
            except OSError:
                pass
        raise


def run(args):
    baseline_path = args.baseline
    candidate_path = args.candidate
    report_path = args.report
    validate_inputs(baseline_path, candidate_path, report_path)

    entries = (
        ('baseline', baseline_path),
        ('candidate', candidate_path),
    )
    initial_sidecars = find_sidecars(entries)
    if initial_sidecars:
        details = ', '.join(
            f'{label}:{path.name}' for label, path in initial_sidecars
        )
        raise InputError(f'SQLite sidecar found: {details}')

    initial_hashes = {
        'baseline': sha256_file(baseline_path),
        'candidate': sha256_file(candidate_path),
    }

    baseline_connection = None
    candidate_connection = None
    try:
        baseline_connection = open_readonly(baseline_path)
        candidate_connection = open_readonly(candidate_path)
        baseline_version = baseline_connection.execute(
            'PRAGMA user_version'
        ).fetchone()[0]
        candidate_version = candidate_connection.execute(
            'PRAGMA user_version'
        ).fetchone()[0]
        case_reports = [
            compare_case(baseline_connection, candidate_connection, case)
            for case in CASES
        ]
    finally:
        for connection in (candidate_connection, baseline_connection):
            if connection is not None:
                connection.close()

    final_hashes = {
        'baseline': sha256_file(baseline_path),
        'candidate': sha256_file(candidate_path),
    }
    post_sidecars = find_sidecars(entries)

    stability_problems = []
    for label in ('baseline', 'candidate'):
        if initial_hashes[label] != final_hashes[label]:
            stability_problems.append(f'{label}_changed_during_test')
    for label, path in post_sidecars:
        stability_problems.append(
            f'{label}_sidecar_appeared:{path.name}'
        )

    if stability_problems:
        overall_status = 'error'
        exit_code = 2
    elif all(case['status'] == 'passed' for case in case_reports):
        overall_status = 'passed'
        exit_code = 0
    else:
        overall_status = 'failed'
        exit_code = 1

    document = {
        'case_count': len(case_reports),
        'cases': case_reports,
        'input_stability': {
            'problems': stability_problems,
            'status': 'passed' if not stability_problems else 'failed',
        },
        'inputs': {
            'baseline': {
                'file': baseline_path.name,
                'sha256': initial_hashes['baseline'],
                'user_version': baseline_version,
            },
            'candidate': {
                'file': candidate_path.name,
                'sha256': initial_hashes['candidate'],
                'user_version': candidate_version,
            },
        },
        'status': overall_status,
        'version': 1,
    }
    write_report(report_path, document)
    emit(sys.stdout, {
        'case_count': len(case_reports),
        'report': str(report_path),
        'status': overall_status,
    })
    return exit_code


def main(argv=None):
    parser = argparse.ArgumentParser()
    parser.add_argument('baseline', type=Path)
    parser.add_argument('candidate', type=Path)
    parser.add_argument('report', type=Path)
    args = parser.parse_args(argv)

    try:
        return run(args)
    except InputError as exc:
        emit(sys.stderr, {'error': str(exc), 'status': 'input_error'})
        return 2
    except KeyboardInterrupt:
        emit(sys.stderr, {'error': 'interrupted', 'status': 'interrupted'})
        return 130
    except (OSError, sqlite3.Error) as exc:
        emit(sys.stderr, {
            'error': str(exc),
            'error_type': type(exc).__name__,
            'status': 'execution_error',
        })
        return 3
    except Exception as exc:
        emit(sys.stderr, {
            'error': str(exc),
            'error_type': type(exc).__name__,
            'status': 'unexpected_error',
        })
        return 3


if __name__ == '__main__':
    raise SystemExit(main())

3.正常系を実行する

python make_fixture.py
python sqlite_read_regression.py baseline.sqlite candidate.sqlite report.json
echo $?

標準出力と終了コードは次のようになります。

{"files":["baseline.sqlite","candidate.sqlite"],"status":"created"}
{"case_count":3,"report":"report.json","status":"passed"}
0

レポートは標準ライブラリで整形できます。

python -m json.tool report.json
項目 意味
definition_sha256 SQLとバインド値をまとめたテスト定義の識別値
columns SQLiteが返した列名と順序
row_count 結果行数
sha256 型付きで正規化した列名と全行の結果ハッシュ
first_difference 最初に見つかった差分の種類と、1から始まる列番号または行番号
input_stability 試験中に入力ファイルやサイドカーファイルが変化しなかったか

first_difference.kindは、列構成の差ならcolumns、行数の差ならrow_count、セルの型の差ならtype、同じ型の値の差ならvalueです。列構成の差には最初に異なるcolumn_index、セルの差にはrow_number、column_index、列名を記録します。行数の差では、片方だけに存在する最初の行番号をrow_numberへ記録します。

値は内部でinteger、real、text、blob、nullのタグ付き表現へ変換します。Pythonでは1 == 1.0が真になりますが、このテストでは整数と実数を別の型として検出します。

4.差分を検出する

次の操作は、ここで作ったcandidate.sqliteだけに実行してください。

make_difference.pyとして保存します。

import sqlite3

connection = sqlite3.connect('candidate.sqlite')
try:
    cursor = connection.execute(
        '''
        UPDATE review_items
        SET status = ?, confirmed_category = ?
        WHERE batch_id = ? AND record_id = ?
        ''',
        ('accepted', '配送', 'B001', 'R001'),
    )
    if cursor.rowcount != 1:
        raise RuntimeError('expected exactly one updated row')
    connection.commit()
except BaseException:
    connection.rollback()
    raise
finally:
    connection.close()

report.jsonは上書きできないため、新しい出力名を指定します。

python make_difference.py
python sqlite_read_regression.py baseline.sqlite candidate.sqlite report-diff.json
echo $?

検索結果が一致しないため、終了コードは1になります。

{"case_count":3,"report":"report-diff.json","status":"failed"}
1

report-diff.jsonには、各失敗ケースで最初に見つかった差分の種類と位置が記録されます。セル値は複製しません。ただし、列名、件数、ハッシュも業務データから派生した情報です。レポートには元データに準じたアクセス制御を適用します。

終了コードを後続処理へ渡す

終了コード 意味 判断
0 全クエリが一致し、入力も変化しなかった ほかの切り替え条件の確認へ進む
1 結果差分またはクエリ実行エラー 切り替えを止め、SQLと移行内容を確認する
2 入力契約違反、既存レポート、サイドカー、入力内容の変化 隔離コピーと実行条件を確認する
3 ファイル操作、SQLite接続、予期しない実行エラー 実行環境を確認する
130 Ctrl+Cによる中断 未完了として扱う

argparseが引数不足を検出した場合も終了コードは2です。この場合は通常の使用方法が表示され、1行JSONにはなりません。

実データへ合わせるときの注意点

  • 稼働中DBを直接指定しない:SQLiteのバックアップAPIなどで閉じたスナップショットを作り、移行候補はそのスナップショットから作成します。
  • 重要な検索を選ぶ:未処理一覧、確定結果、請求対象など、切り替え後に使う検索を少数から登録します。
  • 並び順を固定する:複数行を比較する場合は、一意キーまで含めたORDER BYを指定します。ORDER BYがない結果や、同順位が残る結果の順序は保証されません。
  • 意図した差分を混ぜない:移行で値を変える仕様なら、完全一致の条件を緩めず、変換後の期待値を確認する別テストに分けます。
  • 同じ誤りは検出できない:移行前後が同じ誤った値を返せば合格します。業務上の正解を確認するテストの代わりにはなりません。
  • 大規模な抽出には使わない:1ケースあたり1万行、100列、正規化したJSON断片の合計20MiBで停止します。進捗ハンドラーによる上限は、処理時間の上限ではありません。
  • ハッシュを改ざん証明にしない:SHA-256は対象や結果の取り違え検出には使えますが、データベースとレポートの両方を変更できる利用者に対する改ざん証明にはなりません。

最初は、切り替え後に必ず使う検索を1本だけCASESへ移します。隔離コピーで合格条件を確認してから、一覧、集計、確定データの順に増やします。

一次資料