スポンサーリンク

CakePHP3のbakeによる自動生成をカスタマイズする

前回に引き続きCakePHP3についての備忘録になります。
今回はbakeの自動生成処理をカスタマイズしてより修正が少なくできるような施策を実施します。以下のような単純なカラムを持つsamplesテーブルを使用します。

DROP TABLE IF EXISTS `samples`;
CREATE TABLE `samples` (
  `id` int(11) NOT NULL AUTO_INCREMENT,
  `value1` varchar(20) NOT NULL,
  `value2` varchar(255) NOT NULL,
  `created` datetime DEFAULT NULL,
  `modified` datetime DEFAULT NULL,
  `delete_flag` char(1) NOT NULL DEFAULT '0',
  PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=6 DEFAULT CHARSET=utf8;

INSERT INTO `samples` VALUES ('1', 'aaa1', 'bbb1', '2019-07-12 00:16:11', '2019-07-12 07:09:22', '0');
INSERT INTO `samples` VALUES ('2', 'test1', 'test2', '2019-07-12 00:17:07', '2019-07-12 00:19:03', '1');
INSERT INTO `samples` VALUES ('3', '111', '2222', '2019-07-12 00:21:36', '2019-07-12 00:21:36', '0');
INSERT INTO `samples` VALUES ('4', '123', '456', '2019-07-12 07:04:07', '2019-07-12 07:04:07', '0');
INSERT INTO `samples` VALUES ('5', '222', '333', '2019-07-12 07:09:12', '2019-07-12 07:09:12', '0');

記事が縦長になったので目次を追加してみました。v(^^)v

bakeによって生成されるソースについて

特にカスタマイズしていないbake allを実行すると以下のようなソースが生成されます。(テストクラス、Fixtureクラスは使い方があまり理解できてないので省略(^^;)

cake_app\bin\cake bake all samples
<?php
namespace App\Model\Entity;

use Cake\ORM\Entity;

/**
 * Sample Entity
 *
 * @property int $id
 * @property string $value1
 * @property string $value2
 * @property \Cake\I18n\FrozenTime|null $created
 * @property \Cake\I18n\FrozenTime|null $modified
 * @property string $delete_flag
 */
class Sample extends Entity
{

    /**
     * Fields that can be mass assigned using newEntity() or patchEntity().
     *
     * Note that when '*' is set to true, this allows all unspecified fields to
     * be mass assigned. For security purposes, it is advised to set '*' to false
     * (or remove it), and explicitly make individual fields accessible as needed.
     *
     * @var array
     */
    protected $_accessible = [
        'value1' => true,
        'value2' => true,
        'created' => true,
        'modified' => true,
        'delete_flag' => true
    ];
}
<?php
namespace App\Controller;

use App\Controller\AppController;

/**
 * Samples Controller
 *
 * @property \App\Model\Table\SamplesTable $Samples
 *
 * @method \App\Model\Entity\Sample[]|\Cake\Datasource\ResultSetInterface paginate($object = null, array $settings = [])
 */
class SamplesController extends AppController
{

    /**
     * Index method
     *
     * @return \Cake\Http\Response|void
     */
    public function index()
    {
        $samples = $this->paginate($this->Samples);

        $this->set(compact('samples'));
    }

    /**
     * View method
     *
     * @param string|null $id Sample id.
     * @return \Cake\Http\Response|void
     * @throws \Cake\Datasource\Exception\RecordNotFoundException When record not found.
     */
    public function view($id = null)
    {
        $sample = $this->Samples->get($id, [
            'contain' => []
        ]);

        $this->set('sample', $sample);
    }

    /**
     * Add method
     *
     * @return \Cake\Http\Response|null Redirects on successful add, renders view otherwise.
     */
    public function add()
    {
        $sample = $this->Samples->newEntity();
        if ($this->request->is('post')) {
            $sample = $this->Samples->patchEntity($sample, $this->request->getData());
            if ($this->Samples->save($sample)) {
                $this->Flash->success(__('The sample has been saved.'));

                return $this->redirect(['action' => 'index']);
            }
            $this->Flash->error(__('The sample could not be saved. Please, try again.'));
        }
        $this->set(compact('sample'));
    }

    /**
     * Edit method
     *
     * @param string|null $id Sample id.
     * @return \Cake\Http\Response|null Redirects on successful edit, renders view otherwise.
     * @throws \Cake\Datasource\Exception\RecordNotFoundException When record not found.
     */
    public function edit($id = null)
    {
        $sample = $this->Samples->get($id, [
            'contain' => []
        ]);
        if ($this->request->is(['patch', 'post', 'put'])) {
            $sample = $this->Samples->patchEntity($sample, $this->request->getData());
            if ($this->Samples->save($sample)) {
                $this->Flash->success(__('The sample has been saved.'));

                return $this->redirect(['action' => 'index']);
            }
            $this->Flash->error(__('The sample could not be saved. Please, try again.'));
        }
        $this->set(compact('sample'));
    }

    /**
     * Delete method
     *
     * @param string|null $id Sample id.
     * @return \Cake\Http\Response|null Redirects to index.
     * @throws \Cake\Datasource\Exception\RecordNotFoundException When record not found.
     */
    public function delete($id = null)
    {
        $this->request->allowMethod(['post', 'delete']);
        $sample = $this->Samples->get($id);
        if ($this->Samples->delete($sample)) {
            $this->Flash->success(__('The sample has been deleted.'));
        } else {
            $this->Flash->error(__('The sample could not be deleted. Please, try again.'));
        }

        return $this->redirect(['action' => 'index']);
    }
}
<?php
namespace App\Model\Table;

use Cake\ORM\Query;
use Cake\ORM\RulesChecker;
use Cake\ORM\Table;
use Cake\Validation\Validator;

/**
 * Samples Model
 *
 * @method \App\Model\Entity\Sample get($primaryKey, $options = [])
 * @method \App\Model\Entity\Sample newEntity($data = null, array $options = [])
 * @method \App\Model\Entity\Sample[] newEntities(array $data, array $options = [])
 * @method \App\Model\Entity\Sample|bool save(\Cake\Datasource\EntityInterface $entity, $options = [])
 * @method \App\Model\Entity\Sample|bool saveOrFail(\Cake\Datasource\EntityInterface $entity, $options = [])
 * @method \App\Model\Entity\Sample patchEntity(\Cake\Datasource\EntityInterface $entity, array $data, array $options = [])
 * @method \App\Model\Entity\Sample[] patchEntities($entities, array $data, array $options = [])
 * @method \App\Model\Entity\Sample findOrCreate($search, callable $callback = null, $options = [])
 *
 * @mixin \Cake\ORM\Behavior\TimestampBehavior
 */
class SamplesTable extends Table
{

    /**
     * Initialize method
     *
     * @param array $config The configuration for the Table.
     * @return void
     */
    public function initialize(array $config)
    {
        parent::initialize($config);

        $this->setTable('samples');
        $this->setDisplayField('id');
        $this->setPrimaryKey('id');

        $this->addBehavior('Timestamp');
    }

    /**
     * Default validation rules.
     *
     * @param \Cake\Validation\Validator $validator Validator instance.
     * @return \Cake\Validation\Validator
     */
    public function validationDefault(Validator $validator)
    {
        $validator
            ->integer('id')
            ->allowEmpty('id', 'create');

        $validator
            ->scalar('value1')
            ->maxLength('value1', 20)
            ->requirePresence('value1', 'create')
            ->notEmpty('value1');

        $validator
            ->scalar('value2')
            ->maxLength('value2', 255)
            ->requirePresence('value2', 'create')
            ->notEmpty('value2');

        $validator
            ->scalar('delete_flag')
            ->maxLength('delete_flag', 1)
            ->requirePresence('delete_flag', 'create')
            ->notEmpty('delete_flag');

        return $validator;
    }
}

<?php
/**
 * @var \App\View\AppView $this
 * @var \App\Model\Entity\Sample[]|\Cake\Collection\CollectionInterface $samples
 */
?>
<nav class="large-3 medium-4 columns" id="actions-sidebar">
    <ul class="side-nav">
        <li class="heading"><?= __('Actions') ?></li>
        <li><?= $this->Html->link(__('New Sample'), ['action' => 'add']) ?></li>
    </ul>
</nav>
<div class="samples index large-9 medium-8 columns content">
    <h3><?= __('Samples') ?></h3>
    <table cellpadding="0" cellspacing="0">
        <thead>
            <tr>
                <th scope="col"><?= $this->Paginator->sort('id') ?></th>
                <th scope="col"><?= $this->Paginator->sort('value1') ?></th>
                <th scope="col"><?= $this->Paginator->sort('value2') ?></th>
                <th scope="col"><?= $this->Paginator->sort('created') ?></th>
                <th scope="col"><?= $this->Paginator->sort('modified') ?></th>
                <th scope="col"><?= $this->Paginator->sort('delete_flag') ?></th>
                <th scope="col" class="actions"><?= __('Actions') ?></th>
            </tr>
        </thead>
        <tbody>
            <?php foreach ($samples as $sample): ?>
            <tr>
                <td><?= $this->Number->format($sample->id) ?></td>
                <td><?= h($sample->value1) ?></td>
                <td><?= h($sample->value2) ?></td>
                <td><?= h($sample->created) ?></td>
                <td><?= h($sample->modified) ?></td>
                <td><?= h($sample->delete_flag) ?></td>
                <td class="actions">
                    <?= $this->Html->link(__('View'), ['action' => 'view', $sample->id]) ?>
                    <?= $this->Html->link(__('Edit'), ['action' => 'edit', $sample->id]) ?>
                    <?= $this->Form->postLink(__('Delete'), ['action' => 'delete', $sample->id], ['confirm' => __('Are you sure you want to delete # {0}?', $sample->id)]) ?>
                </td>
            </tr>
            <?php endforeach; ?>
        </tbody>
    </table>
    <div class="paginator">
        <ul class="pagination">
            <?= $this->Paginator->first('<< ' . __('first')) ?>
            <?= $this->Paginator->prev('< ' . __('previous')) ?>
            <?= $this->Paginator->numbers() ?>
            <?= $this->Paginator->next(__('next') . ' >') ?>
            <?= $this->Paginator->last(__('last') . ' >>') ?>
        </ul>
        <p><?= $this->Paginator->counter(['format' => __('Page {{page}} of {{pages}}, showing {{current}} record(s) out of {{count}} total')]) ?></p>
    </div>
</div>
<?php
/**
 * @var \App\View\AppView $this
 * @var \App\Model\Entity\Sample $sample
 */
?>
<nav class="large-3 medium-4 columns" id="actions-sidebar">
    <ul class="side-nav">
        <li class="heading"><?= __('Actions') ?></li>
        <li><?= $this->Html->link(__('Edit Sample'), ['action' => 'edit', $sample->id]) ?> </li>
        <li><?= $this->Form->postLink(__('Delete Sample'), ['action' => 'delete', $sample->id], ['confirm' => __('Are you sure you want to delete # {0}?', $sample->id)]) ?> </li>
        <li><?= $this->Html->link(__('List Samples'), ['action' => 'index']) ?> </li>
        <li><?= $this->Html->link(__('New Sample'), ['action' => 'add']) ?> </li>
    </ul>
</nav>
<div class="samples view large-9 medium-8 columns content">
    <h3><?= h($sample->id) ?></h3>
    <table class="vertical-table">
        <tr>
            <th scope="row"><?= __('Value1') ?></th>
            <td><?= h($sample->value1) ?></td>
        </tr>
        <tr>
            <th scope="row"><?= __('Value2') ?></th>
            <td><?= h($sample->value2) ?></td>
        </tr>
        <tr>
            <th scope="row"><?= __('Delete Flag') ?></th>
            <td><?= h($sample->delete_flag) ?></td>
        </tr>
        <tr>
            <th scope="row"><?= __('Id') ?></th>
            <td><?= $this->Number->format($sample->id) ?></td>
        </tr>
        <tr>
            <th scope="row"><?= __('Created') ?></th>
            <td><?= h($sample->created) ?></td>
        </tr>
        <tr>
            <th scope="row"><?= __('Modified') ?></th>
            <td><?= h($sample->modified) ?></td>
        </tr>
    </table>
</div>
<?php
/**
 * @var \App\View\AppView $this
 * @var \App\Model\Entity\Sample $sample
 */
?>
<nav class="large-3 medium-4 columns" id="actions-sidebar">
    <ul class="side-nav">
        <li class="heading"><?= __('Actions') ?></li>
        <li><?= $this->Html->link(__('List Samples'), ['action' => 'index']) ?></li>
    </ul>
</nav>
<div class="samples form large-9 medium-8 columns content">
    <?= $this->Form->create($sample) ?>
    <fieldset>
        <legend><?= __('Add Sample') ?></legend>
        <?php
            echo $this->Form->control('value1');
            echo $this->Form->control('value2');
            echo $this->Form->control('delete_flag');
        ?>
    </fieldset>
    <?= $this->Form->button(__('Submit')) ?>
    <?= $this->Form->end() ?>
</div>
<?php
/**
 * @var \App\View\AppView $this
 * @var \App\Model\Entity\Sample $sample
 */
?>
<nav class="large-3 medium-4 columns" id="actions-sidebar">
    <ul class="side-nav">
        <li class="heading"><?= __('Actions') ?></li>
        <li><?= $this->Form->postLink(
                __('Delete'),
                ['action' => 'delete', $sample->id],
                ['confirm' => __('Are you sure you want to delete # {0}?', $sample->id)]
            )
        ?></li>
        <li><?= $this->Html->link(__('List Samples'), ['action' => 'index']) ?></li>
    </ul>
</nav>
<div class="samples form large-9 medium-8 columns content">
    <?= $this->Form->create($sample) ?>
    <fieldset>
        <legend><?= __('Edit Sample') ?></legend>
        <?php
            echo $this->Form->control('value1');
            echo $this->Form->control('value2');
            echo $this->Form->control('delete_flag');
        ?>
    </fieldset>
    <?= $this->Form->button(__('Submit')) ?>
    <?= $this->Form->end() ?>
</div>

EclipseのGitステージングで見ると以下のような感じ。

ビルトインサーバーを起動してブラウザから確認すると以下のような表示となります。bootstrap4を手動で組み込んでいるため、bakeによって生成されたソースが崩れてしまっていることが確認できます。一覧画面では論理削除状態のデータも表示されてしまっています。

一覧画面

編集画面

自動生成処理のカスタマイズについて

自動生成されたソースをカスタマイズするには、まずcomposerによってインストールされたcake_app/vendor/cakephp/bake/src/Template/Bake以下のソースをcake_app/src/Template/Bakeにコピーする必要があるようです。デフォルトではbake実行時にvendor以下の各ソースが参照されており、それをオーバーライドするイメージ。
参考:CakePHP3のBakeコマンドで生成されるファイルをカスタマイズする | 創作メモ帳

コピー元(赤枠部分)

コピー先(赤枠部分)

コピー先のBakeディレクトリは存在しないので、まずディレクトリを作成します。
コピーするのは手を加えるソースだけで良いようですが今回はざっくりとコピーしています。

twigテンプレートの編集について

twigテンプレートはEclipseの標準のテキストエディタで開くと見づらいため、Atomというテキストエディタにtwig用のプラグインを追加して編集しています。Eclipseにもマーケットプレイスにtwig用のプラグインはありましたが、インストールしてみてもうまくtwigファイルが開けませんでした。。(;;
Atomの日本語化とtwigプラグインの導入については以下を参考にしました。
参考:エディタ「Atom」のインストールと初心者向け初期設定
参考:ATOMでtwigファイルを見やすくする方法 – Qiita

カスタマイズ1 – テーブルクラスのカスタマイズ

最初に簡単なところとしてテーブルクラスのカスタマイズを行います。

  1. cake_app/vendor/cakephp/bake/src/Template/Bake/Model/table.twigをコピー
  2. cake_app/src/Template/Bake/Model/table.twigを編集
    1. AppTableという全テーブルクラスの親となるクラスを継承する
    2. Timestampビヘイビアの設定はAppTableで行っているため生成されるクラス内でaddBehaviorしない
    3. 削除フラグのバリデーション処理を削除

出来上がったtwigテンプレートが以下。{{}}で囲まれた箇所がtwigテンプレートによって展開される変数です。ソースを読んでるうちになんとなーく理解できてくる。事前にプッシュ済みのAppTableのTimestampビヘイビアの設定部分も参考までに記載します。

{#
/**
 * CakePHP(tm) : Rapid Development Framework (http://cakephp.org)
 * Copyright (c) Cake Software Foundation, Inc. (http://cakefoundation.org)
 *
 * Licensed under The MIT License
 * For full copyright and license information, please see the LICENSE.txt
 * Redistributions of files must retain the above copyright notice.
 *
 * @copyright     Copyright (c) Cake Software Foundation, Inc. (http://cakefoundation.org)
 * @link          http://cakephp.org CakePHP(tm) Project
 * @since         2.0.0
 * @license       http://www.opensource.org/licenses/mit-license.php MIT License
 */
#}
{% set annotations = DocBlock.buildTableAnnotations(associations, associationInfo, behaviors, entity, namespace) %}
<?php
namespace {{ namespace }}\Model\Table;

{% set uses = ['use Cake\\ORM\\Query;', 'use Cake\\ORM\\RulesChecker;', 'use Cake\\ORM\\Table;', 'use Cake\\Validation\\Validator;'] %}
{{ uses|join('\n')|raw }}

{{ DocBlock.classDescription(name, 'Model', annotations)|raw }}
{# AppTableを継承する #}
class {{ name }}Table extends AppTable
{

    /**
     * Initialize method
     *
     * @param array $config The configuration for the Table.
     * @return void
     */
    public function initialize(array $config)
    {
        parent::initialize($config);

{% if table %}
        $this->setTable('{{ table }}');
{% endif %}

{%- if displayField %}
        $this->setDisplayField('{{ displayField }}');
{% endif %}

{%- if primaryKey %}
    {%- if primaryKey is iterable and primaryKey|length > 1 %}
        $this->setPrimaryKey([{{ Bake.stringifyList(primaryKey, {'indent': false})|raw }}]);
        {{- "\n" }}
    {%- else %}
        $this->setPrimaryKey('{{ primaryKey|as_array|first }}');
        {{- "\n" }}
    {%- endif %}
{% endif %}

{%- if behaviors %}

{% endif %}

{%- for behavior, behaviorData in behaviors %}
{# Timestampビヘイビアは継承元のAppTableで定義してるのでここでは出力しない #}
{% if behavior != 'Timestamp' %}
        $this->addBehavior('{{ behavior }}'{{ (behaviorData ? (", [" ~ Bake.stringifyList(behaviorData, {'indent': 3, 'quotes': false})|raw ~ ']') : '')|raw }});
{% endif %}
{% endfor %}

{%- if associations.belongsTo or associations.hasMany or associations.belongsToMany %}

{% endif %}

{%- for type, assocs in associations %}
    {%- for assoc in assocs %}
        {%- set assocData = [] %}
        {%- for key, val in assoc if key is not same as('alias') %}
            {%- set assocData = assocData|merge({(key): val}) %}
        {%- endfor %}
        $this->{{ type }}('{{ assoc.alias }}', [{{ Bake.stringifyList(assocData, {'indent': 3})|raw }}]);
        {{- "\n" }}
    {%- endfor %}
{% endfor %}
    }
{{- "\n" }}

{%- if validation %}

    /**
     * Default validation rules.
     *
     * @param \Cake\Validation\Validator $validator Validator instance.
     * @return \Cake\Validation\Validator
     */
    public function validationDefault(Validator $validator)
    {
{% for field, rules in validation %}
{# delete_flagのバリデーションは不要 #}
{% if field != 'delete_flag' %}
{% set validationMethods = Bake.getValidationMethods(field, rules) %}
{% if validationMethods %}
        $validator
{% for validationMethod in validationMethods %}
{% if loop.last %}
{% set validationMethod = validationMethod ~ ';' %}
{% endif %}
            {{ validationMethod|raw }}
{% endfor %}

{% endif %}
{% endif %}
{% endfor %}
        return $validator;
    }
{% endif %}

{%- if rulesChecker %}

    /**
     * Returns a rules checker object that will be used for validating
     * application integrity.
     *
     * @param \Cake\ORM\RulesChecker $rules The rules object to be modified.
     * @return \Cake\ORM\RulesChecker
     */
    public function buildRules(RulesChecker $rules)
    {
{% for field, rule in rulesChecker %}
        $rules->add($rules->{{ rule.name }}(['{{ field }}']{{ (rule.extra is defined and rule.extra ? (", '#{rule.extra}'") : '')|raw }}));
{% endfor %}

        return $rules;
    }
{% endif %}

{%- if connection is not same as('default') %}

    /**
     * Returns the database connection name to use by default.
     *
     * @return string
     */
    public static function defaultConnectionName()
    {
        return '{{ connection }}';
    }
{% endif %}
}
<?php
namespace App\Model\Table;

use Cake\ORM\Query;
use Cake\ORM\Table;
use App\Model\Table\DeleteType;
class AppTable extends Table {

  public function initialize(array $config) {
    parent::initialize($config);

    /** 作成日時、更新日時の自動付与 */
    $this->addBehavior('Timestamp', [
        'events' => [
            'Model.beforeSave' => [
                'created' => 'new',
                'modified' => 'always'
            ]
        ]
    ]);
  }
}

カスタマイズ2 – 一覧画面のカスタマイズ

  1. cake_app/vendor/cakephp/bake/src/Template/Bake/Template/index.twigをコピー
  2. src/Template/Bake/Template/index.twigを編集
    1. デザインをbootstrap4風にする
    2. 多言語化の__()は削除して日本語をベタ書き
    3. 削除フラグは画面表示不要のため出力対象外とする
    4. ページャーはElement以下のファイルをインクルード

できあがったtwigテンプレートが以下。

{#
/**
 * CakePHP(tm) : Rapid Development Framework (http://cakephp.org)
 * Copyright (c) Cake Software Foundation, Inc. (http://cakefoundation.org)
 *
 * Licensed under The MIT License
 * For full copyright and license information, please see the LICENSE.txt
 * Redistributions of files must retain the above copyright notice.
 *
 * @copyright     Copyright (c) Cake Software Foundation, Inc. (http://cakefoundation.org)
 * @link          http://cakephp.org CakePHP(tm) Project
 * @since         2.0.0
 * @license       http://www.opensource.org/licenses/mit-license.php MIT License
 */
#}
<?php
/**
 * @var \{{ namespace }}\View\AppView $this
 * @var \{{ entityClass }}[]|\Cake\Collection\CollectionInterface ${{ pluralVar }}
 */
?>
{% set fields = Bake.filterFields(fields, schema, modelObject, indexColumns, ['binary', 'text']) %}
<div class="col-md-12 mb-12">
 <div class="card">
  <div class="card-header">
   <button type="button" class="btn btn-flat btn-outline-secondary" onclick="location.href='/{{ pluralVar }}/add/'">新規登録</button>
  </div>
  <div class="card-body">
   <table class="table table-bordered">
    <thead>
            <tr>
{% for field in fields %}
{# delete_flagは出力対象外 #}
{% if field not in ['delete_flag'] %}
                <th scope="col"><?= $this->Paginator->sort('{{ field }}') ?></th>
{% endif %}
{% endfor %}
                <th scope="col" class="actions"><?= __('Actions') ?></th>
            </tr>
    </thead>
    <tbody>
            <?php foreach (${{ pluralVar }} as ${{ singularVar }}) { ?>
            <tr>
{% for field in fields %}
{# delete_flagは出力対象外 #}
{% if field not in ['delete_flag'] %}
{% set isKey = false %}
{% if associations.BelongsTo %}
{% for alias, details in associations.BelongsTo if field == details.foreignKey %}
{% set isKey = true %}
                <td><?= ${{ singularVar }}->has('{{ details.property }}') ? $this->Html->link(${{ singularVar }}->{{ details.property }}->{{ details.displayField }}, ['controller' => '{{ details.controller }}', 'action' => 'view', ${{ singularVar }}->{{ details.property }}->{{ details.primaryKey[0] }}]) : '' ?></td>
{% endfor %}
{% endif %}
{% if isKey is not same as(true) %}
{% set columnData = Bake.columnData(field, schema) %}
{% if columnData.type not in ['integer', 'float', 'decimal', 'biginteger', 'smallinteger', 'tinyinteger'] %}
                <td><?= h(${{ singularVar }}->{{ field }}) ?></td>
{% else %}
                <td><?= $this->Number->format(${{ singularVar }}->{{ field }}) ?></td>
{% endif %}
{% endif %}
{% endif %}
{% endfor %}
{% set pk = '$' ~ singularVar ~ '->' ~ primaryKey[0] %}
                <td class="actions">
                    <?= $this->Html->link('詳細', ['action' => 'view', {{ pk|raw }}], ['class' => 'btn btn-flat btn-outline-secondary']) ?>
                    <?= $this->Html->link('編集', ['action' => 'edit', {{ pk|raw }}], ['class' => 'btn btn-flat btn-outline-secondary']) ?>
                    <?= $this->Form->postLink('削除', ['action' => 'delete', {{ pk|raw }}], ['class' => 'btn btn-flat btn-outline-secondary', 'confirm' => __('ID {0} を削除します。よろしいですか?', {{ pk|raw }})]) ?>
                </td>
            </tr>
          <?php } ?>
    </tbody>
   </table>
  </div>
 </div>
 <?= $this->element('pager') ?>
</div>
<nav aria-label="pager">
  <p class="text-center"><?= $this->Paginator->counter('全{{count}}件中 {{start}}件目~{{end}}件目を表示') ?></p>
  <?php if ($this->Paginator->hasPage(null, 2)) {?>
    <ul class="pagination pg-blue justify-content-center">
      <?= $this->Paginator->first('<< ')?>
      <?php if (!empty($this->Paginator->hasPrev())) {?><?= $this->Paginator->prev('< ') ?><?php } ?>
      <?= $this->Paginator->numbers()?>
      <?php if (!empty($this->Paginator->hasNext())) { ?><?= $this->Paginator->next(' >') ?><?php } ?>
      <?= $this->Paginator->last(' >>')?>
    </ul>
  <?php } ?>
</nav>

カスタマイズ3 – コントローラークラスのカスタマイズ

コントローラークラスのテンプレートもvendor配下に存在しますがビューとは異なり関数ごとにテンプレートが分けられていました。controller.twigテンプレート内の{%- for action in actions %}の辺りで各アクションのtwigを読み込んでいる模様。
今回はadd()とedit()の処理を共通化する対応を行ってみました。

  1. cake_app/vendor/cakephp/bake/src/Template/Bake/Controllerをコピー
  2. cake_app/vendor/cakephp/bake/src/Template/Bake/Element/Controllerをコピー
  3. cake_app/src/Template/Bake/Element/Controller/edit.twigを編集
    1. add()とedit()の処理を統合したような_form()を作成
    2. edit()自体は_form()を呼び出すよう修正
  4. cake_app/src/Template/Bake/Element/Controller/add.twigを編集
    1. edit.twig側で生成している_form()を呼び出すよう修正
{#
/**
 * CakePHP(tm) : Rapid Development Framework (http://cakephp.org)
 * Copyright (c) Cake Software Foundation, Inc. (http://cakefoundation.org)
 *
 * Licensed under The MIT License
 * For full copyright and license information, please see the LICENSE.txt
 * Redistributions of files must retain the above copyright notice.
 *
 * @copyright     Copyright (c) Cake Software Foundation, Inc. (http://cakefoundation.org)
 * @link          http://cakephp.org CakePHP(tm) Project
 * @since         2.0.0
 * @license       http://www.opensource.org/licenses/mit-license.php MIT License
 */
#}
{% set belongsTo = Bake.aliasExtractor(modelObj, 'BelongsTo') %}
{% set belongsToMany = Bake.aliasExtractor(modelObj, 'belongsToMany') %}
{% set compact = ["'#{singularName}'"] %}

    /**
     * Edit method
     *
     * @param string|null $id {{ singularHumanName }} id.
     * @return \Cake\Http\Response|null Redirects on successful edit, renders view otherwise.
     * @throws \Cake\Datasource\Exception\RecordNotFoundException When record not found.
     */
    public function edit($id = null)
    {
        $this->_form($id);
    }

{# addとeditの両方で呼び出すprivateな関数を作成。addから$this->edit()するだけでもいいように思えるが、フラッシュメッセージの辺りがうまく動かなかったのでこのような形となった #}
    /**
     * Add and Edit Common method
     *
     * @param string|null $id {{ singularHumanName }} id.
     * @return \Cake\Http\Response|null Redirects on successful edit, renders view otherwise.
     * @throws \Cake\Datasource\Exception\RecordNotFoundException When record not found.
     */
    private function _form($id = null)
    {
{# addアクションとeditアクションの統合による修正 #}
        if ($this->request->action == 'edit') {
            ${{ singularName }} = $this->{{ currentModelName }}->get($id, [
                'contain' => [{{ Bake.stringifyList(belongsToMany, {'indent': false})|raw }}]
            ]);
        } else {
            ${{ singularName }} = $this->{{ currentModelName }}->newEntity();
        }
        if ($this->request->is(['patch', 'post', 'put'])) {
            ${{ singularName }} = $this->{{ currentModelName }}->patchEntity(${{ singularName }}, $this->request->getData());
            if ($this->{{ currentModelName }}->save(${{ singularName }})) {
                $this->Flash->success(__('The {{ singularHumanName|lower }} has been saved.'));

                return $this->redirect(['action' => 'index']);
            }
            $this->Flash->error('エラーが発生しました。');
        }
{% for assoc in belongsTo|merge(belongsToMany) %}
    {%- set otherName = Bake.getAssociatedTableAlias(modelObj, assoc) %}
    {%- set otherPlural = otherName|variable %}
        ${{ otherPlural }} = $this->{{ currentModelName }}->{{ otherName }}->find('list', ['limit' => 200]);
        {{- "\n" }}
    {%- set compact = compact|merge(["'#{otherPlural}'"]) %}
{% endfor %}
        $this->set(compact({{ compact|join(', ')|raw }}));
        $this->render('edit');
    }
{#
/**
 * CakePHP(tm) : Rapid Development Framework (http://cakephp.org)
 * Copyright (c) Cake Software Foundation, Inc. (http://cakefoundation.org)
 *
 * Licensed under The MIT License
 * For full copyright and license information, please see the LICENSE.txt
 * Redistributions of files must retain the above copyright notice.
 *
 * @copyright     Copyright (c) Cake Software Foundation, Inc. (http://cakefoundation.org)
 * @link          http://cakephp.org CakePHP(tm) Project
 * @since         2.0.0
 * @license       http://www.opensource.org/licenses/mit-license.php MIT License
 */
#}
{% set compact = ["'#{singularName}'"] %}

    /**
     * Add method
     *
     * @return \Cake\Http\Response|null Redirects on successful add, renders view otherwise.
     */
    public function add()
    {
{# edit.twig側で作成している関数を呼び出す #}
        $this->_form();
    }

テンプレートカスタマイズ後にbakeしなおす

上記のカスタマイズ以外にも以下の辺りを編集していますがその辺は省略

  • add.ctpを削除、edit.ctp、view.ctpを変更
  • 削除処理を論理削除に変更

いろいろテンプレートをカスタマイズした後でsamplesテーブルについてbakeし直します。–forceオプションを付けると確認なしで上書きされます。

cake_app\bin\cake bake all samples --force

事前にカスタマイズ前の状態で生成されたソースをGitステージングでステージされた変更側に放り込んでおくと差分が確認しやすくて便利。

以下のような感じで差分が確認できます

生成されたソースのうち差分が発生しているソースは以下のような感じ。

<?php
namespace App\Controller;

use App\Controller\AppController;
use App\Model\Table\DeleteType;

/**
 * Samples Controller
 *
 * @property \App\Model\Table\SamplesTable $Samples
 *
 * @method \App\Model\Entity\Sample[]|\Cake\Datasource\ResultSetInterface paginate($object = null, array $settings = [])
 */
class SamplesController extends AppController
{

    /**
     * Index method
     *
     * @return \Cake\Http\Response|void
     */
    public function index()
    {
        $samples = $this->paginate($this->Samples);

        $this->set(compact('samples'));
    }

    /**
     * View method
     *
     * @param string|null $id Sample id.
     * @return \Cake\Http\Response|void
     * @throws \Cake\Datasource\Exception\RecordNotFoundException When record not found.
     */
    public function view($id = null)
    {
        $sample = $this->Samples->get($id, [
            'contain' => []
        ]);

        $this->set('sample', $sample);
    }

    /**
     * Add method
     *
     * @return \Cake\Http\Response|null Redirects on successful add, renders view otherwise.
     */
    public function add()
    {
        $this->_form();
    }

    /**
     * Edit method
     *
     * @param string|null $id Sample id.
     * @return \Cake\Http\Response|null Redirects on successful edit, renders view otherwise.
     * @throws \Cake\Datasource\Exception\RecordNotFoundException When record not found.
     */
    public function edit($id = null)
    {
        $this->_form($id);
    }

    /**
     * Add and Edit Common method
     *
     * @param string|null $id Sample id.
     * @return \Cake\Http\Response|null Redirects on successful edit, renders view otherwise.
     * @throws \Cake\Datasource\Exception\RecordNotFoundException When record not found.
     */
    private function _form($id = null)
    {
        if ($this->request->action == 'edit') {
            $sample = $this->Samples->get($id, [
                'contain' => []
            ]);
        } else {
            $sample = $this->Samples->newEntity();
        }
        if ($this->request->is(['patch', 'post', 'put'])) {
            $sample = $this->Samples->patchEntity($sample, $this->request->getData());
            if ($this->Samples->save($sample)) {
                $this->Flash->success(__('The sample has been saved.'));

                return $this->redirect(['action' => 'index']);
            }
            $this->Flash->error('エラーが発生しました。');
        }
        $this->set(compact('sample'));
        $this->render('edit');
    }
    /**
     * Delete method
     *
     * @param string|null $id Sample id.
     * @return \Cake\Http\Response|null Redirects to index.
     * @throws \Cake\Datasource\Exception\RecordNotFoundException When record not found.
     */
    public function delete($id = null)
    {
        $this->request->allowMethod(['post', 'delete']);
        if ($this->Samples->deleteRecord($id, DeleteType::LOGICAL)) {
            $this->Flash->success(__('The sample has been deleted.'));
        } else {
            $this->Flash->error('エラーが発生しました。');
        }

        return $this->redirect(['action' => 'index']);
    }
}
<?php
namespace App\Model\Table;

use Cake\ORM\Query;
use Cake\ORM\RulesChecker;
use Cake\ORM\Table;
use Cake\Validation\Validator;

/**
 * Samples Model
 *
 * @method \App\Model\Entity\Sample get($primaryKey, $options = [])
 * @method \App\Model\Entity\Sample newEntity($data = null, array $options = [])
 * @method \App\Model\Entity\Sample[] newEntities(array $data, array $options = [])
 * @method \App\Model\Entity\Sample|bool save(\Cake\Datasource\EntityInterface $entity, $options = [])
 * @method \App\Model\Entity\Sample|bool saveOrFail(\Cake\Datasource\EntityInterface $entity, $options = [])
 * @method \App\Model\Entity\Sample patchEntity(\Cake\Datasource\EntityInterface $entity, array $data, array $options = [])
 * @method \App\Model\Entity\Sample[] patchEntities($entities, array $data, array $options = [])
 * @method \App\Model\Entity\Sample findOrCreate($search, callable $callback = null, $options = [])
 *
 * @mixin \Cake\ORM\Behavior\TimestampBehavior
 */
class SamplesTable extends AppTable
{

    /**
     * Initialize method
     *
     * @param array $config The configuration for the Table.
     * @return void
     */
    public function initialize(array $config)
    {
        parent::initialize($config);

        $this->setTable('samples');
        $this->setDisplayField('id');
        $this->setPrimaryKey('id');

    }

    /**
     * Default validation rules.
     *
     * @param \Cake\Validation\Validator $validator Validator instance.
     * @return \Cake\Validation\Validator
     */
    public function validationDefault(Validator $validator)
    {
        $validator
            ->integer('id')
            ->allowEmpty('id', 'create');

        $validator
            ->scalar('value1')
            ->maxLength('value1', 20)
            ->requirePresence('value1', 'create')
            ->notEmpty('value1');

        $validator
            ->scalar('value2')
            ->maxLength('value2', 255)
            ->requirePresence('value2', 'create')
            ->notEmpty('value2');

        return $validator;
    }
}
<?php
/**
 * @var \App\View\AppView $this
 * @var \App\Model\Entity\Sample[]|\Cake\Collection\CollectionInterface $samples
 */
?>
<div class="col-md-12 mb-12">
 <div class="card">
  <div class="card-header">
   <button type="button" class="btn btn-flat btn-outline-secondary" onclick="location.href='/samples/add/'">新規登録</button>
  </div>
  <div class="card-body">
   <table class="table table-bordered">
    <thead>
            <tr>
                <th scope="col"><?= $this->Paginator->sort('id') ?></th>
                <th scope="col"><?= $this->Paginator->sort('value1') ?></th>
                <th scope="col"><?= $this->Paginator->sort('value2') ?></th>
                <th scope="col"><?= $this->Paginator->sort('created') ?></th>
                <th scope="col"><?= $this->Paginator->sort('modified') ?></th>
                <th scope="col" class="actions"><?= __('Actions') ?></th>
            </tr>
    </thead>
    <tbody>
            <?php foreach ($samples as $sample) { ?>
            <tr>
                <td><?= $this->Number->format($sample->id) ?></td>
                <td><?= h($sample->value1) ?></td>
                <td><?= h($sample->value2) ?></td>
                <td><?= h($sample->created) ?></td>
                <td><?= h($sample->modified) ?></td>
                <td class="actions">
                    <?= $this->Html->link('詳細', ['action' => 'view', $sample->id], ['class' => 'btn btn-flat btn-outline-secondary']) ?>
                    <?= $this->Html->link('編集', ['action' => 'edit', $sample->id], ['class' => 'btn btn-flat btn-outline-secondary']) ?>
                    <?= $this->Form->postLink('削除', ['action' => 'delete', $sample->id], ['class' => 'btn btn-flat btn-outline-secondary', 'confirm' => __('ID {0} を削除します。よろしいですか?', $sample->id)]) ?>
                </td>
            </tr>
          <?php } ?>
    </tbody>
   </table>
  </div>
 </div>
 <?= $this->element('pager') ?>
</div>
<?php
/**
 * @var \App\View\AppView $this
 * @var \App\Model\Entity\Sample $sample
 */
?>
<div class="col-md-12 mb-12">
  <div class="card">
    <div class="card-body">
      <table class="vertical-table">
        <tr>
            <th scope="row"><?= __('Value1') ?></th>
            <td><?= h($sample->value1) ?></td>
        </tr>
        <tr>
            <th scope="row"><?= __('Value2') ?></th>
            <td><?= h($sample->value2) ?></td>
        </tr>
        <tr>
            <th scope="row"><?= __('Id') ?></th>
            <td><?= $this->Number->format($sample->id) ?></td>
        </tr>
        <tr>
            <th scope="row"><?= __('Created') ?></th>
            <td><?= h($sample->created->i18nFormat('yyyy/MM/dd HH:mm:ss')) ?></td>
        </tr>
        <tr>
            <th scope="row"><?= __('Modified') ?></th>
            <td><?= h($sample->modified->i18nFormat('yyyy/MM/dd HH:mm:ss')) ?></td>
        </tr>
      </table>
    </div>
  </div>
</div>
<?php
/**
 * @var \App\View\AppView $this
 * @var \App\Model\Entity\Sample $sample
 */
 $button_name = (!empty($sample) && !$sample->isNew()) ? "更新" : "登録";
 $this->assign('title', "sample{$button_name}");
?>
 <div class="col-md-12 mb-12">
  <div class="card">
   <div class="card-body">
    <?= $this->Form->create($sample) ?>
    <div class="row">
            <div class="col-md-6 col-sm-12">
                <div class="form-group">
                    <?= $this->Form->control('value1', ['class' => 'form-control']); ?>
                </div>
            </div>
            <div class="col-md-6 col-sm-12">
                <div class="form-group">
                    <?= $this->Form->control('value2', ['class' => 'form-control']); ?>
                </div>
            </div>
    <div class="col-md-12">
        <?= $this->Form->button($button_name, ['class' => "btn btn-flat btn-outline-secondary"]) ?>
    </div>
    </div>
    <?= $this->Form->end() ?>
   </div>
  </div>
 </div>

カスタマイズ後の動作確認

一覧画面

編集画面

その他

今回の内容についてGitへのプッシュは分かっていないところがあるのでひとまず行わないことにしました。
bakeのカスタマイズは大変ですがここで頑張ればあとあとの作業工数を大きく減らすことができるのでこれからも少しずつ進めていきたい。

今回は単純な1つのテーブルを元にした自動生成を行いましたが例えばsamplesとsample_childsのような親子関係のテーブルで1:nのアソシエーション設定が存在するときに、sample_childs側もbakeするとsamplesのview.ctpの画面から遷移できたりするみたいです。先にsample_childs側のbakeが必要かも?
この辺うまい具合に自動生成しようと思うと相当大変な感じがするのと、アソシエーション先を1件ずつ登録するような機能を使うかというと微妙なので、そこまではサポートしない方が良い気がしてきた。

その他やりたいこととかメモなど列挙

  1. bakeの各タスク自体の拡張について調べる
    1. ざっと検索した感じうまく見つけられなかった(;;
    2. cake_app/vendor/cakephp/bake/src/Shell/BakeShell.phpとTaskディレクトリをsrc/Shell以下にコピーして名前空間いじったら動いてるけどあってるかわからない
  2. 項目名にテーブルのCOMMENT句から取得した論理名を設定する
    1. コメントはinformation_schemaから取得する必要がありそう
    2. ConnectionManagerからいろいろやる必要がありそう
    3. 上記1を解決しないと難しそう
  3. date型のときにdatepickerを表示したりする
  4. プルダウンの表示がよくわからないので調べる
  5. bootstrap4の導入にはfriendsofcake/bootstrap-uiという著名なプラグインがあるのでそちらに乗り換えた方が良いのかも
  6. アソシエーション先のテンプレートの生成は冗長な感じがするので削除する

bakeによって生成されたソースを丸ごとコピー&ペーストしたら文字数がすごいことになってしまった。。

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