はじめに
FileMaker 15 (2016年~)で蓄積された多量のデータ(とアプリ)を、MariaDB(とBlazor)へ移すための、試行錯誤の記録です。
まだ途上であり、移行の必要なデータ(とアプリ)が多数残っているので、将来のために記録を残します。
なお、この記事では、データベースのマイグレーションのみを扱い、Blazorでの開発には触れません。
環境
- FileMaker Pro 15 (Advanced)
- Windows 11
- MariaDB 10.6
- Ubuntu WSL2
経緯
古いFileMakerのアプリ群をBlazor + MariaDBで再構築しています。
最初のアプリの移行では、小さなCSVを経由して手作業で移し、大きな障害は生じませんでした。
元のアプリは、fmp12ファイル305MB強で、さほど大きくありませんでした。
また、大部分のデータは、オリジナルのネットソースから直接取り込み直しました。
なお、上記は、あくまでもBlazorでの構築例として書かれていて、FileMakerからの移行については触れていません。
二つ目のアプリの移行を試行したところ、CSVでのエクスポート中にFileMakerが異常終了しました。
何度試みても最後まで書き出せず、最終的に断念しました。
このアプリは、移行対象の中では最大級のもので、fmp12ファイルで10GB弱あります。
この記事は、このデータの移行の顛末です。
マイグレーションの選択肢
方法1: 別名で保存する
最も簡便な方法で、最初のアプリデータの移行で採用しました。
残念ながら、今回は使えませんでした。
手法
CSVで書き出して、LOAD DATA INFILE ~
で取り込みます。
テーブルの構成を変えない場合は、実テーブルに直接取り込んで、内容を調整します。
構成を新しくする場合は、いったん、一時テーブルに取り込み、その後、実テーブルへ振り分けます。
利点
標準の機能を利用するので、書き出しの際に手間がありません。
課題
取り込み後に変換が必要
データ形式や値の範囲など、そのまま使用すると馴染まない部分が生じますので、ある程度の変換を要します。
例えば、日時の区切り文字や、空欄をNULL
に置き換えるなどは鉄板です。
文字列で持っているプライマリキーをシリアル番号に振り直すこともあるかも知れません。
エクスポート中にFileMakerが異常終了する
FileMakerで、巨大なデータを書き出そうとしたときには、よくあることです。(よね?)
方法2: 直接接続する
この記事では詳しく扱いません。
手法1: ODBC
FileMakerのODBCのドライバを導入して、MariaDBからFileMakerをデータソースとして参照します。
資料
FileMaker 15 ODBCとJDBCガイド [PDF]
https://help.claris.com/archive/docs/15/ja/fm15_odbc_jdbc_guide.pdf
FileMaker クライアントドライバを使用する場合は、ポート 2399 を予約する必要があります。
= ODBCのデータソース参照先ポート
ソフトウェア アップデート:FileMaker xDBC クライアントドライバ
Linux からの Windows ネットワーク アプリへのアクセス (ホスト IP)
Linux ディストリビューションから次のコマンドを実行して、ホスト マシンの IP アドレスを取得します:
ip route show | grep -i default | awk '{ print $3}'
= ODBCのデータソース参照先IPアドレス
ストレージエンジン FederatedX について
https://mariadb.com/kb/en/about-federatedx/
> INSTALL SONAME 'ha_federatedx'; -- プラグインを(動的に)導入
> CREATE SERVER '<server>' foreign data wrapper 'mysql' options (~); -- 接続先サーバを定義
> CREATE TABLE ~ ENGINE=FEDERATED CONNECTTION='<server>/<database>'; -- 接続先テーブルを作成
FileMaker 15 SQLリファレンスガイド [PDF]
https://help.claris.com/archive/docs/15/ja/fm15_sql_reference.pdf
参考
ODBCで逆向きに接続する場合
外部 SQL データソース(ESS)に対してサポートされている ODBC ドライバ
「MySQL Community Server 5.6.12:Windows 用 | MySQL ODBC 5.2 Unicode Driver バージョン 5.2.7 (32 ビットおよび 64 ビット)」
MySQL Connector/ODBC (Archived Versions)
https://downloads.mysql.com/archives/c-odbc/
MariaDB ODBC Connector
手法2: JDBC
FileMakerのJDBCのドライバを導入して移行する記事がありました。
試行
ちょっと触った限りでは、SQL Workbench/J
の最新版では、FileMaker 15のJDBCドライバが認識されませんでした。
FileMaker 15 のガイドには「JDK 1.6以降」と書かれているので、古いバージョンの「works with Java 7」辺りを試すことで、適合するものを見付けられるかも知れません。
利点
接続後は、SQLだけで全部できます。
課題
ドライバの導入と設定、SQLでの開発が必要です。
特に、「接続する」こと自体の敷居が高く、至れませんでした。
方法3: 別形式に変換する
今回、採用した方法です。
手法
FileMakerで生成したSQLをエクスポートして、sudo mariadb db_name < exported.sql
で取り込みます。
利点
あらかじめ、適切な変換を施せるため、移行後の手間がありません。
また、移行時に手作業を挟まないため、再現が容易で、試行錯誤しやすいです。
課題
FileMakerでの開発が必要ですが、SQLに比べると制限が多いです。
特に、カスタム関数でループさせる手段が再帰呼び出ししかないのは厳しいです。
SQLをエクスポートする
題材 (参考)
移行するテーブルは、設定、書籍、ページの3つです。
対象のアプリは、インターネットで公開されているテキストを取り込んで、epubに変換し、kindleのパーソナル・ドキュメントに送信するものです。
開発のサイクル
以下の手順を回しました。
- 移行先のDBスキーマを設計
- 移行先DBを仮作成してdump
- 移行元DBのテーブル毎に
sql
計算フィールドを作成 - スキーマとテーブル毎の
sql
フィールドを小刻みにエクスポートして、ひとつのファイルにまとめるスクリプトを作成 - スクリプトを走らせてSQLを生成
- 生成されたSQLをMariaDBで実行
- 移行先DBを確認してフィードバック
移行先のスキーマ
生成したSQLの冒頭で実行することを前提に書かれています。
DROP TABLE IF EXISTS `books`;
CREATE TABLE `books` (
`id` bigint(20) NOT NULL AUTO_INCREMENT,
`version` int(11) NOT NULL DEFAULT 0,
`created` datetime NOT NULL DEFAULT current_timestamp(),
`modified` datetime NOT NULL DEFAULT current_timestamp() ON UPDATE current_timestamp(),
`url1` varchar(255) NOT NULL DEFAULT '',
`url2` varchar(255) NOT NULL DEFAULT '',
`html` longtext DEFAULT NULL,
`site` INT(11) NOT NULL DEFAULT 0,
`novel_code` varchar(255) DEFAULT NULL,
`series_title` longtext DEFAULT NULL,
`novel_title` longtext DEFAULT NULL,
`novel_writername` longtext DEFAULT NULL,
`novel_ex` longtext DEFAULT NULL,
`ruby_enable` bit(1) NOT NULL DEFAULT b'0',
`count_of_sheets` int(20) DEFAULT NULL,
`number_of_published` int(20) DEFAULT NULL,
`published_at` datetime DEFAULT NULL,
`read` bit(1) NOT NULL DEFAULT b'0',
`memorandum` varchar(255) DEFAULT NULL,
`status` varchar(50) NOT NULL DEFAULT '',
`html_backup` longtext DEFAULT NULL,
`errata` longtext DEFAULT NULL,
`wish` bit(1) NOT NULL DEFAULT b'0',
`memo` varchar(255) DEFAULT NULL,
`bookmark` varchar(255) DEFAULT NULL,
PRIMARY KEY (`id`) USING BTREE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin;
DELIMITER ;;
CREATE TRIGGER `version_check_before_update_on_books` BEFORE UPDATE ON `books` FOR EACH ROW begin
if new.version <= old.version then
signal SQLSTATE '45000'
set MESSAGE_TEXT = 'Version mismatch detected.';
end if;
END ;;
DELIMITER ;
DROP TABLE IF EXISTS `settings`;
CREATE TABLE `settings` (
`id` bigint(20) NOT NULL DEFAULT 1,
`version` int(11) NOT NULL DEFAULT 0,
`created` datetime NOT NULL DEFAULT current_timestamp(),
`modified` datetime NOT NULL DEFAULT current_timestamp() ON UPDATE current_timestamp(),
`aozoraEpub3_command` varchar(255) NOT NULL DEFAULT '',
`personal_document_path` varchar(255) NOT NULL DEFAULT '',
`personal_document_limit_size` varchar(255) NOT NULL DEFAULT '',
`smtp_account` varchar(255) NOT NULL DEFAULT '',
`smtp_mailaddress` varchar(255) NOT NULL DEFAULT '',
`smtp_replyto` varchar(255) NOT NULL DEFAULT '',
`smtp_server` varchar(255) NOT NULL DEFAULT '',
`smtp_port` varchar(255) NOT NULL DEFAULT '',
`smtp_username` varchar(255) NOT NULL DEFAULT '',
`smtp_password` varchar(255) NOT NULL DEFAULT '',
`smtp_mailto` varchar(255) NOT NULL DEFAULT '',
`smtp_cc` varchar(255) NOT NULL DEFAULT '',
`smtp_bcc` varchar(255) NOT NULL DEFAULT '',
`smtp_subject` varchar(255) NOT NULL DEFAULT '',
`smtp_body` varchar(255) NOT NULL DEFAULT '',
`import_log` longtext NOT NULL DEFAULT '',
`memo` varchar(255) NOT NULL DEFAULT '',
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin;
DELIMITER ;;
CREATE TRIGGER `version_check_before_update_on_settings` BEFORE UPDATE ON `settings` FOR EACH ROW begin
if new.version <= old.version then
signal SQLSTATE '45000'
set MESSAGE_TEXT = 'Version mismatch detected.';
end if;
END ;;
DELIMITER ;
DROP TABLE IF EXISTS `sheets`;
CREATE TABLE `sheets` (
`id` bigint(20) NOT NULL AUTO_INCREMENT,
`version` int(11) NOT NULL DEFAULT 0,
`created` datetime NOT NULL DEFAULT current_timestamp(),
`modified` datetime NOT NULL DEFAULT current_timestamp() ON UPDATE current_timestamp(),
`book_id` bigint(20) NOT NULL,
`url` varchar(255) NOT NULL DEFAULT '',
`html` longtext DEFAULT NULL,
`sheet_update` datetime DEFAULT NULL,
`novel_no` INT(20) NOT NULL DEFAULT 0,
`errata` longtext DEFAULT NULL,
PRIMARY KEY (`id`) USING BTREE,
CONSTRAINT `fk_bookid_books_id` FOREIGN KEY (`book_id`) REFERENCES `books` (`id`) ON UPDATE RESTRICT ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin;
DELIMITER ;;
CREATE TRIGGER `version_check_before_update_on_sheets` BEFORE UPDATE ON `sheets` FOR EACH ROW begin
if new.version <= old.version then
signal SQLSTATE '45000'
set MESSAGE_TEXT = 'Version mismatch detected.';
end if;
END ;;
DELIMITER ;
テーブル毎のSQL生成フィールド
初期化されたテーブルに挿入される前提のSQLを生成します。
Book
List (
"INSERT INTO `books` VALUES ("
& record_id // Id
& ",0," //Version
& sql.Column ( created ; "''" ) & ","
& sql.Column ( modified ; "''" ) & ","
& sql.Column ( url[1] ; "''" ) & ","
& sql.Column ( url[2] ; "''" ) & ","
& sql.NullableColumn ( If ( site = 0 ; direct_content ; html ) ) & ","
& sql.Column ( site ; "0" ) & ","
& sql.NullableColumn ( novel_code ) & ","
& sql.NullableColumn ( series_title ) & ","
& sql.NullableColumn ( If ( site = 0 and not IsEmpty ( direct_title_writername ) ; GetValue ( direct_title_writername ; 1 ) ; novel_title ) ) & ","
& sql.NullableColumn ( If ( site = 0 and not IsEmpty ( direct_title_writername ) ; GetValue ( direct_title_writername ; 2 ) ; novel_writername ) ) & ","
& sql.NullableColumn ( novel_ex ) & ","
& sql.Boolean ( ruby_enable ) & ","
& sql.NullableColumn ( count_of_sheets ) & ","
& sql.NullableColumn ( number_of_published ) & ","
& sql.NullableColumn ( published_at ) & ","
& sql.Boolean ( read ) & ","
& sql.NullableColumn ( memorandum ) & ","
& sql.Column ( status ; "''" ) & ","
& sql.NullableColumn ( html_backup ) & ","
& sql.NullableColumn ( errata ) & ","
& sql.Boolean ( wish ) & ","
& sql.NullableColumn ( memo ) & ","
& sql.NullableColumn ( bookmark ) & ");" ;
List ( UnLimit Sheet::sql ) ;
Char(10) )
Sheet
"INSERT INTO `sheets` VALUES ("
& Get ( レコード ID ) // Id
& ",0," //Version
& sql.Column ( created ; "''" ) & ","
& sql.Column ( modified ; "''" ) & ","
& sql.Column ( Book::record_id ; "''" ) & "," // BookId
& sql.Column ( url ; "''" ) & ","
& sql.NullableColumn ( If ( site = 0 ; direct_content ; html ) ) & ","
& sql.NullableColumn ( sheet_update ) & ","
& sql.Column ( novel_no ; "0" ) & ","
& sql.NullableColumn ( errata ) & ");"
Setting
"INSERT INTO `settings` VALUES ("
& "1," // Id
& "0," // Version
& sql.Column ( created ; "''" ) & ","
& sql.Column ( modified ; "''" ) & ","
& sql.Column ( AozoraEpub3_command ; "''" ) & ","
& sql.Column ( personal_document_path ; "''" ) & ","
& sql.Column ( personal_document_limit_size ; "''" ) & ","
& sql.Column ( smtp_account ; "''" ) & ","
& sql.Column ( smtp_mailaddress ; "''" ) & ","
& sql.Column ( smtp_replyto ; "''" ) & ","
& sql.Column ( smtp_server ; "''" ) & ","
& sql.Column ( smtp_port ; "''" ) & ","
& sql.Column ( smtp_username ; "''" ) & ","
& sql.Column ( smtp_password ; "''" ) & ","
& sql.Column ( smtp_mailto ; "''" ) & ","
& sql.Column ( smtp_cc ; "''" ) & ","
& sql.Column ( smtp_bcc ; "''" ) & ","
& sql.Column ( smtp_subject ; "''" ) & ","
& sql.Column ( smtp_body ; "''" ) & ","
& sql.Column ( import_log ; "''" ) & ","
& sql.Column ( memo ; "''" ) & ");"
& Char(10)
カスタム関数
SQLの生成で使用している関数群です。
型に合わせた出力
// NULL許容数値
If ( IsEmpty(value) ; "NULL" ; value )
// 真偽値
If ( value ; "TRUE" ; "FALSE" )
// NULL許容列
If ( IsEmpty( field ) ; "NULL" ; sql.Column ( field; "" ) )
// NULLでない列を型に合わせた形式にする、defaultが空の場合は0または''を使う
Let ([
type = MiddleWords( FieldType( Get( ファイル名 ) ; GetFieldName( field ) ) ; 2 ; 1 );
number = type = "Number";
datetime = type = "Timestamp";
blob = type = "Container"
];
Case (
IsEmpty ( field ) and IsEmpty ( default ) ; If ( number ; 0 ; "''" ) ;
IsEmpty ( field ) ; default ;
number ; field ;
// blob ; "_binary 0x" & HexEncode ( field );
blob ; "load_file('" & GetContainerAttribute ( field ; "filename" ) & "')" ;
sql.Quote ( Case (
datetime ; Substitute( field ; "/" ; "-" ) ;
field ) )
)
)
SQLのエスケープ
// 引用表現
"'" & Substitute( string ;
["\\" ; "\\\\"] ;
["'" ; "\'"] ;
["\"" ; "\\\""] ;
[Char(13)&Char(10) ; Char(10)] ;
[Char(13) ; Char(10)] ;
[Char(11) ; Char(10)] ;
[Char(10) ; "\\"&"n"]
) & "'"
本来は、"\\n"
と書けば良いハズですが、FileMakerが"\n"
に置き換えてしまうので、別文字列にして連結しています。
パス形式の変換
// FileMakerの内部パス表現をWindowsで使用可能な表現に変換する
Substitute(
Case(
Left( path ; 9 ) = "filewin:/" ; Right( path ; Length( path ) - 9 ) ;
Left( path ; 9 ) = "filemac:/" ; Right( path ; Length( path ) - 9 ) ;
Left( path ; 6 ) = "file:/" ; Right( path ; Length( path ) - 6 ) ;
Left( path ; 1 ) = "/" and Left( path ; 2 ) ≠ "//" ; Right( path ; Length( path ) - 1 ) ;
path
)
; ["/" ; "\\"] )
SQLを保存するスクリプト
一度に多量のエクスポートを行うとFileMakerが異常終了するので、小刻みに出力して、最後に連結します。
また、フィールドのエクスポートは、utf-16になるので、連結後、utf-8に変換します。
If [ Get ( ウインドウモード ) = 0 ]
ウインドウの固定
変数を設定 [ $outfile; 値:"exported.sql" ]
変数を設定 [ $tempfile1; 値:Get(テンポラリパス) &"temporary_0000000.sql" ]
変数を設定 [ $tempfile2; 値:Get(テンポラリパス) &"temporary2.sql" ]
Event を送信 [ ファイル/アプリケーションを開く; "cmd.exe /cdel " & dosPath ( Get (テンポラリパス) & "temporary*.sql" ) ]
ディレクトリの取得 [ フォルダの作成を許可; $outpath; ダイアログのタイトル: "「" & $outfile & "」の出力先フォルダを選択"; デフォルトの場所: Get ( デスクトップパス ) ]
ユーザによる強制終了を許可 [ オフ ]
#schema
フィールド内容のエクスポート [ Setting::schema_sql_export; 「$tempfile1」 ]
#data
変数を設定 [ $tempfile; 値:Get ( テンポラリパス ) & "temporary_0000001.sql" ]
フィールド内容のエクスポート [ Setting::sql; 「$tempfile」 ]
レイアウト切り替え [ 「Book表示」 (Book) ]
全レコードを表示
レコード/検索条件/ページへ移動 [ 最初の ]
変数を設定 [ $count; 値:1 ]
ユーザによる強制終了を許可 [ オン ]
Loop
変数を設定 [ $tempfile; 値:Get ( テンポラリパス ) & "temporary_" & Right ( "000000" & Book::record_id ; 6 ) & ".sql" ]
ユーザによる強制終了を許可 [ オフ ]
フィールド内容のエクスポート [ Book::sql; 「$tempfile」 ]
ユーザによる強制終了を許可 [ オン ]
レコード/検索条件/ページへ移動 [ 次の; 最後まできたら終了 ]
変数を設定 [ $count; 値:$count + 1 ]
End Loop
Event を送信 [ ファイル/アプリケーションを開く; "cmd.exe /ccopy " & dosPath ( Get(テンポラリパス) & "temporary_*.sql" ) & " " & dosPath ( $tempfile2 ) & "&&emeditor.exe \"" & dosPath ( $tempfile2 ) & "\" /cp 65537 /cps 65001 /ss- /sa \"" & dosPath ( $outpath & $outfile ) & "\"" // ref: https://www.emeditor.org/ja/howto/file/file_commandline.html ]
レイアウト切り替え [ 元のレイアウト ]
End If
課題と解決
オブジェクトフィールドを blob に受け渡す
今回の題材ではオブジェクトフィールドは使われていませんが、一応、検証してみました。
試行1: HexEncode を実装する
FM16で実装されたHexEncode(container)
が使えれば、16進リテラル(_binary 0x~
)で出力できます。
同関数を自作して、既出のカスタム関数sql.Column
のコメントアウトされている箇所で使用を試みたのですが、メモリ不足で、まともに動きませんでした。
HexEncodeの実装
// オブジェクトをHEX形式に変換する
base64.HexDump ( Substitute ( Base64Encode ( container ) ; [Char(10) ; ""] ; [Char(13) ; ""] ; [Char(11) ; ""] ) ; 0 ; GetContainerAttribute ( container ; "fileSize" ) )
// base64文字列から任意範囲のバイト列を16進ダンプする
If ( start >= size ; "" ;
Let (
byte = base64.GetByte ( base64 ; start )
;
Hex ( bitwise.ShiftRight ( byte ; 4 ) ) & Hex ( bitwise.And ( byte ; 15 ) )
& base64.HexDump ( base64 ; start + 1 ; size )
)
)
// base64文字列から任意の1byteを取得する
Let ([
sb = index * 8;
sc = base64.Decode ( Middle ( base64 ; Int ( sb / 6 ) + 1 ; 1 ) );
scb = Mod ( sb ; 6 );
eb = sb + 7;
ec = base64.Decode ( Middle ( base64 ; Int ( eb / 6 ) + 1 ; 1 ) );
ecb = Mod ( eb ; 6 )
];
bitwise.ShiftLeft ( bitwise.And ( sc ; bitwise.ShiftLeft ( 1 ; 6 - scb ) - 1 ) ; ecb + 1 )
+ bitwise.ShiftRight ( ec ; 5 - ecb )
)
// 4bitを16進表現化
Case (
nibble < 10 ; GetAsText ( nibble ) ;
nibble = 10 ; "A" ;
nibble = 11 ; "B" ;
nibble = 12 ; "C" ;
nibble = 13 ; "D" ;
nibble = 14 ; "E" ;
nibble = 15 ; "F" ;
)
// ビット毎の論理積
If ( a = 0 or b = 0 ; 0 ;
Let ([
a0 = Mod ( a ; 2 );
b0 = Mod ( b ; 2 );
as = Int ( a / 2 );
bs = Int ( b / 2 )
];
If ( a0 = 1 and b0 = 1 ; 1 ; 0 ) + bitwise.And ( as; bs ) * 2 )
)
// 左シフト
If ( b = 0 ; a ; bitwise.ShiftLeft ( a * 2; b - 1 ) )
// 右シフト
If ( b = 0 ; a ; bitwise.ShiftRight ( Int( a / 2 ); b - 1 ) )
// base64の任意の1文字をデコードする
Let ( c = Left( letter ; 1 ) ; Case (
Exact ( c ; "A" ) ; 0;
Exact ( c ; "B" ) ; 1;
// ~ ~ ~
Exact ( c ; "+" ) ; 62;
Exact ( c ; "/" ) ; 63;
-1
) )
試行2: 外部ファイルで受け渡す
フィールド毎にエクスポートして、SQL実行時(または事後)にインポートします。
-- 他のカラムとともに挿入
insert into '<table>' ('<column>') values (load_file('<path>'));
-- 他のカラムの後で更新
update '<table>' set '<column>'=load_file('<path>') where Id=<id>;
-- (参考) blobカラムからファイルに書き出す
select '<column>' from '<table>' where Id=<id> into dumpfile '<path>';
フィールドをエクスポートするとUTF-16になる
仕方がないので、出力後に強引に変換しました。
"emeditor.exe \"" & dosPath ( $infile ) & "\" /cp 65537 /cps 65001 /ss- /sa \"" & dosPath ( $outpath & $outfile ) & "\""
ファイルの連結順序を制御する
copy source*.sql destination.sql
みたいなことをした際に、複数のソースファイルが連結される順序は、ファイル名の昇順になるようです。
おわりに
まだ、データの移行ができたところで、アプリの移植はこれからです。
進むにつれてデータの構成が変化したり、さらに移行済みアプリが増えるに従って、手法が追加される可能性もあります。(あるいは、基本方針から覆る可能性も…)
将来、忘れた頃に読んでも解るように書いたつもりですが、まだ、自分にしか解らないようなところがあるかもしれません。
ご不明な点があれば、ご遠慮なくコメントをお寄せください。
あるいは、識者のご助言をいただければ助かります。
最後までお読みいただきありがとうございました。