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
5 changes: 5 additions & 0 deletions .changeset/beige-eyes-tap.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@hyperdx/app": patch
---

Improve how filters are parsed on the search page
157 changes: 157 additions & 0 deletions packages/app/src/__tests__/searchFilters.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,163 @@ describe('searchFilters', () => {
const result = parseQuery([{ type: 'lucene', condition: `app:*` }]);
expect(result.filters).toEqual({});
});

it('extracts IN clauses from complex conditions with AND operator', () => {
const result = parseQuery([
{
type: 'sql',
condition: `SpanName = 'flagd.evaluation.v1.Service/EventStream' AND SpanKind IN ('Server', 'SPAN_KIND_SERVER')`,
},
]);
expect(result.filters).toEqual({
SpanKind: {
included: new Set(['Server', 'SPAN_KIND_SERVER']),
excluded: new Set(),
},
});
});

it('skips conditions with OR operator (not supported)', () => {
const result = parseQuery([
{
type: 'sql',
condition: `level IN ('error') OR severity IN ('high')`,
},
]);
// OR is not supported, so it just tries to parse as-is and should fail cleanly
expect(result.filters).toEqual({});
});

it('skips conditions with only equality operators', () => {
const result = parseQuery([
{
type: 'sql',
condition: `status_code = 200`,
},
]);
expect(result.filters).toEqual({});
});

it('skips conditions with only comparison operators', () => {
const result = parseQuery([
{
type: 'sql',
condition: `duration > 1000`,
},
]);
expect(result.filters).toEqual({});
});

it('parses simple IN conditions alongside extracting from complex conditions', () => {
const result = parseQuery([
{ type: 'sql', condition: `service IN ('app', 'api')` },
{
type: 'sql',
condition: `SpanName = 'test' AND SpanKind IN ('Server')`,
},
{ type: 'sql', condition: `level IN ('error')` },
]);
expect(result.filters).toEqual({
service: { included: new Set(['app', 'api']), excluded: new Set() },
SpanKind: { included: new Set(['Server']), excluded: new Set() },
level: { included: new Set(['error']), excluded: new Set() },
});
});

it('handles multiple IN clauses with AND', () => {
const result = parseQuery([
{
type: 'sql',
condition: `service IN ('app') AND level IN ('error', 'warn')`,
},
]);
expect(result.filters).toEqual({
service: { included: new Set(['app']), excluded: new Set() },
level: { included: new Set(['error', 'warn']), excluded: new Set() },
});
});

it('extracts NOT IN clauses from complex conditions', () => {
const result = parseQuery([
{
type: 'sql',
condition: `status = 'active' AND level NOT IN ('debug')`,
},
]);
expect(result.filters).toEqual({
level: { included: new Set(), excluded: new Set(['debug']) },
});
});

it('handles string values with special characters in AND conditions', () => {
const result = parseQuery([
{
type: 'sql',
condition: `SpanName = 'flagd.evaluation.v1.Service/EventStream' AND SpanKind IN ('Server', 'SPAN_KIND_SERVER')`,
},
]);
expect(result.filters).toEqual({
SpanKind: {
included: new Set(['Server', 'SPAN_KIND_SERVER']),
excluded: new Set(),
},
});
});

it('handles JSON values with commas and special characters', () => {
const result = parseQuery([
{
type: 'sql',
condition: `Body IN ('{"orderId": "123", "total": 100}')`,
},
]);
expect(result.filters).toEqual({
Body: {
included: new Set(['{"orderId": "123", "total": 100}']),
excluded: new Set(),
},
});
});

it('handles complex multi-line JSON values', () => {
const result = parseQuery([
{
type: 'sql',
condition: `Body IN ('Order details: { "orderId": "7b54ad99", "items": [{"id": 1}, {"id": 2}] }')`,
},
]);
expect(result.filters).toEqual({
Body: {
included: new Set([
'Order details: { "orderId": "7b54ad99", "items": [{"id": 1}, {"id": 2}] }',
]),
excluded: new Set(),
},
});
});

it('handles multiple simple values alongside single complex JSON value', () => {
const result = parseQuery([
{
type: 'sql',
condition: `status IN ('active', 'pending')`,
},
{
type: 'sql',
condition: `data IN ('{"key": "value", "nested": {"a": 1}}')`,
},
]);
expect(result.filters).toEqual({
status: {
included: new Set(['active', 'pending']),
excluded: new Set(),
},
data: {
included: new Set(['{"key": "value", "nested": {"a": 1}}']),
excluded: new Set(),
},
});
});
});

describe('areFiltersEqual', () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -49,12 +49,7 @@ export const DBRowTableFieldWithPopover = ({
const { onPropertyAddClick } = useContext(RowSidePanelContext);

// Check if we have both the column name and filter function available
// Only show filter for ServiceName and SeverityText
const canFilter =
columnName &&
(columnName === 'ServiceName' || columnName === 'SeverityText') &&
onPropertyAddClick &&
cellValue != null;
const canFilter = columnName && onPropertyAddClick && cellValue != null;

const handleMouseEnter = () => {
if (hoverDisabled) return;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -49,13 +49,13 @@ export default function ServiceDashboardEndpointSidePanel({
const filters: Filter[] = [
{
type: 'sql',
condition: `${expressions.spanName} = '${endpoint}' AND ${expressions.isSpanKindServer}`,
condition: `${expressions.spanName} IN ('${endpoint}') AND ${expressions.isSpanKindServer}`,
},
];
if (service) {
filters.push({
type: 'sql',
condition: `${expressions.service} = '${service}'`,
condition: `${expressions.service} IN ('${service}')`,
});
}
return filters;
Expand Down
171 changes: 144 additions & 27 deletions packages/app/src/searchFilters.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,135 @@ export const areFiltersEqual = (a: FilterState, b: FilterState) => {
return true;
};

// Helper function to split on commas while respecting quoted strings
function splitValuesOnComma(valuesStr: string): string[] {
const values: string[] = [];
let currentValue = '';
let inString = false;

for (let i = 0; i < valuesStr.length; i++) {
const char = valuesStr[i];

if (char === "'" && (i === 0 || valuesStr[i - 1] !== '\\')) {
inString = !inString;
currentValue += char;
continue;
}

if (!inString && char === ',') {
if (currentValue.trim()) {
// Remove surrounding quotes if present
const trimmed = currentValue.trim();
const unquoted =
trimmed.startsWith("'") && trimmed.endsWith("'")
? trimmed.slice(1, -1)
: trimmed;
values.push(unquoted);
}
currentValue = '';
continue;
}

currentValue += char;
}

// Add the last value
if (currentValue.trim()) {
const trimmed = currentValue.trim();
const unquoted =
trimmed.startsWith("'") && trimmed.endsWith("'")
? trimmed.slice(1, -1)
: trimmed;
values.push(unquoted);
}

return values;
}

// Helper function to extract simple IN/NOT IN clauses from a condition
// This handles both simple conditions and compound conditions with AND
function extractInClauses(condition: string): Array<{
key: string;
values: string[];
isExclude: boolean;
}> {
const results: Array<{
key: string;
values: string[];
isExclude: boolean;
}> = [];

// Split on ' AND ' while respecting quoted strings
const parts: string[] = [];
let currentPart = '';
let inString = false;

for (let i = 0; i < condition.length; i++) {
const char = condition[i];

if (char === "'" && (i === 0 || condition[i - 1] !== '\\')) {
inString = !inString;
currentPart += char;
continue;
}

if (!inString && condition.slice(i, i + 5).toUpperCase() === ' AND ') {
if (currentPart.trim()) {
parts.push(currentPart.trim());
}
currentPart = '';
i += 4; // Skip past ' AND '
continue;
}

currentPart += char;
}

if (currentPart.trim()) {
parts.push(currentPart.trim());
}

// Process each part to extract IN/NOT IN clauses
for (const part of parts) {
// Skip parts that contain OR (not supported) or comparison operators
if (
part.toUpperCase().includes(' OR ') ||
part.includes('=') ||
part.includes('<') ||
part.includes('>')
) {
continue;
}

const isExclude = part.includes('NOT IN');

// Check if this is an IN clause
if (part.includes(' IN ') || part.includes(' NOT IN ')) {
const [key, values] = part.split(isExclude ? ' NOT IN ' : ' IN ');

if (key && values) {
const keyStr = key.trim();
// Remove outer parentheses and split on commas while respecting quotes
const trimmedValues = values.trim();
const withoutParens =
trimmedValues.startsWith('(') && trimmedValues.endsWith(')')
? trimmedValues.slice(1, -1)
: trimmedValues;

const valuesArray = splitValuesOnComma(withoutParens);

results.push({
key: keyStr,
values: valuesArray,
isExclude,
});
}
}
}

return results;
}

export const parseQuery = (
q: Filter[],
): {
Expand Down Expand Up @@ -125,35 +254,23 @@ export const parseQuery = (
}
}

// Handle IN/NOT IN conditions
const isExclude = filter.condition.includes('NOT IN');
const [key, values] = filter.condition.split(
isExclude ? ' NOT IN ' : ' IN ',
);

// Skip if key or values is not present
if (!key || !values) {
continue;
}

const keyStr = key.trim();
const valuesStr = values
.replace('(', '')
.replace(')', '')
.split(',')
.map(v => v.trim().replace(/'/g, ''));
// Extract all simple IN/NOT IN clauses from the condition
// This handles both simple conditions and compound conditions with AND/OR
const inClauses = extractInClauses(filter.condition);

if (!state.has(keyStr)) {
state.set(keyStr, { included: new Set(), excluded: new Set() });
}
const sets = state.get(keyStr)!;
valuesStr.forEach(v => {
if (isExclude) {
sets.excluded.add(v);
} else {
sets.included.add(v);
for (const clause of inClauses) {
if (!state.has(clause.key)) {
state.set(clause.key, { included: new Set(), excluded: new Set() });
}
});
const sets = state.get(clause.key)!;
clause.values.forEach(v => {
if (clause.isExclude) {
sets.excluded.add(v);
} else {
sets.included.add(v);
}
});
}
}
return { filters: Object.fromEntries(state) };
};
Expand Down
Loading