Skip to content

Commit 48e67e5

Browse files
committed
fix: report misconfigured Recall write connections and skip unseen views
An unknown write connection, a non-string adapter, a SQLite connection without a path, an empty ClickHouse database or an unterminated environment reference now fails the command with C975, like a write connection without a read connection; an unset environment variable still only skips recording. A ClickHouse writer that sees none of the views, as when granted only the tables, issues no view DDL, warns of no view upgrade and records no version it cannot vouch for. A test checks that both dialects' views expose the same columns.
1 parent f84bcd5 commit 48e67e5

15 files changed

Lines changed: 370 additions & 34 deletions

File tree

‎crates/sqlbuild-python/src/bindings/_helpers/recall/clickhouse_store.rs‎

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -195,6 +195,7 @@ impl ClickHouseStore {
195195
return Ok(StoreVersion::Recorded {
196196
version: state.version,
197197
age_seconds: state.version_age_seconds,
198+
views_visible: !state.views.is_empty(),
198199
});
199200
}
200201
let mut expected = view_columns().map_err(|message| StoreError {
@@ -225,7 +226,11 @@ impl ClickHouseStore {
225226
.or_default()
226227
.insert(text(row.get(1)));
227228
}
228-
Ok(StoreVersion::Unrecorded(schema_age(&expected, &observed)))
229+
Ok(StoreVersion::Unrecorded(schema_age(
230+
&expected,
231+
&observed,
232+
&VIEW_NAMES,
233+
)))
229234
}
230235

231236
fn require_database(&self, py: Python<'_>) -> Result<(), StoreError> {
@@ -325,7 +330,12 @@ impl ClickHouseStore {
325330
tables,
326331
});
327332
}
328-
for (name, statement) in view_statements(Dialect::ClickHouse, &self.location.options) {
333+
let view_ddl: Vec<(&str, String)> = if plan.view_ddl {
334+
view_statements(Dialect::ClickHouse, &self.location.options)
335+
} else {
336+
Vec::new()
337+
};
338+
for (name, statement) in view_ddl {
329339
let statement: String = if plan.replace_views {
330340
statement
331341
} else if !views.contains(name) {
@@ -920,6 +930,8 @@ fn observed_version(store: StoreVersion) -> i64 {
920930
match store {
921931
StoreVersion::Recorded { version, .. } => version,
922932
StoreVersion::Unrecorded(SchemaAge::Older) => 0,
923-
StoreVersion::Unrecorded(SchemaAge::Current | SchemaAge::Newer) => RECALL_SCHEMA_VERSION,
933+
StoreVersion::Unrecorded(SchemaAge::Current | SchemaAge::Newer | SchemaAge::Unseen) => {
934+
RECALL_SCHEMA_VERSION
935+
}
924936
}
925937
}

‎crates/sqlbuild-recall/src/schema/main/schema_age.rs‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,12 +4,16 @@ use std::collections::{BTreeMap, BTreeSet};
44

55
use crate::schema::models::SchemaAge;
66

7-
/// Compare the `observed` columns of each table and view with this build's `expected` ones; both evolve additively, so a missing relation or column means an older store and an unknown column a newer one. A relation this build does not define is ignored.
7+
/// Compare the `observed` columns of each table and view with this build's `expected` ones; both evolve additively, so a missing relation or column means an older store and an unknown column a newer one. When none of `views` is observed, the user may simply not see them, so nothing is judged. A relation this build does not define is ignored.
88
#[must_use]
99
pub fn schema_age(
1010
expected: &BTreeMap<&'static str, BTreeSet<String>>,
1111
observed: &BTreeMap<String, BTreeSet<String>>,
12+
views: &[&str],
1213
) -> SchemaAge {
14+
if !views.iter().any(|view| observed.contains_key(*view)) {
15+
return SchemaAge::Unseen;
16+
}
1317
let mut older: bool = false;
1418
for (relation, columns) in expected {
1519
let Some(present) = observed.get(*relation) else {

‎crates/sqlbuild-recall/src/schema/main/version_plan.rs‎

Lines changed: 28 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -3,32 +3,44 @@
33
use crate::schema::constants::{RECALL_SCHEMA_VERSION, SECONDS_PER_DAY};
44
use crate::schema::models::{SchemaAge, StoreVersion, VersionPlan};
55

6-
/// Upgrade an older store and record this build's version; never replace a newer store's views or record a lower version than it holds. A version row older than half the retention is appended again with the highest version seen, so retention never removes the last one.
6+
/// Upgrade an older store and record this build's version; never replace a newer store's views or record a lower version than it holds. A version row older than half the retention is appended again with the highest version seen, so retention never removes the last one. A user who sees no view runs no view statements and, without a version row, records none.
77
#[must_use]
88
pub fn version_plan(store: StoreVersion, retention_days: u32) -> VersionPlan {
9+
let refresh = |version: i64, age_seconds: i64| {
10+
(age_seconds.saturating_mul(2) > i64::from(retention_days) * SECONDS_PER_DAY)
11+
.then_some(version)
12+
};
13+
let plan = |replace_views: bool, record: Option<i64>| VersionPlan {
14+
replace_views,
15+
view_ddl: true,
16+
record,
17+
};
918
match store {
10-
StoreVersion::Recorded { version, .. } if version < RECALL_SCHEMA_VERSION => VersionPlan {
11-
replace_views: true,
12-
record: Some(RECALL_SCHEMA_VERSION),
13-
},
1419
StoreVersion::Recorded {
1520
version,
1621
age_seconds,
22+
views_visible: false,
1723
} => VersionPlan {
1824
replace_views: false,
19-
record: (age_seconds.saturating_mul(2) > i64::from(retention_days) * SECONDS_PER_DAY)
20-
.then_some(version),
21-
},
22-
StoreVersion::Unrecorded(SchemaAge::Older) => VersionPlan {
23-
replace_views: true,
24-
record: Some(RECALL_SCHEMA_VERSION),
25-
},
26-
StoreVersion::Unrecorded(SchemaAge::Current) => VersionPlan {
27-
replace_views: false,
28-
record: Some(RECALL_SCHEMA_VERSION),
25+
view_ddl: false,
26+
record: (version >= RECALL_SCHEMA_VERSION)
27+
.then(|| refresh(version, age_seconds))
28+
.flatten(),
2929
},
30-
StoreVersion::Unrecorded(SchemaAge::Newer) => VersionPlan {
30+
StoreVersion::Recorded { version, .. } if version < RECALL_SCHEMA_VERSION => {
31+
plan(true, Some(RECALL_SCHEMA_VERSION))
32+
}
33+
StoreVersion::Recorded {
34+
version,
35+
age_seconds,
36+
..
37+
} => plan(false, refresh(version, age_seconds)),
38+
StoreVersion::Unrecorded(SchemaAge::Older) => plan(true, Some(RECALL_SCHEMA_VERSION)),
39+
StoreVersion::Unrecorded(SchemaAge::Current) => plan(false, Some(RECALL_SCHEMA_VERSION)),
40+
StoreVersion::Unrecorded(SchemaAge::Newer) => plan(false, None),
41+
StoreVersion::Unrecorded(SchemaAge::Unseen) => VersionPlan {
3142
replace_views: false,
43+
view_ddl: false,
3244
record: None,
3345
},
3446
}

‎crates/sqlbuild-recall/src/schema/models.rs‎

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -128,13 +128,19 @@ pub enum SchemaAge {
128128
Current,
129129
/// A relation has a column this build does not know: a newer build created it.
130130
Newer,
131+
/// No view is visible, as to a user granted only the tables, so the store's age is unknown.
132+
Unseen,
131133
}
132134

133135
/// What a writer knows about the store's schema version.
134136
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
135137
pub enum StoreVersion {
136-
/// The highest recorded version, and the age in seconds of the newest version row.
137-
Recorded { version: i64, age_seconds: i64 },
138+
/// The highest recorded version, the age in seconds of the newest version row, and whether any view is visible to this user.
139+
Recorded {
140+
version: i64,
141+
age_seconds: i64,
142+
views_visible: bool,
143+
},
138144
/// No version row is left, as after retention expired it; the columns tell the store's age.
139145
Unrecorded(SchemaAge),
140146
}
@@ -144,6 +150,8 @@ pub enum StoreVersion {
144150
pub struct VersionPlan {
145151
/// Replace every view with this build's definition.
146152
pub replace_views: bool,
153+
/// Run view statements at all: replacements, or creating missing views. A user who sees no view issues none, since they would fail on every start.
154+
pub view_ddl: bool,
147155
/// The version to append once the views are settled, if any.
148156
pub record: Option<i64>,
149157
}

‎crates/sqlbuild-recall/src/schema/tests/helpers.rs‎

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -119,3 +119,58 @@ pub(crate) fn changed_statements_view(
119119
statements.retain(|column| !removed.contains(&column.as_str()));
120120
views
121121
}
122+
123+
/// Views that pass a table through with `SELECT *`, and that table.
124+
const PASSTHROUGH_VIEWS: [(&str, Table); 3] = [
125+
("events", Table::Events),
126+
("diagnostics", Table::Diagnostics),
127+
("conflicts", Table::Conflicts),
128+
];
129+
130+
/// The columns a ClickHouse view body exposes: its passed-through table's, or the aliases of its final top-level `SELECT` list.
131+
pub(crate) fn clickhouse_view_columns(view: &str, body: &str) -> BTreeSet<String> {
132+
PASSTHROUGH_VIEWS
133+
.iter()
134+
.find(|(name, _)| *name == view)
135+
.map(|(_, table)| {
136+
table_columns(*table)
137+
.into_iter()
138+
.map(|column| column.name.to_owned())
139+
.collect()
140+
})
141+
.unwrap_or_else(|| select_list_names(body))
142+
}
143+
144+
fn select_list_names(body: &str) -> BTreeSet<String> {
145+
let start: usize = body.rfind("\nSELECT").map_or(0, |index| index + 1);
146+
let list: &str = body[start + "SELECT".len()..]
147+
.split("\nFROM ")
148+
.next()
149+
.unwrap_or_default();
150+
top_level_items(list)
151+
.iter()
152+
.map(|item| item_name(item))
153+
.collect()
154+
}
155+
156+
fn top_level_items(list: &str) -> Vec<String> {
157+
let marked: String = list
158+
.chars()
159+
.scan((0_i32, false), |(depth, quoted), character| {
160+
let separator: bool = *depth == 0 && !*quoted && character == ',';
161+
*quoted ^= character == '\'';
162+
*depth += i32::from(!*quoted && character == '(');
163+
*depth -= i32::from(!*quoted && character == ')');
164+
Some([character, '\u{0}'][usize::from(separator)])
165+
})
166+
.collect();
167+
marked.split('\u{0}').map(str::to_owned).collect()
168+
}
169+
170+
fn item_name(item: &str) -> String {
171+
let normalized: String = item.split_whitespace().collect::<Vec<&str>>().join(" ");
172+
normalized.rsplit_once(" AS ").map_or_else(
173+
|| normalized.rsplit('.').next().unwrap_or_default().to_owned(),
174+
|(_, alias)| alias.to_owned(),
175+
)
176+
}

‎crates/sqlbuild-recall/src/schema/tests/test_types.rs‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,3 +43,9 @@ pub(crate) struct VersionPlanTestCase {
4343
pub(crate) store: crate::schema::models::StoreVersion,
4444
pub(crate) expected_plan: crate::schema::models::VersionPlan,
4545
}
46+
47+
pub(crate) struct ViewColumnsTestCase {
48+
pub(crate) description: &'static str,
49+
pub(crate) view: &'static str,
50+
pub(crate) expected_same: bool,
51+
}

0 commit comments

Comments
 (0)