Enable Discovery debug logging for production troubleshooting
- Add DISCOVERY_LOG_LEVEL=debug - Add DISCOVERY_SHOW_PROGRESS=true - Temporary changes for debugging InitializerProcessor fixes on production
This commit is contained in:
156
src/Application/Search/CreateIndexRequest.php
Normal file
156
src/Application/Search/CreateIndexRequest.php
Normal file
@@ -0,0 +1,156 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Application\Search;
|
||||
|
||||
use App\Framework\Exception\ErrorCode;
|
||||
use App\Framework\Exception\FrameworkException;
|
||||
use App\Framework\Search\SearchFieldConfig;
|
||||
use App\Framework\Search\SearchFieldType;
|
||||
use App\Framework\Search\SearchIndexConfig;
|
||||
|
||||
/**
|
||||
* Request object for creating search indexes
|
||||
*/
|
||||
final readonly class CreateIndexRequest
|
||||
{
|
||||
/**
|
||||
* @param array<string, array> $fields
|
||||
* @param array<string, mixed> $settings
|
||||
*/
|
||||
public function __construct(
|
||||
public string $entityType,
|
||||
public array $fields,
|
||||
public array $settings = [],
|
||||
public bool $enabled = true
|
||||
) {
|
||||
}
|
||||
|
||||
public static function fromArray(string $entityType, array $data): self
|
||||
{
|
||||
$fields = $data['fields'] ?? [];
|
||||
$settings = $data['settings'] ?? [];
|
||||
$enabled = $data['enabled'] ?? true;
|
||||
|
||||
// Validate required fields
|
||||
if (empty($fields)) {
|
||||
throw FrameworkException::create(
|
||||
ErrorCode::VAL_BUSINESS_RULE_VIOLATION,
|
||||
'At least one field configuration is required'
|
||||
);
|
||||
}
|
||||
|
||||
// Validate field configurations
|
||||
foreach ($fields as $fieldName => $fieldConfig) {
|
||||
if (! is_array($fieldConfig) || ! isset($fieldConfig['type'])) {
|
||||
throw FrameworkException::create(
|
||||
ErrorCode::VAL_BUSINESS_RULE_VIOLATION,
|
||||
"Invalid field configuration for '{$fieldName}'. Type is required."
|
||||
);
|
||||
}
|
||||
|
||||
// Validate field type
|
||||
try {
|
||||
SearchFieldType::from($fieldConfig['type']);
|
||||
} catch (\ValueError) {
|
||||
throw FrameworkException::create(
|
||||
ErrorCode::VAL_BUSINESS_RULE_VIOLATION,
|
||||
"Invalid field type '{$fieldConfig['type']}' for field '{$fieldName}'"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return new self(
|
||||
entityType: $entityType,
|
||||
fields: $fields,
|
||||
settings: $settings,
|
||||
enabled: $enabled
|
||||
);
|
||||
}
|
||||
|
||||
public function toIndexConfig(): SearchIndexConfig
|
||||
{
|
||||
$fieldConfigs = [];
|
||||
|
||||
foreach ($this->fields as $fieldName => $fieldData) {
|
||||
$fieldConfigs[$fieldName] = new SearchFieldConfig(
|
||||
type: SearchFieldType::from($fieldData['type']),
|
||||
isSearchable: $fieldData['searchable'] ?? true,
|
||||
isFilterable: $fieldData['filterable'] ?? true,
|
||||
isSortable: $fieldData['sortable'] ?? true,
|
||||
isHighlightable: $fieldData['highlightable'] ?? true,
|
||||
boost: (float) ($fieldData['boost'] ?? 1.0),
|
||||
analyzer: $fieldData['analyzer'] ?? null,
|
||||
format: $fieldData['format'] ?? null,
|
||||
options: $fieldData['options'] ?? []
|
||||
);
|
||||
}
|
||||
|
||||
return new SearchIndexConfig(
|
||||
entityType: $this->entityType,
|
||||
fields: $fieldConfigs,
|
||||
settings: $this->settings,
|
||||
enabled: $this->enabled
|
||||
);
|
||||
}
|
||||
|
||||
public static function defaultForEntity(string $entityType): self
|
||||
{
|
||||
return new self(
|
||||
entityType: $entityType,
|
||||
fields: [
|
||||
'title' => [
|
||||
'type' => 'text',
|
||||
'searchable' => true,
|
||||
'highlightable' => true,
|
||||
'boost' => 2.0,
|
||||
],
|
||||
'content' => [
|
||||
'type' => 'text',
|
||||
'searchable' => true,
|
||||
'highlightable' => true,
|
||||
'boost' => 1.0,
|
||||
],
|
||||
'category' => [
|
||||
'type' => 'keyword',
|
||||
'searchable' => false,
|
||||
'filterable' => true,
|
||||
'sortable' => true,
|
||||
'highlightable' => false,
|
||||
],
|
||||
'tags' => [
|
||||
'type' => 'keyword',
|
||||
'searchable' => true,
|
||||
'filterable' => true,
|
||||
'sortable' => false,
|
||||
'highlightable' => false,
|
||||
],
|
||||
'created_at' => [
|
||||
'type' => 'datetime',
|
||||
'searchable' => false,
|
||||
'filterable' => true,
|
||||
'sortable' => true,
|
||||
'highlightable' => false,
|
||||
],
|
||||
'status' => [
|
||||
'type' => 'keyword',
|
||||
'searchable' => false,
|
||||
'filterable' => true,
|
||||
'sortable' => true,
|
||||
'highlightable' => false,
|
||||
],
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
public function toArray(): array
|
||||
{
|
||||
return [
|
||||
'entity_type' => $this->entityType,
|
||||
'fields' => $this->fields,
|
||||
'settings' => $this->settings,
|
||||
'enabled' => $this->enabled,
|
||||
];
|
||||
}
|
||||
}
|
||||
515
src/Application/Search/SearchController.php
Normal file
515
src/Application/Search/SearchController.php
Normal file
@@ -0,0 +1,515 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Application\Search;
|
||||
|
||||
|
||||
use App\Framework\Attributes\Route;
|
||||
use App\Framework\Exception\ErrorCode;
|
||||
use App\Framework\Exception\FrameworkException;
|
||||
use App\Framework\Http\Method;
|
||||
use App\Framework\Http\Request;
|
||||
use App\Framework\Http\Status;
|
||||
use App\Framework\Router\Result\JsonResult;
|
||||
use App\Framework\Search\SearchDocument;
|
||||
use App\Framework\Search\SearchFilter;
|
||||
use App\Framework\Search\SearchFilterType;
|
||||
use App\Framework\Search\SearchService;
|
||||
use App\Framework\Search\SearchSortDirection;
|
||||
|
||||
/**
|
||||
* REST API controller for search functionality
|
||||
*/
|
||||
final readonly class SearchController
|
||||
{
|
||||
public function __construct(
|
||||
private SearchService $searchService
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Search for documents
|
||||
* GET /api/search/{entityType}
|
||||
*/
|
||||
#[Route(path: '/api/search/{entityType}', method: Method::GET)]
|
||||
public function search(Request $request): JsonResult
|
||||
{
|
||||
try {
|
||||
$entityType = $request->routeParameters->get('entityType');
|
||||
$searchRequest = SearchRequest::fromHttpRequest($request);
|
||||
|
||||
$queryBuilder = $this->searchService
|
||||
->for($entityType)
|
||||
->query($searchRequest->query);
|
||||
|
||||
// Apply filters
|
||||
foreach ($searchRequest->filters as $field => $filterData) {
|
||||
$filter = $this->createSearchFilter($filterData);
|
||||
$queryBuilder->filter($field, $filter);
|
||||
}
|
||||
|
||||
// Apply boosts
|
||||
foreach ($searchRequest->boosts as $field => $boost) {
|
||||
$queryBuilder->boost($field, $boost);
|
||||
}
|
||||
|
||||
// Apply field restrictions
|
||||
if (! empty($searchRequest->fields)) {
|
||||
$queryBuilder->fields($searchRequest->fields);
|
||||
}
|
||||
|
||||
// Apply highlighting
|
||||
if (! empty($searchRequest->highlight)) {
|
||||
$queryBuilder->highlight($searchRequest->highlight);
|
||||
}
|
||||
|
||||
// Apply pagination
|
||||
$queryBuilder->limit($searchRequest->limit)
|
||||
->offset($searchRequest->offset);
|
||||
|
||||
// Apply sorting
|
||||
if ($searchRequest->sortBy) {
|
||||
$queryBuilder->sortBy(
|
||||
$searchRequest->sortBy,
|
||||
SearchSortDirection::from($searchRequest->sortDirection)
|
||||
);
|
||||
} elseif ($searchRequest->sortByRelevance) {
|
||||
$queryBuilder->sortByRelevance();
|
||||
}
|
||||
|
||||
// Apply advanced options
|
||||
if ($searchRequest->enableFuzzy) {
|
||||
$queryBuilder->fuzzy(true);
|
||||
}
|
||||
|
||||
if ($searchRequest->minScore > 0) {
|
||||
$queryBuilder->minScore($searchRequest->minScore);
|
||||
}
|
||||
|
||||
// Execute search
|
||||
$result = $queryBuilder->search();
|
||||
|
||||
return new JsonResult([
|
||||
'success' => true,
|
||||
'data' => [
|
||||
'hits' => array_map(fn ($hit) => $hit->toArray(), $result->hits),
|
||||
'total' => $result->total,
|
||||
'max_score' => $result->maxScore,
|
||||
'took' => $result->took,
|
||||
'has_more' => $result->total > ($searchRequest->offset + $searchRequest->limit),
|
||||
'page' => intval($searchRequest->offset / $searchRequest->limit) + 1,
|
||||
'per_page' => $searchRequest->limit,
|
||||
'total_pages' => ceil($result->total / $searchRequest->limit),
|
||||
],
|
||||
'aggregations' => $result->aggregations,
|
||||
]);
|
||||
|
||||
} catch (FrameworkException $e) {
|
||||
return new JsonResult([
|
||||
'success' => false,
|
||||
'error' => [
|
||||
'message' => $e->getMessage(),
|
||||
'code' => $e->getErrorCode()->value,
|
||||
'details' => $e->getData(),
|
||||
],
|
||||
], Status::BAD_REQUEST);
|
||||
} catch (\Exception $e) {
|
||||
return new JsonResult([
|
||||
'success' => false,
|
||||
'error' => [
|
||||
'message' => 'Internal search error',
|
||||
'code' => 'SEARCH_ERROR',
|
||||
],
|
||||
], Status::INTERNAL_SERVER_ERROR);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Index a single document
|
||||
* POST /api/search/{entityType}/{id}
|
||||
*/
|
||||
#[Route(path: '/api/search/{entityType}/{id}', method: Method::POST)]
|
||||
public function indexDocument(Request $request): JsonResult
|
||||
{
|
||||
try {
|
||||
$entityType = $request->routeParameters->get('entityType');
|
||||
$id = $request->routeParameters->get('id');
|
||||
$document = $request->parsedBody->toArray();
|
||||
|
||||
if (empty($document)) {
|
||||
throw FrameworkException::create(
|
||||
ErrorCode::VAL_BUSINESS_RULE_VIOLATION,
|
||||
'Document data is required'
|
||||
);
|
||||
}
|
||||
|
||||
$success = $this->searchService->index($entityType, $id, $document);
|
||||
|
||||
if (! $success) {
|
||||
throw FrameworkException::create(
|
||||
ErrorCode::DB_QUERY_FAILED,
|
||||
'Failed to index document'
|
||||
);
|
||||
}
|
||||
|
||||
return new JsonResult([
|
||||
'success' => true,
|
||||
'data' => [
|
||||
'entity_type' => $entityType,
|
||||
'id' => $id,
|
||||
'indexed' => true,
|
||||
'message' => 'Document indexed successfully',
|
||||
],
|
||||
], Status::CREATED);
|
||||
|
||||
} catch (FrameworkException $e) {
|
||||
return new JsonResult([
|
||||
'success' => false,
|
||||
'error' => [
|
||||
'message' => $e->getMessage(),
|
||||
'code' => $e->getErrorCode()->value,
|
||||
'details' => $e->getData(),
|
||||
],
|
||||
], Status::BAD_REQUEST);
|
||||
} catch (\Exception $e) {
|
||||
return new JsonResult([
|
||||
'success' => false,
|
||||
'error' => [
|
||||
'message' => 'Failed to index document',
|
||||
'code' => 'INDEX_ERROR',
|
||||
],
|
||||
], Status::INTERNAL_SERVER_ERROR);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Bulk index multiple documents
|
||||
* POST /api/search/{entityType}/bulk
|
||||
*/
|
||||
#[Route(path: '/api/search/{entityType}/bulk', method: Method::POST)]
|
||||
public function bulkIndex(Request $request): JsonResult
|
||||
{
|
||||
try {
|
||||
$entityType = $request->routeParameters->get('entityType');
|
||||
$requestData = $request->parsedBody->toArray();
|
||||
|
||||
if (! isset($requestData['documents']) || ! is_array($requestData['documents'])) {
|
||||
throw FrameworkException::create(
|
||||
ErrorCode::VAL_BUSINESS_RULE_VIOLATION,
|
||||
'Documents array is required'
|
||||
);
|
||||
}
|
||||
|
||||
$documents = [];
|
||||
foreach ($requestData['documents'] as $docData) {
|
||||
if (! isset($docData['id']) || ! isset($docData['data'])) {
|
||||
throw FrameworkException::create(
|
||||
ErrorCode::VAL_BUSINESS_RULE_VIOLATION,
|
||||
'Each document must have id and data fields'
|
||||
);
|
||||
}
|
||||
|
||||
$documents[] = new SearchDocument(
|
||||
id: $docData['id'],
|
||||
entityType: $entityType,
|
||||
data: $docData['data'],
|
||||
metadata: $docData['metadata'] ?? []
|
||||
);
|
||||
}
|
||||
|
||||
$result = $this->searchService->bulkIndex($documents);
|
||||
|
||||
return new JsonResult([
|
||||
'success' => true,
|
||||
'data' => [
|
||||
'entity_type' => $entityType,
|
||||
'bulk_result' => $result->toArray(),
|
||||
],
|
||||
], $result->isFullySuccessful() ? Status::CREATED : Status::ACCEPTED);
|
||||
|
||||
} catch (FrameworkException $e) {
|
||||
return new JsonResult([
|
||||
'success' => false,
|
||||
'error' => [
|
||||
'message' => $e->getMessage(),
|
||||
'code' => $e->getErrorCode()->value,
|
||||
'details' => $e->getData(),
|
||||
],
|
||||
], Status::BAD_REQUEST);
|
||||
} catch (\Exception $e) {
|
||||
return new JsonResult([
|
||||
'success' => false,
|
||||
'error' => [
|
||||
'message' => 'Bulk indexing failed',
|
||||
'code' => 'BULK_INDEX_ERROR',
|
||||
],
|
||||
], Status::INTERNAL_SERVER_ERROR);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a document
|
||||
* PUT /api/search/{entityType}/{id}
|
||||
*/
|
||||
#[Route(path: '/api/search/{entityType}/{id}', method: Method::PUT)]
|
||||
public function updateDocument(Request $request): JsonResult
|
||||
{
|
||||
try {
|
||||
$entityType = $request->routeParameters->get('entityType');
|
||||
$id = $request->routeParameters->get('id');
|
||||
$document = $request->parsedBody->toArray();
|
||||
|
||||
if (empty($document)) {
|
||||
throw FrameworkException::create(
|
||||
ErrorCode::VAL_BUSINESS_RULE_VIOLATION,
|
||||
'Document data is required'
|
||||
);
|
||||
}
|
||||
|
||||
$success = $this->searchService->update($entityType, $id, $document);
|
||||
|
||||
if (! $success) {
|
||||
throw FrameworkException::create(
|
||||
ErrorCode::DB_QUERY_FAILED,
|
||||
'Failed to update document'
|
||||
);
|
||||
}
|
||||
|
||||
return new JsonResult([
|
||||
'success' => true,
|
||||
'data' => [
|
||||
'entity_type' => $entityType,
|
||||
'id' => $id,
|
||||
'updated' => true,
|
||||
'message' => 'Document updated successfully',
|
||||
],
|
||||
]);
|
||||
|
||||
} catch (FrameworkException $e) {
|
||||
return new JsonResult([
|
||||
'success' => false,
|
||||
'error' => [
|
||||
'message' => $e->getMessage(),
|
||||
'code' => $e->getErrorCode()->value,
|
||||
'details' => $e->getData(),
|
||||
],
|
||||
], Status::BAD_REQUEST);
|
||||
} catch (\Exception $e) {
|
||||
return new JsonResult([
|
||||
'success' => false,
|
||||
'error' => [
|
||||
'message' => 'Failed to update document',
|
||||
'code' => 'UPDATE_ERROR',
|
||||
],
|
||||
], Status::INTERNAL_SERVER_ERROR);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a document from the search index
|
||||
* DELETE /api/search/{entityType}/{id}
|
||||
*/
|
||||
#[Route(path: '/api/search/{entityType}/{id}', method: Method::DELETE)]
|
||||
public function deleteDocument(Request $request): JsonResult
|
||||
{
|
||||
try {
|
||||
$entityType = $request->routeParameters->get('entityType');
|
||||
$id = $request->routeParameters->get('id');
|
||||
|
||||
$success = $this->searchService->delete($entityType, $id);
|
||||
|
||||
return new JsonResult([
|
||||
'success' => true,
|
||||
'data' => [
|
||||
'entity_type' => $entityType,
|
||||
'id' => $id,
|
||||
'deleted' => $success,
|
||||
'message' => $success ? 'Document deleted successfully' : 'Document not found',
|
||||
],
|
||||
]);
|
||||
|
||||
} catch (FrameworkException $e) {
|
||||
return new JsonResult([
|
||||
'success' => false,
|
||||
'error' => [
|
||||
'message' => $e->getMessage(),
|
||||
'code' => $e->getErrorCode()->value,
|
||||
'details' => $e->getData(),
|
||||
],
|
||||
], Status::BAD_REQUEST);
|
||||
} catch (\Exception $e) {
|
||||
return new JsonResult([
|
||||
'success' => false,
|
||||
'error' => [
|
||||
'message' => 'Failed to delete document',
|
||||
'code' => 'DELETE_ERROR',
|
||||
],
|
||||
], Status::INTERNAL_SERVER_ERROR);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get search engine statistics
|
||||
* GET /api/search/_stats
|
||||
*/
|
||||
#[Route(path: '/api/search/_stats', method: Method::GET)]
|
||||
public function getStats(Request $request): JsonResult
|
||||
{
|
||||
try {
|
||||
$stats = $this->searchService->getStats();
|
||||
|
||||
return new JsonResult([
|
||||
'success' => true,
|
||||
'data' => [
|
||||
'engine_stats' => $stats->toArray(),
|
||||
'available' => $this->searchService->isAvailable(),
|
||||
],
|
||||
]);
|
||||
|
||||
} catch (\Exception $e) {
|
||||
return new JsonResult([
|
||||
'success' => false,
|
||||
'error' => [
|
||||
'message' => 'Failed to get search statistics',
|
||||
'code' => 'STATS_ERROR',
|
||||
],
|
||||
], Status::INTERNAL_SERVER_ERROR);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get index-specific statistics
|
||||
* GET /api/search/{entityType}/_stats
|
||||
*/
|
||||
#[Route(path: '/api/search/{entityType}/_stats', method: Method::GET)]
|
||||
public function getIndexStats(Request $request): JsonResult
|
||||
{
|
||||
try {
|
||||
$entityType = $request->routeParameters->get('entityType');
|
||||
$indexManager = $this->searchService->getIndexManager();
|
||||
|
||||
$stats = $indexManager->getIndexStats($entityType);
|
||||
|
||||
if (! $stats) {
|
||||
return new JsonResult([
|
||||
'success' => false,
|
||||
'error' => [
|
||||
'message' => "Index '{$entityType}' not found",
|
||||
'code' => 'INDEX_NOT_FOUND',
|
||||
],
|
||||
], Status::NOT_FOUND);
|
||||
}
|
||||
|
||||
return new JsonResult([
|
||||
'success' => true,
|
||||
'data' => [
|
||||
'index_stats' => $stats->toArray(),
|
||||
'entity_type' => $entityType,
|
||||
],
|
||||
]);
|
||||
|
||||
} catch (\Exception $e) {
|
||||
return new JsonResult([
|
||||
'success' => false,
|
||||
'error' => [
|
||||
'message' => 'Failed to get index statistics',
|
||||
'code' => 'INDEX_STATS_ERROR',
|
||||
],
|
||||
], Status::INTERNAL_SERVER_ERROR);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create or update an index
|
||||
* PUT /api/search/{entityType}/_index
|
||||
*/
|
||||
#[Route(path: '/api/search/{entityType}/_index', method: Method::PUT)]
|
||||
public function createIndex(Request $request): JsonResult
|
||||
{
|
||||
try {
|
||||
$entityType = $request->routeParameters->get('entityType');
|
||||
$requestData = $request->parsedBody->toArray();
|
||||
|
||||
$createIndexRequest = CreateIndexRequest::fromArray($entityType, $requestData);
|
||||
$indexManager = $this->searchService->getIndexManager();
|
||||
|
||||
$success = $indexManager->createIndex($entityType, $createIndexRequest->toIndexConfig());
|
||||
|
||||
if (! $success) {
|
||||
throw FrameworkException::create(
|
||||
ErrorCode::DB_QUERY_FAILED,
|
||||
'Failed to create search index'
|
||||
);
|
||||
}
|
||||
|
||||
return new JsonResult([
|
||||
'success' => true,
|
||||
'data' => [
|
||||
'entity_type' => $entityType,
|
||||
'created' => true,
|
||||
'message' => 'Search index created successfully',
|
||||
],
|
||||
], Status::CREATED);
|
||||
|
||||
} catch (FrameworkException $e) {
|
||||
return new JsonResult([
|
||||
'success' => false,
|
||||
'error' => [
|
||||
'message' => $e->getMessage(),
|
||||
'code' => $e->getErrorCode()->value,
|
||||
'details' => $e->getData(),
|
||||
],
|
||||
], Status::BAD_REQUEST);
|
||||
} catch (\Exception $e) {
|
||||
return new JsonResult([
|
||||
'success' => false,
|
||||
'error' => [
|
||||
'message' => 'Failed to create index',
|
||||
'code' => 'CREATE_INDEX_ERROR',
|
||||
],
|
||||
], Status::INTERNAL_SERVER_ERROR);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete an index
|
||||
* DELETE /api/search/{entityType}/_index
|
||||
*/
|
||||
#[Route(path: '/api/search/{entityType}/_index', method: Method::DELETE)]
|
||||
public function deleteIndex(Request $request): JsonResult
|
||||
{
|
||||
try {
|
||||
$entityType = $request->routeParameters->get('entityType');
|
||||
$indexManager = $this->searchService->getIndexManager();
|
||||
|
||||
$success = $indexManager->deleteIndex($entityType);
|
||||
|
||||
return new JsonResult([
|
||||
'success' => true,
|
||||
'data' => [
|
||||
'entity_type' => $entityType,
|
||||
'deleted' => $success,
|
||||
'message' => $success ? 'Index deleted successfully' : 'Index not found',
|
||||
],
|
||||
]);
|
||||
|
||||
} catch (\Exception $e) {
|
||||
return new JsonResult([
|
||||
'success' => false,
|
||||
'error' => [
|
||||
'message' => 'Failed to delete index',
|
||||
'code' => 'DELETE_INDEX_ERROR',
|
||||
],
|
||||
], Status::INTERNAL_SERVER_ERROR);
|
||||
}
|
||||
}
|
||||
|
||||
private function createSearchFilter(array $filterData): SearchFilter
|
||||
{
|
||||
$type = SearchFilterType::from($filterData['type'] ?? 'equals');
|
||||
$value = $filterData['value'] ?? null;
|
||||
|
||||
return new SearchFilter($type, $value);
|
||||
}
|
||||
}
|
||||
224
src/Application/Search/SearchRequest.php
Normal file
224
src/Application/Search/SearchRequest.php
Normal file
@@ -0,0 +1,224 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Application\Search;
|
||||
|
||||
use App\Framework\Exception\ErrorCode;
|
||||
use App\Framework\Exception\FrameworkException;
|
||||
use App\Framework\Http\Request;
|
||||
|
||||
/**
|
||||
* Represents a search request with validation
|
||||
*/
|
||||
final readonly class SearchRequest
|
||||
{
|
||||
/**
|
||||
* @param array<string, array> $filters
|
||||
* @param array<string, float> $boosts
|
||||
* @param array<string> $fields
|
||||
* @param array<string> $highlight
|
||||
*/
|
||||
public function __construct(
|
||||
public string $query,
|
||||
public array $filters = [],
|
||||
public array $boosts = [],
|
||||
public array $fields = [],
|
||||
public array $highlight = [],
|
||||
public int $limit = 20,
|
||||
public int $offset = 0,
|
||||
public ?string $sortBy = null,
|
||||
public string $sortDirection = 'asc',
|
||||
public bool $sortByRelevance = true,
|
||||
public bool $enableHighlighting = true,
|
||||
public bool $enableFuzzy = false,
|
||||
public float $minScore = 0.0
|
||||
) {
|
||||
}
|
||||
|
||||
public static function fromHttpRequest(Request $request): self
|
||||
{
|
||||
$query = $request->query;
|
||||
|
||||
// Parse search query
|
||||
$searchQuery = $query->get('q', '*');
|
||||
|
||||
// Parse filters
|
||||
$filters = [];
|
||||
if ($query->has('filters')) {
|
||||
$filtersParam = $query->get('filters');
|
||||
if (is_string($filtersParam)) {
|
||||
$filters = json_decode($filtersParam, true) ?? [];
|
||||
} elseif (is_array($filtersParam)) {
|
||||
$filters = $filtersParam;
|
||||
}
|
||||
}
|
||||
|
||||
// Parse individual filter parameters (filter[field][type]=value)
|
||||
foreach ($query->toArray() as $key => $value) {
|
||||
if (preg_match('/^filter\[([^]]+)](?:\[([^]]+)])?$/', $key, $matches)) {
|
||||
$field = $matches[1];
|
||||
$type = $matches[2] ?? 'equals';
|
||||
|
||||
if (! isset($filters[$field])) {
|
||||
$filters[$field] = [];
|
||||
}
|
||||
|
||||
$filters[$field]['type'] = $type;
|
||||
$filters[$field]['value'] = $value;
|
||||
}
|
||||
}
|
||||
|
||||
// Parse boosts
|
||||
$boosts = [];
|
||||
if ($query->has('boosts')) {
|
||||
$boostsParam = $query->get('boosts');
|
||||
if (is_string($boostsParam)) {
|
||||
$boosts = json_decode($boostsParam, true) ?? [];
|
||||
} elseif (is_array($boostsParam)) {
|
||||
$boosts = $boostsParam;
|
||||
}
|
||||
}
|
||||
|
||||
// Parse fields restriction
|
||||
$fields = [];
|
||||
if ($query->has('fields')) {
|
||||
$fieldsParam = $query->get('fields');
|
||||
if (is_string($fieldsParam)) {
|
||||
$fields = array_map('trim', explode(',', $fieldsParam));
|
||||
} elseif (is_array($fieldsParam)) {
|
||||
$fields = $fieldsParam;
|
||||
}
|
||||
}
|
||||
|
||||
// Parse highlight fields
|
||||
$highlight = [];
|
||||
if ($query->has('highlight')) {
|
||||
$highlightParam = $query->get('highlight');
|
||||
if (is_string($highlightParam)) {
|
||||
$highlight = array_map('trim', explode(',', $highlightParam));
|
||||
} elseif (is_array($highlightParam)) {
|
||||
$highlight = $highlightParam;
|
||||
}
|
||||
} elseif ($query->getBool('enable_highlighting', true)) {
|
||||
// Default highlighting for text fields
|
||||
$highlight = ['title', 'content', 'description'];
|
||||
}
|
||||
|
||||
// Parse pagination
|
||||
$limit = min(100, max(1, $query->getInt('limit', 20)));
|
||||
$offset = max(0, $query->getInt('offset', 0));
|
||||
|
||||
// Alternative pagination via page parameter
|
||||
if ($query->has('page')) {
|
||||
$page = max(1, $query->getInt('page', 1));
|
||||
$perPage = min(100, max(1, $query->getInt('per_page', 20)));
|
||||
$offset = ($page - 1) * $perPage;
|
||||
$limit = $perPage;
|
||||
}
|
||||
|
||||
// Parse sorting
|
||||
$sortBy = $query->get('sort_by');
|
||||
$sortDirection = strtolower($query->get('sort_direction', 'asc'));
|
||||
$sortByRelevance = $query->getBool('sort_by_relevance', ! $sortBy);
|
||||
|
||||
// Validate sort direction
|
||||
if (! in_array($sortDirection, ['asc', 'desc'])) {
|
||||
throw FrameworkException::create(
|
||||
ErrorCode::VAL_BUSINESS_RULE_VIOLATION,
|
||||
'Invalid sort direction. Must be "asc" or "desc"'
|
||||
);
|
||||
}
|
||||
|
||||
// Parse advanced options
|
||||
$enableHighlighting = $query->getBool('enable_highlighting', true);
|
||||
$enableFuzzy = $query->getBool('enable_fuzzy', false);
|
||||
$minScore = max(0.0, $query->getFloat('min_score', 0.0));
|
||||
|
||||
return new self(
|
||||
query: $searchQuery,
|
||||
filters: $filters,
|
||||
boosts: $boosts,
|
||||
fields: $fields,
|
||||
highlight: $highlight,
|
||||
limit: $limit,
|
||||
offset: $offset,
|
||||
sortBy: $sortBy,
|
||||
sortDirection: $sortDirection,
|
||||
sortByRelevance: $sortByRelevance,
|
||||
enableHighlighting: $enableHighlighting,
|
||||
enableFuzzy: $enableFuzzy,
|
||||
minScore: $minScore
|
||||
);
|
||||
}
|
||||
|
||||
public function validate(): void
|
||||
{
|
||||
if (empty(trim($this->query))) {
|
||||
throw FrameworkException::create(
|
||||
ErrorCode::VAL_BUSINESS_RULE_VIOLATION,
|
||||
'Search query cannot be empty'
|
||||
);
|
||||
}
|
||||
|
||||
if ($this->limit < 1 || $this->limit > 100) {
|
||||
throw FrameworkException::create(
|
||||
ErrorCode::VAL_BUSINESS_RULE_VIOLATION,
|
||||
'Limit must be between 1 and 100'
|
||||
);
|
||||
}
|
||||
|
||||
if ($this->offset < 0) {
|
||||
throw FrameworkException::create(
|
||||
ErrorCode::VAL_BUSINESS_RULE_VIOLATION,
|
||||
'Offset cannot be negative'
|
||||
);
|
||||
}
|
||||
|
||||
if ($this->minScore < 0) {
|
||||
throw FrameworkException::create(
|
||||
ErrorCode::VAL_BUSINESS_RULE_VIOLATION,
|
||||
'Minimum score cannot be negative'
|
||||
);
|
||||
}
|
||||
|
||||
// Validate filters
|
||||
foreach ($this->filters as $field => $filterData) {
|
||||
if (! is_array($filterData) || ! isset($filterData['type'])) {
|
||||
throw FrameworkException::create(
|
||||
ErrorCode::VAL_BUSINESS_RULE_VIOLATION,
|
||||
"Invalid filter format for field '{$field}'"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Validate boosts
|
||||
foreach ($this->boosts as $field => $boost) {
|
||||
if (! is_numeric($boost) || $boost < 0) {
|
||||
throw FrameworkException::create(
|
||||
ErrorCode::VAL_BUSINESS_RULE_VIOLATION,
|
||||
"Invalid boost value for field '{$field}'. Must be a positive number"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public function toArray(): array
|
||||
{
|
||||
return [
|
||||
'query' => $this->query,
|
||||
'filters' => $this->filters,
|
||||
'boosts' => $this->boosts,
|
||||
'fields' => $this->fields,
|
||||
'highlight' => $this->highlight,
|
||||
'limit' => $this->limit,
|
||||
'offset' => $this->offset,
|
||||
'sort_by' => $this->sortBy,
|
||||
'sort_direction' => $this->sortDirection,
|
||||
'sort_by_relevance' => $this->sortByRelevance,
|
||||
'enable_highlighting' => $this->enableHighlighting,
|
||||
'enable_fuzzy' => $this->enableFuzzy,
|
||||
'min_score' => $this->minScore,
|
||||
];
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user