Skip to main content

surql_parser/
diff.rs

1//! Schema diffing — compare two SchemaGraphs and report structural changes.
2
3use crate::SchemaGraph;
4use std::collections::BTreeSet;
5
6/// Structural diff between two schema states.
7///
8/// Captures added/removed tables, fields, indexes, events, and functions.
9/// Changed table modes (e.g., SCHEMAFULL to SCHEMALESS) and field type changes
10/// are tracked separately.
11#[derive(Debug, Default, PartialEq, Eq)]
12pub struct SchemaDiff {
13	pub added_tables: Vec<String>,
14	pub removed_tables: Vec<String>,
15	pub changed_tables: Vec<TableChange>,
16	pub added_fields: Vec<(String, String)>,
17	pub removed_fields: Vec<(String, String)>,
18	pub changed_fields: Vec<FieldTypeChange>,
19	pub added_indexes: Vec<(String, String)>,
20	pub removed_indexes: Vec<(String, String)>,
21	pub added_events: Vec<(String, String)>,
22	pub removed_events: Vec<(String, String)>,
23	pub added_functions: Vec<String>,
24	pub removed_functions: Vec<String>,
25}
26
27/// A table whose definition changed between before and after.
28#[derive(Debug, PartialEq, Eq)]
29pub struct TableChange {
30	pub name: String,
31	pub before_full: bool,
32	pub after_full: bool,
33}
34
35/// A field whose type changed between before and after.
36#[derive(Debug, PartialEq, Eq)]
37pub struct FieldTypeChange {
38	pub table: String,
39	pub field: String,
40	pub before_type: String,
41	pub after_type: String,
42}
43
44impl SchemaDiff {
45	pub fn is_empty(&self) -> bool {
46		self.added_tables.is_empty()
47			&& self.removed_tables.is_empty()
48			&& self.changed_tables.is_empty()
49			&& self.added_fields.is_empty()
50			&& self.removed_fields.is_empty()
51			&& self.changed_fields.is_empty()
52			&& self.added_indexes.is_empty()
53			&& self.removed_indexes.is_empty()
54			&& self.added_events.is_empty()
55			&& self.removed_events.is_empty()
56			&& self.added_functions.is_empty()
57			&& self.removed_functions.is_empty()
58	}
59}
60
61impl std::fmt::Display for SchemaDiff {
62	fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
63		if self.is_empty() {
64			return write!(f, "No schema changes.");
65		}
66
67		for t in &self.added_tables {
68			writeln!(f, "+ TABLE {t}")?;
69		}
70		for t in &self.removed_tables {
71			writeln!(f, "- TABLE {t}")?;
72		}
73		for c in &self.changed_tables {
74			let before = if c.before_full {
75				"SCHEMAFULL"
76			} else {
77				"SCHEMALESS"
78			};
79			let after = if c.after_full {
80				"SCHEMAFULL"
81			} else {
82				"SCHEMALESS"
83			};
84			writeln!(f, "~ TABLE {}: {before} -> {after}", c.name)?;
85		}
86		for (table, field) in &self.added_fields {
87			writeln!(f, "+ FIELD {field} ON {table}")?;
88		}
89		for (table, field) in &self.removed_fields {
90			writeln!(f, "- FIELD {field} ON {table}")?;
91		}
92		for change in &self.changed_fields {
93			writeln!(
94				f,
95				"~ FIELD {} ON {}: {} -> {}",
96				change.field, change.table, change.before_type, change.after_type
97			)?;
98		}
99		for (table, idx) in &self.added_indexes {
100			writeln!(f, "+ INDEX {idx} ON {table}")?;
101		}
102		for (table, idx) in &self.removed_indexes {
103			writeln!(f, "- INDEX {idx} ON {table}")?;
104		}
105		for (table, ev) in &self.added_events {
106			writeln!(f, "+ EVENT {ev} ON {table}")?;
107		}
108		for (table, ev) in &self.removed_events {
109			writeln!(f, "- EVENT {ev} ON {table}")?;
110		}
111		for func in &self.added_functions {
112			writeln!(f, "+ FUNCTION fn::{func}")?;
113		}
114		for func in &self.removed_functions {
115			writeln!(f, "- FUNCTION fn::{func}")?;
116		}
117		Ok(())
118	}
119}
120
121/// Compare two schema graphs and return a structured diff.
122///
123/// # Example
124///
125/// ```
126/// use surql_parser::{SchemaGraph, diff::compare_schemas};
127///
128/// let before = SchemaGraph::from_source("
129///     DEFINE TABLE user SCHEMAFULL;
130///     DEFINE FIELD name ON user TYPE string;
131/// ").unwrap();
132///
133/// let after = SchemaGraph::from_source("
134///     DEFINE TABLE user SCHEMAFULL;
135///     DEFINE FIELD name ON user TYPE string;
136///     DEFINE FIELD email ON user TYPE string;
137///     DEFINE TABLE post SCHEMALESS;
138/// ").unwrap();
139///
140/// let diff = compare_schemas(&before, &after);
141/// assert_eq!(diff.added_tables, vec!["post"]);
142/// assert_eq!(diff.added_fields, vec![("user".to_string(), "email".to_string())]);
143/// ```
144pub fn compare_schemas(before: &SchemaGraph, after: &SchemaGraph) -> SchemaDiff {
145	let mut diff = SchemaDiff::default();
146
147	let before_tables: BTreeSet<&str> = before.table_names().collect();
148	let after_tables: BTreeSet<&str> = after.table_names().collect();
149
150	for &name in &after_tables {
151		if !before_tables.contains(name) {
152			diff.added_tables.push(name.to_string());
153			// Name comes from table_names() iterator over the same SchemaGraph — guaranteed to exist
154			let Some(table) = after.table(name) else {
155				continue;
156			};
157			for field in &table.fields {
158				diff.added_fields
159					.push((name.to_string(), field.name.clone()));
160			}
161			for idx in &table.indexes {
162				diff.added_indexes
163					.push((name.to_string(), idx.name.clone()));
164			}
165			for ev in &table.events {
166				diff.added_events.push((name.to_string(), ev.name.clone()));
167			}
168		}
169	}
170	for &name in &before_tables {
171		if !after_tables.contains(name) {
172			diff.removed_tables.push(name.to_string());
173			// Name comes from table_names() iterator over the same SchemaGraph — guaranteed to exist
174			let Some(table) = before.table(name) else {
175				continue;
176			};
177			for field in &table.fields {
178				diff.removed_fields
179					.push((name.to_string(), field.name.clone()));
180			}
181			for idx in &table.indexes {
182				diff.removed_indexes
183					.push((name.to_string(), idx.name.clone()));
184			}
185			for ev in &table.events {
186				diff.removed_events
187					.push((name.to_string(), ev.name.clone()));
188			}
189		}
190	}
191
192	let common_tables: BTreeSet<&str> =
193		before_tables.intersection(&after_tables).copied().collect();
194	for &name in &common_tables {
195		// Names come from intersection of both table_names() iterators — guaranteed to exist in both
196		let Some(before_table) = before.table(name) else {
197			continue;
198		};
199		let Some(after_table) = after.table(name) else {
200			continue;
201		};
202
203		if before_table.full != after_table.full {
204			diff.changed_tables.push(TableChange {
205				name: name.to_string(),
206				before_full: before_table.full,
207				after_full: after_table.full,
208			});
209		}
210
211		let before_fields: BTreeSet<&str> = before_table
212			.fields
213			.iter()
214			.map(|f| f.name.as_str())
215			.collect();
216		let after_fields: BTreeSet<&str> =
217			after_table.fields.iter().map(|f| f.name.as_str()).collect();
218		for &field in &after_fields {
219			if !before_fields.contains(field) {
220				diff.added_fields
221					.push((name.to_string(), field.to_string()));
222			}
223		}
224		for &field in &before_fields {
225			if !after_fields.contains(field) {
226				diff.removed_fields
227					.push((name.to_string(), field.to_string()));
228			}
229		}
230
231		// Detect field type changes for fields present in both
232		for before_field in &before_table.fields {
233			if let Some(after_field) = after_table
234				.fields
235				.iter()
236				.find(|f| f.name == before_field.name)
237				&& before_field.kind != after_field.kind
238			{
239				diff.changed_fields.push(FieldTypeChange {
240					table: name.to_string(),
241					field: before_field.name.clone(),
242					before_type: before_field.kind.clone().unwrap_or_default(),
243					after_type: after_field.kind.clone().unwrap_or_default(),
244				});
245			}
246		}
247
248		let before_indexes: BTreeSet<&str> = before_table
249			.indexes
250			.iter()
251			.map(|i| i.name.as_str())
252			.collect();
253		let after_indexes: BTreeSet<&str> = after_table
254			.indexes
255			.iter()
256			.map(|i| i.name.as_str())
257			.collect();
258		for &idx in &after_indexes {
259			if !before_indexes.contains(idx) {
260				diff.added_indexes.push((name.to_string(), idx.to_string()));
261			}
262		}
263		for &idx in &before_indexes {
264			if !after_indexes.contains(idx) {
265				diff.removed_indexes
266					.push((name.to_string(), idx.to_string()));
267			}
268		}
269
270		let before_events: BTreeSet<&str> = before_table
271			.events
272			.iter()
273			.map(|e| e.name.as_str())
274			.collect();
275		let after_events: BTreeSet<&str> =
276			after_table.events.iter().map(|e| e.name.as_str()).collect();
277		for &ev in &after_events {
278			if !before_events.contains(ev) {
279				diff.added_events.push((name.to_string(), ev.to_string()));
280			}
281		}
282		for &ev in &before_events {
283			if !after_events.contains(ev) {
284				diff.removed_events.push((name.to_string(), ev.to_string()));
285			}
286		}
287	}
288
289	let before_fns: BTreeSet<&str> = before.function_names().collect();
290	let after_fns: BTreeSet<&str> = after.function_names().collect();
291	for &name in &after_fns {
292		if !before_fns.contains(name) {
293			diff.added_functions.push(name.to_string());
294		}
295	}
296	for &name in &before_fns {
297		if !after_fns.contains(name) {
298			diff.removed_functions.push(name.to_string());
299		}
300	}
301
302	diff
303}