Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 10 additions & 1 deletion src/qql/ast_nodes.py
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,13 @@ class CreateCollectionStmt:
model: str | None = None # dense model; None → use config default


@dataclass(frozen=True)
class CreateIndexStmt:
collection: str
field_name: str
schema: str


@dataclass(frozen=True)
class DropCollectionStmt:
collection: str
Expand Down Expand Up @@ -188,14 +195,16 @@ class RecommendStmt:
@dataclass(frozen=True)
class DeleteStmt:
collection: str
point_id: str | int
point_id: str | int | None = None
query_filter: FilterExpr | None = None


# Union type for all top-level statement nodes
ASTNode = (
InsertStmt
| InsertBulkStmt
| CreateCollectionStmt
| CreateIndexStmt
| DropCollectionStmt
| ShowCollectionsStmt
| SearchStmt
Expand Down
61 changes: 59 additions & 2 deletions src/qql/executor.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
MatchValue,
Modifier,
PayloadField,
PayloadSchemaType,
PointStruct,
Prefetch,
Range,
Expand All @@ -44,6 +45,7 @@
BetweenExpr,
CompareExpr,
CreateCollectionStmt,
CreateIndexStmt,
DeleteStmt,
DropCollectionStmt,
FilterExpr,
Expand Down Expand Up @@ -93,6 +95,8 @@ def execute(self, node: ASTNode) -> ExecutionResult:
return self._execute_insert(node)
if isinstance(node, CreateCollectionStmt):
return self._execute_create(node)
if isinstance(node, CreateIndexStmt):
return self._execute_create_index(node)
if isinstance(node, DropCollectionStmt):
return self._execute_drop(node)
if isinstance(node, ShowCollectionsStmt):
Expand Down Expand Up @@ -321,6 +325,43 @@ def _execute_create(self, node: CreateCollectionStmt) -> ExecutionResult:
message=f"Collection '{node.collection}' created ({dims}-dimensional vectors, cosine distance)",
)

def _execute_create_index(self, node: CreateIndexStmt) -> ExecutionResult:
if not self._client.collection_exists(node.collection):
raise QQLRuntimeError(f"Collection '{node.collection}' does not exist")

schema_map = {
"keyword": PayloadSchemaType.KEYWORD,
"integer": PayloadSchemaType.INTEGER,
"float": PayloadSchemaType.FLOAT,
"bool": PayloadSchemaType.BOOL,
"text": PayloadSchemaType.TEXT,
"geo": PayloadSchemaType.GEO,
"datetime": PayloadSchemaType.DATETIME,
}
try:
field_schema = schema_map[node.schema]
except KeyError as e:
raise QQLRuntimeError(
"Unknown index type '"
f"{node.schema}'. Expected one of: keyword, integer, float, bool, text, geo, datetime"
) from e

try:
self._client.create_payload_index(
collection_name=node.collection,
field_name=node.field_name,
field_schema=field_schema,
)
except UnexpectedResponse as e:
raise QQLRuntimeError(f"Qdrant error during CREATE INDEX: {e}") from e

return ExecutionResult(
success=True,
message=(
f"Created index on '{node.collection}.{node.field_name}' as '{node.schema}'"
),
)

def _execute_drop(self, node: DropCollectionStmt) -> ExecutionResult:
if not self._client.collection_exists(node.collection):
raise QQLRuntimeError(f"Collection '{node.collection}' does not exist")
Expand Down Expand Up @@ -648,9 +689,25 @@ def _execute_delete(self, node: DeleteStmt) -> ExecutionResult:
if not self._client.collection_exists(node.collection):
raise QQLRuntimeError(f"Collection '{node.collection}' does not exist")

from qdrant_client.models import PointIdsList

try:
if node.query_filter is not None:
self._client.delete(
collection_name=node.collection,
wait=True,
points_selector=self._wrap_as_filter(
self._build_qdrant_filter(node.query_filter)
),
)
return ExecutionResult(
success=True,
message=f"Deleted points from '{node.collection}' by filter",
)

from qdrant_client.models import PointIdsList

if node.point_id is None:
raise QQLRuntimeError("DELETE requires either a point id or a filter")

self._client.delete(
collection_name=node.collection,
wait=True,
Expand Down
8 changes: 8 additions & 0 deletions src/qql/lexer.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@ class TokenKind(Enum):
WITH = auto()
ACORN = auto()
CREATE = auto()
INDEX = auto()
ON = auto()
DROP = auto()
SHOW = auto()
COLLECTIONS = auto()
Expand All @@ -42,6 +44,8 @@ class TokenKind(Enum):
FROM = auto()
WHERE = auto()
ID = auto()
FOR = auto()
TYPE = auto()
# ── Filter keywords ───────────────────────────────────────────────────
AND = auto()
OR = auto()
Expand Down Expand Up @@ -96,6 +100,8 @@ class TokenKind(Enum):
"WITH": TokenKind.WITH,
"ACORN": TokenKind.ACORN,
"CREATE": TokenKind.CREATE,
"INDEX": TokenKind.INDEX,
"ON": TokenKind.ON,
"DROP": TokenKind.DROP,
"SHOW": TokenKind.SHOW,
"COLLECTIONS": TokenKind.COLLECTIONS,
Expand All @@ -117,6 +123,8 @@ class TokenKind(Enum):
"FROM": TokenKind.FROM,
"WHERE": TokenKind.WHERE,
"ID": TokenKind.ID,
"FOR": TokenKind.FOR,
"TYPE": TokenKind.TYPE,
# Filter keywords
"AND": TokenKind.AND,
"OR": TokenKind.OR,
Expand Down
86 changes: 51 additions & 35 deletions src/qql/parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
BetweenExpr,
CompareExpr,
CreateCollectionStmt,
CreateIndexStmt,
DeleteStmt,
DropCollectionStmt,
FilterExpr,
Expand Down Expand Up @@ -150,34 +151,45 @@ def _parse_insert_bulk_body(self) -> InsertBulkStmt:

def _parse_create(self) -> CreateCollectionStmt:
self._expect(TokenKind.CREATE)
self._expect(TokenKind.COLLECTION)
collection = self._parse_identifier()
hybrid: bool = False
model: str | None = None

if self._peek().kind == TokenKind.HYBRID:
# Bare HYBRID shorthand — backward compat
if self._peek().kind == TokenKind.COLLECTION:
self._advance()
hybrid = True
elif self._peek().kind == TokenKind.USING:
self._advance() # consume USING
collection = self._parse_identifier()
hybrid: bool = False
model: str | None = None

if self._peek().kind == TokenKind.HYBRID:
self._advance() # consume HYBRID
# Bare HYBRID shorthand — backward compat
self._advance()
hybrid = True
# Optional DENSE MODEL sub-clause
if self._peek().kind == TokenKind.DENSE:
self._advance() # consume DENSE
elif self._peek().kind == TokenKind.USING:
self._advance() # consume USING
if self._peek().kind == TokenKind.HYBRID:
self._advance() # consume HYBRID
hybrid = True
# Optional DENSE MODEL sub-clause
if self._peek().kind == TokenKind.DENSE:
self._advance() # consume DENSE
self._expect(TokenKind.MODEL)
model = self._expect(TokenKind.STRING).value
else:
self._expect(TokenKind.MODEL)
model = self._expect(TokenKind.STRING).value
else:
self._expect(TokenKind.MODEL)
model = self._expect(TokenKind.STRING).value

return CreateCollectionStmt(
collection=collection,
hybrid=hybrid,
model=model,
)
return CreateCollectionStmt(
collection=collection,
hybrid=hybrid,
model=model,
)

self._expect(TokenKind.INDEX)
self._expect(TokenKind.ON)
self._expect(TokenKind.COLLECTION)
collection = self._parse_identifier()
self._expect(TokenKind.FOR)
field_name = self._parse_field_path()
self._expect(TokenKind.TYPE)
schema = self._expect(TokenKind.IDENTIFIER).value.lower()
return CreateIndexStmt(collection=collection, field_name=field_name, schema=schema)

def _parse_drop(self) -> DropCollectionStmt:
self._expect(TokenKind.DROP)
Expand Down Expand Up @@ -356,20 +368,24 @@ def _parse_delete(self) -> DeleteStmt:
self._expect(TokenKind.FROM)
collection = self._parse_identifier()
self._expect(TokenKind.WHERE)
self._expect(TokenKind.ID)
self._expect(TokenKind.EQUALS)
tok = self._peek()
if tok.kind == TokenKind.STRING:
self._advance()
point_id: str | int = tok.value
elif tok.kind == TokenKind.INTEGER:
if self._peek().kind == TokenKind.ID:
self._advance()
point_id = int(tok.value)
else:
raise QQLSyntaxError(
f"Expected string or integer for point id, got '{tok.value}'", tok.pos
)
return DeleteStmt(collection=collection, point_id=point_id)
self._expect(TokenKind.EQUALS)
tok = self._peek()
if tok.kind == TokenKind.STRING:
self._advance()
point_id: str | int = tok.value
elif tok.kind == TokenKind.INTEGER:
self._advance()
point_id = int(tok.value)
else:
raise QQLSyntaxError(
f"Expected string or integer for point id, got '{tok.value}'", tok.pos
)
return DeleteStmt(collection=collection, point_id=point_id)

query_filter = self._parse_filter_expr()
return DeleteStmt(collection=collection, query_filter=query_filter)

# ── WHERE clause filter parsing (precedence: NOT > AND > OR) ─────────

Expand Down
30 changes: 30 additions & 0 deletions tests/test_executor.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

from qql.ast_nodes import (
CreateCollectionStmt,
CreateIndexStmt,
DeleteStmt,
DropCollectionStmt,
InsertBulkStmt,
Expand Down Expand Up @@ -246,6 +247,21 @@ def test_create_existing_collection_is_noop(self, executor, mock_client):
assert "already exists" in result.message


class TestCreateIndex:
def test_create_index_calls_qdrant(self, executor, mock_client):
mock_client.collection_exists.return_value = True
node = CreateIndexStmt(collection="articles", field_name="category", schema="keyword")
result = executor.execute(node)
mock_client.create_payload_index.assert_called_once()
assert result.success is True

def test_create_index_nonexistent_collection_raises(self, executor, mock_client):
mock_client.collection_exists.return_value = False
node = CreateIndexStmt(collection="ghost", field_name="category", schema="keyword")
with pytest.raises(QQLRuntimeError, match="does not exist"):
executor.execute(node)


class TestCreateWithModel:
def test_create_with_model_passes_model_to_embedder(self, mock_client, cfg, mocker):
mock_emb = mocker.MagicMock()
Expand Down Expand Up @@ -630,6 +646,20 @@ def test_delete_calls_qdrant_delete(self, executor, mock_client):
mock_client.delete.assert_called_once()
assert result.success is True

def test_delete_by_filter_calls_qdrant_delete_with_filter(self, executor, mock_client):
from qdrant_client.models import Filter
from qql.ast_nodes import CompareExpr

mock_client.collection_exists.return_value = True
node = DeleteStmt(
collection="articles",
query_filter=CompareExpr(field="category", op="=", value="archived"),
)
result = executor.execute(node)
selector = mock_client.delete.call_args.kwargs["points_selector"]
assert isinstance(selector, Filter)
assert result.success is True

def test_delete_nonexistent_collection_raises(self, executor, mock_client):
mock_client.collection_exists.return_value = False
node = DeleteStmt(collection="ghost", point_id="x")
Expand Down
17 changes: 17 additions & 0 deletions tests/test_parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
BetweenExpr,
CompareExpr,
CreateCollectionStmt,
CreateIndexStmt,
DeleteStmt,
DropCollectionStmt,
InExpr,
Expand Down Expand Up @@ -165,6 +166,13 @@ def test_create_collection(self):
assert isinstance(node, CreateCollectionStmt)
assert node.collection == "my_col"

def test_create_index(self):
node = parse("CREATE INDEX ON COLLECTION articles FOR category TYPE keyword")
assert isinstance(node, CreateIndexStmt)
assert node.collection == "articles"
assert node.field_name == "category"
assert node.schema == "keyword"


class TestDrop:
def test_drop_collection(self):
Expand Down Expand Up @@ -199,12 +207,21 @@ def test_delete_by_string_id(self):
assert isinstance(node, DeleteStmt)
assert node.collection == "notes"
assert node.point_id == "abc-123"
assert node.query_filter is None

def test_delete_by_integer_id(self):
node = parse("DELETE FROM notes WHERE id = 99")
assert isinstance(node, DeleteStmt)
assert node.point_id == 99

def test_delete_by_filter(self):
node = parse("DELETE FROM articles WHERE category = 'archived'")
assert isinstance(node, DeleteStmt)
assert node.point_id is None
assert isinstance(node.query_filter, CompareExpr)
assert node.query_filter.field == "category"
assert node.query_filter.value == "archived"


class TestRecommend:
def test_recommend_with_positive_ids(self):
Expand Down
Loading