【Python】SQLiteにデータを保存・検索する:SQLはプレースホルダーで渡す

PythonのTopに戻る

結論:SQLの構造は固定し、値はプレースホルダーで渡す

SQLiteへ記録を保存・検索するときは、SQL文の中へ値を文字列連結せず、?などのプレースホルダーとパラメーターを使う。名前に引用符があっても、SQLの命令として解釈せず値として渡せる。検索条件を毎回文字列として組み立てるより、安全性だけでなくコードの見通しもよくなる。

SQLiteはファイルを使うデータベースで、Pythonの標準ライブラリsqlite3から利用できる。全件を読み込んでから絞るCSVとは違い、WHEREで条件を指定して必要な行を取得できる。ただし保存形式を変えるだけで全ての検索が高速になるわけではなく、件数や検索条件に応じて索引や設計を考える必要がある。まずは小さな表で値の渡し方と保存の確定を確認しよう。

そのまま動かせる例

例は一時フォルダーのデータベースへ3件の測定値を保存し、値が2以上の行を取り出す。3件目の名前にはSQLのような文字列を意図的に使い、あくまでデータとして保存・検索されることを確かめる。既存のデータベースへ接続したり、実データの表を削除したりする操作ではない。標準ライブラリだけで動く。

Python 3.12で追加されたautocommit引数を明示しているため、3.12以降で実行してほしい。autocommit=Falseを選び、書き込みをwith conの範囲で確定する。外側のclosingは接続を閉じるためのものである。一時フォルダーは処理終了時に削除されるので、実際に記録を残す場合は自分の保存先を使い、既存ファイルと区別する。

from contextlib import closing
from pathlib import Path
from tempfile import TemporaryDirectory
import sqlite3

with TemporaryDirectory() as folder:
    path = Path(folder) / "measurements.sqlite3"
    tricky = "A'); DROP TABLE readings;--"
    with closing(sqlite3.connect(path, autocommit=False)) as con:
        with con:
            con.execute("CREATE TABLE readings (id INTEGER PRIMARY KEY, name TEXT NOT NULL, value REAL NOT NULL)")
            con.executemany("INSERT INTO readings(name, value) VALUES (?, ?)",
                            [("sample-A", 1.25), ("sample-B", 3.5), (tricky, 2.0)])
        rows = con.execute("SELECT name, value FROM readings WHERE value >= ? ORDER BY id", (2.0,)).fetchall()
        literal = con.execute("SELECT name FROM readings WHERE name = ?", (tricky,)).fetchone()
        assert literal == (tricky,)
        assert rows == [("sample-B", 3.5), (tricky, 2.0)]
        assert con.execute("SELECT count(*) FROM readings").fetchone()[0] == 3
        print("selected rows:", rows)
        print("SQL-like text kept as data:", literal == (tricky,))
    with closing(sqlite3.connect(path, autocommit=False)) as con:
        count = con.execute("SELECT count(*) FROM readings").fetchone()[0]
        assert count == 3
        print("rows after reconnect:", count)

実行結果

selected rows: [('sample-B', 3.5), ("A'); DROP TABLE readings;--", 2.0)]
SQL-like text kept as data: True
rows after reconnect: 3

1個のパラメーターも列として渡す

検索条件が一つだけでも、パラメーターは(2.0,)のようなタプルにする。末尾のカンマがない(2.0)は単なる数値の括弧であり、タプルではない。文字列もそのまま渡すと文字ごとの列として誤解される原因になるので、(name,)の形を使う。複数行の追加にはexecutemanyへ各行のパラメーター列を渡す。

プレースホルダーで置き換えられるのは値であり、テーブル名や列名、ORDER BYの構文そのものではない。利用者が並べ替え列を選べる場合は、事前に決めた許可リストから既知のSQLを選ぶなど別の方法が必要になる。値の指定をパラメーター化したから、任意のSQL断片まで安全に受け付けられるわけではない。

取得した行と保存状態を確認する

fetchallは結果をまとめて取り出し、この例ではタプルのリストになる。fetchoneは1行だけを返し、該当がなければNoneになるので、その場合も考慮したい。SQLはORDER BYを付けない限り表示順を保証するものではない。比較や再現可能な出力が必要なら、並び順を明示する。大きな結果を全件fetchallするとメモリを使うので、必要なら反復や小分け取得を使う。

接続をいったん閉じて開き直しても3行残ることを確認している。これは書き込みが確定したことを確かめるためである。with conはトランザクションの成功・失敗を扱うが、接続そのものを閉じる操作ではない。autocommitの設定によって動作が変わるため、古い例のisolation_level任せの挙動と混ぜず、手元のPython版と設定を合わせて理解しよう。

型とSQLの制約は入口の検証と組み合わせる

この表は名前と値をNOT NULLにしているが、業務上の全ての条件を検証するものではない。測定単位、許される範囲、同じ試料の重複などは必要に応じて制約や入力検証を追加する。SQLiteの型の扱いもPythonの型注釈とは異なる。文字列として保存する日時の形式など、後で別のプログラムが読むときの規則も決めておこう。

パラメーター化はSQL注入対策の基本だが、アクセス権限やバックアップ、機密データの保存方針まで代わりに決めるものではない。複数の関連した更新を一まとまりにする場合は、途中で失敗した際のrollbackも確認する必要がある。まず値が正しく往復し、接続を閉じても必要な記録が残ることを小さな例で確かめてから、実際のデータへ進めるとよい。

確認環境と参考資料

例はLinux・CPython 3.12.14で実行した。掲載した出力はこの環境での結果である。公式資料のstable版や最新版は更新されるため、手元のバージョンと対応する仕様も確認してほしい。

関連項目:SQLiteの途中失敗で半端な更新を残さない:commitとrollback / JSONで保存できない値をどう扱う?日時・数値・日本語の受け渡し

PythonのTopに戻る