【FuelPHP】データベースへ投げたクエリ結果を格納した変数の値を変更できなかった仕組み

 次の様なことをすると値が一見変更されるようでされません。エラーもでません(実はNoticeがでるっぽいです。この問題が出た時の実行環境はNoticeを無効化していた感)。

$users = DB::select('*')->from('users')
    ->execute();
foreach($users => $key as $value){
    $users[$key]['hoge'] = 'fuga';
}
var_dump($users);
// $usersの子要素が'hoge'をキーにする'fuga'を持たずにクエリ結果そのまま

 この罠な挙動の回避策自体はシンプルです。PHPに備わっている扱いやすい型である配列にすればよいです。

$users = DB::select('*')->from('users')
    ->execute()->as_array();
foreach($users => $key as $value){
    $users[$key]['hoge'] = 'fuga';
}
var_dump($users);
// $usersの子要素が'hoge'をキーにする'fuga'がある

 上の様にすると無事に値が追加されます。
 なぜこのようなことが起こるかというとFuelPHPのクエリ結果はforeachでき、配列としてアクセスもできますがその実オブジェクトであり、PHPでない独自処理が飛び交い、その中の処理の仕様でそうなっているからです。具体的には次の様になっています。
 まず、クエリ結果のオブジェクトは何であるか調べます。これはget_class関数が役に立ちます(FuelPHPのコメントはobject, mixedばかりで当てにならないことが多くて辛いです)。
 PHP: get_class – Manual
 get_class()で分かるexecute()で返ってきたオブジェクトはfuel/core/classes/database/result/cached.phpの

class Database_Result_Cached extends \Database_Result

 のインスタンスです。このDatabase_Result_Cachedとextends元の\Database_Resultに配列として扱った時の定義、foreachした時の定義があります。
 まず見るのはforeachした時、現在のキー(例でいう$key)がどうなっているか定義であるkeyメソッドです。PHPでforeachできるオブジェクトはPHP組み込みインタフェースであるIteratorインタフェースを満たしているクラスのインスタンスです。
PHP: Iterator – Manual
 Iteratorインタフェースでforeachの現在のキーはkeyメソッドの返り値と定められています。Database_Result_Cachedのkeyメソッドは次の様に実装されています。

	/**
	 * Implements [Iterator::key], returns the current row number.
	 *
	 *     echo key($result);
	 *
	 * @return  integer
	 */
	public function key()
	{
		return $this->_current_row;
	}

 面倒なので略しますが$this->_current_rowはイテレーターのインデックスとして定義されています。for(i;iPHP: ArrayAccess – Manual
 ArrayAccessインタフェースで配列としてアクセスした時、返ってくる値はoffsetGetの返り値と定められていますDatabase_Result_CachedのoffsetGetは次の様に実装されています。

// \Fuel\Core\Database_Result::offsetGet
	/**
	 * Implements [ArrayAccess::offsetGet], gets a given row.
	 *
	 *     $row = $result[10];
	 *
	 * @param integer $offset
	 *
	 * @return  mixed
	 */
	public function offsetGet($offset)
	{
	// イテレータの流用。$this->_current_rowを$offsetの値に変える
		if ( ! $this->seek($offset))
		{
			return null;
		}
	// イテレータのcurrentメソッドを呼び出し。実装は次
		$result = $this->current();

		// sanitize the data if needed
		if ($this->_sanitization_enabled)
		{
			$result = \Security::clean($result, null, 'security.output_filter');
		}

		return $result;
	}
// \Fuel\Core\Database_Result_Cached::current
	/**
	 * @return mixed
	 */
	public function current()
	{
		if ($this->valid())
		{
			// sanitize the data if needed
		// どちらの分岐でも現在のインデックスでクエリ結果プロパティ$this->_resultにアクセスし、その結果を変数に格納
			if ( ! $this->_sanitization_enabled)
			{
				$result = $this->_result[$this->_current_row];
			}
			else
			{
				$result = \Security::clean($this->_result[$this->_current_row], null, 'security.output_filter');
			}
		// クエリ結果の一部が格納された変数をそのまま返す
			return $result;
		}
	}

 要するに、offsetGetの結果で揮発する一時変数$resultを返しています。つまり最初のコードは次の様に説明できます。

$users = DB::select('*')->from('users')
    ->execute();
// $usersは$users->_resultという配列プロパティに結果を持つ
foreach($users => $key as $value){
// $users[$key]は$users->_result[$key]の複製された値を参照する
    $users[$key]['hoge'] = 'fuga';
// $users[$key]['hoge'] = 'fuga'は$users->_result[$key]の複製された値を変更する
}
// オリジナルの$usersは何も変わっていない
var_dump($users);

 このためPHP的に正常処理であり、値の変更が一見されそうでありながらも値の変更がされない処理が成立しました。この実装は$user[$key]を参照しようが$valueを参照しようが全く同じという実装でもあります。
 Database_Resultクラスのコードを見るにこれは読み取り専用クラスで元々書き換えを想定したつくりでないようです。配列として大本を変えようとすると

throw new \FuelException('Database results are read-only');

 が投げられます。今回のforeachからの配列アクセスでも同様に例外を投げるコードであるべきなのでしょうが漏れてしまいました。

 ちなみにoffsetGetの返り値の書き換えを反映するにはリファレンスを返す必要があり、書き換え側でもそれ用の書き方をする必要があります。面倒な上、分かりにくいので別にsetterを作った方がよさそうです。
PHP: リファレンスを返す – Manual

追記(2020/04/03):
 記事の問題と同じ問題を起こせる最小に近い構成は次です。左辺で配列内部の配列を参照したためにoffsetGetが動き、期待した動作になりませんでした。あとこれNoticeでますね。E_ALLならば問題が起きていても気づけます。

<?php
class Obj implements ArrayAccess {
    private $container = [];

    public function __construct() {
        $this->container = [
            'a' => [
                'b' => 'c',
            ],
        ];
    }

    public function offsetSet($offset, $value) {
        $this->container[$offset] = $value;
    }

    public function offsetExists($offset) {
        return isset($this->container[$offset]);
    }

    public function offsetUnset($offset) {
        unset($this->container[$offset]);
    }

    public function offsetGet($offset) {
        return isset($this->container[$offset]) ? $this->container[$offset] : null;
    }
}

$obj = new Obj;

var_dump($obj["a"]["b"]); // 'c'
$obj["a"]["b"] = 'd'; // $obj->offsetGet("a")["b"] = 'd'; とやっているのと同じ
var_dump($obj["a"]["b"]); // 'c'
$obj["a"] = ['b' => 'd']; // オブジェクトを一次元配列として扱えばoffsetSetが動く
var_dump($obj["a"]["b"]); // 'd'

実行結果

string(1) "c"

Notice: Indirect modification of overloaded element of Obj has no effect in [...][...] on line 33
string(1) "c" string(1) "d"
>株式会社シーポイントラボ

株式会社シーポイントラボ

TEL:053-543-9889
営業時間:9:00~18:00(月〜金)
住所:〒432-8003
   静岡県浜松市中央区和地山3-1-7
   浜松イノベーションキューブ 315
※ご来社の際はインターホンで「316」をお呼びください

CTR IMG