Quellcode durchsuchen

Initial alpha release (v0.1.0) - Core ORM functionality

phpdevcommunity vor 7 Monaten
Commit
a538f3b733
95 geänderte Dateien mit 7508 neuen und 0 gelöschten Zeilen
  1. 2 0
      .gitignore
  2. 361 0
      README.md
  3. 37 0
      composer.json
  4. 27 0
      functions/helpers.php
  5. 39 0
      src/Cache/ColumnCache.php
  6. 47 0
      src/Cache/EntityMemcachedCache.php
  7. 39 0
      src/Cache/OneToManyCache.php
  8. 32 0
      src/Cache/PrimaryKeyColumnCache.php
  9. 166 0
      src/Collection/ObjectStorage.php
  10. 55 0
      src/Command/DatabaseCreateCommand.php
  11. 67 0
      src/Command/DatabaseDropCommand.php
  12. 58 0
      src/Command/QueryExecuteCommand.php
  13. 85 0
      src/Command/ShowTablesCommand.php
  14. 48 0
      src/Debugger/PDOStatementLogger.php
  15. 41 0
      src/Debugger/SqlDebugger.php
  16. 15 0
      src/Driver/DriverInterface.php
  17. 32 0
      src/Driver/DriverManager.php
  18. 57 0
      src/Driver/SqliteDriver.php
  19. 11 0
      src/Entity/EntityInterface.php
  20. 118 0
      src/EntityManager.php
  21. 180 0
      src/Expression/Expr.php
  22. 142 0
      src/Generator/SchemaDiffGenerator.php
  23. 76 0
      src/Hydrator/ArrayHydrator.php
  24. 119 0
      src/Hydrator/EntityHydrator.php
  25. 142 0
      src/Mapper/ColumnMapper.php
  26. 25 0
      src/Mapper/EntityMapper.php
  27. 20 0
      src/Mapping/Column/BoolColumn.php
  28. 140 0
      src/Mapping/Column/Column.php
  29. 19 0
      src/Mapping/Column/DateColumn.php
  30. 20 0
      src/Mapping/Column/DateTimeColumn.php
  31. 34 0
      src/Mapping/Column/DecimalColumn.php
  32. 21 0
      src/Mapping/Column/FloatColumn.php
  33. 21 0
      src/Mapping/Column/IntColumn.php
  34. 53 0
      src/Mapping/Column/JoinColumn.php
  35. 19 0
      src/Mapping/Column/JsonColumn.php
  36. 14 0
      src/Mapping/Column/PrimaryKeyColumn.php
  37. 27 0
      src/Mapping/Column/StringColumn.php
  38. 20 0
      src/Mapping/Column/TextColumn.php
  39. 27 0
      src/Mapping/Entity.php
  40. 51 0
      src/Mapping/Index.php
  41. 57 0
      src/Mapping/OneToMany.php
  42. 153 0
      src/Metadata/ColumnMetadata.php
  43. 163 0
      src/Metadata/DatabaseSchemaDiffMetadata.php
  44. 61 0
      src/Metadata/IndexMetadata.php
  45. 54 0
      src/Migration/MigrationDirectory.php
  46. 227 0
      src/Migration/PaperMigration.php
  47. 103 0
      src/PaperConnection.php
  48. 41 0
      src/Parser/SQLTypeParser.php
  49. 29 0
      src/Pdo/PaperPDO.php
  50. 143 0
      src/Platform/AbstractPlatform.php
  51. 92 0
      src/Platform/PlatformInterface.php
  52. 207 0
      src/Platform/SqlitePlatform.php
  53. 89 0
      src/Proxy/ProxyInitializedTrait.php
  54. 18 0
      src/Proxy/ProxyInterface.php
  55. 29 0
      src/Query/AliasDetector.php
  56. 24 0
      src/Query/AliasGenerator.php
  57. 115 0
      src/Query/Fetcher.php
  58. 396 0
      src/Query/QueryBuilder.php
  59. 150 0
      src/Repository/Repository.php
  60. 204 0
      src/Schema/SchemaInterface.php
  61. 221 0
      src/Schema/SqliteSchema.php
  62. 66 0
      src/Serializer/SerializerToArray.php
  63. 58 0
      src/Serializer/SerializerToDb.php
  64. 17 0
      src/Types/BoolType.php
  65. 39 0
      src/Types/DateTimeType.php
  66. 35 0
      src/Types/DateType.php
  67. 17 0
      src/Types/DecimalType.php
  68. 17 0
      src/Types/FloatType.php
  69. 17 0
      src/Types/IntType.php
  70. 17 0
      src/Types/IntegerType.php
  71. 35 0
      src/Types/JsonType.php
  72. 17 0
      src/Types/ObjectType.php
  73. 17 0
      src/Types/StringType.php
  74. 23 0
      src/Types/Type.php
  75. 20 0
      src/Types/TypeFactory.php
  76. 85 0
      src/UnitOfWork.php
  77. 57 0
      tests/Common/AliasDetectorTest.php
  78. 190 0
      tests/Common/ObjectStorageTest.php
  79. 53 0
      tests/Common/OrmTestMemory.php
  80. 46 0
      tests/Common/SqlDebuggerTest.php
  81. 65 0
      tests/DatabaseShowTablesCommandTest.php
  82. 68 0
      tests/Entity/CommentTest.php
  83. 141 0
      tests/Entity/PostTest.php
  84. 70 0
      tests/Entity/TagTest.php
  85. 166 0
      tests/Entity/UserTest.php
  86. 32 0
      tests/Factory/DatabaseConnectionFactory.php
  87. 156 0
      tests/Helper/DataBaseHelperTest.php
  88. 116 0
      tests/MigrationTest.php
  89. 148 0
      tests/PersistAndFlushTest.php
  90. 73 0
      tests/PlatformDiffTest.php
  91. 166 0
      tests/PlatformTest.php
  92. 15 0
      tests/Repository/PostTestRepository.php
  93. 15 0
      tests/Repository/TagTestRepository.php
  94. 366 0
      tests/RepositoryTest.php
  95. 2 0
      tests/migrations/.gitignore

+ 2 - 0
.gitignore

@@ -0,0 +1,2 @@
+/vendor/
+/.idea/

+ 361 - 0
README.md

@@ -0,0 +1,361 @@
+# PaperORM - A Simple and Lightweight PHP ORM
+PaperORM is a PHP ORM designed for projects requiring a lightweight yet performant object-relational mapping solution.
+
+## 📖 Documentation
+
+- [English](#english)
+- [Français](#français)
+
+## English
+
+PaperORM is a PHP ORM designed for projects requiring a lightweight yet performant object-relational mapping solution. Specifically developed for PHP 7.4 and above, it positions itself as a lighter alternative to existing solutions.
+
+At just 3MB compared to Doctrine's 75MB with dependencies, PaperORM offers the essential features of a modern ORM while maintaining a minimal footprint. It includes:
+- Database schema management
+- Migration system
+- Repository pattern
+
+## Installation
+
+PaperORM is available via **Composer** and installs in seconds.
+
+### 📦 Via Composer (recommended)
+```bash
+composer require phpdevcommunity/paper-orm:1.0.0-alpha
+```  
+
+### 🔧 Minimal Configuration
+Create a simple configuration file to connect PaperORM to your database:
+
+```php
+<?php
+require_once 'vendor/autoload.php';
+
+use PhpDevCommunity\PaperORM\EntityManager;
+
+// Basic configuration (MySQL, SQLite)  
+$entityManager = new EntityManager([
+            'driver' => 'sqlite',
+            'user' => null,
+            'password' => null,
+            'memory' => true,
+]);
+```
+
+✅ **PaperORM is now ready to use!**
+
+*Note: PDO and corresponding database extensions must be enabled (pdo_mysql, pdo_sqlite, etc.).*
+
+## Basic Usage
+
+### Defining an Entity
+
+```php
+use PaperORM\Entity\EntityInterface;
+use PaperORM\Mapping\{PrimaryKeyColumn, StringColumn, BoolColumn, DateTimeColumn, OneToMany, JoinColumn};
+
+class User implements EntityInterface
+{
+    private ?int $id = null;
+    private string $name;
+    private string $email;
+    private bool $isActive = true;
+    private \DateTime $createdAt;
+    
+    public static function getTableName(): string 
+    {
+        return 'users';
+    }
+    
+    public static function columnsMapping(): array
+    {
+        return [
+            new PrimaryKeyColumn('id'),
+            new StringColumn('name'),
+            new StringColumn('email'),
+            new BoolColumn('isActive'),
+            new DateTimeColumn('createdAt')
+        ];
+    }
+    
+    // Getters/Setters...
+}
+```
+
+### CRUD Operations
+
+**Fetching Entities:**
+```php
+// Get user by ID
+$user = $entityManager->getRepository(User::class)->find(1);
+
+// Filtered query
+$users = $entityManager->getRepository(User::class)
+    ->findBy()
+    ->where('isActive', true)
+    ->orderBy('name', 'ASC')
+    ->limit(10)
+    ->toArray();
+```
+
+**Insert/Update:**
+```php
+$newUser = new User();
+$newUser->setName('Jean Dupont')
+        ->setEmail('jean@example.com');
+
+$entityManager->persist($newUser);
+$entityManager->flush();
+```
+
+**Delete:**
+```php
+$user = $entityManager->getRepository(User::class)->find(1);
+$entityManager->remove($user);
+$entityManager->flush();
+```
+
+### Entity Relationships
+
+```php
+// OneToMany relationship
+class Article 
+{
+    // ...
+    public static function columnsMapping(): array
+    {
+        return [
+            new OneToMany('comments', Comment::class, 'article')
+        ];
+    }
+}
+
+// Fetch with join
+$articleWithComments = $entityManager->getRepository(Article::class)
+    ->find(1)
+    ->with('comments')
+    ->toObject();
+```
+
+### Result Formats
+
+```php
+// Associative array
+$userArray = $repository->find(1)->toArray();
+
+// Entity object
+$userObject = $repository->find(1)->toObject();
+
+// Object collection
+$activeUsers = $repository->findBy()
+    ->where('isActive', true)
+    ->toCollection();
+```
+
+> PaperORM offers a simple API while covering the essential needs of a modern ORM.
+
+## Beta Version - Contribute to Development
+
+PaperORM is currently in **beta version** and actively evolving. We invite interested developers to:
+
+### 🐞 Report Bugs
+If you encounter issues, open a [GitHub issue](https://github.com/phpdevcommunity/paper-orm/issues) detailing:
+- Context
+- Reproduction steps
+- Expected vs. actual behavior
+
+### 💡 Suggest Improvements
+Ideas for:
+- Performance optimization
+- API improvements
+- New features
+
+### 📖 Contribute to Documentation
+Complete documentation is being written. You can:
+- Fix errors
+- Add examples
+- Translate sections
+
+**Note:** This version is stable for development use but requires additional testing for production.
+
+---
+
+*Active development continues - stay tuned for updates!*
+
+## Français
+
+PaperORM est un ORM PHP conçu pour les projets qui nécessitent une solution de mapping objet-relationnel légère et performante. Développé spécifiquement pour PHP 7.4 et versions ultérieures, il se positionne comme une alternative plus légère aux solutions existantes.
+
+Avec seulement 3Mo contre 75Mo pour Doctrine avec ses dépendances, PaperORM propose les fonctionnalités essentielles d'un ORM moderne tout en conservant une empreinte minimale. Il intègre notamment :
+- La gestion des schémas de base de données
+- Un système de migrations
+- Le pattern Repository
+
+
+## Installation
+
+PaperORM est disponible via **Composer** et s'installe en quelques secondes.
+
+### 📦 Via Composer (recommandé)
+```bash
+composer require phpdevcommunity/paper-orm:1.0.0-alpha
+```  
+
+### 🔧 Configuration minimale
+Créez un fichier de configuration simple pour connecter PaperORM à votre base de données :
+
+```php
+<?php
+require_once 'vendor/autoload.php';
+
+use PhpDevCommunity\PaperORM\EntityManager;
+
+// Configuration de base (MySQL, SQLite)  
+$entityManager = new EntityManager([
+            'driver' => 'sqlite',
+            'user' => null,
+            'password' => null,
+            'memory' => true,
+]);
+```
+
+✅ **PaperORM est maintenant prêt à être utilisé !**  
+
+*Remarque : PDO et les extensions correspondantes à votre SGBD doivent être activées (pdo_mysql, pdo_sqlite, etc.).*
+
+## Utilisation de base
+
+### Définition d'une entité
+
+```php
+use PaperORM\Entity\EntityInterface;
+use PaperORM\Mapping\{PrimaryKeyColumn, StringColumn, BoolColumn, DateTimeColumn, OneToMany, JoinColumn};
+
+class User implements EntityInterface
+{
+    private ?int $id = null;
+    private string $name;
+    private string $email;
+    private bool $isActive = true;
+    private \DateTime $createdAt;
+    
+    public static function getTableName(): string 
+    {
+        return 'users';
+    }
+    
+    public static function columnsMapping(): array
+    {
+        return [
+            new PrimaryKeyColumn('id'),
+            new StringColumn('name'),
+            new StringColumn('email'),
+            new BoolColumn('isActive'),
+            new DateTimeColumn('createdAt')
+        ];
+    }
+    
+    // Getters/Setters...
+}
+```
+
+### Opérations CRUD
+
+**Récupération d'entités :**
+```php
+// Récupérer un utilisateur par ID
+$user = $entityManager->getRepository(User::class)->find(1);
+
+// Requête avec filtres
+$users = $entityManager->getRepository(User::class)
+    ->findBy()
+    ->where('isActive', true)
+    ->orderBy('name', 'ASC')
+    ->limit(10)
+    ->toArray();
+```
+
+**Insertion/Mise à jour :**
+```php
+$newUser = new User();
+$newUser->setName('Jean Dupont')
+        ->setEmail('jean@example.com');
+
+$entityManager->persist($newUser);
+$entityManager->flush();
+```
+
+**Suppression :**
+```php
+$user = $entityManager->getRepository(User::class)->find(1);
+$entityManager->remove($user);
+$entityManager->flush();
+```
+
+### Relations entre entités
+
+```php
+// Relation OneToMany
+class Article 
+{
+    // ...
+    public static function columnsMapping(): array
+    {
+        return [
+            new OneToMany('comments', Comment::class, 'article')
+        ];
+    }
+}
+
+// Récupération avec jointure
+$articleWithComments = $entityManager->getRepository(Article::class)
+    ->find(1)
+    ->with('comments')
+    ->toObject();
+```
+
+### Format des résultats
+
+```php
+// Tableau associatif
+$userArray = $repository->find(1)->toArray();
+
+// Objet entité
+$userObject = $repository->find(1)->toObject();
+
+// Collection d'objets
+$activeUsers = $repository->findBy()
+    ->where('isActive', true)
+    ->toCollection();
+```
+
+> PaperORM propose une API simple tout en couvrant les besoins essentiels d'un ORM moderne.
+
+## Version Bêta - Contribuez au développement
+
+PaperORM est actuellement en **version bêta** et évolue activement. Nous invitons tous les développeurs intéressés à :
+
+### 🐞 Signaler des bugs
+Si vous rencontrez un problème, ouvrez une [issue GitHub](https://github.com/phpdevcommunity/paper-orm/issues) en détaillant :
+- Le contexte
+- Les étapes pour reproduire
+- Le comportement attendu vs. observé
+
+### 💡 Proposer des améliorations
+Des idées pour :
+- Optimiser les performances
+- Améliorer l'API
+- Ajouter des fonctionnalités
+
+### 📖 Contribuer à la documentation
+La documentation complète est en cours de rédaction. Vous pouvez :
+- Corriger des erreurs
+- Ajouter des exemples
+- Traduire des sections
+
+**Note** : Cette version est stable pour un usage en développement, mais nécessite des tests supplémentaires pour la production.
+
+---
+
+*Le développement actif continue - restez à l'écoute pour les mises à jour !*
+

+ 37 - 0
composer.json

@@ -0,0 +1,37 @@
+{
+  "name": "phpdevcommunity/paperorm",
+  "description": "PaperORM is a lightweight Object-Relational Mapping (ORM) library ",
+  "type": "library",
+  "license": "MIT",
+  "authors": [
+    {
+      "name": "F. Michel",
+      "homepage": "https://www.phpdevcommunity.com"
+    }
+  ],
+  "autoload": {
+    "psr-4": {
+      "PhpDevCommunity\\PaperORM\\": "src",
+      "Test\\PhpDevCommunity\\PaperORM\\": "tests"
+    },
+    "files": [
+      "functions/helpers.php"
+    ]
+  },
+  "require": {
+    "php": ">=7.4",
+    "ext-pdo": "*",
+    "ext-json": "*",
+    "ext-ctype": "*",
+    "phpdevcommunity/relational-query": "^1.0",
+    "phpdevcommunity/php-console": "^1.0"
+  },
+  "require-dev": {
+    "phpdevcommunity/unitester": "^0.1.0@alpha"
+  },
+  "config": {
+    "allow-plugins": {
+      "dealerdirect/phpcodesniffer-composer-installer": false
+    }
+  }
+}

+ 27 - 0
functions/helpers.php

@@ -0,0 +1,27 @@
+<?php
+
+if (!function_exists('str_starts_with')) {
+
+    /**
+     * @param string $haystack
+     * @param string $needle
+     * @return bool
+     */
+    function str_starts_with(string $haystack, string $needle): bool
+    {
+        return substr($haystack, 0, strlen($needle)) === $needle;
+    }
+}
+
+if (!function_exists('str_contains')) {
+
+    /**
+     * @param string $haystack
+     * @param string $needle
+     * @return bool
+     */
+    function str_contains(string $haystack, string $needle): bool
+    {
+        return strpos($haystack, $needle) !== false;
+    }
+}

+ 39 - 0
src/Cache/ColumnCache.php

@@ -0,0 +1,39 @@
+<?php
+
+namespace PhpDevCommunity\PaperORM\Cache;
+
+
+use PhpDevCommunity\PaperORM\Mapping\Column\Column;
+
+final class ColumnCache
+{
+    private static ?ColumnCache $instance = null;
+    private array $data = [];
+
+    public static function getInstance(): self
+    {
+        if (self::$instance === null) {
+            self::$instance = new self();
+        }
+        return self::$instance;
+    }
+    
+    public function set(string $key, array $columns)
+    {
+        foreach ($columns as $column) {
+            if (!$column instanceof Column) {
+                throw new \InvalidArgumentException('All values in the array must be instances of Column.');
+            }
+        }
+
+        $this->data[$key] = $columns;
+    }
+
+    public function get(string $key): array
+    {
+        if (isset($this->data[$key])) {
+            return $this->data[$key];
+        }
+        return [];
+    }
+}

+ 47 - 0
src/Cache/EntityMemcachedCache.php

@@ -0,0 +1,47 @@
+<?php
+
+namespace PhpDevCommunity\PaperORM\Cache;
+
+
+final class EntityMemcachedCache
+{
+    /**
+     * @var array<object>
+     */
+    private array $cache = [];
+
+
+    public function get(string $class, string $primaryKeyValue): ?object
+    {
+        $key = $this->generateKey($class, $primaryKeyValue);
+        if ($this->has($key)) {
+            return $this->cache[$key];
+        }
+        return null;
+    }
+
+    public function has(string $key): bool
+    {
+        return isset($this->cache[$key]);
+    }
+
+    public function set(string $class, string $primaryKeyValue, object $value): void
+    {
+        $this->cache[$this->generateKey($class, $primaryKeyValue)] = $value;
+    }
+
+    public function invalidate(string $class, string $primaryKeyValue): void
+    {
+        unset($this->cache[$this->generateKey($class, $primaryKeyValue)]);
+    }
+
+    public function clear(): void
+    {
+        $this->cache = [];
+    }
+
+    private function generateKey(string $class, string $primaryKeyValue): string
+    {
+        return md5($class . $primaryKeyValue);
+    }
+}

+ 39 - 0
src/Cache/OneToManyCache.php

@@ -0,0 +1,39 @@
+<?php
+
+namespace PhpDevCommunity\PaperORM\Cache;
+
+use InvalidArgumentException;
+use PhpDevCommunity\PaperORM\Mapping\OneToMany;
+
+final class OneToManyCache
+{
+    private static ?OneToManyCache $instance = null;
+    private array $data = [];
+
+    public static function getInstance(): self
+    {
+        if (self::$instance === null) {
+            self::$instance = new self();
+        }
+        return self::$instance;
+    }
+
+    public function set(string $key, array $oneToManyRelations)
+    {
+        foreach ($oneToManyRelations as $oneToManyRelation) {
+            if (!$oneToManyRelation instanceof OneToMany) {
+                throw new InvalidArgumentException(self::class . ' - All values in the array must be instances of OneToMany.');
+            }
+        }
+
+        $this->data[$key] = $oneToManyRelations;
+    }
+
+    public function get(string $key): array
+    {
+        if (isset($this->data[$key])) {
+            return $this->data[$key];
+        }
+        return [];
+    }
+}

+ 32 - 0
src/Cache/PrimaryKeyColumnCache.php

@@ -0,0 +1,32 @@
+<?php
+
+namespace PhpDevCommunity\PaperORM\Cache;
+
+
+use PhpDevCommunity\PaperORM\Mapping\Column\PrimaryKeyColumn;
+
+final class PrimaryKeyColumnCache
+{
+    private static ?PrimaryKeyColumnCache $instance = null;
+    private array $data = [];
+
+    public static function getInstance(): self
+    {
+        if (self::$instance === null) {
+            self::$instance = new self();
+        }
+        return self::$instance;
+    }
+    public function set(string $key, PrimaryKeyColumn $primaryKeyColumn)
+    {
+        $this->data[$key] = $primaryKeyColumn;
+    }
+
+    public function get(string $key): ?PrimaryKeyColumn
+    {
+        if (isset($this->data[$key])) {
+            return $this->data[$key];
+        }
+        return null;
+    }
+}

+ 166 - 0
src/Collection/ObjectStorage.php

@@ -0,0 +1,166 @@
+<?php
+
+namespace PhpDevCommunity\PaperORM\Collection;
+
+use SplObjectStorage;
+
+class ObjectStorage extends SplObjectStorage
+{
+
+    public function __construct(array $data = [])
+    {
+        foreach ($data as $item) {
+            $this->attach($item);
+        }
+    }
+
+    /**
+     * Find the object with the given primary key value.
+     *
+     * @param mixed $pk The primary key value to search for.
+     * @return object|null The object with the given primary key value, or null if not found.
+     */
+    public function findPk($pk): ?object
+    {
+        if ($pk === null) {
+            return null;
+        }
+
+        foreach ($this as $object) {
+            if (method_exists($object, 'getId') && $object->getId() === $pk) {
+                return $object;
+            }
+            if (method_exists($object, 'getPrimaryKeyValue') && $object->getPrimaryKeyValue() === $pk) {
+                return $object;
+            }
+        }
+        return null;
+    }
+
+    /**
+     * Finds and returns an object based on the specified method and value.
+     *
+     * @param string $method The method to search by
+     * @param mixed $value The value to search for
+     * @return object|null The found object or null if not found
+     */
+    public function findOneBy(string $method, $value): ?object
+    {
+        foreach ($this as $object) {
+            if (method_exists($object, $method) && $object->$method() === $value) {
+                return $object;
+            }
+        }
+        return null;
+
+    }
+
+    /**
+     * Finds an object in the collection using a callback.
+     *
+     * @param callable $callback The callback used for searching.
+     * @return mixed|null The found object or null if no object matches the criteria.
+     */
+    public function find(callable $callback)
+    {
+        foreach ($this as $item) {
+            if ($callback($item)) {
+                return $item;
+            }
+        }
+        return null;
+    }
+
+    /**
+     * Finds all objects in the collection that match a given criteria.
+     *
+     * @param callable $callback The callback used for searching.
+     * @return array An array containing all objects that match the criteria.
+     */
+    public function filter(callable $callback): array
+    {
+        $foundObjects = [];
+        foreach ($this as $item) {
+            if ($callback($item)) {
+                $foundObjects[] = $item;
+            }
+        }
+        return $foundObjects;
+    }
+
+    public function isEmpty(): bool
+    {
+        return count($this) === 0;
+    }
+
+    /**
+     * Retrieves the first object in the collection.
+     *
+     * @return mixed|null The first object or null if the collection is empty.
+     */
+    public function first()
+    {
+        foreach ($this as $item) {
+            return $item;
+        }
+        return null;
+    }
+
+    /**
+     * Converts the collection to an array.
+     *
+     * @return array The collection converted to an array.
+     */
+    public function toArray(): array
+    {
+        return iterator_to_array($this);
+    }
+
+    /**
+     * Retrieves the last item in the collection.
+     *
+     * @return mixed|null The last item in the collection, or null if the collection is empty.
+     */
+    public function last()
+    {
+        $last = null;
+        foreach ($this as $item) {
+            $last = $item;
+        }
+        return $last;
+    }
+
+    /**
+     * Removes all objects from the collection.
+     */
+    public function clear(): void
+    {
+        foreach ($this as $item) {
+            $this->detach($item);
+        }
+    }
+
+    /**
+     * Adds an object to the collection.
+     *
+     * @param object $object The object to be added.
+     * @return self Returns the updated collection.
+     */
+    public function add(object $object): self
+    {
+        $this->attach($object);
+        return $this;
+    }
+
+    /**
+     * Removes an object from the collection.
+     *
+     * @param object $object The object to be removed.
+     * @return self Returns the updated collection.
+     */
+    public function remove(object $object): self
+    {
+        $this->detach($object);
+        return $this;
+    }
+}

+ 55 - 0
src/Command/DatabaseCreateCommand.php

@@ -0,0 +1,55 @@
+<?php
+
+namespace PhpDevCommunity\PaperORM\Command;
+
+use PhpDevCommunity\Console\Command\CommandInterface;
+use PhpDevCommunity\Console\InputInterface;
+use PhpDevCommunity\Console\Option\CommandOption;
+use PhpDevCommunity\Console\Output\ConsoleOutput;
+use PhpDevCommunity\Console\OutputInterface;
+use PhpDevCommunity\PaperORM\EntityManager;
+
+class DatabaseCreateCommand implements CommandInterface
+{
+    private EntityManager $entityManager;
+
+    public function __construct(EntityManager $entityManager)
+    {
+        $this->entityManager = $entityManager;
+    }
+
+    public function getName(): string
+    {
+        return 'paper:database:create';
+    }
+
+    public function getDescription(): string
+    {
+        return 'Create a new SQL database';
+    }
+
+    public function getOptions(): array
+    {
+        return [
+            new CommandOption('if-not-exists', null, 'Create the SQL database only if it does not already exist', true)
+        ];
+    }
+
+    public function getArguments(): array
+    {
+        return [];
+    }
+
+    public function execute(InputInterface $input, OutputInterface $output): void
+    {
+        $io = ConsoleOutput::create($output);
+        $platform = $this->entityManager->createDatabasePlatform();
+        if ($input->getOptionValue('if-not-exists') === true) {
+            $platform->createDatabaseIfNotExists();
+            $io->info(sprintf('The SQL database "%s" has been successfully created (if it did not already exist).', $platform->getDatabaseName()));
+        } else {
+            $platform->createDatabase();
+            $io->success(sprintf('The SQL database "%s" has been successfully created.', $platform->getDatabaseName()));
+        }
+    }
+}

+ 67 - 0
src/Command/DatabaseDropCommand.php

@@ -0,0 +1,67 @@
+<?php
+
+namespace PhpDevCommunity\PaperORM\Command;
+
+use LogicException;
+use PhpDevCommunity\Console\Command\CommandInterface;
+use PhpDevCommunity\Console\InputInterface;
+use PhpDevCommunity\Console\Option\CommandOption;
+use PhpDevCommunity\Console\Output\ConsoleOutput;
+use PhpDevCommunity\Console\OutputInterface;
+use PhpDevCommunity\PaperORM\EntityManager;
+
+class DatabaseDropCommand implements CommandInterface
+{
+    private EntityManager $entityManager;
+
+    private ?string $env;
+
+    public function __construct(EntityManager $entityManager, ?string $env = null)
+    {
+        $this->entityManager = $entityManager;
+        $this->env = $env;
+    }
+
+    public function getName(): string
+    {
+        return 'paper:database:drop';
+    }
+
+    public function getDescription(): string
+    {
+        return 'Drop the SQL database';
+    }
+
+    public function getOptions(): array
+    {
+        return [
+            new CommandOption('force', 'f', 'Force the database drop', true)
+        ];
+    }
+
+    public function getArguments(): array
+    {
+        return [];
+    }
+
+    public function execute(InputInterface $input, OutputInterface $output): void
+    {
+        $io = ConsoleOutput::create($output);
+        if (!$this->isEnabled()) {
+            throw new LogicException('This command is only available in `dev` environment.');
+        }
+
+        if (!$input->getOptionValue('force')) {
+            throw new LogicException('You must use the --force option to drop the database.');
+        }
+
+        $platform = $this->entityManager->createDatabasePlatform();
+        $platform->dropDatabase();
+        $io->success('The SQL database has been successfully dropped.');
+    }
+
+    private function isEnabled(): bool
+    {
+        return 'dev' === $this->env;
+    }
+}

+ 58 - 0
src/Command/QueryExecuteCommand.php

@@ -0,0 +1,58 @@
+<?php
+
+namespace PhpDevCommunity\PaperORM\Command;
+
+use PhpDevCommunity\Console\Argument\CommandArgument;
+use PhpDevCommunity\Console\Command\CommandInterface;
+use PhpDevCommunity\Console\InputInterface;
+use PhpDevCommunity\Console\Output\ConsoleOutput;
+use PhpDevCommunity\Console\OutputInterface;
+use PhpDevCommunity\PaperORM\EntityManager;
+
+class QueryExecuteCommand implements CommandInterface
+{
+    private EntityManager $entityManager;
+    public function __construct(EntityManager $entityManager)
+    {
+        $this->entityManager = $entityManager;
+    }
+
+    public function getName(): string
+    {
+       return 'paper:query:execute';
+    }
+
+    public function getDescription(): string
+    {
+        return 'Execute a SQL query';
+    }
+
+    public function getOptions(): array
+    {
+        return [];
+    }
+
+    public function getArguments(): array
+    {
+        return [
+            new CommandArgument('query', true, null, 'The SQL query : select * from users')
+        ];
+    }
+
+    public function execute(InputInterface $input, OutputInterface $output): void
+    {
+        $io = ConsoleOutput::create($output);
+        $query = $input->getOptionValue("query");
+        if ($query === null) {
+            throw new \LogicException("SQL query is required");
+        }
+        $data = $this->entityManager->getConnection()->fetchAll($query);
+        if ($data === []) {
+            $io->info('The query yielded an empty result set.');
+            return;
+        }
+
+        $io->title('Database : ' . $this->entityManager->createDatabasePlatform()->getDatabaseName());
+        $io->table(array_keys($data[0]), $data);
+    }
+}

+ 85 - 0
src/Command/ShowTablesCommand.php

@@ -0,0 +1,85 @@
+<?php
+
+namespace PhpDevCommunity\PaperORM\Command;
+
+use PhpDevCommunity\Console\Argument\CommandArgument;
+use PhpDevCommunity\Console\Command\CommandInterface;
+use PhpDevCommunity\Console\InputInterface;
+use PhpDevCommunity\Console\Option\CommandOption;
+use PhpDevCommunity\Console\Output\ConsoleOutput;
+use PhpDevCommunity\Console\OutputInterface;
+use PhpDevCommunity\PaperORM\EntityManager;
+use PhpDevCommunity\PaperORM\Metadata\ColumnMetadata;
+
+class ShowTablesCommand implements CommandInterface
+{
+    private EntityManager $entityManager;
+    public function __construct(EntityManager $entityManager)
+    {
+        $this->entityManager = $entityManager;
+    }
+
+    public function getName(): string
+    {
+        return 'paper:show:tables';
+    }
+
+    public function getDescription(): string
+    {
+        return 'Show the list of tables in the SQL database';
+    }
+
+    public function getOptions(): array
+    {
+        return [
+             new CommandOption('columns', null, 'Show the list of columns table ', true)
+        ];
+    }
+
+    public function getArguments(): array
+    {
+        return [
+            new CommandArgument( 'table', false, null, 'The name of the table')
+        ];
+    }
+
+    public function execute(InputInterface $input, OutputInterface $output): void
+    {
+        $io = ConsoleOutput::create($output);
+        $tableName = null;
+        $withColumns = false;
+        if ($input->hasArgument('table')) {
+            $tableName = $input->getArgumentValue('table');
+        }
+        if ($input->hasOption('columns')) {
+            $withColumns = $input->getOptionValue('columns');
+        }
+
+        $platform = $this->entityManager->createDatabasePlatform();
+        $io->info('Database : ' . $platform->getDatabaseName());
+        $tables = $platform->listTables();
+        if ($tableName !== null) {
+            if (!in_array($tableName, $tables)) {
+                throw new \LogicException(sprintf('The table "%s" does not exist', $tableName));
+            }
+            $tables = [$tableName];
+        }
+
+        if ($withColumns === false) {
+            $io->table(['Tables'], array_map(function (string $table) {
+                return [$table];
+            }, $tables));
+            $io->writeln('');
+        }else {
+            foreach ($tables as $table) {
+                $io->title(sprintf('Table : %s', $table));
+                if ($withColumns === true) {
+                    $columns = array_map(function (ColumnMetadata $column) {
+                        return $column->toArray();
+                    }, $platform->listTableColumns($table));
+                    $io->table(array_keys($columns[0]), $columns);
+                }
+            }
+        }
+    }
+}

+ 48 - 0
src/Debugger/PDOStatementLogger.php

@@ -0,0 +1,48 @@
+<?php
+
+namespace PhpDevCommunity\PaperORM\Debugger;
+
+class PDOStatementLogger extends \PDOStatement
+{
+    private SqlDebugger $debugger;
+    private array $boundParams = [];
+    protected function __construct(SqlDebugger $debugger)
+    {
+        $this->debugger = $debugger;
+    }
+
+    public function execute($params = null): bool
+    {
+        $this->startQuery($this->queryString, $params ?: $this->boundParams);
+        $result = parent::execute($params);
+        $this->stopQuery();
+        return $result;
+    }
+
+    public function bindValue($param, $value, $type = \PDO::PARAM_STR): bool
+    {
+        $this->boundParams[$param] = $value;
+        return parent::bindValue($param, $value, $type);
+    }
+
+    private function startQuery(string $query, array $params): void
+    {
+        if ($this->getSqlDebugger() === null) {
+            return;
+        }
+        $this->getSqlDebugger()->startQuery($query , $params);
+    }
+
+    private function stopQuery(): void
+    {
+        if ($this->getSqlDebugger() === null) {
+            return;
+        }
+        $this->getSqlDebugger()->stopQuery();
+    }
+
+    public function getSqlDebugger(): ?SqlDebugger
+    {
+        return $this->debugger;
+    }
+}

+ 41 - 0
src/Debugger/SqlDebugger.php

@@ -0,0 +1,41 @@
+<?php
+
+namespace PhpDevCommunity\PaperORM\Debugger;
+
+final class SqlDebugger
+{
+    private array $queries = [];
+
+    public int $currentQuery = 0;
+
+    public function startQuery(string $query, array $params): void
+    {
+        $query = preg_replace('/\s*\n\s*/', ' ', $query);
+        $this->queries[++$this->currentQuery] = [
+            'query' => sprintf('[%s] %s', strtok($query, " "), $query),
+            'params' => $params,
+            'startTime' => microtime(true),
+            'executionTime' => 0
+        ];
+    }
+
+    public function stopQuery(): void
+    {
+        if (!isset($this->queries[$this->currentQuery]['startTime'])) {
+            throw new \LogicException('stopQuery() called without startQuery()');
+        }
+
+        $start = $this->queries[$this->currentQuery]['startTime'];
+        $this->queries[$this->currentQuery]['executionTime'] = microtime(true) - $start;
+    }
+
+    public function getQueries(): array
+    {
+        return array_values($this->queries);
+    }
+
+    public function clear(): void
+    {
+        $this->queries = [];
+    }
+}

+ 15 - 0
src/Driver/DriverInterface.php

@@ -0,0 +1,15 @@
+<?php
+
+namespace PhpDevCommunity\PaperORM\Driver;
+
+use PhpDevCommunity\PaperORM\PaperConnection;
+use PhpDevCommunity\PaperORM\Pdo\PaperPDO;
+use PhpDevCommunity\PaperORM\Platform\PlatformInterface;
+use PhpDevCommunity\PaperORM\Schema\SchemaInterface;
+
+interface DriverInterface
+{
+    public function connect(array $params): PaperPDO;
+    public function createDatabasePlatform(PaperConnection $connection): PlatformInterface;
+    public function createDatabaseSchema(): SchemaInterface;
+}

+ 32 - 0
src/Driver/DriverManager.php

@@ -0,0 +1,32 @@
+<?php
+
+namespace PhpDevCommunity\PaperORM\Driver;
+
+use Exception;
+use PhpDevCommunity\PaperORM\PaperConnection;
+
+class DriverManager
+{
+    private static array $driverSchemeAliases = [
+        'sqlite' => SqliteDriver::class,
+        'sqlite3' => SqliteDriver::class,
+    ];
+
+    public static function getConnection(string $driver, array $params): PaperConnection
+    {
+        $driver = strtolower($driver);
+
+        $drivers = self::$driverSchemeAliases;
+        if (isset($params['driver_class'])) {
+            $drivers[$driver] = $params['driver_class'];
+        }
+        if (!isset($drivers[$driver])) {
+            throw new Exception('Driver not found, please check your config : ' . $driver);
+        }
+
+        $driver = $drivers[$driver];
+        $driver = new $driver();
+        return new PaperConnection($driver, $params);
+    }
+
+}

+ 57 - 0
src/Driver/SqliteDriver.php

@@ -0,0 +1,57 @@
+<?php
+
+namespace PhpDevCommunity\PaperORM\Driver;
+
+use PDO;
+use PhpDevCommunity\PaperORM\PaperConnection;
+use PhpDevCommunity\PaperORM\Pdo\PaperPDO;
+use PhpDevCommunity\PaperORM\Platform\PlatformInterface;
+use PhpDevCommunity\PaperORM\Platform\SqlitePlatform;
+use PhpDevCommunity\PaperORM\Schema\SqliteSchema;
+
+final class SqliteDriver implements DriverInterface
+{
+    public function connect(
+        #[SensitiveParameter]
+        array $params
+    ): PaperPDO
+    {
+        $driverOptions = $params['driverOptions'] ?? [
+            PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION
+        ];
+
+        return new PaperPDO(
+            $this->constructPdoDsn(array_intersect_key($params, ['path' => true, 'memory' => true])),
+            $params['user'] ?? '',
+            $params['password'] ?? '',
+            $driverOptions,
+        );
+    }
+
+    /**
+     * Constructs the Sqlite PDO DSN.
+     *
+     * @param array<string, mixed> $params
+     */
+    private function constructPdoDsn(array $params): string
+    {
+        $dsn = 'sqlite:';
+        if (isset($params['path'])) {
+            $dsn .= $params['path'];
+        } elseif (isset($params['memory'])) {
+            $dsn .= ':memory:';
+        }
+
+        return $dsn;
+    }
+
+    public function createDatabasePlatform(PaperConnection $connection): PlatformInterface
+    {
+        return new SqlitePlatform($connection, $this->createDatabaseSchema());
+    }
+
+    public function createDatabaseSchema(): SqliteSchema
+    {
+        return new SqliteSchema();
+    }
+}

+ 11 - 0
src/Entity/EntityInterface.php

@@ -0,0 +1,11 @@
+<?php
+
+namespace PhpDevCommunity\PaperORM\Entity;
+
+interface EntityInterface
+{
+    static public function getTableName(): string;
+    static public function getRepositoryName(): ?string;
+    static public function columnsMapping(): array;
+    public function getPrimaryKeyValue();
+}

+ 118 - 0
src/EntityManager.php

@@ -0,0 +1,118 @@
+<?php
+
+namespace PhpDevCommunity\PaperORM;
+
+use PhpDevCommunity\PaperORM\Cache\EntityMemcachedCache;
+use PhpDevCommunity\PaperORM\Driver\DriverManager;
+use PhpDevCommunity\PaperORM\Mapper\EntityMapper;
+use PhpDevCommunity\PaperORM\Platform\PlatformInterface;
+use PhpDevCommunity\PaperORM\Repository\Repository;
+
+class EntityManager
+{
+    private PaperConnection $connection;
+
+    private UnitOfWork $unitOfWork;
+
+    /**
+     * @var array<string, Repository>
+     */
+    private array $repositories = [];
+
+    private EntityMemcachedCache $cache;
+
+    public function __construct(array $config = [])
+    {
+        $driver = $config['driver'];
+        $this->connection = DriverManager::getConnection($driver, $config);
+        $this->unitOfWork = new UnitOfWork();
+        $this->cache = new EntityMemcachedCache();
+    }
+
+    public function persist(object $entity): void
+    {
+        $this->unitOfWork->persist($entity);
+    }
+
+    public function remove(object $entity): void
+    {
+        $this->unitOfWork->remove($entity);
+    }
+
+    public function flush(): void
+    {
+        foreach ($this->unitOfWork->getEntityInsertions() as &$entity) {
+            $repository = $this->getRepository(get_class($entity));
+            $repository->insert($entity);
+            $this->unitOfWork->unsetEntity($entity);
+        }
+
+        foreach ($this->unitOfWork->getEntityUpdates() as $entityToUpdate) {
+            $repository = $this->getRepository(get_class($entityToUpdate));
+            $repository->update($entityToUpdate);
+            $this->unitOfWork->unsetEntity($entityToUpdate);
+        }
+
+        foreach ($this->unitOfWork->getEntityDeletions() as $entityToDelete) {
+            $repository = $this->getRepository(get_class($entityToDelete));
+            $repository->delete($entityToDelete);
+            $this->unitOfWork->unsetEntity($entityToDelete);
+        }
+
+        $this->unitOfWork->clear();
+    }
+
+    public function getRepository(string $entity): Repository
+    {
+        $repositoryName = EntityMapper::getRepositoryName($entity);
+        if ($repositoryName === null) {
+            $repositoryName = 'ProxyRepository'.$entity;
+        }
+        if (!isset($this->repositories[$repositoryName])) {
+            if (!class_exists($repositoryName)) {
+                $repository = new class($entity, $this) extends Repository
+                {
+                    private string $entityName;
+                    public function __construct($entityName, EntityManager $em)  {
+                        $this->entityName = $entityName;
+                        parent::__construct($em);
+                    }
+
+                    public function getEntityName(): string
+                    {
+                        return $this->entityName;
+                    }
+                };
+            }else {
+                $repository = new $repositoryName($this);
+            }
+            $this->repositories[$repositoryName] = $repository;
+        }
+
+        return  $this->repositories[$repositoryName];
+    }
+
+
+    public function createDatabasePlatform(): PlatformInterface
+    {
+        $driver = $this->connection->getDriver();
+        return $driver->createDatabasePlatform($this->getConnection());
+    }
+
+
+    public function getConnection(): PaperConnection
+    {
+        return $this->connection;
+    }
+
+    public function getCache(): EntityMemcachedCache
+    {
+        return $this->cache;
+    }
+
+    public function clear(): void
+    {
+        $this->getCache()->clear();
+    }
+
+}

+ 180 - 0
src/Expression/Expr.php

@@ -0,0 +1,180 @@
+<?php
+
+namespace PhpDevCommunity\PaperORM\Expression;
+
+use LogicException;
+
+class Expr
+{
+    private string $key;
+    private string $operator;
+    private $value;
+    private ?string $alias;
+    private bool $prepared = false;
+
+    public function __construct(string $key, string $operator, $value = null)
+    {
+        if ( ($operator === 'IN' || $operator === 'NOT IN') && !is_array($value)) {
+            throw new LogicException('IN and NOT IN operators require an array '. gettype($value) . ' given');
+        }
+
+        $this->key = $key;
+        $this->operator = $operator;
+        $this->value = $value;
+    }
+
+    public function toPrepared(string $alias = null): string
+    {
+        $this->prepared = true;
+        $this->alias = $alias;
+
+        $str = $this->__toString();
+
+        $this->prepared = false;
+        $this->alias = null;
+
+        return $str;
+    }
+
+    public function __toString(): string
+    {
+        $key = $this->key;
+        if ($this->alias !== null) {
+            $key = sprintf('%s.%s', $this->alias, $this->key);
+        }
+
+        $value = $this->getValue();
+        if ($this->prepared) {
+            $value = [];
+            foreach ($this->getBoundValue() as $k => $v) {
+                $value[] = $k;
+            }
+            $value = implode(', ', $value);
+        }
+
+
+        switch ($this->operator) {
+            case '=':
+                $str = "$key = $value";
+                break;
+            case '!=':
+                $str = "$key <> $value";
+                break;
+            case '>':
+                $str = "$key > $value";
+                break;
+            case '>=':
+                $str = "$key >= $value";
+                break;
+            case '<':
+                $str = "$key < $value";
+                break;
+            case '<=':
+                $str = "$key <= $value";
+                break;
+            case 'NULL':
+                $str = "$key IS NULL";
+                break;
+            case '!NULL':
+                $str = "$key IS NOT NULL";
+                break;
+            case 'IN':
+                $str = "$key IN (" . $value . ")";
+                break;
+            case '!IN':
+                $str = "$key NOT IN (" . $value . ")";
+                break;
+            default:
+                throw new LogicException('Unknown operator ' . $this->operator);
+        }
+
+        return $str;
+    }
+
+    public function getKey(): string
+    {
+        return $this->key;
+    }
+
+    public function getAliasKey(): string
+    {
+        return ':' . $this->getKey();
+    }
+
+    public function getValue()
+    {
+        return $this->value;
+    }
+
+    public function getBoundValue()
+    {
+        if ($this->getValue() === null) {
+            return [];
+        }
+        
+        if ($this->operator === 'IN' || $this->operator === '!IN') {
+            $value = [];
+            foreach ($this->getValue() as $k => $v) {
+                $key = $this->getAliasKey() . '_' . $k;
+                $value[$key] = $v;
+            }
+            return $value;
+        }
+        return [$this->getAliasKey() => $this->getValue()];
+    }
+
+    public static function or(string ...$expressions): string
+    {
+        return '(' . implode(') OR (', $expressions) . ')';
+    }
+
+    public static function equal(string $key, $value): self
+    {
+        return new self($key, '=', $value);
+    }
+
+    public static function notEqual(string $key, $value): self
+    {
+        return new self($key, '!=', $value);
+    }
+
+    public static function greaterThan(string $key, $value): self
+    {
+        return new self($key, '>', $value);
+    }
+
+    public static function greaterThanEqual(string $key, $value): self
+    {
+        return new self($key, '>=', $value);
+    }
+
+    public static function lowerThan(string $key, $value): self
+    {
+        return new self($key, '<', $value);
+    }
+
+    public static function lowerThanEqual(string $key, $value): self
+    {
+        return new self($key, '<=', $value);
+    }
+
+    public static function isNull(string $key): self
+    {
+        return new self($key, 'NULL');
+    }
+
+    public static function isNotNull(string $key): self
+    {
+        return new self($key, '!NULL');
+    }
+
+    public static function in(string $key, array $values): self
+    {
+        return new self($key, 'IN', $values);
+    }
+
+    public static function notIn(string $key, array $values): self
+    {
+        return new self($key, '!IN', $values);
+    }
+}

+ 142 - 0
src/Generator/SchemaDiffGenerator.php

@@ -0,0 +1,142 @@
+<?php
+
+namespace PhpDevCommunity\PaperORM\Generator;
+
+use LogicException;
+use PhpDevCommunity\PaperORM\Mapping\Column\Column;
+use PhpDevCommunity\PaperORM\Mapping\Index;
+use PhpDevCommunity\PaperORM\Platform\PlatformInterface;
+
+final class SchemaDiffGenerator
+{
+    private PlatformInterface $platform;
+
+    public function __construct(PlatformInterface $platform)
+    {
+        $this->platform = $platform;
+    }
+
+    public function generateDiffStatements(array $tables): array
+    {
+        $tablesExist = $this->platform->listTables();
+        $schema = $this->platform->getSchema();
+        $sqlUp = [];
+        $sqlDown = [];
+        foreach ($tables as $tableName => $tableData) {
+
+            if (!isset($tableData['columns'])) {
+                throw new LogicException(sprintf(
+                    "Missing column definitions for table '%s'. Each table must have a 'columns' key with its column structure.",
+                    $tableName
+                ));
+            }
+
+            if (!isset($tableData['indexes'])) {
+                throw new LogicException(sprintf(
+                    "Missing index definitions for table '%s'. Ensure the 'indexes' key is set, even if empty, to maintain consistency.",
+                    $tableName
+                ));
+            }
+
+            /**
+             * @var array<Column> $columns
+             * @var array<Index> $indexes
+             */
+            $columns = $tableData['columns'];
+            $indexes = $tableData['indexes'];
+
+            if ($schema->supportsIndexes()) {
+                foreach ($columns as $column) {
+                    if (!$column->getIndex()) {
+                        continue;
+                    }
+                    $indexes[] = $column->getIndex();
+                }
+            } else {
+                $indexes = [];
+            }
+
+            $diff = $this->platform->diff($tableName, $columns, $indexes);
+            $columnsToAdd = $diff->getColumnsToAdd();
+            $columnsToUpdate = $diff->getColumnsToUpdate();
+            $columnsToDelete = $diff->getColumnsToDelete();
+
+            $indexesToAdd = $diff->getIndexesToAdd();
+            $indexesToUpdate = $diff->getIndexesToUpdate();
+            $indexesToDelete = $diff->getIndexesToDelete();
+
+            if (!in_array($tableName, $tablesExist)) {
+                $sqlUp[] = $schema->createTable($tableName, $columnsToAdd);
+                foreach ($indexesToAdd as $index) {
+                    $sqlUp[] = $schema->createIndex($index);
+                    $sqlDown[] = $schema->dropIndex($index);
+                }
+                $sqlDown[] = $schema->dropTable($tableName);
+                continue;
+            }
+
+            foreach ($columnsToAdd as $column) {
+                $sqlUp[] = $schema->addColumn($tableName, $column);
+                if ($schema->supportsDropColumn()) {
+                    $sqlDown[] = $schema->dropColumn($tableName, $column);
+                } else {
+                    $sqlDown[] = sprintf(
+                        '-- Drop column %s is not supported with %s. You might need to manually drop the column.',
+                        $column->getName(),
+                        get_class($schema)
+                    );
+                }
+            }
+            foreach ($indexesToAdd as $index) {
+                $sqlUp[] = $schema->createIndex($index);
+                $sqlDown[] = $schema->dropIndex($index);
+            }
+
+            foreach ($columnsToUpdate as $column) {
+                if ($schema->supportsModifyColumn()) {
+                    $sqlUp[] = $schema->modifyColumn($tableName, $column);
+                    $sqlDown[] = $schema->modifyColumn($tableName, $diff->getOriginalColumn($column->getName()));
+                } else {
+                    $sqlUp[] = sprintf(
+                        '-- Modify column %s is not supported with %s. Consider creating a new column and migrating the data.',
+                        $column->getName(),
+                        get_class($schema)
+                    );
+                }
+            }
+
+            foreach ($indexesToDelete as $index) {
+                $sqlUp[] = $schema->dropIndex($index);
+                $sqlDown[] = $schema->createIndex($diff->getOriginalIndex($index->getName()));
+            }
+
+            foreach ($columnsToDelete as $column) {
+                if ($schema->supportsDropColumn()) {
+                    $sqlUp[] = $schema->dropColumn($tableName, $column);
+                    $sqlDown[] = $schema->addColumn($tableName, $diff->getOriginalColumn($column->getName()));
+                } else {
+                    $sqlUp[] = sprintf(
+                        '-- Drop column %s is not supported with %s. Consider manually handling this operation.',
+                        $column->getName(),
+                        get_class($schema)
+                    );
+                }
+            }
+
+            foreach ($indexesToUpdate as $index) {
+                $sqlUp[] = $schema->dropIndex($diff->getOriginalIndex($index->getName()));
+                $sqlUp[] = $schema->createIndex($index);
+
+                $sqlDown[] = $schema->dropIndex($index);
+                $sqlDown[] = $schema->createIndex($diff->getOriginalIndex($index->getName()));
+            }
+        }
+
+
+        return [
+            'up' => $sqlUp,
+            'down' => $sqlDown
+        ];
+    }
+
+}

+ 76 - 0
src/Hydrator/ArrayHydrator.php

@@ -0,0 +1,76 @@
+<?php
+
+namespace PhpDevCommunity\PaperORM\Hydrator;
+
+use LogicException;
+use PhpDevCommunity\PaperORM\Cache\EntityMemcachedCache;
+use PhpDevCommunity\PaperORM\Collection\ObjectStorage;
+use PhpDevCommunity\PaperORM\Entity\EntityInterface;
+use PhpDevCommunity\PaperORM\Mapper\ColumnMapper;
+use PhpDevCommunity\PaperORM\Mapping\Column\JoinColumn;
+use PhpDevCommunity\PaperORM\Mapping\OneToMany;
+use ReflectionClass;
+
+final class ArrayHydrator
+{
+
+    public function __construct()
+    {
+    }
+
+    public function hydrate(string $object, array $data): array
+    {
+        if (!class_exists($object)) {
+            throw new LogicException('Class ' . $object . ' does not exist');
+        }
+        if (!is_subclass_of($object, EntityInterface::class)) {
+            throw new LogicException('Class ' . $object . ' is not an PhpDevCommunity\PaperORM\Entity\EntityInterface');
+        }
+        $columns = array_merge(ColumnMapper::getColumns($object), ColumnMapper::getOneToManyRelations($object));
+
+        $result = [];
+        foreach ($columns as $column) {
+            if ($column instanceof OneToMany || $column instanceof JoinColumn) {
+                $name = $column->getProperty();
+            } else {
+                $name = $column->getName();
+            }
+            if (!array_key_exists($name, $data)) {
+                continue;
+            }
+
+            $value = $data[$name];
+            $propertyName = $column->getProperty();
+            if ($column instanceof JoinColumn) {
+                if (!is_array($value) && $value !== null) {
+                    $value = null;
+                }
+                $entityName = $column->getTargetEntity();
+                if (is_array($value)) {
+                    $value = $this->hydrate($entityName, $value);
+                }
+                $result[$propertyName] = $value;
+                continue;
+            } elseif ($column instanceof OneToMany) {
+                if (!is_array($value)) {
+                    $value = [];
+                }
+                $entityName = $column->getTargetEntity();
+                $storage = [];
+                foreach ($value as $item) {
+                    $storage[] = $this->hydrate($entityName, $item);
+                }
+                $result[$propertyName] = $storage;
+                unset($storage);
+                continue;
+            }
+            $value =  $column->convertToPHP($value);
+            if ($value instanceof \DateTimeInterface) {
+                $value = $column->convertToDatabase($value);
+            }
+            $result[$propertyName] = $value;
+        }
+        return $result;
+    }
+
+}

+ 119 - 0
src/Hydrator/EntityHydrator.php

@@ -0,0 +1,119 @@
+<?php
+
+namespace PhpDevCommunity\PaperORM\Hydrator;
+
+use InvalidArgumentException;
+use LogicException;
+use PhpDevCommunity\PaperORM\Cache\EntityMemcachedCache;
+use PhpDevCommunity\PaperORM\Collection\ObjectStorage;
+use PhpDevCommunity\PaperORM\Entity\EntityInterface;
+use PhpDevCommunity\PaperORM\Mapper\ColumnMapper;
+use PhpDevCommunity\PaperORM\Mapping\Column\JoinColumn;
+use PhpDevCommunity\PaperORM\Mapping\OneToMany;
+use PhpDevCommunity\PaperORM\Proxy\ProxyInterface;
+use ReflectionClass;
+
+final class EntityHydrator
+{
+    private ?EntityMemcachedCache $cache;
+
+    public function __construct(EntityMemcachedCache $cache = null)
+    {
+        $this->cache = $cache;
+    }
+
+    public function hydrate($objectOrClass, array $data): object
+    {
+        if (!class_exists($objectOrClass)) {
+            throw new LogicException('Class ' . $objectOrClass . ' does not exist');
+        }
+        if (!is_subclass_of($objectOrClass, EntityInterface::class)) {
+            throw new LogicException('Class ' . $objectOrClass . ' is not an PhpDevCommunity\PaperORM\Entity\EntityInterface');
+        }
+        $object = $objectOrClass;
+        if (!is_object($object)) {
+            $primaryKeyColumn = ColumnMapper::getPrimaryKeyColumnName($object);
+            $object = $this->cache->get($objectOrClass, $data[$primaryKeyColumn]) ?: $this->createProxyObject($object);
+            $this->cache->set($objectOrClass, $data[$primaryKeyColumn], $object);
+        }
+        $reflection = new ReflectionClass($object);
+        if ($reflection->getParentClass()) {
+            $reflection = $reflection->getParentClass();
+        }
+        $columns = array_merge(ColumnMapper::getColumns($object), ColumnMapper::getOneToManyRelations($object));
+
+        $properties = [];
+        foreach ($columns as $column) {
+
+            if ($column instanceof OneToMany || $column instanceof JoinColumn) {
+                $name = $column->getProperty();
+            } else {
+                $name = $column->getName();
+            }
+
+            if (!array_key_exists($name, $data)) {
+                continue;
+            }
+            $value = $data[$name];
+
+            if (!$column instanceof OneToMany) {
+                $properties[$column->getProperty()] = $column;
+            }
+
+            $property = $reflection->getProperty($column->getProperty());
+            $property->setAccessible(true);
+            if ($column instanceof JoinColumn) {
+                if (!is_array($value) && $value !== null) {
+                    $value = null;
+                }
+                $entityName = $column->getTargetEntity();
+                if (is_array($value)) {
+                    $value = $this->hydrate($entityName, $value);
+                }
+                $property->setValue($object, $value);
+                continue;
+            } elseif ($column instanceof OneToMany) {
+                if (!is_array($value)) {
+                    $value = [];
+                }
+                $entityName = $column->getTargetEntity();
+                $storage = $property->getValue($object) ?: new ObjectStorage();
+                foreach ($value as $item) {
+                    $storage->add($this->hydrate($entityName, $item));
+                }
+                $property->setValue($object, $storage);
+                continue;
+            }
+
+            $property->setValue($object, $column->convertToPHP($value));
+        }
+
+        if ($object instanceof ProxyInterface) {
+            $object->__setInitialized($properties);
+        }
+
+        return $object;
+    }
+
+    private function createProxyObject(string $class)
+    {
+        if (!class_exists($class)) {
+            throw new InvalidArgumentException("Class $class does not exist.");
+        }
+
+
+        $sanitizedClass = str_replace('\\', '_', $class);
+        $proxyClass = 'Proxy_' . $sanitizedClass;
+
+        if (!class_exists($proxyClass)) {
+            eval("
+            class $proxyClass extends \\$class implements \\PhpDevCommunity\\PaperORM\\Proxy\\ProxyInterface {
+                use \\PhpDevCommunity\\PaperORM\\Proxy\\ProxyInitializedTrait;
+            }
+        ");
+        }
+
+        return new $proxyClass();
+    }
+
+}

+ 142 - 0
src/Mapper/ColumnMapper.php

@@ -0,0 +1,142 @@
+<?php
+
+namespace PhpDevCommunity\PaperORM\Mapper;
+
+use InvalidArgumentException;
+use LogicException;
+use PhpDevCommunity\PaperORM\Cache\ColumnCache;
+use PhpDevCommunity\PaperORM\Cache\OneToManyCache;
+use PhpDevCommunity\PaperORM\Cache\PrimaryKeyColumnCache;
+use PhpDevCommunity\PaperORM\Entity\EntityInterface;
+use PhpDevCommunity\PaperORM\Mapping\Column\Column;
+use PhpDevCommunity\PaperORM\Mapping\Column\JoinColumn;
+use PhpDevCommunity\PaperORM\Mapping\Column\PrimaryKeyColumn;
+use PhpDevCommunity\PaperORM\Mapping\OneToMany;
+use ReflectionClass;
+
+final class ColumnMapper
+{
+
+    static public function getPrimaryKeyColumn($class): PrimaryKeyColumn
+    {
+        if (is_object($class)) {
+            $class = get_class($class);
+        }
+        $cache = PrimaryKeyColumnCache::getInstance();
+        if (empty($cache->get($class))) {
+
+            $columnsFiltered = array_filter(self::getColumns($class), function (Column $column) {
+                return $column instanceof PrimaryKeyColumn;
+            });
+
+            if (count($columnsFiltered) === 0) {
+                throw new LogicException(self::class . ' At least one primary key is required. : ' . $class);
+            }
+
+            if (count($columnsFiltered) > 1) {
+                throw new LogicException(self::class . ' Only one primary key is allowed. : ' . $class);
+            }
+
+            $primaryKey = $columnsFiltered[0];
+
+            $cache->set($class, $primaryKey);
+        }
+        return $cache->get($class);
+    }
+    static public function getPrimaryKeyColumnName($class): string
+    {
+        return self::getPrimaryKeyColumn($class)->getName();
+    }
+
+    /**
+     * @param string|object $class
+     * @return array<Column>
+     */
+    static public function getColumns($class): array
+    {
+        if (is_object($class)) {
+            $class = get_class($class);
+        }
+        $cache = ColumnCache::getInstance();
+        if (empty($cache->get($class))) {
+            self::loadCache($class);
+        }
+        return $cache->get($class);
+    }
+
+
+    /**
+     * @param $class
+     * @return array<OneToMany>
+     */
+    final static public function getOneToManyRelations($class): array
+    {
+        if (is_object($class)) {
+            $class = get_class($class);
+        }
+
+        $cache = OneToManyCache::getInstance();
+        if (empty($cache->get($class))) {
+            $columnsMapping = [];
+            if (is_subclass_of($class, EntityInterface::class)) {
+                $columnsMapping = $class::columnsMapping();
+                $columnsMapping = array_filter($columnsMapping, function ($column) {
+                    return $column instanceof OneToMany;
+                });
+            }
+
+            $cache->set($class, $columnsMapping);
+            self::loadCache($class);
+        }
+
+        return $cache->get($class);
+    }
+
+    static public function getColumnByProperty(string $class, string $property): ?Column
+    {
+        $columns = self::getColumns($class);
+        foreach ($columns as $column) {
+            if ($column->getProperty() === $property || $column->getName() === $property) {
+                return $column;
+            }
+        }
+        return null;
+    }
+
+    /**
+     * @param string $class
+     * @param string $property
+     * @return JoinColumn|OneToMany
+     */
+    static public function getRelationColumnByProperty(string $class, string $property)
+    {
+        $columns = array_merge(self::getColumns($class) , self::getOneToManyRelations($class));
+        foreach ($columns as $column) {
+            if ($column->getProperty() === $property || $column->getName() === $property) {
+                if ($column instanceof JoinColumn) {
+                    return $column;
+                }
+                if ($column instanceof OneToMany) {
+                    return $column;
+                }
+            }
+        }
+        throw new \InvalidArgumentException(sprintf('Property %s not found in class %s or is a collection and cannot be used in an expression', $property, $class));
+    }
+
+
+    static private function loadCache(string $class): void
+    {
+        if (is_subclass_of($class, EntityInterface::class)) {
+            $columnsMapping = $class::columnsMapping();
+            $columnsMapping = array_filter($columnsMapping, function ($column) {
+                return $column instanceof Column;
+            });
+        }
+        if (empty($columnsMapping)) {
+            throw new InvalidArgumentException('No columns found. : ' . $class);
+        }
+
+        ColumnCache::getInstance()->set($class, $columnsMapping);
+    }
+}

+ 25 - 0
src/Mapper/EntityMapper.php

@@ -0,0 +1,25 @@
+<?php
+
+namespace PhpDevCommunity\PaperORM\Mapper;
+
+
+use PhpDevCommunity\PaperORM\Entity\EntityInterface;
+
+final class EntityMapper
+{
+    static public function getTable($class): string
+    {
+        if (!is_subclass_of($class, EntityInterface::class)) {
+            throw new \LogicException(sprintf('%s must implement %s', $class, EntityInterface::class));
+        }
+        return $class::getTableName();
+    }
+
+    static public function getRepositoryName($class): ?string
+    {
+        if (!is_subclass_of($class, EntityInterface::class)) {
+            throw new \LogicException(sprintf('%s must implement %s', $class, EntityInterface::class));
+        }
+        return $class::getRepositoryName();
+    }
+}

+ 20 - 0
src/Mapping/Column/BoolColumn.php

@@ -0,0 +1,20 @@
+<?php
+
+namespace PhpDevCommunity\PaperORM\Mapping\Column;
+
+use Attribute;
+use PhpDevCommunity\PaperORM\Types\BoolType;
+
+#[Attribute(Attribute::TARGET_CLASS | Attribute::IS_REPEATABLE)]
+final class BoolColumn extends Column
+{
+    public function __construct(
+        string  $property,
+        ?string $name = null,
+        bool    $nullable = false,
+        ?bool $defaultValue = null
+    )
+    {
+        parent::__construct($property, $name, BoolType::class, $nullable, $defaultValue);
+    }
+}

+ 140 - 0
src/Mapping/Column/Column.php

@@ -0,0 +1,140 @@
+<?php
+
+namespace PhpDevCommunity\PaperORM\Mapping\Column;
+
+use PhpDevCommunity\PaperORM\Mapping\Index;
+use PhpDevCommunity\PaperORM\Types\StringType;
+use PhpDevCommunity\PaperORM\Types\Type;
+use PhpDevCommunity\PaperORM\Types\TypeFactory;
+
+abstract class Column
+{
+    private string $property;
+    private ?string $name;
+    private string $type;
+    private bool $unique;
+    private bool $nullable;
+    private $defaultValue;
+    private ?string $firstArgument;
+    private ?string $secondArgument;
+    private ?Index $index = null;
+
+     public function __construct(
+          string $property,
+          ?string $name = null,
+          string $type = StringType::class,
+          bool $nullable = false,
+          $defaultValue = null,
+          bool $unique = false,
+         ?string $firstArgument = null,
+         ?string $secondArgument = null
+     )
+    {
+        $this->property = $property;
+        $this->name = $name;
+        $this->type = $type;
+        $this->defaultValue = $defaultValue;
+        $this->unique = $unique;
+        $this->nullable = $nullable;
+        $this->firstArgument = $firstArgument;
+        $this->secondArgument = $secondArgument;
+
+        if ($this instanceof JoinColumn || $unique === true) {
+            $this->index = new Index([$this->getName()], $unique);
+        }
+    }
+
+    final public function __toString(): string
+    {
+        return $this->getProperty();
+    }
+
+    public function getProperty(): string
+    {
+        return $this->property;
+    }
+
+    final public function getName(): ?string
+    {
+        return $this->name ?: $this->getProperty();
+    }
+
+
+    public function getType(): string
+    {
+        return $this->type;
+    }
+
+    final public function type(string $type): self
+    {
+        $this->type = $type;
+        return $this;
+    }
+
+    public function isUnique(): bool
+    {
+        return $this->unique;
+    }
+
+    public function isNullable(): bool
+    {
+        return $this->nullable;
+    }
+
+    final public function getFirstArgument(): ?string
+    {
+        return $this->firstArgument;
+    }
+
+    final public function getSecondArgument(): ?string
+    {
+        return $this->secondArgument;
+    }
+
+    public function getDefaultValue()
+    {
+        return $this->defaultValue;
+    }
+
+    public function getDefaultValueToDatabase()
+    {
+        return $this->convertToDatabase($this->getDefaultValue());
+    }
+
+    public function getIndex(): ?Index
+    {
+        return $this->index;
+    }
+
+    /**
+     * Converts a value to its corresponding database representation.
+     *
+     * @param mixed $value The value to be converted.
+     * @return mixed The converted value.
+     * @throws \ReflectionException
+     */
+    final function convertToDatabase($value)
+    {
+        $type = $this->getType();
+        if (is_subclass_of($type, Type::class)) {
+            $value = TypeFactory::create($type)->convertToDatabase($value);
+        }
+        return $value;
+    }
+
+    /**
+     * Converts a value to its corresponding PHP representation.
+     *
+     * @param mixed $value The value to be converted.
+     * @return mixed The converted PHP value.
+     * @throws \ReflectionException
+     */
+    final function convertToPHP($value)
+    {
+        $type = $this->getType();
+        if (is_subclass_of($type, Type::class)) {
+            $value = TypeFactory::create($type)->convertToPHP($value);
+        }
+        return $value;
+    }
+}

+ 19 - 0
src/Mapping/Column/DateColumn.php

@@ -0,0 +1,19 @@
+<?php
+
+namespace PhpDevCommunity\PaperORM\Mapping\Column;
+
+use PhpDevCommunity\PaperORM\Types\DateType;
+
+#[\Attribute(\Attribute::TARGET_CLASS|\Attribute::IS_REPEATABLE)]
+final class DateColumn extends Column
+{
+
+    public function __construct(
+        string $property,
+        string $name = null,
+        bool $nullable = false
+    )
+    {
+        parent::__construct($property, $name, DateType::class, $nullable);
+    }
+}

+ 20 - 0
src/Mapping/Column/DateTimeColumn.php

@@ -0,0 +1,20 @@
+<?php
+
+namespace PhpDevCommunity\PaperORM\Mapping\Column;
+
+use Attribute;
+use PhpDevCommunity\PaperORM\Types\DateTimeType;
+
+#[Attribute(Attribute::TARGET_CLASS | Attribute::IS_REPEATABLE)]
+final class DateTimeColumn extends Column
+{
+
+    public function __construct(
+        string $property,
+        string $name = null,
+        bool   $nullable = false
+    )
+    {
+        parent::__construct($property, $name, DateTimeType::class, $nullable);
+    }
+}

+ 34 - 0
src/Mapping/Column/DecimalColumn.php

@@ -0,0 +1,34 @@
+<?php
+
+namespace PhpDevCommunity\PaperORM\Mapping\Column;
+
+use Attribute;
+use PhpDevCommunity\PaperORM\Types\DecimalType;
+
+#[Attribute(Attribute::TARGET_CLASS | Attribute::IS_REPEATABLE)]
+final class DecimalColumn extends Column
+{
+
+    public function __construct(
+        string $property,
+        string $name = null,
+        bool   $nullable = false,
+        string $defaultValue = null,
+        int    $precision = 10,
+        int    $scale = 2,
+        bool   $unique = false
+    )
+    {
+        parent::__construct($property, $name, DecimalType::class, $nullable, $defaultValue, $unique, $precision, $scale);
+    }
+
+    public function getPrecision(): ?int
+    {
+        return $this->getFirstArgument() ? intval($this->getFirstArgument()) : null;
+    }
+
+    public function getScale(): ?int
+    {
+        return $this->getSecondArgument() ? intval($this->getSecondArgument()) : null;
+    }
+}

+ 21 - 0
src/Mapping/Column/FloatColumn.php

@@ -0,0 +1,21 @@
+<?php
+
+namespace PhpDevCommunity\PaperORM\Mapping\Column;
+
+use PhpDevCommunity\PaperORM\Types\FloatType;
+
+#[\Attribute(\Attribute::TARGET_CLASS|\Attribute::IS_REPEATABLE)]
+final class FloatColumn extends Column
+{
+
+    public function __construct(
+        string $property,
+        string $name = null,
+        bool   $nullable = false,
+        ?float $defaultValue = null,
+        bool   $unique = false
+    )
+    {
+        parent::__construct($property, $name, FloatType::class, $nullable, $defaultValue, $unique);
+    }
+}

+ 21 - 0
src/Mapping/Column/IntColumn.php

@@ -0,0 +1,21 @@
+<?php
+
+namespace PhpDevCommunity\PaperORM\Mapping\Column;
+
+use PhpDevCommunity\PaperORM\Types\IntType;
+
+#[\Attribute(\Attribute::TARGET_CLASS|\Attribute::IS_REPEATABLE)]
+final class IntColumn extends Column
+{
+
+    public function __construct(
+        string $property,
+        string $name = null,
+        bool   $nullable = false,
+        ?int $defaultValue = null,
+        bool   $unique = false
+    )
+    {
+        parent::__construct($property, $name, IntType::class, $nullable, $defaultValue, $unique);
+    }
+}

+ 53 - 0
src/Mapping/Column/JoinColumn.php

@@ -0,0 +1,53 @@
+<?php
+
+namespace PhpDevCommunity\PaperORM\Mapping\Column;
+
+use PhpDevCommunity\PaperORM\Types\IntegerType;
+use ReflectionClass;
+
+#[\Attribute(\Attribute::TARGET_CLASS|\Attribute::IS_REPEATABLE)]
+final class JoinColumn extends Column
+{
+    /**
+     * @var string
+     */
+    private string $referencedColumnName;
+    /**
+     * @var string
+     */
+    private string $targetEntity;
+
+    final public function __construct(
+        string  $property,
+        string  $name,
+        string  $referencedColumnName,
+        string  $targetEntity,
+        bool   $nullable = false,
+        bool   $unique = false
+    )
+    {
+        parent::__construct($property, $name, IntegerType::class, $nullable, null, $unique);
+        $this->referencedColumnName = $referencedColumnName;
+        $this->targetEntity = $targetEntity;
+    }
+
+    public function getReferencedColumnName(): string
+    {
+        return $this->referencedColumnName;
+    }
+
+    /**
+     * @return class-string
+     */
+    public function getTargetEntity(): string
+    {
+        return $this->targetEntity;
+    }
+
+
+    public function getType(): string
+    {
+        return '\\' . ltrim(parent::getType(), '\\');
+    }
+
+}

+ 19 - 0
src/Mapping/Column/JsonColumn.php

@@ -0,0 +1,19 @@
+<?php
+
+namespace PhpDevCommunity\PaperORM\Mapping\Column;
+
+use PhpDevCommunity\PaperORM\Types\JsonType;
+
+#[\Attribute(\Attribute::TARGET_CLASS|\Attribute::IS_REPEATABLE)]
+final class JsonColumn extends Column
+{
+    public function __construct(
+        string $property,
+        string $name = null,
+        bool   $nullable = false,
+        array $defaultValue = null
+    )
+    {
+        parent::__construct($property, $name, JsonType::class, $nullable, $defaultValue);
+    }
+}

+ 14 - 0
src/Mapping/Column/PrimaryKeyColumn.php

@@ -0,0 +1,14 @@
+<?php
+
+namespace PhpDevCommunity\PaperORM\Mapping\Column;
+
+use PhpDevCommunity\PaperORM\Types\IntegerType;
+
+#[\Attribute(\Attribute::TARGET_CLASS)]
+final class PrimaryKeyColumn extends Column
+{
+    public function __construct(string $property, string $name = null, string $type = IntegerType::class)
+    {
+        parent::__construct($property, $name, $type);
+    }
+}

+ 27 - 0
src/Mapping/Column/StringColumn.php

@@ -0,0 +1,27 @@
+<?php
+
+namespace PhpDevCommunity\PaperORM\Mapping\Column;
+
+use Attribute;
+use PhpDevCommunity\PaperORM\Types\StringType;
+
+#[Attribute(Attribute::TARGET_CLASS | Attribute::IS_REPEATABLE)]
+final class StringColumn extends Column
+{
+    public function __construct(
+        string $property,
+        string $name = null,
+        int $length = 255,
+        bool   $nullable = false,
+        string $defaultValue = null,
+        bool $unique = false
+    )
+    {
+        parent::__construct($property, $name, StringType::class, $nullable, $defaultValue, $unique, $length);
+    }
+
+    public function getLength(): int
+    {
+        return intval($this->getFirstArgument());
+    }
+}

+ 20 - 0
src/Mapping/Column/TextColumn.php

@@ -0,0 +1,20 @@
+<?php
+
+namespace PhpDevCommunity\PaperORM\Mapping\Column;
+
+use Attribute;
+use PhpDevCommunity\PaperORM\Types\StringType;
+
+#[Attribute(Attribute::TARGET_CLASS | Attribute::IS_REPEATABLE)]
+final class TextColumn extends Column
+{
+    public function __construct(
+        string $property,
+        string $name = null,
+        bool   $nullable = false,
+        string $defaultValue = null
+    )
+    {
+        parent::__construct($property, $name, StringType::class, false, $nullable, $defaultValue);
+    }
+}

+ 27 - 0
src/Mapping/Entity.php

@@ -0,0 +1,27 @@
+<?php
+
+namespace PhpDevCommunity\PaperORM\Mapping;
+
+#[\Attribute(\Attribute::TARGET_CLASS)]
+final class Entity
+{
+    private string $table;
+    private string $repositoryClass;
+
+    public function __construct( string $table, string $repositoryClass)
+    {
+        $this->table = $table;
+        $this->repositoryClass = $repositoryClass;
+    }
+
+    public function getTable(): string
+    {
+        return $this->table;
+    }
+
+    public function getRepositoryClass(): string
+    {
+        return $this->repositoryClass;
+    }
+
+}

+ 51 - 0
src/Mapping/Index.php

@@ -0,0 +1,51 @@
+<?php
+
+namespace PhpDevCommunity\PaperORM\Mapping;
+
+#[\Attribute(\Attribute::TARGET_CLASS)]
+final class Index
+{
+    private array $columns;
+
+    private bool $unique = false;
+    private ?string $name;
+    public function __construct(array $columns, bool $unique = false, string $name = null)
+    {
+        $this->columns = $columns;
+        $this->unique = $unique;
+        $this->name = $name;
+    }
+
+    public function getColumns(): array
+    {
+        return $this->columns;
+    }
+
+    public function setColumns(array $columns): Index
+    {
+        $this->columns = $columns;
+        return $this;
+    }
+
+    public function isUnique(): bool
+    {
+        return $this->unique;
+    }
+
+    public function setUnique(bool $unique): Index
+    {
+        $this->unique = $unique;
+        return $this;
+    }
+
+    public function getName(): ?string
+    {
+        return $this->name;
+    }
+
+    public function setName(?string $name): Index
+    {
+        $this->name = $name;
+        return $this;
+    }
+}

+ 57 - 0
src/Mapping/OneToMany.php

@@ -0,0 +1,57 @@
+<?php
+
+namespace PhpDevCommunity\PaperORM\Mapping;
+
+use PhpDevCommunity\PaperORM\Collection\ObjectStorage;
+
+#[\Attribute(\Attribute::TARGET_CLASS|\Attribute::IS_REPEATABLE)]
+final class OneToMany
+{
+    private string $property;
+    private string $targetEntity;
+    private ?string $mappedBy;
+    private array $criteria;
+    private ObjectStorage $storage;
+    final public function __construct(string $property, string $targetEntity, string $mappedBy = null, array $criteria = [])
+    {
+        $this->property = $property;
+        $this->targetEntity = $targetEntity;
+        $this->mappedBy = $mappedBy;
+        $this->criteria = $criteria;
+        $this->storage = new ObjectStorage();
+    }
+
+    public function getTargetEntity(): string
+    {
+        return $this->targetEntity;
+    }
+
+    public function getMappedBy(): ?string
+    {
+        return $this->mappedBy;
+    }
+
+
+    public function getCriteria(): array
+    {
+        return $this->criteria;
+    }
+
+    public function getDefaultValue():ObjectStorage
+    {
+        return clone $this->storage;
+    }
+    public function getType(): string
+    {
+        return '\\'.ltrim(get_class($this->getDefaultValue()), '\\');
+    }
+
+    public function getName(): string
+    {
+        return $this->getProperty();
+    }
+    public function getProperty(): string
+    {
+        return $this->property;
+    }
+}

+ 153 - 0
src/Metadata/ColumnMetadata.php

@@ -0,0 +1,153 @@
+<?php
+
+namespace PhpDevCommunity\PaperORM\Metadata;
+
+use PhpDevCommunity\PaperORM\Entity\EntityInterface;
+use PhpDevCommunity\PaperORM\Mapping\Column\Column;
+use PhpDevCommunity\PaperORM\Mapping\Column\JoinColumn;
+use PhpDevCommunity\PaperORM\Mapping\Column\PrimaryKeyColumn;
+
+class ColumnMetadata
+{
+    private string $name;
+    private string $type;
+    private bool $isPrimary;
+    private array $foreignKeyMetadata;
+    private bool $isNullable;
+    private $defaultValue;
+    private ?string $comment;
+    private array $attributes;
+    private ?IndexMetadata $indexMetadata;
+
+    public function __construct(
+        string  $name,
+        string  $type,
+        bool    $isPrimary = false,
+        array   $foreignKeyMetadata = [],
+        bool    $isNullable = true,
+                $defaultValue = null,
+        ?string $comment = null,
+        array   $attributes = []
+    )
+    {
+        $this->name = $name;
+        $this->type = strtoupper($type);
+        $this->isPrimary = $isPrimary;
+        $this->foreignKeyMetadata = $foreignKeyMetadata;
+        $this->isNullable = $isNullable;
+        $this->defaultValue = $defaultValue;
+        $this->comment = $comment;
+        $this->attributes = $attributes;
+    }
+
+    // Getters
+    public function getName(): string
+    {
+        return $this->name;
+    }
+
+    public function getType(): string
+    {
+        return $this->type;
+    }
+
+    public function getTypeWithAttributes(): string
+    {
+        if (!empty($this->attributes)) {
+            return sprintf('%s(%s)', $this->getType(), implode(',', $this->attributes));
+        }
+        return $this->getType();
+    }
+
+    public function isPrimary(): bool
+    {
+        return $this->isPrimary;
+    }
+
+    public function getForeignKeyMetadata(): array
+    {
+        return $this->foreignKeyMetadata;
+    }
+
+    public function isNullable(): bool
+    {
+        return $this->isNullable;
+    }
+
+    public function getDefaultValue()
+    {
+        return $this->defaultValue;
+    }
+
+    public function getComment(): ?string
+    {
+        return $this->comment;
+    }
+
+    public function getAttributes(): array
+    {
+        return $this->attributes;
+    }
+
+    public static function fromColumn(Column $column, string $sqlType): self
+    {
+        $foreignKeyMetadata = [];
+        if ($column instanceof JoinColumn) {
+            $targetEntity = $column->getTargetEntity();
+            if (is_subclass_of($targetEntity, EntityInterface::class)) {
+                $foreignKeyMetadata = [
+                    'referencedTable' => $targetEntity::getTableName(),
+                    'referencedColumn' => $column->getReferencedColumnName(),
+                ];
+            }
+        }
+
+        $arguments = [];
+        if ($column->getFirstArgument()) {
+            $arguments[] = $column->getFirstArgument();
+        }
+        if ($column->getSecondArgument()) {
+            $arguments[] = $column->getSecondArgument();
+        }
+
+        return new self(
+            $column->getName(),
+            $sqlType,
+            $column instanceof PrimaryKeyColumn,
+            $foreignKeyMetadata,
+            $column->isNullable(),
+            $column->getDefaultValue(),
+            null,
+            $arguments
+        );
+    }
+
+    public static function fromArray(array $data): self
+    {
+        return new self(
+            $data['name'],
+            $data['type'],
+            $data['primary'] ?? false,
+            $data['foreignKeyMetadata'] ?? false,
+            $data['null'] ?? true,
+            $data['default'] ?? null,
+            $data['comment'] ?? null,
+            $data['attributes'] ?? []
+        );
+    }
+
+    public function toArray(): array
+    {
+        return [
+            'name' => $this->getName(),
+
+            'type' => $this->getType(),
+            'primary' => $this->isPrimary(),
+            'foreignKeyMetadata' => $this->getForeignKeyMetadata(),
+            'null' => $this->isNullable(),
+            'default' => $this->getDefaultValue(),
+            'comment' => $this->getComment(),
+            'attributes' => $this->getAttributes(),
+        ];
+    }
+}

+ 163 - 0
src/Metadata/DatabaseSchemaDiffMetadata.php

@@ -0,0 +1,163 @@
+<?php
+
+namespace PhpDevCommunity\PaperORM\Metadata;
+
+use LogicException;
+
+final class DatabaseSchemaDiffMetadata
+{
+    private array $columnsToAdd = [];
+    private array $columnsToUpdate = [];
+    private array $columnsToDelete = [];
+    private array $originalColumns = [];
+
+
+    private array $indexesToAdd = [];
+    private array $indexesToUpdate = [];
+    private array $indexesToDelete = [];
+    private array $originalIndexes = [];
+
+    /**
+     * @param ColumnMetadata[] $columnsToAdd
+     * @param ColumnMetadata[] $columnsToUpdate
+     * @param ColumnMetadata[] $columnsToDelete
+     * @param ColumnMetadata[] $originalColumns
+     */
+    public function __construct(
+        array $columnsToAdd,
+        array $columnsToUpdate,
+        array $columnsToDelete,
+        array $originalColumns,
+        array $indexesToAdd,
+        array $indexesToUpdate,
+        array $indexesToDelete,
+        array $originalIndexes
+    )
+    {
+        foreach ($columnsToAdd as $column) {
+            if (!$column instanceof ColumnMetadata) {
+                throw new LogicException(sprintf("The column '%s' is not supported.", get_class($column)));
+            }
+            $this->columnsToAdd[$column->getName()] = $column;
+        }
+
+        foreach ($columnsToUpdate as $column) {
+            if (!$column instanceof ColumnMetadata) {
+                throw new LogicException(sprintf("The column '%s' is not supported.", get_class($column)));
+            }
+            $this->columnsToUpdate[$column->getName()] = $column;
+        }
+
+        foreach ($columnsToDelete as $column) {
+            if (!$column instanceof ColumnMetadata) {
+                throw new LogicException(sprintf("The column '%s' is not supported.", get_class($column)));
+            }
+            $this->columnsToDelete[$column->getName()] = $column;
+        }
+
+        foreach ($originalColumns as $column) {
+            if (!$column instanceof ColumnMetadata) {
+                throw new LogicException(sprintf("The column '%s' is not supported.", get_class($column)));
+            }
+            $this->originalColumns[$column->getName()] = $column;
+        }
+
+
+        foreach ($indexesToAdd as $index) {
+            if (!$index instanceof IndexMetadata) {
+                throw new LogicException(sprintf("The index '%s' is not supported.", get_class($index)));
+            }
+            $this->indexesToAdd[$index->getName()] = $index;
+        }
+
+        foreach ($indexesToUpdate as $index) {
+            if (!$index instanceof IndexMetadata) {
+                throw new LogicException(sprintf("The index '%s' is not supported.", get_class($index)));
+            }
+            $this->indexesToUpdate[$index->getName()] = $index;
+        }
+
+        foreach ($indexesToDelete as $index) {
+            if (!$index instanceof IndexMetadata) {
+                throw new LogicException(sprintf("The index '%s' is not supported.", get_class($index)));
+            }
+            $this->indexesToDelete[$index->getName()] = $index;
+        }
+
+        foreach ($originalIndexes as $index) {
+            if (!$index instanceof IndexMetadata) {
+                throw new LogicException(sprintf("The index '%s' is not supported.", get_class($index)));
+            }
+            $this->originalIndexes[$index->getName()] = $index;
+        }
+    }
+
+    /**
+     * @return ColumnMetadata[]
+     */
+    public function getColumnsToAdd(): array
+    {
+        return $this->columnsToAdd;
+    }
+
+    /**
+     * @return ColumnMetadata[]
+     */
+    public function getColumnsToUpdate(): array
+    {
+        return $this->columnsToUpdate;
+    }
+
+    /**
+     * @return ColumnMetadata[]
+     */
+    public function getColumnsToDelete(): array
+    {
+        return $this->columnsToDelete;
+    }
+
+    public function getOriginalColumn(string $name): ColumnMetadata
+    {
+        if (!isset($this->originalColumns[$name])) {
+            throw new LogicException(sprintf("The column '%s' is not supported.", $name));
+        }
+        return $this->originalColumns[$name];
+    }
+
+
+    /**
+     * @return IndexMetadata[]
+     */
+    public function getIndexesToAdd(): array
+    {
+        return $this->indexesToAdd;
+    }
+
+
+    /**
+     * @return IndexMetadata[]
+     */
+    public function getIndexesToUpdate(): array
+    {
+        return $this->indexesToUpdate;
+    }
+
+
+    /**
+     * @return IndexMetadata[]
+     */
+    public function getIndexesToDelete(): array
+    {
+        return $this->indexesToDelete;
+    }
+
+
+    public function getOriginalIndex(string $name): IndexMetadata
+    {
+        if (!isset($this->originalIndexes[$name])) {
+            throw new LogicException(sprintf("The index '%s' is not supported.", $name));
+        }
+        return $this->originalIndexes[$name];
+    }
+
+}

+ 61 - 0
src/Metadata/IndexMetadata.php

@@ -0,0 +1,61 @@
+<?php
+
+namespace PhpDevCommunity\PaperORM\Metadata;
+
+use PhpDevCommunity\PaperORM\Mapping\Column\JoinColumn;
+
+final class IndexMetadata
+{
+    private string $tableName;
+    private ?string $name;
+    private array $columns;
+    private bool $unique;
+
+    public function __construct(string $tableName, ?string $name, array $columns, bool $unique = false)
+    {
+        $this->tableName = $tableName;
+        $this->name = strtoupper($name);
+        $this->columns = $columns;
+        $this->unique = $unique;
+    }
+
+    public function getTableName(): string
+    {
+        return $this->tableName;
+    }
+
+    public function getName(): ?string
+    {
+        return $this->name;
+    }
+
+    public function getColumns(): array
+    {
+        return $this->columns;
+    }
+
+    public function isUnique(): bool
+    {
+        return $this->unique;
+    }
+
+    public static function fromArray(array $data): self
+    {
+        return new self(
+            $data['tableName'],
+            $data['name'],
+            $data['columns'],
+            $data['unique']
+        );
+    }
+
+    public function toArray(): array
+    {
+        return [
+            'tableName' => $this->getTableName(),
+            'name' => $this->getName(),
+            'columns' => $this->getColumns(),
+            'unique' => $this->isUnique()
+        ];
+    }
+}

+ 54 - 0
src/Migration/MigrationDirectory.php

@@ -0,0 +1,54 @@
+<?php
+
+namespace PhpDevCommunity\PaperORM\Migration;
+
+
+final class MigrationDirectory
+{
+    private string $dir;
+
+    public function __construct(string $dir)
+    {
+        if (!is_dir($dir)) {
+            throw new \InvalidArgumentException("Directory '$dir' does not exist.");
+        }
+
+        if (!is_writable($dir)) {
+            throw new \RuntimeException("Directory '$dir' is not writable.");
+        }
+
+        $this->dir = $dir;
+    }
+
+    public function getMigrations(): array
+    {
+        $migrations = [];
+        foreach (new \DirectoryIterator($this->dir) as $file) {
+            if ($file->getExtension() !== 'sql') {
+                continue;
+            }
+            $version = pathinfo($file->getBasename(), PATHINFO_FILENAME);
+            $migrations[$version] = $file->getPathname();
+        }
+        ksort($migrations);
+        return $migrations;
+    }
+
+    public function getMigration(string $version): string
+    {
+        $migrations = $this->getMigrations();
+        if (!array_key_exists($version, $migrations)) {
+            throw new \InvalidArgumentException("Version '$version' does not exist.");
+        }
+
+        return $migrations[$version];
+    }
+
+    /**
+     * @return string
+     */
+    public function getDir(): string
+    {
+        return $this->dir;
+    }
+}

+ 227 - 0
src/Migration/PaperMigration.php

@@ -0,0 +1,227 @@
+<?php
+
+namespace PhpDevCommunity\PaperORM\Migration;
+
+use DateTime;
+use PDOException;
+use PhpDevCommunity\PaperORM\Entity\EntityInterface;
+use PhpDevCommunity\PaperORM\EntityManager;
+use PhpDevCommunity\PaperORM\Generator\SchemaDiffGenerator;
+use PhpDevCommunity\PaperORM\Mapper\ColumnMapper;
+use PhpDevCommunity\PaperORM\Mapping\Column\DateTimeColumn;
+use PhpDevCommunity\PaperORM\Mapping\Column\StringColumn;
+use PhpDevCommunity\PaperORM\PaperConnection;
+use PhpDevCommunity\PaperORM\Platform\PlatformInterface;
+use RuntimeException;
+use function date;
+use function file_get_contents;
+use function file_put_contents;
+
+final class PaperMigration
+{
+
+    /** @var EntityManager The EntityManager to use for migrations. */
+    private EntityManager $em;
+    private PlatformInterface $platform;
+    private string $tableName;
+
+    /** @var array<string> List of successfully migrated versions. */
+    private array $successList = [];
+    /**
+     * @var MigrationDirectory
+     */
+    private MigrationDirectory $directory;
+
+    public static function create(EntityManager $em, string $tableName, string $directory): self
+    {
+        return new self($em, $tableName, $directory);
+    }
+
+    /**
+     * MigrateService constructor.
+     * @param EntityManager $em
+     * @param string $tableName
+     * @param string $directory
+     */
+    private function __construct(EntityManager $em, string $tableName, string $directory)
+    {
+        $this->em = $em;
+        $this->platform = $em->createDatabasePlatform();
+        $this->tableName = $tableName;
+        $this->directory = new MigrationDirectory($directory);
+    }
+
+    public function generateMigration(array $sqlUp = [], array $sqlDown = []): string
+    {
+        $i = 1;
+        $file = date('YmdHis').$i . '.sql';
+        $filename = $this->directory->getDir() . DIRECTORY_SEPARATOR . $file;
+        while (file_exists($filename)) {
+            $i++;
+            $filename = rtrim($filename, ($i - 1).'.sql') . $i . '.sql';
+        }
+
+        $migrationContent = <<<'SQL'
+-- UP MIGRATION --
+%s
+
+-- DOWN MIGRATION --
+%s
+SQL;
+        foreach ($sqlUp as $key => $value) {
+            $sqlUp[$key] = rtrim($value, ';') . ';';
+        }
+
+        foreach ($sqlDown as $key => $value) {
+            $sqlDown[$key] = rtrim($value, ';') . ';';
+        }
+
+        if (empty($sqlUp)) {
+            $sqlUp[] = '-- Write the SQL code corresponding to the up migration here';
+            $sqlUp[] = '-- You can add the necessary SQL statements for updating the database';
+        }
+        if (empty($sqlDown)) {
+            $sqlDown[] = '-- Write the SQL code corresponding to the down migration here';
+            $sqlDown[] = '-- You can add the necessary SQL statements for reverting the up migration';
+        }
+
+        $migrationContent = sprintf($migrationContent, implode(PHP_EOL, $sqlUp), implode(PHP_EOL, $sqlDown));
+
+        file_put_contents($filename, $migrationContent);
+        return $filename;
+    }
+
+    public function migrate(): void
+    {
+        $this->createVersion();
+
+        $this->successList = [];
+        $versions = $this->getConnection()->fetchAll('SELECT version FROM ' . $this->tableName);
+        foreach ($this->directory->getMigrations() as $version => $migration) {
+
+            if (in_array($version, array_column($versions, 'version'))) {
+                continue;
+            }
+
+            $this->up($version);
+            $this->successList[] = $version;
+        }
+    }
+
+    public function diffEntities(array $entities): ?string
+    {
+        $tables = [];
+        foreach ($entities as $entity) {
+            if (is_subclass_of($entity, EntityInterface::class)) {
+                $tableName = $entity::getTableName();
+                $tables[$tableName] = [
+                    'columns' => ColumnMapper::getColumns($entity),
+                    'indexes' => [] // TODO IndexMapper::getIndexes($entity)
+                ];
+            }
+        }
+        return $this->diff($tables);
+    }
+
+    public function diff(array $tables): ?string
+    {
+        $statements = (new SchemaDiffGenerator($this->platform))->generateDiffStatements($tables);
+        $sqlUp = $statements['up'];
+        $sqlDown = $statements['down'];
+
+        if (empty($sqlUp)) {
+            return null;
+        }
+
+        return $this->generateMigration($sqlUp, $sqlDown);
+    }
+
+    public function up(string $version): void
+    {
+        $migration = $this->directory->getMigration($version);
+        $pdo = $this->getConnection()->getPdo();
+        try {
+            $pdo->beginTransaction();
+            $executedQueries = [];
+            foreach (explode(';' . PHP_EOL, self::contentUp($migration)) as $query) {
+                if (str_starts_with($query = trim($query), '--')) {
+                    continue;
+                }
+                $query = rtrim($query, ';') . ';';
+                $this->getConnection()->executeStatement($query);
+                $executedQueries[] = $query;
+            }
+            if ($executedQueries === []) {
+                throw new RuntimeException("Failed to execute any query for version : " . $version);
+            }
+
+            $createdAt = (new DateTime())->format($this->platform->getSchema()->getDateTimeFormatString());
+            $rows = $this->getConnection()->executeStatement('INSERT INTO ' . $this->tableName . ' (version, created_at) VALUES (:version, :created_at)', ['version' => $version, 'created_at' => $createdAt]);
+            if ($rows == 0) {
+                throw new RuntimeException("Failed to execute insert for version : " . $version);
+            }
+            $pdo->commit();
+        } catch (\Throwable $e) {
+            $pdo->rollBack();
+            throw new RuntimeException("Failed to migrate version $version : " . $e->getMessage());
+        }
+    }
+
+    public function down(string $version): void
+    {
+        $migration = $this->directory->getMigration($version);
+        $pdo = $this->getConnection()->getPdo();
+        try {
+            $pdo->beginTransaction();
+            foreach (explode(';' . PHP_EOL, self::contentDown($migration)) as $query) {
+                $this->getConnection()->executeStatement(rtrim($query, ';') . ';');
+            }
+
+            $rows = $this->getConnection()->executeStatement('DELETE FROM ' . $this->tableName . ' WHERE version = :version', ['version' => $version]);
+
+            $pdo->commit();
+
+        } catch (PDOException $e) {
+            $pdo->rollBack();
+            throw new RuntimeException("Failed to execute DOWN migration: " . $e->getMessage());
+        }
+
+    }
+
+    private function createVersion(): void
+    {
+        $this->platform->createTableIfNotExists($this->tableName, [
+            new StringColumn('version', 'version', 50),
+            new DateTimeColumn('created_at', 'created_at')
+        ]);
+    }
+
+    private function getConnection(): PaperConnection
+    {
+        return $this->em->getConnection();
+
+    }
+
+    private static function contentUp(string $migration): string
+    {
+        return trim(str_replace('-- UP MIGRATION --', '', self::content($migration)[0]));
+    }
+
+    private static function contentDown(string $migration): string
+    {
+        $downContent = self::content($migration)[1];
+        return trim($downContent);
+    }
+
+    private static function content(string $migration): array
+    {
+        $migrationContent = file_get_contents($migration);
+        $parts = explode('-- DOWN MIGRATION --', $migrationContent, 2);
+        return [trim($parts[0]), (isset($parts[1]) ? trim($parts[1]) : '')];
+    }
+
+    public function getSuccessList(): array
+    {
+        return $this->successList;
+    }
+}

+ 103 - 0
src/PaperConnection.php

@@ -0,0 +1,103 @@
+<?php
+
+namespace PhpDevCommunity\PaperORM;
+
+use PDO;
+use PDOStatement;
+use PhpDevCommunity\PaperORM\Driver\DriverInterface;
+use PhpDevCommunity\PaperORM\Pdo\PaperPDO;
+
+final class PaperConnection
+{
+    private ?PaperPDO $pdo = null;
+
+    private array $params;
+
+    private DriverInterface $driver;
+
+    private bool $debug = false;
+
+    public function __construct(DriverInterface $driver, array $params)
+    {
+        $this->params = $params;
+        $this->driver = $driver;
+        $this->debug = $params['debug'] ?? false;
+    }
+
+
+    public function executeStatement(string $query, array $params = []): int
+    {
+        $db = $this->executeQuery($query, $params);
+        return $db->rowCount();
+    }
+
+    public function executeQuery(string $query, array $params = []): PDOStatement
+    {
+        $db = $this->getPdo()->prepare($query);
+        if ($db === false) {
+            throw new \Exception($this->getPdo()->errorInfo()[2]);
+        }
+        foreach ($params as $key => $value) {
+            if (is_string($key)) {
+                $db->bindValue(':' . $key, $value);
+            } else {
+                $db->bindValue($key + 1, $value);
+            }
+        }
+        $db->execute();
+        return $db;
+    }
+
+    public function fetch(string $query, array $params = []): ?array
+    {
+        $db = $this->executeQuery($query, $params);
+        $data = $db->fetch(PDO::FETCH_ASSOC);
+        return $data === false ? null : $data;
+    }
+
+    public function fetchAll(string $query, array $params = []): array
+    {
+        $db = $this->executeQuery($query, $params);
+        return $db->fetchAll(PDO::FETCH_ASSOC);
+    }
+
+    public function getParams(): array
+    {
+        return $this->params;
+    }
+
+    public function getDriver(): DriverInterface
+    {
+        return $this->driver;
+    }
+
+    public function getPdo(): PaperPDO
+    {
+        $this->connect();
+        return $this->pdo;
+    }
+
+    public function connect(): bool
+    {
+        if ($this->pdo === null) {
+            $this->pdo = $this->driver->connect($this->params);
+            if ($this->debug) {
+                $this->pdo->enableSqlDebugger();
+            }
+            return true;
+        }
+
+        return false;
+    }
+
+    public function isConnected(): bool
+    {
+        return $this->pdo !== null;
+    }
+
+    public function close(): void
+    {
+        $this->pdo = null;
+    }
+
+}

+ 41 - 0
src/Parser/SQLTypeParser.php

@@ -0,0 +1,41 @@
+<?php
+
+namespace PhpDevCommunity\PaperORM\Parser;
+
+final class SQLTypeParser
+{
+    public static function extractParenthesesValues(string $sqlType): array
+    {
+        $result = sscanf($sqlType, '%*[^(](%[^)]', $inside);
+
+        if ($result === 0 || !isset($inside)) {
+            return [];
+        }
+
+        return array_map('trim', explode(',', $inside));
+    }
+
+    public static function getBaseType(string $sqlType): string
+    {
+        $pos = strpos($sqlType, '(');
+        return $pos === false ? $sqlType : substr(strtoupper($sqlType), 0, $pos);
+    }
+
+    public static function hasParentheses(string $sqlType): bool
+    {
+        return str_contains($sqlType, '(') && str_contains($sqlType, ')');
+    }
+
+    public static function extractTypedParameters(string $sqlType): array
+    {
+        $values = self::extractParenthesesValues($sqlType);
+
+        return array_map(function($value) {
+            if (is_numeric($value)) {
+                return str_contains($value, '.') ? (float)$value : (int)$value;
+            }
+            return $value;
+        }, $values);
+    }
+
+}

+ 29 - 0
src/Pdo/PaperPDO.php

@@ -0,0 +1,29 @@
+<?php
+
+namespace PhpDevCommunity\PaperORM\Pdo;
+
+use PhpDevCommunity\PaperORM\Debugger\PDOStatementLogger;
+use PhpDevCommunity\PaperORM\Debugger\SqlDebugger;
+
+final class PaperPDO extends \PDO
+{
+    private ?SqlDebugger $debug = null;
+
+    public function enableSqlDebugger() : void
+    {
+        if ($this->debug === null) {
+            $this->debug = new SqlDebugger();
+        }
+        $this->setAttribute(\PDO::ATTR_STATEMENT_CLASS, [PDOStatementLogger::class, [$this->debug]]);
+    }
+
+    public function disableSqlDebugger() : void
+    {
+        $this->setAttribute(\PDO::ATTR_STATEMENT_CLASS, null);
+    }
+
+    public function getSqlDebugger(): ?SqlDebugger
+    {
+        return $this->debug;
+    }
+}

+ 143 - 0
src/Platform/AbstractPlatform.php

@@ -0,0 +1,143 @@
+<?php
+
+namespace PhpDevCommunity\PaperORM\Platform;
+
+use LogicException;
+use PhpDevCommunity\PaperORM\Collection\ObjectStorage;
+use PhpDevCommunity\PaperORM\Mapping\Column\Column;
+use PhpDevCommunity\PaperORM\Mapping\Column\JoinColumn;
+use PhpDevCommunity\PaperORM\Mapping\Index;
+use PhpDevCommunity\PaperORM\Metadata\DatabaseSchemaDiffMetadata;
+use PhpDevCommunity\PaperORM\Metadata\ColumnMetadata;
+use PhpDevCommunity\PaperORM\Metadata\IndexMetadata;
+use PhpDevCommunity\PaperORM\Schema\SchemaInterface;
+
+abstract class AbstractPlatform implements PlatformInterface
+{
+    final public function mapColumnsToMetadata(array $columns): array
+    {
+        $columnsMetadata = [];
+        foreach ($columns as $column) {
+            if (!$column instanceof Column) {
+                throw new LogicException(sprintf("The column '%s' is not supported.", is_object($column) ? get_class($column) : gettype($column)));
+            }
+            $columnsMetadata[] = $this->mapColumnToMetadata($column);
+        }
+
+        return $columnsMetadata;
+    }
+    final public function mapColumnToMetadata(Column $column): ColumnMetadata
+    {
+        $mappings = $this->getColumnTypeMappings();
+        $className = get_class($column);
+        if (!array_key_exists($className, $mappings)) {
+            throw new LogicException(sprintf("The column type '%s' is not supported.", $column->getType()));
+        }
+
+        $sqlType = $mappings[$className];
+        return ColumnMetadata::fromColumn($column, $sqlType);
+    }
+
+    /**
+     * @param string $tableName
+     * @param array<Column> $columns
+     * @param array<Index> $indexes
+     * @return void
+     */
+    final public function diff(string $tableName, array $columns, array $indexes): DatabaseSchemaDiffMetadata
+    {
+        list($columnsToAdd, $columnsToUpdate, $columnsToDrop, $originalColumns) = $this->diffColumns($tableName, $columns);
+        list($indexesToAdd, $indexesToUpdate, $indexesToDrop, $originalIndexes) = $this->diffIndexes($tableName, $indexes);
+        return new DatabaseSchemaDiffMetadata(
+            $columnsToAdd,
+            $columnsToUpdate,
+            $columnsToDrop,
+            $originalColumns,
+
+            $indexesToAdd,
+            $indexesToUpdate,
+            $indexesToDrop,
+            $originalIndexes
+        );
+    }
+
+    private function diffColumns(string $tableName, array $columns): array
+    {
+        $columnsFromTable = $this->listTableColumns($tableName);
+        $columnsFromTableByName = [];
+        foreach ($columnsFromTable as $columnMetadata) {
+            $columnsFromTableByName[$columnMetadata->getName()] = $columnMetadata;
+        }
+
+        $columnsToAdd = [];
+        $columnsToUpdate = [];
+        $columnsToDrop = [];
+
+        $columnsExisting = [];
+        foreach ($columns as $column) {
+            $columnMetadata = $this->mapColumnToMetadata($column);
+            if (isset($columnsFromTableByName[$columnMetadata->getName()])) {
+                $columnFromTable = $columnsFromTableByName[$columnMetadata->getName()];
+                if ($columnFromTable->toArray() != $columnMetadata->toArray()) {
+                    $columnsToUpdate[] = $columnMetadata;
+                }
+            } else {
+                $columnsToAdd[] = $columnMetadata;
+            }
+            $columnsExisting[] = $columnMetadata->getName();
+        }
+
+
+        foreach ($columnsFromTableByName as $columnMetadata) {
+            if (!in_array($columnMetadata->getName(), $columnsExisting)) {
+                $columnsToDrop[] = $columnMetadata;
+            }
+        }
+        return [$columnsToAdd, $columnsToUpdate, $columnsToDrop, $columnsFromTable];
+    }
+
+    /**
+     * @param string $tableName
+     * @param array<Index> $indexes
+     * @return array
+     */
+    private function diffIndexes(string $tableName, array $indexes): array
+    {
+        $indexesFromTable = new ObjectStorage($this->listTableIndexes($tableName));
+        $indexesToAdd = [];
+        $indexesToUpdate = [];
+        $indexesToDrop = [];
+
+        $indexesExisting = [];
+        foreach ($indexes as $index) {
+            $indexMetadata = new IndexMetadata($tableName, $index->getName() ?: $this->generateIndexName($index->getColumns()), $index->getColumns(), $index->isUnique());
+            $indexFound = $indexesFromTable->findOneBy('getName', $indexMetadata->getName());
+            if ($indexFound) {
+                if ($indexMetadata->toArray() != $indexFound->toArray()) {
+                    $indexesToUpdate[] = $indexMetadata;
+                }
+            }else {
+                $indexesToAdd[] = $indexMetadata;
+            }
+            $indexesExisting[] = $indexMetadata->getName();
+        }
+
+        foreach ($indexesFromTable as $index) {
+            if (!in_array($index->getName(), $indexesExisting)) {
+                $indexesToDrop[] = $index;
+            }
+        }
+
+        return [$indexesToAdd, $indexesToUpdate, $indexesToDrop, $indexesFromTable->toArray()];
+    }
+
+    final protected function generateIndexName(array $columnNames): string
+    {
+        $hash = implode("", array_map(static function ($column) {
+            return dechex(crc32($column));
+        }, $columnNames));
+
+
+        return strtoupper(substr($this->getPrefixIndexName() . $hash, 0, $this->getMaxLength()));
+    }
+}

+ 92 - 0
src/Platform/PlatformInterface.php

@@ -0,0 +1,92 @@
+<?php
+
+namespace PhpDevCommunity\PaperORM\Platform;
+
+use PhpDevCommunity\PaperORM\Mapping\Column\Column;
+use PhpDevCommunity\PaperORM\Metadata\DatabaseSchemaDiffMetadata;
+use PhpDevCommunity\PaperORM\Metadata\ColumnMetadata;
+use PhpDevCommunity\PaperORM\Metadata\IndexMetadata;
+use PhpDevCommunity\PaperORM\Schema\SchemaInterface;
+
+/**
+ * Interface PlatformInterface
+ *
+ * This interface defines methods for managing platform-specific database operations.
+ */
+interface PlatformInterface
+{
+    /**
+     * Retrieves a list of all tables in the current database.
+     *
+     * @return array Returns an array containing the names of all tables in the database.
+     */
+    public function listTables(): array;
+
+    /**
+     * @param string $tableName
+     * @return array<ColumnMetadata>
+     */
+    public function listTableColumns(string $tableName): array;
+
+    /**
+     * @param string $tableName
+     * @return array<IndexMetadata>
+     */
+    public function listTableIndexes(string $tableName): array;
+
+    /**
+     * Retrieves a list of all databases available on the platform.
+     *
+     * @return array Returns an array containing the names of all databases.
+     */
+    public function listDatabases(): array;
+
+    /**
+     * Creates a new database on the platform.
+     *
+     * @return void
+     */
+    public function createDatabase(): void;
+
+    /**
+     * Creates a new database on the platform if it does not already exist.
+     *
+     * @return void
+     */
+    public function createDatabaseIfNotExists(): void;
+
+    /**
+     * Retrieves the name of the current database.
+     *
+     * @return string Returns the name of the current database.
+     */
+    public function getDatabaseName(): string;
+
+    /**
+     * Drops the current database from the platform.
+     *
+     * @return void
+     */
+    public function dropDatabase(): void;
+
+    /**
+     * @param string $tableName
+     * @param array<Column> $columns
+     * @param array $options
+     * @return int
+     */
+    public function createTable(string $tableName, array $columns): int;
+    public function createTableIfNotExists(string $tableName, array $columns): int;
+    public function dropTable(string $tableName): int;
+    public function addColumn(string $tableName, Column $column): int;
+    public function dropColumn(string $tableName, Column $column): int;
+    public function renameColumn(string $tableName, string $oldColumnName, string $newColumnName): int;
+    public function createIndex(IndexMetadata $indexMetadata): int;
+    public function dropIndex(IndexMetadata $indexMetadata): int;
+    public function getColumnTypeMappings(): array;
+    public function getMaxLength(): int;
+    public function getPrefixIndexName(): string;
+    public function diff(string $tableName, array $columns, array $indexes): DatabaseSchemaDiffMetadata;
+    public function getSchema(): SchemaInterface;
+}
+

+ 207 - 0
src/Platform/SqlitePlatform.php

@@ -0,0 +1,207 @@
+<?php
+
+namespace PhpDevCommunity\PaperORM\Platform;
+
+use LogicException;
+use PhpDevCommunity\PaperORM\Mapping\Column\BoolColumn;
+use PhpDevCommunity\PaperORM\Mapping\Column\Column;
+use PhpDevCommunity\PaperORM\Mapping\Column\DateColumn;
+use PhpDevCommunity\PaperORM\Mapping\Column\DateTimeColumn;
+use PhpDevCommunity\PaperORM\Mapping\Column\DecimalColumn;
+use PhpDevCommunity\PaperORM\Mapping\Column\FloatColumn;
+use PhpDevCommunity\PaperORM\Mapping\Column\IntColumn;
+use PhpDevCommunity\PaperORM\Mapping\Column\JoinColumn;
+use PhpDevCommunity\PaperORM\Mapping\Column\JsonColumn;
+use PhpDevCommunity\PaperORM\Mapping\Column\PrimaryKeyColumn;
+use PhpDevCommunity\PaperORM\Mapping\Column\StringColumn;
+use PhpDevCommunity\PaperORM\Mapping\Column\TextColumn;
+use PhpDevCommunity\PaperORM\Metadata\ColumnMetadata;
+use PhpDevCommunity\PaperORM\Metadata\IndexMetadata;
+use PhpDevCommunity\PaperORM\PaperConnection;
+use PhpDevCommunity\PaperORM\Parser\SQLTypeParser;
+use PhpDevCommunity\PaperORM\Schema\SchemaInterface;
+use PhpDevCommunity\PaperORM\Schema\SqliteSchema;
+
+class SqlitePlatform extends AbstractPlatform
+{
+    private PaperConnection $connection;
+    private SqliteSchema $schema;
+
+    public function __construct(PaperConnection $connection, SqliteSchema $schema)
+    {
+        $this->connection = $connection;
+        $this->schema = $schema;
+    }
+
+    public function getDatabaseName(): string
+    {
+        return "'main'";
+    }
+
+    public function listTables(): array
+    {
+        $rows = $this->connection->fetchAll($this->schema->showTables());
+        $tables = [];
+        foreach ($rows as $row) {
+            $tables[] = $row['name'];
+        }
+        return $tables;
+    }
+
+    /**
+     * @param string $tableName
+     * @return array<ColumnMetadata>
+     */
+    public function listTableColumns(string $tableName): array
+    {
+        $rows = $this->connection->fetchAll($this->schema->showTableColumns($tableName));
+        $foreignKeys = $this->connection->fetchAll($this->schema->showForeignKeys($tableName));
+        $columns = [];
+        foreach ($rows as $row) {
+            $foreignKeyMetadata = [];
+            foreach ($foreignKeys as $foreignKey) {
+                if ($row['name'] == $foreignKey['from']) {
+                    $foreignKeyMetadata = [
+                        'referencedTable' => $foreignKey['table'],
+                        'referencedColumn' => $foreignKey['to'],
+                    ];
+                    break;
+                }
+            }
+            $columnMetadata = ColumnMetadata::fromArray([
+                'name' => $row['name'],
+                'type' => SQLTypeParser::getBaseType($row['type']),
+                'primary' => boolval($row['pk']) == true,
+                'foreignKeyMetadata' => $foreignKeyMetadata,
+                'null' => boolval($row['notnull']) == false,
+                'default' => $row['dflt_value'] ?? null,
+                'comment' => $row['comment'] ?? null,
+                'attributes' => SQLTypeParser::extractTypedParameters($row['type']),
+            ]);
+            $columns[] = $columnMetadata;
+        }
+        return $columns;
+    }
+
+    /**
+     * @param string $tableName
+     * @return array<IndexMetadata>
+     */
+    public function listTableIndexes(string $tableName): array
+    {
+        $indexes = $this->connection->fetchAll($this->schema->showTableIndexes($tableName));
+        $indexesFormatted = [];
+        foreach ($indexes as $index) {
+            $info = $this->connection->fetchAll(sprintf("PRAGMA index_info('%s')", $index['name']));
+            $indexesFormatted[] = IndexMetadata::fromArray([
+                'tableName' => $tableName,
+                'name' => $index['name'],
+                'columns' => array_column($info, 'name'),
+                'unique' => $index['unique'],
+            ]);
+        }
+        return $indexesFormatted;
+    }
+
+    public function listDatabases(): array
+    {
+        throw new LogicException(sprintf("The method '%s' is not supported by the platform interface.", __METHOD__));
+    }
+
+    public function createDatabase(): void
+    {
+        $database = $this->getDatabaseName();
+        if (file_exists($database)) {
+            return;
+        }
+
+        touch($database);
+    }
+
+    public function createDatabaseIfNotExists(): void
+    {
+        $this->createDatabase();
+    }
+
+    public function dropDatabase(): void
+    {
+        $database = $this->getDatabaseName();
+        if (!file_exists($database)) {
+            return;
+        }
+
+        unlink($database);
+    }
+
+    public function createTable(string $tableName, array $columns): int
+    {
+        return $this->connection->executeStatement($this->schema->createTable($tableName, $this->mapColumnsToMetadata($columns)));
+    }
+
+    public function createTableIfNotExists(string $tableName, array $columns, array $options = []): int
+    {
+        return $this->connection->executeStatement($this->schema->createTableIfNotExists($tableName, $this->mapColumnsToMetadata($columns)));
+    }
+
+    public function dropTable(string $tableName): int
+    {
+        return $this->connection->executeStatement($this->schema->dropTable($tableName));
+    }
+
+    public function addColumn(string $tableName, Column $column): int
+    {
+        return $this->connection->executeStatement($this->schema->addColumn($tableName, $this->mapColumnToMetadata($column)));
+    }
+
+    public function dropColumn(string $tableName, Column $column): int
+    {
+        return $this->connection->executeStatement($this->schema->dropColumn($tableName, $this->mapColumnToMetadata($column)));
+    }
+
+    public function renameColumn(string $tableName, string $oldColumnName, string $newColumnName): int
+    {
+        return $this->connection->executeStatement($this->schema->renameColumn($tableName, $oldColumnName, $newColumnName));
+    }
+
+    public function createIndex(IndexMetadata $indexMetadata): int
+    {
+        return $this->connection->executeStatement($this->schema->createIndex($indexMetadata));
+    }
+
+    public function dropIndex(IndexMetadata $indexMetadata): int
+    {
+        return $this->connection->executeStatement($this->schema->dropIndex($indexMetadata));
+    }
+
+    public function getMaxLength(): int
+    {
+        return 30;
+    }
+
+    public function getPrefixIndexName(): string
+    {
+        return 'ix_';
+    }
+
+    public function getColumnTypeMappings(): array
+    {
+        return [
+            PrimaryKeyColumn::class => 'INTEGER',
+            IntColumn::class => 'INTEGER',
+            JoinColumn::class => 'INTEGER',
+            DecimalColumn::class => 'DECIMAL',
+            FloatColumn::class => 'FLOAT',
+            DateColumn::class => 'DATE',
+            DateTimeColumn::class => 'DATETIME',
+            BoolColumn::class => 'BOOLEAN',
+            TextColumn::class => 'TEXT',
+            JsonColumn::class => 'JSON',
+            StringColumn::class => 'VARCHAR',
+        ];
+    }
+
+    public function getSchema(): SchemaInterface
+    {
+        return $this->schema;
+    }
+}

+ 89 - 0
src/Proxy/ProxyInitializedTrait.php

@@ -0,0 +1,89 @@
+<?php
+
+namespace PhpDevCommunity\PaperORM\Proxy;
+
+use DateTimeInterface;
+use PhpDevCommunity\PaperORM\Entity\EntityInterface;
+use PhpDevCommunity\PaperORM\Mapping\Column\Column;
+use PhpDevCommunity\PaperORM\Mapping\Column\DateColumn;
+use PhpDevCommunity\PaperORM\Mapping\Column\DateTimeColumn;
+use PhpDevCommunity\PaperORM\Mapping\Column\JoinColumn;
+
+trait ProxyInitializedTrait
+{
+    /**
+     * @var array<string,Column>
+     */
+    private array $__propertiesInitialized = [];
+    private array $__valuesInitialized = [];
+    private bool $__initialized = false;
+
+    public function __setInitialized(array $propertiesInitialized)
+    {
+        $this->__initialized = true;
+        $this->__propertiesInitialized = $propertiesInitialized;
+        $this->__valuesInitialized = $this->getValues();
+    }
+
+    public function __isInitialized(): bool
+    {
+        return $this->__initialized;
+    }
+
+    public function __wasModified(): bool
+    {
+        if (!$this->__initialized) {
+            return false;
+        }
+        return json_encode($this->getValues()) !== json_encode($this->__valuesInitialized);
+    }
+
+    public function __getPropertiesModified() : array
+    {
+        if (!$this->__initialized) {
+            return [];
+        }
+        return array_keys(array_diff_assoc($this->getValues(), $this->__valuesInitialized));
+    }
+
+    public function __destroy() : void
+    {
+        $this->__initialized = false;
+        $this->__propertiesInitialized = [];
+        $this->__valuesInitialized = [];
+    }
+
+    public function __reset(): void
+    {
+        $this->__setInitialized($this->__propertiesInitialized);
+    }
+
+    private function getParentClass(): string
+    {
+        return get_parent_class($this);
+    }
+
+    private function getValues(): array
+    {
+        $reflectionProxy = new \ReflectionClass($this);
+        $reflection = $reflectionProxy->getParentClass();
+        $cleanedData = [];
+
+        foreach ($this->__propertiesInitialized as $key => $column) {
+            $property = $reflection->getProperty($key);
+            $property->setAccessible(true);
+            $value = $property->getValue($this);
+            if ($column instanceof DateTimeColumn && $value instanceof DateTimeInterface) {
+                $cleanedData[$key] = $value->getTimestamp();
+            } elseif ($column instanceof DateColumn && $value instanceof DateTimeInterface) {
+                $cleanedData[$key] = $value;
+            } elseif ($column instanceof JoinColumn && $value instanceof EntityInterface) {
+                $cleanedData[$key] = $value->getPrimaryKeyValue();
+            } else {
+                $cleanedData[$key] = $value;
+            }
+            unset($value);
+        }
+        return $cleanedData;
+    }
+}

+ 18 - 0
src/Proxy/ProxyInterface.php

@@ -0,0 +1,18 @@
+<?php
+
+namespace PhpDevCommunity\PaperORM\Proxy;
+
+interface ProxyInterface
+{
+    public function __setInitialized(array $propertiesInitialized);
+
+    public function __isInitialized(): bool;
+
+    public function __wasModified(): bool;
+
+    public function __getPropertiesModified() : array;
+
+    public function __destroy(): void;
+
+    public function __reset(): void;
+}

+ 29 - 0
src/Query/AliasDetector.php

@@ -0,0 +1,29 @@
+<?php
+
+namespace PhpDevCommunity\PaperORM\Query;
+
+final class AliasDetector
+{
+
+    public static function detect(string $sql): array
+    {
+        $sql = str_replace(PHP_EOL, ' ', $sql);
+        $tokens = explode(' ', str_replace([",", "(", ")", ';'], " ", $sql));
+        $aliases = [];
+
+        foreach ($tokens as $token) {
+            if (strpos($token, '.') !== false && $token[0] !== "'" && $token[0] !== '"') {
+                $parts = explode('.', $token, 2);
+                if (count($parts) === 2) {
+                    $aliases[$parts[0]][] = $parts[1];
+                }
+            }
+        }
+
+        foreach ($aliases as $alias => $columns) {
+            $aliases[$alias] = array_values(array_unique($columns));
+        }
+        return $aliases;
+    }
+
+}

+ 24 - 0
src/Query/AliasGenerator.php

@@ -0,0 +1,24 @@
+<?php
+
+namespace PhpDevCommunity\PaperORM\Query;
+
+final class AliasGenerator
+{
+    private array $usedAliases = [];
+
+    public function generateAlias(string $entity): string
+    {
+        $entityName = basename(str_replace('\\', '/', $entity));
+        $alias = strtolower(substr($entityName, 0, 2));
+        if (in_array($alias, $this->usedAliases)) {
+            $suffix = 1;
+            while (in_array($alias . $suffix, $this->usedAliases)) {
+                $suffix++;
+            }
+            $alias .= $suffix;
+        }
+        $this->usedAliases[] = $alias;
+
+        return $alias;
+    }
+}

+ 115 - 0
src/Query/Fetcher.php

@@ -0,0 +1,115 @@
+<?php
+
+namespace PhpDevCommunity\PaperORM\Query;
+
+use PhpDevCommunity\PaperORM\Expression\Expr;
+use PhpDevCommunity\PaperORM\Mapper\ColumnMapper;
+
+final class Fetcher
+{
+    private QueryBuilder $queryBuilder;
+    private array $arguments = [];
+    private bool $collection;
+
+
+    public function __construct(QueryBuilder $queryBuilder, bool $collection = true)
+    {
+        $this->queryBuilder = $queryBuilder;
+        $this->collection = $collection;
+    }
+
+    public function where(Expr ...$expressions): Fetcher
+    {
+        $alias = $this->queryBuilder->getPrimaryAlias();
+        foreach ($expressions as $expression) {
+            $this->queryBuilder->where($expression->toPrepared($alias));
+            foreach ($expression->getBoundValue() as $k => $v) {
+                $this->arguments[ltrim($k, ':')] = $v;
+            }
+        }
+        return $this;
+    }
+
+    public function orderBy(string $column, string $direction = 'ASC'): Fetcher
+    {
+        $alias = $this->queryBuilder->getPrimaryAlias();
+        $this->queryBuilder->orderBy(sprintf('%s.%s', $alias, $column), $direction);
+        return $this;
+    }
+
+    public function limit(?int $limit): Fetcher
+    {
+        $this->queryBuilder->setMaxResults($limit);
+        return $this;
+    }
+
+    public function first(): Fetcher
+    {
+        $this->collection = false;
+        return $this;
+    }
+
+    public function with(string...$relationsClasses): Fetcher
+    {
+        foreach ($relationsClasses as $relationClass) {
+            $this->joinRelation('left', $relationClass);
+        }
+
+        return $this;
+    }
+
+    public function has(string...$relationsClasses): Fetcher
+    {
+        foreach ($relationsClasses as $relationClass) {
+            $this->joinRelation('inner', $relationClass);
+        }
+        return $this;
+    }
+
+    public function toArray(): ?array
+    {
+        if ($this->collection) {
+            return $this->queryBuilder->getResult($this->arguments, false);
+        }
+
+        return $this->queryBuilder->getOneOrNullResult($this->arguments, false);
+    }
+
+    public function toObject()
+    {
+        if ($this->collection) {
+            return $this->queryBuilder->getResult($this->arguments);
+        }
+
+        return $this->queryBuilder->getOneOrNullResult($this->arguments);
+    }
+
+    private function joinRelation(string $type, string $expression): void
+    {
+        $alias = $this->queryBuilder->getPrimaryAlias();
+        if (class_exists($expression)) {
+            if ($type === 'left') {
+                $this->queryBuilder->leftJoin($alias, $expression);
+                return;
+            }
+            $this->queryBuilder->innerJoin($alias, $expression);
+            return;
+        }
+
+        $currentEntityName = $this->queryBuilder->getPrimaryEntityName();
+        $currentAlias = $alias;
+        foreach (explode('.', $expression) as $propertyName) {
+            $column = ColumnMapper::getRelationColumnByProperty($currentEntityName, $propertyName);
+            $targetEntityName = $column->getTargetEntity();
+            $property = $column->getProperty();
+            if ($type === 'left') {
+                $this->queryBuilder->leftJoin($currentAlias, $targetEntityName, $property);
+            } else {
+                $this->queryBuilder->innerJoin($currentAlias, $targetEntityName, $property);
+            }
+            $currentAliases = $this->queryBuilder->getAliasesFromEntityName($targetEntityName, $property);
+            $currentAlias = end($currentAliases);
+            $currentEntityName = $targetEntityName;
+        }
+    }
+}

+ 396 - 0
src/Query/QueryBuilder.php

@@ -0,0 +1,396 @@
+<?php
+
+namespace PhpDevCommunity\PaperORM\Query;
+
+use InvalidArgumentException;
+use LogicException;
+use PhpDevCommunity\PaperORM\EntityManager;
+use PhpDevCommunity\PaperORM\Hydrator\ArrayHydrator;
+use PhpDevCommunity\PaperORM\Hydrator\EntityHydrator;
+use PhpDevCommunity\PaperORM\Mapper\ColumnMapper;
+use PhpDevCommunity\PaperORM\Mapper\EntityMapper;
+use PhpDevCommunity\PaperORM\Mapping\Column\Column;
+use PhpDevCommunity\PaperORM\Mapping\Column\JoinColumn;
+use PhpDevCommunity\PaperORM\Mapping\OneToMany;
+use PhpDevCommunity\Sql\QL\JoinQL;
+
+final class QueryBuilder
+{
+    private EntityManager $em;
+
+    private string $primaryKey;
+
+    private AliasGenerator $aliasGenerator;
+    private array $select = [];
+    private array $where = [];
+    private array $orderBy = [];
+
+    private array $joins = [];
+    private array $joinsAlreadyAdded = [];
+
+    private ?int $maxResults = null;
+
+    public function __construct(EntityManager $em, string $primaryKey = 'id')
+    {
+        $this->em = $em;
+        $this->aliasGenerator = new AliasGenerator();
+        $this->primaryKey = $primaryKey;
+    }
+
+    public function getResultIterator(array $parameters = [], bool $objectHydrator = true): iterable
+    {
+        foreach ($this->buildSqlQuery()->getResultIterator($parameters) as $item) {
+            yield $this->hydrate([$item], $objectHydrator)[0];
+        }
+    }
+
+    public function getResult(array $parameters = [], bool $objectHydrator = true): array
+    {
+        return $this->hydrate($this->buildSqlQuery()->getResult($parameters), $objectHydrator);
+    }
+
+    public function getOneOrNullResult(array $parameters = [], bool $objectHydrator = true)
+    {
+        $item = $this->buildSqlQuery()->getOneOrNullResult($parameters);
+        if ($item === null) {
+            return null;
+        }
+        return $this->hydrate([$item], $objectHydrator)[0];
+    }
+
+    public function select(string $entityName, array $properties = []): self
+    {
+        $this->select = [
+            'table' => $this->getTableName($entityName),
+            'entityName' => $entityName,
+            'alias' => $this->aliasGenerator->generateAlias($entityName),
+            'properties' => $properties
+        ];
+        return $this;
+    }
+
+    public function getPrimaryAlias(): string
+    {
+        if (empty($this->select)) {
+            throw new LogicException('Select must be called before getPrimaryAlias');
+        }
+
+        return $this->select['alias'];
+    }
+
+    public function getPrimaryEntityName(): string
+    {
+        if (empty($this->select)) {
+            throw new LogicException('Select must be called before getPrimaryEntityName');
+        }
+
+        return $this->select['entityName'];
+    }
+    public function where(string ...$expressions): self
+    {
+        foreach ($expressions as $expression) {
+            $this->where[] = $expression;
+        }
+        return $this;
+    }
+
+    public function orderBy(string $sort, string $order = 'ASC'): self
+    {
+        $this->orderBy[] = [
+            'sort' => $sort,
+            'order' => $order
+        ];
+        return $this;
+    }
+
+    public function resetWhere(): self
+    {
+        $this->where = [];
+        return $this;
+    }
+
+    public function resetOrderBy() : self
+    {
+        $this->orderBy = [];
+        return $this;
+    }
+
+    public function leftJoin(string $fromAliasOrEntityName, string $targetEntityName, ?string $property = null): self
+    {
+        return $this->join('LEFT', $fromAliasOrEntityName, $targetEntityName, $property);
+    }
+
+    public function innerJoin(string $fromAliasOrEntityName, string $targetEntityName, ?string $property = null): self
+    {
+        return $this->join('INNER', $fromAliasOrEntityName, $targetEntityName, $property);
+    }
+
+    public function setMaxResults(?int $maxResults): self
+    {
+        if ($this->select === []) {
+            throw new LogicException(
+                'You must call the select() method first to define the main table for the query '
+            );
+        }
+        $this->maxResults = $maxResults;
+        return $this;
+    }
+
+    private function join(string $type, string $fromAliasOrEntityName, string $targetEntityName, ?string $property = null): self
+    {
+        if (class_exists($fromAliasOrEntityName)) {
+            $fromAliases = $this->getAliasesFromEntityName($fromAliasOrEntityName);
+        } else {
+            $fromAliases = [$fromAliasOrEntityName];
+        }
+
+        /**
+         * @comment IS security , we need to check if the join is already added !!!
+         */
+        $key = md5(sprintf('%s.%s.%s.%s', $type,$fromAliasOrEntityName, $targetEntityName, $property));
+        if (in_array($key, $this->joinsAlreadyAdded)) {
+            return $this;
+        }
+
+        $this->joinsAlreadyAdded[] = $key;
+
+        foreach ($fromAliases as $fromAlias) {
+            $fromEntityName = $this->getEntityNameFromAlias($fromAlias);
+            $columns = $this->getRelationsColumns($fromEntityName, $targetEntityName, $property);
+            foreach ($columns as $column) {
+                $alias = $this->aliasGenerator->generateAlias($targetEntityName);
+                $this->joins[$alias] = [
+                    'type' => $type,
+                    'alias' => $alias,
+                    'targetEntity' => $targetEntityName,
+                    'targetTable' => $this->getTableName($targetEntityName),
+                    'fromEntityName' => $fromEntityName,
+                    'fromTable' => $this->getTableName($fromEntityName),
+                    'fromAlias' => $fromAlias,
+                    'column' => $column,
+                    'property' => $property,
+                    'isOneToMany' => $column instanceof OneToMany
+                ];
+            }
+
+        }
+
+        return $this;
+
+    }
+
+    private function convertPropertiesToColumns(string $entityName, array $properties): array
+    {
+        $columns = [];
+        foreach ($properties as $property) {
+            if ($property instanceof Column) {
+                $propertyName = $property->getProperty();
+            } elseif (is_string($property)) {
+                $propertyName = $property;
+            } else {
+                throw new InvalidArgumentException("Property {$property} not found in class " . $entityName);
+            }
+
+            $column = ColumnMapper::getColumnByProperty($entityName, $propertyName);
+            if ($column === null) {
+                throw new InvalidArgumentException("Property {$propertyName} not found in class " . $entityName);
+            }
+
+            $columns[] = $column->getName();
+        }
+        return $columns;
+    }
+
+    /**
+     * @param string $entityName
+     * @param string $targetEntityName
+     * @param string|null $property
+     * @return array<int,JoinColumn|OneToMany>
+     */
+    private function getRelationsColumns(string $entityName, string $targetEntityName, ?string $property = null): array
+    {
+        $relationsColumns = [];
+        foreach (ColumnMapper::getColumns($entityName) as $column) {
+            if ($column instanceof JoinColumn) {
+                $relationsColumns[$column->getProperty()] = $column;
+            }
+        }
+
+        foreach (ColumnMapper::getOneToManyRelations($entityName) as $column) {
+            $relationsColumns[$column->getProperty()] = $column;
+        }
+
+        if ($relationsColumns === []) {
+            throw new InvalidArgumentException("Entity {$targetEntityName} not found in class " . $entityName);
+        }
+
+        $columns = [];
+
+        if ($property) {
+            $column = $relationsColumns[$property] ?? null;
+            if ($column) {
+                $columns[] = $column;
+            }
+        } else {
+            foreach ($relationsColumns as $column) {
+                if ($column->getTargetEntity() === $targetEntityName) {
+                    $columns[] = $column;
+                }
+            }
+        }
+
+        if ($columns === []) {
+            throw new InvalidArgumentException("Entity {$targetEntityName} not found in class " . $entityName);
+        }
+
+        return $columns;
+    }
+
+    private function getTableName(string $entityName): string
+    {
+        return EntityMapper::getTable($entityName);
+    }
+
+    public function buildSqlQuery(): JoinQL
+    {
+        if ($this->select === []) {
+            throw new LogicException('No query specified');
+        }
+
+        $properties = $this->select['properties'];
+        $entityName = $this->select['entityName'];
+        $alias = $this->select['alias'];
+        $table = $this->select['table'];
+
+        if ($properties === []) {
+            $properties = ColumnMapper::getColumns($entityName);
+        }
+        $columns = $this->convertPropertiesToColumns($entityName, $properties);
+        $joinQl = new JoinQL($this->em->getConnection()->getPdo(), $this->primaryKey);
+        $joinQl->select($table, $alias, $columns);
+        foreach ($this->joins as $join) {
+            $fromAlias = $join['fromAlias'];
+            $targetTable = $join['targetTable'];
+            $targetEntity = $join['targetEntity'];
+            $alias = $join['alias'];
+            /**
+             * @var JoinColumn|OneToMany $column
+             */
+            $column = $join['column'];
+            $isOneToMany = $join['isOneToMany'];
+            $type = $join['type'];
+            $name = null;
+
+            $columns = $this->convertPropertiesToColumns($targetEntity, ColumnMapper::getColumns($targetEntity));
+            $joinQl->addSelect($alias, $columns);
+            $criteria = [];
+            if ($column instanceof JoinColumn) {
+                $criteria = [$column->getName() => $column->getReferencedColumnName()];
+                $name = $column->getName();
+            } elseif ($column instanceof OneToMany) {
+                $criteria = $column->getCriteria();
+                $mappedBy = $column->getMappedBy(); //@todo VOIR SI RENDRE OBLIGATOIRE : A DISCUTER !!!
+                if ($mappedBy) {
+                    $columnMappedBy = ColumnMapper::getColumnByProperty($targetEntity, $mappedBy);
+                    if (!$columnMappedBy instanceof JoinColumn) {
+                        throw new InvalidArgumentException("Property mapped by {$mappedBy} not found in class " . $targetEntity);
+                    }
+                    $name = $columnMappedBy->getName();
+                    $criteria = $criteria + [$columnMappedBy->getReferencedColumnName() => $columnMappedBy->getName()];
+                }
+            }
+            $joinConditions = [];
+            foreach ($criteria as $key => $value) {
+                $value = "$alias.$value";
+                $joinConditions[] = "$fromAlias.$key = $value";
+            }
+            if ($type === 'LEFT') {
+                $joinQl->leftJoin($fromAlias, $targetTable, $alias, $joinConditions, $isOneToMany, $column->getProperty(), $name);
+            } elseif ($type === 'INNER') {
+                $joinQl->innerJoin($fromAlias, $targetTable, $alias, $joinConditions, $isOneToMany, $column->getProperty(), $name);
+            }
+        }
+
+        foreach ($this->where as $where) {
+            $joinQl->where($this->resolveExpression($where));
+        }
+
+        foreach ($this->orderBy as $orderBy) {
+            $joinQl->orderBy($this->resolveExpression($orderBy['sort']), $orderBy['order']);
+        }
+
+        if ($this->maxResults) {
+            $joinQl->setMaxResults($this->maxResults);
+        }
+        return $joinQl;
+    }
+
+    public function getAliasesFromEntityName(string $entityName, string $property = null): array
+    {
+        $aliases = [];
+        if (isset($this->select['entityName']) && $this->select['entityName'] === $entityName) {
+            $aliases[] = $this->select['alias'];
+        }
+        foreach ($this->joins as $keyAsAlias => $join) {
+            if ($join['targetEntity'] === $entityName && $join['property'] === $property) {
+                $aliases[] = $keyAsAlias;
+            }
+        }
+
+        if ($aliases === []) {
+            throw new LogicException('Alias not found for ' . $entityName);
+        }
+
+        return $aliases;
+    }
+
+    private function getEntityNameFromAlias(string $alias): string
+    {
+        if (isset($this->select['alias']) && $this->select['alias'] === $alias) {
+            return $this->select['entityName'];
+        }
+        $entityName = null;
+        foreach ($this->joins as $keyAsAlias => $join) {
+            if ($keyAsAlias === $alias) {
+                $entityName = $join['targetEntity'];
+                break;
+            }
+        }
+        if ($entityName === null) {
+            throw new LogicException('Entity name not found for ' . $alias);
+        }
+
+        return $entityName;
+    }
+
+    private function hydrate(array $data, bool $objectHydrator = true): array
+    {
+        if (!$objectHydrator) {
+            $hydrator = new ArrayHydrator();
+        } else {
+            $hydrator = new EntityHydrator($this->em->getCache());
+        }
+        $collection = [];
+        foreach ($data as $item) {
+            $collection[] = $hydrator->hydrate($this->select['entityName'], $item);
+        }
+        return $collection;
+    }
+
+    private function resolveExpression(string $expression): string
+    {
+        $aliases = AliasDetector::detect($expression);
+        foreach ($aliases as $alias => $properties) {
+            $fromEntityName = $this->getEntityNameFromAlias($alias);
+            foreach ($properties as $property) {
+                $column = ColumnMapper::getColumnByProperty($fromEntityName, $property);
+                if ($column === null) {
+                    throw new InvalidArgumentException(sprintf('Property %s not found in class %s or is a collection and cannot be used in an expression', $property, $fromEntityName));
+                }
+                $expression = str_replace($alias . '.' . $property, $alias . '.'.$column->getName(), $expression);
+            }
+
+        }
+        return $expression;
+    }
+}

+ 150 - 0
src/Repository/Repository.php

@@ -0,0 +1,150 @@
+<?php
+
+namespace PhpDevCommunity\PaperORM\Repository;
+
+use LogicException;
+use PhpDevCommunity\PaperORM\Entity\EntityInterface;
+use PhpDevCommunity\PaperORM\EntityManager;
+use PhpDevCommunity\PaperORM\Expression\Expr;
+use PhpDevCommunity\PaperORM\Mapper\ColumnMapper;
+use PhpDevCommunity\PaperORM\Mapper\EntityMapper;
+use PhpDevCommunity\PaperORM\Proxy\ProxyInterface;
+use PhpDevCommunity\PaperORM\Query\Fetcher;
+use PhpDevCommunity\PaperORM\Query\QueryBuilder;
+use PhpDevCommunity\PaperORM\Serializer\SerializerToDb;
+
+abstract class Repository
+{
+
+    private EntityManager $em;
+
+    public function __construct(EntityManager $em)
+    {
+        $this->em = $em;
+    }
+
+    /**
+     * Get the name of the table associated with this repository.
+     *
+     * @return string The name of the table.
+     */
+    final public function getTableName(): string
+    {
+        $entityName = $this->getEntityName();
+        return EntityMapper::getTable($entityName);
+    }
+
+    /**
+     * Get the name of the model associated with this repository.
+     *
+     * @return class-string<EntityInterface> The name of the model.
+     */
+    abstract public function getEntityName(): string;
+
+    public function find(int $pk): Fetcher
+    {
+        $entityName = $this->getEntityName();
+        $primaryKeyColumn = ColumnMapper::getPrimaryKeyColumnName($entityName);
+        return $this->findBy()->where(Expr::equal($primaryKeyColumn, $pk))->first();
+    }
+
+    public function findBy(array $arguments = []): Fetcher
+    {
+        $expressions = [];
+        foreach ($arguments as $key => $value) {
+            $expressions[] = Expr::equal($key, $value);
+        }
+        return (new Fetcher($this->qb(), true))->where(...$expressions);
+    }
+
+    public function where(Expr ...$expressions): Fetcher
+    {
+        return (new Fetcher($this->qb(), true))->where(...$expressions);
+    }
+
+    public function insert(object $entity): int
+    {
+        $this->checkEntity($entity);
+        if ($entity->getPrimaryKeyValue() !== null) {
+            throw new LogicException(static::class . sprintf(' Cannot insert an entity %s with a primary key ', get_class($entity)));
+        }
+    }
+
+    public function update(object $entityToUpdate): int
+    {
+        $this->checkEntity($entityToUpdate, true);
+        if ($entityToUpdate->getPrimaryKeyValue() === null) {
+            throw new LogicException(static::class . sprintf(' Cannot update an entity %s without a primary key ', get_class($entityToUpdate)));
+        }
+
+        /**
+         * @var ProxyInterface|EntityInterface $entityToUpdate
+         */
+        if (!$entityToUpdate->__wasModified()) {
+            return 0;
+        }
+
+        $qb = \PhpDevCommunity\Sql\QueryBuilder::update($this->getTableName())
+            ->where(
+                sprintf('`%s` = %s',
+                    ColumnMapper::getPrimaryKeyColumnName($this->getEntityName()),
+                    $entityToUpdate->getPrimaryKeyValue()
+                )
+            );
+        $values = [];
+        foreach ((new SerializerToDb($entityToUpdate))->serialize($entityToUpdate->__getPropertiesModified()) as $key => $value) {
+            $keyWithoutBackticks = str_replace("`", "", $key);
+            $qb->set($key, ":$keyWithoutBackticks");
+            $values[$keyWithoutBackticks] = $value;
+        }
+        $rows = $this->em->getConnection()->executeStatement($qb, $values);
+        if ($rows > 0) {
+            $entityToUpdate->__reset();
+        }
+        return $rows;
+
+    }
+
+    public function delete(object $entityToDelete): int
+    {
+        /**
+         * @var ProxyInterface|EntityInterface $entityToUpdate
+         */
+        $this->checkEntity($entityToDelete, true);
+        if ($entityToDelete->getPrimaryKeyValue() === null) {
+            throw new LogicException(static::class . sprintf(' Cannot delete an entity %s without a primary key ', get_class($entityToDelete)));
+        }
+
+        $qb = \PhpDevCommunity\Sql\QueryBuilder::delete($this->getTableName())
+            ->where(
+                sprintf('`%s` = %s',
+                    ColumnMapper::getPrimaryKeyColumnName($this->getEntityName()),
+                    $entityToDelete->getPrimaryKeyValue()
+                )
+            );
+
+        $rows =  $this->em->getConnection()->executeStatement($qb);
+        if ($rows > 0) {
+            $entityToDelete->__destroy();
+        }
+        return $rows;
+    }
+
+    public function qb(): QueryBuilder
+    {
+        $queryBuilder = new QueryBuilder($this->em);
+        return $queryBuilder->select($this->getEntityName(), []);
+    }
+
+    private function checkEntity(object $entity, bool $proxy = false): void
+    {
+        $entityName = $this->getEntityName();
+        if (!$entity instanceof $entityName) {
+            throw new LogicException($entityName . ' Cannot insert an entity of type ' . get_class($entity));
+        }
+
+        if ($proxy && (!$entity instanceof ProxyInterface || !$entity->__isInitialized())) {
+            throw new LogicException($entityName . ' Cannot use an entity is not a proxy');
+        }
+    }
+}

+ 204 - 0
src/Schema/SchemaInterface.php

@@ -0,0 +1,204 @@
+<?php
+
+namespace PhpDevCommunity\PaperORM\Schema;
+
+use PhpDevCommunity\PaperORM\Metadata\ColumnMetadata;
+use PhpDevCommunity\PaperORM\Metadata\IndexMetadata;
+
+/**
+ * Interface SchemaInterface
+ *
+ * Defines methods for managing database schema operations.
+ */
+interface SchemaInterface
+{
+    /**
+     * Shows all databases.
+     *
+     * @return string Returns the SQL query for showing all databases.
+     */
+    public function showDatabases(): string;
+
+    /**
+     * Shows all tables in the database.
+     *
+     * @return string Returns the SQL query for showing all tables.
+     */
+    public function showTables(): string;
+
+    public function showTableColumns(string $tableName): string;
+
+    public function showForeignKeys(string $tableName): string;
+
+    public function showTableIndexes(string $tableName): string;
+
+    /**
+     * Creates a new database.
+     *
+     * @param string $databaseName The name of the database to create.
+     * @return string Returns the SQL query for creating the database.
+     */
+    public function createDatabase(string $databaseName): string;
+
+    /**
+     * Creates a new database if it does not exist.
+     *
+     * @param string $databaseName The name of the database to create.
+     * @return string Returns the SQL query for creating the database if not exists.
+     */
+    public function createDatabaseIfNotExists(string $databaseName): string;
+
+    /**
+     * Drops an existing database.
+     *
+     * @param string $databaseName The name of the database to drop.
+     * @return string Returns the SQL query for dropping the database.
+     */
+    public function dropDatabase(string $databaseName): string;
+
+    /**
+     * Creates a new table.
+     *
+     * @param string $tableName The name of the table to create.
+     * @param array<ColumnMetadata> $columns An array of ColumnMetadata objects.
+     * @param array $options Additional options for table creation.
+     * @return string Returns the SQL query for creating the table.
+     */
+    public function createTable(string $tableName, array $columns, array $options = []): string;
+
+    /**
+     * Creates a new table if it does not exist.
+     *
+     * @param string $tableName The name of the table to create.
+     * @param array<ColumnMetadata> $columns An array of ColumnMetadata objects.
+     * @param array $options Additional options for table creation.
+     * @return string Returns the SQL query for creating the table if not exists.
+     */
+    public function createTableIfNotExists(string $tableName, array $columns, array $options = []): string;
+
+    /**
+     * Adds a new foreign key constraint.
+     *
+     * @param string $tableName The name of the table to modify.
+     * @param ColumnMetadata $columnMetadata
+     * @return string Returns the SQL query for adding the foreign key constraint.
+     */
+    public function createForeignKeyConstraints(string $tableName, ColumnMetadata $columnMetadata) :string;
+
+    /**
+     * Drops an existing table.
+     *
+     * @param string $tableName The name of the table to drop.
+     * @return string Returns the SQL query for dropping the table.
+     */
+    public function dropTable(string $tableName): string;
+
+    /**
+     * Renames an existing table.
+     *
+     * @param string $oldTableName The current name of the table.
+     * @param string $newTableName The new name for the table.
+     * @return string Returns the SQL query for renaming the table.
+     */
+    public function renameTable(string $oldTableName, string $newTableName): string;
+
+    /**
+     * Adds a new column to an existing table.
+     *
+     * @param string $tableName The name of the table to modify.
+     * @param ColumnMetadata $columnMetadata The name of the new column.
+     * @return string Returns the SQL query for adding the column.
+     */
+    public function addColumn(string $tableName, ColumnMetadata $columnMetadata): string;
+
+    /**
+     * Drops an existing column from a table.
+     *
+     * @param string $tableName The name of the table to modify.
+     * @param ColumnMetadata $columnMetadata The column to drop.
+     * @return string Returns the SQL query for dropping the column.
+     */
+    public function dropColumn(string $tableName, ColumnMetadata $columnMetadata): string;
+
+    /**
+     * Modifies the definition of an existing column in a table.
+     *
+     * @param string $tableName The name of the table to modify.
+     * @param ColumnMetadata $columnMetadata The column to modify.
+     * @return string Returns the SQL query for modifying the column.
+     */
+    public function modifyColumn(string $tableName, ColumnMetadata $columnMetadata): string;
+
+    /**
+     * Creates a new index on a table.
+     *
+     * @param IndexMetadata $indexMetadata
+     * @return string Returns the SQL query for creating the index.
+     */
+    public function createIndex(IndexMetadata $indexMetadata): string;
+
+    /**
+     * Drops an existing index from a table.
+     *
+     * @param IndexMetadata $indexMetadata
+     * @return string Returns the SQL query for dropping the index.
+     */
+    public function dropIndex(IndexMetadata $indexMetadata): string;
+
+    /**
+     * Returns the format string for DateTime objects.
+     *
+     * @return string The format string.
+     */
+    public function getDateTimeFormatString(): string;
+
+    /**
+     * Returns the format string for Date objects.
+     *
+     * @return string The format string.
+     */
+    public function getDateFormatString(): string;
+
+    /**
+     * Checks if the database supports foreign key constraints.
+     *
+     * @return bool True if supported, false otherwise.
+     */
+    public function supportsForeignKeyConstraints(): bool;
+
+    /**
+     * Checks if the database supports indexes.
+     *
+     * @return bool True if supported, false otherwise.
+     */
+    public function supportsIndexes(): bool;
+
+    /**
+     * Checks if the database supports transactions.
+     *
+     * @return bool True if supported, false otherwise.
+     */
+    public function supportsTransactions(): bool;
+
+    /**
+     * Checks if the database supports dropping columns from a table.
+     *
+     * @return bool True if supported, false otherwise.
+     */
+    public function supportsDropColumn(): bool;
+
+    /**
+     * Checks if the database supports modifying the type of a column.
+     *
+     * @return bool True if supported, false otherwise.
+     */
+    public function supportsModifyColumn(): bool;
+
+    /**
+     * Checks if the database supports adding foreign keys.
+     *
+     * @return bool True if supported, false otherwise.
+     */
+    public function supportsAddForeignKey(): bool;
+
+}

+ 221 - 0
src/Schema/SqliteSchema.php

@@ -0,0 +1,221 @@
+<?php
+
+namespace PhpDevCommunity\PaperORM\Schema;
+
+use LogicException;
+use PhpDevCommunity\PaperORM\Metadata\ColumnMetadata;
+use PhpDevCommunity\PaperORM\Metadata\IndexMetadata;
+
+class SqliteSchema implements SchemaInterface
+{
+
+    public function showDatabases(): string
+    {
+        throw new LogicException(sprintf("The method '%s' is not supported by the schema interface.", __METHOD__));
+    }
+
+    public function showTables(): string
+    {
+        return "SELECT name FROM  sqlite_schema WHERE  type ='table' AND name NOT LIKE 'sqlite_%'";
+    }
+
+    public function showTableColumns(string $tableName): string
+    {
+        return sprintf("PRAGMA table_info('%s')", $tableName);
+    }
+
+    public function showForeignKeys(string $tableName): string
+    {
+        return sprintf("PRAGMA foreign_key_list('%s')", $tableName);
+    }
+
+    public function showTableIndexes(string $tableName): string
+    {
+        return sprintf("PRAGMA index_list('%s')", $tableName);
+    }
+
+    public function createDatabase(string $databaseName): string
+    {
+        throw new LogicException(sprintf("The method '%s' is not supported by the schema interface.", __METHOD__));
+    }
+
+    public function createDatabaseIfNotExists(string $databaseName): string
+    {
+        throw new LogicException(sprintf("The method '%s' is not supported by the schema interface.", __METHOD__));
+    }
+
+    public function dropDatabase(string $databaseName): string
+    {
+        throw new LogicException(sprintf("The method '%s' is not supported by the schema interface.", __METHOD__));
+    }
+
+    /**
+     * @param string $tableName
+     * @param array<ColumnMetadata> $columns
+     * @param array $options
+     * @return string
+     */
+    public function createTable(string $tableName, array $columns, array $options = []): string
+    {
+        $lines = [];
+        $foreignKeys = [];
+        foreach ($columns as $columnMetadata) {
+            $line = sprintf('%s %s', $columnMetadata->getName(), $columnMetadata->getTypeWithAttributes());
+            if ($columnMetadata->isPrimary()) {
+                $line .= ' PRIMARY KEY';
+            }
+            if (!$columnMetadata->isNullable()) {
+                $line .= ' NOT NULL';
+            }
+            if ($columnMetadata->getDefaultValue() !== null) {
+                $line .= sprintf(' DEFAULT %s', $columnMetadata->getDefaultValue());
+            }
+            $lines[] = $line;
+
+            if (!empty($columnMetadata->getForeignKeyMetadata())) {
+                $foreignKeys[] = $columnMetadata;
+            }
+        }
+
+        foreach ($foreignKeys as $foreignKey) {
+            $lines[] = $this->foreignKeyConstraints($foreignKey);
+        }
+        $options['indexes'] = $options['indexes'] ?? [];
+
+        $linesString = implode(',', $lines);
+
+        $createTable = sprintf("CREATE TABLE $tableName (%s)", $linesString);
+
+        $indexesSql = [];
+        foreach ($options['indexes'] as $index) {
+            $createTable .= $this->createIndex($index);
+        }
+
+        return $createTable.';'.implode(';', $indexesSql);
+    }
+
+    public function createTableIfNotExists(string $tableName, array $columns, array $options = []): string
+    {
+        $createTable = $this->createTable($tableName, $columns, $options);
+        return str_replace('CREATE TABLE', 'CREATE TABLE IF NOT EXISTS', $createTable);
+    }
+
+    public function createForeignKeyConstraints(string $tableName, ColumnMetadata $columnMetadata): string
+    {
+        throw new LogicException(sprintf("The method '%s' is not supported by the schema interface.", __METHOD__));
+    }
+
+    public function dropTable(string $tableName): string
+    {
+        return sprintf('DROP TABLE %s', $tableName);
+    }
+
+    public function renameTable(string $oldTableName, string $newTableName): string
+    {
+        return sprintf('ALTER TABLE %s RENAME TO %s', $oldTableName, $newTableName);
+    }
+
+    public function addColumn(string $tableName, ColumnMetadata $columnMetadata): string
+    {
+        $sql =  sprintf('ALTER TABLE %s ADD %s %s', $tableName, $columnMetadata->getName(), $columnMetadata->getTypeWithAttributes());
+
+        if (!$columnMetadata->isNullable()) {
+            $sql .= ' NOT NULL';
+        }
+
+        if ($columnMetadata->getDefaultValue() !== null) {
+            $sql .= sprintf(' DEFAULT %s', $columnMetadata->getDefaultValue());
+        }
+
+        return $sql;
+    }
+
+    public function dropColumn(string $tableName, ColumnMetadata $columnMetadata): string
+    {
+        if (!$this->supportsDropColumn()) {
+            throw new \LogicException(sprintf("The method '%s' is not supported with SQLite versions older than 3.35.0.", __METHOD__));
+        }
+        return sprintf('ALTER TABLE %s DROP COLUMN %s', $tableName, $columnMetadata->getName());
+    }
+
+    public function renameColumn(string $tableName, string $oldColumnName, string $newColumnName): string
+    {
+        return sprintf('ALTER TABLE %s RENAME COLUMN %s to %s', $tableName, $oldColumnName, $newColumnName);
+    }
+
+    public function modifyColumn(string $tableName, ColumnMetadata $columnMetadata): string
+    {
+        throw new LogicException(sprintf("The method '%s' is not supported by the schema interface.", __METHOD__));
+    }
+
+    /**
+     * @param IndexMetadata $indexMetadata
+     * @return string
+     */
+    public function createIndex(IndexMetadata $indexMetadata): string
+    {
+        $sql = sprintf('CREATE INDEX %s ON %s (%s)', $indexMetadata->getName(), $indexMetadata->getTableName(), implode(', ', $indexMetadata->getColumns()));
+        if ($indexMetadata->isUnique()) {
+            $sql = str_replace('CREATE INDEX', 'CREATE UNIQUE INDEX', $sql);
+        }
+
+        return $sql;
+    }
+
+    public function dropIndex(IndexMetadata $indexMetadata): string
+    {
+        return sprintf('DROP INDEX %s;', $indexMetadata->getName());
+    }
+
+    public function getDateTimeFormatString(): string
+    {
+        return 'Y-m-d H:i:s';
+    }
+
+    public function getDateFormatString(): string
+    {
+        return 'Y-m-d';
+    }
+
+    private function foreignKeyConstraints(ColumnMetadata $columnMetadata): string
+    {
+        $foreignKeys = $columnMetadata->getForeignKeyMetadata();
+        if (empty($foreignKeys)) {
+            return '';
+        }
+        $referencedTable = $foreignKeys['referencedTable'];
+        $referencedColumn = $foreignKeys['referencedColumn'];
+
+        return sprintf('FOREIGN KEY (%s) REFERENCES %s (%s)', $columnMetadata->getName(), $referencedTable, $referencedColumn);
+    }
+
+    public function supportsForeignKeyConstraints(): bool
+    {
+       return true;
+    }
+
+    public function supportsIndexes(): bool
+    {
+        return true;
+    }
+
+    public function supportsTransactions(): bool
+    {
+        return true;
+    }
+
+    public function supportsDropColumn(): bool
+    {
+        return \SQLite3::version()['versionString'] >= '3.35.0';
+    }
+
+    public function supportsModifyColumn(): bool
+    {
+        return false;
+    }
+
+    public function supportsAddForeignKey(): bool
+    {
+        return false;
+    }
+}

+ 66 - 0
src/Serializer/SerializerToArray.php

@@ -0,0 +1,66 @@
+<?php
+
+namespace PhpDevCommunity\PaperORM\Serializer;
+
+use LogicException;
+use PhpDevCommunity\PaperORM\Mapper\ColumnMapper;
+use PhpDevCommunity\PaperORM\Mapping\Column\DateTimeColumn;
+use PhpDevCommunity\PaperORM\Mapping\Column\JoinColumn;
+use ReflectionClass;
+use ReflectionException;
+
+final class SerializerToArray
+{
+    private object $entity;
+
+    public function __construct(object $entity)
+    {
+        $this->entity = $entity;
+    }
+
+    public function serialize(): array
+    {
+        $entity = $this->entity;
+        $columns = ColumnMapper::getColumns(get_class($entity));
+        if (count($columns) === 0) {
+            return [];
+        }
+
+        $reflection = new ReflectionClass($entity);
+        $data = [];
+        foreach ($columns as $column) {
+            try {
+                if (false !== ($reflectionParent = $reflection->getParentClass()) && $reflectionParent->hasProperty($column->getProperty())) {
+                    $property = $reflectionParent->getProperty($column->getProperty());
+                }else {
+                    $property = $reflection->getProperty($column->getProperty());
+                }
+            } catch (ReflectionException $e) {
+                throw new LogicException("Property {$column->getProperty()} not found in class " . get_class($entity));
+            }
+
+            $property->setAccessible(true);
+            $value = $property->getValue($entity);
+            $propertyName = $column->getProperty();
+            if (is_iterable($value)) {
+                $data[$propertyName] = iterator_to_array($value);
+                continue;
+            }
+
+            if ($column instanceof DateTimeColumn) {
+                $data[$propertyName] = $column->convertToDatabase($value);
+                continue;
+            }
+
+            if ($column instanceof JoinColumn) {
+                $data[$propertyName] = (new self($value))->serialize();
+                continue;
+            }
+
+            $data[$propertyName] = $property->getValue($entity);
+
+        }
+        return $data;
+    }
+
+}

+ 58 - 0
src/Serializer/SerializerToDb.php

@@ -0,0 +1,58 @@
+<?php
+
+namespace PhpDevCommunity\PaperORM\Serializer;
+
+
+use PhpDevCommunity\PaperORM\Entity\EntityInterface;
+use PhpDevCommunity\PaperORM\Mapper\ColumnMapper;
+use PhpDevCommunity\PaperORM\Mapping\Column\JoinColumn;
+
+final class SerializerToDb
+{
+    /**
+     * @var object
+     */
+    private object $entity;
+
+    public function __construct(object $entity)
+    {
+        $this->entity = $entity;
+    }
+
+    public function serialize(array $columnsToSerialize = [] ): array
+    {
+        $entity = $this->entity;
+        $columns = ColumnMapper::getColumns(get_class($entity));
+        $reflection = new \ReflectionClass($entity);
+        $dbData = [];
+        foreach ($columns as $column) {
+            if (!empty($columnsToSerialize) && !in_array($column->getProperty(), $columnsToSerialize)) {
+                continue;
+            }
+
+            try {
+                if (false !== ($reflectionParent = $reflection->getParentClass()) && $reflectionParent->hasProperty($column->getProperty())) {
+                    $property = $reflectionParent->getProperty($column->getProperty());
+                }else {
+                    $property = $reflection->getProperty($column->getProperty());
+                }
+            }    catch (\ReflectionException $e) {
+                throw new \InvalidArgumentException("Property {$column->getProperty()} not found in class " . get_class($entity));
+            }
+
+            $property->setAccessible(true);
+            $key = sprintf('`%s`', $column->getName());
+            $value = $property->getValue($entity);
+            if ($column instanceof JoinColumn) {
+                if (is_object($value) && ($value instanceof EntityInterface || method_exists($value, 'getPrimaryKeyValue'))) {
+                    $value = $value->getPrimaryKeyValue();
+                }
+            }
+
+            $dbData[$key] = $column->convertToDatabase($value) ;
+
+        }
+        return $dbData;
+    }
+
+}

+ 17 - 0
src/Types/BoolType.php

@@ -0,0 +1,17 @@
+<?php
+
+namespace PhpDevCommunity\PaperORM\Types;
+
+final class BoolType extends Type
+{
+
+    public function convertToDatabase($value): ?int
+    {
+        return $value === null ? null : (int)$value;
+    }
+
+    public function convertToPHP($value): ?bool
+    {
+        return $value === null ? null : (bool)$value;
+    }
+}

+ 39 - 0
src/Types/DateTimeType.php

@@ -0,0 +1,39 @@
+<?php
+
+namespace PhpDevCommunity\PaperORM\Types;
+
+use DateTime;
+use DateTimeInterface;
+use LogicException;
+
+final class DateTimeType extends Type
+{
+
+    public function convertToDatabase($value, string $format = 'Y-m-d H:i:s'): ?string
+    {
+        if ($value === null) {
+            return null;
+        }
+
+        if ($value instanceof DateTimeInterface) {
+            return $value->format($format);
+        }
+
+        throw new LogicException('Could not convert PHP value "' . $value . '" to ' . self::class);
+    }
+
+    public function convertToPHP($value, string $format = 'Y-m-d H:i:s'): ?DateTimeInterface
+    {
+        if ($value === null || $value instanceof DateTimeInterface) {
+            return $value;
+        }
+
+        $date = DateTime::createFromFormat($format, $value);
+        if (!$date instanceof DateTimeInterface) {
+            throw new LogicException('Could not convert database value "' . $value . '" to ' . self::class);
+        }
+
+        return $date;
+    }
+
+}

+ 35 - 0
src/Types/DateType.php

@@ -0,0 +1,35 @@
+<?php
+
+namespace PhpDevCommunity\PaperORM\Types;
+
+final class DateType extends Type
+{
+
+    public function convertToDatabase($value, string $format = 'Y-m-d'): ?string
+    {
+        if ($value === null) {
+            return null;
+        }
+
+        if ($value instanceof \DateTimeInterface) {
+            return $value->format($format);
+        }
+
+        throw new \LogicException('Could not convert PHP value "' . $value . '" to ' . self::class);
+    }
+
+    public function convertToPHP($value, string $format = 'Y-m-d'): ?\DateTimeInterface
+    {
+        if ($value === null || $value instanceof \DateTimeInterface) {
+            return $value;
+        }
+
+        $date = \DateTime::createFromFormat($format,$value);
+        if (!$date instanceof \DateTimeInterface) {
+            throw new \LogicException('Could not convert database value "' . $value . '" to ' . self::class);
+        }
+
+        return $date;
+    }
+
+}

+ 17 - 0
src/Types/DecimalType.php

@@ -0,0 +1,17 @@
+<?php
+
+namespace PhpDevCommunity\PaperORM\Types;
+
+final class DecimalType extends Type
+{
+
+    public function convertToDatabase($value): ?string
+    {
+        return $value === null ? null : (string)$value;
+    }
+
+    public function convertToPHP($value): ?string
+    {
+        return $value === null ? null : (string)$value;
+    }
+}

+ 17 - 0
src/Types/FloatType.php

@@ -0,0 +1,17 @@
+<?php
+
+namespace PhpDevCommunity\PaperORM\Types;
+
+final class FloatType extends Type
+{
+
+    public function convertToDatabase($value): ?string
+    {
+        return $value === null ? null : floatval($value);
+    }
+
+    public function convertToPHP($value): ?string
+    {
+        return $value === null ? null : floatval($value);
+    }
+}

+ 17 - 0
src/Types/IntType.php

@@ -0,0 +1,17 @@
+<?php
+
+namespace PhpDevCommunity\PaperORM\Types;
+
+final class IntType extends Type
+{
+
+    public function convertToDatabase($value): ?int
+    {
+        return $value === null ? null : (int)$value;
+    }
+
+    public function convertToPHP($value): ?int
+    {
+        return $value === null ? null : (int)$value;
+    }
+}

+ 17 - 0
src/Types/IntegerType.php

@@ -0,0 +1,17 @@
+<?php
+
+namespace PhpDevCommunity\PaperORM\Types;
+
+final class IntegerType extends Type
+{
+
+    public function convertToDatabase($value): ?int
+    {
+        return $value === null ? null : (int)$value;
+    }
+
+    public function convertToPHP($value): ?int
+    {
+        return $value === null ? null : (int)$value;
+    }
+}

+ 35 - 0
src/Types/JsonType.php

@@ -0,0 +1,35 @@
+<?php
+
+namespace PhpDevCommunity\PaperORM\Types;
+
+final class JsonType extends Type
+{
+
+    public function convertToDatabase($value): ?string
+    {
+        if ($value === null) {
+            return null;
+        }
+
+        $encoded = json_encode($value);
+        if (json_last_error() !== JSON_ERROR_NONE) {
+            throw new \LogicException('Could not convert PHP value "' . $value . '" to ' . self::class);
+        }
+
+        return $encoded;
+    }
+
+    public function convertToPHP($value): ?array
+    {
+        if ($value === null || $value === '') {
+            return null;
+        }
+
+        $array = json_decode($value, true);
+        if (json_last_error() !== JSON_ERROR_NONE) {
+            throw new \LogicException('Could not convert  database value "' . $value . '" to ' . self::class);
+        }
+
+        return $array;
+    }
+}

+ 17 - 0
src/Types/ObjectType.php

@@ -0,0 +1,17 @@
+<?php
+
+namespace PhpDevCommunity\PaperORM\Types;
+
+final class ObjectType extends Type
+{
+
+    public function convertToDatabase($value): ?string
+    {
+        return $value === null ? null : serialize($value);
+    }
+
+    public function convertToPHP($value): ?object
+    {
+        return $value === null ? null : unserialize($value);
+    }
+}

+ 17 - 0
src/Types/StringType.php

@@ -0,0 +1,17 @@
+<?php
+
+namespace PhpDevCommunity\PaperORM\Types;
+
+final class StringType extends Type
+{
+
+    public function convertToDatabase($value): ?string
+    {
+        return $value === null ? null : (string)$value;
+    }
+
+    public function convertToPHP($value): ?string
+    {
+        return $value === null ? null : (string)$value;
+    }
+}

+ 23 - 0
src/Types/Type.php

@@ -0,0 +1,23 @@
+<?php
+
+namespace PhpDevCommunity\PaperORM\Types;
+
+abstract class Type
+{
+    final public function __construct()
+    {
+    }
+
+    /**
+     * @param mixed $value
+     * @return mixed
+     */
+    abstract public function convertToDatabase($value);
+
+    /**
+     * @param mixed $value
+     * @return mixed
+     */
+    abstract public function convertToPHP($value);
+
+}

+ 20 - 0
src/Types/TypeFactory.php

@@ -0,0 +1,20 @@
+<?php
+
+namespace PhpDevCommunity\PaperORM\Types;
+
+final class TypeFactory
+{
+    /**
+     * @param string $typeClass
+     * @return Type
+     * @throws \ReflectionException
+     */
+    public static function create(string $typeClass): Type
+    {
+        $type = (new \ReflectionClass($typeClass))->newInstance();
+        if (!$type instanceof Type) {
+            throw new \InvalidArgumentException($typeClass. ' must be an instance of '.Type::class);
+        }
+        return $type;
+    }
+}

+ 85 - 0
src/UnitOfWork.php

@@ -0,0 +1,85 @@
+<?php
+
+namespace PhpDevCommunity\PaperORM;
+
+
+final class UnitOfWork
+{
+
+    /**
+     * A list of all pending entity insertions.
+     *
+     * @psalm-var array<int, object>
+     */
+    private array $entityInsertions = [];
+
+    /**
+     * A list of all pending entity updates.
+     *
+     * @psalm-var array<int, object>
+     */
+    private array $entityUpdates = [];
+
+    /**
+     * A list of all pending entity deletions.
+     *
+     * @psalm-var array<int, object>
+     */
+    private array $entityDeletions = [];
+
+    public function getEntityInsertions(): array
+    {
+        return $this->entityInsertions;
+    }
+
+    public function getEntityUpdates(): array
+    {
+        return $this->entityUpdates;
+    }
+
+    public function getEntityDeletions(): array
+    {
+        return $this->entityDeletions;
+    }
+
+    public function persist(object $entity): void
+    {
+        $this->unsetEntity($entity);
+
+        $id = spl_object_id($entity);
+        if (!$entity->getPrimaryKeyValue()) {
+            $this->entityInsertions[$id] = $entity;
+            return;
+        }
+
+        $this->entityUpdates[$id] = $entity;
+    }
+
+    public function remove(object $entity): void
+    {
+        $this->unsetEntity($entity);
+
+        $id = spl_object_id($entity);
+        $this->entityDeletions[$id] = $entity;
+    }
+
+    public function unsetEntity(object $entity): void
+    {
+        $id = spl_object_id($entity);
+        if (isset($this->entityUpdates[$id])) {
+            unset($this->entityUpdates[$id]);
+        }
+        if (isset($this->entityInsertions[$id])) {
+            unset($this->entityInsertions[$id]);
+        }
+        if (isset($this->entityDeletions[$id])) {
+            unset($this->entityDeletions[$id]);
+        }
+    }
+     public function clear(): void
+     {
+         $this->entityInsertions = [];
+         $this->entityUpdates = [];
+         $this->entityDeletions = [];
+     }
+}

+ 57 - 0
tests/Common/AliasDetectorTest.php

@@ -0,0 +1,57 @@
+<?php
+
+namespace Test\PhpDevCommunity\PaperORM\Common;
+
+use PhpDevCommunity\PaperORM\Query\AliasDetector;
+use PhpDevCommunity\UniTester\TestCase;
+
+class AliasDetectorTest extends TestCase
+{
+
+    protected function setUp(): void
+    {
+        // TODO: Implement setUp() method.
+    }
+
+    protected function tearDown(): void
+    {
+        // TODO: Implement tearDown() method.
+    }
+
+    protected function execute(): void
+    {
+        $this->testSimpleQuery();
+        $this->testComplexQuery();
+    }
+
+    private function testSimpleQuery()
+    {
+        $sql = "SELECT t.id, t.name FROM table t, table2 t2 WHERE t.id = t2.id";
+        $expectedAliases = [
+            't' => ['id', 'name'],
+            't2' => ['id'],
+        ];
+
+        $result = AliasDetector::detect($sql);
+
+        $this->assertEquals($expectedAliases, $result);
+    }
+
+
+    private function testComplexQuery()
+    {
+        $sql = "SELECT u.id, u.name AS user_name, o.amount, p.price, 'test.value' AS fake_column
+FROM users u
+JOIN orders o ON u.id = o.user_id
+LEFT JOIN products p ON o.product_id = p.id
+WHERE u.status = 'active' AND o.amount > 100
+ORDER BY u.name;";
+        $expectedAliases = [
+            'u' => ['id', 'name', "status"],
+            'o' => ['amount', 'user_id', 'product_id'],
+            'p' => ['price', 'id'],
+        ];
+        $result = AliasDetector::detect($sql);
+        $this->assertEquals($expectedAliases, $result);
+    }
+}

+ 190 - 0
tests/Common/ObjectStorageTest.php

@@ -0,0 +1,190 @@
+<?php
+
+namespace Test\PhpDevCommunity\PaperORM\Common;
+
+
+use PhpDevCommunity\PaperORM\Collection\ObjectStorage;
+use PhpDevCommunity\UniTester\TestCase;
+use Test\PhpDevCommunity\PaperORM\Entity\UserTest;
+
+class ObjectStorageTest extends TestCase
+{
+
+    protected function setUp(): void
+    {
+        // TODO: Implement setUp() method.
+    }
+
+    protected function tearDown(): void
+    {
+        // TODO: Implement tearDown() method.
+    }
+
+    protected function execute(): void
+    {
+        $this->testFind();
+        $this->testFindReturnsNullIfNotFound();
+        $this->testFindBy();
+        $this->testFindByReturnsEmptyArrayIfNotFound();
+        $this->testFirst();
+        $this->testFirstReturnsNullIfCollectionIsEmpty();
+        $this->testToArray();
+        $this->testToArrayReturnsEmptyArrayIfCollectionIsEmpty();
+        $this->testIsEmptyReturnsTrueForEmptyObjectStorage();
+        $this->testIsEmptyReturnsFalseForObjectStorageWithItems();
+        $this->testFindPkWithNullPrimaryKey();
+        $this->testFindPkWithNonExistentPrimaryKey();
+        $this->testFindPkWithExistingPrimaryKey();
+        $this->testFindOneBy();
+    }
+    public function testFind()
+    {
+        $collection = new ObjectStorage();
+        $collection->attach(new \stdClass());
+        $collection->attach(new \stdClass());
+
+        $foundObject = $collection->find(function ($item) {
+            return true;
+        });
+
+        $this->assertInstanceOf(\stdClass::class, $foundObject);
+    }
+
+    public function testFindReturnsNullIfNotFound()
+    {
+        $collection = new ObjectStorage();
+
+        $foundObject = $collection->find(function ($item) {
+            return true;
+        });
+
+        $this->assertNull($foundObject);
+    }
+
+    public function testFindBy()
+    {
+        $collection = new ObjectStorage();
+        $object1 = new \stdClass();
+        $object2 = new \stdClass();
+        $collection->attach($object1);
+        $collection->attach($object2);
+
+        $foundObjects = $collection->filter(function ($item) use($object1) {
+            return $item === $object1;
+        });
+
+        $this->assertStrictEquals(1, count($foundObjects));
+        $this->assertTrue(in_array( $object1, $foundObjects));
+    }
+
+    public function testFindByReturnsEmptyArrayIfNotFound()
+    {
+        $collection = new ObjectStorage();
+
+        $foundObjects = $collection->filter(function ($item) {
+            return true;
+        });
+
+        $this->assertEmpty($foundObjects);
+    }
+
+    public function testFirst()
+    {
+        $collection = new ObjectStorage();
+        $object = new \stdClass();
+        $collection->attach($object);
+
+        $firstObject = $collection->first();
+
+        $this->assertStrictEquals($object, $firstObject);
+    }
+
+    public function testFirstReturnsNullIfCollectionIsEmpty()
+    {
+        $collection = new ObjectStorage();
+
+        $firstObject = $collection->first();
+
+        $this->assertNull($firstObject);
+    }
+
+    public function testToArray()
+    {
+        $collection = new ObjectStorage();
+        $object1 = new \stdClass();
+        $object2 = new \stdClass();
+        $collection->attach($object1);
+        $collection->attach($object2);
+
+        $array = $collection->toArray();
+
+        $this->assertStrictEquals(2, count($array));
+        $this->assertTrue(in_array( $object1, $array));
+        $this->assertTrue(in_array( $object2, $array));
+    }
+
+    public function testToArrayReturnsEmptyArrayIfCollectionIsEmpty()
+    {
+        $collection = new ObjectStorage();
+
+        $array = $collection->toArray();
+
+        $this->assertEmpty($array);
+    }
+
+    public function testIsEmptyReturnsTrueForEmptyObjectStorage()
+    {
+        $objectStorage = new ObjectStorage();
+        $this->assertTrue($objectStorage->isEmpty());
+    }
+
+    public function testIsEmptyReturnsFalseForObjectStorageWithItems()
+    {
+        $object1 = new \stdClass();
+        $object2 = new \stdClass();
+
+        $objectStorage = new ObjectStorage();
+        $objectStorage->attach($object1);
+        $objectStorage->attach($object2);
+
+        $this->assertFalse($objectStorage->isEmpty());
+    }
+
+    public function testFindPkWithNullPrimaryKey()
+    {
+        $collection = new ObjectStorage();
+        $result = $collection->findPk(null);
+        $this->assertNull($result);
+    }
+
+    public function testFindPkWithNonExistentPrimaryKey()
+    {
+        $collection = new ObjectStorage();
+        $result = $collection->findPk(999);
+        $this->assertNull($result);
+    }
+
+    public function testFindPkWithExistingPrimaryKey()
+    {
+        $collection = new ObjectStorage();
+        $object = new UserTest();
+        $object->setId(123);
+        $collection->attach($object);
+        $result = $collection->findPk(123);
+        $this->assertStrictEquals($object, $result);
+    }
+
+    public function testFindOneBy()
+    {
+        $user = new UserTest();
+        $user->setFirstname('John');
+        $objectStorage = new ObjectStorage();
+        $objectStorage->attach($user);
+        $foundObject = $objectStorage->findOneBy('getFirstname', 'John');
+        $this->assertStrictEquals($user, $foundObject);
+
+        $foundObject = $objectStorage->findOneBy('getNonExistentMethod', 'John');
+        $this->assertNull($foundObject);
+    }
+
+}

+ 53 - 0
tests/Common/OrmTestMemory.php

@@ -0,0 +1,53 @@
+<?php
+
+namespace Test\PhpDevCommunity\PaperORM\Common;
+
+use PhpDevCommunity\PaperORM\EntityManager;
+use PhpDevCommunity\UniTester\TestCase;
+use Test\PhpDevCommunity\PaperORM\Entity\UserTest;
+use Test\PhpDevCommunity\PaperORM\Helper\DataBaseHelperTest;
+
+class OrmTestMemory extends TestCase
+{
+    private EntityManager $em;
+
+    protected function setUp(): void
+    {
+        $this->em = new EntityManager([
+            'driver' => 'sqlite',
+            'user' => null,
+            'password' => null,
+            'memory' => true,
+            'debug' => false
+        ]);
+        $this->setUpDatabaseSchema();
+    }
+
+    protected function setUpDatabaseSchema(): void
+    {
+        DataBaseHelperTest::init($this->em, 10000);
+    }
+
+    protected function tearDown(): void
+    {
+        $this->em->getConnection()->close();
+    }
+
+
+    protected function execute(): void
+    {
+        $memory = memory_get_usage();
+        $users = $this->em->getRepository(UserTest::class)
+            ->findBy()
+            ->toObject()
+        ;
+        $this->assertStrictEquals(10000, count($users));
+        foreach ($users as $user) {
+            $this->assertInstanceOf(UserTest::class, $user);
+            $this->assertNotEmpty($user);
+        }
+        $memory = memory_get_usage(true) - $memory;
+        $memory = ceil($memory / 1024 / 1024);
+        $this->assertTrue( $memory <= 30 );
+    }
+}

+ 46 - 0
tests/Common/SqlDebuggerTest.php

@@ -0,0 +1,46 @@
+<?php
+
+namespace Test\PhpDevCommunity\PaperORM\Common;
+
+
+use PhpDevCommunity\PaperORM\Debugger\SqlDebugger;
+use PhpDevCommunity\UniTester\TestCase;
+
+class SqlDebuggerTest extends TestCase
+{
+    protected function setUp(): void
+    {
+        // TODO: Implement setUp() method.
+    }
+
+    protected function tearDown(): void
+    {
+        // TODO: Implement tearDown() method.
+    }
+
+    protected function execute(): void
+    {
+        $this->testSqlDebuggerStartQuery();
+        $this->testSqlDebugger();
+    }
+
+    public function testSqlDebuggerStartQuery()
+    {
+        $sqlDebugger = new SqlDebugger();
+        $sqlDebugger->startQuery('SELECT * FROM users', []);
+        $this->assertTrue(array_key_exists('startTime', $sqlDebugger->getQueries()[0]));
+    }
+
+    public function testSqlDebugger()
+    {
+        $sqlDebugger = new SqlDebugger();
+        $sqlDebugger->startQuery('SELECT * FROM users', []);
+        $sqlDebugger->stopQuery();
+        $queries = $sqlDebugger->getQueries();
+        $this->assertStrictEquals(1, count($queries));
+        $this->assertEquals('[SELECT] SELECT * FROM users', $queries[0]['query']);
+        $this->assertEquals([], $queries[0]['params']);
+        $this->assertNotNull($queries[0]['executionTime']);
+    }
+
+}

+ 65 - 0
tests/DatabaseShowTablesCommandTest.php

@@ -0,0 +1,65 @@
+<?php
+
+namespace Test\PhpDevCommunity\PaperORM;
+
+use PhpDevCommunity\Console\CommandParser;
+use PhpDevCommunity\Console\CommandRunner;
+use PhpDevCommunity\Console\Output;
+use PhpDevCommunity\PaperORM\Command\ShowTablesCommand;
+use PhpDevCommunity\PaperORM\EntityManager;
+use PhpDevCommunity\PaperORM\Mapping\Column\BoolColumn;
+use PhpDevCommunity\PaperORM\Mapping\Column\IntColumn;
+use PhpDevCommunity\PaperORM\Mapping\Column\JoinColumn;
+use PhpDevCommunity\PaperORM\Mapping\Column\PrimaryKeyColumn;
+use PhpDevCommunity\PaperORM\Mapping\Column\StringColumn;
+use PhpDevCommunity\UniTester\TestCase;
+use Test\PhpDevCommunity\PaperORM\Entity\UserTest;
+
+class DatabaseShowTablesCommandTest extends TestCase
+{
+    private EntityManager $em;
+
+    protected function setUp(): void
+    {
+        $this->em = new EntityManager([
+            'driver' => 'sqlite',
+            'user' => null,
+            'password' => null,
+            'memory' => true,
+        ]);
+
+    }
+
+    protected function tearDown(): void
+    {
+        $this->em->getConnection()->close();
+    }
+
+    protected function execute(): void
+    {
+        $platform = $this->em->createDatabasePlatform();
+        $platform->createTable('user', [
+            new PrimaryKeyColumn('id'),
+            new StringColumn('firstname'),
+            new StringColumn('lastname'),
+            new StringColumn('email'),
+            new StringColumn('password'),
+            new BoolColumn('is_active'),
+        ]);
+
+        $platform->createTable('post', [
+            new PrimaryKeyColumn('id'),
+            new JoinColumn('user', 'user_id', 'id', UserTest::class),
+            new StringColumn('title'),
+            new StringColumn('content'),
+        ]);
+
+        $runner = new CommandRunner([
+            new ShowTablesCommand($this->em)
+        ]);
+
+        $code = $runner->run(new CommandParser(['', 'paper:show:tables', '--columns']), new Output(function ($message) use(&$countMessages) {
+        }));
+        $this->assertEquals(0, $code);
+    }
+}

+ 68 - 0
tests/Entity/CommentTest.php

@@ -0,0 +1,68 @@
+<?php
+
+namespace Test\PhpDevCommunity\PaperORM\Entity;
+
+use PhpDevCommunity\PaperORM\Entity\EntityInterface;
+use PhpDevCommunity\PaperORM\Mapping\Column\StringColumn;
+use PhpDevCommunity\PaperORM\Mapping\Column\JoinColumn;
+use PhpDevCommunity\PaperORM\Mapping\Column\PrimaryKeyColumn;
+use Test\PhpDevCommunity\PaperORM\Repository\TagTestRepository;
+
+class CommentTest implements EntityInterface
+{
+
+    private ?int $id = null;
+    private ?string $body = null;
+    private ?PostTest $post = null;
+
+    static public function getTableName(): string
+    {
+        return 'comment';
+    }
+
+    static public function getRepositoryName(): ?string
+    {
+        return null;
+    }
+
+    static public function columnsMapping(): array
+    {
+        return [
+            new PrimaryKeyColumn('id'),
+            new StringColumn('body'),
+            new JoinColumn('post', 'post_id', 'id', PostTest::class),
+        ];
+    }
+
+    public function getPrimaryKeyValue() : ?int
+    {
+        return $this->getId();
+    }
+
+    public function getId(): ?int
+    {
+        return $this->id;
+    }
+
+    public function getBody(): ?string
+    {
+        return $this->body;
+    }
+
+    public function setBody(?string $body): CommentTest
+    {
+        $this->body = $body;
+        return $this;
+    }
+
+    public function getPost(): ?PostTest
+    {
+        return $this->post;
+    }
+
+    public function setPost(?PostTest $post): CommentTest
+    {
+        $this->post = $post;
+        return $this;
+    }
+}

+ 141 - 0
tests/Entity/PostTest.php

@@ -0,0 +1,141 @@
+<?php
+
+namespace Test\PhpDevCommunity\PaperORM\Entity;
+
+use DateTime;
+use PhpDevCommunity\PaperORM\Collection\ObjectStorage;
+use PhpDevCommunity\PaperORM\Entity\EntityInterface;
+use PhpDevCommunity\PaperORM\Mapping\Column\StringColumn;
+use PhpDevCommunity\PaperORM\Mapping\Column\DateTimeColumn;
+use PhpDevCommunity\PaperORM\Mapping\Column\JoinColumn;
+use PhpDevCommunity\PaperORM\Mapping\Column\PrimaryKeyColumn;
+use PhpDevCommunity\PaperORM\Mapping\OneToMany;
+use Test\PhpDevCommunity\PaperORM\Repository\PostTestRepository;
+
+class PostTest implements EntityInterface
+{
+
+    private ?int $id = null;
+
+    private ?string $title = null;
+
+    private ?string $content = null;
+
+    private ?DateTime $createdAt = null;
+
+    private ?UserTest $user = null;
+
+    private ObjectStorage $tags;
+    private ObjectStorage $comments;
+
+    public function __construct()
+    {
+        $this->tags = new ObjectStorage();
+        $this->comments = new ObjectStorage();
+    }
+
+    static public function getTableName(): string
+    {
+        return 'post';
+    }
+
+    static public function getRepositoryName(): string
+    {
+        return PostTestRepository::class;
+    }
+
+    static public function columnsMapping(): array
+    {
+        return [
+            new PrimaryKeyColumn('id'),
+            new StringColumn('title'),
+            new StringColumn('content'),
+            new DateTimeColumn('createdAt', 'created_at'),
+            new JoinColumn('user', 'user_id', 'id', UserTest::class),
+            new OneToMany('tags', TagTest::class, 'post'),
+            new OneToMany('comments', CommentTest::class, 'post'),
+        ];
+    }
+
+    public function getPrimaryKeyValue(): ?int
+    {
+        return $this->getId();
+    }
+
+    public function getId(): ?int
+    {
+        return $this->id;
+    }
+
+    public function setId(?int $id): PostTest
+    {
+        $this->id = $id;
+        return $this;
+    }
+
+    public function getTitle(): ?string
+    {
+        return $this->title;
+    }
+
+    public function setTitle(?string $title): PostTest
+    {
+        $this->title = $title;
+        return $this;
+    }
+
+    public function getContent(): ?string
+    {
+        return $this->content;
+    }
+
+    public function setContent(?string $content): PostTest
+    {
+        $this->content = $content;
+        return $this;
+    }
+
+    public function getCreatedAt(): ?DateTime
+    {
+        return $this->createdAt;
+    }
+
+    public function setCreatedAt(?DateTime $createdAt): PostTest
+    {
+        $this->createdAt = $createdAt;
+        return $this;
+    }
+
+    public function getUser(): ?UserTest
+    {
+        return $this->user;
+    }
+
+    public function setUser(?UserTest $user): PostTest
+    {
+        $this->user = $user;
+        return $this;
+    }
+
+    public function getTags(): ObjectStorage
+    {
+        return $this->tags;
+    }
+
+    public function addTag(TagTest $tag): PostTest
+    {
+        $this->tags->add($tag);
+        return $this;
+    }
+
+    public function getComments(): ObjectStorage
+    {
+        return $this->comments;
+    }
+
+    public function addComment(CommentTest $comment): PostTest
+    {
+        $this->comments->add($comment);
+        return $this;
+    }
+}

+ 70 - 0
tests/Entity/TagTest.php

@@ -0,0 +1,70 @@
+<?php
+
+namespace Test\PhpDevCommunity\PaperORM\Entity;
+
+use PhpDevCommunity\PaperORM\Entity\EntityInterface;
+use PhpDevCommunity\PaperORM\Mapping\Column\StringColumn;
+use PhpDevCommunity\PaperORM\Mapping\Column\DateTimeColumn;
+use PhpDevCommunity\PaperORM\Mapping\Column\JoinColumn;
+use PhpDevCommunity\PaperORM\Mapping\Column\PrimaryKeyColumn;
+use Test\PhpDevCommunity\PaperORM\Repository\PostTestRepository;
+use Test\PhpDevCommunity\PaperORM\Repository\TagTestRepository;
+
+class TagTest implements EntityInterface
+{
+
+    private ?int $id = null;
+    private ?string $name = null;
+    private ?PostTest $post = null;
+
+    static public function getTableName(): string
+    {
+        return 'tag';
+    }
+
+    static public function getRepositoryName(): string
+    {
+        return TagTestRepository::class;
+    }
+
+    static public function columnsMapping(): array
+    {
+        return [
+            new PrimaryKeyColumn('id'),
+            new StringColumn('name'),
+            new JoinColumn('post', 'post_id', 'id', PostTest::class),
+        ];
+    }
+
+    public function getPrimaryKeyValue() : ?int
+    {
+        return $this->getId();
+    }
+
+    public function getId(): ?int
+    {
+        return $this->id;
+    }
+
+    public function getName(): ?string
+    {
+        return $this->name;
+    }
+
+    public function setName(?string $name): TagTest
+    {
+        $this->name = $name;
+        return $this;
+    }
+
+    public function getPost(): ?PostTest
+    {
+        return $this->post;
+    }
+
+    public function setPost(?PostTest $post): TagTest
+    {
+        $this->post = $post;
+        return $this;
+    }
+}

+ 166 - 0
tests/Entity/UserTest.php

@@ -0,0 +1,166 @@
+<?php
+
+namespace Test\PhpDevCommunity\PaperORM\Entity;
+
+use PhpDevCommunity\PaperORM\Collection\ObjectStorage;
+use PhpDevCommunity\PaperORM\Entity\EntityInterface;
+use PhpDevCommunity\PaperORM\Mapping\Column\BoolColumn;
+use PhpDevCommunity\PaperORM\Mapping\Column\StringColumn;
+use PhpDevCommunity\PaperORM\Mapping\Column\DateTimeColumn;
+use PhpDevCommunity\PaperORM\Mapping\Column\JoinColumn;
+use PhpDevCommunity\PaperORM\Mapping\Column\PrimaryKeyColumn;
+use PhpDevCommunity\PaperORM\Mapping\OneToMany;
+
+class UserTest implements EntityInterface
+{
+    private ?int $id = null;
+
+    private ?string $firstname = null;
+
+    private ?string $lastname    = null;
+
+    private ?string $email = null;
+
+    private ?string $password = null;
+
+    private bool $active = false;
+
+    private ?\DateTime $createdAt = null;
+
+    private ObjectStorage $posts;
+    private ?PostTest $lastPost = null;
+    public function __construct()
+    {
+        $this->posts = new ObjectStorage();
+        $this->createdAt = new \DateTime();
+    }
+
+    static public function getTableName(): string
+    {
+        return 'user';
+    }
+
+    static public function getRepositoryName(): ?string
+    {
+        return null;
+    }
+
+    static public function columnsMapping(): array
+    {
+        return [
+            new PrimaryKeyColumn('id'),
+            new StringColumn('firstname'),
+            new StringColumn('lastname'),
+            new StringColumn('email'),
+            new StringColumn('password'),
+            new BoolColumn('active', 'is_active'),
+            new DateTimeColumn('createdAt', 'created_at'),
+            new OneToMany('posts', PostTest::class,  'user'),
+            new JoinColumn('lastPost', 'last_post_id', 'id', PostTest::class),
+        ];
+    }
+
+    public function getPrimaryKeyValue() : ?int
+    {
+        return $this->getId();
+    }
+
+    public function getId(): ?int
+    {
+        return $this->id;
+    }
+
+    public function setId(?int $id): UserTest
+    {
+        $this->id = $id;
+        return $this;
+    }
+
+    public function getFirstname(): ?string
+    {
+        return $this->firstname;
+    }
+
+    public function setFirstname(?string $firstname): UserTest
+    {
+        $this->firstname = $firstname;
+        return $this;
+    }
+
+    public function getLastname(): ?string
+    {
+        return $this->lastname;
+    }
+
+    public function setLastname(?string $lastname): UserTest
+    {
+        $this->lastname = $lastname;
+        return $this;
+    }
+
+    public function getEmail(): ?string
+    {
+        return $this->email;
+    }
+
+    public function setEmail(?string $email): UserTest
+    {
+        $this->email = $email;
+        return $this;
+    }
+
+    public function getPassword(): ?string
+    {
+        return $this->password;
+    }
+
+    public function setPassword(?string $password): UserTest
+    {
+        $this->password = $password;
+        return $this;
+    }
+
+    public function isActive(): bool
+    {
+        return $this->active;
+    }
+
+    public function setActive(bool $active): UserTest
+    {
+        $this->active = $active;
+        return $this;
+    }
+
+    public function getCreatedAt(): ?\DateTime
+    {
+        return $this->createdAt;
+    }
+
+    public function setCreatedAt(?\DateTime $createdAt): UserTest
+    {
+        $this->createdAt = $createdAt;
+        return $this;
+    }
+
+    public function getPosts(): ObjectStorage
+    {
+        return $this->posts;
+    }
+
+    public function addPost(PostTest $post): UserTest
+    {
+        $this->posts->add($post);
+        return $this;
+    }
+
+    public function getLastPost(): ?PostTest
+    {
+        return $this->lastPost;
+    }
+
+    public function setLastPost(?PostTest $lastPost): UserTest
+    {
+        $this->lastPost = $lastPost;
+        return $this;
+    }
+}

+ 32 - 0
tests/Factory/DatabaseConnectionFactory.php

@@ -0,0 +1,32 @@
+<?php
+
+namespace Test\PhpDevCommunity\PaperORM\Factory;
+
+use PhpDevCommunity\PaperORM\EntityManager;
+
+final class DatabaseConnectionFactory
+{
+    public static function createConnection(string $driver): EntityManager
+    {
+        switch ($driver) {
+            case 'sqlite':
+                return new EntityManager([
+                    'driver' => 'sqlite',
+                    'user' => null,
+                    'password' => null,
+                    'memory' => true,
+                ]);
+
+            case 'mariadb':
+                return new EntityManager([
+                    'driver' => 'pdo_mysql',
+                    'host' => 'localhost',
+                    'dbname' => 'test_db',
+                    'user' => 'root',
+                    'password' => '',
+                ]);
+            default:
+                throw new \InvalidArgumentException("Database driver '$driver' not supported");
+        }
+    }
+}

+ 156 - 0
tests/Helper/DataBaseHelperTest.php

@@ -0,0 +1,156 @@
+<?php
+
+namespace Test\PhpDevCommunity\PaperORM\Helper;
+
+use PhpDevCommunity\PaperORM\EntityManager;
+use PhpDevCommunity\PaperORM\Mapping\Column\BoolColumn;
+use PhpDevCommunity\PaperORM\Mapping\Column\DateTimeColumn;
+use PhpDevCommunity\PaperORM\Mapping\Column\JoinColumn;
+use PhpDevCommunity\PaperORM\Mapping\Column\PrimaryKeyColumn;
+use PhpDevCommunity\PaperORM\Mapping\Column\StringColumn;
+use PhpDevCommunity\PaperORM\Metadata\IndexMetadata;
+use PhpDevCommunity\PaperORM\PaperConnection;
+use Test\PhpDevCommunity\PaperORM\Entity\PostTest;
+use Test\PhpDevCommunity\PaperORM\Entity\UserTest;
+
+class DataBaseHelperTest
+{
+
+    public static function init(EntityManager $entityManager, int $nbUsers = 5)
+    {
+        $connection = $entityManager->getConnection();
+        $connection->close();
+        $connection->connect();
+        $platform = $entityManager->createDatabasePlatform();
+        $platform->createTable('user', [
+            new PrimaryKeyColumn('id'),
+            new JoinColumn('post', 'last_post_id', 'id', PostTest::class, true),
+            new StringColumn('firstname'),
+            new StringColumn('lastname'),
+            new StringColumn('email'),
+            new StringColumn('password'),
+            new BoolColumn('is_active'),
+            new DateTimeColumn('created_at', 'created_at', true),
+        ]);
+
+        $platform->createTable('post', [
+            new PrimaryKeyColumn('id'),
+            new JoinColumn('user', 'user_id', 'id', UserTest::class, false),
+            new StringColumn('title'),
+            new StringColumn('content'),
+            new DateTimeColumn('created_at', 'created_at', true),
+        ]);
+
+        $platform->createIndex(new IndexMetadata('post', 'idx_post_user_id', ['user_id']));
+
+        $platform->createTable('tag', [
+            new PrimaryKeyColumn('id'),
+            new JoinColumn('post', 'post_id', 'id', PostTest::class),
+            new StringColumn('name'),
+        ]);
+
+
+        $platform->createTable('comment', [
+            new PrimaryKeyColumn('id'),
+            new JoinColumn('post', 'post_id', 'id', PostTest::class),
+            new StringColumn('body'),
+        ]);
+
+        for ($i = 0; $i <$nbUsers; $i++) {
+            $user = [
+                'firstname' => 'John' . $i,
+                'lastname' => 'Doe' . $i,
+                'email' => $i . 'bqQpB@example.com',
+                'password' => 'password123',
+                'is_active' => true,
+                'created_at' => (new \DateTime())->format($platform->getSchema()->getDateTimeFormatString()),
+            ];
+
+            $stmt = $connection->getPdo()->prepare("INSERT INTO user (firstname, lastname, email, password, is_active, created_at) VALUES (:firstname, :lastname, :email, :password, :is_active, :created_at)");
+            $stmt->execute([
+                'firstname' => $user['firstname'],
+                'lastname' => $user['lastname'],
+                'email' => $user['email'],
+                'password' => $user['password'],
+                'is_active' => $user['is_active'],
+                'created_at' => $user['created_at']
+            ]);
+        }
+//
+        $nbPosts = $nbUsers - 1;
+        for ($i = 0; $i < $nbPosts; $i++) {
+            $id = uniqid('post_', true);
+            $post = [
+                'user_id' => $i + 1,
+                'title' => 'Post ' . $id,
+                'content' => 'Content ' . $id,
+                'created_at' => (new \DateTime())->format($platform->getSchema()->getDateTimeFormatString()),
+            ];
+
+            $stmt = $connection->getPdo()->prepare("INSERT INTO post (user_id, title, content, created_at)  VALUES (:user_id, :title, :content, :created_at)");
+            $stmt->execute([
+                'user_id' => $post['user_id'],
+                'title' => $post['title'],
+                'content' => $post['content'],
+                'created_at' => $post['created_at']
+            ]);
+            $id = uniqid('post_', true);
+            $post = [
+                'user_id' => $i + 1,
+                'title' => 'Post ' . $id,
+                'content' => 'Content ' . $id,
+            ];
+            $connection->executeStatement("INSERT INTO post (user_id, title, content) VALUES (
+                '{$post['user_id']}',
+                '{$post['title']}',
+                '{$post['content']}'
+            )");
+
+            $lastId = $connection->getPdo()->lastInsertId();
+            $connection->executeStatement('UPDATE user SET last_post_id = ' . $lastId . ' WHERE id = ' . $post['user_id']);
+        }
+
+        $nbTags = $nbPosts * 2;
+        for ($i = 0; $i < $nbTags; $i++) {
+            $id = uniqid('tag_', true);
+            $tag = [
+                'post_id' => $i + 1,
+                'name' => 'Tag ' . $id,
+            ];
+            $connection->executeStatement("INSERT INTO tag (post_id, name) VALUES (
+                '{$tag['post_id']}',
+                '{$tag['name']}      '
+            )");
+
+            $id = uniqid('tag_', true);
+            $tag = [
+                'post_id' => $i + 1,
+                'name' => 'Tag ' . $id,
+            ];
+            $connection->executeStatement("INSERT INTO tag (post_id, name) VALUES (
+                '{$tag['post_id']}',
+                '{$tag['name']}      '
+            )");
+        }
+
+        $nbComments = $nbTags - 1;
+        for ($i = 0; $i <$nbComments; $i++) {
+            $id = uniqid('comment_', true);
+            $comment = [
+                'post_id' => $i + 1,
+                'body' => 'Comment ' . $id,
+            ];
+            $connection->executeStatement("INSERT INTO comment (post_id, body) VALUES (
+                '{$comment['post_id']}',
+                '{$comment['body']}      '
+            )");
+
+            $comment['body'] = 'Comment ' . $id . ' 2';
+            $connection->executeStatement("INSERT INTO comment (post_id, body) VALUES (
+                '{$comment['post_id']}',
+                '{$comment['body']}      '
+            )");
+        }
+    }
+
+}

+ 116 - 0
tests/MigrationTest.php

@@ -0,0 +1,116 @@
+<?php
+
+namespace Test\PhpDevCommunity\PaperORM;
+
+use PhpDevCommunity\PaperORM\EntityManager;
+use PhpDevCommunity\PaperORM\Mapper\ColumnMapper;
+use PhpDevCommunity\PaperORM\Mapping\Column\IntColumn;
+use PhpDevCommunity\PaperORM\Mapping\Column\StringColumn;
+use PhpDevCommunity\PaperORM\Migration\PaperMigration;
+use PhpDevCommunity\UniTester\TestCase;
+use Test\PhpDevCommunity\PaperORM\Entity\PostTest;
+use Test\PhpDevCommunity\PaperORM\Entity\UserTest;
+
+class MigrationTest extends TestCase
+{
+    private EntityManager $em;
+    private string $migrationDir;
+    private PaperMigration $paperMigration;
+    protected function setUp(): void
+    {
+        $this->em = new EntityManager([
+            'driver' => 'sqlite',
+            'user' => null,
+            'password' => null,
+            'memory' => true,
+        ]);
+        $this->migrationDir = __DIR__ . '/migrations';
+        $this->paperMigration = PaperMigration::create($this->em, 'mig_versions', $this->migrationDir);
+
+    }
+    protected function tearDown(): void
+    {
+        $this->em->getConnection()->close();
+        $folder = $this->migrationDir;
+        array_map('unlink', glob("$folder/*.*"));
+    }
+
+    protected function execute(): void
+    {
+        $this->em->getConnection()->close();
+        $this->testDiff();
+        $this->testExecute();
+        $this->testColumnModification();
+        $this->testFailedMigration();
+    }
+
+    private function testDiff() :   void
+    {
+        $this->em->getConnection()->close();
+        $migrationFile = $this->paperMigration->diffEntities([
+            UserTest::class,
+            PostTest::class
+        ]);
+
+        $this->assertStringContains(file_get_contents($migrationFile), '-- UP MIGRATION --');
+        $this->assertStringContains(file_get_contents($migrationFile), 'CREATE TABLE user (id INTEGER PRIMARY KEY NOT NULL,firstname VARCHAR(255) NOT NULL,lastname VARCHAR(255) NOT NULL,email VARCHAR(255) NOT NULL,password VARCHAR(255) NOT NULL,is_active BOOLEAN NOT NULL,created_at DATETIME NOT NULL,last_post_id INTEGER NOT NULL,FOREIGN KEY (last_post_id) REFERENCES post (id));');
+        $this->assertStringContains(file_get_contents($migrationFile), 'CREATE INDEX IX_2D053F64 ON user (last_post_id);');
+        $this->assertStringContains(file_get_contents($migrationFile), 'CREATE TABLE post (id INTEGER PRIMARY KEY NOT NULL,title VARCHAR(255) NOT NULL,content VARCHAR(255) NOT NULL,created_at DATETIME NOT NULL,user_id INTEGER NOT NULL,FOREIGN KEY (user_id) REFERENCES user (id));');
+        $this->assertStringContains(file_get_contents($migrationFile), 'CREATE INDEX IX_A76ED395 ON post (user_id);');
+
+        $this->assertStringContains(file_get_contents($migrationFile), '-- DOWN MIGRATION --');
+        $this->assertStringContains(file_get_contents($migrationFile), 'DROP INDEX IX_2D053F64;');
+        $this->assertStringContains(file_get_contents($migrationFile), 'DROP TABLE user;');
+        $this->assertStringContains(file_get_contents($migrationFile), 'DROP INDEX IX_A76ED395;');
+        $this->assertStringContains(file_get_contents($migrationFile), 'DROP TABLE post;');
+     }
+
+    private function testExecute(): void
+    {
+        $this->paperMigration->migrate();
+        $successList = $this->paperMigration->getSuccessList();
+        $this->assertTrue(count($successList) === 1);
+
+        $migrationFile = $this->paperMigration->diffEntities([UserTest::class]);
+        $this->assertNull($migrationFile);
+    }
+
+    private function testColumnModification(): void
+    {
+        $userColumns = ColumnMapper::getColumns(UserTest::class);
+        $userColumns[3] = new StringColumn('email', 'email', 255, true, null, true);
+        $userColumns[] = new IntColumn('childs', 'childs', false, 0);
+        $migrationFile = $this->paperMigration->diff([
+            'user' => [
+                'columns' => $userColumns,
+                'indexes' => []
+            ]
+        ]);
+        $this->paperMigration->migrate();
+        $successList = $this->paperMigration->getSuccessList();
+        $this->assertTrue(count($successList) === 1);
+        $this->assertEquals(pathinfo($migrationFile, PATHINFO_FILENAME), $successList[0]);
+        $this->assertStringContains(file_get_contents( $migrationFile ), 'ALTER TABLE user ADD childs INTEGER NOT NULL DEFAULT 0;');
+        $this->assertStringContains(file_get_contents( $migrationFile ), 'CREATE UNIQUE INDEX IX_E7927C74 ON user (email);');
+        $this->assertStringContains(file_get_contents( $migrationFile ), 'DROP INDEX IX_E7927C74;');
+    }
+
+    private function testFailedMigration(): void
+    {
+        $userColumns = ColumnMapper::getColumns(UserTest::class);
+        $userColumns[3] = new StringColumn('email', 'email', 100, true, null, true);
+        $this->paperMigration->diff([
+            'user' => [
+                'columns' => $userColumns,
+                'indexes' => []
+            ]
+        ]);
+
+        $this->expectException( \RuntimeException::class, function ()  {
+            $this->paperMigration->migrate();
+        });
+        $successList = $this->paperMigration->getSuccessList();
+        $this->assertTrue(count($successList) === 0);
+
+    }
+}

+ 148 - 0
tests/PersistAndFlushTest.php

@@ -0,0 +1,148 @@
+<?php
+
+namespace Test\PhpDevCommunity\PaperORM;
+
+use PhpDevCommunity\PaperORM\EntityManager;
+use PhpDevCommunity\PaperORM\Expression\Expr;
+use PhpDevCommunity\PaperORM\Proxy\ProxyInterface;
+use PhpDevCommunity\UniTester\TestCase;
+use Test\PhpDevCommunity\PaperORM\Entity\PostTest;
+use Test\PhpDevCommunity\PaperORM\Entity\UserTest;
+use Test\PhpDevCommunity\PaperORM\Helper\DataBaseHelperTest;
+
+class PersistAndFlushTest extends TestCase
+{
+    private EntityManager $em;
+
+    protected function setUp(): void
+    {
+        $this->em = new EntityManager([
+            'driver' => 'sqlite',
+            'user' => null,
+            'password' => null,
+            'memory' => true,
+            'debug' => true
+        ]);
+        $this->setUpDatabaseSchema();
+    }
+
+    protected function setUpDatabaseSchema(): void
+    {
+        DataBaseHelperTest::init($this->em);
+    }
+
+    protected function tearDown(): void
+    {
+        $this->em->getConnection()->close();
+    }
+
+    protected function execute(): void
+    {
+        $this->testUpdate();
+        $this->testUpdateJoinColumn();
+        $this->testDelete();
+    }
+
+    private function testUpdate(): void
+    {
+        $userRepository = $this->em->getRepository(UserTest::class);
+        $user = $userRepository->findBy()->first()->orderBy('id')->toObject();
+        $this->assertInstanceOf(ProxyInterface::class, $user);
+        $this->assertInstanceOf(UserTest::class, $user);
+        /**
+         * @var ProxyInterface|UserTest $user
+         */
+        $user->setActive(false);
+        $user->setLastname('TOTO');
+        $this->assertStrictEquals(2, count($user->__getPropertiesModified()));
+
+        $this->em->persist($user);
+        $this->em->flush();
+        $user = null;
+        $this->em->clear();
+
+        $user = $userRepository->findBy()->first()->orderBy('id')->toObject();
+        $this->assertInstanceOf(ProxyInterface::class, $user);
+        $this->assertInstanceOf(UserTest::class, $user);
+        /**
+         * @var ProxyInterface|UserTest $user
+         */
+        $this->assertStrictEquals(0, count($user->__getPropertiesModified()));
+        $this->assertFalse($user->isActive());
+        $this->assertStrictEquals('TOTO', $user->getLastname());
+    }
+
+
+    private function testUpdateJoinColumn()
+    {
+        $userRepository = $this->em->getRepository(UserTest::class);
+        $postRepository = $this->em->getRepository(PostTest::class);
+        $post = $postRepository->findBy()->first()
+            ->where(Expr::isNotNull('user'))
+            ->with(UserTest::class)
+            ->toObject();
+        $this->assertInstanceOf(ProxyInterface::class, $post);
+        $this->assertInstanceOf(PostTest::class, $post);
+        $this->assertInstanceOf(UserTest::class, $post->getUser());
+        $this->assertStrictEquals(1, $post->getUser()->getId());
+
+        $user2 = $userRepository->find(2)
+            ->with(PostTest::class)
+            ->toObject();
+        $this->assertStrictEquals(2, count($user2->getPosts()->toArray()));
+        foreach ($user2->getPosts()->toArray() as $postItem) {
+            $this->assertInstanceOf(ProxyInterface::class, $postItem);
+            $this->assertInstanceOf(PostTest::class, $postItem);
+        }
+        $post->setUser($user2);
+        $this->em->persist($post);
+        $this->em->flush();
+        $user2 = $userRepository->find(2)
+            ->with(PostTest::class)
+            ->toObject();
+        $this->assertStrictEquals(3, count($user2->getPosts()->toArray()));
+
+        $user1 = $userRepository->find(1)->with(PostTest::class)->toObject();
+        $this->assertStrictEquals(1, count($user1->getPosts()->toArray()));
+    }
+
+    private function testDelete()
+    {
+        $user = $this->em->getRepository(UserTest::class)->find(1)->toObject();
+        $this->assertInstanceOf(ProxyInterface::class, $user);
+        $this->assertInstanceOf(UserTest::class, $user);
+
+        $posts = $user->getPosts();
+        $this->em->remove($user);
+        $this->em->flush();
+        $this->assertFalse($user->__isInitialized());
+        /**
+         * @var PostTest|ProxyInterface $post
+         */
+        $post = $this->em->getRepository(PostTest::class)
+            ->findBy()
+            ->first()
+            ->where(Expr::equal('user', $user->getId()))
+            ->with(UserTest::class)
+            ->toObject();
+        $this->assertNull($post->getUser());
+
+        $user = $this->em->getRepository(UserTest::class)->find(1)->toObject();
+        $this->assertNull($user);
+
+        $ids = [];
+        foreach ($posts as $postToDelete) {
+            $ids[] = $postToDelete->getId();
+            $this->em->remove($postToDelete);
+            $this->em->flush();
+            $this->assertFalse($postToDelete->__isInitialized());
+        }
+        $this->assertStrictEquals($posts->count(), count($ids));
+        foreach ($ids as $idPost) {
+            $postToDelete = $this->em->getRepository(PostTest::class)->find($idPost)->toObject();
+            $this->assertNull($postToDelete);
+        }
+
+        $this->assertFalse($post->__isInitialized());
+    }
+}

+ 73 - 0
tests/PlatformDiffTest.php

@@ -0,0 +1,73 @@
+<?php
+
+namespace Test\PhpDevCommunity\PaperORM;
+
+use PhpDevCommunity\PaperORM\EntityManager;
+use PhpDevCommunity\PaperORM\Mapping\Column\BoolColumn;
+use PhpDevCommunity\PaperORM\Mapping\Column\PrimaryKeyColumn;
+use PhpDevCommunity\PaperORM\Mapping\Column\StringColumn;
+use PhpDevCommunity\UniTester\TestCase;
+
+class PlatformDiffTest extends TestCase
+{
+    private EntityManager $em;
+
+    protected function setUp(): void
+    {
+        $this->em = new EntityManager([
+            'driver' => 'sqlite',
+            'user' => null,
+            'password' => null,
+            'memory' => true,
+        ]);
+
+    }
+
+    protected function tearDown(): void
+    {
+        $this->em->getConnection()->close();
+    }
+
+    protected function execute(): void
+    {
+        $platform = $this->em->createDatabasePlatform();
+        $columns = [
+            new PrimaryKeyColumn('id'),
+            new StringColumn('firstname'),
+            new StringColumn('lastname'),
+            new StringColumn('email'),
+            new StringColumn('password'),
+            new BoolColumn('active', 'is_active'),
+        ];
+        $platform->createTable('user', $columns);
+
+        $diff = $platform->diff('user', $columns, [] );
+
+        $this->assertEmpty($diff->getColumnsToAdd());
+        $this->assertEmpty($diff->getColumnsToUpdate());
+        $this->assertEmpty($diff->getColumnsToDelete());
+//
+//
+        $columns[3] = new StringColumn('username');
+
+        $diff = $platform->diff('user', $columns, [] );
+
+        $this->assertTrue(count($diff->getColumnsToAdd()) == 1);
+        $this->assertTrue(count($diff->getColumnsToDelete()) == 1);
+        $this->assertEmpty($diff->getColumnsToUpdate());
+
+        $platform->dropTable('user');
+        $platform->createTable('user', $columns, [] );
+
+        $columns[3] = new StringColumn('username', 'username', 100);
+        $diff = $platform->diff('user', $columns, [] );
+
+        $this->assertTrue(count($diff->getColumnsToUpdate()) == 1);
+        $this->assertEmpty($diff->getColumnsToAdd());
+        $this->assertEmpty($diff->getColumnsToDelete());
+
+
+        $diff = $platform->diff('user2', $columns, [] );
+        $this->assertTrue(count($diff->getColumnsToAdd()) == 6);
+    }
+}

+ 166 - 0
tests/PlatformTest.php

@@ -0,0 +1,166 @@
+<?php
+
+namespace Test\PhpDevCommunity\PaperORM;
+
+use PhpDevCommunity\PaperORM\EntityManager;
+use PhpDevCommunity\PaperORM\Mapping\Column\BoolColumn;
+use PhpDevCommunity\PaperORM\Mapping\Column\IntColumn;
+use PhpDevCommunity\PaperORM\Mapping\Column\PrimaryKeyColumn;
+use PhpDevCommunity\PaperORM\Mapping\Column\StringColumn;
+use PhpDevCommunity\PaperORM\Metadata\ColumnMetadata;
+use PhpDevCommunity\UniTester\TestCase;
+
+class PlatformTest extends TestCase
+{
+    private EntityManager $em;
+
+    protected function setUp(): void
+    {
+        $this->em = new EntityManager([
+            'driver' => 'sqlite',
+            'user' => null,
+            'password' => null,
+            'memory' => true,
+        ]);
+
+    }
+
+    protected function tearDown(): void
+    {
+        $this->em->getConnection()->close();
+    }
+
+    protected function execute(): void
+    {
+      $this->testCreateTables();
+      $this->testDropTable();
+      $this->testDropColumn();
+      $this->testAddColumn();
+      $this->testRenameColumn();
+    }
+
+    public function testCreateTables()
+    {
+        $this->em->getConnection()->close();
+        $this->em->getConnection()->connect();
+        $platform = $this->em->createDatabasePlatform();
+        $platform->createTable('user', [
+            new PrimaryKeyColumn('id'),
+            new StringColumn('firstname'),
+            new StringColumn('lastname'),
+            new StringColumn('email'),
+            new StringColumn('password'),
+            new BoolColumn('is_active'),
+        ]);
+
+
+        $platform->createTable('post', [
+            new PrimaryKeyColumn('id'),
+            new IntColumn('user_id'),
+            new StringColumn('title'),
+            new StringColumn('content'),
+        ], [
+            'FOREIGN KEY (user_id) REFERENCES user (id)'
+        ]);
+
+        $this->assertStrictEquals(2, count($platform->listTables()));
+        $this->assertEquals(['user', 'post'], $platform->listTables());
+    }
+
+
+    public function testDropTable()
+    {
+        $this->em->getConnection()->close();
+        $this->em->getConnection()->connect();
+
+        $platform = $this->em->createDatabasePlatform();
+        $platform->createTable('user', [
+            new PrimaryKeyColumn('id'),
+            new StringColumn('firstname'),
+            new StringColumn('lastname'),
+            new StringColumn('email'),
+            new StringColumn('password'),
+            new BoolColumn('is_active'),
+        ]);
+
+        $this->assertStrictEquals(1, count($platform->listTables()));
+        $platform->dropTable('user');
+        $this->assertStrictEquals(0, count($platform->listTables()));
+    }
+
+    public function testDropColumn()
+    {
+        if (\SQLite3::version()['versionString'] < '3.35.0') {
+            return;
+        }
+
+        $this->em->getConnection()->close();
+        $this->em->getConnection()->connect();
+
+        $platform = $this->em->createDatabasePlatform();
+        $platform->createTable('user', [
+            new PrimaryKeyColumn('id'),
+            new StringColumn('firstname'),
+            new StringColumn('lastname'),
+            new StringColumn('email'),
+            new StringColumn('password'),
+            new BoolColumn('is_active'),
+        ]);
+
+        $this->assertStrictEquals(6, count($platform->listTableColumns('user')));
+        $platform->dropColumn('user', 'lastname');
+        $this->assertStrictEquals(5, count($platform->listTableColumns('user')));
+    }
+
+    public function testAddColumn()
+    {
+        $this->em->getConnection()->close();
+        $this->em->getConnection()->connect();
+
+        $platform = $this->em->createDatabasePlatform();
+        $platform->createTable('user', [
+            new PrimaryKeyColumn('id'),
+            new StringColumn('firstname'),
+            new StringColumn('lastname'),
+            new StringColumn('email'),
+            new StringColumn('password'),
+            new BoolColumn('is_active'),
+        ]);
+
+        $this->assertStrictEquals(6, count($platform->listTableColumns('user')));
+        $platform->addColumn('user', new StringColumn('username'));
+        $this->assertStrictEquals(7, count($platform->listTableColumns('user')));
+    }
+
+    public function testRenameColumn()
+    {
+        $this->em->getConnection()->close();
+        $this->em->getConnection()->connect();
+
+        $platform = $this->em->createDatabasePlatform();
+        $platform->createTable('user', [
+            new PrimaryKeyColumn('id'),
+            new StringColumn('firstname'),
+            new StringColumn('lastname'),
+            new StringColumn('email'),
+            new StringColumn('password'),
+            new BoolColumn('is_active'),
+        ]);
+
+        $columnsAsArray = array_map(function (ColumnMetadata $column) {
+            return $column->toArray();
+        }, $platform->listTableColumns('user'));
+        $columns = array_column($columnsAsArray, 'name');
+        $this->assertTrue(in_array('firstname', $columns));
+
+        $platform->renameColumn('user', 'firstname', 'prenom');
+
+        $columnsAsArray = array_map(function (ColumnMetadata $column) {
+            return $column->toArray();
+        }, $platform->listTableColumns('user'));
+        $columns = array_column($columnsAsArray, 'name');
+        $this->assertTrue(!in_array('firstname', $columns));
+        $this->assertTrue(in_array('prenom', $columns));
+    }
+
+}

+ 15 - 0
tests/Repository/PostTestRepository.php

@@ -0,0 +1,15 @@
+<?php
+
+namespace Test\PhpDevCommunity\PaperORM\Repository;
+
+use PhpDevCommunity\PaperORM\Repository\Repository;
+use Test\PhpDevCommunity\PaperORM\Entity\PostTest;
+use Test\PhpDevCommunity\PaperORM\Entity\UserTest;
+
+class PostTestRepository extends Repository
+{
+    public function getEntityName(): string
+    {
+        return PostTest::class;
+    }
+}

+ 15 - 0
tests/Repository/TagTestRepository.php

@@ -0,0 +1,15 @@
+<?php
+
+namespace Test\PhpDevCommunity\PaperORM\Repository;
+
+use PhpDevCommunity\PaperORM\Repository\Repository;
+use Test\PhpDevCommunity\PaperORM\Entity\PostTest;
+use Test\PhpDevCommunity\PaperORM\Entity\TagTest;
+
+class TagTestRepository extends Repository
+{
+    public function getEntityName(): string
+    {
+        return TagTest::class;
+    }
+}

+ 366 - 0
tests/RepositoryTest.php

@@ -0,0 +1,366 @@
+<?php
+
+namespace Test\PhpDevCommunity\PaperORM;
+
+use PhpDevCommunity\PaperORM\EntityManager;
+use PhpDevCommunity\UniTester\TestCase;
+use Test\PhpDevCommunity\PaperORM\Entity\PostTest;
+use Test\PhpDevCommunity\PaperORM\Entity\UserTest;
+use Test\PhpDevCommunity\PaperORM\Helper\DataBaseHelperTest;
+
+class RepositoryTest extends TestCase
+{
+    private EntityManager $em;
+
+    protected function setUp(): void
+    {
+        $this->em = new EntityManager([
+            'driver' => 'sqlite',
+            'user' => null,
+            'password' => null,
+            'memory' => true,
+        ]);
+        $this->setUpDatabaseSchema();
+    }
+
+    protected function tearDown(): void
+    {
+        $this->em->getConnection()->close();
+    }
+
+    protected function execute(): void
+    {
+        $this->testSelectWithoutJoin();
+        $this->testSelectInnerJoin();
+        $this->testSelectLeftJoin();
+    }
+
+    public function testSelectWithoutJoin(): void
+    {
+        $userRepository = $this->em->getRepository(UserTest::class);
+        $user = $userRepository->findBy()
+            ->first()->orderBy('id')->toArray();
+
+        $this->assertStrictEquals( 1, $user['id'] );
+        $this->assertStrictEquals( 'John0', $user['firstname'] );
+        $this->assertStrictEquals( 'Doe0', $user['lastname'] );
+        $this->assertStrictEquals( '0bqQpB@example.com', $user['email'] );
+        $this->assertStrictEquals( 'password123', $user['password'] );
+
+        $user = $userRepository->find(1)->toArray();
+
+        $this->assertStrictEquals( 1, $user['id'] );
+        $this->assertStrictEquals( 'John0', $user['firstname'] );
+        $this->assertStrictEquals( 'Doe0', $user['lastname'] );
+        $this->assertStrictEquals( '0bqQpB@example.com', $user['email'] );
+        $this->assertStrictEquals( 'password123', $user['password'] );
+
+        /**
+         * @var UserTest $user
+         */
+        $user = $userRepository->find(1)->toObject();
+
+        $this->assertStrictEquals( 1, $user->getId() );
+        $this->assertStrictEquals( 'John0', $user->getFirstname() );
+        $this->assertStrictEquals( 'Doe0', $user->getLastname() );
+        $this->assertStrictEquals( '0bqQpB@example.com', $user->getEmail() );
+        $this->assertStrictEquals( 'password123', $user->getPassword() );
+        $this->assertInstanceOf( \DateTimeInterface::class, $user->getCreatedAt() );
+        $this->assertEmpty($user->getPosts()->toArray());
+        $this->assertNull($user->getLastPost());
+
+        $users = $userRepository->findBy()->orderBy('id')->toArray();
+        $this->assertStrictEquals( 1, $users[0]['id'] );
+        $this->assertStrictEquals(5, count($users));
+
+        $users = $userRepository->findBy()->orderBy('id')->toObject();
+        $this->assertStrictEquals(5, count($users));
+        foreach ($users as $user) {
+            $this->assertInstanceOf(UserTest::class, $user);
+        }
+    }
+
+    public function testSelectInnerJoin(): void
+    {
+        $userRepository = $this->em->getRepository(UserTest::class);
+        $user = $userRepository->findBy()
+            ->first()
+            ->orderBy('id', 'DESC')
+            ->has(PostTest::class)
+            ->toArray();
+
+        $this->assertStrictEquals( 4, $user['id'] );
+        $this->assertTrue(is_array( $user['posts'] ));
+        $this->assertNotEmpty($user['posts']);
+        $this->assertTrue(is_array( $user['lastPost'] ));
+        $this->assertNotEmpty($user['lastPost']);
+
+        $this->em->clear();
+        /**
+         * @var UserTest $user
+         */
+        $user = $userRepository->findBy()
+            ->first()
+            ->orderBy('id', 'DESC')
+            ->has(PostTest::class)
+            ->toObject();
+
+        $this->assertStrictEquals( 4, $user->getId() );
+        $this->assertNotEmpty($user->getPosts()->toArray());
+        $this->assertInstanceOf(PostTest::class, $user->getLastPost());
+
+
+        $this->em->clear();
+        $users = $userRepository->findBy()->orderBy('id', 'DESC')->has(PostTest::class)->toArray();
+        $this->assertStrictEquals( 4, $users[0]['id'] );
+        $this->assertStrictEquals(4, count($users));
+
+        $this->em->clear();
+        $users = $userRepository->findBy()->orderBy('id', 'DESC')->has(PostTest::class)->toObject();
+        $this->assertStrictEquals( 4, $users[0]->getId() );
+        $this->assertStrictEquals(4, count($users));
+        foreach ($users as $user) {
+            $this->assertInstanceOf(UserTest::class, $user);
+            $this->assertInstanceOf(PostTest::class, $user->getLastPost());
+            $this->assertEmpty($user->getLastPost()->getTags()->toArray());
+
+            $this->assertNotEmpty($user->getPosts()->toArray());
+            foreach ($user->getPosts() as $post) {
+                $this->assertInstanceOf(PostTest::class, $post);
+                $this->assertNull($post->getUser());
+                $this->assertEmpty($post->getTags()->toArray());
+            }
+        }
+
+        $this->em->clear();
+        $users = $userRepository->findBy()
+            ->orderBy('id', 'DESC')
+            ->has('posts.tags')
+            ->toObject();
+        foreach ($users as $user) {;
+            $this->assertInstanceOf(UserTest::class, $user);
+            $this->assertNull($user->getLastPost());
+            $this->assertNotEmpty($user->getPosts()->toArray());
+            foreach ($user->getPosts() as $post) {
+                $this->assertInstanceOf(PostTest::class, $post);
+                $this->assertNull($post->getUser());
+                $this->assertNotEmpty($post->getTags()->toArray());
+            }
+        }
+
+        $this->em->clear();
+        $users = $userRepository->findBy()
+            ->orderBy('id', 'DESC')
+            ->has('posts.tags')
+            ->has('lastPost.tags')
+            ->toObject();
+        foreach ($users as $user) {
+            $this->assertInstanceOf(UserTest::class, $user);
+            $this->assertInstanceOf(PostTest::class, $user->getLastPost());
+            $this->assertNotEmpty($user->getPosts()->toArray());
+            $this->assertNotEmpty($user->getLastPost()->getTags()->toArray());
+            foreach ($user->getPosts() as $post) {
+                $this->assertInstanceOf(PostTest::class, $post);
+                $this->assertNull($post->getUser());
+                $this->assertNotEmpty($post->getTags()->toArray());
+            }
+        }
+
+        $this->em->clear();
+        $users = $userRepository->findBy()
+            ->orderBy('id', 'DESC')
+            ->has('posts.tags')
+            ->has('posts.user')
+            ->has('lastPost.tags')
+            ->toObject();
+        foreach ($users as $user) {
+            $this->assertInstanceOf(UserTest::class, $user);
+            $this->assertInstanceOf(PostTest::class, $user->getLastPost());
+            $this->assertNotEmpty($user->getPosts()->toArray());
+            $this->assertNotEmpty($user->getLastPost()->getTags()->toArray());
+
+            $this->assertStrictEquals($user, $user->getLastPost()->getUser());
+            foreach ($user->getPosts() as $post) {
+                $this->assertStrictEquals($user, $post->getUser());
+                $this->assertInstanceOf(PostTest::class, $post);
+                $this->assertInstanceOf(UserTest::class, $post->getUser());
+                $this->assertNotEmpty($post->getTags()->toArray());
+            }
+        }
+
+        $this->em->clear();
+        $users = $userRepository->findBy()
+            ->orderBy('id', 'DESC')
+            ->has('posts.tags')
+            ->has('posts.comments')
+            ->toArray();
+
+        $this->assertStrictEquals(4, count($users));
+        foreach ($users as $user) {
+            $this->assertTrue(is_array( $user['posts'] ));
+            $this->assertNotEmpty($user['posts']);
+            foreach ($user['posts'] as $post) {
+                $this->assertTrue(!array_key_exists('user', $post));
+                $this->assertNotEmpty($post['comments']);
+                $this->assertNotEmpty($post['tags']);
+                $this->assertStrictEquals(2, count($post['comments']));
+            }
+        };
+
+        $this->em->clear();
+        $users = $userRepository->findBy()
+            ->orderBy('id', 'DESC')
+            ->has('posts.tags')
+            ->has('posts.comments')
+            ->toObject();
+
+        $this->assertStrictEquals(4, count($users));
+        foreach ($users as $user) {
+            $this->assertInstanceOf(UserTest::class, $user);
+            $this->assertNull($user->getLastPost());
+            $this->assertNotEmpty($user->getPosts()->toArray());
+            foreach ($user->getPosts() as $post) {
+                $this->assertInstanceOf(PostTest::class, $post);
+                $this->assertNull($post->getUser());
+                $this->assertNotEmpty($post->getTags()->toArray());
+                $this->assertNotEmpty($post->getComments()->toArray());
+            }
+        };
+        $this->em->clear();
+        $users = $userRepository->findBy()
+            ->orderBy('id', 'DESC')
+            ->has('lastPost.comments')
+            ->toObject();
+
+
+        $this->assertStrictEquals(3, count($users));
+        foreach ($users as $user) {
+            $this->assertInstanceOf(UserTest::class, $user);
+            $this->assertEmpty($user->getPosts()->toArray());
+
+            $post = $user->getLastPost();
+            $this->assertInstanceOf(PostTest::class, $post);
+            $this->assertNull($post->getUser());
+            $this->assertEmpty($post->getTags()->toArray());
+            $this->assertNotEmpty($post->getComments()->toArray());
+        }
+
+        $users = $userRepository->findBy()
+            ->has('lastPost.user')
+            ->limit(2)
+            ->toObject();
+
+        $this->assertStrictEquals(2, count($users));
+        foreach ($users as $user) {
+            $this->assertInstanceOf(UserTest::class, $user);
+            $this->assertInstanceOf(PostTest::class, $user->getLastPost());
+            $this->assertStrictEquals($user, $user->getLastPost()->getUser());
+            $this->assertEmpty($user->getPosts()->toArray());
+        }
+    }
+
+    public function testSelectLeftJoin(): void
+    {
+        $userRepository = $this->em->getRepository(UserTest::class);
+        $user = $userRepository->findBy()
+            ->first()
+            ->orderBy('id', 'DESC')
+            ->with(PostTest::class)
+            ->toArray();
+
+
+        $this->assertStrictEquals( 5, $user['id'] );
+        $this->assertTrue(is_array( $user['posts'] ));
+        $this->assertEmpty($user['posts']);
+        $this->assertNull($user['lastPost']);
+
+        $this->em->clear();
+        /**
+         * @var UserTest $user
+         */
+        $user = $userRepository->findBy()
+            ->first()
+            ->orderBy('id', 'DESC')
+            ->with(PostTest::class)
+            ->toObject();
+
+        $this->assertStrictEquals( 5, $user->getId() );
+        $this->assertEmpty($user->getPosts()->toArray());
+        $this->assertNull($user->getLastPost());
+
+        $this->em->clear();
+        $users = $userRepository->findBy()->orderBy('id', 'DESC')->with(PostTest::class)->toArray();
+        $this->assertStrictEquals( 5, $users[0]['id'] );
+        $this->assertStrictEquals(5, count($users));
+
+        $this->em->clear();
+        $users = $userRepository->findBy()->orderBy('id', 'DESC')->with(PostTest::class)->toObject();
+
+        $this->assertStrictEquals( 5, $users[0]->getId() );
+        $this->assertStrictEquals(5, count($users));
+        foreach ($users as $user) {
+            $this->assertInstanceOf(UserTest::class, $user);
+            if ($user->getId() === 5) {
+                $this->assertNull($user->getLastPost());
+                $this->assertEmpty($user->getPosts()->toArray());
+                continue;
+            }
+            $this->assertInstanceOf(PostTest::class, $user->getLastPost());
+            $this->assertEmpty($user->getLastPost()->getTags()->toArray());
+
+            $this->assertNotEmpty($user->getPosts()->toArray());
+            foreach ($user->getPosts() as $post) {
+                $this->assertInstanceOf(PostTest::class, $post);
+                $this->assertNull($post->getUser());
+                $this->assertEmpty($post->getTags()->toArray());
+            }
+        }
+
+        $this->em->clear();
+        $users = $userRepository->findBy()
+            ->orderBy('id', 'DESC')
+            ->with('posts.tags')
+            ->toObject();
+        foreach ($users as $user) {
+            $this->assertInstanceOf(UserTest::class, $user);
+            if ($user->getId() === 5) {
+                $this->assertEmpty($user->getPosts()->toArray());
+            }else {
+                $this->assertNotEmpty($user->getPosts()->toArray());
+            }
+            $this->assertNull($user->getLastPost());
+            foreach ($user->getPosts() as $post) {
+                $this->assertInstanceOf(PostTest::class, $post);
+                $this->assertNull($post->getUser());
+                $this->assertNotEmpty($post->getTags()->toArray());
+            }
+        }
+
+        $this->em->clear();
+        $users = $userRepository->findBy()
+            ->orderBy('id', 'DESC')
+            ->with('posts.tags')
+            ->with('lastPost.tags')
+            ->toObject();
+        foreach ($users as $user) {
+            $this->assertInstanceOf(UserTest::class, $user);
+            if ($user->getId() === 5) {
+                $this->assertNull($user->getLastPost());
+                $this->assertEmpty($user->getPosts()->toArray());
+            }else {
+                $this->assertInstanceOf(PostTest::class, $user->getLastPost());
+                $this->assertNotEmpty($user->getPosts()->toArray());
+                $this->assertNotEmpty($user->getLastPost()->getTags()->toArray());
+            }
+            foreach ($user->getPosts() as $post) {
+                $this->assertInstanceOf(PostTest::class, $post);
+                $this->assertNull($post->getUser());
+                $this->assertNotEmpty($post->getTags()->toArray());
+            }
+        }
+    }
+    protected function setUpDatabaseSchema(): void
+    {
+        DataBaseHelperTest::init($this->em);
+    }
+}

+ 2 - 0
tests/migrations/.gitignore

@@ -0,0 +1,2 @@
+*
+!.gitignore