Skip to content

feat!: migrate ClickHouse adapter to the utopia-php/query 0.6 builder - #4

Merged
lohanidamodar merged 7 commits into
mainfrom
feat/utopia-query-0.3.x
Aug 25, 2026
Merged

feat!: migrate ClickHouse adapter to the utopia-php/query 0.6 builder#4
lohanidamodar merged 7 commits into
mainfrom
feat/utopia-query-0.3.x

Conversation

@lohanidamodar

@lohanidamodar lohanidamodar commented May 17, 2026

Copy link
Copy Markdown
Contributor

Summary

Migrates the ClickHouse adapter to utopia-php/query 0.3.x (stable pin 0.3.*, resolving to the tagged 0.3.3 release — no dev-branch pins). Every table CREATE, materialized view, INSERT envelope, SELECT and DELETE now compiles through Schema\ClickHouse / Builder\ClickHouse; the adapter keeps only its HTTP transport, routing logic, and the few DDL shapes the library cannot express yet.

Rebased onto current main (7c3fbf1) as a single commit, so the migration now also covers everything that landed while this PR was open:

What changed

Schema (DDL via Schema\ClickHouse)

  • createTable() emits the events/gauges tables through Table\ClickHouse: typed columns with codec(), Engine::MergeTree, partitionBy, orderBy, settings.
  • createDailyTable() uses Engine::SummingMergeTree; createDailyMaterializedView() goes through Schema\ClickHouse::createMaterializedView (the MV body remains a hand SELECT — subquery-aggregation MV bodies do not round-trip through the builder yet).
  • Column shapes the typed API cannot express are emitted via rawColumn() so the deployed DDL stays byte-for-byte identical: DateTime64(3, 'UTC') (timezone argument), LowCardinality(Nullable(String)) (the typed API emits the invalid Nullable(LowCardinality(...)) nesting), and the hyphenated skip-index names (index-path), which the typed index API rejects.
  • Projections (ADD PROJECTION), MODIFY SETTING, the ADD COLUMN IF NOT EXISTS dim backfills and the retention MODIFY TTL / REMOVE TTL ALTERs stay raw SQL — no schema-layer support yet. The new geo / sdk / sdkVersion / ordinal columns flow into both the schema-layer CREATE and the raw backfill path automatically, since both read from Metric::getEventSchema() / getGaugeSchema().

Reads (SELECT via Builder\ClickHouse)

  • Every builder is initialised with useNamedBindings() + withParamTypes(); all schema columns are pinned to their ClickHouse parameter types (DateTime64(3, 'UTC'), Int64, String) so placeholders never fall back to PHP value inference.
  • Migrated: find/findFromTable, findAggregatedFromTable (interval buckets + dim group-bys), count (including the bounded LIMIT max subquery), sum and the routed daily/hybrid paths, findDaily, sumDaily, sumDailyBatch, getTotal*, getTimeSeries.
  • The hybrid daily+raw sum compiles its two sides as independent builder statements and merges them under SUM(...) FROM (... UNION ALL ...), prefixing one side's named bindings to avoid placeholder collisions.
  • Cursor pagination keeps the tuple-keyset comparison as a whereRaw fragment with its own named bindings (upstream builder helper deferred).
  • contains() / containsAny() / notContains() now pass straight through to the builder. The 0.3 ClickHouse builder compiles them to position(column, ?), which is the same substring semantics Align contains query with utopia-php/database semantics #28 introduced, and matches needles literally — so the adapter no longer needs its own escapeLikeWildcards() helper for % / _. The adapter still validates that every needle is a string and still rejects empty value lists.

Writes

  • addBatch() compiles the INSERT INTO t (...) FORMAT JSONEachRow envelope via insertFormat(); JSONEachRow body assembly and the non-retrying POST transport are unchanged.

Deletes

  • purge() / purgeDaily() emit lightweight DELETE FROM through the builder.

Query 0.3 API surface

  • Query::getMethod() returns the Method enum, so all string TYPE_* comparisons are gone (ClickHouse and Database adapters).
  • BREAKING: the UsageQuery::TYPE_GROUP_BY_INTERVAL / TYPE_GROUP_BY string constants are removed. UsageQuery::groupByInterval() and UsageQuery::groupBy() remain and now map to Method::GroupByTimeBucket / Method::GroupBy; groupBy() also accepts an array of columns to stay signature-compatible with the 0.3 base class.
  • composer.lock moves utopia-php/query 0.1.1 -> 0.3.3 and nothing else; utopia-php/validators and utopia-php/database stay on main's pins.

Test plan

  • CI Linter (Pint, psr12) — pass
  • CI CodeQL job, which runs composer check (PHPStan level max over src + tests) — pass
  • CI Tests on the pinned docker-compose.yml stack (ClickHouse 25.11 + MariaDB 10.7) — OK (264 tests, 1305 assertions)

Locally the rebase was additionally verified without Docker, against a natively installed ClickHouse 26.8.1 + MariaDB 11.8.8: 264 tests, 1293 assertions, 2 failures, 0 errors, and an origin/main (7c3fbf1) run on the exact same servers produced an identical result — same two tests, same assertion count. Those two, ClickHouseSchemaTest::testEventsTableSwapsBloomForSetOnLowCardinality and ::testGaugesTableSwapsBloomForSetOnLowCardinality, are a ClickHouse-version artefact: 26.x renders skip indexes as INDEX `index-status` (status) TYPE set(0) (parenthesised column list) where 25.11 renders INDEX `index-status` status TYPE set(0), and the assertions expect the un-parenthesised form. They pass on CI's 25.11 image.

Green coverage includes the SHOW CREATE TABLE schema assertions (codecs, LowCardinality(Nullable(String)), DateTime64(3, 'UTC'), hyphenated index-* names), projection/dim-routing, cursor pagination, purge propagation, the retention-TTL tests, and the contains wildcard-escaping e2e test added in #28.

Still deferred upstream (follow-up PRs)

  • Tuple-cursor builder helper (cursor pagination still uses whereRaw)
  • LowCardinality(Nullable(...)) column type and timezone argument for DateTime64
  • Skip-index names containing hyphens
  • Projections / MODIFY SETTING / ADD COLUMN IF NOT EXISTS / MODIFY TTL in the schema layer
  • MV bodies with aggregation subqueries

@greptile-apps

greptile-apps Bot commented May 17, 2026

Copy link
Copy Markdown

Greptile Summary

Migrates the ClickHouse adapter from hand-assembled SQL strings to the utopia-php/query 0.6 builder and schema layer, bumping the library from 0.1.1 to 0.6.0. All previously flagged thread concerns are addressed in this revision: runStatement dead code removed, ContainsAny and IsNull/IsNotNull properly added to the Database adapter with an explicit default throw, and the dependency is pinned to the stable 0.6.0 release.

  • DDLcreateTable, createDailyTable, and createDailyMaterializedView now go through Schema\ClickHouse; column shapes the typed API cannot yet express (timezone-argument DateTime64, LowCardinality(Nullable(...)), hyphenated skip-index names) fall back to rawColumn so deployed DDL stays byte-for-byte identical.
  • DML / SELECT — every read and delete path (findFromTable, findAggregatedFromTable, countFromTable, sumFromTable, sumDailyBatch, findDaily, getTimeSeries, purge, purgeDaily) is migrated; named bindings use useNamedBindings()+withParamTypes() throughout, and the hybrid sum path prefixes one side's bindings to prevent placeholder collisions.
  • Breaking APIUsageQuery::TYPE_GROUP_BY_INTERVAL, TYPE_GROUP_BY, TYPE_AGGREGATE string constants are removed; groupByInterval/groupBy/aggregate factories now emit Method enum values, and the many now-redundant isGroupByInterval/extractGroupByInterval/removeGroupByInterval helpers are deleted.

Confidence Score: 5/5

  • Safe to merge — the migration is behaviorally equivalent to main, all previously identified gaps are closed, and the test suite (264 tests, 1305 assertions on CI's ClickHouse 25.11) covers schema assertions, cursor pagination, purge propagation, retention TTL, and the contains wildcard e2e test.
  • Every read, write, and delete path is migrated to the builder, the Database adapter's previously silently-dropped ContainsAny predicate now correctly filters, IsNull/IsNotNull are properly wired in, and the dependency is locked to a stable tagged release. The only comments are minor style observations that do not affect correctness.
  • No files require special attention.

Important Files Changed

Filename Overview
src/Usage/Adapter/ClickHouse.php Large migration: all SQL assembly switched from hand-written strings to the ClickHouse builder/schema layer. New helpers (qualifyDdl, prefixNamedBindings, normalizeTimeValues, applyFilters, applyTenantFilter, applyOrderBy) are small and well-scoped. No logic regressions found; previously flagged issues (runStatement dead code, ContainsAny, IsNull/IsNotNull) are resolved.
src/Usage/Adapter/Database.php String TYPE_* constants replaced with Method enum values. ContainsAny, IsNull and IsNotNull arms added; default throw added to catch unsupported methods. CursorAfter/CursorBefore now explicitly refuse rather than silently produce wrong results.
src/Usage/UsageQuery.php TYPE_GROUP_BY_INTERVAL, TYPE_GROUP_BY, TYPE_AGGREGATE string constants removed; groupByInterval/groupBy/aggregate factories now emit Method enum values. Helper methods isGroupByInterval/extractGroupByInterval/removeGroupByInterval etc. removed in favour of direct Method enum comparisons. groupBy() accepts string
composer.json utopia-php/query constraint bumped from 0.1.* to 0.6.*; composer.lock resolves to the tagged 0.6.0 release with a stable reference hash. Previous dev-branch pin concern from thread is resolved.
tests/Usage/UsageBase.php New testContainsAnyFiltersRatherThanBeingDropped test added to catch the previously silently-dropped ContainsAny predicate in both adapters. Test asserts filtered count is strictly less than unfiltered count, providing a meaningful regression guard.

Reviews (13): Last reviewed commit: "chore: correct the changelog version and..." | Re-trigger Greptile

Comment thread src/Usage/Adapter/ClickHouse.php Outdated
Comment thread composer.json Outdated
Comment thread phpstan-baseline.neon Outdated
Comment thread src/Usage/Adapter/ClickHouse.php Outdated
Comment thread src/Usage/Adapter/ClickHouse.php Outdated
Comment thread src/Usage/Adapter/ClickHouse.php Outdated
Comment thread src/Usage/Adapter/ClickHouse.php Outdated
Bump utopia-php/query from 0.1.* to 0.3.* and compile the ClickHouse
adapter's SQL through the shared builder and schema layer instead of
hand-assembled strings:

- Reads (find, count, sum, totals, time series, daily, hybrid
  daily+raw union) build through the ClickHouse query builder with
  typed named bindings; every schema column is pinned to its
  ClickHouse parameter type so placeholder types never fall back to
  PHP value inference.
- CREATE TABLE for the events/gauges/daily tables and the daily
  materialized view go through the schema layer. Column shapes the
  typed API cannot express (DateTime64 timezone argument,
  LowCardinality(Nullable(...)), hyphenated skip-index names) are
  emitted as raw definitions so deployed DDL is byte-for-byte
  unchanged. Projections, ALTER-based dim backfills, the retention
  TTL ALTERs and the MV body stay raw SQL, which the schema layer
  cannot express yet.
- INSERT envelopes compile via insertFormat(); the JSONEachRow body
  assembly and transport are unchanged.
- Query 0.3 API: getMethod() returns the Method enum, so all string
  TYPE_* comparisons are gone. UsageQuery keeps its groupByInterval()
  and groupBy() factories, now mapped to Method::GroupByTimeBucket and
  Method::GroupBy; groupBy() also accepts an array of columns to stay
  signature-compatible with the base class.
- contains()/containsAny()/notContains() pass straight through to the
  builder, which compiles them to position(column, needle) on
  ClickHouse. That keeps the utopia-php/database substring semantics
  while matching needles literally, so the adapter no longer has to
  escape LIKE wildcards itself.
@lohanidamodar
lohanidamodar force-pushed the feat/utopia-query-0.3.x branch from 8fc13b3 to 597c4fa Compare July 26, 2026 05:22
Main added an aggregate('max') hint for rolling a gauge level series up to a
coarser interval. It arrived encoded as a TYPE_AGGREGATE string constant, which
is exactly what query 0.3 removes, so it is re-expressed as Method::Max - the
same translation groupByInterval and groupBy already had. Values stay empty
because base Query::max() reserves them for an alias; extractAggregate() reads
the function name back off the method, so the adapter's string-based contract
is unchanged.

Two real defects came out of the merge rather than the conflict markers:

- findAcrossTenants() (new on main) reads with a null tenant, but this branch
  had narrowed the read chain to a non-nullable string when tenant filtering
  moved into applyTenantFilter(). Every cross-tenant read would have died on a
  TypeError. applyTenantFilter/applyFilters/findAggregatedFromTable now take
  ?string, and a null tenant adds no predicate - widening the scope, never
  narrowing it to the empty one, which still fails fast.
- The aggregate('max') override reassigned $valueExpr after the builder had
  already consumed it. That worked upstream, where the SQL was assembled as a
  string at the end, but here it was dead: the hint would have been accepted
  and silently ignored. Moved above the builder.

The switch in parseQueries kept a 'case UsageQuery::TYPE_AGGREGATE' arm, which
after the enum migration can never match - replaced with case Method::Max.

composer: took main's client ^0.3 and database ^7.0.0 with our query 0.3.*
pin, and regenerated the lock from main's rather than hand-merging 16 conflict
blocks. Exactly one package moves against main: query 0.1.1 -> 0.3.3.

Gates: pint pass, phpstan [OK] No errors, UsageQueryTest 29 tests / 84
assertions, each rewritten assertion driven red first. The ClickHouse suite
needs a live server and was not run here.
The branch was pinned at 0.3.* while query had released 0.4, 0.5 and 0.6. No
adapter changes were needed: the Parser -> Classifier rename, the
UnsupportedException removal, nested join conditions and Schema\Order are all
symbols this package does not use.

Query::contains() is deprecated in 0.6 in favour of containsString() for
substring matching. One test called it; containsString() returns the same
Method::Contains the adapter switches on, so the swap changes no SQL. The two
other hits are unrelated - one is Utopia\Database\Query, a different package,
and one is a comment.

pint pass, phpstan [OK] No errors, UsageQueryTest 29 tests / 84 assertions. The
ClickHouse suite needs a live server and runs in CI.
@lohanidamodar lohanidamodar changed the title feat: migrate ClickHouse adapter to utopia-php/query 0.3.x builder feat!: migrate ClickHouse adapter to the utopia-php/query 0.6 builder Aug 25, 2026
@lohanidamodar

Copy link
Copy Markdown
Contributor Author

Now on utopia-php/query 0.6, and ready for review

Both this PR and its sibling were pinned at 0.3.* while query had released 0.4, 0.5 and 0.6. All three consumers — audit, usage and cloud — now move together, because composer resolves one utopia-php/query for the whole tree: leaving one behind either fails to resolve or silently picks a version none of them were tested against.

No adapter changes were needed. The breaking changes in between are all symbols these packages don't use:

release change exposure here
0.4.0 ParserClassifier rename none
0.4.0 UnsupportedException replaced by typed capability none
0.4.0 stops silently dropping input a dialect can't honour none
0.5.0 nested join ON conditions none
0.6.0 Schema\Order for index directions index() call still compatible

The one thing 0.6 surfaced was Query::contains() being deprecated in favour of containsString() (substring) and containsAny() (array attributes). Only tests called the deprecated factory — the adapters switch on Method::Contains, which is unchanged — and containsString() returns that same Method, so the swap emits identical SQL. Verified rather than assumed: the SQL snapshots hold at the same assertion count with the deprecation notices gone.

CI is green on 0.6 including the ClickHouse integration suites, which is what actually exercises the emitted SQL against a server.

Release order this unblocks: merge and tag here, then cloud swaps its dev-… branch aliases for real version constraints. Cloud is currently pinned to the dev branches of both packages, so it cannot merge until these release.

Eight public statics - isGroupByInterval, extractGroupByInterval,
removeGroupByInterval, isGroupBy, extractGroupBy, removeGroupBy, isAggregate and
removeAggregate - had no caller outside their own tests. The only references in
src were the helpers calling each other, a closed loop.

They are left over from the pre-0.3 design, where the adapter pulled hints out
of the query list before compiling it. parseQueries() now switches on Method
directly (GroupByTimeBucket, GroupBy, Max), so nothing needs to ask a Query what
kind it is or to filter it back out of a list.

Checked the consumer, not just this package: cloud calls groupBy,
groupByInterval, aggregate and the inherited parse, and none of the removed
methods. What stays is what is actually used - VALID_INTERVALS and
VALID_AGGREGATES, the three factories, and extractAggregate, which the adapter
uses to read an aggregate's name back off its Method.

278 -> 171 lines. Also dropped testGroupByIsMethod and testAggregateIsMethod:
after the isMethod() override went away in the 0.3 migration they only asserted
that the base library resolves its own enum.

phpstan [OK] No errors, pint pass, UsageQueryTest 15 tests / 49 assertions.
Comment thread src/Usage/Adapter/Database.php
Review caught that Method::ContainsAny is handled by the ClickHouse adapter -
it is in VALUE_REQUIRED_METHODS and has an arm in parseQueries - while
Database::convertQueriesToDatabase had neither a case for it nor a default. The
predicate fell out of the switch, contributed no WHERE fragment, and the query
returned every row: a wrong answer wearing the shape of a working one, and the
two backends disagreeing on the same input.

Verified before fixing: no 'case Method::ContainsAny' and no 'default:' in that
switch, against an arm at ClickHouse.php:3743.

Mapped it to DatabaseQuery::containsAny(), and added a default that throws.
The missing case was the bug; the missing default is why it could happen
quietly, and why the next method added upstream would repeat it. That is the
same reasoning the ClickHouse adapter already applies to empty filter values,
where it refuses rather than emit a full-table match.

testContainsAnyFiltersRatherThanBeingDropped asserts the filtered set is
genuinely smaller than the unfiltered one, which is what a dropped predicate
would break. It lives in UsageBase so both adapters run it, and needs the live
MariaDB/ClickHouse services - so its red has not been observed locally, only the
static gates have run here.
Comment thread src/Usage/Adapter/Database.php
…fault

The default-throw added in 48c1cda made the silent drops visible, and re-review
immediately used that to find four more: IsNull, IsNotNull, CursorAfter,
CursorBefore and Max were all reaching it. Turning a silent wrong answer into a
loud failure is progress, but throwing on methods the adapter can actually serve
is its own regression.

- IsNull / IsNotNull now delegate to DatabaseQuery::isNull()/isNotNull(). They
  take no values, which is why they are absent from VALUE_REQUIRED_METHODS.
- Max joins GroupBy and GroupByTimeBucket as explicitly not pushed down: this
  adapter returns raw rows, so an aggregate hint is ignored rather than refused.
- CursorAfter / CursorBefore refuse with a message naming the limitation. This
  adapter has never implemented cursor pagination and DatabaseQuery's cursors
  take a Document rather than the value carried here, so the honest answer is to
  say so instead of returning an unpaginated set that reads as success.

Every Method the ClickHouse adapter handles now has an explicit arm here -
verified by diffing the two switches - so the default is left for methods that
genuinely do not exist yet, which is what it is for.
@lohanidamodar

Copy link
Copy Markdown
Contributor Author

Right, and fixed in 9bae116 — the default-throw did its job by making these visible, but throwing on methods the adapter can serve is its own regression.

Diffed the two switches rather than fixing only the two you named, which turned up five reaching the default:

  • IsNull / IsNotNull — now delegate to DatabaseQuery::isNull() / isNotNull(). They take no values, which is why they are absent from VALUE_REQUIRED_METHODS.
  • Max — joins GroupBy and GroupByTimeBucket as explicitly not pushed down. This adapter returns raw rows, so an aggregate hint is ignored rather than refused, matching the existing documented behaviour for the other two.
  • CursorAfter / CursorBefore — refuse, with a message naming the limitation. This adapter has never implemented cursor pagination, and DatabaseQuery's cursors take a Document rather than the value carried here, so silently returning an unpaginated set would read as success.

Every Method the ClickHouse adapter handles now has an explicit arm here, verified by diffing the case lists. The default is left for methods that genuinely do not exist yet, which is what it is for.

Two findings from the re-review, both accurate.

The CHANGELOG still announced 'query 0.3.x' and a bump 'to 0.3.*' after
composer.json moved to 0.6.*. That is the file a consumer reads to decide
whether an upgrade will break them, so a stale version label there is worse
than a stale comment.

parseQueries() in the ClickHouse adapter had no default arm, the same shape that
let ContainsAny be silently dropped by the Database adapter in 48c1cda. It has
24 arms and accepts a whitelist, so nothing reaches the end today - but that was
equally true of the Database switch until a method was added upstream. Both
adapters now refuse an unhandled method rather than compiling a query without
it.

pint pass, phpstan [OK] No errors, UsageQueryTest 15/49.
@lohanidamodar
lohanidamodar requested a review from abnegate August 25, 2026 04:53
@lohanidamodar
lohanidamodar merged commit e710b16 into main Aug 25, 2026
4 checks passed
@lohanidamodar
lohanidamodar deleted the feat/utopia-query-0.3.x branch August 25, 2026 05:10
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants