顯示具有 mysql 標籤的文章。 顯示所有文章
顯示具有 mysql 標籤的文章。 顯示所有文章

2026年8月31日 星期一

Python一下:用SQLAlchemy與PyMySQL重構智慧養殖資料存取層

《Python一下:從風土資料到智慧生活》第 28 篇

用SQLAlchemy與PyMySQL重構智慧養殖資料存取層

讓資料表成為Python物件,讓交易有清楚邊界,也讓SQLite與MySQL共用大部分程式。

CH13 關聯式資料庫SQLAlchemy 2.xORM
學習目標
完成本篇後,你能說明Engine、Model、Session與Repository的責任;使用SQLAlchemy 2.x宣告關聯模型;以ORM新增、查詢及更新資料;正確控制交易與連線池;並以SQLite進行快速測試、以PyMySQL連接MySQL。

一、為何要增加SQLAlchemy這一層?

直接使用PyMySQL完全可行,但當SQL散落在API、排程、分析腳本與管理頁面中,欄名修改、交易處理及測試會逐漸困難。SQLAlchemy提供一致的資料存取介面;ORM則把資料表的列映射成Python物件。

元件主要責任水井村例子
Engine管理資料庫方言、連線與連線池MySQL正式環境、SQLite課堂測試
Model描述資料表、欄位與關聯Site、Sensor、Reading
Session追蹤物件及管理一個工作單元新增一次巡查紀錄並提交
Repository集中常用資料存取規則查最新水溫、建立場域
ORM不是不用學SQL:SQLAlchemy最後仍會產生SQL。若不了解JOIN、索引、NULL與交易,就可能寫出「看起來很Python、實際查詢很慢」的程式。

二、安裝SQLAlchemy與PyMySQL

import subprocess
import sys


subprocess.run(
    [
        sys.executable, "-m", "pip", "install",
        "SQLAlchemy>=2.0,<3.0", "PyMySQL>=1.1,<2.0",
    ],
    check=True,
)

實際專案應使用虛擬環境並鎖定經測試的版本。套件升級前先跑測試,不在正式環境臨時更新。

三、建立資料庫網址,但不把密碼寫進程式

import os
from sqlalchemy import URL


def mysql_url_from_environment():
    password = os.getenv("SHUIJING_DB_PASSWORD", "")
    if not password:
        raise RuntimeError("尚未設定SHUIJING_DB_PASSWORD")

    return URL.create(
        drivername="mysql+pymysql",
        username=os.getenv("SHUIJING_DB_USER", "shuijing_app"),
        password=password,
        host=os.getenv("SHUIJING_DB_HOST", "127.0.0.1"),
        port=int(os.getenv("SHUIJING_DB_PORT", "3306")),
        database=os.getenv("SHUIJING_DB_NAME", "shuijing_demo"),
        query={"charset": "utf8mb4"},
    )

URL.create()會處理密碼中的特殊字元。不要print完整URL,因為它可能包含憑證;Colab請使用Secrets,GitHub則使用平台的Secret設定。

四、建立Engine與連線池

from sqlalchemy import create_engine


engine = create_engine(
    mysql_url_from_environment(),
    pool_pre_ping=True,
    pool_recycle=1800,
    pool_size=5,
    max_overflow=5,
    pool_timeout=10,
    echo=False,
)

print(engine.url.render_as_string(hide_password=True))

pool_pre_ping可在借出連線前檢查是否仍有效;連線池大小必須配合MySQL上限、網站程序數與實際流量計算,不可每個程序都任意設很大。

五、先宣告共同Model基底

from sqlalchemy.orm import DeclarativeBase


class Base(DeclarativeBase):
    pass

本篇採SQLAlchemy 2.x的型別化宣告方式。每個Model都繼承Base,後續可由metadata取得所有資料表描述。

六、建立Site、Sensor與Reading模型

from __future__ import annotations

from datetime import datetime
from typing import Optional

from sqlalchemy import (
    BigInteger, Boolean, DateTime, Float, ForeignKey,
    Index, Integer, String, UniqueConstraint,
)
from sqlalchemy.orm import Mapped, mapped_column, relationship


ID_TYPE = BigInteger().with_variant(Integer, "sqlite")


class Site(Base):
    __tablename__ = "sites"

    site_id: Mapped[int] = mapped_column(
        ID_TYPE, primary_key=True, autoincrement=True
    )
    site_code: Mapped[str] = mapped_column(
        String(30), unique=True, nullable=False
    )
    display_name: Mapped[str] = mapped_column(String(100))
    active: Mapped[bool] = mapped_column(Boolean, default=True)
    sensors: Mapped[list[Sensor]] = relationship(
        back_populates="site"
    )


class Sensor(Base):
    __tablename__ = "sensors"

    sensor_id: Mapped[int] = mapped_column(
        ID_TYPE, primary_key=True, autoincrement=True
    )
    sensor_code: Mapped[str] = mapped_column(
        String(50), unique=True, nullable=False
    )
    site_id: Mapped[int] = mapped_column(
        ForeignKey("sites.site_id", ondelete="RESTRICT")
    )
    kind: Mapped[str] = mapped_column(String(30))
    unit: Mapped[str] = mapped_column(String(20))
    site: Mapped[Site] = relationship(back_populates="sensors")
    readings: Mapped[list[Reading]] = relationship(
        back_populates="sensor"
    )


class Reading(Base):
    __tablename__ = "readings"
    __table_args__ = (
        UniqueConstraint(
            "sensor_id", "observed_at",
            name="uq_sensor_time",
        ),
        Index("idx_readings_time", "observed_at"),
    )

    reading_id: Mapped[int] = mapped_column(
        ID_TYPE, primary_key=True, autoincrement=True
    )
    sensor_id: Mapped[int] = mapped_column(
        ForeignKey("sensors.sensor_id", ondelete="RESTRICT")
    )
    observed_at: Mapped[datetime] = mapped_column(
        DateTime(timezone=False)
    )
    value: Mapped[Optional[float]] = mapped_column(Float)
    quality: Mapped[str] = mapped_column(String(10))
    note: Mapped[str] = mapped_column(String(255), default="")
    sensor: Mapped[Sensor] = relationship(
        back_populates="readings"
    )

ID_TYPE讓MySQL使用BIGINT,SQLite測試則使用可正確連結ROWID自動編號的INTEGER。Python型別提示協助編輯器及靜態檢查,但不會自動完成所有資料驗證;quality允許值、時間規則及數值合理性仍應在應用層和資料庫層共同約束。

七、建立資料表:教材可以,正式系統要遷移

Base.metadata.create_all(engine)

create_all()適合初學、測試及全新資料庫,但不會安全地替既有資料表改欄位。正式系統應使用Alembic建立可審查、可追蹤、可回復的結構遷移版本。

八、用Session新增一座場域

from sqlalchemy.orm import Session


with Session(engine) as session:
    with session.begin():
        site = Site(
            site_code="SITE-01",
            display_name="示範池A",
        )
        session.add(site)

print("場域新增完成")

session.begin()區塊正常結束時提交,發生例外時回滾。離開Session後不要任意依賴尚未載入的關聯資料。

九、一次建立場域、感測器與讀值

from datetime import datetime


with Session(engine) as session:
    with session.begin():
        site = Site(
            site_code="SITE-02",
            display_name="示範池B",
        )
        sensor = Sensor(
            sensor_code="TEMP-02",
            kind="temperature",
            unit="°C",
        )
        sensor.readings.append(
            Reading(
                observed_at=datetime(2026, 8, 31, 9, 0),
                value=29.1,
                quality="valid",
            )
        )
        site.sensors.append(sensor)
        session.add(site)

ORM會依關聯順序寫入三張表。教材時間為示例;正式專案應統一以UTC或明確規則寫入,且不可把無時區時間與有時區時間混用。

十、用select()查詢,而不是舊式Query

from sqlalchemy import select


statement = (
    select(Site)
    .where(Site.active.is_(True))
    .order_by(Site.site_code)
)

with Session(engine) as session:
    sites = session.scalars(statement).all()
    for site in sites:
        print(site.site_code, site.display_name)

select(Site)回傳Site物件;如果選擇個別欄位,結果形態會不同。先確認自己需要物件、欄位列,還是單一純量。

十一、JOIN查詢最新有效水溫

statement = (
    select(
        Site.site_code,
        Site.display_name,
        Sensor.sensor_code,
        Reading.observed_at,
        Reading.value,
        Sensor.unit,
    )
    .join(Site.sensors)
    .join(Sensor.readings)
    .where(
        Site.site_code == "SITE-02",
        Sensor.kind == "temperature",
        Reading.quality == "valid",
    )
    .order_by(
        Reading.observed_at.desc(),
        Reading.reading_id.desc(),
    )
    .limit(1)
)

with Session(engine) as session:
    latest = session.execute(statement).mappings().first()
    print(dict(latest) if latest else "尚無有效資料")

SQLAlchemy會把比較值轉成參數,而不是直接拼進SQL。若需要動態欄位或排序,仍必須使用程式內允許清單。

十二、彙總各場域的有效資料

from sqlalchemy import and_, func


statement = (
    select(
        Site.site_code,
        func.count(Reading.reading_id).label("valid_count"),
        func.round(func.avg(Reading.value), 2).label("average"),
    )
    .outerjoin(
        Sensor,
        and_(
            Sensor.site_id == Site.site_id,
            Sensor.kind == "temperature",
        ),
    )
    .outerjoin(
        Reading,
        and_(
            Reading.sensor_id == Sensor.sensor_id,
            Reading.quality == "valid",
        ),
    )
    .group_by(Site.site_id, Site.site_code)
    .order_by(Site.site_code)
)

with Session(engine) as session:
    for row in session.execute(statement).mappings():
        print(dict(row))

把有效資料條件放在LEFT JOIN的ON子句,才能保留沒有有效資料的場域。若放到WHERE,查詢可能悄悄變成只剩有資料的場域。

十三、避免N+1查詢

逐一讀取每座場域的sensors,可能額外送出很多SQL。需要一起使用關聯時,可明確預載:

from sqlalchemy.orm import selectinload


statement = (
    select(Site)
    .options(selectinload(Site.sensors))
    .order_by(Site.site_code)
)

with Session(engine) as session:
    sites = session.scalars(statement).all()
    for site in sites:
        print(site.site_code, len(site.sensors))

selectinload通常以第二個IN查詢載入集合,避免每座場域各查一次。不要為了省查詢而預載所有歷史讀值,資料量可能非常大。

十四、建立輸入驗證函式

from datetime import datetime


def build_reading(observed_at, value, quality, note=""):
    if quality not in {"valid", "unknown", "invalid"}:
        raise ValueError("不支援的資料品質")
    if not isinstance(observed_at, datetime):
        raise TypeError("observed_at必須是datetime")
    if quality == "unknown":
        value = None
    elif value is None:
        raise ValueError("valid或invalid必須保留原始值")
    else:
        value = float(value)

    return Reading(
        observed_at=observed_at,
        value=value,
        quality=quality,
        note=str(note)[:255],
    )
資料品質先於圖表:unknown表示沒有可信數值,invalid表示保留原始值但不宜納入一般統計。不要把兩者都轉成0,否則平均值與告警會被扭曲。

十五、Repository:集中常用存取規則

class SiteRepository:
    def __init__(self, session):
        self.session = session

    def get_by_code(self, site_code):
        statement = select(Site).where(
            Site.site_code == site_code
        )
        return self.session.scalar(statement)

    def add(self, site_code, display_name):
        if self.get_by_code(site_code) is not None:
            raise ValueError("場域代碼已存在")
        site = Site(
            site_code=site_code,
            display_name=display_name,
        )
        self.session.add(site)
        return site


with Session(engine) as session:
    with session.begin():
        repository = SiteRepository(session)
        repository.add("SITE-03", "示範池C")

Repository不應偷偷commit;由外層服務決定交易邊界,才能把多個Repository操作包在同一交易中。

十六、服務層:把場域規則與資料存取分開

def register_sensor(session, site_code, sensor_code, kind, unit):
    repository = SiteRepository(session)
    site = repository.get_by_code(site_code)
    if site is None:
        raise ValueError("場域不存在")

    duplicate = session.scalar(
        select(Sensor).where(Sensor.sensor_code == sensor_code)
    )
    if duplicate is not None:
        raise ValueError("感測器代碼已存在")

    sensor = Sensor(
        sensor_code=sensor_code,
        kind=kind,
        unit=unit,
        site=site,
    )
    session.add(sensor)
    return sensor


with Session(engine) as session:
    with session.begin():
        register_sensor(
            session, "SITE-03", "TEMP-03",
            "temperature", "°C",
        )

十七、用SQLite快速測試相同Model

from sqlalchemy import create_engine
from sqlalchemy.orm import Session


test_engine = create_engine("sqlite+pysqlite:///:memory:")
Base.metadata.create_all(test_engine)

with Session(test_engine) as session:
    with session.begin():
        repository = SiteRepository(session)
        repository.add("TEST-01", "測試場域")

with Session(test_engine) as session:
    saved = session.scalar(
        select(Site).where(Site.site_code == "TEST-01")
    )
    assert saved is not None
    assert saved.display_name == "測試場域"

print("測試通過")

SQLite測試快速,但不能完全代表MySQL:型別、排序規則、鎖定、時區、函式與並行行為可能不同。因此還需要少量MySQL整合測試。

十八、Session常見錯誤

錯誤改善方式
全站共用一個Session每個請求或工作單元建立並關閉Session
Repository自行commit由服務層統一決定提交或回滾
離開Session後才讀延遲關聯在Session內預載或轉為輸出資料
直接回傳ORM物件給外部API選擇必要欄位,避免洩漏內部資料
把echo=True留在正式環境使用受控Log,避免敏感參數外洩

十九、USR資料治理:Model不等於公開資料

Model可能包含場域內部欄位,但學生、養殖戶、公開儀表板與研究資料集需要不同視圖。應在服務層或API輸出層做角色授權與欄位選擇,使用匿名場域代碼,避免輸出姓名、電話、精確座標、設備Token與連線資訊。

def public_site_summary(site, latest_value=None):
    return {
        "site_code": site.site_code,
        "display_name": site.display_name,
        "latest_temperature": latest_value,
    }


# 不輸出內部主鍵、帳號、精確位置或設備憑證

二十、課堂挑戰

挑戰A|基礎:為Reading增加received_at欄位,說明observed_at與received_at的差別。
挑戰B|進階:建立ReadingRepository,完成「批次新增」及「查每支感測器最新有效讀值」,但不要在Repository內commit。
挑戰C|USR場域:設計公開版、養殖戶版及維運版三種輸出資料,逐欄說明誰能看、為何需要,以及保存多久。

二十一、用AI協助審查ORM,而不是盲目產碼

請擔任SQLAlchemy 2.x程式碼審查助教。
系統使用MySQL、PyMySQL,模型為Site、Sensor、Reading。
請檢查:關聯方向、外鍵、唯一鍵、NULL、索引、時區、
Session生命週期、交易邊界、N+1查詢、連線池與敏感資料輸出。
不要改用舊式session.query,也不要要求真實帳密。
請把問題分成「會造成資料錯誤」「效能風險」「維護性」三類,
每項提供最小修正範例與可執行測試。

Python一下:從SQLite走向MySQL——建立多人共用的智慧養殖資料庫

《Python一下:從風土資料到智慧生活》第 27 篇

從SQLite走向MySQL——建立多人共用的智慧養殖資料庫

讓場域設備、學生團隊與管理平台安全共用資料,而不是共用一個資料庫檔案。

CH13 關聯式資料庫MySQLPyMySQL
學習目標
完成本篇後,你能比較SQLite與MySQL的適用情境,建立UTF-8資料庫及最小權限帳號,使用環境變數與PyMySQL安全連線,以交易完成批次寫入、查詢與更新,並說明遠端部署、備份及個資保護的基本原則。

一、SQLite很好,為何還需要MySQL?

SQLite把整個資料庫放在單一檔案中,適合個人練習、離線程式、小型裝置及單機原型。當多座智慧養殖場域、Django平台與學生團隊需要同時存取,伺服器型資料庫較容易集中管理帳號、連線、權限、交易與備份。

比較SQLiteMySQL
架構程式直接讀寫檔案用戶端連線至資料庫伺服器
安裝Python內建sqlite3需安裝、啟動及維護服務
多人並行讀取方便,寫入並行有限適合多使用者與網路服務
帳號權限主要依賴檔案權限可依帳號、主機與資料庫授權
適合本系列課堂練習、離線採集多人共用平台與正式場域
不是資料越多就一定要換MySQL:是否遷移應看同時使用者、寫入頻率、權限、維運能力、可用性及備份需求。若SQLite已能安全滿足需求,不必為了「看起來專業」增加系統複雜度。

二、準備MySQL與Python驅動程式

先由教師或系統管理者安裝受支援版本的MySQL Server。Python端使用PyMySQL:

import subprocess
import sys


subprocess.run(
    [sys.executable, "-m", "pip", "install", "PyMySQL"],
    check=True,
)

在終端機直接執行時,也可輸入 python -m pip install PyMySQL。教室與正式專案應使用虛擬環境,並將套件版本記錄於requirements.txt。

三、由管理者建立資料庫與專用帳號

以下SQL應由有權限的管理者在MySQL工具中執行;請替換密碼,且不要直接開放給整個網際網路。

ADMIN_SQL = """
CREATE DATABASE IF NOT EXISTS shuijing_demo
  CHARACTER SET utf8mb4
  COLLATE utf8mb4_0900_ai_ci;

CREATE USER IF NOT EXISTS
  'shuijing_app'@'10.0.0.%'
  IDENTIFIED BY '請換成長且唯一的密碼';

GRANT SELECT, INSERT, UPDATE, DELETE
  ON shuijing_demo.*
  TO 'shuijing_app'@'10.0.0.%';

FLUSH PRIVILEGES;
"""

print(ADMIN_SQL)

10.0.0.%只是私有網段示例,必須依實際網路縮小允許來源。應另外設計遷移帳號負責CREATE與ALTER;平常執行的應用程式帳號不需要DROP、GRANT等高權限。

四、把連線設定放在環境變數

不要把密碼寫進Python、Colab、GitHub或部落格。不同作業系統的設定方式不同,程式只負責讀取:

import os


DB_CONFIG = {
    "host": os.getenv("SHUIJING_DB_HOST", "127.0.0.1"),
    "port": int(os.getenv("SHUIJING_DB_PORT", "3306")),
    "user": os.getenv("SHUIJING_DB_USER", "shuijing_app"),
    "password": os.getenv("SHUIJING_DB_PASSWORD", ""),
    "database": os.getenv("SHUIJING_DB_NAME", "shuijing_demo"),
}

if not DB_CONFIG["password"]:
    raise RuntimeError("尚未設定SHUIJING_DB_PASSWORD")
Google Colab提醒:不要在公開Notebook中顯示密碼。可使用Colab的Secrets功能讀取密鑰;資料庫也不應直接暴露3306連接埠給所有來源。教學可使用校內測試網段、VPN、SSH通道或受控雲端環境。

五、建立安全連線函式

import pymysql


def connect_database(config):
    return pymysql.connect(
        host=config["host"],
        port=config["port"],
        user=config["user"],
        password=config["password"],
        database=config["database"],
        charset="utf8mb4",
        cursorclass=pymysql.cursors.DictCursor,
        autocommit=False,
        connect_timeout=10,
        read_timeout=10,
        write_timeout=10,
    )


connection = connect_database(DB_CONFIG)
with connection.cursor() as cursor:
    cursor.execute("SELECT VERSION() AS version")
    print(cursor.fetchone())

連線逾時避免程式無限等待;DictCursor讓查詢結果以欄名存取。正式遠端連線應依部署環境驗證TLS憑證,不應只為方便而關閉驗證。

六、建立三張InnoDB關聯表

SCHEMA_SQL = [
    """
    CREATE TABLE IF NOT EXISTS sites (
      site_id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
      site_code VARCHAR(30) NOT NULL UNIQUE,
      display_name VARCHAR(100) NOT NULL,
      active BOOLEAN NOT NULL DEFAULT TRUE,
      created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
    ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
    """,
    """
    CREATE TABLE IF NOT EXISTS sensors (
      sensor_id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
      sensor_code VARCHAR(50) NOT NULL UNIQUE,
      site_id BIGINT UNSIGNED NOT NULL,
      kind VARCHAR(30) NOT NULL,
      unit VARCHAR(20) NOT NULL,
      CONSTRAINT fk_sensors_site
        FOREIGN KEY (site_id) REFERENCES sites(site_id)
        ON UPDATE CASCADE ON DELETE RESTRICT
    ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
    """,
    """
    CREATE TABLE IF NOT EXISTS readings (
      reading_id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
      sensor_id BIGINT UNSIGNED NOT NULL,
      observed_at DATETIME(6) NOT NULL,
      value DOUBLE NULL,
      quality ENUM('valid','unknown','invalid') NOT NULL,
      note VARCHAR(255) NOT NULL DEFAULT '',
      received_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
      UNIQUE KEY uq_sensor_time (sensor_id, observed_at),
      KEY idx_readings_time (observed_at),
      CONSTRAINT fk_readings_sensor
        FOREIGN KEY (sensor_id) REFERENCES sensors(sensor_id)
        ON DELETE RESTRICT
    ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
    """,
]

try:
    with connection.cursor() as cursor:
        for statement in SCHEMA_SQL:
            cursor.execute(statement)
    connection.commit()
except Exception:
    connection.rollback()
    raise

使用InnoDB才能可靠支援交易與外鍵。時間欄位的時區策略要先約定:常見做法是以UTC寫入 DATETIME,應用程式顯示時再轉成臺灣時間,避免夏令時間或跨區部署造成歧義。

七、SQLite與MySQL最容易混淆的差異

用途SQLitePyMySQL/MySQL
參數佔位符?%s
自動編號INTEGER PRIMARY KEYAUTO_INCREMENT
真假值通常以0與1儲存BOOLEAN實際為TINYINT(1)
新增或忽略ON CONFLICT DO NOTHINGINSERT IGNORE或ON DUPLICATE KEY UPDATE
列資料sqlite3.RowDictCursor

八、用參數化SQL新增場域

def add_site(connection, site_code, display_name):
    sql = """
        INSERT INTO sites (site_code, display_name)
        VALUES (%s, %s)
    """
    try:
        with connection.cursor() as cursor:
            cursor.execute(sql, (site_code, display_name))
        connection.commit()
    except pymysql.MySQLError:
        connection.rollback()
        raise


add_site(connection, "SITE-01", "示範池A")

即使PyMySQL的佔位符長得像字串格式化,也不能自己使用 % 或f-string拼接SQL;參數必須交給 execute() 的第二個參數處理。

九、重複執行也安全的Upsert

def upsert_site(connection, site_code, display_name):
    sql = """
        INSERT INTO sites (site_code, display_name)
        VALUES (%s, %s) AS new
        ON DUPLICATE KEY UPDATE
          display_name = new.display_name
    """
    try:
        with connection.cursor() as cursor:
            cursor.execute(sql, (site_code, display_name))
        connection.commit()
    except pymysql.MySQLError:
        connection.rollback()
        raise


upsert_site(connection, "SITE-01", "示範池A")

Upsert可讓同步工作重跑而不產生重複場域,但更新哪些欄位仍需明確規劃,避免新資料不小心覆蓋人工校正內容。

十、交易:場域與感測器一起成功

def create_site_with_sensor(
    connection, site_code, display_name,
    sensor_code, kind, unit
):
    try:
        with connection.cursor() as cursor:
            cursor.execute(
                """
                INSERT INTO sites (site_code, display_name)
                VALUES (%s, %s)
                """,
                (site_code, display_name),
            )
            site_id = cursor.lastrowid
            cursor.execute(
                """
                INSERT INTO sensors
                  (sensor_code, site_id, kind, unit)
                VALUES (%s, %s, %s, %s)
                """,
                (sensor_code, site_id, kind, unit),
            )
        connection.commit()
    except pymysql.MySQLError:
        connection.rollback()
        raise

任何一步失敗都回滾,避免只留下沒有設備的半套資料。不要在尚未完成整個工作單元前提早commit。

十一、批次寫入感測資料

def insert_readings(connection, sensor_id, items):
    sql = """
        INSERT INTO readings
          (sensor_id, observed_at, value, quality, note)
        VALUES (%s, %s, %s, %s, %s)
        ON DUPLICATE KEY UPDATE
          value = VALUES(value),
          quality = VALUES(quality),
          note = VALUES(note)
    """
    rows = [
        (
            sensor_id,
            item["observed_at"],
            item.get("value"),
            item["quality"],
            item.get("note", ""),
        )
        for item in items
    ]
    try:
        with connection.cursor() as cursor:
            cursor.executemany(sql, rows)
        connection.commit()
    except pymysql.MySQLError:
        connection.rollback()
        raise
先驗證再入庫:程式仍應檢查時間格式、資料型別、quality允許值與合理範圍。超出範圍的原始數值可標成invalid保留,以利追查;設備離線則用NULL加unknown,不要偽裝成0。

十二、JOIN查出最新有效水溫

def latest_temperature(connection, site_code):
    sql = """
        SELECT s.site_code,
               s.display_name,
               e.sensor_code,
               r.observed_at,
               r.value,
               e.unit
        FROM sites AS s
        JOIN sensors AS e ON e.site_id = s.site_id
        JOIN readings AS r ON r.sensor_id = e.sensor_id
        WHERE s.site_code = %s
          AND e.kind = 'temperature'
          AND r.quality = 'valid'
        ORDER BY r.observed_at DESC, r.reading_id DESC
        LIMIT 1
    """
    with connection.cursor() as cursor:
        cursor.execute(sql, (site_code,))
        return cursor.fetchone()


print(latest_temperature(connection, "SITE-01"))

十三、鎖定資料後更新:避免互相覆蓋

def deactivate_site(connection, site_code):
    try:
        with connection.cursor() as cursor:
            cursor.execute(
                """
                SELECT site_id, active
                FROM sites
                WHERE site_code = %s
                FOR UPDATE
                """,
                (site_code,),
            )
            site = cursor.fetchone()
            if site is None:
                raise ValueError("找不到場域")
            cursor.execute(
                "UPDATE sites SET active = FALSE WHERE site_id = %s",
                (site["site_id"],),
            )
        connection.commit()
    except Exception:
        connection.rollback()
        raise

FOR UPDATE需置於交易中,適合「先讀再改」且不能被他人同時改動的流程。鎖定範圍與交易時間應盡量縮小,以免造成等待或死結。

十四、連線生命週期與錯誤紀錄

import logging


logging.basicConfig(level=logging.INFO)
logger = logging.getLogger("shuijing-db")


def run_job(config):
    connection = None
    try:
        connection = connect_database(config)
        with connection.cursor() as cursor:
            cursor.execute("SELECT 1 AS healthy")
            logger.info("database health=%s", cursor.fetchone()["healthy"])
    except pymysql.MySQLError as error:
        logger.error("資料庫作業失敗:%s", type(error).__name__)
        raise
    finally:
        if connection is not None:
            connection.close()

正式Log不要輸出密碼、完整連線字串、Token、住戶姓名或精確座標。網站服務通常使用連線池;每次請求都新建連線會浪費資源,下篇SQLAlchemy會再處理。

十五、從SQLite搬到MySQL的正確流程

  1. 盤點SQLite結構、型別、外鍵、索引、NULL及重複資料。
  2. 先在MySQL測試環境建立結構,不直接動正式系統。
  3. 以Python分批讀取SQLite、驗證、轉換時間與寫入MySQL。
  4. 比對每表筆數、抽樣內容、外鍵及彙總結果。
  5. 安排短暫停止寫入或雙寫切換,保留可回復方案。
  6. 確認應用程式穩定後,再依保存政策封存舊資料。
def compare_counts(sqlite_connection, mysql_connection, table):
    allowed = {"sites", "sensors", "readings"}
    if table not in allowed:
        raise ValueError("不允許的資料表")

    sqlite_count = sqlite_connection.execute(
        f"SELECT COUNT(*) FROM {table}"
    ).fetchone()[0]
    with mysql_connection.cursor() as cursor:
        cursor.execute(f"SELECT COUNT(*) AS total FROM {table}")
        mysql_count = cursor.fetchone()["total"]
    return sqlite_count, mysql_count

表名不能使用一般參數佔位符,所以只能從程式內的允許清單選取。筆數相等只是第一關,還要比對內容、關聯與統計結果。

十六、備份不是複製資料夾

MySQL運作中不能只複製資料目錄。應使用資料庫提供的邏輯備份或經驗證的實體備份機制,安排自動化、異地保存、加密、保存期限及定期還原演練。

BACKUP_CHECKLIST = [
    "備份範圍包含結構、資料、觸發器與必要帳號設定",
    "備份檔加密,存取權限與正式資料一致或更嚴格",
    "至少保存一份於不同故障範圍",
    "記錄備份時間、版本、雜湊值與執行結果",
    "定期在隔離環境實際還原並驗證查詢",
]

for number, item in enumerate(BACKUP_CHECKLIST, start=1):
    print(number, item)

十七、水井村USR的資料治理界線

做法原因
教材使用匿名場域代碼避免揭露養殖戶身分與精確位置
設備只寫入指定表格降低憑證外洩造成的損害
管理者、教師、學生分開帳號便於停權、稽核與責任釐清
平台不直接回傳所有原始資料依角色與任務提供最少資料
保留品質標記與規則版本避免把設備異常誤認為養殖異常

十八、課堂挑戰

挑戰A|基礎:建立一個只能SELECT指定資料表的唯讀帳號,說明它和應用程式讀寫帳號的差別。
挑戰B|進階:設計Python匯入程式,將1000筆資料分批寫入,每批失敗時只回滾該批,並記錄可重跑的錯誤清單。
挑戰C|USR場域:畫出「感測器→閘道器→API→MySQL→儀表板」的資料流,為每一段標示傳輸加密、身分驗證、最小權限與斷線補傳策略。

十九、用AI協助檢查遷移設計

請擔任MySQL資料庫助教,檢查SQLite移轉到MySQL的設計。
資料表為sites、sensors、readings,使用Python與PyMySQL。
請逐項檢查:資料型別、utf8mb4、時區、NULL、外鍵、
唯一鍵、索引、參數化SQL、交易、最小權限、TLS、備份與回復。
不要要求我貼出密碼或真實個資。
請將「一定要改」「建議改進」「需要場域確認」分成三類,
並為每一項提供可驗證方法。

2023年12月23日 星期六

使用pymysql套件來操作MySQL資料庫

 參考資料如下:

  1. 黃建庭,"Python程式設計從入門到進階應用",全華,第13章,第13-16頁。
  2. [2020鐵人賽Day17]糊裡糊塗Python就上手-MySQL操作探險歷程(上),https://ithelp.ithome.com.tw/articles/10246378
  3. [2020鐵人賽Day18]糊裡糊塗Python就上手-MySQL操作探險歷程(下),https://ithelp.ithome.com.tw/articles/10246379

本篇文章是將參考資料的第一篇文章的範例,用參考資料第二、三篇的套件改寫。

程式如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
import pymysql

# 連接到 MySQL 資料庫
connect_db = pymysql.connect(host='localhost', port=3306, user='root', passwd='', charset='utf8')

# 使用 with 來確保資源的正確釋放
with connect_db.cursor() as cursor:
    # 建立一個名為 'school' 的資料庫
    cursor.execute('CREATE DATABASE school')

    # 選擇 'school' 資料庫
    cursor.execute('USE school')

    # 建立一個名為 'stu' 的表格
    sql = """CREATE TABLE stu (
        stuid INTEGER PRIMARY KEY,
        name VARCHAR(50) NOT NULL,
        pid VARCHAR(20) NOT NULL,
        phone VARCHAR(20) NOT NULL )"""
    cursor.execute(sql)

    # 插入一條記錄到 'stu' 表格
    sql = "INSERT INTO stu VALUES (104001, 'Claire', 'B342222', '1245667')"
    cursor.execute(sql)

    # 查詢 'stu' 表格的所有記錄
    result = cursor.execute("SELECT * FROM stu")
    rows = cursor.fetchall()
    print(rows)

    # 刪除 'stu' 表格
    cursor.execute('DROP TABLE stu')

    # 刪除 'school' 資料庫
    cursor.execute('DROP DATABASE school')

執行結果:
((104001, 'Claire', 'B342222', '1245667'),)