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:
2025-08-11 20:13:26 +02:00
parent 59fd3dd3b1
commit 55a330b223
3683 changed files with 2956207 additions and 16948 deletions

View 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,
];
}
}

View 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);
}
}

View 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,
];
}
}