comparison roundup/hyperdb.py @ 8416:370689471a08 issue2550923_computed_property

merge from default branch accumulated changes since Nov 2023
author John Rouillard <rouilj@ieee.org>
date Sun, 17 Aug 2025 16:12:25 -0400
parents 14a8e11f3a87 a81a3cd067fa
children
comparison
equal deleted inserted replaced
7693:78585199552a 8416:370689471a08
19 """Hyperdatabase implementation, especially field types. 19 """Hyperdatabase implementation, especially field types.
20 """ 20 """
21 __docformat__ = 'restructuredtext' 21 __docformat__ = 'restructuredtext'
22 22
23 # standard python modules 23 # standard python modules
24 import copy
24 import logging 25 import logging
25 import os 26 import os
26 import re 27 import re
27 import shutil 28 import shutil
28 import sys 29 import sys
29 import traceback 30 import traceback
30 import weakref 31 import weakref
31 32
33 from hashlib import md5
34
32 # roundup modules 35 # roundup modules
33 from . import date, password 36 from . import date, password
34 from .support import ensureParentsExist, PrioList 37 from .support import ensureParentsExist, PrioList
35 from roundup.mlink_expr import Expression 38 from roundup.mlink_expr import Expression
36 from roundup.i18n import _ 39 from roundup.i18n import _
37 from roundup.cgi.exceptions import DetectorError 40 from roundup.cgi.exceptions import DetectorError
38 from roundup.anypy.cmp_ import NoneAndDictComparable 41 from roundup.anypy.cmp_ import NoneAndDictComparable
39 from roundup.anypy.strings import eval_import 42 from roundup.anypy.strings import b2s, bs2b, eval_import
40 43
41 logger = logging.getLogger('roundup.hyperdb') 44 logger = logging.getLogger('roundup.hyperdb')
45
46 # marker used for an unspecified keyword argument
47 _marker = []
42 48
43 49
44 # 50 #
45 # Types 51 # Types
46 # 52 #
191 return val 197 return val
192 return str(val) 198 return str(val)
193 199
194 200
195 class Date(_Type): 201 class Date(_Type):
196 """An object designating a Date property.""" 202 """An object designating a Date property.
203 The display_time parameter specifies if we want date and time or
204 date only. Both display_time and format are defaults for the
205 field method of the DateHTMLProperty (for rendering html).
206 """
197 def __init__(self, offset=None, required=False, default_value=None, 207 def __init__(self, offset=None, required=False, default_value=None,
198 quiet=False): 208 quiet=False, display_time='yes', format=None):
199 super(Date, self).__init__(required=required, 209 super(Date, self).__init__(required=required,
200 default_value=default_value, 210 default_value=default_value,
201 quiet=quiet) 211 quiet=quiet)
202 self._offset = offset 212 self._offset = offset
213 self.display_time = display_time == 'yes'
214 self.format = format
203 215
204 def offset(self, db): 216 def offset(self, db):
205 if self._offset is not None: 217 if self._offset is not None:
206 return self._offset 218 return self._offset
207 return db.getUserTimezone() 219 return db.getUserTimezone()
1116 def unserialise(self, classname, node): 1128 def unserialise(self, classname, node):
1117 """Decode the marshalled node data 1129 """Decode the marshalled node data
1118 """ 1130 """
1119 return node 1131 return node
1120 1132
1121 def getnode(self, classname, nodeid): 1133 def getnode(self, classname, nodeid, allow_abort=True):
1122 """Get a node from the database. 1134 """Get a node from the database.
1123 1135
1124 'cache' exists for backwards compatibility, and is not used. 1136 'cache' exists for backwards compatibility, and is not used.
1137 'allow_abort' determines if we allow that the current
1138 transaction is aborted due to a data error (e.g. invalid nodeid).
1125 """ 1139 """
1126 raise NotImplementedError 1140 raise NotImplementedError
1127 1141
1128 def hasnode(self, classname, nodeid): 1142 def hasnode(self, classname, nodeid):
1129 """Determine if the database has a given node. 1143 """Determine if the database has a given node.
1286 If an id in a link or multilink property does not refer to a valid 1300 If an id in a link or multilink property does not refer to a valid
1287 node, an IndexError is raised. 1301 node, an IndexError is raised.
1288 """ 1302 """
1289 raise NotImplementedError 1303 raise NotImplementedError
1290 1304
1291 _marker = [] 1305 def get(self, nodeid, propname, default=_marker, cache=1, allow_abort=True):
1292
1293 def get(self, nodeid, propname, default=_marker, cache=1):
1294 """Get the value of a property on an existing node of this class. 1306 """Get the value of a property on an existing node of this class.
1295 1307
1296 'nodeid' must be the id of an existing node of this class or an 1308 'nodeid' must be the id of an existing node of this class or an
1297 IndexError is raised. 'propname' must be the name of a property 1309 IndexError is raised. 'propname' must be the name of a property
1298 of this class or a KeyError is raised. 1310 of this class or a KeyError is raised.
1299 1311
1300 'cache' exists for backwards compatibility, and is not used. 1312 'cache' exists for backwards compatibility, and is not used.
1313 'allow_abort' determines if we allow that the current
1314 transaction is aborted due to a data error (e.g. invalid nodeid).
1301 """ 1315 """
1302 raise NotImplementedError 1316 raise NotImplementedError
1303 1317
1304 # not in spec 1318 # not in spec
1305 def getnode(self, nodeid): 1319 def getnode(self, nodeid):
1353 1367
1354 Make node available for all operations like it was before retirement. 1368 Make node available for all operations like it was before retirement.
1355 """ 1369 """
1356 raise NotImplementedError 1370 raise NotImplementedError
1357 1371
1358 def is_retired(self, nodeid): 1372 def is_retired(self, nodeid, allow_abort=True):
1359 """Return true if the node is rerired 1373 """Return true if the node is rerired
1374 'allow_abort' specifies if we allow the transaction to be
1375 aborted if a syntactically invalid nodeid is passed.
1360 """ 1376 """
1361 raise NotImplementedError 1377 raise NotImplementedError
1362 1378
1363 def destroy(self, nodeid): 1379 def destroy(self, nodeid):
1364 """Destroy a node. 1380 """Destroy a node.
1859 # cache for each id returned. Note that the filter_iter doesn't 1875 # cache for each id returned. Note that the filter_iter doesn't
1860 # promise to correctly sort by multilink (which isn't sane to do 1876 # promise to correctly sort by multilink (which isn't sane to do
1861 # anyway). 1877 # anyway).
1862 filter_iter = filter 1878 filter_iter = filter
1863 1879
1880 def filter_with_permissions(self, search_matches, filterspec, sort=[],
1881 group=[], retired=False, exact_match_spec={},
1882 limit=None, offset=None,
1883 permission='View', userid=None):
1884 """ Do the same as filter but return only the items the user is
1885 entitled to see, running the results through security checks.
1886 The userid defaults to the current database user.
1887 """
1888 if userid is None:
1889 userid = self.db.getuid()
1890 cn = self.classname
1891 sec = self.db.security
1892 filterspec = sec.filterFilterspec(userid, cn, filterspec)
1893 if exact_match_spec:
1894 exact_match_spec = sec.filterFilterspec(userid, cn,
1895 exact_match_spec)
1896 sort = sec.filterSortspec(userid, cn, sort)
1897 group = sec.filterSortspec(userid, cn, group)
1898 item_ids = self.filter(search_matches, filterspec, sort, group,
1899 retired, exact_match_spec, limit, offset)
1900 check = sec.hasPermission
1901 if check(permission, userid, cn, skip_permissions_with_check = True):
1902 allowed = item_ids
1903 else:
1904 debug = self.db.config.RDBMS_DEBUG_FILTER
1905 # Note that is_filterable returns True if no permissions are
1906 # found. This makes it fail early (with an empty allowed list)
1907 # instead of running through all ids with an empty
1908 # permission list.
1909 if not debug and sec.is_filterable(permission, userid, cn):
1910 new_ids = set(item_ids)
1911 confirmed = set()
1912 for perm in sec.filter_iter(permission, userid, cn):
1913 fargs = perm.filter(self.db, userid, self)
1914 for farg in fargs:
1915 farg.update(sort=[], group=[], retired=None)
1916 result = self.filter(list(new_ids), **farg)
1917 new_ids.difference_update(result)
1918 confirmed.update(result)
1919 # all allowed?
1920 if not new_ids:
1921 break
1922 # all allowed?
1923 if not new_ids:
1924 break
1925 # Need to sort again in database
1926 allowed = self.filter(confirmed, {}, sort=sort, group=group,
1927 retired=None)
1928 else: # Last resort: filter in python
1929 allowed = [id for id in item_ids
1930 if check(permission, userid, cn, itemid=id)]
1931 return allowed
1932
1933
1864 def count(self): 1934 def count(self):
1865 """Get the number of nodes in this class. 1935 """Get the number of nodes in this class.
1866 1936
1867 If the returned integer is 'numnodes', the ids of all the nodes 1937 If the returned integer is 'numnodes', the ids of all the nodes
1868 in this class run from 1 to numnodes, and numnodes+1 will be the 1938 in this class run from 1 to numnodes, and numnodes+1 will be the
2101 2171
2102 return value 2172 return value
2103 2173
2104 2174
2105 class FileClass: 2175 class FileClass:
2106 """ A class that requires the "content" property and stores it on 2176 """ This class defines a large chunk of data. To support this, it
2107 disk. 2177 has a mandatory String property "content" which is saved off
2178 externally to the hyperdb.
2179
2180 The default MIME type of this data is defined by the
2181 "default_mime_type" class attribute, which may be overridden by
2182 each node if the class defines a "type" String property.
2108 """ 2183 """
2109 default_mime_type = 'text/plain' 2184 default_mime_type = 'text/plain'
2110 2185
2111 def __init__(self, db, classname, **properties): 2186 def _update_properties(self, properties):
2112 """The newly-created class automatically includes the "content" 2187 """The newly-created class automatically includes the "content"
2113 property. 2188 and "type" properties. This method must be called by __init__.
2114 """ 2189 """
2115 if 'content' not in properties: 2190 if 'content' not in properties:
2116 properties['content'] = String(indexme='yes') 2191 properties['content'] = String(indexme='yes')
2192 if 'type' not in properties:
2193 properties['type'] = String()
2194
2195 def create(self, **propvalues):
2196 """ snaffle the file propvalue and store in a file
2197 """
2198 # we need to fire the auditors now, or the content property won't
2199 # be in propvalues for the auditors to play with
2200 self.fireAuditors('create', None, propvalues)
2201
2202 # now remove the content property so it's not stored in the db
2203 content = propvalues['content']
2204 del propvalues['content']
2205
2206 # do the database create
2207 newid = self.create_inner(**propvalues)
2208
2209 # figure the mime type
2210 mime_type = propvalues.get('type', self.default_mime_type)
2211
2212 # optionally index
2213 # This wasn't done for the anydbm backend (but the 'set' method
2214 # *did* update the index) so this is probably a bug-fix for anydbm
2215 if self.properties['content'].indexme:
2216 index_content = content
2217 if bytes != str and isinstance(content, bytes):
2218 index_content = content.decode('utf-8', errors='ignore')
2219 self.db.indexer.add_text((self.classname, newid, 'content'),
2220 index_content, mime_type)
2221
2222 # store off the content as a file
2223 self.db.storefile(self.classname, newid, None, bs2b(content))
2224
2225 # fire reactors
2226 self.fireReactors('create', newid, None)
2227
2228 return newid
2117 2229
2118 def export_propnames(self): 2230 def export_propnames(self):
2119 """ Don't export the "content" property 2231 """ Don't export the "content" property
2120 """ 2232 """
2121 propnames = list(self.getprops().keys()) 2233 propnames = list(self.getprops().keys())
2138 source = self.db.filename(self.classname, nodeid) 2250 source = self.db.filename(self.classname, nodeid)
2139 2251
2140 dest = self.exportFilename(dirname, nodeid) 2252 dest = self.exportFilename(dirname, nodeid)
2141 ensureParentsExist(dest) 2253 ensureParentsExist(dest)
2142 shutil.copyfile(source, dest) 2254 shutil.copyfile(source, dest)
2255
2256 def get(self, nodeid, propname, default=_marker, cache=1, allow_abort=True):
2257 """ Trap the content propname and get it from the file
2258
2259 'cache' exists for backwards compatibility, and is not used.
2260
2261 'allow_abort' determines if we allow that the current
2262 transaction is aborted due to a data error (e.g. invalid nodeid).
2263 """
2264 poss_msg = 'Possibly an access right configuration problem.'
2265 if propname == 'content':
2266 try:
2267 return b2s(self.db.getfile(self.classname, nodeid, None))
2268 except IOError as strerror:
2269 # BUG: by catching this we don't see an error in the log.
2270 return 'ERROR reading file: %s%s\n%s\n%s' % (
2271 self.classname, nodeid, poss_msg, strerror)
2272 except UnicodeDecodeError:
2273 # if content is not text (e.g. jpeg file) we get
2274 # unicode error trying to convert to string in python 3.
2275 # trap it and supply an error message. Include md5sum
2276 # of content as this string is included in the etag
2277 # calculation of the object.
2278 return ('%s%s is not text, retrieve using '
2279 'binary_content property. mdsum: %s') % (
2280 self.classname, nodeid,
2281 md5(self.db.getfile(
2282 self.classname,
2283 nodeid,
2284 None)).hexdigest()) # nosec - bandit md5 use ok
2285 elif propname == 'binary_content':
2286 return self.db.getfile(self.classname, nodeid, None)
2287
2288 if default is not _marker:
2289 return self.subclass.get(self, nodeid, propname, default,
2290 allow_abort=allow_abort)
2291 else:
2292 return self.subclass.get(self, nodeid, propname,
2293 allow_abort=allow_abort)
2143 2294
2144 def import_files(self, dirname, nodeid): 2295 def import_files(self, dirname, nodeid):
2145 """ Import the "content" property as a file 2296 """ Import the "content" property as a file
2146 """ 2297 """
2147 source = self.exportFilename(dirname, nodeid) 2298 source = self.exportFilename(dirname, nodeid)
2164 # other types. So if mime type of file is correct, we 2315 # other types. So if mime type of file is correct, we
2165 # call add_text on content. 2316 # call add_text on content.
2166 self.db.indexer.add_text((self.classname, nodeid, 'content'), 2317 self.db.indexer.add_text((self.classname, nodeid, 'content'),
2167 index_content, mime_type) 2318 index_content, mime_type)
2168 2319
2320 def index(self, nodeid):
2321 """ Add (or refresh) the node to search indexes.
2322
2323 Use the content-type property for the content property.
2324 """
2325 # find all the String properties that have indexme
2326 for prop, propclass in self.getprops().items():
2327 if prop == 'content' and propclass.indexme:
2328 mime_type = self.get(nodeid, 'type', self.default_mime_type)
2329 index_content = self.get(nodeid, 'binary_content')
2330 if bytes != str and isinstance(index_content, bytes):
2331 index_content = index_content.decode('utf-8',
2332 errors='ignore')
2333 self.db.indexer.add_text((self.classname, nodeid, 'content'),
2334 index_content, mime_type)
2335 elif isinstance(propclass, String) and propclass.indexme:
2336 # index them under (classname, nodeid, property)
2337 try:
2338 value = str(self.get(nodeid, prop))
2339 except IndexError:
2340 # node has been destroyed
2341 continue
2342 self.db.indexer.add_text((self.classname, nodeid, prop), value)
2343
2344 def set(self, itemid, **propvalues):
2345 """ Snarf the "content" propvalue and update it in a file
2346 """
2347 self.fireAuditors('set', itemid, propvalues)
2348
2349 # create the oldvalues dict - fill in any missing values
2350 oldvalues = copy.deepcopy(self.db.getnode(self.classname, itemid))
2351 # The following is redundant for rdbms backends but needed for anydbm
2352 # the performance impact is so low we that we don't factor this.
2353 for name, prop in self.getprops(protected=0).items():
2354 if name in oldvalues:
2355 continue
2356 if isinstance(prop, Multilink):
2357 oldvalues[name] = []
2358 else:
2359 oldvalues[name] = None
2360
2361 # now remove the content property so it's not stored in the db
2362 content = None
2363 if 'content' in propvalues:
2364 content = propvalues['content']
2365 del propvalues['content']
2366
2367 # do the database update
2368 propvalues = self.set_inner(itemid, **propvalues)
2369
2370 # do content?
2371 if content:
2372 # store and possibly index
2373 self.db.storefile(self.classname, itemid, None, bs2b(content))
2374 if self.properties['content'].indexme:
2375 index_content = content
2376 if bytes != str and isinstance(content, bytes):
2377 index_content = content.decode('utf-8', errors='ignore')
2378 mime_type = self.get(itemid, 'type', self.default_mime_type)
2379 self.db.indexer.add_text((self.classname, itemid, 'content'),
2380 index_content, mime_type)
2381 propvalues['content'] = content
2382
2383 # fire reactors
2384 self.fireReactors('set', itemid, oldvalues)
2385 return propvalues
2169 2386
2170 class Node: 2387 class Node:
2171 """ A convenience wrapper for the given node 2388 """ A convenience wrapper for the given node
2172 """ 2389 """
2173 def __init__(self, cl, nodeid, cache=1): 2390 def __init__(self, cl, nodeid, cache=1):

Roundup Issue Tracker: http://roundup-tracker.org/