Skip to content
Open
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
17 changes: 17 additions & 0 deletions src/components/queryBuilder/FilterEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -262,6 +262,23 @@ export const FilterEditor = (props: {
FilterOperator.OutsideGrafanaTimeRange,
].includes(f.value!)
);
} else if (isJSONType) {
// JSON sub-column values are strings; exclude IsEmpty/IsNotEmpty which are unreliable on JSON paths
return filterOperators.filter((f) =>
[
FilterOperator.IsAnything,
FilterOperator.Equals,
FilterOperator.NotEquals,
FilterOperator.Like,
FilterOperator.NotLike,
FilterOperator.ILike,
FilterOperator.NotILike,
FilterOperator.In,
FilterOperator.NotIn,
FilterOperator.IsNull,
FilterOperator.IsNotNull,
].includes(f.value!)
);
} else {
return filterOperators.filter((f) =>
[
Expand Down
21 changes: 20 additions & 1 deletion src/components/queryBuilder/utils.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { generateSql } from 'data/sqlGenerator';
import { getQueryOptionsFromSql, isDateTimeType, isDateType, isNumberType } from './utils';
import { getQueryOptionsFromSql, isDateTimeType, isDateType, isJsonType, isNumberType } from './utils';
import {
AggregateType,
BuilderMode,
Expand Down Expand Up @@ -129,6 +129,25 @@ describe('isNumberType', () => {
});
});

describe('isJsonType', () => {
it('returns true for JSON type', () => {
expect(isJsonType('JSON')).toBe(true);
expect(isJsonType('json')).toBe(true);
});

it("returns true for legacy Object('json') type", () => {
expect(isJsonType("Object('json')")).toBe(true);
expect(isJsonType("object('json')")).toBe(true);
});

it('returns false for non-JSON types', () => {
expect(isJsonType('String')).toBe(false);
expect(isJsonType('Map(String, String)')).toBe(false);
expect(isJsonType('UInt64')).toBe(false);
expect(isJsonType('DateTime')).toBe(false);
});
});

describe('getQueryOptionsFromSql', () => {
testCondition('handles a table without a database', 'SELECT name FROM "foo"', {
queryType: QueryType.Table,
Expand Down
4 changes: 4 additions & 0 deletions src/components/queryBuilder/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,10 @@ export const isDateTimeType = (type: string): boolean => {
export const isStringType = (type: string): boolean => {
return !(isBooleanType(type) || isNumberType(type) || isDateType(type));
};
export const isJsonType = (type: string): boolean => {
const t = type?.toLowerCase() || '';
return t.startsWith('json') || t.startsWith("object('json')");
};
export const isNullFilter = (filter: Filter): filter is NullFilter => {
return [FilterOperator.IsNull, FilterOperator.IsNotNull].includes(filter.operator);
};
Expand Down
107 changes: 107 additions & 0 deletions src/data/sqlGenerator.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -940,6 +940,113 @@ describe('getFilters', () => {
expect(sql).toEqual(`( NumericAttrs['retry_count'] = 3 )`);
});

it('generates dot-notation SQL for JSON mapKey filter (basic path)', () => {
const options = {
filters: [
{
condition: 'AND',
filterType: 'custom',
key: 'LogAttributes',
mapKey: 'request_id',
operator: FilterOperator.Equals,
type: 'JSON',
value: 'abc123',
},
],
} as QueryBuilderOptions;
const sql = _testExports.getFilters(options);
expect(sql).toEqual("( LogAttributes.`request_id`::Nullable(String) = 'abc123' )");
});

it('generates dot-notation SQL for JSON mapKey filter (nested path)', () => {
const options = {
filters: [
{
condition: 'AND',
filterType: 'custom',
key: 'SpanAttributes',
mapKey: 'http.status_code',
operator: FilterOperator.Equals,
type: 'JSON',
value: '200',
},
],
} as QueryBuilderOptions;
const sql = _testExports.getFilters(options);
expect(sql).toEqual("( SpanAttributes.`http`.`status_code`::Nullable(String) = '200' )");
});

it('generates correct IN clause for JSON mapKey filter', () => {
const options = {
filters: [
{
condition: 'AND',
filterType: 'custom',
key: 'LogAttributes',
mapKey: 'level',
operator: FilterOperator.In,
type: 'JSON',
value: ['error', 'warn'],
},
],
} as QueryBuilderOptions;
const sql = _testExports.getFilters(options);
expect(sql).toEqual("( LogAttributes.`level`::Nullable(String) IN ('error', 'warn') )");
});

it('generates correct NOT IN clause for JSON mapKey filter', () => {
const options = {
filters: [
{
condition: 'AND',
filterType: 'custom',
key: 'LogAttributes',
mapKey: 'level',
operator: FilterOperator.NotIn,
type: 'JSON',
value: ['debug', 'trace'],
},
],
} as QueryBuilderOptions;
const sql = _testExports.getFilters(options);
expect(sql).toEqual("( LogAttributes.`level`::Nullable(String) NOT IN ('debug', 'trace') )");
});

it('generates LIKE clause for JSON mapKey filter', () => {
const options = {
filters: [
{
condition: 'AND',
filterType: 'custom',
key: 'ResourceAttributes',
mapKey: 'service.name',
operator: FilterOperator.Like,
type: 'JSON',
value: 'my-service',
},
],
} as QueryBuilderOptions;
const sql = _testExports.getFilters(options);
expect(sql).toEqual("( ResourceAttributes.`service`.`name`::Nullable(String) LIKE '%my-service%' )");
});

it('generates IS NULL clause for JSON mapKey filter', () => {
const options = {
filters: [
{
condition: 'AND',
filterType: 'custom',
key: 'LogAttributes',
mapKey: 'user_id',
operator: FilterOperator.IsNull,
type: 'JSON',
},
],
} as QueryBuilderOptions;
const sql = _testExports.getFilters(options);
expect(sql).toEqual('( LogAttributes.`user_id`::Nullable(String) IS NULL )');
});

it('returns complex filter array', () => {
const options = {
columns: [{ name: 'hinted', hint: ColumnHint.Time }],
Expand Down
29 changes: 21 additions & 8 deletions src/data/sqlGenerator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -148,19 +148,26 @@ const generateTraceIdQuery = (options: QueryBuilderOptions): string => {
selectParts.push(getTraceDurationSelectSql(escapeIdentifier(traceDurationTime.name), timeUnit));
}

// TODO: for tags and serviceTags, consider the column type. They might not require mapping, they could already be JSON.
const traceTags = getColumnByHint(options, ColumnHint.TraceTags);
if (traceTags !== undefined) {
selectParts.push(
`arrayMap(key -> map('key', key, 'value',${escapeIdentifier(traceTags.name)}[key]), mapKeys(${escapeIdentifier(traceTags.name)})) as tags`
);
if (traceTags.type?.toLowerCase().startsWith('json')) {
selectParts.push(`${escapeIdentifier(traceTags.name)} as tags`);
} else {
selectParts.push(
`arrayMap(key -> map('key', key, 'value',${escapeIdentifier(traceTags.name)}[key]), mapKeys(${escapeIdentifier(traceTags.name)})) as tags`
);
}
}

const traceServiceTags = getColumnByHint(options, ColumnHint.TraceServiceTags);
if (traceServiceTags !== undefined) {
selectParts.push(
`arrayMap(key -> map('key', key, 'value',${escapeIdentifier(traceServiceTags.name)}[key]), mapKeys(${escapeIdentifier(traceServiceTags.name)})) as serviceTags`
);
if (traceServiceTags.type?.toLowerCase().startsWith('json')) {
selectParts.push(`${escapeIdentifier(traceServiceTags.name)} as serviceTags`);
} else {
selectParts.push(
`arrayMap(key -> map('key', key, 'value',${escapeIdentifier(traceServiceTags.name)}[key]), mapKeys(${escapeIdentifier(traceServiceTags.name)})) as serviceTags`
);
}
}

const traceStatusCode = getColumnByHint(options, ColumnHint.TraceStatusCode);
Expand Down Expand Up @@ -849,7 +856,13 @@ const getFilters = (options: QueryBuilderOptions): string => {
.split('.')
.map((p) => `\`${p}\``)
.join('.');
column += `.${escapedJSONPaths}`;
// JSON path extraction returns Dynamic, which ClickHouse's `IN` / `NOT IN` reject
// with ILLEGAL_TYPE_OF_ARGUMENT. Cast to Nullable(String) so every filter operator
// works — `IS NULL` still detects missing keys (a plain ::String cast would swallow
// that signal), and `=` / `!=` / `LIKE` are unaffected.
column = `${column}.${escapedJSONPaths}::Nullable(String)`;
// Update type so filter value generation routes through the string-aware branches.
type = 'String';
}

filterParts.push(column);
Expand Down
24 changes: 24 additions & 0 deletions tests/e2e/fixtures/seed.sql
Original file line number Diff line number Diff line change
Expand Up @@ -29,3 +29,27 @@ INSERT INTO e2e_test.events (timestamp, level, message, value, service) VALUES
('2024-03-15 10:07:00', 'info', 'Scheduled task started', 1.0, 'scheduler'),
('2024-03-15 10:08:00', 'info', 'Scheduled task completed', 1.0, 'scheduler'),
('2024-03-15 10:09:00', 'error', 'Database connection failed', 0.0, 'db');

-- JSON-column fixture — used by jsonFilter.spec.ts to exercise the shape of SQL
-- our query builder generates for JSON sub-column filters (backtick-escaped
-- dot-notation paths, string coercion for IN / NOT IN / IS NULL operators).
-- The JSON type is stable from ClickHouse 25.3 onwards; `latest-alpine` is
-- expected. If the CI image predates that, flip the setting below to the
-- older experimental flag (`allow_experimental_json_type = 1`) or pin
-- CLICKHOUSE_VERSION in docker-compose.yaml.
SET enable_json_type = 1;

CREATE TABLE IF NOT EXISTS e2e_test.json_events
(
timestamp DateTime,
message String,
attributes JSON
)
ENGINE = MergeTree
ORDER BY timestamp;

INSERT INTO e2e_test.json_events (timestamp, message, attributes) VALUES
('2024-03-15 10:00:00', 'login succeeded', '{"user_id":"u-1","level":"info","http":{"status_code":"200"}}'),
('2024-03-15 10:01:00', 'request timed out', '{"user_id":"u-2","level":"error","http":{"status_code":"504"}}'),
('2024-03-15 10:02:00', 'bad request', '{"user_id":"u-3","level":"warn","http":{"status_code":"400"}}'),
('2024-03-15 10:03:00', 'login succeeded', '{"user_id":"u-4","level":"info","http":{"status_code":"200"}}');
Loading
Loading