親子関係の配列を階層ごと取得したい

何がやりたいかというと自分自身のユニークIDと親になるデータのIDを持っている配列があって、階層の深さは不明。こういう場合、その親子関係にある配列を階層ごと取得したい。ちょっとわかりづらいので、例題を。以下のようなデータ配列があるとします。

$terms = array(
	array( 'id' => 1, 'parent_id' => 0, 'name' => 'Size' ),
	array( 'id' => 2, 'parent_id' => 0, 'name' => 'Gender' ),
	array( 'id' => 3, 'parent_id' => 1, 'name' => 'L' ),
	array( 'id' => 4, 'parent_id' => 1, 'name' => 'M' ),
	array( 'id' => 5, 'parent_id' => 2, 'name' => 'Men' ),
	array( 'id' => 6, 'parent_id' => 5, 'name' => 'Pants' ),
	array( 'id' => 7, 'parent_id' => 2, 'name' => 'Women' ),
	array( 'id' => 8, 'parent_id' => 7, 'name' => 'Skirt' )
);

parent_idが親要素のidを指しています。parent_idが「0」のデータはルートカテゴリーを表します。この配列データを使って、以下のような形を作りたい。

1:Size
→3:L
→4:M
2:Gender
→5:Men
→→6:Pants
→7:Women
→→8:Skirt
  • 1→3
  • 1→4
  • 2→5→6
  • 2→7→8

つまり親子関係が成立しているカテゴリ(例題の場合4つ)を配列で取得したい。要はパンくずリスト作るんですけどね。これを実現する方法を考えてみる。

再帰処理を考える

こういう操作系ロジックを考えるときには、まずどちらから操作を始めるか考えます。ルートカテゴリはわかっていて末端のカテゴリが何階層になるかわからない。しかもカテゴリによって階層の深さが違うとなれば、末端のカテゴリの集合を作ってそれからルートになるまで繰り返す(再帰)処理を作ればよいと考えました。

まず末端のカテゴリーである証明をしなければいけません。証明する条件はparent_idに自身のidが含まれていないこと」になります。そうですよね。誰の親でもない=末端のカテゴリーという話。ですので、まず親カテゴリーだけのIDの集合を作って、それから自身のIDと比較して存在チェックを行います。

$parent_ids = array();
foreach ( $terms as $term ) {
	$parent_ids[] = $term['parent_id'];
}
var_dump( $parent_ids );

5行目のダンプで表示した内容は以下の通り。

array (size=8)
  0 => int 0
  1 => int 0
  2 => int 1
  3 => int 1
  4 => int 2
  5 => int 5
  6 => int 2
  7 => int 7

PHPには特定のcolumnだけ抜き出して新しい配列を作成してくれる便利関数array_columnがあるのですが、PHPのバージョンが5系だと5.5以上でないと対応していないので、まだちょっと使えない。7系はもちろん使用可能(PHPのバージョン上げない人多い気がする)。この関数を使用するとシンプルに以下のように書けます。

$parent_ids = array_column( $terms, 'parent_id' );

「0,0,1,1,2,5,2,7」が子カテゴリーを持つ親IDの集合になります。これを変数$parent_idsの配列に格納しました。この配列に存在しないID=末端のカテゴリーとなるので、PHPのin_array関数で存在チェックを行います。

$term_bottom = array();
foreach ( $terms as $term ) {
	if ( !in_array( $term['id'], $parent_ids ) ) {
		$term_bottom[] = $term['id'];
	}
}
var_dump( $term_bottom );

これもダンプで確認。表示した内容は以下の通り。

array (size=4)
  0 => int 3
  1 => int 4
  2 => int 6
  3 => int 8

「3,4,6,8」が末端カテゴリーIDの集合になります。nameも最終的に表示させたいので取得しておきます。これらを変数$term_bottomの配列に格納しました。この配列をループさせて再帰処理を実行する関数を作成しようと思います。

再帰処理を実行する関数の作成

まずは先程の末端カテゴリー配列をループさせ、その中で再帰処理を実行する関数をコールします。以下のような処理を追加。

$categories = array();
foreach ( $term_bottom as $term_id ) {
	$categories[] = $this->set_ids( $term_id, $terms );
}
var_dump( $categories );

3行目で再帰処理を実行する関数$this->set_ids()をコールしています(Class内で作成したprivate関数なので$this)。この関数を以下のように組みました。

private function set_ids ( $id, $terms, $args = array() ) {
	if ( $id == 0 ) {
		return array_reverse( $args );
	} else {
		$args[] = $id;

		foreach ( $terms as $term ) {
			if ( $term['id'] == $id ) {
				return $this->set_ids( $term['parent_id'], $terms, $args );
			}
		}
	}
}

引数

引数は全部で3つ。

  • 第1引数:最初は末端カテゴリーID/再帰時は親ID
  • 第3引数:カテゴリー階層配列(idはユニーク)
  • 第4引数:データを格納していく配列

処理の流れ

関数コール後の処理の流れをカテゴリー構成「2→7→8」を作成する例を元に解説。

  1. 最初のコールで、$this->set_ids( 8, $terms );を渡す。
  2. 関数2行目:$idは8なのでFalse分岐へ
  3. 関数5行目:$args配列にID8をプッシュ
  4. 関数7〜11行:ID8とマッチするidを探して見つかったら関数をコール
  5. 関数9行:関数コールで、ID8の親ID7をセット。$this->set_ids( 7, $terms, $args );を渡す。
  6. 関数2行目:2回目のコール、$idは7なのでFalse分岐へ
  7. 関数5行目:$args配列にID7をプッシュ
  8. 関数7〜11行:ID7とマッチするidを探して見つかったら関数をコール
  9. 関数9行:関数コールで、ID7の親ID2をセット。$this->set_ids( 2, $terms, $args );を渡す。
  10. 関数2行目:3回目のコール、$idは2なのでFalse分岐へ
  11. 関数5行目:$args配列にID2をプッシュ
  12. 関数7〜11行:2とマッチするidを探して見つかったら関数をコール
  13. 関数9行:関数コールで、ID2の親ID0をセット。$this->set_ids( 0, $terms, $args );を渡す。
  14. 関数2行目:4回目のコール、$idは0なのでTrue分岐へ
  15. 関数3行目:$args配列には「8→7→2」の順でプッシュされているので、「親→子」の順にするために、array_reverse関数で順序反転。
  16. 反転した配列をreturnして終了

これで1度目のループが完了で、$categories配列に$args配列の値がプッシュされます。

再帰処理の結果

最後に$categories配列のダンプ結果!

array (size=4)
  0 =>
    array (size=2)
      0 => int 1
      1 => int 3
  1 =>
    array (size=2)
      0 => int 1
      1 => int 4
  2 =>
    array (size=3)
      0 => int 2
      1 => int 5
      2 => int 6
  3 =>
    array (size=3)
      0 => int 2
      1 => int 7
      2 => int 8

できた!\(^o^)/
あとは名前を配列から取得してリストつくればOK。実はこれWordPressプラグインのパンくずリストの実装(カテゴリー)に使おうと思っていました。が、実はWordPrssのカテゴリー階層取得には簡単にできる関数が準備されてあるようです。次回はその関数get_ancestorsのお話を。

処理全文(再帰処理のサンプルソースコード)

最後に再帰処理のサンプルソースコードを貼り付けておきます。

new Sample();

class Sample {
	public function __construct () {
		$this->list_page_render();
	}

	public function list_page_render () {
		echo 'Current PHP version: ' . phpversion() . PHP_EOL;

		// カテゴリー階層配列(idはユニーク)
		$terms = array(
			array( 'id' => 1, 'parent_id' => 0, 'name' => 'Size' ),
			array( 'id' => 2, 'parent_id' => 0, 'name' => 'Gender' ),
			array( 'id' => 3, 'parent_id' => 1, 'name' => 'L' ),
			array( 'id' => 4, 'parent_id' => 1, 'name' => 'M' ),
			array( 'id' => 5, 'parent_id' => 2, 'name' => 'Men' ),
			array( 'id' => 6, 'parent_id' => 5, 'name' => 'Pants' ),
			array( 'id' => 7, 'parent_id' => 2, 'name' => 'Women' ),
			array( 'id' => 8, 'parent_id' => 7, 'name' => 'Skirt' )
		);

		// 親IDだけの配列を作成
		$parent_ids = array();
		foreach ( $terms as $term ) {
			$parent_ids[] = $term['parent_id'];
		}

		// カテゴリーの最下層のidを配列に保持
		$term_bottom = array();
		foreach ( $terms as $term ) {
			if ( !in_array( $term['id'], $parent_ids ) ) {
				$term_bottom[] = $term['id'];
			}
		}

		// 最下層の配列をループして木構造の頂点まで($parent_id = 0)
		$categories = array();
		foreach ( $term_bottom as $term_id ) {
			$categories[] = $this->set_ids( $term_id, $terms );
		}
		var_dump( $categories );
	}

	private function set_ids ( $id, $terms, $args = array() ) {
		if ( $id == 0 ) {
			return array_reverse( $args );
		} else {
			$args[] = $id;

			foreach ( $terms as $term ) {
				if ( $term['id'] == $id ) {
					return $this->set_ids( $term['parent_id'], $terms, $args );
				}
			}
		}
	}
}