Skip to main content

relay_knowledge/storage/sqlite/code/frameworks/
mod.rs

1//! Framework graph persistence and bounded repository-scope reads.
2
3use rusqlite::{Connection, Transaction, params, params_from_iter, types::Value};
4
5use crate::{
6    domain::{
7        CodeFrameworkEdgeRecord, CodeFrameworkNodeRecord, FrameworkEdgeKind, FrameworkGraph,
8        FrameworkGraphRequest, FrameworkKind, FrameworkNodeKind, RepositoryCodeRange,
9    },
10    storage::{FrameworkGraphStore, StorageError, StorageFuture},
11};
12
13use super::{
14    SearchDocumentInserter, SqliteGraphStore, ensure_queryable_code_scope,
15    query::hits::required_repository,
16};
17
18impl FrameworkGraphStore for SqliteGraphStore {
19    fn search_framework_graph(
20        &self,
21        request: FrameworkGraphRequest,
22    ) -> StorageFuture<'_, FrameworkGraph> {
23        self.run_read_snapshot(move |connection| search(connection, request))
24    }
25
26    fn search_framework_graph_scope(
27        &self,
28        source_scope: String,
29        request: FrameworkGraphRequest,
30    ) -> StorageFuture<'_, FrameworkGraph> {
31        self.run_read_snapshot(move |connection| {
32            ensure_queryable_code_scope(connection, &source_scope)?;
33            search_scope(connection, &source_scope, request)
34        })
35    }
36}
37
38pub(super) fn insert_records(
39    transaction: &Transaction<'_>,
40    nodes: &[CodeFrameworkNodeRecord],
41    edges: &[CodeFrameworkEdgeRecord],
42) -> Result<(), StorageError> {
43    let mut node_statement = transaction.prepare(
44        "INSERT OR REPLACE INTO code_repository_framework_nodes (
45            repository_id, source_scope, node_id, file_id, path, framework, kind, name,
46            detail, symbol_snapshot_id, byte_start, byte_end, line_start, line_end
47         ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14)",
48    )?;
49    let mut edge_statement = transaction.prepare(
50        "INSERT OR REPLACE INTO code_repository_framework_edges (
51            repository_id, source_scope, edge_id, file_id, path, framework, kind,
52            source_node_id, target_node_id, target_hint, resolution_state,
53            confidence_basis_points, confidence_tier, byte_start, byte_end, line_start, line_end
54         ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17)",
55    )?;
56    let mut search_documents = SearchDocumentInserter::new(transaction)?;
57    for node in nodes {
58        node_statement.execute(params![
59            node.repository_id,
60            node.source_scope,
61            node.node_id,
62            node.file_id,
63            node.path,
64            node.framework.as_str(),
65            node.kind.as_str(),
66            node.name,
67            node.detail,
68            node.symbol_snapshot_id,
69            node.byte_range.start,
70            node.byte_range.end,
71            node.line_range.start,
72            node.line_range.end,
73        ])?;
74        search_documents.insert(
75            &node.source_scope,
76            "framework_node",
77            &node.node_id,
78            &node.path,
79            framework_language(node.framework),
80            [
81                node.framework.as_str(),
82                node.kind.as_str(),
83                node.name.as_str(),
84                node.detail.as_deref().unwrap_or_default(),
85                node.path.as_str(),
86            ],
87        )?;
88    }
89    for edge in edges {
90        edge_statement.execute(params![
91            edge.repository_id,
92            edge.source_scope,
93            edge.edge_id,
94            edge.file_id,
95            edge.path,
96            edge.framework.as_str(),
97            edge.kind.as_str(),
98            edge.source_node_id,
99            edge.target_node_id,
100            edge.target_hint,
101            edge.resolution_state,
102            edge.confidence_basis_points,
103            edge.confidence_tier,
104            edge.byte_range.start,
105            edge.byte_range.end,
106            edge.line_range.start,
107            edge.line_range.end,
108        ])?;
109        search_documents.insert(
110            &edge.source_scope,
111            "framework_edge",
112            &edge.edge_id,
113            &edge.path,
114            framework_language(edge.framework),
115            [
116                edge.framework.as_str(),
117                edge.kind.as_str(),
118                edge.target_hint.as_deref().unwrap_or_default(),
119                edge.path.as_str(),
120            ],
121        )?;
122    }
123    search_documents.finish()?;
124    Ok(())
125}
126
127pub(super) fn search(
128    connection: &mut Connection,
129    request: FrameworkGraphRequest,
130) -> Result<FrameworkGraph, StorageError> {
131    let status = required_repository(connection, &request.repository)?;
132    let source_scope = status.last_indexed_scope_id.as_deref().ok_or_else(|| {
133        StorageError::InvalidInput(format!(
134            "code repository '{}' does not have an indexed source scope",
135            status.alias
136        ))
137    })?;
138    search_scope(connection, source_scope, request)
139}
140
141pub(super) fn search_scope(
142    connection: &mut Connection,
143    source_scope: &str,
144    request: FrameworkGraphRequest,
145) -> Result<FrameworkGraph, StorageError> {
146    let (node_sql, node_params) = node_query(source_scope, &request);
147    let mut node_statement = connection.prepare(&node_sql)?;
148    let node_rows = node_statement.query_map(params_from_iter(node_params), |row| {
149        Ok(CodeFrameworkNodeRecord {
150            repository_id: row.get(0)?,
151            source_scope: row.get(1)?,
152            node_id: row.get(2)?,
153            file_id: row.get(3)?,
154            path: row.get(4)?,
155            framework: parse_framework(row.get::<_, String>(5)?.as_str())?,
156            kind: parse_node_kind(row.get::<_, String>(6)?.as_str())?,
157            name: row.get(7)?,
158            detail: row.get(8)?,
159            symbol_snapshot_id: row.get(9)?,
160            byte_range: RepositoryCodeRange {
161                start: row.get(10)?,
162                end: row.get(11)?,
163            },
164            line_range: RepositoryCodeRange {
165                start: row.get(12)?,
166                end: row.get(13)?,
167            },
168        })
169    })?;
170    let mut nodes = node_rows.collect::<Result<Vec<_>, _>>()?;
171    let mut truncated = nodes.len() > request.limit;
172    nodes.truncate(request.limit);
173    drop(node_statement);
174
175    let (edge_sql, edge_params) = edge_query(source_scope, &request);
176    let mut edge_statement = connection.prepare(&edge_sql)?;
177    let edge_rows = edge_statement.query_map(params_from_iter(edge_params), |row| {
178        Ok(CodeFrameworkEdgeRecord {
179            repository_id: row.get(0)?,
180            source_scope: row.get(1)?,
181            edge_id: row.get(2)?,
182            file_id: row.get(3)?,
183            path: row.get(4)?,
184            framework: parse_framework(row.get::<_, String>(5)?.as_str())?,
185            kind: parse_edge_kind(row.get::<_, String>(6)?.as_str())?,
186            source_node_id: row.get(7)?,
187            target_node_id: row.get(8)?,
188            target_hint: row.get(9)?,
189            resolution_state: row.get(10)?,
190            confidence_basis_points: row.get(11)?,
191            confidence_tier: row.get(12)?,
192            byte_range: RepositoryCodeRange {
193                start: row.get(13)?,
194                end: row.get(14)?,
195            },
196            line_range: RepositoryCodeRange {
197                start: row.get(15)?,
198                end: row.get(16)?,
199            },
200        })
201    })?;
202    let mut edges = edge_rows.collect::<Result<Vec<_>, _>>()?;
203    truncated |= edges.len() > request.limit;
204    edges.truncate(request.limit);
205    drop(edge_statement);
206    resolve_edge_targets(connection, source_scope, &mut edges)?;
207
208    Ok(FrameworkGraph {
209        nodes,
210        edges,
211        truncated,
212    })
213}
214
215fn node_query(source_scope: &str, request: &FrameworkGraphRequest) -> (String, Vec<Value>) {
216    let mut sql = String::from(
217        "SELECT repository_id, source_scope, node_id, file_id, path, framework, kind, name,
218                detail, symbol_snapshot_id, byte_start, byte_end, line_start, line_end
219         FROM code_repository_framework_nodes WHERE source_scope = ?",
220    );
221    let mut values = vec![Value::Text(source_scope.to_owned())];
222    push_framework_filter(&mut sql, &mut values, request);
223    if !request.kinds.is_empty() {
224        push_in_filter(
225            &mut sql,
226            &mut values,
227            "kind",
228            request.kinds.iter().map(|kind| kind.as_str()),
229        );
230    }
231    push_path_filter(&mut sql, &mut values, request);
232    for term in query_terms(request) {
233        sql.push_str(
234            " AND (lower(name) LIKE ? ESCAPE '\\' OR lower(COALESCE(detail, '')) LIKE ? ESCAPE '\\' OR lower(path) LIKE ? ESCAPE '\\')",
235        );
236        values.extend(std::iter::repeat_n(Value::Text(term), 3));
237    }
238    sql.push_str(" ORDER BY path, line_start, kind, name LIMIT ?");
239    values.push(Value::Integer(limit_probe(request.limit)));
240    (sql, values)
241}
242
243fn edge_query(source_scope: &str, request: &FrameworkGraphRequest) -> (String, Vec<Value>) {
244    let mut sql = String::from(
245        "SELECT repository_id, source_scope, edge_id, file_id, path, framework, kind,
246                source_node_id, target_node_id, target_hint, resolution_state,
247                confidence_basis_points, confidence_tier, byte_start, byte_end, line_start, line_end
248         FROM code_repository_framework_edges WHERE source_scope = ?",
249    );
250    let mut values = vec![Value::Text(source_scope.to_owned())];
251    push_framework_filter(&mut sql, &mut values, request);
252    push_path_filter(&mut sql, &mut values, request);
253    for term in query_terms(request) {
254        sql.push_str(
255            " AND (lower(kind) LIKE ? ESCAPE '\\' OR lower(COALESCE(target_hint, '')) LIKE ? ESCAPE '\\' OR lower(path) LIKE ? ESCAPE '\\')",
256        );
257        values.extend(std::iter::repeat_n(Value::Text(term), 3));
258    }
259    sql.push_str(" ORDER BY path, line_start, kind, edge_id LIMIT ?");
260    values.push(Value::Integer(limit_probe(request.limit)));
261    (sql, values)
262}
263
264fn push_framework_filter(
265    sql: &mut String,
266    values: &mut Vec<Value>,
267    request: &FrameworkGraphRequest,
268) {
269    if !request.frameworks.is_empty() {
270        push_in_filter(
271            sql,
272            values,
273            "framework",
274            request
275                .frameworks
276                .iter()
277                .map(|framework| framework.as_str()),
278        );
279    }
280}
281
282fn push_in_filter<'a>(
283    sql: &mut String,
284    values: &mut Vec<Value>,
285    column: &str,
286    selected: impl IntoIterator<Item = &'a str>,
287) {
288    let selected = selected.into_iter().collect::<Vec<_>>();
289    sql.push_str(&format!(
290        " AND {column} IN ({})",
291        std::iter::repeat_n("?", selected.len())
292            .collect::<Vec<_>>()
293            .join(", ")
294    ));
295    values.extend(
296        selected
297            .into_iter()
298            .map(|value| Value::Text(value.to_owned())),
299    );
300}
301
302fn push_path_filter(sql: &mut String, values: &mut Vec<Value>, request: &FrameworkGraphRequest) {
303    if request.repository.path_filters.is_empty() {
304        return;
305    }
306    sql.push_str(" AND (");
307    for (index, path) in request.repository.path_filters.iter().enumerate() {
308        if index > 0 {
309            sql.push_str(" OR ");
310        }
311        sql.push_str("path = ? OR path LIKE ? ESCAPE '\\'");
312        values.push(Value::Text(path.clone()));
313        values.push(Value::Text(format!("{}/%", escape_like(path))));
314    }
315    sql.push(')');
316}
317
318fn query_terms(request: &FrameworkGraphRequest) -> Vec<String> {
319    request
320        .query
321        .as_deref()
322        .into_iter()
323        .flat_map(str::split_whitespace)
324        .map(|term| format!("%{}%", escape_like(&term.to_ascii_lowercase())))
325        .collect()
326}
327
328fn escape_like(value: &str) -> String {
329    value
330        .replace('\\', "\\\\")
331        .replace('%', "\\%")
332        .replace('_', "\\_")
333}
334
335fn limit_probe(limit: usize) -> i64 {
336    i64::try_from(limit.saturating_add(1)).unwrap_or(i64::MAX)
337}
338
339fn resolve_edge_targets(
340    connection: &Connection,
341    source_scope: &str,
342    edges: &mut [CodeFrameworkEdgeRecord],
343) -> Result<(), StorageError> {
344    let mut statement = connection.prepare(
345        "SELECT node_id
346         FROM code_repository_framework_nodes
347         WHERE source_scope = ?1 AND framework = ?2
348           AND (
349             path = ?3 OR name = ?3 COLLATE NOCASE OR detail = ?3 COLLATE NOCASE
350             OR lower(replace(name, '-', '')) = ?4
351             OR lower(replace(COALESCE(detail, ''), '-', '')) = ?4
352           )
353           AND (
354             (?5 = 'owns_template' AND kind = 'template')
355             OR (?5 IN ('renders', 'imports') AND kind IN ('component', 'directive', 'pipe'))
356             OR (?5 = 'binds_input' AND kind IN ('input', 'prop', 'model'))
357             OR (?5 = 'handles_output' AND kind IN ('output', 'emit'))
358             OR (?5 = 'writes' AND kind IN ('input', 'output', 'prop', 'emit', 'model'))
359             OR (?5 = 'provides_slot' AND kind = 'slot')
360           )
361         ORDER BY path, line_start, node_id
362         LIMIT 2",
363    )?;
364    for edge in edges
365        .iter_mut()
366        .filter(|edge| edge.target_node_id.is_none())
367    {
368        let Some(target_hint) = edge.target_hint.as_deref() else {
369            continue;
370        };
371        let lookup = normalized_target_hint(edge.kind, target_hint);
372        let normalized = lookup
373            .chars()
374            .filter(|character| *character != '-')
375            .flat_map(char::to_lowercase)
376            .collect::<String>();
377        let candidates = statement
378            .query_map(
379                params![
380                    source_scope,
381                    edge.framework.as_str(),
382                    lookup,
383                    normalized,
384                    edge.kind.as_str()
385                ],
386                |row| {
387                    Ok(TargetCandidate {
388                        node_id: row.get(0)?,
389                    })
390                },
391            )?
392            .collect::<Result<Vec<_>, _>>()?
393            .into_iter()
394            .collect::<Vec<_>>();
395        if let [candidate] = candidates.as_slice() {
396            edge.target_node_id = Some(candidate.node_id.clone());
397            edge.resolution_state = "resolved".to_owned();
398            edge.confidence_basis_points = edge.confidence_basis_points.max(9_000);
399            edge.confidence_tier = "linked".to_owned();
400        } else if candidates.len() > 1 {
401            edge.resolution_state = "ambiguous".to_owned();
402        }
403    }
404    Ok(())
405}
406
407struct TargetCandidate {
408    node_id: String,
409}
410
411fn normalized_target_hint(kind: FrameworkEdgeKind, hint: &str) -> &str {
412    match kind {
413        FrameworkEdgeKind::BindsInput
414        | FrameworkEdgeKind::HandlesOutput
415        | FrameworkEdgeKind::Writes => hint
416            .trim_start_matches("v-bind:")
417            .trim_start_matches("v-on:")
418            .trim_start_matches("v-model:")
419            .trim_matches(|character| matches!(character, '[' | ']' | '(' | ')' | '@' | ':')),
420        _ => hint,
421    }
422}
423
424fn framework_language(framework: FrameworkKind) -> &'static str {
425    match framework {
426        FrameworkKind::Angular => "html",
427        FrameworkKind::Vue => "vue",
428    }
429}
430
431fn parse_framework(value: &str) -> Result<FrameworkKind, rusqlite::Error> {
432    match value {
433        "angular" => Ok(FrameworkKind::Angular),
434        "vue" => Ok(FrameworkKind::Vue),
435        _ => Err(invalid_framework_enum("framework")),
436    }
437}
438
439fn parse_node_kind(value: &str) -> Result<FrameworkNodeKind, rusqlite::Error> {
440    match value {
441        "component" => Ok(FrameworkNodeKind::Component),
442        "directive" => Ok(FrameworkNodeKind::Directive),
443        "pipe" => Ok(FrameworkNodeKind::Pipe),
444        "template" => Ok(FrameworkNodeKind::Template),
445        "input" => Ok(FrameworkNodeKind::Input),
446        "output" => Ok(FrameworkNodeKind::Output),
447        "prop" => Ok(FrameworkNodeKind::Prop),
448        "emit" => Ok(FrameworkNodeKind::Emit),
449        "model" => Ok(FrameworkNodeKind::Model),
450        "slot" => Ok(FrameworkNodeKind::Slot),
451        "template_variable" => Ok(FrameworkNodeKind::TemplateVariable),
452        "control_flow" => Ok(FrameworkNodeKind::ControlFlow),
453        _ => Err(invalid_framework_enum("framework node kind")),
454    }
455}
456
457fn parse_edge_kind(value: &str) -> Result<FrameworkEdgeKind, rusqlite::Error> {
458    match value {
459        "owns_template" => Ok(FrameworkEdgeKind::OwnsTemplate),
460        "declares" => Ok(FrameworkEdgeKind::Declares),
461        "imports" => Ok(FrameworkEdgeKind::Imports),
462        "renders" => Ok(FrameworkEdgeKind::Renders),
463        "binds_input" => Ok(FrameworkEdgeKind::BindsInput),
464        "handles_output" => Ok(FrameworkEdgeKind::HandlesOutput),
465        "reads" => Ok(FrameworkEdgeKind::Reads),
466        "writes" => Ok(FrameworkEdgeKind::Writes),
467        "uses_directive" => Ok(FrameworkEdgeKind::UsesDirective),
468        "provides_slot" => Ok(FrameworkEdgeKind::ProvidesSlot),
469        _ => Err(invalid_framework_enum("framework edge kind")),
470    }
471}
472
473fn invalid_framework_enum(field: &'static str) -> rusqlite::Error {
474    rusqlite::Error::FromSqlConversionFailure(
475        0,
476        rusqlite::types::Type::Text,
477        Box::new(std::io::Error::new(
478            std::io::ErrorKind::InvalidData,
479            format!("unknown {field} in framework graph storage"),
480        )),
481    )
482}
483
484#[cfg(test)]
485#[path = "mod_tests.rs"]
486mod tests;