Complex projections¶
This page explains how to use complex projections in ts-sql-query
to map the result of a SQL query into deeply structured JavaScript/TypeScript objects. It covers how to define nested objects in the result, how to manage optionality rules based on joins and field presence, and how to handle advanced features like required fields inside optional objects.
Nested object projections¶
In ts-sql-query
, the result of a query doesn't need to be a flat object. You can build results as nested objects, where properties themselves can be objects. Multiple levels of nesting are supported.
Limitation
- Only 5 nesting levels are supported.
const companyId = 24;
const customersOfCompany = connection.selectFrom(tCustomer)
.where(tCustomer.companyId.equals(companyId))
.select({
id: tCustomer.id,
name: {
firstName: tCustomer.firstName,
lastName: tCustomer.lastName,
},
birthday: tCustomer.birthday
})
.orderBy('name.firstName')
.orderBy('name.lastName')
.executeSelectMany();
The executed query is:
The parameters are: [ 24 ]
The result type is:
const customersOfCompany: Promise<{
id: number;
name: {
firstName: string;
lastName: string;
};
birthday?: Date;
}[]>
Rules for nested object projections¶
When projecting nested objects, it's important to determine when those objects and their fields should be required or optional. ts-sql-query
applies a set of rules to infer these types based on the source tables and the way fields are selected or joined.
Rules (in priority order):
- In the case there are properties defined as
asRequiredInOptionalObject
: all other non-required properties will be marked as optional; the properties defined asasRequiredInOptionalObject
will be considered required; the object will be considered optional. If any property defined asasRequiredInOptionalObject
has no value, the whole object will be ignored, independently if there are other properties with value. - In the case that all properties are coming from the same outer (left) join and the original table has required properties, those properties will be treated automatically as
asRequiredInOptionalObject
. - In the case there are required properties or required inner objects: all other non-required properties or non-required inner objects properties will be marked as optional; the object will be considered required.
- In any other case: all properties and inner objects will be marked as optional, the object will be considered optional.
Detailed rules
The following is a more detailed version of the rules explained above (in priority order):
- There are
asRequiredInOptionalObject
fields:- the resulting object is marked as optional
asRequiredInOptionalObject
fields are marked as required- required objects remain as required but must not exist if the
asRequiredInOptionalObject
fields have no value (ignoring the inner objects) - inner objects remain as in their definition but must not exist if the
asRequiredInOptionalObject
fields have no value (ignoring the inner objects) - required fields coming from a left join & optional fields are marked as optional
- All fields (minimum one, ignoring inner objects) have the same identical outer (left) join dependency
- the fields that were required because the value is required in the original table used for the outer join will be treated as
asRequiredInOptionalObject
in the same way described in the previous point.
- the fields that were required because the value is required in the original table used for the outer join will be treated as
- There are required fields or inner objects:
- the resulting object is marked as required
- required fields are marked as required
- optional fields are marked as optional
- inner objects remain as in their definition
- There are no required fields or inner objects:
- the resulting object is marked as optional
- optional fields are marked as optional
- inner objects remain as in their definition
Note
When you indicate that a table will be used in a left join, all required columns are treated as optional in ts-sql-query
because the left join is per se optional; the rule number 2 is the only one that can revert it implicitly.
Limitation
-
You cannot use complex projections in queries that will be used as table in other query (created using
forUseInQueryAs
that corresponds to the with clause in SQL) -
Only 5 nesting levels are supported.
Optional nested objects with required fields¶
You can take advantage of the optional type of an inner object to mark the inner properties as required. That means the inner properties are optional, but we know they will be required together if they have value; in the case they have no value, the whole object must not exist. To do this, we must call the asRequiredInOptionalObject
method on the properties that must exist. This helps model business rules where a group of fields should either all be present or all be absent, and avoids having to manually check each field for null or undefined.
const companies = connection.selectFrom(tCompany)
.select({
id: tCompany.id,
name: tCompany.name,
parentId: tCompany.parentId,
ubication: {
latitude: tCompany.ubicationLatitude.asRequiredInOptionalObject(),
longitude: tCompany.ubicationLongitude.asRequiredInOptionalObject(),
comment: tCompany.ubicationComment
}
})
.executeSelectMany();
The executed query is:
The parameters are: [ ]
The result type is:
const companies: Promise<{
id: number;
name: string;
parentId?: number;
ubication?: {
latitude: string;
longitude: string;
comment?: string;
};
}[]>
Business rule
Ubication is optional. If present, both ubicationLatitude
and ubicationLongitude
must contain a value. The ubicationComment
field is optional and may contain a value only if the two coordinates are present.
Nested objects with inner joins¶
const customerWithCompanyInOneQuery = connection.selectFrom(tCustomer)
.innerJoin(tCompany).on(tCompany.id.equals(tCustomer.companyId))
.select({
id: tCustomer.id,
firstName: tCustomer.firstName,
lastName: tCustomer.lastName,
birthday: tCustomer.birthday,
company: {
id: tCompany.id,
name: tCompany.name
}
}).where(
tCustomer.id .equals(12)
).executeSelectOne();
The executed query is:
The parameters are: [ 12 ]
The result type is:
const customerWithCompanyInOneQuery: Promise<{
id: number;
firstName: string;
lastName: string;
company: {
id: number;
name: string;
};
birthday?: Date;
}>
Nested objects with left joins¶
When all inner object's properties come from the same left join, the inner object is transformed in optional, and the inner properties reflect optionality in the original table. All nested inner objects will only exist if their immediate parent object has values and any its properties (coming from a left join) has value (not null).
Note
Even if a field is required in the original table, a left join makes it optional in the result unless additional conditions apply (see rule #2 above).
const parent = tCompany.forUseInLeftJoinAs('parent');
const parentParent = tCompany.forUseInLeftJoinAs('parentParent');
const companyMultiParent = connection.selectFrom(tCompany)
.leftJoin(parent).on(tCompany.parentId.equals(parent.id))
.leftJoin(parentParent).on(parent.parentId.equals(parentParent.id))
.select({
id: tCompany.id,
name: tCompany.name,
parent: {
id: parent.id,
name: parent.name,
parent: {
id: parentParent.id,
name: parentParent.name,
parentId: parentParent.parentId,
}
}
})
.executeSelectMany();
The executed query is:
select
company.id as id,
company.name as name,
parent.id as `parent.id`,
parent.name as `parent.name`,
parentParent.id as `parent.parent.id`,
parentParent.name as `parent.parent.name`,
parentParent.parent_id as `parent.parent.parentId`
from company
left join company as parent on company.parent_id = parent.id
left join company as parentParent on parent.parent_id = parentParent.id
select
company.id as id,
company.`name` as `name`,
parent.id as `parent.id`,
parent.`name` as `parent.name`,
parentParent.id as `parent.parent.id`,
parentParent.`name` as `parent.parent.name`,
parentParent.parent_id as `parent.parent.parentId`
from company
left join company as parent on company.parent_id = parent.id
left join company as parentParent on parent.parent_id = parentParent.id
select
company.id as "id",
company.name as "name",
parent.id as "parent.id",
parent.name as "parent.name",
parentParent.id as "parent.parent.id",
parentParent.name as "parent.parent.name",
parentParent.parent_id as "parent.parent.parentId"
from company
left join company parent on company.parent_id = parent.id
left join company parentParent on parent.parent_id = parentParent.id
select
company.id as id,
company.name as name,
parent.id as "parent.id",
parent.name as "parent.name",
parentParent.id as "parent.parent.id",
parentParent.name as "parent.parent.name",
parentParent.parent_id as "parent.parent.parentId"
from company
left join company as parent on company.parent_id = parent.id
left join company as parentParent on parent.parent_id = parentParent.id
select
company.id as id,
company.name as name,
parent.id as "parent.id",
parent.name as "parent.name",
parentParent.id as "parent.parent.id",
parentParent.name as "parent.parent.name",
parentParent.parent_id as "parent.parent.parentId"
from company
left join company as parent on company.parent_id = parent.id
left join company as parentParent on parent.parent_id = parentParent.id
select
company.id as id,
company.name as name,
parent.id as [parent.id],
parent.name as [parent.name],
parentParent.id as [parent.parent.id],
parentParent.name as [parent.parent.name],
parentParent.parent_id as [parent.parent.parentId]
from company
left join company as parent on company.parent_id = parent.id
left join company as parentParent on parent.parent_id = parentParent.id
The parameters are: [ ]
The result type is: