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
4 changes: 2 additions & 2 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -678,8 +678,8 @@ data class Article(
@DynamicUpdate(FIELD)
record Article(
@PK Integer id,
@Nonnull String title,
@Nonnull String content
String title,
String content
) implements Entity<Integer> {}
```

Expand Down
4 changes: 2 additions & 2 deletions docs/dirty-checking.md
Original file line number Diff line number Diff line change
Expand Up @@ -309,8 +309,8 @@ data class User(
```java
@DynamicUpdate(FIELD)
record User(@PK Integer id,
@Nonnull String email,
@Nonnull String name,
String email,
String name,
@FK City city
) implements Entity<Integer> {}
```
Expand Down
76 changes: 38 additions & 38 deletions docs/entities.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ record User(@PK Integer id,
String email,
LocalDate birthDate,
String street,
String postalCode,
@Nullable String postalCode,
@FK City city
) implements Entity<Integer> {}
```
Expand Down Expand Up @@ -86,13 +86,13 @@ data class User(
</TabItem>
<TabItem value="java" label="Java">

In Java, record components are nullable by default. Use `@Nonnull` to mark fields that must always have a value — Storm recognizes `jakarta.annotation.Nonnull`, `javax.annotation.Nonnull`, and JSpecify's `org.jspecify.annotations.NonNull` on the component; other null-marker annotations (Lombok, JetBrains, Spring) are not recognized, and JSpecify `@NullMarked` scope defaults are not applied. `@PK` fields and primitive types (`int`, `long`, etc.) are inherently non-nullable. As with Kotlin, nullability determines JOIN behavior: a non-nullable `@FK` field produces an `INNER JOIN`, while a nullable one — including a bare `@FK` field without `@Nonnull` — produces a `LEFT JOIN`. If the FK column is `NOT NULL` in the database, annotate the field with `@Nonnull`, or joins silently degrade to `LEFT JOIN`.
In Java, record components are non-null by default, exactly like Kotlin: `String` means a value is always present, and nullable is the marked case. Mark nullable fields with `@Nullable` — Storm recognizes JSpecify's `org.jspecify.annotations.Nullable` (as a type-use annotation), `jakarta.annotation.Nullable`, and `javax.annotation.Nullable`. This matches JSpecify's `@NullMarked` semantics: annotating your model package `@NullMarked` is welcome documentation for static checkers, and `@NullUnmarked` on a class or package opts back into lenient, nullable-by-default components for that scope. As with Kotlin, nullability determines JOIN behavior: a non-nullable `@FK` field — including a bare, unannotated one — produces an `INNER JOIN`, while a `@Nullable @FK` field produces a `LEFT JOIN`. If the FK column allows `NULL` in the database, annotate the field `@Nullable`, or rows without the reference are silently filtered by the inner join.

```java
record User(@PK Integer id,
@Nonnull String email, // Non-nullable
@Nonnull LocalDate birthDate, // Non-nullable
String postalCode, // Nullable (default)
String email, // Non-nullable (default)
LocalDate birthDate, // Non-nullable (default)
@Nullable String postalCode, // Nullable
@Nullable @FK City city // Nullable (results in LEFT JOIN)
) implements Entity<Integer> {}
```
Expand Down Expand Up @@ -163,7 +163,7 @@ Use `NONE` when:

```java
record User(@PK Integer id, // Database generates via auto-increment
@Nonnull String name
String name
) implements Entity<Integer> {}
```

Expand All @@ -178,7 +178,7 @@ var inserted = orm.entity(User.class).insert(user); // Returns User with genera

```java
record Order(@PK(generation = SEQUENCE, sequence = "order_seq") Long id,
@Nonnull BigDecimal total
BigDecimal total
) implements Entity<Long> {}
```

Expand All @@ -188,7 +188,7 @@ Storm fetches the next value from the sequence before inserting.

```java
record Country(@PK(generation = NONE) String code, // Caller provides the value
@Nonnull String name
String name
) implements Entity<String> {}
```

Expand Down Expand Up @@ -229,8 +229,8 @@ data class UserRole(
record UserRolePk(int userId, int roleId) {}

record UserRole(@PK UserRolePk userRolePk,
@Nonnull @FK User user,
@Nonnull @FK Role role
@FK User user,
@FK Role role
) implements Entity<UserRolePk> {}
```

Expand Down Expand Up @@ -330,8 +330,8 @@ data class SomeEntity(
record UserEmailUk(int userId, String email) {}

record SomeEntity(@PK Integer id,
@Nonnull @FK User user,
@Nonnull String email,
@FK User user,
String email,
@UK @Persist(insertable = false, updatable = false) UserEmailUk uniqueKey
) implements Entity<Integer> {}
```
Expand Down Expand Up @@ -377,13 +377,13 @@ data class Owner(
Use records for embedded components:

```java
record Address(String street,
@FK City city) {}
record Address(@Nullable String street,
@Nullable @FK City city) {}

record Owner(@PK Integer id,
@Nonnull String firstName,
@Nonnull String lastName,
@Nonnull Address address,
String firstName,
String lastName,
Address address,
@Nullable String telephone
) implements Entity<Integer> {}
```
Expand Down Expand Up @@ -419,9 +419,9 @@ In this example, the `owner` and `city` foreign keys define the actual persisted
record OwnerCityKey(int ownerId, int cityId) {}

record Pet(@PK Integer id,
@Nonnull String name,
@Nonnull @FK Owner owner,
@Nonnull @FK City city,
String name,
@FK Owner owner,
@FK City city,
@Persist(insertable = false, updatable = false) OwnerCityKey ownerCityKey
) implements Entity<Integer> {}
```
Expand Down Expand Up @@ -477,17 +477,17 @@ enum RoleType {
}

record Role(@PK Integer id,
@Nonnull String name,
@Nonnull RoleType type // Stored as "USER" or "ADMIN"
String name,
RoleType type // Stored as "USER" or "ADMIN"
) implements Entity<Integer> {}
```

To store by ordinal:

```java
record Role(@PK Integer id,
@Nonnull String name,
@Nonnull @DbEnum(ORDINAL) RoleType type // Stored as 0 or 1
String name,
@DbEnum(ORDINAL) RoleType type // Stored as 0 or 1
) implements Entity<Integer> {}
```

Expand Down Expand Up @@ -522,7 +522,7 @@ record Money(BigDecimal amount) {}

@DbTable("product")
record Product(@PK Integer id,
@Nonnull String name,
String name,
@Convert(converter = MoneyConverter.class) Money price
) implements Entity<Integer> {}
```
Expand Down Expand Up @@ -571,8 +571,8 @@ Use `@Version` for optimistic locking:

```java
record Owner(@PK Integer id,
@Nonnull String firstName,
@Nonnull String lastName,
String firstName,
String lastName,
@Version int version
) implements Entity<Integer> {}
```
Expand All @@ -581,10 +581,10 @@ Timestamps are also supported:

```java
record Visit(@PK Integer id,
@Nonnull LocalDate visitDate,
LocalDate visitDate,
@Nullable String description,
@Nonnull @FK Pet pet,
@Version Instant timestamp
@FK Pet pet,
@Nullable @Version Instant timestamp
) implements Entity<Integer> {}
```

Expand Down Expand Up @@ -619,9 +619,9 @@ Use `@Persist(updatable = false)` for fields that should only be set on insert:

```java
record Pet(@PK Integer id,
@Nonnull String name,
@Nonnull @Persist(updatable = false) LocalDate birthDate,
@Nonnull @FK @Persist(updatable = false) PetType type,
String name,
@Persist(updatable = false) LocalDate birthDate,
@FK @Persist(updatable = false) PetType type,
@Nullable @FK Owner owner
) implements Entity<Integer> {}
```
Expand All @@ -638,8 +638,8 @@ Since Storm entities are immutable, updating a field means creating a new instan
```java
@Builder(toBuilder = true)
record User(@PK Integer id,
@Nonnull String email,
@Nonnull String name,
String email,
String name,
@FK City city
) implements Entity<Integer> {}
```
Expand Down Expand Up @@ -802,14 +802,14 @@ data class User(
// Suppress all schema validation for a legacy entity.
@DbIgnore
record LegacyUser(@PK Integer id,
@Nonnull String name
String name
) implements Entity<Integer> {}

// Suppress schema validation for a specific field.
record User(@PK Integer id,
@Nonnull String name,
String name,
@DbIgnore("DB uses FLOAT, but column only stores whole numbers")
@Nonnull Integer age
Integer age
) implements Entity<Integer> {}
```

Expand Down
2 changes: 1 addition & 1 deletion docs/faq.md
Original file line number Diff line number Diff line change
Expand Up @@ -110,7 +110,7 @@ var updated = new User(user.id(), "new@example.com", user.name(), user.city());
**Custom wither methods:** Define `with*` methods on the record that return a new instance with a single field changed. Clean API but requires a method per field.

```java
record User(@PK Integer id, @Nonnull String email, @Nonnull String name, @FK City city
record User(@PK Integer id, String email, String name, @FK City city
) implements Entity<Integer> {
User withEmail(String email) { return new User(id, email, name, city); }
}
Expand Down
2 changes: 1 addition & 1 deletion docs/first-entity.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ record User(@PK Integer id,
) implements Entity<Integer> {}
```

In Java, record components are nullable by default. Use `@Nonnull` on fields that must always have a value. Primitive types (`int`, `long`, etc.) are inherently non-nullable.
In Java, record components are non-null by default, exactly like Kotlin. Mark nullable fields with `@Nullable` (JSpecify's `org.jspecify.annotations.Nullable` or `jakarta.annotation.Nullable`); see [Defining Entities](entities.md) for the full nullability rules.

The `@Builder` annotation is from [Lombok](https://projectlombok.org/) and is optional. It generates a builder that lets you construct entities without specifying the primary key, and creates modified copies via `toBuilder()`. Without Lombok, you can pass `null` as the primary key (e.g., `new City(null, "Sunnyvale", 161_884)`) or define a convenience constructor that omits it. See [Modifying Entities](entities.md#modifying-entities) for details.

Expand Down
6 changes: 4 additions & 2 deletions docs/hydration.md
Original file line number Diff line number Diff line change
Expand Up @@ -564,16 +564,18 @@ If a non-nullable field receives NULL from the database, Storm throws an excepti
</TabItem>
<TabItem value="java" label="Java">

Use `@Nonnull` and `@Nullable` annotations:
Record components are non-null by default, exactly like Kotlin. Mark nullable fields with `@Nullable`:

```java
record User(
int id, // Primitive = non-nullable
@Nonnull String email, // Non-nullable
String email, // Non-nullable (default)
@Nullable String nickname // Nullable
) {}
```

If a non-nullable field receives NULL from the database, Storm throws an exception.

</TabItem>
</Tabs>

Expand Down
16 changes: 8 additions & 8 deletions docs/metamodel.md
Original file line number Diff line number Diff line change
Expand Up @@ -414,8 +414,8 @@ data class SomeEntity(
record UserEmailUk(int userId, String email) {}

record SomeEntity(@PK Integer id,
@Nonnull @FK User user,
@Nonnull String email,
@FK User user,
String email,
@UK @Persist(insertable = false, updatable = false) UserEmailUk uniqueKey
) implements Entity<Integer> {}
```
Expand Down Expand Up @@ -549,7 +549,7 @@ In standard SQL, `NULL != NULL`. This means a `UNIQUE` constraint typically allo

Because of this, Storm validates nullable unique keys at two levels:

1. **Compile-time warning.** The metamodel processor emits a warning when a `@UK` field is nullable (a nullable type in Kotlin, or a reference type without `@Nonnull` in Java) and the default `nullsDistinct = true` applies.
1. **Compile-time warning.** The metamodel processor emits a warning when a `@UK` field is nullable (a nullable type in Kotlin, a `@Nullable` reference type in Java, or any reference type in a `@NullUnmarked` scope) and the default `nullsDistinct = true` applies.
2. **Runtime check.** The `scroll` method throws a `PersistenceException` if the key's metamodel indicates that nulls are distinct for a nullable field, preventing silent data loss.

Database behavior varies. Some databases offer stricter NULL handling for unique constraints:
Expand All @@ -562,10 +562,10 @@ The `@UK` annotation provides a `nullsDistinct` attribute to control this behavi

| Field | `nullsDistinct` | Effect |
|-------|-----------------|--------|
| `@UK @Nonnull String email` | (irrelevant) | Safe. No warning, no runtime check. |
| `@UK String email` | (irrelevant) | Safe. Non-null by default. No warning, no runtime check. |
| `@UK int count` | (irrelevant) | Safe. Primitive is never null. |
| `@UK String email` | `true` (default) | Compile-time warning. `scroll` throws `PersistenceException`. |
| `@UK(nullsDistinct = false) String email` | `false` | No warning. `scroll` works (user asserts DB prevents duplicate NULLs). |
| `@UK @Nullable String email` | `true` (default) | Compile-time warning. `scroll` throws `PersistenceException`. |
| `@UK(nullsDistinct = false) @Nullable String email` | `false` | No warning. `scroll` works (user asserts DB prevents duplicate NULLs). |

When `nullsDistinct` is set to `false`, you are telling Storm that your database constraint prevents duplicate `NULL` values in the column. Storm trusts this assertion and skips both the compile-time warning and the runtime check. Use this only when your database actually enforces this guarantee (for example, with a `NULLS NOT DISTINCT` unique index in PostgreSQL 15+, or on SQL Server where unique indexes allow at most one `NULL` by default).

Expand Down Expand Up @@ -596,13 +596,13 @@ data class User(
```java
// Safe (non-nullable)
record User(@PK Integer id,
@UK @Nonnull String email, // Non-nullable, safe for scrolling
@UK String email, // Non-null by default, safe for scrolling
String name
) implements Entity<Integer> {}

// Opt-in for nullable keys
record User(@PK Integer id,
@UK(nullsDistinct = false) String email, // DB prevents duplicate NULLs
@UK(nullsDistinct = false) @Nullable String email, // DB prevents duplicate NULLs
String name
) implements Entity<Integer> {}
```
Expand Down
4 changes: 2 additions & 2 deletions docs/migration-from-jpa.md
Original file line number Diff line number Diff line change
Expand Up @@ -77,8 +77,8 @@ Storm derives the table name (`user`) from the class name and column names (`ema
```java
record User(
@PK Long id,
@Nonnull String email,
@Nonnull String name,
String email,
String name,
@Nullable @FK City city,
@Nullable LocalDateTime createdAt
) implements Entity<Long> {}
Expand Down
30 changes: 15 additions & 15 deletions docs/projections.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,8 +60,8 @@ data class OwnerView(
@DbTable("owner")
record OwnerView(
@PK Integer id,
@Nonnull String firstName,
@Nonnull String lastName,
String firstName,
String lastName,
@Nullable String telephone
) implements Projection<Integer> {}
```
Expand Down Expand Up @@ -91,9 +91,9 @@ data class VisitSummary(

```java
record VisitSummary(
@Nonnull LocalDate visitDate,
LocalDate visitDate,
@Nullable String description,
@Nonnull String petName
String petName
) implements Projection<Void> {}
```

Expand Down Expand Up @@ -124,7 +124,7 @@ data class PetView(
```java
@DbTable("pet")
record PetView(@PK Integer id,
@Nonnull String name,
String name,
@FK OwnerView owner // References another projection
) implements Projection<Integer> {}
```
Expand Down Expand Up @@ -419,28 +419,28 @@ data class OwnerDetail(
```java
// Full entity for writes
record Owner(@PK Integer id,
@Nonnull String firstName,
@Nonnull String lastName,
@Nonnull String address,
@Nonnull String city,
String firstName,
String lastName,
String address,
String city,
@Nullable String telephone,
@Version int version
) implements Entity<Integer> {}

// Lightweight projection for list views
@DbTable("owner")
record OwnerListItem(@PK Integer id,
@Nonnull String firstName,
@Nonnull String lastName
String firstName,
String lastName
) implements Projection<Integer> {}

// Detailed projection for detail views
@DbTable("owner")
record OwnerDetail(@PK Integer id,
@Nonnull String firstName,
@Nonnull String lastName,
@Nonnull String address,
@Nonnull String city,
String firstName,
String lastName,
String address,
String city,
@Nullable String telephone
) implements Projection<Integer> {}
```
Expand Down
Loading
Loading