Lightweight, framework-agnostic ClickHouse helpers for PHP applications.
$qb = new ClickHouseQueryBuilder(allowedFields: ['id', 'status'], fieldTypes: ['id' => T::UInt64]);
$where = $qb->buildWhere(new Equals('status', 'active'));
$sql = $qb->buildSelect(table: 'events', where: $where->sql, limit: 20);ClickHouseClientFactory+ClickHouseConfig— build a configured client over any PSR-18 HTTP client (auto-discovered or injected; HTTP/HTTPS).ClickHouseQueryBuilder— turnyiisoft/datafilters and sort into safe, parameterized SQL.ClickHouseFilterVisitor+ClickHouseSqlFilterVisitor— extensible visitor for SQL generation per filter type.ClickHouseDataReader— an immutableDataReaderInterfaceready for yiisoft/data paginators.ClickHouseBatchWriter— buffered, batched inserts.ClickHouseTableBuilder— fluentCREATE TABLEDDL.ClickHousePartitionManager— list / drop / detach / attach / move / freeze partitions.ClickHouseMutationBuilder— asyncALTER … UPDATE/DELETEwith mutation tracking.ClickHouseMigrationRunner— idempotent, checksum-verified*.sqlmigrations.ClickHouseMigrationGenerator— creates new migration files with auto-incremented numeric prefixes.ClickHouseDataType— type-name constants and factories for parametric/nested types.
Built on top of simpod/clickhouse-client. The query/reader pieces integrate with the yiisoft/data reader abstractions, so they slot naturally into Yii3 admin grids and paginated APIs, but nothing here requires the full framework.
Using an AI coding assistant?
llms.txtis a compact, self-contained reference of the whole public API plus copy-paste recipes — drop it into the model's context. Contributors: seeAGENTS.md.
- Requirements
- Installation
- Quick start
- Components
- ClickHouseConfig & ClickHouseClientFactory
- ClickHouseQueryBuilder & WhereClause
- ClickHouseFilterVisitor
- ClickHouseDataReader
- ClickHouseBatchWriter
- ClickHouseTableBuilder
- ClickHousePartitionManager
- ClickHouseMutationBuilder
- ClickHouseDataType
- ClickHouseMigrationRunner
- ClickHouseMigrationGenerator & status()
- Console commands
- Interfaces
- Timezone handling
- Dependency injection
- Security notes
- What is intentionally not included
- Examples
- Development
- License
| Requirement | Version |
|---|---|
| PHP | ^8.3 |
| A PSR-18 HTTP client + PSR-17 factories | any implementation |
| ClickHouse server | tested against 23.x – 26.x over the HTTP interface (port 8123) |
The toolkit depends only on interfaces (psr/http-client, psr/http-factory, psr/log, php-http/discovery, simpod/clickhouse-client, yiisoft/data) — not on any concrete HTTP client. It auto-discovers an installed PSR-18 client/PSR-17 factories via php-http/discovery, or you can inject your own.
composer require rasuvaeff/clickhouse-toolkitYou also need a PSR-18 client and PSR-17 factories if your project doesn't already ship one, e.g.:
composer require guzzlehttp/guzzle
# or: composer require symfony/http-client nyholm/psr7use Rasuvaeff\ClickHouseToolkit\ClickHouseClientFactory;
use Rasuvaeff\ClickHouseToolkit\ClickHouseConfig;
use Rasuvaeff\ClickHouseToolkit\ClickHouseDataType as T;
use Rasuvaeff\ClickHouseToolkit\ClickHouseQueryBuilder;
use SimPod\ClickHouseClient\Format\JsonEachRow;
use Yiisoft\Data\Reader\Filter\In;
use Yiisoft\Data\Reader\Sort;
// 1. Build a client.
$client = (new ClickHouseClientFactory(new ClickHouseConfig(
host: 'clickhouse',
port: 8123,
database: 'app',
username: 'default',
password: '',
)))->create();
// 2. Build a safe, parameterized query from user-supplied filters.
$qb = new ClickHouseQueryBuilder(
allowedFields: ['id', 'status', 'created_at'],
fieldTypes: ['id' => T::UInt64, 'created_at' => T::DateTime],
defaultSort: 'id DESC',
);
$where = $qb->buildWhere(new In('status', ['active', 'pending']));
$orderBy = $qb->buildOrderBy(Sort::only(['created_at'])->withOrder(['created_at' => 'desc']));
$sql = $qb->buildSelect(table: 'events', columns: ['id', 'status'], where: $where->sql, orderBy: $orderBy, limit: 20);
// 3. Execute.
$output = $where->isEmpty()
? $client->select($sql, new JsonEachRow())
: $client->selectWithParams($sql, $where->params, new JsonEachRow());
foreach ($output->data as $row) {
// ...
}ClickHouseConfig holds connection settings; ClickHouseClientFactory turns it into a SimPod\ClickHouseClient\Client\PsrClickHouseClient. The HTTP client and PSR-17 factories are auto-discovered (or injected). The endpoint is an absolute URI built from the config; authentication and database are sent via X-ClickHouse-* headers (an AuthenticatingHttpClient decorator), so credentials never appear in the URL.
final readonly class ClickHouseConfig
{
public function __construct(
public string $host = '127.0.0.1',
public int $port = 8123,
public string $database = 'default',
public string $username = 'default',
public string $password = '',
public bool $secure = false, // true -> https://
) {}
public function baseUri(): string; // e.g. "http://127.0.0.1:8123"
}use Rasuvaeff\ClickHouseToolkit\ClickHouseClientFactory;
use Rasuvaeff\ClickHouseToolkit\ClickHouseConfig;
// Auto-discovers an installed PSR-18 client + PSR-17 factories:
$client = (new ClickHouseClientFactory(new ClickHouseConfig(
host: 'ch.internal',
secure: true, // https
)))->create();
$client->executeQuery('SELECT 1');To control timeouts, retries or TLS, build your own PSR-18 client and inject it (along with the PSR-17 factories you want):
use GuzzleHttp\Client;
$factory = new ClickHouseClientFactory(
config: new ClickHouseConfig(host: 'ch.internal', secure: true),
httpClient: new Client(['timeout' => 10.0]),
// requestFactory / streamFactory / uriFactory are optional (auto-discovered when null)
);Translates yiisoft/data filters and sort into parameterized ClickHouse SQL. The builder is the security boundary: only fields present in allowedFields are emitted in WHERE and ORDER BY; anything else is silently dropped. Comparison values become bound parameters with unique keys (p0, p1, …), so the same field may appear multiple times without collisions.
public function __construct(
private array $allowedFields, // list<string>
private array $fieldTypes = [], // field => ClickHouse type, default "String" (use ClickHouseDataType constants)
private string $defaultSort = '', // no ORDER BY by default; pass e.g. 'id DESC' for stable pagination
private ?FilterInterface $mandatoryFilter = null,
private ?string $serverTimezone = null, // IANA timezone; DateTime values are converted before formatting
) {}| Method | Returns | Description |
|---|---|---|
buildWhere(FilterInterface $filter) |
WhereClause |
{sql, params}; sql is empty when nothing matched. |
buildOrderBy(?Sort $sort) |
string |
ORDER BY fragment (allow-list-checked), or defaultSort; empty string means no ORDER BY. |
buildSelect(string $table, array $columns = [], string $where = '', ?string $orderBy = null, ?int $limit = 20, int $offset = 0) |
string |
columns empty → SELECT *; empty order → no ORDER BY; limit null → no LIMIT/OFFSET. |
buildCount(string $table, string $where = '') |
string |
SELECT count() AS cnt FROM .... |
buildDistinct(string $table, string $column) |
string |
SELECT DISTINCT col FROM ... ORDER BY col. |
WhereClause is a small DTO: public string $sql, public array $params, and isEmpty(): bool.
Supported filters
yiisoft/data filter |
Rendered as | Notes |
|---|---|---|
All |
empty WHERE |
|
None |
0 |
matches nothing |
Equals |
field = {p0:Type} |
|
GreaterThan / GreaterThanOrEqual |
field > / >= {p0:Type} |
|
LessThan / LessThanOrEqual |
field < / <= {p0:Type} |
|
EqualsNull |
field IS NULL |
no params |
In |
field IN ({p0:Type}, {p1:Type}, …) |
empty values → 0 (match nothing) |
Between |
field BETWEEN {p0:Type} AND {p1:Type} |
|
Like |
field ILIKE {p0:String} (or LIKE if caseSensitive) |
non-string fields are wrapped in toString(field); empty values are dropped; value bound + wildcard-escaped; honours LikeMode Contains/StartsWith/EndsWith |
Not |
NOT (...) |
dropped if the inner filter is empty |
AndX / OrX |
(a AND/OR b …) |
empty sub-filters skipped |
DateTimeInterface values are normalized to Y-m-d H:i:s; bool to 0/1.
Mandatory filters (tenant / owner / ACL)
The builder is fluent and immutable. withMandatoryFilter() attaches an
always-applied filter that is AND-combined with the user filter and bypasses
the allow-list (its fields need not be in allowedFields; identifiers are still
validated). This is the safe way to enforce access constraints — the user filter can
only narrow within it.
$qb = ClickHouseQueryBuilder::create(['id', 'status'], ['id' => T::UInt64])
->withMandatoryFilter(new Equals('tenant_id', $tenantId));
$where = $qb->buildWhere($userFilter); // (tenant_id = {p0:...}) AND (<user filter>)Raw expressions
ClickHouseRawFilter is a FilterInterface that emits a raw SQL fragment for things
the typed filters can't express. The SQL is trusted (never from user input); values
go in $params using {name:Type} placeholders whose names must not clash with the
builder's auto keys (p0, p1, …).
use Rasuvaeff\ClickHouseToolkit\ClickHouseRawFilter;
$where = $qb->buildWhere(new ClickHouseRawFilter('toDate(created_at) = {d:Date}', ['d' => '2024-01-01']));Full read + count cycle
use Yiisoft\Data\Reader\Filter\AndX;
use Yiisoft\Data\Reader\Filter\Equals;
use Yiisoft\Data\Reader\Filter\GreaterThanOrEqual;
$where = $qb->buildWhere(new AndX(
new Equals('status', 'active'),
new GreaterThanOrEqual('user_id', 1000),
));
$selectSql = $qb->buildSelect(table: 'events', columns: ['id', 'status'], where: $where->sql, limit: 50);
$countSql = $qb->buildCount(table: 'events', where: $where->sql);
$rows = $client->selectWithParams($selectSql, $where->params, new JsonEachRow())->data;
$total = (int) ($client->selectWithParams($countSql, $where->params, new JsonEachRow())->data[0]['cnt'] ?? 0);The query builder delegates SQL generation to a visitor. ClickHouseFilterVisitor is the interface with a visit*() method per filter type; ClickHouseSqlFilterVisitor is the default implementation. Use dispatch(FilterInterface $filter, int &$index, bool $trusted) to route any filter to the right method.
Implement ClickHouseFilterVisitor and inject via withVisitor() to customise SQL generation:
use Rasuvaeff\ClickHouseToolkit\ClickHouseFilterVisitor;
use Rasuvaeff\ClickHouseToolkit\ClickHouseQueryBuilder;
$qb = ClickHouseQueryBuilder::create(['id'], ['id' => 'UInt64'])
->withVisitor(new MyCustomVisitor());An immutable Yiisoft\Data\Reader\DataReaderInterface backed by a ClickHouse table. Filtering, sorting and pagination are delegated to the query builder; rows are mapped to your value type by a supplied mapper. It plugs straight into yiisoft/data paginators (OffsetPaginator, KeysetPaginator).
use Rasuvaeff\ClickHouseToolkit\ClickHouseDataReader;
use Rasuvaeff\ClickHouseToolkit\ClickHouseDataType as T;
use Rasuvaeff\ClickHouseToolkit\ClickHouseQueryBuilder;
use Yiisoft\Data\Reader\Filter\Equals;
use Yiisoft\Data\Reader\Sort;
$reader = new ClickHouseDataReader(
client: $client,
table: 'events',
queryBuilder: new ClickHouseQueryBuilder(
allowedFields: ['id', 'type', 'created_at'],
fieldTypes: ['id' => T::UInt64, 'created_at' => T::DateTime],
defaultSort: 'id DESC',
),
mapper: static fn (array $row): array => ['id' => (int) $row['id'], 'type' => (string) $row['type']],
columns: ['id', 'type'],
);
$page = $reader
->withFilter(new Equals('type', 'click'))
->withSort(Sort::only(['id'])->withOrder(['id' => 'desc']))
->withLimit(20)
->withOffset(40);
$total = $page->count(); // ignores limit/offset
$rows = $page->read(); // mapped valuesImplements read(), readOne(), count(), getIterator(), and the immutable withFilter/withSort/withLimit/withOffset (+ getters). With no limit set, read() omits LIMIT and returns the full result.
Buffers rows and inserts them in fixed-size batches. Each row is projected onto the declared columns (extra keys dropped, missing keys → null), so loosely-shaped associative rows are fine. Failures are wrapped in ClickHouseWriteException.
use Rasuvaeff\ClickHouseToolkit\ClickHouseBatchWriter;
$writer = new ClickHouseBatchWriter(
client: $client,
table: 'events',
columns: ['id', 'type', 'user_id', 'created_at'],
batchSize: 1000,
);
$writer->write($rows); // $rows: iterable<array<string, mixed>> — a generator keeps memory flatImplements ClickHouseWriterInterface (write(iterable $rows): void).
Fluent CREATE TABLE builder. build() returns the SQL; execute() runs it via
the client. The table name and column names are validated identifiers; column
types, the engine, and the ORDER BY / PARTITION BY / PRIMARY KEY expressions are
emitted verbatim — DDL is developer-authored, so keep them trusted.
use Rasuvaeff\ClickHouseToolkit\ClickHouseDataType as T;
use Rasuvaeff\ClickHouseToolkit\ClickHouseTableBuilder;
ClickHouseTableBuilder::create($client, 'events')
->ifNotExists()
->column('id', T::UInt64)
->column('created_at', T::DateTime)
->engine('MergeTree()')
->partitionBy('toYYYYMM(created_at)')
->primaryKey('id')
->orderBy('(id, created_at)')
->execute();build()/execute() throw if no columns or no engine were set.
Manages MergeTree partitions through ALTER TABLE … PARTITION. Partition
operations can't use bound parameters, so a partition is addressed by its id
(from getPartitions()) and emitted as an escaped PARTITION ID '…'; table and
column names are validated identifiers.
use Rasuvaeff\ClickHouseToolkit\ClickHousePartitionManager;
$pm = new ClickHousePartitionManager($client);
foreach ($pm->getPartitions('events') as $p) {
// ['partition' => '202401', 'partition_id' => '202401', 'rows' => 12345, 'bytes' => 987654]
}
$pm->dropPartition('events', '202401');
$pm->detachPartition('events', '202401');
$pm->attachPartition('events', '202401');
$pm->freezePartition('events', '202401');
$pm->clearColumnInPartition('events', '202401', 'payload');
$pm->movePartition('events', 'events_archive', '202401'); // MOVE … TO TABLE
$pm->replacePartition('events', 'events_mirror', '202401'); // REPLACE … FROMSubmits and tracks mutations — ALTER TABLE … UPDATE/DELETE, the only way to
modify or delete existing rows. Mutations are asynchronous. The $set and
$condition fragments are trusted (developer-authored); pass user values as
bound {name:Type} parameters (ClickHouse supports parameters in ALTER).
use Rasuvaeff\ClickHouseToolkit\ClickHouseMutationBuilder;
$mb = new ClickHouseMutationBuilder($client);
$mb->update('events', 'status = {st:String}', 'id = {id:UInt64}', ['st' => 'archived', 'id' => 42]);
$mb->delete('events', 'created_at < {cutoff:DateTime}', ['cutoff' => '2023-01-01 00:00:00']);
$mb->waitForMutations('events', timeout: 30.0); // poll system.mutations until done -> bool
foreach ($mb->getMutations('events') as $m) {
// ['mutation_id' => '...', 'command' => '...', 'is_done' => true, 'parts_to_do' => 0, 'latest_fail_reason' => '']
}
$mb->killMutation('events', $mutationId);Applies *.sql files from a directory in filename order, recording each applied file with a content checksum in a _migrations table.
- Idempotent — already-applied files are skipped.
- Tamper-evident — if an already-applied file's contents changed, a
ClickHouseMigrationExceptionis thrown instead of silently diverging. - One statement per file — contents are sent as a single query (no naive
;splitting). - Optional PSR-3 logging — pass a
LoggerInterfaceto log applied/skipped files.
use Rasuvaeff\ClickHouseToolkit\ClickHouseMigrationRunner;
$runner = new ClickHouseMigrationRunner(
client: $client,
migrationsPath: __DIR__ . '/migrations',
logger: $logger, // optional PSR-3
);
$applied = $runner->run(); // list<string> of files applied this callTracking table (created automatically):
CREATE TABLE IF NOT EXISTS `_migrations` (
name String, checksum String, applied_at DateTime64(6) DEFAULT now64(6)
) ENGINE = ReplacingMergeTree(applied_at) ORDER BY nameName files so lexicographic order equals execution order, e.g. 001_create_events.sql, 002_add_index.sql.
Concurrency & partial failure. ClickHouse has no transactions and the runner uses no distributed lock: the applied-list is read, then each file is executed and recorded separately. Two runners started at once may both run the same pending file, and if a file's DDL succeeds but the
_migrationsinsert does not, the next run repeats it. Run migrations from a single deploy step, prefer idempotent DDL (CREATE TABLE IF NOT EXISTS,ALTER TABLE ... ADD COLUMN IF NOT EXISTS), and wraprun()in an external lock if you need stronger guarantees.
Two helpers that round out the migration workflow:
ClickHouseMigrationGeneratorcreates a new migration file with the next sequential numeric prefix. It is a plain filesystem helper — no ClickHouse client required.ClickHouseMigrationRunner::status()reports the state of every migration file relative to the_migrationstable.
use Rasuvaeff\ClickHouseToolkit\ClickHouseMigrationGenerator;
$generator = new ClickHouseMigrationGenerator(__DIR__ . '/migrations');
$path = $generator->generate('add events index');
// Creates migrations/003_add_events_index.sql (003 = highest existing prefix + 1)
// with a header comment; write your DDL below the header.The description is sanitised to a slug (lowercase, non-alphanumeric runs collapse to _, trimmed). Prefix width matches the widest existing one and grows past 999 (999 → 1000).
use Rasuvaeff\ClickHouseToolkit\ClickHouseMigrationState;
$statuses = $runner->status(); // list<ClickHouseMigrationStatus>, sorted by name
foreach ($statuses as $status) {
// $status->name — '001_create_events.sql'
// $status->state — ClickHouseMigrationState::Applied
// $status->checksum — sha1 of the current file (null for Missing)
// $status->appliedAt — stored applied_at string (null for Pending)
}| State | Meaning |
|---|---|
Applied |
File exists, checksum matches the stored one. |
Pending |
File exists, not recorded in _migrations yet. |
Missing |
Recorded in _migrations, but the source file was removed. |
Diverged |
File exists and was recorded, but the checksum no longer matches (or conflicting checksums were recorded). |
Unlike run(), status() never throws on divergence — it surfaces the anomaly through the Diverged state.
Three Symfony Console commands wrap the migration API for CLI use. They live in Rasuvaeff\ClickHouseToolkit\Command and require symfony/console (^7.2, listed in require).
| Command | Wraps | Description |
|---|---|---|
clickhouse:migrations:generate <description> |
ClickHouseMigrationGenerator::generate() |
Creates NNN_description.sql with the next prefix. Exit 2 on invalid description, 1 on filesystem failure. |
clickhouse:migrations:status |
ClickHouseMigrationRunner::status() |
Prints a table of migrations + state counts. Exit 1 when any Missing or Diverged exists. |
clickhouse:migrations:migrate |
ClickHouseMigrationRunner::run() |
Applies pending migrations, one line per file. Idempotent. |
Register them in your Symfony Console Application:
use Rasuvaeff\ClickHouseToolkit\ClickHouseMigrationGenerator;
use Rasuvaeff\ClickHouseToolkit\ClickHouseMigrationRunner;
use Rasuvaeff\ClickHouseToolkit\Command\ClickHouseMigrationsGenerateCommand;
use Rasuvaeff\ClickHouseToolkit\Command\ClickHouseMigrationsRunCommand;
use Rasuvaeff\ClickHouseToolkit\Command\ClickHouseMigrationsStatusCommand;
use Symfony\Component\Console\Application;
$application = new Application('clickhouse-migrations');
$application->addCommands([
new ClickHouseMigrationsGenerateCommand(new ClickHouseMigrationGenerator($migrationsPath)),
new ClickHouseMigrationsStatusCommand($runner),
new ClickHouseMigrationsRunCommand($runner),
]);
$application->run();A runnable wiring example is in examples/console-application.php.
To wire the API directly into Yii3, Symfony or Laravel (container binding + your
own console command), see examples/framework-integrations.md.
Type-name constants and factories so type definitions are self-documenting and
typo-proof. Types are plain strings, usable anywhere one is expected
(ClickHouseTableBuilder columns, ClickHouseQueryBuilder field types).
use Rasuvaeff\ClickHouseToolkit\ClickHouseDataType as T;
T::UInt64; // 'UInt64'
T::nullable(T::String); // 'Nullable(String)'
T::array(T::nullable(T::String)); // 'Array(Nullable(String))'
T::map(T::String, T::UInt64); // 'Map(String, UInt64)'
T::decimal(10, 2); // 'Decimal(10, 2)'
T::dateTime64(3, 'UTC'); // "DateTime64(3, 'UTC')"
T::enum8(['active' => 1, 'inactive' => 2]); // "Enum8('active' = 1, 'inactive' = 2)"Composite types (Enum, timezone-qualified DateTime) are for column definitions, not query-parameter types.
| Interface | Method(s) | Purpose |
|---|---|---|
ClickHouseMigrationRunnerInterface |
run(): list<string> |
Implemented by ClickHouseMigrationRunner. |
ClickHouseWriterInterface |
write(iterable $rows): void |
Implemented by ClickHouseBatchWriter. |
ClickHouseReaderInterface |
findByFilters(...), countByFilters(...) |
A simpler reader contract than DataReaderInterface; implement it per table when you don't need the full reader (see examples/EventReader.php). |
ClickHouseFilterVisitor |
visit*() per filter type |
SQL generation for each filter type. Implemented by ClickHouseSqlFilterVisitor. Inject a custom implementation via withVisitor(). |
ClickHouseQueryBuilder accepts an optional serverTimezone (IANA name, e.g. "UTC", "Europe/Moscow"). When set, DateTimeInterface filter values are converted to that timezone before being formatted as Y-m-d H:i:s. This applies to filters whose value is a DateTimeInterface object (Equals, comparisons, Between); In values are scalar/string values and are passed as provided. Without serverTimezone, the object's own timezone is used (backward compatible).
$qb = new ClickHouseQueryBuilder(
allowedFields: ['created_at'],
fieldTypes: ['created_at' => T::DateTime],
serverTimezone: 'UTC',
);
// A DateTimeImmutable in Europe/Moscow (+03:00) will be formatted as UTC.
$where = $qb->buildWhere(new Equals('created_at', new \DateTimeImmutable('2024-06-15 15:00:00+03:00')));
// params: ['p0' => '2024-06-15 12:00:00']Fluent: $qb->withServerTimezone('UTC') returns a new instance.
Any PSR-11 container works. Example using Yiisoft DI definitions (Yii3):
use Rasuvaeff\ClickHouseToolkit\ClickHouseClientFactory;
use Rasuvaeff\ClickHouseToolkit\ClickHouseConfig;
use Rasuvaeff\ClickHouseToolkit\ClickHouseMigrationRunner;
use Rasuvaeff\ClickHouseToolkit\ClickHouseMigrationRunnerInterface;
use SimPod\ClickHouseClient\Client\ClickHouseClient;
use SimPod\ClickHouseClient\Client\PsrClickHouseClient;
return [
ClickHouseConfig::class => static fn (): ClickHouseConfig => new ClickHouseConfig(
host: $_ENV['CLICKHOUSE_HOST'] ?? 'clickhouse',
port: (int) ($_ENV['CLICKHOUSE_PORT'] ?? 8123),
database: $_ENV['CLICKHOUSE_DB'] ?? 'app',
username: $_ENV['CLICKHOUSE_USER'] ?? 'default',
password: $_ENV['CLICKHOUSE_PASSWORD'] ?? '',
),
PsrClickHouseClient::class => static fn (ClickHouseClientFactory $f): PsrClickHouseClient => $f->create(),
ClickHouseClient::class => PsrClickHouseClient::class, // toolkit classes type-hint the interface
ClickHouseMigrationRunnerInterface::class => static fn (ClickHouseClient $client): ClickHouseMigrationRunner => new ClickHouseMigrationRunner(
client: $client,
migrationsPath: dirname(__DIR__) . '/resources/clickhouse-migrations',
),
];See examples/di-container.php for a runnable plain-PHP container wiring.
- Allow-list enforcement.
ClickHouseQueryBuilderonly emits allow-listed fields inWHEREandORDER BY(eachallowedFieldsentry is validated as an identifier at construction). Pass user-controlled filter/sort objects straight through — unknown fields are dropped. - Disallowed user filters are silently dropped (widening, not narrowing). For mandatory tenant/owner/ACL constraints do not rely on user filters — use
withMandatoryFilter(), which is always applied and AND-combined so the user filter can only narrow within it. - Bound parameters. All comparison/
In/Between/Likevalues are passed as ClickHouse bound parameters ({pN:Type}) with unique keys; values are never concatenated into SQL. Likeescaping.Likevalues are wildcard-escaped (addcslashes($value, '%_\\')) and bound as a parameter — the quote is not escaped (it lives in the parameter, not the SQL). EmptyLikevalues are dropped. Non-string fields are compared astoString(field) LIKE/ILIKE {pN:String}so user filters cannot make ClickHouse reject numeric/date columns.- Table/column names passed to
buildSelect/buildCount/buildDistinctand thecolumnsprojection are not escaped, but they are validated as plain SQL identifiers (db.tableallowed); a malformed identifier throwsInvalidArgumentException. Still pass trusted, plain identifiers — the validator rejects raw expressions (toDate(x) AS d), so build those yourself. - Pagination.
buildSelectrejects negativelimit/offsetwithInvalidArgumentException. orderBypassed tobuildSelect, and the constructor's non-emptydefaultSort, are trusted raw ORDER BY fragments — not validated. UsebuildOrderBy()output (allow-list-checked) or a hard-coded constant; never build them from untrusted input. The defaultdefaultSortis empty, so generic builders do not assume anidcolumn; set one explicitly for stable pagination.fieldTypestype tokens are validated (allowing parametric types likeArray(Nullable(String))) so they can't break out of the{name:Type}placeholder. They are developer configuration, not user input.- Credentials travel in
X-ClickHouse-*headers, not the URL.
- Concrete readers/writers for specific tables (row shapes are app-specific — use
ClickHouseDataReaderwith a mapper, or implementClickHouseReaderInterface). - Migration rollback / down-migrations. ClickHouse DDL (
ALTER ... DELETE) is often irreversible, so rollback creates a false sense of safety. Use forward-fix migrations with idempotent DDL instead. - Connection pooling or retries. Inject your own PSR-18 client (see Quick start) if you need timeouts, retry policies or circuit breakers.
- Framework bootloaders/service providers (wire it in your app — see Dependency injection).
Runnable, self-contained examples live in examples/:
| File | Server? | Shows |
|---|---|---|
query-builder.php |
no | Every supported filter/sort/select/count/distinct — prints the generated SQL. |
di-container.php |
no | Wiring the toolkit into a PSR-11 container. |
client.php |
yes | Building a client and running a query. |
run-migrations.php + migrations/ |
yes | Applying *.sql migrations idempotently. |
generate-migration.php |
no | Creating a new migration file with ClickHouseMigrationGenerator. |
migrations-status.php |
yes | Reporting migration state via ClickHouseMigrationRunner::status(). |
console-application.php |
yes | Wiring the three Symfony Console commands into an Application. |
batch-writer.php |
yes | Batched inserts via ClickHouseBatchWriter. |
reader.php + EventReader.php |
yes | A ClickHouseReaderInterface implementation with row mapping. |
data-reader.php |
yes | Immutable ClickHouseDataReader (paginator-ready). |
See examples/README.md for how to run them.
composer install
composer build # validate + normalize + require-checker + cs + psalm + testo
composer test # testo Unit + Integration suites
composer cs:fix # apply php-cs-fixer
composer psalm # static analysis (errorLevel=1)Integration tests in tests/Integration/ run end-to-end against a real server and are skipped unless CLICKHOUSE_HOST is set:
CLICKHOUSE_HOST=127.0.0.1 CLICKHOUSE_PASSWORD=… vendor/bin/testo --suite=IntegrationCI runs composer build on PHP 8.3, 8.4, and 8.5.
BSD-3-Clause. See LICENSE.md.