66import logging
77from http import HTTPStatus
88from typing import Any , Self
9+ from urllib .parse import quote
910
1011import aiohttp
1112from aiocouch import CouchDB , Database
1617logger = logging .getLogger (__name__ )
1718
1819
20+ class DocumentConflictError (RuntimeError ):
21+ """Raised when a document update conflicts with a newer CouchDB revision."""
22+
23+
1924class CouchDBClient :
2025 """Async CouchDB client wrapper."""
2126
@@ -222,7 +227,6 @@ async def find(
222227 selector : dict [str , Any ],
223228 limit : int | None = None ,
224229 skip : int = 0 ,
225- fields : list [str ] | None = None ,
226230 ) -> list [dict [str , Any ]]:
227231 """Find documents using a Mango query selector.
228232
@@ -231,9 +235,6 @@ async def find(
231235 limit: Maximum number of results to return per call.
232236 Defaults to the instance's ``default_query_limit``.
233237 skip: Number of results to skip (for pagination)
234- fields: Optional list of fields to include in results (projection).
235- When set, ``arc_content`` and other large fields can be
236- excluded to reduce memory and network usage.
237238
238239 Returns:
239240 List of matching documents
@@ -242,12 +243,7 @@ async def find(
242243 raise RuntimeError ("Not connected to CouchDB" )
243244
244245 effective_limit = limit if limit is not None else self ._default_query_limit
245- # aiocouch's find passes extra kwargs through to the _find body.
246- kwargs : dict [str , Any ] = {"limit" : effective_limit , "skip" : skip }
247- if fields is not None :
248- kwargs ["fields" ] = fields
249-
250- result = self ._db .find (selector , ** kwargs )
246+ result = self ._db .find (selector , limit = effective_limit , skip = skip )
251247 docs = [dict (doc ) async for doc in result ]
252248
253249 if len (docs ) == effective_limit :
@@ -260,6 +256,117 @@ async def find(
260256
261257 return docs
262258
259+ async def find_projected (
260+ self ,
261+ selector : dict [str , Any ],
262+ fields : list [str ],
263+ limit : int | None = None ,
264+ skip : int = 0 ,
265+ ) -> list [dict [str , Any ]]:
266+ """Find documents using CouchDB _find with explicit field projection.
267+
268+ This method uses the raw HTTP endpoint because aiocouch's ``Database.find``
269+ returns full ``Document`` objects and therefore does not support the
270+ ``fields`` parameter.
271+
272+ Args:
273+ selector: Mango query selector.
274+ fields: List of fields to return (CouchDB ``fields`` projection).
275+ limit: Maximum number of results to return per call.
276+ Defaults to the instance's ``default_query_limit``.
277+ skip: Number of results to skip (for pagination).
278+
279+ Returns:
280+ List of projected documents.
281+ """
282+ if not self ._db :
283+ raise RuntimeError ("Not connected to CouchDB" )
284+ if not self ._db_name :
285+ raise RuntimeError ("Database name is not set" )
286+
287+ effective_limit = limit if limit is not None else self ._default_query_limit
288+
289+ payload : dict [str , Any ] = {
290+ "selector" : selector ,
291+ "fields" : fields ,
292+ "limit" : effective_limit ,
293+ "skip" : skip ,
294+ }
295+
296+ url = f"{ self ._url } /{ self ._db_name } /_find"
297+ session = self ._get_session ()
298+ async with session .post (url , json = payload ) as resp :
299+ if resp .status != HTTPStatus .OK :
300+ text = await resp .text ()
301+ logger .error ("CouchDB _find with projection failed: %s" , text )
302+ raise RuntimeError (f"CouchDB _find failed with status { resp .status } : { text } " )
303+
304+ response_data = await resp .json ()
305+
306+ docs_raw = response_data .get ("docs" , [])
307+ docs : list [dict [str , Any ]] = [dict (doc ) for doc in docs_raw ]
308+
309+ if len (docs ) == effective_limit :
310+ logger .warning (
311+ "CouchDB find_projected() returned exactly %d documents for selector %s — "
312+ "results may be silently truncated. Use skip/limit for pagination." ,
313+ effective_limit ,
314+ selector ,
315+ )
316+
317+ return docs
318+
319+ async def save_document_if_revision_matches (
320+ self ,
321+ doc_id : str ,
322+ data : dict [str , Any ],
323+ * ,
324+ expected_rev : str ,
325+ ) -> dict [str , Any ]:
326+ """Save a document only if the expected revision still matches.
327+
328+ Uses raw ``PUT /{db}/{docid}`` to allow optimistic-concurrency handling
329+ in higher layers (retry on 409 Conflict).
330+
331+ Args:
332+ doc_id: Document ID.
333+ data: Complete document payload to save.
334+ expected_rev: Revision expected by the caller.
335+
336+ Returns:
337+ Saved document payload including updated ``_rev``.
338+
339+ Raises:
340+ DocumentConflictError: If CouchDB returns 409 conflict.
341+ RuntimeError: For non-success HTTP errors.
342+ """
343+ if not self ._db :
344+ raise RuntimeError ("Not connected to CouchDB" )
345+ if not self ._db_name :
346+ raise RuntimeError ("Database name is not set" )
347+
348+ payload = dict (data )
349+ payload ["_id" ] = doc_id
350+ payload ["_rev" ] = expected_rev
351+
352+ encoded_doc_id = quote (doc_id , safe = "" )
353+ url = f"{ self ._url } /{ self ._db_name } /{ encoded_doc_id } "
354+ session = self ._get_session ()
355+
356+ async with session .put (url , json = payload ) as resp :
357+ if resp .status in {HTTPStatus .CREATED , HTTPStatus .ACCEPTED , HTTPStatus .OK }:
358+ response_data = await resp .json ()
359+ new_rev = response_data .get ("rev" )
360+ if isinstance (new_rev , str ):
361+ payload ["_rev" ] = new_rev
362+ return payload
363+
364+ if resp .status == HTTPStatus .CONFLICT :
365+ raise DocumentConflictError (f"Conflict updating document { doc_id } " )
366+
367+ text = await resp .text ()
368+ raise RuntimeError (f"Failed to update document { doc_id } : { resp .status } { text } " )
369+
263370 def _get_session (self ) -> aiohttp .ClientSession :
264371 """Return the shared aiohttp session, creating it on first call."""
265372 if self ._session is None :
0 commit comments