diff --git a/internal/cli/app.go b/internal/cli/app.go index f065a3d..51d2c48 100644 --- a/internal/cli/app.go +++ b/internal/cli/app.go @@ -73,16 +73,12 @@ func runGenerate(ctx context.Context, cmd *cli.Command, l logger.Logger, targets dryRun := cmd.Bool("dry-run") debug := cmd.Bool("debug") orch := codegen.NewOrchestrator(l, cfg, configPath) - results, err := orch.Generate(ctx, codegen.GenerateOpts{ + _, err = orch.Generate(ctx, codegen.GenerateOpts{ DryRun: dryRun, Debug: debug, Targets: targets, }) - if err != nil { - return err - } - - return codegen.WriteResults(results, dryRun) + return err } func newGenerateCmd(l logger.Logger) *cli.Command { diff --git a/internal/codegen/orchestrator.go b/internal/codegen/orchestrator.go index 1ecebcb..6ff1473 100644 --- a/internal/codegen/orchestrator.go +++ b/internal/codegen/orchestrator.go @@ -107,6 +107,15 @@ func (o *Orchestrator) Generate(ctx context.Context, opts GenerateOpts) ([]Gener debugf("generate custom queries: %s (%d files)", time.Since(stepStart), len(results)) } + // Flush CRUD + custom-query results to disk now so sqlc can read them. + // sqlc parses query files from disk, so they must exist before sqlc runs. + if err := WriteResults(allResults, opts.DryRun); err != nil { + return nil, fmt.Errorf("write crud results for schema %s: %w", schema.Name, err) + } + for i := range allResults { + allResults[i].Action = ActionUnchanged + } + // 2. Generate models (reuses parsed catalog + sqlc config for overrides/defaults) if o.shouldRun("models", opts) && schema.Models != nil { stepStart := time.Now() @@ -118,8 +127,9 @@ func (o *Orchestrator) Generate(ctx context.Context, opts GenerateOpts) ([]Gener debugf("generate models: %s", time.Since(stepStart)) } - // 3. Run sqlc - if o.shouldRun("sqlc", opts) && schema.Sqlc != nil { + // 3. Run sqlc — skipped in dry-run because sqlc has no preview mode and + // its inputs (CRUD SQL) were not written to disk. + if !opts.DryRun && o.shouldRun("sqlc", opts) && schema.Sqlc != nil { stepStart := time.Now() gen := sqlcgen.NewGenerator(o.logger, o.configDir) if err := gen.Run(&schema); err != nil { diff --git a/internal/codegen/orchestrator_test.go b/internal/codegen/orchestrator_test.go new file mode 100644 index 0000000..0cf6470 --- /dev/null +++ b/internal/codegen/orchestrator_test.go @@ -0,0 +1,138 @@ +package codegen + +import ( + "context" + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/tkcrm/pgxgen/internal/config" + "github.com/tkcrm/pgxgen/pkg/logger" +) + +const testSchemaSQL = `CREATE TABLE users ( + id TEXT PRIMARY KEY, + email TEXT NOT NULL, + name TEXT NOT NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP +); +` + +// setupTestProject writes a minimal SQL schema to a temp dir and returns +// (configDir, configPath). The configPath file itself is not written — +// tests construct V2Config in code and pass configPath only so the +// orchestrator can derive configDir from it. +func setupTestProject(t *testing.T) (string, string) { + t.Helper() + tmp := t.TempDir() + migrationsDir := filepath.Join(tmp, "sql", "migrations") + require.NoError(t, os.MkdirAll(migrationsDir, 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(migrationsDir, "001_init.sql"), []byte(testSchemaSQL), 0o644)) + return tmp, filepath.Join(tmp, "pgxgen.yaml") +} + +func newTestConfig(withSqlc bool) *config.V2Config { + cfg := &config.V2Config{ + Version: "2", + Schemas: []config.SchemaConfig{ + { + Name: "main", + Engine: "sqlite", + SchemaDir: "sql/migrations", + Defaults: &config.DefaultsConfig{ + QueriesDirPrefix: "sql/queries", + OutputDirPrefix: "internal/store/repos", + Crud: &config.CrudDefaultsConfig{ + Methods: map[string]*config.MethodConfig{ + "get": {}, + "delete": {}, + }, + }, + }, + Tables: map[string]config.TableConfig{ + "users": { + PrimaryColumn: "id", + Crud: &config.TableCrudConfig{ + Methods: map[string]*config.MethodConfig{ + "get": {}, + "delete": {}, + }, + }, + }, + }, + }, + }, + } + if withSqlc { + cfg.Schemas[0].Sqlc = &config.SqlcConfig{ + Defaults: &config.SqlcDefaultsConfig{ + SqlPackage: "database/sql", + EmitInterface: true, + }, + } + } + return cfg +} + +// TestGenerate_CrudWrittenBeforeSqlc is the regression test for the bug +// where sqlc was invoked before CRUD SQL files reached disk. With the +// fix, Generate writes CRUD results before running sqlc, so sqlc finds +// its inputs on a fresh project (no pre-existing query directories). +func TestGenerate_CrudWrittenBeforeSqlc(t *testing.T) { + tmp, configPath := setupTestProject(t) + cfg := newTestConfig(true) + + queriesPath := filepath.Join(tmp, "sql", "queries", "users") + require.NoDirExists(t, queriesPath, "queries dir must not pre-exist — this is the fresh-project scenario") + + orch := NewOrchestrator(logger.New(), cfg, configPath) + _, err := orch.Generate(context.Background(), GenerateOpts{}) + require.NoError(t, err, "Generate must succeed on a fresh project") + + assert.FileExists(t, filepath.Join(queriesPath, "users_gen.sql"), "CRUD SQL must be written") + + sqlcOutDir := filepath.Join(tmp, "internal", "store", "repos", "users") + assert.DirExists(t, sqlcOutDir, "sqlc must have produced its output directory") + + entries, err := os.ReadDir(sqlcOutDir) + require.NoError(t, err) + var goFiles []string + for _, e := range entries { + if filepath.Ext(e.Name()) == ".go" { + goFiles = append(goFiles, e.Name()) + } + } + assert.NotEmpty(t, goFiles, "sqlc must have generated at least one .go file in %s", sqlcOutDir) +} + +// TestGenerate_DryRunSkipsWritesAndSqlc verifies that dry-run touches +// nothing on disk and does not invoke sqlc (which has no preview mode). +func TestGenerate_DryRunSkipsWritesAndSqlc(t *testing.T) { + tmp, configPath := setupTestProject(t) + cfg := newTestConfig(true) + + orch := NewOrchestrator(logger.New(), cfg, configPath) + _, err := orch.Generate(context.Background(), GenerateOpts{DryRun: true}) + require.NoError(t, err) + + assert.NoDirExists(t, filepath.Join(tmp, "sql", "queries"), "dry-run must not write CRUD files") + assert.NoDirExists(t, filepath.Join(tmp, "internal"), "dry-run must not invoke sqlc") + assert.NoDirExists(t, filepath.Join(tmp, ".pgxgen"), "dry-run must not write sqlc.yaml") +} + +// TestGenerate_CrudOnlyTargetSkipsSqlc verifies the crud-only target +// writes CRUD files and does not invoke sqlc. +func TestGenerate_CrudOnlyTargetSkipsSqlc(t *testing.T) { + tmp, configPath := setupTestProject(t) + cfg := newTestConfig(true) + + orch := NewOrchestrator(logger.New(), cfg, configPath) + _, err := orch.Generate(context.Background(), GenerateOpts{Targets: []string{"crud"}}) + require.NoError(t, err) + + assert.FileExists(t, filepath.Join(tmp, "sql", "queries", "users", "users_gen.sql")) + assert.NoDirExists(t, filepath.Join(tmp, "internal"), "sqlc must not run when target is crud only") + assert.NoFileExists(t, filepath.Join(tmp, ".pgxgen", "sqlc.yaml")) +}