Skip to content

Commit a96ab04

Browse files
authored
Merge pull request #28 from database-playground/pan93412/dbp-121-快速查詢-questions-categories
DBP-121: querying questionCategories
2 parents d78afff + 6f3cfe9 commit a96ab04

File tree

6 files changed

+249
-17
lines changed

6 files changed

+249
-17
lines changed

flake.lock

Lines changed: 3 additions & 3 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

go.mod

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ require (
1919
github.com/samber/lo v1.52.0
2020
github.com/stretchr/testify v1.11.1
2121
github.com/testcontainers/testcontainers-go v0.39.0
22-
github.com/urfave/cli/v3 v3.4.1
22+
github.com/urfave/cli/v3 v3.5.0
2323
github.com/vektah/gqlparser/v2 v2.5.30
2424
go.uber.org/fx v1.24.0
2525
golang.org/x/oauth2 v0.32.0
@@ -81,7 +81,7 @@ require (
8181
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
8282
github.com/jackc/puddle/v2 v2.2.2 // indirect
8383
github.com/json-iterator/go v1.1.12 // indirect
84-
github.com/klauspost/compress v1.18.0 // indirect
84+
github.com/klauspost/compress v1.18.1 // indirect
8585
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
8686
github.com/leodido/go-urn v1.4.0 // indirect
8787
github.com/lufia/plan9stats v0.0.0-20251013123823-9fd1530e3ec3 // indirect
@@ -138,14 +138,14 @@ require (
138138
go.uber.org/zap v1.27.0 // indirect
139139
golang.org/x/arch v0.22.0 // indirect
140140
golang.org/x/crypto v0.43.0 // indirect
141-
golang.org/x/exp v0.0.0-20251009144603-d2f985daa21b // indirect
141+
golang.org/x/exp v0.0.0-20251017212417-90e834f514db // indirect
142142
golang.org/x/mod v0.29.0 // indirect
143143
golang.org/x/net v0.46.0 // indirect
144144
golang.org/x/sync v0.17.0 // indirect
145145
golang.org/x/sys v0.37.0 // indirect
146146
golang.org/x/text v0.30.0 // indirect
147147
golang.org/x/tools v0.38.0 // indirect
148-
google.golang.org/genproto/googleapis/rpc v0.0.0-20251014184007-4626949a642f // indirect
148+
google.golang.org/genproto/googleapis/rpc v0.0.0-20251020155222-88f65dc88635 // indirect
149149
google.golang.org/grpc v1.76.0 // indirect
150150
google.golang.org/protobuf v1.36.10 // indirect
151151
gopkg.in/yaml.v3 v3.0.1 // indirect

go.sum

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -158,8 +158,8 @@ github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
158158
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
159159
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
160160
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
161-
github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
162-
github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ=
161+
github.com/klauspost/compress v1.18.1 h1:bcSGx7UbpBqMChDtsF28Lw6v/G94LPrrbMbdC3JH2co=
162+
github.com/klauspost/compress v1.18.1/go.mod h1:ZQFFVG+MdnR0P+l6wpXgIL4NTtwiKIdBnrBd8Nrxr+0=
163163
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
164164
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
165165
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
@@ -269,8 +269,8 @@ github.com/ugorji/go/codec v1.3.0 h1:Qd2W2sQawAfG8XSvzwhBeoGq71zXOC/Q1E9y/wUcsUA
269269
github.com/ugorji/go/codec v1.3.0/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
270270
github.com/urfave/cli/v2 v2.27.7 h1:bH59vdhbjLv3LAvIu6gd0usJHgoTTPhCFib8qqOwXYU=
271271
github.com/urfave/cli/v2 v2.27.7/go.mod h1:CyNAG/xg+iAOg0N4MPGZqVmv2rCoP267496AOXUZjA4=
272-
github.com/urfave/cli/v3 v3.4.1 h1:1M9UOCy5bLmGnuu1yn3t3CB4rG79Rtoxuv1sPhnm6qM=
273-
github.com/urfave/cli/v3 v3.4.1/go.mod h1:FJSKtM/9AiiTOJL4fJ6TbMUkxBXn7GO9guZqoZtpYpo=
272+
github.com/urfave/cli/v3 v3.5.0 h1:qCuFMmdayTF3zmjG8TSsoBzrDqszNrklYg2x3g4MSgw=
273+
github.com/urfave/cli/v3 v3.5.0/go.mod h1:ysVLtOEmg2tOy6PknnYVhDoouyC/6N42TMeoMzskhso=
274274
github.com/vektah/gqlparser/v2 v2.5.30 h1:EqLwGAFLIzt1wpx1IPpY67DwUujF1OfzgEyDsLrN6kE=
275275
github.com/vektah/gqlparser/v2 v2.5.30/go.mod h1:D1/VCZtV3LPnQrcPBeR/q5jkSQIPti0uYCP/RI0gIeo=
276276
github.com/vmihailenco/msgpack/v5 v5.4.1 h1:cQriyiUvjTwOHg8QZaPihLWeRAAVoCpE00IUPn0Bjt8=
@@ -323,8 +323,8 @@ golang.org/x/arch v0.22.0 h1:c/Zle32i5ttqRXjdLyyHZESLD/bB90DCU1g9l/0YBDI=
323323
golang.org/x/arch v0.22.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A=
324324
golang.org/x/crypto v0.43.0 h1:dduJYIi3A3KOfdGOHX8AVZ/jGiyPa3IbBozJ5kNuE04=
325325
golang.org/x/crypto v0.43.0/go.mod h1:BFbav4mRNlXJL4wNeejLpWxB7wMbc79PdRGhWKncxR0=
326-
golang.org/x/exp v0.0.0-20251009144603-d2f985daa21b h1:18qgiDvlvH7kk8Ioa8Ov+K6xCi0GMvmGfGW0sgd/SYA=
327-
golang.org/x/exp v0.0.0-20251009144603-d2f985daa21b/go.mod h1:j/pmGrbnkbPtQfxEe5D0VQhZC6qKbfKifgD0oM7sR70=
326+
golang.org/x/exp v0.0.0-20251017212417-90e834f514db h1:by6IehL4BH5k3e3SJmcoNbOobMey2SLpAF79iPOEBvw=
327+
golang.org/x/exp v0.0.0-20251017212417-90e834f514db/go.mod h1:j/pmGrbnkbPtQfxEe5D0VQhZC6qKbfKifgD0oM7sR70=
328328
golang.org/x/mod v0.29.0 h1:HV8lRxZC4l2cr3Zq1LvtOsi/ThTgWnUk/y64QSs8GwA=
329329
golang.org/x/mod v0.29.0/go.mod h1:NyhrlYXJ2H4eJiRy/WDBO6HMqZQ6q9nk4JzS3NuCK+w=
330330
golang.org/x/net v0.46.0 h1:giFlY12I07fugqwPuWJi68oOnpfqFnJIJzaIIm2JVV4=
@@ -361,8 +361,8 @@ google.golang.org/genproto v0.0.0-20250603155806-513f23925822 h1:rHWScKit0gvAPuO
361361
google.golang.org/genproto v0.0.0-20250603155806-513f23925822/go.mod h1:HubltRL7rMh0LfnQPkMH4NPDFEWp0jw3vixw7jEM53s=
362362
google.golang.org/genproto/googleapis/api v0.0.0-20250804133106-a7a43d27e69b h1:ULiyYQ0FdsJhwwZUwbaXpZF5yUE3h+RA+gxvBu37ucc=
363363
google.golang.org/genproto/googleapis/api v0.0.0-20250804133106-a7a43d27e69b/go.mod h1:oDOGiMSXHL4sDTJvFvIB9nRQCGdLP1o/iVaqQK8zB+M=
364-
google.golang.org/genproto/googleapis/rpc v0.0.0-20251014184007-4626949a642f h1:1FTH6cpXFsENbPR5Bu8NQddPSaUUE6NA2XdZdDSAJK4=
365-
google.golang.org/genproto/googleapis/rpc v0.0.0-20251014184007-4626949a642f/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk=
364+
google.golang.org/genproto/googleapis/rpc v0.0.0-20251020155222-88f65dc88635 h1:3uycTxukehWrxH4HtPRtn1PDABTU331ViDjyqrUbaog=
365+
google.golang.org/genproto/googleapis/rpc v0.0.0-20251020155222-88f65dc88635/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk=
366366
google.golang.org/grpc v1.76.0 h1:UnVkv1+uMLYXoIz6o7chp59WfQUYA2ex/BXQ9rHZu7A=
367367
google.golang.org/grpc v1.76.0/go.mod h1:Ju12QI8M6iQJtbcsV+awF5a4hfJMLi4X0JLo94ULZ6c=
368368
google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE=

graph/question.graphqls

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,11 @@ extend type Query {
1616
otherwise, you can only get your own submissions.
1717
"""
1818
submission(id: ID!): Submission!
19+
20+
"""
21+
Get the list of question categories.
22+
"""
23+
questionCategories: [String!]! @scope(scope: "question:read")
1924
}
2025

2126
extend type Mutation {

graph/question.resolvers.go

Lines changed: 15 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

graph/question_resolver_test.go

Lines changed: 214 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,11 +23,13 @@ import (
2323
// createTestDatabase creates a test database entity
2424
func createTestDatabase(t *testing.T, entClient *ent.Client) *ent.Database {
2525
t.Helper()
26+
// Generate unique slug and relation figure to avoid UNIQUE constraint violations
27+
uniqueID := strconv.FormatInt(time.Now().UnixNano(), 10)
2628
database, err := entClient.Database.Create().
27-
SetSlug("test-db").
29+
SetSlug("test-db-" + uniqueID).
2830
SetDescription("Test Database").
2931
SetSchema("CREATE TABLE test (id INT, name VARCHAR(255));").
30-
SetRelationFigure("test-relation-figure").
32+
SetRelationFigure("test-relation-figure-" + uniqueID).
3133
Save(context.Background())
3234
require.NoError(t, err)
3335
return database
@@ -688,3 +690,213 @@ func TestQuestionResolver_Solved(t *testing.T) {
688690
require.Contains(t, err.Error(), defs.CodeUnauthorized)
689691
})
690692
}
693+
694+
func TestQueryResolver_QuestionCategories(t *testing.T) {
695+
entClient := testhelper.NewEntSqliteClient(t)
696+
resolver := NewTestResolver(t, entClient, &mockAuthStorage{})
697+
cfg := Config{
698+
Resolvers: resolver,
699+
Directives: DirectiveRoot{Scope: directive.ScopeDirective},
700+
}
701+
srv := handler.New(NewExecutableSchema(cfg))
702+
srv.AddTransport(transport.POST{})
703+
gqlClient := client.New(srv)
704+
705+
// Create test group and user
706+
group, err := createTestGroup(t, entClient)
707+
require.NoError(t, err)
708+
709+
testUser, err := entClient.User.Create().
710+
SetName("testUser").
711+
SetEmail("test@example.com").
712+
SetGroup(group).
713+
Save(context.Background())
714+
require.NoError(t, err)
715+
716+
t.Run("success - returns empty array when no questions", func(t *testing.T) {
717+
var resp struct {
718+
QuestionCategories []string
719+
}
720+
query := `query { questionCategories }`
721+
722+
err := gqlClient.Post(query, &resp, func(bd *client.Request) {
723+
bd.HTTP = bd.HTTP.WithContext(auth.WithUser(bd.HTTP.Context(), auth.TokenInfo{
724+
UserID: testUser.ID,
725+
Scopes: []string{"question:read"},
726+
}))
727+
})
728+
require.NoError(t, err)
729+
require.NotNil(t, resp.QuestionCategories)
730+
require.Len(t, resp.QuestionCategories, 0)
731+
})
732+
733+
t.Run("success - returns single category", func(t *testing.T) {
734+
// Create test database
735+
database := createTestDatabase(t, entClient)
736+
737+
// Create question with category
738+
_, err := entClient.Question.Create().
739+
SetCategory("basic-select").
740+
SetDifficulty("easy").
741+
SetTitle("Test Query 1").
742+
SetDescription("Write a SELECT query").
743+
SetReferenceAnswer("SELECT * FROM test;").
744+
SetDatabase(database).
745+
Save(context.Background())
746+
require.NoError(t, err)
747+
748+
var resp struct {
749+
QuestionCategories []string
750+
}
751+
query := `query { questionCategories }`
752+
753+
err = gqlClient.Post(query, &resp, func(bd *client.Request) {
754+
bd.HTTP = bd.HTTP.WithContext(auth.WithUser(bd.HTTP.Context(), auth.TokenInfo{
755+
UserID: testUser.ID,
756+
Scopes: []string{"question:read"},
757+
}))
758+
})
759+
require.NoError(t, err)
760+
require.Len(t, resp.QuestionCategories, 1)
761+
require.Contains(t, resp.QuestionCategories, "basic-select")
762+
})
763+
764+
t.Run("success - returns unique categories when questions have duplicate categories", func(t *testing.T) {
765+
// Create test database
766+
database := createTestDatabase(t, entClient)
767+
768+
// Create multiple questions with same category
769+
_, err := entClient.Question.Create().
770+
SetCategory("joins").
771+
SetDifficulty("medium").
772+
SetTitle("Join Query 1").
773+
SetDescription("Write a JOIN query").
774+
SetReferenceAnswer("SELECT * FROM test t1 JOIN test t2 ON t1.id = t2.id;").
775+
SetDatabase(database).
776+
Save(context.Background())
777+
require.NoError(t, err)
778+
779+
_, err = entClient.Question.Create().
780+
SetCategory("joins").
781+
SetDifficulty("medium").
782+
SetTitle("Join Query 2").
783+
SetDescription("Write another JOIN query").
784+
SetReferenceAnswer("SELECT * FROM test t1 LEFT JOIN test t2 ON t1.id = t2.id;").
785+
SetDatabase(database).
786+
Save(context.Background())
787+
require.NoError(t, err)
788+
789+
var resp struct {
790+
QuestionCategories []string
791+
}
792+
query := `query { questionCategories }`
793+
794+
err = gqlClient.Post(query, &resp, func(bd *client.Request) {
795+
bd.HTTP = bd.HTTP.WithContext(auth.WithUser(bd.HTTP.Context(), auth.TokenInfo{
796+
UserID: testUser.ID,
797+
Scopes: []string{"question:read"},
798+
}))
799+
})
800+
require.NoError(t, err)
801+
// Should have at least one category (joins)
802+
require.GreaterOrEqual(t, len(resp.QuestionCategories), 1)
803+
require.Contains(t, resp.QuestionCategories, "joins")
804+
805+
// Count occurrences of "joins" - should only appear once
806+
joinCount := 0
807+
for _, cat := range resp.QuestionCategories {
808+
if cat == "joins" {
809+
joinCount++
810+
}
811+
}
812+
require.Equal(t, 1, joinCount, "Category 'joins' should appear exactly once")
813+
})
814+
815+
t.Run("success - returns multiple different categories", func(t *testing.T) {
816+
// Create test database
817+
database := createTestDatabase(t, entClient)
818+
819+
// Create questions with different categories
820+
_, err := entClient.Question.Create().
821+
SetCategory("aggregation").
822+
SetDifficulty("easy").
823+
SetTitle("Aggregation Query").
824+
SetDescription("Use aggregation functions").
825+
SetReferenceAnswer("SELECT COUNT(*) FROM test;").
826+
SetDatabase(database).
827+
Save(context.Background())
828+
require.NoError(t, err)
829+
830+
_, err = entClient.Question.Create().
831+
SetCategory("subqueries").
832+
SetDifficulty("hard").
833+
SetTitle("Subquery Challenge").
834+
SetDescription("Use subqueries").
835+
SetReferenceAnswer("SELECT * FROM test WHERE id IN (SELECT id FROM test);").
836+
SetDatabase(database).
837+
Save(context.Background())
838+
require.NoError(t, err)
839+
840+
var resp struct {
841+
QuestionCategories []string
842+
}
843+
query := `query { questionCategories }`
844+
845+
err = gqlClient.Post(query, &resp, func(bd *client.Request) {
846+
bd.HTTP = bd.HTTP.WithContext(auth.WithUser(bd.HTTP.Context(), auth.TokenInfo{
847+
UserID: testUser.ID,
848+
Scopes: []string{"question:read"},
849+
}))
850+
})
851+
require.NoError(t, err)
852+
require.GreaterOrEqual(t, len(resp.QuestionCategories), 2)
853+
require.Contains(t, resp.QuestionCategories, "aggregation")
854+
require.Contains(t, resp.QuestionCategories, "subqueries")
855+
})
856+
857+
t.Run("success - works with wildcard scope", func(t *testing.T) {
858+
var resp struct {
859+
QuestionCategories []string
860+
}
861+
query := `query { questionCategories }`
862+
863+
err := gqlClient.Post(query, &resp, func(bd *client.Request) {
864+
bd.HTTP = bd.HTTP.WithContext(auth.WithUser(bd.HTTP.Context(), auth.TokenInfo{
865+
UserID: testUser.ID,
866+
Scopes: []string{"*"},
867+
}))
868+
})
869+
require.NoError(t, err)
870+
require.NotNil(t, resp.QuestionCategories)
871+
// At this point we should have at least categories from previous tests in this run
872+
// Since we don't clean up between tests in the same test function, we expect at least 1
873+
require.GreaterOrEqual(t, len(resp.QuestionCategories), 1)
874+
})
875+
876+
t.Run("forbidden - user without question:read scope", func(t *testing.T) {
877+
var resp struct {
878+
QuestionCategories []string
879+
}
880+
query := `query { questionCategories }`
881+
882+
err := gqlClient.Post(query, &resp, func(bd *client.Request) {
883+
bd.HTTP = bd.HTTP.WithContext(auth.WithUser(bd.HTTP.Context(), auth.TokenInfo{
884+
UserID: testUser.ID,
885+
Scopes: []string{"submission:read"}, // Wrong scope
886+
}))
887+
})
888+
require.Error(t, err)
889+
require.Contains(t, err.Error(), defs.CodeForbidden)
890+
})
891+
892+
t.Run("unauthorized - no authentication", func(t *testing.T) {
893+
var resp struct {
894+
QuestionCategories []string
895+
}
896+
query := `query { questionCategories }`
897+
898+
err := gqlClient.Post(query, &resp)
899+
require.Error(t, err)
900+
require.Contains(t, err.Error(), defs.CodeUnauthorized)
901+
})
902+
}

0 commit comments

Comments
 (0)