SwiftData en Swift: @Model, @Query, ModelContext y relaciones entre modelos

Core Data siempre fue el framework de persistencia de Apple para iOS, pero su API basada en Objective-C, los NSManagedObject y la verbosidad de las migraciones de esquema lo hacían complicado. SwiftData, presentado en WWDC23, es la respuesta nativa en Swift: usa macros, integra directamente con SwiftUI, y reemplaza casi todo el boilerplate de Core Data con unas pocas líneas.

@Model: definir el esquema con Swift

La macro @Model convierte una clase Swift normal en un modelo persistente. No necesitas heredar de nada ni implementar protocolos especiales:

import SwiftData

@Model
final class Tarea {
    var titulo: String
    var completada: Bool
    var fechaCreacion: Date
    var prioridad: Int

    init(titulo: String, prioridad: Int = 0) {
        self.titulo = titulo
        self.completada = false
        self.fechaCreacion = .now
        self.prioridad = prioridad
    }
}

La macro genera automáticamente el esquema de persistencia, los identificadores únicos y el seguimiento de cambios. Todas las propiedades almacenadas son persistidas por defecto.

Propiedades excluidas y únicas

@Model
final class Usuario {
    @Attribute(.unique) var email: String  // Restricción de unicidad
    var nombre: String

    @Attribute(.externalStorage)
    var fotoPerfil: Data?  // Almacenado fuera de la BD (para datos grandes)

    @Transient  // No se persiste
    var sesionActiva: Bool = false

    init(email: String, nombre: String) {
        self.email = email
        self.nombre = nombre
    }
}

@Relationship: relaciones entre modelos

Las relaciones se definen con la macro @Relationship:

@Model
final class Proyecto {
    var nombre: String
    var descripcion: String

    @Relationship(deleteRule: .cascade, inverse: Tarea.proyecto)
    var tareas: [Tarea] = []

    init(nombre: String, descripcion: String = "") {
        self.nombre = nombre
        self.descripcion = descripcion
    }
}

@Model
final class Tarea {
    var titulo: String
    var completada: Bool
    var proyecto: Proyecto?  // Relación inversa

    init(titulo: String) {
        self.titulo = titulo
        self.completada = false
    }
}

Las reglas de borrado disponibles son: .nullify (pone la relación a nil), .cascade (borra los objetos relacionados), .deny (impide el borrado si hay objetos relacionados) y .noAction.

ModelContainer: configurar la pila de persistencia

ModelContainer es el equivalente al NSPersistentContainer de Core Data. Se configura una vez y se inyecta en el entorno de SwiftUI:

@main
struct MiApp: App {
    var body: some Scene {
        WindowGroup {
            ContentView()
        }
        .modelContainer(for: [Tarea.self, Proyecto.self])
    }
}

// O con configuración personalizada:
@main
struct MiApp: App {
    let contenedor: ModelContainer

    init() {
        let esquema = Schema([Tarea.self, Proyecto.self, Usuario.self])
        let configuracion = ModelConfiguration(
            schema: esquema,
            isStoredInMemoryOnly: false, // true para previews y tests
            allowsSave: true
        )
        contenedor = try! ModelContainer(for: esquema, configurations: [configuracion])
    }

    var body: some Scene {
        WindowGroup { ContentView() }
            .modelContainer(contenedor)
    }
}

@Query: leer datos en SwiftUI

@Query es la forma declarativa de leer y observar datos persistidos. La vista se actualiza automáticamente cuando los datos cambian:

struct ListaTareas: View {
    // Todas las tareas, ordenadas por fecha
    @Query(sort: Tarea.fechaCreacion, order: .reverse)
    private var tareas: [Tarea]

    // Solo las tareas completadas
    @Query(filter: #Predicate { $0.completada })
    private var tareasCompletadas: [Tarea]

    // Con predicado dinámico — se reconfigura con .query()
    @Query private var tareasProyecto: [Tarea]

    var body: some View {
        List(tareas) { tarea in
            TareaRow(tarea: tarea)
        }
    }
}

Para predicados dinámicos basados en parámetros externos, pasa la configuración de @Query como parámetro del inicializador:

struct TareasProyecto: View {
    let proyecto: Proyecto

    @Query private var tareas: [Tarea]

    init(proyecto: Proyecto) {
        self.proyecto = proyecto
        _tareas = Query(
            filter: #Predicate { tarea in
                tarea.proyecto?.nombre == proyecto.nombre
            },
            sort: .fechaCreacion
        )
    }

    var body: some View {
        List(tareas) { TareaRow(tarea: $0) }
    }
}

ModelContext: operaciones CRUD

ModelContext es el equivalente al NSManagedObjectContext. Lo obtienes del entorno para insertar, borrar y guardar:

struct FormularioTarea: View {
    @Environment(.modelContext) private var contexto
    @State private var titulo = ""

    var body: some View {
        Form {
            TextField("Título", text: $titulo)
            Button("Guardar") { guardar() }
        }
    }

    func guardar() {
        let tarea = Tarea(titulo: titulo)
        contexto.insert(tarea) // Insertar
        try? contexto.save()   // Persistir (opcional — se autosalva)
    }
}

// Borrar
func borrar(_ tarea: Tarea, context: ModelContext) {
    context.delete(tarea)
}

// Borrar múltiples con predicado
func borrarCompletadas(context: ModelContext) throws {
    try context.delete(model: Tarea.self,
                       where: #Predicate { $0.completada })
}

Migraciones de esquema

SwiftData gestiona las migraciones con VersionedSchema y SchemaMigrationPlan:

enum TareasSchemaV1: VersionedSchema {
    static var versionIdentifier = Schema.Version(1, 0, 0)
    static var models: [any PersistentModel.Type] { [Tarea.self] }

    @Model final class Tarea {
        var titulo: String
        init(titulo: String) { self.titulo = titulo }
    }
}

enum TareasSchemaV2: VersionedSchema {
    static var versionIdentifier = Schema.Version(2, 0, 0)
    static var models: [any PersistentModel.Type] { [Tarea.self] }

    @Model final class Tarea {
        var titulo: String
        var prioridad: Int // Nueva propiedad
        init(titulo: String, prioridad: Int = 0) {
            self.titulo = titulo
            self.prioridad = prioridad
        }
    }
}

enum TareasMigracion: SchemaMigrationPlan {
    static var schemas: [any VersionedSchema.Type] {
        [TareasSchemaV1.self, TareasSchemaV2.self]
    }

    static var stages: [MigrationStage] {
        [.lightweight(fromVersion: TareasSchemaV1.self,
                      toVersion: TareasSchemaV2.self)]
    }
}

SwiftData en previews y tests

extension ModelContainer {
    static var preview: ModelContainer = {
        let config = ModelConfiguration(isStoredInMemoryOnly: true)
        let container = try! ModelContainer(
            for: Tarea.self,
            configurations: config
        )
        // Poblar con datos de prueba
        let contexto = container.mainContext
        contexto.insert(Tarea(titulo: "Tarea de ejemplo"))
        contexto.insert(Tarea(titulo: "Otra tarea"))
        return container
    }()
}

#Preview {
    ListaTareas()
        .modelContainer(.preview)
}

Resumen

SwiftData elimina la brecha entre el modelo de datos y el código Swift: los modelos son clases Swift normales, las consultas son propiedades SwiftUI, y las relaciones se definen con macros en lugar de editores de esquema visuales. Para apps nuevas que soporten iOS 17+, es la elección natural. Para proyectos con Core Data existente, puedes usar ambos frameworks en paralelo durante la transición.

COMPARTE ESTE ARTÍCULO

COMPARTIR EN FACEBOOK
COMPARTIR EN TWITTER
COMPARTIR EN LINKEDIN
COMPARTIR EN WHATSAPP