feat: add transactional relation service

This commit is contained in:
grayTerminal-sh 2026-07-21 08:01:55 +02:00
parent fbaaac583d
commit 9a9f326c12
5 changed files with 3130 additions and 7 deletions

View file

@ -35,12 +35,9 @@ EVIDENCE_IMPORT_DIALOG_TEST_CFLAGS := \
-Iinclude \ -Iinclude \
$(shell $(PKG_CONFIG) --cflags gtk4) $(shell $(PKG_CONFIG) --cflags gtk4)
EVIDENCE_IMPORT_DIALOG_TEST_LDFLAGS := \ EVIDENCE_IMPORT_DIALOG_TEST_LDFLAGS := $(shell $(PKG_CONFIG) --libs gtk4)
$(shell $(PKG_CONFIG) --libs gtk4)
EVIDENCE_INTEGRITY_VERIFIER_TEST_CFLAGS := \ EVIDENCE_INTEGRITY_VERIFIER_TEST_CFLAGS := $(TEST_CFLAGS) -Wpedantic
$(TEST_CFLAGS) \
-Wpedantic
ENTITY_RECORD_TEST_CFLAGS := $(TEST_CFLAGS) -Wpedantic ENTITY_RECORD_TEST_CFLAGS := $(TEST_CFLAGS) -Wpedantic
ENTITY_TYPE_DAO_TEST_CFLAGS := $(TEST_CFLAGS) -Wpedantic ENTITY_TYPE_DAO_TEST_CFLAGS := $(TEST_CFLAGS) -Wpedantic
ENTITY_DAO_TEST_CFLAGS := $(TEST_CFLAGS) -Wpedantic ENTITY_DAO_TEST_CFLAGS := $(TEST_CFLAGS) -Wpedantic
@ -48,6 +45,10 @@ EVIDENCE_ENTITY_DAO_TEST_CFLAGS := $(TEST_CFLAGS) -Wpedantic
RELATION_RECORD_TEST_CFLAGS := $(TEST_CFLAGS) -Wpedantic RELATION_RECORD_TEST_CFLAGS := $(TEST_CFLAGS) -Wpedantic
RELATION_DAO_TEST_CFLAGS := $(TEST_CFLAGS) -Wpedantic RELATION_DAO_TEST_CFLAGS := $(TEST_CFLAGS) -Wpedantic
RELATION_EVIDENCE_DAO_TEST_CFLAGS := $(TEST_CFLAGS) -Wpedantic RELATION_EVIDENCE_DAO_TEST_CFLAGS := $(TEST_CFLAGS) -Wpedantic
RELATION_SERVICE_TEST_CFLAGS := \
$(TEST_CFLAGS) \
-Wpedantic \
-DRELATION_SERVICE_ENABLE_TEST_HOOKS
SRC := $(shell find src -name "*.c") SRC := $(shell find src -name "*.c")
@ -96,6 +97,7 @@ TEST_EVIDENCE_ENTITY_DAO := tests/test_evidence_entity_dao
TEST_RELATION_RECORD := tests/test_relation_record TEST_RELATION_RECORD := tests/test_relation_record
TEST_RELATION_DAO := tests/test_relation_dao TEST_RELATION_DAO := tests/test_relation_dao
TEST_RELATION_EVIDENCE_DAO := tests/test_relation_evidence_dao TEST_RELATION_EVIDENCE_DAO := tests/test_relation_evidence_dao
TEST_RELATION_SERVICE := tests/test_relation_service
all: $(TARGET) all: $(TARGET)
@ -457,6 +459,24 @@ $(TEST_RELATION_EVIDENCE_DAO): \
src/database/error.c src/database/error.c
$(CC) $(RELATION_EVIDENCE_DAO_TEST_CFLAGS) $^ -o $@ $(TEST_LDFLAGS) -lsqlite3 $(CC) $(RELATION_EVIDENCE_DAO_TEST_CFLAGS) $^ -o $@ $(TEST_LDFLAGS) -lsqlite3
$(TEST_RELATION_SERVICE): \
tests/test_relation_service.c \
src/core/relation_service.c \
src/dao/relation_dao.c \
src/dao/relation_evidence_dao.c \
src/dao/evidence_dao.c \
src/dao/entity_dao.c \
src/models/relation_record.c \
src/models/evidence_record.c \
src/models/entity_record.c \
src/database/database.c \
src/database/schema.c \
src/database/statement.c \
src/database/transaction.c \
src/database/error.c
$(CC) $(RELATION_SERVICE_TEST_CFLAGS) $^ -o $@ \
$(TEST_LDFLAGS) -lsqlite3
test: \ test: \
$(TEST_NODE) \ $(TEST_NODE) \
$(TEST_TREE_MODEL) \ $(TEST_TREE_MODEL) \
@ -498,7 +518,8 @@ test: \
$(TEST_EVIDENCE_ENTITY_DAO) \ $(TEST_EVIDENCE_ENTITY_DAO) \
$(TEST_RELATION_RECORD) \ $(TEST_RELATION_RECORD) \
$(TEST_RELATION_DAO) \ $(TEST_RELATION_DAO) \
$(TEST_RELATION_EVIDENCE_DAO) $(TEST_RELATION_EVIDENCE_DAO) \
$(TEST_RELATION_SERVICE)
@echo "Exécution des tests..." @echo "Exécution des tests..."
@./$(TEST_NODE) @./$(TEST_NODE)
@./$(TEST_TREE_MODEL) @./$(TEST_TREE_MODEL)
@ -541,6 +562,7 @@ test: \
@$(TEST_RELATION_RECORD) @$(TEST_RELATION_RECORD)
@$(TEST_RELATION_DAO) @$(TEST_RELATION_DAO)
@$(TEST_RELATION_EVIDENCE_DAO) @$(TEST_RELATION_EVIDENCE_DAO)
@$(TEST_RELATION_SERVICE)
@echo "Tous les tests sont valides." @echo "Tous les tests sont valides."
%.o: %.c %.o: %.c
@ -589,6 +611,7 @@ clean:
$(TEST_EVIDENCE_ENTITY_DAO) \ $(TEST_EVIDENCE_ENTITY_DAO) \
$(TEST_RELATION_RECORD) \ $(TEST_RELATION_RECORD) \
$(TEST_RELATION_DAO) \ $(TEST_RELATION_DAO) \
$(TEST_RELATION_EVIDENCE_DAO) $(TEST_RELATION_EVIDENCE_DAO) \
$(TEST_RELATION_SERVICE)
.PHONY: clean run test .PHONY: clean run test

View file

@ -0,0 +1,133 @@
/******************************************************************************
* @file relation_service.h
* @brief Orchestration transactionnelle de la création des relations.
******************************************************************************/
#ifndef LABFY_INVESTIGATION_RELATION_SERVICE_H
#define LABFY_INVESTIGATION_RELATION_SERVICE_H
#include "database/database.h"
#include "models/relation_record.h"
#include <glib.h>
G_BEGIN_DECLS
/**
* @brief Service opaque de gestion transactionnelle des relations.
*
* Le service emprunte la connexion Database reçue à sa création.
*/
typedef struct RelationService RelationService;
/**
* @brief Catégories d'erreurs produites par RelationService.
*/
typedef enum
{
/**
* Un argument public est invalide.
*/
RELATION_SERVICE_ERROR_INVALID_ARGUMENT,
/**
* Une allocation mémoire a échoué.
*/
RELATION_SERVICE_ERROR_MEMORY,
/**
* La transaction SQLite n'a pas pu être démarrée.
*/
RELATION_SERVICE_ERROR_TRANSACTION_BEGIN,
/**
* La relation n'a pas pu être insérée.
*/
RELATION_SERVICE_ERROR_RELATION_INSERT,
/**
* Une preuve n'a pas pu être associée à la relation.
*/
RELATION_SERVICE_ERROR_EVIDENCE_LINK,
/**
* La transaction SQLite n'a pas pu être validée.
*/
RELATION_SERVICE_ERROR_TRANSACTION_COMMIT,
/**
* La transaction SQLite n'a pas pu être annulée.
*/
RELATION_SERVICE_ERROR_TRANSACTION_ROLLBACK
} RelationServiceError;
/**
* @brief Domaine d'erreur du service.
*/
#define RELATION_SERVICE_ERROR \
relation_service_error_quark()
/**
* @brief Retourne le domaine d'erreur du service.
*/
GQuark relation_service_error_quark(void);
/**
* @brief Crée un service utilisant une connexion Database existante.
*
* Le service emprunte la connexion et possède les DAO qu'il crée.
*
* @param database Connexion SQLite ouverte et initialisée.
* @param error Emplacement facultatif recevant une erreur.
*
* @return Nouveau service, ou NULL en cas d'échec.
*/
RelationService *relation_service_new(
Database *database,
GError **error
);
/**
* @brief Libère le service.
*
* Les DAO internes sont libérés, mais la connexion empruntée n'est
* jamais fermée.
*
* Cette fonction accepte NULL.
*
* @param relation_service Service à libérer.
*/
void relation_service_free(
RelationService *relation_service
);
/**
* @brief Crée atomiquement une relation et ses associations aux preuves.
*
* relation_record, evidence_identifiers et les chaînes du tableau sont
* empruntés pendant l'appel.
*
* evidence_identifiers peut être NULL ou vide.
*
* Un retour TRUE garantit que la relation et toutes ses associations ont
* é validées par COMMIT.
*
* Un retour FALSE signifie que l'opération complète a échoué.
*
* @param relation_service Service valide.
* @param relation_record Relation à insérer.
* @param evidence_identifiers Tableau facultatif d'UUID de preuves.
* @param error Emplacement facultatif recevant une erreur.
*
* @return TRUE si l'opération complète est validée.
*/
gboolean relation_service_create(
RelationService *relation_service,
const RelationRecord *relation_record,
const GPtrArray *evidence_identifiers,
GError **error
);
G_END_DECLS
#endif

View file

@ -0,0 +1,37 @@
/******************************************************************************
* @file relation_service_test.h
* @brief Points d'injection réservés aux tests de RelationService.
******************************************************************************/
#ifndef LABFY_INVESTIGATION_RELATION_SERVICE_TEST_H
#define LABFY_INVESTIGATION_RELATION_SERVICE_TEST_H
#include <glib.h>
G_BEGIN_DECLS
/**
* @brief Réinitialise tous les points d'injection du service.
*
* Disponible uniquement lorsque relation_service.c est compilé avec
* RELATION_SERVICE_ENABLE_TEST_HOOKS.
*/
void relation_service_test_reset_hooks(void);
/**
* @brief Simule l'échec du prochain COMMIT.
*/
void relation_service_test_set_commit_failure(
gboolean enabled
);
/**
* @brief Simule l'échec du prochain ROLLBACK.
*/
void relation_service_test_set_rollback_failure(
gboolean enabled
);
G_END_DECLS
#endif

777
src/core/relation_service.c Normal file
View file

@ -0,0 +1,777 @@
/******************************************************************************
* @file relation_service.c
* @brief Orchestration transactionnelle de la création des relations.
******************************************************************************/
#include "core/relation_service.h"
#include "dao/relation_dao.h"
#include "dao/relation_evidence_dao.h"
#include "database/error.h"
#include "database/transaction.h"
#include <glib.h>
/**
* @brief Représentation privée du service.
*
* La connexion Database est empruntée.
* Les deux DAO sont possédés.
*/
struct RelationService
{
Database *database;
RelationDao *relation_dao;
RelationEvidenceDao *relation_evidence_dao;
};
#ifdef RELATION_SERVICE_ENABLE_TEST_HOOKS
#include "core/relation_service_test.h"
static gboolean relation_service_test_commit_failure =
FALSE;
static gboolean relation_service_test_rollback_failure =
FALSE;
/**
* @brief Consomme un point d'injection à usage unique.
*/
static gboolean relation_service_test_take_hook(
gboolean *hook
)
{
gboolean enabled =
FALSE;
if (hook == NULL)
{
return FALSE;
}
enabled =
*hook;
*hook =
FALSE;
return enabled;
}
void relation_service_test_reset_hooks(void)
{
relation_service_test_commit_failure =
FALSE;
relation_service_test_rollback_failure =
FALSE;
}
void relation_service_test_set_commit_failure(
gboolean enabled
)
{
relation_service_test_commit_failure =
enabled;
}
void relation_service_test_set_rollback_failure(
gboolean enabled
)
{
relation_service_test_rollback_failure =
enabled;
}
#endif
/**
* @brief Enregistre une erreur littérale du service.
*/
static void relation_service_set_error_literal(
GError **error,
RelationServiceError error_code,
const char *message
)
{
if (error == NULL ||
*error != NULL)
{
return;
}
g_set_error_literal(
error,
RELATION_SERVICE_ERROR,
error_code,
message
);
}
/**
* @brief Conserve le contexte d'une erreur provenant d'un DAO.
*/
static void relation_service_set_nested_error(
GError **error,
RelationServiceError error_code,
const char *context,
const GError *nested_error
)
{
if (error == NULL ||
*error != NULL)
{
return;
}
g_set_error(
error,
RELATION_SERVICE_ERROR,
error_code,
"%s : %s",
context,
nested_error != NULL
? nested_error->message
: "erreur inconnue"
);
}
/**
* @brief Conserve le contexte de la dernière erreur Database.
*/
static void relation_service_set_database_error(
GError **error,
RelationServiceError error_code,
const char *context,
const Database *database
)
{
const char *database_message =
NULL;
if (error == NULL ||
*error != NULL)
{
return;
}
database_message =
database_error_get_message(
database
);
g_set_error(
error,
RELATION_SERVICE_ERROR,
error_code,
"%s : %s",
context,
database_message != NULL
? database_message
: "erreur Database inconnue"
);
}
/**
* @brief Vérifie les champs obligatoires du modèle.
*/
static gboolean relation_service_record_is_valid(
const RelationRecord *relation_record
)
{
if (relation_record == NULL)
{
return FALSE;
}
if (relation_record_get_identifier(
relation_record
) == NULL ||
relation_record_get_source_entity_identifier(
relation_record
) == NULL ||
relation_record_get_target_entity_identifier(
relation_record
) == NULL ||
relation_record_get_relation_type(
relation_record
) == NULL ||
relation_record_get_created_at(
relation_record
) == NULL ||
relation_record_get_updated_at(
relation_record
) == NULL)
{
return FALSE;
}
return TRUE;
}
/**
* @brief Vérifie les UUID de preuves avant toute transaction.
*
* Le contrôle des doublons est volontairement effectué sans allocation
* supplémentaire. Les listes de preuves liées à une relation restent
* normalement courtes et cette validation demeure simple et déterministe.
*/
static gboolean relation_service_evidence_identifiers_are_valid(
const GPtrArray *evidence_identifiers,
GError **error
)
{
guint evidence_index =
0;
guint previous_index =
0;
if (evidence_identifiers == NULL)
{
return TRUE;
}
for (evidence_index = 0;
evidence_index < evidence_identifiers->len;
evidence_index++)
{
const char *evidence_identifier =
g_ptr_array_index(
evidence_identifiers,
evidence_index
);
if (evidence_identifier == NULL ||
!g_uuid_string_is_valid(
evidence_identifier
))
{
relation_service_set_error_literal(
error,
RELATION_SERVICE_ERROR_INVALID_ARGUMENT,
"La demande contient un identifiant de preuve invalide."
);
return FALSE;
}
for (previous_index = 0;
previous_index < evidence_index;
previous_index++)
{
const char *previous_identifier =
g_ptr_array_index(
evidence_identifiers,
previous_index
);
if (g_strcmp0(
evidence_identifier,
previous_identifier
) == 0)
{
relation_service_set_error_literal(
error,
RELATION_SERVICE_ERROR_INVALID_ARGUMENT,
"La demande contient plusieurs fois la même preuve."
);
return FALSE;
}
}
}
return TRUE;
}
/**
* @brief Valide la transaction, sauf panne simulée par un test.
*/
static gboolean relation_service_commit_transaction(
Database *database
)
{
#ifdef RELATION_SERVICE_ENABLE_TEST_HOOKS
if (relation_service_test_take_hook(
&relation_service_test_commit_failure
))
{
database_error_set(
database,
DATABASE_ERROR_SQLITE,
"Échec de COMMIT simulé par RelationService."
);
return FALSE;
}
#endif
return database_transaction_commit(
database
);
}
/**
* @brief Annule la transaction, sauf panne simulée par un test.
*/
static gboolean relation_service_execute_rollback(
Database *database
)
{
#ifdef RELATION_SERVICE_ENABLE_TEST_HOOKS
if (relation_service_test_take_hook(
&relation_service_test_rollback_failure
))
{
/*
* La vraie transaction est tout de même annulée afin que le test
* ne laisse pas la connexion dans un état inutilisable.
*/
(void) database_transaction_rollback(
database
);
database_error_set(
database,
DATABASE_ERROR_SQLITE,
"Échec de ROLLBACK simulé par RelationService."
);
return FALSE;
}
#endif
return database_transaction_rollback(
database
);
}
/**
* @brief Annule la transaction et conserve l'erreur métier initiale.
*/
static gboolean relation_service_rollback_transaction(
Database *database,
GError **error
)
{
const char *database_message =
NULL;
char *primary_error_message =
NULL;
if (database == NULL)
{
return FALSE;
}
if (!database_transaction_is_active(
database
))
{
return TRUE;
}
if (relation_service_execute_rollback(
database
))
{
return TRUE;
}
database_message =
database_error_get_message(
database
);
if (error == NULL)
{
g_warning(
"Impossible d'annuler la transaction SQLite : %s",
database_message != NULL
? database_message
: "erreur inconnue"
);
return FALSE;
}
if (*error != NULL)
{
primary_error_message =
g_strdup(
(*error)->message
);
g_clear_error(
error
);
}
g_set_error(
error,
RELATION_SERVICE_ERROR,
RELATION_SERVICE_ERROR_TRANSACTION_ROLLBACK,
"Impossible d'annuler la transaction SQLite : %s. "
"Cause initiale : %s",
database_message != NULL
? database_message
: "erreur inconnue",
primary_error_message != NULL
? primary_error_message
: "erreur inconnue"
);
g_free(
primary_error_message
);
return FALSE;
}
GQuark relation_service_error_quark(void)
{
return g_quark_from_static_string(
"relation-service-error-quark"
);
}
RelationService *relation_service_new(
Database *database,
GError **error
)
{
RelationService *relation_service =
NULL;
RelationDao *relation_dao =
NULL;
RelationEvidenceDao *relation_evidence_dao =
NULL;
GError *dao_error =
NULL;
g_return_val_if_fail(
error == NULL || *error == NULL,
NULL
);
if (database == NULL)
{
relation_service_set_error_literal(
error,
RELATION_SERVICE_ERROR_INVALID_ARGUMENT,
"La connexion Database est obligatoire."
);
return NULL;
}
relation_dao =
relation_dao_new(
database,
&dao_error
);
if (relation_dao == NULL)
{
relation_service_set_nested_error(
error,
RELATION_SERVICE_ERROR_MEMORY,
"Impossible de créer le DAO des relations",
dao_error
);
g_clear_error(
&dao_error
);
return NULL;
}
relation_evidence_dao =
relation_evidence_dao_new(
database,
&dao_error
);
if (relation_evidence_dao == NULL)
{
relation_service_set_nested_error(
error,
RELATION_SERVICE_ERROR_MEMORY,
"Impossible de créer le DAO des associations relation-preuve",
dao_error
);
g_clear_error(
&dao_error
);
relation_dao_free(
relation_dao
);
return NULL;
}
relation_service =
g_try_new0(
RelationService,
1
);
if (relation_service == NULL)
{
relation_evidence_dao_free(
relation_evidence_dao
);
relation_dao_free(
relation_dao
);
relation_service_set_error_literal(
error,
RELATION_SERVICE_ERROR_MEMORY,
"Impossible d'allouer le service de gestion des relations."
);
return NULL;
}
relation_service->database =
database;
relation_service->relation_dao =
relation_dao;
relation_service->relation_evidence_dao =
relation_evidence_dao;
return relation_service;
}
void relation_service_free(
RelationService *relation_service
)
{
if (relation_service == NULL)
{
return;
}
relation_evidence_dao_free(
relation_service->relation_evidence_dao
);
relation_dao_free(
relation_service->relation_dao
);
relation_service->relation_evidence_dao =
NULL;
relation_service->relation_dao =
NULL;
relation_service->database =
NULL;
g_free(
relation_service
);
}
gboolean relation_service_create(
RelationService *relation_service,
const RelationRecord *relation_record,
const GPtrArray *evidence_identifiers,
GError **error
)
{
GError *dao_error =
NULL;
guint evidence_index =
0;
gboolean transaction_started =
FALSE;
gboolean success =
FALSE;
g_return_val_if_fail(
error == NULL || *error == NULL,
FALSE
);
if (relation_service == NULL ||
relation_service->database == NULL ||
relation_service->relation_dao == NULL ||
relation_service->relation_evidence_dao == NULL)
{
relation_service_set_error_literal(
error,
RELATION_SERVICE_ERROR_INVALID_ARGUMENT,
"Le service de gestion des relations est invalide."
);
return FALSE;
}
if (!relation_service_record_is_valid(
relation_record
))
{
relation_service_set_error_literal(
error,
RELATION_SERVICE_ERROR_INVALID_ARGUMENT,
"Le modèle de relation est invalide."
);
return FALSE;
}
if (!relation_service_evidence_identifiers_are_valid(
evidence_identifiers,
error
))
{
return FALSE;
}
if (!database_transaction_begin(
relation_service->database
))
{
relation_service_set_database_error(
error,
RELATION_SERVICE_ERROR_TRANSACTION_BEGIN,
"Impossible de démarrer la transaction SQLite",
relation_service->database
);
return FALSE;
}
transaction_started =
TRUE;
if (!relation_dao_insert(
relation_service->relation_dao,
relation_record,
&dao_error
))
{
relation_service_set_nested_error(
error,
RELATION_SERVICE_ERROR_RELATION_INSERT,
"Impossible d'enregistrer la relation",
dao_error
);
goto cleanup;
}
g_clear_error(
&dao_error
);
if (evidence_identifiers != NULL)
{
for (evidence_index = 0;
evidence_index < evidence_identifiers->len;
evidence_index++)
{
const char *evidence_identifier =
g_ptr_array_index(
evidence_identifiers,
evidence_index
);
if (!relation_evidence_dao_link(
relation_service->relation_evidence_dao,
relation_record_get_identifier(
relation_record
),
evidence_identifier,
&dao_error
))
{
if (error != NULL &&
*error == NULL)
{
g_set_error(
error,
RELATION_SERVICE_ERROR,
RELATION_SERVICE_ERROR_EVIDENCE_LINK,
"Impossible d'associer la preuve %s "
"à la relation : %s",
evidence_identifier,
dao_error != NULL
? dao_error->message
: "erreur DAO inconnue"
);
}
goto cleanup;
}
g_clear_error(
&dao_error
);
}
}
if (!relation_service_commit_transaction(
relation_service->database
))
{
relation_service_set_database_error(
error,
RELATION_SERVICE_ERROR_TRANSACTION_COMMIT,
"Impossible de valider la transaction SQLite",
relation_service->database
);
goto cleanup;
}
transaction_started =
FALSE;
success =
TRUE;
cleanup:
g_clear_error(
&dao_error
);
if (!success &&
transaction_started)
{
(void) relation_service_rollback_transaction(
relation_service->database,
error
);
transaction_started =
FALSE;
}
return success;
}

File diff suppressed because it is too large Load diff