Rust - Extension Development
This page tells you how to develop the two Rust PHP extensions in this monorepo. Both use the ext-php-rs framework.
| Extension | Package | What it provides |
|---|---|---|
arrow-ext |
flow-php/arrow-ext |
Parquet reader and writer powered by the Apache Arrow Rust ecosystem, exposed as Flow\Parquet\Engine\RustParquetEngine |
flow-php-ext |
flow-php/flow-php-ext |
The native column backend (Flow\ETL\Values\Column\RustBackend, RustColumn, RustColumnBuilder over Apache Arrow arrays) and the Rust CSV / JSON / Parquet sources and encoders |
Both are optional. The pure-PHP implementations in flow-php/etl and flow-php/parquet stay the reference for the
behaviour. Each contract has a Php*, a Rust* and an Adaptive* class. If the extension is loaded, the
Adaptive* class picks the Rust class.
For the usage documentation, read Arrow Extension and Flow PHP Extension.
Development Setup
nix-shell --arg with-rust true
This shell gives you the Rust toolchain, clang, libclang and the PHP dev headers. With them, you can build each extension from source.
with-arrow-ext and with-flow-php-ext both default to !with-rust. --arg with-rust true therefore turns off the
prebuilt extensions, and you do not have to give these flags. Give --arg with-arrow-ext false or
--arg with-flow-php-ext false only to override an explicit true. If you combine --arg with-rust true with one of
them set to true, the shell.nix assertion fails, because a prebuilt extension and a source build collide.
[!IMPORTANT] After
make build, PHP does not use the new extension. Thetesttarget of each Makefile loads the binary explicitly withphp -d extension=.... To use a new build in other places, runmake install, give-d extension=yourself, or startnix-shellagain. For more information, read Rebuilding after a source change.
Project Structure
src/extension/arrow-ext/
├── Cargo.toml # Rust dependencies and build config
├── Makefile # Build orchestration
├── src/ # Rust source code
│ ├── lib.rs # Extension entry point, module registration
│ ├── interfaces.rs # Flow\Parquet\{ParquetEngine, ParquetFileReader, ParquetFileWriter}, registered at MINIT
│ ├── parquet/ # RustParquetEngine, readers, writer, canonical Arrow types, Arrow C Data batches
│ ├── thrift.rs # Footer bytes as the PHP ThriftModel objects
│ ├── render.rs # Parquet refusals as the Flow\Parquet exceptions
│ ├── php.rs # PHP engine helpers (class entries, hashtables, calls)
│ ├── values.rs # PHP values through the engine's C date API
│ ├── alloc.rs # Counting global allocator
│ └── exception.rs # Exception mapping
├── php/ # PHP stubs for static analysis (used when extension is not loaded)
│ └── Flow/
│ ├── Arrow/
│ └── Parquet/Engine/
├── tests/
│ └── phpt/ # PHPT test files
└── ext/
└── config.m4 # PIE compatibility
src/extension/flow-php-ext/
├── Cargo.toml # Rust dependencies and build config
├── build.rs # Build script
├── Makefile # Build orchestration
├── src/ # Rust source code
│ ├── lib.rs # Extension entry point, module registration
│ ├── interfaces.rs # the package interfaces the Rust classes implement, registered at MINIT
│ ├── iterator.rs # RustIterator, the iterator the Rust sources return
│ ├── backend.rs # RustBackend
│ ├── arithmetic.rs # RustBackend::add/subtract/multiply/modulo on native columns
│ ├── compare.rs # RustBackend::compare on native columns
│ ├── sort.rs # RustBackend::sort on native columns
│ ├── group_table.rs # RustGroupTable: group ids by key, held between batches
│ ├── grouped_totals.rs # RustGroupedTotals: running sums and counts per group id
│ ├── grouped_counts.rs # RustGroupedCounts: non-null counts per group id
│ ├── column.rs # RustColumn
│ ├── builder.rs # RustColumnBuilder: native cast lanes, PHP lane for the rest
│ ├── physical.rs # Arrow rows as physical and logical zvals
│ ├── plan.rs # Per-type plan, cached by Type object and type JSON
│ ├── render.rs # flow-batch-frame refusals as the PHP messages
│ ├── cast.rs # Value casting
│ ├── json_check.rs # JSON validation shared by casting and CSV inference
│ ├── batch_columns.rs # BatchColumns: one native builder per definition, shared by the CSV and JSON sources
│ ├── source.rs # Shared stream loop of the Rust open sources
│ ├── csv/ # RustCSVOpenSource, RustCSVEncoder, schema-inference fold
│ ├── json/ # RustJsonOpenSource, RustJsonEncoder, JSON values into columns
│ ├── text/ # The PHP side of the text writers: Rows batches, DateTimeZone transitions
│ ├── parquet/ # RustParquetOpenSource, RustParquetFileWriter
│ ├── arrow_c.rs # RustColumnsBatch and the PHP side of the Arrow C Data hand-off (arrow-ext)
│ ├── ctx.rs # Request-scoped context and engine helpers
│ ├── globals.rs # Module globals, RINIT/RSHUTDOWN
│ ├── alloc.rs # Counting global allocator (allocatedBytes())
│ ├── values.rs # Zval <-> PHP value helpers
│ └── exception.rs # Exception mapping
├── crates/ # The cargo workspace members: pure Rust, no PHP types, every #[test] lives here
│ ├── flow-batch-frame/ # Column buffers, builders, BATCH frame bodies, kinds and value shapes, column keys
│ ├── flow-column-kernels/ # Compare, arithmetic, sort, grouped totals and group keys, as the PHP backend answers
│ ├── flow-column-text/ # Column values as the writers render them: floats, dates, JSON strings, uuids
│ ├── flow-json-records/ # JSON lines / array framing and member-to-column mapping
│ ├── flow-arrow-c/ # Batches across the Arrow C Data Interface, imported bytes counted
│ ├── flow-csv-tokenizer/ # CSV records and fields as php_fgetcsv() produces them
│ └── flow-numeric-string/ # PHP's is_numeric() grammar
├── php/ # PHP stubs for static analysis
│ └── Flow/
├── tests/
│ └── phpt/ # PHPT test files
└── ext/
└── config.m4 # PIE compatibility
Commands
Replace <extension> with arrow-ext or flow-php-ext.
Build:
nix-shell --arg with-rust true --run "cd src/extension/<extension> && make build"
Run the PHPT tests. test depends on build, so this command builds first:
nix-shell --arg with-rust true --run "cd src/extension/<extension> && make test"
Remove the build artifacts:
nix-shell --arg with-rust true --run "cd src/extension/<extension> && make clean"
Run the PHP test suites against the prebuilt extension. These commands use the default shell, not the Rust shell:
nix-shell --run "just test --testsuite=lib-parquet-integration" # arrow-ext
nix-shell --run "just test --testsuite=adapter-parquet-integration" # arrow-ext
nix-shell --run "just test --testsuite=etl-unit" # flow-php-ext
Make Targets
| Target | Description |
|---|---|
build |
Build the extension (cargo + copy) |
test |
Build, then run PHPT tests |
install |
Copy the built module into PHP's extension_dir |
clean |
Remove build artifacts |
rebuild |
Full clean + build |
The two PHPT runners use the same shell loop:
- Each test runs with
php -d extension=<the built .so>, not withphp -n. Thephp.iniof the shell and its extensions also load. - The runner obeys
--SKIPIF--blocks and reports the number of skipped tests. - The runner compares the output exactly. It accepts
--EXPECTF--, but it does not expand%s.
flow-php-ext also takes ARROW_SO=<path>, which loads arrow-ext first. The Parquet ETL phpts then run and do not
skip: make test ARROW_SO=../arrow-ext/ext/modules/arrow.so.
Rebuilding after a source change
.nix/pkgs/php-arrow-ext and .nix/pkgs/php-flow-php-ext build their extension from the local repository source.
A shell that you started before a change to a .rs file still contains the previous build. If you run just test in
that shell, the failures come from the old binary, not from your change.
Start nix-shell again to build the derivation again, or build and test the extension directly:
nix-shell --arg with-rust true --run "cd src/extension/flow-php-ext && make build && make test"
This command also covers all crates under crates/. The flow-php-ext build compiles them from the same workspace.
Extension ABI
Each extension registers an int constant with the version of the contract that its PHP package expects. The PHP
package holds the same number:
| Extension | Constant (Rust lib.rs) |
PHP side |
|---|---|---|
| flow_php | FLOW_PHP_ABI |
Flow\ETL\Foundation\FlowPhpExtension::ABI |
| arrow | FLOW_ARROW_ABI |
Flow\Parquet\Engine\ArrowExtension::ABI |
Increase both numbers in one change each time the contract changes. The contract is a class or interface that the
extension registers, or the schema, options or values that go across it. Each Adaptive* pick calls available().
If the loaded extension has a different ABI, or no ABI, the pick refuses it with a RuntimeException. The message
names the extension and its version. phpt 099 (flow_php) and 054 (arrow) make sure that the two numbers are equal.
An extension also registers PHP interfaces. The PHP declarations of these interfaces are guarded by
interface_exists(X::class, false). If an extension does not register one of them, PHP still gets the PHP
declaration. The extension then fails at the ABI check, not with "Class not found".
The flow-php-ext workspace
src/extension/flow-php-ext is a cargo workspace:
- The root package is the extension. It contains only the PHP glue: ext-php-rs classes, Zval conversions and exception text.
- The members under
crates/are pure Rust with no PHP types.
Each #[test] is in a member, so the test binaries link without a PHP engine. If a member uses a Zend symbol, it
fails to link on Linux. CI runs this link for each push. Each member returns its refusals as data (Error,
Refusal, Malformed). flow_php converts each refusal into the PHP message. flow-batch-frame holds the batch
layout of Flow. It has the buffers of one column in the canonical form of Column::encode() and back. It also has the
builders that append rows to the buffers, and the BATCH frame body around them.
nix-shell --arg with-rust true --run "cd src/extension/flow-php-ext && cargo test --workspace --features arrow-validate"
nix-shell --arg with-rust true --run "cd src/extension/flow-php-ext && cargo fmt --all --check && cargo clippy --workspace --all-targets -- -D warnings"
arrow-validate turns on arrow-data/force_validate. This feature makes sure again that each array that the
builders give with build_unchecked is valid. make test builds the extension with it, and make build builds it
without it. The same feature and the same three commands (without --workspace) apply to src/extension/arrow-ext.
arrow-ext does not depend on the workspace. Its canonical_type() (src/parquet/canonical.rs) must produce the same
Arrow types as kind::data_type() of flow-batch-frame. flow-php-ext phpt 091 tests this.
Releasing
You do not have to change a version number. After the tag X.Y.Z, builds without a tag report
X.(Y+1).0-dev+<commits>.g<sha>. The nix packages report X.(Y+1).0-dev. shell.nix reads this value from the newest
X.Y.Z tag in .git. In a checkout without tags, the nix-shell fails and tells you to run git fetch --tags.
Found a typo or an outdated section? Edit this page on GitHub