Merge pull request #3541 from InspectorCaracal/improve-obj-search
Rework Object searching to behave more consistently
This commit is contained in:
commit
e01f79acc2
4 changed files with 110 additions and 106 deletions
|
|
@ -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.")
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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")
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue