From 8f5b4d599cff37a33fef7d479f1c3da86e31b364 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sama=C3=ABl=20Villette?= Date: Thu, 21 Apr 2022 09:50:02 +0200 Subject: [PATCH] feat(backup): added backup strategy (#3) * refactor(connection): refactored connection model namespace * feat(backup): updated connection configuration to backup * tests: added unit tests for generic factory * tests(backup): added unit tests backup registry * feat(backup): added max backup files handle in backup command * tests(backup): added keep other backup files tests on backup command --- README.md | 33 +- composer.json | 4 +- composer.lock | 815 ++++++++++++------ phpstan.neon | 8 +- src/Command/BackupDatabasesCommand.php | 163 +++- .../Compiler/RegisterConnectionsPass.php | 13 +- src/DependencyInjection/Configuration.php | 41 +- .../SymandyDatabaseBackupExtension.php | 2 +- src/Factory/Backup/BackupFactory.php | 38 + src/Factory/Connection/ConnectionFactory.php | 36 + src/Factory/ConnectionFactory.php | 31 - src/Factory/Factory.php | 39 + src/Factory/FactoryInterface.php | 18 + src/Factory/NamedFactoryInterface.php | 18 + src/Model/Backup/Backup.php | 34 + src/Model/Backup/Strategy.php | 26 + src/Model/Connection.php | 17 - src/Model/Connection/Connection.php | 15 + .../{ => Connection}/ConnectionDriver.php | 2 +- .../{ => Connection}/MySQLConnection.php | 8 +- src/Registry/Backup/BackupRegistry.php | 30 + .../Backup/BackupRegistryInterface.php | 18 + src/Registry/ConnectionRegistry.php | 44 - src/Registry/ConnectionRegistryInterface.php | 28 - src/Registry/NamedRegistry.php | 30 + src/Registry/NamedRegistryTrait.php | 51 ++ src/Resources/config/services.xml | 21 +- .../Command/BackupDatabasesCommandTest.php | 103 ++- tests/Functional/ContainerTest.php | 6 +- tests/Unit/BackupRegistryTest.php | 39 + .../Compiler/RegisterConnectionsPassTest.php | 39 +- .../DependencyInjection/ConfigurationTest.php | 88 +- tests/Unit/Factory/FactoryTest.php | 39 + tests/app/config/packages/config.yaml | 20 +- tests/app/src/Model/Foo.php | 16 + 35 files changed, 1399 insertions(+), 534 deletions(-) create mode 100644 src/Factory/Backup/BackupFactory.php create mode 100644 src/Factory/Connection/ConnectionFactory.php delete mode 100644 src/Factory/ConnectionFactory.php create mode 100644 src/Factory/Factory.php create mode 100644 src/Factory/FactoryInterface.php create mode 100644 src/Factory/NamedFactoryInterface.php create mode 100644 src/Model/Backup/Backup.php create mode 100644 src/Model/Backup/Strategy.php delete mode 100644 src/Model/Connection.php create mode 100644 src/Model/Connection/Connection.php rename src/Model/{ => Connection}/ConnectionDriver.php (83%) rename src/Model/{ => Connection}/MySQLConnection.php (89%) create mode 100644 src/Registry/Backup/BackupRegistry.php create mode 100644 src/Registry/Backup/BackupRegistryInterface.php delete mode 100644 src/Registry/ConnectionRegistry.php delete mode 100644 src/Registry/ConnectionRegistryInterface.php create mode 100644 src/Registry/NamedRegistry.php create mode 100644 src/Registry/NamedRegistryTrait.php create mode 100644 tests/Unit/BackupRegistryTest.php create mode 100644 tests/Unit/Factory/FactoryTest.php create mode 100644 tests/app/src/Model/Foo.php diff --git a/README.md b/README.md index a75f1d4..6d31fb3 100644 --- a/README.md +++ b/README.md @@ -32,19 +32,24 @@ to `config/packages` directory. ```yaml symandy_database_backup: - connections: - # Multiple connections can be added + backups: foo: - # driver: !php/const \Symandy\DatabaseBackupBundle\Model\ConnectionDriver::MySQL - driver: mysql - - # Usage of environment variables as parameters is recommended for connections configuration - configuration: - user: "%app.foo_db_user%" - password: "%app.foo_db_password%" - host: 127.0.0.1 # Already the default value, don't need to be added - port: 3306 # Already the default value, don't need to be added - databases: [foo, bar, baz] # Will only back up these databases + connection: + # driver: !php/const \Symandy\DatabaseBackupBundle\Model\ConnectionDriver::MySQL + driver: mysql + + # Usage of environment variables as parameters is recommended for connections configuration + configuration: + user: "%app.foo_db_user%" + password: "%app.foo_db_password%" + host: 127.0.0.1 # Already the default value, don't need to be added + port: 3306 # Already the default value, don't need to be added + databases: [foo, bar, baz] # Will only back up these databases + strategy: + max_files: 5 # Number of files kept after a backup (per database) + # backup_directory: "/var/www/backups" # The directory must be created and must have the right permissions + backup_directory: "%kernel.project_dir%/backups" + # backup_directory: ~ # The current directory will be used if no value is passed ``` ### Drivers @@ -52,10 +57,10 @@ symandy_database_backup: Only the `mysql` driver is currently available. ## Usage -Once the connections are configured, you only have to run the following command to generate the dumped databases files: +Once the backups are configured, you only have to run the following command to generate the dumped databases backup files: ```shell php bin/console symandy:databases:backup ``` -It will generate one file by connection in the format `---.sql`. +It will generate one file by database in the format `----.sql`. diff --git a/composer.json b/composer.json index f766b54..c6e23c3 100644 --- a/composer.json +++ b/composer.json @@ -24,7 +24,9 @@ "symfony/http-kernel": "^6.0", "symfony/dependency-injection": "^6.0", "symfony/config": "^6.0", - "symfony/process": "^6.0" + "symfony/process": "^6.0", + "symfony/serializer": "^6.0", + "symfony/property-access": "^6.0" }, "require-dev": { "phpstan/phpstan": "^1.5", diff --git a/composer.lock b/composer.lock index 771bc86..bbd2f12 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "460a84d7864ecac73aa036caf6761fd8", + "content-hash": "23be3f54d2de6a44b10db4cc80106b5d", "packages": [ { "name": "psr/container", @@ -951,6 +951,171 @@ ], "time": "2021-10-20T20:35:02+00:00" }, + { + "name": "symfony/polyfill-intl-grapheme", + "version": "v1.25.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-intl-grapheme.git", + "reference": "81b86b50cf841a64252b439e738e97f4a34e2783" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-intl-grapheme/zipball/81b86b50cf841a64252b439e738e97f4a34e2783", + "reference": "81b86b50cf841a64252b439e738e97f4a34e2783", + "shasum": "" + }, + "require": { + "php": ">=7.1" + }, + "suggest": { + "ext-intl": "For best performance" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "1.23-dev" + }, + "thanks": { + "name": "symfony/polyfill", + "url": "https://github.com/symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Intl\\Grapheme\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill for intl's grapheme_* functions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "grapheme", + "intl", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-intl-grapheme/tree/v1.25.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2021-11-23T21:10:46+00:00" + }, + { + "name": "symfony/polyfill-intl-normalizer", + "version": "v1.25.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-intl-normalizer.git", + "reference": "8590a5f561694770bdcd3f9b5c69dde6945028e8" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-intl-normalizer/zipball/8590a5f561694770bdcd3f9b5c69dde6945028e8", + "reference": "8590a5f561694770bdcd3f9b5c69dde6945028e8", + "shasum": "" + }, + "require": { + "php": ">=7.1" + }, + "suggest": { + "ext-intl": "For best performance" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "1.23-dev" + }, + "thanks": { + "name": "symfony/polyfill", + "url": "https://github.com/symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Intl\\Normalizer\\": "" + }, + "classmap": [ + "Resources/stubs" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill for intl's Normalizer class and related functions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "intl", + "normalizer", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-intl-normalizer/tree/v1.25.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2021-02-19T12:13:01+00:00" + }, { "name": "symfony/polyfill-mbstring", "version": "v1.25.0", @@ -1174,6 +1339,275 @@ ], "time": "2022-03-18T16:21:55+00:00" }, + { + "name": "symfony/property-access", + "version": "v6.0.7", + "source": { + "type": "git", + "url": "https://github.com/symfony/property-access.git", + "reference": "384dbce5632f5a4f1117bbc59b050f8ff5f89cc4" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/property-access/zipball/384dbce5632f5a4f1117bbc59b050f8ff5f89cc4", + "reference": "384dbce5632f5a4f1117bbc59b050f8ff5f89cc4", + "shasum": "" + }, + "require": { + "php": ">=8.0.2", + "symfony/property-info": "^5.4|^6.0" + }, + "require-dev": { + "symfony/cache": "^5.4|^6.0" + }, + "suggest": { + "psr/cache-implementation": "To cache access methods." + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\PropertyAccess\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides functions to read and write from/to an object or array using a simple string notation", + "homepage": "https://symfony.com", + "keywords": [ + "access", + "array", + "extraction", + "index", + "injection", + "object", + "property", + "property path", + "reflection" + ], + "support": { + "source": "https://github.com/symfony/property-access/tree/v6.0.7" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2022-03-31T17:18:25+00:00" + }, + { + "name": "symfony/property-info", + "version": "v6.0.7", + "source": { + "type": "git", + "url": "https://github.com/symfony/property-info.git", + "reference": "0f26f0870f05d65d5c06681ecbf36e546204f4b5" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/property-info/zipball/0f26f0870f05d65d5c06681ecbf36e546204f4b5", + "reference": "0f26f0870f05d65d5c06681ecbf36e546204f4b5", + "shasum": "" + }, + "require": { + "php": ">=8.0.2", + "symfony/string": "^5.4|^6.0" + }, + "conflict": { + "phpdocumentor/reflection-docblock": "<5.2", + "phpdocumentor/type-resolver": "<1.4.0", + "symfony/dependency-injection": "<5.4" + }, + "require-dev": { + "doctrine/annotations": "^1.10.4", + "phpdocumentor/reflection-docblock": "^5.2", + "phpstan/phpdoc-parser": "^1.0", + "symfony/cache": "^5.4|^6.0", + "symfony/dependency-injection": "^5.4|^6.0", + "symfony/serializer": "^5.4|^6.0" + }, + "suggest": { + "phpdocumentor/reflection-docblock": "To use the PHPDoc", + "psr/cache-implementation": "To cache results", + "symfony/doctrine-bridge": "To use Doctrine metadata", + "symfony/serializer": "To use Serializer metadata" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\PropertyInfo\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Kévin Dunglas", + "email": "dunglas@gmail.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Extracts information about PHP class' properties using metadata of popular sources", + "homepage": "https://symfony.com", + "keywords": [ + "doctrine", + "phpdoc", + "property", + "symfony", + "type", + "validator" + ], + "support": { + "source": "https://github.com/symfony/property-info/tree/v6.0.7" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2022-03-31T17:18:25+00:00" + }, + { + "name": "symfony/serializer", + "version": "v6.0.7", + "source": { + "type": "git", + "url": "https://github.com/symfony/serializer.git", + "reference": "384208d97ba09fdeeb49096bf3b3db4c2c48dcef" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/serializer/zipball/384208d97ba09fdeeb49096bf3b3db4c2c48dcef", + "reference": "384208d97ba09fdeeb49096bf3b3db4c2c48dcef", + "shasum": "" + }, + "require": { + "php": ">=8.0.2", + "symfony/polyfill-ctype": "~1.8" + }, + "conflict": { + "doctrine/annotations": "<1.12", + "phpdocumentor/reflection-docblock": "<3.2.2", + "phpdocumentor/type-resolver": "<1.4.0", + "symfony/dependency-injection": "<5.4", + "symfony/property-access": "<5.4", + "symfony/property-info": "<5.4", + "symfony/uid": "<5.4", + "symfony/yaml": "<5.4" + }, + "require-dev": { + "doctrine/annotations": "^1.12", + "phpdocumentor/reflection-docblock": "^3.2|^4.0|^5.0", + "symfony/cache": "^5.4|^6.0", + "symfony/config": "^5.4|^6.0", + "symfony/dependency-injection": "^5.4|^6.0", + "symfony/error-handler": "^5.4|^6.0", + "symfony/filesystem": "^5.4|^6.0", + "symfony/form": "^5.4|^6.0", + "symfony/http-foundation": "^5.4|^6.0", + "symfony/http-kernel": "^5.4|^6.0", + "symfony/mime": "^5.4|^6.0", + "symfony/property-access": "^5.4|^6.0", + "symfony/property-info": "^5.4|^6.0", + "symfony/uid": "^5.4|^6.0", + "symfony/validator": "^5.4|^6.0", + "symfony/var-dumper": "^5.4|^6.0", + "symfony/var-exporter": "^5.4|^6.0", + "symfony/yaml": "^5.4|^6.0" + }, + "suggest": { + "psr/cache-implementation": "For using the metadata cache.", + "symfony/config": "For using the XML mapping loader.", + "symfony/mime": "For using a MIME type guesser within the DataUriNormalizer.", + "symfony/property-access": "For using the ObjectNormalizer.", + "symfony/property-info": "To deserialize relations.", + "symfony/var-exporter": "For using the metadata compiler.", + "symfony/yaml": "For using the default YAML mapping loader." + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Serializer\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Handles serializing and deserializing data structures, including object graphs, into array structures or other formats like XML and JSON.", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/serializer/tree/v6.0.7" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2022-03-24T18:04:39+00:00" + }, { "name": "symfony/service-contracts", "version": "v3.0.1", @@ -1238,7 +1672,92 @@ "standards" ], "support": { - "source": "https://github.com/symfony/service-contracts/tree/v3.0.1" + "source": "https://github.com/symfony/service-contracts/tree/v3.0.1" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2022-03-13T20:10:05+00:00" + }, + { + "name": "symfony/string", + "version": "v6.0.3", + "source": { + "type": "git", + "url": "https://github.com/symfony/string.git", + "reference": "522144f0c4c004c80d56fa47e40e17028e2eefc2" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/string/zipball/522144f0c4c004c80d56fa47e40e17028e2eefc2", + "reference": "522144f0c4c004c80d56fa47e40e17028e2eefc2", + "shasum": "" + }, + "require": { + "php": ">=8.0.2", + "symfony/polyfill-ctype": "~1.8", + "symfony/polyfill-intl-grapheme": "~1.0", + "symfony/polyfill-intl-normalizer": "~1.0", + "symfony/polyfill-mbstring": "~1.0" + }, + "conflict": { + "symfony/translation-contracts": "<2.0" + }, + "require-dev": { + "symfony/error-handler": "^5.4|^6.0", + "symfony/http-client": "^5.4|^6.0", + "symfony/translation-contracts": "^2.0|^3.0", + "symfony/var-exporter": "^5.4|^6.0" + }, + "type": "library", + "autoload": { + "files": [ + "Resources/functions.php" + ], + "psr-4": { + "Symfony\\Component\\String\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides an object-oriented API to strings and deals with bytes, UTF-8 code points and grapheme clusters in a unified way", + "homepage": "https://symfony.com", + "keywords": [ + "grapheme", + "i18n", + "string", + "unicode", + "utf-8", + "utf8" + ], + "support": { + "source": "https://github.com/symfony/string/tree/v6.0.3" }, "funding": [ { @@ -1254,7 +1773,7 @@ "type": "tidelift" } ], - "time": "2022-03-13T20:10:05+00:00" + "time": "2022-01-02T09:55:41+00:00" }, { "name": "symfony/var-dumper", @@ -1516,20 +2035,20 @@ }, { "name": "doctrine/common", - "version": "3.2.2", + "version": "3.3.0", "source": { "type": "git", "url": "https://github.com/doctrine/common.git", - "reference": "295082d3750987065912816a9d536c2df735f637" + "reference": "c824e95d4c83b7102d8bc60595445a6f7d540f96" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/doctrine/common/zipball/295082d3750987065912816a9d536c2df735f637", - "reference": "295082d3750987065912816a9d536c2df735f637", + "url": "https://api.github.com/repos/doctrine/common/zipball/c824e95d4c83b7102d8bc60595445a6f7d540f96", + "reference": "c824e95d4c83b7102d8bc60595445a6f7d540f96", "shasum": "" }, "require": { - "doctrine/persistence": "^2.0", + "doctrine/persistence": "^2.0 || ^3.0", "php": "^7.1 || ^8.0" }, "require-dev": { @@ -1586,7 +2105,7 @@ ], "support": { "issues": "https://github.com/doctrine/common/issues", - "source": "https://github.com/doctrine/common/tree/3.2.2" + "source": "https://github.com/doctrine/common/tree/3.3.0" }, "funding": [ { @@ -1602,7 +2121,7 @@ "type": "tidelift" } ], - "time": "2022-02-02T09:15:57+00:00" + "time": "2022-02-05T18:28:51+00:00" }, { "name": "doctrine/dbal", @@ -2189,16 +2708,16 @@ }, { "name": "doctrine/persistence", - "version": "2.5.0", + "version": "2.5.1", "source": { "type": "git", "url": "https://github.com/doctrine/persistence.git", - "reference": "f8776dd9a0bdcd838812951a75f4ada72065a82a" + "reference": "4473480044c88f30e0e8288e7123b60c7eb9efa3" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/doctrine/persistence/zipball/f8776dd9a0bdcd838812951a75f4ada72065a82a", - "reference": "f8776dd9a0bdcd838812951a75f4ada72065a82a", + "url": "https://api.github.com/repos/doctrine/persistence/zipball/4473480044c88f30e0e8288e7123b60c7eb9efa3", + "reference": "4473480044c88f30e0e8288e7123b60c7eb9efa3", "shasum": "" }, "require": { @@ -2271,9 +2790,9 @@ ], "support": { "issues": "https://github.com/doctrine/persistence/issues", - "source": "https://github.com/doctrine/persistence/tree/2.5.0" + "source": "https://github.com/doctrine/persistence/tree/2.5.1" }, - "time": "2022-04-06T14:59:40+00:00" + "time": "2022-04-14T21:47:17+00:00" }, { "name": "myclabs/deep-copy", @@ -2730,16 +3249,16 @@ }, { "name": "phpstan/phpstan", - "version": "1.5.4", + "version": "1.5.6", "source": { "type": "git", "url": "https://github.com/phpstan/phpstan.git", - "reference": "bbf68cae24f6dc023c607ea0f87da55dd9d55c2b" + "reference": "799dd8c2d2c9c704bb55d2078078cb970cf0f6d1" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpstan/phpstan/zipball/bbf68cae24f6dc023c607ea0f87da55dd9d55c2b", - "reference": "bbf68cae24f6dc023c607ea0f87da55dd9d55c2b", + "url": "https://api.github.com/repos/phpstan/phpstan/zipball/799dd8c2d2c9c704bb55d2078078cb970cf0f6d1", + "reference": "799dd8c2d2c9c704bb55d2078078cb970cf0f6d1", "shasum": "" }, "require": { @@ -2765,7 +3284,7 @@ "description": "PHPStan - PHP Static Analysis Tool", "support": { "issues": "https://github.com/phpstan/phpstan/issues", - "source": "https://github.com/phpstan/phpstan/tree/1.5.4" + "source": "https://github.com/phpstan/phpstan/tree/1.5.6" }, "funding": [ { @@ -2785,7 +3304,7 @@ "type": "tidelift" } ], - "time": "2022-04-03T12:39:00+00:00" + "time": "2022-04-15T11:13:37+00:00" }, { "name": "phpunit/php-code-coverage", @@ -4850,171 +5369,6 @@ ], "time": "2022-03-06T11:27:28+00:00" }, - { - "name": "symfony/polyfill-intl-grapheme", - "version": "v1.25.0", - "source": { - "type": "git", - "url": "https://github.com/symfony/polyfill-intl-grapheme.git", - "reference": "81b86b50cf841a64252b439e738e97f4a34e2783" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-intl-grapheme/zipball/81b86b50cf841a64252b439e738e97f4a34e2783", - "reference": "81b86b50cf841a64252b439e738e97f4a34e2783", - "shasum": "" - }, - "require": { - "php": ">=7.1" - }, - "suggest": { - "ext-intl": "For best performance" - }, - "type": "library", - "extra": { - "branch-alias": { - "dev-main": "1.23-dev" - }, - "thanks": { - "name": "symfony/polyfill", - "url": "https://github.com/symfony/polyfill" - } - }, - "autoload": { - "files": [ - "bootstrap.php" - ], - "psr-4": { - "Symfony\\Polyfill\\Intl\\Grapheme\\": "" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Nicolas Grekas", - "email": "p@tchwork.com" - }, - { - "name": "Symfony Community", - "homepage": "https://symfony.com/contributors" - } - ], - "description": "Symfony polyfill for intl's grapheme_* functions", - "homepage": "https://symfony.com", - "keywords": [ - "compatibility", - "grapheme", - "intl", - "polyfill", - "portable", - "shim" - ], - "support": { - "source": "https://github.com/symfony/polyfill-intl-grapheme/tree/v1.25.0" - }, - "funding": [ - { - "url": "https://symfony.com/sponsor", - "type": "custom" - }, - { - "url": "https://github.com/fabpot", - "type": "github" - }, - { - "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", - "type": "tidelift" - } - ], - "time": "2021-11-23T21:10:46+00:00" - }, - { - "name": "symfony/polyfill-intl-normalizer", - "version": "v1.25.0", - "source": { - "type": "git", - "url": "https://github.com/symfony/polyfill-intl-normalizer.git", - "reference": "8590a5f561694770bdcd3f9b5c69dde6945028e8" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-intl-normalizer/zipball/8590a5f561694770bdcd3f9b5c69dde6945028e8", - "reference": "8590a5f561694770bdcd3f9b5c69dde6945028e8", - "shasum": "" - }, - "require": { - "php": ">=7.1" - }, - "suggest": { - "ext-intl": "For best performance" - }, - "type": "library", - "extra": { - "branch-alias": { - "dev-main": "1.23-dev" - }, - "thanks": { - "name": "symfony/polyfill", - "url": "https://github.com/symfony/polyfill" - } - }, - "autoload": { - "files": [ - "bootstrap.php" - ], - "psr-4": { - "Symfony\\Polyfill\\Intl\\Normalizer\\": "" - }, - "classmap": [ - "Resources/stubs" - ] - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Nicolas Grekas", - "email": "p@tchwork.com" - }, - { - "name": "Symfony Community", - "homepage": "https://symfony.com/contributors" - } - ], - "description": "Symfony polyfill for intl's Normalizer class and related functions", - "homepage": "https://symfony.com", - "keywords": [ - "compatibility", - "intl", - "normalizer", - "polyfill", - "portable", - "shim" - ], - "support": { - "source": "https://github.com/symfony/polyfill-intl-normalizer/tree/v1.25.0" - }, - "funding": [ - { - "url": "https://symfony.com/sponsor", - "type": "custom" - }, - { - "url": "https://github.com/fabpot", - "type": "github" - }, - { - "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", - "type": "tidelift" - } - ], - "time": "2021-02-19T12:13:01+00:00" - }, { "name": "symfony/polyfill-php72", "version": "v1.25.0", @@ -5262,91 +5616,6 @@ ], "time": "2022-01-31T19:46:53+00:00" }, - { - "name": "symfony/string", - "version": "v6.0.3", - "source": { - "type": "git", - "url": "https://github.com/symfony/string.git", - "reference": "522144f0c4c004c80d56fa47e40e17028e2eefc2" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/symfony/string/zipball/522144f0c4c004c80d56fa47e40e17028e2eefc2", - "reference": "522144f0c4c004c80d56fa47e40e17028e2eefc2", - "shasum": "" - }, - "require": { - "php": ">=8.0.2", - "symfony/polyfill-ctype": "~1.8", - "symfony/polyfill-intl-grapheme": "~1.0", - "symfony/polyfill-intl-normalizer": "~1.0", - "symfony/polyfill-mbstring": "~1.0" - }, - "conflict": { - "symfony/translation-contracts": "<2.0" - }, - "require-dev": { - "symfony/error-handler": "^5.4|^6.0", - "symfony/http-client": "^5.4|^6.0", - "symfony/translation-contracts": "^2.0|^3.0", - "symfony/var-exporter": "^5.4|^6.0" - }, - "type": "library", - "autoload": { - "files": [ - "Resources/functions.php" - ], - "psr-4": { - "Symfony\\Component\\String\\": "" - }, - "exclude-from-classmap": [ - "/Tests/" - ] - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Nicolas Grekas", - "email": "p@tchwork.com" - }, - { - "name": "Symfony Community", - "homepage": "https://symfony.com/contributors" - } - ], - "description": "Provides an object-oriented API to strings and deals with bytes, UTF-8 code points and grapheme clusters in a unified way", - "homepage": "https://symfony.com", - "keywords": [ - "grapheme", - "i18n", - "string", - "unicode", - "utf-8", - "utf8" - ], - "support": { - "source": "https://github.com/symfony/string/tree/v6.0.3" - }, - "funding": [ - { - "url": "https://symfony.com/sponsor", - "type": "custom" - }, - { - "url": "https://github.com/fabpot", - "type": "github" - }, - { - "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", - "type": "tidelift" - } - ], - "time": "2022-01-02T09:55:41+00:00" - }, { "name": "symfony/var-exporter", "version": "v6.0.7", @@ -5611,5 +5880,5 @@ "php": ">=8.1" }, "platform-dev": [], - "plugin-api-version": "2.2.0" + "plugin-api-version": "2.3.0" } diff --git a/phpstan.neon b/phpstan.neon index cc48943..adfcf81 100644 --- a/phpstan.neon +++ b/phpstan.neon @@ -6,9 +6,15 @@ parameters: - tests/Unit ignoreErrors: # src - - '#PHPDoc tag @var for variable \$connections has no value type specified in iterable type array.#' - '#Call to an undefined method Symfony\\Component\\Config\\Definition\\Builder\\NodeDefinition::fixXmlConfig\(\).#' - '#Symandy\\DatabaseBackupBundle\\DependencyInjection\\SymandyDatabaseBackupExtension::loadInternal\(\) has parameter \$mergedConfig with no value type specified in iterable type array.#' + - '#Method Symandy\\DatabaseBackupBundle\\Registry\\Backup\\BackupRegistry::registerFromNameAndOptions\(\) has parameter \$options with no value type specified in iterable type array.#' + - '#Method Symandy\\DatabaseBackupBundle\\Registry\\Backup\\BackupRegistryInterface::registerFromNameAndOptions\(\) has parameter \$options with no value type specified in iterable type array.#' + - '#Method Symandy\\DatabaseBackupBundle\\Factory\\FactoryInterface::create\(\) has parameter \$options with no value type specified in iterable type array.#' + - '#Method Symandy\\DatabaseBackupBundle\\Factory\\NamedFactoryInterface::createNamed\(\) has parameter \$options with no value type specified in iterable type array.#' + - '#Method Symandy\\DatabaseBackupBundle\\Factory\\Factory::create\(\) has parameter \$options with no value type specified in iterable type array.#' + - '#Method Symandy\\DatabaseBackupBundle\\Factory\\Backup\\BackupFactory::createNamed\(\) has parameter \$options with no value type specified in iterable type array.#' + - '#Method Symandy\\DatabaseBackupBundle\\Factory\\Connection\\ConnectionFactory::create\(\) has parameter \$options with no value type specified in iterable type array.#' # tests - '#Method Symandy\\Tests\\DatabaseBackupBundle\\Functional\\Command\\BackupDatabasesCommandTest::getConnectionOptions\(\) return type has no value type specified in iterable type array.#' diff --git a/src/Command/BackupDatabasesCommand.php b/src/Command/BackupDatabasesCommand.php index 083be81..80a83c3 100644 --- a/src/Command/BackupDatabasesCommand.php +++ b/src/Command/BackupDatabasesCommand.php @@ -5,16 +5,22 @@ namespace Symandy\DatabaseBackupBundle\Command; use DateTime; -use Symandy\DatabaseBackupBundle\Model\MySQLConnection; -use Symandy\DatabaseBackupBundle\Registry\ConnectionRegistryInterface; +use RuntimeException; +use Symandy\DatabaseBackupBundle\Model\Backup\Backup; +use Symandy\DatabaseBackupBundle\Model\Connection\MySQLConnection; +use Symandy\DatabaseBackupBundle\Registry\Backup\BackupRegistry; use Symfony\Component\Console\Attribute\AsCommand; use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Input\InputArgument; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Output\OutputInterface; use Symfony\Component\Console\Style\SymfonyStyle; +use Symfony\Component\Filesystem\Filesystem; +use Symfony\Component\Finder\Finder; +use Symfony\Component\Finder\SplFileInfo; use Symfony\Component\Process\ExecutableFinder; use Symfony\Component\Process\Process; +use function Symfony\Component\String\u; #[AsCommand( name: 'symandy:databases:backup', @@ -23,18 +29,24 @@ final class BackupDatabasesCommand extends Command { - public function __construct(private ConnectionRegistryInterface $connectionRegistry) + private Filesystem $filesystem; + + private Finder $finder; + + public function __construct(private readonly BackupRegistry $backupRegistry) { parent::__construct(); + $this->filesystem = new Filesystem(); + $this->finder = new Finder(); } protected function configure(): void { $this ->addArgument( - 'connections', + 'backups', InputArgument::OPTIONAL | InputArgument::IS_ARRAY, - 'The configured database connections to export' + 'The name of the backups to be performed' ) ; } @@ -43,53 +55,128 @@ protected function execute(InputInterface $input, OutputInterface $output): int { $io = new SymfonyStyle($input, $output); - /** @var array $connections */ - $connections = $input->getArgument('connections'); - $connectionsToExport = []; + /** @var array $backups */ + $backups = $input->getArgument('backups'); + $backupsToExecute = []; - foreach ($connections as $connection) { - if (!$this->connectionRegistry->has($connection)) { - $io->error("Connection $connection does not exist"); + foreach ($backups as $backup) { + if (!$this->backupRegistry->has($backup)) { + $io->error("Backup $backup does not exist in registry"); return Command::FAILURE; } - $connectionsToExport[] = $this->connectionRegistry->get($connection); + $backupsToExecute[] = $this->backupRegistry->get($backup); + } + + if ([] === $backups) { + $backupsToExecute = $this->backupRegistry->all(); } - if ([] === $connections) { - $connectionsToExport = $this->connectionRegistry->all(); + if ([] === $backupsToExecute) { + $io->warning('No backup to be done'); + + return Command::SUCCESS; } $mysqldump = (new ExecutableFinder())->find('mysqldump'); - /** @var MySQLConnection $connection */ - foreach ($connectionsToExport as $connection) { - $dumpSqlCommand = sprintf( - '%s -u "${:DB_USER}" -h "${:DB_HOST}" -P "${:DB_PORT}" --databases %s > "${:FILENAME}".sql', - $mysqldump, - implode(' ', $connection->getDatabases()), - ); - - $io->info(sprintf("Start exporting databases for %s connection", $connection->getName())); - - $process = Process::fromShellCommandline($dumpSqlCommand); - $process->setPty(Process::isPtySupported()); - $process->run(null, [ - 'DB_USER' => $connection->getUser(), - 'DB_HOST' => $connection->getHost(), - 'DB_PORT' => $connection->getPort(), - 'MYSQL_PWD' => $connection->getPassword(), - 'FILENAME' => sprintf('%s-%s', $connection->getName(), (new DateTime())->format('Y-m-d')) - ]); - - if (!$process->isSuccessful()) { - $io->error($process->getErrorOutput()); + /** @var Backup $backup */ + foreach ($backupsToExecute as $backup) { + /** @var MySQLConnection $connection */ + $connection = $backup->getConnection(); + $backupName = $backup->getName(); - return Command::FAILURE; + if ([] === $connection->getDatabases()) { + $io->warning(sprintf("No database to backup in %s configuration, Skipping", $backupName)); + + continue; + } + + if (null !== ($backupDirectory = $backup->getStrategy()->getBackupDirectory())) { + if (!$this->filesystem->exists($backupDirectory)) { + $io->error("Backup directory \"$backupDirectory\" does not exist"); + + return Command::INVALID; + } + } + + $backupDirectory ??= false !== getcwd() ? + getcwd() : + throw new RuntimeException('Unable to get the current directory, check the user permissions') + ; + + $io->info(sprintf("The backup %s is in progress", $backupName)); + + foreach ($connection->getDatabases() as $database) { + if ($output->isVerbose()) { + $io->comment("Backup for $database database has started"); + } + + $date = (new DateTime())->format('Y-m-d'); + $filePath = "$backupDirectory/$backupName-$database-$date.sql"; + + $process = Process::fromShellCommandline( + '"${:MYSQL_DUMP}" -u "${:DB_USER}" -h "${:DB_HOST}" -P "${:DB_PORT}" "${:DB_NAME}" > "${:FILEPATH}"' + ); + + $process->setPty(Process::isPtySupported()); + $process->run(null, [ + 'MYSQL_DUMP' => $mysqldump, + 'DB_USER' => $connection->getUser(), + 'DB_HOST' => $connection->getHost(), + 'DB_PORT' => $connection->getPort(), + 'DB_NAME' => $database, + 'MYSQL_PWD' => $connection->getPassword(), + 'FILEPATH' => $filePath + ]); + + if (!$process->isSuccessful()) { + $message = '' !== $process->getErrorOutput() ? $process->getErrorOutput() : $process->getOutput(); + + $io->error(u($message)->collapseWhitespace()->toString()); + + return Command::FAILURE; + } + + $finder = $this->finder + ->in($backupDirectory) + ->name(["$backupName-$database-*.sql"]) + ->sortByModifiedTime() + ->depth(['== 0']) + ->files() + ; + $filesCount = $finder->count(); + + /** @var array $ */ + $files = iterator_to_array($finder); + + $maxFiles = $backup->getStrategy()->getMaxFiles(); + + if (null !== $maxFiles && $filesCount > $maxFiles) { + $filesToDeleteCount = $filesCount - $maxFiles; + array_splice($files, $filesToDeleteCount); + + if (1 === $filesToDeleteCount) { + $io->warning('Reached the max backup files limit, removing the oldest one'); + } else { + $io->warning(sprintf( + 'Reached the max backup files limit, removing the %d oldest ones', + $filesToDeleteCount + )); + } + + foreach ($files as $file) { + if ($output->isVerbose()) { + $io->comment(sprintf('Deleting "%s"', $file->getRealPath())); + } + + $this->filesystem->remove($file->getRealPath()); + } + } } - $io->success(sprintf('Connection %s have been exported', $connection->getName())); + $io->success(sprintf('Backup %s has been successfully completed', $backupName)); } return Command::SUCCESS; diff --git a/src/DependencyInjection/Compiler/RegisterConnectionsPass.php b/src/DependencyInjection/Compiler/RegisterConnectionsPass.php index c7768e8..627820d 100644 --- a/src/DependencyInjection/Compiler/RegisterConnectionsPass.php +++ b/src/DependencyInjection/Compiler/RegisterConnectionsPass.php @@ -4,7 +4,6 @@ namespace Symandy\DatabaseBackupBundle\DependencyInjection\Compiler; -use Symandy\DatabaseBackupBundle\Model\ConnectionDriver; use Symfony\Component\DependencyInjection\Compiler\CompilerPassInterface; use Symfony\Component\DependencyInjection\ContainerBuilder; @@ -13,20 +12,20 @@ final class RegisterConnectionsPass implements CompilerPassInterface public function process(ContainerBuilder $container): void { - if (!$container->has('symandy_database_backup.registry.connection_registry')) { + if (!$container->has('symandy_database_backup.registry.backup_registry')) { return; } - $definition = $container->getDefinition('symandy_database_backup.registry.connection_registry'); + $definition = $container->getDefinition('symandy_database_backup.registry.backup_registry'); - /** @var array $connections */ - $connections = $container->getParameter('symandy.connections'); + $backups = $container->getParameter('symandy.backups'); - foreach ($connections as $name => $options) { + /** @var array> $backups */ + foreach ($backups as $name => $options) { $definition->addMethodCall('registerFromNameAndOptions', [$name, $options]); } - $container->getParameterBag()->remove('symandy.connections'); + $container->getParameterBag()->remove('symandy.backups'); } } diff --git a/src/DependencyInjection/Configuration.php b/src/DependencyInjection/Configuration.php index 1b42099..189b703 100644 --- a/src/DependencyInjection/Configuration.php +++ b/src/DependencyInjection/Configuration.php @@ -4,7 +4,7 @@ namespace Symandy\DatabaseBackupBundle\DependencyInjection; -use Symandy\DatabaseBackupBundle\Model\ConnectionDriver; +use Symandy\DatabaseBackupBundle\Model\Connection\ConnectionDriver; use Symfony\Component\Config\Definition\Builder\TreeBuilder; use Symfony\Component\Config\Definition\ConfigurationInterface; @@ -17,28 +17,39 @@ public function getConfigTreeBuilder(): TreeBuilder $treeBuilder ->getRootNode() - ->fixXmlConfig('connection') + ->fixXmlConfig('backup') ->children() - ->arrayNode('connections') + ->arrayNode('backups') ->useAttributeAsKey('name') ->arrayPrototype() ->children() - ->variableNode('driver') + ->arrayNode('connection') ->isRequired() - ->beforeNormalization() - ->ifString() - ->then(fn(string $v) => ConnectionDriver::from($v)) + ->children() + ->variableNode('driver') + ->isRequired() + ->beforeNormalization() + ->ifString() + ->then(fn(string $v) => ConnectionDriver::from($v)) + ->end() + ->end() + ->arrayNode('configuration') + ->children() + ->scalarNode('user')->end() + ->scalarNode('password')->end() + ->scalarNode('host')->end() + ->integerNode('port')->end() + ->arrayNode('databases') + ->scalarPrototype()->end() + ->end() + ->end() + ->end() ->end() ->end() - ->arrayNode('configuration') + ->arrayNode('strategy') ->children() - ->scalarNode('user')->end() - ->scalarNode('password')->end() - ->scalarNode('host')->end() - ->integerNode('port')->end() - ->arrayNode('databases') - ->scalarPrototype()->end() - ->end() + ->integerNode('max_files')->isRequired()->defaultNull()->end() + ->scalarNode('backup_directory')->isRequired()->defaultNull()->end() ->end() ->end() ->end() diff --git a/src/DependencyInjection/SymandyDatabaseBackupExtension.php b/src/DependencyInjection/SymandyDatabaseBackupExtension.php index 6a96733..a195c31 100644 --- a/src/DependencyInjection/SymandyDatabaseBackupExtension.php +++ b/src/DependencyInjection/SymandyDatabaseBackupExtension.php @@ -21,7 +21,7 @@ protected function loadInternal(array $mergedConfig, ContainerBuilder $container $loader = new XmlFileLoader($container, new FileLocator(__DIR__ . '/../Resources/config')); $loader->load('services.xml'); - $container->setParameter('symandy.connections', $mergedConfig['connections']); + $container->setParameter('symandy.backups', $mergedConfig['backups']); } } diff --git a/src/Factory/Backup/BackupFactory.php b/src/Factory/Backup/BackupFactory.php new file mode 100644 index 0000000..3c29ae5 --- /dev/null +++ b/src/Factory/Backup/BackupFactory.php @@ -0,0 +1,38 @@ + + */ +final class BackupFactory implements NamedFactoryInterface +{ + + /** + * @param ConnectionFactory $connectionFactory + * @param FactoryInterface $strategyFactory + */ + public function __construct( + private readonly ConnectionFactory $connectionFactory, + private readonly FactoryInterface $strategyFactory + ) { + } + + public function createNamed(string $name, array $options): Backup + { + return new Backup( + $name, + $this->connectionFactory->create($options['connection']), + $this->strategyFactory->create($options['strategy']) + ); + } + +} diff --git a/src/Factory/Connection/ConnectionFactory.php b/src/Factory/Connection/ConnectionFactory.php new file mode 100644 index 0000000..6d8f4f9 --- /dev/null +++ b/src/Factory/Connection/ConnectionFactory.php @@ -0,0 +1,36 @@ + + */ +final class ConnectionFactory implements FactoryInterface +{ + + public function create(array $options): Connection + { + /** @var ConnectionDriver $driver */ + $driver = + $options['driver'] ?? + throw new InvalidArgumentException('Connection driver must be configured') + ; + + /** @var class-string $classname */ + $classname = $driver->getConnectionClass(); + + /** @var FactoryInterface $factory */ + $factory = new Factory($classname); + + return $factory->create($options['configuration']); + } + +} diff --git a/src/Factory/ConnectionFactory.php b/src/Factory/ConnectionFactory.php deleted file mode 100644 index b0814f0..0000000 --- a/src/Factory/ConnectionFactory.php +++ /dev/null @@ -1,31 +0,0 @@ - $options - */ - public static function create(string $name, array $options): Connection - { - /** @var ConnectionDriver $driver */ - $driver = - $options['driver'] ?? - throw new InvalidArgumentException('Connection driver must be configured') - ; - - /** @var class-string $classname */ - $classname = $driver->getConnectionClass(); - - return new $classname(...['name' => $name], ...$options['configuration']); - } - -} diff --git a/src/Factory/Factory.php b/src/Factory/Factory.php new file mode 100644 index 0000000..6acb784 --- /dev/null +++ b/src/Factory/Factory.php @@ -0,0 +1,39 @@ + + */ +class Factory implements FactoryInterface +{ + + private readonly ObjectNormalizer $normalizer; + + /** + * @param class-string $className + */ + public function __construct(protected readonly string $className) + { + $this->normalizer = new ObjectNormalizer(nameConverter: new CamelCaseToSnakeCaseNameConverter()); + } + + /** + * @throws ExceptionInterface + */ + public function create(array $options): object + { + /** @var T $object */ + $object = $this->normalizer->denormalize($options, $this->className); + + return $object; + } + +} diff --git a/src/Factory/FactoryInterface.php b/src/Factory/FactoryInterface.php new file mode 100644 index 0000000..532d718 --- /dev/null +++ b/src/Factory/FactoryInterface.php @@ -0,0 +1,18 @@ +name; + } + + public function getConnection(): Connection + { + return $this->connection; + } + + public function getStrategy(): Strategy + { + return $this->strategy; + } + +} diff --git a/src/Model/Backup/Strategy.php b/src/Model/Backup/Strategy.php new file mode 100644 index 0000000..da94e61 --- /dev/null +++ b/src/Model/Backup/Strategy.php @@ -0,0 +1,26 @@ +maxFiles; + } + + public function getBackupDirectory(): ?string + { + return $this->backupDirectory; + } + +} diff --git a/src/Model/Connection.php b/src/Model/Connection.php deleted file mode 100644 index 292782c..0000000 --- a/src/Model/Connection.php +++ /dev/null @@ -1,17 +0,0 @@ - - */ - public function getOptions(): array; - -} diff --git a/src/Model/Connection/Connection.php b/src/Model/Connection/Connection.php new file mode 100644 index 0000000..e4c3672 --- /dev/null +++ b/src/Model/Connection/Connection.php @@ -0,0 +1,15 @@ + + */ + public function getOptions(): array; + +} diff --git a/src/Model/ConnectionDriver.php b/src/Model/Connection/ConnectionDriver.php similarity index 83% rename from src/Model/ConnectionDriver.php rename to src/Model/Connection/ConnectionDriver.php index 8d6eefc..01523a2 100644 --- a/src/Model/ConnectionDriver.php +++ b/src/Model/Connection/ConnectionDriver.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Symandy\DatabaseBackupBundle\Model; +namespace Symandy\DatabaseBackupBundle\Model\Connection; enum ConnectionDriver: string { diff --git a/src/Model/MySQLConnection.php b/src/Model/Connection/MySQLConnection.php similarity index 89% rename from src/Model/MySQLConnection.php rename to src/Model/Connection/MySQLConnection.php index 5ccb9c7..5171105 100644 --- a/src/Model/MySQLConnection.php +++ b/src/Model/Connection/MySQLConnection.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Symandy\DatabaseBackupBundle\Model; +namespace Symandy\DatabaseBackupBundle\Model\Connection; final class MySQLConnection implements Connection { @@ -11,7 +11,6 @@ final class MySQLConnection implements Connection * @param array $databases */ public function __construct( - private readonly string $name, private readonly ?string $user = null, private readonly ?string $password = null, private readonly ?string $host = '127.0.0.1', @@ -20,11 +19,6 @@ public function __construct( ) { } - public function getName(): ?string - { - return $this->name; - } - public function getUser(): ?string { return $this->user; diff --git a/src/Registry/Backup/BackupRegistry.php b/src/Registry/Backup/BackupRegistry.php new file mode 100644 index 0000000..e83fa62 --- /dev/null +++ b/src/Registry/Backup/BackupRegistry.php @@ -0,0 +1,30 @@ + + */ +final class BackupRegistry implements NamedRegistry +{ + + /** @use NamedRegistryTrait */ + use NamedRegistryTrait; + + public function __construct(private readonly BackupFactory $backupFactory) + { + } + + public function registerFromNameAndOptions(string $name, array $options): void + { + $this->register($name, $this->backupFactory->createNamed($name, $options)); + } + +} diff --git a/src/Registry/Backup/BackupRegistryInterface.php b/src/Registry/Backup/BackupRegistryInterface.php new file mode 100644 index 0000000..250e3c4 --- /dev/null +++ b/src/Registry/Backup/BackupRegistryInterface.php @@ -0,0 +1,18 @@ + + */ +interface BackupRegistryInterface extends NamedRegistry +{ + + public function registerFromNameAndOptions(string $name, array $options): void; + +} diff --git a/src/Registry/ConnectionRegistry.php b/src/Registry/ConnectionRegistry.php deleted file mode 100644 index 56c5661..0000000 --- a/src/Registry/ConnectionRegistry.php +++ /dev/null @@ -1,44 +0,0 @@ - */ - private array $registry = []; - - /** - * @return array - */ - public function all(): array - { - return $this->registry; - } - - public function has(string $name): bool - { - return array_key_exists($name, $this->registry); - } - - public function get(string $name): Connection - { - return $this->registry[$name] ?? throw new \InvalidArgumentException("Connection $name does not exists"); - } - - public function register(string $name, Connection $connection): void - { - $this->registry[$name] = $connection; - } - - public function registerFromNameAndOptions(string $name, array $options): void - { - $this->register($name, ConnectionFactory::create($name, $options)); - } - -} diff --git a/src/Registry/ConnectionRegistryInterface.php b/src/Registry/ConnectionRegistryInterface.php deleted file mode 100644 index 7e7dd55..0000000 --- a/src/Registry/ConnectionRegistryInterface.php +++ /dev/null @@ -1,28 +0,0 @@ - - */ - public function all(): array; - - public function has(string $name): bool; - - public function get(string $name): Connection; - - public function register(string $name, Connection $connection): void; - - /** - * @param array $options - */ - public function registerFromNameAndOptions(string $name, array $options): void; - -} diff --git a/src/Registry/NamedRegistry.php b/src/Registry/NamedRegistry.php new file mode 100644 index 0000000..2878f7a --- /dev/null +++ b/src/Registry/NamedRegistry.php @@ -0,0 +1,30 @@ + + */ + public function all(): array; + + public function has(string $name): bool; + + /** + * @return T + */ + public function get(string $name): object; + + /** + * @param T $item + */ + public function register(string $name, object $item): void; + +} diff --git a/src/Registry/NamedRegistryTrait.php b/src/Registry/NamedRegistryTrait.php new file mode 100644 index 0000000..04e76ed --- /dev/null +++ b/src/Registry/NamedRegistryTrait.php @@ -0,0 +1,51 @@ + */ + private array $registry = []; + + /** + * @return array + */ + public function all(): array + { + return $this->registry; + } + + public function has(string $name): bool + { + return array_key_exists($name, $this->registry); + } + + /** + * @return T + */ + public function get(string $name): object + { + return $this->registry[$name] ?? throw new InvalidArgumentException(sprintf( + 'class %s does not have any item named %s in its registry', + static::class, + $name + )); + } + + /** + * @param T $item + */ + public function register(string $name, object $item): void + { + $this->registry[$name] = $item; + } + +} diff --git a/src/Resources/config/services.xml b/src/Resources/config/services.xml index d42a765..5dd2766 100644 --- a/src/Resources/config/services.xml +++ b/src/Resources/config/services.xml @@ -9,11 +9,26 @@ - + console.command - - + + + + + Symandy\DatabaseBackupBundle\Model\Backup\Strategy + + + + + + + + + + + + diff --git a/tests/Functional/Command/BackupDatabasesCommandTest.php b/tests/Functional/Command/BackupDatabasesCommandTest.php index feeb2d6..3ce9926 100644 --- a/tests/Functional/Command/BackupDatabasesCommandTest.php +++ b/tests/Functional/Command/BackupDatabasesCommandTest.php @@ -4,6 +4,7 @@ namespace Symandy\Tests\DatabaseBackupBundle\Functional\Command; +use DateTime; use Doctrine\DBAL\Exception as DBALException; use Doctrine\ORM\EntityManager; use Doctrine\ORM\EntityManagerInterface; @@ -36,6 +37,11 @@ protected function setUp(): void } $schemaManager->createDatabase($options['dbname']); + + $filesystem = new Filesystem(); + if (!$filesystem->exists([self::$kernel->getProjectDir() . '/backups'])) { + $filesystem->mkdir([self::$kernel->getProjectDir() . '/backups']); + } } public function testBackupCommand(): void @@ -45,14 +51,94 @@ public function testBackupCommand(): void } $application = new Application(self::$kernel); - $command = $application->find('symandy:databases:backup'); $commandTester = new CommandTester($command); - $commandTester->execute([]); + $commandTester->execute([]); $commandTester->assertCommandIsSuccessful(); + $backupFiles = (new Finder()) + ->in([self::$kernel->getProjectDir() . '/backups']) + ->depth('== 0') + ->name(['main-db_test_1-*.sql']) + ->files() + ->name('*.sql') + ; + + self::assertEquals(1, $backupFiles->count()); + } + + public function testBackupCommandMaxFiles(): void + { + if (!self::$booted) { + self::bootKernel(); + } + + $filesystem = new Filesystem(); + $filesystem->touch( + [self::$kernel->getProjectDir() . '/backups/other-backup-db_test_1-2022-04-01.sql'], + (new DateTime('2022-03-01'))->getTimestamp() + ); + $filesystem->touch( + [self::$kernel->getProjectDir() . '/backups/other-backup-main-db_test_1-2022-04-01.sql'], + (new DateTime('2022-03-01'))->getTimestamp() + ); + + foreach (range(1, 10) as $day) { + $date = new DateTime("2022-04-$day"); + $formattedDay = str_pad((string) $day, 2, '0', STR_PAD_LEFT); + + $filesystem->touch( + [self::$kernel->getProjectDir() . "/backups/main-db_test_1-2022-04-$formattedDay.sql"], + $date->getTimestamp() + ); + } + + $application = new Application(self::$kernel); + $command = $application->find('symandy:databases:backup'); + $commandTester = new CommandTester($command); + $commandTester->execute([]); - self::assertEquals(1, (new Finder())->in(['./'])->files()->name('*.sql')->count()); + $backupFilesFinder = (new Finder()) + ->in([self::$kernel->getProjectDir() . '/backups']) + ->depth('== 0') + ->name(['main-db_test_1-*.sql']) + ->files() + ; + $otherBackupFilesFinder = (new Finder()) + ->in([self::$kernel->getProjectDir() . '/backups']) + ->depth('== 0') + ->name(['other-backup-db_test_1-*.sql']) + ->name(['other-backup-main-db_test_1-*.sql']) + ->files() + ; + + self::assertEquals(5, $backupFilesFinder->count()); + self::assertEquals(2, $otherBackupFilesFinder->count()); + $formattedTodayDate = (new DateTime())->format('Y-m-d'); + + $backupFiles = iterator_to_array($backupFilesFinder); + $otherBackupFiles = iterator_to_array($otherBackupFilesFinder); + $filePathPrefix = self::$kernel->getProjectDir() . '/backups/main-db_test_1'; + + self::assertArrayNotHasKey("$filePathPrefix-2022-04-01.sql", $backupFiles); + self::assertArrayNotHasKey("$filePathPrefix-2022-04-02.sql", $backupFiles); + self::assertArrayNotHasKey("$filePathPrefix-2022-04-03.sql", $backupFiles); + self::assertArrayNotHasKey("$filePathPrefix-2022-04-04.sql", $backupFiles); + self::assertArrayNotHasKey("$filePathPrefix-2022-04-05.sql", $backupFiles); + self::assertArrayNotHasKey("$filePathPrefix-2022-04-06.sql", $backupFiles); + self::assertArrayHasKey("$filePathPrefix-2022-04-07.sql", $backupFiles); + self::assertArrayHasKey("$filePathPrefix-2022-04-08.sql", $backupFiles); + self::assertArrayHasKey("$filePathPrefix-2022-04-09.sql", $backupFiles); + self::assertArrayHasKey("$filePathPrefix-2022-04-10.sql", $backupFiles); + self::assertArrayHasKey("$filePathPrefix-$formattedTodayDate.sql", $backupFiles); + self::assertArrayHasKey( + self::$kernel->getProjectDir() . '/backups/other-backup-db_test_1-2022-04-01.sql', + $otherBackupFiles + ); + self::assertArrayHasKey( + self::$kernel->getProjectDir() . '/backups/other-backup-main-db_test_1-2022-04-01.sql', + $otherBackupFiles + ); } /** @@ -70,9 +156,14 @@ protected function tearDown(): void $schemaManager->dropDatabase($options['dbname']); } - foreach ((new Finder())->in(['./'])->files()->name('*.sql') as $file) { - (new Filesystem())->remove([$file->getRealPath()]); - } + $backupFiles = (new Finder()) + ->in([self::$kernel->getProjectDir() . '/backups']) + ->depth('== 0') + ->files() + ->name('*.sql') + ; + + (new Filesystem())->remove($backupFiles); } private function getConnectionOptions(bool $withDbName = false): array diff --git a/tests/Functional/ContainerTest.php b/tests/Functional/ContainerTest.php index 8ee8199..9bd6fb0 100644 --- a/tests/Functional/ContainerTest.php +++ b/tests/Functional/ContainerTest.php @@ -4,7 +4,7 @@ namespace Symandy\Tests\DatabaseBackupBundle\Functional; -use Symandy\DatabaseBackupBundle\Registry\ConnectionRegistryInterface; +use Symandy\DatabaseBackupBundle\Registry\Backup\BackupRegistry; final class ContainerTest extends AbstractFunctionalTestCase { @@ -13,8 +13,8 @@ public function testContainerHasServices(): void { $container = self::getContainer(); - self::assertTrue($container->has('symandy_database_backup.registry.connection_registry')); - self::assertTrue($container->has(ConnectionRegistryInterface::class)); + self::assertTrue($container->has('symandy_database_backup.registry.backup_registry')); + self::assertTrue($container->has(BackupRegistry::class)); } } diff --git a/tests/Unit/BackupRegistryTest.php b/tests/Unit/BackupRegistryTest.php new file mode 100644 index 0000000..938dc4f --- /dev/null +++ b/tests/Unit/BackupRegistryTest.php @@ -0,0 +1,39 @@ + ['driver' => ConnectionDriver::MySQL, 'configuration' => []], + 'strategy' => ['max_files' => 1, 'backup_directory' => '/path/to/backup/dir'] + ]; + + $backup = new Backup('backup-1', new MySQLConnection(), new Strategy(1, '/path/to/backup/dir')); + + $backupRegistry->registerFromNameAndOptions('backup-1', $options); + + self::assertCount(1, $backupRegistry->all()); + self::assertTrue($backupRegistry->has('backup-1')); + self::assertEquals($backup, $backupRegistry->get('backup-1')); + } + +} diff --git a/tests/Unit/DependencyInjection/Compiler/RegisterConnectionsPassTest.php b/tests/Unit/DependencyInjection/Compiler/RegisterConnectionsPassTest.php index e15ef7e..8ef24dd 100644 --- a/tests/Unit/DependencyInjection/Compiler/RegisterConnectionsPassTest.php +++ b/tests/Unit/DependencyInjection/Compiler/RegisterConnectionsPassTest.php @@ -7,10 +7,14 @@ use Exception; use PHPUnit\Framework\TestCase; use Symandy\DatabaseBackupBundle\DependencyInjection\Compiler\RegisterConnectionsPass; -use Symandy\DatabaseBackupBundle\Model\ConnectionDriver; -use Symandy\DatabaseBackupBundle\Registry\ConnectionRegistry; -use Symandy\DatabaseBackupBundle\Registry\ConnectionRegistryInterface; +use Symandy\DatabaseBackupBundle\Factory\Backup\BackupFactory; +use Symandy\DatabaseBackupBundle\Factory\Connection\ConnectionFactory; +use Symandy\DatabaseBackupBundle\Factory\Factory; +use Symandy\DatabaseBackupBundle\Model\Backup\Strategy; +use Symandy\DatabaseBackupBundle\Model\Connection\ConnectionDriver; +use Symandy\DatabaseBackupBundle\Registry\Backup\BackupRegistry; use Symfony\Component\DependencyInjection\ContainerBuilder; +use Symfony\Component\DependencyInjection\Reference; final class RegisterConnectionsPassTest extends TestCase { @@ -21,17 +25,31 @@ final class RegisterConnectionsPassTest extends TestCase public function testProcess(): void { $container = new ContainerBuilder(); + + $container->register('symandy_database_backup.factory.connection', ConnectionFactory::class); + $container + ->register('symandy_database_backup.factory.strategy', Factory::class) + ->setArgument('$className', Strategy::class) + ; + + $container + ->register('symandy_database_backup.factory.backup', BackupFactory::class) + ->setArgument('$strategyFactory', new Reference('symandy_database_backup.factory.strategy')) + ->setArgument('$connectionFactory', new Reference('symandy_database_backup.factory.connection')) + ; + $container - ->register('symandy_database_backup.registry.connection_registry', ConnectionRegistry::class) + ->register('symandy_database_backup.registry.backup_registry', BackupRegistry::class) + ->setArgument('$backupFactory', new Reference('symandy_database_backup.factory.backup')) ->setPublic(true) ; - $container->setParameter('symandy.connections', $this->getConnectionsConfiguration()); + $container->setParameter('symandy.backups', $this->getConnectionsConfiguration()); $container->addCompilerPass(new RegisterConnectionsPass()); $container->compile(); - /** @var ConnectionRegistryInterface $registry */ - $registry = $container->get('symandy_database_backup.registry.connection_registry'); + /** @var BackupRegistry $registry */ + $registry = $container->get('symandy_database_backup.registry.backup_registry'); self::assertCount(1, $registry->all()); } @@ -40,8 +58,11 @@ private function getConnectionsConfiguration(): array { return [ 'server-1' => [ - 'driver' => ConnectionDriver::MySQL, - 'configuration' => [] + 'connection' => [ + 'driver' => ConnectionDriver::MySQL, + 'configuration' => [] + ], + 'strategy' => [] ] ]; } diff --git a/tests/Unit/DependencyInjection/ConfigurationTest.php b/tests/Unit/DependencyInjection/ConfigurationTest.php index f16307b..31dce76 100644 --- a/tests/Unit/DependencyInjection/ConfigurationTest.php +++ b/tests/Unit/DependencyInjection/ConfigurationTest.php @@ -6,7 +6,7 @@ use PHPUnit\Framework\TestCase; use Symandy\DatabaseBackupBundle\DependencyInjection\Configuration; -use Symandy\DatabaseBackupBundle\Model\ConnectionDriver; +use Symandy\DatabaseBackupBundle\Model\Connection\ConnectionDriver; use Symfony\Component\Config\Definition\Exception\InvalidConfigurationException; use Symfony\Component\Config\Definition\Processor; use ValueError; @@ -18,8 +18,8 @@ public function testDefaultOptions(): void { $configuration = $this->processConfiguration(); - self::assertArrayHasKey('connections', $configuration); - self::assertEmpty($configuration['connections']); + self::assertArrayHasKey('backups', $configuration); + self::assertEmpty($configuration['backups']); } public function testDriverNotExist(): void @@ -27,40 +27,65 @@ public function testDriverNotExist(): void $this->expectException(ValueError::class); $this->processConfiguration([[ - 'connections' => [ - 'test' => ['driver' => 'unknown'] + 'backups' => [ + 'test' => [ + 'connection' => [ + 'driver' => 'unknown' + ] + ] ] ]]); } - public function testNoDriver(): void + public function testNoConnection(): void { $this->expectException(InvalidConfigurationException::class); $this->processConfiguration([[ - 'connections' => [ + 'backups' => [ 'test' => [] ] ]]); } + public function testNoDriver(): void + { + $this->expectException(InvalidConfigurationException::class); + + $this->processConfiguration([[ + 'backups' => [ + 'test' => [ + 'connection' => [] + ] + ] + ]]); + } + public function testDriverValue(): void { $configuration = $this->processConfiguration([[ - 'connections' => [ - 'test' => ['driver' => 'mysql'] + 'backups' => [ + 'test' => [ + 'connection' => [ + 'driver' => 'mysql' + ] + ] ] ]]); - self::assertEquals(ConnectionDriver::MySQL, $configuration['connections']['test']['driver']); + self::assertEquals(ConnectionDriver::MySQL, $configuration['backups']['test']['connection']['driver']); $configuration = $this->processConfiguration([[ - 'connections' => [ - 'test' => ['driver' => ConnectionDriver::MySQL] + 'backups' => [ + 'test' => [ + 'connection' => [ + 'driver' => ConnectionDriver::MySQL + ] + ] ] ]]); - self::assertEquals(ConnectionDriver::MySQL, $configuration['connections']['test']['driver']); + self::assertEquals(ConnectionDriver::MySQL, $configuration['backups']['test']['connection']['driver']); } public function testInvalidOptions(): void @@ -68,8 +93,12 @@ public function testInvalidOptions(): void $this->expectException(InvalidConfigurationException::class); $this->processConfiguration([[ - 'connections' => [ - 'test' => ['driver' => 'mysql', 'unknown-parameter' => 'test'] + 'backups' => [ + 'test' => [ + 'connection' => [ + 'driver' => 'mysql', 'unknown-parameter' => 'test' + ] + ] ] ]]); } @@ -77,27 +106,32 @@ public function testInvalidOptions(): void public function testConfigurationValues(): void { $configuration = $this->processConfiguration([[ - 'connections' => [ + 'backups' => [ 'test' => [ - 'driver' => ConnectionDriver::MySQL, - 'configuration' => [ - 'user' => 'user-test', - 'password' => 'password-test', - 'host' => 'host-test', - 'port' => 0000, - 'databases' => ['db-1', 'db-2'] + 'connection' => [ + 'driver' => ConnectionDriver::MySQL, + 'configuration' => [ + 'user' => 'user-test', + 'password' => 'password-test', + 'host' => 'host-test', + 'port' => 0000, + 'databases' => ['db-1', 'db-2'] + ] ] ] ] ]]); self::assertNotEmpty($configuration); - self::assertArrayHasKey('test', $configuration['connections']); - self::assertArrayHasKey('configuration', $configuration['connections']['test']); + self::assertArrayHasKey('test', $configuration['backups']); + self::assertArrayHasKey('connection', $configuration['backups']['test']); - $connectionConfiguration = $configuration['connections']['test']['configuration']; - self::assertIsArray($connectionConfiguration); + $connectionNode = $configuration['backups']['test']['connection']; + self::assertIsArray($connectionNode); + self::assertArrayHasKey('configuration', $connectionNode); + $connectionConfiguration = $connectionNode['configuration']; + self::assertIsArray($connectionConfiguration); self::assertArrayHasKey('user', $connectionConfiguration); self::assertArrayHasKey('password', $connectionConfiguration); self::assertArrayHasKey('host', $connectionConfiguration); diff --git a/tests/Unit/Factory/FactoryTest.php b/tests/Unit/Factory/FactoryTest.php new file mode 100644 index 0000000..5e22c6f --- /dev/null +++ b/tests/Unit/Factory/FactoryTest.php @@ -0,0 +1,39 @@ + 'test-1', 'baz' => 'test-2']; + $fooFactory = new Factory(Foo::class); + + $foo = $fooFactory->create($options); + + self::assertInstanceOf(Foo::class, $foo); + self::assertEquals('test-1', $foo->fooBar); + self::assertEquals('test-2', $foo->baz); + } + + public function testCreateMissingOptions(): void + { + $options = ['boo' => 'test']; + + $fooFactory = new Factory(Foo::class); + + $this->expectException(SerializerException::class); + + $fooFactory->create($options); + } + +} diff --git a/tests/app/config/packages/config.yaml b/tests/app/config/packages/config.yaml index 36b9c9e..ef713ed 100644 --- a/tests/app/config/packages/config.yaml +++ b/tests/app/config/packages/config.yaml @@ -2,15 +2,19 @@ framework: test: true symandy_database_backup: - connections: + backups: main: - driver: mysql - configuration: - user: '%test.database_user%' - password: '%test.database_password%' - host: '%test.database_host%' - port: '%test.database_port%' - databases: ['%test.database_name%'] + connection: + driver: mysql + configuration: + user: '%test.database_user%' + password: '%test.database_password%' + host: '%test.database_host%' + port: '%test.database_port%' + databases: ['%test.database_name%'] + strategy: + max_files: 5 + backup_directory: "%kernel.project_dir%/backups" parameters: env(TEST_DATABASE_USER): root diff --git a/tests/app/src/Model/Foo.php b/tests/app/src/Model/Foo.php new file mode 100644 index 0000000..50068fd --- /dev/null +++ b/tests/app/src/Model/Foo.php @@ -0,0 +1,16 @@ +