diff --git a/demo/README.md b/demo/README.md index c66965ac..333405d2 100644 --- a/demo/README.md +++ b/demo/README.md @@ -8,8 +8,8 @@ Tabularis features end to end. | Engine | Port | Databases | Theme | | ------------- | ----- | -------------------------------- | ---------------------------- | -| MySQL 8.4 | 3306 | `tabularis_demo`, `blog_demo` | HR/e-commerce + blog CMS | -| PostgreSQL 16 | 5432 | `tabularis_demo`, `analytics_demo` | HR/e-commerce + web analytics (JSONB) | +| MySQL 8.4 | 3306 | `tabularis_demo`, `blog_demo`, `perf_demo` | HR/e-commerce + blog CMS + wide-table perf | +| PostgreSQL 16 | 5432 | `tabularis_demo`, `analytics_demo`, `perf_demo` | HR/e-commerce + web analytics (JSONB) + wide-table perf | | SQL Server 2022 | 1433 | `tabularis_demo`, `finance_demo` | HR/e-commerce + accounting | `tabularis_demo` is the **same logical schema** on all three engines (departments, @@ -17,6 +17,18 @@ employees, products, customers, orders, order_items) — useful for testing the showcase notebook against any driver. The second database per engine highlights features specific to that ecosystem. +### `perf_demo` — wide-table scroll stress test + +On MySQL and PostgreSQL the `perf_demo` database holds a single +`wide_table` with **50 columns × 50 000 rows** (a mix of int, bigint, decimal, +double, varchar, text, date, datetime, boolean and JSON/JSONB columns). It +reproduces slow-scroll reports with many columns/rows so DataGrid virtualization +and memoization can be profiled — open `perf_demo.wide_table` and scroll both +axes; it should stay fluid. This is a heavy seed, so the **first** container boot +takes a little longer. The SQL is generated by +[`generate-perf-sql.py`](./generate-perf-sql.py) (edit there and re-run to change +the column/row counts). + ## Prerequisites - Docker Desktop or Docker Engine 24+ with the Compose plugin @@ -70,11 +82,12 @@ All three servers share the same password for simplicity: Open Tabularis → **Connections** → **Import** and pick `connections.json`. -This adds a **Tabularis Demo (Docker)** group with two pre-configured -connections: +This adds a **Tabularis Demo (Docker)** group with pre-configured connections: -- **Demo · MySQL** — exposes `tabularis_demo` and `blog_demo` -- **Demo · PostgreSQL** — exposes `tabularis_demo` and `analytics_demo` +- **Demo · MySQL** — exposes `tabularis_demo`, `blog_demo` and `perf_demo` +- **Demo · PostgreSQL** — exposes `tabularis_demo` +- **Demo · PostgreSQL (analytics_demo)** — the JSONB analytics database +- **Demo · PostgreSQL (perf_demo)** — the wide-table scroll stress test > **SQL Server is not in `connections.json`.** Tabularis core currently ships > drivers for MySQL, PostgreSQL, and SQLite only; the official plugin registry @@ -94,14 +107,19 @@ Once `Demo · MySQL` is connected and selected on `tabularis_demo`, import demo/ ├── docker-compose.yml ├── connections.json # Importable into Tabularis +├── generate-perf-sql.py # Regenerates the 06-perf-wide.sql seeds ├── notebook-showcase.tabularis-notebook ├── init/ │ ├── mysql/ │ │ ├── 01-tabularis-demo.sql -│ │ └── 02-blog-demo.sql +│ │ ├── 02-blog-demo.sql +│ │ ├── ... +│ │ └── 06-perf-wide.sql # 50 cols x 50k rows (perf_demo) │ ├── postgres/ │ │ ├── 01-tabularis-demo.sql -│ │ └── 02-analytics-demo.sql +│ │ ├── 02-analytics-demo.sql +│ │ ├── ... +│ │ └── 06-perf-wide.sql # 50 cols x 50k rows (perf_demo) │ └── mssql/ │ ├── run-init.sh # Sidecar entrypoint │ ├── 01-tabularis-demo.sql # Idempotent diff --git a/demo/connections.json b/demo/connections.json index a410376b..739d5b22 100644 --- a/demo/connections.json +++ b/demo/connections.json @@ -20,7 +20,7 @@ "port": 3306, "username": "root", "password": "Tabularis_Demo_2026!", - "database": ["tabularis_demo", "blog_demo"], + "database": ["tabularis_demo", "blog_demo", "perf_demo"], "ssl_mode": null, "ssl_ca": null, "ssl_cert": null, @@ -71,6 +71,27 @@ "ssh_connection_id": null, "save_in_keychain": true } + }, + { + "id": "tabularis-demo-postgres-perf", + "name": "Demo · PostgreSQL (perf_demo)", + "group_id": "tabularis-demo-group", + "sort_order": 3, + "params": { + "driver": "postgres", + "host": "127.0.0.1", + "port": 5432, + "username": "postgres", + "password": "Tabularis_Demo_2026!", + "database": "perf_demo", + "ssl_mode": null, + "ssl_ca": null, + "ssl_cert": null, + "ssl_key": null, + "ssh_enabled": false, + "ssh_connection_id": null, + "save_in_keychain": true + } } ], "ssh_connections": [] diff --git a/demo/generate-perf-sql.py b/demo/generate-perf-sql.py new file mode 100644 index 00000000..18a76beb --- /dev/null +++ b/demo/generate-perf-sql.py @@ -0,0 +1,165 @@ +#!/usr/bin/env python3 +"""Generate the perf "wide table" seed for the demo stack (MySQL + Postgres). + +Produces a `perf_demo.wide_table` with 50 columns (id + 49 data columns of +mixed types) and 50_000 rows, used to stress-test DataGrid scroll performance. + +Run from the repo root: python3 demo/generate-perf-sql.py +Writes: demo/init/mysql/06-perf-wide.sql, demo/init/postgres/06-perf-wide.sql +""" + +ROWS = 50000 + +# Each spec: (name, mysql_type, pg_type, mysql_expr, pg_expr) +# {g} is the row counter (1..ROWS) and is the same variable name in both dialects. +specials = [ + ("json_data", "JSON", "JSONB", + "JSON_OBJECT('row', {g}, 'label', CONCAT('item-', {g}), 'tags', JSON_ARRAY('alpha','beta', {g} MOD 7))", + "jsonb_build_object('row', {g}, 'label', 'item-' || {g}, 'tags', jsonb_build_array('alpha','beta', {g} % 7))"), + ("long_text", "TEXT", "TEXT", + "REPEAT(CONCAT('Lorem ipsum dolor row ', {g}, '. '), 6)", + "repeat('Lorem ipsum dolor row ' || {g} || '. ', 6)"), + ("created_at", "DATETIME", "TIMESTAMP", + "DATE_ADD('2000-01-01 00:00:00', INTERVAL {g} SECOND)", + "TIMESTAMP '2000-01-01 00:00:00' + ({g} * INTERVAL '1 second')"), + ("updated_at", "DATETIME", "TIMESTAMP", + "DATE_ADD('2010-01-01 00:00:00', INTERVAL ({g}*2) SECOND)", + "TIMESTAMP '2010-01-01 00:00:00' + (({g}*2) * INTERVAL '1 second')"), + ("birth_date", "DATE", "DATE", + "DATE_ADD('1970-01-01', INTERVAL ({g} MOD 18000) DAY)", + "DATE '1970-01-01' + ({g} % 18000)"), + ("is_active", "TINYINT(1)", "BOOLEAN", + "({g} MOD 2)", + "({g} % 2 = 0)"), + ("is_verified", "TINYINT(1)", "BOOLEAN", + "({g} MOD 3 = 0)", + "({g} % 3 = 0)"), + ("amount", "DECIMAL(12,2)", "DECIMAL(12,2)", + "ROUND(({g} MOD 100000) * 1.25, 2)", + "ROUND((({g} % 100000) * 1.25)::numeric, 2)"), + ("balance", "DECIMAL(14,4)", "DECIMAL(14,4)", + "ROUND(({g} MOD 50000) * 0.337, 4)", + "ROUND((({g} % 50000) * 0.337)::numeric, 4)"), + ("score", "DOUBLE", "DOUBLE PRECISION", + "({g} * 0.5)", + "({g} * 0.5)"), + ("big_value", "BIGINT", "BIGINT", + "({g} * 1000003)", + "({g}::bigint * 1000003)"), +] + +# Fill the remaining columns up to 49 data columns with attr_NN cycling types. +DATA_COLS = 49 +fill_count = DATA_COLS - len(specials) +cycle = ["varchar", "int", "decimal"] + +fills = [] +for i in range(fill_count): + idx = i + 1 + name = f"attr_{idx:02d}" + kind = cycle[i % len(cycle)] + if kind == "varchar": + fills.append((name, "VARCHAR(80)", "VARCHAR(80)", + f"CONCAT('attr{idx}-', {{g}})", + f"'attr{idx}-' || {{g}}")) + elif kind == "int": + fills.append((name, "INT", "INTEGER", + f"(({{g}} * {idx}) MOD 100000)", + f"(({{g}} * {idx}) % 100000)")) + else: # decimal + fills.append((name, "DECIMAL(12,2)", "DECIMAL(12,2)", + f"ROUND(({{g}} MOD 9999) * {idx} * 0.1, 2)", + f"ROUND((({{g}} % 9999) * {idx} * 0.1)::numeric, 2)")) + +cols = specials + fills +assert len(cols) == DATA_COLS, len(cols) + +HEADER = f"""-- ============================================================= +-- Tabularis Demo — Perf / wide-table stress test ({{engine}}) +-- Database: perf_demo +-- Table: wide_table -> 50 columns x {ROWS:,} rows +-- Purpose: reproduce slow-scroll reports with many columns/rows so +-- DataGrid virtualization/memoization can be profiled. +-- NOTE: heavy dataset — first container boot takes a little longer. +-- Generated by demo/generate-perf-sql.py (edit there, then re-run). +-- =============================================================""" + +# ---- MySQL ---------------------------------------------------------------- +my_coldefs = [" id BIGINT NOT NULL PRIMARY KEY"] +for name, mty, _pty, _me, _pe in cols: + my_coldefs.append(f" {name} {mty}") +my_collist = ",\n".join(my_coldefs) + +my_insert_cols = ["id"] + [c[0] for c in cols] +my_insert_exprs = ["g.g"] + [c[3].replace("{g}", "g.g") for c in cols] + +digit = "(SELECT 0 d UNION ALL SELECT 1 UNION ALL SELECT 2 UNION ALL SELECT 3 UNION ALL SELECT 4 UNION ALL SELECT 5 UNION ALL SELECT 6 UNION ALL SELECT 7 UNION ALL SELECT 8 UNION ALL SELECT 9)" + +mysql = f"""{HEADER.replace("{engine}", "MySQL 8")} + +SET NAMES utf8mb4; + +CREATE DATABASE IF NOT EXISTS perf_demo + CHARACTER SET utf8mb4 + COLLATE utf8mb4_unicode_ci; + +USE perf_demo; + +DROP TABLE IF EXISTS wide_table; + +CREATE TABLE wide_table ( +{my_collist} +) ENGINE=InnoDB; + +-- 50k rows via 5 cross-joined digit tables (0..9 each -> 0..99999), filtered. +INSERT INTO wide_table ( + {", ".join(my_insert_cols)} +) +SELECT + {",\n ".join(my_insert_exprs)} +FROM ( + SELECT (d5.d*10000 + d4.d*1000 + d3.d*100 + d2.d*10 + d1.d) + 1 AS g + FROM {digit} d1 + CROSS JOIN {digit} d2 + CROSS JOIN {digit} d3 + CROSS JOIN {digit} d4 + CROSS JOIN {digit} d5 +) AS g +WHERE g.g <= {ROWS}; +""" + +# ---- Postgres ------------------------------------------------------------- +pg_coldefs = [" id BIGINT NOT NULL PRIMARY KEY"] +for name, _mty, pty, _me, _pe in cols: + pg_coldefs.append(f" {name} {pty}") +pg_collist = ",\n".join(pg_coldefs) + +pg_insert_cols = ["id"] + [c[0] for c in cols] +pg_insert_exprs = ["g"] + [c[4].replace("{g}", "g") for c in cols] + +postgres = f"""{HEADER.replace("{engine}", "PostgreSQL 16")} + +CREATE DATABASE perf_demo; + +\\connect perf_demo + +DROP TABLE IF EXISTS wide_table; + +CREATE TABLE wide_table ( +{pg_collist} +); + +INSERT INTO wide_table ( + {", ".join(pg_insert_cols)} +) +SELECT + {",\n ".join(pg_insert_exprs)} +FROM generate_series(1, {ROWS}) AS g; +""" + +with open("demo/init/mysql/06-perf-wide.sql", "w") as f: + f.write(mysql) +with open("demo/init/postgres/06-perf-wide.sql", "w") as f: + f.write(postgres) + +print("wrote", DATA_COLS + 1, "columns,", ROWS, "rows") diff --git a/demo/init/mysql/06-perf-wide.sql b/demo/init/mysql/06-perf-wide.sql new file mode 100644 index 00000000..73a6e0de --- /dev/null +++ b/demo/init/mysql/06-perf-wide.sql @@ -0,0 +1,137 @@ +-- ============================================================= +-- Tabularis Demo — Perf / wide-table stress test (MySQL 8) +-- Database: perf_demo +-- Table: wide_table -> 50 columns x 50,000 rows +-- Purpose: reproduce slow-scroll reports with many columns/rows so +-- DataGrid virtualization/memoization can be profiled. +-- NOTE: heavy dataset — first container boot takes a little longer. +-- Generated by demo/generate-perf-sql.py (edit there, then re-run). +-- ============================================================= + +SET NAMES utf8mb4; + +CREATE DATABASE IF NOT EXISTS perf_demo + CHARACTER SET utf8mb4 + COLLATE utf8mb4_unicode_ci; + +USE perf_demo; + +DROP TABLE IF EXISTS wide_table; + +CREATE TABLE wide_table ( + id BIGINT NOT NULL PRIMARY KEY, + json_data JSON, + long_text TEXT, + created_at DATETIME, + updated_at DATETIME, + birth_date DATE, + is_active TINYINT(1), + is_verified TINYINT(1), + amount DECIMAL(12,2), + balance DECIMAL(14,4), + score DOUBLE, + big_value BIGINT, + attr_01 VARCHAR(80), + attr_02 INT, + attr_03 DECIMAL(12,2), + attr_04 VARCHAR(80), + attr_05 INT, + attr_06 DECIMAL(12,2), + attr_07 VARCHAR(80), + attr_08 INT, + attr_09 DECIMAL(12,2), + attr_10 VARCHAR(80), + attr_11 INT, + attr_12 DECIMAL(12,2), + attr_13 VARCHAR(80), + attr_14 INT, + attr_15 DECIMAL(12,2), + attr_16 VARCHAR(80), + attr_17 INT, + attr_18 DECIMAL(12,2), + attr_19 VARCHAR(80), + attr_20 INT, + attr_21 DECIMAL(12,2), + attr_22 VARCHAR(80), + attr_23 INT, + attr_24 DECIMAL(12,2), + attr_25 VARCHAR(80), + attr_26 INT, + attr_27 DECIMAL(12,2), + attr_28 VARCHAR(80), + attr_29 INT, + attr_30 DECIMAL(12,2), + attr_31 VARCHAR(80), + attr_32 INT, + attr_33 DECIMAL(12,2), + attr_34 VARCHAR(80), + attr_35 INT, + attr_36 DECIMAL(12,2), + attr_37 VARCHAR(80), + attr_38 INT +) ENGINE=InnoDB; + +-- 50k rows via 5 cross-joined digit tables (0..9 each -> 0..99999), filtered. +INSERT INTO wide_table ( + id, json_data, long_text, created_at, updated_at, birth_date, is_active, is_verified, amount, balance, score, big_value, attr_01, attr_02, attr_03, attr_04, attr_05, attr_06, attr_07, attr_08, attr_09, attr_10, attr_11, attr_12, attr_13, attr_14, attr_15, attr_16, attr_17, attr_18, attr_19, attr_20, attr_21, attr_22, attr_23, attr_24, attr_25, attr_26, attr_27, attr_28, attr_29, attr_30, attr_31, attr_32, attr_33, attr_34, attr_35, attr_36, attr_37, attr_38 +) +SELECT + g.g, + JSON_OBJECT('row', g.g, 'label', CONCAT('item-', g.g), 'tags', JSON_ARRAY('alpha','beta', g.g MOD 7)), + REPEAT(CONCAT('Lorem ipsum dolor row ', g.g, '. '), 6), + DATE_ADD('2000-01-01 00:00:00', INTERVAL g.g SECOND), + DATE_ADD('2010-01-01 00:00:00', INTERVAL (g.g*2) SECOND), + DATE_ADD('1970-01-01', INTERVAL (g.g MOD 18000) DAY), + (g.g MOD 2), + (g.g MOD 3 = 0), + ROUND((g.g MOD 100000) * 1.25, 2), + ROUND((g.g MOD 50000) * 0.337, 4), + (g.g * 0.5), + (g.g * 1000003), + CONCAT('attr1-', g.g), + ((g.g * 2) MOD 100000), + ROUND((g.g MOD 9999) * 3 * 0.1, 2), + CONCAT('attr4-', g.g), + ((g.g * 5) MOD 100000), + ROUND((g.g MOD 9999) * 6 * 0.1, 2), + CONCAT('attr7-', g.g), + ((g.g * 8) MOD 100000), + ROUND((g.g MOD 9999) * 9 * 0.1, 2), + CONCAT('attr10-', g.g), + ((g.g * 11) MOD 100000), + ROUND((g.g MOD 9999) * 12 * 0.1, 2), + CONCAT('attr13-', g.g), + ((g.g * 14) MOD 100000), + ROUND((g.g MOD 9999) * 15 * 0.1, 2), + CONCAT('attr16-', g.g), + ((g.g * 17) MOD 100000), + ROUND((g.g MOD 9999) * 18 * 0.1, 2), + CONCAT('attr19-', g.g), + ((g.g * 20) MOD 100000), + ROUND((g.g MOD 9999) * 21 * 0.1, 2), + CONCAT('attr22-', g.g), + ((g.g * 23) MOD 100000), + ROUND((g.g MOD 9999) * 24 * 0.1, 2), + CONCAT('attr25-', g.g), + ((g.g * 26) MOD 100000), + ROUND((g.g MOD 9999) * 27 * 0.1, 2), + CONCAT('attr28-', g.g), + ((g.g * 29) MOD 100000), + ROUND((g.g MOD 9999) * 30 * 0.1, 2), + CONCAT('attr31-', g.g), + ((g.g * 32) MOD 100000), + ROUND((g.g MOD 9999) * 33 * 0.1, 2), + CONCAT('attr34-', g.g), + ((g.g * 35) MOD 100000), + ROUND((g.g MOD 9999) * 36 * 0.1, 2), + CONCAT('attr37-', g.g), + ((g.g * 38) MOD 100000) +FROM ( + SELECT (d5.d*10000 + d4.d*1000 + d3.d*100 + d2.d*10 + d1.d) + 1 AS g + FROM (SELECT 0 d UNION ALL SELECT 1 UNION ALL SELECT 2 UNION ALL SELECT 3 UNION ALL SELECT 4 UNION ALL SELECT 5 UNION ALL SELECT 6 UNION ALL SELECT 7 UNION ALL SELECT 8 UNION ALL SELECT 9) d1 + CROSS JOIN (SELECT 0 d UNION ALL SELECT 1 UNION ALL SELECT 2 UNION ALL SELECT 3 UNION ALL SELECT 4 UNION ALL SELECT 5 UNION ALL SELECT 6 UNION ALL SELECT 7 UNION ALL SELECT 8 UNION ALL SELECT 9) d2 + CROSS JOIN (SELECT 0 d UNION ALL SELECT 1 UNION ALL SELECT 2 UNION ALL SELECT 3 UNION ALL SELECT 4 UNION ALL SELECT 5 UNION ALL SELECT 6 UNION ALL SELECT 7 UNION ALL SELECT 8 UNION ALL SELECT 9) d3 + CROSS JOIN (SELECT 0 d UNION ALL SELECT 1 UNION ALL SELECT 2 UNION ALL SELECT 3 UNION ALL SELECT 4 UNION ALL SELECT 5 UNION ALL SELECT 6 UNION ALL SELECT 7 UNION ALL SELECT 8 UNION ALL SELECT 9) d4 + CROSS JOIN (SELECT 0 d UNION ALL SELECT 1 UNION ALL SELECT 2 UNION ALL SELECT 3 UNION ALL SELECT 4 UNION ALL SELECT 5 UNION ALL SELECT 6 UNION ALL SELECT 7 UNION ALL SELECT 8 UNION ALL SELECT 9) d5 +) AS g +WHERE g.g <= 50000; diff --git a/demo/init/postgres/06-perf-wide.sql b/demo/init/postgres/06-perf-wide.sql new file mode 100644 index 00000000..421ab556 --- /dev/null +++ b/demo/init/postgres/06-perf-wide.sql @@ -0,0 +1,124 @@ +-- ============================================================= +-- Tabularis Demo — Perf / wide-table stress test (PostgreSQL 16) +-- Database: perf_demo +-- Table: wide_table -> 50 columns x 50,000 rows +-- Purpose: reproduce slow-scroll reports with many columns/rows so +-- DataGrid virtualization/memoization can be profiled. +-- NOTE: heavy dataset — first container boot takes a little longer. +-- Generated by demo/generate-perf-sql.py (edit there, then re-run). +-- ============================================================= + +CREATE DATABASE perf_demo; + +\connect perf_demo + +DROP TABLE IF EXISTS wide_table; + +CREATE TABLE wide_table ( + id BIGINT NOT NULL PRIMARY KEY, + json_data JSONB, + long_text TEXT, + created_at TIMESTAMP, + updated_at TIMESTAMP, + birth_date DATE, + is_active BOOLEAN, + is_verified BOOLEAN, + amount DECIMAL(12,2), + balance DECIMAL(14,4), + score DOUBLE PRECISION, + big_value BIGINT, + attr_01 VARCHAR(80), + attr_02 INTEGER, + attr_03 DECIMAL(12,2), + attr_04 VARCHAR(80), + attr_05 INTEGER, + attr_06 DECIMAL(12,2), + attr_07 VARCHAR(80), + attr_08 INTEGER, + attr_09 DECIMAL(12,2), + attr_10 VARCHAR(80), + attr_11 INTEGER, + attr_12 DECIMAL(12,2), + attr_13 VARCHAR(80), + attr_14 INTEGER, + attr_15 DECIMAL(12,2), + attr_16 VARCHAR(80), + attr_17 INTEGER, + attr_18 DECIMAL(12,2), + attr_19 VARCHAR(80), + attr_20 INTEGER, + attr_21 DECIMAL(12,2), + attr_22 VARCHAR(80), + attr_23 INTEGER, + attr_24 DECIMAL(12,2), + attr_25 VARCHAR(80), + attr_26 INTEGER, + attr_27 DECIMAL(12,2), + attr_28 VARCHAR(80), + attr_29 INTEGER, + attr_30 DECIMAL(12,2), + attr_31 VARCHAR(80), + attr_32 INTEGER, + attr_33 DECIMAL(12,2), + attr_34 VARCHAR(80), + attr_35 INTEGER, + attr_36 DECIMAL(12,2), + attr_37 VARCHAR(80), + attr_38 INTEGER +); + +INSERT INTO wide_table ( + id, json_data, long_text, created_at, updated_at, birth_date, is_active, is_verified, amount, balance, score, big_value, attr_01, attr_02, attr_03, attr_04, attr_05, attr_06, attr_07, attr_08, attr_09, attr_10, attr_11, attr_12, attr_13, attr_14, attr_15, attr_16, attr_17, attr_18, attr_19, attr_20, attr_21, attr_22, attr_23, attr_24, attr_25, attr_26, attr_27, attr_28, attr_29, attr_30, attr_31, attr_32, attr_33, attr_34, attr_35, attr_36, attr_37, attr_38 +) +SELECT + g, + jsonb_build_object('row', g, 'label', 'item-' || g, 'tags', jsonb_build_array('alpha','beta', g % 7)), + repeat('Lorem ipsum dolor row ' || g || '. ', 6), + TIMESTAMP '2000-01-01 00:00:00' + (g * INTERVAL '1 second'), + TIMESTAMP '2010-01-01 00:00:00' + ((g*2) * INTERVAL '1 second'), + DATE '1970-01-01' + (g % 18000), + (g % 2 = 0), + (g % 3 = 0), + ROUND(((g % 100000) * 1.25)::numeric, 2), + ROUND(((g % 50000) * 0.337)::numeric, 4), + (g * 0.5), + (g::bigint * 1000003), + 'attr1-' || g, + ((g * 2) % 100000), + ROUND(((g % 9999) * 3 * 0.1)::numeric, 2), + 'attr4-' || g, + ((g * 5) % 100000), + ROUND(((g % 9999) * 6 * 0.1)::numeric, 2), + 'attr7-' || g, + ((g * 8) % 100000), + ROUND(((g % 9999) * 9 * 0.1)::numeric, 2), + 'attr10-' || g, + ((g * 11) % 100000), + ROUND(((g % 9999) * 12 * 0.1)::numeric, 2), + 'attr13-' || g, + ((g * 14) % 100000), + ROUND(((g % 9999) * 15 * 0.1)::numeric, 2), + 'attr16-' || g, + ((g * 17) % 100000), + ROUND(((g % 9999) * 18 * 0.1)::numeric, 2), + 'attr19-' || g, + ((g * 20) % 100000), + ROUND(((g % 9999) * 21 * 0.1)::numeric, 2), + 'attr22-' || g, + ((g * 23) % 100000), + ROUND(((g % 9999) * 24 * 0.1)::numeric, 2), + 'attr25-' || g, + ((g * 26) % 100000), + ROUND(((g % 9999) * 27 * 0.1)::numeric, 2), + 'attr28-' || g, + ((g * 29) % 100000), + ROUND(((g % 9999) * 30 * 0.1)::numeric, 2), + 'attr31-' || g, + ((g * 32) % 100000), + ROUND(((g % 9999) * 33 * 0.1)::numeric, 2), + 'attr34-' || g, + ((g * 35) % 100000), + ROUND(((g % 9999) * 36 * 0.1)::numeric, 2), + 'attr37-' || g, + ((g * 38) % 100000) +FROM generate_series(1, 50000) AS g; diff --git a/src/components/ui/DataGrid.tsx b/src/components/ui/DataGrid.tsx index 43333d51..49dc0cef 100644 --- a/src/components/ui/DataGrid.tsx +++ b/src/components/ui/DataGrid.tsx @@ -41,16 +41,11 @@ import { getColumnSortState, calculateSelectionRange, toggleSetValue, - resolveInsertionCellDisplay, - resolveExistingCellDisplay, - getCellStateClass, type MergedRow, - type ColumnDisplayInfo, } from "../../utils/dataGrid"; import { isGeometricType, formatGeometricValue } from "../../utils/geometry"; import { isBlobColumn, isBlobWireFormat } from "../../utils/blob"; import { isJsonColumn, isJsonContent } from "../../utils/json"; -import { isLongTextCellTarget, truncateCellPreview } from "../../utils/text"; import { pickPrimaryForeignKeyByColumn, getForeignKeyForPreview, @@ -60,13 +55,7 @@ import { parseDateTime, formatDateTime, } from "../../utils/dateInput"; -import { GeometryInput } from "./GeometryInput"; -import { DateInput } from "./DateInput"; import { RowEditorSidebar } from "./RowEditorSidebar"; -import { JsonCell } from "./JsonCell"; -import { JsonExpansionEditor } from "./JsonExpansionEditor"; -import { TextCell } from "./TextCell"; -import { TextExpansionEditor } from "./TextExpansionEditor"; import { useDatabase } from "../../hooks/useDatabase"; import { rowsToCSV, @@ -80,6 +69,7 @@ import type { TableColumn, ForeignKey, } from "../../types/editor"; +import { MemoRow, type RowCtx } from "./DataGridRow"; interface DataGridProps { columns: string[]; @@ -219,6 +209,13 @@ export const DataGrid = React.memo( colIndex: number; } | null>(null); const editInputRef = useRef(null); + // Mirror of editingCell so the commit/keydown callbacks can read the latest + // value without listing editingCell in their deps — keeps their identity + // stable so the memoized rows don't re-render on every keystroke/scroll. + const editingCellRef = useRef(editingCell); + useEffect(() => { + editingCellRef.current = editingCell; + }, [editingCell]); const pendingJsonSessions = useRef< Map >(new Map()); @@ -442,11 +439,24 @@ export const DataGrid = React.memo( } }, [editingCell]); - const handleCellDoubleClick = ( - rowIndex: number, - colIndex: number, - value: unknown, - ) => { + const buildRowDataWithPending = useCallback( + (rowArray: unknown[], isInsertion: boolean): Record => { + const rowData: Record = {}; + columns.forEach((col, idx) => { + rowData[col] = rowArray[idx]; + }); + if (!isInsertion && pkIndexMap !== null) { + const pkVal = rowArray[pkIndexMap]; + const pending = pendingChanges?.[String(pkVal)]?.changes; + if (pending) Object.assign(rowData, pending); + } + return rowData; + }, + [columns, pkIndexMap, pendingChanges], + ); + + const handleCellDoubleClick = useCallback( + (rowIndex: number, colIndex: number, value: unknown) => { if (!tableName || readonlyProp) return; const mergedRow = mergedRows[rowIndex]; @@ -499,13 +509,26 @@ export const DataGrid = React.memo( } setEditingCell({ rowIndex, colIndex, value: editValue }); - }; + }, + [ + tableName, + readonlyProp, + mergedRows, + pkColumn, + columns, + columnTypeMap, + columnLengthMap, + buildRowDataWithPending, + openJsonViewerWindow, + ], + ); const isCommittingRef = useRef(false); - const handleEditCommit = async () => { + const handleEditCommit = useCallback(async () => { // Prevent multiple concurrent commits (e.g., from rapid blur events) if (isCommittingRef.current) return; + const editingCell = editingCellRef.current; if (!editingCell || !tableName) { setEditingCell(null); return; @@ -596,9 +619,23 @@ export const DataGrid = React.memo( } finally { isCommittingRef.current = false; } - }; + }, [ + tableName, + mergedRows, + columns, + onPendingInsertionChange, + onPendingChange, + pkIndexMap, + pkColumn, + connectionId, + activeSchema, + onRefresh, + showAlert, + t, + ]); - const handleKeyDown = (e: React.KeyboardEvent) => { + const handleKeyDown = useCallback((e: React.KeyboardEvent) => { + const editingCell = editingCellRef.current; if (e.key === "Enter") { handleEditCommit(); } else if (e.key === "Escape") { @@ -645,7 +682,7 @@ export const DataGrid = React.memo( }, 0); } } - }; + }, [handleEditCommit, mergedRows, columns]); const columnHelper = useMemo(() => createColumnHelper(), []); @@ -916,22 +953,6 @@ export const DataGrid = React.memo( setContextMenu(null); }, [contextMenu, columns, pendingInsertions, onDuplicateRow]); - const buildRowDataWithPending = useCallback( - (rowArray: unknown[], isInsertion: boolean): Record => { - const rowData: Record = {}; - columns.forEach((col, idx) => { - rowData[col] = rowArray[idx]; - }); - if (!isInsertion && pkIndexMap !== null) { - const pkVal = rowArray[pkIndexMap]; - const pending = pendingChanges?.[String(pkVal)]?.changes; - if (pending) Object.assign(rowData, pending); - } - return rowData; - }, - [columns, pkIndexMap, pendingChanges], - ); - const openSidebarEditor = useCallback(() => { if (!contextMenu) return; const isInsertion = contextMenu.mergedRow?.type === "insertion"; @@ -1135,6 +1156,83 @@ export const DataGrid = React.memo( return () => document.removeEventListener("keydown", handleKeyDown); }, [editingCell, selectedRowIndices, focusedCell, copyCellValue, copySelectedCells, readonlyProp, deleteRowsByIndices]); + // Stable per-row dependency bundle. Memoizing it lets React.memo on MemoRow + // skip re-rendering rows that didn't change during scroll. + const rowCtx: RowCtx = useMemo( + () => ({ + columns, + autoIncrementColumns, + defaultValueColumns, + nullableColumns, + pkColumn, + pendingChanges, + columnTypeMap, + columnLengthMap, + isJsonCellTarget, + fksByColumn, + t, + mergedRows, + pkIndexMap, + parentViewportWidth, + readonly: readonlyProp, + updateSelection, + setFocusedCell, + setExpandedCell, + setEditingCell, + setSidebarRowData, + setSidebarOpen, + handleRowClick, + handleCellDoubleClick, + handleContextMenu, + handleEditCommit, + handleKeyDown, + onForeignKeyShowPanel, + onForeignKeyHidePanel, + onForeignKeyNavigate, + onPendingChange, + onPendingInsertionChange, + openJsonViewerWindow, + buildRowDataWithPending, + editInputRef, + }), + [ + columns, + autoIncrementColumns, + defaultValueColumns, + nullableColumns, + pkColumn, + pendingChanges, + columnTypeMap, + columnLengthMap, + isJsonCellTarget, + fksByColumn, + t, + mergedRows, + pkIndexMap, + parentViewportWidth, + readonlyProp, + updateSelection, + setFocusedCell, + setExpandedCell, + setEditingCell, + setSidebarRowData, + setSidebarOpen, + handleRowClick, + handleCellDoubleClick, + handleContextMenu, + handleEditCommit, + handleKeyDown, + onForeignKeyShowPanel, + onForeignKeyHidePanel, + onForeignKeyNavigate, + onPendingChange, + onPendingInsertionChange, + openJsonViewerWindow, + buildRowDataWithPending, + editInputRef, + ], + ); + // Show "no data" if there are no columns (even with pending insertions, we can't render without column info) // OR if there are columns but no data and no pending insertions if (columns.length === 0) { @@ -1201,563 +1299,40 @@ export const DataGrid = React.memo( {rowVirtualizer.getVirtualItems().map((virtualRow) => { - const row = tableRows[virtualRow.index]; const rowIndex = virtualRow.index; + const row = tableRows[rowIndex]; + const rowOriginal = row.original as unknown[]; const isSelected = selectedRowIndices.has(rowIndex); - - // Check if this is an insertion row const mergedRow = mergedRows[rowIndex]; const isInsertion = mergedRow?.type === "insertion"; - - // Get PK for pending check (using pre-calculated pkIndexMap) const pkVal = pkIndexMap !== null - ? String(row.original[pkIndexMap]) + ? String(rowOriginal[pkIndexMap]) : null; const isPendingDelete = !isInsertion && pkVal ? pendingDeletions?.[pkVal] !== undefined : false; - const expansionMatchesRow = - expandedCell?.rowIndex === rowIndex; - + const isRowEditing = editingCell?.rowIndex === rowIndex; + const isRowFocused = focusedCell?.rowIndex === rowIndex; + const isRowExpanded = expandedCell?.rowIndex === rowIndex; return ( - - - { - setFocusedCell(null); - onForeignKeyHidePanel?.(); - handleRowClick(rowIndex, e); - }} - className={`px-2 py-1.5 text-xs text-center border-b border-r border-default sticky left-0 z-10 cursor-pointer select-none w-[50px] min-w-[50px] ${ - isInsertion - ? isSelected - ? "bg-blue-900/40 text-blue-200 font-bold" - : "bg-green-950/30 text-green-300 font-bold" - : isPendingDelete - ? "bg-red-950/50 text-red-500 line-through" - : isSelected - ? "bg-blue-900/40 text-blue-200 font-bold" - : "bg-base text-muted hover:bg-surface-secondary" - }`} - > - {isInsertion ? "NEW" : rowIndex + 1} - - {row.getVisibleCells().map((cell, colIndex) => { - const isEditing = - editingCell?.rowIndex === rowIndex && - editingCell?.colIndex === colIndex; - - const colName = cell.column.id; - - const columnInfo: ColumnDisplayInfo = { - colName, - autoIncrementColumns, - defaultValueColumns, - nullableColumns, - }; - - const resolved = isInsertion - ? resolveInsertionCellDisplay( - cell.getValue(), - columnInfo, - ) - : resolveExistingCellDisplay( - cell.getValue(), - pkVal, - pkColumn, - pendingChanges, - columnInfo, - ); - - const { - displayValue, - hasPendingChange, - isModified, - isAutoIncrementPlaceholder, - isDefaultValuePlaceholder, - } = resolved; - - const colTypeForCell = columnTypeMap?.get(colName); - const rawCellValue = cell.getValue(); - const isJsonCell = - isJsonCellTarget(colTypeForCell, rawCellValue) && - !isPendingDelete; - const isLongTextCell = - !isJsonCell && - !isPendingDelete && - isLongTextCellTarget( - colTypeForCell, - hasPendingChange ? displayValue : rawCellValue, - ); - - const stateClass = getCellStateClass({ - isPendingDelete, - isSelected, - isInsertion, - isAutoIncrementPlaceholder, - isDefaultValuePlaceholder, - isModified, - isJsonCell, - }); - - const isFocused = - focusedCell?.rowIndex === rowIndex && - focusedCell?.colIndex === colIndex; - - const fkForPreview = getForeignKeyForPreview( - colName, - rawCellValue, - fksByColumn, - { isPendingDelete, isInsertion }, - ); - - return ( - { - // Don't handle row click if clicking on a button - const target = e.target as HTMLElement; - if (target.closest("button")) { - return; - } - setFocusedCell({ rowIndex, colIndex }); - updateSelection(new Set()); - - if (fkForPreview && onForeignKeyShowPanel) { - onForeignKeyShowPanel( - fkForPreview, - rawCellValue, - ); - } else { - onForeignKeyHidePanel?.(); - } - }} - onDoubleClick={() => - !isPendingDelete && - handleCellDoubleClick( - rowIndex, - colIndex, - isAutoIncrementPlaceholder || - isDefaultValuePlaceholder - ? "" - : displayValue, - ) - } - onContextMenu={(e) => - handleContextMenu( - e, - row.original, - rowIndex, - colIndex, - colName, - ) - } - className={`px-4 py-1.5 text-sm border-b border-r border-default last:border-r-0 font-mono ${isEditing ? "relative" : "whitespace-nowrap truncate max-w-[300px]"} ${fkForPreview ? "cursor-pointer" : "cursor-text"} ${stateClass} ${isFocused ? "ring-2 ring-inset ring-blue-400" : ""}`} - title={ - !isEditing - ? truncateCellPreview( - formatCellValue( - displayValue, - t("dataGrid.null"), - colTypeForCell, - columnLengthMap?.get(colName), - ), - ).text - : "" - } - > - {isEditing - ? (() => { - const colType = columnTypeMap?.get(colName); - if (colType && isGeometricType(colType)) { - return ( - - setEditingCell((prev) => - prev - ? { - ...prev, - value: newValue, - isRawSql, - } - : null, - ) - } - onBlur={handleEditCommit} - onKeyDown={handleKeyDown} - onSqlFunctionsClick={() => { - // Close inline editing - setEditingCell(null); - - // Open sidebar with the current row - const mergedRow = - mergedRows[rowIndex]; - if (mergedRow) { - setSidebarRowData({ - data: buildRowDataWithPending( - mergedRow.rowData, - mergedRow.type === "insertion", - ), - rowIndex: rowIndex, - focusField: colName, - }); - setSidebarOpen(true); - } - }} - className="w-full bg-base text-primary border-none outline-none p-0 m-0 font-mono" - /> - ); - } - const dateMode = colType - ? getDateInputMode(colType) - : null; - if (dateMode) { - return ( - - setEditingCell((prev) => - prev - ? { ...prev, value: newValue } - : null, - ) - } - onBlur={handleEditCommit} - onKeyDown={handleKeyDown} - inputRef={editInputRef} - /> - ); - } - const textValue = String( - editingCell.value ?? "", - ); - // Measure the longest line to size the textarea - const lines = textValue.split("\n"); - const canvas = - document.createElement("canvas"); - const ctx = canvas.getContext("2d"); - if (ctx) { - ctx.font = - "14px ui-monospace, SFMono-Regular, monospace"; - } - const longestLineWidth = ctx - ? Math.max( - ...lines.map( - (line) => ctx.measureText(line).width, - ), - ) - : 200; - // padding (p-2 = 8px * 2) + small buffer - const textareaWidth = - Math.ceil(longestLineWidth) + 32; - - return ( - <> - {/* Invisible placeholder to preserve td width */} - - {String(displayValue)} - -