Skip to content

Commit 93ce2e9

Browse files
committed
feat: implement CREATE INDEX statement and associated functionality
1 parent ff356b8 commit 93ce2e9

6 files changed

Lines changed: 175 additions & 38 deletions

File tree

src/qql/ast_nodes.py

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -145,6 +145,13 @@ class CreateCollectionStmt:
145145
model: str | None = None # dense model; None → use config default
146146

147147

148+
@dataclass(frozen=True)
149+
class CreateIndexStmt:
150+
collection: str
151+
field_name: str
152+
schema: str
153+
154+
148155
@dataclass(frozen=True)
149156
class DropCollectionStmt:
150157
collection: str
@@ -188,14 +195,16 @@ class RecommendStmt:
188195
@dataclass(frozen=True)
189196
class DeleteStmt:
190197
collection: str
191-
point_id: str | int
198+
point_id: str | int | None = None
199+
query_filter: FilterExpr | None = None
192200

193201

194202
# Union type for all top-level statement nodes
195203
ASTNode = (
196204
InsertStmt
197205
| InsertBulkStmt
198206
| CreateCollectionStmt
207+
| CreateIndexStmt
199208
| DropCollectionStmt
200209
| ShowCollectionsStmt
201210
| SearchStmt

src/qql/executor.py

Lines changed: 59 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@
2626
MatchValue,
2727
Modifier,
2828
PayloadField,
29+
PayloadSchemaType,
2930
PointStruct,
3031
Prefetch,
3132
Range,
@@ -44,6 +45,7 @@
4445
BetweenExpr,
4546
CompareExpr,
4647
CreateCollectionStmt,
48+
CreateIndexStmt,
4749
DeleteStmt,
4850
DropCollectionStmt,
4951
FilterExpr,
@@ -93,6 +95,8 @@ def execute(self, node: ASTNode) -> ExecutionResult:
9395
return self._execute_insert(node)
9496
if isinstance(node, CreateCollectionStmt):
9597
return self._execute_create(node)
98+
if isinstance(node, CreateIndexStmt):
99+
return self._execute_create_index(node)
96100
if isinstance(node, DropCollectionStmt):
97101
return self._execute_drop(node)
98102
if isinstance(node, ShowCollectionsStmt):
@@ -321,6 +325,43 @@ def _execute_create(self, node: CreateCollectionStmt) -> ExecutionResult:
321325
message=f"Collection '{node.collection}' created ({dims}-dimensional vectors, cosine distance)",
322326
)
323327

328+
def _execute_create_index(self, node: CreateIndexStmt) -> ExecutionResult:
329+
if not self._client.collection_exists(node.collection):
330+
raise QQLRuntimeError(f"Collection '{node.collection}' does not exist")
331+
332+
schema_map = {
333+
"keyword": PayloadSchemaType.KEYWORD,
334+
"integer": PayloadSchemaType.INTEGER,
335+
"float": PayloadSchemaType.FLOAT,
336+
"bool": PayloadSchemaType.BOOL,
337+
"text": PayloadSchemaType.TEXT,
338+
"geo": PayloadSchemaType.GEO,
339+
"datetime": PayloadSchemaType.DATETIME,
340+
}
341+
try:
342+
field_schema = schema_map[node.schema]
343+
except KeyError as e:
344+
raise QQLRuntimeError(
345+
"Unknown index type '"
346+
f"{node.schema}'. Expected one of: keyword, integer, float, bool, text, geo, datetime"
347+
) from e
348+
349+
try:
350+
self._client.create_payload_index(
351+
collection_name=node.collection,
352+
field_name=node.field_name,
353+
field_schema=field_schema,
354+
)
355+
except UnexpectedResponse as e:
356+
raise QQLRuntimeError(f"Qdrant error during CREATE INDEX: {e}") from e
357+
358+
return ExecutionResult(
359+
success=True,
360+
message=(
361+
f"Created index on '{node.collection}.{node.field_name}' as '{node.schema}'"
362+
),
363+
)
364+
324365
def _execute_drop(self, node: DropCollectionStmt) -> ExecutionResult:
325366
if not self._client.collection_exists(node.collection):
326367
raise QQLRuntimeError(f"Collection '{node.collection}' does not exist")
@@ -648,9 +689,25 @@ def _execute_delete(self, node: DeleteStmt) -> ExecutionResult:
648689
if not self._client.collection_exists(node.collection):
649690
raise QQLRuntimeError(f"Collection '{node.collection}' does not exist")
650691

651-
from qdrant_client.models import PointIdsList
652-
653692
try:
693+
if node.query_filter is not None:
694+
self._client.delete(
695+
collection_name=node.collection,
696+
wait=True,
697+
points_selector=self._wrap_as_filter(
698+
self._build_qdrant_filter(node.query_filter)
699+
),
700+
)
701+
return ExecutionResult(
702+
success=True,
703+
message=f"Deleted points from '{node.collection}' by filter",
704+
)
705+
706+
from qdrant_client.models import PointIdsList
707+
708+
if node.point_id is None:
709+
raise QQLRuntimeError("DELETE requires either a point id or a filter")
710+
654711
self._client.delete(
655712
collection_name=node.collection,
656713
wait=True,

src/qql/lexer.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,8 @@ class TokenKind(Enum):
2121
WITH = auto()
2222
ACORN = auto()
2323
CREATE = auto()
24+
INDEX = auto()
25+
ON = auto()
2426
DROP = auto()
2527
SHOW = auto()
2628
COLLECTIONS = auto()
@@ -42,6 +44,8 @@ class TokenKind(Enum):
4244
FROM = auto()
4345
WHERE = auto()
4446
ID = auto()
47+
FOR = auto()
48+
TYPE = auto()
4549
# ── Filter keywords ───────────────────────────────────────────────────
4650
AND = auto()
4751
OR = auto()
@@ -96,6 +100,8 @@ class TokenKind(Enum):
96100
"WITH": TokenKind.WITH,
97101
"ACORN": TokenKind.ACORN,
98102
"CREATE": TokenKind.CREATE,
103+
"INDEX": TokenKind.INDEX,
104+
"ON": TokenKind.ON,
99105
"DROP": TokenKind.DROP,
100106
"SHOW": TokenKind.SHOW,
101107
"COLLECTIONS": TokenKind.COLLECTIONS,
@@ -117,6 +123,8 @@ class TokenKind(Enum):
117123
"FROM": TokenKind.FROM,
118124
"WHERE": TokenKind.WHERE,
119125
"ID": TokenKind.ID,
126+
"FOR": TokenKind.FOR,
127+
"TYPE": TokenKind.TYPE,
120128
# Filter keywords
121129
"AND": TokenKind.AND,
122130
"OR": TokenKind.OR,

src/qql/parser.py

Lines changed: 51 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
BetweenExpr,
77
CompareExpr,
88
CreateCollectionStmt,
9+
CreateIndexStmt,
910
DeleteStmt,
1011
DropCollectionStmt,
1112
FilterExpr,
@@ -150,34 +151,45 @@ def _parse_insert_bulk_body(self) -> InsertBulkStmt:
150151

151152
def _parse_create(self) -> CreateCollectionStmt:
152153
self._expect(TokenKind.CREATE)
153-
self._expect(TokenKind.COLLECTION)
154-
collection = self._parse_identifier()
155-
hybrid: bool = False
156-
model: str | None = None
157-
158-
if self._peek().kind == TokenKind.HYBRID:
159-
# Bare HYBRID shorthand — backward compat
154+
if self._peek().kind == TokenKind.COLLECTION:
160155
self._advance()
161-
hybrid = True
162-
elif self._peek().kind == TokenKind.USING:
163-
self._advance() # consume USING
156+
collection = self._parse_identifier()
157+
hybrid: bool = False
158+
model: str | None = None
159+
164160
if self._peek().kind == TokenKind.HYBRID:
165-
self._advance() # consume HYBRID
161+
# Bare HYBRID shorthand — backward compat
162+
self._advance()
166163
hybrid = True
167-
# Optional DENSE MODEL sub-clause
168-
if self._peek().kind == TokenKind.DENSE:
169-
self._advance() # consume DENSE
164+
elif self._peek().kind == TokenKind.USING:
165+
self._advance() # consume USING
166+
if self._peek().kind == TokenKind.HYBRID:
167+
self._advance() # consume HYBRID
168+
hybrid = True
169+
# Optional DENSE MODEL sub-clause
170+
if self._peek().kind == TokenKind.DENSE:
171+
self._advance() # consume DENSE
172+
self._expect(TokenKind.MODEL)
173+
model = self._expect(TokenKind.STRING).value
174+
else:
170175
self._expect(TokenKind.MODEL)
171176
model = self._expect(TokenKind.STRING).value
172-
else:
173-
self._expect(TokenKind.MODEL)
174-
model = self._expect(TokenKind.STRING).value
175177

176-
return CreateCollectionStmt(
177-
collection=collection,
178-
hybrid=hybrid,
179-
model=model,
180-
)
178+
return CreateCollectionStmt(
179+
collection=collection,
180+
hybrid=hybrid,
181+
model=model,
182+
)
183+
184+
self._expect(TokenKind.INDEX)
185+
self._expect(TokenKind.ON)
186+
self._expect(TokenKind.COLLECTION)
187+
collection = self._parse_identifier()
188+
self._expect(TokenKind.FOR)
189+
field_name = self._parse_field_path()
190+
self._expect(TokenKind.TYPE)
191+
schema = self._expect(TokenKind.IDENTIFIER).value.lower()
192+
return CreateIndexStmt(collection=collection, field_name=field_name, schema=schema)
181193

182194
def _parse_drop(self) -> DropCollectionStmt:
183195
self._expect(TokenKind.DROP)
@@ -356,20 +368,24 @@ def _parse_delete(self) -> DeleteStmt:
356368
self._expect(TokenKind.FROM)
357369
collection = self._parse_identifier()
358370
self._expect(TokenKind.WHERE)
359-
self._expect(TokenKind.ID)
360-
self._expect(TokenKind.EQUALS)
361-
tok = self._peek()
362-
if tok.kind == TokenKind.STRING:
363-
self._advance()
364-
point_id: str | int = tok.value
365-
elif tok.kind == TokenKind.INTEGER:
371+
if self._peek().kind == TokenKind.ID:
366372
self._advance()
367-
point_id = int(tok.value)
368-
else:
369-
raise QQLSyntaxError(
370-
f"Expected string or integer for point id, got '{tok.value}'", tok.pos
371-
)
372-
return DeleteStmt(collection=collection, point_id=point_id)
373+
self._expect(TokenKind.EQUALS)
374+
tok = self._peek()
375+
if tok.kind == TokenKind.STRING:
376+
self._advance()
377+
point_id: str | int = tok.value
378+
elif tok.kind == TokenKind.INTEGER:
379+
self._advance()
380+
point_id = int(tok.value)
381+
else:
382+
raise QQLSyntaxError(
383+
f"Expected string or integer for point id, got '{tok.value}'", tok.pos
384+
)
385+
return DeleteStmt(collection=collection, point_id=point_id)
386+
387+
query_filter = self._parse_filter_expr()
388+
return DeleteStmt(collection=collection, query_filter=query_filter)
373389

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

tests/test_executor.py

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
from qql.ast_nodes import (
44
CreateCollectionStmt,
5+
CreateIndexStmt,
56
DeleteStmt,
67
DropCollectionStmt,
78
InsertBulkStmt,
@@ -246,6 +247,21 @@ def test_create_existing_collection_is_noop(self, executor, mock_client):
246247
assert "already exists" in result.message
247248

248249

250+
class TestCreateIndex:
251+
def test_create_index_calls_qdrant(self, executor, mock_client):
252+
mock_client.collection_exists.return_value = True
253+
node = CreateIndexStmt(collection="articles", field_name="category", schema="keyword")
254+
result = executor.execute(node)
255+
mock_client.create_payload_index.assert_called_once()
256+
assert result.success is True
257+
258+
def test_create_index_nonexistent_collection_raises(self, executor, mock_client):
259+
mock_client.collection_exists.return_value = False
260+
node = CreateIndexStmt(collection="ghost", field_name="category", schema="keyword")
261+
with pytest.raises(QQLRuntimeError, match="does not exist"):
262+
executor.execute(node)
263+
264+
249265
class TestCreateWithModel:
250266
def test_create_with_model_passes_model_to_embedder(self, mock_client, cfg, mocker):
251267
mock_emb = mocker.MagicMock()
@@ -630,6 +646,20 @@ def test_delete_calls_qdrant_delete(self, executor, mock_client):
630646
mock_client.delete.assert_called_once()
631647
assert result.success is True
632648

649+
def test_delete_by_filter_calls_qdrant_delete_with_filter(self, executor, mock_client):
650+
from qdrant_client.models import Filter
651+
from qql.ast_nodes import CompareExpr
652+
653+
mock_client.collection_exists.return_value = True
654+
node = DeleteStmt(
655+
collection="articles",
656+
query_filter=CompareExpr(field="category", op="=", value="archived"),
657+
)
658+
result = executor.execute(node)
659+
selector = mock_client.delete.call_args.kwargs["points_selector"]
660+
assert isinstance(selector, Filter)
661+
assert result.success is True
662+
633663
def test_delete_nonexistent_collection_raises(self, executor, mock_client):
634664
mock_client.collection_exists.return_value = False
635665
node = DeleteStmt(collection="ghost", point_id="x")

tests/test_parser.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
BetweenExpr,
66
CompareExpr,
77
CreateCollectionStmt,
8+
CreateIndexStmt,
89
DeleteStmt,
910
DropCollectionStmt,
1011
InExpr,
@@ -165,6 +166,13 @@ def test_create_collection(self):
165166
assert isinstance(node, CreateCollectionStmt)
166167
assert node.collection == "my_col"
167168

169+
def test_create_index(self):
170+
node = parse("CREATE INDEX ON COLLECTION articles FOR category TYPE keyword")
171+
assert isinstance(node, CreateIndexStmt)
172+
assert node.collection == "articles"
173+
assert node.field_name == "category"
174+
assert node.schema == "keyword"
175+
168176

169177
class TestDrop:
170178
def test_drop_collection(self):
@@ -199,12 +207,21 @@ def test_delete_by_string_id(self):
199207
assert isinstance(node, DeleteStmt)
200208
assert node.collection == "notes"
201209
assert node.point_id == "abc-123"
210+
assert node.query_filter is None
202211

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

217+
def test_delete_by_filter(self):
218+
node = parse("DELETE FROM articles WHERE category = 'archived'")
219+
assert isinstance(node, DeleteStmt)
220+
assert node.point_id is None
221+
assert isinstance(node.query_filter, CompareExpr)
222+
assert node.query_filter.field == "category"
223+
assert node.query_filter.value == "archived"
224+
208225

209226
class TestRecommend:
210227
def test_recommend_with_positive_ids(self):

0 commit comments

Comments
 (0)