synapseone-sdk 0.7.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
synapseone_sdk/base.py ADDED
@@ -0,0 +1,744 @@
1
+ # Copyright 2026 SynapseOne Contributors
2
+ # Licensed under the Apache License, Version 2.0 (the "License");
3
+ """SynapseBase client: collections, documents, queries, batch writes, atomic
4
+ counters, a change feed and typed object <-> data mapping.
5
+
6
+ The service stores raw JSON ``data``; a collection may declare a *mapping*
7
+ from logical field names to dot paths inside that data. Writes can send
8
+ ``object`` (logical keys) and reads can ask ``as_view="object"`` — the service
9
+ converts in both directions. :class:`BaseCollection` is a small typed wrapper
10
+ that binds one collection to a dataclass/model so application code works with
11
+ objects instead of JSON.
12
+
13
+ Access the service through ``SynapseOneClient(...).base`` or build it directly
14
+ with a :class:`~synapseone_sdk._http.HttpClient`.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import dataclasses
20
+ from dataclasses import dataclass
21
+ from typing import Any
22
+ from urllib.parse import quote, urlencode
23
+
24
+ from ._http import UNSET, HttpClient
25
+
26
+ # ---------------------------------------------------------------------------
27
+ # mapping helpers (client-side mirror of the server's object<->data mapping)
28
+ # ---------------------------------------------------------------------------
29
+
30
+
31
+ def normalize_mapping(mapping: Any | None) -> dict[str, dict]:
32
+ """Normalize a base mapping to ``{logical: {"path", "type", "required"}}``.
33
+
34
+ Accepts the canonical ``{"fields": {...}}`` form as well as the shorthand
35
+ ``{logical: "dot.path"}`` / ``{logical: {"path": ...}}`` maps.
36
+ """
37
+ if not mapping:
38
+ return {}
39
+ fields = (
40
+ mapping.get("fields")
41
+ if isinstance(mapping, dict) and isinstance(mapping.get("fields"), dict)
42
+ else mapping
43
+ )
44
+ if not isinstance(fields, dict):
45
+ return {}
46
+ out: dict[str, dict] = {}
47
+ for name, spec in fields.items():
48
+ if isinstance(spec, str):
49
+ out[str(name)] = {"path": spec}
50
+ elif isinstance(spec, dict):
51
+ out[str(name)] = dict(spec)
52
+ return out
53
+
54
+
55
+ def _get_path(data: Any, path: str) -> Any:
56
+ cur = data
57
+ for key in str(path).split("."):
58
+ if not isinstance(cur, dict) or key not in cur:
59
+ return UNSET
60
+ cur = cur[key]
61
+ return cur
62
+
63
+
64
+ def _set_path(target: dict, path: str, value: Any) -> None:
65
+ segments = str(path).split(".")
66
+ cur = target
67
+ for key in segments[:-1]:
68
+ if not isinstance(cur.get(key), dict):
69
+ cur[key] = {}
70
+ cur = cur[key]
71
+ cur[segments[-1]] = value
72
+
73
+
74
+ def data_to_object(data: dict | None, mapping: Any | None) -> dict:
75
+ """Project stored ``data`` onto logical field names (empty mapping = copy)."""
76
+ fields = normalize_mapping(mapping)
77
+ if not fields:
78
+ return dict(data or {})
79
+ out: dict = {}
80
+ for name, spec in fields.items():
81
+ value = _get_path(data or {}, spec.get("path", ""))
82
+ if value is not UNSET:
83
+ out[name] = value
84
+ return out
85
+
86
+
87
+ def object_to_data(obj: dict | None, mapping: Any | None) -> dict:
88
+ """Project a logical object onto stored ``data`` (empty mapping = copy)."""
89
+ fields = normalize_mapping(mapping)
90
+ out: dict = {}
91
+ for key, value in dict(obj or {}).items():
92
+ spec = fields.get(key)
93
+ _set_path(out, spec["path"] if spec else key, value)
94
+ return out
95
+
96
+
97
+ def model_to_object(instance: Any) -> dict:
98
+ """Convert a model instance (dataclass/class/dict) to a logical object."""
99
+ if instance is None:
100
+ raise ValueError("instance is required")
101
+ if isinstance(instance, dict):
102
+ return dict(instance)
103
+ if dataclasses.is_dataclass(instance) and not isinstance(instance, type):
104
+ return {
105
+ f.name: getattr(instance, f.name)
106
+ for f in dataclasses.fields(instance)
107
+ if getattr(instance, f.name) is not None
108
+ }
109
+ if hasattr(instance, "__dict__"):
110
+ return {
111
+ k: v
112
+ for k, v in vars(instance).items()
113
+ if not k.startswith("_") and v is not None
114
+ }
115
+ raise TypeError("model instances must be dataclasses, plain classes or dicts")
116
+
117
+
118
+ def object_to_model(obj: dict | None, model: type | None) -> Any:
119
+ """Build a model instance from a logical object (None model = pass through)."""
120
+ data = dict(obj or {})
121
+ if model is None:
122
+ return data
123
+ if dataclasses.is_dataclass(model):
124
+ kwargs: dict = {}
125
+ for f in dataclasses.fields(model):
126
+ if f.name in data:
127
+ kwargs[f.name] = data[f.name]
128
+ return model(**kwargs)
129
+ return model(**data)
130
+
131
+
132
+ # ---------------------------------------------------------------------------
133
+ # service
134
+ # ---------------------------------------------------------------------------
135
+
136
+
137
+ @dataclass
138
+ class QueryResult:
139
+ """Result of :meth:`BaseService.query_documents` / :meth:`BaseCollection.query`."""
140
+
141
+ items: list
142
+ total: int
143
+ limit: int
144
+ offset: int
145
+ next_cursor: str | None = None
146
+
147
+ def __len__(self) -> int:
148
+ return len(self.items)
149
+
150
+ def __iter__(self):
151
+ return iter(self.items)
152
+
153
+
154
+ @dataclass
155
+ class VectorSearchResult:
156
+ """Result of :meth:`BaseService.vector_search` / :meth:`BaseCollection.vector_search`."""
157
+
158
+ items: list
159
+ total: int
160
+ limit: int
161
+ metric: str
162
+
163
+ def __len__(self) -> int:
164
+ return len(self.items)
165
+
166
+ def __iter__(self):
167
+ return iter(self.items)
168
+
169
+
170
+ class BaseService:
171
+ """Typed methods for every ``/v1/base`` endpoint exposed by the gateway.
172
+
173
+ The API accepts raw ``data`` (the stored JSON document) or logical
174
+ ``object`` payloads; pass exactly one of the two on writes.
175
+ """
176
+
177
+ def __init__(self, http: HttpClient) -> None:
178
+ self._http = http
179
+
180
+ def _req(self, method: str, path: str, body: Any | None = None) -> Any:
181
+ return self._http.request(method, path, body)
182
+
183
+ def collection(
184
+ self, name: str, *, model: type | None = None, mapping: Any | None = None
185
+ ) -> BaseCollection:
186
+ """Bind a collection name (plus optional model/mapping) for typed use."""
187
+ return BaseCollection(self, name, model=model, mapping=mapping)
188
+
189
+ # ---- collections ----
190
+ def list_collections(
191
+ self, org_id: str | None = None, account_id: str | None = None
192
+ ) -> list:
193
+ """List collections, optionally filtered by org and/or account."""
194
+ params: dict[str, Any] = {}
195
+ if org_id:
196
+ params["orgId"] = org_id
197
+ if account_id:
198
+ params["accountId"] = account_id
199
+ qs = f"?{urlencode(params)}" if params else ""
200
+ return self._req("GET", f"/v1/base/collections{qs}")
201
+
202
+ def create_collection(
203
+ self,
204
+ name: str,
205
+ description: str | None = None,
206
+ mapping: Any | None = None,
207
+ org_id: str | None = None,
208
+ account_id: str | None = None,
209
+ ) -> dict:
210
+ """Create a collection, optionally with an object<->data mapping."""
211
+ body: dict = {"name": name}
212
+ if description is not None:
213
+ body["description"] = description
214
+ if mapping is not None:
215
+ body["mapping"] = mapping
216
+ if org_id:
217
+ body["orgId"] = org_id
218
+ if account_id:
219
+ body["accountId"] = account_id
220
+ return self._req("POST", "/v1/base/collections", body)
221
+
222
+ def get_collection(self, collection: str) -> dict:
223
+ """Fetch a collection plus document stats."""
224
+ return self._req("GET", f"/v1/base/collections/{quote(collection, safe='')}")
225
+
226
+ def update_collection(
227
+ self,
228
+ collection: str,
229
+ name: str | None = None,
230
+ description: Any = UNSET,
231
+ mapping: Any = UNSET,
232
+ ) -> dict:
233
+ """Patch a collection's name, description and/or mapping.
234
+
235
+ ``description=None`` clears the description; omit it to keep the value.
236
+ """
237
+ patch: dict = {}
238
+ if name is not None:
239
+ patch["name"] = name
240
+ if description is not UNSET:
241
+ patch["description"] = description
242
+ if mapping is not UNSET:
243
+ patch["mapping"] = mapping
244
+ return self._req(
245
+ "PATCH", f"/v1/base/collections/{quote(collection, safe='')}", patch
246
+ )
247
+
248
+ def delete_collection(self, collection: str) -> dict:
249
+ """Delete a collection and all its documents."""
250
+ return self._req("DELETE", f"/v1/base/collections/{quote(collection, safe='')}")
251
+
252
+ def collection_stats(self, collection: str) -> dict:
253
+ """Fetch a collection's document stats."""
254
+ return self._req(
255
+ "GET", f"/v1/base/collections/{quote(collection, safe='')}/stats"
256
+ )
257
+
258
+ # ---- documents ----
259
+ def create_document(
260
+ self,
261
+ collection: str,
262
+ data: Any = UNSET,
263
+ *,
264
+ object: Any = UNSET,
265
+ id: str | None = None,
266
+ ) -> dict:
267
+ """Create a document from raw ``data`` or a logical ``object``.
268
+
269
+ Pass exactly one of ``data`` (stored JSON) and ``object`` (mapped
270
+ logical keys); the server validates the payload against the mapping.
271
+ """
272
+ body = _write_body(data, object)
273
+ if id is not None:
274
+ body["id"] = id
275
+ return self._req(
276
+ "POST", f"/v1/base/collections/{quote(collection, safe='')}/documents", body
277
+ )
278
+
279
+ def get_document(self, collection: str, doc_id: str, as_view: str = "data") -> dict:
280
+ """Fetch one document; ``as_view`` is 'data' or 'object'."""
281
+ qs = f"?as={quote(as_view, safe='')}" if as_view != "data" else ""
282
+ return self._req(
283
+ "GET",
284
+ f"/v1/base/collections/{quote(collection, safe='')}/documents/"
285
+ f"{quote(doc_id, safe='')}{qs}",
286
+ )
287
+
288
+ def set_document(
289
+ self,
290
+ collection: str,
291
+ doc_id: str,
292
+ data: Any = UNSET,
293
+ *,
294
+ object: Any = UNSET,
295
+ if_version: int | None = None,
296
+ merge: bool = False,
297
+ ) -> dict:
298
+ """Create-or-replace a document (PUT).
299
+
300
+ ``merge=True`` deep-merges ``data`` into the existing document instead
301
+ of replacing it; ``if_version`` enables optimistic concurrency.
302
+ """
303
+ body = _write_body(data, object)
304
+ if if_version is not None:
305
+ body["ifVersion"] = if_version
306
+ if merge:
307
+ body["merge"] = True
308
+ return self._req(
309
+ "PUT",
310
+ f"/v1/base/collections/{quote(collection, safe='')}/documents/"
311
+ f"{quote(doc_id, safe='')}",
312
+ body,
313
+ )
314
+
315
+ def update_document(
316
+ self,
317
+ collection: str,
318
+ doc_id: str,
319
+ data: Any = UNSET,
320
+ *,
321
+ object: Any = UNSET,
322
+ unset: list | None = None,
323
+ if_version: int | None = None,
324
+ ) -> dict:
325
+ """Patch a document (PATCH): deep-merge ``data``/``object`` and/or
326
+ remove dot paths listed in ``unset``."""
327
+ if data is UNSET and object is UNSET and not unset:
328
+ raise ValueError("provide data/object and/or unset")
329
+ body: dict = {}
330
+ if data is not UNSET or object is not UNSET:
331
+ body.update(_write_body(data, object))
332
+ if unset:
333
+ body["unset"] = list(unset)
334
+ if if_version is not None:
335
+ body["ifVersion"] = if_version
336
+ return self._req(
337
+ "PATCH",
338
+ f"/v1/base/collections/{quote(collection, safe='')}/documents/"
339
+ f"{quote(doc_id, safe='')}",
340
+ body,
341
+ )
342
+
343
+ def delete_document(
344
+ self, collection: str, doc_id: str, if_version: int | None = None
345
+ ) -> dict:
346
+ """Delete a document, optionally guarded by ``if_version``."""
347
+ qs = f"?ifVersion={int(if_version)}" if if_version is not None else ""
348
+ return self._req(
349
+ "DELETE",
350
+ f"/v1/base/collections/{quote(collection, safe='')}/documents/"
351
+ f"{quote(doc_id, safe='')}{qs}",
352
+ )
353
+
354
+ # ---- query ----
355
+ def query_documents(
356
+ self,
357
+ collection: str,
358
+ where: Any = None,
359
+ *,
360
+ order_by: Any = None,
361
+ limit: int | None = None,
362
+ offset: int | None = None,
363
+ cursor: str | None = None,
364
+ select: list | None = None,
365
+ as_view: str = "data",
366
+ ) -> QueryResult:
367
+ """Run a query: filter tree, ordering, cursor/offset pagination and
368
+ projection.
369
+
370
+ ``where`` accepts leaf ``{field, op, value}`` nodes, ``and``/``or``/
371
+ ``not`` groups, arrays (implicit ``and``) and the shorthand
372
+ ``{"status": "active", "age": {"gte": 18}}``. Metadata fields
373
+ ``$id/$version/$createdAt/$updatedAt`` and mapping logical names are
374
+ resolved by the service.
375
+ """
376
+ body: dict = {"as": as_view}
377
+ if where is not None:
378
+ body["where"] = where
379
+ if order_by is not None:
380
+ body["orderBy"] = order_by
381
+ if limit is not None:
382
+ body["limit"] = limit
383
+ if offset is not None:
384
+ body["offset"] = offset
385
+ if cursor is not None:
386
+ body["cursor"] = cursor
387
+ if select is not None:
388
+ body["select"] = select
389
+ raw = self._req(
390
+ "POST", f"/v1/base/collections/{quote(collection, safe='')}/query", body
391
+ )
392
+ return QueryResult(
393
+ items=list(raw.get("items", [])),
394
+ total=int(raw.get("total", 0)),
395
+ limit=int(raw.get("limit", 0)),
396
+ offset=int(raw.get("offset", 0)),
397
+ next_cursor=raw.get("nextCursor"),
398
+ )
399
+
400
+ def vector_search(
401
+ self,
402
+ collection: str,
403
+ vector: list[float],
404
+ *,
405
+ limit: int | None = None,
406
+ k: int | None = None,
407
+ field: str | None = None,
408
+ metric: str | None = None,
409
+ where: Any = None,
410
+ filter: Any = None,
411
+ select: list | None = None,
412
+ as_view: str = "data",
413
+ ) -> VectorSearchResult:
414
+ """Run vector similarity search with optional metadata filtering."""
415
+ body: dict = {"vector": vector, "as": as_view}
416
+ effective_limit = limit if limit is not None else k
417
+ if effective_limit is not None:
418
+ body["limit"] = effective_limit
419
+ if field is not None:
420
+ body["field"] = field
421
+ if metric is not None:
422
+ body["metric"] = metric
423
+ effective_where = where if where is not None else filter
424
+ if effective_where is not None:
425
+ body["where"] = effective_where
426
+ if select is not None:
427
+ body["select"] = select
428
+ raw = self._req(
429
+ "POST",
430
+ f"/v1/base/collections/{quote(collection, safe='')}/vector-search",
431
+ body,
432
+ )
433
+ return VectorSearchResult(
434
+ items=list(raw.get("items", [])),
435
+ total=int(raw.get("total", 0)),
436
+ limit=int(raw.get("limit", effective_limit or 10)),
437
+ metric=str(raw.get("metric", "cosine")),
438
+ )
439
+
440
+ def list_documents(
441
+ self, collection: str, limit: int | None = None, offset: int | None = None
442
+ ) -> list:
443
+ """Page through a collection's documents in id order."""
444
+ params: dict[str, Any] = {}
445
+ if limit is not None:
446
+ params["limit"] = limit
447
+ if offset is not None:
448
+ params["offset"] = offset
449
+ qs = f"?{urlencode(params)}" if params else ""
450
+ return self._req(
451
+ "GET",
452
+ f"/v1/base/collections/{quote(collection, safe='')}/documents{qs}",
453
+ )
454
+
455
+ def increment(
456
+ self,
457
+ collection: str,
458
+ doc_id: str,
459
+ field: str,
460
+ by: float = 1,
461
+ if_version: int | None = None,
462
+ ) -> dict:
463
+ """Atomically add ``by`` to a numeric dot-path field."""
464
+ body: dict = {"field": field, "by": by}
465
+ if if_version is not None:
466
+ body["ifVersion"] = if_version
467
+ return self._req(
468
+ "POST",
469
+ f"/v1/base/collections/{quote(collection, safe='')}/documents/"
470
+ f"{quote(doc_id, safe='')}/increment",
471
+ body,
472
+ )
473
+
474
+ def batch(self, collection: str, operations: list) -> dict:
475
+ """Run 1..100 create/set/update/delete/increment ops in one call.
476
+
477
+ Each operation is ``{"op": ..., "id"?, "data"|"object"?, ...}``; the
478
+ response reports per-op success and never fails the whole batch on a
479
+ single bad op.
480
+ """
481
+ return self._req(
482
+ "POST",
483
+ f"/v1/base/collections/{quote(collection, safe='')}/documents:batch",
484
+ {"operations": operations},
485
+ )
486
+
487
+ def changes(
488
+ self,
489
+ collection: str,
490
+ since: str,
491
+ limit: int | None = None,
492
+ as_view: str = "data",
493
+ ) -> dict:
494
+ """Poll the change feed for documents updated since an ISO timestamp.
495
+
496
+ Returns ``{changes, nextSince, hasMore}``; pass ``nextSince`` back in
497
+ on the next call. Deletes are not emitted.
498
+ """
499
+ params: dict[str, Any] = {"since": since}
500
+ if limit is not None:
501
+ params["limit"] = limit
502
+ if as_view != "data":
503
+ params["as"] = as_view
504
+ return self._req(
505
+ "GET",
506
+ f"/v1/base/collections/{quote(collection, safe='')}/changes?"
507
+ f"{urlencode(params)}",
508
+ )
509
+
510
+
511
+ def _write_body(data: Any, object: Any) -> dict:
512
+ has_data = data is not UNSET
513
+ has_object = object is not UNSET
514
+ if has_data and has_object:
515
+ raise ValueError("provide exactly one of data or object")
516
+ if not has_data and not has_object:
517
+ raise ValueError("provide exactly one of data or object")
518
+ return {"data": data} if has_data else {"object": object}
519
+
520
+
521
+ # ---------------------------------------------------------------------------
522
+ # typed collection
523
+ # ---------------------------------------------------------------------------
524
+
525
+
526
+ class BaseCollection:
527
+ """A collection bound to a service, optionally with a model + mapping.
528
+
529
+ ``model`` is any dataclass (or plain class) whose attribute names match the
530
+ mapping's logical field names. ``mapping`` mirrors the server-side mapping
531
+ (``{logical: "dot.path"}`` or ``{logical: {path, type, required}}``) and is
532
+ used for endpoints that return raw ``data`` (list/increment/changes).
533
+ """
534
+
535
+ def __init__(
536
+ self,
537
+ service: BaseService,
538
+ name: str,
539
+ *,
540
+ model: type | None = None,
541
+ mapping: Any | None = None,
542
+ ) -> None:
543
+ self.service = service
544
+ self.name = name
545
+ self.model = model
546
+ self.mapping = normalize_mapping(mapping)
547
+
548
+ # -- model conversion --
549
+ def to_object(self, instance: Any) -> dict:
550
+ """Convert a model instance (or dict) to a logical object payload."""
551
+ return model_to_object(instance)
552
+
553
+ def from_object(self, obj: dict | None) -> Any:
554
+ """Convert a logical object response into a model instance."""
555
+ return object_to_model(obj, self.model)
556
+
557
+ def from_data(self, data: dict | None) -> Any:
558
+ """Convert raw stored ``data`` into a model instance."""
559
+ return object_to_model(data_to_object(data, self.mapping), self.model)
560
+
561
+ def _doc_result(self, doc: dict) -> Any:
562
+ """Convert a write response envelope into a model (falls back to raw)."""
563
+ if "object" in doc:
564
+ return self.from_object(doc.get("object"))
565
+ if "data" in doc:
566
+ return self.from_data(doc.get("data"))
567
+ return doc
568
+
569
+ # -- documents --
570
+ def create(
571
+ self,
572
+ instance: Any = UNSET,
573
+ *,
574
+ id: str | None = None,
575
+ data: Any = UNSET,
576
+ object: Any = UNSET,
577
+ ) -> Any:
578
+ """Create a document from a model instance, logical object or raw data."""
579
+ if instance is not UNSET:
580
+ object = self.to_object(instance)
581
+ doc = self.service.create_document(self.name, data, object=object, id=id)
582
+ return self._doc_result(doc)
583
+
584
+ def get(self, doc_id: str) -> Any:
585
+ """Fetch one document as a model instance."""
586
+ doc = self.service.get_document(self.name, doc_id, as_view="object")
587
+ return self.from_object(doc.get("object"))
588
+
589
+ def get_document(self, doc_id: str, as_view: str = "data") -> dict:
590
+ """Fetch the raw document envelope (id/version/timestamps + data|object)."""
591
+ return self.service.get_document(self.name, doc_id, as_view=as_view)
592
+
593
+ def list(self, limit: int | None = None, offset: int | None = None) -> list:
594
+ """Page through documents, converted to models via the mapping."""
595
+ return [
596
+ self.from_data(doc.get("data"))
597
+ for doc in self.service.list_documents(
598
+ self.name, limit=limit, offset=offset
599
+ )
600
+ ]
601
+
602
+ def set(
603
+ self,
604
+ doc_id: str,
605
+ instance: Any = UNSET,
606
+ *,
607
+ data: Any = UNSET,
608
+ object: Any = UNSET,
609
+ if_version: int | None = None,
610
+ merge: bool = False,
611
+ ) -> Any:
612
+ """Create-or-replace a document from a model instance/object/data."""
613
+ if instance is not UNSET:
614
+ object = self.to_object(instance)
615
+ doc = self.service.set_document(
616
+ self.name, doc_id, data, object=object, if_version=if_version, merge=merge
617
+ )
618
+ return self._doc_result(doc)
619
+
620
+ def update(
621
+ self,
622
+ doc_id: str,
623
+ instance: Any = UNSET,
624
+ *,
625
+ data: Any = UNSET,
626
+ object: Any = UNSET,
627
+ unset: list | None = None,
628
+ if_version: int | None = None,
629
+ ) -> Any:
630
+ """Patch a document (deep-merge instance/object/data and/or unset paths)."""
631
+ if instance is not UNSET:
632
+ object = self.to_object(instance)
633
+ doc = self.service.update_document(
634
+ self.name, doc_id, data, object=object, unset=unset, if_version=if_version
635
+ )
636
+ return self._doc_result(doc)
637
+
638
+ def delete(self, doc_id: str, if_version: int | None = None) -> dict:
639
+ """Delete a document, optionally guarded by ``if_version``."""
640
+ return self.service.delete_document(self.name, doc_id, if_version=if_version)
641
+
642
+ def increment(
643
+ self,
644
+ doc_id: str,
645
+ field: str,
646
+ by: float = 1,
647
+ if_version: int | None = None,
648
+ ) -> Any:
649
+ """Atomically increment a numeric field, returning the updated model."""
650
+ doc = self.service.increment(
651
+ self.name, doc_id, field, by, if_version=if_version
652
+ )
653
+ return self.from_data(doc.get("data"))
654
+
655
+ def query(
656
+ self,
657
+ where: Any = None,
658
+ *,
659
+ order_by: Any = None,
660
+ limit: int | None = None,
661
+ offset: int | None = None,
662
+ cursor: str | None = None,
663
+ select: list | None = None,
664
+ ) -> QueryResult:
665
+ """Query the collection and convert the page to model instances."""
666
+ result = self.service.query_documents(
667
+ self.name,
668
+ where,
669
+ order_by=order_by,
670
+ limit=limit,
671
+ offset=offset,
672
+ cursor=cursor,
673
+ select=select,
674
+ as_view="object",
675
+ )
676
+ result.items = [self.from_object(item.get("object")) for item in result.items]
677
+ return result
678
+
679
+ def vector_search(
680
+ self,
681
+ vector: list[float],
682
+ *,
683
+ limit: int | None = None,
684
+ k: int | None = None,
685
+ field: str | None = None,
686
+ metric: str | None = None,
687
+ where: Any = None,
688
+ filter: Any = None,
689
+ select: list | None = None,
690
+ ) -> VectorSearchResult:
691
+ """Run vector similarity search; converts result object payloads to models."""
692
+ raw = self.service.vector_search(
693
+ self.name,
694
+ vector,
695
+ limit=limit,
696
+ k=k,
697
+ field=field,
698
+ metric=metric,
699
+ where=where,
700
+ filter=filter,
701
+ select=select,
702
+ as_view="object",
703
+ )
704
+ items = []
705
+ for item in raw.items:
706
+ it = dict(item)
707
+ if "object" in it:
708
+ it["object"] = self.from_object(it["object"])
709
+ items.append(it)
710
+ return VectorSearchResult(
711
+ items=items,
712
+ total=raw.total,
713
+ limit=raw.limit,
714
+ metric=raw.metric,
715
+ )
716
+
717
+ def changes(self, since: str, limit: int | None = None) -> dict:
718
+ """Poll the change feed and convert entries to model instances."""
719
+ raw = self.service.changes(self.name, since, limit=limit, as_view="object")
720
+ raw["changes"] = [
721
+ {**ch, "document": self.from_object(ch.get("object"))}
722
+ for ch in raw.get("changes", [])
723
+ ]
724
+ return raw
725
+
726
+ def batch(self, operations: list) -> dict:
727
+ """Run a batch of create/set/update/delete/increment operations."""
728
+ return self.service.batch(self.name, operations)
729
+
730
+ def stats(self) -> dict:
731
+ """Fetch this collection's document stats."""
732
+ return self.service.collection_stats(self.name)
733
+
734
+
735
+ __all__ = [
736
+ "BaseCollection",
737
+ "BaseService",
738
+ "QueryResult",
739
+ "data_to_object",
740
+ "model_to_object",
741
+ "normalize_mapping",
742
+ "object_to_data",
743
+ "object_to_model",
744
+ ]