古い FileMaker から MySQL / MariaDB へのマイグレーション

# はじめに
FileMaker 15 (2016年~)で蓄積された多量のデータ(とアプリ)を、MariaDB(とBlazor)へ移すための、試行錯誤の記録です。
まだ途上であり、移行の必要なデータ(とアプリ)が多数残っているので、将来のために記録を残します。
なお、この記事では、データベースのマイグレーションのみを扱い、Blazorでの開発には触れません。

### 環境
- FileMaker Pro 15 (Advanced)
    - Windows 11
- MariaDB 10.6
    - Ubuntu WSL2

### 経緯
古いFileMakerのアプリ群をBlazor + MariaDBで再構築しています。

最初のアプリの移行では、小さなCSVを経由して手作業で移し、大きな障害は生じませんでした。
元のアプリは、fmp12ファイル305MB強で、さほど大きくありませんでした。
また、大部分のデータは、オリジナルのネットソースから直接取り込み直しました。

https://github.com/tetr4lab/ExLibris10

なお、上記は、あくまでもBlazorでの構築例として書かれていて、FileMakerからの移行については触れていません。

二つ目のアプリの移行を試行したところ、CSVでのエクスポート中にFileMakerが異常終了しました。
何度試みても最後まで書き出せず、最終的に断念しました。
このアプリは、移行対象の中では最大級のもので、fmp12ファイルで10GB弱あります。

この記事は、このデータの移行の顛末です。

# マイグレーションの選択肢
## 方法1: 別名で保存する
最も簡便な方法で、最初のアプリデータの移行で採用しました。
残念ながら、今回は使えませんでした。

### 手法
CSVで書き出して、`LOAD DATA INFILE ~`で取り込みます。
テーブルの構成を変えない場合は、実テーブルに直接取り込んで、内容を調整します。
構成を新しくする場合は、いったん、一時テーブルに取り込み、その後、実テーブルへ振り分けます。

### 利点
標準の機能を利用するので、書き出しの際に手間がありません。

### 課題
#### 取り込み後に変換が必要
データ形式や値の範囲など、そのまま使用すると馴染まない部分が生じますので、ある程度の変換を要します。
例えば、日時の区切り文字や、空欄を`NULL`に置き換えるなどは鉄板です。
文字列で持っているプライマリキーをシリアル番号に振り直すこともあるかも知れません。

#### エクスポート中にFileMakerが異常終了する
FileMakerで、巨大なデータを書き出そうとしたときには、よくあることです。(よね?)

## 方法2: 直接接続する
この記事では詳しく扱いません。
### 手法1: ODBC
FileMakerのODBCのドライバを導入して、MariaDBからFileMakerをデータソースとして参照します。

<details><summary>資料</summary>

#### FileMaker 15 ODBCとJDBCガイド [PDF]
https://help.claris.com/archive/docs/15/ja/fm15_odbc_jdbc_guide.pdf

> FileMaker クライアントドライバを使用する場合は、ポート 2399 を予約する必要があります。

　= ODBCのデータソース参照先ポート

#### ソフトウェア アップデート：FileMaker xDBC クライアントドライバ
https://support.claris.com/s/article/Software-Update-FileMaker-xDBC-client-drivers-for-FileMaker-1503692806454?language=ja

#### Linux からの Windows ネットワーク アプリへのアクセス (ホスト IP)
https://learn.microsoft.com/ja-jp/windows/wsl/networking#accessing-windows-networking-apps-from-linux-host-ip

> Linux ディストリビューションから次のコマンドを実行して、ホスト マシンの IP アドレスを取得します: `ip route show | grep -i default | awk '{ print $3}'`

　= ODBCのデータソース参照先IPアドレス

#### ストレージエンジン FederatedX について

https://mariadb.com/kb/en/about-federatedx/

```sql:MariaDB
> 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

#### 参考

<details><summary>ODBCで逆向きに接続する場合</summary>

##### 外部 SQL データソース（ESS）に対してサポートされている ODBC ドライバ
https://support.claris.com/s/article/%E5%A4%96%E9%83%A8-SQL-%E3%83%87%E3%83%BC%E3%82%BF%E3%82%BD%E3%83%BC%E3%82%B9%E3%81%AB%E5%AF%BE%E3%81%97%E3%81%A6%E3%82%B5%E3%83%9D%E3%83%BC%E3%83%88%E3%81%95%E3%82%8C%E3%81%A6%E3%81%84%E3%82%8B-ODBC-%E3%83%89%E3%83%A9%E3%82%A4%E3%83%90-1503692938179?language=ja

「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
https://mariadb.com/kb/en/mariadb-connector-odbc/

</details>

</details>

### 手法2: JDBC
FileMakerのJDBCのドライバを導入して移行する記事がありました。

https://qiita.com/mackey1966/items/b22b6c3e2485363f43ab

<details><summary>試行</summary>

ちょっと触った限りでは、[`SQL Workbench/J`](https://www.sql-workbench.eu/)の最新版では、FileMaker 15のJDBCドライバが認識されませんでした。
FileMaker 15 のガイドには「JDK 1.6以降」と書かれているので、[古いバージョン](https://www.sql-workbench.eu/download-archive.html)の「works with Java 7」辺りを試すことで、適合するものを見付けられるかも知れません。

</details>

### 利点
接続後は、SQLだけで全部できます。

### 課題
ドライバの導入と設定、SQLでの開発が必要です。
特に、「接続する」こと自体の敷居が高く、至れませんでした。

## 方法3: 別形式に変換する
今回、採用した方法です。

### 手法
FileMakerで生成したSQLをエクスポートして、`sudo mariadb db_name < exported.sql`で取り込みます。

### 利点
あらかじめ、適切な変換を施せるため、移行後の手間がありません。
また、移行時に手作業を挟まないため、再現が容易で、試行錯誤しやすいです。

### 課題
FileMakerでの開発が必要ですが、SQLに比べると制限が多いです。
特に、カスタム関数でループさせる手段が再帰呼び出ししかないのは厳しいです。

# SQLをエクスポートする
### 題材 (参考)
移行するテーブルは、設定、書籍、ページの3つです。
対象のアプリは、インターネットで公開されているテキストを取り込んで、[epubに変換](https://github.com/kyukyunyorituryo/AozoraEpub3)し、[kindleのパーソナル・ドキュメントに送信](https://www.amazon.co.jp/sendtokindle/email)するものです。

## 開発のサイクル
以下の手順を回しました。
1. 移行先のDBスキーマを設計
1. 移行先DBを仮作成してdump
1. 移行元DBのテーブル毎に`sql`計算フィールドを作成
1. スキーマとテーブル毎の`sql`フィールドを小刻みにエクスポートして、ひとつのファイルにまとめるスクリプトを作成
1. スクリプトを走らせてSQLを生成
1. 生成されたSQLをMariaDBで実行
1. 移行先DBを確認してフィードバック

## 移行先のスキーマ
生成したSQLの冒頭で実行することを前提に書かれています。

```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
```java:計算フィールド 'Book::sql'
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
```java:計算フィールド 'Sheet::sql'
"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
```java:計算フィールド 'Setting::sql'
"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の生成で使用している関数群です。

### 型に合わせた出力

```java:カスタム関数 'sql.NullableInt ( value )'
// NULL許容数値
If ( IsEmpty(value) ; "NULL" ; value )

```

```java:カスタム関数 'sql.Boolean ( value )'
// 真偽値
If ( value ; "TRUE" ; "FALSE" )	
```

```java:カスタム関数 'sql.NullableColumn ( field )'
// NULL許容列
If ( IsEmpty( field ) ; "NULL" ; sql.Column ( field; "" ) )
```

```java:カスタム関数 'sql.Column ( field ; default )'
// 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のエスケープ

```java:カスタム関数 'sql.Quote ( string )'
// 引用表現
"'" & Substitute( string ; 
  ["\\" ; "\\\\"] ; 
  ["'" ; "\'"] ; 
  ["\"" ; "\\\""] ; 
  [Char(13)&Char(10) ; Char(10)] ; 
  [Char(13) ; Char(10)] ; 
  [Char(11) ; Char(10)] ; 
  [Char(10) ; "\\"&"n"] 
) & "'"
```

本来は、`"\\n"`と書けば良いハズですが、FileMakerが`"\n"`に置き換えてしまうので、別文字列にして連結しています。

### パス形式の変換
```java:カスタム関数 'dosPath ( path )'
// 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に変換します。

```zsh:スクリプト 'SQLを保存'
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`のコメントアウトされている箇所で使用を試みたのですが、メモリ不足で、まともに動きませんでした。

<details><summary>HexEncodeの実装</summary>

```java:カスタム関数 'HexEncode ( container )'
// オブジェクトをHEX形式に変換する
base64.HexDump ( Substitute ( Base64Encode ( container ) ; [Char(10) ; ""] ; [Char(13) ; ""] ; [Char(11) ; ""] ) ; 0 ; GetContainerAttribute ( container ; "fileSize" ) )
```

```java:カスタム関数 'base64.HexDump ( base64 ; start ; size )'
// 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 )
    )
)
```

```java:カスタム関数 'base64.GetByte ( base64 ; index )'
// 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 )
)
```

```java:カスタム関数 'Hex ( nibble )'
// 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" ;
)
```

```java:カスタム関数 'bitwise.And ( a ; b )'
// ビット毎の論理積
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 )
)
```

```java:カスタム関数 'bitwise.ShiftLeft ( a ; b )'
// 左シフト
If ( b = 0 ; a ; bitwise.ShiftLeft ( a * 2; b - 1 ) )	
```

```java:カスタム関数 'bitwise.ShiftRight ( a ; b )'
// 右シフト
If ( b = 0 ; a ; bitwise.ShiftRight ( Int( a / 2 ); b - 1 ) )
```

```java:カスタム関数 'base64.Decode ( letter )'
// base64の任意の1文字をデコードする
Let ( c = Left( letter ; 1 ) ; Case (
    Exact ( c ; "A" ) ; 0;
    Exact ( c ; "B" ) ; 1;
//  ~ ~ ~
    Exact ( c ; "+" ) ; 62;
    Exact ( c ; "/" ) ; 63;
    -1
) )
```

</details>

#### 試行2: 外部ファイルで受け渡す
フィールド毎にエクスポートして、SQL実行時(または事後)にインポートします。

```sql:MariaDB
-- 他のカラムとともに挿入
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になる
仕方がないので、出力後に強引に変換しました。

```java:Eventを送信
"emeditor.exe \"" & dosPath ( $infile ) & "\" /cp 65537 /cps 65001 /ss- /sa \"" & dosPath ( $outpath & $outfile ) & "\""
```
https://www.emeditor.org/ja/howto/file/file_commandline.html

### ファイルの連結順序を制御する
`copy source*.sql destination.sql` みたいなことをした際に、複数のソースファイルが連結される順序は、ファイル名の昇順になるようです。

# おわりに
まだ、データの移行ができたところで、アプリの移植はこれからです。
進むにつれてデータの構成が変化したり、さらに移行済みアプリが増えるに従って、手法が追加される可能性もあります。(あるいは、基本方針から覆る可能性も…)

将来、忘れた頃に読んでも解るように書いたつもりですが、まだ、自分にしか解らないようなところがあるかもしれません。
ご不明な点があれば、ご遠慮なくコメントをお寄せください。
あるいは、識者のご助言をいただければ助かります。
最後までお読みいただきありがとうございました。