docs: consolidate documentation into organized structure

- Move 12 markdown files from root to docs/ subdirectories
- Organize documentation by category:
  • docs/troubleshooting/ (1 file)  - Technical troubleshooting guides
  • docs/deployment/      (4 files) - Deployment and security documentation
  • docs/guides/          (3 files) - Feature-specific guides
  • docs/planning/        (4 files) - Planning and improvement proposals

Root directory cleanup:
- Reduced from 16 to 4 markdown files in root
- Only essential project files remain:
  • CLAUDE.md (AI instructions)
  • README.md (Main project readme)
  • CLEANUP_PLAN.md (Current cleanup plan)
  • SRC_STRUCTURE_IMPROVEMENTS.md (Structure improvements)

This improves:
 Documentation discoverability
 Logical organization by purpose
 Clean root directory
 Better maintainability
This commit is contained in:
2025-10-05 11:05:04 +02:00
parent 887847dde6
commit 5050c7d73a
36686 changed files with 196456 additions and 12398919 deletions

View File

@@ -6,7 +6,8 @@ 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\Database\ConnectionInterface;
use App\Framework\Database\ValueObjects\SqlQuery;
use App\Framework\Exception\ErrorCode;
use App\Framework\Exception\FrameworkException;
use App\Framework\Search\BulkIndexResult;
@@ -20,7 +21,7 @@ use App\Framework\Search\SearchIndexStats;
final readonly class DatabaseIndexManager implements SearchIndexManager
{
public function __construct(
private Connection $connection,
private ConnectionInterface $connection,
private DatabaseSearchConfig $config = new DatabaseSearchConfig()
) {
}
@@ -86,10 +87,9 @@ final readonly class DatabaseIndexManager implements SearchIndexManager
$sql = "SELECT COUNT(*) FROM information_schema.tables
WHERE table_schema = DATABASE() AND table_name = ?";
$statement = $this->connection->prepare($sql);
$statement->execute([$tableName]);
$result = $this->connection->query(SqlQuery::create($sql, [$tableName]));
return (int) $statement->fetchColumn() > 0;
return (int) $result->fetchColumn() > 0;
} catch (\Exception) {
return false;
@@ -102,10 +102,9 @@ final readonly class DatabaseIndexManager implements SearchIndexManager
$this->ensureMetadataTableExists();
$sql = "SELECT config FROM search_index_metadata WHERE entity_type = ?";
$statement = $this->connection->prepare($sql);
$statement->execute([$entityType]);
$result = $this->connection->query(SqlQuery::create($sql, [$entityType]));
$configData = $statement->fetchColumn();
$configData = $result->fetchColumn();
if (! $configData) {
return null;
@@ -133,9 +132,8 @@ final readonly class DatabaseIndexManager implements SearchIndexManager
// Get current document count
$sql = "SELECT COUNT(*) FROM {$tableName}";
$statement = $this->connection->prepare($sql);
$statement->execute();
$totalDocuments = (int) $statement->fetchColumn();
$result = $this->connection->query(SqlQuery::create($sql));
$totalDocuments = (int) $result->fetchColumn();
// Rebuild FULLTEXT indexes
$this->connection->exec("ALTER TABLE {$tableName} DROP INDEX title");
@@ -176,11 +174,10 @@ final readonly class DatabaseIndexManager implements SearchIndexManager
$this->ensureMetadataTableExists();
$sql = "SELECT entity_type, config FROM search_index_metadata";
$statement = $this->connection->prepare($sql);
$statement->execute();
$result = $this->connection->query(SqlQuery::create($sql));
$indexes = [];
while ($row = $statement->fetch()) {
while ($row = $result->fetch()) {
$config = $this->deserializeIndexConfig(json_decode($row['config'], true));
$indexes[$row['entity_type']] = $config;
}
@@ -226,9 +223,8 @@ final readonly class DatabaseIndexManager implements SearchIndexManager
FROM information_schema.tables
WHERE table_schema = DATABASE() AND table_name = ?";
$statement = $this->connection->prepare($sql);
$statement->execute([$tableName]);
$tableInfo = $statement->fetch();
$result = $this->connection->query(SqlQuery::create($sql, [$tableName]));
$tableInfo = $result->fetch();
if (! $tableInfo) {
return null;
@@ -236,9 +232,8 @@ final readonly class DatabaseIndexManager implements SearchIndexManager
// Get last indexed timestamp
$sql = "SELECT MAX(indexed_at) as last_indexed FROM {$tableName}";
$statement = $this->connection->prepare($sql);
$statement->execute();
$lastIndexed = $statement->fetchColumn();
$result = $this->connection->query(SqlQuery::create($sql));
$lastIndexed = $result->fetchColumn();
return new SearchIndexStats(
entityType: $entityType,
@@ -329,11 +324,10 @@ final readonly class DatabaseIndexManager implements SearchIndexManager
VALUES (?, ?)
ON DUPLICATE KEY UPDATE config = VALUES(config), updated_at = CURRENT_TIMESTAMP";
$statement = $this->connection->prepare($sql);
$statement->execute([
$this->connection->execute(SqlQuery::create($sql, [
$entityType,
json_encode($config->toArray()),
]);
]));
}
private function removeIndexConfig(string $entityType): void
@@ -341,8 +335,7 @@ final readonly class DatabaseIndexManager implements SearchIndexManager
$this->ensureMetadataTableExists();
$sql = "DELETE FROM search_index_metadata WHERE entity_type = ?";
$statement = $this->connection->prepare($sql);
$statement->execute([$entityType]);
$this->connection->execute(SqlQuery::create($sql, [$entityType]));
}
private function deserializeIndexConfig(array $data): SearchIndexConfig

View File

@@ -6,10 +6,12 @@ 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\Database\ConnectionInterface;
use App\Framework\Database\ValueObjects\SqlQuery;
use App\Framework\Exception\ErrorCode;
use App\Framework\Exception\FrameworkException;
use App\Framework\Search\BulkIndexResult;
use App\Framework\Search\SearchDocument;
use App\Framework\Search\SearchEngine;
use App\Framework\Search\SearchEngineStats;
use App\Framework\Search\SearchFilterType;
@@ -24,7 +26,7 @@ use App\Framework\Search\SearchResult;
final readonly class DatabaseSearchEngine implements SearchEngine
{
public function __construct(
private Connection $connection,
private ConnectionInterface $connection,
private DatabaseSearchConfig $config = new DatabaseSearchConfig()
) {
}
@@ -37,9 +39,8 @@ final readonly class DatabaseSearchEngine implements SearchEngine
$sql = $this->buildSearchSQL($query);
$params = $this->buildSearchParams($query);
$statement = $this->connection->prepare($sql);
$statement->execute($params);
$results = $statement->fetchAll();
$result = $this->connection->query(SqlQuery::create($sql, $params));
$results = $result->fetchAll();
$hits = $this->convertToHits($results, $query);
$total = $this->getTotalCount($query);
@@ -66,71 +67,68 @@ final readonly class DatabaseSearchEngine implements SearchEngine
}
}
public function index(string $entityType, string $id, array $document): bool
public function index(SearchDocument $document): bool
{
try {
$tableName = $this->getSearchTableName($entityType);
$this->ensureSearchTableExists($entityType);
$tableName = $this->getSearchTableName($document->entityType->toString());
$this->ensureSearchTableExists($document->entityType->toString());
// Convert document to searchable format
$searchData = $this->prepareDocumentForIndexing($document);
$searchData = $this->prepareDocumentForIndexing($document->data->toArray());
$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),
$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,
return $this->connection->execute(SqlQuery::create($sql, [
$document->id->toString(),
$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,
'entity_type' => $document->entityType->toString(),
'entity_id' => $document->id->toString(),
'error' => $e->getMessage(),
]);
}
}
public function delete(string $entityType, string $id): bool
public function delete(SearchDocument $document): bool
{
try {
$tableName = $this->getSearchTableName($entityType);
$tableName = $this->getSearchTableName($document->entityType->toString());
$sql = "DELETE FROM {$tableName} WHERE entity_id = ?";
$statement = $this->connection->prepare($sql);
return $statement->execute([$id]);
return $this->connection->execute(SqlQuery::create($sql, [$document->id->toString()]));
} catch (\Exception $e) {
throw FrameworkException::create(
ErrorCode::DB_QUERY_FAILED,
'Failed to delete document: ' . $e->getMessage()
)->withData([
'entity_type' => $entityType,
'entity_id' => $id,
'entity_type' => $document->entityType->toString(),
'entity_id' => $document->id->toString(),
'error' => $e->getMessage(),
]);
}
}
public function update(string $entityType, string $id, array $document): bool
public function update(SearchDocument $document): bool
{
// For database backend, update is the same as index (INSERT ... ON DUPLICATE KEY UPDATE)
return $this->index($entityType, $id, $document);
return $this->index($document);
}
public function bulkIndex(array $documents): BulkIndexResult
@@ -144,18 +142,18 @@ final readonly class DatabaseSearchEngine implements SearchEngine
foreach ($documents as $document) {
try {
if ($this->index($document->entityType, $document->id, $document->data)) {
if ($this->index($document)) {
$successful++;
$successfulIds[] = $document->id;
$successfulIds[] = $document->id->toString();
} else {
$failed++;
$failedIds[] = $document->id;
$errors[$document->id] = 'Index operation returned false';
$failedIds[] = $document->id->toString();
$errors[$document->id->toString()] = 'Index operation returned false';
}
} catch (\Exception $e) {
$failed++;
$failedIds[] = $document->id;
$errors[$document->id] = $e->getMessage();
$failedIds[] = $document->id->toString();
$errors[$document->id->toString()] = $e->getMessage();
}
}
@@ -188,9 +186,8 @@ final readonly class DatabaseSearchEngine implements SearchEngine
WHERE table_schema = DATABASE()
AND table_name LIKE 'search_%'";
$statement = $this->connection->prepare($sql);
$statement->execute();
$tables = $statement->fetchAll();
$result = $this->connection->query(SqlQuery::create($sql));
$tables = $result->fetchAll();
foreach ($tables as $table) {
$entityType = str_replace('search_', '', $table['table_name']);
@@ -223,8 +220,7 @@ final readonly class DatabaseSearchEngine implements SearchEngine
public function isAvailable(): bool
{
try {
$statement = $this->connection->prepare('SELECT 1');
$statement->execute();
$this->connection->query(SqlQuery::create('SELECT 1'));
return true;
} catch (\Exception) {
@@ -424,10 +420,9 @@ final readonly class DatabaseSearchEngine implements SearchEngine
}
}
$statement = $this->connection->prepare($sql);
$statement->execute($params);
$result = $this->connection->query(SqlQuery::create($sql, $params));
return (int) $statement->fetchColumn();
return (int) $result->fetchColumn();
}
private function getMaxScore(array $hits): float

View File

@@ -4,45 +4,44 @@ declare(strict_types=1);
namespace App\Framework\Search;
use App\Framework\Search\ValueObjects\DocumentData;
use App\Framework\Search\ValueObjects\DocumentMetadata;
use App\Framework\Search\ValueObjects\EntityId;
use App\Framework\Search\ValueObjects\EntityType;
/**
* Represents a document to be indexed
* Now uses Value Objects to eliminate primitive obsession
*/
final readonly class SearchDocument
{
/**
* @param array<string, mixed> $data
* @param array<string, mixed> $metadata
*/
public readonly DocumentMetadata $metadata;
public function __construct(
public string $id,
public string $entityType,
public array $data,
public array $metadata = []
public EntityId $id,
public EntityType $entityType,
public DocumentData $data,
?DocumentMetadata $metadata = null
) {
$this->metadata = $metadata ?? DocumentMetadata::empty();
}
public function hasMetadata(): bool
{
return ! empty($this->metadata);
return ! $this->metadata->isEmpty();
}
public function withMetadata(string $key, mixed $value): self
{
$metadata = $this->metadata;
$metadata[$key] = $value;
return new self(
$this->id,
$this->entityType,
$this->data,
$metadata
$this->metadata->with($key, $value)
);
}
/**
* @param array<string, mixed> $data
*/
public function withData(array $data): self
public function withDocumentData(DocumentData $data): self
{
return new self(
$this->id,
@@ -52,16 +51,54 @@ final readonly class SearchDocument
);
}
public function withField(string $field, mixed $value): self
{
return new self(
$this->id,
$this->entityType,
$this->data->with($field, $value),
$this->metadata
);
}
public function withoutField(string $field): self
{
return new self(
$this->id,
$this->entityType,
$this->data->without($field),
$this->metadata
);
}
/**
* @return array<string, mixed>
*/
public function toArray(): array
{
return [
'id' => $this->id,
'entity_type' => $this->entityType,
'data' => $this->data,
'metadata' => $this->metadata,
'id' => $this->id->toString(),
'entity_type' => $this->entityType->toString(),
'data' => $this->data->toArray(),
'metadata' => $this->metadata->toArray(),
];
}
/**
* Get the document as an array suitable for search engine indexing
* @return array<string, mixed>
*/
public function toIndexArray(): array
{
$indexData = $this->data->toArray();
$indexData['_id'] = $this->id->toString();
$indexData['_type'] = $this->entityType->toString();
// Add metadata as non-searchable fields
foreach ($this->metadata->toArray() as $key => $value) {
$indexData["_meta_{$key}"] = $value;
}
return $indexData;
}
}

View File

@@ -7,6 +7,7 @@ namespace App\Framework\Search;
/**
* Core search engine interface
* Provides backend-agnostic search functionality
* Uses Value Objects to eliminate primitive obsession
*/
interface SearchEngine
{
@@ -18,17 +19,17 @@ interface SearchEngine
/**
* Index a document for searching
*/
public function index(string $entityType, string $id, array $document): bool;
public function index(SearchDocument $document): bool;
/**
* Remove document from search index
*/
public function delete(string $entityType, string $id): bool;
public function delete(SearchDocument $document): bool;
/**
* Update existing document in search index
*/
public function update(string $entityType, string $id, array $document): bool;
public function update(SearchDocument $document): bool;
/**
* Bulk index multiple documents

View File

@@ -0,0 +1,124 @@
<?php
declare(strict_types=1);
namespace App\Framework\Search\ValueObjects;
use InvalidArgumentException;
/**
* Value Object for search document data
* Encapsulates the searchable content and attributes of a document
*/
final readonly class DocumentData
{
/**
* @param array<string, mixed> $fields
*/
public function __construct(private array $fields = [])
{
$this->validateFields($fields);
}
public static function empty(): self
{
return new self([]);
}
/**
* @param array<string, mixed> $fields
*/
public static function fromArray(array $fields): self
{
return new self($fields);
}
public function with(string $field, mixed $value): self
{
$fields = $this->fields;
$fields[$field] = $value;
return new self($fields);
}
public function without(string $field): self
{
$fields = $this->fields;
unset($fields[$field]);
return new self($fields);
}
public function has(string $field): bool
{
return array_key_exists($field, $this->fields);
}
public function get(string $field, mixed $default = null): mixed
{
return $this->fields[$field] ?? $default;
}
/**
* @return array<string, mixed>
*/
public function toArray(): array
{
return $this->fields;
}
public function isEmpty(): bool
{
return empty($this->fields);
}
/**
* Get only the text fields for full-text search
* @return array<string, string>
*/
public function getTextFields(): array
{
return array_filter(
$this->fields,
fn ($value) => is_string($value) && ! empty(trim($value))
);
}
/**
* Get fields suitable for faceting/filtering
* @return array<string, scalar>
*/
public function getFacetFields(): array
{
return array_filter(
$this->fields,
fn ($value) => is_scalar($value)
);
}
/**
* @param array<string, mixed> $fields
*/
private function validateFields(array $fields): void
{
foreach ($fields as $key => $value) {
if (! is_string($key) || empty(trim($key))) {
throw new InvalidArgumentException('Document field keys must be non-empty strings');
}
if (strlen($key) > 255) {
throw new InvalidArgumentException('Document field key cannot exceed 255 characters');
}
// Ensure field names are search-engine safe
if (! preg_match('/^[a-zA-Z][a-zA-Z0-9_]*$/', $key)) {
throw new InvalidArgumentException('Document field key must start with letter and contain only alphanumeric characters and underscores');
}
// Validate value types (only allow serializable data)
if (is_resource($value) || (is_object($value) && ! method_exists($value, '__toString'))) {
throw new InvalidArgumentException("Document field '{$key}' contains non-serializable data");
}
}
}
}

View File

@@ -0,0 +1,161 @@
<?php
declare(strict_types=1);
namespace App\Framework\Search\ValueObjects;
use DateTimeImmutable;
use InvalidArgumentException;
/**
* Value Object for search document metadata
* Contains non-searchable metadata about the document (timestamps, versioning, etc.)
*/
final readonly class DocumentMetadata
{
/**
* @param array<string, mixed> $metadata
*/
public function __construct(private array $metadata = [])
{
$this->validateMetadata($metadata);
}
public static function empty(): self
{
return new self([]);
}
/**
* @param array<string, mixed> $metadata
*/
public static function fromArray(array $metadata): self
{
return new self($metadata);
}
public static function withTimestamps(): self
{
$now = new DateTimeImmutable();
return new self([
'created_at' => $now->format('c'),
'updated_at' => $now->format('c'),
]);
}
public function with(string $key, mixed $value): self
{
$metadata = $this->metadata;
$metadata[$key] = $value;
return new self($metadata);
}
public function withVersion(string $version): self
{
return $this->with('version', $version);
}
public function withSource(string $source): self
{
return $this->with('source', $source);
}
public function withLanguage(string $language): self
{
return $this->with('language', $language);
}
public function withPriority(int $priority): self
{
if ($priority < 0 || $priority > 100) {
throw new InvalidArgumentException('Priority must be between 0 and 100');
}
return $this->with('priority', $priority);
}
public function withUpdatedTimestamp(): self
{
return $this->with('updated_at', (new DateTimeImmutable())->format('c'));
}
public function has(string $key): bool
{
return array_key_exists($key, $this->metadata);
}
public function get(string $key, mixed $default = null): mixed
{
return $this->metadata[$key] ?? $default;
}
/**
* @return array<string, mixed>
*/
public function toArray(): array
{
return $this->metadata;
}
public function isEmpty(): bool
{
return empty($this->metadata);
}
public function getVersion(): ?string
{
return $this->metadata['version'] ?? null;
}
public function getSource(): ?string
{
return $this->metadata['source'] ?? null;
}
public function getLanguage(): ?string
{
return $this->metadata['language'] ?? null;
}
public function getPriority(): int
{
return (int) ($this->metadata['priority'] ?? 50);
}
public function getCreatedAt(): ?DateTimeImmutable
{
$timestamp = $this->metadata['created_at'] ?? null;
return $timestamp ? new DateTimeImmutable($timestamp) : null;
}
public function getUpdatedAt(): ?DateTimeImmutable
{
$timestamp = $this->metadata['updated_at'] ?? null;
return $timestamp ? new DateTimeImmutable($timestamp) : null;
}
/**
* @param array<string, mixed> $metadata
*/
private function validateMetadata(array $metadata): void
{
foreach ($metadata as $key => $value) {
if (! is_string($key) || empty(trim($key))) {
throw new InvalidArgumentException('Metadata keys must be non-empty strings');
}
if (strlen($key) > 255) {
throw new InvalidArgumentException('Metadata key cannot exceed 255 characters');
}
// Validate value types (only allow serializable data)
if (is_resource($value) || (is_object($value) && ! method_exists($value, '__toString'))) {
throw new InvalidArgumentException("Metadata key '{$key}' contains non-serializable data");
}
}
}
}

View File

@@ -0,0 +1,55 @@
<?php
declare(strict_types=1);
namespace App\Framework\Search\ValueObjects;
use InvalidArgumentException;
/**
* Value Object for search entity identifiers
* Ensures entity IDs are valid and properly formatted
*/
final readonly class EntityId
{
public function __construct(public string $value)
{
if (empty(trim($value))) {
throw new InvalidArgumentException('Entity ID cannot be empty');
}
if (strlen($value) > 255) {
throw new InvalidArgumentException('Entity ID cannot exceed 255 characters');
}
// Ensure ID is search-engine safe (no special characters that could break indexing)
if (! preg_match('/^[a-zA-Z0-9._-]+$/', $value)) {
throw new InvalidArgumentException('Entity ID must contain only alphanumeric characters, dots, underscores, and hyphens');
}
}
public static function fromString(string $id): self
{
return new self($id);
}
public static function generate(): self
{
return new self(uniqid('entity_', true));
}
public function toString(): string
{
return $this->value;
}
public function equals(self $other): bool
{
return $this->value === $other->value;
}
public function __toString(): string
{
return $this->value;
}
}

View File

@@ -0,0 +1,85 @@
<?php
declare(strict_types=1);
namespace App\Framework\Search\ValueObjects;
use InvalidArgumentException;
/**
* Value Object for search entity types
* Defines what kind of entity is being indexed (user, product, article, etc.)
*/
final readonly class EntityType
{
public function __construct(public string $value)
{
if (empty(trim($value))) {
throw new InvalidArgumentException('Entity type cannot be empty');
}
if (strlen($value) > 100) {
throw new InvalidArgumentException('Entity type cannot exceed 100 characters');
}
// Ensure entity type follows naming conventions (lowercase, underscores)
if (! preg_match('/^[a-z][a-z0-9_]*$/', $value)) {
throw new InvalidArgumentException('Entity type must start with lowercase letter and contain only lowercase letters, numbers, and underscores');
}
}
// Common entity types for convenience
public static function user(): self
{
return new self('user');
}
public static function product(): self
{
return new self('product');
}
public static function article(): self
{
return new self('article');
}
public static function category(): self
{
return new self('category');
}
public static function order(): self
{
return new self('order');
}
public static function fromString(string $type): self
{
return new self($type);
}
public function toString(): string
{
return $this->value;
}
public function equals(self $other): bool
{
return $this->value === $other->value;
}
/**
* Get the index name for this entity type
* Useful for search engines that use separate indices per type
*/
public function getIndexName(): string
{
return "search_{$this->value}";
}
public function __toString(): string
{
return $this->value;
}
}