update
This commit is contained in:
4
packages/query-dsl/.gitignore
vendored
Normal file
4
packages/query-dsl/.gitignore
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
/node_modules/
|
||||
/dist/
|
||||
/coverage/
|
||||
/.env
|
||||
336
packages/query-dsl/docs/query-language.md
Normal file
336
packages/query-dsl/docs/query-language.md
Normal file
@@ -0,0 +1,336 @@
|
||||
# Query Language Specification
|
||||
|
||||
This document describes the SQL-like query language syntax for building database queries. The language supports filtering on both text and numeric fields, including nested JSON fields, with logical operators for complex queries.
|
||||
|
||||
## Overview
|
||||
|
||||
The query language provides a human-readable, SQL-like syntax that can be parsed into the internal JSON query format used by the system. It supports:
|
||||
|
||||
- Text field conditions (equality, pattern matching, membership)
|
||||
- Numeric field conditions (comparison operators, membership)
|
||||
- Nested JSON field access using dot notation
|
||||
- Logical operators (AND, OR) with grouping
|
||||
- NULL value checks
|
||||
|
||||
## Syntax
|
||||
|
||||
### Field References
|
||||
|
||||
Fields are referenced using dot notation for nested JSON paths:
|
||||
|
||||
```
|
||||
field_name
|
||||
metadata.foo
|
||||
metadata.nested.deep.field
|
||||
```
|
||||
|
||||
**Examples:**
|
||||
- `content` - top-level field
|
||||
- `metadata.author` - nested field in metadata object
|
||||
- `metadata.tags.0` - array element (if needed)
|
||||
|
||||
### Text Conditions
|
||||
|
||||
Text conditions operate on string values:
|
||||
|
||||
| Operator | Syntax | Description |
|
||||
|----------|--------|-------------|
|
||||
| Equality | `field = 'value'` | Exact match |
|
||||
| Inequality | `field != 'value'` | Not equal |
|
||||
| NULL check | `field IS NULL` | Field is null |
|
||||
| NOT NULL | `field IS NOT NULL` | Field is not null |
|
||||
| Pattern match | `field LIKE 'pattern'` | SQL LIKE pattern matching |
|
||||
| Not like | `field NOT LIKE 'pattern'` | Negated pattern matching |
|
||||
| In list | `field IN ('val1', 'val2', 'val3')` | Value in list |
|
||||
| Not in list | `field NOT IN ('val1', 'val2')` | Value not in list |
|
||||
|
||||
**String Literals:**
|
||||
- Single quotes: `'value'`
|
||||
- Escaped quotes: `'O''Brien'` (double single quote)
|
||||
- Empty string: `''`
|
||||
|
||||
**LIKE Patterns:**
|
||||
- `%` matches any sequence of characters
|
||||
- `_` matches any single character
|
||||
- Examples: `'%cat%'`, `'test_%'`, `'exact'`
|
||||
|
||||
**Examples:**
|
||||
```sql
|
||||
content = 'hello world'
|
||||
metadata.foo = 'bar'
|
||||
type != 'draft'
|
||||
source IS NULL
|
||||
title LIKE '%cat%'
|
||||
author NOT LIKE '%admin%'
|
||||
status IN ('published', 'archived')
|
||||
category NOT IN ('deleted', 'hidden')
|
||||
```
|
||||
|
||||
### Numeric Conditions
|
||||
|
||||
Numeric conditions operate on number values:
|
||||
|
||||
| Operator | Syntax | Description |
|
||||
|----------|--------|-------------|
|
||||
| Equality | `field = 123` | Exact match |
|
||||
| Inequality | `field != 123` | Not equal |
|
||||
| NULL check | `field IS NULL` | Field is null |
|
||||
| NOT NULL | `field IS NOT NULL` | Field is not null |
|
||||
| Greater than | `field > 10` | Greater than |
|
||||
| Greater or equal | `field >= 10` | Greater than or equal |
|
||||
| Less than | `field < 10` | Less than |
|
||||
| Less or equal | `field <= 10` | Less than or equal |
|
||||
| In list | `field IN (1, 2, 3)` | Value in list |
|
||||
| Not in list | `field NOT IN (1, 2, 3)` | Value not in list |
|
||||
|
||||
**Numeric Literals:**
|
||||
- Integers: `123`, `-45`, `0`
|
||||
- Decimals: `123.45`, `-0.5`, `3.14159`
|
||||
- Scientific notation: `1e10`, `2.5e-3` (if supported)
|
||||
|
||||
**Examples:**
|
||||
```sql
|
||||
typeVersion = 1
|
||||
score > 0.5
|
||||
views >= 100
|
||||
priority < 5
|
||||
age <= 65
|
||||
rating IN (1, 2, 3, 4, 5)
|
||||
count NOT IN (0, -1)
|
||||
```
|
||||
|
||||
### Logical Operators
|
||||
|
||||
Combine conditions using `AND` and `OR` operators:
|
||||
|
||||
| Operator | Syntax | Description |
|
||||
|----------|--------|-------------|
|
||||
| AND | `condition1 AND condition2` | Both conditions must be true |
|
||||
| OR | `condition1 OR condition2` | At least one condition must be true |
|
||||
|
||||
**Grouping:**
|
||||
Use parentheses `()` to group conditions and control operator precedence:
|
||||
|
||||
```sql
|
||||
(condition1 AND condition2) OR condition3
|
||||
condition1 AND (condition2 OR condition3)
|
||||
```
|
||||
|
||||
**Examples:**
|
||||
```sql
|
||||
type = 'article' AND status = 'published'
|
||||
metadata.foo = 'bar' OR metadata.foo = 'baz'
|
||||
(type = 'post' OR type = 'page') AND views > 100
|
||||
```
|
||||
|
||||
### Operator Precedence
|
||||
|
||||
1. Parentheses `()` - highest precedence
|
||||
2. `AND` - evaluated before OR
|
||||
3. `OR` - lowest precedence
|
||||
|
||||
**Examples:**
|
||||
```sql
|
||||
-- Equivalent to: (A AND B) OR C
|
||||
A AND B OR C
|
||||
|
||||
-- Equivalent to: A AND (B OR C)
|
||||
A AND (B OR C)
|
||||
|
||||
-- Explicit grouping
|
||||
(A OR B) AND (C OR D)
|
||||
```
|
||||
|
||||
## Complete Examples
|
||||
|
||||
### Simple Conditions
|
||||
|
||||
```sql
|
||||
-- Text equality
|
||||
metadata.author = 'John Doe'
|
||||
|
||||
-- Numeric comparison
|
||||
views >= 1000
|
||||
|
||||
-- Pattern matching
|
||||
title LIKE '%tutorial%'
|
||||
|
||||
-- NULL check
|
||||
source IS NULL
|
||||
```
|
||||
|
||||
### Multiple Conditions
|
||||
|
||||
```sql
|
||||
-- AND operator
|
||||
type = 'article' AND status = 'published' AND views > 100
|
||||
|
||||
-- OR operator
|
||||
category = 'tech' OR category = 'science'
|
||||
|
||||
-- Mixed operators
|
||||
(type = 'post' OR type = 'page') AND published = true
|
||||
```
|
||||
|
||||
### Complex Nested Queries
|
||||
|
||||
```sql
|
||||
-- Nested AND within OR
|
||||
(metadata.foo = 'bar' AND type = 'demo') OR metadata.foo = 'baz'
|
||||
|
||||
-- Multiple levels of nesting
|
||||
((status = 'active' AND views > 100) OR (status = 'featured' AND views > 50)) AND category = 'news'
|
||||
|
||||
-- Complex query with multiple field types
|
||||
type = 'article' AND (metadata.author = 'John' OR metadata.author = 'Jane') AND views >= 100 AND rating IN (4, 5)
|
||||
```
|
||||
|
||||
### Array/List Operations
|
||||
|
||||
```sql
|
||||
-- Text IN
|
||||
status IN ('published', 'archived', 'draft')
|
||||
|
||||
-- Numeric IN
|
||||
priority IN (1, 2, 3)
|
||||
|
||||
-- NOT IN
|
||||
category NOT IN ('deleted', 'hidden')
|
||||
```
|
||||
|
||||
## Type Inference
|
||||
|
||||
The parser will infer the condition type (text vs number) based on:
|
||||
|
||||
1. **Operator context**: Operators like `>`, `<`, `>=`, `<=` imply numeric
|
||||
2. **Value type**:
|
||||
- Quoted strings (`'value'`) → text condition
|
||||
- Unquoted numbers (`123`, `45.6`) → numeric condition
|
||||
- `NULL` → can be either (context-dependent)
|
||||
3. **Field name**: If a field is known to be numeric, numeric operators are used
|
||||
|
||||
**Examples:**
|
||||
```sql
|
||||
-- Text condition (quoted string)
|
||||
author = 'John'
|
||||
|
||||
-- Numeric condition (unquoted number)
|
||||
age = 30
|
||||
|
||||
-- Numeric comparison
|
||||
score > 0.5
|
||||
|
||||
-- Text pattern
|
||||
title LIKE '%test%'
|
||||
```
|
||||
|
||||
## Escaping and Special Characters
|
||||
|
||||
### String Escaping
|
||||
|
||||
- Single quotes in strings: `'O''Brien'` → `O'Brien`
|
||||
- Empty string: `''`
|
||||
|
||||
### Field Name Escaping
|
||||
|
||||
If field names contain special characters or reserved words, they can be quoted (implementation-dependent):
|
||||
|
||||
```sql
|
||||
-- Reserved words or special characters (if supported)
|
||||
"order" = 'asc'
|
||||
"metadata.field-name" = 'value'
|
||||
```
|
||||
|
||||
## Error Handling
|
||||
|
||||
The parser should provide clear error messages for:
|
||||
|
||||
- Invalid syntax
|
||||
- Mismatched parentheses
|
||||
- Invalid operators for field types
|
||||
- Missing values
|
||||
- Invalid escape sequences
|
||||
|
||||
## Grammar (BNF-like)
|
||||
|
||||
```
|
||||
query ::= expression
|
||||
expression ::= condition | group
|
||||
group ::= '(' expression ')'
|
||||
| expression AND expression
|
||||
| expression OR expression
|
||||
condition ::= text_condition | numeric_condition
|
||||
text_condition ::= field ( '=' | '!=' | 'LIKE' | 'NOT LIKE' ) string_literal
|
||||
| field 'IS' ( 'NULL' | 'NOT NULL' )
|
||||
| field 'IN' '(' string_list ')'
|
||||
| field 'NOT IN' '(' string_list ')'
|
||||
numeric_condition ::= field ( '=' | '!=' | '>' | '>=' | '<' | '<=' ) number
|
||||
| field 'IS' ( 'NULL' | 'NOT NULL' )
|
||||
| field 'IN' '(' number_list ')'
|
||||
| field 'NOT IN' '(' number_list ')'
|
||||
field ::= identifier ( '.' identifier )*
|
||||
identifier ::= [a-zA-Z_][a-zA-Z0-9_]*
|
||||
string_literal ::= "'" ( escaped_char | [^'] )* "'"
|
||||
escaped_char ::= "''"
|
||||
string_list ::= string_literal ( ',' string_literal )*
|
||||
number ::= [0-9]+ ( '.' [0-9]+ )? ( [eE] [+-]? [0-9]+ )?
|
||||
number_list ::= number ( ',' number )*
|
||||
```
|
||||
|
||||
## Migration from JSON Format
|
||||
|
||||
The SQL-like syntax maps to the JSON format as follows:
|
||||
|
||||
**JSON:**
|
||||
```json
|
||||
{
|
||||
"type": "text",
|
||||
"field": ["metadata", "foo"],
|
||||
"conditions": {
|
||||
"equal": "bar"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**SQL:**
|
||||
```sql
|
||||
metadata.foo = 'bar'
|
||||
```
|
||||
|
||||
**JSON (with operator):**
|
||||
```json
|
||||
{
|
||||
"type": "operator",
|
||||
"operator": "and",
|
||||
"conditions": [
|
||||
{
|
||||
"type": "text",
|
||||
"field": ["metadata", "foo"],
|
||||
"conditions": {
|
||||
"equal": "bar"
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"field": ["type"],
|
||||
"conditions": {
|
||||
"equal": "demo"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**SQL:**
|
||||
```sql
|
||||
metadata.foo = 'bar' AND type = 'demo'
|
||||
```
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
1. **Whitespace**: Whitespace is generally ignored except within string literals
|
||||
2. **Case sensitivity**:
|
||||
- Operators (`AND`, `OR`, `LIKE`, etc.) are case-insensitive
|
||||
- Field names and string values are case-sensitive
|
||||
3. **Comments**: Not supported in initial version (can be added later)
|
||||
4. **Table prefixes**: The parser may support optional table name prefixes (e.g., `documents.metadata.foo`) if needed
|
||||
33
packages/query-dsl/package.json
Normal file
33
packages/query-dsl/package.json
Normal file
@@ -0,0 +1,33 @@
|
||||
{
|
||||
"type": "module",
|
||||
"main": "dist/exports.js",
|
||||
"scripts": {
|
||||
"build": "tsc --build",
|
||||
"test:unit": "vitest --run --passWithNoTests",
|
||||
"test": "pnpm run \"/^test:/\""
|
||||
},
|
||||
"packageManager": "pnpm@10.6.0",
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
"exports": {
|
||||
".": "./dist/exports.js"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@morten-olsen/stash-configs": "workspace:*",
|
||||
"@morten-olsen/stash-tests": "workspace:*",
|
||||
"@types/node": "24.10.2",
|
||||
"@vitest/coverage-v8": "4.0.15",
|
||||
"typescript": "5.9.3",
|
||||
"vitest": "4.0.15"
|
||||
},
|
||||
"name": "@morten-olsen/stash-query-dsl",
|
||||
"version": "1.0.0",
|
||||
"imports": {
|
||||
"#root/*": "./src/*"
|
||||
},
|
||||
"dependencies": {
|
||||
"chevrotain": "^11.0.3",
|
||||
"zod": "4.1.13"
|
||||
}
|
||||
}
|
||||
2
packages/query-dsl/src/exports.ts
Normal file
2
packages/query-dsl/src/exports.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from './query-parser.schemas.js';
|
||||
export { QueryParser } from './query-parser.js';
|
||||
457
packages/query-dsl/src/query-parser.parser.ts
Normal file
457
packages/query-dsl/src/query-parser.parser.ts
Normal file
@@ -0,0 +1,457 @@
|
||||
import { createToken, Lexer, EmbeddedActionsParser } from 'chevrotain';
|
||||
|
||||
import type { QueryFilter, QueryCondition } from './query-parser.schemas.js';
|
||||
|
||||
// ----------------- Lexer -----------------
|
||||
|
||||
// Whitespace (skipped)
|
||||
const WhiteSpace = createToken({ name: 'WhiteSpace', pattern: /\s+/, group: Lexer.SKIPPED });
|
||||
|
||||
// Keywords (must be defined before Identifier to take precedence)
|
||||
const And = createToken({ name: 'And', pattern: /AND/i, longer_alt: undefined });
|
||||
const Or = createToken({ name: 'Or', pattern: /OR/i, longer_alt: undefined });
|
||||
const Like = createToken({ name: 'Like', pattern: /LIKE/i, longer_alt: undefined });
|
||||
const Not = createToken({ name: 'Not', pattern: /NOT/i, longer_alt: undefined });
|
||||
const In = createToken({ name: 'In', pattern: /IN/i, longer_alt: undefined });
|
||||
const Is = createToken({ name: 'Is', pattern: /IS/i, longer_alt: undefined });
|
||||
const Null = createToken({ name: 'Null', pattern: /NULL/i, longer_alt: undefined });
|
||||
|
||||
// Identifier (must come after keywords)
|
||||
const Identifier = createToken({ name: 'Identifier', pattern: /[a-zA-Z_][a-zA-Z0-9_]*/ });
|
||||
|
||||
// Set longer_alt for keywords to handle cases like "ANDROID" not matching "AND"
|
||||
And.LONGER_ALT = Identifier;
|
||||
Or.LONGER_ALT = Identifier;
|
||||
Like.LONGER_ALT = Identifier;
|
||||
Not.LONGER_ALT = Identifier;
|
||||
In.LONGER_ALT = Identifier;
|
||||
Is.LONGER_ALT = Identifier;
|
||||
Null.LONGER_ALT = Identifier;
|
||||
|
||||
// Literals
|
||||
const StringLiteral = createToken({
|
||||
name: 'StringLiteral',
|
||||
pattern: /'(?:''|[^'])*'/,
|
||||
});
|
||||
|
||||
const NumberLiteral = createToken({
|
||||
name: 'NumberLiteral',
|
||||
pattern: /-?(?:0|[1-9]\d*)(?:\.\d+)?(?:[eE][+-]?\d+)?/,
|
||||
});
|
||||
|
||||
// Operators
|
||||
const NotEquals = createToken({ name: 'NotEquals', pattern: /!=/ });
|
||||
const GreaterThanOrEqual = createToken({ name: 'GreaterThanOrEqual', pattern: />=/ });
|
||||
const LessThanOrEqual = createToken({ name: 'LessThanOrEqual', pattern: /<=/ });
|
||||
const Equals = createToken({ name: 'Equals', pattern: /=/ });
|
||||
const GreaterThan = createToken({ name: 'GreaterThan', pattern: />/ });
|
||||
const LessThan = createToken({ name: 'LessThan', pattern: /</ });
|
||||
|
||||
// Punctuation
|
||||
const LParen = createToken({ name: 'LParen', pattern: /\(/ });
|
||||
const RParen = createToken({ name: 'RParen', pattern: /\)/ });
|
||||
const Comma = createToken({ name: 'Comma', pattern: /,/ });
|
||||
const Dot = createToken({ name: 'Dot', pattern: /\./ });
|
||||
|
||||
// Token order matters! More specific patterns first.
|
||||
const allTokens = [
|
||||
WhiteSpace,
|
||||
// Multi-char operators first
|
||||
NotEquals,
|
||||
GreaterThanOrEqual,
|
||||
LessThanOrEqual,
|
||||
// Single-char operators
|
||||
Equals,
|
||||
GreaterThan,
|
||||
LessThan,
|
||||
// Punctuation
|
||||
LParen,
|
||||
RParen,
|
||||
Comma,
|
||||
Dot,
|
||||
// Keywords (before Identifier)
|
||||
And,
|
||||
Or,
|
||||
Like,
|
||||
Not,
|
||||
In,
|
||||
Is,
|
||||
Null,
|
||||
// Literals
|
||||
StringLiteral,
|
||||
NumberLiteral,
|
||||
// Identifier last
|
||||
Identifier,
|
||||
];
|
||||
|
||||
const QueryLexer = new Lexer(allTokens);
|
||||
|
||||
// ----------------- Parser -----------------
|
||||
|
||||
class QueryParserParser extends EmbeddedActionsParser {
|
||||
constructor() {
|
||||
super(allTokens);
|
||||
this.performSelfAnalysis();
|
||||
}
|
||||
|
||||
// OR has lowest precedence
|
||||
#orExpression = this.RULE('orExpression', (): QueryFilter => {
|
||||
let left = this.SUBRULE(this.#andExpression);
|
||||
|
||||
this.MANY(() => {
|
||||
this.CONSUME(Or);
|
||||
const right = this.SUBRULE2(this.#andExpression);
|
||||
left = this.ACTION(() => this.#combineWithOperator(left, right, 'or'));
|
||||
});
|
||||
|
||||
return left;
|
||||
});
|
||||
|
||||
// AND has higher precedence than OR
|
||||
#andExpression = this.RULE('andExpression', (): QueryFilter => {
|
||||
let left = this.SUBRULE(this.#primaryExpression);
|
||||
|
||||
this.MANY(() => {
|
||||
this.CONSUME(And);
|
||||
const right = this.SUBRULE2(this.#primaryExpression);
|
||||
left = this.ACTION(() => this.#combineWithOperator(left, right, 'and'));
|
||||
});
|
||||
|
||||
return left;
|
||||
});
|
||||
|
||||
// Primary: parenthesized expression or condition
|
||||
#primaryExpression = this.RULE('primaryExpression', (): QueryFilter => {
|
||||
return this.OR([
|
||||
{
|
||||
ALT: () => {
|
||||
this.CONSUME(LParen);
|
||||
const expr = this.SUBRULE(this.#orExpression);
|
||||
this.CONSUME(RParen);
|
||||
return expr;
|
||||
},
|
||||
},
|
||||
{ ALT: () => this.SUBRULE(this.#condition) },
|
||||
]);
|
||||
});
|
||||
|
||||
// Condition: field followed by operator and value(s)
|
||||
#condition = this.RULE('condition', (): QueryCondition => {
|
||||
const field = this.SUBRULE(this.#fieldReference);
|
||||
|
||||
return this.OR([
|
||||
// IS NULL / IS NOT NULL
|
||||
{
|
||||
ALT: () => {
|
||||
this.CONSUME(Is);
|
||||
const isNot = this.OPTION(() => this.CONSUME(Not)) !== undefined;
|
||||
this.CONSUME(Null);
|
||||
|
||||
return this.ACTION(() => ({
|
||||
type: 'text' as const,
|
||||
field,
|
||||
conditions: isNot ? { notEqual: undefined, equal: undefined } : { equal: null },
|
||||
}));
|
||||
},
|
||||
},
|
||||
// NOT IN (strings) - LA(1)=NOT, LA(2)=IN, LA(3)=(, LA(4)=value
|
||||
{
|
||||
GATE: () => this.LA(4).tokenType === StringLiteral,
|
||||
ALT: () => {
|
||||
this.CONSUME2(Not);
|
||||
this.CONSUME(In);
|
||||
const values = this.SUBRULE(this.#stringInList);
|
||||
return this.ACTION(() => ({
|
||||
type: 'text' as const,
|
||||
field,
|
||||
conditions: { notIn: values },
|
||||
}));
|
||||
},
|
||||
},
|
||||
// NOT IN (numbers)
|
||||
{
|
||||
GATE: () => this.LA(4).tokenType === NumberLiteral,
|
||||
ALT: () => {
|
||||
this.CONSUME3(Not);
|
||||
this.CONSUME2(In);
|
||||
const values = this.SUBRULE(this.#numberInList);
|
||||
return this.ACTION(() => ({
|
||||
type: 'number' as const,
|
||||
field,
|
||||
conditions: { notIn: values },
|
||||
}));
|
||||
},
|
||||
},
|
||||
// NOT LIKE
|
||||
{
|
||||
ALT: () => {
|
||||
this.CONSUME4(Not);
|
||||
this.CONSUME(Like);
|
||||
const pattern = this.CONSUME(StringLiteral);
|
||||
return this.ACTION(() => ({
|
||||
type: 'text' as const,
|
||||
field,
|
||||
conditions: { notLike: this.#extractStringValue(pattern.image) },
|
||||
}));
|
||||
},
|
||||
},
|
||||
// IN (strings) - LA(1)=IN, LA(2)=(, LA(3)=value
|
||||
{
|
||||
GATE: () => this.LA(3).tokenType === StringLiteral,
|
||||
ALT: () => {
|
||||
this.CONSUME3(In);
|
||||
const values = this.SUBRULE2(this.#stringInList);
|
||||
return this.ACTION(() => ({
|
||||
type: 'text' as const,
|
||||
field,
|
||||
conditions: { in: values },
|
||||
}));
|
||||
},
|
||||
},
|
||||
// IN (numbers)
|
||||
{
|
||||
GATE: () => this.LA(3).tokenType === NumberLiteral,
|
||||
ALT: () => {
|
||||
this.CONSUME4(In);
|
||||
const values = this.SUBRULE2(this.#numberInList);
|
||||
return this.ACTION(() => ({
|
||||
type: 'number' as const,
|
||||
field,
|
||||
conditions: { in: values },
|
||||
}));
|
||||
},
|
||||
},
|
||||
// LIKE
|
||||
{
|
||||
ALT: () => {
|
||||
this.CONSUME2(Like);
|
||||
const pattern = this.CONSUME2(StringLiteral);
|
||||
return this.ACTION(() => ({
|
||||
type: 'text' as const,
|
||||
field,
|
||||
conditions: { like: this.#extractStringValue(pattern.image) },
|
||||
}));
|
||||
},
|
||||
},
|
||||
// = string
|
||||
{
|
||||
GATE: () => this.LA(2).tokenType === StringLiteral,
|
||||
ALT: () => {
|
||||
this.CONSUME(Equals);
|
||||
const token = this.CONSUME3(StringLiteral);
|
||||
return this.ACTION(() => ({
|
||||
type: 'text' as const,
|
||||
field,
|
||||
conditions: { equal: this.#extractStringValue(token.image) },
|
||||
}));
|
||||
},
|
||||
},
|
||||
// = number
|
||||
{
|
||||
GATE: () => this.LA(2).tokenType === NumberLiteral,
|
||||
ALT: () => {
|
||||
this.CONSUME2(Equals);
|
||||
const token = this.CONSUME(NumberLiteral);
|
||||
return this.ACTION(() => ({
|
||||
type: 'number' as const,
|
||||
field,
|
||||
conditions: { equals: parseFloat(token.image) },
|
||||
}));
|
||||
},
|
||||
},
|
||||
// = NULL
|
||||
{
|
||||
ALT: () => {
|
||||
this.CONSUME3(Equals);
|
||||
this.CONSUME2(Null);
|
||||
return this.ACTION(() => ({
|
||||
type: 'text' as const,
|
||||
field,
|
||||
conditions: { equal: null },
|
||||
}));
|
||||
},
|
||||
},
|
||||
// != string
|
||||
{
|
||||
GATE: () => this.LA(2).tokenType === StringLiteral,
|
||||
ALT: () => {
|
||||
this.CONSUME(NotEquals);
|
||||
const token = this.CONSUME4(StringLiteral);
|
||||
return this.ACTION(() => ({
|
||||
type: 'text' as const,
|
||||
field,
|
||||
conditions: { notEqual: this.#extractStringValue(token.image) },
|
||||
}));
|
||||
},
|
||||
},
|
||||
// != number
|
||||
{
|
||||
ALT: () => {
|
||||
this.CONSUME2(NotEquals);
|
||||
const token = this.CONSUME2(NumberLiteral);
|
||||
return this.ACTION(() => ({
|
||||
type: 'number' as const,
|
||||
field,
|
||||
conditions: { notEquals: parseFloat(token.image) },
|
||||
}));
|
||||
},
|
||||
},
|
||||
// > number
|
||||
{
|
||||
ALT: () => {
|
||||
this.CONSUME(GreaterThan);
|
||||
const token = this.CONSUME3(NumberLiteral);
|
||||
return this.ACTION(() => ({
|
||||
type: 'number' as const,
|
||||
field,
|
||||
conditions: { greaterThan: parseFloat(token.image) },
|
||||
}));
|
||||
},
|
||||
},
|
||||
// >= number
|
||||
{
|
||||
ALT: () => {
|
||||
this.CONSUME(GreaterThanOrEqual);
|
||||
const token = this.CONSUME4(NumberLiteral);
|
||||
return this.ACTION(() => ({
|
||||
type: 'number' as const,
|
||||
field,
|
||||
conditions: { greaterThanOrEqual: parseFloat(token.image) },
|
||||
}));
|
||||
},
|
||||
},
|
||||
// < number
|
||||
{
|
||||
ALT: () => {
|
||||
this.CONSUME(LessThan);
|
||||
const token = this.CONSUME5(NumberLiteral);
|
||||
return this.ACTION(() => ({
|
||||
type: 'number' as const,
|
||||
field,
|
||||
conditions: { lessThan: parseFloat(token.image) },
|
||||
}));
|
||||
},
|
||||
},
|
||||
// <= number
|
||||
{
|
||||
ALT: () => {
|
||||
this.CONSUME(LessThanOrEqual);
|
||||
const token = this.CONSUME6(NumberLiteral);
|
||||
return this.ACTION(() => ({
|
||||
type: 'number' as const,
|
||||
field,
|
||||
conditions: { lessThanOrEqual: parseFloat(token.image) },
|
||||
}));
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
// Field reference: identifier.identifier.identifier...
|
||||
#fieldReference = this.RULE('fieldReference', (): string[] => {
|
||||
const parts: string[] = [];
|
||||
const first = this.CONSUME(Identifier);
|
||||
this.ACTION(() => parts.push(first.image));
|
||||
|
||||
this.MANY(() => {
|
||||
this.CONSUME(Dot);
|
||||
const next = this.CONSUME2(Identifier);
|
||||
this.ACTION(() => parts.push(next.image));
|
||||
});
|
||||
|
||||
return parts;
|
||||
});
|
||||
|
||||
// String IN list: ('val1', 'val2', ...)
|
||||
#stringInList = this.RULE('stringInList', (): string[] => {
|
||||
const values: string[] = [];
|
||||
|
||||
this.CONSUME(LParen);
|
||||
const first = this.CONSUME(StringLiteral);
|
||||
this.ACTION(() => values.push(this.#extractStringValue(first.image)));
|
||||
|
||||
this.MANY(() => {
|
||||
this.CONSUME(Comma);
|
||||
const next = this.CONSUME2(StringLiteral);
|
||||
this.ACTION(() => values.push(this.#extractStringValue(next.image)));
|
||||
});
|
||||
|
||||
this.CONSUME(RParen);
|
||||
return values;
|
||||
});
|
||||
|
||||
// Number IN list: (1, 2, 3, ...)
|
||||
#numberInList = this.RULE('numberInList', (): number[] => {
|
||||
const values: number[] = [];
|
||||
|
||||
this.CONSUME2(LParen);
|
||||
const first = this.CONSUME(NumberLiteral);
|
||||
this.ACTION(() => values.push(parseFloat(first.image)));
|
||||
|
||||
this.MANY(() => {
|
||||
this.CONSUME2(Comma);
|
||||
const next = this.CONSUME2(NumberLiteral);
|
||||
this.ACTION(() => values.push(parseFloat(next.image)));
|
||||
});
|
||||
|
||||
this.CONSUME2(RParen);
|
||||
return values;
|
||||
});
|
||||
|
||||
// Extract string value from quoted literal, handling escaped quotes
|
||||
#extractStringValue(image: string): string {
|
||||
// Remove surrounding quotes and unescape doubled quotes
|
||||
return image.slice(1, -1).replace(/''/g, "'");
|
||||
}
|
||||
|
||||
// Combine two filters with an operator, flattening if possible
|
||||
#combineWithOperator(left: QueryFilter, right: QueryFilter, operator: 'and' | 'or'): QueryFilter {
|
||||
if (left.type === 'operator' && left.operator === operator) {
|
||||
return {
|
||||
type: 'operator',
|
||||
operator,
|
||||
conditions: [...left.conditions, right],
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
type: 'operator',
|
||||
operator,
|
||||
conditions: [left, right],
|
||||
};
|
||||
}
|
||||
|
||||
// Entry point
|
||||
#query = this.RULE('query', (): QueryFilter => {
|
||||
return this.SUBRULE(this.#orExpression);
|
||||
});
|
||||
|
||||
public parse = (input: string): QueryFilter => {
|
||||
const lexResult = QueryLexer.tokenize(input);
|
||||
|
||||
if (lexResult.errors.length > 0) {
|
||||
const error = lexResult.errors[0];
|
||||
// Check if this looks like an unterminated string (starts with ' but lexer failed)
|
||||
if (error.message.includes("'") || input.slice(error.offset).startsWith("'")) {
|
||||
// Count unescaped single quotes
|
||||
const unescapedQuotes = input.replace(/''/g, '').match(/'/g);
|
||||
if (unescapedQuotes && unescapedQuotes.length % 2 !== 0) {
|
||||
throw new Error(`Unterminated string starting at position ${error.offset}`);
|
||||
}
|
||||
}
|
||||
throw new Error(`Lexer error at position ${error.offset}: ${error.message}`);
|
||||
}
|
||||
|
||||
this.input = lexResult.tokens;
|
||||
const result = this.#query();
|
||||
|
||||
if (this.errors.length > 0) {
|
||||
const error = this.errors[0];
|
||||
throw new Error(`Parse error: ${error.message}`);
|
||||
}
|
||||
|
||||
return result;
|
||||
};
|
||||
}
|
||||
|
||||
export { QueryParserParser, QueryLexer };
|
||||
85
packages/query-dsl/src/query-parser.schemas.ts
Normal file
85
packages/query-dsl/src/query-parser.schemas.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
const queryConditionTextSchema = z.object({
|
||||
type: z.literal('text'),
|
||||
tableName: z.string().optional(),
|
||||
field: z.array(z.string()),
|
||||
conditions: z.object({
|
||||
equal: z.string().nullish(),
|
||||
notEqual: z.string().optional(),
|
||||
like: z.string().optional(),
|
||||
notLike: z.string().optional(),
|
||||
in: z.array(z.string()).optional(),
|
||||
notIn: z.array(z.string()).optional(),
|
||||
}),
|
||||
});
|
||||
|
||||
type QueryConditionText = z.infer<typeof queryConditionTextSchema>;
|
||||
|
||||
const queryConditionNumberSchema = z.object({
|
||||
type: z.literal('number'),
|
||||
tableName: z.string().optional(),
|
||||
field: z.array(z.string()),
|
||||
conditions: z.object({
|
||||
equals: z.number().nullish(),
|
||||
notEquals: z.number().nullish(),
|
||||
greaterThan: z.number().optional(),
|
||||
greaterThanOrEqual: z.number().optional(),
|
||||
lessThan: z.number().optional(),
|
||||
lessThanOrEqual: z.number().optional(),
|
||||
in: z.array(z.number()).optional(),
|
||||
notIn: z.array(z.number()).optional(),
|
||||
}),
|
||||
});
|
||||
|
||||
type QueryConditionNumber = z.infer<typeof queryConditionNumberSchema>;
|
||||
|
||||
const queryConditionSchema = z.discriminatedUnion('type', [queryConditionTextSchema, queryConditionNumberSchema]);
|
||||
|
||||
type QueryCondition = z.infer<typeof queryConditionSchema>;
|
||||
|
||||
type QueryFilter = QueryCondition | QueryOperator;
|
||||
|
||||
type QueryOperator = {
|
||||
type: 'operator';
|
||||
operator: 'and' | 'or';
|
||||
conditions: QueryFilter[];
|
||||
};
|
||||
|
||||
// Create a depth-limited recursive schema for OpenAPI compatibility
|
||||
// This supports up to 3 levels of nesting, which should be sufficient for most use cases
|
||||
// OpenAPI cannot handle z.lazy(), so we manually define the nesting
|
||||
// If you need deeper nesting, you can add more levels (Level3, Level4, etc.)
|
||||
const queryFilterSchemaLevel0: z.ZodType<QueryFilter> = z.union([
|
||||
queryConditionSchema,
|
||||
z.object({
|
||||
type: z.literal('operator'),
|
||||
operator: z.enum(['and', 'or']),
|
||||
conditions: z.array(queryConditionSchema),
|
||||
}),
|
||||
]);
|
||||
|
||||
const queryFilterSchemaLevel1: z.ZodType<QueryFilter> = z.union([
|
||||
queryConditionSchema,
|
||||
z.object({
|
||||
type: z.literal('operator'),
|
||||
operator: z.enum(['and', 'or']),
|
||||
conditions: z.array(queryFilterSchemaLevel0),
|
||||
}),
|
||||
]);
|
||||
|
||||
const queryFilterSchemaLevel2: z.ZodType<QueryFilter> = z.union([
|
||||
queryConditionSchema,
|
||||
z.object({
|
||||
type: z.literal('operator'),
|
||||
operator: z.enum(['and', 'or']),
|
||||
conditions: z.array(queryFilterSchemaLevel1),
|
||||
}),
|
||||
]);
|
||||
|
||||
// Export the depth-limited schema (supports 3 levels of nesting)
|
||||
// This works with OpenAPI schema generation
|
||||
const queryFilterSchema = queryFilterSchemaLevel2;
|
||||
|
||||
export type { QueryConditionText, QueryConditionNumber, QueryOperator, QueryCondition, QueryFilter };
|
||||
export { queryConditionSchema, queryFilterSchema };
|
||||
135
packages/query-dsl/src/query-parser.stringifier.ts
Normal file
135
packages/query-dsl/src/query-parser.stringifier.ts
Normal file
@@ -0,0 +1,135 @@
|
||||
import type {
|
||||
QueryFilter,
|
||||
QueryOperator,
|
||||
QueryCondition,
|
||||
QueryConditionText,
|
||||
QueryConditionNumber,
|
||||
} from './query-parser.schemas.js';
|
||||
|
||||
class Stringifier {
|
||||
#stringifyFilter = (filter: QueryFilter, needsParens: boolean): string => {
|
||||
if (filter.type === 'operator') {
|
||||
return this.#stringifyOperator(filter, needsParens);
|
||||
}
|
||||
return this.#stringifyCondition(filter);
|
||||
};
|
||||
|
||||
#stringifyOperator = (op: QueryOperator, needsParens: boolean): string => {
|
||||
if (op.conditions.length === 0) {
|
||||
return '';
|
||||
}
|
||||
|
||||
if (op.conditions.length === 1) {
|
||||
return this.#stringifyFilter(op.conditions[0], needsParens);
|
||||
}
|
||||
|
||||
const operator = op.operator.toUpperCase();
|
||||
const parts = op.conditions.map((condition) => {
|
||||
// Child operators need parens if they have a different operator
|
||||
const childNeedsParens = condition.type === 'operator' && condition.operator !== op.operator;
|
||||
return this.#stringifyFilter(condition, childNeedsParens);
|
||||
});
|
||||
|
||||
const result = parts.join(` ${operator} `);
|
||||
|
||||
return needsParens ? `(${result})` : result;
|
||||
};
|
||||
|
||||
#stringifyCondition = (condition: QueryCondition): string => {
|
||||
const fieldStr = condition.field.join('.');
|
||||
|
||||
if (condition.type === 'text') {
|
||||
return this.#stringifyTextCondition(fieldStr, condition.conditions);
|
||||
}
|
||||
|
||||
return this.#stringifyNumberCondition(fieldStr, condition.conditions);
|
||||
};
|
||||
|
||||
#stringifyTextCondition = (field: string, conditions: QueryConditionText['conditions']): string => {
|
||||
if (conditions.equal !== undefined) {
|
||||
if (conditions.equal === null) {
|
||||
return `${field} IS NULL`;
|
||||
}
|
||||
return `${field} = ${this.#escapeString(conditions.equal)}`;
|
||||
}
|
||||
|
||||
if (conditions.notEqual !== undefined) {
|
||||
return `${field} != ${this.#escapeString(conditions.notEqual)}`;
|
||||
}
|
||||
|
||||
if (conditions.like !== undefined) {
|
||||
return `${field} LIKE ${this.#escapeString(conditions.like)}`;
|
||||
}
|
||||
|
||||
if (conditions.notLike !== undefined) {
|
||||
return `${field} NOT LIKE ${this.#escapeString(conditions.notLike)}`;
|
||||
}
|
||||
|
||||
if (conditions.in !== undefined) {
|
||||
const values = conditions.in.map((v) => this.#escapeString(v)).join(', ');
|
||||
return `${field} IN (${values})`;
|
||||
}
|
||||
|
||||
if (conditions.notIn !== undefined) {
|
||||
const values = conditions.notIn.map((v) => this.#escapeString(v)).join(', ');
|
||||
return `${field} NOT IN (${values})`;
|
||||
}
|
||||
|
||||
throw new Error('Invalid text condition: no condition specified');
|
||||
};
|
||||
|
||||
#stringifyNumberCondition = (field: string, conditions: QueryConditionNumber['conditions']): string => {
|
||||
if (conditions.equals !== undefined) {
|
||||
if (conditions.equals === null) {
|
||||
return `${field} IS NULL`;
|
||||
}
|
||||
return `${field} = ${conditions.equals}`;
|
||||
}
|
||||
|
||||
if (conditions.notEquals !== undefined) {
|
||||
if (conditions.notEquals === null) {
|
||||
return `${field} IS NOT NULL`;
|
||||
}
|
||||
return `${field} != ${conditions.notEquals}`;
|
||||
}
|
||||
|
||||
if (conditions.greaterThan !== undefined) {
|
||||
return `${field} > ${conditions.greaterThan}`;
|
||||
}
|
||||
|
||||
if (conditions.greaterThanOrEqual !== undefined) {
|
||||
return `${field} >= ${conditions.greaterThanOrEqual}`;
|
||||
}
|
||||
|
||||
if (conditions.lessThan !== undefined) {
|
||||
return `${field} < ${conditions.lessThan}`;
|
||||
}
|
||||
|
||||
if (conditions.lessThanOrEqual !== undefined) {
|
||||
return `${field} <= ${conditions.lessThanOrEqual}`;
|
||||
}
|
||||
|
||||
if (conditions.in !== undefined) {
|
||||
const values = conditions.in.join(', ');
|
||||
return `${field} IN (${values})`;
|
||||
}
|
||||
|
||||
if (conditions.notIn !== undefined) {
|
||||
const values = conditions.notIn.join(', ');
|
||||
return `${field} NOT IN (${values})`;
|
||||
}
|
||||
|
||||
throw new Error('Invalid number condition: no condition specified');
|
||||
};
|
||||
|
||||
#escapeString = (value: string): string => {
|
||||
const escaped = value.replace(/'/g, "''");
|
||||
return `'${escaped}'`;
|
||||
};
|
||||
|
||||
public stringify = (filter: QueryFilter): string => {
|
||||
return this.#stringifyFilter(filter, false);
|
||||
};
|
||||
}
|
||||
|
||||
export { Stringifier };
|
||||
753
packages/query-dsl/src/query-parser.test.ts
Normal file
753
packages/query-dsl/src/query-parser.test.ts
Normal file
@@ -0,0 +1,753 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
});
|
||||
18
packages/query-dsl/src/query-parser.ts
Normal file
18
packages/query-dsl/src/query-parser.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { Stringifier } from './query-parser.stringifier.js';
|
||||
import { QueryParserParser } from './query-parser.parser.js';
|
||||
import type { QueryFilter } from './query-parser.schemas.js';
|
||||
|
||||
class QueryParser {
|
||||
#stringifier = new Stringifier();
|
||||
#parser = new QueryParserParser();
|
||||
|
||||
public parse = (input: string): QueryFilter => {
|
||||
return this.#parser.parse(input);
|
||||
};
|
||||
|
||||
public stringify = (filter: QueryFilter): string => {
|
||||
return this.#stringifier.stringify(filter);
|
||||
};
|
||||
}
|
||||
|
||||
export { QueryParser };
|
||||
10
packages/query-dsl/tsconfig.json
Normal file
10
packages/query-dsl/tsconfig.json
Normal file
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src"
|
||||
},
|
||||
"include": [
|
||||
"src/**/*.ts"
|
||||
],
|
||||
"extends": "@morten-olsen/stash-configs/tsconfig.json"
|
||||
}
|
||||
12
packages/query-dsl/vitest.config.ts
Normal file
12
packages/query-dsl/vitest.config.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { defineConfig } from 'vitest/config';
|
||||
import { getAliases } from '@morten-olsen/stash-tests/vitest';
|
||||
|
||||
// eslint-disable-next-line import/no-default-export
|
||||
export default defineConfig(async () => {
|
||||
const aliases = await getAliases();
|
||||
return {
|
||||
resolve: {
|
||||
alias: aliases,
|
||||
},
|
||||
};
|
||||
});
|
||||
Reference in New Issue
Block a user