From a47ea614812b04f724919c53842801342b0fe930 Mon Sep 17 00:00:00 2001 From: Lucas Machado Date: Wed, 27 May 2026 12:22:49 +0200 Subject: [PATCH 1/2] feat: add per-table row volumes --- README.md | 4 +- docs/commands.md | 8 +- internal/faker/faker.go | 22 +++- internal/faker/faker_test.go | 102 ++++++++++++++++++ internal/tui/execute.go | 4 +- internal/tui/execute_test.go | 34 ++++++ internal/tui/gaps.go | 45 ++++++-- internal/tui/gaps_test.go | 36 ++++--- internal/tui/generate.go | 37 ++++++- internal/tui/generate_test.go | 16 +-- internal/tui/review.go | 53 ++++++---- internal/tui/table_rows.go | 179 ++++++++++++++++++++++++++++++++ internal/tui/table_rows_test.go | 62 +++++++++++ internal/tui/tui.go | 41 +++++++- internal/tui/wizard_test.go | 61 +++++++---- internal/web/runners.go | 57 ++++++---- internal/web/runners_test.go | 115 ++++++++++++++++++++ internal/web/static/app.js | 100 ++++++++++++++++-- internal/web/static/style.css | 33 +++++- 19 files changed, 889 insertions(+), 120 deletions(-) create mode 100644 internal/tui/table_rows.go create mode 100644 internal/tui/table_rows_test.go diff --git a/README.md b/README.md index 2c89a58..6da69b9 100644 --- a/README.md +++ b/README.md @@ -73,8 +73,8 @@ seedstorm gaps \ - **Enum coverage** — every enum value appears at least `--rows` times, independently per column - **AI enrichment** — Gemini rewrites faker hints for domain-meaningful data; supply `--prompt` for richer context - **Gap analysis** — `gaps` shows which tables are empty with row counts and FK context; `--fill` seeds only the empty ones -- **Interactive TUI** — wizard for table selection, config, and review before seeding -- **Web UI** — `seedstorm serve` exposes an interactive graph workspace with click-to-select tables, live SSE job logs, multi-DB session switcher, and connection presets in `localStorage` +- **Interactive TUI** — wizard for table selection, global config, per-table row volumes, and review before seeding +- **Web UI** — `seedstorm serve` exposes an interactive graph workspace with click-to-select tables, per-table row overrides, live SSE job logs, multi-DB session switcher, and connection presets in `localStorage` - **Dry-run** — preview the seed plan and INSERT SQL without touching the database - **Export** — generate fake data as YAML, JSON, or SQL without a live connection diff --git a/docs/commands.md b/docs/commands.md index 49180e2..e936ad5 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -116,6 +116,8 @@ seedstorm seed \ --interactive ``` +The interactive TUI includes a **Volumes** step after global config. Each selected table starts with the `--rows` value, and you can override individual tables before review, dry-run, or execution. + seed interactive TUI demo | Flag | Default | Description | @@ -168,6 +170,8 @@ seedstorm gaps \ --interactive ``` +Interactive gap fill also includes the **Volumes** step, so empty child tables can receive higher or lower row counts than their auto-required parents. + Sample output (gap analysis report): ``` @@ -214,6 +218,8 @@ seedstorm generate --schema schema.yaml --rows 20 --format yaml seedstorm generate --schema schema.yaml --interactive ``` +In interactive mode, the **Volumes** step can override row counts per selected table while `--rows` remains the default. + generate demo | Flag | Default | Description | @@ -253,7 +259,7 @@ SEEDSTORM_ADDR=127.0.0.1:9000 seedstorm serve What the UI gives you: -- **Workspace** — Cytoscape DAG of every table; click to select, non-nullable parents auto-lock as a dependency closure (mirrors the TUI). Three run modes: **Seed**, **Fill empty**, **Generate (no DB write)**. Live SSE log stream + status pill. +- **Workspace** — Cytoscape DAG of every table; click to select, non-nullable parents auto-lock as a dependency closure (mirrors the TUI). The selected-table panel lets you override row counts per table for **Seed**, **Fill empty**, and workspace **Generate** runs while `Rows` remains the default. Live SSE log stream + status pill. - **Connection management** — multi-session: hold several DBs open in one browser and switch from a topbar dropdown. Saved connection presets in `localStorage` with optional password (eye-icon reveal, closed by default). Passwords are kept in process memory only on the server. - **Standalone tools** — `/generate`, `/enrich`, `/export` mirror the CLI commands as forms. diff --git a/internal/faker/faker.go b/internal/faker/faker.go index 0a1daf2..82726a4 100644 --- a/internal/faker/faker.go +++ b/internal/faker/faker.go @@ -32,6 +32,12 @@ func Generate(s *schema.Schema, sortedTables []string, rows, enumRows int, conn // Use this when you only want to seed a subset of tables (e.g. empty ones) // while still being able to resolve FK references to already-populated parents. func GenerateFiltered(s *schema.Schema, allTables, targetTables []string, rows, enumRows int, conn *sql.DB, dbType string) (map[string][]map[string]interface{}, error) { + return GenerateFilteredWithCounts(s, allTables, targetTables, rows, enumRows, nil, conn, dbType) +} + +// GenerateFilteredWithCounts is like GenerateFiltered, but tableRows can +// override the default row count for individual target tables. +func GenerateFilteredWithCounts(s *schema.Schema, allTables, targetTables []string, rows, enumRows int, tableRows map[string]int, conn *sql.DB, dbType string) (map[string][]map[string]interface{}, error) { data := make(map[string][]map[string]interface{}) generatedPKs := make(map[string][]interface{}) @@ -46,21 +52,27 @@ func GenerateFiltered(s *schema.Schema, allTables, targetTables []string, rows, for _, tableName := range sortedTables { table := s.Tables[tableName] data[tableName] = nil + tableRowCount := rows + _, hasRowOverride := tableRows[tableName] + if override := tableRows[tableName]; override > 0 { + tableRowCount = override + } enumCol, enumVals := findEnumColumn(table) - if enumCol != "" && enumRows > 0 { + if enumCol != "" && enumRows > 0 && !hasRowOverride { if err := generateEnumRows(data, generatedPKs, table, tableName, enumCol, enumVals, enumRows); err != nil { return nil, fmt.Errorf("table %s: %w", tableName, err) } } else { - if err := generateStandardRows(data, generatedPKs, table, tableName, rows); err != nil { + if err := generateStandardRows(data, generatedPKs, table, tableName, tableRowCount); err != nil { return nil, fmt.Errorf("table %s: %w", tableName, err) } - // Guarantee every enum value appears at least `rows` times, independently per column. + // Guarantee every enum value appears at least the requested table + // row count, independently per column. enumCols := findAllEnumColumns(table) - if len(enumCols) > 0 { - if err := topUpEnumCoverage(data, generatedPKs, table, tableName, enumCols, rows); err != nil { + if len(enumCols) > 0 && !hasRowOverride { + if err := topUpEnumCoverage(data, generatedPKs, table, tableName, enumCols, tableRowCount); err != nil { return nil, fmt.Errorf("table %s enum top-up: %w", tableName, err) } } diff --git a/internal/faker/faker_test.go b/internal/faker/faker_test.go index 27280fb..c0d73b5 100644 --- a/internal/faker/faker_test.go +++ b/internal/faker/faker_test.go @@ -732,6 +732,108 @@ func TestGenerate_reproducibleWithSeed(t *testing.T) { } } +func TestGenerateFilteredWithCountsUsesGlobalRowsByDefault(t *testing.T) { + s := &schema.Schema{ + Tables: map[string]schema.Table{ + "users": { + Columns: map[string]schema.Column{ + "id": {Type: "integer", PK: true}, + "name": {Type: "varchar", Faker: "name"}, + }, + }, + "orders": { + Columns: map[string]schema.Column{ + "id": {Type: "integer", PK: true}, + "user_id": {Type: "integer", FK: "users.id"}, + }, + }, + }, + } + + data, err := GenerateFilteredWithCounts(s, []string{"users", "orders"}, []string{"users", "orders"}, 3, 0, nil, nil, "pgx") + if err != nil { + t.Fatalf("GenerateFilteredWithCounts: %v", err) + } + if got := len(data["users"]); got != 3 { + t.Fatalf("users rows = %d, want 3", got) + } + if got := len(data["orders"]); got != 3 { + t.Fatalf("orders rows = %d, want 3", got) + } +} + +func TestGenerateFilteredWithCountsOverridesIndividualTables(t *testing.T) { + s := &schema.Schema{ + Tables: map[string]schema.Table{ + "users": { + Columns: map[string]schema.Column{ + "id": {Type: "integer", PK: true}, + "name": {Type: "varchar", Faker: "name"}, + }, + }, + "orders": { + Columns: map[string]schema.Column{ + "id": {Type: "integer", PK: true}, + "user_id": {Type: "integer", FK: "users.id"}, + }, + }, + }, + } + + data, err := GenerateFilteredWithCounts(s, []string{"users", "orders"}, []string{"users", "orders"}, 2, 0, map[string]int{ + "orders": 5, + }, nil, "pgx") + if err != nil { + t.Fatalf("GenerateFilteredWithCounts: %v", err) + } + if got := len(data["users"]); got != 2 { + t.Fatalf("users rows = %d, want 2", got) + } + if got := len(data["orders"]); got != 5 { + t.Fatalf("orders rows = %d, want 5", got) + } +} + +func TestGenerateFilteredWithCountsUsesOverrideAsExactEnumTableVolume(t *testing.T) { + s := &schema.Schema{ + Tables: map[string]schema.Table{ + "tickets": makeEnumTable(map[string][]string{ + "status": {"open", "closed"}, + }), + }, + } + + data, err := GenerateFilteredWithCounts(s, []string{"tickets"}, []string{"tickets"}, 1, 0, map[string]int{ + "tickets": 3, + }, nil, "pgx") + if err != nil { + t.Fatalf("GenerateFilteredWithCounts: %v", err) + } + if got := len(data["tickets"]); got != 3 { + t.Fatalf("tickets rows = %d, want exact table override 3", got) + } +} + +func TestGenerateFilteredWithCountsOverrideWinsOverEnumRows(t *testing.T) { + s := &schema.Schema{ + Tables: map[string]schema.Table{ + "tickets": makeEnumTable(map[string][]string{ + "status": {"open", "closed"}, + }), + }, + } + + data, err := GenerateFilteredWithCounts(s, []string{"tickets"}, []string{"tickets"}, 1, 2, map[string]int{ + "tickets": 7, + }, nil, "pgx") + if err != nil { + t.Fatalf("GenerateFilteredWithCounts: %v", err) + } + if got := len(data["tickets"]); got != 7 { + t.Fatalf("tickets rows = %d, want table override 7 instead of enumRows total 4", got) + } +} + func TestGenerate_differentSeedsDifferentOutput(t *testing.T) { s := &schema.Schema{ Tables: map[string]schema.Table{ diff --git a/internal/tui/execute.go b/internal/tui/execute.go index 4b85085..7a55630 100644 --- a/internal/tui/execute.go +++ b/internal/tui/execute.go @@ -282,7 +282,7 @@ func startSeed(ctx context.Context, s *seedParams) tea.Cmd { } } - data, err := faker.Generate(s.schema, s.tables, s.rows, s.enumRows, conn, s.dbType) + data, err := faker.GenerateFilteredWithCounts(s.schema, s.tables, s.tables, s.rows, s.enumRows, s.tableRows, conn, s.dbType) if err != nil { return seedDoneMsg{err: fmt.Errorf("data generation failed: %w", err)} } @@ -317,7 +317,7 @@ func startSeed(ctx context.Context, s *seedParams) tea.Cmd { // startDryRun returns a tea.Cmd that generates data and builds a summary. func startDryRun(s *seedParams) tea.Cmd { return func() tea.Msg { - data, err := faker.Generate(s.schema, s.tables, s.rows, s.enumRows, nil, s.dbType) + data, err := faker.GenerateFilteredWithCounts(s.schema, s.tables, s.tables, s.rows, s.enumRows, s.tableRows, nil, s.dbType) if err != nil { return dryRunDoneMsg{err: fmt.Errorf("data generation failed: %w", err)} } diff --git a/internal/tui/execute_test.go b/internal/tui/execute_test.go index d30eb20..3b6fb84 100644 --- a/internal/tui/execute_test.go +++ b/internal/tui/execute_test.go @@ -452,3 +452,37 @@ func TestStartDryRun_multipleTables(t *testing.T) { t.Errorf("total = %d, want 6", dm.total) } } + +func TestStartDryRun_usesPerTableRowOverrides(t *testing.T) { + s := makeSchema(map[string]map[string]schema.Column{ + "users": { + "id": {Type: "integer", PK: true}, + }, + "orders": { + "id": {Type: "integer", PK: true}, + "user_id": {Type: "integer", FK: "users.id"}, + }, + }) + params := &seedParams{ + schema: s, + tables: []string{"users", "orders"}, + rows: 2, + tableRows: map[string]int{"orders": 5}, + dbType: "pgx", + } + msg := startDryRun(params)() + dm := msg.(dryRunDoneMsg) + if dm.err != nil { + t.Fatalf("unexpected error: %v", dm.err) + } + if dm.total != 7 { + t.Fatalf("total = %d, want 7", dm.total) + } + got := map[string]int{} + for _, table := range dm.tables { + got[table.name] = table.rows + } + if got["users"] != 2 || got["orders"] != 5 { + t.Fatalf("rows by table = %+v, want users=2 orders=5", got) + } +} diff --git a/internal/tui/gaps.go b/internal/tui/gaps.go index 437008c..5d5ac16 100644 --- a/internal/tui/gaps.go +++ b/internal/tui/gaps.go @@ -22,6 +22,7 @@ type gapsStep int const ( gapsStepPicker gapsStep = iota gapsStepConfig + gapsStepRows gapsStepReview gapsStepExecute ) @@ -39,6 +40,7 @@ type GapsModel struct { picker tablePickerModel config configModel + volumes tableRowsModel review reviewModel execute executeModel @@ -125,6 +127,7 @@ func (m GapsModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.width = msg.Width m.height = msg.Height m.picker.height = msg.Height + m.volumes.height = msg.Height case tea.KeyMsg: if msg.String() == "ctrl+c" { m.quitting = true @@ -137,6 +140,8 @@ func (m GapsModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m.updatePicker(msg) case gapsStepConfig: return m.updateConfig(msg) + case gapsStepRows: + return m.updateRows(msg) case gapsStepReview: return m.updateReview(msg) case gapsStepExecute: @@ -202,12 +207,33 @@ func (m GapsModel) updateConfig(msg tea.Msg) (tea.Model, tea.Cmd) { } resolved, _ := ResolveDeps(m.graph, cleanSelected, m.sortedAll) + m.volumes = newTableRows(resolved, m.config.Rows(), nil, m.height) + m.step = gapsStepRows + } + return m, cmd +} + +func (m GapsModel) updateRows(msg tea.Msg) (tea.Model, tea.Cmd) { + var cmd tea.Cmd + m.volumes, cmd = m.volumes.Update(msg) + + if m.volumes.quitting { + m.quitting = true + return m, tea.Quit + } + if m.volumes.back { + m.volumes.back = false + m.config.done = false + m.step = gapsStepConfig + return m, nil + } + if m.volumes.done { parents := make(map[string][]string) - for _, t := range resolved { + for _, t := range m.volumes.tables { parents[t] = m.graph.Parents(t) } - m.review = newReview(resolved, parents, - m.config.Rows(), m.config.EnumRows(), m.config.BatchSize(), false) + m.review = newReview(m.volumes.tables, parents, + m.config.Rows(), m.config.EnumRows(), m.config.BatchSize(), false, m.volumes.TableRows()) m.step = gapsStepReview } return m, cmd @@ -223,8 +249,8 @@ func (m GapsModel) updateReview(msg tea.Msg) (tea.Model, tea.Cmd) { } if m.review.back { m.review.back = false - m.config.done = false - m.step = gapsStepConfig + m.volumes.done = false + m.step = gapsStepRows return m, nil } if m.review.done { @@ -233,6 +259,7 @@ func (m GapsModel) updateReview(msg tea.Msg) (tea.Model, tea.Cmd) { tables: m.review.tables, rows: m.review.rows, enumRows: m.review.enumRows, + tableRows: m.review.tableRows, batchSize: m.review.batch, truncate: false, // gaps never truncates dbType: m.dbType, @@ -261,7 +288,7 @@ func (m GapsModel) updateExecute(msg tea.Msg) (tea.Model, tea.Cmd) { func (m GapsModel) View() string { var sb strings.Builder - steps := []string{"Tables", "Config", "Review", "Execute"} + steps := []string{"Tables", "Config", "Volumes", "Review", "Execute"} var breadcrumbs []string for i, name := range steps { if gapsStep(i) == m.step { @@ -282,6 +309,8 @@ func (m GapsModel) View() string { sb.WriteString(m.picker.View()) case gapsStepConfig: sb.WriteString(m.config.View()) + case gapsStepRows: + sb.WriteString(m.volumes.View()) case gapsStepReview: sb.WriteString(m.review.View()) case gapsStepExecute: @@ -310,7 +339,7 @@ func startGapsFill(ctx context.Context, s *seedParams, allSorted []string) tea.C return seedDoneMsg{err: fmt.Errorf("failed to ping database: %w", err)} } - data, err := faker.GenerateFiltered(s.schema, allSorted, s.tables, s.rows, s.enumRows, conn, s.dbType) + data, err := faker.GenerateFilteredWithCounts(s.schema, allSorted, s.tables, s.rows, s.enumRows, s.tableRows, conn, s.dbType) if err != nil { return seedDoneMsg{err: fmt.Errorf("data generation failed: %w", err)} } @@ -345,7 +374,7 @@ func startGapsFill(ctx context.Context, s *seedParams, allSorted []string) tea.C // startGapsDryRun generates data for gap tables and returns a preview. func startGapsDryRun(s *seedParams, allSorted []string) tea.Cmd { return func() tea.Msg { - data, err := faker.GenerateFiltered(s.schema, allSorted, s.tables, s.rows, s.enumRows, nil, s.dbType) + data, err := faker.GenerateFilteredWithCounts(s.schema, allSorted, s.tables, s.rows, s.enumRows, s.tableRows, nil, s.dbType) if err != nil { return dryRunDoneMsg{err: fmt.Errorf("data generation failed: %w", err)} } diff --git a/internal/tui/gaps_test.go b/internal/tui/gaps_test.go index cf91a1d..95fcae5 100644 --- a/internal/tui/gaps_test.go +++ b/internal/tui/gaps_test.go @@ -67,12 +67,16 @@ func TestGaps_pickerToConfig(t *testing.T) { } } -func TestGaps_configToReview(t *testing.T) { +func TestGaps_configToVolumesToReview(t *testing.T) { m := buildGapsModel() m2 := sendGapsKey(m, "enter") // → config - m3 := sendGapsKey(m2, "enter") // → review - if getGaps(m3).step != gapsStepReview { - t.Fatalf("enter should advance to review, got step %d", getGaps(m3).step) + m3 := sendGapsKey(m2, "enter") // → volumes + if getGaps(m3).step != gapsStepRows { + t.Fatalf("enter should advance to volumes, got step %d", getGaps(m3).step) + } + m4 := sendGapsKey(m3, "enter") // → review + if getGaps(m4).step != gapsStepReview { + t.Fatalf("enter should advance to review, got step %d", getGaps(m4).step) } } @@ -89,9 +93,14 @@ func TestGaps_backFromReviewToConfig(t *testing.T) { m := buildGapsModel() m2 := sendGapsKey(m, "enter") m3 := sendGapsKey(m2, "enter") - m4 := sendGapsKey(m3, "b") - if getGaps(m4).step != gapsStepConfig { - t.Fatal("b should go back to config") + m4 := sendGapsKey(m3, "enter") + m5 := sendGapsKey(m4, "b") + if getGaps(m5).step != gapsStepRows { + t.Fatal("b from review should go back to volumes") + } + m6 := sendGapsKey(m5, "b") + if getGaps(m6).step != gapsStepConfig { + t.Fatal("b from volumes should go back to config") } } @@ -116,8 +125,9 @@ func TestGaps_quitFromReview(t *testing.T) { m := buildGapsModel() m2 := sendGapsKey(m, "enter") m3 := sendGapsKey(m2, "enter") - m4 := sendGapsKey(m3, "q") - if !getGaps(m4).quitting { + m4 := sendGapsKey(m3, "enter") + m5 := sendGapsKey(m4, "q") + if !getGaps(m5).quitting { t.Error("q should set quitting") } } @@ -126,8 +136,9 @@ func TestGaps_dryRunFromReview(t *testing.T) { m := buildGapsModel() m2 := sendGapsKey(m, "enter") m3 := sendGapsKey(m2, "enter") - m4 := sendGapsKey(m3, "d") - gm := getGaps(m4) + m4 := sendGapsKey(m3, "enter") + m5 := sendGapsKey(m4, "d") + gm := getGaps(m5) if gm.step != gapsStepExecute { t.Fatalf("d should advance to execute, got step %d", gm.step) } @@ -140,7 +151,8 @@ func TestGaps_reviewNeverShowsTruncate(t *testing.T) { m := buildGapsModel() m2 := sendGapsKey(m, "enter") m3 := sendGapsKey(m2, "enter") - gm := getGaps(m3) + m4 := sendGapsKey(m3, "enter") + gm := getGaps(m4) if gm.review.truncate { t.Error("gaps should never set truncate") } diff --git a/internal/tui/generate.go b/internal/tui/generate.go index b61d30c..3b69fbb 100644 --- a/internal/tui/generate.go +++ b/internal/tui/generate.go @@ -25,6 +25,7 @@ type genStep int const ( genStepPicker genStep = iota genStepConfig + genStepRows genStepExecute ) @@ -213,6 +214,7 @@ type GenModel struct { picker tablePickerModel genConfig genConfigModel + volumes tableRowsModel execute executeModel quitting bool @@ -271,6 +273,7 @@ func (m GenModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.width = msg.Width m.height = msg.Height m.picker.height = msg.Height + m.volumes.height = msg.Height case tea.KeyMsg: if msg.String() == "ctrl+c" { m.quitting = true @@ -283,6 +286,8 @@ func (m GenModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m.updatePicker(msg) case genStepConfig: return m.updateGenConfig(msg) + case genStepRows: + return m.updateRows(msg) case genStepExecute: return m.updateExecute(msg) } @@ -337,12 +342,32 @@ func (m GenModel) updateGenConfig(msg tea.Msg) (tea.Model, tea.Cmd) { tables = append(tables, t) } } + m.volumes = newTableRows(tables, m.genConfig.Rows(), nil, m.height) + m.step = genStepRows + } + return m, cmd +} - m.execute = newExecute(len(tables), false) +func (m GenModel) updateRows(msg tea.Msg) (tea.Model, tea.Cmd) { + var cmd tea.Cmd + m.volumes, cmd = m.volumes.Update(msg) + + if m.volumes.quitting { + m.quitting = true + return m, tea.Quit + } + if m.volumes.back { + m.volumes.back = false + m.genConfig.done = false + m.step = genStepConfig + return m, nil + } + if m.volumes.done { + m.execute = newExecute(len(m.volumes.tables), false) m.execute.dryRun = true // generate is always a "dry run" (no DB) m.step = genStepExecute - return m, tea.Batch(m.execute.spinner.Tick, startGenerate(m.schema, tables, m.genConfig.Rows(), m.genConfig.Format(), m.genConfig.OutPath(), m.dbType)) + return m, tea.Batch(m.execute.spinner.Tick, startGenerate(m.schema, m.volumes.tables, m.genConfig.Rows(), m.volumes.TableRows(), m.genConfig.Format(), m.genConfig.OutPath(), m.dbType)) } return m, cmd } @@ -372,7 +397,7 @@ func (m GenModel) updateExecute(msg tea.Msg) (tea.Model, tea.Cmd) { func (m GenModel) View() string { var sb strings.Builder - steps := []string{"Tables", "Config", "Generate"} + steps := []string{"Tables", "Config", "Volumes", "Generate"} var breadcrumbs []string for i, name := range steps { if genStep(i) == m.step { @@ -391,6 +416,8 @@ func (m GenModel) View() string { sb.WriteString(m.picker.View()) case genStepConfig: sb.WriteString(m.genConfig.View()) + case genStepRows: + sb.WriteString(m.volumes.View()) case genStepExecute: sb.WriteString(m.execute.View()) } @@ -398,9 +425,9 @@ func (m GenModel) View() string { } // startGenerate generates data and optionally writes to file. -func startGenerate(s *schema.Schema, tables []string, rows int, format, outPath, dbType string) tea.Cmd { +func startGenerate(s *schema.Schema, tables []string, rows int, tableRows map[string]int, format, outPath, dbType string) tea.Cmd { return func() tea.Msg { - data, err := faker.Generate(s, tables, rows, 0, nil, dbType) + data, err := faker.GenerateFilteredWithCounts(s, tables, tables, rows, 0, tableRows, nil, dbType) if err != nil { return generateDoneMsg{err: fmt.Errorf("generation failed: %w", err)} } diff --git a/internal/tui/generate_test.go b/internal/tui/generate_test.go index 278e7a4..320be5a 100644 --- a/internal/tui/generate_test.go +++ b/internal/tui/generate_test.go @@ -57,12 +57,16 @@ func TestGen_pickerToConfig(t *testing.T) { } } -func TestGen_configToExecute(t *testing.T) { +func TestGen_configToVolumesToExecute(t *testing.T) { m := buildGenModel() m2 := sendGenKey(m, "enter") // → config - m3 := sendGenKey(m2, "enter") // → execute - if getGen(m3).step != genStepExecute { - t.Fatalf("enter on config should advance to execute, got step %d", getGen(m3).step) + m3 := sendGenKey(m2, "enter") // → volumes + if getGen(m3).step != genStepRows { + t.Fatalf("enter on config should advance to volumes, got step %d", getGen(m3).step) + } + m4 := sendGenKey(m3, "enter") // → execute + if getGen(m4).step != genStepExecute { + t.Fatalf("enter on volumes should advance to execute, got step %d", getGen(m4).step) } } @@ -113,8 +117,8 @@ func TestGen_viewShowsGenerateHeader(t *testing.T) { func TestGen_viewShowsAllStepNames(t *testing.T) { m := buildGenModel() view := m.View() - if !stringContains(view, "Tables") || !stringContains(view, "Config") || !stringContains(view, "Generate") { - t.Error("breadcrumb should show all 3 step names") + if !stringContains(view, "Tables") || !stringContains(view, "Config") || !stringContains(view, "Volumes") || !stringContains(view, "Generate") { + t.Error("breadcrumb should show all 4 step names") } } diff --git a/internal/tui/review.go b/internal/tui/review.go index 729c569..04b083e 100644 --- a/internal/tui/review.go +++ b/internal/tui/review.go @@ -8,26 +8,32 @@ import ( ) type reviewModel struct { - tables []string - parents map[string][]string // table -> FK parent names - rows int - enumRows int - truncate bool - batch int - done bool - dryRun bool - back bool - quitting bool + tables []string + parents map[string][]string // table -> FK parent names + rows int + enumRows int + tableRows map[string]int + truncate bool + batch int + done bool + dryRun bool + back bool + quitting bool } -func newReview(tables []string, parents map[string][]string, rows, enumRows, batch int, truncate bool) reviewModel { +func newReview(tables []string, parents map[string][]string, rows, enumRows, batch int, truncate bool, tableRows ...map[string]int) reviewModel { + overrides := map[string]int(nil) + if len(tableRows) > 0 { + overrides = tableRows[0] + } return reviewModel{ - tables: tables, - parents: parents, - rows: rows, - enumRows: enumRows, - truncate: truncate, - batch: batch, + tables: tables, + parents: parents, + rows: rows, + enumRows: enumRows, + tableRows: overrides, + truncate: truncate, + batch: batch, } } @@ -58,6 +64,9 @@ func (m reviewModel) View() string { // Config summary sb.WriteString(fmt.Sprintf(" Tables: %d\n", len(m.tables))) sb.WriteString(fmt.Sprintf(" Rows/table: %d\n", m.rows)) + if len(m.tableRows) > 0 { + sb.WriteString(fmt.Sprintf(" Overrides: %d table(s)\n", len(m.tableRows))) + } if m.enumRows > 0 { sb.WriteString(fmt.Sprintf(" Enum rows: %d\n", m.enumRows)) } @@ -77,15 +86,19 @@ func (m reviewModel) View() string { } } - sb.WriteString(fmt.Sprintf(" %-*s %-*s %s\n", numWidth, "#", tableWidth, "Table", "Dependencies")) - sb.WriteString(fmt.Sprintf(" %s\n", strings.Repeat("─", numWidth+2+tableWidth+2+30))) + sb.WriteString(fmt.Sprintf(" %-*s %-*s %-8s %s\n", numWidth, "#", tableWidth, "Table", "Rows", "Dependencies")) + sb.WriteString(fmt.Sprintf(" %s\n", strings.Repeat("─", numWidth+2+tableWidth+2+8+2+30))) for i, table := range m.tables { deps := "—" if parents, ok := m.parents[table]; ok && len(parents) > 0 { deps = strings.Join(parents, ", ") } - sb.WriteString(fmt.Sprintf(" %-*d %-*s %s\n", numWidth, i+1, tableWidth, table, deps)) + rows := m.rows + if override := m.tableRows[table]; override > 0 { + rows = override + } + sb.WriteString(fmt.Sprintf(" %-*d %-*s %-8d %s\n", numWidth, i+1, tableWidth, table, rows, deps)) } sb.WriteString("\n") diff --git a/internal/tui/table_rows.go b/internal/tui/table_rows.go new file mode 100644 index 0000000..662e9b9 --- /dev/null +++ b/internal/tui/table_rows.go @@ -0,0 +1,179 @@ +package tui + +import ( + "fmt" + "strconv" + "strings" + + "github.com/charmbracelet/bubbles/textinput" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" +) + +type tableRowsModel struct { + tables []string + defaultRows int + inputs []textinput.Model + cursor int + done bool + back bool + quitting bool + height int + offset int +} + +func newTableRows(tables []string, defaultRows int, existing map[string]int, height int) tableRowsModel { + inputs := make([]textinput.Model, len(tables)) + for i, tableName := range tables { + ti := textinput.New() + ti.CharLimit = 10 + ti.Width = 10 + value := defaultRows + if existing != nil && existing[tableName] > 0 { + value = existing[tableName] + } + ti.SetValue(strconv.Itoa(value)) + if i == 0 { + ti.Focus() + } + inputs[i] = ti + } + return tableRowsModel{ + tables: tables, + defaultRows: defaultRows, + inputs: inputs, + height: height, + } +} + +func (m tableRowsModel) Update(msg tea.Msg) (tableRowsModel, tea.Cmd) { + switch msg := msg.(type) { + case tea.KeyMsg: + switch msg.String() { + case "tab", "down", "j": + m.move(1) + return m, nil + case "shift+tab", "up", "k": + m.move(-1) + return m, nil + case "enter": + m.done = true + return m, nil + case "b", "esc": + m.back = true + return m, nil + case "q", "ctrl+c": + m.quitting = true + return m, nil + } + case tea.WindowSizeMsg: + m.height = msg.Height + } + + if len(m.inputs) == 0 || m.cursor >= len(m.inputs) { + return m, nil + } + var cmd tea.Cmd + m.inputs[m.cursor], cmd = m.inputs[m.cursor].Update(msg) + return m, cmd +} + +func (m *tableRowsModel) move(delta int) { + if len(m.inputs) == 0 { + return + } + m.inputs[m.cursor].Blur() + m.cursor = (m.cursor + delta + len(m.inputs)) % len(m.inputs) + m.inputs[m.cursor].Focus() + visible := m.visibleRows() + if m.cursor < m.offset { + m.offset = m.cursor + } + if m.cursor >= m.offset+visible { + m.offset = m.cursor - visible + 1 + } +} + +func (m tableRowsModel) visibleRows() int { + h := m.height + if h < 20 { + h = 40 + } + available := h - 8 + if available < 1 { + available = 1 + } + if available > len(m.tables) { + return len(m.tables) + } + return available +} + +func (m tableRowsModel) TableRows() map[string]int { + out := make(map[string]int) + for i, tableName := range m.tables { + n, err := strconv.Atoi(strings.TrimSpace(m.inputs[i].Value())) + if err != nil || n < 1 { + n = m.defaultRows + } + if n != m.defaultRows { + out[tableName] = n + } + } + if len(out) == 0 { + return nil + } + return out +} + +func (m tableRowsModel) EffectiveRows(tableName string) int { + for i, t := range m.tables { + if t != tableName { + continue + } + n, err := strconv.Atoi(strings.TrimSpace(m.inputs[i].Value())) + if err != nil || n < 1 { + return m.defaultRows + } + return n + } + return m.defaultRows +} + +func (m tableRowsModel) View() string { + var sb strings.Builder + sb.WriteString(titleStyle.Render("Set table volumes")) + sb.WriteString("\n\n") + sb.WriteString(dimStyle.Render(fmt.Sprintf(" Default rows/table: %d. Edit only the tables that need a different volume.", m.defaultRows))) + sb.WriteString("\n\n") + + visible := m.visibleRows() + end := m.offset + visible + if end > len(m.tables) { + end = len(m.tables) + } + nameWidth := 0 + for _, tableName := range m.tables { + if len(tableName) > nameWidth { + nameWidth = len(tableName) + } + } + + for i := m.offset; i < end; i++ { + cursor := " " + name := m.tables[i] + if i == m.cursor { + cursor = cursorStyle.Render("▸ ") + name = lipgloss.NewStyle().Bold(true).Render(name) + } + sb.WriteString(fmt.Sprintf("%s%-*s %s rows\n", cursor, nameWidth, name, m.inputs[i].View())) + } + + if len(m.tables) > visible { + sb.WriteString(dimStyle.Render(fmt.Sprintf("\n showing %d-%d of %d tables", m.offset+1, end, len(m.tables)))) + sb.WriteString("\n") + } + sb.WriteString("\n") + sb.WriteString(helpStyle.Render(" tab/↑↓ navigate • type row count • enter confirm • b back • q quit")) + return sb.String() +} diff --git a/internal/tui/table_rows_test.go b/internal/tui/table_rows_test.go new file mode 100644 index 0000000..199c7c4 --- /dev/null +++ b/internal/tui/table_rows_test.go @@ -0,0 +1,62 @@ +package tui + +import ( + "testing" + + tea "github.com/charmbracelet/bubbletea" +) + +func sendRowsKey(m tableRowsModel, key string) tableRowsModel { + var msg tea.Msg + switch key { + case "enter": + msg = tea.KeyMsg{Type: tea.KeyEnter} + case "down": + msg = tea.KeyMsg{Type: tea.KeyDown} + case "backspace": + msg = tea.KeyMsg{Type: tea.KeyBackspace} + case "b": + msg = tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'b'}} + default: + msg = tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune(key)} + } + updated, _ := m.Update(msg) + return updated +} + +func TestTableRowsDefaultsProduceNoOverrides(t *testing.T) { + m := newTableRows([]string{"users", "orders"}, 10, nil, 40) + + if got := m.TableRows(); got != nil { + t.Fatalf("TableRows() = %+v, want nil when every table uses default", got) + } + if got := m.EffectiveRows("orders"); got != 10 { + t.Fatalf("EffectiveRows(orders) = %d, want 10", got) + } +} + +func TestTableRowsCapturesOnlyChangedTables(t *testing.T) { + m := newTableRows([]string{"users", "orders"}, 10, nil, 40) + m = sendRowsKey(m, "down") + m.inputs[m.cursor].SetValue("25") + + got := m.TableRows() + if len(got) != 1 || got["orders"] != 25 { + t.Fatalf("TableRows() = %+v, want only orders=25", got) + } + if got := m.EffectiveRows("orders"); got != 25 { + t.Fatalf("EffectiveRows(orders) = %d, want 25", got) + } +} + +func TestTableRowsInvalidInputFallsBackToDefault(t *testing.T) { + m := newTableRows([]string{"users"}, 10, nil, 40) + m.inputs[m.cursor].SetValue("0") + + if got := m.TableRows(); got != nil { + t.Fatalf("TableRows() = %+v, want nil for invalid override", got) + } + if got := m.EffectiveRows("users"); got != 10 { + t.Fatalf("EffectiveRows(users) = %d, want fallback 10", got) + } +} diff --git a/internal/tui/tui.go b/internal/tui/tui.go index 1524f89..897f21c 100644 --- a/internal/tui/tui.go +++ b/internal/tui/tui.go @@ -16,6 +16,7 @@ type step int const ( stepPicker step = iota stepConfig + stepRows stepReview stepExecute ) @@ -26,6 +27,7 @@ type seedParams struct { tables []string rows int enumRows int + tableRows map[string]int batchSize int truncate bool dbType string @@ -44,6 +46,7 @@ type Model struct { picker tablePickerModel config configModel + volumes tableRowsModel review reviewModel execute executeModel @@ -112,6 +115,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.width = msg.Width m.height = msg.Height m.picker.height = msg.Height + m.volumes.height = msg.Height case tea.KeyMsg: if msg.String() == "ctrl+c" { m.quitting = true @@ -124,6 +128,8 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m.updatePicker(msg) case stepConfig: return m.updateConfig(msg) + case stepRows: + return m.updateRows(msg) case stepReview: return m.updateReview(msg) case stepExecute: @@ -182,7 +188,6 @@ func (m Model) updateConfig(msg tea.Msg) (tea.Model, tea.Cmd) { return m, nil } if m.config.done { - // Build review data selected := m.picker.selectedTables() var tables []string for _, t := range m.sortedAll { @@ -190,13 +195,36 @@ func (m Model) updateConfig(msg tea.Msg) (tea.Model, tea.Cmd) { tables = append(tables, t) } } + m.volumes = newTableRows(tables, m.config.Rows(), nil, m.height) + m.step = stepRows + } + + return m, cmd +} + +func (m Model) updateRows(msg tea.Msg) (tea.Model, tea.Cmd) { + var cmd tea.Cmd + m.volumes, cmd = m.volumes.Update(msg) + + if m.volumes.quitting { + m.quitting = true + return m, tea.Quit + } + if m.volumes.back { + m.volumes.back = false + m.config.done = false + m.step = stepConfig + return m, nil + } + if m.volumes.done { + tables := m.volumes.tables parents := make(map[string][]string) for _, t := range tables { parents[t] = m.graph.Parents(t) } m.review = newReview(tables, parents, - m.config.Rows(), m.config.EnumRows(), m.config.BatchSize(), m.config.Truncate()) + m.config.Rows(), m.config.EnumRows(), m.config.BatchSize(), m.config.Truncate(), m.volumes.TableRows()) m.step = stepReview } @@ -213,8 +241,8 @@ func (m Model) updateReview(msg tea.Msg) (tea.Model, tea.Cmd) { } if m.review.back { m.review.back = false - m.config.done = false - m.step = stepConfig + m.volumes.done = false + m.step = stepRows return m, nil } if m.review.done { @@ -223,6 +251,7 @@ func (m Model) updateReview(msg tea.Msg) (tea.Model, tea.Cmd) { tables: m.review.tables, rows: m.review.rows, enumRows: m.review.enumRows, + tableRows: m.review.tableRows, batchSize: m.review.batch, truncate: m.review.truncate, dbType: m.dbType, @@ -262,7 +291,7 @@ func (m Model) View() string { var sb strings.Builder // Step breadcrumb - steps := []string{"Tables", "Config", "Review", "Execute"} + steps := []string{"Tables", "Config", "Volumes", "Review", "Execute"} var breadcrumbs []string for i, name := range steps { if step(i) == m.step { @@ -285,6 +314,8 @@ func (m Model) View() string { sb.WriteString(m.picker.View()) case stepConfig: sb.WriteString(m.config.View()) + case stepRows: + sb.WriteString(m.volumes.View()) case stepReview: sb.WriteString(m.review.View()) case stepExecute: diff --git a/internal/tui/wizard_test.go b/internal/tui/wizard_test.go index 6f47bb6..8e81048 100644 --- a/internal/tui/wizard_test.go +++ b/internal/tui/wizard_test.go @@ -86,14 +86,15 @@ func TestWizard_fullFlowToReview(t *testing.T) { t.Fatalf("enter on picker should advance to config, got step %d", getModel(m2).step) } - // Step 2: Config — press enter to advance + // Step 2: Config — press enter to advance to table volumes. m3 := sendKey(m2, "enter") - if getModel(m3).step != stepReview { - t.Fatalf("enter on config should advance to review, got step %d", getModel(m3).step) + if getModel(m3).step != stepRows { + t.Fatalf("enter on config should advance to volumes, got step %d", getModel(m3).step) } + m4 := sendKey(m3, "enter") // Verify review has all 3 tables - rm := getModel(m3) + rm := getModel(m4) if len(rm.review.tables) != 3 { t.Errorf("review should have 3 tables, got %d", len(rm.review.tables)) } @@ -111,10 +112,15 @@ func TestWizard_backFromConfigToPicker(t *testing.T) { func TestWizard_backFromReviewToConfig(t *testing.T) { m := buildTestModel() m2 := sendKey(m, "enter") // picker → config - m3 := sendKey(m2, "enter") // config → review - m4 := sendKey(m3, "b") // review → back to config - if getModel(m4).step != stepConfig { - t.Fatalf("b on review should go back to config, got step %d", getModel(m4).step) + m3 := sendKey(m2, "enter") // config → volumes + m4 := sendKey(m3, "enter") // volumes → review + m5 := sendKey(m4, "b") // review → back to volumes + if getModel(m5).step != stepRows { + t.Fatalf("b on review should go back to volumes, got step %d", getModel(m5).step) + } + m6 := sendKey(m5, "b") // volumes → back to config + if getModel(m6).step != stepConfig { + t.Fatalf("b on volumes should go back to config, got step %d", getModel(m6).step) } } @@ -138,9 +144,10 @@ func TestWizard_quitFromConfig(t *testing.T) { func TestWizard_quitFromReview(t *testing.T) { m := buildTestModel() m2 := sendKey(m, "enter") // → config - m3 := sendKey(m2, "enter") // → review - m4 := sendKey(m3, "q") - if !getModel(m4).quitting { + m3 := sendKey(m2, "enter") // → volumes + m4 := sendKey(m3, "enter") // → review + m5 := sendKey(m4, "q") + if !getModel(m5).quitting { t.Error("q on review should set quitting=true") } } @@ -152,8 +159,9 @@ func TestWizard_deselectLeafReducesReviewCount(t *testing.T) { m3 := sendKey(m2, "down") // cursor=2 (order_items) m4 := sendKey(m3, "space") // toggle off order_items m5 := sendKey(m4, "enter") // → config - m6 := sendKey(m5, "enter") // → review - rm := getModel(m6) + m6 := sendKey(m5, "enter") // → volumes + m7 := sendKey(m6, "enter") // → review + rm := getModel(m7) if len(rm.review.tables) != 2 { t.Errorf("deselecting leaf should leave 2 tables, got %d: %v", len(rm.review.tables), rm.review.tables) } @@ -198,8 +206,9 @@ func TestWizard_autoDepResolutionOnAdvance(t *testing.T) { func TestWizard_reviewShowsCorrectRowCount(t *testing.T) { m := buildTestModel() m2 := sendKey(m, "enter") // → config (rows=10 from buildTestModel) - m3 := sendKey(m2, "enter") // → review - rm := getModel(m3) + m3 := sendKey(m2, "enter") // → volumes + m4 := sendKey(m3, "enter") // → review + rm := getModel(m4) if rm.review.rows != 10 { t.Errorf("review should show 10 rows (from config default), got %d", rm.review.rows) } @@ -208,10 +217,11 @@ func TestWizard_reviewShowsCorrectRowCount(t *testing.T) { func TestWizard_dryRunFromReview(t *testing.T) { m := buildTestModel() m2 := sendKey(m, "enter") // → config - m3 := sendKey(m2, "enter") // → review - m4 := sendKey(m3, "d") // dry-run + m3 := sendKey(m2, "enter") // → volumes + m4 := sendKey(m3, "enter") // → review + m5 := sendKey(m4, "d") // dry-run - rm := getModel(m4) + rm := getModel(m5) if rm.step != stepExecute { t.Fatalf("d on review should advance to execute, got step %d", rm.step) } @@ -236,10 +246,17 @@ func TestWizard_viewRendersAllSteps(t *testing.T) { t.Error("config view should contain 'Configure'") } - // Review view should contain "Review" + // Volumes view should contain volume controls m3 := sendKey(m2, "enter") view3 := getModel(m3).View() - if !contains(view3, "Review") { + if !contains(view3, "Set table volumes") { + t.Error("volumes view should contain 'Set table volumes'") + } + + // Review view should contain "Review" + m4 := sendKey(m3, "enter") + view4 := getModel(m4).View() + if !contains(view4, "Review") { t.Error("review view should contain 'Review'") } } @@ -247,8 +264,8 @@ func TestWizard_viewRendersAllSteps(t *testing.T) { func TestWizard_breadcrumbShowsProgress(t *testing.T) { m := buildTestModel() view := m.View() - if !contains(view, "Tables") || !contains(view, "Config") || !contains(view, "Review") || !contains(view, "Execute") { - t.Error("breadcrumb should show all 4 step names") + if !contains(view, "Tables") || !contains(view, "Config") || !contains(view, "Volumes") || !contains(view, "Review") || !contains(view, "Execute") { + t.Error("breadcrumb should show all 5 step names") } } diff --git a/internal/web/runners.go b/internal/web/runners.go index b85befb..c32e3b4 100644 --- a/internal/web/runners.go +++ b/internal/web/runners.go @@ -40,13 +40,14 @@ func jobLogger(w io.Writer) zerolog.Logger { // restricts seeding to the listed tables plus their transitive non-nullable // FK parents. type SeedRequest struct { - Rows int `json:"rows"` - EnumRows int `json:"enumRows"` - BatchSize int `json:"batchSize"` - DisableFK bool `json:"disableFK"` - Truncate bool `json:"truncate"` - DryRun bool `json:"dryRun"` - Tables []string `json:"tables,omitempty"` + Rows int `json:"rows"` + EnumRows int `json:"enumRows"` + BatchSize int `json:"batchSize"` + DisableFK bool `json:"disableFK"` + Truncate bool `json:"truncate"` + DryRun bool `json:"dryRun"` + Tables []string `json:"tables,omitempty"` + TableRows map[string]int `json:"tableRows,omitempty"` } func (s *Server) runSeed(ctx context.Context, sess *Session, req SeedRequest, jc JobControl) (map[string]any, error) { @@ -120,7 +121,7 @@ func (s *Server) runSeed(ctx context.Context, sess *Session, req SeedRequest, jc } // GenerateFiltered preloads PKs from allSorted so target tables can FK-ref // already-populated parents; targetTables alone is what gets generated. - data, err := faker.GenerateFiltered(sc, allSorted, targetTables, req.Rows, req.EnumRows, connArg, sess.DBType) + data, err := faker.GenerateFilteredWithCounts(sc, allSorted, targetTables, req.Rows, req.EnumRows, cleanTableRows(req.TableRows), connArg, sess.DBType) if err != nil { return nil, fmt.Errorf("generation: %w", err) } @@ -182,12 +183,13 @@ func (s *Server) runSeed(ctx context.Context, sess *Session, req SeedRequest, jc // GapsRequest mirrors the gaps CLI flags. Tables, when set, restricts the // fill phase to the listed empty tables (plus their transitive parents). type GapsRequest struct { - Rows int `json:"rows"` - EnumRows int `json:"enumRows"` - BatchSize int `json:"batchSize"` - Fill bool `json:"fill"` - DryRun bool `json:"dryRun"` - Tables []string `json:"tables,omitempty"` + Rows int `json:"rows"` + EnumRows int `json:"enumRows"` + BatchSize int `json:"batchSize"` + Fill bool `json:"fill"` + DryRun bool `json:"dryRun"` + Tables []string `json:"tables,omitempty"` + TableRows map[string]int `json:"tableRows,omitempty"` } func (s *Server) runGaps(ctx context.Context, sess *Session, req GapsRequest, jc JobControl) (map[string]any, error) { @@ -258,7 +260,7 @@ func (s *Server) runGaps(ctx context.Context, sess *Session, req GapsRequest, jc jc.Phase("generate") log.Info().Int("gap_tables", len(gapTables)).Int("rows", req.Rows).Msg("Generating data for empty tables") - data, err := faker.GenerateFiltered(sc, allSorted, gapTables, req.Rows, req.EnumRows, conn, sess.DBType) + data, err := faker.GenerateFilteredWithCounts(sc, allSorted, gapTables, req.Rows, req.EnumRows, cleanTableRows(req.TableRows), conn, sess.DBType) if err != nil { return nil, err } @@ -292,9 +294,10 @@ func (s *Server) runGaps(ctx context.Context, sess *Session, req GapsRequest, jc // GenerateRequest mirrors the generate CLI flags. Tables, when set, restricts // generation to the listed tables plus their transitive non-nullable parents. type GenerateRequest struct { - Rows int `json:"rows"` - Format string `json:"format"` // yaml | json | sql - Tables []string `json:"tables,omitempty"` + Rows int `json:"rows"` + Format string `json:"format"` // yaml | json | sql + Tables []string `json:"tables,omitempty"` + TableRows map[string]int `json:"tableRows,omitempty"` } func (s *Server) runGenerate(ctx context.Context, sess *Session, req GenerateRequest, jc JobControl) (map[string]any, error) { @@ -328,7 +331,7 @@ func (s *Server) runGenerate(ctx context.Context, sess *Session, req GenerateReq jc.Phase("generate") log.Info().Int("rows", req.Rows).Int("tables", len(targetTables)).Msg("Generating fake data") // GenerateFiltered is fine here too: with conn=nil it skips PK preload. - data, err := faker.GenerateFiltered(sc, allSorted, targetTables, req.Rows, 0, nil, sess.DBType) + data, err := faker.GenerateFilteredWithCounts(sc, allSorted, targetTables, req.Rows, 0, cleanTableRows(req.TableRows), nil, sess.DBType) if err != nil { return nil, err } @@ -360,6 +363,22 @@ func tableRowCounts(data map[string][]map[string]any, sortedTables []string) (ma return counts, total } +func cleanTableRows(rows map[string]int) map[string]int { + if len(rows) == 0 { + return nil + } + clean := make(map[string]int, len(rows)) + for tableName, count := range rows { + if tableName != "" && count > 0 { + clean[tableName] = count + } + } + if len(clean) == 0 { + return nil + } + return clean +} + func encodeData(data map[string][]map[string]any, sortedTables []string, format, dbType string) (string, error) { switch strings.ToLower(format) { case "json": diff --git a/internal/web/runners_test.go b/internal/web/runners_test.go index cefb27f..f73c072 100644 --- a/internal/web/runners_test.go +++ b/internal/web/runners_test.go @@ -1,10 +1,19 @@ package web import ( + "context" "reflect" "testing" + + "github.com/AxeForging/seedstorm/internal/schema" ) +type testJobControl struct{} + +func (testJobControl) Write(p []byte) (int, error) { return len(p), nil } +func (testJobControl) Phase(string) {} +func (testJobControl) Progress(int, int, string) {} + func TestTableRowCounts(t *testing.T) { data := map[string][]map[string]any{ "users": { @@ -26,3 +35,109 @@ func TestTableRowCounts(t *testing.T) { t.Fatalf("total = %d, want 3", total) } } + +func TestCleanTableRowsKeepsPositiveOverrides(t *testing.T) { + got := cleanTableRows(map[string]int{ + "users": 4, + "orders": 0, + "": 9, + "items": -1, + }) + + want := map[string]int{"users": 4} + if !reflect.DeepEqual(got, want) { + t.Fatalf("cleanTableRows = %+v, want %+v", got, want) + } +} + +func TestCleanTableRowsReturnsNilForEmptyInput(t *testing.T) { + if got := cleanTableRows(nil); got != nil { + t.Fatalf("cleanTableRows(nil) = %+v, want nil", got) + } + if got := cleanTableRows(map[string]int{"users": 0}); got != nil { + t.Fatalf("cleanTableRows(non-positive only) = %+v, want nil", got) + } +} + +func TestRunSeedDryRunAppliesTableRowOverrides(t *testing.T) { + srv, err := New(Options{Addr: "127.0.0.1:0"}) + if err != nil { + t.Fatalf("New: %v", err) + } + sess := &Session{ + DBType: "pgx", + schema: runnerRowCountSchema(), + } + + result, err := srv.runSeed(context.Background(), sess, SeedRequest{ + Rows: 2, + BatchSize: 100, + DryRun: true, + TableRows: map[string]int{"orders": 4}, + }, testJobControl{}) + if err != nil { + t.Fatalf("runSeed: %v", err) + } + if got := result["totalRows"]; got != 6 { + t.Fatalf("totalRows = %v, want 6", got) + } + counts, ok := result["tableCounts"].(map[string]int) + if !ok { + t.Fatalf("tableCounts type = %T, want map[string]int", result["tableCounts"]) + } + if counts["users"] != 2 || counts["orders"] != 4 { + t.Fatalf("tableCounts = %+v, want users=2 orders=4", counts) + } + if result["output"] == "" { + t.Fatalf("dry-run output should include generated SQL") + } +} + +func TestRunGenerateAppliesTableRowOverridesWithoutBreakingDefaults(t *testing.T) { + srv, err := New(Options{Addr: "127.0.0.1:0"}) + if err != nil { + t.Fatalf("New: %v", err) + } + sess := &Session{ + DBType: "pgx", + schema: runnerRowCountSchema(), + } + + result, err := srv.runGenerate(context.Background(), sess, GenerateRequest{ + Rows: 3, + Format: "yaml", + TableRows: map[string]int{"orders": 1}, + }, testJobControl{}) + if err != nil { + t.Fatalf("runGenerate: %v", err) + } + if got := result["totalRows"]; got != 4 { + t.Fatalf("totalRows = %v, want 4", got) + } + counts, ok := result["tableCounts"].(map[string]int) + if !ok { + t.Fatalf("tableCounts type = %T, want map[string]int", result["tableCounts"]) + } + if counts["users"] != 3 || counts["orders"] != 1 { + t.Fatalf("tableCounts = %+v, want users=3 orders=1", counts) + } +} + +func runnerRowCountSchema() *schema.Schema { + return &schema.Schema{ + Tables: map[string]schema.Table{ + "users": { + Columns: map[string]schema.Column{ + "id": {Type: "integer", PK: true}, + "name": {Type: "varchar", Faker: "name"}, + }, + }, + "orders": { + Columns: map[string]schema.Column{ + "id": {Type: "integer", PK: true}, + "user_id": {Type: "integer", FK: "users.id"}, + }, + }, + }, + } +} diff --git a/internal/web/static/app.js b/internal/web/static/app.js index dce5591..c4ce693 100644 --- a/internal/web/static/app.js +++ b/internal/web/static/app.js @@ -639,6 +639,8 @@ selected: new Set(), // explicit user picks auto: new Set(), // auto-locked transitive parents parents: {}, // table → [hard parents] + children: {}, // table → [hard children] + tableRows: {}, // table → explicit row count override nodes: [], // raw graph payload edges: [], mode: "seed", @@ -687,6 +689,7 @@ focusFirstSearchHit(); } }); + document.getElementById("cfg-rows")?.addEventListener("input", () => refreshSelectionUI()); document.getElementById("ws-fit")?.addEventListener("click", () => fitGraph()); document.getElementById("ws-zoom-in")?.addEventListener("click", () => zoomGraph(1.18)); document.getElementById("ws-zoom-out")?.addEventListener("click", () => zoomGraph(0.84)); @@ -724,9 +727,16 @@ ws.edges = data.edges || []; // Compute hard FK parents per table (from non-nullable edges). ws.parents = {}; - for (const n of ws.nodes) ws.parents[n.id] = []; + ws.children = {}; + for (const n of ws.nodes) { + ws.parents[n.id] = []; + ws.children[n.id] = []; + } for (const e of ws.edges) { - if (!e.nullable) ws.parents[e.target].push(e.source); + if (!e.nullable) { + ws.parents[e.target].push(e.source); + ws.children[e.source].push(e.target); + } } const elements = [ @@ -780,6 +790,7 @@ return { id: n.id, label: n.label, + displayLabel: n.label, count: n.count, counted: n.counted, countLabel: n.counted ? formatCount(n.count) : "?", @@ -811,7 +822,7 @@ "background-color": "#1d2230", "border-color": "#3b465f", "border-width": 1.5, - "label": "data(label)", + "label": "data(displayLabel)", "color": "#e6e9f2", "font-size": 12, "text-valign": "center", @@ -822,6 +833,9 @@ "height": "label", "transition-property": "border-color background-color", "transition-duration": 150, + "text-wrap": "wrap", + "text-max-width": 150, + "line-height": 1.25, }, }, // count badge using overlay node label trick @@ -1029,6 +1043,7 @@ function clearSelection() { ws.selected = new Set(); ws.auto = new Set(); + ws.tableRows = {}; refreshSelectionUI(); } function selectEmpty() { @@ -1052,6 +1067,7 @@ ws.cy.nodes().forEach((n) => { const id = n.id(); n.removeClass("selected auto"); + n.data("displayLabel", nodeDisplayLabel(id)); if (ws.selected.has(id)) n.addClass("selected"); else if (ws.auto.has(id)) n.addClass("auto"); }); @@ -1083,7 +1099,30 @@ const main = document.createElement("div"); main.className = "ws-sel-main"; const name = document.createElement("span"); + name.className = "ws-sel-name"; name.textContent = item.id; + const volume = document.createElement("label"); + volume.className = "ws-sel-rows"; + volume.title = "Rows to create for this table in this run"; + const volumeText = document.createElement("span"); + const childCount = selectedChildCount(item.id); + volumeText.textContent = childCount > 0 ? `${childCount} child${childCount === 1 ? "" : "ren"}` : "rows"; + const volumeInput = document.createElement("input"); + volumeInput.type = "number"; + volumeInput.min = "1"; + volumeInput.inputMode = "numeric"; + volumeInput.placeholder = String(defaultRows()); + volumeInput.value = ws.tableRows[item.id] ? String(ws.tableRows[item.id]) : ""; + volumeInput.addEventListener("click", (ev) => ev.stopPropagation()); + volumeInput.addEventListener("input", (ev) => { + ev.stopPropagation(); + const n = Number(ev.target.value || 0); + if (n > 0) ws.tableRows[item.id] = n; + else delete ws.tableRows[item.id]; + syncNodeRowLabels(); + updateRunScope(); + }); + volume.append(volumeText, volumeInput); const actions = document.createElement("span"); actions.className = "ws-sel-actions"; const tag = document.createElement("span"); @@ -1110,12 +1149,13 @@ ev.stopPropagation(); ws.selected.delete(item.id); ws.peek.delete(item.id); + delete ws.tableRows[item.id]; recomputeAuto(); refreshSelectionUI(); }); actions.append(remove); } - main.append(name, actions); + main.append(name, volume, actions); li.append(main); const preview = document.createElement("div"); preview.className = "ws-sel-peek"; @@ -1141,11 +1181,33 @@ refreshSelectionUI(); } + function defaultRows() { + return Number(document.getElementById("cfg-rows")?.value || 0) || 20; + } + + function selectedChildCount(tableName) { + const effective = new Set([...ws.selected, ...ws.auto]); + return (ws.children[tableName] || []).filter((child) => effective.has(child)).length; + } + + function nodeDisplayLabel(id) { + const override = ws.tableRows[id]; + const effective = ws.selected.has(id) || ws.auto.has(id); + return effective && override > 0 ? `${id}\n${formatCount(override)} rows` : id; + } + + function syncNodeRowLabels() { + if (!ws.cy) return; + ws.cy.batch(() => { + ws.cy.nodes().forEach((n) => n.data("displayLabel", nodeDisplayLabel(n.id()))); + }); + } + async function loadPeek(tableName, target) { target.hidden = false; target.innerHTML = '

Loading rows...

'; - const q = new URLSearchParams({ table: tableName, limit: "5", offset: "0" }); - const res = await fetch("/api/table?" + q.toString()); + const q = new URLSearchParams({ table: tableName, limit: "5", offset: "0", _: String(Date.now()) }); + const res = await fetch("/api/table?" + q.toString(), { cache: "no-store" }); const data = await res.json(); if (!res.ok) { target.innerHTML = `

Preview failed: ${escapeHTML(data.error || res.statusText)}

`; @@ -1163,7 +1225,9 @@ }).join(""); return `
${cells}
`; }).join(""); - const more = data.total > data.rows.length ? `${data.rows.length} of ${data.total}` : `${data.total} rows`; + const more = data.total > data.rows.length + ? `Showing first ${data.rows.length} of ${data.total} rows` + : `${data.total} rows`; target.innerHTML = `
${more}
${cards} @@ -1197,9 +1261,13 @@ const run = document.getElementById("ws-run"); const modeLabel = ws.mode === "gaps" ? "Fill empty" : (ws.mode === "generate" ? "Generate" : "Seed"); if (scope) { + const overrideCount = Object.keys(tableRowPayload()).length; + const volumeText = overrideCount > 0 + ? ` · ${overrideCount} customized` + : ""; scope.textContent = effective === 0 ? `Run scope: all ${total} tables` - : `Run scope: ${effective} tables (${explicit} selected, ${auto} required)`; + : `Run scope: ${effective} tables (${explicit} selected, ${auto} required)${volumeText}`; } if (run) { run.textContent = effective === 0 ? `${modeLabel} all tables` : `${modeLabel} ${effective} tables`; @@ -1395,8 +1463,9 @@ table: tableName, limit: String(ws.modal.limit), offset: String(ws.modal.offset), + _: String(Date.now()), }); - const res = await fetch("/api/table?" + q.toString()); + const res = await fetch("/api/table?" + q.toString(), { cache: "no-store" }); const data = await res.json(); if (!res.ok) { box.innerHTML = `

Preview failed: ${escapeHTML(data.error || res.statusText)}

`; @@ -1423,8 +1492,9 @@ table: tableName, limit: String(ws.preview.limit), offset: String(ws.preview.offset), + _: String(Date.now()), }); - const res = await fetch("/api/table?" + q.toString()); + const res = await fetch("/api/table?" + q.toString(), { cache: "no-store" }); const data = await res.json(); if (!res.ok) { box.innerHTML = `

Preview failed: ${escapeHTML(data.error || res.statusText)}

`; @@ -1498,6 +1568,7 @@ dryRun: document.getElementById("cfg-dryrun").checked, disableFK: document.getElementById("cfg-disablefk").checked, tables, + tableRows: tableRowPayload(), }; let endpoint = "/api/seed"; if (ws.mode === "gaps") { endpoint = "/api/gaps"; cfg.fill = true; } @@ -1526,6 +1597,15 @@ }); } + function tableRowPayload() { + const effective = new Set([...ws.selected, ...ws.auto]); + const out = {}; + for (const [tableName, rows] of Object.entries(ws.tableRows)) { + if (rows > 0 && effective.has(tableName)) out[tableName] = rows; + } + return out; + } + function onLogPulse(line) { if (!ws.cy) return; // zerolog console writer renders `Seeding table` and `Filling table` with key=value pairs. diff --git a/internal/web/static/style.css b/internal/web/static/style.css index 3596db5..6eb9494 100644 --- a/internal/web/static/style.css +++ b/internal/web/static/style.css @@ -1112,8 +1112,31 @@ textarea { font-family: var(--mono); font-size: 12px; } transition: border-color 100ms; } .ws-sel-main { - display: flex; align-items: center; justify-content: space-between; - gap: 8px; + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + align-items: center; + gap: 6px 8px; +} +.ws-sel-name { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; +} +.ws-sel-rows { + display: inline-flex; + align-items: center; + gap: 5px; + color: var(--muted); + font-family: var(--mono); + font-size: 10px; + cursor: default; +} +.ws-sel-rows input { + width: 58px; + min-height: 26px; + padding: 3px 6px; + font-family: var(--mono); + font-size: 11px; } .ws-sel-item:hover { border-color: var(--line); } .ws-sel-item.sel { border-left: 3px solid var(--accent); } @@ -1122,8 +1145,11 @@ textarea { font-family: var(--mono); font-size: 12px; } .ws-sel-actions { display: inline-flex; align-items: center; + flex-wrap: wrap; gap: 6px; - flex: 0 0 auto; + grid-column: 1 / -1; + min-width: 0; + max-width: 100%; } .ws-sel-tag { font-size: 9px; text-transform: uppercase; @@ -1141,6 +1167,7 @@ textarea { font-family: var(--mono); font-size: 12px; } font-family: var(--mono); font-size: 10px; padding: 2px 6px; + max-width: 100%; } .ws-sel-view:hover { border-color: rgba(121,216,179,0.34); From 9cff073daae8058c1f254ee6a2c504ee70193af0 Mon Sep 17 00:00:00 2001 From: Lucas Machado Date: Wed, 27 May 2026 12:27:11 +0200 Subject: [PATCH 2/2] ci: make ai reviewer non-blocking --- .github/workflows/pr.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index ec88c6a..f91a78d 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -53,6 +53,7 @@ jobs: steps: - uses: actions/checkout@v4 - uses: AxeForging/reviewforge@main + continue-on-error: true with: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} AI_PROVIDER: gemini