Skip to main content

cqlite_core/schema/
cql_parser.rs

1//! CQL Schema Parser
2//!
3//! This module provides parsing capabilities for CQL CREATE TABLE statements
4//! to extract table schema information including table names, column definitions,
5//! partition keys, clustering keys, and type information.
6
7use crate::cql::{CqlCreateTable, CqlDataType};
8use crate::error::{Error, Result};
9use crate::parser::types::CqlTypeId;
10use crate::schema::{ClusteringColumn, ClusteringOrder, Column, KeyColumn, TableSchema};
11use nom::{
12    branch::alt,
13    bytes::complete::{tag_no_case, take_while, take_while1},
14    character::complete::char,
15    combinator::{map, opt},
16    multi::{separated_list0, separated_list1},
17    sequence::{delimited, preceded, separated_pair, tuple},
18    IResult,
19};
20use serde_json;
21use std::collections::HashMap;
22
23/// CQL keyword parser - case insensitive
24fn keyword(s: &str) -> impl Fn(&str) -> IResult<&str, &str> + '_ {
25    move |input| tag_no_case(s)(input)
26}
27
28/// Parse whitespace and comments
29fn ws(input: &str) -> IResult<&str, &str> {
30    take_while(|c: char| c.is_whitespace())(input)
31}
32
33/// Parse mandatory whitespace
34fn ws1(input: &str) -> IResult<&str, &str> {
35    take_while1(|c: char| c.is_whitespace())(input)
36}
37
38/// Parse identifier (table name, column name, etc.)
39fn identifier(input: &str) -> IResult<&str, String> {
40    let (input, name) = alt((
41        // Quoted identifier
42        delimited(char('"'), take_while1(|c: char| c != '"'), char('"')),
43        // Unquoted identifier
44        take_while1(|c: char| c.is_alphanumeric() || c == '_'),
45    ))(input)?;
46
47    Ok((input, name.to_string()))
48}
49
50/// Parse a qualified table name (keyspace.table or just table)
51fn qualified_table_name(input: &str) -> IResult<&str, (Option<String>, String)> {
52    let (input, first) = identifier(input)?;
53    let (input, second) = opt(preceded(char('.'), identifier))(input)?;
54
55    match second {
56        Some(table) => Ok((input, (Some(first), table))),
57        None => Ok((input, (None, first))),
58    }
59}
60
61/// Maximum allowed CQL type nesting depth for the nom schema parser. Mirrors
62/// [`crate::parser::complex_types::ComplexTypeParser`] (`max_depth = 32`) and the
63/// [`crate::schema::CqlType`] string parser guard.
64///
65/// Without this bound, a hostile or malformed schema with pathological nesting
66/// (e.g. `frozen<` × 50_000) recurses in `parse_type_inner` until the thread
67/// stack overflows and, under `panic = "abort"`, aborts the whole process
68/// instead of returning an error (issue #1690). The guard is
69/// `depth > MAX_TYPE_NESTING_DEPTH`, where `depth` is 0 for the outermost type
70/// and increments once per nesting level, so a leaf reached at exactly depth 32
71/// (i.e. 32 levels of nesting) is the last allowed depth; a 33rd level returns a
72/// nom failure (surfaced as `Err`).
73const MAX_TYPE_NESTING_DEPTH: usize = 32;
74
75/// Parse CQL data type
76fn cql_type(input: &str) -> IResult<&str, String> {
77    // Handle complex types like list<text>, map<text, bigint>, frozen<set<uuid>>.
78    // `depth` bounds recursion so pathological nesting cannot overflow the stack.
79    fn parse_type_inner(input: &str, depth: usize) -> IResult<&str, String> {
80        if depth > MAX_TYPE_NESTING_DEPTH {
81            // Unrecoverable failure so the enclosing `alt` short-circuits instead
82            // of backtracking; surfaces to the caller as `Err` (never a panic).
83            return Err(nom::Err::Failure(nom::error::Error::new(
84                input,
85                nom::error::ErrorKind::TooLarge,
86            )));
87        }
88
89        let (input, base) = alt((
90            // Collection types
91            map(
92                tuple((
93                    alt((keyword("list"), keyword("set"))),
94                    char('<'),
95                    |i| parse_type_inner(i, depth + 1),
96                    char('>'),
97                )),
98                |(collection, _, inner, _)| format!("{}<{}>", collection, inner),
99            ),
100            // Map type
101            map(
102                tuple((
103                    keyword("map"),
104                    char('<'),
105                    |i| parse_type_inner(i, depth + 1),
106                    char(','),
107                    ws,
108                    |i| parse_type_inner(i, depth + 1),
109                    char('>'),
110                )),
111                |(_, _, key_type, _, _, value_type, _)| {
112                    format!("map<{}, {}>", key_type, value_type)
113                },
114            ),
115            // Tuple type
116            map(
117                tuple((
118                    keyword("tuple"),
119                    char('<'),
120                    separated_list1(tuple((ws, char(','), ws)), |i| {
121                        parse_type_inner(i, depth + 1)
122                    }),
123                    char('>'),
124                )),
125                |(_, _, types, _)| format!("tuple<{}>", types.join(", ")),
126            ),
127            // Frozen type
128            map(
129                tuple((
130                    keyword("frozen"),
131                    char('<'),
132                    |i| parse_type_inner(i, depth + 1),
133                    char('>'),
134                )),
135                |(_, _, inner, _)| format!("frozen<{}>", inner),
136            ),
137            // Simple types and UDTs
138            map(identifier, |name| name),
139        ))(input)?;
140
141        Ok((input, base))
142    }
143
144    let (input, _) = ws(input)?;
145    let (input, type_name) = parse_type_inner(input, 0)?;
146    let (input, _) = ws(input)?;
147
148    Ok((input, type_name))
149}
150
151/// Crate-internal fuzz shim (issue #1614) exposing the module-private nom
152/// [`cql_type`] parser so the in-crate `fuzz_support` block can drive it
153/// directly against arbitrary strings (proving the `MAX_TYPE_NESTING_DEPTH`
154/// guard returns `Err`, never a stack overflow). Not `pub` — reachable only
155/// within cqlite-core, so it widens no external surface.
156#[cfg(feature = "fuzz")]
157pub(crate) fn cql_type_fuzz(input: &str) -> IResult<&str, String> {
158    cql_type(input)
159}
160
161/// Parse column definition (with optional STATIC modifier and inline PRIMARY KEY)
162/// Returns (name, data_type, is_static)
163fn column_definition(input: &str) -> IResult<&str, (String, String, bool)> {
164    let (input, _) = ws(input)?;
165    let (input, name) = identifier(input)?;
166    let (input, _) = ws1(input)?;
167    let (input, data_type) = cql_type(input)?;
168    let (input, _) = ws(input)?;
169
170    // Check for STATIC modifier (Issue #255)
171    let (input, is_static) = opt(keyword("static"))(input)?;
172    let is_static = is_static.is_some();
173    let (input, _) = ws(input)?;
174
175    // Check for inline PRIMARY KEY (parse it but don't modify data_type)
176    // The PRIMARY KEY constraint is tracked via partition_keys/clustering_keys, not in data_type
177    let (input, _is_primary) = opt(tuple((keyword("primary"), ws1, keyword("key"))))(input)?;
178
179    // Return the data_type as-is (e.g., "uuid", not "uuid PRIMARY KEY")
180    // Issue #192: data_type must be a pure CQL type name for proper type matching
181    Ok((input, (name, data_type, is_static)))
182}
183
184/// Parse PRIMARY KEY specification
185fn primary_key_spec(input: &str) -> IResult<&str, (Vec<String>, Vec<String>)> {
186    let (input, _) = ws(input)?;
187    let (input, _) = keyword("primary")(input)?;
188    let (input, _) = ws1(input)?;
189    let (input, _) = keyword("key")(input)?;
190    let (input, _) = ws(input)?;
191    let (input, _) = char('(')(input)?;
192    let (input, _) = ws(input)?;
193
194    // Parse partition key (can be composite)
195    let (input, partition_keys) = alt((
196        // Composite partition key: ((col1, col2), clustering...)
197        map(
198            tuple((
199                char('('),
200                ws,
201                separated_list1(tuple((ws, char(','), ws)), identifier),
202                ws,
203                char(')'),
204            )),
205            |(_, _, keys, _, _)| keys,
206        ),
207        // Single partition key: (col1, clustering...)
208        map(identifier, |key| vec![key]),
209    ))(input)?;
210
211    let (input, _) = ws(input)?;
212
213    // Parse clustering keys (optional)
214    let (input, clustering_keys) = opt(preceded(
215        tuple((char(','), ws)),
216        separated_list1(tuple((ws, char(','), ws)), identifier),
217    ))(input)?;
218
219    let (input, _) = ws(input)?;
220    let (input, _) = char(')')(input)?;
221
222    Ok((input, (partition_keys, clustering_keys.unwrap_or_default())))
223}
224
225/// Parse a CQL map (`{ ... }`) or list (`[ ... ]`) literal value, capturing it
226/// verbatim (including the delimiters). Handles nested `{}`/`[]` and single
227/// quoted strings (which may themselves contain `{`, `}`, `[`, `]`). This lets
228/// `table_options` skip past complex option values such as
229/// `compression = {'class': 'LZ4Compressor'}` and continue collecting later
230/// options (Issue #852 review finding).
231fn bracketed_value(input: &str) -> IResult<&str, String> {
232    let bytes: &[u8] = input.as_bytes();
233    // Must start with an opening brace or bracket.
234    match bytes.first() {
235        Some(b'{') | Some(b'[') => {}
236        _ => {
237            return Err(nom::Err::Error(nom::error::Error::new(
238                input,
239                nom::error::ErrorKind::Char,
240            )))
241        }
242    }
243
244    // Combined nesting depth across both `{}` and `[]`. The literal is complete
245    // when depth returns to zero. Single-quoted strings are skipped so that
246    // brackets/braces inside string contents do not affect nesting.
247    let mut depth: usize = 0;
248    let mut in_string = false;
249    let mut i = 0usize;
250    while i < bytes.len() {
251        let c = bytes[i];
252        if in_string {
253            if c == b'\'' {
254                // CQL escapes a single quote by doubling it ('').
255                if bytes.get(i + 1) == Some(&b'\'') {
256                    i += 2;
257                    continue;
258                }
259                in_string = false;
260            }
261            i += 1;
262            continue;
263        }
264        match c {
265            b'\'' => in_string = true,
266            b'{' | b'[' => depth += 1,
267            b'}' | b']' => {
268                depth -= 1;
269                if depth == 0 {
270                    let end = i + 1;
271                    let (value, rest) = input.split_at(end);
272                    return Ok((rest, value.to_string()));
273                }
274            }
275            _ => {}
276        }
277        i += 1;
278    }
279
280    // Unterminated literal.
281    Err(nom::Err::Error(nom::error::Error::new(
282        input,
283        nom::error::ErrorKind::Char,
284    )))
285}
286
287/// Parse a single-quoted CQL string literal, returning the un-escaped contents.
288///
289/// CQL escapes a single quote inside a single-quoted string by doubling it
290/// (`''`). The naive `delimited(char('\''), take_while(|c| c != '\''), char('\''))`
291/// stops at the first inner quote, which corrupts later parsing (e.g.
292/// `comment = 'Bob''s table' AND ...` would leave `s table' AND ...`
293/// unconsumed and silently drop subsequent options). This parser walks the
294/// full literal, collapses each `''` to a single `'`, and consumes through the
295/// terminating quote (Issue #852 branch-review finding).
296fn single_quoted_string(input: &str) -> IResult<&str, String> {
297    let bytes = input.as_bytes();
298    if bytes.first() != Some(&b'\'') {
299        return Err(nom::Err::Error(nom::error::Error::new(
300            input,
301            nom::error::ErrorKind::Char,
302        )));
303    }
304
305    let mut value = String::new();
306    let mut i = 1usize; // skip opening quote
307    while i < bytes.len() {
308        if bytes[i] == b'\'' {
309            // Doubled single-quote ('') is an escaped literal quote.
310            if bytes.get(i + 1) == Some(&b'\'') {
311                value.push('\'');
312                i += 2;
313                continue;
314            }
315            // Terminating quote.
316            let rest = &input[i + 1..];
317            return Ok((rest, value));
318        }
319        // Push the next UTF-8 char starting at byte index `i`. Slicing on a
320        // char boundary is safe because the only multi-byte handling we do is
321        // ASCII single-quote detection above.
322        let ch_str = &input[i..];
323        if let Some(ch) = ch_str.chars().next() {
324            value.push(ch);
325            i += ch.len_utf8();
326        } else {
327            break;
328        }
329    }
330
331    // Unterminated string literal.
332    Err(nom::Err::Error(nom::error::Error::new(
333        input,
334        nom::error::ErrorKind::Char,
335    )))
336}
337
338/// Parse (and skip) a `CLUSTERING ORDER BY (col ASC|DESC, ...)` WITH item.
339///
340/// This item is not a `key = value` pair, so it must be matched explicitly;
341/// otherwise the generic option parser fails on it, `separated_list0` returns
342/// early, and any later `AND`-separated options (e.g. `bloom_filter_fp_chance`)
343/// are silently dropped (Issue #852 branch-review finding). The clustering
344/// order itself is captured for completeness so it is not lost, but the
345/// per-column ordering is already tracked via the schema's clustering columns.
346fn clustering_order_item(input: &str) -> IResult<&str, (String, String)> {
347    let (input, _) = keyword("clustering")(input)?;
348    let (input, _) = ws1(input)?;
349    let (input, _) = keyword("order")(input)?;
350    let (input, _) = ws1(input)?;
351    let (input, _) = keyword("by")(input)?;
352    let (input, _) = ws(input)?;
353
354    // Capture the parenthesized column-ordering list verbatim, stopping at the
355    // matching close paren. A naive `take_while(|c| c != ')')` truncates the body
356    // at the first `)`, but a quoted clustering identifier may itself contain a
357    // `)` (e.g. `CLUSTERING ORDER BY ("C)k" DESC)`). Treating that inner `)` as
358    // the clause terminator would drop the DESC direction and silently fall back
359    // to ASC (Issue #852 branch-review finding). The scan below skips over single-
360    // and double-quoted identifiers (honoring CQL's doubled-quote escaping) so a
361    // `)` inside a quoted name is not mistaken for the clause terminator.
362    let (input, _) = char('(')(input)?;
363    let (input, body) = clustering_order_body_scan(input)?;
364    let (input, _) = char(')')(input)?;
365
366    Ok((
367        input,
368        (
369            "clustering order by".to_string(),
370            format!("({})", body.trim()),
371        ),
372    ))
373}
374
375/// Scan the body of a `CLUSTERING ORDER BY (...)` clause up to (but not
376/// including) its matching close paren, treating `)` inside single- or
377/// double-quoted identifiers as literal content rather than the terminator.
378///
379/// CQL escapes a quote inside a quoted identifier/string by doubling it (`""`
380/// for double-quoted identifiers, `''` for single-quoted strings), so a doubled
381/// quote is consumed as content and does not close the quoted span.
382fn clustering_order_body_scan(input: &str) -> IResult<&str, &str> {
383    let bytes = input.as_bytes();
384    let mut i = 0usize;
385    // The active quote character when inside a quoted span (`'` or `"`).
386    let mut quote: Option<u8> = None;
387    while i < bytes.len() {
388        let c = bytes[i];
389        match quote {
390            Some(q) => {
391                if c == q {
392                    // Doubled quote is an escaped literal quote, not the close.
393                    if bytes.get(i + 1) == Some(&q) {
394                        i += 2;
395                        continue;
396                    }
397                    quote = None;
398                }
399            }
400            None => match c {
401                b'\'' | b'"' => quote = Some(c),
402                b')' => {
403                    let (body, rest) = input.split_at(i);
404                    return Ok((rest, body));
405                }
406                _ => {}
407            },
408        }
409        i += 1;
410    }
411
412    // No (unquoted) close paren found — unterminated clause body.
413    Err(nom::Err::Error(nom::error::Error::new(
414        input,
415        nom::error::ErrorKind::Char,
416    )))
417}
418
419/// Parse the body of a `CLUSTERING ORDER BY (...)` clause (the text captured
420/// between the parentheses, e.g. `ck DESC` or `c1 ASC, c2 DESC`) into a map of
421/// column name -> [`ClusteringOrder`]. Each entry's column name is parsed with
422/// the same [`identifier`] parser used for column and primary-key names, so
423/// quoted identifiers (e.g. `"Ck"`) have their surrounding quotes stripped and
424/// therefore match the clustering column names stored in the schema. Entries
425/// with an unrecognized/absent direction default to ASC (per
426/// [`ClusteringOrder::from`]).
427fn parse_clustering_order_body(body: &str) -> HashMap<String, ClusteringOrder> {
428    // Strip the surrounding parens that `clustering_order_item` re-added, if present.
429    let inner = body.trim().trim_start_matches('(').trim_end_matches(')');
430
431    // Parse the comma-separated list with nom rather than `inner.split(',')`,
432    // because a quoted clustering identifier may itself contain a comma
433    // (e.g. `"C,k" DESC`). Splitting on raw commas would break such a name
434    // into two entries and silently leave the column at its default ASC
435    // (Issue #852 branch-review finding). The shared `identifier` parser
436    // already handles quoted identifiers (including embedded commas), so a
437    // `separated_list0` over `clustering_order_entry` parses each entry
438    // correctly while keeping behavior identical for all normal cases.
439    let entries = separated_list0(tuple((ws, char(','), ws)), clustering_order_entry);
440    match delimited(ws, entries, ws)(inner) {
441        Ok((_, items)) => items.into_iter().collect(),
442        Err(_) => HashMap::new(),
443    }
444}
445
446/// Parse a single `CLUSTERING ORDER BY` entry: a column identifier followed by
447/// an optional `ASC`/`DESC` direction (defaulting to ASC). The column name is
448/// parsed with the canonical [`identifier`] parser so quoted names are unquoted
449/// exactly as the schema's clustering columns were, and quoted names containing
450/// a comma are kept intact.
451fn clustering_order_entry(input: &str) -> IResult<&str, (String, ClusteringOrder)> {
452    let (input, col) = identifier(input)?;
453    // Optional whitespace + direction keyword. Absent/unrecognized → ASC.
454    let (input, direction) = opt(preceded(ws1, alt((keyword("asc"), keyword("desc")))))(input)?;
455    let order = direction
456        .map(crate::schema::ClusteringOrder::from)
457        .unwrap_or(crate::schema::ClusteringOrder::Asc);
458    Ok((input, (col, order)))
459}
460
461/// Parse table options (WITH clause)
462fn table_options(input: &str) -> IResult<&str, HashMap<String, String>> {
463    let (input, _) = ws(input)?;
464    let (input, _) = keyword("with")(input)?;
465    let (input, _) = ws1(input)?;
466
467    // Parse option = value pairs
468    let option_pair = map(
469        separated_pair(
470            identifier,
471            tuple((ws, char('='), ws)),
472            alt((
473                // Map literal: {'class': 'LZ4Compressor', ...} (possibly nested).
474                // Captured verbatim so option collection can continue past it.
475                bracketed_value,
476                // String value (handles doubled-single-quote `''` escaping).
477                single_quoted_string,
478                // Numeric or identifier value
479                map(
480                    take_while1(|c: char| c.is_alphanumeric() || c == '_' || c == '.'),
481                    |s: &str| s.to_string(),
482                ),
483            )),
484        ),
485        |(key, value)| (key, value),
486    );
487
488    // A WITH item is either `CLUSTERING ORDER BY (...)` or a `key = value` pair.
489    // Both must be matched so a non-`key=value` item never stops collection and
490    // silently drops later `AND`-separated options (Issue #852).
491    let with_item = alt((clustering_order_item, option_pair));
492
493    let (input, options) = separated_list0(tuple((ws, keyword("and"), ws)), with_item)(input)?;
494
495    Ok((input, options.into_iter().collect()))
496}
497
498/// Split CQL file content into individual statements (semicolon-delimited)
499/// Respects string literals and comments to avoid splitting inside them
500pub fn split_cql_statements(input: &str) -> Vec<String> {
501    let mut statements = Vec::new();
502    let mut current_statement = String::new();
503    let mut in_string = false;
504    let mut in_single_line_comment = false;
505    let mut in_multi_line_comment = false;
506    let mut escape_next = false;
507
508    let chars: Vec<char> = input.chars().collect();
509    let mut i = 0;
510
511    while i < chars.len() {
512        let c = chars[i];
513
514        // Handle escape sequences in strings
515        if escape_next {
516            current_statement.push(c);
517            escape_next = false;
518            i += 1;
519            continue;
520        }
521
522        // Check for multi-line comment start
523        if !in_string
524            && !in_single_line_comment
525            && !in_multi_line_comment
526            && i + 1 < chars.len()
527            && c == '/'
528            && chars[i + 1] == '*'
529        {
530            in_multi_line_comment = true;
531            current_statement.push(c);
532            current_statement.push(chars[i + 1]);
533            i += 2;
534            continue;
535        }
536
537        // Check for multi-line comment end
538        if in_multi_line_comment && i + 1 < chars.len() && c == '*' && chars[i + 1] == '/' {
539            in_multi_line_comment = false;
540            current_statement.push(c);
541            current_statement.push(chars[i + 1]);
542            i += 2;
543            continue;
544        }
545
546        // Check for single-line comment start
547        if !in_string
548            && !in_multi_line_comment
549            && !in_single_line_comment
550            && i + 1 < chars.len()
551            && c == '-'
552            && chars[i + 1] == '-'
553        {
554            in_single_line_comment = true;
555            current_statement.push(c);
556            current_statement.push(chars[i + 1]);
557            i += 2;
558            continue;
559        }
560
561        // Handle newline (ends single-line comment)
562        if c == '\n' {
563            in_single_line_comment = false;
564            current_statement.push(c);
565            i += 1;
566            continue;
567        }
568
569        // Skip processing if inside a comment
570        if in_single_line_comment || in_multi_line_comment {
571            current_statement.push(c);
572            i += 1;
573            continue;
574        }
575
576        // Handle string literals (single quotes)
577        if c == '\'' {
578            in_string = !in_string;
579            current_statement.push(c);
580            i += 1;
581            continue;
582        }
583
584        // Handle escape in string
585        if in_string && c == '\\' {
586            escape_next = true;
587            current_statement.push(c);
588            i += 1;
589            continue;
590        }
591
592        // Handle semicolon (statement separator)
593        if !in_string && c == ';' {
594            let trimmed = current_statement.trim();
595            if !trimmed.is_empty() {
596                statements.push(trimmed.to_string());
597            }
598            current_statement.clear();
599            i += 1;
600            continue;
601        }
602
603        current_statement.push(c);
604        i += 1;
605    }
606
607    // Add final statement if non-empty
608    let trimmed = current_statement.trim();
609    if !trimmed.is_empty() {
610        statements.push(trimmed.to_string());
611    }
612
613    // Clean up statements: remove leading/trailing comment-only lines
614    statements
615        .into_iter()
616        .map(|stmt| strip_leading_trailing_comments(&stmt))
617        .filter(|s| !s.is_empty())
618        .collect()
619}
620
621/// Strip leading and trailing comment-only lines from a statement
622fn strip_leading_trailing_comments(stmt: &str) -> String {
623    let lines: Vec<&str> = stmt.lines().collect();
624    let mut start = 0;
625    let mut end = lines.len();
626
627    // Find first non-comment line
628    for (i, line) in lines.iter().enumerate() {
629        let trimmed = line.trim();
630        if !trimmed.is_empty() && !trimmed.starts_with("--") && !trimmed.starts_with("/*") {
631            start = i;
632            break;
633        }
634    }
635
636    // Find last non-comment line
637    for (i, line) in lines.iter().enumerate().rev() {
638        let trimmed = line.trim();
639        if !trimmed.is_empty() && !trimmed.starts_with("--") && !trimmed.ends_with("*/") {
640            end = i + 1;
641            break;
642        }
643    }
644
645    if start >= end {
646        return String::new();
647    }
648
649    lines[start..end].join("\n")
650}
651
652#[cfg(test)]
653mod tests_splitter {
654    use super::*;
655
656    #[test]
657    fn test_split_with_comments() {
658        let cql = r#"
659        -- Comment
660        CREATE TYPE test.udt (field text);
661
662        /* Multi-line
663           comment */
664        CREATE TABLE test.tbl (id int PRIMARY KEY);
665        "#;
666
667        let stmts = split_cql_statements(cql);
668        assert_eq!(stmts.len(), 2);
669        assert!(stmts[0].contains("CREATE TYPE"));
670        assert!(!stmts[0].contains("--"));
671        assert!(stmts[1].contains("CREATE TABLE"));
672    }
673}
674
675/// Statement type classification
676#[derive(Debug, Clone, PartialEq)]
677pub enum StatementType {
678    CreateTable,
679    CreateType,
680    Other(String),
681}
682
683/// Classify a CQL statement by type
684pub fn classify_statement(statement: &str) -> StatementType {
685    let normalized = statement.trim().to_lowercase();
686
687    // Remove leading whitespace and comments
688    let normalized = normalized
689        .lines()
690        .map(|line| {
691            // Remove single-line comments
692            if let Some(pos) = line.find("--") {
693                &line[..pos]
694            } else {
695                line
696            }
697        })
698        .collect::<Vec<&str>>()
699        .join(" ");
700
701    let normalized = normalized.trim();
702
703    if normalized.starts_with("create table")
704        || normalized.starts_with("create table if not exists")
705    {
706        StatementType::CreateTable
707    } else if normalized.starts_with("create type")
708        || normalized.starts_with("create type if not exists")
709    {
710        StatementType::CreateType
711    } else {
712        StatementType::Other(
713            normalized
714                .split_whitespace()
715                .next()
716                .unwrap_or("unknown")
717                .to_string(),
718        )
719    }
720}
721
722/// Parse CREATE TYPE statement to extract UDT definition
723#[allow(clippy::type_complexity)]
724pub fn parse_create_type(
725    input: &str,
726) -> IResult<&str, (String, Option<String>, Vec<(String, String)>)> {
727    let (input, _) = ws(input)?;
728    let (input, _) = keyword("create")(input)?;
729    let (input, _) = ws1(input)?;
730    let (input, _) = keyword("type")(input)?;
731    let (input, _) = ws1(input)?;
732
733    // Optional IF NOT EXISTS
734    let (input, _) = opt(tuple((
735        keyword("if"),
736        ws1,
737        keyword("not"),
738        ws1,
739        keyword("exists"),
740        ws1,
741    )))(input)?;
742
743    // Type name (qualified or unqualified)
744    let (input, (keyspace, type_name)) = qualified_table_name(input)?;
745
746    let (input, _) = ws(input)?;
747    let (input, _) = char('(')(input)?;
748    let (input, _) = ws(input)?;
749
750    // Parse field definitions
751    let (input, fields) = separated_list1(
752        tuple((ws, char(','), ws)),
753        map(
754            tuple((identifier, ws1, cql_type)),
755            |(name, _, field_type)| (name, field_type),
756        ),
757    )(input)?;
758
759    let (input, _) = ws(input)?;
760    let (input, _) = char(')')(input)?;
761
762    Ok((input, (type_name, keyspace, fields)))
763}
764
765/// Parse a complete CREATE TABLE statement
766pub fn parse_create_table(input: &str) -> IResult<&str, TableSchema> {
767    let (input, _) = ws(input)?;
768    let (input, _) = keyword("create")(input)?;
769    let (input, _) = ws1(input)?;
770    let (input, _) = keyword("table")(input)?;
771    let (input, _) = ws1(input)?;
772
773    // Optional IF NOT EXISTS
774    let (input, _) = opt(tuple((
775        keyword("if"),
776        ws1,
777        keyword("not"),
778        ws1,
779        keyword("exists"),
780        ws1,
781    )))(input)?;
782
783    // Table name (qualified or unqualified)
784    let (input, (keyspace, table_name)) = qualified_table_name(input)?;
785
786    let (input, _) = ws(input)?;
787    let (input, _) = char('(')(input)?;
788    let (input, _) = ws(input)?;
789
790    // Parse column definitions and constraints
791    // Columns are stored as (name, data_type, is_static)
792    let mut columns: Vec<(String, String, bool)> = Vec::new();
793    let mut partition_keys = Vec::new();
794    let mut clustering_keys = Vec::new();
795    let mut primary_key_found = false;
796
797    let (input, items) = separated_list1(
798        tuple((ws, char(','), ws)),
799        alt((
800            // Primary key constraint - returns 3-tuple with is_static=false (unused)
801            map(primary_key_spec, |keys| {
802                (
803                    "PRIMARY_KEY".to_string(),
804                    serde_json::to_string(&keys).unwrap_or_default(),
805                    false, // is_static not applicable for PRIMARY KEY constraint
806                )
807            }),
808            // Column definition - returns (name, data_type, is_static)
809            column_definition,
810        )),
811    )(input)?;
812
813    // Process parsed items
814    for (name, value, is_static) in items {
815        if name == "PRIMARY_KEY" {
816            // Parse the JSON-encoded key specification
817            if let Ok(keys_tuple) = serde_json::from_str::<(Vec<String>, Vec<String>)>(&value) {
818                partition_keys = keys_tuple.0;
819                clustering_keys = keys_tuple.1;
820                primary_key_found = true;
821            }
822            continue;
823        }
824        columns.push((name, value, is_static));
825    }
826
827    let (input, _) = ws(input)?;
828    let (input, _) = char(')')(input)?;
829
830    // Parse optional WITH clause. The parsed key=value pairs (e.g.
831    // `bloom_filter_fp_chance = 1.0`) are preserved on the schema's `comments`
832    // bag so the writer can honor them (Issue #852). Keys are normalized to
833    // lowercase since CQL option names are case-insensitive.
834    let (input, with_options) = opt(table_options)(input)?;
835
836    // Extract per-column clustering order from the captured `CLUSTERING ORDER BY
837    // (...)` option so it can be applied to each clustering column below. Columns
838    // not named in the clause default to ASC (#849/#852 branch-review).
839    let clustering_order_map: HashMap<String, ClusteringOrder> = with_options
840        .as_ref()
841        .and_then(|opts| opts.get("clustering order by"))
842        .map(|body| parse_clustering_order_body(body))
843        .unwrap_or_default();
844
845    // If no primary key was found in constraints, look for inline PRIMARY KEY or use first column
846    if !primary_key_found && !columns.is_empty() {
847        // Check if any column has "PRIMARY KEY" in its type (inline definition)
848        let mut found_inline = false;
849        for (col_name, col_type, _is_static) in &columns {
850            if col_type.to_lowercase().contains("primary key") {
851                partition_keys.push(col_name.clone());
852                found_inline = true;
853                break;
854            }
855        }
856
857        // If still no primary key found, assume first column is partition key
858        if !found_inline {
859            partition_keys.push(columns[0].0.clone());
860        }
861    }
862
863    // Build schema
864    let schema = TableSchema {
865        keyspace: keyspace.unwrap_or_else(|| "default".to_string()),
866        table: table_name,
867        partition_keys: partition_keys
868            .into_iter()
869            .enumerate()
870            .map(|(pos, name)| {
871                let data_type = columns
872                    .iter()
873                    .find(|(col_name, _, _)| col_name == &name)
874                    .map(|(_, dt, _)| dt.clone())
875                    .unwrap_or_else(|| "text".to_string());
876
877                KeyColumn {
878                    name,
879                    data_type,
880                    position: pos,
881                }
882            })
883            .collect(),
884        clustering_keys: clustering_keys
885            .into_iter()
886            .enumerate()
887            .map(|(pos, name)| {
888                let data_type = columns
889                    .iter()
890                    .find(|(col_name, _, _)| col_name == &name)
891                    .map(|(_, dt, _)| dt.clone())
892                    .unwrap_or_else(|| "text".to_string());
893
894                let order = clustering_order_map
895                    .get(&name)
896                    .cloned()
897                    .unwrap_or(crate::schema::ClusteringOrder::Asc);
898
899                ClusteringColumn {
900                    name,
901                    data_type,
902                    position: pos,
903                    order,
904                }
905            })
906            .collect(),
907        columns: columns
908            .into_iter()
909            .map(|(name, data_type_with_constraints, is_static)| {
910                // Remove PRIMARY KEY constraint from data type
911                let data_type = if data_type_with_constraints
912                    .to_lowercase()
913                    .contains("primary key")
914                {
915                    data_type_with_constraints
916                        .to_lowercase()
917                        .replace("primary key", "")
918                        .trim()
919                        .to_string()
920                } else {
921                    data_type_with_constraints
922                };
923
924                Column {
925                    name,
926                    data_type,
927                    nullable: true,
928                    default: None,
929                    is_static,
930                }
931            })
932            .collect(),
933        comments: with_options
934            .unwrap_or_default()
935            .into_iter()
936            .map(|(k, v)| (k.to_lowercase(), v))
937            .collect(),
938        dropped_columns: std::collections::HashMap::new(),
939    };
940
941    Ok((input, schema))
942}
943
944/// Convert CQL type string to internal CqlTypeId
945pub fn cql_type_to_type_id(cql_type: &str) -> Result<CqlTypeId> {
946    cql_type_to_type_id_with_depth(cql_type, 0)
947}
948
949/// Recursive body of [`cql_type_to_type_id`], threading the current nesting
950/// `depth` so the `frozen<...>` unwrap is bounded at [`MAX_TYPE_NESTING_DEPTH`]
951/// (issue #1690). Without this bound a pathological `frozen<` × N string reaching
952/// this public conversion path (via `SchemaManager::cql_type_to_internal`) would
953/// recurse until the stack overflows and, under `panic = "abort"`, abort the
954/// whole process instead of returning an error. `depth` is 0 at the top level and
955/// increments by one per nesting level.
956fn cql_type_to_type_id_with_depth(cql_type: &str, depth: usize) -> Result<CqlTypeId> {
957    if depth > MAX_TYPE_NESTING_DEPTH {
958        return Err(Error::schema(format!(
959            "type nesting too deep (max {})",
960            MAX_TYPE_NESTING_DEPTH
961        )));
962    }
963
964    let type_lower = cql_type.trim().to_lowercase();
965
966    // Handle collection types
967    if type_lower.starts_with("list<") {
968        return Ok(CqlTypeId::List);
969    }
970    if type_lower.starts_with("set<") {
971        return Ok(CqlTypeId::Set);
972    }
973    if type_lower.starts_with("map<") {
974        return Ok(CqlTypeId::Map);
975    }
976    if type_lower.starts_with("tuple<") {
977        return Ok(CqlTypeId::Tuple);
978    }
979    if type_lower.starts_with("frozen<") {
980        // Extract inner type from frozen<type>
981        if let Some(inner_start) = type_lower.find('<') {
982            if let Some(inner_end) = type_lower.rfind('>') {
983                let inner_type = &type_lower[inner_start + 1..inner_end];
984                return cql_type_to_type_id_with_depth(inner_type, depth + 1);
985            }
986        }
987    }
988
989    // Handle primitive types
990    match type_lower.as_str() {
991        "ascii" => Ok(CqlTypeId::Ascii),
992        "bigint" | "long" => Ok(CqlTypeId::BigInt),
993        "blob" => Ok(CqlTypeId::Blob),
994        "boolean" | "bool" => Ok(CqlTypeId::Boolean),
995        "counter" => Ok(CqlTypeId::Counter),
996        "decimal" => Ok(CqlTypeId::Decimal),
997        "double" => Ok(CqlTypeId::Double),
998        "float" => Ok(CqlTypeId::Float),
999        "int" | "integer" => Ok(CqlTypeId::Int),
1000        "timestamp" => Ok(CqlTypeId::Timestamp),
1001        "uuid" => Ok(CqlTypeId::Uuid),
1002        "varchar" | "text" => Ok(CqlTypeId::Varchar),
1003        "varint" => Ok(CqlTypeId::Varint),
1004        "timeuuid" => Ok(CqlTypeId::Timeuuid),
1005        "inet" => Ok(CqlTypeId::Inet),
1006        "date" => Ok(CqlTypeId::Date),
1007        "time" => Ok(CqlTypeId::Time),
1008        "smallint" => Ok(CqlTypeId::Smallint),
1009        "tinyint" => Ok(CqlTypeId::Tinyint),
1010        "duration" => Ok(CqlTypeId::Duration),
1011        _ => {
1012            // Assume it's a UDT if not a known primitive type
1013            Ok(CqlTypeId::Udt)
1014        }
1015    }
1016}
1017
1018/// Extract table name from CQL CREATE TABLE statement
1019pub fn extract_table_name(cql: &str) -> Result<(Option<String>, String)> {
1020    match parse_create_table(cql) {
1021        Ok((_, schema)) => {
1022            let keyspace = if schema.keyspace == "default" {
1023                None
1024            } else {
1025                Some(schema.keyspace)
1026            };
1027            Ok((keyspace, schema.table))
1028        }
1029        Err(_) => {
1030            // Fallback: simple regex-like extraction
1031            let cql_lower = cql.to_lowercase();
1032            if let Some(table_start) = cql_lower.find("create table") {
1033                let after_table = &cql[table_start + 12..];
1034                if let Some(if_not_exists) = after_table.find("if not exists") {
1035                    let after_if = &after_table[if_not_exists + 13..];
1036                    return extract_simple_table_name(after_if);
1037                }
1038                return extract_simple_table_name(after_table);
1039            }
1040
1041            Err(Error::schema(
1042                "Failed to extract table name from CQL".to_string(),
1043            ))
1044        }
1045    }
1046}
1047
1048/// Simple table name extraction fallback
1049fn extract_simple_table_name(input: &str) -> Result<(Option<String>, String)> {
1050    let trimmed = input.trim();
1051    let words: Vec<&str> = trimmed.split_whitespace().collect();
1052
1053    if words.is_empty() {
1054        return Err(Error::schema("No table name found".to_string()));
1055    }
1056
1057    let table_name = words[0];
1058
1059    // Handle qualified names
1060    if let Some(dot_pos) = table_name.find('.') {
1061        let keyspace = &table_name[..dot_pos];
1062        let table = &table_name[dot_pos + 1..];
1063        Ok((Some(keyspace.to_string()), table.to_string()))
1064    } else {
1065        Ok((None, table_name.to_string()))
1066    }
1067}
1068
1069/// Check if a table name matches the given pattern
1070pub fn table_name_matches(
1071    schema_keyspace: &Option<String>,
1072    schema_table: &str,
1073    target_keyspace: &Option<String>,
1074    target_table: &str,
1075) -> bool {
1076    // Table name must match exactly
1077    if schema_table != target_table {
1078        return false;
1079    }
1080
1081    // If target has no keyspace, match any keyspace
1082    if target_keyspace.is_none() {
1083        return true;
1084    }
1085
1086    // If both have keyspaces, they must match
1087    schema_keyspace == target_keyspace
1088}
1089
1090/// Parse CQL schema and extract metadata for SSTable reading
1091pub fn parse_cql_schema(cql: &str) -> Result<TableSchema> {
1092    match parse_create_table(cql) {
1093        Ok((_, schema)) => {
1094            // Validate the parsed schema
1095            schema.validate()?;
1096            Ok(schema)
1097        }
1098        Err(nom::Err::Error(e) | nom::Err::Failure(e)) => Err(Error::schema(format!(
1099            "Failed to parse CQL schema: {:?}",
1100            e
1101        ))),
1102        Err(nom::Err::Incomplete(_)) => Err(Error::schema("Incomplete CQL schema".to_string())),
1103    }
1104}
1105
1106/// Parse CQL schema using the visitor pattern (preferred method for new code)
1107///
1108/// This function demonstrates how to use the visitor pattern for AST-based parsing.
1109/// It provides better error handling, validation, and is more maintainable than
1110/// the legacy nom-based parser.
1111pub fn parse_cql_schema_with_visitor(cql: &str) -> Result<TableSchema> {
1112    // Note: This is a demonstration function. In a complete implementation,
1113    // you would first parse the CQL into an AST using the nom parser,
1114    // then use the visitor pattern to convert it to TableSchema.
1115    //
1116    // For now, this uses the existing nom parser for demonstration purposes.
1117
1118    use crate::cql::traits::CqlVisitor;
1119    use crate::cql::visitor::SchemaBuilderVisitor;
1120    use crate::cql::CqlStatement;
1121
1122    // Parse using the existing nom parser to get the TableSchema
1123    let schema = parse_cql_schema(cql)?;
1124
1125    // Demonstrate the visitor pattern by reconstructing the AST and then using the visitor
1126    // (In real usage, you would have the AST from a parser)
1127    let ast = table_schema_to_ast(&schema)?;
1128    let statement = CqlStatement::CreateTable(ast);
1129
1130    // Use the visitor to convert AST back to TableSchema
1131    let mut visitor = SchemaBuilderVisitor;
1132    visitor.visit_statement(&statement)
1133}
1134
1135/// Helper function to convert TableSchema to AST for demonstration
1136/// (In real usage, the AST would come directly from a parser)
1137fn table_schema_to_ast(schema: &TableSchema) -> Result<CqlCreateTable> {
1138    use crate::cql::{
1139        CqlColumnDef, CqlCreateTable, CqlIdentifier, CqlPrimaryKey, CqlTable, CqlTableOptions,
1140    };
1141
1142    // Convert table reference
1143    let table = if schema.keyspace == "default" {
1144        CqlTable::new(&schema.table)
1145    } else {
1146        CqlTable::with_keyspace(&schema.keyspace, &schema.table)
1147    };
1148
1149    // Convert columns
1150    let columns: Result<Vec<CqlColumnDef>> = schema
1151        .columns
1152        .iter()
1153        .map(|col| {
1154            Ok(CqlColumnDef {
1155                name: CqlIdentifier::new(&col.name),
1156                data_type: string_to_cql_data_type(&col.data_type)?,
1157                is_static: col.is_static,
1158            })
1159        })
1160        .collect();
1161
1162    let columns = columns?;
1163
1164    // Convert primary key
1165    let partition_key: Vec<CqlIdentifier> = schema
1166        .partition_keys
1167        .iter()
1168        .map(|pk| CqlIdentifier::new(&pk.name))
1169        .collect();
1170
1171    let clustering_key: Vec<CqlIdentifier> = schema
1172        .clustering_keys
1173        .iter()
1174        .map(|ck| CqlIdentifier::new(&ck.name))
1175        .collect();
1176
1177    Ok(CqlCreateTable {
1178        if_not_exists: false,
1179        table,
1180        columns,
1181        primary_key: CqlPrimaryKey {
1182            partition_key,
1183            clustering_key,
1184        },
1185        options: CqlTableOptions {
1186            options: HashMap::new(),
1187        },
1188    })
1189}
1190
1191/// Convert string type to CqlDataType (simplified version)
1192fn string_to_cql_data_type(type_str: &str) -> Result<CqlDataType> {
1193    use crate::cql::{CqlDataType, CqlIdentifier};
1194
1195    let type_lower = type_str.trim().to_lowercase();
1196
1197    // Handle collection types
1198    if type_lower.starts_with("list<") && type_lower.ends_with('>') {
1199        let inner_type_str = &type_lower[5..type_lower.len() - 1];
1200        let inner_type = string_to_cql_data_type(inner_type_str)?;
1201        return Ok(CqlDataType::List(Box::new(inner_type)));
1202    }
1203
1204    if type_lower.starts_with("set<") && type_lower.ends_with('>') {
1205        let inner_type_str = &type_lower[4..type_lower.len() - 1];
1206        let inner_type = string_to_cql_data_type(inner_type_str)?;
1207        return Ok(CqlDataType::Set(Box::new(inner_type)));
1208    }
1209
1210    if type_lower.starts_with("map<") && type_lower.ends_with('>') {
1211        let inner = &type_lower[4..type_lower.len() - 1];
1212        if let Some(comma_pos) = inner.find(',') {
1213            let key_type_str = inner[..comma_pos].trim();
1214            let value_type_str = inner[comma_pos + 1..].trim();
1215            let key_type = string_to_cql_data_type(key_type_str)?;
1216            let value_type = string_to_cql_data_type(value_type_str)?;
1217            return Ok(CqlDataType::Map(Box::new(key_type), Box::new(value_type)));
1218        }
1219    }
1220
1221    if type_lower.starts_with("frozen<") && type_lower.ends_with('>') {
1222        let inner_type_str = &type_lower[7..type_lower.len() - 1];
1223        let inner_type = string_to_cql_data_type(inner_type_str)?;
1224        return Ok(CqlDataType::Frozen(Box::new(inner_type)));
1225    }
1226
1227    // Handle primitive types
1228    match type_lower.as_str() {
1229        "boolean" | "bool" => Ok(CqlDataType::Boolean),
1230        "tinyint" => Ok(CqlDataType::TinyInt),
1231        "smallint" => Ok(CqlDataType::SmallInt),
1232        "int" => Ok(CqlDataType::Int),
1233        "bigint" | "long" => Ok(CqlDataType::BigInt),
1234        "varint" => Ok(CqlDataType::Varint),
1235        "decimal" => Ok(CqlDataType::Decimal),
1236        "float" => Ok(CqlDataType::Float),
1237        "double" => Ok(CqlDataType::Double),
1238        "text" | "varchar" => Ok(CqlDataType::Text),
1239        "ascii" => Ok(CqlDataType::Ascii),
1240        "blob" => Ok(CqlDataType::Blob),
1241        "timestamp" => Ok(CqlDataType::Timestamp),
1242        "date" => Ok(CqlDataType::Date),
1243        "time" => Ok(CqlDataType::Time),
1244        "uuid" => Ok(CqlDataType::Uuid),
1245        "timeuuid" => Ok(CqlDataType::TimeUuid),
1246        "inet" => Ok(CqlDataType::Inet),
1247        "duration" => Ok(CqlDataType::Duration),
1248        "counter" => Ok(CqlDataType::Counter),
1249        _ => {
1250            // Assume it's a UDT
1251            Ok(CqlDataType::Udt(CqlIdentifier::new(type_str)))
1252        }
1253    }
1254}
1255
1256#[cfg(test)]
1257mod tests {
1258    use super::*;
1259
1260    #[test]
1261    fn test_simple_table_parsing() {
1262        let cql = r#"
1263            CREATE TABLE users (
1264                id uuid PRIMARY KEY,
1265                name text,
1266                email text
1267            )
1268        "#;
1269
1270        let schema = parse_cql_schema(cql).unwrap();
1271        assert_eq!(schema.table, "users");
1272        assert_eq!(schema.columns.len(), 3);
1273        assert_eq!(schema.partition_keys.len(), 1);
1274        assert_eq!(schema.partition_keys[0].name, "id");
1275    }
1276
1277    #[test]
1278    fn test_qualified_table_name() {
1279        let cql = r#"
1280            CREATE TABLE myapp.users (
1281                id bigint PRIMARY KEY,
1282                name text
1283            )
1284        "#;
1285
1286        let schema = parse_cql_schema(cql).unwrap();
1287        assert_eq!(schema.keyspace, "myapp");
1288        assert_eq!(schema.table, "users");
1289    }
1290
1291    #[test]
1292    fn test_complex_types() {
1293        let cql = r#"
1294            CREATE TABLE complex_table (
1295                id uuid PRIMARY KEY,
1296                tags set<text>,
1297                metadata map<text, text>,
1298                coordinates list<double>
1299            )
1300        "#;
1301
1302        let schema = parse_cql_schema(cql).unwrap();
1303        assert_eq!(schema.columns.len(), 4);
1304
1305        let tags_col = schema.columns.iter().find(|c| c.name == "tags").unwrap();
1306        assert_eq!(tags_col.data_type, "set<text>");
1307
1308        let metadata_col = schema
1309            .columns
1310            .iter()
1311            .find(|c| c.name == "metadata")
1312            .unwrap();
1313        assert_eq!(metadata_col.data_type, "map<text, text>");
1314    }
1315
1316    #[test]
1317    fn test_table_name_extraction() {
1318        let cql = "CREATE TABLE IF NOT EXISTS myapp.users (id uuid PRIMARY KEY)";
1319        let (keyspace, table) = extract_table_name(cql).unwrap();
1320        assert_eq!(keyspace, Some("myapp".to_string()));
1321        assert_eq!(table, "users");
1322    }
1323
1324    #[test]
1325    fn test_cql_type_conversion() {
1326        assert_eq!(cql_type_to_type_id("text").unwrap(), CqlTypeId::Varchar);
1327        assert_eq!(cql_type_to_type_id("bigint").unwrap(), CqlTypeId::BigInt);
1328        assert_eq!(cql_type_to_type_id("list<text>").unwrap(), CqlTypeId::List);
1329        assert_eq!(
1330            cql_type_to_type_id("frozen<set<uuid>>").unwrap(),
1331            CqlTypeId::Set
1332        );
1333    }
1334
1335    #[test]
1336    fn test_table_name_matching() {
1337        // Exact match
1338        assert!(table_name_matches(
1339            &Some("ks".to_string()),
1340            "users",
1341            &Some("ks".to_string()),
1342            "users"
1343        ));
1344
1345        // Match with wildcard keyspace
1346        assert!(table_name_matches(
1347            &Some("ks".to_string()),
1348            "users",
1349            &None,
1350            "users"
1351        ));
1352
1353        // No match - different table
1354        assert!(!table_name_matches(
1355            &Some("ks".to_string()),
1356            "users",
1357            &Some("ks".to_string()),
1358            "orders"
1359        ));
1360
1361        // No match - different keyspace
1362        assert!(!table_name_matches(
1363            &Some("ks1".to_string()),
1364            "users",
1365            &Some("ks2".to_string()),
1366            "users"
1367        ));
1368    }
1369
1370    #[test]
1371    fn test_composite_primary_key() {
1372        let cql = r#"
1373            CREATE TABLE time_series (
1374                partition_key text,
1375                clustering_key timestamp,
1376                value double,
1377                PRIMARY KEY (partition_key, clustering_key)
1378            )
1379        "#;
1380
1381        let schema = parse_cql_schema(cql).unwrap();
1382        assert_eq!(schema.partition_keys.len(), 1);
1383        assert_eq!(schema.clustering_keys.len(), 1);
1384
1385        assert_eq!(schema.partition_keys[0].name, "partition_key");
1386        assert_eq!(schema.clustering_keys[0].name, "clustering_key");
1387    }
1388
1389    #[test]
1390    fn test_frozen_collections() {
1391        let cql = r#"
1392            CREATE TABLE frozen_test (
1393                id uuid PRIMARY KEY,
1394                frozen_set frozen<set<text>>,
1395                frozen_map frozen<map<text, bigint>>,
1396                nested_frozen frozen<list<frozen<set<uuid>>>>
1397            )
1398        "#;
1399
1400        let schema = parse_cql_schema(cql).unwrap();
1401
1402        let frozen_set = schema
1403            .columns
1404            .iter()
1405            .find(|c| c.name == "frozen_set")
1406            .unwrap();
1407        assert_eq!(frozen_set.data_type, "frozen<set<text>>");
1408
1409        let frozen_map = schema
1410            .columns
1411            .iter()
1412            .find(|c| c.name == "frozen_map")
1413            .unwrap();
1414        assert_eq!(frozen_map.data_type, "frozen<map<text, bigint>>");
1415
1416        let nested = schema
1417            .columns
1418            .iter()
1419            .find(|c| c.name == "nested_frozen")
1420            .unwrap();
1421        assert_eq!(nested.data_type, "frozen<list<frozen<set<uuid>>>>");
1422    }
1423
1424    #[test]
1425    fn test_udt_columns() {
1426        let cql = r#"
1427            CREATE TABLE user_profiles (
1428                user_id uuid PRIMARY KEY,
1429                address address_type,
1430                preferences frozen<user_prefs>
1431            )
1432        "#;
1433
1434        let schema = parse_cql_schema(cql).unwrap();
1435
1436        let address_col = schema.columns.iter().find(|c| c.name == "address").unwrap();
1437        assert_eq!(address_col.data_type, "address_type");
1438
1439        let prefs_col = schema
1440            .columns
1441            .iter()
1442            .find(|c| c.name == "preferences")
1443            .unwrap();
1444        assert_eq!(prefs_col.data_type, "frozen<user_prefs>");
1445    }
1446
1447    #[test]
1448    fn test_tuple_types() {
1449        let cql = r#"
1450            CREATE TABLE tuple_test (
1451                id uuid PRIMARY KEY,
1452                coordinates tuple<double, double>,
1453                person_info tuple<text, int, boolean>
1454            )
1455        "#;
1456
1457        let schema = parse_cql_schema(cql).unwrap();
1458
1459        let coords = schema
1460            .columns
1461            .iter()
1462            .find(|c| c.name == "coordinates")
1463            .unwrap();
1464        assert_eq!(coords.data_type, "tuple<double, double>");
1465
1466        let person = schema
1467            .columns
1468            .iter()
1469            .find(|c| c.name == "person_info")
1470            .unwrap();
1471        assert_eq!(person.data_type, "tuple<text, int, boolean>");
1472    }
1473
1474    /// Issue #1690 (P0 safety): the nom `cql_type` parser must NOT stack-overflow
1475    /// on pathologically nested type strings (which under `panic = "abort"` abort
1476    /// the whole process). Recursion is bounded at [`MAX_TYPE_NESTING_DEPTH`], so
1477    /// deep input returns `Err` long before the stack is exhausted.
1478    #[test]
1479    fn test_cql_type_adversarial_deep_nesting_returns_err_not_abort() {
1480        let s = "frozen<".repeat(50_000) + "int" + &">".repeat(50_000);
1481        assert!(
1482            cql_type(&s).is_err(),
1483            "pathological nesting must return Err, not abort"
1484        );
1485    }
1486
1487    /// The depth bound is exact and behavior-preserving: a leaf reached at depth
1488    /// == [`MAX_TYPE_NESTING_DEPTH`] (i.e. that many `frozen<...>` levels) is the
1489    /// last allowed depth and reconstructs to the identical type string it did
1490    /// before the guard existed; one level deeper returns `Err`.
1491    #[test]
1492    fn test_cql_type_nesting_depth_boundary_is_exact() {
1493        let depth = MAX_TYPE_NESTING_DEPTH; // 32 — the last allowed nesting level.
1494
1495        // At the bound: must parse and round-trip to the exact same string.
1496        let ok_str = "frozen<".repeat(depth) + "int" + &">".repeat(depth);
1497        let (rest, parsed) =
1498            cql_type(&ok_str).expect("nesting at the depth bound must still parse");
1499        assert!(rest.trim().is_empty(), "entire input must be consumed");
1500        assert_eq!(parsed, ok_str, "type string must be preserved unchanged");
1501
1502        // One past the bound: must error (nom failure surfaced as Err), not abort.
1503        let bad_str = "frozen<".repeat(depth + 1) + "int" + &">".repeat(depth + 1);
1504        assert!(
1505            cql_type(&bad_str).is_err(),
1506            "one level past the bound must return Err"
1507        );
1508    }
1509
1510    /// Issue #1690 (P0 safety): the public `cql_type_to_type_id` conversion path
1511    /// (reached via `SchemaManager::cql_type_to_internal`) unwraps `frozen<...>`
1512    /// recursively. Pathologically nested `frozen<` input must return `Err`, not
1513    /// stack-overflow / abort. A leaf at the depth bound still resolves; one level
1514    /// deeper errors.
1515    #[test]
1516    fn test_cql_type_to_type_id_deep_frozen_returns_err_not_abort() {
1517        let s = "frozen<".repeat(50_000) + "int" + &">".repeat(50_000);
1518        assert!(
1519            cql_type_to_type_id(&s).is_err(),
1520            "pathological frozen nesting must return Err, not abort"
1521        );
1522
1523        let depth = MAX_TYPE_NESTING_DEPTH;
1524        // frozen<...> unwrapping: `depth` frozen levels reach the leaf at `depth`,
1525        // which is the last allowed level and must still resolve to the leaf id.
1526        let ok_str = "frozen<".repeat(depth) + "int" + &">".repeat(depth);
1527        assert_eq!(
1528            cql_type_to_type_id(&ok_str).expect("frozen nesting at the bound must resolve"),
1529            CqlTypeId::Int,
1530            "the leaf type id must be unchanged"
1531        );
1532
1533        let bad_str = "frozen<".repeat(depth + 1) + "int" + &">".repeat(depth + 1);
1534        let err = cql_type_to_type_id(&bad_str).expect_err("one level past the bound must error");
1535        let msg = err.to_string();
1536        assert!(
1537            msg.contains("nesting") || msg.contains("deep"),
1538            "error message must mention nesting/depth, got: {msg}"
1539        );
1540    }
1541
1542    #[test]
1543    fn test_case_insensitive_keywords() {
1544        let cql = r#"
1545            create table Users (
1546                ID UUID primary key,
1547                Name TEXT,
1548                Email VARCHAR
1549            )
1550        "#;
1551
1552        let schema = parse_cql_schema(cql).unwrap();
1553        assert_eq!(schema.table, "Users");
1554        assert_eq!(schema.columns.len(), 3);
1555    }
1556
1557    #[test]
1558    fn test_quoted_identifiers() {
1559        let cql = r#"
1560            CREATE TABLE "CaseSensitive" (
1561                "Id" uuid PRIMARY KEY,
1562                "Name With Spaces" text
1563            )
1564        "#;
1565
1566        let schema = parse_cql_schema(cql).unwrap();
1567        assert_eq!(schema.table, "CaseSensitive");
1568
1569        let space_col = schema.columns.iter().find(|c| c.name == "Name With Spaces");
1570        assert!(space_col.is_some());
1571    }
1572
1573    #[test]
1574    fn test_fallback_table_extraction() {
1575        // Test cases where full parsing might fail but we can still extract table name
1576        let cql = "CREATE TABLE myapp.orders (id bigint PRIMARY KEY)";
1577        let (keyspace, table) = extract_table_name(cql).unwrap();
1578        assert_eq!(keyspace, Some("myapp".to_string()));
1579        assert_eq!(table, "orders");
1580    }
1581
1582    #[test]
1583    fn test_all_primitive_types() {
1584        let type_mappings = vec![
1585            ("ascii", CqlTypeId::Ascii),
1586            ("bigint", CqlTypeId::BigInt),
1587            ("blob", CqlTypeId::Blob),
1588            ("boolean", CqlTypeId::Boolean),
1589            ("counter", CqlTypeId::Counter),
1590            ("decimal", CqlTypeId::Decimal),
1591            ("double", CqlTypeId::Double),
1592            ("float", CqlTypeId::Float),
1593            ("int", CqlTypeId::Int),
1594            ("timestamp", CqlTypeId::Timestamp),
1595            ("uuid", CqlTypeId::Uuid),
1596            ("varchar", CqlTypeId::Varchar),
1597            ("text", CqlTypeId::Varchar),
1598            ("varint", CqlTypeId::Varint),
1599            ("timeuuid", CqlTypeId::Timeuuid),
1600            ("inet", CqlTypeId::Inet),
1601            ("date", CqlTypeId::Date),
1602            ("time", CqlTypeId::Time),
1603            ("smallint", CqlTypeId::Smallint),
1604            ("tinyint", CqlTypeId::Tinyint),
1605            ("duration", CqlTypeId::Duration),
1606        ];
1607
1608        for (cql_type, expected_id) in type_mappings {
1609            assert_eq!(
1610                cql_type_to_type_id(cql_type).unwrap(),
1611                expected_id,
1612                "Failed for type: {}",
1613                cql_type
1614            );
1615        }
1616    }
1617
1618    /// Issue #852 (review finding 2): the parser must preserve `WITH` table
1619    /// options into `TableSchema.comments` so the writer can honor
1620    /// `bloom_filter_fp_chance`. Previously the WITH clause was parsed and
1621    /// discarded, leaving `comments` empty.
1622    #[test]
1623    fn test_with_bloom_filter_fp_chance_preserved_in_comments() {
1624        let cql = "CREATE TABLE ks.t (id int PRIMARY KEY, name text) \
1625                   WITH bloom_filter_fp_chance = 1.0";
1626        let schema = parse_cql_schema(cql).expect("schema should parse");
1627        assert_eq!(
1628            schema
1629                .comments
1630                .get("bloom_filter_fp_chance")
1631                .map(String::as_str),
1632            Some("1.0"),
1633            "WITH bloom_filter_fp_chance must be preserved into comments, got: {:?}",
1634            schema.comments
1635        );
1636    }
1637
1638    /// The option name is case-insensitive and additional options coexist.
1639    #[test]
1640    fn test_with_multiple_options_preserved() {
1641        let cql = "CREATE TABLE ks.t (id int PRIMARY KEY, name text) \
1642                   WITH gc_grace_seconds = 0 AND bloom_filter_fp_chance = 0.01";
1643        let schema = parse_cql_schema(cql).expect("schema should parse");
1644        assert_eq!(
1645            schema
1646                .comments
1647                .get("bloom_filter_fp_chance")
1648                .map(String::as_str),
1649            Some("0.01")
1650        );
1651        assert_eq!(
1652            schema.comments.get("gc_grace_seconds").map(String::as_str),
1653            Some("0")
1654        );
1655    }
1656
1657    /// Issue #852 (review finding, roborev job 741): a map-valued option such as
1658    /// `compression = {'class': 'LZ4Compressor'}` appearing BEFORE the bloom
1659    /// option must not stop option collection. Previously the map value failed
1660    /// to parse, so `bloom_filter_fp_chance` was dropped and the writer fell
1661    /// back to 0.01 (emitting Filter.db incorrectly).
1662    #[test]
1663    fn test_with_map_valued_option_before_bloom_preserved() {
1664        let cql = "CREATE TABLE ks.t (id int PRIMARY KEY, name text) \
1665                   WITH compression = {'class': 'LZ4Compressor'} \
1666                   AND bloom_filter_fp_chance = 1.0";
1667        let schema = parse_cql_schema(cql).expect("schema should parse");
1668        assert_eq!(
1669            schema
1670                .comments
1671                .get("bloom_filter_fp_chance")
1672                .map(String::as_str),
1673            Some("1.0"),
1674            "bloom_filter_fp_chance must survive a preceding map-valued option, got: {:?}",
1675            schema.comments
1676        );
1677    }
1678
1679    /// A multi-entry compaction map (with nested-looking comma-separated
1680    /// entries) before the bloom option must also be skipped cleanly.
1681    #[test]
1682    fn test_with_compaction_map_before_bloom_preserved() {
1683        let cql = "CREATE TABLE ks.t (id int PRIMARY KEY, name text) \
1684                   WITH compaction = {'class': 'SizeTieredCompactionStrategy', 'max_threshold': '32'} \
1685                   AND bloom_filter_fp_chance = 1.0";
1686        let schema = parse_cql_schema(cql).expect("schema should parse");
1687        assert_eq!(
1688            schema
1689                .comments
1690                .get("bloom_filter_fp_chance")
1691                .map(String::as_str),
1692            Some("1.0"),
1693            "bloom_filter_fp_chance must survive a preceding compaction map, got: {:?}",
1694            schema.comments
1695        );
1696    }
1697
1698    /// List-valued options must also be tolerated without stopping collection.
1699    #[test]
1700    fn test_with_list_valued_option_before_bloom_preserved() {
1701        let cql = "CREATE TABLE ks.t (id int PRIMARY KEY, name text) \
1702                   WITH some_list = ['a', 'b'] \
1703                   AND bloom_filter_fp_chance = 1.0";
1704        let schema = parse_cql_schema(cql).expect("schema should parse");
1705        assert_eq!(
1706            schema
1707                .comments
1708                .get("bloom_filter_fp_chance")
1709                .map(String::as_str),
1710            Some("1.0"),
1711            "bloom_filter_fp_chance must survive a preceding list-valued option, got: {:?}",
1712            schema.comments
1713        );
1714    }
1715
1716    /// A map-valued option's value should be captured (round-tripped) too.
1717    #[test]
1718    fn test_with_map_valued_option_value_captured() {
1719        let cql = "CREATE TABLE ks.t (id int PRIMARY KEY, name text) \
1720                   WITH compression = {'class': 'LZ4Compressor'}";
1721        let schema = parse_cql_schema(cql).expect("schema should parse");
1722        assert_eq!(
1723            schema.comments.get("compression").map(String::as_str),
1724            Some("{'class': 'LZ4Compressor'}"),
1725            "map-valued option value should be preserved verbatim, got: {:?}",
1726            schema.comments
1727        );
1728    }
1729
1730    /// Issue #852 (branch review, roborev job 775): a `CLUSTERING ORDER BY (...)`
1731    /// WITH item appearing BEFORE the bloom option must not stop option
1732    /// collection. Previously `option_pair` could not parse the non-`key=value`
1733    /// CLUSTERING item, so `separated_list0` returned early and the trailing
1734    /// `AND bloom_filter_fp_chance = 1.0` was silently dropped (the writer then
1735    /// fell back to 0.01 and emitted Filter.db despite the CQL disabling it).
1736    #[test]
1737    fn test_with_clustering_order_before_bloom_preserved() {
1738        let cql = "CREATE TABLE ks.t (id int, ck int, name text, PRIMARY KEY (id, ck)) \
1739                   WITH CLUSTERING ORDER BY (ck DESC) AND bloom_filter_fp_chance = 1.0";
1740        let schema = parse_cql_schema(cql).expect("schema should parse");
1741        assert_eq!(
1742            schema
1743                .comments
1744                .get("bloom_filter_fp_chance")
1745                .map(String::as_str),
1746            Some("1.0"),
1747            "bloom_filter_fp_chance must survive a preceding CLUSTERING ORDER BY, got: {:?}",
1748            schema.comments
1749        );
1750    }
1751
1752    /// A `CLUSTERING ORDER BY (...)` with multiple columns and mixed
1753    /// ASC/DESC ordering must also be skipped cleanly.
1754    #[test]
1755    fn test_with_clustering_order_multi_column_before_bloom_preserved() {
1756        let cql =
1757            "CREATE TABLE ks.t (id int, c1 int, c2 int, name text, PRIMARY KEY (id, c1, c2)) \
1758                   WITH CLUSTERING ORDER BY (c1 ASC, c2 DESC) AND bloom_filter_fp_chance = 1.0";
1759        let schema = parse_cql_schema(cql).expect("schema should parse");
1760        assert_eq!(
1761            schema
1762                .comments
1763                .get("bloom_filter_fp_chance")
1764                .map(String::as_str),
1765            Some("1.0"),
1766            "bloom_filter_fp_chance must survive a multi-column CLUSTERING ORDER BY, got: {:?}",
1767            schema.comments
1768        );
1769    }
1770
1771    /// #849/#852 (branch review, roborev job 777): a single DESC clustering
1772    /// column must be applied to `clustering_keys` so DESC write/merge ordering
1773    /// is honored for CQL-parsed schemas.
1774    #[test]
1775    fn test_clustering_order_desc_applied_to_clustering_keys() {
1776        let cql = "CREATE TABLE ks.t (pk int, ck int, v int, PRIMARY KEY (pk, ck)) \
1777                   WITH CLUSTERING ORDER BY (ck DESC)";
1778        let schema = parse_cql_schema(cql).expect("schema should parse");
1779        let ck = schema
1780            .clustering_keys
1781            .iter()
1782            .find(|c| c.name == "ck")
1783            .expect("ck clustering column must exist");
1784        assert_eq!(
1785            ck.order,
1786            ClusteringOrder::Desc,
1787            "ck must carry DESC ordering, got: {:?}",
1788            schema.clustering_keys
1789        );
1790    }
1791
1792    /// #849/#852: a mixed multi-column clustering order must apply each column's
1793    /// direction independently (one ASC, one DESC).
1794    #[test]
1795    fn test_clustering_order_mixed_applied_per_column() {
1796        let cql = "CREATE TABLE ks.t (pk int, c1 int, c2 int, v int, PRIMARY KEY (pk, c1, c2)) \
1797                   WITH CLUSTERING ORDER BY (c1 ASC, c2 DESC)";
1798        let schema = parse_cql_schema(cql).expect("schema should parse");
1799        let c1 = schema
1800            .clustering_keys
1801            .iter()
1802            .find(|c| c.name == "c1")
1803            .expect("c1 must exist");
1804        let c2 = schema
1805            .clustering_keys
1806            .iter()
1807            .find(|c| c.name == "c2")
1808            .expect("c2 must exist");
1809        assert_eq!(c1.order, ClusteringOrder::Asc, "c1 should be ASC");
1810        assert_eq!(c2.order, ClusteringOrder::Desc, "c2 should be DESC");
1811    }
1812
1813    /// #849/#852: with no CLUSTERING ORDER BY clause, clustering columns default
1814    /// to ASC.
1815    #[test]
1816    fn test_clustering_order_defaults_to_asc_without_clause() {
1817        let cql = "CREATE TABLE ks.t (pk int, ck int, v int, PRIMARY KEY (pk, ck))";
1818        let schema = parse_cql_schema(cql).expect("schema should parse");
1819        let ck = schema
1820            .clustering_keys
1821            .iter()
1822            .find(|c| c.name == "ck")
1823            .expect("ck must exist");
1824        assert_eq!(
1825            ck.order,
1826            ClusteringOrder::Asc,
1827            "ck must default to ASC without a CLUSTERING ORDER BY clause"
1828        );
1829    }
1830
1831    /// #852 (branch review, roborev job 788): a QUOTED clustering identifier in
1832    /// `CLUSTERING ORDER BY (...)` must match the clustering column name (which is
1833    /// stored unquoted by `identifier()`), so its DESC direction is applied rather
1834    /// than silently defaulting to ASC.
1835    #[test]
1836    fn test_clustering_order_quoted_identifier_applied() {
1837        let cql = "CREATE TABLE ks.t (pk int, \"Ck\" int, v int, PRIMARY KEY (pk, \"Ck\")) \
1838                   WITH CLUSTERING ORDER BY (\"Ck\" DESC)";
1839        let schema = parse_cql_schema(cql).expect("schema should parse");
1840        let ck = schema
1841            .clustering_keys
1842            .iter()
1843            .find(|c| c.name == "Ck")
1844            .expect("Ck clustering column must exist");
1845        assert_eq!(
1846            ck.order,
1847            ClusteringOrder::Desc,
1848            "quoted \"Ck\" must carry DESC ordering, got: {:?}",
1849            schema.clustering_keys
1850        );
1851    }
1852
1853    /// #852 (job 788): a mixed clustering order mixing a quoted DESC column with
1854    /// an unquoted ASC column must apply each direction to the correct column.
1855    #[test]
1856    fn test_clustering_order_mixed_quoted_and_unquoted() {
1857        let cql =
1858            "CREATE TABLE ks.t (pk int, c1 int, \"Ck\" int, v int, PRIMARY KEY (pk, c1, \"Ck\")) \
1859                   WITH CLUSTERING ORDER BY (c1 ASC, \"Ck\" DESC)";
1860        let schema = parse_cql_schema(cql).expect("schema should parse");
1861        let c1 = schema
1862            .clustering_keys
1863            .iter()
1864            .find(|c| c.name == "c1")
1865            .expect("c1 must exist");
1866        let ck = schema
1867            .clustering_keys
1868            .iter()
1869            .find(|c| c.name == "Ck")
1870            .expect("Ck must exist");
1871        assert_eq!(c1.order, ClusteringOrder::Asc, "c1 should be ASC");
1872        assert_eq!(ck.order, ClusteringOrder::Desc, "Ck should be DESC");
1873    }
1874
1875    /// #852 (branch review, roborev job 797): a QUOTED clustering identifier that
1876    /// itself CONTAINS a comma (e.g. `"C,k"`) must not be split on the embedded
1877    /// comma. `parse_clustering_order_body` previously used `inner.split(',')`,
1878    /// which broke `"C,k" DESC` into two bogus entries and silently left the
1879    /// column at its default ASC. Parsing the list with nom + the shared
1880    /// `identifier` parser keeps the quoted name intact and applies its DESC.
1881    #[test]
1882    fn test_clustering_order_quoted_identifier_with_comma_applied() {
1883        let cql = "CREATE TABLE ks.t (pk int, \"C,k\" int, v int, PRIMARY KEY (pk, \"C,k\")) \
1884                   WITH CLUSTERING ORDER BY (\"C,k\" DESC)";
1885        let schema = parse_cql_schema(cql).expect("schema should parse");
1886        let ck = schema
1887            .clustering_keys
1888            .iter()
1889            .find(|c| c.name == "C,k")
1890            .expect("\"C,k\" clustering column must exist");
1891        assert_eq!(
1892            ck.order,
1893            ClusteringOrder::Desc,
1894            "quoted \"C,k\" (with embedded comma) must carry DESC ordering, got: {:?}",
1895            schema.clustering_keys
1896        );
1897    }
1898
1899    /// #852 (branch review, roborev job 816): a QUOTED clustering identifier that
1900    /// itself CONTAINS a close-paren (e.g. `"C)k"`) must not terminate the
1901    /// `CLUSTERING ORDER BY (...)` clause body early. The clause-body scan in
1902    /// `clustering_order_item` previously stopped at the first `)`, truncating the
1903    /// body to `("C` and silently dropping the DESC direction (the column fell
1904    /// back to its default ASC). A quote-aware scan keeps the quoted name intact
1905    /// so its DESC is applied.
1906    #[test]
1907    fn test_clustering_order_quoted_identifier_with_close_paren_applied() {
1908        let cql = "CREATE TABLE ks.t (pk int, \"C)k\" int, v int, PRIMARY KEY (pk, \"C)k\")) \
1909                   WITH CLUSTERING ORDER BY (\"C)k\" DESC)";
1910        let schema = parse_cql_schema(cql).expect("schema should parse");
1911        let ck = schema
1912            .clustering_keys
1913            .iter()
1914            .find(|c| c.name == "C)k")
1915            .expect("\"C)k\" clustering column must exist");
1916        assert_eq!(
1917            ck.order,
1918            ClusteringOrder::Desc,
1919            "quoted \"C)k\" (with embedded close-paren) must carry DESC ordering, got: {:?}",
1920            schema.clustering_keys
1921        );
1922    }
1923
1924    /// Issue #852 (branch review, roborev job 775): a string option value with a
1925    /// doubled single-quote escape (`''`) must be parsed in full so a later
1926    /// `AND`-separated option survives. Previously the string parser stopped at
1927    /// the first inner `'`, leaving `s table' AND ...` unconsumed and dropping
1928    /// the bloom option.
1929    ///
1930    /// The captured comment value uses the CQL convention of un-escaping the
1931    /// doubled quote, i.e. `Bob's table`.
1932    #[test]
1933    fn test_with_quote_escaped_comment_before_bloom_preserved() {
1934        let cql = "CREATE TABLE ks.t (id int PRIMARY KEY, name text) \
1935                   WITH comment = 'Bob''s table' AND bloom_filter_fp_chance = 1.0";
1936        let schema = parse_cql_schema(cql).expect("schema should parse");
1937        assert_eq!(
1938            schema
1939                .comments
1940                .get("bloom_filter_fp_chance")
1941                .map(String::as_str),
1942            Some("1.0"),
1943            "bloom_filter_fp_chance must survive a quote-escaped comment, got: {:?}",
1944            schema.comments
1945        );
1946        assert_eq!(
1947            schema.comments.get("comment").map(String::as_str),
1948            Some("Bob's table"),
1949            "doubled single-quote must be un-escaped in the captured comment, got: {:?}",
1950            schema.comments
1951        );
1952    }
1953}