Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 8 additions & 6 deletions cmd/ccg/main_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"log/slog"
"os"
"path/filepath"
"strconv"
"strings"
"testing"
"time"
Expand Down Expand Up @@ -250,8 +251,8 @@ func TestRunMigrations_SQLiteDownRestoresNullableColumns(t *testing.T) {
if err != nil {
t.Fatalf("create migrator: %v", err)
}
if err := migrator.Steps(-6); err != nil {
t.Fatalf("run down migration: %v", err)
if err := migrator.Migrate(1); err != nil {
t.Fatalf("migrate down to version 1: %v", err)
}

var version migration.MigrationSchemaVersion
Expand Down Expand Up @@ -295,8 +296,8 @@ func TestRunMigrations_SQLiteDownFromVersionThreeDropsPolicyTables(t *testing.T)
if err != nil {
t.Fatalf("create migrator: %v", err)
}
if err := migrator.Steps(-5); err != nil {
t.Fatalf("run down migration: %v", err)
if err := migrator.Migrate(2); err != nil {
t.Fatalf("migrate down to version 2: %v", err)
}

var version migration.MigrationSchemaVersion
Expand Down Expand Up @@ -409,7 +410,8 @@ func TestEnsureSchemaVersion_LogsRuntimeSchemaPassAndFail(t *testing.T) {
t.Fatalf("ensure schema version: %v", err)
}
passLog := logs.String()
for _, want := range []string{"database runtime schema check passed", "driver=sqlite", "required_version=7", "auto_migrated=true"} {
requiredVersionLog := "required_version=" + strconv.Itoa(migration.RequiredSchemaVersion)
for _, want := range []string{"database runtime schema check passed", "driver=sqlite", requiredVersionLog, "auto_migrated=true"} {
if !strings.Contains(passLog, want) {
t.Fatalf("expected runtime schema pass log to contain %q, got %q", want, passLog)
}
Expand All @@ -423,7 +425,7 @@ func TestEnsureSchemaVersion_LogsRuntimeSchemaPassAndFail(t *testing.T) {
t.Fatal("expected parity failure")
}
failLog := logs.String()
for _, want := range []string{"database runtime schema check failed", "driver=sqlite", "required_version=7", "auto_migrated=false", "error.message="} {
for _, want := range []string{"database runtime schema check failed", "driver=sqlite", requiredVersionLog, "auto_migrated=false", "error.message="} {
if !strings.Contains(failLog, want) {
t.Fatalf("expected runtime schema failure log to contain %q, got %q", want, failLog)
}
Expand Down
3 changes: 3 additions & 0 deletions internal/adapters/inbound/cli/build.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import (
"github.com/spf13/cobra"
"github.com/tae2089/trace"

"github.com/tae2089/code-context-graph/internal/app/ingest"
"github.com/tae2089/code-context-graph/internal/app/ingest/workflow"
requestctx "github.com/tae2089/code-context-graph/internal/ctx"
)
Expand Down Expand Up @@ -37,10 +38,12 @@ func newBuildCmd(deps *Deps) *cobra.Command {
paths := resolveIncludePaths(includePaths)
fileLimit := resolveMaxFileBytes(maxFileBytes)
totalLimit := resolveMaxTotalParsedBytes(maxTotalParsedBytes)
parseCache, _ := deps.Store.(ingest.ParseCache)
svc := &workflow.Service{
Store: deps.Store,
UnitOfWork: deps.UnitOfWork,
Search: deps.Search,
ParseCache: parseCache,
Walkers: deps.Walkers,
Logger: deps.Logger,
}
Expand Down
2 changes: 1 addition & 1 deletion internal/adapters/inbound/cli/build_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ func setupBuildTest(t *testing.T) (*Deps, *bytes.Buffer, *bytes.Buffer, *gorm.DB
t.Helper()
deps, stdout, stderr := newTestDeps()

db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{Logger: gormlogger.Discard})
db, err := gorm.Open(sqlite.Open(filepath.Join(t.TempDir(), "ccg.db")), &gorm.Config{Logger: gormlogger.Discard})
if err != nil {
t.Fatal(err)
}
Expand Down
3 changes: 3 additions & 0 deletions internal/adapters/inbound/cli/update.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (
"github.com/spf13/cobra"
"github.com/tae2089/trace"

"github.com/tae2089/code-context-graph/internal/app/ingest"
"github.com/tae2089/code-context-graph/internal/app/ingest/workflow"
requestctx "github.com/tae2089/code-context-graph/internal/ctx"
)
Expand Down Expand Up @@ -40,10 +41,12 @@ func newUpdateCmd(deps *Deps) *cobra.Command {
ns := resolveNamespace(cmd)
ctx = requestctx.WithNamespace(ctx, ns)

parseCache, _ := deps.Store.(ingest.ParseCache)
svc := &workflow.Service{
Store: deps.Store,
UnitOfWork: deps.UnitOfWork,
Search: deps.Search,
ParseCache: parseCache,
Walkers: deps.Walkers,
Logger: deps.Logger,
}
Expand Down
4 changes: 2 additions & 2 deletions internal/adapters/inbound/cli/update_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ func setupUpdateTest(t *testing.T) (*Deps, *bytes.Buffer, *bytes.Buffer, *gorm.D
t.Helper()
deps, stdout, stderr := newTestDeps()

db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{Logger: gormlogger.Discard})
db, err := gorm.Open(sqlite.Open(filepath.Join(t.TempDir(), "ccg.db")), &gorm.Config{Logger: gormlogger.Discard})
if err != nil {
t.Fatal(err)
}
Expand Down Expand Up @@ -61,7 +61,7 @@ func setupUpdateGraphOnlyTest(t *testing.T) (*Deps, *bytes.Buffer, *bytes.Buffer
t.Helper()
deps, stdout, stderr := newTestDeps()

db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{Logger: gormlogger.Discard})
db, err := gorm.Open(sqlite.Open(filepath.Join(t.TempDir(), "ccg.db")), &gorm.Config{Logger: gormlogger.Discard})
if err != nil {
t.Fatal(err)
}
Expand Down
2 changes: 2 additions & 0 deletions internal/adapters/inbound/mcp/handler_parse.go
Original file line number Diff line number Diff line change
Expand Up @@ -71,10 +71,12 @@ func (h *handlers) graphService() *workflow.Service {
if candidate, ok := h.deps.Build.Store.(ingest.GraphStore); ok {
graphStore = candidate
}
parseCache, _ := h.deps.Build.Store.(ingest.ParseCache)
return &workflow.Service{
Store: graphStore,
UnitOfWork: h.deps.Build.UnitOfWork,
Search: h.deps.Build.Search,
ParseCache: parseCache,
Parsers: walkers,
Logger: h.logger(),
}
Expand Down
161 changes: 161 additions & 0 deletions internal/adapters/outbound/graphgorm/annotation_bulk_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,161 @@
package graphgorm

import (
"context"
"fmt"
"sync/atomic"
"testing"
"time"

"gorm.io/driver/sqlite"
"gorm.io/gorm"
"gorm.io/gorm/logger"

requestctx "github.com/tae2089/code-context-graph/internal/ctx"
"github.com/tae2089/code-context-graph/internal/domain/graph"
)

type annotationSQLCounter struct {
statements atomic.Int64
}

func (c *annotationSQLCounter) LogMode(logger.LogLevel) logger.Interface { return c }
func (c *annotationSQLCounter) Info(context.Context, string, ...any) {}
func (c *annotationSQLCounter) Warn(context.Context, string, ...any) {}
func (c *annotationSQLCounter) Error(context.Context, string, ...any) {}
func (c *annotationSQLCounter) Trace(_ context.Context, _ time.Time, fc func() (string, int64), _ error) {
c.statements.Add(1)
_, _ = fc()
}

func (c *annotationSQLCounter) reset() {
c.statements.Store(0)
}

func setupAnnotationSQLMeasurement(t *testing.T, count int) (*Store, context.Context, []*graph.Annotation, *annotationSQLCounter) {
t.Helper()
counter := &annotationSQLCounter{}
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{Logger: counter})
if err != nil {
t.Fatalf("open SQLite: %v", err)
}
store := New(db)
if err := store.AutoMigrate(); err != nil {
t.Fatalf("auto migrate: %v", err)
}
ctx := requestctx.WithNamespace(context.Background(), "annotation-sql-measurement")
nodes := make([]graph.Node, count)
for i := range nodes {
nodes[i] = graph.Node{
QualifiedName: fmt.Sprintf("sample.F%d", i),
Kind: graph.NodeKindFunction,
Name: fmt.Sprintf("F%d", i),
FilePath: "sample.go",
StartLine: i + 1,
EndLine: i + 1,
Language: "go",
}
}
if err := store.UpsertNodes(ctx, nodes); err != nil {
t.Fatalf("upsert nodes: %v", err)
}
stored, err := store.GetNodesByFile(ctx, "sample.go")
if err != nil {
t.Fatalf("load nodes: %v", err)
}
annotations := make([]*graph.Annotation, len(stored))
for i := range stored {
annotations[i] = &graph.Annotation{
NodeID: stored[i].ID,
Summary: fmt.Sprintf("summary %d", i),
Tags: []graph.DocTag{{
Kind: graph.TagIntent,
Value: fmt.Sprintf("intent %d", i),
Ordinal: 0,
}},
}
}
counter.reset()
return store, ctx, annotations, counter
}

func TestUpsertAnnotations_BatchesSQLAndPreservesTags(t *testing.T) {
store, ctx, annotations, counter := setupAnnotationSQLMeasurement(t, 20)
started := time.Now()
if err := store.UpsertAnnotations(ctx, annotations); err != nil {
t.Fatalf("upsert annotations: %v", err)
}
statements := counter.statements.Load()
t.Logf("bulk annotation candidate: annotations=%d statements=%d elapsed=%s", len(annotations), statements, time.Since(started))
if statements > 5 {
t.Fatalf("bulk annotation statements = %d, want <= 5", statements)
}

for i, annotation := range annotations {
got, err := store.GetAnnotation(ctx, annotation.NodeID)
if err != nil {
t.Fatalf("get annotation %d: %v", i, err)
}
if got == nil || got.Summary != annotation.Summary {
t.Fatalf("annotation %d = %+v, want summary %q", i, got, annotation.Summary)
}
if len(got.Tags) != 1 || got.Tags[0].Value != annotation.Tags[0].Value {
t.Fatalf("annotation %d tags = %+v, want %+v", i, got.Tags, annotation.Tags)
}
}

for i, annotation := range annotations {
annotation.Summary = fmt.Sprintf("updated summary %d", i)
annotation.Tags = []graph.DocTag{{Kind: graph.TagDomainRule, Value: fmt.Sprintf("rule %d", i), Ordinal: 0}}
}
counter.reset()
if err := store.UpsertAnnotations(ctx, annotations); err != nil {
t.Fatalf("update annotations: %v", err)
}
if statements := counter.statements.Load(); statements > 5 {
t.Fatalf("bulk annotation update statements = %d, want <= 5", statements)
}
for i, annotation := range annotations {
got, err := store.GetAnnotation(ctx, annotation.NodeID)
if err != nil {
t.Fatalf("get updated annotation %d: %v", i, err)
}
if got == nil || got.Summary != annotation.Summary || len(got.Tags) != 1 || got.Tags[0].Value != annotation.Tags[0].Value {
t.Fatalf("updated annotation %d = %+v, want %+v", i, got, annotation)
}
}
}

func TestUpsertAnnotations_RejectsForeignNodeBeforeWrites(t *testing.T) {
store, ctx, annotations, _ := setupAnnotationSQLMeasurement(t, 1)
foreignCtx := requestctx.WithNamespace(context.Background(), "foreign")
foreignNodes := []graph.Node{{
QualifiedName: "foreign.F",
Kind: graph.NodeKindFunction,
Name: "F",
FilePath: "foreign.go",
StartLine: 1,
EndLine: 1,
Language: "go",
}}
if err := store.UpsertNodes(foreignCtx, foreignNodes); err != nil {
t.Fatalf("upsert foreign node: %v", err)
}
storedForeign, err := store.GetNodesByFile(foreignCtx, "foreign.go")
if err != nil || len(storedForeign) != 1 {
t.Fatalf("load foreign node: nodes=%+v err=%v", storedForeign, err)
}
foreignAnnotation := &graph.Annotation{NodeID: storedForeign[0].ID, Summary: "foreign"}

err = store.UpsertAnnotations(ctx, []*graph.Annotation{annotations[0], foreignAnnotation})
if err == nil {
t.Fatal("expected foreign-node batch to be rejected")
}
got, getErr := store.GetAnnotation(ctx, annotations[0].NodeID)
if getErr != nil {
t.Fatalf("get owned annotation after rejection: %v", getErr)
}
if got != nil {
t.Fatalf("owned annotation was written before batch rejection: %+v", got)
}
}
105 changes: 105 additions & 0 deletions internal/adapters/outbound/graphgorm/deletegraph_query_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
package graphgorm

import (
"context"
"strings"
"sync"
"testing"
"time"

"gorm.io/driver/sqlite"
"gorm.io/gorm"
"gorm.io/gorm/logger"

requestctx "github.com/tae2089/code-context-graph/internal/ctx"
"github.com/tae2089/code-context-graph/internal/domain/graph"
)

type deleteGraphSQLRecorder struct {
mu sync.Mutex
statements []string
}

func (r *deleteGraphSQLRecorder) LogMode(logger.LogLevel) logger.Interface { return r }
func (r *deleteGraphSQLRecorder) Info(context.Context, string, ...any) {}
func (r *deleteGraphSQLRecorder) Warn(context.Context, string, ...any) {}
func (r *deleteGraphSQLRecorder) Error(context.Context, string, ...any) {}
func (r *deleteGraphSQLRecorder) Trace(_ context.Context, _ time.Time, fc func() (string, int64), _ error) {
sql, _ := fc()
r.mu.Lock()
r.statements = append(r.statements, sql)
r.mu.Unlock()
}

func (r *deleteGraphSQLRecorder) reset() {
r.mu.Lock()
r.statements = nil
r.mu.Unlock()
}

func (r *deleteGraphSQLRecorder) searchDocumentDelete() string {
r.mu.Lock()
defer r.mu.Unlock()
for _, statement := range r.statements {
if strings.Contains(statement, "DELETE FROM `search_documents`") {
return statement
}
}
return ""
}

func TestDeleteGraph_DeletesSearchDocumentsByNamespace(t *testing.T) {
recorder := &deleteGraphSQLRecorder{}
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{Logger: recorder})
if err != nil {
t.Fatalf("open SQLite: %v", err)
}
store := New(db)
if err := store.AutoMigrate(); err != nil {
t.Fatalf("auto migrate: %v", err)
}
if err := db.AutoMigrate(&graph.SearchDocument{}); err != nil {
t.Fatalf("migrate search documents: %v", err)
}

ctx := requestctx.WithNamespace(context.Background(), "delete-me")
if err := store.UpsertNodes(ctx, []graph.Node{{
QualifiedName: "pkg.DeleteMe",
Kind: graph.NodeKindFunction,
Name: "DeleteMe",
FilePath: "delete.go",
StartLine: 1,
EndLine: 1,
Language: "go",
}}); err != nil {
t.Fatalf("upsert node: %v", err)
}
node, err := store.GetNode(ctx, "pkg.DeleteMe")
if err != nil {
t.Fatalf("get node: %v", err)
}
if err := db.Create(&graph.SearchDocument{
Namespace: "delete-me",
NodeID: node.ID,
Content: "delete me",
Language: "go",
}).Error; err != nil {
t.Fatalf("create search document: %v", err)
}

recorder.reset()
if err := store.DeleteGraph(ctx); err != nil {
t.Fatalf("DeleteGraph: %v", err)
}

statement := recorder.searchDocumentDelete()
if statement == "" {
t.Fatal("search_documents DELETE statement not recorded")
}
if !strings.Contains(statement, "WHERE namespace = \"delete-me\"") {
t.Fatalf("search_documents DELETE = %q, want direct namespace predicate", statement)
}
if strings.Contains(statement, "SELECT `id` FROM `nodes`") {
t.Fatalf("search_documents DELETE = %q, must not select node IDs", statement)
}
}
Loading
Loading