From 3c4dfb48dc841e96cab298f5342ee8895f8c8aa6 Mon Sep 17 00:00:00 2001 From: Norbert Orzechowicz Date: Tue, 22 Sep 2026 10:29:05 +0200 Subject: [PATCH 1/3] feat(flow-php/etl): every extractor declares source statistics - Extractor::statistics() with Cardinality and Statistics: rows and bytes known before reading - file sources declare listed bytes; CSV and JSON Lines estimate rows from the schema sample - Parquet and Floe read footer totals once, shared with schema(); Floe footer gains a statistics block - PostgreSQL and Doctrine (PostgreSQL, MySQL) estimate rows with EXPLAIN, never ANALYZE - exact counts for sequences, file listings, cache index and whole-source samples - native CSV reader reports consumed bytes --- documentation/components/core/floe.md | 6 +- documentation/upgrading.md | 34 ++++ .../Adapter/Avro/FlixTech/AvroExtractor.php | 6 + .../src/Flow/ETL/Adapter/CSV/CSVExtractor.php | 59 +++++- .../Flow/ETL/Adapter/CSV/CSVFileSample.php | 27 ++- .../Flow/ETL/Adapter/CSV/CSVLineReader.php | 33 +++- .../Flow/ETL/Adapter/CSV/CSVOpenSource.php | 14 ++ .../Flow/ETL/Adapter/CSV/CSVSampledFiles.php | 44 +++++ .../Flow/ETL/Adapter/CSV/CSVSampledRows.php | 40 ++++ .../src/Flow/ETL/Adapter/CSV/CSVSamples.php | 63 ++++++ .../ETL/Adapter/CSV/NativeCSVOpenSource.php | 38 +++- .../Flow/ETL/Adapter/CSV/PhpCSVOpenSource.php | 31 ++- .../CSV/Tests/Context/CSVFixtureContext.php | 33 ++++ .../Tests/Integration/CSVFileSampleTest.php | 34 ++++ .../CSV/Tests/Integration/CSVSamplesTest.php | 68 +++++++ .../Integration/NativeCSVOpenSourceTest.php | 53 +++++ .../Integration/PhpCSVOpenSourceTest.php | 14 ++ .../CSV/Tests/Unit/CSVExtractorTest.php | 184 ++++++++++++++++++ .../CSV/Tests/Unit/CSVLineReaderTest.php | 25 +++ .../CSV/Tests/Unit/CSVSampledFilesTest.php | 48 +++++ .../CSV/Tests/Unit/CSVSampledRowsTest.php | 34 ++++ .../Adapter/Doctrine/DbalKeySetExtractor.php | 16 ++ .../Doctrine/DbalLimitOffsetExtractor.php | 22 +++ .../Adapter/Doctrine/DbalQueryExtractor.php | 46 +++++ .../Doctrine/Explain/ExplainedRows.php | 64 ++++++ .../Doctrine/Explain/MySqlExplainedRows.php | 47 +++++ .../Explain/PostgreSqlExplainedRows.php | 51 +++++ .../Doctrine/Tests/Context/PlannedTable.php | 41 ++++ .../PosgreSQLDbalLimitOffsetExtractorTest.php | 23 +++ .../PostgreSQLDbalQueryExtractorTest.php | 31 +++ .../PostgreSQLKeySetExtractorTest.php | 20 ++ .../Integration/Explain/ExplainedRowsTest.php | 77 ++++++++ .../Explain/MySqlExplainedRowsTest.php | 43 ++++ .../Explain/PostgreSqlExplainedRowsTest.php | 33 ++++ .../Tests/Unit/DbalKeySetExtractorTest.php | 18 ++ .../Unit/DbalLimitOffsetExtractorTest.php | 44 +++++ .../Tests/Unit/DbalQueryExtractorTest.php | 29 +++ .../Flow/ETL/Adapter/Excel/ExcelExtractor.php | 48 ++++- .../ETL/Adapter/Excel/WorkbookSampler.php | 32 ++- .../ETL/Adapter/Excel/WorkbookSheetSample.php | 56 ++++++ .../Tests/Integration/ExcelExtractorTest.php | 124 ++++++++++++ .../Tests/Integration/WorkbookSamplerTest.php | 52 ++++- .../Tests/Unit/WorkbookSheetSampleTest.php | 58 ++++++ .../GoogleSheet/GoogleSheetExtractor.php | 43 ++++ .../Adapter/GoogleSheet/GoogleSheetReader.php | 2 +- .../Adapter/GoogleSheet/GoogleSheetSample.php | 2 + .../GoogleSheet/GoogleSheetSampler.php | 5 + .../Tests/Unit/GoogleSheetExtractorTest.php | 73 +++++++ .../Tests/Unit/GoogleSheetSampleTest.php | 3 +- .../Tests/Unit/GoogleSheetSamplerTest.php | 10 + .../Http/PsrHttpClientDynamicExtractor.php | 6 + .../Http/PsrHttpClientPaginatedExtractor.php | 6 + .../Http/PsrHttpClientStaticExtractor.php | 6 + .../JSON/JSONMachine/JsonExtractor.php | 44 ++++- .../JSON/JSONMachine/JsonFileReader.php | 14 +- .../JSON/JSONMachine/JsonFileSample.php | 55 ++++++ .../JSON/JSONMachine/JsonLinesExtractor.php | 47 ++++- .../JSON/JSONMachine/JsonReadBytes.php | 29 +++ .../JSON/JSONMachine/JsonSampledFiles.php | 52 +++++ .../JSON/JSONMachine/JsonSampledRows.php | 40 ++++ .../Adapter/JSON/JSONMachine/JsonSamples.php | 63 ++++++ .../JSONMachine/JsonExtractorTest.php | 106 ++++++++++ .../JSONMachine/JsonFileReaderTest.php | 10 +- .../JSONMachine/JsonFileSampleTest.php | 90 +++++++++ .../JSONMachine/JsonLinesExtractorTest.php | 141 ++++++++++++++ .../JSONMachine/JsonSamplesTest.php | 81 ++++++++ .../Unit/JSONMachine/JsonReadBytesTest.php | 25 +++ .../Unit/JSONMachine/JsonSampledFilesTest.php | 53 +++++ .../Unit/JSONMachine/JsonSampledRowsTest.php | 34 ++++ .../ETL/Adapter/Parquet/ParquetExtractor.php | 28 +++ .../ETL/Adapter/Parquet/ParquetSourceFile.php | 17 ++ .../Tests/Unit/ParquetExtractorTest.php | 174 +++++++++++++++++ .../ETL/Adapter/PostgreSql/ExplainedRows.php | 32 +++ .../PostgreSql/PostgreSqlCursorExtractor.php | 14 ++ .../PostgreSql/PostgreSqlKeySetExtractor.php | 14 ++ .../PostgreSqlLimitOffsetExtractor.php | 14 ++ .../PostgreSql/Tests/Double/SpyClient.php | 27 ++- ...stgreSqlCursorExtractorIntegrationTest.php | 18 ++ .../Tests/Unit/ExplainedRowsTest.php | 41 ++++ .../Unit/PostgreSqlCursorExtractorTest.php | 43 ++++ .../Unit/PostgreSqlKeySetExtractorTest.php | 58 ++++++ .../PostgreSqlLimitOffsetExtractorTest.php | 43 ++++ .../Flow/ETL/Adapter/Text/TextExtractor.php | 11 ++ .../Tests/Integration/TextExtractorTest.php | 43 ++++ .../ETL/Adapter/XML/XMLParserExtractor.php | 11 ++ .../ETL/Adapter/XML/XMLReaderExtractor.php | 11 ++ .../Adapter/XML/Tests/Fixtures/listed/a.xml | 1 + .../Adapter/XML/Tests/Fixtures/listed/b.xml | 1 + .../Integration/XMLParserExtractorTest.php | 42 ++++ .../Integration/XMLReaderExtractorTest.php | 37 ++++ .../etl/src/Flow/ETL/Cache/CacheIndex.php | 46 ++++- src/core/etl/src/Flow/ETL/Cardinality.php | 91 +++++++++ src/core/etl/src/Flow/ETL/Extractor.php | 16 ++ .../src/Flow/ETL/Extractor/ArrayExtractor.php | 11 ++ .../Flow/ETL/Extractor/BatchByExtractor.php | 5 + .../src/Flow/ETL/Extractor/BatchExtractor.php | 5 + .../src/Flow/ETL/Extractor/CacheExtractor.php | 39 +++- .../src/Flow/ETL/Extractor/ChainExtractor.php | 14 ++ .../ETL/Extractor/CollectingExtractor.php | 5 + .../Flow/ETL/Extractor/DataFrameExtractor.php | 5 + .../src/Flow/ETL/Extractor/FeedExtractor.php | 5 + .../src/Flow/ETL/Extractor/FileReading.php | 2 +- .../src/Flow/ETL/Extractor/FilesExtractor.php | 15 +- .../Flow/ETL/Extractor/FooterStatistics.php | 59 ++++++ .../src/Flow/ETL/Extractor/ListedFiles.php | 58 ++++++ .../Flow/ETL/Extractor/MemoryExtractor.php | 8 + .../src/Flow/ETL/Extractor/PathFiltering.php | 50 +++++ .../ETL/Extractor/PathPartitionsExtractor.php | 15 +- .../src/Flow/ETL/Extractor/RowsExtractor.php | 18 ++ .../Flow/ETL/Extractor/SelfDescribingFile.php | 5 + .../Flow/ETL/Extractor/SequenceExtractor.php | 7 + .../DatePeriodSequenceGenerator.php | 8 + .../NumberSequenceGenerator.php | 25 +++ .../SequenceGenerator/SequenceGenerator.php | 3 + .../etl/src/Flow/ETL/Extractor/SourceFile.php | 1 + .../etl/src/Flow/ETL/Extractor/Statistics.php | 20 ++ .../Flow/ETL/Processor/CachingProcessor.php | 2 +- src/core/etl/src/Flow/Floe/FloeExtractor.php | 24 +++ src/core/etl/src/Flow/Floe/FloeMerger.php | 4 +- src/core/etl/src/Flow/Floe/FloeSerializer.php | 4 +- src/core/etl/src/Flow/Floe/FloeSourceFile.php | 10 + .../etl/src/Flow/Floe/FloeStreamReader.php | 12 +- .../etl/src/Flow/Floe/FloeStreamWriter.php | 4 +- src/core/etl/src/Flow/Floe/Footer.php | 19 +- src/core/etl/src/Flow/Floe/Section.php | 2 +- src/core/etl/src/Flow/Floe/Statistics.php | 63 ++++++ .../Flow/ETL/Tests/Context/MemoryFiles.php | 29 +++ .../Flow/ETL/Tests/Double/CountingCache.php | 53 +++++ .../ETL/Tests/Double/CountingExtractor.php | 6 + .../ETL/Tests/Double/DeclaringExtractor.php | 39 ++++ .../Flow/ETL/Tests/Double/EmptyExtractor.php | 6 + .../Flow/ETL/Tests/Double/FakeExtractor.php | 6 + .../Double/FakeRandomOrdersExtractor.php | 6 + .../Tests/Double/FakeSelfDescribingFile.php | 7 + .../Double/FakeStaticOrdersExtractor.php | 6 + .../tests/Flow/ETL/Tests/Double/KeepPaths.php | 36 ++++ .../Tests/Double/MixedSequenceGenerator.php | 6 + .../Tests/Double/OneRowBatchesExtractor.php | 6 + .../ETL/Tests/Double/RecordingExtractor.php | 6 + .../Tests/Double/RecordingFileExtractor.php | 6 + .../Double/RecordingSequenceGenerator.php | 12 +- .../ETL/Tests/Double/RepeatableExtractor.php | 6 + .../ETL/Tests/Double/RowLessExtractor.php | 6 + .../Tests/Double/StopIgnoringExtractor.php | 6 + .../ThrowingAfterFirstBatchExtractor.php | 6 + .../Double/UndescribableRowLessExtractor.php | 6 + .../ETL/Tests/Double/UnsizedFilesystem.php | 85 ++++++++ .../Tests/Double/VaryingBatchesExtractor.php | 6 + .../Tests/Integration/DataFrame/CacheTest.php | 6 + .../Integration/DataFrame/DisplayTest.php | 11 ++ .../Tests/Integration/DataFrame/LimitTest.php | 31 +++ .../Integration/DataFrame/OffsetTest.php | 21 ++ .../Extractor/CacheExtractorTest.php | 74 +++++++ .../Extractor/PathPartitionsExtractorTest.php | 32 +++ .../ETL/Tests/Unit/Cache/CacheIndexTest.php | 54 ++++- .../Flow/ETL/Tests/Unit/CardinalityTest.php | 136 +++++++++++++ .../Flow/ETL/Tests/Unit/DataFrameTest.php | 26 +++ .../ETL/Tests/Unit/ETLErrorHandlingTest.php | 16 ++ .../Unit/Executor/OffsetPipelineTest.php | 21 ++ .../Unit/Extractor/ArrayExtractorTest.php | 27 +++ .../Unit/Extractor/BatchByExtractorTest.php | 18 ++ .../Unit/Extractor/BatchExtractorTest.php | 18 ++ .../Unit/Extractor/ChainExtractorTest.php | 52 +++++ .../Extractor/CollectingExtractorTest.php | 18 ++ .../Unit/Extractor/FilesExtractorTest.php | 46 +++++ .../Unit/Extractor/FooterStatisticsTest.php | 55 ++++++ .../Tests/Unit/Extractor/ListedFilesTest.php | 86 ++++++++ .../Unit/Extractor/MemoryExtractorTest.php | 6 + .../Unit/Extractor/RowsExtractorTest.php | 13 ++ .../Unit/Extractor/SequenceExtractorTest.php | 19 ++ .../DatePeriodSequenceGeneratorTest.php | 83 ++++++++ .../NumberSequenceGeneratorTest.php | 52 +++++ .../Tests/Unit/Extractor/SourceFileTest.php | 10 + .../Tests/Unit/Extractor/StatisticsTest.php | 46 +++++ .../Unit/Processor/CachingProcessorTest.php | 2 + .../Tests/Context/FloeStreamReaderContext.php | 13 ++ .../pre-statistics/heterogeneous.floe | Bin 0 -> 418 bytes .../Tests/Fixtures/v2/all-entry-types.floe | Bin 3508 -> 3533 bytes .../Flow/Floe/Tests/Fixtures/v2/empty.floe | Bin 105 -> 128 bytes .../Floe/Tests/Fixtures/v2/heterogeneous.floe | Bin 418 -> 442 bytes .../Tests/Integration/FloeExtractorTest.php | 152 +++++++++++++++ .../Tests/Integration/FloeHardBreakTest.php | 28 +++ .../Tests/Integration/FloeStatisticsTest.php | 130 +++++++++++++ .../Flow/Floe/Tests/Mother/FooterMother.php | 13 +- .../Floe/Tests/Unit/FloeSerializerTest.php | 4 +- .../Floe/Tests/Unit/FloeStreamWriterTest.php | 6 +- .../Flow/Floe/Tests/Unit/FloeWriterTest.php | 16 +- .../Flow/Floe/Tests/Unit/FooterReaderTest.php | 2 +- .../tests/Flow/Floe/Tests/Unit/FooterTest.php | 46 +++-- .../Flow/Floe/Tests/Unit/SectionTest.php | 9 +- .../Flow/Floe/Tests/Unit/StatisticsTest.php | 51 +++++ .../ETL/Adapter/CSV/RustCSVReaderNative.php | 11 ++ src/extension/flow-php-ext/src/csv/mod.rs | 30 ++- .../flow-php-ext/src/csv/tokenizer.rs | 9 + src/extension/flow-php-ext/src/lib.rs | 5 + .../tests/phpt/054_csv_consumed_bytes.phpt | 88 +++++++++ web/landing/assets/wasm/data/orders.floe | Bin 56407 -> 56434 bytes .../examples/topics/joins/join_each/code.php | 6 + 198 files changed, 6096 insertions(+), 116 deletions(-) create mode 100644 src/adapter/etl-adapter-csv/src/Flow/ETL/Adapter/CSV/CSVSampledFiles.php create mode 100644 src/adapter/etl-adapter-csv/src/Flow/ETL/Adapter/CSV/CSVSampledRows.php create mode 100644 src/adapter/etl-adapter-csv/src/Flow/ETL/Adapter/CSV/CSVSamples.php create mode 100644 src/adapter/etl-adapter-csv/tests/Flow/ETL/Adapter/CSV/Tests/Integration/CSVSamplesTest.php create mode 100644 src/adapter/etl-adapter-csv/tests/Flow/ETL/Adapter/CSV/Tests/Unit/CSVExtractorTest.php create mode 100644 src/adapter/etl-adapter-csv/tests/Flow/ETL/Adapter/CSV/Tests/Unit/CSVSampledFilesTest.php create mode 100644 src/adapter/etl-adapter-csv/tests/Flow/ETL/Adapter/CSV/Tests/Unit/CSVSampledRowsTest.php create mode 100644 src/adapter/etl-adapter-doctrine/src/Flow/ETL/Adapter/Doctrine/Explain/ExplainedRows.php create mode 100644 src/adapter/etl-adapter-doctrine/src/Flow/ETL/Adapter/Doctrine/Explain/MySqlExplainedRows.php create mode 100644 src/adapter/etl-adapter-doctrine/src/Flow/ETL/Adapter/Doctrine/Explain/PostgreSqlExplainedRows.php create mode 100644 src/adapter/etl-adapter-doctrine/tests/Flow/ETL/Adapter/Doctrine/Tests/Context/PlannedTable.php create mode 100644 src/adapter/etl-adapter-doctrine/tests/Flow/ETL/Adapter/Doctrine/Tests/Integration/Explain/ExplainedRowsTest.php create mode 100644 src/adapter/etl-adapter-doctrine/tests/Flow/ETL/Adapter/Doctrine/Tests/Integration/Explain/MySqlExplainedRowsTest.php create mode 100644 src/adapter/etl-adapter-doctrine/tests/Flow/ETL/Adapter/Doctrine/Tests/Integration/Explain/PostgreSqlExplainedRowsTest.php create mode 100644 src/adapter/etl-adapter-excel/src/Flow/ETL/Adapter/Excel/WorkbookSheetSample.php create mode 100644 src/adapter/etl-adapter-excel/tests/Flow/ETL/Adapter/Excel/Tests/Unit/WorkbookSheetSampleTest.php create mode 100644 src/adapter/etl-adapter-json/src/Flow/ETL/Adapter/JSON/JSONMachine/JsonFileSample.php create mode 100644 src/adapter/etl-adapter-json/src/Flow/ETL/Adapter/JSON/JSONMachine/JsonReadBytes.php create mode 100644 src/adapter/etl-adapter-json/src/Flow/ETL/Adapter/JSON/JSONMachine/JsonSampledFiles.php create mode 100644 src/adapter/etl-adapter-json/src/Flow/ETL/Adapter/JSON/JSONMachine/JsonSampledRows.php create mode 100644 src/adapter/etl-adapter-json/src/Flow/ETL/Adapter/JSON/JSONMachine/JsonSamples.php create mode 100644 src/adapter/etl-adapter-json/tests/Flow/ETL/Adapter/JSON/Tests/Integration/JSONMachine/JsonFileSampleTest.php create mode 100644 src/adapter/etl-adapter-json/tests/Flow/ETL/Adapter/JSON/Tests/Integration/JSONMachine/JsonSamplesTest.php create mode 100644 src/adapter/etl-adapter-json/tests/Flow/ETL/Adapter/JSON/Tests/Unit/JSONMachine/JsonReadBytesTest.php create mode 100644 src/adapter/etl-adapter-json/tests/Flow/ETL/Adapter/JSON/Tests/Unit/JSONMachine/JsonSampledFilesTest.php create mode 100644 src/adapter/etl-adapter-json/tests/Flow/ETL/Adapter/JSON/Tests/Unit/JSONMachine/JsonSampledRowsTest.php create mode 100644 src/adapter/etl-adapter-parquet/tests/Flow/ETL/Adapter/Parquet/Tests/Unit/ParquetExtractorTest.php create mode 100644 src/adapter/etl-adapter-postgresql/src/Flow/ETL/Adapter/PostgreSql/ExplainedRows.php create mode 100644 src/adapter/etl-adapter-postgresql/tests/Flow/ETL/Adapter/PostgreSql/Tests/Unit/ExplainedRowsTest.php create mode 100644 src/adapter/etl-adapter-xml/tests/Flow/ETL/Adapter/XML/Tests/Fixtures/listed/a.xml create mode 100644 src/adapter/etl-adapter-xml/tests/Flow/ETL/Adapter/XML/Tests/Fixtures/listed/b.xml create mode 100644 src/core/etl/src/Flow/ETL/Cardinality.php create mode 100644 src/core/etl/src/Flow/ETL/Extractor/FooterStatistics.php create mode 100644 src/core/etl/src/Flow/ETL/Extractor/ListedFiles.php create mode 100644 src/core/etl/src/Flow/ETL/Extractor/Statistics.php create mode 100644 src/core/etl/src/Flow/Floe/Statistics.php create mode 100644 src/core/etl/tests/Flow/ETL/Tests/Context/MemoryFiles.php create mode 100644 src/core/etl/tests/Flow/ETL/Tests/Double/CountingCache.php create mode 100644 src/core/etl/tests/Flow/ETL/Tests/Double/DeclaringExtractor.php create mode 100644 src/core/etl/tests/Flow/ETL/Tests/Double/KeepPaths.php create mode 100644 src/core/etl/tests/Flow/ETL/Tests/Double/UnsizedFilesystem.php create mode 100644 src/core/etl/tests/Flow/ETL/Tests/Unit/CardinalityTest.php create mode 100644 src/core/etl/tests/Flow/ETL/Tests/Unit/Extractor/FooterStatisticsTest.php create mode 100644 src/core/etl/tests/Flow/ETL/Tests/Unit/Extractor/ListedFilesTest.php create mode 100644 src/core/etl/tests/Flow/ETL/Tests/Unit/Extractor/SequenceGenerator/DatePeriodSequenceGeneratorTest.php create mode 100644 src/core/etl/tests/Flow/ETL/Tests/Unit/Extractor/SequenceGenerator/NumberSequenceGeneratorTest.php create mode 100644 src/core/etl/tests/Flow/ETL/Tests/Unit/Extractor/StatisticsTest.php create mode 100644 src/core/etl/tests/Flow/Floe/Tests/Fixtures/pre-statistics/heterogeneous.floe create mode 100644 src/core/etl/tests/Flow/Floe/Tests/Integration/FloeExtractorTest.php create mode 100644 src/core/etl/tests/Flow/Floe/Tests/Integration/FloeHardBreakTest.php create mode 100644 src/core/etl/tests/Flow/Floe/Tests/Integration/FloeStatisticsTest.php create mode 100644 src/core/etl/tests/Flow/Floe/Tests/Unit/StatisticsTest.php create mode 100644 src/extension/flow-php-ext/tests/phpt/054_csv_consumed_bytes.phpt diff --git a/documentation/components/core/floe.md b/documentation/components/core/floe.md index 95e44491a7..9c2d84ca54 100644 --- a/documentation/components/core/floe.md +++ b/documentation/components/core/floe.md @@ -355,11 +355,15 @@ scanning rows**: "schema": { /* the file's single schema */ }, "sections": [ { "offset": 6, "partitionsId": 0, "rowCount": 2 } ], "partitions": [ { "country": "PL" } ], - "totalRows": 2, + "statistics": { "rows": 2, "byteSize": 118 }, "metadata": { /* typed key/value, Schema\Metadata */ } } ``` +- **`statistics`** holds the file's row count and the uncompressed bytes of its data frames (header and + footer excluded), so a reader can size the file before reading a row. The footer and its sections + ignore keys they do not know, so a field added later does not break an older reader. + - **`sections`** map a byte `offset` → `partitionsId` + `rowCount`, so a reader can skip whole sections (offset/limit pushdown) and know each section's partition combination up front. Sections bound partition combinations and appends only; every section shares the file's one schema. diff --git a/documentation/upgrading.md b/documentation/upgrading.md index dca41dedcf..605bd841a5 100644 --- a/documentation/upgrading.md +++ b/documentation/upgrading.md @@ -238,6 +238,40 @@ after it shift by one. `Stage::physical` is new - see the core documentation. | `CSVFileReader::samples()` yields `Generator`s | yields `CSVFileSample` (`IteratorAggregate`); `$unit->getIterator()` for the generator | | `CSVFileReader::sample($source)` | `new CSVFileSample($opener, $source)` | +### 24) `flow-php/etl` - Floe footer carries a statistics block, existing `.floe` files must be rewritten + +| Before | After | +|-------------------------------------------------|-----------------------------------------------------------------| +| footer key `totalRows` | `statistics.rows` | +| - | `statistics.byteSize` - uncompressed data bytes | +| footer/section parsers rejected any unknown key | unknown keys ignored, so later additions are not a break | +| reading a file written by 0.44.x | `Floe footer is malformed: ... "statistics"` - rewrite the file | + +Header version stays `0x02`: the change is in the footer, not the frame layout. Files written by 0.44.x +cannot be read. Regenerate them from their source, or export them with 0.44.x to another format before +upgrading. + +### 25) `flow-php/etl` - every `Extractor` declares `statistics()` + +| Before | After | +|---------------------------------------------------------|-------------------------------------------------------------------------------------------------| +| `Extractor`: `extract()`, `schema()`, `withSchema()` | `+ statistics(): Statistics` - a custom extractor that knows nothing returns `new Statistics()` | +| `SequenceGenerator`: `generate()` | `+ rows(): Cardinality` - how many items `generate()` yields | +| `SelfDescribingFile`: `close()`, `schema()`, `source()` | `+ statistics(): Statistics` - that one file's rows and bytes from its own metadata | +| `CacheIndex` rows: `key` | `key`, `rows` - an index written by 0.44.x reads fine, its row count is unknown | +| `JsonFileReader::samples()` yields `Generator`s | yields `JsonFileSample` (`IteratorAggregate`) | +| `WorkbookSampler::samples()` yields `Generator`s | yields `WorkbookSheetSample` (`IteratorAggregate`) | + +```php +final class MyExtractor implements Extractor +{ + public function statistics(): Statistics + { + return new Statistics(); + } +} +``` + --- ## Upgrading from 0.43.x to 0.44.x diff --git a/src/adapter/etl-adapter-avro/src/Flow/ETL/Adapter/Avro/FlixTech/AvroExtractor.php b/src/adapter/etl-adapter-avro/src/Flow/ETL/Adapter/Avro/FlixTech/AvroExtractor.php index 5bc3921307..e1c982a389 100644 --- a/src/adapter/etl-adapter-avro/src/Flow/ETL/Adapter/Avro/FlixTech/AvroExtractor.php +++ b/src/adapter/etl-adapter-avro/src/Flow/ETL/Adapter/Avro/FlixTech/AvroExtractor.php @@ -9,6 +9,7 @@ use Flow\ETL\Extractor; use Flow\ETL\Extractor\FileExtractor; use Flow\ETL\Extractor\PathFiltering; +use Flow\ETL\Extractor\Statistics; use Flow\ETL\FlowContext; use Flow\ETL\Rows; use Flow\ETL\Schema; @@ -65,4 +66,9 @@ public function withSchema(Schema $schema): static return $this; } + + public function statistics(): Statistics + { + return new Statistics(); + } } diff --git a/src/adapter/etl-adapter-csv/src/Flow/ETL/Adapter/CSV/CSVExtractor.php b/src/adapter/etl-adapter-csv/src/Flow/ETL/Adapter/CSV/CSVExtractor.php index 534ebc7af9..229bde0285 100644 --- a/src/adapter/etl-adapter-csv/src/Flow/ETL/Adapter/CSV/CSVExtractor.php +++ b/src/adapter/etl-adapter-csv/src/Flow/ETL/Adapter/CSV/CSVExtractor.php @@ -4,6 +4,7 @@ namespace Flow\ETL\Adapter\CSV; +use Flow\ETL\Cardinality; use Flow\ETL\Exception\InferredSchemaException; use Flow\ETL\Exception\InvalidArgumentException; use Flow\ETL\Extractor; @@ -12,9 +13,11 @@ use Flow\ETL\Extractor\FileExtractor; use Flow\ETL\Extractor\FileReading; use Flow\ETL\Extractor\InfersSchema; +use Flow\ETL\Extractor\ListedFiles; use Flow\ETL\Extractor\MetadataColumnsExtractor; use Flow\ETL\Extractor\RewindableExtractor; use Flow\ETL\Extractor\Signal; +use Flow\ETL\Extractor\Statistics; use Flow\ETL\FlowContext; use Flow\ETL\Row\RawRowValues; use Flow\ETL\Rows; @@ -52,10 +55,19 @@ final class CSVExtractor implements private CSVReadOptions $readOptions; + /** + * What the last schema inference sampled; null until one ran. + */ + private ?CSVSampledFiles $sampled = null; + private ?Schema $schema = null; + private ?Statistics $statistics = null; + private readonly Filesystem $filesystem; + private ?ListedFiles $listed = null; + public function __construct( private readonly Path $path, Filesystem $filesystem = new NativeLocalFilesystem(), @@ -99,11 +111,13 @@ public function extract(FlowContext $context, ?int $limit = null, Filter $pathFi $derived = $this->derivedSchema; if ($derived === null) { + $samples = new CSVSamples($reader->samples($this->inference->sampleSize)); $derived = $this->derivedSchema = (new SchemaInferrer( $this->inference, new StringTypeNarrower($this->inference->candidates()->toArray()), - ))->infer($reader->header()->names, $reader->samples($this->inference->sampleSize)); + ))->infer($reader->header()->names, $samples); + $this->sampled = $samples->sampledFiles(); } $base = $fileColumns->withoutTail($derived); @@ -184,6 +198,8 @@ public function inferSchema(SchemaInferenceBuilder $builder): static { $this->inference = $builder->build(); $this->derivedSchema = null; + $this->sampled = null; + $this->statistics = null; return $this; } @@ -205,11 +221,13 @@ public function schema(): Schema iterator_to_array($this->sourceFiles($this->filesystem, $this->path), false), ); + $samples = new CSVSamples($reader->samples($this->inference->sampleSize)); $derived = $this->derivedSchema = (new SchemaInferrer( $this->inference, new StringTypeNarrower($this->inference->candidates()->toArray()), - ))->infer($reader->header()->names, $reader->samples($this->inference->sampleSize)); + ))->infer($reader->header()->names, $samples); + $this->sampled = $samples->sampledFiles(); } return $fileColumns->declare($fileColumns->withoutTail($derived)); @@ -225,9 +243,34 @@ public function source(): Path return $this->path; } + /** + * Rows come from the sample schema inference already read; without one they are unknown, a sniff is never run + * just for them. + */ + public function statistics(): Statistics + { + if ($this->statistics !== null) { + return $this->statistics; + } + + $this->listed ??= ListedFiles::of($this->sourceFiles($this->filesystem, $this->path)); + $size = $this->listed->bytes; + + if ($this->sampled === null) { + return new Statistics(rows: Cardinality::unknown(), size: $size); + } + + return $this->statistics = new Statistics( + rows: $this->sampled->estimatedRows($this->listed->count, $size), + size: $size, + ); + } + public function withBOMRemoval(bool $removeBOM): self { $this->derivedSchema = null; + $this->sampled = null; + $this->statistics = null; $this->readOptions = $this->readOptions->withRemoveBOM($removeBOM); return $this; @@ -243,6 +286,8 @@ public function withCharactersReadInLine(int $charactersReadInLine): self } $this->derivedSchema = null; + $this->sampled = null; + $this->statistics = null; $this->readOptions = $this->readOptions->withCharactersReadInLine($charactersReadInLine); return $this; @@ -251,6 +296,8 @@ public function withCharactersReadInLine(int $charactersReadInLine): self public function withEmptyToNull(bool $emptyToNull): self { $this->derivedSchema = null; + $this->sampled = null; + $this->statistics = null; $this->readOptions = $this->readOptions->withEmptyToNull($emptyToNull); return $this; @@ -259,6 +306,8 @@ public function withEmptyToNull(bool $emptyToNull): self public function withEnclosure(string $enclosure): self { $this->derivedSchema = null; + $this->sampled = null; + $this->statistics = null; $this->readOptions = $this->readOptions->withEnclosure($enclosure); return $this; @@ -267,6 +316,8 @@ public function withEnclosure(string $enclosure): self public function withEscape(string $escape): self { $this->derivedSchema = null; + $this->sampled = null; + $this->statistics = null; $this->readOptions = $this->readOptions->withEscape($escape); return $this; @@ -275,6 +326,8 @@ public function withEscape(string $escape): self public function withHeader(bool $withHeader): self { $this->derivedSchema = null; + $this->sampled = null; + $this->statistics = null; $this->readOptions = $this->readOptions->withHeader($withHeader); return $this; @@ -290,6 +343,8 @@ public function withSchema(Schema $schema): static public function withSeparator(string $separator): self { $this->derivedSchema = null; + $this->sampled = null; + $this->statistics = null; $this->readOptions = $this->readOptions->withSeparator($separator); return $this; diff --git a/src/adapter/etl-adapter-csv/src/Flow/ETL/Adapter/CSV/CSVFileSample.php b/src/adapter/etl-adapter-csv/src/Flow/ETL/Adapter/CSV/CSVFileSample.php index 10bb522b1c..719409c244 100644 --- a/src/adapter/etl-adapter-csv/src/Flow/ETL/Adapter/CSV/CSVFileSample.php +++ b/src/adapter/etl-adapter-csv/src/Flow/ETL/Adapter/CSV/CSVFileSample.php @@ -16,11 +16,13 @@ /** * @implements IteratorAggregate */ -final readonly class CSVFileSample implements IteratorAggregate, SniffsColumnTypes +final class CSVFileSample implements IteratorAggregate, SniffsColumnTypes { + private ?CSVSampledRows $sampled = null; + public function __construct( - private CSVSourceOpener $opener, - private SourceFile $source, + private readonly CSVSourceOpener $opener, + private readonly SourceFile $source, ) {} /** @@ -48,9 +50,26 @@ public function sniffColumnTypes( $open = $this->opener->open($this->source); try { - return $open->sniff($names, $rowBudget, $inference, $typer); + $columnTypes = $open->sniff($names, $rowBudget, $inference, $typer); + + // the byte count stays out of ColumnTypes: SniffsColumnTypes is an inference contract, statistics are not + $this->sampled = new CSVSampledRows( + $open->producedRows(), + $open->producedBytes(), + $rowBudget === -1 || $columnTypes->rows() < $rowBudget, + ); + + return $columnTypes; } finally { $open->close(); } } + + /** + * What the last sniffColumnTypes() read; null until one ran. + */ + public function sampled(): ?CSVSampledRows + { + return $this->sampled; + } } diff --git a/src/adapter/etl-adapter-csv/src/Flow/ETL/Adapter/CSV/CSVLineReader.php b/src/adapter/etl-adapter-csv/src/Flow/ETL/Adapter/CSV/CSVLineReader.php index 482134978c..c0441f8af4 100644 --- a/src/adapter/etl-adapter-csv/src/Flow/ETL/Adapter/CSV/CSVLineReader.php +++ b/src/adapter/etl-adapter-csv/src/Flow/ETL/Adapter/CSV/CSVLineReader.php @@ -10,25 +10,43 @@ use function rtrim; use function str_contains; use function str_starts_with; +use function strlen; use function substr; -final readonly class CSVLineReader +final class CSVLineReader { - private CSVRecordBoundary $boundary; + private readonly CSVRecordBoundary $boundary; + + /** + * @var int<0, max> + */ + private int $lastRecordBytes = 0; /** * @param null|int<1, max> $charactersReadInLine */ public function __construct( - private string $enclosure, + private readonly string $enclosure, string $separator = ',', string $escape = '\\', - private ?int $charactersReadInLine = null, - private bool $removeBOM = true, + private readonly ?int $charactersReadInLine = null, + private readonly bool $removeBOM = true, ) { $this->boundary = new CSVRecordBoundary($enclosure, $separator, $escape); } + /** + * The bytes the record readLines() yielded last took in the stream, line endings and BOM included. + * SourceStream::readLines() cannot tell whether the last line ended with "\n", so a last record without one is + * counted one byte too long. + * + * @return int<0, max> + */ + public function lastRecordBytes(): int + { + return $this->lastRecordBytes; + } + /** * @return \Generator */ @@ -36,9 +54,12 @@ public function readLines(SourceStream $stream): Generator { $lineNumber = 0; $buffer = ''; + $bytes = 0; foreach ($stream->readLines(length: $this->charactersReadInLine) as $rawLine) { $buffer .= $rawLine; + $bytes += strlen($rawLine) + 1; + $this->lastRecordBytes = $bytes; if (!str_contains($buffer, $this->enclosure)) { yield $this->removeBOM && $lineNumber === 0 @@ -46,6 +67,7 @@ public function readLines(SourceStream $stream): Generator : rtrim($buffer, "\r\n"); $lineNumber++; $buffer = ''; + $bytes = 0; } else { if ($this->boundary->isComplete($buffer)) { yield $this->removeBOM && $lineNumber === 0 @@ -53,6 +75,7 @@ public function readLines(SourceStream $stream): Generator : rtrim($buffer, "\r\n"); $lineNumber++; $buffer = ''; + $bytes = 0; } else { $buffer .= "\n"; } diff --git a/src/adapter/etl-adapter-csv/src/Flow/ETL/Adapter/CSV/CSVOpenSource.php b/src/adapter/etl-adapter-csv/src/Flow/ETL/Adapter/CSV/CSVOpenSource.php index e030ac48df..49b35a7200 100644 --- a/src/adapter/etl-adapter-csv/src/Flow/ETL/Adapter/CSV/CSVOpenSource.php +++ b/src/adapter/etl-adapter-csv/src/Flow/ETL/Adapter/CSV/CSVOpenSource.php @@ -14,6 +14,20 @@ interface CSVOpenSource { public function close(): void; + /** + * The bytes, as read, of the rows producedRows() counts: line endings included, the header record excluded. + * + * @return int<0, max> + */ + public function producedBytes(): int; + + /** + * Rows read from the source so far by records() or sniff() - it may be more than a consumer took. + * + * @return int<0, max> + */ + public function producedRows(): int; + /** * This instance is consumed afterwards. * diff --git a/src/adapter/etl-adapter-csv/src/Flow/ETL/Adapter/CSV/CSVSampledFiles.php b/src/adapter/etl-adapter-csv/src/Flow/ETL/Adapter/CSV/CSVSampledFiles.php new file mode 100644 index 0000000000..6e7062a08d --- /dev/null +++ b/src/adapter/etl-adapter-csv/src/Flow/ETL/Adapter/CSV/CSVSampledFiles.php @@ -0,0 +1,44 @@ +whole && $this->files === $listedFiles) { + return Cardinality::exact($this->rows); + } + + if ($listedBytes->estimate === null || $this->rows === 0 || $this->bytes === 0) { + return Cardinality::unknown(); + } + + return Cardinality::approximately( + (int) round($listedBytes->estimate / ($this->bytes / $this->rows)), + Cardinality::DEFAULT_RELATIVE_ERROR, + ); + } +} diff --git a/src/adapter/etl-adapter-csv/src/Flow/ETL/Adapter/CSV/CSVSampledRows.php b/src/adapter/etl-adapter-csv/src/Flow/ETL/Adapter/CSV/CSVSampledRows.php new file mode 100644 index 0000000000..312f05384b --- /dev/null +++ b/src/adapter/etl-adapter-csv/src/Flow/ETL/Adapter/CSV/CSVSampledRows.php @@ -0,0 +1,40 @@ + + */ + public int $bytes; + + /** + * @var int<0, max> + */ + public int $rows; + + /** + * @param int $rows rows read while sniffing + * @param int $bytes bytes those rows took as read, line endings included + * @param bool $wholeFile the sniff reached the end of the file, so $rows is every row it holds + */ + public function __construct( + int $rows, + int $bytes, + public bool $wholeFile, + ) { + if ($rows < 0 || $bytes < 0) { + throw new InvalidArgumentException( + 'Sampled rows and bytes must not be negative, given: ' . $rows . ' rows, ' . $bytes . ' bytes', + ); + } + + $this->rows = $rows; + $this->bytes = $bytes; + } +} diff --git a/src/adapter/etl-adapter-csv/src/Flow/ETL/Adapter/CSV/CSVSamples.php b/src/adapter/etl-adapter-csv/src/Flow/ETL/Adapter/CSV/CSVSamples.php new file mode 100644 index 0000000000..e6533e5215 --- /dev/null +++ b/src/adapter/etl-adapter-csv/src/Flow/ETL/Adapter/CSV/CSVSamples.php @@ -0,0 +1,63 @@ + + */ +final class CSVSamples implements IteratorAggregate +{ + /** + * @var list + */ + private array $pulled = []; + + /** + * @param iterable $samples + */ + public function __construct( + private readonly iterable $samples, + ) {} + + /** + * Keeps only the samples the consumer pulled: a listing the inference stopped short of costs nothing. + * + * @return Generator + */ + public function getIterator(): Generator + { + foreach ($this->samples as $sample) { + $this->pulled[] = $sample; + + yield $sample; + } + } + + public function sampledFiles(): CSVSampledFiles + { + $files = 0; + $rows = 0; + $bytes = 0; + $whole = true; + + foreach ($this->pulled as $sample) { + $sampled = $sample->sampled(); + + if ($sampled === null) { + continue; + } + + $files++; + $rows += $sampled->rows; + $bytes += $sampled->bytes; + $whole = $whole && $sampled->wholeFile; + } + + return new CSVSampledFiles($files, $rows, $bytes, $whole); + } +} diff --git a/src/adapter/etl-adapter-csv/src/Flow/ETL/Adapter/CSV/NativeCSVOpenSource.php b/src/adapter/etl-adapter-csv/src/Flow/ETL/Adapter/CSV/NativeCSVOpenSource.php index ab3da4a2c1..12b826a463 100644 --- a/src/adapter/etl-adapter-csv/src/Flow/ETL/Adapter/CSV/NativeCSVOpenSource.php +++ b/src/adapter/etl-adapter-csv/src/Flow/ETL/Adapter/CSV/NativeCSVOpenSource.php @@ -19,6 +19,7 @@ use function array_map; use function array_values; use function class_exists; +use function count; use function extension_loaded; use function Flow\Types\DSL\type_boolean; use function Flow\Types\DSL\type_date; @@ -32,7 +33,7 @@ use function Flow\Types\DSL\type_xml; use function max; -final readonly class NativeCSVOpenSource implements CSVOpenSource +final class NativeCSVOpenSource implements CSVOpenSource { /** * Each chunk is held as a PHP string, so it sets the path's peak memory. 32 KB measured 12.0 MB real peak - the PHP @@ -46,14 +47,19 @@ /** * @var int<1, max> */ - private int $chunkSize; + private readonly int $chunkSize; + + /** + * @var int<0, max> + */ + private int $producedRows = 0; /** * @param null|int<1, max> $charactersReadInLine */ public function __construct( - private SourceStream $stream, - private RustCSVReaderNative $reader, + private readonly SourceStream $stream, + private readonly RustCSVReaderNative $reader, ?int $charactersReadInLine = null, ) { // a local file ignores the read length, as NativeLocalSourceStream::readLines() already does on the PHP path; @@ -90,12 +96,24 @@ public function columns(): array return $this->reader->headers(); } + public function producedBytes(): int + { + return $this->reader->consumedBytes(); + } + + public function producedRows(): int + { + return $this->producedRows; + } + public function records(): Generator { foreach ($this->stream->iterate($this->chunkSize) as $chunk) { $this->reader->feed($chunk); while (($batch = $this->reader->next(self::BATCH)) !== []) { + $this->producedRows += count($batch); + foreach ($batch as $values) { yield $values; } @@ -105,6 +123,8 @@ public function records(): Generator $this->reader->finish(); while (($batch = $this->reader->next(self::BATCH)) !== []) { + $this->producedRows += count($batch); + foreach ($batch as $values) { yield $values; } @@ -137,7 +157,10 @@ public function sniff(array $names, int $rowBudget, SchemaInference $inference, foreach ($this->stream->iterate($this->chunkSize) as $chunk) { $this->reader->feed($chunk); - $this->reader->fold($fold, $rowBudget === -1 ? -1 : max(0, $rowBudget - $fold->rows())); + $this->producedRows += $this->reader->fold( + $fold, + $rowBudget === -1 ? -1 : max(0, $rowBudget - $fold->rows()), + ); if ($rowBudget !== -1 && $fold->rows() >= $rowBudget) { break; @@ -146,7 +169,10 @@ public function sniff(array $names, int $rowBudget, SchemaInference $inference, if ($rowBudget === -1 || $fold->rows() < $rowBudget) { $this->reader->finish(); - $this->reader->fold($fold, $rowBudget === -1 ? -1 : max(0, $rowBudget - $fold->rows())); + $this->producedRows += $this->reader->fold( + $fold, + $rowBudget === -1 ? -1 : max(0, $rowBudget - $fold->rows()), + ); } return ColumnTypes::fromColumnTypes( diff --git a/src/adapter/etl-adapter-csv/src/Flow/ETL/Adapter/CSV/PhpCSVOpenSource.php b/src/adapter/etl-adapter-csv/src/Flow/ETL/Adapter/CSV/PhpCSVOpenSource.php index 5af14d0595..f352def658 100644 --- a/src/adapter/etl-adapter-csv/src/Flow/ETL/Adapter/CSV/PhpCSVOpenSource.php +++ b/src/adapter/etl-adapter-csv/src/Flow/ETL/Adapter/CSV/PhpCSVOpenSource.php @@ -12,12 +12,22 @@ use Flow\Types\Type\TypeNarrower; use Generator; -final readonly class PhpCSVOpenSource implements CSVOpenSource +final class PhpCSVOpenSource implements CSVOpenSource { + /** + * @var int<0, max> + */ + private int $producedBytes = 0; + + /** + * @var int<0, max> + */ + private int $producedRows = 0; + public function __construct( - private SourceStream $stream, - private CSVEncoder $encoder, - private CSVLineReader $lineReader, + private readonly SourceStream $stream, + private readonly CSVEncoder $encoder, + private readonly CSVLineReader $lineReader, ) {} public function close(): void @@ -51,11 +61,24 @@ public function records(): Generator { foreach ($this->lineReader->readLines($this->stream) as $line) { foreach ($this->encoder->decode([$line]) as $values) { + $this->producedBytes += $this->lineReader->lastRecordBytes(); + $this->producedRows++; + yield $values; } } } + public function producedBytes(): int + { + return $this->producedBytes; + } + + public function producedRows(): int + { + return $this->producedRows; + } + public function sniff(array $names, int $rowBudget, SchemaInference $inference, TypeNarrower $typer): ColumnTypes { return (new SchemaInferrer($inference, $typer))->sniff($names, $this->records(), $rowBudget); diff --git a/src/adapter/etl-adapter-csv/tests/Flow/ETL/Adapter/CSV/Tests/Context/CSVFixtureContext.php b/src/adapter/etl-adapter-csv/tests/Flow/ETL/Adapter/CSV/Tests/Context/CSVFixtureContext.php index c01f3cda5b..6f73770427 100644 --- a/src/adapter/etl-adapter-csv/tests/Flow/ETL/Adapter/CSV/Tests/Context/CSVFixtureContext.php +++ b/src/adapter/etl-adapter-csv/tests/Flow/ETL/Adapter/CSV/Tests/Context/CSVFixtureContext.php @@ -38,7 +38,10 @@ use function Flow\Filesystem\DSL\memory_filesystem; use function Flow\Filesystem\DSL\path; use function Flow\Filesystem\DSL\path_real; +use function range; +use function sprintf; use function str_ends_with; +use function str_repeat; use function strlen; use function substr; @@ -243,6 +246,36 @@ public static function phpRecords(string $fixture): Generator } } + /** + * A 1000-row file whose first data row is 4 bytes and every other one 101 - 100 914 bytes in total. + */ + public static function narrowFirstRow(): string + { + $content = "id,payload\n1,a\n"; + + foreach (range(2, 1000) as $id) { + $content .= sprintf("%04d,%s\n", $id, str_repeat('x', 95)); + } + + return $content; + } + + /** + * An 8-byte header and $rows rows of exactly 10 bytes each. + * + * @param int<1, 999> $rows + */ + public static function tenByteRows(int $rows): string + { + $content = "id,name\n"; + + foreach (range(1, $rows) as $id) { + $content .= sprintf("%03d,abcde\n", $id); + } + + return $content; + } + public static function memory(string $content): MemoryFilesystem { $filesystem = memory_filesystem(); diff --git a/src/adapter/etl-adapter-csv/tests/Flow/ETL/Adapter/CSV/Tests/Integration/CSVFileSampleTest.php b/src/adapter/etl-adapter-csv/tests/Flow/ETL/Adapter/CSV/Tests/Integration/CSVFileSampleTest.php index c20e5b80ba..6208c55566 100644 --- a/src/adapter/etl-adapter-csv/tests/Flow/ETL/Adapter/CSV/Tests/Integration/CSVFileSampleTest.php +++ b/src/adapter/etl-adapter-csv/tests/Flow/ETL/Adapter/CSV/Tests/Integration/CSVFileSampleTest.php @@ -4,7 +4,9 @@ namespace Flow\ETL\Adapter\CSV\Tests\Integration; +use Flow\ETL\Adapter\CSV\CSVSampledRows; use Flow\ETL\Adapter\CSV\Tests\Context\CSVFixtureContext; +use Flow\ETL\Schema\Inference\SchemaInference; use Flow\ETL\Schema\Inference\TypeFloor; use Flow\ETL\Tests\Double\CountingFilesystem; use Flow\ETL\Tests\FlowTestCase; @@ -90,4 +92,36 @@ public function test_sniffing_narrows_with_the_narrower_it_is_handed(): void $sniffed->schema(new TypeFloor($inference->candidates())), ); } + + public function test_nothing_is_sampled_until_a_sniff_ran(): void + { + static::assertNull(CSVFixtureContext::sample('five_rows.csv')->sampled()); + } + + public function test_a_sniff_that_reached_the_end_sampled_the_whole_file(): void + { + $sample = CSVFixtureContext::sample('five_rows.csv'); + + $sample->sniffColumnTypes(['id', 'name'], 10, new SchemaInference(), new StringTypeNarrower()); + + static::assertEquals(new CSVSampledRows(5, 20, true), $sample->sampled()); + } + + public function test_a_sniff_without_a_budget_sampled_the_whole_file(): void + { + $sample = CSVFixtureContext::sample('five_rows.csv'); + + $sample->sniffColumnTypes(['id', 'name'], -1, new SchemaInference(), new StringTypeNarrower()); + + static::assertEquals(new CSVSampledRows(5, 20, true), $sample->sampled()); + } + + public function test_a_sniff_stopped_by_its_budget_did_not_sample_the_whole_file(): void + { + $sample = CSVFixtureContext::sample('five_rows.csv'); + + $sample->sniffColumnTypes(['id', 'name'], 5, new SchemaInference(), new StringTypeNarrower()); + + static::assertEquals(new CSVSampledRows(5, 20, false), $sample->sampled()); + } } diff --git a/src/adapter/etl-adapter-csv/tests/Flow/ETL/Adapter/CSV/Tests/Integration/CSVSamplesTest.php b/src/adapter/etl-adapter-csv/tests/Flow/ETL/Adapter/CSV/Tests/Integration/CSVSamplesTest.php new file mode 100644 index 0000000000..87e2b6b233 --- /dev/null +++ b/src/adapter/etl-adapter-csv/tests/Flow/ETL/Adapter/CSV/Tests/Integration/CSVSamplesTest.php @@ -0,0 +1,68 @@ +sniffColumnTypes(['id', 'name'], -1, new SchemaInference(), new StringTypeNarrower()); + } + + static::assertEquals(new CSVSampledFiles(2, 10, 40, true), $samples->sampledFiles()); + } + + public function test_samples_past_the_last_pulled_one_are_never_created(): void + { + $created = 0; + $samples = new CSVSamples( + ( + /** @return Generator */ + static function () use (&$created): Generator { + foreach (['five_rows.csv', 'five_rows.csv', 'five_rows.csv'] as $fixture) { + $created++; + + yield CSVFixtureContext::sample($fixture); + } + } + )(), + ); + + foreach ($samples as $sample) { + $sample->sniffColumnTypes(['id', 'name'], -1, new SchemaInference(), new StringTypeNarrower()); + + break; + } + + static::assertSame(1, $created); + static::assertEquals(new CSVSampledFiles(1, 5, 20, true), $samples->sampledFiles()); + } + + public function test_a_pulled_sample_that_was_never_sniffed_is_not_counted(): void + { + $samples = new CSVSamples([CSVFixtureContext::sample('five_rows.csv')]); + + iterator_to_array($samples, false); + + static::assertEquals(new CSVSampledFiles(0, 0, 0, true), $samples->sampledFiles()); + } +} diff --git a/src/adapter/etl-adapter-csv/tests/Flow/ETL/Adapter/CSV/Tests/Integration/NativeCSVOpenSourceTest.php b/src/adapter/etl-adapter-csv/tests/Flow/ETL/Adapter/CSV/Tests/Integration/NativeCSVOpenSourceTest.php index 34bd1d36b6..6cae0ede10 100644 --- a/src/adapter/etl-adapter-csv/tests/Flow/ETL/Adapter/CSV/Tests/Integration/NativeCSVOpenSourceTest.php +++ b/src/adapter/etl-adapter-csv/tests/Flow/ETL/Adapter/CSV/Tests/Integration/NativeCSVOpenSourceTest.php @@ -10,6 +10,7 @@ use Flow\ETL\Adapter\CSV\Tests\Context\CSVFixtureContext; use Flow\ETL\Adapter\CSV\Tests\Double\LengthCapturingSourceStream; use Flow\ETL\Schema\Inference\InferredTypes; +use Flow\ETL\Schema\Inference\SchemaInference; use Flow\ETL\Schema\Inference\SchemaInferrer; use Flow\ETL\Schema\Inference\TypeFloor; use Flow\ETL\Tests\Double\CountingFilesystem; @@ -332,4 +333,56 @@ public function test_columns_of_a_header_without_a_trailing_newline(): void )->columns(), ); } + + public function test_produced_rows_and_bytes_exclude_the_header(): void + { + if (!NativeCSVOpenSource::isSupported()) { + static::markTestSkipped('flow_php extension with RustCSVReaderNative is not loaded'); + } + + $open = CSVFixtureContext::openNative('five_rows.csv'); + + try { + iterator_to_array($open->records(), false); + + static::assertSame(5, $open->producedRows()); + static::assertSame(20, $open->producedBytes()); + } finally { + $open->close(); + } + } + + /** + * Samples that stop before EOF: SourceStream::readLines() hides whether the last line ended with "\n", so the PHP + * path counts a last record without one a byte longer than the native path does (CSVLineReader::lastRecordBytes()). + */ + #[TestWith(['annual-enterprise-survey-2019-financial-year-provisional-csv.csv'])] + #[TestWith(['orders_flow.csv'])] + #[TestWith(['five_rows.csv'])] + #[TestWith(['fold_traps.csv'])] + #[TestWith(['ragged.csv'])] + public function test_php_and_native_paths_agree_on_bytes_per_row(string $fixture): void + { + if (!NativeCSVOpenSource::isSupported()) { + static::markTestSkipped('flow_php extension with RustCSVReaderNative is not loaded'); + } + + $header = CSVFixtureContext::openPhp($fixture); + $names = $header->columns(); + $header->close(); + $php = CSVFixtureContext::openPhp($fixture); + $native = CSVFixtureContext::openNative($fixture); + $typer = new StringTypeNarrower(infer_schema()->build()->candidates()->toArray()); + + try { + $php->sniff($names, 2, new SchemaInference(), $typer); + $native->sniff($names, 2, new SchemaInference(), $typer); + + static::assertSame($php->producedRows(), $native->producedRows()); + static::assertSame($php->producedBytes(), $native->producedBytes()); + } finally { + $php->close(); + $native->close(); + } + } } diff --git a/src/adapter/etl-adapter-csv/tests/Flow/ETL/Adapter/CSV/Tests/Integration/PhpCSVOpenSourceTest.php b/src/adapter/etl-adapter-csv/tests/Flow/ETL/Adapter/CSV/Tests/Integration/PhpCSVOpenSourceTest.php index debe8ca9b9..79f52fcd22 100644 --- a/src/adapter/etl-adapter-csv/tests/Flow/ETL/Adapter/CSV/Tests/Integration/PhpCSVOpenSourceTest.php +++ b/src/adapter/etl-adapter-csv/tests/Flow/ETL/Adapter/CSV/Tests/Integration/PhpCSVOpenSourceTest.php @@ -101,4 +101,18 @@ public function test_records_yields_one_logical_record_per_iteration(): void $open->close(); } } + + public function test_produced_rows_and_bytes_exclude_the_header(): void + { + $open = CSVFixtureContext::openPhp('five_rows.csv'); + + try { + iterator_to_array($open->records(), false); + + static::assertSame(5, $open->producedRows()); + static::assertSame(20, $open->producedBytes()); + } finally { + $open->close(); + } + } } diff --git a/src/adapter/etl-adapter-csv/tests/Flow/ETL/Adapter/CSV/Tests/Unit/CSVExtractorTest.php b/src/adapter/etl-adapter-csv/tests/Flow/ETL/Adapter/CSV/Tests/Unit/CSVExtractorTest.php new file mode 100644 index 0000000000..2f0bc0eca4 --- /dev/null +++ b/src/adapter/etl-adapter-csv/tests/Flow/ETL/Adapter/CSV/Tests/Unit/CSVExtractorTest.php @@ -0,0 +1,184 @@ + + */ + public static function sample_dropping_setters(): Generator + { + yield 'inferSchema' => [static fn(CSVExtractor $e): mixed => $e->inferSchema(infer_schema())]; + yield 'withBOMRemoval' => [static fn(CSVExtractor $e): mixed => $e->withBOMRemoval(true)]; + yield 'withCharactersReadInLine' => [static fn(CSVExtractor $e): mixed => $e->withCharactersReadInLine(1024)]; + yield 'withEmptyToNull' => [static fn(CSVExtractor $e): mixed => $e->withEmptyToNull(true)]; + yield 'withEnclosure' => [static fn(CSVExtractor $e): mixed => $e->withEnclosure('"')]; + yield 'withEscape' => [static fn(CSVExtractor $e): mixed => $e->withEscape('\\')]; + yield 'withHeader' => [static fn(CSVExtractor $e): mixed => $e->withHeader(true)]; + yield 'withSeparator' => [static fn(CSVExtractor $e): mixed => $e->withSeparator(',')]; + } + + #[DataProvider('sample_dropping_setters')] + public function test_a_changed_read_option_drops_the_sample(Closure $setter): void + { + $extractor = from_csv(CSVFixtureContext::path('five_rows.csv')); + $extractor->schema(); + + static::assertEquals(Cardinality::exact(5), $extractor->statistics()->rows); + + $setter($extractor); + + static::assertEquals(Cardinality::unknown(), $extractor->statistics()->rows); + } + + public function test_a_declared_schema_declares_unknown_rows(): void + { + $extractor = from_csv( + CSVFixtureContext::path('five_rows.csv'), + schema: schema(int_schema('id'), str_schema('name')), + ); + $extractor->schema(); + + static::assertEquals(Cardinality::unknown(), $extractor->statistics()->rows); + static::assertEquals(Cardinality::exact(28), $extractor->statistics()->size); + } + + public function test_a_file_shorter_than_the_sample_declares_an_exact_row_count(): void + { + $extractor = from_csv(CSVFixtureContext::path('five_rows.csv')); + $extractor->schema(); + + static::assertEquals(Cardinality::exact(5), $extractor->statistics()->rows); + } + + public function test_a_header_only_file_declares_zero_rows_exactly(): void + { + $extractor = from_csv(CSVFixtureContext::path('header_only.csv')); + $extractor->schema(); + + static::assertEquals(Cardinality::exact(0), $extractor->statistics()->rows); + static::assertEquals(Cardinality::exact(8), $extractor->statistics()->size); + } + + public function test_a_member_without_a_size_makes_both_facts_unknown(): void + { + $extractor = from_csv(CSVFixtureContext::path('glob_with_empty/*.csv')) + ->inferSchema(infer_schema()->sampleSize(1)); + $extractor->schema(); + + static::assertEquals(Cardinality::unknown(), $extractor->statistics()->rows); + static::assertEquals(Cardinality::unknown(), $extractor->statistics()->size); + } + + public function test_a_narrow_first_row_does_not_skew_the_estimate(): void + { + $extractor = from_csv( + path('memory://source.csv'), + filesystem: CSVFixtureContext::memory(CSVFixtureContext::narrowFirstRow()), + )->inferSchema(infer_schema()->sampleSize(20)); + $extractor->schema(); + + $rows = $extractor->statistics()->rows; + + static::assertEquals(Cardinality::approximately(1050, Cardinality::DEFAULT_RELATIVE_ERROR), $rows); + static::assertLessThanOrEqual($rows->relativeError, abs(1050 - 1000) / 1000); + } + + public function test_an_empty_file_declares_zero_rows_exactly(): void + { + $extractor = from_csv(CSVFixtureContext::path('empty.csv')); + $extractor->schema(); + + static::assertEquals(Cardinality::exact(0), $extractor->statistics()->rows); + static::assertEquals(Cardinality::unknown(), $extractor->statistics()->size); + } + + public function test_extract_samples_like_schema_does(): void + { + $extractor = from_csv(CSVFixtureContext::path('five_rows.csv')); + + iterator_to_array($extractor->extract(flow_context()), false); + + static::assertEquals(Cardinality::exact(5), $extractor->statistics()->rows); + } + + public function test_it_declares_the_listed_byte_total_exactly(): void + { + $extractor = from_csv( + path('memory://source.csv'), + filesystem: CSVFixtureContext::memory(CSVFixtureContext::tenByteRows(100)), + ); + + static::assertEquals(Cardinality::exact(1008), $extractor->statistics()->size); + } + + public function test_it_estimates_rows_from_bytes_and_the_mean_sampled_row(): void + { + $extractor = from_csv( + path('memory://source.csv'), + filesystem: CSVFixtureContext::memory(CSVFixtureContext::tenByteRows(100)), + )->inferSchema(infer_schema()->sampleSize(10)); + $extractor->schema(); + + static::assertEquals( + Cardinality::approximately(101, Cardinality::DEFAULT_RELATIVE_ERROR), + $extractor->statistics()->rows, + ); + } + + public function test_statistics_are_computed_at_most_once(): void + { + $filesystem = new CountingFilesystem(new NativeLocalFilesystem()); + $extractor = from_csv(CSVFixtureContext::path('five_rows.csv'), filesystem: $filesystem); + $extractor->schema(); + $listCalls = $filesystem->listCalls; + + static::assertSame($extractor->statistics(), $extractor->statistics()); + static::assertSame($listCalls + 1, $filesystem->listCalls); + } + + public function test_statistics_before_schema_declares_unknown_rows(): void + { + $extractor = from_csv(CSVFixtureContext::path('five_rows.csv')); + + static::assertEquals(Cardinality::unknown(), $extractor->statistics()->rows); + + $extractor->schema(); + + static::assertEquals(Cardinality::exact(5), $extractor->statistics()->rows); + } + + public function test_statistics_before_a_sample_list_the_source_once(): void + { + $filesystem = new CountingFilesystem(new NativeLocalFilesystem()); + $extractor = from_csv(CSVFixtureContext::path('five_rows.csv'), filesystem: $filesystem); + + $extractor->statistics(); + $extractor->statistics(); + + static::assertSame(1, $filesystem->listCalls); + } +} diff --git a/src/adapter/etl-adapter-csv/tests/Flow/ETL/Adapter/CSV/Tests/Unit/CSVLineReaderTest.php b/src/adapter/etl-adapter-csv/tests/Flow/ETL/Adapter/CSV/Tests/Unit/CSVLineReaderTest.php index ae7675d4b2..3dfbec0e2c 100644 --- a/src/adapter/etl-adapter-csv/tests/Flow/ETL/Adapter/CSV/Tests/Unit/CSVLineReaderTest.php +++ b/src/adapter/etl-adapter-csv/tests/Flow/ETL/Adapter/CSV/Tests/Unit/CSVLineReaderTest.php @@ -9,6 +9,7 @@ use Flow\Filesystem\Stream\MemorySourceStream; use Generator; use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\Attributes\TestWith; use PHPUnit\Framework\TestCase; use function Flow\Filesystem\DSL\path; @@ -394,4 +395,28 @@ public function test_reading_simple_csv_without_quotes(): void static::assertSame('value1,value2,value3', $lines[1]); static::assertSame('value4,value5,value6', $lines[2]); } + + /** + * @param non-empty-string $content + * @param list $expected + */ + #[TestWith(["a,b\n1,2\n", [4, 4]])] + #[TestWith(["a,b\r\n1,2\r\n", [5, 5]])] + #[TestWith(["a,b\n\"x\ny\",1\n", [4, 8]])] + #[TestWith(["a,b\r\n\"x\r\ny\",1\r\n", [5, 10]])] + #[TestWith(["\xEF\xBB\xBFa,b\n1,2\n", [7, 4]])] + #[TestWith(["a,b\n1,2", [4, 4]])] + public function test_last_record_bytes_count_what_the_record_took_in_the_stream( + string $content, + array $expected, + ): void { + $reader = new CSVLineReader('"'); + $bytes = []; + + foreach ($reader->readLines(new MemorySourceStream($content)) as $_line) { + $bytes[] = $reader->lastRecordBytes(); + } + + static::assertSame($expected, $bytes); + } } diff --git a/src/adapter/etl-adapter-csv/tests/Flow/ETL/Adapter/CSV/Tests/Unit/CSVSampledFilesTest.php b/src/adapter/etl-adapter-csv/tests/Flow/ETL/Adapter/CSV/Tests/Unit/CSVSampledFilesTest.php new file mode 100644 index 0000000000..4b7153af1f --- /dev/null +++ b/src/adapter/etl-adapter-csv/tests/Flow/ETL/Adapter/CSV/Tests/Unit/CSVSampledFilesTest.php @@ -0,0 +1,48 @@ +estimatedRows(1, Cardinality::exact(1000)), + ); + } + + public function test_every_listed_file_read_whole_is_exact(): void + { + static::assertEquals( + Cardinality::exact(10), + (new CSVSampledFiles(2, 10, 100, true))->estimatedRows(2, Cardinality::exact(100)), + ); + } + + public function test_a_listed_file_the_samples_never_read_makes_it_an_estimate(): void + { + static::assertEquals( + Cardinality::approximately(20), + (new CSVSampledFiles(1, 10, 100, true))->estimatedRows(2, Cardinality::exact(200)), + ); + } + + public function test_no_listed_bytes_or_no_sampled_rows_is_unknown(): void + { + static::assertEquals(Cardinality::unknown(), (new CSVSampledFiles(1, 10, 100, false))->estimatedRows( + 1, + Cardinality::unknown(), + )); + static::assertEquals(Cardinality::unknown(), (new CSVSampledFiles(1, 0, 0, false))->estimatedRows( + 1, + Cardinality::exact(100), + )); + } +} diff --git a/src/adapter/etl-adapter-csv/tests/Flow/ETL/Adapter/CSV/Tests/Unit/CSVSampledRowsTest.php b/src/adapter/etl-adapter-csv/tests/Flow/ETL/Adapter/CSV/Tests/Unit/CSVSampledRowsTest.php new file mode 100644 index 0000000000..fe7037e74e --- /dev/null +++ b/src/adapter/etl-adapter-csv/tests/Flow/ETL/Adapter/CSV/Tests/Unit/CSVSampledRowsTest.php @@ -0,0 +1,34 @@ +rows); + static::assertSame(20, $sampled->bytes); + static::assertTrue($sampled->wholeFile); + } + + #[TestWith([-1, 0])] + #[TestWith([0, -1])] + public function test_negative_counts_are_rejected(int $rows, int $bytes): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage( + 'Sampled rows and bytes must not be negative, given: ' . $rows . ' rows, ' . $bytes . ' bytes', + ); + + new CSVSampledRows($rows, $bytes, false); + } +} diff --git a/src/adapter/etl-adapter-doctrine/src/Flow/ETL/Adapter/Doctrine/DbalKeySetExtractor.php b/src/adapter/etl-adapter-doctrine/src/Flow/ETL/Adapter/Doctrine/DbalKeySetExtractor.php index 208bdc3d78..2b5638b6b3 100644 --- a/src/adapter/etl-adapter-doctrine/src/Flow/ETL/Adapter/Doctrine/DbalKeySetExtractor.php +++ b/src/adapter/etl-adapter-doctrine/src/Flow/ETL/Adapter/Doctrine/DbalKeySetExtractor.php @@ -6,6 +6,7 @@ use Doctrine\DBAL\Connection; use Doctrine\DBAL\Query\QueryBuilder; +use Flow\ETL\Adapter\Doctrine\Explain\ExplainedRows; use Flow\ETL\Adapter\Doctrine\Pagination\Key; use Flow\ETL\Adapter\Doctrine\Pagination\KeySet; use Flow\ETL\Exception\InvalidArgumentException; @@ -16,6 +17,7 @@ use Flow\ETL\Extractor\Batches; use Flow\ETL\Extractor\RewindableExtractor; use Flow\ETL\Extractor\Signal; +use Flow\ETL\Extractor\Statistics; use Flow\ETL\FlowContext; use Flow\ETL\Rows; use Flow\ETL\Schema; @@ -50,6 +52,8 @@ final class DbalKeySetExtractor implements BatchableExtractor, Extractor, Rewind private ?SchemaNotDerivableException $refusal = null; + private ?Statistics $statistics = null; + public function __construct( private readonly Connection $connection, private readonly QueryBuilder $queryBuilder, @@ -212,6 +216,17 @@ public function schema(): Schema } } + public function statistics(): Statistics + { + return $this->statistics ??= new Statistics(rows: (new ExplainedRows())->of( + $this->connection, + $this->queryBuilder->getSQL(), + $this->queryBuilder->getParameters(), + $this->queryBuilder->getParameterTypes(), + $this->maximum, + )); + } + public function withKeyAliasSuffix(string $keyAliasSuffix): self { $this->keyAliasSuffix = $keyAliasSuffix; @@ -235,6 +250,7 @@ public function withMaximum(int $maximum): self } $this->maximum = $maximum; + $this->statistics = null; return $this; } diff --git a/src/adapter/etl-adapter-doctrine/src/Flow/ETL/Adapter/Doctrine/DbalLimitOffsetExtractor.php b/src/adapter/etl-adapter-doctrine/src/Flow/ETL/Adapter/Doctrine/DbalLimitOffsetExtractor.php index e48762273c..0394ed3701 100644 --- a/src/adapter/etl-adapter-doctrine/src/Flow/ETL/Adapter/Doctrine/DbalLimitOffsetExtractor.php +++ b/src/adapter/etl-adapter-doctrine/src/Flow/ETL/Adapter/Doctrine/DbalLimitOffsetExtractor.php @@ -6,6 +6,7 @@ use Doctrine\DBAL\Connection; use Doctrine\DBAL\Query\QueryBuilder; +use Flow\ETL\Adapter\Doctrine\Explain\ExplainedRows; use Flow\ETL\Exception\InvalidArgumentException; use Flow\ETL\Exception\SchemaNotDerivableException; use Flow\ETL\Extractor; @@ -13,6 +14,7 @@ use Flow\ETL\Extractor\Batches; use Flow\ETL\Extractor\RewindableExtractor; use Flow\ETL\Extractor\Signal; +use Flow\ETL\Extractor\Statistics; use Flow\ETL\FlowContext; use Flow\ETL\Rows; use Flow\ETL\Schema; @@ -20,6 +22,7 @@ use function count; use function is_numeric; +use function max; use function min; final class DbalLimitOffsetExtractor implements BatchableExtractor, Extractor, RewindableExtractor @@ -36,6 +39,8 @@ final class DbalLimitOffsetExtractor implements BatchableExtractor, Extractor, R private ?SchemaNotDerivableException $refusal = null; + private ?Statistics $statistics = null; + public function __construct( private readonly Connection $connection, private readonly QueryBuilder $queryBuilder, @@ -183,6 +188,21 @@ public function schema(): Schema } } + /** + * The plan already applies the query builder's own limit and offset; withOffset() replaces that offset. + */ + public function statistics(): Statistics + { + return $this->statistics ??= new Statistics(rows: (new ExplainedRows())->of( + $this->connection, + $this->queryBuilder->getSQL(), + $this->queryBuilder->getParameters(), + $this->queryBuilder->getParameterTypes(), + $this->maximum ?? $this->queryBuilder->getMaxResults(), + $this->offset === 0 ? 0 : max(0, $this->offset - $this->queryBuilder->getFirstResult()), + )); + } + public function withMaximum(int $maximum): self { if ($maximum <= 0) { @@ -190,6 +210,7 @@ public function withMaximum(int $maximum): self } $this->maximum = $maximum; + $this->statistics = null; return $this; } @@ -201,6 +222,7 @@ public function withOffset(int $offset): self } $this->offset = $offset; + $this->statistics = null; return $this; } diff --git a/src/adapter/etl-adapter-doctrine/src/Flow/ETL/Adapter/Doctrine/DbalQueryExtractor.php b/src/adapter/etl-adapter-doctrine/src/Flow/ETL/Adapter/Doctrine/DbalQueryExtractor.php index 83728fd81c..686b22063c 100644 --- a/src/adapter/etl-adapter-doctrine/src/Flow/ETL/Adapter/Doctrine/DbalQueryExtractor.php +++ b/src/adapter/etl-adapter-doctrine/src/Flow/ETL/Adapter/Doctrine/DbalQueryExtractor.php @@ -8,18 +8,23 @@ use Doctrine\DBAL\Connection; use Doctrine\DBAL\ParameterType; use Doctrine\DBAL\Types\Type; +use Flow\ETL\Adapter\Doctrine\Explain\ExplainedRows; +use Flow\ETL\Cardinality; use Flow\ETL\Exception\SchemaNotDerivableException; use Flow\ETL\Extractor; use Flow\ETL\Extractor\BatchableExtractor; use Flow\ETL\Extractor\Batches; use Flow\ETL\Extractor\RewindableExtractor; use Flow\ETL\Extractor\Signal; +use Flow\ETL\Extractor\Statistics; use Flow\ETL\FlowContext; use Flow\ETL\Rows; use Flow\ETL\Schema; use Generator; +use function array_slice; use function count; +use function round; /** * batchSize() is a yield cap, not a fetch size: fetchAllAssociative() materialises the whole result set per @@ -31,6 +36,8 @@ */ final class DbalQueryExtractor implements BatchableExtractor, Extractor, RewindableExtractor { + private const int EXPLAINED_PARAMETER_SETS = 10; + use Batches; private ParametersSet $parametersSet; @@ -41,6 +48,8 @@ final class DbalQueryExtractor implements BatchableExtractor, Extractor, Rewinda private ?SchemaNotDerivableException $refusal = null; + private ?Statistics $statistics = null; + /** * @var array|string, ArrayParameterType|ParameterType|string|Type> */ @@ -154,9 +163,45 @@ public function schema(): Schema } } + /** + * One plan per parameter set, summed; past EXPLAINED_PARAMETER_SETS the explained sets are scaled to all of them, + * so planning never pays a round trip per set. + */ + public function statistics(): Statistics + { + if ($this->statistics === null) { + $sets = $this->parametersSet->all(); + $explained = array_slice($sets, 0, self::EXPLAINED_PARAMETER_SETS); + $rows = Cardinality::exact(0); + + foreach ($explained as $parameters) { + $rows = $rows->merge((new ExplainedRows())->of( + $this->connection, + $this->query, + $parameters, + $this->types, + )); + } + + $this->statistics = new Statistics( + rows: count($sets) === count($explained) + ? $rows + : new Cardinality( + estimate: $rows->estimate === null + ? null + : (int) round(($rows->estimate * count($sets)) / count($explained)), + relativeError: Cardinality::DEFAULT_RELATIVE_ERROR, + ), + ); + } + + return $this->statistics; + } + public function withParameters(ParametersSet $parametersSet): self { $this->parametersSet = $parametersSet; + $this->statistics = null; return $this; } @@ -174,6 +219,7 @@ public function withSchema(Schema $schema): static public function withTypes(array $types): self { $this->types = $types; + $this->statistics = null; return $this; } diff --git a/src/adapter/etl-adapter-doctrine/src/Flow/ETL/Adapter/Doctrine/Explain/ExplainedRows.php b/src/adapter/etl-adapter-doctrine/src/Flow/ETL/Adapter/Doctrine/Explain/ExplainedRows.php new file mode 100644 index 0000000000..b769718d76 --- /dev/null +++ b/src/adapter/etl-adapter-doctrine/src/Flow/ETL/Adapter/Doctrine/Explain/ExplainedRows.php @@ -0,0 +1,64 @@ +|list $parameters + * @param array|string, ArrayParameterType|ParameterType|string|Type> $types + */ + public function of( + Connection $connection, + string $sql, + array $parameters = [], + array $types = [], + ?int $maximum = null, + int $offset = 0, + ): Cardinality { + $platform = $connection->getDatabasePlatform(); + $planned = match (true) { + $platform instanceof PostgreSQLPlatform => (new PostgreSqlExplainedRows())->of( + $connection, + $sql, + $parameters, + $types, + ), + $platform instanceof MySQL80Platform => (new MySqlExplainedRows())->of( + $connection, + $sql, + $parameters, + $types, + ), + default => null, + }; + + if ($planned === null) { + return new Cardinality(atMost: $maximum); + } + + $estimate = max(0, $planned - $offset); + + return new Cardinality( + atMost: $maximum, + estimate: $maximum === null ? $estimate : min($estimate, $maximum), + relativeError: Cardinality::DEFAULT_RELATIVE_ERROR, + ); + } +} diff --git a/src/adapter/etl-adapter-doctrine/src/Flow/ETL/Adapter/Doctrine/Explain/MySqlExplainedRows.php b/src/adapter/etl-adapter-doctrine/src/Flow/ETL/Adapter/Doctrine/Explain/MySqlExplainedRows.php new file mode 100644 index 0000000000..dabfb9b165 --- /dev/null +++ b/src/adapter/etl-adapter-doctrine/src/Flow/ETL/Adapter/Doctrine/Explain/MySqlExplainedRows.php @@ -0,0 +1,47 @@ + Filter: (t.g > 1) (cost=0.75 rows=2)". A root without an + * estimate (a GROUP BY read back from "") is unknown: a child's estimate counts other rows. + * + * @param array|list $parameters + * @param array|string, ArrayParameterType|ParameterType|string|Type> $types + */ + public function of(Connection $connection, string $sql, array $parameters = [], array $types = []): ?int + { + $plan = $connection->fetchOne('EXPLAIN FORMAT=TREE ' . $sql, $parameters, $types); + + if (!is_string($plan)) { + return null; + } + + $matches = []; + + if (preg_match('/\brows=(\d+(?:\.\d+)?(?:e[+-]?\d+)?)/i', explode("\n", $plan)[0], $matches) !== 1) { + return null; + } + + if (!is_numeric($matches[1])) { + return null; + } + + return (int) round((float) $matches[1]); + } +} diff --git a/src/adapter/etl-adapter-doctrine/src/Flow/ETL/Adapter/Doctrine/Explain/PostgreSqlExplainedRows.php b/src/adapter/etl-adapter-doctrine/src/Flow/ETL/Adapter/Doctrine/Explain/PostgreSqlExplainedRows.php new file mode 100644 index 0000000000..8cc00e8a86 --- /dev/null +++ b/src/adapter/etl-adapter-doctrine/src/Flow/ETL/Adapter/Doctrine/Explain/PostgreSqlExplainedRows.php @@ -0,0 +1,51 @@ +|list $parameters + * @param array|string, ArrayParameterType|ParameterType|string|Type> $types + */ + public function of(Connection $connection, string $sql, array $parameters = [], array $types = []): ?int + { + $plan = $connection->fetchOne('EXPLAIN (FORMAT JSON) ' . $sql, $parameters, $types); + + // @mago-expect analysis:mixed-assignment + $plan = is_string($plan) ? json_decode($plan, true) : $plan; + + if (!is_array($plan) || !array_key_exists(0, $plan) || !is_array($plan[0])) { + return null; + } + + // @mago-expect analysis:mixed-assignment + $root = $plan[0]['Plan'] ?? null; + + if (!is_array($root) || !array_key_exists('Plan Rows', $root)) { + return null; + } + + // @mago-expect analysis:mixed-assignment + $rows = $root['Plan Rows']; + + return is_int($rows) || is_float($rows) ? (int) round($rows) : null; + } +} diff --git a/src/adapter/etl-adapter-doctrine/tests/Flow/ETL/Adapter/Doctrine/Tests/Context/PlannedTable.php b/src/adapter/etl-adapter-doctrine/tests/Flow/ETL/Adapter/Doctrine/Tests/Context/PlannedTable.php new file mode 100644 index 0000000000..7e070d7b0d --- /dev/null +++ b/src/adapter/etl-adapter-doctrine/tests/Flow/ETL/Adapter/Doctrine/Tests/Context/PlannedTable.php @@ -0,0 +1,41 @@ +createTable( + (new Table($name, [ + new Column('id', Type::getType(Types::INTEGER), ['notnull' => true]), + new Column('grp', Type::getType(Types::INTEGER), ['notnull' => true]), + ]))->addPrimaryKeyConstraint(PrimaryKeyConstraint::editor()->setUnquotedColumnNames('id')->create()), + ); + + for ($id = 1; $id <= $rows; $id++) { + $context->insert($name, ['id' => $id, 'grp' => $id % 3]); + } + + // DBAL has no statement for refreshing planner statistics + $context + ->connection() + ->executeStatement( + $context->connection()->getDatabasePlatform() instanceof AbstractMySQLPlatform + ? 'ANALYZE TABLE ' . $name + : 'ANALYZE ' . $name, + ); + } +} diff --git a/src/adapter/etl-adapter-doctrine/tests/Flow/ETL/Adapter/Doctrine/Tests/Integration/Dialects/PosgreSQLDbalLimitOffsetExtractorTest.php b/src/adapter/etl-adapter-doctrine/tests/Flow/ETL/Adapter/Doctrine/Tests/Integration/Dialects/PosgreSQLDbalLimitOffsetExtractorTest.php index 0b71acca73..05a7c3d5b6 100644 --- a/src/adapter/etl-adapter-doctrine/tests/Flow/ETL/Adapter/Doctrine/Tests/Integration/Dialects/PosgreSQLDbalLimitOffsetExtractorTest.php +++ b/src/adapter/etl-adapter-doctrine/tests/Flow/ETL/Adapter/Doctrine/Tests/Integration/Dialects/PosgreSQLDbalLimitOffsetExtractorTest.php @@ -12,7 +12,9 @@ use Flow\ETL\Adapter\Doctrine\Order; use Flow\ETL\Adapter\Doctrine\OrderBy; use Flow\ETL\Adapter\Doctrine\Table as ETLTable; +use Flow\ETL\Adapter\Doctrine\Tests\Context\PlannedTable; use Flow\ETL\Adapter\Doctrine\Tests\IntegrationTestCase; +use Flow\ETL\Cardinality; use function Flow\ETL\Adapter\Doctrine\from_dbal_limit_offset; use function Flow\ETL\Adapter\Doctrine\from_dbal_limit_offset_qb; @@ -436,4 +438,25 @@ public function test_extracting_selected_columns(): void $data->toArray(), ); } + + public function test_statistics_apply_the_offset_and_the_query_builder_limit(): void + { + PlannedTable::create($this->pgsqlDatabaseContext, 'flow_doctrine_statistics_test', 100); + $queryBuilder = $this->pgsqlDatabaseContext + ->connection() + ->createQueryBuilder() + ->select('*') + ->from('flow_doctrine_statistics_test') + ->orderBy('id') + ->setMaxResults(20); + + static::assertEquals( + new Cardinality(atMost: 20, estimate: 10, relativeError: Cardinality::DEFAULT_RELATIVE_ERROR), + from_dbal_limit_offset_qb( + $this->pgsqlDatabaseContext->connection(), + $queryBuilder, + offset: 10, + )->statistics()->rows, + ); + } } diff --git a/src/adapter/etl-adapter-doctrine/tests/Flow/ETL/Adapter/Doctrine/Tests/Integration/Dialects/PostgreSQLDbalQueryExtractorTest.php b/src/adapter/etl-adapter-doctrine/tests/Flow/ETL/Adapter/Doctrine/Tests/Integration/Dialects/PostgreSQLDbalQueryExtractorTest.php index cba22d8321..a4042bc3cd 100644 --- a/src/adapter/etl-adapter-doctrine/tests/Flow/ETL/Adapter/Doctrine/Tests/Integration/Dialects/PostgreSQLDbalQueryExtractorTest.php +++ b/src/adapter/etl-adapter-doctrine/tests/Flow/ETL/Adapter/Doctrine/Tests/Integration/Dialects/PostgreSQLDbalQueryExtractorTest.php @@ -11,8 +11,11 @@ use Doctrine\DBAL\Types\Types; use Flow\ETL\Adapter\Doctrine\DbalLoader; use Flow\ETL\Adapter\Doctrine\ParametersSet; +use Flow\ETL\Adapter\Doctrine\Tests\Context\PlannedTable; use Flow\ETL\Adapter\Doctrine\Tests\IntegrationTestCase; +use Flow\ETL\Cardinality; +use function array_fill; use function Flow\ETL\Adapter\Doctrine\from_dbal_queries; use function Flow\ETL\Adapter\Doctrine\from_dbal_query; use function Flow\ETL\DSL\data_frame; @@ -193,4 +196,32 @@ public function test_extracting_multiple_rows_with_schema(): void $schema->normalize(), ); } + + public function test_statistics_sum_the_plan_of_every_parameter_set(): void + { + PlannedTable::create($this->pgsqlDatabaseContext, 'flow_doctrine_statistics_test', 99); + + static::assertEquals( + Cardinality::approximately(66), + from_dbal_queries( + $this->pgsqlDatabaseContext->connection(), + 'SELECT * FROM flow_doctrine_statistics_test WHERE grp = :grp', + new ParametersSet(['grp' => 0], ['grp' => 1]), + )->statistics()->rows, + ); + } + + public function test_statistics_scale_the_first_ten_parameter_sets_to_all_of_them(): void + { + PlannedTable::create($this->pgsqlDatabaseContext, 'flow_doctrine_statistics_test', 99); + + static::assertEquals( + Cardinality::approximately(396), + from_dbal_queries( + $this->pgsqlDatabaseContext->connection(), + 'SELECT * FROM flow_doctrine_statistics_test WHERE grp = :grp', + new ParametersSet(...array_fill(0, 10, ['grp' => 0]), ...array_fill(0, 2, ['grp' => 99])), + )->statistics()->rows, + ); + } } diff --git a/src/adapter/etl-adapter-doctrine/tests/Flow/ETL/Adapter/Doctrine/Tests/Integration/Dialects/PostgreSQLKeySetExtractorTest.php b/src/adapter/etl-adapter-doctrine/tests/Flow/ETL/Adapter/Doctrine/Tests/Integration/Dialects/PostgreSQLKeySetExtractorTest.php index a3fc228c78..a5ae126119 100644 --- a/src/adapter/etl-adapter-doctrine/tests/Flow/ETL/Adapter/Doctrine/Tests/Integration/Dialects/PostgreSQLKeySetExtractorTest.php +++ b/src/adapter/etl-adapter-doctrine/tests/Flow/ETL/Adapter/Doctrine/Tests/Integration/Dialects/PostgreSQLKeySetExtractorTest.php @@ -7,7 +7,9 @@ use DateTimeImmutable; use Flow\Clock\FakeClock; use Flow\ETL\Adapter\Doctrine\DbalMetadata; +use Flow\ETL\Adapter\Doctrine\Tests\Context\PlannedTable; use Flow\ETL\Adapter\Doctrine\Tests\IntegrationTestCase; +use Flow\ETL\Cardinality; use function Flow\ETL\Adapter\Doctrine\from_dbal_key_set_qb; use function Flow\ETL\Adapter\Doctrine\pagination_key_asc; @@ -404,4 +406,22 @@ public function test_extraction_when_key_is_ambiguous_column_with_custom_key_col $rows, ); } + + public function test_statistics_are_the_plan_of_the_base_query(): void + { + PlannedTable::create($this->pgsqlDatabaseContext, 'flow_doctrine_statistics_test', 100); + + static::assertEquals( + Cardinality::approximately(100), + from_dbal_key_set_qb( + $this->pgsqlDatabaseContext->connection(), + $this->pgsqlDatabaseContext + ->connection() + ->createQueryBuilder() + ->select('*') + ->from('flow_doctrine_statistics_test'), + pagination_key_set(pagination_key_asc('id')), + )->statistics()->rows, + ); + } } diff --git a/src/adapter/etl-adapter-doctrine/tests/Flow/ETL/Adapter/Doctrine/Tests/Integration/Explain/ExplainedRowsTest.php b/src/adapter/etl-adapter-doctrine/tests/Flow/ETL/Adapter/Doctrine/Tests/Integration/Explain/ExplainedRowsTest.php new file mode 100644 index 0000000000..edb08ed515 --- /dev/null +++ b/src/adapter/etl-adapter-doctrine/tests/Flow/ETL/Adapter/Doctrine/Tests/Integration/Explain/ExplainedRowsTest.php @@ -0,0 +1,77 @@ +pgsqlDatabaseContext, 'flow_explain_test', 5); + + static::assertEquals( + new Cardinality(atMost: 3, estimate: 3, relativeError: Cardinality::DEFAULT_RELATIVE_ERROR), + (new ExplainedRows())->of( + $this->pgsqlDatabaseContext->connection(), + 'SELECT * FROM flow_explain_test', + maximum: 3, + ), + ); + } + + public function test_an_offset_is_taken_off_the_estimate_and_never_below_zero(): void + { + PlannedTable::create($this->pgsqlDatabaseContext, 'flow_explain_test', 5); + + static::assertEquals( + Cardinality::approximately(1), + (new ExplainedRows())->of( + $this->pgsqlDatabaseContext->connection(), + 'SELECT * FROM flow_explain_test', + offset: 4, + ), + ); + static::assertEquals( + Cardinality::approximately(0), + (new ExplainedRows())->of( + $this->pgsqlDatabaseContext->connection(), + 'SELECT * FROM flow_explain_test', + offset: 9, + ), + ); + } + + public function test_mysql_plans_are_read(): void + { + PlannedTable::create($this->mysqlDatabaseContext, 'flow_explain_test', 5); + + static::assertEquals( + Cardinality::approximately(5), + (new ExplainedRows())->of($this->mysqlDatabaseContext->connection(), 'SELECT * FROM flow_explain_test'), + ); + } + + public function test_sqlite_declares_only_the_maximum(): void + { + PlannedTable::create($this->sqliteDatabaseContext, 'flow_explain_test', 5); + + static::assertEquals(Cardinality::unknown(), (new ExplainedRows())->of( + $this->sqliteDatabaseContext->connection(), + 'SELECT * FROM flow_explain_test', + )); + static::assertEquals( + Cardinality::atMost(3), + (new ExplainedRows())->of( + $this->sqliteDatabaseContext->connection(), + 'SELECT * FROM flow_explain_test', + maximum: 3, + ), + ); + } +} diff --git a/src/adapter/etl-adapter-doctrine/tests/Flow/ETL/Adapter/Doctrine/Tests/Integration/Explain/MySqlExplainedRowsTest.php b/src/adapter/etl-adapter-doctrine/tests/Flow/ETL/Adapter/Doctrine/Tests/Integration/Explain/MySqlExplainedRowsTest.php new file mode 100644 index 0000000000..d53ed380f4 --- /dev/null +++ b/src/adapter/etl-adapter-doctrine/tests/Flow/ETL/Adapter/Doctrine/Tests/Integration/Explain/MySqlExplainedRowsTest.php @@ -0,0 +1,43 @@ +mysqlDatabaseContext, 'flow_explain_test', 5); + + static::assertNull((new MySqlExplainedRows())->of( + $this->mysqlDatabaseContext->connection(), + 'SELECT grp, COUNT(*) FROM flow_explain_test GROUP BY grp', + )); + } + + public function test_a_parameterised_query_is_planned(): void + { + PlannedTable::create($this->mysqlDatabaseContext, 'flow_explain_test', 100); + + static::assertSame(10, (new MySqlExplainedRows())->of( + $this->mysqlDatabaseContext->connection(), + 'SELECT * FROM flow_explain_test WHERE id > ?', + [90], + )); + } + + public function test_the_root_line_rows_are_the_estimate(): void + { + PlannedTable::create($this->mysqlDatabaseContext, 'flow_explain_test', 5); + + static::assertSame(5, (new MySqlExplainedRows())->of( + $this->mysqlDatabaseContext->connection(), + 'SELECT * FROM flow_explain_test', + )); + } +} diff --git a/src/adapter/etl-adapter-doctrine/tests/Flow/ETL/Adapter/Doctrine/Tests/Integration/Explain/PostgreSqlExplainedRowsTest.php b/src/adapter/etl-adapter-doctrine/tests/Flow/ETL/Adapter/Doctrine/Tests/Integration/Explain/PostgreSqlExplainedRowsTest.php new file mode 100644 index 0000000000..1262e6188f --- /dev/null +++ b/src/adapter/etl-adapter-doctrine/tests/Flow/ETL/Adapter/Doctrine/Tests/Integration/Explain/PostgreSqlExplainedRowsTest.php @@ -0,0 +1,33 @@ +pgsqlDatabaseContext, 'flow_explain_test', 100); + + static::assertSame(10, (new PostgreSqlExplainedRows())->of( + $this->pgsqlDatabaseContext->connection(), + 'SELECT * FROM flow_explain_test WHERE id > ?', + [90], + )); + } + + public function test_the_root_plan_rows_are_the_estimate(): void + { + PlannedTable::create($this->pgsqlDatabaseContext, 'flow_explain_test', 5); + + static::assertSame(5, (new PostgreSqlExplainedRows())->of( + $this->pgsqlDatabaseContext->connection(), + 'SELECT * FROM flow_explain_test', + )); + } +} diff --git a/src/adapter/etl-adapter-doctrine/tests/Flow/ETL/Adapter/Doctrine/Tests/Unit/DbalKeySetExtractorTest.php b/src/adapter/etl-adapter-doctrine/tests/Flow/ETL/Adapter/Doctrine/Tests/Unit/DbalKeySetExtractorTest.php index 7076e5bf52..ef3b1fb4b3 100644 --- a/src/adapter/etl-adapter-doctrine/tests/Flow/ETL/Adapter/Doctrine/Tests/Unit/DbalKeySetExtractorTest.php +++ b/src/adapter/etl-adapter-doctrine/tests/Flow/ETL/Adapter/Doctrine/Tests/Unit/DbalKeySetExtractorTest.php @@ -9,6 +9,7 @@ use Flow\ETL\Adapter\Doctrine\Tests\Context\InMemorySqlite; use Flow\ETL\Adapter\Doctrine\Tests\Context\SelectQueryCounter; use Flow\ETL\Adapter\Doctrine\Tests\Double\NativeHandleStub; +use Flow\ETL\Cardinality; use Flow\ETL\Exception\SchemaNotDerivableException; use Flow\ETL\Tests\FlowTestCase; use stdClass; @@ -122,4 +123,21 @@ public function test_is_repeatable(): void ))->isRepeatable(), ); } + + public function test_a_new_maximum_drops_the_statistics(): void + { + $connection = InMemorySqlite::withUsers(InMemorySqlite::connection(), 3); + $extractor = (new DbalKeySetExtractor( + $connection, + $connection->createQueryBuilder()->select('*')->from('users'), + pagination_key_set(pagination_key_asc('id')), + ))->withMaximum(2); + + static::assertSame($extractor->statistics(), $extractor->statistics()); + static::assertEquals(Cardinality::atMost(2), $extractor->statistics()->rows); + + $extractor->withMaximum(5); + + static::assertEquals(Cardinality::atMost(5), $extractor->statistics()->rows); + } } diff --git a/src/adapter/etl-adapter-doctrine/tests/Flow/ETL/Adapter/Doctrine/Tests/Unit/DbalLimitOffsetExtractorTest.php b/src/adapter/etl-adapter-doctrine/tests/Flow/ETL/Adapter/Doctrine/Tests/Unit/DbalLimitOffsetExtractorTest.php index 68e4d9bfbe..86de2399b0 100644 --- a/src/adapter/etl-adapter-doctrine/tests/Flow/ETL/Adapter/Doctrine/Tests/Unit/DbalLimitOffsetExtractorTest.php +++ b/src/adapter/etl-adapter-doctrine/tests/Flow/ETL/Adapter/Doctrine/Tests/Unit/DbalLimitOffsetExtractorTest.php @@ -9,6 +9,7 @@ use Flow\ETL\Adapter\Doctrine\Tests\Context\InMemorySqlite; use Flow\ETL\Adapter\Doctrine\Tests\Context\SelectQueryCounter; use Flow\ETL\Adapter\Doctrine\Tests\Double\NativeHandleStub; +use Flow\ETL\Cardinality; use Flow\ETL\Exception\SchemaNotDerivableException; use Flow\ETL\Rows; use Flow\ETL\Tests\FlowTestCase; @@ -243,4 +244,47 @@ public function test_is_repeatable(): void ))->isRepeatable(), ); } + + public function test_a_new_maximum_drops_the_statistics(): void + { + $connection = InMemorySqlite::withUsers(InMemorySqlite::connection(), 3); + $extractor = (new DbalLimitOffsetExtractor( + $connection, + $connection->createQueryBuilder()->select('*')->from('users')->orderBy('id'), + ))->withMaximum(2); + + static::assertSame($extractor->statistics(), $extractor->statistics()); + static::assertEquals(Cardinality::atMost(2), $extractor->statistics()->rows); + + $extractor->withMaximum(5); + + static::assertEquals(Cardinality::atMost(5), $extractor->statistics()->rows); + } + + public function test_a_new_offset_drops_the_statistics(): void + { + $connection = InMemorySqlite::withUsers(InMemorySqlite::connection(), 3); + $extractor = new DbalLimitOffsetExtractor( + $connection, + $connection->createQueryBuilder()->select('*')->from('users')->orderBy('id'), + ); + $before = $extractor->statistics(); + + $extractor->withOffset(1); + + static::assertNotSame($before, $extractor->statistics()); + } + + public function test_the_query_builder_limit_bounds_the_rows(): void + { + $connection = InMemorySqlite::withUsers(InMemorySqlite::connection(), 3); + + static::assertEquals( + Cardinality::atMost(2), + (new DbalLimitOffsetExtractor( + $connection, + $connection->createQueryBuilder()->select('*')->from('users')->orderBy('id')->setMaxResults(2), + ))->statistics()->rows, + ); + } } diff --git a/src/adapter/etl-adapter-doctrine/tests/Flow/ETL/Adapter/Doctrine/Tests/Unit/DbalQueryExtractorTest.php b/src/adapter/etl-adapter-doctrine/tests/Flow/ETL/Adapter/Doctrine/Tests/Unit/DbalQueryExtractorTest.php index 18257d0bb7..905908cd2b 100644 --- a/src/adapter/etl-adapter-doctrine/tests/Flow/ETL/Adapter/Doctrine/Tests/Unit/DbalQueryExtractorTest.php +++ b/src/adapter/etl-adapter-doctrine/tests/Flow/ETL/Adapter/Doctrine/Tests/Unit/DbalQueryExtractorTest.php @@ -10,6 +10,7 @@ use Flow\ETL\Adapter\Doctrine\Tests\Context\InMemorySqlite; use Flow\ETL\Adapter\Doctrine\Tests\Context\SelectQueryCounter; use Flow\ETL\Adapter\Doctrine\Tests\Double\NativeHandleStub; +use Flow\ETL\Cardinality; use Flow\ETL\Exception\SchemaNotDerivableException; use Flow\ETL\Tests\FlowTestCase; use PHPUnit\Framework\Attributes\TestWith; @@ -133,4 +134,32 @@ public function test_is_repeatable(): void (new DbalQueryExtractor(InMemorySqlite::connection(), 'SELECT * FROM users'))->isRepeatable(), ); } + + public function test_new_parameters_drop_the_statistics(): void + { + $extractor = new DbalQueryExtractor( + InMemorySqlite::withUsers(InMemorySqlite::connection(), 3), + 'SELECT * FROM users', + ); + + static::assertSame($extractor->statistics(), $extractor->statistics()); + static::assertEquals(Cardinality::unknown(), $extractor->statistics()->rows); + + $extractor->withParameters(new ParametersSet()); + + static::assertEquals(Cardinality::exact(0), $extractor->statistics()->rows); + } + + public function test_new_types_drop_the_statistics(): void + { + $extractor = new DbalQueryExtractor( + InMemorySqlite::withUsers(InMemorySqlite::connection(), 3), + 'SELECT * FROM users', + ); + $before = $extractor->statistics(); + + $extractor->withTypes([]); + + static::assertNotSame($before, $extractor->statistics()); + } } diff --git a/src/adapter/etl-adapter-excel/src/Flow/ETL/Adapter/Excel/ExcelExtractor.php b/src/adapter/etl-adapter-excel/src/Flow/ETL/Adapter/Excel/ExcelExtractor.php index 95d71f3cc7..858b0a539b 100644 --- a/src/adapter/etl-adapter-excel/src/Flow/ETL/Adapter/Excel/ExcelExtractor.php +++ b/src/adapter/etl-adapter-excel/src/Flow/ETL/Adapter/Excel/ExcelExtractor.php @@ -4,6 +4,7 @@ namespace Flow\ETL\Adapter\Excel; +use Flow\ETL\Cardinality; use Flow\ETL\Exception\InferredSchemaException; use Flow\ETL\Exception\InvalidArgumentException; use Flow\ETL\Extractor; @@ -12,9 +13,11 @@ use Flow\ETL\Extractor\FileExtractor; use Flow\ETL\Extractor\FileReading; use Flow\ETL\Extractor\InfersSchema; +use Flow\ETL\Extractor\ListedFiles; use Flow\ETL\Extractor\MetadataColumnsExtractor; use Flow\ETL\Extractor\RewindableExtractor; use Flow\ETL\Extractor\Signal; +use Flow\ETL\Extractor\Statistics; use Flow\ETL\FlowContext; use Flow\ETL\Row\RawRowValues; use Flow\ETL\Rows; @@ -57,12 +60,23 @@ final class ExcelExtractor implements */ private ?WorkbookSampler $sampled = null; + private int $sampledFiles = 0; + + /** + * What the last inference's samples read; null until one ran. Kept apart from $sampled, which extract() takes. + */ + private ?Cardinality $sampledRows = null; + private ?Schema $schema = null; + private ?Statistics $statistics = null; + private string $inferredFrom = ''; private readonly Filesystem $filesystem; + private ?ListedFiles $listed = null; + public function __construct( private readonly Path $path, Filesystem $filesystem = new NativeLocalFilesystem(), @@ -140,6 +154,8 @@ public function extract(FlowContext $context, ?int $limit = null, Filter $pathFi $this->inference, new CellTypeNarrower($this->inference->candidates()), ))->infer($header->names, $sampler->samples($this->inference->sampleSize)); + $this->sampledRows = $sampler->sampledRows(); + $this->sampledFiles = count($sources); } catch (Throwable $failure) { $sampler->close(); @@ -251,9 +267,10 @@ public function schema(): Schema $derived = $this->derivedSchema; if ($derived === null) { + $sources = iterator_to_array($this->sourceFiles($this->filesystem, $this->path), false); $sampler = new WorkbookSampler( new WorkbookReader($this->readOptions, new ExcelFormatDetector($this->filesystem)), - iterator_to_array($this->sourceFiles($this->filesystem, $this->path), false), + $sources, ); try { @@ -266,6 +283,8 @@ public function schema(): Schema $this->inference, new CellTypeNarrower($this->inference->candidates()), ))->infer($header->names, $sampler->samples($this->inference->sampleSize)); + $this->sampledRows = $sampler->sampledRows(); + $this->sampledFiles = count($sources); } catch (Throwable $failure) { $sampler->close(); @@ -289,6 +308,30 @@ public function source(): Path return $this->path; } + /** + * The size is the listed, compressed byte count. Rows are known only when the sample schema inference already + * read every row of every file: OpenSpout discards the row bound of a sheet's . + */ + public function statistics(): Statistics + { + if ($this->statistics !== null) { + return $this->statistics; + } + + $this->listed ??= ListedFiles::of($this->sourceFiles($this->filesystem, $this->path)); + $size = $this->listed->bytes; + + if ($this->sampledRows === null) { + return new Statistics(rows: Cardinality::unknown(), size: $size); + } + + // an extract() under a pushed path filter sampled only the files the filter kept + return $this->statistics = new Statistics( + rows: $this->sampledFiles === $this->listed->count ? $this->sampledRows : Cardinality::unknown(), + size: $size, + ); + } + public function withConvertEmptyToNull(bool $convertEmptyToNull): self { $this->readOptions = $this->readOptions->withConvertEmptyToNull($convertEmptyToNull); @@ -345,5 +388,8 @@ private function forgetInference(): void $this->inferredFrom = ''; $this->sampled?->close(); $this->sampled = null; + $this->sampledRows = null; + $this->sampledFiles = 0; + $this->statistics = null; } } diff --git a/src/adapter/etl-adapter-excel/src/Flow/ETL/Adapter/Excel/WorkbookSampler.php b/src/adapter/etl-adapter-excel/src/Flow/ETL/Adapter/Excel/WorkbookSampler.php index 633656f8d7..fb2e0bf2ed 100644 --- a/src/adapter/etl-adapter-excel/src/Flow/ETL/Adapter/Excel/WorkbookSampler.php +++ b/src/adapter/etl-adapter-excel/src/Flow/ETL/Adapter/Excel/WorkbookSampler.php @@ -4,12 +4,13 @@ namespace Flow\ETL\Adapter\Excel; +use Flow\ETL\Cardinality; use Flow\ETL\Extractor\SourceFile; -use Flow\ETL\Row\RawRowValues; use Flow\ETL\Schema\Inference\SchemaSampler; use Generator; use function array_key_exists; +use function count; final class WorkbookSampler implements SchemaSampler { @@ -18,6 +19,11 @@ final class WorkbookSampler implements SchemaSampler */ private array $sheets = []; + /** + * @var array + */ + private array $samples = []; + /** * @param list $files materialised, not a Generator: columns() and samples() both walk it */ @@ -50,17 +56,37 @@ public function header(): ExcelHeader * A bounded sample keeps what it parsed for take(); an unbounded one parses every row, so it streams and closes * as it goes. Either way SchemaInferrer stops advancing a sheet once its budget is spent. * - * @return Generator> + * @return Generator */ public function samples(int $rowBudget): iterable { foreach ($this->files as $index => $file) { $sheet = $this->sheets[$index] ??= $this->workbook->sheet($file); - yield $rowBudget === -1 ? $sheet->rows() : $sheet->sample(); + yield $this->samples[$index] = new WorkbookSheetSample( + $rowBudget === -1 ? $sheet->rows() : $sheet->sample(), + ); } } + /** + * Exact when the samples read every row of every file, unknown otherwise. + */ + public function sampledRows(): Cardinality + { + $rows = 0; + + foreach ($this->samples as $sample) { + if (!$sample->wholeSheet()) { + return Cardinality::unknown(); + } + + $rows += $sample->rows(); + } + + return count($this->samples) === count($this->files) ? Cardinality::exact($rows) : Cardinality::unknown(); + } + /** * The sheet this sampler read $file through, handed over to the caller to read on and close; null when the * sample never reached the file. diff --git a/src/adapter/etl-adapter-excel/src/Flow/ETL/Adapter/Excel/WorkbookSheetSample.php b/src/adapter/etl-adapter-excel/src/Flow/ETL/Adapter/Excel/WorkbookSheetSample.php new file mode 100644 index 0000000000..e176188f72 --- /dev/null +++ b/src/adapter/etl-adapter-excel/src/Flow/ETL/Adapter/Excel/WorkbookSheetSample.php @@ -0,0 +1,56 @@ + + */ +final class WorkbookSheetSample implements IteratorAggregate +{ + /** + * @var int<0, max> + */ + private int $rows = 0; + + private bool $wholeSheet = false; + + /** + * @param Generator $sheetRows + */ + public function __construct( + private readonly Generator $sheetRows, + ) {} + + /** + * @return Generator + */ + public function getIterator(): Generator + { + foreach ($this->sheetRows as $rowValues) { + $this->rows++; + + yield $rowValues; + } + + $this->wholeSheet = true; + } + + /** + * @return int<0, max> + */ + public function rows(): int + { + return $this->rows; + } + + public function wholeSheet(): bool + { + return $this->wholeSheet; + } +} diff --git a/src/adapter/etl-adapter-excel/tests/Flow/ETL/Adapter/Excel/Tests/Integration/ExcelExtractorTest.php b/src/adapter/etl-adapter-excel/tests/Flow/ETL/Adapter/Excel/Tests/Integration/ExcelExtractorTest.php index c4b2e5e64f..cacb94e1f7 100644 --- a/src/adapter/etl-adapter-excel/tests/Flow/ETL/Adapter/Excel/Tests/Integration/ExcelExtractorTest.php +++ b/src/adapter/etl-adapter-excel/tests/Flow/ETL/Adapter/Excel/Tests/Integration/ExcelExtractorTest.php @@ -9,6 +9,7 @@ use Flow\ETL\Adapter\Excel\ExcelExtractor; use Flow\ETL\Adapter\Excel\ExcelReader; use Flow\ETL\Adapter\Excel\Tests\Context\ExcelFixtureContext; +use Flow\ETL\Cardinality; use Flow\ETL\Config; use Flow\ETL\Exception\InferredSchemaException; use Flow\ETL\Exception\InvalidArgumentException; @@ -18,6 +19,8 @@ use Flow\ETL\Rows; use Flow\ETL\Tests\Context\ExtractedRows; use Flow\ETL\Tests\Double\CountingFilesystem; +use Flow\ETL\Tests\Double\KeepPaths; +use Flow\ETL\Tests\Double\UnsizedFilesystem; use Flow\ETL\Tests\FlowTestCase; use Generator; use PHPUnit\Framework\Attributes\DataProvider; @@ -776,4 +779,125 @@ public function test_limit_reached_on_the_first_file_tail_batch_skips_the_remain static::assertCount(2, ExtractedRows::of($extractor, limit: 2)); } + + /** + * @return Generator + */ + public static function sample_dropping_setters(): Generator + { + yield 'inferSchema' => [static fn(ExcelExtractor $e): mixed => $e->inferSchema(infer_schema())]; + yield 'withConvertEmptyToNull' => [static fn(ExcelExtractor $e): mixed => $e->withConvertEmptyToNull(false)]; + yield 'withHeader' => [static fn(ExcelExtractor $e): mixed => $e->withHeader(true)]; + yield 'withOffset' => [static fn(ExcelExtractor $e): mixed => $e->withOffset(1)]; + yield 'withReader' => [static fn(ExcelExtractor $e): mixed => $e->withReader(ExcelReader::XLSX)]; + yield 'withSheetName' => [static fn(ExcelExtractor $e): mixed => $e->withSheetName('Sheet1')]; + } + + #[DataProvider('sample_dropping_setters')] + public function test_a_changed_read_option_drops_the_sampled_rows(Closure $setter): void + { + $extractor = from_excel(ExcelFixtureContext::path('sniff/a')); + $extractor->schema(); + + static::assertEquals(Cardinality::exact(10), $extractor->statistics()->rows); + + $setter($extractor); + + static::assertEquals(Cardinality::unknown(), $extractor->statistics()->rows); + } + + public function test_a_declared_schema_declares_unknown_rows(): void + { + $extractor = from_excel(ExcelFixtureContext::path('sniff/a'))->withSchema(schema(int_schema('id'))); + $extractor->schema(); + + static::assertEquals(Cardinality::unknown(), $extractor->statistics()->rows); + } + + public function test_a_member_without_a_size_makes_the_size_unknown(): void + { + $extractor = from_excel( + ExcelFixtureContext::path('sniff/*'), + new UnsizedFilesystem(native_local_filesystem(), [ExcelFixtureContext::path('sniff/b')->uri()]), + ); + + static::assertEquals(Cardinality::unknown(), $extractor->statistics()->size); + } + + public function test_a_sample_that_read_every_sheet_declares_exact_rows(): void + { + $extractor = from_excel(ExcelFixtureContext::path('sniff/*')); + $extractor->schema(); + + static::assertEquals(Cardinality::exact(20), $extractor->statistics()->rows); + } + + public function test_a_sample_that_stopped_early_declares_unknown_rows(): void + { + $extractor = from_excel(ExcelFixtureContext::path('sniff/*'))->inferSchema(infer_schema()->sampleSize(3)); + $extractor->schema(); + + static::assertEquals(Cardinality::unknown(), $extractor->statistics()->rows); + } + + public function test_extract_samples_like_schema_does(): void + { + $extractor = from_excel(ExcelFixtureContext::path('sniff/a')); + + iterator_to_array($extractor->extract(flow_context()), false); + + static::assertEquals(Cardinality::exact(10), $extractor->statistics()->rows); + } + + public function test_it_declares_the_listed_byte_total_exactly(): void + { + static::assertEquals( + Cardinality::exact(17_504), + from_excel(ExcelFixtureContext::path('sniff/*'))->statistics()->size, + ); + } + + public function test_statistics_before_schema_declares_unknown_rows(): void + { + $extractor = from_excel(ExcelFixtureContext::path('sniff/a')); + + static::assertEquals(Cardinality::unknown(), $extractor->statistics()->rows); + + $extractor->schema(); + + static::assertEquals(Cardinality::exact(10), $extractor->statistics()->rows); + } + + public function test_statistics_are_computed_at_most_once(): void + { + $filesystem = new CountingFilesystem(native_local_filesystem()); + $extractor = from_excel(ExcelFixtureContext::path('sniff/a'), $filesystem); + $extractor->schema(); + $listCalls = $filesystem->listCalls; + + static::assertSame($extractor->statistics(), $extractor->statistics()); + static::assertSame($listCalls + 1, $filesystem->listCalls); + } + + public function test_the_listing_is_read_once(): void + { + $filesystem = new CountingFilesystem(native_local_filesystem()); + $extractor = from_excel(ExcelFixtureContext::path('sniff/a'), $filesystem); + + $extractor->statistics(); + $extractor->statistics(); + + static::assertSame(1, $filesystem->listCalls); + } + + public function test_a_filtered_extract_declares_unknown_rows(): void + { + $extractor = from_excel(ExcelFixtureContext::path('sniff/*')); + + iterator_to_array($extractor->extract(flow_context(), null, new KeepPaths([ExcelFixtureContext::path( + 'sniff/a', + )])), false); + + static::assertEquals(Cardinality::unknown(), $extractor->statistics()->rows); + } } diff --git a/src/adapter/etl-adapter-excel/tests/Flow/ETL/Adapter/Excel/Tests/Integration/WorkbookSamplerTest.php b/src/adapter/etl-adapter-excel/tests/Flow/ETL/Adapter/Excel/Tests/Integration/WorkbookSamplerTest.php index 2f968fed9a..bee1633f24 100644 --- a/src/adapter/etl-adapter-excel/tests/Flow/ETL/Adapter/Excel/Tests/Integration/WorkbookSamplerTest.php +++ b/src/adapter/etl-adapter-excel/tests/Flow/ETL/Adapter/Excel/Tests/Integration/WorkbookSamplerTest.php @@ -6,9 +6,10 @@ use Flow\ETL\Adapter\Excel\ExcelReadOptions; use Flow\ETL\Adapter\Excel\Tests\Context\ExcelFixtureContext; +use Flow\ETL\Adapter\Excel\WorkbookSheetSample; +use Flow\ETL\Cardinality; use Flow\ETL\Tests\Double\CountingFilesystem; use Flow\ETL\Tests\FlowTestCase; -use Generator; use function Flow\ETL\DSL\bool_schema; use function Flow\ETL\DSL\date_schema; @@ -233,7 +234,7 @@ public function test_samples_does_not_ration_the_row_budget(): void } } - public function test_samples_yields_one_unstarted_generator_per_sheet(): void + public function test_samples_yields_one_unopened_sample_per_sheet(): void { $filesystem = new CountingFilesystem(native_local_filesystem()); $sampler = ExcelFixtureContext::globSampler('sniff/*', $filesystem); @@ -243,11 +244,11 @@ public function test_samples_yields_one_unstarted_generator_per_sheet(): void $names = $sampler->header()->names; $afterHeader = $filesystem->readFromCalls; - // the first inner generator is the sheet header() read from, so its header row is already spent + // the first sample is the sheet header() read from, so its header row is already spent $firstSheet = iterator_to_array($samples[0], false); static::assertCount(2, $samples); - static::assertContainsOnlyInstancesOf(Generator::class, $samples); + static::assertContainsOnlyInstancesOf(WorkbookSheetSample::class, $samples); static::assertSame(0, $beforeAnyAdvance); static::assertSame(['id', 'name', 'email'], $names); static::assertSame(1, $afterHeader); @@ -341,4 +342,47 @@ public function test_typed_cells_keep_their_types(): void ExcelFixtureContext::infer(ExcelFixtureContext::sampler('int_float_bool.xlsx')), ); } + + public function test_nothing_sampled_declares_unknown_rows(): void + { + static::assertEquals(Cardinality::unknown(), ExcelFixtureContext::sampler('sniff/a')->sampledRows()); + } + + public function test_a_file_the_samples_never_reached_declares_unknown_rows(): void + { + $sampler = ExcelFixtureContext::globSampler('sniff/*'); + $sampler->header(); + + foreach ($sampler->samples(20_480) as $sample) { + iterator_to_array($sample, false); + + break; + } + + static::assertEquals(Cardinality::unknown(), $sampler->sampledRows()); + } + + public function test_samples_that_read_every_sheet_declare_exact_rows(): void + { + $sampler = ExcelFixtureContext::globSampler('sniff/*'); + $sampler->header(); + + foreach ($sampler->samples(20_480) as $sample) { + iterator_to_array($sample, false); + } + + static::assertEquals(Cardinality::exact(20), $sampler->sampledRows()); + } + + public function test_an_unbounded_sample_counts_the_rows_it_streamed(): void + { + $sampler = ExcelFixtureContext::sampler('sniff/a'); + $sampler->header(); + + foreach ($sampler->samples(-1) as $sample) { + iterator_to_array($sample, false); + } + + static::assertEquals(Cardinality::exact(10), $sampler->sampledRows()); + } } diff --git a/src/adapter/etl-adapter-excel/tests/Flow/ETL/Adapter/Excel/Tests/Unit/WorkbookSheetSampleTest.php b/src/adapter/etl-adapter-excel/tests/Flow/ETL/Adapter/Excel/Tests/Unit/WorkbookSheetSampleTest.php new file mode 100644 index 0000000000..45d87eb0a2 --- /dev/null +++ b/src/adapter/etl-adapter-excel/tests/Flow/ETL/Adapter/Excel/Tests/Unit/WorkbookSheetSampleTest.php @@ -0,0 +1,58 @@ + 1]); + yield new RawRowValues(['id' => 2]); + })(), + ); + + foreach ($sample as $_rowValues) { + break; + } + + static::assertSame(1, $sample->rows()); + static::assertFalse($sample->wholeSheet()); + } + + public function test_a_sample_read_to_the_end_is_whole(): void + { + $sample = new WorkbookSheetSample( + (static function () { + yield new RawRowValues(['id' => 1]); + yield new RawRowValues(['id' => 2]); + })(), + ); + + iterator_to_array($sample, false); + + static::assertSame(2, $sample->rows()); + static::assertTrue($sample->wholeSheet()); + } + + public function test_nothing_is_counted_before_iteration(): void + { + $sample = new WorkbookSheetSample( + (static function () { + yield new RawRowValues(['id' => 1]); + })(), + ); + + static::assertSame(0, $sample->rows()); + static::assertFalse($sample->wholeSheet()); + } +} diff --git a/src/adapter/etl-adapter-google-sheet/src/Flow/ETL/Adapter/GoogleSheet/GoogleSheetExtractor.php b/src/adapter/etl-adapter-google-sheet/src/Flow/ETL/Adapter/GoogleSheet/GoogleSheetExtractor.php index b2711f71e3..2472e454e5 100644 --- a/src/adapter/etl-adapter-google-sheet/src/Flow/ETL/Adapter/GoogleSheet/GoogleSheetExtractor.php +++ b/src/adapter/etl-adapter-google-sheet/src/Flow/ETL/Adapter/GoogleSheet/GoogleSheetExtractor.php @@ -4,6 +4,7 @@ namespace Flow\ETL\Adapter\GoogleSheet; +use Flow\ETL\Cardinality; use Flow\ETL\Exception\InferredSchemaException; use Flow\ETL\Exception\InvalidArgumentException; use Flow\ETL\Extractor; @@ -14,6 +15,7 @@ use Flow\ETL\Extractor\MetadataColumnsExtractor; use Flow\ETL\Extractor\RewindableExtractor; use Flow\ETL\Extractor\Signal; +use Flow\ETL\Extractor\Statistics; use Flow\ETL\FlowContext; use Flow\ETL\Row\RawRowValues; use Flow\ETL\Rows; @@ -58,8 +60,15 @@ final class GoogleSheetExtractor implements private int $rowsPerPage = 1000; + /** + * The grid row count the last schema inference fetched; null until one ran. + */ + private ?int $gridRowCount = null; + private ?Schema $schema = null; + private ?Statistics $statistics = null; + public function __construct( private readonly Sheets $service, private readonly string $spreadsheetId, @@ -87,6 +96,11 @@ public function extract(FlowContext $context, ?int $limit = null): Generator $this->inference, $this->readOptions->typer($this->inference->candidates()), ))->infer($sampler->header(), $sampler->samples($this->inference->sampleSize))); + + if ($sampledNow) { + $this->gridRowCount = $sampler->gridRowCount(); + } + $schema = $this->addMetadataColumns ? $base->add(str_schema('_spread_sheet_id'), str_schema('_sheet_name')) : $base; @@ -163,6 +177,8 @@ public function inferSchema(SchemaInferenceBuilder $builder): static { $this->inference = $builder->build(); $this->derivedSchema = null; + $this->gridRowCount = null; + $this->statistics = null; return $this; } @@ -173,20 +189,41 @@ public function schema(): Schema new GoogleSheetReader($this->service, $this->spreadsheetId, $this->columnRange, $this->readOptions), $this->inference->sampleSize, ); + $sampledNow = $this->schema === null && $this->derivedSchema === null; $base = $this->schema ?? ($this->derivedSchema ??= (new SchemaInferrer( $this->inference, $this->readOptions->typer($this->inference->candidates()), ))->infer($sampler->header(), $sampler->samples($this->inference->sampleSize))); + if ($sampledNow) { + $this->gridRowCount = $sampler->gridRowCount(); + } + return $this->addMetadataColumns ? $base->add(str_schema('_spread_sheet_id'), str_schema('_sheet_name')) : $base; } + /** + * The grid's allocated rows bound the populated ones; they come from the sample schema inference already + * fetched, and without one nothing is declared - no request is made just for them. The Sheets API reports no + * byte size. + */ + public function statistics(): Statistics + { + if ($this->gridRowCount === null) { + return new Statistics(); + } + + return $this->statistics ??= new Statistics(rows: Cardinality::atMost($this->gridRowCount)); + } + public function withDropExtraColumns(bool $dropExtraColumns): self { $this->readOptions = $this->readOptions->withDropExtraColumns($dropExtraColumns); $this->derivedSchema = null; + $this->gridRowCount = null; + $this->statistics = null; return $this; } @@ -195,6 +232,8 @@ public function withEmptyToNull(bool $emptyToNull): self { $this->readOptions = $this->readOptions->withEmptyToNull($emptyToNull); $this->derivedSchema = null; + $this->gridRowCount = null; + $this->statistics = null; return $this; } @@ -203,6 +242,8 @@ public function withHeader(bool $withHeader): self { $this->readOptions = $this->readOptions->withHeader($withHeader); $this->derivedSchema = null; + $this->gridRowCount = null; + $this->statistics = null; return $this; } @@ -214,6 +255,8 @@ public function withOptions(array $options): self { $this->readOptions = $this->readOptions->withOptions($options); $this->derivedSchema = null; + $this->gridRowCount = null; + $this->statistics = null; return $this; } diff --git a/src/adapter/etl-adapter-google-sheet/src/Flow/ETL/Adapter/GoogleSheet/GoogleSheetReader.php b/src/adapter/etl-adapter-google-sheet/src/Flow/ETL/Adapter/GoogleSheet/GoogleSheetReader.php index eb07c04d44..c4eae47aac 100644 --- a/src/adapter/etl-adapter-google-sheet/src/Flow/ETL/Adapter/GoogleSheet/GoogleSheetReader.php +++ b/src/adapter/etl-adapter-google-sheet/src/Flow/ETL/Adapter/GoogleSheet/GoogleSheetReader.php @@ -95,6 +95,6 @@ public function sample(int $rows): GoogleSheetSample $values->get($this->spreadsheetId, $range->toString(), $this->readOptions->options)->getValues() ?? [], )); - return new GoogleSheetSample($encoder->headers(), $decoded, $range->endRow >= $rowCount); + return new GoogleSheetSample($encoder->headers(), $decoded, $range->endRow >= $rowCount, $rowCount); } } diff --git a/src/adapter/etl-adapter-google-sheet/src/Flow/ETL/Adapter/GoogleSheet/GoogleSheetSample.php b/src/adapter/etl-adapter-google-sheet/src/Flow/ETL/Adapter/GoogleSheet/GoogleSheetSample.php index e05ecdb94f..77a7380ed4 100644 --- a/src/adapter/etl-adapter-google-sheet/src/Flow/ETL/Adapter/GoogleSheet/GoogleSheetSample.php +++ b/src/adapter/etl-adapter-google-sheet/src/Flow/ETL/Adapter/GoogleSheet/GoogleSheetSample.php @@ -13,10 +13,12 @@ * @param list $rows * @param bool $wholeSheet the range reached the last row of the grid, so $rows IS the sheet and a read can be * served from it instead of fetching the same rows again + * @param int $gridRowCount the rows the grid allocates - an upper bound on the rows it holds */ public function __construct( public array $names, public array $rows, public bool $wholeSheet, + public int $gridRowCount, ) {} } diff --git a/src/adapter/etl-adapter-google-sheet/src/Flow/ETL/Adapter/GoogleSheet/GoogleSheetSampler.php b/src/adapter/etl-adapter-google-sheet/src/Flow/ETL/Adapter/GoogleSheet/GoogleSheetSampler.php index 5e7222ed15..362ad86c77 100644 --- a/src/adapter/etl-adapter-google-sheet/src/Flow/ETL/Adapter/GoogleSheet/GoogleSheetSampler.php +++ b/src/adapter/etl-adapter-google-sheet/src/Flow/ETL/Adapter/GoogleSheet/GoogleSheetSampler.php @@ -54,6 +54,11 @@ public function batches(int $batchSize): ?Generator })(); } + public function gridRowCount(): int + { + return $this->sample()->gridRowCount; + } + /** * @return list */ diff --git a/src/adapter/etl-adapter-google-sheet/tests/Flow/ETL/Adapter/GoogleSheet/Tests/Unit/GoogleSheetExtractorTest.php b/src/adapter/etl-adapter-google-sheet/tests/Flow/ETL/Adapter/GoogleSheet/Tests/Unit/GoogleSheetExtractorTest.php index 96c83a550c..a9f72d5cea 100644 --- a/src/adapter/etl-adapter-google-sheet/tests/Flow/ETL/Adapter/GoogleSheet/Tests/Unit/GoogleSheetExtractorTest.php +++ b/src/adapter/etl-adapter-google-sheet/tests/Flow/ETL/Adapter/GoogleSheet/Tests/Unit/GoogleSheetExtractorTest.php @@ -9,9 +9,11 @@ use Flow\ETL\Adapter\GoogleSheet\Tests\Context\GoogleSheetFixtureContext; use Flow\ETL\Adapter\GoogleSheet\Tests\Double\StubSpreadsheetsResource; use Flow\ETL\Adapter\GoogleSheet\Tests\Mother\SheetValuesMother; +use Flow\ETL\Cardinality; use Flow\ETL\Exception\InferredSchemaException; use Flow\ETL\Exception\InvalidArgumentException; use Flow\ETL\Exception\SchemaMismatchException; +use Flow\ETL\Extractor\Statistics; use Flow\ETL\Rows; use Flow\ETL\Tests\Context\ExtractedRows; use Flow\ETL\Tests\FlowTestCase; @@ -544,4 +546,75 @@ public function test_is_repeatable(): void { static::assertTrue(GoogleSheetFixtureContext::extractor(new Sheets())->isRepeatable()); } + + public function test_it_declares_the_grid_row_count_as_an_upper_bound(): void + { + $values = GoogleSheetFixtureContext::values(SheetValuesMother::range([['id'], ['1']])); + $extractor = GoogleSheetFixtureContext::extractor(GoogleSheetFixtureContext::service(100, $values)); + $extractor->schema(); + + static::assertEquals(Cardinality::atMost(100), $extractor->statistics()->rows); + } + + public function test_it_declares_no_byte_size(): void + { + $values = GoogleSheetFixtureContext::values(SheetValuesMother::range([['id'], ['1']])); + $extractor = GoogleSheetFixtureContext::extractor(GoogleSheetFixtureContext::service(100, $values)); + $extractor->schema(); + + static::assertEquals(Cardinality::unknown(), $extractor->statistics()->size); + } + + public function test_the_grid_row_count_costs_no_extra_request(): void + { + $values = GoogleSheetFixtureContext::values(SheetValuesMother::range([['id'], ['1']])); + $service = GoogleSheetFixtureContext::service(100, $values); + $extractor = GoogleSheetFixtureContext::extractor($service); + + $extractor->schema(); + $statistics = $extractor->statistics(); + + static::assertSame($statistics, $extractor->statistics()); + static::assertCount(1, $values->getCalls); + + /** @var StubSpreadsheetsResource $spreadsheets */ + $spreadsheets = $service->spreadsheets; + static::assertSame(1, $spreadsheets->calls); + } + + public function test_statistics_without_a_sample_declare_nothing_and_request_nothing(): void + { + $values = GoogleSheetFixtureContext::values(); + $service = GoogleSheetFixtureContext::service(100, $values); + + static::assertEquals(new Statistics(), GoogleSheetFixtureContext::extractor($service)->statistics()); + static::assertSame([], $values->getCalls); + + /** @var StubSpreadsheetsResource $spreadsheets */ + $spreadsheets = $service->spreadsheets; + static::assertSame(0, $spreadsheets->calls); + } + + public function test_a_read_samples_the_grid_row_count_too(): void + { + $values = GoogleSheetFixtureContext::valuesAndBatches([SheetValuesMother::range([['id'], ['1'], ['2']])], []); + $extractor = GoogleSheetFixtureContext::extractor(GoogleSheetFixtureContext::service(3, $values)); + + iterator_to_array($extractor->extract(flow_context()), false); + + static::assertEquals(Cardinality::atMost(3), $extractor->statistics()->rows); + } + + #[DataProvider('memo_resetting_setters')] + public function test_shape_changing_setters_drop_the_sampled_statistics(Closure $setter): void + { + $values = GoogleSheetFixtureContext::values(SheetValuesMother::range([['id'], ['1']])); + $extractor = GoogleSheetFixtureContext::extractor(GoogleSheetFixtureContext::service(100, $values)); + + $extractor->schema(); + $extractor->statistics(); + $setter($extractor); + + static::assertEquals(new Statistics(), $extractor->statistics()); + } } diff --git a/src/adapter/etl-adapter-google-sheet/tests/Flow/ETL/Adapter/GoogleSheet/Tests/Unit/GoogleSheetSampleTest.php b/src/adapter/etl-adapter-google-sheet/tests/Flow/ETL/Adapter/GoogleSheet/Tests/Unit/GoogleSheetSampleTest.php index 740fbb5874..3cabc1ae69 100644 --- a/src/adapter/etl-adapter-google-sheet/tests/Flow/ETL/Adapter/GoogleSheet/Tests/Unit/GoogleSheetSampleTest.php +++ b/src/adapter/etl-adapter-google-sheet/tests/Flow/ETL/Adapter/GoogleSheet/Tests/Unit/GoogleSheetSampleTest.php @@ -12,10 +12,11 @@ final class GoogleSheetSampleTest extends FlowTestCase { public function test_carries_names_and_rows(): void { - $sample = new GoogleSheetSample(['a'], [new RawRowValues(['a' => 1])], wholeSheet: true); + $sample = new GoogleSheetSample(['a'], [new RawRowValues(['a' => 1])], wholeSheet: true, gridRowCount: 1000); static::assertSame(['a'], $sample->names); static::assertSame([['a' => 1]], [$sample->rows[0]->values]); static::assertTrue($sample->wholeSheet); + static::assertSame(1000, $sample->gridRowCount); } } diff --git a/src/adapter/etl-adapter-google-sheet/tests/Flow/ETL/Adapter/GoogleSheet/Tests/Unit/GoogleSheetSamplerTest.php b/src/adapter/etl-adapter-google-sheet/tests/Flow/ETL/Adapter/GoogleSheet/Tests/Unit/GoogleSheetSamplerTest.php index be2405a929..32780a6304 100644 --- a/src/adapter/etl-adapter-google-sheet/tests/Flow/ETL/Adapter/GoogleSheet/Tests/Unit/GoogleSheetSamplerTest.php +++ b/src/adapter/etl-adapter-google-sheet/tests/Flow/ETL/Adapter/GoogleSheet/Tests/Unit/GoogleSheetSamplerTest.php @@ -68,4 +68,14 @@ public function test_header_and_samples_share_one_request(): void static::assertCount(1, $values->getCalls); } + + public function test_grid_row_count_comes_from_the_sample_request(): void + { + $values = GoogleSheetFixtureContext::values(SheetValuesMother::range([['id'], ['1']])); + $sampler = GoogleSheetFixtureContext::sampler($values, 2, rowCount: 500); + + static::assertSame(500, $sampler->gridRowCount()); + $sampler->header(); + static::assertCount(1, $values->getCalls); + } } diff --git a/src/adapter/etl-adapter-http/src/Flow/ETL/Adapter/Http/PsrHttpClientDynamicExtractor.php b/src/adapter/etl-adapter-http/src/Flow/ETL/Adapter/Http/PsrHttpClientDynamicExtractor.php index 9f38ffddf5..863b36448a 100644 --- a/src/adapter/etl-adapter-http/src/Flow/ETL/Adapter/Http/PsrHttpClientDynamicExtractor.php +++ b/src/adapter/etl-adapter-http/src/Flow/ETL/Adapter/Http/PsrHttpClientDynamicExtractor.php @@ -7,6 +7,7 @@ use Flow\ETL\Adapter\Http\DynamicExtractor\NextRequestFactory; use Flow\ETL\Extractor; use Flow\ETL\Extractor\Signal; +use Flow\ETL\Extractor\Statistics; use Flow\ETL\FlowContext; use Flow\ETL\Rows; use Flow\ETL\Schema; @@ -126,4 +127,9 @@ public function withSchema(Schema $schema): static return $this; } + + public function statistics(): Statistics + { + return new Statistics(); + } } diff --git a/src/adapter/etl-adapter-http/src/Flow/ETL/Adapter/Http/PsrHttpClientPaginatedExtractor.php b/src/adapter/etl-adapter-http/src/Flow/ETL/Adapter/Http/PsrHttpClientPaginatedExtractor.php index 3b51857e37..481cc8dac3 100644 --- a/src/adapter/etl-adapter-http/src/Flow/ETL/Adapter/Http/PsrHttpClientPaginatedExtractor.php +++ b/src/adapter/etl-adapter-http/src/Flow/ETL/Adapter/Http/PsrHttpClientPaginatedExtractor.php @@ -8,6 +8,7 @@ use Flow\ETL\Adapter\Http\Pagination\Paginator; use Flow\ETL\Extractor; use Flow\ETL\Extractor\Signal; +use Flow\ETL\Extractor\Statistics; use Flow\ETL\FlowContext; use Flow\ETL\Rows; use Flow\ETL\Schema; @@ -130,4 +131,9 @@ public function withSchema(Schema $schema): static return $this; } + + public function statistics(): Statistics + { + return new Statistics(); + } } diff --git a/src/adapter/etl-adapter-http/src/Flow/ETL/Adapter/Http/PsrHttpClientStaticExtractor.php b/src/adapter/etl-adapter-http/src/Flow/ETL/Adapter/Http/PsrHttpClientStaticExtractor.php index 1c1f880c36..97210c3fc3 100644 --- a/src/adapter/etl-adapter-http/src/Flow/ETL/Adapter/Http/PsrHttpClientStaticExtractor.php +++ b/src/adapter/etl-adapter-http/src/Flow/ETL/Adapter/Http/PsrHttpClientStaticExtractor.php @@ -6,6 +6,7 @@ use Flow\ETL\Extractor; use Flow\ETL\Extractor\Signal; +use Flow\ETL\Extractor\Statistics; use Flow\ETL\FlowContext; use Flow\ETL\Rows; use Flow\ETL\Schema; @@ -121,4 +122,9 @@ public function withSchema(Schema $schema): static return $this; } + + public function statistics(): Statistics + { + return new Statistics(); + } } diff --git a/src/adapter/etl-adapter-json/src/Flow/ETL/Adapter/JSON/JSONMachine/JsonExtractor.php b/src/adapter/etl-adapter-json/src/Flow/ETL/Adapter/JSON/JSONMachine/JsonExtractor.php index 23af3904af..fe306d8b1f 100644 --- a/src/adapter/etl-adapter-json/src/Flow/ETL/Adapter/JSON/JSONMachine/JsonExtractor.php +++ b/src/adapter/etl-adapter-json/src/Flow/ETL/Adapter/JSON/JSONMachine/JsonExtractor.php @@ -4,6 +4,7 @@ namespace Flow\ETL\Adapter\JSON\JSONMachine; +use Flow\ETL\Cardinality; use Flow\ETL\Exception\InvalidArgumentException; use Flow\ETL\Extractor; use Flow\ETL\Extractor\BatchableExtractor; @@ -11,9 +12,11 @@ use Flow\ETL\Extractor\FileExtractor; use Flow\ETL\Extractor\FileReading; use Flow\ETL\Extractor\InfersSchema; +use Flow\ETL\Extractor\ListedFiles; use Flow\ETL\Extractor\MetadataColumnsExtractor; use Flow\ETL\Extractor\RewindableExtractor; use Flow\ETL\Extractor\Signal; +use Flow\ETL\Extractor\Statistics; use Flow\ETL\FlowContext; use Flow\ETL\Row\RawRowValues; use Flow\ETL\Rows; @@ -49,10 +52,19 @@ final class JsonExtractor implements private bool $pointerToEntryName = false; + /** + * What the last schema inference sampled; null until one ran. + */ + private ?JsonSampledFiles $sampled = null; + private ?Schema $schema = null; + private ?Statistics $statistics = null; + private readonly Filesystem $filesystem; + private ?ListedFiles $listed = null; + public function __construct( private readonly Path $path, Filesystem $filesystem = new NativeLocalFilesystem(), @@ -101,11 +113,13 @@ public function extract(FlowContext $context, ?int $limit = null, Filter $pathFi $derived = $this->derivedSchema; if ($derived === null) { + $samples = new JsonSamples($reader->samples($this->inference->sampleSize)); $derived = $this->derivedSchema = (new SchemaInferrer($this->inference, new InstanceOfTypeNarrower()))->infer( [], - $reader->samples($this->inference->sampleSize), + $samples, ); + $this->sampled = $samples->sampledFiles(); } $base = $fileColumns->withoutTail($derived); @@ -145,6 +159,8 @@ public function inferSchema(SchemaInferenceBuilder $builder): static { $this->inference = $builder->build(); $this->derivedSchema = null; + $this->sampled = null; + $this->statistics = null; return $this; } @@ -169,11 +185,13 @@ public function schema(): Schema iterator_to_array($this->sourceFiles($this->filesystem, $this->path), false), ); + $samples = new JsonSamples($reader->samples($this->inference->sampleSize)); $derived = $this->derivedSchema = (new SchemaInferrer($this->inference, new InstanceOfTypeNarrower()))->infer( [], - $reader->samples($this->inference->sampleSize), + $samples, ); + $this->sampled = $samples->sampledFiles(); } return $fileColumns->declare($fileColumns->withoutTail($derived)); @@ -189,6 +207,26 @@ public function source(): Path return $this->path; } + /** + * A document has no bytes-per-row unit, so rows are known only when the sample schema inference already read + * every element of every file. + */ + public function statistics(): Statistics + { + if ($this->statistics !== null) { + return $this->statistics; + } + + $this->listed ??= ListedFiles::of($this->sourceFiles($this->filesystem, $this->path)); + $size = $this->listed->bytes; + + if ($this->sampled === null) { + return new Statistics(rows: Cardinality::unknown(), size: $size); + } + + return $this->statistics = new Statistics(rows: $this->sampled->exactRows($this->listed->count), size: $size); + } + /** * @param string $pointer * @param bool $pointerToEntryName - when true pointer will be used as entry name for extracted data @@ -196,6 +234,8 @@ public function source(): Path public function withPointer(string $pointer, bool $pointerToEntryName = false): self { $this->derivedSchema = null; + $this->sampled = null; + $this->statistics = null; $this->pointer = $pointer; $this->pointerToEntryName = $pointerToEntryName; diff --git a/src/adapter/etl-adapter-json/src/Flow/ETL/Adapter/JSON/JSONMachine/JsonFileReader.php b/src/adapter/etl-adapter-json/src/Flow/ETL/Adapter/JSON/JSONMachine/JsonFileReader.php index 3237d46534..89cfbbf2c9 100644 --- a/src/adapter/etl-adapter-json/src/Flow/ETL/Adapter/JSON/JSONMachine/JsonFileReader.php +++ b/src/adapter/etl-adapter-json/src/Flow/ETL/Adapter/JSON/JSONMachine/JsonFileReader.php @@ -18,6 +18,7 @@ use function is_array; use function iterator_to_array; use function json_decode; +use function strlen; use function trim; /** @@ -127,9 +128,12 @@ public function documentItems(SourceStream $stream): Generator * * @return Generator */ - public function lineItems(SourceStream $stream): Generator + public function lineItems(SourceStream $stream, JsonReadBytes $read = new JsonReadBytes()): Generator { foreach ($stream->readLines() as $line) { + // readLines() strips the "\n" and hides whether the last line had one, so that line counts 1 byte more + $read->add(strlen($line) + 1); + if (trim($line, self::C_ISSPACE) === '') { continue; } @@ -155,14 +159,14 @@ public function lineItems(SourceStream $stream): Generator * * @return Generator */ - public function sample(SourceFile $source): Generator + public function sample(SourceFile $source, JsonReadBytes $read = new JsonReadBytes()): Generator { $stream = $this->filesystem->readFrom($source->path); try { $items = match ($this->format) { JsonFormat::Document => $this->documentItems($stream), - JsonFormat::Lines => $this->lineItems($stream), + JsonFormat::Lines => $this->lineItems($stream, $read), }; /** @var array $row */ @@ -186,12 +190,12 @@ public function sample(SourceFile $source): Generator /** * $rowBudget is deliberately unused: sample() is lazy and SchemaInferrer stops advancing it. * - * @return Generator> + * @return Generator */ public function samples(int $rowBudget): iterable { foreach ($this->sources as $source) { - yield $this->sample($source); + yield new JsonFileSample($this, $source); } } } diff --git a/src/adapter/etl-adapter-json/src/Flow/ETL/Adapter/JSON/JSONMachine/JsonFileSample.php b/src/adapter/etl-adapter-json/src/Flow/ETL/Adapter/JSON/JSONMachine/JsonFileSample.php new file mode 100644 index 0000000000..817cda9469 --- /dev/null +++ b/src/adapter/etl-adapter-json/src/Flow/ETL/Adapter/JSON/JSONMachine/JsonFileSample.php @@ -0,0 +1,55 @@ + + */ +final class JsonFileSample implements IteratorAggregate +{ + private ?JsonSampledRows $sampled = null; + + public function __construct( + private readonly JsonFileReader $reader, + private readonly SourceFile $source, + ) {} + + /** + * Records what it read when the consumer stops or the file ends, whichever comes first. + * + * @return Generator + */ + public function getIterator(): Generator + { + $read = new JsonReadBytes(); + $rows = 0; + $wholeFile = false; + + try { + foreach ($this->reader->sample($this->source, $read) as $row) { + $rows++; + + yield $row; + } + + $wholeFile = true; + } finally { + $this->sampled = new JsonSampledRows($rows, $read->total(), $wholeFile); + } + } + + /** + * Null until the sample was iterated. + */ + public function sampled(): ?JsonSampledRows + { + return $this->sampled; + } +} diff --git a/src/adapter/etl-adapter-json/src/Flow/ETL/Adapter/JSON/JSONMachine/JsonLinesExtractor.php b/src/adapter/etl-adapter-json/src/Flow/ETL/Adapter/JSON/JSONMachine/JsonLinesExtractor.php index 768b3a295f..249feaac05 100644 --- a/src/adapter/etl-adapter-json/src/Flow/ETL/Adapter/JSON/JSONMachine/JsonLinesExtractor.php +++ b/src/adapter/etl-adapter-json/src/Flow/ETL/Adapter/JSON/JSONMachine/JsonLinesExtractor.php @@ -4,6 +4,7 @@ namespace Flow\ETL\Adapter\JSON\JSONMachine; +use Flow\ETL\Cardinality; use Flow\ETL\Exception\InvalidArgumentException; use Flow\ETL\Extractor; use Flow\ETL\Extractor\BatchableExtractor; @@ -11,9 +12,11 @@ use Flow\ETL\Extractor\FileExtractor; use Flow\ETL\Extractor\FileReading; use Flow\ETL\Extractor\InfersSchema; +use Flow\ETL\Extractor\ListedFiles; use Flow\ETL\Extractor\MetadataColumnsExtractor; use Flow\ETL\Extractor\RewindableExtractor; use Flow\ETL\Extractor\Signal; +use Flow\ETL\Extractor\Statistics; use Flow\ETL\FlowContext; use Flow\ETL\Row\RawRowValues; use Flow\ETL\Rows; @@ -49,10 +52,19 @@ final class JsonLinesExtractor implements private bool $pointerToEntryName = false; + /** + * What the last schema inference sampled; null until one ran. + */ + private ?JsonSampledFiles $sampled = null; + private ?Schema $schema = null; + private ?Statistics $statistics = null; + private readonly Filesystem $filesystem; + private ?ListedFiles $listed = null; + public function __construct( private readonly Path $path, Filesystem $filesystem = new NativeLocalFilesystem(), @@ -101,11 +113,13 @@ public function extract(FlowContext $context, ?int $limit = null, Filter $pathFi $derived = $this->derivedSchema; if ($derived === null) { + $samples = new JsonSamples($reader->samples($this->inference->sampleSize)); $derived = $this->derivedSchema = (new SchemaInferrer($this->inference, new InstanceOfTypeNarrower()))->infer( [], - $reader->samples($this->inference->sampleSize), + $samples, ); + $this->sampled = $samples->sampledFiles(); } $base = $fileColumns->withoutTail($derived); @@ -145,6 +159,8 @@ public function inferSchema(SchemaInferenceBuilder $builder): static { $this->inference = $builder->build(); $this->derivedSchema = null; + $this->sampled = null; + $this->statistics = null; return $this; } @@ -169,11 +185,13 @@ public function schema(): Schema iterator_to_array($this->sourceFiles($this->filesystem, $this->path), false), ); + $samples = new JsonSamples($reader->samples($this->inference->sampleSize)); $derived = $this->derivedSchema = (new SchemaInferrer($this->inference, new InstanceOfTypeNarrower()))->infer( [], - $reader->samples($this->inference->sampleSize), + $samples, ); + $this->sampled = $samples->sampledFiles(); } return $fileColumns->declare($fileColumns->withoutTail($derived)); @@ -189,6 +207,29 @@ public function source(): Path return $this->path; } + /** + * Rows come from the sample schema inference already read; without one they are unknown, a sample is never read + * just for them. + */ + public function statistics(): Statistics + { + if ($this->statistics !== null) { + return $this->statistics; + } + + $this->listed ??= ListedFiles::of($this->sourceFiles($this->filesystem, $this->path)); + $size = $this->listed->bytes; + + if ($this->sampled === null) { + return new Statistics(rows: Cardinality::unknown(), size: $size); + } + + return $this->statistics = new Statistics( + rows: $this->sampled->estimatedRows($this->listed->count, $size), + size: $size, + ); + } + /** * @param string $pointer * @param bool $pointerToEntryName - when true pointer will be used as entry name for extracted data @@ -196,6 +237,8 @@ public function source(): Path public function withPointer(string $pointer, bool $pointerToEntryName = false): self { $this->derivedSchema = null; + $this->sampled = null; + $this->statistics = null; $this->pointer = $pointer; $this->pointerToEntryName = $pointerToEntryName; diff --git a/src/adapter/etl-adapter-json/src/Flow/ETL/Adapter/JSON/JSONMachine/JsonReadBytes.php b/src/adapter/etl-adapter-json/src/Flow/ETL/Adapter/JSON/JSONMachine/JsonReadBytes.php new file mode 100644 index 0000000000..65d8fdb6d6 --- /dev/null +++ b/src/adapter/etl-adapter-json/src/Flow/ETL/Adapter/JSON/JSONMachine/JsonReadBytes.php @@ -0,0 +1,29 @@ + + */ + private int $total = 0; + + /** + * @param int<0, max> $bytes + */ + public function add(int $bytes): void + { + $this->total += $bytes; + } + + /** + * @return int<0, max> + */ + public function total(): int + { + return $this->total; + } +} diff --git a/src/adapter/etl-adapter-json/src/Flow/ETL/Adapter/JSON/JSONMachine/JsonSampledFiles.php b/src/adapter/etl-adapter-json/src/Flow/ETL/Adapter/JSON/JSONMachine/JsonSampledFiles.php new file mode 100644 index 0000000000..457a6eccea --- /dev/null +++ b/src/adapter/etl-adapter-json/src/Flow/ETL/Adapter/JSON/JSONMachine/JsonSampledFiles.php @@ -0,0 +1,52 @@ +whole && $this->files === $listedFiles ? Cardinality::exact($this->rows) : Cardinality::unknown(); + } + + /** + * Listed bytes over the mean bytes of the sampled rows; exact when the samples read every row of every listed file. + */ + public function estimatedRows(int $listedFiles, Cardinality $listedBytes): Cardinality + { + if ($this->whole && $this->files === $listedFiles) { + return Cardinality::exact($this->rows); + } + + if ($listedBytes->estimate === null || $this->rows === 0 || $this->bytes === 0) { + return Cardinality::unknown(); + } + + return Cardinality::approximately( + (int) round($listedBytes->estimate / ($this->bytes / $this->rows)), + Cardinality::DEFAULT_RELATIVE_ERROR, + ); + } +} diff --git a/src/adapter/etl-adapter-json/src/Flow/ETL/Adapter/JSON/JSONMachine/JsonSampledRows.php b/src/adapter/etl-adapter-json/src/Flow/ETL/Adapter/JSON/JSONMachine/JsonSampledRows.php new file mode 100644 index 0000000000..8d0c68509c --- /dev/null +++ b/src/adapter/etl-adapter-json/src/Flow/ETL/Adapter/JSON/JSONMachine/JsonSampledRows.php @@ -0,0 +1,40 @@ + + */ + public int $bytes; + + /** + * @var int<0, max> + */ + public int $rows; + + /** + * @param int $rows rows read while sampling + * @param int $bytes bytes of the lines those rows came from, line endings and blank lines included; 0 for a document + * @param bool $wholeFile the sample reached the end of the file, so $rows is every row it holds + */ + public function __construct( + int $rows, + int $bytes, + public bool $wholeFile, + ) { + if ($rows < 0 || $bytes < 0) { + throw new InvalidArgumentException( + 'Sampled rows and bytes must not be negative, given: ' . $rows . ' rows, ' . $bytes . ' bytes', + ); + } + + $this->rows = $rows; + $this->bytes = $bytes; + } +} diff --git a/src/adapter/etl-adapter-json/src/Flow/ETL/Adapter/JSON/JSONMachine/JsonSamples.php b/src/adapter/etl-adapter-json/src/Flow/ETL/Adapter/JSON/JSONMachine/JsonSamples.php new file mode 100644 index 0000000000..0de9d8a3bb --- /dev/null +++ b/src/adapter/etl-adapter-json/src/Flow/ETL/Adapter/JSON/JSONMachine/JsonSamples.php @@ -0,0 +1,63 @@ + + */ +final class JsonSamples implements IteratorAggregate +{ + /** + * @var list + */ + private array $pulled = []; + + /** + * @param iterable $samples + */ + public function __construct( + private readonly iterable $samples, + ) {} + + /** + * Keeps only the samples the consumer pulled: a listing the inference stopped short of costs nothing. + * + * @return Generator + */ + public function getIterator(): Generator + { + foreach ($this->samples as $sample) { + $this->pulled[] = $sample; + + yield $sample; + } + } + + public function sampledFiles(): JsonSampledFiles + { + $files = 0; + $rows = 0; + $bytes = 0; + $whole = true; + + foreach ($this->pulled as $sample) { + $sampled = $sample->sampled(); + + if ($sampled === null) { + continue; + } + + $files++; + $rows += $sampled->rows; + $bytes += $sampled->bytes; + $whole = $whole && $sampled->wholeFile; + } + + return new JsonSampledFiles($files, $rows, $bytes, $whole); + } +} diff --git a/src/adapter/etl-adapter-json/tests/Flow/ETL/Adapter/JSON/Tests/Integration/JSONMachine/JsonExtractorTest.php b/src/adapter/etl-adapter-json/tests/Flow/ETL/Adapter/JSON/Tests/Integration/JSONMachine/JsonExtractorTest.php index b867f3fc00..fe1ed868bf 100644 --- a/src/adapter/etl-adapter-json/tests/Flow/ETL/Adapter/JSON/Tests/Integration/JSONMachine/JsonExtractorTest.php +++ b/src/adapter/etl-adapter-json/tests/Flow/ETL/Adapter/JSON/Tests/Integration/JSONMachine/JsonExtractorTest.php @@ -7,12 +7,14 @@ use Closure; use Flow\ETL\Adapter\JSON\JSONMachine\JsonExtractor; use Flow\ETL\Adapter\JSON\Tests\Context\JsonFixtureContext; +use Flow\ETL\Cardinality; use Flow\ETL\Config; use Flow\ETL\Exception\InvalidArgumentException; use Flow\ETL\Exception\SchemaMismatchException; use Flow\ETL\Extractor\Signal; use Flow\ETL\Schema\Definition\StringDefinition; use Flow\ETL\Tests\Context\ExtractedRows; +use Flow\ETL\Tests\Context\MemoryFiles; use Flow\ETL\Tests\Double\CountingFilesystem; use Flow\ETL\Tests\FlowTestCase; use Flow\Filesystem\Local\NativeLocalFilesystem; @@ -581,4 +583,108 @@ public function test_is_repeatable(): void { static::assertTrue(from_json(__DIR__ . '/../../Fixtures/timezones.json')->isRepeatable()); } + + /** + * @param Closure(JsonExtractor): void $setter + */ + #[DataProvider('shapeChangingSetters')] + public function test_a_shape_changing_setter_drops_the_sample(Closure $setter): void + { + $extractor = from_json(JsonFixtureContext::path('five_rows.json')); + $extractor->schema(); + + static::assertEquals(Cardinality::exact(5), $extractor->statistics()->rows); + + $setter($extractor); + + static::assertEquals(Cardinality::unknown(), $extractor->statistics()->rows); + } + + public function test_a_declared_schema_declares_unknown_rows(): void + { + $extractor = from_json(JsonFixtureContext::path('five_rows.json'), schema: schema(int_schema('id'))); + $extractor->schema(); + + static::assertEquals(Cardinality::unknown(), $extractor->statistics()->rows); + static::assertEquals(Cardinality::exact(46), $extractor->statistics()->size); + } + + public function test_a_document_the_sample_read_whole_declares_an_exact_row_count(): void + { + $extractor = from_json(JsonFixtureContext::path('five_rows.json')); + $extractor->schema(); + + static::assertEquals(Cardinality::exact(5), $extractor->statistics()->rows); + } + + public function test_a_document_the_sample_did_not_finish_declares_unknown_rows(): void + { + $extractor = from_json(JsonFixtureContext::path('five_rows.json'))->inferSchema(infer_schema()->sampleSize(2)); + $extractor->schema(); + + static::assertEquals(Cardinality::unknown(), $extractor->statistics()->rows); + } + + public function test_a_file_the_sample_never_opened_declares_unknown_rows(): void + { + $extractor = from_json(JsonFixtureContext::path('glob_with_empty/*.json')) + ->inferSchema(infer_schema()->filesToSniff(1)); + $extractor->schema(); + + static::assertEquals(Cardinality::unknown(), $extractor->statistics()->rows); + } + + public function test_a_member_without_a_size_makes_the_size_unknown(): void + { + static::assertEquals( + Cardinality::unknown(), + from_json(JsonFixtureContext::path('glob_with_empty/*.json'))->statistics()->size, + ); + } + + public function test_it_declares_the_listed_byte_total_exactly(): void + { + $extractor = from_json( + path('memory://dir/*.json'), + filesystem: MemoryFiles::with([ + 'memory://dir/a.json' => '[{"id":1}]', + 'memory://dir/b.json' => '[{"id":2},{"id":3}]', + ]), + ); + + static::assertEquals(Cardinality::exact(29), $extractor->statistics()->size); + } + + public function test_statistics_before_schema_declares_unknown_rows(): void + { + $extractor = from_json(JsonFixtureContext::path('five_rows.json')); + + static::assertEquals(Cardinality::unknown(), $extractor->statistics()->rows); + + $extractor->schema(); + + static::assertEquals(Cardinality::exact(5), $extractor->statistics()->rows); + } + + public function test_statistics_are_computed_at_most_once(): void + { + $filesystem = new CountingFilesystem(new NativeLocalFilesystem()); + $extractor = from_json(JsonFixtureContext::path('five_rows.json'), filesystem: $filesystem); + $extractor->schema(); + $listCalls = $filesystem->listCalls; + + static::assertSame($extractor->statistics(), $extractor->statistics()); + static::assertSame($listCalls + 1, $filesystem->listCalls); + } + + public function test_the_listing_is_read_once(): void + { + $filesystem = new CountingFilesystem(new NativeLocalFilesystem()); + $extractor = from_json(JsonFixtureContext::path('five_rows.json'), filesystem: $filesystem); + + $extractor->statistics(); + $extractor->statistics(); + + static::assertSame(1, $filesystem->listCalls); + } } diff --git a/src/adapter/etl-adapter-json/tests/Flow/ETL/Adapter/JSON/Tests/Integration/JSONMachine/JsonFileReaderTest.php b/src/adapter/etl-adapter-json/tests/Flow/ETL/Adapter/JSON/Tests/Integration/JSONMachine/JsonFileReaderTest.php index 1491a63b58..443fef7467 100644 --- a/src/adapter/etl-adapter-json/tests/Flow/ETL/Adapter/JSON/Tests/Integration/JSONMachine/JsonFileReaderTest.php +++ b/src/adapter/etl-adapter-json/tests/Flow/ETL/Adapter/JSON/Tests/Integration/JSONMachine/JsonFileReaderTest.php @@ -4,13 +4,13 @@ namespace Flow\ETL\Adapter\JSON\Tests\Integration\JSONMachine; +use Flow\ETL\Adapter\JSON\JSONMachine\JsonFileSample; use Flow\ETL\Adapter\JSON\JSONMachine\JsonFormat; use Flow\ETL\Adapter\JSON\Tests\Context\JsonFixtureContext; use Flow\ETL\Row\RawRowValues; use Flow\ETL\Tests\Double\CountingFilesystem; use Flow\ETL\Tests\FlowTestCase; use Flow\Filesystem\Local\NativeLocalFilesystem; -use Generator; use JsonMachine\Exception\SyntaxErrorException; use JsonMachine\Items; use JsonMachine\JsonDecoder\ExtJsonDecoder; @@ -268,7 +268,7 @@ public function test_a_glob_reader_samples_every_listed_file_in_listing_order(): static::assertSame(2, $counting->closedStreams()); } - public function test_samples_yields_one_unstarted_generator_per_source_in_listing_order(): void + public function test_samples_yields_one_unopened_sample_per_source_in_listing_order(): void { $counting = new CountingFilesystem(new NativeLocalFilesystem()); $reader = JsonFixtureContext::reader( @@ -280,12 +280,12 @@ public function test_samples_yields_one_unstarted_generator_per_source_in_listin $samples = iterator_to_array($reader->samples(10), false); $beforeAdvance = $counting->readFromCalls; - $first = $samples[0]->current(); + $first = $samples[0]->getIterator()->current(); $afterAdvance = $counting->readFromCalls; static::assertCount(2, $samples); - static::assertContainsOnlyInstancesOf(Generator::class, $samples); - static::assertSame(0, $beforeAdvance, 'samples() opens nothing until an inner generator is advanced'); + static::assertContainsOnlyInstancesOf(JsonFileSample::class, $samples); + static::assertSame(0, $beforeAdvance, 'samples() opens nothing until a sample is iterated'); static::assertSame(1, $afterAdvance, 'advancing the first sample opens exactly the first source'); static::assertNotNull($first); static::assertSame(['id' => 1], $first->values); diff --git a/src/adapter/etl-adapter-json/tests/Flow/ETL/Adapter/JSON/Tests/Integration/JSONMachine/JsonFileSampleTest.php b/src/adapter/etl-adapter-json/tests/Flow/ETL/Adapter/JSON/Tests/Integration/JSONMachine/JsonFileSampleTest.php new file mode 100644 index 0000000000..624c03290e --- /dev/null +++ b/src/adapter/etl-adapter-json/tests/Flow/ETL/Adapter/JSON/Tests/Integration/JSONMachine/JsonFileSampleTest.php @@ -0,0 +1,90 @@ +sampled()); + } + + public function test_a_document_counts_rows_and_no_bytes(): void + { + $sample = new JsonFileSample(JsonFixtureContext::reader(), JsonFixtureContext::source('five_rows.json')); + + iterator_to_array($sample, false); + + static::assertEquals(new JsonSampledRows(5, 0, true), $sample->sampled()); + } + + public function test_a_last_line_without_a_line_ending_counts_one_byte_more(): void + { + $sample = new JsonFileSample( + JsonFixtureContext::reader(JsonFormat::Lines, MemoryFiles::with(['memory://source.jsonl' => '{"id":1}'])), + new SourceFile(path('memory://source.jsonl')), + ); + + iterator_to_array($sample, false); + + static::assertEquals(new JsonSampledRows(1, 9, true), $sample->sampled()); + } + + public function test_a_sample_stopped_early_records_what_it_read(): void + { + $sample = new JsonFileSample( + JsonFixtureContext::reader(JsonFormat::Lines), + JsonFixtureContext::source('five_rows.jsonl'), + ); + + foreach ($sample as $index => $_row) { + if ($index === 1) { + break; + } + } + + static::assertEquals(new JsonSampledRows(2, 18, false), $sample->sampled()); + } + + public function test_a_whole_file_records_every_row_and_byte(): void + { + $sample = new JsonFileSample( + JsonFixtureContext::reader(JsonFormat::Lines), + JsonFixtureContext::source('five_rows.jsonl'), + ); + + iterator_to_array($sample, false); + + static::assertEquals(new JsonSampledRows(5, 45, true), $sample->sampled()); + } + + public function test_nothing_is_recorded_before_the_sample_is_iterated(): void + { + static::assertNull( + (new JsonFileSample( + JsonFixtureContext::reader(JsonFormat::Lines), + JsonFixtureContext::source('five_rows.jsonl'), + ))->sampled(), + ); + } +} diff --git a/src/adapter/etl-adapter-json/tests/Flow/ETL/Adapter/JSON/Tests/Integration/JSONMachine/JsonLinesExtractorTest.php b/src/adapter/etl-adapter-json/tests/Flow/ETL/Adapter/JSON/Tests/Integration/JSONMachine/JsonLinesExtractorTest.php index b3bb2c4d44..dc9a26c557 100644 --- a/src/adapter/etl-adapter-json/tests/Flow/ETL/Adapter/JSON/Tests/Integration/JSONMachine/JsonLinesExtractorTest.php +++ b/src/adapter/etl-adapter-json/tests/Flow/ETL/Adapter/JSON/Tests/Integration/JSONMachine/JsonLinesExtractorTest.php @@ -7,12 +7,14 @@ use Closure; use Flow\ETL\Adapter\JSON\JSONMachine\JsonLinesExtractor; use Flow\ETL\Adapter\JSON\Tests\Context\JsonFixtureContext; +use Flow\ETL\Cardinality; use Flow\ETL\Config; use Flow\ETL\Exception\InvalidArgumentException; use Flow\ETL\Exception\SchemaMismatchException; use Flow\ETL\Extractor\Signal; use Flow\ETL\Schema\Definition\StringDefinition; use Flow\ETL\Tests\Context\ExtractedRows; +use Flow\ETL\Tests\Context\MemoryFiles; use Flow\ETL\Tests\Double\CountingFilesystem; use Flow\ETL\Tests\FlowTestCase; use Flow\Filesystem\Local\NativeLocalFilesystem; @@ -20,6 +22,7 @@ use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\TestWith; +use function abs; use function array_keys; use function array_sum; use function Flow\ETL\Adapter\JSON\from_json_lines; @@ -40,6 +43,8 @@ use function Flow\Types\DSL\type_integer; use function iterator_to_array; use function max; +use function sprintf; +use function str_repeat; final class JsonLinesExtractorTest extends FlowTestCase { @@ -598,4 +603,140 @@ public function test_is_repeatable(): void { static::assertTrue(from_json_lines(__DIR__ . '/../../Fixtures/timezones.jsonl')->isRepeatable()); } + + /** + * @param Closure(JsonLinesExtractor): void $setter + */ + #[DataProvider('shapeChangingSetters')] + public function test_a_shape_changing_setter_drops_the_sample(Closure $setter): void + { + $extractor = from_json_lines(JsonFixtureContext::path('five_rows.jsonl')); + $extractor->schema(); + + static::assertEquals(Cardinality::exact(5), $extractor->statistics()->rows); + + $setter($extractor); + + static::assertEquals(Cardinality::unknown(), $extractor->statistics()->rows); + } + + public function test_a_declared_schema_declares_unknown_rows(): void + { + $extractor = from_json_lines(JsonFixtureContext::path('five_rows.jsonl'))->withSchema(schema(int_schema('id'))); + $extractor->schema(); + + static::assertEquals(Cardinality::unknown(), $extractor->statistics()->rows); + static::assertEquals(Cardinality::exact(45), $extractor->statistics()->size); + } + + public function test_a_file_shorter_than_the_sample_declares_an_exact_row_count(): void + { + $extractor = from_json_lines(JsonFixtureContext::path('five_rows.jsonl')); + $extractor->schema(); + + static::assertEquals(Cardinality::exact(5), $extractor->statistics()->rows); + } + + public function test_a_member_without_a_size_makes_the_size_unknown(): void + { + $extractor = from_json_lines(JsonFixtureContext::path('glob_with_empty/*.jsonl')) + ->inferSchema(infer_schema()->sampleSize(1)); + $extractor->schema(); + + static::assertEquals(Cardinality::unknown(), $extractor->statistics()->rows); + static::assertEquals(Cardinality::unknown(), $extractor->statistics()->size); + } + + public function test_a_narrow_first_row_does_not_skew_the_estimate(): void + { + $lines = "{\"a\":1}\n"; + + for ($i = 0; $i < 99; $i++) { + $lines .= sprintf("{\"a\":\"%012d\"}\n", $i); + } + + $extractor = from_json_lines( + path('memory://source.jsonl'), + MemoryFiles::with([ + 'memory://source.jsonl' => $lines, + ]), + )->inferSchema(infer_schema()->sampleSize(20)); + $extractor->schema(); + + $rows = $extractor->statistics()->rows; + + static::assertEquals(Cardinality::approximately(103, Cardinality::DEFAULT_RELATIVE_ERROR), $rows); + static::assertLessThanOrEqual($rows->relativeError, abs(103 - 100) / 100); + } + + public function test_extract_samples_like_schema_does(): void + { + $extractor = from_json_lines(JsonFixtureContext::path('five_rows.jsonl')); + + iterator_to_array($extractor->extract(flow_context()), false); + + static::assertEquals(Cardinality::exact(5), $extractor->statistics()->rows); + } + + public function test_it_declares_the_listed_byte_total_exactly(): void + { + $extractor = from_json_lines( + path('memory://dir/*.jsonl'), + MemoryFiles::with([ + 'memory://dir/a.jsonl' => "{\"id\":1}\n", + 'memory://dir/b.jsonl' => "{\"id\":2}\n{\"id\":3}\n", + ]), + ); + + static::assertEquals(Cardinality::exact(27), $extractor->statistics()->size); + } + + public function test_it_estimates_rows_from_bytes_and_the_mean_sampled_row(): void + { + $extractor = from_json_lines( + path('memory://source.jsonl'), + MemoryFiles::with([ + 'memory://source.jsonl' => str_repeat("{\"i\":1234}\n", 100), + ]), + )->inferSchema(infer_schema()->sampleSize(10)); + $extractor->schema(); + + static::assertEquals( + Cardinality::approximately(100, Cardinality::DEFAULT_RELATIVE_ERROR), + $extractor->statistics()->rows, + ); + } + + public function test_statistics_are_computed_at_most_once(): void + { + $filesystem = new CountingFilesystem(new NativeLocalFilesystem()); + $extractor = from_json_lines(JsonFixtureContext::path('five_rows.jsonl'), filesystem: $filesystem); + $extractor->schema(); + $listCalls = $filesystem->listCalls; + + static::assertSame($extractor->statistics(), $extractor->statistics()); + static::assertSame($listCalls + 1, $filesystem->listCalls); + } + + public function test_statistics_before_schema_declares_unknown_rows(): void + { + $extractor = from_json_lines(JsonFixtureContext::path('five_rows.jsonl')); + + static::assertEquals(Cardinality::unknown(), $extractor->statistics()->rows); + + $extractor->schema(); + + static::assertEquals(Cardinality::exact(5), $extractor->statistics()->rows); + } + + public function test_the_listing_is_read_once(): void + { + $filesystem = new CountingFilesystem(new NativeLocalFilesystem()); + $extractor = from_json_lines(JsonFixtureContext::path('five_rows.jsonl'), filesystem: $filesystem); + + $extractor->statistics(); + $extractor->statistics(); + + static::assertSame(1, $filesystem->listCalls); + } } diff --git a/src/adapter/etl-adapter-json/tests/Flow/ETL/Adapter/JSON/Tests/Integration/JSONMachine/JsonSamplesTest.php b/src/adapter/etl-adapter-json/tests/Flow/ETL/Adapter/JSON/Tests/Integration/JSONMachine/JsonSamplesTest.php new file mode 100644 index 0000000000..c6d3ac3b80 --- /dev/null +++ b/src/adapter/etl-adapter-json/tests/Flow/ETL/Adapter/JSON/Tests/Integration/JSONMachine/JsonSamplesTest.php @@ -0,0 +1,81 @@ +sampledFiles()); + } + + public function test_samples_past_the_last_pulled_one_are_never_created(): void + { + $created = 0; + $samples = new JsonSamples( + ( + /** @return Generator */ + static function () use (&$created): Generator { + foreach (['five_rows.jsonl', 'five_rows.jsonl'] as $fixture) { + $created++; + + yield new JsonFileSample( + JsonFixtureContext::reader(JsonFormat::Lines), + JsonFixtureContext::source($fixture), + ); + } + } + )(), + ); + + foreach ($samples as $sample) { + iterator_to_array($sample, false); + + break; + } + + static::assertSame(1, $created); + static::assertEquals(new JsonSampledFiles(1, 5, 45, true), $samples->sampledFiles()); + } + + public function test_a_pulled_sample_that_was_never_iterated_is_not_counted(): void + { + $samples = new JsonSamples([ + new JsonFileSample( + JsonFixtureContext::reader(JsonFormat::Lines), + JsonFixtureContext::source('five_rows.jsonl'), + ), + ]); + + iterator_to_array($samples, false); + + static::assertEquals(new JsonSampledFiles(0, 0, 0, true), $samples->sampledFiles()); + } +} diff --git a/src/adapter/etl-adapter-json/tests/Flow/ETL/Adapter/JSON/Tests/Unit/JSONMachine/JsonReadBytesTest.php b/src/adapter/etl-adapter-json/tests/Flow/ETL/Adapter/JSON/Tests/Unit/JSONMachine/JsonReadBytesTest.php new file mode 100644 index 0000000000..7d3d862790 --- /dev/null +++ b/src/adapter/etl-adapter-json/tests/Flow/ETL/Adapter/JSON/Tests/Unit/JSONMachine/JsonReadBytesTest.php @@ -0,0 +1,25 @@ +total()); + } + + public function test_it_sums_what_was_added(): void + { + $read = new JsonReadBytes(); + $read->add(9); + $read->add(10); + + static::assertSame(19, $read->total()); + } +} diff --git a/src/adapter/etl-adapter-json/tests/Flow/ETL/Adapter/JSON/Tests/Unit/JSONMachine/JsonSampledFilesTest.php b/src/adapter/etl-adapter-json/tests/Flow/ETL/Adapter/JSON/Tests/Unit/JSONMachine/JsonSampledFilesTest.php new file mode 100644 index 0000000000..5c90dc5398 --- /dev/null +++ b/src/adapter/etl-adapter-json/tests/Flow/ETL/Adapter/JSON/Tests/Unit/JSONMachine/JsonSampledFilesTest.php @@ -0,0 +1,53 @@ +estimatedRows(1, Cardinality::exact(1000)), + ); + } + + public function test_every_listed_file_read_whole_is_exact(): void + { + $sampled = new JsonSampledFiles(2, 10, 100, true); + + static::assertEquals(Cardinality::exact(10), $sampled->estimatedRows(2, Cardinality::exact(100))); + static::assertEquals(Cardinality::exact(10), $sampled->exactRows(2)); + } + + public function test_a_listed_file_the_samples_never_read_is_not_exact(): void + { + $sampled = new JsonSampledFiles(1, 10, 100, true); + + static::assertEquals(Cardinality::approximately(20), $sampled->estimatedRows(2, Cardinality::exact(200))); + static::assertEquals(Cardinality::unknown(), $sampled->exactRows(2)); + } + + public function test_a_partial_document_sample_is_unknown(): void + { + static::assertEquals(Cardinality::unknown(), (new JsonSampledFiles(1, 10, 0, false))->exactRows(1)); + } + + public function test_no_listed_bytes_or_no_sampled_bytes_is_unknown(): void + { + static::assertEquals(Cardinality::unknown(), (new JsonSampledFiles(1, 10, 100, false))->estimatedRows( + 1, + Cardinality::unknown(), + )); + static::assertEquals(Cardinality::unknown(), (new JsonSampledFiles(1, 10, 0, false))->estimatedRows( + 1, + Cardinality::exact(100), + )); + } +} diff --git a/src/adapter/etl-adapter-json/tests/Flow/ETL/Adapter/JSON/Tests/Unit/JSONMachine/JsonSampledRowsTest.php b/src/adapter/etl-adapter-json/tests/Flow/ETL/Adapter/JSON/Tests/Unit/JSONMachine/JsonSampledRowsTest.php new file mode 100644 index 0000000000..0359a02575 --- /dev/null +++ b/src/adapter/etl-adapter-json/tests/Flow/ETL/Adapter/JSON/Tests/Unit/JSONMachine/JsonSampledRowsTest.php @@ -0,0 +1,34 @@ +expectException(InvalidArgumentException::class); + $this->expectExceptionMessage( + 'Sampled rows and bytes must not be negative, given: ' . $rows . ' rows, ' . $bytes . ' bytes', + ); + + new JsonSampledRows($rows, $bytes, false); + } + + public function test_it_keeps_what_it_was_given(): void + { + $sampled = new JsonSampledRows(3, 27, true); + + static::assertSame(3, $sampled->rows); + static::assertSame(27, $sampled->bytes); + static::assertTrue($sampled->wholeFile); + } +} diff --git a/src/adapter/etl-adapter-parquet/src/Flow/ETL/Adapter/Parquet/ParquetExtractor.php b/src/adapter/etl-adapter-parquet/src/Flow/ETL/Adapter/Parquet/ParquetExtractor.php index a43c8f7fcd..fd53ee644d 100644 --- a/src/adapter/etl-adapter-parquet/src/Flow/ETL/Adapter/Parquet/ParquetExtractor.php +++ b/src/adapter/etl-adapter-parquet/src/Flow/ETL/Adapter/Parquet/ParquetExtractor.php @@ -14,6 +14,7 @@ use Flow\ETL\Extractor\MetadataColumnsExtractor; use Flow\ETL\Extractor\RewindableExtractor; use Flow\ETL\Extractor\Signal; +use Flow\ETL\Extractor\Statistics; use Flow\ETL\FlowContext; use Flow\ETL\Rows; use Flow\ETL\Schema; @@ -31,6 +32,7 @@ use function array_values; use function count; +use function iterator_count; use function max; use function sprintf; @@ -63,6 +65,8 @@ final class ParquetExtractor implements private SchemaConverter $schemaConverter; + private ?Statistics $statistics = null; + private readonly Filesystem $filesystem; /** @@ -206,6 +210,11 @@ public function schema(): Schema ); } + public function statistics(): Statistics + { + return $this->statistics ??= $this->declare(); + } + /** * Reconcile every listed file's schema instead of trusting the first one. */ @@ -213,6 +222,7 @@ public function unionByName(bool $union = true): self { $this->unionByName = $union; $this->derivedSchema = null; + $this->statistics = null; return $this; } @@ -231,6 +241,7 @@ public function withByteOrder(ByteOrder $byteOrder): self { $this->byteOrder = $byteOrder; $this->derivedSchema = null; + $this->statistics = null; return $this; } @@ -242,6 +253,7 @@ public function withColumns(array $columns): self { $this->columns = $columns; $this->derivedSchema = null; + $this->statistics = null; return $this; } @@ -250,6 +262,7 @@ public function withEngine(?ParquetEngine $engine): self { $this->engine = $engine; $this->derivedSchema = null; + $this->statistics = null; return $this; } @@ -261,6 +274,7 @@ public function withOffset(int $offset): self } $this->offset = $offset; + $this->statistics = null; return $this; } @@ -269,10 +283,24 @@ public function withOptions(Options $options): self { $this->options = $options; $this->derivedSchema = null; + $this->statistics = null; return $this; } + /** + * The footers schema() reads - the first file's, or every file's under unionByName - read once whichever of the + * two asks first, and scaled to the listing when only the first was read. + */ + private function declare(): Statistics + { + $footers = $this->schema === null + ? $this->derivedFooters($this->files(), $this->unionByName) + : $this->footers($this->files(), $this->unionByName); + + return $footers->of(iterator_count($this->sourceFiles($this->filesystem, $this->path)), $this->offset ?? 0); + } + /** * @return Generator */ diff --git a/src/adapter/etl-adapter-parquet/src/Flow/ETL/Adapter/Parquet/ParquetSourceFile.php b/src/adapter/etl-adapter-parquet/src/Flow/ETL/Adapter/Parquet/ParquetSourceFile.php index f518270f70..6ee4a400a4 100644 --- a/src/adapter/etl-adapter-parquet/src/Flow/ETL/Adapter/Parquet/ParquetSourceFile.php +++ b/src/adapter/etl-adapter-parquet/src/Flow/ETL/Adapter/Parquet/ParquetSourceFile.php @@ -4,8 +4,10 @@ namespace Flow\ETL\Adapter\Parquet; +use Flow\ETL\Cardinality; use Flow\ETL\Extractor\SelfDescribingFile; use Flow\ETL\Extractor\SourceFile; +use Flow\ETL\Extractor\Statistics; use Flow\ETL\Schema; use Flow\Filesystem\SourceStream; use Flow\Parquet\ParquetFile; @@ -44,4 +46,19 @@ public function source(): SourceFile { return $this->source; } + + /** + * Uncompressed row-group bytes, as the format reports them. + */ + public function statistics(): Statistics + { + $metadata = $this->file->metadata(); + $bytes = 0; + + foreach ($metadata->rowGroups()->all() as $rowGroup) { + $bytes += $rowGroup->totalByteSize(); + } + + return new Statistics(rows: Cardinality::exact($metadata->rowsNumber()), size: Cardinality::exact($bytes)); + } } diff --git a/src/adapter/etl-adapter-parquet/tests/Flow/ETL/Adapter/Parquet/Tests/Unit/ParquetExtractorTest.php b/src/adapter/etl-adapter-parquet/tests/Flow/ETL/Adapter/Parquet/Tests/Unit/ParquetExtractorTest.php new file mode 100644 index 0000000000..928019bc73 --- /dev/null +++ b/src/adapter/etl-adapter-parquet/tests/Flow/ETL/Adapter/Parquet/Tests/Unit/ParquetExtractorTest.php @@ -0,0 +1,174 @@ +statistics(); + + static::assertEquals( + Cardinality::approximately(5 * 1000, Cardinality::DEFAULT_RELATIVE_ERROR), + $statistics->rows, + ); + static::assertEquals( + Cardinality::approximately(5 * 15934, Cardinality::DEFAULT_RELATIVE_ERROR), + $statistics->size, + ); + } + + public function test_a_single_file_declares_the_footer_row_count(): void + { + static::assertEquals( + Cardinality::exact(1000), + from_parquet(__DIR__ . '/../Integration/Fixtures/Pagination/01_1000.parquet')->statistics()->rows, + ); + } + + public function test_a_single_file_declares_the_uncompressed_byte_size(): void + { + static::assertEquals( + Cardinality::exact(15934), + from_parquet(__DIR__ . '/../Integration/Fixtures/Pagination/01_1000.parquet')->statistics()->size, + ); + } + + public function test_an_offset_is_subtracted_from_the_row_count(): void + { + static::assertEquals( + Cardinality::exact(900), + from_parquet( + __DIR__ . '/../Integration/Fixtures/Pagination/01_1000.parquet', + offset: 100, + )->statistics()->rows, + ); + } + + public function test_an_offset_larger_than_the_file_declares_zero_rows(): void + { + static::assertEquals( + Cardinality::exact(0), + from_parquet( + __DIR__ . '/../Integration/Fixtures/Pagination/01_1000.parquet', + offset: 1_000_000_000, + )->statistics()->rows, + ); + } + + public function test_changing_the_offset_invalidates_the_memo(): void + { + $extractor = from_parquet(__DIR__ . '/../Integration/Fixtures/Pagination/01_1000.parquet'); + + static::assertEquals(Cardinality::exact(1000), $extractor->statistics()->rows); + static::assertEquals(Cardinality::exact(750), $extractor->withOffset(250)->statistics()->rows); + } + + public function test_statistics_are_computed_at_most_once(): void + { + $filesystem = new CountingFilesystem(new NativeLocalFilesystem()); + $extractor = from_parquet( + __DIR__ . '/../Integration/Fixtures/Pagination/01_1000.parquet', + filesystem: $filesystem, + ); + + static::assertSame($extractor->statistics(), $extractor->statistics()); + static::assertSame(1, $filesystem->readFromCalls); + static::assertSame(1, $filesystem->closedStreams()); + } + + public function test_union_by_name_sums_every_footer_exactly(): void + { + $statistics = from_parquet(__DIR__ . '/../Integration/Fixtures/Pagination/*.parquet') + ->unionByName() + ->statistics(); + + static::assertEquals(Cardinality::exact(1000 + 500 + 350 + 2000 + 15), $statistics->rows); + static::assertEquals(Cardinality::exact(15934 + 7932 + 5532 + 32935 + 252), $statistics->size); + } + + /** + * @return Generator + */ + public static function memo_resetting_setters(): Generator + { + yield 'unionByName' => [static fn(ParquetExtractor $e): mixed => $e->unionByName()]; + yield 'withByteOrder' => [static fn(ParquetExtractor $e): mixed => $e->withByteOrder(ByteOrder::LITTLE_ENDIAN)]; + yield 'withColumns' => [static fn(ParquetExtractor $e): mixed => $e->withColumns(['id'])]; + yield 'withEngine' => [static fn(ParquetExtractor $e): mixed => $e->withEngine(null)]; + yield 'withOptions' => [static fn(ParquetExtractor $e): mixed => $e->withOptions(Options::default())]; + } + + #[DataProvider('memo_resetting_setters')] + public function test_a_setter_reads_the_footer_again(Closure $setter): void + { + $filesystem = new CountingFilesystem(new NativeLocalFilesystem()); + $extractor = from_parquet( + __DIR__ . '/../Integration/Fixtures/Pagination/01_1000.parquet', + filesystem: $filesystem, + ); + + $extractor->statistics(); + $setter($extractor); + $extractor->statistics(); + + static::assertSame(2, $filesystem->readFromCalls); + } + + public function test_a_new_offset_reuses_the_footer_already_read(): void + { + $filesystem = new CountingFilesystem(new NativeLocalFilesystem()); + $extractor = from_parquet( + __DIR__ . '/../Integration/Fixtures/Pagination/01_1000.parquet', + filesystem: $filesystem, + ); + + $extractor->statistics(); + $extractor->withOffset(100); + + static::assertEquals(Cardinality::exact(900), $extractor->statistics()->rows); + static::assertSame(1, $filesystem->readFromCalls); + } + + public function test_schema_then_statistics_reads_the_footer_once(): void + { + $filesystem = new CountingFilesystem(new NativeLocalFilesystem()); + $extractor = from_parquet( + __DIR__ . '/../Integration/Fixtures/Pagination/01_1000.parquet', + filesystem: $filesystem, + ); + + $extractor->schema(); + $extractor->statistics(); + + static::assertSame(1, $filesystem->readFromCalls); + } + + public function test_a_declared_schema_still_reads_the_footer_for_statistics(): void + { + static::assertEquals( + Cardinality::exact(1000), + from_parquet(__DIR__ . '/../Integration/Fixtures/Pagination/01_1000.parquet') + ->withSchema(schema(int_schema('id'))) + ->statistics() + ->rows, + ); + } +} diff --git a/src/adapter/etl-adapter-postgresql/src/Flow/ETL/Adapter/PostgreSql/ExplainedRows.php b/src/adapter/etl-adapter-postgresql/src/Flow/ETL/Adapter/PostgreSql/ExplainedRows.php new file mode 100644 index 0000000000..4237c74c1b --- /dev/null +++ b/src/adapter/etl-adapter-postgresql/src/Flow/ETL/Adapter/PostgreSql/ExplainedRows.php @@ -0,0 +1,32 @@ + $parameters + */ + public function of(Client $client, string|Sql $query, array $parameters = [], ?int $maximum = null): Cardinality + { + $estimate = $client->explain($query, $parameters, ExplainConfig::forEstimate())->rootNode()->estimatedRows(); + + return new Cardinality( + atMost: $maximum, + estimate: $maximum === null ? $estimate : min($estimate, $maximum), + relativeError: Cardinality::DEFAULT_RELATIVE_ERROR, + ); + } +} diff --git a/src/adapter/etl-adapter-postgresql/src/Flow/ETL/Adapter/PostgreSql/PostgreSqlCursorExtractor.php b/src/adapter/etl-adapter-postgresql/src/Flow/ETL/Adapter/PostgreSql/PostgreSqlCursorExtractor.php index c8efec2ff5..6575ba692a 100644 --- a/src/adapter/etl-adapter-postgresql/src/Flow/ETL/Adapter/PostgreSql/PostgreSqlCursorExtractor.php +++ b/src/adapter/etl-adapter-postgresql/src/Flow/ETL/Adapter/PostgreSql/PostgreSqlCursorExtractor.php @@ -11,6 +11,7 @@ use Flow\ETL\Extractor\Batches; use Flow\ETL\Extractor\RewindableExtractor; use Flow\ETL\Extractor\Signal; +use Flow\ETL\Extractor\Statistics; use Flow\ETL\FlowContext; use Flow\ETL\Rows; use Flow\ETL\Schema; @@ -50,6 +51,8 @@ final class PostgreSqlCursorExtractor implements BatchableExtractor, Extractor, private ?Schema $schema = null; + private ?Statistics $statistics = null; + /** * @param list $parameters */ @@ -186,6 +189,16 @@ public function schema(): Schema } } + public function statistics(): Statistics + { + return $this->statistics ??= new Statistics(rows: (new ExplainedRows())->of( + $this->client, + $this->query, + $this->parameters, + $this->maximum, + )); + } + public function withCursorName(string $cursorName): self { $this->cursorName = $cursorName; @@ -200,6 +213,7 @@ public function withMaximum(int $maximum): self } $this->maximum = $maximum; + $this->statistics = null; return $this; } diff --git a/src/adapter/etl-adapter-postgresql/src/Flow/ETL/Adapter/PostgreSql/PostgreSqlKeySetExtractor.php b/src/adapter/etl-adapter-postgresql/src/Flow/ETL/Adapter/PostgreSql/PostgreSqlKeySetExtractor.php index bb15917b57..037cb6b9d2 100644 --- a/src/adapter/etl-adapter-postgresql/src/Flow/ETL/Adapter/PostgreSql/PostgreSqlKeySetExtractor.php +++ b/src/adapter/etl-adapter-postgresql/src/Flow/ETL/Adapter/PostgreSql/PostgreSqlKeySetExtractor.php @@ -14,6 +14,7 @@ use Flow\ETL\Extractor\Batches; use Flow\ETL\Extractor\RewindableExtractor; use Flow\ETL\Extractor\Signal; +use Flow\ETL\Extractor\Statistics; use Flow\ETL\FlowContext; use Flow\ETL\Rows; use Flow\ETL\Schema; @@ -47,6 +48,8 @@ final class PostgreSqlKeySetExtractor implements BatchableExtractor, Extractor, private ?Schema $schema = null; + private ?Statistics $statistics = null; + /** * @param list $parameters */ @@ -154,6 +157,16 @@ public function schema(): Schema } } + public function statistics(): Statistics + { + return $this->statistics ??= new Statistics(rows: (new ExplainedRows())->of( + $this->client, + $this->query, + $this->parameters, + $this->maximum, + )); + } + public function withMaximum(int $maximum): self { if ($maximum <= 0) { @@ -161,6 +174,7 @@ public function withMaximum(int $maximum): self } $this->maximum = $maximum; + $this->statistics = null; return $this; } diff --git a/src/adapter/etl-adapter-postgresql/src/Flow/ETL/Adapter/PostgreSql/PostgreSqlLimitOffsetExtractor.php b/src/adapter/etl-adapter-postgresql/src/Flow/ETL/Adapter/PostgreSql/PostgreSqlLimitOffsetExtractor.php index b89e26476c..8d3ea01930 100644 --- a/src/adapter/etl-adapter-postgresql/src/Flow/ETL/Adapter/PostgreSql/PostgreSqlLimitOffsetExtractor.php +++ b/src/adapter/etl-adapter-postgresql/src/Flow/ETL/Adapter/PostgreSql/PostgreSqlLimitOffsetExtractor.php @@ -11,6 +11,7 @@ use Flow\ETL\Extractor\Batches; use Flow\ETL\Extractor\RewindableExtractor; use Flow\ETL\Extractor\Signal; +use Flow\ETL\Extractor\Statistics; use Flow\ETL\FlowContext; use Flow\ETL\Rows; use Flow\ETL\Schema; @@ -36,6 +37,8 @@ final class PostgreSqlLimitOffsetExtractor implements BatchableExtractor, Extrac private ?Schema $schema = null; + private ?Statistics $statistics = null; + /** * @param list $parameters */ @@ -143,6 +146,16 @@ public function schema(): Schema } } + public function statistics(): Statistics + { + return $this->statistics ??= new Statistics(rows: (new ExplainedRows())->of( + $this->client, + $this->query, + $this->parameters, + $this->maximum, + )); + } + public function withMaximum(int $maximum): self { if ($maximum <= 0) { @@ -150,6 +163,7 @@ public function withMaximum(int $maximum): self } $this->maximum = $maximum; + $this->statistics = null; return $this; } diff --git a/src/adapter/etl-adapter-postgresql/tests/Flow/ETL/Adapter/PostgreSql/Tests/Double/SpyClient.php b/src/adapter/etl-adapter-postgresql/tests/Flow/ETL/Adapter/PostgreSql/Tests/Double/SpyClient.php index c6cd29919c..e240caa745 100644 --- a/src/adapter/etl-adapter-postgresql/tests/Flow/ETL/Adapter/PostgreSql/Tests/Double/SpyClient.php +++ b/src/adapter/etl-adapter-postgresql/tests/Flow/ETL/Adapter/PostgreSql/Tests/Double/SpyClient.php @@ -13,7 +13,10 @@ use Flow\PostgreSql\Client\Notification; use Flow\PostgreSql\Client\RowMapper; use Flow\PostgreSql\Client\Types\ValueConverters; +use Flow\PostgreSql\Explain\Plan\Cost; use Flow\PostgreSql\Explain\Plan\Plan; +use Flow\PostgreSql\Explain\Plan\PlanNode; +use Flow\PostgreSql\Explain\Plan\PlanNodeType; use Flow\PostgreSql\QueryBuilder\Schema\ColumnType; use Flow\PostgreSql\QueryBuilder\Sql; use RuntimeException; @@ -50,6 +53,11 @@ final class SpyClient implements Client private int $scalarIntAnswer = 0; + private int $explainAnswer = 0; + + /** @var list, config: null|ExplainConfig}> */ + public array $explained = []; + /** @var array */ private array $executeFailures = []; @@ -81,6 +89,16 @@ public function willFailRollBack(Throwable $failure): self return $this; } + /** + * explain() answers with a root plan node estimating $rows. + */ + public function willExplain(int $rows): self + { + $this->explainAnswer = $rows; + + return $this; + } + public function willCountTotal(int $total): self { $this->scalarIntAnswer = $total; @@ -198,7 +216,14 @@ public function execute(Sql|string $sql, array|ConvertedParameters $parameters = public function explain(Sql|string $sql, array $parameters = [], ?ExplainConfig $config = null): Plan { - throw new RuntimeException('SpyClient does not implement ' . __FUNCTION__); + $this->calls[] = 'explain'; + $this->explained[] = [ + 'sql' => $sql instanceof Sql ? $sql->toSql() : $sql, + 'parameters' => $parameters, + 'config' => $config, + ]; + + return new Plan(new PlanNode(PlanNodeType::SEQ_SCAN, new Cost(0.0, 10.0), $this->explainAnswer, 8)); } public function fetch(Sql|string $sql, array $parameters = []): ?array diff --git a/src/adapter/etl-adapter-postgresql/tests/Flow/ETL/Adapter/PostgreSql/Tests/Integration/PostgreSqlCursorExtractorIntegrationTest.php b/src/adapter/etl-adapter-postgresql/tests/Flow/ETL/Adapter/PostgreSql/Tests/Integration/PostgreSqlCursorExtractorIntegrationTest.php index 96ab6deee7..b6cba927f5 100644 --- a/src/adapter/etl-adapter-postgresql/tests/Flow/ETL/Adapter/PostgreSql/Tests/Integration/PostgreSqlCursorExtractorIntegrationTest.php +++ b/src/adapter/etl-adapter-postgresql/tests/Flow/ETL/Adapter/PostgreSql/Tests/Integration/PostgreSqlCursorExtractorIntegrationTest.php @@ -5,6 +5,7 @@ namespace Flow\ETL\Adapter\PostgreSql\Tests\Integration; use Flow\ETL\Adapter\PostgreSql\Tests\IntegrationTestCase; +use Flow\ETL\Cardinality; use Flow\ETL\Exception\InvalidArgumentException; use Flow\ETL\Exception\SchemaNotDerivableException; use Flow\PostgreSql\Client\Exception\QueryException; @@ -16,6 +17,7 @@ use function Flow\ETL\DSL\from_array; use function Flow\ETL\DSL\int_schema; use function Flow\ETL\DSL\schema; +use function Flow\PostgreSql\DSL\analyze; use function Flow\PostgreSql\DSL\asc; use function Flow\PostgreSql\DSL\binary_expr; use function Flow\PostgreSql\DSL\col; @@ -27,8 +29,10 @@ use function Flow\PostgreSql\DSL\create; use function Flow\PostgreSql\DSL\delete; use function Flow\PostgreSql\DSL\func; +use function Flow\PostgreSql\DSL\gt; use function Flow\PostgreSql\DSL\insert; use function Flow\PostgreSql\DSL\literal; +use function Flow\PostgreSql\DSL\param; use function Flow\PostgreSql\DSL\select; use function Flow\PostgreSql\DSL\star; use function Flow\PostgreSql\DSL\table; @@ -381,4 +385,18 @@ private function insertTestData(int $count): void $this->client->execute($insert); } + + public function test_statistics_are_planned_without_reading_a_row(): void + { + $this->client->execute(analyze()->table($this->tableName)); + + static::assertEquals( + Cardinality::approximately(5), + from_pgsql_cursor( + $this->client, + select(star())->from(table($this->tableName))->where(gt(col('id'), param(1))), + [20], + )->statistics()->rows, + ); + } } diff --git a/src/adapter/etl-adapter-postgresql/tests/Flow/ETL/Adapter/PostgreSql/Tests/Unit/ExplainedRowsTest.php b/src/adapter/etl-adapter-postgresql/tests/Flow/ETL/Adapter/PostgreSql/Tests/Unit/ExplainedRowsTest.php new file mode 100644 index 0000000000..0daa9bc922 --- /dev/null +++ b/src/adapter/etl-adapter-postgresql/tests/Flow/ETL/Adapter/PostgreSql/Tests/Unit/ExplainedRowsTest.php @@ -0,0 +1,41 @@ +of((new SpyClient())->willExplain(7), 'SELECT 1', maximum: 50), + ); + } + + public function test_a_maximum_below_the_estimate_caps_it(): void + { + static::assertEquals( + new Cardinality(atMost: 5, estimate: 5, relativeError: Cardinality::DEFAULT_RELATIVE_ERROR), + (new ExplainedRows())->of((new SpyClient())->willExplain(7), 'SELECT 1', maximum: 5), + ); + } + + public function test_the_plan_is_asked_for_an_estimate_only(): void + { + $client = (new SpyClient())->willExplain(7); + + static::assertEquals(Cardinality::approximately(7), (new ExplainedRows())->of($client, 'SELECT $1', [3])); + static::assertEquals( + [['sql' => 'SELECT $1', 'parameters' => [3], 'config' => ExplainConfig::forEstimate()]], + $client->explained, + ); + } +} diff --git a/src/adapter/etl-adapter-postgresql/tests/Flow/ETL/Adapter/PostgreSql/Tests/Unit/PostgreSqlCursorExtractorTest.php b/src/adapter/etl-adapter-postgresql/tests/Flow/ETL/Adapter/PostgreSql/Tests/Unit/PostgreSqlCursorExtractorTest.php index d3ecbeba17..90d4cc7ec9 100644 --- a/src/adapter/etl-adapter-postgresql/tests/Flow/ETL/Adapter/PostgreSql/Tests/Unit/PostgreSqlCursorExtractorTest.php +++ b/src/adapter/etl-adapter-postgresql/tests/Flow/ETL/Adapter/PostgreSql/Tests/Unit/PostgreSqlCursorExtractorTest.php @@ -7,10 +7,12 @@ use Flow\ETL\Adapter\PostgreSql\Tests\Double\SpyClient; use Flow\ETL\Adapter\PostgreSql\Tests\Double\StubCursor; use Flow\ETL\Adapter\PostgreSql\Tests\Mother\ColumnMother; +use Flow\ETL\Cardinality; use Flow\ETL\Exception\InvalidArgumentException; use Flow\ETL\Exception\SchemaMismatchException; use Flow\ETL\Exception\SchemaNotDerivableException; use Flow\ETL\Tests\FlowTestCase; +use Flow\PostgreSql\AST\Transformers\ExplainConfig; use Flow\PostgreSql\Client\Exception\PostgreSqlError; use Flow\PostgreSql\Client\Exception\QueryException; use RuntimeException; @@ -411,4 +413,45 @@ public function test_is_repeatable(): void { static::assertTrue(from_pgsql_cursor(new SpyClient(), 'SELECT id FROM t')->isRepeatable()); } + + public function test_statistics_are_the_planner_estimate_without_analyze(): void + { + $client = (new SpyClient())->willExplain(42); + + static::assertEquals( + Cardinality::approximately(42), + from_pgsql_cursor($client, 'SELECT * FROM t WHERE id > $1', [1])->statistics()->rows, + ); + static::assertEquals( + [['sql' => 'SELECT * FROM t WHERE id > $1', 'parameters' => [1], 'config' => ExplainConfig::forEstimate()]], + $client->explained, + ); + } + + public function test_statistics_are_explained_once(): void + { + $client = (new SpyClient())->willExplain(42); + $extractor = from_pgsql_cursor($client, 'SELECT * FROM t WHERE id > $1', [1]); + + static::assertSame($extractor->statistics(), $extractor->statistics()); + static::assertSame(1, $client->callsTo('explain')); + } + + public function test_a_maximum_bounds_and_caps_the_estimate_and_a_new_one_drops_the_statistics(): void + { + $client = (new SpyClient())->willExplain(42); + $extractor = from_pgsql_cursor($client, 'SELECT * FROM t WHERE id > $1', [1])->withMaximum(10); + + static::assertEquals( + new Cardinality(atMost: 10, estimate: 10, relativeError: Cardinality::DEFAULT_RELATIVE_ERROR), + $extractor->statistics()->rows, + ); + + $extractor->withMaximum(100); + + static::assertEquals( + new Cardinality(atMost: 100, estimate: 42, relativeError: Cardinality::DEFAULT_RELATIVE_ERROR), + $extractor->statistics()->rows, + ); + } } diff --git a/src/adapter/etl-adapter-postgresql/tests/Flow/ETL/Adapter/PostgreSql/Tests/Unit/PostgreSqlKeySetExtractorTest.php b/src/adapter/etl-adapter-postgresql/tests/Flow/ETL/Adapter/PostgreSql/Tests/Unit/PostgreSqlKeySetExtractorTest.php index 05b1d7e9f2..9876dd9593 100644 --- a/src/adapter/etl-adapter-postgresql/tests/Flow/ETL/Adapter/PostgreSql/Tests/Unit/PostgreSqlKeySetExtractorTest.php +++ b/src/adapter/etl-adapter-postgresql/tests/Flow/ETL/Adapter/PostgreSql/Tests/Unit/PostgreSqlKeySetExtractorTest.php @@ -7,9 +7,11 @@ use Flow\ETL\Adapter\PostgreSql\Tests\Double\SpyClient; use Flow\ETL\Adapter\PostgreSql\Tests\Double\StubCursor; use Flow\ETL\Adapter\PostgreSql\Tests\Mother\ColumnMother; +use Flow\ETL\Cardinality; use Flow\ETL\Exception\InvalidArgumentException; use Flow\ETL\Exception\SchemaNotDerivableException; use Flow\ETL\Tests\FlowTestCase; +use Flow\PostgreSql\AST\Transformers\ExplainConfig; use Flow\PostgreSql\Client\Exception\PostgreSqlError; use Flow\PostgreSql\Client\Exception\QueryException; @@ -337,4 +339,60 @@ public function test_is_repeatable(): void )->isRepeatable(), ); } + + public function test_statistics_are_the_planner_estimate_without_analyze(): void + { + $client = (new SpyClient())->willExplain(42); + + static::assertEquals( + Cardinality::approximately(42), + from_pgsql_key_set( + $client, + 'SELECT * FROM t WHERE id > $1', + pgsql_pagination_key_set(pgsql_pagination_key_asc('id')), + [1], + )->statistics()->rows, + ); + static::assertEquals( + [['sql' => 'SELECT * FROM t WHERE id > $1', 'parameters' => [1], 'config' => ExplainConfig::forEstimate()]], + $client->explained, + ); + } + + public function test_statistics_are_explained_once(): void + { + $client = (new SpyClient())->willExplain(42); + $extractor = from_pgsql_key_set( + $client, + 'SELECT * FROM t WHERE id > $1', + pgsql_pagination_key_set(pgsql_pagination_key_asc('id')), + [1], + ); + + static::assertSame($extractor->statistics(), $extractor->statistics()); + static::assertSame(1, $client->callsTo('explain')); + } + + public function test_a_maximum_bounds_and_caps_the_estimate_and_a_new_one_drops_the_statistics(): void + { + $client = (new SpyClient())->willExplain(42); + $extractor = from_pgsql_key_set( + $client, + 'SELECT * FROM t WHERE id > $1', + pgsql_pagination_key_set(pgsql_pagination_key_asc('id')), + [1], + )->withMaximum(10); + + static::assertEquals( + new Cardinality(atMost: 10, estimate: 10, relativeError: Cardinality::DEFAULT_RELATIVE_ERROR), + $extractor->statistics()->rows, + ); + + $extractor->withMaximum(100); + + static::assertEquals( + new Cardinality(atMost: 100, estimate: 42, relativeError: Cardinality::DEFAULT_RELATIVE_ERROR), + $extractor->statistics()->rows, + ); + } } diff --git a/src/adapter/etl-adapter-postgresql/tests/Flow/ETL/Adapter/PostgreSql/Tests/Unit/PostgreSqlLimitOffsetExtractorTest.php b/src/adapter/etl-adapter-postgresql/tests/Flow/ETL/Adapter/PostgreSql/Tests/Unit/PostgreSqlLimitOffsetExtractorTest.php index e68e4ae5f1..7d1909e5ae 100644 --- a/src/adapter/etl-adapter-postgresql/tests/Flow/ETL/Adapter/PostgreSql/Tests/Unit/PostgreSqlLimitOffsetExtractorTest.php +++ b/src/adapter/etl-adapter-postgresql/tests/Flow/ETL/Adapter/PostgreSql/Tests/Unit/PostgreSqlLimitOffsetExtractorTest.php @@ -7,10 +7,12 @@ use Flow\ETL\Adapter\PostgreSql\Tests\Double\SpyClient; use Flow\ETL\Adapter\PostgreSql\Tests\Double\StubCursor; use Flow\ETL\Adapter\PostgreSql\Tests\Mother\ColumnMother; +use Flow\ETL\Cardinality; use Flow\ETL\Exception\InvalidArgumentException; use Flow\ETL\Exception\SchemaNotDerivableException; use Flow\ETL\Rows; use Flow\ETL\Tests\FlowTestCase; +use Flow\PostgreSql\AST\Transformers\ExplainConfig; use Flow\PostgreSql\Client\Exception\PostgreSqlError; use Flow\PostgreSql\Client\Exception\QueryException; @@ -322,4 +324,45 @@ public function test_is_repeatable(): void { static::assertTrue(from_pgsql_limit_offset(new SpyClient(), 'SELECT id FROM t ORDER BY id')->isRepeatable()); } + + public function test_statistics_are_the_planner_estimate_without_analyze(): void + { + $client = (new SpyClient())->willExplain(42); + + static::assertEquals( + Cardinality::approximately(42), + from_pgsql_limit_offset($client, 'SELECT * FROM t WHERE id > $1', [1])->statistics()->rows, + ); + static::assertEquals( + [['sql' => 'SELECT * FROM t WHERE id > $1', 'parameters' => [1], 'config' => ExplainConfig::forEstimate()]], + $client->explained, + ); + } + + public function test_statistics_are_explained_once(): void + { + $client = (new SpyClient())->willExplain(42); + $extractor = from_pgsql_limit_offset($client, 'SELECT * FROM t WHERE id > $1', [1]); + + static::assertSame($extractor->statistics(), $extractor->statistics()); + static::assertSame(1, $client->callsTo('explain')); + } + + public function test_a_maximum_bounds_and_caps_the_estimate_and_a_new_one_drops_the_statistics(): void + { + $client = (new SpyClient())->willExplain(42); + $extractor = from_pgsql_limit_offset($client, 'SELECT * FROM t WHERE id > $1', [1])->withMaximum(10); + + static::assertEquals( + new Cardinality(atMost: 10, estimate: 10, relativeError: Cardinality::DEFAULT_RELATIVE_ERROR), + $extractor->statistics()->rows, + ); + + $extractor->withMaximum(100); + + static::assertEquals( + new Cardinality(atMost: 100, estimate: 42, relativeError: Cardinality::DEFAULT_RELATIVE_ERROR), + $extractor->statistics()->rows, + ); + } } diff --git a/src/adapter/etl-adapter-text/src/Flow/ETL/Adapter/Text/TextExtractor.php b/src/adapter/etl-adapter-text/src/Flow/ETL/Adapter/Text/TextExtractor.php index abd8604eec..dbe45a2bff 100644 --- a/src/adapter/etl-adapter-text/src/Flow/ETL/Adapter/Text/TextExtractor.php +++ b/src/adapter/etl-adapter-text/src/Flow/ETL/Adapter/Text/TextExtractor.php @@ -10,9 +10,11 @@ use Flow\ETL\Extractor\Batches; use Flow\ETL\Extractor\FileExtractor; use Flow\ETL\Extractor\FileReading; +use Flow\ETL\Extractor\ListedFiles; use Flow\ETL\Extractor\MetadataColumnsExtractor; use Flow\ETL\Extractor\RewindableExtractor; use Flow\ETL\Extractor\Signal; +use Flow\ETL\Extractor\Statistics; use Flow\ETL\FlowContext; use Flow\ETL\Row\RawRowValues; use Flow\ETL\Rows; @@ -43,6 +45,8 @@ final class TextExtractor implements private readonly Filesystem $filesystem; + private ?ListedFiles $listed = null; + public function __construct( private readonly Path $path, Filesystem $filesystem = new NativeLocalFilesystem(), @@ -158,6 +162,13 @@ public function source(): Path return $this->path; } + public function statistics(): Statistics + { + $this->listed ??= ListedFiles::of($this->sourceFiles($this->filesystem, $this->path)); + + return new Statistics(size: $this->listed->bytes); + } + public function withSchema(Schema $schema): static { $this->schema = $schema; diff --git a/src/adapter/etl-adapter-text/tests/Flow/ETL/Adapter/Text/Tests/Integration/TextExtractorTest.php b/src/adapter/etl-adapter-text/tests/Flow/ETL/Adapter/Text/Tests/Integration/TextExtractorTest.php index 517321fd33..f350ac449f 100644 --- a/src/adapter/etl-adapter-text/tests/Flow/ETL/Adapter/Text/Tests/Integration/TextExtractorTest.php +++ b/src/adapter/etl-adapter-text/tests/Flow/ETL/Adapter/Text/Tests/Integration/TextExtractorTest.php @@ -5,9 +5,13 @@ namespace Flow\ETL\Adapter\Text\Tests\Integration; use Flow\ETL\Adapter\Text\TextExtractor; +use Flow\ETL\Cardinality; use Flow\ETL\Extractor\Signal; use Flow\ETL\Tests\Context\ExtractedRows; +use Flow\ETL\Tests\Context\MemoryFiles; +use Flow\ETL\Tests\Double\CountingFilesystem; use Flow\ETL\Tests\Double\RecordingFilesystem; +use Flow\ETL\Tests\Double\UnsizedFilesystem; use Flow\ETL\Tests\FlowTestCase; use function Flow\ETL\Adapter\Text\from_text; @@ -17,6 +21,7 @@ use function Flow\ETL\DSL\schema; use function Flow\ETL\DSL\str_schema; use function Flow\Filesystem\DSL\native_local_filesystem; +use function Flow\Filesystem\DSL\path; use function Flow\Filesystem\DSL\path_real; use function Flow\Types\DSL\type_string; use function iterator_to_array; @@ -143,4 +148,42 @@ public function test_a_stopped_read_closes_its_stream(): void static::assertContains('closeSource', $filesystem->calls); } + + public function test_it_declares_the_listed_byte_total_exactly(): void + { + $statistics = from_text( + path('memory://dir/*.txt'), + MemoryFiles::with(['memory://dir/a.txt' => 'abcdefghij', 'memory://dir/b.txt' => 'abcde']), + )->statistics(); + + static::assertEquals(Cardinality::exact(15), $statistics->size); + static::assertEquals(Cardinality::unknown(), $statistics->rows); + } + + public function test_a_member_without_a_size_makes_the_size_unknown(): void + { + $statistics = from_text( + path('memory://dir/*.txt'), + new UnsizedFilesystem(MemoryFiles::with([ + 'memory://dir/a.txt' => 'abcdefghij', + 'memory://dir/b.txt' => 'abcde', + ]), ['memory://dir/b.txt']), + )->statistics(); + + static::assertEquals(Cardinality::unknown(), $statistics->size); + } + + public function test_the_listing_is_read_once(): void + { + $filesystem = new CountingFilesystem(MemoryFiles::with([ + 'memory://dir/a.txt' => 'abcdefghij', + 'memory://dir/b.txt' => 'abcde', + ])); + $extractor = from_text(path('memory://dir/*.txt'), $filesystem); + + $extractor->statistics(); + $extractor->statistics(); + + static::assertSame(1, $filesystem->listCalls); + } } diff --git a/src/adapter/etl-adapter-xml/src/Flow/ETL/Adapter/XML/XMLParserExtractor.php b/src/adapter/etl-adapter-xml/src/Flow/ETL/Adapter/XML/XMLParserExtractor.php index 952b94f36b..36f7a8ce3f 100644 --- a/src/adapter/etl-adapter-xml/src/Flow/ETL/Adapter/XML/XMLParserExtractor.php +++ b/src/adapter/etl-adapter-xml/src/Flow/ETL/Adapter/XML/XMLParserExtractor.php @@ -10,9 +10,11 @@ use Flow\ETL\Extractor\Batches; use Flow\ETL\Extractor\FileExtractor; use Flow\ETL\Extractor\FileReading; +use Flow\ETL\Extractor\ListedFiles; use Flow\ETL\Extractor\MetadataColumnsExtractor; use Flow\ETL\Extractor\RewindableExtractor; use Flow\ETL\Extractor\Signal; +use Flow\ETL\Extractor\Statistics; use Flow\ETL\FlowContext; use Flow\ETL\Row\RawRowValues; use Flow\ETL\Rows; @@ -50,6 +52,8 @@ final class XMLParserExtractor implements private readonly Filesystem $filesystem; + private ?ListedFiles $listed = null; + /** * To iterate only over nodes, use `$loader->withXMLNodePath('root/elements/element')`. * @@ -168,6 +172,13 @@ public function source(): Path return $this->path; } + public function statistics(): Statistics + { + $this->listed ??= ListedFiles::of($this->sourceFiles($this->filesystem, $this->path)); + + return new Statistics(size: $this->listed->bytes); + } + /** * @param int<1, max> $bufferSize $bufferSize - size of the chunks to read from the xml file. Bigger chunks means faster reading but more memory usage. */ diff --git a/src/adapter/etl-adapter-xml/src/Flow/ETL/Adapter/XML/XMLReaderExtractor.php b/src/adapter/etl-adapter-xml/src/Flow/ETL/Adapter/XML/XMLReaderExtractor.php index 45b0e0bc74..f41e7b0ef1 100644 --- a/src/adapter/etl-adapter-xml/src/Flow/ETL/Adapter/XML/XMLReaderExtractor.php +++ b/src/adapter/etl-adapter-xml/src/Flow/ETL/Adapter/XML/XMLReaderExtractor.php @@ -11,9 +11,11 @@ use Flow\ETL\Extractor\Batches; use Flow\ETL\Extractor\FileExtractor; use Flow\ETL\Extractor\FileReading; +use Flow\ETL\Extractor\ListedFiles; use Flow\ETL\Extractor\MetadataColumnsExtractor; use Flow\ETL\Extractor\RewindableExtractor; use Flow\ETL\Extractor\Signal; +use Flow\ETL\Extractor\Statistics; use Flow\ETL\FlowContext; use Flow\ETL\Row\RawRowValues; use Flow\ETL\Rows; @@ -50,6 +52,8 @@ final class XMLReaderExtractor implements private readonly Filesystem $filesystem; + private ?ListedFiles $listed = null; + /** * In order to iterate only over nodes us root/elements/element. * @@ -218,6 +222,13 @@ public function source(): Path return $this->path; } + public function statistics(): Statistics + { + $this->listed ??= ListedFiles::of($this->sourceFiles($this->filesystem, $this->path)); + + return new Statistics(size: $this->listed->bytes); + } + public function withSchema(Schema $schema): static { $this->schema = $schema; diff --git a/src/adapter/etl-adapter-xml/tests/Flow/ETL/Adapter/XML/Tests/Fixtures/listed/a.xml b/src/adapter/etl-adapter-xml/tests/Flow/ETL/Adapter/XML/Tests/Fixtures/listed/a.xml new file mode 100644 index 0000000000..f3f286eafc --- /dev/null +++ b/src/adapter/etl-adapter-xml/tests/Flow/ETL/Adapter/XML/Tests/Fixtures/listed/a.xml @@ -0,0 +1 @@ + diff --git a/src/adapter/etl-adapter-xml/tests/Flow/ETL/Adapter/XML/Tests/Fixtures/listed/b.xml b/src/adapter/etl-adapter-xml/tests/Flow/ETL/Adapter/XML/Tests/Fixtures/listed/b.xml new file mode 100644 index 0000000000..80eac2ed95 --- /dev/null +++ b/src/adapter/etl-adapter-xml/tests/Flow/ETL/Adapter/XML/Tests/Fixtures/listed/b.xml @@ -0,0 +1 @@ + diff --git a/src/adapter/etl-adapter-xml/tests/Flow/ETL/Adapter/XML/Tests/Integration/XMLParserExtractorTest.php b/src/adapter/etl-adapter-xml/tests/Flow/ETL/Adapter/XML/Tests/Integration/XMLParserExtractorTest.php index 2cf17beecb..262d9ad7c7 100644 --- a/src/adapter/etl-adapter-xml/tests/Flow/ETL/Adapter/XML/Tests/Integration/XMLParserExtractorTest.php +++ b/src/adapter/etl-adapter-xml/tests/Flow/ETL/Adapter/XML/Tests/Integration/XMLParserExtractorTest.php @@ -5,12 +5,16 @@ namespace Flow\ETL\Adapter\XML\Tests\Integration; use Flow\ETL\Adapter\XML\XMLParserExtractor; +use Flow\ETL\Cardinality; use Flow\ETL\Config; use Flow\ETL\Exception\RuntimeException; use Flow\ETL\Exception\SchemaMismatchException; use Flow\ETL\Extractor\Signal; use Flow\ETL\Tests\Context\ExtractedRows; +use Flow\ETL\Tests\Context\MemoryFiles; +use Flow\ETL\Tests\Double\CountingFilesystem; use Flow\ETL\Tests\Double\RecordingFilesystem; +use Flow\ETL\Tests\Double\UnsizedFilesystem; use Flow\ETL\Tests\FlowIntegrationTestCase; use function array_keys; @@ -400,4 +404,42 @@ public function test_a_stopped_read_closes_its_stream(): void static::assertContains('closeSource', $filesystem->calls); } + + public function test_it_declares_the_listed_byte_total_exactly(): void + { + $statistics = from_xml( + path('memory://dir/*.xml'), + filesystem: MemoryFiles::with(['memory://dir/a.xml' => 'abcdefghij', 'memory://dir/b.xml' => 'abcde']), + )->statistics(); + + static::assertEquals(Cardinality::exact(15), $statistics->size); + static::assertEquals(Cardinality::unknown(), $statistics->rows); + } + + public function test_a_member_without_a_size_makes_the_size_unknown(): void + { + $statistics = from_xml( + path('memory://dir/*.xml'), + filesystem: new UnsizedFilesystem(MemoryFiles::with([ + 'memory://dir/a.xml' => 'abcdefghij', + 'memory://dir/b.xml' => 'abcde', + ]), ['memory://dir/b.xml']), + )->statistics(); + + static::assertEquals(Cardinality::unknown(), $statistics->size); + } + + public function test_the_listing_is_read_once(): void + { + $filesystem = new CountingFilesystem(MemoryFiles::with([ + 'memory://dir/a.xml' => 'abcdefghij', + 'memory://dir/b.xml' => 'abcde', + ])); + $extractor = from_xml(path('memory://dir/*.xml'), filesystem: $filesystem); + + $extractor->statistics(); + $extractor->statistics(); + + static::assertSame(1, $filesystem->listCalls); + } } diff --git a/src/adapter/etl-adapter-xml/tests/Flow/ETL/Adapter/XML/Tests/Integration/XMLReaderExtractorTest.php b/src/adapter/etl-adapter-xml/tests/Flow/ETL/Adapter/XML/Tests/Integration/XMLReaderExtractorTest.php index e8f03eaea1..ee4304e0ee 100644 --- a/src/adapter/etl-adapter-xml/tests/Flow/ETL/Adapter/XML/Tests/Integration/XMLReaderExtractorTest.php +++ b/src/adapter/etl-adapter-xml/tests/Flow/ETL/Adapter/XML/Tests/Integration/XMLReaderExtractorTest.php @@ -6,8 +6,11 @@ use DOMDocument; use Flow\ETL\Adapter\XML\XMLReaderExtractor; +use Flow\ETL\Cardinality; use Flow\ETL\Extractor\Signal; use Flow\ETL\Tests\Context\ExtractedRows; +use Flow\ETL\Tests\Double\CountingFilesystem; +use Flow\ETL\Tests\Double\UnsizedFilesystem; use Flow\ETL\Tests\FlowIntegrationTestCase; use function array_keys; @@ -17,6 +20,7 @@ use function Flow\ETL\DSL\schema; use function Flow\ETL\DSL\str_schema; use function Flow\ETL\DSL\xml_schema; +use function Flow\Filesystem\DSL\native_local_filesystem; use function Flow\Filesystem\DSL\path; use function Flow\Filesystem\DSL\path_real; use function Flow\Types\DSL\type_string; @@ -193,4 +197,37 @@ public function test_is_repeatable(): void (new XMLReaderExtractor(path_real(__DIR__ . '/../Fixtures/flow_orders.xml'), 'root/row'))->isRepeatable(), ); } + + public function test_it_declares_the_listed_byte_total_exactly(): void + { + // @mago-ignore analysis:deprecated-class + $statistics = (new XMLReaderExtractor(path_real(__DIR__ . '/../Fixtures/listed/*.xml')))->statistics(); + + static::assertEquals(Cardinality::exact(13), $statistics->size); + static::assertEquals(Cardinality::unknown(), $statistics->rows); + } + + public function test_a_member_without_a_size_makes_the_size_unknown(): void + { + $filesystem = new UnsizedFilesystem(native_local_filesystem(), [ + path_real(__DIR__ . '/../Fixtures/listed/b.xml')->uri(), + ]); + + // @mago-ignore analysis:deprecated-class + $extractor = new XMLReaderExtractor(path_real(__DIR__ . '/../Fixtures/listed/*.xml'), '', $filesystem); + + static::assertEquals(Cardinality::unknown(), $extractor->statistics()->size); + } + + public function test_the_listing_is_read_once(): void + { + $filesystem = new CountingFilesystem(native_local_filesystem()); + // @mago-ignore analysis:deprecated-class + $extractor = new XMLReaderExtractor(path_real(__DIR__ . '/../Fixtures/listed/*.xml'), '', $filesystem); + + $extractor->statistics(); + $extractor->statistics(); + + static::assertSame(1, $filesystem->listCalls); + } } diff --git a/src/core/etl/src/Flow/ETL/Cache/CacheIndex.php b/src/core/etl/src/Flow/ETL/Cache/CacheIndex.php index 7cb8ccbe82..81c5aa46f2 100644 --- a/src/core/etl/src/Flow/ETL/Cache/CacheIndex.php +++ b/src/core/etl/src/Flow/ETL/Cache/CacheIndex.php @@ -4,25 +4,28 @@ namespace Flow\ETL\Cache; +use Flow\ETL\Cardinality; use Flow\ETL\Exception\InvalidArgumentException; use Flow\ETL\Row; use Flow\ETL\Rows; use function array_map; +use function Flow\ETL\DSL\int_schema; use function Flow\ETL\DSL\row; use function Flow\ETL\DSL\rows; use function Flow\ETL\DSL\schema; use function Flow\ETL\DSL\str_schema; use function get_debug_type; +use function is_int; use function is_string; use function sprintf; final class CacheIndex { /** - * @var array + * @var list */ - private array $index = []; + private array $chunks = []; public function __construct( public readonly string $key, @@ -45,30 +48,55 @@ public static function fromRows(string $key, Rows $rows): self )); } - $index->add($value); + // an index written by 0.44.x has no "rows" column + $chunkRows = $row->has('rows') ? $row->get('rows') : null; + + if ($chunkRows !== null && !is_int($chunkRows)) { + throw new InvalidArgumentException(sprintf( + 'CacheIndex expects rows with an integer "rows" entry, got: %s', + get_debug_type($chunkRows), + )); + } + + $index->add($value, $chunkRows); } return $index; } - public function add(string $value): void + public function add(string $value, ?int $rows = null): void { - $this->index[] = $value; + $this->chunks[] = ['key' => $value, 'rows' => $rows]; + } + + public function rows(): Cardinality + { + $total = 0; + + foreach ($this->chunks as $chunk) { + if ($chunk['rows'] === null) { + return Cardinality::unknown(); + } + + $total += $chunk['rows']; + } + + return Cardinality::exact($total); } public function toRows(): Rows { return rows( - schema(str_schema('key')), - ...array_map(static fn(string $value): Row => row(['key' => $value]), $this->index), + schema(str_schema('key'), int_schema('rows', nullable: true)), + ...array_map(static fn(array $chunk): Row => row($chunk), $this->chunks), ); } /** - * @return array + * @return list */ public function values(): array { - return $this->index; + return array_map(static fn(array $chunk): string => $chunk['key'], $this->chunks); } } diff --git a/src/core/etl/src/Flow/ETL/Cardinality.php b/src/core/etl/src/Flow/ETL/Cardinality.php new file mode 100644 index 0000000000..e1995165d9 --- /dev/null +++ b/src/core/etl/src/Flow/ETL/Cardinality.php @@ -0,0 +1,91 @@ + + */ + public ?int $atMost; + + /** + * @var null|int<0, max> + */ + public ?int $estimate; + + public float $relativeError; + + /** + * @param null|int $atMost a GUARANTEED upper bound; null when nothing is guaranteed + * @param null|int $estimate a best guess that may be wrong in either direction + * @param float $relativeError rough 1-sigma relative error of $estimate; meaningless without one + */ + public function __construct( + ?int $atMost = null, + ?int $estimate = null, + float $relativeError = self::DEFAULT_RELATIVE_ERROR, + ) { + if ($atMost !== null && $atMost < 0) { + throw new InvalidArgumentException('Cardinality upper bound must not be negative, given: ' . $atMost); + } + + if ($estimate !== null && $estimate < 0) { + throw new InvalidArgumentException('Cardinality estimate must not be negative, given: ' . $estimate); + } + + if ($relativeError < 0.0) { + throw new InvalidArgumentException('Relative error must not be negative, given: ' . $relativeError); + } + + $this->atMost = $atMost; + $this->estimate = $estimate; + $this->relativeError = $relativeError; + } + + public static function unknown(): self + { + return new self(); + } + + public static function exact(int $rows): self + { + return new self($rows, $rows, 0.0); + } + + public static function atMost(int $rows): self + { + return new self(atMost: $rows); + } + + public static function approximately(int $rows, float $relativeError = self::DEFAULT_RELATIVE_ERROR): self + { + return new self(estimate: $rows, relativeError: $relativeError); + } + + /** + * The estimate when it is known to at least $maxRelativeError, null when it is not. + * A caller that needs a guarantee reads $atMost instead. + * + * @return null|int<0, max> + */ + public function confident(float $maxRelativeError): ?int + { + return $this->estimate !== null && $this->relativeError <= $maxRelativeError ? $this->estimate : null; + } + + public function merge(self $other): self + { + return new self( + atMost: $this->atMost !== null && $other->atMost !== null ? $this->atMost + $other->atMost : null, + estimate: $this->estimate !== null && $other->estimate !== null ? $this->estimate + $other->estimate : null, + relativeError: max($this->relativeError, $other->relativeError), + ); + } +} diff --git a/src/core/etl/src/Flow/ETL/Extractor.php b/src/core/etl/src/Flow/ETL/Extractor.php index af514a1d7f..2cb20bb0be 100644 --- a/src/core/etl/src/Flow/ETL/Extractor.php +++ b/src/core/etl/src/Flow/ETL/Extractor.php @@ -6,6 +6,7 @@ use Flow\ETL\Exception\SchemaNotDerivableException; use Flow\ETL\Extractor\Signal; +use Flow\ETL\Extractor\Statistics; use Generator; interface Extractor @@ -38,6 +39,21 @@ public function extract(FlowContext $context, ?int $limit = null): Generator; */ public function schema(): Schema; + /** + * What the source knows about the data before a single row is read: how many rows, how many bytes. A source + * that knows nothing returns `new Statistics()`. Describes the source with NO pushdown applied - a limit or a + * partition filter the plan pushed is the plan's to account for. + * + * Called AT MOST ONCE per run, and only when something needs the answer - so it must be lazy, never computed in + * the constructor - and it MUST be idempotent and cheap on repeat: memoise what it reads, the way schema() does. + * + * A file source may only spend what the run already spends: it may complete a listing it must produce anyway and + * read metadata from the files schema() already opens, and it may extrapolate from those. It must not open an + * extra file or make an extra remote call. A database source may ask the planner for an estimate (EXPLAIN without + * ANALYZE), never run the query. + */ + public function statistics(): Statistics; + /** * Declares the shape every yielded batch must carry. A source that describes itself uses this * instead of its own description, and values are cast to fit. diff --git a/src/core/etl/src/Flow/ETL/Extractor/ArrayExtractor.php b/src/core/etl/src/Flow/ETL/Extractor/ArrayExtractor.php index 43b97b2610..4e44843471 100644 --- a/src/core/etl/src/Flow/ETL/Extractor/ArrayExtractor.php +++ b/src/core/etl/src/Flow/ETL/Extractor/ArrayExtractor.php @@ -4,6 +4,7 @@ namespace Flow\ETL\Extractor; +use Flow\ETL\Cardinality; use Flow\ETL\Exception\InvalidLogicException; use Flow\ETL\Extractor; use Flow\ETL\FlowContext; @@ -39,6 +40,8 @@ final class ArrayExtractor implements BatchableExtractor, Extractor, InfersSchem private readonly ?Path $spillRoot; + private ?Statistics $statistics = null; + /** * @param iterable> $dataset an array is described in place; anything else is read * exactly once, spilled, and replayed @@ -157,6 +160,14 @@ public function schema(): Schema ))->infer([], $this->source->samples(-1)); } + public function statistics(): Statistics + { + // a generator is one-shot and spilled, so its length is unknown until it has been read + return $this->statistics ??= new Statistics( + rows: is_array($this->dataset) ? Cardinality::exact(count($this->dataset)) : Cardinality::unknown(), + ); + } + public function withSchema(Schema $schema): static { $this->schema = $schema; diff --git a/src/core/etl/src/Flow/ETL/Extractor/BatchByExtractor.php b/src/core/etl/src/Flow/ETL/Extractor/BatchByExtractor.php index 55dc468c9b..81ae95d6f2 100644 --- a/src/core/etl/src/Flow/ETL/Extractor/BatchByExtractor.php +++ b/src/core/etl/src/Flow/ETL/Extractor/BatchByExtractor.php @@ -97,6 +97,11 @@ public function schema(): Schema return $this->extractor->schema(); } + public function statistics(): Statistics + { + return $this->extractor->statistics(); + } + public function withSchema(Schema $schema): static { $this->schema = $schema; diff --git a/src/core/etl/src/Flow/ETL/Extractor/BatchExtractor.php b/src/core/etl/src/Flow/ETL/Extractor/BatchExtractor.php index 35b6f9dfd3..ccf19c498b 100644 --- a/src/core/etl/src/Flow/ETL/Extractor/BatchExtractor.php +++ b/src/core/etl/src/Flow/ETL/Extractor/BatchExtractor.php @@ -79,6 +79,11 @@ public function schema(): Schema return $this->extractor->schema(); } + public function statistics(): Statistics + { + return $this->extractor->statistics(); + } + public function withSchema(Schema $schema): static { $this->schema = $schema; diff --git a/src/core/etl/src/Flow/ETL/Extractor/CacheExtractor.php b/src/core/etl/src/Flow/ETL/Extractor/CacheExtractor.php index 681b6d595f..488727bbd4 100644 --- a/src/core/etl/src/Flow/ETL/Extractor/CacheExtractor.php +++ b/src/core/etl/src/Flow/ETL/Extractor/CacheExtractor.php @@ -6,6 +6,7 @@ use Flow\ETL\Cache; use Flow\ETL\Cache\CacheIndex; +use Flow\ETL\Cardinality; use Flow\ETL\Extractor; use Flow\ETL\FlowContext; use Flow\ETL\Rows; @@ -20,6 +21,13 @@ final class CacheExtractor implements Extractor, RewindableExtractor private ?Extractor $fallbackExtractor = null; + /** + * The index schema() and statistics() read from the constructor's cache, parsed once. + */ + private ?CacheIndex $index = null; + + private ?Statistics $statistics = null; + public function __construct( private readonly string $id, private readonly ?Cache $cache = null, @@ -99,7 +107,9 @@ public function schema(): Schema if ($this->cache !== null && $this->cache->has($this->id)) { $schema = new Schema(); - foreach (CacheIndex::fromRows($this->id, $this->cache->get($this->id))->values() as $cacheKey) { + $this->index ??= CacheIndex::fromRows($this->id, $this->cache->get($this->id)); + + foreach ($this->index->values() as $cacheKey) { $schema = $schema->merge($this->cache->schema($cacheKey)); } @@ -116,6 +126,33 @@ public function schema(): Schema return new Schema(); } + /** + * Answers from the arm extract() will take, and only when a cache was handed to the constructor: one taken from + * the FlowContext may hold the entry this cache lacks. + */ + public function statistics(): Statistics + { + if ($this->statistics !== null) { + return $this->statistics; + } + + if ($this->cache === null) { + return new Statistics(); + } + + if ($this->cache->has($this->id)) { + return $this->statistics = new Statistics( + rows: ($this->index ??= CacheIndex::fromRows($this->id, $this->cache->get($this->id)))->rows(), + ); + } + + if ($this->fallbackExtractor === null) { + return new Statistics(rows: Cardinality::exact(0)); + } + + return $this->fallbackExtractor->statistics(); + } + public function withSchema(Schema $schema): static { $this->schema = $schema; diff --git a/src/core/etl/src/Flow/ETL/Extractor/ChainExtractor.php b/src/core/etl/src/Flow/ETL/Extractor/ChainExtractor.php index 6648e8bde5..6f4a444d11 100644 --- a/src/core/etl/src/Flow/ETL/Extractor/ChainExtractor.php +++ b/src/core/etl/src/Flow/ETL/Extractor/ChainExtractor.php @@ -4,12 +4,15 @@ namespace Flow\ETL\Extractor; +use Flow\ETL\Cardinality; use Flow\ETL\Extractor; use Flow\ETL\FlowContext; use Flow\ETL\Rows; use Flow\ETL\Schema; use Generator; +use function array_reduce; + final class ChainExtractor implements Extractor, OverridingExtractor, RewindableExtractor { private ?Schema $schema = null; @@ -71,6 +74,17 @@ public function schema(): Schema return $schema; } + public function statistics(): Statistics + { + return array_reduce( + $this->extractors, + static fn(Statistics $statistics, Extractor $extractor): Statistics => $statistics->merge( + $extractor->statistics(), + ), + new Statistics(rows: Cardinality::exact(0), size: Cardinality::exact(0)), + ); + } + public function withSchema(Schema $schema): static { $this->schema = $schema; diff --git a/src/core/etl/src/Flow/ETL/Extractor/CollectingExtractor.php b/src/core/etl/src/Flow/ETL/Extractor/CollectingExtractor.php index 35d5593ec6..159e1c1602 100644 --- a/src/core/etl/src/Flow/ETL/Extractor/CollectingExtractor.php +++ b/src/core/etl/src/Flow/ETL/Extractor/CollectingExtractor.php @@ -54,6 +54,11 @@ public function schema(): Schema return $this->extractor->schema(); } + public function statistics(): Statistics + { + return $this->extractor->statistics(); + } + public function withSchema(Schema $schema): static { $this->schema = $schema; diff --git a/src/core/etl/src/Flow/ETL/Extractor/DataFrameExtractor.php b/src/core/etl/src/Flow/ETL/Extractor/DataFrameExtractor.php index 049478c3e1..cf3c7a86ea 100644 --- a/src/core/etl/src/Flow/ETL/Extractor/DataFrameExtractor.php +++ b/src/core/etl/src/Flow/ETL/Extractor/DataFrameExtractor.php @@ -85,4 +85,9 @@ public function withSchema(Schema $schema): static return $this; } + + public function statistics(): Statistics + { + return new Statistics(); + } } diff --git a/src/core/etl/src/Flow/ETL/Extractor/FeedExtractor.php b/src/core/etl/src/Flow/ETL/Extractor/FeedExtractor.php index 79048d0016..e242faafd5 100644 --- a/src/core/etl/src/Flow/ETL/Extractor/FeedExtractor.php +++ b/src/core/etl/src/Flow/ETL/Extractor/FeedExtractor.php @@ -89,4 +89,9 @@ public function withSchema(Schema $schema): static return $this; } + + public function statistics(): Statistics + { + return new Statistics(); + } } diff --git a/src/core/etl/src/Flow/ETL/Extractor/FileReading.php b/src/core/etl/src/Flow/ETL/Extractor/FileReading.php index 370c6706b3..bd57eb3735 100644 --- a/src/core/etl/src/Flow/ETL/Extractor/FileReading.php +++ b/src/core/etl/src/Flow/ETL/Extractor/FileReading.php @@ -35,7 +35,7 @@ private function fileColumns(Filesystem $filesystem, Path $path): FileColumns private function sourceFiles(Filesystem $filesystem, Path $path, Filter $pathFilter = new OnlyFiles()): Generator { foreach ((new FileListing($filesystem))->list($path, $pathFilter) as $status) { - yield new SourceFile($status->path); + yield new SourceFile($status->path, $status->size); } } } diff --git a/src/core/etl/src/Flow/ETL/Extractor/FilesExtractor.php b/src/core/etl/src/Flow/ETL/Extractor/FilesExtractor.php index c4f52896b6..3f4383ab28 100644 --- a/src/core/etl/src/Flow/ETL/Extractor/FilesExtractor.php +++ b/src/core/etl/src/Flow/ETL/Extractor/FilesExtractor.php @@ -4,6 +4,7 @@ namespace Flow\ETL\Extractor; +use Flow\ETL\Cardinality; use Flow\ETL\Exception\InvalidArgumentException; use Flow\ETL\Extractor; use Flow\ETL\FlowContext; @@ -27,6 +28,8 @@ final class FilesExtractor implements BatchableExtractor, Extractor, FileExtract { private ?Schema $schema = null; + private ?Statistics $statistics = null; + use Batches; use ListingPartitions; @@ -66,7 +69,7 @@ public function extract(FlowContext $context, ?int $limit = null, Filter $pathFi $yielded = 0; foreach ((new FileListing($this->filesystem))->list($this->path, $pathFilter) as $fileStatus) { - $constants = $fileColumns->forFile(new SourceFile($fileStatus->path), $schema); + $constants = $fileColumns->forFile(new SourceFile($fileStatus->path, $fileStatus->size), $schema); $extension = $fileStatus->path->extension(); $buffer[] = $constants->fill([ @@ -134,6 +137,16 @@ public function schema(): Schema )); } + public function statistics(): Statistics + { + if ($this->statistics === null) { + $listed = ListedFiles::of((new FileListing($this->filesystem))->list($this->path, new OnlyFiles())); + $this->statistics = new Statistics(rows: Cardinality::exact($listed->count), size: $listed->bytes); + } + + return $this->statistics; + } + public function withSchema(Schema $schema): static { $this->schema = $schema; diff --git a/src/core/etl/src/Flow/ETL/Extractor/FooterStatistics.php b/src/core/etl/src/Flow/ETL/Extractor/FooterStatistics.php new file mode 100644 index 0000000000..ecfb822ab0 --- /dev/null +++ b/src/core/etl/src/Flow/ETL/Extractor/FooterStatistics.php @@ -0,0 +1,59 @@ + + */ + public int $files; + + /** + * @param Statistics $statistics what the read footers declare, merged + * @param int $files how many footers were read + */ + public function __construct( + public Statistics $statistics, + int $files, + ) { + if ($files < 0) { + throw new InvalidArgumentException('Read footer count must not be negative, given: ' . $files); + } + + $this->files = $files; + } + + /** + * Exact when every listed file's footer was read; otherwise the read footers are scaled to the listing + * (DuckDB's rule, parquet_extension.cpp:866-877). The offset is taken off the rows, never below zero. + */ + public function of(int $listedFiles, int $offset): Statistics + { + $rows = $this->statistics->rows->estimate ?? 0; + $bytes = $this->statistics->size->estimate ?? 0; + + if ($this->files === 0 || $this->files >= $listedFiles) { + return new Statistics(rows: Cardinality::exact(max(0, $rows - $offset)), size: Cardinality::exact($bytes)); + } + + return new Statistics( + rows: Cardinality::approximately( + max(0, (int) round(($rows * $listedFiles) / $this->files) - $offset), + Cardinality::DEFAULT_RELATIVE_ERROR, + ), + size: Cardinality::approximately( + (int) round(($bytes * $listedFiles) / $this->files), + Cardinality::DEFAULT_RELATIVE_ERROR, + ), + ); + } +} diff --git a/src/core/etl/src/Flow/ETL/Extractor/ListedFiles.php b/src/core/etl/src/Flow/ETL/Extractor/ListedFiles.php new file mode 100644 index 0000000000..c6c7c6e9bd --- /dev/null +++ b/src/core/etl/src/Flow/ETL/Extractor/ListedFiles.php @@ -0,0 +1,58 @@ + + */ + public int $count; + + /** + * @param Cardinality $bytes total listed bytes, unknown when ANY member did not report a size + */ + public function __construct( + int $count, + public Cardinality $bytes, + ) { + if ($count < 0) { + throw new InvalidArgumentException('Listed file count must not be negative, given: ' . $count); + } + + $this->count = $count; + } + + /** + * One pass, nothing kept but the two totals. NativeLocalFilesystem::statFor() is `filesize($p) ?: null`, so it + * reports a zero-byte file as null. + * + * @param iterable $files + */ + public static function of(iterable $files): self + { + $count = 0; + $total = 0; + $sized = true; + + foreach ($files as $file) { + $count++; + + if ($file->size === null) { + $sized = false; + + continue; + } + + $total += $file->size; + } + + return new self($count, $sized ? Cardinality::exact($total) : Cardinality::unknown()); + } +} diff --git a/src/core/etl/src/Flow/ETL/Extractor/MemoryExtractor.php b/src/core/etl/src/Flow/ETL/Extractor/MemoryExtractor.php index 51cf095249..2c62187509 100644 --- a/src/core/etl/src/Flow/ETL/Extractor/MemoryExtractor.php +++ b/src/core/etl/src/Flow/ETL/Extractor/MemoryExtractor.php @@ -107,4 +107,12 @@ public function withSchema(Schema $schema): static return $this; } + + /** + * Declares nothing: ArrayMemory::save() is array_merge, so the row count can grow mid-run. + */ + public function statistics(): Statistics + { + return new Statistics(); + } } diff --git a/src/core/etl/src/Flow/ETL/Extractor/PathFiltering.php b/src/core/etl/src/Flow/ETL/Extractor/PathFiltering.php index 23e13d22a5..27772816f5 100644 --- a/src/core/etl/src/Flow/ETL/Extractor/PathFiltering.php +++ b/src/core/etl/src/Flow/ETL/Extractor/PathFiltering.php @@ -4,6 +4,7 @@ namespace Flow\ETL\Extractor; +use Flow\ETL\Cardinality; use Flow\ETL\Schema; use Flow\Filesystem\Path; use Flow\Filesystem\Path\Filter\OnlyFiles; @@ -18,6 +19,11 @@ trait PathFiltering private string $derivedFrom = ''; + /** + * The footers the fold read while deriving the schema; set together with $derivedSchema. + */ + private ?FooterStatistics $derivedFooters = null; + /** * One listing per extractor instance, however often schema() is asked. * @@ -38,6 +44,8 @@ private function derivedSchema(Generator $files, bool $unionByName): Schema } $schema = new Schema(); + $statistics = new Statistics(rows: Cardinality::exact(0), size: Cardinality::exact(0)); + $read = 0; $this->derivedFrom = ''; foreach ($files as $file) { @@ -47,6 +55,8 @@ private function derivedSchema(Generator $files, bool $unionByName): Schema } $schema = $schema->merge($file->schema()); + $statistics = $statistics->merge($file->statistics()); + $read++; } finally { $file->close(); } @@ -56,9 +66,49 @@ private function derivedSchema(Generator $files, bool $unionByName): Schema } } + $this->derivedFooters = new FooterStatistics($statistics, $read); + return $this->derivedSchema = $schema; } + /** + * The footers the schema fold reads, derived once: statistics() never opens a footer schema() already did. + * + * @param Generator $files an unstarted generator + */ + private function derivedFooters(Generator $files, bool $unionByName): FooterStatistics + { + $this->derivedSchema($files, $unionByName); + + return $this->derivedFooters ?? new FooterStatistics(new Statistics(), 0); + } + + /** + * The same footers without deriving a schema, for a read whose schema was declared: nothing else opens them. + * + * @param Generator $files an unstarted generator + */ + private function footers(Generator $files, bool $unionByName): FooterStatistics + { + $statistics = new Statistics(rows: Cardinality::exact(0), size: Cardinality::exact(0)); + $read = 0; + + foreach ($files as $file) { + try { + $statistics = $statistics->merge($file->statistics()); + $read++; + } finally { + $file->close(); + } + + if (!$unionByName) { + break; + } + } + + return new FooterStatistics($statistics, $read); + } + /** * @return array */ diff --git a/src/core/etl/src/Flow/ETL/Extractor/PathPartitionsExtractor.php b/src/core/etl/src/Flow/ETL/Extractor/PathPartitionsExtractor.php index a655bdc357..567f4a823b 100644 --- a/src/core/etl/src/Flow/ETL/Extractor/PathPartitionsExtractor.php +++ b/src/core/etl/src/Flow/ETL/Extractor/PathPartitionsExtractor.php @@ -4,6 +4,7 @@ namespace Flow\ETL\Extractor; +use Flow\ETL\Cardinality; use Flow\ETL\Exception\InvalidArgumentException; use Flow\ETL\Extractor; use Flow\ETL\FlowContext; @@ -27,12 +28,15 @@ use function Flow\ETL\DSL\str_schema; use function Flow\Types\DSL\type_map; use function Flow\Types\DSL\type_string; +use function iterator_count; use function sprintf; final class PathPartitionsExtractor implements BatchableExtractor, Extractor, FileExtractor, RewindableExtractor { private ?Schema $schema = null; + private ?Statistics $statistics = null; + use Batches; use ListingPartitions; @@ -72,7 +76,7 @@ public function extract(FlowContext $context, ?int $limit = null, Filter $pathFi $yielded = 0; foreach ((new FileListing($this->filesystem))->list($this->path, $pathFilter) as $fileStatus) { - $constants = $fileColumns->forFile(new SourceFile($fileStatus->path), $schema); + $constants = $fileColumns->forFile(new SourceFile($fileStatus->path, $fileStatus->size), $schema); $buffer[] = $constants->fill([ 'path' => $fileStatus->path->uri(), 'partitions' => array_merge(...array_values(array_map(static fn(Partition $p) => [ @@ -128,6 +132,15 @@ public function schema(): Schema )); } + public function statistics(): Statistics + { + return $this->statistics ??= + new Statistics(rows: Cardinality::exact(iterator_count((new FileListing($this->filesystem))->list( + $this->path, + new OnlyFiles(), + )))); + } + public function withSchema(Schema $schema): static { $this->schema = $schema; diff --git a/src/core/etl/src/Flow/ETL/Extractor/RowsExtractor.php b/src/core/etl/src/Flow/ETL/Extractor/RowsExtractor.php index 1d83eef516..a21dcb9dd2 100644 --- a/src/core/etl/src/Flow/ETL/Extractor/RowsExtractor.php +++ b/src/core/etl/src/Flow/ETL/Extractor/RowsExtractor.php @@ -4,6 +4,7 @@ namespace Flow\ETL\Extractor; +use Flow\ETL\Cardinality; use Flow\ETL\Extractor; use Flow\ETL\FlowContext; use Flow\ETL\Rows; @@ -14,6 +15,8 @@ final class RowsExtractor implements Extractor, RewindableExtractor { private ?Schema $schema = null; + private ?Statistics $statistics = null; + /** * @var array */ @@ -60,6 +63,21 @@ public function schema(): Schema return $schema; } + public function statistics(): Statistics + { + if ($this->statistics === null) { + $count = 0; + + foreach ($this->rows as $rows) { + $count += $rows->count(); + } + + $this->statistics = new Statistics(rows: Cardinality::exact($count)); + } + + return $this->statistics; + } + public function withSchema(Schema $schema): static { $this->schema = $schema; diff --git a/src/core/etl/src/Flow/ETL/Extractor/SelfDescribingFile.php b/src/core/etl/src/Flow/ETL/Extractor/SelfDescribingFile.php index 12d7a97272..65fcc12a3e 100644 --- a/src/core/etl/src/Flow/ETL/Extractor/SelfDescribingFile.php +++ b/src/core/etl/src/Flow/ETL/Extractor/SelfDescribingFile.php @@ -13,4 +13,9 @@ public function close(): void; public function schema(): Schema; public function source(): SourceFile; + + /** + * What this one file's own metadata declares, read from what schema() already opened. + */ + public function statistics(): Statistics; } diff --git a/src/core/etl/src/Flow/ETL/Extractor/SequenceExtractor.php b/src/core/etl/src/Flow/ETL/Extractor/SequenceExtractor.php index 39985cb7d1..80b299ef89 100644 --- a/src/core/etl/src/Flow/ETL/Extractor/SequenceExtractor.php +++ b/src/core/etl/src/Flow/ETL/Extractor/SequenceExtractor.php @@ -27,6 +27,8 @@ final class SequenceExtractor implements BatchableExtractor, Extractor, InfersSc private ?Schema $schema = null; + private ?Statistics $statistics = null; + public function __construct( private readonly SequenceGenerator $generator, private readonly string $entryName = 'entry', @@ -108,6 +110,11 @@ public function schema(): Schema ); } + public function statistics(): Statistics + { + return $this->statistics ??= new Statistics(rows: $this->generator->rows()); + } + public function withSchema(Schema $schema): static { $this->schema = $schema; diff --git a/src/core/etl/src/Flow/ETL/Extractor/SequenceGenerator/DatePeriodSequenceGenerator.php b/src/core/etl/src/Flow/ETL/Extractor/SequenceGenerator/DatePeriodSequenceGenerator.php index 0e98a60a2e..c9d2059984 100644 --- a/src/core/etl/src/Flow/ETL/Extractor/SequenceGenerator/DatePeriodSequenceGenerator.php +++ b/src/core/etl/src/Flow/ETL/Extractor/SequenceGenerator/DatePeriodSequenceGenerator.php @@ -5,8 +5,11 @@ namespace Flow\ETL\Extractor\SequenceGenerator; use DatePeriod; +use Flow\ETL\Cardinality; use Generator; +use function iterator_count; + final readonly class DatePeriodSequenceGenerator implements SequenceGenerator { /** @@ -23,4 +26,9 @@ public function generate(): Generator yield $item; } } + + public function rows(): Cardinality + { + return Cardinality::exact(iterator_count($this->period)); + } } diff --git a/src/core/etl/src/Flow/ETL/Extractor/SequenceGenerator/NumberSequenceGenerator.php b/src/core/etl/src/Flow/ETL/Extractor/SequenceGenerator/NumberSequenceGenerator.php index 4a9b15a42c..e9f50f6563 100644 --- a/src/core/etl/src/Flow/ETL/Extractor/SequenceGenerator/NumberSequenceGenerator.php +++ b/src/core/etl/src/Flow/ETL/Extractor/SequenceGenerator/NumberSequenceGenerator.php @@ -4,8 +4,13 @@ namespace Flow\ETL\Extractor\SequenceGenerator; +use Flow\ETL\Cardinality; use Generator; +use function abs; +use function count; +use function intdiv; +use function is_int; use function range; final readonly class NumberSequenceGenerator implements SequenceGenerator @@ -22,4 +27,24 @@ public function generate(): Generator yield $item; } } + + /** + * Integer bounds with a positive step no wider than the span are counted arithmetically; everything else counts + * the same range() generate() walks, because PHP rounds float ranges its own way and throws on a step that does + * not fit - a formula would diverge from what extract() yields. + */ + public function rows(): Cardinality + { + if ( + is_int($this->start) + && is_int($this->end) + && is_int($this->step) + && $this->step > 0 + && abs($this->end - $this->start) >= $this->step + ) { + return Cardinality::exact(intdiv(abs($this->end - $this->start), $this->step) + 1); + } + + return Cardinality::exact(count(range($this->start, $this->end, $this->step))); + } } diff --git a/src/core/etl/src/Flow/ETL/Extractor/SequenceGenerator/SequenceGenerator.php b/src/core/etl/src/Flow/ETL/Extractor/SequenceGenerator/SequenceGenerator.php index c23e62c813..a84446ea2f 100644 --- a/src/core/etl/src/Flow/ETL/Extractor/SequenceGenerator/SequenceGenerator.php +++ b/src/core/etl/src/Flow/ETL/Extractor/SequenceGenerator/SequenceGenerator.php @@ -4,6 +4,7 @@ namespace Flow\ETL\Extractor\SequenceGenerator; +use Flow\ETL\Cardinality; use Generator; interface SequenceGenerator @@ -12,4 +13,6 @@ interface SequenceGenerator * @return \Generator */ public function generate(): Generator; + + public function rows(): Cardinality; } diff --git a/src/core/etl/src/Flow/ETL/Extractor/SourceFile.php b/src/core/etl/src/Flow/ETL/Extractor/SourceFile.php index eac798b388..136f2cade7 100644 --- a/src/core/etl/src/Flow/ETL/Extractor/SourceFile.php +++ b/src/core/etl/src/Flow/ETL/Extractor/SourceFile.php @@ -18,6 +18,7 @@ public function __construct( public Path $path, + public ?int $size = null, ) { $values = []; diff --git a/src/core/etl/src/Flow/ETL/Extractor/Statistics.php b/src/core/etl/src/Flow/ETL/Extractor/Statistics.php new file mode 100644 index 0000000000..0b2079d608 --- /dev/null +++ b/src/core/etl/src/Flow/ETL/Extractor/Statistics.php @@ -0,0 +1,20 @@ +rows->merge($other->rows), $this->size->merge($other->size)); + } +} diff --git a/src/core/etl/src/Flow/ETL/Processor/CachingProcessor.php b/src/core/etl/src/Flow/ETL/Processor/CachingProcessor.php index 2b80da2cbe..af160151f0 100644 --- a/src/core/etl/src/Flow/ETL/Processor/CachingProcessor.php +++ b/src/core/etl/src/Flow/ETL/Processor/CachingProcessor.php @@ -54,7 +54,7 @@ public function process(Generator $rows, FlowContext $context): Generator foreach ($rows as $batch) { $cacheKey = bin2hex(random_bytes(16)); $cache->set($cacheKey, $batch); - $index->add($cacheKey); + $index->add($cacheKey, $batch->count()); if (!$stopped) { $signal = yield $batch; diff --git a/src/core/etl/src/Flow/Floe/FloeExtractor.php b/src/core/etl/src/Flow/Floe/FloeExtractor.php index dbe37f00e5..2de58e36ef 100644 --- a/src/core/etl/src/Flow/Floe/FloeExtractor.php +++ b/src/core/etl/src/Flow/Floe/FloeExtractor.php @@ -14,6 +14,7 @@ use Flow\ETL\Extractor\MetadataColumnsExtractor; use Flow\ETL\Extractor\RewindableExtractor; use Flow\ETL\Extractor\Signal; +use Flow\ETL\Extractor\Statistics; use Flow\ETL\FlowContext; use Flow\ETL\Row; use Flow\ETL\Row\Hydrator; @@ -28,6 +29,7 @@ use Flow\Floe\Codec\NoopCodec; use Generator; +use function iterator_count; use function sprintf; final class FloeExtractor implements @@ -44,6 +46,8 @@ final class FloeExtractor implements private ?int $offset = null; + private ?Statistics $statistics = null; + private bool $unionByName = false; private readonly Filesystem $filesystem; @@ -166,6 +170,11 @@ public function schema(): Schema ); } + public function statistics(): Statistics + { + return $this->statistics ??= $this->declare(); + } + /** * Reconcile every listed file's footer instead of trusting the first one. */ @@ -173,6 +182,7 @@ public function unionByName(bool $union = true): self { $this->unionByName = $union; $this->derivedSchema = null; + $this->statistics = null; return $this; } @@ -194,10 +204,24 @@ public function withOffset(int $offset): self } $this->offset = $offset; + $this->statistics = null; return $this; } + /** + * The footers schema() reads - the first file's, or every file's under unionByName - read once whichever of the + * two asks first, and scaled to the listing when only the first was read. + */ + private function declare(): Statistics + { + $footers = $this->schema === null + ? $this->derivedFooters($this->files(), $this->unionByName) + : $this->footers($this->files(), $this->unionByName); + + return $footers->of(iterator_count($this->sourceFiles($this->filesystem, $this->path)), $this->offset ?? 0); + } + /** * @return Generator */ diff --git a/src/core/etl/src/Flow/Floe/FloeMerger.php b/src/core/etl/src/Flow/Floe/FloeMerger.php index 79e3177326..29b46851b5 100644 --- a/src/core/etl/src/Flow/Floe/FloeMerger.php +++ b/src/core/etl/src/Flow/Floe/FloeMerger.php @@ -215,7 +215,7 @@ private function mergeSplice(array $sources, array $layouts, ?Schema $merged, Pa $sections[] = new Section($section->offset - $copyStart + $outputStart, $section->rowCount); } - $totalRows += $footer->totalRows; + $totalRows += $footer->statistics->rows; $mergedMetadata = $mergedMetadata->merge($footer->metadata); } @@ -227,7 +227,7 @@ private function mergeSplice(array $sources, array $layouts, ?Schema $merged, Pa FloeStreamWriter::writerVersion(), $schema, $sections, - $totalRows, + new Statistics($totalRows, $frameWriter->position() - Format::HEADER_LENGTH), $mergedMetadata->merge($metadata), ))->toJson(); diff --git a/src/core/etl/src/Flow/Floe/FloeSerializer.php b/src/core/etl/src/Flow/Floe/FloeSerializer.php index bb8d79aa9d..3b7c8d0e90 100644 --- a/src/core/etl/src/Flow/Floe/FloeSerializer.php +++ b/src/core/etl/src/Flow/Floe/FloeSerializer.php @@ -63,11 +63,11 @@ public function unserialize(SourceStream $source): Rows } } - if (count($rows) !== $footer->totalRows) { + if (count($rows) !== $footer->statistics->rows) { throw new FloeException(sprintf( 'Floe payload is corrupted, decoded %d of %d rows', count($rows), - $footer->totalRows, + $footer->statistics->rows, )); } diff --git a/src/core/etl/src/Flow/Floe/FloeSourceFile.php b/src/core/etl/src/Flow/Floe/FloeSourceFile.php index 556332d696..ec5cf384e5 100644 --- a/src/core/etl/src/Flow/Floe/FloeSourceFile.php +++ b/src/core/etl/src/Flow/Floe/FloeSourceFile.php @@ -4,8 +4,10 @@ namespace Flow\Floe; +use Flow\ETL\Cardinality; use Flow\ETL\Extractor\SelfDescribingFile; use Flow\ETL\Extractor\SourceFile; +use Flow\ETL\Extractor\Statistics; use Flow\ETL\Schema; final readonly class FloeSourceFile implements SelfDescribingFile @@ -29,4 +31,12 @@ public function source(): SourceFile { return $this->source; } + + public function statistics(): Statistics + { + return new Statistics( + rows: Cardinality::exact($this->reader->totalRows()), + size: Cardinality::exact($this->reader->byteSize()), + ); + } } diff --git a/src/core/etl/src/Flow/Floe/FloeStreamReader.php b/src/core/etl/src/Flow/Floe/FloeStreamReader.php index 6ed8396b1f..ab3b818e96 100644 --- a/src/core/etl/src/Flow/Floe/FloeStreamReader.php +++ b/src/core/etl/src/Flow/Floe/FloeStreamReader.php @@ -373,12 +373,22 @@ public function schema(): Schema return $this->schema ??= $this->footer()->schema(); } + /** + * Uncompressed bytes of the data frames, as the footer records them. + * + * @throws FloeException + */ + public function byteSize(): int + { + return $this->footer()->statistics->byteSize; + } + /** * @throws FloeException */ public function totalRows(): int { - return $this->footer()->totalRows; + return $this->footer()->statistics->rows; } /** diff --git a/src/core/etl/src/Flow/Floe/FloeStreamWriter.php b/src/core/etl/src/Flow/Floe/FloeStreamWriter.php index 8e29e520f0..c28a22cd10 100644 --- a/src/core/etl/src/Flow/Floe/FloeStreamWriter.php +++ b/src/core/etl/src/Flow/Floe/FloeStreamWriter.php @@ -115,7 +115,7 @@ public function resume(DestinationStream $stream, Footer $footer, int $fileLengt } } - $this->totalRows = $footer->totalRows; + $this->totalRows = $footer->statistics->rows; $this->open = true; } @@ -136,7 +136,7 @@ public function close(): void self::writerVersion(), $schema, $this->sections, - $this->totalRows, + new Statistics($this->totalRows, $this->frameWriter()->position() - Format::HEADER_LENGTH), $this->metadata, ))->toJson(); diff --git a/src/core/etl/src/Flow/Floe/Footer.php b/src/core/etl/src/Flow/Floe/Footer.php index 602b1ec640..3e41cb89d5 100644 --- a/src/core/etl/src/Flow/Floe/Footer.php +++ b/src/core/etl/src/Flow/Floe/Footer.php @@ -33,7 +33,7 @@ public function __construct( public string $writer, public array $schema, public array $sections, - public int $totalRows, + public Statistics $statistics, public Metadata $metadata, ) {} @@ -69,9 +69,9 @@ public static function fromArray(array $data): self 'writer' => type_string(), 'schema' => type_array(), 'sections' => type_list(type_array()), - 'totalRows' => type_integer(), + 'statistics' => type_array(), 'metadata' => type_array(), - ])->assert($data); + ], allow_extra: true)->assert($data); } catch (InvalidTypeException $e) { throw new FloeException('Floe footer is malformed: ' . $e->getMessage(), 0, $e); } @@ -95,7 +95,14 @@ public static function fromArray(array $data): self throw new FloeException('Floe footer metadata is malformed: ' . $e->getMessage(), 0, $e); } - return new self($data['version'], $data['writer'], $schema, $sections, $data['totalRows'], $metadata); + return new self( + $data['version'], + $data['writer'], + $schema, + $sections, + Statistics::fromArray($data['statistics']), + $metadata, + ); } public function schema(): Schema @@ -114,7 +121,7 @@ public function schemaBody(): string * writer: string, * schema: array>, * sections: array, - * totalRows: int, + * statistics: array{rows: int, byteSize: int}, * metadata: array, * } */ @@ -131,7 +138,7 @@ public function normalize(): array 'writer' => $this->writer, 'schema' => $this->schema, 'sections' => $sections, - 'totalRows' => $this->totalRows, + 'statistics' => $this->statistics->normalize(), 'metadata' => $this->metadata->normalize(), ]; } diff --git a/src/core/etl/src/Flow/Floe/Section.php b/src/core/etl/src/Flow/Floe/Section.php index 808edaefc4..8e180bdb5c 100644 --- a/src/core/etl/src/Flow/Floe/Section.php +++ b/src/core/etl/src/Flow/Floe/Section.php @@ -28,7 +28,7 @@ public static function fromArray(array $data): self $data = type_structure([ 'offset' => type_integer(), 'rowCount' => type_integer(), - ])->assert($data); + ], allow_extra: true)->assert($data); } catch (InvalidTypeException $e) { throw new FloeException('Floe footer section is malformed: ' . $e->getMessage(), 0, $e); } diff --git a/src/core/etl/src/Flow/Floe/Statistics.php b/src/core/etl/src/Flow/Floe/Statistics.php new file mode 100644 index 0000000000..2ff2ae3f2d --- /dev/null +++ b/src/core/etl/src/Flow/Floe/Statistics.php @@ -0,0 +1,63 @@ + $data + * + * @throws FloeException + */ + public static function fromArray(array $data): self + { + try { + $data = type_structure([ + 'rows' => type_integer(), + 'byteSize' => type_integer(), + ], allow_extra: true)->assert($data); + } catch (InvalidTypeException $e) { + throw new FloeException('Floe footer statistics are malformed: ' . $e->getMessage(), 0, $e); + } + + return new self($data['rows'], $data['byteSize']); + } + + /** + * @return array{rows: int, byteSize: int} + */ + public function normalize(): array + { + return [ + 'rows' => $this->rows, + 'byteSize' => $this->byteSize, + ]; + } +} diff --git a/src/core/etl/tests/Flow/ETL/Tests/Context/MemoryFiles.php b/src/core/etl/tests/Flow/ETL/Tests/Context/MemoryFiles.php new file mode 100644 index 0000000000..357e621e12 --- /dev/null +++ b/src/core/etl/tests/Flow/ETL/Tests/Context/MemoryFiles.php @@ -0,0 +1,29 @@ + $contents file content keyed by memory:// URI + */ + public static function with(array $contents): MemoryFilesystem + { + $filesystem = memory_filesystem(); + + foreach ($contents as $uri => $content) { + $stream = $filesystem->writeTo(path($uri)); + $stream->append($content); + $stream->close(); + } + + return $filesystem; + } +} diff --git a/src/core/etl/tests/Flow/ETL/Tests/Double/CountingCache.php b/src/core/etl/tests/Flow/ETL/Tests/Double/CountingCache.php new file mode 100644 index 0000000000..a76cc15c8e --- /dev/null +++ b/src/core/etl/tests/Flow/ETL/Tests/Double/CountingCache.php @@ -0,0 +1,53 @@ +inner->clear(); + } + + public function delete(string $key): void + { + $this->inner->delete($key); + } + + public function get(string $key): Rows + { + $this->getCalls++; + + return $this->inner->get($key); + } + + public function has(string $key): bool + { + return $this->inner->has($key); + } + + public function schema(string $key): Schema + { + return $this->inner->schema($key); + } + + public function set(string $key, Rows $value): void + { + $this->inner->set($key, $value); + } +} diff --git a/src/core/etl/tests/Flow/ETL/Tests/Double/CountingExtractor.php b/src/core/etl/tests/Flow/ETL/Tests/Double/CountingExtractor.php index 070822d587..08010bdf56 100644 --- a/src/core/etl/tests/Flow/ETL/Tests/Double/CountingExtractor.php +++ b/src/core/etl/tests/Flow/ETL/Tests/Double/CountingExtractor.php @@ -9,6 +9,7 @@ use Flow\ETL\Extractor\Batches; use Flow\ETL\Extractor\RewindableExtractor; use Flow\ETL\Extractor\Signal; +use Flow\ETL\Extractor\Statistics; use Flow\ETL\FlowContext; use Flow\ETL\Rows; use Flow\ETL\Schema; @@ -94,4 +95,9 @@ public function withSchema(Schema $schema): static { return $this; } + + public function statistics(): Statistics + { + return new Statistics(); + } } diff --git a/src/core/etl/tests/Flow/ETL/Tests/Double/DeclaringExtractor.php b/src/core/etl/tests/Flow/ETL/Tests/Double/DeclaringExtractor.php new file mode 100644 index 0000000000..7a70182d65 --- /dev/null +++ b/src/core/etl/tests/Flow/ETL/Tests/Double/DeclaringExtractor.php @@ -0,0 +1,39 @@ +schema()); + } + + public function schema(): Schema + { + return new Schema(); + } + + public function statistics(): Statistics + { + return $this->statistics; + } + + public function withSchema(Schema $schema): static + { + return $this; + } +} diff --git a/src/core/etl/tests/Flow/ETL/Tests/Double/EmptyExtractor.php b/src/core/etl/tests/Flow/ETL/Tests/Double/EmptyExtractor.php index 6292cb2b73..945f35ac3b 100644 --- a/src/core/etl/tests/Flow/ETL/Tests/Double/EmptyExtractor.php +++ b/src/core/etl/tests/Flow/ETL/Tests/Double/EmptyExtractor.php @@ -5,6 +5,7 @@ namespace Flow\ETL\Tests\Double; use Flow\ETL\Extractor; +use Flow\ETL\Extractor\Statistics; use Flow\ETL\FlowContext; use Flow\ETL\Schema; use Generator; @@ -28,4 +29,9 @@ public function withSchema(Schema $schema): static { return $this; } + + public function statistics(): Statistics + { + return new Statistics(); + } } diff --git a/src/core/etl/tests/Flow/ETL/Tests/Double/FakeExtractor.php b/src/core/etl/tests/Flow/ETL/Tests/Double/FakeExtractor.php index 1b1629fca2..d6e616ac4e 100644 --- a/src/core/etl/tests/Flow/ETL/Tests/Double/FakeExtractor.php +++ b/src/core/etl/tests/Flow/ETL/Tests/Double/FakeExtractor.php @@ -7,6 +7,7 @@ use DateTimeImmutable; use Flow\ETL\Extractor; use Flow\ETL\Extractor\Signal; +use Flow\ETL\Extractor\Statistics; use Flow\ETL\FlowContext; use Flow\ETL\Rows; use Flow\ETL\Schema; @@ -144,4 +145,9 @@ public function withSchema(Schema $schema): static { return $this; } + + public function statistics(): Statistics + { + return new Statistics(); + } } diff --git a/src/core/etl/tests/Flow/ETL/Tests/Double/FakeRandomOrdersExtractor.php b/src/core/etl/tests/Flow/ETL/Tests/Double/FakeRandomOrdersExtractor.php index 2dbc8ecf30..6d8db4a73a 100644 --- a/src/core/etl/tests/Flow/ETL/Tests/Double/FakeRandomOrdersExtractor.php +++ b/src/core/etl/tests/Flow/ETL/Tests/Double/FakeRandomOrdersExtractor.php @@ -8,6 +8,7 @@ use Faker\Factory; use Flow\ETL\Extractor; use Flow\ETL\Extractor\Signal; +use Flow\ETL\Extractor\Statistics; use Flow\ETL\FlowContext; use Flow\ETL\Schema; use Generator; @@ -158,4 +159,9 @@ public function withSchema(Schema $schema): static { return $this; } + + public function statistics(): Statistics + { + return new Statistics(); + } } diff --git a/src/core/etl/tests/Flow/ETL/Tests/Double/FakeSelfDescribingFile.php b/src/core/etl/tests/Flow/ETL/Tests/Double/FakeSelfDescribingFile.php index 670f07b550..bcdd55f5eb 100644 --- a/src/core/etl/tests/Flow/ETL/Tests/Double/FakeSelfDescribingFile.php +++ b/src/core/etl/tests/Flow/ETL/Tests/Double/FakeSelfDescribingFile.php @@ -7,6 +7,7 @@ use Flow\ETL\Exception\RuntimeException; use Flow\ETL\Extractor\SelfDescribingFile; use Flow\ETL\Extractor\SourceFile; +use Flow\ETL\Extractor\Statistics; use Flow\ETL\Schema; final class FakeSelfDescribingFile implements SelfDescribingFile @@ -17,6 +18,7 @@ public function __construct( private readonly Schema $schema, private readonly SourceFile $source, private readonly bool $describingThrows = false, + private readonly Statistics $statistics = new Statistics(), ) {} public function close(): void @@ -37,4 +39,9 @@ public function source(): SourceFile { return $this->source; } + + public function statistics(): Statistics + { + return $this->statistics; + } } diff --git a/src/core/etl/tests/Flow/ETL/Tests/Double/FakeStaticOrdersExtractor.php b/src/core/etl/tests/Flow/ETL/Tests/Double/FakeStaticOrdersExtractor.php index 25c329f82d..5446922670 100644 --- a/src/core/etl/tests/Flow/ETL/Tests/Double/FakeStaticOrdersExtractor.php +++ b/src/core/etl/tests/Flow/ETL/Tests/Double/FakeStaticOrdersExtractor.php @@ -7,6 +7,7 @@ use DateTimeImmutable; use Flow\ETL\Extractor; use Flow\ETL\Extractor\Signal; +use Flow\ETL\Extractor\Statistics; use Flow\ETL\FlowContext; use Flow\ETL\Row\AdaptiveRowHydrator; use Flow\ETL\Row\Hydrator; @@ -153,4 +154,9 @@ public function withSchema(Schema $schema): static { return $this; } + + public function statistics(): Statistics + { + return new Statistics(); + } } diff --git a/src/core/etl/tests/Flow/ETL/Tests/Double/KeepPaths.php b/src/core/etl/tests/Flow/ETL/Tests/Double/KeepPaths.php new file mode 100644 index 0000000000..c55a3c59e5 --- /dev/null +++ b/src/core/etl/tests/Flow/ETL/Tests/Double/KeepPaths.php @@ -0,0 +1,36 @@ + + */ + private array $uris; + + /** + * @param list $paths + */ + public function __construct(array $paths) + { + $this->uris = array_map(static fn(Path $path): string => $path->uri(), $paths); + } + + public function accept(FileStatus $status): bool + { + return $status->isFile() && in_array($status->path->uri(), $this->uris, true); + } +} diff --git a/src/core/etl/tests/Flow/ETL/Tests/Double/MixedSequenceGenerator.php b/src/core/etl/tests/Flow/ETL/Tests/Double/MixedSequenceGenerator.php index 983662234d..e970fba631 100644 --- a/src/core/etl/tests/Flow/ETL/Tests/Double/MixedSequenceGenerator.php +++ b/src/core/etl/tests/Flow/ETL/Tests/Double/MixedSequenceGenerator.php @@ -4,6 +4,7 @@ namespace Flow\ETL\Tests\Double; +use Flow\ETL\Cardinality; use Flow\ETL\Extractor\SequenceGenerator\SequenceGenerator; use Generator; @@ -14,4 +15,9 @@ public function generate(): Generator yield 1000; yield 'AB-01'; } + + public function rows(): Cardinality + { + return Cardinality::exact(2); + } } diff --git a/src/core/etl/tests/Flow/ETL/Tests/Double/OneRowBatchesExtractor.php b/src/core/etl/tests/Flow/ETL/Tests/Double/OneRowBatchesExtractor.php index 70f6cc5142..de0daac4e3 100644 --- a/src/core/etl/tests/Flow/ETL/Tests/Double/OneRowBatchesExtractor.php +++ b/src/core/etl/tests/Flow/ETL/Tests/Double/OneRowBatchesExtractor.php @@ -9,6 +9,7 @@ use Flow\ETL\Extractor\Batches; use Flow\ETL\Extractor\RewindableExtractor; use Flow\ETL\Extractor\Signal; +use Flow\ETL\Extractor\Statistics; use Flow\ETL\FlowContext; use Flow\ETL\Rows; use Flow\ETL\Schema; @@ -51,4 +52,9 @@ public function withSchema(Schema $schema): static { return $this; } + + public function statistics(): Statistics + { + return new Statistics(); + } } diff --git a/src/core/etl/tests/Flow/ETL/Tests/Double/RecordingExtractor.php b/src/core/etl/tests/Flow/ETL/Tests/Double/RecordingExtractor.php index dc83c4a006..082249c8a2 100644 --- a/src/core/etl/tests/Flow/ETL/Tests/Double/RecordingExtractor.php +++ b/src/core/etl/tests/Flow/ETL/Tests/Double/RecordingExtractor.php @@ -5,6 +5,7 @@ namespace Flow\ETL\Tests\Double; use Flow\ETL\Extractor; +use Flow\ETL\Extractor\Statistics; use Flow\ETL\FlowContext; use Flow\ETL\Rows; use Flow\ETL\Schema; @@ -52,4 +53,9 @@ public function withSchema(Schema $schema): static { return $this; } + + public function statistics(): Statistics + { + return new Statistics(); + } } diff --git a/src/core/etl/tests/Flow/ETL/Tests/Double/RecordingFileExtractor.php b/src/core/etl/tests/Flow/ETL/Tests/Double/RecordingFileExtractor.php index a73941f15f..52cffddc9c 100644 --- a/src/core/etl/tests/Flow/ETL/Tests/Double/RecordingFileExtractor.php +++ b/src/core/etl/tests/Flow/ETL/Tests/Double/RecordingFileExtractor.php @@ -5,6 +5,7 @@ namespace Flow\ETL\Tests\Double; use Flow\ETL\Extractor\FileExtractor; +use Flow\ETL\Extractor\Statistics; use Flow\ETL\FlowContext; use Flow\ETL\Rows; use Flow\ETL\Schema; @@ -84,4 +85,9 @@ public function withSchema(Schema $schema): static { return $this; } + + public function statistics(): Statistics + { + return new Statistics(); + } } diff --git a/src/core/etl/tests/Flow/ETL/Tests/Double/RecordingSequenceGenerator.php b/src/core/etl/tests/Flow/ETL/Tests/Double/RecordingSequenceGenerator.php index 0b99103cef..1cfcb08c66 100644 --- a/src/core/etl/tests/Flow/ETL/Tests/Double/RecordingSequenceGenerator.php +++ b/src/core/etl/tests/Flow/ETL/Tests/Double/RecordingSequenceGenerator.php @@ -4,16 +4,19 @@ namespace Flow\ETL\Tests\Double; +use Flow\ETL\Cardinality; use Flow\ETL\Extractor\SequenceGenerator\SequenceGenerator; use Generator; /** - * Counts generate() calls so a test can assert how many passes a run makes over the sequence. + * Counts generate() and rows() calls so a test can assert how many passes a run makes over the sequence. */ final class RecordingSequenceGenerator implements SequenceGenerator { public int $generateCalls = 0; + public int $rowsCalls = 0; + public function __construct( private readonly SequenceGenerator $inner, ) {} @@ -24,4 +27,11 @@ public function generate(): Generator yield from $this->inner->generate(); } + + public function rows(): Cardinality + { + $this->rowsCalls++; + + return $this->inner->rows(); + } } diff --git a/src/core/etl/tests/Flow/ETL/Tests/Double/RepeatableExtractor.php b/src/core/etl/tests/Flow/ETL/Tests/Double/RepeatableExtractor.php index b01be67adc..fc488b488b 100644 --- a/src/core/etl/tests/Flow/ETL/Tests/Double/RepeatableExtractor.php +++ b/src/core/etl/tests/Flow/ETL/Tests/Double/RepeatableExtractor.php @@ -7,6 +7,7 @@ use Flow\ETL\Extractor; use Flow\ETL\Extractor\OverridingExtractor; use Flow\ETL\Extractor\RewindableExtractor; +use Flow\ETL\Extractor\Statistics; use Flow\ETL\FlowContext; use Flow\ETL\Rows; use Flow\ETL\Schema; @@ -59,4 +60,9 @@ public function withSchema(Schema $schema): static { return $this; } + + public function statistics(): Statistics + { + return new Statistics(); + } } diff --git a/src/core/etl/tests/Flow/ETL/Tests/Double/RowLessExtractor.php b/src/core/etl/tests/Flow/ETL/Tests/Double/RowLessExtractor.php index 3d2fed8e0e..847b90134e 100644 --- a/src/core/etl/tests/Flow/ETL/Tests/Double/RowLessExtractor.php +++ b/src/core/etl/tests/Flow/ETL/Tests/Double/RowLessExtractor.php @@ -5,6 +5,7 @@ namespace Flow\ETL\Tests\Double; use Flow\ETL\Extractor; +use Flow\ETL\Extractor\Statistics; use Flow\ETL\FlowContext; use Flow\ETL\Schema; use Generator; @@ -41,4 +42,9 @@ public function withSchema(Schema $schema): static return $this; } + + public function statistics(): Statistics + { + return new Statistics(); + } } diff --git a/src/core/etl/tests/Flow/ETL/Tests/Double/StopIgnoringExtractor.php b/src/core/etl/tests/Flow/ETL/Tests/Double/StopIgnoringExtractor.php index 31b9ee48e4..93bfa17393 100644 --- a/src/core/etl/tests/Flow/ETL/Tests/Double/StopIgnoringExtractor.php +++ b/src/core/etl/tests/Flow/ETL/Tests/Double/StopIgnoringExtractor.php @@ -8,6 +8,7 @@ use Flow\ETL\Extractor\BatchableExtractor; use Flow\ETL\Extractor\Batches; use Flow\ETL\Extractor\RewindableExtractor; +use Flow\ETL\Extractor\Statistics; use Flow\ETL\FlowContext; use Flow\ETL\Rows; use Flow\ETL\Schema; @@ -48,4 +49,9 @@ public function withSchema(Schema $schema): static { return $this; } + + public function statistics(): Statistics + { + return new Statistics(); + } } diff --git a/src/core/etl/tests/Flow/ETL/Tests/Double/ThrowingAfterFirstBatchExtractor.php b/src/core/etl/tests/Flow/ETL/Tests/Double/ThrowingAfterFirstBatchExtractor.php index ba618b08c6..b600a182b0 100644 --- a/src/core/etl/tests/Flow/ETL/Tests/Double/ThrowingAfterFirstBatchExtractor.php +++ b/src/core/etl/tests/Flow/ETL/Tests/Double/ThrowingAfterFirstBatchExtractor.php @@ -5,6 +5,7 @@ namespace Flow\ETL\Tests\Double; use Flow\ETL\Extractor; +use Flow\ETL\Extractor\Statistics; use Flow\ETL\FlowContext; use Flow\ETL\Schema; use Generator; @@ -33,4 +34,9 @@ public function withSchema(Schema $schema): static { return $this; } + + public function statistics(): Statistics + { + return new Statistics(); + } } diff --git a/src/core/etl/tests/Flow/ETL/Tests/Double/UndescribableRowLessExtractor.php b/src/core/etl/tests/Flow/ETL/Tests/Double/UndescribableRowLessExtractor.php index ffcdd0c264..1a74cfc87b 100644 --- a/src/core/etl/tests/Flow/ETL/Tests/Double/UndescribableRowLessExtractor.php +++ b/src/core/etl/tests/Flow/ETL/Tests/Double/UndescribableRowLessExtractor.php @@ -6,6 +6,7 @@ use Flow\ETL\Exception\SchemaNotDerivableException; use Flow\ETL\Extractor; +use Flow\ETL\Extractor\Statistics; use Flow\ETL\FlowContext; use Flow\ETL\Schema; use Generator; @@ -32,4 +33,9 @@ public function withSchema(Schema $schema): static { return $this; } + + public function statistics(): Statistics + { + return new Statistics(); + } } diff --git a/src/core/etl/tests/Flow/ETL/Tests/Double/UnsizedFilesystem.php b/src/core/etl/tests/Flow/ETL/Tests/Double/UnsizedFilesystem.php new file mode 100644 index 0000000000..2b9b501c95 --- /dev/null +++ b/src/core/etl/tests/Flow/ETL/Tests/Double/UnsizedFilesystem.php @@ -0,0 +1,85 @@ + $unsizedUris + */ + public function __construct( + private Filesystem $inner, + private array $unsizedUris, + ) {} + + public function appendTo(Path $path): DestinationStream + { + return $this->inner->appendTo($path); + } + + public function getSystemTmpDir(): Path + { + return $this->inner->getSystemTmpDir(); + } + + public function list(Path $path, Filter $pathFilter = new KeepAll()): Generator + { + foreach ($this->inner->list($path, $pathFilter) as $status) { + yield in_array($status->path->uri(), $this->unsizedUris, true) + ? new FileStatus($status->path, $status->isFile(), null, $status->lastModifiedAt) + : $status; + } + } + + public function mount(): Mount + { + return $this->inner->mount(); + } + + public function mv(Path $from, Path $to): bool + { + return $this->inner->mv($from, $to); + } + + public function readFrom(Path $path): SourceStream + { + return $this->inner->readFrom($path); + } + + public function rm(Path $path): bool + { + return $this->inner->rm($path); + } + + public function status(Path $path): ?FileStatus + { + return $this->inner->status($path); + } + + public function supports(Path $path): bool + { + return $this->inner->supports($path); + } + + public function writeTo(Path $path): DestinationStream + { + return $this->inner->writeTo($path); + } +} diff --git a/src/core/etl/tests/Flow/ETL/Tests/Double/VaryingBatchesExtractor.php b/src/core/etl/tests/Flow/ETL/Tests/Double/VaryingBatchesExtractor.php index 90bc7b0f4a..2b73216114 100644 --- a/src/core/etl/tests/Flow/ETL/Tests/Double/VaryingBatchesExtractor.php +++ b/src/core/etl/tests/Flow/ETL/Tests/Double/VaryingBatchesExtractor.php @@ -5,6 +5,7 @@ namespace Flow\ETL\Tests\Double; use Flow\ETL\Extractor; +use Flow\ETL\Extractor\Statistics; use Flow\ETL\FlowContext; use Flow\ETL\Rows; use Flow\ETL\Schema; @@ -42,4 +43,9 @@ public function withSchema(Schema $schema): static { return $this; } + + public function statistics(): Statistics + { + return new Statistics(); + } } diff --git a/src/core/etl/tests/Flow/ETL/Tests/Integration/DataFrame/CacheTest.php b/src/core/etl/tests/Flow/ETL/Tests/Integration/DataFrame/CacheTest.php index d146e90064..96db55cdaa 100644 --- a/src/core/etl/tests/Flow/ETL/Tests/Integration/DataFrame/CacheTest.php +++ b/src/core/etl/tests/Flow/ETL/Tests/Integration/DataFrame/CacheTest.php @@ -8,6 +8,7 @@ use Flow\ETL\Cache\Implementation\FilesystemCache; use Flow\ETL\Cache\Implementation\InMemoryCache; use Flow\ETL\Extractor; +use Flow\ETL\Extractor\Statistics; use Flow\ETL\FlowContext; use Flow\ETL\Rows; use Flow\ETL\Schema; @@ -67,6 +68,11 @@ public function extract(FlowContext $context, ?int $limit = null): Generator return $this->extractor->extract($context); } + + public function statistics(): Statistics + { + return new Statistics(); + } }; $cache = new InMemoryCache(); diff --git a/src/core/etl/tests/Flow/ETL/Tests/Integration/DataFrame/DisplayTest.php b/src/core/etl/tests/Flow/ETL/Tests/Integration/DataFrame/DisplayTest.php index 336ee7db3c..e362c2e8d8 100644 --- a/src/core/etl/tests/Flow/ETL/Tests/Integration/DataFrame/DisplayTest.php +++ b/src/core/etl/tests/Flow/ETL/Tests/Integration/DataFrame/DisplayTest.php @@ -7,6 +7,7 @@ use DateTimeImmutable; use Flow\ETL\Extractor; use Flow\ETL\Extractor\Signal; +use Flow\ETL\Extractor\Statistics; use Flow\ETL\FlowContext; use Flow\ETL\Rows; use Flow\ETL\Schema; @@ -111,6 +112,11 @@ enum_schema('enum', BackedStringEnum::class), ); } } + + public function statistics(): Statistics + { + return new Statistics(); + } })->collect(); self::assertCommandOutputIdentical(<<<'ASCIITABLE' @@ -191,6 +197,11 @@ public function extract(FlowContext $context, ?int $limit = null): Generator ); } } + + public function statistics(): Statistics + { + return new Statistics(); + } }) ->collect() ->repartition(ref('group')); diff --git a/src/core/etl/tests/Flow/ETL/Tests/Integration/DataFrame/LimitTest.php b/src/core/etl/tests/Flow/ETL/Tests/Integration/DataFrame/LimitTest.php index 1336941293..4d3a548d51 100644 --- a/src/core/etl/tests/Flow/ETL/Tests/Integration/DataFrame/LimitTest.php +++ b/src/core/etl/tests/Flow/ETL/Tests/Integration/DataFrame/LimitTest.php @@ -7,6 +7,7 @@ use Flow\ETL\Exception\InvalidArgumentException; use Flow\ETL\Extractor; use Flow\ETL\Extractor\Signal; +use Flow\ETL\Extractor\Statistics; use Flow\ETL\FlowContext; use Flow\ETL\Optimizer; use Flow\ETL\Planner; @@ -105,6 +106,11 @@ public function extract(FlowContext $context, ?int $limit = null): Generator yield rows(schema(integer_schema('id')), row(['id' => $i])); } } + + public function statistics(): Statistics + { + return new Statistics(); + } })->fetch(); static::assertCount(20, $rows); @@ -135,6 +141,11 @@ public function extract(FlowContext $context, ?int $limit = null): Generator yield rows(schema(integer_schema('id')), row(['id' => $i + 1]), row(['id' => $i + 2])); } } + + public function statistics(): Statistics + { + return new Statistics(); + } }) ->limit(10) ->fetch(); @@ -206,6 +217,11 @@ public function extract(FlowContext $context, ?int $limit = null): Generator ); } } + + public function statistics(): Statistics + { + return new Statistics(); + } }) ->withEntries([ 'expanded' => ref('ids')->expand(), @@ -248,6 +264,11 @@ public function extract(FlowContext $context, ?int $limit = null): Generator yield rows(schema(integer_schema('id')), row(['id' => $i + 1]), row(['id' => $i + 2])); } } + + public function statistics(): Statistics + { + return new Statistics(); + } }) ->batchSize(50) ->limit(10) @@ -281,6 +302,11 @@ public function extract(FlowContext $context, ?int $limit = null): Generator yield rows(schema(integer_schema('id')), row(['id' => $i + 1]), row(['id' => $i + 2])); } } + + public function statistics(): Statistics + { + return new Statistics(); + } }) ->limit(10) ->collect() @@ -314,6 +340,11 @@ public function extract(FlowContext $context, ?int $limit = null): Generator yield rows(schema(integer_schema('id')), row(['id' => $i])); } } + + public function statistics(): Statistics + { + return new Statistics(); + } }) ->limit(10) ->fetch(); diff --git a/src/core/etl/tests/Flow/ETL/Tests/Integration/DataFrame/OffsetTest.php b/src/core/etl/tests/Flow/ETL/Tests/Integration/DataFrame/OffsetTest.php index a33bf10584..e9fb7349ff 100644 --- a/src/core/etl/tests/Flow/ETL/Tests/Integration/DataFrame/OffsetTest.php +++ b/src/core/etl/tests/Flow/ETL/Tests/Integration/DataFrame/OffsetTest.php @@ -7,6 +7,7 @@ use Flow\ETL\Exception\InvalidArgumentException; use Flow\ETL\Extractor; use Flow\ETL\Extractor\Signal; +use Flow\ETL\Extractor\Statistics; use Flow\ETL\FlowContext; use Flow\ETL\Rows; use Flow\ETL\Schema; @@ -141,6 +142,11 @@ public function extract(FlowContext $context, ?int $limit = null): Generator yield rows(schema(integer_schema('id')), row(['id' => $i + 1])); } } + + public function statistics(): Statistics + { + return new Statistics(); + } }) ->batchSize(3) ->offset(4) @@ -184,6 +190,11 @@ public function extract(FlowContext $context, ?int $limit = null): Generator yield rows(schema(integer_schema('id')), row(['id' => $i + 1])); } } + + public function statistics(): Statistics + { + return new Statistics(); + } }) ->offset(2) ->collect() @@ -243,6 +254,11 @@ public function extract(FlowContext $context, ?int $limit = null): Generator ); } } + + public function statistics(): Statistics + { + return new Statistics(); + } }) ->withEntries([ 'expanded' => ref('ids')->expand(), @@ -306,6 +322,11 @@ public function extract(FlowContext $context, ?int $limit = null): Generator yield rows(schema(integer_schema('id')), row(['id' => $i + 1])); } } + + public function statistics(): Statistics + { + return new Statistics(); + } }) ->offset(4) ->fetch(); diff --git a/src/core/etl/tests/Flow/ETL/Tests/Integration/Extractor/CacheExtractorTest.php b/src/core/etl/tests/Flow/ETL/Tests/Integration/Extractor/CacheExtractorTest.php index dc5362b4f8..d4b0ce9658 100644 --- a/src/core/etl/tests/Flow/ETL/Tests/Integration/Extractor/CacheExtractorTest.php +++ b/src/core/etl/tests/Flow/ETL/Tests/Integration/Extractor/CacheExtractorTest.php @@ -7,9 +7,13 @@ use Flow\ETL\Cache\CacheIndex; use Flow\ETL\Cache\Implementation\FilesystemCache; use Flow\ETL\Cache\Implementation\InMemoryCache; +use Flow\ETL\Cardinality; use Flow\ETL\Extractor\Signal; use Flow\ETL\Rows; +use Flow\ETL\Tests\Double\CountingCache; +use Flow\ETL\Tests\Double\CountingExtractor; use Flow\ETL\Tests\FlowIntegrationTestCase; +use Flow\ETL\Tests\Mother\RowsMother; use function array_map; use function array_merge; @@ -28,6 +32,76 @@ final class CacheExtractorTest extends FlowIntegrationTestCase { + public function test_a_cached_index_declares_its_rows_exactly(): void + { + $cache = new InMemoryCache(); + df(config_builder()->cache($cache)) + ->read(from_array([['id' => 1], ['id' => 2], ['id' => 3]])) + ->cache('cached') + ->run(); + + $extractor = from_cache('cached', cache: $cache); + + static::assertEquals(Cardinality::exact(3), $extractor->statistics()->rows); + static::assertSame($extractor->statistics(), $extractor->statistics()); + } + + public function test_schema_and_statistics_load_the_index_once(): void + { + $cache = new CountingCache(new InMemoryCache()); + df(config_builder()->cache($cache)) + ->read(from_array([['id' => 1], ['id' => 2]])) + ->cache('cached') + ->run(); + $loads = $cache->getCalls; + + $extractor = from_cache('cached', cache: $cache); + $extractor->schema(); + $extractor->statistics(); + + static::assertSame($loads + 1, $cache->getCalls); + } + + public function test_a_cache_from_the_context_declares_nothing(): void + { + static::assertEquals( + Cardinality::unknown(), + from_cache('cached', from_array([['id' => 1]]))->statistics()->rows, + ); + } + + public function test_a_missing_entry_without_a_fallback_declares_zero_rows(): void + { + static::assertEquals( + Cardinality::exact(0), + from_cache('missing', cache: new InMemoryCache())->statistics()->rows, + ); + } + + public function test_a_missing_entry_declares_what_the_fallback_declares(): void + { + static::assertEquals( + Cardinality::exact(2), + from_cache( + 'missing', + from_array([['id' => 1], ['id' => 2]]), + cache: new InMemoryCache(), + )->statistics()->rows, + ); + } + + public function test_a_missing_entry_with_an_undeclaring_fallback_declares_nothing(): void + { + static::assertEquals( + Cardinality::unknown(), + from_cache( + 'missing', + new CountingExtractor(schema(int_schema('id')), RowsMother::sequentialIds(2)), + cache: new InMemoryCache(), + )->statistics()->rows, + ); + } + public function test_extracting_rows_from_cache(): void { $cache = new InMemoryCache(); diff --git a/src/core/etl/tests/Flow/ETL/Tests/Integration/Extractor/PathPartitionsExtractorTest.php b/src/core/etl/tests/Flow/ETL/Tests/Integration/Extractor/PathPartitionsExtractorTest.php index a0d7edd552..138eac2586 100644 --- a/src/core/etl/tests/Flow/ETL/Tests/Integration/Extractor/PathPartitionsExtractorTest.php +++ b/src/core/etl/tests/Flow/ETL/Tests/Integration/Extractor/PathPartitionsExtractorTest.php @@ -4,9 +4,12 @@ namespace Flow\ETL\Tests\Integration\Extractor; +use Flow\ETL\Cardinality; use Flow\ETL\Extractor\PathPartitionsExtractor; use Flow\ETL\Rows; use Flow\ETL\Tests\Context\ExtractedRows; +use Flow\ETL\Tests\Context\MemoryFiles; +use Flow\ETL\Tests\Double\CountingFilesystem; use Flow\ETL\Tests\FlowIntegrationTestCase; use Flow\Filesystem\Tests\OperatingSystem; @@ -27,6 +30,35 @@ final class PathPartitionsExtractorTest extends FlowIntegrationTestCase { use OperatingSystem; + public function test_it_declares_one_row_per_listed_file(): void + { + $statistics = from_path_partitions('memory://dir/**/*', MemoryFiles::with([ + 'memory://dir/year=2024/a.txt' => 'a', + 'memory://dir/year=2024/b.txt' => 'b', + 'memory://dir/year=2025/c.txt' => 'c', + ]))->statistics(); + + static::assertEquals(Cardinality::exact(3), $statistics->rows); + static::assertEquals(Cardinality::unknown(), $statistics->size); + } + + public function test_an_empty_listing_declares_zero_rows(): void + { + static::assertEquals( + Cardinality::exact(0), + from_path_partitions('memory://dir/**/*', MemoryFiles::with([]))->statistics()->rows, + ); + } + + public function test_the_listing_is_read_once(): void + { + $filesystem = new CountingFilesystem(MemoryFiles::with(['memory://dir/year=2024/a.txt' => 'a'])); + $extractor = from_path_partitions('memory://dir/**/*', $filesystem); + + static::assertSame($extractor->statistics(), $extractor->statistics()); + static::assertSame(1, $filesystem->listCalls); + } + public function test_partition_directories_are_declared_as_string_columns_next_to_the_map(): void { $extractor = from_path_partitions(path_real(__DIR__ . '/Fixtures/multi_partitioned/**/*')); diff --git a/src/core/etl/tests/Flow/ETL/Tests/Unit/Cache/CacheIndexTest.php b/src/core/etl/tests/Flow/ETL/Tests/Unit/Cache/CacheIndexTest.php index af9781ceaa..7f36e3845c 100644 --- a/src/core/etl/tests/Flow/ETL/Tests/Unit/Cache/CacheIndexTest.php +++ b/src/core/etl/tests/Flow/ETL/Tests/Unit/Cache/CacheIndexTest.php @@ -5,6 +5,7 @@ namespace Flow\ETL\Tests\Unit\Cache; use Flow\ETL\Cache\CacheIndex; +use Flow\ETL\Cardinality; use Flow\ETL\Exception\InvalidArgumentException; use Flow\ETL\Tests\FlowTestCase; @@ -26,6 +27,28 @@ public function test_empty_index_round_trip(): void static::assertEquals($index, CacheIndex::fromRows('dataset-id', $indexRows)); } + public function test_an_empty_index_declares_zero_rows_exactly(): void + { + static::assertEquals(Cardinality::exact(0), (new CacheIndex('dataset-id'))->rows()); + } + + public function test_an_index_without_a_rows_column_declares_unknown_rows(): void + { + $index = CacheIndex::fromRows('dataset-id', rows(schema(str_schema('key')), row(['key' => 'chunk-1']))); + + static::assertSame(['chunk-1'], $index->values()); + static::assertEquals(Cardinality::unknown(), $index->rows()); + } + + public function test_a_chunk_without_a_count_makes_the_rows_unknown(): void + { + $index = new CacheIndex('dataset-id'); + $index->add('chunk-1', 3); + $index->add('chunk-2'); + + static::assertEquals(Cardinality::unknown(), $index->rows()); + } + public function test_from_rows_key_comes_from_the_argument(): void { $index = new CacheIndex('original-key'); @@ -52,6 +75,27 @@ public function test_from_rows_with_non_string_key_entry_throws(): void CacheIndex::fromRows('dataset-id', rows(schema(str_schema('key', nullable: true)), row(['key' => null]))); } + public function test_from_rows_with_non_integer_rows_entry_throws(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('CacheIndex expects rows with an integer "rows" entry, got: string'); + + CacheIndex::fromRows('dataset-id', rows( + schema(str_schema('key'), str_schema('rows')), + row(['key' => 'chunk-1', 'rows' => 'three']), + )); + } + + public function test_rows_sum_the_chunk_counts(): void + { + $index = new CacheIndex('dataset-id'); + $index->add('chunk-1', 3); + $index->add('chunk-2', 0); + $index->add('chunk-3', 4); + + static::assertEquals(Cardinality::exact(7), CacheIndex::fromRows('dataset-id', $index->toRows())->rows()); + } + public function test_to_rows_from_rows_round_trip_preserves_order(): void { $index = new CacheIndex('dataset-id'); @@ -65,14 +109,18 @@ public function test_to_rows_from_rows_round_trip_preserves_order(): void static::assertSame(['chunk-b', 'chunk-a', 'chunk-c'], $reconstructed->values()); } - public function test_to_rows_stores_one_key_entry_per_row(): void + public function test_to_rows_stores_one_key_and_rows_entry_per_chunk(): void { $index = new CacheIndex('dataset-id'); - $index->add('chunk-1'); + $index->add('chunk-1', 2); $index->add('chunk-2'); static::assertEquals( - rows(schema(str_schema('key')), row(['key' => 'chunk-1']), row(['key' => 'chunk-2'])), + rows( + schema(str_schema('key'), int_schema('rows', nullable: true)), + row(['key' => 'chunk-1', 'rows' => 2]), + row(['key' => 'chunk-2', 'rows' => null]), + ), $index->toRows(), ); } diff --git a/src/core/etl/tests/Flow/ETL/Tests/Unit/CardinalityTest.php b/src/core/etl/tests/Flow/ETL/Tests/Unit/CardinalityTest.php new file mode 100644 index 0000000000..449feba42b --- /dev/null +++ b/src/core/etl/tests/Flow/ETL/Tests/Unit/CardinalityTest.php @@ -0,0 +1,136 @@ +merge(Cardinality::exact(20))->atMost); + static::assertNull(Cardinality::atMost(10)->merge(Cardinality::approximately(20))->atMost); + } + + public function test_a_negative_bound_is_rejected(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Cardinality upper bound must not be negative, given: -1'); + + new Cardinality(atMost: -1); + } + + public function test_a_negative_estimate_is_rejected(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Cardinality estimate must not be negative, given: -1'); + + new Cardinality(estimate: -1); + } + + public function test_a_negative_relative_error_is_rejected(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Relative error must not be negative, given: -0.1'); + + new Cardinality(relativeError: -0.1); + } + + public function test_an_estimate_survives_only_if_both_sides_have_one(): void + { + static::assertSame(30, Cardinality::approximately(10)->merge(Cardinality::exact(20))->estimate); + static::assertNull(Cardinality::approximately(10)->merge(Cardinality::atMost(20))->estimate); + } + + public function test_approximately_is_an_estimate_without_a_bound(): void + { + $cardinality = Cardinality::approximately(16820); + + static::assertNull($cardinality->atMost); + static::assertSame(16820, $cardinality->estimate); + static::assertSame(Cardinality::DEFAULT_RELATIVE_ERROR, $cardinality->relativeError); + } + + public function test_approximately_is_confident_within_its_declared_error(): void + { + static::assertSame(16820, Cardinality::approximately(16820, 0.5)->confident(0.5)); + } + + public function test_approximately_is_not_confident_below_its_declared_error(): void + { + static::assertNull(Cardinality::approximately(16820, 0.5)->confident(0.1)); + } + + public function test_at_most_is_a_bound_without_an_estimate(): void + { + $cardinality = Cardinality::atMost(500); + + static::assertSame(500, $cardinality->atMost); + static::assertNull($cardinality->estimate); + } + + public function test_at_most_is_never_confident(): void + { + static::assertNull(Cardinality::atMost(500)->confident(1.0)); + } + + public function test_exact_is_both_a_bound_and_an_estimate(): void + { + $cardinality = Cardinality::exact(1000); + + static::assertSame(1000, $cardinality->atMost); + static::assertSame(1000, $cardinality->estimate); + static::assertSame(0.0, $cardinality->relativeError); + } + + public function test_exact_is_confident_at_zero_error(): void + { + static::assertSame(1000, Cardinality::exact(1000)->confident(0.0)); + } + + public function test_the_sum_carries_the_larger_relative_error(): void + { + static::assertSame( + 0.3, + Cardinality::approximately(10, 0.1)->merge(Cardinality::approximately(20, 0.3))->relativeError, + ); + static::assertSame( + 0.3, + Cardinality::approximately(10, 0.3)->merge(Cardinality::approximately(20, 0.1))->relativeError, + ); + } + + public function test_two_exact_counts_add_up_to_an_exact_count(): void + { + static::assertEquals(Cardinality::exact(30), Cardinality::exact(10)->merge(Cardinality::exact(20))); + } + + public function test_unknown_knows_nothing(): void + { + $cardinality = Cardinality::unknown(); + + static::assertNull($cardinality->atMost); + static::assertNull($cardinality->estimate); + static::assertNull($cardinality->confident(1.0)); + } + + public function test_unknown_merged_with_anything_is_unknown(): void + { + static::assertEquals(Cardinality::unknown(), Cardinality::unknown()->merge(Cardinality::exact(10))); + static::assertEquals(Cardinality::unknown(), Cardinality::exact(10)->merge(Cardinality::unknown())); + } + + public function test_a_validated_count_reads_back_as_a_non_negative_int(): void + { + // str_repeat() takes int<0, max>: the analyzer accepts these properties only because the guard narrowed them + static::assertSame(5, strlen(str_repeat('x', Cardinality::atMost(5)->atMost ?? 0))); + static::assertSame(7, strlen(str_repeat('x', Cardinality::approximately(7)->estimate ?? 0))); + } +} diff --git a/src/core/etl/tests/Flow/ETL/Tests/Unit/DataFrameTest.php b/src/core/etl/tests/Flow/ETL/Tests/Unit/DataFrameTest.php index d9457f7952..bab2432c9e 100644 --- a/src/core/etl/tests/Flow/ETL/Tests/Unit/DataFrameTest.php +++ b/src/core/etl/tests/Flow/ETL/Tests/Unit/DataFrameTest.php @@ -13,6 +13,7 @@ use Flow\ETL\Exception\InvalidLogicException; use Flow\ETL\Extractor; use Flow\ETL\Extractor\Signal; +use Flow\ETL\Extractor\Statistics; use Flow\ETL\FlowContext; use Flow\ETL\Loader; use Flow\ETL\Memory\ArrayMemory; @@ -263,6 +264,11 @@ public function extract(FlowContext $context, ?int $limit = null): Generator yield rows(schema(integer_schema('id')), row(['id' => $i])); } } + + public function statistics(): Statistics + { + return new Statistics(); + } }) ->filter(ref('id')->mod(lit(2))->same(lit(0))) ->fetch(); @@ -413,6 +419,11 @@ public function extract(FlowContext $context, ?int $limit = null): Generator yield rows(schema(integer_schema('id')), row(['id' => $i])); } } + + public function statistics(): Statistics + { + return new Statistics(); + } }) ->withEntry('odd', ref('id')->mod(lit(2))->equals(lit(0))) ->fetch(); @@ -490,6 +501,11 @@ public function extract(FlowContext $context, ?int $limit = null): Generator ]), ); } + + public function statistics(): Statistics + { + return new Statistics(); + } }; $addStampStringEntry = new class implements Transformer { @@ -772,6 +788,11 @@ public function extract(FlowContext $context, ?int $limit = null): Generator row(['id' => 10]), ); } + + public function statistics(): Statistics + { + return new Statistics(); + } }) ->with(new class implements Transformer { public function bind(Schema $input): BoundStep @@ -825,6 +846,11 @@ public function extract(FlowContext $context, ?int $limit = null): Generator yield rows(schema(integer_schema('id')), row(['id' => 2])); yield rows(schema(integer_schema('id')), row(['id' => 3])); } + + public function statistics(): Statistics + { + return new Statistics(); + } }) ->with(new class implements Transformer { public function bind(Schema $input): BoundStep diff --git a/src/core/etl/tests/Flow/ETL/Tests/Unit/ETLErrorHandlingTest.php b/src/core/etl/tests/Flow/ETL/Tests/Unit/ETLErrorHandlingTest.php index e53d89341a..f5ae242a1b 100644 --- a/src/core/etl/tests/Flow/ETL/Tests/Unit/ETLErrorHandlingTest.php +++ b/src/core/etl/tests/Flow/ETL/Tests/Unit/ETLErrorHandlingTest.php @@ -8,6 +8,7 @@ use Flow\ETL\BoundStep; use Flow\ETL\Extractor; use Flow\ETL\Extractor\Signal; +use Flow\ETL\Extractor\Statistics; use Flow\ETL\FlowContext; use Flow\ETL\Loader; use Flow\ETL\Rows; @@ -88,6 +89,11 @@ public function extract(FlowContext $context, ?int $limit = null): Generator 'phase' => null, ])); } + + public function statistics(): Statistics + { + return new Statistics(); + } }; $brokenTransformer = new class implements Transformer { @@ -164,6 +170,11 @@ public function extract(FlowContext $context, ?int $limit = null): Generator 'phase' => null, ])); } + + public function statistics(): Statistics + { + return new Statistics(); + } }; $brokenTransformer = new class implements Transformer { @@ -262,6 +273,11 @@ public function extract(FlowContext $context, ?int $limit = null): Generator 'phase' => null, ])); } + + public function statistics(): Statistics + { + return new Statistics(); + } }; $brokenTransformer = new class implements Transformer { diff --git a/src/core/etl/tests/Flow/ETL/Tests/Unit/Executor/OffsetPipelineTest.php b/src/core/etl/tests/Flow/ETL/Tests/Unit/Executor/OffsetPipelineTest.php index 55911d589f..6e3e8cafac 100644 --- a/src/core/etl/tests/Flow/ETL/Tests/Unit/Executor/OffsetPipelineTest.php +++ b/src/core/etl/tests/Flow/ETL/Tests/Unit/Executor/OffsetPipelineTest.php @@ -7,6 +7,7 @@ use Flow\ETL\Exception\InvalidArgumentException; use Flow\ETL\Executor\Segments; use Flow\ETL\Extractor; +use Flow\ETL\Extractor\Statistics; use Flow\ETL\FlowContext; use Flow\ETL\Processor\OffsetProcessor; use Flow\ETL\Schema; @@ -100,6 +101,11 @@ public function extract(FlowContext $context, ?int $limit = null): Generator yield rows(schema(int_schema('id')), row(['id' => 3]), row(['id' => 4])); yield rows(schema(int_schema('id')), row(['id' => 5]), row(['id' => 6])); } + + public function statistics(): Statistics + { + return new Statistics(); + } }); $segments->add(new OffsetProcessor(4)); $result = iterator_to_array(ExecutedSegments::of($segments, flow_context(config()))); @@ -126,6 +132,11 @@ public function extract(FlowContext $context, ?int $limit = null): Generator yield rows(schema(int_schema('id')), row(['id' => 3]), row(['id' => 4]), row(['id' => 5])); yield rows(schema(int_schema('id')), row(['id' => 6])); } + + public function statistics(): Statistics + { + return new Statistics(); + } }); $segments->add(new OffsetProcessor(3)); $result = iterator_to_array(ExecutedSegments::of($segments, flow_context(config()))); @@ -152,6 +163,11 @@ public function extract(FlowContext $context, ?int $limit = null): Generator yield rows(schema(int_schema('id')), row(['id' => 1]), row(['id' => 2]), row(['id' => 3])); yield rows(schema(int_schema('id')), row(['id' => 4]), row(['id' => 5])); } + + public function statistics(): Statistics + { + return new Statistics(); + } }); $segments->add(new OffsetProcessor(1)); $result = iterator_to_array(ExecutedSegments::of($segments, flow_context(config()))); @@ -200,6 +216,11 @@ public function extract(FlowContext $context, ?int $limit = null): Generator yield rows(schema()); yield rows(schema(int_schema('id')), row(['id' => 3])); } + + public function statistics(): Statistics + { + return new Statistics(); + } }); $segments->add(new OffsetProcessor(2)); $result = iterator_to_array(ExecutedSegments::of($segments, flow_context(config()))); diff --git a/src/core/etl/tests/Flow/ETL/Tests/Unit/Extractor/ArrayExtractorTest.php b/src/core/etl/tests/Flow/ETL/Tests/Unit/Extractor/ArrayExtractorTest.php index 045a17c6e9..15ce8a1999 100644 --- a/src/core/etl/tests/Flow/ETL/Tests/Unit/Extractor/ArrayExtractorTest.php +++ b/src/core/etl/tests/Flow/ETL/Tests/Unit/Extractor/ArrayExtractorTest.php @@ -6,9 +6,11 @@ use ArrayIterator; use ArrayObject; +use Flow\ETL\Cardinality; use Flow\ETL\Exception\InferredSchemaException; use Flow\ETL\Exception\InvalidLogicException; use Flow\ETL\Extractor\ArrayExtractor; +use Flow\ETL\Extractor\Statistics; use Flow\ETL\Row\PhpRowHydrator; use Flow\ETL\Schema\Definition; use Flow\ETL\Schema\Definition\UnionDefinition; @@ -400,4 +402,29 @@ public function test_array_extractor_honours_the_batch_contract(): void RowsMother::sequentialIds(5), ); } + + public function test_an_array_dataset_declares_an_exact_row_count(): void + { + static::assertEquals( + new Statistics(rows: Cardinality::exact(3)), + from_array([['id' => 1], ['id' => 2], ['id' => 3]])->statistics(), + ); + } + + public function test_a_generator_dataset_declares_nothing(): void + { + /** @var callable(): Generator> $generator */ + $generator = static function (): Generator { + yield ['id' => 1]; + }; + + static::assertEquals(new Statistics(), from_array($generator())->statistics()); + } + + public function test_statistics_are_computed_at_most_once(): void + { + $extractor = from_array([['id' => 1]]); + + static::assertSame($extractor->statistics(), $extractor->statistics()); + } } diff --git a/src/core/etl/tests/Flow/ETL/Tests/Unit/Extractor/BatchByExtractorTest.php b/src/core/etl/tests/Flow/ETL/Tests/Unit/Extractor/BatchByExtractorTest.php index 37ce2393dc..e927239705 100644 --- a/src/core/etl/tests/Flow/ETL/Tests/Unit/Extractor/BatchByExtractorTest.php +++ b/src/core/etl/tests/Flow/ETL/Tests/Unit/Extractor/BatchByExtractorTest.php @@ -4,14 +4,19 @@ namespace Flow\ETL\Tests\Unit\Extractor; +use Flow\ETL\Cardinality; use Flow\ETL\Exception\InvalidArgumentException; use Flow\ETL\Exception\SchemaMismatchException; +use Flow\ETL\Extractor\Statistics; +use Flow\ETL\Memory\ArrayMemory; +use Flow\ETL\Tests\Double\DeclaringExtractor; use Flow\ETL\Tests\Double\VaryingBatchesExtractor; use PHPUnit\Framework\TestCase; use function Flow\ETL\DSL\batched_by; use function Flow\ETL\DSL\config; use function Flow\ETL\DSL\flow_context; +use function Flow\ETL\DSL\from_memory; use function Flow\ETL\DSL\from_rows; use function Flow\ETL\DSL\int_schema; use function Flow\ETL\DSL\ref; @@ -186,4 +191,17 @@ public function test_is_repeatable(): void batched_by(from_rows(rows(schema(int_schema('id')), row(['id' => 1]))), ref('id'))->isRepeatable(), ); } + + public function test_it_passes_through_the_child_statistics(): void + { + $child = new DeclaringExtractor(new Statistics(Cardinality::exact(3), Cardinality::exact(300))); + $extractor = batched_by($child, 'id'); + + static::assertEquals(new Statistics(Cardinality::exact(3), Cardinality::exact(300)), $extractor->statistics()); + + $child->statistics = new Statistics(Cardinality::exact(1)); + + static::assertEquals(new Statistics(Cardinality::exact(1)), $extractor->statistics()); + static::assertEquals(new Statistics(), batched_by(from_memory(new ArrayMemory()), 'id')->statistics()); + } } diff --git a/src/core/etl/tests/Flow/ETL/Tests/Unit/Extractor/BatchExtractorTest.php b/src/core/etl/tests/Flow/ETL/Tests/Unit/Extractor/BatchExtractorTest.php index e7b5bcd069..5b34d76fd4 100644 --- a/src/core/etl/tests/Flow/ETL/Tests/Unit/Extractor/BatchExtractorTest.php +++ b/src/core/etl/tests/Flow/ETL/Tests/Unit/Extractor/BatchExtractorTest.php @@ -4,9 +4,13 @@ namespace Flow\ETL\Tests\Unit\Extractor; +use Flow\ETL\Cardinality; use Flow\ETL\Exception\InvalidArgumentException; use Flow\ETL\Exception\SchemaMismatchException; use Flow\ETL\Extractor\BatchExtractor; +use Flow\ETL\Extractor\Statistics; +use Flow\ETL\Memory\ArrayMemory; +use Flow\ETL\Tests\Double\DeclaringExtractor; use Flow\ETL\Tests\Double\FakeExtractor; use Flow\ETL\Tests\Double\VaryingBatchesExtractor; use Flow\ETL\Tests\FlowTestCase; @@ -14,6 +18,7 @@ use function Flow\ETL\DSL\batches; use function Flow\ETL\DSL\flow_context; +use function Flow\ETL\DSL\from_memory; use function Flow\ETL\DSL\from_rows; use function Flow\ETL\DSL\int_schema; use function Flow\ETL\DSL\row; @@ -113,4 +118,17 @@ public function test_is_repeatable(): void { static::assertTrue(batches(new FakeExtractor(10), 5)->isRepeatable()); } + + public function test_it_passes_through_the_child_statistics(): void + { + $child = new DeclaringExtractor(new Statistics(Cardinality::exact(3), Cardinality::exact(300))); + $extractor = batches($child, 10); + + static::assertEquals(new Statistics(Cardinality::exact(3), Cardinality::exact(300)), $extractor->statistics()); + + $child->statistics = new Statistics(Cardinality::exact(1)); + + static::assertEquals(new Statistics(Cardinality::exact(1)), $extractor->statistics()); + static::assertEquals(new Statistics(), batches(from_memory(new ArrayMemory()), 10)->statistics()); + } } diff --git a/src/core/etl/tests/Flow/ETL/Tests/Unit/Extractor/ChainExtractorTest.php b/src/core/etl/tests/Flow/ETL/Tests/Unit/Extractor/ChainExtractorTest.php index 57fd54dff3..92768cfdb4 100644 --- a/src/core/etl/tests/Flow/ETL/Tests/Unit/Extractor/ChainExtractorTest.php +++ b/src/core/etl/tests/Flow/ETL/Tests/Unit/Extractor/ChainExtractorTest.php @@ -4,17 +4,23 @@ namespace Flow\ETL\Tests\Unit\Extractor; +use Flow\ETL\Cardinality; use Flow\ETL\Extractor; use Flow\ETL\Extractor\BatchableExtractor; +use Flow\ETL\Extractor\Statistics; use Flow\ETL\FlowContext; +use Flow\ETL\Memory\ArrayMemory; use Flow\ETL\Schema; +use Flow\ETL\Tests\Double\DeclaringExtractor; use Flow\ETL\Tests\FlowTestCase; use Generator; use function Flow\ETL\DSL\df; use function Flow\ETL\DSL\flow_context; use function Flow\ETL\DSL\from_all; +use function Flow\ETL\DSL\from_array; use function Flow\ETL\DSL\from_data_frame; +use function Flow\ETL\DSL\from_memory; use function Flow\ETL\DSL\from_rows; use function Flow\ETL\DSL\int_schema; use function Flow\ETL\DSL\row; @@ -60,6 +66,11 @@ public function extract(FlowContext $context, ?int $limit = null): Generator yield rows(schema(int_schema('id')), row(['id' => 1])); yield rows(schema(int_schema('id')), row(['id' => 2])); } + + public function statistics(): Statistics + { + return new Statistics(); + } }, new class implements Extractor { public function withSchema(Schema $schema): static { @@ -76,6 +87,11 @@ public function extract(FlowContext $context, ?int $limit = null): Generator yield rows(schema(int_schema('id')), row(['id' => 3])); yield rows(schema(int_schema('id')), row(['id' => 4])); } + + public function statistics(): Statistics + { + return new Statistics(); + } }); self::assertExtractedRowsEquals( @@ -106,4 +122,40 @@ public function test_is_repeatable(): void { static::assertTrue(from_all(from_rows(rows(schema(int_schema('id')), row(['id' => 1]))))->isRepeatable()); } + + public function test_it_sums_the_statistics_of_its_children(): void + { + static::assertEquals( + new Statistics(rows: Cardinality::exact(5), size: Cardinality::exact(300)), + from_all( + new DeclaringExtractor(new Statistics(Cardinality::exact(2), Cardinality::exact(100))), + new DeclaringExtractor(new Statistics(Cardinality::exact(3), Cardinality::exact(200))), + )->statistics(), + ); + } + + public function test_one_unknown_child_makes_the_chain_unknown(): void + { + static::assertEquals( + new Statistics(), + from_all(from_array([['id' => 1], ['id' => 2]]), from_memory(new ArrayMemory([['id' => 3]])))->statistics(), + ); + } + + public function test_one_estimated_child_makes_the_chain_estimated(): void + { + $estimated = new DeclaringExtractor(new Statistics(rows: Cardinality::approximately(10, 0.2))); + $chain = from_all(from_array([['id' => 1], ['id' => 2]]), $estimated); + + static::assertEquals(Cardinality::approximately(12, 0.2), $chain->statistics()->rows); + + $estimated->statistics = new Statistics(rows: Cardinality::exact(10)); + + static::assertEquals(Cardinality::exact(12), $chain->statistics()->rows); + } + + public function test_an_empty_chain_declares_zero_rows_and_bytes_exactly(): void + { + static::assertEquals(new Statistics(Cardinality::exact(0), Cardinality::exact(0)), from_all()->statistics()); + } } diff --git a/src/core/etl/tests/Flow/ETL/Tests/Unit/Extractor/CollectingExtractorTest.php b/src/core/etl/tests/Flow/ETL/Tests/Unit/Extractor/CollectingExtractorTest.php index 95077e06e3..8c4877f1fb 100644 --- a/src/core/etl/tests/Flow/ETL/Tests/Unit/Extractor/CollectingExtractorTest.php +++ b/src/core/etl/tests/Flow/ETL/Tests/Unit/Extractor/CollectingExtractorTest.php @@ -4,10 +4,15 @@ namespace Flow\ETL\Tests\Unit\Extractor; +use Flow\ETL\Cardinality; use Flow\ETL\Extractor\CollectingExtractor; +use Flow\ETL\Extractor\Statistics; +use Flow\ETL\Memory\ArrayMemory; +use Flow\ETL\Tests\Double\DeclaringExtractor; use Flow\ETL\Tests\FlowTestCase; use function Flow\ETL\DSL\flow_context; +use function Flow\ETL\DSL\from_memory; use function Flow\ETL\DSL\from_rows; use function Flow\ETL\DSL\int_schema; use function Flow\ETL\DSL\row; @@ -55,4 +60,17 @@ public function test_is_repeatable(): void (new CollectingExtractor(from_rows(rows(schema(int_schema('id')), row(['id' => 1])))))->isRepeatable(), ); } + + public function test_it_passes_through_the_child_statistics(): void + { + $child = new DeclaringExtractor(new Statistics(Cardinality::exact(3), Cardinality::exact(300))); + $extractor = new CollectingExtractor($child); + + static::assertEquals(new Statistics(Cardinality::exact(3), Cardinality::exact(300)), $extractor->statistics()); + + $child->statistics = new Statistics(Cardinality::exact(1)); + + static::assertEquals(new Statistics(Cardinality::exact(1)), $extractor->statistics()); + static::assertEquals(new Statistics(), (new CollectingExtractor(from_memory(new ArrayMemory())))->statistics()); + } } diff --git a/src/core/etl/tests/Flow/ETL/Tests/Unit/Extractor/FilesExtractorTest.php b/src/core/etl/tests/Flow/ETL/Tests/Unit/Extractor/FilesExtractorTest.php index 6ab02bd371..85c6c045b6 100644 --- a/src/core/etl/tests/Flow/ETL/Tests/Unit/Extractor/FilesExtractorTest.php +++ b/src/core/etl/tests/Flow/ETL/Tests/Unit/Extractor/FilesExtractorTest.php @@ -4,10 +4,14 @@ namespace Flow\ETL\Tests\Unit\Extractor; +use Flow\ETL\Cardinality; use Flow\ETL\Extractor\FilesExtractor; use Flow\ETL\Extractor\Signal; use Flow\ETL\Schema; use Flow\ETL\Tests\Context\ExtractedRows; +use Flow\ETL\Tests\Context\MemoryFiles; +use Flow\ETL\Tests\Double\CountingFilesystem; +use Flow\ETL\Tests\Double\UnsizedFilesystem; use Flow\ETL\Tests\FlowTestCase; use function Flow\ETL\DSL\files; @@ -18,6 +22,48 @@ final class FilesExtractorTest extends FlowTestCase { + public function test_it_declares_one_row_per_listed_file_and_their_bytes_exactly(): void + { + $statistics = files('memory://dir/*', MemoryFiles::with([ + 'memory://dir/a.txt' => 'abcdefghij', + 'memory://dir/b.txt' => 'abcde', + ]))->statistics(); + + static::assertEquals(Cardinality::exact(2), $statistics->rows); + static::assertEquals(Cardinality::exact(15), $statistics->size); + } + + public function test_an_empty_listing_declares_zero_rows_and_zero_bytes(): void + { + $statistics = files('memory://dir/*', MemoryFiles::with([]))->statistics(); + + static::assertEquals(Cardinality::exact(0), $statistics->rows); + static::assertEquals(Cardinality::exact(0), $statistics->size); + } + + public function test_a_member_without_a_size_makes_the_size_unknown(): void + { + $statistics = files( + 'memory://dir/*', + new UnsizedFilesystem(MemoryFiles::with([ + 'memory://dir/a.txt' => 'abcdefghij', + 'memory://dir/b.txt' => 'abcde', + ]), ['memory://dir/b.txt']), + )->statistics(); + + static::assertEquals(Cardinality::exact(2), $statistics->rows); + static::assertEquals(Cardinality::unknown(), $statistics->size); + } + + public function test_the_listing_is_read_once(): void + { + $filesystem = new CountingFilesystem(MemoryFiles::with(['memory://dir/a.txt' => 'abc'])); + $extractor = files('memory://dir/*', $filesystem); + + static::assertSame($extractor->statistics(), $extractor->statistics()); + static::assertSame(1, $filesystem->listCalls); + } + public function test_a_zero_string_extension_is_not_null(): void { $batches = iterator_to_array(files(__DIR__ . '/Fixtures/ZeroExtension/*')->extract(flow_context())); diff --git a/src/core/etl/tests/Flow/ETL/Tests/Unit/Extractor/FooterStatisticsTest.php b/src/core/etl/tests/Flow/ETL/Tests/Unit/Extractor/FooterStatisticsTest.php new file mode 100644 index 0000000000..c8bbc86615 --- /dev/null +++ b/src/core/etl/tests/Flow/ETL/Tests/Unit/Extractor/FooterStatisticsTest.php @@ -0,0 +1,55 @@ +of(3, 0), + ); + } + + public function test_the_first_footer_is_scaled_to_the_listing(): void + { + static::assertEquals( + new Statistics(rows: Cardinality::approximately(40), size: Cardinality::approximately(400)), + (new FooterStatistics(new Statistics(Cardinality::exact(10), Cardinality::exact(100)), 1))->of(4, 0), + ); + } + + public function test_the_offset_is_taken_off_the_rows_never_below_zero(): void + { + $footers = new FooterStatistics(new Statistics(Cardinality::exact(10), Cardinality::exact(100)), 1); + + static::assertEquals(Cardinality::exact(7), $footers->of(1, 3)->rows); + static::assertEquals(Cardinality::exact(0), $footers->of(1, 30)->rows); + static::assertEquals(Cardinality::approximately(37), $footers->of(4, 3)->rows); + } + + public function test_no_footer_read_is_zero_exactly(): void + { + static::assertEquals( + new Statistics(rows: Cardinality::exact(0), size: Cardinality::exact(0)), + (new FooterStatistics(new Statistics(Cardinality::exact(0), Cardinality::exact(0)), 0))->of(0, 0), + ); + } + + public function test_a_negative_footer_count_is_refused(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Read footer count must not be negative, given: -1'); + + new FooterStatistics(new Statistics(), -1); + } +} diff --git a/src/core/etl/tests/Flow/ETL/Tests/Unit/Extractor/ListedFilesTest.php b/src/core/etl/tests/Flow/ETL/Tests/Unit/Extractor/ListedFilesTest.php new file mode 100644 index 0000000000..74e434f185 --- /dev/null +++ b/src/core/etl/tests/Flow/ETL/Tests/Unit/Extractor/ListedFilesTest.php @@ -0,0 +1,86 @@ +status(path(__DIR__ . '/../../Fixtures/empty.txt')); + + static::assertNotNull($empty); + static::assertEquals( + Cardinality::unknown(), + ListedFiles::of([ + new SourceFile(path('memory://orders/a.csv'), 100), + new SourceFile($empty->path, $empty->size), + ])->bytes, + ); + } + + public function test_an_empty_listing_is_zero_bytes_exactly(): void + { + static::assertEquals(Cardinality::exact(0), ListedFiles::of([])->bytes); + } + + public function test_it_sums_the_listed_sizes(): void + { + static::assertEquals( + Cardinality::exact(300), + ListedFiles::of([ + new SourceFile(path('memory://orders/a.csv'), 100), + new SourceFile(path('memory://orders/b.csv'), 200), + ])->bytes, + ); + } + + public function test_one_member_without_a_size_makes_the_total_unknown(): void + { + static::assertEquals( + Cardinality::unknown(), + ListedFiles::of([ + new SourceFile(path('memory://orders/a.csv')), + new SourceFile(path('memory://orders/b.csv'), 200), + ])->bytes, + ); + } + + public function test_it_counts_every_listed_file_sized_or_not(): void + { + static::assertSame( + 2, + ListedFiles::of([ + new SourceFile(path('memory://orders/a.csv')), + new SourceFile(path('memory://orders/b.csv'), 200), + ])->count, + ); + } + + public function test_it_reads_file_statuses_as_well(): void + { + $listed = ListedFiles::of([new FileStatus(path('memory://orders/a.csv'), true, 100)]); + + static::assertSame(1, $listed->count); + static::assertEquals(Cardinality::exact(100), $listed->bytes); + } + + public function test_a_negative_count_is_refused(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Listed file count must not be negative, given: -1'); + + new ListedFiles(-1, Cardinality::unknown()); + } +} diff --git a/src/core/etl/tests/Flow/ETL/Tests/Unit/Extractor/MemoryExtractorTest.php b/src/core/etl/tests/Flow/ETL/Tests/Unit/Extractor/MemoryExtractorTest.php index cc61b7d74f..c8300a73a2 100644 --- a/src/core/etl/tests/Flow/ETL/Tests/Unit/Extractor/MemoryExtractorTest.php +++ b/src/core/etl/tests/Flow/ETL/Tests/Unit/Extractor/MemoryExtractorTest.php @@ -6,6 +6,7 @@ use Flow\ETL\Exception\InferredSchemaException; use Flow\ETL\Extractor\MemoryExtractor; +use Flow\ETL\Extractor\Statistics; use Flow\ETL\Memory\ArrayMemory; use Flow\ETL\Tests\Double\CountingMemory; use Flow\ETL\Tests\FlowTestCase; @@ -184,4 +185,9 @@ public function test_memory_extractor_honours_the_batch_contract(): void RowsMother::sequentialIds(5), ); } + + public function test_it_declares_nothing_because_memory_can_grow_mid_run(): void + { + static::assertEquals(new Statistics(), from_memory(new ArrayMemory())->statistics()); + } } diff --git a/src/core/etl/tests/Flow/ETL/Tests/Unit/Extractor/RowsExtractorTest.php b/src/core/etl/tests/Flow/ETL/Tests/Unit/Extractor/RowsExtractorTest.php index 6b83eb311b..dad9e64077 100644 --- a/src/core/etl/tests/Flow/ETL/Tests/Unit/Extractor/RowsExtractorTest.php +++ b/src/core/etl/tests/Flow/ETL/Tests/Unit/Extractor/RowsExtractorTest.php @@ -4,6 +4,8 @@ namespace Flow\ETL\Tests\Unit\Extractor; +use Flow\ETL\Cardinality; +use Flow\ETL\Extractor\Statistics; use Flow\ETL\Tests\FlowTestCase; use function Flow\ETL\DSL\flow_context; @@ -57,4 +59,15 @@ public function test_is_repeatable(): void { static::assertTrue(from_rows(rows(schema(int_schema('number')), row(['number' => 1])))->isRepeatable()); } + + public function test_it_declares_an_exact_row_count(): void + { + $extractor = from_rows( + rows(schema(int_schema('number')), row(['number' => 1]), row(['number' => 2])), + rows(schema(int_schema('number')), row(['number' => 3])), + ); + + static::assertEquals(new Statistics(rows: Cardinality::exact(3)), $extractor->statistics()); + static::assertSame($extractor->statistics(), $extractor->statistics()); + } } diff --git a/src/core/etl/tests/Flow/ETL/Tests/Unit/Extractor/SequenceExtractorTest.php b/src/core/etl/tests/Flow/ETL/Tests/Unit/Extractor/SequenceExtractorTest.php index 844a5ae33d..9ae9fb4bc6 100644 --- a/src/core/etl/tests/Flow/ETL/Tests/Unit/Extractor/SequenceExtractorTest.php +++ b/src/core/etl/tests/Flow/ETL/Tests/Unit/Extractor/SequenceExtractorTest.php @@ -7,6 +7,7 @@ use DateInterval; use DatePeriod; use DateTimeImmutable; +use Flow\ETL\Cardinality; use Flow\ETL\Exception\InferredSchemaException; use Flow\ETL\Extractor\SequenceExtractor; use Flow\ETL\Tests\Double\MixedSequenceGenerator; @@ -156,6 +157,24 @@ public function test_infer_schema_resets_the_memo(): void static::assertSame('integer', $extractor->schema()->get('code')->type()->toString()); } + public function test_statistics_declare_the_generator_rows_and_no_size(): void + { + $statistics = from_sequence_number('num', 1, 10, 2)->statistics(); + + static::assertEquals(Cardinality::exact(5), $statistics->rows); + static::assertEquals(Cardinality::unknown(), $statistics->size); + } + + public function test_statistics_ask_the_generator_once(): void + { + $generator = new RecordingSequenceGenerator(new MixedSequenceGenerator()); + $extractor = new SequenceExtractor($generator, 'code'); + + static::assertSame($extractor->statistics(), $extractor->statistics()); + static::assertSame(1, $generator->rowsCalls); + static::assertSame(0, $generator->generateCalls); + } + public function test_schema_is_memoised(): void { $extractor = new SequenceExtractor(new MixedSequenceGenerator(), 'code'); diff --git a/src/core/etl/tests/Flow/ETL/Tests/Unit/Extractor/SequenceGenerator/DatePeriodSequenceGeneratorTest.php b/src/core/etl/tests/Flow/ETL/Tests/Unit/Extractor/SequenceGenerator/DatePeriodSequenceGeneratorTest.php new file mode 100644 index 0000000000..7bf6e6935a --- /dev/null +++ b/src/core/etl/tests/Flow/ETL/Tests/Unit/Extractor/SequenceGenerator/DatePeriodSequenceGeneratorTest.php @@ -0,0 +1,83 @@ +rows()); + } + + public function test_excluding_the_start_date_is_counted(): void + { + $generator = new DatePeriodSequenceGenerator( + new DatePeriod( + new DateTimeImmutable('2023-01-01'), + new DateInterval('P1D'), + new DateTimeImmutable('2023-01-11'), + DatePeriod::EXCLUDE_START_DATE, + ), + ); + + static::assertEquals(Cardinality::exact(9), $generator->rows()); + static::assertSame(9, iterator_count($generator->generate())); + } + + public function test_including_the_end_date_is_counted(): void + { + $generator = new DatePeriodSequenceGenerator( + new DatePeriod( + new DateTimeImmutable('2023-01-01'), + new DateInterval('P1D'), + new DateTimeImmutable('2023-01-11'), + DatePeriod::INCLUDE_END_DATE, + ), + ); + + static::assertEquals(Cardinality::exact(11), $generator->rows()); + static::assertSame(11, iterator_count($generator->generate())); + } + + public function test_recurrences_are_counted(): void + { + $generator = new DatePeriodSequenceGenerator( + new DatePeriod(new DateTimeImmutable('2023-01-01'), new DateInterval('P1D'), 5), + ); + + static::assertEquals(Cardinality::exact(6), $generator->rows()); + static::assertSame(6, iterator_count($generator->generate())); + } + + public function test_variable_length_months_are_counted(): void + { + $generator = new DatePeriodSequenceGenerator( + new DatePeriod( + new DateTimeImmutable('2023-01-31'), + new DateInterval('P1M'), + new DateTimeImmutable('2023-12-31'), + ), + ); + + static::assertEquals(Cardinality::exact(iterator_count($generator->generate())), $generator->rows()); + } +} diff --git a/src/core/etl/tests/Flow/ETL/Tests/Unit/Extractor/SequenceGenerator/NumberSequenceGeneratorTest.php b/src/core/etl/tests/Flow/ETL/Tests/Unit/Extractor/SequenceGenerator/NumberSequenceGeneratorTest.php new file mode 100644 index 0000000000..8be11690e0 --- /dev/null +++ b/src/core/etl/tests/Flow/ETL/Tests/Unit/Extractor/SequenceGenerator/NumberSequenceGeneratorTest.php @@ -0,0 +1,52 @@ +rows()); + static::assertSame($expected, iterator_count($generator->generate())); + } + + #[TestWith([1, 10, 0])] + #[TestWith([1, 3, 5])] + #[TestWith([1, 10, -2])] + public function test_a_step_the_range_rejects_is_rejected_the_same_way(int $start, int $end, int $step): void + { + $this->expectException(ValueError::class); + + (new NumberSequenceGenerator($start, $end, $step))->rows(); + } +} diff --git a/src/core/etl/tests/Flow/ETL/Tests/Unit/Extractor/SourceFileTest.php b/src/core/etl/tests/Flow/ETL/Tests/Unit/Extractor/SourceFileTest.php index d09c2e9dba..a87045a380 100644 --- a/src/core/etl/tests/Flow/ETL/Tests/Unit/Extractor/SourceFileTest.php +++ b/src/core/etl/tests/Flow/ETL/Tests/Unit/Extractor/SourceFileTest.php @@ -31,4 +31,14 @@ public function test_uri_is_the_paths_uri(): void (new SourceFile(path('memory://orders/year=2024/data.csv')))->uri(), ); } + + public function test_it_carries_the_listed_size(): void + { + static::assertSame(1024, (new SourceFile(path('memory://orders/data.csv'), 1024))->size); + } + + public function test_a_size_may_be_absent(): void + { + static::assertNull((new SourceFile(path('memory://orders/data.csv')))->size); + } } diff --git a/src/core/etl/tests/Flow/ETL/Tests/Unit/Extractor/StatisticsTest.php b/src/core/etl/tests/Flow/ETL/Tests/Unit/Extractor/StatisticsTest.php new file mode 100644 index 0000000000..1f82a4b5a1 --- /dev/null +++ b/src/core/etl/tests/Flow/ETL/Tests/Unit/Extractor/StatisticsTest.php @@ -0,0 +1,46 @@ +rows); + static::assertEquals(Cardinality::unknown(), $statistics->size); + } + + public function test_merge_adds_rows_and_size_independently(): void + { + static::assertEquals( + new Statistics(Cardinality::exact(30), Cardinality::approximately(300)), + (new Statistics(Cardinality::exact(10), Cardinality::approximately(100)))->merge( + new Statistics(Cardinality::exact(20), Cardinality::exact(200)), + ), + ); + } + + public function test_merging_with_an_unknown_partial_yields_unknown(): void + { + static::assertEquals( + new Statistics(), + (new Statistics(Cardinality::exact(10), Cardinality::exact(100)))->merge(new Statistics()), + ); + } + + public function test_rows_and_size_are_declared_independently(): void + { + $statistics = new Statistics(size: Cardinality::exact(1024)); + + static::assertEquals(Cardinality::unknown(), $statistics->rows); + static::assertEquals(Cardinality::exact(1024), $statistics->size); + } +} diff --git a/src/core/etl/tests/Flow/ETL/Tests/Unit/Processor/CachingProcessorTest.php b/src/core/etl/tests/Flow/ETL/Tests/Unit/Processor/CachingProcessorTest.php index f0b61087f3..9208c0412d 100644 --- a/src/core/etl/tests/Flow/ETL/Tests/Unit/Processor/CachingProcessorTest.php +++ b/src/core/etl/tests/Flow/ETL/Tests/Unit/Processor/CachingProcessorTest.php @@ -6,6 +6,7 @@ use Flow\ETL\Cache\CacheIndex; use Flow\ETL\Cache\Implementation\InMemoryCache; +use Flow\ETL\Cardinality; use Flow\ETL\Extractor\Signal; use Flow\ETL\Processor\CachingProcessor; use Flow\ETL\Rows; @@ -43,6 +44,7 @@ public function test_a_stop_still_completes_the_cache(): void static::assertInstanceOf(Rows::class, $indexRows); static::assertCount(5, CacheIndex::fromRows('stop-cache', $indexRows)->values()); + static::assertEquals(Cardinality::exact(5), CacheIndex::fromRows('stop-cache', $indexRows)->rows()); } public function test_bind_returns_the_input_schema(): void diff --git a/src/core/etl/tests/Flow/Floe/Tests/Context/FloeStreamReaderContext.php b/src/core/etl/tests/Flow/Floe/Tests/Context/FloeStreamReaderContext.php index bb2c65fc07..77e61f9e34 100644 --- a/src/core/etl/tests/Flow/Floe/Tests/Context/FloeStreamReaderContext.php +++ b/src/core/etl/tests/Flow/Floe/Tests/Context/FloeStreamReaderContext.php @@ -8,9 +8,11 @@ use Flow\ETL\Schema\Metadata; use Flow\Filesystem\Filesystem; use Flow\Filesystem\Path; +use Flow\Floe\Codec\NoopCodec; use Flow\Floe\FloeReader; use Flow\Floe\FloeWriter; use Flow\Floe\Footer; +use Flow\Floe\FooterReader; use Flow\Floe\Format; use Flow\Floe\FrameReader; @@ -38,6 +40,17 @@ public static function footer(Filesystem $filesystem, Path $path): Footer return Footer::fromJson($source->read($footerLength, $size - Format::TRAILER_LENGTH - $footerLength)); } + /** + * The bytes between the header and the FOOTER frame - what the footer's byteSize has to report. + */ + public static function dataBytes(Filesystem $filesystem, Path $path): int + { + return ( + (new FooterReader())->read($filesystem->readFrom($path), new NoopCodec())->footerFrameStart + - Format::HEADER_LENGTH + ); + } + /** * @return array frame type and body, in file order */ diff --git a/src/core/etl/tests/Flow/Floe/Tests/Fixtures/pre-statistics/heterogeneous.floe b/src/core/etl/tests/Flow/Floe/Tests/Fixtures/pre-statistics/heterogeneous.floe new file mode 100644 index 0000000000000000000000000000000000000000..fb2acd05e9b1083150729895e52c02710312f6a8 GIT binary patch literal 418 zcmaixL2Cjr5QVeb(zDQiFz1|#l#+`=sVAY}wRnizi7v)OlG(1?CI8!_v+fowBIb}x z-n{wXCA0bGM=tV_5F*QX>(;qA3}W7Ez1WESxidTrq8LVwlb-N+Y5o;YQ|`C$gCUmQ zfqIcphmtUWg34{3uN_E;#Rhj8)K?1u4YxFl)a(&dJDgBD!Zpt&39hnMuPk?swh<-l zko32vj<8Jf53;(3L<*%_|7TFBZX$|ngt1=JUBZ$>VL_ZZo@04qA`+-q3BlLzzH)pS YC$^C%Z5O^~-}o{~o6;M<-Hdry+wM%N+w43&8wK6b19ePmn7x{<(C&LSsCgm<))S-rX-dmDp^(6*8b;WU~u#C GcLe~|HW7IM diff --git a/src/core/etl/tests/Flow/Floe/Tests/Fixtures/v2/empty.floe b/src/core/etl/tests/Flow/Floe/Tests/Fixtures/v2/empty.floe index b49a2b92a27aeab40b60c2a0ab78e3efb2ec9494..433f9706c9d8dc3a839a983aab4130c726de880c 100644 GIT binary patch delta 74 zcmd07VB~P~@polnU@M)-VP{obl30>iT#}hwtYlTKRFq#1qz!bGk}6A5gEOmAft*?$ XrQFn##FWI6M4*z|+FS+(29OB=PZ<{5 delta 51 zcmZo*%;a$M@polnV2hi`VW(V@Uy_&;lwV$~WM!bEl$%e^ZhMg|5qAAeT> DgW?Ya diff --git a/src/core/etl/tests/Flow/Floe/Tests/Integration/FloeExtractorTest.php b/src/core/etl/tests/Flow/Floe/Tests/Integration/FloeExtractorTest.php new file mode 100644 index 0000000000..f297fa9c85 --- /dev/null +++ b/src/core/etl/tests/Flow/Floe/Tests/Integration/FloeExtractorTest.php @@ -0,0 +1,152 @@ +fs(), + $this->cacheDir->suffix('glob/a.floe'), + rows(schema(int_schema('id')), row(['id' => 1]), row(['id' => 2])), + ); + FloeStreamReaderContext::write( + $this->fs(), + $this->cacheDir->suffix('glob/b.floe'), + rows(schema(int_schema('id')), row(['id' => 3])), + ); + $firstBytes = FloeStreamReaderContext::dataBytes($this->fs(), $this->cacheDir->suffix('glob/a.floe')); + + $statistics = from_floe($this->cacheDir->suffix('glob/*.floe'), filesystem: $this->fs())->statistics(); + + static::assertEquals(Cardinality::approximately(4, Cardinality::DEFAULT_RELATIVE_ERROR), $statistics->rows); + static::assertEquals( + Cardinality::approximately(2 * $firstBytes, Cardinality::DEFAULT_RELATIVE_ERROR), + $statistics->size, + ); + } + + public function test_a_single_file_declares_exact_rows_and_byte_size(): void + { + $path = $this->cacheDir->suffix('single.floe'); + FloeStreamReaderContext::write( + $this->fs(), + $path, + rows(schema(int_schema('id')), row(['id' => 1]), row(['id' => 2])), + ); + + $statistics = from_floe($path, filesystem: $this->fs())->statistics(); + + static::assertEquals(Cardinality::exact(2), $statistics->rows); + static::assertEquals( + Cardinality::exact(FloeStreamReaderContext::dataBytes($this->fs(), $path)), + $statistics->size, + ); + } + + public function test_an_offset_is_subtracted_from_the_row_count(): void + { + $path = $this->cacheDir->suffix('offset.floe'); + FloeStreamReaderContext::write( + $this->fs(), + $path, + rows(schema(int_schema('id')), row(['id' => 1]), row(['id' => 2])), + ); + + $extractor = from_floe($path, filesystem: $this->fs()); + + static::assertEquals(Cardinality::exact(2), $extractor->statistics()->rows); + static::assertEquals(Cardinality::exact(1), $extractor->withOffset(1)->statistics()->rows); + static::assertEquals(Cardinality::exact(0), $extractor->withOffset(10)->statistics()->rows); + } + + public function test_statistics_are_computed_at_most_once(): void + { + $path = $this->cacheDir->suffix('memo.floe'); + FloeStreamReaderContext::write($this->fs(), $path, rows(schema(int_schema('id')), row(['id' => 1]))); + $filesystem = new CountingFilesystem($this->fs()); + $extractor = from_floe($path, filesystem: $filesystem); + + static::assertSame($extractor->statistics(), $extractor->statistics()); + static::assertSame(1, $filesystem->readFromCalls); + static::assertSame(1, $filesystem->closedStreams()); + } + + public function test_schema_then_statistics_reads_the_footer_once(): void + { + $path = $this->cacheDir->suffix('schema-first.floe'); + FloeStreamReaderContext::write($this->fs(), $path, rows(schema(int_schema('id')), row(['id' => 1]))); + $filesystem = new CountingFilesystem($this->fs()); + $extractor = from_floe($path, filesystem: $filesystem); + + $extractor->schema(); + + static::assertEquals(Cardinality::exact(1), $extractor->statistics()->rows); + static::assertSame(1, $filesystem->readFromCalls); + } + + public function test_a_declared_schema_still_reads_the_footer_for_statistics(): void + { + $path = $this->cacheDir->suffix('declared.floe'); + FloeStreamReaderContext::write($this->fs(), $path, rows(schema(int_schema('id')), row(['id' => 1]))); + + static::assertEquals( + Cardinality::exact(1), + from_floe($path, filesystem: $this->fs())->withSchema(schema(int_schema('id')))->statistics()->rows, + ); + } + + public function test_union_by_name_sums_every_footer_exactly(): void + { + FloeStreamReaderContext::write( + $this->fs(), + $this->cacheDir->suffix('union/a.floe'), + rows(schema(int_schema('id')), row(['id' => 1]), row(['id' => 2])), + ); + FloeStreamReaderContext::write( + $this->fs(), + $this->cacheDir->suffix('union/b.floe'), + rows(schema(int_schema('id')), row(['id' => 3])), + ); + + $statistics = from_floe($this->cacheDir->suffix('union/*.floe'), filesystem: $this->fs()) + ->unionByName() + ->statistics(); + + static::assertEquals(Cardinality::exact(3), $statistics->rows); + static::assertEquals( + Cardinality::exact( + FloeStreamReaderContext::dataBytes($this->fs(), $this->cacheDir->suffix('union/a.floe')) + + FloeStreamReaderContext::dataBytes($this->fs(), $this->cacheDir->suffix('union/b.floe')), + ), + $statistics->size, + ); + } + + public function test_union_by_name_invalidates_the_memo(): void + { + $path = $this->cacheDir->suffix('union-memo.floe'); + FloeStreamReaderContext::write($this->fs(), $path, rows(schema(int_schema('id')), row(['id' => 1]))); + $filesystem = new CountingFilesystem($this->fs()); + $extractor = from_floe($path, filesystem: $filesystem); + + $extractor->statistics(); + $extractor->unionByName()->statistics(); + + static::assertSame(2, $filesystem->readFromCalls); + } +} diff --git a/src/core/etl/tests/Flow/Floe/Tests/Integration/FloeHardBreakTest.php b/src/core/etl/tests/Flow/Floe/Tests/Integration/FloeHardBreakTest.php new file mode 100644 index 0000000000..3bdef57169 --- /dev/null +++ b/src/core/etl/tests/Flow/Floe/Tests/Integration/FloeHardBreakTest.php @@ -0,0 +1,28 @@ +expectException(FloeException::class); + $this->expectExceptionMessageMatches('/Floe footer is malformed: .*statistics/'); + + FloeEngineContext::phpReader($this->fs()) + ->read(path(__DIR__ . '/../Fixtures/pre-statistics/heterogeneous.floe')) + ->schema(); + } +} diff --git a/src/core/etl/tests/Flow/Floe/Tests/Integration/FloeStatisticsTest.php b/src/core/etl/tests/Flow/Floe/Tests/Integration/FloeStatisticsTest.php new file mode 100644 index 0000000000..a7c0cbeedb --- /dev/null +++ b/src/core/etl/tests/Flow/Floe/Tests/Integration/FloeStatisticsTest.php @@ -0,0 +1,130 @@ +cacheDir->suffix('compact-a.floe'); + $b = $this->cacheDir->suffix('compact-b.floe'); + $spliced = $this->cacheDir->suffix('compact-spliced.floe'); + $compacted = $this->cacheDir->suffix('compact-compacted.floe'); + FloeStreamReaderContext::write( + $this->fs(), + $a, + rows(schema(int_schema('id')), row(['id' => 1]), row(['id' => 2])), + ); + FloeStreamReaderContext::write($this->fs(), $b, rows(schema(int_schema('id')), row(['id' => 3]))); + + merge_floe([$a, $b], $spliced); + merge_floe([$a, $b], $compacted, compact: true); + + static::assertSame(3, FloeStreamReaderContext::footer($this->fs(), $spliced)->statistics->rows); + static::assertSame(3, FloeStreamReaderContext::footer($this->fs(), $compacted)->statistics->rows); + static::assertSame( + FloeStreamReaderContext::dataBytes($this->fs(), $compacted), + FloeStreamReaderContext::footer($this->fs(), $compacted)->statistics->byteSize, + ); + } + + public function test_a_merged_file_reports_the_output_byte_size_not_the_inputs(): void + { + $a = $this->cacheDir->suffix('splice-a.floe'); + $b = $this->cacheDir->suffix('splice-b.floe'); + $out = $this->cacheDir->suffix('splice-out.floe'); + FloeStreamReaderContext::write($this->fs(), $a, rows(schema(str_schema('name')), row(['name' => 'first']))); + FloeStreamReaderContext::write($this->fs(), $b, rows(schema(str_schema('name')), row(['name' => 'second']))); + + merge_floe([$a, $b], $out); + + $statistics = FloeStreamReaderContext::footer($this->fs(), $out)->statistics; + + static::assertSame(FloeStreamReaderContext::dataBytes($this->fs(), $out), $statistics->byteSize); + static::assertSame( + FloeStreamReaderContext::footer($this->fs(), $a)->statistics->byteSize + + FloeStreamReaderContext::footer($this->fs(), $b)->statistics->byteSize, + $statistics->byteSize, + ); + static::assertLessThan( + (int) $this->fs()->readFrom($a)->size() + (int) $this->fs()->readFrom($b)->size(), + $statistics->byteSize, + ); + } + + public function test_a_multi_section_file_sums_its_sections(): void + { + $path = $this->cacheDir->suffix('sections.floe'); + FloeStreamReaderContext::write($this->fs(), $path, rows(schema(int_schema('id')), row(['id' => 1]))); + + foreach ([[2, 3], [4, 5, 6]] as $ids) { + $writer = new FloeWriter($this->fs(), schema(int_schema('id'))); + $writer->append($path); + + foreach ($ids as $id) { + $writer->write(rows(schema(int_schema('id')), row(['id' => $id]))); + } + + $writer->close(); + } + + $footer = FloeStreamReaderContext::footer($this->fs(), $path); + $sectionRows = 0; + + foreach ($footer->sections as $section) { + $sectionRows += $section->rowCount; + } + + static::assertCount(3, $footer->sections); + static::assertSame(6, $sectionRows); + static::assertSame(6, $footer->statistics->rows); + static::assertSame(FloeStreamReaderContext::dataBytes($this->fs(), $path), $footer->statistics->byteSize); + } + + public function test_a_written_file_reports_its_rows_and_byte_size(): void + { + $path = $this->cacheDir->suffix('written.floe'); + FloeStreamReaderContext::write( + $this->fs(), + $path, + rows(schema(int_schema('id')), row(['id' => 1]), row(['id' => 2])), + ); + + $statistics = FloeStreamReaderContext::footer($this->fs(), $path)->statistics; + + static::assertSame(2, $statistics->rows); + static::assertSame(FloeStreamReaderContext::dataBytes($this->fs(), $path), $statistics->byteSize); + static::assertGreaterThan(0, $statistics->byteSize); + } + + public function test_an_appended_file_reports_the_combined_totals(): void + { + $path = $this->cacheDir->suffix('appended.floe'); + FloeStreamReaderContext::write($this->fs(), $path, rows(schema(int_schema('id')), row(['id' => 1]))); + $before = FloeStreamReaderContext::footer($this->fs(), $path)->statistics->byteSize; + + $writer = new FloeWriter($this->fs(), schema(int_schema('id'))); + $writer->append($path); + $writer->write(rows(schema(int_schema('id')), row(['id' => 2]), row(['id' => 3]))); + $writer->close(); + + $statistics = FloeStreamReaderContext::footer($this->fs(), $path)->statistics; + + static::assertSame(3, $statistics->rows); + static::assertSame(FloeStreamReaderContext::dataBytes($this->fs(), $path), $statistics->byteSize); + static::assertGreaterThan($before, $statistics->byteSize); + } +} diff --git a/src/core/etl/tests/Flow/Floe/Tests/Mother/FooterMother.php b/src/core/etl/tests/Flow/Floe/Tests/Mother/FooterMother.php index 00b89b6359..a693414734 100644 --- a/src/core/etl/tests/Flow/Floe/Tests/Mother/FooterMother.php +++ b/src/core/etl/tests/Flow/Floe/Tests/Mother/FooterMother.php @@ -6,6 +6,7 @@ use Flow\ETL\Schema\Metadata; use Flow\Floe\Footer; +use Flow\Floe\Statistics; final class FooterMother { @@ -17,10 +18,18 @@ final class FooterMother public static function footer( array $schema = [], array $sections = [], - int $totalRows = 0, + int $rows = 0, + int $byteSize = 0, array $metadata = [], ): Footer { /** @var array> $schema */ - return new Footer(1, 'test-writer', $schema, $sections, $totalRows, Metadata::fromArray($metadata)); + return new Footer( + 1, + 'test-writer', + $schema, + $sections, + new Statistics($rows, $byteSize), + Metadata::fromArray($metadata), + ); } } diff --git a/src/core/etl/tests/Flow/Floe/Tests/Unit/FloeSerializerTest.php b/src/core/etl/tests/Flow/Floe/Tests/Unit/FloeSerializerTest.php index 76fe9e00f9..04da98eedc 100644 --- a/src/core/etl/tests/Flow/Floe/Tests/Unit/FloeSerializerTest.php +++ b/src/core/etl/tests/Flow/Floe/Tests/Unit/FloeSerializerTest.php @@ -177,10 +177,10 @@ public function test_unserialize_rejects_empty_payload(): void public function test_unserialize_rejects_row_count_mismatch(): void { $bytes = serialize_to_string(new FloeSerializer(), rows(schema(int_schema('id')), row(['id' => 1]))); - // inflate the footer's totalRows while the body still holds a single row; the JSON + // inflate the footer's row count while the body still holds a single row; the JSON // byte-length is unchanged (1 -> 2) so the trailer stays valid and the read reaches // the whole-value row-count guard - $corrupted = str_replace('"totalRows":1', '"totalRows":2', $bytes); + $corrupted = str_replace('"statistics":{"rows":1', '"statistics":{"rows":2', $bytes); $this->expectException(SerializationException::class); $this->expectExceptionMessage('decoded 1 of 2 rows'); diff --git a/src/core/etl/tests/Flow/Floe/Tests/Unit/FloeStreamWriterTest.php b/src/core/etl/tests/Flow/Floe/Tests/Unit/FloeStreamWriterTest.php index b3c9c8082b..5b5f9eee36 100644 --- a/src/core/etl/tests/Flow/Floe/Tests/Unit/FloeStreamWriterTest.php +++ b/src/core/etl/tests/Flow/Floe/Tests/Unit/FloeStreamWriterTest.php @@ -91,7 +91,7 @@ public function test_create_on_stream_round_trips(): void $footer = FloeStreamReaderContext::footer($filesystem, $path); - static::assertSame(3, $footer->totalRows); + static::assertSame(3, $footer->statistics->rows); static::assertSame(['source' => 'stream'], $footer->metadata->normalize()); static::assertSame( [Format::FRAME_ROW, Format::FRAME_ROW, Format::FRAME_ROW, Format::FRAME_FOOTER], @@ -173,7 +173,7 @@ public function test_batch_omitting_a_nullable_session_column_is_accepted_and_ro $writer->close(); $footer = FloeStreamReaderContext::footer($filesystem, $path); - static::assertSame(2, $footer->totalRows); + static::assertSame(2, $footer->statistics->rows); static::assertNotSame([], $footer->schema); static::assertCount(2, FloeStreamReaderContext::readAll($filesystem, $path)->all()); } @@ -262,7 +262,7 @@ public function test_empty_batch_writes_no_schema_or_section(): void static::assertSame([Format::FRAME_FOOTER], FloeStreamReaderContext::frameTypes($filesystem, $path)); $footer = FloeStreamReaderContext::footer($filesystem, $path); - static::assertSame(0, $footer->totalRows); + static::assertSame(0, $footer->statistics->rows); static::assertSame([], $footer->schema); static::assertCount(0, $footer->sections); } diff --git a/src/core/etl/tests/Flow/Floe/Tests/Unit/FloeWriterTest.php b/src/core/etl/tests/Flow/Floe/Tests/Unit/FloeWriterTest.php index 148fd10cff..c5d83e954a 100644 --- a/src/core/etl/tests/Flow/Floe/Tests/Unit/FloeWriterTest.php +++ b/src/core/etl/tests/Flow/Floe/Tests/Unit/FloeWriterTest.php @@ -88,7 +88,7 @@ public function test_append_on_missing_file_behaves_like_create(): void $writer->write(rows(schema(int_schema('id')), row(['id' => 1]))); $writer->close(); - static::assertSame(1, FloeStreamReaderContext::footer($filesystem, $path)->totalRows); + static::assertSame(1, FloeStreamReaderContext::footer($filesystem, $path)->statistics->rows); } public function test_append_on_empty_file_behaves_like_create(): void @@ -102,7 +102,7 @@ public function test_append_on_empty_file_behaves_like_create(): void $writer->write(rows(schema(int_schema('id')), row(['id' => 1]))); $writer->close(); - static::assertSame(1, FloeStreamReaderContext::footer($filesystem, $path)->totalRows); + static::assertSame(1, FloeStreamReaderContext::footer($filesystem, $path)->statistics->rows); } public function test_append_to_a_closed_zero_row_file_derives_the_schema_from_the_first_batch(): void @@ -151,7 +151,7 @@ public function test_create_with_an_explicit_schema_and_no_writes_records_the_sc $footer = FloeStreamReaderContext::footer($filesystem, $path); - static::assertSame(0, $footer->totalRows); + static::assertSame(0, $footer->statistics->rows); static::assertTrue($footer->schema()->isSame(schema(int_schema('id')))); } @@ -267,7 +267,7 @@ public function test_append_with_incompatible_schema_keeps_previous_rows_readabl $footer = FloeStreamReaderContext::footer($filesystem, $path); - static::assertSame(1, $footer->totalRows); + static::assertSame(1, $footer->statistics->rows); static::assertCount(1, $footer->sections); } @@ -333,7 +333,7 @@ public function test_append_with_same_schema_adds_a_section_reusing_the_footer_s $footer = FloeStreamReaderContext::footer($filesystem, $path); - static::assertSame(2, $footer->totalRows); + static::assertSame(2, $footer->statistics->rows); static::assertNotSame([], $footer->schema); static::assertCount(2, $footer->sections); } @@ -374,7 +374,7 @@ public function test_closing_empty_file_writes_header_and_footer_only(): void $footer = FloeStreamReaderContext::footer($filesystem, $path); - static::assertSame(0, $footer->totalRows); + static::assertSame(0, $footer->statistics->rows); static::assertSame([], $footer->sections); static::assertSame([], $footer->schema); } @@ -402,7 +402,7 @@ public function test_two_writes_with_the_same_schema_in_one_session_share_one_se $footer = FloeStreamReaderContext::footer($filesystem, $path); - static::assertSame(2, $footer->totalRows); + static::assertSame(2, $footer->statistics->rows); static::assertCount(1, $footer->sections); static::assertSame( [Format::FRAME_ROW, Format::FRAME_ROW, Format::FRAME_FOOTER], @@ -447,7 +447,7 @@ public function test_within_batch_heterogeneous_rows_encode_under_one_union_sche static::assertNotSame([], $footer->schema); static::assertCount(1, $footer->sections); - static::assertSame(3, $footer->totalRows); + static::assertSame(3, $footer->statistics->rows); static::assertSame(3, $footer->sections[0]->rowCount); $fileSchema = $footer->schema(); diff --git a/src/core/etl/tests/Flow/Floe/Tests/Unit/FooterReaderTest.php b/src/core/etl/tests/Flow/Floe/Tests/Unit/FooterReaderTest.php index e4ad28854b..28e5f7e642 100644 --- a/src/core/etl/tests/Flow/Floe/Tests/Unit/FooterReaderTest.php +++ b/src/core/etl/tests/Flow/Floe/Tests/Unit/FooterReaderTest.php @@ -38,7 +38,7 @@ public function test_reads_footer_location_of_a_written_file(): void $location = (new FooterReader())->read($fs->readFrom($path), new NoopCodec()); - static::assertSame(2, $location->footer->totalRows); + static::assertSame(2, $location->footer->statistics->rows); static::assertGreaterThan(Format::HEADER_LENGTH, $location->footerFrameStart); static::assertSame(Format::FRAME_FOOTER, ord($fs->readFrom($path)->read(1, $location->footerFrameStart))); } diff --git a/src/core/etl/tests/Flow/Floe/Tests/Unit/FooterTest.php b/src/core/etl/tests/Flow/Floe/Tests/Unit/FooterTest.php index ae6e9397c5..ecd34de58e 100644 --- a/src/core/etl/tests/Flow/Floe/Tests/Unit/FooterTest.php +++ b/src/core/etl/tests/Flow/Floe/Tests/Unit/FooterTest.php @@ -66,11 +66,30 @@ public function test_from_json_with_non_integer_version_throws(): void Footer::fromJson('{"version":"1"}'); } - public function test_from_json_with_non_integer_total_rows_throws(): void + public function test_a_footer_without_a_statistics_block_is_rejected(): void { $this->expectException(FloeException::class); + $this->expectExceptionMessage('Floe footer is malformed'); + + Footer::fromJson('{"version":2,"writer":"x","schema":[],"sections":[],"totalRows":0,"metadata":{}}'); + } + + public function test_malformed_statistics_are_rejected(): void + { + $this->expectException(FloeException::class); + $this->expectExceptionMessage('Floe footer statistics are malformed'); + + Footer::fromJson( + '{"version":2,"writer":"x","schema":[],"sections":[],"statistics":{"rows":"0","byteSize":0},"metadata":{}}', + ); + } - Footer::fromJson('{"version":1,"totalRows":"0"}'); + public function test_the_footer_carries_a_statistics_block(): void + { + static::assertSame( + ['rows' => 5, 'byteSize' => 240], + Footer::fromJson(FooterMother::footer(rows: 5, byteSize: 240)->toJson())->normalize()['statistics'], + ); } public function test_from_json_with_non_object_footer_throws(): void @@ -95,7 +114,7 @@ public function test_from_json_with_non_string_writer_throws(): void { $this->expectException(FloeException::class); - Footer::fromJson('{"version":1,"totalRows":0,"writer":42}'); + Footer::fromJson('{"version":1,"statistics":{"rows":0,"byteSize":0},"writer":42}'); } public function test_from_json_round_trips_typed_metadata(): void @@ -120,7 +139,9 @@ public function test_from_json_with_non_scalar_metadata_value_throws(): void $this->expectException(FloeException::class); $this->expectExceptionMessage('metadata is malformed'); - Footer::fromJson('{"version":1,"writer":"x","schema":[],"sections":[],"totalRows":0,"metadata":{"bad":null}}'); + Footer::fromJson( + '{"version":1,"writer":"x","schema":[],"sections":[],"statistics":{"rows":0,"byteSize":0},"metadata":{"bad":null}}', + ); } public function test_schema_body_re_encodes_stored_schema(): void @@ -139,7 +160,8 @@ public function test_to_json_round_trips_every_field(): void $footer = FooterMother::footer( schema: schema(int_schema('id'))->normalize(), sections: [new Section(6, 2), new Section(120, 3)], - totalRows: 5, + rows: 5, + byteSize: 240, metadata: ['source' => 'test'], ); @@ -151,20 +173,20 @@ public function test_normalize_round_trips_through_from_array(): void $footer = FooterMother::footer( schema: schema(int_schema('id'))->normalize(), sections: [new Section(6, 2), new Section(120, 3)], - totalRows: 5, + rows: 5, + byteSize: 240, metadata: ['source' => 'test'], ); static::assertEquals($footer, Footer::fromArray($footer->normalize())); } - public function test_a_footer_carrying_a_partitions_key_is_refused(): void + public function test_an_unknown_footer_key_is_ignored(): void { - // partitions come from the path, so a footer has no place to put them - $this->expectException(FloeException::class); - $this->expectExceptionMessage('Floe footer is malformed'); - - Footer::fromArray([...FooterMother::footer()->normalize(), 'partitions' => []]); + static::assertEquals( + FooterMother::footer(rows: 5), + Footer::fromArray([...FooterMother::footer(rows: 5)->normalize(), 'partitions' => []]), + ); } public function test_reconstruct_rows_round_trips_unpartitioned_rows(): void diff --git a/src/core/etl/tests/Flow/Floe/Tests/Unit/SectionTest.php b/src/core/etl/tests/Flow/Floe/Tests/Unit/SectionTest.php index 3dcf79d861..d8ce1455ab 100644 --- a/src/core/etl/tests/Flow/Floe/Tests/Unit/SectionTest.php +++ b/src/core/etl/tests/Flow/Floe/Tests/Unit/SectionTest.php @@ -24,11 +24,12 @@ public function test_from_array_with_non_integer_field_throws(): void Section::fromArray(['offset' => '6', 'partitionsId' => 0, 'rowCount' => 1]); } - public function test_from_array_with_non_integer_partitions_id_throws(): void + public function test_an_unknown_section_key_is_ignored(): void { - $this->expectException(FloeException::class); - - Section::fromArray(['offset' => 6, 'partitionsId' => '0', 'rowCount' => 1]); + static::assertEquals( + new Section(6, 1), + Section::fromArray(['offset' => 6, 'partitionsId' => '0', 'rowCount' => 1]), + ); } public function test_normalize_round_trips(): void diff --git a/src/core/etl/tests/Flow/Floe/Tests/Unit/StatisticsTest.php b/src/core/etl/tests/Flow/Floe/Tests/Unit/StatisticsTest.php new file mode 100644 index 0000000000..28c83e9440 --- /dev/null +++ b/src/core/etl/tests/Flow/Floe/Tests/Unit/StatisticsTest.php @@ -0,0 +1,51 @@ +expectException(FloeException::class); + $this->expectExceptionMessage('Floe footer statistics are malformed'); + + Statistics::fromArray(['rows' => 1]); + } + + public function test_an_unknown_key_is_ignored(): void + { + static::assertEquals( + new Statistics(3, 120), + Statistics::fromArray(['rows' => 3, 'byteSize' => 120, 'min' => 1]), + ); + } + + public function test_it_normalizes_to_rows_and_byte_size(): void + { + static::assertSame(['rows' => 3, 'byteSize' => 120], (new Statistics(3, 120))->normalize()); + } + + public function test_it_round_trips_through_from_array(): void + { + $statistics = new Statistics(3, 120); + + static::assertEquals($statistics, Statistics::fromArray($statistics->normalize())); + } + + #[TestWith([-1, 0, 'Floe statistics rows must not be negative, given: -1'])] + #[TestWith([0, -1, 'Floe statistics byte size must not be negative, given: -1'])] + public function test_negative_values_are_rejected(int $rows, int $byteSize, string $message): void + { + $this->expectException(FloeException::class); + $this->expectExceptionMessage($message); + + new Statistics($rows, $byteSize); + } +} diff --git a/src/extension/flow-php-ext/php/Flow/ETL/Adapter/CSV/RustCSVReaderNative.php b/src/extension/flow-php-ext/php/Flow/ETL/Adapter/CSV/RustCSVReaderNative.php index aac509ac5c..1a35add1bd 100644 --- a/src/extension/flow-php-ext/php/Flow/ETL/Adapter/CSV/RustCSVReaderNative.php +++ b/src/extension/flow-php-ext/php/Flow/ETL/Adapter/CSV/RustCSVReaderNative.php @@ -26,6 +26,17 @@ public function __construct( throw new RuntimeException('flow_php extension is not loaded'); } + /** + * The bytes, as read, of every row next() and fold() produced so far - line endings included, the header + * record excluded. + * + * @return int<0, max> + */ + public function consumedBytes(): int + { + throw new RuntimeException('flow_php extension is not loaded'); + } + public function feed(string $chunk): void { throw new RuntimeException('flow_php extension is not loaded'); diff --git a/src/extension/flow-php-ext/src/csv/mod.rs b/src/extension/flow-php-ext/src/csv/mod.rs index 979be674b8..6b7fc4e6ae 100644 --- a/src/extension/flow-php-ext/src/csv/mod.rs +++ b/src/extension/flow-php-ext/src/csv/mod.rs @@ -57,6 +57,7 @@ pub struct CsvReader { cells: Vec, record: Record, first_row_pending: bool, + consumed_bytes: u64, } impl CsvReader { @@ -95,6 +96,7 @@ impl CsvReader { cells: Vec::new(), record: Record::default(), first_row_pending: false, + consumed_bytes: 0, }) } @@ -106,6 +108,12 @@ impl CsvReader { self.tokenizer.finish(); } + /// The bytes, as read, of every row `next` and `fold` produced so far: line endings included, the header + /// record excluded. + pub fn consumed_bytes(&self) -> u64 { + self.consumed_bytes + } + /// The resolved header, `[]` until the first record is available. pub fn headers(&mut self) -> Vec<&[u8]> { self.resolve_headers(); @@ -127,7 +135,7 @@ impl CsvReader { }; while batch.len() < batch_size { - if !next_row(&mut self.first_row_pending, &mut self.tokenizer, &mut self.record) { + if !next_row(&mut self.first_row_pending, &mut self.tokenizer, &mut self.record, &mut self.consumed_bytes) { break; } @@ -152,7 +160,7 @@ impl CsvReader { let mut positions = vec![None; self.cells.len()]; while limit.is_none_or(|limit| folded < limit) { - if !next_row(&mut self.first_row_pending, &mut self.tokenizer, &mut self.record) { + if !next_row(&mut self.first_row_pending, &mut self.tokenizer, &mut self.record, &mut self.consumed_bytes) { break; } @@ -215,9 +223,21 @@ impl CsvReader { } } -/// The first row is pending when auto headers were sized by it; otherwise the next complete record. -fn next_row(first_row_pending: &mut bool, tokenizer: &mut Tokenizer, record: &mut Record) -> bool { - std::mem::take(first_row_pending) || tokenizer.next(record) +/// The first row is pending when auto headers were sized by it; otherwise the next complete record. Either way it is +/// the tokenizer's last record, so its bytes are added to `consumed_bytes`. +fn next_row( + first_row_pending: &mut bool, + tokenizer: &mut Tokenizer, + record: &mut Record, + consumed_bytes: &mut u64, +) -> bool { + if !(std::mem::take(first_row_pending) || tokenizer.next(record)) { + return false; + } + + *consumed_bytes += tokenizer.last_record_bytes() as u64; + + true } /// `CSVRowNormalizer::normalize()` for one cell: padding past the record's end, `emptyToNull`; `None` is null. diff --git a/src/extension/flow-php-ext/src/csv/tokenizer.rs b/src/extension/flow-php-ext/src/csv/tokenizer.rs index a3af8a56f0..1596d0ef35 100644 --- a/src/extension/flow-php-ext/src/csv/tokenizer.rs +++ b/src/extension/flow-php-ext/src/csv/tokenizer.rs @@ -68,6 +68,7 @@ pub struct Tokenizer { scan: Scan, finished: bool, first_record: bool, + last_record_bytes: usize, } impl Tokenizer { @@ -81,6 +82,7 @@ impl Tokenizer { scan: Scan::FieldStart, finished: false, first_record: true, + last_record_bytes: 0, } } @@ -98,6 +100,11 @@ impl Tokenizer { self.finished = true; } + /// The bytes the last record `next` returned took in the stream: line ending and BOM included. + pub fn last_record_bytes(&self) -> usize { + self.last_record_bytes + } + pub fn next(&mut self, record: &mut Record) -> bool { let Some(range) = self.next_record() else { return false; @@ -130,6 +137,8 @@ impl Tokenizer { None => return None, }; + self.last_record_bytes = self.record_start - record.start; + while record.end > record.start && matches!(self.buffer[record.end - 1], b'\r' | b'\n') { record.end -= 1; } diff --git a/src/extension/flow-php-ext/src/lib.rs b/src/extension/flow-php-ext/src/lib.rs index a22baa5a84..8072751f50 100644 --- a/src/extension/flow-php-ext/src/lib.rs +++ b/src/extension/flow-php-ext/src/lib.rs @@ -309,6 +309,11 @@ impl RustCSVReaderNative { Ok(self.reader.fold(&mut fold.fold, limit)? as i64) } + #[php(name = "consumedBytes")] + pub fn consumed_bytes(&self) -> i64 { + self.reader.consumed_bytes() as i64 + } + pub fn next(&mut self, batch_size: i64) -> PhpResult { let batch_size = usize::try_from(batch_size) .ok() diff --git a/src/extension/flow-php-ext/tests/phpt/054_csv_consumed_bytes.phpt b/src/extension/flow-php-ext/tests/phpt/054_csv_consumed_bytes.phpt new file mode 100644 index 0000000000..5f2ac050a5 --- /dev/null +++ b/src/extension/flow-php-ext/tests/phpt/054_csv_consumed_bytes.phpt @@ -0,0 +1,88 @@ +--TEST-- +native CSV consumedBytes() counts the bytes of the rows it produced exactly like the PHP path, over any chunk size +--SKIPIF-- + +--FILE-- +records() as $values) { + if (++$taken >= $rows) { + break; + } + } + + return $source->producedBytes(); +}; + +$native = static function (string $raw, bool $withHeader, int $rows, int $chunk): int { + $reader = new RustCSVReaderNative(',', '"', '\\', $withHeader, true, true); + $fold = new RustColumnFoldNative([], []); + $folded = 0; + + foreach (str_split($raw, $chunk) as $piece) { + $reader->feed($piece); + $folded += $reader->fold($fold, $rows - $folded); + } + + $reader->finish(); + $reader->fold($fold, $rows - $folded); + + return $reader->consumedBytes(); +}; + +$cases = [ + 'LF' => ["a,b\n1,2\n33,44\n", true, 2], + 'CRLF' => ["a,b\r\n1,2\r\n33,44\r\n", true, 2], + 'quoted multi-line LF' => ["a,b\n\"x\ny\",1\n2,3\n", true, 2], + 'quoted multi-line CRLF' => ["a,b\r\n\"x\r\ny\",1\r\n2,3\r\n", true, 2], + 'blank lines' => ["a,b\n\n1,2\n\n", true, 3], + 'BOM without header' => ["\xEF\xBB\xBF1,2\n3,4\n", false, 2], + 'first two of three rows' => ["a,b\n1,2\n33,44\n555,666\n", true, 2], +]; + +foreach ($cases as $label => [$raw, $withHeader, $rows]) { + $expected = $php($raw, $withHeader, $rows); + $counts = array_unique(array_map( + static fn(int $chunk): int => $native($raw, $withHeader, $rows, $chunk), + [1, 3, 7, 4096], + )); + + echo $label, ': ', $counts === [$expected] ? 'identical ' . $expected : 'FAIL php=' . $expected . ' native=' . implode(',', $counts), "\n"; +} + +$reader = new RustCSVReaderNative(',', '"', '\\', true, true, true); +$reader->feed("a,b\n1,2\n33,44\n"); +$reader->finish(); +$reader->next(1); +echo 'next() counts too: ', $reader->consumedBytes(), "\n"; +$reader->next(10); +echo 'header excluded: ', $reader->consumedBytes(), "\n"; +?> +--EXPECT-- +LF: identical 10 +CRLF: identical 12 +quoted multi-line LF: identical 12 +quoted multi-line CRLF: identical 15 +blank lines: identical 6 +BOM without header: identical 11 +first two of three rows: identical 10 +next() counts too: 4 +header excluded: 10 diff --git a/web/landing/assets/wasm/data/orders.floe b/web/landing/assets/wasm/data/orders.floe index 6dafe73507b324d157c43f1a0be435c96d7db16d..3136f68f7aa943972c9b85cf5f1cdb9ac81a179c 100644 GIT binary patch delta 81 zcmcbschema; } + public function statistics(): Statistics + { + return new Statistics(); + } + public function withSchema(Schema $schema): static { $clone = clone $this; From a04f1588d98d4feec5ad43094cea54f4451641a9 Mon Sep 17 00:00:00 2001 From: Norbert Orzechowicz Date: Tue, 22 Sep 2026 12:38:31 +0200 Subject: [PATCH 2/3] feat(flow-php/etl): first consumers of source statistics - physical explain prints every source's declared statistics - analyze reports declared vs. read rows per source (withSourceStatistics(), --stats-sources) - count() is planned as Trigger::count and answered from exact statistics by the CountFromStatistics rule --- documentation/components/cli/docs.md | 2 + documentation/components/core/core.md | 13 +- .../components/core/data-retrieval.md | 33 +++- documentation/upgrading.md | 6 + .../Integration/ParquetExtractorTest.php | 63 +++++++ .../Flow/CLI/Command/FileAnalyzeCommand.php | 4 + .../Flow/CLI/Command/PipelineRunCommand.php | 4 + .../CLI/Command/Traits/StatisticsOptions.php | 23 ++- .../CLI/Formatter/PipelineReportFormatter.php | 27 +++ .../Integration/FileAnalyzeCommandTest.php | 20 ++ .../Integration/PipelineRunCommandTest.php | 24 +++ src/core/etl/src/Flow/ETL/Analyze.php | 14 ++ src/core/etl/src/Flow/ETL/DataFrame.php | 31 ++-- src/core/etl/src/Flow/ETL/Dataset/Report.php | 12 ++ .../src/Flow/ETL/Dataset/SourceStatistics.php | 42 +++++ src/core/etl/src/Flow/ETL/Executor.php | 5 + .../etl/src/Flow/ETL/Executor/Pipeline.php | 8 + .../etl/src/Flow/ETL/Executor/SourceRows.php | 73 ++++++++ .../Flow/ETL/Executor/StatisticsCollector.php | 12 ++ src/core/etl/src/Flow/ETL/Optimizer.php | 2 + .../Optimizer/Rule/CountFromStatistics.php | 42 +++++ .../Flow/ETL/Plan/Explain/StatisticsLine.php | 66 +++++++ .../src/Flow/ETL/Plan/Explain/StepDetails.php | 12 ++ src/core/etl/src/Flow/ETL/Plan/Node/Count.php | 51 ++++++ src/core/etl/src/Flow/ETL/Plan/Trigger.php | 3 + src/core/etl/src/Flow/ETL/Planner.php | 23 ++- .../src/Flow/ETL/Planner/NodeTranslator.php | 2 + .../src/Flow/ETL/Planner/PipelineSplit.php | 15 +- .../Flow/ETL/Processor/CountingProcessor.php | 49 +++++ .../Integration/DataFrame/AnalyzeTest.php | 30 +++ .../Config/Telemetry/TelemetryContextTest.php | 9 +- .../Flow/ETL/Tests/Unit/DataFrameTest.php | 35 ++++ .../Unit/Dataset/SourceStatisticsTest.php | 69 +++++++ .../ETL/Tests/Unit/Executor/PipelineTest.php | 7 +- .../Tests/Unit/Executor/SourceRowsTest.php | 100 ++++++++++ .../Unit/Executor/StatisticsCollectorTest.php | 33 ++++ .../Rule/CountFromStatisticsTest.php | 171 ++++++++++++++++++ .../Flow/ETL/Tests/Unit/OptimizerTest.php | 13 +- .../Unit/Plan/Explain/PhysicalOutlineTest.php | 15 +- .../Unit/Plan/Explain/StatisticsLineTest.php | 121 +++++++++++++ .../Unit/Plan/Explain/StepDetailsTest.php | 25 ++- .../ETL/Tests/Unit/Plan/Node/CountTest.php | 48 +++++ .../Flow/ETL/Tests/Unit/Plan/TriggerTest.php | 21 +++ .../Tests/Unit/Planner/NodeTranslatorTest.php | 13 ++ .../Tests/Unit/Planner/PipelineSplitTest.php | 31 ++++ .../tests/Flow/ETL/Tests/Unit/PlannerTest.php | 25 +++ .../Unit/Processor/CountingProcessorTest.php | 53 ++++++ web/landing/assets/wasm/tools/flow.phar | Bin 3932617 -> 3960456 bytes web/landing/resources/api.json | 2 +- 49 files changed, 1455 insertions(+), 47 deletions(-) create mode 100644 src/core/etl/src/Flow/ETL/Dataset/SourceStatistics.php create mode 100644 src/core/etl/src/Flow/ETL/Executor/SourceRows.php create mode 100644 src/core/etl/src/Flow/ETL/Optimizer/Rule/CountFromStatistics.php create mode 100644 src/core/etl/src/Flow/ETL/Plan/Explain/StatisticsLine.php create mode 100644 src/core/etl/src/Flow/ETL/Plan/Node/Count.php create mode 100644 src/core/etl/src/Flow/ETL/Processor/CountingProcessor.php create mode 100644 src/core/etl/tests/Flow/ETL/Tests/Unit/Dataset/SourceStatisticsTest.php create mode 100644 src/core/etl/tests/Flow/ETL/Tests/Unit/Executor/SourceRowsTest.php create mode 100644 src/core/etl/tests/Flow/ETL/Tests/Unit/Optimizer/Rule/CountFromStatisticsTest.php create mode 100644 src/core/etl/tests/Flow/ETL/Tests/Unit/Plan/Explain/StatisticsLineTest.php create mode 100644 src/core/etl/tests/Flow/ETL/Tests/Unit/Plan/Node/CountTest.php create mode 100644 src/core/etl/tests/Flow/ETL/Tests/Unit/Processor/CountingProcessorTest.php diff --git a/documentation/components/cli/docs.md b/documentation/components/cli/docs.md index 143734fe21..506580ec8c 100644 --- a/documentation/components/cli/docs.md +++ b/documentation/components/cli/docs.md @@ -262,6 +262,7 @@ Options: --schema-union-by-name Union the column sets of every sniffed source instead of taking the first one. --stats-schema[=STATS-SCHEMA] Prints schema of executed data transformation pipeline. [default: false] --stats-columns[=STATS-COLUMNS] Prints number of rows in dataset. [default: false] + --stats-sources[=STATS-SOURCES] Prints the rows every source declared next to the rows it yielded. [default: false] -h, --help Display help for the given command. When no command is given display help for the list command --silent Do not output any message -q, --quiet Only errors are displayed. All other output is suppressed @@ -406,6 +407,7 @@ Options: --config=CONFIG Path to a local php file that MUST return instance of: Flow\ETL\Config --stats-schema[=STATS-SCHEMA] Prints schema of executed data transformation pipeline. [default: false] --stats-columns[=STATS-COLUMNS] Prints number of rows in dataset. [default: false] + --stats-sources[=STATS-SOURCES] Prints the rows every source declared next to the rows it yielded. [default: false] -h, --help Display help for the given command. When no command is given display help for the list command --silent Do not output any message -q, --quiet Only errors are displayed. All other output is suppressed diff --git a/documentation/components/core/core.md b/documentation/components/core/core.md index 4835c72fc9..4746aace40 100644 --- a/documentation/components/core/core.md +++ b/documentation/components/core/core.md @@ -48,7 +48,8 @@ tree read from the bottom up: a node's children are where its rows come from, an rows reach them - the source is `#1`. What a node does is listed under it. The verbs build a plan with no consumer on top; **the trigger adds the one it needs**, so `explain()` takes the -trigger it should print - `Trigger::rows` by default, `Trigger::run` for the plan `run()` executes. +trigger it should print - `Trigger::rows` by default, `Trigger::run` for the plan `run()` executes, `Trigger::count` +for the plan `count()` executes. ```php echo data_frame() @@ -154,6 +155,9 @@ step sits in is where rows stop flowing through, and every step lists the settin storage among them, which the logical stages cannot know because the planner picks it. ```php +$users = [['id' => 1, 'name' => 'Alice'], ['id' => 2, 'name' => 'Bob']]; +$emails = [['id' => 1, 'email' => 'alice@example.com']]; + echo data_frame() ->read(from_array($users)) ->join(data_frame()->read(from_array($emails)), join_on(['id' => 'id'], join_prefix: 'joined_'), Join::left) @@ -164,13 +168,14 @@ echo data_frame() ```text Physical plan -│ Schema: derived +│ Columns: id, name, joined_id, joined_email └─ Pipeline #1 │ Processor: CollectingProcessor │ Schema: declared │ Loader: StreamLoader └─ Pipeline #0 │ Extractor: ArrayExtractor + │ Statistics: rows exact 2 · size unknown │ Processor: HashJoinProcessor │ Join: left │ On: id = id @@ -180,9 +185,12 @@ Physical plan │ Batch: 1000 └─ Right side: Pipeline #0 Extractor: ArrayExtractor + Statistics: rows exact 1 · size unknown ``` A joined frame is planned apart, so its pipelines are numbered apart - `Right side:` says which plan they belong to. +Every source lists what it declares about itself before a row is read - `exact n`, `≤ n` (a guaranteed bound), +`~n ±e%` (an estimate) or `unknown`. A source that declares nothing lists no `Statistics:` line. Reaching this stage plans the frame, so a source that infers its schema by reading is read here; the logical stages never read a row. @@ -235,6 +243,7 @@ Before a frame runs, the optimizer rewrites its plan. `Optimizer::default()` run | `CombineSortAndLimit` | `sortBy()` followed by `limit()` keeps only the top rows instead of sorting all | | `PushLimitIntoSource` | the extractor stops reading once the limit (plus any `offset()`) is reached | | `PushFilterIntoSource` | a `filter()` on partition columns skips whole partition directories | +| `CountFromStatistics` | `count()` over a source that knows its rows exactly reads that number, no rows | Rules live in `Flow\ETL\Optimizer\Rule` and are configured through `config_builder()->optimizer()`: diff --git a/documentation/components/core/data-retrieval.md b/documentation/components/core/data-retrieval.md index b321e488cc..494f897c5f 100644 --- a/documentation/components/core/data-retrieval.md +++ b/documentation/components/core/data-retrieval.md @@ -99,9 +99,17 @@ $totalCount = $dataFrame->count(); echo "Total rows: $totalCount\n"; ``` -> **Performance Warning**: The `count()` method must process the entire dataset to return the total count, which can -> be expensive for large datasets. Consider whether you actually need the exact count or if an approximation would -> suffice. +A frame that only reads one source is counted from the source's statistics when they are exact - a Parquet or Floe +file answers from its footer without reading a row (`explain(Trigger::count)` shows it): + +```php +read(from_parquet('orders.parquet'))->count(); +``` + +> **Performance Warning**: Any other frame - a filter, a limit, a new column, a join, an estimated source - is executed +> in full to count its rows, which can be expensive for large datasets. ## Iteration with Callback @@ -124,4 +132,23 @@ sinks and returns a `Report` when asked to analyze. $dataFrame->write(to_json('out.json'))->run(); $report = $dataFrame->write(to_json('out.json'))->run(analyze: analyze()->withSchema()); +``` + +`withSourceStatistics()` puts the rows every source declared next to the rows it actually yielded - the output row +count alone cannot tell them apart once a filter or a join sits in between: + +```php +read(from_parquet('orders/*.parquet')) + ->filter(ref('total')->greaterThan(lit(100))) + ->run(analyze: analyze()->withSourceStatistics()); + +foreach ($report->sources() as $source) { + echo $source->extractor; // ParquetExtractor + echo $source->declared->rows->estimate; // 20000 - extrapolated from the first footer + echo $source->rows; // 29000 - read + echo $source->rowsError(); // 0.31 - |declared - read| / read, null without an estimate +} ``` \ No newline at end of file diff --git a/documentation/upgrading.md b/documentation/upgrading.md index 605bd841a5..fe2111ab3d 100644 --- a/documentation/upgrading.md +++ b/documentation/upgrading.md @@ -272,6 +272,12 @@ final class MyExtractor implements Extractor } ``` +### 26) `flow-php/etl` - `Report` takes the source statistics of the run + +| Before | After | +|--------------------------------------------------|------------------------------------------------------------------------------------------------------| +| `new Report(?Schema $schema, Statistics $stats)` | `new Report(?Schema $schema, Statistics $stats, ?array $sources)` - `null` unless analyzed with them | + --- ## Upgrading from 0.43.x to 0.44.x diff --git a/src/adapter/etl-adapter-parquet/tests/Flow/ETL/Adapter/Parquet/Tests/Integration/ParquetExtractorTest.php b/src/adapter/etl-adapter-parquet/tests/Flow/ETL/Adapter/Parquet/Tests/Integration/ParquetExtractorTest.php index ef49fcc324..8cb86d2065 100644 --- a/src/adapter/etl-adapter-parquet/tests/Flow/ETL/Adapter/Parquet/Tests/Integration/ParquetExtractorTest.php +++ b/src/adapter/etl-adapter-parquet/tests/Flow/ETL/Adapter/Parquet/Tests/Integration/ParquetExtractorTest.php @@ -6,8 +6,10 @@ use DateTimeImmutable; use Flow\ETL\Adapter\Parquet\Tests\Context\ParquetFilesContext; +use Flow\ETL\Cardinality; use Flow\ETL\Exception\InferredSchemaException; use Flow\ETL\Extractor\Signal; +use Flow\ETL\Plan\Stage; use Flow\ETL\Tests\Context\ExtractedRows; use Flow\ETL\Tests\Double\CountingFilesystem; use Flow\ETL\Tests\FlowTestCase; @@ -20,9 +22,12 @@ use function array_keys; use function Flow\ETL\Adapter\Parquet\from_parquet; +use function Flow\ETL\Adapter\Parquet\to_parquet; +use function Flow\ETL\DSL\analyze; use function Flow\ETL\DSL\config; use function Flow\ETL\DSL\df; use function Flow\ETL\DSL\flow_context; +use function Flow\ETL\DSL\from_sequence_number; use function Flow\ETL\DSL\int_schema; use function Flow\ETL\DSL\partition_types; use function Flow\ETL\DSL\row; @@ -233,6 +238,64 @@ public function test_extract_closes_the_reader_when_the_generator_is_abandoned() static::assertSame($filesystem->readFromCalls, $filesystem->closedStreams()); } + public function test_explain_lists_the_statistics_the_file_declares(): void + { + static::assertStringContainsString( + <<<'PLAN' + Extractor: ParquetExtractor + Statistics: rows exact 1 000 · size exact 327 527 B + PLAN, + df() + ->read(from_parquet(path(__DIR__ . '/Fixtures/orders_1k.parquet'))) + ->explain() + ->toString(Stage::physical), + ); + } + + public function test_analyze_reports_the_extrapolated_rows_next_to_the_rows_read(): void + { + $memory = memory_filesystem(); + df() + ->read(from_sequence_number('id', 1, 10_000)) + ->write(to_parquet(path('memory://glob/a.parquet'), filesystem: $memory)) + ->run(); + df() + ->read(from_sequence_number('id', 1, 19_000)) + ->write(to_parquet(path('memory://glob/b.parquet'), filesystem: $memory)) + ->run(); + + $report = df()->read(from_parquet(path('memory://glob/*.parquet'), filesystem: $memory))->run( + analyze()->withSourceStatistics(), + ); + + static::assertNotNull($report); + $sources = $report->sources(); + static::assertNotNull($sources); + static::assertCount(1, $sources); + static::assertSame('ParquetExtractor', $sources[0]->extractor); + static::assertEquals(Cardinality::approximately(20_000), $sources[0]->declared->rows); + static::assertSame(29_000, $sources[0]->rows); + static::assertEqualsWithDelta(9_000 / 29_000, $sources[0]->rowsError(), 0.000_001); + } + + public function test_count_answers_from_the_footer_without_reading_the_rows(): void + { + $filesystem = new CountingFilesystem(new NativeLocalFilesystem()); + + $counted = df() + ->read(from_parquet(path(__DIR__ . '/Fixtures/orders_1k.parquet'), filesystem: $filesystem)) + ->count(); + + static::assertSame( + df() + ->read(from_parquet(path(__DIR__ . '/Fixtures/orders_1k.parquet'))) + ->fetch() + ->count(), + $counted, + ); + static::assertSame(1, $filesystem->readFromCalls); + } + public function test_extract_closes_every_reader_it_opens(): void { $filesystem = new CountingFilesystem(new NativeLocalFilesystem()); diff --git a/src/cli/src/Flow/CLI/Command/FileAnalyzeCommand.php b/src/cli/src/Flow/CLI/Command/FileAnalyzeCommand.php index dbdc07576d..bb5bbb48ff 100644 --- a/src/cli/src/Flow/CLI/Command/FileAnalyzeCommand.php +++ b/src/cli/src/Flow/CLI/Command/FileAnalyzeCommand.php @@ -157,6 +157,10 @@ protected function execute(InputInterface $input, OutputInterface $output): int $analyze->withSchema()->withColumnStatistics(); } + if (option_bool('stats-sources', $input)) { + $analyze->withSourceStatistics(); + } + $report = $df->write(new ProgressBarLoader($progress))->run(analyze: $analyze); $progress->finish(); diff --git a/src/cli/src/Flow/CLI/Command/PipelineRunCommand.php b/src/cli/src/Flow/CLI/Command/PipelineRunCommand.php index 1d8fda5dfc..9bff0a522e 100644 --- a/src/cli/src/Flow/CLI/Command/PipelineRunCommand.php +++ b/src/cli/src/Flow/CLI/Command/PipelineRunCommand.php @@ -82,6 +82,10 @@ public function execute(InputInterface $input, OutputInterface $output): int $analyze->withColumnStatistics(); } + if ($analyze && option_bool('stats-sources', $input)) { + $analyze->withSourceStatistics(); + } + try { ob_start(); $extension = $this->pipelinePath->extension(); diff --git a/src/cli/src/Flow/CLI/Command/Traits/StatisticsOptions.php b/src/cli/src/Flow/CLI/Command/Traits/StatisticsOptions.php index 814375acb2..5d15887f5d 100644 --- a/src/cli/src/Flow/CLI/Command/Traits/StatisticsOptions.php +++ b/src/cli/src/Flow/CLI/Command/Traits/StatisticsOptions.php @@ -11,12 +11,21 @@ trait StatisticsOptions { private function addStatisticsOptions(Command $command): void { - $command->addOption( - 'stats-schema', - null, - InputOption::VALUE_OPTIONAL, - 'Prints schema of executed data transformation pipeline.', - false, - )->addOption('stats-columns', null, InputOption::VALUE_OPTIONAL, 'Prints number of rows in dataset.', false); + $command + ->addOption( + 'stats-schema', + null, + InputOption::VALUE_OPTIONAL, + 'Prints schema of executed data transformation pipeline.', + false, + ) + ->addOption('stats-columns', null, InputOption::VALUE_OPTIONAL, 'Prints number of rows in dataset.', false) + ->addOption( + 'stats-sources', + null, + InputOption::VALUE_OPTIONAL, + 'Prints the rows every source declared next to the rows it yielded.', + false, + ); } } diff --git a/src/cli/src/Flow/CLI/Formatter/PipelineReportFormatter.php b/src/cli/src/Flow/CLI/Formatter/PipelineReportFormatter.php index ecf78dc1be..3dd059b031 100644 --- a/src/cli/src/Flow/CLI/Formatter/PipelineReportFormatter.php +++ b/src/cli/src/Flow/CLI/Formatter/PipelineReportFormatter.php @@ -5,6 +5,7 @@ namespace Flow\CLI\Formatter; use Flow\ETL\Dataset\Report; +use Flow\ETL\Plan\Explain\StatisticsLine; use Symfony\Component\Console\Helper\TableSeparator; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Style\SymfonyStyle; @@ -91,6 +92,32 @@ public function format(): void ->render(); } + $sources = $this->report->sources(); + + if (option_bool('stats-sources', $this->input) && $sources !== null) { + $statisticsLine = new StatisticsLine(); + $normalizedSources = []; + + foreach ($sources as $source) { + $normalizedSources[] = [ + 'extractor' => $source->extractor, + 'declared' => $statisticsLine->cardinality($source->declared->rows), + 'measured' => $statisticsLine->number($source->rows), + 'error' => $statisticsLine->percent($source->rowsError()), + ]; + } + + $this->style->newLine(); + $this->style->section('Sources'); + + $this->style + ->createTable() + ->setHeaders(['Extractor', 'Declared Rows', 'Measured Rows', 'Rows Error']) + ->setRows($normalizedSources) + ->setStyle('box') + ->render(); + } + $this->style->newLine(); $this->style->definitionList( 'Statistics', diff --git a/src/cli/tests/Flow/CLI/Tests/Integration/FileAnalyzeCommandTest.php b/src/cli/tests/Flow/CLI/Tests/Integration/FileAnalyzeCommandTest.php index e7832e3a64..e228ee133a 100644 --- a/src/cli/tests/Flow/CLI/Tests/Integration/FileAnalyzeCommandTest.php +++ b/src/cli/tests/Flow/CLI/Tests/Integration/FileAnalyzeCommandTest.php @@ -137,6 +137,26 @@ public function test_read_rows_csv_without_schema(): void self::assertCommandOutputContains('Execution Time', $tester->getDisplay()); } + public function test_read_rows_csv_with_source_statistics(): void + { + $tester = new CommandTester(new FileAnalyzeCommand('file:analyze')); + + $tester->execute([ + 'input-file' => __DIR__ . '/Fixtures/orders.csv', + '--stats-sources' => true, + ]); + + $tester->assertCommandIsSuccessful(); + + self::assertCommandOutputContains(<<<'OUTPUT' + ┌──────────────┬───────────────┬───────────────┬────────────┐ + │ Extractor │ Declared Rows │ Measured Rows │ Rows Error │ + ├──────────────┼───────────────┼───────────────┼────────────┤ + │ CSVExtractor │ exact 43 │ 43 │ 0% │ + └──────────────┴───────────────┴───────────────┴────────────┘ + OUTPUT, $tester->getDisplay()); + } + public function test_file_analyze_command_registers_the_analyze_alias(): void { $application = new Application(); diff --git a/src/cli/tests/Flow/CLI/Tests/Integration/PipelineRunCommandTest.php b/src/cli/tests/Flow/CLI/Tests/Integration/PipelineRunCommandTest.php index 0ade486cfe..51e8433970 100644 --- a/src/cli/tests/Flow/CLI/Tests/Integration/PipelineRunCommandTest.php +++ b/src/cli/tests/Flow/CLI/Tests/Integration/PipelineRunCommandTest.php @@ -64,6 +64,30 @@ public function test_run_and_analyze_command(): void OUTPUT, $tester->getDisplay()); } + public function test_run_and_analyze_command_lists_declared_and_measured_source_rows(): void + { + $tester = new CommandTester(new PipelineRunCommand('run')); + + $tester->execute([ + 'pipeline-file' => __DIR__ . '/Fixtures/pipeline.php', + '--analyze' => true, + '--stats-sources' => true, + ]); + + $tester->assertCommandIsSuccessful(); + + self::assertCommandOutputContains(<<<'OUTPUT' + Sources + ------- + + ┌────────────────┬───────────────┬───────────────┬────────────┐ + │ Extractor │ Declared Rows │ Measured Rows │ Rows Error │ + ├────────────────┼───────────────┼───────────────┼────────────┤ + │ ArrayExtractor │ exact 3 │ 3 │ 0% │ + └────────────────┴───────────────┴───────────────┴────────────┘ + OUTPUT, $tester->getDisplay()); + } + public function test_run_command(): void { $tester = new CommandTester(new PipelineRunCommand('run')); diff --git a/src/core/etl/src/Flow/ETL/Analyze.php b/src/core/etl/src/Flow/ETL/Analyze.php index 229f7a80de..2c16108003 100644 --- a/src/core/etl/src/Flow/ETL/Analyze.php +++ b/src/core/etl/src/Flow/ETL/Analyze.php @@ -10,6 +10,8 @@ final class Analyze private bool $collectSchema = false; + private bool $collectSourceStatistics = false; + public function __construct() {} public function collectColumnStatistics(): bool @@ -22,6 +24,11 @@ public function collectSchema(): bool return $this->collectSchema; } + public function collectSourceStatistics(): bool + { + return $this->collectSourceStatistics; + } + public function withColumnStatistics(): self { $this->collectColumnStatistics = true; @@ -35,4 +42,11 @@ public function withSchema(): self return $this; } + + public function withSourceStatistics(): self + { + $this->collectSourceStatistics = true; + + return $this; + } } diff --git a/src/core/etl/src/Flow/ETL/DataFrame.php b/src/core/etl/src/Flow/ETL/DataFrame.php index 327c3bd11b..168075b601 100644 --- a/src/core/etl/src/Flow/ETL/DataFrame.php +++ b/src/core/etl/src/Flow/ETL/DataFrame.php @@ -38,6 +38,7 @@ use function count; use function Flow\ETL\DSL\refs; use function Flow\ETL\DSL\to_output; +use function Flow\Types\DSL\type_integer; use function is_string; /** @@ -202,18 +203,19 @@ public function constrain(Constraint $constraint, Constraint ...$constraints): s */ public function count(): int { - $total = 0; - - foreach ($this->context - ->config - ->executor() - ->execute( - $this->context->config->planner()->plan(Trigger::rows->plan($this->root, $this->sinks), $this->context), - ) as $rows) { - $total += $rows->count(); - } - - return $total; + return type_integer()->assert( + $this->context + ->config + ->executor() + ->fetch( + $this->context + ->config + ->planner() + ->plan(Trigger::count->plan($this->root, $this->sinks), $this->context), + ) + ->first() + ->get('count'), + ); } /** @@ -715,7 +717,10 @@ public function run(bool|Analyze $analyze = false): ?Report ->config ->executor() ->execute( - $this->context->config->planner()->plan(Trigger::run->plan($this->root, $this->sinks), $this->context), + $this->context + ->config + ->planner() + ->plan(Trigger::run->plan($this->root, $this->sinks), $this->context, $collector->sources()), ) as $rows) { $collector->capture($rows); } diff --git a/src/core/etl/src/Flow/ETL/Dataset/Report.php b/src/core/etl/src/Flow/ETL/Dataset/Report.php index 1cebf4a142..40fbd90c8f 100644 --- a/src/core/etl/src/Flow/ETL/Dataset/Report.php +++ b/src/core/etl/src/Flow/ETL/Dataset/Report.php @@ -8,11 +8,23 @@ final readonly class Report { + /** + * @param null|list $sources null when the run was not analyzed with source statistics + */ public function __construct( private ?Schema $schema, private Statistics $statistics, + private ?array $sources, ) {} + /** + * @return null|list + */ + public function sources(): ?array + { + return $this->sources; + } + public function schema(): ?Schema { return $this->schema; diff --git a/src/core/etl/src/Flow/ETL/Dataset/SourceStatistics.php b/src/core/etl/src/Flow/ETL/Dataset/SourceStatistics.php new file mode 100644 index 0000000000..10a646e9af --- /dev/null +++ b/src/core/etl/src/Flow/ETL/Dataset/SourceStatistics.php @@ -0,0 +1,42 @@ +declared->rows->estimate; + + if ($estimate === null) { + return null; + } + + if ($this->rows === 0) { + return $estimate === 0 ? 0.0 : INF; + } + + return abs($estimate - $this->rows) / $this->rows; + } +} diff --git a/src/core/etl/src/Flow/ETL/Executor.php b/src/core/etl/src/Flow/ETL/Executor.php index 84a8975b9e..52ae71ff3c 100644 --- a/src/core/etl/src/Flow/ETL/Executor.php +++ b/src/core/etl/src/Flow/ETL/Executor.php @@ -133,6 +133,11 @@ public function executePipeline(Pipeline $pipeline): Generator $generator = $source instanceof FileExtractor ? $source->extract($leaf->context(), $leaf->limit(), $leaf->pathFilter()) : $source->extract($leaf->context(), $leaf->limit()); + $sources = $leaf->sources(); + + if ($sources !== null) { + $generator = $sources->count($source, $generator); + } foreach ($chain as $stage) { foreach ($stage->segments()->all() as $segment) { diff --git a/src/core/etl/src/Flow/ETL/Executor/Pipeline.php b/src/core/etl/src/Flow/ETL/Executor/Pipeline.php index 59711d6f11..534927d41b 100644 --- a/src/core/etl/src/Flow/ETL/Executor/Pipeline.php +++ b/src/core/etl/src/Flow/ETL/Executor/Pipeline.php @@ -22,6 +22,8 @@ * behind an input edge * @param Filter $pathFilter handed to a FileExtractor source when this pipeline reads it; meaningless * behind an input edge + * @param null|SourceRows $sources counts the rows of the source when this pipeline reads it and the run is + * analyzed with source statistics; null otherwise */ public function __construct( public int $id, @@ -30,6 +32,7 @@ public function __construct( private ?self $input = null, private ?int $limit = null, private Filter $pathFilter = new OnlyFiles(), + private ?SourceRows $sources = null, ) {} public function segments(): Segments @@ -55,6 +58,11 @@ public function pathFilter(): Filter return $this->pathFilter; } + public function sources(): ?SourceRows + { + return $this->sources; + } + /** * The upstream stage, cut off after a blocking node. The Executor flattens the chain. */ diff --git a/src/core/etl/src/Flow/ETL/Executor/SourceRows.php b/src/core/etl/src/Flow/ETL/Executor/SourceRows.php new file mode 100644 index 0000000000..ef72696fd0 --- /dev/null +++ b/src/core/etl/src/Flow/ETL/Executor/SourceRows.php @@ -0,0 +1,73 @@ + + */ + private SplObjectStorage $rows; + + public function __construct() + { + /** @var SplObjectStorage $rows */ + $rows = new SplObjectStorage(); + $this->rows = $rows; + } + + /** + * Passes $batches through unchanged. A stop is forwarded and ends the count: the consumer that sent it reads no + * further batch. + * + * @param Generator $batches what $extractor yields + * + * @return Generator + */ + public function count(Extractor $extractor, Generator $batches): Generator + { + if (!$this->rows->offsetExists($extractor)) { + $this->rows[$extractor] = 0; + } + + foreach ($batches as $rows) { + $this->rows[$extractor] += $rows->count(); + + $signal = yield $rows; + + if ($signal === Signal::STOP) { + $batches->send(Signal::STOP); + + return; + } + } + } + + /** + * @return list in the order the sources were first read + */ + public function statistics(): array + { + $statistics = []; + + foreach ($this->rows as $extractor) { + $statistics[] = new SourceStatistics( + (new ReflectionClass($extractor))->getShortName(), + $extractor->statistics(), + $this->rows[$extractor], + ); + } + + return $statistics; + } +} diff --git a/src/core/etl/src/Flow/ETL/Executor/StatisticsCollector.php b/src/core/etl/src/Flow/ETL/Executor/StatisticsCollector.php index b354ceeb52..ea9d8ad556 100644 --- a/src/core/etl/src/Flow/ETL/Executor/StatisticsCollector.php +++ b/src/core/etl/src/Flow/ETL/Executor/StatisticsCollector.php @@ -31,6 +31,8 @@ final class StatisticsCollector private ?Schema $schema = null; + private ?SourceRows $sources = null; + private readonly DateTimeImmutable $startedAt; private readonly HighResolutionTime $startTime; @@ -55,6 +57,7 @@ public function __construct( if ($this->analyze !== null) { $this->columnStatistics = $this->analyze->collectColumnStatistics() ? new Columns() : null; $this->schema = $this->analyze->collectSchema() ? new Schema() : null; + $this->sources = $this->analyze->collectSourceStatistics() ? new SourceRows() : null; } gc_collect_cycles(); @@ -92,6 +95,14 @@ public function capture(Rows $rows): void } } + /** + * Handed to the planner, so the pipelines that read a source count its rows. + */ + public function sources(): ?SourceRows + { + return $this->sources; + } + /** * @return (T is Analyze|true ? Report : null) */ @@ -112,6 +123,7 @@ public function report(): ?Report $this->memory, $this->columnStatistics, ), + $this->sources?->statistics(), ); } } diff --git a/src/core/etl/src/Flow/ETL/Optimizer.php b/src/core/etl/src/Flow/ETL/Optimizer.php index 6095cde1d0..507fc28e28 100644 --- a/src/core/etl/src/Flow/ETL/Optimizer.php +++ b/src/core/etl/src/Flow/ETL/Optimizer.php @@ -9,6 +9,7 @@ use Flow\ETL\Optimizer\Rule; use Flow\ETL\Optimizer\Rule\CombineLimits; use Flow\ETL\Optimizer\Rule\CombineSortAndLimit; +use Flow\ETL\Optimizer\Rule\CountFromStatistics; use Flow\ETL\Optimizer\Rule\PushFilterIntoSource; use Flow\ETL\Optimizer\Rule\PushLimitIntoSource; use Flow\ETL\Plan\LogicalPlan; @@ -37,6 +38,7 @@ public static function default(): self new CombineSortAndLimit(), new PushLimitIntoSource(), new PushFilterIntoSource(), + new CountFromStatistics(), ); } diff --git a/src/core/etl/src/Flow/ETL/Optimizer/Rule/CountFromStatistics.php b/src/core/etl/src/Flow/ETL/Optimizer/Rule/CountFromStatistics.php new file mode 100644 index 0000000000..f9bf766b08 --- /dev/null +++ b/src/core/etl/src/Flow/ETL/Optimizer/Rule/CountFromStatistics.php @@ -0,0 +1,42 @@ +root instanceof Result ? $plan->root->children()[0] : null; + $read = $count instanceof Count ? $count->children()[0] : null; + + if (!$read instanceof Read || $read->limit() !== null || !$read->pathFilter() instanceof OnlyFiles) { + return $plan; + } + + $rows = $read->extractor()->statistics()->rows; + + if ($rows->atMost === null || $rows->atMost !== $rows->estimate || $rows->relativeError !== 0.0) { + return $plan; + } + + return new LogicalPlan(new Result(new Read(from_rows((new CountingProcessor())->rows($rows->atMost))))); + } +} diff --git a/src/core/etl/src/Flow/ETL/Plan/Explain/StatisticsLine.php b/src/core/etl/src/Flow/ETL/Plan/Explain/StatisticsLine.php new file mode 100644 index 0000000000..31d734b72a --- /dev/null +++ b/src/core/etl/src/Flow/ETL/Plan/Explain/StatisticsLine.php @@ -0,0 +1,66 @@ +isUnknown($statistics->rows) && $this->isUnknown($statistics->size)) { + return null; + } + + return ( + 'rows ' . $this->cardinality($statistics->rows) . ' · size ' . $this->cardinality($statistics->size, ' B') + ); + } + + public function cardinality(Cardinality $cardinality, string $unit = ''): string + { + $atMost = $cardinality->atMost; + $estimate = $cardinality->estimate; + + if ($estimate === null) { + return $atMost === null ? 'unknown' : '≤ ' . $this->number($atMost) . $unit; + } + + if ($atMost === $estimate && $cardinality->relativeError === 0.0) { + return 'exact ' . $this->number($estimate) . $unit; + } + + $approximately = '~' . $this->number($estimate) . $unit . ' ±' . $this->percent($cardinality->relativeError); + + return $atMost === null ? $approximately : $approximately . ' (≤ ' . $this->number($atMost) . $unit . ')'; + } + + public function percent(?float $error): string + { + return match (true) { + $error === null => 'unknown', + is_infinite($error) => '∞', + default => round($error * 100, 2) . '%', + }; + } + + public function number(int $number): string + { + return number_format($number, 0, '', ' '); + } + + public function isUnknown(Cardinality $cardinality): bool + { + return $cardinality->atMost === null && $cardinality->estimate === null; + } +} diff --git a/src/core/etl/src/Flow/ETL/Plan/Explain/StepDetails.php b/src/core/etl/src/Flow/ETL/Plan/Explain/StepDetails.php index 7738770a65..26ad2c6fd6 100644 --- a/src/core/etl/src/Flow/ETL/Plan/Explain/StepDetails.php +++ b/src/core/etl/src/Flow/ETL/Plan/Explain/StepDetails.php @@ -39,6 +39,7 @@ public function __construct( private Details $details = new Details(), private Condition $condition = new Condition(), + private StatisticsLine $statistics = new StatisticsLine(), ) {} /** @@ -105,10 +106,21 @@ public function settings(Extractor|Loader|Processor|Transformer $step): array : ['Constraints: ' . implode(', ', array_map($this->details->name(...), $step->constraints))], $step instanceof CollectingProcessor => $step->declared === null ? [] : ['Schema: declared'], $step instanceof OffsetProcessor => ['Skip: ' . $step->offset], + $step instanceof Extractor => $this->extractor($step), default => [], }; } + /** + * @return list + */ + public function extractor(Extractor $extractor): array + { + $statistics = $this->statistics->of($extractor->statistics()); + + return $statistics === null ? [] : ['Statistics: ' . $statistics]; + } + /** * @return list */ diff --git a/src/core/etl/src/Flow/ETL/Plan/Node/Count.php b/src/core/etl/src/Flow/ETL/Plan/Node/Count.php new file mode 100644 index 0000000000..eb2daa625e --- /dev/null +++ b/src/core/etl/src/Flow/ETL/Plan/Node/Count.php @@ -0,0 +1,51 @@ + + */ + public function children(): array + { + return [$this->input]; + } + + public function withChildren(array $children): self + { + return $children[0] === $this->input ? $this : new self($children[0]); + } + + public function rowCount(): RowCount + { + return RowCount::reducing; + } + + public function transparency(): Transparency + { + return Transparency::opaque; + } + + public function materialization(): Materialization + { + return Materialization::blocking; + } + + public function redefines(): Redefined + { + return Redefined::unknown(); + } +} diff --git a/src/core/etl/src/Flow/ETL/Plan/Trigger.php b/src/core/etl/src/Flow/ETL/Plan/Trigger.php index f724ce89e4..1cb3c46060 100644 --- a/src/core/etl/src/Flow/ETL/Plan/Trigger.php +++ b/src/core/etl/src/Flow/ETL/Plan/Trigger.php @@ -15,12 +15,15 @@ enum Trigger case run; + case count; + public function plan(Node $root, Sinks $sinks = new Sinks()): LogicalPlan { $all = $sinks->all(); $consumers = match ($this) { self::rows => [new Node\Result($root), ...$all], + self::count => [new Node\Result(new Node\Count($root)), ...$all], self::run => ($all[0] ?? null) instanceof Node\Write && $all[0]->children()[0] === $root ? $all : [new Node\Result($root), ...$all], diff --git a/src/core/etl/src/Flow/ETL/Planner.php b/src/core/etl/src/Flow/ETL/Planner.php index 29141168ef..d195a44a15 100644 --- a/src/core/etl/src/Flow/ETL/Planner.php +++ b/src/core/etl/src/Flow/ETL/Planner.php @@ -6,6 +6,7 @@ use Flow\ETL\Exception\SchemaNotDerivableException; use Flow\ETL\Executor\PhysicalPlan; +use Flow\ETL\Executor\SourceRows; use Flow\ETL\Plan\LogicalPlan; use Flow\ETL\Plan\Node; use Flow\ETL\Planner\NodeTranslator; @@ -24,17 +25,19 @@ public function __construct( * The optimizer rewrites the plan first, so the one walk that translates and binds sees the final tree. A frame * this one joined is part of the plan: nothing is planned with another frame's planner. * A failure is reported as a started and failed DataFrame span of $context, then rethrown. + * + * @param null|SourceRows $sources counts the rows every source yields, a joined frame's included */ - public function plan(LogicalPlan $logical, FlowContext $context): PhysicalPlan + public function plan(LogicalPlan $logical, FlowContext $context, ?SourceRows $sources = null): PhysicalPlan { $planned = new PlannedNodes(); try { $logical = $this->optimizer->optimize($logical, $context); - $this->node($logical->root, $context, $planned); + $this->node($logical->root, $context, $planned, $sources); - return (new PipelineSplit())->of($logical, $planned, $context); + return (new PipelineSplit())->of($logical, $planned, $context, $sources); } catch (Throwable $e) { // a failure is never reported without a start $context->telemetry()->dataFrameStarted($context); @@ -48,8 +51,12 @@ public function plan(LogicalPlan $logical, FlowContext $context): PhysicalPlan * Plans $node and everything under it, each node once by identity: translated to steps, then bound to the * schema of its input. A prefix several consumers share is planned once; a joined frame is planned apart. */ - public function node(Node $node, FlowContext $context, PlannedNodes $planned): PlannedNode - { + public function node( + Node $node, + FlowContext $context, + PlannedNodes $planned, + ?SourceRows $sources = null, + ): PlannedNode { if ($planned->has($node)) { return $planned->of($node); } @@ -57,7 +64,7 @@ public function node(Node $node, FlowContext $context, PlannedNodes $planned): P $inputs = []; foreach ($node instanceof Node\JoinsFrame ? [$node->children()[0]] : $node->children() as $child) { - $inputs[] = $this->node($child, $context, $planned); + $inputs[] = $this->node($child, $context, $planned, $sources); } $frames = []; @@ -65,8 +72,8 @@ public function node(Node $node, FlowContext $context, PlannedNodes $planned): P if ($node instanceof Node\JoinsFrame) { // the joined frame runs as a pipeline of its own, so a node it shares with this plan needs its own steps $right = new PlannedNodes(); - $this->node($node->right(), $context, $right); - $frames[] = (new PipelineSplit())->of(new LogicalPlan($node->right()), $right, $context); + $this->node($node->right(), $context, $right, $sources); + $frames[] = (new PipelineSplit())->of(new LogicalPlan($node->right()), $right, $context, $sources); } $steps = NodeTranslator::toSteps($node, $context, $frames); diff --git a/src/core/etl/src/Flow/ETL/Planner/NodeTranslator.php b/src/core/etl/src/Flow/ETL/Planner/NodeTranslator.php index be3d32668e..354deaf9cb 100644 --- a/src/core/etl/src/Flow/ETL/Planner/NodeTranslator.php +++ b/src/core/etl/src/Flow/ETL/Planner/NodeTranslator.php @@ -19,6 +19,7 @@ use Flow\ETL\Processor\CachingProcessor; use Flow\ETL\Processor\CollectingProcessor; use Flow\ETL\Processor\ConstrainedProcessor; +use Flow\ETL\Processor\CountingProcessor; use Flow\ETL\Processor\OffsetProcessor; use Flow\ETL\Processor\TopNProcessor; use Flow\ETL\Processor\VoidProcessor; @@ -71,6 +72,7 @@ public static function toSteps(Node $node, FlowContext $context, array $frames): $node instanceof Node\Collect => [new CollectingProcessor()], $node instanceof Node\CollectRefs => [new CollectReferencesTransformer($node->references)], $node instanceof Node\Constrain => [new ConstrainedProcessor($node->constraints)], + $node instanceof Node\Count => [new CountingProcessor()], $node instanceof Node\CrossJoin => [new CrossJoinRowsTransformer( $frames[0], $context->config->executor(), diff --git a/src/core/etl/src/Flow/ETL/Planner/PipelineSplit.php b/src/core/etl/src/Flow/ETL/Planner/PipelineSplit.php index 55f761b675..fb5645b886 100644 --- a/src/core/etl/src/Flow/ETL/Planner/PipelineSplit.php +++ b/src/core/etl/src/Flow/ETL/Planner/PipelineSplit.php @@ -10,6 +10,7 @@ use Flow\ETL\Executor\Pipeline; use Flow\ETL\Executor\Raw; use Flow\ETL\Executor\Segments; +use Flow\ETL\Executor\SourceRows; use Flow\ETL\FlowContext; use Flow\ETL\Plan\LogicalPlan; use Flow\ETL\Plan\Materialization; @@ -25,11 +26,16 @@ * @param LogicalPlan $logical a whole frame's plan, or a join's right side * @param PlannedNodes $planned every node of the plan already planned * @param FlowContext $context the frame this plan belongs to + * @param null|SourceRows $sources handed to the pipeline that reads the source, when the run counts its rows * * @throws InvalidLogicException when the spine's leaf is not a Read, or a sink shares no node with the spine */ - public function of(LogicalPlan $logical, PlannedNodes $planned, FlowContext $context): PhysicalPlan - { + public function of( + LogicalPlan $logical, + PlannedNodes $planned, + FlowContext $context, + ?SourceRows $sources = null, + ): PhysicalPlan { $root = $logical->root; $top = $logical->spine(); $spine = []; @@ -70,14 +76,15 @@ public function of(LogicalPlan $logical, PlannedNodes $planned, FlowContext $con } if ($node->materialization() === Materialization::blocking && $node !== $top) { - $input = new Pipeline($id++, $segments, $context, $input, $limit, $pathFilter); + $input = new Pipeline($id++, $segments, $context, $input, $limit, $pathFilter, $sources); $segments = new Segments(); $limit = null; $pathFilter = new OnlyFiles(); + $sources = null; } } - $pipeline = new Pipeline($id, $segments, $context, $input, $limit, $pathFilter); + $pipeline = new Pipeline($id, $segments, $context, $input, $limit, $pathFilter, $sources); $refusal = $planned->refusal(); return $refusal === null diff --git a/src/core/etl/src/Flow/ETL/Processor/CountingProcessor.php b/src/core/etl/src/Flow/ETL/Processor/CountingProcessor.php new file mode 100644 index 0000000000..485304d5c5 --- /dev/null +++ b/src/core/etl/src/Flow/ETL/Processor/CountingProcessor.php @@ -0,0 +1,49 @@ +schema()); + } + + public function process(Generator $rows, FlowContext $context): Generator + { + $count = 0; + + foreach ($rows as $batch) { + $count += $batch->count(); + } + + yield $this->rows($count); + } + + /** + * The one row a count hands out, whether it was counted or known upfront. + */ + public function rows(int $count): Rows + { + return rows($this->schema(), row(['count' => $count])); + } + + public function schema(): Schema + { + return schema(int_schema('count')); + } +} diff --git a/src/core/etl/tests/Flow/ETL/Tests/Integration/DataFrame/AnalyzeTest.php b/src/core/etl/tests/Flow/ETL/Tests/Integration/DataFrame/AnalyzeTest.php index 742c6c1c55..5d97461e19 100644 --- a/src/core/etl/tests/Flow/ETL/Tests/Integration/DataFrame/AnalyzeTest.php +++ b/src/core/etl/tests/Flow/ETL/Tests/Integration/DataFrame/AnalyzeTest.php @@ -6,10 +6,14 @@ use DateTimeImmutable; use Flow\Clock\FakeClock; +use Flow\ETL\Cardinality; use Flow\ETL\Dataset\Report; +use Flow\ETL\Dataset\SourceStatistics; use Flow\ETL\Dataset\Statistics\Columns; use Flow\ETL\Dataset\Statistics\HighResolutionTime; +use Flow\ETL\Extractor\Statistics; use Flow\ETL\FlowContext; +use Flow\ETL\Join\Join; use Flow\ETL\Rows; use Flow\ETL\Tests\Double\InlineLoader; use Flow\ETL\Tests\FlowIntegrationTestCase; @@ -22,6 +26,9 @@ use function Flow\ETL\DSL\from_array; use function Flow\ETL\DSL\infer_schema; use function Flow\ETL\DSL\int_schema; +use function Flow\ETL\DSL\join_on; +use function Flow\ETL\DSL\lit; +use function Flow\ETL\DSL\ref; use function Flow\ETL\DSL\schema; use function Flow\ETL\DSL\str_schema; @@ -169,6 +176,29 @@ public function test_analyzing_partitioned_datasets(): void ); } + public function test_analyzing_source_statistics_counts_every_source_apart_from_the_output(): void + { + $report = df() + ->read(from_array([['id' => 1], ['id' => 2], ['id' => 3], ['id' => 4]])) + ->filter(ref('id')->greaterThan(lit(2))) + ->join( + df()->read(from_array([['id' => 3], ['id' => 4], ['id' => 5]])), + join_on(['id' => 'id']), + Join::inner, + ) + ->run(analyze: analyze()->withSourceStatistics()); + + static::assertNotNull($report); + static::assertSame(2, $report->statistics()->totalRows()); + static::assertEquals( + [ + new SourceStatistics('ArrayExtractor', new Statistics(Cardinality::exact(3)), 3), + new SourceStatistics('ArrayExtractor', new Statistics(Cardinality::exact(4)), 4), + ], + $report->sources(), + ); + } + public function test_run_explicit_analyze_overrides_config(): void { $config = config_builder()->analyze(analyze())->build(); diff --git a/src/core/etl/tests/Flow/ETL/Tests/Unit/Config/Telemetry/TelemetryContextTest.php b/src/core/etl/tests/Flow/ETL/Tests/Unit/Config/Telemetry/TelemetryContextTest.php index cd5e9c28b8..64da37b0d7 100644 --- a/src/core/etl/tests/Flow/ETL/Tests/Unit/Config/Telemetry/TelemetryContextTest.php +++ b/src/core/etl/tests/Flow/ETL/Tests/Unit/Config/Telemetry/TelemetryContextTest.php @@ -10,6 +10,7 @@ use Flow\ETL\Loader\StreamLoader; use Flow\ETL\Optimizer\Rule\CombineLimits; use Flow\ETL\Optimizer\Rule\CombineSortAndLimit; +use Flow\ETL\Optimizer\Rule\CountFromStatistics; use Flow\ETL\Optimizer\Rule\PushFilterIntoSource; use Flow\ETL\Optimizer\Rule\PushLimitIntoSource; use Flow\ETL\Tests\Context\MemoryTelemetryContext; @@ -970,7 +971,13 @@ public function test_optimizer_rules_are_logged(): void static::assertCount(1, $debugLogs); static::assertSame( - [CombineLimits::class, CombineSortAndLimit::class, PushLimitIntoSource::class, PushFilterIntoSource::class], + [ + CombineLimits::class, + CombineSortAndLimit::class, + PushLimitIntoSource::class, + PushFilterIntoSource::class, + CountFromStatistics::class, + ], $debugLogs[0]->record->attributes->get('optimizer_rules'), ); } diff --git a/src/core/etl/tests/Flow/ETL/Tests/Unit/DataFrameTest.php b/src/core/etl/tests/Flow/ETL/Tests/Unit/DataFrameTest.php index bab2432c9e..dc403af5a5 100644 --- a/src/core/etl/tests/Flow/ETL/Tests/Unit/DataFrameTest.php +++ b/src/core/etl/tests/Flow/ETL/Tests/Unit/DataFrameTest.php @@ -148,6 +148,41 @@ public function test_count(): void static::assertSame(5, $count); } + public function test_count_of_a_frame_that_yields_no_rows_is_zero(): void + { + static::assertSame( + 0, + df() + ->read(from_array([['id' => 1], ['id' => 2]])) + ->filter(lit(false)) + ->count(), + ); + } + + public function test_count_counts_the_rows_left_after_a_filter(): void + { + static::assertSame( + 2, + df() + ->read(from_array([['id' => 1], ['id' => 2], ['id' => 3]])) + ->filter(ref('id')->greaterThan(lit(1))) + ->count(), + ); + } + + public function test_count_still_writes_the_frames_sinks(): void + { + $memory = new ArrayMemory(); + + $count = df() + ->read(from_array([['id' => 1], ['id' => 2]])) + ->write(to_memory($memory)) + ->count(); + + static::assertSame(2, $count); + static::assertSame([['id' => 1], ['id' => 2]], $memory->dump()); + } + public function test_drop(): void { $rows = df() diff --git a/src/core/etl/tests/Flow/ETL/Tests/Unit/Dataset/SourceStatisticsTest.php b/src/core/etl/tests/Flow/ETL/Tests/Unit/Dataset/SourceStatisticsTest.php new file mode 100644 index 0000000000..d3aa3624c4 --- /dev/null +++ b/src/core/etl/tests/Flow/ETL/Tests/Unit/Dataset/SourceStatisticsTest.php @@ -0,0 +1,69 @@ +rowsError(), + 0.000_001, + ); + } + + public function test_an_exact_declaration_that_holds_has_no_error(): void + { + static::assertSame( + 0.0, + (new SourceStatistics('Source', new Statistics(Cardinality::exact(5)), 5))->rowsError(), + ); + } + + public function test_there_is_no_error_without_declared_rows(): void + { + static::assertNull((new SourceStatistics('Source', new Statistics(), 5))->rowsError()); + } + + public function test_an_upper_bound_alone_has_no_error(): void + { + static::assertNull((new SourceStatistics('Source', new Statistics(Cardinality::atMost(10)), 5))->rowsError()); + } + + public function test_an_estimate_of_zero_against_zero_rows_has_no_error(): void + { + static::assertSame( + 0.0, + (new SourceStatistics('Source', new Statistics(Cardinality::approximately(0)), 0))->rowsError(), + ); + } + + public function test_a_positive_estimate_against_zero_rows_is_infinitely_off(): void + { + static::assertSame( + INF, + (new SourceStatistics('Source', new Statistics(Cardinality::approximately(500)), 0))->rowsError(), + ); + } +} diff --git a/src/core/etl/tests/Flow/ETL/Tests/Unit/Executor/PipelineTest.php b/src/core/etl/tests/Flow/ETL/Tests/Unit/Executor/PipelineTest.php index 2a913c2d1a..c7da1b6421 100644 --- a/src/core/etl/tests/Flow/ETL/Tests/Unit/Executor/PipelineTest.php +++ b/src/core/etl/tests/Flow/ETL/Tests/Unit/Executor/PipelineTest.php @@ -6,6 +6,7 @@ use Flow\ETL\Executor\Pipeline; use Flow\ETL\Executor\Segments; +use Flow\ETL\Executor\SourceRows; use Flow\ETL\Tests\FlowTestCase; use Flow\ETL\Tests\Mother\NodeMother; use Flow\Filesystem\Path\Filter\OnlyFiles; @@ -22,7 +23,9 @@ public function test_accessors_return_the_values_it_was_built_with(): void $input = new Pipeline(0, new Segments(from_array([['id' => 1]])), $context); $filter = new RejectingFilter(); - $pipeline = new Pipeline(1, $segments, $context, $input, 3, $filter); + $sources = new SourceRows(); + + $pipeline = new Pipeline(1, $segments, $context, $input, 3, $filter, $sources); static::assertSame(1, $pipeline->id); static::assertSame($segments, $pipeline->segments()); @@ -30,6 +33,7 @@ public function test_accessors_return_the_values_it_was_built_with(): void static::assertSame($input, $pipeline->input()); static::assertSame(3, $pipeline->limit()); static::assertSame($filter, $pipeline->pathFilter()); + static::assertSame($sources, $pipeline->sources()); } public function test_a_leaf_pipeline_defaults_to_no_input_no_limit_and_only_files(): void @@ -39,5 +43,6 @@ public function test_a_leaf_pipeline_defaults_to_no_input_no_limit_and_only_file static::assertNull($pipeline->input()); static::assertNull($pipeline->limit()); static::assertInstanceOf(OnlyFiles::class, $pipeline->pathFilter()); + static::assertNull($pipeline->sources()); } } diff --git a/src/core/etl/tests/Flow/ETL/Tests/Unit/Executor/SourceRowsTest.php b/src/core/etl/tests/Flow/ETL/Tests/Unit/Executor/SourceRowsTest.php new file mode 100644 index 0000000000..194505b424 --- /dev/null +++ b/src/core/etl/tests/Flow/ETL/Tests/Unit/Executor/SourceRowsTest.php @@ -0,0 +1,100 @@ + 1], ['id' => 2], ['id' => 3]])->withBatchSize(2); + $sources = new SourceRows(); + + $batches = iterator_to_array($sources->count($extractor, $extractor->extract(flow_context())), false); + + static::assertEquals(iterator_to_array($extractor->extract(flow_context()), false), $batches); + static::assertEquals( + [new SourceStatistics('ArrayExtractor', new Statistics(Cardinality::exact(3)), 3)], + $sources->statistics(), + ); + } + + public function test_a_stop_reaches_the_source_and_ends_the_count(): void + { + $extractor = new CountingExtractor( + schema(int_schema('id')), + rows(schema(int_schema('id')), row(['id' => 1]), row(['id' => 2]), row(['id' => 3])), + ); + $extractor->withBatchSize(1); + $sources = new SourceRows(); + $counted = $sources->count($extractor, $extractor->extract(flow_context())); + + static::assertCount(1, $counted->current()); + $counted->send(Signal::STOP); + + static::assertFalse($counted->valid()); + static::assertSame(1, $extractor->batchesYielded); + static::assertSame(1, $sources->statistics()[0]->rows); + } + + public function test_a_source_read_twice_is_listed_once_with_both_counts(): void + { + $extractor = from_array([['id' => 1], ['id' => 2]]); + $sources = new SourceRows(); + + iterator_to_array($sources->count($extractor, $extractor->extract(flow_context()))); + iterator_to_array($sources->count($extractor, $extractor->extract(flow_context()))); + + static::assertCount(1, $sources->statistics()); + static::assertSame(4, $sources->statistics()[0]->rows); + } + + public function test_sources_are_listed_in_the_order_they_were_first_read(): void + { + $first = from_array([['id' => 1]]); + $second = from_array([['id' => 1], ['id' => 2]]); + $sources = new SourceRows(); + + iterator_to_array($sources->count($second, $second->extract(flow_context()))); + iterator_to_array($sources->count($first, $first->extract(flow_context()))); + + static::assertSame([2, 1], [$sources->statistics()[0]->rows, $sources->statistics()[1]->rows]); + } + + public function test_a_source_that_yields_nothing_is_listed_with_zero_rows(): void + { + $extractor = from_array([]); + $sources = new SourceRows(); + + iterator_to_array($sources->count($extractor, $extractor->extract(flow_context()))); + + static::assertSame(0, $sources->statistics()[0]->rows); + } + + public function test_a_source_never_read_is_not_listed(): void + { + $extractor = from_array([['id' => 1]]); + $sources = new SourceRows(); + + $sources->count($extractor, $extractor->extract(flow_context())); + + static::assertSame([], $sources->statistics()); + } +} diff --git a/src/core/etl/tests/Flow/ETL/Tests/Unit/Executor/StatisticsCollectorTest.php b/src/core/etl/tests/Flow/ETL/Tests/Unit/Executor/StatisticsCollectorTest.php index 684fdc62a3..145eba87f4 100644 --- a/src/core/etl/tests/Flow/ETL/Tests/Unit/Executor/StatisticsCollectorTest.php +++ b/src/core/etl/tests/Flow/ETL/Tests/Unit/Executor/StatisticsCollectorTest.php @@ -6,20 +6,25 @@ use DateTimeImmutable; use Flow\Clock\FakeClock; +use Flow\ETL\Cardinality; use Flow\ETL\Config\ConfigBuilder; use Flow\ETL\Dataset\Report; +use Flow\ETL\Dataset\SourceStatistics; use Flow\ETL\Dataset\Statistics\Columns; use Flow\ETL\Dataset\Statistics\HighResolutionTime; use Flow\ETL\Executor\StatisticsCollector; +use Flow\ETL\Extractor\Statistics; use Flow\ETL\Tests\FlowTestCase; use function Flow\ETL\DSL\analyze; use function Flow\ETL\DSL\flow_context; +use function Flow\ETL\DSL\from_array; use function Flow\ETL\DSL\int_schema; use function Flow\ETL\DSL\row; use function Flow\ETL\DSL\rows; use function Flow\ETL\DSL\schema; use function Flow\ETL\DSL\str_schema; +use function iterator_to_array; final class StatisticsCollectorTest extends FlowTestCase { @@ -171,6 +176,34 @@ public function test_report_returns_report_with_memory_consumption(): void static::assertGreaterThanOrEqual(0, $report->statistics()->memory->initial()->inBytes()); } + public function test_sources_are_counted_and_reported_when_enabled(): void + { + $collector = new StatisticsCollector(analyze()->withSourceStatistics(), flow_context()); + $extractor = from_array([['id' => 1], ['id' => 2]]); + $sources = $collector->sources(); + static::assertNotNull($sources); + + iterator_to_array($sources->count($extractor, $extractor->extract(flow_context()))); + + static::assertEquals( + [new SourceStatistics('ArrayExtractor', new Statistics(Cardinality::exact(2)), 2)], + $collector->report()?->sources(), + ); + } + + public function test_sources_are_not_counted_when_not_enabled(): void + { + $collector = new StatisticsCollector(analyze(), flow_context()); + + static::assertNull($collector->sources()); + static::assertNull($collector->report()?->sources()); + } + + public function test_sources_are_not_counted_when_analyze_is_false(): void + { + static::assertNull((new StatisticsCollector(false, flow_context()))->sources()); + } + public function test_schema_is_null_when_not_enabled(): void { $collector = new StatisticsCollector(analyze(), flow_context()); diff --git a/src/core/etl/tests/Flow/ETL/Tests/Unit/Optimizer/Rule/CountFromStatisticsTest.php b/src/core/etl/tests/Flow/ETL/Tests/Unit/Optimizer/Rule/CountFromStatisticsTest.php new file mode 100644 index 0000000000..de5434b921 --- /dev/null +++ b/src/core/etl/tests/Flow/ETL/Tests/Unit/Optimizer/Rule/CountFromStatisticsTest.php @@ -0,0 +1,171 @@ +apply( + df()->read(from_array([['id' => 1], ['id' => 2], ['id' => 3]]))->explain(Trigger::count)->logical, + NodeMother::context(), + ); + + static::assertEquals( + from_rows(rows(schema(int_schema('count')), row(['count' => 3]))), + $plan->source()->extractor(), + ); + } + + public function test_a_count_over_a_plain_sequence_read_reads_the_number_instead(): void + { + $plan = (new CountFromStatistics())->apply( + df()->read(from_sequence_number('id', 1, 10))->explain(Trigger::count)->logical, + NodeMother::context(), + ); + + static::assertEquals( + from_rows(rows(schema(int_schema('count')), row(['count' => 10]))), + $plan->source()->extractor(), + ); + } + + public function test_a_count_over_a_plain_parquet_read_reads_the_number_instead(): void + { + $memory = memory_filesystem(); + df() + ->read(from_sequence_number('id', 1, 25)) + ->write(to_parquet(path('memory://count/file.parquet'), filesystem: $memory)) + ->run(); + + $plan = (new CountFromStatistics())->apply( + df() + ->read(from_parquet(path('memory://count/file.parquet'), filesystem: $memory)) + ->explain(Trigger::count) + ->logical, + NodeMother::context(), + ); + + static::assertEquals( + from_rows(rows(schema(int_schema('count')), row(['count' => 25]))), + $plan->source()->extractor(), + ); + } + + public function test_a_count_over_a_plain_floe_read_reads_the_number_instead(): void + { + $memory = memory_filesystem(); + df() + ->read(from_sequence_number('id', 1, 25)) + ->write(to_floe(path('memory://count/file.floe'), filesystem: $memory)) + ->run(); + + $plan = (new CountFromStatistics())->apply( + df() + ->read(from_floe(path('memory://count/file.floe'), filesystem: $memory)) + ->explain(Trigger::count) + ->logical, + NodeMother::context(), + ); + + static::assertEquals( + from_rows(rows(schema(int_schema('count')), row(['count' => 25]))), + $plan->source()->extractor(), + ); + } + + public static function inexact_rows(): Generator + { + yield 'approximate' => [Cardinality::approximately(10)]; + yield 'estimate equal to its bound, with an error' => [new Cardinality(10, 10)]; + yield 'upper bound alone' => [Cardinality::atMost(10)]; + yield 'unknown' => [Cardinality::unknown()]; + } + + #[DataProvider('inexact_rows')] + public function test_a_source_that_does_not_know_its_rows_exactly_is_counted_by_running(Cardinality $rows): void + { + $plan = Trigger::count->plan(NodeMother::read(new DeclaringExtractor(new Statistics($rows)))); + + static::assertSame($plan, (new CountFromStatistics())->apply($plan, NodeMother::context())); + } + + public function test_a_read_with_a_pushed_limit_is_counted_by_running(): void + { + $plan = Trigger::count->plan(NodeMother::read()->withLimit(1)); + + static::assertSame($plan, (new CountFromStatistics())->apply($plan, NodeMother::context())); + } + + public function test_a_read_with_a_pushed_partition_filter_is_counted_by_running(): void + { + $plan = Trigger::count->plan(NodeMother::read()->withPathFilter(new RejectingFilter())); + + static::assertSame($plan, (new CountFromStatistics())->apply($plan, NodeMother::context())); + } + + public static function frames_counted_by_running(): Generator + { + yield 'filter' => [df()->read(from_array([['id' => 1], ['id' => 2]]))->filter(ref('id')->equals(lit(1)))]; + yield 'limit' => [df()->read(from_array([['id' => 1], ['id' => 2]]))->limit(1)]; + yield 'join' => [df() + ->read(from_array([['id' => 1]])) + ->join(df()->read(from_array([['id' => 1]])), join_on(['id' => 'id']))]; + yield 'a transform that changes the rows' => [df() + ->read(from_array([['id' => 1], ['id' => 1]])) + ->dropDuplicates('id')]; + yield 'a transform that keeps the rows' => [df() + ->read(from_array([['id' => 1]])) + ->withEntry('copy', ref('id'))]; + yield 'a sink' => [df()->read(from_array([['id' => 1]]))->write(to_memory(new ArrayMemory()))]; + } + + #[DataProvider('frames_counted_by_running')] + public function test_a_frame_that_does_more_than_read_is_counted_by_running(DataFrame $frame): void + { + $plan = $frame->explain(Trigger::count)->logical; + + static::assertSame($plan, (new CountFromStatistics())->apply($plan, NodeMother::context())); + } + + public function test_a_plan_that_does_not_count_is_left_alone(): void + { + $plan = df()->read(from_array([['id' => 1]]))->explain(Trigger::rows)->logical; + + static::assertSame($plan, (new CountFromStatistics())->apply($plan, NodeMother::context())); + } +} diff --git a/src/core/etl/tests/Flow/ETL/Tests/Unit/OptimizerTest.php b/src/core/etl/tests/Flow/ETL/Tests/Unit/OptimizerTest.php index a85b192b93..ab27e626d8 100644 --- a/src/core/etl/tests/Flow/ETL/Tests/Unit/OptimizerTest.php +++ b/src/core/etl/tests/Flow/ETL/Tests/Unit/OptimizerTest.php @@ -9,6 +9,7 @@ use Flow\ETL\Optimizer; use Flow\ETL\Optimizer\Rule\CombineLimits; use Flow\ETL\Optimizer\Rule\CombineSortAndLimit; +use Flow\ETL\Optimizer\Rule\CountFromStatistics; use Flow\ETL\Optimizer\Rule\PushFilterIntoSource; use Flow\ETL\Optimizer\Rule\PushLimitIntoSource; use Flow\ETL\Tests\Double\RecordingRule; @@ -55,10 +56,16 @@ public function test_an_optimizer_without_rules_returns_the_plan_it_was_given(): static::assertSame($plan, (new Optimizer())->optimize($plan, NodeMother::context())); } - public function test_default_registers_combine_limits_combine_sort_and_limit_push_limit_then_push_filter_into_source(): void + public function test_default_registers_combine_limits_combine_sort_and_limit_push_limit_push_filter_then_count_from_statistics(): void { static::assertSame( - [CombineLimits::class, CombineSortAndLimit::class, PushLimitIntoSource::class, PushFilterIntoSource::class], + [ + CombineLimits::class, + CombineSortAndLimit::class, + PushLimitIntoSource::class, + PushFilterIntoSource::class, + CountFromStatistics::class, + ], array_map(static fn($rule) => $rule::class, Optimizer::default()->rules()), ); } @@ -66,7 +73,7 @@ public function test_default_registers_combine_limits_combine_sort_and_limit_pus public function test_without_drops_the_named_rule_and_keeps_the_rest(): void { static::assertSame( - [CombineLimits::class, CombineSortAndLimit::class, PushFilterIntoSource::class], + [CombineLimits::class, CombineSortAndLimit::class, PushFilterIntoSource::class, CountFromStatistics::class], array_map( static fn($rule) => $rule::class, Optimizer::default()->without(PushLimitIntoSource::class)->rules(), diff --git a/src/core/etl/tests/Flow/ETL/Tests/Unit/Plan/Explain/PhysicalOutlineTest.php b/src/core/etl/tests/Flow/ETL/Tests/Unit/Plan/Explain/PhysicalOutlineTest.php index bab0cb46aa..bce4212b75 100644 --- a/src/core/etl/tests/Flow/ETL/Tests/Unit/Plan/Explain/PhysicalOutlineTest.php +++ b/src/core/etl/tests/Flow/ETL/Tests/Unit/Plan/Explain/PhysicalOutlineTest.php @@ -25,7 +25,10 @@ public function test_a_plan_without_a_blocking_step_is_one_pipeline(): void static::assertSame(['Columns: id'], $root->lines); static::assertCount(1, $root->children); static::assertSame('Pipeline #0', $root->children[0]->name); - static::assertSame(['Extractor: ArrayExtractor'], $root->children[0]->lines); + static::assertSame( + ['Extractor: ArrayExtractor', ' Statistics: rows exact 1 · size unknown'], + $root->children[0]->lines, + ); static::assertSame([], $root->children[0]->children); } @@ -37,6 +40,7 @@ public function test_every_step_is_listed_in_the_order_the_rows_reach_it(): void │ Columns: id └─ Pipeline #0 Extractor: ArrayExtractor + Statistics: rows exact 1 · size unknown Processor: CollectingProcessor Schema: declared Loader: StreamLoader @@ -62,6 +66,7 @@ public function test_a_joined_frame_is_a_plan_of_its_own_under_the_step_that_rea │ Loader: StreamLoader └─ Pipeline #0 │ Extractor: ArrayExtractor + │ Statistics: rows exact 1 · size unknown │ Processor: HashJoinProcessor │ Join: left │ On: id = id @@ -71,6 +76,7 @@ public function test_a_joined_frame_is_a_plan_of_its_own_under_the_step_that_rea │ Batch: 1000 └─ Right side: Pipeline #0 Extractor: ArrayExtractor + Statistics: rows exact 1 · size unknown PLAN, (new TreeLayout())->render((new PhysicalOutline())->of(PhysicalPlanMother::of( data_frame() @@ -92,7 +98,12 @@ public function test_a_pushed_limit_is_listed_under_the_source_that_was_handed_i )); static::assertSame( - ['Extractor: ArrayExtractor', ' Limit: 1', 'Transformer: LimitTransformer'], + [ + 'Extractor: ArrayExtractor', + ' Statistics: rows exact 2 · size unknown', + ' Limit: 1', + 'Transformer: LimitTransformer', + ], $root->children[0]->lines, ); } diff --git a/src/core/etl/tests/Flow/ETL/Tests/Unit/Plan/Explain/StatisticsLineTest.php b/src/core/etl/tests/Flow/ETL/Tests/Unit/Plan/Explain/StatisticsLineTest.php new file mode 100644 index 0000000000..70d8da6a20 --- /dev/null +++ b/src/core/etl/tests/Flow/ETL/Tests/Unit/Plan/Explain/StatisticsLineTest.php @@ -0,0 +1,121 @@ +cardinality(Cardinality::exact(1_000))); + } + + public function test_an_upper_bound_alone_is_written_as_at_most(): void + { + static::assertSame('≤ 25 000', (new StatisticsLine())->cardinality(Cardinality::atMost(25_000))); + } + + public function test_an_estimate_alone_carries_its_relative_error_in_percent(): void + { + static::assertSame('~20 000 ±50%', (new StatisticsLine())->cardinality(Cardinality::approximately(20_000))); + static::assertSame( + '~20 000 ±12.5%', + (new StatisticsLine())->cardinality(Cardinality::approximately(20_000, 0.125)), + ); + } + + public function test_an_estimate_with_an_error_of_zero_and_no_bound_is_not_exact(): void + { + static::assertSame('~7 ±0%', (new StatisticsLine())->cardinality(Cardinality::approximately(7, 0.0))); + } + + public function test_an_estimate_under_a_bound_lists_both(): void + { + static::assertSame( + '~800 ±25% (≤ 1 000)', + (new StatisticsLine())->cardinality(new Cardinality(1_000, 800, 0.25)), + ); + } + + public function test_an_estimate_equal_to_its_bound_is_not_exact_while_it_carries_an_error(): void + { + static::assertSame('~1 000 ±50% (≤ 1 000)', (new StatisticsLine())->cardinality(new Cardinality(1_000, 1_000))); + } + + public function test_an_unknown_count_is_written_as_unknown(): void + { + static::assertSame('unknown', (new StatisticsLine())->cardinality(Cardinality::unknown())); + } + + public function test_numbers_are_grouped_in_thousands(): void + { + static::assertSame('exact 0', (new StatisticsLine())->cardinality(Cardinality::exact(0))); + static::assertSame('exact 999', (new StatisticsLine())->cardinality(Cardinality::exact(999))); + static::assertSame( + 'exact 1 234 567 890', + (new StatisticsLine())->cardinality(Cardinality::exact(1_234_567_890)), + ); + } + + public function test_the_unit_follows_every_number(): void + { + static::assertSame('~800 B ±25% (≤ 1 000 B)', (new StatisticsLine())->cardinality( + new Cardinality(1_000, 800, 0.25), + ' B', + )); + static::assertSame('≤ 1 000 B', (new StatisticsLine())->cardinality(Cardinality::atMost(1_000), ' B')); + static::assertSame('exact 309 188 B', (new StatisticsLine())->cardinality(Cardinality::exact(309_188), ' B')); + static::assertSame('unknown', (new StatisticsLine())->cardinality(Cardinality::unknown(), ' B')); + } + + public function test_a_relative_error_is_written_in_percent(): void + { + static::assertSame('0%', (new StatisticsLine())->percent(0.0)); + static::assertSame('45%', (new StatisticsLine())->percent(0.45)); + static::assertSame('33.33%', (new StatisticsLine())->percent(1 / 3)); + static::assertSame('250%', (new StatisticsLine())->percent(2.5)); + } + + public function test_an_infinite_relative_error_is_written_as_infinity(): void + { + static::assertSame('∞', (new StatisticsLine())->percent(INF)); + } + + public function test_a_missing_relative_error_is_written_as_unknown(): void + { + static::assertSame('unknown', (new StatisticsLine())->percent(null)); + } + + public function test_the_line_lists_rows_then_size_in_bytes(): void + { + static::assertSame( + 'rows exact 1 000 · size exact 309 188 B', + (new StatisticsLine())->of(new Statistics(Cardinality::exact(1_000), Cardinality::exact(309_188))), + ); + } + + public function test_the_line_lists_an_unknown_fact_next_to_a_known_one(): void + { + static::assertSame( + 'rows unknown · size exact 10 B', + (new StatisticsLine())->of(new Statistics(size: Cardinality::exact(10))), + ); + static::assertSame( + 'rows ≤ 5 · size unknown', + (new StatisticsLine())->of(new Statistics(rows: Cardinality::atMost(5))), + ); + } + + public function test_there_is_no_line_when_both_facts_are_unknown(): void + { + static::assertNull((new StatisticsLine())->of(new Statistics())); + } +} diff --git a/src/core/etl/tests/Flow/ETL/Tests/Unit/Plan/Explain/StepDetailsTest.php b/src/core/etl/tests/Flow/ETL/Tests/Unit/Plan/Explain/StepDetailsTest.php index cfdc09ae52..1515448c22 100644 --- a/src/core/etl/tests/Flow/ETL/Tests/Unit/Plan/Explain/StepDetailsTest.php +++ b/src/core/etl/tests/Flow/ETL/Tests/Unit/Plan/Explain/StepDetailsTest.php @@ -8,8 +8,10 @@ use Flow\ETL\Bucketing\HashBucketing; use Flow\ETL\Bucketing\NativeHasher; use Flow\ETL\Bucketing\Storage\MemoryBuckets; +use Flow\ETL\Cardinality; use Flow\ETL\Constraint\UniqueConstraint; use Flow\ETL\Executor; +use Flow\ETL\Extractor\Statistics; use Flow\ETL\GroupBy; use Flow\ETL\GroupBy\DeclaredPivotValues; use Flow\ETL\Join\Comparison\Equal; @@ -32,6 +34,7 @@ use Flow\ETL\Processor\TopNProcessor; use Flow\ETL\Processor\VoidProcessor; use Flow\ETL\Processor\WindowProcessor; +use Flow\ETL\Tests\Double\DeclaringExtractor; use Flow\ETL\Tests\FlowTestCase; use Flow\ETL\Tests\Mother\HashJoinProcessorMother; use Flow\ETL\Tests\Mother\PhysicalPlanMother; @@ -52,7 +55,10 @@ public function test_a_step_is_labelled_by_what_it_is(): void { $details = new StepDetails(); - static::assertSame(['Extractor: ArrayExtractor'], $details->lines(from_array([['id' => 1]]))); + static::assertSame( + ['Extractor: ArrayExtractor', ' Statistics: rows exact 1 · size unknown'], + $details->lines(from_array([['id' => 1]])), + ); static::assertSame(['Processor: VoidProcessor'], $details->lines(new VoidProcessor())); static::assertSame( ['Processor: BatchingProcessor', ' Batch: 100'], @@ -212,7 +218,22 @@ public function test_a_window_lists_the_column_it_writes_and_its_function(): voi public function test_a_step_the_explain_does_not_know_lists_nothing(): void { - static::assertSame([], (new StepDetails())->settings(from_array([['id' => 1]]))); + static::assertSame([], (new StepDetails())->settings(new VoidProcessor())); + } + + public function test_a_source_lists_the_statistics_it_declares(): void + { + static::assertSame( + ['Statistics: rows exact 1 000 · size ~309 188 B ±50%'], + (new StepDetails())->settings(new DeclaringExtractor( + new Statistics(Cardinality::exact(1_000), Cardinality::approximately(309_188)), + )), + ); + } + + public function test_a_source_that_declares_nothing_lists_no_statistics(): void + { + static::assertSame([], (new StepDetails())->settings(new DeclaringExtractor(new Statistics()))); } public function test_bucketing_lists_its_strategy_and_storage(): void diff --git a/src/core/etl/tests/Flow/ETL/Tests/Unit/Plan/Node/CountTest.php b/src/core/etl/tests/Flow/ETL/Tests/Unit/Plan/Node/CountTest.php new file mode 100644 index 0000000000..d5f51b1e21 --- /dev/null +++ b/src/core/etl/tests/Flow/ETL/Tests/Unit/Plan/Node/CountTest.php @@ -0,0 +1,48 @@ +withChildren([$input])); + } + + public function test_with_children_returns_a_new_instance_when_a_child_changes(): void + { + $input = NodeMother::read(); + $other = NodeMother::read(); + $node = new Count($input); + + $rebuilt = $node->withChildren([$other]); + + static::assertNotSame($node, $rebuilt); + static::assertInstanceOf(Count::class, $rebuilt); + static::assertSame([$other], $rebuilt->children()); + } + + public function test_declarations(): void + { + $input = NodeMother::read(); + $node = new Count($input); + + static::assertSame(RowCount::reducing, $node->rowCount()); + static::assertSame(Transparency::opaque, $node->transparency()); + static::assertSame(Materialization::blocking, $node->materialization()); + static::assertEquals(Redefined::unknown(), $node->redefines()); + } +} diff --git a/src/core/etl/tests/Flow/ETL/Tests/Unit/Plan/TriggerTest.php b/src/core/etl/tests/Flow/ETL/Tests/Unit/Plan/TriggerTest.php index 85a6b60716..41c1551fc8 100644 --- a/src/core/etl/tests/Flow/ETL/Tests/Unit/Plan/TriggerTest.php +++ b/src/core/etl/tests/Flow/ETL/Tests/Unit/Plan/TriggerTest.php @@ -5,6 +5,7 @@ namespace Flow\ETL\Tests\Unit\Plan; use Flow\ETL\Memory\ArrayMemory; +use Flow\ETL\Plan\Node\Count; use Flow\ETL\Plan\Node\Outputs; use Flow\ETL\Plan\Node\Result; use Flow\ETL\Plan\Node\Transaction; @@ -42,6 +43,26 @@ public function test_rows_with_sinks_is_the_result_then_every_sink_in_order(): v static::assertSame([$first, $second], [$root->children()[1], $root->children()[2]]); } + public function test_count_without_sinks_is_a_result_over_a_count_of_the_root(): void + { + $read = NodeMother::read(); + + $root = Trigger::count->plan($read)->root; + + static::assertEquals(new Result(new Count($read)), $root); + } + + public function test_count_with_sinks_keeps_every_sink_after_the_count(): void + { + $read = NodeMother::read(); + $write = new Write($read, to_memory(new ArrayMemory())); + + static::assertEquals( + new Outputs(new Result(new Count($read)), $write), + Trigger::count->plan($read, new Sinks($write))->root, + ); + } + public function test_run_with_one_write_over_the_root_makes_that_write_the_root(): void { $read = NodeMother::read(); diff --git a/src/core/etl/tests/Flow/ETL/Tests/Unit/Planner/NodeTranslatorTest.php b/src/core/etl/tests/Flow/ETL/Tests/Unit/Planner/NodeTranslatorTest.php index 37365d1768..be3b1624e9 100644 --- a/src/core/etl/tests/Flow/ETL/Tests/Unit/Planner/NodeTranslatorTest.php +++ b/src/core/etl/tests/Flow/ETL/Tests/Unit/Planner/NodeTranslatorTest.php @@ -17,6 +17,7 @@ use Flow\ETL\Plan\Node\Collect; use Flow\ETL\Plan\Node\CollectRefs; use Flow\ETL\Plan\Node\Constrain; +use Flow\ETL\Plan\Node\Count; use Flow\ETL\Plan\Node\CrossJoin; use Flow\ETL\Plan\Node\Discard; use Flow\ETL\Plan\Node\Distinct; @@ -47,6 +48,7 @@ use Flow\ETL\Processor\CachingProcessor; use Flow\ETL\Processor\CollectingProcessor; use Flow\ETL\Processor\ConstrainedProcessor; +use Flow\ETL\Processor\CountingProcessor; use Flow\ETL\Processor\GroupByAggregationProcessor; use Flow\ETL\Processor\HashJoinProcessor; use Flow\ETL\Processor\MemorySortProcessor; @@ -211,6 +213,17 @@ public function test_cross_join_steps_are_the_exact_list_in_order(): void ); } + public function test_count_steps_are_the_exact_list_in_order(): void + { + static::assertSame( + [CountingProcessor::class], + array_map( + static fn($step) => $step::class, + NodeTranslator::toSteps(new Count(NodeMother::read()), NodeMother::context(), []), + ), + ); + } + public function test_discard_steps_are_the_exact_list_in_order(): void { static::assertSame( diff --git a/src/core/etl/tests/Flow/ETL/Tests/Unit/Planner/PipelineSplitTest.php b/src/core/etl/tests/Flow/ETL/Tests/Unit/Planner/PipelineSplitTest.php index d7f0e9f2fc..3ec9d45a48 100644 --- a/src/core/etl/tests/Flow/ETL/Tests/Unit/Planner/PipelineSplitTest.php +++ b/src/core/etl/tests/Flow/ETL/Tests/Unit/Planner/PipelineSplitTest.php @@ -8,6 +8,7 @@ use Flow\ETL\Executor; use Flow\ETL\Executor\Raw; use Flow\ETL\Executor\SinkFeed; +use Flow\ETL\Executor\SourceRows; use Flow\ETL\Executor\TransactionalSinks; use Flow\ETL\Join\Join as JoinType; use Flow\ETL\Loader\ArrayLoader; @@ -667,4 +668,34 @@ public function test_the_leaf_pipeline_carries_the_reads_limit_and_path_filter() static::assertSame(5, $plan->root()->input()?->limit()); static::assertNull($plan->root()->limit()); } + + public function test_only_the_pipeline_that_reads_the_source_counts_its_rows(): void + { + $logical = new LogicalPlan(new Result(NodeMother::limit( + NodeMother::sort(NodeMother::read(from_array([['id' => 1]], schema(int_schema('id'))))), + 5, + ))); + $planned = new PlannedNodes(); + $sources = new SourceRows(); + (new Planner())->node($logical->root, NodeMother::context(), $planned); + + $plan = (new PipelineSplit())->of($logical, $planned, NodeMother::context(), $sources); + + static::assertSame($sources, $plan->root()->input()?->sources()); + static::assertNull($plan->root()->sources()); + } + + public function test_a_pipeline_counts_no_rows_unless_asked(): void + { + $logical = new LogicalPlan(new Result(NodeMother::read(from_array([['id' => 1]], schema(int_schema('id')))))); + $planned = new PlannedNodes(); + (new Planner())->node($logical->root, NodeMother::context(), $planned); + + static::assertNull( + (new PipelineSplit()) + ->of($logical, $planned, NodeMother::context()) + ->root() + ->sources(), + ); + } } diff --git a/src/core/etl/tests/Flow/ETL/Tests/Unit/PlannerTest.php b/src/core/etl/tests/Flow/ETL/Tests/Unit/PlannerTest.php index 5948767b8b..95c21713d2 100644 --- a/src/core/etl/tests/Flow/ETL/Tests/Unit/PlannerTest.php +++ b/src/core/etl/tests/Flow/ETL/Tests/Unit/PlannerTest.php @@ -6,11 +6,13 @@ use ArrayObject; use Flow\ETL\Adapter\CSV\CSVLoader; +use Flow\ETL\Dataset\SourceStatistics; use Flow\ETL\Exception\DataDependentSchemaException; use Flow\ETL\Exception\SchemaDefinitionNotFoundException; use Flow\ETL\Executor\Described; use Flow\ETL\Executor\Raw; use Flow\ETL\Executor\SinkFeed; +use Flow\ETL\Executor\SourceRows; use Flow\ETL\Join\Join as JoinType; use Flow\ETL\Loader\MemoryLoader; use Flow\ETL\Memory\ArrayMemory; @@ -279,6 +281,29 @@ public function test_a_joins_right_side_runs_with_the_outer_context(): void static::assertSame([$context], $recording->contexts); } + public function test_a_joins_right_side_counts_its_source_rows_too(): void + { + $node = new CrossJoin( + NodeMother::read(from_array([['id' => 1], ['id' => 2]], schema(int_schema('id')))), + df() + ->read(from_array([['id' => 1], ['id' => 2], ['id' => 3]], schema(int_schema('id')))) + ->explain() + ->logical + ->root, + 'r_', + ); + $context = NodeMother::context(config()); + $sources = new SourceRows(); + + $plan = (new Planner(Optimizer::default()))->plan(new LogicalPlan(new Result($node)), $context, $sources); + iterator_to_array($context->config->executor()->execute($plan)); + + static::assertSame( + [2, 3], + array_map(static fn(SourceStatistics $source): int => $source->rows, $sources->statistics()), + ); + } + public function test_a_read_frame_is_a_source_of_this_pipeline(): void { $extractor = from_data_frame(df()->read(from_csv(__DIR__ . '/../Fixtures/orders.csv'))->select('id')); diff --git a/src/core/etl/tests/Flow/ETL/Tests/Unit/Processor/CountingProcessorTest.php b/src/core/etl/tests/Flow/ETL/Tests/Unit/Processor/CountingProcessorTest.php new file mode 100644 index 0000000000..0c33f90c24 --- /dev/null +++ b/src/core/etl/tests/Flow/ETL/Tests/Unit/Processor/CountingProcessorTest.php @@ -0,0 +1,53 @@ + 1]), row(['id' => 2])); + yield rows(schema(int_schema('id'))); + yield rows(schema(int_schema('id')), row(['id' => 3])); + })(); + + static::assertEquals( + [rows(schema(int_schema('count')), row(['count' => 3]))], + iterator_to_array((new CountingProcessor())->process($batches, flow_context())), + ); + } + + public function test_no_batch_counts_to_zero(): void + { + $batches = (static function () { + yield from []; + })(); + + static::assertEquals( + [rows(schema(int_schema('count')), row(['count' => 0]))], + iterator_to_array((new CountingProcessor())->process($batches, flow_context())), + ); + } + + public function test_bind_outputs_the_count_column_whatever_the_input(): void + { + $bound = (new CountingProcessor())->bind(schema(int_schema('id'), str_schema('name'))); + + static::assertEquals(schema(int_schema('count')), $bound->output); + static::assertEquals(new CountingProcessor(), $bound->step); + } +} diff --git a/web/landing/assets/wasm/tools/flow.phar b/web/landing/assets/wasm/tools/flow.phar index 1db86033b8e4ca8fa3e06d8c7259550be87d0997..d0c4b09847ed6b7addcd4883122e4769d92e6b2b 100755 GIT binary patch delta 269630 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 ~7wf3|sE&M@fFStVRUzu{^Z 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 delta 243456 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 Date: Tue, 22 Sep 2026 13:05:38 +0200 Subject: [PATCH 3/3] fix(flow-php/etl): compare source statistics only with complete reads - rowsError() is null for a read narrowed by a pushed limit/partition filter or stopped early - Cardinality::exactly() and isUnknown() replace re-derived checks in explain and count() - document source statistics: Cardinality, what each source declares, custom extractors --- documentation/components/core/core.md | 1 + .../components/core/data-retrieval.md | 13 ++- documentation/components/core/statistics.md | 100 ++++++++++++++++++ src/core/etl/src/Flow/ETL/Cardinality.php | 17 +++ .../src/Flow/ETL/Dataset/SourceStatistics.php | 7 +- src/core/etl/src/Flow/ETL/Executor.php | 7 +- .../etl/src/Flow/ETL/Executor/SourceRead.php | 43 ++++++++ .../etl/src/Flow/ETL/Executor/SourceRows.php | 35 +++--- .../Optimizer/Rule/CountFromStatistics.php | 10 +- .../Flow/ETL/Plan/Explain/StatisticsLine.php | 16 ++- .../Integration/DataFrame/AnalyzeTest.php | 21 +++- .../Flow/ETL/Tests/Unit/CardinalityTest.php | 32 ++++++ .../Unit/Dataset/SourceStatisticsTest.php | 20 +++- .../Tests/Unit/Executor/SourceReadTest.php | 63 +++++++++++ .../Tests/Unit/Executor/SourceRowsTest.php | 59 +++++++++-- .../Unit/Executor/StatisticsCollectorTest.php | 4 +- .../documentation/navigation_left.html.twig | 1 + 17 files changed, 397 insertions(+), 52 deletions(-) create mode 100644 documentation/components/core/statistics.md create mode 100644 src/core/etl/src/Flow/ETL/Executor/SourceRead.php create mode 100644 src/core/etl/tests/Flow/ETL/Tests/Unit/Executor/SourceReadTest.php diff --git a/documentation/components/core/core.md b/documentation/components/core/core.md index 4746aace40..e6467b93cd 100644 --- a/documentation/components/core/core.md +++ b/documentation/components/core/core.md @@ -295,6 +295,7 @@ For detailed information about specific DataFrame operations, see the following - **[Partitioning](/documentation/components/core/partitioning.md)** - Data partitioning for efficient processing - **[Caching](/documentation/components/core/caching.md)** - Performance optimization through caching - **[Floe File Format](/documentation/components/core/floe.md)** - Flow's native self-describing binary row format +- **[Source Statistics](/documentation/components/core/statistics.md)** - What a source declares about its rows and bytes before it is read - **[Data Retrieval](/documentation/components/core/data-retrieval.md)** - Methods for getting processed data ### Data Quality & Validation diff --git a/documentation/components/core/data-retrieval.md b/documentation/components/core/data-retrieval.md index 494f897c5f..3192a17f29 100644 --- a/documentation/components/core/data-retrieval.md +++ b/documentation/components/core/data-retrieval.md @@ -149,6 +149,17 @@ foreach ($report->sources() as $source) { echo $source->extractor; // ParquetExtractor echo $source->declared->rows->estimate; // 20000 - extrapolated from the first footer echo $source->rows; // 29000 - read - echo $source->rowsError(); // 0.31 - |declared - read| / read, null without an estimate + echo $source->rowsError(); // 0.31 - |declared - read| / read +} +``` + +`rowsError()` is null without an estimate, and for a read that did not cover the whole source - a pushed `limit()` or +partition filter, or a read stopped early - since the declaration describes the whole source: + +```php +sources() as $source) { + $source->complete; // false after a pushed limit() or a stopped read } ``` \ No newline at end of file diff --git a/documentation/components/core/statistics.md b/documentation/components/core/statistics.md new file mode 100644 index 0000000000..c51dfec7d3 --- /dev/null +++ b/documentation/components/core/statistics.md @@ -0,0 +1,100 @@ +# Source Statistics + +[DOC_LINK:/documentation/components/core/core.md] + +[TOC] + +Every extractor says what it knows about its data before a row is read - how many rows, how many bytes: + +```php +statistics(); + +$statistics->rows; // Flow\ETL\Cardinality +$statistics->size; // Flow\ETL\Cardinality, bytes +``` + +The answer describes the whole source, with no `limit()` or partition filter applied. + +## Cardinality + +A `Cardinality` carries a guarantee and a guess side by side: + +```php +exactly(); // 1000 - null unless the count is exact +Cardinality::approximately(1_000)->confident(0.25); // null - the estimate is looser than 25% +Cardinality::unknown()->isUnknown(); // true +``` + +`atMost` is never exceeded. `estimate` may be wrong in either direction, by roughly `relativeError`. + +## What each source declares + +| Source | rows | size | +|------------------------------------------------------------------------|---------------------------------------------------------------------------------------------------------------------------|------------------------------| +| `from_parquet()`, `from_floe()` | exact from the footers; an estimate when a glob's footers were not all read | exact, or estimate on a glob | +| `from_csv()`, `from_json_lines()` | unknown before the schema is sniffed; exact when the sniff read every file whole; else an estimate from the mean row size | exact listed bytes | +| `from_json()`, `from_excel()` | unknown before the schema is sniffed; exact when the sniff read every file whole; else unknown | exact listed bytes | +| `from_text()`, `from_xml()` | unknown | exact listed bytes | +| `files()` | exact listed file count | exact listed bytes | +| `from_path_partitions()` | exact listed file count | unknown | +| `from_array()`, `from_rows()`, `from_sequence_*()` | exact | unknown | +| `from_cache()` | exact from the cache index | unknown | +| `from_pgsql_*()`, `from_dbal_*()` | estimate from `EXPLAIN` (never `ANALYZE`), bounded by `withMaximum()` | unknown | +| `from_google_sheet()` | bounded by the sheet's grid size once the schema is sniffed | unknown | +| `from_all()`, `batches()`, `batched_by()` | what the wrapped extractors declare, merged | same | +| `from_avro()`, `from_*_http_*()`, `from_memory()`, `from_data_frame()` | unknown | unknown | + +## Where statistics are used + +- `explain()->toString(Stage::physical)` lists them under every source: `Statistics: rows exact 1 000 · size exact 327 527 B`. +- `count()` over a source with exact rows reads the number instead of the rows - see + [Data Retrieval](/documentation/components/core/data-retrieval.md). +- `run(analyze: analyze()->withSourceStatistics())` puts the declared rows next to the rows each source yielded. + +## A custom extractor + +`statistics()` is part of the `Extractor` contract. A source that knows nothing returns `new Statistics()`: + +```php +statistics ??= new Statistics( + rows: Cardinality::approximately($this->api->totalHint(), Cardinality::DEFAULT_RELATIVE_ERROR), + ); + } + + // extract(), schema(), withSchema() +} +``` + +`statistics()` is called at most once per run, only when something needs the answer. Memoise what it reads. It +may spend only what the run spends anyway - a listing it must produce, metadata of files `schema()` already opens - +and never open an extra file or make an extra remote call. A database source may ask its planner (`EXPLAIN`), never +run the query. diff --git a/src/core/etl/src/Flow/ETL/Cardinality.php b/src/core/etl/src/Flow/ETL/Cardinality.php index e1995165d9..c3e9b4ba1e 100644 --- a/src/core/etl/src/Flow/ETL/Cardinality.php +++ b/src/core/etl/src/Flow/ETL/Cardinality.php @@ -80,6 +80,23 @@ public function confident(float $maxRelativeError): ?int return $this->estimate !== null && $this->relativeError <= $maxRelativeError ? $this->estimate : null; } + /** + * The count when it is guaranteed and known exactly, null otherwise. + * + * @return null|int<0, max> + */ + public function exactly(): ?int + { + return $this->atMost !== null && $this->atMost === $this->estimate && $this->relativeError === 0.0 + ? $this->atMost + : null; + } + + public function isUnknown(): bool + { + return $this->atMost === null && $this->estimate === null; + } + public function merge(self $other): self { return new self( diff --git a/src/core/etl/src/Flow/ETL/Dataset/SourceStatistics.php b/src/core/etl/src/Flow/ETL/Dataset/SourceStatistics.php index 10a646e9af..ac78dbdb8f 100644 --- a/src/core/etl/src/Flow/ETL/Dataset/SourceStatistics.php +++ b/src/core/etl/src/Flow/ETL/Dataset/SourceStatistics.php @@ -14,22 +14,25 @@ { /** * @param string $extractor the source's short class name + * @param bool $complete every read of the source ran to its end with no limit or partition filter pushed into it */ public function __construct( public string $extractor, public Statistics $declared, public int $rows, + public bool $complete, ) {} /** * How far the declared estimate was off, relative to the rows measured. Null without an estimate: an upper bound - * alone is a guarantee, not a guess. Against 0 measured rows any positive estimate is infinitely off. + * alone is a guarantee, not a guess. Null for an incomplete read too: the declaration describes the whole source, + * the rows measured only part of it. Against 0 measured rows any positive estimate is infinitely off. */ public function rowsError(): ?float { $estimate = $this->declared->rows->estimate; - if ($estimate === null) { + if ($estimate === null || !$this->complete) { return null; } diff --git a/src/core/etl/src/Flow/ETL/Executor.php b/src/core/etl/src/Flow/ETL/Executor.php index 52ae71ff3c..cf51e2bbea 100644 --- a/src/core/etl/src/Flow/ETL/Executor.php +++ b/src/core/etl/src/Flow/ETL/Executor.php @@ -9,6 +9,7 @@ use Flow\ETL\Executor\PhysicalPlan; use Flow\ETL\Executor\Pipeline; use Flow\ETL\Extractor\FileExtractor; +use Flow\Filesystem\Path\Filter\OnlyFiles; use Generator; use Throwable; use WeakMap; @@ -136,7 +137,11 @@ public function executePipeline(Pipeline $pipeline): Generator $sources = $leaf->sources(); if ($sources !== null) { - $generator = $sources->count($source, $generator); + $generator = $sources->count( + $source, + $generator, + $leaf->limit() !== null || !$leaf->pathFilter() instanceof OnlyFiles, + ); } foreach ($chain as $stage) { diff --git a/src/core/etl/src/Flow/ETL/Executor/SourceRead.php b/src/core/etl/src/Flow/ETL/Executor/SourceRead.php new file mode 100644 index 0000000000..602a908493 --- /dev/null +++ b/src/core/etl/src/Flow/ETL/Executor/SourceRead.php @@ -0,0 +1,43 @@ +open++; + $this->narrowed = $this->narrowed || $narrowed; + } + + public function counted(int $rows): void + { + $this->rows += $rows; + } + + public function closed(): void + { + $this->open--; + } + + public function rows(): int + { + return $this->rows; + } + + /** + * Every read ran to its end and none had a limit or a partition filter pushed into it. + */ + public function isComplete(): bool + { + return $this->open === 0 && !$this->narrowed; + } +} diff --git a/src/core/etl/src/Flow/ETL/Executor/SourceRows.php b/src/core/etl/src/Flow/ETL/Executor/SourceRows.php index ef72696fd0..093aa8475c 100644 --- a/src/core/etl/src/Flow/ETL/Executor/SourceRows.php +++ b/src/core/etl/src/Flow/ETL/Executor/SourceRows.php @@ -15,35 +15,39 @@ final class SourceRows { /** - * @var SplObjectStorage + * @var SplObjectStorage */ - private SplObjectStorage $rows; + private SplObjectStorage $reads; public function __construct() { - /** @var SplObjectStorage $rows */ - $rows = new SplObjectStorage(); - $this->rows = $rows; + /** @var SplObjectStorage $reads */ + $reads = new SplObjectStorage(); + $this->reads = $reads; } /** * Passes $batches through unchanged. A stop is forwarded and ends the count: the consumer that sent it reads no - * further batch. + * further batch, so that read never closes. * * @param Generator $batches what $extractor yields + * @param bool $narrowed a limit or a partition filter was pushed into this read * * @return Generator */ - public function count(Extractor $extractor, Generator $batches): Generator + public function count(Extractor $extractor, Generator $batches, bool $narrowed): Generator { - if (!$this->rows->offsetExists($extractor)) { - $this->rows[$extractor] = 0; + if (!$this->reads->offsetExists($extractor)) { + $this->reads[$extractor] = new SourceRead(); } - foreach ($batches as $rows) { - $this->rows[$extractor] += $rows->count(); + $read = $this->reads[$extractor]; + $read->opened($narrowed); - $signal = yield $rows; + foreach ($batches as $batch) { + $read->counted($batch->count()); + + $signal = yield $batch; if ($signal === Signal::STOP) { $batches->send(Signal::STOP); @@ -51,6 +55,8 @@ public function count(Extractor $extractor, Generator $batches): Generator return; } } + + $read->closed(); } /** @@ -60,11 +66,12 @@ public function statistics(): array { $statistics = []; - foreach ($this->rows as $extractor) { + foreach ($this->reads as $extractor) { $statistics[] = new SourceStatistics( (new ReflectionClass($extractor))->getShortName(), $extractor->statistics(), - $this->rows[$extractor], + $this->reads[$extractor]->rows(), + $this->reads[$extractor]->isComplete(), ); } diff --git a/src/core/etl/src/Flow/ETL/Optimizer/Rule/CountFromStatistics.php b/src/core/etl/src/Flow/ETL/Optimizer/Rule/CountFromStatistics.php index f9bf766b08..1ab40fce68 100644 --- a/src/core/etl/src/Flow/ETL/Optimizer/Rule/CountFromStatistics.php +++ b/src/core/etl/src/Flow/ETL/Optimizer/Rule/CountFromStatistics.php @@ -31,12 +31,10 @@ public function apply(LogicalPlan $plan, FlowContext $context): LogicalPlan return $plan; } - $rows = $read->extractor()->statistics()->rows; + $rows = $read->extractor()->statistics()->rows->exactly(); - if ($rows->atMost === null || $rows->atMost !== $rows->estimate || $rows->relativeError !== 0.0) { - return $plan; - } - - return new LogicalPlan(new Result(new Read(from_rows((new CountingProcessor())->rows($rows->atMost))))); + return $rows === null + ? $plan + : new LogicalPlan(new Result(new Read(from_rows((new CountingProcessor())->rows($rows))))); } } diff --git a/src/core/etl/src/Flow/ETL/Plan/Explain/StatisticsLine.php b/src/core/etl/src/Flow/ETL/Plan/Explain/StatisticsLine.php index 31d734b72a..4c158f0cb8 100644 --- a/src/core/etl/src/Flow/ETL/Plan/Explain/StatisticsLine.php +++ b/src/core/etl/src/Flow/ETL/Plan/Explain/StatisticsLine.php @@ -18,7 +18,7 @@ */ public function of(Statistics $statistics): ?string { - if ($this->isUnknown($statistics->rows) && $this->isUnknown($statistics->size)) { + if ($statistics->rows->isUnknown() && $statistics->size->isUnknown()) { return null; } @@ -31,13 +31,14 @@ public function cardinality(Cardinality $cardinality, string $unit = ''): string { $atMost = $cardinality->atMost; $estimate = $cardinality->estimate; + $exactly = $cardinality->exactly(); - if ($estimate === null) { - return $atMost === null ? 'unknown' : '≤ ' . $this->number($atMost) . $unit; + if ($exactly !== null) { + return 'exact ' . $this->number($exactly) . $unit; } - if ($atMost === $estimate && $cardinality->relativeError === 0.0) { - return 'exact ' . $this->number($estimate) . $unit; + if ($estimate === null) { + return $atMost === null ? 'unknown' : '≤ ' . $this->number($atMost) . $unit; } $approximately = '~' . $this->number($estimate) . $unit . ' ±' . $this->percent($cardinality->relativeError); @@ -58,9 +59,4 @@ public function number(int $number): string { return number_format($number, 0, '', ' '); } - - public function isUnknown(Cardinality $cardinality): bool - { - return $cardinality->atMost === null && $cardinality->estimate === null; - } } diff --git a/src/core/etl/tests/Flow/ETL/Tests/Integration/DataFrame/AnalyzeTest.php b/src/core/etl/tests/Flow/ETL/Tests/Integration/DataFrame/AnalyzeTest.php index 5d97461e19..9b149c6ff1 100644 --- a/src/core/etl/tests/Flow/ETL/Tests/Integration/DataFrame/AnalyzeTest.php +++ b/src/core/etl/tests/Flow/ETL/Tests/Integration/DataFrame/AnalyzeTest.php @@ -192,13 +192,30 @@ public function test_analyzing_source_statistics_counts_every_source_apart_from_ static::assertSame(2, $report->statistics()->totalRows()); static::assertEquals( [ - new SourceStatistics('ArrayExtractor', new Statistics(Cardinality::exact(3)), 3), - new SourceStatistics('ArrayExtractor', new Statistics(Cardinality::exact(4)), 4), + new SourceStatistics('ArrayExtractor', new Statistics(Cardinality::exact(3)), 3, true), + new SourceStatistics('ArrayExtractor', new Statistics(Cardinality::exact(4)), 4, true), ], $report->sources(), ); } + public function test_a_source_read_under_a_pushed_limit_reports_no_rows_error(): void + { + $report = df() + ->read(from_array([['id' => 1], ['id' => 2], ['id' => 3], ['id' => 4]])) + ->limit(2) + ->run(analyze: analyze()->withSourceStatistics()); + + static::assertNotNull($report); + $sources = $report->sources(); + static::assertNotNull($sources); + static::assertEquals( + [new SourceStatistics('ArrayExtractor', new Statistics(Cardinality::exact(4)), 4, false)], + $sources, + ); + static::assertNull($sources[0]->rowsError()); + } + public function test_run_explicit_analyze_overrides_config(): void { $config = config_builder()->analyze(analyze())->build(); diff --git a/src/core/etl/tests/Flow/ETL/Tests/Unit/CardinalityTest.php b/src/core/etl/tests/Flow/ETL/Tests/Unit/CardinalityTest.php index 449feba42b..79b74667ce 100644 --- a/src/core/etl/tests/Flow/ETL/Tests/Unit/CardinalityTest.php +++ b/src/core/etl/tests/Flow/ETL/Tests/Unit/CardinalityTest.php @@ -7,6 +7,8 @@ use Flow\ETL\Cardinality; use Flow\ETL\Exception\InvalidArgumentException; use Flow\ETL\Tests\FlowTestCase; +use Generator; +use PHPUnit\Framework\Attributes\DataProvider; use function str_repeat; use function strlen; @@ -112,6 +114,36 @@ public function test_two_exact_counts_add_up_to_an_exact_count(): void static::assertEquals(Cardinality::exact(30), Cardinality::exact(10)->merge(Cardinality::exact(20))); } + public function test_an_exact_count_reads_back_exactly(): void + { + static::assertSame(10, Cardinality::exact(10)->exactly()); + static::assertSame(0, Cardinality::exact(0)->exactly()); + } + + public static function inexact_counts(): Generator + { + yield 'approximate' => [Cardinality::approximately(10)]; + yield 'approximate with no error, no bound' => [Cardinality::approximately(10, 0.0)]; + yield 'estimate equal to its bound, with an error' => [new Cardinality(10, 10)]; + yield 'estimate under its bound, no error' => [new Cardinality(10, 8, 0.0)]; + yield 'upper bound alone' => [Cardinality::atMost(10)]; + yield 'unknown' => [Cardinality::unknown()]; + } + + #[DataProvider('inexact_counts')] + public function test_a_count_that_is_not_exact_reads_back_as_null(Cardinality $cardinality): void + { + static::assertNull($cardinality->exactly()); + } + + public function test_only_a_count_with_neither_bound_nor_estimate_is_unknown(): void + { + static::assertTrue(Cardinality::unknown()->isUnknown()); + static::assertFalse(Cardinality::atMost(10)->isUnknown()); + static::assertFalse(Cardinality::approximately(10)->isUnknown()); + static::assertFalse(Cardinality::exact(0)->isUnknown()); + } + public function test_unknown_knows_nothing(): void { $cardinality = Cardinality::unknown(); diff --git a/src/core/etl/tests/Flow/ETL/Tests/Unit/Dataset/SourceStatisticsTest.php b/src/core/etl/tests/Flow/ETL/Tests/Unit/Dataset/SourceStatisticsTest.php index d3aa3624c4..74fbfb53c0 100644 --- a/src/core/etl/tests/Flow/ETL/Tests/Unit/Dataset/SourceStatisticsTest.php +++ b/src/core/etl/tests/Flow/ETL/Tests/Unit/Dataset/SourceStatisticsTest.php @@ -28,6 +28,7 @@ public function test_the_rows_error_is_the_estimates_distance_relative_to_the_ro 'Source', new Statistics(Cardinality::approximately($estimate)), $measured, + true, ))->rowsError(), 0.000_001, ); @@ -37,25 +38,34 @@ public function test_an_exact_declaration_that_holds_has_no_error(): void { static::assertSame( 0.0, - (new SourceStatistics('Source', new Statistics(Cardinality::exact(5)), 5))->rowsError(), + (new SourceStatistics('Source', new Statistics(Cardinality::exact(5)), 5, true))->rowsError(), + ); + } + + public function test_an_incomplete_read_has_no_error(): void + { + static::assertNull( + (new SourceStatistics('Source', new Statistics(Cardinality::exact(100)), 10, false))->rowsError(), ); } public function test_there_is_no_error_without_declared_rows(): void { - static::assertNull((new SourceStatistics('Source', new Statistics(), 5))->rowsError()); + static::assertNull((new SourceStatistics('Source', new Statistics(), 5, true))->rowsError()); } public function test_an_upper_bound_alone_has_no_error(): void { - static::assertNull((new SourceStatistics('Source', new Statistics(Cardinality::atMost(10)), 5))->rowsError()); + static::assertNull( + (new SourceStatistics('Source', new Statistics(Cardinality::atMost(10)), 5, true))->rowsError(), + ); } public function test_an_estimate_of_zero_against_zero_rows_has_no_error(): void { static::assertSame( 0.0, - (new SourceStatistics('Source', new Statistics(Cardinality::approximately(0)), 0))->rowsError(), + (new SourceStatistics('Source', new Statistics(Cardinality::approximately(0)), 0, true))->rowsError(), ); } @@ -63,7 +73,7 @@ public function test_a_positive_estimate_against_zero_rows_is_infinitely_off(): { static::assertSame( INF, - (new SourceStatistics('Source', new Statistics(Cardinality::approximately(500)), 0))->rowsError(), + (new SourceStatistics('Source', new Statistics(Cardinality::approximately(500)), 0, true))->rowsError(), ); } } diff --git a/src/core/etl/tests/Flow/ETL/Tests/Unit/Executor/SourceReadTest.php b/src/core/etl/tests/Flow/ETL/Tests/Unit/Executor/SourceReadTest.php new file mode 100644 index 0000000000..a21771c626 --- /dev/null +++ b/src/core/etl/tests/Flow/ETL/Tests/Unit/Executor/SourceReadTest.php @@ -0,0 +1,63 @@ +rows()); + static::assertTrue($read->isComplete()); + } + + public function test_an_open_read_is_not_complete_until_it_closes(): void + { + $read = new SourceRead(); + $read->opened(false); + $read->counted(3); + + static::assertFalse($read->isComplete()); + + $read->closed(); + + static::assertSame(3, $read->rows()); + static::assertTrue($read->isComplete()); + } + + public function test_every_read_must_close(): void + { + $read = new SourceRead(); + $read->opened(false); + $read->opened(false); + $read->closed(); + + static::assertFalse($read->isComplete()); + } + + public function test_a_narrowed_read_never_makes_the_source_complete(): void + { + $read = new SourceRead(); + $read->opened(true); + $read->closed(); + $read->opened(false); + $read->closed(); + + static::assertFalse($read->isComplete()); + } + + public function test_counts_add_up_across_reads(): void + { + $read = new SourceRead(); + $read->counted(2); + $read->counted(5); + + static::assertSame(7, $read->rows()); + } +} diff --git a/src/core/etl/tests/Flow/ETL/Tests/Unit/Executor/SourceRowsTest.php b/src/core/etl/tests/Flow/ETL/Tests/Unit/Executor/SourceRowsTest.php index 194505b424..59bc66e0d8 100644 --- a/src/core/etl/tests/Flow/ETL/Tests/Unit/Executor/SourceRowsTest.php +++ b/src/core/etl/tests/Flow/ETL/Tests/Unit/Executor/SourceRowsTest.php @@ -27,11 +27,11 @@ public function test_every_batch_passes_through_unchanged_and_is_counted(): void $extractor = from_array([['id' => 1], ['id' => 2], ['id' => 3]])->withBatchSize(2); $sources = new SourceRows(); - $batches = iterator_to_array($sources->count($extractor, $extractor->extract(flow_context())), false); + $batches = iterator_to_array($sources->count($extractor, $extractor->extract(flow_context()), false), false); static::assertEquals(iterator_to_array($extractor->extract(flow_context()), false), $batches); static::assertEquals( - [new SourceStatistics('ArrayExtractor', new Statistics(Cardinality::exact(3)), 3)], + [new SourceStatistics('ArrayExtractor', new Statistics(Cardinality::exact(3)), 3, true)], $sources->statistics(), ); } @@ -44,7 +44,7 @@ public function test_a_stop_reaches_the_source_and_ends_the_count(): void ); $extractor->withBatchSize(1); $sources = new SourceRows(); - $counted = $sources->count($extractor, $extractor->extract(flow_context())); + $counted = $sources->count($extractor, $extractor->extract(flow_context()), false); static::assertCount(1, $counted->current()); $counted->send(Signal::STOP); @@ -52,6 +52,47 @@ public function test_a_stop_reaches_the_source_and_ends_the_count(): void static::assertFalse($counted->valid()); static::assertSame(1, $extractor->batchesYielded); static::assertSame(1, $sources->statistics()[0]->rows); + static::assertFalse($sources->statistics()[0]->complete); + } + + public function test_a_narrowed_read_is_not_complete(): void + { + $extractor = from_array([['id' => 1], ['id' => 2]]); + $sources = new SourceRows(); + + iterator_to_array($sources->count($extractor, $extractor->extract(flow_context()), true)); + + static::assertSame(2, $sources->statistics()[0]->rows); + static::assertFalse($sources->statistics()[0]->complete); + } + + public function test_one_narrowed_read_makes_the_source_incomplete(): void + { + $extractor = from_array([['id' => 1]]); + $sources = new SourceRows(); + + iterator_to_array($sources->count($extractor, $extractor->extract(flow_context()), false)); + iterator_to_array($sources->count($extractor, $extractor->extract(flow_context()), true)); + + static::assertFalse($sources->statistics()[0]->complete); + } + + public function test_interleaved_reads_of_one_source_add_up(): void + { + $extractor = from_array([['id' => 1], ['id' => 2]])->withBatchSize(1); + $sources = new SourceRows(); + $first = $sources->count($extractor, $extractor->extract(flow_context()), false); + $second = $sources->count($extractor, $extractor->extract(flow_context()), false); + + $first->current(); + iterator_to_array($second); + static::assertFalse($sources->statistics()[0]->complete); + + $first->next(); + $first->next(); + + static::assertSame(4, $sources->statistics()[0]->rows); + static::assertTrue($sources->statistics()[0]->complete); } public function test_a_source_read_twice_is_listed_once_with_both_counts(): void @@ -59,8 +100,8 @@ public function test_a_source_read_twice_is_listed_once_with_both_counts(): void $extractor = from_array([['id' => 1], ['id' => 2]]); $sources = new SourceRows(); - iterator_to_array($sources->count($extractor, $extractor->extract(flow_context()))); - iterator_to_array($sources->count($extractor, $extractor->extract(flow_context()))); + iterator_to_array($sources->count($extractor, $extractor->extract(flow_context()), false)); + iterator_to_array($sources->count($extractor, $extractor->extract(flow_context()), false)); static::assertCount(1, $sources->statistics()); static::assertSame(4, $sources->statistics()[0]->rows); @@ -72,8 +113,8 @@ public function test_sources_are_listed_in_the_order_they_were_first_read(): voi $second = from_array([['id' => 1], ['id' => 2]]); $sources = new SourceRows(); - iterator_to_array($sources->count($second, $second->extract(flow_context()))); - iterator_to_array($sources->count($first, $first->extract(flow_context()))); + iterator_to_array($sources->count($second, $second->extract(flow_context()), false)); + iterator_to_array($sources->count($first, $first->extract(flow_context()), false)); static::assertSame([2, 1], [$sources->statistics()[0]->rows, $sources->statistics()[1]->rows]); } @@ -83,7 +124,7 @@ public function test_a_source_that_yields_nothing_is_listed_with_zero_rows(): vo $extractor = from_array([]); $sources = new SourceRows(); - iterator_to_array($sources->count($extractor, $extractor->extract(flow_context()))); + iterator_to_array($sources->count($extractor, $extractor->extract(flow_context()), false)); static::assertSame(0, $sources->statistics()[0]->rows); } @@ -93,7 +134,7 @@ public function test_a_source_never_read_is_not_listed(): void $extractor = from_array([['id' => 1]]); $sources = new SourceRows(); - $sources->count($extractor, $extractor->extract(flow_context())); + $sources->count($extractor, $extractor->extract(flow_context()), false); static::assertSame([], $sources->statistics()); } diff --git a/src/core/etl/tests/Flow/ETL/Tests/Unit/Executor/StatisticsCollectorTest.php b/src/core/etl/tests/Flow/ETL/Tests/Unit/Executor/StatisticsCollectorTest.php index 145eba87f4..9c466dac8a 100644 --- a/src/core/etl/tests/Flow/ETL/Tests/Unit/Executor/StatisticsCollectorTest.php +++ b/src/core/etl/tests/Flow/ETL/Tests/Unit/Executor/StatisticsCollectorTest.php @@ -183,10 +183,10 @@ public function test_sources_are_counted_and_reported_when_enabled(): void $sources = $collector->sources(); static::assertNotNull($sources); - iterator_to_array($sources->count($extractor, $extractor->extract(flow_context()))); + iterator_to_array($sources->count($extractor, $extractor->extract(flow_context()), false)); static::assertEquals( - [new SourceStatistics('ArrayExtractor', new Statistics(Cardinality::exact(2)), 2)], + [new SourceStatistics('ArrayExtractor', new Statistics(Cardinality::exact(2)), 2, true)], $collector->report()?->sources(), ); } diff --git a/web/landing/templates/documentation/navigation_left.html.twig b/web/landing/templates/documentation/navigation_left.html.twig index 9a29195c0f..2287846322 100644 --- a/web/landing/templates/documentation/navigation_left.html.twig +++ b/web/landing/templates/documentation/navigation_left.html.twig @@ -63,6 +63,7 @@

  • {{ nav.nav_link('/documentation/components/core/caching', 'Caching', currentPath) }}
  • {{ nav.nav_link('/documentation/components/core/floe', 'Floe File Format', currentPath) }}
  • {{ nav.nav_link('/documentation/components/core/partitioning', 'Partitioning', currentPath) }}
  • +
  • {{ nav.nav_link('/documentation/components/core/statistics', 'Source Statistics', currentPath) }}
  • {{ nav.nav_link('/documentation/components/core/constraints', 'Constraints', currentPath) }}
  • {{ nav.nav_link('/documentation/components/core/schema', 'Schema', currentPath) }}
  • {{ nav.nav_link('/documentation/components/core/display', 'Display', currentPath) }}
  • *{mclx(BK-pqemM8Z`*O@3j4_ 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