Skip to content

Commit d4eac8a

Browse files
authored
Merge pull request #2693 from flow-php/parquet-filtering
Parquet filtering
2 parents a963743 + d768dc1 commit d4eac8a

621 files changed

Lines changed: 36366 additions & 7225 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.

‎Justfile‎

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -138,9 +138,7 @@ docs-api:
138138

139139
# Regenerate Parquet Thrift PHP classes.
140140
gen-thrift: && fix
141-
grep -q 'namespace php Flow.Parquet.ThriftModel' src/lib/parquet/src/Flow/Parquet/Resources/Thrift/parquet.thrift || { echo "Flow php namespace not found in thrift definition!"; exit 1; }
142-
rm src/lib/parquet/src/Flow/Parquet/ThriftModel/*.php
143-
thrift --gen php --out src/lib/parquet/src src/lib/parquet/src/Flow/Parquet/Resources/Thrift/parquet.thrift
141+
php bin/thrift.php parquet
144142

145143
# Regenerate PostgreSQL pg_query Protobuf PHP classes.
146144
gen-protobuf-pg: && fix

‎bin/docs.php‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,7 @@ public function execute(InputInterface $input, OutputInterface $output): int
7373
__DIR__ . '/../src/adapter/etl-adapter-xml/src/Flow/ETL/Adapter/XML/functions.php',
7474
__DIR__ . '/../src/lib/filesystem/src/Flow/Filesystem/DSL/functions.php',
7575
__DIR__ . '/../src/lib/types/src/Flow/Types/DSL/functions.php',
76+
__DIR__ . '/../src/lib/parquet/src/Flow/Parquet/DSL/functions.php',
7677
__DIR__ . '/../src/lib/postgresql/src/Flow/PostgreSql/DSL/schema.php',
7778
__DIR__ . '/../src/lib/postgresql/src/Flow/PostgreSql/DSL/query.php',
7879
__DIR__ . '/../src/lib/postgresql/src/Flow/PostgreSql/DSL/condition.php',

‎bin/thrift.php‎

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
#!/usr/bin/env php
2+
<?php
3+
4+
declare(strict_types=1);
5+
6+
use Symfony\Component\Console\Application;
7+
use Symfony\Component\Console\Command\Command;
8+
use Symfony\Component\Console\Input\InputInterface;
9+
use Symfony\Component\Console\Input\InputOption;
10+
use Symfony\Component\Console\Output\OutputInterface;
11+
use Symfony\Component\Process\ExecutableFinder;
12+
use Symfony\Component\Process\Process;
13+
14+
use function Flow\Types\DSL\type_string;
15+
16+
require __DIR__ . '/../vendor/autoload.php';
17+
18+
$application = new Application('Flow-PHP - Thrift');
19+
20+
$application->addCommand(new class extends Command {
21+
public function configure(): void
22+
{
23+
$this
24+
->setName('parquet')
25+
->setDescription('Regenerates the Parquet ThriftModel classes from parquet.thrift.')
26+
->addOption(
27+
'thrift',
28+
null,
29+
InputOption::VALUE_REQUIRED,
30+
'Thrift compiler binary, found in PATH when omitted',
31+
);
32+
}
33+
34+
public function execute(InputInterface $input, OutputInterface $output): int
35+
{
36+
$source = __DIR__ . '/../src/lib/parquet/src';
37+
$idl = $source . '/Flow/Parquet/Resources/Thrift/parquet.thrift';
38+
$models = $source . '/Flow/Parquet/ThriftModel';
39+
40+
$thrift = $input->getOption('thrift') === null
41+
? (new ExecutableFinder())->find('thrift')
42+
: type_string()->assert($input->getOption('thrift'));
43+
44+
if (!is_string($thrift) || !is_executable($thrift)) {
45+
$output->writeln('<error>Thrift compiler not found, pass an executable with --thrift</error>');
46+
47+
return Command::FAILURE;
48+
}
49+
50+
if (!str_contains((string) file_get_contents($idl), 'namespace php Flow.Parquet.ThriftModel')) {
51+
$output->writeln('<error>Flow php namespace not found in thrift definition!</error>');
52+
53+
return Command::FAILURE;
54+
}
55+
56+
foreach (glob($models . '/*.php') ?: [] as $file) {
57+
unlink($file);
58+
}
59+
60+
(new Process([$thrift, '--gen', 'php', '--out', $source, $idl]))->mustRun(
61+
static fn(string $type, string $buffer) => $output->write($buffer),
62+
);
63+
64+
// the Thrift compiler types every field non-null; a field the IDL does not require is null when a file leaves it out
65+
foreach (glob($models . '/*.php') ?: [] as $file) {
66+
$class = 'Flow\\Parquet\\ThriftModel\\' . basename($file, '.php');
67+
68+
if (!class_exists($class) || !property_exists($class, '_TSPEC')) {
69+
continue;
70+
}
71+
72+
/** @var array<int, array{var: string, isRequired: bool}> $spec */
73+
$spec = (new ReflectionProperty($class, '_TSPEC'))->getValue();
74+
$model = (string) file_get_contents($file);
75+
$rewritten = $model;
76+
77+
foreach ($spec as $field) {
78+
if ($field['isRequired']) {
79+
continue;
80+
}
81+
82+
$rewritten = (string) preg_replace(
83+
'/(\* @var )(?!null\|)([^\n]+)(\n\s*\*\/\n\s*public \$'
84+
. preg_quote($field['var'], '/')
85+
. ' = null;)/',
86+
'$1null|$2$3',
87+
$rewritten,
88+
);
89+
}
90+
91+
if ($rewritten !== $model) {
92+
file_put_contents($file, $rewritten);
93+
}
94+
}
95+
96+
return Command::SUCCESS;
97+
}
98+
});
99+
100+
$application->run();

‎composer.json‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -260,6 +260,7 @@
260260
"src/lib/filesystem/src/Flow/Filesystem/DSL/functions.php",
261261
"src/lib/parquet/src/Flow/Parquet/functions.php",
262262
"src/lib/parquet/src/Flow/Parquet/Binary/functions.php",
263+
"src/lib/parquet/src/Flow/Parquet/DSL/functions.php",
263264
"src/lib/parquet/src/stubs.php",
264265
"src/lib/postgresql/src/Flow/PostgreSql/DSL/client.php",
265266
"src/lib/postgresql/src/Flow/PostgreSql/DSL/condition.php",

‎documentation/components/adapters/parquet.md‎

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,3 +73,72 @@ data_frame()
7373
->write(to_output())
7474
->run();
7575
```
76+
77+
A `UINT_64` column reads as `numeric_string` (`'18446744073709551615'` is past `PHP_INT_MAX`). A `numeric_string`
78+
column is written as a parquet `STRING`; write `UINT_64` through the lib with an explicit schema:
79+
80+
```php
81+
<?php
82+
83+
use Flow\Parquet\ParquetFile\Schema;
84+
use Flow\Parquet\ParquetFile\Schema\{FlatColumn, LogicalType, PhysicalType};
85+
use Flow\Parquet\Writer;
86+
87+
(new Writer())->write(
88+
__DIR__ . '/ids.parquet',
89+
Schema::with(new FlatColumn('id', PhysicalType::INT64, logicalType: LogicalType::integer(64, false))),
90+
[['id' => 7], ['id' => '18446744073709551615']],
91+
);
92+
```
93+
94+
## Filter pushdown
95+
96+
A `filter()` directly over `from_parquet()` moves into the reader, which returns only matching rows: row groups whose
97+
statistics prove that no row matches are skipped, and a `limit()` after the filter stops the read at that many
98+
matching rows:
99+
100+
```php
101+
<?php
102+
103+
use function Flow\ETL\Adapter\Parquet\from_parquet;
104+
use function Flow\ETL\DSL\{data_frame, lit, ref, to_output};
105+
106+
data_frame()
107+
->read(from_parquet(__DIR__ . '/orders/*.parquet'))
108+
->filter(ref('id')->greaterThan(lit(1000))->and(ref('status')->equals(lit('paid'))))
109+
->limit(10)
110+
->write(to_output())
111+
->run();
112+
```
113+
114+
Each part of an `and()` is pushed on its own when it compares a file column with a literal it can compare exactly.
115+
116+
Pushed:
117+
118+
- `equals`, `notEquals`, `same`, `notSame`, `lessThan`, `lessThanEqual`, `greaterThan`, `greaterThanEqual`,
119+
`between`, `isIn` (literal list), `startsWith`, `isNull`, `isNotNull`, and `not()`/`and()`/`or()` of those
120+
- an int literal on a float column up to 2^53, an integral float on an int column below 2^53
121+
- a `date` literal on a `datetime` column and the reverse
122+
- a digit string (`lit('1000')`) on a `numeric_string` (`UINT_64`) column
123+
124+
Evaluated on the rows read:
125+
126+
- other functions, two columns compared, a literal of another type
127+
- `json` columns (except `isNull`/`isNotNull`), ordering on `uuid`
128+
- a column the file stores as another type than the read
129+
130+
Partition columns list fewer files instead ([partitioning](/documentation/components/core/partitioning.md)).
131+
132+
`explain()` shows the filter and the limit under `Read`, not what the reader skipped:
133+
134+
```
135+
#3 Result
136+
│ Rows this plan hands out: to the trigger, or to the node reading it
137+
└─ #2 Limit
138+
│ Limit: 10
139+
└─ #1 Read
140+
Extractor: ParquetExtractor
141+
Source: file:///orders/*.parquet
142+
Limit: 10
143+
Filter: All
144+
```

‎documentation/components/core/core.md‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -67,7 +67,7 @@ Outputs
6767
├─ #4 Result
6868
│ │ Rows this plan hands out: to the trigger, or to the node reading it
6969
│ └─ #3 Select
70-
│ │ Columns: id, email
70+
│ │ Keeps only: id, email
7171
│ └─ #2 Filter
7272
│ │ Condition: IsNotNull
7373
│ └─ #1 Read
@@ -129,7 +129,7 @@ echo $dataFrame->explain()->toString(format: Format::flow);
129129
└─ #2 Filter
130130
│ Condition: IsNotNull
131131
├─ #3 Select
132-
│ │ Columns: id, email
132+
│ │ Keeps only: id, email
133133
│ └─ #4 Result
134134
│ Rows this plan hands out: to the trigger, or to the node reading it
135135
└─ #5 Write
@@ -154,7 +154,7 @@ echo $dataFrame->explain()->toString(format: Format::boxes);
154154
┌─────────────┴─────────────┐┌─────────────┴─────────────┐
155155
│ #3 Select ││ #2 Filter │
156156
│ ──────────────────── ││ (shared) │
157-
│ Columns: id, email ││ │
157+
│ Keeps only: id, email ││ │
158158
└─────────────┬─────────────┘└───────────────────────────┘
159159
┌─────────────┴─────────────┐
160160
│ #2 Filter │

‎documentation/components/core/partitioning.md‎

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -268,8 +268,10 @@ it from the file schema (Parquet, Floe), so `filter(ref('date')->between(lit($fr
268268

269269
### Partition Pruning
270270

271-
The optimizer pushes a `filter()` that reads only partition columns into the source, so whole directories
272-
are skipped; a filter on a body column still reads everything and then discards. A partition column renamed
271+
The optimizer moves a `filter()` into the file source, which returns only matching rows. A filter that reads only
272+
partition columns skips whole directories; a filter on a body column reads every file and drops the rows it does not
273+
match (Parquet skips row groups, see [filter pushdown](/documentation/components/adapters/parquet.md#filter-pushdown)).
274+
A `limit()` after the filter reaches the source with it ([filter pushdown](/documentation/components/adapters/parquet.md#filter-pushdown)). A partition column renamed
273275
with `rename()` or copied with `withEntry('day', ref('date'))` is still pruned; any other redefinition of it
274276
stops the push. A filter whose answer can change between two evaluations - `call()`, `now()`, `uuid()`,
275277
`to_date_time()` with a format that leaves fields out (`'Y-m-d'`) - still filters rows but prunes nothing.
@@ -283,6 +285,7 @@ use function Flow\ETL\DSL\{data_frame, lit, ref, to_output};
283285
data_frame()
284286
->read(from_csv(__DIR__ . '/output/date=*/department=*/*.csv'))
285287
->filter(ref('date')->greaterThanEqual(lit('2024-01-01')))
288+
->limit(10)
286289
->write(to_output())
287290
->run();
288291
```

‎documentation/components/extensions/arrow-ext.md‎

Lines changed: 12 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -92,18 +92,23 @@ use Flow\Parquet\Engine\{RustParquetFileReader, RustParquetFileWriter};
9292
use Flow\Parquet\Engine\Arrow\{OptionsConverter, SchemaConverter};
9393
use Flow\Parquet\Options;
9494
use Flow\Parquet\ParquetFile\Compressions;
95+
use Flow\Parquet\ParquetFile\Index\StreamIndexes;
96+
use Flow\Parquet\Reader\ReadPlan;
9597

9698
use function Flow\Filesystem\DSL\{native_local_filesystem, path};
9799

98100
$filesystem = native_local_filesystem();
99101

100102
$file = new RustParquetFileReader($filesystem->readFrom(path('data.parquet'))); // Flow\Parquet\ParquetFileReader, one footer read
101-
$file->rowsNumber();
102-
$file->schema(); // Flow\Parquet\ParquetFile\Schema
103-
$file->metadata(); // Flow\Parquet\ParquetFile\Metadata
104-
$file->thrift(); // Flow\Parquet\ThriftModel\FileMetaData
105-
106-
foreach ($file->readColumns(['id', 'name'], 1_000, null, null) as $chunk) { // RustColumnsReader, an Iterator that streams once
103+
$footer = $file->footer(); // Flow\Parquet\Engine\RustParquetFooter, readable after close()
104+
$footer->rowsNumber();
105+
$footer->schema(); // Flow\Parquet\ParquetFile\Schema
106+
$footer->metadata(); // Flow\Parquet\ParquetFile\Metadata
107+
$footer->thrift(); // Flow\Parquet\ThriftModel\FileMetaData
108+
$indexes = new StreamIndexes($footer, $filesystem->readFrom(path('data.parquet'))); // page indexes, read on demand
109+
$plan = ReadPlan::window($footer->rowsNumber(), $indexes, offset: 10, limit: 100); // rows 10-109
110+
111+
foreach ($file->readColumns(['id', 'name'], 1_000, $plan) as $chunk) { // RustColumnsReader, an Iterator that streams once
107112
// ['id' => [1, 2, ...], 'name' => ['a', 'b', ...]]
108113
}
109114

@@ -118,7 +123,7 @@ $writer->writeBatch([['id' => 1, 'name' => 'a']]); // an array or any Traversa
118123
$writer->writeColumns(['id' => [2, 3], 'name' => ['b', null]]);
119124
$writer->close(); // footer, then the stream closed
120125

121-
$batches = new RustBatchReader($file, ['id', 'name'], 1_000, null, null); // batch size, offset, limit
126+
$batches = new RustBatchReader($file, ['id', 'name'], 1_000, $plan); // batch size, rows to read
122127
$batches->schema(); // Flow\Arrow\RustArrowSchema
123128
$batches->next(); // Flow\Arrow\RustParquetBatch, null after the last
124129
```

0 commit comments

Comments
 (0)