diff --git a/evennia/contrib/game_systems/clothing/clothing.py b/evennia/contrib/game_systems/clothing/clothing.py index 6fe957eb72..11ba05e4b3 100644 --- a/evennia/contrib/game_systems/clothing/clothing.py +++ b/evennia/contrib/game_systems/clothing/clothing.py @@ -527,6 +527,9 @@ class CmdRemove(MuxCommand): help_category = "clothing" def func(self): + if not self.args: + self.caller.msg("Usage: remove ") + return clothing = self.caller.search(self.args, candidates=self.caller.contents) if not clothing: self.caller.msg("You don't have anything like that.") diff --git a/evennia/contrib/game_systems/clothing/tests.py b/evennia/contrib/game_systems/clothing/tests.py index 8a2711c3d9..4cc207b7e5 100644 --- a/evennia/contrib/game_systems/clothing/tests.py +++ b/evennia/contrib/game_systems/clothing/tests.py @@ -85,7 +85,7 @@ class TestClothingCmd(BaseEvenniaCommandTest): ) # Test remove command. - self.call(clothing.CmdRemove(), "", "Could not find ''.", caller=self.wearer) + self.call(clothing.CmdRemove(), "", "Usage: remove ", caller=self.wearer) self.call( clothing.CmdRemove(), "hat", diff --git a/evennia/objects/manager.py b/evennia/objects/manager.py index f962d7e55a..afddc809a8 100644 --- a/evennia/objects/manager.py +++ b/evennia/objects/manager.py @@ -281,8 +281,7 @@ class ObjectDBManager(TypedObjectManager): Args: ostring (str): A search criterion. exact (bool, optional): Require exact match of ostring - (still case-insensitive). If `False`, will do fuzzy matching - using `evennia.utils.utils.string_partial_matching` algorithm. + (still case-insensitive). If `False`, will do fuzzy matching with a regex filter. candidates (list): Only match among these candidates. typeclasses (list): Only match objects with typeclasses having thess path strings. @@ -305,7 +304,7 @@ class ObjectDBManager(TypedObjectManager): cand_restriction = candidates is not None and Q(pk__in=candidates_id) or Q() type_restriction = typeclasses and Q(db_typeclass_path__in=make_iter(typeclasses)) or Q() if exact: - # exact match - do direct search + # exact matches only return ( ( self.filter( @@ -321,50 +320,26 @@ class ObjectDBManager(TypedObjectManager): .distinct() .order_by("id") ) - elif candidates: - # fuzzy with candidates - search_candidates = ( - self.filter(cand_restriction & type_restriction).distinct().order_by("id") - ) - else: - # fuzzy without supplied candidates - we select our own candidates - search_candidates = ( - self.filter( - type_restriction - & (Q(db_key__icontains=ostring) | Q(db_tags__db_key__icontains=ostring)) - ) - .distinct() - .order_by("id") - ) - # fuzzy matching - key_strings = search_candidates.values_list("db_key", flat=True).order_by("id") - match_ids = [] - index_matches = string_partial_matching(key_strings, ostring, ret_index=True) - if index_matches: - # a match by key - match_ids = [ - obj.id for ind, obj in enumerate(search_candidates) if ind in index_matches - ] - else: - # match by alias rather than by key - search_candidates = search_candidates.filter( - db_tags__db_tagtype__iexact="alias", db_tags__db_key__icontains=ostring - ).distinct() - alias_strings = [] - alias_candidates = [] - # TODO create the alias_strings and alias_candidates lists more efficiently? - for candidate in search_candidates: - for alias in candidate.aliases.all(): - alias_strings.append(alias) - alias_candidates.append(candidate) - index_matches = string_partial_matching(alias_strings, ostring, ret_index=True) - if index_matches: - # it's possible to have multiple matches to the same Object, we must weed those out - match_ids = [alias_candidates[ind].id for ind in index_matches] - # TODO - not ideal to have to do a second lookup here, but we want to return a queryset - # rather than a list ... maybe the above queries can be improved. - return self.filter(id__in=match_ids) + # convert search term to partial-match regex + search_regex = r".* ".join(re.escape(word) for word in ostring.split()) + r'.*' + + # do the fuzzy search and return whatever it matches + return ( + ( + self.filter( + cand_restriction + & type_restriction + & ( + Q(db_key__iregex=search_regex) + | Q(db_tags__db_key__iregex=search_regex) + & Q(db_tags__db_tagtype__iexact="alias") + ) + ) + ) + .distinct() + .order_by("id") + ) # main search methods and helper functions @@ -380,14 +355,13 @@ class ObjectDBManager(TypedObjectManager): ): """ Search as an object globally or in a list of candidates and - return results. The result is always an Object. Always returns - a list. + return results. Always returns a QuerySet of Objects. Args: searchdata (str or Object): The entity to match for. This is usually a key string but may also be an object itself. By default (if no `attribute_name` is set), this will - search `object.key` and `object.aliases` in order. + search `object.key` and `object.aliases`. Can also be on the form #dbref, which will (if `exact=True`) be matched against primary key. attribute_name (str): Use this named Attribute to @@ -417,63 +391,43 @@ class ObjectDBManager(TypedObjectManager): a match. Returns: - matches (list): Matching objects + matches (QuerySet): Matching objects """ def _searcher(searchdata, candidates, typeclass, exact=False): """ - Helper method for searching objects. `typeclass` is only used - for global searching (no candidates) + Helper method for searching objects. """ if attribute_name: # attribute/property search (always exact). matches = self.get_objs_with_db_property_value( attribute_name, searchdata, candidates=candidates, typeclasses=typeclass ) - if matches: - return matches - return self.get_objs_with_attr_value( - attribute_name, searchdata, candidates=candidates, typeclasses=typeclass - ) + if not matches: + matches = self.get_objs_with_attr_value( + attribute_name, searchdata, candidates=candidates, typeclasses=typeclass + ) else: # normal key/alias search - return self.get_objs_with_key_or_alias( + matches = self.get_objs_with_key_or_alias( searchdata, exact=exact, candidates=candidates, typeclasses=typeclass ) + if matches and tags: + # additionally filter matches by tags + for tagkey, tagcategory in tags: + matches = matches.filter( + db_tags__db_key=tagkey, db_tags__db_category=tagcategory + ) - def _search_by_tag(query, taglist): - for tagkey, tagcategory in taglist: - query = query.filter(db_tags__db_key=tagkey, db_tags__db_category=tagcategory) - - return query - - if not searchdata and searchdata != 0: - if tags: - return _search_by_tag(self.all(), make_iter(tags)) - - return self.none() - - if typeclass: - # typeclass may also be a list - typeclasses = make_iter(typeclass) - for i, typeclass in enumerate(make_iter(typeclasses)): - if callable(typeclass): - typeclasses[i] = "%s.%s" % (typeclass.__module__, typeclass.__name__) - else: - typeclasses[i] = "%s" % typeclass - typeclass = typeclasses + return matches if candidates is not None: if not candidates: - # candidates is the empty list. This should mean no matches can ever be acquired. - return [] + # candidates is an empty list. This should mean no matches can ever be acquired. + return self.none() # Convenience check to make sure candidates are really dbobjs candidates = [cand for cand in make_iter(candidates) if cand] - if typeclass: - candidates = [ - cand for cand in candidates if _GA(cand, "db_typeclass_path") in typeclass - ] dbref = not attribute_name and exact and use_dbref and self.dbref(searchdata) if dbref: @@ -486,51 +440,54 @@ class ObjectDBManager(TypedObjectManager): else: return self.none() + if typeclass: + # typeclass may be a string, a typeclass, or a list + typeclasses = make_iter(typeclass) + for i, typeclass in enumerate(make_iter(typeclasses)): + if callable(typeclass): + typeclasses[i] = "%s.%s" % (typeclass.__module__, typeclass.__name__) + else: + typeclasses[i] = "%s" % typeclass + typeclass = typeclasses + # Search through all possibilities. match_number = None # always run first check exact - we don't want partial matches # if on the form of 1-keyword etc. matches = _searcher(searchdata, candidates, typeclass, exact=True) + stripped_searchdata = searchdata if not matches: # no matches found - check if we are dealing with N-keyword # query - if so, strip it. - match = _MULTIMATCH_REGEX.match(str(searchdata)) + match_data = _MULTIMATCH_REGEX.match(str(searchdata)) match_number = None - stripped_searchdata = searchdata - if match: + if match_data: # strips the number - match_number, stripped_searchdata = match.group("number"), match.group("name") + match_number, stripped_searchdata = match_data.group("number"), match_data.group( + "name" + ) match_number = int(match_number) - 1 if match_number is not None: # run search against the stripped data matches = _searcher(stripped_searchdata, candidates, typeclass, exact=True) - if not matches: - # final chance to get a looser match against the number-strippped query - matches = _searcher(stripped_searchdata, candidates, typeclass, exact=False) - elif not exact: - matches = _searcher(searchdata, candidates, typeclass, exact=False) - if tags: - matches = _search_by_tag(matches, make_iter(tags)) + # at this point, if there are no matches, we give it a chance to find fuzzy matches + if not exact and not matches: + # we use stripped_searchdata in case a match number was included + matches = _searcher(stripped_searchdata, candidates, typeclass, exact=False) # deal with result - if len(matches) == 1 and match_number is not None and match_number != 0: - # this indicates trying to get a single match with a match-number - # targeting some higher-number match (like 2-box when there is only - # one box in the room). This leads to a no-match. - matches = self.none() - elif len(matches) > 1 and match_number is not None: - # multiple matches, but a number was given to separate them + if match_number is not None: if 0 <= match_number < len(matches): # limit to one match (we still want a queryset back) - # TODO: Can we do this some other way and avoid a second lookup? + # NOTE: still haven't found a way to avoid a second lookup matches = self.filter(id=matches[match_number].id) else: # a number was given outside of range. This means a no-match. matches = self.none() - # return a list (possibly empty) + # return a QuerySet (possibly empty) return matches # alias for backwards compatibility diff --git a/evennia/objects/tests.py b/evennia/objects/tests.py index df00189d50..4c73bc82a6 100644 --- a/evennia/objects/tests.py +++ b/evennia/objects/tests.py @@ -247,6 +247,50 @@ class TestObjectManager(BaseEvenniaTest): ) self.assertEqual(list(query), [self.char1]) + def test_get_objs_with_key_or_alias(self): + query = ObjectDB.objects.get_objs_with_key_or_alias("Char") + self.assertEqual(list(query), [self.char1]) + query = ObjectDB.objects.get_objs_with_key_or_alias( + "Char", typeclasses="evennia.objects.objects.DefaultObject" + ) + self.assertEqual(list(query), []) + query = ObjectDB.objects.get_objs_with_key_or_alias( + "Char", candidates=[self.char1, self.char2] + ) + self.assertEqual(list(query), [self.char1]) + + self.char1.aliases.add("test alias") + query = ObjectDB.objects.get_objs_with_key_or_alias("test alias") + self.assertEqual(list(query), [self.char1]) + + query = ObjectDB.objects.get_objs_with_key_or_alias("") + self.assertFalse(query) + query = ObjectDB.objects.get_objs_with_key_or_alias("", exact=False) + self.assertEqual(list(query), list(ObjectDB.objects.all().order_by('id'))) + + query = ObjectDB.objects.get_objs_with_key_or_alias( + "", exact=False, typeclasses="evennia.objects.objects.DefaultCharacter" + ) + self.assertEqual(list(query), [self.char1, self.char2]) + + def test_search_object(self): + self.char1.tags.add("test tag") + self.obj1.tags.add("test tag") + + query = ObjectDB.objects.search_object("", exact=False, tags=[("test tag", None)]) + self.assertEqual(list(query), [self.obj1, self.char1]) + + query = ObjectDB.objects.search_object("Char", tags=[("invalid tag", None)]) + self.assertFalse(query) + + query = ObjectDB.objects.search_object( + "", + exact=False, + tags=[("test tag", None)], + typeclass="evennia.objects.objects.DefaultCharacter", + ) + self.assertEqual(list(query), [self.char1]) + def test_get_objs_with_attr(self): self.obj1.db.testattr = "testval1" query = ObjectDB.objects.get_objs_with_attr("testattr")