23 KiB
Searching for things
prev lesson | next lesson
We have gone through how to create the various entities in Evennia. But creating something is of little use if we cannot find and use it afterwards.
Main search functions
The base tools are the evennia.search_* functions, such as evennia.search_object.
rose = evennia.search_object(key="rose")
acct = evennia.search_account(key="MyAccountName", email="foo@bar.com")
What is returned from the main search functions is actually a `queryset`. They can be
treated like lists except that they can't modified in-place. We'll discuss querysets at
the end of this lesson.
Strings are always case-insensitive, so searching for "rose", "Rose" or "rOsE" give the same results.
It's important to remember that what is returned from these search methods is a listing of 0, one or more
elements - all the matches to your search. To get the first match:
rose = rose[0]
Often you really want all matches to the search parameters you specify. In other situations, having zero or more than one match is a sign of a problem and you need to handle this case yourself.
the_one_ring = evennia.search_object(key="The one Ring")
if not the_one_ring:
# handle not finding the ring at all
elif len(the_one_ring) > 1:
# handle finding more than one ring
else:
# ok - exactly one ring found
the_one_ring = the_one_ring[0]
There are equivalent search functions for all the main resources. You can find a listing of them in the Search functions section of the API frontpage.
Searching using Object.search
On the DefaultObject is a .search method which we have already tried out when we made Commands. For
this to be used you must already have an object available:
rose = obj.search("rose")
The .search method wraps evennia.search_object and handles its output in various ways.
- By default it will always search for objects among those in
obj.location.contentsandobj.contents(that is, things in obj's inventory or in the same room). - It will always return exactly one match. If it found zero or more than one match, the return is
None. - On a no-match or multimatch,
.searchwill automatically send an error message toobj.
So this method handles error messaging for you. A very common way to use it is in commands:
from evennia import Command
class MyCommand(Command):
key = "findfoo"
def func(self):
foo = self.caller.search("foo")
if not foo:
return
Remember, self.caller is the one calling the command. This is usually a Character, which
inherits from DefaultObject! This (rather stupid) Command searches for an object named "foo" in
the same location. If it can't find it, foo will be None. The error has already been reported
to self.caller so we just abort with return.
You can use .search to find anything, not just stuff in the same room:
volcano = self.caller.search("Volcano", global=True)
If you only want to search for a specific list of things, you can do so too:
stone = self.caller.search("MyStone", candidates=[obj1, obj2, obj3, obj4])
This will only return a match if MyStone is one of the four provided candidate objects. This is quite powerful, here's how you'd find something only in your inventory:
potion = self.caller.search("Healing potion", candidates=self.caller.contents)
You can also turn off the automatic error handling:
swords = self.search("Sword", quiet=True)
With quiet=True the user will not be notified on zero or multi-match errors. Instead you are expected to handle this
yourself and what you get back is now a list of zero, one or more matches!
What can be searched for
These are the main database entities one can search for:
Most of the time you'll likely spend your time searching for Objects and the occasional Accounts.
So to find an entity, what can be searched for?
Search by key
The key is the name of the entity. Searching for this is always case-insensitive.
Search by aliases
Objects and Accounts can have any number of aliases. When searching for key these will searched too,
you can't easily search only for aliases.
rose.aliases.add("flower")
If the above rose has a key "Rose", it can now also be found by searching for flower. In-game
you can assign new aliases to things with the alias command.
Search by location
Only Objects (things inheriting from evennia.DefaultObject) has a location. This is usually a room.
The Object.search method will automatically limit it search by location, but it also works for the
general search function. If we assume room is a particular Room instance,
chest = evennia.search_object("Treasure chest", location=room)
Search by Tags
Think of a Tag as the label the airport puts on your luggage when flying. Everyone going on the same plane gets a tag grouping them together so the airport can know what should go to which plane. Entities in Evennia can be grouped in the same way. Any number of tags can be attached to each object.
rose.tags.add("flowers")
daffodil.tags.add("flowers")
tulip.tags.add("flowers")
You can now find all flowers using the search_tag function:
all_flowers = evennia.search_tag("flowers")
Tags can also have categories. By default this category is None which is also considered a category.
silmarillion.tags.add("fantasy", category="books")
ice_and_fire.tags.add("fantasy", category="books")
mona_lisa_overdrive.tags.add("cyberpunk", category="books")
Note that if you specify the tag you must also include its category, otherwise that category
will be None and find no matches.
all_fantasy_books = evennia.search_tag("fantasy") # no matches!
all_fantasy_books = evennia.search_tag("fantasy", category="books")
Only the second line above returns the two fantasy books. If we specify a category however, we can get all tagged entities within that category:
all_books = evennia.search_tag(category="books")
This gets all three books.
Search by Attribute
We can also search by the Attributes associated with entities.
For example, let's give our rose thorns:
rose.db.has_thorns = True
wines.db.has_thorns = True
daffodil.db.has_thorns = False
Now we can find things attribute and the value we want it to have:
is_ouch = evennia.search_object_attribute("has_thorns", True)
This returns the rose and the wines.
Searching by Attribute can be very practical. But if you plan to do a search very often, searching by-tag is generally faster.
Search by Typeclass
Sometimes it's useful to find all objects of a specific Typeclass. All of Evennia's search tools support this.
all_roses = evennia.search_object(typeclass="typeclasses.flowers.Rose")
If you have the Rose class already imported you can also pass it directly:
all_roses = evennia.search_object(typeclass=Rose)
You can also search using the typeclass itself:
all_roses = Rose.objects.all()
This last way of searching is a simple form of a Django query. This is a way to express SQL queries using Python. We'll cover this some more as an Extra-credits section at the end of this lesson.
Search by dbref
The database id or #dbref is unique and never-reused within each database table. In search methods you can
replace the search for key with the dbref to search for. This must be written as a string #dbref:
the_answer = self.caller.search("#42")
eightball = evennia.search_object("#8")
Since #dbref is always unique, this search is always global.
You may be used to using #dbrefs a lot from other codebases. It is however considered
`bad practice` in Evennia to rely on hard-coded #dbrefs. It makes your code hard to maintain
and tied to the exact layout of the database. In 99% of cases you should pass the actual objects
around and search by key/tags/attribute instead.
Finding objects relative each other
Let's consider a chest with a coin inside it. The chests stand in a room dungeon. In the dungeon is also
a door. This is an exit leading outside.
coin.locationischest.chest.locationisdungeon.door.locationisdungeon.room.locationisNonesince it's not inside something else.
One can use this to find what is inside what. For example, coin.location.location is the room.
We can also find what is inside each object. This is a list of things.
room.contentsis[chest, door]chest.contentsis[coin]coin.contentsis[], the empty list since there's nothing 'inside' the coin.door.contentsis[]too.
A convenient helper is .contents_get - this allows to restrict what is returned:
room.contents_get(exclude=chest)- this returns everything in the room except the chest (maybe it's hidden?)
There is a special property for finding exits:
room.exitsis[door]coin.exitsis[](same for all the other objects)
There is a property .destination which is only used by exits:
door.destinationisoutside(or wherever the door leads)room.destinationisNone(same for all the other non-exit objects)
Database queries
The search functions and methods above are enough for most cases. But sometimes you need to be more specific:
- You want to find all
Characters... - ... who are in Rooms tagged as
moonlit... - ... and who has the Attribute
lycantrophywith a level higher than 2 ... - ... because they'll immediately become werewolves!
In principle you could achieve this with the existing search functions combined with a lot of loops and if statements. But for something non-standard like this querying the database directly will be more efficient.
A django queryset represents a database query. One can add querysets together to build ever-more complicated queries. Only when you are trying to use the results of the queryset will it actually call the database.
The normal way to build a queryset is to define what class of entity you want to search by getting its
.objects resource, and then call various methods on that. We've seen this one before:
all_weapons = Weapon.objects.all()
This is now a queryset representing all instances of Weapon. If Weapon had a subclass Cannon and we
only wanted the cannons, we would do
all_cannons = Cannon.objects.all()
Note that Weapon and Cannon are different typeclasses. You won't find any Cannon instances in
the all_weapon result above, confusing as that may sound. To get instances of a Typeclass and the
instances of all its children classes you need to use _family:
The all_family, filter_family etc is an Evennia-specific
thing. It's not part of regular Django.
really_all_weapons = Weapon.objects.all_family()
This result now contains both Weapon and Cannon instances.
To actually limit your search by other criteria than the Typeclass you need to use .filter
(or .filter_family) instead:
roses = Flower.objects.filter(db_key="rose")
This is a queryset representing all objects having a db_key equal to "rose".
Since this is a queryset you can keep adding to it:
local_roses = roses.filter(db_location=myroom)
We could also have written this in one statement:
local_roses = Flower.objects.filter(db_key="rose", db_location=myroom)
We can also .exclude something from results
local_non_red_roses = local_roses.exclude(db_key="red_rose")
Only until we actually try to examine the result will the database be called. Here it's called when we try to loop over the queryset:
for rose in local_non_red_roses:
print(rose)
From now on, the queryset is evaluated and we can't keep adding more queries to it - we'd need to create a new queryset if we wanted to find some other result.
Note how we use db_key and db_location. This is the actual names of these database fields. By convention
Evennia uses db_ in front of every database field, but when you access it in Python you can skip the db_. This
is why you can use obj.key and obj.location in normal code. Here we are calling the database directly though
and need to use the 'real' names.
Here are the most commonly used methods to use with the objects managers:
filter- query for a listing of objects based on search criteria. Gives empty queryset if none were found.get- query for a single match - raises exception if none were found, or more than one was found.all- get all instances of the particular type.filter_family- likefilter, but search all sub classes as well.get_family- likeget, but search all sub classes as well.all_family- likeall, but return entities of all subclasses as well.
All of Evennia search functions use querysets under the hood. The
evennia.search_*functions actually return querysets, which means you could in principle keep adding queries to their results as well.
Queryset field lookups
Above we found roses with exactly the db_key "rose". This is an exact match that is case sensitive,
so it would not find "Rose".
# this is case-sensitive and the same as =
roses = Flower.objects.filter(db_key__exact="rose"
# the i means it's case-insensitive
roses = Flower.objects.filter(db_key__iequals="rose")
The Django field query language uses __ in the same way as Python uses . to access resources. This
is because . is not allowed in a function keyword.
roses = Flower.objects.filter(db_key__icontains="rose")
This will find all flowers whose name contains the string "rose", like "roses", "wild rose" etc. The
i in the beginning makes the search case-insensitive. Other useful variations to use
are __istartswith and __iendswith. You can also use __gt, __ge for "greater-than"/"greater-or-equal-than"
comparisons (same for __lt and __le). There is also __in:
swords = Weapons.objects.filter(db_key__in=("rapier", "two-hander", "shortsword"))
For more field lookups, see the django docs on the subject.
Get that werewolf ...
Let's see if we can make a query for the werewolves in the moonlight we mentioned at the beginning of this section.
Firstly, we make ourselves and our current location match the criteria, so we can test:
> py here.tags.add("moonlit")
> py me.db.lycantrophy = 3
This is an example of a more complex query. We'll consider it an example of what is possible.
Note the way of writing this code. It would have been very hard to read if we just wrote it in
one long line. But since we wrapped it in `(...)` we can spread it out over multiple lines
without worrying about line breaks!
from typeclasses.characters import Character
will_transform = (
Character.objects
.filter(
db_location__db_tags__db_key__iexact="moonlit",
db_attributes__db_key="lycantrophy",
db_attributes__db_value__gt=2)
)
- Line 3 - We want to find
Characters, so we access.objectson theCharactertypeclass. - Line 4 - We start to filter ...
- Line 5
- ... by accessing the
db_locationfield (usually this is a Room) - ... and on that location, we get the value of
db_tags(this is a many-to-many database field that we can treat like an object for this purpose; it references all Tags on the location) - ... and from those
Tags, we looking forTagswhosedb_keyis "monlit" (non-case sensitive).
- ... by accessing the
- Line 6 - ... We also want only Characters with
Attributeswhosedb_keyis exactly"lycantrophy" - Line 7 - ... at the same time as the
Attribute'sdb_valueis greater-than 2.
Running this query makes our newly lycantrrophic Character appear in will_transform. Success!
Don't confuse database fields with Attributes you set via
obj.db.attr = 'foo'orobj.attributes.add(). Attributes are custom database entities linked to an object. They are not separate fields on that object likedb_keyordb_locationare.
Complex queries
All examples so far used AND relations. The arguments to .filter are added together with AND
("we want tag room to be "monlit" and lycantrhopy be > 2").
For queries using OR and NOT we need Django's
Q object. It is
import from Django directly:
from django.db.models import Q
Q() objects take the same arguments like .filter:
Q(db_key="foo")
The special thing is that these Q objects can then be chained together with special symbols:
| for OR, & for AND. A tilde ~ in front negates the expression inside the Q and thus works like NOT.
Let us expand our original werewolf query. Not only do we want to find all Characters in a moonlit room
with a certain level of lycanthrophy. Now we also want the full moon to immediately transform people who were
recently bitten, even if their lycantrophy level is not yet high enough (more dramatic this way!). Let's say there is
a Tag "recently_bitten" that controls this.
This is how we'd change our query:
from django.db.models import Q
will_transform = (
Character.objects
.filter(
Q(db_location__db_tags__db_key__iexact="moonlit")
& (
Q(db_attributes__db_key="lycantrophy",
db_attributes__db_value__gt=2)
| Q(db__tags__db__key__iexact="recently_bitten")
)
)
)
We now grouped the filter
In our original Lycanthrope example we wanted our werewolves to have names that could start with any vowel except for the specific beginning "ab".
from django.db.models import Q
from typeclasses.characters import Character
query = Q()
for letter in ("aeiouy"):
query |= Q(db_key__istartswith=letter)
query &= ~Q(db_key__istartswith="ab")
query = Character.objects.filter(query)
list_of_lycanthropes = list(query)
In the above example, we construct our query our of several Q objects that each represent one part
of the query. We iterate over the list of vowels, and add an OR condition to the query using |=
(this is the same idea as using += which may be more familiar). Each OR condition checks that
the name starts with one of the valid vowels. Afterwards, we add (using &=) an AND condition
that is negated with the ~ symbol. In other words we require that any match should not start
with the string "ab". Note that we don't actually hit the database until we convert the query to a
list at the end (we didn't need to do that either, but could just have kept the query until we
needed to do something with the matches).
Annotations and F objects
What if we wanted to filter on some condition that isn't represented easily by a field on the object? Maybe we want to find rooms only containing five or more objects?
We could retrieve all interesting candidates and run them through a for-loop to get and count
their .content properties. We'd then just return a list of only those objects with enough
contents. It would look something like this (note: don't actually do this!):
# probably not a good idea to do it this way
from typeclasses.rooms import Room
queryset = Room.objects.all() # get all Rooms
rooms = [room for room in queryset if len(room.contents) >= 5]
Once the number of rooms in your game increases, this could become quite expensive. Additionally, in some particular contexts, like when using the web features of Evennia, you must have the result as a queryset in order to use it in operations, such as in Django's admin interface when creating list filters.
Enter F objects and annotations. So-called F expressions allow you to do a query that looks at a value of each object in the database, while annotations allow you to calculate and attach a value to a query. So, let's do the same example as before directly in the database:
from typeclasses.rooms import Room
from django.db.models import Count
room_count = Room.objects.annotate(num_objects=Count('locations_set'))
queryset = room_count.filter(num_objects__gte=5)
rooms = (Room.objects.annotate(num_objects=Count('locations_set'))
.filter(num_objects__gte=5))
rooms = list(rooms)
Here we first create an annotation num_objects of type Count, which is a Django class. Note that
use of location_set in that Count. The *_set is a back-reference automatically created by
Django. In this case it allows you to find all objects that has the current object as location.
Once we have those, they are counted.
Next we filter on this annotation, using the name num_objects as something we can filter for. We
use num_objects__gte=5 which means that num_objects should be greater than 5. This is a little
harder to get one's head around but much more efficient than lopping over all objects in Python.
What if we wanted to compare two parameters against one another in a query? For example, what if instead of having 5 or more objects, we only wanted objects that had a bigger inventory than they had tags? Here an F-object comes in handy:
from django.db.models import Count, F
from typeclasses.rooms import Room
result = (Room.objects.annotate(num_objects=Count('locations_set'),
num_tags=Count('db_tags'))
.filter(num_objects__gt=F('num_tags')))
F-objects allows for wrapping an annotated structure on the right-hand-side of the expression. It will be evaluated on-the-fly as needed.
Grouping By and Values
Suppose you used tags to mark someone belonging an organization. Now you want to make a list and
need to get the membership count of every organization all at once. That's where annotations and the
.values_list queryset method come in. Values/Values Lists are an alternate way of returning a
queryset - instead of objects, you get a list of dicts or tuples that hold selected properties from
the the matches. It also allows you a way to 'group up' queries for returning information. For
example, to get a display about each tag per Character and the names of the tag:
result = (Character.objects.filter(db_tags__db_category="organization")
.values_list('db_tags__db_key')
.annotate(cnt=Count('id'))
.order_by('-cnt'))
The result queryset will be a list of tuples ordered in descending order by the number of matches, in a format like the following:
[('Griatch Fanclub', 3872), ("Chainsol's Ainneve Testers", 2076), ("Blaufeuer's Whitespace Fixers",
1903),
("Volund's Bikeshed Design Crew", 1764), ("Tehom's Misanthropes", 1)]
[prev lesson](Creating-Things) | [next lesson]()