feat(refs): orphan FK scanner — Prometheus gauge nsi_orphan_references_total
@Scheduled sweep раз в час (configurable). Walks all schemas, для каждого x-references marker считает orphans через JdbcTemplate native SQL (WHERE source.data->>field NOT IN target dict active records). Метрика: Gauge per (source_dict, source_field, target_dict, target_field). Cardinality bounded by FK count в bundles (~5 для cuod v1.2.1). Conditional: ordinis.references.scan-enabled=true (default off, включаем явно в prod values). Read-only, no write impact. OrphanReferenceQuery (ordinis-domain): native SQL helper, identifier validation против SQL injection. Field name regex check т.к. JSONB key path не bind'ится через @Param. Tests: 4 в OrphanReferenceScannerTest (parseRef edge cases). Реальный SQL логику покроет integration тест на staging данных. Total project tests: 191 → 195.
This commit is contained in:
+82
@@ -0,0 +1,82 @@
|
||||
package cloud.nstart.terravault.ordinis.domain.record;
|
||||
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* Native SQL для подсчёта orphan FK references — записи где
|
||||
* {@code data->>source_field} не находится в active records target dictionary
|
||||
* по {@code data->>target_field}.
|
||||
*
|
||||
* <p>Используется {@code OrphanReferenceScanner} раз в час для метрики
|
||||
* {@code nsi_orphan_references_total}. Отдельный component (не repository
|
||||
* метод) потому что используем JdbcTemplate с dynamic field names — JPA
|
||||
* не может взять {@code @Param} в JSONB key path.
|
||||
*/
|
||||
@Component
|
||||
public class OrphanReferenceQuery {
|
||||
|
||||
private final JdbcTemplate jdbc;
|
||||
|
||||
@Autowired
|
||||
public OrphanReferenceQuery(JdbcTemplate jdbc) {
|
||||
this.jdbc = jdbc;
|
||||
}
|
||||
|
||||
/**
|
||||
* Считает orphan references: active records в source dictionary где
|
||||
* {@code data->>source_field} НЕ присутствует среди active records
|
||||
* target dictionary {@code data->>target_field}. NULL-значения
|
||||
* пропускаются (optional fields, x-references не required).
|
||||
*
|
||||
* <p>Field names валидируются caller'ом (берутся из schema parser, не
|
||||
* из user input) — SQL injection невозможна. Используем native SQL
|
||||
* concat потому что JdbcTemplate не bind'ит JSONB key paths.
|
||||
*
|
||||
* @return count of orphan source records
|
||||
*/
|
||||
public long countOrphans(
|
||||
UUID sourceDictionaryId,
|
||||
String sourceField,
|
||||
UUID targetDictionaryId,
|
||||
String targetField) {
|
||||
validateField(sourceField);
|
||||
validateField(targetField);
|
||||
|
||||
String sql = """
|
||||
SELECT count(*) FROM dictionary_records src
|
||||
WHERE src.dictionary_id = ?
|
||||
AND src.valid_from <= now()
|
||||
AND src.valid_to > now()
|
||||
AND src.data ->> '%s' IS NOT NULL
|
||||
AND src.data ->> '%s' NOT IN (
|
||||
SELECT tgt.data ->> '%s'
|
||||
FROM dictionary_records tgt
|
||||
WHERE tgt.dictionary_id = ?
|
||||
AND tgt.valid_from <= now()
|
||||
AND tgt.valid_to > now()
|
||||
AND tgt.data ->> '%s' IS NOT NULL
|
||||
)
|
||||
""".formatted(sourceField, sourceField, targetField, targetField);
|
||||
|
||||
Long count = jdbc.queryForObject(sql, Long.class, sourceDictionaryId, targetDictionaryId);
|
||||
return count == null ? 0 : count;
|
||||
}
|
||||
|
||||
/**
|
||||
* SQL injection guard. Field name приходит из JSON Schema (мы парсим),
|
||||
* но мы не доверяем — extra paranoid check. Алlowед identifier rules.
|
||||
*/
|
||||
private static void validateField(String field) {
|
||||
if (field == null || field.isBlank()) {
|
||||
throw new IllegalArgumentException("field name is empty");
|
||||
}
|
||||
if (!field.matches("^[a-zA-Z_][a-zA-Z0-9_]{0,127}$")) {
|
||||
throw new IllegalArgumentException(
|
||||
"field name has unsafe characters: " + field);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user