Spec-Zone.ru › Ruby on Rails 8.1

класс ActiveRecord::Migration

Родительский класс:
Object

Миграции Active Record

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

Пример простой миграции:

class AddSsl < ActiveRecord::Migration[8.1]
  def up
    add_column :accounts, :ssl_enabled, :boolean, default: true
  end

  def down
    remove_column :accounts, :ssl_enabled
  end
end

Эта миграция добавит логический флаг в таблицу accounts и удалит его при откате миграции. Здесь показано, что все миграции имеют два метода up и down, описывающих преобразования, необходимые для применения или отмены миграции. Эти методы могут включать как специфичные для миграции методы, например add_column и remove_column, так и обычный код Ruby для создания данных, необходимых для преобразований.

Пример более сложной миграции, которой также требуется инициализировать данные:

class AddSystemSettings < ActiveRecord::Migration[8.1]
  def up
    create_table :system_settings do |t|
      t.string  :name
      t.string  :label
      t.text    :value
      t.string  :type
      t.integer :position
    end

    SystemSetting.create  name:  'notice',
                          label: 'Use notice?',
                          value: 1
  end

  def down
    drop_table :system_settings
  end
end

Сначала эта миграция добавляет таблицу system_settings, а затем создаёт в ней первую строку, используя модель Active Record, которая зависит от этой таблицы. Здесь также используется более продвинутый синтаксис create_table, позволяющий указать полную схему таблицы в одном блоке.

Доступные преобразования

Создание

  • create_join_table(table_1, table_2, options): Создаёт соединительную таблицу, имя которой составляется в лексикографическом порядке из первых двух аргументов. Подробнее см. в разделе ActiveRecord::ConnectionAdapters::SchemaStatements#create_join_table.

  • create_table(name, options): Создаёт таблицу с именем name и делает объект таблицы доступным в блоке, где можно добавлять в неё столбцы в том же формате, что и в add_column. См. пример выше. Хеш параметров предназначен для фрагментов вроде «DEFAULT CHARSET=UTF-8», которые добавляются к определению создаваемой таблицы.

  • add_column(table_name, column_name, type, options): Добавляет в таблицу table_name новый столбец с именем column_name, тип которого должен быть одним из следующих: :string, :text, :integer, :float, :decimal, :datetime, :timestamp, :time, :date, :binary, :boolean. Значение по умолчанию можно задать, передав хеш options, например { default: 11 }. Другие параметры включают :limit и :null (например, { limit: 50, null: false }). Подробнее см. в разделе ActiveRecord::ConnectionAdapters::TableDefinition#column.

  • add_foreign_key(from_table, to_table, options): Добавляет внешний ключ. from_table — это таблица со столбцом-ключом, а to_table содержит ссылку на первичный ключ.

  • add_index(table_name, column_names, options): Добавляет новый индекс с именем столбца. Другие параметры включают :name, :unique (например, { name: 'users_name_index', unique: true }) и :order (например, { order: { name: :desc } }).

  • add_reference(:table_name, :reference_name): Добавляет новый столбец reference_name_id, имеющий по умолчанию тип integer. Подробнее см. в разделе ActiveRecord::ConnectionAdapters::SchemaStatements#add_reference.

  • add_timestamps(table_name, options): Добавляет в table_name столбцы временных меток (created_at и updated_at).

Изменение

  • change_column(table_name, column_name, type, options): Изменяет тип столбца, используя те же параметры, что и add_column.

  • change_column_default(table_name, column_name, default_or_changes): Задаёт для column_name значение по умолчанию, определённое через default_or_changes, в таблице table_name. Передача хеша, содержащего :from и :to, в качестве default_or_changes позволит отменить это изменение при откате миграции.

  • change_column_null(table_name, column_name, null, default = nil): Устанавливает или удаляет ограничение NOT NULL для column_name. Флаг null указывает, может ли значение быть NULL. Подробнее см. в разделе ActiveRecord::ConnectionAdapters::SchemaStatements#change_column_null.

  • change_table(name, options): Позволяет изменять столбцы таблицы с именем name. Объект таблицы становится доступен в блоке, где можно добавлять и удалять столбцы, индексы или внешние ключи.

  • rename_column(table_name, column_name, new_column_name): Переименовывает столбец, сохраняя его тип и содержимое.

  • rename_index(table_name, old_name, new_name): Переименовывает индекс.

  • rename_table(old_name, new_name): Переименовывает таблицу old_name в new_name.

Удаление

  • drop_table(*names): Удаляет указанные таблицы.

  • drop_join_table(table_1, table_2, options): Удаляет соединительную таблицу, заданную указанными аргументами.

  • remove_column(table_name, column_name, type, options): Удаляет столбец с именем column_name из таблицы table_name.

  • remove_columns(table_name, *column_names): Удаляет указанные столбцы из определения таблицы.

  • remove_foreign_key(from_table, to_table = nil, **options): Удаляет указанный внешний ключ из таблицы table_name.

  • remove_index(table_name, column: column_names): Удаляет индекс, указанный параметром column_names.

  • remove_index(table_name, name: index_name): Удаляет индекс, указанный параметром index_name.

  • remove_reference(table_name, ref_name, options): Удаляет ссылки на table_name, заданные параметром ref_name.

  • remove_timestamps(table_name, options): Удаляет столбцы временных меток (created_at и updated_at) из определения таблицы.

Необратимые преобразования

Некоторые преобразования являются разрушительными и не могут быть отменены. Такие миграции должны вызывать исключение ActiveRecord::IrreversibleMigration в методе down.

Запуск миграций из Rails

В пакет Rails входят несколько инструментов для создания и применения миграций.

Чтобы создать новую миграцию, используйте

$ bin/rails generate migration MyNewMigration

где MyNewMigration — имя миграции. Генератор создаст пустой файл миграции timestamp_my_new_migration.rb в каталоге db/migrate/, где timestamp — дата и время создания миграции в формате UTC.

Для создания миграций, добавляющих поля в таблицу, предусмотрено специальное синтаксическое сокращение.

$ bin/rails generate migration add_fieldname_to_tablename fieldname:string

Эта команда создаст файл timestamp_add_fieldname_to_tablename.rb, который будет выглядеть так:

class AddFieldnameToTablename < ActiveRecord::Migration[8.1]
  def change
    add_column :tablenames, :fieldname, :string
  end
end

Чтобы запустить миграции для текущей настроенной базы данных, используйте bin/rails db:migrate. Эта команда обновит базу данных, выполнив все ожидающие миграции, и создаст таблицу schema_migrations, если она отсутствует (см. раздел «О таблице schema_migrations» ниже). Кроме того, будет вызвана команда db:schema:dump, которая обновит файл db/schema.rb в соответствии со структурой базы данных.

Чтобы откатить базу данных к предыдущей версии миграции, используйте bin/rails db:rollback VERSION=X, где X — версия, до которой нужно выполнить откат. Также можно использовать параметр STEP, чтобы откатить несколько последних миграций. bin/rails db:rollback STEP=2 выполнит откат двух последних миграций.

Если какая-либо миграция вызовет исключение ActiveRecord::IrreversibleMigration, этот шаг завершится с ошибкой, и вам придётся выполнить некоторые действия вручную.

Другие примеры

Не все миграции изменяют схему. Некоторые лишь исправляют данные:

class RemoveEmptyTags < ActiveRecord::Migration[8.1]
  def up
    Tag.all.each { |tag| tag.destroy if tag.pages.empty? }
  end

  def down
    # not much we can do to restore deleted data
    raise ActiveRecord::IrreversibleMigration, "Can't recover the deleted tags"
  end
end

Другие при применении миграции удаляют столбцы, а не при её откате:

class RemoveUnnecessaryItemAttributes < ActiveRecord::Migration[8.1]
  def up
    remove_column :items, :incomplete_items_count
    remove_column :items, :completed_items_count
  end

  def down
    add_column :items, :incomplete_items_count
    add_column :items, :completed_items_count
  end
end

А иногда нужно выполнить SQL-команду, для которой в миграциях нет прямой абстракции:

class MakeJoinUnique < ActiveRecord::Migration[8.1]
  def up
    execute "ALTER TABLE `pages_linked_pages` ADD UNIQUE `page_id_linked_page_id` (`page_id`,`linked_page_id`)"
  end

  def down
    execute "ALTER TABLE `pages_linked_pages` DROP INDEX `page_id_linked_page_id`"
  end
end

Использование модели после изменения её таблицы

Иногда нужно добавить столбец в миграции и сразу же заполнить его. В этом случае необходимо вызвать Base#reset_column_information, чтобы убедиться, что модель получила актуальные сведения о столбцах после добавления нового столбца. Пример:

class AddPeopleSalary < ActiveRecord::Migration[8.1]
  def up
    add_column :people, :salary, :integer
    Person.reset_column_information
    Person.all.each do |p|
      p.update_attribute :salary, SalaryCalculator.compute(p)
    end
  end
end

Управление подробностью вывода

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

Чтобы отключить этот вывод, задайте ActiveRecord::Migration.verbose = false.

Также можно выводить собственные сообщения и замеры времени с помощью метода say_with_time:

def up
  ...
  say_with_time "Updating salaries..." do
    Person.all.each do |p|
      p.update_attribute :salary, SalaryCalculator.compute(p)
    end
  end
  ...
end

После завершения блока будет выведена фраза «Обновление зарплат…» вместе с замером времени его выполнения.

Миграции с временными метками

По умолчанию Rails создаёт миграции следующего вида:

20080717013526_your_migration_name.rb

Префикс представляет собой временную метку создания (в формате UTC). Не следует изменять временные метки вручную. Чтобы проверить, что временные метки миграций соответствуют ожидаемому Active Record формату, можно использовать следующий параметр конфигурации:

config.active_record.validate_migration_timestamps = true

Если вы предпочитаете числовые префиксы, отключите миграции с временными метками, задав:

config.active_record.timestamped_migrations = false

В файле application.rb.

Обратимые миграции

Обратимые миграции умеют самостоятельно выполнять down. Достаточно указать логику up, и система Migration определит, как выполнить для вас команды отката.

Чтобы определить обратимую миграцию, добавьте в неё метод change, например:

class TenderloveMigration < ActiveRecord::Migration[8.1]
  def change
    create_table(:horses) do |t|
      t.column :content, :text
      t.column :remind_at, :datetime
    end
  end
end

При применении эта миграция создаст таблицу horses, а при откате автоматически определит, как удалить её.

Некоторые команды нельзя отменить. Если вы хотите определить действия для применения и отката в таких случаях, как и прежде, необходимо определить методы up и down.

Если команду нельзя отменить, при откате миграции будет вызвано исключение ActiveRecord::IrreversibleMigration.

Список обратимых команд см. в разделе ActiveRecord::Migration::CommandRecorder.

Транзакционные миграции

Если адаптер базы данных поддерживает транзакции DDL, все миграции автоматически будут выполняться в транзакции. Однако некоторые запросы нельзя выполнять внутри транзакции; в таких случаях можно отключить автоматические транзакции.

class ChangeEnum < ActiveRecord::Migration[8.1]
  disable_ddl_transaction!

  def up
    execute "ALTER TYPE model_size ADD VALUE 'new_value'"
  end
end

Помните, что вы по-прежнему можете открывать собственные транзакции, даже если находитесь в Migration с self.disable_ddl_transaction!.

Атрибуты

name [RW]
version [RW]

Публичные методы класса

[] (version) Показать исходный код
# File activerecord/lib/active_record/migration.rb, line 629
def self.[](version)
  Compatibility.find(version)
end
check_all_pending! () Показать исходный код
# File activerecord/lib/active_record/migration.rb, line 693
def check_all_pending!
  pending_migrations = []

  ActiveRecord::Tasks::DatabaseTasks.with_temporary_pool_for_each(env: env) do |pool|
    if pending = pool.migration_context.open.pending_migrations
      pending_migrations << pending
    end
  end

  migrations = pending_migrations.flatten

  if migrations.any?
    raise ActiveRecord::PendingMigrationError.new(pending_migrations: migrations)
  end
end

Вызывает ошибку ActiveRecord::PendingMigrationError, если в окружении для любой конфигурации базы данных есть ожидающие миграции.

current_version () Показать исходный код
# File activerecord/lib/active_record/migration.rb, line 633
def self.current_version
  ActiveRecord::VERSION::STRING.to_f
end
disable_ddl_transaction! () Показать исходный код
# File activerecord/lib/active_record/migration.rb, line 735
def disable_ddl_transaction!
  @disable_ddl_transaction = true
end

Отключает обёртывание этой миграции в транзакцию. Даже после вызова disable_ddl_transaction! вы можете создавать собственные транзакции.

Подробнее см. в разделе «Транзакционные миграции» выше.

load_schema_if_pending! () Показать исходный код
# File activerecord/lib/active_record/migration.rb, line 709
def load_schema_if_pending!
  if any_schema_needs_update?
    load_schema!
  end

  check_pending_migrations
end
migrate (direction) Показать исходный код
# File activerecord/lib/active_record/migration.rb, line 727
def migrate(direction)
  new.migrate direction
end
new (name = self.class.name, version = nil) Показать исходный код
# File activerecord/lib/active_record/migration.rb, line 805
def initialize(name = self.class.name, version = nil)
  @name       = name
  @version    = version
  @connection = nil
  @pool       = nil
end
verbose () Показать исходный код
# File activerecord/lib/active_record/migration.rb, line 802
cattr_accessor :verbose

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

Публичные методы экземпляра

announce (message) Показать исходный код
# File activerecord/lib/active_record/migration.rb, line 1010
def announce(message)
  text = "#{version} #{name}: #{message}"
  length = [0, 75 - text.length].max
  write "== %s %s" % [text, "=" * length]
end
connection () Показать исходный код
# File activerecord/lib/active_record/migration.rb, line 1041
def connection
  @connection || ActiveRecord::Tasks::DatabaseTasks.migration_connection
end
connection_pool () Показать исходный код
# File activerecord/lib/active_record/migration.rb, line 1045
def connection_pool
  @pool || ActiveRecord::Tasks::DatabaseTasks.migration_connection_pool
end
copy (destination, sources, options = {}) Показать исходный код
# File activerecord/lib/active_record/migration.rb, line 1066
def copy(destination, sources, options = {})
  copied = []

  FileUtils.mkdir_p(destination) unless File.exist?(destination)
  schema_migration = SchemaMigration::NullSchemaMigration.new
  internal_metadata = InternalMetadata::NullInternalMetadata.new

  destination_migrations = ActiveRecord::MigrationContext.new(destination, schema_migration, internal_metadata).migrations
  last = destination_migrations.last
  sources.each do |scope, path|
    source_migrations = ActiveRecord::MigrationContext.new(path, schema_migration, internal_metadata).migrations

    source_migrations.each do |migration|
      source = File.binread(migration.filename)
      inserted_comment = "# This migration comes from #{scope} (originally #{migration.version})\n"
      magic_comments = +""
      loop do
        # If we have a magic comment in the original migration,
        # insert our comment after the first newline(end of the magic comment line)
        # so the magic keep working.
        # Note that magic comments must be at the first line(except sh-bang).
        source.sub!(/\A(?:#.*\b(?:en)?coding:\s*\S+|#\s*frozen_string_literal:\s*(?:true|false)).*\n/) do |magic_comment|
          magic_comments << magic_comment; ""
        end || break
      end

      if !magic_comments.empty? && source.start_with?("\n")
        magic_comments << "\n"
        source = source[1..-1]
      end

      source = "#{magic_comments}#{inserted_comment}#{source}"

      if duplicate = destination_migrations.detect { |m| m.name == migration.name }
        if options[:on_skip] && duplicate.scope != scope.to_s
          options[:on_skip].call(scope, migration)
        end
        next
      end

      migration.version = next_migration_number(last ? last.version + 1 : 0).to_i
      new_path = File.join(destination, "#{migration.version}_#{migration.name.underscore}.#{scope}.rb")
      old_path, migration.filename = migration.filename, new_path
      last = migration

      File.binwrite(migration.filename, source)
      copied << migration
      options[:on_copy].call(scope, migration, old_path) if options[:on_copy]
      destination_migrations << migration
    end
  end

  copied
end
down () Показать исходный код
# File activerecord/lib/active_record/migration.rb, line 962
def down
  self.class.delegate = self
  return unless self.class.respond_to?(:down)
  self.class.down
end
exec_migration (conn, direction) Показать исходный код
# File activerecord/lib/active_record/migration.rb, line 990
def exec_migration(conn, direction)
  @connection = conn
  if respond_to?(:change)
    if direction == :down
      revert { change }
    else
      change
    end
  else
    public_send(direction)
  end
ensure
  @connection = nil
  @execution_strategy = nil
end
execution_strategy () Показать исходный код
# File activerecord/lib/active_record/migration.rb, line 812
def execution_strategy
  @execution_strategy ||= ActiveRecord.migration_strategy.new(self)
end
method_missing (method, *arguments, &block) Показать исходный код
# File activerecord/lib/active_record/migration.rb, line 1049
def method_missing(method, *arguments, &block)
  say_with_time "#{method}(#{format_arguments(arguments)})" do
    unless connection.respond_to? :revert
      unless arguments.empty? || [:execute, :enable_extension, :disable_extension].include?(method)
        arguments[0] = proper_table_name(arguments.first, table_name_options)
        if method == :rename_table ||
          (method == :remove_foreign_key && !arguments.second.is_a?(Hash))
          arguments[1] = proper_table_name(arguments.second, table_name_options)
        end
      end
    end
    return super unless execution_strategy.respond_to?(method)
    execution_strategy.send(method, *arguments, &block)
  end
end
Вызывает метод суперкласса
migrate (direction) Показать исходный код
# File activerecord/lib/active_record/migration.rb, line 969
def migrate(direction)
  return unless respond_to?(direction)

  case direction
  when :up   then announce "migrating"
  when :down then announce "reverting"
  end

  time_elapsed = nil
  ActiveRecord::Tasks::DatabaseTasks.migration_connection.pool.with_connection do |conn|
    time_elapsed = ActiveSupport::Benchmark.realtime do
      exec_migration(conn, direction)
    end
  end

  case direction
  when :up   then announce "migrated (%.4fs)" % time_elapsed; write
  when :down then announce "reverted (%.4fs)" % time_elapsed; write
  end
end

Выполняет эту миграцию в указанном направлении

next_migration_number (number) Показать исходный код
# File activerecord/lib/active_record/migration.rb, line 1133
def next_migration_number(number)
  if ActiveRecord.timestamped_migrations
    [Time.now.utc.strftime("%Y%m%d%H%M%S"), "%.14d" % number].max
  else
    "%.3d" % number.to_i
  end
end

Определяет номер версии следующей миграции.

proper_table_name (name, options = {}) Показать исходный код
# File activerecord/lib/active_record/migration.rb, line 1124
def proper_table_name(name, options = {})
  if name.respond_to? :table_name
    name.table_name
  else
    "#{options[:table_name_prefix]}#{name}#{options[:table_name_suffix]}"
  end
end

Находит правильное имя таблицы для объекта Active Record. Использует собственное имя table_name объекта Active Record либо префикс или суффикс из переданных параметров.

reversible () { |helper| ... } Показать исходный код
# File activerecord/lib/active_record/migration.rb, line 914
def reversible
  helper = ReversibleBlockHelper.new(reverting?)
  execute_block { yield helper }
end

Используется для задания операции, которую можно выполнить в одном из двух направлений. Вызовите методы up и down переданного объекта, чтобы выполнить блок только в одном направлении. Весь блок будет вызван в правильном порядке в рамках миграции.

В следующем примере перебор пользователей всегда выполняется, когда существуют все три столбца: ‘first_name’, ‘last_name’ и ‘full_name’, даже при откате миграции:

class SplitNameMigration < ActiveRecord::Migration[8.1]
  def change
    add_column :users, :first_name, :string
    add_column :users, :last_name, :string

    reversible do |dir|
      User.reset_column_information
      User.all.each do |u|
        dir.up   { u.first_name, u.last_name = u.full_name.split(' ') }
        dir.down { u.full_name = "#{u.first_name} #{u.last_name}" }
        u.save
      end
    end

    revert { add_column :users, :full_name, :string }
  end
end
revert (*migration_classes, &block) Показать исходный код
# File activerecord/lib/active_record/migration.rb, line 857
def revert(*migration_classes, &block)
  run(*migration_classes.reverse, revert: true) unless migration_classes.empty?
  if block_given?
    if connection.respond_to? :revert
      connection.revert(&block)
    else
      recorder = command_recorder
      @connection = recorder
      suppress_messages do
        connection.revert(&block)
      end
      @connection = recorder.delegate
      recorder.replay(self)
    end
  end
end

Отменяет команды миграции для указанного блока и указанных миграций.

При применении следующая миграция удалит таблицу ‘horses’ и создаст таблицу ‘apples’, а при откате выполнит обратные действия.

class FixTLMigration < ActiveRecord::Migration[8.1]
  def change
    revert do
      create_table(:horses) do |t|
        t.text :content
        t.datetime :remind_at
      end
    end
    create_table(:apples) do |t|
      t.string :variety
    end
  end
end

Или, что эквивалентно, если TenderloveMigration определён, как описано в документации по Migration:

require_relative "20121212123456_tenderlove_migration"

class FixupTLMigration < ActiveRecord::Migration[8.1]
  def change
    revert TenderloveMigration

    create_table(:apples) do |t|
      t.string :variety
    end
  end
end

Эту команду можно вкладывать.

reverting? () Показать исходный код
# File activerecord/lib/active_record/migration.rb, line 874
def reverting?
  connection.respond_to?(:reverting) && connection.reverting
end
run (*migration_classes) Показать исходный код
# File activerecord/lib/active_record/migration.rb, line 942
def run(*migration_classes)
  opts = migration_classes.extract_options!
  dir = opts[:direction] || :up
  dir = (dir == :down ? :up : :down) if opts[:revert]
  if reverting?
    # If in revert and going :up, say, we want to execute :down without reverting, so
    revert { run(*migration_classes, direction: dir, revert: true) }
  else
    migration_classes.each do |migration_class|
      migration_class.new.exec_migration(connection, dir)
    end
  end
end

Запускает указанные классы миграции. Последний аргумент может задавать параметры:

  • :direction — значение по умолчанию: :up.

  • :revert — значение по умолчанию: false.

say (message, subitem = false) Показать исходный код
# File activerecord/lib/active_record/migration.rb, line 1018
def say(message, subitem = false)
  write "#{subitem ? "   ->" : "--"} #{message}"
end

Принимает сообщение и выводит его без изменений. Вторым аргументом можно передать логическое значение, указывающее, нужно ли добавлять отступ.

say_with_time (message) { || ... } Показать исходный код
# File activerecord/lib/active_record/migration.rb, line 1024
def say_with_time(message)
  say(message)
  result = nil
  time_elapsed = ActiveSupport::Benchmark.realtime { result = yield }
  say "%.4fs" % time_elapsed, :subitem
  say("#{result} rows", :subitem) if result.is_a?(Integer)
  result
end

Выводит текст и время, затраченное на выполнение блока. Если блок возвращает целое число, оно считается количеством затронутых строк.

suppress_messages () { || ... } Показать исходный код
# File activerecord/lib/active_record/migration.rb, line 1034
def suppress_messages
  save, self.verbose = verbose, false
  yield
ensure
  self.verbose = save
end

Принимает блок и подавляет все сообщения, выводимые этим блоком.

up () Показать исходный код
# File activerecord/lib/active_record/migration.rb, line 956
def up
  self.class.delegate = self
  return unless self.class.respond_to?(:up)
  self.class.up
end
up_only (&block) Показать исходный код
# File activerecord/lib/active_record/migration.rb, line 933
def up_only(&block)
  execute_block(&block) unless reverting?
end

Используется для задания операции, выполняемой только при применении миграции (например, для заполнения нового столбца начальными значениями).

В следующем примере новому столбцу published будет присвоено значение true для всех существующих записей.

class AddPublishedToPosts < ActiveRecord::Migration[8.1]
  def change
    add_column :posts, :published, :boolean, default: false
    up_only do
      execute "update posts set published = 'true'"
    end
  end
end
write (text = "") Показать исходный код
# File activerecord/lib/active_record/migration.rb, line 1006
def write(text = "")
  puts(text) if verbose
end

© 2004–2021 David Heinemeier Hansson
Licensed under the MIT License.

Spec-Zone.ru

Настройки Оффлайн Что нового Помощь О нас
Spec-Zone .ru
спецификации, руководства, описания, API