diff --git a/evennia/commands/default/building.py b/evennia/commands/default/building.py index 309144af73..2ed20f171b 100644 --- a/evennia/commands/default/building.py +++ b/evennia/commands/default/building.py @@ -3,7 +3,7 @@ Building and world design commands """ import re from django.conf import settings -from django.db.models import Q +from django.db.models import Q, Min, Max from evennia.objects.models import ObjectDB from evennia.locks.lockhandler import LockException from evennia.commands.cmdhandler import get_and_merge_cmdsets @@ -13,6 +13,7 @@ from evennia.utils.utils import ( class_from_module, get_all_typeclasses, variable_from_module, + dbref, ) from evennia.utils.eveditor import EvEditor from evennia.utils.evmore import EvMore @@ -2641,7 +2642,7 @@ class CmdFind(COMMAND_DEFAULT_CLASS): caller = self.caller switches = self.switches - if not self.args: + if not self.args or (not self.lhs and not self.rhs): caller.msg("Usage: find [= low [-high]]") return @@ -2649,18 +2650,40 @@ class CmdFind(COMMAND_DEFAULT_CLASS): switches.append("loc") searchstring = self.lhs - low, high = 1, ObjectDB.objects.all().order_by("-id")[0].id + + try: + # Try grabbing the actual min/max id values by database aggregation + qs = ObjectDB.objects.values('id').aggregate(low=Min('id'), high=Max('id')) + low, high = sorted(qs.values()) + if not (low and high): + raise ValueError(f"{self.__class__.__name__}: Min and max ID not returned by aggregation; falling back to queryset slicing.") + except Exception as e: + logger.log_trace(e) + # If that doesn't work for some reason (empty DB?), guess the lower + # bound and do a less-efficient query to find the upper. + low, high = 1, ObjectDB.objects.all().order_by("-id").first().id + if self.rhs: - if "-" in self.rhs: - # also support low-high syntax - limlist = [part.lstrip("#").strip() for part in self.rhs.split("-", 1)] - else: - # otherwise split by space - limlist = [part.lstrip("#") for part in self.rhs.split(None, 1)] - if limlist and limlist[0].isdigit(): - low = max(low, int(limlist[0])) - if len(limlist) > 1 and limlist[1].isdigit(): - high = min(high, int(limlist[1])) + try: + # Check that rhs is either a valid dbref or dbref range + bounds = tuple(sorted(dbref(x, False) for x in re.split('[-\s]+', self.rhs.strip()))) + + # dbref() will return either a valid int or None + assert bounds + # None should not exist in the bounds list + assert None not in bounds + + low = bounds[0] + if len(bounds) > 1: + high = bounds[-1] + + except AssertionError: + caller.msg("Invalid dbref range provided (not a number).") + return + except IndexError as e: + logger.log_err(f"{self.__class__.__name__}: Error parsing upper and lower bounds of query.") + logger.log_trace(e) + low = min(low, high) high = max(low, high) @@ -2672,7 +2695,6 @@ class CmdFind(COMMAND_DEFAULT_CLASS): restrictions = ", %s" % (", ".join(self.switches)) if is_dbref or is_account: - if is_dbref: # a dbref search result = caller.search(searchstring, global_search=True, quiet=True) @@ -2703,7 +2725,7 @@ class CmdFind(COMMAND_DEFAULT_CLASS): ) else: # Not an account/dbref search but a wider search; build a queryset. - # Searchs for key and aliases + # Searches for key and aliases if "exact" in switches: keyquery = Q(db_key__iexact=searchstring, id__gte=low, id__lte=high) aliasquery = Q( @@ -2729,39 +2751,43 @@ class CmdFind(COMMAND_DEFAULT_CLASS): id__lte=high, ) - results = ObjectDB.objects.filter(keyquery | aliasquery).distinct() - nresults = results.count() - - if nresults: - # convert result to typeclasses. - results = [result for result in results] - if "room" in switches: - results = [obj for obj in results if inherits_from(obj, ROOM_TYPECLASS)] - if "exit" in switches: - results = [obj for obj in results if inherits_from(obj, EXIT_TYPECLASS)] - if "char" in switches: - results = [obj for obj in results if inherits_from(obj, CHAR_TYPECLASS)] - nresults = len(results) + # Keep the initial queryset handy for later reuse + result_qs = ObjectDB.objects.filter(keyquery | aliasquery).distinct() + nresults = result_qs.count() + + # Use iterator to minimize memory ballooning on large result sets + results = result_qs.iterator() + + # Check and see if type filtering was requested; skip it if not + if any(x in switches for x in ("room", "exit", "char")): + obj_ids = set() + for obj in results: + if ("room" in switches and inherits_from(obj, ROOM_TYPECLASS)) \ + or ("exit" in switches and inherits_from(obj, EXIT_TYPECLASS)) \ + or ("char" in switches and inherits_from(obj, CHAR_TYPECLASS)): + obj_ids.add(obj.id) + + # Filter previous queryset instead of requesting another + filtered_qs = result_qs.filter(id__in=obj_ids).distinct() + nresults = filtered_qs.count() + + # Use iterator again to minimize memory ballooning + results = filtered_qs.iterator() # still results after type filtering? if nresults: - if nresults > 1: - string = "|w%i Matches|n(#%i-#%i%s):" % (nresults, low, high, restrictions) - for res in results: - string += "\n |g%s - %s|n" % (res.get_display_name(caller), res.path) - else: - string = "|wOne Match|n(#%i-#%i%s):" % (low, high, restrictions) - string += "\n |g%s - %s|n" % ( - results[0].get_display_name(caller), - results[0].path, - ) - if "loc" in self.switches and nresults == 1 and results[0].location: - string += " (|wlocation|n: |g{}|n)".format( - results[0].location.get_display_name(caller) - ) + if nresults > 1: header = f'{nresults} Matches' + else: header = 'One Match' + + string = f"|w{header}|n(#{low}-#{high}{restrictions}):" + res = None + for res in results: + string += f"\n |g{res.get_display_name(caller)} - {res.path}|n" + if "loc" in self.switches and nresults == 1 and res and getattr(res, 'location', None): + string += f" (|wlocation|n: |g{res.location.get_display_name(caller)}|n)" else: - string = "|wMatch|n(#%i-#%i%s):" % (low, high, restrictions) - string += "\n |RNo matches found for '%s'|n" % searchstring + string = f"|wNo Matches|n(#{low}-#{high}{restrictions}):" + string += f"\n |RNo matches found for '{searchstring}'|n" # send result caller.msg(string.strip()) diff --git a/evennia/commands/default/tests.py b/evennia/commands/default/tests.py index 38d5646e04..c6ae160361 100644 --- a/evennia/commands/default/tests.py +++ b/evennia/commands/default/tests.py @@ -1038,11 +1038,32 @@ class TestBuilding(CommandTest): self.call(building.CmdFind(), self.char1.dbref, "Exact dbref match") self.call(building.CmdFind(), "*TestAccount", "Match") - self.call(building.CmdFind(), "/char Obj") - self.call(building.CmdFind(), "/room Obj") - self.call(building.CmdFind(), "/exit Obj") + self.call(building.CmdFind(), "/char Obj", "No Matches") + self.call(building.CmdFind(), "/room Obj", "No Matches") + self.call(building.CmdFind(), "/exit Obj", "No Matches") self.call(building.CmdFind(), "/exact Obj", "One Match") - + + # Test multitype filtering + with mock.patch('evennia.commands.default.building.CHAR_TYPECLASS', 'evennia.objects.objects.DefaultCharacter'): + self.call(building.CmdFind(), "/char/room Obj", "No Matches") + self.call(building.CmdFind(), "/char/room/exit Char", "2 Matches") + self.call(building.CmdFind(), "/char/room/exit/startswith Cha", "2 Matches") + + # Test null search + self.call(building.CmdFind(), "=", "Usage: ") + + # Test bogus dbref range with no search term + self.call(building.CmdFind(), "= obj", "Invalid dbref range provided (not a number).") + self.call(building.CmdFind(), "= #1a", "Invalid dbref range provided (not a number).") + + # Test valid dbref ranges with no search term + self.call(building.CmdFind(), "=#1", "7 Matches(#1-#7)") + self.call(building.CmdFind(), "=1-2", "2 Matches(#1-#2):") + self.call(building.CmdFind(), "=1 - 2", "2 Matches(#1-#2):") + self.call(building.CmdFind(), "=1- #2", "2 Matches(#1-#2):") + self.call(building.CmdFind(), "=1-#2", "2 Matches(#1-#2):") + self.call(building.CmdFind(), "=#1-2", "2 Matches(#1-#2):") + def test_script(self): self.call(building.CmdScript(), "Obj = ", "No scripts defined on Obj") self.call( @@ -1479,4 +1500,4 @@ class TestSystemCommands(CommandTest): mock_channeldb.objects.get_channel = mock.MagicMock(return_value=channel) self.call(syscommands.SystemSendToChannel(), "public:Hello") - channel.msg.assert_called() + channel.msg.assert_called() \ No newline at end of file