Passer au contenu

Modèles#

Les modèles représentent les données stoquées dans les tables ou collections de votre base de données. Ils comportent un ou plusiseurs champs capables de stoquer des valeurs codables. Chaque modèle a un identifiant unique. Des PropertyWrappers sont utilisés pour marquer les identifiants, champs, et relations.

L’exemple ci-dessous représente un modèle simple avec un champ unique. Notez qu’un modèle ne représente pas en totalité un schéma en base de données, comme les contraintes, index, ou clés étrangères. Les schémas sont définis par des migrations. Les modèles ont le rôle spécifique de représenter les données stoquées par le schéma de votre base de données.

final class Planet: Model {
    // Nom de la table ou collection.
    static let schema = "planets"

    // Identifiant unique de la planète.
    @ID(key: .id)
    var id: UUID?

    // Nom de la planète.
    @Field(key: "name")
    var name: String

    // Crée une nouvelle planète vide.
    init() { }

    // Crée une nouvelle planète avec ses propriétés.
    init(id: UUID? = nil, name: String) {
        self.id = id
        self.name = name
    }
}

Schéma#

Chaque modèle a besoin d’une propriété statique schema en lecture seule. Cette chaîne de caractères référence le nom de la table ou collection que représente ce modèle.

final class Planet: Model {
    // Nom de la table ou collection.
    static let schema = "planets"
}

Lorsque vous requêterez ce modèle, les données seront récupérées depuis et enregistrées dans le schéma nommé "planets".

Info

Le nom du schéma est généralement le nom de la classe au pluriel et en minuscules.

Identifiant#

Chaque modèle a besoin d’une propriété id complétée du PropertyWrapper @ID. Ce champ identifie de façon unique les instances de votre modèle.

final class Planet: Model {
    // Identifiant unique de cette planète.
    @ID(key: .id)
    var id: UUID?
}

Par défaut, la propriété @ID devrait utiliser la clé spécialisée .id, qui correspondra à une clé appropriée dans le driver sous-jacent. Pour SQL, il s’agirat de "id", là où NoSQL sélectionnera "_id".

@ID devrait aussi s’appliquer à un champ typé en UUID. C’est la seule valeur d’identifiant actuellement supportée par tous les drivers de base de données. Fluent génèrera automatiquement des identifiants au format UUID lorsque de nouveaux modèles seront créés.

@ID contient aussi une valeur optionnelle, car les nouveaux modèles qui n’ont pas encore été enregistrés n’auront naturellement pas encore d’identifiant associé. Pour récupérer l’identifiant ou lever une erreur, utilisez requireID.

let id = try planet.requireID()

Existance#

@ID possède une propriété exists qui représente l’état d’existence du modèle en base de données. Lorsque vous initialisez un nouveau modèle, sa valeur est définie à false. Une fois que vous enregistrez un modèle, ou lorsque vous en récupérez depuis une base de données, sa valeur est à true. Cette propriété est accessible en lecture/écriture.

if planet.$id.exists {
    // Ce modèle existe en base de données.
}

Identifiant personnalisé#

Fluent supporte des clés et types d’identifiant personnalisé avec la surcharge @ID(custom:).

final class Planet: Model {
    // Identifiant unique de cette planète.
    @ID(custom: "foo")
    var id: Int?
}

L’exemple précédent utilise @ID avec la clé personnalisée "foo" et le type Int. C’est compatible avec les bases de données SQL qui utilisent des clés primaires en auto-incrément, mais n’est en revanche pas compatible avec NoSQL.

Des @IDs personnalisés vous permettent de spécifier comment les identifiants doivent être générés grâce au paramètre generatedBy.

@ID(custom: "foo", generatedBy: .user)

Le paramètre generatedBy peut accepter les valeurs suivantes :

Valeur Description
.user @ID doit se voir affecter une valeur avant d’enregistrer le modèle en base.
.random Le type sur lequel @ID est positionné doit être conforme à RandomGeneratable.
.database La responsabilité de génération de l’identifiant est déléguée à la base de données au moment de l’insertion.

Si le paramètre generatedBy est omis, Fluent essaiera de déduire un comportement approprié selon le type de valeur démarqué par @ID. Par exemple, Int se verra attribué la stratégie .database par défaut.

Initialisateur#

Les modèles doivent avoir une méthode d’initialisation vide.

final class Planet: Model {
    // Crée une nouvelle planète vide.
    init() { }
}

Fluent a besoin de cet méthode pour son fonctionnement interne afin d’initialiser les modèles retournés par les requêtes. Cette méthode est également utilisée dans les processus de réflexion.

Vous souhaiterez peut-être ajouter votre initialisateur personnalisé pour vous simplifier la vie.

final class Planet: Model {
    // Crée une nouvelle planète avec toutes ses propriétés.
    init(id: UUID? = nil, name: String) {
        self.id = id
        self.name = name
    }
}

L’usage de ce genre d’initialisateur rend l’ajout de propriétés futures au modèle plus aisé.

Champs#

Chaque modèle peut avoir zéro champs ou plus, dénotés par @Field, pour stoquer des données.

final class Planet: Model {
    // Nom de la planète.
    @Field(key: "name")
    var name: String
}

Les champs ont besoin que le nom de la colonne en base de données soit défini de façon explicite. Il n’est pas obligatoire que le nom de colonne corresponde au nom de propriété du modèle.

Conseil

Fluent recommande la convention snake_case pour les clés en base de données, et camelCase pour les noms de propriétés.

Les valeurs de champs peuvent être de tout type qui se conforme à Codable. Le stoquage de structures imbriquées et de tableaux dans des champs @Field est possible, mais les opérations de filtrage sont limitées. Se référer à @Group pour une solution alternative.

Pour les champs dont la valeur est facultative, utilisez @OptionalField à la place.

@OptionalField(key: "tag")
var tag: String?

Attention

Un champ non optionnel qui possède la PropertyObserver willSet qui référence sa valeur actuelle, ou la PropertyObserver didSet qui référence oldValue causera une erreur fatale.

Relations#

Les modèles peuvent avoir zéro, une, ou plusieurs relations vers d’autres modèles, telles que @Parent, @Children, et @Siblings. Un chapitre entier est dédié aux relations.

Timestamp#

@Timestamp est un type de champ @Field spécial qui stoque un objet de type Foundation.Date. Les timestamps sont automatiquement définis par Fluent en fonction du déclencheur choisi.

final class Planet: Model {
    // Indique le moment où cette planète a été créée.
    @Timestamp(key: "created_at", on: .create)
    var createdAt: Date?

    // Indique le moment où cette planète a été mise à jour pour la dernière fois.
    @Timestamp(key: "updated_at", on: .update)
    var updatedAt: Date?
}

@Timestamp peut avoir un déclencheur parmi les valeurs suivantes :

Déclencheur Description
.create Déclenché lors de la création de la ligne en base de données.
.update Déclenché lorsqu’une ligne existante en base de données est mise à jour.
.delete Déclenché lorsqu’une ligne existante en base de données est marquée pour suppression. Voir suppression douce (soft delete).

La valeur date de @Timestamp est optionnelle, et devrait être définie à nil lors de l’initialisation d’un nouveau modèle.

Format de Timestamp#

Par défaut, un @Timestamp utilisera un encodage datetime efficace et adapté à votre driver de base de données. Vous pouvez personnaliser la manière dont le timestamp est enregistré dans la base en utilisant le paramètre format.

// Stoque un timestamp au format ISO 8601 qui représente le moment
// où la ligne a été mise à jour pour la dernière fois.
@Timestamp(key: "updated_at", on: .update, format: .iso8601)
var updatedAt: Date?

Notez que la migration associée à cet exemple de format .iso8601 nécessiterait un stoquage au format .string.

.field("updated_at", .string)

Voici une liste des formats timestamp disponibles :

Format Description Type
.default Utilise un encodage datetime efficace et spécifique à votre driver de base de données. Date
.iso8601 Chaîne ISO 8601. Accepte un paramètre withMilliseconds. String
.unix Le nombre de secondes écoulées depuis l’Epoch Unix, avec ses décimales. Double

Vous pouvez accéder à la valeur brute du timestamp grâce à la propriété timestamp.

// Définition manuelle de la valeur du timestamp sur propriété
// @Timestamp formattée en ISO 8601.
model.$updatedAt.timestamp = "2020-06-03T16:20:14+00:00"

Suppression douce#

L’ajout d’un @Timestamp utilisant le déclencheur .delete activera la fonctionnalité de suppression douce pour votre modèle.

final class Planet: Model {
    // Indique le moment où cette planète a été supprimée.
    @Timestamp(key: "deleted_at", on: .delete)
    var deletedAt: Date?
}

La suppression douce conserve les données dans votre base, mais les lignes en question ne seront plus retournées par les requêtes.

Astuce

Vous pouvez manuellement définir un timestamp de suppression à une date future. Cela peut vous servir de mécanisme d’expiration.

Pour forcer un modèle qui a la suppression douce activée a être supprimé définitivement de votre base de données, utilisez le paramètre force de la méthode delete.

// Suppression de la base de données, même si la
// suppression douce est activée pour ce modèle.
model.delete(force: true, on: database)

Vous pouvez restaurer un modèle en état de suppression douce grâce à la méthode restore.

// Supprime le timestamp enregistré par le déclencheur .delete,
// permettant à ce modèle d'être requêté à nouveau. 
model.restore(on: database)

Vous pouvez également inclure les modèles marqués en suppression douce dans vos résultats de requêtes classiques en ajoutant un appel à withDeleted.

// Récupère toutes les planètes, dont celles qui ont été marquées en suppression douce.
Planet.query(on: database).withDeleted().all()

Enum#

@Enum est un type @Field particulier, qui permet le stoquage de valeurs textuelles en énumérations natives pour les bases de données. Les énumérations natives de bases de données vous offrent une couche supplémentaire de sécurisation au niveau du type de données et peuvent avoir de meilleures performances que des énumérations brutes.

// Enumération textuelle et codable représentant des types d'animaux.
enum Animal: String, Codable {
    case dog, cat
}

final class Pet: Model {
    // Stoque le type d'animal en tant qu'énumération native en base de données.
    @Enum(key: "type")
    var type: Animal
}

Seuls les types conformes à RawRepresentable, où RawValue est de type String sont compatibles avec @Enum. Les énumérations basées sur le type String remplissent cette condition par défaut.

Pour stoquer une énumération facultative, utilisez @OptionalEnum.

La base de données doit être préparée en amont par une migration. Voir enum pour plus d’informations à ce sujet.

Enums brutes#

Toute énumération basée sur un type Codable, comme String ou Int, peut être stoquée dans un @Field. Sa valeur brute sera stoquée en base.

Group#

Le type @Group vous permet de stoquer un groupe de plusieurs champs imbriqués en tant que propriété unique dans votre modèle. À l’inverse d’une struct Codable stoquée dans un champ @Field, les champs présents dans un @Group sont requêtables. Fluent accomplit ce résultat en stoquant les champs @Group comme une structure plate dans votre base de données, chaque propriété correspondant à une colonne de la table ou collection du modèle dans lequel le groupe est inclus.

Pour utiliser un @Group, commencez par définir la structure imbriquée que vous souhaitez enregistrer, et conformez-la au protocole Fields. Vous remarquerez des similitudes avec le protocole Model, mais aucun identifiant ou nom de schéma n’est nécessaire ici. Vous pouvez stoquer ici plusieurs propriétés compatibles avec Model, comme @Field, @Enum, ou même d’autres @Group.

// Un animal de compagnie, avec son nom et son espèce.
final class Pet: Fields {
    // Le nom de l'animal.
    @Field(key: "name")
    var name: String

    // L'espèce de l'animal. 
    @Field(key: "type")
    var type: String

    // Crée un nouvel animal vide.
    init() { }
}

Quand vous avez fini de créer le définition des champs, vous pouvez l’utiliser pour définir la valeur d’une propriété @Group.

final class User: Model {
    // L'animal de compagnie de l'utilisateur, imbriqué dans le schéma utilisateur.
    @Group(key: "pet")
    var pet: Pet
}

Les champs d’un @Group sont accessibles avec la syntaxe à points suivante :

let user: User = ...
print(user.pet.name) // String

Vous pouvez inclure des champs imbriqués dans vos requêtes avec cette même syntaxe à points au niveau des PropertyWrappers.

User.query(on: database).filter(\.$pet.$name == "Zizek").all()

Dans la base de données, le @Group est stoqué à plat, les clés étant définies par un préfixe représentant le nom de votre groupe, et contanénées avec le nom du champ par un underscore intermédiaire (_). Voici un exemple de ce à quoi ressemblerait le modèle User dans votre base de données :

id name pet_name pet_type
1 Tanner Zizek Cat
2 Logan Runa Dog

Codable#

Les modèles sont conformes à Codable par défaut. Vous pouvez donc les utiliser avec l’API contenu de Vapor en les conformant au protocole Content.

extension Planet: Content { }

app.get("planets") { req async throws in 
    // Retourne un tableau de toutes les planètes.
    try await Planet.query(on: req.db).all()
}

Pendant la sérialisation vers / depuis Codable, les noms utilisés seront ceux des propriétés du modèle et non les clés de la base de données. Les relations seront sérialisées comme structures imbriquées, et toute donnée de relation pré-chargée sera également incluse.

Info

Pour la majorité des cas, nous vous recommandons d’utiliser un DTO plutôt qu’un modèle pour vos réponses d’API et corps de requêtes. Voir la section Data Transfer Object pour plus de détails.

Data Transfer Object#

La conformité par défaut des modèles à Codable peut simplifier les prototypages et cas de base. Cependant, cela a comme inconvénient d’exposer les informations internes de votre base de données à votre API. Cela n’est généralement pas souhaitable d’un point de vue sécurité - retourner des informations sensibles telles que le hash d’un mot de passe utilisateur est une mauvaise idée - mais aussi d’un point de vue praticité de maintenance et d’évolution. Cela complexifie les changements au niveau du schéma de base de données qui ne devrait pas avoir d’impact sur l’API, comme accepter ou modifier des données dans un format différent, ou bien y ajouter ou en supprimer des champs.

Vous devriez utiliser un DTO (data transfer object) dans la plupart des cas, au lieu d’un modèle (aussi appelé “domain transfer object”). Un DTO est un type Codable différent qui représente une structure de données que vous souhaitez encoder ou décoder. Ils permettent le découplage entre votre API et votre schéma de base de données, et vous permettent de modifier vos modèles sans casser vos API publiques, de maintenir différentes versions en parallèle, et rendre vos API plus propres pour vos clients.

Supposez le modèle User ci-dessous pour les exemples suivants :

// Modèle User simplifié, pour référence.
final class User: Model {
    @ID(key: .id)
    var id: UUID?

    @Field(key: "first_name")
    var firstName: String

    @Field(key: "last_name")
    var lastName: String
}

Un exemple de cas d’usage classique pour les DTOs est l’implémentation de requêtes HTTP PATCH. Ces requêtes n’incluent que les valeurs des champs à mettre à jour. Essayer de décoder directement un Model depuis une de ces requêtes échouerait si un seul des champs requis était manquant. Dans l’exemple ci-dessous, vous pouvez observer l’usage d’un DTO pour décoder une requête et mettre à jour un modèle.

// Structure d'une requête HTTP PATCH sur /users/:id.
struct PatchUser: Decodable {
    var firstName: String?
    var lastName: String?
}

app.patch("users", ":id") { req async throws -> User in 
    // Décode les données de la requête.
    let patch = try req.content.decode(PatchUser.self)
    // Récupère l'utilisateur depuis la base de données.
    guard let user = try await User.find(req.parameters.get("id"), on: req.db) else {
        throw Abort(.notFound)
    }
    // Si le prénom est fourni, on le met à jour.
    if let firstName = patch.firstName {
        user.firstName = firstName
    }
    // Si le nom de famille est fourni, on le met à jour.
    if let lastName = patch.lastName {
        user.lastName = lastName
    }
    // L'utilisateur est mis à jour, puis retourné en réponse.
    try await user.save(on: req.db)
    return user
}

Un autre cas d’usage parlant pour un DTO est la personnalisation du format de vos réponses. L’exemple ci-dessous démontre comment un DTO peut être utilisé pour ajouter un champ calculé à une réponse.

// Structure d'une réponse à GET /users.
struct GetUser: Content {
    var id: UUID
    var name: String
}

app.get("users") { req async throws -> [GetUser] in 
    // Récupère tous les utilisateurs de la base de données.
    let users = try await User.query(on: req.db).all()
    return try users.map { user in
        // Convertit chaque utilisateur au bon format de réponse.
        try GetUser(
            id: user.requireID(),
            name: "\(user.firstName) \(user.lastName)"
        )
    }
}

Un autre cas d’usage concerne les relations, comme celles de parent à enfants ou d’enfant à parent. Voir la documentation de Parent pour un exemple couvrant le décodage d’un modèle comportant une relation de type @Parent.

Même si la structure du DTO est strictement identique au modèle conforme à Codable, le déclarer comme type séparé peut aider de gros projets à rester organisés. Si vous veniez à devoir changer les propriétés de votre modèle, vous n’auriez pas à vous soucier de casser votre API publique. Vous pourriez également définir vos DTO dans un package séparé que vous pourriez partager avec les clients de votre API, et ajouter la conformité à Content dans votre application Vapor.

Alias#

Le protocole ModelAlias vous permet d’identifier de façon unique un même modèle utilisé plusieurs fois dans la même requête. Plus d’informations sont disponibles dans la section sur les jointures.

Enregistrement#

Pour enregistrer un modèle dans la base, utilisez la méthode save(on:).

planet.save(on: database)

Cette méthode appellera create ou update en interne, en fonction de si le modèle existe déjà en base de données ou non.

Création#

Vous pouvez utiliser la méthode create pour enregistrer un nouveau modèle en base de données.

let planet = Planet(name: "Earth")
planet.create(on: database)

create est également disponible sur les tableaux de modèles. Cela enregistre tout les modèles en base de données en un seul lot / une seule requête.

// Exemple de lot à créer.
[earth, mars].create(on: database)

Avertissement

Les modèles qui utilisent @ID(custom:) avec le générateur .database (souvent un Int en auto-incrément) n’auront pas leur identifiant nouvellement créé accessible juste après la création par lot. Si vous avez besoin d’accéder à cet identifiant juste après, appelez create sur chaque modèle.

Pour une création séparée de chaque modèle du tableau, utilisez map + flatten.

[earth, mars].map { $0.create(on: database) }
    .flatten(on: database.eventLoop)

Ou si vous utilisez la syntaxe async/await :

await withThrowingTaskGroup(of: Void.self) { taskGroup in
    [earth, mars].forEach { model in
        taskGroup.addTask { try await model.create(on: database) }
    }
}

Mise à jour#

Vous pouvez utiliser la méthode update pour enregistrer un modèle précédemment récupéré depuis la base de données.

guard let planet = try await Planet.find(..., on: database) else {
    throw Abort(.notFound)
}
planet.name = "Earth"
try await planet.update(on: database)

Pour mettre à jour un tableau de modèles, utilisez map + flatten.

[earth, mars].map { $0.update(on: database) }
    .flatten(on: database.eventLoop)

// TODO

Query#

Les modèles exposent la méthode statique query(on:) qui retourne un QueryBuilder.

Planet.query(on: database).all()

Vous en apprendrez d’avantage sur les requêtes dans la section dédiée.

Find#

Les modèles exposent la méthode statique find(_:on:) pour chercher une instance de modèle par son identifiant.

Planet.find(req.parameters.get("id"), on: database)

Cette méthode retourne nil si aucune correspondance n’est trouvée pour cet identifiant.

Cycle de vie#

Le ModelMiddleware vous permet d’écouter les évènements du cycle de vie de votre modèle pour y exécuter votre propre logique. Les évènements suivants sont disponibles :

Methode Description
create S’exécute avant la création du modèle.
update S’exécute avant la mise à jour du modèle.
delete(force:) S’exécute avant la suppression du modèle.
softDelete S’exécute avant le mise en suppression douce du modèle.
restore S’exécute avant la restauration d’un modèle en état de suppression douce.

Un ModelMiddleware se déclare en utilisant les protocoles ModelMiddleware ou AsyncModelMiddleware. Chaque méthode du cycle de vie possède une implémentation par défaut, vous n’avez donc besoin d’implémenter que celles qui vous seront réellement utiles. Chaque méthode reçoit le modèle en question, une référence vers la base de données, et la prochaine action de la chaîne. Le middleware peut choisir de faire un retour anticipé, de retourner un futur compromis, ou d’appeler l’action suivante et continuer normalement.

Grâce à ces méthodes, vous pourrez exécuter des actions avant et/ou après l’évènement écouté. L’exécution d’actions à postériori peut se faire en mappant le futur retourné par le répondeur suivant de la chaîne.

// Exemple de middleware qui change la casse du nom.
struct PlanetMiddleware: ModelMiddleware {
    func create(model: Planet, on db: Database, next: AnyModelResponder) -> EventLoopFuture<Void> {
        // Le modèle peut être modifié ici avant d'être créé.
        model.name = model.name.capitalized()
        return next.create(model, on: db).map {
            // Une fois la planète créée, ce code 
            // sera exécuté.
            print ("Planet \(model.name) was created")
        }
    }
}

Ou si vous utilisez async/await :

struct PlanetMiddleware: AsyncModelMiddleware {
    func create(model: Planet, on db: Database, next: AnyAsyncModelResponder) async throws {
        // Le modèle peut être modifié ici avant d'être créé.
        model.name = model.name.capitalized()
        try await next.create(model, on: db)
        // Une fois la planète créée, ce code 
        // sera exécuté.
        print ("Planet \(model.name) was created")
    }
}

Une fois votre middleware créé, vous pouvez l’activer grâce à app.databases.middleware.

// Exemple de configuration de middleware de modèle.
app.databases.middleware.use(PlanetMiddleware(), on: .psql)

Espace de base de données#

Fluent permet de définir un espace pour chaque Model, ce qui permet le partitionnement de modèles individuels dans Fluent entre les schémas PostgreSQL, les bases de données MySQL, et de multiples bases de données SQLites associées. MongoDB ne propose pas la fonctionnalité d’espaces de bases de données au moment où cette documentation est écrite. Pour placer un modèle dans un autre espace que celui par défaut, ajoutez une nouvelle propriété statique au modèle :

public static let schema = "planets"
public static let space: String? = "mirror_universe"

// ...

Fluent l’utilisera pour toutes ses requêtes à la base de données.

Modifier cette page