quellabs/objectquel

A sophisticated ORM system with a unique query language and streamlined architecture

dev-main 2025-05-23 11:40 UTC

This package is auto-updated.

Last update: 2025-05-23 11:40:20 UTC


README

ObjectQuel Logo

Latest Version License Downloads

ObjectQuel is a powerful Object-Relational Mapping (ORM) system built on the Data Mapper pattern, offering a clean separation between entities and persistence logic. It combines a purpose-built query language with structured data enrichment and is powered by CakePHP's robust database foundation under the hood.

Table of Contents

The ObjectQuel Advantage

ObjectQuel addresses fundamental design challenges in object-relational mapping through its architecture:

  • Entity-Based Query Language: The ObjectQuel language provides an intuitive, object-oriented syntax for database operations that feels natural to developers
  • Data Mapper Architecture – Entities remain decoupled from the database, ensuring clean, testable domain logic.
  • Powered by CakePHP's Database Layer – Reliable and battle-tested SQL engine under the hood.
  • Relationship Simplicity: Work with complex relationships without complex query code
  • Performance By Design: Multiple built-in optimization strategies for efficient database interactions
  • Hybrid Data Sources: Uniquely combine traditional databases with external JSON data sources

Installation

Installation can be done through composer.

composer require quellabs/objectquel

Quick Start

This shows a quick way to use ObjectQuel:

// 1. Create configuration
$config = new Configuration();
$config->setDsn('mysql://db_user:db_password@localhost:3306/my_database');
$config->setEntityNamespace('App\\Entity');
$config->setEntityPath(__DIR__ . '/src/Entity');

// 2. Create EntityManager
$entityManager = new EntityManager($config);

// 3. Find an entity
$product = $entityManager->find(\App\Entity\ProductEntity::class, 101);

// 4. Update and save
$product->setPrice(29.99);
$entityManager->persist($product);
$entityManager->flush();

// 5. Query using ObjectQuel language
$results = $entityManager->executeQuery("
    range of p is App\\Entity\\ProductEntity
    range of c is App\\Entity\\CategoryEntity via p.categories
    retrieve (p) where p.price < :maxPrice
", [
    'maxPrice' => 50.00
]);

Core Components

ObjectQuel consists of several primary components working together:

  • EntityManager: Central wrapper around the various helper classes
  • EntityStore: Manages entity classes and their relationships
  • UnitOfWork: Tracks individual entities and their changes
  • ObjectQuel: Handles reading and parsing of the ObjectQuel query language
  • Query Decomposer: Analyzes a parsed query and methodically breaks it down into smaller, logically sequenced sub-tasks.
  • Query Executor: Processes each sub-task generated by the Decomposer in optimal order, leveraging specialized handlers for different query types.

Configuration

Creating a Configuration Object

use Quellabs\ObjectQuel\Configuration;

// Create a new configuration object
$config = new Configuration();

Setting Database Connection

You have multiple options for configuring the database connection:

Option 1: Using individual parameters

$config->setDatabaseParams(
    'mysql',              // Database driver
    'localhost',          // Host
    'my_database',        // Database name
    'db_user',            // Username
    'db_password',        // Password
    3306,                 // Port (optional, default: 3306)
    'utf8mb4'             // Character set (optional, default: utf8mb4)
);

Option 2: Using a DSN string

$config->setDsn('mysql://db_user:db_password@localhost:3306/my_database?encoding=utf8mb4');

Option 3: Using an array

$config->setConnectionParams([
    'driver'   => 'mysql',
    'host'     => 'localhost',
    'database' => 'my_database',
    'username' => 'db_user',
    'password' => 'db_password',
    'port'     => 3306,
    'encoding' => 'utf8mb4'
]);

Setting Entity Information

// Set the base namespace for entities
// This is used when generating new entities through sculpt
$config->setEntityNamespace('App\\Entity');

// Set the entity path (directory where entities reside)
$config->setEntityPath(__DIR__ . '/src/Entity');

Configuring Proxies for Lazy Loading

// Set the directory where proxy classes will be stored
$config->setProxyDir(__DIR__ . '/var/cache/proxies');

// Set the namespace for generated proxy classes
$config->setProxyNamespace('App\\Proxies');

Important: Without proper proxy configuration, proxies will be generated dynamically at runtime, significantly impacting performance.

Configuring Metadata Caching

// Enable metadata caching
$config->setUseMetadataCache(true);

// Set where metadata cache will be stored
$config->setMetadataCachePath(__DIR__ . '/var/cache/metadata');

Creating the EntityManager

use Quellabs\ObjectQuel\EntityManager;

// Create the EntityManager with your configuration
$entityManager = new EntityManager($config);

Working with Entities

Entity Retrieval

ObjectQuel provides three ways to retrieve entities:

1. Using find()

// Find entity by primary key
$entity = $entityManager->find(\App\Entity\ProductEntity::class, 23);

2. Using findBy()

// Find entities matching criteria
$entities = $entityManager->findBy(\App\Entity\ProductEntity::class, [
    'name' => 'Widget',
    'price' => 19.99
]);

3. Using a query

// Complex query using ObjectQuel language
$results = $entityManager->executeQuery("
    range of p is App\\Entity\\ProductEntity
    retrieve (p) where p.productId = :productId
", [
    'productId' => 1525
]);

foreach($results as $row) {
    echo $row['p']->getName();
}

Entity Creation

Entities are recognized by the @Orm\Table annotation:

/**
 * Class ProductEntity
 * @Orm\Table(name="products")
 */
class ProductEntity {
    /**
     * @Orm\Column(name="product_id", type="integer", length=11, primary_key=true)
     * @Orm\PrimaryKeyStrategy(strategy="identity")
     */
    private int $productId;

    // Properties and methods...
}

Column Annotation Properties

Each database/entity property is marked by an @Orm\Column annotation. This annotation supports the following parameters:

Parameter Description Options/Format
name The database column name Required
type The data type 'integer', 'string', 'char', 'text', 'datetime', etc.
limit The maximum column length Only relevant for string types
primary_key Define this as a primary key column true or false
default Default value when database column is NULL Value
unsigned For unsigned values true (unsigned) or false (signed, default)
nullable Allow NULL values in the database true (allow NULL) or false (non-NULL required, default)

Primary Key Strategies

For primary key properties, you can apply the @Orm\PrimaryKeyStrategy annotation to define how key values are generated. ObjectQuel supports the following strategies:

Strategy Description
identity Automatically increments values (default strategy)
uuid Generates a unique UUID for each new record
sequence Uses a select query to determine the next value in the sequence

Primary Key Properties and Nullability

While primary keys are defined as NOT NULL in the database (a fundamental requirement for primary keys), in PHP entity classes they should be declared as nullable types (?int, ?string, etc.). This approach properly represents the state of new, unsaved entities that don't yet have ID values assigned:

/**
 * @Orm\Column(name="product_id", type="integer", limit=11, primary_key=true)
 * @Orm\PrimaryKeyStrategy(strategy="identity")
 */
private ?int $productId = null;  // Nullable in PHP, NOT NULL in database

This pattern is especially important for identity (auto-increment) primary keys, as new entities won't have an ID until after they're persisted to the database. ObjectQuel's entity manager uses this nullability to determine whether an entity is new and requires an INSERT rather than an UPDATE operation.

The ObjectQuel Language

ObjectQuel draws inspiration from QUEL, a pioneering database query language developed in the 1970s for the Ingres DBMS at UC Berkeley (later acquired by Oracle). While SQL became the industry standard, QUEL's elegant approach to querying has been adapted here for modern entity-based programming:

  • Entity-Centric: Works with domain entities instead of database tables
  • Intuitive Syntax: Uses RETRIEVE instead of SELECT for more natural data extraction
  • Semantic Aliasing: Defines aliases with range of x is y (similar to FROM y AS x in SQL) to create a more readable data scope
  • Object-Oriented: References entity properties directly instead of database columns, maintaining your domain language
  • Relationship Traversal: Simplifies complex data relationships through intuitive path expressions

While ObjectQuel ultimately translates to SQL, implementing our own query language provides significant advantages. The abstraction layer allows ObjectQuel to:

  1. Express complex operations with elegant, developer-friendly syntax (e.g., productId = /^a/ instead of SQL's more verbose productId REGEXP('^a'))
  2. Intelligently optimize database interactions by splitting operations into multiple efficient SQL queries when needed
  3. Perform additional post-processing operations not possible in SQL alone, such as seamlessly joining traditional database data with external JSON sources

This approach delivers both a more intuitive developer experience and capabilities that extend beyond standard SQL, all while maintaining a consistent, object-oriented interface.

Entity Property Selection

ObjectQuel provides flexibility in what data you retrieve. You can:

Retrieve entire entity objects:

$results = $entityManager->executeQuery("
    range of p is App\\Entity\\ProductEntity
    retrieve (p) where p.productId = :productId
", [
    'productId' => 1525
]);

// Access the retrieved entity
$product = $results[0]['p'];

Retrieve specific properties:

// Returns only the price property values
$results = $entityManager->executeQuery("
    range of p is App\\Entity\\ProductEntity
    retrieve (p.price) where p.productId = :productId
", [
    'productId' => 1525
]);

// Access the retrieved property value
$price = $results[0]['p.price'];

Retrieve a mix of entities and properties:

// Returns product entities and just the name property from descriptions
$results = $entityManager->executeQuery("
    range of p is App\\Entity\\ProductEntity
    range of d is App\\Entity\\ProductDescriptionEntity via p.descriptions
    retrieve (p, d.productName) where p.productId = :productId
    sort by d.productName asc
", [
    'productId' => 1525
]);

// Access the mixed results
$product = $results[0]['p'];
$name = $results[0]['d.productName'];

Search Operations

ObjectQuel transforms database querying with its expressive, developer-friendly syntax that converts complex search operations into elegant, readable code.

Operation Example Description
Exact match main.name = "xyz" Exact value match
Starts with main.name = "xyz*" Starts with "xyz"
Pattern main.name = "abc*xyz" Starts with "abc", ends with "xyz"
Wildcard main.name = "h?nk" Single character wildcard
Regex main.name = /^a/ Regular expression support
Full-text search(main.name, "banana cherry +pear -apple") Full-text search with weights

Pagination

ObjectQuel supports pagination with the WINDOW operator:

range of p is App\\Entity\\ProductEntity
range of d is App\\Entity\\ProductDescriptionEntity via d.productId = p.productId
retrieve (p.productId) sort by d.productName
window 1 using window_size 10

Entity Relationships

ObjectQuel supports five types of relationships:

1. OneToOne (owning-side)

/**
 * @Orm\OneToOne(targetEntity="CustomerEntity", inversedBy="customerId", relationColumn="customerInfoId", fetch="EAGER")
 */
private ?CustomerEntity $customer;
Parameter Description
targetEntity Target entity class
inversedBy Property in target entity for reverse mapping
relationColumn Column storing the foreign key
fetch Loading strategy ("EAGER" or "LAZY"; LAZY is default)

2. OneToOne (inverse-side)

/**
 * @Orm\OneToOne(targetEntity="CustomerEntity", mappedBy="customerId", relationColumn="customerId")
 */
private ?CustomerEntity $customer;
Parameter Description
targetEntity Target entity class
mappedBy Property in target entity that holds the foreign key
relationColumn Column in current entity that corresponds to the relationship

3. ManyToOne (owning-side)

/**
 * @Orm\ManyToOne(targetEntity="CustomerEntity", inversedBy="customerId", fetch="EAGER")
 * @Orm\RequiredRelation
 */
private ?CustomerEntity $customer;
Parameter Description
targetEntity Target entity class
inversedBy Property in target entity for reverse collection mapping
fetch Loading strategy ("EAGER" or "LAZY", optional. EAGER is the default)

The annotation @Orm\RequiredRelation indicates that the relation can be loaded using an INNER JOIN (rather than the default LEFT JOIN) because it's guaranteed to be present, which improves query performance.

4. OneToMany (inverse-side)

/**
 * @Orm\OneToMany(targetEntity="AddressEntity", mappedBy="customerId")
 * @var $addresses EntityCollection
 */
public $addresses;
Parameter Description
targetEntity Target entity class
mappedBy Property in target entity that contains the foreign key
indexBy Optional property to use as collection index

5. ManyToMany

ManyToMany relationships are implemented as a specialized extension of OneToMany/ManyToOne relationships. To establish an effective ManyToMany relation:

  1. Apply the @EntityBridge annotation to your entity class that will serve as the junction table.
  2. This annotation instructs the query processor to treat the entity as an intermediary linking table.
  3. When queries execute, the processor automatically traverses and loads the related ManyToOne associations defined within this bridge entity.
/**
 * Class ProductCategoryEntity
 * @Orm\Table(name="products_categories")
 * @Orm\EntityBridge
 */
class ProductCategoryEntity {
    // Properties defining the relationship
}

The @Orm\EntityBridge pattern extends beyond basic relationship mapping by offering several advanced capabilities:

  • Store supplementary data within the junction table (relationship metadata, timestamps, etc.)
  • Access and manipulate this contextual data alongside the primary relationship information
  • Maintain comprehensive audit trails and relationship history between associated entities

Indexing

ObjectQuel provides powerful indexing capabilities through annotations at the entity level. These annotations allow you to define both regular and unique indexes directly in your entity classes, which will be automatically applied to your database during migrations.

Index Annotations

ObjectQuel supports two types of index annotations:

1. Regular Index (@Orm\Index)

Regular indexes improve query performance for columns frequently used in WHERE clauses, JOIN conditions, or sorting operations.

/**
 * @Orm\Table(name="products")
 * @Orm\Index(name="idx_product_category", columns={"category_id"})
 */
class ProductEntity {
    // Entity properties and methods...
}

2. Unique Index (@Orm\UniqueIndex)

Unique indexes ensure data integrity by preventing duplicate values in the specified columns, while also providing performance benefits.

/**
 * @Orm\Table(name="users")
 * @Orm\UniqueIndex(name="idx_unique_email", columns={"email"})
 */
class UserEntity {
    // Entity properties and methods...
}

Index Annotation Parameters

Both index annotations support the following parameters:

Parameter Description Required
name Unique identifier for the index in the database Yes
columns Array of column names to be included in the index Yes

Composite Indexes

You can create indexes on multiple columns to optimize queries that filter or sort by a combination of fields:

/**
 * @Orm\Table(name="orders")
 * @Orm\Index(name="idx_customer_date", columns={"customer_id", "order_date"})
 */
class OrderEntity {
    // Entity properties and methods...
}

Example Usage

Here's an example of how to use both index types on an entity:

/**
 * @Orm\Table(name="hamster")
 * @Orm\Index(name="idx_hamster_search", columns={"name", "color"})
 * @Orm\UniqueIndex(name="idx_unique_code", columns={"registrationCode"})
 */
class HamsterEntity {
    /**
     * @Orm\Column(name="id", type="integer", length=11, primary_key=true)
     * @Orm\PrimaryKeyStrategy(strategy="identity")
     */
    private int $id;
    
    /**
     * @Orm\Column(name="name", type="string", length=100)
     */
    private string $name;
    
    /**
     * @Orm\Column(name="color", type="string", length=50)
     */
    private string $color;
    
    /**
     * @Orm\Column(name="registration_code", type="string", length=20)
     */
    private string $registrationCode;
    
    // Getters and setters...
}

Integration with Migrations

The index annotations are fully integrated with ObjectQuel's migration system. When you run the make:migrations command, the system will:

  1. Analyze your entity annotations to identify index definitions
  2. Compare these definitions with the current database schema
  3. Generate appropriate migration scripts to add, modify, or remove indexes
  4. Apply these changes when migrations are executed

Example migration generated from index annotations:

<?php
use Phinx\Migration\AbstractMigration;

class EntitySchemaMigration20250515124653 extends AbstractMigration {

    /**
     * This migration was automatically generated by ObjectQuel
     *
     * More information on migrations is available on the Phinx website:
     * https://book.cakephp.org/phinx/0/en/migrations.html
     */
     
    public function up(): void {
        $this->table('hamster', ['id' => false, 'primary_key' => ['id']])
            ->addColumn('id', 'integer', ['limit' => 11, 'null' => false, 'signed' => false, 'identity' => true])
            ->addColumn('woopie', 'string', ['limit' => 255, 'null' => false, 'signed' => true])
            ->addIndex(['woopie'], ['name' => 'xyz', 'unique' => true])
            ->create();
    }

    public function down(): void {
        $this->table('hamster')->drop()->save();
    }
}

Performance Considerations

When defining indexes, consider these best practices:

  • Create indexes only on columns frequently used in WHERE clauses, RANGE conditions, or SORT BY clauses
  • Limit the number of indexes per table to minimize storage overhead and INSERT/UPDATE performance impact
  • Place the most selective columns first in composite indexes
  • Consider database-specific limitations on index sizes and types

Properly designed indexes can dramatically improve query performance while ensuring data integrity through unique constraints.

Saving and Persisting Data

Updating an Entity

// Retrieve an existing entity by its primary key
// This queries the database for a ProductEntity with ID 10
// The entity is immediately loaded and tracked by the EntityManager
// IMPORTANT: If no entity with ID 10 exists, this will return NULL, so error handling may be needed
$entity = $entityManager->find(ProductEntity::class, 10);

// Update entity property value
// Modifies the text property/field of the retrieved entity
// The EntityManager automatically tracks this change since the entity was loaded via find()
$entity->setText("Updated description");

// Register the entity with the EntityManager's identity map
// NOTE: This call is optional in this case because entities retrieved via find() are automatically
// tracked by the EntityManager. Including it may improve code readability by explicitly showing
// which entities are being managed, especially in complex operations
$entityManager->persist($entity);

// Synchronize all pending changes with the database
// This operation:
// 1. Detects changes to managed entities (the text field modification in this case)
// 2. Executes necessary SQL statements (UPDATE in this case)
// 3. Commits the transaction
// 4. Clears the identity map (tracking information)
// After flush(), the EntityManager no longer knows about previously managed entities
$entityManager->flush();

Adding a New Entity

// Create a new entity instance
// This instantiates a fresh entity object in memory without persisting it to the database yet
$entity = new ProductEntity();

// Set entity property value
// At this point, the change exists only in memory
$entity->setText("New product description");

// Register the entity with the EntityManager's identity map
// This tells the EntityManager to start tracking changes for this entity
// IMPORTANT: This step is mandatory in ObjectQuel, unlike some other ORMs that automatically track new entities
// Without this call, the entity would not be saved to the database during flush operations
$entityManager->persist($entity);

// Synchronize all pending changes with the database
// This operation:
// 1. Executes all necessary SQL statements (INSERT in this case)
// 2. Commits the transaction
// 3. Clears the identity map (tracking information)
// After flush(), the EntityManager no longer knows about previously managed entities
$entityManager->flush();

Removing an Entity

// Retrieve an existing entity by its primary key
// This queries the database for a ProductEntity with ID 1520
// The entity is immediately loaded and tracked by the EntityManager
// If no entity with ID 1520 exists, this will return NULL, so error handling may be needed
$entity = $entityManager->find(ProductEntity::class, 1520);

// Mark the entity for removal
// This schedules the entity for deletion when flush() is called
// The entity is not immediately deleted from the database
// NOTE: This only works for entities that are being tracked by the EntityManager
$entityManager->remove($entity);

// Synchronize all pending changes with the database
// This operation:
// 1. Executes necessary SQL statements (DELETE in this case)
// 2. Commits the transaction
// 3. Clears the identity map (tracking information)
// After flush(), the EntityManager no longer tracks this entity, and the record is removed from the database
$entityManager->flush();

Note: When removing entities, ObjectQuel does not automatically cascade deletions to related entities unless you've configured foreign keys in your database engine. If you want child entities to be removed when their parent is deleted, add the @Orm\Cascade annotation to the ManyToOne relationship as shown below:

use Quellabs\ObjectQuel\Annotations\Orm;

class Child { 

   /**
    * @Orm\ManyToOne(targetEntity="Parent")
    * @Orm\Cascade(operations={"remove"})
    */
   private $parent;
   
   // ...
}

This tells ObjectQuel to find and remove all child entities that reference the deleted parent through their ManyToOne relationship.

Using Repositories

ObjectQuel provides a flexible approach to the Repository pattern through its optional Repository base class. Unlike some ORMs that mandate repository usage, ObjectQuel makes repositories entirely optional—giving you the freedom to organize your data access layer as you prefer.

Repository Pattern Benefits

The Repository pattern creates an abstraction layer between your domain logic and data access code, providing several advantages:

  • Type Safety: Better IDE autocomplete and type hinting
  • Code Organization: Centralizes query logic for specific entity types
  • Business Logic: Encapsulates common data access operations
  • Testability: Simplifies mocking for unit tests
  • Query Reusability: Prevents duplication of common queries

Creating Custom Repositories

While you can work directly with the EntityManager, creating entity-specific repositories can enhance your application's structure:

use Quellabs\ObjectQuel\Repository;
use Quellabs\ObjectQuel\ObjectQuel\QuelResult;

class ProductRepository extends Repository {
    
    /**
     * Constructor - specify the entity this repository manages
     * @param EntityManager $entityManager The EntityManager instance
     */
    public function __construct(EntityManager $entityManager) {
        parent::__construct($entityManager, ProductEntity::class);
    }
        
    /**
     * Find products below a certain price
     * @param float $maxPrice Maximum price threshold
     * @return array<ProductEntity> Matching products
     */
    public function findBelowPrice(float $maxPrice): QuelResult {
        return $this->entityManager->executeQuery("
            range of p is App\\Entity\\ProductEntity
            retrieve (p) where p.price < :maxPrice
            sort by p.price asc
        ", [
            'maxPrice' => $maxPrice
        ]);
    }
}

Using Repositories in Your Application

Once you've defined your repositories, you can integrate them into your application:

// Create the repository
$productRepository = new ProductRepository($entityManager);

// Use repository methods
$affordableProducts = $productRepository->findBelowPrice(29.99);

// Still have access to built-in methods
$specificProduct = $productRepository->find(1001);
$featuredProducts = $productRepository->findBy(['featured' => true]);

SignalHub

ObjectQuel provides a robust event system that allows you to execute custom logic at specific points in an entity's lifecycle. This event system is powered by our SignalHub component, offering both standard ORM lifecycle hooks and the flexibility to create custom events.

Lifecycle Events Overview

Lifecycle events allow you to intercept and respond to key moments in an entity's persistence lifecycle, such as:

Lifecycle Event Description Timing
prePersist Triggered before a new entity is inserted Before INSERT query
postPersist Triggered after a new entity is inserted After INSERT query
preUpdate Triggered before an existing entity is updated Before UPDATE query
postUpdate Triggered after an existing entity is updated After UPDATE query
preDelete Triggered before an entity is deleted Before DELETE query
postDelete Triggered after an entity is deleted After DELETE query

Setting Up Lifecycle Callbacks

To enable lifecycle callbacks on an entity:

  1. Mark your entity class with the @LifecycleAware annotation
  2. Add methods with the appropriate lifecycle annotations
use Quellabs\ObjectQuel\Annotations\Orm\Table;
use Quellabs\ObjectQuel\Annotations\Orm\LifecycleAware;
use Quellabs\ObjectQuel\Annotations\Orm\PrePersist;
use Quellabs\ObjectQuel\Annotations\Orm\PostUpdate;

/**
 * @Table(name="products")
 * @LifecycleAware
 */
class ProductEntity {
    /**
     * @Orm\Column(name="created_at", type="datetime", nullable=true)
     */
    private ?\DateTime $createdAt = null;
    
    /**
     * @Orm\Column(name="updated_at", type="datetime", nullable=true)
     */
    private ?\DateTime $updatedAt = null;
    
    /**
     * @PrePersist
     */
    public function setCreatedAt(): void {
        $this->createdAt = new \DateTime();
    }
    
    /**
     * @PostUpdate
     */
    public function setUpdatedAt(): void {
        $this->updatedAt = new \DateTime();
    }
    
    // Other entity properties and methods...
}

Once configured, these lifecycle methods will automatically be called at the appropriate times during the entity's lifecycle without any additional code required in your application logic.

Common Use Cases for Lifecycle Events

Some popular applications of lifecycle events include:

  • Timestamp Management: Automatically set created/updated timestamps
  • Value Generation: Generate UUIDs, slugs, or other derived values
  • Validation: Perform complex validation before persisting data
  • Cache Invalidation: Clear caches when entities change
  • Logging: Record changes for audit trails
  • Related Entity Updates: Update or validate related entities
  • External System Synchronization: Push changes to external services

Listening to Built-in Lifecycle Signals

You can listen to standard entity lifecycle events without modifying your entities:

use Quellabs\SignalHub\SignalHubLocator;

// Get SignalHub instance
$signalHub = SignalHubLocator::getInstance();

// Connect a custom handler to the prePersist signal
$signalHub->getSignal('orm.prePersist')->connect(function(object $entity) {
    // This will be called for all entities before they're persisted
    if ($entity instanceof ProductEntity) {
        // Do something specific for products
        logProductChange($entity);
    }
});

Creating and Using Custom Signals

You can also define your own custom signals for domain-specific events:

use Quellabs\SignalHub\HasSignals;

class ProductService {
    use HasSignals;
    
    public function __construct() {
        // Define a custom signal with parameter types
        $this->createSignal('productPriceChanged', ['ProductEntity', 'float', 'float']);
        
        // Register with SignalHub (optional)
        $this->registerWithHub();
    }
    
    public function updatePrice(ProductEntity $product, float $newPrice): void {
        $oldPrice = $product->getPrice();
        $product->setPrice($newPrice);
        
        // Emit the signal when price changes
        $this->emit('productPriceChanged', $product, $oldPrice, $newPrice);
    }
}

SignalHub supports wildcard patterns for connecting to multiple signals:

// Connect to all signals that start with "product"
$signalHub->getSignal('product*')->connect(function($entity) {
    // This will be called for all product-related signals
    recordProductActivity($entity);
});

You can also specify priorities when connecting handlers to ensure they execute in the desired order:

// Higher priority handlers execute first
$signalHub->getSignal('orm.prePersist')->connect($highPriorityHandler, null, 100);
$signalHub->getSignal('orm.prePersist')->connect($normalPriorityHandler, null, 0);
$signalHub->getSignal('orm.prePersist')->connect($lowPriorityHandler, null, -100);

Performance Considerations

While lifecycle events offer great flexibility, they can impact performance if overused. Keep these guidelines in mind:

  • Lifecycle methods should be lightweight and fast
  • Use PostPersist/PostUpdate for heavy operations when possible
  • Consider batching operations in postFlush for better performance
  • Enable eager loading for entities needed in lifecycle callbacks

When properly implemented, lifecycle events provide a clean, aspect-oriented approach to cross-cutting concerns while maintaining separation of your core domain logic from peripheral concerns like logging, validation, and data transformation.

Utility Tools

ObjectQuel provides a powerful utility tool called sculpt that streamlines the creation of entities in your application. This interactive CLI tool guides you through a structured process, automatically generating properly formatted entity classes with all the necessary components.

Initialization

Before using the sculpt tool, create an objectquel-cli-config.php configuration file in your project's root directory (where your composer.json file is located). This file must include your database credentials, entity namespace, entity path, and migration path.

For convenience, ObjectQuel provides an objectquel-cli-config.php.example file that you can copy and customize with your specific settings. The CLI tools require this configuration file to function properly.

Automatic Entity Generation

To create a new entity, run the following command in your terminal:

php bin/sculpt make:entity

When you execute this command, the sculpt tool will:

  1. Prompt for entity name - Enter a descriptive name for your entity (e.g., "User", "Product", "Order")
  2. Define properties - Add fields with their respective data types (string, integer, boolean, etc.)
  3. Establish relationships - Define connections to other entities (One-to-One, One-to-Many, etc.)
  4. Generate accessors - Create getters and setters for your properties

Creating Entities from Database Tables

To generate an entity from an existing database table, run this command in your terminal:

php bin/sculpt make:entity-from-table

When executed, the sculpt tool will prompt you to select a table name and automatically create a properly structured entity class based on that table's schema.

Generating Database Migrations

To create migrations for entity changes, use this command:

php bin/sculpt make:migrations

When executed, the sculpt tool analyzes differences between your entity definitions and the current database schema. It then automatically generates a migration file containing the necessary SQL statements to synchronize your database with your entities.

Note: The system uses CakePHP's Phinx as its migration engine. All generated migrations follow the Phinx format and can be executed using standard Phinx commands.

Generating Migrations for Index Changes

When you add or modify @Orm\Index or @Orm\UniqueIndex annotations to your entities, the make:migrations command will automatically detect these changes and include them in the generated migration files.

php bin/sculpt make:migrations

This will produce migrations for index changes similar to:

<?php
use Phinx\Migration\AbstractMigration;

class AddProductIndices extends AbstractMigration
{
    public function change()
    {
        $table = $this->table('products');
        
        // Add a regular index
        $table->addIndex(['category_id'], [
            'name' => 'idx_product_category',
            'unique' => false,
        ]);
        
        $table->save();
    }
}

Running Database Migrations

Database migrations allow you to manage and version your database schema changes alongside your application code. This guide covers how to run, rollback, and manage migrations effectively.

Applying Migrations

To apply all pending migrations to your database:

php bin/sculpt quel:migrate

This command will:

  • Identify all pending migrations that haven't been applied yet
  • Execute them in sequence (ordered by creation date)
  • Apply the database schema changes defined in your entity annotations
  • Record the migration in the migrations table (phinxlog) to track what's been applied

Rolling Back Migrations

If you need to undo the most recent migration:

php bin/sculpt quel:migrate --rollback

To roll back multiple migrations at once, specify the number to rollback:

php bin/sculpt quel:migrate --rollback --steps=3

Command Help

For a complete list of migration options and detailed help:

php bin/sculpt help quel:migrate

Technology Stack

Note: Our migration system is a wrapper around Phinx, a powerful database migration tool that provides a robust foundation for schema management. While our commands simplify common migration tasks, some advanced Phinx features like database seeding and migration breakpoints are not available through our wrapper. If you need these advanced capabilities, you can use Phinx directly. For more information on using Phinx's full feature set, refer to the Phinx documentation.

Query Optimization

Query Flags

ObjectQuel supports query flags for optimization, starting with the '@' symbol:

  • @InValuesAreFinal: Optimizes IN() functions for primary keys by eliminating verification queries

Important Notes

  • Proxy cache directories must be writable by the application
  • For best performance in production, enable proxy and metadata caching

Note: When proxy path and namespace settings are not configured, the system generates proxies on-the-fly during runtime. This approach significantly reduces performance and can cause noticeable slowdowns in your application. For optimal performance, always configure both the proxy path and namespace in your application settings.

License

ObjectQuel is released under the MIT License.

MIT License

Copyright (c) 2024-2025 Quellabs

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.