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: 3 additions & 1 deletion docs/entities.md
Original file line number Diff line number Diff line change
Expand Up @@ -736,7 +736,9 @@ record Order(@PK Integer id,

## Suppressing Schema Validation

Use `@DbIgnore` to suppress [schema validation](configuration.md#schema-validation) for an entity or a specific field. This is useful for legacy tables, columns handled by [custom converters](converters.md), or known type mismatches that are safe at runtime.
To suppress constraint-specific warnings (missing primary key, foreign key, or unique constraint), use the `constraint` attribute on `@PK`, `@FK`, or `@UK`. This is more targeted than `@DbIgnore` because it only suppresses the constraint check while preserving all other validation (column existence, type compatibility, nullability). See [Constraint Validation](validation.md#constraint-validation) for details and examples.

Use `@DbIgnore` to suppress [schema validation](configuration.md#schema-validation) for an entity or a specific field entirely. This is useful for legacy tables, columns handled by [custom converters](converters.md), or known type mismatches that are safe at runtime.

<Tabs groupId="language">
<TabItem value="kotlin" label="Kotlin" default>
Expand Down
52 changes: 49 additions & 3 deletions docs/validation.md
Original file line number Diff line number Diff line change
Expand Up @@ -72,10 +72,12 @@ Schema validation compares your entity and projection definitions against the ac
| Each mapped column exists in the table | `COLUMN_NOT_FOUND` | Error |
| Kotlin/Java type is compatible with the SQL column type | `TYPE_INCOMPATIBLE` | Error |
| Entity primary key columns match the database primary key | `PRIMARY_KEY_MISMATCH` | Error |
| `@FK` constraint references the correct target table | `FOREIGN_KEY_MISMATCH` | Error |
| Sequences referenced by `@PK(generation = SEQUENCE)` exist | `SEQUENCE_NOT_FOUND` | Error |
| | | |
| Numeric cross-category conversions (e.g., `Integer` mapped to `DECIMAL`) | `TYPE_NARROWING` | Warning |
| Non-nullable entity field mapped to a nullable database column | `NULLABILITY_MISMATCH` | Warning |
| Entity declares `@PK` but the database has no primary key constraint | `PRIMARY_KEY_MISSING` | Warning |
| `@UK` field has a matching unique constraint in the database | `UNIQUE_KEY_MISSING` | Warning |
| `@FK` field has a matching foreign key constraint in the database | `FOREIGN_KEY_MISSING` | Warning |

Expand All @@ -85,16 +87,60 @@ Schema validation compares your entity and projection definitions against the ac

#### Constraint Validation

The `UNIQUE_KEY_MISSING` and `FOREIGN_KEY_MISSING` checks verify that the database has the constraints your entity model declares. These are warnings rather than errors because the ORM functions correctly without database-level enforcement: queries return the same results, inserts and updates succeed, and keyset pagination works as expected.
Schema validation checks that the database has the constraints your entity model declares. There are two categories of constraint findings:

**Mismatches (errors)** occur when a constraint exists in the database but contradicts the entity definition. For example, if `@FK val city: City` expects a foreign key referencing the `city` table, but the database has a foreign key on that column referencing the `account` table, that is a `FOREIGN_KEY_MISMATCH`. Similarly, if the entity declares `@PK` with columns `(id)` but the database primary key is `(user_id, role_id)`, that is a `PRIMARY_KEY_MISMATCH`. Mismatches are always hard errors because they indicate a bug in the entity definition.

**Missing constraints (warnings)** occur when the database has no constraint at all for a declared `@PK`, `@FK`, or `@UK` field. These are warnings rather than errors because the ORM functions correctly without database-level enforcement: queries return the same results, inserts and updates succeed, and keyset pagination works as expected.

However, database constraints serve as a safety net that the application layer cannot replace:

- **Primary key constraints** ensure row uniqueness at the database level. Without one, duplicate primary key values could be inserted by other applications or direct SQL.
- **Unique constraints** protect against application bugs and concurrent modifications that could insert duplicate values. Without a database-level unique constraint, a `@UK` field might contain duplicates that go undetected until a `findBy` call unexpectedly returns multiple results.
- **Foreign key constraints** protect referential integrity. Without a database-level foreign key constraint, orphaned rows can accumulate when referenced rows are deleted.

In [strict mode](#strict-mode), these warnings are promoted to errors, causing validation to fail if the constraints are missing. Use `@DbIgnore` to suppress these warnings for fields where the missing constraint is intentional (for example, when using application-level deduplication or soft deletes that make database constraints impractical).
##### Suppressing Constraint Warnings

When the database intentionally omits a constraint (for performance, for views, or because integrity is enforced at the application level), use the `constraint` attribute to suppress the warning for that specific field:

<Tabs groupId="language">
<TabItem value="kotlin" label="Kotlin" default>

```kotlin
// No FK constraint for performance reasons.
data class Order(
@PK val id: Int = 0,
@FK(constraint = false) val customer: Customer
) : Entity<Int>

// No unique index in the database.
data class User(
@PK val id: Int = 0,
@UK(constraint = false) val email: String
) : Entity<Int>
```

</TabItem>
<TabItem value="java" label="Java">

```java
// No FK constraint for performance reasons.
record Order(@PK Integer id,
@FK(constraint = false) Customer customer
) implements Entity<Integer> {}

// No unique index in the database.
record User(@PK Integer id,
@UK(constraint = false) String email
) implements Entity<Integer> {}
```

</TabItem>
</Tabs>

Setting `constraint = false` only suppresses the "missing" warning. If the database *does* have a constraint that contradicts the entity definition (a mismatch), it is always reported as a hard error regardless of this flag.

Use `@DbIgnore` to exclude entity fields from schema validation. See [Entities](entities.md) for annotation details.
In [strict mode](#strict-mode), missing constraint warnings are promoted to errors, causing validation to fail. The `constraint = false` flag takes precedence: fields marked with it are excluded from validation even in strict mode.

### Programmatic API

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -47,10 +47,14 @@ public enum ErrorKind {
NULLABILITY_MISMATCH(true),
/** The primary key columns in the entity do not match the database primary key. */
PRIMARY_KEY_MISMATCH,
/** The entity declares a {@code @PK} but the database table has no primary key constraint. @since 1.10 */
PRIMARY_KEY_MISSING(true),
/** A sequence referenced by the entity does not exist in the database. */
SEQUENCE_NOT_FOUND,
/** A {@code @UK} field does not have a matching unique constraint in the database. */
UNIQUE_KEY_MISSING(true),
/** A {@code @FK} field has a foreign key constraint that references a different table than expected. @since 1.10 */
FOREIGN_KEY_MISMATCH,
/** A {@code @FK} field does not have a matching foreign key constraint in the database. */
FOREIGN_KEY_MISSING(true);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ private static String formatMessage(@Nonnull List<SchemaValidationError> errors)
return "Schema validation failed with %d error(s):\n".formatted(errors.size())
+ errors.stream()
.map(error -> " - " + error.toString())
.collect(Collectors.joining("\n"));
.collect(Collectors.joining("\n"))
+ "\nIf intentional, use @DbIgnore to exclude specific types or fields from validation.";
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -282,7 +282,7 @@ private static int countTypes(@Nonnull Iterable<?> types) {
}

static String formatErrors(@Nonnull List<String> errors) {
return "Schema validation failed with %d error(s):\n%s".formatted(
return "Schema validation failed with %d error(s):\n%s\nIf intentional, use @DbIgnore to exclude specific types or fields from validation.".formatted(
errors.size(),
String.join("\n", errors.stream().map(e -> " - " + e).toList()));
}
Expand Down Expand Up @@ -418,12 +418,24 @@ private void validateType(
.filter(Column::primaryKey)
.map(column -> column.name().toUpperCase())
.collect(Collectors.toCollection(() -> new TreeSet<>(String.CASE_INSENSITIVE_ORDER)));

Set<String> dbPkColumns = schema.getPrimaryKeys(tableName).stream()
.map(pk -> pk.columnName().toUpperCase())
.collect(Collectors.toCollection(() -> new TreeSet<>(String.CASE_INSENSITIVE_ORDER)));

if (!dbPkColumns.isEmpty() && !entityPkColumns.equals(dbPkColumns)) {
if (dbPkColumns.isEmpty() && !entityPkColumns.isEmpty()) {
// No PK constraint in the database. Only warn if @PK(constraint = true).
boolean validatePkConstraint = model.recordType().fields().stream()
.filter(field -> field.isAnnotationPresent(PK.class))
.findFirst()
.map(field -> field.getAnnotation(PK.class))
.map(PK::constraint)
.orElse(true);
if (validatePkConstraint) {
errors.add(new SchemaValidationError(type, ErrorKind.PRIMARY_KEY_MISSING,
"No primary key constraint found in table '%s', but entity defines primary key columns %s. If intentional, use @PK(constraint = false) to suppress this check."
.formatted(qualifiedTableName, entityPkColumns)));
}
} else if (!dbPkColumns.isEmpty() && !entityPkColumns.equals(dbPkColumns)) {
// PK constraint exists but differs: always a hard error regardless of constraint flag.
errors.add(new SchemaValidationError(type, ErrorKind.PRIMARY_KEY_MISMATCH,
"Primary key mismatch for table '%s': entity defines %s, database has %s."
.formatted(qualifiedTableName, entityPkColumns, dbPkColumns)));
Expand Down Expand Up @@ -484,6 +496,11 @@ private void validateUniqueKeys(
if (ignoredComponents.contains(field.name())) {
continue;
}
// Skip if the @UK annotation indicates no constraint is expected.
UK ukAnnotation = field.getAnnotation(UK.class);
if (ukAnnotation != null && !ukAnnotation.constraint()) {
continue;
}
// Collect the expected column names for this @UK field.
SortedSet<String> expectedColumns = new TreeSet<>(String.CASE_INSENSITIVE_ORDER);
for (Column column : model.declaredColumns()) {
Expand All @@ -503,7 +520,7 @@ private void validateUniqueKeys(
? "column '%s'".formatted(expectedColumns.first())
: "columns %s".formatted(expectedColumns);
errors.add(new SchemaValidationError(type, ErrorKind.UNIQUE_KEY_MISSING,
"No unique constraint found on %s in table '%s' for @UK field '%s'."
"No unique constraint found on %s in table '%s' for @UK field '%s'. If intentional, use @UK(constraint = false) to suppress this check."
.formatted(columnDescription, qualifiedTableName, field.name())));
}
}
Expand Down Expand Up @@ -577,15 +594,32 @@ private void validateForeignKeys(
if (fkColumnNames.isEmpty()) {
continue;
}
// Check if the @FK annotation requests constraint validation.
FK fkAnnotation = field.getAnnotation(FK.class);
boolean validateFkConstraint = fkAnnotation == null || fkAnnotation.constraint();
// Check if the database has matching FK constraints for each FK column.
for (String fkColumnName : fkColumnNames) {
// Check for an exact match (correct column and correct target table).
boolean found = dbForeignKeys.stream()
.anyMatch(fk -> fk.fkColumnName().equalsIgnoreCase(fkColumnName)
&& fk.pkTableName().equalsIgnoreCase(targetTableName));
if (!found) {
errors.add(new SchemaValidationError(type, ErrorKind.FOREIGN_KEY_MISSING,
"No foreign key constraint found on column '%s' in table '%s' referencing table '%s'."
.formatted(fkColumnName, qualifiedTableName, targetTableName)));
// Check if there is a FK constraint on this column that references a different table.
Optional<DbForeignKey> mismatch = dbForeignKeys.stream()
.filter(fk -> fk.fkColumnName().equalsIgnoreCase(fkColumnName))
.findFirst();
if (mismatch.isPresent()) {
// FK constraint exists but points to the wrong table: always a hard error.
errors.add(new SchemaValidationError(type, ErrorKind.FOREIGN_KEY_MISMATCH,
"Foreign key mismatch on column '%s' in table '%s': entity expects reference to table '%s', but database references table '%s'."
.formatted(fkColumnName, qualifiedTableName, targetTableName,
mismatch.get().pkTableName())));
} else if (validateFkConstraint) {
// No FK constraint at all: only warn if constraint validation is enabled.
errors.add(new SchemaValidationError(type, ErrorKind.FOREIGN_KEY_MISSING,
"No foreign key constraint found on column '%s' in table '%s' referencing table '%s'. If intentional, use @FK(constraint = false) to suppress this check."
.formatted(fkColumnName, qualifiedTableName, targetTableName)));
}
}
}
}
Expand Down
Loading
Loading