Skip to content

Instantly share code, notes, and snippets.

@yano3nora
Last active October 29, 2019 06:24
Show Gist options
  • Save yano3nora/c04aed4e8026adb571e2abcbaf799bb9 to your computer and use it in GitHub Desktop.
Save yano3nora/c04aed4e8026adb571e2abcbaf799bb9 to your computer and use it in GitHub Desktop.
[cakephp: Model] Model ( Table / Entity ) class on CakePHP3. #php #cakephp

Validation / BuildRules

CakePHP3 には2つのバリデーションクラスがあり、管理したい項目によってこれらを使い分ける。大体のケースにおいて フロントやコントローラ内で項目1つ1つについて頑張る必要がなくなる ので是非利用していきたい。

  • Cake\ORM\RulesChecker クラス
  • Cake\Validation\Validator クラス
    • カスタムバリデーション
    • 条件付きバリデーション
    • Form から POST で渡る $this->request->getData() から newEntity() / patchEntity() 時に動作
    • $entity->errors() で「なにがなぜ弾かれているのか」分かり $this->save() が false を返す
    • 上記エラー持ちエンティティをフォームに突っ込む $this->Form->create($entity)$this->Form->control() がメッセージ表示
    • フォームによる操作をチェックするため フォーム提供しユーザに入力させる項目のバリデーションで利用する
// in Table

  // データを保存する前の検査 (DBつながない)
  public function validationDefault(Validator $validator)
  {
    $validator
      ->allowEmpty('id', 'create');
    $validator
      ->requirePresence('name', 'create')
      ->notEmpty('name');
    
    // 全角カナ制限したいケース
    $validator
      ->requirePresence('name_phonetic', 'create')
      ->notEmpty('name_phonetic', __('Empty'))
      ->add('name_phonetic', 'zenkaku_kana', [
        'rule'    => function ($value, $context) {
          return (bool)preg_match('/^[ァ-ヶ・ー ]+$/u', $value);
        },
        'message' => '全角カナで入力してください。',
      ]);
    
    // メールアドレスの確認欄とのマッチングをさせるケース
    $validator
      ->email('email_confirm')
      ->notEmpty('email_confirm')
      ->add('email_confirm', 'confirmEmail', [
        'rule'    => function ($value, $context) {
          return ($value == $context['data']['email']);
        },
        'message' => __('メールアドレスが確認用メールアドレス欄と一致しません'),
      ]);
      
    // 固定・携帯電話のどちらかを入力させたいケース
    // - View 側で $this->Form->input('telephone, ['required'=>false]); する
    //   - サーバでジャッジする前にフロントで弾かれフォームサブミット自体できなくなる
    // - Controller 側で if ($entity->errors()) $this->redirect($this->referer()); で戻してやる
    // - ちなみに $this->Form->text(); などでフォーム出力するとエラーメッセージ表示 div が自動挿入されない
    //   - 上記理由で手動挿入せんとあかん場合は $this->Form->error() を添えてあげる
    $validator  
      ->notEmpty('telephone', '固定・携帯電話番号どちらかを入力してください。')
      ->allowEmpty('telephone', function ($context) {
        return !empty($context['data']['cellular_phone']);
      });

    return $validator;
  }

  // 保存される/されているデータの整合性を保つ検査 (DBつなぐ)
  public function buildRules(RulesChecker $rules)
  {
    // email のシステム全体での重複を許さない
    $rules->add($rules->isUnique(['email']), [
      'errorField' => 'email',
      'message' => MSG_VALIDATE_DUPLICATE,
    ]);
    // validateEmptyPhone
    $validateEmptyPhone = function ($owner) {
      return !(empty($owner->telephone) && empty($owner->cellular_phone));
    };
    $rules->add($validateEmptyPhone, [
      'errorField' => 'cellular_phone',
      'message'    => '携帯電話/固定電話番号のどちらかは必須入力です。',
    ]);
    return $rules;
  }

Entity

Entity クラス - cookbook

Setter / Getter

// App\Model\Entity\User

/**
 * Set Password via DefaultPasswordHasher
 * Set hashed password through patchEntity() in CREATE & UPDATE .
 * @param string $password
 * @return string (hashed password)
 */
protected function _setPassword($password) {
  return (new DefaultPasswordHasher)->hash($password);
}

/**
 * Get formatted code.
 * @param  void
 * @return string
 */
protected function getCode() {
  return sprintf('%05d', $this->code);
}

Virtual property

// App\Model\Entity\User

protected $_virtual = ['full_name'];

/**
 * Get User's full name.
 * @return string $full_name
 */
protected function _getFullName() {
  $full_name = $this->last_name.' '.$this->first_name;
  return $full_name;
}

Entity method

// App\Model\Entity\User

/**
 * Check Password by DefaultPasswordHasher
 * Older password verification on UPDATE.
 * @param string $plainString 
 * @return bool true | false
 */
public function checkPassword(string $plainString='') {
  if (empty($plainString)) return false;
  return (new DefaultPasswordHasher)->check($plainString, $this->password);
}

isDirty() / isNew()

if (!$article->isNew()) {
    echo '既に保存されました!';
}

Table

Table クラス - cookbook

find()

基本

// すべての article を検索、この時点ではクエリーは走らない。
$query = $articles->find();

// イテレーションはクエリーを実行する
foreach ($query as $row) {
}

// all() の呼び出しはクエリーを実行し、結果セットを返す
$results = $query->all();

// isEmpty() で中身があるかどうか
if ($query->isEmpty()) echo '空です';

// count() とかで件数がとれる
echo 'データは'.$query->count().'件です';

// 結果セットがあれば すべての行を取得できる
$data = $results->toArray();

// クエリーから配列への変換はクエリーを実行する
$data = $query->toArray();

発展

$query   = $this->Users->find(); // クエリオブジェクトを準備
$userNum = $query->count();   // ここでは準備していたクエリに対する COUNT が返る
$user    = $query->first();   // 吐かれるクエリは LIMIT 1 となる

// キー/値のペアを検索する
$query = $this->Articles->find('list');
$data  = $query->toArray();
// $data = [
//    1 => '最初の投稿',
//    2 => '私が書いた2つ目の記事',
// ];

// toArray() と array_column を使って結果セットから ID 配列を生成
$managerUserIds = array_column($this->Users->find(['Users.role' => 'manager'])->toArray(), 'id');

NOT / IS / OR / IN

NOT
// in controller
$terminals = $this->Terminals->find()
  ->where([
    'event_id' => $event_id,
    'NOT' => ['user_id IS' => null],  // null は IS 必須
  ])
  ->contain('Users')
  ->all();
$users = [];
OR
// http://www.chitana.me/entry/2016/12/03/164348

$conditions = ['Trees.is_initial'  => false];

foreach ($trees as $tree) {
  $conditions['OR'][] = [
    'Trees.path LIKE' => $tree->path.'_.',
  ];
}

$results = $this->find()->where($conditions);
Where IN / Not IN
// IN
$users = TableRegistry::get('User')->find()
    ->where(['User.id IN' => [1, 2, 3]])
    ->all();

// NOT IN
$users = TableRegistry::get('User')->find()
    ->where([
        'NOT' => ['User.id IN' => [1, 2, 3]]
    ])
    ->all();

Containで関連データ結合

// find() のオプションとして
$query = $articles->find('all', ['contain' => ['Authors', 'Comments']]);

// クエリーオブジェクトのメソッドとして
$query = $articles->find('all');
$query->contain(['Authors', 'Comments']);

// フィルタリングのクロージャ
$query->contain(['Schedules' => function($q) use ($id) {
    return $q->where(['Schedules.date >=' => new time('today')]);
}]);

// contain() 後なら order() なりで関連モデルを指定できる (未検証)
$query = $articles->find();
  ->contain(['Authors', 'Comments'])
  ->order(['Authors.rate' => 'desc']);   // Article を Author の rate (人気度) 降順で

matching で関連データフィルタリング

マニュアル にある通り、 Articles belongsToMany Tags のとき「タグ Tag を持つ記事 Article を探索」するような 特定の関連データを持つデータのフィルタリング をしたいときは matching() メソッドを利用する。

// コントローラーやテーブルのメソッド内で
$tagName = 'CakePHP';
$query = $articles->find();
$query->matching('Tags', function ($q) use ($tagName) {
    return $q->where(['Tags.name' => $tagName]);
});

// notMatching() ってのもあるよ
$tagName = '退屈';
$query = $articlesTable
    ->find()
    ->notMatching('Tags', function ($q) use ($tagName) {
        return $q->where(['Tags.name' => $tagName]);
    });

// matching の 連結
$tagNames = ['CakePHP', 'PHP'];
$categoryNames = ['IT', 'コンピュータ'];

$query = TableRegistry::get('Posts')->find();
$query->matching('Tags', function (Query $q) use ($tagNames) {
  return $q->where(['Tags.name IN' => $tagNames]);
})->matching('Categories', function (Query $q) use ($categoryNames) {
  return $q->where(['Categories.name IN' => $categoryNames]);
});

// 深い関連の matching 
$query = $products->find()->matching(
    'Shops.Cities.Countries', function ($q) {
        return $q->where(['Countries.name' => 'Japan']);
    }
);

// 深い関連の matching の closure に 変数を渡す
$username = 'markstory';
$query = $articles->find()->matching('Comments.Users', function ($q) use ($username) {
    return $q->where(['username' => $username]);
});

matching() を同じモデルで何回もやりたい

Distinct

// in Table (Users hasMany Terminals)
  public function getUsersByEventId(int $event_id=null) {
    if (empty($event_id)) return [];
    $users = [];
    $this->Terminals = TableRegistry::get('Terminals');
    $terminals = $this->Terminals->find()
      ->distinct(['user_id'])
      ->where([
        'event_id' => $event_id,
        'NOT' => ['user_id' => false],
      ])
      ->contain('Users')
      ->all();
    foreach ($terminals ?? $terminals ?? [] as $terminal) {
      $users[] = $terminal->user;
    }
    return $users;
  }

from で FROM 句に仮想テーブルを追加する

CakePHP3.x ORMでFrom句に仮想テーブルを追加する方法

// サブクエリー
$sub_query = $this->Hoges->find();

// ネストしたクエリー
$main_query = $this->Moges->find()->from(['SubTable' => $sub_query]);

// 発行 SQL
SELECT * FROM(SELECT * FROM hoge Hoges) SubTable

// 取り出し
$main_query->toArray();

動的な Finder 生成

// 存在するカラム (プロパティ) なら動的に生成される「カスタム Finder 」で条件を渡せる
$this->Users->findAllByUsername('joebob');
$this->Users->findAllByUsernameAndAge('john', 24);  // 複数もいける
カスタム Finder で find() ロジックを共通化&隠蔽する

Finder は find() 第一引数へキーを渡すことで、以降の検索条件をモデルへ登録できる機能。ビルトインで all / list / threaded が備わっており、これに独自の検索条件 ( = データアクセスレイヤのドメインロジック ) をモデルで吸収し、コントローラの各所に find() 条件が分散することを防ぐことができる。

因みにビルトイン Finder である list などは呼び出し時に拡張したりすることもできるので、フォームの <select> とかもできる限りこれで生成した方がよい。キー/値のペアを検索する

list Finder のカスタマイズ
// key / value をカスタム
$query = $articles->find('list', [
    'keyField' => 'slug',
    'valueField' => 'title'
]);
$data = $query->toArray();
// $data = [
//   'first-post' => '最初の投稿',
//   'second-article-i-wrote' => '私が書いた2つ目の記事',
// ];

// 関連エンティティのデータを valueField へ
$query = $articles->find('list', [
    'keyField' => 'id',
    'valueField' => 'author.name'
])->contain(['Authors']);

// valueField にクロージャ仕込む
$query = $articles->find('list', [
    'keyField' => 'id',
    'valueField' => function ($article) {
        return $article->author->calc('favorite_rate');
    }
]);

// groupField で ネスト
$query = $articles->find('list', [
    'keyField' => 'slug',
    'valueField' => 'title',
    'groupField' => 'author_id'
]);
$data = $query->toArray();
// $data = [
//   1 => [
//     'first-post' => '最初の投稿',
//     'second-article-i-wrote' => '私が書いた2つ目の記事',
//   ],
//   2 => ...
// ];
カスタム Finder のサンプル

finder メソッドは共通で使うクエリーをパッケージ化する理想的な方法です。 クエリーを抽象化できるようにすることで、メソッドは使いやすくなります。 fineder メソッドは、あなたが作成したい finder の名前が Foo の場合、 findFoo というように規約に則ったメソッドを作成することで定義されます。

  • $options 内のキーは containconditions などが予約済みなので注意!!
    • conditions : 中に突っ込んだハッシュが where() に足された状態で Query スタート
    • contain : 中に突っ込んだハッシュが contain() に足された状態で Query スタート
// App\Model\Queues

/**
 * Finder: Awaiting queues.
 * @param  Query $query
 * @param  array $options
 * @return Query
 */
public function findAwaiting(Query $query, array $options) {
  $contains   = [];
  $conditions = [];
  if (isset($options['user_id']))    $conditions += ['Queues.user_id' => $options['user_id']];
  if (isset($options['type']))       $conditions += ['Queues.type'    => $options['type']];
  if (!empty($options['contained'])) $contains    = ['Users'];
  return $query
    ->contain($contains)
    ->where($conditions)
    ->order(['Queues.created' => 'desc']);
}
/**
 * Custom Finder: index
 * 
 * 検索+ページネーション的な index 画面で
 * GET で受け取る検索条件から where 条件配列こねこねを Entity Method で
 * find() 以降のデータアクセスロジックをカスタム Finder「index」で持ってくる的なやつ
 *
 * @param  Query $Query
 * @param  array $options = [where, User]  
 * @return Query 
 */
public function findIndex(Query $Query, array $options) {
  if (!$where = $options['where'] ?? null) return false;
  if (!$User  = $options['User'] ?? null) return false;
  return $Query
    ->where($where)
    ->matching('UserReports', function ($q) use ($User) {
      return $q->where(['UserReports.user_id' => $User->id]);
    });
}

// In Controller
if (!$User = $this->Auth->user()) throw new \Exception('Forbidden.');
$_Report = $this->Reports->newEntity($this->request->query(), ['validate' => false]);
$Reports = $this->Reports->find('index', [
  'where' => $_Report->generateSearchConditions(),
  'User'  => $User,
]);
$Reports = $this->paginate($Reports, [
  'limit' => PAGINATE_LIMIT,
  'order' => ['Reports.issued_date' => 'desc'],
]);
$this->set(compact('User', '_Report', 'Reports'));

// find() の重ね掛け前提で組むともっと複雑な表現を抽象化できる
$this->Users->find('index')->find('recent');

beforeFind() でデフォルトソート順や論理削除フラグの設定

beforeFind - cookbook
cakephp3で基本のorderを指定する場合はbeforeFind()メソッドをTableクラスに追加する。

// Set default sort.
public function beforeFind(Event $event ,Query $query, ArrayObject $options, $primary) {
  if (!isset($query->order)) {
    $query->order(['Tags.sort' => 'asc']);
  }
  return $query;
}

// Set excluding is_delete.
public function beforeFind(Event $event ,Query $query, ArrayObject $options, $primary) {
  $query->where(['Tags.is_delete' => false]);
  return $query;
}

exists()

CakePHP2 系では id を渡して存在確認してたけど、配列で条件を渡す hasAny() と統一された。

$fancyTable = TableRegistry::get('FancyTable');
$exists = $fancyTable->exists(['name' => 'fancy', 'active' => false]);

newEntity() / patchEntity()

patchEntity とかしたあとに変更をなかったことにする

// In Controller.
$User = $this->Users->patchEntity($User, $this->request->getData());
if (empty($this->request->data('password'))) {
    $User->unsetProperty('password');
    $User->dirty('password', false);  // 変更されませんでしたよ
}
$this->Users->save($User);  // POST に password があっても変更されない

save() / saveOrFail()

delete() / deleteOrFail() / deleteAll()

delete() は通常の delete() と違い beforeDelete / afterDelete などのイベントコールバックが発生しないので注意。

// Controller.
public function deleteAnyConditions($conditions)
{ 
    $deletedNumber = $this->deleteAll($conditions);
    if ($deletedNumber === false) throw new \Exception('Failed to delete all entities.');
    return $deletedNumber;
}

数万件をまとめて delete したい

ぶっちゃけ deleteAll() と Tables 設定頑張るより負荷分散して delete() で一個ずつ殺すのが楽。

public function deleteAnyConditions($conditions, $per=1000)
{ 
    $deleted = 0;
    $target  = $this->find()->count();
    for ($i = 0; $i < $target; $i += $per) {
        $Targets = $this->find()
            ->where($conditions)
            ->offset($i)
            ->limit($per);
        foreach ($Targets as $Target) {
          $this->delete($Target);
          $deleted++;
        }
    }
    return $deleted;
}

Query

QueryBuilder - 複雑なクエリ QueryBuilder まとめ

// usersテーブルのidカラムの最大値は?
$query = $this->Users->find();
$ret = $query->select(['max_id' => $query->func()->max('id')])->first();
echo $ret->max_id;

select as で仮想カラムを作ってフィルタリング

namespace App\Controller;

class EventsController extends AdminsAppController
{

    public function index()
    {
        try {
            $Query = $this->Events->find()
                ->select(['remnant' => '(Events.capacity - COUNT(Applicants.id))'])
                ->leftJoinWith('Applicants')
                ->group(['Events.id'])
                ->enableAutoFields(true);
            if ($this->request->query('sort') == 'remnant') {
                $Query->order(['remnant' => $this->request->query('direction')]);
            }
            if (isset($this->request->data['remnant'])) {
                $Query->formatResults(function (\Cake\Collection\CollectionInterface $results) {
                    return $results->filter(function ($row) {
                        // Return to result sets, according to query.
                        return ($row->remnant >= $this->request->query('remnant'));
                    });
                });
            }
            $Events = $this->paginate($Query);
        } catch (NotFoundException $exception) {
            return $this->redirect(['action' => 'index']);
        }
 
        $this->set(compact('Events'));
    }

}

// View
// 
// <th>
//   <?php if ($this->request->query('direction') == 'asc'): ?>
//     <?php echo $this->Paginator->sort('remnant', '残り枠', ['direction' => 'desc']) ?>
//   <?php else: ?>
//     <?php echo $this->Paginator->sort('remnant', '残り枠', ['direction' => 'asc']) ?>
//   <?php endif ?>
// </th>

group / having で関連モデルに対して sum や count とかで集計

集約 - Group と Having

/** 
 * Count user's storage usage.
 * @param  int $user_id
 * @return int $byte
 */
public function countStorageUsage(int $user_id) {
  $this->Users = TableRegistry::get('Users');
  $query   = $this->Users->find();
  $results = $query->where(['Users.id' => $user_id])
    ->select([
      'usage' => $query->func()->sum('Files.size'),
    ])
    ->leftJoinWith('Files', function ($q) {
      return $q->where(['Files.size >' => 0]);
    })
    ->group(['User.id'])
    ->enableAutoFields(true);  // select() したカラム以外も付与して返却。
  return $results->first()->usage;
}

map で Query 操作と仮想プロパティ付与

クエリーは Collection オブジェクトである

// Collection ライブラリーの combine() メソッドを使います
// これは find('list') と等価です
$keyValueList = $articles->find()->combine('id', 'title');

// 上級な例
$results = $articles->find()
    ->where(['id >' => 1])
    ->order(['title' => 'DESC'])
    ->map(function ($row) { // map() は Collection のメソッドで、クエリーを実行します
        $row->trimmedTitle = trim($row->title);
        return $row;
    })
    ->combine('id', 'trimmedTitle') // combine() も Collection のメソッドです
    ->toArray(); // これも Collection のメソッドです

foreach ($results as $id => $trimmedTitle) {
    echo "$id : $trimmedTitle";
}

formatResults x map で ResultSet ( CollectionInterface ) 生成後に演算・値付与する

計算フィールドを追加する CollectionInterface - CakePHP3 api

// フィールド、条件、関連が構築済であると仮定します。
$query->formatResults(function (\Cake\Collection\CollectionInterface $results) {
    return $results->map(function ($row) {
        $row['age'] = $row['birth_date']->diff(new \DateTime)->y;
        return $row;
    });
});

// filter で抽出する例
$query->formatResults(function (\Cake\Collection\CollectionInterface $results) {
    return $results->filter(function ($row) {
        return ($row->is_publish);  // true のやつだけリザルトセットに返す
    });
});

case 文で select as みたいなことする

// In UsersController.
$Users = $this->Users->find()->where($conditions);  // Users 検索条件付与
$Users = $Users->select($this->Users);              // 全カラム取得

// Users.type みたいなしょーもないカラムで番号を返してこのあとページネーションでソートしたい
$Users = $Users->select(['type_number' => $Users->newExpr()->addCase(
  [
    $Users->newExpr()->add(['type' => 'DMP']),
    $Users->newExpr()->add(['type' => 'REP']),
    $Users->newExpr()->add(['type' => 'HBP']),
    $Users->newExpr()->add(['type' => 'RFP']),
    $Users->newExpr()->add(['type' => 'NAP']),
  ],
  [0, 1, 2, 3, 4],
  ['integer', 'integer', 'integer', 'integer', 'integer']
)]);


// Users は User.type_number が付与されてる ( select as 的な ) 
foreach ($Users ?? [] as $User) {
  echo $User->type;         // DMP
  echo $User->type_number;  // 0
}

// ページネーションを強制 ( $this->Paginator->sort('type_number') だけで動けよ... )
if ($this->request->query('sort') == 'type_number') {
  $Users->order(['type_number' => $this->request->query('direction')]);
}
$Users = $this->paginate($Users, ['limit' => PAGINATE_LIMIT]);
$this->set(compact('Users'));
// 町 (city) を人口 (population size) に基いて SMALL、MEDIUM、LARGE に分類
$cities = $this->cities->find()
    ->where(function ($exp, $q) {
        return $exp->addCase(
            [
                $q->newExpr()->lt('population', 100000),
                $q->newExpr()->between('population', 100000, 999000),
                $q->newExpr()->gte('population', 999001),
            ],
            ['SMALL',  'MEDIUM', 'LARGE'],  // 条件に合致したときの値
            ['string', 'string', 'string']  // それぞれの値の型
        );
    });
// Case1: Published.
$publishedCase = $query->newExpr()
    ->addCase(
        $query->newExpr()->add(['published' => 'Y']),
        1,
        'integer'
    );

// Case2: Unpublished
$unpublishedCase = $query->newExpr()
    ->addCase(
        $query->newExpr()->add(['published' => 'N']),
        1,
        'integer'
    );

// Let's find with case.
$articles = $this->articles->find()
    ->select([
        'number_published' => $query->func()->count($publishedCase),
        'number_unpublished' => $query->func()->count($unpublishedCase)
    ]);

関連データフィルタリングをしつつ OR する

$tagName = 'PHP';
$categoryName = 'IT';

$postsTable = TableRegistry::get('Posts');

$conditions = [];
$conditions[] = ['Posts.id IN' =>
                 $postsTable->find()
                 ->select(['Posts.id'])
                 ->matching('Tags', function (Query $q) use ($tagName) {
                   return $q->where(['Tags.name' => $tagName]);
                 })
                ];
$conditions[] = ['Posts.id IN' =>
                 $postsTable->find()
                 ->select(['Posts.id'])
                 ->matching('Categories', function (Query $q) use ($categoryName) {
                   return $q->where(['Categories.name' => $categoryName]);
                 })
                ];

$query = $postsTable->find();
$query->where(['OR' => $conditions]);

Expression を用いた条件付け

マニュアル 高度な条件 を参照。

$query = $articles->find()
    ->where(function ($exp) {
        $orConditions = $exp->or_(['author_id' => 2])
            ->eq('author_id', 5);
        return $exp
            ->add($orConditions)
            ->eq('published', true)
            ->gte('view_count', 10);
    });

// SQL
SELECT *
	FROM articles
	WHERE (
		(author_id = 2 OR author_id = 5)
		AND published = 1
		AND view_count >= 10)
newExpr() で全てのタグを持つポストをマッチング
<?php
// 純粋マッチングでは不可能なので Group/Having で抽出する
// https://goo.gl/XThFuQ

// In Table.
$tagNames = ['CakePHP', 'PHP'];

$query = TableRegistry::get('Posts')->find();
$query
  ->matching('Tags', function (Query $q) use ($tagNames) {
    return $q->where(['Tags.name IN' => $tagNames]);
  })
  ->group(['Posts.id'])
  ->having([
    $this->query()->newExpr()->eq('COUNT(DISTINCT Tags.name)', count($tagNames))
  ]);

QueryBuilder で Update / Delete

// https://book.cakephp.org/3.0/ja/orm/query-builder.html#delete

$query = $articles->query(); //findじゃないよ
$query->delete()
    ->where(['id' => $id])
    ->execute();

$query = $articles->query();
$query->update()
    ->set(['published' => true])
    ->where(['id' => $id])
    ->execute();
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment