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:
91
src/Framework/Search/BulkIndexResult.php
Normal file
91
src/Framework/Search/BulkIndexResult.php
Normal 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: []
|
||||
);
|
||||
}
|
||||
}
|
||||
372
src/Framework/Search/Engines/DatabaseIndexManager.php
Normal file
372
src/Framework/Search/Engines/DatabaseIndexManager.php
Normal 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']
|
||||
);
|
||||
}
|
||||
}
|
||||
46
src/Framework/Search/Engines/DatabaseSearchConfig.php
Normal file
46
src/Framework/Search/Engines/DatabaseSearchConfig.php
Normal 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}>";
|
||||
}
|
||||
}
|
||||
492
src/Framework/Search/Engines/DatabaseSearchEngine.php
Normal file
492
src/Framework/Search/Engines/DatabaseSearchEngine.php
Normal 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,
|
||||
];
|
||||
}
|
||||
}
|
||||
61
src/Framework/Search/SearchDocument.php
Normal file
61
src/Framework/Search/SearchDocument.php
Normal 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,
|
||||
];
|
||||
}
|
||||
}
|
||||
54
src/Framework/Search/SearchEngine.php
Normal file
54
src/Framework/Search/SearchEngine.php
Normal 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;
|
||||
}
|
||||
70
src/Framework/Search/SearchEngineStats.php
Normal file
70
src/Framework/Search/SearchEngineStats.php
Normal 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,
|
||||
];
|
||||
}
|
||||
}
|
||||
84
src/Framework/Search/SearchEventListener.php
Normal file
84
src/Framework/Search/SearchEventListener.php
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
187
src/Framework/Search/SearchFieldConfig.php
Normal file
187
src/Framework/Search/SearchFieldConfig.php
Normal 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,
|
||||
];
|
||||
}
|
||||
}
|
||||
30
src/Framework/Search/SearchFieldType.php
Normal file
30
src/Framework/Search/SearchFieldType.php
Normal 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';
|
||||
}
|
||||
72
src/Framework/Search/SearchFilter.php
Normal file
72
src/Framework/Search/SearchFilter.php
Normal 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,
|
||||
];
|
||||
}
|
||||
}
|
||||
27
src/Framework/Search/SearchFilterType.php
Normal file
27
src/Framework/Search/SearchFilterType.php
Normal 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';
|
||||
}
|
||||
82
src/Framework/Search/SearchHit.php
Normal file
82
src/Framework/Search/SearchHit.php
Normal 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,
|
||||
];
|
||||
}
|
||||
}
|
||||
93
src/Framework/Search/SearchIndexConfig.php
Normal file
93
src/Framework/Search/SearchIndexConfig.php
Normal 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,
|
||||
];
|
||||
}
|
||||
}
|
||||
58
src/Framework/Search/SearchIndexManager.php
Normal file
58
src/Framework/Search/SearchIndexManager.php
Normal 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;
|
||||
}
|
||||
62
src/Framework/Search/SearchIndexStats.php
Normal file
62
src/Framework/Search/SearchIndexStats.php
Normal 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,
|
||||
];
|
||||
}
|
||||
}
|
||||
180
src/Framework/Search/SearchIndexingService.php
Normal file
180
src/Framework/Search/SearchIndexingService.php
Normal 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);
|
||||
}
|
||||
}
|
||||
162
src/Framework/Search/SearchQuery.php
Normal file
162
src/Framework/Search/SearchQuery.php
Normal 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,
|
||||
];
|
||||
}
|
||||
}
|
||||
284
src/Framework/Search/SearchQueryBuilder.php
Normal file
284
src/Framework/Search/SearchQueryBuilder.php
Normal 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;
|
||||
}
|
||||
}
|
||||
84
src/Framework/Search/SearchResult.php
Normal file
84
src/Framework/Search/SearchResult.php
Normal 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,
|
||||
];
|
||||
}
|
||||
}
|
||||
99
src/Framework/Search/SearchService.php
Normal file
99
src/Framework/Search/SearchService.php
Normal 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;
|
||||
}
|
||||
}
|
||||
56
src/Framework/Search/SearchSortBy.php
Normal file
56
src/Framework/Search/SearchSortBy.php
Normal 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);
|
||||
}
|
||||
}
|
||||
14
src/Framework/Search/SearchSortDirection.php
Normal file
14
src/Framework/Search/SearchSortDirection.php
Normal 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';
|
||||
}
|
||||
27
src/Framework/Search/SearchSortField.php
Normal file
27
src/Framework/Search/SearchSortField.php
Normal 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,
|
||||
];
|
||||
}
|
||||
}
|
||||
111
src/Framework/Search/SearchableAdapter.php
Normal file
111
src/Framework/Search/SearchableAdapter.php
Normal 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;
|
||||
}
|
||||
}
|
||||
187
src/Framework/Search/SearchableMapping.php
Normal file
187
src/Framework/Search/SearchableMapping.php
Normal 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(),
|
||||
];
|
||||
}
|
||||
}
|
||||
126
src/Framework/Search/SearchableMappingRegistry.php
Normal file
126
src/Framework/Search/SearchableMappingRegistry.php
Normal 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());
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user