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,91 @@
<?php
declare(strict_types=1);
namespace App\Framework\Search;
use App\Framework\Core\ValueObjects\Timestamp;
/**
* Result of bulk indexing operation
*/
final readonly class BulkIndexResult
{
/**
* @param array<string> $successfulIds
* @param array<string> $failedIds
* @param array<string, string> $errors
*/
public function __construct(
public int $total,
public int $successful,
public int $failed,
public Timestamp $startedAt,
public Timestamp $finishedAt,
public array $successfulIds = [],
public array $failedIds = [],
public array $errors = []
) {
}
public function isFullySuccessful(): bool
{
return $this->failed === 0;
}
public function hasErrors(): bool
{
return $this->failed > 0;
}
public function hasPartialSuccess(): bool
{
return $this->successful > 0 && $this->failed > 0;
}
public function getSuccessRate(): float
{
if ($this->total === 0) {
return 0.0;
}
return ($this->successful / $this->total) * 100;
}
public function getDurationMs(): float
{
return $this->finishedAt->toFloat() - $this->startedAt->toFloat();
}
public function toArray(): array
{
return [
'total' => $this->total,
'successful' => $this->successful,
'failed' => $this->failed,
'successful_ids' => $this->successfulIds,
'failed_ids' => $this->failedIds,
'errors' => $this->errors,
'started_at' => $this->startedAt->toISOString(),
'finished_at' => $this->finishedAt->toISOString(),
'duration_ms' => $this->getDurationMs(),
'success_rate' => $this->getSuccessRate(),
];
}
public static function empty(): self
{
$now = Timestamp::now();
return new self(
total: 0,
successful: 0,
failed: 0,
startedAt: $now,
finishedAt: $now,
successfulIds: [],
failedIds: [],
errors: []
);
}
}

View File

@@ -0,0 +1,372 @@
<?php
declare(strict_types=1);
namespace App\Framework\Search\Engines;
use App\Framework\Core\ValueObjects\Byte;
use App\Framework\Core\ValueObjects\Timestamp;
use App\Framework\Database\Connection;
use App\Framework\Exception\ErrorCode;
use App\Framework\Exception\FrameworkException;
use App\Framework\Search\BulkIndexResult;
use App\Framework\Search\SearchIndexConfig;
use App\Framework\Search\SearchIndexManager;
use App\Framework\Search\SearchIndexStats;
/**
* Database-based search index manager
*/
final readonly class DatabaseIndexManager implements SearchIndexManager
{
public function __construct(
private Connection $connection,
private DatabaseSearchConfig $config = new DatabaseSearchConfig()
) {
}
public function createIndex(string $entityType, SearchIndexConfig $config): bool
{
try {
$tableName = $this->config->getTableName($entityType);
// Drop existing table if exists
$this->connection->exec("DROP TABLE IF EXISTS {$tableName}");
// Build CREATE TABLE SQL
$sql = $this->buildCreateTableSQL($tableName, $config);
$this->connection->exec($sql);
// Create metadata entry
$this->saveIndexConfig($entityType, $config);
return true;
} catch (\Exception $e) {
throw FrameworkException::create(
ErrorCode::DB_QUERY_FAILED,
'Failed to create search index: ' . $e->getMessage()
)->withData([
'entity_type' => $entityType,
'error' => $e->getMessage(),
]);
}
}
public function deleteIndex(string $entityType): bool
{
try {
$tableName = $this->config->getTableName($entityType);
// Drop the table
$this->connection->exec("DROP TABLE IF EXISTS {$tableName}");
// Remove metadata
$this->removeIndexConfig($entityType);
return true;
} catch (\Exception $e) {
throw FrameworkException::create(
ErrorCode::DB_QUERY_FAILED,
'Failed to delete search index: ' . $e->getMessage()
)->withData([
'entity_type' => $entityType,
'error' => $e->getMessage(),
]);
}
}
public function indexExists(string $entityType): bool
{
try {
$tableName = $this->config->getTableName($entityType);
$sql = "SELECT COUNT(*) FROM information_schema.tables
WHERE table_schema = DATABASE() AND table_name = ?";
$statement = $this->connection->prepare($sql);
$statement->execute([$tableName]);
return (int) $statement->fetchColumn() > 0;
} catch (\Exception) {
return false;
}
}
public function getIndexConfig(string $entityType): ?SearchIndexConfig
{
try {
$this->ensureMetadataTableExists();
$sql = "SELECT config FROM search_index_metadata WHERE entity_type = ?";
$statement = $this->connection->prepare($sql);
$statement->execute([$entityType]);
$configData = $statement->fetchColumn();
if (! $configData) {
return null;
}
return $this->deserializeIndexConfig(json_decode($configData, true));
} catch (\Exception) {
return null;
}
}
public function updateIndex(string $entityType, SearchIndexConfig $config): bool
{
// For database backend, updating means recreating the table
return $this->createIndex($entityType, $config);
}
public function reindex(string $entityType): BulkIndexResult
{
$startedAt = Timestamp::now();
try {
$tableName = $this->config->getTableName($entityType);
// Get current document count
$sql = "SELECT COUNT(*) FROM {$tableName}";
$statement = $this->connection->prepare($sql);
$statement->execute();
$totalDocuments = (int) $statement->fetchColumn();
// Rebuild FULLTEXT indexes
$this->connection->exec("ALTER TABLE {$tableName} DROP INDEX title");
$this->connection->exec("ALTER TABLE {$tableName} ADD FULLTEXT(title, content)");
$finishedAt = Timestamp::now();
return new BulkIndexResult(
total: $totalDocuments,
successful: $totalDocuments,
failed: 0,
startedAt: $startedAt,
finishedAt: $finishedAt,
successfulIds: [],
failedIds: [],
errors: []
);
} catch (\Exception $e) {
$finishedAt = Timestamp::now();
return new BulkIndexResult(
total: 0,
successful: 0,
failed: 1,
startedAt: $startedAt,
finishedAt: $finishedAt,
successfulIds: [],
failedIds: [],
errors: ['reindex' => $e->getMessage()]
);
}
}
public function listIndexes(): array
{
try {
$this->ensureMetadataTableExists();
$sql = "SELECT entity_type, config FROM search_index_metadata";
$statement = $this->connection->prepare($sql);
$statement->execute();
$indexes = [];
while ($row = $statement->fetch()) {
$config = $this->deserializeIndexConfig(json_decode($row['config'], true));
$indexes[$row['entity_type']] = $config;
}
return $indexes;
} catch (\Exception $e) {
throw FrameworkException::create(
ErrorCode::DB_QUERY_FAILED,
'Failed to list search indexes: ' . $e->getMessage()
);
}
}
public function optimizeIndex(string $entityType): bool
{
try {
$tableName = $this->config->getTableName($entityType);
// Optimize table
$this->connection->exec("OPTIMIZE TABLE {$tableName}");
return true;
} catch (\Exception $e) {
throw FrameworkException::create(
ErrorCode::DB_QUERY_FAILED,
'Failed to optimize search index: ' . $e->getMessage()
)->withData([
'entity_type' => $entityType,
'error' => $e->getMessage(),
]);
}
}
public function getIndexStats(string $entityType): ?SearchIndexStats
{
try {
$tableName = $this->config->getTableName($entityType);
// Get table statistics
$sql = "SELECT table_rows, data_length, create_time
FROM information_schema.tables
WHERE table_schema = DATABASE() AND table_name = ?";
$statement = $this->connection->prepare($sql);
$statement->execute([$tableName]);
$tableInfo = $statement->fetch();
if (! $tableInfo) {
return null;
}
// Get last indexed timestamp
$sql = "SELECT MAX(indexed_at) as last_indexed FROM {$tableName}";
$statement = $this->connection->prepare($sql);
$statement->execute();
$lastIndexed = $statement->fetchColumn();
return new SearchIndexStats(
entityType: $entityType,
documentCount: (int) $tableInfo['table_rows'],
indexSize: Byte::fromBytes((int) $tableInfo['data_length']),
queryCount: 0, // Not tracked in database backend
averageQueryTimeMs: 0.0, // Not tracked in database backend
lastIndexed: $lastIndexed ? Timestamp::fromString($lastIndexed) : Timestamp::now(),
createdAt: $tableInfo['create_time'] ? Timestamp::fromString($tableInfo['create_time']) : Timestamp::now()
);
} catch (\Exception $e) {
throw FrameworkException::create(
ErrorCode::DB_QUERY_FAILED,
'Failed to get index statistics: ' . $e->getMessage()
)->withData([
'entity_type' => $entityType,
'error' => $e->getMessage(),
]);
}
}
private function buildCreateTableSQL(string $tableName, SearchIndexConfig $config): string
{
$sql = "CREATE TABLE {$tableName} (";
$sql .= "id INT AUTO_INCREMENT PRIMARY KEY,";
$sql .= "entity_id VARCHAR(255) NOT NULL UNIQUE,";
// Add configured fields
foreach ($config->fields as $fieldName => $fieldConfig) {
$sql .= $this->getColumnDefinition($fieldName, $fieldConfig) . ",";
}
$sql .= "indexed_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,";
// Add FULLTEXT index for searchable text fields
$textFields = [];
foreach ($config->fields as $fieldName => $fieldConfig) {
if ($fieldConfig->isSearchable && $fieldConfig->isText()) {
$textFields[] = $fieldName;
}
}
if (! empty($textFields)) {
$sql .= "FULLTEXT(" . implode(', ', $textFields) . "),";
}
// Remove trailing comma and close
$sql = rtrim($sql, ',') . ")";
return $sql;
}
private function getColumnDefinition(string $fieldName, $fieldConfig): string
{
return match ($fieldConfig->type) {
\App\Framework\Search\SearchFieldType::TEXT => "{$fieldName} TEXT",
\App\Framework\Search\SearchFieldType::KEYWORD => "{$fieldName} VARCHAR(255)",
\App\Framework\Search\SearchFieldType::INTEGER => "{$fieldName} INT",
\App\Framework\Search\SearchFieldType::LONG => "{$fieldName} BIGINT",
\App\Framework\Search\SearchFieldType::FLOAT => "{$fieldName} FLOAT",
\App\Framework\Search\SearchFieldType::DOUBLE => "{$fieldName} DOUBLE",
\App\Framework\Search\SearchFieldType::BOOLEAN => "{$fieldName} BOOLEAN",
\App\Framework\Search\SearchFieldType::DATE => "{$fieldName} DATE",
\App\Framework\Search\SearchFieldType::DATETIME => "{$fieldName} DATETIME",
\App\Framework\Search\SearchFieldType::TIMESTAMP => "{$fieldName} TIMESTAMP",
default => "{$fieldName} JSON",
};
}
private function ensureMetadataTableExists(): void
{
$sql = "CREATE TABLE IF NOT EXISTS search_index_metadata (
entity_type VARCHAR(255) PRIMARY KEY,
config JSON NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
)";
$this->connection->exec($sql);
}
private function saveIndexConfig(string $entityType, SearchIndexConfig $config): void
{
$this->ensureMetadataTableExists();
$sql = "INSERT INTO search_index_metadata (entity_type, config)
VALUES (?, ?)
ON DUPLICATE KEY UPDATE config = VALUES(config), updated_at = CURRENT_TIMESTAMP";
$statement = $this->connection->prepare($sql);
$statement->execute([
$entityType,
json_encode($config->toArray()),
]);
}
private function removeIndexConfig(string $entityType): void
{
$this->ensureMetadataTableExists();
$sql = "DELETE FROM search_index_metadata WHERE entity_type = ?";
$statement = $this->connection->prepare($sql);
$statement->execute([$entityType]);
}
private function deserializeIndexConfig(array $data): SearchIndexConfig
{
$fields = [];
foreach ($data['fields'] as $fieldName => $fieldData) {
$fields[$fieldName] = new \App\Framework\Search\SearchFieldConfig(
type: \App\Framework\Search\SearchFieldType::from($fieldData['type']),
isSearchable: $fieldData['is_searchable'],
isFilterable: $fieldData['is_filterable'],
isSortable: $fieldData['is_sortable'],
isHighlightable: $fieldData['is_highlightable'],
boost: $fieldData['boost'],
analyzer: $fieldData['analyzer'],
format: $fieldData['format'],
options: $fieldData['options']
);
}
return new SearchIndexConfig(
entityType: $data['entity_type'],
fields: $fields,
settings: $data['settings'],
enabled: $data['enabled']
);
}
}

View File

@@ -0,0 +1,46 @@
<?php
declare(strict_types=1);
namespace App\Framework\Search\Engines;
/**
* Configuration for database search engine
*/
final readonly class DatabaseSearchConfig
{
/**
* @param array<string, string> $fieldMappings
* @param array<string> $fulltextFields
*/
public function __construct(
public string $tablePrefix = 'search_',
public array $fieldMappings = [
'title' => 'title',
'content' => 'content',
'metadata' => 'metadata',
],
public array $fulltextFields = ['title', 'content'],
public bool $enableHighlighting = true,
public string $highlightTag = 'em',
public int $maxHighlightLength = 200,
public bool $enableFuzzySearch = false,
public float $minimumScoreThreshold = 0.1
) {
}
public function getTableName(string $entityType): string
{
return $this->tablePrefix . strtolower($entityType);
}
public function getHighlightOpenTag(): string
{
return "<{$this->highlightTag}>";
}
public function getHighlightCloseTag(): string
{
return "</{$this->highlightTag}>";
}
}

View File

@@ -0,0 +1,492 @@
<?php
declare(strict_types=1);
namespace App\Framework\Search\Engines;
use App\Framework\Core\ValueObjects\Byte;
use App\Framework\Core\ValueObjects\Timestamp;
use App\Framework\Database\Connection;
use App\Framework\Exception\ErrorCode;
use App\Framework\Exception\FrameworkException;
use App\Framework\Search\BulkIndexResult;
use App\Framework\Search\SearchEngine;
use App\Framework\Search\SearchEngineStats;
use App\Framework\Search\SearchFilterType;
use App\Framework\Search\SearchHit;
use App\Framework\Search\SearchQuery;
use App\Framework\Search\SearchResult;
/**
* Database-based search engine using FULLTEXT indexes
* Supports MySQL and PostgreSQL
*/
final readonly class DatabaseSearchEngine implements SearchEngine
{
public function __construct(
private Connection $connection,
private DatabaseSearchConfig $config = new DatabaseSearchConfig()
) {
}
public function search(SearchQuery $query): SearchResult
{
$startTime = microtime(true);
try {
$sql = $this->buildSearchSQL($query);
$params = $this->buildSearchParams($query);
$statement = $this->connection->prepare($sql);
$statement->execute($params);
$results = $statement->fetchAll();
$hits = $this->convertToHits($results, $query);
$total = $this->getTotalCount($query);
$maxScore = $this->getMaxScore($hits);
$took = (microtime(true) - $startTime) * 1000;
return new SearchResult(
hits: $hits,
total: $total,
maxScore: $maxScore,
took: $took
);
} catch (\Exception $e) {
throw FrameworkException::create(
ErrorCode::DB_QUERY_FAILED,
'Search query failed: ' . $e->getMessage()
)->withData([
'entity_type' => $query->entityType,
'query' => $query->query,
'error' => $e->getMessage(),
]);
}
}
public function index(string $entityType, string $id, array $document): bool
{
try {
$tableName = $this->getSearchTableName($entityType);
$this->ensureSearchTableExists($entityType);
// Convert document to searchable format
$searchData = $this->prepareDocumentForIndexing($document);
$sql = "INSERT INTO {$tableName} (entity_id, title, content, metadata, indexed_at)
VALUES (?, ?, ?, ?, ?)
ON DUPLICATE KEY UPDATE
title = VALUES(title),
content = VALUES(content),
metadata = VALUES(metadata),
indexed_at = VALUES(indexed_at)";
$statement = $this->connection->prepare($sql);
return $statement->execute([
$id,
$searchData['title'],
$searchData['content'],
json_encode($searchData['metadata']),
date('Y-m-d H:i:s'),
]);
} catch (\Exception $e) {
throw FrameworkException::create(
ErrorCode::DB_QUERY_FAILED,
'Failed to index document: ' . $e->getMessage()
)->withData([
'entity_type' => $entityType,
'entity_id' => $id,
'error' => $e->getMessage(),
]);
}
}
public function delete(string $entityType, string $id): bool
{
try {
$tableName = $this->getSearchTableName($entityType);
$sql = "DELETE FROM {$tableName} WHERE entity_id = ?";
$statement = $this->connection->prepare($sql);
return $statement->execute([$id]);
} catch (\Exception $e) {
throw FrameworkException::create(
ErrorCode::DB_QUERY_FAILED,
'Failed to delete document: ' . $e->getMessage()
)->withData([
'entity_type' => $entityType,
'entity_id' => $id,
'error' => $e->getMessage(),
]);
}
}
public function update(string $entityType, string $id, array $document): bool
{
// For database backend, update is the same as index (INSERT ... ON DUPLICATE KEY UPDATE)
return $this->index($entityType, $id, $document);
}
public function bulkIndex(array $documents): BulkIndexResult
{
$startedAt = Timestamp::now();
$successful = 0;
$failed = 0;
$successfulIds = [];
$failedIds = [];
$errors = [];
foreach ($documents as $document) {
try {
if ($this->index($document->entityType, $document->id, $document->data)) {
$successful++;
$successfulIds[] = $document->id;
} else {
$failed++;
$failedIds[] = $document->id;
$errors[$document->id] = 'Index operation returned false';
}
} catch (\Exception $e) {
$failed++;
$failedIds[] = $document->id;
$errors[$document->id] = $e->getMessage();
}
}
$finishedAt = Timestamp::now();
return new BulkIndexResult(
total: count($documents),
successful: $successful,
failed: $failed,
startedAt: $startedAt,
finishedAt: $finishedAt,
successfulIds: $successfulIds,
failedIds: $failedIds,
errors: $errors
);
}
public function getStats(): SearchEngineStats
{
try {
$stats = [
'total_documents' => 0,
'index_counts' => [],
'disk_usage_bytes' => 0,
];
// Get table statistics
$sql = "SELECT table_name, table_rows, data_length
FROM information_schema.tables
WHERE table_schema = DATABASE()
AND table_name LIKE 'search_%'";
$statement = $this->connection->prepare($sql);
$statement->execute();
$tables = $statement->fetchAll();
foreach ($tables as $table) {
$entityType = str_replace('search_', '', $table['table_name']);
$stats['index_counts'][$entityType] = (int) $table['table_rows'];
$stats['total_documents'] += (int) $table['table_rows'];
$stats['disk_usage_bytes'] += (int) $table['data_length'];
}
return new SearchEngineStats(
engineName: 'Database',
version: $this->connection->getAttribute(\PDO::ATTR_SERVER_VERSION),
isHealthy: $this->isAvailable(),
totalDocuments: $stats['total_documents'],
indexCounts: $stats['index_counts'],
diskUsage: Byte::fromBytes($stats['disk_usage_bytes']),
memoryUsage: Byte::fromBytes(0), // N/A for database
averageQueryTimeMs: 0.0, // Could be tracked separately
totalQueries: 0, // Could be tracked separately
lastUpdated: Timestamp::now()
);
} catch (\Exception $e) {
throw FrameworkException::create(
ErrorCode::DB_QUERY_FAILED,
'Failed to get search statistics: ' . $e->getMessage()
);
}
}
public function isAvailable(): bool
{
try {
$statement = $this->connection->prepare('SELECT 1');
$statement->execute();
return true;
} catch (\Exception) {
return false;
}
}
public function getName(): string
{
return 'Database Search Engine';
}
private function buildSearchSQL(SearchQuery $query): string
{
$tableName = $this->getSearchTableName($query->entityType);
$sql = "SELECT entity_id, title, content, metadata,
MATCH(title, content) AGAINST(? IN BOOLEAN MODE) as relevance_score";
$sql .= " FROM {$tableName}";
$sql .= " WHERE MATCH(title, content) AGAINST(? IN BOOLEAN MODE)";
// Add filters
if ($query->hasFilters()) {
foreach ($query->filters as $field => $filter) {
$sql .= " AND " . $this->buildFilterSQL($field, $filter);
}
}
// Add minimum score filter
if ($query->minScore > 0) {
$sql .= " AND MATCH(title, content) AGAINST(? IN BOOLEAN MODE) >= ?";
}
// Add sorting
if (! empty($query->sortBy->fields)) {
$orderClauses = [];
foreach ($query->sortBy->fields as $sortField) {
if ($sortField->field === '_score') {
$orderClauses[] = "relevance_score " . $sortField->direction->value;
} else {
$orderClauses[] = "JSON_EXTRACT(metadata, '$.{$sortField->field}') " . $sortField->direction->value;
}
}
$sql .= " ORDER BY " . implode(', ', $orderClauses);
} else {
$sql .= " ORDER BY relevance_score DESC";
}
// Add limit and offset
$sql .= " LIMIT ? OFFSET ?";
return $sql;
}
private function buildSearchParams(SearchQuery $query): array
{
$params = [
$this->formatQueryForFulltext($query->query), // For SELECT relevance score
$this->formatQueryForFulltext($query->query), // For WHERE clause
];
// Add filter parameters
if ($query->hasFilters()) {
foreach ($query->filters as $filter) {
$params = array_merge($params, $this->getFilterParams($filter));
}
}
// Add minimum score parameters
if ($query->minScore > 0) {
$params[] = $this->formatQueryForFulltext($query->query);
$params[] = $query->minScore;
}
// Add limit and offset
$params[] = $query->limit;
$params[] = $query->offset;
return $params;
}
private function formatQueryForFulltext(string $query): string
{
// Convert to MySQL BOOLEAN MODE format
$query = trim($query);
if ($query === '*') {
return '*';
}
// Split into words and add + prefix for AND behavior
$words = preg_split('/\s+/', $query);
$formattedWords = array_map(fn ($word) => '+' . $word . '*', $words);
return implode(' ', $formattedWords);
}
private function buildFilterSQL(string $field, $filter): string
{
return match ($filter->type) {
SearchFilterType::EQUALS => "JSON_EXTRACT(metadata, '$.{$field}') = ?",
SearchFilterType::IN => "JSON_EXTRACT(metadata, '$.{$field}') IN (" . str_repeat('?,', count($filter->value) - 1) . "?)",
SearchFilterType::GREATER_THAN => "JSON_EXTRACT(metadata, '$.{$field}') > ?",
SearchFilterType::LESS_THAN => "JSON_EXTRACT(metadata, '$.{$field}') < ?",
SearchFilterType::CONTAINS => "JSON_EXTRACT(metadata, '$.{$field}') LIKE ?",
SearchFilterType::EXISTS => "JSON_EXTRACT(metadata, '$.{$field}') IS NOT NULL",
SearchFilterType::NOT_EXISTS => "JSON_EXTRACT(metadata, '$.{$field}') IS NULL",
default => throw FrameworkException::create(
ErrorCode::VAL_BUSINESS_RULE_VIOLATION,
"Unsupported filter type: {$filter->type->value}"
),
};
}
private function getFilterParams($filter): array
{
return match ($filter->type) {
SearchFilterType::EQUALS => [$filter->value],
SearchFilterType::IN => $filter->value,
SearchFilterType::GREATER_THAN => [$filter->value],
SearchFilterType::LESS_THAN => [$filter->value],
SearchFilterType::CONTAINS => ['%' . $filter->value . '%'],
SearchFilterType::EXISTS, SearchFilterType::NOT_EXISTS => [],
default => [],
};
}
private function convertToHits(array $results, SearchQuery $query): array
{
$hits = [];
foreach ($results as $result) {
$metadata = json_decode($result['metadata'] ?? '{}', true);
$source = array_merge($metadata, [
'title' => $result['title'],
'content' => $result['content'],
]);
$highlight = [];
if ($query->enableHighlighting) {
$highlight = $this->generateHighlights($result, $query->query);
}
$hits[] = new SearchHit(
id: $result['entity_id'],
entityType: $query->entityType,
score: (float) ($result['relevance_score'] ?? 0.0),
source: $source,
highlight: $highlight
);
}
return $hits;
}
private function generateHighlights(array $result, string $query): array
{
$highlights = [];
// Simple highlighting - can be enhanced
$searchTerms = explode(' ', $query);
foreach (['title', 'content'] as $field) {
if (isset($result[$field])) {
$highlighted = $result[$field];
foreach ($searchTerms as $term) {
$term = trim($term);
if (! empty($term)) {
$highlighted = preg_replace(
'/(' . preg_quote($term, '/') . ')/i',
'<em>$1</em>',
$highlighted
);
}
}
if ($highlighted !== $result[$field]) {
$highlights[$field] = [$highlighted];
}
}
}
return $highlights;
}
private function getTotalCount(SearchQuery $query): int
{
$tableName = $this->getSearchTableName($query->entityType);
$sql = "SELECT COUNT(*) FROM {$tableName} WHERE MATCH(title, content) AGAINST(? IN BOOLEAN MODE)";
$params = [$this->formatQueryForFulltext($query->query)];
if ($query->hasFilters()) {
foreach ($query->filters as $field => $filter) {
$sql .= " AND " . $this->buildFilterSQL($field, $filter);
$params = array_merge($params, $this->getFilterParams($filter));
}
}
$statement = $this->connection->prepare($sql);
$statement->execute($params);
return (int) $statement->fetchColumn();
}
private function getMaxScore(array $hits): float
{
if (empty($hits)) {
return 0.0;
}
return max(array_map(fn ($hit) => $hit->score, $hits));
}
private function getSearchTableName(string $entityType): string
{
return 'search_' . strtolower($entityType);
}
private function ensureSearchTableExists(string $entityType): void
{
$tableName = $this->getSearchTableName($entityType);
$sql = "CREATE TABLE IF NOT EXISTS {$tableName} (
id INT AUTO_INCREMENT PRIMARY KEY,
entity_id VARCHAR(255) NOT NULL UNIQUE,
title TEXT NOT NULL,
content LONGTEXT NOT NULL,
metadata JSON,
indexed_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FULLTEXT(title, content)
)";
$this->connection->exec($sql);
}
private function prepareDocumentForIndexing(array $document): array
{
// Extract searchable fields
$title = $document['title'] ?? $document['name'] ?? '';
$content = $document['content'] ?? $document['description'] ?? '';
// Combine all text fields for content
$textFields = [];
foreach ($document as $key => $value) {
if (is_string($value) && ! in_array($key, ['title', 'name'])) {
$textFields[] = $value;
}
}
if (empty($content)) {
$content = implode(' ', $textFields);
}
// Store remaining fields as metadata
$metadata = $document;
unset($metadata['title'], $metadata['name'], $metadata['content'], $metadata['description']);
return [
'title' => $title,
'content' => $content,
'metadata' => $metadata,
];
}
}

View File

@@ -0,0 +1,61 @@
<?php
declare(strict_types=1);
namespace App\Framework\Search;
/**
* Represents a document to be indexed
*/
final readonly class SearchDocument
{
/**
* @param array<string, mixed> $data
* @param array<string, mixed> $metadata
*/
public function __construct(
public string $id,
public string $entityType,
public array $data,
public array $metadata = []
) {
}
public function hasMetadata(): bool
{
return ! empty($this->metadata);
}
public function withMetadata(string $key, mixed $value): self
{
$metadata = $this->metadata;
$metadata[$key] = $value;
return new self(
$this->id,
$this->entityType,
$this->data,
$metadata
);
}
public function withData(array $data): self
{
return new self(
$this->id,
$this->entityType,
$data,
$this->metadata
);
}
public function toArray(): array
{
return [
'id' => $this->id,
'entity_type' => $this->entityType,
'data' => $this->data,
'metadata' => $this->metadata,
];
}
}

View File

@@ -0,0 +1,54 @@
<?php
declare(strict_types=1);
namespace App\Framework\Search;
/**
* Core search engine interface
* Provides backend-agnostic search functionality
*/
interface SearchEngine
{
/**
* Execute a search query
*/
public function search(SearchQuery $query): SearchResult;
/**
* Index a document for searching
*/
public function index(string $entityType, string $id, array $document): bool;
/**
* Remove document from search index
*/
public function delete(string $entityType, string $id): bool;
/**
* Update existing document in search index
*/
public function update(string $entityType, string $id, array $document): bool;
/**
* Bulk index multiple documents
*
* @param SearchDocument[] $documents
*/
public function bulkIndex(array $documents): BulkIndexResult;
/**
* Get search statistics and health
*/
public function getStats(): SearchEngineStats;
/**
* Check if the search engine is available
*/
public function isAvailable(): bool;
/**
* Get engine name/type
*/
public function getName(): string;
}

View File

@@ -0,0 +1,70 @@
<?php
declare(strict_types=1);
namespace App\Framework\Search;
use App\Framework\Core\ValueObjects\Byte;
use App\Framework\Core\ValueObjects\Timestamp;
/**
* Search engine statistics and health information
*/
final readonly class SearchEngineStats
{
/**
* @param array<string, int> $indexCounts
* @param array<string, mixed> $metadata
*/
public function __construct(
public string $engineName,
public string $version,
public bool $isHealthy,
public int $totalDocuments,
public array $indexCounts,
public Byte $diskUsage,
public Byte $memoryUsage,
public float $averageQueryTimeMs,
public int $totalQueries,
public Timestamp $lastUpdated,
public array $metadata = []
) {
}
public function hasIndexes(): bool
{
return ! empty($this->indexCounts);
}
public function getIndexCount(): int
{
return count($this->indexCounts);
}
public function getDocumentsForIndex(string $index): int
{
return $this->indexCounts[$index] ?? 0;
}
public function toArray(): array
{
return [
'engine_name' => $this->engineName,
'version' => $this->version,
'is_healthy' => $this->isHealthy,
'total_documents' => $this->totalDocuments,
'index_counts' => $this->indexCounts,
'index_count' => $this->getIndexCount(),
'disk_usage_bytes' => $this->diskUsage->toBytes(),
'disk_usage_mb' => $this->diskUsage->toMegaBytes(),
'disk_usage_gb' => $this->diskUsage->toGigaBytes(),
'memory_usage_bytes' => $this->memoryUsage->toBytes(),
'memory_usage_mb' => $this->memoryUsage->toMegaBytes(),
'memory_usage_gb' => $this->memoryUsage->toGigaBytes(),
'average_query_time_ms' => $this->averageQueryTimeMs,
'total_queries' => $this->totalQueries,
'last_updated' => $this->lastUpdated->toISOString(),
'metadata' => $this->metadata,
];
}
}

View File

@@ -0,0 +1,84 @@
<?php
declare(strict_types=1);
namespace App\Framework\Search;
use App\Framework\Attributes\EventListener;
/**
* Event listener for automatic search indexing without modifying entities
*/
final readonly class SearchEventListener
{
public function __construct(
private SearchIndexingService $indexingService
) {
}
/**
* Handle entity creation events
*/
#[EventListener(event: 'entity.created')]
public function onEntityCreated(object $event): void
{
if (method_exists($event, 'getEntity')) {
$entity = $event->getEntity();
if ($this->indexingService->shouldAutoIndex($entity)) {
$this->indexingService->indexEntity($entity);
}
}
}
/**
* Handle entity update events
*/
#[EventListener(event: 'entity.updated')]
public function onEntityUpdated(object $event): void
{
if (method_exists($event, 'getEntity')) {
$entity = $event->getEntity();
if ($this->indexingService->shouldAutoIndex($entity)) {
$this->indexingService->updateEntity($entity);
}
}
}
/**
* Handle entity deletion events
*/
#[EventListener(event: 'entity.deleted')]
public function onEntityDeleted(object $event): void
{
if (method_exists($event, 'getEntity')) {
$entity = $event->getEntity();
if ($this->indexingService->shouldAutoIndex($entity)) {
$this->indexingService->removeEntity($entity);
}
}
}
/**
* Handle bulk entity operations
*/
#[EventListener(event: 'entities.bulk_created')]
public function onBulkEntitiesCreated(object $event): void
{
if (method_exists($event, 'getEntities')) {
$entities = $event->getEntities();
// Filter only auto-indexable entities
$searchableEntities = array_filter(
$entities,
fn ($entity) => $this->indexingService->shouldAutoIndex($entity)
);
if (! empty($searchableEntities)) {
$this->indexingService->bulkIndexEntities($searchableEntities);
}
}
}
}

View File

@@ -0,0 +1,187 @@
<?php
declare(strict_types=1);
namespace App\Framework\Search;
/**
* Configuration for a search field
*/
final readonly class SearchFieldConfig
{
public function __construct(
public SearchFieldType $type,
public bool $isSearchable = true,
public bool $isFilterable = true,
public bool $isSortable = true,
public bool $isHighlightable = true,
public float $boost = 1.0,
public ?string $analyzer = null,
public ?string $format = null,
public array $options = []
) {
}
public function isText(): bool
{
return $this->type === SearchFieldType::TEXT;
}
public function isKeyword(): bool
{
return $this->type === SearchFieldType::KEYWORD;
}
public function isNumeric(): bool
{
return in_array($this->type, [
SearchFieldType::INTEGER,
SearchFieldType::FLOAT,
SearchFieldType::DOUBLE,
]);
}
public function isDate(): bool
{
return $this->type === SearchFieldType::DATE;
}
public function isBoosted(): bool
{
return $this->boost !== 1.0;
}
public function withBoost(float $boost): self
{
return new self(
$this->type,
$this->isSearchable,
$this->isFilterable,
$this->isSortable,
$this->isHighlightable,
$boost,
$this->analyzer,
$this->format,
$this->options
);
}
public function withAnalyzer(string $analyzer): self
{
return new self(
$this->type,
$this->isSearchable,
$this->isFilterable,
$this->isSortable,
$this->isHighlightable,
$this->boost,
$analyzer,
$this->format,
$this->options
);
}
public function searchableOnly(): self
{
return new self(
$this->type,
true,
false,
false,
$this->isHighlightable,
$this->boost,
$this->analyzer,
$this->format,
$this->options
);
}
public function filterableOnly(): self
{
return new self(
$this->type,
false,
true,
$this->isSortable,
false,
$this->boost,
$this->analyzer,
$this->format,
$this->options
);
}
public static function text(float $boost = 1.0, ?string $analyzer = null): self
{
return new self(
type: SearchFieldType::TEXT,
boost: $boost,
analyzer: $analyzer
);
}
public static function keyword(bool $sortable = true): self
{
return new self(
type: SearchFieldType::KEYWORD,
isSortable: $sortable,
isHighlightable: false
);
}
public static function integer(bool $sortable = true): self
{
return new self(
type: SearchFieldType::INTEGER,
isSearchable: false,
isSortable: $sortable,
isHighlightable: false
);
}
public static function float(bool $sortable = true): self
{
return new self(
type: SearchFieldType::FLOAT,
isSearchable: false,
isSortable: $sortable,
isHighlightable: false
);
}
public static function date(bool $sortable = true, ?string $format = null): self
{
return new self(
type: SearchFieldType::DATE,
isSearchable: false,
isSortable: $sortable,
isHighlightable: false,
format: $format
);
}
public static function boolean(): self
{
return new self(
type: SearchFieldType::BOOLEAN,
isSearchable: false,
isSortable: false,
isHighlightable: false
);
}
public function toArray(): array
{
return [
'type' => $this->type->value,
'is_searchable' => $this->isSearchable,
'is_filterable' => $this->isFilterable,
'is_sortable' => $this->isSortable,
'is_highlightable' => $this->isHighlightable,
'boost' => $this->boost,
'analyzer' => $this->analyzer,
'format' => $this->format,
'options' => $this->options,
];
}
}

View File

@@ -0,0 +1,30 @@
<?php
declare(strict_types=1);
namespace App\Framework\Search;
/**
* Available search field types
*/
enum SearchFieldType: string
{
case TEXT = 'text';
case KEYWORD = 'keyword';
case INTEGER = 'integer';
case LONG = 'long';
case FLOAT = 'float';
case DOUBLE = 'double';
case BOOLEAN = 'boolean';
case DATE = 'date';
case DATETIME = 'datetime';
case TIMESTAMP = 'timestamp';
case BINARY = 'binary';
case OBJECT = 'object';
case NESTED = 'nested';
case GEO_POINT = 'geo_point';
case GEO_SHAPE = 'geo_shape';
case IP = 'ip';
case COMPLETION = 'completion';
case TOKEN_COUNT = 'token_count';
}

View File

@@ -0,0 +1,72 @@
<?php
declare(strict_types=1);
namespace App\Framework\Search;
/**
* Represents a search filter
*/
final readonly class SearchFilter
{
public function __construct(
public SearchFilterType $type,
public mixed $value,
public ?string $operator = null
) {
}
public static function equals(mixed $value): self
{
return new self(SearchFilterType::EQUALS, $value);
}
public static function in(array $values): self
{
return new self(SearchFilterType::IN, $values);
}
public static function range(mixed $min, mixed $max): self
{
return new self(SearchFilterType::RANGE, ['min' => $min, 'max' => $max]);
}
public static function greaterThan(mixed $value): self
{
return new self(SearchFilterType::GREATER_THAN, $value);
}
public static function lessThan(mixed $value): self
{
return new self(SearchFilterType::LESS_THAN, $value);
}
public static function contains(string $value): self
{
return new self(SearchFilterType::CONTAINS, $value);
}
public static function startsWith(string $value): self
{
return new self(SearchFilterType::STARTS_WITH, $value);
}
public static function exists(): self
{
return new self(SearchFilterType::EXISTS, null);
}
public static function notExists(): self
{
return new self(SearchFilterType::NOT_EXISTS, null);
}
public function toArray(): array
{
return [
'type' => $this->type->value,
'value' => $this->value,
'operator' => $this->operator,
];
}
}

View File

@@ -0,0 +1,27 @@
<?php
declare(strict_types=1);
namespace App\Framework\Search;
/**
* Available search filter types
*/
enum SearchFilterType: string
{
case EQUALS = 'equals';
case IN = 'in';
case RANGE = 'range';
case GREATER_THAN = 'gt';
case GREATER_THAN_OR_EQUAL = 'gte';
case LESS_THAN = 'lt';
case LESS_THAN_OR_EQUAL = 'lte';
case CONTAINS = 'contains';
case STARTS_WITH = 'starts_with';
case ENDS_WITH = 'ends_with';
case EXISTS = 'exists';
case NOT_EXISTS = 'not_exists';
case NOT_EQUALS = 'not_equals';
case NOT_IN = 'not_in';
case REGEX = 'regex';
}

View File

@@ -0,0 +1,82 @@
<?php
declare(strict_types=1);
namespace App\Framework\Search;
/**
* Represents a single search hit
*/
final readonly class SearchHit
{
/**
* @param array<string, mixed> $source
* @param array<string, array<string>> $highlight
* @param array<string, mixed> $metadata
*/
public function __construct(
public string $id,
public string $entityType,
public float $score,
public array $source,
public array $highlight = [],
public array $metadata = []
) {
}
public function hasHighlight(): bool
{
return ! empty($this->highlight);
}
public function getHighlightFor(string $field): ?array
{
return $this->highlight[$field] ?? null;
}
public function hasMetadata(): bool
{
return ! empty($this->metadata);
}
public function getMetadata(string $key): mixed
{
return $this->metadata[$key] ?? null;
}
public function withHighlight(array $highlight): self
{
return new self(
$this->id,
$this->entityType,
$this->score,
$this->source,
$highlight,
$this->metadata
);
}
public function withMetadata(array $metadata): self
{
return new self(
$this->id,
$this->entityType,
$this->score,
$this->source,
$this->highlight,
$metadata
);
}
public function toArray(): array
{
return [
'id' => $this->id,
'entity_type' => $this->entityType,
'score' => $this->score,
'source' => $this->source,
'highlight' => $this->highlight,
'metadata' => $this->metadata,
];
}
}

View File

@@ -0,0 +1,93 @@
<?php
declare(strict_types=1);
namespace App\Framework\Search;
/**
* Configuration for a search index
*/
final readonly class SearchIndexConfig
{
/**
* @param array<string, SearchFieldConfig> $fields
* @param array<string, mixed> $settings
*/
public function __construct(
public string $entityType,
public array $fields,
public array $settings = [],
public bool $enabled = true
) {
}
public function hasField(string $fieldName): bool
{
return isset($this->fields[$fieldName]);
}
public function getField(string $fieldName): ?SearchFieldConfig
{
return $this->fields[$fieldName] ?? null;
}
public function getSearchableFields(): array
{
return array_filter(
$this->fields,
fn ($field) => $field->isSearchable
);
}
public function getFilterableFields(): array
{
return array_filter(
$this->fields,
fn ($field) => $field->isFilterable
);
}
public function getSortableFields(): array
{
return array_filter(
$this->fields,
fn ($field) => $field->isSortable
);
}
public function withField(string $name, SearchFieldConfig $field): self
{
$fields = $this->fields;
$fields[$name] = $field;
return new self(
$this->entityType,
$fields,
$this->settings,
$this->enabled
);
}
public function withSetting(string $key, mixed $value): self
{
$settings = $this->settings;
$settings[$key] = $value;
return new self(
$this->entityType,
$this->fields,
$settings,
$this->enabled
);
}
public function toArray(): array
{
return [
'entity_type' => $this->entityType,
'fields' => array_map(fn ($field) => $field->toArray(), $this->fields),
'settings' => $this->settings,
'enabled' => $this->enabled,
];
}
}

View File

@@ -0,0 +1,58 @@
<?php
declare(strict_types=1);
namespace App\Framework\Search;
/**
* Manages search indexes and configurations
*/
interface SearchIndexManager
{
/**
* Create a new search index
*/
public function createIndex(string $entityType, SearchIndexConfig $config): bool;
/**
* Delete a search index
*/
public function deleteIndex(string $entityType): bool;
/**
* Check if an index exists
*/
public function indexExists(string $entityType): bool;
/**
* Get index configuration
*/
public function getIndexConfig(string $entityType): ?SearchIndexConfig;
/**
* Update index mapping/configuration
*/
public function updateIndex(string $entityType, SearchIndexConfig $config): bool;
/**
* Reindex all documents for an entity type
*/
public function reindex(string $entityType): BulkIndexResult;
/**
* Get all available indexes
*
* @return array<string, SearchIndexConfig>
*/
public function listIndexes(): array;
/**
* Optimize/refresh an index
*/
public function optimizeIndex(string $entityType): bool;
/**
* Get index statistics
*/
public function getIndexStats(string $entityType): ?SearchIndexStats;
}

View File

@@ -0,0 +1,62 @@
<?php
declare(strict_types=1);
namespace App\Framework\Search;
use App\Framework\Core\ValueObjects\Byte;
use App\Framework\Core\ValueObjects\Timestamp;
/**
* Statistics for a specific search index
*/
final readonly class SearchIndexStats
{
public function __construct(
public string $entityType,
public int $documentCount,
public Byte $indexSize,
public int $queryCount,
public float $averageQueryTimeMs,
public Timestamp $lastIndexed,
public Timestamp $createdAt,
public array $metadata = []
) {
}
public function hasDocuments(): bool
{
return $this->documentCount > 0;
}
public function hasQueries(): bool
{
return $this->queryCount > 0;
}
public function getAverageDocumentSize(): float
{
if ($this->documentCount === 0) {
return 0.0;
}
return $this->indexSize->toBytes() / $this->documentCount;
}
public function toArray(): array
{
return [
'entity_type' => $this->entityType,
'document_count' => $this->documentCount,
'index_size_bytes' => $this->indexSize->toBytes(),
'index_size_mb' => $this->indexSize->toMegaBytes(),
'index_size_gb' => $this->indexSize->toGigaBytes(),
'query_count' => $this->queryCount,
'average_query_time_ms' => $this->averageQueryTimeMs,
'average_document_size_bytes' => $this->getAverageDocumentSize(),
'last_indexed' => $this->lastIndexed->toISOString(),
'created_at' => $this->createdAt->toISOString(),
'metadata' => $this->metadata,
];
}
}

View File

@@ -0,0 +1,180 @@
<?php
declare(strict_types=1);
namespace App\Framework\Search;
use App\Framework\Exception\ErrorCode;
use App\Framework\Exception\FrameworkException;
/**
* Service for automatic indexing without modifying entities
*/
final readonly class SearchIndexingService
{
public function __construct(
private SearchService $searchService,
private SearchableMappingRegistry $mappingRegistry
) {
}
/**
* Index a single entity if it's searchable
*/
public function indexEntity(object $entity): bool
{
$adapter = $this->mappingRegistry->createAdapter($entity);
if (! $adapter) {
return false; // Entity not searchable or disabled
}
try {
$document = $adapter->toSearchDocument();
return $this->searchService->index(
$adapter->getEntityType(),
$adapter->getId(),
$document
);
} catch (\Exception $e) {
throw FrameworkException::create(
ErrorCode::SEARCH_INDEX_FAILED,
'Failed to index entity: ' . $e->getMessage()
)->withData([
'entity_type' => $adapter->getEntityType(),
'entity_id' => $adapter->getId(),
'entity_class' => get_class($entity),
'error' => $e->getMessage(),
]);
}
}
/**
* Update entity in search index
*/
public function updateEntity(object $entity): bool
{
$adapter = $this->mappingRegistry->createAdapter($entity);
if (! $adapter) {
return false;
}
try {
$document = $adapter->toSearchDocument();
return $this->searchService->update(
$adapter->getEntityType(),
$adapter->getId(),
$document
);
} catch (\Exception $e) {
throw FrameworkException::create(
ErrorCode::SEARCH_UPDATE_FAILED,
'Failed to update entity in search index: ' . $e->getMessage()
)->withData([
'entity_type' => $adapter->getEntityType(),
'entity_id' => $adapter->getId(),
'entity_class' => get_class($entity),
]);
}
}
/**
* Remove entity from search index
*/
public function removeEntity(object $entity): bool
{
$adapter = $this->mappingRegistry->createAdapter($entity);
if (! $adapter) {
return false;
}
return $this->searchService->delete(
$adapter->getEntityType(),
$adapter->getId()
);
}
/**
* Bulk index multiple entities
*/
public function bulkIndexEntities(array $entities): BulkIndexResult
{
$documents = [];
foreach ($entities as $entity) {
$adapter = $this->mappingRegistry->createAdapter($entity);
if (! $adapter) {
continue; // Skip non-searchable entities
}
try {
$documents[] = new SearchDocument(
id: $adapter->getId(),
entityType: $adapter->getEntityType(),
data: $adapter->toSearchDocument()
);
} catch (\Exception) {
// Skip entities that can't be converted to search documents
continue;
}
}
if (empty($documents)) {
return BulkIndexResult::empty();
}
return $this->searchService->bulkIndex($documents);
}
/**
* Check if entity should be auto-indexed
*/
public function shouldAutoIndex(object $entity): bool
{
$mapping = $this->mappingRegistry->getByEntityClass(get_class($entity));
return $mapping && $mapping->enabled && $mapping->autoIndex;
}
/**
* Get all searchable entity types
*/
public function getSearchableEntityTypes(): array
{
return $this->mappingRegistry->getEntityTypes();
}
/**
* Reindex all entities of a specific type using callback
*/
public function reindexEntityType(string $entityType, callable $entityProvider): BulkIndexResult
{
$mapping = $this->mappingRegistry->getByEntityType($entityType);
if (! $mapping) {
throw FrameworkException::create(
ErrorCode::SEARCH_CONFIG_INVALID,
"No mapping found for entity type '{$entityType}'"
);
}
// Get entities from provider (e.g., repository)
$entities = $entityProvider();
if (! is_array($entities) && ! ($entities instanceof \Traversable)) {
throw FrameworkException::create(
ErrorCode::SEARCH_CONFIG_INVALID,
'Entity provider must return array or Traversable'
);
}
return $this->bulkIndexEntities($entities instanceof \Traversable ? iterator_to_array($entities) : $entities);
}
}

View File

@@ -0,0 +1,162 @@
<?php
declare(strict_types=1);
namespace App\Framework\Search;
/**
* Represents a search query with all parameters
*/
final readonly class SearchQuery
{
/**
* @param array<string, SearchFilter> $filters
* @param array<string, float> $boosts
* @param array<string> $fields
* @param array<string> $highlightFields
*/
public function __construct(
public string $entityType,
public string $query,
public array $filters = [],
public array $boosts = [],
public array $fields = [],
public array $highlightFields = [],
public int $limit = 20,
public int $offset = 0,
public SearchSortBy $sortBy = new SearchSortBy(),
public bool $enableHighlighting = true,
public bool $enableFuzzyMatching = false,
public float $minScore = 0.0
) {
}
public function hasFilters(): bool
{
return ! empty($this->filters);
}
public function hasBoosts(): bool
{
return ! empty($this->boosts);
}
public function hasFieldRestriction(): bool
{
return ! empty($this->fields);
}
public function withLimit(int $limit): self
{
return new self(
$this->entityType,
$this->query,
$this->filters,
$this->boosts,
$this->fields,
$this->highlightFields,
$limit,
$this->offset,
$this->sortBy,
$this->enableHighlighting,
$this->enableFuzzyMatching,
$this->minScore
);
}
public function withOffset(int $offset): self
{
return new self(
$this->entityType,
$this->query,
$this->filters,
$this->boosts,
$this->fields,
$this->highlightFields,
$this->limit,
$offset,
$this->sortBy,
$this->enableHighlighting,
$this->enableFuzzyMatching,
$this->minScore
);
}
public function withFilter(string $field, SearchFilter $filter): self
{
$filters = $this->filters;
$filters[$field] = $filter;
return new self(
$this->entityType,
$this->query,
$filters,
$this->boosts,
$this->fields,
$this->highlightFields,
$this->limit,
$this->offset,
$this->sortBy,
$this->enableHighlighting,
$this->enableFuzzyMatching,
$this->minScore
);
}
public function withBoost(string $field, float $boost): self
{
$boosts = $this->boosts;
$boosts[$field] = $boost;
return new self(
$this->entityType,
$this->query,
$this->filters,
$boosts,
$this->fields,
$this->highlightFields,
$this->limit,
$this->offset,
$this->sortBy,
$this->enableHighlighting,
$this->enableFuzzyMatching,
$this->minScore
);
}
public function withSortBy(SearchSortBy $sortBy): self
{
return new self(
$this->entityType,
$this->query,
$this->filters,
$this->boosts,
$this->fields,
$this->highlightFields,
$this->limit,
$this->offset,
$sortBy,
$this->enableHighlighting,
$this->enableFuzzyMatching,
$this->minScore
);
}
public function toArray(): array
{
return [
'entity_type' => $this->entityType,
'query' => $this->query,
'filters' => array_map(fn ($filter) => $filter->toArray(), $this->filters),
'boosts' => $this->boosts,
'fields' => $this->fields,
'highlight_fields' => $this->highlightFields,
'limit' => $this->limit,
'offset' => $this->offset,
'sort_by' => $this->sortBy->toArray(),
'enable_highlighting' => $this->enableHighlighting,
'enable_fuzzy_matching' => $this->enableFuzzyMatching,
'min_score' => $this->minScore,
];
}
}

View File

@@ -0,0 +1,284 @@
<?php
declare(strict_types=1);
namespace App\Framework\Search;
/**
* Fluent builder for search queries
*/
final class SearchQueryBuilder
{
private string $query = '*';
private array $filters = [];
private array $boosts = [];
private array $fields = [];
private array $highlightFields = [];
private int $limit = 20;
private int $offset = 0;
private SearchSortBy $sortBy;
private bool $enableHighlighting = true;
private bool $enableFuzzyMatching = false;
private float $minScore = 0.0;
public function __construct(
private readonly SearchEngine $engine,
private readonly string $entityType
) {
$this->sortBy = SearchSortBy::relevance();
}
/**
* Set the search query string
*/
public function query(string $query): self
{
$this->query = $query;
return $this;
}
/**
* Add a filter
*/
public function filter(string $field, SearchFilter $filter): self
{
$this->filters[$field] = $filter;
return $this;
}
/**
* Add multiple filters at once
*/
public function filters(array $filters): self
{
foreach ($filters as $field => $value) {
if ($value instanceof SearchFilter) {
$this->filters[$field] = $value;
} else {
$this->filters[$field] = SearchFilter::equals($value);
}
}
return $this;
}
/**
* Add a field boost
*/
public function boost(string $field, float $boost): self
{
$this->boosts[$field] = $boost;
return $this;
}
/**
* Restrict search to specific fields
*/
public function fields(array $fields): self
{
$this->fields = $fields;
return $this;
}
/**
* Set fields to highlight
*/
public function highlight(array $fields): self
{
$this->highlightFields = $fields;
return $this;
}
/**
* Set result limit
*/
public function limit(int $limit): self
{
$this->limit = $limit;
return $this;
}
/**
* Set result offset for pagination
*/
public function offset(int $offset): self
{
$this->offset = $offset;
return $this;
}
/**
* Set pagination (page-based)
*/
public function page(int $page, int $perPage = 20): self
{
$this->limit = $perPage;
$this->offset = ($page - 1) * $perPage;
return $this;
}
/**
* Sort by relevance (default)
*/
public function sortByRelevance(): self
{
$this->sortBy = SearchSortBy::relevance();
return $this;
}
/**
* Sort by a field
*/
public function sortBy(string $field, SearchSortDirection $direction = SearchSortDirection::ASC): self
{
$this->sortBy = SearchSortBy::field($field, $direction);
return $this;
}
/**
* Sort by multiple fields
*/
public function sortByMultiple(SearchSortField ...$fields): self
{
$this->sortBy = SearchSortBy::multiple(...$fields);
return $this;
}
/**
* Enable/disable highlighting
*/
public function highlighting(bool $enable): self
{
$this->enableHighlighting = $enable;
return $this;
}
/**
* Enable/disable fuzzy matching
*/
public function fuzzy(bool $enable): self
{
$this->enableFuzzyMatching = $enable;
return $this;
}
/**
* Set minimum score threshold
*/
public function minScore(float $minScore): self
{
$this->minScore = $minScore;
return $this;
}
/**
* Common filter shortcuts
*/
public function where(string $field, mixed $value): self
{
return $this->filter($field, SearchFilter::equals($value));
}
public function whereIn(string $field, array $values): self
{
return $this->filter($field, SearchFilter::in($values));
}
public function whereRange(string $field, mixed $min, mixed $max): self
{
return $this->filter($field, SearchFilter::range($min, $max));
}
public function whereGreaterThan(string $field, mixed $value): self
{
return $this->filter($field, SearchFilter::greaterThan($value));
}
public function whereLessThan(string $field, mixed $value): self
{
return $this->filter($field, SearchFilter::lessThan($value));
}
public function whereContains(string $field, string $value): self
{
return $this->filter($field, SearchFilter::contains($value));
}
public function whereStartsWith(string $field, string $value): self
{
return $this->filter($field, SearchFilter::startsWith($value));
}
/**
* Build and return the search query
*/
public function build(): SearchQuery
{
return new SearchQuery(
entityType: $this->entityType,
query: $this->query,
filters: $this->filters,
boosts: $this->boosts,
fields: $this->fields,
highlightFields: $this->highlightFields,
limit: $this->limit,
offset: $this->offset,
sortBy: $this->sortBy,
enableHighlighting: $this->enableHighlighting,
enableFuzzyMatching: $this->enableFuzzyMatching,
minScore: $this->minScore
);
}
/**
* Build and execute the search query
*/
public function search(): SearchResult
{
return $this->engine->search($this->build());
}
/**
* Execute search and return only the first result
*/
public function first(): ?SearchHit
{
$result = $this->limit(1)->search();
return $result->hits[0] ?? null;
}
/**
* Count total results without retrieving them
*/
public function count(): int
{
$result = $this->limit(0)->search();
return $result->total;
}
}

View File

@@ -0,0 +1,84 @@
<?php
declare(strict_types=1);
namespace App\Framework\Search;
/**
* Represents search results with metadata
*/
final readonly class SearchResult
{
/**
* @param SearchHit[] $hits
* @param array<string, mixed> $aggregations
*/
public function __construct(
public array $hits,
public int $total,
public float $maxScore,
public float $took, // in milliseconds
public array $aggregations = [],
public ?string $scrollId = null
) {
}
public function isEmpty(): bool
{
return empty($this->hits);
}
public function hasResults(): bool
{
return ! $this->isEmpty();
}
public function getHitCount(): int
{
return count($this->hits);
}
public function hasAggregations(): bool
{
return ! empty($this->aggregations);
}
public function getIds(): array
{
return array_map(fn ($hit) => $hit->id, $this->hits);
}
public function getDocuments(): array
{
return array_map(fn ($hit) => $hit->source, $this->hits);
}
public function getScores(): array
{
return array_map(fn ($hit) => $hit->score, $this->hits);
}
public function withScrollId(string $scrollId): self
{
return new self(
$this->hits,
$this->total,
$this->maxScore,
$this->took,
$this->aggregations,
$scrollId
);
}
public function toArray(): array
{
return [
'hits' => array_map(fn ($hit) => $hit->toArray(), $this->hits),
'total' => $this->total,
'max_score' => $this->maxScore,
'took' => $this->took,
'aggregations' => $this->aggregations,
'scroll_id' => $this->scrollId,
];
}
}

View File

@@ -0,0 +1,99 @@
<?php
declare(strict_types=1);
namespace App\Framework\Search;
/**
* Main search service providing a fluent API
*/
final readonly class SearchService
{
public function __construct(
private SearchEngine $engine,
private SearchIndexManager $indexManager
) {
}
/**
* Create a search query builder for an entity type
*/
public function for(string $entityType): SearchQueryBuilder
{
return new SearchQueryBuilder($this->engine, $entityType);
}
/**
* Execute a pre-built search query
*/
public function search(SearchQuery $query): SearchResult
{
return $this->engine->search($query);
}
/**
* Index a document
*/
public function index(string $entityType, string $id, array $document): bool
{
return $this->engine->index($entityType, $id, $document);
}
/**
* Index multiple documents at once
*
* @param SearchDocument[] $documents
*/
public function bulkIndex(array $documents): BulkIndexResult
{
return $this->engine->bulkIndex($documents);
}
/**
* Delete a document from the search index
*/
public function delete(string $entityType, string $id): bool
{
return $this->engine->delete($entityType, $id);
}
/**
* Update an existing document
*/
public function update(string $entityType, string $id, array $document): bool
{
return $this->engine->update($entityType, $id, $document);
}
/**
* Get search engine statistics
*/
public function getStats(): SearchEngineStats
{
return $this->engine->getStats();
}
/**
* Check if search engine is available
*/
public function isAvailable(): bool
{
return $this->engine->isAvailable();
}
/**
* Get the underlying search engine
*/
public function getEngine(): SearchEngine
{
return $this->engine;
}
/**
* Get the index manager
*/
public function getIndexManager(): SearchIndexManager
{
return $this->indexManager;
}
}

View File

@@ -0,0 +1,56 @@
<?php
declare(strict_types=1);
namespace App\Framework\Search;
/**
* Represents search sorting configuration
*/
final readonly class SearchSortBy
{
/**
* @param array<SearchSortField> $fields
*/
public function __construct(
public array $fields = []
) {
}
public static function relevance(): self
{
return new self([
new SearchSortField('_score', SearchSortDirection::DESC),
]);
}
public static function field(string $field, SearchSortDirection $direction = SearchSortDirection::ASC): self
{
return new self([
new SearchSortField($field, $direction),
]);
}
public static function multiple(SearchSortField ...$fields): self
{
return new self($fields);
}
public function addField(string $field, SearchSortDirection $direction = SearchSortDirection::ASC): self
{
$fields = $this->fields;
$fields[] = new SearchSortField($field, $direction);
return new self($fields);
}
public function hasFields(): bool
{
return ! empty($this->fields);
}
public function toArray(): array
{
return array_map(fn ($field) => $field->toArray(), $this->fields);
}
}

View File

@@ -0,0 +1,14 @@
<?php
declare(strict_types=1);
namespace App\Framework\Search;
/**
* Search sort directions
*/
enum SearchSortDirection: string
{
case ASC = 'asc';
case DESC = 'desc';
}

View File

@@ -0,0 +1,27 @@
<?php
declare(strict_types=1);
namespace App\Framework\Search;
/**
* Represents a single sort field
*/
final readonly class SearchSortField
{
public function __construct(
public string $field,
public SearchSortDirection $direction,
public ?float $boost = null
) {
}
public function toArray(): array
{
return [
'field' => $this->field,
'direction' => $this->direction->value,
'boost' => $this->boost,
];
}
}

View File

@@ -0,0 +1,111 @@
<?php
declare(strict_types=1);
namespace App\Framework\Search;
/**
* Adapter pattern to make any entity searchable without modifying it
*/
final readonly class SearchableAdapter
{
public function __construct(
private object $entity,
private SearchableMapping $mapping
) {
}
public function getEntity(): object
{
return $this->entity;
}
public function getEntityType(): string
{
return $this->mapping->entityType;
}
public function getId(): string
{
$idField = $this->mapping->idField;
if (method_exists($this->entity, $idField)) {
return (string) $this->entity->{$idField}();
}
if (property_exists($this->entity, $idField)) {
return (string) $this->entity->{$idField};
}
throw new \InvalidArgumentException("ID field '{$idField}' not found on entity");
}
public function toSearchDocument(): array
{
$document = [];
foreach ($this->mapping->fieldMappings as $searchField => $mapping) {
$value = $this->extractValue($mapping);
if ($value !== null) {
$document[$searchField] = $value;
}
}
return $document;
}
public function getSearchableFields(): array
{
return array_keys($this->mapping->fieldMappings);
}
public function getBoosts(): array
{
return $this->mapping->boosts;
}
private function extractValue(FieldMapping $mapping): mixed
{
$entity = $this->entity;
// Support for nested field access like "user.email"
$fieldPath = explode('.', $mapping->entityField);
foreach ($fieldPath as $fieldName) {
if ($entity === null) {
return null;
}
// Try method first (getter)
$getterName = 'get' . ucfirst($fieldName);
if (method_exists($entity, $getterName)) {
$entity = $entity->{$getterName}();
continue;
}
// Try direct method call
if (method_exists($entity, $fieldName)) {
$entity = $entity->{$fieldName}();
continue;
}
// Try property access
if (property_exists($entity, $fieldName)) {
$entity = $entity->{$fieldName};
continue;
}
return null;
}
// Apply transformation if specified
if ($mapping->hasTransformer()) {
return $mapping->transform($entity);
}
return $entity;
}
}

View File

@@ -0,0 +1,187 @@
<?php
declare(strict_types=1);
namespace App\Framework\Search;
/**
* Configuration for making entities searchable via external mapping
*/
final readonly class SearchableMapping
{
/**
* @param array<string, FieldMapping> $fieldMappings Search field name => entity field mapping
* @param array<string, float> $boosts Field boost values for relevance scoring
*/
public function __construct(
public string $entityType,
public string $entityClass,
public string $idField,
public array $fieldMappings,
public array $boosts = [],
public bool $autoIndex = true,
public bool $enabled = true
) {
}
public static function for(string $entityClass): SearchableMappingBuilder
{
return new SearchableMappingBuilder($entityClass);
}
public function getSearchFields(): array
{
return array_keys($this->fieldMappings);
}
public function hasField(string $searchField): bool
{
return isset($this->fieldMappings[$searchField]);
}
public function getFieldMapping(string $searchField): ?FieldMapping
{
return $this->fieldMappings[$searchField] ?? null;
}
public function getBoost(string $field): float
{
return $this->boosts[$field] ?? 1.0;
}
public function toArray(): array
{
return [
'entity_type' => $this->entityType,
'entity_class' => $this->entityClass,
'id_field' => $this->idField,
'field_mappings' => array_map(fn ($mapping) => $mapping->toArray(), $this->fieldMappings),
'boosts' => $this->boosts,
'auto_index' => $this->autoIndex,
'enabled' => $this->enabled,
];
}
}
/**
* Builder for creating searchable mappings fluently
*/
final class SearchableMappingBuilder
{
private string $entityType;
private string $idField = 'id';
private array $fieldMappings = [];
private array $boosts = [];
private bool $autoIndex = true;
private bool $enabled = true;
public function __construct(
private readonly string $entityClass
) {
// Default entity type from class name
$parts = explode('\\', $entityClass);
$this->entityType = strtolower(end($parts));
}
public function entityType(string $entityType): self
{
$this->entityType = $entityType;
return $this;
}
public function idField(string $idField): self
{
$this->idField = $idField;
return $this;
}
public function field(string $searchField, string $entityField, ?callable $transformer = null): self
{
$this->fieldMappings[$searchField] = new FieldMapping($entityField, $transformer);
return $this;
}
public function boost(string $field, float $boost): self
{
$this->boosts[$field] = $boost;
return $this;
}
public function autoIndex(bool $autoIndex = true): self
{
$this->autoIndex = $autoIndex;
return $this;
}
public function enabled(bool $enabled = true): self
{
$this->enabled = $enabled;
return $this;
}
public function build(): SearchableMapping
{
return new SearchableMapping(
entityType: $this->entityType,
entityClass: $this->entityClass,
idField: $this->idField,
fieldMappings: $this->fieldMappings,
boosts: $this->boosts,
autoIndex: $this->autoIndex,
enabled: $this->enabled
);
}
}
/**
* Maps a search field to an entity field with optional transformation
*/
final readonly class FieldMapping
{
private ?\Closure $transformerClosure;
public function __construct(
public string $entityField,
?callable $transformer = null
) {
$this->transformerClosure = $transformer ? \Closure::fromCallable($transformer) : null;
}
public function getTransformer(): ?\Closure
{
return $this->transformerClosure;
}
public function hasTransformer(): bool
{
return $this->transformerClosure !== null;
}
public function transform(mixed $value): mixed
{
if ($this->transformerClosure === null) {
return $value;
}
return ($this->transformerClosure)($value);
}
public function toArray(): array
{
return [
'entity_field' => $this->entityField,
'has_transformer' => $this->hasTransformer(),
];
}
}

View File

@@ -0,0 +1,126 @@
<?php
declare(strict_types=1);
namespace App\Framework\Search;
/**
* Central registry for searchable entity mappings
*/
final class SearchableMappingRegistry
{
/** @var array<string, SearchableMapping> */
private array $mappingsByEntityType = [];
/** @var array<string, SearchableMapping> */
private array $mappingsByEntityClass = [];
public function register(SearchableMapping $mapping): void
{
$this->mappingsByEntityType[$mapping->entityType] = $mapping;
$this->mappingsByEntityClass[$mapping->entityClass] = $mapping;
}
public function getByEntityType(string $entityType): ?SearchableMapping
{
return $this->mappingsByEntityType[$entityType] ?? null;
}
public function getByEntityClass(string $entityClass): ?SearchableMapping
{
return $this->mappingsByEntityClass[$entityClass] ?? null;
}
public function hasMapping(string $entityTypeOrClass): bool
{
return isset($this->mappingsByEntityType[$entityTypeOrClass])
|| isset($this->mappingsByEntityClass[$entityTypeOrClass]);
}
public function getAllMappings(): array
{
return array_values($this->mappingsByEntityType);
}
public function getEntityTypes(): array
{
return array_keys($this->mappingsByEntityType);
}
/**
* Create adapter for entity
*/
public function createAdapter(object $entity): ?SearchableAdapter
{
$entityClass = get_class($entity);
$mapping = $this->getByEntityClass($entityClass);
if (! $mapping || ! $mapping->enabled) {
return null;
}
return new SearchableAdapter($entity, $mapping);
}
/**
* Check if entity is searchable
*/
public function isSearchable(object|string $entityOrClass): bool
{
$entityClass = is_object($entityOrClass) ? get_class($entityOrClass) : $entityOrClass;
$mapping = $this->getByEntityClass($entityClass);
return $mapping && $mapping->enabled;
}
/**
* Bulk register mappings from configuration
*/
public function registerFromConfig(array $config): void
{
foreach ($config as $entityClass => $mappingConfig) {
$builder = SearchableMapping::for($entityClass);
if (isset($mappingConfig['entity_type'])) {
$builder->entityType($mappingConfig['entity_type']);
}
if (isset($mappingConfig['id_field'])) {
$builder->idField($mappingConfig['id_field']);
}
if (isset($mappingConfig['auto_index'])) {
$builder->autoIndex($mappingConfig['auto_index']);
}
if (isset($mappingConfig['enabled'])) {
$builder->enabled($mappingConfig['enabled']);
}
// Add field mappings
foreach ($mappingConfig['fields'] ?? [] as $searchField => $fieldConfig) {
if (is_string($fieldConfig)) {
// Simple mapping: 'title' => 'name'
$builder->field($searchField, $fieldConfig);
} elseif (is_array($fieldConfig)) {
// Complex mapping with transformer
$entityField = $fieldConfig['field'];
$transformer = $fieldConfig['transformer'] ?? null;
if ($transformer && is_string($transformer) && function_exists($transformer)) {
$transformer = $transformer(...);
}
$builder->field($searchField, $entityField, $transformer);
}
}
// Add boosts
foreach ($mappingConfig['boosts'] ?? [] as $field => $boost) {
$builder->boost($field, (float) $boost);
}
$this->register($builder->build());
}
}
}