From a2ec57cf47cc0d6d8f0be34112d48d09adf8cb8d Mon Sep 17 00:00:00 2001 From: Ben Visness Date: Wed, 11 May 2022 22:24:05 -0500 Subject: [PATCH] Seed projects --- src/db/db.go | 80 +++++++++++++++ src/migration/seed.go | 223 ++++++++++++++++++++++++++++++------------ src/utils/utils.go | 17 ++++ 3 files changed, 259 insertions(+), 61 deletions(-) diff --git a/src/db/db.go b/src/db/db.go index c96ea90e..1035ba60 100644 --- a/src/db/db.go +++ b/src/db/db.go @@ -124,6 +124,22 @@ func Query[T any]( } } +/* +Identical to Query, but panics if there was an error. +*/ +func MustQuery[T any]( + ctx context.Context, + conn ConnOrTx, + query string, + args ...any, +) []*T { + result, err := Query[T](ctx, conn, query, args...) + if err != nil { + panic(err) + } + return result +} + /* Identical to Query, but returns only the first result row. If there are no rows in the result set, returns NotFound. @@ -148,6 +164,22 @@ func QueryOne[T any]( return result, nil } +/* +Identical to QueryOne, but panics if there was an error. +*/ +func MustQueryOne[T any]( + ctx context.Context, + conn ConnOrTx, + query string, + args ...any, +) *T { + result, err := QueryOne[T](ctx, conn, query, args...) + if err != nil { + panic(err) + } + return result +} + /* Identical to Query, but returns concrete values instead of pointers. More convenient for primitive types. @@ -176,6 +208,22 @@ func QueryScalar[T any]( return result, nil } +/* +Identical to QueryScalar, but panics if there was an error. +*/ +func MustQueryScalar[T any]( + ctx context.Context, + conn ConnOrTx, + query string, + args ...any, +) []T { + result, err := QueryScalar[T](ctx, conn, query, args...) + if err != nil { + panic(err) + } + return result +} + /* Identical to QueryScalar, but returns only the first result value. If there are no rows in the result set, returns NotFound. @@ -202,6 +250,22 @@ func QueryOneScalar[T any]( return *result, nil } +/* +Identical to QueryOneScalar, but panics if there was an error. +*/ +func MustQueryOneScalar[T any]( + ctx context.Context, + conn ConnOrTx, + query string, + args ...any, +) T { + result, err := QueryOneScalar[T](ctx, conn, query, args...) + if err != nil { + panic(err) + } + return result +} + /* Identical to Query, but returns the ResultIterator instead of automatically converting the results to a slice. The iterator must be closed after use. */ @@ -249,6 +313,22 @@ func QueryIterator[T any]( return it, nil } +/* +Identical to QueryIterator, but panics if there was an error. +*/ +func MustQueryIterator[T any]( + ctx context.Context, + conn ConnOrTx, + query string, + args ...any, +) *Iterator[T] { + result, err := QueryIterator[T](ctx, conn, query, args...) + if err != nil { + panic(err) + } + return result +} + // TODO: QueryFunc? type compiledQuery struct { diff --git a/src/migration/seed.go b/src/migration/seed.go index c8a497b2..02728a29 100644 --- a/src/migration/seed.go +++ b/src/migration/seed.go @@ -6,6 +6,8 @@ import ( "math/rand" "os" "os/exec" + "strings" + "time" "git.handmade.network/hmn/hmn/src/auth" "git.handmade.network/hmn/hmn/src/config" @@ -13,6 +15,7 @@ import ( "git.handmade.network/hmn/hmn/src/hmndata" "git.handmade.network/hmn/hmn/src/models" "git.handmade.network/hmn/hmn/src/oops" + "git.handmade.network/hmn/hmn/src/parsing" "git.handmade.network/hmn/hmn/src/utils" lorem "github.com/HandmadeNetwork/golorem" "github.com/jackc/pgx/v4" @@ -46,7 +49,7 @@ func SeedFromFile(seedFile string) { // Creates only what's necessary to get the site running. Not really very useful for // local dev on its own; sample data makes things a lot better. -func BareMinimumSeed() { +func BareMinimumSeed() *models.Project { Migrate(LatestVersion()) ctx := context.Background() @@ -55,48 +58,20 @@ func BareMinimumSeed() { }) defer conn.Close(ctx) - tx, err := conn.Begin(ctx) - if err != nil { - panic(err) - } + tx := utils.Must1(conn.Begin(ctx)) defer tx.Rollback(ctx) fmt.Println("Creating HMN project...") - _, err = tx.Exec(ctx, - ` - INSERT INTO project (id, slug, name, blurb, description, personal, lifecycle, color_1, color_2, forum_enabled, blog_enabled, date_created) - VALUES (1, 'hmn', 'Handmade Network', '', '', FALSE, $1, 'ab4c47', 'a5467d', TRUE, TRUE, '2017-01-01T00:00:00Z') - `, - models.ProjectLifecycleActive, - ) - if err != nil { - panic(err) - } + hmn := seedProject(ctx, tx, seedHMN) - fmt.Println("Creating main forum...") - _, err = tx.Exec(ctx, ` - INSERT INTO subforum (id, slug, name, parent_id, project_id) - VALUES (2, '', 'Handmade Network', NULL, 1) - `) - if err != nil { - panic(err) - } - _, err = tx.Exec(ctx, ` - UPDATE project SET forum_id = 2 WHERE slug = 'hmn' - `) - if err != nil { - panic(err) - } + utils.Must0(tx.Commit(ctx)) - err = tx.Commit(ctx) - if err != nil { - panic(err) - } + return hmn } // Seeds the database with sample data for local dev. func SampleSeed() { - BareMinimumSeed() + hmn := BareMinimumSeed() ctx := context.Background() conn := db.NewConnWithConfig(config.PostgresConfig{ @@ -104,10 +79,7 @@ func SampleSeed() { }) defer conn.Close(ctx) - tx, err := conn.Begin(ctx) - if err != nil { - panic(err) - } + tx := utils.Must1(conn.Begin(ctx)) defer tx.Rollback(ctx) fmt.Println("Creating admin user (\"admin\"/\"password\")...") @@ -128,24 +100,48 @@ func SampleSeed() { users := []*models.User{alice, bob, charlie, spammer} - fmt.Println("Creating some forum threads...") - for i := 0; i < 5; i++ { - thread := seedThread(ctx, tx, models.Thread{}) - populateThread(ctx, tx, thread, users, rand.Intn(5)+1) + fmt.Println("Creating starter projects...") + hero := seedProject(ctx, tx, seedHandmadeHero) + fourcoder := seedProject(ctx, tx, seed4coder) + for i := 0; i < 3; i++ { + name := fmt.Sprintf("%s %s", lorem.Word(1, 10), lorem.Word(1, 10)) + slug := strings.ReplaceAll(strings.ToLower(name), " ", "-") + seedProject(ctx, tx, models.Project{ + Slug: slug, + Name: name, + Blurb: lorem.Sentence(6, 16), + Description: lorem.Paragraph(3, 5), + + Personal: true, + }) } + fmt.Println("Creating some forum threads...") + for i := 0; i < 5; i++ { + for _, project := range []*models.Project{hmn, hero, fourcoder} { + thread := seedThread(ctx, tx, project, models.Thread{}) + populateThread(ctx, tx, thread, users, rand.Intn(5)+1) + } + } // spam-only thread { - thread := seedThread(ctx, tx, models.Thread{}) + thread := seedThread(ctx, tx, hmn, models.Thread{}) populateThread(ctx, tx, thread, []*models.User{spammer}, 1) } - fmt.Println("Creating the news posts...") + fmt.Println("Creating news posts...") { + // Main site news posts for i := 0; i < 3; i++ { - thread := seedThread(ctx, tx, models.Thread{Type: models.ThreadTypeProjectBlogPost}) + thread := seedThread(ctx, tx, hmn, models.Thread{Type: models.ThreadTypeProjectBlogPost}) populateThread(ctx, tx, thread, []*models.User{admin, alice, bob, charlie}, rand.Intn(5)+1) } + + // 4coder + for i := 0; i < 5; i++ { + thread := seedThread(ctx, tx, fourcoder, models.Thread{Type: models.ThreadTypeProjectBlogPost}) + populateThread(ctx, tx, thread, []*models.User{bob}, 1) + } } // admin := CreateAdminUser("admin", "12345678") @@ -159,14 +155,11 @@ func SampleSeed() { // Create codelanguages // Create library and library resources - err = tx.Commit(ctx) - if err != nil { - panic(err) - } + utils.Must0(tx.Commit(ctx)) } func seedUser(ctx context.Context, conn db.ConnOrTx, input models.User) *models.User { - user, err := db.QueryOne[models.User](ctx, conn, + user := db.MustQueryOne[models.User](ctx, conn, ` INSERT INTO hmn_user ( username, password, email, @@ -194,24 +187,17 @@ func seedUser(ctx context.Context, conn db.ConnOrTx, input models.User) *models. utils.OrDefault(input.Name, randomName()), utils.OrDefault(input.Bio, lorem.Paragraph(0, 2)), utils.OrDefault(input.Blurb, lorem.Sentence(0, 14)), utils.OrDefault(input.Signature, lorem.Sentence(0, 16)), input.ShowEmail, ) - if err != nil { - panic(err) - } - err = auth.SetPassword(ctx, conn, input.Username, "password") - if err != nil { - panic(err) - } + utils.Must0(auth.SetPassword(ctx, conn, input.Username, "password")) return user } -func seedThread(ctx context.Context, tx pgx.Tx, input models.Thread) *models.Thread { +func seedThread(ctx context.Context, tx pgx.Tx, project *models.Project, input models.Thread) *models.Thread { input.Type = utils.OrDefault(input.Type, models.ThreadTypeForumPost) var defaultSubforum *int if input.Type == models.ThreadTypeForumPost { - id := 2 - defaultSubforum = &id + defaultSubforum = project.ForumID } thread, err := db.QueryOne[models.Thread](ctx, tx, @@ -232,7 +218,7 @@ func seedThread(ctx context.Context, tx pgx.Tx, input models.Thread) *models.Thr `, utils.OrDefault(input.Title, lorem.Sentence(3, 8)), utils.OrDefault(input.Type, models.ThreadTypeForumPost), false, - utils.OrDefault(input.ProjectID, models.HMNProjectID), utils.OrDefault(input.SubforumID, defaultSubforum), + project.ID, utils.OrDefault(input.SubforumID, defaultSubforum), -1, -1, ) if err != nil { @@ -258,6 +244,68 @@ func populateThread(ctx context.Context, tx pgx.Tx, thread *models.Thread, users } } +var latestProjectId int + +func seedProject(ctx context.Context, tx pgx.Tx, input models.Project) *models.Project { + project := db.MustQueryOne[models.Project](ctx, tx, + ` + INSERT INTO project ( + id, + slug, name, blurb, + description, descparsed, + color_1, color_2, + featured, personal, lifecycle, hidden, + forum_enabled, blog_enabled, + date_created + ) + VALUES ( + $1, + $2, $3, $4, + $5, $6, + $7, $8, + $9, $10, $11, $12, + $13, $14, + $15 + ) + RETURNING $columns + `, + utils.OrDefault(input.ID, latestProjectId+1), + input.Slug, input.Name, input.Blurb, + input.Description, parsing.ParseMarkdown(input.Description, parsing.ForumRealMarkdown), + input.Color1, input.Color2, + input.Featured, input.Personal, utils.OrDefault(input.Lifecycle, models.ProjectLifecycleActive), input.Hidden, + input.ForumEnabled, input.BlogEnabled, + utils.OrDefault(input.DateCreated, time.Now()), + ) + latestProjectId = utils.IntMax(latestProjectId, project.ID) + + // Create forum (even if unused) + forum := db.MustQueryOne[models.Subforum](ctx, tx, + ` + INSERT INTO subforum ( + slug, name, + project_id + ) + VALUES ( + $1, $2, + $3 + ) + RETURNING $columns + `, + "", project.Name, + project.ID, + ) + + // Associate forum with project + utils.Must1(tx.Exec(ctx, + `UPDATE project SET forum_id = $1 WHERE id = $2`, + forum.ID, project.ID, + )) + project.ForumID = &forum.ID + + return project +} + func randomName() string { return "John Doe" // chosen by fair dice roll. guaranteed to be random. } @@ -265,3 +313,56 @@ func randomName() string { func randomBool() bool { return rand.Intn(2) == 1 } + +var seedHMN = models.Project{ + ID: models.HMNProjectID, + Slug: models.HMNProjectSlug, + Name: "Handmade Network", + Blurb: "Changing the way software is written", + Description: ` +[project=hero]Originally inspired by Handmade Hero[/project], we're an offshoot of its community, hoping to change the way software is written. To this end we've circulated our [url=https://handmade.network/manifesto]manifesto[/url] and built this website, in the hopes of fostering this community. We invite others to host projects built with same goals in mind and build up or expand their community's reach in our little tree house and hope it proves vibrant soil for the exchange of ideas as well as code. + `, + + Color1: "ab4c47", Color2: "a5467d", + Hidden: true, + ForumEnabled: true, BlogEnabled: true, + + DateCreated: time.Date(2017, 1, 1, 0, 0, 0, 0, time.UTC), +} + +var seedHandmadeHero = models.Project{ + Slug: "hero", + Name: "Handmade Hero", + Blurb: "An ongoing project to create a complete, professional-quality game accompanied by videos that explain every single line of its source code.", + Description: ` +Handmade Hero is an ongoing project by [Casey Muratori](http://mollyrocket.com/casey) to create a complete, professional-quality game accompanied by videos that explain every single line of its source code. The series began on November 17th, 2014, and is estimated to run for at least 600 episodes. Programming sessions are limited to one hour per weekday so it remains manageable for people who practice coding along with the series at home. + +For more information, see the official website at https://handmadehero.org + `, + + Color1: "19328a", Color2: "f1f0a2", + Featured: true, + ForumEnabled: true, BlogEnabled: false, + + DateCreated: time.Date(2017, 1, 10, 0, 0, 0, 0, time.UTC), +} + +var seed4coder = models.Project{ + Slug: "4coder", + Name: "4coder", + Blurb: "A programmable, cross platform, IDE template", + Description: ` +4coder preview video: https://www.youtube.com/watch?v=Nop5UW2kV3I + +4coder differentiates from other editors by focusing on powerful C/C++ customization and extension, and ease of cross platform use. This means that 4coder greatly reduces the cost of creating cross platform development tools such as debuggers, code intelligence systems. It means that tools specialized to your particular needs can be programmed in C/C++ or any language that interfaces with C/C++, which is almost all of them. + +In other words, 4coder is attempting to live in a space between an IDE and a power editor such as Emacs or Vim. + +Want to try it out? [url=https://4coder.itch.io/4coder]Get your alpha build now[/url]! + `, + + Color1: "002107", Color2: "cccccc", + ForumEnabled: true, BlogEnabled: true, + + DateCreated: time.Date(2017, 1, 10, 0, 0, 0, 0, time.UTC), +} diff --git a/src/utils/utils.go b/src/utils/utils.go index bff8cfe3..65a72015 100644 --- a/src/utils/utils.go +++ b/src/utils/utils.go @@ -20,6 +20,23 @@ func OrDefault[T comparable](v T, def T) T { } } +// Takes an (error) return and panics if there is an error. +// Helps avoid `if err != nil` in scripts. Use sparingly in real code. +func Must0(err error) { + if err != nil { + panic(err) + } +} + +// Takes a (something, error) return and panics if there is an error. +// Helps avoid `if err != nil` in scripts. Use sparingly in real code. +func Must1[T any](v T, err error) T { + if err != nil { + panic(err) + } + return v +} + func IntMin(a, b int) int { if a < b { return a