7
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

PHPAdvent Calendar 2022

Day 2

PHP で Postgres の boolean 型を安全に扱う

Last updated at Posted at 2022-12-01

忙しい人向けのまとめ

  • PHP で Postgres のデータを取得すると、各カラムは文字列
  • boolean 型を取得したときは "t" または "f"
  • 普段 PHP で bool 型を扱っている時と同じように扱うには、テーブル定義に合わせて記述するクラスに boolean のカラムは含めず、マジックメソッドで別途処理するとよい

背景

通常、PHP で Postgres からデータを取得する際、各カラムの値が文字列で取得されます。
boolean 型のカラムは true が "t"、false が "f" で取得されます。

論理型の値は "t" あるいは "f" の形式で返します。 配列を含むそれ以外の型は、PostgreSQL のやりかたにしたがって文字列として フォーマットされた形式で返します。

この仕様をあらかじめ把握していない場合、私たちは取得した値を if のカッコにそのまま放り込んでしまいます。
"f" は bool にキャストすると true ですから、本来の意図に反して if 節の中の処理が実行されてしまうのです。

なので、PHP と Postgres を扱う方は if ($is_deleted === 't') という風な書き方をする必要があるのですが、いちいちこのように書いていては PHP で bool 型を扱う感覚ともかけ離れてしまうし、仮にこの書き方を忘れてしまったとしてもレビュー工程で見過ごされてしまうのではないか、と思うのです(テストでカバーされていれば話は別ですが)。

なので、 PHP でいつも bool 型を扱う感じで if ($is_deleted) などと書いても不具合が起きないように私が採用している方法をご紹介します。

環境

Docker で PHP および Postgres の環境を用意します。

env/docker-compose.yaml
version: '3'

services:
  php:
    build:
      context: php
    volumes:
      - ../src:/src
    working_dir: /src
    tty: true
  db:
    image: postgres:14-alpine
    volumes:
      - ./db/docker-entrypoint-initdb.d:/docker-entrypoint-initdb.d
    environment:
      POSTGRES_PASSWORD: root
      POSTGRES_USER: root
      POSTGRES_DB: root
    ports:
      - "5432:5432"
env/php/Dockerfile
FROM php:8.1-alpine

RUN apk update \
    && apk add libpq-dev \
    && docker-php-ext-install pgsql
env/db/docker-entrypoint-initdb.d/data.sql
CREATE TABLE participant
(
    "id"        SERIAL NOT NULL,
    "name"      VARCHAR,
    "can_drink" BOOLEAN
);

INSERT INTO participant
    ("name", "can_drink")
VALUES
    ('John', TRUE),
    ('Jane', FALSE);

用意するデータ

今回は飲み会の参加者に提供する飲み物を決めるという想定で、名前と飲酒可能かどうかを格納するテーブルを用意します。

id name can_drink
1 John true
2 Jane false

pg_fetch_object 関数を用いて取得する

DB の定義に合わせてプロパティを設定

PHP の PostgreSQL 向けモジュールには、データの行を事前に定義したクラスで取得できる pg_fetch_object という関数があります。

型とカラム名をテーブル定義に合わせた、以下のようなクラスを使用します。

src/Participant1.php
<?php

class Participant1
{
    public int $id;
    public string $name;
    public bool $can_drink;
}

取得

src/index.php
<?php

require_once __DIR__ . '/Participant1.php';

$db_conn = pg_connect('host=db port=5432 dbname=root user=root password=root');

$qu = pg_query($db_conn, 'SELECT * FROM participant');
while ($data = pg_fetch_object($qu, null, Participant1::class)) {
    $name = $data->name;
    $canDrink = $data->can_drink;
    $message = $canDrink
        ? "$name can drink!"
        : "$name cannot drink...";
    echo $message, PHP_EOL;
}

結果

John can drink!
Jane can drink!

データ上では Jane さんは can_drink = false (飲めない) のはずなのですが、飲める!と出てしまいました。。。
これでは Jane さんが困ってしまいます 😢
各行が取得されたときのクラスを見ると、たしかに Jane さんの can_drinktrue になっています。

object(Participant1)#3 (3) {
  ["id"]=>
  int(1)
  ["name"]=>
  string(4) "John"
  ["can_drink"]=>
  bool(true)
}
object(Participant1)#4 (3) {
  ["id"]=>
  int(2)
  ["name"]=>
  string(4) "Jane"
  ["can_drink"]=>
  bool(true)
}

これは bool 型の $can_drink"f" を入れようとしたときに bool 型にキャストされて true になってしまった影響と考えられます。

これを解決するため、クラスを以下のように改良します。

boolean のカラムだけプロパティを直接定義せず、マジックメソッドで正しい値を埋め込む

src/Participant2.php
<?php

/**
 * boolean のカラムだけプロパティを直接定義せず、マジックメソッドで正しい値を埋め込むようにする。
 *
 * @property bool $can_drink
 */
class Participant2
{
    /**
     * boolean 値として認識させたいカラムをここに記述する。
     *
     * @var string[]
     */
    protected static array $booleanColumns = [
        'can_drink',
    ];

    /**
     * string や int など、キャストされても意味合いが変わらない場合は、このようにプロパティで記述する。
     *
     * @var int
     */
    public int $id;
    public string $name;
    // public bool $can_drink;

    public function __set(string $name, $value): void
    {
        if (in_array($name, self::$booleanColumns, true)) {
            $this->{$name} = match ($value) {
                't' => true,
                default => false,
            };

            return;
        }

        $this->{$name} = $value;
    }
}

bool 型のカラムだけあえてプロパティに定義せず、マジックメソッドが実行されるようにします。
$booleanColumns にあらかじめ bool 型のカラムはこれだよと書いておき、指定したカラムを扱う際は "t"true に、それ以外の値を false に置き換えます。
コード上に $can_drink の定義がなくなるため、IDE 上で補完したい場合はクラスのドキュメントに @property 属性で書きます。
(bool 型を返す can_drink() 関数を作ってもいいかもしれません)

match は PHP 8 系から使えるようになった書き方で、switch 句の型が厳密になったもの、というイメージです。

スクリプトを以下のように書き換えて実行します。

src/index.php
<?php

require_once __DIR__ . '/Participant2.php';

$db_conn = pg_connect('host=db port=5432 dbname=root user=root password=root');

$qu = pg_query($db_conn, 'SELECT * FROM participant');
while ($data = pg_fetch_object($qu, null, Participant2::class)) {
    $name = $data->name;
    $canDrink = $data->can_drink;
    $message = $canDrink
        ? "$name can drink!"
        : "$name cannot drink...";
    echo $message, PHP_EOL;
}

結果

John can drink!
Jane cannot drink...

Jane さんの「飲めない」が正しく出力されました :tada:

補足

__set 関数はコンストラクタよりも先に実行されている

先ほどのデータ取得にこちらのクラスを使ってみると、

src/Participant3.php
class Participant3
{
    // public int $id;
    // public string $name;
    // public bool $can_drink;

    public function __construct()
    {
        var_dump('constructor executed');
    }

    public function __set(string $name, $value): void
    {
        $this->{$name} = $value;
        var_dump('__set executed');
    }
}

出力結果は以下のようになります。

string(14) "__set executed"
string(14) "__set executed"
string(14) "__set executed"
string(20) "constructor executed"
string(14) "__set executed"
string(14) "__set executed"
string(14) "__set executed"
string(20) "constructor executed"

各行ごとに __set が先に実行され、続いてコンストラクタが実行されていることがわかります。


今回検証した内容は こちらのリポジトリ にもありますので、合わせてご覧ください。

7
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
7
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?