Internal design documentation explaining how seedling turns a root model plus options into inserted records. For practical usage, see the Guide.
flowchart LR
A["Public API<br/>InsertOne / Build / InsertMany"] --> B["Option collection<br/>Set / Use / Ref / Omit / When / With"]
B --> C["planner.Plan<br/>expand relations and validate options"]
C --> D["graph.Graph<br/>nodes + dependency edges + FK/PK bindings"]
D --> E["graph.TopoSort<br/>dependency-first execution order"]
E --> F["executor.Execute<br/>assign FKs and call Blueprint.Insert"]
F --> G["Result<br/>Root + Nodes + Graph"]
At a high level:
plannerdecides which records must exist.graphstores those records and the PK-to-FK bindings between them.executorwalks the graph in topological order, assigns FK fields, and runs inserts.
BuildE[T] resolves the root blueprint, collects options, and delegates graph construction to internal/planner.
Plan[T] is intentionally reusable:
BuildEcreates the graph once.InsertEclones that graph before execution.AfterInsertcallbacks run after executor completion.AfterInsertclosures are captured once at build time, so reusing a plan also reuses any callback state they hold.
The planner starts from the root blueprint and recursively expands required relations.
Inputs:
- root Go type
- registry-backed blueprint definitions
- normalized option set
Outputs:
graph.Graph- fully expanded nodes for the requested fixture
- dependency edges annotated with PK/FK field bindings
Core responsibilities:
- validate
Use,Ref,Omit,When, and FK-relatedSet - create a node from
Defaults() - apply root and nested options before execution
- expand
belongs_to,has_many, andmany_to_many - lazy evaluation with
Only(...): skip root-level relations not in the set, building only the required subgraph - reuse already-expanded nodes by node ID
- mark
Use(...)relations as provided nodes so the executor skips insertion
Relation expansion rules:
belongs_to: expand the parent first and add an edgeparent -> childhas_many: create child nodes and bind each child back to the parentmany_to_many: create the related child node plus an explicit join node
flowchart TD
T["task"] -->|belongs_to project_id <- projects.id| P["project"]
T -->|belongs_to assignee_user_id <- users.id| U["user"]
P -->|belongs_to company_id <- companies.id| C1["company"]
U -->|belongs_to company_id <- companies.id| C2["company"]
The planner graph is dependency-oriented, so a child keeps edges to the parents it depends on.
internal/graph is the neutral representation between planning and execution.
Each node stores:
- blueprint name
- table name
- current value
- primary key fields
- whether the node is provided by the caller
Each edge stores one or more field bindings:
- parent PK field
- child FK field
This is what lets seedling support composite keys without special executor branches.
The graph must be acyclic. TopoSort() uses Kahn's algorithm and returns nodes in the order required for insertion.
The executor consumes a cloned graph and mutates node values during execution.
For each node in topological order:
- Read PK values from dependency parents.
- Assign those values into the child FK fields.
- Skip insert when the node is provided via
Use(...). - Otherwise call the blueprint's
Insert(ctx, db, value). - Store the inserted value back on the node and in the result map.
sequenceDiagram
participant API as Plan.InsertE
participant G as graph.Graph
participant X as executor.Execute
participant DB as Blueprint.Insert
API->>G: Clone()
API->>X: Execute(ctx, db, cloned graph)
X->>G: TopoSort()
loop for each node
X->>X: assign parent PKs to child FKs
alt provided node
X->>X: skip insert
else generated node
X->>DB: Insert(ctx, db, value)
DB-->>X: inserted value with PKs
end
end
X-->>API: Result{Root, Nodes, Graph}
Important detail:
- FK fields are not finalized by the planner.
- They are assigned by the executor immediately before each insert.
- This allows parent PKs generated by the database to flow into downstream child nodes.
This separation keeps the core predictable:
planneris about shape and validity.graphis about transport and ordering.executoris about runtime values and side effects.
That split also enables DebugString(), DryRunString(), Validate(), graph cloning, and future graph export features without entangling insertion logic.
seedling-gen generates blueprint registration code from various schema sources. Install it with Homebrew (brew install --cask mhiro2/tap/seedling-gen) or go install github.com/mhiro2/seedling/cmd/seedling-gen@latest (see README Installation). Adapters parse their inputs independently, then normalize the results into a shared intermediate representation before rendering.
flowchart LR
SQL["sql subcommand<br/>schema.sql"] --> Parse["ParseSchema"]
SQLCConfig["sqlc --config<br/>sqlc.yaml"] --> ParseC["ParseSqlcConfig<br/>+ ParseSchema"]
SQLCManual["sqlc --dir + schema.sql<br/>generated Go + SQL DDL"] --> Parse
SQLCManual --> ParseSqlc["ParseSqlcDir"]
GORM["GORM models<br/>*.go with gorm tags"] --> ParseG["ParseGormDir<br/>go/ast"]
ENT["ent schemas<br/>Fields() / Edges()"] --> ParseE["ParseEntSchemaDir<br/>go/ast"]
ATLAS["Atlas HCL<br/>schema.hcl"] --> ParseA["ParseAtlasHCL<br/>regex"]
Parse --> T["[]Table"]
ParseC --> T
ParseA --> T
ParseG --> GM["[]GormModel"]
ParseE --> ES["[]EntSchema"]
ParseSqlc --> SQLC["SqlcInfo"]
T --> IR["[]normalizedModel"]
GM --> IR
ES --> IR
SQLC --> IR
IR --> Render["shared blueprint renderer"]
Render --> Out["Go source code<br/>Blueprint registrations"]
SQL DDL, sqlc config, manual sqlc mode, and Atlas HCL still share the common []Table parser output. GORM and ent continue to parse into adapter-specific types ([]GormModel, []EntSchema). Before code generation, each adapter is normalized into the same []normalizedModel IR, including PK metadata, belongs-to relations, and Insert/Delete hook bodies. The final renderer is shared, so adapter-specific logic is isolated to parsing and normalization rather than duplicated template code.
The same normalization path also powers seedling-gen --explain and --json. Diagnostic mode emits both the parser output and the inferred blueprint summary, so relation naming, PK detection, and sqlc query matching can be inspected without reading generated code.
All parsers treat malformed input (unclosed parentheses, mismatched braces) as a hard error rather than returning partial results. When -out is specified, the output is written atomically via a temporary file so that a failure never leaves a partial file on disk.
- Guide -- practical workflows and API usage patterns
- README -- project overview, Quick Start, and comparison table
- pkg.go.dev API reference -- full type and function documentation