Введение

Недавно я реструктурировал одно из своих iOS-приложений и пересмотрел некоторые модели, чтобы повысить общую способность к развитию моего приложения. Чтобы облегчить эти изменения, мой Vapor API также требовал обновления. Это изменение привело к тому, что структура хранилища моего приложения стала более универсальной и простой в использовании для разных, но связанных объектов.

Недостатком было то, что некоторые таблицы в моей базе данных необходимо было изменить. Некоторым нужно было добавить или удалить столбец или два. Это было просто. Другие таблицы требовали более комплексного подхода, поскольку данные определенных столбцов необходимо было объединить и/или изменить перед помещением в новый столбец.

Лично я не особо разбираюсь в SQL, поэтому я хотел по возможности избегать написания необработанных операторов SQL. К сожалению, большинство примеров, которые мне удалось найти в Интернете, показали одну из двух вещей:

  1. Простая миграция, например добавление или удаление столбца или
  2. Немного более сложный пример, но с использованием необработанных операторов SQL.

Пример: миграция

В этой статье я буду использовать пример таблицы с тремя столбцами: hours, minutes, seconds. Цель состоит в том, чтобы перенести эти три столбца в один столбец duration, суммируя значения в каждом из столбцов после преобразования в секунды.

Теперь предположим, что мы начинаем со следующего объекта:

final class Event: Model {
    let hours: Double
    let minutes: Double
    let seconds: Double
    var duration: Double {
        return hours * 3600 + minutes * 60 + seconds
    }
    ...
}
extension Event: Preparation {
    static func prepare(_ database: Database) throws {
        try database.create(self) { builder in
            builder.id()
            builder.double(Keys.hours)
            builder.double(Keys.minutes)
            builder.double(Keys.seconds)
        }
    }
    static func revert(_ database: Database) throws {
        try database.delete(self)
    }
}

Понятно, что созданная нами таблица довольно ограничена и хранит кучу ненужных данных, которые можно упростить в один столбец. Упростим нашу модель.

final class Event: Model {
    let duration: Double
    ...
}

После обновления модели одна из проблем заключается в том, что база данных по-прежнему содержит данные, недоступные через объект. Это потому, что мы удалили свойства и при необходимости обновили наш метод init(row: Row). Другая проблема в том, что у нас нет значения для duration.

Добавление нового столбца

Добавить новый столбец duration очень просто. Нам просто нужно создать новую структуру миграции.

struct AddDurationColumn: Preparation {
    static func prepare(_ database: Database) throws {
        try database.modify(Event.self) { modifier in
            // Create the column with initial value of 0
            modifier.double(Keys.duration, default: 0)
        }
    }
        
    static func revert(_ database: Database) throws {
        try database.delete(Event.self)
    }
}

Миграция трех столбцов в один

Теперь наша база данных содержит четыре столбца: hours, minutes, seconds и duration. Наш следующий шаг — взять hours, minutes и seconds и объединить их в столбец duration. Мы сделаем это в три шага:

  1. Мы начинаем с перебора всех Event объектов и создания запроса для извлечения объекта из базы данных с id текущего event, обрабатываемого в цикле.
  2. Затем мы используем драйвер базы данных для получения Node, соответствующего значению, хранящемуся в каждом из трех столбцов, к которым нам нужен доступ. В этом примере значение wrapped узла представляет собой массив, содержащий тип значения, хранящийся в базе данных, — массив с одним объектом, который является Double.
  3. Получив значения, соответствующие hours, minutes и seconds, мы можем установить текущую продолжительность event, преобразовав hours и minutes в секунды, а затем просуммировав три значения. Чтобы обновить event, мы сохраняем его после присвоения соответствующего свойства.
struct UpdateDurationColumn: Preparation {
    static func prepare(_ database: Database) throws {
        try Event.all().forEach { event in
            // 1. Loop through all 'Event' objects and create a
            //    query to retrieve the row with the id that matches
            //    the current event's id
            let query = try Event.makeQuery().filter("id", event.id)
            // 2. Get the 'Node' corresponding to each value from
            //    the columns we need
            let hoursNode: Node = try database.driver
                .query(query)
                .get("hours")
            let minutesNode: Node = try database.driver
                .query(query)
                .get("minutes")
            let secondsNode: Node = try database.driver
                .query(query)
                .get("seconds")
            // You'll likely want to error handle better than 
            // just returning 
            guard let hrs = hoursNode.wrapped.array?.first?.double,
            let mins = minutesNode.wrapped.array?.first?.double,
            let secs = secondsNode.wrapped.array?.first?.double
            else { return }
            // 3. Update the event's duration and save it
            event.duration = hrs * 3600 + mins * 60 + secs
            try event.save()
        }
    }
    static func revert(_ database: Database) throws {
        try database.delete(Event.self)
    }
}

Примечание. Обычно я не добавляю новую строку после каждого вызова функции, однако, чтобы улучшить читаемость кода в этой статье, я решил включить их. То же самое можно сказать и о сокращении имен переменных.

Удаление старых столбцов

Чтобы закончить, теперь нам нужно удалить старые столбцы. Так же, как добавить столбец, удалить его просто.

struct DeleteHMSColumns: Preparation {
    static func prepare(_ database: Database) throws {
        try database.modify(Event.self) { modifier in
            modifier.delete("hours")
            modifier.delete("minutes")
            modifier.delete("seconds")
        }
    }
    
    static func revert(_ database: Database) throws {
        try database.delete(Event.self)
    }
}

Убираться

Мы можем упростить все эти миграции в одну структуру миграции. Обратите внимание, что мы также обновили наш метод prepare:

extension Event: Preparation {
    static func prepare(_ database: Database) throws {
        try database.create(self) { builder in
            builder.id()
            builder.double(Keys.duration)
        }
    }
    
    static func revert(_ database: Database) throws {
        try database.delete(self)
    }
    
    struct DurationMigration: Preparation {
        static func prepare(_ database: Database) throws {
            // Create the column with initial value of 0
            try database.modify(Event.self) { modifier in
                modifier.double(Keys.duration, default: 0)
            }
            // Process old data and input into new column
            try Event.all().forEach { event in
                let query = try Event.makeQuery()
                    .filter("id", event.id)
                let hoursNode: Node = try database.driver
                    .query(query)
                    .get("hours")
                let minutesNode: Node = try database.driver
                    .query(query)
                    .get("minutes")
                let secondsNode: Node = try database.driver
                    .query(query)
                    .get("seconds")
                guard let hrs = hoursNode.wrapped.array?.first?
                    .double,
                let mins = minutesNode.wrapped.array?.first?
                    .double,
                let secs = secondsNode.wrapped.array?.first?
                    .double
                else { return }
                event.duration = hrs * 3600 + mins * 60 + secs
                try event.save()
            }
            // Delete hours, minutes, seconds columns
            try database.modify(Event.self) { modifier in
                modifier.delete("hours")
                modifier.delete("minutes")
                modifier.delete("seconds")
            }
        }
        
        static func revert(_ database: Database) throws {
            try database.delete(Event.self)
        }
    }
}

Чтобы эта миграция вступила в силу, мы должны добавить ее в наши приготовления.

preparations.append(Event.DurationMigration.self)

Последние мысли

Если вы тестируете это впервые или вам не совсем нравится написанный вами код миграции, не удаляйте нужные вам данные, пока не будете удовлетворены всеми остальными частями миграции. Это может помочь вам избежать необходимости воссоздавать данные со старой схемой, чтобы иметь возможность снова протестировать миграцию, если она пошла не так, как планировалось. Удаление одного нового столбца, который еще не использовался, намного лучше, чем удаление столбцов, в которых есть еще используемые данные.

Вывод

Мы успешно перенесли нашу исходную таблицу со столбцами для hours, minutes и seconds в более простую таблицу с одним столбцом duration. Надеюсь, эта статья поможет некоторым из вас или послужит отправной точкой для более сложных миграций в ваших приложениях Vapor.