Coverage for /home/runner/work/viur-core/viur-core/viur/src/viur/core/bones/randomslice.py: 28%
60 statements
« prev ^ index » next coverage.py v7.16.1, created at 2026-09-25 15:02 +0000
« prev ^ index » next coverage.py v7.16.1, created at 2026-09-25 15:02 +0000
1from random import random, sample, shuffle
2import typing as t
4from itertools import chain
5from math import ceil
7from viur.core import db
8from viur.core.bones.base import BaseBone
11class RandomSliceBone(BaseBone):
12 """
13 This class is particularly useful when you want to retrieve a random sample of elements from a
14 larger dataset without needing to fetch all the data from the database. By performing multiple
15 subqueries and processing the results, RandomSliceBone provides an efficient way to get a
16 randomized selection of elements from the database query.
17 Simulates the orderby=random from SQL.
18 If you sort by this bone, the query will return a random set of elements from that query.
20 :param visible: Indicates if the bone is visible, defaults to False.
21 :param readOnly: Indicates if the bone is read-only, defaults to True.
22 :param slices: The number of slices to use, defaults to 2.
23 :param sliceSize: The size of each slice, defaults to 0.5.
24 :param kwargs: Additional keyword arguments.
25 """
27 type = "randomslice"
29 def __init__(self, *, visible=False, readOnly=True, slices=2, sliceSize=0.5, **kwargs):
30 """
31 Initializes a new RandomSliceBone.
34 """
35 if visible or not readOnly:
36 raise NotImplementedError("A RandomSliceBone must not visible and readonly!")
37 super().__init__(indexed=True, visible=False, readOnly=True, **kwargs)
38 self.slices = slices
39 self.sliceSize = sliceSize
41 def serialize(self, skel: 'SkeletonInstance', name: str, parentIndexed: bool) -> bool:
42 """
43 Serializes the bone into a format that can be written into the datastore. Instead of using
44 the existing value, it writes a randomly chosen float in the range [0, 1) as the value for
45 this bone.
47 :param SkeletonInstance skel: The SkeletonInstance this bone is part of.
48 :param str name: The property name this bone has in its Skeleton (not the description).
49 :param bool parentIndexed: Indicates if the parent bone is indexed.
50 :return: Returns True if the serialization is successful.
51 :rtype: bool
52 """
53 skel.dbEntity[name] = random()
54 skel.dbEntity.exclude_from_indexes.discard(name) # Random bones can never be not indexed
55 return True
57 def buildDBSort(
58 self,
59 name: str,
60 skel: 'viur.core.skeleton.SkeletonInstance',
61 query: db.Query,
62 params: dict,
63 postfix: str = "",
64 ) -> t.Optional[db.Query]:
65 """
66 Modifies the database query to return a random selection of elements by creating multiple
67 subqueries, each covering a slice of the data. This method doesn't just change the order of
68 the selected elements, but also changes which elements are returned.
70 :param str name: The property name this bone has in its Skeleton (not the description).
71 :param SkeletonInstance skel: The :class:viur.core.skeleton.Skeleton instance this bone is part of.
72 :param db.Query query: The current :class:viur.core.db.Query instance the filters should be applied to.
73 :param Dict params: The dictionary of filters the client wants to have applied.
74 :param postfix: Unused; only present to match the signature of :meth:`BaseBone.buildDBSort`.
75 :return: The modified :class:viur.core.db.Query instance.
76 :rtype: Optional[db.Query]
78 .. note:: The params are controlled by the client, so you must expect and safely handle
79 malformed data.
81 The method also contains an inner function, applyFilterHook, that applies the filter hook to
82 the given filter if set, or returns the unmodified filter. This allows the orderby=random
83 functionality to be used in relational queries as well.
84 """
86 def applyFilterHook(dbfilter, property, value):
87 """
88 Applies dbfilter._filterHook to the given filter if set,
89 else return the unmodified filter.
90 Allows orderby=random also be used in relational-queries.
91 """
92 if query._filterHook is None:
93 return property, value
94 try:
95 property, value = query._filterHook(query, property, value)
96 except:
97 # Either, the filterHook tried to do something special to the query (which won't
98 # work as we are currently rewriting the core part of it) or it thinks that the query
99 # is unsatisfiable (fe. because of a missing ref/parent key in RelationalBone).
100 # In each case we kill the query here - making it to return no results
101 raise RuntimeError()
102 return property, value
104 if "orderby" in params and params["orderby"] == name:
105 # We select a random set of elements from that collection
106 assert not isinstance(query.queries, list), \
107 "orderby=random is not possible on a query that already uses an IN-filter!"
108 origFilter: dict = query.queries.filters
109 origKind = query.getKind()
110 queries = []
111 for unused in range(0, self.slices): # Fetch 3 Slices from the set
112 rndVal = random() # Choose our Slice center
113 # Right Side
114 q = db.QueryDefinition(origKind, {}, [])
115 property, value = applyFilterHook(query, f"{name} <=", rndVal)
116 q.filters[property] = value
117 q.orders = [db.QueryOrder(name, db.SortOrder.Descending)]
118 queries.append(q)
119 # Left Side
120 q = db.QueryDefinition(origKind, {}, [])
121 property, value = applyFilterHook(query, f"{name} >", rndVal)
122 q.filters[property] = value
123 q.orders = [db.QueryOrder(name)]
124 queries.append(q)
125 query.queries = queries
126 # Map the original filter back in
127 for k, v in origFilter.items():
128 query.filter(k, v)
129 query._customMultiQueryMerge = self.customMultiQueryMerge
130 query._calculateInternalMultiQueryLimit = self.calculateInternalMultiQueryLimit
132 def calculateInternalMultiQueryLimit(self, query: db.Query, targetAmount: int) -> int:
133 """
134 Calculates the number of entries to be fetched in each subquery.
136 :param db.Query query: The :class:viur.core.db.Query instance.
137 :param int targetAmount: The number of entries to be returned from the db.Query.
138 :return: The number of elements the db.Query should fetch on each subquery.
139 :rtype: int
140 """
141 return ceil(targetAmount * self.sliceSize)
143 def customMultiQueryMerge(self, dbFilter: db.Query, result: list[db.Entity], targetAmount: int) \
144 -> list[db.Entity]:
145 """
146 Merges the results of multiple subqueries by randomly selecting 'targetAmount' elements
147 from the combined 'result' list.
149 :param db.Query dbFilter: The db.Query instance calling this function.
150 :param List[db.Entity] result: The list of results for each subquery that has been run.
151 :param int targetAmount: The number of results to be returned from the db.Query.
152 :return: A list of elements to be returned from the db.Query.
153 :rtype: List[db.Entity]
154 """
155 # res is a list of iterators at this point, chain them together
156 res = chain(*[list(x) for x in result])
157 # Remove duplicates
158 tmpDict = {}
159 for item in res:
160 tmpDict[str(item.key)] = item
161 res = list(tmpDict.values())
162 # Slice the requested amount of results our 3times lager set
163 res = sample(res, min(len(res), targetAmount))
164 shuffle(res)
165 return res