How to Efficiently Generate Test Data With SQL

Written by yuridanilov | Published 2022/09/26
Tech Story Tags: sql | postgres | mysql | rdbms | software-testing | software-development | software-engineering | debugging

TLDRThere are a large number of utilities for generating test data. But in some cases you may wish to generate this data manually. I would like to share my own experience in this area and hope that the examples that I will cover in this article will help you with this.via the TL;DR App

There are a large number of utilities for generating test data. But in some cases, you may wish to generate this data manually.

I would like to share my own experience in this area and hope that the examples that I will cover in this article will help you with this.

You can always use database-specific functions and they will probably work more efficiently, but I use primitive functions to make the code as portable as possible.

The examples below are for PostgreSQL but can be adapted for other databases with some modifications. Later I will talk about this.

Series

First of all, we need a query generating a specified number of rows. I prefer to use the generate_series function for this:

select s as id from generate_series(1, 1000) AS s; -- number of rows to generate

The result set contains 1000 rows:

First and last name

Let's say we want to generate the names of our clients.

We can use a couple of arrays containing first names and last names and randomly select values from them:

select
    arrays.firstnames[trunc(random() * ARRAY_LENGTH(arrays.firstnames, 1) + 1)] AS firstname,
    arrays.lastnames[trunc(random() * ARRAY_LENGTH(arrays.lastnames,1) + 1)] AS lastname
from  (
    SELECT ARRAY[
		'Adam', 'Bill', 'Bob', 'Donald', 'Frank', 'George', 'James', 'John', 'Jacob', 'Jack', 'Martin', 'Matthew', 'Max', 'Michael', 'Paul','Peter', 'Ronald',
		'Samuel','Steve','William', 'Abigail', 'Alice', 'Amanda', 'Barbara','Betty', 'Carol', 'Donna', 'Jane','Jennifer','Julie','Mary','Melissa','Sarah','Susan'
    ] AS firstnames,
    ARRAY[
        'Matthews','Smith','Jones','Davis','Jacobson','Williams','Donaldson','Maxwell','Peterson','Stevens', 'Franklin','Washington','Jefferson','Adams',
        'Jackson','Johnson','Lincoln','Grant','Fillmore','Harding','Taft', 'Truman','Nixon','Ford','Carter','Reagan','Bush','Clinton','Hancock'
    ] AS lastnames
) AS arrays;

The result:

Here we use the expression that generates a random index within the array length:

trunc(random() * ARRAY_LENGTH(arrays.firstnames, 1) + 1)

Connecting arrays with series

Combined with the generate_series function, our query can now generate 1000 rows of random first and last names with IDs:

select s as id,
    arrays.firstnames[trunc(random() * ARRAY_LENGTH(arrays.firstnames, 1) + 1)] AS firstname,
    arrays.lastnames[trunc(random() * ARRAY_LENGTH(arrays.lastnames,1) + 1)] AS lastname
from generate_series(1, 1000) AS s -- number of rows to generate
CROSS JOIN(
    SELECT ARRAY[
		'Adam', 'Bill', 'Bob', 'Donald', 'Frank', 'George', 'James', 'John', 'Jacob', 'Jack', 'Martin', 'Matthew', 'Max', 'Michael', 'Paul','Peter', 'Ronald',
		'Samuel','Steve','William', 'Abigail', 'Alice', 'Amanda', 'Barbara','Betty', 'Carol', 'Donna', 'Jane','Jennifer','Julie','Mary','Melissa','Sarah','Susan'
    ] AS firstnames,
    ARRAY[
        'Matthews','Smith','Jones','Davis','Jacobson','Williams','Donaldson','Maxwell','Peterson','Stevens', 'Franklin','Washington','Jefferson','Adams',
        'Jackson','Johnson','Lincoln','Grant','Fillmore','Harding','Taft', 'Truman','Nixon','Ford','Carter','Reagan','Bush','Clinton','Hancock'
    ] AS lastnames
) AS arrays;

And an example of the result:

Random letter for a middle name

Suppose we also need a random letter for a middle name, then we can use the following query:

select substring('ABCDEFGHIJKLMNOPQRSTUVWXYZ' from trunc(random() * 26 + 1)::int for 1) AS middlename;

We take a string containing all the letters of the alphabet and choose a random character from it.

The result:

Date of birth

What other fields might we need? Maybe a date of birth.

The following expression returns a date between 18 and 100(approximately) years ago:

select date(now() - trunc(random() * 365 * 82 /*max age + 18*/) * '1 day'::interval - interval '18 year' /* min age*/) as birth_date;

Example of birth date:

You can check the correctness of the expression by replacing the random with 1. This expression returns us to date approximately 100 years ago:

select date(now() - trunc(1 * 365 * 82 /*max age + 18*/) * '1 day'::interval - interval '18 year' /* min age*/) as birth_date;

"Approximately" - because we don't take into account leap years.

SSN

SSN is a 9-digit value that can be obtained in the following way:

select 100000000 + round(random() * 900000000) as ssn;

SSN:

The SSN is usually written in the format 999-99-9999. A little later we will see how to do this.

Money

Also, for financial institutions, the amount on the client's account matters.

Often it is represented by 2 decimal places, so we will specify an interval and round it up:

select round((random() * 100000)::numeric, 2) as amount;

The result:

Address and phone

Often you need information about the customer's address and phone.

An address usually consists of a house number, street, city, and state.

Let's add the corresponding arrays to our query:

    ARRAY[
		'Green', 'Smith', 'Church', 'Grant', 'Cedar', 'Forest', 'Frankl', 'Birch', 'Jones', 'Brown',
		'Cherry', 'Willow', 'Rose', 'School', 'Wilson', 'Center', 'Walnut', 'Mill', 'Valley'
    ] as streets,
    ARRAY[
		'Washington', 'Franklin', 'Clinton', 'Georgetown', 'Springfield', 'Chester', 'Greenville', 'Dayton', 'Madison', 'Salem',
		'Winchester', 'Oakland', 'Milton', 'Newport', 'Ashland', 'Riverside', 'Manchester', 'Oxford', 'Burlington', 'Jackson', 'Milford'
    ] as cities,
    ARRAY[
		'AK','AL','AR','AZ','CA','CO','CT','DC','DE','FL','GA','HI','IA','ID','IL','IN','KS','KY','LA','MA','MD','ME','MI','MN','MS','MO',
		'MT','NC','NE','NH','NJ','NM','NV','NY','ND','OH','OK','OR','PA','RI','SC','SD','TN','TX','UT','VT','VA','WA','WV','WI','WY'
    ] as states

The street name is often followed by its type. We can use the case operator with a random switch parameter like this:

select case (random() * 2)::int when 0 then 'St.' when 1 then 'Ave.' when 2 then 'Rd.' end as street_type;

The result:

The case construct is useful when you want to randomize a small number of distinct values.

A house number is just a random number, let's say between 100 and 10000:

select (100 + random() * 9900)::int as house;

The result:

A phone number in the 'XXX XXX XXXX' format can be generated like this:

select concat(FLOOR(100 + random() * 900), ' ', FLOOR(100 + random() * 900), ' ', FLOOR(1000 + random() * 9000)) as phone;

Phone number:

Putting it all together

Now we put everything together and we get the following query:

select s as id,
    arrays.firstnames[trunc(random() * ARRAY_LENGTH(arrays.firstnames, 1) + 1)] AS firstname,
    substring('ABCDEFGHIJKLMNOPQRSTUVWXYZ' from trunc(random() * 26 + 1)::int for 1) AS middlename,
    arrays.lastnames[trunc(random() * ARRAY_LENGTH(arrays.lastnames,1) + 1)] AS lastname,
	date(now() - trunc(random() * 365 * 82 /*max age + 18*/) * '1 day'::interval - interval '18 year' /* min age*/) as birth_date,
	100000000 + round(random() * 900000000) as ssn,
	round((random() * 100000)::numeric, 2) as amount,
	(100 + random() * 9900)::int as house,
	arrays.streets[trunc(random() * ARRAY_LENGTH(arrays.streets, 1) + 1)]  AS street,
	case (random() * 2)::int when 0 then 'St.' when 1 then 'Ave.' when 2 then 'Rd.' end as street_type,
	arrays.cities[trunc(random() * ARRAY_LENGTH(arrays.cities, 1) + 1)] AS city,
	arrays.states[trunc(random() * ARRAY_LENGTH(arrays.states, 1) + 1)] AS state,
	concat(FLOOR(100 + random() * 900), ' ', FLOOR(100 + random() * 900), ' ', FLOOR(1000 + random() * 9000)) as phone
from generate_series(1, 1000) AS s -- number of rows to generate
CROSS JOIN(
    SELECT ARRAY[
		'Adam', 'Bill', 'Bob', 'Donald', 'Frank', 'George', 'James', 'John', 'Jacob', 'Jack', 'Martin', 'Matthew', 'Max', 'Michael', 'Paul','Peter', 'Ronald',
		'Samuel','Steve','William', 'Abigail', 'Alice', 'Amanda', 'Barbara','Betty', 'Carol', 'Donna', 'Jane','Jennifer','Julie','Mary','Melissa','Sarah','Susan'
    ] AS firstnames,
    ARRAY[
        'Matthews','Smith','Jones','Davis','Jacobson','Williams','Donaldson','Maxwell','Peterson','Stevens', 'Franklin','Washington','Jefferson','Adams',
        'Jackson','Johnson','Lincoln','Grant','Fillmore','Harding','Taft', 'Truman','Nixon','Ford','Carter','Reagan','Bush','Clinton','Hancock'
    ] AS lastnames,
    ARRAY[
		'Green', 'Smith', 'Church', 'Grant', 'Cedar', 'Forest', 'Frankl', 'Birch', 'Jones', 'Brown',
		'Cherry', 'Willow', 'Rose', 'School', 'Wilson', 'Center', 'Walnut', 'Mill', 'Valley'
    ] as streets,
    ARRAY[
		'Washington', 'Franklin', 'Clinton', 'Georgetown', 'Springfield', 'Chester', 'Greenville', 'Dayton', 'Madison', 'Salem',
		'Winchester', 'Oakland', 'Milton', 'Newport', 'Ashland', 'Riverside', 'Manchester', 'Oxford', 'Burlington', 'Jackson', 'Milford'
    ] as cities,
    ARRAY[
		'AK','AL','AR','AZ','CA','CO','CT','DC','DE','FL','GA','HI','IA','ID','IL','IN','KS','KY','LA','MA','MD','ME','MI','MN','MS','MO',
		'MT','NC','NE','NH','NJ','NM','NV','NY','ND','OH','OK','OR','PA','RI','SC','SD','TN','TX','UT','VT','VA','WA','WV','WI','WY'
    ] as states
) AS arrays;

And the result set:

Template string randomization function

We used a concatenation of several random values to generate a phone number, but in some cases, this method is not very convenient.

If you have a large number of columns whose values must match a certain pattern, you can use the function:

CREATE OR REPLACE FUNCTION rand_format(str text)
RETURNS text
LANGUAGE sql
AS $function$
       select
         array_to_string(
           array_agg(
             replace(replace(x, 'A', substring('ABCDEFGHIJKLMNOPQRSTUVWXYZ' from trunc(random()*26 + 1)::int for 1)), '9', trunc(random()*9 + 1)::text)
           ),
         '')
       from (select regexp_split_to_table(str, '') as x) a
$function$
;

The function takes a string containing a pattern and replaces 9 with a random number and the letter A with a random alphabetic character.

Examples of how to use it:

select
	rand_format('(999) 999-9999') as phone_number,
	rand_format('AAA-9999') as plate_number,
	rand_format('99 99 999999') as passport_number,
	rand_format('99 AA 999999') as driver_licence,
	rand_format('199.199.199.199') as ip_address;

Here is the result:

Of course, there are a number of limitations in such a primitive implementation of the function. For example, each of the 4 numbers in the IP address is generated in the range from 100 to 199, although it should be in the range from 0 to 255. But for some test cases, this may be acceptable.

Randomization function with a subset

I would like to give here one more overloaded implementation of this function:

CREATE OR REPLACE FUNCTION rand_format(str text, subset text)
RETURNS text
LANGUAGE sql
AS $function$
       select
         array_to_string(
           array_agg(
             replace(x, 'A', substring(subset from trunc(random()*length(subset) + 1)::int for 1))
           ),
         '')
       from (select regexp_split_to_table(str, '') as x) a
$function$
;

This implementation takes 2 parameters as input - a template string and a set of characters to replace. Thus, all A's will be replaced with random ones from the pattern.

Examples of this function call:

select
	rand_format('AAAA:AAAA:AAAA:AAAA:AAAA:AAAA:AAAA:AAAA', '0123456789abcdef') as ip_v6,
	rand_format('AAAAAAAA AAAAAAAA AAAAAAAA AAAAAAAA', 'abcdefghijklmnopqrstuvwxyz ') secret_phrase;

Here we can see how this function works:

Extending the Query

Let's add all of the above to our main query, and also create an outer query so that we can combine test columns:

select
	concat(firstname, ' ', middlename, ' ', lastname) as client_name,
	concat(firstname, middlename, lastname, '@', case (random()*2)::int when 0 then 'gmail' when 1 then 'hotmail' when 2 then 'yahoo' end || '.com') as email,
	concat(house, ', ', street, ' ', street_type, ', ', city, ', ', state) as address,
	birth_date, ssn, amount, phone,
	rand_format('(999) 999-9999') as phone_number2,
	rand_format('AAA-9999') as plate_number,
	rand_format('99 99 999999') as passport_number,
	rand_format('99 AA 999999') as driver_licence,
	rand_format('199.199.199.199') as ip_address,
	rand_format('AAAA:AAAA:AAAA:AAAA:AAAA:AAAA:AAAA:AAAA', '0123456789abcdef') as ip_v6,
	rand_format('AAAAAAAA AAAAAAAA AAAAAAAA AAAAAAAA', 'abcdefghijklmnopqrstuvwxyz ') secret_phrase
from (
select s as id,
    arrays.firstnames[trunc(random() * ARRAY_LENGTH(arrays.firstnames, 1) + 1)] AS firstname,
    substring('ABCDEFGHIJKLMNOPQRSTUVWXYZ' from trunc(random() * 26 + 1)::int for 1) AS middlename,
    arrays.lastnames[trunc(random() * ARRAY_LENGTH(arrays.lastnames,1) + 1)] AS lastname,
	date(now() - trunc(random() * 365 * 82 /*max age + 18*/) * '1 day'::interval - interval '18 year' /* min age*/) as birth_date,
	rand_format('999-99-9999') as ssn,
	round((random() * 100000)::numeric, 2) as amount,
	(100 + random() * 9900)::int as house,
	arrays.streets[trunc(random() * ARRAY_LENGTH(arrays.streets, 1) + 1)]  AS street,
	case (random() * 2)::int when 0 then 'St.' when 1 then 'Ave.' when 2 then 'Rd.' end as street_type,
	arrays.cities[trunc(random() * ARRAY_LENGTH(arrays.cities, 1) + 1)] AS city,
	arrays.states[trunc(random() * ARRAY_LENGTH(arrays.states, 1) + 1)] AS state,
	concat(FLOOR(100 + random() * 900), ' ', FLOOR(100 + random() * 900), ' ', FLOOR(1000 + random() * 9000)) as phone
from generate_series(1, 1000) AS s -- number of rows to generate
CROSS JOIN(
    SELECT ARRAY[
		'Adam', 'Bill', 'Bob', 'Donald', 'Frank', 'George', 'James', 'John', 'Jacob', 'Jack', 'Martin', 'Matthew', 'Max', 'Michael', 'Paul','Peter', 'Ronald',
		'Samuel','Steve','William', 'Abigail', 'Alice', 'Amanda', 'Barbara','Betty', 'Carol', 'Donna', 'Jane','Jennifer','Julie','Mary','Melissa','Sarah','Susan'
    ] AS firstnames,
    ARRAY[
        'Matthews','Smith','Jones','Davis','Jacobson','Williams','Donaldson','Maxwell','Peterson','Stevens', 'Franklin','Washington','Jefferson','Adams',
        'Jackson','Johnson','Lincoln','Grant','Fillmore','Harding','Taft', 'Truman','Nixon','Ford','Carter','Reagan','Bush','Clinton','Hancock'
    ] AS lastnames,
    ARRAY[
		'Green', 'Smith', 'Church', 'Grant', 'Cedar', 'Forest', 'Frankl', 'Birch', 'Jones', 'Brown',
		'Cherry', 'Willow', 'Rose', 'School', 'Wilson', 'Center', 'Walnut', 'Mill', 'Valley'
    ] as streets,
    ARRAY[
		'Washington', 'Franklin', 'Clinton', 'Georgetown', 'Springfield', 'Chester', 'Greenville', 'Dayton', 'Madison', 'Salem',
		'Winchester', 'Oakland', 'Milton', 'Newport', 'Ashland', 'Riverside', 'Manchester', 'Oxford', 'Burlington', 'Jackson', 'Milford'
    ] as cities,
    ARRAY[
		'AK','AL','AR','AZ','CA','CO','CT','DC','DE','FL','GA','HI','IA','ID','IL','IN','KS','KY','LA','MA','MD','ME','MI','MN','MS','MO',
		'MT','NC','NE','NH','NJ','NM','NV','NY','ND','OH','OK','OR','PA','RI','SC','SD','TN','TX','UT','VT','VA','WA','WV','WI','WY'
    ] as states
) AS arrays
) test_data;

And our result test set is:

With this query, you can combine different fields with each other to get the values of new ones. For example, we used firstname, middlename, and lastname to generate values for the email field.

Note that we also used the rand_format function to generate the SSN in the format 123-45-6789.

Using in MySQL

For MySQL version 8, you can use the CTE to generate series:

WITH RECURSIVE s as
	(SELECT 1 AS n UNION ALL SELECT n + 1 AS value FROM s WHERE s.n < 1000)
SELECT n from s;

If you have MySQL version 5, then to generate a sequence, you can create a table and add data to it:

create table t(f int);
insert into t select 1;
insert into t select * from t; -- execute this line multiple times

If it is not possible to use CTE and if you do not want to create tables, you can use a table from information_schema, for example, columns:

select * from information_schema.COLUMNS c; -- in my database I get 3652 rows

If you need to generate more records in one query, then you can join the table with itself:

-- in my case this query returns more than 13 million of rows
select * from information_schema.COLUMNS c cross join information_schema.COLUMNS c1;

Although, this method may not be the best solution in terms of performance.

In order for the above queries to work correctly in MySQL, you also need to:

  • replace random() with rand()
  • replace type casting with a round or floor function
  • write arrays via union
  • change the way ID is generated

Example of query in MySQL using information_schema:

SET @row_number = 0;

select  @row_number := @row_number + 1 as id, a.*
from
(select
	firstnames.firstname, lastnames.lastname,
	100000000 + round(rand() * 900000000) as ssn,
	round((rand() * 100000), 2) as amount,
	round(100 + rand() * 9900) as house,
	case (round(rand() * 2)) when 0 then 'St.' when 1 then 'Ave.' when 2 then 'Rd.' end as street_type,
	concat(FLOOR(100 + rand() * 900), ' ', FLOOR(100 + rand() * 900), ' ', FLOOR(1000 + rand() * 9000)) as phone
from (
    select 'Adam' as firstname
    union select 'Bill' union select 'Bob' union select 'Donald' union select 'Frank' union select 'George'
	union select 'James' union select 'John' union select 'Jacob' union select 'Jack' union select 'Martin' union select 'Matthew'
	union select 'Max' union select 'Michael' union select 'Paul' union select 'Ronald'
 ) firstnames,
 (
	select 'Matthews' as lastname union select 'Smith' union select 'Jones' union select 'Davis' union select 'Jacobson' union select 'Williams' union select 'Donaldson'
	union select 'Maxwell' union select 'Peterson' union select 'Stevens' union select 'Franklin' union select 'Washington' union select 'Jefferson'
	union select 'Adams' union select 'Jackson' union select 'Johnson' union select 'Lincoln' union select 'Grant' union select 'Fillmore' union select 'Harding'
	union select 'Taft' union select 'Truman' union select 'Nixon' union select 'Ford' union select 'Carter' union select 'Reagan' union select 'Bush'
	union select 'Clinton' union select 'Hancock'
 ) lastnames
join information_schema.columns c
order by rand()
) a
;

And its result:

Conclusion

For other DBMS, there may also be some differences, but in general, this approach can be used in any other relational DBMS.

By having this query handy or by creating a view based on it, you will always be equipped with a fast and efficient way to generate test data.


Written by yuridanilov | Software Developer
Published by HackerNoon on 2022/09/26