Merge pull request #3541 from InspectorCaracal/improve-obj-search

Rework Object searching to behave more consistently
This commit is contained in:
Griatch 2024-09-29 09:42:49 +02:00 committed by GitHub
commit e01f79acc2
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 110 additions and 106 deletions

View file

@ -527,6 +527,9 @@ class CmdRemove(MuxCommand):
help_category = "clothing" help_category = "clothing"
def func(self): def func(self):
if not self.args:
self.caller.msg("Usage: remove <worn clothing object>")
return
clothing = self.caller.search(self.args, candidates=self.caller.contents) clothing = self.caller.search(self.args, candidates=self.caller.contents)
if not clothing: if not clothing:
self.caller.msg("You don't have anything like that.") self.caller.msg("You don't have anything like that.")

View file

@ -85,7 +85,7 @@ class TestClothingCmd(BaseEvenniaCommandTest):
) )
# Test remove command. # Test remove command.
self.call(clothing.CmdRemove(), "", "Could not find ''.", caller=self.wearer) self.call(clothing.CmdRemove(), "", "Usage: remove <worn clothing object>", caller=self.wearer)
self.call( self.call(
clothing.CmdRemove(), clothing.CmdRemove(),
"hat", "hat",

View file

@ -281,8 +281,7 @@ class ObjectDBManager(TypedObjectManager):
Args: Args:
ostring (str): A search criterion. ostring (str): A search criterion.
exact (bool, optional): Require exact match of ostring exact (bool, optional): Require exact match of ostring
(still case-insensitive). If `False`, will do fuzzy matching (still case-insensitive). If `False`, will do fuzzy matching with a regex filter.
using `evennia.utils.utils.string_partial_matching` algorithm.
candidates (list): Only match among these candidates. candidates (list): Only match among these candidates.
typeclasses (list): Only match objects with typeclasses having thess path strings. 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() 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() type_restriction = typeclasses and Q(db_typeclass_path__in=make_iter(typeclasses)) or Q()
if exact: if exact:
# exact match - do direct search # exact matches only
return ( return (
( (
self.filter( self.filter(
@ -321,50 +320,26 @@ class ObjectDBManager(TypedObjectManager):
.distinct() .distinct()
.order_by("id") .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 = [] # convert search term to partial-match regex
index_matches = string_partial_matching(key_strings, ostring, ret_index=True) search_regex = r".* ".join(re.escape(word) for word in ostring.split()) + r'.*'
if index_matches:
# a match by key # do the fuzzy search and return whatever it matches
match_ids = [ return (
obj.id for ind, obj in enumerate(search_candidates) if ind in index_matches (
] self.filter(
else: cand_restriction
# match by alias rather than by key & type_restriction
search_candidates = search_candidates.filter( & (
db_tags__db_tagtype__iexact="alias", db_tags__db_key__icontains=ostring Q(db_key__iregex=search_regex)
).distinct() | Q(db_tags__db_key__iregex=search_regex)
alias_strings = [] & Q(db_tags__db_tagtype__iexact="alias")
alias_candidates = [] )
# TODO create the alias_strings and alias_candidates lists more efficiently? )
for candidate in search_candidates: )
for alias in candidate.aliases.all(): .distinct()
alias_strings.append(alias) .order_by("id")
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)
# main search methods and helper functions # 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 Search as an object globally or in a list of candidates and
return results. The result is always an Object. Always returns return results. Always returns a QuerySet of Objects.
a list.
Args: Args:
searchdata (str or Object): The entity to match for. This is searchdata (str or Object): The entity to match for. This is
usually a key string but may also be an object itself. usually a key string but may also be an object itself.
By default (if no `attribute_name` is set), this will 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 Can also be on the form #dbref, which will (if
`exact=True`) be matched against primary key. `exact=True`) be matched against primary key.
attribute_name (str): Use this named Attribute to attribute_name (str): Use this named Attribute to
@ -417,63 +391,43 @@ class ObjectDBManager(TypedObjectManager):
a match. a match.
Returns: Returns:
matches (list): Matching objects matches (QuerySet): Matching objects
""" """
def _searcher(searchdata, candidates, typeclass, exact=False): def _searcher(searchdata, candidates, typeclass, exact=False):
""" """
Helper method for searching objects. `typeclass` is only used Helper method for searching objects.
for global searching (no candidates)
""" """
if attribute_name: if attribute_name:
# attribute/property search (always exact). # attribute/property search (always exact).
matches = self.get_objs_with_db_property_value( matches = self.get_objs_with_db_property_value(
attribute_name, searchdata, candidates=candidates, typeclasses=typeclass attribute_name, searchdata, candidates=candidates, typeclasses=typeclass
) )
if matches: if not matches:
return matches matches = self.get_objs_with_attr_value(
return self.get_objs_with_attr_value( attribute_name, searchdata, candidates=candidates, typeclasses=typeclass
attribute_name, searchdata, candidates=candidates, typeclasses=typeclass )
)
else: else:
# normal key/alias search # 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 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): return matches
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
if candidates is not None: if candidates is not None:
if not candidates: if not candidates:
# candidates is the empty list. This should mean no matches can ever be acquired. # candidates is an empty list. This should mean no matches can ever be acquired.
return [] return self.none()
# Convenience check to make sure candidates are really dbobjs # Convenience check to make sure candidates are really dbobjs
candidates = [cand for cand in make_iter(candidates) if cand] 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) dbref = not attribute_name and exact and use_dbref and self.dbref(searchdata)
if dbref: if dbref:
@ -486,51 +440,54 @@ class ObjectDBManager(TypedObjectManager):
else: else:
return self.none() 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. # Search through all possibilities.
match_number = None match_number = None
# always run first check exact - we don't want partial matches # always run first check exact - we don't want partial matches
# if on the form of 1-keyword etc. # if on the form of 1-keyword etc.
matches = _searcher(searchdata, candidates, typeclass, exact=True) matches = _searcher(searchdata, candidates, typeclass, exact=True)
stripped_searchdata = searchdata
if not matches: if not matches:
# no matches found - check if we are dealing with N-keyword # no matches found - check if we are dealing with N-keyword
# query - if so, strip it. # query - if so, strip it.
match = _MULTIMATCH_REGEX.match(str(searchdata)) match_data = _MULTIMATCH_REGEX.match(str(searchdata))
match_number = None match_number = None
stripped_searchdata = searchdata if match_data:
if match:
# strips the number # 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 match_number = int(match_number) - 1
if match_number is not None: if match_number is not None:
# run search against the stripped data # run search against the stripped data
matches = _searcher(stripped_searchdata, candidates, typeclass, exact=True) 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: # at this point, if there are no matches, we give it a chance to find fuzzy matches
matches = _search_by_tag(matches, make_iter(tags)) 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 # deal with result
if len(matches) == 1 and match_number is not None and match_number != 0: if match_number is not None:
# 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 0 <= match_number < len(matches): if 0 <= match_number < len(matches):
# limit to one match (we still want a queryset back) # 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) matches = self.filter(id=matches[match_number].id)
else: else:
# a number was given outside of range. This means a no-match. # a number was given outside of range. This means a no-match.
matches = self.none() matches = self.none()
# return a list (possibly empty) # return a QuerySet (possibly empty)
return matches return matches
# alias for backwards compatibility # alias for backwards compatibility

View file

@ -247,6 +247,50 @@ class TestObjectManager(BaseEvenniaTest):
) )
self.assertEqual(list(query), [self.char1]) 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): def test_get_objs_with_attr(self):
self.obj1.db.testattr = "testval1" self.obj1.db.testattr = "testval1"
query = ObjectDB.objects.get_objs_with_attr("testattr") query = ObjectDB.objects.get_objs_with_attr("testattr")