最近更新: 2011-09-23

schema-database - 查詢結果與 PDO~~FETCH_CLASS

我上週整理我幾年前開發的一個具備簡單 Active record 與 ORM 功能的 PHP 資料庫函數庫 schema-database 。我想要為查詢函數加上一個參數,讓編程人員選擇查詢結果的型態為陣列或個體。因為 schema-database 的底層是 PDO ,所以要調整 PDO 汲取方法(fetch)的選項。

在改寫動作中,我注意到 PDO 有一個選項叫 PDO::FETCH_CLASS 。它與 PDO::FETCH_OBJ 的差別在於 PDO::FETCH_OBJ 汲取出的資料型態是標準類別的個體(stdClass),也就是單純的屬性集合體。而 PDO::FETCH_CLASS 汲取出的資料型態則是指定的類別的個體;這個類別可由使用者自己定義。 PDO::FETCH_CLASS 的用途,很明顯可以搭配 ORM 類,讓 PDO 直接幫使用者配置 ORM 實體。

由於我的 schema-database 本身就有提供一個通用的 ORM 類別,叫 Database_Row。自然我就順手加上了這個選項,讓使用者可以取出具有 ORM 能力的資料記錄。但是 PDO::FETCH_CLASS 有一個在文件中沒有提到的奇特行為,讓我一開始寫的程式發生錯誤,困擾了我一個下午的時間。

在我說明這個奇特行為之前,先讓我們試著思考一下同樣的事,我們應該怎麼做?

首先,我們會從資料庫中讀出指定的記錄。接著我們會配置對應的 ORM 個體。最後將記錄的資料內容,填入 ORM 個體的屬性。或者直接把資料內容作為配置 ORM 個體時的建構參數。總之,一個通常的過程是: 一、調用建構方法配置個體,二、填入資料欄位(個體屬性)。 但是 PDO::FETCH_CLASS 奇特之處就在於它的過程是相反的。它是先填入個體屬性,再調用建構方法。很奇特吧。 在 C++/Java 等語言中,根本沒有先填屬性再調建構方法這種事,因為在你調用建構方法之前根本沒東西存在。但是 PHP 並不禁止這種行為。

下列內容就是用 PHP 語言表示上述所說的過程。假設 Book 是一個 ORM 類別。第一段是一般預期的配置過程。第二段則是 PDO::FETCH_CLASS 的配置過程。

<?php
class Book {
    function __construct() {
        $this->isbn = $this->title = $this->author = '';
    }
}

$input_data = array(
    'isbn'  => '1234567890',
    'title' => 'Title 1',
    'author'=>  'rock'
);

// 1. expected way:
$b1 = new Book;
foreach ($input_data as $field => $v) {
    $b1->$field = $v;
}

// 2. PDO_FETCH_CLASS's way:
foreach ($input_data as $field => $v) {
    $b2->$field = $v;
}
$b2 = new Book;

echo "B1 title: {$b1->title}\n";
echo "B2 title: {$b2->title}\n"; // empty...
?>

眼尖的人,應該不用執行就可以看出 PDO::FETCH_CLASS 的奇特行為會帶來什麼困擾。 通常,建構方法會將個體的所有屬性指派為預設值或是空值。這表示 PDO::FETCH_CLASS 調用建構方法之後,它原先設定的欄位內容就全部重置了。於是接下來我們取出的值就不是我們預期的內容。

這個奇特行為曾經被舉報為 bug 與修正。但後來因為某些理由(但不知道是什麼),又被 PHP 維護團隊還原回去了,但另外增加一個 PDO::FETCH_PROPS_LATE 的調整選項。相關訊息可參考「Bug #49521 - PDO fetchObject sets values before calling constructor」。 PDO::FETCH_PROPS_LATE 選項可以延後設定屬性的行為,使 PDO::FETCH_CLASS 的配置過程改為先調建構方法再設屬性。

延用前一節的 PHP 程式碼,修改為實際 PDO::FETCH_CLASSPDO::FETCH_PROPS_LATE 的用法。

<?php
class Book {
    function __construct() {
        $this->isbn = $this->title = $this->author = '';
    }
    
    function dump() {
        echo "ISBN: {$this->isbn}\n";
        echo "Title: {$this->title}\n";
        echo "Author: {$this->author}\n";
    }
}

$dsn = 'sqlite::memory:';
$db = new PDO($dsn);
$db->query("CREATE TABLE books (
    isbn varchar(13) primary key,
    title varchar(1024) not null,
    author varchar(1024) not null
    );");
$db->query("INSERT INTO books VALUES ('1234567890', 'Title1 ', 'rock');");

$stat = $db->query("SELECT * FROM books;");

//$stat->setFetchMode(PDO::FETCH_CLASS, 'Book'); 
  // Unexcepted result. The constructor will reset all properties.

$stat->setFetchMode(PDO::FETCH_CLASS | PDO::FETCH_PROPS_LATE, 'Book');

while ($book = $stat->fetch()) {
    echo 'Class of book is ', get_class($book), "\n";
    $book->dump();
}
?>

我一開始說這是我在修改 schema-database 函數庫時碰到的問題。那麼用我的 schema-database 配上 PDO::FETCH_CLASS 會如何? 因為我的 schema-database 的 Database_Row 的建構方法總是會初始資料欄位,若是按照 PDO 原先的奇特行為,那麼所有的資料欄位都會被重置,就沒用了。必須搭配 PDO::FETCH_PROPS_LATE 才會得到正確結果。所以我將 schema-database 設計為看到 PDO::FETCH_CLASS 的選項時,就會自己搭配 PDO::FETCH_PROPS_LATE,且將記錄配置為 Database_Row 個體。

前一節的 pdo_fetch_props_late_example.php ,改用 schema-database 的 Database_Query 實作的話,程式碼將會是下列內容:

<?php
require_once 'database_query.php';

$dsn = 'sqlite::memory:';
$db = new PDO($dsn);
$db->query("CREATE TABLE books (
    isbn varchar(13) primary key,
    title varchar(1024) not null,
    author varchar(1024) not null
    );");
$db->query("INSERT INTO books VALUES ('1234567890', 'Title1 ', 'rock');");

$query = new Database_Query(array('db' => $db));

$books = $query->from('books')->select(PDO::FETCH_CLASS);
// schema-database 看到 PDO::FETCH_CLASS 的選項時,總是搭配 PDO::FETCH_PROPS_LATE,
// 且將記錄配置為 Database_Row 個體。

foreach ($books as $book) {
    echo 'Class of book is ', get_class($book), "\n"; // Database_Row
    echo "ISBN: {$book->isbn}; Title: {$book->title}\n";
}
?>

我在「PHP - Schema-Database」的介紹文章最後,舉了一個 PowerQuery 例子。當時在該範例中,我要自己取出記錄後轉成 Database_Row 個體。現在不必這麼做了。 但實際操作時的效能議題依舊。如果你只需要從查詢結果中取值,我仍建議你取為標準個體(PDO::FETCH_OBJ)或陣列(PDO::FETCH_ASSOC)。

樂多舊網址: http://blog.roodo.com/rocksaying/archives/16920627.html

樂多舊回應
未留名 (#comment-22046751)
Wed, 12 Oct 2011 14:59:48 +0800
我不熟識 PHP 。依我理解,這是一個應該由類別建構式來初始化還是應該由 PDO framework 透過資料庫提供的資料來初始化的問題,亦即是應該由被呼叫方還應該由呼叫方初始化的問題。

假若在呼叫完建構式後,再由 framework 主動初始化一次,即是你認為通常的過程,則在建構式執行時,建構式沒有之後會發生的事的知識(亦不應該有),無法再做進一步的初始化。變通方法之一是要求類別有一個 init 函式讓 framework 或 end programmer 在 framework 初始化完後(即填完資料後)再呼叫。變通方法之二是讓類別的實作暴露出來,由 end programmer 在 framework 初始化完後再自行做後續初始化。怎樣都好,都對類別有特定要求。

假若由 framework 先初始化就得到你的問題。變通方法之一是提供額外物件或函式,讓類別建構式可以加以使用來做初始化工作。

第三種方式是,把資料庫資料當參數傳入建構式來讓建構式被初始化,但這樣一來,建構式的介面就被固定了。

只要你以這裡存在 framework 、類別、使用它們的 end programmer 三方的角度思考,就會發現這是一個非常難搞的問題。
未留名 (#comment-22067079)
Thu, 27 Oct 2011 16:48:56 +0800
LungZeno ,在 PDO::FETCH_CLASS 的情境中,類別實體是由 PDO 配置的,並不是由我們的程式配置後傳給 PDO。 PDO 既是調用建構式的人,也是設定個體初始屬性的人。

然後 PDO 是透過指派運算把資料值指派給個體屬性,而不是透過建構式參數列,所以不必顧慮到它會不會限定建構式介面的事。如果程序員需要隱藏屬性的存取細節,基本上只要透過 PHP 的 magic method 覆寫個體屬性的指派行為即可,不需要提供初始值的特定設定方法

我想 PHP 開發團隊當時或許真的顧慮到什麼,才保留了這個奇特行為。但基於語言的特性,PHP 在處理這問題時,困難處應該不在你說的點上。

如果你對 PDO FETCH_CLASS 的用法有興趣,也可以再看看我寫的 database_query.php 內部是如何處理的。