Skip to content

Commit 42e76b9

Browse files
Benoit Aubuchonclaude
andcommitted
fix(dictionaries): depth/quote-aware clause scanner + add missing keywords
Replaces naive .find() with find_top_level_keyword(), a scanner that tracks parenthesis depth and single/double-quote state so keywords inside SOURCE payloads, INVALIDATE_QUERY bodies, or COMMENT strings never incorrectly split a clause boundary. Also adds INVALIDATE_QUERY and COMMENT to the recognised clause list so drift in those optional clauses is detected rather than silently ignored. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent b40d286 commit 42e76b9

1 file changed

Lines changed: 95 additions & 31 deletions

File tree

apps/framework-cli/src/framework/core/infra_reality_checker.rs

Lines changed: 95 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -134,6 +134,12 @@ fn normalize_database(db: &Option<String>, default_database: &str) -> String {
134134
/// our generated DDL (LAYOUT before LIFETIME) and ClickHouse's SHOW CREATE
135135
/// output (LIFETIME before LAYOUT)
136136
///
137+
/// Recognised top-level clauses: `PRIMARY KEY`, `SOURCE`, `LAYOUT`, `LIFETIME`,
138+
/// `INVALIDATE_QUERY`, `SETTINGS`, `COMMENT`. Clause boundaries are detected with
139+
/// a depth/quote-aware scanner so keywords inside parentheses or quoted strings
140+
/// (e.g. an `INVALIDATE_QUERY` body containing the word `SETTINGS`) are not
141+
/// mistaken for clause starts.
142+
///
137143
/// **Limitation**: identifier quoting and type-alias differences (e.g.
138144
/// `Int64` vs `Int64`) are preserved as-is; ClickHouse and our generator both
139145
/// use backtick-quoted identifiers so these should agree in practice.
@@ -164,44 +170,102 @@ fn dicts_ddl_equivalent(actual_ddl: &str, desired_ddl: &str) -> bool {
164170

165171
let col_block = ddl[start..end].to_string();
166172

167-
// Parse top-level clauses by their keyword prefix rather than splitting by lines.
168-
// This handles multi-line clauses like SOURCE(...) that may span multiple lines.
173+
// Parse top-level clauses by keyword prefix using a depth/quote-aware scanner.
174+
// Skips keyword matches inside parentheses or quoted strings so that e.g.
175+
// a SOURCE url containing "LIFETIME" or an INVALIDATE_QUERY containing "SETTINGS"
176+
// never splits a clause incorrectly.
169177
let remainder = ddl[end..].trim();
170-
let mut clauses = Vec::new();
171-
let clause_keywords = ["PRIMARY KEY", "SOURCE", "LAYOUT", "LIFETIME", "SETTINGS"];
178+
let clause_keywords: &[&str] = &[
179+
"PRIMARY KEY",
180+
"SOURCE",
181+
"LAYOUT",
182+
"LIFETIME",
183+
"INVALIDATE_QUERY",
184+
"SETTINGS",
185+
"COMMENT",
186+
];
187+
188+
// Returns the byte position (in `text`) and keyword for the first
189+
// top-level keyword occurrence at or after `from`, skipping over
190+
// parenthesised content and single/double-quoted strings.
191+
fn find_top_level_keyword<'a>(
192+
text: &str,
193+
from: usize,
194+
keywords: &[&'a str],
195+
) -> Option<(usize, &'a str)> {
196+
let mut depth: usize = 0;
197+
let mut in_single_quote = false;
198+
let mut in_double_quote = false;
199+
200+
for (i, c) in text[from..].char_indices() {
201+
let abs_pos = from + i;
202+
203+
if in_single_quote {
204+
if c == '\'' {
205+
in_single_quote = false;
206+
}
207+
continue;
208+
}
209+
if in_double_quote {
210+
if c == '"' {
211+
in_double_quote = false;
212+
}
213+
continue;
214+
}
215+
match c {
216+
'(' => depth += 1,
217+
')' => depth = depth.saturating_sub(1),
218+
'\'' if depth == 0 => in_single_quote = true,
219+
'"' if depth == 0 => in_double_quote = true,
220+
_ if depth == 0 => {
221+
for &kw in keywords {
222+
if text[abs_pos..].starts_with(kw) {
223+
// Require a word boundary before (start or non-alnum/underscore)
224+
let prev_ok = abs_pos == from
225+
|| text[..abs_pos]
226+
.chars()
227+
.next_back()
228+
.map(|p| !p.is_alphanumeric() && p != '_')
229+
.unwrap_or(true);
230+
// Require a word boundary after (end or non-alnum/underscore)
231+
let after = abs_pos + kw.len();
232+
let next_ok = text[after..]
233+
.chars()
234+
.next()
235+
.map(|n| !n.is_alphanumeric() && n != '_')
236+
.unwrap_or(true);
237+
if prev_ok && next_ok {
238+
return Some((abs_pos, kw));
239+
}
240+
}
241+
}
242+
}
243+
_ => {}
244+
}
245+
}
246+
None
247+
}
172248

249+
let mut clauses = Vec::new();
173250
let mut current_pos = 0;
174251
while current_pos < remainder.len() {
175-
// Find the next clause keyword
176-
let next_clause_start = clause_keywords
177-
.iter()
178-
.filter_map(|&keyword| {
179-
remainder[current_pos..]
180-
.find(keyword)
181-
.map(|pos| (current_pos + pos, keyword))
182-
})
183-
.min_by_key(|(pos, _)| *pos);
184-
185-
if let Some((keyword_pos, keyword)) = next_clause_start {
186-
// Find where this clause ends (either at the next keyword or end of string)
187-
let clause_start = keyword_pos;
188-
let clause_end = clause_keywords
189-
.iter()
190-
.filter_map(|&kw| {
191-
remainder[clause_start + keyword.len()..]
192-
.find(kw)
193-
.map(|pos| clause_start + keyword.len() + pos)
194-
})
195-
.min()
252+
match find_top_level_keyword(remainder, current_pos, clause_keywords) {
253+
None => break,
254+
Some((clause_start, keyword)) => {
255+
let clause_end = find_top_level_keyword(
256+
remainder,
257+
clause_start + keyword.len(),
258+
clause_keywords,
259+
)
260+
.map(|(pos, _)| pos)
196261
.unwrap_or(remainder.len());
197262

198-
let clause_text = remainder[clause_start..clause_end].trim();
199-
if !clause_text.is_empty() {
200-
clauses.push(clause_text.to_string());
263+
let clause_text = remainder[clause_start..clause_end].trim();
264+
if !clause_text.is_empty() {
265+
clauses.push(clause_text.to_string());
266+
}
267+
current_pos = clause_end;
201268
}
202-
current_pos = clause_end;
203-
} else {
204-
break;
205269
}
206270
}
207271

0 commit comments

Comments
 (0)