Relationship Mapping

Relationship mapping links entities together to reflect how data relates in your domain. Instead of manually writing JOIN queries, you define relationships once using annotations, then ObjectQuel handles the database operations automatically.

Relationship Types

ObjectQuel supports four relationship types:

ManyToOne - Many entities reference one entity. Example: Many products belong to one category.

class ProductEntity {
    /**
     * @Orm\ManyToOne(targetEntity="CategoryEntity")
     */
    private CategoryEntity $category;
}

OneToMany - One entity has many related entities. Example: One category has many products.

class CategoryEntity {
    /**
     * @Orm\OneToMany(targetEntity="ProductEntity", mappedBy="categoryId")
     */
    public EntityCollection $products;
}

OneToOne - One entity relates to exactly one other entity. Example: One user has one profile.

// Owning side
class UserEntity {
    /**
     * @Orm\Column(name="profile_id", type="integer", nullable=true)
     */
    private ?int $profileId = null;

    /**
     * @Orm\OneToOne(targetEntity="ProfileEntity", inversedBy="user")
     */
    private ?ProfileEntity $profile = null;

    public function setProfile(?ProfileEntity $profile): void {
        $this->profile = $profile;
        $this->profileId = $profile?->getProfileId();
    }
}

// Inverse side
class ProfileEntity {
    /**
     * @Orm\OneToOne(targetEntity="UserEntity", mappedBy="profileId")
     */
    private ?UserEntity $user = null;
}

ManyToMany - Many entities relate to many entities. ObjectQuel uses explicit bridge entities for this (see Entity Bridge guide for details).

Owning vs Inverse Side

Every relationship has two sides. Understanding the difference is critical:

/**
 * @Orm\Table(name="products")
 */
class ProductEntity {
    /**
     * @Orm\Column(name="category_id", type="integer")
     */
    private int $categoryId;

    /**
     * @Orm\ManyToOne(targetEntity="CategoryEntity")
     */
    private CategoryEntity $category;  // OWNING SIDE - has the foreign key
}

/**
 * @Orm\Table(name="categories")
 */
class CategoryEntity {
    /**
     * @Orm\OneToMany(targetEntity="ProductEntity", mappedBy="categoryId")
     */
    public EntityCollection $products;  // INVERSE SIDE - referenced by foreign key
}
Side Description Impact
Owning Side The entity with the foreign key column Changes here affect the database
Inverse Side The entity referenced by the foreign key Changes here are ignored by ObjectQuel

Critical rule: Only changes to the owning side establish or break relationships. The inverse side collection is purely for navigation.

Fetch Strategies

Control when related entities are loaded using the fetch parameter:

class ProductEntity {
    /**
     * EAGER - Load immediately when product is loaded
     * @Orm\ManyToOne(targetEntity="CategoryEntity", fetch="EAGER")
     */
    private CategoryEntity $category;

    /**
     * LAZY - Load only when accessed
     * @Orm\ManyToOne(targetEntity="BrandEntity", fetch="LAZY")
     */
    private ?BrandEntity $brand = null;
}

When to use EAGER:

  • Relationship is almost always needed
  • Related entity is small
  • Using @RequiredRelation (mandatory relationships)

When to use LAZY:

  • Relationship is rarely accessed
  • Related entity is large or has many properties
  • Collection relationships (OneToMany)

Required vs Optional Relationships

Use @RequiredRelation for mandatory relationships:

class ProductEntity {
    /**
     * Required - every product must have a category
     * @Orm\ManyToOne(targetEntity="CategoryEntity", fetch="EAGER")
     * @Orm\RequiredRelation
     */
    private CategoryEntity $category;

    /**
     * @Orm\Column(name="category_id", type="integer")
     */
    private int $categoryId;  // NOT NULL in database

    /**
     * Optional - product may have a brand
     * @Orm\ManyToOne(targetEntity="BrandEntity", fetch="LAZY")
     */
    private ?BrandEntity $brand = null;

    /**
     * @Orm\Column(name="brand_id", type="integer", nullable=true)
     */
    private ?int $brandId = null;  // NULL allowed
}

@RequiredRelation uses INNER JOIN instead of LEFT JOIN, improving query performance.

Working with Collections

OneToMany relationships use EntityCollection to hold related entities:

use Quellabs\ObjectQuel\Collections\EntityCollection;

class CustomerEntity {
    /**
     * @Orm\OneToMany(targetEntity="OrderEntity", mappedBy="customerId")
     */
    public EntityCollection $orders;

    public function __construct() {
        $this->orders = new EntityCollection();
    }
}

// Adding entities
$customer->orders->add($order);

// Removing entities
$customer->orders->removeElement($order);

// Checking if collection contains an entity
if ($customer->orders->contains($order)) {
    // ...
}

// Counting items
$count = count($customer->orders);

Querying Across Relationships

Use the via keyword to traverse relationships in ObjectQuel queries:

// Query using relationship property
$results = $entityManager->executeQuery("
    range of p is App\\Entity\\ProductEntity
    range of c is App\\Entity\\CategoryEntity via p.category
    retrieve (p, c.name) where c.active = true
");

// Query using explicit join condition
$results = $entityManager->executeQuery("
    range of p is App\\Entity\\ProductEntity
    range of c is App\\Entity\\CategoryEntity via p.categoryId = c.categoryId
    retrieve (p, c.name) where c.active = true
");

// Multi-level traversal
$results = $entityManager->executeQuery("
    range of c is App\\Entity\\CustomerEntity
    range of o is App\\Entity\\OrderEntity via c.orders
    range of i is App\\Entity\\OrderItemEntity via o.items
    retrieve (c.email, o.orderId, i.quantity)
    where o.orderDate >= :date
", ['date' => '2024-01-01']);

Common Pitfalls

1. N+1 Query Problem

// BAD - Triggers one query per product
$products = $entityManager->executeQuery("
    range of p is App\\Entity\\ProductEntity
    retrieve (p)
");

foreach($products as $row) {
    $product = $row['p'];
    echo $product->getCategory()->getName();  // Triggers a query!
}

// GOOD - One query loads everything
$products = $entityManager->executeQuery("
    range of p is App\\Entity\\ProductEntity
    range of c is App\\Entity\\CategoryEntity via p.category
    retrieve (p, c)
");

foreach($products as $row) {
    $product = $row['p'];
    echo $product->getCategory()->getName();  // No query!
}

2. Forgetting to Update Owning Side

// WRONG - Only updates inverse side
$category->products->add($product);  // This does nothing!

// CORRECT - Update owning side
$product->setCategory($category);  // This persists
$category->products->add($product);  // This is just for navigation

3. Not Initializing Collections

// WRONG
class CategoryEntity {
    public EntityCollection $products;  // Uninitialized!
}

// CORRECT
class CategoryEntity {
    public EntityCollection $products;

    public function __construct() {
        $this->products = new EntityCollection();
    }
}

Performance Tips

  • Use @RequiredRelation for mandatory relationships (INNER JOIN vs LEFT JOIN)
  • Preload relationships in queries to avoid N+1 problems
  • Use LAZY loading for rarely accessed relationships
  • Add database indexes on foreign key columns
  • Consider denormalization for frequently accessed data