Skip to content

Commit 2d2e067

Browse files
committed
feat(flow-php/etl): partition placeholders in read/write paths
- {column} placeholders in destination paths turn partitions into file/directory names - placeholders in extractor paths match like wildcards and recover partition values, incl. pruning - Partition name and value now forbid { and } (breaking, upgrade note added) - save modes (overwrite/append) applied to resolved placeholder paths
1 parent 16300e1 commit 2d2e067

26 files changed

Lines changed: 1105 additions & 22 deletions

File tree

documentation/components/core/partitioning.md

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,55 @@ $dataFrame
9292
->run();
9393
```
9494

95+
## Partition Placeholders
96+
97+
By default every partition becomes a `column=value` directory. With `{column}` placeholders in the destination path, selected partitions can become part of the file (or directory) name instead:
98+
99+
```php
100+
<?php
101+
102+
data_frame()
103+
->read(from_array([
104+
['date' => '2024-01-01', 'department' => 'sales', 'amount' => 100],
105+
['date' => '2024-01-01', 'department' => 'marketing', 'amount' => 200],
106+
]))
107+
->partitionBy('date', 'department')
108+
->write(to_parquet(__DIR__ . '/output/{department}.parquet'))
109+
->run();
110+
```
111+
112+
**File structure:**
113+
```
114+
output/
115+
├── date=2024-01-01/
116+
│ ├── sales.parquet
117+
│ └── marketing.parquet
118+
```
119+
120+
Partitions consumed by placeholders are removed from the `column=value` directory chain; all remaining partitions still become directories. Placeholders can appear in any path segment and can be combined, for example `to_csv(__DIR__ . '/output/{date}/{department}_report.csv')`.
121+
122+
Rules:
123+
124+
- Every placeholder must match a `partitionBy()` column, otherwise the write fails.
125+
- A destination path with placeholders requires partitioned rows - without `partitionBy()` the write fails.
126+
- Save modes behave exactly like with directory partitions, applied to the resolved file path.
127+
128+
### Reading Data Partitioned with Placeholders
129+
130+
Placeholders work in extractor paths too - matching files like a wildcard and re-attaching the partition value from the file name:
131+
132+
```php
133+
<?php
134+
135+
data_frame()
136+
->read(from_parquet(__DIR__ . '/output/date=*/{department}.parquet'))
137+
->filterPartitions(ref('department')->equals(lit('sales'))) // partition pruning works too
138+
->write(to_output())
139+
->run();
140+
```
141+
142+
Keep in mind that a placeholder matches any file in that location, and the partition value is taken from the file name as-is. Files appended to an existing location get randomized suffixes (`sales_a1b2c3.parquet`), which become part of the recovered partition value.
143+
95144
## Performance Considerations
96145

97146
### Choosing Partition Columns

documentation/upgrading.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -258,6 +258,16 @@ Keep the registered `Telemetry` instance referenced for as long as it should be
258258
Custom `SchemaValidator` implementations must return a `Flow\ETL\Schema\Validator\ValidationContext`
259259
built from the missing, mismatched (`MismatchedDefinition`), and unexpected definitions they reject.
260260

261+
### 18) `flow-php/filesystem` - `Partition` name and value forbid `{` and `}`
262+
263+
| Before | After |
264+
|----------------------------------|-----------------------------------|
265+
| `new Partition('na{me', 'a}b')` | throws `InvalidArgumentException` |
266+
| `partitionBy()` values with `{}` | throws `InvalidArgumentException` |
267+
268+
`{name}` in a path is now a partition placeholder resolved from `partitionBy()` columns; strip braces from partition
269+
values before partitioning.
270+
261271
---
262272

263273
## Upgrading from 0.40.x to 0.41.x

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

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@
1111
use function Flow\ETL\Adapter\CSV\from_csv;
1212
use function Flow\ETL\Adapter\CSV\to_csv;
1313
use function Flow\ETL\DSL\df;
14+
use function Flow\ETL\DSL\from_array;
15+
use function Flow\ETL\DSL\lit;
1416
use function Flow\ETL\DSL\overwrite;
1517
use function Flow\ETL\DSL\ref;
1618
use function mkdir;
@@ -41,4 +43,65 @@ public function test_loading_csv_files(): void
4143
unlink($path);
4244
}
4345
}
46+
47+
public function test_writing_and_reading_csv_files_with_partition_placeholders(): void
48+
{
49+
$dir = __DIR__ . '/var/test_writing_and_reading_csv_files_with_partition_placeholders';
50+
51+
df()
52+
->read(from_array([
53+
['year' => '2024', 'name' => '123456-PL', 'total' => 100],
54+
['year' => '2024', 'name' => '789-DE', 'total' => 200],
55+
['year' => '2025', 'name' => '555-FR', 'total' => 300],
56+
]))
57+
->partitionBy('year', 'name')
58+
->saveMode(overwrite())
59+
->load(to_csv($dir . '/{name}.csv'))
60+
->run();
61+
62+
static::assertFileExists($dir . '/year=2024/123456-PL.csv');
63+
static::assertFileExists($dir . '/year=2024/789-DE.csv');
64+
static::assertFileExists($dir . '/year=2025/555-FR.csv');
65+
66+
$rows = df()
67+
->read(from_csv($dir . '/year=*/{name}.csv'))
68+
->sortBy(ref('total'))
69+
->fetch();
70+
71+
static::assertCount(3, $rows);
72+
static::assertEquals(['2024', '2024', '2025'], $rows->reduceToArray(ref('year')));
73+
static::assertEquals(['123456-PL', '789-DE', '555-FR'], $rows->reduceToArray(ref('name')));
74+
75+
$prunedRows = df()
76+
->read(from_csv($dir . '/year=*/{name}.csv'))
77+
->filterPartitions(ref('name')->equals(lit('789-DE')))
78+
->fetch();
79+
80+
static::assertCount(1, $prunedRows);
81+
static::assertEquals(['789-DE'], $prunedRows->reduceToArray(ref('name')));
82+
}
83+
84+
/**
85+
* https://github.com/flow-php/flow/issues/2238
86+
*/
87+
public function test_writing_csv_files_with_last_partition_as_file_name(): void
88+
{
89+
$output = __DIR__ . '/var/test_writing_csv_files_with_last_partition_as_file_name/output';
90+
91+
df()
92+
->read(from_array([
93+
['order-year' => '2024', 'order-month' => '03', 'order-name' => '123456-PL', 'total' => 100],
94+
['order-year' => '2024', 'order-month' => '03', 'order-name' => '789-DE', 'total' => 200],
95+
['order-year' => '2025', 'order-month' => '01', 'order-name' => '555-FR', 'total' => 300],
96+
]))
97+
->partitionBy('order-year', 'order-month', 'order-name')
98+
->saveMode(overwrite())
99+
->load(to_csv($output . '/{order-name}.csv'))
100+
->run();
101+
102+
static::assertFileExists($output . '/order-year=2024/order-month=03/123456-PL.csv');
103+
static::assertFileExists($output . '/order-year=2024/order-month=03/789-DE.csv');
104+
static::assertFileExists($output . '/order-year=2025/order-month=01/555-FR.csv');
105+
static::assertFileDoesNotExist($output . '/order-year=2024/order-month=03/order-name=123456-PL');
106+
}
44107
}

src/core/etl/src/Flow/ETL/Extractor/PathPartitionsExtractor.php

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
use Flow\ETL\FlowContext;
99
use Flow\Filesystem\Partition;
1010
use Flow\Filesystem\Path;
11+
use Flow\Filesystem\Path\Filter\PlaceholderPartitions;
1112
use Generator;
1213

1314
use function array_map;
@@ -34,8 +35,16 @@ public function __construct(
3435
*/
3536
public function extract(FlowContext $context): Generator
3637
{
37-
foreach ($context->filesystem($this->path)->list($this->path, $this->filter()) as $fileStatus) {
38-
$partitions = $fileStatus->path->partitions();
38+
$hasPlaceholders = [] !== $this->path->partitionPlaceholders();
39+
$filter = $hasPlaceholders ? new PlaceholderPartitions($this->path, $this->filter()) : $this->filter();
40+
41+
foreach ($context->filesystem($this->path)->list($this->path, $filter) as $fileStatus) {
42+
$partitions = $hasPlaceholders
43+
? $fileStatus
44+
->path
45+
->withPartitions($this->path->extractPlaceholderPartitions($fileStatus->path))
46+
->partitions()
47+
: $fileStatus->path->partitions();
3948

4049
$row = row(
4150
string_entry('path', $fileStatus->path->uri()),

src/core/etl/src/Flow/ETL/Filesystem/FilesystemStreams.php

Lines changed: 33 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
use Flow\Filesystem\Partition;
1313
use Flow\Filesystem\Path;
1414
use Flow\Filesystem\Path\Filter;
15+
use Flow\Filesystem\Path\Filter\PlaceholderPartitions;
1516
use Flow\Filesystem\SourceStream;
1617
use Flow\Filesystem\Stream\VoidStream;
1718
use Generator;
@@ -58,7 +59,7 @@ public function closeStreams(Path $path): void
5859
}
5960

6061
if ($this->saveMode === SaveMode::Overwrite) {
61-
if ($fileStream->path()->partitions()->count()) {
62+
if ($fileStream->path()->partitions()->count() || [] !== $path->partitionPlaceholders()) {
6263
$filename = str_replace(self::FLOW_TMP_FILE_PREFIX, '', $fileStream->path()->filename());
6364

6465
$partitionFilesPattern = path(
@@ -134,9 +135,18 @@ public function isOpen(Path $path, array $partitions = []): bool
134135
public function list(Path $path, Filter $pathFilter): Generator
135136
{
136137
$fs = $this->fstab->for($path);
138+
$hasPlaceholders = [] !== $path->partitionPlaceholders();
139+
140+
if ($hasPlaceholders) {
141+
$pathFilter = new PlaceholderPartitions($path, $pathFilter);
142+
}
137143

138144
foreach ($fs->list($path, $pathFilter) as $file) {
139-
yield $fs->readFrom($file->path);
145+
yield $fs->readFrom(
146+
$hasPlaceholders
147+
? $file->path->withPartitions($path->extractPlaceholderPartitions($file->path))
148+
: $file->path,
149+
);
140150
}
141151
}
142152

@@ -163,10 +173,18 @@ public function listOpenStreams(Path $path): Generator
163173
*/
164174
public function read(Path $path, array $partitions = []): SourceStream
165175
{
166-
if ($path->isPattern()) {
176+
$placeholders = $path->partitionPlaceholders();
177+
178+
if ($path->isPattern() && [] === $placeholders) {
167179
throw new RuntimeException("Path can't be pattern, given: " . $path->uri());
168180
}
169181

182+
if ([] !== $placeholders && !count($partitions)) {
183+
throw new RuntimeException(
184+
'Path "' . $path->uri() . '" contains partition placeholders but no partitions were given',
185+
);
186+
}
187+
170188
$destination = count($partitions) ? $path->addPartitions(...$partitions) : $path;
171189

172190
return $this->fstab->for($path)->readFrom($destination);
@@ -203,8 +221,18 @@ public function writeTo(Path $path, array $partitions = []): DestinationStream
203221
throw new RuntimeException('Stream path must have an extension, given: ' . $path->uri());
204222
}
205223

206-
if ($path->isPattern()) {
207-
throw new RuntimeException("Destination path can't be patter, given:" . $path->uri());
224+
$placeholders = $path->partitionPlaceholders();
225+
226+
if ($path->isPattern() && [] === $placeholders) {
227+
throw new RuntimeException("Destination path can't be pattern, given: " . $path->uri());
228+
}
229+
230+
if ([] !== $placeholders && !count($partitions)) {
231+
throw new RuntimeException(
232+
'Destination path "'
233+
. $path->uri()
234+
. '" contains partition placeholders but rows are not partitioned, add partitionBy() to your pipeline',
235+
);
208236
}
209237

210238
$pathUri = $path->uri();
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
*
2+
!.gitignore

src/core/etl/tests/Flow/ETL/Tests/Integration/DataFrame/PartitioningTest.php

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -216,6 +216,47 @@ static function (int $i): array {
216216
});
217217
}
218218

219+
public function test_partitioning_by_path_placeholders_only(): void
220+
{
221+
$output = __DIR__ . '/Fixtures/Partitioning/placeholders';
222+
223+
df()
224+
->read(from_array([
225+
['order-year' => '2024', 'order-month' => '03', 'order-name' => '123456-PL', 'text' => 'order 1'],
226+
['order-year' => '2024', 'order-month' => '03', 'order-name' => '789-DE', 'text' => 'order 2'],
227+
['order-year' => '2025', 'order-month' => '01', 'order-name' => '555-FR', 'text' => 'order 3'],
228+
]))
229+
->partitionBy('order-year', 'order-month', 'order-name')
230+
->drop('order-year', 'order-month', 'order-name')
231+
->saveMode(overwrite())
232+
->write(to_text($output . '/{order-year}/{order-month}/{order-name}.txt'))
233+
->run();
234+
235+
static::assertFileExists($output . '/2024/03/123456-PL.txt');
236+
static::assertFileExists($output . '/2024/03/789-DE.txt');
237+
static::assertFileExists($output . '/2025/01/555-FR.txt');
238+
239+
df()->read(from_text($output
240+
. '/{order-year}/{order-month}/{order-name}.txt'))->run(function (Rows $rows): void {
241+
$this->assertSame(
242+
['order-year', 'order-month', 'order-name'],
243+
array_map(static fn(Partition $p) => $p->name, $rows->partitions()->toArray()),
244+
);
245+
});
246+
247+
df()->read(from_text($output . '/**/*.txt'))->run(function (Rows $rows): void {
248+
$this->assertFalse($rows->isPartitioned());
249+
});
250+
251+
$prunedRows = df()
252+
->read(from_text($output . '/{order-year}/{order-month}/{order-name}.txt'))
253+
->filterPartitions(ref('order-month')->equals(lit('01')))
254+
->fetch();
255+
256+
static::assertCount(1, $prunedRows);
257+
static::assertSame(['555-FR'], $prunedRows->reduceToArray('order-name'));
258+
}
259+
219260
public function test_pruning_multiple_partitions(): void
220261
{
221262
$rows = df()

0 commit comments

Comments
 (0)