2
3

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Python 3.14のt-stringでSQLと値を分離する

2
Posted at

Python 3.14のt-stringでSQLと値を分離する

f-stringでSQLを組み立てると、補間した瞬間に固定SQLと外部から来た値の境界が消えます。後段の関数が受け取るのは、すでに完成したstrだけです。

Python 3.14で追加されたt-stringは、ここが違います。結果はstrではなくstring.templatelib.Templateで、固定部分と補間値を別々に取得できます。

ただし、t-stringに置き換えるだけでSQLが安全になるわけではありません。Templateを受け取る側で「固定部分はSQL本文、補間部分はDBパラメータ」と決めるところまでが設計です。

f-stringでは値の由来が消える

Python 3.14と標準ライブラリのsqlite3だけで確認します。

まず、避けたい書き方です。

# Python 3.14
import sqlite3

con = sqlite3.connect(":memory:")
con.execute("CREATE TABLE users (name TEXT)")
con.executemany(
    "INSERT INTO users VALUES (?)",
    [("alice",), ("bob",)],
)

name = "alice' OR 1=1 --"
sql = f"SELECT name FROM users WHERE name = '{name}'"

print(sql)
print(con.execute(sql).fetchall())

生成されるSQLはこうなります。

SELECT name FROM users WHERE name = 'alice' OR 1=1 --'
[('alice',), ('bob',)]

問題はf-stringそのものというより、値をSQL本文へ埋め込んだことです。

nameがどこから来た値だったとしても、f-stringを評価した後には、

"SELECT name FROM users WHERE name = 'alice' OR 1=1 --'"

という一つのstrしか残りません。

後段でこれを受け取った関数は、SELECT name ...の部分とname由来の部分を構造として区別できません。

もちろん通常は最初から、

con.execute(
    "SELECT name FROM users WHERE name = ?",
    (name,),
)

と書けば済みます。

では、SQLを組み立てるAPIを作りたい場合に、t-stringは何を変えるのでしょうか。

t-stringは完成した文字列を返さない

Python 3.14のt-stringは、PEP 750で導入されたtemplate string literalsです。

同じような式をtプレフィックスで書いてみます。

# Python 3.14
from string.templatelib import Template

name = "alice"

query = t"SELECT name FROM users WHERE name = {name}"

print(type(query))
print(query.strings)
print(query.interpolations)

queryはstrではなくTemplateです。

stringsには静的な断片が入り、interpolationsには補間部分が残ります。

概念的には次の構造です。

strings:
("SELECT name FROM users WHERE name = ", "")

interpolations:
(Interpolation("alice", "name", None, ""),)

公式ドキュメントでは、stringsは常にinterpolationsより一つ多い要素を持ちます。

つまり、

strings[0]
interpolations[0]
strings[1]
interpolations[1]
strings[2]
...

という位置関係を保てます。

ここがf-stringとの大きな違いです。

なお、Templateを単純にstr()へ変換してSQLとして使う、というものではありません。

name = "alice"
query = t"SELECT name FROM users WHERE name = {name}"

print(str(query))

欲しいのは完成済みSQL文字列ではなく、固定部分と動的な値がまだ区別された構造です。

その構造をどう消費するかは、Templateを受け取るコード側が決めます。

TemplateをSQLとパラメータに分ける

Python 3.14のt-stringでSQLと値を分離する

そこで、Template専用の小さなレンダラを書きます。

方針は単純です。

固定部分だけをSQL本文へコピーし、Interpolationが現れた場所には?を置きます。実際の値は別のparamsへ入れます。

# Python 3.14
import sqlite3
from string.templatelib import Template


def sql(template: Template) -> tuple[str, tuple[object, ...]]:
    parts: list[str] = []
    params: list[object] = []

    for i, interpolation in enumerate(template.interpolations):
        parts.append(template.strings[i])

        if interpolation.conversion is not None:
            raise ValueError("conversion is not allowed")

        if interpolation.format_spec:
            raise ValueError("format_spec is not allowed")

        parts.append("?")
        params.append(interpolation.value)

    parts.append(template.strings[-1])

    return "".join(parts), tuple(params)


con = sqlite3.connect(":memory:")
con.execute("CREATE TABLE users (name TEXT)")
con.executemany(
    "INSERT INTO users VALUES (?)",
    [("alice",), ("bob",)],
)

name = "alice' OR 1=1 --"

statement, params = sql(
    t"SELECT name FROM users WHERE name = {name}"
)

print(statement)
print(params)
print(con.execute(statement, params).fetchall())

生成されるのは次の組み合わせです。

SELECT name FROM users WHERE name = ?
("alice' OR 1=1 --",)
[]

nameの中にSQLとして意味を持つ文字が含まれていても、SQL本文には入りません。

重要なのは、レンダラがInterpolationの値を自分でエスケープしているわけではないことです。

parts.append("?")
params.append(interpolation.value)

として、SQL本文と値を分離したままsqlite3.execute()へ渡しています。

つまり境界は、

t-string
    ↓
Template
    ↓
SQL本文 + params
    ↓
sqlite3.execute(statement, params)

となります。

t-stringの役割は、最初の段階で固定部分と補間部分を潰さずに保持するところまでです。

conversionとformat_specをどう扱うか

もう一つ見落としやすいのが、Interpolationには値以外の情報もあることです。

Python 3.14のInterpolationは主に、

value
expression
conversion
format_spec

を持ちます。

たとえば、

value = 123
template = t"SELECT {value!r}"

では、f-stringのようにrepr()済み文字列が補間されるわけではありません。conversionとして"r"が保持され、どう処理するかはTemplateの利用側に委ねられます。

format_specも同様です。

SQLレンダラでこれらを安易に適用すると、「Interpolationの値はそのままDBパラメータへ渡す」という単純な契約が崩れます。

そのため上の最小実装では、

if interpolation.conversion is not None:
    raise ValueError("conversion is not allowed")

if interpolation.format_spec:
    raise ValueError("format_spec is not allowed")

として拒否しました。

これはPythonの制約ではなく、このSQLレンダラのAPI設計です。

もう一つ境界を決める必要があります。テーブル名や列名です。

たとえば、

column = "name"
query = t"SELECT {column} FROM users"

を先ほどのレンダラへ渡すと、

SELECT ? FROM users

になります。

これはnameという識別子を動的にSQLへ埋め込む意味にはなりません。DBパラメータは、SQL構文そのものを組み立てる仕組みではないからです。

動的な列名が必要なら、許可した識別子から選択するなど、値のパラメータ化とは別のAPIとして扱うほうが境界が明確です。

ここまで考えると、t-stringの利点は「SQLをいい感じに安全化してくれる」ことではありません。

文字列になる前の構造をアプリケーション側へ渡せることです。

その構造に対して、

static string  -> SQL本文
interpolation  -> DBパラメータ
conversion     -> 拒否
format_spec    -> 拒否

という規則を決められます。

逆に、Templateを受け取った後でInterpolationの値をSQL本文へ直接連結すれば、t-stringを使っていてもこの境界は失われます。安全性を担うのはt""という構文ではなく、Templateを消費する側の契約です。

実際のSQL生成では、IN句の可変長展開、NULLの扱い、識別子、DBごとのプレースホルダー形式なども考える必要があります。この最小レンダラをそのままSQLビルダーに育てるより、まず「どのInterpolationを値として許し、何をSQL構文として扱うのか」をAPIとして決めるところから確かめるのがよさそうです。

参考

2
3
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
2
3

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?