Skip to content

Commit 1631c2f

Browse files
authored
Merge pull request #2696 from flow-php/empty-input-schema
fix(flow-php/etl): empty sources without a declared schema fail at the source
2 parents 39633f7 + e3cb7e7 commit 1631c2f

47 files changed

Lines changed: 1079 additions & 112 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎documentation/components/adapters/parquet.md‎

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -56,8 +56,7 @@ Flow `float` columns are written as Parquet `DOUBLE`.
5656

5757
## Schema
5858

59-
The file footer is the schema: `from_parquet(...)->withSchema()` throws. Project with `columns:`, change types after
60-
reading:
59+
The file footer is the schema. Project with `columns:`, change types after reading:
6160

6261
```php
6362
<?php
@@ -91,6 +90,28 @@ use Flow\Parquet\Writer;
9190
);
9291
```
9392

93+
### Declared schema
94+
95+
```php
96+
<?php
97+
98+
use function Flow\ETL\Adapter\Parquet\from_parquet;
99+
use function Flow\ETL\DSL\{data_frame, int_schema, schema, str_schema, to_output};
100+
101+
data_frame()
102+
->read(from_parquet(__DIR__ . '/orders/**/*.parquet')->withSchema(schema(
103+
int_schema('id'),
104+
str_schema('name', nullable: true),
105+
)))
106+
->select('id')
107+
->write(to_output())
108+
->run();
109+
```
110+
111+
- A glob that matches no file reads 0 rows; without `withSchema()` it throws `InferredSchemaException`.
112+
- Columns match by name: an undeclared file column is not read, a declared nullable column a file lacks is `null`, a
113+
declared non-nullable one fails.
114+
94115
## Filter pushdown
95116

96117
A `filter()` directly over `from_parquet()` moves into the reader, which returns only matching rows: row groups whose

‎documentation/components/core/core.md‎

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -236,6 +236,20 @@ $dataFrame = data_frame()
236236

237237
> **Note**: Flow PHP supports many data sources through specialized adapters. See individual adapter documentation for specific extractor usage (CSV, JSON, Parquet, databases, APIs, etc.).
238238
239+
An empty source has no column to infer a schema from and throws `InferredSchemaException`. Declare the schema to read it:
240+
241+
```php
242+
<?php
243+
244+
use function Flow\ETL\DSL\{data_frame, from_array, int_schema, schema, str_schema, to_output};
245+
246+
data_frame()
247+
->read(from_array([], schema(int_schema('id'), str_schema('name'))))
248+
->select('id')
249+
->write(to_output())
250+
->run();
251+
```
252+
239253
## Memory Management Best Practices
240254

241255
1. **Prefer Generator Methods**: Use `get()`, `getEachAsArray()` over `fetch()` for large datasets

‎documentation/components/core/floe.md‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,8 @@ data_frame()
4141

4242
Batches hold at most `withBatchSize()` rows, never spanning two BATCH frames.
4343

44-
The footer is the schema: `from_floe(...)->withSchema()` throws. Change a column's type or zone after reading:
44+
The footer is the schema; `withSchema()` reads every file by name against a declared one, as
45+
[Parquet](/documentation/components/adapters/parquet.md) does. Change a column's type or zone after reading:
4546

4647
```php
4748
<?php

‎documentation/components/core/schema.md‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -210,8 +210,8 @@ The HTTP extractors accept a schema the same way - see
210210
[Typing the row with a schema](/documentation/components/adapters/http.md) for declaring the response body as a
211211
`structure`.
212212

213-
Self-descriptive formats carry their schema in the file itself, so their extractors read it from there and have no
214-
`withSchema()` - Parquet and Floe in this repository.
213+
Self-descriptive formats carry their schema in the file itself - Parquet and Floe in this repository. Their extractors
214+
read it from there unless `withSchema()` declares one, which reads every file by name.
215215

216216
### PostgreSQL sources describe themselves, and keep `withSchema()`
217217

‎documentation/upgrading.md‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -138,6 +138,15 @@ public function scan(FlowContext $context, FileScan $scan): Generator
138138
| Symfony HttpClient `url.full`, `url.scheme`, `server.address`, `server.port` came from the `$url` argument (`/me`, `unknown` with `base_uri`) | they come from the effective URL, `base_uri` and the `query` option included |
139139
| `exception.stacktrace` was `getTraceAsString()`, with argument values when `zend.exception_ignore_args` is off | `StackTrace::format()`: every frame renders `()`, whatever `zend.exception_ignore_args` is |
140140

141+
### 9) `flow-php/etl`, `flow-php/etl-adapter-*` - an empty source without a declared schema fails at the source; Parquet and Floe accept `withSchema()`
142+
143+
| Before | After |
144+
|---------------------------------------------------------------------------------------------------------|-------------------------------------------------------------------------------------------------------------------------|
145+
| an empty source (`from_all()`, `from_array([])`, a glob that matches no file) described an empty schema | `schema()` throws `InferredSchemaException`; declare the schema with `->withSchema(...)` to read an empty source |
146+
| `df()->read(from_array([]))->fetch()` returned 0 rows | throws `InferredSchemaException`; `from_array([], schema: $schema)` returns 0 rows |
147+
| `from_parquet(...)->withSchema()`, `from_floe(...)->withSchema()` threw `InvalidArgumentException` | read every file by name against the declared schema: undeclared columns are not read, a missing nullable column is null |
148+
| `from_cache($id)` without a cache instance described an empty schema | `SchemaNotDerivableException`; the plan runs unbound |
149+
141150
---
142151

143152
## Upgrading from 0.45.x to 0.46.x

‎src/adapter/etl-adapter-csv/src/Flow/ETL/Adapter/CSV/CSVExtractor.php‎

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
namespace Flow\ETL\Adapter\CSV;
66

77
use Flow\ETL\Cardinality;
8+
use Flow\ETL\Exception\InferredSchemaException;
89
use Flow\ETL\Exception\InvalidArgumentException;
910
use Flow\ETL\Extractor;
1011
use Flow\ETL\Extractor\BatchableExtractor;
@@ -101,7 +102,7 @@ public function isRepeatable(): bool
101102
public function scan(FlowContext $context, FileScan $scan): Generator
102103
{
103104
$fileColumns = $this->fileColumns($this->filesystem, $this->path);
104-
$schema = $this->schema();
105+
$schema = $this->describe();
105106
$sources = iterator_to_array($this->sourceFiles($this->filesystem, $this->path, $scan->pathFilter), false);
106107

107108
yield from (new FileReadLoop($fileColumns, $schema, $scan->conjuncts))->read(
@@ -134,6 +135,21 @@ public function inferSchema(SchemaInferenceBuilder $builder): static
134135
}
135136

136137
public function schema(): Schema
138+
{
139+
$schema = $this->describe();
140+
$fileColumns = $this->fileColumns($this->filesystem, $this->path);
141+
142+
if ($this->schema === null && $fileColumns->withoutTail($schema)->count() === 0) {
143+
throw InferredSchemaException::nothingToInferFrom('from_csv()', sprintf(
144+
'no file matching "%s" carries a column',
145+
$this->path->uri(),
146+
));
147+
}
148+
149+
return $schema;
150+
}
151+
152+
private function describe(): Schema
137153
{
138154
$fileColumns = $this->fileColumns($this->filesystem, $this->path);
139155

‎src/adapter/etl-adapter-csv/tests/Flow/ETL/Adapter/CSV/Tests/Integration/CSVExtractorTest.php‎

Lines changed: 57 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,39 @@ final class CSVExtractorTest extends FlowTestCase
7171
{
7272
use OperatingSystem;
7373

74+
public function test_an_empty_glob_refuses_to_infer_a_schema(): void
75+
{
76+
$this->expectException(InferredSchemaException::class);
77+
$this->expectExceptionMessage(
78+
'from_csv() has nothing to infer a schema from: no file matching "memory://empty/*.csv" carries a column. '
79+
. 'Declare the schema with ->withSchema(...).',
80+
);
81+
82+
from_csv(path('memory://empty/*.csv'), filesystem: memory_filesystem())->schema();
83+
}
84+
85+
public function test_an_empty_glob_with_metadata_columns_refuses_to_infer_a_schema(): void
86+
{
87+
$this->expectException(InferredSchemaException::class);
88+
$this->expectExceptionMessage(
89+
'from_csv() has nothing to infer a schema from: no file matching "memory://empty/*.csv" carries a column. '
90+
. 'Declare the schema with ->withSchema(...).',
91+
);
92+
93+
from_csv(path('memory://empty/*.csv'), filesystem: memory_filesystem())->withMetadataColumns(true)->schema();
94+
}
95+
96+
public function test_an_empty_glob_with_a_declared_schema_answers_it_and_extracts_nothing(): void
97+
{
98+
$extractor = from_csv(
99+
path('memory://empty/*.csv'),
100+
filesystem: memory_filesystem(),
101+
)->withSchema(schema(int_schema('id')));
102+
103+
static::assertEquals(schema(int_schema('id')), $extractor->schema());
104+
static::assertSame([], iterator_to_array($extractor->extract(flow_context()), false));
105+
}
106+
74107
public function test_a_hive_null_partition_value_reads_back_as_null(): void
75108
{
76109
$dir = __DIR__ . '/var/test_a_hive_null_partition_value_reads_back_as_null';
@@ -484,17 +517,32 @@ public function test_a_header_only_file_infers_every_column_as_string(): void
484517
static::assertSame([], iterator_to_array($extractor->extract(flow_context(config()))));
485518
}
486519

487-
public function test_an_empty_file_infers_an_empty_schema(): void
520+
public function test_an_empty_file_refuses_to_infer_a_schema_and_extracts_nothing(): void
488521
{
489522
$extractor = from_csv(CSVFixtureContext::path('empty.csv'));
490523

491-
static::assertSame([], $extractor->schema()->definitions());
492524
static::assertSame([], iterator_to_array($extractor->extract(flow_context(config()))));
525+
526+
$this->expectException(InferredSchemaException::class);
527+
$this->expectExceptionMessage(
528+
'from_csv() has nothing to infer a schema from: no file matching "'
529+
. path_real(CSVFixtureContext::path('empty.csv'))->uri()
530+
. '" carries a column. Declare the schema with ->withSchema(...).',
531+
);
532+
533+
$extractor->schema();
493534
}
494535

495-
public function test_a_glob_of_only_empty_files_infers_an_empty_schema(): void
536+
public function test_a_glob_of_only_empty_files_refuses_to_infer_a_schema(): void
496537
{
497-
static::assertSame([], from_csv(CSVFixtureContext::path('two_empty/*.csv'))->schema()->definitions());
538+
$this->expectException(InferredSchemaException::class);
539+
$this->expectExceptionMessage(
540+
'from_csv() has nothing to infer a schema from: no file matching "'
541+
. path_real(CSVFixtureContext::path('two_empty/*.csv'))->uri()
542+
. '" carries a column. Declare the schema with ->withSchema(...).',
543+
);
544+
545+
from_csv(CSVFixtureContext::path('two_empty/*.csv'))->schema();
498546
}
499547

500548
public function test_an_empty_file_in_a_glob_is_skipped(): void
@@ -1224,17 +1272,12 @@ public function test_record_counts_match_fgetcsv_for_every_fixture(string $fixtu
12241272

12251273
fclose($handle);
12261274

1227-
static::assertSame(
1275+
self::assertExtractedRowsCount(
12281276
max(0, $records - 1),
1229-
df()
1230-
->read(
1231-
from_csv($path)
1232-
->withSeparator($dialect->separator)
1233-
->withEnclosure($dialect->enclosure)
1234-
->withEscape($dialect->escape),
1235-
)
1236-
->fetch()
1237-
->count(),
1277+
from_csv($path)
1278+
->withSeparator($dialect->separator)
1279+
->withEnclosure($dialect->enclosure)
1280+
->withEscape($dialect->escape),
12381281
);
12391282
}
12401283

‎src/adapter/etl-adapter-csv/tests/Flow/ETL/Adapter/CSV/Tests/Unit/CSVExtractorTest.php‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -123,7 +123,7 @@ public function test_a_narrow_first_row_does_not_skew_the_estimate(): void
123123
public function test_an_empty_file_declares_zero_rows_exactly(): void
124124
{
125125
$extractor = from_csv(CSVFixtureContext::path('empty.csv'));
126-
$extractor->schema();
126+
self::assertExtractedRowsCount(0, $extractor);
127127

128128
static::assertEquals(Cardinality::exact(0), $extractor->statistics()->rows);
129129
static::assertEquals(Cardinality::unknown(), $extractor->statistics()->size);

‎src/adapter/etl-adapter-excel/src/Flow/ETL/Adapter/Excel/ExcelExtractor.php‎

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
namespace Flow\ETL\Adapter\Excel;
66

77
use Flow\ETL\Cardinality;
8+
use Flow\ETL\Exception\InferredSchemaException;
89
use Flow\ETL\Exception\InvalidArgumentException;
910
use Flow\ETL\Extractor;
1011
use Flow\ETL\Extractor\BatchableExtractor;
@@ -125,7 +126,7 @@ public function isRepeatable(): bool
125126
public function scan(FlowContext $context, FileScan $scan): Generator
126127
{
127128
$fileColumns = $this->fileColumns($this->filesystem, $this->path);
128-
$schema = $this->schema();
129+
$schema = $this->describe();
129130
// only the first extract() after an inference reads on from its sample; every later one parses afresh
130131
$batches = new ExcelFileBatches(
131132
new WorkbookReader($this->readOptions, new ExcelFormatDetector($this->filesystem)),
@@ -163,6 +164,21 @@ public function inferSchema(SchemaInferenceBuilder $builder): static
163164
}
164165

165166
public function schema(): Schema
167+
{
168+
$schema = $this->describe();
169+
$fileColumns = $this->fileColumns($this->filesystem, $this->path);
170+
171+
if ($this->schema === null && $fileColumns->withoutTail($schema)->count() === 0) {
172+
throw InferredSchemaException::nothingToInferFrom('from_excel()', sprintf(
173+
'no file matching "%s" carries a column',
174+
$this->path->uri(),
175+
));
176+
}
177+
178+
return $schema;
179+
}
180+
181+
private function describe(): Schema
166182
{
167183
$fileColumns = $this->fileColumns($this->filesystem, $this->path);
168184

‎src/adapter/etl-adapter-excel/tests/Flow/ETL/Adapter/Excel/Tests/Integration/ExcelExtractorTest.php‎

Lines changed: 51 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,38 @@ public static function provide_nullable_fixtures(): iterable
7676
yield 'xlsx' => [__DIR__ . '/../Fixtures/nullable_fixture.xlsx'];
7777
}
7878

79+
public function test_an_empty_glob_refuses_to_infer_a_schema(): void
80+
{
81+
$this->expectException(InferredSchemaException::class);
82+
$this->expectExceptionMessage(
83+
'from_excel() has nothing to infer a schema from: no file matching "'
84+
. path_real(ExcelFixtureContext::file('*.no-match'))->uri()
85+
. '" carries a column. Declare the schema with ->withSchema(...).',
86+
);
87+
88+
from_excel(ExcelFixtureContext::file('*.no-match'))->schema();
89+
}
90+
91+
public function test_an_empty_glob_with_metadata_columns_refuses_to_infer_a_schema(): void
92+
{
93+
$this->expectException(InferredSchemaException::class);
94+
$this->expectExceptionMessage(
95+
'from_excel() has nothing to infer a schema from: no file matching "'
96+
. path_real(ExcelFixtureContext::file('*.no-match'))->uri()
97+
. '" carries a column. Declare the schema with ->withSchema(...).',
98+
);
99+
100+
from_excel(ExcelFixtureContext::file('*.no-match'))->withMetadataColumns(true)->schema();
101+
}
102+
103+
public function test_an_empty_glob_with_a_declared_schema_answers_it_and_extracts_nothing(): void
104+
{
105+
$extractor = from_excel(ExcelFixtureContext::file('*.no-match'))->withSchema(schema(int_schema('id')));
106+
107+
static::assertEquals(schema(int_schema('id')), $extractor->schema());
108+
static::assertSame([], iterator_to_array($extractor->extract(flow_context()), false));
109+
}
110+
79111
#[DataProvider('provide_fixtures')]
80112
public function test_extract_excel_file(string $fixtureName): void
81113
{
@@ -463,18 +495,34 @@ public function test_cross_stream_infers_one_schema_and_the_partition_column():
463495
);
464496
}
465497

466-
#[TestWith(['empty_sheet.xlsx', []])]
467-
#[TestWith(['empty_sheet.ods', []])]
468498
#[TestWith(['header_only.xlsx', ['id', 'name']])]
469499
#[TestWith(['header_only.ods', ['id', 'name']])]
470-
public function test_header_only_and_empty_sheets(string $fixture, array $names): void
500+
public function test_header_only_sheets(string $fixture, array $names): void
471501
{
472502
$extractor = from_excel(ExcelFixtureContext::file($fixture));
473503

474504
static::assertSame($names, $extractor->schema()->references()->names());
475505
static::assertCount(0, df()->extract($extractor)->fetch()->toArray());
476506
}
477507

508+
#[TestWith(['empty_sheet.xlsx'])]
509+
#[TestWith(['empty_sheet.ods'])]
510+
public function test_an_empty_sheet_refuses_to_infer_a_schema_and_extracts_nothing(string $fixture): void
511+
{
512+
$extractor = from_excel(ExcelFixtureContext::file($fixture));
513+
514+
static::assertSame([], iterator_to_array($extractor->extract(flow_context()), false));
515+
516+
$this->expectException(InferredSchemaException::class);
517+
$this->expectExceptionMessage(
518+
'from_excel() has nothing to infer a schema from: no file matching "'
519+
. path_real(ExcelFixtureContext::file($fixture))->uri()
520+
. '" carries a column. Declare the schema with ->withSchema(...).',
521+
);
522+
523+
$extractor->schema();
524+
}
525+
478526
/**
479527
* Guards the three tests below: OpenSpout only writes a shared-strings folder when it picks the file-based
480528
* cache, which depends on the runner's memory_limit and the fixture's unique-string count. If it ever picks

0 commit comments

Comments
 (0)