Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions src/Drivers/EloquentEntitySet.php
Original file line number Diff line number Diff line change
Expand Up @@ -435,6 +435,30 @@ protected function configureBuilder($builder)

$orderby = $this->generateOrderBy();

// Add LEFT JOINs for navigation properties referenced in $orderby
if (!empty($this->orderByNavigationJoins)) {
// Qualify SELECT columns to avoid ambiguity with joined tables
$builder->select($this->getTable().'.*');

foreach ($this->orderByNavigationJoins as $joinInfo) {
$navigationProperty = $joinInfo['navigationProperty'];
$binding = $joinInfo['binding'];
$targetSet = $binding->getTarget();

$constraints = $navigationProperty->getConstraints();
foreach ($constraints as $constraint) {
$localColumn = $this->getModel()->qualifyColumn(
$this->getPropertySourceName($constraint->getProperty())
);
$targetTable = $targetSet->getTable();
$targetColumn = $targetTable . '.' .
$targetSet->getPropertySourceName($constraint->getReferencedProperty());

$builder->leftJoin($targetTable, $localColumn, '=', $targetColumn);
}
}
}

if ($orderby->hasStatement()) {
$builder->orderByRaw($orderby->getStatement(), $orderby->getParameters());
}
Expand Down
43 changes: 41 additions & 2 deletions src/Drivers/SQL/SQLOrderBy.php
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,13 @@
*/
trait SQLOrderBy
{
/**
* Navigation properties referenced in $orderby that need LEFT JOINs.
* Populated by generateOrderBy(), consumed by configureBuilder().
* @var array
*/
protected array $orderByNavigationJoins = [];

/**
* Generate expression for orderby parameters
* @return SQLExpression
Expand All @@ -29,6 +36,7 @@ protected function generateOrderBy(): SQLExpression
}

$this->assertValidOrderBy();
$this->orderByNavigationJoins = [];

$orders = $orderby->getSortOrders();

Expand All @@ -42,9 +50,40 @@ protected function generateOrderBy(): SQLExpression
$order = array_shift($orders);
[$propertyName, $direction] = $order;

$property = $properties[$propertyName];
if (str_contains($propertyName, '/')) {
// Navigation property path (e.g., "Status/SortOrder")
$segments = explode('/', $propertyName);
[$navPropertyName, $targetPropertyName] = $segments;

$navigationProperty = $this->getType()->getNavigationProperties()->get($navPropertyName);
$binding = $this->getBindingByNavigationProperty($navigationProperty);
$targetSet = $binding->getTarget();
$targetProperty = $targetSet->getType()->getProperty($targetPropertyName);

// Store join info for configureBuilder()
$joinKey = $navPropertyName;
if (!isset($this->orderByNavigationJoins[$joinKey])) {
$this->orderByNavigationJoins[$joinKey] = [
'navigationProperty' => $navigationProperty,
'binding' => $binding,
];
}

// Generate fully qualified column: "target_table"."target_column"
$targetTable = $targetSet->getTable();
$targetColumn = $targetSet->getPropertySourceName($targetProperty);
$expression->pushStatement(
$this->quoteSingleIdentifier($targetTable) . '.' .
$this->quoteSingleIdentifier($targetColumn)
);
} else {
// Direct property (existing behavior)
$property = $properties[$propertyName];
$expression->pushStatement(
$this->quoteSingleIdentifier($this->getPropertySourceName($property))
);
}

$expression->pushStatement($this->quoteSingleIdentifier($this->getPropertySourceName($property)));
$expression->pushStatement($direction);

if ($this->getDriver() === SQLEntitySet::PostgreSQL) {
Expand Down
35 changes: 33 additions & 2 deletions src/Drivers/SQLEntitySet.php
Original file line number Diff line number Diff line change
Expand Up @@ -360,15 +360,46 @@ public function getResultExpression(): SQLExpression

$expression->pushStatement(sprintf("FROM %s", $this->quoteSingleIdentifier($this->getTable())));

$orderby = $this->generateOrderBy();

// Add LEFT JOINs for navigation properties referenced in $orderby
if (!empty($this->orderByNavigationJoins)) {
foreach ($this->orderByNavigationJoins as $joinInfo) {
$navigationProperty = $joinInfo['navigationProperty'];
$binding = $joinInfo['binding'];
$targetSet = $binding->getTarget();

$constraints = $navigationProperty->getConstraints();
foreach ($constraints as $constraint) {
$localColumn = sprintf(
'%s.%s',
$this->quoteSingleIdentifier($this->getTable()),
$this->quoteSingleIdentifier($this->getPropertySourceName($constraint->getProperty()))
);
$targetTable = $targetSet->getTable();
$targetColumnRef = sprintf(
'%s.%s',
$this->quoteSingleIdentifier($targetTable),
$this->quoteSingleIdentifier($targetSet->getPropertySourceName($constraint->getReferencedProperty()))
);

$expression->pushStatement(sprintf(
'LEFT JOIN %s ON %s = %s',
$this->quoteSingleIdentifier($targetTable),
$localColumn,
$targetColumnRef
));
}
}
}

$where = $this->generateWhere();

if ($where->hasStatement()) {
$expression->pushStatement('WHERE');
$expression->pushExpression($where);
}

$orderby = $this->generateOrderBy();

if ($orderby->hasStatement()) {
$expression->pushStatement('ORDER BY');
$expression->pushExpression($orderby);
Expand Down
64 changes: 62 additions & 2 deletions src/EntitySet.php
Original file line number Diff line number Diff line change
Expand Up @@ -1123,10 +1123,70 @@ protected function assertValidOrderBy(): void
return $sort[0];
}, $orderby->getSortOrders());

if ($diff = array_diff($sortProperties, $keys)) {
$invalidProperties = [];

foreach ($sortProperties as $propertyName) {
// Direct property — already valid
if (in_array($propertyName, $keys)) {
continue;
}

// Check for navigation property path (e.g., "Status/SortOrder")
if (str_contains($propertyName, '/')) {
$segments = explode('/', $propertyName);

if (count($segments) !== 2) {
$invalidProperties[] = $propertyName;
continue;
}

[$navPropertyName, $targetPropertyName] = $segments;

$navigationProperty = $this->getType()->getNavigationProperties()->get($navPropertyName);

if (!$navigationProperty) {
$invalidProperties[] = $propertyName;
continue;
}

if ($navigationProperty->isCollection()) {
throw new BadRequestException(
'invalid_orderby_collection_navigation',
sprintf(
'Cannot order by collection navigation property (%s)',
$navPropertyName
)
);
}

$binding = $this->getBindingByNavigationProperty($navigationProperty);
if (!$binding) {
$invalidProperties[] = $propertyName;
continue;
}

$targetType = $binding->getTarget()->getType();
$targetProperty = $targetType->getProperty($targetPropertyName);

if (!$targetProperty) {
$invalidProperties[] = $propertyName;
continue;
}

// Valid navigation property path
continue;
}

$invalidProperties[] = $propertyName;
}

if ($invalidProperties) {
throw new BadRequestException(
'invalid_sort_property',
sprintf('The orderby parameter specified properties (%s) that did not exist', join(',', $diff))
sprintf(
'The orderby parameter specified properties (%s) that did not exist',
join(',', $invalidProperties)
)
);
}
}
Expand Down
49 changes: 49 additions & 0 deletions tests/OrderBy/OrderBy.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

namespace Flat3\Lodata\Tests\OrderBy;

use Flat3\Lodata\Facades\Lodata;
use Flat3\Lodata\Tests\Helpers\Request;
use Flat3\Lodata\Tests\TestCase;

Expand Down Expand Up @@ -71,4 +72,52 @@ public function test_orderby_invalid_multiple()
->orderby('name asc age desc')
);
}

public function test_orderby_navigation_property()
{
if (!Lodata::getEntitySet($this->entitySet)?->getType()->getNavigationProperties()->get('flight')) {
$this->markTestSkipped('Driver does not configure flight navigation property');
}

$this->assertJsonResponseSnapshot(
(new Request)
->path($this->entitySetPath)
->orderby('flight/origin asc')
);
}

public function test_orderby_navigation_property_desc()
{
if (!Lodata::getEntitySet($this->entitySet)?->getType()->getNavigationProperties()->get('flight')) {
$this->markTestSkipped('Driver does not configure flight navigation property');
}

$this->assertJsonResponseSnapshot(
(new Request)
->path($this->entitySetPath)
->orderby('flight/origin desc')
);
}

public function test_orderby_navigation_property_invalid()
{
$this->assertBadRequest(
(new Request)
->path($this->entitySetPath)
->orderby('nonexistent/foo asc')
);
}

public function test_orderby_navigation_property_collection()
{
if (!Lodata::getEntitySet($this->flightEntitySet)) {
$this->markTestSkipped('Driver does not configure flight entity set');
}

$this->assertBadRequest(
(new Request)
->path($this->flightEntitySetPath)
->orderby('passengers/name asc')
);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
{
"error": {
"code": "invalid_sort_property",
"message": "The orderby parameter specified properties (nonexistent/foo) that did not exist",
"target": null,
"details": [],
"innererror": {}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
{
"@context": "http://localhost/odata/$metadata#Passengers",
"value": [
{
"id": 2,
"flight_id": null,
"name": "Beta",
"dob": "2001-02-02T05:05:05+00:00",
"age": 3,
"chips": false,
"dq": "2001-02-02",
"in_role": "P2DT5H5M5.2999999999884S",
"open_time": null,
"colour": null,
"sock_colours": null,
"emails": []
},
{
"id": 4,
"flight_id": null,
"name": "Delta",
"dob": null,
"age": null,
"chips": null,
"dq": null,
"in_role": "PT2M7S",
"open_time": null,
"colour": null,
"sock_colours": null,
"emails": []
},
{
"id": 5,
"flight_id": null,
"name": "Epsilon",
"dob": "2003-04-04T07:07:07+00:00",
"age": 2.4,
"chips": null,
"dq": "2003-04-04",
"in_role": "PT14M48.9S",
"open_time": "23:11:33.000000",
"colour": null,
"sock_colours": null,
"emails": []
},
{
"id": 1,
"flight_id": 1,
"name": "Alpha",
"dob": "2000-01-01T04:04:04+00:00",
"age": 4,
"chips": true,
"dq": "2000-01-01",
"in_role": "P1DT0S",
"open_time": "05:05:05.000000",
"colour": "Green",
"sock_colours": "Green,Blue",
"emails": [
"alpha@example.com",
"alpha@beta.com"
]
},
{
"id": 3,
"flight_id": 1,
"name": "Gamma",
"dob": "2002-03-03T06:06:06+00:00",
"age": 2,
"chips": true,
"dq": "2002-03-03",
"in_role": "P4DT32M41S",
"open_time": "07:07:07.000000",
"colour": "Blue",
"sock_colours": "Red,Green,Blue",
"emails": [
"gamma@example.com"
]
}
]
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
{
"error": {
"code": "invalid_orderby_collection_navigation",
"message": "Cannot order by collection navigation property (passengers)",
"target": null,
"details": [],
"innererror": {}
}
}
Loading