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

## 忙しい人向けのまとめ

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

## 背景

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

https://www.php.net/manual/ja/function.pg-fetch-result.php#refsect1-function.pg-fetch-result-returnvalues
> 論理型の値は "t" あるいは "f" の形式で返します。 配列を含むそれ以外の型は、PostgreSQL のやりかたにしたがって文字列として フォーマットされた形式で返します。

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

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

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

## 環境

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

```yaml: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"
```

```dockerfile:env/php/Dockerfile
FROM php:8.1-alpine

RUN apk update \
    && apk add libpq-dev \
    && docker-php-ext-install pgsql
```

```sql: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` という関数があります。

https://www.php.net/manual/ja/function.pg-fetch-object.php

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

```php:src/Participant1.php
<?php

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

取得

```php: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;
}
```

結果

```text
John can drink!
Jane can drink!
```

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

```text
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 のカラムだけプロパティを直接定義せず、マジックメソッドで正しい値を埋め込む

```php: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 句の型が厳密になったもの、というイメージです。

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

```php: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;
}
```

結果

```text
John can drink!
Jane cannot drink...
```

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

## 補足

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

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

```php: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');
    }
}
```

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

```text
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` が先に実行され、続いてコンストラクタが実行されていることがわかります。

---

今回検証した内容は [こちらのリポジトリ](https://gitlab.com/technical-study/articles/pg-fetch-object-boolean-as-boolean) にもありますので、合わせてご覧ください。