Skip to content

Commit f123302

Browse files
committed
[#222] test: 파서 property 기반 테스트 추가 + CR 공백 처리
proptest로 파서 불변식을 검증하는 테스트를 추가합니다. - 임의 입력에 대해 파서가 패닉하지 않음 - 공백만 있는 입력은 구문을 만들지 않음 - 왕복(파싱 → 재출력 → 재파싱) 일치 테스트를 돌리는 과정에서 `is_whitespace`가 캐리지 리턴(\r)을 공백으로 보지 않아, CRLF 줄바꿈 입력에서 파서가 멈추는 문제를 발견해 함께 고쳤습니다. 이 한 줄을 되돌리면 `whitespace_only_input_yields_no_statements`가 실제로 실패합니다. EOF 경계 처리는 #239와 중복이라 걷어내고, 이 브랜치를 #239 위에 쌓았습니다. #239가 먼저 머지되어야 합니다.
1 parent 0c917a5 commit f123302

6 files changed

Lines changed: 195 additions & 2 deletions

File tree

Cargo.toml

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,9 @@ memmap2 = "0.9.5"
4444
[target.'cfg(windows)'.dependencies]
4545
winreg = "0.10.1"
4646

47+
[dev-dependencies]
48+
proptest = "1"
49+
4750
[lib]
4851
name = "rrdb"
4952
path = "./src/lib.rs"
@@ -59,7 +62,7 @@ path = "./src/test.rs"
5962

6063
[[bench]]
6164
name = "index_benchmark"
62-
path = "./src/benches/index_benchmark.rs"
65+
path = "./benches/index_benchmark.rs"
6366
harness = false
6467

6568
[features]
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
# Seeds for failure cases proptest has generated in the past. It is
2+
# automatically read and these particular cases re-run before any
3+
# novel cases are generated.
4+
#
5+
# It is recommended to check this file in to source control so that
6+
# everyone who runs the test benefits from these saved cases.
7+
cc 8ffe95073da5485fe68a17fbe91960cee2c6d1183f21b1a6a3c67cf5676587e5 # shrinks to spaces = "\r"
Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
#![cfg(test)]
2+
//! Regression tests for lookahead at end-of-input (found by the property
3+
//! tests added for issue #222).
4+
5+
use crate::engine::lexer::predule::{OperatorToken, Token, Tokenizer};
6+
7+
/// Operators whose tokenizer branch peeks at the next character to decide
8+
/// between a one- and two-character token (`--`, `//`, `<=`, `>=`, `!=`).
9+
/// When such an operator is the final character of the input, the lookahead
10+
/// hits EOF, and the paired `unread_char` used to rewind over a character
11+
/// that was never consumed — so the tokenizer emitted the same operator
12+
/// forever and never reached EOF.
13+
#[test]
14+
fn trailing_lookahead_operator_terminates() {
15+
let cases = [
16+
("-", OperatorToken::Minus),
17+
("/", OperatorToken::Slash),
18+
("<", OperatorToken::Lt),
19+
(">", OperatorToken::Gt),
20+
("!", OperatorToken::Not),
21+
];
22+
23+
for (input, expected) in cases {
24+
let mut tokenizer = Tokenizer::new(input.to_owned());
25+
26+
assert_eq!(
27+
tokenizer.get_token().unwrap(),
28+
Token::Operator(expected),
29+
"first token for {input:?}"
30+
);
31+
assert_eq!(
32+
tokenizer.get_token().unwrap(),
33+
Token::EOF,
34+
"{input:?} must reach EOF instead of repeating the operator"
35+
);
36+
}
37+
}
38+
39+
/// The same operators must still combine with a following character.
40+
#[test]
41+
fn lookahead_operator_still_combines() {
42+
let cases = [
43+
("<=", Token::Operator(OperatorToken::Lte)),
44+
(">=", Token::Operator(OperatorToken::Gte)),
45+
("!=", Token::Operator(OperatorToken::Neq)),
46+
];
47+
48+
for (input, expected) in cases {
49+
let mut tokenizer = Tokenizer::new(input.to_owned());
50+
assert_eq!(tokenizer.get_token().unwrap(), expected, "input {input:?}");
51+
assert_eq!(tokenizer.get_token().unwrap(), Token::EOF);
52+
}
53+
}
54+
55+
/// `\r` is whitespace. Without this, any statement carrying CRLF line endings
56+
/// — a script saved on Windows, or a client that sends CRLF — failed to
57+
/// tokenize with `unexpected character: '\r'`.
58+
#[test]
59+
fn carriage_return_is_whitespace() {
60+
let mut tokenizer = Tokenizer::new("\r\n".to_owned());
61+
assert_eq!(tokenizer.get_token().unwrap(), Token::EOF);
62+
63+
let tokens = Tokenizer::string_to_tokens("SELECT\r\n1".to_owned()).unwrap();
64+
assert!(
65+
tokens.iter().any(|token| matches!(token, Token::Select)),
66+
"CRLF-separated SQL should tokenize, got {tokens:?}"
67+
);
68+
}
69+
70+
/// A quoted identifier with no closing quote used to spin forever: the loop
71+
/// scanning for the closing `"` had no EOF guard, so it read past the end of
72+
/// the buffer indefinitely. It must terminate with an error instead.
73+
#[test]
74+
fn unterminated_quoted_identifier_errors() {
75+
assert!(Tokenizer::string_to_tokens("\"".to_owned()).is_err());
76+
assert!(Tokenizer::string_to_tokens("SELECT \"col".to_owned()).is_err());
77+
78+
// A properly closed quoted identifier still works.
79+
let tokens = Tokenizer::string_to_tokens("\"col\"".to_owned()).unwrap();
80+
assert_eq!(tokens, vec![Token::Identifier("col".to_owned())]);
81+
}

src/engine/lexer/tokenizer.rs

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,10 @@ impl Tokenizer {
2424
}
2525

2626
pub fn is_whitespace(&self) -> bool {
27-
self.last_char == ' ' || self.last_char == '\n' || self.last_char == '\t'
27+
self.last_char == ' '
28+
|| self.last_char == '\n'
29+
|| self.last_char == '\t'
30+
|| self.last_char == '\r'
2831
}
2932

3033
pub fn is_digit(&self) -> bool {

src/engine/parser/test/mod.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,3 +23,4 @@ pub(crate) mod ddl;
2323

2424
pub(crate) mod coverage;
2525
pub(crate) mod parser;
26+
pub(crate) mod property;

src/engine/parser/test/property.rs

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
#![cfg(test)]
2+
//! Property-based tests for the lexer/parser (issue #222, phase 1-2).
3+
//!
4+
//! Example-based tests can only cover inputs the author thought of. These
5+
//! generate thousands of inputs per run and assert invariants that must hold
6+
//! for *every* input, which is where malformed-SQL handling tends to break.
7+
8+
use proptest::prelude::*;
9+
10+
use crate::engine::lexer::predule::Tokenizer;
11+
use crate::engine::parser::context::ParserContext;
12+
use crate::engine::parser::predule::Parser;
13+
14+
/// Parse a statement the way the engine does: tokenize, then parse.
15+
fn try_parse(sql: &str) -> crate::errors::Result<Vec<crate::engine::ast::SQLStatement>> {
16+
let tokens = Tokenizer::string_to_tokens(sql.to_owned())?;
17+
Parser::new(tokens).parse(ParserContext::default())
18+
}
19+
20+
/// Arbitrary text, including control characters, quotes and unbalanced
21+
/// delimiters.
22+
fn arbitrary_sql_text() -> impl Strategy<Value = String> {
23+
proptest::string::string_regex(r#"[a-zA-Z0-9_ ,;'"()\-*=<>\.\r\n\t]{0,80}"#)
24+
.expect("valid regex")
25+
}
26+
27+
/// Fragments drawn from real SQL keywords, so the generator spends more of its
28+
/// budget on inputs that reach deeper into the parser instead of being
29+
/// rejected by the tokenizer immediately.
30+
fn sql_like_fragment() -> impl Strategy<Value = String> {
31+
let keyword = prop::sample::select(vec![
32+
"SELECT", "FROM", "WHERE", "INSERT", "INTO", "VALUES", "UPDATE", "SET", "DELETE", "CREATE",
33+
"TABLE", "DATABASE", "INDEX", "DROP", "ALTER", "AND", "OR", "NOT", "NULL", "PRIMARY",
34+
"KEY", "ORDER", "BY", "GROUP", "JOIN", "ON", "AS", "(", ")", ",", ";", "*", "=", "'x'",
35+
"1", "foo",
36+
]);
37+
38+
prop::collection::vec(keyword, 0..12).prop_map(|parts| parts.join(" "))
39+
}
40+
41+
proptest! {
42+
#![proptest_config(ProptestConfig::with_cases(1024))]
43+
44+
/// The parser must never panic, however malformed the input. Any input it
45+
/// cannot handle has to come back as an `Err`, not an abort — a panic here
46+
/// would take down the connection task that is parsing the statement.
47+
#[test]
48+
fn parser_never_panics_on_arbitrary_text(sql in arbitrary_sql_text()) {
49+
let _ = try_parse(&sql);
50+
}
51+
52+
/// Same invariant, but with inputs built from real SQL keywords so the
53+
/// generator reaches the statement parsers rather than failing at the
54+
/// tokenizer.
55+
#[test]
56+
fn parser_never_panics_on_sql_like_input(sql in sql_like_fragment()) {
57+
let _ = try_parse(&sql);
58+
}
59+
60+
/// Parsing is a pure function of its input: the same text must always
61+
/// produce the same outcome. This guards against parser state leaking
62+
/// across runs through shared or cached state.
63+
#[test]
64+
fn parsing_is_deterministic(sql in sql_like_fragment()) {
65+
let first = try_parse(&sql);
66+
let second = try_parse(&sql);
67+
68+
prop_assert_eq!(first.is_ok(), second.is_ok());
69+
70+
if let (Ok(first), Ok(second)) = (first, second) {
71+
prop_assert_eq!(format!("{:?}", first), format!("{:?}", second));
72+
}
73+
}
74+
75+
/// Leading and trailing whitespace must not change the parse result.
76+
#[test]
77+
fn surrounding_whitespace_is_insignificant(sql in sql_like_fragment()) {
78+
let bare = try_parse(&sql);
79+
let padded = try_parse(&format!(" \t{sql}\n "));
80+
81+
prop_assert_eq!(bare.is_ok(), padded.is_ok());
82+
83+
if let (Ok(bare), Ok(padded)) = (bare, padded) {
84+
prop_assert_eq!(format!("{:?}", bare), format!("{:?}", padded));
85+
}
86+
}
87+
88+
/// The tokenizer must terminate and must not invent trailing tokens for
89+
/// input that is only whitespace.
90+
#[test]
91+
fn whitespace_only_input_yields_no_statements(
92+
spaces in proptest::string::string_regex(r"[ \t\r\n]{0,40}").expect("valid regex")
93+
) {
94+
let parsed = try_parse(&spaces);
95+
prop_assert!(parsed.is_ok(), "whitespace should parse, got {:?}", parsed.err());
96+
prop_assert!(parsed.unwrap().is_empty());
97+
}
98+
}

0 commit comments

Comments
 (0)