From a7fc0ce3c9ffbd18034eb7691a8eaeb6d4762d38 Mon Sep 17 00:00:00 2001 From: Ibrahim BinAlshikh Date: Tue, 30 Dec 2025 17:06:10 +0300 Subject: [PATCH 01/44] chore: Updated License Headers --- WebFiori/Database/AbstractQuery.php | 2 +- WebFiori/Database/ColOption.php | 2 +- WebFiori/Database/Column.php | 2 +- WebFiori/Database/ColumnFactory.php | 2 +- WebFiori/Database/Condition.php | 2 +- WebFiori/Database/Connection.php | 2 +- WebFiori/Database/ConnectionInfo.php | 2 +- WebFiori/Database/DataType.php | 2 +- WebFiori/Database/Database.php | 2 +- WebFiori/Database/DatabaseException.php | 2 +- WebFiori/Database/DateTimeValidator.php | 2 +- WebFiori/Database/EntityMapper.php | 2 +- WebFiori/Database/Expression.php | 2 +- WebFiori/Database/FK.php | 2 +- WebFiori/Database/ForeignKey.php | 2 +- WebFiori/Database/InsertBuilder.php | 2 +- WebFiori/Database/JoinTable.php | 2 +- WebFiori/Database/MsSql/MSSQLColumn.php | 2 +- WebFiori/Database/MsSql/MSSQLConnection.php | 2 +- WebFiori/Database/MsSql/MSSQLInsertBuilder.php | 2 +- WebFiori/Database/MsSql/MSSQLQuery.php | 2 +- WebFiori/Database/MsSql/MSSQLTable.php | 2 +- WebFiori/Database/MySql/MySQLColumn.php | 2 +- WebFiori/Database/MySql/MySQLConnection.php | 2 +- WebFiori/Database/MySql/MySQLInsertBuilder.php | 2 +- WebFiori/Database/MySql/MySQLQuery.php | 2 +- WebFiori/Database/MySql/MySQLTable.php | 2 +- WebFiori/Database/RecordMapper.php | 2 +- WebFiori/Database/ResultSet.php | 2 +- WebFiori/Database/Schema/AbstractMigration.php | 9 +++++++++ WebFiori/Database/Schema/AbstractSeeder.php | 9 +++++++++ WebFiori/Database/Schema/DatabaseChange.php | 9 +++++++++ WebFiori/Database/Schema/SchemaRunner.php | 9 +++++++++ WebFiori/Database/SelectExpression.php | 2 +- WebFiori/Database/Table.php | 2 +- WebFiori/Database/TableFactory.php | 2 +- WebFiori/Database/TypesMap.php | 2 +- WebFiori/Database/WhereExpression.php | 2 +- 38 files changed, 70 insertions(+), 34 deletions(-) diff --git a/WebFiori/Database/AbstractQuery.php b/WebFiori/Database/AbstractQuery.php index c023db17..dd0014fd 100644 --- a/WebFiori/Database/AbstractQuery.php +++ b/WebFiori/Database/AbstractQuery.php @@ -3,7 +3,7 @@ /** * This file is licensed under MIT License. * - * Copyright (c) 2019 Ibrahim BinAlshikh + * Copyright (c) 2019 WebFiori Framework * * For more information on the license, please visit: * https://github.com/WebFiori/.github/blob/main/LICENSE diff --git a/WebFiori/Database/ColOption.php b/WebFiori/Database/ColOption.php index 0dca3390..d3e4cc93 100644 --- a/WebFiori/Database/ColOption.php +++ b/WebFiori/Database/ColOption.php @@ -3,7 +3,7 @@ /** * This file is licensed under MIT License. * - * Copyright (c) 2024 Ibrahim BinAlshikh + * Copyright (c) 2024 WebFiori Framework * * For more information on the license, please visit: * https://github.com/WebFiori/.github/blob/main/LICENSE diff --git a/WebFiori/Database/Column.php b/WebFiori/Database/Column.php index 54687b03..34e1b48c 100644 --- a/WebFiori/Database/Column.php +++ b/WebFiori/Database/Column.php @@ -3,7 +3,7 @@ /** * This file is licensed under MIT License. * - * Copyright (c) 2019 Ibrahim BinAlshikh + * Copyright (c) 2019 WebFiori Framework * * For more information on the license, please visit: * https://github.com/WebFiori/.github/blob/main/LICENSE diff --git a/WebFiori/Database/ColumnFactory.php b/WebFiori/Database/ColumnFactory.php index 3e9a6492..72e21f27 100644 --- a/WebFiori/Database/ColumnFactory.php +++ b/WebFiori/Database/ColumnFactory.php @@ -3,7 +3,7 @@ /** * This file is licensed under MIT License. * - * Copyright (c) 2019 Ibrahim BinAlshikh + * Copyright (c) 2019 WebFiori Framework * * For more information on the license, please visit: * https://github.com/WebFiori/.github/blob/main/LICENSE diff --git a/WebFiori/Database/Condition.php b/WebFiori/Database/Condition.php index 4724510f..a27cb937 100644 --- a/WebFiori/Database/Condition.php +++ b/WebFiori/Database/Condition.php @@ -3,7 +3,7 @@ /** * This file is licensed under MIT License. * - * Copyright (c) 2019 Ibrahim BinAlshikh + * Copyright (c) 2019 WebFiori Framework * * For more information on the license, please visit: * https://github.com/WebFiori/.github/blob/main/LICENSE diff --git a/WebFiori/Database/Connection.php b/WebFiori/Database/Connection.php index 83e8bd8a..86e0e129 100644 --- a/WebFiori/Database/Connection.php +++ b/WebFiori/Database/Connection.php @@ -3,7 +3,7 @@ /** * This file is licensed under MIT License. * - * Copyright (c) 2019 Ibrahim BinAlshikh + * Copyright (c) 2019 WebFiori Framework * * For more information on the license, please visit: * https://github.com/WebFiori/.github/blob/main/LICENSE diff --git a/WebFiori/Database/ConnectionInfo.php b/WebFiori/Database/ConnectionInfo.php index 0eb16ff8..9200bdfe 100644 --- a/WebFiori/Database/ConnectionInfo.php +++ b/WebFiori/Database/ConnectionInfo.php @@ -3,7 +3,7 @@ /** * This file is licensed under MIT License. * - * Copyright (c) 2019 Ibrahim BinAlshikh + * Copyright (c) 2019 WebFiori Framework * * For more information on the license, please visit: * https://github.com/WebFiori/.github/blob/main/LICENSE diff --git a/WebFiori/Database/DataType.php b/WebFiori/Database/DataType.php index 95b9a94d..32b60284 100644 --- a/WebFiori/Database/DataType.php +++ b/WebFiori/Database/DataType.php @@ -3,7 +3,7 @@ /** * This file is licensed under MIT License. * - * Copyright (c) 2024 Ibrahim BinAlshikh + * Copyright (c) 2024 WebFiori Framework * * For more information on the license, please visit: * https://github.com/WebFiori/.github/blob/main/LICENSE diff --git a/WebFiori/Database/Database.php b/WebFiori/Database/Database.php index 407d4a71..f0999773 100644 --- a/WebFiori/Database/Database.php +++ b/WebFiori/Database/Database.php @@ -3,7 +3,7 @@ /** * This file is licensed under MIT License. * - * Copyright (c) 2019 Ibrahim BinAlshikh + * Copyright (c) 2019 WebFiori Framework * * For more information on the license, please visit: * https://github.com/WebFiori/.github/blob/main/LICENSE diff --git a/WebFiori/Database/DatabaseException.php b/WebFiori/Database/DatabaseException.php index f91a17df..f9627b65 100644 --- a/WebFiori/Database/DatabaseException.php +++ b/WebFiori/Database/DatabaseException.php @@ -3,7 +3,7 @@ /** * This file is licensed under MIT License. * - * Copyright (c) 2019 Ibrahim BinAlshikh + * Copyright (c) 2019 WebFiori Framework * * For more information on the license, please visit: * https://github.com/WebFiori/.github/blob/main/LICENSE diff --git a/WebFiori/Database/DateTimeValidator.php b/WebFiori/Database/DateTimeValidator.php index 726da25f..023608df 100644 --- a/WebFiori/Database/DateTimeValidator.php +++ b/WebFiori/Database/DateTimeValidator.php @@ -3,7 +3,7 @@ /** * This file is licensed under MIT License. * - * Copyright (c) 2019 Ibrahim BinAlshikh + * Copyright (c) 2019 WebFiori Framework * * For more information on the license, please visit: * https://github.com/WebFiori/.github/blob/main/LICENSE diff --git a/WebFiori/Database/EntityMapper.php b/WebFiori/Database/EntityMapper.php index f7b57497..fe31b948 100644 --- a/WebFiori/Database/EntityMapper.php +++ b/WebFiori/Database/EntityMapper.php @@ -3,7 +3,7 @@ /** * This file is licensed under MIT License. * - * Copyright (c) 2019 Ibrahim BinAlshikh + * Copyright (c) 2019 WebFiori Framework * * For more information on the license, please visit: * https://github.com/WebFiori/.github/blob/main/LICENSE diff --git a/WebFiori/Database/Expression.php b/WebFiori/Database/Expression.php index 13b57fbf..8c5de644 100644 --- a/WebFiori/Database/Expression.php +++ b/WebFiori/Database/Expression.php @@ -3,7 +3,7 @@ /** * This file is licensed under MIT License. * - * Copyright (c) 2019 Ibrahim BinAlshikh + * Copyright (c) 2019 WebFiori Framework * * For more information on the license, please visit: * https://github.com/WebFiori/.github/blob/main/LICENSE diff --git a/WebFiori/Database/FK.php b/WebFiori/Database/FK.php index 039ce000..1db557f5 100644 --- a/WebFiori/Database/FK.php +++ b/WebFiori/Database/FK.php @@ -3,7 +3,7 @@ /** * This file is licensed under MIT License. * - * Copyright (c) 2023 Ibrahim BinAlshikh + * Copyright (c) 2023 WebFiori Framework * * For more information on the license, please visit: * https://github.com/WebFiori/.github/blob/main/LICENSE diff --git a/WebFiori/Database/ForeignKey.php b/WebFiori/Database/ForeignKey.php index 41f9cfd7..087655f0 100644 --- a/WebFiori/Database/ForeignKey.php +++ b/WebFiori/Database/ForeignKey.php @@ -3,7 +3,7 @@ /** * This file is licensed under MIT License. * - * Copyright (c) 2019 Ibrahim BinAlshikh + * Copyright (c) 2019 WebFiori Framework * * For more information on the license, please visit: * https://github.com/WebFiori/.github/blob/main/LICENSE diff --git a/WebFiori/Database/InsertBuilder.php b/WebFiori/Database/InsertBuilder.php index 614ae28c..c8d02df3 100644 --- a/WebFiori/Database/InsertBuilder.php +++ b/WebFiori/Database/InsertBuilder.php @@ -3,7 +3,7 @@ /** * This file is licensed under MIT License. * - * Copyright (c) 2023 Ibrahim BinAlshikh + * Copyright (c) 2023 WebFiori Framework * * For more information on the license, please visit: * https://github.com/WebFiori/.github/blob/main/LICENSE diff --git a/WebFiori/Database/JoinTable.php b/WebFiori/Database/JoinTable.php index 388ef8f5..d149af9d 100644 --- a/WebFiori/Database/JoinTable.php +++ b/WebFiori/Database/JoinTable.php @@ -3,7 +3,7 @@ /** * This file is licensed under MIT License. * - * Copyright (c) 2019 Ibrahim BinAlshikh + * Copyright (c) 2019 WebFiori Framework * * For more information on the license, please visit: * https://github.com/WebFiori/.github/blob/main/LICENSE diff --git a/WebFiori/Database/MsSql/MSSQLColumn.php b/WebFiori/Database/MsSql/MSSQLColumn.php index b5cbd644..68807031 100644 --- a/WebFiori/Database/MsSql/MSSQLColumn.php +++ b/WebFiori/Database/MsSql/MSSQLColumn.php @@ -3,7 +3,7 @@ /** * This file is licensed under MIT License. * - * Copyright (c) 2019 Ibrahim BinAlshikh + * Copyright (c) 2019 WebFiori Framework * * For more information on the license, please visit: * https://github.com/WebFiori/.github/blob/main/LICENSE diff --git a/WebFiori/Database/MsSql/MSSQLConnection.php b/WebFiori/Database/MsSql/MSSQLConnection.php index 6cd65179..cb1e4c26 100644 --- a/WebFiori/Database/MsSql/MSSQLConnection.php +++ b/WebFiori/Database/MsSql/MSSQLConnection.php @@ -3,7 +3,7 @@ /** * This file is licensed under MIT License. * - * Copyright (c) 2019 Ibrahim BinAlshikh + * Copyright (c) 2019 WebFiori Framework * * For more information on the license, please visit: * https://github.com/WebFiori/.github/blob/main/LICENSE diff --git a/WebFiori/Database/MsSql/MSSQLInsertBuilder.php b/WebFiori/Database/MsSql/MSSQLInsertBuilder.php index 9e7befc9..adf8e1b5 100644 --- a/WebFiori/Database/MsSql/MSSQLInsertBuilder.php +++ b/WebFiori/Database/MsSql/MSSQLInsertBuilder.php @@ -3,7 +3,7 @@ /** * This file is licensed under MIT License. * - * Copyright (c) 2023 Ibrahim BinAlshikh + * Copyright (c) 2023 WebFiori Framework * * For more information on the license, please visit: * https://github.com/WebFiori/.github/blob/main/LICENSE diff --git a/WebFiori/Database/MsSql/MSSQLQuery.php b/WebFiori/Database/MsSql/MSSQLQuery.php index ecefaad3..8bf90466 100644 --- a/WebFiori/Database/MsSql/MSSQLQuery.php +++ b/WebFiori/Database/MsSql/MSSQLQuery.php @@ -3,7 +3,7 @@ /** * This file is licensed under MIT License. * - * Copyright (c) 2019 Ibrahim BinAlshikh + * Copyright (c) 2019 WebFiori Framework * * For more information on the license, please visit: * https://github.com/WebFiori/.github/blob/main/LICENSE diff --git a/WebFiori/Database/MsSql/MSSQLTable.php b/WebFiori/Database/MsSql/MSSQLTable.php index 649d9fdb..37bbb138 100644 --- a/WebFiori/Database/MsSql/MSSQLTable.php +++ b/WebFiori/Database/MsSql/MSSQLTable.php @@ -3,7 +3,7 @@ /** * This file is licensed under MIT License. * - * Copyright (c) 2019 Ibrahim BinAlshikh + * Copyright (c) 2019 WebFiori Framework * * For more information on the license, please visit: * https://github.com/WebFiori/.github/blob/main/LICENSE diff --git a/WebFiori/Database/MySql/MySQLColumn.php b/WebFiori/Database/MySql/MySQLColumn.php index 809e6b75..d178a18b 100644 --- a/WebFiori/Database/MySql/MySQLColumn.php +++ b/WebFiori/Database/MySql/MySQLColumn.php @@ -3,7 +3,7 @@ /** * This file is licensed under MIT License. * - * Copyright (c) 2019 Ibrahim BinAlshikh + * Copyright (c) 2019 WebFiori Framework * * For more information on the license, please visit: * https://github.com/WebFiori/.github/blob/main/LICENSE diff --git a/WebFiori/Database/MySql/MySQLConnection.php b/WebFiori/Database/MySql/MySQLConnection.php index a6085b9a..2040e0c0 100644 --- a/WebFiori/Database/MySql/MySQLConnection.php +++ b/WebFiori/Database/MySql/MySQLConnection.php @@ -3,7 +3,7 @@ /** * This file is licensed under MIT License. * - * Copyright (c) 2019 Ibrahim BinAlshikh + * Copyright (c) 2019 WebFiori Framework * * For more information on the license, please visit: * https://github.com/WebFiori/.github/blob/main/LICENSE diff --git a/WebFiori/Database/MySql/MySQLInsertBuilder.php b/WebFiori/Database/MySql/MySQLInsertBuilder.php index 95657230..470b3fd2 100644 --- a/WebFiori/Database/MySql/MySQLInsertBuilder.php +++ b/WebFiori/Database/MySql/MySQLInsertBuilder.php @@ -3,7 +3,7 @@ /** * This file is licensed under MIT License. * - * Copyright (c) 2023 Ibrahim BinAlshikh + * Copyright (c) 2023 WebFiori Framework * * For more information on the license, please visit: * https://github.com/WebFiori/.github/blob/main/LICENSE diff --git a/WebFiori/Database/MySql/MySQLQuery.php b/WebFiori/Database/MySql/MySQLQuery.php index ce509c11..90362c43 100644 --- a/WebFiori/Database/MySql/MySQLQuery.php +++ b/WebFiori/Database/MySql/MySQLQuery.php @@ -3,7 +3,7 @@ /** * This file is licensed under MIT License. * - * Copyright (c) 2019 Ibrahim BinAlshikh + * Copyright (c) 2019 WebFiori Framework * * For more information on the license, please visit: * https://github.com/WebFiori/.github/blob/main/LICENSE diff --git a/WebFiori/Database/MySql/MySQLTable.php b/WebFiori/Database/MySql/MySQLTable.php index 766a77a5..743e4ea0 100644 --- a/WebFiori/Database/MySql/MySQLTable.php +++ b/WebFiori/Database/MySql/MySQLTable.php @@ -3,7 +3,7 @@ /** * This file is licensed under MIT License. * - * Copyright (c) 2019 Ibrahim BinAlshikh + * Copyright (c) 2019 WebFiori Framework * * For more information on the license, please visit: * https://github.com/WebFiori/.github/blob/main/LICENSE diff --git a/WebFiori/Database/RecordMapper.php b/WebFiori/Database/RecordMapper.php index 83c6ba35..f1409e8d 100644 --- a/WebFiori/Database/RecordMapper.php +++ b/WebFiori/Database/RecordMapper.php @@ -3,7 +3,7 @@ /** * This file is licensed under MIT License. * - * Copyright (c) 2019 Ibrahim BinAlshikh + * Copyright (c) 2019 WebFiori Framework * * For more information on the license, please visit: * https://github.com/WebFiori/.github/blob/main/LICENSE diff --git a/WebFiori/Database/ResultSet.php b/WebFiori/Database/ResultSet.php index 97a13496..3558a686 100644 --- a/WebFiori/Database/ResultSet.php +++ b/WebFiori/Database/ResultSet.php @@ -3,7 +3,7 @@ /** * This file is licensed under MIT License. * - * Copyright (c) 2019 Ibrahim BinAlshikh + * Copyright (c) 2019 WebFiori Framework * * For more information on the license, please visit: * https://github.com/WebFiori/.github/blob/main/LICENSE diff --git a/WebFiori/Database/Schema/AbstractMigration.php b/WebFiori/Database/Schema/AbstractMigration.php index fcae1bdc..ec07ec8a 100644 --- a/WebFiori/Database/Schema/AbstractMigration.php +++ b/WebFiori/Database/Schema/AbstractMigration.php @@ -1,4 +1,13 @@ Date: Tue, 30 Dec 2025 17:17:19 +0300 Subject: [PATCH 02/44] chore: Updated License Headers --- WebFiori/Database/AbstractQuery.php | 2 +- WebFiori/Database/ColOption.php | 2 +- WebFiori/Database/Column.php | 2 +- WebFiori/Database/ColumnFactory.php | 2 +- WebFiori/Database/Condition.php | 2 +- WebFiori/Database/Connection.php | 2 +- WebFiori/Database/ConnectionInfo.php | 2 +- WebFiori/Database/DataType.php | 2 +- WebFiori/Database/Database.php | 2 +- WebFiori/Database/DatabaseException.php | 2 +- WebFiori/Database/DateTimeValidator.php | 2 +- WebFiori/Database/EntityMapper.php | 2 +- WebFiori/Database/Expression.php | 2 +- WebFiori/Database/FK.php | 2 +- WebFiori/Database/ForeignKey.php | 2 +- WebFiori/Database/InsertBuilder.php | 2 +- WebFiori/Database/JoinTable.php | 2 +- WebFiori/Database/MsSql/MSSQLColumn.php | 2 +- WebFiori/Database/MsSql/MSSQLConnection.php | 2 +- WebFiori/Database/MsSql/MSSQLInsertBuilder.php | 2 +- WebFiori/Database/MsSql/MSSQLQuery.php | 2 +- WebFiori/Database/MsSql/MSSQLTable.php | 2 +- WebFiori/Database/MultiResultSet.php | 2 +- WebFiori/Database/MySql/MySQLColumn.php | 2 +- WebFiori/Database/MySql/MySQLConnection.php | 2 +- WebFiori/Database/MySql/MySQLInsertBuilder.php | 2 +- WebFiori/Database/MySql/MySQLQuery.php | 2 +- WebFiori/Database/MySql/MySQLTable.php | 2 +- WebFiori/Database/RecordMapper.php | 2 +- WebFiori/Database/ResultSet.php | 2 +- WebFiori/Database/Schema/AbstractMigration.php | 2 +- WebFiori/Database/Schema/AbstractSeeder.php | 2 +- WebFiori/Database/Schema/DatabaseChange.php | 2 +- WebFiori/Database/Schema/SchemaRunner.php | 2 +- WebFiori/Database/SelectExpression.php | 2 +- WebFiori/Database/Table.php | 2 +- WebFiori/Database/TableFactory.php | 2 +- WebFiori/Database/TypesMap.php | 2 +- WebFiori/Database/WhereExpression.php | 2 +- 39 files changed, 39 insertions(+), 39 deletions(-) diff --git a/WebFiori/Database/AbstractQuery.php b/WebFiori/Database/AbstractQuery.php index dd0014fd..c8b1d3b3 100644 --- a/WebFiori/Database/AbstractQuery.php +++ b/WebFiori/Database/AbstractQuery.php @@ -3,7 +3,7 @@ /** * This file is licensed under MIT License. * - * Copyright (c) 2019 WebFiori Framework + * Copyright (c) 2019-present WebFiori Framework * * For more information on the license, please visit: * https://github.com/WebFiori/.github/blob/main/LICENSE diff --git a/WebFiori/Database/ColOption.php b/WebFiori/Database/ColOption.php index d3e4cc93..39a1e46e 100644 --- a/WebFiori/Database/ColOption.php +++ b/WebFiori/Database/ColOption.php @@ -3,7 +3,7 @@ /** * This file is licensed under MIT License. * - * Copyright (c) 2024 WebFiori Framework + * Copyright (c) 2024-present WebFiori Framework * * For more information on the license, please visit: * https://github.com/WebFiori/.github/blob/main/LICENSE diff --git a/WebFiori/Database/Column.php b/WebFiori/Database/Column.php index 34e1b48c..cb3b6546 100644 --- a/WebFiori/Database/Column.php +++ b/WebFiori/Database/Column.php @@ -3,7 +3,7 @@ /** * This file is licensed under MIT License. * - * Copyright (c) 2019 WebFiori Framework + * Copyright (c) 2019-present WebFiori Framework * * For more information on the license, please visit: * https://github.com/WebFiori/.github/blob/main/LICENSE diff --git a/WebFiori/Database/ColumnFactory.php b/WebFiori/Database/ColumnFactory.php index 72e21f27..10f2048b 100644 --- a/WebFiori/Database/ColumnFactory.php +++ b/WebFiori/Database/ColumnFactory.php @@ -3,7 +3,7 @@ /** * This file is licensed under MIT License. * - * Copyright (c) 2019 WebFiori Framework + * Copyright (c) 2019-present WebFiori Framework * * For more information on the license, please visit: * https://github.com/WebFiori/.github/blob/main/LICENSE diff --git a/WebFiori/Database/Condition.php b/WebFiori/Database/Condition.php index a27cb937..7d6cf82e 100644 --- a/WebFiori/Database/Condition.php +++ b/WebFiori/Database/Condition.php @@ -3,7 +3,7 @@ /** * This file is licensed under MIT License. * - * Copyright (c) 2019 WebFiori Framework + * Copyright (c) 2019-present WebFiori Framework * * For more information on the license, please visit: * https://github.com/WebFiori/.github/blob/main/LICENSE diff --git a/WebFiori/Database/Connection.php b/WebFiori/Database/Connection.php index 86e0e129..18e5bb71 100644 --- a/WebFiori/Database/Connection.php +++ b/WebFiori/Database/Connection.php @@ -3,7 +3,7 @@ /** * This file is licensed under MIT License. * - * Copyright (c) 2019 WebFiori Framework + * Copyright (c) 2019-present WebFiori Framework * * For more information on the license, please visit: * https://github.com/WebFiori/.github/blob/main/LICENSE diff --git a/WebFiori/Database/ConnectionInfo.php b/WebFiori/Database/ConnectionInfo.php index 9200bdfe..a802daf7 100644 --- a/WebFiori/Database/ConnectionInfo.php +++ b/WebFiori/Database/ConnectionInfo.php @@ -3,7 +3,7 @@ /** * This file is licensed under MIT License. * - * Copyright (c) 2019 WebFiori Framework + * Copyright (c) 2019-present WebFiori Framework * * For more information on the license, please visit: * https://github.com/WebFiori/.github/blob/main/LICENSE diff --git a/WebFiori/Database/DataType.php b/WebFiori/Database/DataType.php index 32b60284..897df76c 100644 --- a/WebFiori/Database/DataType.php +++ b/WebFiori/Database/DataType.php @@ -3,7 +3,7 @@ /** * This file is licensed under MIT License. * - * Copyright (c) 2024 WebFiori Framework + * Copyright (c) 2024-present WebFiori Framework * * For more information on the license, please visit: * https://github.com/WebFiori/.github/blob/main/LICENSE diff --git a/WebFiori/Database/Database.php b/WebFiori/Database/Database.php index f0999773..07e439a4 100644 --- a/WebFiori/Database/Database.php +++ b/WebFiori/Database/Database.php @@ -3,7 +3,7 @@ /** * This file is licensed under MIT License. * - * Copyright (c) 2019 WebFiori Framework + * Copyright (c) 2019-present WebFiori Framework * * For more information on the license, please visit: * https://github.com/WebFiori/.github/blob/main/LICENSE diff --git a/WebFiori/Database/DatabaseException.php b/WebFiori/Database/DatabaseException.php index f9627b65..f2a6f65c 100644 --- a/WebFiori/Database/DatabaseException.php +++ b/WebFiori/Database/DatabaseException.php @@ -3,7 +3,7 @@ /** * This file is licensed under MIT License. * - * Copyright (c) 2019 WebFiori Framework + * Copyright (c) 2019-present WebFiori Framework * * For more information on the license, please visit: * https://github.com/WebFiori/.github/blob/main/LICENSE diff --git a/WebFiori/Database/DateTimeValidator.php b/WebFiori/Database/DateTimeValidator.php index 023608df..b1627755 100644 --- a/WebFiori/Database/DateTimeValidator.php +++ b/WebFiori/Database/DateTimeValidator.php @@ -3,7 +3,7 @@ /** * This file is licensed under MIT License. * - * Copyright (c) 2019 WebFiori Framework + * Copyright (c) 2019-present WebFiori Framework * * For more information on the license, please visit: * https://github.com/WebFiori/.github/blob/main/LICENSE diff --git a/WebFiori/Database/EntityMapper.php b/WebFiori/Database/EntityMapper.php index fe31b948..cd266e07 100644 --- a/WebFiori/Database/EntityMapper.php +++ b/WebFiori/Database/EntityMapper.php @@ -3,7 +3,7 @@ /** * This file is licensed under MIT License. * - * Copyright (c) 2019 WebFiori Framework + * Copyright (c) 2019-present WebFiori Framework * * For more information on the license, please visit: * https://github.com/WebFiori/.github/blob/main/LICENSE diff --git a/WebFiori/Database/Expression.php b/WebFiori/Database/Expression.php index 8c5de644..529af744 100644 --- a/WebFiori/Database/Expression.php +++ b/WebFiori/Database/Expression.php @@ -3,7 +3,7 @@ /** * This file is licensed under MIT License. * - * Copyright (c) 2019 WebFiori Framework + * Copyright (c) 2019-present WebFiori Framework * * For more information on the license, please visit: * https://github.com/WebFiori/.github/blob/main/LICENSE diff --git a/WebFiori/Database/FK.php b/WebFiori/Database/FK.php index 1db557f5..c0b52344 100644 --- a/WebFiori/Database/FK.php +++ b/WebFiori/Database/FK.php @@ -3,7 +3,7 @@ /** * This file is licensed under MIT License. * - * Copyright (c) 2023 WebFiori Framework + * Copyright (c) 2023-present WebFiori Framework * * For more information on the license, please visit: * https://github.com/WebFiori/.github/blob/main/LICENSE diff --git a/WebFiori/Database/ForeignKey.php b/WebFiori/Database/ForeignKey.php index 087655f0..4e2234d4 100644 --- a/WebFiori/Database/ForeignKey.php +++ b/WebFiori/Database/ForeignKey.php @@ -3,7 +3,7 @@ /** * This file is licensed under MIT License. * - * Copyright (c) 2019 WebFiori Framework + * Copyright (c) 2019-present WebFiori Framework * * For more information on the license, please visit: * https://github.com/WebFiori/.github/blob/main/LICENSE diff --git a/WebFiori/Database/InsertBuilder.php b/WebFiori/Database/InsertBuilder.php index c8d02df3..75520e91 100644 --- a/WebFiori/Database/InsertBuilder.php +++ b/WebFiori/Database/InsertBuilder.php @@ -3,7 +3,7 @@ /** * This file is licensed under MIT License. * - * Copyright (c) 2023 WebFiori Framework + * Copyright (c) 2023-present WebFiori Framework * * For more information on the license, please visit: * https://github.com/WebFiori/.github/blob/main/LICENSE diff --git a/WebFiori/Database/JoinTable.php b/WebFiori/Database/JoinTable.php index d149af9d..81dc1b42 100644 --- a/WebFiori/Database/JoinTable.php +++ b/WebFiori/Database/JoinTable.php @@ -3,7 +3,7 @@ /** * This file is licensed under MIT License. * - * Copyright (c) 2019 WebFiori Framework + * Copyright (c) 2019-present WebFiori Framework * * For more information on the license, please visit: * https://github.com/WebFiori/.github/blob/main/LICENSE diff --git a/WebFiori/Database/MsSql/MSSQLColumn.php b/WebFiori/Database/MsSql/MSSQLColumn.php index 68807031..0022a6b6 100644 --- a/WebFiori/Database/MsSql/MSSQLColumn.php +++ b/WebFiori/Database/MsSql/MSSQLColumn.php @@ -3,7 +3,7 @@ /** * This file is licensed under MIT License. * - * Copyright (c) 2019 WebFiori Framework + * Copyright (c) 2019-present WebFiori Framework * * For more information on the license, please visit: * https://github.com/WebFiori/.github/blob/main/LICENSE diff --git a/WebFiori/Database/MsSql/MSSQLConnection.php b/WebFiori/Database/MsSql/MSSQLConnection.php index cb1e4c26..e5ba2e68 100644 --- a/WebFiori/Database/MsSql/MSSQLConnection.php +++ b/WebFiori/Database/MsSql/MSSQLConnection.php @@ -3,7 +3,7 @@ /** * This file is licensed under MIT License. * - * Copyright (c) 2019 WebFiori Framework + * Copyright (c) 2019-present WebFiori Framework * * For more information on the license, please visit: * https://github.com/WebFiori/.github/blob/main/LICENSE diff --git a/WebFiori/Database/MsSql/MSSQLInsertBuilder.php b/WebFiori/Database/MsSql/MSSQLInsertBuilder.php index adf8e1b5..e80e8169 100644 --- a/WebFiori/Database/MsSql/MSSQLInsertBuilder.php +++ b/WebFiori/Database/MsSql/MSSQLInsertBuilder.php @@ -3,7 +3,7 @@ /** * This file is licensed under MIT License. * - * Copyright (c) 2023 WebFiori Framework + * Copyright (c) 2023-present WebFiori Framework * * For more information on the license, please visit: * https://github.com/WebFiori/.github/blob/main/LICENSE diff --git a/WebFiori/Database/MsSql/MSSQLQuery.php b/WebFiori/Database/MsSql/MSSQLQuery.php index 8bf90466..2098c546 100644 --- a/WebFiori/Database/MsSql/MSSQLQuery.php +++ b/WebFiori/Database/MsSql/MSSQLQuery.php @@ -3,7 +3,7 @@ /** * This file is licensed under MIT License. * - * Copyright (c) 2019 WebFiori Framework + * Copyright (c) 2019-present WebFiori Framework * * For more information on the license, please visit: * https://github.com/WebFiori/.github/blob/main/LICENSE diff --git a/WebFiori/Database/MsSql/MSSQLTable.php b/WebFiori/Database/MsSql/MSSQLTable.php index 37bbb138..6ab467da 100644 --- a/WebFiori/Database/MsSql/MSSQLTable.php +++ b/WebFiori/Database/MsSql/MSSQLTable.php @@ -3,7 +3,7 @@ /** * This file is licensed under MIT License. * - * Copyright (c) 2019 WebFiori Framework + * Copyright (c) 2019-present WebFiori Framework * * For more information on the license, please visit: * https://github.com/WebFiori/.github/blob/main/LICENSE diff --git a/WebFiori/Database/MultiResultSet.php b/WebFiori/Database/MultiResultSet.php index 59ce88fe..2ec26ab1 100644 --- a/WebFiori/Database/MultiResultSet.php +++ b/WebFiori/Database/MultiResultSet.php @@ -3,7 +3,7 @@ /** * This file is licensed under MIT License. * - * Copyright (c) 2025 WebFiori Framework + * Copyright (c) 2025-present WebFiori Framework * * For more information on the license, please visit: * https://github.com/WebFiori/.github/blob/main/LICENSE diff --git a/WebFiori/Database/MySql/MySQLColumn.php b/WebFiori/Database/MySql/MySQLColumn.php index d178a18b..6ebe686a 100644 --- a/WebFiori/Database/MySql/MySQLColumn.php +++ b/WebFiori/Database/MySql/MySQLColumn.php @@ -3,7 +3,7 @@ /** * This file is licensed under MIT License. * - * Copyright (c) 2019 WebFiori Framework + * Copyright (c) 2019-present WebFiori Framework * * For more information on the license, please visit: * https://github.com/WebFiori/.github/blob/main/LICENSE diff --git a/WebFiori/Database/MySql/MySQLConnection.php b/WebFiori/Database/MySql/MySQLConnection.php index 2040e0c0..1ed602c2 100644 --- a/WebFiori/Database/MySql/MySQLConnection.php +++ b/WebFiori/Database/MySql/MySQLConnection.php @@ -3,7 +3,7 @@ /** * This file is licensed under MIT License. * - * Copyright (c) 2019 WebFiori Framework + * Copyright (c) 2019-present WebFiori Framework * * For more information on the license, please visit: * https://github.com/WebFiori/.github/blob/main/LICENSE diff --git a/WebFiori/Database/MySql/MySQLInsertBuilder.php b/WebFiori/Database/MySql/MySQLInsertBuilder.php index 470b3fd2..963d1a30 100644 --- a/WebFiori/Database/MySql/MySQLInsertBuilder.php +++ b/WebFiori/Database/MySql/MySQLInsertBuilder.php @@ -3,7 +3,7 @@ /** * This file is licensed under MIT License. * - * Copyright (c) 2023 WebFiori Framework + * Copyright (c) 2023-present WebFiori Framework * * For more information on the license, please visit: * https://github.com/WebFiori/.github/blob/main/LICENSE diff --git a/WebFiori/Database/MySql/MySQLQuery.php b/WebFiori/Database/MySql/MySQLQuery.php index 90362c43..cf07b381 100644 --- a/WebFiori/Database/MySql/MySQLQuery.php +++ b/WebFiori/Database/MySql/MySQLQuery.php @@ -3,7 +3,7 @@ /** * This file is licensed under MIT License. * - * Copyright (c) 2019 WebFiori Framework + * Copyright (c) 2019-present WebFiori Framework * * For more information on the license, please visit: * https://github.com/WebFiori/.github/blob/main/LICENSE diff --git a/WebFiori/Database/MySql/MySQLTable.php b/WebFiori/Database/MySql/MySQLTable.php index 743e4ea0..ae9bf2d4 100644 --- a/WebFiori/Database/MySql/MySQLTable.php +++ b/WebFiori/Database/MySql/MySQLTable.php @@ -3,7 +3,7 @@ /** * This file is licensed under MIT License. * - * Copyright (c) 2019 WebFiori Framework + * Copyright (c) 2019-present WebFiori Framework * * For more information on the license, please visit: * https://github.com/WebFiori/.github/blob/main/LICENSE diff --git a/WebFiori/Database/RecordMapper.php b/WebFiori/Database/RecordMapper.php index f1409e8d..f75fec74 100644 --- a/WebFiori/Database/RecordMapper.php +++ b/WebFiori/Database/RecordMapper.php @@ -3,7 +3,7 @@ /** * This file is licensed under MIT License. * - * Copyright (c) 2019 WebFiori Framework + * Copyright (c) 2019-present WebFiori Framework * * For more information on the license, please visit: * https://github.com/WebFiori/.github/blob/main/LICENSE diff --git a/WebFiori/Database/ResultSet.php b/WebFiori/Database/ResultSet.php index 3558a686..045d0eba 100644 --- a/WebFiori/Database/ResultSet.php +++ b/WebFiori/Database/ResultSet.php @@ -3,7 +3,7 @@ /** * This file is licensed under MIT License. * - * Copyright (c) 2019 WebFiori Framework + * Copyright (c) 2019-present WebFiori Framework * * For more information on the license, please visit: * https://github.com/WebFiori/.github/blob/main/LICENSE diff --git a/WebFiori/Database/Schema/AbstractMigration.php b/WebFiori/Database/Schema/AbstractMigration.php index ec07ec8a..2921230a 100644 --- a/WebFiori/Database/Schema/AbstractMigration.php +++ b/WebFiori/Database/Schema/AbstractMigration.php @@ -2,7 +2,7 @@ /** * This file is licensed under MIT License. * - * Copyright (c) 2025 WebFiori Framework + * Copyright (c) 2025-present WebFiori Framework * * For more information on the license, please visit: * https://github.com/WebFiori/.github/blob/main/LICENSE diff --git a/WebFiori/Database/Schema/AbstractSeeder.php b/WebFiori/Database/Schema/AbstractSeeder.php index 2a4032bb..5c5e68ff 100644 --- a/WebFiori/Database/Schema/AbstractSeeder.php +++ b/WebFiori/Database/Schema/AbstractSeeder.php @@ -2,7 +2,7 @@ /** * This file is licensed under MIT License. * - * Copyright (c) 2025 WebFiori Framework + * Copyright (c) 2025-present WebFiori Framework * * For more information on the license, please visit: * https://github.com/WebFiori/.github/blob/main/LICENSE diff --git a/WebFiori/Database/Schema/DatabaseChange.php b/WebFiori/Database/Schema/DatabaseChange.php index b76179ba..4aa0db6d 100644 --- a/WebFiori/Database/Schema/DatabaseChange.php +++ b/WebFiori/Database/Schema/DatabaseChange.php @@ -2,7 +2,7 @@ /** * This file is licensed under MIT License. * - * Copyright (c) 2025 WebFiori Framework + * Copyright (c) 2025-present WebFiori Framework * * For more information on the license, please visit: * https://github.com/WebFiori/.github/blob/main/LICENSE diff --git a/WebFiori/Database/Schema/SchemaRunner.php b/WebFiori/Database/Schema/SchemaRunner.php index 6fe61fb4..e5ea8536 100644 --- a/WebFiori/Database/Schema/SchemaRunner.php +++ b/WebFiori/Database/Schema/SchemaRunner.php @@ -2,7 +2,7 @@ /** * This file is licensed under MIT License. * - * Copyright (c) 2025 WebFiori Framework + * Copyright (c) 2025-present WebFiori Framework * * For more information on the license, please visit: * https://github.com/WebFiori/.github/blob/main/LICENSE diff --git a/WebFiori/Database/SelectExpression.php b/WebFiori/Database/SelectExpression.php index afec63c6..728403b8 100644 --- a/WebFiori/Database/SelectExpression.php +++ b/WebFiori/Database/SelectExpression.php @@ -3,7 +3,7 @@ /** * This file is licensed under MIT License. * - * Copyright (c) 2019 WebFiori Framework + * Copyright (c) 2019-present WebFiori Framework * * For more information on the license, please visit: * https://github.com/WebFiori/.github/blob/main/LICENSE diff --git a/WebFiori/Database/Table.php b/WebFiori/Database/Table.php index 4a7f89c8..75c565ad 100644 --- a/WebFiori/Database/Table.php +++ b/WebFiori/Database/Table.php @@ -3,7 +3,7 @@ /** * This file is licensed under MIT License. * - * Copyright (c) 2019 WebFiori Framework + * Copyright (c) 2019-present WebFiori Framework * * For more information on the license, please visit: * https://github.com/WebFiori/.github/blob/main/LICENSE diff --git a/WebFiori/Database/TableFactory.php b/WebFiori/Database/TableFactory.php index 843372b6..57588ac4 100644 --- a/WebFiori/Database/TableFactory.php +++ b/WebFiori/Database/TableFactory.php @@ -3,7 +3,7 @@ /** * This file is licensed under MIT License. * - * Copyright (c) 2025 WebFiori Framework + * Copyright (c) 2025-present WebFiori Framework * * For more information on the license, please visit: * https://github.com/WebFiori/.github/blob/main/LICENSE diff --git a/WebFiori/Database/TypesMap.php b/WebFiori/Database/TypesMap.php index b953a2e0..bad80ebf 100644 --- a/WebFiori/Database/TypesMap.php +++ b/WebFiori/Database/TypesMap.php @@ -3,7 +3,7 @@ /** * This file is licensed under MIT License. * - * Copyright (c) 2023 WebFiori Framework + * Copyright (c) 2023-present WebFiori Framework * * For more information on the license, please visit: * https://github.com/WebFiori/.github/blob/main/LICENSE diff --git a/WebFiori/Database/WhereExpression.php b/WebFiori/Database/WhereExpression.php index d986d513..d5eaba7f 100644 --- a/WebFiori/Database/WhereExpression.php +++ b/WebFiori/Database/WhereExpression.php @@ -3,7 +3,7 @@ /** * This file is licensed under MIT License. * - * Copyright (c) 2019 WebFiori Framework + * Copyright (c) 2019-present WebFiori Framework * * For more information on the license, please visit: * https://github.com/WebFiori/.github/blob/main/LICENSE From ff787982ba3c9bc6d88eb4b2eb54700d429392ee Mon Sep 17 00:00:00 2001 From: Ibrahim BinAlshikh Date: Thu, 1 Jan 2026 00:06:27 +0300 Subject: [PATCH 03/44] feat: Repo --- .../Repository/AbstractRepository.php | 149 ++++++++++++++++++ WebFiori/Database/Repository/CursorPage.php | 43 +++++ WebFiori/Database/Repository/Page.php | 63 ++++++++ 3 files changed, 255 insertions(+) create mode 100644 WebFiori/Database/Repository/AbstractRepository.php create mode 100644 WebFiori/Database/Repository/CursorPage.php create mode 100644 WebFiori/Database/Repository/Page.php diff --git a/WebFiori/Database/Repository/AbstractRepository.php b/WebFiori/Database/Repository/AbstractRepository.php new file mode 100644 index 00000000..e1e4660e --- /dev/null +++ b/WebFiori/Database/Repository/AbstractRepository.php @@ -0,0 +1,149 @@ +db = $db; + } + + abstract protected function getTableName(): string; + abstract protected function toEntity(array $row): object; + abstract protected function toArray(object $entity): array; + abstract protected function getIdField(): string; + + /** @return T|null */ + public function findById(mixed $id): ?object { + $result = $this->db->table($this->getTableName()) + ->select() + ->where($this->getIdField(), $id) + ->execute(); + + return $result->getCount() > 0 ? $this->toEntity($result->fetch()) : null; + } + + /** @return T[] */ + public function findAll(): array { + $result = $this->db->table($this->getTableName()) + ->select() + ->execute(); + + return array_map(fn($row) => $this->toEntity($row), $result->fetchAll()); + } + + public function count(): int { + $result = $this->db->table($this->getTableName()) + ->selectCount(null, 'total') + ->execute(); + + return (int) $result->fetch()['total']; + } + + /** @return Page */ + public function paginate(int $page = 1, int $perPage = 20, array $orderBy = []): Page { + $page = max(1, $page); + $offset = ($page - 1) * $perPage; + + $total = $this->count(); + + $query = $this->db->table($this->getTableName()) + ->select() + ->limit($perPage, $offset); + + if (!empty($orderBy)) { + $query->orderBy($orderBy); + } + + $result = $query->execute(); + $items = array_map(fn($row) => $this->toEntity($row), $result->fetchAll()); + + return new Page($items, $page, $perPage, $total); + } + + /** @return CursorPage */ + public function paginateByCursor( + ?string $cursor = null, + int $limit = 20, + ?string $cursorField = null, + string $direction = 'ASC' + ): CursorPage { + $cursorField = $cursorField ?? $this->getIdField(); + $operator = $direction === 'ASC' ? '>' : '<'; + + $query = $this->db->table($this->getTableName())->select(); + + if ($cursor !== null) { + $cursorValue = base64_decode($cursor); + $query->where($cursorField, $cursorValue, $operator); + } + + $result = $query->orderBy([$cursorField => $direction]) + ->limit($limit + 1) + ->execute(); + + $rows = $result->fetchAll(); + $hasMore = count($rows) > $limit; + + if ($hasMore) { + array_pop($rows); + } + + $items = array_map(fn($row) => $this->toEntity($row), $rows); + + $nextCursor = null; + if ($hasMore && !empty($rows)) { + $lastRow = end($rows); + $nextCursor = base64_encode((string) $lastRow[$cursorField]); + } + + return new CursorPage($items, $nextCursor, null, $hasMore); + } + + /** @param T $entity */ + public function save(object $entity): void { + $data = $this->toArray($entity); + $id = $data[$this->getIdField()] ?? null; + unset($data[$this->getIdField()]); + + if ($id === null) { + $this->db->table($this->getTableName())->insert($data)->execute(); + } else { + $this->db->table($this->getTableName()) + ->update($data) + ->where($this->getIdField(), $id) + ->execute(); + } + } + + public function deleteById(mixed $id): void { + $this->db->table($this->getTableName()) + ->delete() + ->where($this->getIdField(), $id) + ->execute(); + } + + public function deleteAll(): void { + $this->db->table($this->getTableName()) + ->delete() + ->execute(); + } + + protected function createQuery(): \WebFiori\Database\AbstractQuery { + return $this->db->table($this->getTableName())->select(); + } + + protected function getDatabase(): Database { + return $this->db; + } +} diff --git a/WebFiori/Database/Repository/CursorPage.php b/WebFiori/Database/Repository/CursorPage.php new file mode 100644 index 00000000..52925e14 --- /dev/null +++ b/WebFiori/Database/Repository/CursorPage.php @@ -0,0 +1,43 @@ +items = $items; + $this->nextCursor = $nextCursor; + $this->previousCursor = $previousCursor; + $this->hasMore = $hasMore; + } + + /** @return T[] */ + public function getItems(): array { + return $this->items; + } + + public function getNextCursor(): ?string { + return $this->nextCursor; + } + + public function getPreviousCursor(): ?string { + return $this->previousCursor; + } + + public function hasMore(): bool { + return $this->hasMore; + } +} diff --git a/WebFiori/Database/Repository/Page.php b/WebFiori/Database/Repository/Page.php new file mode 100644 index 00000000..de93e795 --- /dev/null +++ b/WebFiori/Database/Repository/Page.php @@ -0,0 +1,63 @@ +items = $items; + $this->currentPage = $currentPage; + $this->perPage = $perPage; + $this->totalItems = $totalItems; + } + + /** @return T[] */ + public function getItems(): array { + return $this->items; + } + + public function getCurrentPage(): int { + return $this->currentPage; + } + + public function getPerPage(): int { + return $this->perPage; + } + + public function getTotalItems(): int { + return $this->totalItems; + } + + public function getTotalPages(): int { + return (int) ceil($this->totalItems / $this->perPage); + } + + public function hasNextPage(): bool { + return $this->currentPage < $this->getTotalPages(); + } + + public function hasPreviousPage(): bool { + return $this->currentPage > 1; + } + + public function getNextPage(): ?int { + return $this->hasNextPage() ? $this->currentPage + 1 : null; + } + + public function getPreviousPage(): ?int { + return $this->hasPreviousPage() ? $this->currentPage - 1 : null; + } +} From 9ea1fcf38dc12f0a4bd491817b99b7212f216084 Mon Sep 17 00:00:00 2001 From: Ibrahim BinAlshikh Date: Thu, 1 Jan 2026 00:06:37 +0300 Subject: [PATCH 04/44] Update ResultSet.php --- WebFiori/Database/ResultSet.php | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/WebFiori/Database/ResultSet.php b/WebFiori/Database/ResultSet.php index 045d0eba..3d1b75a8 100644 --- a/WebFiori/Database/ResultSet.php +++ b/WebFiori/Database/ResultSet.php @@ -51,6 +51,18 @@ class ResultSet implements Countable, Iterator { public function __construct(array $resultArr = []) { $this->setData($resultArr); } + public function fetchAll() : array { + return $this->getRows(); + } + public function getCount() : int { + return $this->getRowsCount(); + } + public function fetch() : array { + if ($this->getCount() > 0) { + return $this->fetchAll()[0]; + } + return []; + } /** * Reset the values in the set to default values. * From 83c4a5cc76029b8ed7da6302f728bf025ef67af6 Mon Sep 17 00:00:00 2001 From: Ibrahim BinAlshikh Date: Thu, 1 Jan 2026 00:09:44 +0300 Subject: [PATCH 05/44] feat: Attributes --- .../Attributes/AttributeTableBuilder.php | 114 ++++++++++++++++++ WebFiori/Database/Attributes/Column.php | 24 ++++ WebFiori/Database/Attributes/ForeignKey.php | 16 +++ WebFiori/Database/Attributes/Table.php | 13 ++ .../Repository/AbstractRepository.php | 3 +- 5 files changed, 169 insertions(+), 1 deletion(-) create mode 100644 WebFiori/Database/Attributes/AttributeTableBuilder.php create mode 100644 WebFiori/Database/Attributes/Column.php create mode 100644 WebFiori/Database/Attributes/ForeignKey.php create mode 100644 WebFiori/Database/Attributes/Table.php diff --git a/WebFiori/Database/Attributes/AttributeTableBuilder.php b/WebFiori/Database/Attributes/AttributeTableBuilder.php new file mode 100644 index 00000000..e9258202 --- /dev/null +++ b/WebFiori/Database/Attributes/AttributeTableBuilder.php @@ -0,0 +1,114 @@ +getAttributes(Table::class)[0] ?? null; + + if (!$tableAttr) { + throw new \RuntimeException("Class $entityClass must have #[Table] attribute"); + } + + $tableConfig = $tableAttr->newInstance(); + + $table = $dbType === 'mysql' + ? new MySQLTable($tableConfig->name) + : new MSSQLTable($tableConfig->name); + + if ($tableConfig->comment) { + $table->setComment($tableConfig->comment); + } + + $columns = []; + $foreignKeys = []; + + foreach ($reflection->getProperties() as $property) { + $columnAttrs = $property->getAttributes(Column::class); + + if (empty($columnAttrs)) { + continue; + } + + $columnConfig = $columnAttrs[0]->newInstance(); + $columnKey = self::propertyToKey($property->getName()); + + $columns[$columnKey] = [ + ColOption::TYPE => $columnConfig->type, + ColOption::NAME => $columnConfig->name, + ColOption::SIZE => $columnConfig->size, + ColOption::SCALE => $columnConfig->scale, + ColOption::PRIMARY => $columnConfig->primary, + ColOption::UNIQUE => $columnConfig->unique, + ColOption::NULL => $columnConfig->nullable, + ColOption::AUTO_INCREMENT => $columnConfig->autoIncrement, + ColOption::IDENTITY => $columnConfig->identity, + ColOption::AUTO_UPDATE => $columnConfig->autoUpdate, + ColOption::DEFAULT => $columnConfig->default, + ColOption::COMMENT => $columnConfig->comment, + ColOption::VALIDATOR => $columnConfig->validator + ]; + + $fkAttrs = $property->getAttributes(ForeignKey::class); + foreach ($fkAttrs as $fkAttr) { + $fkConfig = $fkAttr->newInstance(); + $foreignKeys[] = [ + 'property' => $columnKey, + 'config' => $fkConfig + ]; + } + } + + $table->addColumns($columns); + + // Store table references for foreign keys + $tableRegistry = []; + + foreach ($foreignKeys as $fk) { + $refTableName = $fk['config']->table; + $refColName = $fk['config']->column; + + // Create a minimal table reference if not exists + if (!isset($tableRegistry[$refTableName])) { + $refTable = $dbType === 'mysql' + ? new MySQLTable($refTableName) + : new MSSQLTable($refTableName); + + // Add the referenced column to make FK work + $refTable->addColumns([ + $refColName => [ + ColOption::TYPE => DataType::INT, + ColOption::PRIMARY => true + ] + ]); + + $tableRegistry[$refTableName] = $refTable; + } + + $table->addReference( + $tableRegistry[$refTableName], + [$fk['property'] => $refColName], + $fk['config']->name ?? 'fk_' . $fk['property'], + $fk['config']->onUpdate, + $fk['config']->onDelete + ); + } + + return $table; + } + + private static function propertyToKey(string $propertyName): string { + return strtolower(preg_replace('/([a-z])([A-Z])/', '$1-$2', $propertyName)); + } +} diff --git a/WebFiori/Database/Attributes/Column.php b/WebFiori/Database/Attributes/Column.php new file mode 100644 index 00000000..d22fcbcf --- /dev/null +++ b/WebFiori/Database/Attributes/Column.php @@ -0,0 +1,24 @@ +db->table($this->getTableName()) ->select() - ->limit($perPage, $offset); + ->limit($perPage) + ->offset($offset); if (!empty($orderBy)) { $query->orderBy($orderBy); From dbde80c0e7c89acfe94f7754a97af79c204bfaf8 Mon Sep 17 00:00:00 2001 From: Ibrahim BinAlshikh Date: Thu, 1 Jan 2026 13:33:37 +0300 Subject: [PATCH 06/44] feat: Entity Generator --- WebFiori/Database/Column.php | 8 +- WebFiori/Database/DataType.php | 15 ++ WebFiori/Database/EntityGenerator.php | 208 ++++++++++++++++++++++++++ WebFiori/Database/EntityMapper.php | 11 +- 4 files changed, 235 insertions(+), 7 deletions(-) create mode 100644 WebFiori/Database/EntityGenerator.php diff --git a/WebFiori/Database/Column.php b/WebFiori/Database/Column.php index cb3b6546..d02d806c 100644 --- a/WebFiori/Database/Column.php +++ b/WebFiori/Database/Column.php @@ -325,15 +325,11 @@ public function getOwner() { * the column to an entity class. For example, the 'varchar' in MySQL is * a 'string' in PHP. * - * @return string A string that represents column datatype in PHP. + * @return string A string that represents column datatype in PHP (int, float, bool, string). * */ public function getPHPType() : string { - if ($this->getDatatype() == 'char') { - return 'string'; - } - - return 'mixed'; + return DataType::toPHPType($this->getDatatype()); } /** * Returns the previous table which was owns the column. diff --git a/WebFiori/Database/DataType.php b/WebFiori/Database/DataType.php index 897df76c..1b683492 100644 --- a/WebFiori/Database/DataType.php +++ b/WebFiori/Database/DataType.php @@ -204,4 +204,19 @@ class DataType { * */ const VARCHAR = 'varchar'; + + /** + * Maps database data type to PHP type. + * + * @param string $dbType The database data type + * @return string The PHP type (int, float, bool, string) + */ + public static function toPHPType(string $dbType): string { + return match (strtolower($dbType)) { + self::INT, self::BIGINT => 'int', + self::FLOAT, self::DOUBLE, self::DECIMAL, self::MONEY => 'float', + self::BOOL, self::BIT => 'bool', + default => 'string' + }; + } } diff --git a/WebFiori/Database/EntityGenerator.php b/WebFiori/Database/EntityGenerator.php new file mode 100644 index 00000000..c72ca0ac --- /dev/null +++ b/WebFiori/Database/EntityGenerator.php @@ -0,0 +1,208 @@ +table = $table; + $this->entityName = $entityName; + $this->path = rtrim($path, '/\\'); + $this->namespace = trim($namespace, '\\'); + } + + /** + * Generates the entity class file. + * + * @return bool True on success, false on failure + */ + public function generate(): bool { + $code = $this->buildClass(); + $filePath = $this->path . DIRECTORY_SEPARATOR . $this->entityName . '.php'; + return file_put_contents($filePath, $code) !== false; + } + + /** + * Builds the complete class code. + * + * @return string The generated PHP code + */ + private function buildClass(): string { + $code = "namespace) { + $code .= "namespace {$this->namespace};\n\n"; + } + + $code .= "/**\n"; + $code .= " * Auto-generated immutable entity for table '{$this->table->getName()}'\n"; + $code .= " * \n"; + $code .= " * Generated on: " . date('Y-m-d H:i:s') . "\n"; + $code .= " * \n"; + $code .= " * This entity uses:\n"; + $code .= " * - Protected properties (extensible)\n"; + $code .= " * - Named arguments (PHP 8+)\n"; + $code .= " * - Immutable (no setters)\n"; + $code .= " */\n"; + $code .= "class {$this->entityName} {\n"; + + $code .= $this->buildConstructor(); + $code .= $this->buildGetters(); + + $code .= "}\n"; + + return $code; + } + + /** + * Builds the constructor with promoted properties. + * + * @return string The constructor code + */ + private function buildConstructor(): string { + $code = " public function __construct(\n"; + $params = []; + + foreach ($this->table->getCols() as $key => $col) { + $phpType = $col->getPHPType(); + $propName = $this->toCamelCase($key); + $nullable = $this->isNullable($col) ? '?' : ''; + $default = $this->getDefault($col); + + $params[] = " protected {$nullable}{$phpType} \${$propName}{$default}"; + } + + $code .= implode(",\n", $params); + $code .= "\n ) {}\n\n"; + + return $code; + } + + /** + * Builds getter methods for all properties. + * + * @return string The getter methods code + */ + private function buildGetters(): string { + $code = ''; + + foreach ($this->table->getCols() as $key => $col) { + $phpType = $col->getPHPType(); + $propName = $this->toCamelCase($key); + $methodName = 'get' . ucfirst($propName); + $nullable = $this->isNullable($col) ? '?' : ''; + + $code .= " public function {$methodName}(): {$nullable}{$phpType} {\n"; + $code .= " return \$this->{$propName};\n"; + $code .= " }\n\n"; + } + + return $code; + } + + /** + * Checks if column should be nullable in PHP. + * + * @param Column $col The column to check + * @return bool True if nullable, false otherwise + */ + private function isNullable(Column $col): bool { + return $col->isNull() || $col->isAutoInc(); + } + + /** + * Gets the default value for a property. + * + * @param Column $col The column to get default for + * @return string The default value as PHP code + */ + private function getDefault(Column $col): string { + if ($col->isAutoInc()) { + return ' = null'; + } + + if ($col->isNull()) { + return ' = null'; + } + + $default = $col->getDefault(); + if ($default !== null) { + $phpType = $col->getPHPType(); + + if ($phpType === 'string') { + return " = '" . addslashes($default) . "'"; + } + if ($phpType === 'int' || $phpType === 'float') { + return " = {$default}"; + } + if ($phpType === 'bool') { + return $default ? ' = true' : ' = false'; + } + } + + // Required field with no default + $phpType = $col->getPHPType(); + if ($phpType === 'string') { + return " = ''"; + } + if ($phpType === 'int') { + return ' = 0'; + } + if ($phpType === 'float') { + return ' = 0.0'; + } + if ($phpType === 'bool') { + return ' = false'; + } + + return ''; + } + + /** + * Converts kebab-case to camelCase. + * + * @param string $key The kebab-case string + * @return string The camelCase string + */ + private function toCamelCase(string $key): string { + $parts = explode('-', $key); + $camelCase = array_shift($parts); + + foreach ($parts as $part) { + $camelCase .= ucfirst($part); + } + + return $camelCase; + } +} diff --git a/WebFiori/Database/EntityMapper.php b/WebFiori/Database/EntityMapper.php index cd266e07..081dc618 100644 --- a/WebFiori/Database/EntityMapper.php +++ b/WebFiori/Database/EntityMapper.php @@ -23,6 +23,13 @@ * - Static mapping method for converting database records to objects * - Proper type hints and documentation * + * @deprecated Use manual entity classes with Repository pattern instead. + * This class is kept for legacy support and rapid prototyping only. + * For production code, create entities manually and use AbstractRepository + * with toEntity() method for mapping. + * + * @see AbstractRepository For the recommended approach to entity mapping + * * @author Ibrahim * */ @@ -175,8 +182,10 @@ public function create() : bool { ."\n"; } $this->classStr .= "/**\n" - ." * An auto-generated entity class which maps to a record in the\n" + ." * Domain entity which maps to a record in the\n" ." * table '".trim($this->getTable()->getNormalName(), "`")."'\n" + ." *" + ." * Each model consist of table schema + domain entity.'\n" ." **/\n"; if ($this->implJsonI) { From a4f1a7f8b72a05cf87ef4c22309ba3c6d2cb90a2 Mon Sep 17 00:00:00 2001 From: Ibrahim BinAlshikh Date: Thu, 1 Jan 2026 13:42:18 +0300 Subject: [PATCH 07/44] refactor: Move Classes to Correct NS --- WebFiori/Database/AbstractQuery.php | 3 ++ WebFiori/Database/JoinTable.php | 1 + .../Database/MsSql/MSSQLInsertBuilder.php | 2 +- .../Database/MySql/MySQLInsertBuilder.php | 2 +- WebFiori/Database/{ => Query}/Condition.php | 4 +- WebFiori/Database/{ => Query}/Expression.php | 2 +- .../Database/{ => Query}/InsertBuilder.php | 2 +- .../Database/{ => Query}/SelectExpression.php | 3 +- .../Database/{ => Query}/WhereExpression.php | 2 +- WebFiori/Database/Table.php | 20 ++++++++++ .../Tests/Database/Common/ConditionTest.php | 2 +- .../Tests/Database/Common/ExpressionTest.php | 8 ++-- .../Database/MsSql/MSSQLQueryBuilderTest.php | 2 +- .../Tests/Database/MsSql/MSSQLTableTest.php | 39 ++++++++++++++++++- 14 files changed, 78 insertions(+), 14 deletions(-) rename WebFiori/Database/{ => Query}/Condition.php (98%) rename WebFiori/Database/{ => Query}/Expression.php (98%) rename WebFiori/Database/{ => Query}/InsertBuilder.php (99%) rename WebFiori/Database/{ => Query}/SelectExpression.php (99%) rename WebFiori/Database/{ => Query}/WhereExpression.php (99%) diff --git a/WebFiori/Database/AbstractQuery.php b/WebFiori/Database/AbstractQuery.php index c8b1d3b3..e225d7a4 100644 --- a/WebFiori/Database/AbstractQuery.php +++ b/WebFiori/Database/AbstractQuery.php @@ -14,6 +14,9 @@ use Throwable; use WebFiori\Database\MsSql\MSSQLQuery; use WebFiori\Database\MySql\MySQLQuery; +use WebFiori\Database\Query\Condition; +use WebFiori\Database\Query\Expression; +use WebFiori\Database\Query\InsertBuilder; /** * A base class that can be used to build SQL queries. * diff --git a/WebFiori/Database/JoinTable.php b/WebFiori/Database/JoinTable.php index 81dc1b42..74bc9c8f 100644 --- a/WebFiori/Database/JoinTable.php +++ b/WebFiori/Database/JoinTable.php @@ -17,6 +17,7 @@ use WebFiori\Database\MySql\MySQLColumn; use WebFiori\Database\MySql\MySQLQuery; use WebFiori\Database\MySql\MySQLTable; +use WebFiori\Database\Query\Condition; /** * A class that represents two joined tables. * diff --git a/WebFiori/Database/MsSql/MSSQLInsertBuilder.php b/WebFiori/Database/MsSql/MSSQLInsertBuilder.php index e80e8169..ee493748 100644 --- a/WebFiori/Database/MsSql/MSSQLInsertBuilder.php +++ b/WebFiori/Database/MsSql/MSSQLInsertBuilder.php @@ -11,7 +11,7 @@ */ namespace WebFiori\Database\MsSql; -use WebFiori\Database\InsertBuilder; +use WebFiori\Database\Query\InsertBuilder; /** * A class which is used to construct insert query for MSSQL server. diff --git a/WebFiori/Database/MySql/MySQLInsertBuilder.php b/WebFiori/Database/MySql/MySQLInsertBuilder.php index 963d1a30..6fc7a7c7 100644 --- a/WebFiori/Database/MySql/MySQLInsertBuilder.php +++ b/WebFiori/Database/MySql/MySQLInsertBuilder.php @@ -12,7 +12,7 @@ namespace WebFiori\Database\MySql; use WebFiori\Database\Column; -use WebFiori\Database\InsertBuilder; +use WebFiori\Database\Query\InsertBuilder; /** * A class which is used to construct insert query for MySQL database. diff --git a/WebFiori/Database/Condition.php b/WebFiori/Database/Query/Condition.php similarity index 98% rename from WebFiori/Database/Condition.php rename to WebFiori/Database/Query/Condition.php index 7d6cf82e..cd9274b7 100644 --- a/WebFiori/Database/Condition.php +++ b/WebFiori/Database/Query/Condition.php @@ -9,7 +9,9 @@ * https://github.com/WebFiori/.github/blob/main/LICENSE * */ -namespace WebFiori\Database; +namespace WebFiori\Database\Query; + +use WebFiori\Database\Column; /** * Represents a binary conditional statement for SQL WHERE clauses. diff --git a/WebFiori/Database/Expression.php b/WebFiori/Database/Query/Expression.php similarity index 98% rename from WebFiori/Database/Expression.php rename to WebFiori/Database/Query/Expression.php index 529af744..9fa0918f 100644 --- a/WebFiori/Database/Expression.php +++ b/WebFiori/Database/Query/Expression.php @@ -9,7 +9,7 @@ * https://github.com/WebFiori/.github/blob/main/LICENSE * */ -namespace WebFiori\Database; +namespace WebFiori\Database\Query; /** * A class that can be used to represent any SQL expression. diff --git a/WebFiori/Database/InsertBuilder.php b/WebFiori/Database/Query/InsertBuilder.php similarity index 99% rename from WebFiori/Database/InsertBuilder.php rename to WebFiori/Database/Query/InsertBuilder.php index 75520e91..6b5923a7 100644 --- a/WebFiori/Database/InsertBuilder.php +++ b/WebFiori/Database/Query/InsertBuilder.php @@ -9,7 +9,7 @@ * https://github.com/WebFiori/.github/blob/main/LICENSE * */ -namespace WebFiori\Database; +namespace WebFiori\Database\Query; /** * A class which is used to build insert SQL queries for diffrent database engines. diff --git a/WebFiori/Database/SelectExpression.php b/WebFiori/Database/Query/SelectExpression.php similarity index 99% rename from WebFiori/Database/SelectExpression.php rename to WebFiori/Database/Query/SelectExpression.php index 728403b8..0a88920d 100644 --- a/WebFiori/Database/SelectExpression.php +++ b/WebFiori/Database/Query/SelectExpression.php @@ -9,9 +9,10 @@ * https://github.com/WebFiori/.github/blob/main/LICENSE * */ -namespace WebFiori\Database; +namespace WebFiori\Database\Query; use InvalidArgumentException; +use WebFiori\Database\Table; /** * A class which is used to build the select expression of a select query. diff --git a/WebFiori/Database/WhereExpression.php b/WebFiori/Database/Query/WhereExpression.php similarity index 99% rename from WebFiori/Database/WhereExpression.php rename to WebFiori/Database/Query/WhereExpression.php index d5eaba7f..dba3714f 100644 --- a/WebFiori/Database/WhereExpression.php +++ b/WebFiori/Database/Query/WhereExpression.php @@ -9,7 +9,7 @@ * https://github.com/WebFiori/.github/blob/main/LICENSE * */ -namespace WebFiori\Database; +namespace WebFiori\Database\Query; /** * A class which is used to build 'where' expressions. diff --git a/WebFiori/Database/Table.php b/WebFiori/Database/Table.php index 75c565ad..efe346ea 100644 --- a/WebFiori/Database/Table.php +++ b/WebFiori/Database/Table.php @@ -380,6 +380,26 @@ public function getEntityMapper() : EntityMapper { return $this->mapper; } + + /** + * Returns an entity generator for generating PHP 8+ immutable entities. + * + * The generator creates entities with: + * - Protected properties (extensible) + * - Named arguments + * - Only getters (immutable) + * - Proper type hints + * + * @param string $entityName The name of the entity class + * @param string $path The directory where the entity will be created + * @param string $namespace The namespace for the entity + * + * @return EntityGenerator An instance of EntityGenerator + */ + public function getEntityGenerator(string $entityName = 'Entity', string $path = __DIR__, string $namespace = '') : EntityGenerator { + return new EntityGenerator($this, $entityName, $path, $namespace); + } + /** * Returns a foreign key given its name. * diff --git a/tests/WebFiori/Tests/Database/Common/ConditionTest.php b/tests/WebFiori/Tests/Database/Common/ConditionTest.php index 250d7621..bcdd40b4 100644 --- a/tests/WebFiori/Tests/Database/Common/ConditionTest.php +++ b/tests/WebFiori/Tests/Database/Common/ConditionTest.php @@ -1,7 +1,7 @@ assertEquals('boolean',$table->getColByKey('is-active')->getDatatype()); } -} + /** + * @test + */ + public function testAttributeBasedTable() { + $table = \WebFiori\Database\Attributes\AttributeTableBuilder::build( + \WebFiori\Tests\Database\MsSql\MSSQLAttributeTestUser::class, + 'mssql' + ); + + $this->assertEquals('[test_users]', $table->getName()); + $this->assertTrue($table->hasColumn('id')); + $this->assertTrue($table->hasColumn('name')); + $this->assertTrue($table->hasColumn('email')); + + $idCol = $table->getColByKey('id'); + $this->assertTrue($idCol->isPrimary()); + $this->assertTrue($idCol->isIdentity()); + + $emailCol = $table->getColByKey('email'); + $this->assertTrue($emailCol->isUnique()); + } + /** + * @test + */ + public function testAttributeBasedTableWithFK() { + $table = \WebFiori\Database\Attributes\AttributeTableBuilder::build( + \WebFiori\Tests\Database\MsSql\MSSQLAttributeTestPost::class, + 'mssql' + ); + + $this->assertEquals('[test_posts]', $table->getName()); + $this->assertTrue($table->hasColumnWithKey('user-id')); + + $fk = $table->getForeignKey('fk_post_user'); + $this->assertNotNull($fk); + $this->assertEquals('[test_users]', $fk->getSourceName()); + } +} From 889a65d66b4f62a3a77e4a831e01c93dc621a31e Mon Sep 17 00:00:00 2001 From: Ibrahim BinAlshikh Date: Thu, 1 Jan 2026 13:47:55 +0300 Subject: [PATCH 08/44] refactor: Namespaces Correction --- WebFiori/Database/Database.php | 1 + WebFiori/Database/{ => Entity}/EntityGenerator.php | 5 ++++- WebFiori/Database/{ => Entity}/EntityMapper.php | 4 +++- WebFiori/Database/{ => Entity}/RecordMapper.php | 2 +- WebFiori/Database/{ => Factory}/ColumnFactory.php | 7 ++++++- WebFiori/Database/{ => Factory}/TableFactory.php | 5 ++++- WebFiori/Database/MsSql/MSSQLColumn.php | 2 +- WebFiori/Database/MySql/MySQLColumn.php | 2 +- WebFiori/Database/{ => Util}/DateTimeValidator.php | 2 +- WebFiori/Database/{ => Util}/TypesMap.php | 2 +- tests/WebFiori/Tests/Database/Common/EntityMapperTest.php | 2 +- tests/WebFiori/Tests/Database/Common/RecordMapperTest.php | 2 +- 12 files changed, 25 insertions(+), 11 deletions(-) rename WebFiori/Database/{ => Entity}/EntityGenerator.php (98%) rename WebFiori/Database/{ => Entity}/EntityMapper.php (99%) rename WebFiori/Database/{ => Entity}/RecordMapper.php (99%) rename WebFiori/Database/{ => Factory}/ColumnFactory.php (96%) rename WebFiori/Database/{ => Factory}/TableFactory.php (91%) rename WebFiori/Database/{ => Util}/DateTimeValidator.php (98%) rename WebFiori/Database/{ => Util}/TypesMap.php (98%) diff --git a/WebFiori/Database/Database.php b/WebFiori/Database/Database.php index 07e439a4..5829f3f3 100644 --- a/WebFiori/Database/Database.php +++ b/WebFiori/Database/Database.php @@ -18,6 +18,7 @@ use WebFiori\Database\MySql\MySQLConnection; use WebFiori\Database\MySql\MySQLQuery; use WebFiori\Database\MySql\MySQLTable; +use WebFiori\Database\Factory\TableFactory; use WebFiori\Database\Performance\PerformanceOption; use WebFiori\Database\Performance\QueryPerformanceMonitor; /** diff --git a/WebFiori/Database/EntityGenerator.php b/WebFiori/Database/Entity/EntityGenerator.php similarity index 98% rename from WebFiori/Database/EntityGenerator.php rename to WebFiori/Database/Entity/EntityGenerator.php index c72ca0ac..1f3d473c 100644 --- a/WebFiori/Database/EntityGenerator.php +++ b/WebFiori/Database/Entity/EntityGenerator.php @@ -8,7 +8,10 @@ * https://github.com/WebFiori/.github/blob/main/LICENSE * */ -namespace WebFiori\Database; +namespace WebFiori\Database\Entity; + +use WebFiori\Database\Table; +use WebFiori\Database\Column; /** * Entity code generator using PHP 8 features. diff --git a/WebFiori/Database/EntityMapper.php b/WebFiori/Database/Entity/EntityMapper.php similarity index 99% rename from WebFiori/Database/EntityMapper.php rename to WebFiori/Database/Entity/EntityMapper.php index 081dc618..b904edfd 100644 --- a/WebFiori/Database/EntityMapper.php +++ b/WebFiori/Database/Entity/EntityMapper.php @@ -9,11 +9,13 @@ * https://github.com/WebFiori/.github/blob/main/LICENSE * */ -namespace WebFiori\Database; +namespace WebFiori\Database\Entity; use InvalidArgumentException; use WebFiori\Json\Json; use WebFiori\Json\JsonI; +use WebFiori\Database\Table; +use WebFiori\Database\Column; /** * Code generator for creating entity classes from table blueprints. * diff --git a/WebFiori/Database/RecordMapper.php b/WebFiori/Database/Entity/RecordMapper.php similarity index 99% rename from WebFiori/Database/RecordMapper.php rename to WebFiori/Database/Entity/RecordMapper.php index f75fec74..71b5ea99 100644 --- a/WebFiori/Database/RecordMapper.php +++ b/WebFiori/Database/Entity/RecordMapper.php @@ -9,7 +9,7 @@ * https://github.com/WebFiori/.github/blob/main/LICENSE * */ -namespace WebFiori\Database; +namespace WebFiori\Database\Entity; /** * A class which is used to map a database record to a system entity. diff --git a/WebFiori/Database/ColumnFactory.php b/WebFiori/Database/Factory/ColumnFactory.php similarity index 96% rename from WebFiori/Database/ColumnFactory.php rename to WebFiori/Database/Factory/ColumnFactory.php index 10f2048b..f66556fa 100644 --- a/WebFiori/Database/ColumnFactory.php +++ b/WebFiori/Database/Factory/ColumnFactory.php @@ -9,10 +9,15 @@ * https://github.com/WebFiori/.github/blob/main/LICENSE * */ -namespace WebFiori\Database; +namespace WebFiori\Database\Factory; use WebFiori\Database\MsSql\MSSQLColumn; use WebFiori\Database\MySql\MySQLColumn; +use WebFiori\Database\Column; +use WebFiori\Database\DatabaseException; +use WebFiori\Database\ConnectionInfo; +use WebFiori\Database\Util\TypesMap; +use WebFiori\Database\ColOption; /** * A factory class for creating column objects. diff --git a/WebFiori/Database/TableFactory.php b/WebFiori/Database/Factory/TableFactory.php similarity index 91% rename from WebFiori/Database/TableFactory.php rename to WebFiori/Database/Factory/TableFactory.php index 57588ac4..b0f23135 100644 --- a/WebFiori/Database/TableFactory.php +++ b/WebFiori/Database/Factory/TableFactory.php @@ -9,10 +9,13 @@ * https://github.com/WebFiori/.github/blob/main/LICENSE * */ -namespace WebFiori\Database; +namespace WebFiori\Database\Factory; use WebFiori\Database\MsSql\MSSQLTable; use WebFiori\Database\MySql\MySQLTable; +use WebFiori\Database\Table; +use WebFiori\Database\DatabaseException; +use WebFiori\Database\ConnectionInfo; /** * diff --git a/WebFiori/Database/MsSql/MSSQLColumn.php b/WebFiori/Database/MsSql/MSSQLColumn.php index 0022a6b6..19f4dbaa 100644 --- a/WebFiori/Database/MsSql/MSSQLColumn.php +++ b/WebFiori/Database/MsSql/MSSQLColumn.php @@ -14,7 +14,7 @@ use WebFiori\Database\Column; use WebFiori\Database\ColumnFactory; use WebFiori\Database\DatabaseException; -use WebFiori\Database\DateTimeValidator; +use WebFiori\Database\Util\DateTimeValidator; /** * A class that represents a column in MSSQL table. * diff --git a/WebFiori/Database/MySql/MySQLColumn.php b/WebFiori/Database/MySql/MySQLColumn.php index 6ebe686a..9b725e61 100644 --- a/WebFiori/Database/MySql/MySQLColumn.php +++ b/WebFiori/Database/MySql/MySQLColumn.php @@ -14,7 +14,7 @@ use WebFiori\Database\Column; use WebFiori\Database\ColumnFactory; use WebFiori\Database\DatabaseException; -use WebFiori\Database\DateTimeValidator; +use WebFiori\Database\Util\DateTimeValidator; use WebFiori\Database\Table; /** diff --git a/WebFiori/Database/DateTimeValidator.php b/WebFiori/Database/Util/DateTimeValidator.php similarity index 98% rename from WebFiori/Database/DateTimeValidator.php rename to WebFiori/Database/Util/DateTimeValidator.php index b1627755..d9f622fe 100644 --- a/WebFiori/Database/DateTimeValidator.php +++ b/WebFiori/Database/Util/DateTimeValidator.php @@ -9,7 +9,7 @@ * https://github.com/WebFiori/.github/blob/main/LICENSE * */ -namespace WebFiori\Database; +namespace WebFiori\Database\Util; /** * A utility class which is used to validate date and time strings diff --git a/WebFiori/Database/TypesMap.php b/WebFiori/Database/Util/TypesMap.php similarity index 98% rename from WebFiori/Database/TypesMap.php rename to WebFiori/Database/Util/TypesMap.php index bad80ebf..33348786 100644 --- a/WebFiori/Database/TypesMap.php +++ b/WebFiori/Database/Util/TypesMap.php @@ -9,7 +9,7 @@ * https://github.com/WebFiori/.github/blob/main/LICENSE * */ -namespace WebFiori\Database; +namespace WebFiori\Database\Util; /** * A class that holds mapping of data types between different DBMSs. diff --git a/tests/WebFiori/Tests/Database/Common/EntityMapperTest.php b/tests/WebFiori/Tests/Database/Common/EntityMapperTest.php index df2a6993..b4277b35 100644 --- a/tests/WebFiori/Tests/Database/Common/EntityMapperTest.php +++ b/tests/WebFiori/Tests/Database/Common/EntityMapperTest.php @@ -5,7 +5,7 @@ use UserClass; use WebFiori\Database\ColOption; use WebFiori\Database\DataType; -use WebFiori\Database\EntityMapper; +use WebFiori\Database\Entity\EntityMapper; use WebFiori\Tests\Database\MySql\MySQLTestSchema; /** diff --git a/tests/WebFiori/Tests/Database/Common/RecordMapperTest.php b/tests/WebFiori/Tests/Database/Common/RecordMapperTest.php index 0608c2ec..6d370e1c 100644 --- a/tests/WebFiori/Tests/Database/Common/RecordMapperTest.php +++ b/tests/WebFiori/Tests/Database/Common/RecordMapperTest.php @@ -2,7 +2,7 @@ namespace WebFiori\Tests\Database\Common; use PHPUnit\Framework\TestCase; -use WebFiori\Database\RecordMapper; +use WebFiori\Database\Entity\RecordMapper; use WebFiori\Tests\User; /** * Description of RecordMapperTest From 457c8ef4d0eaeccf712f33cc3db9aa19da88501a Mon Sep 17 00:00:00 2001 From: Ibrahim BinAlshikh Date: Thu, 1 Jan 2026 14:24:17 +0300 Subject: [PATCH 09/44] refactor: Enhanced Migrations Runner --- .../Attributes/AttributeTableBuilder.php | 92 ++++++--- WebFiori/Database/Attributes/Column.php | 4 +- WebFiori/Database/Attributes/ForeignKey.php | 2 +- WebFiori/Database/MsSql/MSSQLColumn.php | 2 +- WebFiori/Database/MySql/MySQLColumn.php | 2 +- .../Schema/SchemaChangeRepository.php | 187 ++++++++++++++++++ .../Database/Schema/SchemaMigrationsTable.php | 56 ++++++ WebFiori/Database/Schema/SchemaRunner.php | 98 ++++----- 8 files changed, 353 insertions(+), 90 deletions(-) create mode 100644 WebFiori/Database/Schema/SchemaChangeRepository.php create mode 100644 WebFiori/Database/Schema/SchemaMigrationsTable.php diff --git a/WebFiori/Database/Attributes/AttributeTableBuilder.php b/WebFiori/Database/Attributes/AttributeTableBuilder.php index e9258202..6d3dd661 100644 --- a/WebFiori/Database/Attributes/AttributeTableBuilder.php +++ b/WebFiori/Database/Attributes/AttributeTableBuilder.php @@ -34,40 +34,78 @@ public static function build(string $entityClass, string $dbType = 'mysql'): Tab $columns = []; $foreignKeys = []; - foreach ($reflection->getProperties() as $property) { - $columnAttrs = $property->getAttributes(Column::class); - - if (empty($columnAttrs)) { - continue; + // Check for class-level Column attributes + $classColumnAttrs = $reflection->getAttributes(Column::class); + + if (!empty($classColumnAttrs)) { + // Class-level approach: columns defined at class level + foreach ($classColumnAttrs as $columnAttr) { + $columnConfig = $columnAttr->newInstance(); + $columnKey = $columnConfig->name ?? throw new \RuntimeException("Column name is required for class-level attributes"); + + $columns[$columnKey] = [ + ColOption::TYPE => $columnConfig->type, + ColOption::NAME => $columnConfig->name, + ColOption::SIZE => $columnConfig->size, + ColOption::SCALE => $columnConfig->scale, + ColOption::PRIMARY => $columnConfig->primary, + ColOption::UNIQUE => $columnConfig->unique, + ColOption::NULL => $columnConfig->nullable, + ColOption::AUTO_INCREMENT => $columnConfig->autoIncrement, + ColOption::IDENTITY => $columnConfig->identity, + ColOption::AUTO_UPDATE => $columnConfig->autoUpdate, + ColOption::DEFAULT => $columnConfig->default, + ColOption::COMMENT => $columnConfig->comment, + ColOption::VALIDATOR => $columnConfig->callback + ]; } - $columnConfig = $columnAttrs[0]->newInstance(); - $columnKey = self::propertyToKey($property->getName()); - - $columns[$columnKey] = [ - ColOption::TYPE => $columnConfig->type, - ColOption::NAME => $columnConfig->name, - ColOption::SIZE => $columnConfig->size, - ColOption::SCALE => $columnConfig->scale, - ColOption::PRIMARY => $columnConfig->primary, - ColOption::UNIQUE => $columnConfig->unique, - ColOption::NULL => $columnConfig->nullable, - ColOption::AUTO_INCREMENT => $columnConfig->autoIncrement, - ColOption::IDENTITY => $columnConfig->identity, - ColOption::AUTO_UPDATE => $columnConfig->autoUpdate, - ColOption::DEFAULT => $columnConfig->default, - ColOption::COMMENT => $columnConfig->comment, - ColOption::VALIDATOR => $columnConfig->validator - ]; - - $fkAttrs = $property->getAttributes(ForeignKey::class); - foreach ($fkAttrs as $fkAttr) { + // Check for class-level ForeignKey attributes + $classFkAttrs = $reflection->getAttributes(ForeignKey::class); + foreach ($classFkAttrs as $fkAttr) { $fkConfig = $fkAttr->newInstance(); $foreignKeys[] = [ - 'property' => $columnKey, + 'property' => $fkConfig->column, 'config' => $fkConfig ]; } + } else { + // Property-level approach: columns defined on properties + foreach ($reflection->getProperties() as $property) { + $columnAttrs = $property->getAttributes(Column::class); + + if (empty($columnAttrs)) { + continue; + } + + $columnConfig = $columnAttrs[0]->newInstance(); + $columnKey = self::propertyToKey($property->getName()); + + $columns[$columnKey] = [ + ColOption::TYPE => $columnConfig->type, + ColOption::NAME => $columnConfig->name, + ColOption::SIZE => $columnConfig->size, + ColOption::SCALE => $columnConfig->scale, + ColOption::PRIMARY => $columnConfig->primary, + ColOption::UNIQUE => $columnConfig->unique, + ColOption::NULL => $columnConfig->nullable, + ColOption::AUTO_INCREMENT => $columnConfig->autoIncrement, + ColOption::IDENTITY => $columnConfig->identity, + ColOption::AUTO_UPDATE => $columnConfig->autoUpdate, + ColOption::DEFAULT => $columnConfig->default, + ColOption::COMMENT => $columnConfig->comment, + ColOption::VALIDATOR => $columnConfig->callback + ]; + + $fkAttrs = $property->getAttributes(ForeignKey::class); + foreach ($fkAttrs as $fkAttr) { + $fkConfig = $fkAttr->newInstance(); + $foreignKeys[] = [ + 'property' => $columnKey, + 'config' => $fkConfig + ]; + } + } } $table->addColumns($columns); diff --git a/WebFiori/Database/Attributes/Column.php b/WebFiori/Database/Attributes/Column.php index d22fcbcf..91cb6b01 100644 --- a/WebFiori/Database/Attributes/Column.php +++ b/WebFiori/Database/Attributes/Column.php @@ -4,7 +4,7 @@ use Attribute; -#[Attribute(Attribute::TARGET_PROPERTY)] +#[Attribute(Attribute::TARGET_PROPERTY | Attribute::TARGET_CLASS | Attribute::IS_REPEATABLE)] class Column { public function __construct( public string $type, @@ -19,6 +19,6 @@ public function __construct( public mixed $default = null, public ?string $name = null, public ?string $comment = null, - public ?callable $callback = null + public mixed $callback = null ) {} } diff --git a/WebFiori/Database/Attributes/ForeignKey.php b/WebFiori/Database/Attributes/ForeignKey.php index 1884b4fd..b41f2bb2 100644 --- a/WebFiori/Database/Attributes/ForeignKey.php +++ b/WebFiori/Database/Attributes/ForeignKey.php @@ -4,7 +4,7 @@ use Attribute; -#[Attribute(Attribute::TARGET_PROPERTY | Attribute::IS_REPEATABLE)] +#[Attribute(Attribute::TARGET_PROPERTY | Attribute::TARGET_CLASS | Attribute::IS_REPEATABLE)] class ForeignKey { public function __construct( public string $table, diff --git a/WebFiori/Database/MsSql/MSSQLColumn.php b/WebFiori/Database/MsSql/MSSQLColumn.php index 19f4dbaa..0211b00c 100644 --- a/WebFiori/Database/MsSql/MSSQLColumn.php +++ b/WebFiori/Database/MsSql/MSSQLColumn.php @@ -12,7 +12,7 @@ namespace WebFiori\Database\MsSql; use WebFiori\Database\Column; -use WebFiori\Database\ColumnFactory; +use WebFiori\Database\Factory\ColumnFactory; use WebFiori\Database\DatabaseException; use WebFiori\Database\Util\DateTimeValidator; /** diff --git a/WebFiori/Database/MySql/MySQLColumn.php b/WebFiori/Database/MySql/MySQLColumn.php index 9b725e61..b1590f25 100644 --- a/WebFiori/Database/MySql/MySQLColumn.php +++ b/WebFiori/Database/MySql/MySQLColumn.php @@ -12,7 +12,7 @@ namespace WebFiori\Database\MySql; use WebFiori\Database\Column; -use WebFiori\Database\ColumnFactory; +use WebFiori\Database\Factory\ColumnFactory; use WebFiori\Database\DatabaseException; use WebFiori\Database\Util\DateTimeValidator; use WebFiori\Database\Table; diff --git a/WebFiori/Database/Schema/SchemaChangeRepository.php b/WebFiori/Database/Schema/SchemaChangeRepository.php new file mode 100644 index 00000000..a8a86d9d --- /dev/null +++ b/WebFiori/Database/Schema/SchemaChangeRepository.php @@ -0,0 +1,187 @@ +count(['change_name' => $changeName]) > 0; + } + + /** + * Record a change as applied. + * + * @param DatabaseChange $change The change to record + * @return int The ID of the inserted record + */ + public function recordChange(DatabaseChange $change): int { + $this->getDatabase()->table($this->getTableName()) + ->insert([ + 'change_name' => $change->getName(), + 'type' => $change->getType(), + 'applied-on' => date('Y-m-d H:i:s'), + 'db-name' => $this->getDatabase()->getConnectionInfo()->getDatabase() + ])->execute(); + + return $this->getDatabase()->getLastInsertId(); + } + + /** + * Remove a change record. + * + * @param string $changeName The fully qualified class name of the change + * @return int Number of records deleted + */ + public function removeChange(string $changeName): int { + return $this->getDatabase()->table($this->getTableName()) + ->delete() + ->where('change_name', $changeName) + ->execute() + ->getRowsCount(); + } + + /** + * Get all applied changes. + * + * @return array Array of change records + */ + public function getAllApplied(): array { + return $this->getDatabase()->table($this->getTableName()) + ->select() + ->execute() + ->getRows(); + } + + /** + * Get applied changes by type. + * + * @param string $type Either 'migration' or 'seeder' + * @return array Array of change records + */ + public function getByType(string $type): array { + return $this->getDatabase()->table($this->getTableName()) + ->select() + ->where('type', $type) + ->execute() + ->getRows(); + } + + /** + * Get all applied migrations. + * + * @return array Array of migration records + */ + public function getAllMigrations(): array { + return $this->getByType('migration'); + } + + /** + * Get all applied seeders. + * + * @return array Array of seeder records + */ + public function getAllSeeders(): array { + return $this->getByType('seeder'); + } + + /** + * Count applied changes. + * + * @param array $conditions Optional conditions (e.g., ['type' => 'migration']) + * @return int Number of applied changes + */ + public function count(array $conditions = []): int { + $query = $this->getDatabase()->table($this->getTableName())->select(); + + foreach ($conditions as $col => $val) { + $query->where($col, $val); + } + + return $query->execute()->getRowsCount(); + } + + /** + * Clear all change records (use with caution). + * + * @return int Number of records deleted + */ + public function clearAll(): int { + return $this->deleteAll(); + } +} diff --git a/WebFiori/Database/Schema/SchemaMigrationsTable.php b/WebFiori/Database/Schema/SchemaMigrationsTable.php new file mode 100644 index 00000000..3713f11a --- /dev/null +++ b/WebFiori/Database/Schema/SchemaMigrationsTable.php @@ -0,0 +1,56 @@ +environment = $environment; - $dbType = $connectionInfo !== null ? $connectionInfo->getDatabaseType() : 'mysql'; $this->onErrCallbacks = []; $this->onRegErrCallbacks = []; - $this->createBlueprint('schema_changes')->addColumns([ - 'id' => [ - ColOption::TYPE => DataType::INT, - ColOption::PRIMARY => true, - ColOption::AUTO_INCREMENT => true, - ColOption::IDENTITY => true, - ColOption::COMMENT => 'The unique identifier of the change.' - ], - 'change_name' => [ - ColOption::TYPE => DataType::VARCHAR, - ColOption::SIZE => 255, - ColOption::COMMENT => 'The name of the change.' - ], - 'type' => [ - ColOption::TYPE => DataType::VARCHAR, - ColOption::SIZE => 20, - ColOption::COMMENT => 'The type of the change (migration, seeder, etc.).' - ], - 'db-name' => [ - ColOption::TYPE => DataType::VARCHAR, - ColOption::SIZE => 255, - ColOption::COMMENT => 'The name of the database at which the migration was applied to.' - ], - 'applied-on' => [ - ColOption::TYPE => $dbType == ConnectionInfo::SUPPORTED_DATABASES[1] ? DataType::DATETIME2 : DataType::DATETIME, - ColOption::COMMENT => 'The date and time at which the change was applied.' - ] - ]); + + $table = AttributeTableBuilder::build( + SchemaMigrationsTable::class, + $this->getConnectionInfo()->getDatabaseType() + ); + + // Handle MSSQL datetime2 type + if ($this->getConnectionInfo()->getDatabaseType() === ConnectionInfo::SUPPORTED_DATABASES[1]) { + $table->getColByKey('applied-on')->setDatatype(DataType::DATETIME2); + } + + $this->addTable($table); + $this->repository = new SchemaChangeRepository($this); $this->dbChanges = []; } /** @@ -157,17 +143,8 @@ public function apply(): array { } try { - $this->transaction(function($db) use ($change) { - $change->execute($db); - $db->table('schema_changes') - ->insert([ - 'change_name' => $change->getName(), - 'type' => $change->getType(), - 'applied-on' => date('Y-m-d H:i:s'), - 'db-name' => $db->getConnectionInfo()->getDatabase() - ])->execute(); - }); - + $change->execute($this); + $this->getRepository()->recordChange($change); $applied[] = $change; $appliedInPass = true; } catch (\Throwable $ex) { @@ -203,17 +180,15 @@ public function applyOne(): ?DatabaseChange { continue; } - $this->transaction(function($db) use ($change) { - $migrationDb = $change->getDatabase() ?? $db; - $change->execute($migrationDb); - $db->table('schema_changes') - ->insert([ - 'change_name' => $change->getName(), - 'type' => $change->getType(), - 'applied-on' => date('Y-m-d H:i:s'), - 'db-name' => $db->getConnectionInfo()->getDatabase() - ])->execute(); - }); + try { + $change->execute($this); + $this->getRepository()->recordChange($change); + $applied[] = $change; + } catch (\Throwable $ex) { + foreach ($this->onErrCallbacks as $callback) { + call_user_func_array($callback, [$ex, $change, $this]); + } + } return $change; } @@ -301,11 +276,18 @@ public function hasChange(string $name): bool { * @return bool True if the change has been applied, false otherwise. */ public function isApplied(string $name): bool { - return $this->table('schema_changes') - ->select(['change_name']) - ->where('change_name', $name) - ->execute() - ->getRowsCount() == 1; + return $this->getRepository()->count([ + 'change_name' => $name + ]) == 1; + } + + /** + * Get the schema change repository. + * + * @return SchemaChangeRepository The repository instance + */ + public function getRepository(): SchemaChangeRepository { + return $this->repository; } /** * Rollback database changes up to a specific change. @@ -353,7 +335,7 @@ private function attemptRoolback(DatabaseChange $change, &$rolled) : bool { try { $migrationDb = $change->getDatabase() ?? $this; $change->rollback($migrationDb); - $this->table('schema_changes')->delete()->where('change_name', $change->getName())->execute(); + $this->repository->removeChange($change->getName()); $rolled[] = $change; return true; @@ -412,7 +394,7 @@ public function register(DatabaseChange|string $change): bool { $this->dbChanges[] = $change; return true; - } catch (Throwable $ex) { + } catch (\Throwable $ex) { foreach ($this->onRegErrCallbacks as $callback) { call_user_func_array($callback, [$ex]); } From aaae1f06b6abe26e9bce009ce5d3a663caa8b808 Mon Sep 17 00:00:00 2001 From: Ibrahim BinAlshikh Date: Thu, 1 Jan 2026 15:53:04 +0300 Subject: [PATCH 10/44] feat: Migration/Seeder Discovery --- WebFiori/Database/Schema/SchemaRunner.php | 991 ++++++++++-------- .../Database/Schema/DiscoverFromPathTest.php | 214 ++++ 2 files changed, 745 insertions(+), 460 deletions(-) create mode 100644 tests/WebFiori/Tests/Database/Schema/DiscoverFromPathTest.php diff --git a/WebFiori/Database/Schema/SchemaRunner.php b/WebFiori/Database/Schema/SchemaRunner.php index b5957b49..3e62963b 100644 --- a/WebFiori/Database/Schema/SchemaRunner.php +++ b/WebFiori/Database/Schema/SchemaRunner.php @@ -1,460 +1,531 @@ -setPath(/path/to/migrations); - * $runner->setNamespace(App\Migrations); - * $runner->setEnvironment(dev); - * $runner->runAll(); // Execute all pending changes - * ``` - * - * @author Ibrahim - */ -class SchemaRunner extends Database { - private $dbChanges; - private $environment; - private $onErrCallbacks; - private $onRegErrCallbacks; - private SchemaChangeRepository $repository; - /** - * Initialize a new schema runner with configuration. - * - * @param ConnectionInfo|null $connectionInfo Database connection information. - * @param string $environment Target environment (dev, test, prod) - affects which changes run. - */ - public function __construct(?ConnectionInfo $connectionInfo, string $environment = 'dev') { - parent::__construct($connectionInfo); - $this->environment = $environment; - $this->onErrCallbacks = []; - $this->onRegErrCallbacks = []; - - $table = AttributeTableBuilder::build( - SchemaMigrationsTable::class, - $this->getConnectionInfo()->getDatabaseType() - ); - - // Handle MSSQL datetime2 type - if ($this->getConnectionInfo()->getDatabaseType() === ConnectionInfo::SUPPORTED_DATABASES[1]) { - $table->getColByKey('applied-on')->setDatatype(DataType::DATETIME2); - } - - $this->addTable($table); - - $this->repository = new SchemaChangeRepository($this); - $this->dbChanges = []; - } - /** - * Register a callback to handle execution errors. - * - * The callback will be invoked when a migration or seeder fails during execution. - * Multiple callbacks can be registered and will be called in registration order. - * - * @param callable $callback Function to call on execution errors. Receives error details. - */ - public function addOnErrorCallback(callable $callback): void { - $this->onErrCallbacks[] = $callback; - } - /** - * Register a callback to handle class registration errors. - * - * The callback will be invoked when a migration or seeder class cannot be - * properly loaded or instantiated during the discovery process. - * - * @param callable $callback Function to call on registration errors. Receives error details. - */ - public function addOnRegisterErrorCallback(callable $callback): void { - $this->onRegErrCallbacks[] = $callback; - - if (empty($this->dbChanges)) { - // No changes registered - } - } - - /** - * Apply all pending database changes. - * - * @return array Array of applied DatabaseChange instances. - */ - public function apply(): array { - $applied = []; - - // Keep applying changes until no more can be applied - $appliedInPass = true; - - while ($appliedInPass) { - $appliedInPass = false; - - foreach ($this->dbChanges as $change) { - if ($this->isApplied($change->getName())) { - continue; - } - - if (!$this->shouldRunInEnvironment($change)) { - continue; - } - - if (!$this->areDependenciesSatisfied($change)) { - continue; - } - - try { - $change->execute($this); - $this->getRepository()->recordChange($change); - $applied[] = $change; - $appliedInPass = true; - } catch (\Throwable $ex) { - foreach ($this->onErrCallbacks as $callback) { - call_user_func_array($callback, [$ex, $change, $this]); - } - // Continue with next change instead of breaking - } - } - } - - return $applied; - } - - /** - * Apply the next pending database change. - * - * @return DatabaseChange|null The applied change, or null if no pending changes. - */ - public function applyOne(): ?DatabaseChange { - $change = null; - try { - foreach ($this->dbChanges as $change) { - if ($this->isApplied($change->getName())) { - continue; - } - - if (!$this->shouldRunInEnvironment($change)) { - continue; - } - - if (!$this->areDependenciesSatisfied($change)) { - continue; - } - - try { - $change->execute($this); - $this->getRepository()->recordChange($change); - $applied[] = $change; - } catch (\Throwable $ex) { - foreach ($this->onErrCallbacks as $callback) { - call_user_func_array($callback, [$ex, $change, $this]); - } - } - - return $change; - } - } catch (\Throwable $ex) { - foreach ($this->onErrCallbacks as $callback) { - call_user_func_array($callback, [$ex, $change, $this]); - } - } - - return null; - } - - /** - * Remove all registered execution error callbacks. - */ - public function clearErrorCallbacks(): void { - $this->onErrCallbacks = []; - } - - /** - * Remove all registered class registration error callbacks. - */ - public function clearRegisterErrorCallbacks(): void { - $this->onRegErrCallbacks = []; - } - - /** - * Create the schema tracking table if it does not exist. - * - * This table stores information about which migrations and seeders - * have been applied, including timestamps and execution status. - * Required for tracking database change history. - */ - public function createSchemaTable() { - $this->createTables(); - $this->execute(); - } - - /** - * Drop the schema tracking table from the database. - * - * Removes the table that stores migration and seeder execution history. - * Use with caution as this will lose all tracking information. - */ - public function dropSchemaTable() { - $this->table('schema_changes')->drop(); - $this->execute(); - } - - /** - * Get all discovered database changes (migrations and seeders). - * - * @return array Array of DatabaseChange instances found in the configured path. - */ - public function getChanges(): array { - return $this->dbChanges; - } - - /** - * Get the current execution environment. - * - * The environment determines which migrations and seeders will be executed. - * Changes can specify which environments they should run in. - * - * @return string The current environment (e.g., 'dev', 'test', 'prod'). - */ - public function getEnvironment(): string { - return $this->environment; - } - - /** - * Check if a database change exists in the discovered changes. - * - * @param string $name The class name of the change to check. - * @return bool True if the change exists, false otherwise. - */ - public function hasChange(string $name): bool { - return $this->findChangeByName($name) !== null; - } - - /** - * Check if a specific database change has been applied. - * - * @param string $name The class name of the change to check. - * @return bool True if the change has been applied, false otherwise. - */ - public function isApplied(string $name): bool { - return $this->getRepository()->count([ - 'change_name' => $name - ]) == 1; - } - - /** - * Get the schema change repository. - * - * @return SchemaChangeRepository The repository instance - */ - public function getRepository(): SchemaChangeRepository { - return $this->repository; - } - /** - * Rollback database changes up to a specific change. - * - * @param string|null $changeName The change to rollback to, or null to rollback all. - * @return array Array of rolled back DatabaseChange instances. - */ - public function rollbackUpTo(?string $changeName): array { - $changes = array_reverse($this->getChanges()); - $rolled = []; - - if (empty($changes)) { - return $rolled; - } - - if ($changeName !== null && $this->hasChange($changeName)) { - foreach ($changes as $change) { - if ($change->getName() == $changeName && $this->isApplied($change->getName())) { - $this->attemptRoolback($change, $rolled); - - return $rolled; - } - } - } else if ($changeName === null) { - foreach ($changes as $change) { - if ($this->isApplied($change->getName()) && !$this->attemptRoolback($change, $rolled)) { - return $rolled; - } - } - } - - return $rolled; - } - - private function areDependenciesSatisfied(DatabaseChange $change): bool { - foreach ($change->getDependencies() as $depName) { - if (!$this->isApplied($depName)) { - return false; - } - } - - return true; - } - private function attemptRoolback(DatabaseChange $change, &$rolled) : bool { - try { - $migrationDb = $change->getDatabase() ?? $this; - $change->rollback($migrationDb); - $this->repository->removeChange($change->getName()); - $rolled[] = $change; - - return true; - } catch (\Throwable $ex) { - foreach ($this->onErrCallbacks as $callback) { - call_user_func_array($callback, [$ex, $change, $this]); - } - - return false; - } - } - - private function findChangeByName(string $name): ?DatabaseChange { - foreach ($this->dbChanges as $change) { - $changeName = $change->getName(); - - // Exact match - if ($changeName === $name) { - return $change; - } - - // Check if name is a short class name and change is full class name - if (str_ends_with($changeName, '\\'.$name)) { - return $change; - } - - // Check if name is full class name and change is short class name - if (str_ends_with($name, '\\'.$changeName)) { - return $change; - } - } - - return null; - } - - /** - * Register a database change. - * - * @param DatabaseChange|string $change The change instance or class name. - * @return bool True if registered successfully, false otherwise. - */ - public function register(DatabaseChange|string $change): bool { - try { - if (is_string($change)) { - if (!class_exists($change)) { - throw new Exception("Class does not exist: {$change}"); - } - - if (!is_subclass_of($change, DatabaseChange::class)) { - throw new Exception("Class is not a subclass of DatabaseChange: {$change}"); - } - - $change = new $change(); - } - - $this->dbChanges[] = $change; - return true; - - } catch (\Throwable $ex) { - foreach ($this->onRegErrCallbacks as $callback) { - call_user_func_array($callback, [$ex]); - } - return false; - } - } - - /** - * Register multiple database changes. - * - * @param array $changes Array of DatabaseChange instances or class names. - */ - public function registerAll(array $changes): void { - foreach ($changes as $change) { - $this->register($change); - } - } - - private function shouldRunInEnvironment(DatabaseChange $change): bool { - $environments = $change->getEnvironments(); - - return empty($environments) || in_array($this->environment, $environments); - } - - private function sortChangesByDependencies() { - $sorted = []; - $visited = []; - - foreach ($this->dbChanges as $change) { - $visiting = []; - $this->topologicalSort($change, $visited, $sorted, $visiting); - } - - $this->dbChanges = $sorted; - } - - private function topologicalSort(DatabaseChange $change, array &$visited, array &$sorted, array &$visiting) { - $className = $change->getName(); - - if (isset($visiting[$className])) { - $cycle = array_merge(array_keys($visiting), [$className]); - throw new DatabaseException('Circular dependency detected: '.implode(' -> ', $cycle)); - } - - if (isset($visited[$className])) { - return; - } - - $visiting[$className] = true; - - foreach ($change->getDependencies() as $depName) { - $dep = $this->findChangeByName($depName); - - if ($dep) { - $this->topologicalSort($dep, $visited, $sorted, $visiting); - } - } - - unset($visiting[$className]); - $visited[$className] = true; - $sorted[] = $change; - } -} +setPath(/path/to/migrations); + * $runner->setNamespace(App\Migrations); + * $runner->setEnvironment(dev); + * $runner->runAll(); // Execute all pending changes + * ``` + * + * @author Ibrahim + */ +class SchemaRunner extends Database { + private $dbChanges; + private $environment; + private $onErrCallbacks; + private $onRegErrCallbacks; + private SchemaChangeRepository $repository; + /** + * Initialize a new schema runner with configuration. + * + * @param ConnectionInfo|null $connectionInfo Database connection information. + * @param string $environment Target environment (dev, test, prod) - affects which changes run. + */ + public function __construct(?ConnectionInfo $connectionInfo, string $environment = 'dev') { + parent::__construct($connectionInfo); + $this->environment = $environment; + $this->onErrCallbacks = []; + $this->onRegErrCallbacks = []; + + $table = AttributeTableBuilder::build( + SchemaMigrationsTable::class, + $this->getConnectionInfo()->getDatabaseType() + ); + + // Handle MSSQL datetime2 type + if ($this->getConnectionInfo()->getDatabaseType() === ConnectionInfo::SUPPORTED_DATABASES[1]) { + $table->getColByKey('applied-on')->setDatatype(DataType::DATETIME2); + } + + $this->addTable($table); + + $this->repository = new SchemaChangeRepository($this); + $this->dbChanges = []; + } + /** + * Register a callback to handle execution errors. + * + * The callback will be invoked when a migration or seeder fails during execution. + * Multiple callbacks can be registered and will be called in registration order. + * + * @param callable $callback Function to call on execution errors. Receives error details. + */ + public function addOnErrorCallback(callable $callback): void { + $this->onErrCallbacks[] = $callback; + } + /** + * Register a callback to handle class registration errors. + * + * The callback will be invoked when a migration or seeder class cannot be + * properly loaded or instantiated during the discovery process. + * + * @param callable $callback Function to call on registration errors. Receives error details. + */ + public function addOnRegisterErrorCallback(callable $callback): void { + $this->onRegErrCallbacks[] = $callback; + + if (empty($this->dbChanges)) { + // No changes registered + } + } + + /** + * Apply all pending database changes. + * + * @return array Array of applied DatabaseChange instances. + */ + public function apply(): array { + $applied = []; + + // Keep applying changes until no more can be applied + $appliedInPass = true; + + while ($appliedInPass) { + $appliedInPass = false; + + foreach ($this->dbChanges as $change) { + if ($this->isApplied($change->getName())) { + continue; + } + + if (!$this->shouldRunInEnvironment($change)) { + continue; + } + + if (!$this->areDependenciesSatisfied($change)) { + continue; + } + + try { + $change->execute($this); + $this->getRepository()->recordChange($change); + $applied[] = $change; + $appliedInPass = true; + } catch (\Throwable $ex) { + foreach ($this->onErrCallbacks as $callback) { + call_user_func_array($callback, [$ex, $change, $this]); + } + // Continue with next change instead of breaking + } + } + } + + return $applied; + } + + /** + * Apply the next pending database change. + * + * @return DatabaseChange|null The applied change, or null if no pending changes. + */ + public function applyOne(): ?DatabaseChange { + $change = null; + try { + foreach ($this->dbChanges as $change) { + if ($this->isApplied($change->getName())) { + continue; + } + + if (!$this->shouldRunInEnvironment($change)) { + continue; + } + + if (!$this->areDependenciesSatisfied($change)) { + continue; + } + + try { + $change->execute($this); + $this->getRepository()->recordChange($change); + $applied[] = $change; + } catch (\Throwable $ex) { + foreach ($this->onErrCallbacks as $callback) { + call_user_func_array($callback, [$ex, $change, $this]); + } + } + + return $change; + } + } catch (\Throwable $ex) { + foreach ($this->onErrCallbacks as $callback) { + call_user_func_array($callback, [$ex, $change, $this]); + } + } + + return null; + } + + /** + * Remove all registered execution error callbacks. + */ + public function clearErrorCallbacks(): void { + $this->onErrCallbacks = []; + } + + /** + * Remove all registered class registration error callbacks. + */ + public function clearRegisterErrorCallbacks(): void { + $this->onRegErrCallbacks = []; + } + + /** + * Create the schema tracking table if it does not exist. + * + * This table stores information about which migrations and seeders + * have been applied, including timestamps and execution status. + * Required for tracking database change history. + */ + public function createSchemaTable() { + $this->createTables(); + $this->execute(); + } + + /** + * Drop the schema tracking table from the database. + * + * Removes the table that stores migration and seeder execution history. + * Use with caution as this will lose all tracking information. + */ + public function dropSchemaTable() { + $this->table('schema_changes')->drop(); + $this->execute(); + } + + /** + * Get all discovered database changes (migrations and seeders). + * + * @return array Array of DatabaseChange instances found in the configured path. + */ + public function getChanges(): array { + return $this->dbChanges; + } + + /** + * Get the current execution environment. + * + * The environment determines which migrations and seeders will be executed. + * Changes can specify which environments they should run in. + * + * @return string The current environment (e.g., 'dev', 'test', 'prod'). + */ + public function getEnvironment(): string { + return $this->environment; + } + + /** + * Check if a database change exists in the discovered changes. + * + * @param string $name The class name of the change to check. + * @return bool True if the change exists, false otherwise. + */ + public function hasChange(string $name): bool { + return $this->findChangeByName($name) !== null; + } + + /** + * Check if a specific database change has been applied. + * + * @param string $name The class name of the change to check. + * @return bool True if the change has been applied, false otherwise. + */ + public function isApplied(string $name): bool { + return $this->getRepository()->count([ + 'change_name' => $name + ]) == 1; + } + + /** + * Get the schema change repository. + * + * @return SchemaChangeRepository The repository instance + */ + public function getRepository(): SchemaChangeRepository { + return $this->repository; + } + /** + * Rollback database changes up to a specific change. + * + * @param string|null $changeName The change to rollback to, or null to rollback all. + * @return array Array of rolled back DatabaseChange instances. + */ + public function rollbackUpTo(?string $changeName): array { + $changes = array_reverse($this->getChanges()); + $rolled = []; + + if (empty($changes)) { + return $rolled; + } + + if ($changeName !== null && $this->hasChange($changeName)) { + foreach ($changes as $change) { + if ($change->getName() == $changeName && $this->isApplied($change->getName())) { + $this->attemptRoolback($change, $rolled); + + return $rolled; + } + } + } else if ($changeName === null) { + foreach ($changes as $change) { + if ($this->isApplied($change->getName()) && !$this->attemptRoolback($change, $rolled)) { + return $rolled; + } + } + } + + return $rolled; + } + + private function areDependenciesSatisfied(DatabaseChange $change): bool { + foreach ($change->getDependencies() as $depName) { + if (!$this->isApplied($depName)) { + return false; + } + } + + return true; + } + private function attemptRoolback(DatabaseChange $change, &$rolled) : bool { + try { + $migrationDb = $change->getDatabase() ?? $this; + $change->rollback($migrationDb); + $this->repository->removeChange($change->getName()); + $rolled[] = $change; + + return true; + } catch (\Throwable $ex) { + foreach ($this->onErrCallbacks as $callback) { + call_user_func_array($callback, [$ex, $change, $this]); + } + + return false; + } + } + + private function findChangeByName(string $name): ?DatabaseChange { + foreach ($this->dbChanges as $change) { + $changeName = $change->getName(); + + // Exact match + if ($changeName === $name) { + return $change; + } + + // Check if name is a short class name and change is full class name + if (str_ends_with($changeName, '\\'.$name)) { + return $change; + } + + // Check if name is full class name and change is short class name + if (str_ends_with($name, '\\'.$changeName)) { + return $change; + } + } + + return null; + } + + /** + * Register a database change. + * + * @param DatabaseChange|string $change The change instance or class name. + * @return bool True if registered successfully, false otherwise. + */ + public function register(DatabaseChange|string $change): bool { + try { + if (is_string($change)) { + if (!class_exists($change)) { + throw new Exception("Class does not exist: {$change}"); + } + + if (!is_subclass_of($change, DatabaseChange::class)) { + throw new Exception("Class is not a subclass of DatabaseChange: {$change}"); + } + + $change = new $change(); + } + + $this->dbChanges[] = $change; + return true; + + } catch (\Throwable $ex) { + foreach ($this->onRegErrCallbacks as $callback) { + call_user_func_array($callback, [$ex]); + } + return false; + } + } + + /** + * Register multiple database changes. + * + * @param array $changes Array of DatabaseChange instances or class names. + */ + public function registerAll(array $changes): void { + foreach ($changes as $change) { + $this->register($change); + } + } + + /** + * Discover and register database changes from a directory. + * + * Scans the specified directory for PHP files containing classes that extend + * DatabaseChange (migrations and seeders). Each discovered class is automatically + * registered with the schema runner. + * + * @param string $path Absolute path to the directory containing migration/seeder files. + * @param string $namespace The PHP namespace for classes in the directory. + * @param bool $recursive Whether to scan subdirectories recursively. Default is false. + * @return int Number of changes discovered and registered. + */ + public function discoverFromPath(string $path, string $namespace, bool $recursive = false): int { + $count = 0; + + if (!is_dir($path)) { + return $count; + } + + $namespace = rtrim($namespace, '\\'); + $iterator = $recursive + ? new \RecursiveIteratorIterator(new \RecursiveDirectoryIterator($path, \RecursiveDirectoryIterator::SKIP_DOTS)) + : new \DirectoryIterator($path); + + foreach ($iterator as $file) { + if ($file->isFile() && $file->getExtension() === 'php') { + $className = $this->resolveClassName($file, $path, $namespace, $recursive); + + if ($className !== null && $this->register($className)) { + $count++; + } + } + } + + return $count; + } + + /** + * Resolve the fully qualified class name from a file. + * + * @param \SplFileInfo $file The file to resolve. + * @param string $basePath The base directory path. + * @param string $namespace The base namespace. + * @param bool $recursive Whether recursive scanning is enabled. + * @return string|null The fully qualified class name, or null if not a valid change class. + */ + private function resolveClassName(\SplFileInfo $file, string $basePath, string $namespace, bool $recursive): ?string { + $filename = $file->getBasename('.php'); + + if ($recursive) { + $relativePath = substr($file->getPath(), strlen($basePath)); + $relativePath = trim(str_replace(DIRECTORY_SEPARATOR, '\\', $relativePath), '\\'); + $className = $relativePath ? $namespace . '\\' . $relativePath . '\\' . $filename : $namespace . '\\' . $filename; + } else { + $className = $namespace . '\\' . $filename; + } + + if (!class_exists($className)) { + require_once $file->getPathname(); + } + + if (class_exists($className) && is_subclass_of($className, DatabaseChange::class)) { + $reflection = new ReflectionClass($className); + if (!$reflection->isAbstract()) { + return $className; + } + } + + return null; + } + + private function shouldRunInEnvironment(DatabaseChange $change): bool { + $environments = $change->getEnvironments(); + + return empty($environments) || in_array($this->environment, $environments); + } + + private function sortChangesByDependencies() { + $sorted = []; + $visited = []; + + foreach ($this->dbChanges as $change) { + $visiting = []; + $this->topologicalSort($change, $visited, $sorted, $visiting); + } + + $this->dbChanges = $sorted; + } + + private function topologicalSort(DatabaseChange $change, array &$visited, array &$sorted, array &$visiting) { + $className = $change->getName(); + + if (isset($visiting[$className])) { + $cycle = array_merge(array_keys($visiting), [$className]); + throw new DatabaseException('Circular dependency detected: '.implode(' -> ', $cycle)); + } + + if (isset($visited[$className])) { + return; + } + + $visiting[$className] = true; + + foreach ($change->getDependencies() as $depName) { + $dep = $this->findChangeByName($depName); + + if ($dep) { + $this->topologicalSort($dep, $visited, $sorted, $visiting); + } + } + + unset($visiting[$className]); + $visited[$className] = true; + $sorted[] = $change; + } +} diff --git a/tests/WebFiori/Tests/Database/Schema/DiscoverFromPathTest.php b/tests/WebFiori/Tests/Database/Schema/DiscoverFromPathTest.php new file mode 100644 index 00000000..53c60fd3 --- /dev/null +++ b/tests/WebFiori/Tests/Database/Schema/DiscoverFromPathTest.php @@ -0,0 +1,214 @@ +tempDir = sys_get_temp_dir() . '/schema_discover_test_' . uniqid(); + mkdir($this->tempDir, 0777, true); + } + + protected function tearDown(): void { + $this->removeDirectory($this->tempDir); + gc_collect_cycles(); + } + + private function removeDirectory(string $dir): void { + if (!is_dir($dir)) { + return; + } + $files = new \RecursiveIteratorIterator( + new \RecursiveDirectoryIterator($dir, \RecursiveDirectoryIterator::SKIP_DOTS), + \RecursiveIteratorIterator::CHILD_FIRST + ); + foreach ($files as $file) { + $file->isDir() ? rmdir($file->getPathname()) : unlink($file->getPathname()); + } + rmdir($dir); + } + + private function getConnectionInfo(): ConnectionInfo { + return new ConnectionInfo('mysql', 'root', getenv('MYSQL_ROOT_PASSWORD') ?: '123456', 'testing_db', '127.0.0.1'); + } + + public function testDiscoverFromEmptyDirectory() { + $runner = new SchemaRunner($this->getConnectionInfo()); + + $count = $runner->discoverFromPath($this->tempDir, 'TestMigrations'); + + $this->assertEquals(0, $count); + $this->assertEmpty($runner->getChanges()); + } + + public function testDiscoverFromNonExistentDirectory() { + $runner = new SchemaRunner($this->getConnectionInfo()); + + $count = $runner->discoverFromPath('/non/existent/path', 'TestMigrations'); + + $this->assertEquals(0, $count); + } + + public function testDiscoverMigrationClass() { + $this->createMigrationFile($this->tempDir, 'TestMigrationA', 'TestMigrations'); + + $runner = new SchemaRunner($this->getConnectionInfo()); + $count = $runner->discoverFromPath($this->tempDir, 'TestMigrations'); + + $this->assertEquals(1, $count); + $this->assertCount(1, $runner->getChanges()); + $this->assertTrue($runner->hasChange('TestMigrations\\TestMigrationA')); + } + + public function testDiscoverMultipleClasses() { + $this->createMigrationFile($this->tempDir, 'MigrationOne', 'TestMigrations'); + $this->createMigrationFile($this->tempDir, 'MigrationTwo', 'TestMigrations'); + $this->createSeederFile($this->tempDir, 'SeederOne', 'TestMigrations'); + + $runner = new SchemaRunner($this->getConnectionInfo()); + $count = $runner->discoverFromPath($this->tempDir, 'TestMigrations'); + + $this->assertEquals(3, $count); + $this->assertCount(3, $runner->getChanges()); + } + + public function testDiscoverIgnoresNonPhpFiles() { + $this->createMigrationFile($this->tempDir, 'ValidMigration', 'TestMigrations'); + file_put_contents($this->tempDir . '/readme.txt', 'This is not a PHP file'); + file_put_contents($this->tempDir . '/config.json', '{}'); + + $runner = new SchemaRunner($this->getConnectionInfo()); + $count = $runner->discoverFromPath($this->tempDir, 'TestMigrations'); + + $this->assertEquals(1, $count); + } + + public function testDiscoverIgnoresNonDatabaseChangeClasses() { + $this->createMigrationFile($this->tempDir, 'ValidMigration', 'TestMigrations'); + $this->createNonChangeClass($this->tempDir, 'SomeHelper', 'TestMigrations'); + + $runner = new SchemaRunner($this->getConnectionInfo()); + $count = $runner->discoverFromPath($this->tempDir, 'TestMigrations'); + + $this->assertEquals(1, $count); + } + + public function testDiscoverIgnoresAbstractClasses() { + $this->createMigrationFile($this->tempDir, 'ConcreteMigration', 'TestMigrations'); + $this->createAbstractMigrationFile($this->tempDir, 'BaseMigration', 'TestMigrations'); + + $runner = new SchemaRunner($this->getConnectionInfo()); + $count = $runner->discoverFromPath($this->tempDir, 'TestMigrations'); + + $this->assertEquals(1, $count); + } + + public function testDiscoverNonRecursiveIgnoresSubdirectories() { + $this->createMigrationFile($this->tempDir, 'RootMigration', 'TestMigrations'); + + $subDir = $this->tempDir . '/SubDir'; + mkdir($subDir); + $this->createMigrationFile($subDir, 'SubMigration', 'TestMigrations\\SubDir'); + + $runner = new SchemaRunner($this->getConnectionInfo()); + $count = $runner->discoverFromPath($this->tempDir, 'TestMigrations', recursive: false); + + $this->assertEquals(1, $count); + $this->assertTrue($runner->hasChange('TestMigrations\\RootMigration')); + $this->assertFalse($runner->hasChange('TestMigrations\\SubDir\\SubMigration')); + } + + public function testDiscoverRecursiveIncludesSubdirectories() { + $this->createMigrationFile($this->tempDir, 'RootMigration', 'TestMigrations'); + + $subDir = $this->tempDir . '/SubDir'; + mkdir($subDir); + $this->createMigrationFile($subDir, 'SubMigration', 'TestMigrations\\SubDir'); + + $deepDir = $subDir . '/Deep'; + mkdir($deepDir); + $this->createMigrationFile($deepDir, 'DeepMigration', 'TestMigrations\\SubDir\\Deep'); + + $runner = new SchemaRunner($this->getConnectionInfo()); + $count = $runner->discoverFromPath($this->tempDir, 'TestMigrations', recursive: true); + + $this->assertEquals(3, $count); + $this->assertTrue($runner->hasChange('TestMigrations\\RootMigration')); + $this->assertTrue($runner->hasChange('TestMigrations\\SubDir\\SubMigration')); + $this->assertTrue($runner->hasChange('TestMigrations\\SubDir\\Deep\\DeepMigration')); + } + + public function testDiscoverWithTrailingSlashInNamespace() { + $this->createMigrationFile($this->tempDir, 'TestMigration', 'TestMigrations'); + + $runner = new SchemaRunner($this->getConnectionInfo()); + $count = $runner->discoverFromPath($this->tempDir, 'TestMigrations\\'); + + $this->assertEquals(1, $count); + $this->assertTrue($runner->hasChange('TestMigrations\\TestMigration')); + } + + private function createMigrationFile(string $dir, string $className, string $namespace): void { + $content = << Date: Thu, 1 Jan 2026 15:56:21 +0300 Subject: [PATCH 11/44] fix: Ignore if Migration Already Registered --- WebFiori/Database/Schema/SchemaRunner.php | 11 +- .../Database/Schema/SchemaRunnerTest.php | 711 +++++++++--------- 2 files changed, 366 insertions(+), 356 deletions(-) diff --git a/WebFiori/Database/Schema/SchemaRunner.php b/WebFiori/Database/Schema/SchemaRunner.php index 3e62963b..2e1d99e7 100644 --- a/WebFiori/Database/Schema/SchemaRunner.php +++ b/WebFiori/Database/Schema/SchemaRunner.php @@ -374,11 +374,20 @@ private function findChangeByName(string $name): ?DatabaseChange { /** * Register a database change. * + * If a change with the same name is already registered, this method + * returns false without registering a duplicate. + * * @param DatabaseChange|string $change The change instance or class name. - * @return bool True if registered successfully, false otherwise. + * @return bool True if registered successfully, false if already registered or on error. */ public function register(DatabaseChange|string $change): bool { try { + $name = is_string($change) ? $change : $change->getName(); + + if ($this->hasChange($name)) { + return false; + } + if (is_string($change)) { if (!class_exists($change)) { throw new Exception("Class does not exist: {$change}"); diff --git a/tests/WebFiori/Tests/Database/Schema/SchemaRunnerTest.php b/tests/WebFiori/Tests/Database/Schema/SchemaRunnerTest.php index 8630e4ce..11bf848d 100644 --- a/tests/WebFiori/Tests/Database/Schema/SchemaRunnerTest.php +++ b/tests/WebFiori/Tests/Database/Schema/SchemaRunnerTest.php @@ -1,355 +1,356 @@ -getConnectionInfo()); - - $this->assertEquals('dev', $runner->getEnvironment()); - $this->assertIsArray($runner->getChanges()); - } - - public function testConstructWithEnvironment() { - $runner = new SchemaRunner($this->getConnectionInfo(), 'test'); - - $this->assertEquals('test', $runner->getEnvironment()); - } - - public function testGetChanges() { - $runner = new SchemaRunner($this->getConnectionInfo()); - - $this->assertIsArray($runner->getChanges()); - $this->assertEmpty($runner->getChanges()); - } - - public function testInvalidPath() { - // Test registration-based approach doesn't need path validation - $runner = new SchemaRunner($this->getConnectionInfo()); - $runner->register(TestMigration::class); - - $this->assertCount(1, $runner->getChanges()); - } - - public function testAddOnErrorCallback() { - $runner = new SchemaRunner($this->getConnectionInfo()); - - $callbackCalled = false; - $runner->addOnErrorCallback(function($err, $change, $schema) use (&$callbackCalled) { - $callbackCalled = true; - }); - - // Callback should be added (we can't directly test this without triggering an error) - $this->assertTrue(true); - } - - public function testAddOnRegisterErrorCallback() { - $runner = new SchemaRunner($this->getConnectionInfo()); - - $callbackCalled = false; - $runner->addOnRegisterErrorCallback(function($err) use (&$callbackCalled) { - $callbackCalled = true; - }); - - // Callback should be added - $this->assertTrue(true); - } - - public function testClearErrorCallbacks() { - $runner = new SchemaRunner($this->getConnectionInfo()); - - $runner->addOnErrorCallback(function($err, $change, $schema) {}); - $runner->clearErrorCallbacks(); - - // Should clear callbacks without error - $this->assertTrue(true); - } - - public function testClearRegisterErrorCallbacks() { - $runner = new SchemaRunner($this->getConnectionInfo()); - - $runner->addOnRegisterErrorCallback(function($err) {}); - $runner->clearRegisterErrorCallbacks(); - - // Should clear callbacks without error - $this->assertTrue(true); - } - - public function testHasChange() { - $runner = new SchemaRunner($this->getConnectionInfo()); - $runner->register(TestMigration::class); - - $this->assertTrue($runner->hasChange(TestMigration::class)); - $this->assertFalse($runner->hasChange('NonExistentChange')); - } - - public function testApplyOne() { - try { - $runner = new SchemaRunner($this->getConnectionInfo()); - $runner->createSchemaTable(); - - $change = $runner->applyOne(); - - if ($change !== null) { - $this->assertInstanceOf('WebFiori\\Database\\Schema\\DatabaseChange', $change); - $this->assertTrue($runner->isApplied($change->getName())); - } else { - // If no changes to apply, that's also valid - $this->assertTrue(true, 'No changes to apply'); - } - - $runner->dropSchemaTable(); - } catch (DatabaseException $ex) { - $this->markTestSkipped('Database connection failed: ' . $ex->getMessage()); - } - } - - public function testApplyOneWithNoChanges() { - try { - $runner = new SchemaRunner($this->getConnectionInfo()); - $runner->createSchemaTable(); - - // Apply all changes first - $runner->apply(); - - // Now applyOne should return null - $change = $runner->applyOne(); - $this->assertNull($change); - - $runner->dropSchemaTable(); - } catch (DatabaseException $ex) { - $this->markTestSkipped('Database connection failed: ' . $ex->getMessage()); - } - } - - public function testRollbackUpToSpecificChange() { - try { - $runner = new SchemaRunner($this->getConnectionInfo()); - $runner->createSchemaTable(); - - $applied = $runner->apply(); - - if (!empty($applied)) { - $lastChange = end($applied); - $rolled = $runner->rollbackUpTo($lastChange->getName()); - - $this->assertIsArray($rolled); - $this->assertCount(1, $rolled); - $this->assertEquals($lastChange->getName(), $rolled[0]->getName()); - $this->assertFalse($runner->isApplied($lastChange->getName())); - } else { - $this->assertTrue(true, 'No changes were applied to rollback'); - } - - $runner->dropSchemaTable(); - } catch (DatabaseException $ex) { - $this->markTestSkipped('Database connection failed: ' . $ex->getMessage()); - } - } - - public function testRollbackUpToNonExistentChange() { - try { - $runner = new SchemaRunner($this->getConnectionInfo()); - $runner->createSchemaTable(); - - $rolled = $runner->rollbackUpTo('NonExistentChange'); - $this->assertIsArray($rolled); - $this->assertEmpty($rolled); - - $runner->dropSchemaTable(); - } catch (DatabaseException $ex) { - $this->markTestSkipped('Database connection failed: ' . $ex->getMessage()); - } - } - - public function testErrorCallbackOnExecutionFailure() { - $runner = new SchemaRunner($this->getConnectionInfo()); - - $errorCaught = false; - $runner->addOnErrorCallback(function($err, $change, $schema) use (&$errorCaught) { - $errorCaught = true; - $this->assertInstanceOf('Throwable', $err); - }); - - // Simulate error by accessing private property and triggering callbacks - $reflection = new \ReflectionClass($runner); - $property = $reflection->getProperty('onErrCallbacks'); - $property->setAccessible(true); - $callbacks = $property->getValue($runner); - - // Manually trigger callbacks to test - foreach ($callbacks as $callback) { - call_user_func_array($callback, [new \Exception('test'), null, null]); - } - - $this->assertTrue($errorCaught); - } - - // File System Scanning Issues - public function testSubdirectoryMigrationsNotDetected() { - // Test that registration approach doesn't have subdirectory issues - $runner = new SchemaRunner($this->getConnectionInfo()); - $runner->register(TestMigration::class); - - $changes = $runner->getChanges(); - $this->assertCount(1, $changes); - } - - public function testFileExtensionAssumptions() { - $tempDir = sys_get_temp_dir() . '/schema_test_' . uniqid(); - mkdir($tempDir, 0777, true); - - // Create file with multiple dots - file_put_contents($tempDir . '/Migration.backup.php', 'getConnectionInfo()); - - // Should handle file with multiple dots gracefully - $this->assertIsArray($runner->getChanges()); - - // Cleanup - unlink($tempDir . '/Migration.backup.php'); - rmdir($tempDir); - } - - public function testPermissionIssuesOnDirectory() { - // Test that registration approach doesn't have permission issues - $runner = new SchemaRunner($this->getConnectionInfo()); - $runner->register(TestMigration::class); - - $this->assertTrue($runner->hasChange(TestMigration::class)); - } - - // Class Loading Issues - public function testNamespaceMismatch() { - // Test registration with invalid class name - $runner = new SchemaRunner($this->getConnectionInfo()); - - $this->expectException(\Exception::class); - $this->expectExceptionMessage('Class does not exist'); - - $runner->register('InvalidNamespace\\NonExistentClass'); - } - - public function testConstructorDependencies() { - // Test registration handles constructor requirements properly - $runner = new SchemaRunner($this->getConnectionInfo()); - $result = $runner->register(TestMigration::class); - - $this->assertTrue($result); - $this->assertCount(1, $runner->getChanges()); - } - - // Dependency Resolution Issues - public function testMissingDependency() { - // Test dependency validation with registration - $runner = new SchemaRunner($this->getConnectionInfo()); - $runner->register(TestMigration::class); - - $changes = $runner->getChanges(); - $this->assertCount(1, $changes); - - // Test that changes are registered properly - $this->assertInstanceOf('WebFiori\\Database\\Schema\\DatabaseChange', $changes[0]); - } - - public function testCircularDependency() { - // Test circular dependency detection with registration - $runner = new SchemaRunner($this->getConnectionInfo()); - $runner->registerAll([TestMigration::class, TestSeeder::class]); - - // Registration succeeds, circular dependencies detected during execution - $this->assertCount(2, $runner->getChanges()); - } - - // Schema Tracking Issues - public function testSchemaTableNotExists() { - try { - $runner = new SchemaRunner($this->getConnectionInfo()); - $runner->createSchemaTable(); // Ensure table exists first - - // Test that we can check if changes are applied - $this->assertFalse($runner->isApplied('NonExistentMigration')); - - } catch (DatabaseException $ex) { - $this->markTestSkipped('Database connection failed: ' . $ex->getMessage()); - } - } - - public function testDuplicateChangeDetection() { - $runner = new SchemaRunner($this->getConnectionInfo()); - $runner->register(TestMigration::class); - $runner->register(TestMigration::class); // Register same class twice - - $changes = $runner->getChanges(); - $this->assertCount(2, $changes); // Both instances are registered - } - - public function testNameCollisionInFindChangeByName() { - $runner = new SchemaRunner($this->getConnectionInfo()); - $runner->registerAll([TestMigration::class, TestSeeder::class]); - - $this->assertTrue($runner->hasChange(TestMigration::class)); - $this->assertTrue($runner->hasChange(TestSeeder::class)); - } - - // Error Handling Issues - public function testSilentFailureInApply() { - $runner = new SchemaRunner($this->getConnectionInfo()); - - $errorCaught = false; - $runner->addOnErrorCallback(function($err, $change, $schema) use (&$errorCaught) { - $errorCaught = true; - }); - - $runner->register(TestMigration::class); - - // Test that errors are properly caught - $this->assertCount(1, $runner->getChanges()); - } - - public function testRollbackFailureContinuesExecution() { - try { - $runner = new SchemaRunner($this->getConnectionInfo()); - $runner->createSchemaTable(); - - $errorCaught = false; - $runner->addOnErrorCallback(function($err, $change, $schema) use (&$errorCaught) { - $errorCaught = true; - }); - - $runner->register(TestMigration::class); - $changes = $runner->getChanges(); - - // Test that rollback handling works - $this->assertCount(1, $changes); - $this->assertIsCallable([$runner, 'rollbackUpTo']); - - } catch (DatabaseException $ex) { - $this->markTestSkipped('Database connection failed: ' . $ex->getMessage()); - } - } -} +getConnectionInfo()); + + $this->assertEquals('dev', $runner->getEnvironment()); + $this->assertIsArray($runner->getChanges()); + } + + public function testConstructWithEnvironment() { + $runner = new SchemaRunner($this->getConnectionInfo(), 'test'); + + $this->assertEquals('test', $runner->getEnvironment()); + } + + public function testGetChanges() { + $runner = new SchemaRunner($this->getConnectionInfo()); + + $this->assertIsArray($runner->getChanges()); + $this->assertEmpty($runner->getChanges()); + } + + public function testInvalidPath() { + // Test registration-based approach doesn't need path validation + $runner = new SchemaRunner($this->getConnectionInfo()); + $runner->register(TestMigration::class); + + $this->assertCount(1, $runner->getChanges()); + } + + public function testAddOnErrorCallback() { + $runner = new SchemaRunner($this->getConnectionInfo()); + + $callbackCalled = false; + $runner->addOnErrorCallback(function($err, $change, $schema) use (&$callbackCalled) { + $callbackCalled = true; + }); + + // Callback should be added (we can't directly test this without triggering an error) + $this->assertTrue(true); + } + + public function testAddOnRegisterErrorCallback() { + $runner = new SchemaRunner($this->getConnectionInfo()); + + $callbackCalled = false; + $runner->addOnRegisterErrorCallback(function($err) use (&$callbackCalled) { + $callbackCalled = true; + }); + + // Callback should be added + $this->assertTrue(true); + } + + public function testClearErrorCallbacks() { + $runner = new SchemaRunner($this->getConnectionInfo()); + + $runner->addOnErrorCallback(function($err, $change, $schema) {}); + $runner->clearErrorCallbacks(); + + // Should clear callbacks without error + $this->assertTrue(true); + } + + public function testClearRegisterErrorCallbacks() { + $runner = new SchemaRunner($this->getConnectionInfo()); + + $runner->addOnRegisterErrorCallback(function($err) {}); + $runner->clearRegisterErrorCallbacks(); + + // Should clear callbacks without error + $this->assertTrue(true); + } + + public function testHasChange() { + $runner = new SchemaRunner($this->getConnectionInfo()); + $runner->register(TestMigration::class); + + $this->assertTrue($runner->hasChange(TestMigration::class)); + $this->assertFalse($runner->hasChange('NonExistentChange')); + } + + public function testApplyOne() { + try { + $runner = new SchemaRunner($this->getConnectionInfo()); + $runner->createSchemaTable(); + + $change = $runner->applyOne(); + + if ($change !== null) { + $this->assertInstanceOf('WebFiori\\Database\\Schema\\DatabaseChange', $change); + $this->assertTrue($runner->isApplied($change->getName())); + } else { + // If no changes to apply, that's also valid + $this->assertTrue(true, 'No changes to apply'); + } + + $runner->dropSchemaTable(); + } catch (DatabaseException $ex) { + $this->markTestSkipped('Database connection failed: ' . $ex->getMessage()); + } + } + + public function testApplyOneWithNoChanges() { + try { + $runner = new SchemaRunner($this->getConnectionInfo()); + $runner->createSchemaTable(); + + // Apply all changes first + $runner->apply(); + + // Now applyOne should return null + $change = $runner->applyOne(); + $this->assertNull($change); + + $runner->dropSchemaTable(); + } catch (DatabaseException $ex) { + $this->markTestSkipped('Database connection failed: ' . $ex->getMessage()); + } + } + + public function testRollbackUpToSpecificChange() { + try { + $runner = new SchemaRunner($this->getConnectionInfo()); + $runner->createSchemaTable(); + + $applied = $runner->apply(); + + if (!empty($applied)) { + $lastChange = end($applied); + $rolled = $runner->rollbackUpTo($lastChange->getName()); + + $this->assertIsArray($rolled); + $this->assertCount(1, $rolled); + $this->assertEquals($lastChange->getName(), $rolled[0]->getName()); + $this->assertFalse($runner->isApplied($lastChange->getName())); + } else { + $this->assertTrue(true, 'No changes were applied to rollback'); + } + + $runner->dropSchemaTable(); + } catch (DatabaseException $ex) { + $this->markTestSkipped('Database connection failed: ' . $ex->getMessage()); + } + } + + public function testRollbackUpToNonExistentChange() { + try { + $runner = new SchemaRunner($this->getConnectionInfo()); + $runner->createSchemaTable(); + + $rolled = $runner->rollbackUpTo('NonExistentChange'); + $this->assertIsArray($rolled); + $this->assertEmpty($rolled); + + $runner->dropSchemaTable(); + } catch (DatabaseException $ex) { + $this->markTestSkipped('Database connection failed: ' . $ex->getMessage()); + } + } + + public function testErrorCallbackOnExecutionFailure() { + $runner = new SchemaRunner($this->getConnectionInfo()); + + $errorCaught = false; + $runner->addOnErrorCallback(function($err, $change, $schema) use (&$errorCaught) { + $errorCaught = true; + $this->assertInstanceOf('Throwable', $err); + }); + + // Simulate error by accessing private property and triggering callbacks + $reflection = new \ReflectionClass($runner); + $property = $reflection->getProperty('onErrCallbacks'); + $property->setAccessible(true); + $callbacks = $property->getValue($runner); + + // Manually trigger callbacks to test + foreach ($callbacks as $callback) { + call_user_func_array($callback, [new \Exception('test'), null, null]); + } + + $this->assertTrue($errorCaught); + } + + // File System Scanning Issues + public function testSubdirectoryMigrationsNotDetected() { + // Test that registration approach doesn't have subdirectory issues + $runner = new SchemaRunner($this->getConnectionInfo()); + $runner->register(TestMigration::class); + + $changes = $runner->getChanges(); + $this->assertCount(1, $changes); + } + + public function testFileExtensionAssumptions() { + $tempDir = sys_get_temp_dir() . '/schema_test_' . uniqid(); + mkdir($tempDir, 0777, true); + + // Create file with multiple dots + file_put_contents($tempDir . '/Migration.backup.php', 'getConnectionInfo()); + + // Should handle file with multiple dots gracefully + $this->assertIsArray($runner->getChanges()); + + // Cleanup + unlink($tempDir . '/Migration.backup.php'); + rmdir($tempDir); + } + + public function testPermissionIssuesOnDirectory() { + // Test that registration approach doesn't have permission issues + $runner = new SchemaRunner($this->getConnectionInfo()); + $runner->register(TestMigration::class); + + $this->assertTrue($runner->hasChange(TestMigration::class)); + } + + // Class Loading Issues + public function testNamespaceMismatch() { + // Test registration with invalid class name + $runner = new SchemaRunner($this->getConnectionInfo()); + + $this->expectException(\Exception::class); + $this->expectExceptionMessage('Class does not exist'); + + $runner->register('InvalidNamespace\\NonExistentClass'); + } + + public function testConstructorDependencies() { + // Test registration handles constructor requirements properly + $runner = new SchemaRunner($this->getConnectionInfo()); + $result = $runner->register(TestMigration::class); + + $this->assertTrue($result); + $this->assertCount(1, $runner->getChanges()); + } + + // Dependency Resolution Issues + public function testMissingDependency() { + // Test dependency validation with registration + $runner = new SchemaRunner($this->getConnectionInfo()); + $runner->register(TestMigration::class); + + $changes = $runner->getChanges(); + $this->assertCount(1, $changes); + + // Test that changes are registered properly + $this->assertInstanceOf('WebFiori\\Database\\Schema\\DatabaseChange', $changes[0]); + } + + public function testCircularDependency() { + // Test circular dependency detection with registration + $runner = new SchemaRunner($this->getConnectionInfo()); + $runner->registerAll([TestMigration::class, TestSeeder::class]); + + // Registration succeeds, circular dependencies detected during execution + $this->assertCount(2, $runner->getChanges()); + } + + // Schema Tracking Issues + public function testSchemaTableNotExists() { + try { + $runner = new SchemaRunner($this->getConnectionInfo()); + $runner->createSchemaTable(); // Ensure table exists first + + // Test that we can check if changes are applied + $this->assertFalse($runner->isApplied('NonExistentMigration')); + + } catch (DatabaseException $ex) { + $this->markTestSkipped('Database connection failed: ' . $ex->getMessage()); + } + } + + public function testDuplicateChangeDetection() { + $runner = new SchemaRunner($this->getConnectionInfo()); + $first = $runner->register(TestMigration::class); + $second = $runner->register(TestMigration::class); // Register same class twice + + $this->assertTrue($first); + $this->assertFalse($second); // Second registration returns false + $this->assertCount(1, $runner->getChanges()); // Only one instance registered + } + + public function testNameCollisionInFindChangeByName() { + $runner = new SchemaRunner($this->getConnectionInfo()); + $runner->registerAll([TestMigration::class, TestSeeder::class]); + + $this->assertTrue($runner->hasChange(TestMigration::class)); + $this->assertTrue($runner->hasChange(TestSeeder::class)); + } + + // Error Handling Issues + public function testSilentFailureInApply() { + $runner = new SchemaRunner($this->getConnectionInfo()); + + $errorCaught = false; + $runner->addOnErrorCallback(function($err, $change, $schema) use (&$errorCaught) { + $errorCaught = true; + }); + + $runner->register(TestMigration::class); + + // Test that errors are properly caught + $this->assertCount(1, $runner->getChanges()); + } + + public function testRollbackFailureContinuesExecution() { + try { + $runner = new SchemaRunner($this->getConnectionInfo()); + $runner->createSchemaTable(); + + $errorCaught = false; + $runner->addOnErrorCallback(function($err, $change, $schema) use (&$errorCaught) { + $errorCaught = true; + }); + + $runner->register(TestMigration::class); + $changes = $runner->getChanges(); + + // Test that rollback handling works + $this->assertCount(1, $changes); + $this->assertIsCallable([$runner, 'rollbackUpTo']); + + } catch (DatabaseException $ex) { + $this->markTestSkipped('Database connection failed: ' . $ex->getMessage()); + } + } +} From e5694e635b3fc232eba9800ae610a8a7af14f459 Mon Sep 17 00:00:00 2001 From: Ibrahim BinAlshikh Date: Thu, 1 Jan 2026 17:06:26 +0300 Subject: [PATCH 12/44] feat: Batching of Migrations --- WebFiori/Database/Schema/DatabaseChange.php | 325 +++++++++--------- .../Schema/SchemaChangeRepository.php | 62 +++- .../Database/Schema/SchemaMigrationsTable.php | 6 + WebFiori/Database/Schema/SchemaRunner.php | 46 ++- .../Database/Schema/BatchTrackingTest.php | 231 +++++++++++++ 5 files changed, 514 insertions(+), 156 deletions(-) create mode 100644 tests/WebFiori/Tests/Database/Schema/BatchTrackingTest.php diff --git a/WebFiori/Database/Schema/DatabaseChange.php b/WebFiori/Database/Schema/DatabaseChange.php index 4aa0db6d..1694d68b 100644 --- a/WebFiori/Database/Schema/DatabaseChange.php +++ b/WebFiori/Database/Schema/DatabaseChange.php @@ -1,153 +1,172 @@ -setAppliedAt(date('Y-m-d H:i:s')); - } - /** - * Execute the database change. - * - * @param Database $db The database instance to execute against. - */ - /** - * Execute the database change (apply the migration or seeder). - * - * This method contains the logic to apply the database change. - * For migrations: create/modify tables, columns, indexes, etc. - * For seeders: insert data into tables. - * - * @param Database $db The database instance to execute changes on. - */ - abstract public function execute(Database $db): void; - /** - * Get the timestamp when this change was applied. - * - * @return string The date and time when this change was applied in Y-m-d H:i:s format. - */ - public function getAppliedAt(): string { - return $this->appliedAt; - } - - /** - * Get the list of changes this change depends on. - * - * Dependencies ensure changes are executed in the correct order. - * For example, a migration that adds a foreign key depends on - * the migration that creates the referenced table. - * - * @return array Array of class names that must be executed before this change. - */ - public function getDependencies(): array { - return []; - } - - /** - * Get the unique identifier for this database change. - * - * @return int The unique identifier assigned by the schema tracking system. - */ - public function getId(): int { - return $this->id; - } - - - /** - * Get the name of this database change. - * - * The name is derived from the class name and used for tracking - * and identification purposes in the schema management system. - * - * @return string The class name of this database change. - */ - public function getName(): string { - return static::class; - } - - /** - * Get the type of database change. - * - * @return string Either 'migration' or 'seeder'. - */ - abstract public function getType(): string; - - /** - * Rollback the database change (undo the migration or seeder). - * - * This method contains the logic to reverse the database change. - * For migrations: drop tables, remove columns, etc. - * For seeders: typically not implemented as data rollback is complex. - * - * @param Database $db The database instance to execute rollback on. - */ - abstract public function rollback(Database $db): void; - - /** - * Set the timestamp when this change was applied. - * - * @param string $date The date and time in Y-m-d H:i:s format. - */ - public function setAppliedAt(string $date) { - $this->appliedAt = $date; - } - /** - * Set the unique identifier for this database change. - * - * @param int $id The unique identifier assigned by the schema tracking system. - */ - public function setId(int $id) { - $this->id = $id; - } - - /** - * Set the database instance for this change. - * - * @param Database $db The database instance to use for this change. - */ - public function setDatabase(Database $db): void { - $this->database = $db; - } - - /** - * Get the database instance for this change. - * - * @return Database|null The database instance or null if not set. - */ - public function getDatabase(): ?Database { - return $this->database; - } -} +setAppliedAt(date('Y-m-d H:i:s')); + } + /** + * Execute the database change. + * + * @param Database $db The database instance to execute against. + */ + /** + * Execute the database change (apply the migration or seeder). + * + * This method contains the logic to apply the database change. + * For migrations: create/modify tables, columns, indexes, etc. + * For seeders: insert data into tables. + * + * @param Database $db The database instance to execute changes on. + */ + abstract public function execute(Database $db): void; + /** + * Get the timestamp when this change was applied. + * + * @return string The date and time when this change was applied in Y-m-d H:i:s format. + */ + public function getAppliedAt(): string { + return $this->appliedAt; + } + + /** + * Get the list of changes this change depends on. + * + * Dependencies ensure changes are executed in the correct order. + * For example, a migration that adds a foreign key depends on + * the migration that creates the referenced table. + * + * @return array Array of class names that must be executed before this change. + */ + public function getDependencies(): array { + return []; + } + + /** + * Get the unique identifier for this database change. + * + * @return int The unique identifier assigned by the schema tracking system. + */ + public function getId(): int { + return $this->id; + } + + + /** + * Get the name of this database change. + * + * The name is derived from the class name and used for tracking + * and identification purposes in the schema management system. + * + * @return string The class name of this database change. + */ + public function getName(): string { + return static::class; + } + + /** + * Get the type of database change. + * + * @return string Either 'migration' or 'seeder'. + */ + abstract public function getType(): string; + + /** + * Rollback the database change (undo the migration or seeder). + * + * This method contains the logic to reverse the database change. + * For migrations: drop tables, remove columns, etc. + * For seeders: typically not implemented as data rollback is complex. + * + * @param Database $db The database instance to execute rollback on. + */ + abstract public function rollback(Database $db): void; + + /** + * Set the timestamp when this change was applied. + * + * @param string $date The date and time in Y-m-d H:i:s format. + */ + public function setAppliedAt(string $date) { + $this->appliedAt = $date; + } + /** + * Set the unique identifier for this database change. + * + * @param int $id The unique identifier assigned by the schema tracking system. + */ + public function setId(int $id) { + $this->id = $id; + } + + /** + * Get the batch number when this change was applied. + * + * @return int The batch number, or 0 if not yet applied. + */ + public function getBatch(): int { + return $this->batch; + } + + /** + * Set the batch number for this change. + * + * @param int $batch The batch number. + */ + public function setBatch(int $batch): void { + $this->batch = $batch; + } + + /** + * Set the database instance for this change. + * + * @param Database $db The database instance to use for this change. + */ + public function setDatabase(Database $db): void { + $this->database = $db; + } + + /** + * Get the database instance for this change. + * + * @return Database|null The database instance or null if not set. + */ + public function getDatabase(): ?Database { + return $this->database; + } +} diff --git a/WebFiori/Database/Schema/SchemaChangeRepository.php b/WebFiori/Database/Schema/SchemaChangeRepository.php index a8a86d9d..3b607b95 100644 --- a/WebFiori/Database/Schema/SchemaChangeRepository.php +++ b/WebFiori/Database/Schema/SchemaChangeRepository.php @@ -87,7 +87,7 @@ public function isApplied(string $changeName): bool { /** * Record a change as applied. * - * @param DatabaseChange $change The change to record + * @param DatabaseChange $change The change to record (must have batch set via setBatch()) * @return int The ID of the inserted record */ public function recordChange(DatabaseChange $change): int { @@ -96,11 +96,69 @@ public function recordChange(DatabaseChange $change): int { 'change_name' => $change->getName(), 'type' => $change->getType(), 'applied-on' => date('Y-m-d H:i:s'), - 'db-name' => $this->getDatabase()->getConnectionInfo()->getDatabase() + 'db-name' => $this->getDatabase()->getConnectionInfo()->getDatabase(), + 'batch' => $change->getBatch() ])->execute(); return $this->getDatabase()->getLastInsertId(); } + + /** + * Get the next batch number. + * + * @return int The next batch number + */ + public function getNextBatchNumber(): int { + $result = $this->getDatabase()->table($this->getTableName()) + ->select(['batch']) + ->orderBy(['batch' => 'd']) + ->limit(1) + ->execute(); + + if ($result->getRowsCount() === 0) { + return 1; + } + + return (int) $result->getRows()[0]['batch'] + 1; + } + + /** + * Get the last batch number. + * + * @return int The last batch number, or 0 if no batches exist + */ + public function getLastBatchNumber(): int { + return $this->getNextBatchNumber() - 1; + } + + /** + * Get changes by batch number. + * + * @param int $batch The batch number + * @return array Array of change records + */ + public function getByBatch(int $batch): array { + return $this->getDatabase()->table($this->getTableName()) + ->select() + ->where('batch', $batch) + ->execute() + ->getRows(); + } + + /** + * Get change names from the last batch. + * + * @return array Array of change names from the last batch + */ + public function getLastBatchChangeNames(): array { + $lastBatch = $this->getLastBatchNumber(); + if ($lastBatch === 0) { + return []; + } + + $records = $this->getByBatch($lastBatch); + return array_column($records, 'change_name'); + } /** * Remove a change record. diff --git a/WebFiori/Database/Schema/SchemaMigrationsTable.php b/WebFiori/Database/Schema/SchemaMigrationsTable.php index 3713f11a..c0dcbc3c 100644 --- a/WebFiori/Database/Schema/SchemaMigrationsTable.php +++ b/WebFiori/Database/Schema/SchemaMigrationsTable.php @@ -53,4 +53,10 @@ type: DataType::DATETIME, comment: 'The date and time at which the change was applied.' )] +#[Column( + name: 'batch', + type: DataType::INT, + default: 1, + comment: 'The batch number when this change was applied.' +)] class SchemaMigrationsTable {} diff --git a/WebFiori/Database/Schema/SchemaRunner.php b/WebFiori/Database/Schema/SchemaRunner.php index 2e1d99e7..7362ceb6 100644 --- a/WebFiori/Database/Schema/SchemaRunner.php +++ b/WebFiori/Database/Schema/SchemaRunner.php @@ -118,10 +118,14 @@ public function addOnRegisterErrorCallback(callable $callback): void { /** * Apply all pending database changes. * + * All changes applied in a single call to apply() are assigned the same + * batch number, allowing them to be rolled back together. + * * @return array Array of applied DatabaseChange instances. */ public function apply(): array { $applied = []; + $batch = $this->getRepository()->getNextBatchNumber(); // Keep applying changes until no more can be applied $appliedInPass = true; @@ -144,6 +148,7 @@ public function apply(): array { try { $change->execute($this); + $change->setBatch($batch); $this->getRepository()->recordChange($change); $applied[] = $change; $appliedInPass = true; @@ -162,10 +167,14 @@ public function apply(): array { /** * Apply the next pending database change. * + * Each call to applyOne() creates a new batch with a single change. + * * @return DatabaseChange|null The applied change, or null if no pending changes. */ public function applyOne(): ?DatabaseChange { $change = null; + $batch = $this->getRepository()->getNextBatchNumber(); + try { foreach ($this->dbChanges as $change) { if ($this->isApplied($change->getName())) { @@ -182,8 +191,8 @@ public function applyOne(): ?DatabaseChange { try { $change->execute($this); + $change->setBatch($batch); $this->getRepository()->recordChange($change); - $applied[] = $change; } catch (\Throwable $ex) { foreach ($this->onErrCallbacks as $callback) { call_user_func_array($callback, [$ex, $change, $this]); @@ -289,6 +298,41 @@ public function isApplied(string $name): bool { public function getRepository(): SchemaChangeRepository { return $this->repository; } + + /** + * Rollback all changes from the last batch. + * + * @return array Array of rolled back DatabaseChange instances. + */ + public function rollbackLastBatch(): array { + $lastBatch = $this->getRepository()->getLastBatchNumber(); + if ($lastBatch === 0) { + return []; + } + return $this->rollbackBatch($lastBatch); + } + + /** + * Rollback all changes from a specific batch. + * + * @param int $batch The batch number to rollback. + * @return array Array of rolled back DatabaseChange instances. + */ + public function rollbackBatch(int $batch): array { + $changeNames = array_column($this->getRepository()->getByBatch($batch), 'change_name'); + $rolled = []; + + // Rollback in reverse order + $changes = array_reverse($this->getChanges()); + foreach ($changes as $change) { + if (in_array($change->getName(), $changeNames)) { + $this->attemptRoolback($change, $rolled); + } + } + + return $rolled; + } + /** * Rollback database changes up to a specific change. * diff --git a/tests/WebFiori/Tests/Database/Schema/BatchTrackingTest.php b/tests/WebFiori/Tests/Database/Schema/BatchTrackingTest.php new file mode 100644 index 00000000..90e71a62 --- /dev/null +++ b/tests/WebFiori/Tests/Database/Schema/BatchTrackingTest.php @@ -0,0 +1,231 @@ +getConnectionInfo()); + + try { + $runner->createSchemaTable(); + + $nextBatch = $runner->getRepository()->getNextBatchNumber(); + $this->assertEquals(1, $nextBatch); + + $runner->dropSchemaTable(); + } catch (\Exception $ex) { + $this->markTestSkipped('Database connection failed: ' . $ex->getMessage()); + } + } + + public function testGetLastBatchNumberEmpty() { + $runner = new SchemaRunner($this->getConnectionInfo()); + + try { + $runner->createSchemaTable(); + + $lastBatch = $runner->getRepository()->getLastBatchNumber(); + $this->assertEquals(0, $lastBatch); + + $runner->dropSchemaTable(); + } catch (\Exception $ex) { + $this->markTestSkipped('Database connection failed: ' . $ex->getMessage()); + } + } + + public function testApplyAssignsSameBatchNumber() { + $runner = new SchemaRunner($this->getConnectionInfo()); + $runner->register(BatchMigrationA::class); + $runner->register(BatchMigrationB::class); + + try { + $runner->createSchemaTable(); + + $applied = $runner->apply(); + $this->assertCount(2, $applied); + + // Both should have batch 1 + $records = $runner->getRepository()->getByBatch(1); + $this->assertCount(2, $records); + + $runner->dropSchemaTable(); + } catch (\Exception $ex) { + $this->markTestSkipped('Database connection failed: ' . $ex->getMessage()); + } + } + + public function testMultipleApplyCallsCreateDifferentBatches() { + $runner = new SchemaRunner($this->getConnectionInfo()); + $runner->register(BatchMigrationA::class); + + try { + $runner->createSchemaTable(); + + // First apply - batch 1 + $runner->apply(); + + // Register more and apply again - batch 2 + $runner->register(BatchMigrationB::class); + $runner->apply(); + + $batch1 = $runner->getRepository()->getByBatch(1); + $batch2 = $runner->getRepository()->getByBatch(2); + + $this->assertCount(1, $batch1); + $this->assertCount(1, $batch2); + $this->assertEquals(BatchMigrationA::class, $batch1[0]['change_name']); + $this->assertEquals(BatchMigrationB::class, $batch2[0]['change_name']); + + $runner->dropSchemaTable(); + } catch (\Exception $ex) { + $this->markTestSkipped('Database connection failed: ' . $ex->getMessage()); + } + } + + public function testApplyOneCreatesNewBatchEachTime() { + $runner = new SchemaRunner($this->getConnectionInfo()); + $runner->register(BatchMigrationA::class); + $runner->register(BatchMigrationB::class); + + try { + $runner->createSchemaTable(); + + $runner->applyOne(); // batch 1 + $runner->applyOne(); // batch 2 + + $batch1 = $runner->getRepository()->getByBatch(1); + $batch2 = $runner->getRepository()->getByBatch(2); + + $this->assertCount(1, $batch1); + $this->assertCount(1, $batch2); + + $runner->dropSchemaTable(); + } catch (\Exception $ex) { + $this->markTestSkipped('Database connection failed: ' . $ex->getMessage()); + } + } + + public function testRollbackLastBatch() { + $runner = new SchemaRunner($this->getConnectionInfo()); + $runner->register(BatchMigrationA::class); + + try { + $runner->createSchemaTable(); + + // Apply batch 1 + $runner->apply(); + + // Register more and apply batch 2 + $runner->register(BatchMigrationB::class); + $runner->register(BatchMigrationC::class); + $runner->apply(); + + // Rollback last batch (should rollback B and C) + $rolled = $runner->rollbackLastBatch(); + + $this->assertCount(2, $rolled); + $this->assertTrue($runner->isApplied(BatchMigrationA::class)); + $this->assertFalse($runner->isApplied(BatchMigrationB::class)); + $this->assertFalse($runner->isApplied(BatchMigrationC::class)); + + $runner->dropSchemaTable(); + } catch (\Exception $ex) { + $this->markTestSkipped('Database connection failed: ' . $ex->getMessage()); + } + } + + public function testRollbackBatch() { + $runner = new SchemaRunner($this->getConnectionInfo()); + $runner->register(BatchMigrationA::class); + + try { + $runner->createSchemaTable(); + + // Apply batch 1 + $runner->apply(); + + // Apply batch 2 + $runner->register(BatchMigrationB::class); + $runner->apply(); + + // Rollback batch 1 specifically + $rolled = $runner->rollbackBatch(1); + + $this->assertCount(1, $rolled); + $this->assertFalse($runner->isApplied(BatchMigrationA::class)); + $this->assertTrue($runner->isApplied(BatchMigrationB::class)); + + $runner->dropSchemaTable(); + } catch (\Exception $ex) { + $this->markTestSkipped('Database connection failed: ' . $ex->getMessage()); + } + } + + public function testRollbackLastBatchWhenEmpty() { + $runner = new SchemaRunner($this->getConnectionInfo()); + + try { + $runner->createSchemaTable(); + + $rolled = $runner->rollbackLastBatch(); + $this->assertEmpty($rolled); + + $runner->dropSchemaTable(); + } catch (\Exception $ex) { + $this->markTestSkipped('Database connection failed: ' . $ex->getMessage()); + } + } + + public function testGetLastBatchChangeNames() { + $runner = new SchemaRunner($this->getConnectionInfo()); + $runner->register(BatchMigrationA::class); + $runner->register(BatchMigrationB::class); + + try { + $runner->createSchemaTable(); + + $runner->apply(); + + $names = $runner->getRepository()->getLastBatchChangeNames(); + + $this->assertCount(2, $names); + $this->assertContains(BatchMigrationA::class, $names); + $this->assertContains(BatchMigrationB::class, $names); + + $runner->dropSchemaTable(); + } catch (\Exception $ex) { + $this->markTestSkipped('Database connection failed: ' . $ex->getMessage()); + } + } +} From 699673a8a0a6930ea52d6f7fc80c0c7e6e33f3b3 Mon Sep 17 00:00:00 2001 From: Ibrahim BinAlshikh Date: Sun, 4 Jan 2026 17:34:50 +0300 Subject: [PATCH 13/44] feat: Add Support for Dry Run --- WebFiori/Database/Database.php | 78 ++++++++- WebFiori/Database/Schema/SchemaRunner.php | 43 +++++ .../Tests/Database/Schema/DryRunTest.php | 165 ++++++++++++++++++ 3 files changed, 277 insertions(+), 9 deletions(-) create mode 100644 tests/WebFiori/Tests/Database/Schema/DryRunTest.php diff --git a/WebFiori/Database/Database.php b/WebFiori/Database/Database.php index 5829f3f3..b15ae2a0 100644 --- a/WebFiori/Database/Database.php +++ b/WebFiori/Database/Database.php @@ -68,6 +68,18 @@ class Database { * */ private $queries; + /** + * Whether dry-run mode is enabled. + * + * @var bool + */ + private bool $dryRun = false; + /** + * Queries captured during dry-run mode. + * + * @var array + */ + private array $capturedQueries = []; /** * The instance which is used to build database queries. @@ -224,13 +236,8 @@ public function clearPerformanceMetrics(): void { * and so on. */ public function createBlueprint(string $name) : Table { - $connection = $this->getConnection(); - - if ($connection === null) { - $dbType = 'mysql'; - } else { - $dbType = $connection->getConnectionInfo()->getDatabaseType(); - } + $connInfo = $this->getConnectionInfo(); + $dbType = $connInfo !== null ? $connInfo->getDatabaseType() : 'mysql'; if ($dbType == 'mssql') { $blueprint = new MSSQLTable($name); @@ -342,9 +349,19 @@ public function enablePerformanceMonitoring(): void { * */ public function execute() { - $conn = $this->getConnection(); $lastQuery = $this->getLastQuery(); + // Dry-run mode: capture query without executing + if ($this->dryRun) { + $this->capturedQueries[] = $lastQuery; + $this->queries[] = $lastQuery; + $this->clear(); + $this->getQueryGenerator()->setQuery(null); + return new ResultSet([]); + } + + $conn = $this->getConnection(); + // Start performance monitoring $startTime = $this->performanceEnabled ? microtime(true) : null; @@ -404,6 +421,36 @@ public function getConnection() : ?Connection { public function getConnectionInfo() : ?ConnectionInfo { return $this->connectionInfo; } + /** + * Enable or disable dry-run mode. + * + * When dry-run mode is enabled, queries are captured but not executed. + * This is useful for previewing what SQL would be generated. + * + * @param bool $dryRun True to enable dry-run mode, false to disable. + */ + public function setDryRun(bool $dryRun): void { + $this->dryRun = $dryRun; + if ($dryRun) { + $this->capturedQueries = []; + } + } + /** + * Check if dry-run mode is enabled. + * + * @return bool True if dry-run mode is enabled, false otherwise. + */ + public function isDryRun(): bool { + return $this->dryRun; + } + /** + * Get queries captured during dry-run mode. + * + * @return array Array of SQL query strings captured during dry-run. + */ + public function getCapturedQueries(): array { + return $this->capturedQueries; + } /** * Returns an indexed array that contains all executed SQL queries. @@ -524,7 +571,20 @@ public function getQueries() : array { * */ public function getQueryGenerator() : AbstractQuery { - if (!$this->isConnected()) { + // In dry-run mode, create query generator without connection + if ($this->dryRun && $this->queryGenerator === null) { + $connInfo = $this->getConnectionInfo(); + $dbType = $connInfo !== null ? $connInfo->getDatabaseType() : 'mysql'; + + if ($dbType == 'mssql') { + $this->queryGenerator = new MSSQLQuery(); + } else { + $this->queryGenerator = new MySQLQuery(); + } + $this->queryGenerator->setSchema($this); + } + + if ($this->queryGenerator === null && !$this->isConnected()) { if ($this->getConnectionInfo() === null) { throw new DatabaseException("Connection information not set."); } else { diff --git a/WebFiori/Database/Schema/SchemaRunner.php b/WebFiori/Database/Schema/SchemaRunner.php index 7362ceb6..9cd4510b 100644 --- a/WebFiori/Database/Schema/SchemaRunner.php +++ b/WebFiori/Database/Schema/SchemaRunner.php @@ -289,6 +289,49 @@ public function isApplied(string $name): bool { 'change_name' => $name ]) == 1; } + /** + * Get pending database changes that would be applied. + * + * This method returns changes that have not been applied yet and would + * run in the current environment. Optionally captures the SQL queries + * that would be executed using dry-run mode. + * + * @param bool $withQueries If true, executes each change in dry-run mode + * to capture the SQL queries. Default is false. + * @return array Array of associative arrays with keys: + * - 'change': The DatabaseChange instance + * - 'queries': Array of SQL strings (only if $withQueries is true) + */ + public function getPendingChanges(bool $withQueries = false): array { + $pending = []; + + foreach ($this->dbChanges as $change) { + if ($this->isApplied($change->getName())) { + continue; + } + + if (!$this->shouldRunInEnvironment($change)) { + continue; + } + + $info = ['change' => $change, 'queries' => []]; + + if ($withQueries) { + $this->setDryRun(true); + try { + $change->execute($this); + $info['queries'] = $this->getCapturedQueries(); + } catch (\Throwable $ex) { + // Capture failed, queries may be partial + } + $this->setDryRun(false); + } + + $pending[] = $info; + } + + return $pending; + } /** * Get the schema change repository. diff --git a/tests/WebFiori/Tests/Database/Schema/DryRunTest.php b/tests/WebFiori/Tests/Database/Schema/DryRunTest.php new file mode 100644 index 00000000..605c0ee6 --- /dev/null +++ b/tests/WebFiori/Tests/Database/Schema/DryRunTest.php @@ -0,0 +1,165 @@ +createBlueprint('dry_run_test')->addColumns([ + 'id' => [ColOption::TYPE => DataType::INT, ColOption::PRIMARY => true] + ]); + $db->createTables(); + $db->execute(); + } + + public function down(Database $db): void { + $db->table('dry_run_test')->drop()->execute(); + } +} + +class DryRunTest extends TestCase { + + private function getConnectionInfo(): ConnectionInfo { + return new ConnectionInfo('mysql', 'root', getenv('MYSQL_ROOT_PASSWORD') ?: '123456', 'testing_db', '127.0.0.1'); + } + + public function testSetDryRunMode() { + $db = new Database($this->getConnectionInfo()); + + $this->assertFalse($db->isDryRun()); + + $db->setDryRun(true); + $this->assertTrue($db->isDryRun()); + + $db->setDryRun(false); + $this->assertFalse($db->isDryRun()); + } + + public function testDryRunCapturesQueries() { + $db = new Database($this->getConnectionInfo()); + $db->setDryRun(true); + + $db->createBlueprint('test_table')->addColumns([ + 'id' => [ColOption::TYPE => DataType::INT, ColOption::PRIMARY => true] + ]); + $db->createTables(); + $db->execute(); + + $captured = $db->getCapturedQueries(); + + $this->assertNotEmpty($captured); + $this->assertStringContainsStringIgnoringCase('CREATE TABLE', $captured[0]); + } + + public function testDryRunReturnsEmptyResultSet() { + $db = new Database($this->getConnectionInfo()); + $db->setDryRun(true); + + $db->createBlueprint('test_table')->addColumns([ + 'id' => [ColOption::TYPE => DataType::INT, ColOption::PRIMARY => true] + ]); + $db->createTables(); + $result = $db->execute(); + + $this->assertInstanceOf('WebFiori\Database\ResultSet', $result); + $this->assertEquals(0, $result->getRowsCount()); + } + + public function testDryRunClearsOnEnable() { + $db = new Database($this->getConnectionInfo()); + $db->setDryRun(true); + + $db->createBlueprint('test1')->addColumns([ + 'id' => [ColOption::TYPE => DataType::INT, ColOption::PRIMARY => true] + ]); + $db->createTables(); + $db->execute(); + + $this->assertNotEmpty($db->getCapturedQueries()); + + // Re-enable should clear + $db->setDryRun(true); + $this->assertEmpty($db->getCapturedQueries()); + } + + public function testGetPendingChangesWithoutQueries() { + $runner = new SchemaRunner($this->getConnectionInfo()); + $runner->register(DryRunMigration::class); + + try { + $runner->createSchemaTable(); + + $pending = $runner->getPendingChanges(false); + + $this->assertCount(1, $pending); + $this->assertInstanceOf(DryRunMigration::class, $pending[0]['change']); + $this->assertEmpty($pending[0]['queries']); + + $runner->dropSchemaTable(); + } catch (\Exception $ex) { + $this->markTestSkipped('Database connection failed: ' . $ex->getMessage()); + } + } + + public function testGetPendingChangesWithQueries() { + $runner = new SchemaRunner($this->getConnectionInfo()); + $runner->register(DryRunMigration::class); + + try { + $runner->createSchemaTable(); + + $pending = $runner->getPendingChanges(true); + + $this->assertCount(1, $pending); + $this->assertNotEmpty($pending[0]['queries']); + $this->assertStringContainsStringIgnoringCase('CREATE TABLE', $pending[0]['queries'][0]); + + $runner->dropSchemaTable(); + } catch (\Exception $ex) { + $this->markTestSkipped('Database connection failed: ' . $ex->getMessage()); + } + } + + public function testGetPendingChangesExcludesApplied() { + $runner = new SchemaRunner($this->getConnectionInfo()); + $runner->register(DryRunMigration::class); + + try { + $runner->createSchemaTable(); + $runner->apply(); + + $pending = $runner->getPendingChanges(); + $this->assertEmpty($pending); + + $runner->rollbackUpTo(null); + $runner->dropSchemaTable(); + } catch (\Exception $ex) { + $this->markTestSkipped('Database connection failed: ' . $ex->getMessage()); + } + } + + public function testDryRunDoesNotExecuteQueries() { + $db = new Database($this->getConnectionInfo()); + $db->setDryRun(true); + + // Build a query that would fail if executed (table doesn't exist) + $db->createBlueprint('nonexistent_dry_run_table')->addColumns([ + 'id' => [ColOption::TYPE => DataType::INT, ColOption::PRIMARY => true] + ]); + $db->createTables(); + + // Should not throw - query is captured, not executed + $result = $db->execute(); + + $this->assertNotEmpty($db->getCapturedQueries()); + $this->assertInstanceOf('WebFiori\Database\ResultSet', $result); + } +} + From a5d4ef6c4b74e19eb0679eba0a9ffb902af90b5d Mon Sep 17 00:00:00 2001 From: Ibrahim BinAlshikh Date: Sun, 4 Jan 2026 17:52:43 +0300 Subject: [PATCH 14/44] feat: Add Support for `DatabaseChangeResult` --- .../Database/Schema/DatabaseChangeResult.php | 128 ++++++++++++++++ WebFiori/Database/Schema/SchemaRunner.php | 40 ++++- .../Schema/ApplyResultIntegrationTest.php | 138 ++++++++++++++++++ .../Schema/DatabaseChangeResultTest.php | 100 +++++++++++++ .../Tests/Database/Schema/DevOnlySeeder.php | 13 ++ .../Database/Schema/FailingMigration.php | 13 ++ .../Database/Schema/SuccessfulMigration.php | 11 ++ 7 files changed, 435 insertions(+), 8 deletions(-) create mode 100644 WebFiori/Database/Schema/DatabaseChangeResult.php create mode 100644 tests/WebFiori/Tests/Database/Schema/ApplyResultIntegrationTest.php create mode 100644 tests/WebFiori/Tests/Database/Schema/DatabaseChangeResultTest.php create mode 100644 tests/WebFiori/Tests/Database/Schema/DevOnlySeeder.php create mode 100644 tests/WebFiori/Tests/Database/Schema/FailingMigration.php create mode 100644 tests/WebFiori/Tests/Database/Schema/SuccessfulMigration.php diff --git a/WebFiori/Database/Schema/DatabaseChangeResult.php b/WebFiori/Database/Schema/DatabaseChangeResult.php new file mode 100644 index 00000000..60aab003 --- /dev/null +++ b/WebFiori/Database/Schema/DatabaseChangeResult.php @@ -0,0 +1,128 @@ + Changes that were successfully applied + */ + private array $applied = []; + + /** + * @var array Changes that were skipped + */ + private array $skipped = []; + + /** + * @var array Changes that failed + */ + private array $failed = []; + + /** + * @var float Total execution time in milliseconds + */ + private float $totalTimeMs = 0; + + /** + * Add an applied change. + */ + public function addApplied(DatabaseChange $change): void { + $this->applied[] = $change; + } + + /** + * Add a skipped change with reason. + */ + public function addSkipped(DatabaseChange $change, string $reason): void { + $this->skipped[] = ['change' => $change, 'reason' => $reason]; + } + + /** + * Add a failed change with error. + */ + public function addFailed(DatabaseChange $change, \Throwable $error): void { + $this->failed[] = ['change' => $change, 'error' => $error]; + } + + /** + * Get all applied changes. + * + * @return array + */ + public function getApplied(): array { + return $this->applied; + } + + /** + * Get all skipped changes with reasons. + * + * @return array + */ + public function getSkipped(): array { + return $this->skipped; + } + + /** + * Get all failed changes with errors. + * + * @return array + */ + public function getFailed(): array { + return $this->failed; + } + + /** + * Set total execution time. + */ + public function setTotalTime(float $timeMs): void { + $this->totalTimeMs = $timeMs; + } + + /** + * Get total execution time in milliseconds. + */ + public function getTotalTime(): float { + return $this->totalTimeMs; + } + + /** + * Check if all changes were successful (none failed). + */ + public function isSuccessful(): bool { + return empty($this->failed); + } + + /** + * Get count of applied changes (Countable interface). + */ + public function count(): int { + return count($this->applied); + } + + /** + * Iterate over applied changes (IteratorAggregate interface). + */ + public function getIterator(): Traversable { + return new ArrayIterator($this->applied); + } +} diff --git a/WebFiori/Database/Schema/SchemaRunner.php b/WebFiori/Database/Schema/SchemaRunner.php index 9cd4510b..75d1d9fb 100644 --- a/WebFiori/Database/Schema/SchemaRunner.php +++ b/WebFiori/Database/Schema/SchemaRunner.php @@ -121,11 +121,15 @@ public function addOnRegisterErrorCallback(callable $callback): void { * All changes applied in a single call to apply() are assigned the same * batch number, allowing them to be rolled back together. * - * @return array Array of applied DatabaseChange instances. + * @return DatabaseChangeResult Result containing applied, skipped, and failed changes. */ - public function apply(): array { - $applied = []; + public function apply(): DatabaseChangeResult { + $result = new DatabaseChangeResult(); $batch = $this->getRepository()->getNextBatchNumber(); + $startTime = microtime(true); + + // Track which changes we've already processed + $processed = []; // Keep applying changes until no more can be applied $appliedInPass = true; @@ -134,34 +138,54 @@ public function apply(): array { $appliedInPass = false; foreach ($this->dbChanges as $change) { - if ($this->isApplied($change->getName())) { + $name = $change->getName(); + + if (isset($processed[$name])) { + continue; + } + + if ($this->isApplied($name)) { + $processed[$name] = true; + $result->addSkipped($change, 'Already applied'); continue; } if (!$this->shouldRunInEnvironment($change)) { + $processed[$name] = true; + $result->addSkipped($change, 'Environment mismatch'); continue; } if (!$this->areDependenciesSatisfied($change)) { - continue; + continue; // Don't mark as processed - may be satisfied later } try { $change->execute($this); $change->setBatch($batch); $this->getRepository()->recordChange($change); - $applied[] = $change; + $result->addApplied($change); + $processed[$name] = true; $appliedInPass = true; } catch (\Throwable $ex) { + $result->addFailed($change, $ex); + $processed[$name] = true; foreach ($this->onErrCallbacks as $callback) { call_user_func_array($callback, [$ex, $change, $this]); } - // Continue with next change instead of breaking } } } - return $applied; + // Mark unprocessed changes as skipped (unsatisfied dependencies) + foreach ($this->dbChanges as $change) { + if (!isset($processed[$change->getName()])) { + $result->addSkipped($change, 'Unsatisfied dependencies'); + } + } + + $result->setTotalTime((microtime(true) - $startTime) * 1000); + return $result; } /** diff --git a/tests/WebFiori/Tests/Database/Schema/ApplyResultIntegrationTest.php b/tests/WebFiori/Tests/Database/Schema/ApplyResultIntegrationTest.php new file mode 100644 index 00000000..adef9712 --- /dev/null +++ b/tests/WebFiori/Tests/Database/Schema/ApplyResultIntegrationTest.php @@ -0,0 +1,138 @@ +getConnectionInfo()); + $runner->register(SuccessfulMigration::class); + + try { + $runner->createSchemaTable(); + + $result = $runner->apply(); + + $this->assertInstanceOf(DatabaseChangeResult::class, $result); + $this->assertCount(1, $result->getApplied()); + $this->assertTrue($result->isSuccessful()); + $this->assertGreaterThan(0, $result->getTotalTime()); + + $runner->dropSchemaTable(); + } catch (\Exception $ex) { + $this->markTestSkipped('Database connection failed: ' . $ex->getMessage()); + } + } + + public function testApplyTracksSkippedEnvironment() { + $runner = new SchemaRunner($this->getConnectionInfo(), 'production'); + $runner->register(DevOnlySeeder::class); + + try { + $runner->createSchemaTable(); + + $result = $runner->apply(); + + $this->assertEmpty($result->getApplied()); + $this->assertCount(1, $result->getSkipped()); + $this->assertEquals('Environment mismatch', $result->getSkipped()[0]['reason']); + + $runner->dropSchemaTable(); + } catch (\Exception $ex) { + $this->markTestSkipped('Database connection failed: ' . $ex->getMessage()); + } + } + + public function testApplyTracksAlreadyApplied() { + $runner = new SchemaRunner($this->getConnectionInfo()); + $runner->register(SuccessfulMigration::class); + + try { + $runner->createSchemaTable(); + + // First apply + $runner->apply(); + + // Second apply - should skip + $result = $runner->apply(); + + $this->assertEmpty($result->getApplied()); + $this->assertCount(1, $result->getSkipped()); + $this->assertEquals('Already applied', $result->getSkipped()[0]['reason']); + + $runner->dropSchemaTable(); + } catch (\Exception $ex) { + $this->markTestSkipped('Database connection failed: ' . $ex->getMessage()); + } + } + + public function testApplyTracksFailed() { + $runner = new SchemaRunner($this->getConnectionInfo()); + $runner->register(FailingMigration::class); + + try { + $runner->createSchemaTable(); + + $result = $runner->apply(); + + $this->assertEmpty($result->getApplied()); + $this->assertCount(1, $result->getFailed()); + $this->assertFalse($result->isSuccessful()); + $this->assertInstanceOf(\Throwable::class, $result->getFailed()[0]['error']); + + $runner->dropSchemaTable(); + } catch (\Exception $ex) { + $this->markTestSkipped('Database connection failed: ' . $ex->getMessage()); + } + } + + public function testBackwardCompatibilityCount() { + $runner = new SchemaRunner($this->getConnectionInfo()); + $runner->register(SuccessfulMigration::class); + + try { + $runner->createSchemaTable(); + + $result = $runner->apply(); + + // Old code: count($applied) still works + $this->assertEquals(1, count($result)); + + $runner->dropSchemaTable(); + } catch (\Exception $ex) { + $this->markTestSkipped('Database connection failed: ' . $ex->getMessage()); + } + } + + public function testBackwardCompatibilityForeach() { + $runner = new SchemaRunner($this->getConnectionInfo()); + $runner->register(SuccessfulMigration::class); + + try { + $runner->createSchemaTable(); + + $result = $runner->apply(); + + // Old code: foreach ($applied as $change) still works + $count = 0; + foreach ($result as $change) { + $this->assertInstanceOf(SuccessfulMigration::class, $change); + $count++; + } + $this->assertEquals(1, $count); + + $runner->dropSchemaTable(); + } catch (\Exception $ex) { + $this->markTestSkipped('Database connection failed: ' . $ex->getMessage()); + } + } +} diff --git a/tests/WebFiori/Tests/Database/Schema/DatabaseChangeResultTest.php b/tests/WebFiori/Tests/Database/Schema/DatabaseChangeResultTest.php new file mode 100644 index 00000000..a58f479c --- /dev/null +++ b/tests/WebFiori/Tests/Database/Schema/DatabaseChangeResultTest.php @@ -0,0 +1,100 @@ +assertEmpty($result->getApplied()); + $this->assertEmpty($result->getSkipped()); + $this->assertEmpty($result->getFailed()); + $this->assertEquals(0, $result->getTotalTime()); + $this->assertTrue($result->isSuccessful()); + $this->assertEquals(0, count($result)); + } + + public function testAddApplied() { + $result = new DatabaseChangeResult(); + $change = new SuccessfulMigration(); + + $result->addApplied($change); + + $this->assertCount(1, $result->getApplied()); + $this->assertSame($change, $result->getApplied()[0]); + $this->assertEquals(1, count($result)); + } + + public function testAddSkipped() { + $result = new DatabaseChangeResult(); + $change = new SuccessfulMigration(); + + $result->addSkipped($change, 'Already applied'); + + $this->assertCount(1, $result->getSkipped()); + $this->assertSame($change, $result->getSkipped()[0]['change']); + $this->assertEquals('Already applied', $result->getSkipped()[0]['reason']); + } + + public function testAddFailed() { + $result = new DatabaseChangeResult(); + $change = new SuccessfulMigration(); + $error = new \Exception('Test error'); + + $result->addFailed($change, $error); + + $this->assertCount(1, $result->getFailed()); + $this->assertSame($change, $result->getFailed()[0]['change']); + $this->assertSame($error, $result->getFailed()[0]['error']); + $this->assertFalse($result->isSuccessful()); + } + + public function testTotalTime() { + $result = new DatabaseChangeResult(); + + $result->setTotalTime(123.45); + + $this->assertEquals(123.45, $result->getTotalTime()); + } + + public function testCountableInterface() { + $result = new DatabaseChangeResult(); + $result->addApplied(new SuccessfulMigration()); + $result->addApplied(new DevOnlySeeder()); + + $this->assertEquals(2, count($result)); + } + + public function testIteratorInterface() { + $result = new DatabaseChangeResult(); + $m1 = new SuccessfulMigration(); + $m2 = new DevOnlySeeder(); + $result->addApplied($m1); + $result->addApplied($m2); + + $iterated = []; + foreach ($result as $change) { + $iterated[] = $change; + } + + $this->assertCount(2, $iterated); + $this->assertSame($m1, $iterated[0]); + $this->assertSame($m2, $iterated[1]); + } + + public function testIsSuccessfulWithMixedResults() { + $result = new DatabaseChangeResult(); + $result->addApplied(new SuccessfulMigration()); + $result->addSkipped(new DevOnlySeeder(), 'Environment'); + + $this->assertTrue($result->isSuccessful()); + + $result->addFailed(new FailingMigration(), new \Exception('Error')); + + $this->assertFalse($result->isSuccessful()); + } +} diff --git a/tests/WebFiori/Tests/Database/Schema/DevOnlySeeder.php b/tests/WebFiori/Tests/Database/Schema/DevOnlySeeder.php new file mode 100644 index 00000000..f1a5db87 --- /dev/null +++ b/tests/WebFiori/Tests/Database/Schema/DevOnlySeeder.php @@ -0,0 +1,13 @@ + Date: Sun, 4 Jan 2026 17:57:52 +0300 Subject: [PATCH 15/44] feat: Add Support for `DatabaseChangeGenerator` --- .../Schema/DatabaseChangeGenerator.php | 241 ++++++++++++++++++ WebFiori/Database/Schema/GeneratorOption.php | 34 +++ .../Schema/DatabaseChangeGeneratorTest.php | 204 +++++++++++++++ 3 files changed, 479 insertions(+) create mode 100644 WebFiori/Database/Schema/DatabaseChangeGenerator.php create mode 100644 WebFiori/Database/Schema/GeneratorOption.php create mode 100644 tests/WebFiori/Tests/Database/Schema/DatabaseChangeGeneratorTest.php diff --git a/WebFiori/Database/Schema/DatabaseChangeGenerator.php b/WebFiori/Database/Schema/DatabaseChangeGenerator.php new file mode 100644 index 00000000..7cd249ad --- /dev/null +++ b/WebFiori/Database/Schema/DatabaseChangeGenerator.php @@ -0,0 +1,241 @@ +path = rtrim($path, DIRECTORY_SEPARATOR); + return $this; + } + + /** + * Get the configured path. + */ + public function getPath(): string { + return $this->path; + } + + /** + * Set the namespace for generated classes. + * + * @param string $namespace The PHP namespace. + */ + public function setNamespace(string $namespace): self { + $this->namespace = trim($namespace, '\\'); + return $this; + } + + /** + * Get the configured namespace. + */ + public function getNamespace(): string { + return $this->namespace; + } + + /** + * Enable or disable timestamp prefix in filenames. + * + * When enabled, files are named like: 2026_01_04_175000_CreateUsersTable.php + * + * @param bool $use True to enable timestamp prefix. + */ + public function useTimestampPrefix(bool $use): self { + $this->useTimestamp = $use; + return $this; + } + + /** + * Check if timestamp prefix is enabled. + */ + public function isTimestampPrefixEnabled(): bool { + return $this->useTimestamp; + } + + /** + * Create a migration class file. + * + * @param string $name The class name (e.g., 'CreateUsersTable'). + * @param array $options Optional settings: + * - GeneratorOption::DEPENDENCIES: array of class names this migration depends on + * - GeneratorOption::TABLE: table name hint for comments + * @return string The full path to the created file. + */ + public function createMigration(string $name, array $options = []): string { + $dependencies = $options[GeneratorOption::DEPENDENCIES] ?? []; + $table = $options[GeneratorOption::TABLE] ?? null; + + $content = $this->buildMigrationContent($name, $dependencies, $table); + return $this->writeFile($name, $content); + } + + /** + * Create a seeder class file. + * + * @param string $name The class name (e.g., 'UsersSeeder'). + * @param array $options Optional settings: + * - GeneratorOption::ENVIRONMENTS: array of environments where seeder should run + * - GeneratorOption::DEPENDENCIES: array of class names this seeder depends on + * @return string The full path to the created file. + */ + public function createSeeder(string $name, array $options = []): string { + $environments = $options[GeneratorOption::ENVIRONMENTS] ?? []; + $dependencies = $options[GeneratorOption::DEPENDENCIES] ?? []; + + $content = $this->buildSeederContent($name, $environments, $dependencies); + return $this->writeFile($name, $content); + } + + private function buildMigrationContent(string $name, array $dependencies, ?string $table): string { + $lines = []; + $lines[] = 'namespace) { + $lines[] = 'namespace ' . $this->namespace . ';'; + $lines[] = ''; + } + + $lines[] = 'use WebFiori\Database\Schema\AbstractMigration;'; + $lines[] = 'use WebFiori\Database\Database;'; + $lines[] = ''; + $lines[] = "class {$name} extends AbstractMigration {"; + + // Add getDependencies if specified + if (!empty($dependencies)) { + $lines[] = ''; + $lines[] = ' public function getDependencies(): array {'; + $lines[] = ' return ['; + foreach ($dependencies as $dep) { + $lines[] = ' ' . $this->formatDependency($dep) . ','; + } + $lines[] = ' ];'; + $lines[] = ' }'; + } + + $lines[] = ''; + $lines[] = ' public function up(Database $db): void {'; + if ($table) { + $lines[] = " // TODO: Create or modify table '{$table}'"; + } else { + $lines[] = ' // TODO: Implement migration'; + } + $lines[] = ' }'; + $lines[] = ''; + $lines[] = ' public function down(Database $db): void {'; + if ($table) { + $lines[] = " // TODO: Reverse changes to table '{$table}'"; + } else { + $lines[] = ' // TODO: Reverse migration'; + } + $lines[] = ' }'; + $lines[] = '}'; + $lines[] = ''; + + return implode("\n", $lines); + } + + private function buildSeederContent(string $name, array $environments, array $dependencies): string { + $lines = []; + $lines[] = 'namespace) { + $lines[] = 'namespace ' . $this->namespace . ';'; + $lines[] = ''; + } + + $lines[] = 'use WebFiori\Database\Schema\AbstractSeeder;'; + $lines[] = 'use WebFiori\Database\Database;'; + $lines[] = ''; + $lines[] = "class {$name} extends AbstractSeeder {"; + + // Add getEnvironments if specified + if (!empty($environments)) { + $lines[] = ''; + $lines[] = ' public function getEnvironments(): array {'; + $lines[] = ' return [' . $this->formatStringArray($environments) . '];'; + $lines[] = ' }'; + } + + // Add getDependencies if specified + if (!empty($dependencies)) { + $lines[] = ''; + $lines[] = ' public function getDependencies(): array {'; + $lines[] = ' return ['; + foreach ($dependencies as $dep) { + $lines[] = ' ' . $this->formatDependency($dep) . ','; + } + $lines[] = ' ];'; + $lines[] = ' }'; + } + + $lines[] = ''; + $lines[] = ' public function run(Database $db): void {'; + $lines[] = ' // TODO: Implement seeder'; + $lines[] = ' }'; + $lines[] = '}'; + $lines[] = ''; + + return implode("\n", $lines); + } + + private function formatDependency(string $dep): string { + // If it looks like a fully qualified class name, use ::class syntax + if (str_contains($dep, '\\')) { + return $dep . '::class'; + } + // If it's a simple class name, also use ::class syntax + if (preg_match('/^[A-Z][a-zA-Z0-9_]*$/', $dep)) { + return $dep . '::class'; + } + // Otherwise treat as string + return "'" . $dep . "'"; + } + + private function formatStringArray(array $items): string { + $formatted = array_map(fn($item) => "'{$item}'", $items); + return implode(', ', $formatted); + } + + private function writeFile(string $name, string $content): string { + if (empty($this->path)) { + throw new \RuntimeException('Path not set. Call setPath() first.'); + } + + if (!is_dir($this->path)) { + mkdir($this->path, 0755, true); + } + + $filename = $this->useTimestamp + ? date('Y_m_d_His') . '_' . $name . '.php' + : $name . '.php'; + + $fullPath = $this->path . DIRECTORY_SEPARATOR . $filename; + file_put_contents($fullPath, $content); + + return $fullPath; + } +} diff --git a/WebFiori/Database/Schema/GeneratorOption.php b/WebFiori/Database/Schema/GeneratorOption.php new file mode 100644 index 00000000..2961f080 --- /dev/null +++ b/WebFiori/Database/Schema/GeneratorOption.php @@ -0,0 +1,34 @@ +tempDir = sys_get_temp_dir() . '/db_generator_test_' . uniqid(); + mkdir($this->tempDir, 0755, true); + } + + protected function tearDown(): void { + $this->removeDirectory($this->tempDir); + } + + private function removeDirectory(string $dir): void { + if (!is_dir($dir)) { + return; + } + $files = new \RecursiveIteratorIterator( + new \RecursiveDirectoryIterator($dir, \RecursiveDirectoryIterator::SKIP_DOTS), + \RecursiveIteratorIterator::CHILD_FIRST + ); + foreach ($files as $file) { + $file->isDir() ? rmdir($file->getPathname()) : unlink($file->getPathname()); + } + rmdir($dir); + } + + public function testSetPath() { + $generator = new DatabaseChangeGenerator(); + $generator->setPath('/some/path'); + + $this->assertEquals('/some/path', $generator->getPath()); + } + + public function testSetNamespace() { + $generator = new DatabaseChangeGenerator(); + $generator->setNamespace('App\\Migrations'); + + $this->assertEquals('App\\Migrations', $generator->getNamespace()); + } + + public function testSetNamespaceTrimsSlashes() { + $generator = new DatabaseChangeGenerator(); + $generator->setNamespace('\\App\\Migrations\\'); + + $this->assertEquals('App\\Migrations', $generator->getNamespace()); + } + + public function testUseTimestampPrefix() { + $generator = new DatabaseChangeGenerator(); + + $this->assertFalse($generator->isTimestampPrefixEnabled()); + + $generator->useTimestampPrefix(true); + $this->assertTrue($generator->isTimestampPrefixEnabled()); + } + + public function testCreateMigrationBasic() { + $generator = new DatabaseChangeGenerator(); + $generator->setPath($this->tempDir); + $generator->setNamespace('App\\Migrations'); + + $path = $generator->createMigration('CreateUsersTable'); + + $this->assertFileExists($path); + $this->assertStringEndsWith('CreateUsersTable.php', $path); + + $content = file_get_contents($path); + $this->assertStringContainsString('namespace App\\Migrations;', $content); + $this->assertStringContainsString('use WebFiori\Database\Schema\AbstractMigration;', $content); + $this->assertStringContainsString('class CreateUsersTable extends AbstractMigration', $content); + $this->assertStringContainsString('public function up(Database $db): void', $content); + $this->assertStringContainsString('public function down(Database $db): void', $content); + } + + public function testCreateMigrationWithTimestamp() { + $generator = new DatabaseChangeGenerator(); + $generator->setPath($this->tempDir); + $generator->useTimestampPrefix(true); + + $path = $generator->createMigration('CreateUsersTable'); + + $this->assertFileExists($path); + $this->assertMatchesRegularExpression('/\d{4}_\d{2}_\d{2}_\d{6}_CreateUsersTable\.php$/', $path); + } + + public function testCreateMigrationWithDependencies() { + $generator = new DatabaseChangeGenerator(); + $generator->setPath($this->tempDir); + + $path = $generator->createMigration('AddPostsTable', [ + GeneratorOption::DEPENDENCIES => ['CreateUsersTable', 'App\\Migrations\\CreateCategoriesTable'] + ]); + + $content = file_get_contents($path); + $this->assertStringContainsString('public function getDependencies(): array', $content); + $this->assertStringContainsString('CreateUsersTable::class', $content); + $this->assertStringContainsString('App\\Migrations\\CreateCategoriesTable::class', $content); + } + + public function testCreateMigrationWithTableHint() { + $generator = new DatabaseChangeGenerator(); + $generator->setPath($this->tempDir); + + $path = $generator->createMigration('CreateUsersTable', [ + GeneratorOption::TABLE => 'users' + ]); + + $content = file_get_contents($path); + $this->assertStringContainsString("table 'users'", $content); + } + + public function testCreateSeederBasic() { + $generator = new DatabaseChangeGenerator(); + $generator->setPath($this->tempDir); + $generator->setNamespace('App\\Seeders'); + + $path = $generator->createSeeder('UsersSeeder'); + + $this->assertFileExists($path); + $this->assertStringEndsWith('UsersSeeder.php', $path); + + $content = file_get_contents($path); + $this->assertStringContainsString('namespace App\\Seeders;', $content); + $this->assertStringContainsString('use WebFiori\Database\Schema\AbstractSeeder;', $content); + $this->assertStringContainsString('class UsersSeeder extends AbstractSeeder', $content); + $this->assertStringContainsString('public function run(Database $db): void', $content); + } + + public function testCreateSeederWithEnvironments() { + $generator = new DatabaseChangeGenerator(); + $generator->setPath($this->tempDir); + + $path = $generator->createSeeder('TestDataSeeder', [ + GeneratorOption::ENVIRONMENTS => ['dev', 'test'] + ]); + + $content = file_get_contents($path); + $this->assertStringContainsString('public function getEnvironments(): array', $content); + $this->assertStringContainsString("'dev'", $content); + $this->assertStringContainsString("'test'", $content); + } + + public function testCreateSeederWithDependencies() { + $generator = new DatabaseChangeGenerator(); + $generator->setPath($this->tempDir); + + $path = $generator->createSeeder('PostsSeeder', [ + GeneratorOption::DEPENDENCIES => ['UsersSeeder'] + ]); + + $content = file_get_contents($path); + $this->assertStringContainsString('public function getDependencies(): array', $content); + $this->assertStringContainsString('UsersSeeder::class', $content); + } + + public function testCreateWithoutNamespace() { + $generator = new DatabaseChangeGenerator(); + $generator->setPath($this->tempDir); + + $path = $generator->createMigration('CreateUsersTable'); + + $content = file_get_contents($path); + $this->assertStringNotContainsString('namespace', $content); + } + + public function testCreateWithoutPathThrows() { + $generator = new DatabaseChangeGenerator(); + + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('Path not set'); + + $generator->createMigration('CreateUsersTable'); + } + + public function testCreatesDirectoryIfNotExists() { + $generator = new DatabaseChangeGenerator(); + $newDir = $this->tempDir . '/nested/path'; + $generator->setPath($newDir); + + $path = $generator->createMigration('CreateUsersTable'); + + $this->assertFileExists($path); + $this->assertDirectoryExists($newDir); + } + + public function testFluentInterface() { + $generator = new DatabaseChangeGenerator(); + + $result = $generator + ->setPath($this->tempDir) + ->setNamespace('App\\Migrations') + ->useTimestampPrefix(true); + + $this->assertSame($generator, $result); + } +} From 302f3efc62365df99ebc3480d0be436aa54cd6e5 Mon Sep 17 00:00:00 2001 From: Ibrahim BinAlshikh Date: Sun, 4 Jan 2026 18:10:41 +0300 Subject: [PATCH 16/44] refactor: Removal of `setDatabase/getDatabase` --- WebFiori/Database/Schema/DatabaseChange.php | 24 - WebFiori/Database/Schema/SchemaRunner.php | 3 +- .../Database/Schema/SchemaValidationTest.php | 464 +++++++++--------- 3 files changed, 234 insertions(+), 257 deletions(-) diff --git a/WebFiori/Database/Schema/DatabaseChange.php b/WebFiori/Database/Schema/DatabaseChange.php index 1694d68b..d2ffeb53 100644 --- a/WebFiori/Database/Schema/DatabaseChange.php +++ b/WebFiori/Database/Schema/DatabaseChange.php @@ -32,7 +32,6 @@ abstract class DatabaseChange { private $appliedAt; private $id; private int $batch = 0; - private ?Database $database = null; /** * Initialize a new database change with optional name and order. @@ -40,11 +39,6 @@ abstract class DatabaseChange { public function __construct() { $this->setAppliedAt(date('Y-m-d H:i:s')); } - /** - * Execute the database change. - * - * @param Database $db The database instance to execute against. - */ /** * Execute the database change (apply the migration or seeder). * @@ -151,22 +145,4 @@ public function getBatch(): int { public function setBatch(int $batch): void { $this->batch = $batch; } - - /** - * Set the database instance for this change. - * - * @param Database $db The database instance to use for this change. - */ - public function setDatabase(Database $db): void { - $this->database = $db; - } - - /** - * Get the database instance for this change. - * - * @return Database|null The database instance or null if not set. - */ - public function getDatabase(): ?Database { - return $this->database; - } } diff --git a/WebFiori/Database/Schema/SchemaRunner.php b/WebFiori/Database/Schema/SchemaRunner.php index 75d1d9fb..9a61e51d 100644 --- a/WebFiori/Database/Schema/SchemaRunner.php +++ b/WebFiori/Database/Schema/SchemaRunner.php @@ -444,8 +444,7 @@ private function areDependenciesSatisfied(DatabaseChange $change): bool { } private function attemptRoolback(DatabaseChange $change, &$rolled) : bool { try { - $migrationDb = $change->getDatabase() ?? $this; - $change->rollback($migrationDb); + $change->rollback($this); $this->repository->removeChange($change->getName()); $rolled[] = $change; diff --git a/tests/WebFiori/Tests/Database/Schema/SchemaValidationTest.php b/tests/WebFiori/Tests/Database/Schema/SchemaValidationTest.php index ea38390f..69c3ee32 100644 --- a/tests/WebFiori/Tests/Database/Schema/SchemaValidationTest.php +++ b/tests/WebFiori/Tests/Database/Schema/SchemaValidationTest.php @@ -1,231 +1,233 @@ -getConnectionInfo()); - $changes = $runner->getChanges(); - - // Should ignore non-DatabaseChange classes - $this->assertEmpty($changes); - - // Cleanup - unlink($tempDir . '/NotAMigration.php'); - rmdir($tempDir); - } - - public function testAbstractClassInstantiationError() { - $tempDir = sys_get_temp_dir() . '/schema_test_' . uniqid(); - mkdir($tempDir, 0777, true); - - // Create abstract migration class - file_put_contents($tempDir . '/AbstractTestMigration.php', 'getConnectionInfo()); - $runner->addOnRegisterErrorCallback(function($err) use (&$errorCaught) { - $errorCaught = true; - }); - - $changes = $runner->getChanges(); - - // Should handle abstract class error - $this->assertEmpty($changes); - - // Cleanup - unlink($tempDir . '/AbstractTestMigration.php'); - rmdir($tempDir); - } - - public function testIncompleteClassImplementation() { - $tempDir = sys_get_temp_dir() . '/schema_test_' . uniqid(); - mkdir($tempDir, 0777, true); - - // Create migration missing required methods - file_put_contents($tempDir . '/IncompleteMigration.php', 'getConnectionInfo()); - $runner->addOnRegisterErrorCallback(function($err) use (&$errorCaught) { - $errorCaught = true; - }); - - try { - $runner->createSchemaTable(); - $applied = $runner->apply(); - - // Should handle incomplete implementation - $this->assertIsArray($applied); - } catch (DatabaseException $ex) { - $this->markTestSkipped('Database connection failed: ' . $ex->getMessage()); - } - - // Cleanup - unlink($tempDir . '/IncompleteMigration.php'); - rmdir($tempDir); - } - - public function testReturnTypeInconsistencies() { - // Test registration handles type validation properly - $runner = new SchemaRunner($this->getConnectionInfo()); - $runner->register(TestMigration::class); - - $changes = $runner->getChanges(); - $this->assertCount(1, $changes); - $this->assertInstanceOf('WebFiori\\Database\\Schema\\DatabaseChange', $changes[0]); - } - - public function testInterfaceValidationMissing() { - $runner = new SchemaRunner($this->getConnectionInfo()); - - // Register the expected number of migrations - for ($i = 0; $i < 1; $i++) { - $runner->register(TestMigration::class); - } - - $changes = $runner->getChanges(); - $this->assertCount(1, $changes); - } - - // Performance and Scalability Issues - public function testMemoryUsageWithManyMigrations() { - $runner = new SchemaRunner($this->getConnectionInfo()); - - // Register the expected number of migrations - for ($i = 0; $i < 50; $i++) { - $runner->register(TestMigration::class); - } - - $changes = $runner->getChanges(); - $this->assertCount(50, $changes); - } - - public function testRepeatedDirectoryScanningOverhead() { - $runner = new SchemaRunner($this->getConnectionInfo()); - - // Register the expected number of migrations - for ($i = 0; $i < 1; $i++) { - $runner->register(TestMigration::class); - } - - $changes = $runner->getChanges(); - $this->assertCount(1, $changes); - } - - public function testTopologicalSortPerformance() { - $runner = new SchemaRunner($this->getConnectionInfo()); - - // Register the expected number of migrations - for ($i = 0; $i < 20; $i++) { - $runner->register(TestMigration::class); - } - - $changes = $runner->getChanges(); - $this->assertCount(20, $changes); - } - - // File System Edge Cases - public function testEmptyFileHandling() { - $tempDir = sys_get_temp_dir() . '/schema_test_' . uniqid(); - mkdir($tempDir, 0777, true); - - // Create empty PHP file - file_put_contents($tempDir . '/EmptyFile.php', ''); - - $errorCaught = false; - $runner = new SchemaRunner($this->getConnectionInfo()); - $runner->addOnRegisterErrorCallback(function($err) use (&$errorCaught) { - $errorCaught = true; - }); - - $changes = $runner->getChanges(); - - // Should handle empty file gracefully - $this->assertEmpty($changes); - - // Cleanup - unlink($tempDir . '/EmptyFile.php'); - rmdir($tempDir); - } - - public function testInvalidPhpSyntaxHandling() { - $tempDir = sys_get_temp_dir() . '/schema_test_' . uniqid(); - mkdir($tempDir, 0777, true); - - // Create file with invalid PHP syntax - file_put_contents($tempDir . '/InvalidSyntax.php', 'getConnectionInfo()); - $runner->addOnRegisterErrorCallback(function($err) use (&$errorCaught) { - $errorCaught = true; - }); - - $changes = $runner->getChanges(); - - // Should handle syntax errors gracefully - $this->assertEmpty($changes); - - // Cleanup - unlink($tempDir . '/InvalidSyntax.php'); - rmdir($tempDir); - } - - public function testNonPhpFileIgnored() { - $tempDir = sys_get_temp_dir() . '/schema_test_' . uniqid(); - mkdir($tempDir, 0777, true); - - // Create non-PHP files - file_put_contents($tempDir . '/README.txt', 'This is not a PHP file'); - file_put_contents($tempDir . '/config.json', '{"key": "value"}'); - - $runner = new SchemaRunner($this->getConnectionInfo()); - $changes = $runner->getChanges(); - - // Should ignore non-PHP files - $this->assertEmpty($changes); - - // Cleanup - unlink($tempDir . '/README.txt'); - unlink($tempDir . '/config.json'); - rmdir($tempDir); - } -} +getConnectionInfo()); + $changes = $runner->getChanges(); + + // Should ignore non-DatabaseChange classes + $this->assertEmpty($changes); + + // Cleanup + unlink($tempDir . '/NotAMigration.php'); + rmdir($tempDir); + } + + public function testAbstractClassInstantiationError() { + $tempDir = sys_get_temp_dir() . '/schema_test_' . uniqid(); + mkdir($tempDir, 0777, true); + + // Create abstract migration class + file_put_contents($tempDir . '/AbstractTestMigration.php', 'getConnectionInfo()); + $runner->addOnRegisterErrorCallback(function($err) use (&$errorCaught) { + $errorCaught = true; + }); + + $changes = $runner->getChanges(); + + // Should handle abstract class error + $this->assertEmpty($changes); + + // Cleanup + unlink($tempDir . '/AbstractTestMigration.php'); + rmdir($tempDir); + } + + public function testIncompleteClassImplementation() { + $tempDir = sys_get_temp_dir() . '/schema_test_' . uniqid(); + mkdir($tempDir, 0777, true); + + // Create migration missing required methods + file_put_contents($tempDir . '/IncompleteMigration.php', 'getConnectionInfo()); + $runner->addOnRegisterErrorCallback(function($err) use (&$errorCaught) { + $errorCaught = true; + }); + + try { + $runner->createSchemaTable(); + $applied = $runner->apply(); + + // Should handle incomplete implementation + $this->assertIsArray($applied); + } catch (DatabaseException $ex) { + $this->markTestSkipped('Database connection failed: ' . $ex->getMessage()); + } + + // Cleanup + unlink($tempDir . '/IncompleteMigration.php'); + rmdir($tempDir); + } + + public function testReturnTypeInconsistencies() { + // Test registration handles type validation properly + $runner = new SchemaRunner($this->getConnectionInfo()); + $runner->register(TestMigration::class); + + $changes = $runner->getChanges(); + $this->assertCount(1, $changes); + $this->assertInstanceOf('WebFiori\\Database\\Schema\\DatabaseChange', $changes[0]); + } + + public function testInterfaceValidationMissing() { + $runner = new SchemaRunner($this->getConnectionInfo()); + + // Register the expected number of migrations + for ($i = 0; $i < 1; $i++) { + $runner->register(TestMigration::class); + } + + $changes = $runner->getChanges(); + $this->assertCount(1, $changes); + } + + // Performance and Scalability Issues + public function testMemoryUsageWithManyMigrations() { + $runner = new SchemaRunner($this->getConnectionInfo()); + + // Register the same class multiple times - duplicates are now prevented + for ($i = 0; $i < 50; $i++) { + $runner->register(TestMigration::class); + } + + // Only 1 should be registered due to duplicate prevention + $changes = $runner->getChanges(); + $this->assertCount(1, $changes); + } + + public function testRepeatedDirectoryScanningOverhead() { + $runner = new SchemaRunner($this->getConnectionInfo()); + + // Register the expected number of migrations + for ($i = 0; $i < 1; $i++) { + $runner->register(TestMigration::class); + } + + $changes = $runner->getChanges(); + $this->assertCount(1, $changes); + } + + public function testTopologicalSortPerformance() { + $runner = new SchemaRunner($this->getConnectionInfo()); + + // Register the same class multiple times - duplicates are now prevented + for ($i = 0; $i < 20; $i++) { + $runner->register(TestMigration::class); + } + + // Only 1 should be registered due to duplicate prevention + $changes = $runner->getChanges(); + $this->assertCount(1, $changes); + } + + // File System Edge Cases + public function testEmptyFileHandling() { + $tempDir = sys_get_temp_dir() . '/schema_test_' . uniqid(); + mkdir($tempDir, 0777, true); + + // Create empty PHP file + file_put_contents($tempDir . '/EmptyFile.php', ''); + + $errorCaught = false; + $runner = new SchemaRunner($this->getConnectionInfo()); + $runner->addOnRegisterErrorCallback(function($err) use (&$errorCaught) { + $errorCaught = true; + }); + + $changes = $runner->getChanges(); + + // Should handle empty file gracefully + $this->assertEmpty($changes); + + // Cleanup + unlink($tempDir . '/EmptyFile.php'); + rmdir($tempDir); + } + + public function testInvalidPhpSyntaxHandling() { + $tempDir = sys_get_temp_dir() . '/schema_test_' . uniqid(); + mkdir($tempDir, 0777, true); + + // Create file with invalid PHP syntax + file_put_contents($tempDir . '/InvalidSyntax.php', 'getConnectionInfo()); + $runner->addOnRegisterErrorCallback(function($err) use (&$errorCaught) { + $errorCaught = true; + }); + + $changes = $runner->getChanges(); + + // Should handle syntax errors gracefully + $this->assertEmpty($changes); + + // Cleanup + unlink($tempDir . '/InvalidSyntax.php'); + rmdir($tempDir); + } + + public function testNonPhpFileIgnored() { + $tempDir = sys_get_temp_dir() . '/schema_test_' . uniqid(); + mkdir($tempDir, 0777, true); + + // Create non-PHP files + file_put_contents($tempDir . '/README.txt', 'This is not a PHP file'); + file_put_contents($tempDir . '/config.json', '{"key": "value"}'); + + $runner = new SchemaRunner($this->getConnectionInfo()); + $changes = $runner->getChanges(); + + // Should ignore non-PHP files + $this->assertEmpty($changes); + + // Cleanup + unlink($tempDir . '/README.txt'); + unlink($tempDir . '/config.json'); + rmdir($tempDir); + } +} From dca1048d0281185e71eb48e70c7c3a3dd149f811 Mon Sep 17 00:00:00 2001 From: Ibrahim BinAlshikh Date: Sun, 4 Jan 2026 18:13:31 +0300 Subject: [PATCH 17/44] feat: Add Support for Getting Connection Info Under Change --- .../Database/Schema/DatabaseChangeResult.php | 27 +++++++++++++++++++ WebFiori/Database/Schema/SchemaRunner.php | 1 + .../Schema/DatabaseChangeResultTest.php | 13 +++++++++ 3 files changed, 41 insertions(+) diff --git a/WebFiori/Database/Schema/DatabaseChangeResult.php b/WebFiori/Database/Schema/DatabaseChangeResult.php index 60aab003..56f6ff59 100644 --- a/WebFiori/Database/Schema/DatabaseChangeResult.php +++ b/WebFiori/Database/Schema/DatabaseChangeResult.php @@ -14,6 +14,7 @@ use IteratorAggregate; use ArrayIterator; use Traversable; +use WebFiori\Database\ConnectionInfo; /** * Result of applying database changes (migrations and seeders). @@ -43,6 +44,11 @@ class DatabaseChangeResult implements Countable, IteratorAggregate { */ private float $totalTimeMs = 0; + /** + * @var ConnectionInfo|null Connection info for the database changes were applied to + */ + private ?ConnectionInfo $connectionInfo = null; + /** * Add an applied change. */ @@ -112,6 +118,27 @@ public function isSuccessful(): bool { return empty($this->failed); } + /** + * Set the connection info for the database changes were applied to. + */ + public function setConnectionInfo(ConnectionInfo $connectionInfo): void { + $this->connectionInfo = $connectionInfo; + } + + /** + * Get the connection info for the database changes were applied to. + */ + public function getConnectionInfo(): ?ConnectionInfo { + return $this->connectionInfo; + } + + /** + * Get the database name changes were applied to. + */ + public function getDatabaseName(): ?string { + return $this->connectionInfo?->getDBName(); + } + /** * Get count of applied changes (Countable interface). */ diff --git a/WebFiori/Database/Schema/SchemaRunner.php b/WebFiori/Database/Schema/SchemaRunner.php index 9a61e51d..65c8f506 100644 --- a/WebFiori/Database/Schema/SchemaRunner.php +++ b/WebFiori/Database/Schema/SchemaRunner.php @@ -125,6 +125,7 @@ public function addOnRegisterErrorCallback(callable $callback): void { */ public function apply(): DatabaseChangeResult { $result = new DatabaseChangeResult(); + $result->setConnectionInfo($this->getConnectionInfo()); $batch = $this->getRepository()->getNextBatchNumber(); $startTime = microtime(true); diff --git a/tests/WebFiori/Tests/Database/Schema/DatabaseChangeResultTest.php b/tests/WebFiori/Tests/Database/Schema/DatabaseChangeResultTest.php index a58f479c..d279582c 100644 --- a/tests/WebFiori/Tests/Database/Schema/DatabaseChangeResultTest.php +++ b/tests/WebFiori/Tests/Database/Schema/DatabaseChangeResultTest.php @@ -97,4 +97,17 @@ public function testIsSuccessfulWithMixedResults() { $this->assertFalse($result->isSuccessful()); } + + public function testConnectionInfo() { + $result = new DatabaseChangeResult(); + + $this->assertNull($result->getConnectionInfo()); + $this->assertNull($result->getDatabaseName()); + + $connInfo = new \WebFiori\Database\ConnectionInfo('mysql', 'root', '123456', 'test_db'); + $result->setConnectionInfo($connInfo); + + $this->assertSame($connInfo, $result->getConnectionInfo()); + $this->assertEquals('test_db', $result->getDatabaseName()); + } } From 145ad9c4f8a85ab99bdb4524cdd1c83b96a231a8 Mon Sep 17 00:00:00 2001 From: Ibrahim BinAlshikh Date: Sun, 4 Jan 2026 18:39:44 +0300 Subject: [PATCH 18/44] feat: Wrap Transitions in Changes --- .../Database/Schema/AbstractMigration.php | 200 ++++++++---------- WebFiori/Database/Schema/AbstractSeeder.php | 179 +++++++--------- WebFiori/Database/Schema/DatabaseChange.php | 36 ++++ WebFiori/Database/Schema/SchemaRunner.php | 22 +- .../Schema/TransactionWrapperTest.php | 123 +++++++++++ 5 files changed, 354 insertions(+), 206 deletions(-) create mode 100644 tests/WebFiori/Tests/Database/Schema/TransactionWrapperTest.php diff --git a/WebFiori/Database/Schema/AbstractMigration.php b/WebFiori/Database/Schema/AbstractMigration.php index 2921230a..d185e1b5 100644 --- a/WebFiori/Database/Schema/AbstractMigration.php +++ b/WebFiori/Database/Schema/AbstractMigration.php @@ -1,107 +1,93 @@ -up($db); - } - - /** - * Get the environments where this migration should be executed. - * - * By default, migrations run in all environments (dev, test, prod). - * Override this method to restrict execution to specific environments. - * For example, return ['dev'] to only run in development. - * Migrations run in all environments by default. - * - * @return array Empty array means all environments. - */ - public function getEnvironments(): array { - return []; - } - - /** - * Get the type identifier for this database change. - * - * This method is used by the SchemaRunner to categorize and track - * different types of database changes. Migrations are distinguished - * from seeders by this type identifier. - * - * @return string Always returns 'migration'. - */ - public function getType(): string { - return 'migration'; - } - - /** - * Rollback the database change (undo the migration). - * - * This method contains the logic to rollback the database change by calling - * the down() method implemented by concrete migration classes. - * - * @param Database $db The database instance to execute rollback on. - */ - public function rollback(Database $db): void { - $this->down($db); - } - - /** - * Apply the migration changes to the database. - * - * This method should contain the forward migration logic such as: - * - Creating tables, columns, indexes - * - Modifying existing schema elements - * - Adding constraints and relationships - * - * @param Database $db The database instance to execute changes on. - */ - abstract public function up(Database $db): void; -} +up($db); + } + + /** + * Get the type identifier for this database change. + * + * This method is used by the SchemaRunner to categorize and track + * different types of database changes. Migrations are distinguished + * from seeders by this type identifier. + * + * @return string Always returns 'migration'. + */ + public function getType(): string { + return 'migration'; + } + + /** + * Rollback the database change (undo the migration). + * + * This method contains the logic to rollback the database change by calling + * the down() method implemented by concrete migration classes. + * + * @param Database $db The database instance to execute rollback on. + */ + public function rollback(Database $db): void { + $this->down($db); + } + + /** + * Apply the migration changes to the database. + * + * This method should contain the forward migration logic such as: + * - Creating tables, columns, indexes + * - Modifying existing schema elements + * - Adding constraints and relationships + * + * @param Database $db The database instance to execute changes on. + */ + abstract public function up(Database $db): void; +} diff --git a/WebFiori/Database/Schema/AbstractSeeder.php b/WebFiori/Database/Schema/AbstractSeeder.php index 5c5e68ff..6377554d 100644 --- a/WebFiori/Database/Schema/AbstractSeeder.php +++ b/WebFiori/Database/Schema/AbstractSeeder.php @@ -1,97 +1,82 @@ -run($db); - } - - /** - * Get the environments where this seeder should be executed. - * - * Seeders often need environment-specific behavior: - * - Production seeders: essential reference data only - * - Development seeders: sample data for testing - * - Test seeders: specific test fixtures - * Override this method to control execution environments. - * - * @return array Array of environment names. Empty array means all environments. - */ - public function getEnvironments(): array { - return []; - } - - /** - * Get the type identifier for this database change. - * - * This method is used by the SchemaRunner to categorize and track - * different types of database changes. Seeders are distinguished - * from migrations by this type identifier. - * - * @return string Always returns 'seeder'. - */ - public function getType(): string { - return 'seeder'; - } - - /** - * Rollback the database change (optional for seeders). - * - * Most seeders don't implement rollback functionality as data - * seeding is typically not reversible. Override this method - * if your seeder needs rollback capability. - * - * @param Database $db The database instance to execute rollback on. - */ - public function rollback(Database $db): void { - // Default implementation does nothing - // Override in concrete seeders if rollback is needed - } - - /** - * Run the seeder to populate the database with data. - * - * This method should contain the data insertion logic such as: - * - Inserting reference/lookup data - * - Creating default user accounts - * - Populating sample data for development - * - Setting up initial application configuration - * - * @param Database $db The database instance to execute seeding on. - */ - abstract public function run(Database $db): void; -} +run($db); + } + + /** + * Get the type identifier for this database change. + * + * This method is used by the SchemaRunner to categorize and track + * different types of database changes. Seeders are distinguished + * from migrations by this type identifier. + * + * @return string Always returns 'seeder'. + */ + public function getType(): string { + return 'seeder'; + } + + /** + * Rollback the database change (optional for seeders). + * + * Most seeders don't implement rollback functionality as data + * seeding is typically not reversible. Override this method + * if your seeder needs rollback capability. + * + * @param Database $db The database instance to execute rollback on. + */ + public function rollback(Database $db): void { + // Default implementation does nothing + // Override in concrete seeders if rollback is needed + } + + /** + * Run the seeder to populate the database with data. + * + * This method should contain the data insertion logic such as: + * - Inserting reference/lookup data + * - Creating default user accounts + * - Populating sample data for development + * - Setting up initial application configuration + * + * @param Database $db The database instance to execute seeding on. + */ + abstract public function run(Database $db): void; +} diff --git a/WebFiori/Database/Schema/DatabaseChange.php b/WebFiori/Database/Schema/DatabaseChange.php index d2ffeb53..f29b8897 100644 --- a/WebFiori/Database/Schema/DatabaseChange.php +++ b/WebFiori/Database/Schema/DatabaseChange.php @@ -71,6 +71,42 @@ public function getDependencies(): array { return []; } + /** + * Get the environments where this change should be executed. + * + * Override this method to restrict execution to specific environments. + * + * @return array Array of environment names. Empty array means all environments. + */ + public function getEnvironments(): array { + return []; + } + + /** + * Determine if this change should be wrapped in a database transaction. + * + * Override this method to control transaction behavior. By default, + * changes are wrapped in transactions for safety. + * + * Guidelines: + * - Return true for DML operations (INSERT, UPDATE, DELETE) - always safe + * - Return true for DDL on MSSQL/PostgreSQL - they support transactional DDL + * - Return false for DDL on MySQL - it auto-commits and can't be rolled back + * + * For DBMS-aware behavior, override and check the database type: + * ```php + * public function useTransaction(Database $db): bool { + * return $db->getConnectionInfo()->getDatabaseType() !== 'mysql'; + * } + * ``` + * + * @param Database $db The database instance (for DBMS-aware decisions). + * @return bool True to wrap in transaction, false to execute directly. + */ + public function useTransaction(Database $db): bool { + return true; + } + /** * Get the unique identifier for this database change. * diff --git a/WebFiori/Database/Schema/SchemaRunner.php b/WebFiori/Database/Schema/SchemaRunner.php index 65c8f506..63679550 100644 --- a/WebFiori/Database/Schema/SchemaRunner.php +++ b/WebFiori/Database/Schema/SchemaRunner.php @@ -162,7 +162,7 @@ public function apply(): DatabaseChangeResult { } try { - $change->execute($this); + $this->executeChange($change); $change->setBatch($batch); $this->getRepository()->recordChange($change); $result->addApplied($change); @@ -215,7 +215,7 @@ public function applyOne(): ?DatabaseChange { } try { - $change->execute($this); + $this->executeChange($change); $change->setBatch($batch); $this->getRepository()->recordChange($change); } catch (\Throwable $ex) { @@ -235,6 +235,24 @@ public function applyOne(): ?DatabaseChange { return null; } + /** + * Execute a database change, optionally wrapped in a transaction. + * + * This method checks the change's useTransaction() method to determine + * whether to wrap the execution in a database transaction. + * + * @param DatabaseChange $change The change to execute. + */ + protected function executeChange(DatabaseChange $change): void { + if ($change->useTransaction($this)) { + $this->transaction(function (Database $db) use ($change) { + $change->execute($db); + }); + } else { + $change->execute($this); + } + } + /** * Remove all registered execution error callbacks. */ diff --git a/tests/WebFiori/Tests/Database/Schema/TransactionWrapperTest.php b/tests/WebFiori/Tests/Database/Schema/TransactionWrapperTest.php new file mode 100644 index 00000000..a3f8c358 --- /dev/null +++ b/tests/WebFiori/Tests/Database/Schema/TransactionWrapperTest.php @@ -0,0 +1,123 @@ +getConnectionInfo()->getDatabaseType(); + return self::$detectedDbType !== 'mysql'; + } +} + +class TransactionWrapperTest extends TestCase { + + protected function setUp(): void { + TransactionEnabledMigration::$executed = false; + TransactionDisabledMigration::$executed = false; + DbmsAwareMigration::$executed = false; + DbmsAwareMigration::$detectedDbType = null; + } + + private function getConnectionInfo(): ConnectionInfo { + return new ConnectionInfo('mysql', 'root', getenv('MYSQL_ROOT_PASSWORD') ?: '123456', 'testing_db', '127.0.0.1'); + } + + public function testUseTransactionDefaultsToTrue() { + $migration = new TransactionEnabledMigration(); + $db = new Database($this->getConnectionInfo()); + + $this->assertTrue($migration->useTransaction($db)); + } + + public function testUseTransactionCanBeDisabled() { + $migration = new TransactionDisabledMigration(); + $db = new Database($this->getConnectionInfo()); + + $this->assertFalse($migration->useTransaction($db)); + } + + public function testDbmsAwareTransaction() { + $migration = new DbmsAwareMigration(); + + $mysqlDb = new Database(new ConnectionInfo('mysql', 'root', '123456', 'test')); + $this->assertFalse($migration->useTransaction($mysqlDb)); + $this->assertEquals('mysql', DbmsAwareMigration::$detectedDbType); + + $mssqlDb = new Database(new ConnectionInfo('mssql', 'sa', '123456', 'test')); + $this->assertTrue($migration->useTransaction($mssqlDb)); + $this->assertEquals('mssql', DbmsAwareMigration::$detectedDbType); + } + + public function testApplyUsesTransactionWhenEnabled() { + $runner = new SchemaRunner($this->getConnectionInfo()); + $runner->register(TransactionEnabledMigration::class); + + try { + $runner->createSchemaTable(); + $result = $runner->apply(); + + $this->assertTrue(TransactionEnabledMigration::$executed); + $this->assertCount(1, $result->getApplied()); + + $runner->dropSchemaTable(); + } catch (\Exception $ex) { + $this->markTestSkipped('Database connection failed: ' . $ex->getMessage()); + } + } + + public function testApplySkipsTransactionWhenDisabled() { + $runner = new SchemaRunner($this->getConnectionInfo()); + $runner->register(TransactionDisabledMigration::class); + + try { + $runner->createSchemaTable(); + $result = $runner->apply(); + + $this->assertTrue(TransactionDisabledMigration::$executed); + $this->assertCount(1, $result->getApplied()); + + $runner->dropSchemaTable(); + } catch (\Exception $ex) { + $this->markTestSkipped('Database connection failed: ' . $ex->getMessage()); + } + } +} From 9ab0c9e07c3ab016afcc6d16e037e24bcabff0c9 Mon Sep 17 00:00:00 2001 From: Ibrahim BinAlshikh Date: Sun, 4 Jan 2026 22:25:16 +0300 Subject: [PATCH 19/44] refactor: Fix Missing Imports --- WebFiori/Database/Entity/RecordMapper.php | 2 ++ WebFiori/Database/Factory/TableFactory.php | 5 +++-- WebFiori/Database/Query/InsertBuilder.php | 3 +++ WebFiori/Database/Query/SelectExpression.php | 3 +++ WebFiori/Database/Table.php | 2 ++ 5 files changed, 13 insertions(+), 2 deletions(-) diff --git a/WebFiori/Database/Entity/RecordMapper.php b/WebFiori/Database/Entity/RecordMapper.php index 71b5ea99..ad61e6a7 100644 --- a/WebFiori/Database/Entity/RecordMapper.php +++ b/WebFiori/Database/Entity/RecordMapper.php @@ -11,6 +11,8 @@ */ namespace WebFiori\Database\Entity; +use WebFiori\Database\DatabaseException; + /** * A class which is used to map a database record to a system entity. * diff --git a/WebFiori/Database/Factory/TableFactory.php b/WebFiori/Database/Factory/TableFactory.php index b0f23135..6e12b93b 100644 --- a/WebFiori/Database/Factory/TableFactory.php +++ b/WebFiori/Database/Factory/TableFactory.php @@ -11,11 +11,12 @@ */ namespace WebFiori\Database\Factory; +use WebFiori\Database\ConnectionInfo; +use WebFiori\Database\DatabaseException; +use WebFiori\Database\Factory\ColumnFactory; use WebFiori\Database\MsSql\MSSQLTable; use WebFiori\Database\MySql\MySQLTable; use WebFiori\Database\Table; -use WebFiori\Database\DatabaseException; -use WebFiori\Database\ConnectionInfo; /** * diff --git a/WebFiori/Database/Query/InsertBuilder.php b/WebFiori/Database/Query/InsertBuilder.php index 6b5923a7..6fb4db66 100644 --- a/WebFiori/Database/Query/InsertBuilder.php +++ b/WebFiori/Database/Query/InsertBuilder.php @@ -11,6 +11,9 @@ */ namespace WebFiori\Database\Query; +use WebFiori\Database\Column; +use WebFiori\Database\Table; + /** * A class which is used to build insert SQL queries for diffrent database engines. * diff --git a/WebFiori/Database/Query/SelectExpression.php b/WebFiori/Database/Query/SelectExpression.php index 0a88920d..baac0aa5 100644 --- a/WebFiori/Database/Query/SelectExpression.php +++ b/WebFiori/Database/Query/SelectExpression.php @@ -12,6 +12,9 @@ namespace WebFiori\Database\Query; use InvalidArgumentException; +use WebFiori\Database\AbstractQuery; +use WebFiori\Database\Column; +use WebFiori\Database\JoinTable; use WebFiori\Database\Table; /** diff --git a/WebFiori/Database/Table.php b/WebFiori/Database/Table.php index efe346ea..66698af0 100644 --- a/WebFiori/Database/Table.php +++ b/WebFiori/Database/Table.php @@ -11,8 +11,10 @@ */ namespace WebFiori\Database; +use WebFiori\Database\Factory\ColumnFactory; use WebFiori\Database\MsSql\MSSQLTable; use WebFiori\Database\MySql\MySQLTable; +use WebFiori\Database\Query\SelectExpression; /** * Abstract base class for representing database table structures. From 815588f16119fc387e69c8b08ad25f4113249bed Mon Sep 17 00:00:00 2001 From: Ibrahim BinAlshikh Date: Sun, 4 Jan 2026 22:25:29 +0300 Subject: [PATCH 20/44] Create MSSQLAttributeTestUser.php --- .../Tests/Database/MsSql/MSSQLAttributeTestUser.php | 13 +++++++++++++ 1 file changed, 13 insertions(+) create mode 100644 tests/WebFiori/Tests/Database/MsSql/MSSQLAttributeTestUser.php diff --git a/tests/WebFiori/Tests/Database/MsSql/MSSQLAttributeTestUser.php b/tests/WebFiori/Tests/Database/MsSql/MSSQLAttributeTestUser.php new file mode 100644 index 00000000..9ec7ce06 --- /dev/null +++ b/tests/WebFiori/Tests/Database/MsSql/MSSQLAttributeTestUser.php @@ -0,0 +1,13 @@ + Date: Sun, 4 Jan 2026 22:25:38 +0300 Subject: [PATCH 21/44] Create MSSQLAttributeTestPost.php --- .../Database/MsSql/MSSQLAttributeTestPost.php | 15 +++++++++++++++ 1 file changed, 15 insertions(+) create mode 100644 tests/WebFiori/Tests/Database/MsSql/MSSQLAttributeTestPost.php diff --git a/tests/WebFiori/Tests/Database/MsSql/MSSQLAttributeTestPost.php b/tests/WebFiori/Tests/Database/MsSql/MSSQLAttributeTestPost.php new file mode 100644 index 00000000..8a5b363a --- /dev/null +++ b/tests/WebFiori/Tests/Database/MsSql/MSSQLAttributeTestPost.php @@ -0,0 +1,15 @@ + Date: Sun, 4 Jan 2026 22:43:07 +0300 Subject: [PATCH 22/44] fix: Imports Correction --- WebFiori/Database/Table.php | 1 + tests/WebFiori/Tests/Database/MsSql/MSSQLColumnTest.php | 2 +- tests/WebFiori/Tests/Database/MySql/MySQLColumnTest.php | 2 +- 3 files changed, 3 insertions(+), 2 deletions(-) diff --git a/WebFiori/Database/Table.php b/WebFiori/Database/Table.php index 66698af0..7625ae52 100644 --- a/WebFiori/Database/Table.php +++ b/WebFiori/Database/Table.php @@ -11,6 +11,7 @@ */ namespace WebFiori\Database; +use WebFiori\Database\Entity\EntityMapper; use WebFiori\Database\Factory\ColumnFactory; use WebFiori\Database\MsSql\MSSQLTable; use WebFiori\Database\MySql\MySQLTable; diff --git a/tests/WebFiori/Tests/Database/MsSql/MSSQLColumnTest.php b/tests/WebFiori/Tests/Database/MsSql/MSSQLColumnTest.php index 0d606458..b6be0fef 100644 --- a/tests/WebFiori/Tests/Database/MsSql/MSSQLColumnTest.php +++ b/tests/WebFiori/Tests/Database/MsSql/MSSQLColumnTest.php @@ -2,7 +2,7 @@ namespace WebFiori\Tests\Database\MsSql; use PHPUnit\Framework\TestCase; -use WebFiori\Database\ColumnFactory; +use WebFiori\Database\Factory\ColumnFactory; use WebFiori\Database\MsSql\MSSQLColumn; use WebFiori\Database\MySql\MySQLColumn; /** diff --git a/tests/WebFiori/Tests/Database/MySql/MySQLColumnTest.php b/tests/WebFiori/Tests/Database/MySql/MySQLColumnTest.php index 89b180cc..5ee28134 100644 --- a/tests/WebFiori/Tests/Database/MySql/MySQLColumnTest.php +++ b/tests/WebFiori/Tests/Database/MySql/MySQLColumnTest.php @@ -9,7 +9,7 @@ namespace WebFiori\Tests\Database\MySql; use PHPUnit\Framework\TestCase; -use WebFiori\Database\ColumnFactory; +use WebFiori\Database\Factory\ColumnFactory; use WebFiori\Database\DataType; use WebFiori\Database\MsSql\MSSQLColumn; use WebFiori\Database\MySql\MySQLColumn; From da6a6e65df9e54f3b8f784c4e075d390dafc90eb Mon Sep 17 00:00:00 2001 From: Ibrahim BinAlshikh Date: Sun, 4 Jan 2026 22:58:04 +0300 Subject: [PATCH 23/44] refactor: Mixed Data Type --- tests/WebFiori/Tests/Database/MsSql/MSSQLColumnTest.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/WebFiori/Tests/Database/MsSql/MSSQLColumnTest.php b/tests/WebFiori/Tests/Database/MsSql/MSSQLColumnTest.php index b6be0fef..9a470deb 100644 --- a/tests/WebFiori/Tests/Database/MsSql/MSSQLColumnTest.php +++ b/tests/WebFiori/Tests/Database/MsSql/MSSQLColumnTest.php @@ -274,9 +274,9 @@ public function testGetPHPType05() { */ public function testGetPHPType06() { $colObj = new MSSQLColumn('col', 'mixed'); - $this->assertEquals('mixed', $colObj->getPHPType()); + $this->assertEquals('string', $colObj->getPHPType()); $colObj->setIsNull(true); - $this->assertEquals('mixed|null', $colObj->getPHPType()); + $this->assertEquals('string|null', $colObj->getPHPType()); } /** * @test From e56a1ba6f044327766a53049b6517b313a992840 Mon Sep 17 00:00:00 2001 From: Ibrahim BinAlshikh Date: Sun, 4 Jan 2026 23:05:18 +0300 Subject: [PATCH 24/44] fix(mysql): Auto-Increment --- WebFiori/Database/MySql/MySQLColumn.php | 6 ++++++ WebFiori/Database/MySql/MySQLTable.php | 2 +- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/WebFiori/Database/MySql/MySQLColumn.php b/WebFiori/Database/MySql/MySQLColumn.php index b1590f25..ebaf087c 100644 --- a/WebFiori/Database/MySql/MySQLColumn.php +++ b/WebFiori/Database/MySql/MySQLColumn.php @@ -106,6 +106,12 @@ public function __toString() { if ($this->isUnique() && $colDataType != 'boolean' && $colDataType != 'bool') { $retVal .= 'unique '; } + + // Add auto_increment before default and comment + if ($this->isAutoInc()) { + $retVal .= 'auto_increment '; + } + $retVal .= $this->defaultPart(); if ($colDataType == 'varchar' || $colDataType == 'text' || $colDataType == 'mediumtext' || $colDataType == 'mixed') { diff --git a/WebFiori/Database/MySql/MySQLTable.php b/WebFiori/Database/MySql/MySQLTable.php index ae9bf2d4..ff3ade73 100644 --- a/WebFiori/Database/MySql/MySQLTable.php +++ b/WebFiori/Database/MySql/MySQLTable.php @@ -296,7 +296,7 @@ private function createTableColumns() { $index = 0; foreach ($cols as $colObj) { - $autoIncPart = $colObj->isAutoInc() ? ' auto_increment' : ''; + $autoIncPart = ''; if ($index + 1 == $count) { $queryStr .= ' '.$colObj->asString().$autoIncPart.""; From 180ebecffb032d185343ae31321ebcc67cce3244 Mon Sep 17 00:00:00 2001 From: Ibrahim BinAlshikh Date: Sun, 4 Jan 2026 23:12:13 +0300 Subject: [PATCH 25/44] test: Fix Test Case --- tests/WebFiori/Tests/Database/Schema/SchemaAdvancedTest.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/WebFiori/Tests/Database/Schema/SchemaAdvancedTest.php b/tests/WebFiori/Tests/Database/Schema/SchemaAdvancedTest.php index 8a391fa5..2c20c890 100644 --- a/tests/WebFiori/Tests/Database/Schema/SchemaAdvancedTest.php +++ b/tests/WebFiori/Tests/Database/Schema/SchemaAdvancedTest.php @@ -48,7 +48,7 @@ public function testApplyAndRollbackSequence() { // Test apply - may return 0 if already applied $applied = $runner->apply(); - $this->assertIsArray($applied); + $this->assertInstanceOf(\WebFiori\Database\Schema\DatabaseChangeResult::class, $applied); } catch (DatabaseException $ex) { $this->markTestSkipped('Database connection failed: ' . $ex->getMessage()); From caddba0bae6c31edd4113d1b8729696bb05d9450 Mon Sep 17 00:00:00 2001 From: Ibrahim BinAlshikh Date: Sun, 4 Jan 2026 23:20:48 +0300 Subject: [PATCH 26/44] test: Fix Test Case --- tests/WebFiori/Tests/Database/Schema/SchemaValidationTest.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/WebFiori/Tests/Database/Schema/SchemaValidationTest.php b/tests/WebFiori/Tests/Database/Schema/SchemaValidationTest.php index 69c3ee32..3c7f3122 100644 --- a/tests/WebFiori/Tests/Database/Schema/SchemaValidationTest.php +++ b/tests/WebFiori/Tests/Database/Schema/SchemaValidationTest.php @@ -93,7 +93,7 @@ class IncompleteMigration extends \\WebFiori\\Database\\Schema\\AbstractMigratio $applied = $runner->apply(); // Should handle incomplete implementation - $this->assertIsArray($applied); + $this->assertInstanceOf(\WebFiori\Database\Schema\DatabaseChangeResult::class, $applied); } catch (DatabaseException $ex) { $this->markTestSkipped('Database connection failed: ' . $ex->getMessage()); } From 95cdf0aaf235e1df3b5ddf07b210c26c8a20dee8 Mon Sep 17 00:00:00 2001 From: Ibrahim BinAlshikh Date: Sun, 4 Jan 2026 23:30:04 +0300 Subject: [PATCH 27/44] Update SchemaErrorHandlingTest.php --- .../Tests/Database/Schema/SchemaErrorHandlingTest.php | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/WebFiori/Tests/Database/Schema/SchemaErrorHandlingTest.php b/tests/WebFiori/Tests/Database/Schema/SchemaErrorHandlingTest.php index 9656f9e4..c71da9e1 100644 --- a/tests/WebFiori/Tests/Database/Schema/SchemaErrorHandlingTest.php +++ b/tests/WebFiori/Tests/Database/Schema/SchemaErrorHandlingTest.php @@ -156,7 +156,9 @@ public function testDatabaseConnectionFailureDuringExecution() { $this->expectException(DatabaseException::class); $this->expectExceptionMessage('Unable to connect to database'); - $runner = new SchemaRunner($badConnection); + $runner = new SchemaRunner($badConnection); + // Trigger actual database connection attempt + $runner->createSchemaTable(); } // Type Safety and Validation Issues From 82d3af7ef622500c40f814f51020b15c0dce3094 Mon Sep 17 00:00:00 2001 From: Ibrahim BinAlshikh Date: Sun, 4 Jan 2026 23:33:18 +0300 Subject: [PATCH 28/44] Update SchemaRunnerTest.php --- tests/WebFiori/Tests/Database/Schema/SchemaRunnerTest.php | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/tests/WebFiori/Tests/Database/Schema/SchemaRunnerTest.php b/tests/WebFiori/Tests/Database/Schema/SchemaRunnerTest.php index 11bf848d..8636a00f 100644 --- a/tests/WebFiori/Tests/Database/Schema/SchemaRunnerTest.php +++ b/tests/WebFiori/Tests/Database/Schema/SchemaRunnerTest.php @@ -151,9 +151,10 @@ public function testRollbackUpToSpecificChange() { $runner->createSchemaTable(); $applied = $runner->apply(); + $appliedChanges = $applied->getApplied(); - if (!empty($applied)) { - $lastChange = end($applied); + if (!empty($appliedChanges)) { + $lastChange = end($appliedChanges); $rolled = $runner->rollbackUpTo($lastChange->getName()); $this->assertIsArray($rolled); From f7055c9e8e683231ae18862ff0b3db2a55f329e3 Mon Sep 17 00:00:00 2001 From: Ibrahim BinAlshikh Date: Sun, 4 Jan 2026 23:36:58 +0300 Subject: [PATCH 29/44] Update SchemaRunnerTest.php --- .../Tests/Database/Schema/SchemaRunnerTest.php | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/tests/WebFiori/Tests/Database/Schema/SchemaRunnerTest.php b/tests/WebFiori/Tests/Database/Schema/SchemaRunnerTest.php index 8636a00f..a2985d6e 100644 --- a/tests/WebFiori/Tests/Database/Schema/SchemaRunnerTest.php +++ b/tests/WebFiori/Tests/Database/Schema/SchemaRunnerTest.php @@ -249,10 +249,16 @@ public function testNamespaceMismatch() { // Test registration with invalid class name $runner = new SchemaRunner($this->getConnectionInfo()); - $this->expectException(\Exception::class); - $this->expectExceptionMessage('Class does not exist'); + $errorCaught = false; + $runner->addOnRegisterErrorCallback(function($err) use (&$errorCaught) { + $errorCaught = true; + $this->assertStringContainsString('Class does not exist', $err->getMessage()); + }); - $runner->register('InvalidNamespace\\NonExistentClass'); + // Should return false for non-existent class + $result = $runner->register('InvalidNamespace\\NonExistentClass'); + $this->assertFalse($result); + $this->assertTrue($errorCaught, 'Error callback should have been called'); } public function testConstructorDependencies() { From adab255c844fb0785daa04b34cc6818779c6ebef Mon Sep 17 00:00:00 2001 From: Ibrahim BinAlshikh Date: Sun, 4 Jan 2026 23:54:41 +0300 Subject: [PATCH 30/44] Update SchemaChangeRepository.php --- .../Database/Schema/SchemaChangeRepository.php | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/WebFiori/Database/Schema/SchemaChangeRepository.php b/WebFiori/Database/Schema/SchemaChangeRepository.php index 3b607b95..8de83fad 100644 --- a/WebFiori/Database/Schema/SchemaChangeRepository.php +++ b/WebFiori/Database/Schema/SchemaChangeRepository.php @@ -96,11 +96,11 @@ public function recordChange(DatabaseChange $change): int { 'change_name' => $change->getName(), 'type' => $change->getType(), 'applied-on' => date('Y-m-d H:i:s'), - 'db-name' => $this->getDatabase()->getConnectionInfo()->getDatabase(), + 'db-name' => $this->getDatabase()->getConnectionInfo()->getDBName(), 'batch' => $change->getBatch() ])->execute(); - return $this->getDatabase()->getLastInsertId(); + return $this->getLastInsertId(); } /** @@ -242,4 +242,17 @@ public function count(array $conditions = []): int { public function clearAll(): int { return $this->deleteAll(); } + + /** + * Get the last insert ID from the database connection. + * + * @return int The last insert ID, or 0 if not available + */ + private function getLastInsertId(): int { + return (int)$this->getDatabase() + ->getQueryGenerator() + ->selectMax($this->getIdField(), 'max') + ->execute() + ->getRows()[0]['max']; + } } From a43b66e15dcd1a002c6b331902b81716a013a74e Mon Sep 17 00:00:00 2001 From: Ibrahim BinAlshikh Date: Mon, 5 Jan 2026 00:04:15 +0300 Subject: [PATCH 31/44] Update DryRunTest.php --- .../Tests/Database/Schema/DryRunTest.php | 26 ++++++++++++++++--- 1 file changed, 23 insertions(+), 3 deletions(-) diff --git a/tests/WebFiori/Tests/Database/Schema/DryRunTest.php b/tests/WebFiori/Tests/Database/Schema/DryRunTest.php index 605c0ee6..b79f6e28 100644 --- a/tests/WebFiori/Tests/Database/Schema/DryRunTest.php +++ b/tests/WebFiori/Tests/Database/Schema/DryRunTest.php @@ -133,14 +133,34 @@ public function testGetPendingChangesExcludesApplied() { try { $runner->createSchemaTable(); - $runner->apply(); + // Ensure clean state + $runner->rollbackUpTo(null); + + // Check if we have any changes to work with + $allChanges = $runner->getChanges(); + if (empty($allChanges)) { + $this->markTestSkipped('No changes registered for testing'); + return; + } + + // Apply changes + $result = $runner->apply(); + + // After apply, pending changes should exclude applied ones $pending = $runner->getPendingChanges(); - $this->assertEmpty($pending); + $applied = $result->getApplied(); + + // If we applied changes, pending should be empty or reduced + if (!empty($applied)) { + $this->assertEmpty($pending, 'Pending changes should exclude applied changes'); + } else { + $this->markTestSkipped('No changes were applied to test exclusion'); + } $runner->rollbackUpTo(null); $runner->dropSchemaTable(); - } catch (\Exception $ex) { + } catch (\WebFiori\Database\DatabaseException $ex) { $this->markTestSkipped('Database connection failed: ' . $ex->getMessage()); } } From 2c1ff4f4e8d23c03d0fbf540594cc0e51f6322ae Mon Sep 17 00:00:00 2001 From: Ibrahim BinAlshikh Date: Mon, 5 Jan 2026 00:04:27 +0300 Subject: [PATCH 32/44] Update SchemaErrorHandlingTest.php --- .../WebFiori/Tests/Database/Schema/SchemaErrorHandlingTest.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/WebFiori/Tests/Database/Schema/SchemaErrorHandlingTest.php b/tests/WebFiori/Tests/Database/Schema/SchemaErrorHandlingTest.php index c71da9e1..87f99817 100644 --- a/tests/WebFiori/Tests/Database/Schema/SchemaErrorHandlingTest.php +++ b/tests/WebFiori/Tests/Database/Schema/SchemaErrorHandlingTest.php @@ -32,7 +32,7 @@ public function testRollbackErrorStopsExecution() { // Apply changes first $applied = $runner->apply(); - if (!empty($applied)) { + if (!empty($applied->getApplied())) { $errorCaught = false; $runner->addOnErrorCallback(function($err, $change, $schema) use (&$errorCaught) { $errorCaught = true; From 74d3ffd8da5f3c55f08d7adce731fafdf81c8a72 Mon Sep 17 00:00:00 2001 From: Ibrahim BinAlshikh Date: Mon, 5 Jan 2026 00:06:35 +0300 Subject: [PATCH 33/44] chore: Run CS Fixer --- .../Attributes/AttributeTableBuilder.php | 57 +- WebFiori/Database/Attributes/Column.php | 4 +- WebFiori/Database/Attributes/ForeignKey.php | 4 +- WebFiori/Database/Attributes/Table.php | 4 +- WebFiori/Database/DataType.php | 2 +- WebFiori/Database/Database.php | 136 +-- WebFiori/Database/Entity/EntityGenerator.php | 93 +- WebFiori/Database/Entity/EntityMapper.php | 4 +- WebFiori/Database/Factory/ColumnFactory.php | 8 +- WebFiori/Database/Factory/TableFactory.php | 1 - WebFiori/Database/MsSql/MSSQLColumn.php | 2 +- WebFiori/Database/MsSql/MSSQLConnection.php | 14 +- WebFiori/Database/MultiResultSet.php | 52 +- WebFiori/Database/MySql/MySQLColumn.php | 8 +- WebFiori/Database/MySql/MySQLConnection.php | 36 +- WebFiori/Database/MySql/MySQLQuery.php | 3 +- .../Performance/PerformanceAnalyzer.php | 240 ++--- .../Performance/PerformanceOption.php | 234 ++--- WebFiori/Database/Performance/QueryMetric.php | 252 ++--- .../Performance/QueryPerformanceMonitor.php | 940 +++++++++--------- WebFiori/Database/Query/Condition.php | 2 - .../Repository/AbstractRepository.php | 104 +- WebFiori/Database/Repository/CursorPage.php | 13 +- WebFiori/Database/Repository/Page.php | 39 +- WebFiori/Database/ResultSet.php | 25 +- .../Database/Schema/AbstractMigration.php | 1 + WebFiori/Database/Schema/AbstractSeeder.php | 1 + WebFiori/Database/Schema/DatabaseChange.php | 77 +- .../Schema/DatabaseChangeGenerator.php | 147 +-- .../Database/Schema/DatabaseChangeResult.php | 91 +- WebFiori/Database/Schema/GeneratorOption.php | 5 +- .../Schema/SchemaChangeRepository.php | 273 ++--- .../Database/Schema/SchemaMigrationsTable.php | 5 +- WebFiori/Database/Schema/SchemaRunner.php | 316 +++--- WebFiori/Database/Table.php | 36 +- examples/01-basic-connection/example.php | 21 +- examples/02-basic-queries/example.php | 35 +- examples/03-table-blueprints/UserTable.php | 92 +- examples/03-table-blueprints/example.php | 358 +++---- examples/04-entity-mapping/example.php | 334 +++---- examples/05-transactions/example.php | 22 +- .../06-migrations/AddEmailIndexMigration.php | 103 +- .../CreateUsersTableMigration.php | 141 ++- examples/06-migrations/example.php | 329 +++--- examples/07-seeders/CategoriesSeeder.php | 128 +-- examples/07-seeders/UsersSeeder.php | 110 +- examples/07-seeders/example.php | 418 ++++---- .../08-performance-monitoring/example.php | 280 +++--- examples/09-multi-result-queries/example.php | 47 +- 49 files changed, 2858 insertions(+), 2789 deletions(-) diff --git a/WebFiori/Database/Attributes/AttributeTableBuilder.php b/WebFiori/Database/Attributes/AttributeTableBuilder.php index 6d3dd661..4bf219cc 100644 --- a/WebFiori/Database/Attributes/AttributeTableBuilder.php +++ b/WebFiori/Database/Attributes/AttributeTableBuilder.php @@ -1,48 +1,45 @@ getAttributes(Table::class)[0] ?? null; - + if (!$tableAttr) { throw new \RuntimeException("Class $entityClass must have #[Table] attribute"); } - + $tableConfig = $tableAttr->newInstance(); - + $table = $dbType === 'mysql' ? new MySQLTable($tableConfig->name) : new MSSQLTable($tableConfig->name); - + if ($tableConfig->comment) { $table->setComment($tableConfig->comment); } - + $columns = []; $foreignKeys = []; - + // Check for class-level Column attributes $classColumnAttrs = $reflection->getAttributes(Column::class); - + if (!empty($classColumnAttrs)) { // Class-level approach: columns defined at class level foreach ($classColumnAttrs as $columnAttr) { $columnConfig = $columnAttr->newInstance(); $columnKey = $columnConfig->name ?? throw new \RuntimeException("Column name is required for class-level attributes"); - + $columns[$columnKey] = [ ColOption::TYPE => $columnConfig->type, ColOption::NAME => $columnConfig->name, @@ -59,9 +56,10 @@ public static function build(string $entityClass, string $dbType = 'mysql'): Tab ColOption::VALIDATOR => $columnConfig->callback ]; } - + // Check for class-level ForeignKey attributes $classFkAttrs = $reflection->getAttributes(ForeignKey::class); + foreach ($classFkAttrs as $fkAttr) { $fkConfig = $fkAttr->newInstance(); $foreignKeys[] = [ @@ -73,14 +71,14 @@ public static function build(string $entityClass, string $dbType = 'mysql'): Tab // Property-level approach: columns defined on properties foreach ($reflection->getProperties() as $property) { $columnAttrs = $property->getAttributes(Column::class); - + if (empty($columnAttrs)) { continue; } - + $columnConfig = $columnAttrs[0]->newInstance(); $columnKey = self::propertyToKey($property->getName()); - + $columns[$columnKey] = [ ColOption::TYPE => $columnConfig->type, ColOption::NAME => $columnConfig->name, @@ -96,8 +94,9 @@ public static function build(string $entityClass, string $dbType = 'mysql'): Tab ColOption::COMMENT => $columnConfig->comment, ColOption::VALIDATOR => $columnConfig->callback ]; - + $fkAttrs = $property->getAttributes(ForeignKey::class); + foreach ($fkAttrs as $fkAttr) { $fkConfig = $fkAttr->newInstance(); $foreignKeys[] = [ @@ -107,22 +106,22 @@ public static function build(string $entityClass, string $dbType = 'mysql'): Tab } } } - + $table->addColumns($columns); - + // Store table references for foreign keys $tableRegistry = []; - + foreach ($foreignKeys as $fk) { $refTableName = $fk['config']->table; $refColName = $fk['config']->column; - + // Create a minimal table reference if not exists if (!isset($tableRegistry[$refTableName])) { $refTable = $dbType === 'mysql' ? new MySQLTable($refTableName) : new MSSQLTable($refTableName); - + // Add the referenced column to make FK work $refTable->addColumns([ $refColName => [ @@ -130,22 +129,22 @@ public static function build(string $entityClass, string $dbType = 'mysql'): Tab ColOption::PRIMARY => true ] ]); - + $tableRegistry[$refTableName] = $refTable; } - + $table->addReference( $tableRegistry[$refTableName], [$fk['property'] => $refColName], - $fk['config']->name ?? 'fk_' . $fk['property'], + $fk['config']->name ?? 'fk_'.$fk['property'], $fk['config']->onUpdate, $fk['config']->onDelete ); } - + return $table; } - + private static function propertyToKey(string $propertyName): string { return strtolower(preg_replace('/([a-z])([A-Z])/', '$1-$2', $propertyName)); } diff --git a/WebFiori/Database/Attributes/Column.php b/WebFiori/Database/Attributes/Column.php index 91cb6b01..462f0243 100644 --- a/WebFiori/Database/Attributes/Column.php +++ b/WebFiori/Database/Attributes/Column.php @@ -1,5 +1,4 @@ */ const VARCHAR = 'varchar'; - + /** * Maps database data type to PHP type. * diff --git a/WebFiori/Database/Database.php b/WebFiori/Database/Database.php index b15ae2a0..2d1bcf5d 100644 --- a/WebFiori/Database/Database.php +++ b/WebFiori/Database/Database.php @@ -12,13 +12,13 @@ namespace WebFiori\Database; use Exception; +use WebFiori\Database\Factory\TableFactory; use WebFiori\Database\MsSql\MSSQLConnection; use WebFiori\Database\MsSql\MSSQLQuery; use WebFiori\Database\MsSql\MSSQLTable; use WebFiori\Database\MySql\MySQLConnection; use WebFiori\Database\MySql\MySQLQuery; use WebFiori\Database\MySql\MySQLTable; -use WebFiori\Database\Factory\TableFactory; use WebFiori\Database\Performance\PerformanceOption; use WebFiori\Database\Performance\QueryPerformanceMonitor; /** @@ -31,6 +31,12 @@ * */ class Database { + /** + * Queries captured during dry-run mode. + * + * @var array + */ + private array $capturedQueries = []; /** * The connection which is used to connect to the database. * @@ -47,6 +53,12 @@ class Database { * */ private $connectionInfo; + /** + * Whether dry-run mode is enabled. + * + * @var bool + */ + private bool $dryRun = false; private $lastErr; /** * Whether performance monitoring is enabled. @@ -68,18 +80,6 @@ class Database { * */ private $queries; - /** - * Whether dry-run mode is enabled. - * - * @var bool - */ - private bool $dryRun = false; - /** - * Queries captured during dry-run mode. - * - * @var array - */ - private array $capturedQueries = []; /** * The instance which is used to build database queries. @@ -357,6 +357,7 @@ public function execute() { $this->queries[] = $lastQuery; $this->clear(); $this->getQueryGenerator()->setQuery(null); + return new ResultSet([]); } @@ -371,7 +372,7 @@ public function execute() { $this->queries[] = $lastQuery; $this->clear(); $resultSet = $this->getLastResultSet(); - + $this->getQueryGenerator()->setQuery(null); // Record performance metrics @@ -382,6 +383,14 @@ public function execute() { return $resultSet; } + /** + * Get queries captured during dry-run mode. + * + * @return array Array of SQL query strings captured during dry-run. + */ + public function getCapturedQueries(): array { + return $this->capturedQueries; + } /** * Returns the connection at which the instance will use to run SQL queries. * @@ -421,36 +430,6 @@ public function getConnection() : ?Connection { public function getConnectionInfo() : ?ConnectionInfo { return $this->connectionInfo; } - /** - * Enable or disable dry-run mode. - * - * When dry-run mode is enabled, queries are captured but not executed. - * This is useful for previewing what SQL would be generated. - * - * @param bool $dryRun True to enable dry-run mode, false to disable. - */ - public function setDryRun(bool $dryRun): void { - $this->dryRun = $dryRun; - if ($dryRun) { - $this->capturedQueries = []; - } - } - /** - * Check if dry-run mode is enabled. - * - * @return bool True if dry-run mode is enabled, false otherwise. - */ - public function isDryRun(): bool { - return $this->dryRun; - } - /** - * Get queries captured during dry-run mode. - * - * @return array Array of SQL query strings captured during dry-run. - */ - public function getCapturedQueries(): array { - return $this->capturedQueries; - } /** * Returns an indexed array that contains all executed SQL queries. @@ -575,7 +554,7 @@ public function getQueryGenerator() : AbstractQuery { if ($this->dryRun && $this->queryGenerator === null) { $connInfo = $this->getConnectionInfo(); $dbType = $connInfo !== null ? $connInfo->getDatabaseType() : 'mysql'; - + if ($dbType == 'mssql') { $this->queryGenerator = new MSSQLQuery(); } else { @@ -583,7 +562,7 @@ public function getQueryGenerator() : AbstractQuery { } $this->queryGenerator->setSchema($this); } - + if ($this->queryGenerator === null && !$this->isConnected()) { if ($this->getConnectionInfo() === null) { throw new DatabaseException("Connection information not set."); @@ -702,6 +681,14 @@ public function isConnected() : bool { return true; } + /** + * Check if dry-run mode is enabled. + * + * @return bool True if dry-run mode is enabled, false otherwise. + */ + public function isDryRun(): bool { + return $this->dryRun; + } /** * Sets the number of records that will be fetched by the query. * @@ -775,6 +762,27 @@ public function orWhere(string $col, mixed $val = null, string $cond = '=') : Ab public function page(int $num, int $itemsCount) : AbstractQuery { return $this->getQueryGenerator()->page($num, $itemsCount); } + /** + * Sets the database query to a raw SQL query. + * + * @param string $query A string that represents the query. + * + * @return Database The method will return the same instance at which the + * method is called on. + * + * @throws DatabaseException + */ + public function raw(string $query, array $params = []) : Database { + $t = $this->getQueryGenerator()->getTable(); + + if ($t !== null) { + $t->getSelect()->clear(); + } + $this->getQueryGenerator()->setQuery($query); + $this->getQueryGenerator()->setBindings($params); + + return $this; + } /** * Reset the bindings which was set by building and executing a query. * @@ -836,6 +844,21 @@ public function setConnectionInfo(ConnectionInfo $info) { } $this->connectionInfo = $info; } + /** + * Enable or disable dry-run mode. + * + * When dry-run mode is enabled, queries are captured but not executed. + * This is useful for previewing what SQL would be generated. + * + * @param bool $dryRun True to enable dry-run mode, false to disable. + */ + public function setDryRun(bool $dryRun): void { + $this->dryRun = $dryRun; + + if ($dryRun) { + $this->capturedQueries = []; + } + } /** * Configure performance monitoring settings. @@ -865,29 +888,8 @@ public function setPerformanceConfig(array $config): void { * @throws DatabaseException */ public function setQuery(string $query, array $params = []) : Database { - return $this->raw($query, $params); } - /** - * Sets the database query to a raw SQL query. - * - * @param string $query A string that represents the query. - * - * @return Database The method will return the same instance at which the - * method is called on. - * - * @throws DatabaseException - */ - public function raw(string $query, array $params = []) : Database { - $t = $this->getQueryGenerator()->getTable(); - - if ($t !== null) { - $t->getSelect()->clear(); - } - $this->getQueryGenerator()->setQuery($query); - $this->getQueryGenerator()->setBindings($params); - return $this; - } /** * Select one of the tables which exist on the schema and use it to build * SQL queries. diff --git a/WebFiori/Database/Entity/EntityGenerator.php b/WebFiori/Database/Entity/EntityGenerator.php index 1f3d473c..a82353f5 100644 --- a/WebFiori/Database/Entity/EntityGenerator.php +++ b/WebFiori/Database/Entity/EntityGenerator.php @@ -1,4 +1,5 @@ path = rtrim($path, '/\\'); $this->namespace = trim($namespace, '\\'); } - + /** * Generates the entity class file. * @@ -52,10 +53,11 @@ public function __construct(Table $table, string $entityName, string $path = __D */ public function generate(): bool { $code = $this->buildClass(); - $filePath = $this->path . DIRECTORY_SEPARATOR . $this->entityName . '.php'; + $filePath = $this->path.DIRECTORY_SEPARATOR.$this->entityName.'.php'; + return file_put_contents($filePath, $code) !== false; } - + /** * Builds the complete class code. * @@ -63,15 +65,15 @@ public function generate(): bool { */ private function buildClass(): string { $code = "namespace) { $code .= "namespace {$this->namespace};\n\n"; } - + $code .= "/**\n"; $code .= " * Auto-generated immutable entity for table '{$this->table->getName()}'\n"; $code .= " * \n"; - $code .= " * Generated on: " . date('Y-m-d H:i:s') . "\n"; + $code .= " * Generated on: ".date('Y-m-d H:i:s')."\n"; $code .= " * \n"; $code .= " * This entity uses:\n"; $code .= " * - Protected properties (extensible)\n"; @@ -79,15 +81,15 @@ private function buildClass(): string { $code .= " * - Immutable (no setters)\n"; $code .= " */\n"; $code .= "class {$this->entityName} {\n"; - + $code .= $this->buildConstructor(); $code .= $this->buildGetters(); - + $code .= "}\n"; - + return $code; } - + /** * Builds the constructor with promoted properties. * @@ -96,22 +98,22 @@ private function buildClass(): string { private function buildConstructor(): string { $code = " public function __construct(\n"; $params = []; - + foreach ($this->table->getCols() as $key => $col) { $phpType = $col->getPHPType(); $propName = $this->toCamelCase($key); $nullable = $this->isNullable($col) ? '?' : ''; $default = $this->getDefault($col); - + $params[] = " protected {$nullable}{$phpType} \${$propName}{$default}"; } - + $code .= implode(",\n", $params); $code .= "\n ) {}\n\n"; - + return $code; } - + /** * Builds getter methods for all properties. * @@ -119,31 +121,21 @@ private function buildConstructor(): string { */ private function buildGetters(): string { $code = ''; - + foreach ($this->table->getCols() as $key => $col) { $phpType = $col->getPHPType(); $propName = $this->toCamelCase($key); - $methodName = 'get' . ucfirst($propName); + $methodName = 'get'.ucfirst($propName); $nullable = $this->isNullable($col) ? '?' : ''; - + $code .= " public function {$methodName}(): {$nullable}{$phpType} {\n"; $code .= " return \$this->{$propName};\n"; $code .= " }\n\n"; } - + return $code; } - - /** - * Checks if column should be nullable in PHP. - * - * @param Column $col The column to check - * @return bool True if nullable, false otherwise - */ - private function isNullable(Column $col): bool { - return $col->isNull() || $col->isAutoInc(); - } - + /** * Gets the default value for a property. * @@ -154,44 +146,61 @@ private function getDefault(Column $col): string { if ($col->isAutoInc()) { return ' = null'; } - + if ($col->isNull()) { return ' = null'; } - + $default = $col->getDefault(); + if ($default !== null) { $phpType = $col->getPHPType(); - + if ($phpType === 'string') { - return " = '" . addslashes($default) . "'"; + return " = '".addslashes($default)."'"; } + if ($phpType === 'int' || $phpType === 'float') { return " = {$default}"; } + if ($phpType === 'bool') { return $default ? ' = true' : ' = false'; } } - + // Required field with no default $phpType = $col->getPHPType(); + if ($phpType === 'string') { return " = ''"; } + if ($phpType === 'int') { return ' = 0'; } + if ($phpType === 'float') { return ' = 0.0'; } + if ($phpType === 'bool') { return ' = false'; } - + return ''; } - + + /** + * Checks if column should be nullable in PHP. + * + * @param Column $col The column to check + * @return bool True if nullable, false otherwise + */ + private function isNullable(Column $col): bool { + return $col->isNull() || $col->isAutoInc(); + } + /** * Converts kebab-case to camelCase. * @@ -201,11 +210,11 @@ private function getDefault(Column $col): string { private function toCamelCase(string $key): string { $parts = explode('-', $key); $camelCase = array_shift($parts); - + foreach ($parts as $part) { $camelCase .= ucfirst($part); } - + return $camelCase; } } diff --git a/WebFiori/Database/Entity/EntityMapper.php b/WebFiori/Database/Entity/EntityMapper.php index b904edfd..e52dd960 100644 --- a/WebFiori/Database/Entity/EntityMapper.php +++ b/WebFiori/Database/Entity/EntityMapper.php @@ -12,10 +12,10 @@ namespace WebFiori\Database\Entity; use InvalidArgumentException; +use WebFiori\Database\Column; +use WebFiori\Database\Table; use WebFiori\Json\Json; use WebFiori\Json\JsonI; -use WebFiori\Database\Table; -use WebFiori\Database\Column; /** * Code generator for creating entity classes from table blueprints. * diff --git a/WebFiori/Database/Factory/ColumnFactory.php b/WebFiori/Database/Factory/ColumnFactory.php index f66556fa..fd735bfd 100644 --- a/WebFiori/Database/Factory/ColumnFactory.php +++ b/WebFiori/Database/Factory/ColumnFactory.php @@ -11,13 +11,13 @@ */ namespace WebFiori\Database\Factory; -use WebFiori\Database\MsSql\MSSQLColumn; -use WebFiori\Database\MySql\MySQLColumn; +use WebFiori\Database\ColOption; use WebFiori\Database\Column; -use WebFiori\Database\DatabaseException; use WebFiori\Database\ConnectionInfo; +use WebFiori\Database\DatabaseException; +use WebFiori\Database\MsSql\MSSQLColumn; +use WebFiori\Database\MySql\MySQLColumn; use WebFiori\Database\Util\TypesMap; -use WebFiori\Database\ColOption; /** * A factory class for creating column objects. diff --git a/WebFiori/Database/Factory/TableFactory.php b/WebFiori/Database/Factory/TableFactory.php index 6e12b93b..d2027ed3 100644 --- a/WebFiori/Database/Factory/TableFactory.php +++ b/WebFiori/Database/Factory/TableFactory.php @@ -13,7 +13,6 @@ use WebFiori\Database\ConnectionInfo; use WebFiori\Database\DatabaseException; -use WebFiori\Database\Factory\ColumnFactory; use WebFiori\Database\MsSql\MSSQLTable; use WebFiori\Database\MySql\MySQLTable; use WebFiori\Database\Table; diff --git a/WebFiori/Database/MsSql/MSSQLColumn.php b/WebFiori/Database/MsSql/MSSQLColumn.php index 0211b00c..427333bc 100644 --- a/WebFiori/Database/MsSql/MSSQLColumn.php +++ b/WebFiori/Database/MsSql/MSSQLColumn.php @@ -12,8 +12,8 @@ namespace WebFiori\Database\MsSql; use WebFiori\Database\Column; -use WebFiori\Database\Factory\ColumnFactory; use WebFiori\Database\DatabaseException; +use WebFiori\Database\Factory\ColumnFactory; use WebFiori\Database\Util\DateTimeValidator; /** * A class that represents a column in MSSQL table. diff --git a/WebFiori/Database/MsSql/MSSQLConnection.php b/WebFiori/Database/MsSql/MSSQLConnection.php index e5ba2e68..3c0fb92f 100644 --- a/WebFiori/Database/MsSql/MSSQLConnection.php +++ b/WebFiori/Database/MsSql/MSSQLConnection.php @@ -13,9 +13,9 @@ use WebFiori\Database\AbstractQuery; use WebFiori\Database\Connection; -use WebFiori\Database\MultiResultSet; use WebFiori\Database\ConnectionInfo; use WebFiori\Database\DatabaseException; +use WebFiori\Database\MultiResultSet; use WebFiori\Database\ResultSet; /** * A class that represents a connection to MSSQL server. @@ -177,7 +177,7 @@ public function rollBack(?string $name = null) { * Execute MSSQL query. * * @param AbstractQuery $query A query builder that has the generated MSSQL - /** + * /** * Execute a query and return execution status. * * @param AbstractQuery|null $query The query to execute. If null, uses the last set query. @@ -243,15 +243,17 @@ private function runOtherQuery() { if (!is_resource($r)) { $this->setSqlErr(); + return false; } // Collect all result sets $allResults = []; - + // First result set if (sqlsrv_has_rows($r)) { $data = []; + while ($row = sqlsrv_fetch_array($r, SQLSRV_FETCH_ASSOC)) { $data[] = $row; } @@ -263,6 +265,7 @@ private function runOtherQuery() { // Additional result sets while (sqlsrv_next_result($r)) { $data = []; + while ($row = sqlsrv_fetch_array($r, SQLSRV_FETCH_ASSOC)) { $data[] = $row; } @@ -285,14 +288,16 @@ private function runSelectQuery() { if (!is_resource($r)) { $this->setSqlErr(); + return false; } // Collect all result sets $allResults = []; - + // First result set $data = []; + while ($row = sqlsrv_fetch_array($r, SQLSRV_FETCH_ASSOC)) { $data[] = $row; } @@ -301,6 +306,7 @@ private function runSelectQuery() { // Additional result sets while (sqlsrv_next_result($r)) { $data = []; + while ($row = sqlsrv_fetch_array($r, SQLSRV_FETCH_ASSOC)) { $data[] = $row; } diff --git a/WebFiori/Database/MultiResultSet.php b/WebFiori/Database/MultiResultSet.php index 2ec26ab1..4b61b64c 100644 --- a/WebFiori/Database/MultiResultSet.php +++ b/WebFiori/Database/MultiResultSet.php @@ -29,12 +29,12 @@ class MultiResultSet implements Countable, Iterator { * @var int Current position in the result sets array */ private $cursorPos; - + /** * @var ResultSet[] Array of ResultSet objects */ private $resultSets; - + /** * Creates new instance of MultiResultSet. * @@ -43,12 +43,12 @@ class MultiResultSet implements Countable, Iterator { public function __construct(array $resultSets = []) { $this->cursorPos = 0; $this->resultSets = []; - + foreach ($resultSets as $resultData) { $this->addResultSet($resultData); } } - + /** * Add a result set to the collection. * @@ -61,7 +61,7 @@ public function addResultSet($resultData): void { $this->resultSets[] = new ResultSet($resultData); } } - + /** * Get the number of result sets. * @@ -70,7 +70,7 @@ public function addResultSet($resultData): void { public function count(): int { return count($this->resultSets); } - + /** * Get the current ResultSet object. * @@ -80,7 +80,7 @@ public function count(): int { public function current() { return $this->valid() ? $this->resultSets[$this->cursorPos] : null; } - + /** * Get a specific result set by index. * @@ -90,7 +90,7 @@ public function current() { public function getResultSet(int $index): ?ResultSet { return isset($this->resultSets[$index]) ? $this->resultSets[$index] : null; } - + /** * Get all result sets. * @@ -99,7 +99,22 @@ public function getResultSet(int $index): ?ResultSet { public function getResultSets(): array { return $this->resultSets; } - + + /** + * Get total number of records across all result sets. + * + * @return int Total number of records + */ + public function getTotalRecordCount(): int { + $total = 0; + + foreach ($this->resultSets as $resultSet) { + $total += $resultSet->getRowsCount(); + } + + return $total; + } + /** * Get the current cursor position. * @@ -109,7 +124,7 @@ public function getResultSets(): array { public function key() { return $this->cursorPos; } - + /** * Move to the next result set. */ @@ -117,7 +132,7 @@ public function key() { public function next(): void { $this->cursorPos++; } - + /** * Reset cursor to the first result set. */ @@ -125,20 +140,7 @@ public function next(): void { public function rewind(): void { $this->cursorPos = 0; } - - /** - * Get total number of records across all result sets. - * - * @return int Total number of records - */ - public function getTotalRecordCount(): int { - $total = 0; - foreach ($this->resultSets as $resultSet) { - $total += $resultSet->getRowsCount(); - } - return $total; - } - + /** * Check if current position is valid. * diff --git a/WebFiori/Database/MySql/MySQLColumn.php b/WebFiori/Database/MySql/MySQLColumn.php index ebaf087c..24c40d89 100644 --- a/WebFiori/Database/MySql/MySQLColumn.php +++ b/WebFiori/Database/MySql/MySQLColumn.php @@ -12,10 +12,10 @@ namespace WebFiori\Database\MySql; use WebFiori\Database\Column; -use WebFiori\Database\Factory\ColumnFactory; use WebFiori\Database\DatabaseException; -use WebFiori\Database\Util\DateTimeValidator; +use WebFiori\Database\Factory\ColumnFactory; use WebFiori\Database\Table; +use WebFiori\Database\Util\DateTimeValidator; /** * A class that represents a column in MySQL table. @@ -106,12 +106,12 @@ public function __toString() { if ($this->isUnique() && $colDataType != 'boolean' && $colDataType != 'bool') { $retVal .= 'unique '; } - + // Add auto_increment before default and comment if ($this->isAutoInc()) { $retVal .= 'auto_increment '; } - + $retVal .= $this->defaultPart(); if ($colDataType == 'varchar' || $colDataType == 'text' || $colDataType == 'mediumtext' || $colDataType == 'mixed') { diff --git a/WebFiori/Database/MySql/MySQLConnection.php b/WebFiori/Database/MySql/MySQLConnection.php index 1ed602c2..d13fe2d6 100644 --- a/WebFiori/Database/MySql/MySQLConnection.php +++ b/WebFiori/Database/MySql/MySQLConnection.php @@ -13,11 +13,11 @@ use mysqli; use mysqli_stmt; -use WebFiori\Database\MultiResultSet; use WebFiori\Database\AbstractQuery; use WebFiori\Database\Connection; use WebFiori\Database\ConnectionInfo; use WebFiori\Database\DatabaseException; +use WebFiori\Database\MultiResultSet; use WebFiori\Database\ResultSet; /** * MySQL database connection handler with prepared statement support. @@ -151,6 +151,14 @@ public function connect() : bool { public function getMysqli() { return $this->link; } + /** + * Get the mysqli link for testing purposes. + * + * @return mysqli The mysqli connection link + */ + public function getMysqliLink() { + return $this->link; + } public function rollBack(?string $name = null) { //The null check is for php<8 @@ -172,7 +180,7 @@ public function rollBack(?string $name = null) { * * @param AbstractQuery $query A query builder that has the generated MySQL * query. - /** + * /** * Execute a query and return execution status. * * @param AbstractQuery|null $query The query to execute. If null, uses the last set query. @@ -205,6 +213,7 @@ public function runQuery(?AbstractQuery $query = null): bool { try { $result = false; + if ($qType == 'insert') { $result = $this->runInsertQuery(); } else if ($qType == 'update') { @@ -215,6 +224,7 @@ public function runQuery(?AbstractQuery $query = null): bool { $result = $this->runOtherQuery(); } $query->resetBinding(); + return $result; } catch (\Exception $ex) { $this->setErrCode($ex->getCode()); @@ -283,9 +293,11 @@ private function runOtherQuery() { $values = array_merge($this->getLastQuery()->getBindings()['values']); $successExec = false; $r = null; + // Execute query if (count($values) != 0 && !empty($params)) { $paramCount = substr_count($sql, '?'); + if ($paramCount == count($values) && strlen($params) == count($values)) { $stmt = mysqli_prepare($this->link, $sql); mysqli_stmt_bind_param($stmt, $params, ...$values); @@ -302,12 +314,13 @@ private function runOtherQuery() { if (($r === null || $r === false) && !$successExec) { $this->setErrMessage($this->link->error); $this->setErrCode($this->link->errno); + return false; } // Collect all result sets $allResults = []; - + // First result set if (is_object($r) && method_exists($r, 'fetch_assoc')) { $rows = mysqli_fetch_all($r, MYSQLI_ASSOC); @@ -318,6 +331,7 @@ private function runOtherQuery() { // Additional result sets while (mysqli_more_results($this->link)) { mysqli_next_result($this->link); + if ($result = mysqli_store_result($this->link)) { $rows = mysqli_fetch_all($result, MYSQLI_ASSOC); $allResults[] = $rows; @@ -333,17 +347,10 @@ private function runOtherQuery() { } $this->setErrCode(0); + return true; } - /** - * Get the mysqli link for testing purposes. - * - * @return mysqli The mysqli connection link - */ - public function getMysqliLink() { - return $this->link; - } - + private function runSelectQuery() { $sql = $this->getLastQuery()->getQuery(); $params = $this->getLastQuery()->getBindings()['bind']; @@ -363,12 +370,13 @@ private function runSelectQuery() { if (!$r) { $this->setErrMessage($this->link->error); $this->setErrCode($this->link->errno); + return false; } // Collect all result sets $allResults = []; - + // First result set $rows = mysqli_fetch_all($r, MYSQLI_ASSOC); $allResults[] = $rows; @@ -377,6 +385,7 @@ private function runSelectQuery() { // Additional result sets while (mysqli_more_results($this->link)) { mysqli_next_result($this->link); + if ($result = mysqli_store_result($this->link)) { $rows = mysqli_fetch_all($result, MYSQLI_ASSOC); $allResults[] = $rows; @@ -392,6 +401,7 @@ private function runSelectQuery() { } $this->setErrCode(0); + return true; } private function runUpdateQuery() { diff --git a/WebFiori/Database/MySql/MySQLQuery.php b/WebFiori/Database/MySql/MySQLQuery.php index cf07b381..806c43c8 100644 --- a/WebFiori/Database/MySql/MySQLQuery.php +++ b/WebFiori/Database/MySql/MySQLQuery.php @@ -339,6 +339,7 @@ public function setBindings(array $bindings, string $merge = 'none') { if (!isset($bindings['bind']) && !isset($bindings['values'])) { // Simple array - convert to structured format $bindString = ''; + foreach ($bindings as $value) { if (is_int($value)) { $bindString .= 'i'; @@ -353,7 +354,7 @@ public function setBindings(array $bindings, string $merge = 'none') { 'values' => $bindings ]; } - + $currentBinding = $this->bindings['bind']; $values = $this->bindings['values']; diff --git a/WebFiori/Database/Performance/PerformanceAnalyzer.php b/WebFiori/Database/Performance/PerformanceAnalyzer.php index 7e0f653d..40b70861 100644 --- a/WebFiori/Database/Performance/PerformanceAnalyzer.php +++ b/WebFiori/Database/Performance/PerformanceAnalyzer.php @@ -1,120 +1,120 @@ -monitor = $monitor; - } - - /** - * Calculate the average execution time per query. - * - * @return float Average execution time in milliseconds, 0 if no metrics. - */ - public function getAverageTime(): float { - $metrics = $this->monitor->getMetrics(); - - if (empty($metrics)) { - return 0.0; - } - - return $this->getTotalTime() / count($metrics); - } - - /** - * Calculate query performance efficiency as percentage of fast queries. - * - * @return float Efficiency percentage (0-100). - */ - public function getEfficiency(): float { - $metrics = $this->monitor->getMetrics(); - - if (empty($metrics)) { - return 100.0; - } - - $slowCount = count($this->getSlowQueries()); - $fastCount = count($metrics) - $slowCount; - - return ($fastCount / count($metrics)) * 100; - } - - /** - * Get the total number of queries analyzed. - * - * @return int Total query count. - */ - public function getQueryCount(): int { - return count($this->monitor->getMetrics()); - } - - /** - * Get a performance score based on average execution time. - * - * @return string Performance score: SCORE_EXCELLENT, SCORE_GOOD, or SCORE_NEEDS_IMPROVEMENT. - */ - public function getScore(): string { - $avgTime = $this->getAverageTime(); - - if ($avgTime < 10) { - return self::SCORE_EXCELLENT; - } elseif ($avgTime < 50) { - return self::SCORE_GOOD; - } else { - return self::SCORE_NEEDS_IMPROVEMENT; - } - } - - /** - * Get all queries that exceed the slow query threshold. - * - * @return array Array of QueryMetric instances for slow queries. - */ - public function getSlowQueries(): array { - return $this->monitor->getSlowQueries(); - } - - /** - * Get the number of slow queries. - * - * @return int Slow query count. - */ - public function getSlowQueryCount(): int { - return count($this->getSlowQueries()); - } - - /** - * Calculate the total execution time of all queries. - * - * @return float Total execution time in milliseconds. - */ - public function getTotalTime(): float { - $total = 0.0; - - foreach ($this->monitor->getMetrics() as $metric) { - $total += $metric->getExecutionTimeMs(); - } - - return $total; - } -} +monitor = $monitor; + } + + /** + * Calculate the average execution time per query. + * + * @return float Average execution time in milliseconds, 0 if no metrics. + */ + public function getAverageTime(): float { + $metrics = $this->monitor->getMetrics(); + + if (empty($metrics)) { + return 0.0; + } + + return $this->getTotalTime() / count($metrics); + } + + /** + * Calculate query performance efficiency as percentage of fast queries. + * + * @return float Efficiency percentage (0-100). + */ + public function getEfficiency(): float { + $metrics = $this->monitor->getMetrics(); + + if (empty($metrics)) { + return 100.0; + } + + $slowCount = count($this->getSlowQueries()); + $fastCount = count($metrics) - $slowCount; + + return ($fastCount / count($metrics)) * 100; + } + + /** + * Get the total number of queries analyzed. + * + * @return int Total query count. + */ + public function getQueryCount(): int { + return count($this->monitor->getMetrics()); + } + + /** + * Get a performance score based on average execution time. + * + * @return string Performance score: SCORE_EXCELLENT, SCORE_GOOD, or SCORE_NEEDS_IMPROVEMENT. + */ + public function getScore(): string { + $avgTime = $this->getAverageTime(); + + if ($avgTime < 10) { + return self::SCORE_EXCELLENT; + } elseif ($avgTime < 50) { + return self::SCORE_GOOD; + } else { + return self::SCORE_NEEDS_IMPROVEMENT; + } + } + + /** + * Get all queries that exceed the slow query threshold. + * + * @return array Array of QueryMetric instances for slow queries. + */ + public function getSlowQueries(): array { + return $this->monitor->getSlowQueries(); + } + + /** + * Get the number of slow queries. + * + * @return int Slow query count. + */ + public function getSlowQueryCount(): int { + return count($this->getSlowQueries()); + } + + /** + * Calculate the total execution time of all queries. + * + * @return float Total execution time in milliseconds. + */ + public function getTotalTime(): float { + $total = 0.0; + + foreach ($this->monitor->getMetrics() as $metric) { + $total += $metric->getExecutionTimeMs(); + } + + return $total; + } +} diff --git a/WebFiori/Database/Performance/PerformanceOption.php b/WebFiori/Database/Performance/PerformanceOption.php index 9b4ec14d..2d4eea0e 100644 --- a/WebFiori/Database/Performance/PerformanceOption.php +++ b/WebFiori/Database/Performance/PerformanceOption.php @@ -1,117 +1,117 @@ -queryHash = $queryHash; - $this->queryType = $queryType; - $this->query = $query; - $this->executionTimeMs = $executionTimeMs; - $this->rowsAffected = $rowsAffected; - $this->memoryUsageMb = $memoryUsageMb; - $this->executedAt = $executedAt; - $this->databaseName = $databaseName; - } - - /** - * Get the database name. - * - * @return string Database name - */ - public function getDatabaseName(): string { - return $this->databaseName; - } - - /** - * Get the execution timestamp. - * - * @return float Unix timestamp with microseconds - */ - public function getExecutedAt(): float { - return $this->executedAt; - } - - /** - * Get the execution time in milliseconds. - * - * @return float Execution time with microsecond precision - */ - public function getExecutionTimeMs(): float { - return $this->executionTimeMs; - } - - /** - * Get the memory usage in megabytes. - * - * @return float Memory usage - */ - public function getMemoryUsageMb(): float { - return $this->memoryUsageMb; - } - - /** - * Get the actual SQL query that was executed. - * - * @return string The SQL query - */ - public function getQuery(): string { - return $this->query; - } - - /** - * Get the MD5 hash of the normalized query. - * - * @return string MD5 hash of the normalized query - */ - public function getQueryHash(): string { - return $this->queryHash; - } - - /** - * Get the query type. - * - * @return string Query type (SELECT, INSERT, UPDATE, DELETE) - */ - public function getQueryType(): string { - return $this->queryType; - } - - /** - * Get the number of rows affected or returned. - * - * @return int Row count - */ - public function getRowsAffected(): int { - return $this->rowsAffected; - } -} +queryHash = $queryHash; + $this->queryType = $queryType; + $this->query = $query; + $this->executionTimeMs = $executionTimeMs; + $this->rowsAffected = $rowsAffected; + $this->memoryUsageMb = $memoryUsageMb; + $this->executedAt = $executedAt; + $this->databaseName = $databaseName; + } + + /** + * Get the database name. + * + * @return string Database name + */ + public function getDatabaseName(): string { + return $this->databaseName; + } + + /** + * Get the execution timestamp. + * + * @return float Unix timestamp with microseconds + */ + public function getExecutedAt(): float { + return $this->executedAt; + } + + /** + * Get the execution time in milliseconds. + * + * @return float Execution time with microsecond precision + */ + public function getExecutionTimeMs(): float { + return $this->executionTimeMs; + } + + /** + * Get the memory usage in megabytes. + * + * @return float Memory usage + */ + public function getMemoryUsageMb(): float { + return $this->memoryUsageMb; + } + + /** + * Get the actual SQL query that was executed. + * + * @return string The SQL query + */ + public function getQuery(): string { + return $this->query; + } + + /** + * Get the MD5 hash of the normalized query. + * + * @return string MD5 hash of the normalized query + */ + public function getQueryHash(): string { + return $this->queryHash; + } + + /** + * Get the query type. + * + * @return string Query type (SELECT, INSERT, UPDATE, DELETE) + */ + public function getQueryType(): string { + return $this->queryType; + } + + /** + * Get the number of rows affected or returned. + * + * @return int Row count + */ + public function getRowsAffected(): int { + return $this->rowsAffected; + } +} diff --git a/WebFiori/Database/Performance/QueryPerformanceMonitor.php b/WebFiori/Database/Performance/QueryPerformanceMonitor.php index 0e2afbcb..4f8a4670 100644 --- a/WebFiori/Database/Performance/QueryPerformanceMonitor.php +++ b/WebFiori/Database/Performance/QueryPerformanceMonitor.php @@ -1,470 +1,470 @@ -config = array_merge($this->getDefaultConfig(), $config); - $this->database = $database; - $this->validateConfig(); - } - - /** - * Clear all stored metrics. - */ - public function clearMetrics(): void { - if ($this->config[PerformanceOption::STORAGE_TYPE] === PerformanceOption::STORAGE_MEMORY) { - $this->memoryMetrics = []; - } else { - $this->clearDatabaseMetrics(); - } - } - - /** - * Create a performance analyzer for the collected metrics. - * - * @return PerformanceAnalyzer Analyzer instance with current metrics and configuration. - */ - public function getAnalyzer(): PerformanceAnalyzer { - return new PerformanceAnalyzer($this); - } - - /** - * Get all performance metrics. - * - * @return array Array of QueryMetric instances or metric arrays - */ - public function getMetrics(): array { - if ($this->config[PerformanceOption::STORAGE_TYPE] === PerformanceOption::STORAGE_MEMORY) { - return $this->memoryMetrics; - } - - if (!$this->database) { - return []; - } - - try { - return $this->getMetricsFromDatabase(); - } catch (\Exception $e) { - // If table doesn't exist, return empty array - return []; - } - } - - /** - * Get slow queries based on configured threshold. - * - * @param int|null $thresholdMs Custom threshold in milliseconds - * @return array Array of slow query metrics - */ - public function getSlowQueries(?int $thresholdMs = null): array { - $threshold = $thresholdMs ?? $this->config[PerformanceOption::SLOW_QUERY_THRESHOLD]; - $metrics = $this->getMetrics(); - - return array_values(array_filter($metrics, function($metric) use ($threshold) - { - $executionTime = $metric instanceof QueryMetric - ? $metric->getExecutionTimeMs() - : $metric['execution_time_ms']; - - return $executionTime >= $threshold; - })); - } - - /** - * Get the configured slow query threshold. - * - * @return float Slow query threshold in milliseconds. - */ - public function getSlowQueryThreshold(): float { - return (float) $this->config[PerformanceOption::SLOW_QUERY_THRESHOLD]; - } - /** - * Get performance statistics summary. - * - * @return array Statistics including avg, min, max execution times - */ - public function getStatistics(): array { - $metrics = $this->getMetrics(); - - if (empty($metrics)) { - return [ - 'total_queries' => 0, - 'avg_execution_time' => 0, - 'min_execution_time' => 0, - 'max_execution_time' => 0, - 'slow_queries_count' => 0 - ]; - } - - $executionTimes = array_map(function($metric) - { - return $metric instanceof QueryMetric - ? $metric->getExecutionTimeMs() - : $metric['execution_time_ms']; - }, $metrics); - - return [ - 'total_queries' => count($metrics), - 'avg_execution_time' => array_sum($executionTimes) / count($executionTimes), - 'min_execution_time' => min($executionTimes), - 'max_execution_time' => max($executionTimes), - 'slow_queries_count' => count($this->getSlowQueries()) - ]; - } - - /** - * Record a query performance metric. - * - * @param string $query The SQL query that was executed - * @param float $executionTimeMs Execution time in milliseconds - * @param mixed $result Query result (for row count extraction) - */ - public function recordQuery(string $query, float $executionTimeMs, $result = null): void { - if (!$this->config[PerformanceOption::ENABLED]) { - return; - } - - if (!$this->shouldTrackQuery($query)) { - return; - } - - if (!$this->shouldSample()) { - return; - } - - $metric = $this->createMetric($query, $executionTimeMs, $result); - - if ($this->config[PerformanceOption::STORAGE_TYPE] === PerformanceOption::STORAGE_MEMORY) { - $this->storeInMemory($metric); - } else { - $this->storeInDatabase($metric); - } - - $this->performCleanup(); - } - - /** - * Update monitoring configuration. - * - * @param array $config New configuration options - */ - public function updateConfig(array $config): void { - $this->config = array_merge($this->config, $config); - $this->validateConfig(); - } - - /** - * Clean up old metrics from database. - */ - private function cleanupOldDatabaseMetrics(): void { - if (!$this->database) { - return; - } - - $cutoffTime = microtime(true) - ($this->config[PerformanceOption::RETENTION_HOURS] * 3600); - - $this->database->table('query_performance_metrics') - ->delete() - ->where('executed_at', $cutoffTime, '<') - ->execute(); - } - - /** - * Clear metrics from database. - */ - private function clearDatabaseMetrics(): void { - if (!$this->database) { - return; - } - - $this->database->table('query_performance_metrics') - ->delete() - ->execute(); - } - - /** - * Create a QueryMetric instance from query data. - * - * @param string $query SQL query - * @param float $executionTimeMs Execution time - * @param mixed $result Query result - * @return QueryMetric - */ - private function createMetric(string $query, float $executionTimeMs, $result): QueryMetric { - return new QueryMetric( - md5($query), - $this->getQueryType($query), - $query, - $executionTimeMs, - $this->getRowCount($result), - $this->getMemoryUsage(), - microtime(true), - $this->database ? $this->database->getName() : 'unknown' - ); - } - - /** - * Ensure performance metrics table exists. - */ - private function ensureSchemaExists(): void { - if ($this->schemaCreated || !$this->database) { - return; - } - - $this->database->createBlueprint('query_performance_metrics') - ->addColumns([ - 'id' => [ - ColOption::TYPE => DataType::INT, - ColOption::PRIMARY => true, - ColOption::AUTO_INCREMENT => true - ], - 'query_hash' => [ - ColOption::TYPE => DataType::VARCHAR, - ColOption::SIZE => 64 - ], - 'query_type' => [ - ColOption::TYPE => DataType::VARCHAR, - ColOption::SIZE => 20 - ], - 'execution_time_ms' => [ - ColOption::TYPE => DataType::DECIMAL, - ColOption::SIZE => 10, - ColOption::SCALE => 2 - ], - 'rows_affected' => [ - ColOption::TYPE => DataType::INT - ], - 'memory_usage_mb' => [ - ColOption::TYPE => DataType::DECIMAL, - ColOption::SIZE => '8,2' - ], - 'executed_at' => [ - ColOption::TYPE => DataType::DECIMAL, - ColOption::SIZE => '15,6' - ], - 'database_name' => [ - ColOption::TYPE => DataType::VARCHAR, - ColOption::SIZE => 64 - ] - ]); - - $this->database->createTable()->execute(); - $this->schemaCreated = true; - } - - /** - * Get default configuration values. - * - * @return array Default configuration - */ - private function getDefaultConfig(): array { - return [ - PerformanceOption::ENABLED => false, - PerformanceOption::SLOW_QUERY_THRESHOLD => 1000, - PerformanceOption::WARNING_THRESHOLD => 500, - PerformanceOption::SAMPLING_RATE => 1.0, - PerformanceOption::MAX_SAMPLES => 10000, - PerformanceOption::STORAGE_TYPE => PerformanceOption::STORAGE_MEMORY, - PerformanceOption::RETENTION_HOURS => 24, - PerformanceOption::AUTO_CLEANUP => true, - PerformanceOption::MEMORY_LIMIT_MB => 50, - PerformanceOption::TRACK_SELECT => true, - PerformanceOption::TRACK_INSERT => true, - PerformanceOption::TRACK_UPDATE => true, - PerformanceOption::TRACK_DELETE => true - ]; - } - - /** - * Get current memory usage in MB. - * - * @return float Memory usage in megabytes - */ - private function getMemoryUsage(): float { - return memory_get_usage(true) / 1024 / 1024; - } - - /** - * Get metrics from database. - * - * @return array - */ - private function getMetricsFromDatabase(): array { - if (!$this->database) { - return []; - } - - $result = $this->database->table('query_performance_metrics') - ->select() - ->execute(); - - return $result->getRows(); - } - - /** - * Extract query type from SQL. - * - * @param string $query SQL query - * @return string Query type (SELECT, INSERT, UPDATE, DELETE) - */ - private function getQueryType(string $query): string { - $query = trim(strtoupper($query)); - - if (str_starts_with($query, 'SELECT')) { - return 'SELECT'; - } - - if (str_starts_with($query, 'INSERT')) { - return 'INSERT'; - } - - if (str_starts_with($query, 'UPDATE')) { - return 'UPDATE'; - } - - if (str_starts_with($query, 'DELETE')) { - return 'DELETE'; - } - - return 'OTHER'; - } - - /** - * Extract row count from query result. - * - * @param mixed $result Query result - * @return int Row count - */ - private function getRowCount($result): int { - if ($result instanceof ResultSet) { - return $result->count(); - } - - return 0; - } - - /** - * Perform cleanup based on configuration. - */ - private function performCleanup(): void { - if (!$this->config[PerformanceOption::AUTO_CLEANUP]) { - return; - } - - if ($this->config[PerformanceOption::STORAGE_TYPE] === PerformanceOption::STORAGE_DATABASE) { - $this->cleanupOldDatabaseMetrics(); - } - } - - /** - * Check if current query should be sampled. - * - * @return bool True if query should be sampled - */ - private function shouldSample(): bool { - return mt_rand() / mt_getrandmax() <= $this->config[PerformanceOption::SAMPLING_RATE]; - } - - /** - * Check if query should be tracked based on type. - * - * @param string $query SQL query - * @return bool True if query should be tracked - */ - private function shouldTrackQuery(string $query): bool { - $queryType = $this->getQueryType($query); - - return match ($queryType) { - 'SELECT' => $this->config[PerformanceOption::TRACK_SELECT], - 'INSERT' => $this->config[PerformanceOption::TRACK_INSERT], - 'UPDATE' => $this->config[PerformanceOption::TRACK_UPDATE], - 'DELETE' => $this->config[PerformanceOption::TRACK_DELETE], - default => false - }; - } - - /** - * Store metric in database. - * - * @param QueryMetric $metric - */ - private function storeInDatabase(QueryMetric $metric): void { - if (!$this->database) { - return; - } - - $this->ensureSchemaExists(); - - $this->database->table('query_performance_metrics') - ->insert($metric->toArray()) - ->execute(); - } - - /** - * Store metric in memory. - * - * @param QueryMetric $metric - */ - private function storeInMemory(QueryMetric $metric): void { - $this->memoryMetrics[] = $metric; - - if (count($this->memoryMetrics) > $this->config[PerformanceOption::MAX_SAMPLES]) { - array_shift($this->memoryMetrics); - } - } - - /** - * Validate configuration values. - * - * @throws InvalidArgumentException If configuration is invalid - */ - private function validateConfig(): void { - if (!is_bool($this->config[PerformanceOption::ENABLED])) { - throw new InvalidArgumentException('ENABLED must be boolean'); - } - - if ($this->config[PerformanceOption::SAMPLING_RATE] < 0 || $this->config[PerformanceOption::SAMPLING_RATE] > 1) { - throw new InvalidArgumentException('SAMPLING_RATE must be between 0.0 and 1.0'); - } - - if (!in_array($this->config[PerformanceOption::STORAGE_TYPE], [ - PerformanceOption::STORAGE_MEMORY, - PerformanceOption::STORAGE_DATABASE - ])) { - throw new InvalidArgumentException('Invalid STORAGE_TYPE'); - } - } -} +config = array_merge($this->getDefaultConfig(), $config); + $this->database = $database; + $this->validateConfig(); + } + + /** + * Clear all stored metrics. + */ + public function clearMetrics(): void { + if ($this->config[PerformanceOption::STORAGE_TYPE] === PerformanceOption::STORAGE_MEMORY) { + $this->memoryMetrics = []; + } else { + $this->clearDatabaseMetrics(); + } + } + + /** + * Create a performance analyzer for the collected metrics. + * + * @return PerformanceAnalyzer Analyzer instance with current metrics and configuration. + */ + public function getAnalyzer(): PerformanceAnalyzer { + return new PerformanceAnalyzer($this); + } + + /** + * Get all performance metrics. + * + * @return array Array of QueryMetric instances or metric arrays + */ + public function getMetrics(): array { + if ($this->config[PerformanceOption::STORAGE_TYPE] === PerformanceOption::STORAGE_MEMORY) { + return $this->memoryMetrics; + } + + if (!$this->database) { + return []; + } + + try { + return $this->getMetricsFromDatabase(); + } catch (\Exception $e) { + // If table doesn't exist, return empty array + return []; + } + } + + /** + * Get slow queries based on configured threshold. + * + * @param int|null $thresholdMs Custom threshold in milliseconds + * @return array Array of slow query metrics + */ + public function getSlowQueries(?int $thresholdMs = null): array { + $threshold = $thresholdMs ?? $this->config[PerformanceOption::SLOW_QUERY_THRESHOLD]; + $metrics = $this->getMetrics(); + + return array_values(array_filter($metrics, function($metric) use ($threshold) + { + $executionTime = $metric instanceof QueryMetric + ? $metric->getExecutionTimeMs() + : $metric['execution_time_ms']; + + return $executionTime >= $threshold; + })); + } + + /** + * Get the configured slow query threshold. + * + * @return float Slow query threshold in milliseconds. + */ + public function getSlowQueryThreshold(): float { + return (float) $this->config[PerformanceOption::SLOW_QUERY_THRESHOLD]; + } + /** + * Get performance statistics summary. + * + * @return array Statistics including avg, min, max execution times + */ + public function getStatistics(): array { + $metrics = $this->getMetrics(); + + if (empty($metrics)) { + return [ + 'total_queries' => 0, + 'avg_execution_time' => 0, + 'min_execution_time' => 0, + 'max_execution_time' => 0, + 'slow_queries_count' => 0 + ]; + } + + $executionTimes = array_map(function($metric) + { + return $metric instanceof QueryMetric + ? $metric->getExecutionTimeMs() + : $metric['execution_time_ms']; + }, $metrics); + + return [ + 'total_queries' => count($metrics), + 'avg_execution_time' => array_sum($executionTimes) / count($executionTimes), + 'min_execution_time' => min($executionTimes), + 'max_execution_time' => max($executionTimes), + 'slow_queries_count' => count($this->getSlowQueries()) + ]; + } + + /** + * Record a query performance metric. + * + * @param string $query The SQL query that was executed + * @param float $executionTimeMs Execution time in milliseconds + * @param mixed $result Query result (for row count extraction) + */ + public function recordQuery(string $query, float $executionTimeMs, $result = null): void { + if (!$this->config[PerformanceOption::ENABLED]) { + return; + } + + if (!$this->shouldTrackQuery($query)) { + return; + } + + if (!$this->shouldSample()) { + return; + } + + $metric = $this->createMetric($query, $executionTimeMs, $result); + + if ($this->config[PerformanceOption::STORAGE_TYPE] === PerformanceOption::STORAGE_MEMORY) { + $this->storeInMemory($metric); + } else { + $this->storeInDatabase($metric); + } + + $this->performCleanup(); + } + + /** + * Update monitoring configuration. + * + * @param array $config New configuration options + */ + public function updateConfig(array $config): void { + $this->config = array_merge($this->config, $config); + $this->validateConfig(); + } + + /** + * Clean up old metrics from database. + */ + private function cleanupOldDatabaseMetrics(): void { + if (!$this->database) { + return; + } + + $cutoffTime = microtime(true) - ($this->config[PerformanceOption::RETENTION_HOURS] * 3600); + + $this->database->table('query_performance_metrics') + ->delete() + ->where('executed_at', $cutoffTime, '<') + ->execute(); + } + + /** + * Clear metrics from database. + */ + private function clearDatabaseMetrics(): void { + if (!$this->database) { + return; + } + + $this->database->table('query_performance_metrics') + ->delete() + ->execute(); + } + + /** + * Create a QueryMetric instance from query data. + * + * @param string $query SQL query + * @param float $executionTimeMs Execution time + * @param mixed $result Query result + * @return QueryMetric + */ + private function createMetric(string $query, float $executionTimeMs, $result): QueryMetric { + return new QueryMetric( + md5($query), + $this->getQueryType($query), + $query, + $executionTimeMs, + $this->getRowCount($result), + $this->getMemoryUsage(), + microtime(true), + $this->database ? $this->database->getName() : 'unknown' + ); + } + + /** + * Ensure performance metrics table exists. + */ + private function ensureSchemaExists(): void { + if ($this->schemaCreated || !$this->database) { + return; + } + + $this->database->createBlueprint('query_performance_metrics') + ->addColumns([ + 'id' => [ + ColOption::TYPE => DataType::INT, + ColOption::PRIMARY => true, + ColOption::AUTO_INCREMENT => true + ], + 'query_hash' => [ + ColOption::TYPE => DataType::VARCHAR, + ColOption::SIZE => 64 + ], + 'query_type' => [ + ColOption::TYPE => DataType::VARCHAR, + ColOption::SIZE => 20 + ], + 'execution_time_ms' => [ + ColOption::TYPE => DataType::DECIMAL, + ColOption::SIZE => 10, + ColOption::SCALE => 2 + ], + 'rows_affected' => [ + ColOption::TYPE => DataType::INT + ], + 'memory_usage_mb' => [ + ColOption::TYPE => DataType::DECIMAL, + ColOption::SIZE => '8,2' + ], + 'executed_at' => [ + ColOption::TYPE => DataType::DECIMAL, + ColOption::SIZE => '15,6' + ], + 'database_name' => [ + ColOption::TYPE => DataType::VARCHAR, + ColOption::SIZE => 64 + ] + ]); + + $this->database->createTable()->execute(); + $this->schemaCreated = true; + } + + /** + * Get default configuration values. + * + * @return array Default configuration + */ + private function getDefaultConfig(): array { + return [ + PerformanceOption::ENABLED => false, + PerformanceOption::SLOW_QUERY_THRESHOLD => 1000, + PerformanceOption::WARNING_THRESHOLD => 500, + PerformanceOption::SAMPLING_RATE => 1.0, + PerformanceOption::MAX_SAMPLES => 10000, + PerformanceOption::STORAGE_TYPE => PerformanceOption::STORAGE_MEMORY, + PerformanceOption::RETENTION_HOURS => 24, + PerformanceOption::AUTO_CLEANUP => true, + PerformanceOption::MEMORY_LIMIT_MB => 50, + PerformanceOption::TRACK_SELECT => true, + PerformanceOption::TRACK_INSERT => true, + PerformanceOption::TRACK_UPDATE => true, + PerformanceOption::TRACK_DELETE => true + ]; + } + + /** + * Get current memory usage in MB. + * + * @return float Memory usage in megabytes + */ + private function getMemoryUsage(): float { + return memory_get_usage(true) / 1024 / 1024; + } + + /** + * Get metrics from database. + * + * @return array + */ + private function getMetricsFromDatabase(): array { + if (!$this->database) { + return []; + } + + $result = $this->database->table('query_performance_metrics') + ->select() + ->execute(); + + return $result->getRows(); + } + + /** + * Extract query type from SQL. + * + * @param string $query SQL query + * @return string Query type (SELECT, INSERT, UPDATE, DELETE) + */ + private function getQueryType(string $query): string { + $query = trim(strtoupper($query)); + + if (str_starts_with($query, 'SELECT')) { + return 'SELECT'; + } + + if (str_starts_with($query, 'INSERT')) { + return 'INSERT'; + } + + if (str_starts_with($query, 'UPDATE')) { + return 'UPDATE'; + } + + if (str_starts_with($query, 'DELETE')) { + return 'DELETE'; + } + + return 'OTHER'; + } + + /** + * Extract row count from query result. + * + * @param mixed $result Query result + * @return int Row count + */ + private function getRowCount($result): int { + if ($result instanceof ResultSet) { + return $result->count(); + } + + return 0; + } + + /** + * Perform cleanup based on configuration. + */ + private function performCleanup(): void { + if (!$this->config[PerformanceOption::AUTO_CLEANUP]) { + return; + } + + if ($this->config[PerformanceOption::STORAGE_TYPE] === PerformanceOption::STORAGE_DATABASE) { + $this->cleanupOldDatabaseMetrics(); + } + } + + /** + * Check if current query should be sampled. + * + * @return bool True if query should be sampled + */ + private function shouldSample(): bool { + return mt_rand() / mt_getrandmax() <= $this->config[PerformanceOption::SAMPLING_RATE]; + } + + /** + * Check if query should be tracked based on type. + * + * @param string $query SQL query + * @return bool True if query should be tracked + */ + private function shouldTrackQuery(string $query): bool { + $queryType = $this->getQueryType($query); + + return match ($queryType) { + 'SELECT' => $this->config[PerformanceOption::TRACK_SELECT], + 'INSERT' => $this->config[PerformanceOption::TRACK_INSERT], + 'UPDATE' => $this->config[PerformanceOption::TRACK_UPDATE], + 'DELETE' => $this->config[PerformanceOption::TRACK_DELETE], + default => false + }; + } + + /** + * Store metric in database. + * + * @param QueryMetric $metric + */ + private function storeInDatabase(QueryMetric $metric): void { + if (!$this->database) { + return; + } + + $this->ensureSchemaExists(); + + $this->database->table('query_performance_metrics') + ->insert($metric->toArray()) + ->execute(); + } + + /** + * Store metric in memory. + * + * @param QueryMetric $metric + */ + private function storeInMemory(QueryMetric $metric): void { + $this->memoryMetrics[] = $metric; + + if (count($this->memoryMetrics) > $this->config[PerformanceOption::MAX_SAMPLES]) { + array_shift($this->memoryMetrics); + } + } + + /** + * Validate configuration values. + * + * @throws InvalidArgumentException If configuration is invalid + */ + private function validateConfig(): void { + if (!is_bool($this->config[PerformanceOption::ENABLED])) { + throw new InvalidArgumentException('ENABLED must be boolean'); + } + + if ($this->config[PerformanceOption::SAMPLING_RATE] < 0 || $this->config[PerformanceOption::SAMPLING_RATE] > 1) { + throw new InvalidArgumentException('SAMPLING_RATE must be between 0.0 and 1.0'); + } + + if (!in_array($this->config[PerformanceOption::STORAGE_TYPE], [ + PerformanceOption::STORAGE_MEMORY, + PerformanceOption::STORAGE_DATABASE + ])) { + throw new InvalidArgumentException('Invalid STORAGE_TYPE'); + } + } +} diff --git a/WebFiori/Database/Query/Condition.php b/WebFiori/Database/Query/Condition.php index cd9274b7..3a82a54d 100644 --- a/WebFiori/Database/Query/Condition.php +++ b/WebFiori/Database/Query/Condition.php @@ -11,8 +11,6 @@ */ namespace WebFiori\Database\Query; -use WebFiori\Database\Column; - /** * Represents a binary conditional statement for SQL WHERE clauses. * diff --git a/WebFiori/Database/Repository/AbstractRepository.php b/WebFiori/Database/Repository/AbstractRepository.php index cbf54e2d..2dd8c642 100644 --- a/WebFiori/Database/Repository/AbstractRepository.php +++ b/WebFiori/Database/Repository/AbstractRepository.php @@ -1,5 +1,4 @@ db = $db; } - - abstract protected function getTableName(): string; - abstract protected function toEntity(array $row): object; - abstract protected function toArray(object $entity): array; - abstract protected function getIdField(): string; - - /** @return T|null */ - public function findById(mixed $id): ?object { + + public function count(): int { $result = $this->db->table($this->getTableName()) - ->select() + ->selectCount(null, 'total') + ->execute(); + + return (int) $result->fetch()['total']; + } + + public function deleteAll(): void { + $this->db->table($this->getTableName()) + ->delete() + ->execute(); + } + + public function deleteById(mixed $id): void { + $this->db->table($this->getTableName()) + ->delete() ->where($this->getIdField(), $id) ->execute(); - - return $result->getCount() > 0 ? $this->toEntity($result->fetch()) : null; } - + /** @return T[] */ public function findAll(): array { $result = $this->db->table($this->getTableName()) ->select() ->execute(); - + return array_map(fn($row) => $this->toEntity($row), $result->fetchAll()); } - - public function count(): int { + + /** @return T|null */ + public function findById(mixed $id): ?object { $result = $this->db->table($this->getTableName()) - ->selectCount(null, 'total') + ->select() + ->where($this->getIdField(), $id) ->execute(); - - return (int) $result->fetch()['total']; + + return $result->getCount() > 0 ? $this->toEntity($result->fetch()) : null; } - + /** @return Page */ public function paginate(int $page = 1, int $perPage = 20, array $orderBy = []): Page { $page = max(1, $page); $offset = ($page - 1) * $perPage; - + $total = $this->count(); - + $query = $this->db->table($this->getTableName()) ->select() ->limit($perPage) ->offset($offset); - + if (!empty($orderBy)) { $query->orderBy($orderBy); } - + $result = $query->execute(); $items = array_map(fn($row) => $this->toEntity($row), $result->fetchAll()); - + return new Page($items, $page, $perPage, $total); } - + /** @return CursorPage */ public function paginateByCursor( ?string $cursor = null, @@ -81,42 +88,43 @@ public function paginateByCursor( ): CursorPage { $cursorField = $cursorField ?? $this->getIdField(); $operator = $direction === 'ASC' ? '>' : '<'; - + $query = $this->db->table($this->getTableName())->select(); - + if ($cursor !== null) { $cursorValue = base64_decode($cursor); $query->where($cursorField, $cursorValue, $operator); } - + $result = $query->orderBy([$cursorField => $direction]) ->limit($limit + 1) ->execute(); - + $rows = $result->fetchAll(); $hasMore = count($rows) > $limit; - + if ($hasMore) { array_pop($rows); } - + $items = array_map(fn($row) => $this->toEntity($row), $rows); - + $nextCursor = null; + if ($hasMore && !empty($rows)) { $lastRow = end($rows); $nextCursor = base64_encode((string) $lastRow[$cursorField]); } - + return new CursorPage($items, $nextCursor, null, $hasMore); } - + /** @param T $entity */ public function save(object $entity): void { $data = $this->toArray($entity); $id = $data[$this->getIdField()] ?? null; unset($data[$this->getIdField()]); - + if ($id === null) { $this->db->table($this->getTableName())->insert($data)->execute(); } else { @@ -126,25 +134,17 @@ public function save(object $entity): void { ->execute(); } } - - public function deleteById(mixed $id): void { - $this->db->table($this->getTableName()) - ->delete() - ->where($this->getIdField(), $id) - ->execute(); - } - - public function deleteAll(): void { - $this->db->table($this->getTableName()) - ->delete() - ->execute(); - } - + protected function createQuery(): \WebFiori\Database\AbstractQuery { return $this->db->table($this->getTableName())->select(); } - + protected function getDatabase(): Database { return $this->db; } + abstract protected function getIdField(): string; + + abstract protected function getTableName(): string; + abstract protected function toArray(object $entity): array; + abstract protected function toEntity(array $row): object; } diff --git a/WebFiori/Database/Repository/CursorPage.php b/WebFiori/Database/Repository/CursorPage.php index 52925e14..fe41724e 100644 --- a/WebFiori/Database/Repository/CursorPage.php +++ b/WebFiori/Database/Repository/CursorPage.php @@ -1,5 +1,4 @@ previousCursor = $previousCursor; $this->hasMore = $hasMore; } - + /** @return T[] */ public function getItems(): array { return $this->items; } - + public function getNextCursor(): ?string { return $this->nextCursor; } - + public function getPreviousCursor(): ?string { return $this->previousCursor; } - + public function hasMore(): bool { return $this->hasMore; } diff --git a/WebFiori/Database/Repository/Page.php b/WebFiori/Database/Repository/Page.php index de93e795..9e867a20 100644 --- a/WebFiori/Database/Repository/Page.php +++ b/WebFiori/Database/Repository/Page.php @@ -1,5 +1,4 @@ perPage = $perPage; $this->totalItems = $totalItems; } - + + public function getCurrentPage(): int { + return $this->currentPage; + } + /** @return T[] */ public function getItems(): array { return $this->items; } - - public function getCurrentPage(): int { - return $this->currentPage; + + public function getNextPage(): ?int { + return $this->hasNextPage() ? $this->currentPage + 1 : null; } - + public function getPerPage(): int { return $this->perPage; } - + + public function getPreviousPage(): ?int { + return $this->hasPreviousPage() ? $this->currentPage - 1 : null; + } + public function getTotalItems(): int { return $this->totalItems; } - + public function getTotalPages(): int { return (int) ceil($this->totalItems / $this->perPage); } - + public function hasNextPage(): bool { return $this->currentPage < $this->getTotalPages(); } - + public function hasPreviousPage(): bool { return $this->currentPage > 1; } - - public function getNextPage(): ?int { - return $this->hasNextPage() ? $this->currentPage + 1 : null; - } - - public function getPreviousPage(): ?int { - return $this->hasPreviousPage() ? $this->currentPage - 1 : null; - } } diff --git a/WebFiori/Database/ResultSet.php b/WebFiori/Database/ResultSet.php index 3d1b75a8..6268f5ce 100644 --- a/WebFiori/Database/ResultSet.php +++ b/WebFiori/Database/ResultSet.php @@ -51,18 +51,6 @@ class ResultSet implements Countable, Iterator { public function __construct(array $resultArr = []) { $this->setData($resultArr); } - public function fetchAll() : array { - return $this->getRows(); - } - public function getCount() : int { - return $this->getRowsCount(); - } - public function fetch() : array { - if ($this->getCount() > 0) { - return $this->fetchAll()[0]; - } - return []; - } /** * Reset the values in the set to default values. * @@ -95,6 +83,16 @@ public function count() : int { public function current() { return $this->getRows()[$this->cursorPos]; } + public function fetch() : array { + if ($this->getCount() > 0) { + return $this->fetchAll()[0]; + } + + return []; + } + public function fetchAll() : array { + return $this->getRows(); + } /** * Filter the records of the result set using a custom callback. * @@ -128,6 +126,9 @@ public function filter(callable $filterFunction, array $mapArgs = []) : ResultSe return new ResultSet($result); } + public function getCount() : int { + return $this->getRowsCount(); + } /** * Returns an array which contains all original records in the set before * mapping. diff --git a/WebFiori/Database/Schema/AbstractMigration.php b/WebFiori/Database/Schema/AbstractMigration.php index d185e1b5..6e05a0fe 100644 --- a/WebFiori/Database/Schema/AbstractMigration.php +++ b/WebFiori/Database/Schema/AbstractMigration.php @@ -1,4 +1,5 @@ appliedAt; } + /** + * Get the batch number when this change was applied. + * + * @return int The batch number, or 0 if not yet applied. + */ + public function getBatch(): int { + return $this->batch; + } + /** * Get the list of changes this change depends on. * @@ -82,31 +92,6 @@ public function getEnvironments(): array { return []; } - /** - * Determine if this change should be wrapped in a database transaction. - * - * Override this method to control transaction behavior. By default, - * changes are wrapped in transactions for safety. - * - * Guidelines: - * - Return true for DML operations (INSERT, UPDATE, DELETE) - always safe - * - Return true for DDL on MSSQL/PostgreSQL - they support transactional DDL - * - Return false for DDL on MySQL - it auto-commits and can't be rolled back - * - * For DBMS-aware behavior, override and check the database type: - * ```php - * public function useTransaction(Database $db): bool { - * return $db->getConnectionInfo()->getDatabaseType() !== 'mysql'; - * } - * ``` - * - * @param Database $db The database instance (for DBMS-aware decisions). - * @return bool True to wrap in transaction, false to execute directly. - */ - public function useTransaction(Database $db): bool { - return true; - } - /** * Get the unique identifier for this database change. * @@ -155,6 +140,15 @@ abstract public function rollback(Database $db): void; public function setAppliedAt(string $date) { $this->appliedAt = $date; } + + /** + * Set the batch number for this change. + * + * @param int $batch The batch number. + */ + public function setBatch(int $batch): void { + $this->batch = $batch; + } /** * Set the unique identifier for this database change. * @@ -165,20 +159,27 @@ public function setId(int $id) { } /** - * Get the batch number when this change was applied. + * Determine if this change should be wrapped in a database transaction. * - * @return int The batch number, or 0 if not yet applied. - */ - public function getBatch(): int { - return $this->batch; - } - - /** - * Set the batch number for this change. + * Override this method to control transaction behavior. By default, + * changes are wrapped in transactions for safety. * - * @param int $batch The batch number. + * Guidelines: + * - Return true for DML operations (INSERT, UPDATE, DELETE) - always safe + * - Return true for DDL on MSSQL/PostgreSQL - they support transactional DDL + * - Return false for DDL on MySQL - it auto-commits and can't be rolled back + * + * For DBMS-aware behavior, override and check the database type: + * ```php + * public function useTransaction(Database $db): bool { + * return $db->getConnectionInfo()->getDatabaseType() !== 'mysql'; + * } + * ``` + * + * @param Database $db The database instance (for DBMS-aware decisions). + * @return bool True to wrap in transaction, false to execute directly. */ - public function setBatch(int $batch): void { - $this->batch = $batch; + public function useTransaction(Database $db): bool { + return true; } } diff --git a/WebFiori/Database/Schema/DatabaseChangeGenerator.php b/WebFiori/Database/Schema/DatabaseChangeGenerator.php index 7cd249ad..6f0abf07 100644 --- a/WebFiori/Database/Schema/DatabaseChangeGenerator.php +++ b/WebFiori/Database/Schema/DatabaseChangeGenerator.php @@ -1,4 +1,5 @@ path = rtrim($path, DIRECTORY_SEPARATOR); - return $this; - } + public function createMigration(string $name, array $options = []): string { + $dependencies = $options[GeneratorOption::DEPENDENCIES] ?? []; + $table = $options[GeneratorOption::TABLE] ?? null; - /** - * Get the configured path. - */ - public function getPath(): string { - return $this->path; + $content = $this->buildMigrationContent($name, $dependencies, $table); + + return $this->writeFile($name, $content); } /** - * Set the namespace for generated classes. + * Create a seeder class file. * - * @param string $namespace The PHP namespace. + * @param string $name The class name (e.g., 'UsersSeeder'). + * @param array $options Optional settings: + * - GeneratorOption::ENVIRONMENTS: array of environments where seeder should run + * - GeneratorOption::DEPENDENCIES: array of class names this seeder depends on + * @return string The full path to the created file. */ - public function setNamespace(string $namespace): self { - $this->namespace = trim($namespace, '\\'); - return $this; + public function createSeeder(string $name, array $options = []): string { + $environments = $options[GeneratorOption::ENVIRONMENTS] ?? []; + $dependencies = $options[GeneratorOption::DEPENDENCIES] ?? []; + + $content = $this->buildSeederContent($name, $environments, $dependencies); + + return $this->writeFile($name, $content); } /** @@ -56,15 +66,10 @@ public function getNamespace(): string { } /** - * Enable or disable timestamp prefix in filenames. - * - * When enabled, files are named like: 2026_01_04_175000_CreateUsersTable.php - * - * @param bool $use True to enable timestamp prefix. + * Get the configured path. */ - public function useTimestampPrefix(bool $use): self { - $this->useTimestamp = $use; - return $this; + public function getPath(): string { + return $this->path; } /** @@ -75,68 +80,71 @@ public function isTimestampPrefixEnabled(): bool { } /** - * Create a migration class file. + * Set the namespace for generated classes. * - * @param string $name The class name (e.g., 'CreateUsersTable'). - * @param array $options Optional settings: - * - GeneratorOption::DEPENDENCIES: array of class names this migration depends on - * - GeneratorOption::TABLE: table name hint for comments - * @return string The full path to the created file. + * @param string $namespace The PHP namespace. */ - public function createMigration(string $name, array $options = []): string { - $dependencies = $options[GeneratorOption::DEPENDENCIES] ?? []; - $table = $options[GeneratorOption::TABLE] ?? null; + public function setNamespace(string $namespace): self { + $this->namespace = trim($namespace, '\\'); - $content = $this->buildMigrationContent($name, $dependencies, $table); - return $this->writeFile($name, $content); + return $this; } /** - * Create a seeder class file. + * Set the directory path where generated files will be saved. * - * @param string $name The class name (e.g., 'UsersSeeder'). - * @param array $options Optional settings: - * - GeneratorOption::ENVIRONMENTS: array of environments where seeder should run - * - GeneratorOption::DEPENDENCIES: array of class names this seeder depends on - * @return string The full path to the created file. + * @param string $path Absolute path to the directory. */ - public function createSeeder(string $name, array $options = []): string { - $environments = $options[GeneratorOption::ENVIRONMENTS] ?? []; - $dependencies = $options[GeneratorOption::DEPENDENCIES] ?? []; + public function setPath(string $path): self { + $this->path = rtrim($path, DIRECTORY_SEPARATOR); - $content = $this->buildSeederContent($name, $environments, $dependencies); - return $this->writeFile($name, $content); + return $this; + } + + /** + * Enable or disable timestamp prefix in filenames. + * + * When enabled, files are named like: 2026_01_04_175000_CreateUsersTable.php + * + * @param bool $use True to enable timestamp prefix. + */ + public function useTimestampPrefix(bool $use): self { + $this->useTimestamp = $use; + + return $this; } private function buildMigrationContent(string $name, array $dependencies, ?string $table): string { $lines = []; $lines[] = 'namespace) { - $lines[] = 'namespace ' . $this->namespace . ';'; + $lines[] = 'namespace '.$this->namespace.';'; $lines[] = ''; } - + $lines[] = 'use WebFiori\Database\Schema\AbstractMigration;'; $lines[] = 'use WebFiori\Database\Database;'; $lines[] = ''; $lines[] = "class {$name} extends AbstractMigration {"; - + // Add getDependencies if specified if (!empty($dependencies)) { $lines[] = ''; $lines[] = ' public function getDependencies(): array {'; $lines[] = ' return ['; + foreach ($dependencies as $dep) { - $lines[] = ' ' . $this->formatDependency($dep) . ','; + $lines[] = ' '.$this->formatDependency($dep).','; } $lines[] = ' ];'; $lines[] = ' }'; } - + $lines[] = ''; $lines[] = ' public function up(Database $db): void {'; + if ($table) { $lines[] = " // TODO: Create or modify table '{$table}'"; } else { @@ -145,6 +153,7 @@ private function buildMigrationContent(string $name, array $dependencies, ?strin $lines[] = ' }'; $lines[] = ''; $lines[] = ' public function down(Database $db): void {'; + if ($table) { $lines[] = " // TODO: Reverse changes to table '{$table}'"; } else { @@ -161,37 +170,38 @@ private function buildSeederContent(string $name, array $environments, array $de $lines = []; $lines[] = 'namespace) { - $lines[] = 'namespace ' . $this->namespace . ';'; + $lines[] = 'namespace '.$this->namespace.';'; $lines[] = ''; } - + $lines[] = 'use WebFiori\Database\Schema\AbstractSeeder;'; $lines[] = 'use WebFiori\Database\Database;'; $lines[] = ''; $lines[] = "class {$name} extends AbstractSeeder {"; - + // Add getEnvironments if specified if (!empty($environments)) { $lines[] = ''; $lines[] = ' public function getEnvironments(): array {'; - $lines[] = ' return [' . $this->formatStringArray($environments) . '];'; + $lines[] = ' return ['.$this->formatStringArray($environments).'];'; $lines[] = ' }'; } - + // Add getDependencies if specified if (!empty($dependencies)) { $lines[] = ''; $lines[] = ' public function getDependencies(): array {'; $lines[] = ' return ['; + foreach ($dependencies as $dep) { - $lines[] = ' ' . $this->formatDependency($dep) . ','; + $lines[] = ' '.$this->formatDependency($dep).','; } $lines[] = ' ];'; $lines[] = ' }'; } - + $lines[] = ''; $lines[] = ' public function run(Database $db): void {'; $lines[] = ' // TODO: Implement seeder'; @@ -205,18 +215,21 @@ private function buildSeederContent(string $name, array $environments, array $de private function formatDependency(string $dep): string { // If it looks like a fully qualified class name, use ::class syntax if (str_contains($dep, '\\')) { - return $dep . '::class'; + return $dep.'::class'; } + // If it's a simple class name, also use ::class syntax if (preg_match('/^[A-Z][a-zA-Z0-9_]*$/', $dep)) { - return $dep . '::class'; + return $dep.'::class'; } + // Otherwise treat as string - return "'" . $dep . "'"; + return "'".$dep."'"; } private function formatStringArray(array $items): string { $formatted = array_map(fn($item) => "'{$item}'", $items); + return implode(', ', $formatted); } @@ -230,10 +243,10 @@ private function writeFile(string $name, string $content): string { } $filename = $this->useTimestamp - ? date('Y_m_d_His') . '_' . $name . '.php' - : $name . '.php'; + ? date('Y_m_d_His').'_'.$name.'.php' + : $name.'.php'; - $fullPath = $this->path . DIRECTORY_SEPARATOR . $filename; + $fullPath = $this->path.DIRECTORY_SEPARATOR.$filename; file_put_contents($fullPath, $content); return $fullPath; diff --git a/WebFiori/Database/Schema/DatabaseChangeResult.php b/WebFiori/Database/Schema/DatabaseChangeResult.php index 56f6ff59..aa1fe2f6 100644 --- a/WebFiori/Database/Schema/DatabaseChangeResult.php +++ b/WebFiori/Database/Schema/DatabaseChangeResult.php @@ -1,4 +1,5 @@ Changes that were successfully applied */ private array $applied = []; - + /** - * @var array Changes that were skipped + * @var ConnectionInfo|null Connection info for the database changes were applied to */ - private array $skipped = []; - + private ?ConnectionInfo $connectionInfo = null; + /** * @var array Changes that failed */ private array $failed = []; - + /** - * @var float Total execution time in milliseconds + * @var array Changes that were skipped */ - private float $totalTimeMs = 0; + private array $skipped = []; /** - * @var ConnectionInfo|null Connection info for the database changes were applied to + * @var float Total execution time in milliseconds */ - private ?ConnectionInfo $connectionInfo = null; + private float $totalTimeMs = 0; /** * Add an applied change. @@ -56,6 +57,13 @@ public function addApplied(DatabaseChange $change): void { $this->applied[] = $change; } + /** + * Add a failed change with error. + */ + public function addFailed(DatabaseChange $change, \Throwable $error): void { + $this->failed[] = ['change' => $change, 'error' => $error]; + } + /** * Add a skipped change with reason. */ @@ -64,10 +72,10 @@ public function addSkipped(DatabaseChange $change, string $reason): void { } /** - * Add a failed change with error. + * Get count of applied changes (Countable interface). */ - public function addFailed(DatabaseChange $change, \Throwable $error): void { - $this->failed[] = ['change' => $change, 'error' => $error]; + public function count(): int { + return count($this->applied); } /** @@ -80,12 +88,17 @@ public function getApplied(): array { } /** - * Get all skipped changes with reasons. - * - * @return array + * Get the connection info for the database changes were applied to. */ - public function getSkipped(): array { - return $this->skipped; + public function getConnectionInfo(): ?ConnectionInfo { + return $this->connectionInfo; + } + + /** + * Get the database name changes were applied to. + */ + public function getDatabaseName(): ?string { + return $this->connectionInfo?->getDBName(); } /** @@ -98,10 +111,19 @@ public function getFailed(): array { } /** - * Set total execution time. + * Iterate over applied changes (IteratorAggregate interface). */ - public function setTotalTime(float $timeMs): void { - $this->totalTimeMs = $timeMs; + public function getIterator(): Traversable { + return new ArrayIterator($this->applied); + } + + /** + * Get all skipped changes with reasons. + * + * @return array + */ + public function getSkipped(): array { + return $this->skipped; } /** @@ -126,30 +148,9 @@ public function setConnectionInfo(ConnectionInfo $connectionInfo): void { } /** - * Get the connection info for the database changes were applied to. - */ - public function getConnectionInfo(): ?ConnectionInfo { - return $this->connectionInfo; - } - - /** - * Get the database name changes were applied to. - */ - public function getDatabaseName(): ?string { - return $this->connectionInfo?->getDBName(); - } - - /** - * Get count of applied changes (Countable interface). - */ - public function count(): int { - return count($this->applied); - } - - /** - * Iterate over applied changes (IteratorAggregate interface). + * Set total execution time. */ - public function getIterator(): Traversable { - return new ArrayIterator($this->applied); + public function setTotalTime(float $timeMs): void { + $this->totalTimeMs = $timeMs; } } diff --git a/WebFiori/Database/Schema/GeneratorOption.php b/WebFiori/Database/Schema/GeneratorOption.php index 2961f080..73828f3c 100644 --- a/WebFiori/Database/Schema/GeneratorOption.php +++ b/WebFiori/Database/Schema/GeneratorOption.php @@ -1,4 +1,5 @@ deleteAll(); } - + /** - * Check if a change has been applied. + * Count applied changes. * - * @param string $changeName The fully qualified class name of the change - * @return bool True if the change has been applied, false otherwise + * @param array $conditions Optional conditions (e.g., ['type' => 'migration']) + * @return int Number of applied changes */ - public function isApplied(string $changeName): bool { - return $this->count(['change_name' => $changeName]) > 0; + public function count(array $conditions = []): int { + $query = $this->getDatabase()->table($this->getTableName())->select(); + + foreach ($conditions as $col => $val) { + $query->where($col, $val); + } + + return $query->execute()->getRowsCount(); } - + /** - * Record a change as applied. + * Get all applied changes. * - * @param DatabaseChange $change The change to record (must have batch set via setBatch()) - * @return int The ID of the inserted record + * @return array Array of change records */ - public function recordChange(DatabaseChange $change): int { - $this->getDatabase()->table($this->getTableName()) - ->insert([ - 'change_name' => $change->getName(), - 'type' => $change->getType(), - 'applied-on' => date('Y-m-d H:i:s'), - 'db-name' => $this->getDatabase()->getConnectionInfo()->getDBName(), - 'batch' => $change->getBatch() - ])->execute(); - - return $this->getLastInsertId(); + public function getAllApplied(): array { + return $this->getDatabase()->table($this->getTableName()) + ->select() + ->execute() + ->getRows(); } /** - * Get the next batch number. + * Get all applied migrations. * - * @return int The next batch number + * @return array Array of migration records */ - public function getNextBatchNumber(): int { - $result = $this->getDatabase()->table($this->getTableName()) - ->select(['batch']) - ->orderBy(['batch' => 'd']) - ->limit(1) - ->execute(); - - if ($result->getRowsCount() === 0) { - return 1; - } - - return (int) $result->getRows()[0]['batch'] + 1; + public function getAllMigrations(): array { + return $this->getByType('migration'); } /** - * Get the last batch number. + * Get all applied seeders. * - * @return int The last batch number, or 0 if no batches exist + * @return array Array of seeder records */ - public function getLastBatchNumber(): int { - return $this->getNextBatchNumber() - 1; + public function getAllSeeders(): array { + return $this->getByType('seeder'); } /** @@ -145,6 +103,20 @@ public function getByBatch(int $batch): array { ->getRows(); } + /** + * Get applied changes by type. + * + * @param string $type Either 'migration' or 'seeder' + * @return array Array of change records + */ + public function getByType(string $type): array { + return $this->getDatabase()->table($this->getTableName()) + ->select() + ->where('type', $type) + ->execute() + ->getRows(); + } + /** * Get change names from the last batch. * @@ -152,97 +124,96 @@ public function getByBatch(int $batch): array { */ public function getLastBatchChangeNames(): array { $lastBatch = $this->getLastBatchNumber(); + if ($lastBatch === 0) { return []; } - + $records = $this->getByBatch($lastBatch); + return array_column($records, 'change_name'); } - - /** - * Remove a change record. - * - * @param string $changeName The fully qualified class name of the change - * @return int Number of records deleted - */ - public function removeChange(string $changeName): int { - return $this->getDatabase()->table($this->getTableName()) - ->delete() - ->where('change_name', $changeName) - ->execute() - ->getRowsCount(); - } - + /** - * Get all applied changes. + * Get the last batch number. * - * @return array Array of change records + * @return int The last batch number, or 0 if no batches exist */ - public function getAllApplied(): array { - return $this->getDatabase()->table($this->getTableName()) - ->select() - ->execute() - ->getRows(); + public function getLastBatchNumber(): int { + return $this->getNextBatchNumber() - 1; } - + /** - * Get applied changes by type. + * Get the next batch number. * - * @param string $type Either 'migration' or 'seeder' - * @return array Array of change records + * @return int The next batch number */ - public function getByType(string $type): array { - return $this->getDatabase()->table($this->getTableName()) - ->select() - ->where('type', $type) - ->execute() - ->getRows(); + public function getNextBatchNumber(): int { + $result = $this->getDatabase()->table($this->getTableName()) + ->select(['batch']) + ->orderBy(['batch' => 'd']) + ->limit(1) + ->execute(); + + if ($result->getRowsCount() === 0) { + return 1; + } + + return (int) $result->getRows()[0]['batch'] + 1; } - + /** - * Get all applied migrations. + * Get the table name. * - * @return array Array of migration records + * @return string The table name */ - public function getAllMigrations(): array { - return $this->getByType('migration'); + public function getTableName(): string { + return 'schema_changes'; } - + /** - * Get all applied seeders. + * Check if a change has been applied. * - * @return array Array of seeder records + * @param string $changeName The fully qualified class name of the change + * @return bool True if the change has been applied, false otherwise */ - public function getAllSeeders(): array { - return $this->getByType('seeder'); + public function isApplied(string $changeName): bool { + return $this->count(['change_name' => $changeName]) > 0; } - + /** - * Count applied changes. + * Record a change as applied. * - * @param array $conditions Optional conditions (e.g., ['type' => 'migration']) - * @return int Number of applied changes + * @param DatabaseChange $change The change to record (must have batch set via setBatch()) + * @return int The ID of the inserted record */ - public function count(array $conditions = []): int { - $query = $this->getDatabase()->table($this->getTableName())->select(); - - foreach ($conditions as $col => $val) { - $query->where($col, $val); - } - - return $query->execute()->getRowsCount(); + public function recordChange(DatabaseChange $change): int { + $this->getDatabase()->table($this->getTableName()) + ->insert([ + 'change_name' => $change->getName(), + 'type' => $change->getType(), + 'applied-on' => date('Y-m-d H:i:s'), + 'db-name' => $this->getDatabase()->getConnectionInfo()->getDBName(), + 'batch' => $change->getBatch() + ])->execute(); + + return $this->getLastInsertId(); } - + /** - * Clear all change records (use with caution). + * Remove a change record. * + * @param string $changeName The fully qualified class name of the change * @return int Number of records deleted */ - public function clearAll(): int { - return $this->deleteAll(); + public function removeChange(string $changeName): int { + return $this->getDatabase()->table($this->getTableName()) + ->delete() + ->where('change_name', $changeName) + ->execute() + ->getRowsCount(); } - + /** * Get the last insert ID from the database connection. * @@ -255,4 +226,34 @@ private function getLastInsertId(): int { ->execute() ->getRows()[0]['max']; } + + /** + * Get the ID field name. + * + * @return string The ID field name + */ + protected function getIdField(): string { + return 'id'; + } + + /** + * Convert an entity to an array (not used for schema changes). + * + * @param object $entity The entity + * @return array The array representation + */ + protected function toArray(object $entity): array { + return (array) $entity; + } + + /** + * Convert a database record to an entity (not used for schema changes). + * + * @param array $row The database record + * @return object The entity + */ + protected function toEntity(array $row): object { + // Schema changes are not converted to entities, return stdClass + return (object) $row; + } } diff --git a/WebFiori/Database/Schema/SchemaMigrationsTable.php b/WebFiori/Database/Schema/SchemaMigrationsTable.php index c0dcbc3c..b93fe3bc 100644 --- a/WebFiori/Database/Schema/SchemaMigrationsTable.php +++ b/WebFiori/Database/Schema/SchemaMigrationsTable.php @@ -11,8 +11,8 @@ */ namespace WebFiori\Database\Schema; -use WebFiori\Database\Attributes\Table; use WebFiori\Database\Attributes\Column; +use WebFiori\Database\Attributes\Table; use WebFiori\Database\DataType; /** @@ -59,4 +59,5 @@ default: 1, comment: 'The batch number when this change was applied.' )] -class SchemaMigrationsTable {} +class SchemaMigrationsTable { +} diff --git a/WebFiori/Database/Schema/SchemaRunner.php b/WebFiori/Database/Schema/SchemaRunner.php index 63679550..fd1fb2c9 100644 --- a/WebFiori/Database/Schema/SchemaRunner.php +++ b/WebFiori/Database/Schema/SchemaRunner.php @@ -1,4 +1,5 @@ environment = $environment; $this->onErrCallbacks = []; $this->onRegErrCallbacks = []; - + $table = AttributeTableBuilder::build( SchemaMigrationsTable::class, $this->getConnectionInfo()->getDatabaseType() ); - + // Handle MSSQL datetime2 type if ($this->getConnectionInfo()->getDatabaseType() === ConnectionInfo::SUPPORTED_DATABASES[1]) { $table->getColByKey('applied-on')->setDatatype(DataType::DATETIME2); } - + $this->addTable($table); $this->repository = new SchemaChangeRepository($this); @@ -140,7 +140,7 @@ public function apply(): DatabaseChangeResult { foreach ($this->dbChanges as $change) { $name = $change->getName(); - + if (isset($processed[$name])) { continue; } @@ -171,6 +171,7 @@ public function apply(): DatabaseChangeResult { } catch (\Throwable $ex) { $result->addFailed($change, $ex); $processed[$name] = true; + foreach ($this->onErrCallbacks as $callback) { call_user_func_array($callback, [$ex, $change, $this]); } @@ -186,6 +187,7 @@ public function apply(): DatabaseChangeResult { } $result->setTotalTime((microtime(true) - $startTime) * 1000); + return $result; } @@ -199,7 +201,7 @@ public function apply(): DatabaseChangeResult { public function applyOne(): ?DatabaseChange { $change = null; $batch = $this->getRepository()->getNextBatchNumber(); - + try { foreach ($this->dbChanges as $change) { if ($this->isApplied($change->getName())) { @@ -235,24 +237,6 @@ public function applyOne(): ?DatabaseChange { return null; } - /** - * Execute a database change, optionally wrapped in a transaction. - * - * This method checks the change's useTransaction() method to determine - * whether to wrap the execution in a database transaction. - * - * @param DatabaseChange $change The change to execute. - */ - protected function executeChange(DatabaseChange $change): void { - if ($change->useTransaction($this)) { - $this->transaction(function (Database $db) use ($change) { - $change->execute($db); - }); - } else { - $change->execute($this); - } - } - /** * Remove all registered execution error callbacks. */ @@ -279,6 +263,43 @@ public function createSchemaTable() { $this->execute(); } + /** + * Discover and register database changes from a directory. + * + * Scans the specified directory for PHP files containing classes that extend + * DatabaseChange (migrations and seeders). Each discovered class is automatically + * registered with the schema runner. + * + * @param string $path Absolute path to the directory containing migration/seeder files. + * @param string $namespace The PHP namespace for classes in the directory. + * @param bool $recursive Whether to scan subdirectories recursively. Default is false. + * @return int Number of changes discovered and registered. + */ + public function discoverFromPath(string $path, string $namespace, bool $recursive = false): int { + $count = 0; + + if (!is_dir($path)) { + return $count; + } + + $namespace = rtrim($namespace, '\\'); + $iterator = $recursive + ? new \RecursiveIteratorIterator(new \RecursiveDirectoryIterator($path, \RecursiveDirectoryIterator::SKIP_DOTS)) + : new \DirectoryIterator($path); + + foreach ($iterator as $file) { + if ($file->isFile() && $file->getExtension() === 'php') { + $className = $this->resolveClassName($file, $path, $namespace, $recursive); + + if ($className !== null && $this->register($className)) { + $count++; + } + } + } + + return $count; + } + /** * Drop the schema tracking table from the database. * @@ -310,28 +331,6 @@ public function getChanges(): array { public function getEnvironment(): string { return $this->environment; } - - /** - * Check if a database change exists in the discovered changes. - * - * @param string $name The class name of the change to check. - * @return bool True if the change exists, false otherwise. - */ - public function hasChange(string $name): bool { - return $this->findChangeByName($name) !== null; - } - - /** - * Check if a specific database change has been applied. - * - * @param string $name The class name of the change to check. - * @return bool True if the change has been applied, false otherwise. - */ - public function isApplied(string $name): bool { - return $this->getRepository()->count([ - 'change_name' => $name - ]) == 1; - } /** * Get pending database changes that would be applied. * @@ -347,18 +346,18 @@ public function isApplied(string $name): bool { */ public function getPendingChanges(bool $withQueries = false): array { $pending = []; - + foreach ($this->dbChanges as $change) { if ($this->isApplied($change->getName())) { continue; } - + if (!$this->shouldRunInEnvironment($change)) { continue; } - + $info = ['change' => $change, 'queries' => []]; - + if ($withQueries) { $this->setDryRun(true); try { @@ -369,10 +368,10 @@ public function getPendingChanges(bool $withQueries = false): array { } $this->setDryRun(false); } - + $pending[] = $info; } - + return $pending; } @@ -386,16 +385,77 @@ public function getRepository(): SchemaChangeRepository { } /** - * Rollback all changes from the last batch. + * Check if a database change exists in the discovered changes. * - * @return array Array of rolled back DatabaseChange instances. + * @param string $name The class name of the change to check. + * @return bool True if the change exists, false otherwise. */ - public function rollbackLastBatch(): array { - $lastBatch = $this->getRepository()->getLastBatchNumber(); - if ($lastBatch === 0) { - return []; + public function hasChange(string $name): bool { + return $this->findChangeByName($name) !== null; + } + + /** + * Check if a specific database change has been applied. + * + * @param string $name The class name of the change to check. + * @return bool True if the change has been applied, false otherwise. + */ + public function isApplied(string $name): bool { + return $this->getRepository()->count([ + 'change_name' => $name + ]) == 1; + } + + /** + * Register a database change. + * + * If a change with the same name is already registered, this method + * returns false without registering a duplicate. + * + * @param DatabaseChange|string $change The change instance or class name. + * @return bool True if registered successfully, false if already registered or on error. + */ + public function register(DatabaseChange|string $change): bool { + try { + $name = is_string($change) ? $change : $change->getName(); + + if ($this->hasChange($name)) { + return false; + } + + if (is_string($change)) { + if (!class_exists($change)) { + throw new Exception("Class does not exist: {$change}"); + } + + if (!is_subclass_of($change, DatabaseChange::class)) { + throw new Exception("Class is not a subclass of DatabaseChange: {$change}"); + } + + $change = new $change(); + } + + $this->dbChanges[] = $change; + + return true; + } catch (\Throwable $ex) { + foreach ($this->onRegErrCallbacks as $callback) { + call_user_func_array($callback, [$ex]); + } + + return false; + } + } + + /** + * Register multiple database changes. + * + * @param array $changes Array of DatabaseChange instances or class names. + */ + public function registerAll(array $changes): void { + foreach ($changes as $change) { + $this->register($change); } - return $this->rollbackBatch($lastBatch); } /** @@ -410,6 +470,7 @@ public function rollbackBatch(int $batch): array { // Rollback in reverse order $changes = array_reverse($this->getChanges()); + foreach ($changes as $change) { if (in_array($change->getName(), $changeNames)) { $this->attemptRoolback($change, $rolled); @@ -419,6 +480,21 @@ public function rollbackBatch(int $batch): array { return $rolled; } + /** + * Rollback all changes from the last batch. + * + * @return array Array of rolled back DatabaseChange instances. + */ + public function rollbackLastBatch(): array { + $lastBatch = $this->getRepository()->getLastBatchNumber(); + + if ($lastBatch === 0) { + return []; + } + + return $this->rollbackBatch($lastBatch); + } + /** * Rollback database changes up to a specific change. * @@ -500,94 +576,6 @@ private function findChangeByName(string $name): ?DatabaseChange { return null; } - /** - * Register a database change. - * - * If a change with the same name is already registered, this method - * returns false without registering a duplicate. - * - * @param DatabaseChange|string $change The change instance or class name. - * @return bool True if registered successfully, false if already registered or on error. - */ - public function register(DatabaseChange|string $change): bool { - try { - $name = is_string($change) ? $change : $change->getName(); - - if ($this->hasChange($name)) { - return false; - } - - if (is_string($change)) { - if (!class_exists($change)) { - throw new Exception("Class does not exist: {$change}"); - } - - if (!is_subclass_of($change, DatabaseChange::class)) { - throw new Exception("Class is not a subclass of DatabaseChange: {$change}"); - } - - $change = new $change(); - } - - $this->dbChanges[] = $change; - return true; - - } catch (\Throwable $ex) { - foreach ($this->onRegErrCallbacks as $callback) { - call_user_func_array($callback, [$ex]); - } - return false; - } - } - - /** - * Register multiple database changes. - * - * @param array $changes Array of DatabaseChange instances or class names. - */ - public function registerAll(array $changes): void { - foreach ($changes as $change) { - $this->register($change); - } - } - - /** - * Discover and register database changes from a directory. - * - * Scans the specified directory for PHP files containing classes that extend - * DatabaseChange (migrations and seeders). Each discovered class is automatically - * registered with the schema runner. - * - * @param string $path Absolute path to the directory containing migration/seeder files. - * @param string $namespace The PHP namespace for classes in the directory. - * @param bool $recursive Whether to scan subdirectories recursively. Default is false. - * @return int Number of changes discovered and registered. - */ - public function discoverFromPath(string $path, string $namespace, bool $recursive = false): int { - $count = 0; - - if (!is_dir($path)) { - return $count; - } - - $namespace = rtrim($namespace, '\\'); - $iterator = $recursive - ? new \RecursiveIteratorIterator(new \RecursiveDirectoryIterator($path, \RecursiveDirectoryIterator::SKIP_DOTS)) - : new \DirectoryIterator($path); - - foreach ($iterator as $file) { - if ($file->isFile() && $file->getExtension() === 'php') { - $className = $this->resolveClassName($file, $path, $namespace, $recursive); - - if ($className !== null && $this->register($className)) { - $count++; - } - } - } - - return $count; - } - /** * Resolve the fully qualified class name from a file. * @@ -599,26 +587,27 @@ public function discoverFromPath(string $path, string $namespace, bool $recursiv */ private function resolveClassName(\SplFileInfo $file, string $basePath, string $namespace, bool $recursive): ?string { $filename = $file->getBasename('.php'); - + if ($recursive) { $relativePath = substr($file->getPath(), strlen($basePath)); $relativePath = trim(str_replace(DIRECTORY_SEPARATOR, '\\', $relativePath), '\\'); - $className = $relativePath ? $namespace . '\\' . $relativePath . '\\' . $filename : $namespace . '\\' . $filename; + $className = $relativePath ? $namespace.'\\'.$relativePath.'\\'.$filename : $namespace.'\\'.$filename; } else { - $className = $namespace . '\\' . $filename; + $className = $namespace.'\\'.$filename; } - + if (!class_exists($className)) { require_once $file->getPathname(); } - + if (class_exists($className) && is_subclass_of($className, DatabaseChange::class)) { $reflection = new ReflectionClass($className); + if (!$reflection->isAbstract()) { return $className; } } - + return null; } @@ -666,4 +655,23 @@ private function topologicalSort(DatabaseChange $change, array &$visited, array $visited[$className] = true; $sorted[] = $change; } + + /** + * Execute a database change, optionally wrapped in a transaction. + * + * This method checks the change's useTransaction() method to determine + * whether to wrap the execution in a database transaction. + * + * @param DatabaseChange $change The change to execute. + */ + protected function executeChange(DatabaseChange $change): void { + if ($change->useTransaction($this)) { + $this->transaction(function (Database $db) use ($change) + { + $change->execute($db); + }); + } else { + $change->execute($this); + } + } } diff --git a/WebFiori/Database/Table.php b/WebFiori/Database/Table.php index 7625ae52..a27355df 100644 --- a/WebFiori/Database/Table.php +++ b/WebFiori/Database/Table.php @@ -366,24 +366,7 @@ public function getColsNames() : array { public function getComment() { return $this->comment; } - /** - * Returns an instance of the class 'EntityMapper' which can be used to map the - * table to an entity class. - * - * Note that the developer can modify the name of the entity and the namespace - * that it belongs to in addition to the path that the class will be created on. - * - * @return EntityMapper An instance of the class 'EntityMapper' - * - */ - public function getEntityMapper() : EntityMapper { - if ($this->mapper === null) { - $this->mapper = new EntityMapper($this, 'C'); - } - return $this->mapper; - } - /** * Returns an entity generator for generating PHP 8+ immutable entities. * @@ -402,7 +385,24 @@ public function getEntityMapper() : EntityMapper { public function getEntityGenerator(string $entityName = 'Entity', string $path = __DIR__, string $namespace = '') : EntityGenerator { return new EntityGenerator($this, $entityName, $path, $namespace); } - + /** + * Returns an instance of the class 'EntityMapper' which can be used to map the + * table to an entity class. + * + * Note that the developer can modify the name of the entity and the namespace + * that it belongs to in addition to the path that the class will be created on. + * + * @return EntityMapper An instance of the class 'EntityMapper' + * + */ + public function getEntityMapper() : EntityMapper { + if ($this->mapper === null) { + $this->mapper = new EntityMapper($this, 'C'); + } + + return $this->mapper; + } + /** * Returns a foreign key given its name. * diff --git a/examples/01-basic-connection/example.php b/examples/01-basic-connection/example.php index d72b4dd5..4f52d820 100644 --- a/examples/01-basic-connection/example.php +++ b/examples/01-basic-connection/example.php @@ -33,31 +33,34 @@ // Additional connection tests using raw() with parameters echo "\n--- Additional Connection Tests ---\n"; - + // Test current database $result = $database->raw("SELECT DATABASE() as current_db")->execute(); + if ($result && $result->getRowsCount() > 0) { - echo "✓ Current database: " . $result->getRows()[0]['current_db'] . "\n"; + echo "✓ Current database: ".$result->getRows()[0]['current_db']."\n"; } - + // Test server status $result = $database->raw("SHOW STATUS LIKE 'Uptime'")->execute(); + if ($result && $result->getRowsCount() > 0) { $uptime = $result->getRows()[0]['Value']; - echo "✓ Server uptime: " . $uptime . " seconds\n"; + echo "✓ Server uptime: ".$uptime." seconds\n"; } - + // Test connection info $result = $database->raw("SELECT CONNECTION_ID() as connection_id")->execute(); + if ($result && $result->getRowsCount() > 0) { - echo "✓ Connection ID: " . $result->getRows()[0]['connection_id'] . "\n"; + echo "✓ Connection ID: ".$result->getRows()[0]['connection_id']."\n"; } - + $result = $database->raw("SELECT USER() as user_name")->execute(); + if ($result && $result->getRowsCount() > 0) { - echo "✓ Current User: " . $result->getRows()[0]['user_name'] . "\n"; + echo "✓ Current User: ".$result->getRows()[0]['user_name']."\n"; } - } catch (Exception $e) { echo "✗ Error: ".$e->getMessage()."\n"; echo "Note: Make sure MySQL is running and accessible with the provided credentials.\n"; diff --git a/examples/02-basic-queries/example.php b/examples/02-basic-queries/example.php index 08c4ba26..baf8a260 100644 --- a/examples/02-basic-queries/example.php +++ b/examples/02-basic-queries/example.php @@ -104,7 +104,7 @@ // Multi-Result Query Example echo "\n6. Multi-Result Query Example:\n"; - + // Create a stored procedure that returns multiple result sets $database->raw("DROP PROCEDURE IF EXISTS GetUserStats")->execute(); $database->raw(" @@ -115,20 +115,21 @@ SELECT COUNT(*) as total_users, AVG(age) as avg_age FROM test_users; END ")->execute(); - + // Execute the stored procedure $result = $database->raw("CALL GetUserStats()")->execute(); - + if ($result instanceof MultiResultSet) { echo "✓ Multi-result query executed successfully!\n"; - echo "Number of result sets: " . $result->count() . "\n"; - + echo "Number of result sets: ".$result->count()."\n"; + for ($i = 0; $i < $result->count(); $i++) { $resultSet = $result->getResultSet($i); - echo "\nResult Set " . ($i + 1) . ":\n"; - + echo "\nResult Set ".($i + 1).":\n"; + foreach ($resultSet as $row) { echo " "; + foreach ($row as $key => $value) { echo "$key: $value "; } @@ -137,8 +138,10 @@ } } else { echo "Single result set returned:\n"; + foreach ($result as $row) { echo " "; + foreach ($row as $key => $value) { echo "$key: $value "; } @@ -148,7 +151,7 @@ // Another multi-result example with conditional logic echo "\n7. Complex Multi-Result Example:\n"; - + $database->raw("DROP PROCEDURE IF EXISTS ComplexStats")->execute(); $database->raw(" CREATE PROCEDURE ComplexStats() @@ -177,26 +180,27 @@ GROUP BY age_group; END ")->execute(); - + $complexResult = $database->raw("CALL ComplexStats()")->execute(); - + if ($complexResult instanceof MultiResultSet) { echo "✓ Complex multi-result query executed!\n"; - + // Process each result set with specific handling for ($i = 0; $i < $complexResult->count(); $i++) { $rs = $complexResult->getResultSet($i); + if ($rs->getRowsCount() > 0) { $firstRow = $rs->getRows()[0]; - + if (isset($firstRow['section'])) { echo "\n--- {$firstRow['section']} ---\n"; - + foreach ($rs as $row) { if ($row['section'] === 'All Users') { echo " User: {$row['name']} ({$row['email']}) - Age: {$row['age']}\n"; } elseif ($row['section'] === 'Statistics') { - echo " Total: {$row['total_count']}, Min Age: {$row['min_age']}, Max Age: {$row['max_age']}, Avg Age: " . round($row['avg_age'], 1) . "\n"; + echo " Total: {$row['total_count']}, Min Age: {$row['min_age']}, Max Age: {$row['max_age']}, Avg Age: ".round($row['avg_age'], 1)."\n"; } elseif ($row['section'] === 'Age Groups') { echo " {$row['age_group']}: {$row['count']} users\n"; } @@ -212,10 +216,9 @@ $database->raw("DROP PROCEDURE IF EXISTS ComplexStats")->execute(); $database->raw("DROP TABLE test_users")->execute(); echo "✓ Test table and procedures dropped\n"; - } catch (Exception $e) { echo "✗ Error: ".$e->getMessage()."\n"; - echo "Stack trace:\n" . $e->getTraceAsString() . "\n"; + echo "Stack trace:\n".$e->getTraceAsString()."\n"; } echo "\n=== Example Complete ===\n"; diff --git a/examples/03-table-blueprints/UserTable.php b/examples/03-table-blueprints/UserTable.php index 57ff9cea..deb748e5 100644 --- a/examples/03-table-blueprints/UserTable.php +++ b/examples/03-table-blueprints/UserTable.php @@ -1,46 +1,46 @@ -addColumns([ - 'id' => [ - ColOption::TYPE => DataType::INT, - ColOption::SIZE => 11, - ColOption::PRIMARY => true, - ColOption::AUTO_INCREMENT => true - ], - 'username' => [ - ColOption::TYPE => DataType::VARCHAR, - ColOption::SIZE => 50, - ColOption::NULL => false - ], - 'email' => [ - ColOption::TYPE => DataType::VARCHAR, - ColOption::SIZE => 150, - ColOption::NULL => false - ], - 'full_name' => [ - ColOption::TYPE => DataType::VARCHAR, - ColOption::SIZE => 100 - ], - 'is_active' => [ - ColOption::TYPE => DataType::BOOL, - ColOption::DEFAULT => true - ], - 'created_at' => [ - ColOption::TYPE => DataType::TIMESTAMP, - ColOption::DEFAULT => 'current_timestamp' - ] - ]); - } -} +addColumns([ + 'id' => [ + ColOption::TYPE => DataType::INT, + ColOption::SIZE => 11, + ColOption::PRIMARY => true, + ColOption::AUTO_INCREMENT => true + ], + 'username' => [ + ColOption::TYPE => DataType::VARCHAR, + ColOption::SIZE => 50, + ColOption::NULL => false + ], + 'email' => [ + ColOption::TYPE => DataType::VARCHAR, + ColOption::SIZE => 150, + ColOption::NULL => false + ], + 'full_name' => [ + ColOption::TYPE => DataType::VARCHAR, + ColOption::SIZE => 100 + ], + 'is_active' => [ + ColOption::TYPE => DataType::BOOL, + ColOption::DEFAULT => true + ], + 'created_at' => [ + ColOption::TYPE => DataType::TIMESTAMP, + ColOption::DEFAULT => 'current_timestamp' + ] + ]); + } +} diff --git a/examples/03-table-blueprints/example.php b/examples/03-table-blueprints/example.php index 0f35be97..dda84c06 100644 --- a/examples/03-table-blueprints/example.php +++ b/examples/03-table-blueprints/example.php @@ -1,183 +1,183 @@ -createBlueprint('users')->addColumns([ - 'id' => [ - ColOption::TYPE => DataType::INT, - ColOption::SIZE => 11, - ColOption::PRIMARY => true, - ColOption::AUTO_INCREMENT => true - ], - 'username' => [ - ColOption::TYPE => DataType::VARCHAR, - ColOption::SIZE => 50, - ColOption::NULL => false - ], - 'email' => [ - ColOption::TYPE => DataType::VARCHAR, - ColOption::SIZE => 150, - ColOption::NULL => false - ], - 'created_at' => [ - ColOption::TYPE => DataType::TIMESTAMP, - ColOption::DEFAULT => 'current_timestamp' - ] - ]); - - echo "✓ Users table blueprint created\n"; - echo " Columns: ".implode(', ', array_keys($usersTable->getColsNames()))."\n\n"; - - echo "2. Creating Posts Table Blueprint:\n"; - - // Create posts table blueprint - $postsTable = $database->createBlueprint('posts')->addColumns([ - 'id' => [ - ColOption::TYPE => DataType::INT, - ColOption::SIZE => 11, - ColOption::PRIMARY => true, - ColOption::AUTO_INCREMENT => true - ], - 'user_id' => [ - ColOption::TYPE => DataType::INT, - ColOption::SIZE => 11, - ColOption::NULL => false - ], - 'title' => [ - ColOption::TYPE => DataType::VARCHAR, - ColOption::SIZE => 200, - ColOption::NULL => false - ], - 'content' => [ - ColOption::TYPE => DataType::TEXT - ], - 'created_at' => [ - ColOption::TYPE => DataType::TIMESTAMP, - ColOption::DEFAULT => 'current_timestamp' - ] - ]); - - echo "✓ Posts table blueprint created\n"; - echo " Columns: ".implode(', ', array_keys($postsTable->getColsNames()))."\n\n"; - - echo "3. Adding Foreign Key Relationship:\n"; - - // Add foreign key relationship - $postsTable->addReference($usersTable, ['user_id'], 'user_fk'); - echo "✓ Foreign key relationship added (posts.user_id -> users.id)\n\n"; - - echo "4. Generating and Executing CREATE TABLE Statements:\n"; - - // Generate create table queries - $database->createTables(); - - // Show the generated SQL - $sql = $database->getLastQuery(); - echo "Generated SQL:\n"; - echo str_replace(';', ";\n", $sql)."\n\n"; - - // Execute the queries - $database->execute(); - echo "✓ Tables created successfully\n\n"; - - echo "5. Testing the Created Tables:\n"; - - // Insert test data - $database->table('users')->insert([ - 'username' => 'ahmad_salem', - 'email' => 'ahmad@example.com' - ])->execute(); - echo "✓ Inserted test user\n"; - - // Get the user ID - $userResult = $database->table('users') - ->select(['id']) - ->where('username', 'ahmad_salem') - ->execute(); - $userId = $userResult->getRows()[0]['id']; - - $database->table('posts')->insert([ - 'user_id' => $userId, - 'title' => 'My First Post', - 'content' => 'This is the content of my first post.' - ])->execute(); - echo "✓ Inserted test post\n"; - - // Query with join to show relationship +createBlueprint('users')->addColumns([ + 'id' => [ + ColOption::TYPE => DataType::INT, + ColOption::SIZE => 11, + ColOption::PRIMARY => true, + ColOption::AUTO_INCREMENT => true + ], + 'username' => [ + ColOption::TYPE => DataType::VARCHAR, + ColOption::SIZE => 50, + ColOption::NULL => false + ], + 'email' => [ + ColOption::TYPE => DataType::VARCHAR, + ColOption::SIZE => 150, + ColOption::NULL => false + ], + 'created_at' => [ + ColOption::TYPE => DataType::TIMESTAMP, + ColOption::DEFAULT => 'current_timestamp' + ] + ]); + + echo "✓ Users table blueprint created\n"; + echo " Columns: ".implode(', ', array_keys($usersTable->getColsNames()))."\n\n"; + + echo "2. Creating Posts Table Blueprint:\n"; + + // Create posts table blueprint + $postsTable = $database->createBlueprint('posts')->addColumns([ + 'id' => [ + ColOption::TYPE => DataType::INT, + ColOption::SIZE => 11, + ColOption::PRIMARY => true, + ColOption::AUTO_INCREMENT => true + ], + 'user_id' => [ + ColOption::TYPE => DataType::INT, + ColOption::SIZE => 11, + ColOption::NULL => false + ], + 'title' => [ + ColOption::TYPE => DataType::VARCHAR, + ColOption::SIZE => 200, + ColOption::NULL => false + ], + 'content' => [ + ColOption::TYPE => DataType::TEXT + ], + 'created_at' => [ + ColOption::TYPE => DataType::TIMESTAMP, + ColOption::DEFAULT => 'current_timestamp' + ] + ]); + + echo "✓ Posts table blueprint created\n"; + echo " Columns: ".implode(', ', array_keys($postsTable->getColsNames()))."\n\n"; + + echo "3. Adding Foreign Key Relationship:\n"; + + // Add foreign key relationship + $postsTable->addReference($usersTable, ['user_id'], 'user_fk'); + echo "✓ Foreign key relationship added (posts.user_id -> users.id)\n\n"; + + echo "4. Generating and Executing CREATE TABLE Statements:\n"; + + // Generate create table queries + $database->createTables(); + + // Show the generated SQL + $sql = $database->getLastQuery(); + echo "Generated SQL:\n"; + echo str_replace(';', ";\n", $sql)."\n\n"; + + // Execute the queries + $database->execute(); + echo "✓ Tables created successfully\n\n"; + + echo "5. Testing the Created Tables:\n"; + + // Insert test data + $database->table('users')->insert([ + 'username' => 'ahmad_salem', + 'email' => 'ahmad@example.com' + ])->execute(); + echo "✓ Inserted test user\n"; + + // Get the user ID + $userResult = $database->table('users') + ->select(['id']) + ->where('username', 'ahmad_salem') + ->execute(); + $userId = $userResult->getRows()[0]['id']; + + $database->table('posts')->insert([ + 'user_id' => $userId, + 'title' => 'My First Post', + 'content' => 'This is the content of my first post.' + ])->execute(); + echo "✓ Inserted test post\n"; + + // Query with join to show relationship $result = $database->setQuery(" SELECT u.username, p.title, p.created_at FROM users u JOIN posts p ON u.id = p.user_id - ")->execute(); - - echo "\nJoined data:\n"; - - foreach ($result as $row) { - echo " User: {$row['username']}, Post: {$row['title']}, Created: {$row['created_at']}\n"; - } - - echo "\n6. Using Custom Table Class (Extending MySQLTable):\n"; - - // Clean up previous tables first - $database->setQuery("DROP TABLE IF EXISTS posts")->execute(); - $database->setQuery("DROP TABLE IF EXISTS users")->execute(); - - // Include the custom table class - require_once __DIR__.'/UserTable.php'; - - // Create an instance of the custom table - $customTable = new UserTable(); - - echo "✓ Custom UserTable class created\n"; - echo " Table name: ".$customTable->getName()."\n"; - echo " Engine: ".$customTable->getEngine()."\n"; - echo " Charset: ".$customTable->getCharSet()."\n"; - - // Generate and execute CREATE TABLE for custom table - $createQuery = $customTable->toSQL(); - echo "\nGenerated SQL for custom table:\n"; - echo $createQuery."\n\n"; - - // Execute the custom table creation - $database->setQuery($createQuery)->execute(); - echo "✓ Custom table created successfully\n"; - - // Test the custom table - $database->table('users_extended')->insert([ - 'username' => 'sara_ahmad', - 'email' => 'sara@example.com', - 'full_name' => 'Sara Ahmad Al-Mansouri' - ])->execute(); - echo "✓ Inserted test data into custom table\n"; - - // Query the custom table - $result = $database->table('users_extended')->select()->execute(); - echo "Custom table data:\n"; - - foreach ($result as $row) { - echo " User: {$row['full_name']} ({$row['username']}) - Active: ".($row['is_active'] ? 'Yes' : 'No')."\n"; - } - - echo "\n7. Final Cleanup:\n"; - $database->setQuery("DROP TABLE users_extended")->execute(); - echo "✓ Tables dropped\n"; -} catch (Exception $e) { - echo "✗ Error: ".$e->getMessage()."\n"; -} - -echo "\n=== Example Complete ===\n"; + ")->execute(); + + echo "\nJoined data:\n"; + + foreach ($result as $row) { + echo " User: {$row['username']}, Post: {$row['title']}, Created: {$row['created_at']}\n"; + } + + echo "\n6. Using Custom Table Class (Extending MySQLTable):\n"; + + // Clean up previous tables first + $database->setQuery("DROP TABLE IF EXISTS posts")->execute(); + $database->setQuery("DROP TABLE IF EXISTS users")->execute(); + + // Include the custom table class + require_once __DIR__.'/UserTable.php'; + + // Create an instance of the custom table + $customTable = new UserTable(); + + echo "✓ Custom UserTable class created\n"; + echo " Table name: ".$customTable->getName()."\n"; + echo " Engine: ".$customTable->getEngine()."\n"; + echo " Charset: ".$customTable->getCharSet()."\n"; + + // Generate and execute CREATE TABLE for custom table + $createQuery = $customTable->toSQL(); + echo "\nGenerated SQL for custom table:\n"; + echo $createQuery."\n\n"; + + // Execute the custom table creation + $database->setQuery($createQuery)->execute(); + echo "✓ Custom table created successfully\n"; + + // Test the custom table + $database->table('users_extended')->insert([ + 'username' => 'sara_ahmad', + 'email' => 'sara@example.com', + 'full_name' => 'Sara Ahmad Al-Mansouri' + ])->execute(); + echo "✓ Inserted test data into custom table\n"; + + // Query the custom table + $result = $database->table('users_extended')->select()->execute(); + echo "Custom table data:\n"; + + foreach ($result as $row) { + echo " User: {$row['full_name']} ({$row['username']}) - Active: ".($row['is_active'] ? 'Yes' : 'No')."\n"; + } + + echo "\n7. Final Cleanup:\n"; + $database->setQuery("DROP TABLE users_extended")->execute(); + echo "✓ Tables dropped\n"; +} catch (Exception $e) { + echo "✗ Error: ".$e->getMessage()."\n"; +} + +echo "\n=== Example Complete ===\n"; diff --git a/examples/04-entity-mapping/example.php b/examples/04-entity-mapping/example.php index 86f98968..e2190abc 100644 --- a/examples/04-entity-mapping/example.php +++ b/examples/04-entity-mapping/example.php @@ -1,167 +1,167 @@ -createBlueprint('users')->addColumns([ - 'id' => [ - ColOption::TYPE => DataType::INT, - ColOption::SIZE => 11, - ColOption::PRIMARY => true, - ColOption::AUTO_INCREMENT => true - ], - 'first_name' => [ - ColOption::TYPE => DataType::VARCHAR, - ColOption::SIZE => 50, - ColOption::NULL => false - ], - 'last_name' => [ - ColOption::TYPE => DataType::VARCHAR, - ColOption::SIZE => 50, - ColOption::NULL => false - ], - 'email' => [ - ColOption::TYPE => DataType::VARCHAR, - ColOption::SIZE => 150, - ColOption::NULL => false - ], - 'age' => [ - ColOption::TYPE => DataType::INT, - ColOption::SIZE => 3 - ] - ]); - - echo "✓ User table blueprint created\n\n"; - - echo "2. Creating Entity Class:\n"; - - // Get entity mapper and create entity class - $entityMapper = $userTable->getEntityMapper(); - $entityMapper->setEntityName('User'); - $entityMapper->setNamespace(''); - $entityMapper->setPath(__DIR__); - - // Create the entity class - $entityMapper->create(); - echo "✓ User entity class created at: ".__DIR__."/User.php\n"; - - - echo "3. Creating Table in Database:\n"; - - // Create the table - $database->createTables(); - $database->execute(); - echo "✓ User table created in database\n\n"; - - echo "4. Inserting Test Data:\n"; - - // Insert test users - $database->table('users')->insert([ - 'first_name' => 'Khalid', - 'last_name' => 'Al-Rashid', - 'email' => 'khalid.rashid@example.com', - 'age' => 30 - ])->execute(); - - $database->table('users')->insert([ - 'first_name' => 'Aisha', - 'last_name' => 'Mahmoud', - 'email' => 'aisha.mahmoud@example.com', - 'age' => 25 - ])->execute(); - - $database->table('users')->insert([ - 'first_name' => 'Hassan', - 'last_name' => 'Al-Najjar', - 'email' => 'hassan.najjar@example.com', - 'age' => 35 - ])->execute(); - - echo "✓ Test users inserted\n\n"; - - echo "5. Fetching and Mapping Records:\n"; - - // Include the generated entity class - require_once __DIR__.'/User.php'; - - // Fetch records and map to objects - $resultSet = $database->table('users')->select()->execute(); - - $mappedUsers = $resultSet->map(function (array $record) - { - return User::map($record); - }); - - echo "Mapped users as objects:\n"; - - foreach ($mappedUsers as $user) { - echo " - {$user->getFirstName()} {$user->getLastName()} ({$user->getEmail()}) - Age: {$user->getAge()}\n"; - } - echo "\n"; - - echo "6. Working with Individual Objects:\n"; - - // Get first user and demonstrate object methods - $firstUser = $mappedUsers->getRows()[0]; - echo "First user details:\n"; - echo " ID: {$firstUser->getId()}\n"; - echo " Full Name: {$firstUser->getFirstName()} {$firstUser->getLastName()}\n"; - echo " Email: {$firstUser->getEmail()}\n"; - echo " Age: {$firstUser->getAge()}\n\n"; - - echo "7. Filtering with Entity Objects:\n"; - - // Filter users by age - $adultUsers = []; - - foreach ($mappedUsers as $user) { - if ($user->getAge() >= 30) { - $adultUsers[] = $user; - } - } - - echo "Users 30 or older:\n"; - - foreach ($adultUsers as $user) { - echo " - {$user->getFirstName()} {$user->getLastName()} (Age: {$user->getAge()})\n"; - } - - echo "\n8. Cleanup:\n"; - $database->setQuery("DROP TABLE users")->execute(); - echo "✓ User table dropped\n"; - - // Clean up generated file - if (file_exists(__DIR__.'/User.php')) { - unlink(__DIR__.'/User.php'); - echo "✓ Generated User.php file removed\n"; - } -} catch (Exception $e) { - echo "✗ Error: ".$e->getMessage()."\n"; - - // Clean up on error - try { - $database->setQuery("DROP TABLE IF EXISTS users")->execute(); - - if (file_exists(__DIR__.'/User.php')) { - unlink(__DIR__.'/User.php'); - } - } catch (Exception $cleanupError) { - // Ignore cleanup errors - } -} - -echo "\n=== Example Complete ===\n"; +createBlueprint('users')->addColumns([ + 'id' => [ + ColOption::TYPE => DataType::INT, + ColOption::SIZE => 11, + ColOption::PRIMARY => true, + ColOption::AUTO_INCREMENT => true + ], + 'first_name' => [ + ColOption::TYPE => DataType::VARCHAR, + ColOption::SIZE => 50, + ColOption::NULL => false + ], + 'last_name' => [ + ColOption::TYPE => DataType::VARCHAR, + ColOption::SIZE => 50, + ColOption::NULL => false + ], + 'email' => [ + ColOption::TYPE => DataType::VARCHAR, + ColOption::SIZE => 150, + ColOption::NULL => false + ], + 'age' => [ + ColOption::TYPE => DataType::INT, + ColOption::SIZE => 3 + ] + ]); + + echo "✓ User table blueprint created\n\n"; + + echo "2. Creating Entity Class:\n"; + + // Get entity mapper and create entity class + $entityMapper = $userTable->getEntityMapper(); + $entityMapper->setEntityName('User'); + $entityMapper->setNamespace(''); + $entityMapper->setPath(__DIR__); + + // Create the entity class + $entityMapper->create(); + echo "✓ User entity class created at: ".__DIR__."/User.php\n"; + + + echo "3. Creating Table in Database:\n"; + + // Create the table + $database->createTables(); + $database->execute(); + echo "✓ User table created in database\n\n"; + + echo "4. Inserting Test Data:\n"; + + // Insert test users + $database->table('users')->insert([ + 'first_name' => 'Khalid', + 'last_name' => 'Al-Rashid', + 'email' => 'khalid.rashid@example.com', + 'age' => 30 + ])->execute(); + + $database->table('users')->insert([ + 'first_name' => 'Aisha', + 'last_name' => 'Mahmoud', + 'email' => 'aisha.mahmoud@example.com', + 'age' => 25 + ])->execute(); + + $database->table('users')->insert([ + 'first_name' => 'Hassan', + 'last_name' => 'Al-Najjar', + 'email' => 'hassan.najjar@example.com', + 'age' => 35 + ])->execute(); + + echo "✓ Test users inserted\n\n"; + + echo "5. Fetching and Mapping Records:\n"; + + // Include the generated entity class + require_once __DIR__.'/User.php'; + + // Fetch records and map to objects + $resultSet = $database->table('users')->select()->execute(); + + $mappedUsers = $resultSet->map(function (array $record) + { + return User::map($record); + }); + + echo "Mapped users as objects:\n"; + + foreach ($mappedUsers as $user) { + echo " - {$user->getFirstName()} {$user->getLastName()} ({$user->getEmail()}) - Age: {$user->getAge()}\n"; + } + echo "\n"; + + echo "6. Working with Individual Objects:\n"; + + // Get first user and demonstrate object methods + $firstUser = $mappedUsers->getRows()[0]; + echo "First user details:\n"; + echo " ID: {$firstUser->getId()}\n"; + echo " Full Name: {$firstUser->getFirstName()} {$firstUser->getLastName()}\n"; + echo " Email: {$firstUser->getEmail()}\n"; + echo " Age: {$firstUser->getAge()}\n\n"; + + echo "7. Filtering with Entity Objects:\n"; + + // Filter users by age + $adultUsers = []; + + foreach ($mappedUsers as $user) { + if ($user->getAge() >= 30) { + $adultUsers[] = $user; + } + } + + echo "Users 30 or older:\n"; + + foreach ($adultUsers as $user) { + echo " - {$user->getFirstName()} {$user->getLastName()} (Age: {$user->getAge()})\n"; + } + + echo "\n8. Cleanup:\n"; + $database->setQuery("DROP TABLE users")->execute(); + echo "✓ User table dropped\n"; + + // Clean up generated file + if (file_exists(__DIR__.'/User.php')) { + unlink(__DIR__.'/User.php'); + echo "✓ Generated User.php file removed\n"; + } +} catch (Exception $e) { + echo "✗ Error: ".$e->getMessage()."\n"; + + // Clean up on error + try { + $database->setQuery("DROP TABLE IF EXISTS users")->execute(); + + if (file_exists(__DIR__.'/User.php')) { + unlink(__DIR__.'/User.php'); + } + } catch (Exception $cleanupError) { + // Ignore cleanup errors + } +} + +echo "\n=== Example Complete ===\n"; diff --git a/examples/05-transactions/example.php b/examples/05-transactions/example.php index dc078186..35a4db70 100644 --- a/examples/05-transactions/example.php +++ b/examples/05-transactions/example.php @@ -162,7 +162,7 @@ } echo "6. Multi-Result Transaction Analysis:\n"; - + // Create a stored procedure for transaction analysis $database->raw("DROP PROCEDURE IF EXISTS TransactionAnalysis")->execute(); $database->raw(" @@ -191,29 +191,30 @@ LIMIT 5; END ")->execute(); - + $analysisResult = $database->raw("CALL TransactionAnalysis()")->execute(); - + if (method_exists($analysisResult, 'count') && $analysisResult->count() > 1) { echo "✓ Multi-result transaction analysis completed!\n"; - + for ($i = 0; $i < $analysisResult->count(); $i++) { $rs = $analysisResult->getResultSet($i); + if ($rs->getRowsCount() > 0) { $firstRow = $rs->getRows()[0]; - + if (isset($firstRow['report_type'])) { echo "\n--- {$firstRow['report_type']} ---\n"; - + foreach ($rs as $row) { if ($row['report_type'] === 'Account Summary') { - echo " {$row['name']}: $" . number_format($row['balance'], 2) . "\n"; + echo " {$row['name']}: $".number_format($row['balance'], 2)."\n"; } elseif ($row['report_type'] === 'Transaction Summary') { echo " Total Transactions: {$row['total_transactions']}\n"; - echo " Total Amount: $" . number_format($row['total_amount'], 2) . "\n"; - echo " Average Amount: $" . number_format($row['avg_amount'], 2) . "\n"; + echo " Total Amount: $".number_format($row['total_amount'], 2)."\n"; + echo " Average Amount: $".number_format($row['avg_amount'], 2)."\n"; } elseif ($row['report_type'] === 'Recent Transactions') { - echo " {$row['from_name']} → {$row['to_name']}: $" . number_format($row['amount'], 2) . " ({$row['created_at']})\n"; + echo " {$row['from_name']} → {$row['to_name']}: $".number_format($row['amount'], 2)." ({$row['created_at']})\n"; } } } @@ -226,7 +227,6 @@ $database->raw("DROP TABLE transactions")->execute(); $database->raw("DROP TABLE accounts")->execute(); echo "✓ Test tables and procedures dropped\n"; - } catch (Exception $e) { echo "✗ Error: ".$e->getMessage()."\n"; diff --git a/examples/06-migrations/AddEmailIndexMigration.php b/examples/06-migrations/AddEmailIndexMigration.php index 9411921e..5d723f51 100644 --- a/examples/06-migrations/AddEmailIndexMigration.php +++ b/examples/06-migrations/AddEmailIndexMigration.php @@ -1,52 +1,51 @@ -setQuery("ALTER TABLE users ADD UNIQUE INDEX idx_users_email (email)")->execute(); - } - - /** - * Rollback the migration changes from the database. - * - * Removes the unique index from the email column, - * allowing duplicate emails again. - * - * @param Database $db The database instance to execute rollback on. - */ - public function down(Database $db): void { - // Drop email index - $db->setQuery("ALTER TABLE users DROP INDEX idx_users_email")->execute(); - } -} +setQuery("ALTER TABLE users DROP INDEX idx_users_email")->execute(); + } + + /** + * Get the list of migration dependencies. + * + * This migration requires the users table to exist before + * it can add an index to the email column. + * + * @return array Array of migration names this migration depends on. + */ + public function getDependencies(): array { + return ['CreateUsersTableMigration']; + } + + /** + * Apply the migration changes to the database. + * + * Adds a unique index on the email column to enforce + * email uniqueness and improve query performance. + * + * @param Database $db The database instance to execute changes on. + */ + public function up(Database $db): void { + // Add unique index on email column + $db->setQuery("ALTER TABLE users ADD UNIQUE INDEX idx_users_email (email)")->execute(); + } +} diff --git a/examples/06-migrations/CreateUsersTableMigration.php b/examples/06-migrations/CreateUsersTableMigration.php index 6447e30d..d04800b2 100644 --- a/examples/06-migrations/CreateUsersTableMigration.php +++ b/examples/06-migrations/CreateUsersTableMigration.php @@ -1,71 +1,70 @@ -createBlueprint('users')->addColumns([ - 'id' => [ - ColOption::TYPE => DataType::INT, - ColOption::SIZE => 11, - ColOption::PRIMARY => true, - ColOption::AUTO_INCREMENT => true - ], - 'username' => [ - ColOption::TYPE => DataType::VARCHAR, - ColOption::SIZE => 50, - ColOption::NULL => false - ], - 'email' => [ - ColOption::TYPE => DataType::VARCHAR, - ColOption::SIZE => 150, - ColOption::NULL => false - ], - 'password_hash' => [ - ColOption::TYPE => DataType::VARCHAR, - ColOption::SIZE => 255, - ColOption::NULL => false - ], - 'created_at' => [ - ColOption::TYPE => DataType::TIMESTAMP, - ColOption::DEFAULT => 'current_timestamp' - ] - ]); - - $db->createTables(); - $db->execute(); - } - - /** - * Rollback the migration changes from the database. - * - * Drops the users table and all its data. This operation - * is irreversible and will result in data loss. - * - * @param Database $db The database instance to execute rollback on. - */ - public function down(Database $db): void { - // Drop users table - $db->setQuery("DROP TABLE IF EXISTS users")->execute(); - } -} +setQuery("DROP TABLE IF EXISTS users")->execute(); + } + + /** + * Apply the migration changes to the database. + * + * Creates the users table with columns for user authentication + * and basic profile information. + * + * @param Database $db The database instance to execute changes on. + */ + public function up(Database $db): void { + // Create users table + $db->createBlueprint('users')->addColumns([ + 'id' => [ + ColOption::TYPE => DataType::INT, + ColOption::SIZE => 11, + ColOption::PRIMARY => true, + ColOption::AUTO_INCREMENT => true + ], + 'username' => [ + ColOption::TYPE => DataType::VARCHAR, + ColOption::SIZE => 50, + ColOption::NULL => false + ], + 'email' => [ + ColOption::TYPE => DataType::VARCHAR, + ColOption::SIZE => 150, + ColOption::NULL => false + ], + 'password_hash' => [ + ColOption::TYPE => DataType::VARCHAR, + ColOption::SIZE => 255, + ColOption::NULL => false + ], + 'created_at' => [ + ColOption::TYPE => DataType::TIMESTAMP, + ColOption::DEFAULT => 'current_timestamp' + ] + ]); + + $db->createTables(); + $db->execute(); + } +} diff --git a/examples/06-migrations/example.php b/examples/06-migrations/example.php index 953603a8..d316f31b 100644 --- a/examples/06-migrations/example.php +++ b/examples/06-migrations/example.php @@ -1,161 +1,168 @@ -register('CreateUsersTableMigration'); - $runner->register('AddEmailIndexMigration'); - - echo "✓ Schema runner created\n"; - echo "✓ Migration classes registered\n"; - - // Create schema tracking table - $runner->createSchemaTable(); - echo "✓ Schema tracking table created\n\n"; - - echo "3. Checking Available Migrations:\n"; - - $changes = $runner->getChanges(); - echo "Registered migrations:\n"; - foreach ($changes as $change) { - echo " - " . $change->getName() . "\n"; - } - echo "\n"; - - echo "4. Running Migrations:\n"; - - // Force apply all migrations - $changes = $runner->getChanges(); - $appliedChanges = []; - - foreach ($changes as $change) { - if (!$runner->isApplied($change->getName())) { - $change->execute($database); - $appliedChanges[] = $change; - echo " ✓ Applied: " . $change->getName() . "\n"; - } - } - - if (empty($appliedChanges)) { - echo "No migrations to apply (all up to date)\n"; - } - echo "\n"; - - echo "5. Verifying Database Structure:\n"; - - // Check if table exists - $result = $database->setQuery("SHOW TABLES LIKE 'users'")->execute(); - if ($result->getRowsCount() > 0) { - echo "✓ Users table created\n"; - } - - // Check table structure - $result = $database->setQuery("DESCRIBE users")->execute(); - echo "Users table columns:\n"; - foreach ($result as $column) { - echo " - {$column['Field']} ({$column['Type']})\n"; - } - - // Check indexes - $result = $database->setQuery("SHOW INDEX FROM users WHERE Key_name = 'idx_users_email'")->execute(); - if ($result->getRowsCount() > 0) { - echo "✓ Email index created\n"; - } - echo "\n"; - - echo "6. Testing Data Operations:\n"; - - // Insert test data - $database->table('users')->insert([ - 'username' => 'ahmad_hassan', - 'email' => 'ahmad@example.com', - 'password_hash' => password_hash('password123', PASSWORD_DEFAULT) - ])->execute(); - - $database->table('users')->insert([ - 'username' => 'fatima_ali', - 'email' => 'fatima@example.com', - 'password_hash' => password_hash('password456', PASSWORD_DEFAULT) - ])->execute(); - - echo "✓ Test users inserted\n"; - - // Query data - $result = $database->table('users')->select(['username', 'email', 'created_at'])->execute(); - echo "Inserted users:\n"; - foreach ($result as $user) { - echo " - {$user['username']} ({$user['email']}) - {$user['created_at']}\n"; - } - echo "\n"; - - echo "7. Checking Migration Status:\n"; - - // Check which migrations are applied - echo "Migration status:\n"; - foreach ($changes as $change) { - $status = $runner->isApplied($change->getName()) ? "✓ Applied" : "✗ Pending"; - echo " {$change->getName()}: $status\n"; - } - echo "\n"; - - echo "8. Rolling Back Migrations:\n"; - - // Rollback all migrations - $rolledBackChanges = $runner->rollbackUpTo(null); - - if (!empty($rolledBackChanges)) { - echo "Rolled back migrations:\n"; - foreach ($rolledBackChanges as $change) { - echo " ✓ " . $change->getName() . "\n"; - } - } else { - echo "No migrations to rollback\n"; - } - - // Verify rollback - $result = $database->setQuery("SHOW TABLES LIKE 'users'")->execute(); - if ($result->getRowsCount() == 0) { - echo "✓ Users table removed\n"; - } - - echo "\n9. Cleanup:\n"; - $runner->dropSchemaTable(); - echo "✓ Schema tracking table dropped\n"; - -} catch (Exception $e) { - echo "✗ Error: " . $e->getMessage() . "\n"; - - // Clean up on error - try { - $database->setQuery("DROP TABLE IF EXISTS users")->execute(); - $database->setQuery("DROP TABLE IF EXISTS schema_changes")->execute(); - } catch (Exception $cleanupError) { - // Ignore cleanup errors - } -} - -echo "\n=== Example Complete ===\n"; +register('CreateUsersTableMigration'); + $runner->register('AddEmailIndexMigration'); + + echo "✓ Schema runner created\n"; + echo "✓ Migration classes registered\n"; + + // Create schema tracking table + $runner->createSchemaTable(); + echo "✓ Schema tracking table created\n\n"; + + echo "3. Checking Available Migrations:\n"; + + $changes = $runner->getChanges(); + echo "Registered migrations:\n"; + + foreach ($changes as $change) { + echo " - ".$change->getName()."\n"; + } + echo "\n"; + + echo "4. Running Migrations:\n"; + + // Force apply all migrations + $changes = $runner->getChanges(); + $appliedChanges = []; + + foreach ($changes as $change) { + if (!$runner->isApplied($change->getName())) { + $change->execute($database); + $appliedChanges[] = $change; + echo " ✓ Applied: ".$change->getName()."\n"; + } + } + + if (empty($appliedChanges)) { + echo "No migrations to apply (all up to date)\n"; + } + echo "\n"; + + echo "5. Verifying Database Structure:\n"; + + // Check if table exists + $result = $database->setQuery("SHOW TABLES LIKE 'users'")->execute(); + + if ($result->getRowsCount() > 0) { + echo "✓ Users table created\n"; + } + + // Check table structure + $result = $database->setQuery("DESCRIBE users")->execute(); + echo "Users table columns:\n"; + + foreach ($result as $column) { + echo " - {$column['Field']} ({$column['Type']})\n"; + } + + // Check indexes + $result = $database->setQuery("SHOW INDEX FROM users WHERE Key_name = 'idx_users_email'")->execute(); + + if ($result->getRowsCount() > 0) { + echo "✓ Email index created\n"; + } + echo "\n"; + + echo "6. Testing Data Operations:\n"; + + // Insert test data + $database->table('users')->insert([ + 'username' => 'ahmad_hassan', + 'email' => 'ahmad@example.com', + 'password_hash' => password_hash('password123', PASSWORD_DEFAULT) + ])->execute(); + + $database->table('users')->insert([ + 'username' => 'fatima_ali', + 'email' => 'fatima@example.com', + 'password_hash' => password_hash('password456', PASSWORD_DEFAULT) + ])->execute(); + + echo "✓ Test users inserted\n"; + + // Query data + $result = $database->table('users')->select(['username', 'email', 'created_at'])->execute(); + echo "Inserted users:\n"; + + foreach ($result as $user) { + echo " - {$user['username']} ({$user['email']}) - {$user['created_at']}\n"; + } + echo "\n"; + + echo "7. Checking Migration Status:\n"; + + // Check which migrations are applied + echo "Migration status:\n"; + + foreach ($changes as $change) { + $status = $runner->isApplied($change->getName()) ? "✓ Applied" : "✗ Pending"; + echo " {$change->getName()}: $status\n"; + } + echo "\n"; + + echo "8. Rolling Back Migrations:\n"; + + // Rollback all migrations + $rolledBackChanges = $runner->rollbackUpTo(null); + + if (!empty($rolledBackChanges)) { + echo "Rolled back migrations:\n"; + + foreach ($rolledBackChanges as $change) { + echo " ✓ ".$change->getName()."\n"; + } + } else { + echo "No migrations to rollback\n"; + } + + // Verify rollback + $result = $database->setQuery("SHOW TABLES LIKE 'users'")->execute(); + + if ($result->getRowsCount() == 0) { + echo "✓ Users table removed\n"; + } + + echo "\n9. Cleanup:\n"; + $runner->dropSchemaTable(); + echo "✓ Schema tracking table dropped\n"; +} catch (Exception $e) { + echo "✗ Error: ".$e->getMessage()."\n"; + + // Clean up on error + try { + $database->setQuery("DROP TABLE IF EXISTS users")->execute(); + $database->setQuery("DROP TABLE IF EXISTS schema_changes")->execute(); + } catch (Exception $cleanupError) { + // Ignore cleanup errors + } +} + +echo "\n=== Example Complete ===\n"; diff --git a/examples/07-seeders/CategoriesSeeder.php b/examples/07-seeders/CategoriesSeeder.php index e21d1aed..bfda6455 100644 --- a/examples/07-seeders/CategoriesSeeder.php +++ b/examples/07-seeders/CategoriesSeeder.php @@ -1,64 +1,64 @@ - 'Technology', - 'description' => 'Articles about technology and programming', - 'slug' => 'technology' - ], - [ - 'name' => 'Science', - 'description' => 'Scientific articles and research', - 'slug' => 'science' - ], - [ - 'name' => 'Culture', - 'description' => 'Cultural topics and discussions', - 'slug' => 'culture' - ], - [ - 'name' => 'Sports', - 'description' => 'Sports news and updates', - 'slug' => 'sports' - ] - ]; - - foreach ($categories as $category) { - $db->table('categories')->insert($category)->execute(); - } - } -} + 'Technology', + 'description' => 'Articles about technology and programming', + 'slug' => 'technology' + ], + [ + 'name' => 'Science', + 'description' => 'Scientific articles and research', + 'slug' => 'science' + ], + [ + 'name' => 'Culture', + 'description' => 'Cultural topics and discussions', + 'slug' => 'culture' + ], + [ + 'name' => 'Sports', + 'description' => 'Sports news and updates', + 'slug' => 'sports' + ] + ]; + + foreach ($categories as $category) { + $db->table('categories')->insert($category)->execute(); + } + } +} diff --git a/examples/07-seeders/UsersSeeder.php b/examples/07-seeders/UsersSeeder.php index e9bfeccc..332a00d4 100644 --- a/examples/07-seeders/UsersSeeder.php +++ b/examples/07-seeders/UsersSeeder.php @@ -1,55 +1,55 @@ - 'admin', - 'email' => 'admin@example.com', - 'full_name' => 'Administrator', - 'role' => 'admin' - ], - [ - 'username' => 'mohammed_ali', - 'email' => 'mohammed@example.com', - 'full_name' => 'Mohammed Ali Al-Rashid', - 'role' => 'user' - ], - [ - 'username' => 'zahra_hassan', - 'email' => 'zahra@example.com', - 'full_name' => 'Zahra Hassan Al-Mahmoud', - 'role' => 'user' - ], - [ - 'username' => 'omar_khalil', - 'email' => 'omar@example.com', - 'full_name' => 'Omar Khalil Al-Najjar', - 'role' => 'moderator' - ] - ]; - - foreach ($users as $user) { - $db->table('users')->insert($user)->execute(); - } - } -} + 'admin', + 'email' => 'admin@example.com', + 'full_name' => 'Administrator', + 'role' => 'admin' + ], + [ + 'username' => 'mohammed_ali', + 'email' => 'mohammed@example.com', + 'full_name' => 'Mohammed Ali Al-Rashid', + 'role' => 'user' + ], + [ + 'username' => 'zahra_hassan', + 'email' => 'zahra@example.com', + 'full_name' => 'Zahra Hassan Al-Mahmoud', + 'role' => 'user' + ], + [ + 'username' => 'omar_khalil', + 'email' => 'omar@example.com', + 'full_name' => 'Omar Khalil Al-Najjar', + 'role' => 'moderator' + ] + ]; + + foreach ($users as $user) { + $db->table('users')->insert($user)->execute(); + } + } +} diff --git a/examples/07-seeders/example.php b/examples/07-seeders/example.php index e6fc7dde..de32545b 100644 --- a/examples/07-seeders/example.php +++ b/examples/07-seeders/example.php @@ -1,207 +1,211 @@ -setQuery("DROP TABLE IF EXISTS categories")->execute(); - $database->setQuery("DROP TABLE IF EXISTS users")->execute(); - - // Create users table - $database->createBlueprint('users')->addColumns([ - 'id' => [ - ColOption::TYPE => DataType::INT, - ColOption::SIZE => 11, - ColOption::PRIMARY => true, - ColOption::AUTO_INCREMENT => true - ], - 'username' => [ - ColOption::TYPE => DataType::VARCHAR, - ColOption::SIZE => 50, - ColOption::NULL => false - ], - 'email' => [ - ColOption::TYPE => DataType::VARCHAR, - ColOption::SIZE => 150, - ColOption::NULL => false - ], - 'full_name' => [ - ColOption::TYPE => DataType::VARCHAR, - ColOption::SIZE => 100 - ], - 'role' => [ - ColOption::TYPE => DataType::VARCHAR, - ColOption::SIZE => 20, - ColOption::DEFAULT => 'user' - ], - 'is_active' => [ - ColOption::TYPE => DataType::BOOL, - ColOption::DEFAULT => true - ] - ]); - - // Create categories table - $database->createBlueprint('categories')->addColumns([ - 'id' => [ - ColOption::TYPE => DataType::INT, - ColOption::SIZE => 11, - ColOption::PRIMARY => true, - ColOption::AUTO_INCREMENT => true - ], - 'name' => [ - ColOption::TYPE => DataType::VARCHAR, - ColOption::SIZE => 100, - ColOption::NULL => false - ], - 'description' => [ - ColOption::TYPE => DataType::TEXT - ], - 'slug' => [ - ColOption::TYPE => DataType::VARCHAR, - ColOption::SIZE => 100, - ColOption::NULL => false - ] - ]); - - $database->createTables(); - $database->execute(); - - echo "✓ Test tables created\n\n"; - - echo "2. Loading Seeder Classes:\n"; - - // Include seeder classes - require_once __DIR__ . '/UsersSeeder.php'; - require_once __DIR__ . '/CategoriesSeeder.php'; - - echo "✓ Seeder classes loaded\n"; - - echo "3. Setting up Schema Runner:\n"; - - // Create schema runner - $runner = new SchemaRunner($connection); - - // Register seeder classes - $runner->register('UsersSeeder'); - $runner->register('CategoriesSeeder'); - - echo "✓ Schema runner created\n"; - echo "✓ Seeder classes registered\n"; - - // Create schema tracking table - $runner->createSchemaTable(); - echo "✓ Schema tracking table created\n\n"; - - echo "4. Checking Available Seeders:\n"; - - $changes = $runner->getChanges(); - echo "Registered seeders:\n"; - foreach ($changes as $change) { - echo " - " . $change->getName() . "\n"; - } - echo "\n"; - - echo "5. Running Seeders:\n"; - - // Force apply all seeders - $appliedChanges = []; - - foreach ($changes as $change) { - if (!$runner->isApplied($change->getName())) { - $change->execute($database); - $appliedChanges[] = $change; - echo " ✓ Applied: " . $change->getName() . "\n"; - } - } - - if (empty($appliedChanges)) { - echo "No seeders to apply (all up to date)\n"; - } - echo "\n"; - - echo "6. Verifying Seeded Data:\n"; - - // Check users data - $result = $database->table('users')->select()->execute(); - echo "Seeded users ({$result->getRowsCount()} records):\n"; - foreach ($result as $user) { - $status = $user['is_active'] ? 'Active' : 'Inactive'; - echo " - {$user['full_name']} (@{$user['username']}) - {$user['role']} - {$status}\n"; - } - echo "\n"; - - // Check categories data - $result = $database->table('categories')->select()->execute(); - echo "Seeded categories ({$result->getRowsCount()} records):\n"; - foreach ($result as $category) { - echo " - {$category['name']} ({$category['slug']})\n"; - echo " {$category['description']}\n"; - } - echo "\n"; - - echo "7. Testing Seeder Status:\n"; - - // Check which seeders are applied - echo "Seeder status:\n"; - foreach ($changes as $change) { - $status = $runner->isApplied($change->getName()) ? "✓ Applied" : "✗ Pending"; - echo " {$change->getName()}: $status\n"; - } - echo "\n"; - - echo "8. Rolling Back Seeders:\n"; - - // Rollback all seeders (this will clear the data) - $rolledBackChanges = []; - - // Reverse order for rollback - $reversedChanges = array_reverse($changes); - foreach ($reversedChanges as $change) { - $change->rollback($database); - $rolledBackChanges[] = $change; - echo " ✓ Rolled back: " . $change->getName() . "\n"; - } - - // Verify rollback - $userCount = $database->table('users')->select()->execute()->getRowsCount(); - $categoryCount = $database->table('categories')->select()->execute()->getRowsCount(); - - echo "After rollback:\n"; - echo " Users: $userCount records\n"; - echo " Categories: $categoryCount records\n"; - echo "✓ Seeders rolled back successfully\n\n"; - - echo "9. Cleanup:\n"; - $runner->dropSchemaTable(); - $database->setQuery("DROP TABLE categories")->execute(); - $database->setQuery("DROP TABLE users")->execute(); - echo "✓ Test tables and schema tracking table dropped\n"; - -} catch (Exception $e) { - echo "✗ Error: " . $e->getMessage() . "\n"; - - // Clean up on error - try { - $database->setQuery("DROP TABLE IF EXISTS categories")->execute(); - $database->setQuery("DROP TABLE IF EXISTS users")->execute(); - $database->setQuery("DROP TABLE IF EXISTS schema_changes")->execute(); - } catch (Exception $cleanupError) { - // Ignore cleanup errors - } -} - -echo "\n=== Example Complete ===\n"; +setQuery("DROP TABLE IF EXISTS categories")->execute(); + $database->setQuery("DROP TABLE IF EXISTS users")->execute(); + + // Create users table + $database->createBlueprint('users')->addColumns([ + 'id' => [ + ColOption::TYPE => DataType::INT, + ColOption::SIZE => 11, + ColOption::PRIMARY => true, + ColOption::AUTO_INCREMENT => true + ], + 'username' => [ + ColOption::TYPE => DataType::VARCHAR, + ColOption::SIZE => 50, + ColOption::NULL => false + ], + 'email' => [ + ColOption::TYPE => DataType::VARCHAR, + ColOption::SIZE => 150, + ColOption::NULL => false + ], + 'full_name' => [ + ColOption::TYPE => DataType::VARCHAR, + ColOption::SIZE => 100 + ], + 'role' => [ + ColOption::TYPE => DataType::VARCHAR, + ColOption::SIZE => 20, + ColOption::DEFAULT => 'user' + ], + 'is_active' => [ + ColOption::TYPE => DataType::BOOL, + ColOption::DEFAULT => true + ] + ]); + + // Create categories table + $database->createBlueprint('categories')->addColumns([ + 'id' => [ + ColOption::TYPE => DataType::INT, + ColOption::SIZE => 11, + ColOption::PRIMARY => true, + ColOption::AUTO_INCREMENT => true + ], + 'name' => [ + ColOption::TYPE => DataType::VARCHAR, + ColOption::SIZE => 100, + ColOption::NULL => false + ], + 'description' => [ + ColOption::TYPE => DataType::TEXT + ], + 'slug' => [ + ColOption::TYPE => DataType::VARCHAR, + ColOption::SIZE => 100, + ColOption::NULL => false + ] + ]); + + $database->createTables(); + $database->execute(); + + echo "✓ Test tables created\n\n"; + + echo "2. Loading Seeder Classes:\n"; + + // Include seeder classes + require_once __DIR__.'/UsersSeeder.php'; + require_once __DIR__.'/CategoriesSeeder.php'; + + echo "✓ Seeder classes loaded\n"; + + echo "3. Setting up Schema Runner:\n"; + + // Create schema runner + $runner = new SchemaRunner($connection); + + // Register seeder classes + $runner->register('UsersSeeder'); + $runner->register('CategoriesSeeder'); + + echo "✓ Schema runner created\n"; + echo "✓ Seeder classes registered\n"; + + // Create schema tracking table + $runner->createSchemaTable(); + echo "✓ Schema tracking table created\n\n"; + + echo "4. Checking Available Seeders:\n"; + + $changes = $runner->getChanges(); + echo "Registered seeders:\n"; + + foreach ($changes as $change) { + echo " - ".$change->getName()."\n"; + } + echo "\n"; + + echo "5. Running Seeders:\n"; + + // Force apply all seeders + $appliedChanges = []; + + foreach ($changes as $change) { + if (!$runner->isApplied($change->getName())) { + $change->execute($database); + $appliedChanges[] = $change; + echo " ✓ Applied: ".$change->getName()."\n"; + } + } + + if (empty($appliedChanges)) { + echo "No seeders to apply (all up to date)\n"; + } + echo "\n"; + + echo "6. Verifying Seeded Data:\n"; + + // Check users data + $result = $database->table('users')->select()->execute(); + echo "Seeded users ({$result->getRowsCount()} records):\n"; + + foreach ($result as $user) { + $status = $user['is_active'] ? 'Active' : 'Inactive'; + echo " - {$user['full_name']} (@{$user['username']}) - {$user['role']} - {$status}\n"; + } + echo "\n"; + + // Check categories data + $result = $database->table('categories')->select()->execute(); + echo "Seeded categories ({$result->getRowsCount()} records):\n"; + + foreach ($result as $category) { + echo " - {$category['name']} ({$category['slug']})\n"; + echo " {$category['description']}\n"; + } + echo "\n"; + + echo "7. Testing Seeder Status:\n"; + + // Check which seeders are applied + echo "Seeder status:\n"; + + foreach ($changes as $change) { + $status = $runner->isApplied($change->getName()) ? "✓ Applied" : "✗ Pending"; + echo " {$change->getName()}: $status\n"; + } + echo "\n"; + + echo "8. Rolling Back Seeders:\n"; + + // Rollback all seeders (this will clear the data) + $rolledBackChanges = []; + + // Reverse order for rollback + $reversedChanges = array_reverse($changes); + + foreach ($reversedChanges as $change) { + $change->rollback($database); + $rolledBackChanges[] = $change; + echo " ✓ Rolled back: ".$change->getName()."\n"; + } + + // Verify rollback + $userCount = $database->table('users')->select()->execute()->getRowsCount(); + $categoryCount = $database->table('categories')->select()->execute()->getRowsCount(); + + echo "After rollback:\n"; + echo " Users: $userCount records\n"; + echo " Categories: $categoryCount records\n"; + echo "✓ Seeders rolled back successfully\n\n"; + + echo "9. Cleanup:\n"; + $runner->dropSchemaTable(); + $database->setQuery("DROP TABLE categories")->execute(); + $database->setQuery("DROP TABLE users")->execute(); + echo "✓ Test tables and schema tracking table dropped\n"; +} catch (Exception $e) { + echo "✗ Error: ".$e->getMessage()."\n"; + + // Clean up on error + try { + $database->setQuery("DROP TABLE IF EXISTS categories")->execute(); + $database->setQuery("DROP TABLE IF EXISTS users")->execute(); + $database->setQuery("DROP TABLE IF EXISTS schema_changes")->execute(); + } catch (Exception $cleanupError) { + // Ignore cleanup errors + } +} + +echo "\n=== Example Complete ===\n"; diff --git a/examples/08-performance-monitoring/example.php b/examples/08-performance-monitoring/example.php index 272446b8..c6275a5c 100644 --- a/examples/08-performance-monitoring/example.php +++ b/examples/08-performance-monitoring/example.php @@ -1,38 +1,38 @@ -setPerformanceConfig([ - PerformanceOption::ENABLED => true, - PerformanceOption::SLOW_QUERY_THRESHOLD => 50, // 50ms threshold - PerformanceOption::WARNING_THRESHOLD => 25, // 25ms warning - PerformanceOption::SAMPLING_RATE => 1.0, // Monitor all queries - PerformanceOption::MAX_SAMPLES => 1000 // Keep up to 1000 samples - ]); - echo "✓ Performance monitoring configured\n"; - echo " - Slow query threshold: 50ms\n"; - echo " - Warning threshold: 25ms\n"; - echo " - Sampling rate: 100%\n\n"; - - // 3. Create test table for demonstration - echo "3. Creating test table:\n"; - $database->setQuery("DROP TABLE IF EXISTS performance_test")->execute(); +setPerformanceConfig([ + PerformanceOption::ENABLED => true, + PerformanceOption::SLOW_QUERY_THRESHOLD => 50, // 50ms threshold + PerformanceOption::WARNING_THRESHOLD => 25, // 25ms warning + PerformanceOption::SAMPLING_RATE => 1.0, // Monitor all queries + PerformanceOption::MAX_SAMPLES => 1000 // Keep up to 1000 samples + ]); + echo "✓ Performance monitoring configured\n"; + echo " - Slow query threshold: 50ms\n"; + echo " - Warning threshold: 25ms\n"; + echo " - Sampling rate: 100%\n\n"; + + // 3. Create test table for demonstration + echo "3. Creating test table:\n"; + $database->setQuery("DROP TABLE IF EXISTS performance_test")->execute(); $database->setQuery(" CREATE TABLE performance_test ( id INT AUTO_INCREMENT PRIMARY KEY, @@ -41,108 +41,108 @@ created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, INDEX idx_email (email) ) - ")->execute(); - echo "✓ Test table created\n\n"; - - // 4. Execute various queries with different performance characteristics - echo "4. Executing test queries:\n"; - - // Fast queries - for ($i = 1; $i <= 5; $i++) { - $database->table('performance_test')->insert([ - 'name' => "User $i", - 'email' => "user$i@example.com" - ])->execute(); - } - echo "✓ Executed 5 fast INSERT queries\n"; - - // Medium speed queries - for ($i = 1; $i <= 3; $i++) { - $database->table('performance_test') - ->select() - ->where('email', "user$i@example.com") - ->execute(); - } - echo "✓ Executed 3 medium SELECT queries\n"; - - // Simulate slow queries with SLEEP - $database->setQuery("SELECT SLEEP(0.03)")->execute(); // 30ms - $database->setQuery("SELECT SLEEP(0.08)")->execute(); // 80ms - $database->setQuery("SELECT SLEEP(0.12)")->execute(); // 120ms - echo "✓ Executed 3 slow queries with artificial delays\n\n"; - - // 5. Analyze performance using the new PerformanceAnalyzer - echo "5. Performance Analysis:\n"; - $analyzer = $database->getPerformanceMonitor()->getAnalyzer(); - - echo "Query Statistics:\n"; - echo " - Total queries: ".$analyzer->getQueryCount()."\n"; - echo " - Total execution time: ".number_format($analyzer->getTotalTime(), 2)." ms\n"; - echo " - Average execution time: ".number_format($analyzer->getAverageTime(), 2)." ms\n"; - echo " - Performance score: ".$analyzer->getScore()."\n"; - echo " - Query efficiency: ".number_format($analyzer->getEfficiency(), 1)."%\n\n"; - - // 6. Analyze slow queries - echo "6. Slow Query Analysis:\n"; - $slowQueries = $analyzer->getSlowQueries(); - echo " - Slow queries found: ".$analyzer->getSlowQueryCount()."\n"; - - if (!empty($slowQueries)) { - echo " - Slow query details:\n"; - - foreach ($slowQueries as $index => $metric) { - $query = $metric->getQuery(); - $time = $metric->getExecutionTimeMs(); - $rows = $metric->getRowsAffected(); - - // Truncate long queries for display - $displayQuery = strlen($query) > 60 ? substr($query, 0, 57).'...' : $query; - echo " ".($index + 1).". ".number_format($time, 2)."ms - $displayQuery ($rows rows)\n"; - } - } else { - echo " - No slow queries detected\n"; - } - echo "\n"; - - // 7. Performance recommendations - echo "7. Performance Recommendations:\n"; - $score = $analyzer->getScore(); - $efficiency = $analyzer->getEfficiency(); - - switch ($score) { - case PerformanceAnalyzer::SCORE_EXCELLENT: - echo " ✓ Excellent performance! Your queries are running very efficiently.\n"; - break; - case PerformanceAnalyzer::SCORE_GOOD: - echo " ✓ Good performance overall. Consider optimizing slow queries if any.\n"; - break; - case PerformanceAnalyzer::SCORE_NEEDS_IMPROVEMENT: - echo " ⚠ Performance needs improvement. Focus on optimizing slow queries.\n"; - break; - } - - if ($efficiency < 80) { - echo " ⚠ Query efficiency is below 80%. Consider:\n"; - echo " - Adding database indexes\n"; - echo " - Optimizing query structure\n"; - echo " - Reviewing WHERE clauses\n"; - } - - if ($analyzer->getSlowQueryCount() > 0) { - echo " ⚠ Slow queries detected. Consider:\n"; - echo " - Adding appropriate indexes\n"; - echo " - Limiting result sets with LIMIT\n"; - echo " - Breaking complex queries into smaller ones\n"; - } - echo "\n"; - - // 8. Cleanup - echo "8. Cleanup:\n"; - $database->setQuery("DROP TABLE performance_test")->execute(); - echo "✓ Test table dropped\n"; -} catch (Exception $e) { - echo "Error: ".$e->getMessage()."\n"; - echo "Stack trace:\n".$e->getTraceAsString()."\n"; -} - -echo "\n=== Example Complete ==="; + ")->execute(); + echo "✓ Test table created\n\n"; + + // 4. Execute various queries with different performance characteristics + echo "4. Executing test queries:\n"; + + // Fast queries + for ($i = 1; $i <= 5; $i++) { + $database->table('performance_test')->insert([ + 'name' => "User $i", + 'email' => "user$i@example.com" + ])->execute(); + } + echo "✓ Executed 5 fast INSERT queries\n"; + + // Medium speed queries + for ($i = 1; $i <= 3; $i++) { + $database->table('performance_test') + ->select() + ->where('email', "user$i@example.com") + ->execute(); + } + echo "✓ Executed 3 medium SELECT queries\n"; + + // Simulate slow queries with SLEEP + $database->setQuery("SELECT SLEEP(0.03)")->execute(); // 30ms + $database->setQuery("SELECT SLEEP(0.08)")->execute(); // 80ms + $database->setQuery("SELECT SLEEP(0.12)")->execute(); // 120ms + echo "✓ Executed 3 slow queries with artificial delays\n\n"; + + // 5. Analyze performance using the new PerformanceAnalyzer + echo "5. Performance Analysis:\n"; + $analyzer = $database->getPerformanceMonitor()->getAnalyzer(); + + echo "Query Statistics:\n"; + echo " - Total queries: ".$analyzer->getQueryCount()."\n"; + echo " - Total execution time: ".number_format($analyzer->getTotalTime(), 2)." ms\n"; + echo " - Average execution time: ".number_format($analyzer->getAverageTime(), 2)." ms\n"; + echo " - Performance score: ".$analyzer->getScore()."\n"; + echo " - Query efficiency: ".number_format($analyzer->getEfficiency(), 1)."%\n\n"; + + // 6. Analyze slow queries + echo "6. Slow Query Analysis:\n"; + $slowQueries = $analyzer->getSlowQueries(); + echo " - Slow queries found: ".$analyzer->getSlowQueryCount()."\n"; + + if (!empty($slowQueries)) { + echo " - Slow query details:\n"; + + foreach ($slowQueries as $index => $metric) { + $query = $metric->getQuery(); + $time = $metric->getExecutionTimeMs(); + $rows = $metric->getRowsAffected(); + + // Truncate long queries for display + $displayQuery = strlen($query) > 60 ? substr($query, 0, 57).'...' : $query; + echo " ".($index + 1).". ".number_format($time, 2)."ms - $displayQuery ($rows rows)\n"; + } + } else { + echo " - No slow queries detected\n"; + } + echo "\n"; + + // 7. Performance recommendations + echo "7. Performance Recommendations:\n"; + $score = $analyzer->getScore(); + $efficiency = $analyzer->getEfficiency(); + + switch ($score) { + case PerformanceAnalyzer::SCORE_EXCELLENT: + echo " ✓ Excellent performance! Your queries are running very efficiently.\n"; + break; + case PerformanceAnalyzer::SCORE_GOOD: + echo " ✓ Good performance overall. Consider optimizing slow queries if any.\n"; + break; + case PerformanceAnalyzer::SCORE_NEEDS_IMPROVEMENT: + echo " ⚠ Performance needs improvement. Focus on optimizing slow queries.\n"; + break; + } + + if ($efficiency < 80) { + echo " ⚠ Query efficiency is below 80%. Consider:\n"; + echo " - Adding database indexes\n"; + echo " - Optimizing query structure\n"; + echo " - Reviewing WHERE clauses\n"; + } + + if ($analyzer->getSlowQueryCount() > 0) { + echo " ⚠ Slow queries detected. Consider:\n"; + echo " - Adding appropriate indexes\n"; + echo " - Limiting result sets with LIMIT\n"; + echo " - Breaking complex queries into smaller ones\n"; + } + echo "\n"; + + // 8. Cleanup + echo "8. Cleanup:\n"; + $database->setQuery("DROP TABLE performance_test")->execute(); + echo "✓ Test table dropped\n"; +} catch (Exception $e) { + echo "Error: ".$e->getMessage()."\n"; + echo "Stack trace:\n".$e->getTraceAsString()."\n"; +} + +echo "\n=== Example Complete ==="; diff --git a/examples/09-multi-result-queries/example.php b/examples/09-multi-result-queries/example.php index d790c338..190d609b 100644 --- a/examples/09-multi-result-queries/example.php +++ b/examples/09-multi-result-queries/example.php @@ -113,25 +113,25 @@ if ($result instanceof MultiResultSet) { echo "✓ Multi-result query executed successfully!\n"; - echo "Number of result sets: " . $result->count() . "\n\n"; + echo "Number of result sets: ".$result->count()."\n\n"; // Process each result set for ($i = 0; $i < $result->count(); $i++) { $resultSet = $result->getResultSet($i); - + if ($resultSet->getRowsCount() > 0) { $firstRow = $resultSet->getRows()[0]; - + if (isset($firstRow['report_section'])) { echo "--- {$firstRow['report_section']} ---\n"; - + foreach ($resultSet as $row) { if ($row['report_section'] === 'Product Inventory') { - echo " {$row['category']}: {$row['name']} - $" . number_format($row['price'], 2) . " (Stock: {$row['stock']})\n"; + echo " {$row['category']}: {$row['name']} - $".number_format($row['price'], 2)." (Stock: {$row['stock']})\n"; } elseif ($row['report_section'] === 'Sales by Category') { - echo " {$row['category']}: {$row['total_orders']} orders, {$row['total_quantity']} items, $" . number_format($row['total_revenue'], 2) . " revenue\n"; + echo " {$row['category']}: {$row['total_orders']} orders, {$row['total_quantity']} items, $".number_format($row['total_revenue'], 2)." revenue\n"; } elseif ($row['report_section'] === 'Recent Orders') { - echo " {$row['customer_name']}: {$row['product_name']} x{$row['quantity']} = $" . number_format($row['order_total'], 2) . " ({$row['order_date']})\n"; + echo " {$row['customer_name']}: {$row['product_name']} x{$row['quantity']} = $".number_format($row['order_total'], 2)." ({$row['order_date']})\n"; } } echo "\n"; @@ -184,21 +184,21 @@ for ($i = 0; $i < $categoryResult->count(); $i++) { $rs = $categoryResult->getResultSet($i); - + if ($rs->getRowsCount() > 0) { $firstRow = $rs->getRows()[0]; - + if (isset($firstRow['report_section'])) { echo "--- {$firstRow['report_section']} ---\n"; - + foreach ($rs as $row) { if ($row['report_section'] === 'Products in Category') { - echo " {$row['name']}: $" . number_format($row['price'], 2) . " (Stock: {$row['stock']})\n"; + echo " {$row['name']}: $".number_format($row['price'], 2)." (Stock: {$row['stock']})\n"; } elseif ($row['report_section'] === 'Category Statistics') { echo " Category: {$row['category']}\n"; echo " Product Count: {$row['product_count']}\n"; - echo " Average Price: $" . number_format($row['avg_price'], 2) . "\n"; - echo " Price Range: $" . number_format($row['min_price'], 2) . " - $" . number_format($row['max_price'], 2) . "\n"; + echo " Average Price: $".number_format($row['avg_price'], 2)."\n"; + echo " Price Range: $".number_format($row['min_price'], 2)." - $".number_format($row['max_price'], 2)."\n"; echo " Total Stock: {$row['total_stock']}\n"; } elseif ($row['report_section'] === 'Category Orders') { echo " {$row['customer_name']}: {$row['product_name']} x{$row['quantity']} ({$row['order_date']})\n"; @@ -222,12 +222,13 @@ $ordersResults = $businessReport->getResultSet(2); echo "✓ Extracted individual result sets:\n"; - echo " - Inventory results: " . $inventoryResults->getRowsCount() . " products\n"; - echo " - Sales results: " . $salesResults->getRowsCount() . " categories\n"; - echo " - Orders results: " . $ordersResults->getRowsCount() . " orders\n\n"; + echo " - Inventory results: ".$inventoryResults->getRowsCount()." products\n"; + echo " - Sales results: ".$salesResults->getRowsCount()." categories\n"; + echo " - Orders results: ".$ordersResults->getRowsCount()." orders\n\n"; // Process specific result set echo "Low stock products (< 20 items):\n"; + foreach ($inventoryResults as $product) { if ($product['stock'] < 20) { echo " ⚠️ {$product['name']}: {$product['stock']} remaining\n"; @@ -238,16 +239,16 @@ // Find best selling category $bestCategory = null; $bestRevenue = 0; - + foreach ($salesResults as $category) { if ($category['total_revenue'] > $bestRevenue) { $bestRevenue = $category['total_revenue']; $bestCategory = $category['category']; } } - + if ($bestCategory) { - echo "🏆 Best performing category: {$bestCategory} ($" . number_format($bestRevenue, 2) . " revenue)\n\n"; + echo "🏆 Best performing category: {$bestCategory} ($".number_format($bestRevenue, 2)." revenue)\n\n"; } } @@ -290,15 +291,16 @@ // Test with different parameters echo "Summary Report:\n"; $summaryResult = $database->raw("CALL GetDynamicReport(?)", ['summary'])->execute(); - + if ($summaryResult instanceof MultiResultSet) { for ($i = 0; $i < $summaryResult->count(); $i++) { $rs = $summaryResult->getResultSet($i); + foreach ($rs as $row) { if (isset($row['metric'])) { echo " {$row['metric']}: {$row['value']}\n"; } elseif (isset($row['month'])) { - echo " {$row['month']}: $" . number_format($row['revenue'], 2) . "\n"; + echo " {$row['month']}: $".number_format($row['revenue'], 2)."\n"; } } } @@ -311,10 +313,9 @@ $database->raw("DROP TABLE orders")->execute(); $database->raw("DROP TABLE products")->execute(); echo "✓ Test tables and procedures dropped\n"; - } catch (Exception $e) { echo "✗ Error: ".$e->getMessage()."\n"; - echo "Stack trace:\n" . $e->getTraceAsString() . "\n"; + echo "Stack trace:\n".$e->getTraceAsString()."\n"; // Clean up on error try { From d5e533a56634fdb157c224aceacfa90ca2dfc569 Mon Sep 17 00:00:00 2001 From: Ibrahim BinAlshikh Date: Mon, 5 Jan 2026 00:46:00 +0300 Subject: [PATCH 34/44] docs: Updated Code Samples --- examples/03-table-blueprints/UserTable.php | 6 +- examples/03-table-blueprints/example.php | 64 +++--- examples/04-entity-mapping/example.php | 173 +++++---------- .../06-migrations/AddEmailIndexMigration.php | 34 +-- .../CreateUsersTableMigration.php | 30 +-- examples/06-migrations/example.php | 71 +++---- examples/07-seeders/CategoriesSeeder.php | 46 +--- examples/07-seeders/UsersSeeder.php | 21 +- examples/07-seeders/example.php | 95 ++++----- .../10-attribute-based-tables/example.php | 157 ++++++++++++++ examples/11-repository-pattern/example.php | 197 ++++++++++++++++++ .../12-clean-architecture/Domain/User.php | 15 ++ .../Domain/UserRepositoryInterface.php | 13 ++ .../Repository/MySQLUserRepository.php | 46 ++++ examples/12-clean-architecture/example.php | 86 ++++++++ examples/13-pagination/example.php | 123 +++++++++++ examples/README.md | 112 +++++----- 17 files changed, 880 insertions(+), 409 deletions(-) create mode 100644 examples/10-attribute-based-tables/example.php create mode 100644 examples/11-repository-pattern/example.php create mode 100644 examples/12-clean-architecture/Domain/User.php create mode 100644 examples/12-clean-architecture/Domain/UserRepositoryInterface.php create mode 100644 examples/12-clean-architecture/Infrastructure/Repository/MySQLUserRepository.php create mode 100644 examples/12-clean-architecture/example.php create mode 100644 examples/13-pagination/example.php diff --git a/examples/03-table-blueprints/UserTable.php b/examples/03-table-blueprints/UserTable.php index deb748e5..554e605f 100644 --- a/examples/03-table-blueprints/UserTable.php +++ b/examples/03-table-blueprints/UserTable.php @@ -29,15 +29,15 @@ public function __construct() { ColOption::SIZE => 150, ColOption::NULL => false ], - 'full_name' => [ + 'full-name' => [ ColOption::TYPE => DataType::VARCHAR, ColOption::SIZE => 100 ], - 'is_active' => [ + 'is-active' => [ ColOption::TYPE => DataType::BOOL, ColOption::DEFAULT => true ], - 'created_at' => [ + 'created-at' => [ ColOption::TYPE => DataType::TIMESTAMP, ColOption::DEFAULT => 'current_timestamp' ] diff --git a/examples/03-table-blueprints/example.php b/examples/03-table-blueprints/example.php index dda84c06..1bfa82d9 100644 --- a/examples/03-table-blueprints/example.php +++ b/examples/03-table-blueprints/example.php @@ -41,7 +41,7 @@ ]); echo "✓ Users table blueprint created\n"; - echo " Columns: ".implode(', ', array_keys($usersTable->getColsNames()))."\n\n"; + echo " Columns: ".implode(', ', array_keys($usersTable->getCols()))."\n\n"; echo "2. Creating Posts Table Blueprint:\n"; @@ -53,7 +53,7 @@ ColOption::PRIMARY => true, ColOption::AUTO_INCREMENT => true ], - 'user_id' => [ + 'user-id' => [ ColOption::TYPE => DataType::INT, ColOption::SIZE => 11, ColOption::NULL => false @@ -66,34 +66,34 @@ 'content' => [ ColOption::TYPE => DataType::TEXT ], - 'created_at' => [ + 'created-at' => [ ColOption::TYPE => DataType::TIMESTAMP, ColOption::DEFAULT => 'current_timestamp' ] ]); echo "✓ Posts table blueprint created\n"; - echo " Columns: ".implode(', ', array_keys($postsTable->getColsNames()))."\n\n"; + echo " Columns: ".implode(', ', array_keys($postsTable->getCols()))."\n\n"; echo "3. Adding Foreign Key Relationship:\n"; - // Add foreign key relationship - $postsTable->addReference($usersTable, ['user_id'], 'user_fk'); + // Add foreign key relationship with CASCADE actions + $postsTable->addReference($usersTable, ['user-id' => 'id'], 'user_fk', 'cascade', 'cascade'); echo "✓ Foreign key relationship added (posts.user_id -> users.id)\n\n"; - echo "4. Generating and Executing CREATE TABLE Statements:\n"; - - // Generate create table queries - $database->createTables(); + echo "4. Creating Tables One by One:\n"; - // Show the generated SQL - $sql = $database->getLastQuery(); - echo "Generated SQL:\n"; - echo str_replace(';', ";\n", $sql)."\n\n"; + // Create users table first (no dependencies) + $database->table('users')->createTable(); + echo "SQL for users table:\n".$database->getLastQuery()."\n\n"; + $database->execute(); + echo "✓ Users table created\n"; - // Execute the queries + // Create posts table (depends on users) + $database->table('posts')->createTable(); + echo "SQL for posts table:\n".$database->getLastQuery()."\n\n"; $database->execute(); - echo "✓ Tables created successfully\n\n"; + echo "✓ Posts table created\n\n"; echo "5. Testing the Created Tables:\n"; @@ -112,30 +112,25 @@ $userId = $userResult->getRows()[0]['id']; $database->table('posts')->insert([ - 'user_id' => $userId, + 'user-id' => $userId, 'title' => 'My First Post', 'content' => 'This is the content of my first post.' ])->execute(); echo "✓ Inserted test post\n"; // Query with join to show relationship - $result = $database->setQuery(" - SELECT u.username, p.title, p.created_at - FROM users u - JOIN posts p ON u.id = p.user_id + $result = $database->raw(" + SELECT u.username, p.title, p.created_at + FROM users u + JOIN posts p ON u.id = p.user_id ")->execute(); echo "\nJoined data:\n"; - foreach ($result as $row) { echo " User: {$row['username']}, Post: {$row['title']}, Created: {$row['created_at']}\n"; } - echo "\n6. Using Custom Table Class (Extending MySQLTable):\n"; - - // Clean up previous tables first - $database->setQuery("DROP TABLE IF EXISTS posts")->execute(); - $database->setQuery("DROP TABLE IF EXISTS users")->execute(); + echo "\n6. Using Custom Table Class:\n"; // Include the custom table class require_once __DIR__.'/UserTable.php'; @@ -150,31 +145,32 @@ // Generate and execute CREATE TABLE for custom table $createQuery = $customTable->toSQL(); - echo "\nGenerated SQL for custom table:\n"; - echo $createQuery."\n\n"; + echo "\nGenerated SQL for custom table:\n$createQuery\n\n"; // Execute the custom table creation - $database->setQuery($createQuery)->execute(); + $database->raw($createQuery)->execute(); echo "✓ Custom table created successfully\n"; // Test the custom table + $database->addTable($customTable); $database->table('users_extended')->insert([ 'username' => 'sara_ahmad', 'email' => 'sara@example.com', - 'full_name' => 'Sara Ahmad Al-Mansouri' + 'full-name' => 'Sara Ahmad Al-Mansouri' ])->execute(); echo "✓ Inserted test data into custom table\n"; // Query the custom table $result = $database->table('users_extended')->select()->execute(); echo "Custom table data:\n"; - foreach ($result as $row) { echo " User: {$row['full_name']} ({$row['username']}) - Active: ".($row['is_active'] ? 'Yes' : 'No')."\n"; } - echo "\n7. Final Cleanup:\n"; - $database->setQuery("DROP TABLE users_extended")->execute(); + echo "\n7. Cleanup:\n"; + $database->raw("DROP TABLE IF EXISTS users_extended")->execute(); + $database->raw("DROP TABLE IF EXISTS posts")->execute(); + $database->raw("DROP TABLE IF EXISTS users")->execute(); echo "✓ Tables dropped\n"; } catch (Exception $e) { echo "✗ Error: ".$e->getMessage()."\n"; diff --git a/examples/04-entity-mapping/example.php b/examples/04-entity-mapping/example.php index e2190abc..366778ce 100644 --- a/examples/04-entity-mapping/example.php +++ b/examples/04-entity-mapping/example.php @@ -9,159 +9,96 @@ echo "=== WebFiori Database Entity Mapping Example ===\n\n"; +echo "NOTE: EntityMapper is deprecated for production use.\n"; +echo " Use manual entity classes with Repository pattern instead.\n"; +echo " This example shows both approaches.\n\n"; + try { - // Create connection $connection = new ConnectionInfo('mysql', 'root', '123456', 'mysql'); $database = new Database($connection); - echo "1. Creating User Table Blueprint:\n"; + echo "1. Creating User Table:\n"; - // Create user table blueprint $userTable = $database->createBlueprint('users')->addColumns([ - 'id' => [ - ColOption::TYPE => DataType::INT, - ColOption::SIZE => 11, - ColOption::PRIMARY => true, - ColOption::AUTO_INCREMENT => true - ], - 'first_name' => [ - ColOption::TYPE => DataType::VARCHAR, - ColOption::SIZE => 50, - ColOption::NULL => false - ], - 'last_name' => [ - ColOption::TYPE => DataType::VARCHAR, - ColOption::SIZE => 50, - ColOption::NULL => false - ], - 'email' => [ - ColOption::TYPE => DataType::VARCHAR, - ColOption::SIZE => 150, - ColOption::NULL => false - ], - 'age' => [ - ColOption::TYPE => DataType::INT, - ColOption::SIZE => 3 - ] + 'id' => [ColOption::TYPE => DataType::INT, ColOption::PRIMARY => true, ColOption::AUTO_INCREMENT => true], + 'first-name' => [ColOption::TYPE => DataType::VARCHAR, ColOption::SIZE => 50], + 'last-name' => [ColOption::TYPE => DataType::VARCHAR, ColOption::SIZE => 50], + 'email' => [ColOption::TYPE => DataType::VARCHAR, ColOption::SIZE => 150], + 'age' => [ColOption::TYPE => DataType::INT, ColOption::SIZE => 3] ]); - echo "✓ User table blueprint created\n\n"; + $database->table('users')->createTable(); + $database->execute(); + echo "✓ User table created\n\n"; + + echo "2. Inserting Test Data:\n"; - echo "2. Creating Entity Class:\n"; + $database->table('users')->insert(['first-name' => 'Khalid', 'last-name' => 'Al-Rashid', 'email' => 'khalid@example.com', 'age' => 30])->execute(); + $database->table('users')->insert(['first-name' => 'Aisha', 'last-name' => 'Mahmoud', 'email' => 'aisha@example.com', 'age' => 25])->execute(); + $database->table('users')->insert(['first-name' => 'Hassan', 'last-name' => 'Al-Najjar', 'email' => 'hassan@example.com', 'age' => 35])->execute(); + echo "✓ Test users inserted\n\n"; + + // ============================================ + // APPROACH 1: Using EntityMapper (Deprecated) + // ============================================ + echo "3. DEPRECATED: Using EntityMapper (for rapid prototyping only):\n"; - // Get entity mapper and create entity class $entityMapper = $userTable->getEntityMapper(); $entityMapper->setEntityName('User'); $entityMapper->setNamespace(''); $entityMapper->setPath(__DIR__); - - // Create the entity class $entityMapper->create(); - echo "✓ User entity class created at: ".__DIR__."/User.php\n"; - + echo "✓ User entity class generated at: ".__DIR__."/User.php\n"; - echo "3. Creating Table in Database:\n"; - - // Create the table - $database->createTables(); - $database->execute(); - echo "✓ User table created in database\n\n"; - - echo "4. Inserting Test Data:\n"; - - // Insert test users - $database->table('users')->insert([ - 'first_name' => 'Khalid', - 'last_name' => 'Al-Rashid', - 'email' => 'khalid.rashid@example.com', - 'age' => 30 - ])->execute(); - - $database->table('users')->insert([ - 'first_name' => 'Aisha', - 'last_name' => 'Mahmoud', - 'email' => 'aisha.mahmoud@example.com', - 'age' => 25 - ])->execute(); - - $database->table('users')->insert([ - 'first_name' => 'Hassan', - 'last_name' => 'Al-Najjar', - 'email' => 'hassan.najjar@example.com', - 'age' => 35 - ])->execute(); - - echo "✓ Test users inserted\n\n"; - - echo "5. Fetching and Mapping Records:\n"; - - // Include the generated entity class require_once __DIR__.'/User.php'; - // Fetch records and map to objects $resultSet = $database->table('users')->select()->execute(); + $mappedUsers = $resultSet->map(fn($record) => User::map($record)); - $mappedUsers = $resultSet->map(function (array $record) - { - return User::map($record); - }); - - echo "Mapped users as objects:\n"; - + echo "Mapped users (EntityMapper):\n"; foreach ($mappedUsers as $user) { - echo " - {$user->getFirstName()} {$user->getLastName()} ({$user->getEmail()}) - Age: {$user->getAge()}\n"; + echo " - {$user->getFirstName()} {$user->getLastName()} ({$user->getEmail()})\n"; } echo "\n"; - echo "6. Working with Individual Objects:\n"; - - // Get first user and demonstrate object methods - $firstUser = $mappedUsers->getRows()[0]; - echo "First user details:\n"; - echo " ID: {$firstUser->getId()}\n"; - echo " Full Name: {$firstUser->getFirstName()} {$firstUser->getLastName()}\n"; - echo " Email: {$firstUser->getEmail()}\n"; - echo " Age: {$firstUser->getAge()}\n\n"; - - echo "7. Filtering with Entity Objects:\n"; - - // Filter users by age - $adultUsers = []; - - foreach ($mappedUsers as $user) { - if ($user->getAge() >= 30) { - $adultUsers[] = $user; - } - } - - echo "Users 30 or older:\n"; - - foreach ($adultUsers as $user) { - echo " - {$user->getFirstName()} {$user->getLastName()} (Age: {$user->getAge()})\n"; + // ============================================ + // APPROACH 2: Manual Entity (Recommended) + // ============================================ + echo "4. RECOMMENDED: Manual Entity with Repository Pattern:\n"; + + // Define entity manually (in real code, this would be in a separate file) + // See example 11-repository-pattern for full implementation + + echo "Manual entity mapping example:\n"; + $result = $database->table('users')->select()->execute(); + foreach ($result as $row) { + // Manual mapping - more control, no code generation + $user = (object) [ + 'id' => (int) $row['id'], + 'firstName' => $row['first_name'], + 'lastName' => $row['last_name'], + 'email' => $row['email'], + 'age' => (int) $row['age'], + 'fullName' => $row['first_name'].' '.$row['last_name'] + ]; + echo " - {$user->fullName} ({$user->email}) - Age: {$user->age}\n"; } + echo "\n"; - echo "\n8. Cleanup:\n"; - $database->setQuery("DROP TABLE users")->execute(); + echo "5. Cleanup:\n"; + $database->raw("DROP TABLE users")->execute(); echo "✓ User table dropped\n"; - // Clean up generated file if (file_exists(__DIR__.'/User.php')) { unlink(__DIR__.'/User.php'); echo "✓ Generated User.php file removed\n"; } } catch (Exception $e) { echo "✗ Error: ".$e->getMessage()."\n"; - - // Clean up on error try { - $database->setQuery("DROP TABLE IF EXISTS users")->execute(); - - if (file_exists(__DIR__.'/User.php')) { - unlink(__DIR__.'/User.php'); - } - } catch (Exception $cleanupError) { - // Ignore cleanup errors - } + $database->raw("DROP TABLE IF EXISTS users")->execute(); + if (file_exists(__DIR__.'/User.php')) unlink(__DIR__.'/User.php'); + } catch (Exception $cleanupError) {} } echo "\n=== Example Complete ===\n"; diff --git a/examples/06-migrations/AddEmailIndexMigration.php b/examples/06-migrations/AddEmailIndexMigration.php index 5d723f51..c165f71e 100644 --- a/examples/06-migrations/AddEmailIndexMigration.php +++ b/examples/06-migrations/AddEmailIndexMigration.php @@ -5,47 +5,17 @@ /** * Migration to add a unique index on the email column. - * - * This migration adds a unique constraint to the email column - * in the users table to ensure email uniqueness across all users. - * This migration depends on the users table existing. */ class AddEmailIndexMigration extends AbstractMigration { - /** - * Rollback the migration changes from the database. - * - * Removes the unique index from the email column, - * allowing duplicate emails again. - * - * @param Database $db The database instance to execute rollback on. - */ public function down(Database $db): void { - // Drop email index - $db->setQuery("ALTER TABLE users DROP INDEX idx_users_email")->execute(); + $db->raw("ALTER TABLE users DROP INDEX idx_users_email")->execute(); } - /** - * Get the list of migration dependencies. - * - * This migration requires the users table to exist before - * it can add an index to the email column. - * - * @return array Array of migration names this migration depends on. - */ public function getDependencies(): array { return ['CreateUsersTableMigration']; } - /** - * Apply the migration changes to the database. - * - * Adds a unique index on the email column to enforce - * email uniqueness and improve query performance. - * - * @param Database $db The database instance to execute changes on. - */ public function up(Database $db): void { - // Add unique index on email column - $db->setQuery("ALTER TABLE users ADD UNIQUE INDEX idx_users_email (email)")->execute(); + $db->raw("ALTER TABLE users ADD UNIQUE INDEX idx_users_email (email)")->execute(); } } diff --git a/examples/06-migrations/CreateUsersTableMigration.php b/examples/06-migrations/CreateUsersTableMigration.php index d04800b2..c832de00 100644 --- a/examples/06-migrations/CreateUsersTableMigration.php +++ b/examples/06-migrations/CreateUsersTableMigration.php @@ -7,35 +7,13 @@ /** * Migration to create the users table. - * - * This migration creates a basic users table with essential columns - * for user management including auto-incrementing ID, username, email, - * password hash, and creation timestamp. */ class CreateUsersTableMigration extends AbstractMigration { - /** - * Rollback the migration changes from the database. - * - * Drops the users table and all its data. This operation - * is irreversible and will result in data loss. - * - * @param Database $db The database instance to execute rollback on. - */ public function down(Database $db): void { - // Drop users table - $db->setQuery("DROP TABLE IF EXISTS users")->execute(); + $db->raw("DROP TABLE IF EXISTS users")->execute(); } - /** - * Apply the migration changes to the database. - * - * Creates the users table with columns for user authentication - * and basic profile information. - * - * @param Database $db The database instance to execute changes on. - */ public function up(Database $db): void { - // Create users table $db->createBlueprint('users')->addColumns([ 'id' => [ ColOption::TYPE => DataType::INT, @@ -53,18 +31,18 @@ public function up(Database $db): void { ColOption::SIZE => 150, ColOption::NULL => false ], - 'password_hash' => [ + 'password-hash' => [ ColOption::TYPE => DataType::VARCHAR, ColOption::SIZE => 255, ColOption::NULL => false ], - 'created_at' => [ + 'created-at' => [ ColOption::TYPE => DataType::TIMESTAMP, ColOption::DEFAULT => 'current_timestamp' ] ]); - $db->createTables(); + $db->table('users')->createTable(); $db->execute(); } } diff --git a/examples/06-migrations/example.php b/examples/06-migrations/example.php index d316f31b..6a9b55c8 100644 --- a/examples/06-migrations/example.php +++ b/examples/06-migrations/example.php @@ -19,7 +19,7 @@ require_once __DIR__.'/CreateUsersTableMigration.php'; require_once __DIR__.'/AddEmailIndexMigration.php'; - echo "✓ Migration classes loaded\n"; + echo "✓ Migration classes loaded\n\n"; echo "2. Setting up Schema Runner:\n"; @@ -41,52 +41,51 @@ $changes = $runner->getChanges(); echo "Registered migrations:\n"; - foreach ($changes as $change) { echo " - ".$change->getName()."\n"; } echo "\n"; - echo "4. Running Migrations:\n"; + echo "4. Running Migrations (using apply()):\n"; - // Force apply all migrations - $changes = $runner->getChanges(); - $appliedChanges = []; + // Apply all pending migrations + $result = $runner->apply(); - foreach ($changes as $change) { - if (!$runner->isApplied($change->getName())) { - $change->execute($database); - $appliedChanges[] = $change; - echo " ✓ Applied: ".$change->getName()."\n"; + if ($result->count() > 0) { + echo "Applied migrations:\n"; + foreach ($result->getApplied() as $change) { + echo " ✓ ".$change->getName()."\n"; } + } else { + echo "No migrations to apply (all up to date)\n"; } - if (empty($appliedChanges)) { - echo "No migrations to apply (all up to date)\n"; + if (!empty($result->getFailed())) { + echo "Failed migrations:\n"; + foreach ($result->getFailed() as $failure) { + echo " ✗ ".$failure['change']->getName().": ".$failure['error']->getMessage()."\n"; + } } echo "\n"; echo "5. Verifying Database Structure:\n"; // Check if table exists - $result = $database->setQuery("SHOW TABLES LIKE 'users'")->execute(); - - if ($result->getRowsCount() > 0) { + $tableResult = $database->raw("SHOW TABLES LIKE 'users'")->execute(); + if ($tableResult->getRowsCount() > 0) { echo "✓ Users table created\n"; } // Check table structure - $result = $database->setQuery("DESCRIBE users")->execute(); + $descResult = $database->raw("DESCRIBE users")->execute(); echo "Users table columns:\n"; - - foreach ($result as $column) { + foreach ($descResult as $column) { echo " - {$column['Field']} ({$column['Type']})\n"; } // Check indexes - $result = $database->setQuery("SHOW INDEX FROM users WHERE Key_name = 'idx_users_email'")->execute(); - - if ($result->getRowsCount() > 0) { + $indexResult = $database->raw("SHOW INDEX FROM users WHERE Key_name = 'idx_users_email'")->execute(); + if ($indexResult->getRowsCount() > 0) { echo "✓ Email index created\n"; } echo "\n"; @@ -97,31 +96,27 @@ $database->table('users')->insert([ 'username' => 'ahmad_hassan', 'email' => 'ahmad@example.com', - 'password_hash' => password_hash('password123', PASSWORD_DEFAULT) + 'password-hash' => password_hash('password123', PASSWORD_DEFAULT) ])->execute(); $database->table('users')->insert([ 'username' => 'fatima_ali', 'email' => 'fatima@example.com', - 'password_hash' => password_hash('password456', PASSWORD_DEFAULT) + 'password-hash' => password_hash('password456', PASSWORD_DEFAULT) ])->execute(); echo "✓ Test users inserted\n"; // Query data - $result = $database->table('users')->select(['username', 'email', 'created_at'])->execute(); + $selectResult = $database->table('users')->select(['username', 'email', 'created-at'])->execute(); echo "Inserted users:\n"; - - foreach ($result as $user) { + foreach ($selectResult as $user) { echo " - {$user['username']} ({$user['email']}) - {$user['created_at']}\n"; } echo "\n"; echo "7. Checking Migration Status:\n"; - - // Check which migrations are applied echo "Migration status:\n"; - foreach ($changes as $change) { $status = $runner->isApplied($change->getName()) ? "✓ Applied" : "✗ Pending"; echo " {$change->getName()}: $status\n"; @@ -131,12 +126,11 @@ echo "8. Rolling Back Migrations:\n"; // Rollback all migrations - $rolledBackChanges = $runner->rollbackUpTo(null); + $rolledBack = $runner->rollbackUpTo(null); - if (!empty($rolledBackChanges)) { + if (!empty($rolledBack)) { echo "Rolled back migrations:\n"; - - foreach ($rolledBackChanges as $change) { + foreach ($rolledBack as $change) { echo " ✓ ".$change->getName()."\n"; } } else { @@ -144,9 +138,8 @@ } // Verify rollback - $result = $database->setQuery("SHOW TABLES LIKE 'users'")->execute(); - - if ($result->getRowsCount() == 0) { + $verifyResult = $database->raw("SHOW TABLES LIKE 'users'")->execute(); + if ($verifyResult->getRowsCount() == 0) { echo "✓ Users table removed\n"; } @@ -158,8 +151,8 @@ // Clean up on error try { - $database->setQuery("DROP TABLE IF EXISTS users")->execute(); - $database->setQuery("DROP TABLE IF EXISTS schema_changes")->execute(); + $database->raw("DROP TABLE IF EXISTS users")->execute(); + $database->raw("DROP TABLE IF EXISTS schema_changes")->execute(); } catch (Exception $cleanupError) { // Ignore cleanup errors } diff --git a/examples/07-seeders/CategoriesSeeder.php b/examples/07-seeders/CategoriesSeeder.php index bfda6455..3a06cd78 100644 --- a/examples/07-seeders/CategoriesSeeder.php +++ b/examples/07-seeders/CategoriesSeeder.php @@ -5,56 +5,18 @@ /** * Seeder for populating the categories table with sample category data. - * - * This seeder is environment-specific and only runs in development - * and test environments to provide sample categories for testing. */ class CategoriesSeeder extends AbstractSeeder { - /** - * Get the environments where this seeder should be executed. - * - * This seeder only runs in development and test environments - * to avoid populating production with sample data. - * - * @return array Array of environment names where this seeder should run. - */ public function getEnvironments(): array { - // Only run in development and test environments return ['dev', 'test']; } - /** - * Run the seeder to populate the database with data. - * - * Inserts sample categories for content organization including - * technology, science, culture, and sports categories. - * - * @param Database $db The database instance to execute seeding on. - * @return bool True if seeding was successful, false otherwise. - */ public function run(Database $db): void { - // Insert sample categories $categories = [ - [ - 'name' => 'Technology', - 'description' => 'Articles about technology and programming', - 'slug' => 'technology' - ], - [ - 'name' => 'Science', - 'description' => 'Scientific articles and research', - 'slug' => 'science' - ], - [ - 'name' => 'Culture', - 'description' => 'Cultural topics and discussions', - 'slug' => 'culture' - ], - [ - 'name' => 'Sports', - 'description' => 'Sports news and updates', - 'slug' => 'sports' - ] + ['name' => 'Technology', 'description' => 'Articles about technology', 'slug' => 'technology'], + ['name' => 'Science', 'description' => 'Scientific articles', 'slug' => 'science'], + ['name' => 'Culture', 'description' => 'Cultural topics', 'slug' => 'culture'], + ['name' => 'Sports', 'description' => 'Sports news', 'slug' => 'sports'] ]; foreach ($categories as $category) { diff --git a/examples/07-seeders/UsersSeeder.php b/examples/07-seeders/UsersSeeder.php index 332a00d4..5e023112 100644 --- a/examples/07-seeders/UsersSeeder.php +++ b/examples/07-seeders/UsersSeeder.php @@ -5,45 +5,32 @@ /** * Seeder for populating the users table with initial user data. - * - * This seeder creates essential user accounts including an administrator - * and sample users for development and testing purposes. */ class UsersSeeder extends AbstractSeeder { - /** - * Run the seeder to populate the database with data. - * - * Inserts sample user accounts with different roles including - * an administrator account and regular users with Arabic names. - * - * @param Database $db The database instance to execute seeding on. - * @return bool True if seeding was successful, false otherwise. - */ public function run(Database $db): void { - // Insert sample users $users = [ [ 'username' => 'admin', 'email' => 'admin@example.com', - 'full_name' => 'Administrator', + 'full-name' => 'Administrator', 'role' => 'admin' ], [ 'username' => 'mohammed_ali', 'email' => 'mohammed@example.com', - 'full_name' => 'Mohammed Ali Al-Rashid', + 'full-name' => 'Mohammed Ali Al-Rashid', 'role' => 'user' ], [ 'username' => 'zahra_hassan', 'email' => 'zahra@example.com', - 'full_name' => 'Zahra Hassan Al-Mahmoud', + 'full-name' => 'Zahra Hassan Al-Mahmoud', 'role' => 'user' ], [ 'username' => 'omar_khalil', 'email' => 'omar@example.com', - 'full_name' => 'Omar Khalil Al-Najjar', + 'full-name' => 'Omar Khalil Al-Najjar', 'role' => 'moderator' ] ]; diff --git a/examples/07-seeders/example.php b/examples/07-seeders/example.php index de32545b..2bce0533 100644 --- a/examples/07-seeders/example.php +++ b/examples/07-seeders/example.php @@ -18,8 +18,8 @@ echo "1. Creating Test Tables:\n"; // Clean up any existing tables first - $database->setQuery("DROP TABLE IF EXISTS categories")->execute(); - $database->setQuery("DROP TABLE IF EXISTS users")->execute(); + $database->raw("DROP TABLE IF EXISTS categories")->execute(); + $database->raw("DROP TABLE IF EXISTS users")->execute(); // Create users table $database->createBlueprint('users')->addColumns([ @@ -39,7 +39,7 @@ ColOption::SIZE => 150, ColOption::NULL => false ], - 'full_name' => [ + 'full-name' => [ ColOption::TYPE => DataType::VARCHAR, ColOption::SIZE => 100 ], @@ -48,7 +48,7 @@ ColOption::SIZE => 20, ColOption::DEFAULT => 'user' ], - 'is_active' => [ + 'is-active' => [ ColOption::TYPE => DataType::BOOL, ColOption::DEFAULT => true ] @@ -77,10 +77,14 @@ ] ]); - $database->createTables(); + // Create tables one by one + $database->table('users')->createTable(); $database->execute(); + echo "✓ Users table created\n"; - echo "✓ Test tables created\n\n"; + $database->table('categories')->createTable(); + $database->execute(); + echo "✓ Categories table created\n\n"; echo "2. Loading Seeder Classes:\n"; @@ -88,7 +92,7 @@ require_once __DIR__.'/UsersSeeder.php'; require_once __DIR__.'/CategoriesSeeder.php'; - echo "✓ Seeder classes loaded\n"; + echo "✓ Seeder classes loaded\n\n"; echo "3. Setting up Schema Runner:\n"; @@ -110,26 +114,22 @@ $changes = $runner->getChanges(); echo "Registered seeders:\n"; - foreach ($changes as $change) { echo " - ".$change->getName()."\n"; } echo "\n"; - echo "5. Running Seeders:\n"; + echo "5. Running Seeders (using apply()):\n"; - // Force apply all seeders - $appliedChanges = []; + // Apply all pending seeders + $result = $runner->apply(); - foreach ($changes as $change) { - if (!$runner->isApplied($change->getName())) { - $change->execute($database); - $appliedChanges[] = $change; - echo " ✓ Applied: ".$change->getName()."\n"; + if ($result->count() > 0) { + echo "Applied seeders:\n"; + foreach ($result->getApplied() as $change) { + echo " ✓ ".$change->getName()."\n"; } - } - - if (empty($appliedChanges)) { + } else { echo "No seeders to apply (all up to date)\n"; } echo "\n"; @@ -137,30 +137,24 @@ echo "6. Verifying Seeded Data:\n"; // Check users data - $result = $database->table('users')->select()->execute(); - echo "Seeded users ({$result->getRowsCount()} records):\n"; - - foreach ($result as $user) { + $usersResult = $database->table('users')->select()->execute(); + echo "Seeded users ({$usersResult->getRowsCount()} records):\n"; + foreach ($usersResult as $user) { $status = $user['is_active'] ? 'Active' : 'Inactive'; - echo " - {$user['full_name']} (@{$user['username']}) - {$user['role']} - {$status}\n"; + echo " - {$user['full_name']} (@{$user['username']}) - {$user['role']} - $status\n"; } echo "\n"; // Check categories data - $result = $database->table('categories')->select()->execute(); - echo "Seeded categories ({$result->getRowsCount()} records):\n"; - - foreach ($result as $category) { + $categoriesResult = $database->table('categories')->select()->execute(); + echo "Seeded categories ({$categoriesResult->getRowsCount()} records):\n"; + foreach ($categoriesResult as $category) { echo " - {$category['name']} ({$category['slug']})\n"; - echo " {$category['description']}\n"; } echo "\n"; - echo "7. Testing Seeder Status:\n"; - - // Check which seeders are applied + echo "7. Checking Seeder Status:\n"; echo "Seeder status:\n"; - foreach ($changes as $change) { $status = $runner->isApplied($change->getName()) ? "✓ Applied" : "✗ Pending"; echo " {$change->getName()}: $status\n"; @@ -169,40 +163,39 @@ echo "8. Rolling Back Seeders:\n"; - // Rollback all seeders (this will clear the data) - $rolledBackChanges = []; + // Rollback all seeders (note: seeders don't clear data by default) + $rolledBack = $runner->rollbackUpTo(null); - // Reverse order for rollback - $reversedChanges = array_reverse($changes); - - foreach ($reversedChanges as $change) { - $change->rollback($database); - $rolledBackChanges[] = $change; - echo " ✓ Rolled back: ".$change->getName()."\n"; + if (!empty($rolledBack)) { + echo "Rolled back seeders (tracking removed):\n"; + foreach ($rolledBack as $change) { + echo " ✓ ".$change->getName()."\n"; + } + } else { + echo "No seeders to rollback\n"; } - // Verify rollback + // Note: Data remains because seeders don't implement rollback by default $userCount = $database->table('users')->select()->execute()->getRowsCount(); $categoryCount = $database->table('categories')->select()->execute()->getRowsCount(); - echo "After rollback:\n"; + echo "Note: Data remains after rollback (seeders don't clear data by default):\n"; echo " Users: $userCount records\n"; - echo " Categories: $categoryCount records\n"; - echo "✓ Seeders rolled back successfully\n\n"; + echo " Categories: $categoryCount records\n\n"; echo "9. Cleanup:\n"; $runner->dropSchemaTable(); - $database->setQuery("DROP TABLE categories")->execute(); - $database->setQuery("DROP TABLE users")->execute(); + $database->raw("DROP TABLE categories")->execute(); + $database->raw("DROP TABLE users")->execute(); echo "✓ Test tables and schema tracking table dropped\n"; } catch (Exception $e) { echo "✗ Error: ".$e->getMessage()."\n"; // Clean up on error try { - $database->setQuery("DROP TABLE IF EXISTS categories")->execute(); - $database->setQuery("DROP TABLE IF EXISTS users")->execute(); - $database->setQuery("DROP TABLE IF EXISTS schema_changes")->execute(); + $database->raw("DROP TABLE IF EXISTS categories")->execute(); + $database->raw("DROP TABLE IF EXISTS users")->execute(); + $database->raw("DROP TABLE IF EXISTS schema_changes")->execute(); } catch (Exception $cleanupError) { // Ignore cleanup errors } diff --git a/examples/10-attribute-based-tables/example.php b/examples/10-attribute-based-tables/example.php new file mode 100644 index 00000000..19743eac --- /dev/null +++ b/examples/10-attribute-based-tables/example.php @@ -0,0 +1,157 @@ +getCols()))."\n"; + + echo "✓ Articles table blueprint created\n"; + echo " Columns: ".implode(', ', array_keys($articlesTable->getCols()))."\n\n"; + + echo "2. Generated SQL:\n"; + echo "Authors table:\n".$authorsTable->toSQL()."\n\n"; + echo "Articles table:\n".$articlesTable->toSQL()."\n\n"; + + echo "3. Creating Tables in Database:\n"; + + // Clean up first + $database->raw("DROP TABLE IF EXISTS articles")->execute(); + $database->raw("DROP TABLE IF EXISTS authors")->execute(); + + // Create tables + $database->raw($authorsTable->toSQL())->execute(); + echo "✓ Authors table created\n"; + + $database->raw($articlesTable->toSQL())->execute(); + echo "✓ Articles table created\n\n"; + + echo "4. Inserting Test Data:\n"; + + // Add tables to database for query builder + $database->addTable($authorsTable); + $database->addTable($articlesTable); + + // Insert authors + $database->table('authors')->insert([ + 'name' => 'Ibrahim Ali', + 'email' => 'ibrahim@example.com' + ])->execute(); + + $database->table('authors')->insert([ + 'name' => 'Sara Ahmed', + 'email' => 'sara@example.com' + ])->execute(); + + echo "✓ Authors inserted\n"; + + // Insert articles + $database->table('articles')->insert([ + 'author-id' => 1, + 'title' => 'Introduction to PHP 8 Attributes', + 'content' => 'PHP 8 introduced attributes as a way to add metadata to classes...' + ])->execute(); + + $database->table('articles')->insert([ + 'author-id' => 1, + 'title' => 'Database Design Patterns', + 'content' => 'Learn about common database design patterns...' + ])->execute(); + + $database->table('articles')->insert([ + 'author-id' => 2, + 'title' => 'Clean Architecture in PHP', + 'content' => 'Implementing clean architecture principles...' + ])->execute(); + + echo "✓ Articles inserted\n\n"; + + echo "5. Querying Data:\n"; + + // Query with join + $result = $database->raw(" + SELECT a.name as author, ar.title, ar.published_at + FROM authors a + JOIN articles ar ON a.id = ar.author_id + ORDER BY ar.published_at DESC + ")->execute(); + + echo "Articles with authors:\n"; + foreach ($result as $row) { + echo " - {$row['title']} by {$row['author']} ({$row['published_at']})\n"; + } + echo "\n"; + + echo "6. Cleanup:\n"; + $database->raw("DROP TABLE articles")->execute(); + $database->raw("DROP TABLE authors")->execute(); + echo "✓ Tables dropped\n"; +} catch (Exception $e) { + echo "✗ Error: ".$e->getMessage()."\n"; + + // Clean up on error + try { + $database->raw("DROP TABLE IF EXISTS articles")->execute(); + $database->raw("DROP TABLE IF EXISTS authors")->execute(); + } catch (Exception $cleanupError) { + // Ignore + } +} + +echo "\n=== Example Complete ===\n"; diff --git a/examples/11-repository-pattern/example.php b/examples/11-repository-pattern/example.php new file mode 100644 index 00000000..e4fed092 --- /dev/null +++ b/examples/11-repository-pattern/example.php @@ -0,0 +1,197 @@ +name = $name; + $this->category = $category; + $this->price = $price; + $this->stock = $stock; + } +} + +// Define a repository for the Product entity +class ProductRepository extends AbstractRepository { + protected function getTableName(): string { + return 'products'; + } + + protected function getIdField(): string { + return 'id'; + } + + protected function toEntity(array $row): object { + $product = new Product(); + $product->id = (int) $row['id']; + $product->name = $row['name']; + $product->category = $row['category']; + $product->price = (float) $row['price']; + $product->stock = (int) $row['stock']; + return $product; + } + + protected function toArray(object $entity): array { + return [ + 'id' => $entity->id, + 'name' => $entity->name, + 'category' => $entity->category, + 'price' => $entity->price, + 'stock' => $entity->stock + ]; + } + + // Custom method: find products by category + public function findByCategory(string $category): array { + $result = $this->getDatabase()->table($this->getTableName()) + ->select() + ->where('category', $category) + ->execute(); + + return array_map(fn($row) => $this->toEntity($row), $result->fetchAll()); + } + + // Custom method: find low stock products + public function findLowStock(int $threshold = 10): array { + $result = $this->getDatabase()->table($this->getTableName()) + ->select() + ->where('stock', $threshold, '<') + ->execute(); + + return array_map(fn($row) => $this->toEntity($row), $result->fetchAll()); + } +} + +try { + // Create connection + $connection = new ConnectionInfo('mysql', 'root', '123456', 'mysql'); + $database = new Database($connection); + + echo "1. Setting up Database:\n"; + + // Clean up and create table + $database->raw("DROP TABLE IF EXISTS products")->execute(); + + $database->createBlueprint('products')->addColumns([ + 'id' => [ColOption::TYPE => DataType::INT, ColOption::PRIMARY => true, ColOption::AUTO_INCREMENT => true], + 'name' => [ColOption::TYPE => DataType::VARCHAR, ColOption::SIZE => 100], + 'category' => [ColOption::TYPE => DataType::VARCHAR, ColOption::SIZE => 50], + 'price' => [ColOption::TYPE => DataType::DECIMAL, ColOption::SIZE => 10], + 'stock' => [ColOption::TYPE => DataType::INT] + ]); + + $database->table('products')->createTable(); + $database->execute(); + echo "✓ Products table created\n\n"; + + echo "2. Creating Repository:\n"; + $productRepo = new ProductRepository($database); + echo "✓ ProductRepository created\n\n"; + + echo "3. Saving Products (Create):\n"; + + $products = [ + new Product('Laptop', 'Electronics', 999.99, 15), + new Product('Mouse', 'Electronics', 29.99, 50), + new Product('Keyboard', 'Electronics', 79.99, 5), + new Product('Chair', 'Furniture', 199.99, 8), + new Product('Desk', 'Furniture', 299.99, 3), + new Product('Book', 'Education', 19.99, 100) + ]; + + foreach ($products as $product) { + $productRepo->save($product); + echo " ✓ Saved: {$product->name}\n"; + } + echo "\n"; + + echo "4. Finding All Products (Read):\n"; + $allProducts = $productRepo->findAll(); + echo "Total products: ".count($allProducts)."\n"; + foreach ($allProducts as $p) { + echo " - {$p->name} ({$p->category}): \${$p->price} - Stock: {$p->stock}\n"; + } + echo "\n"; + + echo "5. Finding by ID:\n"; + $product = $productRepo->findById(1); + if ($product) { + echo " Found: {$product->name} - \${$product->price}\n\n"; + } + + echo "6. Custom Query - Find by Category:\n"; + $electronics = $productRepo->findByCategory('Electronics'); + echo "Electronics products: ".count($electronics)."\n"; + foreach ($electronics as $p) { + echo " - {$p->name}: \${$p->price}\n"; + } + echo "\n"; + + echo "7. Custom Query - Find Low Stock:\n"; + $lowStock = $productRepo->findLowStock(10); + echo "Low stock products (< 10): ".count($lowStock)."\n"; + foreach ($lowStock as $p) { + echo " ⚠️ {$p->name}: {$p->stock} remaining\n"; + } + echo "\n"; + + echo "8. Updating a Product:\n"; + $product = $productRepo->findById(3); + if ($product) { + echo " Before: {$product->name} - Stock: {$product->stock}\n"; + $product->stock = 25; + $productRepo->save($product); + $updated = $productRepo->findById(3); + echo " After: {$updated->name} - Stock: {$updated->stock}\n\n"; + } + + echo "9. Pagination (Offset-based):\n"; + $page1 = $productRepo->paginate(1, 3); + echo "Page 1 (3 per page):\n"; + echo " Total items: {$page1->getTotalItems()}\n"; + echo " Total pages: {$page1->getTotalPages()}\n"; + echo " Items on this page: ".count($page1->getItems())."\n"; + foreach ($page1->getItems() as $p) { + echo " - {$p->name}\n"; + } + echo "\n"; + + echo "10. Counting Products:\n"; + $count = $productRepo->count(); + echo " Total products in database: $count\n\n"; + + echo "11. Deleting a Product:\n"; + $productRepo->deleteById(6); + echo " ✓ Deleted product with ID 6\n"; + $newCount = $productRepo->count(); + echo " Products remaining: $newCount\n\n"; + + echo "12. Cleanup:\n"; + $database->raw("DROP TABLE products")->execute(); + echo "✓ Products table dropped\n"; +} catch (Exception $e) { + echo "✗ Error: ".$e->getMessage()."\n"; + + try { + $database->raw("DROP TABLE IF EXISTS products")->execute(); + } catch (Exception $cleanupError) { + // Ignore + } +} + +echo "\n=== Example Complete ===\n"; diff --git a/examples/12-clean-architecture/Domain/User.php b/examples/12-clean-architecture/Domain/User.php new file mode 100644 index 00000000..96445191 --- /dev/null +++ b/examples/12-clean-architecture/Domain/User.php @@ -0,0 +1,15 @@ + $entity->id, + 'name' => $entity->name, + 'email' => $entity->email, + 'age' => $entity->age + ]; + } + + public function findById(mixed $id): ?User { + return parent::findById($id); + } + + public function findAll(): array { + return parent::findAll(); + } + + public function delete(mixed $id): void { + $this->deleteById($id); + } +} diff --git a/examples/12-clean-architecture/example.php b/examples/12-clean-architecture/example.php new file mode 100644 index 00000000..56ea6840 --- /dev/null +++ b/examples/12-clean-architecture/example.php @@ -0,0 +1,86 @@ +raw("DROP TABLE IF EXISTS users")->execute(); + $database->createBlueprint('users')->addColumns([ + 'id' => [ColOption::TYPE => DataType::INT, ColOption::PRIMARY => true, ColOption::AUTO_INCREMENT => true], + 'name' => [ColOption::TYPE => DataType::VARCHAR, ColOption::SIZE => 100], + 'email' => [ColOption::TYPE => DataType::VARCHAR, ColOption::SIZE => 150], + 'age' => [ColOption::TYPE => DataType::INT] + ]); + $database->table('users')->createTable(); + $database->execute(); + echo "✓ Database table created\n\n"; + + echo "2. Creating Repository (Infrastructure implements Domain interface):\n"; + $userRepo = new MySQLUserRepository($database); + echo "✓ MySQLUserRepository created\n\n"; + + echo "3. Working with Domain Entities:\n"; + + // Create domain entities (pure PHP, no DB knowledge) + $users = [ + new User(null, 'Ahmed Ali', 'ahmed@example.com', 28), + new User(null, 'Sara Hassan', 'sara@example.com', 32), + new User(null, 'Omar Khalil', 'omar@example.com', 25) + ]; + + foreach ($users as $user) { + $userRepo->save($user); + echo " ✓ Saved: {$user->name}\n"; + } + echo "\n"; + + echo "4. Querying through Repository:\n"; + $allUsers = $userRepo->findAll(); + echo "All users:\n"; + foreach ($allUsers as $user) { + echo " - {$user->name} ({$user->email}) - Age: {$user->age}\n"; + } + echo "\n"; + + echo "5. Finding by ID:\n"; + $user = $userRepo->findById(1); + if ($user) { + echo " Found: {$user->name}\n\n"; + } + + echo "6. Benefits of Clean Architecture:\n"; + echo " ✓ Domain entities are framework-agnostic\n"; + echo " ✓ Repository interface defines contract\n"; + echo " ✓ Easy to swap implementations (MySQL, PostgreSQL, etc.)\n"; + echo " ✓ Domain logic is testable without database\n\n"; + + echo "7. Cleanup:\n"; + $database->raw("DROP TABLE users")->execute(); + echo "✓ Table dropped\n"; +} catch (Exception $e) { + echo "✗ Error: ".$e->getMessage()."\n"; + try { $database->raw("DROP TABLE IF EXISTS users")->execute(); } catch (Exception $e) {} +} + +echo "\n=== Example Complete ===\n"; diff --git a/examples/13-pagination/example.php b/examples/13-pagination/example.php new file mode 100644 index 00000000..b37dedfd --- /dev/null +++ b/examples/13-pagination/example.php @@ -0,0 +1,123 @@ +id = (int) $row['id']; + $user->name = $row['name']; + $user->email = $row['email']; + $user->age = (int) $row['age']; + return $user; + } + + protected function toArray(object $entity): array { + return ['id' => $entity->id, 'name' => $entity->name, 'email' => $entity->email, 'age' => $entity->age]; + } +} + +try { + $connection = new ConnectionInfo('mysql', 'root', '123456', 'mysql'); + $database = new Database($connection); + + echo "1. Setting up Test Data:\n"; + + $database->raw("DROP TABLE IF EXISTS users")->execute(); + $database->createBlueprint('users')->addColumns([ + 'id' => [ColOption::TYPE => DataType::INT, ColOption::PRIMARY => true, ColOption::AUTO_INCREMENT => true], + 'name' => [ColOption::TYPE => DataType::VARCHAR, ColOption::SIZE => 100], + 'email' => [ColOption::TYPE => DataType::VARCHAR, ColOption::SIZE => 150], + 'age' => [ColOption::TYPE => DataType::INT] + ]); + $database->table('users')->createTable(); + $database->execute(); + + // Insert 25 test users + $names = ['Ahmed', 'Fatima', 'Omar', 'Layla', 'Hassan', 'Sara', 'Yusuf', 'Maryam', 'Ali', 'Noor', + 'Khalid', 'Aisha', 'Ibrahim', 'Zahra', 'Mahmoud', 'Hana', 'Tariq', 'Salma', 'Rami', 'Dina', + 'Faisal', 'Lina', 'Samir', 'Rania', 'Walid']; + + foreach ($names as $i => $name) { + $database->table('users')->insert([ + 'name' => $name, + 'email' => strtolower($name).'@example.com', + 'age' => 20 + ($i % 30) + ])->execute(); + } + echo "✓ Created 25 test users\n\n"; + + $repo = new UserRepository($database); + + echo "2. Offset-Based Pagination:\n"; + echo " (Traditional page numbers)\n\n"; + + $perPage = 5; + $totalPages = (int) ceil($repo->count() / $perPage); + + for ($page = 1; $page <= 3; $page++) { + $result = $repo->paginate($page, $perPage); + echo "Page $page of {$result->getTotalPages()}:\n"; + foreach ($result->getItems() as $user) { + echo " - {$user->name} ({$user->email})\n"; + } + echo " Has next: ".($result->hasNextPage() ? 'Yes' : 'No')."\n\n"; + } + + echo "3. Cursor-Based Pagination:\n"; + echo " (Better for large datasets, infinite scroll)\n\n"; + + $cursor = null; + $pageNum = 1; + + while ($pageNum <= 3) { + $result = $repo->paginateByCursor($cursor, 5, 'id', 'ASC'); + echo "Cursor Page $pageNum:\n"; + foreach ($result->getItems() as $user) { + echo " - ID {$user->id}: {$user->name}\n"; + } + echo " Has more: ".($result->hasMore() ? 'Yes' : 'No')."\n"; + + if (!$result->hasMore()) break; + + $cursor = $result->getNextCursor(); + echo " Next cursor: $cursor\n\n"; + $pageNum++; + } + + echo "\n4. Pagination with Ordering:\n"; + $result = $repo->paginate(1, 5, ['age' => 'DESC']); + echo "Top 5 oldest users:\n"; + foreach ($result->getItems() as $user) { + echo " - {$user->name} (Age: {$user->age})\n"; + } + + echo "\n5. Cleanup:\n"; + $database->raw("DROP TABLE users")->execute(); + echo "✓ Table dropped\n"; +} catch (Exception $e) { + echo "✗ Error: ".$e->getMessage()."\n"; + try { $database->raw("DROP TABLE IF EXISTS users")->execute(); } catch (Exception $e) {} +} + +echo "\n=== Example Complete ===\n"; diff --git a/examples/README.md b/examples/README.md index 42b74fcc..6ec707d5 100644 --- a/examples/README.md +++ b/examples/README.md @@ -4,15 +4,21 @@ This directory contains practical examples demonstrating how to use the WebFiori ## Examples Overview -1. **[01-basic-connection](01-basic-connection/)** - How to establish database connections -2. **[02-basic-queries](02-basic-queries/)** - CRUD operations (Insert, Select, Update, Delete) -3. **[03-table-blueprints](03-table-blueprints/)** - Creating and managing database table structures -4. **[04-entity-mapping](04-entity-mapping/)** - Working with entity classes and object mapping -5. **[05-transactions](05-transactions/)** - Database transactions for data integrity -6. **[06-migrations](06-migrations/)** - Database schema migrations and versioning -7. **[07-seeders](07-seeders/)** - Database data seeding and population -8. **[08-performance-monitoring](08-performance-monitoring/)** - Query performance tracking and analysis -9. **[09-multi-result-queries](09-multi-result-queries/)** - Multi-result query handling and stored procedures +| # | Example | Description | +|---|---------|-------------| +| 01 | [basic-connection](01-basic-connection/) | Establishing database connections | +| 02 | [basic-queries](02-basic-queries/) | CRUD operations (Insert, Select, Update, Delete) | +| 03 | [table-blueprints](03-table-blueprints/) | Creating and managing database table structures | +| 04 | [entity-mapping](04-entity-mapping/) | Working with entity classes and object mapping | +| 05 | [transactions](05-transactions/) | Database transactions for data integrity | +| 06 | [migrations](06-migrations/) | Database schema migrations and versioning | +| 07 | [seeders](07-seeders/) | Database data seeding and population | +| 08 | [performance-monitoring](08-performance-monitoring/) | Query performance tracking and analysis | +| 09 | [multi-result-queries](09-multi-result-queries/) | Multi-result query handling and stored procedures | +| 10 | [attribute-based-tables](10-attribute-based-tables/) | PHP 8 attributes for table definitions | +| 11 | [repository-pattern](11-repository-pattern/) | Repository pattern with AbstractRepository | +| 12 | [clean-architecture](12-clean-architecture/) | Clean architecture with domain/infrastructure separation | +| 13 | [pagination](13-pagination/) | Offset and cursor-based pagination | ## Prerequisites @@ -47,64 +53,76 @@ You can modify the connection parameters in each example as needed. - Testing connections with simple queries ### 02-basic-queries -- Table creation and management -- INSERT operations with data validation -- SELECT operations with filtering and conditions -- UPDATE operations with WHERE clauses -- DELETE operations with conditions -- Query result handling +- Using `raw()` method for SQL queries with parameters +- INSERT, SELECT, UPDATE, DELETE operations +- Multi-result queries with stored procedures ### 03-table-blueprints -- Creating table blueprints with column definitions -- Using different data types (INT, VARCHAR, TEXT, TIMESTAMP) +- Creating table blueprints with `createBlueprint()` +- Using `ColOption` and `DataType` constants - Setting column constraints (PRIMARY KEY, NOT NULL, AUTO_INCREMENT) -- Creating foreign key relationships +- Creating foreign key relationships with `addReference()` - Generating and executing CREATE TABLE statements ### 04-entity-mapping -- Generating entity classes from table blueprints +- Generating entity classes from table blueprints using `EntityMapper` +- Auto-generated getters/setters and `map()` method - Mapping database records to PHP objects -- Working with mapped objects and their methods -- Filtering and manipulating object collections ### 05-transactions -- Creating database transactions for data integrity -- Handling successful transaction commits -- Automatic rollback on transaction failures -- Error handling within transactions +- Creating database transactions with `transaction()` method +- Automatic commit on success +- Automatic rollback on exception - Complex multi-table operations ### 06-migrations - Creating migration classes extending `AbstractMigration` +- Implementing `up()` and `down()` methods - Using `SchemaRunner` for migration management -- Registering migrations with the schema runner -- Applying and rolling back migrations -- Schema change tracking and versioning +- Applying migrations with `apply()` +- Rolling back with `rollbackUpTo()` +- Schema change tracking ### 07-seeders - Creating seeder classes extending `AbstractSeeder` +- Implementing `run()` method +- Environment-specific seeding with `getEnvironments()` - Using `SchemaRunner` for seeder management -- Registering seeders with the schema runner -- Populating database with sample data -- Environment-specific seeding ### 08-performance-monitoring -- Configuring performance monitoring settings -- Tracking query execution times and statistics -- Identifying slow queries and performance bottlenecks -- Using `PerformanceAnalyzer` for detailed analysis -- Performance optimization recommendations +- Configuring performance monitoring with `setPerformanceConfig()` +- Using `PerformanceOption` constants +- Tracking query execution times +- Using `PerformanceAnalyzer` for metrics +- Identifying slow queries ### 09-multi-result-queries -- Executing stored procedures that return multiple result sets +- Executing stored procedures returning multiple result sets - Working with `MultiResultSet` objects -- Processing individual result sets from multi-result queries -- Parameterized stored procedure calls using `raw()` method -- Complex business reporting with multiple data views - -## Notes - -- All examples include proper error handling and cleanup -- Generated files (like entity classes) are automatically cleaned up -- Examples use temporary tables that are dropped after execution -- Each example is thoroughly tested and produces expected output +- Processing individual result sets + +### 10-attribute-based-tables +- Using PHP 8 attributes: `#[Table]`, `#[Column]`, `#[ForeignKey]` +- Building tables with `AttributeTableBuilder::build()` +- Defining entities with attribute-based schema + +### 11-repository-pattern +- Extending `AbstractRepository` for CRUD operations +- Implementing `toEntity()` and `toArray()` methods +- Using built-in methods: `findAll()`, `findById()`, `save()`, `deleteById()` +- Creating custom query methods +- Pagination with `paginate()` + +### 13-pagination +- Offset-based pagination with `paginate()` +- Cursor-based pagination with `paginateByCursor()` +- Working with `Page` and `CursorPage` objects +- Pagination with ordering + + +### 12-clean-architecture +- Separating Domain from Infrastructure +- Pure domain entities (no framework dependencies) +- Repository interface in Domain layer +- Database implementation in Infrastructure layer +- Dependency inversion principle From b94caa0cc9686f88f1199ec4f9bb026e4d3e0182 Mon Sep 17 00:00:00 2001 From: Ibrahim BinAlshikh Date: Mon, 5 Jan 2026 00:50:53 +0300 Subject: [PATCH 35/44] docs: Added Links to Related Examples --- examples/01-basic-connection/README.md | 7 +++++ examples/02-basic-queries/README.md | 8 +++++ examples/03-table-blueprints/README.md | 8 +++++ examples/04-entity-mapping/README.md | 7 +++++ examples/05-transactions/README.md | 7 +++++ examples/06-migrations/README.md | 7 +++++ examples/07-seeders/README.md | 7 +++++ examples/08-performance-monitoring/README.md | 7 +++++ examples/09-multi-result-queries/README.md | 30 ++++++++++++++++++ examples/10-attribute-based-tables/README.md | 30 ++++++++++++++++++ examples/11-repository-pattern/README.md | 31 ++++++++++++++++++ examples/12-clean-architecture/README.md | 33 ++++++++++++++++++++ examples/13-pagination/README.md | 30 ++++++++++++++++++ 13 files changed, 212 insertions(+) create mode 100644 examples/09-multi-result-queries/README.md create mode 100644 examples/10-attribute-based-tables/README.md create mode 100644 examples/11-repository-pattern/README.md create mode 100644 examples/12-clean-architecture/README.md create mode 100644 examples/13-pagination/README.md diff --git a/examples/01-basic-connection/README.md b/examples/01-basic-connection/README.md index f702061a..68554073 100644 --- a/examples/01-basic-connection/README.md +++ b/examples/01-basic-connection/README.md @@ -21,3 +21,10 @@ php example.php ## Expected Output The example will output connection status and basic database information. + + +## Related Examples + +- [02-basic-queries](../02-basic-queries/) - Learn CRUD operations after connecting +- [03-table-blueprints](../03-table-blueprints/) - Create table structures programmatically +- [06-migrations](../06-migrations/) - Manage schema changes with migrations diff --git a/examples/02-basic-queries/README.md b/examples/02-basic-queries/README.md index a18dc2e4..1c67a126 100644 --- a/examples/02-basic-queries/README.md +++ b/examples/02-basic-queries/README.md @@ -23,3 +23,11 @@ php example.php ## Expected Output The example will create a test table, perform various CRUD operations, and display the results of each operation. + + +## Related Examples + +- [01-basic-connection](../01-basic-connection/) - Database connection setup +- [03-table-blueprints](../03-table-blueprints/) - Define table structures +- [05-transactions](../05-transactions/) - Wrap operations in transactions +- [09-multi-result-queries](../09-multi-result-queries/) - Handle stored procedures with multiple result sets diff --git a/examples/03-table-blueprints/README.md b/examples/03-table-blueprints/README.md index 40877f27..f961fca5 100644 --- a/examples/03-table-blueprints/README.md +++ b/examples/03-table-blueprints/README.md @@ -24,3 +24,11 @@ php example.php ## Expected Output The example will create table blueprints using both the fluent API and custom table classes, generate SQL statements, and create the actual tables in the database. + + +## Related Examples + +- [04-entity-mapping](../04-entity-mapping/) - Map tables to entity classes +- [06-migrations](../06-migrations/) - Version control your schema changes +- [10-attribute-based-tables](../10-attribute-based-tables/) - Define tables using PHP 8 attributes +- [11-repository-pattern](../11-repository-pattern/) - Use repositories for data access diff --git a/examples/04-entity-mapping/README.md b/examples/04-entity-mapping/README.md index 2f6dca87..88941e28 100644 --- a/examples/04-entity-mapping/README.md +++ b/examples/04-entity-mapping/README.md @@ -23,3 +23,10 @@ php example.php ## Expected Output The example will create a table blueprint, generate an entity class, and demonstrate mapping database records to objects. + + +## Related Examples + +- [03-table-blueprints](../03-table-blueprints/) - Create table structures first +- [11-repository-pattern](../11-repository-pattern/) - Recommended approach for entity mapping +- [12-clean-architecture](../12-clean-architecture/) - Separate domain entities from infrastructure diff --git a/examples/05-transactions/README.md b/examples/05-transactions/README.md index 8533a6f1..6eb8ceec 100644 --- a/examples/05-transactions/README.md +++ b/examples/05-transactions/README.md @@ -22,3 +22,10 @@ php example.php ## Expected Output The example will demonstrate both successful transactions and failed transactions with rollback functionality. + + +## Related Examples + +- [02-basic-queries](../02-basic-queries/) - Basic CRUD operations +- [06-migrations](../06-migrations/) - Schema changes with rollback support +- [07-seeders](../07-seeders/) - Populate data within transactions diff --git a/examples/06-migrations/README.md b/examples/06-migrations/README.md index 98c62abc..14f18425 100644 --- a/examples/06-migrations/README.md +++ b/examples/06-migrations/README.md @@ -24,3 +24,10 @@ php example.php ## Expected Output The example will create migration classes, run them to modify the database schema, and demonstrate rollback functionality. + + +## Related Examples + +- [03-table-blueprints](../03-table-blueprints/) - Define table structures +- [07-seeders](../07-seeders/) - Populate data after migrations +- [05-transactions](../05-transactions/) - Understand rollback behavior diff --git a/examples/07-seeders/README.md b/examples/07-seeders/README.md index edf75549..ed0e5ad9 100644 --- a/examples/07-seeders/README.md +++ b/examples/07-seeders/README.md @@ -24,3 +24,10 @@ php example.php ## Expected Output The example will create seeder classes, run them to populate the database with initial data, and show the seeded records. + + +## Related Examples + +- [06-migrations](../06-migrations/) - Create tables before seeding +- [05-transactions](../05-transactions/) - Wrap seeding in transactions +- [11-repository-pattern](../11-repository-pattern/) - Use repositories for data insertion diff --git a/examples/08-performance-monitoring/README.md b/examples/08-performance-monitoring/README.md index 87de1422..c024d2fa 100644 --- a/examples/08-performance-monitoring/README.md +++ b/examples/08-performance-monitoring/README.md @@ -23,3 +23,10 @@ php example.php ## Expected Output The example will execute various database operations while monitoring performance, then display detailed performance metrics and analysis. + + +## Related Examples + +- [02-basic-queries](../02-basic-queries/) - Queries to monitor +- [09-multi-result-queries](../09-multi-result-queries/) - Complex queries to analyze +- [13-pagination](../13-pagination/) - Optimize paginated queries diff --git a/examples/09-multi-result-queries/README.md b/examples/09-multi-result-queries/README.md new file mode 100644 index 00000000..34b81285 --- /dev/null +++ b/examples/09-multi-result-queries/README.md @@ -0,0 +1,30 @@ +# Multi-Result Queries + +This example demonstrates how to handle stored procedures and queries that return multiple result sets. + +## What This Example Shows + +- Creating stored procedures with multiple SELECT statements +- Executing multi-result queries +- Working with `MultiResultSet` objects +- Processing individual result sets + +## Files + +- `example.php` - Main example code + +## Running the Example + +```bash +php example.php +``` + +## Expected Output + +The example will create stored procedures, execute them, and demonstrate how to iterate through multiple result sets. + +## Related Examples + +- [02-basic-queries](../02-basic-queries/) - Basic query operations +- [05-transactions](../05-transactions/) - Combine with transactions +- [08-performance-monitoring](../08-performance-monitoring/) - Monitor complex query performance diff --git a/examples/10-attribute-based-tables/README.md b/examples/10-attribute-based-tables/README.md new file mode 100644 index 00000000..f65c8773 --- /dev/null +++ b/examples/10-attribute-based-tables/README.md @@ -0,0 +1,30 @@ +# Attribute-Based Tables + +This example demonstrates how to define database tables using PHP 8 attributes. + +## What This Example Shows + +- Using `#[Table]` attribute for table definition +- Using `#[Column]` attribute for column properties +- Using `#[ForeignKey]` attribute for relationships +- Building tables with `AttributeTableBuilder` + +## Files + +- `example.php` - Main example code with entity classes + +## Running the Example + +```bash +php example.php +``` + +## Expected Output + +The example will define entity classes with attributes, build table blueprints from them, and create the tables in the database. + +## Related Examples + +- [03-table-blueprints](../03-table-blueprints/) - Traditional blueprint approach +- [04-entity-mapping](../04-entity-mapping/) - Entity class generation +- [12-clean-architecture](../12-clean-architecture/) - Use attributes with clean architecture diff --git a/examples/11-repository-pattern/README.md b/examples/11-repository-pattern/README.md new file mode 100644 index 00000000..b45641b7 --- /dev/null +++ b/examples/11-repository-pattern/README.md @@ -0,0 +1,31 @@ +# Repository Pattern + +This example demonstrates how to use the Repository pattern with `AbstractRepository` for data access. + +## What This Example Shows + +- Extending `AbstractRepository` for CRUD operations +- Implementing `toEntity()` and `toArray()` methods +- Using built-in methods: `findAll()`, `findById()`, `save()`, `deleteById()` +- Creating custom query methods +- Pagination with `paginate()` + +## Files + +- `example.php` - Main example code with entity and repository classes + +## Running the Example + +```bash +php example.php +``` + +## Expected Output + +The example will demonstrate all repository operations including create, read, update, delete, and pagination. + +## Related Examples + +- [04-entity-mapping](../04-entity-mapping/) - Entity class basics +- [12-clean-architecture](../12-clean-architecture/) - Repository with domain separation +- [13-pagination](../13-pagination/) - Advanced pagination techniques diff --git a/examples/12-clean-architecture/README.md b/examples/12-clean-architecture/README.md new file mode 100644 index 00000000..9db1bb2e --- /dev/null +++ b/examples/12-clean-architecture/README.md @@ -0,0 +1,33 @@ +# Clean Architecture + +This example demonstrates how to implement clean architecture with separation between Domain and Infrastructure layers. + +## What This Example Shows + +- Pure domain entities (no framework dependencies) +- Repository interface in Domain layer +- Database implementation in Infrastructure layer +- Dependency inversion principle + +## Files + +- `example.php` - Main example code +- `Domain/User.php` - Domain entity +- `Domain/UserRepositoryInterface.php` - Repository contract +- `Infrastructure/Repository/MySQLUserRepository.php` - Database implementation + +## Running the Example + +```bash +php example.php +``` + +## Expected Output + +The example will demonstrate how domain entities remain framework-agnostic while infrastructure handles database operations. + +## Related Examples + +- [11-repository-pattern](../11-repository-pattern/) - Repository basics +- [04-entity-mapping](../04-entity-mapping/) - Entity class concepts +- [10-attribute-based-tables](../10-attribute-based-tables/) - Combine with attribute-based definitions diff --git a/examples/13-pagination/README.md b/examples/13-pagination/README.md new file mode 100644 index 00000000..1da6342e --- /dev/null +++ b/examples/13-pagination/README.md @@ -0,0 +1,30 @@ +# Pagination + +This example demonstrates offset-based and cursor-based pagination techniques. + +## What This Example Shows + +- Offset-based pagination with `paginate()` +- Cursor-based pagination with `paginateByCursor()` +- Working with `Page` and `CursorPage` objects +- Pagination with ordering + +## Files + +- `example.php` - Main example code + +## Running the Example + +```bash +php example.php +``` + +## Expected Output + +The example will demonstrate both pagination approaches with sample data, showing page navigation and cursor handling. + +## Related Examples + +- [11-repository-pattern](../11-repository-pattern/) - Repository with pagination +- [08-performance-monitoring](../08-performance-monitoring/) - Monitor pagination query performance +- [02-basic-queries](../02-basic-queries/) - Basic query operations From 591799a1e6216916c5ba47d5e8d4164d32d346d2 Mon Sep 17 00:00:00 2001 From: Ibrahim BinAlshikh Date: Mon, 5 Jan 2026 18:03:46 +0300 Subject: [PATCH 36/44] docs: Updated Clean Arch Example --- .../Domain/UserRepositoryInterface.php | 13 --- .../Repository/UserRepository.php | 48 ++++++++++ .../Infrastructure/Schema/UserTable.php | 18 ++++ examples/12-clean-architecture/README.md | 18 ++-- examples/12-clean-architecture/example.php | 93 ++++++++++--------- 5 files changed, 124 insertions(+), 66 deletions(-) delete mode 100644 examples/12-clean-architecture/Domain/UserRepositoryInterface.php create mode 100644 examples/12-clean-architecture/Infrastructure/Repository/UserRepository.php create mode 100644 examples/12-clean-architecture/Infrastructure/Schema/UserTable.php diff --git a/examples/12-clean-architecture/Domain/UserRepositoryInterface.php b/examples/12-clean-architecture/Domain/UserRepositoryInterface.php deleted file mode 100644 index a2487977..00000000 --- a/examples/12-clean-architecture/Domain/UserRepositoryInterface.php +++ /dev/null @@ -1,13 +0,0 @@ - $entity->id, + 'name' => $entity->name, + 'email' => $entity->email, + 'age' => $entity->age + ]; + } + + /** @return User[] */ + public function findByAge(int $minAge): array { + $result = $this->getDatabase()->table($this->getTableName()) + ->select() + ->where('age', $minAge, '>=') + ->execute(); + + return array_map(fn($row) => $this->toEntity($row), $result->fetchAll()); + } +} diff --git a/examples/12-clean-architecture/Infrastructure/Schema/UserTable.php b/examples/12-clean-architecture/Infrastructure/Schema/UserTable.php new file mode 100644 index 00000000..ea17f7f2 --- /dev/null +++ b/examples/12-clean-architecture/Infrastructure/Schema/UserTable.php @@ -0,0 +1,18 @@ +getCols()))."\n\n"; + + echo "2. Creating Table:\n"; $database->raw("DROP TABLE IF EXISTS users")->execute(); - $database->createBlueprint('users')->addColumns([ - 'id' => [ColOption::TYPE => DataType::INT, ColOption::PRIMARY => true, ColOption::AUTO_INCREMENT => true], - 'name' => [ColOption::TYPE => DataType::VARCHAR, ColOption::SIZE => 100], - 'email' => [ColOption::TYPE => DataType::VARCHAR, ColOption::SIZE => 150], - 'age' => [ColOption::TYPE => DataType::INT] - ]); - $database->table('users')->createTable(); - $database->execute(); - echo "✓ Database table created\n\n"; - - echo "2. Creating Repository (Infrastructure implements Domain interface):\n"; - $userRepo = new MySQLUserRepository($database); - echo "✓ MySQLUserRepository created\n\n"; - - echo "3. Working with Domain Entities:\n"; - - // Create domain entities (pure PHP, no DB knowledge) + $database->addTable($table); + $database->createTables()->execute(); + echo "✓ Users table created\n\n"; + + echo "3. Using Repository (extends AbstractRepository):\n"; + $userRepo = new UserRepository($database); + echo "✓ UserRepository created\n\n"; + + echo "4. Saving Domain Entities:\n"; $users = [ new User(null, 'Ahmed Ali', 'ahmed@example.com', 28), - new User(null, 'Sara Hassan', 'sara@example.com', 32), - new User(null, 'Omar Khalil', 'omar@example.com', 25) + new User(null, 'Sara Hassan', 'sara@example.com', 35), + new User(null, 'Omar Khalil', 'omar@example.com', 22) ]; foreach ($users as $user) { @@ -55,27 +53,34 @@ } echo "\n"; - echo "4. Querying through Repository:\n"; - $allUsers = $userRepo->findAll(); - echo "All users:\n"; - foreach ($allUsers as $user) { - echo " - {$user->name} ({$user->email}) - Age: {$user->age}\n"; + echo "5. Repository Operations:\n"; + + // findAll() + $all = $userRepo->findAll(); + echo "All users (".count($all)."):\n"; + foreach ($all as $u) { + echo " - {$u->name} ({$u->email}) - Age: {$u->age}\n"; } - echo "\n"; - echo "5. Finding by ID:\n"; + // findById() $user = $userRepo->findById(1); - if ($user) { - echo " Found: {$user->name}\n\n"; + echo "\nFind by ID 1: {$user->name}\n"; + + // Custom method + $adults = $userRepo->findByAge(25); + echo "\nUsers age >= 25 (".count($adults)."):\n"; + foreach ($adults as $u) { + echo " - {$u->name} (Age: {$u->age})\n"; } - echo "6. Benefits of Clean Architecture:\n"; - echo " ✓ Domain entities are framework-agnostic\n"; - echo " ✓ Repository interface defines contract\n"; - echo " ✓ Easy to swap implementations (MySQL, PostgreSQL, etc.)\n"; - echo " ✓ Domain logic is testable without database\n\n"; + // Pagination + $page = $userRepo->paginate(1, 2); + echo "\nPage 1 (2 per page): {$page->getTotalItems()} total, {$page->getTotalPages()} pages\n"; + + // count() + echo "\nTotal count: ".$userRepo->count()."\n\n"; - echo "7. Cleanup:\n"; + echo "6. Cleanup:\n"; $database->raw("DROP TABLE users")->execute(); echo "✓ Table dropped\n"; } catch (Exception $e) { From 9b04198236cbdb7f0bab5143358792499511629d Mon Sep 17 00:00:00 2001 From: Ibrahim BinAlshikh Date: Mon, 5 Jan 2026 22:53:27 +0300 Subject: [PATCH 37/44] docs: Updated Code Samples + FK Refactoring --- .../Attributes/AttributeTableBuilder.php | 188 +++++++++--------- WebFiori/Database/Attributes/ForeignKey.php | 19 +- WebFiori/Database/Factory/TableFactory.php | 2 +- examples/01-basic-connection/README.md | 2 +- examples/02-basic-queries/README.md | 2 +- examples/03-table-blueprints/README.md | 4 +- examples/04-entity-mapping/README.md | 2 +- examples/05-transactions/README.md | 2 +- examples/06-migrations/README.md | 6 +- examples/07-seeders/README.md | 6 +- examples/08-performance-monitoring/README.md | 2 +- examples/09-multi-result-queries/README.md | 2 +- .../10-attribute-based-tables/Article.php | 25 +++ examples/10-attribute-based-tables/Author.php | 20 ++ examples/10-attribute-based-tables/README.md | 4 +- .../10-attribute-based-tables/example.php | 98 ++------- examples/11-repository-pattern/Product.php | 16 ++ .../ProductRepository.php | 53 +++++ examples/11-repository-pattern/README.md | 4 +- examples/11-repository-pattern/example.php | 89 +-------- examples/12-clean-architecture/README.md | 8 +- examples/13-pagination/README.md | 4 +- examples/13-pagination/User.php | 8 + examples/13-pagination/UserRepository.php | 33 +++ examples/13-pagination/example.php | 39 +--- .../Attributes/ForeignKeyAttributeTest.php | 117 +++++++++++ 26 files changed, 434 insertions(+), 321 deletions(-) create mode 100644 examples/10-attribute-based-tables/Article.php create mode 100644 examples/10-attribute-based-tables/Author.php create mode 100644 examples/11-repository-pattern/Product.php create mode 100644 examples/11-repository-pattern/ProductRepository.php create mode 100644 examples/13-pagination/User.php create mode 100644 examples/13-pagination/UserRepository.php create mode 100644 tests/WebFiori/Tests/Database/Attributes/ForeignKeyAttributeTest.php diff --git a/WebFiori/Database/Attributes/AttributeTableBuilder.php b/WebFiori/Database/Attributes/AttributeTableBuilder.php index 4bf219cc..b80b5b13 100644 --- a/WebFiori/Database/Attributes/AttributeTableBuilder.php +++ b/WebFiori/Database/Attributes/AttributeTableBuilder.php @@ -4,8 +4,7 @@ use ReflectionClass; use WebFiori\Database\ColOption; use WebFiori\Database\DataType; -use WebFiori\Database\MsSql\MSSQLTable; -use WebFiori\Database\MySql\MySQLTable; +use WebFiori\Database\Factory\TableFactory; use WebFiori\Database\Table as TableClass; class AttributeTableBuilder { @@ -13,138 +12,139 @@ public static function build(string $entityClass, string $dbType = 'mysql'): Tab $reflection = new ReflectionClass($entityClass); $tableAttr = $reflection->getAttributes(Table::class)[0] ?? null; - if (!$tableAttr) { throw new \RuntimeException("Class $entityClass must have #[Table] attribute"); } $tableConfig = $tableAttr->newInstance(); - - $table = $dbType === 'mysql' - ? new MySQLTable($tableConfig->name) - : new MSSQLTable($tableConfig->name); - - if ($tableConfig->comment) { - $table->setComment($tableConfig->comment); - } - $columns = []; $foreignKeys = []; - // Check for class-level Column attributes $classColumnAttrs = $reflection->getAttributes(Column::class); if (!empty($classColumnAttrs)) { - // Class-level approach: columns defined at class level foreach ($classColumnAttrs as $columnAttr) { $columnConfig = $columnAttr->newInstance(); $columnKey = $columnConfig->name ?? throw new \RuntimeException("Column name is required for class-level attributes"); - - $columns[$columnKey] = [ - ColOption::TYPE => $columnConfig->type, - ColOption::NAME => $columnConfig->name, - ColOption::SIZE => $columnConfig->size, - ColOption::SCALE => $columnConfig->scale, - ColOption::PRIMARY => $columnConfig->primary, - ColOption::UNIQUE => $columnConfig->unique, - ColOption::NULL => $columnConfig->nullable, - ColOption::AUTO_INCREMENT => $columnConfig->autoIncrement, - ColOption::IDENTITY => $columnConfig->identity, - ColOption::AUTO_UPDATE => $columnConfig->autoUpdate, - ColOption::DEFAULT => $columnConfig->default, - ColOption::COMMENT => $columnConfig->comment, - ColOption::VALIDATOR => $columnConfig->callback - ]; + $columns[$columnKey] = self::columnConfigToArray($columnConfig); } - // Check for class-level ForeignKey attributes - $classFkAttrs = $reflection->getAttributes(ForeignKey::class); - - foreach ($classFkAttrs as $fkAttr) { - $fkConfig = $fkAttr->newInstance(); - $foreignKeys[] = [ - 'property' => $fkConfig->column, - 'config' => $fkConfig - ]; + foreach ($reflection->getAttributes(ForeignKey::class) as $fkAttr) { + $foreignKeys[] = $fkAttr->newInstance(); } } else { - // Property-level approach: columns defined on properties foreach ($reflection->getProperties() as $property) { $columnAttrs = $property->getAttributes(Column::class); - if (empty($columnAttrs)) { continue; } $columnConfig = $columnAttrs[0]->newInstance(); $columnKey = self::propertyToKey($property->getName()); + $columns[$columnKey] = self::columnConfigToArray($columnConfig); - $columns[$columnKey] = [ - ColOption::TYPE => $columnConfig->type, - ColOption::NAME => $columnConfig->name, - ColOption::SIZE => $columnConfig->size, - ColOption::SCALE => $columnConfig->scale, - ColOption::PRIMARY => $columnConfig->primary, - ColOption::UNIQUE => $columnConfig->unique, - ColOption::NULL => $columnConfig->nullable, - ColOption::AUTO_INCREMENT => $columnConfig->autoIncrement, - ColOption::IDENTITY => $columnConfig->identity, - ColOption::AUTO_UPDATE => $columnConfig->autoUpdate, - ColOption::DEFAULT => $columnConfig->default, - ColOption::COMMENT => $columnConfig->comment, - ColOption::VALIDATOR => $columnConfig->callback - ]; - - $fkAttrs = $property->getAttributes(ForeignKey::class); - - foreach ($fkAttrs as $fkAttr) { - $fkConfig = $fkAttr->newInstance(); - $foreignKeys[] = [ - 'property' => $columnKey, - 'config' => $fkConfig - ]; + foreach ($property->getAttributes(ForeignKey::class) as $fkAttr) { + $fk = $fkAttr->newInstance(); + // For property-level FK, the local column is the property itself + $foreignKeys[] = ['localColumn' => $columnKey, 'config' => $fk]; } } } - $table->addColumns($columns); + $table = TableFactory::create($dbType, $tableConfig->name, $columns); - // Store table references for foreign keys - $tableRegistry = []; + if ($tableConfig->comment) { + $table->setComment($tableConfig->comment); + } + // Add foreign keys foreach ($foreignKeys as $fk) { - $refTableName = $fk['config']->table; - $refColName = $fk['config']->column; - - // Create a minimal table reference if not exists - if (!isset($tableRegistry[$refTableName])) { - $refTable = $dbType === 'mysql' - ? new MySQLTable($refTableName) - : new MSSQLTable($refTableName); - - // Add the referenced column to make FK work - $refTable->addColumns([ - $refColName => [ - ColOption::TYPE => DataType::INT, - ColOption::PRIMARY => true - ] - ]); - - $tableRegistry[$refTableName] = $refTable; + if ($fk instanceof ForeignKey) { + // Class-level FK + self::addForeignKey($table, $fk, $dbType); + } else { + // Property-level FK + self::addPropertyForeignKey($table, $fk['localColumn'], $fk['config'], $dbType); } - - $table->addReference( - $tableRegistry[$refTableName], - [$fk['property'] => $refColName], - $fk['config']->name ?? 'fk_'.$fk['property'], - $fk['config']->onUpdate, - $fk['config']->onDelete - ); } return $table; } + private static function addForeignKey(TableClass $table, ForeignKey $fk, string $dbType): void { + $refTableName = self::resolveTableName($fk->table); + $columnsMap = $fk->getColumnsMap(); + + // Build reference columns for the referenced table + $refColumns = []; + $mapping = []; + + foreach ($columnsMap as $local => $ref) { + if (is_int($local)) { + // Simple array ['col1', 'col2'] - same name on both sides + $local = $ref; + } + $refColumns[$ref] = [ColOption::TYPE => DataType::INT, ColOption::PRIMARY => true]; + $mapping[$local] = $ref; + } + + $refTable = TableFactory::create($dbType, $refTableName, $refColumns); + + $table->addReference( + $refTable, + $mapping, + $fk->name ?? 'fk_'.implode('_', array_keys($mapping)), + $fk->onUpdate, + $fk->onDelete + ); + } + + private static function addPropertyForeignKey(TableClass $table, string $localColumn, ForeignKey $fk, string $dbType): void { + $refTableName = self::resolveTableName($fk->table); + $refColName = $fk->column ?? $localColumn; + + $refTable = TableFactory::create($dbType, $refTableName, [ + $refColName => [ColOption::TYPE => DataType::INT, ColOption::PRIMARY => true] + ]); + + $table->addReference( + $refTable, + [$localColumn => $refColName], + $fk->name ?? 'fk_'.$localColumn, + $fk->onUpdate, + $fk->onDelete + ); + } + + private static function resolveTableName(string $tableOrClass): string { + if (class_exists($tableOrClass)) { + $reflection = new ReflectionClass($tableOrClass); + $tableAttr = $reflection->getAttributes(Table::class)[0] ?? null; + if ($tableAttr) { + return $tableAttr->newInstance()->name; + } + } + return $tableOrClass; + } + + private static function columnConfigToArray(Column $config): array { + return [ + ColOption::TYPE => $config->type, + ColOption::NAME => $config->name, + ColOption::SIZE => $config->size, + ColOption::SCALE => $config->scale, + ColOption::PRIMARY => $config->primary, + ColOption::UNIQUE => $config->unique, + ColOption::NULL => $config->nullable, + ColOption::AUTO_INCREMENT => $config->autoIncrement, + ColOption::IDENTITY => $config->identity, + ColOption::AUTO_UPDATE => $config->autoUpdate, + ColOption::DEFAULT => $config->default, + ColOption::COMMENT => $config->comment, + ColOption::VALIDATOR => $config->callback + ]; + } + private static function propertyToKey(string $propertyName): string { return strtolower(preg_replace('/([a-z])([A-Z])/', '$1-$2', $propertyName)); } diff --git a/WebFiori/Database/Attributes/ForeignKey.php b/WebFiori/Database/Attributes/ForeignKey.php index e2b37a31..aaafa406 100644 --- a/WebFiori/Database/Attributes/ForeignKey.php +++ b/WebFiori/Database/Attributes/ForeignKey.php @@ -2,15 +2,32 @@ namespace WebFiori\Database\Attributes; use Attribute; +use InvalidArgumentException; #[Attribute(Attribute::TARGET_PROPERTY | Attribute::TARGET_CLASS | Attribute::IS_REPEATABLE)] class ForeignKey { public function __construct( public string $table, - public string $column, + public ?string $column = null, + public array $columns = [], public ?string $name = null, public string $onUpdate = 'set null', public string $onDelete = 'set null' ) { + if ($column !== null && !empty($columns)) { + throw new InvalidArgumentException( + "ForeignKey: Use either 'column' or 'columns', not both" + ); + } + } + + /** + * Get columns mapping as array ['localCol' => 'refCol'] + */ + public function getColumnsMap(): array { + if ($this->column !== null) { + return [$this->column]; + } + return $this->columns; } } diff --git a/WebFiori/Database/Factory/TableFactory.php b/WebFiori/Database/Factory/TableFactory.php index d2027ed3..b120868e 100644 --- a/WebFiori/Database/Factory/TableFactory.php +++ b/WebFiori/Database/Factory/TableFactory.php @@ -24,7 +24,7 @@ class TableFactory { public static function create(string $database, string $name, array $cols = []) : Table { if (!in_array($database, ConnectionInfo::SUPPORTED_DATABASES)) { - throw new DatabaseException('Not support database: '.$database); + throw new DatabaseException('Not support database: '.$database.'. Supported: '.implode(', ', ConnectionInfo::SUPPORTED_DATABASES)); } if ($database == 'mssql') { diff --git a/examples/01-basic-connection/README.md b/examples/01-basic-connection/README.md index 68554073..64e7edb9 100644 --- a/examples/01-basic-connection/README.md +++ b/examples/01-basic-connection/README.md @@ -10,7 +10,7 @@ This example demonstrates how to establish a connection to a database using the ## Files -- `example.php` - Main example code +- [`example.php`](example.php) - Main example code ## Running the Example diff --git a/examples/02-basic-queries/README.md b/examples/02-basic-queries/README.md index 1c67a126..02ef8e6a 100644 --- a/examples/02-basic-queries/README.md +++ b/examples/02-basic-queries/README.md @@ -12,7 +12,7 @@ This example demonstrates CRUD operations (Create, Read, Update, Delete) using t ## Files -- `example.php` - Main example code +- [`example.php`](example.php) - Main example code ## Running the Example diff --git a/examples/03-table-blueprints/README.md b/examples/03-table-blueprints/README.md index f961fca5..ff318f9e 100644 --- a/examples/03-table-blueprints/README.md +++ b/examples/03-table-blueprints/README.md @@ -12,8 +12,8 @@ This example demonstrates how to create database table structures using WebFiori ## Files -- `example.php` - Main example code -- `UserTable.php` - Custom table class extending MySQLTable +- [`example.php`](example.php) - Main example code +- [`UserTable.php`](UserTable.php) - Custom table class extending MySQLTable ## Running the Example diff --git a/examples/04-entity-mapping/README.md b/examples/04-entity-mapping/README.md index 88941e28..d08e21ca 100644 --- a/examples/04-entity-mapping/README.md +++ b/examples/04-entity-mapping/README.md @@ -11,7 +11,7 @@ This example demonstrates how to create and use entity classes for object-relati ## Files -- `example.php` - Main example code +- [`example.php`](example.php) - Main example code - `User.php` - Generated entity class (created during execution) ## Running the Example diff --git a/examples/05-transactions/README.md b/examples/05-transactions/README.md index 6eb8ceec..1ab36998 100644 --- a/examples/05-transactions/README.md +++ b/examples/05-transactions/README.md @@ -11,7 +11,7 @@ This example demonstrates how to use database transactions to ensure data integr ## Files -- `example.php` - Main example code +- [`example.php`](example.php) - Main example code ## Running the Example diff --git a/examples/06-migrations/README.md b/examples/06-migrations/README.md index 14f18425..aec4f2f2 100644 --- a/examples/06-migrations/README.md +++ b/examples/06-migrations/README.md @@ -11,9 +11,9 @@ This example demonstrates how to create and run database migrations using WebFio ## Files -- `example.php` - Main example code -- `CreateUsersTableMigration.php` - Migration to create users table -- `AddEmailIndexMigration.php` - Migration to add email index +- [`example.php`](example.php) - Main example code +- [`CreateUsersTableMigration.php`](CreateUsersTableMigration.php) - Migration to create users table +- [`AddEmailIndexMigration.php`](AddEmailIndexMigration.php) - Migration to add email index ## Running the Example diff --git a/examples/07-seeders/README.md b/examples/07-seeders/README.md index ed0e5ad9..648901ec 100644 --- a/examples/07-seeders/README.md +++ b/examples/07-seeders/README.md @@ -11,9 +11,9 @@ This example demonstrates how to create and run database seeders using WebFiori' ## Files -- `example.php` - Main example code -- `UsersSeeder.php` - Seeder for user data -- `CategoriesSeeder.php` - Seeder for category data +- [`example.php`](example.php) - Main example code +- [`UsersSeeder.php`](UsersSeeder.php) - Seeder for user data +- [`CategoriesSeeder.php`](CategoriesSeeder.php) - Seeder for category data ## Running the Example diff --git a/examples/08-performance-monitoring/README.md b/examples/08-performance-monitoring/README.md index c024d2fa..71a9aca9 100644 --- a/examples/08-performance-monitoring/README.md +++ b/examples/08-performance-monitoring/README.md @@ -12,7 +12,7 @@ This example demonstrates how to monitor database query performance using WebFio ## Files -- `example.php` - Main example code +- [`example.php`](example.php) - Main example code ## Running the Example diff --git a/examples/09-multi-result-queries/README.md b/examples/09-multi-result-queries/README.md index 34b81285..a9870be3 100644 --- a/examples/09-multi-result-queries/README.md +++ b/examples/09-multi-result-queries/README.md @@ -11,7 +11,7 @@ This example demonstrates how to handle stored procedures and queries that retur ## Files -- `example.php` - Main example code +- [`example.php`](example.php) - Main example code ## Running the Example diff --git a/examples/10-attribute-based-tables/Article.php b/examples/10-attribute-based-tables/Article.php new file mode 100644 index 00000000..b2ff791c --- /dev/null +++ b/examples/10-attribute-based-tables/Article.php @@ -0,0 +1,25 @@ +raw("DROP TABLE IF EXISTS articles")->execute(); $database->raw("DROP TABLE IF EXISTS authors")->execute(); - // Create tables $database->raw($authorsTable->toSQL())->execute(); echo "✓ Authors table created\n"; @@ -84,57 +42,29 @@ class Article { echo "4. Inserting Test Data:\n"; - // Add tables to database for query builder $database->addTable($authorsTable); $database->addTable($articlesTable); - // Insert authors - $database->table('authors')->insert([ - 'name' => 'Ibrahim Ali', - 'email' => 'ibrahim@example.com' - ])->execute(); - - $database->table('authors')->insert([ - 'name' => 'Sara Ahmed', - 'email' => 'sara@example.com' - ])->execute(); - + $database->table('authors')->insert(['name' => 'Ibrahim Ali', 'email' => 'ibrahim@example.com'])->execute(); + $database->table('authors')->insert(['name' => 'Sara Ahmed', 'email' => 'sara@example.com'])->execute(); echo "✓ Authors inserted\n"; - // Insert articles - $database->table('articles')->insert([ - 'author-id' => 1, - 'title' => 'Introduction to PHP 8 Attributes', - 'content' => 'PHP 8 introduced attributes as a way to add metadata to classes...' - ])->execute(); - - $database->table('articles')->insert([ - 'author-id' => 1, - 'title' => 'Database Design Patterns', - 'content' => 'Learn about common database design patterns...' - ])->execute(); - - $database->table('articles')->insert([ - 'author-id' => 2, - 'title' => 'Clean Architecture in PHP', - 'content' => 'Implementing clean architecture principles...' - ])->execute(); - + $database->table('articles')->insert(['author-id' => 1, 'title' => 'Introduction to PHP 8 Attributes', 'content' => 'PHP 8 introduced attributes...'])->execute(); + $database->table('articles')->insert(['author-id' => 1, 'title' => 'Database Design Patterns', 'content' => 'Learn about patterns...'])->execute(); + $database->table('articles')->insert(['author-id' => 2, 'title' => 'Clean Architecture in PHP', 'content' => 'Implementing clean architecture...'])->execute(); echo "✓ Articles inserted\n\n"; echo "5. Querying Data:\n"; - // Query with join $result = $database->raw(" - SELECT a.name as author, ar.title, ar.published_at - FROM authors a - JOIN articles ar ON a.id = ar.author_id - ORDER BY ar.published_at DESC + SELECT a.name as author, ar.title, ar.`published-at` + FROM authors a JOIN articles ar ON a.id = ar.`author-id` + ORDER BY ar.`published-at` DESC ")->execute(); echo "Articles with authors:\n"; foreach ($result as $row) { - echo " - {$row['title']} by {$row['author']} ({$row['published_at']})\n"; + echo " - {$row['title']} by {$row['author']} ({$row['published-at']})\n"; } echo "\n"; @@ -144,14 +74,10 @@ class Article { echo "✓ Tables dropped\n"; } catch (Exception $e) { echo "✗ Error: ".$e->getMessage()."\n"; - - // Clean up on error try { $database->raw("DROP TABLE IF EXISTS articles")->execute(); $database->raw("DROP TABLE IF EXISTS authors")->execute(); - } catch (Exception $cleanupError) { - // Ignore - } + } catch (Exception $cleanupError) {} } echo "\n=== Example Complete ===\n"; diff --git a/examples/11-repository-pattern/Product.php b/examples/11-repository-pattern/Product.php new file mode 100644 index 00000000..6848bbce --- /dev/null +++ b/examples/11-repository-pattern/Product.php @@ -0,0 +1,16 @@ +name = $name; + $this->category = $category; + $this->price = $price; + $this->stock = $stock; + } +} diff --git a/examples/11-repository-pattern/ProductRepository.php b/examples/11-repository-pattern/ProductRepository.php new file mode 100644 index 00000000..def57315 --- /dev/null +++ b/examples/11-repository-pattern/ProductRepository.php @@ -0,0 +1,53 @@ +id = (int) $row['id']; + $product->name = $row['name']; + $product->category = $row['category']; + $product->price = (float) $row['price']; + $product->stock = (int) $row['stock']; + return $product; + } + + protected function toArray(object $entity): array { + return [ + 'id' => $entity->id, + 'name' => $entity->name, + 'category' => $entity->category, + 'price' => $entity->price, + 'stock' => $entity->stock + ]; + } + + public function findByCategory(string $category): array { + $result = $this->getDatabase()->table($this->getTableName()) + ->select() + ->where('category', $category) + ->execute(); + + return array_map(fn($row) => $this->toEntity($row), $result->fetchAll()); + } + + public function findLowStock(int $threshold = 10): array { + $result = $this->getDatabase()->table($this->getTableName()) + ->select() + ->where('stock', $threshold, '<') + ->execute(); + + return array_map(fn($row) => $this->toEntity($row), $result->fetchAll()); + } +} diff --git a/examples/11-repository-pattern/README.md b/examples/11-repository-pattern/README.md index b45641b7..0beea6ca 100644 --- a/examples/11-repository-pattern/README.md +++ b/examples/11-repository-pattern/README.md @@ -12,7 +12,9 @@ This example demonstrates how to use the Repository pattern with `AbstractReposi ## Files -- `example.php` - Main example code with entity and repository classes +- [`example.php`](example.php) - Main example code +- [`Product.php`](Product.php) - Product entity class +- [`ProductRepository.php`](ProductRepository.php) - Repository extending AbstractRepository ## Running the Example diff --git a/examples/11-repository-pattern/example.php b/examples/11-repository-pattern/example.php index e4fed092..42e3b680 100644 --- a/examples/11-repository-pattern/example.php +++ b/examples/11-repository-pattern/example.php @@ -1,92 +1,23 @@ name = $name; - $this->category = $category; - $this->price = $price; - $this->stock = $stock; - } -} - -// Define a repository for the Product entity -class ProductRepository extends AbstractRepository { - protected function getTableName(): string { - return 'products'; - } - - protected function getIdField(): string { - return 'id'; - } - - protected function toEntity(array $row): object { - $product = new Product(); - $product->id = (int) $row['id']; - $product->name = $row['name']; - $product->category = $row['category']; - $product->price = (float) $row['price']; - $product->stock = (int) $row['stock']; - return $product; - } - - protected function toArray(object $entity): array { - return [ - 'id' => $entity->id, - 'name' => $entity->name, - 'category' => $entity->category, - 'price' => $entity->price, - 'stock' => $entity->stock - ]; - } - - // Custom method: find products by category - public function findByCategory(string $category): array { - $result = $this->getDatabase()->table($this->getTableName()) - ->select() - ->where('category', $category) - ->execute(); - - return array_map(fn($row) => $this->toEntity($row), $result->fetchAll()); - } - - // Custom method: find low stock products - public function findLowStock(int $threshold = 10): array { - $result = $this->getDatabase()->table($this->getTableName()) - ->select() - ->where('stock', $threshold, '<') - ->execute(); - - return array_map(fn($row) => $this->toEntity($row), $result->fetchAll()); - } -} - try { - // Create connection $connection = new ConnectionInfo('mysql', 'root', '123456', 'mysql'); $database = new Database($connection); echo "1. Setting up Database:\n"; - // Clean up and create table $database->raw("DROP TABLE IF EXISTS products")->execute(); - $database->createBlueprint('products')->addColumns([ 'id' => [ColOption::TYPE => DataType::INT, ColOption::PRIMARY => true, ColOption::AUTO_INCREMENT => true], 'name' => [ColOption::TYPE => DataType::VARCHAR, ColOption::SIZE => 100], @@ -94,7 +25,6 @@ public function findLowStock(int $threshold = 10): array { 'price' => [ColOption::TYPE => DataType::DECIMAL, ColOption::SIZE => 10], 'stock' => [ColOption::TYPE => DataType::INT] ]); - $database->table('products')->createTable(); $database->execute(); echo "✓ Products table created\n\n"; @@ -104,7 +34,6 @@ public function findLowStock(int $threshold = 10): array { echo "✓ ProductRepository created\n\n"; echo "3. Saving Products (Create):\n"; - $products = [ new Product('Laptop', 'Electronics', 999.99, 15), new Product('Mouse', 'Electronics', 29.99, 50), @@ -165,33 +94,25 @@ public function findLowStock(int $threshold = 10): array { echo "Page 1 (3 per page):\n"; echo " Total items: {$page1->getTotalItems()}\n"; echo " Total pages: {$page1->getTotalPages()}\n"; - echo " Items on this page: ".count($page1->getItems())."\n"; foreach ($page1->getItems() as $p) { echo " - {$p->name}\n"; } echo "\n"; echo "10. Counting Products:\n"; - $count = $productRepo->count(); - echo " Total products in database: $count\n\n"; + echo " Total products in database: ".$productRepo->count()."\n\n"; echo "11. Deleting a Product:\n"; $productRepo->deleteById(6); echo " ✓ Deleted product with ID 6\n"; - $newCount = $productRepo->count(); - echo " Products remaining: $newCount\n\n"; + echo " Products remaining: ".$productRepo->count()."\n\n"; echo "12. Cleanup:\n"; $database->raw("DROP TABLE products")->execute(); echo "✓ Products table dropped\n"; } catch (Exception $e) { echo "✗ Error: ".$e->getMessage()."\n"; - - try { - $database->raw("DROP TABLE IF EXISTS products")->execute(); - } catch (Exception $cleanupError) { - // Ignore - } + try { $database->raw("DROP TABLE IF EXISTS products")->execute(); } catch (Exception $cleanupError) {} } echo "\n=== Example Complete ===\n"; diff --git a/examples/12-clean-architecture/README.md b/examples/12-clean-architecture/README.md index 758b4e85..1ccb67d8 100644 --- a/examples/12-clean-architecture/README.md +++ b/examples/12-clean-architecture/README.md @@ -11,10 +11,10 @@ This example demonstrates clean architecture with separation between Domain and ## Files -- `example.php` - Main example code -- `Domain/User.php` - Pure domain entity -- `Infrastructure/Schema/UserTable.php` - Table definition with attributes -- `Infrastructure/Repository/UserRepository.php` - Repository using AbstractRepository +- [`example.php`](example.php) - Main example code +- [`Domain/User.php`](Domain/User.php) - Pure domain entity +- [`Infrastructure/Schema/UserTable.php`](Infrastructure/Schema/UserTable.php) - Table definition with attributes +- [`Infrastructure/Repository/UserRepository.php`](Infrastructure/Repository/UserRepository.php) - Repository using AbstractRepository ## Running the Example diff --git a/examples/13-pagination/README.md b/examples/13-pagination/README.md index 1da6342e..b93bf95a 100644 --- a/examples/13-pagination/README.md +++ b/examples/13-pagination/README.md @@ -11,7 +11,9 @@ This example demonstrates offset-based and cursor-based pagination techniques. ## Files -- `example.php` - Main example code +- [`example.php`](example.php) - Main example code +- [`User.php`](User.php) - User entity class +- [`UserRepository.php`](UserRepository.php) - Repository extending AbstractRepository ## Running the Example diff --git a/examples/13-pagination/User.php b/examples/13-pagination/User.php new file mode 100644 index 00000000..41f80121 --- /dev/null +++ b/examples/13-pagination/User.php @@ -0,0 +1,8 @@ +id = (int) $row['id']; + $user->name = $row['name']; + $user->email = $row['email']; + $user->age = (int) $row['age']; + return $user; + } + + protected function toArray(object $entity): array { + return [ + 'id' => $entity->id, + 'name' => $entity->name, + 'email' => $entity->email, + 'age' => $entity->age + ]; + } +} diff --git a/examples/13-pagination/example.php b/examples/13-pagination/example.php index b37dedfd..919d3a32 100644 --- a/examples/13-pagination/example.php +++ b/examples/13-pagination/example.php @@ -1,42 +1,16 @@ id = (int) $row['id']; - $user->name = $row['name']; - $user->email = $row['email']; - $user->age = (int) $row['age']; - return $user; - } - - protected function toArray(object $entity): array { - return ['id' => $entity->id, 'name' => $entity->name, 'email' => $entity->email, 'age' => $entity->age]; - } -} - try { $connection = new ConnectionInfo('mysql', 'root', '123456', 'mysql'); $database = new Database($connection); @@ -53,7 +27,6 @@ protected function toArray(object $entity): array { $database->table('users')->createTable(); $database->execute(); - // Insert 25 test users $names = ['Ahmed', 'Fatima', 'Omar', 'Layla', 'Hassan', 'Sara', 'Yusuf', 'Maryam', 'Ali', 'Noor', 'Khalid', 'Aisha', 'Ibrahim', 'Zahra', 'Mahmoud', 'Hana', 'Tariq', 'Salma', 'Rami', 'Dina', 'Faisal', 'Lina', 'Samir', 'Rania', 'Walid']; @@ -72,11 +45,8 @@ protected function toArray(object $entity): array { echo "2. Offset-Based Pagination:\n"; echo " (Traditional page numbers)\n\n"; - $perPage = 5; - $totalPages = (int) ceil($repo->count() / $perPage); - for ($page = 1; $page <= 3; $page++) { - $result = $repo->paginate($page, $perPage); + $result = $repo->paginate($page, 5); echo "Page $page of {$result->getTotalPages()}:\n"; foreach ($result->getItems() as $user) { echo " - {$user->name} ({$user->email})\n"; @@ -87,7 +57,7 @@ protected function toArray(object $entity): array { echo "3. Cursor-Based Pagination:\n"; echo " (Better for large datasets, infinite scroll)\n\n"; - $cursor = null; + $cursor = null; // null = start from beginning (first page) $pageNum = 1; while ($pageNum <= 3) { @@ -100,6 +70,7 @@ protected function toArray(object $entity): array { if (!$result->hasMore()) break; + // Next cursor is base64-encoded ID of last item, used to fetch next page $cursor = $result->getNextCursor(); echo " Next cursor: $cursor\n\n"; $pageNum++; diff --git a/tests/WebFiori/Tests/Database/Attributes/ForeignKeyAttributeTest.php b/tests/WebFiori/Tests/Database/Attributes/ForeignKeyAttributeTest.php new file mode 100644 index 00000000..f24d2bce --- /dev/null +++ b/tests/WebFiori/Tests/Database/Attributes/ForeignKeyAttributeTest.php @@ -0,0 +1,117 @@ + 'id'], name: 'fk_item_order', onUpdate: 'cascade', onDelete: 'cascade')] +#[ForeignKey(table: 'products', columns: ['product-id' => 'id'], name: 'fk_item_product', onUpdate: 'cascade', onDelete: 'cascade')] +class TestOrderItem { +} + +#[Table(name: 'composite_ref')] +#[Column(name: 'tenant-id', type: DataType::INT)] +#[Column(name: 'user-id', type: DataType::INT)] +#[Column(name: 'data', type: DataType::VARCHAR, size: 100)] +#[ForeignKey(table: 'tenant_users', columns: ['tenant-id' => 'tenant_id', 'user-id' => 'user_id'], name: 'fk_composite', onUpdate: 'cascade', onDelete: 'cascade')] +class TestCompositeFK { +} + +class ForeignKeyAttributeTest extends TestCase { + public function testSingleColumnFK() { + $fk = new ForeignKey(table: 'users', column: 'id'); + $this->assertEquals(['id'], $fk->getColumnsMap()); + } + + public function testMultipleColumnsFK() { + $fk = new ForeignKey(table: 'users', columns: ['local_id' => 'id', 'tenant_id' => 'tenant_id']); + $this->assertEquals(['local_id' => 'id', 'tenant_id' => 'tenant_id'], $fk->getColumnsMap()); + } + + public function testBothColumnAndColumnsThrowsException() { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage("ForeignKey: Use either 'column' or 'columns', not both"); + + new ForeignKey(table: 'users', column: 'id', columns: ['local_id' => 'id']); + } + + public function testClassReferenceResolution() { + $table = AttributeTableBuilder::build(TestPost::class, 'mysql'); + $sql = $table->toSQL(); + + $this->assertStringContainsString('`users`', $sql); + $this->assertStringContainsString('fk_post_user', $sql); + } + + public function testPropertyLevelFK() { + $table = AttributeTableBuilder::build(TestPost::class, 'mysql'); + $sql = $table->toSQL(); + + $this->assertStringContainsString('foreign key (`user-id`) references `users` (`id`)', $sql); + $this->assertStringContainsString('on update cascade on delete cascade', $sql); + } + + public function testClassLevelFKWithColumns() { + $table = AttributeTableBuilder::build(TestOrderItem::class, 'mysql'); + $sql = $table->toSQL(); + + $this->assertStringContainsString('fk_item_order', $sql); + $this->assertStringContainsString('fk_item_product', $sql); + $this->assertStringContainsString('references `orders`', $sql); + $this->assertStringContainsString('references `products`', $sql); + } + + public function testCompositeForeignKey() { + $table = AttributeTableBuilder::build(TestCompositeFK::class, 'mysql'); + $sql = $table->toSQL(); + + $this->assertStringContainsString('fk_composite', $sql); + $this->assertStringContainsString('references `tenant_users`', $sql); + $this->assertStringContainsString('(`tenant_id`, `user_id`)', $sql); + } + + public function testDefaultFKName() { + $fk = new ForeignKey(table: 'users', column: 'id'); + $this->assertNull($fk->name); + } + + public function testDefaultOnUpdateOnDelete() { + $fk = new ForeignKey(table: 'users', column: 'id'); + $this->assertEquals('set null', $fk->onUpdate); + $this->assertEquals('set null', $fk->onDelete); + } +} From a4fd02774df742d3f3774d2b571a183a6fb88260 Mon Sep 17 00:00:00 2001 From: Ibrahim BinAlshikh Date: Mon, 5 Jan 2026 23:26:24 +0300 Subject: [PATCH 38/44] feat: `saveAll` in Repo --- .../Repository/AbstractRepository.php | 157 +++++++++++++++- .../Repository/AbstractRepositoryTest.php | 177 ++++++++++++++++++ 2 files changed, 325 insertions(+), 9 deletions(-) create mode 100644 tests/WebFiori/Tests/Database/Repository/AbstractRepositoryTest.php diff --git a/WebFiori/Database/Repository/AbstractRepository.php b/WebFiori/Database/Repository/AbstractRepository.php index 2dd8c642..e8a92a60 100644 --- a/WebFiori/Database/Repository/AbstractRepository.php +++ b/WebFiori/Database/Repository/AbstractRepository.php @@ -1,22 +1,37 @@ db = $db; } + /** + * Returns the total number of records in the table. + * + * @return int Total record count. + */ public function count(): int { $result = $this->db->table($this->getTableName()) ->selectCount(null, 'total') @@ -25,12 +40,20 @@ public function count(): int { return (int) $result->fetch()['total']; } + /** + * Deletes all records from the table. + */ public function deleteAll(): void { $this->db->table($this->getTableName()) ->delete() ->execute(); } + /** + * Deletes a record by its ID. + * + * @param mixed $id The ID of the record to delete. + */ public function deleteById(mixed $id): void { $this->db->table($this->getTableName()) ->delete() @@ -38,7 +61,11 @@ public function deleteById(mixed $id): void { ->execute(); } - /** @return T[] */ + /** + * Retrieves all records from the table. + * + * @return T[] Array of all entities. + */ public function findAll(): array { $result = $this->db->table($this->getTableName()) ->select() @@ -47,7 +74,13 @@ public function findAll(): array { return array_map(fn($row) => $this->toEntity($row), $result->fetchAll()); } - /** @return T|null */ + /** + * Finds a single record by its ID. + * + * @param mixed $id The ID to search for. + * + * @return T|null The entity if found, null otherwise. + */ public function findById(mixed $id): ?object { $result = $this->db->table($this->getTableName()) ->select() @@ -57,7 +90,15 @@ public function findById(mixed $id): ?object { return $result->getCount() > 0 ? $this->toEntity($result->fetch()) : null; } - /** @return Page */ + /** + * Retrieves paginated records using offset-based pagination. + * + * @param int $page Page number (1-based). + * @param int $perPage Number of records per page. + * @param array $orderBy Associative array of column => direction for sorting. + * + * @return Page Page object containing results and pagination metadata. + */ public function paginate(int $page = 1, int $perPage = 20, array $orderBy = []): Page { $page = max(1, $page); $offset = ($page - 1) * $perPage; @@ -79,7 +120,16 @@ public function paginate(int $page = 1, int $perPage = 20, array $orderBy = []): return new Page($items, $page, $perPage, $total); } - /** @return CursorPage */ + /** + * Retrieves paginated records using cursor-based pagination. + * + * @param string|null $cursor Base64-encoded cursor value, null for first page. + * @param int $limit Maximum number of records to return. + * @param string|null $cursorField Column to use for cursor, defaults to ID field. + * @param string $direction Sort direction ('ASC' or 'DESC'). + * + * @return CursorPage CursorPage object containing results and next cursor. + */ public function paginateByCursor( ?string $cursor = null, int $limit = 20, @@ -119,7 +169,13 @@ public function paginateByCursor( return new CursorPage($items, $nextCursor, null, $hasMore); } - /** @param T $entity */ + /** + * Saves an entity (insert if new, update if existing). + * + * An entity is considered new if its ID field is null. + * + * @param T $entity The entity to save. + */ public function save(object $entity): void { $data = $this->toArray($entity); $id = $data[$this->getIdField()] ?? null; @@ -135,16 +191,99 @@ public function save(object $entity): void { } } - protected function createQuery(): \WebFiori\Database\AbstractQuery { + /** + * Saves multiple entities in a single transaction. + * + * New entities (null ID) are batch inserted in one query. + * Existing entities are updated individually. + * + * @param T[] $entities Array of entities to save. + */ + public function saveAll(array $entities): void { + if (empty($entities)) { + return; + } + + $newEntities = []; + $existingEntities = []; + $idField = $this->getIdField(); + + foreach ($entities as $entity) { + $data = $this->toArray($entity); + if (($data[$idField] ?? null) === null) { + unset($data[$idField]); + $newEntities[] = $data; + } else { + $existingEntities[] = $data; + } + } + + $this->db->transaction(function (Database $db) use ($newEntities, $existingEntities, $idField) { + if (!empty($newEntities)) { + $db->table($this->getTableName())->insert([ + 'cols' => array_keys($newEntities[0]), + 'values' => array_map(fn($e) => array_values($e), $newEntities) + ])->execute(); + } + + foreach ($existingEntities as $data) { + $id = $data[$idField]; + unset($data[$idField]); + $db->table($this->getTableName()) + ->update($data) + ->where($idField, $id) + ->execute(); + } + }); + } + + /** + * Creates a select query for the repository's table. + * + * @return AbstractQuery Query builder instance. + */ + protected function createQuery(): AbstractQuery { return $this->db->table($this->getTableName())->select(); } + /** + * Returns the underlying database instance. + * + * @return Database The database connection. + */ protected function getDatabase(): Database { return $this->db; } + + /** + * Returns the name of the ID/primary key field. + * + * @return string Column name of the primary key. + */ abstract protected function getIdField(): string; + /** + * Returns the database table name for this repository. + * + * @return string Table name. + */ abstract protected function getTableName(): string; + + /** + * Converts an entity to an associative array for database operations. + * + * @param T $entity The entity to convert. + * + * @return array Associative array with column names as keys. + */ abstract protected function toArray(object $entity): array; + + /** + * Converts a database row to an entity object. + * + * @param array $row Associative array from database. + * + * @return T The mapped entity. + */ abstract protected function toEntity(array $row): object; } diff --git a/tests/WebFiori/Tests/Database/Repository/AbstractRepositoryTest.php b/tests/WebFiori/Tests/Database/Repository/AbstractRepositoryTest.php new file mode 100644 index 00000000..c32cba57 --- /dev/null +++ b/tests/WebFiori/Tests/Database/Repository/AbstractRepositoryTest.php @@ -0,0 +1,177 @@ +id = $id; + $this->name = $name; + $this->value = $value; + } +} + +class TestRepository extends AbstractRepository { + protected function getTableName(): string { + return 'test_entities'; + } + + protected function getIdField(): string { + return 'id'; + } + + protected function toEntity(array $row): object { + return new TestEntity( + (int) $row['id'], + $row['name'], + (int) $row['value'] + ); + } + + protected function toArray(object $entity): array { + return [ + 'id' => $entity->id, + 'name' => $entity->name, + 'value' => $entity->value + ]; + } +} + +class AbstractRepositoryTest extends TestCase { + private static ?Database $db = null; + private static ?TestRepository $repo = null; + + public static function setUpBeforeClass(): void { + $conn = new ConnectionInfo('mysql', 'root', '123456', 'testing_db', '127.0.0.1'); + self::$db = new Database($conn); + + self::$db->createBlueprint('test_entities')->addColumns([ + 'id' => [ + ColOption::TYPE => DataType::INT, + ColOption::PRIMARY => true, + ColOption::AUTO_INCREMENT => true + ], + 'name' => [ + ColOption::TYPE => DataType::VARCHAR, + ColOption::SIZE => 100 + ], + 'value' => [ + ColOption::TYPE => DataType::INT + ] + ]); + + self::$db->table('test_entities')->createTable()->execute(); + self::$repo = new TestRepository(self::$db); + } + + public static function tearDownAfterClass(): void { + self::$db->setQuery('DROP TABLE IF EXISTS test_entities'); + self::$db->execute(); + } + + protected function setUp(): void { + self::$db->table('test_entities')->delete()->execute(); + } + + public function testSaveNewEntity() { + $entity = new TestEntity(null, 'Test', 100); + self::$repo->save($entity); + + $this->assertEquals(1, self::$repo->count()); + $found = self::$repo->findAll()[0]; + $this->assertEquals('Test', $found->name); + $this->assertEquals(100, $found->value); + } + + public function testSaveExistingEntity() { + $entity = new TestEntity(null, 'Original', 50); + self::$repo->save($entity); + + $found = self::$repo->findAll()[0]; + $found->name = 'Updated'; + $found->value = 75; + self::$repo->save($found); + + $this->assertEquals(1, self::$repo->count()); + $updated = self::$repo->findById($found->id); + $this->assertEquals('Updated', $updated->name); + $this->assertEquals(75, $updated->value); + } + + public function testSaveAllEmpty() { + self::$repo->saveAll([]); + $this->assertEquals(0, self::$repo->count()); + } + + public function testSaveAllNewEntities() { + $entities = [ + new TestEntity(null, 'Item1', 10), + new TestEntity(null, 'Item2', 20), + new TestEntity(null, 'Item3', 30) + ]; + + self::$repo->saveAll($entities); + + $this->assertEquals(3, self::$repo->count()); + $all = self::$repo->findAll(); + $names = array_map(fn($e) => $e->name, $all); + $this->assertContains('Item1', $names); + $this->assertContains('Item2', $names); + $this->assertContains('Item3', $names); + } + + public function testSaveAllExistingEntities() { + // Insert initial entities + self::$repo->saveAll([ + new TestEntity(null, 'A', 1), + new TestEntity(null, 'B', 2) + ]); + + // Update them + $all = self::$repo->findAll(); + foreach ($all as $entity) { + $entity->value = $entity->value * 10; + } + self::$repo->saveAll($all); + + $this->assertEquals(2, self::$repo->count()); + $updated = self::$repo->findAll(); + $values = array_map(fn($e) => $e->value, $updated); + sort($values); + $this->assertEquals([10, 20], $values); + } + + public function testSaveAllMixed() { + // Insert one entity first + self::$repo->save(new TestEntity(null, 'Existing', 100)); + $existing = self::$repo->findAll()[0]; + $existing->name = 'Modified'; + + // Save mix of new and existing + self::$repo->saveAll([ + $existing, + new TestEntity(null, 'New1', 200), + new TestEntity(null, 'New2', 300) + ]); + + $this->assertEquals(3, self::$repo->count()); + + $modified = self::$repo->findById($existing->id); + $this->assertEquals('Modified', $modified->name); + + $all = self::$repo->findAll(); + $names = array_map(fn($e) => $e->name, $all); + $this->assertContains('New1', $names); + $this->assertContains('New2', $names); + } +} From 84e3a4512e9ef2e37a66b012df94207822b8ee6b Mon Sep 17 00:00:00 2001 From: Ibrahim BinAlshikh Date: Mon, 5 Jan 2026 23:32:16 +0300 Subject: [PATCH 39/44] docs: Updated Code Examples --- WebFiori/Database/Schema/SchemaRunner.php | 2 +- examples/06-migrations/example.php | 33 +++++++++-------------- examples/07-seeders/example.php | 31 ++++++++------------- 3 files changed, 24 insertions(+), 42 deletions(-) diff --git a/WebFiori/Database/Schema/SchemaRunner.php b/WebFiori/Database/Schema/SchemaRunner.php index fd1fb2c9..c7f0509f 100644 --- a/WebFiori/Database/Schema/SchemaRunner.php +++ b/WebFiori/Database/Schema/SchemaRunner.php @@ -275,7 +275,7 @@ public function createSchemaTable() { * @param bool $recursive Whether to scan subdirectories recursively. Default is false. * @return int Number of changes discovered and registered. */ - public function discoverFromPath(string $path, string $namespace, bool $recursive = false): int { + public function discoverFromPath(string $path, string $namespace = '', bool $recursive = false): int { $count = 0; if (!is_dir($path)) { diff --git a/examples/06-migrations/example.php b/examples/06-migrations/example.php index 6a9b55c8..6492b3b4 100644 --- a/examples/06-migrations/example.php +++ b/examples/06-migrations/example.php @@ -13,40 +13,31 @@ $connection = new ConnectionInfo('mysql', 'root', '123456', 'mysql'); $database = new Database($connection); - echo "1. Loading Migration Classes:\n"; - - // Include migration classes - require_once __DIR__.'/CreateUsersTableMigration.php'; - require_once __DIR__.'/AddEmailIndexMigration.php'; - - echo "✓ Migration classes loaded\n\n"; - - echo "2. Setting up Schema Runner:\n"; + echo "1. Setting up Schema Runner:\n"; // Create schema runner $runner = new SchemaRunner($connection); - // Register migration classes - $runner->register('CreateUsersTableMigration'); - $runner->register('AddEmailIndexMigration'); + // Discover and register migration classes from directory + $runner->discoverFromPath(__DIR__, ''); echo "✓ Schema runner created\n"; - echo "✓ Migration classes registered\n"; + echo "✓ Migration classes discovered from path\n"; // Create schema tracking table $runner->createSchemaTable(); echo "✓ Schema tracking table created\n\n"; - echo "3. Checking Available Migrations:\n"; + echo "2. Checking Available Migrations:\n"; $changes = $runner->getChanges(); - echo "Registered migrations:\n"; + echo "Discovered migrations:\n"; foreach ($changes as $change) { echo " - ".$change->getName()."\n"; } echo "\n"; - echo "4. Running Migrations (using apply()):\n"; + echo "3. Running Migrations (using apply()):\n"; // Apply all pending migrations $result = $runner->apply(); @@ -68,7 +59,7 @@ } echo "\n"; - echo "5. Verifying Database Structure:\n"; + echo "4. Verifying Database Structure:\n"; // Check if table exists $tableResult = $database->raw("SHOW TABLES LIKE 'users'")->execute(); @@ -90,7 +81,7 @@ } echo "\n"; - echo "6. Testing Data Operations:\n"; + echo "5. Testing Data Operations:\n"; // Insert test data $database->table('users')->insert([ @@ -115,7 +106,7 @@ } echo "\n"; - echo "7. Checking Migration Status:\n"; + echo "6. Checking Migration Status:\n"; echo "Migration status:\n"; foreach ($changes as $change) { $status = $runner->isApplied($change->getName()) ? "✓ Applied" : "✗ Pending"; @@ -123,7 +114,7 @@ } echo "\n"; - echo "8. Rolling Back Migrations:\n"; + echo "7. Rolling Back Migrations:\n"; // Rollback all migrations $rolledBack = $runner->rollbackUpTo(null); @@ -143,7 +134,7 @@ echo "✓ Users table removed\n"; } - echo "\n9. Cleanup:\n"; + echo "\n8. Cleanup:\n"; $runner->dropSchemaTable(); echo "✓ Schema tracking table dropped\n"; } catch (Exception $e) { diff --git a/examples/07-seeders/example.php b/examples/07-seeders/example.php index 2bce0533..5b462f9d 100644 --- a/examples/07-seeders/example.php +++ b/examples/07-seeders/example.php @@ -86,40 +86,31 @@ $database->execute(); echo "✓ Categories table created\n\n"; - echo "2. Loading Seeder Classes:\n"; - - // Include seeder classes - require_once __DIR__.'/UsersSeeder.php'; - require_once __DIR__.'/CategoriesSeeder.php'; - - echo "✓ Seeder classes loaded\n\n"; - - echo "3. Setting up Schema Runner:\n"; + echo "2. Setting up Schema Runner:\n"; // Create schema runner $runner = new SchemaRunner($connection); - // Register seeder classes - $runner->register('UsersSeeder'); - $runner->register('CategoriesSeeder'); + // Discover and register seeder classes from directory + $runner->discoverFromPath(__DIR__, ''); echo "✓ Schema runner created\n"; - echo "✓ Seeder classes registered\n"; + echo "✓ Seeder classes discovered from path\n"; // Create schema tracking table $runner->createSchemaTable(); echo "✓ Schema tracking table created\n\n"; - echo "4. Checking Available Seeders:\n"; + echo "3. Checking Available Seeders:\n"; $changes = $runner->getChanges(); - echo "Registered seeders:\n"; + echo "Discovered seeders:\n"; foreach ($changes as $change) { echo " - ".$change->getName()."\n"; } echo "\n"; - echo "5. Running Seeders (using apply()):\n"; + echo "4. Running Seeders (using apply()):\n"; // Apply all pending seeders $result = $runner->apply(); @@ -134,7 +125,7 @@ } echo "\n"; - echo "6. Verifying Seeded Data:\n"; + echo "5. Verifying Seeded Data:\n"; // Check users data $usersResult = $database->table('users')->select()->execute(); @@ -153,7 +144,7 @@ } echo "\n"; - echo "7. Checking Seeder Status:\n"; + echo "6. Checking Seeder Status:\n"; echo "Seeder status:\n"; foreach ($changes as $change) { $status = $runner->isApplied($change->getName()) ? "✓ Applied" : "✗ Pending"; @@ -161,7 +152,7 @@ } echo "\n"; - echo "8. Rolling Back Seeders:\n"; + echo "7. Rolling Back Seeders:\n"; // Rollback all seeders (note: seeders don't clear data by default) $rolledBack = $runner->rollbackUpTo(null); @@ -183,7 +174,7 @@ echo " Users: $userCount records\n"; echo " Categories: $categoryCount records\n\n"; - echo "9. Cleanup:\n"; + echo "8. Cleanup:\n"; $runner->dropSchemaTable(); $database->raw("DROP TABLE categories")->execute(); $database->raw("DROP TABLE users")->execute(); From 51548cbf71c56acf42cb89efd5a5e96340c40ee0 Mon Sep 17 00:00:00 2001 From: Ibrahim BinAlshikh Date: Tue, 6 Jan 2026 01:08:39 +0300 Subject: [PATCH 40/44] docs: Multiple Updates --- .github/workflows/php84.yaml | 27 +- .github/workflows/php85.yaml | 128 +++ README.md | 844 +++++++++--------- .../Repository/AbstractRepository.php | 63 +- WebFiori/Database/Table.php | 1 + examples/04-entity-mapping/example.php | 27 +- examples/11-repository-pattern/README.md | 29 +- examples/14-active-record-model/Article.php | 66 ++ examples/14-active-record-model/README.md | 83 ++ examples/14-active-record-model/example.php | 99 ++ examples/README.md | 7 + .../Repository/AbstractRepositoryTest.php | 65 ++ 12 files changed, 946 insertions(+), 493 deletions(-) create mode 100644 .github/workflows/php85.yaml create mode 100644 examples/14-active-record-model/Article.php create mode 100644 examples/14-active-record-model/README.md create mode 100644 examples/14-active-record-model/example.php diff --git a/.github/workflows/php84.yaml b/.github/workflows/php84.yaml index 53f26da6..4b233b8a 100644 --- a/.github/workflows/php84.yaml +++ b/.github/workflows/php84.yaml @@ -100,29 +100,4 @@ jobs: uses: actions/upload-artifact@v4 with: name: code-coverage - path: php-8.4-coverage.xml - - - code-coverage: - name: Coverage - needs: test - uses: WebFiori/workflows/.github/workflows/coverage-codecov.yaml@main - with: - php-version: '8.1' - coverage-file: 'php-8.1-coverage.xml' - secrets: - CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} - - code-quality: - name: Code Quality - needs: test - uses: WebFiori/workflows/.github/workflows/quality-sonarcloud.yaml@main - secrets: - SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} - - release-prod: - name: Prepare Production Release Branch / Publish Release - needs: [code-coverage, code-quality] - uses: WebFiori/workflows/.github/workflows/release-php.yaml@main - with: - branch: 'main' \ No newline at end of file + path: php-8.4-coverage.xml \ No newline at end of file diff --git a/.github/workflows/php85.yaml b/.github/workflows/php85.yaml new file mode 100644 index 00000000..31ebd387 --- /dev/null +++ b/.github/workflows/php85.yaml @@ -0,0 +1,128 @@ +name: PHP 8.5 + +on: + push: + branches: [ main ] + pull_request: + branches: [ main , dev] + +jobs: + test: + runs-on: ubuntu-latest + timeout-minutes: 10 + + env: + SA_SQL_SERVER_PASSWORD: ${{ secrets.SA_SQL_SERVER_PASSWORD }} + MYSQL_ROOT_PASSWORD: ${{ secrets.MYSQL_ROOT_PASSWORD }} + + services: + sqlserver: + image: mcr.microsoft.com/mssql/server:2019-latest + env: + SA_PASSWORD: ${{ secrets.SA_SQL_SERVER_PASSWORD }} + ACCEPT_EULA: Y + MSSQL_PID: Express + ports: + - "1433:1433" + mysql: + image: mysql:8.0 + env: + MYSQL_ROOT_PASSWORD: ${{ secrets.MYSQL_ROOT_PASSWORD }} + MYSQL_DATABASE: testing_db + MYSQL_ROOT_HOST: '%' + ports: + - 3306:3306 + options: --health-cmd="mysqladmin ping" --health-interval=10s --health-timeout=5s --health-retries=3 + strategy: + fail-fast: true + + name: Run PHPUnit Tests + + steps: + - name: Clone Repo + uses: actions/checkout@v4 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: 8.5 + extensions: mysqli, mbstring, sqlsrv + tools: phpunit:11.5.27, composer + + - name: Install ODBC Driver for SQL Server + run: | + curl https://packages.microsoft.com/keys/microsoft.asc | sudo tee /etc/apt/trusted.gpg.d/microsoft.asc + curl https://packages.microsoft.com/config/ubuntu/22.04/prod.list | sudo tee /etc/apt/sources.list.d/mssql-release.list + sudo apt update + sudo ACCEPT_EULA=Y apt install mssql-tools18 unixodbc-dev msodbcsql18 + + - name: Wait for SQL Server + run: | + for i in {1..12}; do + if /opt/mssql-tools18/bin/sqlcmd -S localhost -U SA -P '${{ secrets.SA_SQL_SERVER_PASSWORD }}' -Q 'SELECT 1' -C > /dev/null 2>&1; then + echo "SQL Server is ready" + break + fi + echo "Waiting for SQL Server... ($i/12)" + sleep 10 + done + + - name: Create SQL Server Database + run: /opt/mssql-tools18/bin/sqlcmd -S localhost -U SA -P '${{ secrets.SA_SQL_SERVER_PASSWORD }}' -Q 'create database testing_db' -C + + - name: Setup MySQL Client + run: | + sudo apt update + sudo apt install mysql-client-core-8.0 + + - name: Wait for MySQL + run: | + until mysqladmin ping -h 127.0.0.1 --silent; do + echo 'waiting for mysql...' + sleep 1 + done + + - name: Create MySQL Database + run: | + mysql -h 127.0.0.1 -u root -p${{ secrets.MYSQL_ROOT_PASSWORD }} -e "CREATE DATABASE IF NOT EXISTS testing_db;" + + - name: Install Dependencies + run: composer install --prefer-source --no-interaction + + - name: Execute Tests + run: phpunit --configuration=tests/phpunit10.xml --coverage-clover=clover.xml --stop-on-failure + + - name: Rename coverage report + run: | + mv clover.xml php-8.5-coverage.xml + + - name: Upload Coverage Report + uses: actions/upload-artifact@v4 + with: + name: code-coverage + path: php-8.5-coverage.xml + + + code-coverage: + name: Coverage + needs: test + uses: WebFiori/workflows/.github/workflows/coverage-codecov.yaml@main + with: + php-version: '8.5' + coverage-file: 'php-8.5-coverage.xml' + secrets: + CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} + + code-quality: + name: Code Quality + needs: test + uses: WebFiori/workflows/.github/workflows/quality-sonarcloud.yaml@main + secrets: + SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} + + release-prod: + name: Prepare Production Release Branch / Publish Release + needs: [code-coverage, code-quality] + uses: WebFiori/workflows/.github/workflows/release-php.yaml@main + with: + branch: 'main' \ No newline at end of file diff --git a/README.md b/README.md index f42c8e2d..a3b670f3 100644 --- a/README.md +++ b/README.md @@ -1,446 +1,398 @@ -# Webfiori Database Abstraction Layer - -Database abstraction layer of WebFiori framework. - -

- - PHP 8 Build Status - - - CodeCov - - - Quality Checks - - - Version - - - Downloads - -

- -## Content - -* [Supported PHP Versions](#supported-php-versions) -* [Supported Databases](#supported-databases) -* [Features](#features) -* [Installation](#installation) -* [Usage](#usage) - * [Connecting to Database](#connecting-to-database) - * [Running Basic SQL Queries](#running-basic-sql-queries) - * [Insert Query](#insert-query) - * [Select Query](#select-query) - * [Update Query](#update-query) - * [Delete Query](#delete-query) - * [Building Database Structure](#building-database-structure) - * [Creating Table Blueprint](#creating-table-blueprint) - * [Seeding Structure to Database](#seeding-structure-to-database) - * [Creating Entity Classes and Using Them](#creating-entity-classes-and-using-them) - * [Creating an Entity Class](#creating-an-entity-class) - * [Using Entity Class](#using-entity-class) - * [Database Migrations](#database-migrations) - * [Database Seeders](#database-seeders) - * [Performance Monitoring](#performance-monitoring) - * [Transactions](#transactions) - -## Supported PHP Versions -| Build Status | -|:-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------:| -| | -| | -| | -| | - -## Supported Databases -- MySQL -- MSSQL - -## Features -* Building your database structure within PHP -* Fast and easy to use query builder -* Database abstraction which makes it easy to migrate your system to different DBMS -* Database migrations and seeders -* Performance monitoring and query analysis -* Entity mapping for object-relational mapping -* Transaction support with automatic rollback - -## Installation -To install the library using composer, add following dependency to `composer.json`: `"webfiori/database":"*"` - -## Usage - -### Connecting to Database - -Connecting to a database is simple. First step is to define database connection information using the class `ConnectionInfo`. Later, the instance can be used to establish a connection to the database using the class `Database`. - -```php -use WebFiori\Database\ConnectionInfo; -use WebFiori\Database\Database; - -// This assumes that MySQL is installed on localhost -// and root password is set to '123456' -// and there is a schema with name 'testing_db' -$connection = new ConnectionInfo('mysql', 'root', '123456', 'testing_db'); -$database = new Database($connection); -``` - -### Running Basic SQL Queries - -Most common SQL queries that will be executed in any relational DBMS are insert, select, update, and delete. Following examples shows how the 4 types can be constructed. - -For every query, the table that the query will be executed on must be specified. To specify the table, use the method `Database::table(string $tblName)`. The method will return an instance of the class `AbstractQuery`. The class `AbstractQuery` has many methods which are used to further build the query. Commonly used methods include the following: - -* `AbstractQuery::insert(array $cols)`: Construct an insert query. -* `AbstractQuery::select(array $cols)`: Construct a select query. -* `AbstractQuery::update(array $cols)`: Construct an update query. -* `AbstractQuery::delete()`: Construct a delete query. -* `AbstractQuery::where($col, $val)`: Adds a condition to the query. - -After building the query, the method `AbstractQuery::execute()` can be called to execute the query. If the query is a `select` query, the method will return an instance of the class `ResultSet`. The instance can be used to traverse the records that was returned by the DBMS. - -#### Insert Query - -Insert query is used to add records to the database. To execute an insert query, use the method `AbstractQuery::insert(array $cols)`. The method accepts one parameter. The parameter is an associative array. The indices of the array are columns names and the values of the indices are the values that will be inserted. - -```php -$connection = new ConnectionInfo('mysql', 'root', '123456', 'testing_db'); -$database = new Database($connection); - -$database->table('posts')->insert([ - 'title' => 'Super New Post', - 'author' => 'Me' -])->execute(); -``` - -#### Select Query - -A select query is used to fetch database records and use them in application logic. To execute a select query, use the method `AbstractQuery::select(array $cols)`. The method accepts one optional parameter. The parameter is an array that holds the names of the columns that will be selected. In this case, the method `AbstractQuery::execute()` will return an object of type `ResultSet`. The result set will contain raw fetched records as big array that holds the actual records. Each record is stored as an associative array. - -```php -$connection = new ConnectionInfo('mysql', 'root', '123456', 'testing_db'); -$database = new Database($connection); - -// This assumes that we have a table called 'posts' in the database. -$resultSet = $database->table('posts')->select()->execute(); - -foreach ($resultSet as $record) { - echo $record['title']; -} -``` - -It is possible to add a condition to the select query using the method `AbstractQuery::where()`. - -```php -$connection = new ConnectionInfo('mysql', 'root', '123456', 'testing_db'); -$database = new Database($connection); - -// This assumes that we have a table called 'posts' in the database. -$resultSet = $database->table('posts') - ->select() - ->where('author', 'Ibrahim') - ->execute(); - -foreach ($resultSet as $record) { - echo $record['title']; -} -``` - -#### Update Query - -Update query is used to update a single record or multiple records. To execute an update query, use the method `AbstractQuery::update(array $cols)`. The method accepts one parameter. The parameter is an associative array. The indices of the array are columns names and the values of the indices are the updated values. Usually, for any update query, a `where` condition will be included. To include a `where` condition, the method `AbstractQuery::where()` can be used. - -```php -$connection = new ConnectionInfo('mysql', 'root', '123456', 'testing_db'); -$database = new Database($connection); - -$database->table('posts')->update([ - 'title' => 'Super New Post By Ibrahim', -])->where('author', 'Ibrahim') - ->andWhere('created-on', '2023-03-24') - ->execute(); -``` - -#### Delete Query - -This query is used to delete specific record from the database. To execute delete query, use the method `AbstractQuery::delete()`. A `where` condition should be included to delete specific record. To include a `where` condition, the method `AbstractQuery::where()` can be used. - -```php -$connection = new ConnectionInfo('mysql', 'root', '123456', 'testing_db'); -$database = new Database($connection); - -$database->table('posts')->delete()->where('author', 'Ibrahim')->execute(); -``` - -### Building Database Structure - -One of the features of the library is the ability to define database structure in the source code and later, seed the created structure to create database tables. The blueprint of tables are represented by the class `Table`. The main aim of the blueprint is to make sure that data types in database are represented correctly in the source code. - -#### Creating Table Blueprint - -Each blueprint must have following attributes defined: - -* Name of the blueprint (database table name). -* Columns and their properties such as data type. -* Any relations with other tables. - -The method `Database::createBlueprint()` is used to create a table based on connected DBMS. The method will return an instance of the class `Table` which can be used to further customize the blueprint. - -```php -use WebFiori\Database\ColOption; -use WebFiori\Database\DataType; - -$database->createBlueprint('users_information')->addColumns([ - 'id' => [ - ColOption::TYPE => DataType::INT, - ColOption::SIZE => 5, - ColOption::PRIMARY => true, - ColOption::AUTO_INCREMENT => true - ], - 'first-name' => [ - ColOption::TYPE => DataType::VARCHAR, - ColOption::SIZE => 15 - ], - 'last-name' => [ - ColOption::TYPE => DataType::VARCHAR, - ColOption::SIZE => 15 - ], - 'email' => [ - ColOption::TYPE => DataType::VARCHAR, - ColOption::SIZE => 128 - ] -]); -``` - -#### Seeding Structure to Database - -After creating all blueprints, a query must be structured and executed to create database tables. Building the query can be performed using the method `Database::createTables()`. After calling this method, the method `Database::execute()` must be called to create all database tables. - -```php -// Build the query -$database->createTables(); - -// Execute -$database->execute(); -``` - -### Creating Entity Classes and Using Them - -Entity classes are classes which are based on blueprints (or tables). They can be used to map records of tables to objects. Every blueprint will have an instance of the class `EntityMapper` which can be used to create an entity class. - -Entity classes that are generated using the class `EntityMapper` are special. They will have one static method with name `map()` which can automatically map a record to an instance of the entity. - -#### Creating an Entity Class - -First step in creating an entity is to have the blueprint at which the entity will be based on. From the blueprint, an instance of the class `EntityMapper` is generated. After having the instance, the properties of the entity is set such as its name, namespace and where it will be created. Finally, the method `EntityMapper::create()` can be invoked to write the source code of the class. - -```php -$blueprint = $database->getTable('users_information'); - -// Get entity mapper -$entityMapper = $blueprint->getEntityMapper(); - -// Set properties of the entity -$entityMapper->setEntityName('UserInformation'); -$entityMapper->setNamespace(''); -$entityMapper->setPath(__DIR__); - -// Create the entity. The output will be the class 'UserInformation'. -$entityMapper->create(); -``` - -#### Using Entity Class - -Entity class can be used to map a record to an object. Each entity will have a special method called `map()`. The method accepts a single parameter which is an associative array that represents fetched record. - -The result set instance has one of array methods which is called `map($callback)` This method acts exactly as the function `array_map($callback, $array)`. The return value of the method is another result set with mapped records. - -```php -$resultSet = $database->table('users_information') - ->select() - ->execute(); - -$mappedSet = $resultSet->map(function (array $record) { - return UserInformation::map($record); -}); - -foreach ($mappedSet as $record) { - // $record is an object of type UserInformation - echo $record->getFirstName() . ' ' . $record->getLastName() . "\n"; -} -``` - -### Database Migrations - -Migrations allow you to version control your database schema changes. Each migration represents a specific change to your database structure. - -```php -use WebFiori\Database\Schema\AbstractMigration; -use WebFiori\Database\Database; - -class CreateUsersTable extends AbstractMigration { - - public function up(Database $db): void { - $db->createBlueprint('users')->addColumns([ - 'id' => [ - ColOption::TYPE => DataType::INT, - ColOption::PRIMARY => true, - ColOption::AUTO_INCREMENT => true - ], - 'name' => [ - ColOption::TYPE => DataType::VARCHAR, - ColOption::SIZE => 100 - ], - 'email' => [ - ColOption::TYPE => DataType::VARCHAR, - ColOption::SIZE => 150 - ] - ]); - - $db->createTables(); - $db->execute(); - } - - public function down(Database $db): void { - $db->setQuery("DROP TABLE users"); - $db->execute(); - } -} -``` - -To run migrations, use the SchemaRunner: - -```php -use WebFiori\Database\Schema\SchemaRunner; - -$runner = new SchemaRunner($connectionInfo); - -// Register migration classes -$runner->register('CreateUsersTable'); -$runner->register('AddEmailIndex'); - -// Create schema tracking table -$runner->createSchemaTable(); - -// Apply all pending migrations -$appliedMigrations = $runner->apply(); - -// Rollback migrations -$rolledBackMigrations = $runner->rollbackUpTo(null); -``` - -### Database Seeders - -Seeders allow you to populate your database with sample or default data. - -```php -use WebFiori\Database\Schema\AbstractSeeder; -use WebFiori\Database\Database; - -class UsersSeeder extends AbstractSeeder { - - public function run(Database $db): void { - $db->table('users')->insert([ - 'name' => 'Administrator', - 'email' => 'admin@example.com' - ])->execute(); - - $db->table('users')->insert([ - 'name' => 'John Doe', - 'email' => 'john@example.com' - ])->execute(); - } -} -``` - -To run seeders, use the same SchemaRunner: - -```php -use WebFiori\Database\Schema\SchemaRunner; - -$runner = new SchemaRunner($connectionInfo); - -// Register seeder classes -$runner->register('UsersSeeder'); -$runner->register('CategoriesSeeder'); - -// Create schema tracking table -$runner->createSchemaTable(); - -// Apply all pending seeders -$appliedSeeders = $runner->apply(); -``` - -### Performance Monitoring - -The library includes built-in performance monitoring to help you identify slow queries and optimize database performance. - -```php -use WebFiori\Database\Performance\PerformanceOption; -use WebFiori\Database\Performance\PerformanceAnalyzer; - -// Configure performance monitoring -$database->setPerformanceConfig([ - PerformanceOption::ENABLED => true, - PerformanceOption::SLOW_QUERY_THRESHOLD => 50, // 50ms threshold - PerformanceOption::SAMPLING_RATE => 1.0 // Monitor all queries -]); - -// Execute some queries -$database->table('users')->select()->execute(); -$database->table('posts')->select()->where('status', 'published')->execute(); - -// Analyze performance -$analyzer = $database->getPerformanceMonitor()->getAnalyzer(); - -echo "Total queries: " . $analyzer->getQueryCount() . "\n"; -echo "Average execution time: " . $analyzer->getAverageTime() . "ms\n"; -echo "Performance score: " . $analyzer->getScore() . "\n"; -echo "Slow queries: " . $analyzer->getSlowQueryCount() . "\n"; - -// Check if performance needs improvement -if ($analyzer->getScore() === PerformanceAnalyzer::SCORE_NEEDS_IMPROVEMENT) { - $slowQueries = $analyzer->getSlowQueries(); - foreach ($slowQueries as $metric) { - echo "Slow query: " . $metric->getQuery() . " (" . $metric->getExecutionTimeMs() . "ms)\n"; - } -} -``` - -### Transactions - -Database transactions ensure that multiple operations are executed as a single unit of work. If any operation fails, all operations are rolled back. - -```php -$database->transaction(function (Database $db) { - // Insert user - $db->table('users')->insert([ - 'name' => 'John Doe', - 'email' => 'john@example.com' - ])->execute(); - - // Insert user profile - $db->table('user_profiles')->insert([ - 'user_id' => $db->getLastInsertId(), - 'bio' => 'Software Developer' - ])->execute(); - - // If any query fails, the entire transaction is rolled back -}); -``` - -You can also pass additional parameters to the transaction closure: - -```php -$userData = ['name' => 'Jane Doe', 'email' => 'jane@example.com']; - -$database->transaction(function (Database $db, array $user) { - $db->table('users')->insert($user)->execute(); - - $db->table('user_profiles')->insert([ - 'user_id' => $db->getLastInsertId(), - 'created_at' => date('Y-m-d H:i:s') - ])->execute(); - -}, [$userData]); -``` +# Webfiori Database Abstraction Layer + +Database abstraction layer of WebFiori framework. + +

+ + PHP 8 Build Status + + + CodeCov + + + Quality Checks + + + Version + + + Downloads + +

+ +## Content + +* [Supported PHP Versions](#supported-php-versions) +* [Supported Databases](#supported-databases) +* [Features](#features) +* [Installation](#installation) +* [Usage](#usage) + * [Connecting to Database](#connecting-to-database) + * [Running Basic SQL Queries](#running-basic-sql-queries) + * [Building Database Structure](#building-database-structure) + * [Repository Pattern](#repository-pattern) + * [Active Record Pattern](#active-record-pattern) + * [Entity Generation](#entity-generation) + * [Database Migrations](#database-migrations) + * [Database Seeders](#database-seeders) + * [Performance Monitoring](#performance-monitoring) + * [Transactions](#transactions) + +## Supported PHP Versions +| Build Status | +|:-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------:| +| | +| | +| | +| | +| | + +## Supported Databases +- MySQL +- MSSQL + +## Features +* Building your database structure within PHP +* Fast and easy to use query builder +* Database abstraction which makes it easy to migrate your system to different DBMS +* Repository pattern with `AbstractRepository` for clean data access +* Active Record pattern support for rapid development +* PHP 8 attributes for table definitions +* Database migrations and seeders +* Performance monitoring and query analysis +* Entity generation for object-relational mapping +* Transaction support with automatic rollback + +## Installation +To install the library using composer, add following dependency to `composer.json`: `"webfiori/database":"*"` + +## Usage + +### Connecting to Database + +Connecting to a database is simple. First step is to define database connection information using the class `ConnectionInfo`. Later, the instance can be used to establish a connection to the database using the class `Database`. + +```php +use WebFiori\Database\ConnectionInfo; +use WebFiori\Database\Database; + +$connection = new ConnectionInfo('mysql', 'root', '123456', 'testing_db'); +$database = new Database($connection); +``` + +### Running Basic SQL Queries + +For every query, the table must be specified using `Database::table(string $tblName)`. The method returns an `AbstractQuery` instance with methods for building queries: + +* `insert(array $cols)`: Construct an insert query. +* `select(array $cols)`: Construct a select query. +* `update(array $cols)`: Construct an update query. +* `delete()`: Construct a delete query. +* `where($col, $val)`: Adds a condition to the query. + +After building the query, call `execute()` to run it. + +```php +// Insert +$database->table('posts')->insert([ + 'title' => 'Super New Post', + 'author' => 'Me' +])->execute(); + +// Select +$resultSet = $database->table('posts') + ->select() + ->where('author', 'Ibrahim') + ->execute(); + +foreach ($resultSet as $record) { + echo $record['title']; +} + +// Update +$database->table('posts')->update([ + 'title' => 'Updated Title', +])->where('id', 1)->execute(); + +// Delete +$database->table('posts')->delete()->where('id', 1)->execute(); +``` + +### Building Database Structure + +Define database structure in PHP code using blueprints: + +```php +use WebFiori\Database\ColOption; +use WebFiori\Database\DataType; + +$database->createBlueprint('users')->addColumns([ + 'id' => [ + ColOption::TYPE => DataType::INT, + ColOption::PRIMARY => true, + ColOption::AUTO_INCREMENT => true + ], + 'name' => [ + ColOption::TYPE => DataType::VARCHAR, + ColOption::SIZE => 100 + ], + 'email' => [ + ColOption::TYPE => DataType::VARCHAR, + ColOption::SIZE => 150 + ] +]); + +// Create the table +$database->table('users')->createTable()->execute(); +``` + +### Repository Pattern + +The `AbstractRepository` class provides a clean way to handle data access with separation between entities and database logic. + +#### Creating an Entity + +```php +class Product { + public ?int $id = null; + public string $name; + public float $price; +} +``` + +#### Creating a Repository + +```php +use WebFiori\Database\Repository\AbstractRepository; + +class ProductRepository extends AbstractRepository { + protected function getTableName(): string { + return 'products'; + } + + protected function getIdField(): string { + return 'id'; + } + + protected function toEntity(array $row): object { + $product = new Product(); + $product->id = (int) $row['id']; + $product->name = $row['name']; + $product->price = (float) $row['price']; + return $product; + } + + protected function toArray(object $entity): array { + return [ + 'id' => $entity->id, + 'name' => $entity->name, + 'price' => $entity->price + ]; + } +} +``` + +#### Using the Repository + +```php +$repo = new ProductRepository($database); + +// Create +$product = new Product(); +$product->name = 'Widget'; +$product->price = 29.99; +$repo->save($product); + +// Read +$product = $repo->findById(1); +$allProducts = $repo->findAll(); + +// Update +$product->price = 24.99; +$repo->save($product); + +// Delete +$repo->deleteById(1); + +// Pagination +$page = $repo->paginate(page: 1, perPage: 20); +``` + +### Active Record Pattern + +For rapid development, you can merge entity and repository into a single model class: + +```php +use WebFiori\Database\Attributes\Column; +use WebFiori\Database\Attributes\Table; +use WebFiori\Database\DataType; +use WebFiori\Database\Repository\AbstractRepository; + +#[Table(name: 'articles')] +class Article extends AbstractRepository { + #[Column(type: DataType::INT, primary: true, autoIncrement: true)] + public ?int $id = null; + + #[Column(type: DataType::VARCHAR, size: 200)] + public string $title = ''; + + #[Column(type: DataType::TEXT)] + public string $content = ''; + + protected function getTableName(): string { return 'articles'; } + protected function getIdField(): string { return 'id'; } + + protected function toEntity(array $row): object { + $article = new self($this->db); + $article->id = (int) $row['id']; + $article->title = $row['title']; + $article->content = $row['content']; + return $article; + } + + protected function toArray(object $entity): array { + return [ + 'id' => $entity->id, + 'title' => $entity->title, + 'content' => $entity->content + ]; + } +} +``` + +Usage: + +```php +// Create and save +$article = new Article($database); +$article->title = 'Hello World'; +$article->content = 'My first article'; +$article->save(); + +// Query +$all = $article->findAll(); +$one = $article->findById(1); + +// Update +$article->title = 'Updated Title'; +$article->save(); + +// Delete +$article->deleteById(); + +// Reload from database +$fresh = $article->reload(); +``` + +### Entity Generation + +Generate entity classes from table blueprints: + +```php +$blueprint = $database->getTable('users'); + +$generator = $blueprint->getEntityGenerator('User', __DIR__, 'App\\Entity'); +$generator->generate(); +``` + +### Database Migrations + +Version control your database schema changes: + +```php +use WebFiori\Database\Schema\AbstractMigration; + +class CreateUsersTable extends AbstractMigration { + public function up(Database $db): void { + $db->createBlueprint('users')->addColumns([ + 'id' => [ColOption::TYPE => DataType::INT, ColOption::PRIMARY => true, ColOption::AUTO_INCREMENT => true], + 'name' => [ColOption::TYPE => DataType::VARCHAR, ColOption::SIZE => 100] + ]); + $db->table('users')->createTable()->execute(); + } + + public function down(Database $db): void { + $db->raw("DROP TABLE users")->execute(); + } +} +``` + +Run migrations: + +```php +use WebFiori\Database\Schema\SchemaRunner; + +$runner = new SchemaRunner($connectionInfo); +$runner->discoverFromPath(__DIR__ . '/migrations', 'App\\Migrations'); +$runner->createSchemaTable(); +$runner->apply(); +``` + +### Database Seeders + +Populate your database with sample data: + +```php +use WebFiori\Database\Schema\AbstractSeeder; + +class UsersSeeder extends AbstractSeeder { + public function run(Database $db): void { + $db->table('users')->insert([ + 'name' => 'Administrator', + 'email' => 'admin@example.com' + ])->execute(); + } +} +``` + +### Performance Monitoring + +Track and analyze query performance: + +```php +use WebFiori\Database\Performance\PerformanceOption; + +$database->setPerformanceConfig([ + PerformanceOption::ENABLED => true, + PerformanceOption::SLOW_QUERY_THRESHOLD => 50 +]); + +// Execute queries... + +$analyzer = $database->getPerformanceMonitor()->getAnalyzer(); +echo "Total queries: " . $analyzer->getQueryCount(); +echo "Slow queries: " . $analyzer->getSlowQueryCount(); +``` + +### Transactions + +Execute multiple operations as a single unit: + +```php +$database->transaction(function (Database $db) { + $db->table('users')->insert(['name' => 'John'])->execute(); + $db->table('profiles')->insert([ + 'user_id' => $db->getLastInsertId(), + 'bio' => 'Developer' + ])->execute(); +}); +``` + +## Examples + +See the [examples](examples/) directory for complete working examples: + +- [01-basic-connection](examples/01-basic-connection/) - Database connections +- [02-basic-queries](examples/02-basic-queries/) - CRUD operations +- [03-table-blueprints](examples/03-table-blueprints/) - Table structures +- [04-entity-mapping](examples/04-entity-mapping/) - Entity generation +- [05-transactions](examples/05-transactions/) - Transaction handling +- [06-migrations](examples/06-migrations/) - Schema migrations +- [07-seeders](examples/07-seeders/) - Data seeding +- [08-performance-monitoring](examples/08-performance-monitoring/) - Query analysis +- [09-multi-result-queries](examples/09-multi-result-queries/) - Stored procedures +- [10-attribute-based-tables](examples/10-attribute-based-tables/) - PHP 8 attributes +- [11-repository-pattern](examples/11-repository-pattern/) - Repository pattern +- [12-clean-architecture](examples/12-clean-architecture/) - Domain separation +- [13-pagination](examples/13-pagination/) - Pagination techniques +- [14-active-record-model](examples/14-active-record-model/) - Active Record pattern diff --git a/WebFiori/Database/Repository/AbstractRepository.php b/WebFiori/Database/Repository/AbstractRepository.php index e8a92a60..eb9cbfc1 100644 --- a/WebFiori/Database/Repository/AbstractRepository.php +++ b/WebFiori/Database/Repository/AbstractRepository.php @@ -52,9 +52,17 @@ public function deleteAll(): void { /** * Deletes a record by its ID. * - * @param mixed $id The ID of the record to delete. + * If no ID is passed, uses the ID from $this. + * + * @param mixed $id The ID of the record to delete, or null to use $this->id. + * + * @throws \InvalidArgumentException If no ID is provided and $this has no ID. */ - public function deleteById(mixed $id): void { + public function deleteById(mixed $id = null): void { + $id = $id ?? $this->getEntityId(); + if ($id === null) { + throw new \InvalidArgumentException('Cannot delete: no ID provided'); + } $this->db->table($this->getTableName()) ->delete() ->where($this->getIdField(), $id) @@ -77,11 +85,17 @@ public function findAll(): array { /** * Finds a single record by its ID. * - * @param mixed $id The ID to search for. + * @param mixed $id The ID to search for, or null to use $this->id. * * @return T|null The entity if found, null otherwise. + * + * @throws \InvalidArgumentException If no ID is provided and $this has no ID. */ - public function findById(mixed $id): ?object { + public function findById(mixed $id = null): ?object { + $id = $id ?? $this->getEntityId(); + if ($id === null) { + throw new \InvalidArgumentException('Cannot find: no ID provided'); + } $result = $this->db->table($this->getTableName()) ->select() ->where($this->getIdField(), $id) @@ -90,6 +104,36 @@ public function findById(mixed $id): ?object { return $result->getCount() > 0 ? $this->toEntity($result->fetch()) : null; } + /** + * Reloads $this or the given entity from the database. + * + * @param T|null $entity The entity to reload, or null to reload $this. + * + * @return T|null Fresh entity from database, or null if not found. + * + * @throws \InvalidArgumentException If no entity provided and $this has no ID. + */ + public function reload(?object $entity = null): ?object { + if ($entity === null) { + return $this->findById(); + } + $id = $this->toArray($entity)[$this->getIdField()] ?? null; + return $this->findById($id); + } + + /** + * Gets the ID value from $this if it has entity properties. + * + * @return mixed The ID value or null. + */ + private function getEntityId(): mixed { + $idField = $this->getIdField(); + if (property_exists($this, $idField)) { + return $this->$idField; + } + return null; + } + /** * Retrieves paginated records using offset-based pagination. * @@ -173,10 +217,17 @@ public function paginateByCursor( * Saves an entity (insert if new, update if existing). * * An entity is considered new if its ID field is null. + * If no entity is passed and $this has entity properties, saves $this. + * + * @param T|null $entity The entity to save, or null to save $this. * - * @param T $entity The entity to save. + * @throws \InvalidArgumentException If no entity is provided and $this has no entity properties. */ - public function save(object $entity): void { + public function save(?object $entity = null): void { + if ($entity === null && !property_exists($this, $this->getIdField())) { + throw new \InvalidArgumentException('Cannot save: no entity provided'); + } + $entity = $entity ?? $this; $data = $this->toArray($entity); $id = $data[$this->getIdField()] ?? null; unset($data[$this->getIdField()]); diff --git a/WebFiori/Database/Table.php b/WebFiori/Database/Table.php index a27355df..72ee3365 100644 --- a/WebFiori/Database/Table.php +++ b/WebFiori/Database/Table.php @@ -11,6 +11,7 @@ */ namespace WebFiori\Database; +use WebFiori\Database\Entity\EntityGenerator; use WebFiori\Database\Entity\EntityMapper; use WebFiori\Database\Factory\ColumnFactory; use WebFiori\Database\MsSql\MSSQLTable; diff --git a/examples/04-entity-mapping/example.php b/examples/04-entity-mapping/example.php index 366778ce..ca9f299a 100644 --- a/examples/04-entity-mapping/example.php +++ b/examples/04-entity-mapping/example.php @@ -9,9 +9,7 @@ echo "=== WebFiori Database Entity Mapping Example ===\n\n"; -echo "NOTE: EntityMapper is deprecated for production use.\n"; -echo " Use manual entity classes with Repository pattern instead.\n"; -echo " This example shows both approaches.\n\n"; +echo "This example shows entity generation and manual mapping approaches.\n\n"; try { $connection = new ConnectionInfo('mysql', 'root', '123456', 'mysql'); @@ -41,21 +39,24 @@ // ============================================ // APPROACH 1: Using EntityMapper (Deprecated) // ============================================ - echo "3. DEPRECATED: Using EntityMapper (for rapid prototyping only):\n"; + echo "3. Using EntityGenerator:\n"; - $entityMapper = $userTable->getEntityMapper(); - $entityMapper->setEntityName('User'); - $entityMapper->setNamespace(''); - $entityMapper->setPath(__DIR__); - $entityMapper->create(); + $entityGenerator = $userTable->getEntityGenerator('User', __DIR__, ''); + $entityGenerator->generate(); echo "✓ User entity class generated at: ".__DIR__."/User.php\n"; require_once __DIR__.'/User.php'; $resultSet = $database->table('users')->select()->execute(); - $mappedUsers = $resultSet->map(fn($record) => User::map($record)); - - echo "Mapped users (EntityMapper):\n"; + $mappedUsers = $resultSet->map(fn($record) => new User( + id: (int) $record['id'], + firstName: $record['first_name'], + lastName: $record['last_name'], + email: $record['email'], + age: (int) $record['age'] + )); + + echo "Mapped users (EntityGenerator):\n"; foreach ($mappedUsers as $user) { echo " - {$user->getFirstName()} {$user->getLastName()} ({$user->getEmail()})\n"; } @@ -64,7 +65,7 @@ // ============================================ // APPROACH 2: Manual Entity (Recommended) // ============================================ - echo "4. RECOMMENDED: Manual Entity with Repository Pattern:\n"; + echo "4. Alternative: Manual Entity Mapping:\n"; // Define entity manually (in real code, this would be in a separate file) // See example 11-repository-pattern for full implementation diff --git a/examples/11-repository-pattern/README.md b/examples/11-repository-pattern/README.md index 0beea6ca..7e763453 100644 --- a/examples/11-repository-pattern/README.md +++ b/examples/11-repository-pattern/README.md @@ -2,12 +2,37 @@ This example demonstrates how to use the Repository pattern with `AbstractRepository` for data access. +## Entity + Repository = Model + +In traditional MVC frameworks, a "Model" class often combines data and database logic together (Active Record pattern). The Repository pattern separates these into two focused classes: + +| Component | Responsibility | +|-----------|----------------| +| **Entity** (`Product`) | Plain data object holding state. No database logic. | +| **Repository** (`ProductRepository`) | Handles all database operations for that entity. | + +``` +Traditional MVC Model = Entity + Repository +``` + +## Why Use This Approach? + +1. **Single Responsibility** - Each class has one job. Entities hold data, repositories handle persistence. + +2. **Testability** - Entities can be unit tested without a database. Repositories can be mocked in service tests. + +3. **Flexibility** - Swap database implementations without changing entity code. Easy to switch from MySQL to PostgreSQL or add caching. + +4. **Clean Domain Logic** - Entities stay focused on business rules, not database concerns. + +5. **Reusable Queries** - Common queries live in the repository and can be reused across the application. + ## What This Example Shows - Extending `AbstractRepository` for CRUD operations -- Implementing `toEntity()` and `toArray()` methods +- Implementing `toEntity()` and `toArray()` for mapping - Using built-in methods: `findAll()`, `findById()`, `save()`, `deleteById()` -- Creating custom query methods +- Creating custom query methods (`findByCategory()`, `findLowStock()`) - Pagination with `paginate()` ## Files diff --git a/examples/14-active-record-model/Article.php b/examples/14-active-record-model/Article.php new file mode 100644 index 00000000..a1d94d9b --- /dev/null +++ b/examples/14-active-record-model/Article.php @@ -0,0 +1,66 @@ +db); + $article->id = (int) $row['id']; + $article->title = $row['title']; + $article->content = $row['content']; + $article->authorName = $row['author-name'] ?? ''; + $article->createdAt = $row['created-at'] ?? null; + return $article; + } + + protected function toArray(object $entity): array { + return [ + 'id' => $entity->id, + 'title' => $entity->title, + 'content' => $entity->content, + 'author-name' => $entity->authorName, + ]; + } + + // Custom query methods + public function findByAuthor(string $author): array { + $result = $this->getDatabase()->table($this->getTableName()) + ->select() + ->where('author-name', $author) + ->execute(); + + return array_map(fn($row) => $this->toEntity($row), $result->fetchAll()); + } +} diff --git a/examples/14-active-record-model/README.md b/examples/14-active-record-model/README.md new file mode 100644 index 00000000..6120b5c3 --- /dev/null +++ b/examples/14-active-record-model/README.md @@ -0,0 +1,83 @@ +# Active Record Model + +This example demonstrates merging Entity and Repository into a single Model class, similar to the Active Record pattern used in many MVC frameworks. + +## Concept + +Instead of separating Entity and Repository: + +``` +Separate (Repository Pattern): +├── Product.php (Entity - data only) +└── ProductRepository.php (Repository - database operations) + +Merged (Active Record): +└── Article.php (Model - data + database operations) +``` + +## How It Works + +The `Article` class: +1. **Extends `AbstractRepository`** - Inherits all CRUD operations +2. **Uses `#[Table]` and `#[Column]` attributes** - Defines table structure +3. **Has public properties** - Holds entity data +4. **Implements mapping methods** - `toEntity()` and `toArray()` + +```php +#[Table(name: 'articles')] +class Article extends AbstractRepository { + #[Column(type: DataType::INT, primary: true, autoIncrement: true)] + public ?int $id = null; + + #[Column(type: DataType::VARCHAR, size: 200)] + public string $title = ''; + + // ... more properties +} +``` + +## Usage + +```php +// Create and save directly +$article = new Article($database); +$article->title = 'My Article'; +$article->content = 'Content here...'; +$article->save(); // Saves itself + +// Query using any instance +$articles = $article->findAll(); +$one = $article->findById(1); + +// Update +$one->title = 'Updated Title'; +$one->save(); +``` + +## Trade-offs + +| Approach | Pros | Cons | +|----------|------|------| +| **Merged (Active Record)** | Simple, less files, familiar to MVC developers | Harder to test, mixed responsibilities | +| **Separate (Repository)** | Testable, flexible, clean separation | More files, more boilerplate | + +Choose based on project complexity: +- **Small projects** → Active Record is simpler +- **Large projects** → Repository pattern scales better + +## Files + +- [`example.php`](example.php) - Main example code +- [`Article.php`](Article.php) - Model class with attributes + +## Running the Example + +```bash +php example.php +``` + +## Related Examples + +- [10-attribute-based-tables](../10-attribute-based-tables/) - Using attributes for table definition +- [11-repository-pattern](../11-repository-pattern/) - Separate Entity + Repository approach +- [12-clean-architecture](../12-clean-architecture/) - Full separation with domain layer diff --git a/examples/14-active-record-model/example.php b/examples/14-active-record-model/example.php new file mode 100644 index 00000000..96ba72f1 --- /dev/null +++ b/examples/14-active-record-model/example.php @@ -0,0 +1,99 @@ +addTable($table); + $database->table('articles')->createTable()->execute(); + echo "✓ Articles table created from class attributes\n\n"; + + echo "2. Using the Model:\n"; + + // Create and save articles directly + $article1 = new Article($database); + $article1->title = 'Introduction to WebFiori'; + $article1->content = 'WebFiori is a PHP framework...'; + $article1->authorName = 'Ahmad Hassan'; + $article1->save(); // Saves itself + + $article2 = new Article($database); + $article2->title = 'Database Patterns'; + $article2->content = 'Understanding repository pattern...'; + $article2->authorName = 'Fatima Ali'; + $article2->save(); + + $article3 = new Article($database); + $article3->title = 'Advanced PHP'; + $article3->content = 'PHP 8 features and attributes...'; + $article3->authorName = 'Ahmad Hassan'; + $article3->save(); + + echo "✓ Articles saved\n\n"; + + echo "3. Querying with Repository Methods:\n"; + + // Use any instance for queries + $articleModel = new Article($database); + + // findAll() + $all = $articleModel->findAll(); + echo "All articles ({$articleModel->count()}):\n"; + foreach ($all as $article) { + echo " - {$article->title} by {$article->authorName}\n"; + } + echo "\n"; + + // Custom query method + $byAuthor = $articleModel->findByAuthor('Ahmad Hassan'); + echo "Articles by Ahmad Hassan:\n"; + foreach ($byAuthor as $article) { + echo " - {$article->title}\n"; + } + echo "\n"; + + echo "4. Update and Delete:\n"; + + // Update + $first = $articleModel->findById(1); + $first->title = 'Updated: Introduction to WebFiori'; + $first->save(); // Saves itself + echo "✓ Article updated\n"; + + // Delete + $first->id = 2; + $first->deleteById(); + echo "✓ Article deleted\n"; + + echo "\nRemaining articles:\n"; + foreach ($articleModel->findAll() as $article) { + echo " - [{$article->id}] {$article->title}\n"; + } + echo "\n"; + + echo "5. Cleanup:\n"; + $database->raw("DROP TABLE articles")->execute(); + echo "✓ Table dropped\n"; + +} catch (Exception $e) { + echo "✗ Error: ".$e->getMessage()."\n"; + try { + $database->raw("DROP TABLE IF EXISTS articles")->execute(); + } catch (Exception $cleanupError) {} +} + +echo "\n=== Example Complete ===\n"; diff --git a/examples/README.md b/examples/README.md index 6ec707d5..e82bcdcf 100644 --- a/examples/README.md +++ b/examples/README.md @@ -19,6 +19,7 @@ This directory contains practical examples demonstrating how to use the WebFiori | 11 | [repository-pattern](11-repository-pattern/) | Repository pattern with AbstractRepository | | 12 | [clean-architecture](12-clean-architecture/) | Clean architecture with domain/infrastructure separation | | 13 | [pagination](13-pagination/) | Offset and cursor-based pagination | +| 14 | [active-record-model](14-active-record-model/) | Entity + Repository merged into single Model class | ## Prerequisites @@ -126,3 +127,9 @@ You can modify the connection parameters in each example as needed. - Repository interface in Domain layer - Database implementation in Infrastructure layer - Dependency inversion principle + +### 14-active-record-model +- Merging Entity and Repository into a single Model class +- Using attributes to define table structure on the model +- Active Record pattern for simpler projects +- Trade-offs vs Repository pattern diff --git a/tests/WebFiori/Tests/Database/Repository/AbstractRepositoryTest.php b/tests/WebFiori/Tests/Database/Repository/AbstractRepositoryTest.php index c32cba57..91dd92bf 100644 --- a/tests/WebFiori/Tests/Database/Repository/AbstractRepositoryTest.php +++ b/tests/WebFiori/Tests/Database/Repository/AbstractRepositoryTest.php @@ -174,4 +174,69 @@ public function testSaveAllMixed() { $this->assertContains('New1', $names); $this->assertContains('New2', $names); } + + public function testFindByIdWithNullThrowsException() { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Cannot find: no ID provided'); + + self::$repo->findById(null); + } + + public function testDeleteByIdWithNullThrowsException() { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Cannot delete: no ID provided'); + + self::$repo->deleteById(null); + } + + public function testFindByIdWithValidId() { + self::$repo->save(new TestEntity(null, 'FindMe', 42)); + $all = self::$repo->findAll(); + $id = $all[0]->id; + + $found = self::$repo->findById($id); + + $this->assertNotNull($found); + $this->assertEquals('FindMe', $found->name); + } + + public function testDeleteByIdWithValidId() { + self::$repo->save(new TestEntity(null, 'DeleteMe', 99)); + $all = self::$repo->findAll(); + $id = $all[0]->id; + + self::$repo->deleteById($id); + + $this->assertEquals(0, self::$repo->count()); + } + + public function testSaveWithNullOnPureRepoThrowsException() { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Cannot save: no entity provided'); + + self::$repo->save(); + } + + public function testReloadWithEntity() { + $entity = new TestEntity(null, 'Original', 100); + self::$repo->save($entity); + $saved = self::$repo->findAll()[0]; + + // Modify in database directly + self::$db->table('test_entities') + ->update(['name' => 'Modified']) + ->where('id', $saved->id) + ->execute(); + + $reloaded = self::$repo->reload($saved); + + $this->assertEquals('Modified', $reloaded->name); + } + + public function testReloadWithNullOnPureRepoThrowsException() { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Cannot find: no ID provided'); + + self::$repo->reload(); + } } From 6414b6e95b2bbbf1e544de6566c51776015229af Mon Sep 17 00:00:00 2001 From: Ibrahim BinAlshikh Date: Tue, 6 Jan 2026 01:12:24 +0300 Subject: [PATCH 41/44] fix: Return Count of Deleted --- WebFiori/Database/Schema/SchemaChangeRepository.php | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/WebFiori/Database/Schema/SchemaChangeRepository.php b/WebFiori/Database/Schema/SchemaChangeRepository.php index 5cbe8948..3e6370a5 100644 --- a/WebFiori/Database/Schema/SchemaChangeRepository.php +++ b/WebFiori/Database/Schema/SchemaChangeRepository.php @@ -40,7 +40,9 @@ public function __construct(Database $database) { * @return int Number of records deleted */ public function clearAll(): int { - return $this->deleteAll(); + $count = $this->count(); + $this->deleteAll(); + return $count; } /** From 45fc84a33a28eccef4ab67efeee770289703f571 Mon Sep 17 00:00:00 2001 From: Ibrahim BinAlshikh Date: Tue, 6 Jan 2026 10:53:30 +0300 Subject: [PATCH 42/44] chore: Exclude Examples from Scan --- sonar-project.properties | 24 ++++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/sonar-project.properties b/sonar-project.properties index 281a0445..ec19bba5 100644 --- a/sonar-project.properties +++ b/sonar-project.properties @@ -1,12 +1,12 @@ -sonar.projectKey=WebFiori_database -sonar.organization=webfiori - -sonar.projectName=database -sonar.projectVersion=1.0 - -sonar.exclusions=tests/** -sonar.coverage.exclusions=tests/** - -sonar.php.coverage.reportPaths=clover.xml -# Encoding of the source code. Default is default system encoding -sonar.sourceEncoding=UTF-8 +sonar.projectKey=WebFiori_database +sonar.organization=webfiori + +sonar.projectName=database +sonar.projectVersion=1.0 + +sonar.exclusions=tests/**,examples/** +sonar.coverage.exclusions=tests/**,examples/** + +sonar.php.coverage.reportPaths=clover.xml +# Encoding of the source code. Default is default system encoding +sonar.sourceEncoding=UTF-8 From a417c6bd977d4185e5e1e336879b8915cd5c6545 Mon Sep 17 00:00:00 2001 From: Ibrahim BinAlshikh Date: Tue, 6 Jan 2026 11:12:55 +0300 Subject: [PATCH 43/44] refactor: Fix Quality Issues --- .../Attributes/AttributeTableBuilder.php | 4 +- WebFiori/Database/Attributes/ForeignKey.php | 3 +- .../Attributes/InvalidAttributeException.php | 8 +++ WebFiori/Database/Entity/EntityGenerator.php | 54 ++++++------------- WebFiori/Database/Entity/RecordMapper.php | 7 +-- WebFiori/Database/Query/InsertBuilder.php | 15 +++--- .../Repository/AbstractRepository.php | 6 +-- .../Repository/RepositoryException.php | 8 +++ .../Schema/DatabaseChangeGenerator.php | 4 +- WebFiori/Database/Schema/SchemaException.php | 8 +++ WebFiori/Database/Schema/SchemaRunner.php | 4 +- .../Attributes/ForeignKeyAttributeTest.php | 4 +- .../Repository/AbstractRepositoryTest.php | 9 ++-- .../Schema/DatabaseChangeGeneratorTest.php | 2 +- 14 files changed, 68 insertions(+), 68 deletions(-) create mode 100644 WebFiori/Database/Attributes/InvalidAttributeException.php create mode 100644 WebFiori/Database/Repository/RepositoryException.php create mode 100644 WebFiori/Database/Schema/SchemaException.php diff --git a/WebFiori/Database/Attributes/AttributeTableBuilder.php b/WebFiori/Database/Attributes/AttributeTableBuilder.php index b80b5b13..fc8920b4 100644 --- a/WebFiori/Database/Attributes/AttributeTableBuilder.php +++ b/WebFiori/Database/Attributes/AttributeTableBuilder.php @@ -13,7 +13,7 @@ public static function build(string $entityClass, string $dbType = 'mysql'): Tab $tableAttr = $reflection->getAttributes(Table::class)[0] ?? null; if (!$tableAttr) { - throw new \RuntimeException("Class $entityClass must have #[Table] attribute"); + throw new InvalidAttributeException("Class $entityClass must have #[Table] attribute"); } $tableConfig = $tableAttr->newInstance(); @@ -25,7 +25,7 @@ public static function build(string $entityClass, string $dbType = 'mysql'): Tab if (!empty($classColumnAttrs)) { foreach ($classColumnAttrs as $columnAttr) { $columnConfig = $columnAttr->newInstance(); - $columnKey = $columnConfig->name ?? throw new \RuntimeException("Column name is required for class-level attributes"); + $columnKey = $columnConfig->name ?? throw new InvalidAttributeException("Column name is required for class-level attributes"); $columns[$columnKey] = self::columnConfigToArray($columnConfig); } diff --git a/WebFiori/Database/Attributes/ForeignKey.php b/WebFiori/Database/Attributes/ForeignKey.php index aaafa406..4a83fbfa 100644 --- a/WebFiori/Database/Attributes/ForeignKey.php +++ b/WebFiori/Database/Attributes/ForeignKey.php @@ -2,7 +2,6 @@ namespace WebFiori\Database\Attributes; use Attribute; -use InvalidArgumentException; #[Attribute(Attribute::TARGET_PROPERTY | Attribute::TARGET_CLASS | Attribute::IS_REPEATABLE)] class ForeignKey { @@ -15,7 +14,7 @@ public function __construct( public string $onDelete = 'set null' ) { if ($column !== null && !empty($columns)) { - throw new InvalidArgumentException( + throw new InvalidAttributeException( "ForeignKey: Use either 'column' or 'columns', not both" ); } diff --git a/WebFiori/Database/Attributes/InvalidAttributeException.php b/WebFiori/Database/Attributes/InvalidAttributeException.php new file mode 100644 index 00000000..f93052b6 --- /dev/null +++ b/WebFiori/Database/Attributes/InvalidAttributeException.php @@ -0,0 +1,8 @@ +isAutoInc()) { + if ($col->isAutoInc() || $col->isNull()) { return ' = null'; } - if ($col->isNull()) { - return ' = null'; - } - - $default = $col->getDefault(); - - if ($default !== null) { - $phpType = $col->getPHPType(); - - if ($phpType === 'string') { - return " = '".addslashes($default)."'"; - } - - if ($phpType === 'int' || $phpType === 'float') { - return " = {$default}"; - } - - if ($phpType === 'bool') { - return $default ? ' = true' : ' = false'; - } - } - - // Required field with no default $phpType = $col->getPHPType(); + $default = $col->getDefault(); - if ($phpType === 'string') { - return " = ''"; - } - - if ($phpType === 'int') { - return ' = 0'; - } - - if ($phpType === 'float') { - return ' = 0.0'; - } + $typeDefaults = [ + 'string' => " = ''", + 'int' => ' = 0', + 'float' => ' = 0.0', + 'bool' => ' = false' + ]; - if ($phpType === 'bool') { - return ' = false'; + if ($default !== null) { + return match ($phpType) { + 'string' => " = '" . addslashes($default) . "'", + 'int', 'float' => " = {$default}", + 'bool' => $default ? ' = true' : ' = false', + default => '' + }; } - return ''; + return $typeDefaults[$phpType] ?? ''; } /** diff --git a/WebFiori/Database/Entity/RecordMapper.php b/WebFiori/Database/Entity/RecordMapper.php index ad61e6a7..c435455a 100644 --- a/WebFiori/Database/Entity/RecordMapper.php +++ b/WebFiori/Database/Entity/RecordMapper.php @@ -128,11 +128,8 @@ public function map(array $record) { foreach ($this->getSettersMap() as $method => $colsNames) { if (is_callable([$instance, $method])) { foreach ($colsNames as $colName) { - try { - if (isset($record[$colName])) { - $instance->$method($record[$colName]); - } - } catch (\Throwable $ex) { + if (isset($record[$colName])) { + $instance->$method($record[$colName]); } } } diff --git a/WebFiori/Database/Query/InsertBuilder.php b/WebFiori/Database/Query/InsertBuilder.php index 6fb4db66..c18f6016 100644 --- a/WebFiori/Database/Query/InsertBuilder.php +++ b/WebFiori/Database/Query/InsertBuilder.php @@ -206,17 +206,16 @@ private function build() { } private function buildColsArr() { $colsArr = []; - $colsStr = ''; - $table = $this->getTable(); + $tableObj = $this->getTable(); foreach ($this->cols as $colKey) { - $colObj = $table->getColByKey($colKey); + $colObj = $tableObj->getColByKey($colKey); if ($colObj === null) { - $table->addColumns([ + $tableObj->addColumns([ $colKey => [] ]); - $colObj = $table->getColByKey($colKey); + $colObj = $tableObj->getColByKey($colKey); } $colObj->setWithTablePrefix(false); $colsArr[] = $colObj->getName(); @@ -266,7 +265,7 @@ private function initValsArr() { $colsAndVals = $this->data; if (isset($colsAndVals['cols']) && isset($colsAndVals['values'])) { - $cols = $colsAndVals['cols']; + $colsArr = $colsAndVals['cols']; $tempVals = $colsAndVals['values']; $temp = []; $topIndex = 0; @@ -275,14 +274,14 @@ private function initValsArr() { $index = 0; $temp[] = []; - foreach ($cols as $colKey) { + foreach ($colsArr as $colKey) { $temp[$topIndex][$colKey] = $valsArr[$index]; $index++; } $topIndex++; } $this->vals = $temp; - $this->cols = $cols; + $this->cols = $colsArr; } else { $this->cols = array_keys($colsAndVals); $this->vals = [$colsAndVals]; diff --git a/WebFiori/Database/Repository/AbstractRepository.php b/WebFiori/Database/Repository/AbstractRepository.php index eb9cbfc1..c49cd248 100644 --- a/WebFiori/Database/Repository/AbstractRepository.php +++ b/WebFiori/Database/Repository/AbstractRepository.php @@ -61,7 +61,7 @@ public function deleteAll(): void { public function deleteById(mixed $id = null): void { $id = $id ?? $this->getEntityId(); if ($id === null) { - throw new \InvalidArgumentException('Cannot delete: no ID provided'); + throw new RepositoryException('Cannot delete: no ID provided'); } $this->db->table($this->getTableName()) ->delete() @@ -94,7 +94,7 @@ public function findAll(): array { public function findById(mixed $id = null): ?object { $id = $id ?? $this->getEntityId(); if ($id === null) { - throw new \InvalidArgumentException('Cannot find: no ID provided'); + throw new RepositoryException('Cannot find: no ID provided'); } $result = $this->db->table($this->getTableName()) ->select() @@ -225,7 +225,7 @@ public function paginateByCursor( */ public function save(?object $entity = null): void { if ($entity === null && !property_exists($this, $this->getIdField())) { - throw new \InvalidArgumentException('Cannot save: no entity provided'); + throw new RepositoryException('Cannot save: no entity provided'); } $entity = $entity ?? $this; $data = $this->toArray($entity); diff --git a/WebFiori/Database/Repository/RepositoryException.php b/WebFiori/Database/Repository/RepositoryException.php new file mode 100644 index 00000000..8a5d1d5b --- /dev/null +++ b/WebFiori/Database/Repository/RepositoryException.php @@ -0,0 +1,8 @@ +path)) { - throw new \RuntimeException('Path not set. Call setPath() first.'); + throw new DatabaseException('Path not set. Call setPath() first.'); } if (!is_dir($this->path)) { diff --git a/WebFiori/Database/Schema/SchemaException.php b/WebFiori/Database/Schema/SchemaException.php new file mode 100644 index 00000000..c8740d28 --- /dev/null +++ b/WebFiori/Database/Schema/SchemaException.php @@ -0,0 +1,8 @@ +expectException(InvalidArgumentException::class); + $this->expectException(InvalidAttributeException::class); $this->expectExceptionMessage("ForeignKey: Use either 'column' or 'columns', not both"); new ForeignKey(table: 'users', column: 'id', columns: ['local_id' => 'id']); diff --git a/tests/WebFiori/Tests/Database/Repository/AbstractRepositoryTest.php b/tests/WebFiori/Tests/Database/Repository/AbstractRepositoryTest.php index 91dd92bf..e9eeabea 100644 --- a/tests/WebFiori/Tests/Database/Repository/AbstractRepositoryTest.php +++ b/tests/WebFiori/Tests/Database/Repository/AbstractRepositoryTest.php @@ -8,6 +8,7 @@ use WebFiori\Database\ColOption; use WebFiori\Database\DataType; use WebFiori\Database\Repository\AbstractRepository; +use WebFiori\Database\Repository\RepositoryException; class TestEntity { public ?int $id = null; @@ -176,14 +177,14 @@ public function testSaveAllMixed() { } public function testFindByIdWithNullThrowsException() { - $this->expectException(\InvalidArgumentException::class); + $this->expectException(RepositoryException::class); $this->expectExceptionMessage('Cannot find: no ID provided'); self::$repo->findById(null); } public function testDeleteByIdWithNullThrowsException() { - $this->expectException(\InvalidArgumentException::class); + $this->expectException(RepositoryException::class); $this->expectExceptionMessage('Cannot delete: no ID provided'); self::$repo->deleteById(null); @@ -211,7 +212,7 @@ public function testDeleteByIdWithValidId() { } public function testSaveWithNullOnPureRepoThrowsException() { - $this->expectException(\InvalidArgumentException::class); + $this->expectException(RepositoryException::class); $this->expectExceptionMessage('Cannot save: no entity provided'); self::$repo->save(); @@ -234,7 +235,7 @@ public function testReloadWithEntity() { } public function testReloadWithNullOnPureRepoThrowsException() { - $this->expectException(\InvalidArgumentException::class); + $this->expectException(RepositoryException::class); $this->expectExceptionMessage('Cannot find: no ID provided'); self::$repo->reload(); diff --git a/tests/WebFiori/Tests/Database/Schema/DatabaseChangeGeneratorTest.php b/tests/WebFiori/Tests/Database/Schema/DatabaseChangeGeneratorTest.php index 38d25762..a50d9fa4 100644 --- a/tests/WebFiori/Tests/Database/Schema/DatabaseChangeGeneratorTest.php +++ b/tests/WebFiori/Tests/Database/Schema/DatabaseChangeGeneratorTest.php @@ -174,7 +174,7 @@ public function testCreateWithoutNamespace() { public function testCreateWithoutPathThrows() { $generator = new DatabaseChangeGenerator(); - $this->expectException(\RuntimeException::class); + $this->expectException(\WebFiori\Database\DatabaseException::class); $this->expectExceptionMessage('Path not set'); $generator->createMigration('CreateUsersTable'); From ed99655cf0542d7be8d2bcfb92147e708cf7015e Mon Sep 17 00:00:00 2001 From: Ibrahim BinAlshikh Date: Tue, 6 Jan 2026 11:14:41 +0300 Subject: [PATCH 44/44] chore: Updated License --- LICENSE | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/LICENSE b/LICENSE index f18f60fb..a86e8ad0 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ MIT License -Copyright (c) 2020 WebFiori Framework +Copyright (c) 2020-present WebFiori Framework Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal