пʼятниця, 28 грудня 2012 р.

Історія одного тупняку, або як я конвертував DBF в SQLite3

Нещодавно до мене потрапив файл у форматі dbf з досить цікавою інформацією. Але сьогодні не про це.

Для роботи з цією інформацією, я вирішив імпортувати таблицю DBF у SQLite3. Використовувати сторонній софт для такої тривіальної задачки — це не наш варіант. Ми не шукаємо легких шляхів. Напишемо скрипт самі. Писати, звичайно, будемо на Ruby.

DBF — застаріваючий формат зберігання даних, який (був?) широко розповсюджений на пострадянському просторі.

Недовгі пошуки навели на бібліотеку dbf. Як описує її автор, це невелика швидка бібліотека для читання DBase, Xbase, Clipper і FoxPro файлів баз даних.

Відкриваємо DBF файл:
require 'dbf'
dbf_table = DBF::Table.new(dbf_file, nil)
dbf_columns = dbf_table.columns
І відразу отримуємо помилку:
dbf-2.0.3/lib/dbf/column/base.rb:19:in `initialize': column name cannot be empty (DBF::Column::NameError)

Проблема виникає через те, що при ініціалізації поля, у його назві відсікаються всі не-ASCII символи. А такий порядок речей в моєму випадку недопустимий, оскільки
імена полів у DBF таблиці записані кирилицею у кодуванні cp1251.

Тут стане у нагоді "monkey patching".
Ініціалізуємо таблицю у кодуванні cp1251 і конверуємо імена полів в utf-8:

module DBF
  module Column
    class Base
      attr_reader :name, :type, :length, :decimal
 
      def initialize(name, type, length, decimal, version, encoding = nil)
        @name, @type, @length, @decimal, @version, @encoding = clean(name, encoding), type, length, decimal, version, encoding
 
        raise LengthError, "field length must be greater than 0" unless length > 0
        raise NameError, "column name cannot be empty" if @name.length == 0
      end
 
      def clean(value, encoding)
        value.force_encoding(encoding).encode('utf-8')
      end
 
    end
  end
end
 
dbf_table = DBF::Table.new(dbf_file, nil, 'cp1251')
dbf_columns = dbf_table.columns

Не вдаючись в подробиці створення схеми SQLite3 (повний код скрипта Ви знайдете в кінці статті), перейдемо відразу до імпорту. Задачка звичайно зовсім тривіальна і написання рішення займає лічені хвилини.
dbf_table.each do |record|
  sqlite_db.execute("INSERT INTO the_table VALUES (?,?,?,?,?,?)", record.attributes.values)
end
Тут можна було б піти покурити, попити чаю або кави чи навіть подивитися фільм. Даний процес зайняв досить багато часу.
Час роботи такого рішення мене не вдовольняв зовсім.
Бувалі програмісти мабуть відразу зрозуміють де собака зарита. Але цей тупняк поглинув мене на кілька годин.
Спочатку я грішив на повільність бібліотеки dbf. Але цей варіант був швидко відкинутий. Каменем спотикання була саме бібліотека SQLite3.
Наступну годину я курив, пив пиво і дивився "Ruby Sparks". Між іншим красивий і милий романтичний фільм, але настирливий продакт-плейсмент Apple вже бісить. Паралельно ґвалтува ґуґл в пошуках рішення. І ось воно зійшло:
sql_db.transaction do |db|
  dbf_table.each do |record|
    db.execute("INSERT INTO the_table VALUES (?,?,?,?,?,?)", record.attributes.values)
  end
end

Несподівано скрипт, який виконувався годинами, виконався за 10 секунд. Підвищення продуктивності over 9000%. Я отетерів(!) від такого результату.

Мораль цієї історії проста... Комусь здається, що це тривіально, але мені просто хотілось поділитися з кимось цими думками.

Нище ви знайдете повну версію скрипта для конвертування DBF у SQLite3.

# encoding: utf-8
require 'dbf'
require 'ruby-progressbar'
require 'sqlite3'
require 'getoptlong'
module DBF
module Column
class Base
attr_reader :name, :type, :length, :decimal
# Not strip non-ascii characters from column names
def initialize(name, type, length, decimal, version, encoding = nil)
@name, @type, @length, @decimal, @version, @encoding = clean(name, encoding), type, length, decimal, version, encoding
raise LengthError, "field length must be greater than 0" unless length > 0
raise NameError, "column name cannot be empty" if @name.length == 0
end
def clean(value, encoding)
value.force_encoding(encoding).encode('utf-8')
end
end
end
end
def trans
{
0 => { ua: 'Телефон', eng: 'telephone' },
1 => { ua: 'Назва', eng: 'name' },
2 => { ua: 'Місто', eng: 'city' },
3 => { ua: 'Вулиця', eng: 'street' },
4 => { ua: 'Будинок', eng: 'house' },
5 => { ua: 'Квартира', eng: 'apartment' },
}
end
if __FILE__ == $0
dbf_file = nil
sqlite3_file = nil
table = 'phones'
opts = GetoptLong.new(
[ '--help', '-h', GetoptLong::NO_ARGUMENT ],
[ '--output', '-o', GetoptLong::REQUIRED_ARGUMENT ],
)
opts.each do |opt, arg|
case opt
when '--help'
puts "#{File.basename(__FILE__)} [dbf file] -o [sqlite3 file]
"
exit
when '--output'
sqlite3_file = arg
end
end
dbf_file = ARGV.shift
abort "You must supply a DBF filename on the command line" unless dbf_file
dbf_table = DBF::Table.new(dbf_file, nil, 'cp1251')
dbf_columns = dbf_table.columns
sql = "create table #{table} (\n"
sql << "\tid INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,\n"
sql << (0..5).map do |i|
"\t#{trans[i][:eng]} varchar(#{dbf_columns[i].length})"
end.join(",\n")
sql << "\n);"
puts sql
File.delete(sqlite3_file) if File.exist?(sqlite3_file)
sql_db = SQLite3::Database.new(sqlite3_file)
sql_db.execute_batch(sql)
pbar = ProgressBar.create(title: 'data processing', total: dbf_table.record_count, format: '%a |%b>>%i| %p%% (%c of %C) %t')
columns = trans.values.map{ |h| h[:eng] }.join(',')
sql_db.transaction do |db|
dbf_table.each do |record|
db.execute("INSERT INTO #{table} (#{columns}) VALUES (?,?,?,?,?,?)", record.attributes.values)
pbar.increment
end
end
end

Немає коментарів: