From 2f7b6421aae19d639952cfa87e235ab1d3283fca Mon Sep 17 00:00:00 2001 From: Eyo Chen Date: Sat, 3 Aug 2024 11:26:23 +0800 Subject: [PATCH 1/5] feat: add postgres --- db/postgresf/postgresf.go | 36 ++++++++++++++++++++++++++++++++++++ 1 file changed, 36 insertions(+) create mode 100644 db/postgresf/postgresf.go diff --git a/db/postgresf/postgresf.go b/db/postgresf/postgresf.go new file mode 100644 index 0000000..69b4b63 --- /dev/null +++ b/db/postgresf/postgresf.go @@ -0,0 +1,36 @@ +package postgresf + +import ( + "context" + "database/sql" + "fmt" + + "github.com/eyo-chen/gofacto/db" + "github.com/eyo-chen/gofacto/internal/sqllib" +) + +// NewConfig initializes interface for raw PostgreSQL database operations +func NewConfig(db *sql.DB) db.Database { + return sqllib.NewConfig(db, &postgresDialect{}, "postgresf") +} + +// postgresDialect defines the behavior for PostgreSQL SQL dialect +type postgresDialect struct{} + +func (d *postgresDialect) GenPlaceholder(placeholderIndex int) string { + return fmt.Sprintf("$%d", placeholderIndex) +} + +func (d *postgresDialect) GenInsertStmt(tableName, fieldNames, placeholder string) string { + return fmt.Sprintf("INSERT INTO %s (%s) VALUES (%s) RETURNING id", tableName, fieldNames, placeholder) +} + +func (d *postgresDialect) InsertToDB(ctx context.Context, tx *sql.Tx, stmt *sql.Stmt, vals []interface{}) (int64, error) { + var id int64 + err := tx.Stmt(stmt).QueryRowContext(ctx, vals...).Scan(&id) + if err != nil { + return 0, err + } + + return id, nil +} From 38db5183cc0cb4e8384a445fc44a6f820f4436de Mon Sep 17 00:00:00 2001 From: Eyo Chen Date: Sat, 3 Aug 2024 11:34:20 +0800 Subject: [PATCH 2/5] test: add unit testing --- db/postgresf/postgresf_test.go | 468 +++++++++++++++++++++++++++++++++ db/postgresf/schema.sql | 33 +++ go.mod | 1 + go.sum | 2 + 4 files changed, 504 insertions(+) create mode 100644 db/postgresf/postgresf_test.go create mode 100644 db/postgresf/schema.sql diff --git a/db/postgresf/postgresf_test.go b/db/postgresf/postgresf_test.go new file mode 100644 index 0000000..7510335 --- /dev/null +++ b/db/postgresf/postgresf_test.go @@ -0,0 +1,468 @@ +package postgresf + +import ( + "context" + "database/sql" + "fmt" + "log" + "os" + "strings" + "testing" + "time" + + "github.com/eyo-chen/gofacto" + "github.com/eyo-chen/gofacto/internal/docker" + "github.com/eyo-chen/gofacto/internal/testutils" + _ "github.com/lib/pq" +) + +var ( + mockCTX = context.Background() +) + +type Author struct { + ID int64 + FirstName string + LastName string + BirthDate *time.Time + Nationality *string + Email *string + Biography *string + IsActive bool + Rating *float64 + BooksWritten *int32 + LastPublicationTime time.Time + WebsiteURL *string + FanCount *int64 + ProfilePicture []byte +} + +type Book struct { + ID int64 + AuthorID int64 `gofacto:"Author,authors"` + Title string + ISBN *string + PublicationDate *time.Time + Genre *string + Price *float64 + PageCount *int32 + Description *string + InStock bool + CoverImage []byte + CreatedAt time.Time + UpdatedAt time.Time +} + +type testingSuite struct { + db *sql.DB + authorF *gofacto.Factory[Author] + bookF *gofacto.Factory[Book] +} + +func (s *testingSuite) setupSuite() { + // Start PostgreSQL Docker container + port := docker.RunDocker(docker.ImagePostgres) + dba, err := sql.Open("postgres", fmt.Sprintf("postgres://postgres:postgres@localhost:%s/postgres?sslmode=disable", port)) + if err != nil { + log.Fatalf("sql.Open failed: %s", err) + } + + // Set the database connection + s.db = dba + + // Read SQL file + schema, err := os.ReadFile("schema.sql") + if err != nil { + log.Fatalf("Failed to read schema.sql: %s", err) + } + + // Split SQL file content into individual statements + queries := strings.Split(string(schema), ";") + + // Execute SQL statements one by one + for _, query := range queries { + query = strings.TrimSpace(query) + if query == "" { + continue + } + if _, err := dba.Exec(query); err != nil { + log.Fatalf("Failed to execute query: %s, error: %s", query, err) + } + } + + // Set up gofacto factories + s.authorF = gofacto.New(Author{}).SetConfig(gofacto.Config[Author]{ + DB: NewConfig(s.db), + }) + s.bookF = gofacto.New(Book{}).SetConfig(gofacto.Config[Book]{ + DB: NewConfig(s.db), + }) +} + +func (s *testingSuite) tearDownSuite() error { + if err := s.db.Close(); err != nil { + return err + } + + docker.PurgeDocker() + + return nil +} + +func (s *testingSuite) tearDownTest() error { + if _, err := s.db.Exec("DELETE FROM authors"); err != nil { + return err + } + if _, err := s.db.Exec("DELETE FROM books"); err != nil { + return err + } + + s.authorF.Reset() + s.bookF.Reset() + + return nil +} + +func (s *testingSuite) Run(t *testing.T) { + tests := []struct { + name string + fn func(*testing.T) + }{ + {"TestInsert", s.TestInsert}, + {"TestInsertList", s.TestInsertList}, + {"TestWithOne", s.TestWithOne}, + {"TestWithMany", s.TestWithMany}, + {"TestListWithOne", s.TestListWithOne}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + test.fn(t) + if err := s.tearDownTest(); err != nil { + t.Fatalf("Failed to tear down test: %s", err) + } + }) + } +} + +func TestSQLF(t *testing.T) { + s := testingSuite{} + s.setupSuite() + defer func() { + if err := s.tearDownSuite(); err != nil { + t.Fatalf("Failed to tear down suite: %s", err) + } + }() + + s.Run(t) +} + +func (s *testingSuite) TestInsert(t *testing.T) { + // prepare mock data + mockAuthor, err := s.authorF.Build(mockCTX).Insert() + if err != nil { + t.Fatalf("Failed to insert author: %s", err) + } + + // prepare expected data + stmt := "SELECT * FROM authors WHERE id = $1" + row := s.db.QueryRow(stmt, mockAuthor.ID) + var author Author + if err := row.Scan( + &author.ID, + &author.FirstName, + &author.LastName, + &author.BirthDate, + &author.Nationality, + &author.Email, + &author.Biography, + &author.IsActive, + &author.Rating, + &author.BooksWritten, + &author.LastPublicationTime, + &author.WebsiteURL, + &author.FanCount, + &author.ProfilePicture, + ); err != nil { + t.Fatalf("Failed to scan author: %s", err) + } + + // assertion + if err := testutils.CompareVal(mockAuthor, author, "BirthDate", "LastPublicationTime"); err != nil { + t.Fatalf("Inserted author is not the same as the mock author: %s", err) + } +} + +func (s *testingSuite) TestInsertList(t *testing.T) { + // prepare mock data + mockAuthors, err := s.authorF.BuildList(mockCTX, 3).Insert() + if err != nil { + t.Fatalf("Failed to insert authors: %s", err) + } + + // prepare expected data + stmt := "SELECT * FROM authors WHERE id IN ($1, $2, $3)" + rows, err := s.db.Query(stmt, mockAuthors[0].ID, mockAuthors[1].ID, mockAuthors[2].ID) + if err != nil { + t.Fatalf("Failed to query authors: %s", err) + } + defer rows.Close() + + var authors []Author + for rows.Next() { + var author Author + if err := rows.Scan( + &author.ID, + &author.FirstName, + &author.LastName, + &author.BirthDate, + &author.Nationality, + &author.Email, + &author.Biography, + &author.IsActive, + &author.Rating, + &author.BooksWritten, + &author.LastPublicationTime, + &author.WebsiteURL, + &author.FanCount, + &author.ProfilePicture, + ); err != nil { + t.Fatalf("Failed to scan author: %s", err) + } + + authors = append(authors, author) + } + + // assertion + if err := testutils.CompareVal(mockAuthors, authors, "BirthDate", "LastPublicationTime"); err != nil { + t.Fatalf("Inserted authors are not the same as the mock authors: %s", err) + } +} + +func (s *testingSuite) TestWithOne(t *testing.T) { + // prepare mock data + mockAuthor := Author{} + mockGenre := "Science" + ow := Book{Genre: &mockGenre} // set correct enum value + mockBook, err := s.bookF.Build(mockCTX).Overwrite(ow).WithOne(&mockAuthor).Insert() + if err != nil { + t.Fatalf("Failed to insert: %s", err) + } + + // prepare expected data + bookStmt := "SELECT * FROM books WHERE author_id = $1" + bookRow := s.db.QueryRow(bookStmt, mockBook.AuthorID) + var book Book + if err := bookRow.Scan( + &book.ID, + &book.AuthorID, + &book.Title, + &book.ISBN, + &book.PublicationDate, + &book.Genre, + &book.Price, + &book.PageCount, + &book.Description, + &book.InStock, + &book.CoverImage, + &book.CreatedAt, + &book.UpdatedAt, + ); err != nil { + t.Fatalf("Failed to scan book: %s", err) + } + + // trim the value of CHAR(13) + // when selecting the value of CHAR(13) from postgres, it will include the padding space + trimStr := strings.TrimSpace(*book.ISBN) + book.ISBN = &trimStr + + authorStmt := "SELECT * FROM authors WHERE id = $1" + authorRow := s.db.QueryRow(authorStmt, mockBook.AuthorID) + var author Author + if err := authorRow.Scan( + &author.ID, + &author.FirstName, + &author.LastName, + &author.BirthDate, + &author.Nationality, + &author.Email, + &author.Biography, + &author.IsActive, + &author.Rating, + &author.BooksWritten, + &author.LastPublicationTime, + &author.WebsiteURL, + &author.FanCount, + &author.ProfilePicture, + ); err != nil { + t.Fatalf("Failed to scan author: %s", err) + } + + // assertion + if err := testutils.CompareVal(mockBook, book, "PublicationDate", "CreatedAt", "UpdatedAt"); err != nil { + t.Fatalf("Inserted book is not the same as the mock book: %s", err) + } + + if err := testutils.CompareVal(mockAuthor, author, "BirthDate", "LastPublicationTime"); err != nil { + t.Fatalf("Inserted author is not the same as the mock author: %s", err) + } +} + +func (s *testingSuite) TestWithMany(t *testing.T) { + // prepare mock data + mockAnyAuthors := make([]interface{}, 3) + for i := 0; i < 3; i++ { + mockAnyAuthors[i] = &Author{} + } + mockGenre := "Science" + ow := Book{Genre: &mockGenre} // set correct enum value + mockBooks, err := s.bookF.BuildList(mockCTX, 3).Overwrite(ow).WithMany(mockAnyAuthors).Insert() + if err != nil { + t.Fatalf("Failed to insert books: %s", err) + } + + mockAuthors := make([]Author, 3) + for i := 0; i < 3; i++ { + mockAuthors[i] = *mockAnyAuthors[i].(*Author) + } + + // prepare expected data + bookStmt := "SELECT * FROM books WHERE author_id = $1" + authorStmt := "SELECT * FROM authors WHERE id = $1" + + // loop through each data to check association connection + for i := 0; i < 3; i++ { + bookRow := s.db.QueryRow(bookStmt, mockBooks[i].AuthorID) + var book Book + if err := bookRow.Scan( + &book.ID, + &book.AuthorID, + &book.Title, + &book.ISBN, + &book.PublicationDate, + &book.Genre, + &book.Price, + &book.PageCount, + &book.Description, + &book.InStock, + &book.CoverImage, + &book.CreatedAt, + &book.UpdatedAt, + ); err != nil { + t.Fatalf("Failed to scan book: %s", err) + } + + // trim the value of CHAR(13) + // when selecting the value of CHAR(13) from postgres, it will include the padding space + trimStr := strings.TrimSpace(*book.ISBN) + book.ISBN = &trimStr + + authorRow := s.db.QueryRow(authorStmt, mockBooks[i].AuthorID) + var author Author + if err := authorRow.Scan( + &author.ID, + &author.FirstName, + &author.LastName, + &author.BirthDate, + &author.Nationality, + &author.Email, + &author.Biography, + &author.IsActive, + &author.Rating, + &author.BooksWritten, + &author.LastPublicationTime, + &author.WebsiteURL, + &author.FanCount, + &author.ProfilePicture, + ); err != nil { + t.Fatalf("Failed to scan author: %s", err) + } + + // assertion + if err := testutils.CompareVal(mockBooks[i], book, "PublicationDate", "CreatedAt", "UpdatedAt"); err != nil { + t.Fatalf("Inserted book is not the same as the mock book: %s", err) + } + + if err := testutils.CompareVal(mockAuthors[i], author, "BirthDate", "LastPublicationTime"); err != nil { + t.Fatalf("Inserted author is not the same as the mock author: %s", err) + } + } +} + +func (s *testingSuite) TestListWithOne(t *testing.T) { + // prepare mock data + mockAuthor := Author{} + mockGenre := "Science" + ow := Book{Genre: &mockGenre} // set correct enum value + mockBooks, err := s.bookF.BuildList(mockCTX, 3).Overwrite(ow).WithOne(&mockAuthor).Insert() + if err != nil { + t.Fatalf("Failed to insert books: %s", err) + } + + // verify the inserted data + bookStmt := "SELECT * FROM books WHERE author_id = $1" + bookRows, err := s.db.Query(bookStmt, mockBooks[0].AuthorID) + if err != nil { + t.Fatalf("Failed to query books: %s", err) + } + + var books []Book + for bookRows.Next() { + var book Book + if err := bookRows.Scan( + &book.ID, + &book.AuthorID, + &book.Title, + &book.ISBN, + &book.PublicationDate, + &book.Genre, + &book.Price, + &book.PageCount, + &book.Description, + &book.InStock, + &book.CoverImage, + &book.CreatedAt, + &book.UpdatedAt, + ); err != nil { + t.Fatalf("Failed to scan book: %s", err) + } + + // trim the value of CHAR(13) + // when selecting the value of CHAR(13) from postgres, it will include the padding space + trimStr := strings.TrimSpace(*book.ISBN) + book.ISBN = &trimStr + + books = append(books, book) + } + + authorStmt := "SELECT * FROM authors WHERE id = $1" + authorRow := s.db.QueryRow(authorStmt, mockBooks[0].AuthorID) + var author Author + if err := authorRow.Scan( + &author.ID, + &author.FirstName, + &author.LastName, + &author.BirthDate, + &author.Nationality, + &author.Email, + &author.Biography, + &author.IsActive, + &author.Rating, + &author.BooksWritten, + &author.LastPublicationTime, + &author.WebsiteURL, + &author.FanCount, + &author.ProfilePicture, + ); err != nil { + t.Fatalf("Failed to scan author: %s", err) + } + + // assertion + if err := testutils.CompareVal(mockBooks, books, "PublicationDate", "CreatedAt", "UpdatedAt"); err != nil { + t.Fatalf("Inserted books are not the same as the mock books: %s", err) + } + + if err := testutils.CompareVal(mockAuthor, author, "BirthDate", "LastPublicationTime"); err != nil { + t.Fatalf("Inserted author is not the same as the mock author: %s", err) + } +} diff --git a/db/postgresf/schema.sql b/db/postgresf/schema.sql new file mode 100644 index 0000000..7499208 --- /dev/null +++ b/db/postgresf/schema.sql @@ -0,0 +1,33 @@ +CREATE TABLE IF NOT EXISTS authors ( + id SERIAL PRIMARY KEY, + first_name VARCHAR(50) NOT NULL, + last_name VARCHAR(50) NOT NULL, + birth_date DATE, + nationality VARCHAR(50), + email VARCHAR(100) UNIQUE, + biography TEXT, + is_active BOOLEAN DEFAULT TRUE, + rating NUMERIC(3,2), + books_written INTEGER, + last_publication_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + website_url VARCHAR(255), + fan_count BIGINT, + profile_picture BYTEA +); + +CREATE TABLE IF NOT EXISTS books ( + id SERIAL PRIMARY KEY, + author_id INTEGER, + title VARCHAR(255) NOT NULL, + isbn CHAR(13) UNIQUE, + publication_date DATE, + genre TEXT CHECK (genre IN ('Fiction', 'Non-Fiction', 'Science', 'History', 'Biography', 'Other')), + price NUMERIC(10,2), + page_count SMALLINT, + description TEXT, + in_stock BOOLEAN DEFAULT TRUE, + cover_image BYTEA, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (author_id) REFERENCES authors(id) ON DELETE SET NULL +); \ No newline at end of file diff --git a/go.mod b/go.mod index 889a6cd..b3850c3 100644 --- a/go.mod +++ b/go.mod @@ -23,6 +23,7 @@ require ( github.com/jinzhu/inflection v1.0.0 // indirect github.com/jinzhu/now v1.1.5 // indirect github.com/klauspost/compress v1.13.6 // indirect + github.com/lib/pq v1.10.9 // indirect github.com/mitchellh/mapstructure v1.5.0 // indirect github.com/moby/docker-image-spec v1.3.1 // indirect github.com/moby/term v0.5.0 // indirect diff --git a/go.sum b/go.sum index dc14c27..06001a0 100644 --- a/go.sum +++ b/go.sum @@ -48,6 +48,8 @@ github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= github.com/klauspost/compress v1.13.6 h1:P76CopJELS0TiO2mebmnzgWaajssP/EszplttgQxcgc= github.com/klauspost/compress v1.13.6/go.mod h1:/3/Vjq9QcHkK5uEr5lBEmyoZ1iFhe47etQ6QUkpK6sk= +github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= +github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0= From 75370cb3e0b988126cb0c261638ae313f3cea9b9 Mon Sep 17 00:00:00 2001 From: Eyo Chen Date: Sat, 3 Aug 2024 11:37:15 +0800 Subject: [PATCH 3/5] refactor: correct naming --- db/postgresf/postgresf_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/db/postgresf/postgresf_test.go b/db/postgresf/postgresf_test.go index 7510335..ce5c50f 100644 --- a/db/postgresf/postgresf_test.go +++ b/db/postgresf/postgresf_test.go @@ -145,7 +145,7 @@ func (s *testingSuite) Run(t *testing.T) { } } -func TestSQLF(t *testing.T) { +func TestPostgresf(t *testing.T) { s := testingSuite{} s.setupSuite() defer func() { From 3c649c12b63f7515f6971187bf8aa6d328d33c41 Mon Sep 17 00:00:00 2001 From: Eyo Chen Date: Sat, 3 Aug 2024 11:39:25 +0800 Subject: [PATCH 4/5] temp: make test failed --- db/postgresf/postgresf_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/db/postgresf/postgresf_test.go b/db/postgresf/postgresf_test.go index ce5c50f..9898718 100644 --- a/db/postgresf/postgresf_test.go +++ b/db/postgresf/postgresf_test.go @@ -188,7 +188,7 @@ func (s *testingSuite) TestInsert(t *testing.T) { } // assertion - if err := testutils.CompareVal(mockAuthor, author, "BirthDate", "LastPublicationTime"); err != nil { + if err := testutils.CompareVal(mockAuthor, author, "LastPublicationTime"); err != nil { t.Fatalf("Inserted author is not the same as the mock author: %s", err) } } From 3da3edaeb288d7c7e6de3c4f947a046afa230f3b Mon Sep 17 00:00:00 2001 From: Eyo Chen Date: Sat, 3 Aug 2024 11:41:01 +0800 Subject: [PATCH 5/5] fix: correct failed testing --- db/postgresf/postgresf_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/db/postgresf/postgresf_test.go b/db/postgresf/postgresf_test.go index 9898718..ce5c50f 100644 --- a/db/postgresf/postgresf_test.go +++ b/db/postgresf/postgresf_test.go @@ -188,7 +188,7 @@ func (s *testingSuite) TestInsert(t *testing.T) { } // assertion - if err := testutils.CompareVal(mockAuthor, author, "LastPublicationTime"); err != nil { + if err := testutils.CompareVal(mockAuthor, author, "BirthDate", "LastPublicationTime"); err != nil { t.Fatalf("Inserted author is not the same as the mock author: %s", err) } }