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
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.
+
| 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.
+
| 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 `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);