diff --git a/README.md b/README.md index 12cab82..146c057 100644 --- a/README.md +++ b/README.md @@ -231,6 +231,183 @@ This approach allows you to customize request creation without having to overrid For a complete working example, see [`examples/custom_headers_example.php`](examples/custom_headers_example.php). +### Nested Property Access + +The OData Client provides powerful support for accessing nested properties in OData entities, making it easy to work with complex data structures returned by modern OData services. + +#### Object-Style Access + +Access nested properties naturally using object notation: + +```php +from('People')->find('russellwhyte'); + +// Access nested properties directly +$city = $person->AddressInfo[0]->City; // Object-style access +$country = $person->AddressInfo[0]->CountryRegion; // Deep nesting supported + +// Complex nested structures work naturally +if ($person->Settings && $person->Settings->Preferences) { + $theme = $person->Settings->Preferences->Theme; +} +``` + +#### Dot Notation Access + +Use dot notation for safe navigation through nested properties: + +```php +// Safe access with dot notation - returns null if any part doesn't exist +$city = $person->getProperty('AddressInfo.0.City'); +$country = $person->getProperty('AddressInfo.0.CountryRegion'); +$theme = $person->getProperty('Settings.Preferences.Theme'); + +// Works with array indices and object properties +$firstFriendName = $person->getProperty('Friends.0.FirstName'); +$homeAddress = $person->getProperty('AddressInfo.0.Address'); +``` + +#### Property Existence Checking + +Check if nested properties exist before accessing them: + +```php +// Check existence using hasProperty() +if ($person->hasProperty('AddressInfo.0.City')) { + $city = $person->getProperty('AddressInfo.0.City'); +} + +// Also works with isset() for object-style access +if (isset($person->AddressInfo[0]->City)) { + $city = $person->AddressInfo[0]->City; +} + +// Check for deeply nested paths +if ($person->hasProperty('Settings.Preferences.AutoSave')) { + $autoSave = $person->getProperty('Settings.Preferences.AutoSave'); +} +``` + +#### Working with Collections + +Handle arrays and collections within nested structures: + +```php +// Get people with address information +$people = $client->select('UserName,FirstName,LastName,AddressInfo') + ->from('People') + ->get(); + +foreach ($people as $person) { + echo "Person: " . $person->FirstName . " " . $person->LastName . "\n"; + + // Access nested address info - remains as array for easy filtering + $addresses = $person->AddressInfo; + + // Filter addresses by type + $homeAddresses = array_filter($addresses, function($address) { + return isset($address['Type']) && $address['Type'] === 'Home'; + }); + + // Access properties within filtered results + foreach ($homeAddresses as $address) { + // Convert to Entity for object-style access + $addrEntity = new \SaintSystems\OData\Entity($address); + echo " Home Address: " . $addrEntity->Address . ", " . $addrEntity->City . "\n"; + } +} +``` + +#### Real-World ShareFile OData Example + +Working with ShareFile-style OData responses with Info objects and Children collections: + +```php +// Query for folders with nested Info and Children data +$folders = $client->select('Id,Name,CreatorNameShort,Info,Info/IsAHomeFolder,Children/Id,Children/Name') + ->from('Items') + ->where('HasChildren', true) + ->get(); + +foreach ($folders as $folder) { + echo "Folder: " . $folder->Name . "\n"; + echo "Creator: " . $folder->CreatorNameShort . "\n"; + + // Access nested Info properties + if ($folder->Info) { + echo "Is Home Folder: " . ($folder->Info->IsAHomeFolder ? 'Yes' : 'No') . "\n"; + + // Safe navigation for optional nested properties + if ($folder->hasProperty('Info.Settings.Theme')) { + echo "Theme: " . $folder->getProperty('Info.Settings.Theme') . "\n"; + } + } + + // Work with Children collection + if ($folder->Children) { + echo "Children:\n"; + + // Filter children by type + $subfolders = array_filter($folder->Children, function($child) { + return $child['FileSizeBytes'] == 0; // Folders have 0 file size + }); + + foreach ($subfolders as $subfolder) { + echo " - " . $subfolder['Name'] . " (ID: " . $subfolder['Id'] . ")\n"; + } + } + echo "\n"; +} +``` + +#### Integration with Query Building + +Nested property access works seamlessly with OData query operations: + +```php +// Select specific nested properties +$result = $client->select('Id,Name,Info/IsAHomeFolder,Children/Name,AddressInfo/City') + ->from('Items') + ->get(); + +// Use in where clauses (if supported by the OData service) +$homeItems = $client->from('Items') + ->where('Info/IsAHomeFolder', true) + ->get(); + +// Expand related data and access nested properties +$peopleWithTrips = $client->from('People') + ->expand('Trips') + ->get(); + +foreach ($peopleWithTrips as $person) { + foreach ($person->Trips as $trip) { + // Access nested trip properties + $tripEntity = new \SaintSystems\OData\Entity($trip); + echo $person->FirstName . " has trip: " . $tripEntity->Name . "\n"; + } +} +``` + +**Key Features:** +- **Multiple Access Patterns**: Object notation, dot notation, and array access all supported +- **Automatic Type Conversion**: Nested associative arrays become Entity objects for object-style access +- **Safe Navigation**: Non-existent properties return `null` instead of throwing errors +- **Performance Optimized**: Entity objects created lazily only when accessed +- **Backward Compatible**: All existing code continues to work unchanged +- **Collection Friendly**: Arrays remain as arrays for easy filtering and manipulation + +For comprehensive examples and advanced usage patterns, see [`examples/nested_properties_example.php`](examples/nested_properties_example.php). + ### Lambda Operators (any/all) The OData Client supports lambda operators `any` and `all` for filtering collections within entities. These operators allow you to filter based on conditions within related navigation properties. diff --git a/examples/nested_properties_example.php b/examples/nested_properties_example.php new file mode 100644 index 0000000..2c728dc --- /dev/null +++ b/examples/nested_properties_example.php @@ -0,0 +1,304 @@ +client = new ODataClient('https://services.odata.org/V4/TripPinService', null, $httpProvider); + } + + /** + * Demonstrate object-style nested property access + */ + public function objectStyleAccess() + { + echo "=== Object-Style Nested Property Access ===\n"; + + try { + // Get a person with address information + $person = $this->client->from('People')->find('russellwhyte'); + + echo "Person: {$person->FirstName} {$person->LastName}\n"; + + // Access nested AddressInfo properties using object notation + if ($person->AddressInfo && count($person->AddressInfo) > 0) { + // Convert first address to Entity for object-style access + $address = new Entity($person->AddressInfo[0]); + + echo "Primary Address:\n"; + echo " Address: {$address->Address}\n"; + echo " City: {$address->City}\n"; + echo " Region: {$address->CountryRegion}\n"; + } + + } catch (Exception $e) { + echo "Error: " . $e->getMessage() . "\n"; + } + + echo "\n"; + } + + /** + * Demonstrate dot notation access for safe navigation + */ + public function dotNotationAccess() + { + echo "=== Dot Notation Safe Navigation ===\n"; + + try { + $person = $this->client->from('People')->find('russellwhyte'); + + echo "Person: {$person->FirstName} {$person->LastName}\n"; + + // Safe navigation using dot notation + $address = $person->getProperty('AddressInfo.0.Address'); + $city = $person->getProperty('AddressInfo.0.City'); + $region = $person->getProperty('AddressInfo.0.CountryRegion'); + + echo "Using dot notation:\n"; + echo " Address: " . ($address ?: 'Not available') . "\n"; + echo " City: " . ($city ?: 'Not available') . "\n"; + echo " Region: " . ($region ?: 'Not available') . "\n"; + + // Try accessing a non-existent nested property + $nonExistent = $person->getProperty('NonExistent.Property.Path'); + echo " Non-existent property: " . ($nonExistent ?: 'null (safe)') . "\n"; + + } catch (Exception $e) { + echo "Error: " . $e->getMessage() . "\n"; + } + + echo "\n"; + } + + /** + * Demonstrate property existence checking + */ + public function propertyExistenceChecking() + { + echo "=== Property Existence Checking ===\n"; + + try { + $person = $this->client->from('People')->find('russellwhyte'); + + // Check if nested properties exist + $hasAddress = $person->hasProperty('AddressInfo.0.Address'); + $hasCity = $person->hasProperty('AddressInfo.0.City'); + $hasNonExistent = $person->hasProperty('NonExistent.Property'); + + echo "Property existence checks:\n"; + echo " Has AddressInfo.0.Address: " . ($hasAddress ? 'Yes' : 'No') . "\n"; + echo " Has AddressInfo.0.City: " . ($hasCity ? 'Yes' : 'No') . "\n"; + echo " Has NonExistent.Property: " . ($hasNonExistent ? 'Yes' : 'No') . "\n"; + + // Using isset() with object-style access + if (isset($person->AddressInfo) && count($person->AddressInfo) > 0) { + $address = new Entity($person->AddressInfo[0]); + echo " Address entity created successfully\n"; + + if (isset($address->City)) { + echo " City property exists on address entity\n"; + } + } + + } catch (Exception $e) { + echo "Error: " . $e->getMessage() . "\n"; + } + + echo "\n"; + } + + /** + * Demonstrate working with collections in nested properties + */ + public function workingWithCollections() + { + echo "=== Working with Collections ===\n"; + + try { + // Get multiple people with their address information + $people = $this->client->select('UserName,FirstName,LastName,AddressInfo') + ->from('People') + ->top(3) + ->get(); + + foreach ($people as $person) { + echo "Person: {$person->FirstName} {$person->LastName}\n"; + + // AddressInfo remains as array for easy filtering and manipulation + if ($person->AddressInfo && is_array($person->AddressInfo)) { + echo " Addresses (" . count($person->AddressInfo) . "):\n"; + + foreach ($person->AddressInfo as $index => $addressData) { + // Convert each address to Entity for object-style access + $address = new Entity($addressData); + + echo " [{$index}] {$address->Address}, {$address->City}, {$address->CountryRegion}\n"; + } + } else { + echo " No address information available\n"; + } + echo "\n"; + } + + } catch (Exception $e) { + echo "Error: " . $e->getMessage() . "\n"; + } + } + + /** + * Demonstrate expanded navigation properties with nested access + */ + public function expandedNavigationProperties() + { + echo "=== Expanded Navigation Properties ===\n"; + + try { + // Get a person with their trips expanded + $people = $this->client->from('People') + ->expand('Trips') + ->top(2) + ->get(); + + foreach ($people as $person) { + echo "Person: {$person->FirstName} {$person->LastName}\n"; + + if ($person->Trips && is_array($person->Trips)) { + echo " Trips (" . count($person->Trips) . "):\n"; + + foreach ($person->Trips as $tripData) { + // Convert trip to Entity for object-style access + $trip = new Entity($tripData); + + echo " - {$trip->Name}"; + + // Safe access to nested properties + if ($trip->hasProperty('Budget')) { + echo " (Budget: $" . $trip->getProperty('Budget') . ")"; + } + + echo "\n"; + } + } else { + echo " No trips available\n"; + } + echo "\n"; + } + + } catch (Exception $e) { + echo "Error: " . $e->getMessage() . "\n"; + } + } + + /** + * Demonstrate complex nested data manipulation + */ + public function complexDataManipulation() + { + echo "=== Complex Data Manipulation ===\n"; + + // Create a mock complex entity (simulating ShareFile-style data) + $mockData = [ + 'Id' => 'folder-123', + 'Name' => 'Project Documents', + 'Info' => [ + 'IsAHomeFolder' => false, + 'Description' => 'Project related documents', + 'Settings' => [ + 'AllowPublicSharing' => true, + 'MaxFileSize' => 10485760, // 10MB + 'Theme' => 'professional' + ] + ], + 'Children' => [ + ['Id' => 'file-1', 'Name' => 'Proposal.pdf', 'FileSizeBytes' => 2048000, 'Type' => 'File'], + ['Id' => 'folder-2', 'Name' => 'Images', 'FileSizeBytes' => 0, 'Type' => 'Folder'], + ['Id' => 'file-3', 'Name' => 'Budget.xlsx', 'FileSizeBytes' => 512000, 'Type' => 'File'] + ], + 'Creator' => [ + 'Name' => 'John Doe', + 'Email' => 'john.doe@example.com' + ] + ]; + + $entity = new Entity($mockData); + + echo "Folder: {$entity->Name}\n"; + echo "Creator: {$entity->Creator['Name']} ({$entity->Creator['Email']})\n"; + + // Object-style access to nested Info + echo "Is Home Folder: " . ($entity->Info->IsAHomeFolder ? 'Yes' : 'No') . "\n"; + echo "Description: {$entity->Info->Description}\n"; + + // Deep nested access + echo "Theme: {$entity->Info->Settings->Theme}\n"; + echo "Max File Size: " . number_format($entity->Info->Settings->MaxFileSize / 1024 / 1024, 1) . " MB\n"; + + // Working with Children collection + echo "\nChildren:\n"; + + // Filter for folders (FileSizeBytes = 0) + $folders = array_filter($entity->Children, function($child) { + return $child['FileSizeBytes'] == 0; + }); + + // Filter for files + $files = array_filter($entity->Children, function($child) { + return $child['FileSizeBytes'] > 0; + }); + + echo " Folders (" . count($folders) . "):\n"; + foreach ($folders as $folder) { + echo " - {$folder['Name']}\n"; + } + + echo " Files (" . count($files) . "):\n"; + foreach ($files as $file) { + $sizeKB = number_format($file['FileSizeBytes'] / 1024, 1); + echo " - {$file['Name']} ({$sizeKB} KB)\n"; + } + + echo "\n"; + } + + /** + * Run all examples + */ + public function runAll() + { + echo "OData Client - Nested Property Access Examples\n"; + echo str_repeat("=", 50) . "\n\n"; + + $this->objectStyleAccess(); + $this->dotNotationAccess(); + $this->propertyExistenceChecking(); + $this->workingWithCollections(); + $this->expandedNavigationProperties(); + $this->complexDataManipulation(); + + echo "All examples completed!\n"; + } +} + +// Run the examples +if (basename(__FILE__) == basename($_SERVER['SCRIPT_NAME'])) { + $examples = new NestedPropertiesExample(); + $examples->runAll(); +} \ No newline at end of file diff --git a/src/Entity.php b/src/Entity.php index 537e2ea..3f77e5e 100644 --- a/src/Entity.php +++ b/src/Entity.php @@ -177,6 +177,13 @@ class Entity implements ArrayAccess, Arrayable */ protected static $mutatorCache = []; + /** + * Indicates whether property names should be snake cased when using mutators. + * + * @var bool + */ + protected static $snakeProperties = false; + /** * @var bool */ @@ -563,7 +570,7 @@ public function getMutatedProperties() public static function cacheMutatedProperties($class) { static::$mutatorCache[$class] = collect(static::getMutatorMethods($class))->map(function ($match) { - return lcfirst(static::$snakePropreties ? Str::snake($match) : $match); + return lcfirst(static::$snakeProperties ? Str::snake($match) : $match); })->all(); } @@ -1508,6 +1515,11 @@ public function getProperty($key) $key = $this->primaryKey; } + // Handle nested property access with dot notation (e.g., "Info.IsAHomeFolder") + if (strpos($key, '.') !== false) { + return $this->getNestedPropertyByPath($key); + } + // If the property exists in the properties array or has a "get" mutator we will // get the property's value. Otherwise, we will proceed as if the developers // are asking for a relationship's value. This covers both types of values. @@ -1529,6 +1541,28 @@ public function getProperty($key) return null; } + /** + * Get a nested property using dot notation. + * + * @param string $key + * @return mixed + */ + protected function getNestedPropertyByPath($key) + { + $keys = explode('.', $key); + $value = $this->properties; + + foreach ($keys as $segment) { + if (is_array($value) && array_key_exists($segment, $value)) { + $value = $value[$segment]; + } else { + return null; + } + } + + return $value; + } + /** * Get a plain property (not a relationship). * @@ -1563,9 +1597,51 @@ public function getPropertyValue($key) return $this->asDateTime($value); } + // Convert nested arrays to Entity objects for object-style property access + if (is_array($value) && $this->isAssociativeArray($value)) { + return new static($value); + } + return $value; } + /** + * Check if an array is associative (has string keys or is a complex object). + * + * @param array $array + * @return bool + */ + protected function isAssociativeArray(array $array) + { + if (empty($array)) { + return false; + } + + // If any key is a string, it's associative + foreach (array_keys($array) as $key) { + if (is_string($key)) { + return true; + } + } + + // If all keys are numeric but not sequential (0, 1, 2...), it might be associative + $keys = array_keys($array); + return $keys !== range(0, count($array) - 1); + } + + /** + * Check if a property exists on the entity, including nested properties with dot notation. + * This provides a more intuitive alternative to PHP's built-in property_exists() + * which doesn't work with dynamic properties. + * + * @param string $key + * @return bool + */ + public function hasProperty($key) + { + return !is_null($this->getProperty($key)); + } + /** * Get a property from the $properties array. * diff --git a/tests/NestedPropertiesTest.php b/tests/NestedPropertiesTest.php new file mode 100644 index 0000000..ff101aa --- /dev/null +++ b/tests/NestedPropertiesTest.php @@ -0,0 +1,173 @@ +testData = [ + 'Id' => '123', + 'Name' => 'Test Folder', + 'Info' => [ + 'IsAHomeFolder' => true, + 'Description' => 'Test description', + 'Settings' => [ + 'AllowPublicSharing' => false, + 'MaxFileSize' => 1024 + ] + ], + 'Children' => [ + ['Id' => '456', 'Name' => 'Child 1', 'Type' => 'Folder'], + ['Id' => '789', 'Name' => 'Child 2', 'Type' => 'File'] + ], + 'CreationDate' => '2023-01-01T10:00:00Z' + ]; + } + + public function testBasicPropertyAccess() + { + $entity = new Entity($this->testData); + + $this->assertEquals('123', $entity->Id); + $this->assertEquals('Test Folder', $entity->Name); + $this->assertEquals('2023-01-01T10:00:00Z', $entity->CreationDate); + } + + public function testNestedObjectPropertyAccess() + { + $entity = new Entity($this->testData); + + // Accessing nested object should return an Entity instance + $info = $entity->Info; + $this->assertInstanceOf(Entity::class, $info); + + // Test nested property access + $this->assertTrue($entity->Info->IsAHomeFolder); + $this->assertEquals('Test description', $entity->Info->Description); + + // Test deeply nested properties + $this->assertInstanceOf(Entity::class, $entity->Info->Settings); + $this->assertFalse($entity->Info->Settings->AllowPublicSharing); + $this->assertEquals(1024, $entity->Info->Settings->MaxFileSize); + } + + public function testDotNotationPropertyAccess() + { + $entity = new Entity($this->testData); + + // Test direct dot notation access + $this->assertTrue($entity->getProperty('Info.IsAHomeFolder')); + $this->assertEquals('Test description', $entity->getProperty('Info.Description')); + + // Test deeply nested dot notation access + $this->assertFalse($entity->getProperty('Info.Settings.AllowPublicSharing')); + $this->assertEquals(1024, $entity->getProperty('Info.Settings.MaxFileSize')); + + // Test non-existent properties + $this->assertNull($entity->getProperty('Info.NonExistent')); + $this->assertNull($entity->getProperty('NonExistent.Property')); + } + + public function testArrayPropertyAccess() + { + $entity = new Entity($this->testData); + + // Children should remain as an array (not converted to Entity) + $children = $entity->Children; + $this->assertIsArray($children); + $this->assertCount(2, $children); + + // Test accessing array elements + $this->assertEquals('456', $children[0]['Id']); + $this->assertEquals('Child 1', $children[0]['Name']); + $this->assertEquals('Folder', $children[0]['Type']); + } + + public function testArrayAccessCompatibility() + { + $entity = new Entity($this->testData); + + // Array access should still work as before + $this->assertEquals('123', $entity['Id']); + $this->assertEquals('Test Folder', $entity['Name']); + + // Nested array access should work + $this->assertTrue($entity['Info']['IsAHomeFolder']); + $this->assertEquals('Test description', $entity['Info']['Description']); + $this->assertFalse($entity['Info']['Settings']['AllowPublicSharing']); + + // Array of arrays should work + $this->assertEquals('456', $entity['Children'][0]['Id']); + $this->assertEquals('Child 1', $entity['Children'][0]['Name']); + } + + public function testIssetFunctionality() + { + $entity = new Entity($this->testData); + + // Basic isset checks + $this->assertTrue(isset($entity->Id)); + $this->assertTrue(isset($entity->Name)); + $this->assertTrue(isset($entity->Info)); + + // Nested isset checks + $this->assertTrue(isset($entity->Info->IsAHomeFolder)); + $this->assertTrue(isset($entity->Info->Description)); + $this->assertTrue(isset($entity->Info->Settings)); + $this->assertTrue(isset($entity->Info->Settings->AllowPublicSharing)); + + // Non-existent properties + $this->assertFalse(isset($entity->NonExistent)); + $this->assertFalse(isset($entity->Info->NonExistent)); + } + + public function testBackwardCompatibility() + { + // Test with simple, flat data structure + $simpleData = [ + 'name' => 'Simple Entity', + 'value' => 42, + 'flag' => true + ]; + + $entity = new Entity($simpleData); + + $this->assertEquals('Simple Entity', $entity->name); + $this->assertEquals(42, $entity->value); + $this->assertTrue($entity->flag); + + // Array access should work + $this->assertEquals('Simple Entity', $entity['name']); + $this->assertEquals(42, $entity['value']); + $this->assertTrue($entity['flag']); + } + + public function testEmptyAndNullValues() + { + $data = [ + 'name' => 'Test', + 'emptyArray' => [], + 'nullValue' => null, + 'emptyObject' => [], + 'info' => [ + 'value' => 'test', + 'nullSub' => null + ] + ]; + + $entity = new Entity($data); + + $this->assertEquals('Test', $entity->name); + $this->assertIsArray($entity->emptyArray); + $this->assertNull($entity->nullValue); + $this->assertNull($entity->getProperty('info.nullSub')); + $this->assertEquals('test', $entity->getProperty('info.value')); + } +} \ No newline at end of file diff --git a/tests/ODataClientTest.php b/tests/ODataClientTest.php index e54e4c9..3c64c46 100644 --- a/tests/ODataClientTest.php +++ b/tests/ODataClientTest.php @@ -435,4 +435,216 @@ public function testODataClientPutMethodExists() $odataClient = $this->createODataClient(); $this->assertTrue(method_exists($odataClient, 'put')); } + + // Nested Properties Integration Tests + + public function testNestedPropertiesWithRealTripPinData() + { + $odataClient = $this->createODataClient(); + + // Get a specific person that we know exists in TripPin service + $person = $odataClient->from('People')->find('russellwhyte'); + + $this->assertNotNull($person); + + // Test basic property access + $this->assertEquals('russellwhyte', $person->UserName); + $this->assertEquals('Russell', $person->FirstName); + $this->assertEquals('Whyte', $person->LastName); + + // Test nested AddressInfo property access (complex type collection) + if (property_exists($person, 'AddressInfo') && !empty($person->AddressInfo)) { + // AddressInfo should be an array of address objects + $this->assertIsArray($person->AddressInfo); + $this->assertGreaterThan(0, count($person->AddressInfo)); + + // Test accessing the first address as an array (backward compatibility) + $firstAddress = $person->AddressInfo[0]; + $this->assertIsArray($firstAddress); + + if (isset($firstAddress['Address'])) { + $this->assertIsString($firstAddress['Address']); + } + + if (isset($firstAddress['City'])) { + $this->assertIsArray($firstAddress['City']); + if (isset($firstAddress['City']['Name'])) { + $this->assertIsString($firstAddress['City']['Name']); + } + } + } + } + + public function testNestedPropertyAccessWithDotNotation() + { + $odataClient = $this->createODataClient(); + + $person = $odataClient->from('People')->find('russellwhyte'); + $this->assertNotNull($person); + + // Test hasProperty method with simple properties + $this->assertTrue($person->hasProperty('UserName')); + $this->assertTrue($person->hasProperty('FirstName')); + $this->assertFalse($person->hasProperty('NonExistentProperty')); + + // Test hasProperty with nested properties if they exist + if (property_exists($person, 'AddressInfo') && !empty($person->AddressInfo)) { + $this->assertTrue($person->hasProperty('AddressInfo')); + + // Check if we can access nested properties using dot notation + $addressInfo = $person->getProperty('AddressInfo'); + $this->assertNotNull($addressInfo); + $this->assertIsArray($addressInfo); + } + + // Test accessing non-existent nested properties safely + $this->assertNull($person->getProperty('NonExistent.Property')); + $this->assertNull($person->getProperty('AddressInfo.NonExistent')); + } + + public function testNestedPropertiesWithExpandedData() + { + $odataClient = $this->createODataClient(); + + // Test with expanded navigation properties + $person = $odataClient->from('People') + ->expand('Friends') + ->find('russellwhyte'); + + $this->assertNotNull($person); + + // Test basic properties still work + $this->assertEquals('russellwhyte', $person->UserName); + + // Test Friends navigation property if it exists + if (property_exists($person, 'Friends')) { + $friends = $person->Friends; + + if (!empty($friends)) { + $this->assertIsArray($friends); + + // Test accessing friend properties + $firstFriend = $friends[0]; + $this->assertIsArray($firstFriend); + + if (isset($firstFriend['UserName'])) { + $this->assertIsString($firstFriend['UserName']); + } + + if (isset($firstFriend['FirstName'])) { + $this->assertIsString($firstFriend['FirstName']); + } + } + } + } + + public function testNestedPropertiesObjectStyleAccess() + { + $odataClient = $this->createODataClient(); + + $person = $odataClient->from('People')->find('russellwhyte'); + $this->assertNotNull($person); + + // Test that complex properties are converted to Entity objects for object-style access + if (property_exists($person, 'AddressInfo') && !empty($person->AddressInfo)) { + $addressInfo = $person->AddressInfo; + $this->assertIsArray($addressInfo); + + // For complex nested objects, test if they can be accessed as properties + $firstAddress = $addressInfo[0]; + if (is_array($firstAddress) && !empty($firstAddress)) { + // Create an Entity from the address data to test object-style access + $addressEntity = new Entity($firstAddress); + + // Test isset functionality on nested objects + if (isset($firstAddress['Address'])) { + $this->assertTrue(isset($addressEntity->Address)); + } + + if (isset($firstAddress['City'])) { + $this->assertTrue(isset($addressEntity->City)); + + // Test deeper nesting if City is an object + if (is_array($firstAddress['City'])) { + $cityEntity = $addressEntity->City; + $this->assertInstanceOf(Entity::class, $cityEntity); + + if (isset($firstAddress['City']['Name'])) { + $this->assertTrue(isset($cityEntity->Name)); + $this->assertIsString($cityEntity->Name); + } + } + } + } + } + } + + public function testNestedPropertiesBackwardCompatibility() + { + $odataClient = $this->createODataClient(); + + $person = $odataClient->from('People')->find('russellwhyte'); + $this->assertNotNull($person); + + // Test that array access still works (backward compatibility) + $this->assertEquals('russellwhyte', $person['UserName']); + $this->assertEquals('Russell', $person['FirstName']); + $this->assertEquals('Whyte', $person['LastName']); + + // Test nested array access if AddressInfo exists + if (property_exists($person, 'AddressInfo') && !empty($person->AddressInfo)) { + $this->assertIsArray($person['AddressInfo']); + $this->assertGreaterThan(0, count($person['AddressInfo'])); + + $firstAddress = $person['AddressInfo'][0]; + $this->assertIsArray($firstAddress); + + // Test nested array access + if (isset($firstAddress['City']) && is_array($firstAddress['City'])) { + $this->assertIsArray($person['AddressInfo'][0]['City']); + + if (isset($firstAddress['City']['Name'])) { + $this->assertIsString($person['AddressInfo'][0]['City']['Name']); + } + } + } + } + + public function testNestedPropertiesWithPeopleCollection() + { + $odataClient = $this->createODataClient(); + + // Get multiple people and test nested property access on collection + $people = $odataClient->from('People')->take(3)->get(); + + $this->assertGreaterThan(0, $people->count()); + + foreach ($people as $person) { + $this->assertInstanceOf(Entity::class, $person); + + // Test basic properties exist + $this->assertTrue($person->hasProperty('UserName')); + $this->assertTrue($person->hasProperty('FirstName')); + $this->assertTrue($person->hasProperty('LastName')); + + // Test that properties can be accessed both ways + $this->assertEquals($person->UserName, $person['UserName']); + $this->assertEquals($person->FirstName, $person['FirstName']); + $this->assertEquals($person->LastName, $person['LastName']); + + // Test nested properties if they exist + if ($person->hasProperty('AddressInfo')) { + $addressInfo = $person->getProperty('AddressInfo'); + if (!empty($addressInfo)) { + $this->assertIsArray($addressInfo); + + // Test accessing first address + if (count($addressInfo) > 0) { + $firstAddress = $addressInfo[0]; + $this->assertIsArray($firstAddress); + } + } + } + } + } }