Skip to content
Search

Transformations

Back

Introduction

A Transformation changes a DataFrame. You can combine Transformations and use them again. A Transformer gets one batch (Rows) at a time. A Transformation works on the DataFrame, so it has access to the full processing pipeline.

Every Transformation implements the Transformation interface. The interface has one method:

interface Transformation
{
    public function __invoke(DataFrame $dataFrame): DataFrame;
}

select('id')($dataFrame) equals $dataFrame->with(select('id')). On PHP 8.5, every Transformation is a step of the pipe operator.

Using Transformations

with() applies a transformation to a DataFrame. It accepts a Transformation object, or the result of a DSL function.

use function Flow\ETL\DSL\{df, from_array, select, drop, to_output};

// Keeping columns
df()
    ->read(from_array([['id' => 1, 'name' => 'Alice', 'temporary_column' => 'x']]))
    ->with(select('id', 'name'))
    ->write(to_output())
    ->run();

// Dropping a column
df()
    ->read(from_array([['id' => 1, 'name' => 'Alice', 'temporary_column' => 'x']]))
    ->with(drop('temporary_column'))
    ->write(to_output())
    ->run();

Available Transformations

Select

Keeps only the columns that you select from the DataFrame.

use function Flow\ETL\DSL\{df, from_array, select, ref, to_output};

// Select columns by name
df()
    ->read(from_array([
        ['id' => 1, 'name' => 'Alice', 'age' => 25, 'city' => 'New York'],
        ['id' => 2, 'name' => 'Bob', 'age' => 30, 'city' => 'Los Angeles'],
    ]))
    ->with(select('id', 'name'))
    ->write(to_output())
    ->run();

// Select using References for more control
df()
    ->read(from_array([['id' => 1, 'name' => 'Alice', 'city' => 'New York']]))
    ->with(select(ref('id'), ref('city')))
    ->write(to_output())
    ->run();

Drop

Removes the given columns from the DataFrame. All other columns stay.

use function Flow\ETL\DSL\{df, from_array, drop, ref, to_output};

// Drop columns by name
df()
    ->read(from_array([
        ['id' => 1, 'password' => 'secret', 'name' => 'Alice'],
        ['id' => 2, 'password' => 'hidden', 'name' => 'Bob'],
    ]))
    ->with(drop('password'))
    ->write(to_output())
    ->run();

// Drop using References
df()
    ->read(from_array([['id' => 1, 'temp_column' => 'x', 'debug_info' => 'y']]))
    ->with(drop(ref('temp_column'), ref('debug_info')))
    ->write(to_output())
    ->run();

Batch Size

Sets the batch size for processing. For large datasets, a smaller batch size uses less memory.

use function Flow\ETL\Adapter\CSV\from_csv;
use function Flow\ETL\Adapter\Doctrine\to_dbal_table_insert;
use function Flow\ETL\DSL\{df, batch_size};

// Process large CSV file in batches of 100 rows
df()
    ->read(from_csv('huge_file.csv'))
    ->with(batch_size(100))
    ->write(to_dbal_table_insert($connection, 'users'))
    ->run();

Batch By

Keeps all rows with the same value of a column in one batch. The second argument is a minimum size. It joins small groups into one batch.

use function Flow\ETL\DSL\{df, from_array, batch_by, to_output};

df()
    ->read(from_array([
        ['order_id' => 1, 'item' => 'Widget'],
        ['order_id' => 1, 'item' => 'Gadget'],
        ['order_id' => 2, 'item' => 'Gizmo'],
    ]))
    ->with(batch_by('order_id', 2))
    ->write(to_output())
    ->run();

Add Row Index

Adds an index column to each row. Use it to record the position of a row or to make unique identifiers.

use function Flow\ETL\DSL\{df, from_array, add_row_index, to_output};
use Flow\ETL\Operators\Transformer\AddRowIndex\StartFrom;

// Add default index starting from 0
df()
    ->read(from_array([
        ['name' => 'Alice'],
        ['name' => 'Bob'],
    ]))
    ->with(add_row_index())
    ->write(to_output())
    ->run();
// Output: [['name' => 'Alice', 'index' => 0], ['name' => 'Bob', 'index' => 1]]

// Custom column name and start from 1
df()
    ->read(from_array([['name' => 'Alice'], ['name' => 'Bob']]))
    ->with(add_row_index('row_number', StartFrom::ONE))
    ->write(to_output())
    ->run();

The index starts again on every run of the frame. A custom Transformer that keeps state between batches can do the same. It must implement Flow\ETL\Operators\Transformer\Stateful. Its fresh() returns a new instance in its initial state, and every run uses that instance.

Limit

Sets the maximum number of rows to process. Use it to debug or to take a sample of the data.

use function Flow\ETL\Adapter\CSV\to_csv;
use function Flow\ETL\Adapter\Doctrine\from_dbal_query;
use function Flow\ETL\DSL\{df, from_array, limit, to_output};

// Process only first 1000 rows
df()
    ->read(from_dbal_query($connection, 'SELECT * FROM large_table'))
    ->with(limit(1000))
    ->write(to_csv('sample.csv'))
    ->run();

// Remove limit (process all rows)
df()
    ->read(from_array([['id' => 1], ['id' => 2]]))
    ->with(limit(null))
    ->write(to_output())
    ->run();

Mask Columns

Replaces column values with a mask string. Use it to hide sensitive information.

use function Flow\ETL\DSL\{df, from_array, mask_columns, to_output};

// Mask sensitive columns with default mask
df()
    ->read(from_array([
        ['name' => 'Alice', 'ssn' => '123-45-6789', 'salary' => 50000],
        ['name' => 'Bob', 'ssn' => '987-65-4321', 'salary' => 60000],
    ]))
    ->with(mask_columns(['ssn', 'salary']))
    ->write(to_output())
    ->run();
// Output: [['name' => 'Alice', 'ssn' => '******', 'salary' => '******'], ...]

// Use custom mask
df()
    ->read(from_array([['name' => 'Alice', 'credit_card' => '4111 1111 1111 1111']]))
    ->with(mask_columns(['credit_card'], '[REDACTED]'))
    ->write(to_output())
    ->run();

Chaining Transformations

You can chain Transformations into one pipeline:

use function Flow\ETL\Adapter\CSV\from_csv;
use function Flow\ETL\Adapter\JSON\to_json;
use function Flow\ETL\DSL\{df, select, add_row_index, limit, batch_size};

df()
    ->read(from_csv('users.csv'))
    ->with(select('id', 'name', 'email'))     // Keep only needed columns
    ->with(add_row_index('row_num'))          // Add row numbers
    ->with(limit(1000))                       // Process only first 1000
    ->with(batch_size(50))                    // Process in batches of 50
    ->write(to_json('users_sample.json'))
    ->run();

Transformations as Branches

to_transformation() and to_branch() are branches. Each branch becomes a separate root of the plan. It gets the rows of the node where you wrote it. Flow plans and binds it together with the rest of the frame.

use function Flow\ETL\Adapter\CSV\to_csv;
use function Flow\ETL\DSL\{df, from_array, lit, ref, select, to_branch, to_transformation};

df()
    ->read(from_array([['id' => 1, 'name' => 'Alice', 'active' => true], ['id' => 2, 'name' => 'Bob', 'active' => false]]))
    ->write(to_transformation(select('id', 'name'), to_csv('names.csv')))
    ->write(to_branch(ref('active')->equals(lit(true)), to_csv('active.csv')))
    ->run();

A branch filters first. A to_transformation() inside it transforms the rows that passed the condition. Branches can be nested. Where a branch takes a loader, it also takes another branch or a transaction:

to_branch(ref('active')->equals(lit(true)), to_transformation($sortById, to_csv('active.csv')));

The Transformation runs one time for each run, over the full stream that it gets. Thus limit() and add_row_index() apply to the full stream, not to each batch. A triggering verb (fetch(), count(), schema(), ...) inside it runs the plan before it, as a terminal verb does. onError() inside it throws InvalidLogicException at write(). Set the error handler on the outer frame.

use Flow\ETL\API\{DataFrame, Transformation};

use function Flow\ETL\DSL\{df, from_array, ref, to_output, to_transformation};

$sortById = new class implements Transformation {
    public function __invoke(DataFrame $dataFrame): DataFrame
    {
        return $dataFrame->sortBy([ref('id')]);
    }
};

df()
    ->read(from_array([['id' => 2], ['id' => 1]]))
    ->write(to_transformation($sortById, to_output()))
    ->run();

Memory Cost

All operations give the same correct result inside a branch. They differ in how much data they hold, and they hold it inside the branch. There are three groups:

Cost Operations
Grows with the whole stream sortBy(), aggregate(), groupBy()->aggregate(), pivot(), window functions, collect(), join(), repartition()
Grows with the number of distinct keys dropDuplicates(), constrain() with a UniqueConstraint
Constant select(), withColumn(), filter(), add_row_index(), limit(), until(), offset(), cache($id), batch_size(), batchBy()

The first group buffers rows in memory, or spills them to disk: sortBy() through the external sort, and join() through its hash-join buckets. It does this as on an outer frame. offset() and cache($id) are in the constant group. offset() counts the rows that it skips. cache($id) writes each batch as it passes. They need the full stream for a correct result, but they do not keep it.

Chunk Shape and Order

batch_size(), batchBy() and repartition() change only which rows go into each Rows that the loader of the branch gets. No row is lost or put in the wrong group.

repartition() and join() also change the order of the rows. Both group their output by key, not in input order. batchBy() keeps the input order. It only cuts the batches at the group boundaries.

repartition() does not use constant memory. It puts the full stream into buckets first. Only then are all rows with the same key together. Thus it is in the first group above, with sortBy() and join(). One directory for each key is a different thing, which you declare on the loader: to_csv(...)->partitionBy(partition_by(ref('region'))).

To change the batches of the pipeline, and not only the batches that the loader of the branch gets, call $df->batchSize(...) on the frame.

Failure Behaviour

A branch runs under the ->onError(...) handler of the frame. The handler gets each failure one time:

df()
    ->read(from_array([['id' => 2], ['id' => 1]]))
    ->onError(skip_rows_handler())                // a failing step inside the branch skips that batch
    ->write(to_transformation($sortById, to_csv('sorted.csv')))
    ->run();

A failing step inside the Transformation is a transformation failure. For a blocking operation, the skipped batch is all the rows that it buffered. With the default handler, the failure goes up and no loader of the run is closed.

If the closure() of a loader fails, the handler does not get the failure. The exception comes out of run().

This does not make the writes durable or atomic. closure() commits and closes, so a destination can keep the data written before the failure. For batches that write all or nothing, put the writes in a transaction: to_dbal_transaction(), to_pgsql_transaction().

Creating Custom Transformations

To make a custom transformation, implement the Transformation interface:

use Flow\ETL\API\{DataFrame, Transformation};

use function Flow\ETL\DSL\{df, from_array, ref, to_output};

final class UppercaseNames implements Transformation
{
    public function __invoke(DataFrame $dataFrame): DataFrame
    {
        return $dataFrame->withColumn(
            'name',
            ref('name')->upper()
        );
    }
}

// Use custom transformation
df()
    ->read(from_array([['name' => 'alice'], ['name' => 'bob']]))
    ->with(new UppercaseNames())
    ->write(to_output())
    ->run();

Found a typo or an outdated section? Edit this page on GitHub


Contributors

Built in the open.

Join us on GitHub
scroll back to top