import { describe, it, expect } from 'vitest'; import { QueryParser } from './query-parser.js'; import type { QueryConditionNumber, QueryConditionText, QueryFilter, QueryOperator } from './query-parser.schemas.js'; describe('QueryParser', () => { const parser = new QueryParser(); describe('parse', () => { describe('text conditions', () => { it('should parse simple text equality', () => { const result = parser.parse("name = 'John'"); expect(result).toEqual({ type: 'text', field: ['name'], conditions: { equal: 'John' }, }); }); it('should parse nested field text equality', () => { const result = parser.parse("metadata.author = 'John'"); expect(result).toEqual({ type: 'text', field: ['metadata', 'author'], conditions: { equal: 'John' }, }); }); it('should parse deeply nested field', () => { const result = parser.parse("metadata.nested.deep.field = 'value'"); expect(result).toEqual({ type: 'text', field: ['metadata', 'nested', 'deep', 'field'], conditions: { equal: 'value' }, }); }); it('should parse text not equal', () => { const result = parser.parse("type != 'draft'"); expect(result).toEqual({ type: 'text', field: ['type'], conditions: { notEqual: 'draft' }, }); }); it('should parse LIKE pattern', () => { const result = parser.parse("title LIKE '%cat%'"); expect(result).toEqual({ type: 'text', field: ['title'], conditions: { like: '%cat%' }, }); }); it('should parse NOT LIKE pattern', () => { const result = parser.parse("author NOT LIKE '%admin%'"); expect(result).toEqual({ type: 'text', field: ['author'], conditions: { notLike: '%admin%' }, }); }); it('should parse text IN list', () => { const result = parser.parse("status IN ('published', 'archived', 'draft')"); expect(result).toEqual({ type: 'text', field: ['status'], conditions: { in: ['published', 'archived', 'draft'] }, }); }); it('should parse text NOT IN list', () => { const result = parser.parse("category NOT IN ('deleted', 'hidden')"); expect(result).toEqual({ type: 'text', field: ['category'], conditions: { notIn: ['deleted', 'hidden'] }, }); }); it('should parse IS NULL', () => { const result = parser.parse('source IS NULL'); expect(result).toEqual({ type: 'text', field: ['source'], conditions: { equal: null }, }); }); it('should handle escaped quotes in strings', () => { const result = parser.parse("name = 'O''Brien'"); expect(result).toEqual({ type: 'text', field: ['name'], conditions: { equal: "O'Brien" }, }); }); it('should handle empty string', () => { const result = parser.parse("name = ''"); expect(result).toEqual({ type: 'text', field: ['name'], conditions: { equal: '' }, }); }); }); describe('numeric conditions', () => { it('should parse numeric equality', () => { const result = parser.parse('age = 30'); expect(result).toEqual({ type: 'number', field: ['age'], conditions: { equals: 30 }, }); }); it('should parse numeric not equal', () => { const result = parser.parse('count != 0'); expect(result).toEqual({ type: 'number', field: ['count'], conditions: { notEquals: 0 }, }); }); it('should parse greater than', () => { const result = parser.parse('views > 100'); expect(result).toEqual({ type: 'number', field: ['views'], conditions: { greaterThan: 100 }, }); }); it('should parse greater than or equal', () => { const result = parser.parse('views >= 100'); expect(result).toEqual({ type: 'number', field: ['views'], conditions: { greaterThanOrEqual: 100 }, }); }); it('should parse less than', () => { const result = parser.parse('priority < 5'); expect(result).toEqual({ type: 'number', field: ['priority'], conditions: { lessThan: 5 }, }); }); it('should parse less than or equal', () => { const result = parser.parse('age <= 65'); expect(result).toEqual({ type: 'number', field: ['age'], conditions: { lessThanOrEqual: 65 }, }); }); it('should parse decimal numbers', () => { const result = parser.parse('score > 0.5'); expect(result).toEqual({ type: 'number', field: ['score'], conditions: { greaterThan: 0.5 }, }); }); it('should parse negative numbers', () => { const result = parser.parse('temperature > -10'); expect(result).toEqual({ type: 'number', field: ['temperature'], conditions: { greaterThan: -10 }, }); }); it('should parse numeric IN list', () => { const result = parser.parse('priority IN (1, 2, 3)'); expect(result).toEqual({ type: 'number', field: ['priority'], conditions: { in: [1, 2, 3] }, }); }); it('should parse numeric NOT IN list', () => { const result = parser.parse('count NOT IN (0, -1)'); expect(result).toEqual({ type: 'number', field: ['count'], conditions: { notIn: [0, -1] }, }); }); it('should parse nested field numeric condition', () => { const result = parser.parse('metadata.score >= 0.8'); expect(result).toEqual({ type: 'number', field: ['metadata', 'score'], conditions: { greaterThanOrEqual: 0.8 }, }); }); }); describe('logical operators', () => { it('should parse AND operator', () => { const result = parser.parse("type = 'article' AND status = 'published'"); expect(result).toEqual({ type: 'operator', operator: 'and', conditions: [ { type: 'text', field: ['type'], conditions: { equal: 'article' } }, { type: 'text', field: ['status'], conditions: { equal: 'published' } }, ], }); }); it('should parse OR operator', () => { const result = parser.parse("category = 'tech' OR category = 'science'"); expect(result).toEqual({ type: 'operator', operator: 'or', conditions: [ { type: 'text', field: ['category'], conditions: { equal: 'tech' } }, { type: 'text', field: ['category'], conditions: { equal: 'science' } }, ], }); }); it('should parse multiple AND conditions', () => { const result = parser.parse("type = 'article' AND status = 'published' AND views > 100"); expect(result).toEqual({ type: 'operator', operator: 'and', conditions: [ { type: 'text', field: ['type'], conditions: { equal: 'article' } }, { type: 'text', field: ['status'], conditions: { equal: 'published' } }, { type: 'number', field: ['views'], conditions: { greaterThan: 100 } }, ], }); }); it('should parse multiple OR conditions', () => { const result = parser.parse("type = 'a' OR type = 'b' OR type = 'c'"); expect(result).toEqual({ type: 'operator', operator: 'or', conditions: [ { type: 'text', field: ['type'], conditions: { equal: 'a' } }, { type: 'text', field: ['type'], conditions: { equal: 'b' } }, { type: 'text', field: ['type'], conditions: { equal: 'c' } }, ], }); }); it('should respect AND precedence over OR', () => { // A AND B OR C should be parsed as (A AND B) OR C const result = parser.parse("a = '1' AND b = '2' OR c = '3'"); expect(result).toEqual({ type: 'operator', operator: 'or', conditions: [ { type: 'operator', operator: 'and', conditions: [ { type: 'text', field: ['a'], conditions: { equal: '1' } }, { type: 'text', field: ['b'], conditions: { equal: '2' } }, ], }, { type: 'text', field: ['c'], conditions: { equal: '3' } }, ], }); }); it('should parse parenthesized expressions', () => { const result = parser.parse("(type = 'post' OR type = 'page') AND views > 100"); expect(result).toEqual({ type: 'operator', operator: 'and', conditions: [ { type: 'operator', operator: 'or', conditions: [ { type: 'text', field: ['type'], conditions: { equal: 'post' } }, { type: 'text', field: ['type'], conditions: { equal: 'page' } }, ], }, { type: 'number', field: ['views'], conditions: { greaterThan: 100 } }, ], }); }); it('should parse nested parentheses', () => { const result = parser.parse( "((status = 'active' AND views > 100) OR (status = 'featured' AND views > 50)) AND category = 'news'", ); expect(result).toEqual({ type: 'operator', operator: 'and', conditions: [ { type: 'operator', operator: 'or', conditions: [ { type: 'operator', operator: 'and', conditions: [ { type: 'text', field: ['status'], conditions: { equal: 'active' } }, { type: 'number', field: ['views'], conditions: { greaterThan: 100 } }, ], }, { type: 'operator', operator: 'and', conditions: [ { type: 'text', field: ['status'], conditions: { equal: 'featured' } }, { type: 'number', field: ['views'], conditions: { greaterThan: 50 } }, ], }, ], }, { type: 'text', field: ['category'], conditions: { equal: 'news' } }, ], }); }); }); describe('case insensitivity', () => { it('should parse lowercase AND', () => { const result = parser.parse("a = '1' and b = '2'"); expect(result.type).toBe('operator'); expect((result as QueryOperator).operator).toBe('and'); }); it('should parse lowercase OR', () => { const result = parser.parse("a = '1' or b = '2'"); expect(result.type).toBe('operator'); expect((result as QueryOperator).operator).toBe('or'); }); it('should parse mixed case LIKE', () => { const result = parser.parse("title Like '%test%'"); expect(result).toEqual({ type: 'text', field: ['title'], conditions: { like: '%test%' }, }); }); it('should parse mixed case IS NULL', () => { const result = parser.parse('field Is Null'); expect(result).toEqual({ type: 'text', field: ['field'], conditions: { equal: null }, }); }); it('should parse mixed case IN', () => { const result = parser.parse("status In ('a', 'b')"); expect(result).toEqual({ type: 'text', field: ['status'], conditions: { in: ['a', 'b'] }, }); }); }); describe('whitespace handling', () => { it('should handle extra whitespace', () => { const result = parser.parse(" name = 'John' "); expect(result).toEqual({ type: 'text', field: ['name'], conditions: { equal: 'John' }, }); }); it('should handle no whitespace around operators', () => { const result = parser.parse("name='John'"); expect(result).toEqual({ type: 'text', field: ['name'], conditions: { equal: 'John' }, }); }); it('should handle tabs and newlines', () => { const result = parser.parse("name\t=\n'John'"); expect(result).toEqual({ type: 'text', field: ['name'], conditions: { equal: 'John' }, }); }); }); describe('error handling', () => { it('should throw on invalid syntax', () => { expect(() => parser.parse('invalid')).toThrow(); }); it('should throw on mismatched parentheses', () => { expect(() => parser.parse("(type = 'a'")).toThrow(); }); it('should throw on unterminated string', () => { expect(() => parser.parse("name = 'unterminated")).toThrow(/Unterminated string/); }); it('should throw on unexpected token', () => { expect(() => parser.parse("name = 'a' INVALID")).toThrow(); }); it('should throw on missing value after operator', () => { expect(() => parser.parse('name =')).toThrow(); }); }); }); describe('stringify', () => { describe('text conditions', () => { it('should stringify text equality', () => { const filter: QueryConditionText = { type: 'text', field: ['name'], conditions: { equal: 'John' }, }; expect(parser.stringify(filter)).toBe("name = 'John'"); }); it('should stringify nested field', () => { const filter: QueryConditionText = { type: 'text', field: ['metadata', 'author'], conditions: { equal: 'John' }, }; expect(parser.stringify(filter)).toBe("metadata.author = 'John'"); }); it('should stringify text not equal', () => { const filter: QueryConditionText = { type: 'text', field: ['type'], conditions: { notEqual: 'draft' }, }; expect(parser.stringify(filter)).toBe("type != 'draft'"); }); it('should stringify LIKE', () => { const filter: QueryConditionText = { type: 'text', field: ['title'], conditions: { like: '%cat%' }, }; expect(parser.stringify(filter)).toBe("title LIKE '%cat%'"); }); it('should stringify NOT LIKE', () => { const filter: QueryConditionText = { type: 'text', field: ['author'], conditions: { notLike: '%admin%' }, }; expect(parser.stringify(filter)).toBe("author NOT LIKE '%admin%'"); }); it('should stringify text IN', () => { const filter: QueryConditionText = { type: 'text', field: ['status'], conditions: { in: ['published', 'archived'] }, }; expect(parser.stringify(filter)).toBe("status IN ('published', 'archived')"); }); it('should stringify text NOT IN', () => { const filter: QueryConditionText = { type: 'text', field: ['category'], conditions: { notIn: ['deleted', 'hidden'] }, }; expect(parser.stringify(filter)).toBe("category NOT IN ('deleted', 'hidden')"); }); it('should stringify IS NULL', () => { const filter: QueryConditionText = { type: 'text', field: ['source'], conditions: { equal: null }, }; expect(parser.stringify(filter)).toBe('source IS NULL'); }); it('should escape quotes in strings', () => { const filter: QueryConditionText = { type: 'text', field: ['name'], conditions: { equal: "O'Brien" }, }; expect(parser.stringify(filter)).toBe("name = 'O''Brien'"); }); }); describe('numeric conditions', () => { it('should stringify numeric equality', () => { const filter: QueryConditionNumber = { type: 'number', field: ['age'], conditions: { equals: 30 }, }; expect(parser.stringify(filter)).toBe('age = 30'); }); it('should stringify numeric not equal', () => { const filter: QueryConditionNumber = { type: 'number', field: ['count'], conditions: { notEquals: 0 }, }; expect(parser.stringify(filter)).toBe('count != 0'); }); it('should stringify greater than', () => { const filter: QueryConditionNumber = { type: 'number', field: ['views'], conditions: { greaterThan: 100 }, }; expect(parser.stringify(filter)).toBe('views > 100'); }); it('should stringify greater than or equal', () => { const filter: QueryConditionNumber = { type: 'number', field: ['views'], conditions: { greaterThanOrEqual: 100 }, }; expect(parser.stringify(filter)).toBe('views >= 100'); }); it('should stringify less than', () => { const filter: QueryConditionNumber = { type: 'number', field: ['priority'], conditions: { lessThan: 5 }, }; expect(parser.stringify(filter)).toBe('priority < 5'); }); it('should stringify less than or equal', () => { const filter: QueryConditionNumber = { type: 'number', field: ['age'], conditions: { lessThanOrEqual: 65 }, }; expect(parser.stringify(filter)).toBe('age <= 65'); }); it('should stringify decimal numbers', () => { const filter: QueryConditionNumber = { type: 'number', field: ['score'], conditions: { greaterThan: 0.5 }, }; expect(parser.stringify(filter)).toBe('score > 0.5'); }); it('should stringify numeric IN', () => { const filter: QueryConditionNumber = { type: 'number', field: ['priority'], conditions: { in: [1, 2, 3] }, }; expect(parser.stringify(filter)).toBe('priority IN (1, 2, 3)'); }); it('should stringify numeric NOT IN', () => { const filter: QueryConditionNumber = { type: 'number', field: ['count'], conditions: { notIn: [0, -1] }, }; expect(parser.stringify(filter)).toBe('count NOT IN (0, -1)'); }); it('should stringify numeric IS NULL', () => { const filter: QueryConditionNumber = { type: 'number', field: ['score'], conditions: { equals: null }, }; expect(parser.stringify(filter)).toBe('score IS NULL'); }); it('should stringify numeric IS NOT NULL', () => { const filter: QueryConditionNumber = { type: 'number', field: ['score'], conditions: { notEquals: null }, }; expect(parser.stringify(filter)).toBe('score IS NOT NULL'); }); }); describe('logical operators', () => { it('should stringify AND operator', () => { const filter: QueryFilter = { type: 'operator', operator: 'and', conditions: [ { type: 'text', field: ['type'], conditions: { equal: 'article' } }, { type: 'text', field: ['status'], conditions: { equal: 'published' } }, ], }; expect(parser.stringify(filter)).toBe("type = 'article' AND status = 'published'"); }); it('should stringify OR operator', () => { const filter: QueryFilter = { type: 'operator', operator: 'or', conditions: [ { type: 'text', field: ['category'], conditions: { equal: 'tech' } }, { type: 'text', field: ['category'], conditions: { equal: 'science' } }, ], }; expect(parser.stringify(filter)).toBe("category = 'tech' OR category = 'science'"); }); it('should stringify nested operators with parentheses', () => { const filter: QueryFilter = { type: 'operator', operator: 'and', conditions: [ { type: 'operator', operator: 'or', conditions: [ { type: 'text', field: ['type'], conditions: { equal: 'post' } }, { type: 'text', field: ['type'], conditions: { equal: 'page' } }, ], }, { type: 'number', field: ['views'], conditions: { greaterThan: 100 } }, ], }; expect(parser.stringify(filter)).toBe("(type = 'post' OR type = 'page') AND views > 100"); }); it('should stringify empty operator', () => { const filter: QueryFilter = { type: 'operator', operator: 'and', conditions: [], }; expect(parser.stringify(filter)).toBe(''); }); it('should stringify single-condition operator', () => { const filter: QueryFilter = { type: 'operator', operator: 'and', conditions: [{ type: 'text', field: ['name'], conditions: { equal: 'test' } }], }; expect(parser.stringify(filter)).toBe("name = 'test'"); }); }); }); describe('roundtrip', () => { const testCases = [ "name = 'John'", "metadata.author = 'Jane'", 'views > 100', 'score >= 0.5', "title LIKE '%cat%'", "author NOT LIKE '%admin%'", "status IN ('published', 'archived')", 'priority IN (1, 2, 3)', "type = 'article' AND status = 'published'", "category = 'tech' OR category = 'science'", "(type = 'post' OR type = 'page') AND views > 100", ]; testCases.forEach((query) => { it(`should roundtrip: ${query}`, () => { const parsed = parser.parse(query); const stringified = parser.stringify(parsed); const reparsed = parser.parse(stringified); expect(reparsed).toEqual(parsed); }); }); }); describe('complex real-world queries', () => { it('should handle complex query with multiple field types', () => { const query = "type = 'article' AND (metadata.author = 'John' OR metadata.author = 'Jane') AND views >= 100"; const result = parser.parse(query); expect(result.type).toBe('operator'); const operator = result as QueryOperator; expect(operator.operator).toBe('and'); expect(operator.conditions).toHaveLength(3); }); it('should handle nested JSON paths with conditions', () => { const query = "metadata.nested.deep.value = 'test' AND metadata.nested.count > 10"; const result = parser.parse(query); expect(result.type).toBe('operator'); const operator = result as QueryOperator; const condition1 = operator.conditions[0] as QueryConditionText; const condition2 = operator.conditions[1] as QueryConditionNumber; expect(condition1.field).toEqual(['metadata', 'nested', 'deep', 'value']); expect(condition2.field).toEqual(['metadata', 'nested', 'count']); }); it('should handle query from documentation example', () => { // From the JSON format in docs const expectedJson: QueryFilter = { type: 'operator', operator: 'and', conditions: [ { type: 'text', field: ['metadata', 'foo'], conditions: { equal: 'bar' }, }, { type: 'text', field: ['type'], conditions: { equal: 'demo' }, }, ], }; const sql = "metadata.foo = 'bar' AND type = 'demo'"; const parsed = parser.parse(sql); expect(parsed).toEqual(expectedJson); }); }); });