HHeLiBeXの日記 正道編

日々の記憶の記録とメモ‥

唐突にSQLのGROUP BYの処理をPHPで実装してみた

SQLのGROUP BYは、とても強力な機能であるにもかかわらず、記述はとてもシンプル。

SELECT
      num
    , MAX(score) AS c_max
    , MIN(score) AS c_min
    , AVG(score) AS c_avg
    , SUM(score) AS c_sum
FROM hoge
GROUP BY num
ORDER BY num

なぜか唐突に、これと同じ処理をPHPで書いてみようと思ったらしい。(エラーチェックちゃんとやってないのであしからず(謎))

<?php
class SqlFeature {
    /**
     * SQLのGROUP BYと同じグルーピング&集計処理を行う。
     * 
     * 処理対象のデータは次の形式の配列を指定する。
     * array(
     *     array(
     *         <column_name1> => <column_value1>,
     *         <column_name2> => <column_value2>,
     *         <column_name3> => <column_value3>,
     *             :
     *     ),
     *         :
     * )
     * 
     * グループ化対象列の名前は次の形式の配列を指定する。
     * array(
     *     <column_name1>,
     *     <column_name2>,
     *         :
     * )
     * 
     * 集計関数は次の形式の配列を指定する。
     * array(
     *     <agg_result_column_name1>
     *         => array('name' => <function_name1>, 'column' => <agg_column_name1>),
     *     <agg_result_column_name2>
     *         => array('name' => <function_name2>, 'column' => <agg_column_name2>),
     *             :
     * )
     * 
     * @param $rows 処理対象のデータ
     * @param $groupColumnNames グループ化対象列の名前
     * @param $aggFuncs 集計関数
     * @return 集計結果のデータ
     */
    public static function groupBy($rows, $groupColumnNames, $aggFuncs) {
        if (!is_array($groupColumnNames)) {
            $groupColumnNames = array($groupColumnNames);
        }

        $tmpRows = array();
        // グルーピング処理
        foreach ($rows as $row) {
            // グループ化対象列の値を抽出
            $keyVals = array();
            foreach ($groupColumnNames as $colName) {
                $keyVals[$colName] = $row[$colName];
            }
            // グループを特定するハッシュキーを生成
            $hashKey = self::genKey($keyVals);
            $tmpRows[$hashKey][':key:'] = $keyVals;

            // 各集計結果列用の集計対象値を抽出(配列として保持)
            foreach ($aggFuncs as $colName => $func) {
                $tmpRows[$hashKey][$colName][] = $row[$func['column']];
            }
        }
        // 集計処理
        foreach ($tmpRows as & $t) {
            foreach ($aggFuncs as $colName => $func) {
                // 集計元データを、集計関数での集計結果に置き換え
                $t[$colName] = call_user_func($func['name'], $t[$colName]);
            }
        }
        // 返却用データ配列の生成
        $res = array();
        foreach ($tmpRows as $tmpRow) {
            $row = array();
            // グループ化対象列の値の平坦化
            foreach ($groupColumnNames as $colName) {
                $row[$colName] = $tmpRow[':key:'][$colName];
            }
            // 集計結果の値をコピー
            foreach ($aggFuncs as $colName => $func) {
                $row[$colName] = $tmpRow[$colName];
            }
            $res[] = $row;
        }
        return $res;
    }
    /**
     * 値の配列から、連想配列のキーにするための文字列を生成する。
     */
    /*
     * 配列は連想配列のキーにできないので、代わりとなる文字列を生成するために使用。
     * なんでもいいんだけど、とりあえずMD5値をコロン(':')で囲ったものを連結。
     */
    private static function genKey($values) {
        $res = "";
        foreach ($values as $v) {
            $res .= ':' . md5($v) . ':';
        }
        return $res;
    }
}

使い方は以下のような感じ。

<?php

ini_set('display_errors', 'on');

require_once('SqlFeature.php');

/**
 * 平均を求めるための集計関数。
 */
function avg(array $vals) {
    return array_sum($vals) / count($vals);
}

/**
 * 集計結果を整形して出力する。
 */
function printResult($result) {
    foreach ($result as $r) {
        printf("%3d %15.5lf %15.5lf %15.5lf %15.5lf\n",
            $r["num"], $r["c_max"], $r["c_min"], $r["c_avg"], $r["c_sum"]);
    }
}

$data = array (
    array ( 'num' => '1', 'score' => '25'),
    array ( 'num' => '2', 'score' => '35'),
    array ( 'num' => '3', 'score' => '55'),
    array ( 'num' => '3', 'score' => '65'),
    array ( 'num' => '1', 'score' => '45'),
    array ( 'num' => '3', 'score' => '75'),
    array ( 'num' => '4', 'score' => '5'),
    array ( 'num' => '4', 'score' => '10'),
);
$aggFuncs = array(
    'c_max' => array('name' => 'max', 'column' => 'score'),
    'c_min' => array('name' => 'min', 'column' => 'score'),
    'c_avg' => array('name' => 'avg', 'column' => 'score'),
    'c_sum' => array('name' => 'array_sum', 'column' => 'score'),
);
$result = SqlFeature::groupBy($data, 'num', $aggFuncs);
usort($result, function($a, $b) { return $a['num'] - $b['num']; });
printResult($result);

結果はこんな感じ。

  1        45.00000        25.00000        35.00000        70.00000
  2        35.00000        35.00000        35.00000        35.00000
  3        75.00000        55.00000        65.00000       195.00000
  4        10.00000         5.00000         7.50000        15.00000

ここでは、データをべた書きしているが、以下のような処理でDBMSから取得したデータもそのまま利用可能。

$conn = pg_connect('host=localhost port=5432 dbname=postgres user=postgres password=postgres');
$sql = "select num, score"
    . " from hoge",
$res = pg_query($conn, $sql);
$data = array();
while ($row = pg_fetch_assoc($res)) {
    $data[] = $row;
}
pg_close($conn);