update
This commit is contained in:
@@ -28,6 +28,7 @@
|
||||
"name": "@morten-olsen/fluxcurrent-core",
|
||||
"version": "1.0.0",
|
||||
"dependencies": {
|
||||
"eventemitter3": "^5.0.1",
|
||||
"knex": "^3.1.0",
|
||||
"knex-pglite": "^0.12.0",
|
||||
"pg": "^8.16.3",
|
||||
|
||||
@@ -25,6 +25,17 @@ class DatabaseService {
|
||||
}
|
||||
return this.#dbPromise;
|
||||
};
|
||||
|
||||
public init = async () => {
|
||||
await this.getDb();
|
||||
};
|
||||
|
||||
public close = async () => {
|
||||
if (this.#dbPromise) {
|
||||
await this.#dbPromise;
|
||||
this.#dbPromise = undefined;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export { tableNames, type TableRow } from './migrations/migrations.ts';
|
||||
|
||||
414
packages/core/src/services/documents/documents.dsl.test.ts
Normal file
414
packages/core/src/services/documents/documents.dsl.test.ts
Normal file
@@ -0,0 +1,414 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
|
||||
import { parseDSL } from './documents.dsl.ts';
|
||||
|
||||
describe('DSL Parser', () => {
|
||||
describe('Basic field filtering', () => {
|
||||
it('should parse simple URI equality', () => {
|
||||
const result = parseDSL('uri = "test-doc"');
|
||||
expect(result).toEqual({
|
||||
uris: ['test-doc'],
|
||||
});
|
||||
});
|
||||
|
||||
it('should parse URI in array', () => {
|
||||
const result = parseDSL('uri in ["doc1", "doc2", "doc3"]');
|
||||
expect(result).toEqual({
|
||||
uris: ['doc1', 'doc2', 'doc3'],
|
||||
});
|
||||
});
|
||||
|
||||
it('should parse simple type equality', () => {
|
||||
const result = parseDSL('type = "article"');
|
||||
expect(result).toEqual({
|
||||
types: ['article'],
|
||||
});
|
||||
});
|
||||
|
||||
it('should parse type in array', () => {
|
||||
const result = parseDSL('type in ["article", "blog", "news"]');
|
||||
expect(result).toEqual({
|
||||
types: ['article', 'blog', 'news'],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Meta field filtering', () => {
|
||||
it('should parse number equality', () => {
|
||||
const result = parseDSL('meta.priority = 5');
|
||||
expect(result).toEqual({
|
||||
meta: {
|
||||
type: 'number',
|
||||
field: 'priority',
|
||||
filter: { eq: 5 },
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should parse number comparisons', () => {
|
||||
const testCases = [
|
||||
{ query: 'meta.priority > 3', expected: { gt: 3 } },
|
||||
{ query: 'meta.priority >= 3', expected: { gte: 3 } },
|
||||
{ query: 'meta.priority < 10', expected: { lt: 10 } },
|
||||
{ query: 'meta.priority <= 10', expected: { lte: 10 } },
|
||||
{ query: 'meta.priority != 5', expected: { neq: 5 } },
|
||||
];
|
||||
|
||||
testCases.forEach(({ query, expected }) => {
|
||||
const result = parseDSL(query);
|
||||
expect(result).toEqual({
|
||||
meta: {
|
||||
type: 'number',
|
||||
field: 'priority',
|
||||
filter: expected,
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('should parse text equality and inequality', () => {
|
||||
const result = parseDSL('meta.title = "Test Title"');
|
||||
expect(result).toEqual({
|
||||
meta: {
|
||||
type: 'text',
|
||||
field: 'title',
|
||||
filter: { eq: 'Test Title' },
|
||||
},
|
||||
});
|
||||
|
||||
const result2 = parseDSL('meta.category != "archived"');
|
||||
expect(result2).toEqual({
|
||||
meta: {
|
||||
type: 'text',
|
||||
field: 'category',
|
||||
filter: { neq: 'archived' },
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should parse LIKE patterns', () => {
|
||||
const result = parseDSL('meta.title like "%Test%"');
|
||||
expect(result).toEqual({
|
||||
meta: {
|
||||
type: 'text',
|
||||
field: 'title',
|
||||
filter: { like: '%Test%' },
|
||||
},
|
||||
});
|
||||
|
||||
const result2 = parseDSL('meta.title not like "%Draft%"');
|
||||
expect(result2).toEqual({
|
||||
meta: {
|
||||
type: 'text',
|
||||
field: 'title',
|
||||
filter: { nlike: '%Draft%' },
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should parse boolean values', () => {
|
||||
const result = parseDSL('meta.published = true');
|
||||
expect(result).toEqual({
|
||||
meta: {
|
||||
type: 'bool',
|
||||
field: 'published',
|
||||
filter: { eq: true },
|
||||
},
|
||||
});
|
||||
|
||||
const result2 = parseDSL('meta.archived = false');
|
||||
expect(result2).toEqual({
|
||||
meta: {
|
||||
type: 'bool',
|
||||
field: 'archived',
|
||||
filter: { eq: false },
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Logical operations', () => {
|
||||
it('should parse AND conditions with "and" keyword', () => {
|
||||
const result = parseDSL('meta.priority = 5 and meta.published = true');
|
||||
expect(result).toEqual({
|
||||
meta: {
|
||||
type: 'and',
|
||||
conditions: [
|
||||
{
|
||||
type: 'number',
|
||||
field: 'priority',
|
||||
filter: { eq: 5 },
|
||||
},
|
||||
{
|
||||
type: 'bool',
|
||||
field: 'published',
|
||||
filter: { eq: true },
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should parse AND conditions with "&" operator', () => {
|
||||
const result = parseDSL('meta.priority >= 5 & meta.priority < 10');
|
||||
expect(result).toEqual({
|
||||
meta: {
|
||||
type: 'and',
|
||||
conditions: [
|
||||
{
|
||||
type: 'number',
|
||||
field: 'priority',
|
||||
filter: { gte: 5 },
|
||||
},
|
||||
{
|
||||
type: 'number',
|
||||
field: 'priority',
|
||||
filter: { lt: 10 },
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should parse OR conditions with "or" keyword', () => {
|
||||
const result = parseDSL('type = "article" or type = "blog"');
|
||||
expect(result).toEqual({
|
||||
types: ['article', 'blog'],
|
||||
});
|
||||
});
|
||||
|
||||
it('should parse OR conditions with "|" operator', () => {
|
||||
const result = parseDSL('meta.priority > 8 | meta.urgent = true');
|
||||
expect(result).toEqual({
|
||||
meta: {
|
||||
type: 'or',
|
||||
conditions: [
|
||||
{
|
||||
type: 'number',
|
||||
field: 'priority',
|
||||
filter: { gt: 8 },
|
||||
},
|
||||
{
|
||||
type: 'bool',
|
||||
field: 'urgent',
|
||||
filter: { eq: true },
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Complex expressions', () => {
|
||||
it('should parse mixed field types with logical operations', () => {
|
||||
const result = parseDSL('uri in ["doc1", "doc2"] and meta.priority >= 5');
|
||||
expect(result).toEqual({
|
||||
uris: ['doc1', 'doc2'],
|
||||
meta: {
|
||||
type: 'number',
|
||||
field: 'priority',
|
||||
filter: { gte: 5 },
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should parse parentheses grouping', () => {
|
||||
const result = parseDSL('(meta.priority > 5 or meta.urgent = true) and type = "article"');
|
||||
expect(result).toEqual({
|
||||
types: ['article'],
|
||||
meta: {
|
||||
type: 'or',
|
||||
conditions: [
|
||||
{
|
||||
type: 'number',
|
||||
field: 'priority',
|
||||
filter: { gt: 5 },
|
||||
},
|
||||
{
|
||||
type: 'bool',
|
||||
field: 'urgent',
|
||||
filter: { eq: true },
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should parse nested parentheses', () => {
|
||||
const result = parseDSL('((meta.a = 1 and meta.b = 2) or meta.c = 3) and type = "test"');
|
||||
expect(result).toEqual({
|
||||
types: ['test'],
|
||||
meta: {
|
||||
type: 'or',
|
||||
conditions: [
|
||||
{
|
||||
type: 'and',
|
||||
conditions: [
|
||||
{
|
||||
type: 'number',
|
||||
field: 'a',
|
||||
filter: { eq: 1 },
|
||||
},
|
||||
{
|
||||
type: 'number',
|
||||
field: 'b',
|
||||
filter: { eq: 2 },
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
type: 'number',
|
||||
field: 'c',
|
||||
filter: { eq: 3 },
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle the example from the documentation', () => {
|
||||
const result = parseDSL(
|
||||
'uri in ["a", "b c", "d"] and (meta.created >= 12345 & meta.created < 23456 or (type = "foo" and meta.test != "stuff"))',
|
||||
);
|
||||
|
||||
expect(result).toEqual({
|
||||
uris: ['a', 'b c', 'd'],
|
||||
meta: {
|
||||
type: 'or',
|
||||
conditions: [
|
||||
{
|
||||
type: 'and',
|
||||
conditions: [
|
||||
{
|
||||
type: 'number',
|
||||
field: 'created',
|
||||
filter: { gte: 12345 },
|
||||
},
|
||||
{
|
||||
type: 'number',
|
||||
field: 'created',
|
||||
filter: { lt: 23456 },
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
type: 'text',
|
||||
field: 'test',
|
||||
filter: { neq: 'stuff' },
|
||||
},
|
||||
],
|
||||
},
|
||||
types: ['foo'],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('String handling', () => {
|
||||
it('should handle strings with spaces', () => {
|
||||
const result = parseDSL('meta.title = "This is a test with spaces"');
|
||||
expect(result).toEqual({
|
||||
meta: {
|
||||
type: 'text',
|
||||
field: 'title',
|
||||
filter: { eq: 'This is a test with spaces' },
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle single quotes', () => {
|
||||
const result = parseDSL("meta.title = 'Single quoted string'");
|
||||
expect(result).toEqual({
|
||||
meta: {
|
||||
type: 'text',
|
||||
field: 'title',
|
||||
filter: { eq: 'Single quoted string' },
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle escaped characters', () => {
|
||||
const result = parseDSL('meta.content = "String with \\"quotes\\" and \\n newlines"');
|
||||
expect(result).toEqual({
|
||||
meta: {
|
||||
type: 'text',
|
||||
field: 'content',
|
||||
filter: { eq: 'String with "quotes" and \n newlines' },
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Whitespace handling', () => {
|
||||
it('should ignore extra whitespace', () => {
|
||||
const result = parseDSL(' uri = "test" and meta.priority >= 5 ');
|
||||
expect(result).toEqual({
|
||||
uris: ['test'],
|
||||
meta: {
|
||||
type: 'number',
|
||||
field: 'priority',
|
||||
filter: { gte: 5 },
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Error handling', () => {
|
||||
it('should throw error for invalid syntax', () => {
|
||||
expect(() => parseDSL('uri =')).toThrow('DSL parsing error');
|
||||
expect(() => parseDSL('uri = "test')).toThrow('DSL parsing error');
|
||||
expect(() => parseDSL('(uri = "test"')).toThrow('DSL parsing error');
|
||||
});
|
||||
|
||||
it('should throw error for unknown fields', () => {
|
||||
expect(() => parseDSL('unknown = "test"')).toThrow('DSL parsing error');
|
||||
});
|
||||
|
||||
it('should throw error for unsupported operators', () => {
|
||||
expect(() => parseDSL('uri like "test"')).toThrow('DSL parsing error');
|
||||
expect(() => parseDSL('meta.test > "string"')).toThrow('DSL parsing error');
|
||||
});
|
||||
|
||||
it('should throw error for invalid tokens', () => {
|
||||
expect(() => parseDSL('uri = test@invalid')).toThrow('DSL parsing error');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Edge cases', () => {
|
||||
it('should handle empty arrays', () => {
|
||||
const result = parseDSL('uri in []');
|
||||
expect(result).toEqual({
|
||||
uris: [],
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle decimal numbers', () => {
|
||||
const result = parseDSL('meta.score = 4.5');
|
||||
expect(result).toEqual({
|
||||
meta: {
|
||||
type: 'number',
|
||||
field: 'score',
|
||||
filter: { eq: 4.5 },
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle negative numbers', () => {
|
||||
const result = parseDSL('meta.balance < -100');
|
||||
expect(result).toEqual({
|
||||
meta: {
|
||||
type: 'number',
|
||||
field: 'balance',
|
||||
filter: { lt: -100 },
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should deduplicate URI and type arrays', () => {
|
||||
const result = parseDSL('uri = "test" or uri = "test" or type = "article" or type = "article"');
|
||||
expect(result).toEqual({
|
||||
uris: ['test'],
|
||||
types: ['article'],
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
616
packages/core/src/services/documents/documents.dsl.ts
Normal file
616
packages/core/src/services/documents/documents.dsl.ts
Normal file
@@ -0,0 +1,616 @@
|
||||
import type { DocumentSearchOptions, MetaCondition, MetaFilter } from './documents.schemas.ts';
|
||||
|
||||
// Token types for the DSL
|
||||
type TokenType =
|
||||
| 'IDENTIFIER'
|
||||
| 'STRING'
|
||||
| 'NUMBER'
|
||||
| 'BOOLEAN'
|
||||
| 'OPERATOR'
|
||||
| 'KEYWORD'
|
||||
| 'LPAREN'
|
||||
| 'RPAREN'
|
||||
| 'LBRACKET'
|
||||
| 'RBRACKET'
|
||||
| 'COMMA'
|
||||
| 'DOT'
|
||||
| 'EOF';
|
||||
|
||||
type Token = {
|
||||
type: TokenType;
|
||||
value: string;
|
||||
position: number;
|
||||
};
|
||||
|
||||
class Tokenizer {
|
||||
private input: string;
|
||||
private position = 0;
|
||||
private currentChar: string | null;
|
||||
|
||||
constructor(input: string) {
|
||||
this.input = input.trim();
|
||||
this.currentChar = this.input.length > 0 ? this.input[0] : null;
|
||||
}
|
||||
|
||||
private advance(): void {
|
||||
this.position += 1;
|
||||
if (this.position >= this.input.length) {
|
||||
this.currentChar = null;
|
||||
} else {
|
||||
this.currentChar = this.input[this.position];
|
||||
}
|
||||
}
|
||||
|
||||
private skipWhitespace(): void {
|
||||
while (this.currentChar && /\s/.test(this.currentChar)) {
|
||||
this.advance();
|
||||
}
|
||||
}
|
||||
|
||||
private readString(): string {
|
||||
const quote = this.currentChar; // Either ' or "
|
||||
this.advance(); // Skip opening quote
|
||||
|
||||
let value = '';
|
||||
while (this.currentChar && this.currentChar !== quote) {
|
||||
if (this.currentChar === '\\') {
|
||||
this.advance(); // Skip escape character
|
||||
if (this.currentChar) {
|
||||
// Handle common escape sequences
|
||||
switch (this.currentChar as string) {
|
||||
case 'n':
|
||||
value += '\n';
|
||||
break;
|
||||
case 't':
|
||||
value += '\t';
|
||||
break;
|
||||
case 'r':
|
||||
value += '\r';
|
||||
break;
|
||||
case '\\':
|
||||
value += '\\';
|
||||
break;
|
||||
case '"':
|
||||
value += '"';
|
||||
break;
|
||||
case "'":
|
||||
value += "'";
|
||||
break;
|
||||
default:
|
||||
value += this.currentChar;
|
||||
break;
|
||||
}
|
||||
this.advance();
|
||||
}
|
||||
} else {
|
||||
value += this.currentChar;
|
||||
this.advance();
|
||||
}
|
||||
}
|
||||
|
||||
if (this.currentChar === quote) {
|
||||
this.advance(); // Skip closing quote
|
||||
} else {
|
||||
throw new Error(`Unterminated string at position ${this.position}`);
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
private readNumber(): string {
|
||||
let value = '';
|
||||
|
||||
// Handle negative sign
|
||||
if (this.currentChar === '-') {
|
||||
value += this.currentChar;
|
||||
this.advance();
|
||||
}
|
||||
|
||||
while (this.currentChar && /[\d.]/.test(this.currentChar)) {
|
||||
value += this.currentChar;
|
||||
this.advance();
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
private readIdentifier(): string {
|
||||
let value = '';
|
||||
while (this.currentChar && /[a-zA-Z0-9_]/.test(this.currentChar)) {
|
||||
value += this.currentChar;
|
||||
this.advance();
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
private readOperator(): string {
|
||||
let value = '';
|
||||
|
||||
// Handle multi-character operators
|
||||
const remainingInput = this.input.slice(this.position);
|
||||
|
||||
if (remainingInput.startsWith('>=')) {
|
||||
this.advance();
|
||||
this.advance();
|
||||
return '>=';
|
||||
} else if (remainingInput.startsWith('<=')) {
|
||||
this.advance();
|
||||
this.advance();
|
||||
return '<=';
|
||||
} else if (remainingInput.startsWith('!=')) {
|
||||
this.advance();
|
||||
this.advance();
|
||||
return '!=';
|
||||
} else if (remainingInput.startsWith('not like')) {
|
||||
// Handle "not like" as a single operator
|
||||
this.position += 8;
|
||||
this.currentChar = this.position < this.input.length ? this.input[this.position] : null;
|
||||
return 'not like';
|
||||
} else {
|
||||
value = this.currentChar || '';
|
||||
this.advance();
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
tokenize(): Token[] {
|
||||
const tokens: Token[] = [];
|
||||
|
||||
while (this.currentChar) {
|
||||
this.skipWhitespace();
|
||||
|
||||
if (!this.currentChar) break;
|
||||
|
||||
const startPosition = this.position;
|
||||
|
||||
if (this.currentChar === '"' || this.currentChar === "'") {
|
||||
const value = this.readString();
|
||||
tokens.push({ type: 'STRING', value, position: startPosition });
|
||||
} else if (
|
||||
/\d/.test(this.currentChar) ||
|
||||
(this.currentChar === '-' && /\d/.test(this.input[this.position + 1] || ''))
|
||||
) {
|
||||
const value = this.readNumber();
|
||||
tokens.push({ type: 'NUMBER', value, position: startPosition });
|
||||
} else if (/[a-zA-Z_]/.test(this.currentChar)) {
|
||||
const value = this.readIdentifier();
|
||||
|
||||
// Check for keywords and boolean values
|
||||
if (['and', 'or', 'in', 'like', 'not'].includes(value.toLowerCase())) {
|
||||
tokens.push({ type: 'KEYWORD', value: value.toLowerCase(), position: startPosition });
|
||||
} else if (['true', 'false'].includes(value.toLowerCase())) {
|
||||
tokens.push({ type: 'BOOLEAN', value: value.toLowerCase(), position: startPosition });
|
||||
} else {
|
||||
tokens.push({ type: 'IDENTIFIER', value, position: startPosition });
|
||||
}
|
||||
} else if (['=', '!', '>', '<', '&', '|'].includes(this.currentChar)) {
|
||||
const value = this.readOperator();
|
||||
// Handle & and | as logical operators
|
||||
if (value === '&') {
|
||||
tokens.push({ type: 'KEYWORD', value: 'and', position: startPosition });
|
||||
} else if (value === '|') {
|
||||
tokens.push({ type: 'KEYWORD', value: 'or', position: startPosition });
|
||||
} else {
|
||||
tokens.push({ type: 'OPERATOR', value, position: startPosition });
|
||||
}
|
||||
} else if (this.currentChar === '(') {
|
||||
tokens.push({ type: 'LPAREN', value: '(', position: startPosition });
|
||||
this.advance();
|
||||
} else if (this.currentChar === ')') {
|
||||
tokens.push({ type: 'RPAREN', value: ')', position: startPosition });
|
||||
this.advance();
|
||||
} else if (this.currentChar === '[') {
|
||||
tokens.push({ type: 'LBRACKET', value: '[', position: startPosition });
|
||||
this.advance();
|
||||
} else if (this.currentChar === ']') {
|
||||
tokens.push({ type: 'RBRACKET', value: ']', position: startPosition });
|
||||
this.advance();
|
||||
} else if (this.currentChar === ',') {
|
||||
tokens.push({ type: 'COMMA', value: ',', position: startPosition });
|
||||
this.advance();
|
||||
} else if (this.currentChar === '.') {
|
||||
tokens.push({ type: 'DOT', value: '.', position: startPosition });
|
||||
this.advance();
|
||||
} else {
|
||||
throw new Error(`Unexpected character '${this.currentChar}' at position ${this.position}`);
|
||||
}
|
||||
}
|
||||
|
||||
tokens.push({ type: 'EOF', value: '', position: this.position });
|
||||
return tokens;
|
||||
}
|
||||
}
|
||||
|
||||
// AST node types
|
||||
type ASTNode = {
|
||||
type: string;
|
||||
};
|
||||
|
||||
type ComparisonNode = {
|
||||
type: 'comparison';
|
||||
field: string;
|
||||
operator: string;
|
||||
value: string | number | boolean | string[];
|
||||
} & ASTNode;
|
||||
|
||||
type LogicalNode = {
|
||||
type: 'logical';
|
||||
operator: 'and' | 'or';
|
||||
left: ASTNode;
|
||||
right: ASTNode;
|
||||
} & ASTNode;
|
||||
|
||||
class Parser {
|
||||
private tokens: Token[];
|
||||
private position = 0;
|
||||
private currentToken: Token;
|
||||
|
||||
constructor(tokens: Token[]) {
|
||||
this.tokens = tokens;
|
||||
this.currentToken = tokens[0];
|
||||
}
|
||||
|
||||
private advance() {
|
||||
this.position += 1;
|
||||
if (this.position < this.tokens.length) {
|
||||
this.currentToken = this.tokens[this.position];
|
||||
}
|
||||
return this.currentToken;
|
||||
}
|
||||
|
||||
private expect(tokenType: TokenType | TokenType[], value?: string): Token {
|
||||
const types = Array.isArray(tokenType) ? tokenType : [tokenType];
|
||||
|
||||
if (!types.includes(this.currentToken.type)) {
|
||||
throw new Error(
|
||||
`Expected ${types.join(' or ')}, got ${this.currentToken.type} at position ${this.currentToken.position}`,
|
||||
);
|
||||
}
|
||||
|
||||
if (value && this.currentToken.value !== value) {
|
||||
throw new Error(
|
||||
`Expected '${value}', got '${this.currentToken.value}' at position ${this.currentToken.position}`,
|
||||
);
|
||||
}
|
||||
|
||||
const token = this.currentToken;
|
||||
this.advance();
|
||||
return token;
|
||||
}
|
||||
|
||||
private parseValue(): string | number | boolean | string[] {
|
||||
if (this.currentToken.type === 'STRING') {
|
||||
const value = this.currentToken.value;
|
||||
this.advance();
|
||||
return value;
|
||||
} else if (this.currentToken.type === 'NUMBER') {
|
||||
const value = parseFloat(this.currentToken.value);
|
||||
this.advance();
|
||||
return value;
|
||||
} else if (this.currentToken.type === 'BOOLEAN') {
|
||||
const value = this.currentToken.value === 'true';
|
||||
this.advance();
|
||||
return value;
|
||||
} else if (this.currentToken.type === 'LBRACKET') {
|
||||
this.advance(); // Skip [
|
||||
|
||||
const values: string[] = [];
|
||||
|
||||
if ((this.currentToken.type as TokenType) !== 'RBRACKET') {
|
||||
values.push(String(this.parseValue()));
|
||||
|
||||
while ((this.currentToken.type as TokenType) === 'COMMA') {
|
||||
this.advance(); // Skip comma
|
||||
values.push(String(this.parseValue()));
|
||||
}
|
||||
}
|
||||
|
||||
this.expect('RBRACKET');
|
||||
return values;
|
||||
} else {
|
||||
throw new Error(`Expected value, got ${this.currentToken.type} at position ${this.currentToken.position}`);
|
||||
}
|
||||
}
|
||||
|
||||
private parseComparison(): ComparisonNode {
|
||||
let field: string;
|
||||
|
||||
// Parse field (uri, type, or meta.fieldName)
|
||||
if (this.currentToken.type === 'IDENTIFIER') {
|
||||
const identifier = this.currentToken.value;
|
||||
this.advance();
|
||||
|
||||
if (identifier === 'meta' && (this.currentToken.type as TokenType) === 'DOT') {
|
||||
this.advance(); // Skip dot
|
||||
const fieldName = this.expect('IDENTIFIER').value;
|
||||
field = `meta.${fieldName}`;
|
||||
} else if (['uri', 'type'].includes(identifier)) {
|
||||
field = identifier;
|
||||
} else {
|
||||
throw new Error(`Unknown field '${identifier}' at position ${this.currentToken.position}`);
|
||||
}
|
||||
} else {
|
||||
throw new Error(`Expected field name, got ${this.currentToken.type} at position ${this.currentToken.position}`);
|
||||
}
|
||||
|
||||
// Parse operator
|
||||
let operator: string;
|
||||
if ((this.currentToken.type as TokenType) === 'OPERATOR') {
|
||||
operator = this.currentToken.value;
|
||||
this.advance();
|
||||
} else if ((this.currentToken.type as TokenType) === 'KEYWORD') {
|
||||
if (this.currentToken.value === 'in') {
|
||||
operator = 'in';
|
||||
this.advance();
|
||||
} else if (this.currentToken.value === 'like') {
|
||||
operator = 'like';
|
||||
this.advance();
|
||||
} else if (this.currentToken.value === 'not') {
|
||||
this.advance();
|
||||
this.expect('KEYWORD', 'like');
|
||||
operator = 'not like';
|
||||
} else {
|
||||
throw new Error(`Unexpected keyword '${this.currentToken.value}' at position ${this.currentToken.position}`);
|
||||
}
|
||||
} else {
|
||||
throw new Error(`Expected operator, got ${this.currentToken.type} at position ${this.currentToken.position}`);
|
||||
}
|
||||
|
||||
// Parse value
|
||||
if ((this.currentToken.type as TokenType) === 'EOF') {
|
||||
throw new Error(`Expected value after operator '${operator}' at position ${this.currentToken.position}`);
|
||||
}
|
||||
|
||||
const value = this.parseValue();
|
||||
|
||||
return {
|
||||
type: 'comparison',
|
||||
field,
|
||||
operator,
|
||||
value,
|
||||
};
|
||||
}
|
||||
|
||||
private parseTerm(): ASTNode {
|
||||
if (this.currentToken.type === 'LPAREN') {
|
||||
this.advance(); // Skip (
|
||||
const node = this.parseExpression();
|
||||
this.expect('RPAREN');
|
||||
return node;
|
||||
} else {
|
||||
return this.parseComparison();
|
||||
}
|
||||
}
|
||||
|
||||
private parseExpression(): ASTNode {
|
||||
let node = this.parseTerm();
|
||||
|
||||
while (
|
||||
this.currentToken.type === 'KEYWORD' &&
|
||||
(this.currentToken.value === 'and' || this.currentToken.value === 'or')
|
||||
) {
|
||||
const operator = this.currentToken.value as 'and' | 'or';
|
||||
this.advance();
|
||||
const right = this.parseTerm();
|
||||
|
||||
node = {
|
||||
type: 'logical',
|
||||
operator,
|
||||
left: node,
|
||||
right,
|
||||
} as LogicalNode;
|
||||
}
|
||||
|
||||
return node;
|
||||
}
|
||||
|
||||
parse(): ASTNode {
|
||||
const result = this.parseExpression();
|
||||
this.expect('EOF');
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
// Convert AST to DocumentSearchOptions
|
||||
class Converter {
|
||||
private uris: string[] = [];
|
||||
private types: string[] = [];
|
||||
private metaConditions: MetaCondition[] = [];
|
||||
private hasUriConditions = false;
|
||||
private hasTypeConditions = false;
|
||||
|
||||
private convertComparison(node: ComparisonNode): MetaCondition | null {
|
||||
if (node.field === 'uri') {
|
||||
this.hasUriConditions = true;
|
||||
if (node.operator === '=' && typeof node.value === 'string') {
|
||||
this.uris.push(node.value);
|
||||
} else if (node.operator === 'in' && Array.isArray(node.value)) {
|
||||
this.uris.push(...node.value);
|
||||
} else {
|
||||
throw new Error(`Unsupported operator '${node.operator}' for uri field`);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
if (node.field === 'type') {
|
||||
this.hasTypeConditions = true;
|
||||
if (node.operator === '=' && typeof node.value === 'string') {
|
||||
this.types.push(node.value);
|
||||
} else if (node.operator === 'in' && Array.isArray(node.value)) {
|
||||
this.types.push(...node.value);
|
||||
} else {
|
||||
throw new Error(`Unsupported operator '${node.operator}' for type field`);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
if (node.field.startsWith('meta.')) {
|
||||
const fieldName = node.field.slice(5); // Remove 'meta.' prefix
|
||||
return this.convertMetaFilter(fieldName, node.operator, node.value);
|
||||
}
|
||||
|
||||
throw new Error(`Unknown field '${node.field}'`);
|
||||
}
|
||||
|
||||
private convertMetaFilter(field: string, operator: string, value: string | number | boolean | string[]): MetaFilter {
|
||||
if (typeof value === 'number') {
|
||||
// Number filter
|
||||
const filter: Record<string, number> = {};
|
||||
|
||||
switch (operator) {
|
||||
case '=':
|
||||
filter.eq = value;
|
||||
break;
|
||||
case '!=':
|
||||
filter.neq = value;
|
||||
break;
|
||||
case '>':
|
||||
filter.gt = value;
|
||||
break;
|
||||
case '>=':
|
||||
filter.gte = value;
|
||||
break;
|
||||
case '<':
|
||||
filter.lt = value;
|
||||
break;
|
||||
case '<=':
|
||||
filter.lte = value;
|
||||
break;
|
||||
default:
|
||||
throw new Error(`Unsupported operator '${operator}' for number field`);
|
||||
}
|
||||
|
||||
return {
|
||||
type: 'number',
|
||||
field,
|
||||
filter,
|
||||
};
|
||||
} else if (typeof value === 'boolean') {
|
||||
// Boolean filter
|
||||
if (operator !== '=') {
|
||||
throw new Error(`Unsupported operator '${operator}' for boolean field`);
|
||||
}
|
||||
|
||||
return {
|
||||
type: 'bool',
|
||||
field,
|
||||
filter: { eq: value },
|
||||
};
|
||||
} else if (typeof value === 'string') {
|
||||
// Text filter
|
||||
const filter: Record<string, string> = {};
|
||||
|
||||
switch (operator) {
|
||||
case '=':
|
||||
filter.eq = value;
|
||||
break;
|
||||
case '!=':
|
||||
filter.neq = value;
|
||||
break;
|
||||
case 'like':
|
||||
filter.like = value;
|
||||
break;
|
||||
case 'not like':
|
||||
filter.nlike = value;
|
||||
break;
|
||||
default:
|
||||
throw new Error(`Unsupported operator '${operator}' for text field`);
|
||||
}
|
||||
|
||||
return {
|
||||
type: 'text',
|
||||
field,
|
||||
filter,
|
||||
};
|
||||
} else {
|
||||
throw new Error(`Unsupported value type for meta field`);
|
||||
}
|
||||
}
|
||||
|
||||
private convertLogical(node: LogicalNode): MetaCondition | null {
|
||||
const leftCondition = this.convertNode(node.left);
|
||||
const rightCondition = this.convertNode(node.right);
|
||||
|
||||
const conditions: MetaCondition[] = [];
|
||||
if (leftCondition) conditions.push(leftCondition);
|
||||
if (rightCondition) conditions.push(rightCondition);
|
||||
|
||||
if (conditions.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (conditions.length === 1) {
|
||||
return conditions[0];
|
||||
}
|
||||
|
||||
return {
|
||||
type: node.operator,
|
||||
conditions,
|
||||
};
|
||||
}
|
||||
|
||||
private convertNode(node: ASTNode): MetaCondition | null {
|
||||
if (node.type === 'comparison') {
|
||||
return this.convertComparison(node as ComparisonNode);
|
||||
} else if (node.type === 'logical') {
|
||||
return this.convertLogical(node as LogicalNode);
|
||||
}
|
||||
|
||||
throw new Error(`Unknown node type '${node.type}'`);
|
||||
}
|
||||
|
||||
convert(ast: ASTNode): DocumentSearchOptions {
|
||||
this.uris = [];
|
||||
this.types = [];
|
||||
this.metaConditions = [];
|
||||
this.hasUriConditions = false;
|
||||
this.hasTypeConditions = false;
|
||||
|
||||
const metaCondition = this.convertNode(ast);
|
||||
|
||||
const options: DocumentSearchOptions = {};
|
||||
|
||||
if (this.hasUriConditions) {
|
||||
options.uris = [...new Set(this.uris)]; // Remove duplicates
|
||||
}
|
||||
|
||||
if (this.hasTypeConditions) {
|
||||
options.types = [...new Set(this.types)]; // Remove duplicates
|
||||
}
|
||||
|
||||
if (metaCondition) {
|
||||
options.meta = metaCondition;
|
||||
}
|
||||
|
||||
return options;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a DSL query string into DocumentSearchOptions
|
||||
*
|
||||
* Supports the following syntax:
|
||||
* - URI filtering: `uri = "value"` or `uri in ["val1", "val2"]`
|
||||
* - Type filtering: `type = "value"` or `type in ["val1", "val2"]`
|
||||
* - Meta filtering: `meta.field = value`, `meta.field != value`, `meta.field > value`, etc.
|
||||
* - Boolean logic: `condition1 and condition2` (or `&`), `condition1 or condition2` (or `|`)
|
||||
* - Parentheses: `(condition1 or condition2) and condition3`
|
||||
*
|
||||
* Example: `uri in ["a", "b"] and (meta.priority >= 5 | type = "article")`
|
||||
*
|
||||
* @param query - The DSL query string to parse
|
||||
* @returns DocumentSearchOptions object
|
||||
*/
|
||||
export function parseDSL(query: string): DocumentSearchOptions {
|
||||
try {
|
||||
const tokenizer = new Tokenizer(query);
|
||||
const tokens = tokenizer.tokenize();
|
||||
|
||||
const parser = new Parser(tokens);
|
||||
const ast = parser.parse();
|
||||
|
||||
const converter = new Converter();
|
||||
return converter.convert(ast);
|
||||
} catch (error) {
|
||||
throw new Error(`DSL parsing error: ${error instanceof Error ? error.message : String(error)}`);
|
||||
}
|
||||
}
|
||||
133
packages/core/src/services/documents/documents.filter.ts
Normal file
133
packages/core/src/services/documents/documents.filter.ts
Normal file
@@ -0,0 +1,133 @@
|
||||
import type { MetaCondition, MetaFilter, Document } from './documents.schemas.ts';
|
||||
|
||||
/**
|
||||
* Evaluates a meta filter against a document's metadata
|
||||
*/
|
||||
const evaluateMetaFilter = (filter: MetaFilter, document: Document): boolean => {
|
||||
const fieldValue = document.metadata[filter.field];
|
||||
const fieldExists = filter.field in document.metadata;
|
||||
|
||||
if (filter.type === 'number') {
|
||||
const { gt, gte, lt, lte, eq, neq, nill } = filter.filter;
|
||||
|
||||
// Handle null/undefined checks first
|
||||
if (nill !== undefined) {
|
||||
if (nill) {
|
||||
// Field doesn't exist or is null
|
||||
return !fieldExists || fieldValue === null || fieldValue === undefined;
|
||||
} else {
|
||||
// Field exists and is not null
|
||||
return fieldExists && fieldValue !== null && fieldValue !== undefined;
|
||||
}
|
||||
}
|
||||
|
||||
// If field doesn't exist or is null for numeric operations, return false
|
||||
if (!fieldExists || fieldValue === null || fieldValue === undefined) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const numValue = typeof fieldValue === 'number' ? fieldValue : Number(fieldValue);
|
||||
|
||||
// If conversion to number fails, return false for numeric operations
|
||||
if (isNaN(numValue)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (eq !== undefined && numValue !== eq) return false;
|
||||
if (neq !== undefined && numValue === neq) return false;
|
||||
if (gt !== undefined && numValue <= gt) return false;
|
||||
if (gte !== undefined && numValue < gte) return false;
|
||||
if (lt !== undefined && numValue >= lt) return false;
|
||||
if (lte !== undefined && numValue > lte) return false;
|
||||
|
||||
return true;
|
||||
} else if (filter.type === 'text') {
|
||||
const { eq, neq, like, nlike, nill } = filter.filter;
|
||||
|
||||
// Handle null/undefined checks first
|
||||
if (nill !== undefined) {
|
||||
if (nill) {
|
||||
// Field doesn't exist or is null
|
||||
return !fieldExists || fieldValue === null || fieldValue === undefined;
|
||||
} else {
|
||||
// Field exists and is not null
|
||||
return fieldExists && fieldValue !== null && fieldValue !== undefined;
|
||||
}
|
||||
}
|
||||
|
||||
// If field doesn't exist or is null for text operations, return false
|
||||
if (!fieldExists || fieldValue === null || fieldValue === undefined) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const strValue = String(fieldValue);
|
||||
|
||||
if (eq !== undefined && strValue !== eq) return false;
|
||||
if (neq !== undefined && strValue === neq) return false;
|
||||
if (like !== undefined) {
|
||||
// Convert SQL LIKE pattern to JavaScript regex
|
||||
const regexPattern = like.replace(/\\_/g, '_').replace(/%/g, '.*').replace(/\\\./g, '\\.');
|
||||
const regex = new RegExp(`^${regexPattern}$`, 'i'); // Case insensitive like SQL LIKE
|
||||
if (!regex.test(strValue)) return false;
|
||||
}
|
||||
if (nlike !== undefined) {
|
||||
// Convert SQL NOT LIKE pattern to JavaScript regex
|
||||
const regexPattern = nlike.replace(/\\_/g, '_').replace(/%/g, '.*').replace(/\\\./g, '\\.');
|
||||
const regex = new RegExp(`^${regexPattern}$`, 'i'); // Case insensitive like SQL LIKE
|
||||
if (regex.test(strValue)) return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
} else if (filter.type === 'bool') {
|
||||
const { eq, nill } = filter.filter;
|
||||
|
||||
// Handle null/undefined checks first
|
||||
if (nill !== undefined) {
|
||||
if (nill) {
|
||||
// Field doesn't exist or is null
|
||||
return !fieldExists || fieldValue === null || fieldValue === undefined;
|
||||
} else {
|
||||
// Field exists and is not null
|
||||
return fieldExists && fieldValue !== null && fieldValue !== undefined;
|
||||
}
|
||||
}
|
||||
|
||||
// If field doesn't exist or is null for boolean operations, return false
|
||||
if (!fieldExists || fieldValue === null || fieldValue === undefined) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const boolValue = typeof fieldValue === 'boolean' ? fieldValue : Boolean(fieldValue);
|
||||
return boolValue === eq;
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
/**
|
||||
* Evaluates a meta condition (which can contain filters and nested AND/OR conditions) against a document
|
||||
*/
|
||||
const evaluateMetaCondition = (condition: MetaCondition, document: Document): boolean => {
|
||||
if (condition.type === 'and') {
|
||||
// ALL conditions must be true
|
||||
return condition.conditions.every((subCondition) => evaluateMetaCondition(subCondition, document));
|
||||
} else if (condition.type === 'or') {
|
||||
// AT LEAST ONE condition must be true
|
||||
return condition.conditions.some((subCondition) => evaluateMetaCondition(subCondition, document));
|
||||
} else {
|
||||
// It's a filter, not a condition
|
||||
return evaluateMetaFilter(condition, document);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Filters a document based on the provided filter/condition
|
||||
* Returns true if the document matches the filter, false otherwise
|
||||
*
|
||||
* @param filter - The filter or condition to apply
|
||||
* @param document - The document to test against the filter
|
||||
* @returns true if the document matches the filter, false otherwise
|
||||
*/
|
||||
export const filterDocument = (filter: MetaCondition | MetaFilter, document: Document): boolean => {
|
||||
return evaluateMetaCondition(filter, document);
|
||||
};
|
||||
@@ -1,4 +1,4 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import { describe, expect, beforeEach, afterEach } from 'vitest';
|
||||
|
||||
import { DatabaseService, tableNames } from '../database/database.ts';
|
||||
|
||||
@@ -6,25 +6,26 @@ import { DocumentsService } from './documents.ts';
|
||||
import type { DocumentUpsert, DocumentSearchOptions, MetaCondition } from './documents.schemas.ts';
|
||||
|
||||
import { Services } from '#root/utils/services.ts';
|
||||
import { itWithTiming } from '#root/utils/tests.ts';
|
||||
|
||||
describe('DocumentsService', () => {
|
||||
let services: Services;
|
||||
let documentsService: DocumentsService;
|
||||
let databaseService: DatabaseService;
|
||||
|
||||
beforeEach(() => {
|
||||
beforeEach(async () => {
|
||||
services = new Services();
|
||||
documentsService = services.get(DocumentsService);
|
||||
databaseService = services.get(DatabaseService);
|
||||
await databaseService.init();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
const db = await databaseService.getDb();
|
||||
await db(tableNames.documents).del();
|
||||
await databaseService.close();
|
||||
});
|
||||
|
||||
describe('upsert', () => {
|
||||
it('should insert a new document', async () => {
|
||||
itWithTiming('should insert a new document', async () => {
|
||||
const document: DocumentUpsert = {
|
||||
uri: 'test-doc-1',
|
||||
type: 'article',
|
||||
@@ -56,7 +57,7 @@ describe('DocumentsService', () => {
|
||||
expect(actualResult?.deletedAt).toBeNull();
|
||||
});
|
||||
|
||||
it('should update an existing document', async () => {
|
||||
itWithTiming('should update an existing document', async () => {
|
||||
const document: DocumentUpsert = {
|
||||
uri: 'test-doc-1',
|
||||
type: 'article',
|
||||
@@ -96,7 +97,7 @@ describe('DocumentsService', () => {
|
||||
expect(actualResult?.updatedAt).toBeDefined();
|
||||
});
|
||||
|
||||
it('should allow same URI with different types', async () => {
|
||||
itWithTiming('should allow same URI with different types', async () => {
|
||||
const doc1: DocumentUpsert = {
|
||||
uri: 'shared-uri',
|
||||
type: 'article',
|
||||
@@ -178,14 +179,14 @@ describe('DocumentsService', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it('should return all documents when no filters applied', async () => {
|
||||
itWithTiming('should return all documents when no filters applied', async () => {
|
||||
const options: DocumentSearchOptions = {};
|
||||
const results = await documentsService.search(options);
|
||||
|
||||
expect(results).toHaveLength(4);
|
||||
});
|
||||
|
||||
it('should filter by uris', async () => {
|
||||
itWithTiming('should filter by uris', async () => {
|
||||
const options: DocumentSearchOptions = {
|
||||
uris: ['doc-1', 'doc-3'],
|
||||
};
|
||||
@@ -195,7 +196,7 @@ describe('DocumentsService', () => {
|
||||
expect(results.map((r) => r.uri).sort()).toEqual(['doc-1', 'doc-3']);
|
||||
});
|
||||
|
||||
it('should filter by types', async () => {
|
||||
itWithTiming('should filter by types', async () => {
|
||||
const options: DocumentSearchOptions = {
|
||||
types: ['article'],
|
||||
};
|
||||
@@ -204,7 +205,7 @@ describe('DocumentsService', () => {
|
||||
expect(results.every((r) => r.type === 'article')).toBe(true);
|
||||
});
|
||||
|
||||
it('should apply limit', async () => {
|
||||
itWithTiming('should apply limit', async () => {
|
||||
const options: DocumentSearchOptions = {
|
||||
limit: 2,
|
||||
};
|
||||
@@ -212,7 +213,7 @@ describe('DocumentsService', () => {
|
||||
expect(results).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('should apply offset', async () => {
|
||||
itWithTiming('should apply offset', async () => {
|
||||
const options: DocumentSearchOptions = {
|
||||
limit: 2,
|
||||
offset: 1,
|
||||
@@ -221,7 +222,7 @@ describe('DocumentsService', () => {
|
||||
expect(results).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('should combine multiple filters', async () => {
|
||||
itWithTiming('should combine multiple filters', async () => {
|
||||
const options: DocumentSearchOptions = {
|
||||
types: ['article'],
|
||||
uris: ['doc-1', 'doc-2', 'doc-4'],
|
||||
@@ -293,7 +294,7 @@ describe('DocumentsService', () => {
|
||||
});
|
||||
|
||||
describe('number filters', () => {
|
||||
it('should filter by exact number equality', async () => {
|
||||
itWithTiming('should filter by exact number equality', async () => {
|
||||
const meta: MetaCondition = {
|
||||
type: 'number',
|
||||
field: 'priority',
|
||||
@@ -305,7 +306,7 @@ describe('DocumentsService', () => {
|
||||
expect(results[0].uri).toBe('meta-2');
|
||||
});
|
||||
|
||||
it('should filter by number range', async () => {
|
||||
itWithTiming('should filter by number range', async () => {
|
||||
const meta: MetaCondition = {
|
||||
type: 'number',
|
||||
field: 'priority',
|
||||
@@ -317,7 +318,7 @@ describe('DocumentsService', () => {
|
||||
expect(results[0].uri).toBe('meta-2');
|
||||
});
|
||||
|
||||
it('should filter by greater than', async () => {
|
||||
itWithTiming('should filter by greater than', async () => {
|
||||
const meta: MetaCondition = {
|
||||
type: 'number',
|
||||
field: 'priority',
|
||||
@@ -329,7 +330,7 @@ describe('DocumentsService', () => {
|
||||
expect(results[0].uri).toBe('meta-1');
|
||||
});
|
||||
|
||||
it('should filter by not equal', async () => {
|
||||
itWithTiming('should filter by not equal', async () => {
|
||||
const meta: MetaCondition = {
|
||||
type: 'number',
|
||||
field: 'priority',
|
||||
@@ -343,7 +344,7 @@ describe('DocumentsService', () => {
|
||||
});
|
||||
|
||||
describe('text filters', () => {
|
||||
it('should filter by exact text equality', async () => {
|
||||
itWithTiming('should filter by exact text equality', async () => {
|
||||
const meta: MetaCondition = {
|
||||
type: 'text',
|
||||
field: 'category',
|
||||
@@ -355,7 +356,7 @@ describe('DocumentsService', () => {
|
||||
expect(results.map((r) => r.uri).sort()).toEqual(['meta-1', 'meta-4']);
|
||||
});
|
||||
|
||||
it('should filter by text like pattern', async () => {
|
||||
itWithTiming('should filter by text like pattern', async () => {
|
||||
const meta: MetaCondition = {
|
||||
type: 'text',
|
||||
field: 'title',
|
||||
@@ -367,7 +368,7 @@ describe('DocumentsService', () => {
|
||||
expect(results.map((r) => r.uri).sort()).toEqual(['meta-1', 'meta-2']);
|
||||
});
|
||||
|
||||
it('should filter by text not like pattern', async () => {
|
||||
itWithTiming('should filter by text not like pattern', async () => {
|
||||
const meta: MetaCondition = {
|
||||
type: 'text',
|
||||
field: 'title',
|
||||
@@ -379,7 +380,7 @@ describe('DocumentsService', () => {
|
||||
expect(results.map((r) => r.uri).sort()).toEqual(['meta-3', 'meta-4']);
|
||||
});
|
||||
|
||||
it('should filter by text not equal', async () => {
|
||||
itWithTiming('should filter by text not equal', async () => {
|
||||
const meta: MetaCondition = {
|
||||
type: 'text',
|
||||
field: 'category',
|
||||
@@ -393,7 +394,7 @@ describe('DocumentsService', () => {
|
||||
});
|
||||
|
||||
describe('boolean filters', () => {
|
||||
it('should filter by boolean true', async () => {
|
||||
itWithTiming('should filter by boolean true', async () => {
|
||||
const meta: MetaCondition = {
|
||||
type: 'bool',
|
||||
field: 'published',
|
||||
@@ -405,7 +406,7 @@ describe('DocumentsService', () => {
|
||||
expect(results.map((r) => r.uri).sort()).toEqual(['meta-1', 'meta-3']);
|
||||
});
|
||||
|
||||
it('should filter by boolean false', async () => {
|
||||
itWithTiming('should filter by boolean false', async () => {
|
||||
const meta: MetaCondition = {
|
||||
type: 'bool',
|
||||
field: 'published',
|
||||
@@ -419,7 +420,7 @@ describe('DocumentsService', () => {
|
||||
});
|
||||
|
||||
describe.skip('null filters', () => {
|
||||
it('should filter by null values for numbers', async () => {
|
||||
itWithTiming('should filter by null values for numbers', async () => {
|
||||
const meta: MetaCondition = {
|
||||
type: 'number',
|
||||
field: 'nonexistent',
|
||||
@@ -430,7 +431,7 @@ describe('DocumentsService', () => {
|
||||
expect(results).toHaveLength(4); // All documents should match as none have this field
|
||||
});
|
||||
|
||||
it('should filter by non-null values', async () => {
|
||||
itWithTiming('should filter by non-null values', async () => {
|
||||
const meta: MetaCondition = {
|
||||
type: 'number',
|
||||
field: 'priority',
|
||||
@@ -443,7 +444,7 @@ describe('DocumentsService', () => {
|
||||
});
|
||||
|
||||
describe('complex conditions', () => {
|
||||
it('should handle AND conditions', async () => {
|
||||
itWithTiming('should handle AND conditions', async () => {
|
||||
const meta: MetaCondition = {
|
||||
type: 'and',
|
||||
conditions: [
|
||||
@@ -465,7 +466,7 @@ describe('DocumentsService', () => {
|
||||
expect(results[0].uri).toBe('meta-1');
|
||||
});
|
||||
|
||||
it('should handle OR conditions', async () => {
|
||||
itWithTiming('should handle OR conditions', async () => {
|
||||
const meta: MetaCondition = {
|
||||
type: 'or',
|
||||
conditions: [
|
||||
@@ -487,7 +488,7 @@ describe('DocumentsService', () => {
|
||||
expect(results.map((r) => r.uri).sort()).toEqual(['meta-1', 'meta-3']);
|
||||
});
|
||||
|
||||
it('should handle nested AND/OR conditions', async () => {
|
||||
itWithTiming('should handle nested AND/OR conditions', async () => {
|
||||
const meta: MetaCondition = {
|
||||
type: 'and',
|
||||
conditions: [
|
||||
@@ -519,7 +520,7 @@ describe('DocumentsService', () => {
|
||||
expect(results.map((r) => r.uri).sort()).toEqual(['meta-2', 'meta-4']);
|
||||
});
|
||||
|
||||
it('should handle deeply nested conditions', async () => {
|
||||
itWithTiming('should handle deeply nested conditions', async () => {
|
||||
const meta: MetaCondition = {
|
||||
type: 'or',
|
||||
conditions: [
|
||||
@@ -564,13 +565,13 @@ describe('DocumentsService', () => {
|
||||
});
|
||||
|
||||
describe('edge cases', () => {
|
||||
it('should handle empty search options', async () => {
|
||||
itWithTiming('should handle empty search options', async () => {
|
||||
const results = await documentsService.search({});
|
||||
expect(results).toBeDefined();
|
||||
expect(Array.isArray(results)).toBe(true);
|
||||
});
|
||||
|
||||
it('should handle search with no results', async () => {
|
||||
itWithTiming('should handle search with no results', async () => {
|
||||
const options: DocumentSearchOptions = {
|
||||
uris: ['non-existent-uri'],
|
||||
};
|
||||
@@ -578,7 +579,7 @@ describe('DocumentsService', () => {
|
||||
expect(results).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should handle meta search with no matching conditions', async () => {
|
||||
itWithTiming('should handle meta search with no matching conditions', async () => {
|
||||
await documentsService.upsert({
|
||||
uri: 'test-doc',
|
||||
type: 'article',
|
||||
125
packages/core/src/services/documents/documents.query.ts
Normal file
125
packages/core/src/services/documents/documents.query.ts
Normal file
@@ -0,0 +1,125 @@
|
||||
import type { Knex } from 'knex';
|
||||
|
||||
import type { MetaCondition, MetaFilter } from './documents.schemas.ts';
|
||||
|
||||
const buildMetaFilter = async (builder: Knex.QueryBuilder, filter: MetaFilter): Promise<void> => {
|
||||
const fieldPath = `metadata->'${filter.field}'`;
|
||||
|
||||
if (filter.type === 'number') {
|
||||
const { gt, gte, lt, lte, eq, neq, nill } = filter.filter;
|
||||
|
||||
if (nill !== undefined) {
|
||||
if (nill) {
|
||||
// Field doesn't exist or is null
|
||||
builder.where((subBuilder) => {
|
||||
subBuilder.whereRaw(`NOT (metadata ? '${filter.field}')`).orWhereRaw(`metadata->>'${filter.field}' IS NULL`);
|
||||
});
|
||||
} else {
|
||||
// Field exists and is not null
|
||||
builder.whereRaw(`metadata ? '${filter.field}' AND metadata->>'${filter.field}' IS NOT NULL`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (eq !== undefined) {
|
||||
builder.where(builder.client.raw(`(${fieldPath})::numeric`), eq);
|
||||
}
|
||||
if (neq !== undefined) {
|
||||
builder.where(builder.client.raw(`(${fieldPath})::numeric`), '!=', neq);
|
||||
}
|
||||
if (gt !== undefined) {
|
||||
builder.where(builder.client.raw(`(${fieldPath})::numeric`), '>', gt);
|
||||
}
|
||||
if (gte !== undefined) {
|
||||
builder.where(builder.client.raw(`(${fieldPath})::numeric`), '>=', gte);
|
||||
}
|
||||
if (lt !== undefined) {
|
||||
builder.where(builder.client.raw(`(${fieldPath})::numeric`), '<', lt);
|
||||
}
|
||||
if (lte !== undefined) {
|
||||
builder.where(builder.client.raw(`(${fieldPath})::numeric`), '<=', lte);
|
||||
}
|
||||
} else if (filter.type === 'text') {
|
||||
const { eq, neq, like, nlike, nill } = filter.filter;
|
||||
|
||||
if (nill !== undefined) {
|
||||
if (nill) {
|
||||
// Field doesn't exist or is null
|
||||
builder.where((subBuilder) => {
|
||||
subBuilder.whereRaw(`NOT (metadata ? '${filter.field}')`).orWhereRaw(`metadata->>'${filter.field}' IS NULL`);
|
||||
});
|
||||
} else {
|
||||
// Field exists and is not null
|
||||
builder.whereRaw(`metadata ? '${filter.field}' AND metadata->>'${filter.field}' IS NOT NULL`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (eq !== undefined) {
|
||||
builder.where(builder.client.raw(`metadata->>'${filter.field}'`), eq);
|
||||
}
|
||||
if (neq !== undefined) {
|
||||
builder.where(builder.client.raw(`metadata->>'${filter.field}'`), '!=', neq);
|
||||
}
|
||||
if (like !== undefined) {
|
||||
builder.where(builder.client.raw(`metadata->>'${filter.field}'`), 'like', like);
|
||||
}
|
||||
if (nlike !== undefined) {
|
||||
builder.where(builder.client.raw(`metadata->>'${filter.field}'`), 'not like', nlike);
|
||||
}
|
||||
} else if (filter.type === 'bool') {
|
||||
const { eq, nill } = filter.filter;
|
||||
|
||||
if (nill !== undefined) {
|
||||
if (nill) {
|
||||
// Field doesn't exist or is null
|
||||
builder.where((subBuilder) => {
|
||||
subBuilder.whereRaw(`NOT (metadata ? '${filter.field}')`).orWhereRaw(`metadata->>'${filter.field}' IS NULL`);
|
||||
});
|
||||
} else {
|
||||
// Field exists and is not null
|
||||
builder.whereRaw(`metadata ? '${filter.field}' AND metadata->>'${filter.field}' IS NOT NULL`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
builder.where(builder.client.raw(`(${fieldPath})::boolean`), eq);
|
||||
}
|
||||
};
|
||||
|
||||
const buildMetaCondition = async (builder: Knex.QueryBuilder, condition: MetaCondition): Promise<void> => {
|
||||
if (condition.type === 'and') {
|
||||
// Handle AND conditions - all must be true
|
||||
for (const [index, subCondition] of condition.conditions.entries()) {
|
||||
if (index === 0) {
|
||||
// First condition doesn't need andWhere
|
||||
builder.where((subBuilder) => {
|
||||
buildMetaCondition(subBuilder, subCondition);
|
||||
});
|
||||
} else {
|
||||
builder.andWhere((subBuilder) => {
|
||||
buildMetaCondition(subBuilder, subCondition);
|
||||
});
|
||||
}
|
||||
}
|
||||
} else if (condition.type === 'or') {
|
||||
// Handle OR conditions - at least one must be true
|
||||
for (const [index, subCondition] of condition.conditions.entries()) {
|
||||
if (index === 0) {
|
||||
// First condition doesn't need orWhere
|
||||
builder.where((subBuilder) => {
|
||||
buildMetaCondition(subBuilder, subCondition);
|
||||
});
|
||||
} else {
|
||||
builder.orWhere((subBuilder) => {
|
||||
buildMetaCondition(subBuilder, subCondition);
|
||||
});
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Handle individual filter conditions
|
||||
buildMetaFilter(builder, condition);
|
||||
}
|
||||
};
|
||||
|
||||
export { buildMetaFilter, buildMetaCondition };
|
||||
@@ -12,7 +12,7 @@ const documentSchema = z.object({
|
||||
data: z.record(z.string(), z.unknown()),
|
||||
});
|
||||
|
||||
type Document = z.infer<typeof Document>;
|
||||
type Document = z.infer<typeof documentSchema>;
|
||||
|
||||
const documentUpsertSchema = documentSchema.omit({
|
||||
createdAt: true,
|
||||
@@ -89,5 +89,19 @@ const documentSearchOptionsSchema = z.object({
|
||||
|
||||
type DocumentSearchOptions = z.infer<typeof documentSearchOptionsSchema>;
|
||||
|
||||
export type { Document, DocumentUpsert, MetaFilter, MetaCondition, DocumentSearchOptions };
|
||||
export { documentSchema, documentUpsertSchema, metaFilterSchema, metaConditionSchema, documentSearchOptionsSchema };
|
||||
const documentUpsertEventSchema = z.object({
|
||||
action: z.union([z.literal('insert'), z.literal('update'), z.literal('delete')]),
|
||||
document: documentSchema,
|
||||
});
|
||||
|
||||
type DocumentUpsertEvent = z.infer<typeof documentUpsertEventSchema>;
|
||||
|
||||
export type { Document, DocumentUpsert, MetaFilter, MetaCondition, DocumentSearchOptions, DocumentUpsertEvent };
|
||||
export {
|
||||
documentSchema,
|
||||
documentUpsertSchema,
|
||||
metaFilterSchema,
|
||||
metaConditionSchema,
|
||||
documentSearchOptionsSchema,
|
||||
documentUpsertEventSchema,
|
||||
};
|
||||
|
||||
@@ -1,31 +1,53 @@
|
||||
import type { Knex } from 'knex';
|
||||
import { EventEmitter } from 'eventemitter3';
|
||||
|
||||
import { DatabaseService, tableNames, type TableRow } from '../database/database.ts';
|
||||
|
||||
import type { DocumentSearchOptions, DocumentUpsert, MetaCondition, MetaFilter } from './documents.schemas.ts';
|
||||
import type { Document, DocumentSearchOptions, DocumentUpsert, DocumentUpsertEvent } from './documents.schemas.ts';
|
||||
import { buildMetaCondition } from './documents.query.ts';
|
||||
|
||||
import type { Services } from '#root/utils/services.ts';
|
||||
|
||||
class DocumentsService {
|
||||
type DocumentEvents = {
|
||||
upsert: (document: DocumentUpsertEvent) => void;
|
||||
};
|
||||
|
||||
class DocumentsService extends EventEmitter<DocumentEvents> {
|
||||
#services: Services;
|
||||
|
||||
constructor(services: Services) {
|
||||
super();
|
||||
this.#services = services;
|
||||
}
|
||||
|
||||
public upsert = async (document: DocumentUpsert) => {
|
||||
const db = await this.#services.get(DatabaseService).getDb();
|
||||
const baseItem = {
|
||||
...document,
|
||||
updatedAt: new Date(),
|
||||
deletedAt: null,
|
||||
};
|
||||
await db(tableNames.documents)
|
||||
.insert({ ...baseItem, createdAt: new Date() })
|
||||
.onConflict(['uri', 'type'])
|
||||
.merge({
|
||||
...baseItem,
|
||||
const [current] = await db<TableRow['document']>(tableNames.documents)
|
||||
.where({
|
||||
uri: document.uri,
|
||||
type: document.type,
|
||||
})
|
||||
.limit(1);
|
||||
if (current) {
|
||||
const toInsert: Document = {
|
||||
...document,
|
||||
createdAt: current.updatedAt,
|
||||
updatedAt: new Date().toISOString(),
|
||||
deletedAt: null,
|
||||
};
|
||||
await db(tableNames.documents).update(toInsert).where({
|
||||
uri: document.uri,
|
||||
type: document.type,
|
||||
});
|
||||
this.emit('upsert', { action: 'update', document: toInsert });
|
||||
} else {
|
||||
const toInsert: Document = {
|
||||
...document,
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
await db(tableNames.documents).insert(toInsert);
|
||||
this.emit('upsert', { action: 'insert', document: toInsert });
|
||||
}
|
||||
};
|
||||
|
||||
public search = async (options: DocumentSearchOptions) => {
|
||||
@@ -43,7 +65,7 @@ class DocumentsService {
|
||||
}
|
||||
if (meta) {
|
||||
query = query.where((builder) => {
|
||||
this.buildMetaCondition(builder, meta);
|
||||
buildMetaCondition(builder, meta);
|
||||
});
|
||||
}
|
||||
if (offset) {
|
||||
@@ -51,138 +73,6 @@ class DocumentsService {
|
||||
}
|
||||
return query;
|
||||
};
|
||||
|
||||
/**
|
||||
* Recursively builds meta search conditions with proper scoping
|
||||
*/
|
||||
private buildMetaCondition(builder: Knex.QueryBuilder, condition: MetaCondition): void {
|
||||
if (condition.type === 'and') {
|
||||
// Handle AND conditions - all must be true
|
||||
for (const [index, subCondition] of condition.conditions.entries()) {
|
||||
if (index === 0) {
|
||||
// First condition doesn't need andWhere
|
||||
builder.where((subBuilder) => {
|
||||
this.buildMetaCondition(subBuilder, subCondition);
|
||||
});
|
||||
} else {
|
||||
builder.andWhere((subBuilder) => {
|
||||
this.buildMetaCondition(subBuilder, subCondition);
|
||||
});
|
||||
}
|
||||
}
|
||||
} else if (condition.type === 'or') {
|
||||
// Handle OR conditions - at least one must be true
|
||||
for (const [index, subCondition] of condition.conditions.entries()) {
|
||||
if (index === 0) {
|
||||
// First condition doesn't need orWhere
|
||||
builder.where((subBuilder) => {
|
||||
this.buildMetaCondition(subBuilder, subCondition);
|
||||
});
|
||||
} else {
|
||||
builder.orWhere((subBuilder) => {
|
||||
this.buildMetaCondition(subBuilder, subCondition);
|
||||
});
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Handle individual filter conditions
|
||||
this.buildMetaFilter(builder, condition);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds individual meta filter conditions using JSONB operators
|
||||
*/
|
||||
private buildMetaFilter(builder: Knex.QueryBuilder, filter: MetaFilter): void {
|
||||
const fieldPath = `metadata->'${filter.field}'`;
|
||||
|
||||
if (filter.type === 'number') {
|
||||
const { gt, gte, lt, lte, eq, neq, nill } = filter.filter;
|
||||
|
||||
if (nill !== undefined) {
|
||||
if (nill) {
|
||||
// Field doesn't exist or is null
|
||||
builder.where((subBuilder) => {
|
||||
subBuilder
|
||||
.whereRaw(`NOT (metadata ? '${filter.field}')`)
|
||||
.orWhereRaw(`metadata->>'${filter.field}' IS NULL`);
|
||||
});
|
||||
} else {
|
||||
// Field exists and is not null
|
||||
builder.whereRaw(`metadata ? '${filter.field}' AND metadata->>'${filter.field}' IS NOT NULL`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (eq !== undefined) {
|
||||
builder.where(builder.client.raw(`(${fieldPath})::numeric`), eq);
|
||||
}
|
||||
if (neq !== undefined) {
|
||||
builder.where(builder.client.raw(`(${fieldPath})::numeric`), '!=', neq);
|
||||
}
|
||||
if (gt !== undefined) {
|
||||
builder.where(builder.client.raw(`(${fieldPath})::numeric`), '>', gt);
|
||||
}
|
||||
if (gte !== undefined) {
|
||||
builder.where(builder.client.raw(`(${fieldPath})::numeric`), '>=', gte);
|
||||
}
|
||||
if (lt !== undefined) {
|
||||
builder.where(builder.client.raw(`(${fieldPath})::numeric`), '<', lt);
|
||||
}
|
||||
if (lte !== undefined) {
|
||||
builder.where(builder.client.raw(`(${fieldPath})::numeric`), '<=', lte);
|
||||
}
|
||||
} else if (filter.type === 'text') {
|
||||
const { eq, neq, like, nlike, nill } = filter.filter;
|
||||
|
||||
if (nill !== undefined) {
|
||||
if (nill) {
|
||||
// Field doesn't exist or is null
|
||||
builder.where((subBuilder) => {
|
||||
subBuilder
|
||||
.whereRaw(`NOT (metadata ? '${filter.field}')`)
|
||||
.orWhereRaw(`metadata->>'${filter.field}' IS NULL`);
|
||||
});
|
||||
} else {
|
||||
// Field exists and is not null
|
||||
builder.whereRaw(`metadata ? '${filter.field}' AND metadata->>'${filter.field}' IS NOT NULL`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (eq !== undefined) {
|
||||
builder.where(builder.client.raw(`metadata->>'${filter.field}'`), eq);
|
||||
}
|
||||
if (neq !== undefined) {
|
||||
builder.where(builder.client.raw(`metadata->>'${filter.field}'`), '!=', neq);
|
||||
}
|
||||
if (like !== undefined) {
|
||||
builder.where(builder.client.raw(`metadata->>'${filter.field}'`), 'like', like);
|
||||
}
|
||||
if (nlike !== undefined) {
|
||||
builder.where(builder.client.raw(`metadata->>'${filter.field}'`), 'not like', nlike);
|
||||
}
|
||||
} else if (filter.type === 'bool') {
|
||||
const { eq, nill } = filter.filter;
|
||||
|
||||
if (nill !== undefined) {
|
||||
if (nill) {
|
||||
// Field doesn't exist or is null
|
||||
builder.where((subBuilder) => {
|
||||
subBuilder
|
||||
.whereRaw(`NOT (metadata ? '${filter.field}')`)
|
||||
.orWhereRaw(`metadata->>'${filter.field}' IS NULL`);
|
||||
});
|
||||
} else {
|
||||
// Field exists and is not null
|
||||
builder.whereRaw(`metadata ? '${filter.field}' AND metadata->>'${filter.field}' IS NOT NULL`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
builder.where(builder.client.raw(`(${fieldPath})::boolean`), eq);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export { DocumentsService };
|
||||
|
||||
50
packages/core/src/utils/tests.ts
Normal file
50
packages/core/src/utils/tests.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import { it as vitestIt } from 'vitest';
|
||||
|
||||
type TimedTestOptions = {
|
||||
logTiming?: boolean;
|
||||
threshold?: number; // Log only if test takes longer than this (in ms)
|
||||
};
|
||||
|
||||
/**
|
||||
* A wrapper around vitest's `it` function that measures and logs the execution time
|
||||
* of just the test body (excluding beforeEach/afterEach hooks).
|
||||
*/
|
||||
export function itWithTiming(name: string, fn: () => void | Promise<void>, options: TimedTestOptions = {}) {
|
||||
const { logTiming = true, threshold = 0 } = options;
|
||||
|
||||
return vitestIt(name, async () => {
|
||||
const startTime = performance.now();
|
||||
|
||||
try {
|
||||
await fn();
|
||||
} finally {
|
||||
const endTime = performance.now();
|
||||
const duration = endTime - startTime;
|
||||
|
||||
if (logTiming && duration >= threshold) {
|
||||
console.log(`⏱️ Test "${name}": ${duration.toFixed(2)}ms (body only)`);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Like timedTest, but only logs timing for slow tests (configurable threshold)
|
||||
*/
|
||||
export function slowTest(name: string, fn: () => void | Promise<void>, thresholdMs = 100) {
|
||||
return itWithTiming(name, fn, { logTiming: true, threshold: thresholdMs });
|
||||
}
|
||||
|
||||
/**
|
||||
* Measures execution time of a specific code block within a test
|
||||
*/
|
||||
export async function measureTime<T>(label: string, fn: () => T | Promise<T>): Promise<T> {
|
||||
const startTime = performance.now();
|
||||
try {
|
||||
return await fn();
|
||||
} finally {
|
||||
const endTime = performance.now();
|
||||
const duration = endTime - startTime;
|
||||
console.log(`⏱️ ${label}: ${duration.toFixed(2)}ms`);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user