Skip to content

Commit bdf4c6a

Browse files
authored
Add constraint validation flag to @pk, @fk, and @uk annotations. (#87) (#88)
Add constraint validation flag to @pk, @fk, and @uk annotations. (#87)
1 parent 1f5863d commit bdf4c6a

9 files changed

Lines changed: 299 additions & 13 deletions

File tree

docs/entities.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -736,7 +736,9 @@ record Order(@PK Integer id,
736736

737737
## Suppressing Schema Validation
738738

739-
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.
739+
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.
740+
741+
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.
740742

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

docs/validation.md

Lines changed: 49 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -72,10 +72,12 @@ Schema validation compares your entity and projection definitions against the ac
7272
| Each mapped column exists in the table | `COLUMN_NOT_FOUND` | Error |
7373
| Kotlin/Java type is compatible with the SQL column type | `TYPE_INCOMPATIBLE` | Error |
7474
| Entity primary key columns match the database primary key | `PRIMARY_KEY_MISMATCH` | Error |
75+
| `@FK` constraint references the correct target table | `FOREIGN_KEY_MISMATCH` | Error |
7576
| Sequences referenced by `@PK(generation = SEQUENCE)` exist | `SEQUENCE_NOT_FOUND` | Error |
7677
| | | |
7778
| Numeric cross-category conversions (e.g., `Integer` mapped to `DECIMAL`) | `TYPE_NARROWING` | Warning |
7879
| Non-nullable entity field mapped to a nullable database column | `NULLABILITY_MISMATCH` | Warning |
80+
| Entity declares `@PK` but the database has no primary key constraint | `PRIMARY_KEY_MISSING` | Warning |
7981
| `@UK` field has a matching unique constraint in the database | `UNIQUE_KEY_MISSING` | Warning |
8082
| `@FK` field has a matching foreign key constraint in the database | `FOREIGN_KEY_MISSING` | Warning |
8183

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

8688
#### Constraint Validation
8789

88-
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.
90+
Schema validation checks that the database has the constraints your entity model declares. There are two categories of constraint findings:
91+
92+
**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.
93+
94+
**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.
8995

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

98+
- **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.
9299
- **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.
93100
- **Foreign key constraints** protect referential integrity. Without a database-level foreign key constraint, orphaned rows can accumulate when referenced rows are deleted.
94101

95-
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).
102+
##### Suppressing Constraint Warnings
103+
104+
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:
105+
106+
<Tabs groupId="language">
107+
<TabItem value="kotlin" label="Kotlin" default>
108+
109+
```kotlin
110+
// No FK constraint for performance reasons.
111+
data class Order(
112+
@PK val id: Int = 0,
113+
@FK(constraint = false) val customer: Customer
114+
) : Entity<Int>
115+
116+
// No unique index in the database.
117+
data class User(
118+
@PK val id: Int = 0,
119+
@UK(constraint = false) val email: String
120+
) : Entity<Int>
121+
```
122+
123+
</TabItem>
124+
<TabItem value="java" label="Java">
125+
126+
```java
127+
// No FK constraint for performance reasons.
128+
record Order(@PK Integer id,
129+
@FK(constraint = false) Customer customer
130+
) implements Entity<Integer> {}
131+
132+
// No unique index in the database.
133+
record User(@PK Integer id,
134+
@UK(constraint = false) String email
135+
) implements Entity<Integer> {}
136+
```
137+
138+
</TabItem>
139+
</Tabs>
140+
141+
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.
96142

97-
Use `@DbIgnore` to exclude entity fields from schema validation. See [Entities](entities.md) for annotation details.
143+
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.
98144

99145
### Programmatic API
100146

storm-core/src/main/java/st/orm/core/template/impl/SchemaValidationError.java

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,10 +47,14 @@ public enum ErrorKind {
4747
NULLABILITY_MISMATCH(true),
4848
/** The primary key columns in the entity do not match the database primary key. */
4949
PRIMARY_KEY_MISMATCH,
50+
/** The entity declares a {@code @PK} but the database table has no primary key constraint. @since 1.10 */
51+
PRIMARY_KEY_MISSING(true),
5052
/** A sequence referenced by the entity does not exist in the database. */
5153
SEQUENCE_NOT_FOUND,
5254
/** A {@code @UK} field does not have a matching unique constraint in the database. */
5355
UNIQUE_KEY_MISSING(true),
56+
/** A {@code @FK} field has a foreign key constraint that references a different table than expected. @since 1.10 */
57+
FOREIGN_KEY_MISMATCH,
5458
/** A {@code @FK} field does not have a matching foreign key constraint in the database. */
5559
FOREIGN_KEY_MISSING(true);
5660

storm-core/src/main/java/st/orm/core/template/impl/SchemaValidationException.java

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@ private static String formatMessage(@Nonnull List<SchemaValidationError> errors)
5252
return "Schema validation failed with %d error(s):\n".formatted(errors.size())
5353
+ errors.stream()
5454
.map(error -> " - " + error.toString())
55-
.collect(Collectors.joining("\n"));
55+
.collect(Collectors.joining("\n"))
56+
+ "\nIf intentional, use @DbIgnore to exclude specific types or fields from validation.";
5657
}
5758
}

storm-core/src/main/java/st/orm/core/template/impl/SchemaValidator.java

Lines changed: 42 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -282,7 +282,7 @@ private static int countTypes(@Nonnull Iterable<?> types) {
282282
}
283283

284284
static String formatErrors(@Nonnull List<String> errors) {
285-
return "Schema validation failed with %d error(s):\n%s".formatted(
285+
return "Schema validation failed with %d error(s):\n%s\nIf intentional, use @DbIgnore to exclude specific types or fields from validation.".formatted(
286286
errors.size(),
287287
String.join("\n", errors.stream().map(e -> " - " + e).toList()));
288288
}
@@ -418,12 +418,24 @@ private void validateType(
418418
.filter(Column::primaryKey)
419419
.map(column -> column.name().toUpperCase())
420420
.collect(Collectors.toCollection(() -> new TreeSet<>(String.CASE_INSENSITIVE_ORDER)));
421-
422421
Set<String> dbPkColumns = schema.getPrimaryKeys(tableName).stream()
423422
.map(pk -> pk.columnName().toUpperCase())
424423
.collect(Collectors.toCollection(() -> new TreeSet<>(String.CASE_INSENSITIVE_ORDER)));
425-
426-
if (!dbPkColumns.isEmpty() && !entityPkColumns.equals(dbPkColumns)) {
424+
if (dbPkColumns.isEmpty() && !entityPkColumns.isEmpty()) {
425+
// No PK constraint in the database. Only warn if @PK(constraint = true).
426+
boolean validatePkConstraint = model.recordType().fields().stream()
427+
.filter(field -> field.isAnnotationPresent(PK.class))
428+
.findFirst()
429+
.map(field -> field.getAnnotation(PK.class))
430+
.map(PK::constraint)
431+
.orElse(true);
432+
if (validatePkConstraint) {
433+
errors.add(new SchemaValidationError(type, ErrorKind.PRIMARY_KEY_MISSING,
434+
"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."
435+
.formatted(qualifiedTableName, entityPkColumns)));
436+
}
437+
} else if (!dbPkColumns.isEmpty() && !entityPkColumns.equals(dbPkColumns)) {
438+
// PK constraint exists but differs: always a hard error regardless of constraint flag.
427439
errors.add(new SchemaValidationError(type, ErrorKind.PRIMARY_KEY_MISMATCH,
428440
"Primary key mismatch for table '%s': entity defines %s, database has %s."
429441
.formatted(qualifiedTableName, entityPkColumns, dbPkColumns)));
@@ -484,6 +496,11 @@ private void validateUniqueKeys(
484496
if (ignoredComponents.contains(field.name())) {
485497
continue;
486498
}
499+
// Skip if the @UK annotation indicates no constraint is expected.
500+
UK ukAnnotation = field.getAnnotation(UK.class);
501+
if (ukAnnotation != null && !ukAnnotation.constraint()) {
502+
continue;
503+
}
487504
// Collect the expected column names for this @UK field.
488505
SortedSet<String> expectedColumns = new TreeSet<>(String.CASE_INSENSITIVE_ORDER);
489506
for (Column column : model.declaredColumns()) {
@@ -503,7 +520,7 @@ private void validateUniqueKeys(
503520
? "column '%s'".formatted(expectedColumns.first())
504521
: "columns %s".formatted(expectedColumns);
505522
errors.add(new SchemaValidationError(type, ErrorKind.UNIQUE_KEY_MISSING,
506-
"No unique constraint found on %s in table '%s' for @UK field '%s'."
523+
"No unique constraint found on %s in table '%s' for @UK field '%s'. If intentional, use @UK(constraint = false) to suppress this check."
507524
.formatted(columnDescription, qualifiedTableName, field.name())));
508525
}
509526
}
@@ -577,15 +594,32 @@ private void validateForeignKeys(
577594
if (fkColumnNames.isEmpty()) {
578595
continue;
579596
}
597+
// Check if the @FK annotation requests constraint validation.
598+
FK fkAnnotation = field.getAnnotation(FK.class);
599+
boolean validateFkConstraint = fkAnnotation == null || fkAnnotation.constraint();
580600
// Check if the database has matching FK constraints for each FK column.
581601
for (String fkColumnName : fkColumnNames) {
602+
// Check for an exact match (correct column and correct target table).
582603
boolean found = dbForeignKeys.stream()
583604
.anyMatch(fk -> fk.fkColumnName().equalsIgnoreCase(fkColumnName)
584605
&& fk.pkTableName().equalsIgnoreCase(targetTableName));
585606
if (!found) {
586-
errors.add(new SchemaValidationError(type, ErrorKind.FOREIGN_KEY_MISSING,
587-
"No foreign key constraint found on column '%s' in table '%s' referencing table '%s'."
588-
.formatted(fkColumnName, qualifiedTableName, targetTableName)));
607+
// Check if there is a FK constraint on this column that references a different table.
608+
Optional<DbForeignKey> mismatch = dbForeignKeys.stream()
609+
.filter(fk -> fk.fkColumnName().equalsIgnoreCase(fkColumnName))
610+
.findFirst();
611+
if (mismatch.isPresent()) {
612+
// FK constraint exists but points to the wrong table: always a hard error.
613+
errors.add(new SchemaValidationError(type, ErrorKind.FOREIGN_KEY_MISMATCH,
614+
"Foreign key mismatch on column '%s' in table '%s': entity expects reference to table '%s', but database references table '%s'."
615+
.formatted(fkColumnName, qualifiedTableName, targetTableName,
616+
mismatch.get().pkTableName())));
617+
} else if (validateFkConstraint) {
618+
// No FK constraint at all: only warn if constraint validation is enabled.
619+
errors.add(new SchemaValidationError(type, ErrorKind.FOREIGN_KEY_MISSING,
620+
"No foreign key constraint found on column '%s' in table '%s' referencing table '%s'. If intentional, use @FK(constraint = false) to suppress this check."
621+
.formatted(fkColumnName, qualifiedTableName, targetTableName)));
622+
}
589623
}
590624
}
591625
}

0 commit comments

Comments
 (0)