فایل قابل خواندن لزوماً داده معتبر ندارد

فایل سفارش از اکسل صادر شده و ظاهراً سه ستون ساده دارد؛ اما یک شناسه تکرار شده، مبلغی منفی است و در انتهای فایل یک ستون اضافه دیده می‌شود. اگر برنامه فقط فایل را بخواند و مستقیم در دیتابیس بنویسد، کشف خطا زمانی رخ می‌دهد که بخشی از داده ثبت شده است. مرحله اعتبارسنجی مستقل کمک می‌کند کاربر فایل را اصلاح کند و مسئول داده بداند دقیقاً کدام ردیف رد شده است.

پیش از پردازش، نام و ترتیب ستون‌ها، کدگذاری، واحد مبلغ و سیاست ردیف خالی را مشخص کنید. این مثال قرارداد سخت‌گیرانه id و amount دارد و مبلغ را عدد صحیح غیرمنفی می‌داند. ابزار csv جداکننده و نقل‌قول را می‌خواند، ولی درست‌بودن مبلغ یا یکتایی شناسه را برای شما تعریف نمی‌کند. برای فایل واقعی از newline="" و کدگذاری مشخص استفاده کنید؛ utf-8-sig می‌تواند BOM رایج خروجی بعضی ابزارها را کنار بگذارد.

قرارداد داده و گزارش خطای قابل اصلاح

با DictReader می‌توان نام ستون را به مقدار وصل کرد. ستون اضافه ممکن است زیر کلید None قرار گیرد و مقدار کم‌شده ممکن است None شود؛ بنابراین فقط دسترسی به row["id"] برای اعتبارسنجی کافی نیست. در نمونه هر ردیف ابتدا از نظر شکل، سپس شناسه، مبلغ و تکرار بررسی می‌شود. شماره گزارش‌شده شماره رکورد منطقی با احتساب سرستون است؛ اگر فیلد چندخطی باشد، با شماره خط فیزیکی فایل تفاوت دارد.

همه خطاها را با اطلاعات کافی برای اصلاح جمع کنید، ولی کل اطلاعات شخصی ردیف را در لاگ ننویسید. یک پیام خوب می‌گوید «رکورد ۳: شناسه تکراری»، نه اینکه صرفاً Import failed چاپ کند. محدودیت حجم فایل و تعداد خطا را هم در پروژه واقعی تعیین کنید. نمونه کوچک این مقاله همه رکوردهای معتبر و خطاها را در حافظه نگه می‌دارد؛ برای فایل بزرگ، این راهبرد باید با پردازش مرحله‌ای یا staging جایگزین شود.

اعتبارسنجی را از commit جدا کنید. وقتی سیاست محصول «همه یا هیچ» است، وجود حتی یک خطا باید مانع ثبت نهایی شود. برای ورود جزئی، تعداد پذیرفته و ردشده و روش اجرای مجدد را روشن کنید. کلید یکتا در دیتابیس همچنان لازم است؛ بررسی set داخل برنامه در برابر ورود هم‌زمان دو فایل، تضمین سراسری ایجاد نمی‌کند.

نمونه کد و روش بررسی

پیش‌نیاز و خروجی مورد انتظار در توضیح مثال آمده است. ابتدا نمونه را در محیط آزمایشی اجرا کنید و سپس آن را با داده و محدودیت‌های پروژه خود تطبیق دهید.

1. Python 3؛ کتابخانه استاندارد

import csv
import io

text = "id,amount\nA1,1200\nA1,500\nA2,-1\n"
reader = csv.DictReader(io.StringIO(text, newline=""))
if reader.fieldnames != ["id", "amount"]:
    raise ValueError("unexpected headers")

accepted, errors, seen = [], [], set()
for number, row in enumerate(reader, start=2):
    try:
        if None in row or any(value is None for value in row.values()):
            raise ValueError("incorrect field count")
        key = row["id"].strip()
        if not key:
            raise ValueError("empty id")
        amount = int(row["amount"])
        if amount < 0:
            raise ValueError("negative amount")
        if key in seen:
            raise ValueError("duplicate id")
        seen.add(key)
        accepted.append((key, amount))
    except ValueError as error:
        errors.append(f"record {number}: {error}")

print(errors)
print("Ready to commit:", not errors)

خروجی باید خطای شناسه تکراری رکورد 3، مبلغ منفی رکورد 4 و Ready to commit: False باشد. نمونه عمداً در دیتابیس نمی‌نویسد. شناسه A1 در این قرارداد به حروف بزرگ و کوچک حساس است.

2. اعتبارسنج قابل استفاده مجدد؛ بدون نوشتن دیتابیس

این تابع را در csv_import.py ذخیره کنید. فایل یا جریان متن از بیرون دریافت می‌شود تا هم از دیسک بخوانیم و هم با StringIO آزمایش کنیم. نتیجه ساختاریافته است و هیچ ردیفی را خودکار در دیتابیس ثبت نمی‌کند.

# csv_import.py
import csv

def validate_orders(stream):
    reader = csv.DictReader(stream)
    if reader.fieldnames != ["id", "amount"]:
        raise ValueError("expected headers: id,amount")
    accepted, errors, seen = [], [], set()
    for number, row in enumerate(reader, start=2):
        try:
            if None in row or any(v is None for v in row.values()):
                raise ValueError("incorrect field count")
            key = row["id"].strip()
            if not key:
                raise ValueError("empty id")
            amount = int(row["amount"])
            if amount < 0:
                raise ValueError("negative amount")
            if key in seen:
                raise ValueError("duplicate id")
            seen.add(key)
            accepted.append((key, amount))
        except ValueError as error:
            errors.append({"record": number, "message": str(error)})
    return accepted, errors

خروجی accepted فقط برای ادامه مرحله پردازش است؛ اگر سیاست «همه یا هیچ» دارید، وجود errors باید مانع commit شود. برای فایل بزرگ، تعداد خطاها، اندازه فایل و حافظه را محدود کنید؛ این نسخه برای فایل کنترل‌شده و کوچک طراحی شده است.

3. فرمان ورود با تراکنش و rollback

فایل دوم را کنار ماژول اعتبارسنجی بگذارید. قبل از ساختن اتصال، کل فایل اعتبارسنجی می‌شود. سپس همه INSERTها در یک تراکنش قرار می‌گیرند و کلید اصلی دیتابیس، تکرار میان فایل‌های مختلف را هم می‌گیرد.

# import_orders.py; run only against a disposable demo database.
import csv
import sqlite3
import sys
from csv_import import validate_orders

csv.field_size_limit(1_000_000)
with open(sys.argv[1], encoding="utf-8-sig", newline="") as source:
    rows, errors = validate_orders(source)
if errors:
    for error in errors:
        print(f"record {error['record']}: {error['message']}")
    raise SystemExit(2)

db = sqlite3.connect(sys.argv[2])
try:
    db.execute("CREATE TABLE IF NOT EXISTS orders "
               "(id TEXT PRIMARY KEY, amount INTEGER NOT NULL CHECK(amount>=0))")
    with db:
        db.executemany("INSERT INTO orders(id,amount) VALUES(?,?)", rows)
    print("Imported:", len(rows))
except sqlite3.IntegrityError as error:
    print("Nothing from this transaction was committed:", error)
    raise SystemExit(3)
finally:
    db.close()

با python import_orders.py orders.csv demo-orders.sqlite اجرا کنید. اجرای دوباره همان فایل باید با exit 3 شکست بخورد و هیچ بخش جدیدی از همان تراکنش ثبت نشود. این قرارداد، ورود تکراری را رد می‌کند؛ اگر محصول باید retry را موفق بشمارد، شناسه batch و سیاست idempotency را جدا طراحی کنید.

4. آزمون فایل سالم، شکل خراب و تراکنش ناموفق

این تست‌ها را با python -m pytest -q test_csv_import.py اجرا کنید. آزمون آخر عمداً ردیف دوم را به کلید تکراری می‌رساند؛ بنابراین هم وجود ردیف قبلی و هم نبود ردیف تازه پس از rollback بررسی می‌شود.

# test_csv_import.py
import io
import sqlite3
from csv_import import validate_orders

def test_clean_file():
    rows, errors = validate_orders(io.StringIO("id,amount\nA1,1200\nA2,0\n"))
    assert rows == [("A1", 1200), ("A2", 0)] and not errors

def test_reports_both_errors():
    rows, errors = validate_orders(io.StringIO("id,amount\nA1,1\nA1,2\nA2,-1\n"))
    assert [e["record"] for e in errors] == [3, 4]

def test_rejects_extra_column():
    _, errors = validate_orders(io.StringIO("id,amount\nA1,1,extra\n"))
    assert errors[0]["message"] == "incorrect field count"

def test_database_rolls_back_batch():
    db = sqlite3.connect(":memory:")
    db.execute("CREATE TABLE orders(id TEXT PRIMARY KEY,amount INTEGER)")
    with db:
        db.execute("INSERT INTO orders VALUES('existing',1)")
    try:
        with db:
            db.executemany("INSERT INTO orders VALUES(?,?)",
                           [("new", 2), ("existing", 3)])
    except sqlite3.IntegrityError:
        pass
    assert db.execute("SELECT id FROM orders").fetchall() == [("existing",)]
    db.close()

انتظار 4 passed داریم. این بررسی، تفاوت خطای اعتبارسنجی فایل و خطای محدودیت دیتابیس را قابل مشاهده می‌کند. برای انتشار گزارش ردشدن فایل، شماره رکورد و پیام کافی است؛ کپی کل ردیف خصوصی در لاگ ضرورتی ندارد.

ورود داده بدون نیمه‌کاره‌ماندن عملیات

برای تحویل پروژه، فایل تمیز و فایل دارای خطای شکل، مقدار خالی، عدد منفی و شناسه تکراری آماده کنید. خروجی گزارش را با انتظار خود مقایسه کنید و ببینید بعد از اصلاح، همان فایل قابل ورود است. این تمرین به دانش‌آموز نشان می‌دهد مسیر داده از «خواندن رشته» شروع می‌شود، اما با قرارداد، بازخورد قابل استفاده و ثبت اتمیک کامل می‌شود.

چک‌لیست اجرای عملی

  • قرارداد ستون‌ها، کدگذاری و واحد مبلغ مشخص شود.
  • ستون اضافه و مقدار کم‌شده رد شوند.
  • تکرار شناسه و محدودیت دیتابیس هر دو بررسی شوند.
  • ثبت نهایی از اعتبارسنجی جدا باشد.

منبع

تدوین و راهنمای عملی: دانشنامه لیان.