mcpField
F
frappe-errors-database

frappe-errors-database

github.com/impertio-studioデータ・データベース198118 回インストール30 週間前に更新
リポジトリ

frappe-errors-database について

Frappe 開発者向けのデータベースエラー診断スキルです。一般的な例外の原因と対処法に加え、トランザクション、クエリ、安全なデータベース操作の指針をまとめています。

主な機能

  1. 01データベース例外の原因と対処法の対応表
  2. 02エラー処理パターンの例
  3. 03コミットとセーブポイントの規則
  4. 04パラメーター化 SQL とインジェクション対策
  5. 05クエリ、接続、並行処理の問題整理

活用シーン

  1. 01Frappe のデータベース例外の診断
  2. 02安全な frappe.db.sql() クエリの作成
  3. 03バッチ処理でのトランザクションとデッドロック対応
  4. 04レコード欠落や同時編集の確認

インストール

コマンドをコピーしてターミナルで実行します。

$npx skills add https://github.com/impertio-studio/frappe_claude_skill_package --skill frappe-errors-database

インストール後、Claude Code で/frappe-errors-databaseを入力して読み込まれたか確認します。

同梱ファイル

SKILL.md と一緒にインストールされる参考資料やスクリプト。

references/
anti-patterns.md
examples.md
patterns.md

詳細

Frappe データベースエラーの診断と解決

相互参照:frappe-core-database(API 構文)、frappe-errors-controllers(コントローラーのエラー)。


エラーと修正方法の対応表

エラー/例外HTTP原因修正方法
DuplicateEntryError409挿入/名前変更時の一意制約違反先に存在を確認するか、例外を捕捉して既存のレコードを返します
DoesNotExistError404レコードが存在しない状態での get_doc()先に frappe.db.exists() を使用するか、例外を捕捉します
LinkValidationError417リンクフィールドが存在しないレコードを指している保存前にリンク先の存在を検証します
LinkExistsErrorN/Aリンクされたドキュメントがあるため削除できないリンクされたドキュメントをユーザーに表示します。force=True は慎重に使用します
MandatoryError417保存時に必須フィールドが空挿入/保存前にすべての必須フィールドを設定します
TimestampMismatchErrorN/A同時編集を検出した(modified が変更された)ドキュメントを再読み込みして再試行するか、ユーザーに更新を促します
CharacterLengthExceededError417文字列がフィールドの最大長/DB 列サイズを超えている入力を切り詰めるか、フィールド長を増やします
DataTooLongException417値が DB 列の格納容量を超えているCharacterLengthExceededError と同様です
InReadOnlyMode503読み取り専用モード中に書き込みを試みたfrappe.flags.in_import またはサイト設定を確認します
QueryTimeoutErrorN/Aクエリが制限時間を超えた[v15+]インデックスを追加し、結果セットを縮小して、ページネーションを使用します
QueryDeadlockErrorN/A2 つのトランザクションが互いの処理を待機しているバックオフを入れて再試行し、トランザクションの範囲を縮小します
TooManyWritesErrorN/A1 回のリクエストで過剰な書き込みが発生した操作をバッチ処理し、バックグラウンドジョブを使用します
InternalError(gone away)N/AMariaDB 接続が切断されたfrappe.db.connect() で再接続します
InternalError(too many)N/A接続プールが枯渇したmax_connections を確認し、アイドル状態の接続を閉じます
ValidationError417保存時の一般的な検証エラーエラーメッセージを確認し、フィールド値を修正します
SQL 構文エラーN/Afrappe.db.sql() のパラメーター形式が誤っている辞書と %(name)s を使用します。タプルと %s は使用しません

例外の階層

Exception
├── frappe.ValidationError (HTTP 417)
│   ├── frappe.MandatoryError
│   ├── frappe.LinkValidationError
│   ├── frappe.CharacterLengthExceededError
│   ├── frappe.DataTooLongException
│   ├── frappe.UniqueValidationError
│   ├── frappe.UpdateAfterSubmitError
│   └── frappe.DataError
├── frappe.DoesNotExistError (HTTP 404)
├── frappe.DuplicateEntryError (HTTP 409)  ← inherits NameError
├── frappe.TimestampMismatchError
├── frappe.LinkExistsError
├── frappe.QueryTimeoutError
├── frappe.QueryDeadlockError
├── frappe.TooManyWritesError
├── frappe.InReadOnlyMode (HTTP 503)
└── frappe.db.InternalError  ← MariaDB/Postgres driver error

frappe.db.sql() のパラメーター形式

# ❌ WRONG — %s with positional tuple (works but fragile)
frappe.db.sql("SELECT * FROM `tabItem` WHERE name = %s", ("ITEM-001",))

# ❌ WRONG — f-string or .format() — SQL INJECTION!
frappe.db.sql(f"SELECT * FROM `tabItem` WHERE name = '{item_name}'")
frappe.db.sql("SELECT * FROM `tabItem` WHERE name = '{}'".format(item_name))

# ❌ WRONG — bare % operator
frappe.db.sql("SELECT * FROM `tabItem` WHERE name = '%s'" % item_name)

# ✅ CORRECT — named parameters with dict (ALWAYS use this)
frappe.db.sql(
    "SELECT * FROM `tabItem` WHERE name = %(name)s AND warehouse = %(wh)s",
    {"name": item_name, "wh": warehouse},
    as_dict=True
)

# ✅ CORRECT — frappe.qb (query builder, no injection risk)
Item = frappe.qb.DocType("Item")
result = (
    frappe.qb.from_(Item)
    .select(Item.name, Item.item_name)
    .where(Item.warehouse == warehouse)
    .run(as_dict=True)
)

ルール:パラメーターには必ず辞書と %(name)s を使用してください。SQL 値の文字列フォーマットは絶対に使用しないでください。


get_value が None を返す場合:例外ではありません

# ❌ DANGEROUS — get_value returns None, not raises
credit = frappe.db.get_value("Customer", "CUST-001", "credit_limit")
if credit > 1000:  # TypeError: '>' not supported between NoneType and int
    pass

# ✅ CORRECT — handle None explicitly
credit = frappe.db.get_value("Customer", "CUST-001", "credit_limit")
if credit is None:
    frappe.throw(_("Customer not found"))
credit = credit or 0  # Default to 0 if field is empty

# ✅ CORRECT — get_value with as_dict for multiple fields
data = frappe.db.get_value("Customer", "CUST-001",
    ["credit_limit", "disabled"], as_dict=True)
if not data:  # None when record not found
    frappe.throw(_("Customer not found"))
if data.disabled:
    frappe.throw(_("Customer is disabled"))

メソッドごとの主な動作:

メソッドレコードが見つからない場合フィールドが空の場合
get_doc()DoesNotExistError を発生させるフィールドのデフォルト値を返す
get_value()None を返すNone または "" を返す
get_all()[] を返す結果に含まれる
exists()False を返すN/A
set_value()何もせずに終了するN/A
db.sql()[] または () を返す結果に含まれる

各例外タイプの処理

DuplicateEntryError
# Pattern: Insert with duplicate handling
def create_or_get(doctype, data):
    try:
        doc = frappe.get_doc({"doctype": doctype, **data})
        doc.insert()
        return doc
    except frappe.DuplicateEntryError:
        # Race condition safe: someone else created it
        name = frappe.db.get_value(doctype, data, "name")
        return frappe.get_doc(doctype, name)
TimestampMismatchError
# Pattern: Concurrent edit detection
try:
    doc = frappe.get_doc("Sales Invoice", name)
    doc.update(updates)
    doc.save()
except frappe.TimestampMismatchError:
    frappe.throw(
        _("Document modified by another user. Please refresh and try again."),
        title=_("Concurrent Edit")
    )
LinkValidationError と MandatoryError
# Pattern: Pre-validate before save
def safe_create_invoice(data):
    errors = []

    # Check mandatory fields
    if not data.get("customer"):
        errors.append(_("Customer is required"))
    if not data.get("items"):
        errors.append(_("At least one item is required"))

    # Check link validity
    if data.get("customer"):
        if not frappe.db.exists("Customer", data["customer"]):
            errors.append(_("Customer '{0}' not found").format(data["customer"]))

    if errors:
        frappe.throw("<br>".join(errors))

    doc = frappe.get_doc({"doctype": "Sales Invoice", **data})
    doc.insert()
    return doc
CharacterLengthExceededError
# Pattern: Truncate before save
def safe_set_description(doc, description):
    max_len = 140  # Match field length in DocType
    if len(description) > max_len:
        description = description[:max_len - 3] + "..."
        frappe.msgprint(_("Description truncated to {0} characters").format(max_len))
    doc.description = description
QueryTimeoutError[v15+]
# Pattern: Paginated query to avoid timeout
def get_large_report(filters):
    try:
        return frappe.db.sql(query, filters, as_dict=True)
    except frappe.QueryTimeoutError:
        frappe.log_error(frappe.get_traceback(), "Report Query Timeout")
        frappe.throw(
            _("Report too large. Please narrow your date range or add filters."),
            title=_("Query Timeout")
        )
InReadOnlyMode
# Pattern: Check before write
def safe_write(doctype, name, field, value):
    if frappe.flags.in_import:
        frappe.db.set_value(doctype, name, field, value)
        return
    try:
        frappe.db.set_value(doctype, name, field, value)
    except frappe.InReadOnlyMode:
        frappe.log_error(f"Write blocked: {doctype}/{name}", "Read-Only Mode")
        frappe.throw(_("System is in read-only mode. Please try again later."))

トランザクションのデッドロック

# ❌ CAUSES DEADLOCKS — long transaction with many writes
def process_all():
    for inv in frappe.get_all("Sales Invoice", limit=10000):
        doc = frappe.get_doc("Sales Invoice", inv.name)
        doc.custom_field = "value"
        doc.save()  # Each save locks rows; other processes wait

# ✅ CORRECT — batch with commits to release locks
def process_all():
    invoices = frappe.get_all("Sales Invoice", limit=10000)
    BATCH = 100
    for i in range(0, len(invoices), BATCH):
        for inv in invoices[i:i + BATCH]:
            frappe.db.set_value("Sales Invoice", inv.name, "custom_field", "value")
        frappe.db.commit()  # Release locks after each batch

# ✅ CORRECT — retry on deadlock
import time
def with_deadlock_retry(func, max_retries=3):
    for attempt in range(max_retries):
        try:
            return func()
        except frappe.QueryDeadlockError:
            if attempt < max_retries - 1:
                frappe.db.rollback()
                time.sleep(0.5 * (attempt + 1))
            else:
                raise

MariaDB の接続切断/接続数超過

# Pattern: Connection recovery
def reliable_operation():
    try:
        return frappe.db.sql("SELECT 1")
    except frappe.db.InternalError as e:
        msg = str(e).lower()
        if "gone away" in msg or "lost connection" in msg:
            frappe.db.connect()  # Reconnect
            return frappe.db.sql("SELECT 1")
        if "too many connections" in msg:
            frappe.log_error("Too many DB connections", "Connection Pool")
            frappe.throw(_("Server busy. Please try again in a moment."))
        raise  # Unknown InternalError — re-raise

予防策:

  • MariaDB の設定で wait_timeout を設定します(デフォルトは 28800s)
  • max_connections の設定がワークロードに適していることを確認します
  • 本番環境では接続プーリングを使用します(Gunicorn ワーカー)

トランザクションのルール

コミットするタイミング
コンテキスト自動コミット?手動コミット?
Web リクエスト(POST/PUT)はいいいえ
コントローラーフック(validate、on_update)はいいいえ
doc_events フックはいいいえ
スケジューラータスクいいえ必ず実行
バックグラウンドジョブ(frappe.enqueue)いいえ必ず実行
bench executeいいえ必ず実行
部分ロールバック用のセーブポイント
def complex_operation():
    frappe.db.savepoint("before_risky")
    try:
        risky_database_operation()
    except Exception:
        frappe.db.rollback(save_point="before_risky")
        safe_alternative()  # Continue with fallback

# Transaction hooks [v15+]
frappe.db.after_commit.add(lambda: send_notification())
frappe.db.after_rollback.add(lambda: cleanup_files())

SQL インジェクションの防止

# ❌ INJECTION VULNERABLE — all of these
frappe.db.sql(f"SELECT * FROM `tabItem` WHERE name = '{user_input}'")
frappe.db.sql("SELECT * FROM `tabItem` WHERE name = '%s'" % user_input)
frappe.db.sql("SELECT * FROM `tabItem` WHERE name = '{}'".format(user_input))

# ❌ ALSO VULNERABLE — in permission_query_conditions
def query_conditions(user):
    return f"owner = '{user}'"  # Unescaped!

# ✅ SAFE — parameterized query
frappe.db.sql("SELECT * FROM `tabItem` WHERE name = %(name)s", {"name": user_input})

# ✅ SAFE — frappe.db.escape() for dynamic SQL (permission hooks)
def query_conditions(user):
    return f"owner = {frappe.db.escape(user)}"

# ✅ SAFE — query builder
Item = frappe.qb.DocType("Item")
frappe.qb.from_(Item).where(Item.name == user_input).run()

# ✅ SAFE — ORM methods
frappe.get_all("Item", filters={"name": user_input})
frappe.db.get_value("Item", user_input, "item_name")

db.set_value のサイレントな失敗

# ❌ DANGEROUS — no error if record doesn't exist
frappe.db.set_value("Customer", "NONEXISTENT", "status", "Active")
# Returns without error! No rows updated.

# ✅ ALWAYS verify existence before set_value
if not frappe.db.exists("Customer", customer_name):
    frappe.throw(_("Customer '{0}' not found").format(customer_name))
frappe.db.set_value("Customer", customer_name, "status", "Active")

# Note: set_value skips validate/on_update hooks
# Use doc.save() when you need validation to run

重要なルール

必ず行うこと
  1. frappe.db.sql() では %(name)s の辞書パラメーターを使用します。文字列フォーマットは絶対に使用しないでください
  2. get_doc() の前に frappe.db.exists() で確認するか、DoesNotExistError を捕捉します
  3. すべての insert() 呼び出しで DuplicateEntryError を処理します
  4. API 内のすべての save() で TimestampMismatchError を処理します
  5. スケジューラーとバックグラウンドジョブでは frappe.db.commit() を呼び出します
  6. 大量のクエリはページネーションします。limit パラメーターを使用します
  7. 使用する前に、get_value() の結果が None でないことを確認します
  8. 動的な SQL 文字列では frappe.db.escape() を使用します
絶対に行わないこと
  1. SQL の値に文字列フォーマット(f""、.format()、%)を使用する
  2. コントローラーフックや doc_events 内で frappe.db.commit() を呼び出す
  3. Exception を無条件に捕捉して pass する。ログを記録するか、具体的な例外を再送出します
  4. db.set_value() が成功したと決めつける。レコードが存在しない場合、エラーなしで失敗します
  5. データベースの生のエラーメッセージをユーザーに公開する。詳細はログに記録し、一般的なメッセージを表示します
  6. limit なしで上限のないクエリを実行する。メモリ不足やタイムアウトのリスクがあります

クイックリファレンス:例外処理

try:
    doc = frappe.get_doc("Customer", name)
except frappe.DoesNotExistError:
    frappe.throw(_("Not found"))

try:
    doc.insert()
except frappe.DuplicateEntryError:
    existing = frappe.db.get_value("Customer", filters, "name")
except frappe.MandatoryError as e:
    frappe.throw(_("Missing required field: {0}").format(e))

try:
    doc.save()
except frappe.TimestampMismatchError:
    frappe.throw(_("Document modified. Please refresh."))
except frappe.CharacterLengthExceededError:
    frappe.throw(_("Text too long for field"))

try:
    frappe.delete_doc("Customer", name)
except frappe.LinkExistsError:
    frappe.throw(_("Cannot delete — linked documents exist"))

try:
    frappe.db.sql(query, values)
except frappe.QueryTimeoutError:          # [v15+]
    frappe.throw(_("Query too slow. Add filters."))
except frappe.QueryDeadlockError:
    frappe.db.rollback()                   # Retry with backoff
except frappe.db.InternalError as e:
    frappe.log_error(frappe.get_traceback(), "DB Error")

参考ファイル

ファイル内容
references/patterns.mdすべての DB 操作に関する完全なエラー処理パターン
references/examples.mdエラー処理を含む、完全に動作するサンプル
references/anti-patterns.mdよくある誤りと、誤った例・正しい例の組み合わせ

関連項目

  • frappe-core-database — データベース API の構文とクエリビルダー
  • frappe-errors-controllers — コントローラーのエラー処理
  • frappe-errors-hooks — フックのエラー処理
  • frappe-core-permissions — 権限のパターン

コメント