Repository: balena-io-modules/odata-to-abstract-sql
Pull request #160: Fix OrderBy to use LEFT JOIN instead of INNER JOIN
Author: login: JSReds
Created: 2024-10-08T14:02:31Z | Merged: 2025-03-28T05:46:24Z

Change-type: minor

Currently, the ORDER BY operation applies a FROM clause when dealing with nested resources, effectively performing an implicit JOIN. This approach inadvertently filters out any results that contain missing values, as they do not meet the join criteria. As a result, valid entries with null values in the ordered field are excluded from the final output.

To address this issue, this PR introduces a LEFT JOIN method instead. By using a LEFT JOIN, we ensure that all results are preserved, including those with null values, allowing for a more complete and accurate dataset when ordering is applied.

https://balena.fibery.io/Work/Project/Fix-removal-of-rows-with-null-values-when-sorting-by-an-associated-resource-984


Example:

```
await api.resin.get({
  resource: 'device',
  options: {
	  $select: ['id'],
	  $expand: {
		  is_running__release: {
			  $select: ['id', 'raw_version'],
		  },
	  },
	  $orderby: [
		  'is_running__release/semver_major asc',
		  'is_running__release/semver_minor asc',
		  'is_running__release/semver_patch asc',
		  'is_running__release/semver_prerelease asc',
		  'is_running__release/created_at asc',
		  'is_running__release/semver_build asc',
		  'is_running__release/revision asc',
		  'is_running__release/variant asc',
		  'belongs_to__application/app_name asc',
		  'id asc',
	  ],
  },
 });
```

generated query:

[LEFT_JOIN_EXAMPLE.txt](https://github.com/user-attachments/files/18825959/LEFT_JOIN_EXAMPLE.txt)



Diff:
diff --git a/package.json b/package.json
index 28710633..917b12be 100644
--- a/package.json
+++ b/package.json
@@ -17,7 +17,7 @@
   "license": "BSD",
   "dependencies": {
     "@balena/abstract-sql-compiler": "^10.2.0",
-    "@balena/odata-parser": "^4.1.0",
+    "@balena/odata-parser": "^4.2.1",
     "@types/lodash": "^4.17.10",
     "@types/memoizee": "^0.4.11",
     "@types/string-hash": "^1.1.3",
diff --git a/src/odata-to-abstract-sql.ts b/src/odata-to-abstract-sql.ts
index 82a8db36..88d582f3 100644
--- a/src/odata-to-abstract-sql.ts
+++ b/src/odata-to-abstract-sql.ts
@@ -73,6 +73,7 @@ import type {
 	UnknownTypeNodes,
 	FromTypeNode,
 	EqualsAnyNode,
+	JoinTypeNodes,
 } from '@balena/abstract-sql-compiler';
 import type {
 	ODataBinds,
@@ -84,6 +85,9 @@ import type {
 	OrderByPropertyPath,
 	FilterOption,
 	BindReference,
+	GenericPropertyPath,
+	PropertyPath,
+	MethodCall,
 } from '@balena/odata-parser';
 export type { ODataBinds, ODataQuery, SupportedMethod };
 
@@ -220,6 +224,7 @@ class Query {
 	> = [];
 	public from: Array<FromNode[1]> = [];
 	public where: Array<WhereNode[1]> = [];
+	public joins: JoinTypeNodes[] = [];
 	public extras: Array<
 		FieldsNode | ValuesNode | OrderByNode | LimitNode | OffsetNode
 	> = [];
@@ -228,6 +233,7 @@ class Query {
 		this.select = this.select.concat(otherQuery.select);
 		this.from = this.from.concat(otherQuery.from);
 		this.where = this.where.concat(otherQuery.where);
+		this.joins = this.joins.concat(otherQuery.joins);
 		this.extras = this.extras.concat(otherQuery.extras);
 	}
 	fromResource(
@@ -250,6 +256,27 @@ class Query {
 		);
 		this.from.push(tableRef);
 	}
+	joinResource(
+		odataToAbstractSql: OData2AbstractSQL,
+		resource: AliasedResource,
+		condition: BooleanTypeNodes,
+		args: {
+			extraBindVars: ODataBinds;
+			bindVarsLength: number;
+		} = odataToAbstractSql,
+		bypassDefinition?: boolean,
+	): void {
+		const tableRef = odataToAbstractSql.getTableReference(
+			resource,
+			args.extraBindVars,
+			args.bindVarsLength,
+			bypassDefinition,
+			resource.tableAlias,
+			undefined,
+		);
+		const joinNode: JoinTypeNodes = ['LeftJoin', tableRef, ['On', condition]];
+		this.joins.push(joinNode);
+	}
 	addNestedFieldSelect(fieldName: string, fieldNameAlias: string): void {
 		if (this.from.length !== 1) {
 			throw new Error(
@@ -273,6 +300,9 @@ class Query {
 		this.from.forEach((tableName) => {
 			compiled.push(['From', tableName] as AbstractSqlQuery);
 		});
+		this.joins.forEach((joinNode) => {
+			compiled.push(joinNode);
+		});
 		if (where.length > 0) {
 			if (where.length > 1) {
 				where = [['And', ...where]];
@@ -873,12 +903,12 @@ export class OData2AbstractSQL {
 		throw new SyntaxError(`Could not match bind reference`);
 	}
 	SelectFilter(filter: FilterOption, query: Query, resource: Resource) {
-		this.AddExtraFroms(query, resource, filter);
+		this.AddJoins(query, resource, filter);
 		const where = this.BooleanMatch(filter);
 		query.where.push(where);
 	}
 	OrderBy(orderby: OrderByOption, query: Query, resource: Resource) {
-		this.AddExtraFroms(query, resource, orderby.properties);
+		this.AddJoins(query, resource, orderby.properties);
 		query.extras.push([
 			'OrderBy',
 			...this.OrderByProperties(orderby.properties),
@@ -1027,7 +1057,7 @@ export class OData2AbstractSQL {
 			Parameters<OData2AbstractSQL['AliasSelectField']>
 		>;
 		if (path.options?.$select?.properties) {
-			this.AddExtraFroms(query, resource, path.options.$select.properties);
+			this.AddJoins(query, resource, path.options.$select.properties);
 			odataFieldNames = path.options.$select.properties.map((prop: any) => {
 				const field = this.Property(prop) as {
 					resource: Resource;
@@ -1298,7 +1328,7 @@ export class OData2AbstractSQL {
 			this.resourceAliases[lambda.identifier] = resource;
 
 			this.defaultResource = resource;
-			this.AddExtraFroms(query, resource, lambda.expression);
+			this.AddJoins(query, resource, lambda.expression);
 			const filter = this.BooleanMatch(lambda.expression);
 			if (lambda.method === 'any') {
 				query.where.push(filter);
@@ -1655,12 +1685,19 @@ export class OData2AbstractSQL {
 			],
 		};
 	}
-	AddExtraFroms(query: Query, parentResource: Resource, match: any) {
+	AddJoins(
+		query: Query,
+		parentResource: Resource,
+		// This can be any node that odata-parser returns
+		match:
+			| (GenericPropertyPath<PropertyPath> | MethodCall[1])
+			| Array<GenericPropertyPath<PropertyPath> | MethodCall[1]>,
+	) {
 		// TODO: try removing
 		try {
 			if (Array.isArray(match)) {
 				match.forEach((v) => {
-					this.AddExtraFroms(query, parentResource, v);
+					this.AddJoins(query, parentResource, v);
 				});
 			} else {
 				let nextProp = match;
@@ -1668,6 +1705,8 @@ export class OData2AbstractSQL {
 				while (
 					// tslint:disable-next-line:no-conditional-assignment
 					(prop = nextProp) &&
+					// Confirm that prop is indeed a GenericPropertyPath<PropertyPath>
+					'name' in prop &&
 					prop.name &&
 					prop.property?.name
 				) {
@@ -1676,21 +1715,44 @@ export class OData2AbstractSQL {
 					if (resourceAlias) {
 						parentResource = resourceAlias;
 					} else {
-						parentResource = this.AddNavigation(
+						parentResource = this.AddJoinNavigation(
 							query,
 							parentResource,
 							prop.name,
 						);
 					}
 				}
-				if (nextProp?.args) {
-					this.AddExtraFroms(query, parentResource, prop.args);
+				if (nextProp != null && 'args' in nextProp && nextProp.args != null) {
+					this.AddJoins(query, parentResource, nextProp.args);
 				}
 			}
 		} catch {
 			// ignore
 		}
 	}
+	AddJoinNavigation(
+		query: Query,
+		resource: Resource,
+		extraResource: string,
+	): AliasedResource {
+		const navigation = this.NavigateResources(resource, extraResource);
+		if (
+			!query.joins.some((join) => {
+				const from = join[1];
+				return (
+					(isTableNode(from) && from[1] === navigation.resource.tableAlias) ||
+					(isAliasNode(from) && from[2] === navigation.resource.tableAlias)
+				);
+			})
+		) {
+			query.joinResource(this, navigation.resource, navigation.where);
+			return navigation.resource;
+		} else {
+			throw new SyntaxError(
+				`Could not navigate resources '${resource.name}' and '${extraResource}'`,
+			);
+		}
+	}
 	AddNavigation(
 		query: Query,
 		resource: Resource,
diff --git a/test/chai-sql-types.d.ts b/test/chai-sql-types.d.ts
index 36c674f9..699b9f2b 100644
--- a/test/chai-sql-types.d.ts
+++ b/test/chai-sql-types.d.ts
@@ -13,6 +13,10 @@ declare namespace Chai {
 			table: string | string[],
 			...tables: string[] | string[][]
 		) => Assertion;
+		leftJoin: (
+			join: [string | string[], any[]],
+			...joins: Array<[string | string[], any[]]>
+		) => Assertion;
 		where: (clause?: any[]) => Assertion;
 		orderby: (...clause: any[]) => Assertion;
 		limit: (clause: any[]) => Assertion;
diff --git a/test/chai-sql.js b/test/chai-sql.js
index 043ec2bd..bae08278 100644
--- a/test/chai-sql.js
+++ b/test/chai-sql.js
@@ -48,6 +48,17 @@ chai.use(function ($chai, utils) {
 			);
 			return this;
 		};
+	const binaryClause = (bodyType) =>
+		function (...bodyClauses) {
+			const obj = utils.flag(this, 'object');
+			for (let i = 0; i < bodyClauses.length; i++) {
+				expect(obj).to.contain.something.that.deep.equals(
+					[bodyType, bodyClauses[i][0], bodyClauses[i][1]],
+					bodyType + ' - ' + i,
+				);
+			}
+			return this;
+		};
 
 	const select = (function () {
 		const bodySelect = bodyClause('Select');
@@ -77,6 +88,15 @@ chai.use(function ($chai, utils) {
 		});
 		return fromClause.apply(this, bodyClauses);
 	});
+	const leftJoinClause = binaryClause('LeftJoin');
+	utils.addMethod(assertionPrototype, 'leftJoin', function (...bodyClauses) {
+		bodyClauses = bodyClauses.map(function ([v, condition]) {
+			const resource =
+				typeof v === 'string' ? ['Table', v] : ['Alias', ['Table', v[0]], v[1]];
+			return [resource, ['On', condition]];
+		});
+		return leftJoinClause.apply(this, bodyClauses);
+	});
 	utils.addMethod(assertionPrototype, 'where', bodyClause('Where'));
 	utils.addMethod(assertionPrototype, 'orderby', function (...bodyClauses) {
 		const bodyType = 'OrderBy';
@@ -88,7 +108,6 @@ chai.use(function ($chai, utils) {
 		return this;
 	});
 	utils.addMethod(assertionPrototype, 'groupby', multiBodyClause('GroupBy'));
-	utils.addMethod(assertionPrototype, 'where', bodyClause('Where'));
 	utils.addMethod(assertionPrototype, 'limit', bodyClause('Limit'));
 	utils.addMethod(assertionPrototype, 'offset', bodyClause('Offset'));
 });
diff --git a/test/expand.js b/test/expand.js
index 1439f9e7..6092beb6 100644
--- a/test/expand.js
+++ b/test/expand.js
@@ -253,28 +253,30 @@ test('/pilot?$expand=licence($filter=is_of__pilot/id eq 1)', function (result) {
 		.find({ 0: 'SelectQuery' })
 		.tap((aggSelect) =>
 			aggSelect.splice(aggSelect.length - 1, 0, [
-				'From',
+				'LeftJoin',
 				['Alias', ['Table', 'pilot'], 'pilot.licence.is of-pilot'],
-			]),
-		)
-		.find({ 0: 'Where' })
-		.tap(function (aggWhere) {
-			const currentWhere = aggWhere.splice(1, Infinity);
-			return aggWhere.push(
 				[
-					'And',
+					'On',
 					[
 						'Equals',
 						['ReferencedField', 'pilot.licence', 'id'],
 						['ReferencedField', 'pilot.licence.is of-pilot', 'licence'],
 					],
-					[
-						'IsNotDistinctFrom',
-						['ReferencedField', 'pilot.licence.is of-pilot', 'id'],
-						['Bind', 0],
-					],
-				].concat(currentWhere),
-			);
+				],
+			]),
+		)
+		.find({ 0: 'Where' })
+		.tap(function (aggWhere) {
+			const currentWhere = aggWhere.splice(1, Infinity);
+			return aggWhere.push([
+				'And',
+				[
+					'IsNotDistinctFrom',
+					['ReferencedField', 'pilot.licence.is of-pilot', 'id'],
+					['Bind', 0],
+				],
+				...currentWhere,
+			]);
 		})
 		.value();
 	it('should select from pilot.*, licence.*', () => {
@@ -469,12 +471,16 @@ test('/pilot?$expand=licence&$orderby=licence/name asc', function (result) {
 				agg,
 				..._.reject(pilotFields, { 2: 'licence' }),
 			])
-			.from('pilot', ['licence', 'pilot.licence'])
-			.where([
-				'Equals',
-				['ReferencedField', 'pilot', 'licence'],
-				['ReferencedField', 'pilot.licence', 'id'],
+			.from('pilot')
+			.leftJoin([
+				['licence', 'pilot.licence'],
+				[
+					'Equals',
+					['ReferencedField', 'pilot', 'licence'],
+					['ReferencedField', 'pilot.licence', 'id'],
+				],
 			])
+			.where()
 			.orderby(['ASC', ['ReferencedField', 'pilot.licence', 'name']]);
 	});
 });
diff --git a/test/filterby.js b/test/filterby.js
index fc5d1d0b..4fd643db 100644
--- a/test/filterby.js
+++ b/test/filterby.js
@@ -230,48 +230,51 @@ test(`/pilot?$filter=startswith(p/name,'test1')`, (result) => {
 	});
 });
 
-const navigatedOperandTest = (lhs, op, rhs) =>
+const navigatedOperandTest = (lhs, op, rhs) => {
 	run(function () {
 		const { odata, abstractsql } = createExpression(lhs, op, rhs);
 		test('/pilot?$filter=' + odata, (result) => {
 			it('should select from pilot where "' + odata + '"', () => {
 				expect(result)
 					.to.be.a.query.that.selects(pilotFields)
-					.from('pilot', ['licence', 'pilot.licence'])
-					.where([
-						'And',
+					.from('pilot')
+					.leftJoin([
+						['licence', 'pilot.licence'],
 						[
 							'Equals',
 							['ReferencedField', 'pilot', 'licence'],
 							['ReferencedField', 'pilot.licence', 'id'],
 						],
-						abstractsql,
-					]);
+					])
+					.where(abstractsql);
 			});
 		});
 		test('/pilot/$count?$filter=' + odata, (result) => {
 			it('should count(*) from pilot where "' + odata + '"', () => {
 				expect(result)
 					.to.be.a.query.that.selects($count)
-					.from('pilot', ['licence', 'pilot.licence'])
-					.where([
-						'And',
+					.from('pilot')
+					.leftJoin([
+						['licence', 'pilot.licence'],
 						[
 							'Equals',
 							['ReferencedField', 'pilot', 'licence'],
 							['ReferencedField', 'pilot.licence', 'id'],
 						],
-						abstractsql,
-					]);
+					])
+					.where(abstractsql);
 			});
 		});
 	});
+};
 
 const methodTest = (...args) =>
 	run(() => operandTest(createMethodCall(...args)));
 
 const navigatedMethodTest = (...args) =>
-	run(() => navigatedOperandTest(createMethodCall(...args)));
+	run(() => {
+		navigatedOperandTest(createMethodCall(...args));
+	});
 
 operandTest(2, 'eq', 'name');
 operandTest(2, 'ne', 'name');
@@ -344,16 +347,16 @@ run(function () {
 		it('should select from pilot where "' + odata + '"', () => {
 			expect(result)
 				.to.be.a.query.that.selects(pilotFields)
-				.from('pilot', ['pilot-can fly-plane', 'pilot.pilot-can fly-plane'])
-				.where([
-					'And',
+				.from('pilot')
+				.leftJoin([
+					['pilot-can fly-plane', 'pilot.pilot-can fly-plane'],
 					[
 						'Equals',
 						['ReferencedField', 'pilot', 'id'],
 						['ReferencedField', 'pilot.pilot-can fly-plane', 'pilot'],
 					],
-					abstractsql,
-				]);
+				])
+				.where(abstractsql);
 		});
 	});
 });
@@ -422,18 +425,17 @@ run([['Number', 1]], function () {
 				.to.be.a.query.that.selects(
 					aliasFields('pilot', pilotCanFlyPlaneFields),
 				)
-				.from(
-					'pilot',
-					['pilot-can fly-plane', 'pilot.pilot-can fly-plane'],
+				.from('pilot', ['pilot-can fly-plane', 'pilot.pilot-can fly-plane'])
+				.leftJoin([
 					['plane', 'pilot.pilot-can fly-plane.plane'],
-				)
-				.where([
-					'And',
 					[
 						'Equals',
 						['ReferencedField', 'pilot.pilot-can fly-plane', 'can fly-plane'],
 						['ReferencedField', 'pilot.pilot-can fly-plane.plane', 'id'],
 					],
+				])
+				.where([
+					'And',
 					abstractsql,
 					[
 						'Equals',
@@ -457,20 +459,32 @@ run(function () {
 		10,
 	);
 
-	const filterWhere = [
-		'And',
+	const pilotPilotCanFlyPlaneLeftJoin = [
+		'LeftJoin',
+		['Alias', ['Table', 'pilot-can fly-plane'], 'pilot.pilot-can fly-plane'],
 		[
-			'Equals',
-			['ReferencedField', 'pilot', 'id'],
-			['ReferencedField', 'pilot.pilot-can fly-plane', 'pilot'],
+			'On',
+			[
+				'Equals',
+				['ReferencedField', 'pilot', 'id'],
+				['ReferencedField', 'pilot.pilot-can fly-plane', 'pilot'],
+			],
 		],
+	];
+
+	const pilotPilotCanFlyPlanePlaneLeftJoin = [
+		'LeftJoin',
+		['Alias', ['Table', 'plane'], 'pilot.pilot-can fly-plane.plane'],
 		[
-			'Equals',
-			['ReferencedField', 'pilot.pilot-can fly-plane', 'can fly-plane'],
-			['ReferencedField', 'pilot.pilot-can fly-plane.plane', 'id'],
+			'On',
+			[
+				'Equals',
+				['ReferencedField', 'pilot.pilot-can fly-plane', 'can fly-plane'],
+				['ReferencedField', 'pilot.pilot-can fly-plane.plane', 'id'],
+			],
 		],
-		abstractsql,
 	];
+
 	const insertTest = (result) => {
 		expect(result)
 			.to.be.a.query.that.inserts.fields('name')
@@ -519,22 +533,6 @@ run(function () {
 						[
 							'SelectQuery',
 							['Select', []],
-							[
-								'From',
-								[
-									'Alias',
-									['Table', 'pilot-can fly-plane'],
-									'pilot.pilot-can fly-plane',
-								],
-							],
-							[
-								'From',
-								[
-									'Alias',
-									['Table', 'plane'],
-									'pilot.pilot-can fly-plane.plane',
-								],
-							],
 							[
 								'From',
 								[
@@ -546,7 +544,9 @@ run(function () {
 									'pilot',
 								],
 							],
-							['Where', filterWhere],
+							pilotPilotCanFlyPlaneLeftJoin,
+							pilotPilotCanFlyPlanePlaneLeftJoin,
+							['Where', abstractsql],
 						],
 					],
 				],
@@ -560,19 +560,9 @@ run(function () {
 			'SelectQuery',
 			['Select', [['Alias', ['ReferencedField', 'pilot', 'id'], '$modifyid']]],
 			['From', ['Table', 'pilot']],
-			[
-				'From',
-				[
-					'Alias',
-					['Table', 'pilot-can fly-plane'],
-					'pilot.pilot-can fly-plane',
-				],
-			],
-			[
-				'From',
-				['Alias', ['Table', 'plane'], 'pilot.pilot-can fly-plane.plane'],
-			],
-			['Where', filterWhere],
+			pilotPilotCanFlyPlaneLeftJoin,
+			pilotPilotCanFlyPlanePlaneLeftJoin,
+			['Where', abstractsql],
 		],
 	];
 
@@ -580,21 +570,26 @@ run(function () {
 		it('should select from pilot where "' + odata + '"', () => {
 			expect(result)
 				.to.be.a.query.that.selects(pilotFields)
-				.from('pilot', ['pilot-can fly-plane', 'pilot.pilot-can fly-plane'])
-				.where([
-					'And',
+				.from('pilot')
+				.leftJoin(
 					[
-						'Equals',
-						['ReferencedField', 'pilot', 'id'],
-						['ReferencedField', 'pilot.pilot-can fly-plane', 'pilot'],
+						['pilot-can fly-plane', 'pilot.pilot-can fly-plane'],
+						[
+							'Equals',
+							['ReferencedField', 'pilot', 'id'],
+							['ReferencedField', 'pilot.pilot-can fly-plane', 'pilot'],
+						],
 					],
 					[
-						'Equals',
-						['ReferencedField', 'pilot.pilot-can fly-plane', 'can fly-plane'],
-						['ReferencedField', 'pilot.pilot-can fly-plane.plane', 'id'],
+						['plane', 'pilot.pilot-can fly-plane.plane'],
+						[
+							'Equals',
+							['ReferencedField', 'pilot.pilot-can fly-plane', 'can fly-plane'],
+							['ReferencedField', 'pilot.pilot-can fly-plane.plane', 'id'],
+						],
 					],
-					abstractsql,
-				]);
+				)
+				.where(abstractsql);
 		});
 	});
 
@@ -673,26 +668,26 @@ run(function () {
 						],
 						['From', ['Table', 'pilot']],
 						[
-							'From',
+							'LeftJoin',
 							[
 								'Alias',
 								['Table', 'pilot-can fly-plane'],
 								'pilot.pilot-can fly-plane',
 							],
-						],
-						[
-							'From',
-							['Alias', ['Table', 'plane'], 'pilot.pilot-can fly-plane.plane'],
-						],
-						[
-							'Where',
 							[
-								'And',
+								'On',
 								[
 									'Equals',
 									['ReferencedField', 'pilot', 'id'],
 									['ReferencedField', 'pilot.pilot-can fly-plane', 'pilot'],
 								],
+							],
+						],
+						[
+							'LeftJoin',
+							['Alias', ['Table', 'plane'], 'pilot.pilot-can fly-plane.plane'],
+							[
+								'On',
 								[
 									'Equals',
 									[
@@ -702,9 +697,9 @@ run(function () {
 									],
 									['ReferencedField', 'pilot.pilot-can fly-plane.plane', 'id'],
 								],
-								abstractsql,
 							],
 						],
+						['Where', abstractsql],
 					],
 				]);
 		});
@@ -928,7 +923,6 @@ run(function () {
 							[
 								'SelectQuery',
 								['Select', []],
-								['From', ['Alias', ['Table', 'licence'], 'pilot.licence']],
 								[
 									'From',
 									[
@@ -941,17 +935,18 @@ run(function () {
 									],
 								],
 								[
-									'Where',
+									'LeftJoin',
+									['Alias', ['Table', 'licence'], 'pilot.licence'],
 									[
-										'And',
+										'On',
 										[
 											'Equals',
 											['ReferencedField', 'pilot', 'licence'],
 											['ReferencedField', 'pilot.licence', 'id'],
 										],
-										abstractsql,
 									],
 								],
+								['Where', abstractsql],
 							],
 						],
 					],
@@ -1018,7 +1013,6 @@ run(function () {
 							[
 								'SelectQuery',
 								['Select', []],
-								['From', ['Alias', ['Table', 'licence'], 'pilot.licence']],
 								[
 									'From',
 									[
@@ -1031,17 +1025,18 @@ run(function () {
 									],
 								],
 								[
-									'Where',
+									'LeftJoin',
+									['Alias', ['Table', 'licence'], 'pilot.licence'],
 									[
-										'And',
+										'On',
 										[
 											'Equals',
 											['ReferencedField', 'pilot', 'licence'],
 											['ReferencedField', 'pilot.licence', 'id'],
 										],
-										abstractsql,
 									],
 								],
+								['Where', abstractsql],
 							],
 						],
 					],
@@ -1064,13 +1059,13 @@ run(() =>
 	operandTest(createMethodCall('substring', 'name', 1, 2), 'eq', "'et'"),
 );
 run(() => operandTest(createMethodCall('tolower', 'name'), 'eq', "'pete'"));
-run(() =>
+run(() => {
 	navigatedOperandTest(
 		createMethodCall('tolower', 'licence/name'),
 		'eq',
 		"'pete'",
-	),
-);
+	);
+});
 run(() => operandTest(createMethodCall('toupper', 'name'), 'eq', "'PETE'"));
 run(function () {
 	const concat = createMethodCall('concat', 'name', "'%20'");
@@ -1463,11 +1458,6 @@ const lambdaTest = function (methodName) {
 					['ReferencedField', 'pilot', 'id'],
 					['ReferencedField', 'pilot.pilot-can fly-plane', 'pilot'],
 				],
-				[
-					'Equals',
-					['ReferencedField', 'pilot.pilot-can fly-plane', 'can fly-plane'],
-					['ReferencedField', 'pilot.pilot-can fly-plane.plane', 'id'],
-				],
 			];
 			const filterWhere = getFilterWhereForBind(bindNumber);
 
@@ -1493,8 +1483,20 @@ const lambdaTest = function (methodName) {
 						],
 					],
 					[
-						'From',
+						'LeftJoin',
 						['Alias', ['Table', 'plane'], 'pilot.pilot-can fly-plane.plane'],
+						[
+							'On',
+							[
+								'Equals',
+								[
+									'ReferencedField',
+									'pilot.pilot-can fly-plane',
+									'can fly-plane',
+								],
+								['ReferencedField', 'pilot.pilot-can fly-plane.plane', 'id'],
+							],
+						],
 					],
 					['Where', subWhere],
 				],
@@ -1659,16 +1661,6 @@ const lambdaTest = function (methodName) {
 			return innerWhere;
 		};
 
-		const where = [
-			'And',
-			[
-				'Equals',
-				['ReferencedField', 'pilot', 'id'],
-				['ReferencedField', 'pilot.pilot-can fly-plane', 'pilot'],
-			],
-			getPlaneWhereForBind(0),
-		];
-
 		test(
 			'/pilot?$filter=can_fly__plane/plane/' +
 				methodName +
@@ -1677,12 +1669,22 @@ const lambdaTest = function (methodName) {
 				it('should select from pilot where ...', () => {
 					expect(result)
 						.to.be.a.query.that.selects(pilotFields)
-						.from('pilot', ['pilot-can fly-plane', 'pilot.pilot-can fly-plane'])
+						.from('pilot')
+						.leftJoin([
+							['pilot-can fly-plane', 'pilot.pilot-can fly-plane'],
+							[
+								'Equals',
+								['ReferencedField', 'pilot', 'id'],
+								['ReferencedField', 'pilot.pilot-can fly-plane', 'pilot'],
+							],
+						])
 						.where(where);
 				});
 			},
 		);
 
+		const where = getPlaneWhereForBind(0);
+
 		test(
 			'/pilot/$count?$filter=can_fly__plane/plane/' +
 				methodName +
@@ -1691,27 +1693,39 @@ const lambdaTest = function (methodName) {
 				it('should select count(*) from pilot where ...', () => {
 					expect(result)
 						.to.be.a.query.that.selects($count)
-						.from('pilot', ['pilot-can fly-plane', 'pilot.pilot-can fly-plane'])
+						.from('pilot')
+						.leftJoin([
+							['pilot-can fly-plane', 'pilot.pilot-can fly-plane'],
+							[
+								'Equals',
+								['ReferencedField', 'pilot', 'id'],
+								['ReferencedField', 'pilot.pilot-can fly-plane', 'pilot'],
+							],
+						])
 						.where(where);
 				});
 			},
 		);
 
 		const twoPartWhere = [
-			'And',
-			[
-				'Equals',
-				['ReferencedField', 'pilot', 'id'],
-				['ReferencedField', 'pilot.pilot-can fly-plane', 'pilot'],
-			],
-			['Or', getPlaneWhereForBind(0), getPlaneWhereForBind(1)],
+			'Or',
+			getPlaneWhereForBind(0),
+			getPlaneWhereForBind(1),
 		];
 
 		test(`/pilot?$filter=can_fly__plane/plane/${methodName}(d:d/name eq 'Concorde') or can_fly__plane/plane/${methodName}(d:d/name eq '747')`, (result) => {
 			it('should select from pilot where ...', () => {
 				expect(result)
 					.to.be.a.query.that.selects(pilotFields)
-					.from('pilot', ['pilot-can fly-plane', 'pilot.pilot-can fly-plane'])
+					.from('pilot')
+					.leftJoin([
+						['pilot-can fly-plane', 'pilot.pilot-can fly-plane'],
+						[
+							'Equals',
+							['ReferencedField', 'pilot', 'id'],
+							['ReferencedField', 'pilot.pilot-can fly-plane', 'pilot'],
+						],
+					])
 					.where(twoPartWhere);
 			});
 		});
@@ -1720,7 +1734,15 @@ const lambdaTest = function (methodName) {
 			it('should select count(*) from pilot where ...', () => {
 				expect(result)
 					.to.be.a.query.that.selects($count)
-					.from('pilot', ['pilot-can fly-plane', 'pilot.pilot-can fly-plane'])
+					.from('pilot')
+					.leftJoin([
+						['pilot-can fly-plane', 'pilot.pilot-can fly-plane'],
+						[
+							'Equals',
+							['ReferencedField', 'pilot', 'id'],
+							['ReferencedField', 'pilot.pilot-can fly-plane', 'pilot'],
+						],
+					])
 					.where(twoPartWhere);
 			});
 		});
@@ -1734,7 +1756,15 @@ const lambdaTest = function (methodName) {
 				it(`should select from pilot when filtering using a non-existing alias inside an ${methodName}`, () => {
 					expect(result)
 						.to.be.a.query.that.selects(pilotFields)
-						.from('pilot', ['pilot-can fly-plane', 'pilot.pilot-can fly-plane'])
+						.from('pilot')
+						.leftJoin([
+							['pilot-can fly-plane', 'pilot.pilot-can fly-plane'],
+							[
+								'Equals',
+								['ReferencedField', 'pilot', 'id'],
+								['ReferencedField', 'pilot.pilot-can fly-plane', 'pilot'],
+							],
+						])
 						.where(where);
 				});
 			},
@@ -1920,43 +1950,46 @@ run(function () {
 		it('should select from team where "' + odata + '"', () => {
 			expect(result)
 				.to.be.a.query.that.selects(teamFields)
-				.from(
-					'team',
-					['pilot', 'team.includes-pilot'],
-					['pilot-can fly-plane', 'team.includes-pilot.pilot-can fly-plane'],
-					['plane', 'team.includes-pilot.pilot-can fly-plane.plane'],
-				)
-				.where([
-					'And',
-					[
-						'Equals',
-						['ReferencedField', 'team', 'favourite colour'],
-						['ReferencedField', 'team.includes-pilot', 'is on-team'],
-					],
+				.from('team')
+				.leftJoin(
 					[
-						'Equals',
-						['ReferencedField', 'team.includes-pilot', 'id'],
+						['pilot', 'team.includes-pilot'],
 						[
-							'ReferencedField',
-							'team.includes-pilot.pilot-can fly-plane',
-							'pilot',
+							'Equals',
+							['ReferencedField', 'team', 'favourite colour'],
+							['ReferencedField', 'team.includes-pilot', 'is on-team'],
 						],
 					],
 					[
-						'Equals',
+						['pilot-can fly-plane', 'team.includes-pilot.pilot-can fly-plane'],
 						[
-							'ReferencedField',
-							'team.includes-pilot.pilot-can fly-plane',
-							'can fly-plane',
+							'Equals',
+							['ReferencedField', 'team.includes-pilot', 'id'],
+							[
+								'ReferencedField',
+								'team.includes-pilot.pilot-can fly-plane',
+								'pilot',
+							],
 						],
+					],
+					[
+						['plane', 'team.includes-pilot.pilot-can fly-plane.plane'],
 						[
-							'ReferencedField',
-							'team.includes-pilot.pilot-can fly-plane.plane',
-							'id',
+							'Equals',
+							[
+								'ReferencedField',
+								'team.includes-pilot.pilot-can fly-plane',
+								'can fly-plane',
+							],
+							[
+								'ReferencedField',
+								'team.includes-pilot.pilot-can fly-plane.plane',
+								'id',
+							],
 						],
 					],
-					abstractsql,
-				]);
+				)
+				.where(abstractsql);
 		});
 	});
 });
diff --git a/test/orderby.js b/test/orderby.js
index 48b09d76..6db9d551 100644
--- a/test/orderby.js
+++ b/test/orderby.js
@@ -73,12 +73,16 @@ test('/pilot?$orderby=licence/id asc', (result) => {
 	it('should order by licence/id asc', () => {
 		expect(result)
 			.to.be.a.query.that.selects(pilotFields)
-			.from('pilot', ['licence', 'pilot.licence'])
-			.where([
-				'Equals',
-				['ReferencedField', 'pilot', 'licence'],
-				['ReferencedField', 'pilot.licence', 'id'],
+			.from('pilot')
+			.leftJoin([
+				['licence', 'pilot.licence'],
+				[
+					'Equals',
+					['ReferencedField', 'pilot', 'licence'],
+					['ReferencedField', 'pilot.licence', 'id'],
+				],
 			])
+			.where()
 			.orderby(['ASC', operandToAbstractSQL('licence/id')]);
 	});
 });
@@ -87,12 +91,16 @@ test('/pilot?$orderby=licence/name asc,licence/id desc', (result) => {
 	it('should order by licence/name asc, licence/id desc w/o JOINing the licence twice', () => {
 		expect(result)
 			.to.be.a.query.that.selects(pilotFields)
-			.from('pilot', ['licence', 'pilot.licence'])
-			.where([
-				'Equals',
-				['ReferencedField', 'pilot', 'licence'],
-				['ReferencedField', 'pilot.licence', 'id'],
+			.from('pilot')
+			.leftJoin([
+				['licence', 'pilot.licence'],
+				[
+					'Equals',
+					['ReferencedField', 'pilot', 'licence'],
+					['ReferencedField', 'pilot.licence', 'id'],
+				],
 			])
+			.where()
 			.orderby(
 				['ASC', operandToAbstractSQL('licence/name')],
 				['DESC', operandToAbstractSQL('licence/id')],
@@ -104,24 +112,25 @@ test('/pilot?$orderby=can_fly__plane/plane/id asc', (result) => {
 	it('should order by can_fly__plane/plane/id asc', () => {
 		expect(result)
 			.to.be.a.query.that.selects(pilotFields)
-			.from(
-				'pilot',
-				['pilot-can fly-plane', 'pilot.pilot-can fly-plane'],
-				['plane', 'pilot.pilot-can fly-plane.plane'],
-			)
-			.where([
-				'And',
+			.from('pilot')
+			.leftJoin(
 				[
-					'Equals',
-					['ReferencedField', 'pilot', 'id'],
-					['ReferencedField', 'pilot.pilot-can fly-plane', 'pilot'],
+					['pilot-can fly-plane', 'pilot.pilot-can fly-plane'],
+					[
+						'Equals',
+						['ReferencedField', 'pilot', 'id'],
+						['ReferencedField', 'pilot.pilot-can fly-plane', 'pilot'],
+					],
 				],
 				[
-					'Equals',
-					['ReferencedField', 'pilot.pilot-can fly-plane', 'can fly-plane'],
-					['ReferencedField', 'pilot.pilot-can fly-plane.plane', 'id'],
+					['plane', 'pilot.pilot-can fly-plane.plane'],
+					[
+						'Equals',
+						['ReferencedField', 'pilot.pilot-can fly-plane', 'can fly-plane'],
+						['ReferencedField', 'pilot.pilot-can fly-plane.plane', 'id'],
+					],
 				],
-			])
+			)
 			.orderby(['ASC', operandToAbstractSQL('can_fly__plane/plane/id')]);
 	});
 });
@@ -130,24 +139,26 @@ test('/pilot?$orderby=can_fly__plane/plane/name desc,can_fly__plane/plane/id asc
 	it('should order by can_fly__plane/plane/name desc, can_fly__plane/plane/id asc w/o JOINing the resources twice', () => {
 		expect(result)
 			.to.be.a.query.that.selects(pilotFields)
-			.from(
-				'pilot',
-				['pilot-can fly-plane', 'pilot.pilot-can fly-plane'],
-				['plane', 'pilot.pilot-can fly-plane.plane'],
-			)
-			.where([
-				'And',
+			.from('pilot')
+			.leftJoin(
 				[
-					'Equals',
-					['ReferencedField', 'pilot', 'id'],
-					['ReferencedField', 'pilot.pilot-can fly-plane', 'pilot'],
+					['pilot-can fly-plane', 'pilot.pilot-can fly-plane'],
+					[
+						'Equals',
+						['ReferencedField', 'pilot', 'id'],
+						['ReferencedField', 'pilot.pilot-can fly-plane', 'pilot'],
+					],
 				],
 				[
-					'Equals',
-					['ReferencedField', 'pilot.pilot-can fly-plane', 'can fly-plane'],
-					['ReferencedField', 'pilot.pilot-can fly-plane.plane', 'id'],
+					['plane', 'pilot.pilot-can fly-plane.plane'],
+					[
+						'Equals',
+						['ReferencedField', 'pilot.pilot-can fly-plane', 'can fly-plane'],
+						['ReferencedField', 'pilot.pilot-can fly-plane.plane', 'id'],
+					],
 				],
-			])
+			)
+			.where()
 			.orderby(
 				['DESC', operandToAbstractSQL('can_fly__plane/plane/name')],
 				['ASC', operandToAbstractSQL('can_fly__plane/plane/id')],
@@ -296,12 +307,16 @@ test('/pilot?$select=name,licence/name&$orderby=name asc,licence/name desc', fun
 				operandToAbstractSQL('name'),
 				operandToAbstractSQL('licence/name'),
 			])
-			.from('pilot', ['licence', 'pilot.licence'])
-			.where([
-				'Equals',
-				['ReferencedField', 'pilot', 'licence'],
-				['ReferencedField', 'pilot.licence', 'id'],
+			.from('pilot')
+			.leftJoin([
+				['licence', 'pilot.licence'],
+				[
+					'Equals',
+					['ReferencedField', 'pilot', 'licence'],
+					['ReferencedField', 'pilot.licence', 'id'],
+				],
 			])
+			.where()
 			.orderby(
 				['ASC', operandToAbstractSQL('name')],
 				['DESC', operandToAbstractSQL('licence/name')],
diff --git a/test/select.js b/test/select.js
index 03e654e6..bce491c0 100644
--- a/test/select.js
+++ b/test/select.js
@@ -70,12 +70,16 @@ test('/pilot?$select=was_trained_by__pilot/name', (result) => {
 			.to.be.a.query.that.selects(
 				aliasFields('pilot', [pilotName], 'was trained by'),
 			)
-			.from('pilot', ['pilot', 'pilot.was trained by-pilot'])
-			.where([
-				'Equals',
-				['ReferencedField', 'pilot', 'was trained by-pilot'],
-				['ReferencedField', 'pilot.was trained by-pilot', 'id'],
-			]);
+			.from('pilot')
+			.leftJoin([
+				['pilot', 'pilot.was trained by-pilot'],
+				[
+					'Equals',
+					['ReferencedField', 'pilot', 'was trained by-pilot'],
+					['ReferencedField', 'pilot.was trained by-pilot', 'id'],
+				],
+			])
+			.where();
 	});
 });
 
@@ -98,12 +102,16 @@ test('/pilot?$select=was_trained_by__pilot/p/name', (result) => {
 			.to.be.a.query.that.selects(
 				aliasFields('pilot', [pilotName], 'was trained by'),
 			)
-			.from('pilot', ['pilot', 'pilot.was trained by-pilot'])
-			.where([
-				'Equals',
-				['ReferencedField', 'pilot', 'was trained by-pilot'],
-				['ReferencedField', 'pilot.was trained by-pilot', 'id'],
-			]);
+			.from('pilot')
+			.leftJoin([
+				['pilot', 'pilot.was trained by-pilot'],
+				[
+					'Equals',
+					['ReferencedField', 'pilot', 'was trained by-pilot'],
+					['ReferencedField', 'pilot.was trained by-pilot', 'id'],
+				],
+			])
+			.where();
 	});
 });
 
@@ -122,12 +130,16 @@ test('/pilot?$select=trained__pilot/name', (result) => {
 	it('should select name from pilot', () => {
 		expect(result)
 			.to.be.a.query.that.selects(aliasFields('pilot', [pilotName], 'trained'))
-			.from('pilot', ['pilot', 'pilot.trained-pilot'])
-			.where([
-				'Equals',
-				['ReferencedField', 'pilot', 'id'],
-				['ReferencedField', 'pilot.trained-pilot', 'was trained by-pilot'],
-			]);
+			.from('pilot')
+			.leftJoin([
+				['pilot', 'pilot.trained-pilot'],
+				[
+					'Equals',
+					['ReferencedField', 'pilot', 'id'],
+					['ReferencedField', 'pilot.trained-pilot', 'was trained by-pilot'],
+				],
+			])
+			.where();
 	});
 });
 
@@ -139,24 +151,26 @@ test('/pilot?$select=was_trained_by__pilot/name,trained__pilot/name', (result) =
 					aliasFields('pilot', [pilotName], 'trained'),
 				),
 			)
-			.from(
-				'pilot',
-				['pilot', 'pilot.was trained by-pilot'],
-				['pilot', 'pilot.trained-pilot'],
-			)
-			.where([
-				'And',
+			.from('pilot')
+			.leftJoin(
 				[
-					'Equals',
-					['ReferencedField', 'pilot', 'was trained by-pilot'],
-					['ReferencedField', 'pilot.was trained by-pilot', 'id'],
+					['pilot', 'pilot.was trained by-pilot'],
+					[
+						'Equals',
+						['ReferencedField', 'pilot', 'was trained by-pilot'],
+						['ReferencedField', 'pilot.was trained by-pilot', 'id'],
+					],
 				],
 				[
-					'Equals',
-					['ReferencedField', 'pilot', 'id'],
-					['ReferencedField', 'pilot.trained-pilot', 'was trained by-pilot'],
+					['pilot', 'pilot.trained-pilot'],
+					[
+						'Equals',
+						['ReferencedField', 'pilot', 'id'],
+						['ReferencedField', 'pilot.trained-pilot', 'was trained by-pilot'],
+					],
 				],
-			]);
+			)
+			.where();
 	});
 });
 
@@ -166,12 +180,16 @@ test('/pilot?$select=trained__pilot/name,age', (result) => {
 			.to.be.a.query.that.selects(
 				aliasFields('pilot', [pilotName], 'trained').concat([pilotAge]),
 			)
-			.from('pilot', ['pilot', 'pilot.trained-pilot'])
-			.where([
-				'Equals',
-				['ReferencedField', 'pilot', 'id'],
-				['ReferencedField', 'pilot.trained-pilot', 'was trained by-pilot'],
-			]);
+			.from('pilot')
+			.leftJoin([
+				['pilot', 'pilot.trained-pilot'],
+				[
+					'Equals',
+					['ReferencedField', 'pilot', 'id'],
+					['ReferencedField', 'pilot.trained-pilot', 'was trained by-pilot'],
+				],
+			])
+			.where();
 	});
 });
 
@@ -185,12 +203,16 @@ test('/pilot?$select=licence/id', (result) => {
 	it('should select licence/id for pilots', () => {
 		expect(result)
 			.to.be.a.query.that.selects([operandToAbstractSQL('licence/id')])
-			.from('pilot', ['licence', 'pilot.licence'])
-			.where([
-				'Equals',
-				['ReferencedField', 'pilot', 'licence'],
-				['ReferencedField', 'pilot.licence', 'id'],
-			]);
+			.from('pilot')
+			.leftJoin([
+				['licence', 'pilot.licence'],
+				[
+					'Equals',
+					['ReferencedField', 'pilot', 'licence'],
+					['ReferencedField', 'pilot.licence', 'id'],
+				],
+			])
+			.where();
 	});
 });
 
@@ -200,24 +222,26 @@ test('/pilot?$select=can_fly__plane/plane/id', (result) => {
 			.to.be.a.query.that.selects([
 				operandToAbstractSQL('can_fly__plane/plane/id'),
 			])
-			.from(
-				'pilot',
-				['pilot-can fly-plane', 'pilot.pilot-can fly-plane'],
-				['plane', 'pilot.pilot-can fly-plane.plane'],
-			)
-			.where([
-				'And',
+			.from('pilot')
+			.leftJoin(
 				[
-					'Equals',
-					['ReferencedField', 'pilot', 'id'],
-					['ReferencedField', 'pilot.pilot-can fly-plane', 'pilot'],
+					['pilot-can fly-plane', 'pilot.pilot-can fly-plane'],
+					[
+						'Equals',
+						['ReferencedField', 'pilot', 'id'],
+						['ReferencedField', 'pilot.pilot-can fly-plane', 'pilot'],
+					],
 				],
 				[
-					'Equals',
-					['ReferencedField', 'pilot.pilot-can fly-plane', 'can fly-plane'],
-					['ReferencedField', 'pilot.pilot-can fly-plane.plane', 'id'],
+					['plane', 'pilot.pilot-can fly-plane.plane'],
+					[
+						'Equals',
+						['ReferencedField', 'pilot.pilot-can fly-plane', 'can fly-plane'],
+						['ReferencedField', 'pilot.pilot-can fly-plane.plane', 'id'],
+					],
 				],
-			]);
+			)
+			.where();
 	});
 });
 


Reviews:
user: login: thgreasi
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-14T17:47:29Z
user: login: thgreasi
body: LGTM :+1: 
state: DISMISSED
author_association: CONTRIBUTOR
submitted_at: 2025-03-14T17:59:51Z
user: login: Page-
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-17T16:29:16Z
user: login: thgreasi
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-17T16:39:56Z
user: login: thgreasi
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-17T16:40:48Z
user: login: thgreasi
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-17T18:12:38Z
user: login: thgreasi
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-18T17:30:45Z
user: login: Page-
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-25T17:30:47Z
user: login: thgreasi
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-26T09:33:20Z
user: login: thgreasi
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-26T09:46:05Z
user: login: thgreasi
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-26T09:48:19Z
user: login: Page-
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-03-27T18:38:12Z

Review comments:
diff_hunk: @@ -1726,6 +1758,62 @@ export class OData2AbstractSQL {
 			);
 		}
 	}
+	AddJoins(
+		query: Query,
+		parentResource: Resource,
+		match:
+			| GenericPropertyPath<PropertyPath>
+			| Array<GenericPropertyPath<PropertyPath>>,
path: src/odata-to-abstract-sql.ts
user: login: thgreasi
body: Cant this be v ?
```suggestion
		match: PropertyPath | PropertyPath[],
```
created_at: 2025-03-14T17:47:29Z
author_association: CONTRIBUTOR
diff_hunk: @@ -891,7 +923,7 @@ export class OData2AbstractSQL {
 		query.where.push(where);
 	}
 	OrderBy(orderby: OrderByOption, query: Query, resource: Resource) {
-		this.AddExtraFroms(query, resource, orderby.properties);
+		this.AddJoins(query, resource, orderby.properties, 'LeftJoin');
path: src/odata-to-abstract-sql.ts
user: login: Page-
body: What's the reasoning for only doing this for `OrderBy`?
created_at: 2025-03-17T16:29:13Z
author_association: CONTRIBUTOR
diff_hunk: @@ -891,7 +923,7 @@ export class OData2AbstractSQL {
 		query.where.push(where);
 	}
 	OrderBy(orderby: OrderByOption, query: Query, resource: Resource) {
-		this.AddExtraFroms(query, resource, orderby.properties);
+		this.AddJoins(query, resource, orderby.properties, 'LeftJoin');
path: src/odata-to-abstract-sql.ts
user: login: thgreasi
body: We probably were only aware of OrderBy adding extra Froms, since queries like `/pilot?$select=was_trained_by__pilot/name` are not something that we are familiar with or use.
Is it only those ^ queries that also add extra froms @Page- (if you remember from cache)?
created_at: 2025-03-17T16:39:56Z
author_association: CONTRIBUTOR
diff_hunk: @@ -891,7 +923,7 @@ export class OData2AbstractSQL {
 		query.where.push(where);
 	}
 	OrderBy(orderby: OrderByOption, query: Query, resource: Resource) {
-		this.AddExtraFroms(query, resource, orderby.properties);
+		this.AddJoins(query, resource, orderby.properties, 'LeftJoin');
path: src/odata-to-abstract-sql.ts
user: login: thgreasi
body: I would be happy to work on this. Should we split that to a separate PR though?
created_at: 2025-03-17T16:40:48Z
author_association: CONTRIBUTOR
diff_hunk: @@ -891,7 +923,7 @@ export class OData2AbstractSQL {
 		query.where.push(where);
 	}
 	OrderBy(orderby: OrderByOption, query: Query, resource: Resource) {
-		this.AddExtraFroms(query, resource, orderby.properties);
+		this.AddJoins(query, resource, orderby.properties, 'LeftJoin');
path: src/odata-to-abstract-sql.ts
user: login: thgreasi
body: Something like this v ?
See: https://github.com/balena-io-modules/odata-to-abstract-sql/pull/167/commits/c44b09d624cb7df9e23b6bb3a70b7b2552cc8c80

I've only replaced FROMs with LEFT JOINs in $select paths, b/c it looked like there was no benefit on doing so for $count & $filter expressions and I was also scared about what that could break, so I would prefer some guidance before touching those.
created_at: 2025-03-17T18:12:38Z
author_association: CONTRIBUTOR
diff_hunk: @@ -1726,6 +1727,71 @@ export class OData2AbstractSQL {
 			);
 		}
 	}
+	AddJoins(
+		query: Query,
+		parentResource: Resource,
+		match:
+			| GenericPropertyPath<PropertyPath>
+			| Array<GenericPropertyPath<PropertyPath>>,
+		joinType: JoinType,
+	) {
+		if (Array.isArray(match)) {
+			match.forEach((v) => {
+				this.AddJoins(query, parentResource, v, joinType);
+			});
+		} else {
+			let nextProp = match;
+			let prop;
+			while (
+				// tslint:disable-next-line:no-conditional-assignment
+				(prop = nextProp) &&
+				prop.name &&
+				prop.property?.name
+			) {
+				nextProp = prop.property;
+				const resourceAlias = this.resourceAliases[prop.name];
+				if (resourceAlias) {
+					parentResource = resourceAlias;
+				} else {
+					parentResource = this.AddJoinNavigation(
+						query,
+						parentResource,
+						prop.name,
+						joinType,
+					);
+				}
+			}
+			const maybeMethodCallProp = nextProp as { args?: any[] } | undefined;
+			if (maybeMethodCallProp?.args != null) {
+				this.AddJoins(
+					query,
+					parentResource,
+					maybeMethodCallProp.args,
path: src/odata-to-abstract-sql.ts
user: login: thgreasi
body: Heads-up that this was props.args previously:
https://github.com/balena-io-modules/odata-to-abstract-sql/blob/df845be23124cd790ced0526017092c288dc9ed3/src/odata-to-abstract-sql.ts#L1118-L1120
even in the ometajs version:
https://github.com/balena-io-modules/odata-to-abstract-sql/blob/7989ddf8376de7e8b6d770a1782ee2a762e67294/odata-to-abstract-sql.ometajs#L756-L758
but I couldn't think of a reason to not use the `extraProps.args` that we just checked in the `if`, so at the end I changed it.
created_at: 2025-03-18T17:30:45Z
author_association: CONTRIBUTOR
diff_hunk: @@ -1694,21 +1742,47 @@ export class OData2AbstractSQL {
 					if (resourceAlias) {
 						parentResource = resourceAlias;
 					} else {
-						parentResource = this.AddNavigation(
+						parentResource = this.AddJoinNavigation(
 							query,
 							parentResource,
 							prop.name,
+							joinType,
 						);
 					}
 				}
-				if (nextProp?.args) {
-					this.AddExtraFroms(query, parentResource, prop.args);
+				if (nextProp != null && 'args' in nextProp && nextProp.args != null) {
+					this.AddJoins(query, parentResource, nextProp.args, 'LeftJoin');
 				}
 			}
 		} catch {
 			// ignore
 		}
 	}
+	AddJoinNavigation(
+		query: Query,
+		resource: Resource,
+		extraResource: string,
+		joinType: JoinType,
+	): AliasedResource {
+		const navigation = this.NavigateResources(resource, extraResource);
+		if (
+			!query.joins.some((join) => {
+				const from = join[1];
+				return (
+					(isTableNode(from) && from[1] === navigation.resource.tableAlias) ||
+					(isAliasNode(from) && from[2] === navigation.resource.tableAlias)
+				);
+			})
+		) {
+			console.info(`*** AddJoinNavigation`);
path: src/odata-to-abstract-sql.ts
user: login: Page-
body: Debug
created_at: 2025-03-25T17:29:17Z
author_association: CONTRIBUTOR
diff_hunk: @@ -84,11 +85,16 @@ import type {
 	OrderByPropertyPath,
 	FilterOption,
 	BindReference,
+	GenericPropertyPath,
+	PropertyPath,
+	MethodCall,
 } from '@balena/odata-parser';
 export type { ODataBinds, ODataQuery, SupportedMethod };
 
 type InternalSupportedMethod = Exclude<SupportedMethod, 'MERGE'> | 'PUT-INSERT';
 
+type JoinType = 'Join' | 'LeftJoin' | 'RightJoin';
path: src/odata-to-abstract-sql.ts
user: login: Page-
body: I think this could be
```suggestion
type JoinType = JoinTypeNodes[0];
```
?
created_at: 2025-03-25T17:29:57Z
author_association: CONTRIBUTOR
diff_hunk: @@ -1673,19 +1711,29 @@ export class OData2AbstractSQL {
 			],
 		};
 	}
-	AddExtraFroms(query: Query, parentResource: Resource, match: any) {
+	AddJoins(
+		query: Query,
+		parentResource: Resource,
+		// This can be any node that odata-parser returns
+		match:
+			| (GenericPropertyPath<PropertyPath> | MethodCall[1])
+			| Array<GenericPropertyPath<PropertyPath> | MethodCall[1]>,
+		joinType: JoinType,
path: src/odata-to-abstract-sql.ts
user: login: Page-
body: It seems like this is always set as `LeftJoin` so I'm not sure if it's beneficial to be a parameter?
created_at: 2025-03-25T17:30:36Z
author_association: CONTRIBUTOR
diff_hunk: @@ -1694,21 +1742,47 @@ export class OData2AbstractSQL {
 					if (resourceAlias) {
 						parentResource = resourceAlias;
 					} else {
-						parentResource = this.AddNavigation(
+						parentResource = this.AddJoinNavigation(
 							query,
 							parentResource,
 							prop.name,
+							joinType,
 						);
 					}
 				}
-				if (nextProp?.args) {
-					this.AddExtraFroms(query, parentResource, prop.args);
+				if (nextProp != null && 'args' in nextProp && nextProp.args != null) {
+					this.AddJoins(query, parentResource, nextProp.args, 'LeftJoin');
 				}
 			}
 		} catch {
 			// ignore
 		}
 	}
+	AddJoinNavigation(
+		query: Query,
+		resource: Resource,
+		extraResource: string,
+		joinType: JoinType,
+	): AliasedResource {
+		const navigation = this.NavigateResources(resource, extraResource);
+		if (
+			!query.joins.some((join) => {
+				const from = join[1];
+				return (
+					(isTableNode(from) && from[1] === navigation.resource.tableAlias) ||
+					(isAliasNode(from) && from[2] === navigation.resource.tableAlias)
+				);
+			})
+		) {
+			console.info(`*** AddJoinNavigation`);
path: src/odata-to-abstract-sql.ts
user: login: thgreasi
body: Oops. I guess one of my rebases picked it up.
created_at: 2025-03-26T09:33:20Z
author_association: CONTRIBUTOR
diff_hunk: @@ -84,11 +85,16 @@ import type {
 	OrderByPropertyPath,
 	FilterOption,
 	BindReference,
+	GenericPropertyPath,
+	PropertyPath,
+	MethodCall,
 } from '@balena/odata-parser';
 export type { ODataBinds, ODataQuery, SupportedMethod };
 
 type InternalSupportedMethod = Exclude<SupportedMethod, 'MERGE'> | 'PUT-INSERT';
 
+type JoinType = 'Join' | 'LeftJoin' | 'RightJoin';
path: src/odata-to-abstract-sql.ts
user: login: thgreasi
body: The intention was to (at least initially) only allow Joins with OnNodes, since otherwise the typings inside `joinResource` complained and went more complicated w/o any real benefit.
Switched it to `Exclude<JoinTypeNodes, [string, unknown]>[0]` to still keep `joinResource` simple.
created_at: 2025-03-26T09:46:05Z
author_association: CONTRIBUTOR
diff_hunk: @@ -1673,19 +1711,29 @@ export class OData2AbstractSQL {
 			],
 		};
 	}
-	AddExtraFroms(query: Query, parentResource: Resource, match: any) {
+	AddJoins(
+		query: Query,
+		parentResource: Resource,
+		// This can be any node that odata-parser returns
+		match:
+			| (GenericPropertyPath<PropertyPath> | MethodCall[1])
+			| Array<GenericPropertyPath<PropertyPath> | MethodCall[1]>,
+		joinType: JoinType,
path: src/odata-to-abstract-sql.ts
user: login: thgreasi
body: Dropping the joinType parameter :+1: 
I guess we were no sure whether we might later need inner joins for some other part, but now that we replaced all other uses of `addExtraFroms` I think it's clear that we only need left join for now.
created_at: 2025-03-26T09:48:18Z
author_association: CONTRIBUTOR

PR comments:
user: login: github-advanced-security[bot]
created_at: 2025-01-09T19:17:30Z
body: This pull request sets up GitHub code scanning for this repository. Once the scans have completed and the checks have passed, the analysis results for this pull request branch will appear on [this overview](/balena-io-modules/odata-to-abstract-sql/security/code-scanning?query=pr%3A160+is%3Aopen). Once you merge this pull request, the 'Security' tab will show more code scanning analysis results (for example, for the default branch). Depending on your configuration and choice of analysis tool, future pull requests will be annotated with code scanning analysis results. For more information about GitHub code scanning, check out [the documentation](https://docs.github.com/code-security/code-scanning/introduction-to-code-scanning/about-code-scanning). 
author_association: NONE
user: login: thgreasi
created_at: 2025-03-14T17:42:16Z
body: Tried the device list query in a huge fleet, and the new query is just slightly slower (211ms vs 210ms).
That's expected afaict, since the sorting of the ORDER BY now also includes the device w/ NULL on their FK, so sorting more rows => needs more time.
I have uploaded the old & new query along with their execution plans in zulip v.
See: [#balena-io/balenaCloud > orderBy query generation error @ 💬](https://balena.zulipchat.com/#narrow/channel/346007-balena-io.2FbalenaCloud/topic/orderBy.20query.20generation.20error/near/500198632)
author_association: CONTRIBUTOR
user: login: thgreasi
created_at: 2025-03-24T12:35:29Z
body: @Page- @JSReds Tests seem to be passing in pine, open-balena-api & balena-api
https://github.com/balena-io/pinejs/pull/874
https://github.com/balena-io/pinejs/pull/874
https://github.com/balena-io/balena-api/pull/5696
author_association: CONTRIBUTOR