I've been successful in creating the following projection in combination with the @Query annotation:
Approach 1
interface Deployment{
  val app: String
  val platform: String
  val org: String
  val space: String
  val instances: Int
  val state: String
}
@Query(value="select a.name as app, p.name as platform, o.name as org, s.name as space, a.instances as instances, a.state as state " +
    "from apps a " +
    "inner join spaces s on a.space_id = s.id " +
    "inner join orgs o on s.org_id = o.id " +
    "inner join platforms p on o.platform_id = p.id " +
    "where o.name = :eai " +
    "and lower(a.name) = lower(:app) " +
    "and lower(a.state) = lower(:state) " +
    "order by p.name, s.name",
    nativeQuery = true)
fun getDeploymentsByEaiAndAppAndState(eai: String, app: String?, state: String?): List<Deployment>
Approach 2
However, I need to dynamically create the SQL statement based on information supplied in a filter:
data class DeploymentFilter(
    val eai: String
    )
{
  var app: String? = null
  var state: String? = null
}
override fun getByFilter(filter: DeploymentFilter): List<Deployment> {
  var sql =
    "select a.name as app, p.name as platform, o.name as org, s.name as space, a.instances as instances, a.state as state " +
    "from apps a " +
    "inner join spaces s on a.space_id = s.id " +
    "inner join orgs o on s.org_id = o.id " +
    "inner join platforms p on o.platform_id = p.id " +
    "where o.name = :eai"
  if (!filter.app.isNullOrBlank()) { sql += " and lower(a.name) = lower(:app)" }
  if (!filter.state.isNullOrBlank()) { sql += " and lower(a.state) = lower(:state)" }
  sql += " order by p.name, s.name"
  val query = em.createNativeQuery(sql)
  query.setParameter("eai", filter.eai)
  if (!filter.app.isNullOrBlank()) { query.setParameter("app", filter.app) }
  if (!filter.state.isNullOrBlank()) { query.setParameter("state", filter.state) }
  @Suppress("UNCHECKED_CAST")
  return query.resultList as List<Deployment>
}
Although the code runs and executes, it's as if Spring (or Kotlin) is no longer automatically converting the Java List<Object[]> into a Kotlin List.
When I convert the List<Deployment> to JSON using Approach 1, I correctly see:
{
  "eai": "6949",
  "deployments": [
    {
      "app": "CisBidLine",
      "platform": "edcbo1",
      "instances": 3,
      "org": "6949",
      "state": "STARTED",
      "space": "production"
    },
    ...
However, with Approach 2, the Deployment data is treated as an array instead of an Object:
{
  "eai": "6949",
  "deployments": [
    [
      "CisPicture",
      "edcbo1",
      "6949",
      "production",
      1,
      "STARTED"
    ],
    ...
There's no difference in the environment or logic between both approaches, other than one explicitly using @Query and the other em.createNativeQuery.
Any help would be greatly appreciated!
UPDATE
If I understand this correctly, Kotlin can effectively transform a Map to the Interface, as long as the keys of the Map match the properties of the Interface. Because query.resultList returns a List<Object[]>, the trick might be to transform that to a Map using the aliases as the keys. As I'm using Hibernate under the hood, I found the following worked:
query
.unwrap(org.hibernate.query.NativeQuery::class.java)
.setResultTransformer(org.hibernate.transform.Transformers.ALIAS_TO_ENTITY_MAP)
Now I can simply convert the Map to the Interface using:
@Suppress("UNCHECKED_CAST")
return query.resultList as List<Deployment>
For those not using Hibernate, I'm assuming one could manually convert the List to a Map first, then transform using as List<Deployment>.
I'm open to other approaches, but this seems to make reasonable sense!