feat: support sort-by-alias and aliased duplicate parameterised fields

What does this MR do and why?

Adds sort-by-alias resolution, relaxes the duplicate parameterised field check to accept aliased duplicates, and fixes the transform to treat typed field keys as opaque identifiers. This unblocks the use case raised in !409 (merged): defining multiple instances of the same parameterised field with different parameters and sorting by them.

Four cross-cutting changes:

Sort alias resolution (all modes): A new resolve_sort_aliases pass runs after parameter resolution and before analysis. It scans sort fields for UnknownField entries and resolves them against user-defined aliases in fields/dimensions/metrics. sort: p50 with metrics: durationQuantile(0.5) as "p50" now resolves correctly. Case-insensitive matching.

Aliased duplicate parameterised fields (analytics): The duplicate check from !411 (merged) is relaxed to accept duplicates when each instance has a unique alias. durationQuantile(0.5) as "p50", durationQuantile(0.8) as "p80" now compiles. GraphQL field aliases are emitted (p50: durationQuantile(quantile: 0.5)), key() and name() return the alias for aliased parameterised fields, and inherit_sort_params matches by alias when sorting.

Alias collision validation: Aliases that match a built-in field name are rejected (case-insensitive). title as "status" produces a clear error. Cross-list alias collisions (same alias in fields and dimensions) are also rejected.

Transform: opaque key handling for typed fields: The TransformFields::Typed branch no longer re-parses keys through parse_fields. Keys are treated as opaque data-access identifiers and validated only as non-empty. This fixes a bug where alias keys containing digits (e.g. p95) were rejected because the field parser uses alpha1 (letters only). It also closes a security boundary: a key that looks like labels("bug") is no longer misinterpreted as executable FieldFunction syntax.

The compile output fields array now includes a field property with the base field name (e.g. "durationQuantile"), separate from key (which is the alias "p50" for aliased fields). This lets the frontend use field for presenter/visualisation lookup while using key for data access and uniqueness.

Related to Support sort-by-alias and aliased duplicate par... (#153 - closed)

How to set up and validate locally

  1. cargo test -- all 1,028 tests pass (~80 new)
  2. cargo clippy -- -D warnings -- clean
  3. cargo fmt --check -- clean

Verify via the Ruby gem

cd glql_rb
bundle install
bundle exec rake compile

1. Aliased duplicate metrics (should succeed)

echo "puts Glql.compile('type = pipeline AND project = \"gitlab-org/gitlab-shell\"', { mode: 'analytics', dimensions: 'ref', metrics: 'durationQuantile(0.5) as \"p50\", durationQuantile(0.8) as \"p80\"' }); exit" | bundle exec rake console

Expected: "success"=>true with GraphQL containing p50: durationQuantile(quantile: 0.5) and p80: durationQuantile(quantile: 0.8). The fields array shows "key"=>"p50" with "field"=>"durationQuantile".

2. Sort by alias with correct parameter inheritance (should succeed)

echo "puts Glql.compile('type = pipeline AND project = \"gitlab-org/gitlab-shell\"', { mode: 'analytics', dimensions: 'ref', metrics: 'durationQuantile(0.5) as \"p50\", durationQuantile(0.8) as \"p80\"', sort: 'p50 desc' }); exit" | bundle exec rake console

Expected: "success"=>true with orderBy containing parameters: {quantile: 0.5} (not 0.8), confirming the sort resolved to the correct aliased field.

3. Sort by alias in standard mode (should succeed)

echo "puts Glql.compile('project = \"gitlab-org/gitlab-shell\"', { fields: 'title as \"Heading\", createdAt', sort: 'Heading' }); exit" | bundle exec rake console

Expected: "success"=>true with sort: TITLE_ASC -- the alias Heading resolves to the title field.

4. Unaliased duplicates still rejected (should error)

echo "puts Glql.compile('type = pipeline AND project = \"gitlab-org/gitlab-shell\"', { mode: 'analytics', dimensions: 'ref', metrics: 'durationQuantile(0.5), durationQuantile(0.8)' }); exit" | bundle exec rake console

Expected: "success"=>false with error appears more than once in metrics (unchanged from !411 (merged)).

5. Alias conflicts with field name (should error)

echo "puts Glql.compile('project = \"gitlab-org/gitlab-shell\"', { fields: 'title as \"status\"' }); exit" | bundle exec rake console

Expected: "success"=>false with error Alias "status" conflicts with the field name.

6. Standard mode labels and currentUser still work (regression check)

echo "puts Glql.compile('project = \"gitlab-org/gitlab-shell\"', { fields: 'title, labels(\"bug\", \"critical\"), author' }); exit" | bundle exec rake console

Expected: "success"=>true with GraphQL containing labels { nodes { ... } } and author { ... }. The fields array should include a labels("bug", "critical") key with "field"=>"labels(\"bug\", \"critical\")".

7. Transform with digit-containing alias (regression check)

Build the WASM bundle and test in Node:

wasm-pack build --target web --out-dir pkg
npm install && npm run build
const { glql } = require('./npm/dist/main.js');
(async () => {
  const r = await glql.compile('type = Pipeline and project = "test/project"', {
    mode: 'analytics', dimensions: 'ref',
    metrics: 'durationQuantile(0.5) as "Median", durationQuantile(0.95) as "p95"',
    sort: 'Median desc'
  });
  console.log('compile:', r.success);
  const t = await glql.transform(
    JSON.stringify({ project: { analytics: { finishedPipelines: { aggregated: { count: 1, nodes: [{ dimensions: { ref: 'main' }, Median: 120.5, p95: 450.0 }] } } } } }),
    { fields: r.fields, mode: 'analytics', source: 'Pipelines' }
  );
  console.log('transform:', t.success, t.error || '');
})();

Expected: Both compile and transform succeed. Previously, the transform failed with Unexpected token at end of input: 95 because p95 was re-parsed through the field parser.

Edited by Robert Hunt

Merge request reports

Loading
Loading