Implementazione completa di JOIN SQL con eager loading e idratazione gerarchica multi-entità nell'ORM di SismaFramework, risolvendo il problema N+1 delle query senza introdurre breaking changes.
Caricamento eager di relazioni many-to-one tramite LEFT/INNER/RIGHT JOIN:
$articleModel = new ArticleModel();
// Eager loading dell'autore
$articles = $articleModel->getEntityCollectionWithRelations(['author']);
foreach ($articles as $article) {
echo $article->author->name; // GIÀ CARICATO, nessuna query aggiuntiva
}
Caricamento eager di collection inverse con batch loading:
$authorModel = new AuthorModel();
// Eager loading degli articoli
$authors = $authorModel->getEntityCollectionWithRelations(['articleCollection']);
foreach ($authors as $author) {
foreach ($author->articleCollection as $article) {
echo $article->title; // Collection pre-caricata
}
}
Supporto per relazioni ricorsive (tree structures):
$categoryModel = new CategoryModel();
// Eager loading della categoria padre
$categories = $categoryModel->getEntityCollectionWithRelations(['parentCategory']);
foreach ($categories as $category) {
if (isset($category->parentCategory)) {
echo $category->parentCategory->name; // Già caricato
}
}
// Eager loading delle sotto-categorie (collection)
$parentCategories = $categoryModel->getEntityCollectionWithRelations(['sonCollection']);
foreach ($parentCategories as $parent) {
foreach ($parent->sonCollection as $child) {
echo $child->name; // Collection pre-caricata
}
}
public function getEntityCollectionWithRelations(
array $relations, // ['author', 'category', 'tagCollection']
?string $searchKey = null, // Filtro di ricerca
?array $order = null, // Ordinamento
?int $offset = null, // Paginazione
?int $limit = null, // Limite risultati
JoinType $joinType = JoinType::left // Tipo di JOIN
): SismaCollection
Esempio:
$articles = $articleModel->getEntityCollectionWithRelations(
relations: ['author', 'category'],
searchKey: 'php',
order: ['createdAt' => 'DESC'],
limit: 10
);
public function getEntityByIdWithRelations(
int $id,
array $relations,
JoinType $joinType = JoinType::left
): ?BaseEntity
Esempio:
$article = $articleModel->getEntityByIdWithRelations(
id: 1,
relations: ['author', 'category', 'tagCollection']
);
L'implementazione distingue automaticamente tra:
// L'ORM rileva automaticamente il tipo di relazione
$model->getEntityCollectionWithRelations([
'author', // ← Many-to-one: JOIN SQL
'articleCollection', // ← One-to-many: Batch loading
'parentCategory' // ← Self-reference: JOIN SQL
]);
File: Orm/Enumerations/JoinType.php
enum JoinType {
case inner;
case left;
case right;
case cross;
}
File: Orm/HelperClasses/Query.php
Nuove proprietà:
protected array $joins = [] - Metadati JOINNuovi metodi:
appendJoin() - JOIN manualeappendJoinOnForeignKey() - JOIN automatica da foreign keyappendColumn() - Aggiunta colonnegetJoins() - Recupero metadati
File: Orm/BaseClasses/BaseAdapter.php
Nuovi metodi:
buildJoinOnForeignKey() - Costruisce JOIN da metadata entitybuildJoinedColumns() - Genera colonne con prefisso foreignKey__propertybuildJoinClause() - Costruisce clausola SQL JOINparseSelect() - Esteso con parametro $joins
File: Orm/BaseClasses/BaseResultSet.php
Costante:
protected const COLUMN_SEPARATOR = '__';
Nuove proprietà:
protected array $joinMetadata = []Nuovi metodi:
setJoinMetadata() - Imposta metadati JOINconvertToHierarchicalEntity() - Idratazione multi-entityhydrateRelatedEntity() - Idratazione entity correlategetJoinInfoByProperty() - Recupero info JOINLogica automatica:
protected function hydrate(\stdClass &$result): StandardEntity|BaseEntity
{
if ($this->returnType == StandardEntity::class) {
return $this->convertToStandardEntity($result);
} elseif (!empty($this->joinMetadata)) {
return $this->convertToHierarchicalEntity($result); // ← AUTOMATICO
} else {
return $this->convertToBaseEntity($result);
}
}
File: Orm/BaseClasses/BaseModel.php
Nuovi metodi:
isCollectionRelation() - Rileva se è una collectioneagerLoadCollections() - Carica collections con batch loadingloadCollectionForEntities() - Carica collection specificaappendSelfReferencedJoin() - Gestisce self-referenceSELECT
articles.*,
author.id AS author__id,
author.name AS author__name,
author.email AS author__email
FROM articles
LEFT JOIN authors AS author ON articles.author_id = author.id
WHERE articles.title LIKE ?
ORDER BY articles.created_at DESC
LIMIT 10
-- Query principale
SELECT * FROM authors WHERE ... ;
-- Batch loading automatico (1 sola query per tutti gli autori)
SELECT * FROM articles
WHERE author_id IN (?, ?, ?, ...) -- IDs di tutti gli autori caricati
SELECT
categories.*,
parentCategory.id AS parentCategory__id,
parentCategory.name AS parentCategory__name
FROM categories
LEFT JOIN categories AS parentCategory
ON categories.parent_category_id = parentCategory.id
L'implementazione si integra perfettamente con la cache esistente:
// In convertToHierarchicalEntity()
if (Cache::checkEntityPresenceInCache($relatedEntityClass, $entityId)) {
$relatedEntity = Cache::getEntityById($relatedEntityClass, $entityId);
} else {
$relatedEntity = $this->hydrateRelatedEntity($childData, $relatedEntityClass);
Cache::setEntity($relatedEntity); // ← Cache automatica
}
Vantaggi:
Le colonne delle entity joined usano il separatore __ (doppio underscore):
author__id
author__name
author__email
category__id
category__name
Perché __?
author_id)explode('__', $col, 2)$articles = $articleModel->getEntityCollection(); // 1 query
foreach ($articles as $article) {
echo $article->author->name; // N query (una per articolo)
}
// Totale: 1 + N query
// Many-to-one: 1 SOLA query con JOIN
$articles = $articleModel->getEntityCollectionWithRelations(['author']);
// One-to-many: 2 query totali (main + batch)
$authors = $authorModel->getEntityCollectionWithRelations(['articleCollection']);
Tutto il codice esistente continua a funzionare:
// Vecchia API - FUNZIONA
$articles = $articleModel->getEntityCollection();
foreach ($articles as $article) {
$author = $article->author; // Lazy loading
}
// Nuova API - OPZIONALE
$articles = $articleModel->getEntityCollectionWithRelations(['author']);
foreach ($articles as $article) {
$author = $article->author; // Già caricato
}
parseSelect() ha parametro $joins = [] opzionaleBaseResultSet usa idratazione gerarchica solo se !empty($joinMetadata)
File: Tests/Orm/JoinEagerLoadingTest.php
Test implementati:
__
Oltre ai metodi helper come getEntityCollectionWithRelations(), puoi costruire query completamente personalizzate con JOIN espliciti e condizioni complesse sulle tabelle joined.
use SismaFramework\Orm\HelperClasses\Query;
use SismaFramework\Orm\Enumerations\JoinType;
use SismaFramework\Orm\Enumerations\Operator;
use SismaFramework\Orm\Enumerations\Indexing;
// Query con JOIN e condizioni sulla tabella joined
$query = new Query($articleModel->getAdapter());
$query->setTable(Article::class)
->appendJoinOnForeignKey(JoinType::left, 'author', User::class)
->appendCondition('author.country', Operator::eq, 'IT')
->appendCondition('article.status', Operator::eq, ArticleStatus::PUBLISHED)
->setOrderBy(['article.createdAt' => Indexing::desc])
->setLimit(10);
// Esegui query con hydration automatica
$italianArticles = $articleModel->getEntityCollection(
searchKey: null,
order: null,
offset: null,
limit: null,
customQuery: $query
);
foreach ($italianArticles as $article) {
echo $article->author->name; // Già caricato con JOIN
echo $article->author->country; // = 'IT'
}
// Prodotti con categoria premium E brand specifico
$query = new Query($productModel->getAdapter());
$query->setTable(Product::class)
->appendJoinOnForeignKey(JoinType::inner, 'category', Category::class)
->appendJoinOnForeignKey(JoinType::inner, 'brand', Brand::class)
->appendCondition('category.tier', Operator::eq, 'PREMIUM')
->appendCondition('brand.name', Operator::in, ['Apple', 'Samsung', 'Sony'])
->appendCondition('product.price', Operator::gte, 500.00)
->appendCondition('product.stock', Operator::gt, 0)
->setOrderBy(['product.price' => Indexing::asc]);
$premiumProducts = $productModel->getEntityCollection(customQuery: $query);
// Articoli -> Autore -> Paese (relazione a 3 livelli)
$query = new Query($articleModel->getAdapter());
$query->setTable(Article::class)
->appendJoinOnForeignKey(JoinType::left, 'author', User::class);
// JOIN manuale per il paese dell'autore
$query->appendJoin(
JoinType::left,
Country::class, // Tabella da joinare
'author_country', // Alias
'author.country_id = author_country.id', // Condizione ON
Country::class // Entity class per hydration
);
// Aggiungi colonne del paese
$countryColumns = $query->getAdapter()->buildJoinedColumns('author_country', Country::class);
foreach ($countryColumns as $column) {
$query->appendColumn($column);
}
// Condizione sulla tabella nested
$query->appendCondition('author_country.continent', Operator::eq, 'Europe');
$europeanAuthorArticles = $articleModel->getEntityCollection(customQuery: $query);
// Trova prodotti più economici dei prodotti della stessa categoria
$query = new Query($productModel->getAdapter());
$query->setTable(Product::class)
->appendJoin(
JoinType::inner,
Product::class,
'p2', // Alias per self-join
'product.category_id = p2.category_id AND product.price < p2.price',
Product::class
)
->setDistinct(true) // Evita duplicati
->setOrderBy(['product.category_id' => Indexing::asc, 'product.price' => Indexing::asc]);
$cheaperProducts = $productModel->getEntityCollection(customQuery: $query);
use SismaFramework\Orm\Enumerations\AggregationFunction;
// Media prezzi per categoria (con JOIN)
$query = new Query($productModel->getAdapter());
$query->setTable(Product::class)
->appendJoinOnForeignKey(JoinType::inner, 'category', Category::class)
->setColumns(['category.name', 'category.id'])
->setAVG('product.price', 'average_price', append: true)
->setGroupBy(['category.id'])
->appendCondition('product.active', Operator::eq, true);
// Nota: Questo restituirà stdClass, non entità complete
$categoryAverages = $productModel->getAdapter()->select($query);
// Tutte le combinazioni Size x Color disponibili
$query = new Query($sizeModel->getAdapter());
$query->setTable(Size::class)
->appendJoin(
JoinType::cross,
Color::class,
'color',
'', // CROSS JOIN non ha condizione ON
Color::class
);
$allCombinations = $sizeModel->getEntityCollection(customQuery: $query);
// Trova autori con almeno 10 articoli pubblicati
$subquery = new Query($articleModel->getAdapter());
$subquery->setTable(Article::class)
->setColumns(['author_id'])
->setCount('id', 'article_count')
->setGroupBy(['author_id'])
->appendCondition('status', Operator::eq, ArticleStatus::PUBLISHED)
->setHaving('article_count >= 10');
$query = new Query($userModel->getAdapter());
$query->setTable(User::class)
->appendJoin(
JoinType::inner,
'(' . $subquery->build() . ')',
'prolific_authors',
'user.id = prolific_authors.author_id',
null // Nessuna entity class per subquery
);
$prolificAuthors = $userModel->getEntityCollection(customQuery: $query);
Per query ancora più complesse, puoi usare direttamente il DataMapper:
use SismaFramework\Orm\HelperClasses\DataMapper;
$dataMapper = new DataMapper(Article::class);
$query = new Query($dataMapper->getAdapter());
$query->setTable(Article::class)
->appendJoinOnForeignKey(JoinType::left, 'author', User::class)
->appendJoinOnForeignKey(JoinType::left, 'category', Category::class)
->appendCondition('author.verified', Operator::eq, true)
->appendCondition('category.active', Operator::eq, true)
->appendCondition('article.publishedAt', Operator::gte, '2024-01-01');
// Esegui query custom tramite DataMapper
$verifiedAuthorArticles = $dataMapper->getEntityCollection($query);
// Prodotti con categoria e brand (many-to-one)
$products = $productModel->getEntityCollectionWithRelations(
relations: ['category', 'brand'],
searchKey: 'laptop',
order: ['price' => 'ASC'],
limit: 20
);
// Categoria con tutti i prodotti (one-to-many)
$category = $categoryModel->getEntityByIdWithRelations(
id: 5,
relations: ['productCollection']
);
// Tree di categorie (self-reference)
$categories = $categoryModel->getEntityCollectionWithRelations(['parentCategory']);
// Post con autore e tag
$posts = $postModel->getEntityCollectionWithRelations(
relations: ['author', 'tagCollection'],
order: ['publishedAt' => 'DESC']
);
// Autore con tutti i post
$author = $authorModel->getEntityByIdWithRelations(
id: 1,
relations: ['postCollection', 'country']
);
// Dipendenti con manager
$employees = $employeeModel->getEntityCollectionWithRelations(['manager']);
// Manager con team (self-reference collection)
$managers = $employeeModel->getEntityCollectionWithRelations(['sonCollection']);
L'implementazione fornisce:
✅ Risoluzione problema N+1 - Eager loading completo ✅ Zero breaking changes - Compatibilità totale ✅ API pulita - Semplice da usare ✅ Type-safe - Validation con Reflection API ✅ Cache integration - Sfrutta infrastruttura esistente ✅ Production-ready - Gestione completa di tutti i casi d'uso
L'ORM di SismaFramework ora supporta relazioni complesse mantenendo le performance ottimali! 🎉
Se hai trovato errori o vuoi suggerire miglioramenti, apri una issue su GitHub.
Report Issue Edit on GitHub