diff --git a/yawn-api/src/main/kotlin/com/faire/yawn/project/AggregateKind.kt b/yawn-api/src/main/kotlin/com/faire/yawn/project/AggregateKind.kt new file mode 100644 index 0000000..287c01c --- /dev/null +++ b/yawn-api/src/main/kotlin/com/faire/yawn/project/AggregateKind.kt @@ -0,0 +1,15 @@ +package com.faire.yawn.project + +/** + * The kind of aggregate or grouping projection to apply. + * Each kind maps to a specific SQL aggregation or clause. + */ +enum class AggregateKind { + COUNT, + COUNT_DISTINCT, + SUM, + AVG, + MIN, + MAX, + GROUP_BY, +} diff --git a/yawn-api/src/main/kotlin/com/faire/yawn/project/ModifierKind.kt b/yawn-api/src/main/kotlin/com/faire/yawn/project/ModifierKind.kt new file mode 100644 index 0000000..c0c844b --- /dev/null +++ b/yawn-api/src/main/kotlin/com/faire/yawn/project/ModifierKind.kt @@ -0,0 +1,8 @@ +package com.faire.yawn.project + +/** + * The kind of modifier to wrap around a [ProjectionLeaf]. + */ +enum class ModifierKind { + DISTINCT, +} diff --git a/yawn-api/src/main/kotlin/com/faire/yawn/project/ProjectionLeaf.kt b/yawn-api/src/main/kotlin/com/faire/yawn/project/ProjectionLeaf.kt new file mode 100644 index 0000000..0a6dc6f --- /dev/null +++ b/yawn-api/src/main/kotlin/com/faire/yawn/project/ProjectionLeaf.kt @@ -0,0 +1,60 @@ +package com.faire.yawn.project + +import com.faire.yawn.YawnDef +import kotlin.reflect.KClass + +/** + * An ORM-agnostic descriptor of a single atomic projection. + * + * Leaves are the terminal elements that produce actual SQL in the compiled query projection tree. + * The Query Factory is responsible for converting each leaf to the underlying ORM's implementation projection. + * + * Leaf deduplication uses data class equality: two [Property] leaves with the same [YawnDef.YawnColumnDef] + * reference, or two [Aggregate] leaves with the same [AggregateKind] and column, are considered identical + * and will share an index in the re-packed result list. + */ +sealed interface ProjectionLeaf { + /** + * A simple column property access (SQL: `alias.column`). + */ + data class Property( + val column: YawnDef.YawnColumnDef<*>, + ) : ProjectionLeaf + + /** + * An aggregate or grouping projection on a column (SQL: `SUM(alias.column)`, `GROUP BY alias.column`, etc.). + */ + data class Aggregate( + val kind: AggregateKind, + val column: YawnDef.YawnColumnDef<*>, + ) : ProjectionLeaf + + /** + * A row count projection (SQL: `COUNT(*)`). + */ + class RowCount : ProjectionLeaf { + override fun equals(other: Any?): Boolean = other is RowCount<*> + override fun hashCode(): Int = RowCount::class.hashCode() + } + + /** + * A raw SQL projection for custom expressions. + * + * The [sqlExpression] may use `{alias}` placeholders for table alias substitution. + * [resultTypes] are used by the query factory to map SQL results to Kotlin types. + * It is up to the user to guarantee type-safety when using raw SQL projections! + */ + data class Sql( + val sqlExpression: String, + val aliases: List, + val resultTypes: List>, + ) : ProjectionLeaf + + /** + * Wraps another leaf with a SQL modifier (e.g. DISTINCT). + */ + data class Modifier( + val kind: ModifierKind, + val inner: ProjectionLeaf, + ) : ProjectionLeaf +} diff --git a/yawn-api/src/main/kotlin/com/faire/yawn/project/ProjectionMapper.kt b/yawn-api/src/main/kotlin/com/faire/yawn/project/ProjectionMapper.kt new file mode 100644 index 0000000..115b7a0 --- /dev/null +++ b/yawn-api/src/main/kotlin/com/faire/yawn/project/ProjectionMapper.kt @@ -0,0 +1,13 @@ +package com.faire.yawn.project + +/** + * Maps a raw result row (as a list of values corresponding to resolved projection leaves) + * into the desired projection type. + * + * This is an internal type produced by the [ProjectorResolver] resolution engine. + * You should never need to implement this directly; use the simple lambdas pointing to [ProjectionNode.Value], + * [ProjectionNode.Composite], or [ProjectionNode.Mapped]. + */ +internal fun interface ProjectionMapper { + fun map(results: List): TO +} diff --git a/yawn-api/src/main/kotlin/com/faire/yawn/project/ProjectionNode.kt b/yawn-api/src/main/kotlin/com/faire/yawn/project/ProjectionNode.kt new file mode 100644 index 0000000..95b9a7d --- /dev/null +++ b/yawn-api/src/main/kotlin/com/faire/yawn/project/ProjectionNode.kt @@ -0,0 +1,112 @@ +package com.faire.yawn.project + +import com.faire.yawn.YawnDef + +/** + * A declarative description of a projection's structure. + * + * The [ProjectorResolver] resolution engine walks this tree to produce a flat [ResolvedProjection]. + * There are four variants: + * * [Value]: a single leaf projection (column, aggregate, SQL, etc.). Only Values will survive resolution. + * * [Composite]: a grouping of multiple children with a mapper. Flattened during resolution. + * * [Constant]: a fixed value, with no corresponding SQL. Eliminated during resolution. + * * [Mapped]: wraps another projection with an in-memory transformation. Eliminated during resolution. + */ +sealed interface ProjectionNode { + /** + * A single leaf projection that maps to one slot in the SQL result. + * + * @param leaf the ORM-agnostic projection descriptor. + * @param mapper transforms the raw SQL value to the desired type. Defaults to a simple unchecked cast. + */ + data class Value( + val leaf: ProjectionLeaf, + val mapper: (Any?) -> TO = { + @Suppress("UNCHECKED_CAST") + it as TO + }, + ) : ProjectionNode + + /** + * Groups multiple child projections and combines their results with a mapper. + * + * During resolution, composites are recursively flattened: their children's leaves are added to + * the parent's flat list, and the mapper is composed to reconstruct the grouped result. + */ + data class Composite( + val children: List>, + val mapper: (List) -> TO, + ) : ProjectionNode + + /** + * A constant value that requires no SQL. Eliminated during resolution. + */ + data class Constant( + val value: TO, + ) : ProjectionNode + + /** + * Wraps another projection with a post-query transform. Eliminated during resolution; + * the transform is folded into the composed mapper. + */ + data class Mapped( + val from: YawnProjector, + val transform: (FROM) -> TO, + ) : ProjectionNode + + companion object { + fun property( + column: YawnDef.YawnColumnDef, + ): Value = Value(ProjectionLeaf.Property(column)) + + fun aggregate( + kind: AggregateKind, + column: YawnDef.YawnColumnDef, + ): Value = Value(ProjectionLeaf.Aggregate(kind, column)) + + fun aggregateAs( + kind: AggregateKind, + column: YawnDef.YawnColumnDef<*>, + ): Value = Value(ProjectionLeaf.Aggregate(kind, column)) + + fun rowCount(): Value = + Value(ProjectionLeaf.RowCount()) + + fun sql( + sqlExpression: String, + aliases: List, + resultTypes: List>, + ): Value = Value(ProjectionLeaf.Sql(sqlExpression, aliases, resultTypes)) + + fun constant(value: TO): Constant = Constant(value) + + fun mapped( + from: YawnProjector, + transform: (FROM) -> TO, + ): Mapped = Mapped(from, transform) + + fun composite( + a: YawnProjector, + b: YawnProjector, + mapper: (A, B) -> R, + ): Composite = Composite(listOf(a, b)) { values -> + @Suppress("UNCHECKED_CAST") + mapper(values[0] as A, values[1] as B) + } + + fun composite( + a: YawnProjector, + b: YawnProjector, + c: YawnProjector, + mapper: (A, B, C) -> R, + ): Composite = Composite(listOf(a, b, c)) { values -> + @Suppress("UNCHECKED_CAST") + mapper(values[0] as A, values[1] as B, values[2] as C) + } + + fun composite( + children: List>, + mapper: (List) -> R, + ): Composite = Composite(children, mapper) + } +} diff --git a/yawn-api/src/main/kotlin/com/faire/yawn/project/ProjectorResolver.kt b/yawn-api/src/main/kotlin/com/faire/yawn/project/ProjectorResolver.kt new file mode 100644 index 0000000..dba9ec7 --- /dev/null +++ b/yawn-api/src/main/kotlin/com/faire/yawn/project/ProjectorResolver.kt @@ -0,0 +1,53 @@ +package com.faire.yawn.project + +/** + * Yawn's projection resolution engine, which allows for complex nested projection arrangements that are then + * flattened into an ORM-friendly node structure. + * + * Walks a [ProjectionNode] tree and produces a [ResolvedProjection] by: + * - Flattening [ProjectionNode.Composite] nodes (their children become top-level) + * - Eliminating [ProjectionNode.Constant] nodes (no SQL, value folded into mapper) + * - Eliminating [ProjectionNode.Mapped] nodes (transform folded into mapper) + * - Keeping [ProjectionNode.Value] nodes in a flat list + * - Deduplicating identical [ProjectionLeaf] instances (same leaf shares an index) + * - Composing a [ProjectionMapper] that reconstructs the full result from the flat list + */ +class ProjectorResolver { + private val nodes = mutableListOf>() + private val dedupedLeafsToIndices = mutableMapOf, Int>() + + /** + * Resolves a [YawnProjector] into a [ResolvedProjection]. + */ + fun resolve(projector: YawnProjector): ResolvedProjection { + val mapper = resolveNode(projector.projection()) + return DefaultResolvedProjection(nodes.toList(), mapper) + } + + private fun resolveNode(node: ProjectionNode): ProjectionMapper { + return when (node) { + is ProjectionNode.Value -> resolveValue(node) + is ProjectionNode.Composite -> resolveComposite(node) + is ProjectionNode.Constant -> ProjectionMapper { node.value } + is ProjectionNode.Mapped -> resolveMapped(node) + } + } + + private fun resolveValue(node: ProjectionNode.Value): ProjectionMapper { + val idx = dedupedLeafsToIndices.getOrPut(node.leaf) { + nodes.add(node) + nodes.size - 1 + } + return ProjectionMapper { results -> node.mapper(results[idx]) } + } + + private fun resolveComposite(node: ProjectionNode.Composite): ProjectionMapper { + val childMappers = node.children.map { resolveNode(it.projection()) } + return ProjectionMapper { results -> node.mapper(childMappers.map { it.map(results) }) } + } + + private fun resolveMapped(node: ProjectionNode.Mapped): ProjectionMapper { + val innerMapper = resolveNode(node.from.projection()) + return ProjectionMapper { results -> node.transform(innerMapper.map(results)) } + } +} diff --git a/yawn-api/src/main/kotlin/com/faire/yawn/project/ResolvedProjection.kt b/yawn-api/src/main/kotlin/com/faire/yawn/project/ResolvedProjection.kt new file mode 100644 index 0000000..1ea9c09 --- /dev/null +++ b/yawn-api/src/main/kotlin/com/faire/yawn/project/ResolvedProjection.kt @@ -0,0 +1,35 @@ +package com.faire.yawn.project + +/** + * The result of resolving a [YawnProjector] through the [ProjectorResolver] engine. + * + * Contains a flat list of [ProjectionNode.Value] nodes (all composites, constants, and mapped + * transforms have been eliminated) and a function to map a result row back to the desired type. + * + * This interface is ORM-agnostic. The Query Factory is the one responsible for: + * - Compiling each node's [ProjectionLeaf] to the underlying implementation projection type; + * - Normalizing the ORM result row into a `List` matching the order of [nodes]; + * - Calling [mapRow] to reconstruct the projected type. + */ +interface ResolvedProjection { + /** + * The flat, deduped, re-ordered list of value nodes to compile into the query. + * The query factory must compile them _in this order_, and result values + * must be provided at matching indices when calling [mapRow]. + */ + val nodes: List> + + /** + * Maps a raw result row to the projected type. + * + * @param values raw results in the same order as [nodes]. + */ + fun mapRow(values: List): TO +} + +internal class DefaultResolvedProjection( + override val nodes: List>, + private val mapper: ProjectionMapper, +) : ResolvedProjection { + override fun mapRow(values: List): TO = mapper.map(values) +} diff --git a/yawn-api/src/main/kotlin/com/faire/yawn/project/YawnProjector.kt b/yawn-api/src/main/kotlin/com/faire/yawn/project/YawnProjector.kt new file mode 100644 index 0000000..d9709f4 --- /dev/null +++ b/yawn-api/src/main/kotlin/com/faire/yawn/project/YawnProjector.kt @@ -0,0 +1,28 @@ +package com.faire.yawn.project + +/** + * A type-safe projection descriptor that can be resolved into a flat list of + * [ProjectionNode.Value] nodes for query compilation. + * + * Implementations describe the shape of a projection by returning a [ProjectionNode] + * from [projection]. The [ProjectorResolver] resolution engine then walks this tree, + * flattening composites, eliminating constants and mapped transforms, deduplicating + * identical leaves, and producing a [ResolvedProjection] that the query factory can compile. + * + * @param SOURCE the type of the entity being queried. + * @param TO the result type of this projection. + */ +fun interface YawnProjector { + fun projection(): ProjectionNode +} + +/** + * A [YawnProjector] that is guaranteed to produce a [ProjectionNode.Value]. + * + * This subtype exists mostly so that modifiers like distinct can enforce at compile time + * that they only wrap single-value projections, not composites. But it can be used for other + * contexts as needed. + */ +fun interface YawnValueProjector : YawnProjector { + override fun projection(): ProjectionNode.Value +} diff --git a/yawn-api/src/test/kotlin/com/faire/yawn/project/ProjectorResolverTest.kt b/yawn-api/src/test/kotlin/com/faire/yawn/project/ProjectorResolverTest.kt new file mode 100644 index 0000000..02276b2 --- /dev/null +++ b/yawn-api/src/test/kotlin/com/faire/yawn/project/ProjectorResolverTest.kt @@ -0,0 +1,378 @@ +package com.faire.yawn.project + +import com.faire.yawn.YawnDef +import com.faire.yawn.project.AggregateKind.AVG +import com.faire.yawn.project.AggregateKind.COUNT +import com.faire.yawn.project.AggregateKind.COUNT_DISTINCT +import com.faire.yawn.project.AggregateKind.GROUP_BY +import com.faire.yawn.project.AggregateKind.MAX +import com.faire.yawn.project.AggregateKind.MIN +import com.faire.yawn.project.AggregateKind.SUM +import com.faire.yawn.project.ModifierKind.DISTINCT +import com.faire.yawn.query.YawnCompilationContext +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Test + +internal class ProjectorResolverTest { + private val def = TestDef() + private val nameCol = def.column("name") + private val nullableNameCol = def.column("name") + private val pagesCol = def.column("pages") + private val authorCol = def.column("author") + private val ratingCol = def.column("rating") + private val revenueCol = def.column("revenue") + + @Test + fun `single value projection`() { + val projector = YawnValueProjector { + ProjectionNode.property(nameCol) + } + + val resolved = resolve(projector) + + val leaf = resolved.nodes.single().leaf as ProjectionLeaf.Property + assertThat(leaf.column).isEqualTo(nameCol) + + assertThat(resolveAndMap(projector, "The Hobbit")).isEqualTo("The Hobbit") + } + + @Test + fun `aggregate value projection`() { + val projector = YawnValueProjector { + ProjectionNode.aggregate(MIN, ratingCol) + } + + val resolved = resolve(projector) + + val leaf = resolved.nodes.single().leaf as ProjectionLeaf.Aggregate + assertThat(leaf.kind).isEqualTo(MIN) + + assertThat(resolveAndMap(projector, 3.5)).isEqualTo(3.5) + } + + @Test + fun `constant projection produces no nodes`() { + val projector = YawnProjector { + ProjectionNode.constant(null) + } + + val resolved = resolve(projector) + assertThat(resolved.nodes).isEmpty() + assertThat(resolved.mapRow(listOf())).isNull() + } + + @Test + fun `constant with non-null value`() { + val projector = YawnProjector { + ProjectionNode.constant("hello") + } + + val resolved = resolve(projector) + assertThat(resolved.nodes).isEmpty() + assertThat(resolved.mapRow(listOf())).isEqualTo("hello") + } + + @Test + fun `mapped projection folds transform into mapper`() { + val inner = YawnValueProjector { + ProjectionNode.property(nullableNameCol) + } + val projector = YawnProjector { + ProjectionNode.mapped(inner) { it ?: "default" } + } + + val resolved = resolve(projector) + assertThat(resolved.nodes).hasSize(1) // mapped is eliminated, only the inner value remains + + assertThat(resolved.mapRow(listOf("present"))).isEqualTo("present") + assertThat(resolved.mapRow(listOf(null))).isEqualTo("default") + } + + @Test + fun `pair composite flattens into two nodes`() { + val projector = YawnProjector { + ProjectionNode.composite( + YawnValueProjector { ProjectionNode.property(nameCol) }, + YawnValueProjector { ProjectionNode.aggregate(SUM, pagesCol) }, + ) { a, b -> Pair(a, b) } + } + + val resolved = resolve(projector) + assertThat(resolved.nodes).hasSize(2) + + val result = resolved.mapRow(listOf("The Hobbit", 1_300L)) + assertThat(result).isEqualTo("The Hobbit" to 1_300L) + } + + @Test + fun `triple composite flattens into three nodes`() { + val projector = YawnProjector { + ProjectionNode.composite( + YawnValueProjector { ProjectionNode.property(nameCol) }, + YawnValueProjector { ProjectionNode.property(authorCol) }, + YawnValueProjector { ProjectionNode.aggregate(SUM, pagesCol) }, + ) { a, b, c -> Triple(a, b, c) } + } + + val resolved = resolve(projector) + assertThat(resolved.nodes).hasSize(3) + + val result = resolved.mapRow(listOf("The Hobbit", "Tolkien", 300L)) + assertThat(result).isEqualTo(Triple("The Hobbit", "Tolkien", 300L)) + } + + @Test + fun `deduplication of identical leaves`() { + val projector = YawnProjector { + ProjectionNode.composite( + YawnValueProjector { ProjectionNode.aggregate(SUM, revenueCol) }, + YawnValueProjector { ProjectionNode.aggregate(SUM, revenueCol) }, + ) { a, b -> Pair(a, b) } + } + + val resolved = resolve(projector) + assertThat(resolved.nodes).hasSize(1) // deduplicated! + + val result = resolved.mapRow(listOf(50_000L)) + assertThat(result).isEqualTo(50_000L to 50_000L) // both fields get the same value + } + + @Test + fun `different kinds on same column are NOT deduplicated`() { + val projector = YawnProjector { + ProjectionNode.composite( + YawnValueProjector { ProjectionNode.aggregate(COUNT_DISTINCT, pagesCol) }, + YawnValueProjector { ProjectionNode.aggregate(COUNT, pagesCol) }, + ) { a, b -> Pair(a, b) } + } + + val resolved = resolve(projector) + + // different kinds => different leaves + assertThat(resolved.nodes).hasSize(2) + } + + @Test + fun `modifier wraps leaf`() { + val projector = YawnValueProjector { + val from = ProjectionNode.property(authorCol) + ProjectionNode.Value(ProjectionLeaf.Modifier(DISTINCT, from.leaf), from.mapper) + } + + val resolved = resolve(projector) + + val leaf = resolved.nodes.single().leaf as ProjectionLeaf.Modifier + assertThat(leaf.kind).isEqualTo(DISTINCT) + assertThat(leaf.inner).isEqualTo(ProjectionLeaf.Property(authorCol)) + + assertThat(resolved.mapRow(listOf("Tolkien"))).isEqualTo("Tolkien") + } + + @Test + fun `row count projection`() { + val projector = YawnValueProjector { + ProjectionNode.rowCount() + } + + val resolved = resolve(projector) + + val leaf = resolved.nodes.single().leaf + assertThat(leaf).isInstanceOf(ProjectionLeaf.RowCount::class.java) + + assertThat(resolved.mapRow(listOf(42L))).isEqualTo(42L) + } + + @Test + fun `3-level nested composites with deduplication`() { + val projector = YawnProjector { + ProjectionNode.composite( + listOf( + // publisherName: groupBy + YawnValueProjector { + ProjectionNode.aggregate(GROUP_BY, nameCol) + }, + // topAuthorStats: nested composite + YawnProjector { + ProjectionNode.composite( + // authorInfo: pair(distinct(author), count(pages)) + { + ProjectionNode.composite( + YawnValueProjector { + ProjectionNode.Value( + ProjectionLeaf.Modifier(DISTINCT, ProjectionLeaf.Property(authorCol)), + ) + }, + YawnValueProjector { + ProjectionNode.aggregateAs(COUNT, pagesCol) + }, + ) { a, b -> Pair(a, b) } + }, + // pageStats: pair(avg(pages), max(pages)) + { + ProjectionNode.composite( + YawnValueProjector { + ProjectionNode.aggregateAs(AVG, pagesCol) + }, + YawnValueProjector { + ProjectionNode.aggregate(MAX, pagesCol) + }, + ) { a, b -> Pair(a, b) } + }, + ) { authorInfo, pageStats -> + AuthorSummary( + authorInfo = authorInfo, + pageStats = pageStats, + ) + } + }, + // totalRevenue: sum(revenue) + YawnValueProjector { + ProjectionNode.aggregate(SUM, revenueCol) + }, + // totalRevenueCheck: sum(revenue) (duplicate) + YawnValueProjector { + ProjectionNode.aggregate(SUM, revenueCol) + }, + ), + ) { values -> + @Suppress("UNCHECKED_CAST") + PublisherReport( + publisherName = values[0] as String, + topAuthorStats = values[1] as AuthorSummary, + totalRevenue = values[2] as Long, + totalRevenueCheck = values[3] as Long, + ) + } + } + + val resolved = resolve(projector) + + // Verify flat node list: 6 unique leaves (SUM(revenue) deduped) + assertThat(resolved.nodes).hasSize(6) + + // Verify node types in order + assertThat(resolved.nodes[0].leaf).isEqualTo(ProjectionLeaf.Aggregate(GROUP_BY, nameCol)) + assertThat(resolved.nodes[1].leaf) + .isEqualTo(ProjectionLeaf.Modifier(DISTINCT, ProjectionLeaf.Property(authorCol))) + assertThat(resolved.nodes[2].leaf).isEqualTo(ProjectionLeaf.Aggregate(COUNT, pagesCol)) + assertThat(resolved.nodes[3].leaf).isEqualTo(ProjectionLeaf.Aggregate(AVG, pagesCol)) + assertThat(resolved.nodes[4].leaf).isEqualTo(ProjectionLeaf.Aggregate(MAX, pagesCol)) + assertThat(resolved.nodes[5].leaf).isEqualTo(ProjectionLeaf.Aggregate(SUM, revenueCol)) + + // Verify result mapping with mock data + val result = resolved.mapRow( + listOf("Penguin", "Tolkien", 5L, 320.0, 1_000L, 50_000L), + ) + assertThat(result).isEqualTo( + PublisherReport( + publisherName = "Penguin", + topAuthorStats = AuthorSummary( + authorInfo = "Tolkien" to 5L, + pageStats = 320.0 to 1_000L, + ), + totalRevenue = 50_000L, + totalRevenueCheck = 50_000L, // same value from deduplicated index + ), + ) + } + + @Test + fun `composite with constants and mapped produces minimal nodes`() { + val nullablePagesCol = def.column("pages") + val projector = YawnProjector { + ProjectionNode.composite( + // real column + YawnValueProjector { ProjectionNode.property(nameCol) }, + // null constant; no SQL + { ProjectionNode.constant(null) }, + // coalesce (mapped) wrapping a column; one SQL column + { + ProjectionNode.mapped( + YawnValueProjector { + ProjectionNode.property(nullablePagesCol) + }, + ) { it ?: 0L } + }, + ) { a, b, c -> Triple(a, b, c) } + } + + val resolved = resolve(projector) + + assertThat(resolved.nodes).hasSize(2) // only the two real columns + + val result = resolved.mapRow(listOf("The Hobbit", null)) + assertThat(result).isEqualTo(Triple("The Hobbit", null, 0L)) // coalesce applied, constant is null + } + + @Test + fun `deeply nested mapped chains are all eliminated`() { + val projector = YawnProjector { + ProjectionNode.mapped( + { + ProjectionNode.mapped( + { + ProjectionNode.mapped( + YawnValueProjector { ProjectionNode.property(nameCol) }, + ) { it.lowercase() } + }, + ) { it.trim() } + }, + ) { it.uppercase() } + } + + val resolved = resolve(projector) + + assertThat(resolved.nodes).hasSize(1) // all mapped layers eliminated + + val result = resolved.mapRow(listOf(" Hello World ")) + // Transforms applied inside-out: lowercase -> trim -> uppercase + assertThat(result).isEqualTo("HELLO WORLD") + } + + @Test + fun `sql leaf projection`() { + val projector = YawnValueProjector { + ProjectionNode.sql( + sqlExpression = "LENGTH({alias}.name) AS name_length", + aliases = listOf("name_length"), + resultTypes = listOf(Long::class), + ) + } + + val resolved = resolve(projector) + + val leaf = resolved.nodes.single().leaf as ProjectionLeaf.Sql + assertThat(leaf.sqlExpression).isEqualTo("LENGTH({alias}.name) AS name_length") + + assertThat(resolved.mapRow(listOf(10L))).isEqualTo(10L) + } + + data class AuthorSummary( + val authorInfo: Pair, + val pageStats: Pair, + ) + + data class PublisherReport( + val publisherName: String, + val topAuthorStats: AuthorSummary, + val totalRevenue: Long, + val totalRevenueCheck: Long, + ) + + private class TestDef : YawnDef() { + fun column(name: String): YawnColumnDef = TestColumnDef(name) + + private inner class TestColumnDef(private val name: String) : YawnColumnDef() { + override fun generatePath(context: YawnCompilationContext): String = name + override fun toString(): String = "col($name)" + } + } + + private fun resolve(projector: YawnProjector): ResolvedProjection = + ProjectorResolver().resolve(projector) + + private fun resolveAndMap(projector: YawnProjector, vararg values: Any?): TO { + val resolved = resolve(projector) + return resolved.mapRow(listOf(*values)) + } +}