diff --git a/php/README.md b/php/README.md new file mode 100644 index 00000000..e92e53ab --- /dev/null +++ b/php/README.md @@ -0,0 +1,281 @@ +# PHP Doublets Adapter via GraphQL Client + +A PHP library that provides standard Doublets CRUD operations API in native PHP code style via GraphQL client. This adapter connects to the LinksPlatform Doublets GraphQL server and provides an idiomatic PHP interface for working with Links (Doublets). + +## Features + +- ๐Ÿš€ **Native PHP API** - Clean, idiomatic PHP interface following modern PHP standards +- ๐Ÿ”„ **Full CRUD Operations** - Create, Read, Update, Delete operations for Links +- ๐ŸŽฏ **Query Builder** - Fluent interface for building complex queries +- ๐Ÿ“ฆ **Collection Support** - Rich collection class with filtering, mapping, and iteration +- ๐Ÿงช **Fully Tested** - Comprehensive test suite with PHPUnit +- ๐Ÿ”ง **Easy Setup** - Simple factory methods for common configurations +- ๐ŸŒ **GraphQL Powered** - Uses reliable GraphQL client library underneath +- ๐Ÿ”€ **Swappable Architecture** - Designed to be replaceable with native library when available + +## Installation + +Install via Composer: + +```bash +composer require linksplatform/data-doublets-gql-php +``` + +## Quick Start + +### Basic Usage + +```php +create(fromId: 1, toId: 2); +echo "Created link: {$link}\n"; // Link(id: 3, from: 1, to: 2) + +// Get a link by ID +$link = $client->get(3); +if ($link) { + echo "Found link: {$link}\n"; +} + +// Update a link +$updatedLink = $client->update(id: 3, fromId: 1, toId: 5); +echo "Updated link: {$updatedLink}\n"; + +// Delete a link +$deleted = $client->delete(3); +echo $deleted ? "Link deleted\n" : "Link not found\n"; +``` + +### Advanced Query Examples + +```php +whereFromId(1) + ->whereToId(2) + ->limit(10) + ->offset(5) + ->orderBy('id', 'desc'); + +$links = $client->find($query); + +echo "Found {$links->count()} links:\n"; +foreach ($links as $link) { + echo "- {$link}\n"; +} + +// Get all links with pagination +$allLinks = $client->all(limit: 20, offset: 0); + +// Count links +$totalCount = $client->count(); +echo "Total links: {$totalCount}\n"; + +// Count with criteria +$filteredCount = $client->count($query); +echo "Filtered links: {$filteredCount}\n"; +``` + +### Working with Collections + +```php +all(); + +// Filter collections +$filtered = $links->filter(fn($link) => $link->getFromId() === 1); + +// Find specific links +$link = $links->findById(123); +$fromLinks = $links->findByFromId(1); +$toLinks = $links->findByToId(2); + +// Map to extract data +$ids = $links->map(fn($link) => $link->getId()); + +// Convert to array or JSON +$array = $links->toArray(); +$json = json_encode($links); // Uses JsonSerializable +``` + +### Batch Operations + +```php + 1, 'to_id' => 2], + ['from_id' => 2, 'to_id' => 3], + ['from_id' => 3, 'to_id' => 4], +]; + +$createdLinks = $client->createMany($linksData); +echo "Created {$createdLinks->count()} links\n"; + +// Delete multiple links +$deleteQuery = (new LinksQuery())->whereFromId(1); +$deletedLinks = $client->deleteMany($deleteQuery); +echo "Deleted {$deletedLinks->count()} links\n"; +``` + +### Custom Configuration + +```php + 'Bearer your-token', + 'X-API-Key' => 'your-api-key', + 'X-Client-Version' => '1.0.0' + ] +); +``` + +## API Reference + +### DoubletsClient + +Main client class implementing the `LinksInterface`. + +#### Core Methods + +- `create(int $fromId, int $toId): Link` - Create a new link +- `getOrCreate(int $fromId, int $toId): Link` - Get existing or create new link +- `get(int $id): ?Link` - Get link by ID +- `update(int $id, int $fromId, int $toId): Link` - Update existing link +- `delete(int $id): bool` - Delete link by ID +- `find(LinksQuery $query): LinksCollection` - Find links by criteria +- `all(?int $limit = null, ?int $offset = null): LinksCollection` - Get all links +- `count(?LinksQuery $query = null): int` - Count links + +#### Batch Methods + +- `createMany(array $links): LinksCollection` - Create multiple links +- `deleteMany(LinksQuery $query): LinksCollection` - Delete multiple links + +### LinksQuery + +Fluent query builder for constructing search criteria. + +#### Methods + +- `whereId(int $id): self` - Filter by link ID +- `whereFromId(int $fromId): self` - Filter by source ID +- `whereToId(int $toId): self` - Filter by target ID +- `limit(int $limit): self` - Limit results +- `offset(int $offset): self` - Skip results +- `orderBy(string $field, string $direction = 'asc'): self` - Order results +- `distinctOn(string $field): self` - Distinct values + +### Link + +Represents a single link with ID, from_id, and to_id. + +#### Methods + +- `getId(): int` - Get link ID +- `getFromId(): int` - Get source ID +- `getToId(): int` - Get target ID +- `toArray(): array` - Convert to array +- `toJson(): string` - Convert to JSON +- `__toString(): string` - String representation + +### LinksCollection + +Collection class for working with multiple links. + +#### Methods + +- `count(): int` - Count links +- `first(): ?Link` - Get first link +- `last(): ?Link` - Get last link +- `findById(int $id): ?Link` - Find by ID +- `findByFromId(int $fromId): LinksCollection` - Find by source ID +- `findByToId(int $toId): LinksCollection` - Find by target ID +- `filter(callable $callback): LinksCollection` - Filter links +- `map(callable $callback): array` - Map to array +- `toArray(): array` - Convert to array + +## Requirements + +- PHP 8.0 or higher +- Composer for dependency management +- Access to a LinksPlatform Doublets GraphQL server + +## Development + +### Running Tests + +```bash +composer test +``` + +### Code Style + +```bash +composer cs-check # Check coding standards +composer cs-fix # Fix coding standards +``` + +### Installing Dependencies + +```bash +composer install +``` + +## Architecture + +This library is designed with a clean architecture that separates concerns: + +- **Core Layer**: Link, LinksCollection, LinksQuery - Domain models +- **Interface Layer**: LinksInterface - Contract definition +- **GraphQL Layer**: GraphQLClient, QueryBuilder - GraphQL abstraction +- **Client Layer**: DoubletsClient - Main implementation +- **Factory Layer**: DoubletsClientFactory - Easy instantiation + +The GraphQL client layer can be easily swapped out for a native C++ library implementation when it becomes available, without changing the public API. + +## License + +MIT License + +## Contributing + +1. Fork the repository +2. Create your feature branch (`git checkout -b feature/amazing-feature`) +3. Commit your changes (`git commit -m 'Add some amazing feature'`) +4. Push to the branch (`git push origin feature/amazing-feature`) +5. Open a Pull Request + +## Links + +- [LinksPlatform Documentation](https://github.com/LinksPlatform/Documentation) +- [Data.Doublets.Gql Repository](https://github.com/linksplatform/Data.Doublets.Gql) +- [Online GraphQL Demo](http://linksplatform.ddns.net:29018/ui/playground) \ No newline at end of file diff --git a/php/composer.json b/php/composer.json new file mode 100644 index 00000000..18c9f2d7 --- /dev/null +++ b/php/composer.json @@ -0,0 +1,41 @@ +{ + "name": "linksplatform/data-doublets-gql-php", + "description": "PHP Doublets Adapter via GraphQL client - provides standard Doublets CRUD operations API in native PHP code style", + "type": "library", + "keywords": ["doublets", "graphql", "php", "crud", "linksplatform"], + "license": "MIT", + "authors": [ + { + "name": "Links Platform", + "email": "konard@live.ru" + } + ], + "require": { + "php": ">=8.0", + "gmostafa/php-graphql-client": "^1.13" + }, + "require-dev": { + "phpunit/phpunit": "^10.0", + "squizlabs/php_codesniffer": "^3.7" + }, + "autoload": { + "psr-4": { + "LinksPlatform\\Data\\Doublets\\Gql\\": "src/" + } + }, + "autoload-dev": { + "psr-4": { + "LinksPlatform\\Data\\Doublets\\Gql\\Tests\\": "tests/" + } + }, + "scripts": { + "test": "phpunit", + "cs-check": "phpcs", + "cs-fix": "phpcbf" + }, + "config": { + "sort-packages": true + }, + "minimum-stability": "stable", + "prefer-stable": true +} \ No newline at end of file diff --git a/php/examples/basic_usage.php b/php/examples/basic_usage.php new file mode 100644 index 00000000..358cc892 --- /dev/null +++ b/php/examples/basic_usage.php @@ -0,0 +1,108 @@ +getEndpoint() . "\n\n"; + + // Example 1: Create a new link + echo "1. Creating a new link...\n"; + $newLink = $client->create(fromId: 1, toId: 2); + echo "Created: {$newLink}\n\n"; + + // Example 2: Get link by ID + echo "2. Getting link by ID...\n"; + $foundLink = $client->get($newLink->getId()); + if ($foundLink) { + echo "Found: {$foundLink}\n\n"; + } + + // Example 3: Get or create (idempotent operation) + echo "3. Get or create operation...\n"; + $sameLink = $client->getOrCreate(1, 2); + echo "Got existing: {$sameLink}\n\n"; + + // Example 4: Update a link + echo "4. Updating the link...\n"; + $updatedLink = $client->update($newLink->getId(), fromId: 1, toId: 5); + echo "Updated: {$updatedLink}\n\n"; + + // Example 5: Create more links for querying + echo "5. Creating more links for demonstration...\n"; + $link2 = $client->create(fromId: 2, toId: 3); + $link3 = $client->create(fromId: 1, toId: 3); + echo "Created: {$link2}\n"; + echo "Created: {$link3}\n\n"; + + // Example 6: Query with criteria + echo "6. Querying links with criteria...\n"; + $query = (new LinksQuery()) + ->whereFromId(1) + ->limit(10) + ->orderBy('id', 'desc'); + + $results = $client->find($query); + echo "Found {$results->count()} links with from_id = 1:\n"; + foreach ($results as $link) { + echo " - {$link}\n"; + } + echo "\n"; + + // Example 7: Get all links with pagination + echo "7. Getting all links (limited)...\n"; + $allLinks = $client->all(limit: 5); + echo "Total links (first 5): {$allLinks->count()}\n"; + foreach ($allLinks as $link) { + echo " - {$link}\n"; + } + echo "\n"; + + // Example 8: Count operations + echo "8. Counting links...\n"; + $totalCount = $client->count(); + echo "Total links in database: {$totalCount}\n"; + + $filteredCount = $client->count($query); + echo "Links with from_id = 1: {$filteredCount}\n\n"; + + // Example 9: Collection operations + echo "9. Working with collections...\n"; + $collection = $client->all(limit: 10); + + // Filter collection + $filtered = $collection->filter(fn($link) => $link->getFromId() === 1); + echo "Filtered collection size: {$filtered->count()}\n"; + + // Map to get just IDs + $ids = $collection->map(fn($link) => $link->getId()); + echo "All IDs: " . implode(', ', $ids) . "\n"; + + // Find specific links + $byFromId = $collection->findByFromId(1); + echo "Links from node 1: {$byFromId->count()}\n\n"; + + // Example 10: Clean up - delete created links + echo "10. Cleaning up - deleting created links...\n"; + $deleted1 = $client->delete($newLink->getId()); + $deleted2 = $client->delete($link2->getId()); + $deleted3 = $client->delete($link3->getId()); + + echo "Cleanup completed. Deleted: " . ($deleted1 + $deleted2 + $deleted3) . " links\n"; + +} catch (Exception $e) { + echo "Error: " . $e->getMessage() . "\n"; + echo "Make sure the Doublets GraphQL server is running!\n"; + echo "Start it with: cd csharp/Platform.Data.Doublets.Gql.Server && dotnet run\n"; +} \ No newline at end of file diff --git a/php/examples/batch_operations.php b/php/examples/batch_operations.php new file mode 100644 index 00000000..124ae255 --- /dev/null +++ b/php/examples/batch_operations.php @@ -0,0 +1,154 @@ +getEndpoint() . "\n\n"; + + // Example 1: Batch create operations + echo "1. Creating multiple links in batch...\n"; + $linksToCreate = [ + ['from_id' => 10, 'to_id' => 20], + ['from_id' => 20, 'to_id' => 30], + ['from_id' => 30, 'to_id' => 40], + ['from_id' => 10, 'to_id' => 30], // Alternative path + ['from_id' => 10, 'to_id' => 40], // Direct connection + ]; + + $createdLinks = $client->createMany($linksToCreate); + echo "Created {$createdLinks->count()} links:\n"; + foreach ($createdLinks as $link) { + echo " - {$link}\n"; + } + echo "\n"; + + // Example 2: Query the created links + echo "2. Querying created links...\n"; + $query = (new LinksQuery()) + ->whereFromId(10) + ->orderBy('to_id', 'asc'); + + $fromNode10 = $client->find($query); + echo "Links from node 10: {$fromNode10->count()}\n"; + foreach ($fromNode10 as $link) { + echo " - {$link}\n"; + } + echo "\n"; + + // Example 3: Working with the collection + echo "3. Collection analysis...\n"; + $allCreated = $client->find((new LinksQuery())->whereFromId(10)); + + // Find links going to specific targets + $toNode30 = $allCreated->findByToId(30); + $toNode40 = $allCreated->findByToId(40); + + echo "Direct connections from 10:\n"; + echo " - To node 30: {$toNode30->count()} links\n"; + echo " - To node 40: {$toNode40->count()} links\n"; + + // Map to show all target nodes + $targets = $allCreated->map(fn($link) => $link->getToId()); + echo " - All targets: " . implode(', ', $targets) . "\n\n"; + + // Example 4: Advanced querying + echo "4. Advanced querying with multiple criteria...\n"; + + // Find all intermediate nodes (nodes that are both source and target) + $allLinks = $client->all(); + $fromIds = array_unique($allLinks->map(fn($link) => $link->getFromId())); + $toIds = array_unique($allLinks->map(fn($link) => $link->getToId())); + $intermediateNodes = array_intersect($fromIds, $toIds); + + echo "Intermediate nodes (both source and target): " . implode(', ', $intermediateNodes) . "\n"; + + // Find paths through intermediate nodes + foreach ($intermediateNodes as $intermediate) { + $incoming = $allLinks->findByToId($intermediate); + $outgoing = $allLinks->findByFromId($intermediate); + + if ($incoming->count() > 0 && $outgoing->count() > 0) { + echo "Node {$intermediate} has {$incoming->count()} incoming and {$outgoing->count()} outgoing links\n"; + } + } + echo "\n"; + + // Example 5: Batch operations with filtering + echo "5. Working with filtered collections...\n"; + + // Get all our test links + $testLinks = $client->all()->filter(function($link) { + return in_array($link->getFromId(), [10, 20, 30]) && + in_array($link->getToId(), [20, 30, 40]); + }); + + echo "Test links found: {$testLinks->count()}\n"; + + // Group by from_id + $groupedBySource = []; + foreach ($testLinks as $link) { + $fromId = $link->getFromId(); + if (!isset($groupedBySource[$fromId])) { + $groupedBySource[$fromId] = []; + } + $groupedBySource[$fromId][] = $link; + } + + echo "Grouped by source:\n"; + foreach ($groupedBySource as $fromId => $links) { + $targets = array_map(fn($link) => $link->getToId(), $links); + echo " - From {$fromId}: " . implode(', ', $targets) . "\n"; + } + echo "\n"; + + // Example 6: Batch cleanup + echo "6. Batch cleanup - deleting test links...\n"; + + // Delete all links where from_id is 10 + $deleteQuery = (new LinksQuery())->whereFromId(10); + $deletedFromNode10 = $client->deleteMany($deleteQuery); + echo "Deleted {$deletedFromNode10->count()} links from node 10:\n"; + foreach ($deletedFromNode10 as $link) { + echo " - Deleted: {$link}\n"; + } + + // Delete remaining test links + $deleteQuery2 = (new LinksQuery())->whereFromId(20); + $deletedFromNode20 = $client->deleteMany($deleteQuery2); + + $deleteQuery3 = (new LinksQuery())->whereFromId(30); + $deletedFromNode30 = $client->deleteMany($deleteQuery3); + + $totalDeleted = $deletedFromNode10->count() + $deletedFromNode20->count() + $deletedFromNode30->count(); + echo "\nTotal cleanup: Deleted {$totalDeleted} test links\n"; + + // Verify cleanup + echo "\n7. Verification - checking remaining links...\n"; + $remainingTestLinks = $client->all()->filter(function($link) { + return in_array($link->getFromId(), [10, 20, 30]) && + in_array($link->getToId(), [20, 30, 40]); + }); + + echo "Remaining test links: {$remainingTestLinks->count()}\n"; + if ($remainingTestLinks->count() === 0) { + echo "โœ… Cleanup successful - no test links remaining\n"; + } else { + echo "โš ๏ธ Some test links still exist:\n"; + foreach ($remainingTestLinks as $link) { + echo " - {$link}\n"; + } + } + +} catch (Exception $e) { + echo "Error: " . $e->getMessage() . "\n"; + echo "Make sure the Doublets GraphQL server is running!\n"; + echo "Start it with: cd csharp/Platform.Data.Doublets.Gql.Server && dotnet run\n"; +} \ No newline at end of file diff --git a/php/examples/configuration.php b/php/examples/configuration.php new file mode 100644 index 00000000..7693d702 --- /dev/null +++ b/php/examples/configuration.php @@ -0,0 +1,215 @@ +getEndpoint() . "\n"; +echo "Use this for local development when running the server locally.\n\n"; + +// Example 2: Demo server configuration +echo "2. Demo Server Configuration\n"; +echo "============================\n"; + +$demoClient = DoubletsClientFactory::createDemo(); +echo "Demo endpoint: " . $demoClient->getEndpoint() . "\n"; +echo "Use this to test against the public demo server.\n\n"; + +// Example 3: Custom endpoint +echo "3. Custom Endpoint Configuration\n"; +echo "=================================\n"; + +$customEndpoint = 'http://my-custom-server:8080/v1/graphql'; +$customClient = DoubletsClientFactory::create($customEndpoint); +echo "Custom endpoint: " . $customClient->getEndpoint() . "\n"; +echo "Use this when you have your own server deployment.\n\n"; + +// Example 4: Authentication with Bearer token +echo "4. Authentication Configuration\n"; +echo "===============================\n"; + +$securedEndpoint = 'https://secured-server.example.com/v1/graphql'; +$apiToken = 'your-api-token-here'; +$authenticatedClient = DoubletsClientFactory::createWithToken($securedEndpoint, $apiToken); +echo "Secured endpoint: " . $authenticatedClient->getEndpoint() . "\n"; +echo "This configuration includes Bearer token authentication.\n\n"; + +// Example 5: Custom headers configuration +echo "5. Custom Headers Configuration\n"; +echo "================================\n"; + +$customHeaders = [ + 'Authorization' => 'Bearer custom-token-123', + 'X-API-Key' => 'api-key-456', + 'X-Client-Version' => '1.0.0', + 'X-Request-ID' => uniqid('req_'), + 'User-Agent' => 'DoubletsClient-PHP/1.0' +]; + +$headerClient = DoubletsClientFactory::createWithHeaders($securedEndpoint, $customHeaders); +echo "Endpoint with custom headers: " . $headerClient->getEndpoint() . "\n"; +echo "Custom headers included:\n"; +foreach ($customHeaders as $name => $value) { + echo " - {$name}: {$value}\n"; +} +echo "\n"; + +// Example 6: Environment-based configuration +echo "6. Environment-Based Configuration\n"; +echo "===================================\n"; + +function createClientFromEnvironment(): DoubletsClient +{ + // In a real application, you'd use $_ENV or getenv() + // For this example, we'll simulate environment variables + $env = [ + 'DOUBLETS_ENDPOINT' => 'http://localhost:60341/v1/graphql', + 'DOUBLETS_API_TOKEN' => null, + 'DOUBLETS_ENVIRONMENT' => 'development' + ]; + + $endpoint = $env['DOUBLETS_ENDPOINT']; + $token = $env['DOUBLETS_API_TOKEN']; + $environment = $env['DOUBLETS_ENVIRONMENT']; + + echo "Environment: {$environment}\n"; + echo "Endpoint: {$endpoint}\n"; + + if ($token) { + echo "Using API token authentication\n"; + return DoubletsClientFactory::createWithToken($endpoint, $token); + } else { + echo "No authentication configured\n"; + return DoubletsClientFactory::create($endpoint); + } +} + +$envClient = createClientFromEnvironment(); +echo "\n"; + +// Example 7: Configuration validation +echo "7. Configuration Validation\n"; +echo "============================\n"; + +function validateClientConfiguration(DoubletsClient $client): array +{ + $issues = []; + $endpoint = $client->getEndpoint(); + + // Check if endpoint is valid URL + if (!filter_var($endpoint, FILTER_VALIDATE_URL)) { + $issues[] = "Invalid endpoint URL: {$endpoint}"; + } + + // Check if endpoint uses HTTPS in production + if (!str_starts_with($endpoint, 'https://') && + !str_starts_with($endpoint, 'http://localhost') && + !str_starts_with($endpoint, 'http://127.0.0.1')) { + $issues[] = "Consider using HTTPS for production: {$endpoint}"; + } + + // Check if endpoint path looks correct + if (!str_contains($endpoint, '/graphql')) { + $issues[] = "Endpoint might be missing GraphQL path: {$endpoint}"; + } + + return $issues; +} + +$clients = [ + 'Local' => $localClient, + 'Demo' => $demoClient, + 'Custom' => $customClient, + 'Authenticated' => $authenticatedClient +]; + +foreach ($clients as $name => $client) { + $issues = validateClientConfiguration($client); + echo "{$name} client: "; + if (empty($issues)) { + echo "โœ… Configuration looks good\n"; + } else { + echo "โš ๏ธ Issues found:\n"; + foreach ($issues as $issue) { + echo " - {$issue}\n"; + } + } +} +echo "\n"; + +// Example 8: Production-ready configuration helper +echo "8. Production Configuration Helper\n"; +echo "===================================\n"; + +class DoubletsConfig +{ + public static function fromEnvironment(): DoubletsClient + { + $endpoint = $_ENV['DOUBLETS_ENDPOINT'] ?? 'http://localhost:60341/v1/graphql'; + $token = $_ENV['DOUBLETS_API_TOKEN'] ?? null; + $timeout = (int)($_ENV['DOUBLETS_TIMEOUT'] ?? 30); + $retries = (int)($_ENV['DOUBLETS_RETRIES'] ?? 3); + + $headers = []; + + if ($token) { + $headers['Authorization'] = "Bearer {$token}"; + } + + // Add operational headers + $headers['X-Client-Name'] = 'DoubletsClient-PHP'; + $headers['X-Client-Version'] = '1.0.0'; + $headers['X-Request-Timeout'] = (string)$timeout; + + return DoubletsClientFactory::createWithHeaders($endpoint, $headers); + } + + public static function forDevelopment(): DoubletsClient + { + return DoubletsClientFactory::createLocal(); + } + + public static function forTesting(): DoubletsClient + { + return DoubletsClientFactory::createDemo(); + } + + public static function forProduction(string $endpoint, string $apiToken): DoubletsClient + { + if (!str_starts_with($endpoint, 'https://')) { + throw new InvalidArgumentException('Production endpoint must use HTTPS'); + } + + return DoubletsClientFactory::createWithHeaders($endpoint, [ + 'Authorization' => "Bearer {$apiToken}", + 'X-Client-Name' => 'DoubletsClient-PHP', + 'X-Client-Version' => '1.0.0', + 'X-Environment' => 'production' + ]); + } +} + +// Demo the config helper +echo "Configuration helper examples:\n"; +echo "- Development: " . DoubletsConfig::forDevelopment()->getEndpoint() . "\n"; +echo "- Testing: " . DoubletsConfig::forTesting()->getEndpoint() . "\n"; + +// Production example (would throw exception due to non-HTTPS) +try { + $prodClient = DoubletsConfig::forProduction('http://prod.example.com/graphql', 'token'); +} catch (Exception $e) { + echo "- Production: " . $e->getMessage() . "\n"; +} + +echo "\n"; + +echo "=== Configuration Examples Complete ===\n"; +echo "Choose the configuration method that best fits your use case!\n"; \ No newline at end of file diff --git a/php/phpcs.xml b/php/phpcs.xml new file mode 100644 index 00000000..0693ac76 --- /dev/null +++ b/php/phpcs.xml @@ -0,0 +1,19 @@ + + + Coding standards for LinksPlatform PHP projects + + src + tests + + vendor/* + + + + + + + + + + + \ No newline at end of file diff --git a/php/phpunit.xml b/php/phpunit.xml new file mode 100644 index 00000000..0cb3812a --- /dev/null +++ b/php/phpunit.xml @@ -0,0 +1,17 @@ + + + + + ./tests + + + + + ./src + + + \ No newline at end of file diff --git a/php/src/DoubletsClient.php b/php/src/DoubletsClient.php new file mode 100644 index 00000000..0221306f --- /dev/null +++ b/php/src/DoubletsClient.php @@ -0,0 +1,274 @@ +graphqlClient = new GraphQLClient($graphqlEndpoint, $authHeaders); + $this->queryBuilder = new QueryBuilder(); + } + + /** + * Create a new link between two nodes. + * + * @param int $fromId The source node ID + * @param int $toId The target node ID + * @return Link The created link + * @throws LinksException + */ + public function create(int $fromId, int $toId): Link + { + try { + $mutation = $this->queryBuilder->buildInsertLinkMutation($fromId, $toId); + $response = $this->graphqlClient->runMutation($mutation); + + if (!isset($response['insert_links_one'])) { + throw new LinksException('Failed to create link: invalid response structure'); + } + + return Link::fromArray($response['insert_links_one']); + } catch (GraphQLException $e) { + throw new LinksException('Failed to create link: ' . $e->getMessage(), 0, $e); + } + } + + /** + * Get or create a link between two nodes. + * If a link already exists, return it; otherwise create a new one. + * + * @param int $fromId The source node ID + * @param int $toId The target node ID + * @return Link The existing or newly created link + * @throws LinksException + */ + public function getOrCreate(int $fromId, int $toId): Link + { + // First try to find existing link + $query = (new LinksQuery()) + ->whereFromId($fromId) + ->whereToId($toId) + ->limit(1); + + $existing = $this->find($query); + + if (!$existing->isEmpty()) { + return $existing->first(); + } + + // Create new link if none exists + return $this->create($fromId, $toId); + } + + /** + * Get a link by its ID. + * + * @param int $id The link ID + * @return Link|null The link if found, null otherwise + * @throws LinksException + */ + public function get(int $id): ?Link + { + try { + $query = $this->queryBuilder->buildLinkByIdQuery($id); + $response = $this->graphqlClient->runQuery($query); + + if (!isset($response['links_by_pk']) || $response['links_by_pk'] === null) { + return null; + } + + return Link::fromArray($response['links_by_pk']); + } catch (GraphQLException $e) { + throw new LinksException('Failed to get link: ' . $e->getMessage(), 0, $e); + } + } + + /** + * Update an existing link. + * + * @param int $id The link ID to update + * @param int $fromId The new source node ID + * @param int $toId The new target node ID + * @return Link The updated link + * @throws LinksException + */ + public function update(int $id, int $fromId, int $toId): Link + { + try { + $whereQuery = (new LinksQuery())->whereId($id); + $mutation = $this->queryBuilder->buildUpdateLinksMutation($whereQuery, $fromId, $toId); + $response = $this->graphqlClient->runMutation($mutation); + + if (!isset($response['update_links']['returning']) || empty($response['update_links']['returning'])) { + throw new LinksException('Failed to update link: no link found with ID ' . $id); + } + + return Link::fromArray($response['update_links']['returning'][0]); + } catch (GraphQLException $e) { + throw new LinksException('Failed to update link: ' . $e->getMessage(), 0, $e); + } + } + + /** + * Delete a link by its ID. + * + * @param int $id The link ID to delete + * @return bool True if deletion was successful, false otherwise + * @throws LinksException + */ + public function delete(int $id): bool + { + try { + $whereQuery = (new LinksQuery())->whereId($id); + $mutation = $this->queryBuilder->buildDeleteLinksMutation($whereQuery); + $response = $this->graphqlClient->runMutation($mutation); + + return isset($response['delete_links']['returning']) && !empty($response['delete_links']['returning']); + } catch (GraphQLException $e) { + throw new LinksException('Failed to delete link: ' . $e->getMessage(), 0, $e); + } + } + + /** + * Find links by criteria. + * + * @param LinksQuery $query The query criteria + * @return LinksCollection Collection of matching links + * @throws LinksException + */ + public function find(LinksQuery $query): LinksCollection + { + try { + $graphqlQuery = $this->queryBuilder->buildLinksQuery($query); + $response = $this->graphqlClient->runQuery($graphqlQuery); + + if (!isset($response['links'])) { + throw new LinksException('Failed to find links: invalid response structure'); + } + + return LinksCollection::fromArray($response['links']); + } catch (GraphQLException $e) { + throw new LinksException('Failed to find links: ' . $e->getMessage(), 0, $e); + } + } + + /** + * Get all links. + * + * @param int|null $limit Maximum number of links to return + * @param int|null $offset Number of links to skip + * @return LinksCollection Collection of all links + * @throws LinksException + */ + public function all(?int $limit = null, ?int $offset = null): LinksCollection + { + $query = new LinksQuery(); + + if ($limit !== null) { + $query->limit($limit); + } + + if ($offset !== null) { + $query->offset($offset); + } + + return $this->find($query); + } + + /** + * Count links matching criteria. + * + * @param LinksQuery|null $query The query criteria (null for count all) + * @return int Number of matching links + * @throws LinksException + */ + public function count(?LinksQuery $query = null): int + { + try { + $graphqlQuery = $this->queryBuilder->buildLinksAggregateQuery($query); + $response = $this->graphqlClient->runQuery($graphqlQuery); + + if (!isset($response['links_aggregate']['aggregate']['count'])) { + throw new LinksException('Failed to count links: invalid response structure'); + } + + return (int)$response['links_aggregate']['aggregate']['count']; + } catch (GraphQLException $e) { + throw new LinksException('Failed to count links: ' . $e->getMessage(), 0, $e); + } + } + + /** + * Create multiple links in a single operation. + * + * @param array $links Array of ['from_id' => int, 'to_id' => int] + * @return LinksCollection Collection of created links + * @throws LinksException + */ + public function createMany(array $links): LinksCollection + { + try { + $mutation = $this->queryBuilder->buildInsertLinksMutation($links); + $response = $this->graphqlClient->runMutation($mutation); + + if (!isset($response['insert_links']['returning'])) { + throw new LinksException('Failed to create links: invalid response structure'); + } + + return LinksCollection::fromArray($response['insert_links']['returning']); + } catch (GraphQLException $e) { + throw new LinksException('Failed to create links: ' . $e->getMessage(), 0, $e); + } + } + + /** + * Delete multiple links matching criteria. + * + * @param LinksQuery $query The query criteria for links to delete + * @return LinksCollection Collection of deleted links + * @throws LinksException + */ + public function deleteMany(LinksQuery $query): LinksCollection + { + try { + $mutation = $this->queryBuilder->buildDeleteLinksMutation($query); + $response = $this->graphqlClient->runMutation($mutation); + + if (!isset($response['delete_links']['returning'])) { + throw new LinksException('Failed to delete links: invalid response structure'); + } + + return LinksCollection::fromArray($response['delete_links']['returning']); + } catch (GraphQLException $e) { + throw new LinksException('Failed to delete links: ' . $e->getMessage(), 0, $e); + } + } + + /** + * Get the GraphQL endpoint URL. + * + * @return string + */ + public function getEndpoint(): string + { + return $this->graphqlClient->getEndpoint(); + } +} \ No newline at end of file diff --git a/php/src/DoubletsClientFactory.php b/php/src/DoubletsClientFactory.php new file mode 100644 index 00000000..50dd6a87 --- /dev/null +++ b/php/src/DoubletsClientFactory.php @@ -0,0 +1,78 @@ + 'Bearer ' . $token + ]); + } + + /** + * Create a client with custom headers. + * + * @param string $endpoint GraphQL endpoint URL + * @param array $headers Custom headers + * @return DoubletsClient + */ + public static function createWithHeaders(string $endpoint, array $headers): DoubletsClient + { + return new DoubletsClient($endpoint, $headers); + } +} \ No newline at end of file diff --git a/php/src/Exception/GraphQLException.php b/php/src/Exception/GraphQLException.php new file mode 100644 index 00000000..fcfd65df --- /dev/null +++ b/php/src/Exception/GraphQLException.php @@ -0,0 +1,14 @@ +endpoint = $endpoint; + $this->client = new Client($endpoint, $authHeaders); + } + + /** + * Execute a GraphQL query and return the response data. + * + * @param Query $query + * @return array + * @throws GraphQLException + */ + public function runQuery(Query $query): array + { + try { + $response = $this->client->runQuery($query); + + if ($response->hasErrors()) { + throw new GraphQLException( + 'GraphQL query failed: ' . implode(', ', $response->getErrorMessages()) + ); + } + + return $response->getData(); + } catch (QueryError $e) { + throw new GraphQLException('GraphQL query error: ' . $e->getMessage(), 0, $e); + } + } + + /** + * Execute a GraphQL mutation and return the response data. + * + * @param Mutation $mutation + * @return array + * @throws GraphQLException + */ + public function runMutation(Mutation $mutation): array + { + try { + $response = $this->client->runQuery($mutation); + + if ($response->hasErrors()) { + throw new GraphQLException( + 'GraphQL mutation failed: ' . implode(', ', $response->getErrorMessages()) + ); + } + + return $response->getData(); + } catch (QueryError $e) { + throw new GraphQLException('GraphQL mutation error: ' . $e->getMessage(), 0, $e); + } + } + + /** + * Get the GraphQL endpoint URL. + * + * @return string + */ + public function getEndpoint(): string + { + return $this->endpoint; + } +} \ No newline at end of file diff --git a/php/src/GraphQL/QueryBuilder.php b/php/src/GraphQL/QueryBuilder.php new file mode 100644 index 00000000..bb1b93dd --- /dev/null +++ b/php/src/GraphQL/QueryBuilder.php @@ -0,0 +1,210 @@ +toWhereClause(); + if (!empty($whereClause)) { + $query->setArguments(['where' => $whereClause]); + } + + // Add limit if specified + if ($linksQuery->getLimit() !== null) { + $query->setArguments(['limit' => $linksQuery->getLimit()]); + } + + // Add offset if specified + if ($linksQuery->getOffset() !== null) { + $query->setArguments(['offset' => $linksQuery->getOffset()]); + } + + // Add order by if specified + if (!empty($linksQuery->getOrderBy())) { + $query->setArguments(['order_by' => $linksQuery->getOrderBy()]); + } + + // Add distinct on if specified + if (!empty($linksQuery->getDistinctOn())) { + $query->setArguments(['distinct_on' => $linksQuery->getDistinctOn()]); + } + + // Select fields + $query->setSelectionSet([ + 'id', + 'from_id', + 'to_id' + ]); + + return $query; + } + + /** + * Build a GraphQL query to count links. + * + * @param LinksQuery|null $linksQuery + * @return Query + */ + public function buildLinksAggregateQuery(?LinksQuery $linksQuery = null): Query + { + $query = new Query('links_aggregate'); + + // Add where clause if filters are specified + if ($linksQuery !== null) { + $whereClause = $linksQuery->toWhereClause(); + if (!empty($whereClause)) { + $query->setArguments(['where' => $whereClause]); + } + } + + // Select aggregate fields + $query->setSelectionSet([ + 'aggregate' => [ + 'count' + ] + ]); + + return $query; + } + + /** + * Build a GraphQL query to get a single link by ID. + * + * @param int $id + * @return Query + */ + public function buildLinkByIdQuery(int $id): Query + { + $query = new Query('links_by_pk'); + $query->setArguments(['id' => $id]); + $query->setSelectionSet([ + 'id', + 'from_id', + 'to_id' + ]); + + return $query; + } + + /** + * Build a GraphQL mutation to insert a single link. + * + * @param int $fromId + * @param int $toId + * @return Mutation + */ + public function buildInsertLinkMutation(int $fromId, int $toId): Mutation + { + $mutation = new Mutation('insert_links_one'); + $mutation->setArguments([ + 'object' => [ + 'from_id' => $fromId, + 'to_id' => $toId + ] + ]); + $mutation->setSelectionSet([ + 'id', + 'from_id', + 'to_id' + ]); + + return $mutation; + } + + /** + * Build a GraphQL mutation to insert multiple links. + * + * @param array $links Array of ['from_id' => int, 'to_id' => int] + * @return Mutation + */ + public function buildInsertLinksMutation(array $links): Mutation + { + $mutation = new Mutation('insert_links'); + $mutation->setArguments(['objects' => $links]); + $mutation->setSelectionSet([ + 'returning' => [ + 'id', + 'from_id', + 'to_id' + ] + ]); + + return $mutation; + } + + /** + * Build a GraphQL mutation to update links. + * + * @param LinksQuery $whereQuery + * @param int $newFromId + * @param int $newToId + * @return Mutation + */ + public function buildUpdateLinksMutation(LinksQuery $whereQuery, int $newFromId, int $newToId): Mutation + { + $mutation = new Mutation('update_links'); + $mutation->setArguments([ + 'where' => $whereQuery->toWhereClause(), + '_set' => [ + 'from_id' => $newFromId, + 'to_id' => $newToId + ] + ]); + $mutation->setSelectionSet([ + 'returning' => [ + 'id', + 'from_id', + 'to_id' + ] + ]); + + return $mutation; + } + + /** + * Build a GraphQL mutation to delete links. + * + * @param LinksQuery $whereQuery + * @return Mutation + */ + public function buildDeleteLinksMutation(LinksQuery $whereQuery): Mutation + { + $mutation = new Mutation('delete_links'); + $mutation->setArguments([ + 'where' => $whereQuery->toWhereClause() + ]); + $mutation->setSelectionSet([ + 'returning' => [ + 'id', + 'from_id', + 'to_id' + ] + ]); + + return $mutation; + } +} \ No newline at end of file diff --git a/php/src/Link.php b/php/src/Link.php new file mode 100644 index 00000000..7ff41022 --- /dev/null +++ b/php/src/Link.php @@ -0,0 +1,106 @@ +id = $id; + $this->fromId = $fromId; + $this->toId = $toId; + } + + /** + * Create a Link from GraphQL response data. + * + * @param array $data Array containing 'id', 'from_id', 'to_id' keys + * @return self + */ + public static function fromArray(array $data): self + { + return new self( + (int)$data['id'], + (int)$data['from_id'], + (int)$data['to_id'] + ); + } + + /** + * Get the link ID. + * + * @return int + */ + public function getId(): int + { + return $this->id; + } + + /** + * Get the source node ID. + * + * @return int + */ + public function getFromId(): int + { + return $this->fromId; + } + + /** + * Get the target node ID. + * + * @return int + */ + public function getToId(): int + { + return $this->toId; + } + + /** + * Convert the link to an array representation. + * + * @return array + */ + public function toArray(): array + { + return [ + 'id' => $this->id, + 'from_id' => $this->fromId, + 'to_id' => $this->toId, + ]; + } + + /** + * Convert the link to a JSON string. + * + * @return string + */ + public function toJson(): string + { + return json_encode($this->toArray(), JSON_THROW_ON_ERROR); + } + + /** + * String representation of the link. + * + * @return string + */ + public function __toString(): string + { + return sprintf('Link(id: %d, from: %d, to: %d)', $this->id, $this->fromId, $this->toId); + } +} \ No newline at end of file diff --git a/php/src/LinksCollection.php b/php/src/LinksCollection.php new file mode 100644 index 00000000..5bb1db3a --- /dev/null +++ b/php/src/LinksCollection.php @@ -0,0 +1,208 @@ +links = $links; + } + + /** + * Create a collection from GraphQL response data. + * + * @param array $data Array of link data + * @return self + */ + public static function fromArray(array $data): self + { + $links = []; + foreach ($data as $linkData) { + $links[] = Link::fromArray($linkData); + } + return new self($links); + } + + /** + * Add a link to the collection. + * + * @param Link $link + * @return self + */ + public function add(Link $link): self + { + $this->links[] = $link; + return $this; + } + + /** + * Get a link by its index in the collection. + * + * @param int $index + * @return Link|null + */ + public function get(int $index): ?Link + { + return $this->links[$index] ?? null; + } + + /** + * Get the first link in the collection. + * + * @return Link|null + */ + public function first(): ?Link + { + return $this->links[0] ?? null; + } + + /** + * Get the last link in the collection. + * + * @return Link|null + */ + public function last(): ?Link + { + $count = count($this->links); + return $count > 0 ? $this->links[$count - 1] : null; + } + + /** + * Filter the collection by a callback. + * + * @param callable $callback + * @return self + */ + public function filter(callable $callback): self + { + return new self(array_filter($this->links, $callback)); + } + + /** + * Map the collection to a new array. + * + * @param callable $callback + * @return array + */ + public function map(callable $callback): array + { + return array_map($callback, $this->links); + } + + /** + * Find a link by ID. + * + * @param int $id + * @return Link|null + */ + public function findById(int $id): ?Link + { + foreach ($this->links as $link) { + if ($link->getId() === $id) { + return $link; + } + } + return null; + } + + /** + * Find links by from_id. + * + * @param int $fromId + * @return self + */ + public function findByFromId(int $fromId): self + { + return $this->filter(fn(Link $link) => $link->getFromId() === $fromId); + } + + /** + * Find links by to_id. + * + * @param int $toId + * @return self + */ + public function findByToId(int $toId): self + { + return $this->filter(fn(Link $link) => $link->getToId() === $toId); + } + + /** + * Check if the collection is empty. + * + * @return bool + */ + public function isEmpty(): bool + { + return empty($this->links); + } + + /** + * Convert collection to array. + * + * @return array + */ + public function toArray(): array + { + return array_map(fn(Link $link) => $link->toArray(), $this->links); + } + + /** + * Get raw links array. + * + * @return Link[] + */ + public function getLinks(): array + { + return $this->links; + } + + /** + * Count the number of links. + * + * @return int + */ + public function count(): int + { + return count($this->links); + } + + /** + * Get iterator for foreach loops. + * + * @return ArrayIterator + */ + public function getIterator(): ArrayIterator + { + return new ArrayIterator($this->links); + } + + /** + * JSON serialization. + * + * @return array + */ + public function jsonSerialize(): array + { + return $this->toArray(); + } +} \ No newline at end of file diff --git a/php/src/LinksInterface.php b/php/src/LinksInterface.php new file mode 100644 index 00000000..9f91d17b --- /dev/null +++ b/php/src/LinksInterface.php @@ -0,0 +1,84 @@ +id = $id; + return $this; + } + + /** + * Filter by source node ID. + * + * @param int $fromId The source node ID + * @return self + */ + public function whereFromId(int $fromId): self + { + $this->fromId = $fromId; + return $this; + } + + /** + * Filter by target node ID. + * + * @param int $toId The target node ID + * @return self + */ + public function whereToId(int $toId): self + { + $this->toId = $toId; + return $this; + } + + /** + * Limit the number of results. + * + * @param int $limit Maximum number of results + * @return self + */ + public function limit(int $limit): self + { + $this->limit = $limit; + return $this; + } + + /** + * Skip a number of results. + * + * @param int $offset Number of results to skip + * @return self + */ + public function offset(int $offset): self + { + $this->offset = $offset; + return $this; + } + + /** + * Order results by a field. + * + * @param string $field Field name (id, from_id, to_id) + * @param string $direction Direction (asc, desc) + * @return self + */ + public function orderBy(string $field, string $direction = 'asc'): self + { + $this->orderBy[] = [$field => $direction]; + return $this; + } + + /** + * Select distinct values based on a field. + * + * @param string $field Field name + * @return self + */ + public function distinctOn(string $field): self + { + $this->distinctOn[] = $field; + return $this; + } + + /** + * Get the ID filter. + * + * @return int|null + */ + public function getId(): ?int + { + return $this->id; + } + + /** + * Get the from_id filter. + * + * @return int|null + */ + public function getFromId(): ?int + { + return $this->fromId; + } + + /** + * Get the to_id filter. + * + * @return int|null + */ + public function getToId(): ?int + { + return $this->toId; + } + + /** + * Get the limit. + * + * @return int|null + */ + public function getLimit(): ?int + { + return $this->limit; + } + + /** + * Get the offset. + * + * @return int|null + */ + public function getOffset(): ?int + { + return $this->offset; + } + + /** + * Get the order by clauses. + * + * @return array + */ + public function getOrderBy(): array + { + return $this->orderBy; + } + + /** + * Get the distinct on fields. + * + * @return array + */ + public function getDistinctOn(): array + { + return $this->distinctOn; + } + + /** + * Convert query to GraphQL where clause array. + * + * @return array + */ + public function toWhereClause(): array + { + $where = []; + + if ($this->id !== null) { + $where['id'] = ['_eq' => $this->id]; + } + + if ($this->fromId !== null) { + $where['from_id'] = ['_eq' => $this->fromId]; + } + + if ($this->toId !== null) { + $where['to_id'] = ['_eq' => $this->toId]; + } + + return $where; + } + + /** + * Convert order by to GraphQL format. + * + * @return array + */ + public function toOrderByClause(): array + { + return $this->orderBy; + } +} \ No newline at end of file diff --git a/php/tests/DoubletsClientFactoryTest.php b/php/tests/DoubletsClientFactoryTest.php new file mode 100644 index 00000000..229fc557 --- /dev/null +++ b/php/tests/DoubletsClientFactoryTest.php @@ -0,0 +1,75 @@ +assertInstanceOf(DoubletsClient::class, $client); + $this->assertEquals(DoubletsClientFactory::DEFAULT_LOCAL_ENDPOINT, $client->getEndpoint()); + } + + public function testCreateLocalWithCustomEndpoint(): void + { + $customEndpoint = 'http://custom:8080/graphql'; + $client = DoubletsClientFactory::createLocal($customEndpoint); + + $this->assertInstanceOf(DoubletsClient::class, $client); + $this->assertEquals($customEndpoint, $client->getEndpoint()); + } + + public function testCreateDemo(): void + { + $client = DoubletsClientFactory::createDemo(); + + $this->assertInstanceOf(DoubletsClient::class, $client); + $this->assertEquals(DoubletsClientFactory::DEFAULT_DEMO_ENDPOINT, $client->getEndpoint()); + } + + public function testCreateDemoWithCustomEndpoint(): void + { + $customEndpoint = 'http://demo-custom:8080/graphql'; + $client = DoubletsClientFactory::createDemo($customEndpoint); + + $this->assertInstanceOf(DoubletsClient::class, $client); + $this->assertEquals($customEndpoint, $client->getEndpoint()); + } + + public function testCreate(): void + { + $endpoint = 'http://example.com/graphql'; + $client = DoubletsClientFactory::create($endpoint); + + $this->assertInstanceOf(DoubletsClient::class, $client); + $this->assertEquals($endpoint, $client->getEndpoint()); + } + + public function testCreateWithToken(): void + { + $endpoint = 'http://example.com/graphql'; + $token = 'test-token-123'; + $client = DoubletsClientFactory::createWithToken($endpoint, $token); + + $this->assertInstanceOf(DoubletsClient::class, $client); + $this->assertEquals($endpoint, $client->getEndpoint()); + } + + public function testCreateWithHeaders(): void + { + $endpoint = 'http://example.com/graphql'; + $headers = ['X-Custom-Header' => 'custom-value']; + $client = DoubletsClientFactory::createWithHeaders($endpoint, $headers); + + $this->assertInstanceOf(DoubletsClient::class, $client); + $this->assertEquals($endpoint, $client->getEndpoint()); + } +} \ No newline at end of file diff --git a/php/tests/LinkTest.php b/php/tests/LinkTest.php new file mode 100644 index 00000000..60a76986 --- /dev/null +++ b/php/tests/LinkTest.php @@ -0,0 +1,63 @@ +assertEquals(1, $link->getId()); + $this->assertEquals(2, $link->getFromId()); + $this->assertEquals(3, $link->getToId()); + } + + public function testFromArray(): void + { + $data = [ + 'id' => 10, + 'from_id' => 20, + 'to_id' => 30 + ]; + + $link = Link::fromArray($data); + + $this->assertEquals(10, $link->getId()); + $this->assertEquals(20, $link->getFromId()); + $this->assertEquals(30, $link->getToId()); + } + + public function testToArray(): void + { + $link = new Link(1, 2, 3); + $expected = [ + 'id' => 1, + 'from_id' => 2, + 'to_id' => 3 + ]; + + $this->assertEquals($expected, $link->toArray()); + } + + public function testToJson(): void + { + $link = new Link(1, 2, 3); + $expected = '{"id":1,"from_id":2,"to_id":3}'; + + $this->assertEquals($expected, $link->toJson()); + } + + public function testToString(): void + { + $link = new Link(1, 2, 3); + $expected = 'Link(id: 1, from: 2, to: 3)'; + + $this->assertEquals($expected, (string)$link); + } +} \ No newline at end of file diff --git a/php/tests/LinksCollectionTest.php b/php/tests/LinksCollectionTest.php new file mode 100644 index 00000000..52a08f89 --- /dev/null +++ b/php/tests/LinksCollectionTest.php @@ -0,0 +1,154 @@ +link1 = new Link(1, 10, 20); + $this->link2 = new Link(2, 20, 30); + $this->link3 = new Link(3, 10, 30); + + $this->collection = new LinksCollection([ + $this->link1, + $this->link2, + $this->link3 + ]); + } + + public function testCount(): void + { + $this->assertEquals(3, $this->collection->count()); + $this->assertEquals(3, count($this->collection)); + } + + public function testGet(): void + { + $this->assertEquals($this->link1, $this->collection->get(0)); + $this->assertEquals($this->link2, $this->collection->get(1)); + $this->assertEquals($this->link3, $this->collection->get(2)); + $this->assertNull($this->collection->get(5)); + } + + public function testFirst(): void + { + $this->assertEquals($this->link1, $this->collection->first()); + + $emptyCollection = new LinksCollection(); + $this->assertNull($emptyCollection->first()); + } + + public function testLast(): void + { + $this->assertEquals($this->link3, $this->collection->last()); + + $emptyCollection = new LinksCollection(); + $this->assertNull($emptyCollection->last()); + } + + public function testFindById(): void + { + $this->assertEquals($this->link2, $this->collection->findById(2)); + $this->assertNull($this->collection->findById(999)); + } + + public function testFindByFromId(): void + { + $results = $this->collection->findByFromId(10); + + $this->assertEquals(2, $results->count()); + $this->assertEquals($this->link1, $results->get(0)); + $this->assertEquals($this->link3, $results->get(1)); + } + + public function testFindByToId(): void + { + $results = $this->collection->findByToId(30); + + $this->assertEquals(2, $results->count()); + $this->assertEquals($this->link2, $results->get(0)); + $this->assertEquals($this->link3, $results->get(1)); + } + + public function testFilter(): void + { + $filtered = $this->collection->filter(fn(Link $link) => $link->getFromId() === 10); + + $this->assertEquals(2, $filtered->count()); + $this->assertEquals($this->link1, $filtered->get(0)); + $this->assertEquals($this->link3, $filtered->get(1)); + } + + public function testMap(): void + { + $ids = $this->collection->map(fn(Link $link) => $link->getId()); + + $this->assertEquals([1, 2, 3], $ids); + } + + public function testIsEmpty(): void + { + $this->assertFalse($this->collection->isEmpty()); + + $emptyCollection = new LinksCollection(); + $this->assertTrue($emptyCollection->isEmpty()); + } + + public function testIteration(): void + { + $links = []; + foreach ($this->collection as $link) { + $links[] = $link; + } + + $this->assertEquals([$this->link1, $this->link2, $this->link3], $links); + } + + public function testFromArray(): void + { + $data = [ + ['id' => 1, 'from_id' => 10, 'to_id' => 20], + ['id' => 2, 'from_id' => 20, 'to_id' => 30] + ]; + + $collection = LinksCollection::fromArray($data); + + $this->assertEquals(2, $collection->count()); + $this->assertEquals(1, $collection->get(0)->getId()); + $this->assertEquals(2, $collection->get(1)->getId()); + } + + public function testToArray(): void + { + $expected = [ + ['id' => 1, 'from_id' => 10, 'to_id' => 20], + ['id' => 2, 'from_id' => 20, 'to_id' => 30], + ['id' => 3, 'from_id' => 10, 'to_id' => 30] + ]; + + $this->assertEquals($expected, $this->collection->toArray()); + } + + public function testJsonSerialization(): void + { + $expected = [ + ['id' => 1, 'from_id' => 10, 'to_id' => 20], + ['id' => 2, 'from_id' => 20, 'to_id' => 30], + ['id' => 3, 'from_id' => 10, 'to_id' => 30] + ]; + + $this->assertEquals($expected, $this->collection->jsonSerialize()); + } +} \ No newline at end of file diff --git a/php/tests/LinksQueryTest.php b/php/tests/LinksQueryTest.php new file mode 100644 index 00000000..c09e9f4c --- /dev/null +++ b/php/tests/LinksQueryTest.php @@ -0,0 +1,122 @@ +whereId(123); + + $this->assertEquals(123, $query->getId()); + } + + public function testWhereFromId(): void + { + $query = new LinksQuery(); + $query->whereFromId(456); + + $this->assertEquals(456, $query->getFromId()); + } + + public function testWhereToId(): void + { + $query = new LinksQuery(); + $query->whereToId(789); + + $this->assertEquals(789, $query->getToId()); + } + + public function testLimit(): void + { + $query = new LinksQuery(); + $query->limit(10); + + $this->assertEquals(10, $query->getLimit()); + } + + public function testOffset(): void + { + $query = new LinksQuery(); + $query->offset(5); + + $this->assertEquals(5, $query->getOffset()); + } + + public function testOrderBy(): void + { + $query = new LinksQuery(); + $query->orderBy('id', 'desc'); + + $expected = [['id' => 'desc']]; + $this->assertEquals($expected, $query->getOrderBy()); + } + + public function testDistinctOn(): void + { + $query = new LinksQuery(); + $query->distinctOn('from_id'); + + $expected = ['from_id']; + $this->assertEquals($expected, $query->getDistinctOn()); + } + + public function testFluentInterface(): void + { + $query = (new LinksQuery()) + ->whereFromId(10) + ->whereToId(20) + ->limit(5) + ->offset(2) + ->orderBy('id', 'asc'); + + $this->assertEquals(10, $query->getFromId()); + $this->assertEquals(20, $query->getToId()); + $this->assertEquals(5, $query->getLimit()); + $this->assertEquals(2, $query->getOffset()); + $this->assertEquals([['id' => 'asc']], $query->getOrderBy()); + } + + public function testToWhereClause(): void + { + $query = (new LinksQuery()) + ->whereId(1) + ->whereFromId(10) + ->whereToId(20); + + $expected = [ + 'id' => ['_eq' => 1], + 'from_id' => ['_eq' => 10], + 'to_id' => ['_eq' => 20] + ]; + + $this->assertEquals($expected, $query->toWhereClause()); + } + + public function testToWhereClauseEmpty(): void + { + $query = new LinksQuery(); + + $this->assertEquals([], $query->toWhereClause()); + } + + public function testToOrderByClause(): void + { + $query = (new LinksQuery()) + ->orderBy('id', 'asc') + ->orderBy('from_id', 'desc'); + + $expected = [ + ['id' => 'asc'], + ['from_id' => 'desc'] + ]; + + $this->assertEquals($expected, $query->toOrderByClause()); + } +} \ No newline at end of file