TT-17841: POC — Postgres analytics-aggregation support ($cond, operators) - #170
Conversation
…ors)
Extend the Postgres Aggregate pipeline translator so analytics-style pipelines
run through the same Aggregate([]DBM) interface that works on MongoDB.
B1 (raw-log & uptime reports):
- $sum:{$cond:...} conditional accumulators -> SUM(CASE WHEN ... END), with a
boolean-expression compiler ($and/$or/$eq/$ne/$gt/$gte/$lt/$lte), nested
$cond, field refs and literals. Object and array $cond forms supported.
- Field-identifier validation for aggregation expressions (closes the
fmt.Sprintf injection vector); numeric/bool literals inlined so SELECT-list
expressions don't disturb WHERE parameter ordering.
translateQuery operator fixes (CRUD + $match), surfaced by the tyk-sink POC:
- $ne now includes NULL rows ((col IS NULL OR col <> ?)), matching Mongo.
- $regex/$options -> ~ / ~*, and $exists -> IS [NOT] NULL.
B2 (pre-aggregated/graph report): attempted — determined it needs Mongo-array
vs SQL-dimension schema alignment, so $unwind is now rejected with an actionable
error instead of emitting an incorrect query. See
docs/postgres-analytics-aggregation.md.
Tests: TestTranslateAggregationConditional (string-level, no DB); existing
DB-backed TestTranslateQuery/TestAggregate/conformance pass against Postgres 16.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
CLA Assistant Lite bot: I have read the CLA Document and I hereby sign the CLA 1 out of 2 committers have signed the CLA. |
|
This Proof of Concept (POC) significantly enhances the Postgres driver by introducing support for complex MongoDB-style analytics aggregation pipelines. The primary goal is to allow services like The core of this PR is a new translator that converts conditional aggregation operators ( Key enhancements include:
Files Changed AnalysisThe changes are primarily focused on the Postgres driver located in
Architecture & Impact Assessment
Aggregation Translation Flowgraph TD
subgraph "Application (e.g., Tyk Analytics)"
A["Aggregate(pipeline)"]
end
subgraph "Storage Driver (Postgres)"
B["resolveAggregateFrom()"]
C["translateAggregationPipeline()"]
D{"Recursive Expression Translator"}
end
subgraph "Database"
E["Postgres SQL Query"]
F["FROM (UNION ALL) / GROUP BY / WHERE"]
end
A --|MongoDB-style pipeline|--> B
B --|Resolves sharded tables|--> C
C --|Parses stages and expressions|--> D
D --|e.g. $cond becomes CASE WHEN...|--> C
C --|Generates SQL|--> E
E --> F
Scope Discovery & Context ExpansionThis PR is a foundational step in a broader strategy to achieve feature parity between the MongoDB and Postgres backends. Its impact extends beyond this single feature:
Metadata
Powered by Visor from Probelabs Last updated: 2026-08-13T20:20:24.195Z | Triggered by: pr_updated | Commit: eebccb3 💡 TIP: You can chat with Visor using |
Security Issues (1)
Architecture Issues (3)
Performance Issues (2)
Quality Issues (5)
Powered by Visor from Probelabs Last updated: 2026-08-13T20:19:47.432Z | Triggered by: pr_updated | Commit: eebccb3 💡 TIP: You can chat with Visor using |
…poc-postgres-analytics-aggregation
… tables Migrate previously skipped any table that already existed, so a model gaining a field never got its column created — a regression versus the GORM AutoMigrate consumers (tyk-sink, tyk-analytics) used before adopting the library, forcing manual out-of-band schema changes. Run AutoMigrate for existing tables too: it is idempotent, adds missing columns, and never drops columns or data. Closes gap D from docs/postgres-analytics-aggregation.md. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Every driver lifecycle already implemented Close(), but the public PersistentStorage interface did not expose it, so consumers replacing an unhealthy connection had no way to release the old pool — a connection leak under reconnect churn (surfaced by the tyk-sink POC and flagged in its review). Add Close() to the interface (all drivers satisfy it via their embedded lifecycles), nil-guard the postgres Close so it errors instead of panicking on a never-connected instance, and clear the sql.DB handle. Closes the "no Close/Disconnect" gap from docs/postgres-analytics-aggregation.md. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…arity Every Tyk product opens the gorm fork with UseJSONTags and AutoEmbedd, so existing Postgres schemas name columns after json tags and flatten embedded structs. The driver opened gorm with a bare config, deriving column names from Go field names instead — self-consistent for fresh databases (which is why the driver's own tests passed) but mismatched against real Tyk schemas: tyk-sink's MDCB API tests against a dashboard-provisioned database failed on every query. Surfaced by the TT-17841 tyk-sink POC. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…rst/$last, sharded Aggregate
Remaining translator features needed by the raw-log and uptime analytics
reports (R1/R4 in docs/postgres-analytics-aggregation.md):
- $project rename projections: {Alias: "$field.path"} -> field_path AS Alias
(nested paths dot->underscore, identifiers sanitized). $concat now aliases
correctly (CONCAT(...) AS field) instead of emitting two SELECT items.
- $first/$last accumulators, resolved against the document order set by the
preceding $sort (descending: $first=MAX/$last=MIN; ascending inverse);
error when no preceding $sort on the field. A pre-group $sort no longer
leaks into the grouped query's ORDER BY (it orders docs into accumulators,
matching Mongo semantics).
- Date-sharded aggregation: the _date_sharding directive in a $match fans the
query out across per-day tables via UNION ALL, reusing the discovery logic
now shared with translateQuery (extracted as shardedFrom).
- $match parity: $regex/$options (~/~*) and $exists in buildWhereClause.
- Fixes surfaced by the first live execution: {$sum: 1} now emits COUNT(*)
(previously invalid SUM(*)), and SELECT aliases are double-quoted so
Postgres preserves their case — consumers read Mongo-style field names
(LastTimeStamp, Success) instead of silently getting lowercased columns.
Tested: string-level translator tests plus a DB-backed sharded Aggregate test
(two daily shards, UNION ALL, grouped counts/sums verified) against
Postgres 16; full driver + conformance suites pass; lint clean.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The driver's TableSharding flag (and its options reference) were never set from the public constructor, so date-based sharding was unreachable through NewPersistentStorage — only internal tests could enable it. Plumb ClientOpts.TableSharding into the postgres driver so consumers (tyk-analytics sharded analytics) can turn it on. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…roup Consumers reference nested document fields with dots (geo.country.isocode, latency.upstream); the flattened Postgres schema joins them with underscores. translateQuery already converted; do the same in the aggregation path (buildWhereClause keys, $sort fields, $group _id refs). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
ORDER BY on an aliased accumulator ("Hits") must reference the quoted,
case-preserved alias; raw lowercase columns are unaffected by quoting.
Fields are sanitized before being embedded.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…t CreateIndex Surfaced by the tyk-analytics Tier-1 POC: dashboard bootstrap creates indexes with the same logical name (org_id_ruleset_id) on different tables — fine on MongoDB where index names are per-collection, but Postgres index names are schema-global, so the second CreateIndex failed 42P07 and the consumer panicked. - Namespace the physical index name with its table (<table>_<name>); GetIndexes strips the prefix so consumers see the logical name they created (TTL metadata mapping included). - Re-creating an existing index now succeeds silently (Mongo parity) instead of returning ErrorIndexAlreadyExist. - Fix indexExists comparing quoted identifiers against pg_indexes' raw catalog names — it always returned false, so the pre-check never worked. Driver + conformance suites pass on Postgres 16; assertions updated to the new contract. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Two hardcoded assumptions broke with json-tag naming, where Tyk models declare their ObjectID primary key as json:"_id" (literal _id column): - CreateIndex rewrote the Mongo-style _id key to column "id" unconditionally; it now checks the table and keeps _id when that is the physical column (new columnExists helper). - Update's no-filter fallback hardcoded WHERE id = ?; GORM's update callback already adds the primary-key condition from the model schema (resolving the correct column), so the manual clause is dropped. Surfaced by tyk-analytics dashboard bootstrap (client-IdP index creation). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
AVG() returns Postgres NUMERIC, which interface{} row scans surface as
[]byte/string; document-store consumers expect numbers (Mongo returns
float64). Parse integral/decimal strings in Aggregate's row materialization,
passing non-numeric values through unchanged. Surfaced by tyk-analytics
average-latency assertions reading 0.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…rd range returns empty result
- buildWhereClause now translates $or/$and lists of sub-filters recursively
(they were silently dropped, so any query relying on them matched
everything).
- $in accepts any slice type ([]string included) instead of only
[]interface{}, which was also silently dropped.
- A _date_sharding range covered by no shard tables now short-circuits
Aggregate to an empty result instead of silently reading the unsharded
base table.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Mongo's \$group with an empty _id emits no document when nothing matched; SQL aggregates without GROUP BY always emit one row. Append HAVING COUNT(*) > 0 when a \$group stage produced no group keys. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
…rity) The document-store drivers fill an empty slice and return nil when nothing matches; the Postgres driver returned sql.ErrNoRows, which callers written against Mongo semantics treat as a failure (surfaced as bootstrapper/org lookup errors in tyk-analytics). Single-object queries still return sql.ErrNoRows. Adds a conformance-suite case (NewSlice) so all drivers pin this behavior. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…d query paths
- $group _id field references pass through sanitizeAggField (SQL injection
vector: they were interpolated raw into SELECT and GROUP BY).
- $project composed with $group is rejected explicitly: both stages render
the same flat SELECT list, so one stage was silently discarded.
- Literal accumulator arguments ($sum: 2.5) are inlined instead of bound: a
SELECT-list placeholder precedes WHERE placeholders in the SQL text while
its argument was appended after the $match args, binding values crosswise.
{$sum: 1} arriving as JSON float64 now also takes the COUNT(*) idiom.
- $or/$and in an aggregation $match accept []interface{} (the shape decoders
produce) and return an error on unknown shapes instead of silently
dropping the condition.
- Numeric normalization of Aggregate results is gated on the column's
DatabaseTypeName (NUMERIC/DECIMAL): TEXT group keys like "007" or numeric
identifiers are no longer coerced to numbers.
- {$ne: nil} maps to IS NOT NULL in both translateQuery and the aggregation
$match (the NULL-inclusive form with a nil parameter matched only NULL
rows - the exact complement). $eq gains explicit handling in both paths
(it was silently dropped in translateQuery); {$eq: nil} maps to IS NULL.
- Aggregation $match $ne is NULL-inclusive, matching translateQuery.
- $first/$last reject compound sorts (MIN/MAX cannot express first-per-group
under a leading key) and lastSort resets after each $group so a stale sort
cannot bind to a later group.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…e destination GORM adds a non-zero primary key on the destination struct to the WHERE clause, so Mongo-style callers reusing one struct across lookups got the previous record's ID ANDed into the filter (surfaced as the portal key-request flow failing to load its second policy). Query now scans into a fresh instance and copies back. Conformance case added. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The hand-built objectToMap update payload rendered nested structs as their
Go string form (api_definition='map[...]') and the per-object branch
hardcoded WHERE id = ?, which misses tables whose json-tag primary-key
column differs. Both branches now mirror Update: Select("*").Omit("id").
Updates(object), letting the GORM schema resolve the primary-key condition
and serialize JSON fields.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Update/BulkUpdate omitted the literal "id" column from the SET clause to keep the primary key out of it. With json-tag naming the primary key is "_id" and "id" is a regular data column (dashboard assets), so renames of that column were silently dropped while the real PK stayed in the SET. Resolve the column from the GORM schema instead. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
primaryKeyColumn fell back to "id" when PrioritizedPrimaryField was nil, which is exactly the composite-key case (dashboard Asset: _id + org_id) — re-omitting the "id" data column and silently dropping renames. Omit now targets all schema PrimaryFields; "id" remains only the unparseable-schema fallback. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Grouped results now carry their keys under an _id sub-document (scalar _id for the scalar $group form), matching the document-store drivers, instead of leaking the translator's flat column layout for consumers to reassemble. The translator records the alias-to-column mapping and Aggregate reshapes each row; group-key columns no longer appear at the top level. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>


Description (POC — draft, stacked on #158)
Part of the TT-17841 POC to let Tyk consumers (starting with tyk-analytics) run
their analytics aggregations on Postgres through the same
Aggregate(ctx, row, pipeline []DBM)interface that already works on MongoDB —the Mongo driver passes the pipeline to the server's native engine, while the
Postgres driver must translate it to SQL, and that translator was too limited.
B1 — raw-log & uptime reports (implemented)
$sum: {$cond: …}conditional accumulators →SUM(CASE WHEN … END), theidiom analytics use for success/error/response-code rollups. Includes a
boolean-expression compiler (
$and/$or/$eq/$ne/$gt/$gte/$lt/$lte),nested
$cond, field refs and literals; object and array$condforms.(
sanitizeAggField), closing the oldfmt.Sprintfinjection vector; numericliterals are inlined so SELECT-list expressions don't disturb WHERE parameter
ordering.
translateQueryoperator fixes (CRUD$match, surfaced by the tyk-sinkPOC):
$nenow includes NULL rows to match Mongo semantics(
(col IS NULL OR col <> ?));$regex/$options→~/~*;$exists→IS [NOT] NULL.B2 — pre-aggregated / graph report (attempted)
Determined it requires schema alignment: Mongo stores counters in arrays
(
$lists.*,$apikeys.<key>) and unwinds them, while the SQL schema stores onerow per element keyed by
dimension/dimension_value.$unwindtherefore hasno single-table SQL rewrite, so it is now rejected with an actionable error
rather than silently producing a wrong query. Design + remaining work in
docs/postgres-analytics-aggregation.md.Tests
TestTranslateAggregationConditional— string-level (no DB): conditionalcounts,
$condarray form, identifier rejection, unsupported-operator and$unwinderrors.TestTranslateQuery/TestAggregate/ conformance suitespass against Postgres 16 (validated locally);
golangci-lintclean.Types of changes
$neNULL semantics🤖 Generated with Claude Code