Object Relationships - Modeling Real World Relationships in Python
Tags: python, codenewbie, database, oop
Published: 2024-01-01 20:26:05

Table of Contents:
{%- # TOC start -%}

- [Why use Objects and Object Oriented Programming?](#why-use-objects-and-object-oriented-programming-)
- [One to Many Relationships](#one-to-many-relationships)
  * [Implementing a One-to-Many Relationships](#implementing-a-one-to-many-relationships)
    + [Adding another one-to-many relationship](#adding-another-one-to-many-relationship)
- [Many to Many Relationships](#many-to-many-relationships)
  * [Implementing a Many-to-Many Relationship](#implementing-a-many-to-many-relationship)
- [Conclusion](#conclusion)


{%- # TOC end -%}

# Why use Objects and Object Oriented Programming (OOP)?

If you have coding experience, you have likely come across classes and objects, whether they're ones you created or ones native to your programming language. A class in programming simply refers to a bundle of related data and functionality. `'Hello'` in Python, for example, is an object (or instance) of the `String` class. It comes with its own methods that enhance its functionality, such as `capitalize()`, `join()`, `find()`, etc. Classes create abstractions of real concepts with understandable, reusable code.

Creating your own classes and instances can add a lot of functionality and conciseness to your code, but what if you want those objects to be related to each other? Say you have objects representing paintings and objects representing artists, but now you want to bring them together. Once you have an understanding of how to build object relationships, you can model those real world connections in your code.

Here is a basic set up for the `Artist` and `Painting` classes. Right now they are completely separate entities. Each only includes the `__init__` and `__repr__` methods to initialize object attributes and generate a prettier string when printing the object. I'm also tracking all instances of each class with the class attribute `all`.

``` python
class Artist:
    all = []

    def __init__(self, name):
        self.name = name
        Artist.all.append(self)

    def __repr__(self):
        return f"Artist: {self.name}"


class Painting:
    all = []

    def __init__(self, title, genre):
        self.title = title
        self.genre = genre
        Painting.all.append(self)

    def __repr__(self):
        return f"Painting: {self.title}, {self.genre}"
```

# One to Many Relationships

One to many relationships refer to when an object of type A is associated with multiple objects of type B, but an object of type B only belongs to one object of type A. 

## Implementing a One-to-Many Relationships

From the example above, artists can have many paintings but paintings only belong to one artist. In order to model this one to many relationship, the object which 'belongs' to another object should include an attribute to keep track of that relationship. Since a painting can only belong to one artist, I'll add an attribute called `artist` to my `Painting` class.

``` python
class Painting:
    all = []

    # Added artist attribute below
    def __init__(self, title, genre, artist):
        self.title = title
        self.genre = genre
        self.artist = artist
        Painting.all.append(self)

    def __repr__(self):
        return f"Painting: {self.title} by {self.artist.name}, {self.genre}"
```

> **NOTE**: If you are creating your own application, it'd be beneficial to manage these attributes with *properties* which perform validation checks. For example, you can assert that a painting's `artist` attribute is an object of type `Artist` before setting it. You can read more about how to do this [here](https://realpython.com/python-property/).

Now if I create an example of a painting and its artist, I will always have access to that artist through the painting.

```python
vangogh = Artist("Vincent van Gogh")
starry_night = Painting("Starry Night", "post-impressionism", vangogh)

print(starry_night)
print(starry_night.artist)
# LOG: Painting: Starry Night by Vincent van Gogh, post-impressionism
#      Artist: Vincent van Gogh
```

This is a good start, but this relationship is still one-sided. We need to do a little more if we also want to view all of an artist's paintings. An object of type A, which has many objects of type B, can include a method to return all of its B objects. In this example, I can use [list comprehension](https://realpython.com/list-comprehension-python/) to access all paintings and print only the ones that belong to the current artist.

```python
class Artist:
    all = []

    def __init__(self, name):
        self.name = name
        Artist.all.append(self)

    def __repr__(self):
        return f"Artist: {self.name}"

    # New method
    def paintings(self):
        return [painting for painting in Painting.all if painting.artist == self]
```

```python
vangogh = Artist("Vincent van Gogh")
starry_night = Painting("Starry Night", "post-impressionism", vangogh)
sower = Painting("The Sower", "post-impressionism", vangogh)

print(vangogh.paintings())
# LOG: [Painting: Starry Night by Vincent van Gogh, post-impressionism, 
#       Painting: The Sower by Vincent van Gogh, post-impressionism]
```

### Adding another one-to-many relationship
The one to many relationship between artists and paintings is now set up! I can easily add another one that behaves in a similar way. Here's an example of an additional class, `Museum`, that also has a one to many relationship with `Painting`:

```python
class Artist:
    all = []

    def __init__(self, name):
        self.name = name
        Artist.all.append(self)

    def __repr__(self):
        return f"Artist: {self.name}"

    def paintings(self):
        return [painting for painting in Painting.all if painting.artist == self]


class Museum:
    all = []

    def __init__(self, name):
        self.name = name
        Museum.all.append(self)

    def __repr__(self):
        return f"Museum: {self.name}"

    def paintings(self):
        return [painting for painting in Painting.all if painting.museum == self]


class Painting:
    all = []

    def __init__(self, title, genre, artist, museum):
        self.title = title
        self.genre = genre
        self.artist = artist
        self.museum = museum
        Painting.all.append(self)

    def __repr__(self):
        return f"Painting: {self.title} by {self.artist.name}, {self.genre}, located at {self.museum}"
```

# Many to Many Relationships

Many-to-many refers to relationships where an object of type A can be associated with multiple instances of type B and vice versa. There are many real world examples that fall under this category, such as students and teachers, employees and projects, and actors and movies. 

Because artists can have multiple paintings, any of which potentially belonging to different museums, it makes sense to say that an artist may be featured at multiple museums. Similarly, museums contain multiple paintings that may belong to different artists, so we can also say that museums feature multiple artists. This relationship between `Artist` and `Museum` would be considered a many-to-many relationship.

## Implementing a Many-to-Many Relationship

Many-to-many object relationships can be implemented with intermediary classes where two classes are related to each other *through* the intermediary class. Because artists cannot be featured at museums without a painting and vice versa, `Painting` will serve as our intermediary class. Other options include creating intermediary classes for the primary purpose of connecting two other classes, or storing relationships in list attributes. 

> **NOTE**: If you are not using an intermediary class, be sure to store the many-to-many relationship in just one class to maintain a [single source of truth](https://edunceputans.medium.com/single-source-of-truth-and-problems-with-implication-in-an-organisation-588883492133). Without the `Painting` class, `Artist` might have a property called `museums` which is a list containing its associated museums. The `Museum` class should *not* include an `artists` property. `Museum` can instead include an `artists()` method that finds all artists which contain the current museum in its `museums` list.

In this example, we should be able to view all of an artist's museums by finding its paintings, then accessing the museums that those paintings belong to.

We already have a `paintings()` method in our `Artist` class so we can make another short method to access the corresponding museums.

```python
class Artist:
    all = []

    def __init__(self, name):
        self.name = name
        Artist.all.append(self)

    def __repr__(self):
        return f"Artist: {self.name}"

    def paintings(self):
        return [painting for painting in Painting.all if painting.artist == self]

    # New method
    def museums(self):
        return [painting.museum for painting in self.paintings()]
```

If we look at an `Artist` example, we can now access all of the museums they're featured at.

```python
vangogh = Artist("Vincent van Gogh")
met = Museum("The MET")
npg = Museum("The National Portrait Gallery")
starry_night = Painting("Starry Night", "post-impressionism", vangogh, met)
sower = Painting("The Sower", "post-impressionism", vangogh, npg)

print(vangogh.museums())
# LOG: [Museum: The MET, 
#       Museum: The National Portrait Gallery]
```

To complete the relationship, we need to add a similar method in the `Museum` class to access its featured artists through its paintings.

```python
class Museum:
    all = []

    def __init__(self, name):
        self.name = name
        Museum.all.append(self)

    def __repr__(self):
        return f"Museum: {self.name}"

    def paintings(self):
        return [painting for painting in Painting.all if painting.museum == self]

    def artists(self):
        return [painting.artist for painting in self.paintings()]
```

Now if we have multiple artists featured at one museum, we can access them directly from the `Museum` object.

```python
met = Museum("The MET")
vangogh = Artist("Vincent van Gogh")
pollock = Artist("Jackson Pollock")
starry_night = Painting("Starry Night", "post-impressionism", vangogh, met)
convergence = Painting("Convergence", "abstract expressionism", pollock, met)

print(met.artists())
# LOG: [Artist: Vincent van Gogh,
#       Artist: Jackson Pollock]
```

# Conclusion
As you expand your knowledge of OOP, you may use object relationships in the context of databases. While this is a simple example only involving python, your future object methods may include queries to a database to access and maintain up-to-date information. Modeling object relationships allows us to build scalable, flexible, and complex systems.