From 90117533b181100e04eae5f824d08713ffebe960 Mon Sep 17 00:00:00 2001 From: Denis Smetannikov Date: Wed, 13 Mar 2024 02:54:52 +0400 Subject: [PATCH] Update validation for multiple CSV files. (#14) The "ValidateCsv" command has been updated to handle multiple CSV files from given paths, providing the ability to validate multiple files at once. Relevant error messages and logs have been updated to reflect this change. Additionally, helper methods have been added to the Utils class for improved path handling. --- .github/workflows/demo.yml | 12 +- .phan.php | 1 + Makefile | 8 +- README.md | 106 +++++---- action.yml | 14 +- composer.json | 4 +- composer.lock | 258 +++++++++++----------- src/Commands/ValidateCsv.php | 112 ++++++---- src/Utils.php | 53 +++++ src/Validators/ErrorSuite.php | 48 +++-- tests/Blueprint/CommandsTest.php | 320 ++++++++++++++++++---------- tests/Blueprint/MiscTest.php | 67 ++++++ tests/Blueprint/ValidatorTest.php | 65 +++--- tests/fixtures/batch/demo-1.csv | 3 + tests/fixtures/batch/demo-2.csv | 7 + tests/fixtures/batch/sub/demo-3.csv | 1 + tests/schemas/demo_invalid.yml | 2 +- 17 files changed, 680 insertions(+), 401 deletions(-) create mode 100644 tests/fixtures/batch/demo-1.csv create mode 100644 tests/fixtures/batch/demo-2.csv create mode 100644 tests/fixtures/batch/sub/demo-3.csv diff --git a/.github/workflows/demo.yml b/.github/workflows/demo.yml index 4f7b8d5b..570f316a 100644 --- a/.github/workflows/demo.yml +++ b/.github/workflows/demo.yml @@ -48,23 +48,23 @@ jobs: continue-on-error: true - name: Report - Text - run: OUTPUT=text make demo-github --no-print-directory + run: REPORT=text make demo-github --no-print-directory continue-on-error: true - name: Report - Github Actions - run: OUTPUT=github make demo-github --no-print-directory + run: REPORT=github make demo-github --no-print-directory continue-on-error: true - name: Report - GitLab - run: OUTPUT=gitlab make demo-github --no-print-directory + run: REPORT=gitlab make demo-github --no-print-directory continue-on-error: true - name: Report - TeamCity CI - run: OUTPUT=teamcity make demo-github --no-print-directory + run: REPORT=teamcity make demo-github --no-print-directory continue-on-error: true - name: Report - JUnit - run: OUTPUT=junit make demo-github --no-print-directory + run: REPORT=junit make demo-github --no-print-directory continue-on-error: true @@ -95,7 +95,7 @@ jobs: with: csv: tests/fixtures/demo.csv schema: tests/schemas/demo_invalid.yml - output: table + report: table continue-on-error: true diff --git a/.phan.php b/.phan.php index 8780395e..4c22cc0a 100644 --- a/.phan.php +++ b/.phan.php @@ -30,5 +30,6 @@ 'vendor/league/csv/src', 'vendor/fakerphp/faker/src', 'vendor/symfony/console', + 'vendor/symfony/finder', ], ]); diff --git a/Makefile b/Makefile index 880e4b8a..b41f5a70 100644 --- a/Makefile +++ b/Makefile @@ -16,7 +16,7 @@ ifneq (, $(wildcard ./vendor/jbzoo/codestyle/src/init.Makefile)) include ./vendor/jbzoo/codestyle/src/init.Makefile endif -OUTPUT ?= table +REPORT ?= table build: ##@Project Install all 3rd party dependencies $(call title,"Install/Update all 3rd party dependencies") @@ -77,14 +77,14 @@ demo-invalid: ##@Project Run demo invalid CSV @${PHP_BIN} ./csv-blueprint validate:csv \ --csv=./tests/fixtures/demo.csv \ --schema=./tests/schemas/demo_invalid.yml \ - --output=$(OUTPUT) + --report=$(REPORT) demo-github: ##@Project Run demo invalid CSV @${PHP_BIN} ./csv-blueprint validate:csv \ - --csv=./tests/fixtures/demo.csv \ + --csv=./tests/fixtures/batch/*.csv \ --schema=./tests/schemas/demo_invalid.yml \ - --output=$(OUTPUT) \ + --report=$(REPORT) \ --ansi diff --git a/README.md b/README.md index e9cea785..664fe6b5 100644 --- a/README.md +++ b/README.md @@ -14,12 +14,13 @@ * [As Docker container](#as-docker-container) * [As PHP binary](#as-php-binary) * [As PHP project](#as-php-project) + * [CLI Help Message](#cli-help-message) + * [Report examples](#report-examples) * [Schema Definition](#schema-definition) * [Schema file examples](#schema-file-examples) * [Coming soon](#coming-soon) * [Disadvantages?](#disadvantages) -* [Interesting fact](#interesting-fact) -* [Unit tests and check code style](#unit-tests-and-check-code-style) +* [Contributing](#contributing) * [License](#license) * [See Also](#see-also) @@ -80,9 +81,9 @@ Also see demo in the [GitHub Actions](https://github.com/JBZoo/Csv-Blueprint/act with: csv: tests/fixtures/demo.csv schema: tests/schemas/demo_invalid.yml - output: table # Optional. Default is "github" + report: table # Optional. Default is "github" ``` -**Note**. Output format for GitHub Actions is `github` by default. [GitHub Actions friendly](https://docs.github.com/en/actions/using-workflows/workflow-commands-for-github-actions#setting-a-warning-message). +**Note**. Report format for GitHub Actions is `github` by default. [GitHub Actions friendly](https://docs.github.com/en/actions/using-workflows/workflow-commands-for-github-actions#setting-a-warning-message). This allows you to see bugs in the GitHub interface at the PR level. That is, the error will be shown in a specific place in the CSV file right in diff of your Pull Requests! @@ -145,8 +146,7 @@ make build ### CLI Help Message -Here you can see all available options and commands. -Tool uses [JBZoo/Cli](https://github.com/JBZoo/Cli) package for the CLI interface. +Here you can see all available options and commands. Tool uses [JBZoo/Cli](https://github.com/JBZoo/Cli) package for the CLI interface. So there are options here for all occasions. @@ -155,15 +155,20 @@ So there are options here for all occasions. Description: - Validate CSV file by schema. + Validate CSV file(s) by schema. Usage: validate:csv [options] Options: - -c, --csv=CSV CSV filepath to validate. - -s, --schema=SCHEMA Schema rule filepath. It can be a .yml/.json/.php file. - -o, --output=OUTPUT Report output format. Available options: text, table, github, gitlab, teamcity, junit [default: "table"] + -c, --csv=CSV Path(s) to validate. + You can specify path in which CSV files will be searched (max depth is 10). + Feel free to use glob pattrens. Usage examples: + /full/path/file.csv, p/file.csv, p/*.csv, p/**/*.csv, p/**/name-*.csv, **/*.csv, etc. (multiple values allowed) + -s, --schema=SCHEMA Schema filepath. + It can be a YAML, JSON or PHP. See examples on GitHub. + -r, --report=REPORT Report output format. Available options: + text, table, github, gitlab, teamcity, junit [default: "table"] --no-progress Disable progress bar animation for logs. It will be used only for text output format. --mute-errors Mute any sort of errors. So exit code will be always "0" (if it's possible). It has major priority then --non-zero-on-error. It's on your own risk! @@ -188,52 +193,58 @@ Options: ``` -### Output Example +### Report examples As a result of the validation process, you will receive a human-readable table with a list of errors found in the CSV file. By defualt, the output format is a table, but you can choose from a variety of formats, such as text, GitHub, GitLab, TeamCity, JUnit, and more. For example, the following output is generated using the "table" format. **Notes** -* Output format for GitHub Actions is `github` by default. +* Report format for GitHub Actions is `github` by default. * Tools uses [JBZoo/CI-Report-Converter](https://github.com/JBZoo/CI-Report-Converter) as SDK to convert reports to different formats. So you can easily integrate it with any CI system. -Default output format is `table`: +Default report format is `table`: ``` -./csv-blueprint validate:csv --output=table - - -CSV : ./tests/fixtures/demo.csv -Schema : ./tests/schemas/demo_invalid.yml - -+------+------------------+--------------+-- demo.csv --------------------------------------------+ -| Line | id:Column | Rule | Message | -+------+------------------+--------------+--------------------------------------------------------+ -| 1 | 1: | csv.header | Property "name" is not defined in schema: | -| | | | "./tests/schemas/demo_invalid.yml" | -| 5 | 2:Float | max | Value "74605.944" is greater than "74605" | -| 5 | 4:Favorite color | allow_values | Value "blue" is not allowed. Allowed values: ["red", | -| | | | "green", "Blue"] | -| 6 | 0:Name | min_length | Value "Carl" (length: 4) is too short. Min length is 5 | -| 6 | 3:Birthday | min_date | Value "1955-05-14" is less than the minimum date | -| | | | "1955-05-15T00:00:00.000+00:00" | -| 8 | 3:Birthday | min_date | Value "1955-05-14" is less than the minimum date | -| | | | "1955-05-15T00:00:00.000+00:00" | -| 9 | 3:Birthday | max_date | Value "2010-07-20" is more than the maximum date | -| | | | "2009-01-01T00:00:00.000+00:00" | -| 11 | 0:Name | min_length | Value "Lois" (length: 4) is too short. Min length is 5 | -+------+------------------+--------------+-- demo.csv --------------------------------------------+ - -CSV file is not valid! Found 8 errors. +./csv-blueprint validate:csv --csv='./tests/fixtures/batch/*.csv' --schema='./tests/schemas/demo_invalid.yml' + + +Schema: ./tests/schemas/demo_invalid.yml + +Invalid file: ./tests/fixtures/batch/demo-1.csv ++------+------------------+--------------+ demo-1.csv ------------------------------------------+ +| Line | id:Column | Rule | Message | ++------+------------------+--------------+------------------------------------------------------+ +| 3 | 2:Float | max | Value "74605.944" is greater than "74605" | +| 3 | 4:Favorite color | allow_values | Value "blue" is not allowed. Allowed values: ["red", | +| | | | "green", "Blue"] | ++------+------------------+--------------+ demo-1.csv ------------------------------------------+ + +Invalid file: ./tests/fixtures/batch/demo-2.csv ++------+------------+------------+----- demo-2.csv ---------------------------------------+ +| Line | id:Column | Rule | Message | ++------+------------+------------+--------------------------------------------------------+ +| 2 | 0:Name | min_length | Value "Carl" (length: 4) is too short. Min length is 5 | +| 2 | 3:Birthday | min_date | Value "1955-05-14" is less than the minimum date | +| | | | "1955-05-15T00:00:00.000+00:00" | +| 4 | 3:Birthday | min_date | Value "1955-05-14" is less than the minimum date | +| | | | "1955-05-15T00:00:00.000+00:00" | +| 5 | 3:Birthday | max_date | Value "2010-07-20" is more than the maximum date | +| | | | "2009-01-01T00:00:00.000+00:00" | +| 7 | 0:Name | min_length | Value "Lois" (length: 4) is too short. Min length is 5 | ++------+------------+------------+----- demo-2.csv ---------------------------------------+ + +OK: ./tests/fixtures/batch/sub/demo-3.csv +Found 7 issues in 2 out of 3 CSV files. ``` -Optional output format `text`: + +Optional format `text` with highlited keywords: ```sh -./csv-blueprint validate:csv --output=text +./csv-blueprint validate:csv --report=text ``` -![Output - Text](.github/assets/output-text.png) +![Report - Text](.github/assets/output-text.png) ### Schema Definition @@ -452,24 +463,27 @@ return [ It's random ideas and plans. No orderings and deadlines. But batch processing is the priority #1. +* [x] CSV/Schema file discovery in the folder with regex filename pattern (like `glob(./**/dir/*.csv)`). +* [x] If option `--csv` is a folder, then validate all files in the folder. +* [x] Checking multiple CSV files in one schema. Batch processing. * [ ] Filename pattern validation with regex (like "all files in the folder should be in the format `/^[\d]{4}-[\d]{2}-[\d]{2}\.csv$/`"). -* [ ] CSV/Schema file discovery in the folder with regex filename pattern (like `glob(./**/dir/*.csv)`). +* [ ] Quick stop mode. If the first error is found, then stop the validation process to save time. +* [ ] S3 Storage support. Validate files in the S3 bucket? * [ ] Build phar file and release via GitHub Actions. -* [ ] If option `--csv` is a folder, then validate all files in the folder. * [ ] If option `--csv` is not specified, then the STDIN is used. To build a pipeline in Unix-like systems. * [ ] If option `--schema` is not specified, then validate only super base level things (like "is it a CSV file?"). * [ ] Agregate rules (like "at least one of the fields should be not empty" or "all values must be unique"). * [ ] Create CSV files based on the schema (like "create 1000 rows with random data based on schema and rules"). -* [ ] Checking multiple CSV files in one schema. Batch processing. * [ ] Using multiple schemas for one csv file. Batch processing. * [ ] Parallel validation of really-really large files (1GB+ ?). I know you have them and not so much memory. * [ ] Parallel validation of multiple files at once. * [ ] Benchmarks as part of the CI process and Readme. It's important to know how much time the validation process takes. * [ ] Inheritance of schemas, rules and columns. Define parent schema and override some rules in the child schemas. Make it DRY and easy to maintain. -* [ ] More output formats (like JSON, XML, etc). Any ideas? +* [ ] More report formats (like JSON, XML, etc). Any ideas? * [ ] Complex rules (like "if field `A` is not empty, then field `B` should be not empty too"). * [ ] Input encoding detection + `BOM` (right now it's experimental). It works but not so accurate... UTF-8/16/32 is the best choice for now. -* [ ] Extending with custom rules and custom output formats. Plugins? +* [ ] Gitlab and JUnit reports mus be as one structure. It's not so easy to implement. But it's a good idea. +* [ ] Extending with custom rules and custom report formats. Plugins? * [ ] Optimazation on `php.ini` level to start it faster. JIT. * [ ] More examples and documentation. diff --git a/action.yml b/action.yml index 1bd76e5c..ca2f392b 100644 --- a/action.yml +++ b/action.yml @@ -20,12 +20,16 @@ branding: inputs: csv: - description: 'CSV filepath to validate.' + description: > + Path(s) to validate. You can specify path in which CSV files will be searched + (max depth is 10). + Feel free to use glob pattrens. Usage examples: + /full/path/file.csv, p/file.csv, p/*.csv, p/**/*.csv, p/**/name-*.csv, **/*.csv, etc. required: true schema: - description: 'Schema rule filepath. File can be a Yml or JSON. See examples in the repository.' + description: 'Schema filepath. It can be a YAML, JSON or PHP. See examples on GitHub.' required: true - output: + report: description: 'Report output format. Available options: text, table, github, gitlab, teamcity, junit' default: github required: true @@ -39,7 +43,7 @@ runs: - ${{ inputs.csv }} - '--schema' - ${{ inputs.schema }} - - '--output' - - ${{ inputs.output }} + - '--report' + - ${{ inputs.report }} - '--ansi' - '-vvv' diff --git a/composer.json b/composer.json index abb98fdc..a5820752 100644 --- a/composer.json +++ b/composer.json @@ -34,7 +34,9 @@ "jbzoo/utils" : "^7.1", "jbzoo/ci-report-converter" : "^7.2", "league/csv" : "^9.15", - "symfony/yaml" : "^6.4.3" + "symfony/yaml" : "^6.4.3", + "symfony/filesystem" : "^6.4", + "symfony/finder" : "^6.4" }, "require-dev" : { diff --git a/composer.lock b/composer.lock index 8ccb8d2d..ff94acda 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": "2041fe2d7b451ac595ae0abf49f0eeac", + "content-hash": "8f84bb595c9bcc066f1d026f986eab7f", "packages": [ { "name": "bluepsyduck/symfony-process-manager", @@ -977,6 +977,133 @@ ], "time": "2023-05-23T14:45:45+00:00" }, + { + "name": "symfony/filesystem", + "version": "v6.4.3", + "source": { + "type": "git", + "url": "https://github.com/symfony/filesystem.git", + "reference": "7f3b1755eb49297a0827a7575d5d2b2fd11cc9fb" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/filesystem/zipball/7f3b1755eb49297a0827a7575d5d2b2fd11cc9fb", + "reference": "7f3b1755eb49297a0827a7575d5d2b2fd11cc9fb", + "shasum": "" + }, + "require": { + "php": ">=8.1", + "symfony/polyfill-ctype": "~1.8", + "symfony/polyfill-mbstring": "~1.8" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Filesystem\\": "" + }, + "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 basic utilities for the filesystem", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/filesystem/tree/v6.4.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": "2024-01-23T14:51:35+00:00" + }, + { + "name": "symfony/finder", + "version": "v6.4.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/finder.git", + "reference": "11d736e97f116ac375a81f96e662911a34cd50ce" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/finder/zipball/11d736e97f116ac375a81f96e662911a34cd50ce", + "reference": "11d736e97f116ac375a81f96e662911a34cd50ce", + "shasum": "" + }, + "require": { + "php": ">=8.1" + }, + "require-dev": { + "symfony/filesystem": "^6.0|^7.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Finder\\": "" + }, + "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": "Finds files and directories via an intuitive fluent interface", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/finder/tree/v6.4.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": "2023-10-31T17:30:12+00:00" + }, { "name": "symfony/lock", "version": "v6.4.3", @@ -5228,7 +5355,7 @@ "ezsystems/ezplatform-solr-search-engine": ">=1.7,<1.7.12|>=2,<2.0.2|>=3.3,<3.3.15", "ezsystems/ezplatform-user": ">=1,<1.0.1", "ezsystems/ezpublish-kernel": "<6.13.8.2-dev|>=7,<7.5.31", - "ezsystems/ezpublish-legacy": "<=2017.12.7.3|>=2018.06,<=2019.03.5.1", + "ezsystems/ezpublish-legacy": "<=2017.12.7.3|>=2018.6,<=2019.03.5.1", "ezsystems/platform-ui-assets-bundle": ">=4.2,<4.2.3", "ezsystems/repository-forms": ">=2.3,<2.3.2.1-dev|>=2.5,<2.5.15", "ezyang/htmlpurifier": "<4.1.1", @@ -7290,133 +7417,6 @@ ], "time": "2023-05-23T14:45:45+00:00" }, - { - "name": "symfony/filesystem", - "version": "v6.4.3", - "source": { - "type": "git", - "url": "https://github.com/symfony/filesystem.git", - "reference": "7f3b1755eb49297a0827a7575d5d2b2fd11cc9fb" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/symfony/filesystem/zipball/7f3b1755eb49297a0827a7575d5d2b2fd11cc9fb", - "reference": "7f3b1755eb49297a0827a7575d5d2b2fd11cc9fb", - "shasum": "" - }, - "require": { - "php": ">=8.1", - "symfony/polyfill-ctype": "~1.8", - "symfony/polyfill-mbstring": "~1.8" - }, - "type": "library", - "autoload": { - "psr-4": { - "Symfony\\Component\\Filesystem\\": "" - }, - "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 basic utilities for the filesystem", - "homepage": "https://symfony.com", - "support": { - "source": "https://github.com/symfony/filesystem/tree/v6.4.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": "2024-01-23T14:51:35+00:00" - }, - { - "name": "symfony/finder", - "version": "v6.4.0", - "source": { - "type": "git", - "url": "https://github.com/symfony/finder.git", - "reference": "11d736e97f116ac375a81f96e662911a34cd50ce" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/symfony/finder/zipball/11d736e97f116ac375a81f96e662911a34cd50ce", - "reference": "11d736e97f116ac375a81f96e662911a34cd50ce", - "shasum": "" - }, - "require": { - "php": ">=8.1" - }, - "require-dev": { - "symfony/filesystem": "^6.0|^7.0" - }, - "type": "library", - "autoload": { - "psr-4": { - "Symfony\\Component\\Finder\\": "" - }, - "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": "Finds files and directories via an intuitive fluent interface", - "homepage": "https://symfony.com", - "support": { - "source": "https://github.com/symfony/finder/tree/v6.4.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": "2023-10-31T17:30:12+00:00" - }, { "name": "symfony/options-resolver", "version": "v6.4.0", diff --git a/src/Commands/ValidateCsv.php b/src/Commands/ValidateCsv.php index 32291220..3a359db0 100644 --- a/src/Commands/ValidateCsv.php +++ b/src/Commands/ValidateCsv.php @@ -20,8 +20,10 @@ use JBZoo\Cli\OutLvl; use JBZoo\CsvBlueprint\Csv\CsvFile; use JBZoo\CsvBlueprint\Exception; +use JBZoo\CsvBlueprint\Utils; use JBZoo\CsvBlueprint\Validators\ErrorSuite; use Symfony\Component\Console\Input\InputOption; +use Symfony\Component\Finder\SplFileInfo; /** * @psalm-suppress PropertyNotSetInConstructor @@ -32,25 +34,38 @@ protected function configure(): void { $this ->setName('validate:csv') - ->setDescription('Validate CSV file by schema.') + ->setDescription('Validate CSV file(s) by schema.') ->addOption( 'csv', 'c', - InputOption::VALUE_REQUIRED, - 'CSV filepath to validate.', + InputOption::VALUE_REQUIRED | InputOption::VALUE_IS_ARRAY, + \implode('', [ + "Path(s) to validate.\n" . + 'You can specify path in which CSV files will be searched ', + '(max depth is ' . Utils::MAX_DIRECTORY_DEPTH . ").\n", + "Feel free to use glob pattrens. Usage examples: \n", + '/full/path/file.csv, ', + 'p/file.csv, ', + 'p/*.csv, ', + 'p/**/*.csv, ', + 'p/**/name-*.csv, ', + '**/*.csv, ', + 'etc.', + ]), ) ->addOption( 'schema', 's', InputOption::VALUE_REQUIRED, - 'Schema rule filepath. It can be a .yml/.json/.php file.', + "Schema filepath.\n" . + 'It can be a YAML, JSON or PHP. See examples on GitHub.', ) ->addOption( - 'output', - 'o', + 'report', + 'r', InputOption::VALUE_REQUIRED, - 'Report output format. Available options: ' . - \implode(', ', ErrorSuite::getAvaiableRenderFormats()) . '', + "Report output format. Available options:\n" . + '' . \implode(', ', ErrorSuite::getAvaiableRenderFormats()) . '', ErrorSuite::RENDER_TABLE, ); @@ -59,25 +74,43 @@ protected function configure(): void protected function executeAction(): int { - $csvFilename = $this->getCsvFilepath(); + $csvFilenames = $this->getCsvFilepaths(); $schemaFilename = $this->getSchemaFilepath(); + $this->_(''); + + $errorCounter = 0; + $invalidFiles = 0; + $totalFiles = \count($csvFilenames); + + foreach ($csvFilenames as $csvFilename) { + $csvFile = new CsvFile($csvFilename->getPathname(), $schemaFilename); + $errorSuite = $csvFile->validate(); + + if ($errorSuite->count() > 0) { + $invalidFiles++; + $errorCounter += $errorSuite->count(); + + if ($this->isTextMode()) { + $this->_('Invalid file: ' . Utils::cutPath($csvFilename->getPathname()), OutLvl::E); + } + $output = $errorSuite->render($this->getOptString('report')); + if ($output !== null) { + $this->_($output, $this->isTextMode() ? OutLvl::E : OutLvl::DEFAULT); + } + } elseif ($this->isTextMode()) { + $this->_('OK: ' . Utils::cutPath($csvFilename->getPathname())); + } + } - $csvFile = new CsvFile($csvFilename, $schemaFilename); - $errorSuite = $csvFile->validate(); - if ($errorSuite->count() > 0) { - $this->_( - $errorSuite->render($this->getOptString('output')), - $this->isTextMode() ? OutLvl::E : OutLvl::DEFAULT, - ); - - if ($this->isTextMode()) { - $this->_( - 'CSV file is not valid! ' . - 'Found ' . $errorSuite->count() . ' errors.', - OutLvl::E, - ); + if ($errorCounter > 0 && $this->isTextMode()) { + if ($totalFiles === 1) { + $errMessage = "Found {$errorCounter} issues in CSV file."; + } else { + $errMessage = "Found {$errorCounter} issues in {$invalidFiles} out of {$totalFiles} CSV files."; } + $this->_($errMessage, OutLvl::E); + return self::FAILURE; } @@ -88,19 +121,19 @@ protected function executeAction(): int return self::SUCCESS; } - private function getCsvFilepath(): string + /** + * @return SplFileInfo[] + */ + private function getCsvFilepaths(): array { - $csvFilename = $this->getOptString('csv'); - - if (\file_exists($csvFilename) === false) { - throw new Exception("CSV file not found: {$csvFilename}"); - } + $rawInput = $this->getOptArray('csv'); + $scvFilenames = Utils::findFiles($rawInput); - if ($this->isTextMode()) { - $this->_('CSV : ' . \realpath($csvFilename)); + if (\count($scvFilenames) === 0) { + throw new Exception('CSV file(s) not found in path(s): ' . \implode("\n, ", $rawInput)); } - return $csvFilename; + return $scvFilenames; } private function getSchemaFilepath(): string @@ -112,8 +145,7 @@ private function getSchemaFilepath(): string } if ($this->isTextMode()) { - $this->_('Schema : ' . \realpath($schemaFilename)); - $this->_(''); + $this->_('Schema: ' . Utils::cutPath($schemaFilename)); } return $schemaFilename; @@ -121,14 +153,14 @@ private function getSchemaFilepath(): string private function isTextMode(): bool { - return $this->getOutputMode() === ErrorSuite::RENDER_TEXT - || $this->getOutputMode() === ErrorSuite::RENDER_GITHUB - || $this->getOutputMode() === ErrorSuite::RENDER_TEAMCITY - || $this->getOutputMode() === ErrorSuite::RENDER_TABLE; + return $this->getReportType() === ErrorSuite::REPORT_TEXT + || $this->getReportType() === ErrorSuite::REPORT_GITHUB + || $this->getReportType() === ErrorSuite::REPORT_TEAMCITY + || $this->getReportType() === ErrorSuite::RENDER_TABLE; } - private function getOutputMode(): string + private function getReportType(): string { - return $this->getOptString('output', ErrorSuite::RENDER_TABLE, ErrorSuite::getAvaiableRenderFormats()); + return $this->getOptString('report', ErrorSuite::RENDER_TABLE, ErrorSuite::getAvaiableRenderFormats()); } } diff --git a/src/Utils.php b/src/Utils.php index dd26fed0..b18b8a70 100644 --- a/src/Utils.php +++ b/src/Utils.php @@ -16,8 +16,13 @@ namespace JBZoo\CsvBlueprint; +use Symfony\Component\Finder\Finder; +use Symfony\Component\Finder\SplFileInfo; + final class Utils { + public const MAX_DIRECTORY_DEPTH = 10; + public static function kebabToCamelCase(string $input): string { return \str_replace(' ', '', \ucwords(\str_replace(['-', '_'], ' ', $input))); @@ -44,4 +49,52 @@ public static function prepareRegex(?string $pattern, string $addDelimiter = '/' return $addDelimiter . $pattern . $addDelimiter . 'u'; } + + /** + * Find files from given paths. + * @param string[] $paths + * @return SplFileInfo[] + */ + public static function findFiles(array $paths): array + { + $fileList = []; + + foreach ($paths as $path) { + $path = \trim($path); + if ($path === '') { + continue; + } + + if (\strpos($path, '*') !== false) { + $finder = (new Finder()) + ->in(\dirname($path)) + ->depth('< ' . self::MAX_DIRECTORY_DEPTH) + ->ignoreVCSIgnored(true) + ->ignoreDotFiles(true) + ->followLinks() + ->name(\basename($path)); + + foreach ($finder as $file) { + if (!$file->isReadable()) { + throw new \RuntimeException("File is not readable: {$file->getPathname()}"); + } + + $fileList[$file->getPathname()] = $file; + } + } elseif (\file_exists($path)) { + $fileList[$path] = new SplFileInfo($path, '', $path); + } else { + throw new \RuntimeException("File not found: {$path}"); + } + } + + \ksort($fileList, \SORT_NATURAL); + + return $fileList; + } + + public static function cutPath(string $fullpath): string + { + return \str_replace((string)\getcwd(), '.', $fullpath); + } } diff --git a/src/Validators/ErrorSuite.php b/src/Validators/ErrorSuite.php index f9d128b7..b04184d6 100644 --- a/src/Validators/ErrorSuite.php +++ b/src/Validators/ErrorSuite.php @@ -26,39 +26,45 @@ final class ErrorSuite { - public const RENDER_TEXT = 'text'; + public const REPORT_TEXT = 'text'; public const RENDER_TABLE = 'table'; - public const RENDER_TEAMCITY = 'teamcity'; - public const RENDER_GITLAB = 'gitlab'; - public const RENDER_GITHUB = 'github'; - public const RENDER_JUNIT = 'junit'; + public const REPORT_TEAMCITY = 'teamcity'; + public const REPORT_GITLAB = 'gitlab'; + public const REPORT_GITHUB = 'github'; + public const REPORT_JUNIT = 'junit'; /** @var Error[] */ private array $errors = []; - public function __construct(private ?string $csvFilename = null) + private ?string $csvFilename; + + public function __construct(?string $csvFilename = null) { + $this->csvFilename = $csvFilename; } public function __toString(): string { - return $this->render(self::RENDER_TEXT); + return (string)$this->render(self::REPORT_TEXT); } - public function render(string $mode = self::RENDER_TEXT): string + public function render(string $mode = self::REPORT_TEXT): ?string { if ($this->count() === 0) { - return ''; + return null; } - $sourceSuite = $this->prepareSourceSuite(); - $map = [ - self::RENDER_TEXT => fn (): string => $this->renderPlainText(), + $suite = $this->prepareSourceSuite(); + $map = [ + self::REPORT_TEXT => fn (): string => $this->renderPlainText(), self::RENDER_TABLE => fn (): string => $this->renderTable(), - self::RENDER_GITHUB => static fn (): string => (new GithubCliConverter())->fromInternal($sourceSuite), - self::RENDER_GITLAB => static fn (): string => (new GitLabJsonConverter())->fromInternal($sourceSuite), - self::RENDER_TEAMCITY => static fn (): string => (new TeamCityTestsConverter())->fromInternal($sourceSuite), - self::RENDER_JUNIT => static fn (): string => (new JUnitConverter())->fromInternal($sourceSuite), + self::REPORT_GITHUB => static fn (): string => (new GithubCliConverter())->fromInternal($suite), + self::REPORT_GITLAB => static fn (): string => (new GitLabJsonConverter())->fromInternal($suite), + self::REPORT_JUNIT => static fn (): string => (new JUnitConverter())->fromInternal($suite), + self::REPORT_TEAMCITY => static fn (): string => (new TeamCityTestsConverter( + ['show-datetime' => false], + 42, + ))->fromInternal($suite), ]; if (isset($map[$mode])) { @@ -111,12 +117,12 @@ public function get(int $index): ?Error public static function getAvaiableRenderFormats(): array { return [ - self::RENDER_TEXT, + self::REPORT_TEXT, self::RENDER_TABLE, - self::RENDER_GITHUB, - self::RENDER_GITLAB, - self::RENDER_TEAMCITY, - self::RENDER_JUNIT, + self::REPORT_GITHUB, + self::REPORT_GITLAB, + self::REPORT_TEAMCITY, + self::REPORT_JUNIT, ]; } diff --git a/tests/Blueprint/CommandsTest.php b/tests/Blueprint/CommandsTest.php index 15020dd7..848c5f93 100644 --- a/tests/Blueprint/CommandsTest.php +++ b/tests/Blueprint/CommandsTest.php @@ -25,66 +25,19 @@ use Symfony\Component\Console\Output\BufferedOutput; use function JBZoo\PHPUnit\isFileContains; +use function JBZoo\PHPUnit\isNotEmpty; use function JBZoo\PHPUnit\isSame; final class CommandsTest extends PHPUnit { public function testCreateCsvHelp(): void { - $rootPath = PROJECT_ROOT; - - $actual = $this->realExecution('validate:csv', ['help' => null]); - - $expected = \implode("\n", [ - 'Description:', - ' Validate CSV file by schema.', - '', - 'Usage:', - ' validate:csv [options]', - '', - 'Options:', - ' -c, --csv=CSV CSV filepath to validate.', - ' -s, --schema=SCHEMA Schema rule filepath. It can be a .yml/.json/.php file.', - ' -o, --output=OUTPUT Report output format. Available options: text, table, github, ' . - 'gitlab, teamcity, junit [default: "table"]', - ' --no-progress Disable progress bar animation for logs. It will be used only ' . - 'for text output format.', - ' --mute-errors Mute any sort of errors. So exit code will be always "0" ' . - '(if it\'s possible).', - ' It has major priority then --non-zero-on-error. It\'s on your own risk!', - ' --stdout-only For any errors messages application will use StdOut instead of StdErr. ' . - 'It\'s on your own risk!', - ' --non-zero-on-error None-zero exit code on any StdErr message.', - ' --timestamp Show timestamp at the beginning of each message.It will be used only ' . - 'for text output format.', - ' --profile Display timing and memory usage information.', - ' --output-mode=OUTPUT-MODE Output format. Available options:', - ' text - Default text output format, userfriendly and easy to read.', - ' cron - Shortcut for crontab. It\'s basically focused on human-readable ' . - 'logs output.', - ' It\'s combination of --timestamp --profile --stdout-only --no-progress ' . - '-vv.', - ' logstash - Logstash output format, for integration with ELK stack.', - ' [default: "text"]', - ' --cron Alias for --output-mode=cron. Deprecated!', - ' -h, --help Display help for the given command. When no command is given display ' . - 'help for the list command', - ' -q, --quiet Do not output any message', - ' -V, --version Display this application version', - ' --ansi|--no-ansi Force (or disable --no-ansi) ANSI output', - ' -n, --no-interaction Do not ask any interactive question', - ' -v|vv|vvv, --verbose Increase the verbosity of messages: 1 for normal output, 2 for more ' . - 'verbose output and 3 for debug', - '', - ]); - - isSame($expected, $actual); isFileContains(\implode("\n", [ '```', './csv-blueprint validate:csv --help', '', '', - $expected, + $this->realExecution('validate:csv', ['help' => null]), '```', ]), PROJECT_ROOT . '/README.md'); } @@ -98,13 +51,13 @@ public function testCreateValidatePositive(): void 'schema' => "{$rootPath}/tests/schemas/demo_valid.yml", ]); - $expected = \implode("\n", [ - "CSV : {$rootPath}/tests/fixtures/demo.csv", - "Schema : {$rootPath}/tests/schemas/demo_valid.yml", - '', - 'Looks good!', - '', - ]); + $expected = $expected = <<<'TXT' + Schema: ./tests/schemas/demo_valid.yml + + OK: ./tests/fixtures/demo.csv + Looks good! + + TXT; isSame(0, $exitCode); isSame($expected, $actual); @@ -115,45 +68,91 @@ public function testCreateValidateNegative(): void $rootPath = PROJECT_ROOT; [$actual, $exitCode] = $this->virtualExecution('validate:csv', [ - 'csv' => "{$rootPath}/tests/fixtures/demo.csv", - 'schema' => './tests/schemas/demo_invalid.yml', + 'csv' => "{$rootPath}/tests/fixtures/demo.csv", // Full path + 'schema' => './tests/schemas/demo_invalid.yml', // Relative path ]); - $expected = \implode("\n", [ - "CSV : {$rootPath}/tests/fixtures/demo.csv", - "Schema : {$rootPath}/tests/schemas/demo_invalid.yml", - '', - '+------+------------------+--------------+-- demo.csv --------------------------------------------+', - '| Line | id:Column | Rule | Message |', - '+------+------------------+--------------+--------------------------------------------------------+', - '| 1 | 1: | csv.header | Property "name" is not defined in schema: |', - '| | | | "./tests/schemas/demo_invalid.yml" |', - '| 5 | 2:Float | max | Value "74605.944" is greater than "74605" |', - '| 5 | 4:Favorite color | allow_values | Value "blue" is not allowed. Allowed values: ["red", |', - '| | | | "green", "Blue"] |', - '| 6 | 0:Name | min_length | Value "Carl" (length: 4) is too short. Min length is 5 |', - '| 6 | 3:Birthday | min_date | Value "1955-05-14" is less than the minimum date |', - '| | | | "1955-05-15T00:00:00.000+00:00" |', - '| 8 | 3:Birthday | min_date | Value "1955-05-14" is less than the minimum date |', - '| | | | "1955-05-15T00:00:00.000+00:00" |', - '| 9 | 3:Birthday | max_date | Value "2010-07-20" is more than the maximum date |', - '| | | | "2009-01-01T00:00:00.000+00:00" |', - '| 11 | 0:Name | min_length | Value "Lois" (length: 4) is too short. Min length is 5 |', - '+------+------------------+--------------+-- demo.csv --------------------------------------------+', - '', - 'CSV file is not valid! Found 8 errors.', - '', - ]); + $this->dumpText($actual); + + $expected = <<<'TXT' + Schema: ./tests/schemas/demo_invalid.yml + + Invalid file: ./tests/fixtures/demo.csv + +------+------------------+--------------+-- demo.csv --------------------------------------------+ + | Line | id:Column | Rule | Message | + +------+------------------+--------------+--------------------------------------------------------+ + | 5 | 2:Float | max | Value "74605.944" is greater than "74605" | + | 5 | 4:Favorite color | allow_values | Value "blue" is not allowed. Allowed values: ["red", | + | | | | "green", "Blue"] | + | 6 | 0:Name | min_length | Value "Carl" (length: 4) is too short. Min length is 5 | + | 6 | 3:Birthday | min_date | Value "1955-05-14" is less than the minimum date | + | | | | "1955-05-15T00:00:00.000+00:00" | + | 8 | 3:Birthday | min_date | Value "1955-05-14" is less than the minimum date | + | | | | "1955-05-15T00:00:00.000+00:00" | + | 9 | 3:Birthday | max_date | Value "2010-07-20" is more than the maximum date | + | | | | "2009-01-01T00:00:00.000+00:00" | + | 11 | 0:Name | min_length | Value "Lois" (length: 4) is too short. Min length is 5 | + +------+------------------+--------------+-- demo.csv --------------------------------------------+ + + Found 7 issues in CSV file. + + TXT; + + isSame(1, $exitCode, $actual); + isSame($expected, $actual); + } - isSame(1, $exitCode); + public function testCreateValidateNegativeMultiple(): void + { + $options = [ + 'csv' => './tests/fixtures/batch/*.csv', + 'schema' => './tests/schemas/demo_invalid.yml', + ]; + $optionsAsString = new StringInput(Cli::build('', $options)); + [$actual, $exitCode] = $this->virtualExecution('validate:csv', $options); + + $this->dumpText($actual); + + $expected = <<<'TXT' + Schema: ./tests/schemas/demo_invalid.yml + + Invalid file: ./tests/fixtures/batch/demo-1.csv + +------+------------------+--------------+ demo-1.csv ------------------------------------------+ + | Line | id:Column | Rule | Message | + +------+------------------+--------------+------------------------------------------------------+ + | 3 | 2:Float | max | Value "74605.944" is greater than "74605" | + | 3 | 4:Favorite color | allow_values | Value "blue" is not allowed. Allowed values: ["red", | + | | | | "green", "Blue"] | + +------+------------------+--------------+ demo-1.csv ------------------------------------------+ + + Invalid file: ./tests/fixtures/batch/demo-2.csv + +------+------------+------------+----- demo-2.csv ---------------------------------------+ + | Line | id:Column | Rule | Message | + +------+------------+------------+--------------------------------------------------------+ + | 2 | 0:Name | min_length | Value "Carl" (length: 4) is too short. Min length is 5 | + | 2 | 3:Birthday | min_date | Value "1955-05-14" is less than the minimum date | + | | | | "1955-05-15T00:00:00.000+00:00" | + | 4 | 3:Birthday | min_date | Value "1955-05-14" is less than the minimum date | + | | | | "1955-05-15T00:00:00.000+00:00" | + | 5 | 3:Birthday | max_date | Value "2010-07-20" is more than the maximum date | + | | | | "2009-01-01T00:00:00.000+00:00" | + | 7 | 0:Name | min_length | Value "Lois" (length: 4) is too short. Min length is 5 | + +------+------------+------------+----- demo-2.csv ---------------------------------------+ + + OK: ./tests/fixtures/batch/sub/demo-3.csv + Found 7 issues in 2 out of 3 CSV files. + + TXT; + + isSame(1, $exitCode, $actual); isSame($expected, $actual); isFileContains(\implode("\n", [ '```', - './csv-blueprint validate:csv --output=table', + "./csv-blueprint validate:csv {$optionsAsString}", '', '', - \str_replace($rootPath, '.', $expected), + $expected, '```', ]), PROJECT_ROOT . '/README.md'); } @@ -163,44 +162,126 @@ public function testCreateValidateNegativeText(): void $rootPath = PROJECT_ROOT; [$actual, $exitCode] = $this->virtualExecution('validate:csv', [ - 'csv' => "{$rootPath}/tests/fixtures/demo.csv", - 'schema' => "{$rootPath}/tests/schemas/demo_invalid.yml", - 'output' => 'text', + 'csv' => './tests/fixtures/demo.csv', + 'schema' => './tests/schemas/demo_invalid.yml', + 'report' => 'text', ]); - $expected = \implode("\n", [ - "CSV : {$rootPath}/tests/fixtures/demo.csv", - "Schema : {$rootPath}/tests/schemas/demo_invalid.yml", - '', - '"csv.header" at line 1, column "1:". Property "name" is not defined in schema: ' . - "\"{$rootPath}/tests/schemas/demo_invalid.yml\".", - - '"max" at line 5, column "2:Float". Value "74605.944" is greater than "74605".', - - '"allow_values" at line 5, column "4:Favorite color". Value "blue" is not allowed. ' . - 'Allowed values: ["red", "green", "Blue"].', - - '"min_length" at line 6, column "0:Name". Value "Carl" (length: 4) is too short. ' . - 'Min length is 5.', + $this->dumpText($actual); + + $expected = <<<'TXT' + Schema: ./tests/schemas/demo_invalid.yml + + Invalid file: ./tests/fixtures/demo.csv + "max" at line 5, column "2:Float". Value "74605.944" is greater than "74605". + "allow_values" at line 5, column "4:Favorite color". Value "blue" is not allowed. Allowed values: ["red", "green", "Blue"]. + "min_length" at line 6, column "0:Name". Value "Carl" (length: 4) is too short. Min length is 5. + "min_date" at line 6, column "3:Birthday". Value "1955-05-14" is less than the minimum date "1955-05-15T00:00:00.000+00:00". + "min_date" at line 8, column "3:Birthday". Value "1955-05-14" is less than the minimum date "1955-05-15T00:00:00.000+00:00". + "max_date" at line 9, column "3:Birthday". Value "2010-07-20" is more than the maximum date "2009-01-01T00:00:00.000+00:00". + "min_length" at line 11, column "0:Name". Value "Lois" (length: 4) is too short. Min length is 5. + + Found 7 issues in CSV file. + + TXT; + + isSame(1, $exitCode, $actual); + isSame($expected, $actual); + } - '"min_date" at line 6, column "3:Birthday". Value "1955-05-14" is less than the ' . - 'minimum date "1955-05-15T00:00:00.000+00:00".', + public function testCreateValidateNegativeTeamcity(): void + { + $rootPath = PROJECT_ROOT; - '"min_date" at line 8, column "3:Birthday". Value "1955-05-14" is less than the ' . - 'minimum date "1955-05-15T00:00:00.000+00:00".', + [$actual, $exitCode] = $this->virtualExecution('validate:csv', [ + 'csv' => './tests/fixtures/batch/*.csv', + 'schema' => './tests/schemas/demo_invalid.yml', + 'report' => 'teamcity', + ]); - '"max_date" at line 9, column "3:Birthday". Value "2010-07-20" is more than the ' . - 'maximum date "2009-01-01T00:00:00.000+00:00".', + $this->dumpText($actual); + + $expected = <<<'TXT' + Schema: ./tests/schemas/demo_invalid.yml + + Invalid file: ./tests/fixtures/batch/demo-1.csv + + ##teamcity[testCount count='2' flowId='42'] + + ##teamcity[testSuiteStarted name='demo-1.csv' flowId='42'] + + ##teamcity[testStarted name='max at column 2:Float' locationHint='php_qn://./tests/fixtures/batch/demo-1.csv' flowId='42'] + "max" at line 3, column "2:Float". Value "74605.944" is greater than "74605". + ##teamcity[testFinished name='max at column 2:Float' flowId='42'] + + ##teamcity[testStarted name='allow_values at column 4:Favorite color' locationHint='php_qn://./tests/fixtures/batch/demo-1.csv' flowId='42'] + "allow_values" at line 3, column "4:Favorite color". Value "blue" is not allowed. Allowed values: ["red", "green", "Blue"]. + ##teamcity[testFinished name='allow_values at column 4:Favorite color' flowId='42'] + + ##teamcity[testSuiteFinished name='demo-1.csv' flowId='42'] + + Invalid file: ./tests/fixtures/batch/demo-2.csv + + ##teamcity[testCount count='5' flowId='42'] + + ##teamcity[testSuiteStarted name='demo-2.csv' flowId='42'] + + ##teamcity[testStarted name='min_length at column 0:Name' locationHint='php_qn://./tests/fixtures/batch/demo-2.csv' flowId='42'] + "min_length" at line 2, column "0:Name". Value "Carl" (length: 4) is too short. Min length is 5. + ##teamcity[testFinished name='min_length at column 0:Name' flowId='42'] + + ##teamcity[testStarted name='min_date at column 3:Birthday' locationHint='php_qn://./tests/fixtures/batch/demo-2.csv' flowId='42'] + "min_date" at line 2, column "3:Birthday". Value "1955-05-14" is less than the minimum date "1955-05-15T00:00:00.000+00:00". + ##teamcity[testFinished name='min_date at column 3:Birthday' flowId='42'] + + ##teamcity[testStarted name='min_date at column 3:Birthday' locationHint='php_qn://./tests/fixtures/batch/demo-2.csv' flowId='42'] + "min_date" at line 4, column "3:Birthday". Value "1955-05-14" is less than the minimum date "1955-05-15T00:00:00.000+00:00". + ##teamcity[testFinished name='min_date at column 3:Birthday' flowId='42'] + + ##teamcity[testStarted name='max_date at column 3:Birthday' locationHint='php_qn://./tests/fixtures/batch/demo-2.csv' flowId='42'] + "max_date" at line 5, column "3:Birthday". Value "2010-07-20" is more than the maximum date "2009-01-01T00:00:00.000+00:00". + ##teamcity[testFinished name='max_date at column 3:Birthday' flowId='42'] + + ##teamcity[testStarted name='min_length at column 0:Name' locationHint='php_qn://./tests/fixtures/batch/demo-2.csv' flowId='42'] + "min_length" at line 7, column "0:Name". Value "Lois" (length: 4) is too short. Min length is 5. + ##teamcity[testFinished name='min_length at column 0:Name' flowId='42'] + + ##teamcity[testSuiteFinished name='demo-2.csv' flowId='42'] + + OK: ./tests/fixtures/batch/sub/demo-3.csv + Found 7 issues in 2 out of 3 CSV files. + + TXT; + + isSame(1, $exitCode, $actual); + isSame($expected, $actual); + } - '"min_length" at line 11, column "0:Name". Value "Lois" (length: 4) is too short. ' . - 'Min length is 5.', + public function testMultipleCsvOptions(): void + { + [$expected, $expectedCode] = $this->virtualExecution('validate:csv', [ + 'csv' => './tests/fixtures/batch/*.csv', + 'schema' => './tests/schemas/demo_invalid.yml', + ]); + $actual = $this->realExecution( + 'validate:csv ' . \implode(' ', [ + '--csv="./tests/fixtures/batch/sub/demo-3.csv"', + '--csv="./tests/fixtures/batch/demo-1.csv"', + '--csv="./tests/fixtures/batch/demo-2.csv"', + '--csv="./tests/fixtures/batch/*.csv"', + '--schema="./tests/schemas/demo_invalid.yml"', + '--mute-errors', + '--stdout-only', + '--no-ansi', + ]), + [], '', - 'CSV file is not valid! Found 8 errors.', - '', - ]); + ); - isSame(1, $exitCode); + isNotEmpty($expected); + isNotEmpty($actual); + isSame($expectedCode, 1); isSame($expected, $actual); } @@ -219,14 +300,14 @@ private function virtualExecution(string $action, array $params = []): array return [$buffer->fetch(), $exitCode]; } - private function realExecution(string $action, array $params = []): string + private function realExecution(string $action, array $params = [], string $extra = '--no-ansi'): string { $rootDir = PROJECT_ROOT; return Cli::exec( \implode(' ', [ Sys::getBinary(), - "{$rootDir}/csv-blueprint.php --no-ansi", + "{$rootDir}/csv-blueprint.php {$extra}", $action, '2>&1', ]), @@ -235,4 +316,9 @@ private function realExecution(string $action, array $params = []): string false, ); } + + private function dumpText($text): void + { + \file_put_contents(PROJECT_ROOT . '/build/dump.txt', $text); + } } diff --git a/tests/Blueprint/MiscTest.php b/tests/Blueprint/MiscTest.php index 1003aed7..8c114b5d 100644 --- a/tests/Blueprint/MiscTest.php +++ b/tests/Blueprint/MiscTest.php @@ -20,6 +20,7 @@ use JBZoo\CsvBlueprint\Utils; use JBZoo\PHPUnit\PHPUnit; use Symfony\Component\Finder\Finder; +use Symfony\Component\Finder\SplFileInfo; use function JBZoo\Data\json; use function JBZoo\Data\phpArray; @@ -128,6 +129,72 @@ public function testCompareExamplesWithOrig(): void isSame($origYml, json("{$basepath}.json")->getArrayCopy(), 'JSON config is invalid'); } + public function testFindFiles(): void + { + isSame(['demo.csv'], $this->getFileName(Utils::findFiles([ + PROJECT_ROOT . '/tests/fixtures/demo.csv', + ]))); + + isSame([], $this->getFileName(Utils::findFiles([]))); + + $this->getFileName(Utils::findFiles(['*.qwerty'])); + + isSame(['demo-1.csv', 'demo-2.csv', 'demo-3.csv'], $this->getFileName(Utils::findFiles([ + PROJECT_ROOT . '/tests/fixtures/batch/*.csv', + ]))); + + isSame(['demo-1.csv', 'demo-2.csv', 'demo-3.csv'], $this->getFileName(Utils::findFiles([ + 'tests/fixtures/batch/*.csv', + ]))); + + isSame(['demo-1.csv', 'demo-2.csv', 'demo-3.csv'], $this->getFileName(Utils::findFiles([ + './tests/fixtures/batch/*.csv', + ]))); + + isSame(['demo-1.csv', 'demo-2.csv', 'demo-3.csv'], $this->getFileName(Utils::findFiles(['**/demo-*.csv']))); + + isSame(['demo-1.csv', 'demo-2.csv', 'demo-3.csv', 'demo.csv'], $this->getFileName(Utils::findFiles([ + PROJECT_ROOT . '/tests/fixtures/batch/*.csv', + PROJECT_ROOT . '/tests/fixtures/demo.csv', + ]))); + + isSame(['demo-1.csv', 'demo-2.csv', 'demo-3.csv', 'demo.csv'], $this->getFileName(Utils::findFiles([ + PROJECT_ROOT . '/tests/fixtures/demo.csv', + PROJECT_ROOT . '/tests/fixtures/batch/*.csv', + ]))); + + isSame( + [ + 'demo-1.csv', + 'demo-2.csv', + 'demo-3.csv', + 'complex_header.csv', + 'complex_no_header.csv', + 'demo.csv', + 'empty_header.csv', + 'empty_no_header.csv', + 'simple_header.csv', + 'simple_no_header.csv', + ], + $this->getFileName(Utils::findFiles(['tests/**/*.csv'])), + ); + } + + public function testFindFilesNotFound(): void + { + $this->expectExceptionMessage('File not found: demo.csv'); + $this->getFileName(Utils::findFiles(['demo.csv'])); + } + + /** + * @param SplFileInfo[] $files + * @return string[] + */ + private function getFileName(array $files): array + { + return \array_values(\array_map(static fn (SplFileInfo $file) => $file->getFilename(), $files)); + } + private function testCheckExampleInReadme( string $filepath, string $type, diff --git a/tests/Blueprint/ValidatorTest.php b/tests/Blueprint/ValidatorTest.php index a74202cf..259b0b0a 100644 --- a/tests/Blueprint/ValidatorTest.php +++ b/tests/Blueprint/ValidatorTest.php @@ -21,20 +21,19 @@ use JBZoo\PHPUnit\PHPUnit; use function JBZoo\Data\json; -use function JBZoo\PHPUnit\isContain; use function JBZoo\PHPUnit\isSame; final class ValidatorTest extends PHPUnit { - private const CSV_SIMPLE_HEADER = PROJECT_TESTS . '/fixtures/simple_header.csv'; - private const CSV_SIMPLE_NO_HEADER = PROJECT_TESTS . '/fixtures/simple_no_header.csv'; - private const CSV_COMPLEX = PROJECT_TESTS . '/fixtures/complex_header.csv'; + private const CSV_SIMPLE_HEADER = './tests/fixtures/simple_header.csv'; + private const CSV_SIMPLE_NO_HEADER = './tests/fixtures/simple_no_header.csv'; + private const CSV_COMPLEX = './tests/fixtures/complex_header.csv'; - private const SCHEMA_SIMPLE_HEADER = PROJECT_TESTS . '/schemas/simple_header.yml'; - private const SCHEMA_SIMPLE_NO_HEADER = PROJECT_TESTS . '/schemas/simple_no_header.yml'; + private const SCHEMA_SIMPLE_HEADER = './tests/schemas/simple_header.yml'; + private const SCHEMA_SIMPLE_NO_HEADER = './tests/schemas/simple_no_header.yml'; - private const SCHEMA_SIMPLE_HEADER_PHP = PROJECT_TESTS . '/schemas/simple_header.php'; - private const SCHEMA_SIMPLE_HEADER_JSON = PROJECT_TESTS . '/schemas/simple_header.json'; + private const SCHEMA_SIMPLE_HEADER_PHP = './tests/schemas/simple_header.php'; + private const SCHEMA_SIMPLE_HEADER_JSON = './tests/schemas/simple_header.json'; protected function setUp(): void { @@ -418,7 +417,7 @@ public function testRenderText(): void $csv = new CsvFile(self::CSV_SIMPLE_HEADER, $this->getRule('seq', 'min', 3)); isSame( '"min" at line 2, column "0:seq". Value "1" is less than "3".' . "\n", - \strip_tags($csv->validate(true)->render(ErrorSuite::RENDER_TEXT)), + \strip_tags($csv->validate(true)->render(ErrorSuite::REPORT_TEXT)), ); isSame( @@ -426,7 +425,7 @@ public function testRenderText(): void '"min" at line 2, column "0:seq". Value "1" is less than "3".', '"min" at line 3, column "0:seq". Value "2" is less than "3".' . "\n", ]), - \strip_tags($csv->validate()->render(ErrorSuite::RENDER_TEXT)), + \strip_tags($csv->validate()->render(ErrorSuite::REPORT_TEXT)), ); } @@ -462,16 +461,28 @@ public function testRenderTable(): void public function testRenderTeamCity(): void { $csv = new CsvFile(self::CSV_SIMPLE_HEADER, $this->getRule('seq', 'min', 3)); - $out = $csv->validate()->render(ErrorSuite::RENDER_TEAMCITY); + $out = $csv->validate()->render(ErrorSuite::REPORT_TEAMCITY); $path = self::CSV_SIMPLE_HEADER; - isContain("##teamcity[testCount count='2' ", $out); - isContain("##teamcity[testSuiteStarted name='simple_header.csv' ", $out); - isContain("##teamcity[testStarted name='min at column 0:seq' locationHint='php_qn://{$path}'", $out); - isContain("##teamcity[testFinished name='min at column 0:seq' timestamp", $out); - isContain('Value "1" is less than "3"', $out); - isContain('Value "2" is less than "3"', $out); - isContain("##teamcity[testSuiteFinished name='simple_header.csv'", $out); + $expected = <<<'TEAMCITY' + + ##teamcity[testCount count='2' flowId='42'] + + ##teamcity[testSuiteStarted name='simple_header.csv' flowId='42'] + + ##teamcity[testStarted name='min at column 0:seq' locationHint='php_qn://./tests/fixtures/simple_header.csv' flowId='42'] + "min" at line 2, column "0:seq". Value "1" is less than "3". + ##teamcity[testFinished name='min at column 0:seq' flowId='42'] + + ##teamcity[testStarted name='min at column 0:seq' locationHint='php_qn://./tests/fixtures/simple_header.csv' flowId='42'] + "min" at line 3, column "0:seq". Value "2" is less than "3". + ##teamcity[testFinished name='min at column 0:seq' flowId='42'] + + ##teamcity[testSuiteFinished name='simple_header.csv' flowId='42'] + + TEAMCITY; + + isSame($expected, $out); } public function testRenderGithub(): void @@ -487,7 +498,7 @@ public function testRenderGithub(): void 'column "0:seq". Value "2" is less than "3".', '', ]), - $csv->validate()->render(ErrorSuite::RENDER_GITHUB), + $csv->validate()->render(ErrorSuite::REPORT_GITHUB), ); } @@ -496,7 +507,7 @@ public function testRenderGitlab(): void $csv = new CsvFile(self::CSV_SIMPLE_HEADER, $this->getRule('seq', 'min', 3)); $path = self::CSV_SIMPLE_HEADER; - $cleanJson = json($csv->validate()->render(ErrorSuite::RENDER_GITLAB))->getArrayCopy(); + $cleanJson = json($csv->validate()->render(ErrorSuite::REPORT_GITLAB))->getArrayCopy(); unset($cleanJson[0]['fingerprint'], $cleanJson[1]['fingerprint']); isSame( @@ -504,22 +515,14 @@ public function testRenderGitlab(): void [ 'description' => "min at column 0:seq\n\"min\" at line 2, " . 'column "0:seq". Value "1" is less than "3".', - // 'fingerprint' => '...', 'severity' => 'major', - 'location' => [ - 'path' => $path, - 'lines' => ['begin' => 2], - ], + 'location' => ['path' => $path, 'lines' => ['begin' => 2]], ], [ 'description' => "min at column 0:seq\n\"min\" at line 3, " . 'column "0:seq". Value "2" is less than "3".', - // 'fingerprint' => '..', 'severity' => 'major', - 'location' => [ - 'path' => $path, - 'lines' => ['begin' => 3], - ], + 'location' => ['path' => $path, 'lines' => ['begin' => 3]], ], ], $cleanJson, @@ -545,7 +548,7 @@ public function testRenderJUnit(): void '', '', ]), - $csv->validate()->render(ErrorSuite::RENDER_JUNIT), + $csv->validate()->render(ErrorSuite::REPORT_JUNIT), ); } diff --git a/tests/fixtures/batch/demo-1.csv b/tests/fixtures/batch/demo-1.csv new file mode 100644 index 00000000..daad3d23 --- /dev/null +++ b/tests/fixtures/batch/demo-1.csv @@ -0,0 +1,3 @@ +Name,City,Float,Birthday,Favorite color +Derek,Sarefunaw,-177.9088,2000-01-31,green +Dylan,Wufolu,74605.944,1998-02-28,blue diff --git a/tests/fixtures/batch/demo-2.csv b/tests/fixtures/batch/demo-2.csv new file mode 100644 index 00000000..564b3598 --- /dev/null +++ b/tests/fixtures/batch/demo-2.csv @@ -0,0 +1,7 @@ +Name,City,Float,Birthday,Favorite color +Carl,Gorriju,0.8431,1955-05-14,red +Landon,Mojebol,123.64,1989-05-15,red +Olive,Pebiogu,0,1955-05-14,green +Willie,Sowaah,0.001,2010-07-20,red +Derrick,Rakufag,42,1990-09-10,green +Lois,Mofninle,-19366059127.6032,1988-08-24,green diff --git a/tests/fixtures/batch/sub/demo-3.csv b/tests/fixtures/batch/sub/demo-3.csv new file mode 100644 index 00000000..e417a0e6 --- /dev/null +++ b/tests/fixtures/batch/sub/demo-3.csv @@ -0,0 +1 @@ +Name,City,Float,Birthday,Favorite color diff --git a/tests/schemas/demo_invalid.yml b/tests/schemas/demo_invalid.yml index fe8e396e..e6034d35 100644 --- a/tests/schemas/demo_invalid.yml +++ b/tests/schemas/demo_invalid.yml @@ -19,7 +19,7 @@ columns: min_length: 5 max_length: 7 - - name: + - name: City rules: not_empty: true only_capitalize: true