1use std::collections::{BTreeMap, BTreeSet};
7use std::path::Path;
8
9use serde::{Deserialize, Serialize};
10use sqlparser::ast::{
11 AlterTableOperation, ColumnDef, ColumnOption, Expr, FromTable, IndexColumn, ObjectName, Query, SetExpr, Statement, TableConstraint, TableFactor, TableObject, TableWithJoins
12};
13use sqlparser::dialect::{GenericDialect, MySqlDialect};
14use sqlparser::parser::Parser;
15use thiserror::Error;
16use tree_sitter::Node as SyntaxNode;
17
18use crate::SourceLanguage;
19
20pub const MAX_DATA_INPUT_BYTES: usize = 1_048_576;
22pub const MAX_DATA_SOURCE_LINES: u32 = 100_000;
24pub const MAX_DATA_ITEMS: usize = 4_096;
26pub const MAX_DATA_DEPTH: usize = 32;
28
29const MAX_IDENTIFIER_CHARS: usize = 512;
30const MAX_LITERAL_SQL_BYTES: usize = 65_536;
31
32#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
34#[serde(rename_all = "snake_case")]
35pub enum DataArtifactKind {
36 SqlMigration,
38 DeclarativeSqlSchema,
40 Prisma,
42 Alembic,
44 SqlAlchemy,
46 Diesel,
48 SqlQueryFile,
50 SqlxConfiguration,
52 LiteralQuerySource,
54}
55
56#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
58#[serde(rename_all = "snake_case")]
59pub enum DataFramework {
60 Sqlx,
62 MysqlAsync,
64 PyMysql,
66 Psycopg,
68 SqlAlchemy,
70}
71
72#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
74#[serde(rename_all = "snake_case")]
75pub enum DataArtifactReferenceKind {
76 QueryFile,
78 MigrationDirectory,
80 SqlxDefaultMigrationDirectory,
82}
83
84#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
86#[serde(rename_all = "snake_case")]
87pub enum DataAccessRole {
88 Reader,
90 Writer,
92 ModelBinding,
94 Declaration,
96}
97
98#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
100#[serde(rename_all = "snake_case")]
101pub enum DataOperation {
102 Select,
104 Insert,
106 Update,
108 Delete,
110}
111
112#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
114#[serde(rename_all = "snake_case")]
115pub enum DataWarning {
116 DynamicQuery,
118 UnsupportedConstruct,
120 UnresolvedReference,
122 LimitExceeded,
124 SqlParseRecovery,
126}
127
128#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
130#[serde(transparent)]
131pub struct DataEvidenceLine(u32);
132
133impl DataEvidenceLine {
134 pub fn new(line: u32) -> Result<Self, DataExtractionError> {
141 if line == 0 || line > MAX_DATA_SOURCE_LINES {
142 return Err(DataExtractionError::InvalidEvidenceLine { line });
143 }
144 Ok(Self(line))
145 }
146
147 #[must_use]
149 pub const fn get(self) -> u32 {
150 self.0
151 }
152}
153
154#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
156pub struct DatabaseColumn {
157 pub name: String,
159 pub data_type: Option<String>,
161 pub nullable: Option<bool>,
163 pub primary_key: bool,
165 pub unique: bool,
167 pub default_present: bool,
169 pub evidence: DataEvidenceLine,
171}
172
173#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
175pub struct DatabaseIndex {
176 pub name: Option<String>,
178 pub columns: Vec<String>,
180 pub unique: bool,
182 pub evidence: DataEvidenceLine,
184}
185
186#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
188pub struct DatabaseForeignKey {
189 pub name: Option<String>,
191 pub columns: Vec<String>,
193 pub referenced_table: String,
195 pub referenced_columns: Vec<String>,
197 pub evidence: DataEvidenceLine,
199}
200
201#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
203pub struct DatabaseTable {
204 pub database: Option<String>,
206 pub schema: Option<String>,
208 pub name: String,
210 pub columns: Vec<DatabaseColumn>,
212 pub indexes: Vec<DatabaseIndex>,
214 pub foreign_keys: Vec<DatabaseForeignKey>,
216 pub evidence: DataEvidenceLine,
218}
219
220#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
222pub struct MigrationMetadata {
223 pub revision: Option<String>,
225 pub down_revision: Option<String>,
227 pub order_hint: Option<u64>,
229 pub reversible: bool,
231 pub evidence: DataEvidenceLine,
233}
234
235#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
237pub struct DataAccessObservation {
238 pub role: DataAccessRole,
240 pub table: String,
242 pub model: Option<String>,
244 pub owner: Option<String>,
246 pub operation: Option<DataOperation>,
248 pub evidence: DataEvidenceLine,
250}
251
252#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
254pub struct DataArtifactReference {
255 pub framework: DataFramework,
257 pub kind: DataArtifactReferenceKind,
259 pub path: String,
261 pub owner: Option<String>,
263 pub evidence: DataEvidenceLine,
265}
266
267#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
269pub struct DataDocument {
270 pub source_path: String,
272 pub artifact_kind: DataArtifactKind,
274 pub database_name: Option<String>,
276 pub schema_name: Option<String>,
278 pub tables: Vec<DatabaseTable>,
280 pub migration: Option<MigrationMetadata>,
282 pub accesses: Vec<DataAccessObservation>,
284 pub frameworks: Vec<DataFramework>,
286 pub references: Vec<DataArtifactReference>,
288 pub owners: Vec<String>,
290 pub warnings: Vec<DataWarning>,
292 pub incomplete: bool,
294}
295
296#[derive(Debug, Error, PartialEq, Eq)]
298pub enum DataExtractionError {
299 #[error("database artifact input exceeds the byte limit ({actual} > {maximum})")]
301 InputTooLarge {
302 actual: usize,
304 maximum: usize,
306 },
307 #[error("database artifact input exceeds the line limit ({actual} > {maximum})")]
309 TooManyLines {
310 actual: u32,
312 maximum: u32,
314 },
315 #[error("unsupported database artifact filename or extension")]
317 UnsupportedArtifact,
318 #[error("SQL artifact could not be parsed")]
320 SqlParseFailed,
321 #[error("database artifact exceeds the structured item limit")]
323 ItemLimitExceeded,
324 #[error("evidence line {line} is outside the supported range")]
326 InvalidEvidenceLine {
327 line: u32,
329 },
330}
331
332pub fn extract_data_artifact(
345 source_path: &str,
346 input: &str,
347) -> Result<DataDocument, DataExtractionError> {
348 let kind = artifact_kind(source_path, input).ok_or(DataExtractionError::UnsupportedArtifact)?;
349 if validate_input(input).is_err() {
350 let mut document = empty_document(source_path, kind);
351 mark_incomplete(&mut document, DataWarning::LimitExceeded);
352 return Ok(document);
353 }
354 let mut document = match kind {
355 DataArtifactKind::SqlMigration
356 | DataArtifactKind::DeclarativeSqlSchema
357 | DataArtifactKind::SqlQueryFile => parse_sql_artifact(source_path, input, kind),
358 DataArtifactKind::SqlxConfiguration => parse_sqlx_configuration(source_path, input),
359 DataArtifactKind::Prisma => parse_prisma(source_path, input),
360 DataArtifactKind::Alembic => parse_alembic(source_path, input),
361 DataArtifactKind::SqlAlchemy => parse_sqlalchemy(source_path, input),
362 DataArtifactKind::Diesel => parse_diesel(source_path, input),
363 DataArtifactKind::LiteralQuerySource => {
364 return Err(DataExtractionError::UnsupportedArtifact);
365 }
366 };
367 finish_document(&mut document);
368 Ok(document)
369}
370
371#[must_use]
377pub fn parse_literal_sql_source(
378 language: SourceLanguage,
379 source_path: &str,
380 input: &str,
381) -> DataDocument {
382 parse_literal_sql_source_at_root(
383 language,
384 source_path,
385 &inferred_crate_root(source_path),
386 input,
387 )
388}
389
390#[must_use]
395pub fn parse_literal_sql_source_at_root(
396 language: SourceLanguage,
397 source_path: &str,
398 crate_root: &str,
399 input: &str,
400) -> DataDocument {
401 let mut document = empty_document(source_path, DataArtifactKind::LiteralQuerySource);
402 if validate_input(input).is_err() {
403 mark_incomplete(&mut document, DataWarning::LimitExceeded);
404 return document;
405 }
406
407 if language == SourceLanguage::Rust {
408 extract_rust_database_source(input, crate_root, &mut document);
409 } else if language == SourceLanguage::Python {
410 if input.contains("pymysql") {
411 document.frameworks.push(DataFramework::PyMysql);
412 }
413 if input.contains("psycopg") {
414 document.frameworks.push(DataFramework::Psycopg);
415 }
416 if input.contains("sqlalchemy") || input.contains(".query(") {
417 extract_sqlalchemy_accesses(input, &mut document);
418 }
419 }
420 let literals = quoted_literals(input, language);
421 if literals.len() >= MAX_DATA_ITEMS {
422 mark_incomplete(&mut document, DataWarning::LimitExceeded);
423 }
424 for literal in literals {
425 if literal.value.len() > MAX_LITERAL_SQL_BYTES || !has_query_context(input, &literal) {
426 continue;
427 }
428 let query = if language == SourceLanguage::Python
429 && (literal.interpolated || python_format_call_after(input, &literal))
430 {
431 mark_incomplete(&mut document, DataWarning::DynamicQuery);
432 sanitize_python_f_string(&literal.value)
433 } else if literal.dynamic {
434 mark_incomplete(&mut document, DataWarning::DynamicQuery);
435 None
436 } else {
437 Some(literal.value.clone())
438 };
439 let Some(query) = query else {
440 continue;
441 };
442 let first_keyword = first_sql_keyword(&query);
443 if !matches!(
444 first_keyword.as_deref(),
445 Some("SELECT" | "WITH" | "INSERT" | "UPDATE" | "DELETE")
446 ) {
447 continue;
448 }
449 let Some(statements) = parse_sql_with_supported_dialects(&query) else {
450 mark_incomplete(&mut document, DataWarning::SqlParseRecovery);
451 continue;
452 };
453 let owner = owner_at_line(language, input, literal.line);
454 if let Some(owner) = owner.as_ref() {
455 document.owners.push(owner.clone());
456 }
457 for statement in &statements {
458 append_statement_accesses(
459 statement,
460 evidence(literal.line),
461 owner.as_deref(),
462 &mut document,
463 0,
464 );
465 }
466 document
467 .accesses
468 .retain(|access| !access.table.contains("__csg_dynamic_value__"));
469 }
470
471 if (language != SourceLanguage::Rust || document.frameworks.is_empty())
472 && source_has_dynamic_query(input, language)
473 {
474 mark_incomplete(&mut document, DataWarning::DynamicQuery);
475 }
476 finish_document(&mut document);
477 document
478}
479
480fn validate_input(input: &str) -> Result<(), DataExtractionError> {
481 if input.len() > MAX_DATA_INPUT_BYTES {
482 return Err(DataExtractionError::InputTooLarge {
483 actual: input.len(),
484 maximum: MAX_DATA_INPUT_BYTES,
485 });
486 }
487 let lines = input.lines().count().max(1);
488 let lines = u32::try_from(lines).unwrap_or(u32::MAX);
489 if lines > MAX_DATA_SOURCE_LINES {
490 return Err(DataExtractionError::TooManyLines {
491 actual: lines,
492 maximum: MAX_DATA_SOURCE_LINES,
493 });
494 }
495 Ok(())
496}
497
498fn artifact_kind(source_path: &str, input: &str) -> Option<DataArtifactKind> {
499 let path = Path::new(source_path);
500 let filename = path.file_name()?.to_str()?.to_ascii_lowercase();
501 let extension = path.extension()?.to_str()?.to_ascii_lowercase();
502 let normalized = source_path.replace('\\', "/").to_ascii_lowercase();
503 match (filename.as_str(), extension.as_str()) {
504 ("sqlx.toml", "toml") => Some(DataArtifactKind::SqlxConfiguration),
505 ("schema.sql" | "structure.sql" | "init.sql", "sql") => {
506 Some(DataArtifactKind::DeclarativeSqlSchema)
507 }
508 (_, "sql") if sql_migration_path(&normalized, &filename) => {
509 Some(DataArtifactKind::SqlMigration)
510 }
511 (_, "sql") if sql_contains_only_queries(input) => Some(DataArtifactKind::SqlQueryFile),
512 (_, "sql") => Some(DataArtifactKind::DeclarativeSqlSchema),
513 (_, "prisma") => Some(DataArtifactKind::Prisma),
514 ("schema.rs", "rs") => Some(DataArtifactKind::Diesel),
515 (_, "py")
516 if normalized.contains("/alembic/")
517 || normalized.contains("/versions/")
518 || normalized.contains("/migrations/") =>
519 {
520 Some(DataArtifactKind::Alembic)
521 }
522 ("model.py" | "models.py" | "entities.py", "py") => Some(DataArtifactKind::SqlAlchemy),
523 _ => None,
524 }
525}
526
527fn empty_document(source_path: &str, artifact_kind: DataArtifactKind) -> DataDocument {
528 DataDocument {
529 source_path: source_path.to_owned(),
530 artifact_kind,
531 database_name: None,
532 schema_name: None,
533 tables: Vec::new(),
534 migration: None,
535 accesses: Vec::new(),
536 frameworks: Vec::new(),
537 references: Vec::new(),
538 owners: Vec::new(),
539 warnings: Vec::new(),
540 incomplete: false,
541 }
542}
543
544fn parse_sql_artifact(source_path: &str, input: &str, kind: DataArtifactKind) -> DataDocument {
545 let mut document = empty_document(source_path, kind);
546 if let Some(statements) = parse_sql_with_supported_dialects(input) {
547 let starts = sql_statement_lines(input);
548 for (index, statement) in statements.iter().enumerate() {
549 append_sql_artifact_statement(
550 statement,
551 starts.get(index).copied().unwrap_or(1),
552 kind,
553 &mut document,
554 );
555 }
556 } else {
557 mark_incomplete(&mut document, DataWarning::SqlParseRecovery);
558 for chunk in sql_statement_chunks(input) {
559 if !sql_chunk_is_relevant(chunk.text, kind) {
560 continue;
561 }
562 let Some(statements) = parse_sql_with_supported_dialects(chunk.text) else {
563 continue;
564 };
565 for statement in &statements {
566 append_sql_artifact_statement(statement, chunk.line, kind, &mut document);
567 }
568 }
569 }
570
571 if kind == DataArtifactKind::SqlMigration {
572 document.migration = Some(sql_migration_metadata(source_path, input));
573 }
574 document
575}
576
577fn parse_sql_with_supported_dialects(input: &str) -> Option<Vec<Statement>> {
578 Parser::parse_sql(&GenericDialect {}, input)
579 .or_else(|_| Parser::parse_sql(&MySqlDialect {}, input))
580 .ok()
581}
582
583fn append_sql_artifact_statement(
584 statement: &Statement,
585 line: u32,
586 kind: DataArtifactKind,
587 document: &mut DataDocument,
588) {
589 if kind == DataArtifactKind::SqlQueryFile {
590 append_statement_accesses(statement, evidence(line), None, document, 0);
591 } else if !append_schema_statement(statement, evidence(line), document) {
592 mark_incomplete(document, DataWarning::UnsupportedConstruct);
593 }
594}
595
596fn sql_chunk_is_relevant(input: &str, kind: DataArtifactKind) -> bool {
597 let Some(keyword) = first_sql_keyword(input) else {
598 return false;
599 };
600 if kind == DataArtifactKind::SqlQueryFile {
601 matches!(
602 keyword.as_str(),
603 "SELECT" | "WITH" | "INSERT" | "UPDATE" | "DELETE"
604 )
605 } else {
606 matches!(keyword.as_str(), "CREATE" | "ALTER")
607 }
608}
609
610fn parse_sqlx_configuration(source_path: &str, input: &str) -> DataDocument {
611 let mut document = empty_document(source_path, DataArtifactKind::SqlxConfiguration);
612 document.frameworks.push(DataFramework::Sqlx);
613 let crate_root = Path::new(source_path)
614 .parent()
615 .and_then(Path::to_str)
616 .unwrap_or_default()
617 .replace('\\', "/");
618 let mut section = "";
619
620 for (index, source_line) in input.lines().enumerate() {
621 let line = strip_toml_comment(source_line).trim();
622 if line.starts_with('[') && line.ends_with(']') {
623 section = line[1..line.len() - 1].trim();
624 continue;
625 }
626 if section != "migrate" {
627 continue;
628 }
629 let Some((key, value)) = line.split_once('=') else {
630 continue;
631 };
632 if key.trim() != "migrations-dir" {
633 continue;
634 }
635 let Some(value) = toml_string(value.trim()) else {
636 mark_incomplete(&mut document, DataWarning::UnresolvedReference);
637 continue;
638 };
639 push_sqlx_reference(
640 DataArtifactReferenceKind::MigrationDirectory,
641 value,
642 evidence(u32::try_from(index + 1).unwrap_or(u32::MAX)),
643 &crate_root,
644 None,
645 &mut document,
646 );
647 }
648 document
649}
650
651fn strip_toml_comment(line: &str) -> &str {
652 let mut quote = None;
653 let mut escaped = false;
654 for (index, character) in line.char_indices() {
655 if escaped {
656 escaped = false;
657 continue;
658 }
659 match character {
660 '\\' if quote == Some('"') => escaped = true,
661 '\'' | '"' if quote == Some(character) => quote = None,
662 '\'' | '"' if quote.is_none() => quote = Some(character),
663 '#' if quote.is_none() => return &line[..index],
664 _ => {}
665 }
666 }
667 line
668}
669
670fn toml_string(value: &str) -> Option<&str> {
671 let quote = value.chars().next()?;
672 if !matches!(quote, '\'' | '"') || !value.ends_with(quote) || value.len() < 2 {
673 return None;
674 }
675 Some(&value[1..value.len() - 1])
676}
677
678fn append_schema_statement(
679 statement: &Statement,
680 line: DataEvidenceLine,
681 document: &mut DataDocument,
682) -> bool {
683 match statement {
684 Statement::CreateTable(create) => {
685 let mut table = table_from_name(&create.name, line);
686 table.columns = create
687 .columns
688 .iter()
689 .map(|column| sql_column(column, line))
690 .collect();
691 apply_table_constraints(&mut table, &create.constraints, line);
692 push_declaration_access(document, &table, None);
693 document.tables.push(table);
694 }
695 Statement::CreateIndex(index) => {
696 let table = ensure_table(document, &index.table_name, line);
697 table.indexes.push(DatabaseIndex {
698 name: index.name.as_ref().map(object_name),
699 columns: index.columns.iter().filter_map(index_column_name).collect(),
700 unique: index.unique,
701 evidence: line,
702 });
703 }
704 Statement::AlterTable(alter) => {
705 let table = ensure_table(document, &alter.name, line);
706 for operation in &alter.operations {
707 apply_alter_operation(table, operation, line);
708 }
709 }
710 _ => return false,
711 }
712 true
713}
714
715fn sql_column(column: &ColumnDef, line: DataEvidenceLine) -> DatabaseColumn {
716 let mut nullable = None;
717 let mut primary_key = false;
718 let mut unique = false;
719 let mut default_present = false;
720 for option in &column.options {
721 match &option.option {
722 ColumnOption::Null => nullable = Some(true),
723 ColumnOption::NotNull => nullable = Some(false),
724 ColumnOption::PrimaryKey(_) => primary_key = true,
725 ColumnOption::Unique(_) => unique = true,
726 ColumnOption::Default(_)
727 | ColumnOption::Materialized(_)
728 | ColumnOption::Generated { .. }
729 | ColumnOption::Identity(_) => default_present = true,
730 _ => {}
731 }
732 }
733 DatabaseColumn {
734 name: bounded_identifier(&column.name.value),
735 data_type: Some(safe_data_type(&column.data_type.to_string())),
736 nullable,
737 primary_key,
738 unique,
739 default_present,
740 evidence: line,
741 }
742}
743
744fn apply_table_constraints(
745 table: &mut DatabaseTable,
746 constraints: &[TableConstraint],
747 line: DataEvidenceLine,
748) {
749 for constraint in constraints {
750 match constraint {
751 TableConstraint::PrimaryKey(primary) => {
752 let columns = primary
753 .columns
754 .iter()
755 .filter_map(index_column_name)
756 .collect::<Vec<_>>();
757 mark_columns(table, &columns, true, false);
758 }
759 TableConstraint::Unique(unique) => {
760 let columns = unique
761 .columns
762 .iter()
763 .filter_map(index_column_name)
764 .collect::<Vec<_>>();
765 mark_columns(table, &columns, false, true);
766 table.indexes.push(DatabaseIndex {
767 name: unique
768 .name
769 .as_ref()
770 .or(unique.index_name.as_ref())
771 .map(|name| bounded_identifier(&name.value)),
772 columns,
773 unique: true,
774 evidence: line,
775 });
776 }
777 TableConstraint::ForeignKey(foreign) => {
778 table.foreign_keys.push(DatabaseForeignKey {
779 name: foreign
780 .name
781 .as_ref()
782 .map(|name| bounded_identifier(&name.value)),
783 columns: foreign
784 .columns
785 .iter()
786 .map(|name| bounded_identifier(&name.value))
787 .collect(),
788 referenced_table: object_name(&foreign.foreign_table),
789 referenced_columns: foreign
790 .referred_columns
791 .iter()
792 .map(|name| bounded_identifier(&name.value))
793 .collect(),
794 evidence: line,
795 });
796 }
797 TableConstraint::Index(index) => {
798 table.indexes.push(DatabaseIndex {
799 name: index
800 .name
801 .as_ref()
802 .map(|name| bounded_identifier(&name.value)),
803 columns: index.columns.iter().filter_map(index_column_name).collect(),
804 unique: false,
805 evidence: line,
806 });
807 }
808 _ => {}
809 }
810 }
811}
812
813fn apply_alter_operation(
814 table: &mut DatabaseTable,
815 operation: &AlterTableOperation,
816 line: DataEvidenceLine,
817) {
818 match operation {
819 AlterTableOperation::AddColumn { column_def, .. } => {
820 table.columns.push(sql_column(column_def, line));
821 }
822 AlterTableOperation::AddConstraint { constraint, .. } => {
823 apply_table_constraints(table, std::slice::from_ref(constraint), line);
824 }
825 _ => {}
826 }
827}
828
829fn parse_prisma(source_path: &str, input: &str) -> DataDocument {
830 let mut document = empty_document(source_path, DataArtifactKind::Prisma);
831 for block in named_blocks(input, "model") {
832 let model = bounded_identifier(block.name);
833 let mapped = find_call_literal(block.body, "@@map").unwrap_or_else(|| model.clone());
834 let schema = find_call_literal(block.body, "@@schema");
835 let mut table = table_from_qualified_text(&mapped, evidence(block.line));
836 table.schema = schema.map(|value| bounded_identifier(&value));
837
838 for (offset, raw_line) in block.body.lines().enumerate() {
839 let line = add_line(block.line, offset);
840 let trimmed = raw_line.trim();
841 if trimmed.is_empty()
842 || trimmed.starts_with("//")
843 || trimmed.starts_with("@@")
844 || trimmed.starts_with('}')
845 {
846 continue;
847 }
848 let fields = trimmed.split_whitespace().collect::<Vec<_>>();
849 if fields.len() < 2 {
850 continue;
851 }
852 let field_name =
853 find_call_literal(trimmed, "@map").unwrap_or_else(|| bounded_identifier(fields[0]));
854 let field_type = fields[1];
855 if field_type.starts_with(char::is_uppercase) && trimmed.contains("@relation") {
856 if let Some(foreign_key) = prisma_foreign_key(trimmed, field_type, evidence(line)) {
857 table.foreign_keys.push(foreign_key);
858 }
859 continue;
860 }
861 table.columns.push(DatabaseColumn {
862 name: bounded_identifier(&field_name),
863 data_type: Some(bounded_identifier(
864 field_type.trim_end_matches('?').trim_end_matches("[]"),
865 )),
866 nullable: Some(field_type.ends_with('?')),
867 primary_key: trimmed.contains("@id"),
868 unique: trimmed.contains("@unique"),
869 default_present: trimmed.contains("@default("),
870 evidence: evidence(line),
871 });
872 }
873 document.owners.push(model.clone());
874 document.accesses.push(DataAccessObservation {
875 role: DataAccessRole::ModelBinding,
876 table: qualified_table_name(&table),
877 model: None,
878 owner: Some(model),
879 operation: None,
880 evidence: table.evidence,
881 });
882 document.tables.push(table);
883 }
884 document
885}
886
887fn prisma_foreign_key(
888 line: &str,
889 referenced_model: &str,
890 evidence: DataEvidenceLine,
891) -> Option<DatabaseForeignKey> {
892 let fields = bracket_values_after(line, "fields:")?;
893 let references = bracket_values_after(line, "references:").unwrap_or_default();
894 Some(DatabaseForeignKey {
895 name: None,
896 columns: fields,
897 referenced_table: bounded_identifier(referenced_model.trim_end_matches('?')),
898 referenced_columns: references,
899 evidence,
900 })
901}
902
903fn parse_alembic(source_path: &str, input: &str) -> DataDocument {
904 let mut document = empty_document(source_path, DataArtifactKind::Alembic);
905 document.migration = Some(MigrationMetadata {
906 revision: assignment_literal(input, "revision"),
907 down_revision: assignment_literal(input, "down_revision"),
908 order_hint: filename_order_hint(source_path),
909 reversible: input
910 .lines()
911 .any(|line| line.trim_start().starts_with("def downgrade(")),
912 evidence: evidence(assignment_line(input, "revision").unwrap_or(1)),
913 });
914
915 for call in collect_calls(input, "op.create_table(") {
916 let Some(name) = first_quoted(&call.body) else {
917 mark_incomplete(&mut document, DataWarning::DynamicQuery);
918 continue;
919 };
920 let mut table = table_from_qualified_text(&name, evidence(call.line));
921 for column_call in collect_calls(&call.body, "sa.Column(") {
922 if let Some(column) =
923 python_column(&column_call.body, add_evidence(call.line, column_call.line))
924 {
925 table.columns.push(column);
926 }
927 }
928 for foreign_call in collect_calls(&call.body, "sa.ForeignKeyConstraint(") {
929 if let Some(foreign) = python_foreign_key(
930 &foreign_call.body,
931 add_evidence(call.line, foreign_call.line),
932 ) {
933 table.foreign_keys.push(foreign);
934 }
935 }
936 push_declaration_access(&mut document, &table, Some("upgrade"));
937 document.tables.push(table);
938 }
939
940 for call in collect_calls(input, "op.add_column(") {
941 let quoted = quoted_values(&call.body);
942 let Some(table_name) = quoted.first() else {
943 mark_incomplete(&mut document, DataWarning::DynamicQuery);
944 continue;
945 };
946 let table = ensure_text_table(&mut document, table_name, evidence(call.line));
947 if let Some(column_call) = collect_calls(&call.body, "sa.Column(").first()
948 && let Some(column) = python_column(&column_call.body, evidence(call.line))
949 {
950 table.columns.push(column);
951 }
952 }
953
954 for call in collect_calls(input, "op.create_index(") {
955 let quoted = quoted_values(&call.body);
956 if quoted.len() < 2 {
957 mark_incomplete(&mut document, DataWarning::DynamicQuery);
958 continue;
959 }
960 let columns = bracket_values(&call.body).unwrap_or_default();
961 let unique = call.body.split_whitespace().any(|part| {
962 part.trim_matches(|character: char| character == ',' || character == ')')
963 == "unique=True"
964 });
965 let table = ensure_text_table(&mut document, "ed[1], evidence(call.line));
966 table.indexes.push(DatabaseIndex {
967 name: Some(bounded_identifier("ed[0])),
968 columns,
969 unique,
970 evidence: evidence(call.line),
971 });
972 }
973 document
974}
975
976fn parse_sqlalchemy(source_path: &str, input: &str) -> DataDocument {
977 let mut document = empty_document(source_path, DataArtifactKind::SqlAlchemy);
978 for class in python_classes(input) {
979 let Some(table_name) = assignment_literal(class.body, "__tablename__") else {
980 continue;
981 };
982 let mut table = table_from_qualified_text(&table_name, evidence(class.line));
983 table.schema = table_argument_literal(class.body, "schema");
984 for (offset, line) in class.body.lines().enumerate() {
985 let Some(column_position) = line.find("Column(") else {
986 continue;
987 };
988 let Some((attribute, _)) = line[..column_position].split_once('=') else {
989 continue;
990 };
991 let open = column_position + "Column(".len();
992 let body = balanced_slice(&line[open..]).unwrap_or(&line[open..]);
993 if let Some(column) = python_column_with_fallback(
994 body,
995 evidence(add_line(class.line, offset)),
996 Some(attribute.trim()),
997 ) {
998 if let Some(reference) = call_literal(body, "ForeignKey") {
999 let (foreign_table, foreign_column) = split_reference(&reference);
1000 table.foreign_keys.push(DatabaseForeignKey {
1001 name: None,
1002 columns: vec![column.name.clone()],
1003 referenced_table: foreign_table,
1004 referenced_columns: foreign_column.into_iter().collect(),
1005 evidence: column.evidence,
1006 });
1007 }
1008 table.columns.push(column);
1009 }
1010 }
1011 document.owners.push(class.name.to_owned());
1012 document.accesses.push(DataAccessObservation {
1013 role: DataAccessRole::ModelBinding,
1014 table: qualified_table_name(&table),
1015 model: None,
1016 owner: Some(class.name.to_owned()),
1017 operation: None,
1018 evidence: table.evidence,
1019 });
1020 document.tables.push(table);
1021 }
1022 document
1023}
1024
1025fn parse_diesel(source_path: &str, input: &str) -> DataDocument {
1026 let mut document = empty_document(source_path, DataArtifactKind::Diesel);
1027 for block in macro_blocks(input, "table!") {
1028 let header = block
1029 .body
1030 .lines()
1031 .find(|line| line.contains('('))
1032 .unwrap_or_default()
1033 .trim();
1034 let name = header.split('(').next().unwrap_or_default().trim();
1035 if name.is_empty() {
1036 mark_incomplete(&mut document, DataWarning::UnsupportedConstruct);
1037 continue;
1038 }
1039 let mut table = table_from_qualified_text(name, evidence(block.line));
1040 let primary_keys = header
1041 .split_once('(')
1042 .and_then(|(_, rest)| rest.split_once(')'))
1043 .map_or_else(Vec::new, |(keys, _)| comma_identifiers(keys));
1044 for (offset, raw_line) in block.body.lines().enumerate() {
1045 let Some((name, data_type)) = raw_line.split_once("->") else {
1046 continue;
1047 };
1048 let name = bounded_identifier(name.trim());
1049 if name.is_empty() {
1050 continue;
1051 }
1052 table.columns.push(DatabaseColumn {
1053 primary_key: primary_keys.contains(&name),
1054 name,
1055 data_type: Some(bounded_identifier(
1056 data_type.trim().trim_end_matches(',').trim(),
1057 )),
1058 nullable: Some(data_type.contains("Nullable<")),
1059 unique: false,
1060 default_present: false,
1061 evidence: evidence(add_line(block.line, offset)),
1062 });
1063 }
1064 push_declaration_access(&mut document, &table, None);
1065 document.tables.push(table);
1066 }
1067 document
1068}
1069
1070fn append_statement_accesses(
1071 statement: &Statement,
1072 line: DataEvidenceLine,
1073 owner: Option<&str>,
1074 document: &mut DataDocument,
1075 depth: usize,
1076) {
1077 if depth >= MAX_DATA_DEPTH {
1078 mark_incomplete(document, DataWarning::LimitExceeded);
1079 return;
1080 }
1081 let mut observations = BTreeSet::new();
1082 match statement {
1083 Statement::Query(query) => {
1084 let mut tables = BTreeSet::new();
1085 collect_query_tables(query, &mut tables, depth + 1);
1086 for table in tables {
1087 observations.insert((DataAccessRole::Reader, DataOperation::Select, table));
1088 }
1089 }
1090 Statement::Insert(insert) => {
1091 if let TableObject::TableName(name) = &insert.table {
1092 observations.insert((
1093 DataAccessRole::Writer,
1094 DataOperation::Insert,
1095 object_name(name),
1096 ));
1097 }
1098 if let Some(query) = insert.source.as_deref() {
1099 let mut tables = BTreeSet::new();
1100 collect_query_tables(query, &mut tables, depth + 1);
1101 for table in tables {
1102 observations.insert((DataAccessRole::Reader, DataOperation::Select, table));
1103 }
1104 }
1105 }
1106 Statement::Update(update) => {
1107 let mut tables = BTreeSet::new();
1108 collect_table_with_joins(&update.table, &mut tables, depth + 1);
1109 if let Some(table) = tables.into_iter().next() {
1110 observations.insert((DataAccessRole::Writer, DataOperation::Update, table));
1111 }
1112 }
1113 Statement::Delete(delete) => {
1114 let tables = match &delete.from {
1115 FromTable::WithFromKeyword(tables) | FromTable::WithoutKeyword(tables) => tables,
1116 };
1117 let mut names = BTreeSet::new();
1118 for table in tables {
1119 collect_table_with_joins(table, &mut names, depth + 1);
1120 }
1121 for table in names {
1122 observations.insert((DataAccessRole::Writer, DataOperation::Delete, table));
1123 }
1124 }
1125 _ => mark_incomplete(document, DataWarning::UnsupportedConstruct),
1126 }
1127 document
1128 .accesses
1129 .extend(
1130 observations
1131 .into_iter()
1132 .map(|(role, operation, table)| DataAccessObservation {
1133 role,
1134 table,
1135 model: None,
1136 owner: owner.map(ToOwned::to_owned),
1137 operation: Some(operation),
1138 evidence: line,
1139 }),
1140 );
1141}
1142
1143fn collect_query_tables(query: &Query, output: &mut BTreeSet<String>, depth: usize) {
1144 if depth >= MAX_DATA_DEPTH {
1145 return;
1146 }
1147 if let Some(with) = &query.with {
1148 for cte in &with.cte_tables {
1149 collect_query_tables(&cte.query, output, depth + 1);
1150 }
1151 }
1152 collect_set_expr_tables(&query.body, output, depth + 1);
1153}
1154
1155fn collect_set_expr_tables(expression: &SetExpr, output: &mut BTreeSet<String>, depth: usize) {
1156 if depth >= MAX_DATA_DEPTH {
1157 return;
1158 }
1159 match expression {
1160 SetExpr::Select(select) => {
1161 for table in &select.from {
1162 collect_table_with_joins(table, output, depth + 1);
1163 }
1164 }
1165 SetExpr::Query(query) => collect_query_tables(query, output, depth + 1),
1166 SetExpr::SetOperation { left, right, .. } => {
1167 collect_set_expr_tables(left, output, depth + 1);
1168 collect_set_expr_tables(right, output, depth + 1);
1169 }
1170 SetExpr::Insert(statement) | SetExpr::Update(statement) | SetExpr::Delete(statement) => {
1171 let mut ignored = empty_document("", DataArtifactKind::LiteralQuerySource);
1172 append_statement_accesses(statement, evidence(1), None, &mut ignored, depth + 1);
1173 output.extend(ignored.accesses.into_iter().map(|access| access.table));
1174 }
1175 SetExpr::Table(table) => {
1176 if let Some(table_name) = table.table_name.as_deref() {
1177 output.insert(bounded_identifier(table_name));
1178 }
1179 }
1180 SetExpr::Values(_) | SetExpr::Merge(_) => {}
1181 }
1182}
1183
1184fn collect_table_with_joins(table: &TableWithJoins, output: &mut BTreeSet<String>, depth: usize) {
1185 collect_table_factor(&table.relation, output, depth + 1);
1186 for join in &table.joins {
1187 collect_table_factor(&join.relation, output, depth + 1);
1188 }
1189}
1190
1191fn collect_table_factor(factor: &TableFactor, output: &mut BTreeSet<String>, depth: usize) {
1192 if depth >= MAX_DATA_DEPTH {
1193 return;
1194 }
1195 match factor {
1196 TableFactor::Table { name, args, .. } if args.is_none() => {
1197 output.insert(object_name(name));
1198 }
1199 TableFactor::Derived { subquery, .. } => collect_query_tables(subquery, output, depth + 1),
1200 _ => {}
1201 }
1202}
1203
1204fn table_from_name(name: &ObjectName, line: DataEvidenceLine) -> DatabaseTable {
1205 table_from_qualified_text(&object_name(name), line)
1206}
1207
1208fn table_from_qualified_text(name: &str, line: DataEvidenceLine) -> DatabaseTable {
1209 let parts = name
1210 .split('.')
1211 .map(clean_identifier)
1212 .filter(|part| !part.is_empty())
1213 .collect::<Vec<_>>();
1214 let table_name = parts.last().cloned().unwrap_or_default();
1215 let schema = (parts.len() >= 2).then(|| parts[parts.len() - 2].clone());
1216 let database = (parts.len() >= 3).then(|| parts[parts.len() - 3].clone());
1217 DatabaseTable {
1218 database,
1219 schema,
1220 name: table_name,
1221 columns: Vec::new(),
1222 indexes: Vec::new(),
1223 foreign_keys: Vec::new(),
1224 evidence: line,
1225 }
1226}
1227
1228fn ensure_table<'a>(
1229 document: &'a mut DataDocument,
1230 name: &ObjectName,
1231 line: DataEvidenceLine,
1232) -> &'a mut DatabaseTable {
1233 ensure_text_table(document, &object_name(name), line)
1234}
1235
1236fn ensure_text_table<'a>(
1237 document: &'a mut DataDocument,
1238 name: &str,
1239 line: DataEvidenceLine,
1240) -> &'a mut DatabaseTable {
1241 let candidate = table_from_qualified_text(name, line);
1242 let key = table_key(&candidate);
1243 if let Some(index) = document
1244 .tables
1245 .iter()
1246 .position(|table| table_key(table) == key)
1247 {
1248 return &mut document.tables[index];
1249 }
1250 document.tables.push(candidate);
1251 let index = document.tables.len() - 1;
1252 &mut document.tables[index]
1253}
1254
1255fn push_declaration_access(
1256 document: &mut DataDocument,
1257 table: &DatabaseTable,
1258 owner: Option<&str>,
1259) {
1260 document.accesses.push(DataAccessObservation {
1261 role: DataAccessRole::Declaration,
1262 table: qualified_table_name(table),
1263 model: None,
1264 owner: owner.map(ToOwned::to_owned),
1265 operation: None,
1266 evidence: table.evidence,
1267 });
1268}
1269
1270fn mark_columns(table: &mut DatabaseTable, names: &[String], primary: bool, unique: bool) {
1271 for column in &mut table.columns {
1272 if names
1273 .iter()
1274 .any(|name| clean_identifier(name) == column.name)
1275 {
1276 column.primary_key |= primary;
1277 column.unique |= unique;
1278 }
1279 }
1280}
1281
1282fn object_name(name: &ObjectName) -> String {
1283 bounded_identifier(&name.to_string())
1284}
1285
1286fn index_column_name(column: &IndexColumn) -> Option<String> {
1287 match &column.column.expr {
1288 Expr::Identifier(identifier) => Some(bounded_identifier(&identifier.value)),
1289 Expr::CompoundIdentifier(identifiers) => Some(
1290 identifiers
1291 .iter()
1292 .map(|identifier| bounded_identifier(&identifier.value))
1293 .collect::<Vec<_>>()
1294 .join("."),
1295 ),
1296 _ => None,
1297 }
1298}
1299
1300fn safe_data_type(value: &str) -> String {
1301 if value.contains(['\'', '"']) {
1302 value.split_once('(').map_or_else(
1303 || bounded_identifier(value),
1304 |(name, _)| bounded_identifier(name),
1305 )
1306 } else {
1307 bounded_identifier(value)
1308 }
1309}
1310
1311fn qualified_table_name(table: &DatabaseTable) -> String {
1312 [
1313 table.database.as_deref(),
1314 table.schema.as_deref(),
1315 Some(&table.name),
1316 ]
1317 .into_iter()
1318 .flatten()
1319 .collect::<Vec<_>>()
1320 .join(".")
1321}
1322
1323fn table_key(table: &DatabaseTable) -> (Option<&str>, Option<&str>, &str) {
1324 (
1325 table.database.as_deref(),
1326 table.schema.as_deref(),
1327 &table.name,
1328 )
1329}
1330
1331fn finish_document(document: &mut DataDocument) {
1332 let mut tables = BTreeMap::<(Option<String>, Option<String>, String), DatabaseTable>::new();
1333 for table in std::mem::take(&mut document.tables) {
1334 let key = (
1335 table.database.clone(),
1336 table.schema.clone(),
1337 table.name.clone(),
1338 );
1339 if let Some(existing) = tables.get_mut(&key) {
1340 existing.columns.extend(table.columns);
1341 existing.indexes.extend(table.indexes);
1342 existing.foreign_keys.extend(table.foreign_keys);
1343 existing.evidence = existing.evidence.min(table.evidence);
1344 } else {
1345 tables.insert(key, table);
1346 }
1347 }
1348 document.tables = tables.into_values().collect();
1349 for table in &mut document.tables {
1350 table.columns.sort();
1351 table.columns.dedup();
1352 table.indexes.sort();
1353 table.indexes.dedup();
1354 table.foreign_keys.sort();
1355 table.foreign_keys.dedup();
1356 }
1357 document.accesses.sort();
1358 document.accesses.dedup();
1359 document.frameworks.sort();
1360 document.frameworks.dedup();
1361 document.references.sort();
1362 document.references.dedup();
1363 document.owners.sort();
1364 document.owners.dedup();
1365 document.warnings.sort();
1366 document.warnings.dedup();
1367 document.incomplete |= !document.warnings.is_empty();
1368
1369 if structured_item_count(document) > MAX_DATA_ITEMS {
1370 truncate_structured_items(document);
1371 mark_incomplete(document, DataWarning::LimitExceeded);
1372 document.warnings.sort();
1373 document.warnings.dedup();
1374 }
1375
1376 let databases = document
1377 .tables
1378 .iter()
1379 .filter_map(|table| table.database.clone())
1380 .collect::<BTreeSet<_>>();
1381 let schemas = document
1382 .tables
1383 .iter()
1384 .filter_map(|table| table.schema.clone())
1385 .collect::<BTreeSet<_>>();
1386 document.database_name = single_value(databases);
1387 document.schema_name = single_value(schemas);
1388}
1389
1390fn structured_item_count(document: &DataDocument) -> usize {
1391 document.tables.len()
1392 + document.accesses.len()
1393 + document.frameworks.len()
1394 + document.references.len()
1395 + document.owners.len()
1396 + document
1397 .tables
1398 .iter()
1399 .map(|table| table.columns.len() + table.indexes.len() + table.foreign_keys.len())
1400 .sum::<usize>()
1401}
1402
1403fn truncate_structured_items(document: &mut DataDocument) {
1404 let mut remaining = MAX_DATA_ITEMS;
1405
1406 document.tables.truncate(remaining);
1407 remaining = remaining.saturating_sub(document.tables.len());
1408 document.accesses.truncate(remaining);
1409 remaining = remaining.saturating_sub(document.accesses.len());
1410 document.frameworks.truncate(remaining);
1411 remaining = remaining.saturating_sub(document.frameworks.len());
1412 document.references.truncate(remaining);
1413 remaining = remaining.saturating_sub(document.references.len());
1414 document.owners.truncate(remaining);
1415 remaining = remaining.saturating_sub(document.owners.len());
1416
1417 for table in &mut document.tables {
1418 table.columns.truncate(remaining);
1419 remaining = remaining.saturating_sub(table.columns.len());
1420 table.indexes.truncate(remaining);
1421 remaining = remaining.saturating_sub(table.indexes.len());
1422 table.foreign_keys.truncate(remaining);
1423 remaining = remaining.saturating_sub(table.foreign_keys.len());
1424 }
1425}
1426
1427fn single_value(values: BTreeSet<String>) -> Option<String> {
1428 (values.len() == 1)
1429 .then(|| values.into_iter().next())
1430 .flatten()
1431}
1432
1433fn mark_incomplete(document: &mut DataDocument, warning: DataWarning) {
1434 document.incomplete = true;
1435 document.warnings.push(warning);
1436}
1437
1438fn evidence(line: u32) -> DataEvidenceLine {
1439 DataEvidenceLine(line.clamp(1, MAX_DATA_SOURCE_LINES))
1440}
1441
1442fn add_evidence(base: u32, relative: u32) -> DataEvidenceLine {
1443 evidence(base.saturating_add(relative.saturating_sub(1)))
1444}
1445
1446fn add_line(base: u32, offset: usize) -> u32 {
1447 base.saturating_add(u32::try_from(offset).unwrap_or(u32::MAX))
1448 .min(MAX_DATA_SOURCE_LINES)
1449}
1450
1451fn bounded_identifier(value: &str) -> String {
1452 value.trim().chars().take(MAX_IDENTIFIER_CHARS).collect()
1453}
1454
1455fn clean_identifier(value: &str) -> String {
1456 bounded_identifier(
1457 value.trim_matches(|character| matches!(character, '"' | '\'' | '`' | '[' | ']')),
1458 )
1459}
1460
1461fn filename_order_hint(source_path: &str) -> Option<u64> {
1462 Path::new(source_path)
1463 .file_name()
1464 .and_then(|name| name.to_str())
1465 .and_then(|name| {
1466 let digits = name
1467 .chars()
1468 .take_while(char::is_ascii_digit)
1469 .collect::<String>();
1470 (!digits.is_empty()).then_some(digits)
1471 })
1472 .and_then(|digits| digits.parse().ok())
1473}
1474
1475fn sql_migration_path(normalized_path: &str, filename: &str) -> bool {
1476 normalized_path
1477 .split('/')
1478 .any(|component| component == "migrations")
1479 || filename.ends_with(".up.sql")
1480 || filename.ends_with(".down.sql")
1481}
1482
1483fn sql_contains_only_queries(input: &str) -> bool {
1484 Parser::parse_sql(&GenericDialect {}, input).is_ok_and(|statements| {
1485 !statements.is_empty()
1486 && statements.iter().all(|statement| {
1487 matches!(
1488 statement,
1489 Statement::Query(_)
1490 | Statement::Insert(_)
1491 | Statement::Update(_)
1492 | Statement::Delete(_)
1493 )
1494 })
1495 })
1496}
1497
1498fn sql_migration_metadata(source_path: &str, input: &str) -> MigrationMetadata {
1499 let filename = Path::new(source_path)
1500 .file_name()
1501 .and_then(|name| name.to_str())
1502 .unwrap_or_default();
1503 let order_hint = filename_order_hint(source_path);
1504 let revision = order_hint.map(|value| value.to_string());
1505 MigrationMetadata {
1506 revision,
1507 down_revision: None,
1508 order_hint,
1509 reversible: filename.ends_with(".up.sql")
1510 || filename.ends_with(".down.sql")
1511 || contains_keyword(input, "ROLLBACK"),
1512 evidence: evidence(1),
1513 }
1514}
1515
1516fn inferred_crate_root(source_path: &str) -> String {
1517 let normalized = source_path.replace('\\', "/");
1518 for marker in ["/src/", "/tests/", "/examples/", "/benches/"] {
1519 if let Some((root, _)) = normalized.split_once(marker) {
1520 return root.to_owned();
1521 }
1522 }
1523 if normalized.starts_with("src/")
1524 || normalized.starts_with("tests/")
1525 || normalized.starts_with("examples/")
1526 || normalized.starts_with("benches/")
1527 {
1528 return String::new();
1529 }
1530 normalized
1531 .rsplit_once('/')
1532 .map_or_else(String::new, |(parent, _)| parent.to_owned())
1533}
1534
1535fn normalize_data_reference(crate_root: &str, reference: &str) -> Option<String> {
1536 let reference = reference.replace('\\', "/");
1537 if reference.is_empty()
1538 || reference.starts_with('/')
1539 || reference.as_bytes().get(1) == Some(&b':')
1540 {
1541 return None;
1542 }
1543 let mut components = crate_root
1544 .replace('\\', "/")
1545 .split('/')
1546 .filter(|component| !component.is_empty() && *component != ".")
1547 .map(str::to_owned)
1548 .collect::<Vec<_>>();
1549 for component in reference.split('/') {
1550 match component {
1551 "" | "." => {}
1552 ".." if !components.is_empty() => {
1553 components.pop();
1554 }
1555 ".." => return None,
1556 value => components.push(value.to_owned()),
1557 }
1558 }
1559 (!components.is_empty()).then(|| components.join("/"))
1560}
1561
1562fn sql_statement_lines(input: &str) -> Vec<u32> {
1563 let mut lines = Vec::new();
1564 let mut line = 1_u32;
1565 let mut quote = None;
1566 let mut escaped = false;
1567 let mut statement_started = false;
1568 let mut line_comment = false;
1569 let mut block_comment = false;
1570 let mut characters = input.chars().peekable();
1571 while let Some(character) = characters.next() {
1572 if character == '\n' {
1573 line = line.saturating_add(1);
1574 line_comment = false;
1575 continue;
1576 }
1577 if line_comment {
1578 continue;
1579 }
1580 if block_comment {
1581 if character == '*' && characters.peek() == Some(&'/') {
1582 characters.next();
1583 block_comment = false;
1584 }
1585 continue;
1586 }
1587 if escaped {
1588 escaped = false;
1589 continue;
1590 }
1591 if character == '\\' && quote.is_some() {
1592 escaped = true;
1593 continue;
1594 }
1595 if let Some(active) = quote {
1596 if character == active {
1597 quote = None;
1598 }
1599 continue;
1600 }
1601 if character == '-' && characters.peek() == Some(&'-') {
1602 characters.next();
1603 line_comment = true;
1604 continue;
1605 }
1606 if character == '/' && characters.peek() == Some(&'*') {
1607 characters.next();
1608 block_comment = true;
1609 continue;
1610 }
1611 if !statement_started && !character.is_whitespace() && character != ';' {
1612 lines.push(line);
1613 statement_started = true;
1614 }
1615 if matches!(character, '\'' | '"' | '`') {
1616 quote = Some(character);
1617 } else if character == ';' {
1618 statement_started = false;
1619 }
1620 }
1621 lines
1622}
1623
1624#[derive(Debug, Clone, Copy)]
1625struct SqlStatementChunk<'a> {
1626 text: &'a str,
1627 line: u32,
1628}
1629
1630fn sql_statement_chunks(input: &str) -> Vec<SqlStatementChunk<'_>> {
1631 let mut output = Vec::new();
1632 let mut start = 0_usize;
1633 let mut line = 1_u32;
1634 let mut statement_line = None;
1635 let mut quote = None;
1636 let mut escaped = false;
1637 let mut line_comment = false;
1638 let mut block_comment = false;
1639 let mut characters = input.char_indices().peekable();
1640 while let Some((index, character)) = characters.next() {
1641 if character == '\n' {
1642 line = line.saturating_add(1);
1643 line_comment = false;
1644 continue;
1645 }
1646 if line_comment {
1647 continue;
1648 }
1649 if block_comment {
1650 if character == '*' && characters.peek().is_some_and(|(_, next)| *next == '/') {
1651 characters.next();
1652 block_comment = false;
1653 }
1654 continue;
1655 }
1656 if escaped {
1657 escaped = false;
1658 continue;
1659 }
1660 if character == '\\' && quote.is_some() {
1661 escaped = true;
1662 continue;
1663 }
1664 if let Some(active) = quote {
1665 if character == active {
1666 quote = None;
1667 }
1668 continue;
1669 }
1670 if character == '-' && characters.peek().is_some_and(|(_, next)| *next == '-') {
1671 characters.next();
1672 line_comment = true;
1673 continue;
1674 }
1675 if character == '#' {
1676 line_comment = true;
1677 continue;
1678 }
1679 if character == '/' && characters.peek().is_some_and(|(_, next)| *next == '*') {
1680 characters.next();
1681 block_comment = true;
1682 continue;
1683 }
1684 if !character.is_whitespace() && character != ';' && statement_line.is_none() {
1685 statement_line = Some(line);
1686 }
1687 if matches!(character, '\'' | '"' | '`') {
1688 quote = Some(character);
1689 } else if character == ';' {
1690 let end = index.saturating_add(character.len_utf8());
1691 if let Some(statement_line) = statement_line.take() {
1692 output.push(SqlStatementChunk {
1693 text: &input[start..end],
1694 line: statement_line,
1695 });
1696 }
1697 start = end;
1698 }
1699 if output.len() >= MAX_DATA_ITEMS {
1700 break;
1701 }
1702 }
1703 if output.len() < MAX_DATA_ITEMS
1704 && let Some(statement_line) = statement_line
1705 && start < input.len()
1706 {
1707 output.push(SqlStatementChunk {
1708 text: &input[start..],
1709 line: statement_line,
1710 });
1711 }
1712 output
1713}
1714
1715fn contains_keyword(input: &str, keyword: &str) -> bool {
1716 input
1717 .split(|character: char| !character.is_ascii_alphanumeric() && character != '_')
1718 .any(|word| word.eq_ignore_ascii_case(keyword))
1719}
1720
1721#[derive(Debug)]
1722struct NamedBlock<'a> {
1723 name: &'a str,
1724 body: &'a str,
1725 line: u32,
1726}
1727
1728fn named_blocks<'a>(input: &'a str, keyword: &str) -> Vec<NamedBlock<'a>> {
1729 let mut output = Vec::new();
1730 let mut offset = 0;
1731 let marker = format!("{keyword} ");
1732 while let Some(relative) = input[offset..].find(&marker) {
1733 let start = offset + relative;
1734 if start > 0
1735 && input[..start]
1736 .chars()
1737 .next_back()
1738 .is_some_and(|character| character.is_ascii_alphanumeric() || character == '_')
1739 {
1740 offset = start + marker.len();
1741 continue;
1742 }
1743 let name_start = start + marker.len();
1744 let name_end = input[name_start..]
1745 .find(|character: char| character.is_whitespace() || character == '{')
1746 .map_or(input.len(), |relative_end| name_start + relative_end);
1747 let Some(open_relative) = input[name_end..].find('{') else {
1748 break;
1749 };
1750 let open = name_end + open_relative;
1751 let Some(close) = matching_delimiter(input, open, '{', '}') else {
1752 break;
1753 };
1754 output.push(NamedBlock {
1755 name: &input[name_start..name_end],
1756 body: &input[open + 1..close],
1757 line: line_at(input, start),
1758 });
1759 if output.len() >= MAX_DATA_ITEMS {
1760 break;
1761 }
1762 offset = close + 1;
1763 }
1764 output
1765}
1766
1767fn macro_blocks<'a>(input: &'a str, macro_name: &str) -> Vec<NamedBlock<'a>> {
1768 let mut output = Vec::new();
1769 let mut offset = 0;
1770 while let Some(relative) = input[offset..].find(macro_name) {
1771 let start = offset + relative;
1772 let Some(open_relative) = input[start + macro_name.len()..].find('{') else {
1773 break;
1774 };
1775 let open = start + macro_name.len() + open_relative;
1776 let Some(close) = matching_delimiter(input, open, '{', '}') else {
1777 break;
1778 };
1779 output.push(NamedBlock {
1780 name: &input[start..start + macro_name.len()],
1781 body: &input[open + 1..close],
1782 line: line_at(input, start),
1783 });
1784 if output.len() >= MAX_DATA_ITEMS {
1785 break;
1786 }
1787 offset = close + 1;
1788 }
1789 output
1790}
1791
1792fn matching_delimiter(input: &str, open: usize, left: char, right: char) -> Option<usize> {
1793 let mut depth = 0_usize;
1794 let mut quote = None;
1795 let mut escaped = false;
1796 for (relative, character) in input[open..].char_indices() {
1797 if escaped {
1798 escaped = false;
1799 continue;
1800 }
1801 if character == '\\' && quote.is_some() {
1802 escaped = true;
1803 continue;
1804 }
1805 if let Some(active) = quote {
1806 if character == active {
1807 quote = None;
1808 }
1809 continue;
1810 }
1811 if matches!(character, '\'' | '"' | '`') {
1812 quote = Some(character);
1813 } else if character == left {
1814 depth += 1;
1815 if depth > MAX_DATA_DEPTH {
1816 return None;
1817 }
1818 } else if character == right {
1819 depth = depth.saturating_sub(1);
1820 if depth == 0 {
1821 return Some(open + relative);
1822 }
1823 }
1824 }
1825 None
1826}
1827
1828fn line_at(input: &str, byte: usize) -> u32 {
1829 u32::try_from(
1830 input[..byte.min(input.len())]
1831 .bytes()
1832 .filter(|byte| *byte == b'\n')
1833 .count()
1834 + 1,
1835 )
1836 .unwrap_or(MAX_DATA_SOURCE_LINES)
1837 .min(MAX_DATA_SOURCE_LINES)
1838}
1839
1840fn find_call_literal(input: &str, marker: &str) -> Option<String> {
1841 let position = input.find(marker)?;
1842 first_quoted(&input[position + marker.len()..])
1843}
1844
1845fn bracket_values_after(input: &str, marker: &str) -> Option<Vec<String>> {
1846 let position = input.find(marker)?;
1847 bracket_values(&input[position + marker.len()..])
1848}
1849
1850fn bracket_values(input: &str) -> Option<Vec<String>> {
1851 let open = input.find('[')?;
1852 let close = matching_delimiter(input, open, '[', ']')?;
1853 Some(comma_identifiers(&input[open + 1..close]))
1854}
1855
1856fn comma_identifiers(input: &str) -> Vec<String> {
1857 input
1858 .split(',')
1859 .map(clean_identifier)
1860 .filter(|value| !value.is_empty())
1861 .collect()
1862}
1863
1864#[derive(Debug)]
1865struct Call {
1866 body: String,
1867 line: u32,
1868}
1869
1870fn collect_calls(input: &str, marker: &str) -> Vec<Call> {
1871 let mut output = Vec::new();
1872 let mut offset = 0;
1873 while let Some(relative) = input[offset..].find(marker) {
1874 let start = offset + relative;
1875 let body_start = start + marker.len();
1876 let Some(body) = balanced_slice(&input[body_start..]) else {
1877 break;
1878 };
1879 output.push(Call {
1880 body: body.to_owned(),
1881 line: line_at(input, start),
1882 });
1883 offset = body_start + body.len() + 1;
1884 if output.len() >= MAX_DATA_ITEMS {
1885 break;
1886 }
1887 }
1888 output
1889}
1890
1891fn balanced_slice(input: &str) -> Option<&str> {
1892 let mut depth = 1_usize;
1893 let mut quote = None;
1894 let mut escaped = false;
1895 for (index, character) in input.char_indices() {
1896 if escaped {
1897 escaped = false;
1898 continue;
1899 }
1900 if character == '\\' && quote.is_some() {
1901 escaped = true;
1902 continue;
1903 }
1904 if let Some(active) = quote {
1905 if character == active {
1906 quote = None;
1907 }
1908 continue;
1909 }
1910 if matches!(character, '\'' | '"' | '`') {
1911 quote = Some(character);
1912 } else if character == '(' {
1913 depth += 1;
1914 if depth > MAX_DATA_DEPTH {
1915 return None;
1916 }
1917 } else if character == ')' {
1918 depth -= 1;
1919 if depth == 0 {
1920 return Some(&input[..index]);
1921 }
1922 }
1923 }
1924 None
1925}
1926
1927fn first_quoted(input: &str) -> Option<String> {
1928 quoted_values(input).into_iter().next()
1929}
1930
1931fn quoted_values(input: &str) -> Vec<String> {
1932 let mut output = Vec::new();
1933 let bytes = input.as_bytes();
1934 let mut index = 0;
1935 while index < bytes.len() {
1936 if !matches!(bytes[index], b'\'' | b'"') {
1937 index += 1;
1938 continue;
1939 }
1940 let quote = bytes[index];
1941 let start = index + 1;
1942 index = start;
1943 let mut escaped = false;
1944 while index < bytes.len() {
1945 if escaped {
1946 escaped = false;
1947 } else if bytes[index] == b'\\' {
1948 escaped = true;
1949 } else if bytes[index] == quote {
1950 output.push(bounded_identifier(&input[start..index]));
1951 index += 1;
1952 break;
1953 }
1954 index += 1;
1955 }
1956 }
1957 output
1958}
1959
1960fn assignment_literal(input: &str, name: &str) -> Option<String> {
1961 input.lines().find_map(|line| {
1962 let (left, right) = line.split_once('=')?;
1963 (left.trim() == name).then(|| first_quoted(right)).flatten()
1964 })
1965}
1966
1967fn assignment_line(input: &str, name: &str) -> Option<u32> {
1968 input.lines().enumerate().find_map(|(index, line)| {
1969 let (left, _) = line.split_once('=')?;
1970 (left.trim() == name).then(|| u32::try_from(index + 1).unwrap_or(MAX_DATA_SOURCE_LINES))
1971 })
1972}
1973
1974fn python_column(input: &str, line: DataEvidenceLine) -> Option<DatabaseColumn> {
1975 python_column_with_fallback(input, line, None)
1976}
1977
1978fn python_column_with_fallback(
1979 input: &str,
1980 line: DataEvidenceLine,
1981 fallback_name: Option<&str>,
1982) -> Option<DatabaseColumn> {
1983 let values = quoted_values(input);
1984 let explicit_name = input.trim_start().starts_with(['\'', '"']);
1985 let name = if explicit_name {
1986 values.first().cloned()
1987 } else {
1988 fallback_name.map(bounded_identifier)
1989 }?;
1990 let type_position = usize::from(explicit_name);
1991 let data_type = input
1992 .split(',')
1993 .nth(type_position)
1994 .map(str::trim)
1995 .filter(|value| !value.contains("ForeignKey"))
1996 .map(|value| {
1997 bounded_identifier(
1998 value
1999 .trim_start_matches("sa.")
2000 .trim_end_matches("()")
2001 .trim(),
2002 )
2003 });
2004 Some(DatabaseColumn {
2005 name,
2006 data_type,
2007 nullable: if input.contains("nullable=False") {
2008 Some(false)
2009 } else if input.contains("nullable=True") {
2010 Some(true)
2011 } else {
2012 None
2013 },
2014 primary_key: input.contains("primary_key=True"),
2015 unique: input.contains("unique=True"),
2016 default_present: input.contains("default=") || input.contains("server_default="),
2017 evidence: line,
2018 })
2019}
2020
2021fn python_foreign_key(input: &str, line: DataEvidenceLine) -> Option<DatabaseForeignKey> {
2022 let lists = all_bracket_values(input);
2023 let local = lists.first()?.clone();
2024 let references = lists.get(1)?.clone();
2025 let reference = references.first()?;
2026 let (table, column) = split_reference(reference);
2027 Some(DatabaseForeignKey {
2028 name: None,
2029 columns: local,
2030 referenced_table: table,
2031 referenced_columns: column.into_iter().collect(),
2032 evidence: line,
2033 })
2034}
2035
2036fn all_bracket_values(input: &str) -> Vec<Vec<String>> {
2037 let mut output = Vec::new();
2038 let mut offset = 0;
2039 while let Some(relative) = input[offset..].find('[') {
2040 let open = offset + relative;
2041 let Some(close) = matching_delimiter(input, open, '[', ']') else {
2042 break;
2043 };
2044 output.push(
2045 quoted_values(&input[open + 1..close])
2046 .into_iter()
2047 .map(|value| bounded_identifier(&value))
2048 .collect(),
2049 );
2050 offset = close + 1;
2051 }
2052 output
2053}
2054
2055fn split_reference(reference: &str) -> (String, Option<String>) {
2056 reference.rsplit_once('.').map_or_else(
2057 || (bounded_identifier(reference), None),
2058 |(table, column)| (bounded_identifier(table), Some(bounded_identifier(column))),
2059 )
2060}
2061
2062fn table_argument_literal(input: &str, name: &str) -> Option<String> {
2063 input.lines().find_map(|line| {
2064 if !line.contains("__table_args__") || !line.contains(name) {
2065 return None;
2066 }
2067 let position = line.find(name)?;
2068 let remainder = &line[position + name.len()..];
2069 let value_start = remainder.find(':').map_or(0, |colon| colon + 1);
2070 first_quoted(&remainder[value_start..])
2071 })
2072}
2073
2074#[derive(Debug)]
2075struct PythonClass<'a> {
2076 name: &'a str,
2077 body: &'a str,
2078 line: u32,
2079}
2080
2081fn python_classes(input: &str) -> Vec<PythonClass<'_>> {
2082 let mut starts = Vec::new();
2083 let mut byte = 0;
2084 for (index, line) in input.lines().enumerate() {
2085 if let Some(rest) = line.strip_prefix("class ")
2086 && let Some(end) = rest.find(['(', ':'])
2087 {
2088 starts.push((byte, index + 1, &rest[..end]));
2089 if starts.len() >= MAX_DATA_ITEMS {
2090 break;
2091 }
2092 }
2093 byte += line.len() + 1;
2094 }
2095 starts
2096 .iter()
2097 .enumerate()
2098 .map(|(index, (start, line, name))| {
2099 let body_start = input[*start..]
2100 .find('\n')
2101 .map_or(input.len(), |relative| start + relative + 1);
2102 let body_end = starts
2103 .get(index + 1)
2104 .map_or(input.len(), |(next, _, _)| *next);
2105 PythonClass {
2106 name,
2107 body: &input[body_start..body_end],
2108 line: u32::try_from(*line).unwrap_or(MAX_DATA_SOURCE_LINES),
2109 }
2110 })
2111 .collect()
2112}
2113
2114fn call_literal(input: &str, call: &str) -> Option<String> {
2115 let position = input.find(call)?;
2116 first_quoted(&input[position + call.len()..])
2117}
2118
2119#[derive(Debug, Clone)]
2120struct SourceLiteral {
2121 value: String,
2122 line: u32,
2123 start: usize,
2124 end: usize,
2125 dynamic: bool,
2126 interpolated: bool,
2127}
2128
2129fn quoted_literals(input: &str, language: SourceLanguage) -> Vec<SourceLiteral> {
2130 if language == SourceLanguage::Rust {
2131 return rust_source_string_literals(input);
2132 }
2133 let bytes = input.as_bytes();
2134 let mut output = Vec::new();
2135 let mut index = 0;
2136 while index < bytes.len() {
2137 let quote = match bytes[index] {
2138 b'\'' | b'"' | b'`' => bytes[index],
2139 _ => {
2140 index += 1;
2141 continue;
2142 }
2143 };
2144 let triple = language == SourceLanguage::Python
2145 && index + 2 < bytes.len()
2146 && bytes[index + 1] == quote
2147 && bytes[index + 2] == quote;
2148 let delimiter = if triple { 3 } else { 1 };
2149 let content_start = index + delimiter;
2150 let mut cursor = content_start;
2151 let mut escaped = false;
2152 let mut closed = None;
2153 while cursor < bytes.len() {
2154 if triple
2155 && cursor + 2 < bytes.len()
2156 && bytes[cursor] == quote
2157 && bytes[cursor + 1] == quote
2158 && bytes[cursor + 2] == quote
2159 {
2160 closed = Some(cursor);
2161 break;
2162 }
2163 if !triple {
2164 if escaped {
2165 escaped = false;
2166 cursor += 1;
2167 continue;
2168 }
2169 if bytes[cursor] == b'\\' {
2170 escaped = true;
2171 cursor += 1;
2172 continue;
2173 }
2174 if bytes[cursor] == quote {
2175 closed = Some(cursor);
2176 break;
2177 }
2178 }
2179 cursor += 1;
2180 }
2181 let Some(content_end) = closed else {
2182 break;
2183 };
2184 let value = input[content_start..content_end].to_owned();
2185 let end = content_end + delimiter;
2186 let interpolated = is_interpolated(input, language, index, quote, &value);
2187 let dynamic = interpolated || adjacent_dynamic_operator(input, index, end);
2188 output.push(SourceLiteral {
2189 value,
2190 line: line_at(input, index),
2191 start: index,
2192 end,
2193 dynamic,
2194 interpolated,
2195 });
2196 index = end;
2197 if output.len() >= MAX_DATA_ITEMS {
2198 break;
2199 }
2200 }
2201 output
2202}
2203
2204fn rust_source_string_literals(input: &str) -> Vec<SourceLiteral> {
2205 let mut parser = tree_sitter::Parser::new();
2206 let grammar = tree_sitter_rust::LANGUAGE.into();
2207 if parser.set_language(&grammar).is_err() {
2208 return Vec::new();
2209 }
2210 let Some(tree) = parser.parse(input, None) else {
2211 return Vec::new();
2212 };
2213 let mut output = Vec::new();
2214 collect_rust_string_literals(tree.root_node(), input, &mut output);
2215 output.sort_by_key(|literal| literal.start);
2216 output.truncate(MAX_DATA_ITEMS);
2217 output
2218}
2219
2220fn collect_rust_string_literals(
2221 node: SyntaxNode<'_>,
2222 input: &str,
2223 output: &mut Vec<SourceLiteral>,
2224) {
2225 if matches!(
2226 node.kind(),
2227 "string_literal" | "raw_string_literal" | "byte_string_literal" | "raw_byte_string_literal"
2228 ) {
2229 if let Ok(source) = node.utf8_text(input.as_bytes())
2230 && let Some(mut literal) = rust_string_literals(source, 1).into_iter().next()
2231 {
2232 literal.start = literal.start.saturating_add(node.start_byte());
2233 literal.end = literal.end.saturating_add(node.start_byte());
2234 literal.line = u32::try_from(node.start_position().row)
2235 .unwrap_or(MAX_DATA_SOURCE_LINES)
2236 .saturating_add(1);
2237 literal.dynamic = adjacent_dynamic_operator(input, literal.start, literal.end);
2238 output.push(literal);
2239 }
2240 return;
2241 }
2242 let mut cursor = node.walk();
2243 for child in node.children(&mut cursor) {
2244 collect_rust_string_literals(child, input, output);
2245 }
2246}
2247
2248fn rust_string_literals(input: &str, base_line: u32) -> Vec<SourceLiteral> {
2249 let bytes = input.as_bytes();
2250 let mut output = Vec::new();
2251 let mut index = 0;
2252 while index < bytes.len() && output.len() < MAX_DATA_ITEMS {
2253 if bytes[index] == b'r' {
2254 let mut quote = index + 1;
2255 while quote < bytes.len() && bytes[quote] == b'#' {
2256 quote += 1;
2257 }
2258 if quote < bytes.len() && bytes[quote] == b'"' {
2259 let hashes = quote.saturating_sub(index + 1);
2260 let content_start = quote + 1;
2261 let mut cursor = content_start;
2262 while cursor < bytes.len() {
2263 if bytes[cursor] == b'"'
2264 && cursor + hashes < bytes.len()
2265 && (hashes == 0
2266 || bytes[cursor + 1..=cursor + hashes]
2267 .iter()
2268 .all(|byte| *byte == b'#'))
2269 {
2270 let end = cursor + hashes + 1;
2271 output.push(SourceLiteral {
2272 value: input[content_start..cursor].to_owned(),
2273 line: base_line.saturating_add(line_at(input, index).saturating_sub(1)),
2274 start: index,
2275 end,
2276 dynamic: false,
2277 interpolated: false,
2278 });
2279 index = end;
2280 break;
2281 }
2282 cursor += 1;
2283 }
2284 if index >= content_start {
2285 continue;
2286 }
2287 }
2288 }
2289 if bytes[index] != b'"' {
2290 index += 1;
2291 continue;
2292 }
2293 let content_start = index + 1;
2294 let mut cursor = content_start;
2295 let mut escaped = false;
2296 while cursor < bytes.len() {
2297 if escaped {
2298 escaped = false;
2299 cursor += 1;
2300 continue;
2301 }
2302 match bytes[cursor] {
2303 b'\\' => escaped = true,
2304 b'"' => {
2305 let end = cursor + 1;
2306 output.push(SourceLiteral {
2307 value: unescape_rust_string(&input[content_start..cursor]),
2308 line: base_line.saturating_add(line_at(input, index).saturating_sub(1)),
2309 start: index,
2310 end,
2311 dynamic: adjacent_dynamic_operator(input, index, end),
2312 interpolated: false,
2313 });
2314 index = end;
2315 break;
2316 }
2317 _ => {}
2318 }
2319 cursor += 1;
2320 }
2321 if index < content_start {
2322 break;
2323 }
2324 }
2325 output
2326}
2327
2328fn unescape_rust_string(value: &str) -> String {
2329 let mut output = String::with_capacity(value.len());
2330 let mut characters = value.chars();
2331 while let Some(character) = characters.next() {
2332 if character != '\\' {
2333 output.push(character);
2334 continue;
2335 }
2336 match characters.next() {
2337 Some('n') => output.push('\n'),
2338 Some('r') => output.push('\r'),
2339 Some('t') => output.push('\t'),
2340 Some('\\') | None => output.push('\\'),
2341 Some('"') => output.push('"'),
2342 Some('\'') => output.push('\''),
2343 Some(other) => {
2344 output.push('\\');
2345 output.push(other);
2346 }
2347 }
2348 }
2349 output
2350}
2351
2352#[derive(Debug, Default)]
2353struct SqlxImports {
2354 names: BTreeMap<String, String>,
2355}
2356
2357impl SqlxImports {
2358 fn discover(input: &str) -> Self {
2359 let mut imports = Self::default();
2360 for statement in input.split(';') {
2361 let compact = statement
2362 .chars()
2363 .filter(|character| !character.is_whitespace())
2364 .collect::<String>();
2365 let Some(rest) = compact.strip_prefix("usesqlx::") else {
2366 continue;
2367 };
2368 if let Some(group) = rest
2369 .strip_prefix('{')
2370 .and_then(|value| value.strip_suffix('}'))
2371 {
2372 for item in group.split(',') {
2373 imports.insert_item(item);
2374 }
2375 } else {
2376 imports.insert_item(rest);
2377 }
2378 }
2379 imports
2380 }
2381
2382 fn insert_item(&mut self, item: &str) {
2383 let item = item.rsplit("::").next().unwrap_or(item);
2384 if sqlx_api_kind(item).is_some() || matches!(item, "Executor" | "QueryBuilder" | "Migrator")
2385 {
2386 self.names.insert(item.to_owned(), item.to_owned());
2387 return;
2388 }
2389 for (index, _) in item.match_indices("as") {
2390 let (canonical, local_with_as) = item.split_at(index);
2391 let local = &local_with_as[2..];
2392 if !local.is_empty()
2393 && (sqlx_api_kind(canonical).is_some()
2394 || matches!(canonical, "Executor" | "QueryBuilder" | "Migrator"))
2395 {
2396 self.names.insert(local.to_owned(), canonical.to_owned());
2397 return;
2398 }
2399 }
2400 }
2401
2402 fn canonical<'a>(&'a self, local: &'a str) -> Option<&'a str> {
2403 self.names.get(local).map(String::as_str)
2404 }
2405
2406 fn contains(&self, canonical: &str) -> bool {
2407 self.names.values().any(|value| value == canonical)
2408 }
2409}
2410
2411#[derive(Debug, Default)]
2412struct MysqlAsyncImports {
2413 crate_names: BTreeSet<String>,
2414 traits: BTreeMap<String, String>,
2415}
2416
2417impl MysqlAsyncImports {
2418 fn discover(root: SyntaxNode<'_>, input: &str) -> Self {
2419 let mut declarations = Vec::new();
2420 collect_rust_use_declarations(root, input, &mut declarations);
2421 let mut imports = Self {
2422 crate_names: BTreeSet::from(["mysql_async".to_owned()]),
2423 traits: BTreeMap::new(),
2424 };
2425 for declaration in &declarations {
2426 let compact = compact_rust(declaration);
2427 if let Some(alias) = compact
2428 .strip_prefix("usemysql_asyncas")
2429 .and_then(|value| value.strip_suffix(';'))
2430 .filter(|value| is_rust_identifier(value))
2431 {
2432 imports.crate_names.insert(alias.to_owned());
2433 }
2434 }
2435 for declaration in declarations {
2436 imports.insert_declaration(&compact_rust(declaration));
2437 }
2438 imports
2439 }
2440
2441 fn insert_declaration(&mut self, declaration: &str) {
2442 let Some(path) = declaration.strip_prefix("use") else {
2443 return;
2444 };
2445 if !self
2446 .crate_names
2447 .iter()
2448 .any(|name| path.starts_with(&format!("{name}::")))
2449 {
2450 return;
2451 }
2452 if path.contains("prelude::*") {
2453 for name in MYSQL_ASYNC_TRAITS {
2454 self.traits.insert((*name).to_owned(), (*name).to_owned());
2455 }
2456 }
2457 for canonical in MYSQL_ASYNC_TRAITS {
2458 let Some(start) = path.match_indices(canonical).find_map(|(start, _)| {
2459 let before = &path[..start];
2460 let rest = &path[start + canonical.len()..];
2461 let valid_before = before.ends_with([':', '{', ',']);
2462 let valid_after = rest.starts_with("as")
2463 || rest
2464 .chars()
2465 .next()
2466 .is_some_and(|character| matches!(character, ',' | '}' | ';'));
2467 (valid_before && valid_after).then_some(start)
2468 }) else {
2469 continue;
2470 };
2471 let rest = &path[start + canonical.len()..];
2472 let alias = rest
2473 .strip_prefix("as")
2474 .map(|value| {
2475 value
2476 .chars()
2477 .take_while(|character| {
2478 character.is_ascii_alphanumeric() || *character == '_'
2479 })
2480 .collect::<String>()
2481 })
2482 .filter(|value| is_rust_identifier(value))
2483 .unwrap_or_else(|| (*canonical).to_owned());
2484 self.traits.insert(alias, (*canonical).to_owned());
2485 }
2486 }
2487
2488 fn contains(&self, canonical: &str) -> bool {
2489 self.traits.values().any(|value| value == canonical)
2490 }
2491
2492 fn canonical<'a>(&'a self, local: &'a str) -> Option<&'a str> {
2493 self.traits.get(local).map(String::as_str)
2494 }
2495
2496 fn is_qualified(&self, function: &str) -> bool {
2497 self.crate_names
2498 .iter()
2499 .any(|name| function.contains(&format!("{name}::")))
2500 }
2501}
2502
2503const MYSQL_ASYNC_TRAITS: &[&str] = &["Queryable", "Query", "WithParams", "BatchQuery"];
2504
2505fn collect_rust_use_declarations<'a>(
2506 node: SyntaxNode<'a>,
2507 input: &'a str,
2508 output: &mut Vec<&'a str>,
2509) {
2510 if node.kind() == "use_declaration" {
2511 if let Ok(declaration) = node.utf8_text(input.as_bytes()) {
2512 output.push(declaration);
2513 }
2514 return;
2515 }
2516 let mut cursor = node.walk();
2517 for child in node.children(&mut cursor) {
2518 collect_rust_use_declarations(child, input, output);
2519 }
2520}
2521
2522fn compact_rust(input: &str) -> String {
2523 input
2524 .chars()
2525 .filter(|character| !character.is_whitespace())
2526 .collect()
2527}
2528
2529fn is_rust_identifier(value: &str) -> bool {
2530 let mut characters = value.chars();
2531 characters
2532 .next()
2533 .is_some_and(|character| character == '_' || character.is_ascii_alphabetic())
2534 && characters.all(|character| character == '_' || character.is_ascii_alphanumeric())
2535}
2536
2537#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2538enum SqlxApiKind {
2539 Inline,
2540 QueryFile,
2541 MigrationDirectory,
2542 QueryBuilder,
2543}
2544
2545fn sqlx_api_kind(name: &str) -> Option<SqlxApiKind> {
2546 match name {
2547 "query"
2548 | "query_with"
2549 | "query_with_result"
2550 | "query_unchecked"
2551 | "query_as"
2552 | "query_as_with"
2553 | "query_as_with_result"
2554 | "query_as_unchecked"
2555 | "query_scalar"
2556 | "query_scalar_with"
2557 | "query_scalar_with_result"
2558 | "query_scalar_unchecked"
2559 | "raw_sql" => Some(SqlxApiKind::Inline),
2560 "query_file"
2561 | "query_file_unchecked"
2562 | "query_file_as"
2563 | "query_file_as_unchecked"
2564 | "query_file_scalar"
2565 | "query_file_scalar_unchecked" => Some(SqlxApiKind::QueryFile),
2566 "migrate" => Some(SqlxApiKind::MigrationDirectory),
2567 "QueryBuilder" => Some(SqlxApiKind::QueryBuilder),
2568 _ => None,
2569 }
2570}
2571
2572fn extract_rust_database_source(input: &str, crate_root: &str, document: &mut DataDocument) {
2573 let mut parser = tree_sitter::Parser::new();
2574 let grammar = tree_sitter_rust::LANGUAGE.into();
2575 if parser.set_language(&grammar).is_err() {
2576 mark_incomplete(document, DataWarning::UnsupportedConstruct);
2577 return;
2578 }
2579 let Some(tree) = parser.parse(input, None) else {
2580 mark_incomplete(document, DataWarning::UnsupportedConstruct);
2581 return;
2582 };
2583 if tree.root_node().has_error() {
2584 mark_incomplete(document, DataWarning::SqlParseRecovery);
2585 }
2586 let sqlx_imports = SqlxImports::discover(input);
2587 let mysql_async_imports = MysqlAsyncImports::discover(tree.root_node(), input);
2588 visit_rust_data_nodes(
2589 tree.root_node(),
2590 input,
2591 crate_root,
2592 &sqlx_imports,
2593 &mysql_async_imports,
2594 None,
2595 document,
2596 );
2597}
2598
2599fn visit_rust_data_nodes(
2600 node: SyntaxNode<'_>,
2601 input: &str,
2602 crate_root: &str,
2603 sqlx_imports: &SqlxImports,
2604 mysql_async_imports: &MysqlAsyncImports,
2605 owner: Option<&str>,
2606 document: &mut DataDocument,
2607) {
2608 let owned_name = (node.kind() == "function_item")
2609 .then(|| node.child_by_field_name("name"))
2610 .flatten()
2611 .and_then(|name| name.utf8_text(input.as_bytes()).ok())
2612 .map(bounded_identifier);
2613 let owner = owned_name.as_deref().or(owner);
2614
2615 match node.kind() {
2616 "call_expression" => {
2617 if let (Some(function), Some(arguments)) = (
2618 node.child_by_field_name("function"),
2619 node.child_by_field_name("arguments"),
2620 ) && let (Ok(function), Ok(arguments)) = (
2621 function.utf8_text(input.as_bytes()),
2622 arguments.utf8_text(input.as_bytes()),
2623 ) {
2624 inspect_sqlx_call(
2625 function,
2626 arguments,
2627 node.start_position().row,
2628 crate_root,
2629 sqlx_imports,
2630 owner,
2631 document,
2632 );
2633 inspect_mysql_async_call(
2634 function,
2635 arguments,
2636 node.start_position().row,
2637 mysql_async_imports,
2638 owner,
2639 document,
2640 );
2641 }
2642 }
2643 "macro_invocation" => {
2644 if let Ok(invocation) = node.utf8_text(input.as_bytes()) {
2645 inspect_sqlx_macro(
2646 invocation,
2647 node.start_position().row,
2648 crate_root,
2649 sqlx_imports,
2650 owner,
2651 document,
2652 );
2653 }
2654 }
2655 _ => {}
2656 }
2657
2658 let mut cursor = node.walk();
2659 for child in node.children(&mut cursor) {
2660 visit_rust_data_nodes(
2661 child,
2662 input,
2663 crate_root,
2664 sqlx_imports,
2665 mysql_async_imports,
2666 owner,
2667 document,
2668 );
2669 }
2670}
2671
2672fn inspect_sqlx_call(
2673 function: &str,
2674 arguments: &str,
2675 zero_based_line: usize,
2676 crate_root: &str,
2677 imports: &SqlxImports,
2678 owner: Option<&str>,
2679 document: &mut DataDocument,
2680) {
2681 let compact = strip_rust_generics(function);
2682 let local = compact
2683 .rsplit([':', '.'])
2684 .find(|component| !component.is_empty())
2685 .unwrap_or(&compact);
2686 let qualified = compact.contains("sqlx::");
2687 let canonical = if qualified {
2688 local
2689 } else {
2690 imports.canonical(local).unwrap_or(local)
2691 };
2692 let executor_method = matches!(
2693 canonical,
2694 "execute"
2695 | "execute_many"
2696 | "fetch"
2697 | "fetch_many"
2698 | "fetch_all"
2699 | "fetch_one"
2700 | "fetch_optional"
2701 ) && (imports.contains("Executor")
2702 || compact.contains("sqlx::Executor::"));
2703 let kind = sqlx_api_kind(canonical)
2704 .or_else(|| executor_method.then_some(SqlxApiKind::Inline))
2705 .or_else(|| {
2706 let builder = compact.ends_with("QueryBuilder::new")
2707 || compact.ends_with("QueryBuilder::with_arguments")
2708 || imports.canonical(compact.split("::").next().unwrap_or_default())
2709 == Some("QueryBuilder");
2710 builder.then_some(SqlxApiKind::QueryBuilder)
2711 });
2712 let migrator = compact.ends_with("Migrator::new")
2713 && (qualified
2714 || imports.canonical(compact.split("::").next().unwrap_or_default())
2715 == Some("Migrator"));
2716 let kind = if migrator {
2717 Some(SqlxApiKind::MigrationDirectory)
2718 } else if qualified
2719 || executor_method
2720 || imports.canonical(local).is_some()
2721 || kind == Some(SqlxApiKind::QueryBuilder)
2722 {
2723 kind
2724 } else {
2725 None
2726 };
2727 let Some(kind) = kind else {
2728 return;
2729 };
2730 document.frameworks.push(DataFramework::Sqlx);
2731 let base_line = u32::try_from(zero_based_line)
2732 .unwrap_or(MAX_DATA_SOURCE_LINES)
2733 .saturating_add(1);
2734 let literal = rust_string_literals(arguments, base_line)
2735 .into_iter()
2736 .next();
2737 apply_sqlx_api(kind, literal, base_line, crate_root, owner, document);
2738 if kind == SqlxApiKind::QueryBuilder {
2739 mark_incomplete(document, DataWarning::DynamicQuery);
2740 }
2741}
2742
2743fn inspect_mysql_async_call(
2744 function: &str,
2745 arguments: &str,
2746 zero_based_line: usize,
2747 imports: &MysqlAsyncImports,
2748 owner: Option<&str>,
2749 document: &mut DataDocument,
2750) {
2751 let compact = strip_rust_generics(function);
2752 let method = compact
2753 .rsplit([':', '.'])
2754 .find(|component| !component.is_empty())
2755 .unwrap_or(&compact);
2756 let root = compact.split("::").next().unwrap_or_default();
2757 let imported_trait = imports.canonical(root);
2758 let qualified = imports.is_qualified(&compact);
2759 let method_call = compact.contains('.');
2760 let queryable = mysql_async_queryable_method(method)
2761 && (qualified
2762 || imported_trait == Some("Queryable")
2763 || (method_call && imports.contains("Queryable")));
2764 let required_query_trait = mysql_async_query_trait(method);
2765 let fluent = required_query_trait.is_some()
2766 && (qualified
2767 || imported_trait == required_query_trait
2768 || required_query_trait.is_some_and(|required| imports.contains(required)));
2769 if !queryable && !fluent {
2770 return;
2771 }
2772
2773 let base_line = u32::try_from(zero_based_line)
2774 .unwrap_or(MAX_DATA_SOURCE_LINES)
2775 .saturating_add(1);
2776 let literal = if queryable || qualified || imported_trait.is_some() {
2777 rust_string_literals(arguments, base_line)
2778 .into_iter()
2779 .next()
2780 .or_else(|| mysql_async_fluent_literal(function, method, base_line))
2781 } else {
2782 mysql_async_fluent_literal(function, method, base_line)
2783 };
2784 document.frameworks.push(DataFramework::MysqlAsync);
2785 let Some(literal) = literal else {
2786 mark_incomplete(document, DataWarning::DynamicQuery);
2787 return;
2788 };
2789 if literal.dynamic {
2790 mark_incomplete(document, DataWarning::DynamicQuery);
2791 return;
2792 }
2793 append_inline_sql(&literal.value, evidence(literal.line), owner, document);
2794}
2795
2796fn mysql_async_queryable_method(name: &str) -> bool {
2797 matches!(
2798 name,
2799 "query_iter"
2800 | "prep"
2801 | "exec_iter"
2802 | "query"
2803 | "query_first"
2804 | "query_map"
2805 | "query_fold"
2806 | "query_drop"
2807 | "exec_batch"
2808 | "exec"
2809 | "exec_first"
2810 | "exec_map"
2811 | "exec_fold"
2812 | "exec_drop"
2813 | "query_stream"
2814 | "exec_stream"
2815 )
2816}
2817
2818fn mysql_async_query_trait(name: &str) -> Option<&'static str> {
2819 match name {
2820 "run" | "first" | "fetch" | "reduce" | "map" | "stream" | "ignore" => Some("Query"),
2821 "with" => Some("WithParams"),
2822 "batch" => Some("BatchQuery"),
2823 _ => None,
2824 }
2825}
2826
2827fn mysql_async_fluent_literal(
2828 function: &str,
2829 method: &str,
2830 base_line: u32,
2831) -> Option<SourceLiteral> {
2832 let literal = rust_string_literals(function, base_line)
2833 .into_iter()
2834 .next()?;
2835 let suffix = function.get(literal.end..)?;
2836 let suffix = strip_rust_generics(&compact_rust(suffix));
2837 let suffix = suffix.trim_start_matches(')');
2838 let terminal = format!(".{method}");
2839 (suffix == terminal || (suffix.starts_with(".with(") && suffix.ends_with(&terminal)))
2840 .then_some(literal)
2841}
2842
2843fn inspect_sqlx_macro(
2844 invocation: &str,
2845 zero_based_line: usize,
2846 crate_root: &str,
2847 imports: &SqlxImports,
2848 owner: Option<&str>,
2849 document: &mut DataDocument,
2850) {
2851 let Some((name, arguments)) = invocation.split_once('!') else {
2852 return;
2853 };
2854 let compact = name
2855 .chars()
2856 .filter(|character| !character.is_whitespace())
2857 .collect::<String>();
2858 let local = compact.rsplit("::").next().unwrap_or(&compact);
2859 let qualified = compact.contains("sqlx::");
2860 let canonical = if qualified {
2861 local
2862 } else {
2863 imports.canonical(local).unwrap_or(local)
2864 };
2865 let Some(kind) = sqlx_api_kind(canonical) else {
2866 return;
2867 };
2868 if !qualified && imports.canonical(local).is_none() {
2869 return;
2870 }
2871 document.frameworks.push(DataFramework::Sqlx);
2872 let base_line = u32::try_from(zero_based_line)
2873 .unwrap_or(MAX_DATA_SOURCE_LINES)
2874 .saturating_add(1);
2875 let literal = rust_string_literals(arguments, base_line)
2876 .into_iter()
2877 .next();
2878 apply_sqlx_api(kind, literal, base_line, crate_root, owner, document);
2879}
2880
2881fn apply_sqlx_api(
2882 kind: SqlxApiKind,
2883 literal: Option<SourceLiteral>,
2884 call_line: u32,
2885 crate_root: &str,
2886 owner: Option<&str>,
2887 document: &mut DataDocument,
2888) {
2889 match (kind, literal) {
2890 (SqlxApiKind::Inline | SqlxApiKind::QueryBuilder, Some(literal)) if !literal.dynamic => {
2891 append_inline_sql(&literal.value, evidence(literal.line), owner, document);
2892 }
2893 (SqlxApiKind::QueryFile, Some(literal)) if !literal.dynamic => {
2894 push_sqlx_reference(
2895 DataArtifactReferenceKind::QueryFile,
2896 &literal.value,
2897 evidence(literal.line),
2898 crate_root,
2899 owner,
2900 document,
2901 );
2902 }
2903 (SqlxApiKind::MigrationDirectory, literal) => {
2904 let Some(literal) = literal else {
2905 if let Some(owner) = owner {
2906 document.owners.push(owner.to_owned());
2907 }
2908 document.references.push(DataArtifactReference {
2909 framework: DataFramework::Sqlx,
2910 kind: DataArtifactReferenceKind::SqlxDefaultMigrationDirectory,
2911 path: crate_root.replace('\\', "/").trim_matches('/').to_owned(),
2912 owner: owner.map(ToOwned::to_owned),
2913 evidence: evidence(call_line),
2914 });
2915 return;
2916 };
2917 push_sqlx_reference(
2918 DataArtifactReferenceKind::MigrationDirectory,
2919 &literal.value,
2920 evidence(literal.line),
2921 crate_root,
2922 owner,
2923 document,
2924 );
2925 }
2926 _ => mark_incomplete(document, DataWarning::DynamicQuery),
2927 }
2928}
2929
2930fn append_inline_sql(
2931 sql: &str,
2932 line: DataEvidenceLine,
2933 owner: Option<&str>,
2934 document: &mut DataDocument,
2935) {
2936 let Ok(statements) = Parser::parse_sql(&GenericDialect {}, sql) else {
2937 mark_incomplete(document, DataWarning::SqlParseRecovery);
2938 return;
2939 };
2940 if let Some(owner) = owner {
2941 document.owners.push(owner.to_owned());
2942 }
2943 for statement in &statements {
2944 if matches!(
2945 statement,
2946 Statement::Query(_)
2947 | Statement::Insert(_)
2948 | Statement::Update(_)
2949 | Statement::Delete(_)
2950 ) {
2951 append_statement_accesses(statement, line, owner, document, 0);
2952 } else if !append_schema_statement(statement, line, document) {
2953 mark_incomplete(document, DataWarning::UnsupportedConstruct);
2954 }
2955 }
2956}
2957
2958fn push_sqlx_reference(
2959 kind: DataArtifactReferenceKind,
2960 path: &str,
2961 line: DataEvidenceLine,
2962 crate_root: &str,
2963 owner: Option<&str>,
2964 document: &mut DataDocument,
2965) {
2966 let Some(path) = normalize_data_reference(crate_root, path) else {
2967 mark_incomplete(document, DataWarning::UnresolvedReference);
2968 return;
2969 };
2970 if let Some(owner) = owner {
2971 document.owners.push(owner.to_owned());
2972 }
2973 document.references.push(DataArtifactReference {
2974 framework: DataFramework::Sqlx,
2975 kind,
2976 path,
2977 owner: owner.map(ToOwned::to_owned),
2978 evidence: line,
2979 });
2980}
2981
2982fn strip_rust_generics(value: &str) -> String {
2983 let mut output = String::with_capacity(value.len());
2984 let mut depth = 0_u32;
2985 for character in value.chars().filter(|character| !character.is_whitespace()) {
2986 match character {
2987 '<' => depth = depth.saturating_add(1),
2988 '>' => depth = depth.saturating_sub(1),
2989 _ if depth == 0 => output.push(character),
2990 _ => {}
2991 }
2992 }
2993 output.replace("::::", "::")
2994}
2995
2996fn is_interpolated(
2997 input: &str,
2998 language: SourceLanguage,
2999 start: usize,
3000 quote: u8,
3001 value: &str,
3002) -> bool {
3003 (quote == b'`' && value.contains("${"))
3004 || (language == SourceLanguage::Python
3005 && python_string_prefix(input, start).contains(['f', 'F'])
3006 && value.contains('{')
3007 && value.contains('}'))
3008}
3009
3010fn python_string_prefix(input: &str, start: usize) -> &str {
3011 let bytes = input.as_bytes();
3012 let mut prefix_start = start;
3013 while prefix_start > 0
3014 && bytes[prefix_start - 1].is_ascii_alphabetic()
3015 && start.saturating_sub(prefix_start) < 3
3016 {
3017 prefix_start -= 1;
3018 }
3019 &input[prefix_start..start]
3020}
3021
3022fn sanitize_python_f_string(value: &str) -> Option<String> {
3023 let mut output = String::with_capacity(value.len());
3024 let mut characters = value.char_indices().peekable();
3025 while let Some((_, character)) = characters.next() {
3026 if character == '{' {
3027 if characters.peek().is_some_and(|(_, next)| *next == '{') {
3028 characters.next();
3029 output.push('{');
3030 continue;
3031 }
3032 let mut depth = 1_usize;
3033 let mut quote = None;
3034 let mut escaped = false;
3035 for (_, candidate) in characters.by_ref() {
3036 if escaped {
3037 escaped = false;
3038 continue;
3039 }
3040 if candidate == '\\' && quote.is_some() {
3041 escaped = true;
3042 continue;
3043 }
3044 if let Some(active) = quote {
3045 if candidate == active {
3046 quote = None;
3047 }
3048 continue;
3049 }
3050 if matches!(candidate, '\'' | '"') {
3051 quote = Some(candidate);
3052 } else if candidate == '{' {
3053 depth = depth.saturating_add(1);
3054 } else if candidate == '}' {
3055 depth = depth.saturating_sub(1);
3056 if depth == 0 {
3057 break;
3058 }
3059 }
3060 }
3061 if depth != 0 {
3062 return None;
3063 }
3064 output.push_str("__csg_dynamic_value__");
3065 } else if character == '}' && characters.peek().is_some_and(|(_, next)| *next == '}') {
3066 characters.next();
3067 output.push('}');
3068 } else {
3069 output.push(character);
3070 }
3071 }
3072 Some(output)
3073}
3074
3075fn python_format_call_after(input: &str, literal: &SourceLiteral) -> bool {
3076 input
3077 .get(literal.end..)
3078 .is_some_and(|after| after.trim_start().starts_with(".format("))
3079}
3080
3081fn extract_sqlalchemy_accesses(input: &str, document: &mut DataDocument) {
3082 let mut known_models = BTreeSet::new();
3083 for line in input.lines() {
3084 if line.trim_start().starts_with('#') {
3085 continue;
3086 }
3087 if let Some(model) = sqlalchemy_query_model(line) {
3088 known_models.insert(model);
3089 }
3090 for model in sqlalchemy_qualified_constructors(line) {
3091 known_models.insert(model);
3092 }
3093 }
3094 if known_models.is_empty() {
3095 return;
3096 }
3097 document.frameworks.push(DataFramework::SqlAlchemy);
3098 let mut variables = BTreeMap::new();
3099 for (offset, line) in input.lines().enumerate() {
3100 let trimmed = line.trim();
3101 if trimmed.is_empty() || trimmed.starts_with('#') {
3102 continue;
3103 }
3104 let line_number = u32::try_from(offset)
3105 .unwrap_or(MAX_DATA_SOURCE_LINES)
3106 .saturating_add(1);
3107 if let Some((variable, model)) = sqlalchemy_model_assignment(trimmed, &known_models) {
3108 variables.insert(variable, model);
3109 }
3110 if let Some(model) = sqlalchemy_query_model(trimmed) {
3111 append_sqlalchemy_access(
3112 input,
3113 document,
3114 &model,
3115 if trimmed.contains(".update(") || trimmed.contains(".delete(") {
3116 DataAccessRole::Writer
3117 } else {
3118 DataAccessRole::Reader
3119 },
3120 if trimmed.contains(".update(") {
3121 DataOperation::Update
3122 } else if trimmed.contains(".delete(") {
3123 DataOperation::Delete
3124 } else {
3125 DataOperation::Select
3126 },
3127 line_number,
3128 );
3129 }
3130 for (marker, operation) in [
3131 (".add(", DataOperation::Insert),
3132 (".delete(", DataOperation::Delete),
3133 ] {
3134 let Some(argument) = call_argument_identifier(trimmed, marker) else {
3135 continue;
3136 };
3137 let model = variables
3138 .get(&argument)
3139 .cloned()
3140 .or_else(|| known_models.contains(&argument).then_some(argument));
3141 if let Some(model) = model {
3142 append_sqlalchemy_access(
3143 input,
3144 document,
3145 &model,
3146 DataAccessRole::Writer,
3147 operation,
3148 line_number,
3149 );
3150 }
3151 }
3152 }
3153}
3154
3155fn append_sqlalchemy_access(
3156 input: &str,
3157 document: &mut DataDocument,
3158 model: &str,
3159 role: DataAccessRole,
3160 operation: DataOperation,
3161 line: u32,
3162) {
3163 let owner = owner_at_line(SourceLanguage::Python, input, line);
3164 if let Some(owner) = owner.as_ref() {
3165 document.owners.push(owner.clone());
3166 }
3167 document.accesses.push(DataAccessObservation {
3168 role,
3169 table: String::new(),
3170 model: Some(model.to_owned()),
3171 owner,
3172 operation: Some(operation),
3173 evidence: evidence(line),
3174 });
3175}
3176
3177fn sqlalchemy_query_model(line: &str) -> Option<String> {
3178 let (_, after) = line.split_once(".query(")?;
3179 python_model_name(after)
3180}
3181
3182fn sqlalchemy_qualified_constructors(line: &str) -> Vec<String> {
3183 let mut output = Vec::new();
3184 let mut remaining = line;
3185 while let Some((_, after)) = remaining.split_once("models.") {
3186 if let Some(model) = python_model_name(after)
3187 && after[model.len()..].trim_start().starts_with('(')
3188 {
3189 output.push(model);
3190 }
3191 remaining = after.get(1..).unwrap_or_default();
3192 }
3193 output
3194}
3195
3196fn sqlalchemy_model_assignment(
3197 line: &str,
3198 known_models: &BTreeSet<String>,
3199) -> Option<(String, String)> {
3200 let (left, right) = line.split_once('=')?;
3201 let variable = left.trim();
3202 if !is_python_identifier(variable) {
3203 return None;
3204 }
3205 let model = python_model_name(right.trim())?;
3206 known_models
3207 .contains(&model)
3208 .then(|| (variable.to_owned(), model))
3209}
3210
3211fn call_argument_identifier(line: &str, marker: &str) -> Option<String> {
3212 let (_, after) = line.split_once(marker)?;
3213 let argument = after
3214 .trim_start()
3215 .split(|character: char| !character.is_ascii_alphanumeric() && character != '_')
3216 .next()?;
3217 is_python_identifier(argument).then(|| argument.to_owned())
3218}
3219
3220fn python_model_name(input: &str) -> Option<String> {
3221 let qualified = input
3222 .trim_start()
3223 .split(|character: char| {
3224 !character.is_ascii_alphanumeric() && character != '_' && character != '.'
3225 })
3226 .next()?;
3227 let model = qualified.rsplit('.').next()?;
3228 is_python_identifier(model).then(|| model.to_owned())
3229}
3230
3231fn is_python_identifier(value: &str) -> bool {
3232 let mut characters = value.chars();
3233 characters
3234 .next()
3235 .is_some_and(|character| character == '_' || character.is_ascii_alphabetic())
3236 && characters.all(|character| character == '_' || character.is_ascii_alphanumeric())
3237}
3238
3239fn adjacent_dynamic_operator(input: &str, start: usize, end: usize) -> bool {
3240 let before = input[..start].trim_end();
3241 let after = input[end..].trim_start();
3242 before.ends_with('+')
3243 || after.starts_with('+')
3244 || before.ends_with("format!(")
3245 || after.starts_with(".format(")
3246}
3247
3248fn has_query_context(input: &str, literal: &SourceLiteral) -> bool {
3249 let context_start = floor_char_boundary(input, literal.start.saturating_sub(160));
3250 let before = input[context_start..literal.start].to_ascii_lowercase();
3251 let after_end = floor_char_boundary(input, (literal.end + 80).min(input.len()));
3252 let after = input[literal.end..after_end].to_ascii_lowercase();
3253 [
3254 "query", "execute", "fetch", "select", ".sql(", "sql =", "sql!", "sqlx", "prepare", "raw",
3255 ]
3256 .iter()
3257 .any(|marker| before.contains(marker) || after.contains(marker))
3258}
3259
3260fn floor_char_boundary(input: &str, mut index: usize) -> usize {
3261 while index > 0 && !input.is_char_boundary(index) {
3262 index -= 1;
3263 }
3264 index
3265}
3266
3267fn first_sql_keyword(input: &str) -> Option<String> {
3268 strip_leading_sql_comments(input)
3269 .trim_start()
3270 .split(|character: char| !character.is_ascii_alphabetic())
3271 .find(|value| !value.is_empty())
3272 .map(str::to_ascii_uppercase)
3273}
3274
3275fn strip_leading_sql_comments(mut input: &str) -> &str {
3276 loop {
3277 input = input.trim_start();
3278 if let Some(rest) = input.strip_prefix("--").or_else(|| input.strip_prefix('#')) {
3279 input = rest.split_once('\n').map_or("", |(_, remaining)| remaining);
3280 continue;
3281 }
3282 if let Some(rest) = input.strip_prefix("/*") {
3283 input = rest.split_once("*/").map_or("", |(_, remaining)| remaining);
3284 continue;
3285 }
3286 return input;
3287 }
3288}
3289
3290fn source_has_dynamic_query(input: &str, language: SourceLanguage) -> bool {
3291 input.lines().any(|line| {
3292 let lower = line.to_ascii_lowercase();
3293 let query_call = ["query(", "execute(", "queryrow(", "exec(", ".sql(", "raw("]
3294 .iter()
3295 .any(|marker| lower.contains(marker));
3296 if !query_call {
3297 return false;
3298 }
3299 let has_quote = line.contains('"') || line.contains('\'') || line.contains('`');
3300 !has_quote
3301 || line.contains('+')
3302 || line.contains("${")
3303 || (language == SourceLanguage::Python && (line.contains("f\"") || line.contains("f'")))
3304 })
3305}
3306
3307fn owner_at_line(language: SourceLanguage, input: &str, line: u32) -> Option<String> {
3308 let lines = input
3309 .lines()
3310 .take(usize::try_from(line).ok()?)
3311 .collect::<Vec<_>>();
3312 lines.iter().rev().find_map(|line| {
3313 let trimmed = line.trim();
3314 match language {
3315 SourceLanguage::Rust => function_name_after(trimmed, "fn "),
3316 SourceLanguage::Python => function_name_after(trimmed, "def "),
3317 SourceLanguage::JavaScript | SourceLanguage::TypeScript => {
3318 function_name_after(trimmed, "function ").or_else(|| {
3319 trimmed
3320 .split_once('=')
3321 .filter(|(_, right)| right.contains("=>"))
3322 .map(|(left, _)| bounded_identifier(left.trim()))
3323 })
3324 }
3325 SourceLanguage::Go => function_name_after(trimmed, "func "),
3326 SourceLanguage::Java => java_method_name(trimmed),
3327 }
3328 })
3329}
3330
3331fn function_name_after(line: &str, marker: &str) -> Option<String> {
3332 let rest = line.strip_prefix(marker)?;
3333 let name = rest
3334 .split(|character: char| character == '(' || character.is_whitespace())
3335 .next()?;
3336 (!name.is_empty()).then(|| bounded_identifier(name))
3337}
3338
3339fn java_method_name(line: &str) -> Option<String> {
3340 if !line.ends_with('{') || !line.contains('(') {
3341 return None;
3342 }
3343 let before = line.split_once('(')?.0;
3344 before
3345 .split_whitespace()
3346 .next_back()
3347 .map(bounded_identifier)
3348}
3349
3350#[cfg(test)]
3351mod tests {
3352 use super::*;
3353
3354 #[test]
3355 fn sql_schema_extracts_columns_indexes_and_foreign_keys() {
3356 let input = r"
3357 CREATE TABLE public.users (
3358 id BIGINT PRIMARY KEY,
3359 email TEXT NOT NULL UNIQUE,
3360 password_hash TEXT DEFAULT 'secret-default'
3361 );
3362 CREATE TABLE public.orders (
3363 id BIGINT PRIMARY KEY,
3364 user_id BIGINT,
3365 CONSTRAINT fk_user FOREIGN KEY (user_id) REFERENCES public.users(id)
3366 );
3367 CREATE INDEX idx_orders_user ON public.orders(user_id);
3368 ";
3369
3370 let document = extract_data_artifact("db/schema.sql", input).expect("schema should parse");
3371
3372 assert!(
3373 document.tables.iter().any(|table| {
3374 table.name == "orders" && table.indexes.len() == 1 && table.foreign_keys.len() == 1
3375 }),
3376 "orders table should retain index and foreign key"
3377 );
3378 }
3379
3380 #[test]
3381 fn sql_migration_preserves_numeric_order_hint() {
3382 let document = extract_data_artifact(
3383 "migrations/0042_add_users.sql",
3384 "CREATE TABLE users (id INTEGER);",
3385 )
3386 .expect("migration should parse");
3387
3388 assert_eq!(
3389 document
3390 .migration
3391 .and_then(|migration| migration.order_hint),
3392 Some(42)
3393 );
3394 }
3395
3396 #[test]
3397 fn prisma_extracts_mapped_model_and_column() {
3398 let input = r#"
3399 model User {
3400 id Int @id
3401 displayName String @map("display_name")
3402 posts Post[] @relation(fields: [id], references: [userId])
3403 @@map("app_users")
3404 @@schema("tenant")
3405 }
3406 "#;
3407
3408 let document =
3409 extract_data_artifact("prisma/schema.prisma", input).expect("Prisma should parse");
3410
3411 assert!(document.tables.iter().any(|table| {
3412 table.name == "app_users"
3413 && table.schema.as_deref() == Some("tenant")
3414 && table
3415 .columns
3416 .iter()
3417 .any(|column| column.name == "display_name")
3418 }));
3419 }
3420
3421 #[test]
3422 fn alembic_extracts_revision_table_and_added_column() {
3423 let input = r#"
3424revision = "abc123"
3425down_revision = "abc122"
3426def upgrade():
3427 op.create_table(
3428 "users",
3429 sa.Column("id", sa.Integer(), primary_key=True),
3430 )
3431 op.add_column("users", sa.Column("email", sa.String(), nullable=False))
3432def downgrade():
3433 op.drop_table("users")
3434"#;
3435
3436 let document = extract_data_artifact("alembic/versions/0042_users.py", input)
3437 .expect("Alembic should parse");
3438
3439 assert!(document.tables.iter().any(|table| {
3440 table.name == "users" && table.columns.iter().any(|column| column.name == "email")
3441 }));
3442 }
3443
3444 #[test]
3445 fn sqlalchemy_extracts_model_binding_and_foreign_key() {
3446 let input = r#"
3447class Order(Base):
3448 __tablename__ = "orders"
3449 __table_args__ = {"schema": "billing"}
3450 id = Column(Integer, primary_key=True)
3451 user_id = Column(Integer, ForeignKey("public.users.id"), nullable=False)
3452"#;
3453
3454 let document = extract_data_artifact("models.py", input).expect("SQLAlchemy should parse");
3455
3456 assert!(
3457 document.accesses.iter().any(|access| {
3458 access.role == DataAccessRole::ModelBinding
3459 && access.owner.as_deref() == Some("Order")
3460 && access.table == "billing.orders"
3461 }) && document.tables.iter().any(|table| {
3462 table.columns.iter().any(|column| column.name == "id")
3463 && table.foreign_keys.iter().any(|foreign| {
3464 foreign.columns == ["user_id"] && foreign.referenced_table == "public.users"
3465 })
3466 })
3467 );
3468 }
3469
3470 #[test]
3471 fn sqlalchemy_nested_enum_does_not_replace_owning_model() {
3472 let input = r#"
3473class Services(Base):
3474 class EnumServiceType(str, enum.Enum):
3475 PUBLIC = "PUBLIC"
3476
3477 __tablename__ = "services"
3478 id = Column(Integer, primary_key=True)
3479"#;
3480
3481 let document = extract_data_artifact("models.py", input).expect("SQLAlchemy should parse");
3482
3483 assert!(document.accesses.iter().any(|access| {
3484 access.role == DataAccessRole::ModelBinding
3485 && access.owner.as_deref() == Some("Services")
3486 && access.table == "services"
3487 }));
3488 assert!(
3489 !document
3490 .owners
3491 .iter()
3492 .any(|owner| owner == "EnumServiceType")
3493 );
3494 }
3495
3496 #[test]
3497 fn diesel_extracts_table_macro() {
3498 let input = r"
3499diesel::table! {
3500 public.users (id) {
3501 id -> Int8,
3502 email -> Text,
3503 nickname -> Nullable<Text>,
3504 }
3505}
3506";
3507
3508 let document = extract_data_artifact("src/schema.rs", input).expect("Diesel should parse");
3509
3510 assert!(document.tables.iter().any(|table| {
3511 table.name == "users"
3512 && table.schema.as_deref() == Some("public")
3513 && table
3514 .columns
3515 .iter()
3516 .any(|column| column.name == "nickname" && column.nullable == Some(true))
3517 }));
3518 }
3519
3520 #[test]
3521 fn literal_select_records_only_direct_tables() {
3522 let input = r#"
3523fn load(pool: &Pool) {
3524 sqlx::query("SELECT u.id FROM users u JOIN teams t ON t.id = u.team_id");
3525}
3526"#;
3527
3528 let document = parse_literal_sql_source(SourceLanguage::Rust, "src/users.rs", input);
3529
3530 assert_eq!(
3531 document
3532 .accesses
3533 .iter()
3534 .map(|access| (&access.role, access.table.as_str()))
3535 .collect::<Vec<_>>(),
3536 vec![
3537 (&DataAccessRole::Reader, "teams"),
3538 (&DataAccessRole::Reader, "users")
3539 ]
3540 );
3541 }
3542
3543 #[test]
3544 fn sqlx_inline_apis_cover_functions_macros_raw_strings_and_raw_sql() {
3545 let input = r##"
3546use sqlx::Executor;
3547
3548async fn load(pool: &sqlx::PgPool) {
3549 sqlx::query!(r#"SELECT id FROM users WHERE id = $1"#, 7_i64);
3550 sqlx::query_as_unchecked!(User, "UPDATE users SET active = true");
3551 sqlx::query_scalar::<_, i64>("SELECT count(*) FROM teams");
3552 sqlx::raw_sql("DELETE FROM sessions; INSERT INTO audits(id) VALUES (1);");
3553 pool.fetch_one("SELECT id FROM accounts");
3554}
3555"##;
3556
3557 let document = parse_literal_sql_source(SourceLanguage::Rust, "src/users.rs", input);
3558 let facts = document
3559 .accesses
3560 .iter()
3561 .map(|access| (access.role, access.operation, access.table.as_str()))
3562 .collect::<BTreeSet<_>>();
3563
3564 assert!(document.frameworks.contains(&DataFramework::Sqlx));
3565 assert!(facts.contains(&(DataAccessRole::Reader, Some(DataOperation::Select), "users")));
3566 assert!(facts.contains(&(DataAccessRole::Writer, Some(DataOperation::Update), "users")));
3567 assert!(facts.contains(&(DataAccessRole::Reader, Some(DataOperation::Select), "teams")));
3568 assert!(facts.contains(&(
3569 DataAccessRole::Writer,
3570 Some(DataOperation::Delete),
3571 "sessions"
3572 )));
3573 assert!(facts.contains(&(
3574 DataAccessRole::Writer,
3575 Some(DataOperation::Insert),
3576 "audits"
3577 )));
3578 assert!(facts.contains(&(
3579 DataAccessRole::Reader,
3580 Some(DataOperation::Select),
3581 "accounts"
3582 )));
3583 }
3584
3585 #[test]
3586 fn rust_sqlx_examples_in_comments_and_string_contents_are_not_evidence() {
3587 let input = r##"
3588fn example() {
3589 // sqlx::query!("SELECT id FROM leaked_comment");
3590 let documentation = r#"sqlx::query!("SELECT id FROM leaked_string")"#;
3591}
3592"##;
3593
3594 let document = parse_literal_sql_source(SourceLanguage::Rust, "src/example.rs", input);
3595
3596 assert!(
3597 document.accesses.is_empty()
3598 && document.frameworks.is_empty()
3599 && document.references.is_empty()
3600 );
3601 }
3602
3603 #[test]
3604 fn mysql_async_queryable_methods_cover_text_prepared_and_streaming_apis() {
3605 let input = r##"
3606use mysql_async::prelude::Queryable as DbQueryable;
3607
3608async fn manage(conn: &mut mysql_async::Conn) {
3609 conn.query_iter(r#"SELECT id FROM users"#).await?;
3610 conn.query_first(b"SELECT id FROM teams").await?;
3611 conn.query_map("SELECT id FROM accounts", |row| row).await?;
3612 conn.query_fold("SELECT id FROM audits", (), |_, _| ()).await?;
3613 conn.query_drop("DELETE FROM sessions").await?;
3614 conn.exec_iter("INSERT INTO events(id) VALUES (1)", ()).await?;
3615 conn.exec_batch("UPDATE jobs SET active = true", [()]).await?;
3616 DbQueryable::exec_drop(conn, "DELETE FROM tokens", ()).await?;
3617 conn.query_stream("SELECT id FROM streams").await?;
3618 conn.exec_stream("SELECT id FROM prepared_streams", ()).await?;
3619 conn.prep("UPDATE prepared_jobs SET active = true").await?;
3620}
3621"##;
3622
3623 let document = parse_literal_sql_source(SourceLanguage::Rust, "src/mysql.rs", input);
3624 let facts = document
3625 .accesses
3626 .iter()
3627 .map(|access| (access.role, access.operation, access.table.as_str()))
3628 .collect::<BTreeSet<_>>();
3629
3630 assert_eq!(document.frameworks, vec![DataFramework::MysqlAsync]);
3631 assert!(facts.contains(&(DataAccessRole::Reader, Some(DataOperation::Select), "users")));
3632 assert!(facts.contains(&(DataAccessRole::Reader, Some(DataOperation::Select), "teams")));
3633 assert!(facts.contains(&(
3634 DataAccessRole::Writer,
3635 Some(DataOperation::Delete),
3636 "sessions"
3637 )));
3638 assert!(facts.contains(&(
3639 DataAccessRole::Writer,
3640 Some(DataOperation::Insert),
3641 "events"
3642 )));
3643 assert!(facts.contains(&(DataAccessRole::Writer, Some(DataOperation::Update), "jobs")));
3644 assert!(facts.contains(&(
3645 DataAccessRole::Writer,
3646 Some(DataOperation::Update),
3647 "prepared_jobs"
3648 )));
3649 }
3650
3651 #[test]
3652 fn mysql_async_fluent_query_traits_cover_aliases_params_and_batch() {
3653 let input = r##"
3654use mysql_async::prelude::{
3655 BatchQuery,
3656 Query as DbQuery,
3657 WithParams,
3658};
3659
3660async fn fluent(conn: &mysql_async::Pool) {
3661 "SELECT id FROM users".first(conn).await?;
3662 br#"SELECT id FROM teams"#.fetch(conn).await?;
3663 "UPDATE jobs SET active = true".with(()).ignore(conn).await?;
3664 "INSERT INTO audits(id) VALUES (1)".with([()]).batch(conn).await?;
3665 DbQuery::run("DELETE FROM sessions", conn).await?;
3666}
3667"##;
3668
3669 let document = parse_literal_sql_source(SourceLanguage::Rust, "src/fluent.rs", input);
3670 let facts = document
3671 .accesses
3672 .iter()
3673 .map(|access| (access.role, access.operation, access.table.as_str()))
3674 .collect::<BTreeSet<_>>();
3675
3676 assert!(document.frameworks.contains(&DataFramework::MysqlAsync));
3677 assert_eq!(
3678 facts,
3679 BTreeSet::from([
3680 (DataAccessRole::Reader, Some(DataOperation::Select), "teams"),
3681 (DataAccessRole::Reader, Some(DataOperation::Select), "users"),
3682 (
3683 DataAccessRole::Writer,
3684 Some(DataOperation::Delete),
3685 "sessions"
3686 ),
3687 (
3688 DataAccessRole::Writer,
3689 Some(DataOperation::Insert),
3690 "audits"
3691 ),
3692 (DataAccessRole::Writer, Some(DataOperation::Update), "jobs"),
3693 ])
3694 );
3695 }
3696
3697 #[test]
3698 fn mysql_async_examples_in_comments_and_string_contents_are_not_evidence() {
3699 let input = r##"
3700fn example() {
3701 // use mysql_async::prelude::*;
3702 // conn.query("SELECT id FROM leaked_comment");
3703 let documentation = r#"conn.exec_drop("DELETE FROM leaked_string", ())"#;
3704}
3705"##;
3706
3707 let document = parse_literal_sql_source(SourceLanguage::Rust, "src/example.rs", input);
3708
3709 assert!(document.accesses.is_empty() && document.frameworks.is_empty());
3710 }
3711
3712 #[test]
3713 fn sqlx_query_file_macro_matrix_resolves_from_cargo_crate_root() {
3714 let input = r#"
3715async fn load() {
3716 sqlx::query_file!("queries/one.sql");
3717 sqlx::query_file_unchecked!("queries/two.sql");
3718 sqlx::query_file_as!(User, "queries/three.sql");
3719 sqlx::query_file_as_unchecked!(User, "queries/four.sql");
3720 sqlx::query_file_scalar!("queries/five.sql");
3721 sqlx::query_file_scalar_unchecked!("queries/six.sql");
3722}
3723"#;
3724
3725 let document = parse_literal_sql_source_at_root(
3726 SourceLanguage::Rust,
3727 "crates/api/src/users.rs",
3728 "crates/api",
3729 input,
3730 );
3731
3732 assert_eq!(
3733 document
3734 .references
3735 .iter()
3736 .map(|reference| reference.path.as_str())
3737 .collect::<Vec<_>>(),
3738 vec![
3739 "crates/api/queries/five.sql",
3740 "crates/api/queries/four.sql",
3741 "crates/api/queries/one.sql",
3742 "crates/api/queries/six.sql",
3743 "crates/api/queries/three.sql",
3744 "crates/api/queries/two.sql",
3745 ]
3746 );
3747 assert!(document.references.iter().all(|reference| {
3748 reference.framework == DataFramework::Sqlx
3749 && reference.kind == DataArtifactReferenceKind::QueryFile
3750 && reference.owner.as_deref() == Some("load")
3751 }));
3752 }
3753
3754 #[test]
3755 fn sqlx_migration_apis_resolve_default_custom_and_runtime_directories() {
3756 let input = r#"
3757use sqlx::migrate::Migrator;
3758
3759static DEFAULT: Migrator = sqlx::migrate!();
3760static CUSTOM: Migrator = sqlx::migrate!("db/migrations");
3761
3762async fn runtime() {
3763 Migrator::new(std::path::Path::new("tenant/migrations")).await;
3764}
3765"#;
3766
3767 let document = parse_literal_sql_source_at_root(
3768 SourceLanguage::Rust,
3769 "crates/api/src/lib.rs",
3770 "crates/api",
3771 input,
3772 );
3773
3774 assert_eq!(
3775 document
3776 .references
3777 .iter()
3778 .filter(|reference| {
3779 reference.kind == DataArtifactReferenceKind::MigrationDirectory
3780 })
3781 .map(|reference| reference.path.as_str())
3782 .collect::<Vec<_>>(),
3783 vec!["crates/api/db/migrations", "crates/api/tenant/migrations",]
3784 );
3785 assert!(document.references.iter().any(|reference| {
3786 reference.kind == DataArtifactReferenceKind::SqlxDefaultMigrationDirectory
3787 && reference.path == "crates/api"
3788 }));
3789 }
3790
3791 #[test]
3792 fn sqlx_configuration_extracts_migration_directory_without_leaking_other_values() {
3793 let document = extract_data_artifact(
3794 "crates/api/sqlx.toml",
3795 r#"
3796[database]
3797url = "postgres://secret@example.invalid/private"
3798
3799[migrate]
3800table-name = "app._sqlx_migrations"
3801migrations-dir = "db/migrations" # relative to the crate root
3802
3803[migrate.defaults]
3804migration-type = "reversible"
3805"#,
3806 )
3807 .expect("SQLx configuration should parse");
3808
3809 assert_eq!(document.artifact_kind, DataArtifactKind::SqlxConfiguration);
3810 assert_eq!(document.frameworks, vec![DataFramework::Sqlx]);
3811 assert_eq!(document.references.len(), 1);
3812 assert_eq!(
3813 document.references[0].kind,
3814 DataArtifactReferenceKind::MigrationDirectory
3815 );
3816 assert_eq!(document.references[0].path, "crates/api/db/migrations");
3817 assert!(!format!("{document:?}").contains("secret"));
3818 }
3819
3820 #[test]
3821 fn sqlx_import_alias_and_query_builder_are_recognized_conservatively() {
3822 let input = r#"
3823use sqlx::{query as sql_query, QueryBuilder as SqlBuilder};
3824
3825fn load() {
3826 sql_query("SELECT id FROM users");
3827 let mut builder = SqlBuilder::<sqlx::Postgres>::new("SELECT id FROM teams");
3828 builder.push(" WHERE id = ").push_bind(7_i64);
3829}
3830"#;
3831
3832 let document = parse_literal_sql_source(SourceLanguage::Rust, "src/users.rs", input);
3833
3834 assert!(
3835 document
3836 .accesses
3837 .iter()
3838 .any(|access| { access.table == "users" && access.role == DataAccessRole::Reader })
3839 );
3840 assert!(
3841 document
3842 .accesses
3843 .iter()
3844 .any(|access| { access.table == "teams" && access.role == DataAccessRole::Reader })
3845 );
3846 assert!(
3847 document.incomplete
3848 && document.warnings.contains(&DataWarning::DynamicQuery)
3849 && document.frameworks == vec![DataFramework::Sqlx]
3850 );
3851 }
3852
3853 #[test]
3854 fn standalone_query_files_are_not_misclassified_as_migrations() {
3855 let document = extract_data_artifact(
3856 "queries/find_users.sql",
3857 "SELECT id FROM users WHERE active = true;",
3858 )
3859 .expect("query file should parse");
3860
3861 assert!(
3862 document.artifact_kind == DataArtifactKind::SqlQueryFile
3863 && document.migration.is_none()
3864 && document.accesses.iter().any(|access| {
3865 access.table == "users" && access.role == DataAccessRole::Reader
3866 })
3867 );
3868 }
3869
3870 #[test]
3871 fn sqlx_reversible_migration_filename_sets_version_metadata() {
3872 let document = extract_data_artifact(
3873 "migrations/0042_add_users.up.sql",
3874 "CREATE TABLE users (id INTEGER);",
3875 )
3876 .expect("migration should parse");
3877 let migration = document.migration.expect("migration metadata");
3878
3879 assert!(
3880 migration.order_hint == Some(42)
3881 && migration.revision.as_deref() == Some("42")
3882 && migration.reversible
3883 );
3884 }
3885
3886 #[test]
3887 fn literal_write_records_writer_role() {
3888 let input = r#"
3889async def save(conn):
3890 await conn.execute("UPDATE users SET active = ? WHERE id = ?", True, 7)
3891"#;
3892
3893 let document = parse_literal_sql_source(SourceLanguage::Python, "users.py", input);
3894
3895 assert!(document.accesses.iter().any(|access| {
3896 access.role == DataAccessRole::Writer
3897 && access.operation == Some(DataOperation::Update)
3898 && access.table == "users"
3899 }));
3900 }
3901
3902 #[test]
3903 fn pymysql_wrapper_f_string_preserves_static_table_access() {
3904 let input = r#"
3905from src.clases.Database import Database
3906
3907def load(account_id):
3908 return Database.sql(f"SELECT ID FROM accounts WHERE ID = {account_id}")
3909"#;
3910
3911 let document = parse_literal_sql_source(SourceLanguage::Python, "accounts.py", input);
3912
3913 assert!(document.accesses.iter().any(|access| {
3914 access.role == DataAccessRole::Reader
3915 && access.operation == Some(DataOperation::Select)
3916 && access.table == "accounts"
3917 }));
3918 assert!(
3919 document.incomplete && document.warnings.contains(&DataWarning::DynamicQuery),
3920 "interpolated values must remain explicitly incomplete"
3921 );
3922 }
3923
3924 #[test]
3925 fn pymysql_wrapper_does_not_promote_interpolated_table_names() {
3926 let input = r#"
3927from src.clases.Database import Database
3928
3929def load(table):
3930 return Database.sql(f"SELECT ID FROM {table} WHERE active = 1")
3931"#;
3932
3933 let document = parse_literal_sql_source(SourceLanguage::Python, "accounts.py", input);
3934
3935 assert!(
3936 document.accesses.is_empty()
3937 && document.incomplete
3938 && document.warnings.contains(&DataWarning::DynamicQuery)
3939 );
3940 }
3941
3942 #[test]
3943 fn psycopg_format_values_preserve_static_table_access() {
3944 let input = r#"
3945import psycopg2
3946
3947def search(cursor, value):
3948 cursor.execute("""
3949 SELECT id FROM embeddings WHERE content = '{}'
3950 """.format(value))
3951"#;
3952
3953 let document = parse_literal_sql_source(SourceLanguage::Python, "vectors.py", input);
3954
3955 assert!(document.frameworks.contains(&DataFramework::Psycopg));
3956 assert!(document.accesses.iter().any(|access| {
3957 access.role == DataAccessRole::Reader
3958 && access.table == "embeddings"
3959 && access.owner.as_deref() == Some("search")
3960 }));
3961 assert!(document.warnings.contains(&DataWarning::DynamicQuery));
3962 }
3963
3964 #[test]
3965 fn sqlalchemy_source_records_model_reads_and_writes_without_guessing_tables() {
3966 let input = r#"
3967from sqlalchemy.orm import Session
3968from app import models
3969
3970def create(db: Session):
3971 service = models.Services(name="test")
3972 db.add(service)
3973
3974def list_all(db: Session):
3975 return db.query(models.Services).all()
3976"#;
3977
3978 let document = parse_literal_sql_source(SourceLanguage::Python, "controllers.py", input);
3979
3980 assert!(document.frameworks.contains(&DataFramework::SqlAlchemy));
3981 assert!(document.accesses.iter().any(|access| {
3982 access.role == DataAccessRole::Writer
3983 && access.model.as_deref() == Some("Services")
3984 && access.operation == Some(DataOperation::Insert)
3985 }));
3986 assert!(document.accesses.iter().any(|access| {
3987 access.role == DataAccessRole::Reader
3988 && access.model.as_deref() == Some("Services")
3989 && access.operation == Some(DataOperation::Select)
3990 }));
3991 assert!(
3992 document
3993 .accesses
3994 .iter()
3995 .all(|access| access.table.is_empty())
3996 );
3997 }
3998
3999 #[test]
4000 fn mariadb_dump_recovers_create_tables_after_unsupported_statements() {
4001 let input = r"
4002/*!40101 SET NAMES utf8mb4 */;
4003CREATE DATABASE IF NOT EXISTS `processor`;
4004USE `processor`;
4005
4006CREATE TABLE IF NOT EXISTS `accounts` (
4007 `ID` int(10) unsigned NOT NULL AUTO_INCREMENT,
4008 `number` int(10) unsigned NOT NULL DEFAULT 0,
4009 PRIMARY KEY (`ID`),
4010 UNIQUE KEY `number_unique` (`number`)
4011) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
4012
4013LOCK TABLES `accounts` WRITE;
4014";
4015
4016 let document =
4017 extract_data_artifact("sql/base_embozador.sql", input).expect("dump should recover");
4018
4019 assert!(document.tables.iter().any(|table| {
4020 table.name == "accounts"
4021 && table.columns.iter().any(|column| column.name == "ID")
4022 && table.columns.iter().any(|column| column.name == "number")
4023 }));
4024 assert!(
4025 document.incomplete
4026 && document.warnings.iter().any(|warning| {
4027 matches!(
4028 warning,
4029 DataWarning::SqlParseRecovery | DataWarning::UnsupportedConstruct
4030 )
4031 }),
4032 "recovered dumps must retain the fact that unsupported statements were skipped"
4033 );
4034 }
4035
4036 #[test]
4037 fn literal_queries_cover_remaining_source_languages() {
4038 let cases = [
4039 (
4040 SourceLanguage::JavaScript,
4041 r#"function load() { db.query("SELECT id FROM users"); }"#,
4042 ),
4043 (
4044 SourceLanguage::TypeScript,
4045 r#"const load = () => db.query("SELECT id FROM users");"#,
4046 ),
4047 (
4048 SourceLanguage::Go,
4049 "func load() { db.Query(`SELECT id FROM users`) }",
4050 ),
4051 (
4052 SourceLanguage::Java,
4053 r#"void load() { statement.executeQuery("SELECT id FROM users"); }"#,
4054 ),
4055 ];
4056
4057 assert!(cases.into_iter().all(|(language, source)| {
4058 parse_literal_sql_source(language, "source.file", source)
4059 .accesses
4060 .iter()
4061 .any(|access| access.table == "users" && access.role == DataAccessRole::Reader)
4062 }));
4063 }
4064
4065 #[test]
4066 fn literal_query_context_should_preserve_utf8_boundaries() {
4067 let input = format!(
4068 "{} db.query(\"SELECT id FROM users\") {}",
4069 "─".repeat(70),
4070 "─".repeat(40)
4071 );
4072
4073 let document = parse_literal_sql_source(SourceLanguage::JavaScript, "src/users.js", &input);
4074
4075 assert!(
4076 document
4077 .accesses
4078 .iter()
4079 .any(|access| access.table == "users")
4080 );
4081 }
4082
4083 #[test]
4084 fn dynamic_query_is_incomplete_and_unlinked() {
4085 let input = r#"
4086fn load(pool: &Pool, table: &str) {
4087 let sql = "SELECT * FROM ".to_owned() + table;
4088 sqlx::query(&sql);
4089}
4090"#;
4091
4092 let document = parse_literal_sql_source(SourceLanguage::Rust, "src/dynamic.rs", input);
4093
4094 assert!(
4095 document.incomplete
4096 && document.accesses.is_empty()
4097 && document.warnings.contains(&DataWarning::DynamicQuery)
4098 );
4099 }
4100
4101 #[test]
4102 fn serialization_does_not_persist_secrets_or_sql_bodies() {
4103 let input = r"
4104-- postgres://admin:credential@localhost/private
4105CREATE TABLE users (
4106 id INTEGER,
4107 token TEXT DEFAULT 'top-secret-token'
4108);
4109";
4110 let document =
4111 extract_data_artifact("schema.sql", input).expect("schema should parse safely");
4112 let serialized = serde_json::to_string(&document).expect("document should serialize");
4113
4114 assert!(
4115 !serialized.contains("credential")
4116 && !serialized.contains("top-secret-token")
4117 && !serialized.contains("CREATE TABLE")
4118 && !serialized.contains("postgres://")
4119 );
4120 }
4121
4122 #[test]
4123 fn literal_query_serialization_discards_values_and_body() {
4124 let input = r#"db.query("SELECT id FROM users WHERE token = 'literal-secret-value'")"#;
4125 let document = parse_literal_sql_source(SourceLanguage::JavaScript, "src/users.js", input);
4126 let serialized = serde_json::to_string(&document).expect("document should serialize");
4127
4128 assert!(
4129 !serialized.contains("literal-secret-value")
4130 && !serialized.contains("SELECT id")
4131 && serialized.contains("users")
4132 );
4133 }
4134
4135 #[test]
4136 fn parser_recovery_is_incomplete_without_echoing_secret_input() {
4137 let document = extract_data_artifact(
4138 "schema.sql",
4139 "CREATE TABLE secret (token DEFAULT 'do-not-echo'",
4140 )
4141 .expect("unsupported SQL syntax should degrade");
4142 let serialized = serde_json::to_string(&document).expect("document should serialize");
4143
4144 assert!(
4145 document.incomplete
4146 && document.warnings.contains(&DataWarning::SqlParseRecovery)
4147 && !serialized.contains("do-not-echo")
4148 );
4149 }
4150
4151 #[test]
4152 fn output_is_deterministic_and_deduplicated() {
4153 let input = "CREATE TABLE b (id INT); CREATE TABLE a (id INT);";
4154 let first = extract_data_artifact("schema.sql", input).expect("schema should parse");
4155 let second = extract_data_artifact("schema.sql", input).expect("schema should parse");
4156
4157 assert_eq!(first, second);
4158 }
4159
4160 #[test]
4161 fn unsupported_filename_is_rejected_without_path_echo() {
4162 let error = extract_data_artifact("secrets/config.txt", "password=private")
4163 .expect_err("unsupported file should fail");
4164
4165 assert_eq!(error, DataExtractionError::UnsupportedArtifact);
4166 }
4167
4168 #[test]
4169 fn oversized_input_returns_bounded_incomplete_document() {
4170 let input = "x".repeat(MAX_DATA_INPUT_BYTES + 1);
4171 let document =
4172 extract_data_artifact("schema.sql", &input).expect("oversized SQL should degrade");
4173
4174 assert!(
4175 document.incomplete
4176 && document.warnings == vec![DataWarning::LimitExceeded]
4177 && document.tables.is_empty()
4178 );
4179 }
4180
4181 #[test]
4182 fn oversized_schema_retains_tables_and_truncates_details() {
4183 let mut document = empty_document("schema.sql", DataArtifactKind::DeclarativeSqlSchema);
4184 for table_index in 0..5 {
4185 let mut table = table_from_qualified_text(
4186 &format!("table_{table_index}"),
4187 evidence(table_index + 1),
4188 );
4189 table.columns = (0..1_000)
4190 .map(|column_index| DatabaseColumn {
4191 name: format!("column_{column_index}"),
4192 data_type: Some("integer".to_owned()),
4193 nullable: None,
4194 primary_key: false,
4195 unique: false,
4196 default_present: false,
4197 evidence: evidence(table_index + 1),
4198 })
4199 .collect();
4200 document.tables.push(table);
4201 }
4202
4203 finish_document(&mut document);
4204
4205 assert!(
4206 document.tables.len() == 5
4207 && document
4208 .tables
4209 .iter()
4210 .all(|table| table.name.starts_with("table_"))
4211 && structured_item_count(&document) == MAX_DATA_ITEMS
4212 && document.incomplete
4213 && document.warnings.contains(&DataWarning::LimitExceeded)
4214 );
4215 }
4216
4217 #[test]
4218 fn evidence_line_enforces_bounds() {
4219 assert!(DataEvidenceLine::new(0).is_err());
4220 assert!(DataEvidenceLine::new(MAX_DATA_SOURCE_LINES).is_ok());
4221 assert!(DataEvidenceLine::new(MAX_DATA_SOURCE_LINES + 1).is_err());
4222 }
4223}