Lesson 12.2: Using type-theoretic relations
In the previous lesson, we learned how to map application types onto database types using the type-theoretic framework of the PERA model. In this lesson, we’ll explore how our models change when we represent application classes with type-theoretic relations.
Modeling type hierarchies
In Lesson 12.1, we encountered the following Review
type.
class Review:
def __init__(
self,
id: str,
reviewed: Book,
reviewer: User,
timestamp: datetime,
score: int
):
self.id = id
self.reviewed = reviewed
self.reviewer = reviewer
self.timestamp = timestamp
self.score = score
Now, we have decided to make it part of a type hierarchy of user actions instead, represented by the following classes.
class UserAction(ABC):
def __init__(self, user: User, timestamp: datetime):
self.user = user
self.timestamp = timestamp
class Order(UserAction):
def __init__(
self,
id: str,
user: User,
timestamp: datetime
):
super().__init__(user, timestamp)
self.id = id
self.status = Status.PENDING
class Review(UserAction):
def __init__(
self, id: str,
reviewed: Book,
user: User,
timestamp: datetime,
score: int
):
super().__init__(user, timestamp)
self.id = id
self.reviewed = reviewed
self.score = score
class Login(UserAction):
def __init__(
self,
user: User,
timestamp: datetime,
success: bool
):
super().__init__(user, timestamp)
self.success = success
In Python, the |
Representing these classes in TypeQL is straightforward under the type-theoretic framework. The UserAction
type is composed of the composite User
type, so user-action
should be a relation type with a role user-action:user
. That role is then inherited by its subtypes. The Review
type is also composed of the composite Book
type. In the PERA model, this is represented by adding an extra role review:reviewed
to review
.
define
user-action sub relation,
abstract,
relates user,
owns timestamp;
order sub user-action,
owns id @key,
owns status,
plays order-line:order,
plays delivery:delivered;
review sub user-action,
relates reviewed,
owns id @key,
owns score,
owns verified;
login sub user-action,
owns success;
user plays user-action:user;
book plays review:reviewed;
Write TypeQL type definitions to represent the following Python classes under the type-theoretic PERA framework. Make sure to include any plays
statements for roles defined.
class Company(ABC):
def __init__(self, name: str):
self.name = name
class Publisher(Company):
def __init__(self, name: str):
super().__init__(name)
class Courier(Company):
def __init__(self, name: str):
super().__init__(name)
class User:
def __init__(
self,
id: str,
name: str,
birth_date: datetime,
location: City
):
self.id = id
self.name = name
self.birth_date = birth_date
self.location = location
class Delivery:
def __init__(
self,
delivered: Order,
deliverer: Courier,
destination: Address
):
self.delivered = delivered
self.deliverer = deliverer
self.destination = destination
Sample solution
define
company sub entity,
abstract,
owns name;
publisher sub company;
courier sub company,
plays delivery:deliverer;
user sub relation,
owns id @key,
owns name,
owns birth-date,
relates location;
delivery sub relation,
relates delivered,
relates deliverer,
relates destination;
city plays user:location;
order plays delivery:delivered;
address plays delivery:destination;
Naming interfaces effectively
Next, let’s consider the following Address
class.
class Address:
def __int__(self, street: str, city: City):
self.street = street
self.city = city
@property
def state(self) -> State:
return self.city.state
@property
def country(self) -> Country:
return self.city.country
We could represent this in TypeQL in the following manner. We’ll leave out the properties of Address
for now, but we could always replicate their behaviour using rules.
define
address sub relation,
owns street,
relates city;
city plays address:city;
If we were to implement this as it is, then there would be a disparity between the way that users (defined in the above exercise) and addresses reference cities. The object type city
would play the roles user:location
and address:city
, and there is no way we can polymorphically query these two roles without explicitly listing them. However, if we renamed the address:city
role to address:location
, then we can query them polymorphically via their shared role name, as we saw in Lesson 7.2, using the following pattern.
$located (location: $city);
This pattern will match instances of both user
and address
for $located
, as long as we use the following definition for address
.
define
address sub relation,
owns street,
relates location;
city plays address:location;
For this reason, it is even more important to choose interface names carefully when using the type-theoretic framework of the PERA model.
TypeDB 3.0 will include user-defined functions that allow for ad hoc polymorphism. This will enable a new class of powerful polymorphic queries that do not depend on inheritance hierarchies or interface implementations. By making use of ad hoc polymorphism in TypeDB 3.0, interface names could be chosen freely and the desired polymorphic behaviour could still be implemented! To learn more about this and other powerful new features, see the TypeDB 3.0 roadmap. |
Handling nullary relations
Finally, we will consider the following classes representing places.
class Place(ABC):
def __init__(self, name: str):
self.name = name
class Country(Place):
def __init__(self, name: str):
super().__init__(name)
class State(Place):
def __init__(self, name: str, location: Country):
super().__init__(name)
self.location = location
@property
def country(self) -> Country:
return self.location
class City(Place):
def __init__(self, name: str, location: State | Country):
super().__init__(name)
self.location = location
@property
def state(self) -> Optional[State]:
if type(self.location) is State:
return self.location
@property
def country(self) -> Country:
match self.location:
case State():
return self.location.country
case Country():
return self.location
Here we run into an interesting problem. The types Place
and Country
are composed only of primitive types, but State
and City
are also composed of composite types. The PERA model does not permit the mixing of entity and relation types into a single hierarchy. In the type-theoretic framework, we might consider an implementation where some of the relation types have no roles, as shown here.
define
place sub relation,
abstract,
owns name;
country sub place,
plays city:location,
plays state:location;
state sub place,
relates location,
plays city:location;
city sub place,
relates location;
However, this schema excerpt will not commit to a database. While abstract relation types can have no roles, all concrete relation types must have at least one role. The type country
is concrete but does not have any roles, causing this schema to fail validation. Even if we gave country
a dummy role, we could not instantiate it, as any relation instances without roleplayers are erased automatically on commit. We have a few options to deal with this:
-
Make
country
an entity type instead, and remove the supertypeplace
altogether. -
Add a
location
role tocountry
, and an entity typeworld
to play it. We will instantiateworld
once with no attributes, and it will act as the placeholder location for all countries. -
Change the supertype
place
to an entity type, and model the network of locations withlocating
relations instead oflocation
roles. This is the approach we used in the original bookstore schema under the entity-centric framework.
All of these solutions will allow the schema to pass validation, but none of the resulting schemas will completely match the application model. Here we cannot quite achieve parity with the current model, and we must decide whether to allow the mismatch or modify the application model to match the chosen solution in the database. In this particular case, option (2) lets us come the closest to parity, after which we can make minor changes to the application model to match, if complete parity is required.
define
world sub entity,
plays country:location;
place sub relation,
abstract,
owns name;
country sub place,
relates location,
plays city:location,
plays state:location;
state sub place,
relates location,
plays city:location;
city sub place,
relates location;
class World:
def __init__(self):
pass
class Place(ABC):
def __init__(self, name: str):
self.name = name
class Country(Place):
def __init__(self, name: str, location: World):
super().__init__(name)
self.location = location
class State(Place):
def __init__(self, name: str, location: Country):
super().__init__(name)
self.location = location
@property
def country(self) -> Country:
return self.location
class City(Place):
def __init__(self, name: str, location: State | Country):
super().__init__(name)
self.location = location
@property
def state(self) -> Optional[State]:
if type(self.location) is State:
return self.location
@property
def country(self) -> Country:
match self.location:
case State():
return self.location.country
case Country():
return self.location