Calíopeブログ

テーブルをテストデータで埋める方法:手書きのINSERTから、SQLで行を生成するまで

テストデータ開発MySQLMariaDBPostgreSQLSQLiteSQL ServerOracleClickHouse

誰もが読めるようにAIで翻訳しています。原文はスペイン語です。 原文を読む

私の知っているDBAのほとんどは、もともと開発者でした。今も開発者だという人もいます。私もデータベースの仕事に移る前はコードを書いていました。Calíopeの最初のバージョンを書き始めたのは2008年で、2016年からはDBAとして働いています。開発者だったころのことで覚えているのが、ある問題です。少しも高度なものではなく、誰もが経験したことのある問題です。新しいテーブルを作ると、アプリケーションが動くところを見るために、そこにデータが必要になります。

この記事では、私がその問題を解決してきた方法を、覚えた順に紹介します。例はMySQLで書いてあり、どのコードブロックにも、そのままで動くエンジンを示しています。

どの例も、このテーブルを使います。

CREATE TABLE clientes (
    id     INT AUTO_INCREMENT PRIMARY KEY,
    nombre VARCHAR(100)  NOT NULL,
    email  VARCHAR(150)  NOT NULL,
    saldo  DECIMAL(10,2) NOT NULL DEFAULT 0,
    alta   DATE          NOT NULL
);
対応エンジンMySQLMariaDB

ほかのエンジンで変わるのは自動増分だけです。PostgreSQLとOracleではGENERATED ALWAYS AS IDENTITY、SQL ServerではIDENTITY(1,1)、SQLiteではINTEGER PRIMARY KEYです。ClickHouseには自動増分がありません。

1. INSERTを1つずつ

最初のころは、経験がなく、ほかのやり方があるとも知らなかったので、データを手で打ち込んでいました。INSERTを1つ、もう1つ、さらにもう1つ。

INSERT INTO clientes (nombre, email, saldo, alta)
VALUES ('Ana Mora', 'ana@ejemplo.com', 150.00, '2010-03-01');

INSERT INTO clientes (nombre, email, saldo, alta)
VALUES ('Luis Rojas', 'luis@ejemplo.com', 80.50, '2010-03-02');

INSERT INTO clientes (nombre, email, saldo, alta)
VALUES ('Sofía Vargas', 'sofia@ejemplo.com', 0.00, '2010-03-05');
対応エンジンMySQLMariaDBPostgreSQLSQLiteSQL ServerClickHouse

Oracleでは、日付はDATE '2010-03-01'と書きます。ただの文字列はセッションの日付書式で読み取られ、デフォルトの書式では失敗します。

これでも動きますが、問題が2つあります。数行より多く書くのはとても手間がかかりますし、データはいつも同じです。顧客が3人では、ページ送りが壊れていることにも、一覧が遅いことにも、長い名前で画面が崩れることにも気づきません。

2. 自分自身をコピーするテーブル

やがて、INSERTにはVALUESがなくてもよく、後ろにSELECTを続けられることを知りました。そして、そのSELECTが挿入先と同じテーブルを読むと、テーブルは2倍になります。

INSERT INTO clientes (nombre, email, saldo, alta)
SELECT nombre, email, saldo, alta
FROM clientes;
対応エンジンMySQLMariaDBPostgreSQLSQLiteSQL ServerOracleClickHouse

実行するたびに行数が2倍になります。最初の3行が6行になり、次に12行、24行…と増え、18回実行するとすでに786,432行です。id列はリストに入っていないので、コピーにはエンジンが番号を振ります。

問題は、データが相変わらず同じ3行の繰り返しであることです。しかもemailに一意インデックスがあれば、2回目の実行で失敗します。解決策は、コピーそのものの中で値を変えることです。

INSERT INTO clientes (nombre, email, saldo, alta)
SELECT CONCAT(nombre, ' ', id),
       CONCAT(id, '.', email),
       ROUND(RAND() * 1000, 2),
       alta + INTERVAL FLOOR(RAND() * 365) DAY
FROM clientes;
対応エンジンMySQLMariaDB

ほかのエンジンでは、乱数の関数と日付の計算が変わります。

  • PostgreSQL:random()とalta + (random() * 365)::int。
  • SQLite:abs(random()) % 1000とdate(alta, '+' || (abs(random()) % 365) || ' days')。
  • SQL Server:RAND()は、文のすべての行に同じ値を返します。行ごとに違う値を得るにはABS(CHECKSUM(NEWID())) % 1000を使います。
  • Oracle:DBMS_RANDOM.VALUE(0, 1000)。
  • ClickHouse:rand() % 1000。

注意があります。増え方は指数関数的で、トランザクションログに書かれる量も同じように増えます。2回多く実行しただけで、テーブルは4倍の大きさになります。

3. SQLで行を生成する

年月を重ねるうちに、すでにある行から始める必要はないことを知りました。エンジン自身が数の連番を生成でき、その数の1つ1つが1行になります。MySQLでは再帰CTEで書きます。

SET SESSION cte_max_recursion_depth = 100000;

INSERT INTO clientes (nombre, email, saldo, alta)
WITH RECURSIVE serie (n) AS (
    SELECT 1
    UNION ALL
    SELECT n + 1 FROM serie WHERE n < 100000
)
SELECT CONCAT('Cliente ', n),
       CONCAT('cliente', n, '@ejemplo.com'),
       ROUND(RAND() * 10000, 2),
       DATE '2020-01-01' + INTERVAL FLOOR(RAND() * 2000) DAY
FROM serie;
対応エンジンMySQL 8.0+

1行目が必要なのは、MySQLがデフォルトで再帰を1000段で止めるからです。

連番の生成方法はエンジンごとに違い、ずっと短く書けるものもあります。

SELECT seq FROM seq_1_to_100000;
対応エンジンMariaDB 10.0+
SELECT n FROM generate_series(1, 100000) AS n;
対応エンジンPostgreSQL 8.0+
SELECT value FROM GENERATE_SERIES(1, 100000);
対応エンジンSQL Server 2022+
SELECT LEVEL FROM dual CONNECT BY LEVEL <= 100000;
対応エンジンOracle 10g+
SELECT number + 1 FROM numbers(100000);
対応エンジンClickHouse

SQLiteと、2022より前のSQL Serverでは、再帰CTEを使います。

WITH RECURSIVE serie (n) AS (
    SELECT 1
    UNION ALL
    SELECT n + 1 FROM serie WHERE n < 100000
)
SELECT n FROM serie;
対応エンジンSQLite 3.8.3+

SQL ServerはRECURSIVEを受け付けず、再帰を100段で止めます。最後にオプションを付けると、この上限がなくなります。

WITH serie (n) AS (
    SELECT 1
    UNION ALL
    SELECT n + 1 FROM serie WHERE n < 100000
)
SELECT n FROM serie
OPTION (MAXRECURSION 0);
対応エンジンSQL Server 2005+

いつからできるのか

2008年の私は、こんなことができるとは知りませんでした。実際、私が使っていたMySQLではできなかったのです。古いものから新しいものへ、年表にするとこうなります。

  • PostgreSQL 8.0、2005年1月:generate_series。再帰CTEはその後、8.4(2009年)で入りました。
  • SQL Server 2005、2005年11月:再帰CTE。GENERATE_SERIESが来たのはSQL Server 2022になってからです。
  • Oracle:dualに対するCONNECT BY LEVELは、Oracle 10g(2004年)のころにはすでに行の生成に使われていました。標準の再帰CTEは11g R2(2009年)で入りました。
  • SQLite 3.8.3、2014年2月:WITH RECURSIVE。
  • MariaDB 10.0(2014年):Sequenceエンジンと、そのseq_1_to_Nテーブル。再帰CTEはMariaDB 10.2(2017年)で入りました。
  • ClickHouse:numbers()は、オープンソースとして公開された2016年からあります。
  • MySQL 8.0、2018年4月:再帰CTE。それ以前は、1つの文で連番を生成する方法はありませんでした。

つまり2008年には、PostgreSQL、SQL Server、Oracleではすでにできていました。MySQLはさらに10年かかり、その間に残っていたのは、テーブルを2倍にしていくか、ループのあるプロシージャを書くことでした。

4. テーブルを読み取るプロシージャ

少しプロらしくなってからは、その作業を代わりにやってくれるストアドプロシージャを書きました。information_schemaからテーブルの列とその型を読み取り、それぞれに合ったランダムな値を入れるINSERTを組み立てるものです。

DELIMITER //
CREATE PROCEDURE poblar(IN p_tabla VARCHAR(64), IN p_filas INT)
BEGIN
    DECLARE i INT DEFAULT 0;
    DECLARE v_columnas TEXT;
    DECLARE v_valores  TEXT;

    SELECT GROUP_CONCAT(CONCAT('`', column_name, '`') ORDER BY ordinal_position),
           GROUP_CONCAT(
               CASE
                   WHEN data_type IN ('int', 'bigint', 'smallint', 'tinyint')
                       THEN 'FLOOR(RAND() * 1000)'
                   WHEN data_type IN ('decimal', 'double', 'float')
                       THEN 'ROUND(RAND() * 1000, 2)'
                   WHEN data_type IN ('date', 'datetime', 'timestamp')
                       THEN 'CURRENT_DATE - INTERVAL FLOOR(RAND() * 2000) DAY'
                   ELSE CONCAT('LEFT(SHA2(RAND(), 256), ',
                               LEAST(IFNULL(character_maximum_length, 32), 32), ')')
               END
               ORDER BY ordinal_position)
      INTO v_columnas, v_valores
      FROM information_schema.columns
     WHERE table_schema = DATABASE()
       AND table_name = p_tabla
       AND extra NOT LIKE '%auto_increment%';

    SET @sql = CONCAT('INSERT INTO `', p_tabla, '` (', v_columnas, ') VALUES (', v_valores, ')');
    PREPARE sentencia FROM @sql;
    WHILE i < p_filas DO
        EXECUTE sentencia;
        SET i = i + 1;
    END WHILE;
    DEALLOCATE PREPARE sentencia;
END //
DELIMITER ;

CALL poblar('clientes', 1000);
対応エンジンMySQLMariaDB

ほかのエンジンには、それぞれの列のカタログ(PostgreSQLとSQL Serverではinformation_schema、OracleではALL_TAB_COLUMNS、SQLiteではpragma_table_info)と、それぞれの手続き型言語があるので、エンジンごとに書き直す必要があります。

5. 今は、Calíopeで

あのプロシージャは、Calíopeのある機能の祖先です。カタログのテーブル一覧で、テーブルのコンテキストメニューに「ダミーデータを投入」があります(Macでは右クリック、iPadでは長押し)。作りたい行数を1から5000までで選ぶと、Calíopeは次のことをします。

  • サーバのカタログから列とその型を読み取ります。ENUMの値も読み取ります。
  • 自動増分の列と計算列は飛ばします。これらはエンジン自身が埋めます。
  • 列の型ごとに有効なサンプル値を生成し、始める前にその1つを見せます。
  • 50行ずつのバッチで挿入し、進行状況バーを表示します。バッチが失敗したときは、範囲外の値、CHECK制約、重複など、その理由を伝えます。

外部キーの列でも、値はランダムです。MySQL、MariaDB、PostgreSQLでは、Calíopeは投入の間だけチェックを無効にするので、存在しない親を指す行ができることがあります。これは、一致する行のないJOINをアプリケーションがどう扱うかを試すのに役立ちます。SQL ServerとOracleではチェックは有効なままで、親テーブルにない値を含むバッチは、その警告とともに失敗します。

MySQL、MariaDB、PostgreSQL、SQL Server、Oracle、ClickHouseで使えます。

何に何を使うか

どのやり方も、私の仕事から消えたわけではありません。

  • 手で書く数個のINSERTは、バグを再現するケースのように、中身がわかっている特定のデータが必要なときに。
  • SQLで生成する連番は、量と、値の散らばり方の制御が必要なときに。たとえば、10万人の顧客のうち、登録の80%が直近の1年に集中しているようなデータです。
  • Calíopeは、画面が動くところを見るために、テーブルに何か入っていればいいときに。

すべての記事