Saltar a contenido

Modelos

Los modelos representan datos guardados en tablas o colecciones en tu base de datos. Los modelos tienen uno o más campos que guardan valores codificables. Todos los modelos tienen un identificador único. Los empaquetadores de propiedades (property wrappers) se usan para denotar identificadores, campos y relaciones.

A conntinuación hay un ejemplo de un modelo simple con un campo. Cabe destacar que los modelos no describen el esquema completo (restricciones, índices, claves foráneas...) de la base de datos. Los esquemas se definen en migraciones. Los modelos se centran en representar los datos guardados en los esquemas de tu base de datos.

final class Planet: Model {
    // Nombre de la tabla o colección.
    static let schema = "planets"

    // Identificador único para este Planet.
    @ID(key: .id)
    var id: UUID?

    // El nombre del Planet.
    @Field(key: "name")
    var name: String

    // Crea un nuevo Planet vacío.
    init() { }

    // Crea un nuevo Planet con todas sus propiedades establecidas.
    init(id: UUID? = nil, name: String) {
        self.id = id
        self.name = name
    }
}

Esquema

Todos los modelos requieren una propiedad estática de sólo lectura (get-only) llamada schema. Esta cadena hace referencia al nombre de la tabla o colección que el modelo representa.

final class Planet: Model {
    // Nombre de la tabla o colección.
    static let schema = "planets"
}

Al consultar este modelo, los datos serán recuperados de y guardados en el esquema llamado "planets".

Consejo

El nombre del esquema típicamente es el nombre de la clase en plural y en minúsculas.

Identificador

Todos los modelos deben tener una propiedad id definida usando el property wrapper @ID. Este campo identifica instancias de tu modelo unívocamente.

final class Planet: Model {
    // Identificador único para este Planet.
    @ID(key: .id)
    var id: UUID?
}

Por defecto la propiedad @ID debería usar la clave especial .id, que se resuelve en una clave apropiada para el conector (driver) de la base de datos subyacente. Para SQL es "id" y para NoSQL es "_id".

El @ID también debería ser de tipo UUID. Este es el único valor de identificación soportado por todos los conectores de bases de datos. Fluent generará nuevos identificadores UUID automáticamente en la creación de modelos.

@ID tiene un valor opcional dado que los modelos no guardados pueden no tener un identificador. Para obtener el identificador o lanzar un error, usa requireID.

let id = try planet.requireID()

Exists

@ID tiene una propiedad exists que representa si el modelo existe o no en la base de datos. Cuando inicializas un modelo, el valor es false. Después de guardar un modelo o recuperarlo de la base de datos, the value is true. Esta propiedad es mutable.

if planet.$id.exists {
    // Este modelo existe en la base de datos.
}

Identificador Personalizado

Fluent soporta claves y tipos de identificadores personalizados mediante la sobrecarga @ID(custom:).

final class Planet: Model {
    // Identificador único para este Planet.
    @ID(custom: "foo")
    var id: Int?
}

El ejemplo anterior usa un @ID con la clave personalizada "foo" y el tipo identificador Int. Esto es compatible con bases de datos SQL que usen claves primarias de incremento automático, pero no es compatible con NoSQL.

Los @IDs personalizados permiten al usuario especificar cómo debería generarse el identificador usando el parámetro generatedBy.

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

El parámetro generatedBy soporta estos casos:

Generated By Descripción
.user Se espera que la propiedad @ID sea establecida antes de guardar un nuevo modelo.
.random El tipo del valor @ID debe conformarse a RandomGeneratable.
.database Se espera que la base de datos genere el valor durante el guardado.

Si el parámetro generatedBy se omite, Fluent intentará inferir un caso apropiado basado en el tipo de valor del @ID. Por ejemplo, Int usará por defecto la generación .database si no se especifica lo contrario.

Inicializador

Los modelos deben tener un método inicializador (init) vacío.

final class Planet: Model {
    // Crea un nuevo Planet vacío.
    init() { }
}

Fluent necesita este método internamente para inicializar modelos devueltos por consultas. También es usado para el espejado (reflection).

Puedes querer añadir a tu modelo un inicializador de conveniencia que acepte todas las propiedades.

final class Planet: Model {
    // Crea un nuevo Planet con todas las propiedades establecidas.
    init(id: UUID? = nil, name: String) {
        self.id = id
        self.name = name
    }
}

El uso de inicializadores de conveniencia facilita añadir nuevas propiedades al modelo en un futuro.

Campo

Los modelos pueden tener o no propiedades @Field para guardar datos.

final class Planet: Model {
    // El nombre del Planet.
    @Field(key: "name")
    var name: String
}

Los campos requieren que la clave de la base de datos sea definida explícitamente. No es necesario que sea igual que el nombre de la propiedad.

Consejo

Fluent recomienda usar snake_case para claves de bases de datos y camelCase para nombres de propiedades.

Los valores de los campos pueden ser de cualquier tipo conformado a Codable. Guardar estructuras y colecciones (arrays) anidados en @Field está soportado, pero las operaciones de filtrado son limitadas. Ve a @Group para una alternativa.

Para campos que contengan un valor opcional, usa @OptionalField.

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

Advertencia

Un campo no opcional que tenga una propiedad observadora willSet que referencie su valor actual o una propiedad observadora didSet que referencie a oldValue dará un error fatal.

Relaciones

Los modelos pueden tener ninguna o más propiedades relacionales referenciando otros modelos, @Parent, @Children y @Siblings. Aprende más sobre las relaciones en la sección de relaciones.

Timestamp

@Timestamp es un tipo especial de @Field que guarda un Foundation.Date. Las timestamps (marcas de tiempo) son establecidas automáticamente por Fluent dependiendo del disparador (trigger) seleccionado.

final class Planet: Model {
    // Cuando este Planet fue creado.
    @Timestamp(key: "created_at", on: .create)
    var createdAt: Date?

    // Cuando este Planet fue actualizado por última vez.
    @Timestamp(key: "updated_at", on: .update)
    var updatedAt: Date?
}

@Timestamp soporta los siguiente triggers.

Trigger Descripción
.create Establecido cuando una nueva instancia de modelo es guardada en la base de datos.
.update Establecido cuando una instancia de modelo ya existente es guardada en la base de datos.
.delete Establecido cuando un modelo es eliminado de la base de datos. Ver soft delete.

El valor de fecha de los @Timestamps es opcional y debería establecer a nil al inicializar un nuevo modelo.

Formato de Timestamp

Por defecto @Timestamp usará una codificación eficiente para datetime basada en el controlador de tu base de datos. Puedes personalizar cómo la marca de tiempo se guarda en la base de datos usando el parámetro format.

// Guarda un timestamp formateado según ISO 8601 representando
// cuando este modelo fue actualizado por última vez.
@Timestamp(key: "updated_at", on: .update, format: .iso8601)
var updatedAt: Date?

Cabe destacar que la migración asociada al ejemplo con .iso8601 necesitaría ser guardado en formato .string.

.field("updated_at", .string)

A continuación hay un listado de los formatos disponibles para timestamp.

Formato Descripción Tipo
.default Usa codificación eficiente de datetime específica para la base de datos. Date
.iso8601 Cadena ISO 8601. Soporta parámetro withMilliseconds. String
.unix Segundos desde época Unix incluyendo fracción. Double

Puedes acceder el valor puro (raw value) del timestamp directamente usando la propiedad timestamp.

// Establece manualmente el timestamp en este
// @Timestamp formateado con ISO 8601.
model.$updatedAt.timestamp = "2020-06-03T16:20:14+00:00"

Soft Delete

Añadir un @Timestamp que use el disparador .deletea tu modelo habilitará el borrado no permanente (soft-deletion).

final class Planet: Model {
    // Cuando este Planet fue borrado.
    @Timestamp(key: "deleted_at", on: .delete)
    var deletedAt: Date?
}

Los modelos eliminados de manera no permanente siguen existiendo en la base de datos después de su borrado, pero no serán devueltos en las consultas.

Consejo

Puedes establecer manualmente un timestamp de borrado con una fecha en el futuro. Puede usarse como una fecha de caducidad.

Para forzar el borrado de un modelo de eliminación no permanente en la base de datos, usa el parámetro force en delete.

// Elimina el modelo de la base de datos
// inclusive si es soft-deletable 
model.delete(force: true, on: database)

Para restaurar un modelo eliminado no permanentemente, usa el método restore.

// Limpia en timestamp de borrado del modelo
// para que pueda devolverse en consultas. 
model.restore(on: database)

Para incluir modelos eliminados no permanentemente en una consulta, usa withDeleted.

// Recupera todos los planetas, incluidos los eliminados no permanentemente.
Planet.query(on: database).withDeleted().all()

Enum

@Enum es un tipo especial de @Field para guardar tipos representables mediante cadenas como enumeraciones nativas de bases de datos. Las enumeraciones nativas de bases de datos proporcionan una capa añadida de seguridad de tipos a tu base de datos y pueden ser más óptimas que las enumeraciones en bruto (raw enums).

// Enum Codable y representable mediante String para tipos de animales.
enum Animal: String, Codable {
    case dog, cat
}

final class Pet: Model {
    // Guarda el tipo de animal como un enum nativo de base de datos.
    @Enum(key: "type")
    var type: Animal
}

Solo los tipos conformados a RawRepresentable donde el RawValue es String son compatibles con @Enum. Los enums soportados por String cumplen este requisito por defecto.

Para guardar una enumeración opcional, usa @OptionalEnum.

La base de datos debe prepararse para manejar enumeraciones via una migración. Ver enum para más información.

Raw Enums

Cualquier enumeración respaldada por una tipo de dato Codable, como String o Int, puede guardarse en @Field. Se guardará en la base de datos como el dato en bruto.

Grupo

@Group te permite guardar un grupo anidado de campos en tu modelo como una única propiedad. Al contrario que las structs conformadas con Codable guardadas en un @Field, los campos en un @Group son consultables. Fluent consigue esto guardando @Group como una estructura plana en la base de datos.

Para usar un @Group, primero define la estructura anidada que quieres guardar usando el protocolo Fields. Es muy similar a Model, pero no requiere identificador ni nombre de esquema. Aquí puedes guardar muchas de las propiedades soportadas por Model, como @Field, @Enum, o inclusive otro @Group.

// Una mascota (pet) con nombre y tipo de animal.
final class Pet: Fields {
    // El nombre de la mascota.
    @Field(key: "name")
    var name: String

    // El tipo de la mascota. 
    @Field(key: "type")
    var type: String

    // Crea un nuevo Pet vacío.
    init() { }
}

Después de crear la definición de la estructura anidada (fields), puedes usarla como valor de una propiedad @Group.

final class User: Model {
    // La mascota anidada al usuario.
    @Group(key: "pet")
    var pet: Pet
}

Los campos de un @Group son accesibles mediante la sintaxis de punto (dot-syntax).

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

Puedes consultar campos anidados de la manera habitual usando sintaxis de punto en los empquetadores de propiedad (property wrappers).

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

En la base de datos, @Group se guarda como una estructura plana con claves unidas mediante _. A continuación hay un ejemplo de cómo se vería User en la base de datos.

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

Codable

Los modelos se conforman a Codable por defecto. Esto te permite usar tus modelos con la API de contenido de Vapor añadiendo la conformidad al protocolo Content.

extension Planet: Content { }

app.get("planets") { req async throws in 
    // Devuelve un array de todos los planetas.
    try await Planet.query(on: req.db).all()
}

Al serializar desde/hacia Codable, las propiedades del modelo usarán sus nombres de variable en lugar de las claves. Las relaciones serán serializadas como estructuras anidadas y cualquier carga previa (eager loading) de datos será incluida.

Data Transfer Object

La conformidad por defecto del modelo a Codable puede facilitar el prototipado y usos simples. Sin embargo, no se ajusta a todos los casos de uso. Para ciertas situaciones necesitarás usar un data transfer object (DTO).

Consejo

Un DTO es un tipo Codable aparte que representa la estructura de datos que quieres codificar/descodificar.

Toma como base el siguiente modelo de User para los ejemplos a continuación.

// Modelo de usuario reducido como referencia.
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 caso de uso común de DTOs es la implementación de peticiones (request) PATCH. Estas peticiones solo incluyen valores para los campos que deberían actualizarse. La decodificación de un Model directamente de esa petición fallaría si cualquiera de los campos no opcionales faltase. En el siguiente ejemplo puedes ver cómo se usa un DTO para decodificar datos de la petición y actualizar un modelo.

// Structure of PATCH /users/:id request.
struct PatchUser: Decodable {
    var firstName: String?
    var lastName: String?
}

app.patch("users", ":id") { req async throws -> User in 
    // Decodificar los datos de la petición.
    let patch = try req.content.decode(PatchUser.self)
    // Recuperar el usuario deseado de la base de datos.
    guard let user = try await User.find(req.parameters.get("id"), on: req.db) else {
        throw Abort(.notFound)
    }
    // Si se diera un nombre, se actualiza.
    if let firstName = patch.firstName {
        user.firstName = firstName
    }
    // Si se diera un apellido, se actualiza.
    if let lastName = patch.lastName {
        user.lastName = lastName
    }
    // Guarda el usuario y lo devuelve.
    try await user.save(on: req.db)
    return user
}

Otro uso común de DTO es personalizar el formato de las respuestas de tu API. El ejemplo a continuación muestra cómo puede usarse un DTO para añadir un campo computado a una respuesta.

// Estructura de la respuesta GET /users.
struct GetUser: Content {
    var id: UUID
    var name: String
}

app.get("users") { req async throws -> [GetUser] in 
    // Recuperar todos los usuarios de la base de datos.
    let users = try await User.query(on: req.db).all()
    return try users.map { user in
        // Convertir cada usuario al tipo de devolución para GET.
        try GetUser(
            id: user.requireID(),
            name: "\(user.firstName) \(user.lastName)"
        )
    }
}

Aunque la estructura del DTO sea idéntica a la del modelo conformado con Codable, tenerlo como tipo aparte puede ayudar a mantener los proyectos grandes ordenados. Si necesitaras hacer un cambio en las propiedades de tu modelo, no tienes que preocuparte de si rompes la API pública de tu app. Puedes considerar agrupar tus DTO en un package aparte que puedas compartir con los consumidores de tu API.

Por estas razones, recomendamos encarecidamente usar DTO siempre que sea posible, especialmente en proyectos grandes.

Alias

El protocolo ModelAlias te permite identificar de manera unívoca un modelo unido varias veces en una consulta. Para más información, consulta uniones.

Guardar

Para guardar un modelo en la base de datos, usa el método save(on:).

planet.save(on: database)

Este método llamará internamente a create o update dependiendo de si el modelo ya existe o no en la base de datos.

Crear

Puedes llamar al método create para guardar un modelo nuevo en la base de datos.

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

create también está disponible en una colección (array) de modelos. Con esto puedes guardar en la base de datos todos los modelos en una única remesa (batch) / consulta (query).

// Ejemplo de batch create.
[earth, mars].create(on: database)

Advertencia

Los modelos que usen @ID(custom:) con el generador .database (normalmente Ints de incremento automático) no tendrán sus identificadores recién creados después del batch create. Para situaciones en las que necesites acceder a los identificadores llama a create en cada modelo.

Para crear una colección de modelos individualmente usa map + flatten.

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

Si estás usando async/await puedes usar:

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

Actualizar

Puedes llamar al método update para guardar un modelo recuperado de la base de datos.

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

Para actualizar una colección de modelos usa map + flatten.

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

// TOOD

Consultar

Los modelos exponen un método estático llamado query(on:) que devuelve un constructor de consultas (query builder).

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

Aprende más sobre las consultas en la sección de consulta.

Encontrar

Los modelos tienen un método estático llamado find(_:on:) para encontrar una instancia del modelo por su identificador.

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

Este método devuelve nil si no se ha encontrado ningún modelo con ese identificador.

Ciclo de vida

Los middleware de modelo te permiten engancharte a los eventos del ciclo de vida de tu modelo. Están soportados los siguientes eventos de ciclo de vida (lifecycle):

Método Descripción
create Se ejecuta antes de crear un modelo.
update Se ejecuta antes de actualizar un modelo.
delete(force:) Se ejecuta antes de eliminar un modelo.
softDelete Se ejecuta antes de borrar de manera no permanente (soft-delete) un modelo.
restore Se ejecuta antes de restaurar un modelo (opuesto de soft delete).

Los middleware de modelo se declaran usando los protocolos ModelMiddleware o AsyncModelMiddleware. Todos los eventos de ciclo de vida tienen una implementación por defecto, así que solo necesitas implementar aquellos que necesites. Cada método acepta el modelo en cuestión, una referencia a la base de datos y la siguiente acción en la cadena. El middleware puede elegir devolver pronto, devolver un futuro fallido o llamar a la siguiente acción para continuar de manera normal.

Si usas estos métodos, puedes llevar a cabo acciones tanto antes como después de que un evento en específico se complete. Puedes llevar a cabo acciones después de que el evento se complete si asignas el futuro devuelto por el siguiente respondedor.

// Middleware de ejemplo que capitaliza nombres.
struct PlanetMiddleware: ModelMiddleware {
    func create(model: Planet, on db: Database, next: AnyModelResponder) -> EventLoopFuture<Void> {
        // El modelo puede alterarse aquí antes de ser creado.
        model.name = model.name.capitalized()
        return next.create(model, on: db).map {
            // Una vez se haya creado el planeta
            // el código que haya aquí se ejecutará
            print ("Planet \(model.name) was created")
        }
    }
}

o si usas async/await:

struct PlanetMiddleware: AsyncModelMiddleware {
    func create(model: Planet, on db: Database, next: AnyAsyncModelResponder) async throws {
        // El modelo puede alterarse aquí antes de ser creado.
        model.name = model.name.capitalized()
        try await next.create(model, on: db)
        // Una vez se haya creado el planeta
        // el código que haya aquí se ejecutará
        print ("Planet \(model.name) was created")
    }
}

Una vez hayas creado tu middleware, puedes activarlo usando app.databases.middleware.

// Ejemplo de middleware de configuración de modelo.
app.databases.middleware.use(PlanetMiddleware(), on: .psql)

Espacio de BBDD

Fluent soporta establecer un espacio para un Model, lo que permite la partición de modelos individuales de Fluent entre esquemas de PostgreSQL, bases de datos MySQL, y varias bases de datos SQLite adjuntas. MongoDB no soporta los espacios en el momento de la redacción. Para poner un modelo en un espacio distinto al usado por defecto, añade una nueva propiedad estática al modelo:

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

// ...

Fluent usará esto para la construcción de todas las consultas a la base de datos.