diff --git a/graph/database.graphqls b/graph/database.graphqls new file mode 100644 index 0000000..e19f768 --- /dev/null +++ b/graph/database.graphqls @@ -0,0 +1,12 @@ +extend type Database { + structure: DatabaseStructure! +} + +type DatabaseStructure { + tables: [DatabaseTable!]! +} + +type DatabaseTable { + name: String! + columns: [String!]! +} diff --git a/graph/database.resolvers.go b/graph/database.resolvers.go new file mode 100644 index 0000000..ce45519 --- /dev/null +++ b/graph/database.resolvers.go @@ -0,0 +1,31 @@ +package graph + +// This file will be automatically regenerated based on the schema, any resolver implementations +// will be copied through when generating and any unknown code will be moved to the end. +// Code generated by github.com/99designs/gqlgen version v0.17.80 + +import ( + "context" + + "github.com/database-playground/backend-v2/ent" + "github.com/database-playground/backend-v2/graph/model" + "github.com/database-playground/backend-v2/internal/sqlrunner" + "github.com/samber/lo" +) + +// Structure is the resolver for the structure field. +func (r *databaseResolver) Structure(ctx context.Context, obj *ent.Database) (*model.DatabaseStructure, error) { + structure, err := r.sqlrunner.GetDatabaseStructure(ctx, obj.Schema) + if err != nil { + return nil, err + } + + return &model.DatabaseStructure{ + Tables: lo.Map(structure.Tables, func(table sqlrunner.DatabaseTable, _ int) *model.DatabaseTable { + return &model.DatabaseTable{ + Name: table.Name, + Columns: table.Columns, + } + }), + }, nil +} diff --git a/graph/ent.resolvers.go b/graph/ent.resolvers.go index 2928817..8a84a79 100644 --- a/graph/ent.resolvers.go +++ b/graph/ent.resolvers.go @@ -2,7 +2,7 @@ package graph // This file will be automatically regenerated based on the schema, any resolver implementations // will be copied through when generating and any unknown code will be moved to the end. -// Code generated by github.com/99designs/gqlgen version v0.17.79 +// Code generated by github.com/99designs/gqlgen version v0.17.80 import ( "context" @@ -82,6 +82,9 @@ func (r *queryResolver) Users(ctx context.Context, after *entgql.Cursor[int], fi return entClient.User.Query().Paginate(ctx, after, first, before, last, ent.WithUserOrder(orderBy), ent.WithUserFilter(where.Filter)) } +// Database returns DatabaseResolver implementation. +func (r *Resolver) Database() DatabaseResolver { return &databaseResolver{r} } + // Query returns QueryResolver implementation. func (r *Resolver) Query() QueryResolver { return &queryResolver{r} } @@ -91,8 +94,7 @@ func (r *Resolver) Question() QuestionResolver { return &questionResolver{r} } // User returns UserResolver implementation. func (r *Resolver) User() UserResolver { return &userResolver{r} } -type ( - queryResolver struct{ *Resolver } - questionResolver struct{ *Resolver } - userResolver struct{ *Resolver } -) +type databaseResolver struct{ *Resolver } +type queryResolver struct{ *Resolver } +type questionResolver struct{ *Resolver } +type userResolver struct{ *Resolver } diff --git a/graph/event.resolvers.go b/graph/event.resolvers.go index 7b21615..70691c0 100644 --- a/graph/event.resolvers.go +++ b/graph/event.resolvers.go @@ -2,7 +2,7 @@ package graph // This file will be automatically regenerated based on the schema, any resolver implementations // will be copied through when generating and any unknown code will be moved to the end. -// Code generated by github.com/99designs/gqlgen version v0.17.79 +// Code generated by github.com/99designs/gqlgen version v0.17.80 import ( "context" diff --git a/graph/model/models_gen.go b/graph/model/models_gen.go index bc13d1a..5d35886 100644 --- a/graph/model/models_gen.go +++ b/graph/model/models_gen.go @@ -7,6 +7,15 @@ import ( "github.com/database-playground/backend-v2/models" ) +type DatabaseStructure struct { + Tables []*DatabaseTable `json:"tables"` +} + +type DatabaseTable struct { + Name string `json:"name"` + Columns []string `json:"columns"` +} + // Filter for scope sets. // // The filters are mutually exclusive, only one of them can be provided. diff --git a/graph/question.graphqls b/graph/question.graphqls index 910e0c8..0d9865b 100644 --- a/graph/question.graphqls +++ b/graph/question.graphqls @@ -64,10 +64,15 @@ extend type Question { referenceAnswerResult: SQLExecutionResult! @scope(scope: "question:read") """ - List of your submissions for this question. + List of your submissions for this question, ordered by submitted at descending. """ userSubmissions: [Submission!]! + """ + Get the last submission for this question. + """ + lastSubmission: Submission + """ Have you tried to solve the question? """ diff --git a/graph/question.resolvers.go b/graph/question.resolvers.go index 97e42c7..e210756 100644 --- a/graph/question.resolvers.go +++ b/graph/question.resolvers.go @@ -2,13 +2,14 @@ package graph // This file will be automatically regenerated based on the schema, any resolver implementations // will be copied through when generating and any unknown code will be moved to the end. -// Code generated by github.com/99designs/gqlgen version v0.17.79 +// Code generated by github.com/99designs/gqlgen version v0.17.80 import ( "context" "errors" "fmt" + "entgo.io/ent/dialect/sql" "github.com/database-playground/backend-v2/ent" entQuestion "github.com/database-playground/backend-v2/ent/question" entSubmission "github.com/database-playground/backend-v2/ent/submission" @@ -205,7 +206,7 @@ func (r *questionResolver) UserSubmissions(ctx context.Context, obj *ent.Questio submissions, err := obj.QuerySubmissions().Where( entSubmission.HasUserWith(user.ID(tokenInfo.UserID)), - ).All(ctx) + ).Order(entSubmission.BySubmittedAt(sql.OrderDesc())).All(ctx) if err != nil { return nil, err } @@ -213,6 +214,27 @@ func (r *questionResolver) UserSubmissions(ctx context.Context, obj *ent.Questio return submissions, nil } +// LastSubmission is the resolver for the lastSubmission field. +func (r *questionResolver) LastSubmission(ctx context.Context, obj *ent.Question) (*ent.Submission, error) { + tokenInfo, ok := auth.GetUser(ctx) + if !ok { + return nil, defs.ErrUnauthorized + } + + submission, err := obj.QuerySubmissions().Where( + entSubmission.HasUserWith(user.ID(tokenInfo.UserID)), + ).Order(entSubmission.BySubmittedAt(sql.OrderDesc())).First(ctx) + if err != nil { + if ent.IsNotFound(err) { + return nil, nil + } + + return nil, err + } + + return submission, nil +} + // Attempted is the resolver for the attempted field. func (r *questionResolver) Attempted(ctx context.Context, obj *ent.Question) (bool, error) { tokenInfo, ok := auth.GetUser(ctx) diff --git a/graph/user.resolvers.go b/graph/user.resolvers.go index 445ae59..c31da74 100644 --- a/graph/user.resolvers.go +++ b/graph/user.resolvers.go @@ -2,7 +2,7 @@ package graph // This file will be automatically regenerated based on the schema, any resolver implementations // will be copied through when generating and any unknown code will be moved to the end. -// Code generated by github.com/99designs/gqlgen version v0.17.79 +// Code generated by github.com/99designs/gqlgen version v0.17.80 import ( "context" diff --git a/internal/sqlrunner/models.go b/internal/sqlrunner/models.go index 26fb5b4..9d4d154 100644 --- a/internal/sqlrunner/models.go +++ b/internal/sqlrunner/models.go @@ -45,3 +45,14 @@ type ErrorResponse struct { func (e ErrorResponse) Error() string { return fmt.Sprintf("%s: %s", e.Code, e.Message) } + +// DatabaseStructure is the database structure of a schema. +type DatabaseStructure struct { + Tables []DatabaseTable `json:"tables"` +} + +// DatabaseTable is the table structure of a schema. +type DatabaseTable struct { + Name string `json:"name"` + Columns []string `json:"columns"` +} diff --git a/internal/sqlrunner/sqlrunner.go b/internal/sqlrunner/sqlrunner.go index 47d32e1..f67c082 100644 --- a/internal/sqlrunner/sqlrunner.go +++ b/internal/sqlrunner/sqlrunner.go @@ -66,6 +66,50 @@ func (s *SqlRunner) Query(ctx context.Context, schema, query string) (DataRespon return respBody.Data, nil } +func (s *SqlRunner) GetDatabaseStructure(ctx context.Context, schema string) (DatabaseStructure, error) { + // Query SQLite's master table to get all table names + tablesQuery := "SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%' ORDER BY name" + tablesResp, err := s.Query(ctx, schema, tablesQuery) + if err != nil { + return DatabaseStructure{}, fmt.Errorf("failed to query tables: %w", err) + } + + var tables []DatabaseTable + + // For each table, get its column information + for _, row := range tablesResp.Rows { + if len(row) == 0 { + continue + } + tableName := row[0] + + // Use PRAGMA table_info to get column information + columnsQuery := fmt.Sprintf("PRAGMA table_info(%s)", tableName) + columnsResp, err := s.Query(ctx, schema, columnsQuery) + if err != nil { + return DatabaseStructure{}, fmt.Errorf("query columns for table %s: %w", tableName, err) + } + + var columns []string + // PRAGMA table_info returns: cid, name, type, notnull, dflt_value, pk + // We only need the name (index 1) + for _, columnRow := range columnsResp.Rows { + if len(columnRow) > 1 { + columns = append(columns, columnRow[1]) + } + } + + tables = append(tables, DatabaseTable{ + Name: tableName, + Columns: columns, + }) + } + + return DatabaseStructure{ + Tables: tables, + }, nil +} + func (s *SqlRunner) IsHealthy(ctx context.Context) bool { req, err := http.NewRequestWithContext(ctx, http.MethodGet, fmt.Sprintf("%s/healthz", s.cfg.URI), nil) if err != nil { diff --git a/internal/sqlrunner/sqlrunner_test.go b/internal/sqlrunner/sqlrunner_test.go index b0e59a1..c7f228c 100644 --- a/internal/sqlrunner/sqlrunner_test.go +++ b/internal/sqlrunner/sqlrunner_test.go @@ -61,3 +61,152 @@ func TestQuery_SchemaError(t *testing.T) { t.Errorf("Expected SCHEMA_ERROR, got %v", errResp.Code) } } + +func TestGetDatabaseStructure_Success(t *testing.T) { + s := testhelper.NewSQLRunnerClient(t) + + // Create a schema with multiple tables and columns + schema := ` + CREATE TABLE users ( + id INTEGER PRIMARY KEY, + name TEXT NOT NULL, + email TEXT UNIQUE + ); + CREATE TABLE posts ( + id INTEGER PRIMARY KEY, + title TEXT NOT NULL, + content TEXT, + user_id INTEGER, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP + ); + CREATE TABLE categories ( + id INTEGER PRIMARY KEY, + name TEXT NOT NULL + ); + ` + + structure, err := s.GetDatabaseStructure(context.Background(), schema) + if err != nil { + t.Fatalf("Expected success, got error: %v", err) + } + + // Verify we have the expected number of tables + if len(structure.Tables) != 3 { + t.Errorf("Expected 3 tables, got %d", len(structure.Tables)) + } + + // Helper function to find a table by name + findTable := func(name string) *sqlrunner.DatabaseTable { + for _, table := range structure.Tables { + if table.Name == name { + return &table + } + } + return nil + } + + // Verify users table + usersTable := findTable("users") + if usersTable == nil { + t.Error("Expected to find 'users' table") + } else { + expectedColumns := []string{"id", "name", "email"} + if len(usersTable.Columns) != len(expectedColumns) { + t.Errorf("Expected %d columns in users table, got %d", len(expectedColumns), len(usersTable.Columns)) + } + for i, expected := range expectedColumns { + if i >= len(usersTable.Columns) || usersTable.Columns[i] != expected { + t.Errorf("Expected column %d to be '%s', got '%s'", i, expected, usersTable.Columns[i]) + } + } + } + + // Verify posts table + postsTable := findTable("posts") + if postsTable == nil { + t.Error("Expected to find 'posts' table") + } else { + expectedColumns := []string{"id", "title", "content", "user_id", "created_at"} + if len(postsTable.Columns) != len(expectedColumns) { + t.Errorf("Expected %d columns in posts table, got %d", len(expectedColumns), len(postsTable.Columns)) + } + } + + // Verify categories table + categoriesTable := findTable("categories") + if categoriesTable == nil { + t.Error("Expected to find 'categories' table") + } else { + expectedColumns := []string{"id", "name"} + if len(categoriesTable.Columns) != len(expectedColumns) { + t.Errorf("Expected %d columns in categories table, got %d", len(expectedColumns), len(categoriesTable.Columns)) + } + } +} + +func TestGetDatabaseStructure_EmptyDatabase(t *testing.T) { + s := testhelper.NewSQLRunnerClient(t) + + // Schema that doesn't create any tables - just a comment + schema := "-- Empty database with no tables" + + structure, err := s.GetDatabaseStructure(context.Background(), schema) + if err != nil { + t.Fatalf("Expected success, got error: %v", err) + } + + // Should have no tables + if len(structure.Tables) != 0 { + t.Errorf("Expected 0 tables in empty database, got %d", len(structure.Tables)) + } +} + +func TestGetDatabaseStructure_ErrorHandling(t *testing.T) { + s := testhelper.NewSQLRunnerClient(t) + + // Create a schema with syntax error that should fail + schema := "CREATE TABLE invalid_syntax (id int" + + _, err := s.GetDatabaseStructure(context.Background(), schema) + if err == nil { + t.Error("Expected error for invalid schema, got nil") + } + + // Should be a schema error since the schema creation fails + var errResp *sqlrunner.ErrorResponse + if !errors.As(err, &errResp) { + t.Errorf("Expected ErrorResponse, got %v", err) + } + if errResp.Code != sqlrunner.ErrorCodeSchemaError { + t.Errorf("Expected SCHEMA_ERROR, got %v", errResp.Code) + } +} + +func TestGetDatabaseStructure_WithViews(t *testing.T) { + s := testhelper.NewSQLRunnerClient(t) + + // Create schema with both tables and views + schema := ` + CREATE TABLE products ( + id INTEGER PRIMARY KEY, + name TEXT NOT NULL, + price DECIMAL(10,2) + ); + CREATE VIEW expensive_products AS + SELECT * FROM products WHERE price > 100; + ` + + structure, err := s.GetDatabaseStructure(context.Background(), schema) + if err != nil { + t.Fatalf("Expected success, got error: %v", err) + } + + // Should only include tables, not views (since we filter by type='table') + if len(structure.Tables) != 1 { + t.Errorf("Expected 1 table (views should be excluded), got %d", len(structure.Tables)) + } + + if structure.Tables[0].Name != "products" { + t.Errorf("Expected table name 'products', got '%s'", structure.Tables[0].Name) + } +}