Browse Source

Initial alpha release v0.0.1-alpha

michelphp 23 hours ago
commit
5827e9426d
100 changed files with 7632 additions and 0 deletions
  1. 5 0
      .env.test.dist
  2. 4 0
      .gitignore
  3. 373 0
      LICENSE
  4. 479 0
      README.md
  5. 41 0
      composer.json
  6. 27 0
      functions/helpers.php
  7. 81 0
      src/Assigner/AutoIncrementAssigner.php
  8. 10 0
      src/Assigner/CommitAssignerInterface.php
  9. 47 0
      src/Assigner/SlugAssigner.php
  10. 26 0
      src/Assigner/TimestampAssigner.php
  11. 27 0
      src/Assigner/TokenAssigner.php
  12. 26 0
      src/Assigner/UuidAssigner.php
  13. 10 0
      src/Assigner/ValueAssignerInterface.php
  14. 39 0
      src/Cache/ColumnCache.php
  15. 46 0
      src/Cache/EntityMemcachedCache.php
  16. 39 0
      src/Cache/OneToManyCache.php
  17. 32 0
      src/Cache/PrimaryKeyColumnCache.php
  18. 166 0
      src/Collection/ObjectStorage.php
  19. 73 0
      src/Collector/EntityDirCollector.php
  20. 56 0
      src/Command/DatabaseCreateCommand.php
  21. 68 0
      src/Command/DatabaseDropCommand.php
  22. 135 0
      src/Command/DatabaseSyncCommand.php
  23. 124 0
      src/Command/Migration/MigrationDiffCommand.php
  24. 70 0
      src/Command/Migration/MigrationMigrateCommand.php
  25. 63 0
      src/Command/QueryExecuteCommand.php
  26. 89 0
      src/Command/ShowTablesCommand.php
  27. 42 0
      src/Debugger/PDOStatementLogger.php
  28. 65 0
      src/Debugger/SqlDebugger.php
  29. 15 0
      src/Driver/DriverInterface.php
  30. 42 0
      src/Driver/DriverManager.php
  31. 78 0
      src/Driver/MariaDBDriver.php
  32. 57 0
      src/Driver/SqliteDriver.php
  33. 8 0
      src/Entity/EntityInterface.php
  34. 7 0
      src/Entity/SystemEntityInterface.php
  35. 10 0
      src/Entity/TableMetadataInterface.php
  36. 161 0
      src/EntityManager.php
  37. 23 0
      src/EntityManagerInterface.php
  38. 9 0
      src/Event/Create/PostCreateEvent.php
  39. 10 0
      src/Event/Create/PreCreateEvent.php
  40. 10 0
      src/Event/Delete/PostDeleteEvent.php
  41. 10 0
      src/Event/Delete/PreDeleteEvent.php
  42. 41 0
      src/Event/PaperEvent.php
  43. 10 0
      src/Event/Update/PostUpdateEvent.php
  44. 10 0
      src/Event/Update/PreUpdateEvent.php
  45. 24 0
      src/EventListener/PostCreateEventListener.php
  46. 24 0
      src/EventListener/PostUpdateEventListener.php
  47. 45 0
      src/EventListener/PreCreateEventListener.php
  48. 23 0
      src/EventListener/PreUpdateEventListener.php
  49. 175 0
      src/Expression/Expr.php
  50. 174 0
      src/Generator/SchemaDiffGenerator.php
  51. 95 0
      src/Hydrator/AbstractEntityHydrator.php
  52. 79 0
      src/Hydrator/ArrayHydrator.php
  53. 45 0
      src/Hydrator/EntityHydrator.php
  54. 25 0
      src/Hydrator/ReadOnlyEntityHydrator.php
  55. 104 0
      src/Internal/Entity/PaperKeyValue.php
  56. 49 0
      src/Manager/PaperKeyValueManager.php
  57. 58 0
      src/Manager/PaperSequenceManager.php
  58. 197 0
      src/Mapper/ColumnMapper.php
  59. 88 0
      src/Mapper/EntityMapper.php
  60. 19 0
      src/Mapping/Column/AnyColumn.php
  61. 60 0
      src/Mapping/Column/AutoIncrementColumn.php
  62. 19 0
      src/Mapping/Column/BinaryColumn.php
  63. 19 0
      src/Mapping/Column/BoolColumn.php
  64. 150 0
      src/Mapping/Column/Column.php
  65. 18 0
      src/Mapping/Column/DateColumn.php
  66. 19 0
      src/Mapping/Column/DateTimeColumn.php
  67. 33 0
      src/Mapping/Column/DecimalColumn.php
  68. 20 0
      src/Mapping/Column/FloatColumn.php
  69. 19 0
      src/Mapping/Column/IntColumn.php
  70. 79 0
      src/Mapping/Column/JoinColumn.php
  71. 18 0
      src/Mapping/Column/JsonColumn.php
  72. 14 0
      src/Mapping/Column/PrimaryKeyColumn.php
  73. 45 0
      src/Mapping/Column/SlugColumn.php
  74. 22 0
      src/Mapping/Column/StringColumn.php
  75. 19 0
      src/Mapping/Column/TextColumn.php
  76. 40 0
      src/Mapping/Column/TimestampColumn.php
  77. 34 0
      src/Mapping/Column/TokenColumn.php
  78. 18 0
      src/Mapping/Column/UuidColumn.php
  79. 27 0
      src/Mapping/Entity.php
  80. 51 0
      src/Mapping/Index.php
  81. 70 0
      src/Mapping/OneToMany.php
  82. 173 0
      src/Metadata/ColumnMetadata.php
  83. 219 0
      src/Metadata/DatabaseSchemaDiffMetadata.php
  84. 82 0
      src/Metadata/ForeignKeyMetadata.php
  85. 61 0
      src/Metadata/IndexMetadata.php
  86. 123 0
      src/Michel/Package/MichelPaperORMPackage.php
  87. 54 0
      src/Migration/MigrationDirectory.php
  88. 310 0
      src/Migration/PaperMigration.php
  89. 98 0
      src/PaperConfiguration.php
  90. 122 0
      src/PaperConnection.php
  91. 73 0
      src/Parser/DSNParser.php
  92. 41 0
      src/Parser/SQLTypeParser.php
  93. 30 0
      src/Pdo/PaperPDO.php
  94. 197 0
      src/Persistence/EntityPersistence.php
  95. 256 0
      src/Platform/AbstractPlatform.php
  96. 369 0
      src/Platform/MariaDBPlatform.php
  97. 118 0
      src/Platform/PlatformInterface.php
  98. 367 0
      src/Platform/SqlitePlatform.php
  99. 51 0
      src/Proxy/ProxyFactory.php
  100. 106 0
      src/Proxy/ProxyInitializedTrait.php

+ 5 - 0
.env.test.dist

@@ -0,0 +1,5 @@
+MARIADB_HOST=
+MARIADB_POST=
+MARIADB_DB=
+MARIADB_USER=
+MARIADB_PASSWORD=

+ 4 - 0
.gitignore

@@ -0,0 +1,4 @@
+/vendor/
+/.idea/
+composer.lock
+.env.test

+ 373 - 0
LICENSE

@@ -0,0 +1,373 @@
+Mozilla Public License Version 2.0
+==================================
+
+1. Definitions
+--------------
+
+1.1. "Contributor"
+    means each individual or legal entity that creates, contributes to
+    the creation of, or owns Covered Software.
+
+1.2. "Contributor Version"
+    means the combination of the Contributions of others (if any) used
+    by a Contributor and that particular Contributor's Contribution.
+
+1.3. "Contribution"
+    means Covered Software of a particular Contributor.
+
+1.4. "Covered Software"
+    means Source Code Form to which the initial Contributor has attached
+    the notice in Exhibit A, the Executable Form of such Source Code
+    Form, and Modifications of such Source Code Form, in each case
+    including portions thereof.
+
+1.5. "Incompatible With Secondary Licenses"
+    means
+
+    (a) that the initial Contributor has attached the notice described
+        in Exhibit B to the Covered Software; or
+
+    (b) that the Covered Software was made available under the terms of
+        version 1.1 or earlier of the License, but not also under the
+        terms of a Secondary License.
+
+1.6. "Executable Form"
+    means any form of the work other than Source Code Form.
+
+1.7. "Larger Work"
+    means a work that combines Covered Software with other material, in
+    a separate file or files, that is not Covered Software.
+
+1.8. "License"
+    means this document.
+
+1.9. "Licensable"
+    means having the right to grant, to the maximum extent possible,
+    whether at the time of the initial grant or subsequently, any and
+    all of the rights conveyed by this License.
+
+1.10. "Modifications"
+    means any of the following:
+
+    (a) any file in Source Code Form that results from an addition to,
+        deletion from, or modification of the contents of Covered
+        Software; or
+
+    (b) any new file in Source Code Form that contains any Covered
+        Software.
+
+1.11. "Patent Claims" of a Contributor
+    means any patent claim(s), including without limitation, method,
+    process, and apparatus claims, in any patent Licensable by such
+    Contributor that would be infringed, but for the grant of the
+    License, by the making, using, selling, offering for sale, having
+    made, import, or transfer of either its Contributions or its
+    Contributor Version.
+
+1.12. "Secondary License"
+    means either the GNU General Public License, Version 2.0, the GNU
+    Lesser General Public License, Version 2.1, the GNU Affero General
+    Public License, Version 3.0, or any later versions of those
+    licenses.
+
+1.13. "Source Code Form"
+    means the form of the work preferred for making modifications.
+
+1.14. "You" (or "Your")
+    means an individual or a legal entity exercising rights under this
+    License. For legal entities, "You" includes any entity that
+    controls, is controlled by, or is under common control with You. For
+    purposes of this definition, "control" means (a) the power, direct
+    or indirect, to cause the direction or management of such entity,
+    whether by contract or otherwise, or (b) ownership of more than
+    fifty percent (50%) of the outstanding shares or beneficial
+    ownership of such entity.
+
+2. License Grants and Conditions
+--------------------------------
+
+2.1. Grants
+
+Each Contributor hereby grants You a world-wide, royalty-free,
+non-exclusive license:
+
+(a) under intellectual property rights (other than patent or trademark)
+    Licensable by such Contributor to use, reproduce, make available,
+    modify, display, perform, distribute, and otherwise exploit its
+    Contributions, either on an unmodified basis, with Modifications, or
+    as part of a Larger Work; and
+
+(b) under Patent Claims of such Contributor to make, use, sell, offer
+    for sale, have made, import, and otherwise transfer either its
+    Contributions or its Contributor Version.
+
+2.2. Effective Date
+
+The licenses granted in Section 2.1 with respect to any Contribution
+become effective for each Contribution on the date the Contributor first
+distributes such Contribution.
+
+2.3. Limitations on Grant Scope
+
+The licenses granted in this Section 2 are the only rights granted under
+this License. No additional rights or licenses will be implied from the
+distribution or licensing of Covered Software under this License.
+Notwithstanding Section 2.1(b) above, no patent license is granted by a
+Contributor:
+
+(a) for any code that a Contributor has removed from Covered Software;
+    or
+
+(b) for infringements caused by: (i) Your and any other third party's
+    modifications of Covered Software, or (ii) the combination of its
+    Contributions with other software (except as part of its Contributor
+    Version); or
+
+(c) under Patent Claims infringed by Covered Software in the absence of
+    its Contributions.
+
+This License does not grant any rights in the trademarks, service marks,
+or logos of any Contributor (except as may be necessary to comply with
+the notice requirements in Section 3.4).
+
+2.4. Subsequent Licenses
+
+No Contributor makes additional grants as a result of Your choice to
+distribute the Covered Software under a subsequent version of this
+License (see Section 10.2) or under the terms of a Secondary License (if
+permitted under the terms of Section 3.3).
+
+2.5. Representation
+
+Each Contributor represents that the Contributor believes its
+Contributions are its original creation(s) or it has sufficient rights
+to grant the rights to its Contributions conveyed by this License.
+
+2.6. Fair Use
+
+This License is not intended to limit any rights You have under
+applicable copyright doctrines of fair use, fair dealing, or other
+equivalents.
+
+2.7. Conditions
+
+Sections 3.1, 3.2, 3.3, and 3.4 are conditions of the licenses granted
+in Section 2.1.
+
+3. Responsibilities
+-------------------
+
+3.1. Distribution of Source Form
+
+All distribution of Covered Software in Source Code Form, including any
+Modifications that You create or to which You contribute, must be under
+the terms of this License. You must inform recipients that the Source
+Code Form of the Covered Software is governed by the terms of this
+License, and how they can obtain a copy of this License. You may not
+attempt to alter or restrict the recipients' rights in the Source Code
+Form.
+
+3.2. Distribution of Executable Form
+
+If You distribute Covered Software in Executable Form then:
+
+(a) such Covered Software must also be made available in Source Code
+    Form, as described in Section 3.1, and You must inform recipients of
+    the Executable Form how they can obtain a copy of such Source Code
+    Form by reasonable means in a timely manner, at a charge no more
+    than the cost of distribution to the recipient; and
+
+(b) You may distribute such Executable Form under the terms of this
+    License, or sublicense it under different terms, provided that the
+    license for the Executable Form does not attempt to limit or alter
+    the recipients' rights in the Source Code Form under this License.
+
+3.3. Distribution of a Larger Work
+
+You may create and distribute a Larger Work under terms of Your choice,
+provided that You also comply with the requirements of this License for
+the Covered Software. If the Larger Work is a combination of Covered
+Software with a work governed by one or more Secondary Licenses, and the
+Covered Software is not Incompatible With Secondary Licenses, this
+License permits You to additionally distribute such Covered Software
+under the terms of such Secondary License(s), so that the recipient of
+the Larger Work may, at their option, further distribute the Covered
+Software under the terms of either this License or such Secondary
+License(s).
+
+3.4. Notices
+
+You may not remove or alter the substance of any license notices
+(including copyright notices, patent notices, disclaimers of warranty,
+or limitations of liability) contained within the Source Code Form of
+the Covered Software, except that You may alter any license notices to
+the extent required to remedy known factual inaccuracies.
+
+3.5. Application of Additional Terms
+
+You may choose to offer, and to charge a fee for, warranty, support,
+indemnity or liability obligations to one or more recipients of Covered
+Software. However, You may do so only on Your own behalf, and not on
+behalf of any Contributor. You must make it absolutely clear that any
+such warranty, support, indemnity, or liability obligation is offered by
+You alone, and You hereby agree to indemnify every Contributor for any
+liability incurred by such Contributor as a result of warranty, support,
+indemnity or liability terms You offer. You may include additional
+disclaimers of warranty and limitations of liability specific to any
+jurisdiction.
+
+4. Inability to Comply Due to Statute or Regulation
+---------------------------------------------------
+
+If it is impossible for You to comply with any of the terms of this
+License with respect to some or all of the Covered Software due to
+statute, judicial order, or regulation then You must: (a) comply with
+the terms of this License to the maximum extent possible; and (b)
+describe the limitations and the code they affect. Such description must
+be placed in a text file included with all distributions of the Covered
+Software under this License. Except to the extent prohibited by statute
+or regulation, such description must be sufficiently detailed for a
+recipient of ordinary skill to be able to understand it.
+
+5. Termination
+--------------
+
+5.1. The rights granted under this License will terminate automatically
+if You fail to comply with any of its terms. However, if You become
+compliant, then the rights granted under this License from a particular
+Contributor are reinstated (a) provisionally, unless and until such
+Contributor explicitly and finally terminates Your grants, and (b) on an
+ongoing basis, if such Contributor fails to notify You of the
+non-compliance by some reasonable means prior to 60 days after You have
+come back into compliance. Moreover, Your grants from a particular
+Contributor are reinstated on an ongoing basis if such Contributor
+notifies You of the non-compliance by some reasonable means, this is the
+first time You have received notice of non-compliance with this License
+from such Contributor, and You become compliant prior to 30 days after
+Your receipt of the notice.
+
+5.2. If You initiate litigation against any entity by asserting a patent
+infringement claim (excluding declaratory judgment actions,
+counter-claims, and cross-claims) alleging that a Contributor Version
+directly or indirectly infringes any patent, then the rights granted to
+You by any and all Contributors for the Covered Software under Section
+2.1 of this License shall terminate.
+
+5.3. In the event of termination under Sections 5.1 or 5.2 above, all
+end user license agreements (excluding distributors and resellers) which
+have been validly granted by You or Your distributors under this License
+prior to termination shall survive termination.
+
+************************************************************************
+*                                                                      *
+*  6. Disclaimer of Warranty                                           *
+*  -------------------------                                           *
+*                                                                      *
+*  Covered Software is provided under this License on an "as is"       *
+*  basis, without warranty of any kind, either expressed, implied, or  *
+*  statutory, including, without limitation, warranties that the       *
+*  Covered Software is free of defects, merchantable, fit for a        *
+*  particular purpose or non-infringing. The entire risk as to the     *
+*  quality and performance of the Covered Software is with You.        *
+*  Should any Covered Software prove defective in any respect, You     *
+*  (not any Contributor) assume the cost of any necessary servicing,   *
+*  repair, or correction. This disclaimer of warranty constitutes an   *
+*  essential part of this License. No use of any Covered Software is   *
+*  authorized under this License except under this disclaimer.         *
+*                                                                      *
+************************************************************************
+
+************************************************************************
+*                                                                      *
+*  7. Limitation of Liability                                          *
+*  --------------------------                                          *
+*                                                                      *
+*  Under no circumstances and under no legal theory, whether tort      *
+*  (including negligence), contract, or otherwise, shall any           *
+*  Contributor, or anyone who distributes Covered Software as          *
+*  permitted above, be liable to You for any direct, indirect,         *
+*  special, incidental, or consequential damages of any character      *
+*  including, without limitation, damages for lost profits, loss of    *
+*  goodwill, work stoppage, computer failure or malfunction, or any    *
+*  and all other commercial damages or losses, even if such party      *
+*  shall have been informed of the possibility of such damages. This   *
+*  limitation of liability shall not apply to liability for death or   *
+*  personal injury resulting from such party's negligence to the       *
+*  extent applicable law prohibits such limitation. Some               *
+*  jurisdictions do not allow the exclusion or limitation of           *
+*  incidental or consequential damages, so this exclusion and          *
+*  limitation may not apply to You.                                    *
+*                                                                      *
+************************************************************************
+
+8. Litigation
+-------------
+
+Any litigation relating to this License may be brought only in the
+courts of a jurisdiction where the defendant maintains its principal
+place of business and such litigation shall be governed by laws of that
+jurisdiction, without reference to its conflict-of-law provisions.
+Nothing in this Section shall prevent a party's ability to bring
+cross-claims or counter-claims.
+
+9. Miscellaneous
+----------------
+
+This License represents the complete agreement concerning the subject
+matter hereof. If any provision of this License is held to be
+unenforceable, such provision shall be reformed only to the extent
+necessary to make it enforceable. Any law or regulation which provides
+that the language of a contract shall be construed against the drafter
+shall not be used to construe this License against a Contributor.
+
+10. Versions of the License
+---------------------------
+
+10.1. New Versions
+
+Mozilla Foundation is the license steward. Except as provided in Section
+10.3, no one other than the license steward has the right to modify or
+publish new versions of this License. Each version will be given a
+distinguishing version number.
+
+10.2. Effect of New Versions
+
+You may distribute the Covered Software under the terms of the version
+of the License under which You originally received the Covered Software,
+or under the terms of any subsequent version published by the license
+steward.
+
+10.3. Modified Versions
+
+If you create software not governed by this License, and you want to
+create a new license for such software, you may create and use a
+modified version of this License if you rename the license and remove
+any references to the name of the license steward (except to note that
+such modified license differs from this License).
+
+10.4. Distributing Source Code Form that is Incompatible With Secondary
+Licenses
+
+If You choose to distribute Source Code Form that is Incompatible With
+Secondary Licenses under the terms of this version of the License, the
+notice described in Exhibit B of this License must be attached.
+
+Exhibit A - Source Code Form License Notice
+-------------------------------------------
+
+  This Source Code Form is subject to the terms of the Mozilla Public
+  License, v. 2.0. If a copy of the MPL was not distributed with this
+  file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+If it is not possible or desirable to put the notice in a particular
+file, then You may include the notice in a location (such as a LICENSE
+file in a relevant directory) where a recipient would be likely to look
+for such a notice.
+
+You may add additional accurate notices of copyright ownership.
+
+Exhibit B - "Incompatible With Secondary Licenses" Notice
+---------------------------------------------------------
+
+  This Source Code Form is "Incompatible With Secondary Licenses", as
+  defined by the Mozilla Public License, v. 2.0.

+ 479 - 0
README.md

@@ -0,0 +1,479 @@
+# 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 5MB 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 michel/paper-orm
+```  
+
+### 🔧 Minimal Configuration
+Create a simple configuration file to connect PaperORM to your database:
+
+```php
+<?php
+require_once 'vendor/autoload.php';
+
+use Michel\PaperORM\EntityManager;
+use Michel\PaperORM\PaperConfiguration;
+
+// --- Basic SQLite configuration ---
+$configuration = PaperConfiguration::fromArray([
+    'driver' => 'sqlite',
+    'user' => null,
+    'password' => null,
+    'memory' => true
+], false); // Set to true to enable debug mode (logs queries and ORM operations)
+
+// Basic configuration MySQL/Mariadb
+$configuration = PaperConfiguration::fromArray([
+            'driver' => 'mariadb',
+            'host' => '127.0.0.1',
+            'port' => 3306,
+            'dbname' => 'paper_orm_test',
+            'user' => 'root',
+            'password' => 'root',
+            'charset' => 'utf8mb4',
+], false);  // Set to true to enable debug mode (logs queries and ORM operations)
+
+// --- Optional event listener registration ---
+// Called automatically before any entity creation
+$configuration->withListener(PreCreateEvent::class, new App\Listener\PreCreateListener());
+
+// --- Optional SQL logger ---
+// Use any PSR-3 compatible logger (e.g. Monolog) to log all executed queries
+$configuration->withLogger(new Monolog());
+
+$em = EntityManager::createFromConfig($configuration);
+```
+
+✅ **PaperORM is now ready to use!**
+
+*Note: PDO and corresponding database extensions must be enabled (pdo_mysql, pdo_sqlite, etc.).*
+
+## Basic Usage
+
+> **Note**: The `repository` attribute/method is optional. If none is defined, a dummy repository will be automatically generated.
+
+### Defining an Entity
+
+#### PHP 7.4 < 8
+
+```php
+use Michel\PaperORM\Entity\EntityInterface;
+use Michel\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())->bindProperty('id'),
+            (new StringColumn())->bindProperty('name'),
+            (new StringColumn())->bindProperty('email'),
+            (new BoolColumn('is_active'))->bindProperty('isActive'), // 'is_active' is the column name
+            (new DateTimeColumn('created_at'))->bindProperty('createdAt') // 'created_at' is the column name
+        ];
+    }
+    
+    // Getters/Setters...
+}
+```
+
+#### PHP 8+ with attributes
+
+```php
+use Michel\PaperORM\Entity\EntityInterface;
+use Michel\PaperORM\Mapping\{PrimaryKeyColumn, StringColumn, BoolColumn, DateTimeColumn, OneToMany, JoinColumn};
+
+#[Entity(table : 'user', repository : null)]
+class User implements EntityInterface
+{
+    #[PrimaryKeyColumn]
+    private ?int $id = null;
+    #[StringColumn]
+    private string $name;
+    #[StringColumn]
+    private string $email;
+    #[BoolColumn(name: 'is_active')]
+    private bool $isActive = true;
+    #[DateTimeColumn(name: 'created_at')]
+    private \DateTime $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 
+{
+    // IF PHP >= 8
+    #[\Michel\PaperORM\Mapping\OneToMany(Comment::class, 'article')] 
+    private \Michel\PaperORM\Collection\ObjectStorage $comments;
+    
+    public function __construct() {
+        $this->comments = new ObjectStorage();
+    }
+    
+    // ...
+    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)
+    ->toObject();
+```
+
+> 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/Michel/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 michel/paper-orm
+```  
+
+### 🔧 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 Michel\PaperORM\EntityManager;
+use Michel\PaperORM\PaperConfiguration;
+
+// --- Basic SQLite configuration ---
+$configuration = PaperConfiguration::fromArray([
+    'driver' => 'sqlite',
+    'user' => null,
+    'password' => null,
+    'memory' => true
+], false); // Mettre à true pour activer le mode debug (journalisation des requêtes et opérations ORM)
+
+// Basic configuration MySQL/Mariadb
+$configuration = PaperConfiguration::fromArray([
+            'driver' => 'mariadb',
+            'host' => '127.0.0.1',
+            'port' => 3306,
+            'dbname' => 'paper_orm_test',
+            'user' => 'root',
+            'password' => 'root',
+            'charset' => 'utf8mb4',
+], false);  // Set to true to enable debug mode (logs queries and ORM operations)
+
+// --- Enregistrement optionnel d’un écouteur d’événement ---
+// Appelé automatiquement avant chaque création d’entité
+$configuration->withListener(PreCreateEvent::class, new App\Listener\PreCreateListener());
+
+// --- Journalisation SQL optionnelle ---
+// Permet de journaliser toutes les requêtes exécutées via un logger compatible PSR-3 (ex. Monolog
+$configuration->withLogger(new Monolog());
+
+$em = EntityManager::createFromConfig($configuration);
+```
+
+✅ **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
+
+> **Note**: L’attribut ou la méthode `repository` est facultatif. Si aucun n’est défini, un repository fictif sera généré automatiquement.
+
+### Définition d'une entité
+
+#### PHP 7.4 < 8
+
+```php
+use Michel\PaperORM\Entity\EntityInterface;
+use Michel\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...
+}
+```
+
+#### PHP 8+ avec attributs
+
+```php
+use Michel\PaperORM\Entity\EntityInterface;
+use Michel\PaperORM\Mapping\{PrimaryKeyColumn, StringColumn, BoolColumn, DateTimeColumn, OneToMany, JoinColumn};
+
+#[Entity(table : 'user', repository : null)]
+class User implements EntityInterface
+{
+    #[PrimaryKeyColumn]
+    private ?int $id = null;
+    #[StringColumn]
+    private string $name;
+    #[StringColumn]
+    private string $email;
+    #[BoolColumn(name: 'is_active')]
+    private bool $isActive = true;
+    #[DateTimeColumn(name: 'created_at')]
+    private \DateTime $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 
+{
+    // IF PHP >= 8
+    #[\Michel\PaperORM\Mapping\OneToMany(Comment::class, 'article')] 
+    private \Michel\PaperORM\Collection\ObjectStorage $comments;
+    
+    public function __construct() {
+        $this->comments = new ObjectStorage();
+    }
+    
+    // ...
+    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/Michel/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 !*

+ 41 - 0
composer.json

@@ -0,0 +1,41 @@
+{
+  "name": "michel/paper-orm",
+  "description": "PaperORM is a lightweight Object-Relational Mapping (ORM) library ",
+  "type": "library",
+  "license": "MPL-2.0",
+  "authors": [
+    {
+      "name": "Michel.F"
+    }
+  ],
+  "autoload": {
+    "psr-4": {
+      "Michel\\PaperORM\\": "src",
+      "Test\\Michel\\PaperORM\\": "tests"
+    },
+    "files": [
+      "functions/helpers.php"
+    ]
+  },
+  "require": {
+    "php": ">=7.4",
+    "ext-pdo": "*",
+    "ext-json": "*",
+    "ext-ctype": "*",
+    "ext-iconv": "*",
+    "michel/php-sql-mapper": "^1.0",
+    "michel/console": "^1.0",
+    "michel/michel-package-starter": "^1.0",
+    "michel/filesystem": "^1.0",
+    "michel/psr14-event-dispatcher": "^1.0",
+    "psr/log": "^1.1|^2.0|^3.0"
+  },
+  "require-dev": {
+    "michel/unitester": "^1.0.0"
+  },
+  "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;
+    }
+}

+ 81 - 0
src/Assigner/AutoIncrementAssigner.php

@@ -0,0 +1,81 @@
+<?php
+
+namespace Michel\PaperORM\Assigner;
+
+use Michel\PaperORM\Manager\PaperSequenceManager;
+use Michel\PaperORM\Mapping\Column\AutoIncrementColumn;
+use Michel\PaperORM\Mapping\Column\Column;
+use Michel\PaperORM\Tools\IDBuilder;
+use Michel\PaperORM\Tools\EntityAccessor;
+
+final class AutoIncrementAssigner implements ValueAssignerInterface, CommitAssignerInterface
+{
+    private PaperSequenceManager $sequenceManager;
+
+    public function __construct(PaperSequenceManager $sequenceManager)
+    {
+        $this->sequenceManager = $sequenceManager;
+    }
+
+    public function assign(object $entity, Column $column): void
+    {
+        if (!$column instanceof AutoIncrementColumn) {
+            throw new \InvalidArgumentException(sprintf(
+                'AutoIncrementAssigner::assign(): expected instance of %s, got %s.',
+                AutoIncrementColumn::class,
+                get_class($column)
+            ));
+        }
+
+        $result = self::determineSequenceIdentifiers($column);
+        $prefix = $result['sequence'];
+        $counter = $this->sequenceManager->peek($result['key']);
+        $formatted = sprintf(
+            '%s%s',
+            $prefix,
+            str_pad((string)$counter, $column->getPad(), '0', STR_PAD_LEFT)
+        );
+        $property = $column->getProperty();
+        EntityAccessor::setValue($entity, $property, $formatted);
+    }
+    public function commit(Column $column): void
+    {
+        if (!$column instanceof AutoIncrementColumn) {
+            throw new \InvalidArgumentException(sprintf(
+                'AutoIncrementAssigner::commit(): expected instance of %s, got %s.',
+                AutoIncrementColumn::class,
+                get_class($column)
+            ));
+        }
+
+        $this->sequenceManager->increment(self::determineSequenceIdentifiers($column)['key']);
+    }
+
+    /**
+     * @param AutoIncrementColumn $column
+     * @return array{sequence: string, key: string}
+     * @throws \RandomException
+     */
+    private static function determineSequenceIdentifiers(AutoIncrementColumn $column): array
+    {
+        $key = $column->getKey();
+
+        if (empty($key)) {
+            throw new \LogicException(sprintf(
+                'AutoIncrementColumn "%s": a non-empty key (sequence or table.sequence) must be defined.',
+                $column->getProperty()
+            ));
+        }
+
+        $prefix = $column->getPrefix();
+        $sequenceName = !empty($prefix) ? IDBuilder::generate($prefix) : '';
+        if (!empty($sequenceName)) {
+            $key = sprintf('%s.%s', $key, $sequenceName);
+        }
+        return [
+            'sequence' => $sequenceName,
+            'key' => $key,
+        ];
+    }
+
+}

+ 10 - 0
src/Assigner/CommitAssignerInterface.php

@@ -0,0 +1,10 @@
+<?php
+
+namespace Michel\PaperORM\Assigner;
+
+use Michel\PaperORM\Mapping\Column\Column;
+
+interface CommitAssignerInterface
+{
+    public function commit(Column $column): void;
+}

+ 47 - 0
src/Assigner/SlugAssigner.php

@@ -0,0 +1,47 @@
+<?php
+
+namespace Michel\PaperORM\Assigner;
+
+use InvalidArgumentException;
+use LogicException;
+use Michel\PaperORM\Collection\ObjectStorage;
+use Michel\PaperORM\Mapper\ColumnMapper;
+use Michel\PaperORM\Mapping\Column\Column;
+use Michel\PaperORM\Mapping\Column\SlugColumn;
+use Michel\PaperORM\Tools\EntityAccessor;
+use Michel\PaperORM\Tools\Slugger;
+
+final class SlugAssigner implements ValueAssignerInterface
+{
+    public function assign(object $entity, Column $column): void
+    {
+        if (!$column instanceof SlugColumn) {
+            throw new InvalidArgumentException(sprintf(
+                'SlugAssigner::assign(): expected instance of %s, got %s.',
+                SlugColumn::class,
+                get_class($column)
+            ));
+        }
+        if (EntityAccessor::getValue($entity, $column->getProperty()) !== null) {
+            return;
+        }
+
+        $storage = new ObjectStorage(ColumnMapper::getColumns($entity));
+        $from = $column->getFrom();
+        $separator = $column->getSeparator();
+        $values = [];
+        foreach ($from as $field) {
+            $col = $storage->findOneByMethod('getProperty', $field);
+            if (!$col instanceof Column) {
+                throw new LogicException(sprintf(
+                    'Cannot set slug: expected column "%s" in entity "%s".',
+                    $field,
+                    get_class($entity)
+                ));
+            }
+            $values[$field] = EntityAccessor::getValue($entity, $field);
+        }
+        EntityAccessor::setValue($entity, $column->getProperty(), Slugger::slugify($values, $separator));
+    }
+
+}

+ 26 - 0
src/Assigner/TimestampAssigner.php

@@ -0,0 +1,26 @@
+<?php
+
+namespace Michel\PaperORM\Assigner;
+
+use DateTimeImmutable;
+use InvalidArgumentException;
+use Michel\PaperORM\Mapping\Column\Column;
+use Michel\PaperORM\Mapping\Column\TimestampColumn;
+use Michel\PaperORM\Tools\EntityAccessor;
+
+final class TimestampAssigner implements ValueAssignerInterface
+{
+    public function assign(object $entity, Column $column): void
+    {
+        if (!$column instanceof TimestampColumn) {
+            throw new InvalidArgumentException(sprintf(
+                'TimestampAssigner::assign(): expected instance of %s, got %s.',
+                TimestampColumn::class,
+                get_class($column)
+            ));
+        }
+
+        $property = $column->getProperty();
+        EntityAccessor::setValue($entity, $property, new DateTimeImmutable('now'));
+    }
+}

+ 27 - 0
src/Assigner/TokenAssigner.php

@@ -0,0 +1,27 @@
+<?php
+
+namespace Michel\PaperORM\Assigner;
+
+use Michel\PaperORM\Mapping\Column\Column;
+use Michel\PaperORM\Mapping\Column\SlugColumn;
+use Michel\PaperORM\Mapping\Column\TokenColumn;
+use Michel\PaperORM\Mapping\Column\UuidColumn;
+use Michel\PaperORM\Tools\EntityAccessor;
+use Michel\PaperORM\Tools\IDBuilder;
+
+final class TokenAssigner implements ValueAssignerInterface
+{
+    public function assign(object $entity, Column $column): void
+    {
+        if (!$column instanceof TokenColumn) {
+            throw new \InvalidArgumentException(sprintf(
+                'TokenAssigner::assign(): expected instance of %s, got %s.',
+                TokenColumn::class,
+                get_class($column)
+            ));
+        }
+
+        $property = $column->getProperty();
+        EntityAccessor::setValue($entity, $property, IDBuilder::generate(sprintf("{TOKEN%s}", $column->getLength())));
+    }
+}

+ 26 - 0
src/Assigner/UuidAssigner.php

@@ -0,0 +1,26 @@
+<?php
+
+namespace Michel\PaperORM\Assigner;
+
+use Michel\PaperORM\Mapping\Column\Column;
+use Michel\PaperORM\Mapping\Column\SlugColumn;
+use Michel\PaperORM\Mapping\Column\UuidColumn;
+use Michel\PaperORM\Tools\EntityAccessor;
+use Michel\PaperORM\Tools\IDBuilder;
+
+final class UuidAssigner implements ValueAssignerInterface
+{
+    public function assign(object $entity, Column $column): void
+    {
+        if (!$column instanceof UuidColumn) {
+            throw new \InvalidArgumentException(sprintf(
+                'UuidAssigner::assign(): expected instance of %s, got %s.',
+                UuidColumn::class,
+                get_class($column)
+            ));
+        }
+
+        $property = $column->getProperty();
+        EntityAccessor::setValue($entity, $property, IDBuilder::generate('{UUID}'));
+    }
+}

+ 10 - 0
src/Assigner/ValueAssignerInterface.php

@@ -0,0 +1,10 @@
+<?php
+
+namespace Michel\PaperORM\Assigner;
+
+use Michel\PaperORM\Mapping\Column\Column;
+
+interface ValueAssignerInterface
+{
+    public function assign(object $entity, Column $column): void;
+}

+ 39 - 0
src/Cache/ColumnCache.php

@@ -0,0 +1,39 @@
+<?php
+
+namespace Michel\PaperORM\Cache;
+
+
+use Michel\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 [];
+    }
+}

+ 46 - 0
src/Cache/EntityMemcachedCache.php

@@ -0,0 +1,46 @@
+<?php
+
+namespace Michel\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 Michel\PaperORM\Cache;
+
+use InvalidArgumentException;
+use Michel\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 Michel\PaperORM\Cache;
+
+
+use Michel\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 Michel\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 findOneByMethod(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;
+    }
+}

+ 73 - 0
src/Collector/EntityDirCollector.php

@@ -0,0 +1,73 @@
+<?php
+
+namespace Michel\PaperORM\Collector;
+
+final class EntityDirCollector
+{
+    
+    /** @var string[] */
+    private array $dirs = [];
+
+    public static function bootstrap(array $dirs = []): EntityDirCollector
+    {
+        // Core entity directories of the ORM (always loaded first)
+        $coreDirs = [
+            dirname(__DIR__) . '/Internal/Entity',
+        ];
+        $dirs = array_merge($coreDirs, $dirs);
+        return new EntityDirCollector($dirs);
+    }
+
+    /**
+     * @param string|string[] $dirs
+     */
+    private function __construct(array $dirs = [])
+    {
+        foreach ($dirs as $index => $dir) {
+            if (!is_string($dir)) {
+                $given = gettype($dir);
+                throw new \InvalidArgumentException(sprintf(
+                    'EntityDirCollector::__construct(): each directory must be a string, %s given at index %d.',
+                    $given,
+                    $index
+                ));
+            }
+
+            if (empty($dir)) {
+                throw new \InvalidArgumentException(sprintf(
+                    'EntityDirCollector::__construct(): directory at index %d is an empty string.',
+                    $index
+                ));
+            }
+
+            $this->add($dir);
+        }
+    }
+
+    public function add(string $dir): void
+    {
+        if (!is_dir($dir)) {
+            throw new \InvalidArgumentException(sprintf(
+                'EntityDirCollector::add(): directory "%s" does not exist.',
+                $dir
+            ));
+        }
+        $dir = rtrim($dir, '/');
+        if (!in_array($dir, $this->dirs, true)) {
+            $this->dirs[] = $dir;
+        }
+    }
+
+    /**
+     * @return string[]
+     */
+    public function all(): array
+    {
+        return $this->dirs;
+    }
+
+    public function count(): int
+    {
+        return count($this->dirs);
+    }
+}

+ 56 - 0
src/Command/DatabaseCreateCommand.php

@@ -0,0 +1,56 @@
+<?php
+
+namespace Michel\PaperORM\Command;
+
+use Michel\Console\Command\CommandInterface;
+use Michel\Console\InputInterface;
+use Michel\Console\Option\CommandOption;
+use Michel\Console\Output\ConsoleOutput;
+use Michel\Console\OutputInterface;
+use Michel\PaperORM\EntityManager;
+use Michel\PaperORM\EntityManagerInterface;
+
+class DatabaseCreateCommand implements CommandInterface
+{
+    private EntityManagerInterface $entityManager;
+
+    public function __construct(EntityManagerInterface $entityManager)
+    {
+        $this->entityManager = $entityManager;
+    }
+
+    public function getName(): string
+    {
+        return 'paper:database:create';
+    }
+
+    public function getDescription(): string
+    {
+        return 'Creates the database configured for PaperORM';
+    }
+
+    public function getOptions(): array
+    {
+        return [
+            new CommandOption('if-not-exists', null, 'Only create the database 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->getPlatform();
+        if ($input->hasOption('if-not-exists') && $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()));
+        }
+    }
+}

+ 68 - 0
src/Command/DatabaseDropCommand.php

@@ -0,0 +1,68 @@
+<?php
+
+namespace Michel\PaperORM\Command;
+
+use LogicException;
+use Michel\Console\Command\CommandInterface;
+use Michel\Console\InputInterface;
+use Michel\Console\Option\CommandOption;
+use Michel\Console\Output\ConsoleOutput;
+use Michel\Console\OutputInterface;
+use Michel\PaperORM\EntityManager;
+use Michel\PaperORM\EntityManagerInterface;
+
+class DatabaseDropCommand implements CommandInterface
+{
+    private EntityManagerInterface $entityManager;
+
+    private ?string $env;
+
+    public function __construct(EntityManagerInterface $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->getPlatform();
+        $platform->dropDatabase();
+        $io->success('The SQL database has been successfully dropped.');
+    }
+
+    private function isEnabled(): bool
+    {
+        return 'dev' === $this->env || 'test' === $this->env;
+    }
+}

+ 135 - 0
src/Command/DatabaseSyncCommand.php

@@ -0,0 +1,135 @@
+<?php
+
+namespace Michel\PaperORM\Command;
+
+use LogicException;
+use Michel\Console\Command\CommandInterface;
+use Michel\Console\InputInterface;
+use Michel\Console\Option\CommandOption;
+use Michel\Console\Output\ConsoleOutput;
+use Michel\Console\OutputInterface;
+use Michel\PaperORM\Collector\EntityDirCollector;
+use Michel\PaperORM\Migration\PaperMigration;
+use Michel\PaperORM\Tools\EntityExplorer;
+
+class DatabaseSyncCommand implements CommandInterface
+{
+    private PaperMigration $paperMigration;
+
+    private EntityDirCollector $entityDirCollector;
+
+    private ?string $env;
+
+    /**
+     * @param PaperMigration $paperMigration
+     * @param EntityDirCollector $entityDirCollector
+     * @param string|null $env
+     */
+    public function __construct(PaperMigration $paperMigration, EntityDirCollector $entityDirCollector, ?string $env = null)
+    {
+        $this->paperMigration = $paperMigration;
+        $this->entityDirCollector = $entityDirCollector;
+        $this->env = $env;
+    }
+
+    public function getName(): string
+    {
+        return 'paper:database:sync';
+    }
+
+    public function getDescription(): string
+    {
+        return 'Update the SQL database structure so it matches the current ORM entities.';
+    }
+
+    public function getOptions(): array
+    {
+        return [
+            new CommandOption('no-execute', 'n', 'Show the generated SQL statements without executing them.', true)
+        ];
+    }
+
+    public function getArguments(): array
+    {
+        return [];
+    }
+
+    public function execute(InputInterface $input, OutputInterface $output): void
+    {
+        $io = ConsoleOutput::create($output);
+        $verbose = $input->getOptionValue('verbose');
+        if (!$this->isEnabled()) {
+            throw new LogicException('This command is only available in `dev` environment.');
+        }
+
+        if ($this->entityDirCollector->count() === 0) {
+            $suggested = getcwd() . '/src/Entity';
+
+            throw new LogicException(sprintf(
+                "No entity directories registered in %s.\n" .
+                "You must register at least one directory when building the application.\n\n" .
+                "Example:\n" .
+                "    \$collector = new EntityDirCollector(['%s']);\n" .
+                "    \$command = new %s(\$paperMigration, \$collector);",
+                static::class,
+                $suggested,
+                static::class
+            ));
+        }
+
+        $noExecute = $input->getOptionValue('no-execute');
+        $platform = $this->paperMigration->getEntityManager()->getPlatform();
+        $io->title('Starting database sync on ' . $platform->getDatabaseName());
+        $io->list([
+            'Database : ' . $platform->getDatabaseName(),
+            'Entities directories : ' . count($this->entityDirCollector->all())
+        ]);
+        if ($verbose) {
+            $io->listKeyValues($this->entityDirCollector->all());
+        }
+
+        $entities = EntityExplorer::getEntities($this->entityDirCollector->all());
+        $normalEntities = $entities['normal'];
+        $systemEntities = $entities['system'];
+        $entities = array_merge($normalEntities, $systemEntities);
+        $io->title('Detected entities');
+        $io->list([
+            'Normal entities : ' . count($normalEntities),
+            'System entities : ' . count($systemEntities),
+        ]);
+        if ($verbose) {
+            $io->listKeyValues($entities);
+        }
+
+        $updates = $this->paperMigration->getSqlDiffFromEntities($entities);
+        if (empty($updates)) {
+            $io->info('No differences detected — all entities are already in sync with the database schema.');
+            return;
+        }
+
+        $count = count($updates);
+        $io->writeln("📘 Database synchronization plan");
+        $io->writeln("{$count} SQL statements will be executed:");
+        $io->writeln("");
+        $io->numberedList($updates);
+        if ($noExecute) {
+            $io->info('Preview mode only — SQL statements were displayed but NOT executed.');
+            return;
+        }
+
+        $io->writeln("");
+        $io->writeln("🚀 Applying changes to database...");
+        $conn = $this->paperMigration->getEntityManager()->getConnection();
+        foreach ($updates as $sql) {
+            $conn->executeStatement($sql);
+            $io->writeln("✔ Executed: {$sql}");
+        }
+
+        $io->success("Database successfully synchronized.");
+    }
+
+    private function isEnabled(): bool
+    {
+        return 'dev' === $this->env || 'test' === $this->env;
+    }
+}

+ 124 - 0
src/Command/Migration/MigrationDiffCommand.php

@@ -0,0 +1,124 @@
+<?php
+
+namespace Michel\PaperORM\Command\Migration;
+
+use LogicException;
+use Michel\Console\Command\CommandInterface;
+use Michel\Console\InputInterface;
+use Michel\Console\Output\ConsoleOutput;
+use Michel\Console\OutputInterface;
+use Michel\PaperORM\Collector\EntityDirCollector;
+use Michel\PaperORM\Migration\PaperMigration;
+use Michel\PaperORM\Tools\EntityExplorer;
+use SplFileObject;
+
+class MigrationDiffCommand implements CommandInterface
+{
+    private PaperMigration $paperMigration;
+
+    private EntityDirCollector $entityDirCollector;
+
+    public function __construct(PaperMigration $paperMigration, EntityDirCollector $entityDirCollector)
+    {
+        $this->paperMigration = $paperMigration;
+        $this->entityDirCollector = $entityDirCollector;
+    }
+
+    public function getName(): string
+    {
+        return 'paper:migration:diff';
+    }
+
+    public function getDescription(): string
+    {
+        return 'Generate a migration diff for the SQL database';
+    }
+
+    public function getOptions(): array
+    {
+        return [
+        ];
+    }
+
+    public function getArguments(): array
+    {
+        return [];
+    }
+
+    public function execute(InputInterface $input, OutputInterface $output): void
+    {
+        $io = ConsoleOutput::create($output);
+        $verbose = $input->getOptionValue('verbose');
+
+        if ($this->entityDirCollector->count() === 0) {
+            $suggested = getcwd() . '/src/Entity';
+
+            throw new LogicException(sprintf(
+                "No entity directories registered in %s.\n" .
+                "You must register at least one directory when building the application.\n\n" .
+                "Example:\n" .
+                "    \$collector = new EntityDirCollector(['%s']);\n" .
+                "    \$command = new %s(\$paperMigration, \$collector);",
+                static::class,
+                $suggested,
+                static::class
+            ));
+        }
+
+        $platform = $this->paperMigration->getEntityManager()->getPlatform();
+        $io->title('Starting migration diff on ' . $platform->getDatabaseName());
+        $io->list([
+            'Database : ' . $platform->getDatabaseName(),
+            'Entities directories : ' . implode(', ', $this->entityDirCollector->all())
+        ]);
+
+        $entities = EntityExplorer::getEntities($this->entityDirCollector->all());
+        $normalEntities = $entities['normal'];
+        $systemEntities = $entities['system'];
+        $io->title('Detected entities');
+        $io->list([
+            'Normal entities : ' . count($normalEntities),
+            'System entities : ' . count($systemEntities),
+        ]);
+        if ($verbose) {
+            $io->listKeyValues(array_merge($normalEntities, $systemEntities));
+        }
+
+        $executed = false;
+        $fileApp = $this->paperMigration->generateMigrationFromEntities($normalEntities);
+        if ($fileApp === null) {
+            $io->info('No application migration file was generated — schema already in sync.');
+        } else {
+            $executed = true;
+            $io->success('✔ Application migration file generated: ' . $fileApp);
+        }
+
+        $fileSystem = $this->paperMigration->generateMigrationFromEntities($systemEntities);
+        if ($fileSystem === null) {
+            $io->info('No system migration changes detected.');
+        } else {
+            $executed = true;
+            $io->success('✔ System migration file generated: ' . $fileSystem);
+        }
+
+        if ($verbose === true) {
+            foreach ([$fileSystem, $fileApp] as $file) {
+                if ($file === null || !is_file($file)) {
+                    continue;
+                }
+
+                $io->title('Contents of: ' . basename($file));
+                $splFile = new SplFileObject($file);
+                $lines = [];
+                while (!$splFile->eof()) {
+                    $lines[] = $splFile->fgets();
+                }
+                unset($splFile);
+                $io->listKeyValues($lines);
+            }
+        }
+        if ($executed) {
+            $io->success('Migration diff process completed successfully.');
+        }
+    }
+}

+ 70 - 0
src/Command/Migration/MigrationMigrateCommand.php

@@ -0,0 +1,70 @@
+<?php
+
+namespace Michel\PaperORM\Command\Migration;
+
+use Michel\Console\Command\CommandInterface;
+use Michel\Console\InputInterface;
+use Michel\Console\Output\ConsoleOutput;
+use Michel\Console\OutputInterface;
+use Michel\PaperORM\Migration\PaperMigration;
+
+class MigrationMigrateCommand implements CommandInterface
+{
+    private PaperMigration $paperMigration;
+
+    public function __construct(PaperMigration $paperMigration)
+    {
+        $this->paperMigration = $paperMigration;
+    }
+
+    public function getName(): string
+    {
+        return 'paper:migration:migrate';
+    }
+
+    public function getDescription(): string
+    {
+        return 'Execute all migrations';
+    }
+
+    public function getOptions(): array
+    {
+        return [
+        ];
+    }
+
+    public function getArguments(): array
+    {
+        return [];
+    }
+
+    public function execute(InputInterface $input, OutputInterface $output): void
+    {
+        $io = ConsoleOutput::create($output);
+
+        $platform = $this->paperMigration->getEntityManager()->getPlatform();
+
+        $io->title('Starting migration migrate on ' . $platform->getDatabaseName());
+
+        $successList = [];
+        $error = null;
+        try {
+            $this->paperMigration->migrate();
+            $successList = $this->paperMigration->getSuccessList();
+        }catch (\Throwable $exception){
+            $error = $exception->getMessage();
+        }
+
+        foreach ($successList as $version) {
+            $io->success('Migration successfully executed: version ' . $version);
+        }
+
+        if (empty($successList) && $error === null) {
+            $io->info('No migrations to run. The database is already up to date.');
+        }
+
+        if ($error !== null) {
+            throw new \RuntimeException('An error occurred during the migration process: ' . $error);
+        }
+    }
+}

+ 63 - 0
src/Command/QueryExecuteCommand.php

@@ -0,0 +1,63 @@
+<?php
+
+namespace Michel\PaperORM\Command;
+
+use Michel\Console\Argument\CommandArgument;
+use Michel\Console\Command\CommandInterface;
+use Michel\Console\InputInterface;
+use Michel\Console\Output\ConsoleOutput;
+use Michel\Console\OutputInterface;
+use Michel\PaperORM\EntityManager;
+use Michel\PaperORM\EntityManagerInterface;
+
+class QueryExecuteCommand implements CommandInterface
+{
+    private EntityManagerInterface $entityManager;
+    public function __construct(EntityManagerInterface $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->hasArgument("query") ? $input->getArgumentValue("query") : null;
+        if ($query === null) {
+            throw new \LogicException("SQL query is required");
+        }
+        $io->title('Starting query on ' . $this->entityManager->getPlatform()->getDatabaseName());
+        
+        $data = $this->entityManager->getConnection()->fetchAll($query);
+        $io->listKeyValues([
+            'query' => $query,
+            'rows' => count($data),
+        ]);
+        if ($data === []) {
+            $io->info('The query yielded an empty result set.');
+            return;
+        }
+        $io->table(array_keys($data[0]), $data);
+    }
+}

+ 89 - 0
src/Command/ShowTablesCommand.php

@@ -0,0 +1,89 @@
+<?php
+
+namespace Michel\PaperORM\Command;
+
+use Michel\Console\Argument\CommandArgument;
+use Michel\Console\Command\CommandInterface;
+use Michel\Console\InputInterface;
+use Michel\Console\Option\CommandOption;
+use Michel\Console\Output\ConsoleOutput;
+use Michel\Console\OutputInterface;
+use Michel\PaperORM\EntityManagerInterface;
+use Michel\PaperORM\Metadata\ColumnMetadata;
+
+class ShowTablesCommand implements CommandInterface
+{
+    private EntityManagerInterface $entityManager;
+    public function __construct(EntityManagerInterface $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->getPlatform();
+        $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));
+        }else {
+            foreach ($tables as $table) {
+                $io->title(sprintf('Table : %s', $table));
+                if ($withColumns === true) {
+                    $columns = array_map(function (ColumnMetadata $column) {
+                        $data =  $column->toArray();
+                        foreach ($data as $key => $value) {
+                            $data[$key] = is_array($value) ? json_encode($value) : $value;
+                        }
+                        return $data;
+                    }, $platform->listTableColumns($table));
+                    $io->table(array_keys($columns[0]), $columns);
+                }
+            }
+        }
+        $io->writeln('');
+    }
+}

+ 42 - 0
src/Debugger/PDOStatementLogger.php

@@ -0,0 +1,42 @@
+<?php
+
+namespace Michel\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
+    {
+        $this->getSqlDebugger()->startQuery($query , $params);
+    }
+
+    private function stopQuery(): void
+    {
+        $this->getSqlDebugger()->stopQuery();
+    }
+
+    public function getSqlDebugger(): SqlDebugger
+    {
+        return $this->debugger;
+    }
+}

+ 65 - 0
src/Debugger/SqlDebugger.php

@@ -0,0 +1,65 @@
+<?php
+
+namespace Michel\PaperORM\Debugger;
+
+use Psr\Log\LoggerInterface;
+
+class SqlDebugger
+{
+    private ?LoggerInterface $logger;
+
+    private array $queries = [];
+
+    public int $currentQuery = 0;
+
+    public function __construct(?LoggerInterface $logger = null)
+    {
+        $this->logger = $logger;
+    }
+
+    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'] = round(microtime(true) - $start, 3);
+        if ($this->logger !== null) {
+            $this->logger->debug(json_encode($this->queries[$this->currentQuery]));
+        }
+    }
+
+    public function getQueries(): array
+    {
+        return array_values($this->queries);
+    }
+
+    public function getQueryCount(): int
+    {
+        return count($this->queries);
+    }
+
+    public function getTotalTime(): float
+    {
+        return array_sum(array_column($this->queries, 'executionTime'));
+    }
+
+    public function clear(): void
+    {
+        $this->queries = [];
+    }
+
+}

+ 15 - 0
src/Driver/DriverInterface.php

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

+ 42 - 0
src/Driver/DriverManager.php

@@ -0,0 +1,42 @@
+<?php
+
+namespace Michel\PaperORM\Driver;
+
+use Exception;
+use Michel\PaperORM\PaperConnection;
+use Michel\PaperORM\Platform\MariaDBPlatform;
+
+final class DriverManager
+{
+
+    private function __construct()
+    {
+    }
+
+    private static array $driverSchemeAliases = [
+        'sqlite' => SqliteDriver::class,
+        'sqlite3' => SqliteDriver::class,
+        'mysql' => MariaDBDriver::class,
+        'mariadb' => MariaDBDriver::class
+    ];
+
+    public static function createConnection(string $driver, array $params): PaperConnection
+    {
+        $driver = strtolower($driver);
+
+        $drivers = self::$driverSchemeAliases;
+        if (isset($params['options']['driverClass'])) {
+            $drivers[$driver] = $params['options']['driverClass'];
+            unset($params['options']['driverClass']);
+        }
+
+        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);
+    }
+
+}

+ 78 - 0
src/Driver/MariaDBDriver.php

@@ -0,0 +1,78 @@
+<?php
+
+namespace Michel\PaperORM\Driver;
+
+use PDO;
+use Michel\PaperORM\PaperConnection;
+use Michel\PaperORM\Pdo\PaperPDO;
+use Michel\PaperORM\Platform\MariaDBPlatform;
+use Michel\PaperORM\Platform\PlatformInterface;
+use Michel\PaperORM\Platform\SqlitePlatform;
+use Michel\PaperORM\Schema\MariaDBSchema;
+use Michel\PaperORM\Schema\SqliteSchema;
+
+final class MariaDBDriver implements DriverInterface
+{
+    public function connect(
+        #[SensitiveParameter]
+        array $params
+    ): PaperPDO
+    {
+        $driverOptions = $params['driverOptions'] ?? [
+            PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION
+        ];
+        if (!empty($params['persistent'])) {
+            $driverOptions[PDO::ATTR_PERSISTENT] = true;
+        }
+
+        return new PaperPDO(
+            $this->constructPdoDsn($params),
+            $params['user'] ?? '',
+            $params['password'] ?? '',
+            $driverOptions,
+        );
+    }
+
+    /**
+     * Constructs the Sqlite PDO DSN.
+     *
+     * @param array<string, mixed> $params
+     */
+    private function constructPdoDsn(array $params): string
+    {
+        $dsn = 'mysql:';
+        if (isset($params['host']) && $params['host'] !== '') {
+            $dsn .= 'host=' . $params['host'] . ';';
+        }
+
+        if (isset($params['port'])) {
+            $dsn .= 'port=' . $params['port'] . ';';
+        }
+
+        if (isset($params['dbname'])) {
+            $dsn .= 'dbname=' . $params['dbname'] . ';';
+        }elseif (isset($params['path'])) {
+            $dsn .= 'dbname=' . $params['path'] . ';';
+        }
+
+        if (isset($params['unix_socket'])) {
+            $dsn .= 'unix_socket=' . $params['unix_socket'] . ';';
+        }
+
+        if (isset($params['options']['charset'])) {
+            $dsn .= sprintf('charset=%s;', $params['options']['charset']);
+        }
+
+        return $dsn;
+    }
+
+    public function createDatabasePlatform(PaperConnection $connection): PlatformInterface
+    {
+        return new MariaDBPlatform($connection, $this->createDatabaseSchema());
+    }
+
+    public function createDatabaseSchema(): MariaDBSchema
+    {
+        return new MariaDBSchema();
+    }
+}

+ 57 - 0
src/Driver/SqliteDriver.php

@@ -0,0 +1,57 @@
+<?php
+
+namespace Michel\PaperORM\Driver;
+
+use PDO;
+use Michel\PaperORM\PaperConnection;
+use Michel\PaperORM\Pdo\PaperPDO;
+use Michel\PaperORM\Platform\PlatformInterface;
+use Michel\PaperORM\Platform\SqlitePlatform;
+use Michel\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();
+    }
+}

+ 8 - 0
src/Entity/EntityInterface.php

@@ -0,0 +1,8 @@
+<?php
+
+namespace Michel\PaperORM\Entity;
+
+interface EntityInterface
+{
+    public function getPrimaryKeyValue();
+}

+ 7 - 0
src/Entity/SystemEntityInterface.php

@@ -0,0 +1,7 @@
+<?php
+
+namespace Michel\PaperORM\Entity;
+
+interface SystemEntityInterface
+{
+}

+ 10 - 0
src/Entity/TableMetadataInterface.php

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

+ 161 - 0
src/EntityManager.php

@@ -0,0 +1,161 @@
+<?php
+
+namespace Michel\PaperORM;
+
+use Michel\EventDispatcher\EventDispatcher;
+use Michel\PaperORM\Cache\EntityMemcachedCache;
+use Michel\PaperORM\Manager\PaperKeyValueManager;
+use Michel\PaperORM\Manager\PaperSequenceManager;
+use Michel\PaperORM\Mapper\EntityMapper;
+use Michel\PaperORM\Platform\PlatformInterface;
+use Michel\PaperORM\Repository\Repository;
+use Psr\EventDispatcher\EventDispatcherInterface;
+
+class EntityManager implements EntityManagerInterface
+{
+    private PaperConnection $connection;
+
+    private UnitOfWork $unitOfWork;
+
+    /**
+     * @var array<string, Repository>
+     */
+    private array $repositories = [];
+
+    private EntityMemcachedCache $cache;
+
+    private EventDispatcherInterface $dispatcher;
+    private ?PlatformInterface $platform = null;
+    private PaperKeyValueManager $keyValueManager;
+    private PaperSequenceManager $sequenceManager;
+
+    public static function createFromConfig(PaperConfiguration $config): self
+    {
+        return new self($config);
+    }
+    private function __construct(PaperConfiguration $config)
+    {
+        $this->connection = $config->getConnection();
+        $this->unitOfWork = $config->getUnitOfWork();
+        $this->cache = $config->getCache();
+        $this->dispatcher = new EventDispatcher($config->getListeners());
+        $this->keyValueManager = new PaperKeyValueManager($this);
+        $this->sequenceManager = new PaperSequenceManager($this->keyValueManager);
+    }
+
+    public function persist(object $entity): void
+    {
+        $this->unitOfWork->persist($entity);
+    }
+
+    public function remove(object $entity): void
+    {
+        $this->unitOfWork->remove($entity);
+    }
+
+    public function flush(object $entity = null ): void
+    {
+        foreach ($this->unitOfWork->getEntityInsertions() as &$entityToInsert) {
+            if ($entity && $entity !== $entityToInsert) {
+                continue;
+            }
+            $repository = $this->getRepository(get_class($entityToInsert));
+            $repository->insert($entityToInsert);
+            $this->unitOfWork->unsetEntity($entityToInsert);
+        }
+
+        foreach ($this->unitOfWork->getEntityUpdates() as $entityToUpdate) {
+            if ($entity && $entity !== $entityToUpdate) {
+                continue;
+            }
+            $repository = $this->getRepository(get_class($entityToUpdate));
+            $repository->update($entityToUpdate);
+            $this->unitOfWork->unsetEntity($entityToUpdate);
+        }
+
+        foreach ($this->unitOfWork->getEntityDeletions() as $entityToDelete) {
+            if ($entity && $entity !== $entityToDelete) {
+                continue;
+            }
+            $repository = $this->getRepository(get_class($entityToDelete));
+            $repository->delete($entityToDelete);
+            $this->unitOfWork->unsetEntity($entityToDelete);
+        }
+
+        if ($entity) {
+            $this->unitOfWork->unsetEntity($entity);
+            return;
+        }
+        $this->unitOfWork->clear();
+    }
+
+    public function registry(): PaperKeyValueManager
+    {
+        return $this->keyValueManager;
+    }
+
+    public function sequence(): PaperSequenceManager
+    {
+        return $this->sequenceManager;
+    }
+
+    public function getRepository(string $entity): Repository
+    {
+        $repositoryName = EntityMapper::getRepositoryName($entity);
+        if ($repositoryName === null) {
+            $repositoryName = 'ProxyRepository' . $entity;
+        }
+
+        $dispatcher = $this->dispatcher;
+        if (!isset($this->repositories[$repositoryName])) {
+            if (!class_exists($repositoryName)) {
+                $repository = new class($entity, $this, $dispatcher) extends Repository {
+                    private string $entityName;
+
+                    public function __construct($entityName, EntityManager $em, EventDispatcherInterface $dispatcher = null)
+                    {
+                        $this->entityName = $entityName;
+                        parent::__construct($em, $dispatcher);
+                    }
+
+                    public function getEntityName(): string
+                    {
+                        return $this->entityName;
+                    }
+                };
+            } else {
+                $repository = new $repositoryName($this, $dispatcher);
+            }
+            $this->repositories[$repositoryName] = $repository;
+        }
+
+        return $this->repositories[$repositoryName];
+    }
+
+
+    public function getPlatform(): PlatformInterface
+    {
+        if ($this->platform === null) {
+            $driver = $this->connection->getDriver();
+            $this->platform = $driver->createDatabasePlatform($this->getConnection());
+        }
+        return $this->platform;
+    }
+
+
+    public function getConnection(): PaperConnection
+    {
+        return $this->connection;
+    }
+
+    public function getCache(): EntityMemcachedCache
+    {
+        return $this->cache;
+    }
+
+    public function clear(): void
+    {
+        $this->getCache()->clear();
+    }
+
+}

+ 23 - 0
src/EntityManagerInterface.php

@@ -0,0 +1,23 @@
+<?php
+
+namespace Michel\PaperORM;
+
+use Michel\PaperORM\Cache\EntityMemcachedCache;
+use Michel\PaperORM\Manager\PaperKeyValueManager;
+use Michel\PaperORM\Manager\PaperSequenceManager;
+use Michel\PaperORM\Platform\PlatformInterface;
+use Michel\PaperORM\Repository\Repository;
+
+interface EntityManagerInterface
+{
+    public function persist(object $entity): void;
+    public function remove(object $entity): void;
+    public function flush(object $entity = null ): void;
+    public function registry(): PaperKeyValueManager;
+    public function sequence(): PaperSequenceManager;
+    public function getRepository(string $entity): Repository;
+    public function getPlatform(): PlatformInterface;
+    public function getConnection(): PaperConnection;
+    public function getCache(): EntityMemcachedCache;
+    public function clear(): void;
+}

+ 9 - 0
src/Event/Create/PostCreateEvent.php

@@ -0,0 +1,9 @@
+<?php
+
+namespace Michel\PaperORM\Event\Create;
+
+use Michel\PaperORM\Event\PaperEvent;
+
+class PostCreateEvent extends PaperEvent
+{
+}

+ 10 - 0
src/Event/Create/PreCreateEvent.php

@@ -0,0 +1,10 @@
+<?php
+
+namespace Michel\PaperORM\Event\Create;
+
+
+use Michel\PaperORM\Event\PaperEvent;
+
+class PreCreateEvent extends PaperEvent
+{
+}

+ 10 - 0
src/Event/Delete/PostDeleteEvent.php

@@ -0,0 +1,10 @@
+<?php
+
+namespace Michel\PaperORM\Event\Delete;
+
+use Michel\PaperORM\Event\PaperEvent;
+
+class PostDeleteEvent extends PaperEvent
+{
+
+}

+ 10 - 0
src/Event/Delete/PreDeleteEvent.php

@@ -0,0 +1,10 @@
+<?php
+
+namespace Michel\PaperORM\Event\Delete;
+
+
+use Michel\PaperORM\Event\PaperEvent;
+
+class PreDeleteEvent extends PaperEvent
+{
+}

+ 41 - 0
src/Event/PaperEvent.php

@@ -0,0 +1,41 @@
+<?php
+
+namespace Michel\PaperORM\Event;
+
+use Michel\EventDispatcher\Event;
+use Michel\PaperORM\Entity\EntityInterface;
+use Michel\PaperORM\EntityManagerInterface;
+
+abstract class PaperEvent extends Event
+{
+
+    private EntityManagerInterface $em;
+    private EntityInterface $entity;
+
+    /**
+     * PreCreateEvent constructor.
+     *
+     * @param EntityManagerInterface $em
+     * @param EntityInterface $entity
+     */
+    public function __construct(EntityManagerInterface $em, EntityInterface $entity)
+    {
+        $this->entity = $entity;
+        $this->em = $em;
+    }
+
+
+    public function getEntity(): EntityInterface
+    {
+        return $this->entity;
+    }
+
+    /**
+     * @return EntityManagerInterface
+     */
+    public function getEm(): EntityManagerInterface
+    {
+        return $this->em;
+    }
+
+}

+ 10 - 0
src/Event/Update/PostUpdateEvent.php

@@ -0,0 +1,10 @@
+<?php
+
+namespace Michel\PaperORM\Event\Update;
+
+use Michel\PaperORM\Event\PaperEvent;
+
+class PostUpdateEvent extends PaperEvent
+{
+
+}

+ 10 - 0
src/Event/Update/PreUpdateEvent.php

@@ -0,0 +1,10 @@
+<?php
+
+namespace Michel\PaperORM\Event\Update;
+
+use Michel\PaperORM\Event\PaperEvent;
+
+class PreUpdateEvent extends PaperEvent
+{
+
+}

+ 24 - 0
src/EventListener/PostCreateEventListener.php

@@ -0,0 +1,24 @@
+<?php
+
+namespace Michel\PaperORM\EventListener;
+
+use Michel\PaperORM\Assigner\AutoIncrementAssigner;
+use Michel\PaperORM\Event\Create\PostCreateEvent;
+use Michel\PaperORM\Mapper\ColumnMapper;
+use Michel\PaperORM\Mapping\Column\AutoIncrementColumn;
+
+class PostCreateEventListener
+{
+    public function __invoke(PostCreateEvent $event)
+    {
+        $entity = $event->getEntity();
+        $em = $event->getEm();
+
+        $autoIncrementAssigner = new AutoIncrementAssigner($em->sequence());
+        foreach (ColumnMapper::getColumns($entity) as $column) {
+            if ($column instanceof AutoIncrementColumn) {
+                $autoIncrementAssigner->commit($column);
+            }
+        }
+    }
+}

+ 24 - 0
src/EventListener/PostUpdateEventListener.php

@@ -0,0 +1,24 @@
+<?php
+
+namespace Michel\PaperORM\EventListener;
+
+use Michel\PaperORM\Assigner\AutoIncrementAssigner;
+use Michel\PaperORM\Event\Update\PostUpdateEvent;
+use Michel\PaperORM\Mapper\ColumnMapper;
+use Michel\PaperORM\Mapping\Column\AutoIncrementColumn;
+
+class PostUpdateEventListener
+{
+    public function __invoke(PostUpdateEvent $event)
+    {
+        $entity = $event->getEntity();
+        $em = $event->getEm();
+
+        $autoIncrementAssigner = new AutoIncrementAssigner($em->sequence());
+        foreach (ColumnMapper::getColumns($entity) as $column) {
+            if ($column instanceof AutoIncrementColumn) {
+                $autoIncrementAssigner->commit($column);
+            }
+        }
+    }
+}

+ 45 - 0
src/EventListener/PreCreateEventListener.php

@@ -0,0 +1,45 @@
+<?php
+
+namespace Michel\PaperORM\EventListener;
+
+use Michel\PaperORM\Assigner\AutoIncrementAssigner;
+use Michel\PaperORM\Assigner\SlugAssigner;
+use Michel\PaperORM\Assigner\TimestampAssigner;
+use Michel\PaperORM\Assigner\TokenAssigner;
+use Michel\PaperORM\Assigner\UuidAssigner;
+
+use Michel\PaperORM\Event\Create\PreCreateEvent;
+use Michel\PaperORM\Mapper\ColumnMapper;
+use Michel\PaperORM\Mapping\Column\AutoIncrementColumn;
+use Michel\PaperORM\Mapping\Column\SlugColumn;
+use Michel\PaperORM\Mapping\Column\TimestampColumn;
+use Michel\PaperORM\Mapping\Column\TokenColumn;
+use Michel\PaperORM\Mapping\Column\UuidColumn;
+
+class PreCreateEventListener
+{
+    public function __invoke(PreCreateEvent $event)
+    {
+        $entity = $event->getEntity();
+        $em = $event->getEm();
+
+        $autoIncrementAssigner = new AutoIncrementAssigner($em->sequence());
+        $slugAssigner = new SlugAssigner();
+        $timestampAssigner = new TimestampAssigner();
+        $uuidAssigner = new UuidAssigner();
+        $tokenAssigner = new TokenAssigner();
+        foreach (ColumnMapper::getColumns($entity) as $column) {
+            if ($column instanceof TimestampColumn && $column->isOnCreated()) {
+                $timestampAssigner->assign($entity, $column);
+            } elseif ($column instanceof SlugColumn) {
+                $slugAssigner->assign($entity, $column);
+            } elseif ($column instanceof AutoIncrementColumn) {
+                $autoIncrementAssigner->assign($entity, $column);
+            }elseif ($column instanceof UuidColumn) {
+                $uuidAssigner->assign($entity, $column);
+            }elseif ($column instanceof TokenColumn) {
+                $tokenAssigner->assign($entity, $column);
+            }
+        }
+    }
+}

+ 23 - 0
src/EventListener/PreUpdateEventListener.php

@@ -0,0 +1,23 @@
+<?php
+
+namespace Michel\PaperORM\EventListener;
+
+use Michel\PaperORM\Assigner\TimestampAssigner;
+use Michel\PaperORM\Event\Update\PreUpdateEvent;
+use Michel\PaperORM\Mapper\ColumnMapper;
+use Michel\PaperORM\Mapping\Column\TimestampColumn;
+
+class PreUpdateEventListener
+{
+
+    public function __invoke(PreUpdateEvent $event)
+    {
+        $entity = $event->getEntity();
+        $timestampAssigner = new TimestampAssigner();
+        foreach (ColumnMapper::getColumns($entity) as $column) {
+            if ($column instanceof TimestampColumn && $column->isOnUpdated()) {
+                $timestampAssigner->assign($entity, $column);
+            }
+        }
+    }
+}

+ 175 - 0
src/Expression/Expr.php

@@ -0,0 +1,175 @@
+<?php
+
+namespace Michel\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 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);
+    }
+}

+ 174 - 0
src/Generator/SchemaDiffGenerator.php

@@ -0,0 +1,174 @@
+<?php
+
+namespace Michel\PaperORM\Generator;
+
+use LogicException;
+use Michel\PaperORM\Mapping\Column\Column;
+use Michel\PaperORM\Mapping\Column\JoinColumn;
+use Michel\PaperORM\Mapping\Index;
+use Michel\PaperORM\Metadata\ColumnMetadata;
+use Michel\PaperORM\Platform\PlatformInterface;
+use Michel\PaperORM\Schema\SchemaInterface;
+
+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();
+        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
+                ));
+            }
+        }
+
+        list( $sqlUp, $sqlDown) = $this->diff($tables, $schema, $tablesExist);
+        return [
+            'up' => $sqlUp,
+            'down' => $sqlDown
+        ];
+    }
+
+    private function diff(array $tables, SchemaInterface $schema, array $tablesExist): array
+    {
+        $sqlUp = [];
+        $sqlDown = [];
+        $sqlForeignKeyUp = [];
+        $sqlForeignKeyDown = [];
+        foreach ($tables as $tableName => $tableData) {
+            /**
+             * @var array<Column> $columns
+             * @var array<Index> $indexes
+             */
+            $columns = $tableData['columns'];
+            $indexes = $tableData['indexes'];
+            $diff = $this->platform->diff($tableName, $columns, $indexes);
+            $columnsToAdd = $diff->getColumnsToAdd();
+            $columnsToUpdate = $diff->getColumnsToUpdate();
+            $columnsToDelete = $diff->getColumnsToDelete();
+
+            $foreignKeyToAdd = $diff->getForeignKeyToAdd();
+            $foreignKeyToDrop = $diff->getForeignKeyToDrop();
+
+            $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);
+                }
+
+                foreach ($foreignKeyToAdd as $foreignKey) {
+                    if ($schema->supportsAddForeignKey()) {
+                        $sqlForeignKeyUp[] = $schema->createForeignKeyConstraint($tableName, $foreignKey);
+                    }
+                    if ($schema->supportsDropForeignKey()) {
+                        $sqlForeignKeyDown[] = $schema->dropForeignKeyConstraints($tableName, $foreignKey->getName());
+                    }
+                }
+
+                $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 ($indexesToDelete as $index) {
+                $sqlUp[] = $schema->dropIndex($index);
+                $sqlDown[] = $schema->createIndex($diff->getOriginalIndex($index->getName()));
+            }
+            foreach ($indexesToAdd as $index) {
+                $sqlUp[] = $schema->createIndex($index);
+                $sqlDown[] = $schema->dropIndex($index);
+            }
+
+            foreach ($foreignKeyToAdd as $foreignKey) {
+                if ($schema->supportsAddForeignKey()) {
+                    $sqlUp[] = $schema->createForeignKeyConstraint($tableName, $foreignKey);
+                }
+                if ($schema->supportsDropForeignKey()) {
+                    $sqlDown[] = $schema->dropForeignKeyConstraints($tableName, $foreignKey->getName());
+                }
+            }
+
+            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 ($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()));
+            }
+
+            foreach ($foreignKeyToDrop as $foreignKey) {
+                if ($schema->supportsDropForeignKey()) {
+                    $sqlForeignKeyUp[] = $schema->dropForeignKeyConstraints($tableName, $foreignKey->getName());
+                }
+
+                if ($schema->supportsAddForeignKey()) {
+                    $sqlForeignKeyDown[] = $schema->createForeignKeyConstraint($tableName, $diff->getOriginalForeignKey($foreignKey->getName()));
+                }
+            }
+        }
+
+        $sqlUp = array_merge($sqlUp, $sqlForeignKeyUp);
+        $sqlDown = array_merge($sqlForeignKeyDown, $sqlDown);
+        return [$sqlUp, $sqlDown];
+    }
+}

+ 95 - 0
src/Hydrator/AbstractEntityHydrator.php

@@ -0,0 +1,95 @@
+<?php
+
+namespace Michel\PaperORM\Hydrator;
+
+use Michel\PaperORM\Collection\ObjectStorage;
+use Michel\PaperORM\Entity\EntityInterface;
+use Michel\PaperORM\Mapper\ColumnMapper;
+use Michel\PaperORM\Mapping\Column\JoinColumn;
+use Michel\PaperORM\Mapping\OneToMany;
+use Michel\PaperORM\Proxy\ProxyInterface;
+use Michel\PaperORM\Schema\SchemaInterface;
+
+abstract class AbstractEntityHydrator
+{
+    abstract protected function getSchema(): SchemaInterface;
+    abstract protected function instantiate(string $class, array $data): object;
+
+    /**
+     *
+     * @param object|string $objectOrClass
+     * @param array $data
+     * @return object
+     */
+    public function hydrate($objectOrClass, array $data): object
+    {
+        if (!is_subclass_of($objectOrClass, EntityInterface::class)) {
+            throw new \LogicException(
+                sprintf('Class %s must implement %s', $objectOrClass, EntityInterface::class)
+            );
+        }
+
+        $object = is_string($objectOrClass) ? $this->instantiate($objectOrClass, $data) : $objectOrClass;
+
+        $this->mapProperties($object, $data);
+
+        return $object;
+    }
+
+    private function mapProperties(object $object, array $data): void
+    {
+        $reflection = new \ReflectionClass($object);
+        if ($reflection->getParentClass()) {
+            $reflection = $reflection->getParentClass();
+        }
+
+        $columns = array_merge(
+            ColumnMapper::getColumns($object),
+            ColumnMapper::getOneToManyRelations($object)
+        );
+
+        $properties = [];
+
+        foreach ($columns as $column) {
+            $name = ($column instanceof OneToMany || $column instanceof JoinColumn)
+                ? $column->getProperty()
+                : $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) {
+                $entityName = $column->getTargetEntity();
+                $value = is_array($value) ? $this->hydrate($entityName, $value) : null;
+                $property->setValue($object, $value);
+                continue;
+            }
+
+            if ($column instanceof OneToMany) {
+                $entityName = $column->getTargetEntity();
+                $storage = $property->getValue($object) ?: new ObjectStorage();
+                foreach ((array) $value as $item) {
+                    $storage->add($this->hydrate($entityName, $item));
+                }
+                $property->setValue($object, $storage);
+                continue;
+            }
+
+            $property->setValue($object, $column->convertToPHP($value, $this->getSchema()));
+        }
+
+        if ($object instanceof ProxyInterface) {
+            $object->__setInitialized($properties);
+        }
+    }
+}
+

+ 79 - 0
src/Hydrator/ArrayHydrator.php

@@ -0,0 +1,79 @@
+<?php
+
+namespace Michel\PaperORM\Hydrator;
+
+use LogicException;
+use Michel\PaperORM\Cache\EntityMemcachedCache;
+use Michel\PaperORM\Collection\ObjectStorage;
+use Michel\PaperORM\Entity\EntityInterface;
+use Michel\PaperORM\Mapper\ColumnMapper;
+use Michel\PaperORM\Mapping\Column\JoinColumn;
+use Michel\PaperORM\Mapping\OneToMany;
+use Michel\PaperORM\Schema\SchemaInterface;
+use ReflectionClass;
+
+final class ArrayHydrator
+{
+    private SchemaInterface $schema;
+
+    public function __construct(SchemaInterface $schema)
+    {
+        $this->schema = $schema;
+    }
+
+    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 Michel\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, $this->schema);
+            if ($value instanceof \DateTimeInterface) {
+                $value = $column->convertToDatabase($value, $this->schema);
+            }
+            $result[$propertyName] = $value;
+        }
+        return $result;
+    }
+
+}

+ 45 - 0
src/Hydrator/EntityHydrator.php

@@ -0,0 +1,45 @@
+<?php
+
+namespace Michel\PaperORM\Hydrator;
+
+use InvalidArgumentException;
+use LogicException;
+use Michel\PaperORM\Cache\EntityMemcachedCache;
+use Michel\PaperORM\Collection\ObjectStorage;
+use Michel\PaperORM\Entity\EntityInterface;
+use Michel\PaperORM\Mapper\ColumnMapper;
+use Michel\PaperORM\Mapping\Column\JoinColumn;
+use Michel\PaperORM\Mapping\OneToMany;
+use Michel\PaperORM\Proxy\ProxyFactory;
+use Michel\PaperORM\Proxy\ProxyInterface;
+use Michel\PaperORM\Schema\SchemaInterface;
+use ReflectionClass;
+
+final class EntityHydrator extends AbstractEntityHydrator
+{
+    private EntityMemcachedCache $cache;
+    private SchemaInterface $schema;
+
+    public function __construct(SchemaInterface $schema, EntityMemcachedCache $cache)
+    {
+        $this->cache = $cache;
+        $this->schema = $schema;
+    }
+
+    protected function instantiate(string $class, array $data): object
+    {
+        $primaryKey = ColumnMapper::getPrimaryKeyColumnName($class);
+
+        $object = $this->cache->get($class, $data[$primaryKey]) ?: ProxyFactory::create($class);
+
+        $this->cache->set($class, $data[$primaryKey], $object);
+
+        return $object;
+    }
+
+    protected function getSchema(): SchemaInterface
+    {
+        return $this->schema;
+    }
+}
+

+ 25 - 0
src/Hydrator/ReadOnlyEntityHydrator.php

@@ -0,0 +1,25 @@
+<?php
+
+namespace Michel\PaperORM\Hydrator;
+
+use Michel\PaperORM\Schema\SchemaInterface;
+
+final class ReadOnlyEntityHydrator extends AbstractEntityHydrator
+{
+
+    private SchemaInterface $schema;
+
+    public function __construct(SchemaInterface $schema)
+    {
+        $this->schema = $schema;
+    }
+    protected function instantiate(string $class, array $data): object
+    {
+        return new $class();
+    }
+
+    protected function getSchema(): SchemaInterface
+    {
+        return $this->schema;
+    }
+}

+ 104 - 0
src/Internal/Entity/PaperKeyValue.php

@@ -0,0 +1,104 @@
+<?php
+
+namespace Michel\PaperORM\Internal\Entity;
+use Michel\PaperORM\Entity\EntityInterface;
+use Michel\PaperORM\Entity\SystemEntityInterface;
+use Michel\PaperORM\Mapping\Column\AnyColumn;
+use Michel\PaperORM\Mapping\Column\PrimaryKeyColumn;
+use Michel\PaperORM\Mapping\Column\StringColumn;
+use Michel\PaperORM\Mapping\Column\TimestampColumn;
+use Michel\PaperORM\Mapping\Entity;
+
+#[Entity(table : 'paper_key_value')]
+class PaperKeyValue implements EntityInterface, SystemEntityInterface
+{
+    #[PrimaryKeyColumn]
+    private ?int $id = null;
+
+    #[StringColumn(name: 'k', length: 100, nullable: false, unique: true)]
+    private ?string $key = null;
+
+    /**
+     * @var mixed
+     */
+    #[AnyColumn(name: 'val')]
+    private $value = null;
+
+    #[TimestampColumn(name: 'created_at', onCreated: true)]
+    private ?\DateTimeInterface $createdAt = null;
+
+    #[TimestampColumn(name: 'updated_at', onCreated: false, onUpdated: true)]
+    private ?\DateTimeInterface $updatedAt = null;
+
+    public function getPrimaryKeyValue() : ?int
+    {
+        return $this->id;
+    }
+
+    public function getKey(): ?string
+    {
+        return $this->key;
+    }
+
+    public function setKey(?string $key): PaperKeyValue
+    {
+        $this->key = $key;
+        return $this;
+    }
+
+    public function getValue()
+    {
+        return $this->value;
+    }
+
+    public function setValue($value): PaperKeyValue
+    {
+        $this->value = $value;
+        return $this;
+    }
+
+    public function getCreatedAt(): ?\DateTimeInterface
+    {
+        return $this->createdAt;
+    }
+
+    public function setCreatedAt(?\DateTimeInterface $createdAt): PaperKeyValue
+    {
+        $this->createdAt = $createdAt;
+        return $this;
+    }
+
+    public function getUpdatedAt(): ?\DateTimeInterface
+    {
+        return $this->updatedAt;
+    }
+
+    public function setUpdatedAt(?\DateTimeInterface $updatedAt): PaperKeyValue
+    {
+        $this->updatedAt = $updatedAt;
+        return $this;
+    }
+
+
+    static public function getTableName(): string
+    {
+        return 'paper_key_value';
+    }
+
+    static public function getRepositoryName(): ?string
+    {
+        return null;
+    }
+
+    static public function columnsMapping(): array
+    {
+        return [
+            (new PrimaryKeyColumn())->bindProperty('id'),
+            (new StringColumn('k', 100, false, null, true))->bindProperty('key'),
+            (new AnyColumn('val'))->bindProperty('value'),
+            (new TimestampColumn('created_at', true))->bindProperty('createdAt'),
+            (new TimestampColumn('updated_at', false, true))->bindProperty('updatedAt'),
+        ];
+    }
+
+}

+ 49 - 0
src/Manager/PaperKeyValueManager.php

@@ -0,0 +1,49 @@
+<?php
+
+namespace Michel\PaperORM\Manager;
+
+use Michel\PaperORM\EntityManagerInterface;
+use Michel\PaperORM\Internal\Entity\PaperKeyValue;
+use Michel\PaperORM\Repository\Repository;
+
+final class PaperKeyValueManager
+{
+    private EntityManagerInterface $em;
+    private Repository $repository;
+    public function __construct(EntityManagerInterface $em)
+    {
+        $this->em = $em;
+        $this->repository = $em->getRepository(PaperKeyValue::class);
+    }
+
+    public function get(string $key)
+    {
+        $kv = $this->repository->findOneBy(['key' => strtolower($key)])->toArray();
+        if (empty($kv)) {
+            return null;
+        }
+        return $kv['value'];
+    }
+
+    public function set(string $key, $value): void
+    {
+        $kv = $this->repository->findOneBy(['key' => strtolower($key)])->toObject();
+        if (!$kv instanceof PaperKeyValue) {
+            $kv = new PaperKeyValue();
+            $kv->setKey(strtolower($key));
+        }
+        $kv->setValue($value);
+
+        $this->em->persist($kv);
+        $this->em->flush($kv);
+    }
+
+    public function remove(string $key): void
+    {
+        if ($kv = $this->repository->findOneBy(['key' => $key])->toObject()) {
+            $this->em->remove($kv);
+            $this->em->flush($kv);
+        }
+    }
+
+}

+ 58 - 0
src/Manager/PaperSequenceManager.php

@@ -0,0 +1,58 @@
+<?php
+
+namespace Michel\PaperORM\Manager;
+
+use Michel\PaperORM\Entity\EntityInterface;
+
+final class PaperSequenceManager
+{
+    private PaperKeyValueManager $keyValueManager;
+    private array $cache = [];
+
+    public function __construct(PaperKeyValueManager $keyValueManager)
+    {
+        $this->keyValueManager = $keyValueManager;
+    }
+
+    public function peek(string $key): int
+    {
+        $key = strtolower($key);
+        $next =  $this->getNext($key);
+        $this->cache[$key] = $next;
+        return $next;
+    }
+    public function increment(string $key): void
+    {
+        $key = strtolower($key);
+        $cached = $this->cache[$key] ?? null;
+        $expectedNext = $this->getNext($key);
+        if ($cached !== null && $cached !== $expectedNext) {
+            throw new \RuntimeException(sprintf(
+                'Sequence conflict for key "%s": expected next %d but found %d in storage.',
+                $key,
+                $cached,
+                $expectedNext
+            ));
+        }
+
+        $this->keyValueManager->set($this->resolveKey($key), $expectedNext);
+        unset($this->cache[$key]);
+    }
+
+    public function reset(string $key): void
+    {
+        $this->keyValueManager->set($this->resolveKey($key), 0);
+    }
+
+    private function getNext(string $key) : int
+    {
+        $value = $this->keyValueManager->get($this->resolveKey($key));
+        return $value ? (int)$value + 1 : 1;
+    }
+
+
+    private function resolveKey(string $key): string
+    {
+        return 'sequence.' . $key;
+    }
+}

+ 197 - 0
src/Mapper/ColumnMapper.php

@@ -0,0 +1,197 @@
+<?php
+
+namespace Michel\PaperORM\Mapper;
+
+use InvalidArgumentException;
+use LogicException;
+use Michel\PaperORM\Cache\ColumnCache;
+use Michel\PaperORM\Cache\OneToManyCache;
+use Michel\PaperORM\Cache\PrimaryKeyColumnCache;
+use Michel\PaperORM\Entity\EntityInterface;
+use Michel\PaperORM\Entity\TableMetadataInterface;
+use Michel\PaperORM\Mapping\Column\Column;
+use Michel\PaperORM\Mapping\Column\JoinColumn;
+use Michel\PaperORM\Mapping\Column\PrimaryKeyColumn;
+use Michel\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 = self::getColumnsMapping($class);
+                $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) {
+                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) {
+                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 = self::getColumnsMapping($class);
+            $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);
+    }
+
+    static private function getColumnsMapping($class): array
+    {
+        if (is_subclass_of($class, TableMetadataInterface::class)) {
+            return $class::columnsMapping();
+        }
+
+        if (PHP_VERSION_ID >= 80000) {
+            $columns   = [];
+            $refClass  = new \ReflectionClass($class);
+            while ($refClass) {
+                foreach ($refClass->getProperties() as $property) {
+                    if ($property->getDeclaringClass()->getName() !== $refClass->getName()) {
+                        continue;
+                    }
+
+                    foreach ($property->getAttributes(
+                        Column::class,
+                        \ReflectionAttribute::IS_INSTANCEOF
+                    ) as $attr) {
+                        $instance = $attr->newInstance();
+                        if (method_exists($instance, 'bindProperty')) {
+                            $instance->bindProperty($property->getName());
+                        }
+                        $columns[] = $instance;
+                    }
+
+                    foreach ($property->getAttributes(
+                        OneToMany::class,
+                        \ReflectionAttribute::IS_INSTANCEOF
+                    ) as $attr) {
+                        $instance = $attr->newInstance();
+                        if (method_exists($instance, 'bindProperty')) {
+                            $instance->bindProperty($property->getName());
+                        }
+                        $columns[] = $instance;
+                    }
+                }
+
+                $refClass = $refClass->getParentClass();
+            }
+
+            return $columns;
+        }
+
+        if (method_exists($class, 'columnsMapping')) {
+            return $class::columnsMapping();
+        }
+
+        throw new \LogicException(sprintf(
+            'Entity %s must define columns via interface, attribute or static method : ::columnsMapping() or implement %s',
+            is_object($class) ? get_class($class) : $class, TableMetadataInterface::class
+        ));
+
+    }
+}

+ 88 - 0
src/Mapper/EntityMapper.php

@@ -0,0 +1,88 @@
+<?php
+
+namespace Michel\PaperORM\Mapper;
+
+
+use Michel\PaperORM\Entity\EntityInterface;
+use Michel\PaperORM\Entity\TableMetadataInterface;
+use Michel\PaperORM\Mapping\Entity;
+use Michel\PaperORM\Proxy\ProxyInterface;
+
+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));
+        }
+
+        if (is_subclass_of($class, TableMetadataInterface::class)) {
+            return $class::getTableName();
+        }
+
+        if (PHP_VERSION_ID >= 80000) {
+            $entity = self::getEntityPHP8($class);
+            if ($entity instanceof Entity) {
+                return $entity->getTable();
+            }
+        }
+
+        if (method_exists($class, 'getTableName')) {
+            return $class::getTableName();
+        }
+
+        throw new \LogicException(sprintf(
+            'Entity %s must define a entityName via interface, attribute or static method',
+            is_object($class) ? get_class($class) : $class
+        ));
+    }
+
+    static public function getRepositoryName($class): ?string
+    {
+        if (!is_subclass_of($class, EntityInterface::class)) {
+            throw new \LogicException(sprintf('%s must implement %s', $class, EntityInterface::class));
+        }
+
+        if (is_subclass_of($class, TableMetadataInterface::class)) {
+            return $class::getRepositoryName();
+        }
+
+        if (PHP_VERSION_ID >= 80000) {
+            $entity = self::getEntityPHP8($class);
+            if ($entity instanceof Entity) {
+                return $entity->getRepositoryClass();
+            }
+        }
+
+        if (method_exists($class, 'getRepositoryName')) {
+            return $class::getRepositoryName();
+        }
+
+        throw new \LogicException(sprintf(
+            'Entity %s must define a repository via interface, attribute or static method',
+            is_object($class) ? get_class($class) : $class
+        ));
+    }
+
+    static private function getEntityPHP8($class): ?Entity
+    {
+        if ($class instanceof ProxyInterface) {
+            $class = $class->__getParentClass();
+        }elseif (is_subclass_of($class, ProxyInterface::class)) {
+            $reflector = new \ReflectionClass($class);
+            $parentClass = $reflector->getParentClass();
+            if ($parentClass) {
+                $class = $parentClass->getName();
+            }
+        }
+
+        $reflector = new \ReflectionClass($class);
+        $attributes = $reflector->getAttributes(Entity::class);
+        if (!empty($attributes)) {
+            /** @var \Michel\PaperORM\Mapping\Entity $instance */
+            return $attributes[0]->newInstance();
+        }
+
+        return null;
+    }
+}

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

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

+ 60 - 0
src/Mapping/Column/AutoIncrementColumn.php

@@ -0,0 +1,60 @@
+<?php
+
+namespace Michel\PaperORM\Mapping\Column;
+
+use Attribute;
+use Michel\PaperORM\Types\StringType;
+
+#[\Attribute(\Attribute::TARGET_PROPERTY | \Attribute::IS_REPEATABLE)]
+final class AutoIncrementColumn extends Column
+{
+    private string $key;
+    private int $pad;
+    private ?string $prefix;
+    public function __construct(
+        string  $name = null,
+        string $key = null,
+        int     $pad = 6,
+        ?string $prefix = null,
+        bool    $nullable = false
+    )
+    {
+        if ($pad < 1) {
+            throw new \InvalidArgumentException('AutoIncrementColumn : pad must be at least 1.');
+        }
+
+        if (empty($key)) {
+            throw new \InvalidArgumentException(
+                'AutoIncrementColumn configuration error: A non-empty key (sequence or table.sequence) must be defined.'
+            );
+        }
+
+        if (!preg_match('/^[a-zA-Z0-9_.]+$/', $key)) {
+            throw new \InvalidArgumentException(sprintf(
+                'Invalid key or sequence name "%s": only alphanumeric characters, underscores (_), and dots (.) are allowed.',
+                $key
+            ));
+        }
+
+        $length = strlen($prefix) + $pad;
+        parent::__construct('', $name, StringType::class, $nullable, null, true, $length);
+
+        $this->pad = $pad;
+        $this->prefix = $prefix;
+        $this->key = $key;
+    }
+
+    public function getPad(): int
+    {
+        return $this->pad;
+    }
+
+    public function getPrefix(): ?string
+    {
+        return $this->prefix;
+    }
+    public function getKey(): string
+    {
+        return $this->key;
+    }
+}

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

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

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

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

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

@@ -0,0 +1,150 @@
+<?php
+
+namespace Michel\PaperORM\Mapping\Column;
+
+use Michel\PaperORM\Mapping\Index;
+use Michel\PaperORM\Schema\SchemaInterface;
+use Michel\PaperORM\Tools\NamingStrategy;
+use Michel\PaperORM\Types\StringType;
+use Michel\PaperORM\Types\Type;
+use Michel\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;
+
+     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
+     )
+    {
+
+        if (empty($property) && !empty($name)) {
+            $property = $name;
+        }
+
+        if (!empty($name) && !preg_match('/^[a-zA-Z0-9_]+$/', $name)) {
+            throw new \InvalidArgumentException(sprintf(
+                'Invalid column name "%s": only alphanumeric characters and underscores are allowed.',
+                $name
+            ));
+        }
+
+        $this->property = $property;
+        $this->name = $name;
+        $this->type = $type;
+        $this->defaultValue = $defaultValue;
+        $this->unique = $unique;
+        $this->nullable = $nullable;
+        $this->firstArgument = $firstArgument;
+        $this->secondArgument = $secondArgument;
+    }
+
+    final public function __toString(): string
+    {
+        return $this->getProperty();
+    }
+
+    public function bindProperty(string $propertyName): self
+    {
+        $this->property = $propertyName;
+        return $this;
+    }
+
+    public function getProperty(): string
+    {
+        if (empty($this->property)) {
+            throw  new \LogicException('Property must be set, use bindProperty');
+        }
+        return $this->property;
+    }
+
+    final public function getName(): ?string
+    {
+        $property = $this->getProperty();
+        return $this->name ?:  NamingStrategy::toSnakeCase($property);
+    }
+
+
+    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;
+    }
+
+
+    /**
+     * 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, SchemaInterface $schema)
+    {
+        $type = $this->getType();
+        if (is_subclass_of($type, Type::class)) {
+            $value = TypeFactory::create($schema, $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, SchemaInterface $schema)
+    {
+        $type = $this->getType();
+        if (is_subclass_of($type, Type::class)) {
+            $value = TypeFactory::create($schema, $type)->convertToPHP($value);
+        }
+        return $value;
+    }
+}

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

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

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

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

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

@@ -0,0 +1,33 @@
+<?php
+
+namespace Michel\PaperORM\Mapping\Column;
+
+use Attribute;
+use Michel\PaperORM\Types\DecimalType;
+
+#[\Attribute(\Attribute::TARGET_PROPERTY | \Attribute::IS_REPEATABLE)]
+final class DecimalColumn extends Column
+{
+
+    public function __construct(
+        string $name = null,
+        bool   $nullable = false,
+        string $defaultValue = null,
+        int    $precision = 10,
+        int    $scale = 2,
+        bool   $unique = false
+    )
+    {
+        parent::__construct('', $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;
+    }
+}

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

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

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

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

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

@@ -0,0 +1,79 @@
+<?php
+
+namespace Michel\PaperORM\Mapping\Column;
+
+use Michel\PaperORM\Types\IntegerType;
+use ReflectionClass;
+
+#[\Attribute(\Attribute::TARGET_PROPERTY | \Attribute::IS_REPEATABLE)]
+final class JoinColumn extends Column
+{
+    public const NO_ACTION   = 0;
+    public const RESTRICT    = 1;
+    public const CASCADE     = 2;
+    public const SET_NULL    = 3;
+    public const SET_DEFAULT = 4;
+
+    /**
+     * @var string
+     */
+    private string $referencedColumnName;
+    /**
+     * @var string
+     */
+    private string $targetEntity;
+    private int $onDelete;
+    private int $onUpdate;
+
+    final public function __construct(
+        string  $name,
+        string  $targetEntity,
+        string  $referencedColumnName = 'id',
+        bool   $nullable = false,
+        bool   $unique = false,
+        int    $onDelete = self::NO_ACTION,
+        int    $onUpdate = self::NO_ACTION
+
+    )
+    {
+
+        if ($onDelete === self::SET_NULL && $nullable === false) {
+            throw new \InvalidArgumentException('SET NULL requires nullable=true.');
+        }
+
+        parent::__construct('', $name, IntegerType::class, $nullable, null, $unique);
+        $this->referencedColumnName = $referencedColumnName;
+        $this->targetEntity = $targetEntity;
+        $this->onDelete = $onDelete;
+        $this->onUpdate = $onUpdate;
+    }
+
+    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(), '\\');
+    }
+
+    public function getOnDelete(): int
+    {
+        return $this->onDelete;
+    }
+
+    public function getOnUpdate(): int
+    {
+        return $this->onUpdate;
+    }
+}

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

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

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

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

+ 45 - 0
src/Mapping/Column/SlugColumn.php

@@ -0,0 +1,45 @@
+<?php
+
+namespace Michel\PaperORM\Mapping\Column;
+
+use Attribute;
+use Michel\PaperORM\Types\StringType;
+
+#[\Attribute(\Attribute::TARGET_PROPERTY | \Attribute::IS_REPEATABLE)]
+final class SlugColumn extends Column
+{
+    private array $from;
+    private string $separator;
+
+    public function __construct(
+        string $name = null,
+        array  $from = [],
+        string $separator = '-',
+        int    $length = 128,
+        bool   $nullable = false,
+        bool   $unique = true
+    )
+    {
+        if (empty($separator)) {
+            throw new \InvalidArgumentException('Slug separator cannot be empty.');
+        }
+
+        if (empty($from)) {
+            throw new \InvalidArgumentException('Slug "fields" must reference at least one source column.');
+        }
+
+        parent::__construct('', $name, StringType::class, $nullable, null, $unique, $length);
+        $this->from = $from;
+        $this->separator = $separator;
+    }
+
+    public function getFrom(): array
+    {
+        return $this->from;
+    }
+
+    public function getSeparator(): string
+    {
+        return $this->separator;
+    }
+}

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

@@ -0,0 +1,22 @@
+<?php
+
+namespace Michel\PaperORM\Mapping\Column;
+
+use Attribute;
+use Michel\PaperORM\Types\StringType;
+
+#[\Attribute(\Attribute::TARGET_PROPERTY | \Attribute::IS_REPEATABLE)]
+final class StringColumn extends Column
+{
+    public function __construct(
+        string $name = null,
+        int $length = 255,
+        bool   $nullable = false,
+        string $defaultValue = null,
+        bool $unique = false
+    )
+    {
+        parent::__construct('', $name, StringType::class, $nullable, $defaultValue, $unique, $length);
+    }
+
+}

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

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

+ 40 - 0
src/Mapping/Column/TimestampColumn.php

@@ -0,0 +1,40 @@
+<?php
+
+namespace Michel\PaperORM\Mapping\Column;
+
+use Attribute;
+use Michel\PaperORM\Types\DateTimeType;
+
+#[\Attribute(\Attribute::TARGET_PROPERTY | \Attribute::IS_REPEATABLE)]
+final class TimestampColumn extends Column
+{
+
+    private bool $onCreated;
+    private bool $onUpdated;
+    public function __construct(
+        string $name = null,
+        bool $onCreated = false,
+        bool $onUpdated = false,
+        bool   $nullable = true
+    )
+    {
+        if (!$onCreated && !$onUpdated) {
+            throw new \InvalidArgumentException(
+                'A TimestampColumn must be either onCreated or onUpdated (at least one true).'
+            );
+        }
+        parent::__construct('', $name, DateTimeType::class, $nullable);
+        $this->onCreated = $onCreated;
+        $this->onUpdated = $onUpdated;
+    }
+
+    public function isOnCreated(): bool
+    {
+        return $this->onCreated;
+    }
+
+    public function isOnUpdated(): bool
+    {
+        return $this->onUpdated;
+    }
+}

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

@@ -0,0 +1,34 @@
+<?php
+
+namespace Michel\PaperORM\Mapping\Column;
+
+use Michel\PaperORM\Types\StringType;
+
+#[\Attribute(\Attribute::TARGET_PROPERTY | \Attribute::IS_REPEATABLE)]
+final class TokenColumn extends Column
+{
+
+    private int $length;
+    public function __construct(
+        string $name = null,
+        int    $length = 128,
+        bool   $nullable = false,
+        ?int $defaultValue = null
+    )
+    {
+        if (!in_array($length, [16, 32, 64, 128])) {
+            throw new \InvalidArgumentException(sprintf(
+                'Token length must be 16, 32, 64 or 128, got %s.',
+                $length
+            ));
+        }
+        parent::__construct('', $name, StringType::class, $nullable, $defaultValue, true,$length);
+
+        $this->length = $length;
+    }
+
+    public function getLength(): int
+    {
+        return $this->length;
+    }
+}

+ 18 - 0
src/Mapping/Column/UuidColumn.php

@@ -0,0 +1,18 @@
+<?php
+
+namespace Michel\PaperORM\Mapping\Column;
+
+use Michel\PaperORM\Types\StringType;
+
+#[\Attribute(\Attribute::TARGET_PROPERTY | \Attribute::IS_REPEATABLE)]
+final class UuidColumn extends Column
+{
+    public function __construct(
+        string $name = null,
+        bool   $nullable = false,
+        ?int $defaultValue = null
+    )
+    {
+        parent::__construct('', $name, StringType::class, $nullable, $defaultValue, true, 36);
+    }
+}

+ 27 - 0
src/Mapping/Entity.php

@@ -0,0 +1,27 @@
+<?php
+
+namespace Michel\PaperORM\Mapping;
+
+#[\Attribute(\Attribute::TARGET_CLASS)]
+final class Entity
+{
+    private string $table;
+    private ?string $repositoryClass = null;
+
+    public function __construct( string $table, ?string $repository = null)
+    {
+        $this->table = trim($table, '`');
+        $this->repositoryClass = $repository;
+    }
+
+    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 Michel\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;
+    }
+}

+ 70 - 0
src/Mapping/OneToMany.php

@@ -0,0 +1,70 @@
+<?php
+
+namespace Michel\PaperORM\Mapping;
+
+use Attribute;
+use LogicException;
+use Michel\PaperORM\Collection\ObjectStorage;
+
+#[Attribute(Attribute::TARGET_PROPERTY | Attribute::IS_REPEATABLE)]
+final class OneToMany
+{
+    private ?string $property = null;
+    private string $targetEntity;
+    private ?string $mappedBy;
+    private array $criteria;
+    private ObjectStorage $storage;
+
+    final public function __construct(string $targetEntity, string $mappedBy = null, array $criteria = [])
+    {
+        $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
+    {
+        if (empty($this->property)) {
+            throw  new \LogicException('Property must be set, use bindProperty');
+        }
+        return $this->property;
+    }
+
+    public function bindProperty(string $propertyName): self
+    {
+        $this->property = $propertyName;
+        return $this;
+    }
+}

+ 173 - 0
src/Metadata/ColumnMetadata.php

@@ -0,0 +1,173 @@
+<?php
+
+namespace Michel\PaperORM\Metadata;
+
+use Michel\PaperORM\Entity\EntityInterface;
+use Michel\PaperORM\Mapper\EntityMapper;
+use Michel\PaperORM\Mapping\Column\Column;
+use Michel\PaperORM\Mapping\Column\JoinColumn;
+use Michel\PaperORM\Mapping\Column\PrimaryKeyColumn;
+use Michel\PaperORM\Metadata\ForeignKeyMetadata;
+
+class ColumnMetadata
+{
+    private string $name;
+    private string $type;
+    private bool $isPrimary;
+    private ?ForeignKeyMetadata $foreignKeyMetadata = null;
+    private bool $isNullable;
+    private $defaultValue;
+    private ?string $comment;
+    private array $attributes;
+    private ?IndexMetadata $indexMetadata;
+
+    public function __construct(
+        string              $name,
+        string              $type,
+        bool                $isPrimary = false,
+        bool                $isNullable = true,
+                            $defaultValue = null,
+        ?ForeignKeyMetadata $foreignKeyMetadata = null,
+        ?string             $comment = null,
+        array               $attributes = []
+    )
+    {
+        $this->name = $name;
+        $this->type = strtoupper($type);
+        $this->isPrimary = $isPrimary;
+        $this->isNullable = $isNullable;
+        $this->defaultValue = $defaultValue;
+        $this->foreignKeyMetadata = $foreignKeyMetadata;
+        $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(): ?ForeignKeyMetadata
+    {
+        return $this->foreignKeyMetadata;
+    }
+
+    public function isNullable(): bool
+    {
+        return $this->isNullable;
+    }
+
+    public function getDefaultValue()
+    {
+        $value = $this->defaultValue;
+        if (is_bool($value)) {
+            return intval($value);
+        }
+        return $value;
+    }
+
+    public function getDefaultValuePrintable()
+    {
+        $value = $this->defaultValue;
+        if (is_bool($value)) {
+            return $value ? '1' : '0';
+        }
+        if (is_string($value)) {
+            return "'$value'";
+        }
+        return $value;
+    }
+
+    public function getComment(): ?string
+    {
+        return $this->comment;
+    }
+
+    public function getAttributes(): array
+    {
+        return $this->attributes;
+    }
+
+    public static function fromColumn(
+        Column $column,
+        string $sqlType,
+        ?ForeignKeyMetadata $foreignKeyMetadata = null,
+        ?string $defaultFirstArgument = null,
+        ?string $defaultSecondArgument = null
+    ): self
+    {
+        $arguments = [];
+        if ($column->getFirstArgument()) {
+            $arguments[] = $column->getFirstArgument();
+        }elseif ($defaultFirstArgument) {
+            $arguments[] = $defaultFirstArgument;
+        }
+        if ($column->getSecondArgument()) {
+            $arguments[] = $column->getSecondArgument();
+        }elseif ($defaultSecondArgument) {
+            $arguments[] = $defaultSecondArgument;
+        }
+
+        $defaultValue = $column->getDefaultValue();
+        if (is_array($defaultValue)) {
+            $defaultValue = json_encode($defaultValue);
+        }
+        return new self(
+            $column->getName(),
+            $sqlType,
+            $column instanceof PrimaryKeyColumn,
+            $column->isNullable(),
+            $defaultValue,
+            $foreignKeyMetadata,
+            null,
+            $arguments
+        );
+    }
+
+    public static function fromArray(array $data): self
+    {
+        return new self(
+            $data['name'],
+            $data['type'],
+            $data['primary'] ?? false,
+            $data['null'] ?? true,
+        $data['default'] ?? null,
+            $data['foreignKeyMetadata'] ?? null,
+        $data['comment'] ?? null,
+            $data['attributes'] ?? []
+        );
+    }
+
+    public function toArray(): array
+    {
+        return [
+            'name' => $this->getName(),
+            'type' => $this->getType(),
+            'primary' => $this->isPrimary(),
+            'null' => $this->isNullable(),
+            'default' => $this->getDefaultValue(),
+            'foreignKeyMetadata' => $this->getForeignKeyMetadata() ? $this->getForeignKeyMetadata()->toArray() : null,
+            'comment' => $this->getComment(),
+            'attributes' => $this->getAttributes(),
+        ];
+    }
+}

+ 219 - 0
src/Metadata/DatabaseSchemaDiffMetadata.php

@@ -0,0 +1,219 @@
+<?php
+
+namespace Michel\PaperORM\Metadata;
+
+use LogicException;
+use Michel\PaperORM\Metadata\ForeignKeyMetadata;
+
+final class DatabaseSchemaDiffMetadata
+{
+    private array $columnsToAdd = [];
+    private array $columnsToUpdate = [];
+    private array $columnsToDelete = [];
+    private array $originalColumns = [];
+
+
+    private array $foreignKeyToAdd = [];
+    private array $foreignKeyToDrop = [];
+    private array $originalForeignKeys = [];
+
+
+    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 $foreignKeyToAdd,
+        array $foreignKeyToDrop,
+        array $originalForeignKeys,
+        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 ($foreignKeyToAdd as $foreignKey) {
+            if (!$foreignKey instanceof ForeignKeyMetadata) {
+                throw new LogicException(sprintf("The foreign key '%s' is not supported.", get_class($foreignKey)));
+            }
+            $this->foreignKeyToAdd[$foreignKey->getName()] = $foreignKey;
+        }
+
+
+        foreach ($foreignKeyToDrop as $foreignKey) {
+            if (!$foreignKey instanceof ForeignKeyMetadata) {
+                throw new LogicException(sprintf("The foreign key '%s' is not supported.", get_class($foreignKey)));
+            }
+            $this->foreignKeyToDrop[$foreignKey->getName()] = $foreignKey;
+        }
+
+        foreach ($originalForeignKeys as $foreignKey) {
+            if (!$foreignKey instanceof ForeignKeyMetadata) {
+                throw new LogicException(sprintf("The foreign key '%s' is not supported.", get_class($foreignKey)));
+            }
+            $this->originalForeignKeys[$foreignKey->getName()] = $foreignKey;
+        }
+
+
+        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 array<ForeignKeyMetadata>
+     */
+    public function getForeignKeyToAdd(): array
+    {
+        return $this->foreignKeyToAdd;
+    }
+
+    /**
+     * @return array<ForeignKeyMetadata>
+     */
+    public function getForeignKeyToDrop(): array
+    {
+        return $this->foreignKeyToDrop;
+    }
+
+    public function getOriginalForeignKey(string $name): ForeignKeyMetadata
+    {
+        if (!isset($this->originalForeignKeys[$name])) {
+            throw new LogicException(sprintf("The foreign key '%s' is not supported.", $name));
+        }
+        return $this->originalForeignKeys[$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];
+    }
+
+}

+ 82 - 0
src/Metadata/ForeignKeyMetadata.php

@@ -0,0 +1,82 @@
+<?php
+
+namespace Michel\PaperORM\Metadata;
+
+final class ForeignKeyMetadata
+{
+    public const NO_ACTION   = 0;
+    public const RESTRICT    = 1;
+    public const CASCADE     = 2;
+    public const SET_NULL    = 3;
+    public const SET_DEFAULT = 4;
+
+    private array $columns;
+    private string $referenceTable;
+    private array $referenceColumns;
+    private ?string $name;
+
+    private int $onDelete;
+    private int $onUpdate;
+    public function __construct(array $columns, string $referenceTable, array $referenceColumns, int $onDelete = self::NO_ACTION, int $onUpdate = self::NO_ACTION, ?string $name = null)
+    {
+        $this->columns = $columns;
+        $this->referenceTable = $referenceTable;
+        $this->referenceColumns = $referenceColumns;
+        $this->name = $name;
+        $this->onDelete = $onDelete;
+        $this->onUpdate = $onUpdate;
+    }
+
+    public function getColumns(): array
+    {
+        return $this->columns;
+    }
+
+    public function getReferenceTable(): string
+    {
+        return $this->referenceTable;
+    }
+
+    public function getReferenceColumns(): array
+    {
+        return $this->referenceColumns;
+    }
+
+
+    public function getName(): ?string
+    {
+        return $this->name;
+    }
+
+    public function getOnDelete(): int
+    {
+        return $this->onDelete;
+    }
+
+    public function getOnUpdate(): int
+    {
+        return $this->onUpdate;
+    }
+
+    public static function fromArray(array $data): ForeignKeyMetadata
+    {
+        return new ForeignKeyMetadata($data['columns'], $data['referenceTable'], $data['referenceColumns'], $data['onDelete'] ?? self::NO_ACTION, $data['onUpdate'] ?? self::NO_ACTION, $data['name'] ?? null);
+    }
+
+    public static function fromForeignKeyMetadataOverrideName(ForeignKeyMetadata $foreignKey, string $name): ForeignKeyMetadata
+    {
+        return new ForeignKeyMetadata($foreignKey->getColumns(), $foreignKey->getReferenceTable(), $foreignKey->getReferenceColumns(),$foreignKey->getOnDelete(), $foreignKey->getOnUpdate(), $name);
+    }
+
+    public function toArray() : array
+    {
+        return [
+            'name' => $this->getName(),
+            'columns' => $this->getColumns(),
+            'referenceTable' => $this->getReferenceTable(),
+            'referenceColumns' => $this->getReferenceColumns(),
+            'onDelete' => $this->getOnDelete(),
+            'onUpdate' => $this->getOnUpdate(),
+        ];
+    }
+}

+ 61 - 0
src/Metadata/IndexMetadata.php

@@ -0,0 +1,61 @@
+<?php
+
+namespace Michel\PaperORM\Metadata;
+
+use Michel\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()
+        ];
+    }
+}

+ 123 - 0
src/Michel/Package/MichelPaperORMPackage.php

@@ -0,0 +1,123 @@
+<?php
+
+namespace Michel\PaperORM\Michel\Package;
+
+use Michel\Michel\Package\PackageInterface;
+use Michel\PaperORM\Collector\EntityDirCollector;
+use Michel\PaperORM\Command\DatabaseCreateCommand;
+use Michel\PaperORM\Command\DatabaseDropCommand;
+use Michel\PaperORM\Command\DatabaseSyncCommand;
+use Michel\PaperORM\Command\Migration\MigrationDiffCommand;
+use Michel\PaperORM\Command\Migration\MigrationMigrateCommand;
+use Michel\PaperORM\Command\QueryExecuteCommand;
+use Michel\PaperORM\Command\ShowTablesCommand;
+use Michel\PaperORM\EntityManager;
+use Michel\PaperORM\EntityManagerInterface;
+use Michel\PaperORM\Migration\PaperMigration;
+use Michel\PaperORM\PaperConfiguration;
+use Psr\Container\ContainerInterface;
+use Psr\Log\LoggerInterface;
+
+class MichelPaperORMPackage implements PackageInterface
+{
+    public function getDefinitions(): array
+    {
+        return [
+            PaperConfiguration::class => static function (ContainerInterface $container) {
+                $paperConf = PaperConfiguration::fromDsn(
+                    $container->get('paper.orm.dsn'),
+                    $container->get('paper.orm.debug')
+                );
+                $logger = $container->get('paper.orm.logger');
+                if ($logger) {
+                    $paperConf->withLogger($container->get('paper.orm.logger'));
+                }
+                return $paperConf;
+            },
+            EntityDirCollector::class => static function (ContainerInterface $container) {
+                return EntityDirCollector::bootstrap([$container->get('paper.orm.entity_dir')]);
+            },
+            EntityManagerInterface::class => static function (ContainerInterface $container) {
+                return $container->get(EntityManager::class);
+            },
+            EntityManager::class => static function (ContainerInterface $container) {
+                return EntityManager::createFromConfig($container->get(PaperConfiguration::class));
+            },
+            PaperMigration::class => static function (ContainerInterface $container) {
+                return PaperMigration::create(
+                    $container->get(EntityManagerInterface::class),
+                    $container->get('paper.orm.migrations_table'),
+                    $container->get('paper.orm.migrations_dir')
+                );
+            },
+            MigrationDiffCommand::class => static function (ContainerInterface $container) {
+                return new MigrationDiffCommand($container->get(PaperMigration::class), $container->get(EntityDirCollector::class));
+            },
+            DatabaseDropCommand::class => static function (ContainerInterface $container) {
+                return new DatabaseDropCommand($container->get(EntityManagerInterface::class), $container->get('michel.environment'));
+            },
+            DatabaseSyncCommand::class => static function (ContainerInterface $container) {
+                return new DatabaseSyncCommand($container->get(PaperMigration::class), $container->get(EntityDirCollector::class), $container->get('michel.environment'));
+            }
+        ];
+    }
+
+    public function getParameters(): array
+    {
+        return [
+            'paper.orm.dsn' => getenv('DATABASE_URL') ?? '',
+            'paper.orm.debug' => static function (ContainerInterface $container) {
+                return $container->get('michel.debug');
+            },
+            'paper.orm.logger' => static function (ContainerInterface $container) {
+                if ($container->has(LoggerInterface::class)) {
+                    return  $container->get(LoggerInterface::class);
+                }
+                return null;
+            },
+            'paper.orm.entity_dir' => getenv('PAPER_ORM_ENTITY_DIR') ?: static function (ContainerInterface $container) {
+                $folder = $container->get('michel.project_dir') . DIRECTORY_SEPARATOR . 'src' . DIRECTORY_SEPARATOR . 'Entity';
+                if (!is_dir($folder)) {
+                    mkdir($folder, 0777, true);
+                }
+                return $folder;
+            },
+            'paper.orm.migrations_dir' => getenv('PAPER_ORM_MIGRATIONS_DIR') ?: static function (ContainerInterface $container) {
+                $folder = $container->get('michel.project_dir') . DIRECTORY_SEPARATOR . 'migrations';
+                if (!is_dir($folder)) {
+                    mkdir($folder, 0777, true);
+                }
+                return $folder;
+            },
+            'paper.orm.migrations_table' => getenv('PAPER_ORM_MIGRATIONS_TABLE') ?: 'paper_mig_version',
+        ];
+    }
+
+    public function getRoutes(): array
+    {
+        return [];
+    }
+
+    public function getControllerSources(): array
+    {
+        return [];
+    }
+
+    public function getListeners(): array
+    {
+        return [];
+    }
+
+    public function getCommandSources(): array
+    {
+        return [
+            DatabaseCreateCommand::class,
+            DatabaseDropCommand::class,
+            DatabaseSyncCommand::class,
+            MigrationDiffCommand::class,
+            MigrationMigrateCommand::class,
+            QueryExecuteCommand::class,
+            ShowTablesCommand::class,
+        ];
+    }
+}

+ 54 - 0
src/Migration/MigrationDirectory.php

@@ -0,0 +1,54 @@
+<?php
+
+namespace Michel\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;
+    }
+}

+ 310 - 0
src/Migration/PaperMigration.php

@@ -0,0 +1,310 @@
+<?php
+
+namespace Michel\PaperORM\Migration;
+
+use DateTime;
+use PDOException;
+use Michel\PaperORM\Entity\EntityInterface;
+use Michel\PaperORM\EntityManagerInterface;
+use Michel\PaperORM\Generator\SchemaDiffGenerator;
+use Michel\PaperORM\Mapper\ColumnMapper;
+use Michel\PaperORM\Mapper\EntityMapper;
+use Michel\PaperORM\Mapping\Column\DateTimeColumn;
+use Michel\PaperORM\Mapping\Column\StringColumn;
+use Michel\PaperORM\PaperConnection;
+use Michel\PaperORM\Platform\PlatformInterface;
+use RuntimeException;
+use Throwable;
+use function date;
+use function file_get_contents;
+use function file_put_contents;
+
+final class PaperMigration
+{
+
+    /** @var EntityManagerInterface The EntityManager to use for migrations. */
+    private EntityManagerInterface $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(EntityManagerInterface $em, string $tableName, string $directory): self
+    {
+        return new self($em, $tableName, $directory);
+    }
+
+    /**
+     * MigrateService constructor.
+     * @param EntityManagerInterface $em
+     * @param string $tableName
+     * @param string $directory
+     */
+    private function __construct(EntityManagerInterface $em, string $tableName, string $directory)
+    {
+        $this->em = $em;
+        $this->platform = $em->getPlatform();
+        $this->tableName = $tableName;
+        $this->directory = new MigrationDirectory($directory);
+    }
+
+    /**
+     * @return array<string>
+     */
+    public function getVersionAlreadyMigrated(): array
+    {
+        $version = $this->getConnection()->fetchAll('SELECT version FROM ' . $this->tableName);
+        return array_column($version, 'version');
+    }
+
+    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->getVersionAlreadyMigrated();
+        foreach ($this->directory->getMigrations() as $version => $migration) {
+
+            if (in_array($version, $versions)) {
+                continue;
+            }
+
+            $this->up($version);
+            $this->successList[] = $version;
+        }
+    }
+
+    public function generateMigrationFromEntities(array $entities): ?string
+    {
+        $tables = self::transformEntitiesToTables($entities);
+        $diff = $this->computeDiffTables($tables);
+
+        if (!$diff) {
+            return null;
+        }
+
+        return $this->generateMigration($diff['up'], $diff['down']);
+    }
+
+    /**
+     * Compute the SQL diff (UP part) for preview only.
+     * Does not generate any file.
+     */
+    public function getSqlDiffFromEntities(array $entities): array
+    {
+        $tables = self::transformEntitiesToTables($entities);
+        $diff = $this->computeDiffTables($tables);
+
+        return $diff['up'] ?? [];
+    }
+
+    /**
+     * Generate and write a migration file based on table differences.
+     * Returns the path of the generated file.
+     */
+    public function generateMigrationFromTables(array $tables): ?string
+    {
+        $diff = $this->computeDiffTables($tables);
+        if (!$diff) {
+            return null;
+        }
+
+        return $this->generateMigration($diff['up'], $diff['down']);
+    }
+
+    public function up(string $version): void
+    {
+        $migration = $this->directory->getMigration($version);
+        $txDdl = $this->platform->supportsTransactionalDDL();
+        $conn = $this->getConnection();
+        $pdo = $conn->getPdo();
+        try {
+            if ($txDdl && !$pdo->inTransaction()) {
+                $pdo->beginTransaction();
+            }
+            $executedQueries = [];
+            foreach (explode(';' . PHP_EOL, self::contentUp($migration)) as $query) {
+                $executed = $this->executeQuery($query);
+                if ($executed === false) {
+                    continue;
+                }
+                $executedQueries[] = $query;
+            }
+            if ($executedQueries === []) {
+                throw new RuntimeException("Failed to execute any query for version : " . $version);
+            }
+
+            $createdAt = (new DateTime())->format($this->platform->getSchema()->getDateTimeFormatString());
+            $rows = $conn->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);
+            }
+
+            if ($pdo->inTransaction()) {
+                $pdo->commit();
+            }
+        } catch (Throwable $e) {
+            if ($pdo->inTransaction()) {
+                $pdo->rollBack();
+            }
+            throw new RuntimeException("Failed to migrate version $version : " . $e->getMessage());
+        }
+
+    }
+
+    public function down(string $version): void
+    {
+        $migration = $this->directory->getMigration($version);
+        $txDdl = $this->platform->supportsTransactionalDDL();
+        $conn = $this->getConnection();
+        $pdo = $conn->getPdo();
+        $currentQuery = '';
+        try {
+            if ($txDdl && !$pdo->inTransaction()) {
+                $pdo->beginTransaction();
+            }
+            foreach (explode(';' . PHP_EOL, self::contentDown($migration)) as $query) {
+                $currentQuery = $query;
+                $this->executeQuery($query);
+            }
+            $conn->executeStatement('DELETE FROM ' . $this->tableName . ' WHERE version = :version', ['version' => $version]);
+
+            if ($pdo->inTransaction()) {
+                $pdo->commit();
+            }
+
+        } catch (PDOException $e) {
+            if ($pdo->inTransaction()) {
+                $pdo->rollBack();
+            }
+            throw new RuntimeException(sprintf('Failed to migrate version %s : %s -> %s', $version, $e->getMessage(), $currentQuery));
+        }
+
+    }
+
+    public function getSuccessList(): array
+    {
+        return $this->successList;
+    }
+
+    public function getEntityManager(): EntityManagerInterface
+    {
+        return $this->em;
+    }
+
+    private function createVersion(): void
+    {
+        $this->platform->createTableIfNotExists($this->tableName, [
+            new StringColumn('version', 50),
+            new DateTimeColumn('created_at', 'created_at'),
+        ]);
+    }
+
+    private function getConnection(): PaperConnection
+    {
+        return $this->em->getConnection();
+    }
+
+    private function executeQuery(string $query): bool
+    {
+        $query = trim($query);
+        if (str_starts_with($query = trim($query), '--')) {
+            return false;
+        }
+        $query = rtrim($query, ';') . ';';
+        $this->getConnection()->executeStatement($query);
+        return true;
+    }
+
+    private function computeDiffTables(array $tables): ?array
+    {
+        $statements = (new SchemaDiffGenerator($this->platform))->generateDiffStatements($tables);
+
+        if (empty($statements['up'])) {
+            return null;
+        }
+
+        return [
+            'up'   => $statements['up'],
+            'down' => $statements['down'] ?? '',
+        ];
+    }
+
+    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]) : '')];
+    }
+
+    private static function transformEntitiesToTables(array $entities): array
+    {
+        $tables = [];
+        foreach ($entities as $entity) {
+            if (is_subclass_of($entity, EntityInterface::class)) {
+                $tableName = EntityMapper::getTable($entity);
+                $tables[$tableName] = [
+                    'columns' => ColumnMapper::getColumns($entity),
+                    'indexes' => [] // TODO IndexMapper::getIndexes($entity)
+                ];
+            }
+        }
+
+        return $tables;
+    }
+}

+ 98 - 0
src/PaperConfiguration.php

@@ -0,0 +1,98 @@
+<?php
+
+namespace Michel\PaperORM;
+
+use LogicException;
+use Michel\EventDispatcher\ListenerProvider;
+use Michel\PaperORM\Cache\EntityMemcachedCache;
+use Michel\PaperORM\Driver\DriverManager;
+use Michel\PaperORM\Event\Create\PostCreateEvent;
+use Michel\PaperORM\Event\Create\PreCreateEvent;
+use Michel\PaperORM\Event\Update\PreUpdateEvent;
+use Michel\PaperORM\EventListener\PostCreateEventListener;
+use Michel\PaperORM\EventListener\PreCreateEventListener;
+use Michel\PaperORM\EventListener\PreUpdateEventListener;
+use Michel\PaperORM\Parser\DSNParser;
+use Psr\EventDispatcher\ListenerProviderInterface;
+use Psr\Log\LoggerInterface;
+
+final class PaperConfiguration
+{
+    private PaperConnection $connection;
+    private UnitOfWork $unitOfWork;
+    private EntityMemcachedCache $cache;
+    private ListenerProviderInterface $listeners;
+    private function __construct(
+        PaperConnection           $connection,
+        UnitOfWork                $unitOfWork,
+        EntityMemcachedCache      $cache,
+        ListenerProviderInterface $listeners
+    )
+    {
+        $this->connection = $connection;
+        $this->unitOfWork = $unitOfWork;
+        $this->cache = $cache;
+        $this->listeners = $listeners;
+        $this->registerDefaultListeners();
+    }
+
+    public static function fromDsn(string $dsn, bool $debug = false): self
+    {
+        if ($dsn === '') {
+            throw new LogicException('PaperConfiguration::fromDsn(): DSN cannot be empty.');
+        }
+
+        $params = DSNParser::parse($dsn);
+        return self::fromArray($params, $debug);
+    }
+
+    public static function fromArray(array $params, bool $debug = false): self
+    {
+        $params['extra']['debug'] = $debug;
+        $connection = DriverManager::createConnection($params['driver'], $params);
+        return new self($connection, new UnitOfWork(), new EntityMemcachedCache(), new ListenerProvider());
+    }
+
+    public function withLogger(LoggerInterface $logger): self
+    {
+        $this->connection->withLogger($logger);
+        return $this;
+    }
+
+    public function withListener(string $event, callable $listener): self
+    {
+        $provider = $this->listeners;
+        $provider->addListener($event, $listener);
+
+        return $this;
+    }
+
+    public function getConnection(): PaperConnection
+    {
+        return $this->connection;
+    }
+
+    public function getUnitOfWork(): UnitOfWork
+    {
+        return $this->unitOfWork;
+    }
+
+    public function getCache(): EntityMemcachedCache
+    {
+        return $this->cache;
+    }
+
+    public function getListeners(): ListenerProviderInterface
+    {
+        return $this->listeners;
+    }
+
+    private function registerDefaultListeners(): void
+    {
+        $this->listeners
+            ->addListener(PreCreateEvent::class, new PreCreateEventListener())
+            ->addListener(PostCreateEvent::class, new PostCreateEventListener())
+            ->addListener(PreUpdateEvent::class, new PreUpdateEventListener())
+        ;
+    }
+}

+ 122 - 0
src/PaperConnection.php

@@ -0,0 +1,122 @@
+<?php
+
+namespace Michel\PaperORM;
+
+use Exception;
+use LogicException;
+use PDO;
+use PDOStatement;
+use Michel\PaperORM\Driver\DriverInterface;
+use Michel\PaperORM\Pdo\PaperPDO;
+use Psr\Log\LoggerInterface;
+
+final class PaperConnection
+{
+    private ?PaperPDO $pdo = null;
+
+    private array $params;
+
+    private DriverInterface $driver;
+
+    private bool $debug;
+    private ?LoggerInterface $logger = null;
+
+    public function __construct(DriverInterface $driver, array $params)
+    {
+        $this->params = $params;
+        $this->driver = $driver;
+        $extra = $params['extra'] ?? [];
+        $this->debug = (bool)($extra['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->isConnected()) {
+            $params = $this->params;
+            unset($params['extra']);
+            $this->pdo = $this->driver->connect($this->params);
+            if ($this->debug) {
+                $this->pdo->enableSqlDebugger($this->logger);
+            }
+            return true;
+        }
+
+        return false;
+    }
+
+    public function isConnected(): bool
+    {
+        return $this->pdo !== null;
+    }
+
+    public function close(): void
+    {
+        $this->pdo = null;
+    }
+
+    public function cloneConnectionWithoutDbname(): self
+    {
+        $params = $this->params;
+        unset($params['path']);
+        return new self($this->driver, $params);
+    }
+
+    public function withLogger(LoggerInterface $logger): self
+    {
+        $this->logger = $logger;
+        return $this;
+    }
+}

+ 73 - 0
src/Parser/DSNParser.php

@@ -0,0 +1,73 @@
+<?php
+
+namespace Michel\PaperORM\Parser;
+
+final class DSNParser
+{
+
+    public static function parse(string $dsn): array
+    {
+        if (str_starts_with($dsn, 'sqlite:')) {
+            $rest = substr($dsn, 7);
+
+            $memory = false;
+            $options = [];
+
+            if (str_contains($rest, '?')) {
+                [$path, $query] = explode('?', $rest, 2);
+                parse_str($query, $options);
+            } else {
+                $path = $rest;
+            }
+
+            $path = ltrim($path, '/');
+            if ($path === '' || $path === 'memory' || $path === ':memory:') {
+                $memory = true;
+                $path = null;
+            } elseif (str_starts_with($rest, '///')) {
+                    $path = '/' . $path;
+            }
+
+            return [
+                'driver' => 'sqlite',
+                'path' => $path,
+                'memory' => $memory,
+                'options' => $options,
+            ];
+        }
+
+        $params = parse_url($dsn);
+        if ($params === false) {
+            throw new \InvalidArgumentException("Unable to parse DSN: $dsn");
+        }
+
+
+        $options = [];
+        if (isset($params['query'])) {
+            parse_str($params['query'], $options);
+            unset($params['query']);
+        }
+        $driver = $params['scheme'] ?? null;
+        $host = $params['host'] ?? null;
+        $port = isset($params['port']) ? (int) $params['port'] : null;
+        $user = $params['user'] ?? null;
+        $password = $params['pass'] ?? null;
+        $path = $params['path'] ?? null;
+        if ($path !== null && $path[0] === '/') {
+            $path = substr($path, 1);
+        }
+        $isMemory = ($path === 'memory' || $path === ':memory:');
+
+        return [
+            'driver' => $driver,
+            'host' => $host,
+            'port' => $port,
+            'user' => $user,
+            'password' => $password,
+            'path' => $path,
+            'memory' => $isMemory,
+            'options' => $options
+        ];
+    }
+
+}

+ 41 - 0
src/Parser/SQLTypeParser.php

@@ -0,0 +1,41 @@
+<?php
+
+namespace Michel\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);
+    }
+
+}

+ 30 - 0
src/Pdo/PaperPDO.php

@@ -0,0 +1,30 @@
+<?php
+
+namespace Michel\PaperORM\Pdo;
+
+use Michel\PaperORM\Debugger\PDOStatementLogger;
+use Michel\PaperORM\Debugger\SqlDebugger;
+use Psr\Log\LoggerInterface;
+
+final class PaperPDO extends \PDO
+{
+    private ?SqlDebugger $debug = null;
+
+    public function enableSqlDebugger(?LoggerInterface $logger = null) : void
+    {
+        if ($this->debug === null) {
+            $this->debug = new SqlDebugger($logger);
+        }
+        $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;
+    }
+}

+ 197 - 0
src/Persistence/EntityPersistence.php

@@ -0,0 +1,197 @@
+<?php
+
+namespace Michel\PaperORM\Persistence;
+
+use Michel\PaperORM\Entity\EntityInterface;
+use Michel\PaperORM\EntityManagerInterface;
+use Michel\PaperORM\Event\Create\PostCreateEvent;
+use Michel\PaperORM\Event\Create\PreCreateEvent;
+use Michel\PaperORM\Event\Delete\PostDeleteEvent;
+use Michel\PaperORM\Event\Delete\PreDeleteEvent;
+use Michel\PaperORM\Event\Update\PostUpdateEvent;
+use Michel\PaperORM\Event\Update\PreUpdateEvent;
+use Michel\PaperORM\Hydrator\ReadOnlyEntityHydrator;
+use Michel\PaperORM\Mapper\ColumnMapper;
+use Michel\PaperORM\Mapper\EntityMapper;
+use Michel\PaperORM\Platform\PlatformInterface;
+use Michel\PaperORM\Proxy\ProxyInterface;
+use Michel\PaperORM\Serializer\SerializerToDb;
+use Michel\SqlMapper\QueryBuilder;
+use Psr\EventDispatcher\EventDispatcherInterface;
+
+class EntityPersistence
+{
+    private EntityManagerInterface $em;
+
+    private PlatformInterface $platform;
+    private \SplObjectStorage $managed;
+
+    private ?EventDispatcherInterface $dispatcher;
+    public function __construct(EntityManagerInterface $em, EventDispatcherInterface $dispatcher = null)
+    {
+        $this->em = $em;
+        $this->platform = $em->getPlatform();
+        $this->dispatcher = $dispatcher;
+        $this->managed = new \SplObjectStorage();
+    }
+
+    public function insert(object $entity): int
+    {
+        /**
+         * @var EntityInterface $entity
+         */
+        $this->checkEntity($entity);
+        if ($entity->getPrimaryKeyValue() !== null || $this->managed->contains($entity)) {
+            throw new \LogicException(sprintf(
+                '%s cannot insert entity of type "%s": entity already managed (has primary key, is a proxy, or is attached).',
+                static::class,
+                get_class($entity)
+            ));
+        }
+
+        if ($this->dispatcher) {
+            $this->dispatcher->dispatch(new PreCreateEvent($this->em, $entity));
+        }
+
+        $schema = $this->platform->getSchema();
+        $tableName = EntityMapper::getTable($entity);
+        $qb = QueryBuilder::insert($schema->quote($tableName));
+
+        $values = [];
+        foreach ((new SerializerToDb($entity, $schema))->serialize() as $key => $value) {
+            $qb->setValue($schema->quote($key), ":$key");
+            $values[$key] = $value;
+        }
+        $rows = $this->execute($qb, $values);
+        $conn = $this->platform->getConnection();
+        $lastInsertId = $conn->getPdo()->lastInsertId();
+        if ($rows > 0) {
+            $primaryKeyColumn = ColumnMapper::getPrimaryKeyColumnName($entity);
+            (new ReadOnlyEntityHydrator($schema))->hydrate($entity, [$primaryKeyColumn => $lastInsertId]);
+            $this->managed->attach($entity);
+            if ($this->dispatcher) {
+                $this->dispatcher->dispatch(new PostCreateEvent($this->em, $entity));
+            }
+        }
+        return $rows;
+    }
+
+    public function update(object $entity): int
+    {
+        /**
+         * @var ProxyInterface|EntityInterface $entity
+         */
+        $this->checkEntity($entity, true);
+        if ($entity->getPrimaryKeyValue() === null) {
+            throw new \LogicException(static::class . sprintf(' Cannot update an entity %s without a primary key ', get_class($entity)));
+        }
+
+        if ($entity instanceof ProxyInterface) {
+            if (!$entity->__wasModified()) {
+                return 0;
+            }
+        }
+
+        if ($this->dispatcher) {
+            $this->dispatcher->dispatch(new PreUpdateEvent($this->em, $entity));
+        }
+
+        if ($entity instanceof ProxyInterface) {
+            $propertiesModified = $entity->__getPropertiesModified();
+        } else {
+            $propertiesModified = [];
+        }
+
+        $tableName = EntityMapper::getTable($entity);
+        $schema = $this->platform->getSchema();
+        $qb = QueryBuilder::update($schema->quote($tableName))
+            ->where(
+                sprintf('%s = %s',
+                    $schema->quote(ColumnMapper::getPrimaryKeyColumnName($entity)),
+                    $entity->getPrimaryKeyValue()
+                )
+            );
+
+        $values = [];
+        foreach ((new SerializerToDb($entity, $schema))->serialize($propertiesModified) as $key => $value) {
+            $qb->set($schema->quote($key), ":$key");
+            $values[$key] = $value;
+        }
+        $rows = $this->execute($qb, $values);
+        if ($rows > 0) {
+            if ($entity instanceof ProxyInterface) {
+                $entity->__reset();
+            }
+            if ($this->dispatcher) {
+                $this->dispatcher->dispatch(new PostUpdateEvent($this->em, $entity));
+            }
+        }
+        return $rows;
+    }
+
+    public function delete(object $entity): int
+    {
+        /**
+         * @var ProxyInterface|EntityInterface $entity
+         */
+        $this->checkEntity($entity, true);
+        if ($entity->getPrimaryKeyValue() === null) {
+            throw new \LogicException(static::class . sprintf(' Cannot delete an entity %s without a primary key ', get_class($entity)));
+        }
+
+        if ($this->dispatcher) {
+            $this->dispatcher->dispatch(new PreDeleteEvent($this->em, $entity));
+        }
+
+        $tableName = EntityMapper::getTable($entity);
+        $schema = $this->platform->getSchema();
+        $qb = QueryBuilder::delete($schema->quote($tableName))
+            ->where(sprintf('%s = :id', $schema->quote(ColumnMapper::getPrimaryKeyColumnName($entity))));
+
+        $rows = $this->execute($qb, [
+            'id' => $entity->getPrimaryKeyValue(),
+        ]);
+        if ($rows > 0) {
+            if ($entity instanceof ProxyInterface) {
+                $entity->__destroy();
+            }
+            if ($this->managed->contains($entity)) {
+                $this->managed->detach($entity);
+            }
+
+            if ($this->dispatcher) {
+                $this->dispatcher->dispatch(new PostDeleteEvent($this->em, $entity));
+            }
+        }
+        return $rows;
+    }
+
+    private function execute(string $query, array $params = []): int
+    {
+        $conn = $this->platform->getConnection();
+        return $conn->executeStatement($query, $params);
+    }
+
+    private function checkEntity(object $entity,  bool $requireManaged = false): void
+    {
+        if (!$entity instanceof EntityInterface) {
+            throw new \LogicException(sprintf(
+                'Invalid entity of type "%s". Expected an instance of "%s".',
+                get_class($entity),
+                EntityInterface::class
+            ));
+        }
+
+        if ($requireManaged) {
+            $isManaged = $this->managed->contains($entity);
+            $isProxy = $entity instanceof ProxyInterface && $entity->__isInitialized();
+            if (!$isManaged && !$isProxy) {
+                throw new \LogicException(sprintf(
+                    'Entity of type "%s" is not managed by ORM',
+                    get_class($entity)
+                ));
+            }
+        }
+    }
+
+}

+ 256 - 0
src/Platform/AbstractPlatform.php

@@ -0,0 +1,256 @@
+<?php
+
+namespace Michel\PaperORM\Platform;
+
+use LogicException;
+use Michel\PaperORM\Collection\ObjectStorage;
+use Michel\PaperORM\Entity\EntityInterface;
+use Michel\PaperORM\Mapper\EntityMapper;
+use Michel\PaperORM\Mapping\Column\Column;
+use Michel\PaperORM\Mapping\Column\JoinColumn;
+use Michel\PaperORM\Mapping\Column\PrimaryKeyColumn;
+use Michel\PaperORM\Mapping\Index;
+use Michel\PaperORM\Metadata\ColumnMetadata;
+use Michel\PaperORM\Metadata\DatabaseSchemaDiffMetadata;
+use Michel\PaperORM\Metadata\ForeignKeyMetadata;
+use Michel\PaperORM\Metadata\IndexMetadata;
+
+abstract class AbstractPlatform implements PlatformInterface
+{
+    final public function mapColumnsToMetadata(string $tableName, $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($tableName, $column);
+        }
+
+        return $columnsMetadata;
+    }
+
+    final public function mapColumnToMetadata(string $tableName, 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.", $className));
+        }
+
+        $mapping = $mappings[$className];
+        $sqlType = $mapping['type'];
+        $args = $mapping['args'];
+        $foreignKeyMetadata = null;
+        if ($this->getSchema()->supportsForeignKeyConstraints() && $column instanceof JoinColumn) {
+            $targetEntity = $column->getTargetEntity();
+            if (is_subclass_of($targetEntity, EntityInterface::class)) {
+                $referenceTable = EntityMapper::getTable($targetEntity);
+                $foreignKeyMetadata = ForeignKeyMetadata::fromArray([
+                    'name' => $this->generateForeignKeyName($tableName, [$column->getName()]),
+                    'columns' => [$column->getName()],
+                    'referenceTable' => $referenceTable,
+                    'referenceColumns' => [$column->getReferencedColumnName()],
+                    'onDelete' => $column->getOnDelete(),
+                    'onUpdate' => $column->getOnUpdate(),
+                ]);
+            }
+        }
+        return ColumnMetadata::fromColumn($column, $sqlType, $foreignKeyMetadata, $args[0] ?? null, $args[1] ?? null);
+    }
+
+    /**
+     * @param string $tableName
+     * @param array<Column> $columns
+     * @param array<Index> $indexes
+     * @return DatabaseSchemaDiffMetadata
+     */
+    final public function diff(string $tableName, array $columns, array $indexes): DatabaseSchemaDiffMetadata
+    {
+
+        foreach ($columns as $column) {
+            if ($column->isUnique() && $this->autoCreateIndexUniqueColumns()) {
+                $indexes[] = new Index([$column->getName()], true);
+            } elseif ($column instanceof JoinColumn && $this->autoCreateIndexJoinColumns()) {
+                $indexes[] = new Index([$column->getName()], $column->isUnique());
+            } elseif ($column instanceof PrimaryKeyColumn && $this->autoCreateIndexPrimaryKeys()) {
+                $indexes[] = new Index([$column->getName()], true);
+            }
+        }
+
+        list(
+            $columnsToAdd,
+            $columnsToUpdate,
+            $columnsToDrop,
+            $originalColumns,
+            $foreignKeyToAdd,
+            $foreignKeyToDrop,
+            $originalForeignKeys,
+            ) = $this->diffColumns($tableName, $columns);
+        list($indexesToAdd, $indexesToUpdate, $indexesToDrop, $originalIndexes) = $this->diffIndexes($tableName, $indexes);
+
+        return new DatabaseSchemaDiffMetadata(
+            $columnsToAdd,
+            $columnsToUpdate,
+            $columnsToDrop,
+            $originalColumns,
+            $foreignKeyToAdd,
+            $foreignKeyToDrop,
+            $originalForeignKeys,
+            $indexesToAdd,
+            $indexesToUpdate,
+            $indexesToDrop,
+            $originalIndexes
+        );
+    }
+
+    /**
+     * @param string $tableName
+     * @param array<Column> $columns
+     * @return array
+     *
+     */
+    private function diffColumns(string $tableName, array $columns): array
+    {
+        $columnsFromTable = $this->listTableColumns($tableName);
+        $columnsExisting = [];
+        $foreignKeysExisting = [];
+        foreach ($columnsFromTable as $columnMetadata) {
+            $columnsExisting[$columnMetadata->getName()] = $columnMetadata;
+            if ($columnMetadata->getForeignKeyMetadata()) {
+                $foreignKeysExisting[$columnMetadata->getForeignKeyMetadata()->getName()] = $columnMetadata->getForeignKeyMetadata();
+            }
+        }
+
+        $columnsToAdd = [];
+        $columnsToUpdate = [];
+        $columnsToDrop = [];
+
+        $foreignKeyToAdd = [];
+        $foreignKeyToDrop = [];
+
+        $columnsProcessed = [];
+        $foreignKeysProcessed = [];
+        foreach ($columns as $column) {
+            $columnMetadata = $this->mapColumnToMetadata($tableName, $column);
+            $willBeUpdated = false;
+            if (isset($columnsExisting[$columnMetadata->getName()])) {
+                $columnFromTable = $columnsExisting[$columnMetadata->getName()];
+                if ($columnFromTable->toArray() != $columnMetadata->toArray()) {
+                    $columnsToUpdate[] = $columnMetadata;
+                    $willBeUpdated = true;
+                }
+            } else {
+                $columnsToAdd[] = $columnMetadata;
+            }
+            $columnsProcessed[] = $columnMetadata->getName();
+            if ($columnMetadata->getForeignKeyMetadata()) {
+                $columnForeignKey = $columnMetadata->getForeignKeyMetadata();
+                $foreignKeyName = $columnForeignKey->getName();
+                if (isset($foreignKeysExisting[$foreignKeyName])) {
+                    if ($willBeUpdated || $foreignKeysExisting[$foreignKeyName]->toArray() != $columnForeignKey->toArray()) {
+                        $foreignKeyToDrop[] = $foreignKeysExisting[$foreignKeyName];
+                        $foreignKeyToAdd[] = $columnForeignKey;
+                    }
+                } else {
+                    $foreignKeyToAdd[] = $columnForeignKey;
+                }
+
+                $foreignKeysProcessed[$foreignKeyName] = true;
+            }
+        }
+
+        foreach ($columnsExisting as $columnMetadata) {
+            $willDrop = !in_array($columnMetadata->getName(), $columnsProcessed);
+            if ($willDrop) {
+                $columnsToDrop[] = $columnMetadata;
+            }
+            if ($columnMetadata->getForeignKeyMetadata()) {
+                $columnForeignKey = $columnMetadata->getForeignKeyMetadata();
+                $foreignKeyName = $columnForeignKey->getName();
+                if (($willDrop && isset($foreignKeysExisting[$foreignKeyName])) || !isset($foreignKeysProcessed[$foreignKeyName])) {
+                    $foreignKeyToDrop[] = $columnForeignKey;
+                }
+            }
+        }
+
+        $foreignKeyToAdd = array_values(array_unique($foreignKeyToAdd, SORT_REGULAR));
+        $foreignKeyToDrop = array_values(array_unique($foreignKeyToDrop, SORT_REGULAR));
+
+        return [
+            $columnsToAdd,
+            $columnsToUpdate,
+            $columnsToDrop,
+            $columnsFromTable,
+
+            $foreignKeyToAdd,
+            $foreignKeyToDrop,
+            array_values($foreignKeysExisting),
+        ];
+    }
+
+    /**
+     * @param string $tableName
+     * @param array<Index> $indexes
+     * @return array
+     */
+    private function diffIndexes(string $tableName, array $indexes): array
+    {
+        if ($this->getSchema()->supportsIndexes() === false) {
+            return [[], [], [], []];
+        }
+
+        $indexesFromTable = new ObjectStorage($this->listTableIndexes($tableName));
+        $indexesToAdd = [];
+        $indexesToUpdate = [];
+        $indexesToDrop = [];
+
+        $indexesExisting = [];
+        foreach ($indexes as $index) {
+            $indexMetadata = new IndexMetadata(
+                $tableName,
+                $index->getName() ?: $this->generateIndexName($tableName, $index->getColumns(), $index->isUnique()),
+                $index->getColumns(),
+                $index->isUnique()
+            );
+            $indexFound = $indexesFromTable->findOneByMethod('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(string $tableName, array $columnNames, bool $unique): string
+    {
+        $hash = implode('', array_map(static function ($column) {
+            return dechex(crc32($column));
+        }, array_merge([$tableName], $columnNames)));
+
+        $prefix = $unique ? $this->getPrefixUniqIndexName() : $this->getPrefixIndexName();
+        return strtoupper(substr($prefix . $hash, 0, $this->getMaxLength()));
+    }
+
+    final protected function generateForeignKeyName(string $tableName, array $columnNames): string
+    {
+        $hash = implode('', array_map(static function ($column) {
+            return dechex(crc32($column));
+        }, array_merge([$tableName], $columnNames)));
+
+        return strtoupper(substr($this->getPrefixForeignKeyName() . $hash, 0, $this->getMaxLength()));
+
+    }
+}

+ 369 - 0
src/Platform/MariaDBPlatform.php

@@ -0,0 +1,369 @@
+<?php
+
+namespace Michel\PaperORM\Platform;
+
+use LogicException;
+use Michel\PaperORM\Mapping\Column\AnyColumn;
+use Michel\PaperORM\Mapping\Column\AutoIncrementColumn;
+use Michel\PaperORM\Mapping\Column\BinaryColumn;
+use Michel\PaperORM\Mapping\Column\BoolColumn;
+use Michel\PaperORM\Mapping\Column\Column;
+use Michel\PaperORM\Mapping\Column\DateColumn;
+use Michel\PaperORM\Mapping\Column\DateTimeColumn;
+use Michel\PaperORM\Mapping\Column\DecimalColumn;
+use Michel\PaperORM\Mapping\Column\FloatColumn;
+use Michel\PaperORM\Mapping\Column\IntColumn;
+use Michel\PaperORM\Mapping\Column\JoinColumn;
+use Michel\PaperORM\Mapping\Column\JsonColumn;
+use Michel\PaperORM\Mapping\Column\PrimaryKeyColumn;
+use Michel\PaperORM\Mapping\Column\SlugColumn;
+use Michel\PaperORM\Mapping\Column\StringColumn;
+use Michel\PaperORM\Mapping\Column\TextColumn;
+use Michel\PaperORM\Mapping\Column\TimestampColumn;
+use Michel\PaperORM\Mapping\Column\TokenColumn;
+use Michel\PaperORM\Mapping\Column\UuidColumn;
+use Michel\PaperORM\Metadata\ColumnMetadata;
+use Michel\PaperORM\Metadata\ForeignKeyMetadata;
+use Michel\PaperORM\Metadata\IndexMetadata;
+use Michel\PaperORM\PaperConnection;
+use Michel\PaperORM\Parser\SQLTypeParser;
+use Michel\PaperORM\Schema\MariaDBSchema;
+use Michel\PaperORM\Schema\SchemaInterface;
+
+class MariaDBPlatform extends AbstractPlatform
+{
+    private PaperConnection $connection;
+    private MariaDBSchema $schema;
+
+    public function __construct(PaperConnection $connection, MariaDBSchema $schema)
+    {
+        $this->connection = $connection;
+        $this->schema = $schema;
+    }
+
+    public function getDatabaseName(): string
+    {
+        return $this->connection->getParams()['path'] ?? '';
+    }
+
+    public function listTables(): array
+    {
+        $rows = $this->connection->fetchAll($this->schema->showTables());
+        $tables = [];
+        foreach ($rows as $table) {
+            $table = array_values($table);
+            $tables[] = $table[0];
+        }
+        rsort($tables, SORT_STRING);
+        return $tables;
+    }
+
+    /**
+     * @param string $tableName
+     * @return array<ColumnMetadata>
+     */
+    public function listTableColumns(string $tableName): array
+    {
+        $tables = $this->listTables();
+        if (!in_array($tableName, $tables)) {
+            return [];
+        }
+        $rows = $this->connection->fetchAll($this->schema->showTableColumns($tableName));
+        $foreignKeys = $this->connection->fetchAll($this->schema->showForeignKeys($tableName));
+        $columns = [];
+        foreach ($rows as $row) {
+            $foreignKeyMetadata = null;
+            foreach ($foreignKeys as $foreignKey) {
+                if ($row['Field'] == $foreignKey['COLUMN_NAME']) {
+                    $foreignKeyMetadata = ForeignKeyMetadata::fromArray([
+                        'name' => $foreignKey['CONSTRAINT_NAME'],
+                        'columns' => [$row['Field']],
+                        'referenceTable' => $foreignKey['REFERENCED_TABLE_NAME'],
+                        'referenceColumns' => [$foreignKey['REFERENCED_COLUMN_NAME']],
+                        'onDelete' => $this->convertForeignKeyRuleStringToCode($foreignKey['DELETE_RULE']),
+                        'onUpdate' => $this->convertForeignKeyRuleStringToCode($foreignKey['UPDATE_RULE']),
+                    ]);
+                    break;
+                }
+            }
+            $columnMetadata = ColumnMetadata::fromArray([
+                'name' => $row['Field'],
+                'type' => SQLTypeParser::getBaseType($row['Type']),
+                'primary' => ($row['Key'] === 'PRI'),
+                'foreignKeyMetadata' => $foreignKeyMetadata,
+                'null' => ($row['Null'] === 'YES'),
+                'default' => $row['Default'] ?? 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
+    {
+        $tables = $this->listTables();
+        if (!in_array($tableName, $tables)) {
+            return [];
+        }
+        $indexes = $this->connection->fetchAll($this->schema->showTableIndexes($tableName));
+        $indexByColumns = [];
+        foreach ($indexes as $index) {
+            $indexName = $index['Key_name'];
+            if (isset($indexByColumns[$indexName])) {
+                $indexByColumns[$indexName]['columns'][] = $index['Column_name'];
+                continue;
+            }
+            if ($indexName === 'PRIMARY') {
+                continue;
+            }
+            $indexByColumns[$indexName] = [
+                'tableName' => $index['Table'],
+                'name' => $indexName,
+                'columns' => [$index['Column_name']],
+                'unique' => ((int)$index['Non_unique'] === 0),
+            ];
+        }
+
+        $indexesFormatted = [];
+        foreach ($indexByColumns as $idx) {
+            $indexesFormatted[] = IndexMetadata::fromArray($idx);
+        }
+        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
+    {
+        $connection = $this->connection->cloneConnectionWithoutDbname();
+        $connection->executeStatement($this->schema->createDatabase($this->getDatabaseName()));
+    }
+
+    public function createDatabaseIfNotExists(): void
+    {
+        $connection = $this->connection->cloneConnectionWithoutDbname();
+        $connection->executeStatement($this->schema->createDatabaseIfNotExists($this->getDatabaseName()));
+    }
+
+    public function dropDatabase(): void
+    {
+        $connection = $this->connection->cloneConnectionWithoutDbname();
+        $database = $this->getDatabaseName();
+        $connection->executeStatement($this->schema->dropDatabase($database));
+    }
+
+    public function createTable(string $tableName, array $columns): int
+    {
+        return $this->executeStatement($this->schema->createTable($tableName, $this->mapColumnsToMetadata($tableName, $columns)));
+    }
+
+    public function createTableIfNotExists(string $tableName, array $columns, array $options = []): int
+    {
+        return $this->connection->executeStatement($this->schema->createTableIfNotExists($tableName, $this->mapColumnsToMetadata($tableName, $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($tableName, $column)));
+    }
+
+    public function dropColumn(string $tableName, Column $column): int
+    {
+        return $this->connection->executeStatement($this->schema->dropColumn($tableName, $this->mapColumnToMetadata($tableName, $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 createForeignKeyConstraint(string $tableName, ForeignKeyMetadata $foreignKey): int
+    {
+        return $this->executeStatement($this->schema->createForeignKeyConstraint($tableName, $foreignKey));
+    }
+
+    public function dropForeignKeyConstraints(string $tableName, string $foreignKeyName): int
+    {
+        return $this->executeStatement($this->schema->dropForeignKeyConstraints($tableName, $foreignKeyName));
+    }
+
+    public function getMaxLength(): int
+    {
+        return 30;
+    }
+
+    public function getPrefixIndexName(): string
+    {
+        return 'ix_';
+    }
+
+    public function getPrefixUniqIndexName(): string
+    {
+        return 'uniq_';
+    }
+
+    public function getPrefixForeignKeyName(): string
+    {
+        return 'fk_';
+    }
+
+    public function getColumnTypeMappings(): array
+    {
+        return [
+            PrimaryKeyColumn::class => [
+                'type' => 'INT',
+                'args' => [11]
+            ],
+            IntColumn::class => [
+                'type' => 'INT',
+                'args' => [11]
+            ],
+            JoinColumn::class => [
+                'type' => 'INT',
+                'args' => [11]
+            ],
+            DecimalColumn::class => [
+                'type' => 'DECIMAL',
+                'args' => [10, 5]
+            ],
+            FloatColumn::class => [
+                'type' => 'FLOAT',
+                'args' => []
+            ],
+            DateColumn::class => [
+                'type' => 'DATE',
+                'args' => []
+            ],
+            DateTimeColumn::class => [
+                'type' => 'DATETIME',
+                'args' => []
+            ],
+            TimestampColumn::class => [
+                'type' => 'DATETIME',
+                'args' => [],
+            ],
+            BoolColumn::class => [
+                'type' => 'TINYINT',
+                'args' => [1]
+            ],
+            TextColumn::class => [
+                'type' => 'TEXT',
+                'args' => []
+            ],
+            JsonColumn::class => [
+                'type' => 'LONGTEXT',
+                'args' => []
+            ],
+            StringColumn::class => [
+                'type' => 'VARCHAR',
+                'args' => [255]
+            ],
+            SlugColumn::class => [
+                'type' => 'VARCHAR',
+                'args' => [128]
+            ],
+            BinaryColumn::class => [
+                'type' => 'BLOB',
+                'args' => []
+            ],
+            AnyColumn::class => [
+                'type' => 'VARCHAR',
+                'args' => [150],
+            ],
+            UuidColumn::class => [
+                'type' => 'VARCHAR',
+                'args' => [36],
+            ],
+            AutoIncrementColumn::class => [
+                'type' => 'VARCHAR',
+                'args' => [150],
+            ],
+            TokenColumn::class => [
+                'type' => 'VARCHAR',
+                'args' => [128],
+            ]
+        ];
+    }
+
+    public function convertForeignKeyRuleStringToCode(?string $rule): int
+    {
+        $rule = strtoupper($rule);
+        switch ($rule) {
+            case 'CASCADE':
+                return ForeignKeyMetadata::CASCADE;
+            case 'SET NULL':
+                return ForeignKeyMetadata::SET_NULL;
+            case 'RESTRICT':
+                return ForeignKeyMetadata::RESTRICT;
+            case 'NO ACTION':   /* fall-through */
+            default:
+                return ForeignKeyMetadata::NO_ACTION;
+        }
+    }
+
+    public function getSchema(): SchemaInterface
+    {
+        return $this->schema;
+    }
+
+    public function supportsTransactionalDDL(): bool
+    {
+        return false;
+    }
+
+    public function getConnection(): PaperConnection
+    {
+        return $this->connection;
+    }
+
+    public function executeStatement(string $sql): int
+    {
+        $result = 0;
+        foreach (explode(';', $sql) as $stmt) {
+            $stmt = trim($stmt);
+            if (!empty($stmt)) {
+                $result += $this->getConnection()->executeStatement($stmt);
+            }
+        }
+        return $result;
+    }
+
+    public function autoCreateIndexJoinColumns(): bool
+    {
+        return true;
+    }
+
+    public function autoCreateIndexPrimaryKeys(): bool
+    {
+        return false;
+    }
+
+    public function autoCreateIndexUniqueColumns(): bool
+    {
+        return true;
+    }
+}

+ 118 - 0
src/Platform/PlatformInterface.php

@@ -0,0 +1,118 @@
+<?php
+
+namespace Michel\PaperORM\Platform;
+
+use Michel\PaperORM\Mapping\Column\Column;
+use Michel\PaperORM\Metadata\ForeignKeyMetadata;
+use Michel\PaperORM\Metadata\DatabaseSchemaDiffMetadata;
+use Michel\PaperORM\Metadata\ColumnMetadata;
+use Michel\PaperORM\Metadata\IndexMetadata;
+use Michel\PaperORM\PaperConnection;
+use Michel\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
+     * @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 createForeignKeyConstraint(string $tableName, ForeignKeyMetadata $foreignKey) :int;
+    public function dropForeignKeyConstraints(string $tableName, string $foreignKeyName): int;
+    public function getColumnTypeMappings(): array;
+    public function convertForeignKeyRuleStringToCode(?string $rule): int;
+    public function getMaxLength(): int;
+    public function getPrefixIndexName(): string;
+    public function getPrefixUniqIndexName(): string;
+    public function getPrefixForeignKeyName(): string;
+    public function diff(string $tableName, array $columns, array $indexes): DatabaseSchemaDiffMetadata;
+    public function getSchema(): SchemaInterface;
+    public function supportsTransactionalDDL(): bool;
+
+    public function getConnection(): PaperConnection;
+    /**
+     * Indicates whether this Platform automatically creates indexes
+     * for JoinColumns when generating tables.
+     */
+    public function autoCreateIndexJoinColumns(): bool;
+
+    /**
+     * Indicates whether this Platform automatically creates indexes
+     * for PrimaryKey columns when generating tables.
+     */
+    public function autoCreateIndexPrimaryKeys(): bool;
+
+    /**
+     * Indicates whether this Platform automatically creates indexes
+     * for columns with a unique constraint (that are neither PK nor JoinColumn).
+     */
+    public function autoCreateIndexUniqueColumns(): bool;
+}
+

+ 367 - 0
src/Platform/SqlitePlatform.php

@@ -0,0 +1,367 @@
+<?php
+
+namespace Michel\PaperORM\Platform;
+
+use InvalidArgumentException;
+use LogicException;
+use Michel\PaperORM\Mapping\Column\AnyColumn;
+use Michel\PaperORM\Mapping\Column\AutoIncrementColumn;
+use Michel\PaperORM\Mapping\Column\BinaryColumn;
+use Michel\PaperORM\Mapping\Column\BoolColumn;
+use Michel\PaperORM\Mapping\Column\Column;
+use Michel\PaperORM\Mapping\Column\DateColumn;
+use Michel\PaperORM\Mapping\Column\DateTimeColumn;
+use Michel\PaperORM\Mapping\Column\DecimalColumn;
+use Michel\PaperORM\Mapping\Column\FloatColumn;
+use Michel\PaperORM\Mapping\Column\IntColumn;
+use Michel\PaperORM\Mapping\Column\JoinColumn;
+use Michel\PaperORM\Mapping\Column\JsonColumn;
+use Michel\PaperORM\Mapping\Column\PrimaryKeyColumn;
+use Michel\PaperORM\Mapping\Column\SlugColumn;
+use Michel\PaperORM\Mapping\Column\StringColumn;
+use Michel\PaperORM\Mapping\Column\TextColumn;
+use Michel\PaperORM\Mapping\Column\TimestampColumn;
+use Michel\PaperORM\Mapping\Column\TokenColumn;
+use Michel\PaperORM\Mapping\Column\UuidColumn;
+use Michel\PaperORM\Metadata\ColumnMetadata;
+use Michel\PaperORM\Metadata\ForeignKeyMetadata;
+use Michel\PaperORM\Metadata\IndexMetadata;
+use Michel\PaperORM\PaperConnection;
+use Michel\PaperORM\Parser\SQLTypeParser;
+use Michel\PaperORM\Schema\SchemaInterface;
+use Michel\PaperORM\Schema\SqliteSchema;
+use RuntimeException;
+
+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
+    {
+        $memory = $this->connection->getParams()['memory'] ?? false;
+        if ($memory) {
+            return ':memory:';
+        }
+        return $this->connection->getParams()['path'] ?? '';
+    }
+
+    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 = null;
+            foreach ($foreignKeys as $foreignKey) {
+                if ($row['name'] == $foreignKey['from']) {
+                    $foreignKeyMetadata = ForeignKeyMetadata::fromArray([
+                        'name' => $this->generateForeignKeyName($tableName, [$row['name']]),
+                        'columns' => [$row['name']],
+                        'referenceTable' => $foreignKey['table'],
+                        'referenceColumns' => [$foreignKey['to']],
+                        'onDelete' => $this->convertForeignKeyRuleStringToCode($foreignKey['on_delete']),
+                        'onUpdate' => $this->convertForeignKeyRuleStringToCode($foreignKey['on_update']),
+                    ]);
+                    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 ($database == ':memory:') {
+            return;
+        }
+
+        if (empty($database)) {
+            throw new RuntimeException(sprintf("The database name cannot be empty. %s::createDatabase()", __CLASS__));
+        }
+
+        $databaseFile = pathinfo($database);
+        if (empty($databaseFile['extension'])) {
+            throw new RuntimeException(sprintf("The database name '%s' must have an extension.", $database));
+        }
+
+        if (file_exists($database)) {
+            throw new LogicException(sprintf("The database '%s' already exists.", $database));
+        }
+
+        touch($database);
+        chmod($database, 0666);
+    }
+
+    public function createDatabaseIfNotExists(): void
+    {
+        try {
+            $this->createDatabase();
+        } catch (LogicException $e) {
+            return;
+        }
+    }
+
+    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($tableName, $columns)));
+    }
+
+    public function createTableIfNotExists(string $tableName, array $columns, array $options = []): int
+    {
+        return $this->connection->executeStatement($this->schema->createTableIfNotExists($tableName, $this->mapColumnsToMetadata($tableName, $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($tableName, $column)));
+    }
+
+    public function dropColumn(string $tableName, Column $column): int
+    {
+        return $this->connection->executeStatement($this->schema->dropColumn($tableName, $this->mapColumnToMetadata($tableName, $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 createForeignKeyConstraint(string $tableName, ForeignKeyMetadata $foreignKey): int
+    {
+        throw new LogicException(sprintf("The method '%s' is not supported by the platform interface.", __METHOD__));
+    }
+
+    public function dropForeignKeyConstraints(string $tableName, string $foreignKeyName): int
+    {
+        throw new LogicException(sprintf("The method '%s' is not supported by the platform interface.", __METHOD__));
+    }
+
+    public function getMaxLength(): int
+    {
+        return 30;
+    }
+
+    public function getPrefixIndexName(): string
+    {
+        return 'ix_';
+    }
+
+    public function getPrefixUniqIndexName(): string
+    {
+        return 'uniq_';
+    }
+
+    public function getPrefixForeignKeyName(): string
+    {
+        return 'fk_';
+    }
+
+    public function getColumnTypeMappings(): array
+    {
+        return [
+            PrimaryKeyColumn::class => [
+                'type' => 'INTEGER',
+                'args' => [],
+            ],
+            IntColumn::class => [
+                'type' => 'INTEGER',
+                'args' => [],
+            ],
+            JoinColumn::class => [
+                'type' => 'INTEGER',
+                'args' => [],
+            ],
+            DecimalColumn::class => [
+                'type' => 'DECIMAL',
+                'args' => [10, 5],
+            ],
+            FloatColumn::class => [
+                'type' => 'FLOAT',
+                'args' => [],
+            ],
+            DateColumn::class => [
+                'type' => 'DATE',
+                'args' => [],
+            ],
+            DateTimeColumn::class => [
+                'type' => 'DATETIME',
+                'args' => [],
+            ],
+            BoolColumn::class => [
+                'type' => 'BOOLEAN',
+                'args' => [],
+            ],
+            TextColumn::class => [
+                'type' => 'TEXT',
+                'args' => [],
+            ],
+            JsonColumn::class => [
+                'type' => 'JSON',
+                'args' => [],
+            ],
+            StringColumn::class => [
+                'type' => 'VARCHAR',
+                'args' => [255],
+            ],
+            SlugColumn::class => [
+                'type' => 'VARCHAR',
+                'args' => [128]
+            ],
+            TimestampColumn::class => [
+                'type' => 'DATETIME',
+                'args' => [],
+            ],
+            BinaryColumn::class => [
+                'type' => 'BLOB',
+                'args' => [],
+            ],
+            AnyColumn::class => [
+                'type' => 'VARCHAR',
+                'args' => [150],
+            ],
+            UuidColumn::class => [
+                'type' => 'VARCHAR',
+                'args' => [36],
+            ],
+            AutoIncrementColumn::class => [
+                'type' => 'VARCHAR',
+                'args' => [150],
+            ],
+            TokenColumn::class => [
+                'type' => 'VARCHAR',
+                'args' => [128],
+            ]
+        ];
+    }
+
+    public function convertForeignKeyRuleStringToCode(?string $rule): int
+    {
+        $rule = strtoupper($rule);
+        switch ($rule) {
+            case 'CASCADE':
+                return ForeignKeyMetadata::CASCADE;
+            case 'SET NULL':
+                return ForeignKeyMetadata::SET_NULL;
+            case 'SET DEFAULT':
+                return ForeignKeyMetadata::SET_DEFAULT;
+            case 'RESTRICT':
+                return ForeignKeyMetadata::RESTRICT;
+            case 'NO ACTION':   /* fall-through */
+            default:
+                return ForeignKeyMetadata::NO_ACTION;
+        }
+    }
+
+    public function getSchema(): SchemaInterface
+    {
+        return $this->schema;
+    }
+
+    public function supportsTransactionalDDL(): bool
+    {
+       return true;
+    }
+
+    public function getConnection(): PaperConnection
+    {
+        return $this->connection;
+    }
+
+    public function autoCreateIndexJoinColumns(): bool
+    {
+        return true;
+    }
+
+    public function autoCreateIndexPrimaryKeys(): bool
+    {
+        return false;
+    }
+
+    public function autoCreateIndexUniqueColumns(): bool
+    {
+        return true;
+    }
+}

+ 51 - 0
src/Proxy/ProxyFactory.php

@@ -0,0 +1,51 @@
+<?php
+
+namespace Michel\PaperORM\Proxy;
+
+final class ProxyFactory
+{
+    private static bool $registered = false;
+
+    public static function registerAutoloader(): void
+    {
+        if (self::$registered) {
+            return;
+        }
+
+        spl_autoload_register(function ($class) {
+            if (strpos($class, 'Proxy_') === 0) {
+                $original = str_replace('_', '\\', substr($class, 6));
+                self::generate($original, $class);
+            }
+        });
+
+        self::$registered = true;
+    }
+
+    private static function generate(string $original, string $proxyClass): void
+    {
+        if (!class_exists($original)) {
+            throw new \RuntimeException("Cannot create proxy: original class {$original} does not exist.");
+        }
+
+        if (!class_exists($proxyClass)) {
+            eval("
+                class $proxyClass extends \\$original implements \\Michel\\PaperORM\\Proxy\\ProxyInterface {
+                    use \\Michel\\PaperORM\\Proxy\\ProxyInitializedTrait;
+                }
+            ");
+        }
+    }
+
+    public static function create(string $original): object
+    {
+        $sanitized = str_replace('\\', '_', $original);
+        $proxyClass = 'Proxy_' . $sanitized;
+
+        if (!class_exists($proxyClass)) {
+            self::generate($original, $proxyClass);
+        }
+
+        return new $proxyClass();
+    }
+}

+ 106 - 0
src/Proxy/ProxyInitializedTrait.php

@@ -0,0 +1,106 @@
+<?php
+
+namespace Michel\PaperORM\Proxy;
+
+use DateTimeInterface;
+use Michel\PaperORM\Entity\EntityInterface;
+use Michel\PaperORM\Mapping\Column\Column;
+use Michel\PaperORM\Mapping\Column\DateColumn;
+use Michel\PaperORM\Mapping\Column\DateTimeColumn;
+use Michel\PaperORM\Mapping\Column\JoinColumn;
+use Michel\PaperORM\Types\DateTimeType;
+
+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 count($this->__getPropertiesModified()) > 0;
+    }
+
+    public function __getPropertiesModified() : array
+    {
+        if (!$this->__initialized) {
+            return [];
+        }
+
+        $changed = [];
+        $initial = $this->__valuesInitialized;
+        $current = $this->getValues();
+
+        foreach ($current as $key => $value) {
+            if (!array_key_exists($key, $initial)) {
+                $changed[] = $key;
+                continue;
+            }
+
+            if ($value !== $initial[$key]) {
+                $changed[] = $key;
+            }
+        }
+
+        return $changed;
+    }
+
+    public function __destroy() : void
+    {
+        $this->__initialized = false;
+        $this->__propertiesInitialized = [];
+        $this->__valuesInitialized = [];
+    }
+
+    public function __reset(): void
+    {
+        $this->__setInitialized($this->__propertiesInitialized);
+    }
+
+    public 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->getType() == DateTimeType::class && $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;
+    }
+}

Some files were not shown because too many files changed in this diff