เรียนรู้ Pattern การสร้าง Data Seeding Scripts ใน Frappe Framework ด้วย after_install, after_migrate และ before_uninstall hooks พร้อม Dry-run Mode และ CSV Import

เมื่อเราพัฒนา Custom App บน Frappe Framework มักจะมีข้อมูลพื้นฐานที่ต้อง Import เข้าระบบอัตโนมัติ เช่น Master Data, Default Settings หรือ Reference Data ต่างๆ บทความนี้จะแนะนำ Pattern สำหรับสร้าง Data Seeding Scripts ที่ทำงานอัตโนมัติผ่าน Frappe Hooks
Pattern นี้ออกแบบให้:
after_install และ after_migratebefore_uninstallyour_app/
├── setup/
│ ├── seed_your_data.py # Seed + cleanup functions + CLI command
│ └── seed_another_data.py # แต่ละ concern แยกไฟล์
├── hooks.py # Register CLI commands
├── install.py # after_install + after_migrate calls
└── uninstall.py # before_uninstall cleanup calls
แต่ละ Seed Script จะมี 3 ส่วนหลัก:
seed_*() — ฟังก์ชันหลักสำหรับ Seeding (รองรับ dry_run)cleanup_*() — ฟังก์ชันสำหรับล้างข้อมูลเมื่อ Uninstall*_cmd — Click Command สำหรับรันผ่าน CLI# hooks.py
commands = [
# ... existing commands ...
"your_app.setup.seed_subscription_plans.seed_subscription_plans_cmd",
]ใช้งาน: bench seed-subscription-plans หรือ bench seed-subscription-plans --dry-run
สำคัญ: ทั้ง after_install และ after_migrate เรียกฟังก์ชันเดียวกัน ฟังก์ชัน seed ต้อง Idempotent (รันซ้ำกี่ครั้งก็ได้ผลลัพธ์เหมือนเดิม)
# uninstall.py
from your_app.setup.seed_subscription_plans import cleanup_subscription_plans
def before_uninstall():
# ... other cleanup ...
try:
if cleanup_subscription_plans():
success_count += 1
except Exception as e:
print(f"Subscription plan cleanup failed: {str(e)}")
# ... more cleanup ...สำหรับ Seeding จากไฟล์ CSV ที่มาพร้อมกับ App:
สำหรับ Seeding rows เข้า Child Tables (เช่น Subscription Feature):
Cleanup สำหรับ Child Tables ใช้ SQL DELETE โดยตรง:
def cleanup_child_table_rows():
frappe.db.sql("""
DELETE FROM `tabSubscription Feature`
WHERE custom_module IS NOT NULL AND custom_module != ''
""")
frappe.db.commit()| กลยุทธ์ | ใช้เมื่อ | ตัวอย่าง |
|---|---|---|
| Bulk SQL with WHERE NOT | Flag updates แบบง่าย | SET flag=1 WHERE flag=0 |
| Dedup set before insert | Child table rows | ตรวจสอบ (feature_code, module, limit_type) tuple |
db.set_value with update_modified=False | Per-doc field updates | ไม่ trigger document events |
| Skip if count > 0 | One-time data loads | ตรวจจำนวนก่อนรัน |
ใน install.py ควรรัน Seed Scripts หลัง Custom Field Installation แต่ ก่อน Workspace Setup:
1. Install custom fields (schema ต้องมีก่อน)
2. Seed reference data (Plan Categories, Feature Definitions)
3. Seed relationship data (Plan-Feature mappings)
4. Seed computed data (Usage limits, default quotas)
5. Install workflows and permissions
6. Sync workspaces (อาจ reference seeded data)
| ปัญหา | อาการ | วิธีแก้ |
|---|---|---|
| ไม่ Idempotent | ข้อมูลซ้ำเมื่อ re-migrate | เพิ่ม deduplication check ก่อน insert |
| ไม่มี try/except ใน install.py | error ตัวเดียวบล็อกทั้งหมด | ครอบแต่ละ seed call ด้วย try/except |
ลืม frappe.db.commit() | ข้อมูลไม่ถูกบันทึก | commit หลัง bulk operations |
| ไม่มี cleanup ใน uninstall.py | ข้อมูลค้างหลัง uninstall | จับคู่ seed กับ cleanup function เสมอ |
update_modified=True (default) | trigger document events | ใช้ update_modified=False สำหรับ bulk seeding |
| Dataset ใหญ่ไม่ทำ batching | Timeout หรือ memory issues | commit ทุก 50 items |
A: เพราะ after_migrate รันทุกครั้งที่ bench migrate ถ้าฟังก์ชันไม่ Idempotent จะได้ข้อมูลซ้ำทุกครั้งที่ migrate
A: ใช้ Raw SQL สำหรับ bulk updates ที่ต้องการความเร็ว และใช้ ORM (frappe.get_doc) เมื่อต้อง trigger validation หรือ events
A: แนะนำอย่างยิ่ง เพราะช่วยให้ตรวจสอบได้ว่าจะเกิดอะไรขึ้นก่อนรันจริง โดยเฉพาะบน Production Server
# setup/seed_subscription_plans.py
import csv
import os
import click
import frappe
PLAN_CATEGORIES = ("Starter", "Professional", "Business", "Enterprise")
def seed_subscription_plans(dry_run=False):
"""Main seeding function with dry_run support."""
print(f"Seeding data {'(DRY RUN)' if dry_run else ''}")
if dry_run:
# แสดงสิ่งที่จะเกิดขึ้นโดยไม่เปลี่ยนแปลงข้อมูล
count = frappe.db.count("Item", {"item_group": ["in", PLAN_CATEGORIES]})
print(f" Would update {count} items")
return
# Bulk SQL update สำหรับ flag changes แบบง่าย (เร็ว)
categories_str = ", ".join(f"'{c}'" for c in PLAN_CATEGORIES)
frappe.db.sql(f"""
UPDATE `tabItem`
SET custom_is_subscription_plan = 1
WHERE item_group IN ({categories_str})
""")
frappe.db.commit()
# Per-document updates เมื่อต้องการค่าเฉพาะแต่ละ field
for item_code, value in data_dict.items():
frappe.db.set_value("Item", item_code, "custom_plan_limit", value,
update_modified=False)
frappe.db.commit()
def cleanup_subscription_plans():
"""Reversal function for before_uninstall."""
try:
categories_str = ", ".join(f"'{c}'" for c in PLAN_CATEGORIES)
frappe.db.sql(f"""
UPDATE `tabItem`
SET custom_is_subscription_plan = 0,
custom_plan_limit = NULL
WHERE item_group IN ({categories_str})
""")
frappe.db.commit()
print(" Subscription plan flags reset")
return True
except Exception as e:
frappe.db.rollback()
print(f" Error: {str(e)}")
return False
@click.command("seed-subscription-plans")
@click.option("--dry-run", is_flag=True, help="Analyze without changes")
def seed_subscription_plans_cmd(dry_run):
"""CLI command for manual execution."""
seed_subscription_plans(dry_run=dry_run)# install.py
from your_app.setup.seed_subscription_plans import seed_subscription_plans
def after_install():
# ... other setup ...
# Seed subscription plan data
try:
seed_subscription_plans(dry_run=False)
except Exception as e:
print(f"Subscription plan seeding failed: {str(e)}")
# ... more setup ...
def after_migrate():
# Pattern เดียวกัน — idempotent seeding รันซ้ำได้อย่างปลอดภัย
try:
seed_subscription_plans(dry_run=False)
except Exception as e:
print(f"Subscription plan seeding failed: {str(e)}")import csv
import os
def seed_from_csv(dry_run=False):
app_path = frappe.get_app_path("your_app")
csv_file_path = os.path.join(app_path, "data", "your_data.csv")
with open(csv_file_path, "r", encoding="utf-8") as csvfile:
reader = csv.DictReader(csvfile)
for row in reader:
code = row.get("column_name", "").strip()
if not code or code == "NULL":
continue
# Process row...def seed_child_table_rows():
plan = frappe.get_doc("Subscription Plan", plan_name)
# สร้าง set สำหรับตรวจสอบข้อมูลซ้ำ
existing = set()
for row in plan.get("features", []):
existing.add((
getattr(row, "feature_code", "") or "",
getattr(row, "custom_module", "") or "",
getattr(row, "custom_limit_type", "") or "",
))
# เพิ่ม rows ใหม่ (ข้าม duplicates)
rows_added = 0
for data in new_rows:
key = (data["feature_code"], data["module"], data["limit_type"])
if key in existing:
continue
plan.append("features", {
"feature_name": data["feature_name"],
"feature_code": data["feature_code"],
"custom_module": data["module"],
"custom_limit_type": data["limit_type"],
})
rows_added += 1
if rows_added:
plan.save(ignore_permissions=True)
# Commit เป็น batch สำหรับ dataset ขนาดใหญ่
if batch_count % 50 == 0:
frappe.db.commit()# Dry-run ก่อนเสมอ
bench --site {site} execute your_app.setup.seed_data.seed_data \
--kwargs "{'dry_run': True}"
# รัน Seeding จริง
bench --site {site} execute your_app.setup.seed_data.seed_data
# ตรวจผลลัพธ์
bench --site {site} execute frappe.client.get_count \
--kwargs '{"doctype": "Item", "filters": {"custom_is_subscription_plan": 1}}'
# Cleanup (สำหรับทดสอบ)
bench --site {site} execute your_app.setup.seed_data.cleanup_data
# หรือผ่าน CLI command
bench seed-subscription-plans --dry-run
bench seed-subscription-plans