phillarmonic / syncopate-bundle
Symfony bundle for integrating with SyncopateDB
Installs: 83
Dependents: 0
Suggesters: 0
Security: 0
Stars: 4
Watchers: 1
Forks: 0
Open Issues: 0
Type:symfony-bundle
Requires
- php: >=8.1
- symfony/cache: ^6.4|^7.0
- symfony/config: ^6.4|^7.0
- symfony/console: ^6.4|^7.0
- symfony/dependency-injection: ^6.4|^7.0
- symfony/finder: ^6.4|^7.0
- symfony/framework-bundle: ^6.4|^7.0
- symfony/http-client: ^6.4|^7.0
Requires (Dev)
- phpunit/phpunit: ^9.5|10.0|11.0
- symfony/phpunit-bridge: ^6.0|^7.0
- 1.6.0
- 1.5.0
- 1.4.1
- 1.4.0
- 1.3.1
- 1.3.0
- 1.2.2
- 1.2.1
- 1.2.0
- 1.1.0
- dev-master / 1.0.x-dev
- 1.0.0
- dev-feature/truncate-v2
- dev-feature/truncate
- dev-feature/super-unique-constraints
- dev-feature/unique-constraints
- dev-feature/countabile
- dev-feature/register-compiler-pass
- dev-feature/mapped-joins
- dev-feature/httpcli-optimization
- dev-feature/memory-improvements
- dev-feature/joins
This package is auto-updated.
Last update: 2025-05-09 15:59:18 UTC
README
A Symfony bundle for integrating with SyncopateDB, a flexible, lightweight data store with advanced query capabilities.
Compatibility between the bundle and SyncopateDB
Syncopate Bundle Versions | SyncopateDB Server Versions | State |
---|---|---|
1.x | 0.x | Active development |
2.x (planned) | 1.x (planned) | Planned future version |
Installation
Step 1: Install the Bundle
composer require phillarmonic/syncopate-bundle
Step 2: Enable the Bundle
If you're using Symfony Flex, the bundle will be enabled automatically. Otherwise, add it to your config/bundles.php
:
return [ // ... Phillarmonic\SyncopateBundle\PhillarmonicSyncopateBundle::class => ['all' => true], ];
Step 3: Configure the Bundle
Create a configuration file at config/packages/phillarmonic_syncopate.yaml
:
phillarmonic_syncopate: # Required: set the base URL of your SyncopateDB instance base_url: 'http://localhost:8080' # Optional configurations with defaults timeout: 30 retry_failed: false max_retries: 3 retry_delay: 1000 # Entity discovery entity_paths: - '%kernel.project_dir%/src/Entity' auto_create_entity_types: true # Caching cache_entity_types: true cache_ttl: 3600
Usage
Defining Entities
Define your entities using PHP 8 attributes:
<?php namespace App\Entity; use Phillarmonic\SyncopateBundle\Attribute\Entity; use Phillarmonic\SyncopateBundle\Attribute\Field; use Phillarmonic\SyncopateBundle\Model\EntityDefinition; use Phillarmonic\SyncopateBundle\Trait\EntityTrait; #[Entity(name: 'product', idGenerator: EntityDefinition::ID_TYPE_UUID)] class Product { use EntityTrait; // Include the EntityTrait to add array conversion methods public ?string $id = null; #[Field(type: 'string', indexed: true, required: true)] public string $name; #[Field(type: 'string')] public string $description; #[Field(type: 'float', indexed: true)] public float $price; #[Field(type: 'integer')] public int $stock; #[Field(type: 'datetime', indexed: true)] public \DateTime $createdAt; public function __construct() { $this->createdAt = new \DateTime(); } }
Entity Serialization with EntityTrait
The EntityTrait
provides convenient methods to convert your entities to arrays with fine-grained control:
// Get all fields $allFields = $product->toArray(); // Get only specific fields $simpleData = $product->extract(fields: ['name', 'price']); // Get all fields except specified ones $withoutDescription = $product->extractExcept(exclude: ['description']); // Get fields with custom key mapping $renamedFields = $product->toArray( fields: null, exclude: [], mapping: [ 'name' => 'productName', 'price' => 'cost' ] ); // Result: ['id' => '123', 'productName' => 'Product Name', 'cost' => 19.99, ...]
The toArray()
method has three optional parameters:
$fields
- When provided, only these fields will be included$exclude
- Fields to exclude from the result$mapping
- Maps property names to custom keys in the result
This is particularly useful for API responses, where you might need to transform your internal data model to match external API conventions.
Entity Relationships
SyncopateBundle supports entity relationships with cascade operations:
<?php namespace App\Entity; use Phillarmonic\SyncopateBundle\Attribute\Entity; use Phillarmonic\SyncopateBundle\Attribute\Field; use Phillarmonic\SyncopateBundle\Attribute\Relationship; use Phillarmonic\SyncopateBundle\Trait\EntityTrait; use DateTimeInterface; #[Entity] class Post { use EntityTrait; #[Field] private ?int $id = null; #[Field(required: true)] private string $title; #[Field(required: true)] private string $content; #[Field(type: 'datetime', required: true)] private DateTimeInterface $createdAt; // One-to-many relationship with Comment entities and cascade delete #[Relationship( targetEntity: Comment::class, type: Relationship::TYPE_ONE_TO_MANY, mappedBy: 'post', cascade: Relationship::CASCADE_REMOVE )] private array $comments = []; // ... getters and setters } #[Entity] class Comment { use EntityTrait; #[Field] private ?int $id = null; #[Field(required: true)] private string $content; #[Field(indexed: true, required: true)] private int $postId; // Many-to-one relationship with Post entity #[Relationship( targetEntity: Post::class, type: Relationship::TYPE_MANY_TO_ONE, inversedBy: 'comments' )] private ?Post $post = null; // ... getters and setters }
Supported relationship types:
TYPE_ONE_TO_ONE
: Single reference in both directionsTYPE_ONE_TO_MANY
: Collection on one side, single reference on the otherTYPE_MANY_TO_ONE
: Single reference on one side, collection on the otherTYPE_MANY_TO_MANY
: Collections on both sides
Cascade options:
CASCADE_NONE
: No cascading actions (default)CASCADE_REMOVE
: Automatically delete related entities when the parent is deleted
Custom Repositories
SyncopateBundle supports entity-specific repositories that allow you to define custom query methods for each entity:
1. Create a Custom Repository Class
<?php namespace App\Repository; use App\Entity\Product; use Phillarmonic\SyncopateBundle\Repository\EntityRepository; class ProductRepository extends EntityRepository { /** * Find products in a specific price range */ public function findByPriceRange(float $minPrice, float $maxPrice): array { return $this->createQueryBuilder() ->gte(field: 'price', value: $minPrice) ->lte(field: 'price', value: $maxPrice) ->orderBy(field: 'price', direction: 'ASC') ->getResult(); } /** * Find featured products */ public function findFeaturedProducts(int $limit = 5): array { return $this->createQueryBuilder() ->eq(field: 'featured', value: true) ->gt(field: 'stock', value: 0) ->orderBy(field: 'price', direction: 'ASC') ->limit(limit: $limit) ->getResult(); } /** * Get products formatted for API response */ public function getProductsForApi(): array { $products = $this->findAll(); return array_map(function(Product $product) { return $product->toArray( mapping: [ 'id' => 'productId', 'price' => 'unitPrice', 'stock' => 'availableQuantity' ] ); }, $products); } /** * Count products by category using optimized count API */ public function countByCategory(string $category): int { return $this->createQueryBuilder() ->eq(field: 'category', value: $category) ->count(); } }
2. Specify the Repository Class in Your Entity
<?php namespace App\Entity; use App\Repository\ProductRepository; use Phillarmonic\SyncopateBundle\Attribute\Entity; use Phillarmonic\SyncopateBundle\Attribute\Field; use Phillarmonic\SyncopateBundle\Model\EntityDefinition; use Phillarmonic\SyncopateBundle\Trait\EntityTrait; #[Entity( name: 'product', idGenerator: EntityDefinition::ID_TYPE_UUID, repositoryClass: ProductRepository::class )] class Product { use EntityTrait; // ... property definitions }
3. Use Your Custom Repository Methods
// In a controller or service $repository = $this->repositoryFactory->getRepository(Product::class); // Use custom repository methods $featuredProducts = $repository->findFeaturedProducts(limit: 3); $midRangeProducts = $repository->findByPriceRange(minPrice: 20, maxPrice: 50); // Get API-formatted products $apiProducts = $repository->getProductsForApi(); // Get count of products in a category $electronicsCount = $repository->countByCategory('electronics');
Repository Pattern
Use the repository pattern to interact with your entities:
<?php namespace App\Controller; use App\Entity\Product; use Phillarmonic\SyncopateBundle\Repository\EntityRepositoryFactory; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\Routing\Annotation\Route; class ProductController extends AbstractController { private EntityRepositoryFactory $repositoryFactory; public function __construct(EntityRepositoryFactory $repositoryFactory) { $this->repositoryFactory = $repositoryFactory; } #[Route('/products', name: 'product_list', methods: ['GET'])] public function list(): Response { $repository = $this->repositoryFactory->getRepository(Product::class); $products = $repository->findAll(); // Convert all products to arrays for JSON response $productsArray = array_map(fn($product) => $product->toArray(), $products); return $this->json($productsArray); } #[Route('/products/count', name: 'product_count', methods: ['GET'])] public function count(): Response { $repository = $this->repositoryFactory->getRepository(Product::class); $totalCount = $repository->count(); return $this->json([ 'total' => $totalCount ]); } #[Route('/products/{id}', name: 'product_show', methods: ['GET'])] public function show(string $id): Response { $repository = $this->repositoryFactory->getRepository(Product::class); $product = $repository->find(id: $id); if (!$product) { throw $this->createNotFoundException('Product not found'); } // Only include specific fields in the response $productData = $product->extract(fields: ['name', 'price', 'description']); return $this->json($productData); } #[Route('/products', name: 'product_create', methods: ['POST'])] public function create(): Response { $repository = $this->repositoryFactory->getRepository(Product::class); $product = new Product(); $product->name = 'New Product'; $product->description = 'This is a new product'; $product->price = 19.99; $product->stock = 100; $product = $repository->create(entity: $product); return $this->json($product->toArray(), 201); } #[Route('/products/{id}', name: 'product_update', methods: ['PUT'])] public function update(string $id): Response { $repository = $this->repositoryFactory->getRepository(Product::class); $product = $repository->find(id: $id); if (!$product) { throw $this->createNotFoundException('Product not found'); } $product->price = 29.99; $product = $repository->update(entity: $product); return $this->json($product->toArray()); } #[Route('/products/{id}', name: 'product_delete', methods: ['DELETE'])] public function delete(string $id): Response { $repository = $this->repositoryFactory->getRepository(Product::class); // Will automatically delete related entities with CASCADE_REMOVE $success = $repository->deleteById(id: $id); return $this->json(['success' => $success]); } }
Using the Query Builder
For more complex queries, use the query builder:
$repository = $this->repositoryFactory->getRepository(Product::class); $queryBuilder = $repository->createQueryBuilder(); $products = $queryBuilder ->gt(field: 'price', value: 20) ->lt(field: 'price', value: 100) ->contains(field: 'description', value: 'awesome') ->orderBy(field: 'price', direction: 'DESC') ->limit(limit: 10) ->offset(offset: 0) ->getResult(); // Convert results to arrays with custom field mapping $productsArray = array_map( fn($product) => $product->toArray( mapping: [ 'name' => 'productName', 'price' => 'cost' ] ), $products );
Optimized Count Operations
SyncopateBundle supports efficient count operations that leverage SyncopateDB's dedicated count API, which returns only the count without retrieving all data:
// Simple count of all entities $repository = $this->repositoryFactory->getRepository(Product::class); $totalCount = $repository->count(); // Count with query builder filters $queryBuilder = $repository->createQueryBuilder(); $inStockCount = $queryBuilder ->eq(field: 'inStock', value: true) ->gt(field: 'price', value: 50) ->count(); // Count with pagination info $queryBuilder = $repository->createQueryBuilder(); $filteredCount = $queryBuilder ->contains(field: 'name', value: 'gaming') ->count(); $pageSize = 10; $totalPages = ceil($filteredCount / $pageSize);
Count with Join Queries
The count API also supports join operations, allowing you to count entities based on related data:
// Count posts with comments from the last 7 days $repository = $this->repositoryFactory->getRepository(Post::class); $joinQueryBuilder = $repository->createJoinQueryBuilder(); $recentlyCommentedPostsCount = $joinQueryBuilder ->innerJoin( entityType: 'comment', localField: 'id', foreignField: 'postId', as: 'comments' ) ->gt(field: 'comments.createdAt', value: new \DateTime('-7 days')) ->count(); // Count users who have purchased a specific product $repository = $this->repositoryFactory->getRepository(User::class); $joinQueryBuilder = $repository->createJoinQueryBuilder(); $purchaserCount = $joinQueryBuilder ->innerJoin( entityType: 'order', localField: 'id', foreignField: 'userId', as: 'orders' ) ->innerJoin( entityType: 'order_item', localField: 'orders.id', foreignField: 'orderId', as: 'items' ) ->eq(field: 'items.productId', value: $productId) ->count();
When to use optimized count
The optimized count API is particularly useful for:
- Pagination: Calculate total pages without retrieving all entities
- Performance monitoring: Check the size of result sets before executing expensive queries
- UI elements: Display count badges or indicators with minimal database overhead
- Large datasets: Get counts from tables with millions of records efficiently
- Complex joins: Determine relationship counts without materializing all related entities
This approach significantly reduces memory usage and network traffic compared to retrieving all entities and counting them in PHP.
Join Queries
Use join queries to fetch related entities in a single request:
$repository = $this->repositoryFactory->getRepository(Post::class); $joinQueryBuilder = $repository->createJoinQueryBuilder(); $posts = $joinQueryBuilder ->innerJoin( entityType: 'comment', localField: 'id', foreignField: 'postId', as: 'comments' ) ->gt(field: 'comments.createdAt', value: new \DateTime('-7 days')) ->getJoinResult(); // Prepare posts for API response with renamed fields $postsData = []; foreach ($posts as $post) { $postData = $post->extract(fields: ['title', 'content', 'createdAt']); // Map comments to array with only necessary fields $postData['comments'] = array_map( fn($comment) => $comment->extract(fields: ['content']), $post->comments ); $postsData[] = $postData; }
Direct Service Usage
You can also inject and use the SyncopateService
directly:
<?php use Phillarmonic\SyncopateBundle\Service\SyncopateService; class ProductService { private SyncopateService $syncopateService; public function __construct(SyncopateService $syncopateService) { $this->syncopateService = $syncopateService; } public function getProductsByPriceRange(float $min, float $max): array { return $this->syncopateService->findBy( entityClass: Product::class, criteria: [], orderBy: ['price' => 'ASC'] ); } public function getProductCountByCriteria(array $criteria): int { return $this->syncopateService->count( entityClass: Product::class, criteria: $criteria ); } public function deleteProductWithRelations(string $id): bool { // Will automatically handle cascade delete based on relationship attributes return $this->syncopateService->deleteById( entityClass: Product::class, id: $id, enableCascade: true ); } public function getProductsForApi(): array { $products = $this->getProductsByPriceRange(min: 10, max: 100); // Transform for API response using EntityTrait return array_map( fn($product) => $product->toArray( mapping: [ 'id' => 'productId', 'price' => 'unitPrice', 'stock' => 'availableQuantity' ] ), $products ); } }
Error Handling
SyncopateBundle provides comprehensive error handling with specific exception types and detailed error information from SyncopateDB.
Exception Types
The bundle includes specialized exception classes for different error scenarios:
- SyncopateApiException - Base exception for all SyncopateDB API errors
- SyncopateValidationException - Thrown when entity validation fails
- SyncopateIntegrityConstraintException - Thrown when unique constraints are violated
Error Categories
SyncopateDB errors are organized into categories, each with specific error codes:
- General Errors (SY001-SY099) - General API errors, authentication, timeouts
- Entity Type Errors (SY100-SY199) - Issues with entity type definitions
- Entity Errors (SY200-SY299) - Entity validation, constraints, field issues
- Query Errors (SY300-SY399) - Invalid queries, filters, joins
- Persistence Errors (SY400-SY499) - Database storage, backup, recovery issues
Handling Specific Errors
use Phillarmonic\SyncopateBundle\Exception\SyncopateApiException; use Phillarmonic\SyncopateBundle\Exception\SyncopateValidationException; use Phillarmonic\SyncopateBundle\Exception\SyncopateIntegrityConstraintException; try { $repository = $this->repositoryFactory->getRepository(Product::class); $product = new Product(); $product->name = 'Test Product'; $product->sku = 'SKU123'; // Assuming SKU has a unique constraint $repository->create($product); } catch (SyncopateIntegrityConstraintException $e) { // Handle unique constraint violations $field = $e->getField(); // e.g., 'sku' $value = $e->getValue(); // e.g., 'SKU123' // Get user-friendly message $friendlyMessage = $e->getFriendlyMessage(); // e.g., "The sku 'SKU123' is already in use. Please choose a different value." return $this->json([ 'error' => 'duplicate_entity', 'message' => $friendlyMessage, 'field' => $field ], 409); } catch (SyncopateValidationException $e) { // Handle validation errors with field-specific messages $violations = $e->getViolations(); return $this->json([ 'error' => 'validation_failed', 'message' => 'Please fix the following issues:', 'violations' => $violations ], 400); } catch (SyncopateApiException $e) { // Get detailed error information $httpCode = $e->getCode(); $dbCode = $e->getDbCode(); // e.g., 'SY209' $category = $e->getErrorCategory(); // e.g., 'Entity' // Check if it's a client or server error $isClientError = $e->isClientError(); // Check for specific error types if ($e->isNotFoundError()) { return $this->json([ 'error' => 'not_found', 'message' => 'The requested resource could not be found.' ], 404); } return $this->json([ 'error' => 'api_error', 'message' => $e->getMessage(), 'code' => $dbCode ], $httpCode); }
Common Error Scenarios
Entity Not Found (SY200)
try { $product = $repository->find('non-existent-id'); } catch (SyncopateApiException $e) { if ($e->isErrorType('SY200')) { // Entity not found handling } }
Unique Constraint Violation (SY209)
try { $repository->create($product); } catch (SyncopateIntegrityConstraintException $e) { // Specialized exception type with field information $field = $e->getField(); // The field that caused the violation $value = $e->getValue(); // The duplicated value }
Validation Errors (SY203, SY206, SY207, SY208)
try { $repository->create($invalidProduct); } catch (SyncopateValidationException $e) { // Get all violations as field => message array $violations = $e->getViolations(); // Check for a specific field error if ($e->hasViolation('price')) { $priceError = $e->getViolation('price'); } }
Command Line Tools
The bundle provides console commands for managing entities:
# Register entity types in SyncopateDB bin/console syncopate:register-entity-types # Force update of entity types even if they already exist bin/console syncopate:register-entity-types --force # Specify additional paths to scan for entity classes bin/console syncopate:register-entity-types --path=src/CustomEntities # Register specific entity classes bin/console syncopate:register-entity-types --class=App\\Entity\\Product
Debug Tools
The bundle includes the DebugHelper
class for troubleshooting common issues:
use Phillarmonic\SyncopateBundle\Util\DebugHelper; // Enable detailed debug mode DebugHelper::enableDebug(); // Set a custom log callback DebugHelper::setLogCallback(function($message, $context) { // Your custom logging implementation $this->logger->debug($message, $context ?? []); }); // Check for problematic data types in an array $issues = DebugHelper::checkArrayForProblematicTypes($data); if (!empty($issues)) { foreach ($issues as $issue) { echo "Issue at {$issue['path']}: {$issue['issue']}\n"; } } // Report memory usage $memoryInfo = DebugHelper::getMemoryUsage(); echo "Memory used: {$memoryInfo['current']} (peak: {$memoryInfo['peak']})\n"; // Sanitize data for JSON encoding $sanitizedData = DebugHelper::sanitizeForJson($data); $json = DebugHelper::tryJsonEncode($sanitizedData);
License
This bundle is available under the MIT License.