use actix_web::Result as AwResult;
use actix_web::{HttpRequest, get, post, web};
use clickhouse::Row;
use maud::html;
use serde::{Deserialize, Serialize};
use time::{Date, OffsetDateTime, macros::date};
use crate::ConnectionStore;
// ---------------------------------------------------------------------------
// Row type – one row in the generated test table.
//
// Represents a financial transaction between two users.
// ---------------------------------------------------------------------------
/// ISO 4217 currency codes used in generated transactions.
const CURRENCIES: &[&str] = &["USD", "EUR", "GBP", "JPY", "CHF", "CAD", "AUD", "SEK"];
/// The view / reporting currency that `view_currency_amount` is expressed in.
const VIEW_CURRENCY: &str = "USD";
/// Approximate exchange rates to USD for each entry in CURRENCIES.
const TO_USD: &[f64] = &[1.0, 1.09, 1.27, 0.0067, 1.11, 0.74, 0.65, 0.096];
const GIVEN_NAMES: &[&str] = &[
"Alice", "Bob", "Carol", "David", "Eva", "Frank", "Grace", "Henry", "Iris", "Jack", "Karen",
"Leo", "Mia", "Noah", "Olivia", "Paul", "Quinn", "Rachel", "Sam", "Tina", "Uma", "Victor",
"Wendy", "Xander", "Yara", "Zoe", "Aaron", "Beth", "Carlos", "Diana",
];
const FAMILY_NAMES: &[&str] = &[
"Smith", "Jones", "Williams", "Taylor", "Brown", "Davies", "Evans", "Wilson", "Thomas",
"Roberts", "Johnson", "Walker", "Wright", "Robinson", "Thompson", "White", "Hughes", "Edwards",
"Green", "Hall", "Lewis", "Harris", "Clarke", "Patel", "Jackson", "Wood", "Turner", "Martin",
"Cooper", "Hill",
];
const STREET_NAMES: &[&str] = &[
"Main St",
"Oak Ave",
"Maple Rd",
"Cedar Ln",
"Pine St",
"Elm Dr",
"River Rd",
"Park Ave",
"Lake Dr",
"Hill St",
"Forest Rd",
"Valley Ln",
"Sunset Blvd",
"Highland Ave",
"Spring St",
"Mill Rd",
"Church St",
"Station Rd",
"School Ln",
"Grove Ave",
];
/// (city, postcode) pairs kept consistent so the same city always has the same postcode prefix.
const CITIES: &[(&str, &str)] = &[
("London", "EC1A 1BB"),
("New York", "10001"),
("Berlin", "10115"),
("Paris", "75001"),
("Tokyo", "100-0001"),
("Sydney", "2000"),
("Toronto", "M5H 2N2"),
("Amsterdam", "1012 JS"),
("Madrid", "28001"),
("Vienna", "1010"),
("Stockholm", "111 29"),
("Zurich", "8001"),
("Singapore", "018989"),
("Dubai", "00000"),
("Chicago", "60601"),
("Los Angeles", "90001"),
("San Francisco", "94102"),
("Boston", "02101"),
("Seattle", "98101"),
("Austin", "78701"),
];
/// Return deterministic user details for a zero-based user index.
fn user_details(user_idx: i64) -> (String, String, String, String, String) {
let idx = user_idx as usize;
let given = GIVEN_NAMES[idx % GIVEN_NAMES.len()];
let family = FAMILY_NAMES[(idx / GIVEN_NAMES.len()) % FAMILY_NAMES.len()];
let street_num = (idx % 200 + 1) as u32;
let street = STREET_NAMES[idx % STREET_NAMES.len()];
let address = format!("{street_num} {street}");
let (city, postcode) = CITIES[idx % CITIES.len()];
(
given.to_string(),
family.to_string(),
address,
city.to_string(),
postcode.to_string(),
)
}
#[derive(Row, Serialize)]
struct TestRow {
id: i64,
// --- sender ---
from_user: String,
from_given_name: String,
from_family_name: String,
from_address: String,
from_city: String,
from_postcode: String,
// --- recipient ---
to_user: String,
to_given_name: String,
to_family_name: String,
to_address: String,
to_city: String,
to_postcode: String,
// --- transaction ---
/// Transaction amount in the transaction currency
amount: f64,
/// ISO 4217 currency code of the transaction
currency: String,
/// Amount converted to the view / reporting currency (USD)
view_currency_amount: f64,
/// The reporting currency (always USD for this dataset)
view_currency: String,
/// Whether the transaction was completed successfully
completed: bool,
/// Calendar date of the transaction
#[serde(with = "clickhouse::serde::time::date")]
transaction_date: Date,
/// Exact timestamp of the transaction
#[serde(with = "clickhouse::serde::time::datetime")]
transaction_at: OffsetDateTime,
}
// ---------------------------------------------------------------------------
// Form params
// ---------------------------------------------------------------------------
#[derive(Deserialize)]
pub struct GenerateParams {
#[serde(default = "default_rows")]
rows: u64,
#[serde(default = "default_database")]
database: String,
#[serde(default = "default_table")]
table: String,
}
fn default_rows() -> u64 {
500_000
}
fn default_database() -> String {
"default".to_string()
}
fn default_table() -> String {
"chtmx_test_data".to_string()
}
// ---------------------------------------------------------------------------
// GET /test-data – page
// ---------------------------------------------------------------------------
#[get("/test-data")]
pub async fn test_data_page(
req: HttpRequest,
store: web::Data<ConnectionStore>,
) -> AwResult<maud::Markup> {
let no_conn = store.active_client().is_none();
let content = html! {
div class="w-100 flex flex-column" style="height: 100%;" {
div class="bg-black-70 pa3 bb b--white-20" {
h1 class="f4 fw6 white-90 ma0" { "Test Data Generator" }
p class="f6 white-60 mt2 mb0 lh-copy" {
"Creates (or replaces) a table in ClickHouse filled with deterministic "
"data covering every type chtmx supports: "
span class="white-80 fw6" {
"String, Int64, Float64, Bool, Date, DateTime"
}
"."
}
}
div class="pa3" {
@if no_conn {
div class="bg-near-black ba b--white-20 br2 pa4 mw6" {
p class="f5 white-70 mb3" { "No active database connection" }
a href="/connections"
hx-get="/connections"
hx-target="#feature"
hx-swap="innerHTML"
hx-push-url="true"
class="dib bg-orange white br2 ph3 pv2 no-underline f6 fw6 hover-bg-dark-orange" {
"Configure a connection"
}
}
} @else {
form
hx-post="/test-data/generate"
hx-target="#generate-result"
hx-swap="innerHTML"
hx-indicator="#generate-spinner"
class="mw6" {
div class="mb3" {
label class="db fw6 f6 white-90 mb1" for="td-rows" {
"Number of rows"
}
input
id="td-rows"
type="number"
name="rows"
value="500000"
min="1"
max="10000000"
class="input-reset ba b--white-30 pa2 br2 f6 bg-white-10 white w-100"
style="color: white;";
}
div class="mb3" {
label class="db fw6 f6 white-90 mb1" for="td-database" {
"Database"
}
input
id="td-database"
type="text"
name="database"
value="default"
class="input-reset ba b--white-30 pa2 br2 f6 bg-white-10 white w-100"
style="color: white;";
}
div class="mb3" {
label class="db fw6 f6 white-90 mb1" for="td-table" {
"Table name"
}
input
id="td-table"
type="text"
name="table"
value="chtmx_test_data"
class="input-reset ba b--white-30 pa2 br2 f6 bg-white-10 white w-100"
style="color: white;";
}
div class="flex items-center" {
button
type="submit"
class="bg-orange white bn br2 ph3 pv2 f6 fw6 pointer hover-bg-dark-orange" {
"Generate"
}
span
id="generate-spinner"
class="htmx-indicator ml3 f6 white-70 i" {
"Inserting rows…"
}
}
}
div id="generate-result" class="mt3" {}
}
}
}
};
if req.headers().get("HX-Request").is_some() {
Ok(content)
} else {
Ok(super::render_layout(&content))
}
}
// ---------------------------------------------------------------------------
// POST /test-data/generate – HTMX action endpoint
// ---------------------------------------------------------------------------
#[post("/test-data/generate")]
pub async fn generate(
params: web::Form<GenerateParams>,
store: web::Data<ConnectionStore>,
) -> AwResult<maud::Markup> {
let Some(conn) = store.active() else {
return Ok(error_markup("No active connection."));
};
let rows = params.rows.clamp(1, 10_000_000);
let database = params.database.trim().to_string();
let table = params.table.trim().to_string();
let started = std::time::Instant::now();
match insert_test_data(&conn, &database, &table, rows).await {
Ok(()) => {
let elapsed_ms = started.elapsed().as_millis();
Ok(html! {
div class="bg-dark-green white br2 pa3 f6 lh-copy" {
p class="ma0 fw6 f5" { "Done" }
p class="ma0 mt1" {
"Inserted " span class="fw6" { (rows) } " rows into "
code class="bg-black-20 ph1 br1" {
(database) "." (table)
}
" in " span class="fw6" { (elapsed_ms) " ms" } "."
}
p class="ma0 mt2 white-80" {
"Schema: "
code class="bg-black-20 ph1 br1" {
"id, from_user, from_given_name, from_family_name, from_address, "
"from_city, from_postcode, to_user, to_given_name, to_family_name, "
"to_address, to_city, to_postcode, amount, currency, "
"view_currency_amount, view_currency, completed, "
"transaction_date, transaction_at"
}
}
}
})
}
Err(e) => Ok(error_markup(&e.to_string())),
}
}
fn error_markup(msg: &str) -> maud::Markup {
html! {
div class="bg-dark-red white br2 pa3 f6 lh-copy" {
p class="ma0 fw6" { "Error" }
p class="ma0 mt1" { (msg) }
}
}
}
// ---------------------------------------------------------------------------
// Core insert logic
// ---------------------------------------------------------------------------
const BATCH_SIZE: u64 = 50_000;
/// Drop-and-recreate `database.table`, then stream `row_count` deterministic
/// rows via the ClickHouse binary protocol (clickhouse-rs `insert()`).
async fn insert_test_data(
conn: &crate::connections::Connection,
database: &str,
table: &str,
row_count: u64,
) -> Result<(), Box<dyn std::error::Error>> {
let client = conn.build_client().with_database(database);
// Drop and recreate so repeated calls always produce a clean table.
client
.query(&format!("DROP TABLE IF EXISTS `{table}`"))
.execute()
.await?;
client
.query(&format!(
"CREATE TABLE `{table}` (
id Int64,
from_user String,
from_given_name String,
from_family_name String,
from_address String,
from_city String,
from_postcode String,
to_user String,
to_given_name String,
to_family_name String,
to_address String,
to_city String,
to_postcode String,
amount Float64,
currency String,
view_currency_amount Float64,
view_currency String,
completed Bool,
transaction_date Date,
transaction_at DateTime
) ENGINE = MergeTree() ORDER BY id"
))
.execute()
.await?;
// Anchor date for cycling (time crate constant).
let base_date = date!(2020 - 01 - 01);
let mut offset: u64 = 0;
while offset < row_count {
let batch_len = BATCH_SIZE.min(row_count - offset);
let mut insert = client.insert(table)?;
for i in offset..(offset + batch_len) {
let id = (i + 1) as i64;
// Pick a currency deterministically from the pool.
let currency_idx = (i as usize) % CURRENCIES.len();
let currency = CURRENCIES[currency_idx].to_string();
let rate = TO_USD[currency_idx];
// Transaction amount: varies between ~10 and ~9 999
let amount = ((id * 137 + 42) % 9_990) as f64 + 10.0 + ((id as f64 * 0.73) % 1.0);
let view_currency_amount = (amount * rate * 100.0).round() / 100.0;
// Date: cycles through 365 days starting 2020-01-01
let transaction_date: Date = base_date + time::Duration::days((i % 365) as i64);
// DateTime: same date, hour/minute derived from id
let hour = ((i / 365) % 24) as u8;
let minute = ((i / 24) % 60) as u8;
let transaction_at: OffsetDateTime = transaction_date
.with_hms(hour, minute, 0)
.expect("valid hms")
.assume_utc();
let from_idx = id % 500;
let to_idx = (id * 3 + 1) % 500;
let (from_given, from_family, from_address, from_city, from_postcode) =
user_details(from_idx);
let (to_given, to_family, to_address, to_city, to_postcode) = user_details(to_idx);
insert
.write(&TestRow {
id,
from_user: format!("user_{from_idx}"),
from_given_name: from_given,
from_family_name: from_family,
from_address,
from_city,
from_postcode,
to_user: format!("user_{to_idx}"),
to_given_name: to_given,
to_family_name: to_family,
to_address,
to_city,
to_postcode,
amount,
currency,
view_currency_amount,
view_currency: VIEW_CURRENCY.to_string(),
completed: id % 10 != 0, // ~10 % failed transactions
transaction_date,
transaction_at,
})
.await?;
}
insert.end().await?;
offset += batch_len;
}
Ok(())
}