|
| 1 | +// @index GORM persistence for unresolved-edge reverse lookup and readiness state. |
| 2 | +package graphgorm |
| 3 | + |
| 4 | +import ( |
| 5 | + "context" |
| 6 | + "errors" |
| 7 | + |
| 8 | + "gorm.io/gorm" |
| 9 | + "gorm.io/gorm/clause" |
| 10 | + |
| 11 | + "github.com/tae2089/trace" |
| 12 | + |
| 13 | + requestctx "github.com/tae2089/code-context-graph/internal/ctx" |
| 14 | + "github.com/tae2089/code-context-graph/internal/domain/graph" |
| 15 | +) |
| 16 | + |
| 17 | +const unresolvedQueryChunkSize = 400 |
| 18 | + |
| 19 | +// UpsertUnresolvedEdges stores reverse-index rows without exposing them as traversable graph edges. |
| 20 | +// @intent retain unresolved syntax candidates until a future symbol addition can resolve them. |
| 21 | +// @sideEffect inserts unresolved_edge_candidates rows. |
| 22 | +func (s *Store) UpsertUnresolvedEdges(ctx context.Context, candidates []graph.UnresolvedEdgeCandidate) error { |
| 23 | + if len(candidates) == 0 { |
| 24 | + return nil |
| 25 | + } |
| 26 | + ns := requestctx.FromContext(ctx) |
| 27 | + for i := range candidates { |
| 28 | + candidates[i].Namespace = ns |
| 29 | + } |
| 30 | + if err := s.db.WithContext(ctx).Clauses(clause.OnConflict{ |
| 31 | + Columns: []clause.Column{{Name: "namespace"}, {Name: "fingerprint"}}, |
| 32 | + DoNothing: true, |
| 33 | + }).CreateInBatches(candidates, 500).Error; err != nil { |
| 34 | + return trace.Wrap(err, "upsert unresolved edges") |
| 35 | + } |
| 36 | + return nil |
| 37 | +} |
| 38 | + |
| 39 | +// FindUnresolvedEdgesByLookupKeys finds deduplicated syntax edges matching newly added symbol keys. |
| 40 | +// @intent use the reverse index to identify affected unchanged source files. |
| 41 | +func (s *Store) FindUnresolvedEdgesByLookupKeys(ctx context.Context, keys []string) ([]graph.Edge, error) { |
| 42 | + if len(keys) == 0 { |
| 43 | + return nil, nil |
| 44 | + } |
| 45 | + ns := requestctx.FromContext(ctx) |
| 46 | + seen := make(map[string]struct{}) |
| 47 | + var out []graph.Edge |
| 48 | + for start := 0; start < len(keys); start += unresolvedQueryChunkSize { |
| 49 | + end := min(start+unresolvedQueryChunkSize, len(keys)) |
| 50 | + var rows []graph.UnresolvedEdgeCandidate |
| 51 | + if err := s.db.WithContext(ctx). |
| 52 | + Where("namespace = ? AND lookup_key IN ?", ns, keys[start:end]). |
| 53 | + Order("file_path ASC").Order("line ASC").Order("fingerprint ASC"). |
| 54 | + Find(&rows).Error; err != nil { |
| 55 | + return nil, trace.Wrap(err, "find unresolved edges by lookup keys") |
| 56 | + } |
| 57 | + for _, row := range rows { |
| 58 | + if _, ok := seen[row.Fingerprint]; ok { |
| 59 | + continue |
| 60 | + } |
| 61 | + seen[row.Fingerprint] = struct{}{} |
| 62 | + out = append(out, row.Edge()) |
| 63 | + } |
| 64 | + } |
| 65 | + return out, nil |
| 66 | +} |
| 67 | + |
| 68 | +// FindUnresolvedEdgesByFiles loads every candidate edge owned by the selected affected files. |
| 69 | +// @intent replay import warmup and related edges together after reverse-index selection narrows source files. |
| 70 | +func (s *Store) FindUnresolvedEdgesByFiles(ctx context.Context, filePaths []string) ([]graph.Edge, error) { |
| 71 | + if len(filePaths) == 0 { |
| 72 | + return nil, nil |
| 73 | + } |
| 74 | + ns := requestctx.FromContext(ctx) |
| 75 | + seen := make(map[string]struct{}) |
| 76 | + var out []graph.Edge |
| 77 | + for start := 0; start < len(filePaths); start += unresolvedQueryChunkSize { |
| 78 | + end := min(start+unresolvedQueryChunkSize, len(filePaths)) |
| 79 | + var rows []graph.UnresolvedEdgeCandidate |
| 80 | + if err := s.db.WithContext(ctx). |
| 81 | + Where("namespace = ? AND file_path IN ?", ns, filePaths[start:end]). |
| 82 | + Order("file_path ASC").Order("line ASC").Order("fingerprint ASC"). |
| 83 | + Find(&rows).Error; err != nil { |
| 84 | + return nil, trace.Wrap(err, "find unresolved edges by files") |
| 85 | + } |
| 86 | + for _, row := range rows { |
| 87 | + if _, ok := seen[row.Fingerprint]; ok { |
| 88 | + continue |
| 89 | + } |
| 90 | + seen[row.Fingerprint] = struct{}{} |
| 91 | + out = append(out, row.Edge()) |
| 92 | + } |
| 93 | + } |
| 94 | + return out, nil |
| 95 | +} |
| 96 | + |
| 97 | +// DeleteUnresolvedEdgesByFingerprints removes every lookup-key row for resolved syntax edges. |
| 98 | +// @intent keep the reverse index limited to relationships that still lack endpoints. |
| 99 | +func (s *Store) DeleteUnresolvedEdgesByFingerprints(ctx context.Context, fingerprints []string) error { |
| 100 | + if len(fingerprints) == 0 { |
| 101 | + return nil |
| 102 | + } |
| 103 | + ns := requestctx.FromContext(ctx) |
| 104 | + for start := 0; start < len(fingerprints); start += unresolvedQueryChunkSize { |
| 105 | + end := min(start+unresolvedQueryChunkSize, len(fingerprints)) |
| 106 | + if err := s.db.WithContext(ctx). |
| 107 | + Where("namespace = ? AND fingerprint IN ?", ns, fingerprints[start:end]). |
| 108 | + Delete(&graph.UnresolvedEdgeCandidate{}).Error; err != nil { |
| 109 | + return trace.Wrap(err, "delete resolved unresolved edges") |
| 110 | + } |
| 111 | + } |
| 112 | + return nil |
| 113 | +} |
| 114 | + |
| 115 | +// UnresolvedIndexReady reports whether a compatible full build populated the namespace's reverse index. |
| 116 | +// @intent gate semi-naive update on complete historical unresolved-edge coverage produced by the expected algorithm and parsers. |
| 117 | +func (s *Store) UnresolvedIndexReady(ctx context.Context, version string) (bool, error) { |
| 118 | + if version == "" { |
| 119 | + return false, nil |
| 120 | + } |
| 121 | + var state graph.UnresolvedIndexState |
| 122 | + ns := requestctx.FromContext(ctx) |
| 123 | + result := s.db.WithContext(ctx).Where("namespace = ? AND version = ?", ns, version).First(&state) |
| 124 | + if errors.Is(result.Error, gorm.ErrRecordNotFound) { |
| 125 | + return false, nil |
| 126 | + } |
| 127 | + if result.Error != nil { |
| 128 | + return false, trace.Wrap(result.Error, "load unresolved index state") |
| 129 | + } |
| 130 | + return true, nil |
| 131 | +} |
| 132 | + |
| 133 | +// MarkUnresolvedIndexReady marks the current namespace and producer version after a successful full candidate pass. |
| 134 | +// @intent distinguish a compatible legitimately empty reverse index from stale or uninitialized state. |
| 135 | +// @sideEffect inserts or updates unresolved_index_states. |
| 136 | +func (s *Store) MarkUnresolvedIndexReady(ctx context.Context, version string) error { |
| 137 | + if version == "" { |
| 138 | + return trace.New("unresolved index version is empty") |
| 139 | + } |
| 140 | + state := graph.UnresolvedIndexState{Namespace: requestctx.FromContext(ctx), Version: version} |
| 141 | + if err := s.db.WithContext(ctx).Clauses(clause.OnConflict{ |
| 142 | + Columns: []clause.Column{{Name: "namespace"}}, |
| 143 | + DoUpdates: clause.AssignmentColumns([]string{"version", "updated_at"}), |
| 144 | + }).Create(&state).Error; err != nil { |
| 145 | + return trace.Wrap(err, "mark unresolved index ready") |
| 146 | + } |
| 147 | + return nil |
| 148 | +} |
0 commit comments