Skip to main content
Version: 1.9.0

Projections

What Are Projections?

Projections are read-only data structures that represent database views or complex queries defined via @ProjectionQuery. Like entities, they are plain Kotlin data classes or Java records with no proxies and no bytecode manipulation. Unlike entities, projections support only read operations: no insert, update, or delete.

┌─────────────────────────────────────────────────────────────────────┐
│ Entity vs Projection │
├─────────────────────────────────────────────────────────────────────┤
│ │
│ Entity<ID> Projection<ID> │
│ ─────────── ────────────── │
│ - Full CRUD operations - Read-only operations │
│ - Represents a database table - Represents a query result │
│ - Primary key required - Primary key optional │
│ - Dirty checking supported - No dirty checking needed │
│ │
└─────────────────────────────────────────────────────────────────────┘

When to Use Projections

Database views: Represent database views or materialized views as first-class types in your application.

Complex reusable queries: Use @ProjectionQuery to define projections backed by complex SQL involving joins, aggregations, or subqueries that you want to reuse across your application.

For simple ad-hoc queries or one-off aggregations, prefer using a plain data class. Projections are best suited for reusable, view-like structures. See SQL Templates for details.


Defining a Projection

A projection is a data class (Kotlin) or record (Java) that implements Projection<ID>, where ID is the type of the primary key. Use Projection<Void> when the projection has no primary key.

Basic Projection with Primary Key

data class OwnerView(
@PK val id: Int,
val firstName: String,
val lastName: String,
val telephone: String?
) : Projection<Int>

Storm maps this projection to the owner table (derived from the class name) and selects only the specified columns.

Projection Without Primary Key

When a projection doesn't need a primary key (e.g., aggregation results), use Projection<Void>:

data class VisitSummary(
val visitDate: LocalDate,
val description: String?,
val petName: String
) : Projection<Void>

Projection with Foreign Keys

Projections can reference entities or other projections using @FK:

data class PetView(
@PK val id: Int,
val name: String,
@FK val owner: OwnerView // References another projection
) : Projection<Int>

Storm automatically joins the related table and populates the nested projection.

Projection with Custom SQL

Use @ProjectionQuery to define a projection backed by custom SQL:

@ProjectionQuery("""
SELECT b.id, COUNT(*) AS item_count, SUM(i.price) AS total_price
FROM basket b
JOIN basket_item bi ON bi.basket_id = b.id
JOIN item i ON i.id = bi.item_id
GROUP BY b.id
""")
data class BasketSummary(
@PK val id: Int,
val itemCount: Int,
val totalPrice: BigDecimal
) : Projection<Int>

This is useful for aggregations, complex joins, or mapping database views.


Querying Projections

Getting a ProjectionRepository

Obtain a ProjectionRepository from the ORM template. This is the read-only counterpart to EntityRepository. It provides find, select, count, and existence-check operations, but no insert, update, or delete.

val ownerViews = orm.projection(OwnerView::class)

Basic Operations

The ProjectionRepository supports the same query patterns as EntityRepository, minus write operations. Results are plain data objects with no proxy behavior or session attachment.

// Count all
val count = ownerViews.count()

// Find by primary key (returns null if not found)
val owner = ownerViews.findById(1)

// Get by primary key (throws if not found)
val owner = ownerViews.getById(1)

// Check existence
val exists = ownerViews.existsById(1)

// Fetch all as a list
val allOwners = ownerViews.findAll()

// Fetch all as a lazy stream
ownerViews.selectAll().forEach { owner ->
println(owner.firstName)
}

Query Builder

Use the select() method for type-safe queries with the generated metamodel:

// Filter by field value
val owners = ownerViews.select()
.where(OwnerView_.lastName, EQUALS, "Smith")
.getResultList()

// Filter with comparison operators
val recentVisits = orm.projection(VisitView::class).select()
.where(VisitView_.visitDate, GREATER_THAN, LocalDate.of(2024, 1, 1))
.getResultList()

// Filter by nested foreign key
val ownerPets = orm.projection(PetView::class).select()
.where(PetView_.owner.id, EQUALS, 1)
.getResultList()

// Count with filter
val count = ownerViews.selectCount()
.where(OwnerView_.lastName, EQUALS, "Smith")
.getSingleResult()

Batch Operations

Efficiently fetch multiple projections by ID:

// Fetch multiple by IDs
val ids = listOf(1, 2, 3)
val owners = ownerViews.findAllById(ids)

// Flow-based batch fetching (lazy evaluation)
val idFlow = flowOf(1, 2, 3, 4, 5)
ownerViews.selectById(idFlow).collect { owner ->
// Process each owner
}

Projections vs Entities: Choosing the Right Tool

┌─────────────────────────────────────────────────────────────────────┐
│ When to Use What │
├─────────────────────────────────────────────────────────────────────┤
│ │
│ Use Entity when you need to: │
│ • Create, update, or delete records │
│ • Work with the full row including all columns │
│ • Leverage dirty checking and optimistic locking │
│ • Maintain referential integrity through the ORM │
│ │
│ Use Projection when you need to: │
│ • Map database views or materialized views │
│ • Define reusable complex queries via @ProjectionQuery │
│ │
└─────────────────────────────────────────────────────────────────────┘

Example: Same Table, Different Views

// Full entity for writes
data class Owner(
@PK val id: Int = 0,
val firstName: String,
val lastName: String,
val address: String,
val city: String,
val telephone: String?,
@Version val version: Int = 0
) : Entity<Int>

// Lightweight projection for list views
data class OwnerListItem(
@PK val id: Int,
val firstName: String,
val lastName: String
) : Projection<Int>

// Detailed projection for detail views
data class OwnerDetail(
@PK val id: Int,
val firstName: String,
val lastName: String,
val address: String,
val city: String,
val telephone: String?
) : Projection<Int>

Use Owner when creating or updating owners. Use OwnerListItem for displaying a list (fewer columns, faster queries). Use OwnerDetail for read-only detail views.


Working with Refs

When a projection references another entity or projection but you do not need the full related object in every query, use Ref<T> to store only the foreign key value. This avoids the cost of an additional JOIN when you only need the key. You can resolve the reference later by fetching the full object on demand.

data class PetListItem(
@PK val id: Int,
val name: String,
@FK val owner: Ref<OwnerView> // Lightweight reference
) : Projection<Int>

The Ref contains only the foreign key value. You can resolve it later if needed:

val pet = orm.projection(PetListItem::class).getById(1)

// Access the foreign key without loading the owner
val ownerId = pet.owner.id()

// Load the full owner when needed
val owner = orm.projection(OwnerView::class).getById(ownerId)

Mapping to Custom Tables

By default, Storm derives the table name from the projection class name. Override this with @DbTable:

@DbTable("owner")
data class OwnerSummary(
@PK val id: Int,
@DbColumn("first_name") val name: String
) : Projection<Int>

Use @DbColumn to map fields to columns with different names.


ProjectionRepository Methods

MethodDescription
count()Count all projections
findById(id)Find by primary key, returns null if not found
getById(id)Get by primary key, throws if not found
existsById(id)Check if projection exists
findAll()Fetch all as a list
findAllById(ids)Fetch multiple by IDs
selectAll()Lazy Flow of all projections
selectById(ids)Lazy Flow by IDs
select()Query builder for filtering
selectCount()Query builder for counting

Note: Unlike EntityRepository, there are no insert, update, delete, or upsert methods. Projections are read-only.


Best Practices

1. Keep Projections Focused

Design projections for specific use cases rather than trying to reuse one projection everywhere:

// Good: Purpose-built projections
data class OwnerDropdownItem(
@PK val id: Int,
val displayName: String // Computed: firstName + lastName
) : Projection<Int>

data class OwnerSearchResult(
@PK val id: Int,
val firstName: String,
val lastName: String,
val city: String
) : Projection<Int>

// Avoid: One projection trying to serve all purposes
data class OwnerProjection(
@PK val id: Int,
val firstName: String,
val lastName: String,
val address: String?, // Sometimes null, sometimes not
val city: String?,
val telephone: String?,
val petCount: Int? // Only populated in some queries
) : Projection<Int>

2. Use @ProjectionQuery for Complex Queries

When your projection involves joins, aggregations, or subqueries, define the SQL explicitly:

@ProjectionQuery("""
SELECT
o.id,
o.first_name,
o.last_name,
COUNT(p.id) AS pet_count
FROM owner o
LEFT JOIN pet p ON p.owner_id = o.id
GROUP BY o.id, o.first_name, o.last_name
""")
data class OwnerWithPetCount(
@PK val id: Int,
val firstName: String,
val lastName: String,
val petCount: Int
) : Projection<Int>

3. Prefer Projections for Read-Heavy Paths

In read-heavy scenarios (dashboards, lists, search results), projections reduce database load:

// Instead of loading full entities
val owners = orm.entity(Owner::class).findAll() // Loads all columns

// Load only what you need
val owners = orm.projection(OwnerListItem::class).findAll() // Loads 3 columns

4. Use Void for Keyless Results

Aggregations and analytics often don't have a natural primary key:

@ProjectionQuery("""
SELECT
DATE_TRUNC('month', visit_date) AS month,
COUNT(*) AS visit_count,
COUNT(DISTINCT pet_id) AS unique_pets
FROM visit
GROUP BY DATE_TRUNC('month', visit_date)
""")
data class MonthlyVisitStats(
val month: LocalDate,
val visitCount: Int,
val uniquePets: Int
) : Projection<Void> // No primary key

5. Combine with Entity Graphs

For complex object graphs, you can mix projections with entity relationships:

data class PetWithOwnerSummary(
@PK val id: Int,
val name: String,
val birthDate: LocalDate?,
@FK val owner: OwnerListItem // Projection, not full entity
) : Projection<Int>

This fetches pet details with a lightweight owner summary in a single query.