1use 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;