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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -122,13 +122,13 @@ Types live in `relune-core` (see `model.rs`, `graph.rs`, and related modules).

**`Table`** — `TableId`, `stable_id`, optional `schema_name`, `name`, `columns`, `foreign_keys`, `indexes`, optional `comment`.

**`Column`** — `ColumnId`, `name`, `data_type`, `nullable`, `is_primary_key`, optional `comment`.
**`Column`** — `ColumnId`, `name`, `data_type`, `nullable`, `is_primary_key`, optional `comment`, optional `enum_values` for inline enum/set types (e.g. MySQL `ENUM('a','b')`).

**`ForeignKey`** — Optional constraint `name`, `from_columns`, `to_table`, `to_columns`, `on_delete` / `on_update` (`ReferentialAction`).

**`View`** — Parsed and introspected across all three dialects. Stored with the original SQL definition.

**`Enum`** — PostgreSQL uses named enum types (`CREATE TYPE ... AS ENUM`). MySQL has no schema-level enum type, but live introspection lifts `ENUM(...)` / `SET(...)` column definitions into `Schema.enums` so they can participate in graphing and diffs. SQLite does not contribute enum metadata.
**`Enum`** — PostgreSQL uses named enum types (`CREATE TYPE ... AS ENUM`). MySQL has no schema-level enum type; SQL parsing stores inline `ENUM(...)` / `SET(...)` definitions on `Column.enum_values` rather than synthesizing schema-level enum entries. Live MySQL introspection currently still lifts inline enum/set column types into `Schema.enums`. SQLite does not contribute enum metadata.

**Derived artifacts** flow through the pipeline:

Expand Down
6 changes: 6 additions & 0 deletions crates/relune-core/src/diff.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1049,6 +1049,7 @@ mod tests {
nullable,
is_primary_key: pk,
comment: None,
enum_values: None,
})
.collect(),
foreign_keys: fks
Expand Down Expand Up @@ -1088,6 +1089,7 @@ mod tests {
nullable: true,
is_primary_key: false,
comment: None,
enum_values: None,
})
.collect(),
definition: definition.map(ToString::to_string),
Expand Down Expand Up @@ -1570,6 +1572,7 @@ mod tests {
nullable: false,
is_primary_key: false,
comment: None,
enum_values: None,
},
Column {
id: ColumnId(1),
Expand All @@ -1578,6 +1581,7 @@ mod tests {
nullable: false,
is_primary_key: false,
comment: None,
enum_values: None,
},
],
definition: Some("SELECT id, email FROM users".to_string()),
Expand Down Expand Up @@ -1672,6 +1676,7 @@ mod tests {
nullable: true,
is_primary_key: false,
comment: None,
enum_values: None,
}],
definition: Some("SELECT id FROM users".to_string()),
}],
Expand All @@ -1692,6 +1697,7 @@ mod tests {
nullable: true,
is_primary_key: false,
comment: None,
enum_values: None,
}],
definition: Some("SELECT id FROM users WHERE active".to_string()),
}],
Expand Down
3 changes: 3 additions & 0 deletions crates/relune-core/src/export.rs
Original file line number Diff line number Diff line change
Expand Up @@ -323,6 +323,7 @@ fn import_column(index: usize, export: &ColumnExport) -> Column {
nullable: export.nullable,
is_primary_key: export.primary_key,
comment: None,
enum_values: None,
}
}

Expand Down Expand Up @@ -418,6 +419,7 @@ mod tests {
nullable: false,
is_primary_key: primary_key,
comment: None,
enum_values: None,
}
}

Expand Down Expand Up @@ -668,6 +670,7 @@ mod tests {
nullable: false,
is_primary_key: false,
comment: None,
enum_values: None,
}],
definition: Some("SELECT id FROM users WHERE active".to_string()),
}],
Expand Down
123 changes: 95 additions & 28 deletions crates/relune-core/src/graph.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,6 @@ use sqlparser::ast::{ObjectName, ObjectNamePart, Query, Visit, Visitor};
use sqlparser::dialect::{Dialect, GenericDialect, MySqlDialect, PostgreSqlDialect, SQLiteDialect};
use sqlparser::parser::Parser;
use thiserror::Error;
use tracing::debug;

use crate::model::{
Enum, ForeignKeyTargetResolution, Schema, Table, TableId, View, normalize_identifier,
Expand Down Expand Up @@ -222,12 +221,50 @@ const fn object_name_identifier(part: &ObjectNamePart) -> Option<&str> {
}
}

/// Error returned when no supported SQL dialect can parse a relation
/// definition (e.g. a view's SQL body) for dependency extraction.
///
/// The error preserves a short snippet of the failing source so callers can
/// surface a useful diagnostic instead of silently dropping the view's
/// dependency edges.
#[derive(Debug, Clone, PartialEq, Eq, Error)]
#[error(
"no supported SQL dialect could parse the relation definition for dependency extraction: {snippet}"
)]
pub struct SqlRelationParseError {
/// Truncated snippet of the definition that failed to parse.
pub snippet: String,
}

const SQL_RELATION_PARSE_SNIPPET_LIMIT: usize = 80;

fn snippet_for_definition(definition: &str) -> String {
let trimmed = definition.trim();
let mut buffer = String::with_capacity(SQL_RELATION_PARSE_SNIPPET_LIMIT + 1);
for ch in trimmed.chars() {
if buffer.len() + ch.len_utf8() > SQL_RELATION_PARSE_SNIPPET_LIMIT {
buffer.push('…');
break;
}
buffer.push(ch);
}
buffer
}

/// Collects normalized table/view references from a SQL fragment.
///
/// The result excludes CTE aliases so callers can reason about actual
/// relation dependencies without comment or alias false positives.
#[must_use]
pub fn collect_sql_relations(definition: &str) -> HashSet<SqlRelation> {
///
/// # Errors
///
/// Returns [`SqlRelationParseError`] when no supported dialect can parse
/// `definition`. Callers should surface this as a diagnostic rather than
/// silently treat the view as having no dependencies, otherwise an
/// unparsable view becomes indistinguishable from a genuinely orphan view.
pub fn collect_sql_relations(
definition: &str,
) -> Result<HashSet<SqlRelation>, SqlRelationParseError> {
let generic = GenericDialect {};
let postgres = PostgreSqlDialect {};
let mysql = MySqlDialect {};
Expand All @@ -240,13 +277,12 @@ pub fn collect_sql_relations(definition: &str) -> HashSet<SqlRelation> {
};
let mut collector = RelationCollector::default();
let _ = statements.visit(&mut collector);
return collector.references;
return Ok(collector.references);
}

debug!(
"collect_sql_relations: no dialect could parse the definition; view dependencies may be missing"
);
HashSet::new()
Err(SqlRelationParseError {
snippet: snippet_for_definition(definition),
})
}

impl SchemaGraph {
Expand Down Expand Up @@ -432,26 +468,41 @@ impl SchemaGraph {
});

if let Some(ref definition) = view.definition {
let references = collect_sql_relations(definition);
for table in &schema.tables {
if table_names.contains(&table.name.to_lowercase())
&& references
.iter()
.any(|reference| reference.matches_table(table))
&& let Some(&table_idx) = ids.get(&table.id)
{
graph.add_edge(
table_idx,
view_idx,
GraphEdge {
from: table.stable_id.clone(),
to: view.id.clone(),
label: "view dep".to_string(),
nullable: false,
from_columns: vec![],
to_columns: vec![],
kind: EdgeKind::ViewDependency,
},
// SchemaGraph has no diagnostic channel, so a parse failure is
// surfaced as a `tracing::warn!` rather than a `debug!`. The
// layout-level builder (which does carry diagnostics) records
// this as a `parse_unsupported` Diagnostic so user-facing
// tools can distinguish unparsable views from orphan views.
match collect_sql_relations(definition) {
Ok(references) => {
for table in &schema.tables {
if table_names.contains(&table.name.to_lowercase())
&& references
.iter()
.any(|reference| reference.matches_table(table))
&& let Some(&table_idx) = ids.get(&table.id)
{
graph.add_edge(
table_idx,
view_idx,
GraphEdge {
from: table.stable_id.clone(),
to: view.id.clone(),
label: "view dep".to_string(),
nullable: false,
from_columns: vec![],
to_columns: vec![],
kind: EdgeKind::ViewDependency,
},
);
}
}
}
Err(error) => {
tracing::warn!(
view = %view.qualified_name(),
snippet = %error.snippet,
"Could not parse view definition for dependency extraction; view dependency edges may be missing",
);
}
}
Expand Down Expand Up @@ -535,6 +586,7 @@ mod tests {
nullable,
is_primary_key,
comment: None,
enum_values: None,
}
}

Expand Down Expand Up @@ -1045,4 +1097,19 @@ mod tests {
assert_eq!(deps, vec![public_users]);
assert!(!deps.contains(&analytics_users));
}

#[test]
fn collect_sql_relations_returns_err_when_no_dialect_can_parse() {
let definition = "this is not valid sql ;;;;";
let error = collect_sql_relations(definition).expect_err("definition should not parse");
assert!(!error.snippet.is_empty());
assert!(definition.starts_with(error.snippet.trim_end_matches('…')));
}

#[test]
fn collect_sql_relations_returns_ok_for_parseable_definition() {
let references = collect_sql_relations("select id from public.users")
.expect("definition should parse with at least one supported dialect");
assert!(references.iter().any(|reference| reference.name == "users"));
}
}
2 changes: 1 addition & 1 deletion crates/relune-core/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ pub use diagnostic::{Diagnostic, DiagnosticCode, Severity, SourceSpan};
pub use diff::{ChangeKind, SchemaDiff, diff_schemas};
pub use graph::{
EdgeKind, GraphBuildError, GraphEdge, GraphNode, NodeKind, SchemaGraph, SqlRelation,
collect_sql_relations,
SqlRelationParseError, collect_sql_relations,
};
pub use layout::{Cardinality, EdgeRoute, RouteStyle};
pub use lint::{
Expand Down
1 change: 1 addition & 0 deletions crates/relune-core/src/lint.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1216,6 +1216,7 @@ mod tests {
nullable,
is_primary_key: is_pk,
comment: None,
enum_values: None,
}
}

Expand Down
15 changes: 15 additions & 0 deletions crates/relune-core/src/model.rs
Original file line number Diff line number Diff line change
Expand Up @@ -534,6 +534,17 @@ pub struct Column {
pub is_primary_key: bool,
/// Optional column comment.
pub comment: Option<String>,
/// Inline enum/set values, when the column type carries them directly
/// (e.g. `MySQL` `ENUM('a','b')` or `SET('x','y')`).
///
/// `Schema::enums` is reserved for *named* enum types that exist as
/// independent schema objects. Inline definitions have no separate
/// identity, so storing their values on the column avoids synthesising
/// an enum id like `enum('a','b')` (which contains characters that
/// break Mermaid/DOT/JSON pointer ids and silently collide across
/// different columns that happen to share the same literal).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub enum_values: Option<Vec<String>>,
}

/// Referential action for ON DELETE / ON UPDATE clauses.
Expand Down Expand Up @@ -673,6 +684,7 @@ mod tests {
nullable: false,
is_primary_key: i == 0,
comment: None,
enum_values: None,
})
.collect(),
foreign_keys: fks,
Expand Down Expand Up @@ -1071,6 +1083,7 @@ mod tests {
nullable: true,
is_primary_key: false,
comment: None,
enum_values: None,
})
.collect(),
definition: None,
Expand Down Expand Up @@ -1147,6 +1160,7 @@ mod tests {
nullable: true,
is_primary_key: false,
comment: None,
enum_values: None,
}],
definition: None,
}],
Expand All @@ -1172,6 +1186,7 @@ mod tests {
nullable: true,
is_primary_key: false,
comment: None,
enum_values: None,
}],
definition: None,
}],
Expand Down
1 change: 1 addition & 0 deletions crates/relune-core/src/review/rules.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1909,6 +1909,7 @@ mod tests {
nullable,
is_primary_key: pk,
comment: None,
enum_values: None,
}
}

Expand Down
4 changes: 4 additions & 0 deletions crates/relune-core/tests/integration_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ fn pk_col(id: u64, name: &str) -> Column {
nullable: false,
is_primary_key: true,
comment: None,
enum_values: None,
}
}

Expand All @@ -47,6 +48,7 @@ fn fk_col(id: u64, name: &str) -> Column {
nullable: false,
is_primary_key: false,
comment: None,
enum_values: None,
}
}

Expand Down Expand Up @@ -154,6 +156,7 @@ fn view_with_table_dependency_appears_in_graph() {
nullable: false,
is_primary_key: false,
comment: None,
enum_values: None,
}],
definition: Some("SELECT id FROM users WHERE active".to_string()),
}],
Expand Down Expand Up @@ -306,6 +309,7 @@ fn enum_types_appear_in_graph() {
nullable: false,
is_primary_key: false,
comment: None,
enum_values: None,
},
],
vec![],
Expand Down
2 changes: 1 addition & 1 deletion crates/relune-introspect/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ relune-parser-sql = { path = "../relune-parser-sql" }
testcontainers = "0.24"
testcontainers-modules = { version = "0.12", features = ["postgres", "mysql"] }
tempfile = "3"
tokio = { version = "1", features = ["rt-multi-thread", "macros"] }
tokio = { version = "1", features = ["rt-multi-thread", "macros", "test-util"] }

[lints]
workspace = true
1 change: 1 addition & 0 deletions crates/relune-introspect/src/common.rs
Original file line number Diff line number Diff line change
Expand Up @@ -362,6 +362,7 @@ fn map_column(raw_column: &RawColumn, table_stable_id: &str, is_primary_key: boo
nullable: raw_column.is_nullable,
is_primary_key,
comment: raw_column.column_comment.clone(),
enum_values: None,
}
}

Expand Down
Loading
Loading