Team Ai
Datasetpublic

codekingpro/portable-devtools

sourceHugging Faceupdated 5mo agoView on Hugging Face
1likes15kdownloads
baked.py571 linesDownload Raw Back to ext
1# ext/baked.py
2# Copyright (C) 2005-2026 the SQLAlchemy authors and contributors
3# <see AUTHORS file>
4#
5# This module is part of SQLAlchemy and is released under
6# the MIT License: https://www.opensource.org/licenses/mit-license.php
7# mypy: ignore-errors
8
9
10"""Baked query extension.
11
12Provides a creational pattern for the :class:`.query.Query` object which
13allows the fully constructed object, Core select statement, and string
14compiled result to be fully cached.
15
16
17"""
18
19import collections.abc as collections_abc
20import logging
21
22from .. import exc as sa_exc
23from .. import util
24from ..orm import exc as orm_exc
25from ..orm.query import Query
26from ..orm.session import Session
27from ..sql import func
28from ..sql import literal_column
29from ..sql import util as sql_util
30
31
32log = logging.getLogger(__name__)
33
34
35class Bakery:
36    """Callable which returns a :class:`.BakedQuery`.
37
38    This object is returned by the class method
39    :meth:`.BakedQuery.bakery`.  It exists as an object
40    so that the "cache" can be easily inspected.
41
42    .. versionadded:: 1.2
43
44
45    """
46
47    __slots__ = "cls", "cache"
48
49    def __init__(self, cls_, cache):
50        self.cls = cls_
51        self.cache = cache
52
53    def __call__(self, initial_fn, *args):
54        return self.cls(self.cache, initial_fn, args)
55
56
57class BakedQuery:
58    """A builder object for :class:`.query.Query` objects."""
59
60    __slots__ = "steps", "_bakery", "_cache_key", "_spoiled"
61
62    def __init__(self, bakery, initial_fn, args=()):
63        self._cache_key = ()
64        self._update_cache_key(initial_fn, args)
65        self.steps = [initial_fn]
66        self._spoiled = False
67        self._bakery = bakery
68
69    @classmethod
70    def bakery(cls, size=200, _size_alert=None):
71        """Construct a new bakery.
72
73        :return: an instance of :class:`.Bakery`
74
75        """
76
77        return Bakery(cls, util.LRUCache(size, size_alert=_size_alert))
78
79    def _clone(self):
80        b1 = BakedQuery.__new__(BakedQuery)
81        b1._cache_key = self._cache_key
82        b1.steps = list(self.steps)
83        b1._bakery = self._bakery
84        b1._spoiled = self._spoiled
85        return b1
86
87    def _update_cache_key(self, fn, args=()):
88        self._cache_key += (fn.__code__,) + args
89
90    def __iadd__(self, other):
91        if isinstance(other, tuple):
92            self.add_criteria(*other)
93        else:
94            self.add_criteria(other)
95        return self
96
97    def __add__(self, other):
98        if isinstance(other, tuple):
99            return self.with_criteria(*other)
100        else:
101            return self.with_criteria(other)
102
103    def add_criteria(self, fn, *args):
104        """Add a criteria function to this :class:`.BakedQuery`.
105
106        This is equivalent to using the ``+=`` operator to
107        modify a :class:`.BakedQuery` in-place.
108
109        """
110        self._update_cache_key(fn, args)
111        self.steps.append(fn)
112        return self
113
114    def with_criteria(self, fn, *args):
115        """Add a criteria function to a :class:`.BakedQuery` cloned from this
116        one.
117
118        This is equivalent to using the ``+`` operator to
119        produce a new :class:`.BakedQuery` with modifications.
120
121        """
122        return self._clone().add_criteria(fn, *args)
123
124    def for_session(self, session):
125        """Return a :class:`_baked.Result` object for this
126        :class:`.BakedQuery`.
127
128        This is equivalent to calling the :class:`.BakedQuery` as a
129        Python callable, e.g. ``result = my_baked_query(session)``.
130
131        """
132        return Result(self, session)
133
134    def __call__(self, session):
135        return self.for_session(session)
136
137    def spoil(self, full=False):
138        """Cancel any query caching that will occur on this BakedQuery object.
139
140        The BakedQuery can continue to be used normally, however additional
141        creational functions will not be cached; they will be called
142        on every invocation.
143
144        This is to support the case where a particular step in constructing
145        a baked query disqualifies the query from being cacheable, such
146        as a variant that relies upon some uncacheable value.
147
148        :param full: if False, only functions added to this
149         :class:`.BakedQuery` object subsequent to the spoil step will be
150         non-cached; the state of the :class:`.BakedQuery` up until
151         this point will be pulled from the cache.   If True, then the
152         entire :class:`_query.Query` object is built from scratch each
153         time, with all creational functions being called on each
154         invocation.
155
156        """
157        if not full and not self._spoiled:
158            _spoil_point = self._clone()
159            _spoil_point._cache_key += ("_query_only",)
160            self.steps = [_spoil_point._retrieve_baked_query]
161        self._spoiled = True
162        return self
163
164    def _effective_key(self, session):
165        """Return the key that actually goes into the cache dictionary for
166        this :class:`.BakedQuery`, taking into account the given
167        :class:`.Session`.
168
169        This basically means we also will include the session's query_class,
170        as the actual :class:`_query.Query` object is part of what's cached
171        and needs to match the type of :class:`_query.Query` that a later
172        session will want to use.
173
174        """
175        return self._cache_key + (session._query_cls,)
176
177    def _with_lazyload_options(self, options, effective_path, cache_path=None):
178        """Cloning version of _add_lazyload_options."""
179        q = self._clone()
180        q._add_lazyload_options(options, effective_path, cache_path=cache_path)
181        return q
182
183    def _add_lazyload_options(self, options, effective_path, cache_path=None):
184        """Used by per-state lazy loaders to add options to the
185        "lazy load" query from a parent query.
186
187        Creates a cache key based on given load path and query options;
188        if a repeatable cache key cannot be generated, the query is
189        "spoiled" so that it won't use caching.
190
191        """
192
193        key = ()
194
195        if not cache_path:
196            cache_path = effective_path
197
198        for opt in options:
199            if opt._is_legacy_option or opt._is_compile_state:
200                ck = opt._generate_cache_key()
201                if ck is None:
202                    self.spoil(full=True)
203                else:
204                    assert not ck[1], (
205                        "loader options with variable bound parameters "
206                        "not supported with baked queries.  Please "
207                        "use new-style select() statements for cached "
208                        "ORM queries."
209                    )
210                    key += ck[0]
211
212        self.add_criteria(
213            lambda q: q._with_current_path(effective_path).options(*options),
214            cache_path.path,
215            key,
216        )
217
218    def _retrieve_baked_query(self, session):
219        query = self._bakery.get(self._effective_key(session), None)
220        if query is None:
221            query = self._as_query(session)
222            self._bakery[self._effective_key(session)] = query.with_session(
223                None
224            )
225        return query.with_session(session)
226
227    def _bake(self, session):
228        query = self._as_query(session)
229        query.session = None
230
231        # in 1.4, this is where before_compile() event is
232        # invoked
233        statement = query._statement_20()
234
235        # if the query is not safe to cache, we still do everything as though
236        # we did cache it, since the receiver of _bake() assumes subqueryload
237        # context was set up, etc.
238        #
239        # note also we want to cache the statement itself because this
240        # allows the statement itself to hold onto its cache key that is
241        # used by the Connection, which in itself is more expensive to
242        # generate than what BakedQuery was able to provide in 1.3 and prior
243
244        if statement._compile_options._bake_ok:
245            self._bakery[self._effective_key(session)] = (
246                query,
247                statement,
248            )
249
250        return query, statement
251
252    def to_query(self, query_or_session):
253        """Return the :class:`_query.Query` object for use as a subquery.
254
255        This method should be used within the lambda callable being used
256        to generate a step of an enclosing :class:`.BakedQuery`.   The
257        parameter should normally be the :class:`_query.Query` object that
258        is passed to the lambda::
259
260            sub_bq = self.bakery(lambda s: s.query(User.name))
261            sub_bq += lambda q: q.filter(User.id == Address.user_id).correlate(Address)
262
263            main_bq = self.bakery(lambda s: s.query(Address))
264            main_bq += lambda q: q.filter(sub_bq.to_query(q).exists())
265
266        In the case where the subquery is used in the first callable against
267        a :class:`.Session`, the :class:`.Session` is also accepted::
268
269            sub_bq = self.bakery(lambda s: s.query(User.name))
270            sub_bq += lambda q: q.filter(User.id == Address.user_id).correlate(Address)
271
272            main_bq = self.bakery(
273                lambda s: s.query(Address.id, sub_bq.to_query(q).scalar_subquery())
274            )
275
276        :param query_or_session: a :class:`_query.Query` object or a class
277         :class:`.Session` object, that is assumed to be within the context
278         of an enclosing :class:`.BakedQuery` callable.
279
280
281         .. versionadded:: 1.3
282
283
284        """  # noqa: E501
285
286        if isinstance(query_or_session, Session):
287            session = query_or_session
288        elif isinstance(query_or_session, Query):
289            session = query_or_session.session
290            if session is None:
291                raise sa_exc.ArgumentError(
292                    "Given Query needs to be associated with a Session"
293                )
294        else:
295            raise TypeError(
296                "Query or Session object expected, got %r."
297                % type(query_or_session)
298            )
299        return self._as_query(session)
300
301    def _as_query(self, session):
302        query = self.steps[0](session)
303
304        for step in self.steps[1:]:
305            query = step(query)
306
307        return query
308
309
310class Result:
311    """Invokes a :class:`.BakedQuery` against a :class:`.Session`.
312
313    The :class:`_baked.Result` object is where the actual :class:`.query.Query`
314    object gets created, or retrieved from the cache,
315    against a target :class:`.Session`, and is then invoked for results.
316
317    """
318
319    __slots__ = "bq", "session", "_params", "_post_criteria"
320
321    def __init__(self, bq, session):
322        self.bq = bq
323        self.session = session
324        self._params = {}
325        self._post_criteria = []
326
327    def params(self, *args, **kw):
328        """Specify parameters to be replaced into the string SQL statement."""
329
330        if len(args) == 1:
331            kw.update(args[0])
332        elif len(args) > 0:
333            raise sa_exc.ArgumentError(
334                "params() takes zero or one positional argument, "
335                "which is a dictionary."
336            )
337        self._params.update(kw)
338        return self
339
340    def _using_post_criteria(self, fns):
341        if fns:
342            self._post_criteria.extend(fns)
343        return self
344
345    def with_post_criteria(self, fn):
346        """Add a criteria function that will be applied post-cache.
347
348        This adds a function that will be run against the
349        :class:`_query.Query` object after it is retrieved from the
350        cache.    This currently includes **only** the
351        :meth:`_query.Query.params` and :meth:`_query.Query.execution_options`
352        methods.
353
354        .. warning::  :meth:`_baked.Result.with_post_criteria`
355           functions are applied
356           to the :class:`_query.Query`
357           object **after** the query's SQL statement
358           object has been retrieved from the cache.   Only
359           :meth:`_query.Query.params` and
360           :meth:`_query.Query.execution_options`
361           methods should be used.
362
363
364        .. versionadded:: 1.2
365
366
367        """
368        return self._using_post_criteria([fn])
369
370    def _as_query(self):
371        q = self.bq._as_query(self.session).params(self._params)
372        for fn in self._post_criteria:
373            q = fn(q)
374        return q
375
376    def __str__(self):
377        return str(self._as_query())
378
379    def __iter__(self):
380        return self._iter().__iter__()
381
382    def _iter(self):
383        bq = self.bq
384
385        if not self.session.enable_baked_queries or bq._spoiled:
386            return self._as_query()._iter()
387
388        query, statement = bq._bakery.get(
389            bq._effective_key(self.session), (None, None)
390        )
391        if query is None:
392            query, statement = bq._bake(self.session)
393
394        if self._params:
395            q = query.params(self._params)
396        else:
397            q = query
398        for fn in self._post_criteria:
399            q = fn(q)
400
401        params = q._params
402        execution_options = dict(q._execution_options)
403        execution_options.update(
404            {
405                "_sa_orm_load_options": q.load_options,
406                "compiled_cache": bq._bakery,
407            }
408        )
409
410        result = self.session.execute(
411            statement, params, execution_options=execution_options
412        )
413        if result._attributes.get("is_single_entity", False):
414            result = result.scalars()
415
416        if result._attributes.get("filtered", False):
417            result = result.unique()
418
419        return result
420
421    def count(self):
422        """return the 'count'.
423
424        Equivalent to :meth:`_query.Query.count`.
425
426        Note this uses a subquery to ensure an accurate count regardless
427        of the structure of the original statement.
428
429        """
430
431        col = func.count(literal_column("*"))
432        bq = self.bq.with_criteria(lambda q: q._legacy_from_self(col))
433        return bq.for_session(self.session).params(self._params).scalar()
434
435    def scalar(self):
436        """Return the first element of the first result or None
437        if no rows present.  If multiple rows are returned,
438        raises MultipleResultsFound.
439
440        Equivalent to :meth:`_query.Query.scalar`.
441
442        """
443        try:
444            ret = self.one()
445            if not isinstance(ret, collections_abc.Sequence):
446                return ret
447            return ret[0]
448        except orm_exc.NoResultFound:
449            return None
450
451    def first(self):
452        """Return the first row.
453
454        Equivalent to :meth:`_query.Query.first`.
455
456        """
457
458        bq = self.bq.with_criteria(lambda q: q.slice(0, 1))
459        return (
460            bq.for_session(self.session)
461            .params(self._params)
462            ._using_post_criteria(self._post_criteria)
463            ._iter()
464            .first()
465        )
466
467    def one(self):
468        """Return exactly one result or raise an exception.
469
470        Equivalent to :meth:`_query.Query.one`.
471
472        """
473        return self._iter().one()
474
475    def one_or_none(self):
476        """Return one or zero results, or raise an exception for multiple
477        rows.
478
479        Equivalent to :meth:`_query.Query.one_or_none`.
480
481        """
482        return self._iter().one_or_none()
483
484    def all(self):
485        """Return all rows.
486
487        Equivalent to :meth:`_query.Query.all`.
488
489        """
490        return self._iter().all()
491
492    def get(self, ident):
493        """Retrieve an object based on identity.
494
495        Equivalent to :meth:`_query.Query.get`.
496
497        """
498
499        query = self.bq.steps[0](self.session)
500        return query._get_impl(ident, self._load_on_pk_identity)
501
502    def _load_on_pk_identity(self, session, query, primary_key_identity, **kw):
503        """Load the given primary key identity from the database."""
504
505        mapper = query._raw_columns[0]._annotations["parententity"]
506
507        _get_clause, _get_params = mapper._get_clause
508
509        def setup(query):
510            _lcl_get_clause = _get_clause
511            q = query._clone()
512            q._get_condition()
513            q._order_by = None
514
515            # None present in ident - turn those comparisons
516            # into "IS NULL"
517            if None in primary_key_identity:
518                nones = {
519                    _get_params[col].key
520                    for col, value in zip(
521                        mapper.primary_key, primary_key_identity
522                    )
523                    if value is None
524                }
525                _lcl_get_clause = sql_util.adapt_criterion_to_null(
526                    _lcl_get_clause, nones
527                )
528
529            # TODO: can mapper._get_clause be pre-adapted?
530            q._where_criteria = (
531                sql_util._deep_annotate(_lcl_get_clause, {"_orm_adapt": True}),
532            )
533
534            for fn in self._post_criteria:
535                q = fn(q)
536            return q
537
538        # cache the query against a key that includes
539        # which positions in the primary key are NULL
540        # (remember, we can map to an OUTER JOIN)
541        bq = self.bq
542
543        # add the clause we got from mapper._get_clause to the cache
544        # key so that if a race causes multiple calls to _get_clause,
545        # we've cached on ours
546        bq = bq._clone()
547        bq._cache_key += (_get_clause,)
548
549        bq = bq.with_criteria(
550            setup, tuple(elem is None for elem in primary_key_identity)
551        )
552
553        params = {
554            _get_params[primary_key].key: id_val
555            for id_val, primary_key in zip(
556                primary_key_identity, mapper.primary_key
557            )
558        }
559
560        result = list(bq.for_session(self.session).params(**params))
561        l = len(result)
562        if l > 1:
563            raise orm_exc.MultipleResultsFound()
564        elif l:
565            return result[0]
566        else:
567            return None
568
569
570bakery = BakedQuery.bakery
571 
codekingpro/portable-devtools · Team Ai