forked from fa/breadcrumb-the-shire
Rename the top-level lib/ directory to core/ so the project root immediately communicates which code is core platform and which lives in modules/. PHP namespaces (MintyPHP\*) are unchanged — only the Composer PSR-4 path mapping moves from lib/ to core/. Module-internal lib/ directories (modules/*/lib/) are untouched. Updated across the full stack: - composer.json PSR-4 mapping - bootstrap entry points (web/index.php, bin/*, tests/bootstrap.php) - tooling configs (phpstan.neon, phpunit.xml, php-cs-fixer) - 26 architecture contract tests - enforcement-policy, guard-catalog, quality-gates - all documentation (CLAUDE.md, README, 25 docs/, .agents/skills/) - bin/qa-extended.sh search paths - .claude/settings.local.json permission paths Workflow: .agents/runs/CORE-LIB-RENAME-001/ (Analyst → Planner → Executor → Code Review (4 findings fixed) → Security Review → Acceptance Test → Finalizer) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
282 lines
11 KiB
PHP
282 lines
11 KiB
PHP
<?php
|
|
|
|
namespace MintyPHP\Service\Search;
|
|
|
|
use MintyPHP\Repository\Search\SearchQueryRepository;
|
|
use MintyPHP\Repository\Tenant\UserTenantRepository;
|
|
use MintyPHP\Service\Access\PermissionService;
|
|
use MintyPHP\Support\SearchConfig;
|
|
|
|
class SearchDataService
|
|
{
|
|
public function __construct(
|
|
private readonly PermissionService $permissionService,
|
|
private readonly UserTenantRepository $userTenantRepository,
|
|
private readonly SearchQueryRepository $searchQueryRepository
|
|
) {
|
|
}
|
|
|
|
/**
|
|
* @return array{data: array<int, array<string, mixed>>, total: int}
|
|
*/
|
|
public function buildResultData(int $userId, string $query, string $locale, int $limit, int $offset): array
|
|
{
|
|
$tenantScope = $this->resolveTenantScope($userId);
|
|
$tenantIds = $tenantScope['tenantIds'];
|
|
$tenantScoped = $tenantScope['tenantScoped'];
|
|
|
|
$tenantScopeFilters = SearchConfig::tenantScopeFilters();
|
|
$resources = SearchConfig::resources($query, $locale);
|
|
|
|
// Merge module-contributed search resources into the pipeline.
|
|
// Replace {{userId}} before counting ? placeholders so it is not treated as a bind param.
|
|
$like = \MintyPHP\Support\Search\SearchQueryNormalizer::normalizeLikeQuery($query);
|
|
$userIdStr = (string) $userId;
|
|
foreach (SearchConfig::moduleResources() as $key => $moduleDef) {
|
|
$countSql = str_replace('{{userId}}', $userIdStr, $moduleDef['count_sql']);
|
|
$previewSql = str_replace('{{userId}}', $userIdStr, $moduleDef['preview_sql']);
|
|
$resultSql = str_replace('{{userId}}', $userIdStr, $moduleDef['result_sql']);
|
|
$resources[] = [
|
|
'key' => $key,
|
|
'label' => $moduleDef['label'],
|
|
'permission' => $moduleDef['permission'],
|
|
'countSql' => $countSql,
|
|
'countParams' => array_fill(0, substr_count($countSql, '?'), $like),
|
|
'previewSql' => $previewSql,
|
|
'previewParams' => array_fill(0, max(0, substr_count($previewSql, '?') - 1), $like),
|
|
'resultSql' => $resultSql,
|
|
'resultParams' => array_fill(0, substr_count($resultSql, '?'), $like),
|
|
];
|
|
}
|
|
|
|
$items = [];
|
|
|
|
foreach ($resources as $resource) {
|
|
$key = (string) ($resource['key'] ?? '');
|
|
if ($key === '') {
|
|
continue;
|
|
}
|
|
|
|
$perm = (string) ($resource['permission'] ?? '');
|
|
if ($perm !== '' && !$this->permissionService->userHas($userId, $perm)) {
|
|
continue;
|
|
}
|
|
|
|
$tenantFilter = (string) ($tenantScopeFilters[$key] ?? '');
|
|
$isTenantScoped = $tenantFilter !== '';
|
|
if ($isTenantScoped && !$tenantScoped) {
|
|
continue;
|
|
}
|
|
|
|
$sql = (string) ($resource['resultSql'] ?? '');
|
|
if ($sql === '') {
|
|
continue;
|
|
}
|
|
$sql = str_replace('{{tenantFilter}}', $isTenantScoped ? $tenantFilter : '', $sql);
|
|
$params = is_array($resource['resultParams'] ?? null) ? $resource['resultParams'] : [];
|
|
if ($isTenantScoped && strpos($sql, '???') !== false) {
|
|
$params[] = $tenantIds;
|
|
}
|
|
|
|
$rows = $this->searchQueryRepository->selectRows($sql, $params);
|
|
foreach ($rows as $row) {
|
|
$data = self::normalizeResourceRow($row);
|
|
$mapped = SearchConfig::mapResultItem($key, (string) ($resource['label'] ?? ''), $data);
|
|
if ($mapped !== null) {
|
|
$items[] = $mapped;
|
|
}
|
|
}
|
|
}
|
|
|
|
$items = array_merge($items, SearchConfig::hotkeyResultItems($query));
|
|
if ($this->permissionService->userHas($userId, PermissionService::DOCS_VIEW)) {
|
|
$items = array_merge($items, SearchConfig::docsResultItems($query));
|
|
}
|
|
|
|
$scoreQuery = SearchConfig::normalizeScoreQuery($query);
|
|
$queryLower = mb_strtolower($scoreQuery);
|
|
$scoreItem = static function (array $item) use ($queryLower): int {
|
|
$manualScore = (int) ($item['search_score'] ?? 0);
|
|
if ($manualScore > 0) {
|
|
return $manualScore;
|
|
}
|
|
|
|
$title = mb_strtolower((string) ($item['title'] ?? ''));
|
|
$description = mb_strtolower((string) ($item['description'] ?? ''));
|
|
if ($title === $queryLower) {
|
|
return 400;
|
|
}
|
|
if ($title !== '' && strpos($title, $queryLower) === 0) {
|
|
return 300;
|
|
}
|
|
if ($title !== '' && strpos($title, $queryLower) !== false) {
|
|
return 200;
|
|
}
|
|
if ($description !== '' && strpos($description, $queryLower) !== false) {
|
|
return 100;
|
|
}
|
|
return 0;
|
|
};
|
|
|
|
usort($items, static function (array $a, array $b) use ($scoreItem): int {
|
|
$scoreA = $scoreItem($a);
|
|
$scoreB = $scoreItem($b);
|
|
if ($scoreA !== $scoreB) {
|
|
return $scoreB <=> $scoreA;
|
|
}
|
|
$title = strcmp((string) ($a['title'] ?? ''), (string) ($b['title'] ?? ''));
|
|
if ($title !== 0) {
|
|
return $title;
|
|
}
|
|
return strcmp((string) ($a['type'] ?? ''), (string) ($b['type'] ?? ''));
|
|
});
|
|
|
|
$total = count($items);
|
|
$items = array_slice($items, $offset, $limit);
|
|
|
|
return [
|
|
'data' => $items,
|
|
'total' => $total,
|
|
];
|
|
}
|
|
|
|
/**
|
|
* @return array<int, array<string, mixed>>
|
|
*/
|
|
public function buildPreviewData(int $userId, string $query, string $locale, int $previewLimit = 5): array
|
|
{
|
|
$tenantScope = $this->resolveTenantScope($userId);
|
|
$tenantIds = $tenantScope['tenantIds'];
|
|
$tenantScoped = $tenantScope['tenantScoped'];
|
|
|
|
$tenantScopeFilters = SearchConfig::tenantScopeFilters();
|
|
$resources = SearchConfig::resources($query, $locale);
|
|
|
|
// Merge module-contributed search resources.
|
|
// Replace {{userId}} before counting ? placeholders so it is not treated as a bind param.
|
|
$like = \MintyPHP\Support\Search\SearchQueryNormalizer::normalizeLikeQuery($query);
|
|
$userIdStr = (string) $userId;
|
|
foreach (SearchConfig::moduleResources() as $key => $moduleDef) {
|
|
$countSql = str_replace('{{userId}}', $userIdStr, $moduleDef['count_sql']);
|
|
$previewSql = str_replace('{{userId}}', $userIdStr, $moduleDef['preview_sql']);
|
|
$resultSql = str_replace('{{userId}}', $userIdStr, $moduleDef['result_sql']);
|
|
$resources[] = [
|
|
'key' => $key,
|
|
'label' => $moduleDef['label'],
|
|
'permission' => $moduleDef['permission'],
|
|
'countSql' => $countSql,
|
|
'countParams' => array_fill(0, substr_count($countSql, '?'), $like),
|
|
'previewSql' => $previewSql,
|
|
'previewParams' => array_fill(0, max(0, substr_count($previewSql, '?') - 1), $like),
|
|
'resultSql' => $resultSql,
|
|
'resultParams' => array_fill(0, substr_count($resultSql, '?'), $like),
|
|
];
|
|
}
|
|
|
|
$results = [];
|
|
|
|
foreach ($resources as $resource) {
|
|
$key = (string) ($resource['key'] ?? '');
|
|
if ($key === '') {
|
|
continue;
|
|
}
|
|
|
|
$tenantFilter = (string) ($tenantScopeFilters[$key] ?? '');
|
|
$isTenantScoped = $tenantFilter !== '';
|
|
if ($isTenantScoped && !$tenantScoped) {
|
|
continue;
|
|
}
|
|
|
|
$perm = (string) ($resource['permission'] ?? '');
|
|
if ($perm !== '' && !$this->permissionService->userHas($userId, $perm)) {
|
|
continue;
|
|
}
|
|
|
|
$countSql = str_replace('{{tenantFilter}}', $isTenantScoped ? $tenantFilter : '', (string) ($resource['countSql'] ?? ''));
|
|
$countParams = is_array($resource['countParams'] ?? null) ? $resource['countParams'] : [];
|
|
if ($isTenantScoped && strpos($countSql, '???') !== false) {
|
|
$countParams[] = $tenantIds;
|
|
}
|
|
$count = $this->searchQueryRepository->selectCount($countSql, $countParams);
|
|
|
|
$items = [];
|
|
$previewSql = $resource['previewSql'] ?? null;
|
|
if (is_string($previewSql) && $previewSql !== '') {
|
|
$previewSql = str_replace('{{tenantFilter}}', $isTenantScoped ? $tenantFilter : '', $previewSql);
|
|
$previewParams = is_array($resource['previewParams'] ?? null) ? $resource['previewParams'] : [];
|
|
if ($isTenantScoped && strpos($previewSql, '???') !== false) {
|
|
$previewParams[] = $tenantIds;
|
|
}
|
|
$previewParams[] = $previewLimit;
|
|
$rows = $this->searchQueryRepository->selectRows($previewSql, $previewParams);
|
|
foreach ($rows as $row) {
|
|
$data = self::normalizeResourceRow($row);
|
|
$mapped = SearchConfig::mapPreviewItem($key, $data, $query);
|
|
if ($mapped !== null) {
|
|
$items[] = $mapped;
|
|
}
|
|
}
|
|
}
|
|
|
|
$result = [
|
|
'key' => $key,
|
|
'label' => $resource['label'] ?? '',
|
|
'count' => $count,
|
|
'url' => SearchConfig::listUrl($key, $query),
|
|
'items' => $items,
|
|
];
|
|
if ($key === 'pages' && !empty($items[0]['url'])) {
|
|
$result['url'] = $items[0]['url'];
|
|
}
|
|
$results[] = $result;
|
|
}
|
|
|
|
$hotkeyResource = SearchConfig::hotkeyPreviewResource($query);
|
|
if ($hotkeyResource !== null) {
|
|
$results[] = $hotkeyResource;
|
|
}
|
|
|
|
if ($this->permissionService->userHas($userId, PermissionService::DOCS_VIEW)) {
|
|
$docsResource = SearchConfig::docsPreviewResource($query);
|
|
if ($docsResource !== null) {
|
|
$results[] = $docsResource;
|
|
}
|
|
}
|
|
|
|
return $results;
|
|
}
|
|
|
|
/**
|
|
* @return array{tenantIds: array<int, int>, tenantScoped: bool}
|
|
*/
|
|
private function resolveTenantScope(int $userId): array
|
|
{
|
|
if ($userId > 0) {
|
|
// Prime cached permissions for the current request.
|
|
$this->permissionService->getUserPermissions($userId);
|
|
}
|
|
|
|
$tenantIds = $userId > 0
|
|
? $this->userTenantRepository->listTenantIdsByUserId($userId)
|
|
: [];
|
|
|
|
return [
|
|
'tenantIds' => $tenantIds,
|
|
'tenantScoped' => !empty($tenantIds),
|
|
];
|
|
}
|
|
|
|
/**
|
|
* @return array<string, mixed>
|
|
*/
|
|
private static function normalizeResourceRow(mixed $row): array
|
|
{
|
|
$data = $row;
|
|
if (is_array($row) && count($row) === 1) {
|
|
$data = reset($row);
|
|
}
|
|
|
|
return is_array($data) ? $data : [];
|
|
}
|
|
}
|