Laravelやってみる#9―laravel-nestedsetで木構造データを扱う

Laravel 7.x

ぬにょす(挨拶)。

表題の通りですが、PHPフレームワークであるところのLaravelを使ってみようということで、やったこと・できたこと・できなかったこと等を自分の備忘録として残していきます。

#9の今回は laravel-nestedset パッケージを使って木構造のデータを処理してみます。

やりたいこと

前回の Category モデルに親―子の階層構造を持たせたいと思います。パッと思いつくのは parent_id のようなカラムを追加することですが、子・孫を拾い出すのに再帰処理が必要になります。わざわざ車輪の再発明をするメリットはないので、素直にパッケージを利用して実現してみます。

インストール

composer を使ってインストールします。

composer require kalnoy/nestedset

マイグレーション

テーブル作成処理に nestedset が使うカラムを追加します。

    Schema::create('categories', function (Blueprint $table) {
      $table->id();
      $table->string('name');
      /*追加*/$table->nestedSet();
      $table->timestamps();
    });

テーブルを再作成すると、_lft, _rgt, parent_id というカラムが追加されていました。

モデル

NodeTrait トレイトを追加します。また、テーブルカラムとして存在しない depth プロパティをガードに追加します。

use Kalnoy\Nestedset\NodeTrait;

class Category extends Model
{
  use NodeTrait;

  protected $guarded = [
    'id',
    'created_at',
    'updated_at',
    'depth',
  ];
}

コントローラー

結論から言うと、変更が必要になったのは全件取得する index アクションだけでした。他のアクションは変更なしに NodeTrait トレイトがよしなに計らってくれました。

  public function index()
  {
    return Category::withDepth()->get()->toFlatTree();
  }

  public function store(Request $request)
  {
    $category = new Category($request->all());
    $category->save();
    return response()->json();
  }

  public function update(Request $request, Category $category)
  {
    $category->fill($request->all())->save();
    return response()->json();
  }

  public function destroy(Category $category)
  {
    $category->delete();
    return response()->json();
  }

index アクションでは、withDepth() でルートからの深さを表す depth プロパティを持たせ、フラットな(ネストのない)配列として返却するようにしました。

ビュー

Bootstrap-Vueを使っていたり、axios周りで自前のmixinを使っていたりする点はご了承ください。

<template>
  <b-container>
    <h1>Manage Category</h1>
    <b-row>
      <b-col md="6">
        <b-form @submit.prevent="onUpdate">
          <b-form-group label="Name" label-for="name">
            <b-form-input id="name" v-model="category.name" type="text" required />
          </b-form-group>
          <b-form-group label="Parent" label-for="parent_id">
            <b-form-select v-model="category.parent_id">
              <template v-slot:first>
                <b-form-select-option :value="null">None</b-form-select-option>
              </template>
              <b-form-select-option
                v-for="item in categories"
                :key="item.id"
                :value="item.id"
              >{{ '―'.repeat(item.depth) + ' ' + item.name }}</b-form-select-option>
            </b-form-select>
          </b-form-group>
          <b-button type="submit" variant="primary">{{ category.id ? 'Update' : 'Add' }} Category</b-button>
          <b-button @click="onClear">Clear</b-button>
        </b-form>
      </b-col>
      <b-col md="6">
        <b-table striped hover :items="categories" :fields="fields">
          <template
            v-slot:cell(name)="data"
          >{{ '―'.repeat(data.item.depth) + ' ' + data.item.name }}</template>
          <template v-slot:cell(actions)="data">
            <b-button size="sm" variant="success" @click="onEdit(data.item)">Edit</b-button>
            <b-button size="sm" variant="danger" @click="onRemove(data.item)">Remove</b-button>
          </template>
        </b-table>
      </b-col>
    </b-row>
  </b-container>
</template>

<script>
function Category() {
  this.name = "";
  this.parent_id = null;
}
export default {
  data: function() {
    return {
      category: {},
      categories: [],
      fields: ["name", "actions"]
    };
  },
  mounted: function() {
    this.category = new Category();
    this.getCategories();
  },
  methods: {
    onUpdate: async function() {
      let response;
      if (this.category.id) {
        response = await axios.patch(
          "/api/category/" + this.category.id,
          this.category
        );
      } else {
        response = await axios.post("/api/category", this.category);
      }

      if (this.OK(response)) {
        this.category = new Category();
        this.getCategories();
      } else {
        console.log(response);
      }
    },
    onEdit: function(item) {
      this.category = item;
    },
    onRemove: async function(item) {
      const response = await axios.delete("/api/category/" + item.id);
      if (this.OK(response)) {
        this.getCategories();
      } else {
        console.log(response);
      }
    },
    onClear: function() {
      this.category = new Category();
    },
    getCategories: async function() {
      const response = await axios.get("/api/category");
      if (this.OK(response)) {
        this.categories = response.data;
      } else {
        console.log(response);
      }
    }
  }
};
</script>

実行結果

途中で typo したのはご愛嬌。

まとめ

laravel-nestedset を利用することで、簡単に階層構造を持つデータを扱うことができました。ただ、この手のデータは不整合が起こりやすそうです。パッケージ側でもエラー処理してると思いますが、自前でも親データの存在チェックなどを行う必要があるでしょう。

コメント

タイトルとURLをコピーしました