Concrete CMS Expressエントリ出力1000件限界突破とAND検索実現のDoctrineの活用

Concrete CMSのエクスプレスは大規模データを扱えない?
9,000件を超えるイラストを紹介するエクスプレスデータベースを、以前本ホームページで紹介している「Concrete CMS エクスプレスに一括CSV入力」で構築しました。
知人に誇らしげに見せたところ、「1,000件しか表示できない!」と指摘されました。そこで、この制限を打破しようと試みましたが、容易に突破できるものではありませんでした。Concrete CMS の Express エントリ一覧には「最大 1000 件までしか取得できない」という仕様があり、標準の EntryList やブロック機能を使用する場合、この制約が適用されるようです。そのため、1 万件近い大規模データを扱う本プロジェクトには適していない可能性がありました。(使用環境が Concrete CMS 8.5.19 というレガシーなバージョンであることも、一因であるかもしれません。)
そこで、Concrete CMS のエクスプレスデータを一括削除する際と同様に、Doctrine を活用すれば解決できるのではないかと考え、開発に着手しました。
開発ノート
当初は、標準のExpress一覧ブロックをカスタマイズする方向で対応を模索したが、内部で用いられているORMの制約により、制限の回避は難航しました。そこで、前述の通りDoctrineクエリビルダーを直接用いたエントリ取得方式への移行を決断しましたた。
Doctrineを活用することで、以下の要件を満たす実装が実現できました:
-
ページネーションによる無制限データの分割表示
-
属性に対する柔軟な検索(LIKE句)
-
数値的意味を持つ属性(例:illust_no)への正しいソート
-
Bootstrapと連携したUI上の表現
-
スペース区切りでのAND検索機能
最終的に、検索・ソート・ページングを備えた拡張性の高いExpressリスト表示機能が完成し、Concrete CMSの制約を超えた柔軟なエントリ出力を実現するに至りました。副次的に通常のExpress Listでは実現できないスペース区切りのAND検索機能も付与することができました。
本件を通じて、Concrete CMS内部のEntity管理構造やDoctrine ORMの操作方法に関する理解が深まり、将来的な拡張やメンテナンスにおいても有効な知見を得ることができました。
実装したコードを掲載します
運用している実コードですので、参考程度にとどめてください。
Express エンティティハンドルや属性ハンドルを適切に変更した上で、ご利用ください。
動作確認はConcrete CMS 8.5.19のみで行っており、他のバージョンでの動作は未確認です。
設置の方策
Concrete CMS でこのコードを組み込む方法として、以下の選択肢が考えられます:
- カスタムブロックを作成する(現行方法)
applications > blocks
内にブロック(「express_entry_list」や「page_attribute_display」)を作成し、テーマやページ編集機能と統合することで、より柔軟な管理が可能になります。 - ページテンプレートに直接埋め込む
applications > elements
やthemes
内に適切な要素を作成し、固定ページ内で利用するのも一つの方法です。 - カスタムジョブとして実装する
データの取得・処理を定期的に実行する場合、Concrete CMS の「ジョブ機能」を活用することで、管理画面から簡単に制御できるようになります。
express_ewntry_listカスタムブロックとして組み込んだview.phpコード: htmlとスタイルシートを含みます。 <?php use Concrete\Core\Page\Page; use Concrete\Core\Support\Facade\Application; use Concrete\Core\Express\EntryList; $app = Application::getFacadeApplication(); $c = Page::getCurrentPage(); if (is_object($c) && $c->isEditMode()) { echo '<div class="ccm-edit-mode-disabled-item">' . t('エクスプレスのリストが表示される') . '</div>'; return; } // Expressエンティティハンドル $entityHandle = 'illust'; // エクスプレスエンティティ取得 $entity = \Concrete\Core\Support\Facade\Express::getObjectByHandle($entityHandle); if (!$entity) { echo t("エクスプレスエンティティが見つかりません。"); return; } // 検索キーワードの取得と分割 $searchKeyword = isset($_GET['search']) ? trim($_GET['search']) : ''; $keywords = preg_split('/\s+/u', $searchKeyword, -1, PREG_SPLIT_NO_EMPTY);
// 検索結果のエントリーを 権限に関係なく取得し、100 件ずつ表示 $list = new EntryList($entity); $list->ignorePermissions(); $list->setItemsPerPage(100); // AND検索条件の追加 if (!empty($keywords)) { $query = $list->getQueryObject(); foreach ($keywords as $index => $keyword) { $query->andWhere("ak_illust_kw LIKE :searchKeyword$index"); $query->setParameter("searchKeyword$index", '%' . $keyword . '%'); } }
// ソート順の設定 $list->getQueryObject()->orderBy('CAST(ak_illust_no AS UNSIGNED)', 'DESC'); // 数値型として扱い降順ソート $pagination = $list->getPagination(); $entries = $pagination->getCurrentPageResults(); $totalCount = $list->getTotalResults(); ?> <!-- 検索フォーム --> <form method="get" class="mb-3"> <input type="text" name="search" placeholder="<?= t('キーワード検索') ?>" value="<?= h($searchKeyword) ?>"> <button type="submit"><?= t('検索') ?></button><br>スペース区切りでand検索 </form> <!-- ページネーション表示 --> <div class="mb-3"><strong><?= t("該当件数") ?>: <?= $totalCount ?> 件</strong></div> <?php if ($pagination->haveToPaginate()): ?> <div class="mt-4"> <?= $pagination->renderDefaultView(); ?> </div> <?php endif; ?> <!-- bootstrapレイアウトで書き出し --> <div class="row"> <?php foreach ($entries as $entry): if (!$entry instanceof \Concrete\Core\Entity\Express\Entry) continue; $image = $entry->getAttribute('illust_im_m'); $title = $entry->getAttribute('illust_title'); $no = $entry->getAttribute('illust_no'); $link = $entry->getAttribute('illust_sale_url'); $kw = $entry->getAttribute('illust_kw'); ?> <div class="col-md-4 mb-4"> <div class="card h-100"> <?php if ($image): ?> <?php if ($link): ?><a href="<?= h($link) ?>" target="_blank"><?php endif; ?> <img src="<?= h($image) ?>" class="card-img-top" alt="<?= h($title) ?>"> <?php if ($link): ?></a><?php endif; ?> <?php endif; ?> <div class="card-body"> <div class="card-no">No.<?= h($no) ?></div> <div class="card-title"><?= h($title) ?></div> <div class="card-kw">Kw: <?= h($kw) ?></div> </div> </div> </div> <?php endforeach; ?> </div> <!-- ページネーション再表示 --> <div class="mb-3"><strong><?= t("該当件数") ?>: <?= $totalCount ?> 件</strong></div> <?php if ($pagination->haveToPaginate()): ?> <div class="mt-4"> <?= $pagination->renderDefaultView(); ?> </div> <?php endif; ?> <style> .card { position: relative; background: #efede9; margin: 5px 0px 10px 0px; box-shadow: 0px 0px 0px 5px #efede9; border: dashed 3px #A7A297; padding: 0.2em 0.5em; color: #454545; } .card-img-top { width: 100%; height: auto; object-fit: cover; } .card-no { pointer-events: none; text-decoration: none; } .card-title { pointer-events: none; text-decoration: none; font-weight:bold; } .card-kw { pointer-events: none; text-decoration: none; font-size:50%; line-height:1.2; } </style>
本稿の題材のイラスト紹介ページは、以下のリンクからご覧いただけます。エクスプレスデータベースに登録したPIXTAの画像URLを使用し、画像を表示できるようにしました。




