Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@ jobs:
- bash-ast
- docker-parser
- nginx-ast
- promql
- yamlize

steps:
Expand Down
80 changes: 80 additions & 0 deletions packages/promql/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
# promql

<p align="center">
<img src="https://raw.githubusercontent.com/constructive-io/constructive/refs/heads/main/assets/outline-logo.svg" height="150">
<br />
A TypeScript PromQL parser, AST builders, and deparser
</p>

A dependency-free toolkit for working with [PromQL](https://prometheus.io/docs/prometheus/latest/querying/basics/) as a typed AST — **parse** query strings into an AST, **build** queries programmatically with typed helpers, and **deparse** an AST back to a canonical PromQL string.

## Install

```sh
npm install promql
```

## Why

Composing PromQL with template strings is error-prone (label escaping, precedence, parentheses). This package lets you build queries as typed values and render them safely:

```ts
import { sum, rate, range, metric, neq, by, deparse } from 'promql';

const query = deparse(
sum(
rate(range(metric('container_cpu_usage_seconds_total', [neq('container', '')]), '5m')),
by('namespace')
)
);
// => 'sum by (namespace) (rate(container_cpu_usage_seconds_total{container!=""}[5m]))'
```

## Parse

```ts
import { parse } from 'promql';

const ast = parse('sum by (namespace) (rate(x[5m]))');
// { type: 'AggregateExpr', op: 'sum', grouping: { modifier: 'by', labels: ['namespace'] }, ... }
```

## Deparse

```ts
import { parse, deparse } from 'promql';

deparse(parse('a+b*c')); // 'a + b * c'
```

The deparser inserts parentheses based on operator precedence, so ASTs built by hand (without explicit `ParenExpr` nodes) always render to a string that reparses to the same tree.

## Builders

| Helper | PromQL |
|---|---|
| `metric('up', { job: 'api' })` | `up{job="api"}` |
| `selector({ __name__: 'up' })` | `{__name__="up"}` |
| `eq / neq / re / nre` | `=` `!=` `=~` `!~` matchers |
| `range(sel, '5m')` | `sel[5m]` |
| `subquery(expr, '1h', '1m')` | `expr[1h:1m]` |
| `rate / irate / increase / *_over_time` | function calls |
| `sum / min / max / avg / count / ...` | aggregations |
| `topk / bottomk / quantile / countValues` | aggregations with a parameter |
| `by(...) / without(...)` | grouping clauses |
| `add / sub / mul / div / pow / and / or / unless` | binary operators |
| `on(...) / ignoring(...)` | vector matching |
| `offset(expr, '5m') / at(expr, 100 \| 'start' \| 'end')` | modifiers |

## API

- `parse(input: string): Expr` / `class Parser`
- `deparse(node: Expr): string` / `class Deparser`
- `tokenize(input: string): Token[]` / `class Lexer`, `TokenType`
- `cleanTree`, `astEqual`, `printAst`
- All AST node types (`Expr`, `VectorSelector`, `MatrixSelector`, `AggregateExpr`, …)
- All builder helpers

## License

MIT
49 changes: 49 additions & 0 deletions packages/promql/__tests__/builders.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import * as b from '../src/builders';
import { deparse } from '../src/deparser';
import { parse } from '../src/parser';
import { cleanTree } from '../src/clean';

describe('promql builders', () => {
it('builds the namespace CPU query used by the collector', () => {
const expr = b.sum(
b.rate(b.range(b.metric('container_cpu_usage_seconds_total', [b.neq('container', '')]), '5m')),
b.by('namespace')
);
expect(deparse(expr)).toBe(
'sum by (namespace) (rate(container_cpu_usage_seconds_total{container!=""}[5m]))'
);
});

it('builds the namespace memory query used by the collector', () => {
const expr = b.sum(
b.metric('container_memory_working_set_bytes', [
b.eq('namespace', 'tenant-a'),
b.neq('container', ''),
b.neq('image', ''),
])
);
expect(deparse(expr)).toBe(
'sum(container_memory_working_set_bytes{namespace="tenant-a", container!="", image!=""})'
);
});

it('builder output reparses to an equal AST', () => {
const expr = b.topk(5, b.rate(b.range(b.metric('x', { job: 'api' }), '1m')), b.by('pod'));
const reparsed = parse(deparse(expr));
expect(cleanTree(reparsed)).toEqual(cleanTree(expr));
});

it('escapes label values via JSON quoting', () => {
const expr = b.metric('m', [b.eq('path', 'a"b\\c')]);
expect(deparse(expr)).toBe('m{path="a\\"b\\\\c"}');
// and it reparses back to the original value
const reparsed = parse(deparse(expr)) as { matchers: { value: string }[] };
expect(reparsed.matchers[0].value).toBe('a"b\\c');
});

it('at() accepts start/end/number', () => {
expect(deparse(b.at(b.metric('x'), 'start'))).toBe('x @ start()');
expect(deparse(b.at(b.metric('x'), 'end'))).toBe('x @ end()');
expect(deparse(b.at(b.metric('x'), 1609746000))).toBe('x @ 1609746000');
});
});
56 changes: 56 additions & 0 deletions packages/promql/__tests__/deparser.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import * as b from '../src/builders';
import { deparse } from '../src/deparser';

describe('promql deparser', () => {
it('deparses a selector with matchers', () => {
expect(deparse(b.metric('http_requests_total', { job: 'api' }))).toBe(
'http_requests_total{job="api"}'
);
});

it('deparses a nameless selector', () => {
expect(deparse(b.selector({ __name__: 'up' }))).toBe('{__name__="up"}');
});

it('deparses rate over a range', () => {
expect(deparse(b.rate(b.range(b.metric('x'), '5m')))).toBe('rate(x[5m])');
});

it('deparses aggregation with by-grouping', () => {
expect(deparse(b.sum(b.rate(b.range(b.metric('x'), '5m')), b.by('namespace')))).toBe(
'sum by (namespace) (rate(x[5m]))'
);
});

it('deparses topk with parameter', () => {
expect(deparse(b.topk(3, b.metric('x')))).toBe('topk(3, x)');
});

it('adds parentheses to preserve precedence for built ASTs', () => {
// (a + b) * c — built without explicit ParenExpr
const expr = b.mul(b.add(b.metric('a'), b.metric('b')), b.metric('c'));
expect(deparse(expr)).toBe('(a + b) * c');
});

it('does not add unnecessary parentheses', () => {
const expr = b.add(b.metric('a'), b.mul(b.metric('b'), b.metric('c')));
expect(deparse(expr)).toBe('a + b * c');
});

it('deparses offset and @ modifiers', () => {
expect(deparse(b.offset(b.at(b.metric('x'), 100), '5m'))).toBe('x @ 100 offset 5m');
});

it('deparses bool + vector matching', () => {
const expr = b.binary('==', b.metric('a'), b.metric('b'), {
bool: true,
matching: { on: ['x'], groupLeft: ['y'] },
});
expect(deparse(expr)).toBe('a == bool on (x) group_left (y) b');
});

it('deparses Inf/NaN', () => {
expect(deparse(b.num(Infinity))).toBe('+Inf');
expect(deparse(b.num(NaN))).toBe('NaN');
});
});
123 changes: 123 additions & 0 deletions packages/promql/__tests__/parser.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
import { cleanTree } from '../src/clean';
import { parse } from '../src/parser';

describe('promql parser', () => {
it('parses a bare metric name', () => {
expect(cleanTree(parse('up'))).toEqual({
type: 'VectorSelector',
name: 'up',
matchers: [],
});
});

it('parses a selector with label matchers', () => {
expect(cleanTree(parse('http_requests_total{job="api", code!~"5.."}'))).toEqual({
type: 'VectorSelector',
name: 'http_requests_total',
matchers: [
{ name: 'job', op: '=', value: 'api' },
{ name: 'code', op: '!~', value: '5..' },
],
});
});

it('parses a matrix selector with offset and @', () => {
expect(cleanTree(parse('x[5m] @ 100 offset -1m'))).toEqual({
type: 'MatrixSelector',
vectorSelector: { type: 'VectorSelector', name: 'x', matchers: [] },
range: '5m',
at: { kind: 'timestamp', value: 100 },
offset: '-1m',
});
});

it('parses rate(range) function calls', () => {
expect(cleanTree(parse('rate(x[5m])'))).toEqual({
type: 'Call',
func: 'rate',
args: [
{
type: 'MatrixSelector',
vectorSelector: { type: 'VectorSelector', name: 'x', matchers: [] },
range: '5m',
},
],
});
});

it('parses aggregation with by-clause before args', () => {
const ast = cleanTree(parse('sum by (namespace) (rate(x[5m]))')) as {
type: string;
op: string;
grouping: unknown;
};
expect(ast.type).toBe('AggregateExpr');
expect(ast.op).toBe('sum');
expect(ast.grouping).toEqual({ modifier: 'by', labels: ['namespace'] });
});

it('parses aggregation with by-clause after args', () => {
const ast = cleanTree(parse('sum(rate(x[5m])) by (namespace)')) as { grouping: unknown };
expect(ast.grouping).toEqual({ modifier: 'by', labels: ['namespace'] });
});

it('parses topk with a parameter', () => {
const ast = cleanTree(parse('topk(3, x)')) as { op: string; param: unknown };
expect(ast.op).toBe('topk');
expect(ast.param).toEqual({ type: 'NumberLiteral', value: 3 });
});

it('respects operator precedence', () => {
const ast = cleanTree(parse('a + b * c')) as { op: string; rhs: { op: string } };
expect(ast.op).toBe('+');
expect(ast.rhs.op).toBe('*');
});

it('parses ^ as right-associative', () => {
const ast = cleanTree(parse('a ^ b ^ c')) as { op: string; rhs: { op: string } };
expect(ast.op).toBe('^');
expect(ast.rhs.op).toBe('^');
});

it('parses binary comparison with bool and on() matching', () => {
const ast = cleanTree(parse('a == bool on (x) b')) as {
op: string;
bool: boolean;
matching: unknown;
};
expect(ast.op).toBe('==');
expect(ast.bool).toBe(true);
expect(ast.matching).toEqual({ on: ['x'] });
});

it('parses group_left matching', () => {
const ast = cleanTree(parse('a / on (x) group_left (y) b')) as { matching: unknown };
expect(ast.matching).toEqual({ on: ['x'], groupLeft: ['y'] });
});

it('parses subquery with step', () => {
expect(cleanTree(parse('rate(x[5m])[1h:1m]'))).toMatchObject({
type: 'SubqueryExpr',
range: '1h',
step: '1m',
});
});

it('parses Inf and NaN literals', () => {
expect(cleanTree(parse('Inf'))).toEqual({ type: 'NumberLiteral', value: Infinity });
expect((cleanTree(parse('NaN')) as { value: number }).value).toBeNaN();
});

it('parses recording-rule metric names with colons', () => {
expect(cleanTree(parse('job:http_requests:rate5m'))).toEqual({
type: 'VectorSelector',
name: 'job:http_requests:rate5m',
matchers: [],
});
});

it('throws on malformed input', () => {
expect(() => parse('sum(')).toThrow();
expect(() => parse('{')).toThrow();
});
});
31 changes: 31 additions & 0 deletions packages/promql/__tests__/roundtrip.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import { roundtrip } from '../test-utils';

describe('promql roundtrip (parse → deparse → parse)', () => {
const cases = [
'up',
'http_requests_total{job="api", code!~"5.."}',
'rate(x[5m])',
'sum by (namespace) (rate(container_cpu_usage_seconds_total{container!=""}[5m]))',
'sum(container_memory_working_set_bytes{namespace="tenant-a", container!="", image!=""})',
'topk(3, avg_over_time(x[1h]))',
'a + b * c',
'(a + b) * c',
'a ^ b ^ c',
'a == bool on (x) group_left (y) b',
'histogram_quantile(0.9, rate(http_request_duration_seconds_bucket[5m]))',
'rate(x[5m])[1h:1m]',
'x @ 100 offset -1m',
'quantile(0.95, node_cpu) without (cpu)',
'-foo + bar',
'foo and bar unless baz',
'foo or bar or baz',
'job:http_requests:rate5m',
];

for (const input of cases) {
it(`roundtrips: ${input}`, () => {
const { first, second } = roundtrip(input);
expect(first).toEqual(second);
});
}
});
18 changes: 18 additions & 0 deletions packages/promql/jest.config.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
/** @type {import('ts-jest').JestConfigWithTsJest} */
module.exports = {
preset: 'ts-jest',
testEnvironment: 'node',
transform: {
'^.+\\.tsx?$': [
'ts-jest',
{
babelConfig: false,
tsconfig: 'tsconfig.json',
},
],
},
transformIgnorePatterns: [`/node_modules/*`],
testRegex: '(/__tests__/.*|(\\.|/)(test|spec))\\.(jsx?|tsx?)$',
moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json', 'node'],
modulePathIgnorePatterns: ['dist/*'],
};
Loading
Loading