Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions docs/comparison.md
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,8 @@ JPA (typically implemented by Hibernate) is the most widely used persistence fra
| **Learning Curve** | Gentle; SQL-like | Steep; many concepts |
| **Magic** | What you see is what you get | Proxies, bytecode enhancement |

**Polymorphism differences.** Storm and JPA overlap on Single-Table and Joined Table, but diverge beyond that. Storm adds [Polymorphic FK](polymorphism.md#polymorphic-foreign-keys), a two-column foreign key (type + id) that references independent tables with no shared base. This has no JPA equivalent (Hibernate offers the non-standard `@Any` annotation for a similar purpose). JPA adds Table-per-Class, which duplicates all fields into per-subtype tables and queries the base type via `UNION ALL`, and multi-level inheritance (e.g., `Animal` → `Pet` → `Cat`). Storm intentionally limits hierarchies to a single sealed level, which covers the vast majority of real-world use cases while keeping SQL generation predictable.

### When to Choose Storm

- You want predictable, explicit database behavior
Expand Down
73 changes: 48 additions & 25 deletions docs/polymorphism.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,13 +60,16 @@ The following table summarizes the key differences between the three strategies.
| Aspect | Single-Table | Joined Table | Polymorphic FK |
|--------|-------------|-------------|----------------|
| **Tables** | One shared table | Base table + extension tables | Separate independent tables |
| **Discriminator** | In the shared table | In the base table | In the *referencing* entity |
| **Discriminator** | In the shared table | In the base table (optional<sup>1</sup>) | In the *referencing* entity |
| **Unused columns** | NULL for other subtypes | None (normalized) | None |
| **Query performance** | Fast (no JOINs) | Moderate (LEFT JOINs) | Variable (per-type lookup) |
| **Schema normalization** | Low | High | High |
| **FK from other entities** | Single column | Single column (to base) | Two columns (type + id) |
| **FK from other entities** | Single column | Single column (to base) | Two columns (type + id)<sup>2</sup> |
| **Adding subtypes** | Add columns to shared table | Add new extension table | Add new table |

<sup>1</sup> When `@Discriminator` is omitted, Storm resolves the concrete type at query time by generating an expression that checks which extension table has a matching row. See [The `@Discriminator` Annotation](#the-discriminator-annotation) for details.<br/>
<sup>2</sup> Because the subtypes are independent tables with no shared base table, a single FK column cannot identify both the target table and the target row. The discriminator column identifies the table, and the ID column identifies the row. See [Polymorphic Foreign Keys](#polymorphic-foreign-keys) for details.

Each strategy has strengths that make it the natural choice in certain scenarios. The sections below cover each one in detail.

---
Expand Down Expand Up @@ -129,7 +132,7 @@ The table below summarizes where `@Discriminator` can be placed, whether it is r
|---------|--------|-----------|---------|---------|
| Sealed interface | `TYPE` | **Yes** (Single-Table), Optional (Joined) | Set discriminator column name | `"dtype"` |
| Concrete subtype | `TYPE` | No | Set discriminator value | Simple class name |
| FK field (Polymorphic FK) | `RECORD_COMPONENT` | No | Set discriminator column in referencing table | `"{fieldName}_type"` |
| FK field (Polymorphic FK) | `FIELD` | No | Set discriminator column in referencing table | `"{fieldName}_type"` |

The following examples show how to apply the annotation in each context.

Expand All @@ -139,21 +142,27 @@ The following examples show how to apply the annotation in each context.
```kotlin
// On the sealed interface: required for Single-Table, optional for Joined Table
@Discriminator // uses default column name "dtype"
sealed interface Pet : Entity<Int>
sealed interface Pet : Entity<Int> {
val name: String
}

// Or with a custom column name
@Discriminator(column = "pet_type")
sealed interface Pet : Entity<Int>
sealed interface Pet : Entity<Int> {
val name: String
}

// Joined Table without @Discriminator: type is resolved via extension table PKs
@Polymorphic(JOINED)
sealed interface Pet : Entity<Int>
sealed interface Pet : Entity<Int> {
val name: String
}

// On a subtype: customize the discriminator value (optional)
@Discriminator("LARGE_DOG")
data class Dog(
@PK val id: Int = 0,
val name: String,
@PK override val id: Int = 0,
override val name: String,
val weight: Int
) : Pet
```
Expand All @@ -164,15 +173,21 @@ data class Dog(
```java
// On the sealed interface: required for Single-Table, optional for Joined Table
@Discriminator // uses default column name "dtype"
sealed interface Pet extends Entity<Integer> permits Cat, Dog {}
sealed interface Pet extends Entity<Integer> permits Cat, Dog {
String name();
}

// Or with a custom column name
@Discriminator(column = "pet_type")
sealed interface Pet extends Entity<Integer> permits Cat, Dog {}
sealed interface Pet extends Entity<Integer> permits Cat, Dog {
String name();
}

// Joined Table without @Discriminator: type is resolved via extension table PKs
@Polymorphic(JOINED)
sealed interface Pet extends Entity<Integer> permits Cat, Dog {}
sealed interface Pet extends Entity<Integer> permits Cat, Dog {
String name();
}

// On a subtype: customize the discriminator value (optional)
@Discriminator("LARGE_DOG")
Expand Down Expand Up @@ -645,17 +660,19 @@ With `@Discriminator`:
```kotlin
@Discriminator
@Polymorphic(JOINED)
sealed interface Pet : Entity<Int>
sealed interface Pet : Entity<Int> {
val name: String
}

data class Cat(
@PK val id: Int = 0,
val name: String,
@PK override val id: Int = 0,
override val name: String,
val indoor: Boolean
) : Pet

data class Dog(
@PK val id: Int = 0,
val name: String,
@PK override val id: Int = 0,
override val name: String,
val weight: Int
) : Pet
```
Expand All @@ -664,24 +681,26 @@ Without `@Discriminator`:

```kotlin
@Polymorphic(JOINED)
sealed interface Pet : Entity<Int>
sealed interface Pet : Entity<Int> {
val name: String
}

data class Cat(
@PK val id: Int = 0,
val name: String,
@PK override val id: Int = 0,
override val name: String,
val indoor: Boolean
) : Pet

data class Dog(
@PK val id: Int = 0,
val name: String,
@PK override val id: Int = 0,
override val name: String,
val weight: Int
) : Pet

// Bird has no extension fields, but still gets an extension table
data class Bird(
@PK val id: Int = 0,
val name: String
@PK override val id: Int = 0,
override val name: String
) : Pet
```

Expand All @@ -693,7 +712,9 @@ With `@Discriminator`:
```java
@Discriminator
@Polymorphic(JOINED)
sealed interface Pet extends Entity<Integer> permits Cat, Dog {}
sealed interface Pet extends Entity<Integer> permits Cat, Dog {
String name();
}

record Cat(@PK Integer id, String name, boolean indoor) implements Pet {}

Expand All @@ -704,7 +725,9 @@ Without `@Discriminator`:

```java
@Polymorphic(JOINED)
sealed interface Pet extends Entity<Integer> permits Cat, Dog, Bird {}
sealed interface Pet extends Entity<Integer> permits Cat, Dog, Bird {
String name();
}

record Cat(@PK Integer id, String name, boolean indoor) implements Pet {}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@
/**
* Wrapper element that marks an {@link Expression} as cacheable during template compilation.
*
* <p>Expressions are only allowed to appear in a template when they are part of a {@code WHERE} clause. When the
* <p>Expressions can appear in any clause that supports them, such as {@code WHERE} and {@code HAVING}. When the
* shared compilation logic encounters an expression, it is propagated as a {@code Cacheable} element.</p>
*
* <p>The wrapped expression is not rendered directly into SQL. It is included as part of the overall compilation key so
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
import st.orm.Ref;
import st.orm.core.template.SqlTemplateException;
import st.orm.core.template.TemplateString;
import st.orm.core.template.impl.BindHint.NoBindHint;
import st.orm.core.template.impl.Elements.ObjectExpression;
import st.orm.core.template.impl.Elements.TemplateExpression;

Expand Down Expand Up @@ -122,13 +123,16 @@ private static Class<?> getTypeShape(@Nonnull Object object) throws SqlTemplateE
* <p>This method is responsible for producing the compile-time representation of the element. It must not perform
* runtime binding. Any binding should be deferred to {@link #bind(Cacheable, TemplateBinder, BindHint)}.</p>
*
* @param object the element to compile.
* @param cacheable the element to compile.
* @param compiler the active compiler context.
* @return the compiled result for this element.
*/
@Override
public CompiledElement compile(@Nonnull Cacheable object, @Nonnull TemplateCompiler compiler) {
throw new UncheckedSqlTemplateException(new SqlTemplateException("Compilation not supported for expressions."));
public CompiledElement compile(@Nonnull Cacheable cacheable, @Nonnull TemplateCompiler compiler)
throws SqlTemplateException {
return new CompiledElement(
compiler.getQueryModel().compileExpression(cacheable.expression(), compiler),
NoBindHint.INSTANCE);
}

/**
Expand All @@ -138,12 +142,12 @@ public CompiledElement compile(@Nonnull Cacheable object, @Nonnull TemplateCompi
* parameters, registering bind variables, or applying runtime-only adjustments that must not affect the compiled
* SQL shape.</p>
*
* @param object the element that was compiled.
* @param cacheable the element that was compiled.
* @param binder the binder used to bind runtime values.
* @param bindHint the bind hint for the element, providing additional context for binding.
*/
@Override
public void bind(@Nonnull Cacheable object, @Nonnull TemplateBinder binder, @Nonnull BindHint bindHint) {
throw new UncheckedSqlTemplateException(new SqlTemplateException("Binding not supported for expressions."));
public void bind(@Nonnull Cacheable cacheable, @Nonnull TemplateBinder binder, @Nonnull BindHint bindHint) {
binder.getQueryModel().bindExpression(cacheable.expression(), binder);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
import static st.orm.core.template.impl.RecordReflection.getRecordFields;
import static st.orm.core.template.impl.RecordReflection.getRefDataType;
import static st.orm.core.template.impl.RecordReflection.isRecord;
import static st.orm.core.template.impl.RecordReflection.isSealedEntity;

import jakarta.annotation.Nonnull;
import jakarta.annotation.Nullable;
Expand Down Expand Up @@ -144,6 +145,17 @@ private static <T extends Data, E> Metamodel<T, E> getModel(@Nonnull Class<T> ro
if (path.isEmpty()) {
return (Metamodel<T, E>) root(rootTable);
}
// For sealed entity interfaces, delegate field resolution to the first permitted subclass.
// The sealed interface itself declares accessor methods but is not a record, so getRecordField()
// cannot inspect it directly. This mirrors the delegation pattern used by findPkField().
Class<? extends Data> fieldResolutionClass = rootTable;
if (rootTable.isSealed() && isSealedEntity(rootTable)) {
Class<?>[] permitted = rootTable.getPermittedSubclasses();
if (permitted != null && permitted.length > 0) {
//noinspection unchecked
fieldResolutionClass = (Class<? extends Data>) permitted[0];
}
}
Class<E> fieldType;
String effectivePath;
StringBuilder effectiveField;
Expand All @@ -154,7 +166,7 @@ private static <T extends Data, E> Metamodel<T, E> getModel(@Nonnull Class<T> ro
boolean fieldIsUnique;
boolean nullsDistinct;
try {
RecordField field = getRecordField(rootTable, path);
RecordField field = getRecordField(fieldResolutionClass, path);
declaringType = field.declaringType();
fieldNullable = field.nullable();
fieldIsUnique = field.isAnnotationPresent(UK.class) || field.isAnnotationPresent(PK.class);
Expand All @@ -180,7 +192,7 @@ private static <T extends Data, E> Metamodel<T, E> getModel(@Nonnull Class<T> ro
// Walk up until we hit the Data class boundary; everything below becomes part of field(), everything above
// (including the FK field) becomes path().
while (!effectivePath.isEmpty()) {
RecordField parent = getRecordField(rootTable, effectivePath);
RecordField parent = getRecordField(fieldResolutionClass, effectivePath);
if (Data.class.isAssignableFrom(parent.type())) {
break;
}
Expand All @@ -191,7 +203,7 @@ private static <T extends Data, E> Metamodel<T, E> getModel(@Nonnull Class<T> ro
throw new PersistenceException(e);
}
Metamodel<T, ? extends Data> rootModel = root(rootTable);
String tablePath = getTablePath(rootTable, effectivePath);
String tablePath = getTablePath(fieldResolutionClass, effectivePath);
String tableField = "";
if (!tablePath.isEmpty()
&& effectivePath.length() > tablePath.length()
Expand All @@ -204,7 +216,7 @@ private static <T extends Data, E> Metamodel<T, E> getModel(@Nonnull Class<T> ro
} else {
String tableModelPath = tablePath.isEmpty() ? "" : tablePath;
String tableModelField = tablePath.isEmpty() ? effectivePath : tableField;
Class<? extends Data> tableType = resolveDataTypeAtPath(rootTable, effectivePath);
Class<? extends Data> tableType = resolveDataTypeAtPath(fieldResolutionClass, effectivePath);
MethodHandle tableHandle = buildGetterHandle(rootTable, effectivePath);
tableModel = new SimpleMetamodel<>(
rootTable,
Expand Down Expand Up @@ -463,7 +475,7 @@ private static boolean getNullsDistinct(@Nonnull RecordField field) {
if (uk != null) return uk.nullsDistinct();
if (field.isAnnotationPresent(PK.class)) {
UK metamodelUk = PK.class.getAnnotation(UK.class);
return metamodelUk != null ? metamodelUk.nullsDistinct() : true;
return metamodelUk == null || metamodelUk.nullsDistinct();
}
return true;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -279,6 +279,17 @@ private static <T extends Data, ID> Model<T, ID> createSealedModel(@Nonnull Mode
// For JOINED pattern, extension-specific columns (not common across all subtypes,
// not PK) should not be insertable or updatable via the sealed (base table) model.
boolean extensionColumn = pattern == SealedPattern.JOINED && !isCommon && !isPk;
// Resolve a secondary metamodel rooted at the sealed type so that runtime metamodels
// (e.g., Metamodel.of(Animal.class, "name")) can be used in where clauses. This works
// both with generated metamodels and with the MetamodelFactory runtime path.
Metamodel<Data, ?> sealedFieldMetamodel = null;
try {
//noinspection unchecked
sealedFieldMetamodel = (Metamodel<Data, ?>) Metamodel.of(
(Class<? extends Data>) sealedType, field.name());
} catch (RuntimeException ignored) {
// Field not declared on the sealed interface (e.g., extension-specific field).
}
columns.add(new ColumnImpl(
columnName,
index.getAndIncrement(),
Expand All @@ -294,7 +305,7 @@ private static <T extends Data, ID> Model<T, ID> createSealedModel(@Nonnull Mode
field.isAnnotationPresent(Version.class),
false, // not ref
columnMetamodel,
null
sealedFieldMetamodel
));
fields.add(field);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -397,13 +397,17 @@ public void forEachValue(@Nonnull List<Column> columns,
public void forEachValue(@Nonnull Metamodel<E, ?> metamodel,
@Nonnull Object object,
@Nonnull BiConsumer<Column, Object> consumer) throws SqlTemplateException {
// For sealed entity models, all columns share the same metamodel, so getColumns() cannot
// distinguish between PK and non-PK columns. In WHERE clauses, the PK metamodel is always
// used, so resolve PK columns directly.
// For sealed entity models, columns originally share the same root metamodel, so
// getColumns() could not distinguish between PK and non-PK columns. When a generated
// metamodel is available (e.g., Animal_.name), its canonical form is registered via
// a secondary metamodel on the column, enabling field-specific resolution.
boolean resolvedViaInline = false;
List<Column> columns;
if (discriminatorColumnIndex > 0) {
columns = declaredColumns.stream().filter(Column::primaryKey).toList();
var mapped = metamodel.isColumn() ? columnMap.get(metamodel.canonical()) : null;
columns = mapped != null
? mapped
: declaredColumns.stream().filter(Column::primaryKey).toList();
} else if (metamodel.isInline() && columnMap.get(metamodel.canonical()) == null) {
// Inline record not directly in column map (e.g., Address). Fall back to inline resolution.
columns = getInlineColumns(metamodel);
Expand Down
Loading