diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3f8982b..61aef0a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -30,3 +30,26 @@ jobs: - name: Test run: ctest --test-dir build -C Release --output-on-failure + + sanitizers: + runs-on: ubuntu-latest + + steps: + - name: Check out repository + uses: actions/checkout@v6 + with: + persist-credentials: false + + - name: Configure sanitizers + run: >- + cmake -S . -B build-sanitized + -DCMAKE_BUILD_TYPE=Debug + -DBUILD_TESTING=ON + -DSQL_ENABLE_SANITIZERS=ON + -DCMAKE_COMPILE_WARNING_AS_ERROR=ON + + - name: Build + run: cmake --build build-sanitized --parallel + + - name: Test + run: ctest --test-dir build-sanitized --output-on-failure diff --git a/.gitignore b/.gitignore index bffac08..24aa292 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ # Build output /build/ +/build-*/ /cmake-build-*/ CMakeCache.txt CMakeFiles/ diff --git a/CMakeLists.txt b/CMakeLists.txt index 22cf592..54f6e18 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -6,6 +6,16 @@ set(CMAKE_CXX_STANDARD 17) set(CMAKE_CXX_STANDARD_REQUIRED ON) set(CMAKE_CXX_EXTENSIONS OFF) +option(SQL_ENABLE_SANITIZERS "Enable AddressSanitizer and UndefinedBehaviorSanitizer" OFF) + +if(SQL_ENABLE_SANITIZERS) + if(MSVC) + message(FATAL_ERROR "SQL_ENABLE_SANITIZERS requires GCC or Clang") + endif() + add_compile_options(-fsanitize=address,undefined -fno-omit-frame-pointer) + add_link_options(-fsanitize=address,undefined) +endif() + set(SQL_CORE_SOURCES includes/binary_files/file_record.cpp includes/binary_files/utilities.cpp @@ -21,20 +31,26 @@ set(SQL_CORE_SOURCES add_library(sql_core STATIC ${SQL_CORE_SOURCES}) target_include_directories(sql_core PUBLIC "${CMAKE_CURRENT_SOURCE_DIR}") -if(MSVC) - target_compile_options(sql_core PRIVATE /W4) -else() - target_compile_options(sql_core PRIVATE -Wall -Wextra -Wpedantic) -endif() +function(sql_enable_warnings target) + if(MSVC) + target_compile_options(${target} PRIVATE /W4) + else() + target_compile_options(${target} PRIVATE -Wall -Wextra -Wpedantic) + endif() +endfunction() + +sql_enable_warnings(sql_core) add_executable(run_sql run_sql.cpp) target_link_libraries(run_sql PRIVATE sql_core) +sql_enable_warnings(run_sql) include(CTest) if(BUILD_TESTING) add_executable(sql_tests tests/sql_tests.cpp) target_link_libraries(sql_tests PRIVATE sql_core) + sql_enable_warnings(sql_tests) target_compile_definitions(sql_tests PRIVATE SQL_TEST_FIXTURE_DIR="${CMAKE_CURRENT_SOURCE_DIR}/batch" ) @@ -46,4 +62,14 @@ if(BUILD_TESTING) set_tests_properties(sql_behavior PROPERTIES WORKING_DIRECTORY "${SQL_TEST_WORKING_DIRECTORY}" ) + + add_executable(parser_tests tests/parser_tests.cpp) + target_link_libraries(parser_tests PRIVATE sql_core) + sql_enable_warnings(parser_tests) + add_test(NAME parser_safety COMMAND parser_tests) + + add_executable(bplustree_tests tests/bplustree_tests.cpp) + target_link_libraries(bplustree_tests PRIVATE sql_core) + sql_enable_warnings(bplustree_tests) + add_test(NAME bplustree_evidence COMMAND bplustree_tests) endif() diff --git a/DESIGN.md b/DESIGN.md new file mode 100644 index 0000000..1331c2f --- /dev/null +++ b/DESIGN.md @@ -0,0 +1,50 @@ +# Design and invariants + +SQL in C++ is an educational database engine with a deliberately small grammar and fixed-width local persistence. +The implementation favors inspectable data structures over production database features. + +## Command path + +1. `STokenizer` emits lexical tokens from a command string. +2. `Parser` validates the supported grammar and builds a parse tree. +3. `SQL` validates command-level identifiers and converts failures into CLI errors. +4. `Table` validates schemas, projections, filters, and row arity before storage access. +5. Compound filters pass through `ShuntingYard` and are evaluated against per-field indexes. + +Invalid syntax, unknown tables or fields, unmatched quotes or parentheses, and corrupt files fail closed. +An invalid `WHERE` expression never falls back to an unfiltered query. + +## Storage format + +Each field occupies one zero-padded 100-byte slot. +The schema file stores the field count followed by one slot per field name. +The data file stores each row as exactly `field_count * 100` bytes. + +The format retains the repository's existing fixed-width layout. +Writes now use explicit zero-initialized buffers instead of reading beyond a string allocation. +Values longer than 100 bytes, rows with the wrong number of values, duplicate fields, partial rows, and invalid metadata are rejected before they can be treated as valid data. + +Tables support between 1 and 64 uniquely named fields. +Table and field identifiers use letters, digits, and underscores, and cannot begin with a digit. + +## Indexes and query semantics + +Every field has an in-memory `MMap` backed by the repository's B+ tree implementation. +Indexes are rebuilt from the data file when a table is opened. +Equality and range filters return record numbers, while logical expressions combine those sets with union or intersection. + +Values are strings. +Ordering is lexical rather than numeric, so `"10"` sorts before `"2"`. +The current `lower_bound` and `upper_bound` implementations scan the linked leaf sequence and are not a logarithmic query-planning claim. + +## Deliberate limits + +The engine has no transactions, concurrency control, write-ahead log, schema migration, joins, updates, deletes, query optimizer, or SQL compatibility guarantee. +It is not designed for untrusted multi-user workloads. +The command-line process reads and writes table files in its current working directory. + +## Evidence + +The CTest suite covers end-to-end SQL behavior, parser safety, and deterministic B+ tree insertion and lookup properties. +CI runs release builds on Linux and macOS plus an AddressSanitizer and UndefinedBehaviorSanitizer build on Linux. +The sanitizer job is a memory-safety regression gate, not evidence of production database readiness. diff --git a/README.md b/README.md index 2f910ea..dc9220b 100644 --- a/README.md +++ b/README.md @@ -31,7 +31,7 @@ Values are stored and ordered as strings, so numeric-looking values use lexical | `includes/table` | Owns table persistence, indexes, projections, and selections. | | `includes/sql` | Coordinates parsing and table operations through the public `SQL` interface. | -Additional implementation notes are available in [design.txt](design.txt). +Storage invariants, query semantics, and deliberate limits are documented in [DESIGN.md](DESIGN.md). The canonical repository is [hskl18/SQL](https://github.com/hskl18/SQL). ## Build @@ -51,9 +51,21 @@ The build has no downloaded test framework or other third-party dependency. ctest --test-dir build --output-on-failure ``` -The test executable verifies inserts, projections, compound filters, persistence, invalid input handling, and both repository batch fixtures. +The test executables verify inserts, projections, compound filters, persistence, table and row validation, corrupt-file handling, quote and parenthesis safety, deterministic B+ tree properties, and both repository batch fixtures. Tests run in an isolated build directory, so they do not leave database files in the source tree. +To run the same AddressSanitizer and UndefinedBehaviorSanitizer gate used by CI: + +```bash +cmake -S . -B build-sanitized \ + -DCMAKE_BUILD_TYPE=Debug \ + -DBUILD_TESTING=ON \ + -DSQL_ENABLE_SANITIZERS=ON \ + -DCMAKE_COMPILE_WARNING_AS_ERROR=ON +cmake --build build-sanitized --parallel +ctest --test-dir build-sanitized --output-on-failure +``` + ## Run the CLI ```bash @@ -62,6 +74,7 @@ Tests run in an isolated build directory, so they do not leave database files in Type `end` to exit and `cls` to clear the terminal. The CLI writes each table to `.bin` and `
_fields.bin` in the current working directory. +It reports missing tables, invalid projections, wrong row arity, duplicate schemas, malformed filters, and corrupt fixed-width files without terminating the session. Example session: @@ -84,6 +97,19 @@ select <* | field [, field ...]> from
``` Quote values that contain spaces, such as `"Mary Ann"`. +Quoted values may contain spaces, numbers, and punctuation, but escaped double quotes are not supported. + +## Persistence boundaries + +- Tables contain between 1 and 64 uniquely named fields. +- Identifiers contain letters, digits, and underscores and cannot start with a digit. +- Each stored field occupies one zero-padded 100-byte slot. +- An insert must provide exactly one value per field. +- Values longer than 100 bytes are rejected before a write. +- Existing tables cannot be overwritten by another `CREATE TABLE` command. +- Partial rows and invalid schema metadata fail closed when a table is opened. + +The fixed-width layout is intentionally simple and is not a transactional or crash-recovery format. ## License diff --git a/design.txt b/design.txt deleted file mode 100644 index 70b37c1..0000000 --- a/design.txt +++ /dev/null @@ -1,19 +0,0 @@ -project structure: - --_tests // unit test using gtest framework - --batch // batch files to test - --includes - --binary_files // to store data in binary format - --linked_list // linked list implementation - --map // map implementation - --parser // parser for sql statement, using state machine - --shunting_yard // shunting yard algorithm for infix to postfix - --sql // sql statement implementation - --table // to store data in table format - --tokenizer // to read sql statement and tokenize it - --command_line.cpp // the main entry of the program in cmd - --design.txt - --CMakeLists.txt - --main.cpp // the main entry of the program in gui - --README.md - -// TODO \ No newline at end of file diff --git a/includes/binary_files/file_record.cpp b/includes/binary_files/file_record.cpp index d2e4be0..0c6bd6a 100644 --- a/includes/binary_files/file_record.cpp +++ b/includes/binary_files/file_record.cpp @@ -1,52 +1,58 @@ #include "file_record.h" -//Constructors -FileRecord::FileRecord(string){} -FileRecord::FileRecord(char *str){ - int n = strlen(str); - this->_records.emplace_back(str,n); -} -FileRecord::FileRecord(vector v) :FileRecord() { - this->_records=v; -} +#include +#include +#include +#include + +FileRecord::FileRecord(vector values) : _records(std::move(values)) {} long FileRecord::write(fstream &outs) { - outs.seekg(0, outs.end); + if (_records.empty()) { + throw std::invalid_argument("Cannot write an empty record"); + } + outs.seekp(0, outs.end); long pos = outs.tellp(); - for (const auto & _record : this->_records) outs.write(_record.c_str(), MAX); + if (pos < 0) { + throw std::runtime_error("Unable to seek to the end of the record file"); + } + for (const auto& record : _records) { + if (record.size() > MAX_VALUE_LENGTH) { + throw std::length_error("Record value exceeds 100 bytes"); + } + std::array buffer{}; + std::copy(record.begin(), record.end(), buffer.begin()); + outs.write(buffer.data(), static_cast(buffer.size())); + if (!outs) { + throw std::runtime_error("Unable to write a complete record"); + } + } return pos / (MAX * this->_records.size());//return the record number } // Write the record to the end of the file long FileRecord::read(fstream &ins, long recno) { + if (_records.empty() || recno < 0) { + throw std::invalid_argument("Record shape and number must be valid"); + } long pos = recno * MAX * this->_records.size(); ins.seekg(pos); + if (!ins) return 0; long total = 0; for (auto & _record : this->_records){ - char temp[MAX + 1]; - ins.read(temp, MAX); - temp[ins.gcount()] = '\0'; - - if (ins.gcount() == 0) return 0; - - _record = temp; - total += ins.gcount(); + std::array buffer{}; + ins.read(buffer.data(), static_cast(buffer.size())); + const auto bytes = ins.gcount(); + if (bytes == 0 && total == 0) return 0; + if (bytes != static_cast(buffer.size())) { + throw std::runtime_error("Record file contains a partial record"); + } + const auto end = std::find(buffer.begin(), buffer.end(), '\0'); + _record.assign(buffer.begin(), end); + total += bytes; } return total; //return the number of bytes read } // Read a record from the file - -vector FileRecord::get_records() { - vector ans; - for (auto record : this->_records){ - char* temp = new char[record.size() + 1]; - strncpy(temp, record.c_str(), record.size()); - - temp[record.size()] = '\0'; - ans.push_back(temp); - } - return ans; - //return the record -} diff --git a/includes/binary_files/file_record.h b/includes/binary_files/file_record.h index be476e5..d9e7fd0 100644 --- a/includes/binary_files/file_record.h +++ b/includes/binary_files/file_record.h @@ -4,23 +4,21 @@ #include // cout, endl #include // setw, right #include // fstream -#include // strncpy #include // vector -#include // unique_ptr +#include using namespace std; class FileRecord{ public: + static constexpr std::size_t MAX_VALUE_LENGTH = 100; // When construct a FileRecord, it's either empty or it contains a word FileRecord(){} - FileRecord(string s); - FileRecord(char str[]); - FileRecord(vector v); + explicit FileRecord(vector values); long write(fstream& outs); long read(fstream& ins, long recno); - vector get_records(); + std::size_t encoded_size() const { return MAX_VALUE_LENGTH * _records.size(); } vector get_records_string(){ return this->_records;} int column_size() { return this->_records.size(); } @@ -28,11 +26,9 @@ class FileRecord{ void resize(int size) { this->_records.resize(size); } friend ostream& operator << (ostream& outs, const FileRecord& r) { - int i = 0; - while (r._records[i][0] != '\0') - { - outs << setw(MAX / 4) << right << r._records[i]; - i++; + for (const auto& record : r._records) { + if (record.empty()) break; + outs << setw(MAX / 4) << right << record; } return outs; // return outs << setw(MAX / 4) << right << r._records[0] << setw(MAX / 4) << right << r._records[1] << setw(MAX / 4) << right << r._records[2]; @@ -40,7 +36,7 @@ class FileRecord{ private: // The maximum size of the record - static const int MAX = 100; + static const int MAX = static_cast(MAX_VALUE_LENGTH); // The record vector vector _records; diff --git a/includes/binary_files/utilities.cpp b/includes/binary_files/utilities.cpp index f12aedf..cfa9e4f 100644 --- a/includes/binary_files/utilities.cpp +++ b/includes/binary_files/utilities.cpp @@ -1,5 +1,7 @@ #include "utilities.h" +#include + bool file_exists(const char filename[]){ fstream f; f.open(filename, fstream::in | fstream::binary); @@ -14,7 +16,7 @@ bool file_exists(const char filename[]){ void open_fileRW(fstream& f, const char filename[]){ f.open(filename, ios::in | ios::out | ios::binary); if(!f.is_open()){ - throw "File could not be opened"; + throw std::runtime_error(std::string("File could not be opened: ") + filename); } } // Open a file for writing @@ -22,7 +24,7 @@ void open_fileRW(fstream& f, const char filename[]){ void open_fileW(fstream& f, const char filename[]){ f.open(filename, ios::out | ios::binary); if(!f.is_open()){ - throw "File could not be opened"; + throw std::runtime_error(std::string("File could not be opened: ") + filename); } } // Open a file for reading diff --git a/includes/map/bplustree.h b/includes/map/bplustree.h index 70cf938..098afe5 100644 --- a/includes/map/bplustree.h +++ b/includes/map/bplustree.h @@ -6,6 +6,7 @@ #include // Provides assert #include // Provides size_t #include // Provides std::to_string +#include #include "btree_array_functions.h" // Include the implementation. @@ -30,7 +31,7 @@ class BPlusTree{ } const Item operator *() const{ assert(this->it!= nullptr); - return this->it->data(this->key_ptr); + return this->it->data[this->key_ptr]; } // Postfix ++ (it++) @@ -161,6 +162,16 @@ class BPlusTree{ } else return nullptr; } + + const Item* get_Item(const Item& entry) const{ + const size_t i = first_ge(this->data, this->data_count, entry); + const bool found = (i < this->data_count) && !(entry < this->data[i]); + + if (this->is_leaf() && found) return &this->data[i]; + if (!this->is_leaf() && found) return this->subset[i + 1]->get_Item(entry); + if (!this->is_leaf()) return this->subset[i]->get_Item(entry); + return nullptr; + } }; @@ -261,8 +272,9 @@ bool BPlusTree::contains(const Item& entry) const template const Item &BPlusTree::get(const Item &entry) const { - if(!this->contains(entry))this->insert(entry); - return *this->get_Item(entry); + const Item* item = this->get_Item(entry); + if (!item) throw std::out_of_range("BPlusTree key does not exist"); + return *item; } template @@ -310,6 +322,7 @@ typename BPlusTree::Iterator BPlusTree::upper_bound(const Item &key) template typename BPlusTree::Iterator BPlusTree::begin() { + if (empty()) return end(); return BPlusTree::Iterator(this->get_smallest_node()); } @@ -575,7 +588,7 @@ bool BPlusTree::is_valid() const return false; } // check if the node has too many children - if (child_count > MAXIMUM+1 || child_count < 0) { + if (child_count > MAXIMUM+1) { return false; } // check if the data is sorted diff --git a/includes/parser/parser.cpp b/includes/parser/parser.cpp index a0aada2..7e4e3ef 100644 --- a/includes/parser/parser.cpp +++ b/includes/parser/parser.cpp @@ -1,5 +1,57 @@ #include "parser.h" +namespace { + +bool classify_unquoted_token(const SToken& token, Ptype& type) { + const string text = token.token_str(); + + if (token.type() == TOKEN_ALPHA) { + if (text == "create") type = CREATE; + else if (text == "table") type = TABLE; + else if (text == "fields") type = FIELDS; + else if (text == "insert") type = INSERT; + else if (text == "into") type = INTO; + else if (text == "values") type = VALUES; + else if (text == "select") type = SELECT; + else if (text == "from") type = FROM; + else if (text == "where") type = WHERE; + else if (text == "or" || text == "and") type = LOGICAL; + else type = LITERAL; + return true; + } + + if (token.type() == TOKEN_NUMBER) { + type = LITERAL; + return true; + } + + if (token.type() == TOKEN_OPERATOR) { + const bool is_relational = + text == "<=" || text == ">=" || text == "=" || + text == ">" || text == "<" || text == "!="; + if (!is_relational) return false; + type = RELATIONAL; + return true; + } + + if (token.type() == TOKEN_PAREN) { + type = PARENS; + return true; + } + if (token.type() == TOKEN_STAR && text == "*") { + type = ASTERISK; + return true; + } + if (token.type() == TOKEN_COMMA && text == ",") { + type = COMMAS; + return true; + } + + return false; +} + +} // namespace + //constructor Parser::Parser(){ this->tree = MMap(); @@ -154,68 +206,47 @@ void Parser::init_(){ // tokenize string into vector void Parser::tokenize(){ + bool inside_quote = false; + string quoted_literal; + while (this->token.more()){ - SToken token = SToken(); - this->token >> token; - if (token.type() == TOKEN_UNKNOWN && token.token_str() != "\"") continue; - if (token.type() == TOKEN_SPACE) continue; - // reserved keywords - bool did_push = false; - if (token.type() == TOKEN_ALPHA){ - std::size_t prev_size = this->types.size(); - if (token.token_str() == "create") this->types.push_back(CREATE); - if (token.token_str() == "table") this->types.push_back(TABLE); - if (token.token_str() == "fields") this->types.push_back(FIELDS); - if (token.token_str() == "insert") this->types.push_back(INSERT); - if (token.token_str() == "into") this->types.push_back(INTO); - if (token.token_str() == "values") this->types.push_back(VALUES); - if (token.token_str() == "select") this->types.push_back(SELECT); - if (token.token_str() == "from") this->types.push_back(FROM); - if (token.token_str() == "where") this->types.push_back(WHERE); - - if (prev_size != this->types.size()) did_push = true; + SToken current; + this->token >> current; + const string text = current.token_str(); + + if (inside_quote) { + if (text == "\"") { + this->input.emplace_back(quoted_literal, TOKEN_ALPHA); + this->types.push_back(LITERAL); + quoted_literal.clear(); + inside_quote = false; + } else { + quoted_literal += text; + } + continue; } - // logical and relational - if (token.type() == TOKEN_ALPHA && (token.token_str() == "or" || token.token_str() == "and")){ - did_push = true; - this->types.push_back(LOGICAL); + if (text == "\"") { + inside_quote = true; + continue; } - // =, !=, <, >, <= and >= - bool is_relational = (token.token_str() == "<=" || token.token_str() == ">=" || token.token_str() == "=" || token.token_str() == ">" || token.token_str() == "<" || token.token_str() == "!="); - if (token.type() == TOKEN_OPERATOR && is_relational) this->types.push_back(RELATIONAL); - - if (token.type() == TOKEN_PAREN) this->types.push_back(PARENS); - if (token.type() == TOKEN_STAR && token.token_str() == "*") this->types.push_back(ASTERISK); - if (token.type() == TOKEN_COMMA && token.token_str() == ",") this->types.push_back(COMMAS); - if (!did_push && (token.type() == TOKEN_ALPHA || token.type() == TOKEN_NUMBER)) this->types.push_back(LITERAL); - if (token.type() == TOKEN_UNKNOWN && token.token_str() == "\"") this->types.push_back(QUOTE); - this->input.push_back(token); - } + if (current.type() == TOKEN_SPACE) continue; - // process "" here, basically concat - vector clean_input; - vector clean_type; - for (std::size_t i = 0; i < this->input.size(); ++i){ - if (this->input[i].token_str() == "\""){ - std::size_t j = i + 1; - string s = ""; - while (this->input[j].type() == TOKEN_ALPHA){ - if (j != i + 1) s += " "; - s += this->input[j].token_str(); - ++j; - } - i = j; - clean_input.emplace_back(s, TOKEN_ALPHA); - clean_type.push_back(LITERAL); - continue; + Ptype type = LITERAL; + if (!classify_unquoted_token(current, type)) { + this->input.clear(); + this->types.clear(); + return; } - clean_input.push_back(this->input[i]); - clean_type.push_back(this->types[i]); + + this->input.push_back(current); + this->types.push_back(type); } - this->types = clean_type; - this->input = clean_input; + if (inside_quote) { + this->input.clear(); + this->types.clear(); + } } diff --git a/includes/sql/sql.cpp b/includes/sql/sql.cpp index e71b3a5..774fa7c 100644 --- a/includes/sql/sql.cpp +++ b/includes/sql/sql.cpp @@ -1,50 +1,81 @@ #include "sql.h" +#include +#include + +namespace { + +bool is_identifier(const string& value) { + if (value.empty()) return false; + const auto first = static_cast(value.front()); + if (!(std::isalpha(first) || value.front() == '_')) return false; + for (const char character : value) { + const auto byte = static_cast(character); + if (!(std::isalnum(byte) || character == '_')) return false; + } + return true; +} + +void require_identifier(const string& value, const string& label) { + if (!is_identifier(value)) { + throw std::invalid_argument(label + " must be a valid identifier"); + } +} + +} // namespace + // Constructor SQL::SQL(){ this->_table=Table(); this->_parser=Parser(); this->_error=false; + this->_last_error=""; } // Process commands from a file SQL::SQL(const char* file): SQL(){this->batch(file);} // Process a command and return the result table Table SQL::command(const string& cmd){ + _error = false; + _last_error.clear(); + try { + _parser.set_string(cmd); + MMap parsed_tree = _parser.parse_tree(); + _ptree = parsed_tree; + if (_ptree.empty()) { + throw std::invalid_argument("Invalid command syntax"); + } - this->_parser.set_string(cmd); - MMap parsed_tree = this->_parser.parse_tree(); - this->_ptree = parsed_tree; - - this->_error = _ptree.empty(); - if (this->_error) return _table; - - string table_name = parsed_tree["table_name"][0]; - string command = parsed_tree["command"][0]; - - if (command == "create"){ - vector fields = parsed_tree["fields"]; - _table = Table(table_name, fields); - - }else if (command == "insert"){ - _table = Table(table_name); - _table.insert_into(parsed_tree["values"]); - - }else if (command == "select"){ - - bool where_exists = parsed_tree.contains("where"); - // update for select * - vector selected_fields = parsed_tree["fields"]; - Table t = Table(table_name); - if (selected_fields[0] == "*") selected_fields = t.get_fields(); - - // basic select - if (!where_exists){_table = t.select(selected_fields); - }else{_table = t.select(selected_fields, parsed_tree["condition"]);} - + const string table_name = parsed_tree["table_name"][0]; + const string command_name = parsed_tree["command"][0]; + require_identifier(table_name, "Table name"); + + if (command_name == "create") { + const vector fields = parsed_tree["fields"]; + for (const auto& field : fields) require_identifier(field, "Field name"); + _table = Table(table_name, fields); + } else if (command_name == "insert") { + _table = Table(table_name); + _table.insert_into(parsed_tree["values"]); + } else if (command_name == "select") { + const bool where_exists = parsed_tree.contains("where"); + vector selected_fields = parsed_tree["fields"]; + Table table(table_name); + if (selected_fields[0] == "*") selected_fields = table.get_fields(); + if (!where_exists) { + _table = table.select(selected_fields); + } else { + _table = table.select(selected_fields, parsed_tree["condition"]); + } + } else { + throw std::invalid_argument("Unsupported command"); + } + } catch (const std::exception& error) { + _error = true; + _last_error = error.what(); + _table = Table(); } return _table; - } @@ -54,11 +85,13 @@ void SQL::batch(const char* file){ if (f.fail()){ this->_error = true; + this->_last_error = string("Batch file does not exist: ") + file; cout << "No file named " << file << " exists." << endl; return; } this->_error = false; + this->_last_error.clear(); cout << "------------------------------Batch Begins------------------------------" << endl; string str; while (getline(f, str)){ @@ -67,6 +100,11 @@ void SQL::batch(const char* file){ cout << "command:" << str << endl; this->_table = this->command(str); + if (this->_error) { + cout << "Error: " << this->_last_error << endl << endl; + continue; + } + cout << this->_table << endl; //this._table.print_lookup(); cout << "records selected: " << select_recnos() << endl; diff --git a/includes/sql/sql.h b/includes/sql/sql.h index 6d127d1..05b7578 100644 --- a/includes/sql/sql.h +++ b/includes/sql/sql.h @@ -26,13 +26,15 @@ class SQL{ // void print_lookup() { _command.print_lookup(); } void batch(const char* file); inline bool is_error() const { return this->_error; } + inline const string& last_error() const { return this->_last_error; } void print_lookup() { this->_ptree.print_lookup(); } private: Table _table; Parser _parser; bool _error; + string _last_error; MMap _ptree; }; -#endif // SQL_H \ No newline at end of file +#endif // SQL_H diff --git a/includes/table/table.cpp b/includes/table/table.cpp index 9f3beb2..b07dbe7 100644 --- a/includes/table/table.cpp +++ b/includes/table/table.cpp @@ -1,5 +1,9 @@ #include "table.h" +#include +#include +#include + // Default constructor for SELECT queries Table::Table() { numRecords = 0; @@ -17,6 +21,21 @@ string Table::create_table(const string& table_name, const vector& field Table::Table(const string& table_name, const vector& field_names) : Table() { string table_file = table_name + ".bin"; string field_file = table_name + "_fields.bin"; + if (file_exists(table_file.c_str()) || file_exists(field_file.c_str())) { + throw std::runtime_error("Table already exists: " + table_name); + } + if (field_names.empty() || field_names.size() > MAX_FIELDS) { + throw std::invalid_argument("A table must have between 1 and 64 fields"); + } + std::set unique_fields; + for (const auto& field : field_names) { + if (field.empty() || field.size() > FileRecord::MAX_VALUE_LENGTH) { + throw std::invalid_argument("Field names must be between 1 and 100 bytes"); + } + if (!unique_fields.insert(field).second) { + throw std::invalid_argument("Duplicate field name: " + field); + } + } tableName = table_name; fieldNames = field_names; selectedFields = field_names; @@ -52,6 +71,9 @@ Table::Table(const string& table_name, const vector& field_names) : Tabl Table::Table(const string& table_name) : Table() { string table_file = table_name + ".bin"; string field_file = table_name + "_fields.bin"; + if (!file_exists(table_file.c_str()) || !file_exists(field_file.c_str())) { + throw std::runtime_error("Table does not exist: " + table_name); + } tableName = table_name; fstream f; @@ -60,13 +82,31 @@ Table::Table(const string& table_name) : Table() { // Determine the fields open_fileRW(f, field_file.c_str()); r.resize(1); - // Get the entry size first - r.read(f, 0); - int size = stoi(r.get_records_string()[0]); + if (r.read(f, 0) != static_cast(r.encoded_size())) { + throw std::runtime_error("Field metadata is incomplete for table: " + table_name); + } + const string raw_size = r.get_records_string()[0]; + std::size_t parsed = 0; + unsigned long raw_count = 0; + try { + raw_count = std::stoul(raw_size, &parsed); + } catch (const std::exception&) { + throw std::runtime_error("Field metadata is invalid for table: " + table_name); + } + if (parsed != raw_size.size() || raw_count < 1 || raw_count > MAX_FIELDS) { + throw std::runtime_error("Field count is invalid for table: " + table_name); + } + const auto size = static_cast(raw_count); r.resize(size + 1); + if (std::filesystem::file_size(field_file) != r.encoded_size()) { + throw std::runtime_error("Field metadata size is invalid for table: " + table_name); + } // Read the field names - r.read(f, 0); + f.clear(); + if (r.read(f, 0) != static_cast(r.encoded_size())) { + throw std::runtime_error("Field metadata is incomplete for table: " + table_name); + } vector field_names = r.get_records_string(); auto it = field_names.begin(); // Exclude the size mark @@ -76,21 +116,29 @@ Table::Table(const string& table_name) : Table() { selectedFields = field_names; f.close(); + std::set unique_fields; + for (const auto& field : fieldNames) { + if (field.empty() || !unique_fields.insert(field).second) { + throw std::runtime_error("Field metadata contains invalid names for table: " + table_name); + } + } + for (std::size_t i = 0; i < fieldNames.size(); ++i) { fieldNameMap[fieldNames[i]] = i; } // Resize the vector r.resize(fieldNames.size()); + const auto file_bytes = std::filesystem::file_size(table_file); + if (file_bytes % r.encoded_size() != 0) { + throw std::runtime_error("Record file contains a partial row for table: " + table_name); + } open_fileRW(f, table_file.c_str()); // Read every record - for (long i = 0; r.read(f, i) > 0; ++i) { - // Read ith entry - long bytes = r.read(f, i); - if (bytes == 0) { - break; - } + for (long i = 0;; ++i) { + const long bytes = r.read(f, i); + if (bytes == 0) break; // Increase the number of records ++numRecords; @@ -109,21 +157,19 @@ Table::Table(const string& table_name) : Table() { f.close(); } -Table& Table::operator=(const Table& RHS){ - if (this == &RHS) return *this; - tableName = RHS.tableName; - cache = RHS.cache; - recordIndices = RHS.recordIndices; - printQueue = RHS.printQueue; - fieldNames = RHS.fieldNames; - selectedFields = RHS.selectedFields; - numRecords = RHS.numRecords; - return *this; -} - - // Function to insert data into the table string Table::insert_into(const vector& field_values) { + if (field_values.size() != fieldNames.size()) { + throw std::invalid_argument( + "Expected " + std::to_string(fieldNames.size()) + + " values, received " + std::to_string(field_values.size()) + ); + } + for (const auto& value : field_values) { + if (value.size() > FileRecord::MAX_VALUE_LENGTH) { + throw std::length_error("Values must not exceed 100 bytes"); + } + } // Insert data into binary file fstream f; string table_file = tableName + ".bin"; @@ -178,8 +224,9 @@ ostream& operator<<(ostream& outs, const Table& print_me){ // Helper function to select records based on a given condition vector Table::selectHelp(const string& field_name, const string& op, const string& field_value) { - // If the cache does not contain the field_name, return an empty vector - if (!cache.contains(field_name)) {return {};} + if (!cache.contains(field_name)) { + throw std::invalid_argument("Unknown field: " + field_name); + } // If the operation is "=", return the record indices for the matching field_value else if (op == "=" && cache[field_name].contains(field_value)) { return cache[field_name][field_value]; @@ -219,6 +266,7 @@ vector Table::selectHelp(const string& field_name, const string& op, const Table Table::select(const vector& selected_fields, const string& field_name, const string& op, const string& field_value){ Table temp; + validate_projection(selected_fields); // select all fields if (selected_fields.empty() || selected_fields[0] == "*"){ @@ -259,7 +307,6 @@ Table Table::select(const vector& selected_fields, const string& field_n temp.printQueue += entry[i]; } } - cout << "print:" << temp.printQueue << endl; f.close(); return temp; @@ -272,20 +319,29 @@ Table Table::select(const vector& selected_fields, const vector& ShuntingYard sy(infix); Queue postfix = sy.postfix(); - Table temp = select(selected_fields, postfix); - - if (!infix.empty()){ + const auto release_tokens = [&infix]() { + if (infix.empty()) return; typename Queue::Iterator it; - for (it = infix.begin(); it != infix.end(); ++it){ - delete *it; - } + for (it = infix.begin(); it != infix.end(); ++it) delete *it; infix.clear(); + }; + if (sy.is_error()) { + release_tokens(); + throw std::invalid_argument("Invalid parenthesis structure in WHERE clause"); + } + try { + Table temp = select(selected_fields, postfix); + release_tokens(); + return temp; + } catch (...) { + release_tokens(); + throw; } - return temp; } Table Table::select(const vector& selected_fields, const Queue& expression){ Table temp; + validate_projection(selected_fields); // select all fields if (selected_fields.empty() || selected_fields[0] == "*"){ @@ -334,6 +390,15 @@ Table Table::select(const vector& selected_fields, const Queue& return temp; } +void Table::validate_projection(const vector& fields) const { + if (fields.empty() || (fields.size() == 1 && fields[0] == "*")) return; + for (const auto& field : fields) { + if (!fieldNameMap.contains(field)) { + throw std::invalid_argument("Unknown selected field: " + field); + } + } +} + // Function to evaluate the postfix expression and return the record indices that meet the conditions vector Table::Eval(const Queue& postfix) { Stack s; @@ -351,6 +416,9 @@ vector Table::Eval(const Queue& postfix) { } // If the token is a relational operator, select records from the cache else if (token->token_type() == TOKEN_RELATIONAL) { + if (s.size() < 2) { + throw std::invalid_argument("Malformed relational expression"); + } string relational_op = token->token_string(); string field_value = s.pop()->token_string(); // Pop the field_value first string field_name = s.pop()->token_string(); // Pop the field_name second @@ -361,6 +429,9 @@ vector Table::Eval(const Queue& postfix) { } // If the token is a logical operator, perform set operations on the record indices else if (token->token_type() == TOKEN_LOGICAL) { + if (indices.size() < 2) { + throw std::invalid_argument("Malformed logical expression"); + } vector first = indices.pop(); vector second = indices.pop(); @@ -380,5 +451,8 @@ vector Table::Eval(const Queue& postfix) { } // Return the final result (the record indices that meet the conditions) + if (!s.empty() || indices.size() != 1) { + throw std::invalid_argument("Malformed WHERE expression"); + } return indices.top(); } diff --git a/includes/table/table.h b/includes/table/table.h index d41545b..f0ef980 100644 --- a/includes/table/table.h +++ b/includes/table/table.h @@ -15,6 +15,7 @@ #include #include // vector #include +#include #include "../binary_files/file_record.h" #include "../binary_files/utilities.h" @@ -95,8 +96,9 @@ class Table{ Table(); Table(const string& table_name); Table(const string& table_name, const vector& fieldNames); - Table& operator=(const Table& RHS); - ~Table() {} + Table(const Table&) = default; + Table& operator=(const Table&) = default; + ~Table() = default; // SQL: CREATE TABLE string create_table(const string& tableName, const vector& fieldNames); @@ -121,6 +123,7 @@ class Table{ // Get the number of records in the table inline long record_count() const { return select_recnos().size(); } private: + static constexpr std::size_t MAX_FIELDS = 64; // iterator typedef typedef MMap::Iterator mmap_iter; // title of the table @@ -160,6 +163,7 @@ class Table{ vector selectHelp(const string& field_name, const string& op, const string& field_value); // change name vector Eval(const Queue& eva); + void validate_projection(const vector& fields) const; // static vector get_record_indices(const mmap_iter& begin, const mmap_iter& end){ vector res; diff --git a/run_sql.cpp b/run_sql.cpp index 4da0377..b38906b 100644 --- a/run_sql.cpp +++ b/run_sql.cpp @@ -10,9 +10,10 @@ void displayInstructions() { void clearScreen() { #ifdef _WIN32 - system("cls"); + const int result = system("cls"); + if (result != 0) cerr << "Unable to clear the terminal." << endl; #else - system("clear"); + cout << "\033[2J\033[H" << flush; #endif } @@ -36,7 +37,7 @@ int main() { Table tb = sql.command(input); if (sql.is_error()) { - cout << "Error: Unable to process the command." << endl; + cout << "Error: " << sql.last_error() << endl; continue; } @@ -77,4 +78,4 @@ int main() { //> select * from student //> select * from student where (major=CS or major=Art) //> select * from student where lname>J -//> select * from student where lname>J and (major=CS or major=Art) \ No newline at end of file +//> select * from student where lname>J and (major=CS or major=Art) diff --git a/tests/bplustree_tests.cpp b/tests/bplustree_tests.cpp new file mode 100644 index 0000000..07f1801 --- /dev/null +++ b/tests/bplustree_tests.cpp @@ -0,0 +1,163 @@ +#include "includes/map/bplustree.h" + +#include +#include +#include +#include +#include +#include +#include + +namespace { + +void expect(bool condition, const std::string& message) { + if (!condition) { + throw std::runtime_error(message); + } +} + +std::vector values(BPlusTree& tree) { + std::vector result; + for (auto iterator = tree.begin(); iterator != tree.end(); ++iterator) { + result.push_back(*iterator); + } + return result; +} + +std::vector values(const std::set& reference) { + return {reference.begin(), reference.end()}; +} + +void expect_matches(BPlusTree& tree, const std::set& reference) { + expect(tree.is_valid(), "B+ tree invariants should hold"); + expect(tree.size() == reference.size(), "tree size should match std::set"); + expect(values(tree) == values(reference), "iteration should be ordered and complete"); +} + +void test_fixed_seed_insertions_and_queries() { + BPlusTree tree; + std::set reference; + std::mt19937 generator(0x5EED1234U); + std::uniform_int_distribution distribution(-50, 250); + + for (int iteration = 0; iteration < 2'000; ++iteration) { + const int value = distribution(generator); + const bool actual_inserted = tree.insert(value); + const bool expected_inserted = reference.insert(value).second; + expect( + actual_inserted == expected_inserted, + "duplicate insertion result should match std::set" + ); + expect(tree.is_valid(), "invariants should hold after every insertion"); + } + + expect_matches(tree, reference); + + const BPlusTree& const_tree = tree; + expect( + const_tree.get(*reference.begin()) == *reference.begin(), + "const lookup should return an existing key" + ); + bool missing_lookup_threw = false; + try { + static_cast(const_tree.get(10'000)); + } catch (const std::out_of_range&) { + missing_lookup_threw = true; + } + expect(missing_lookup_threw, "const lookup should reject a missing key"); + + for (int query = -75; query <= 275; ++query) { + expect( + tree.contains(query) == (reference.count(query) == 1), + "contains should match std::set" + ); + + auto actual_find = tree.find(query); + const auto expected_find = reference.find(query); + if (expected_find == reference.end()) { + expect(actual_find == tree.end(), "find should return end for a missing key"); + } else { + expect(actual_find != tree.end(), "find should locate an existing key"); + expect(*actual_find == *expected_find, "find should return the expected key"); + } + + auto actual_lower = tree.lower_bound(query); + const auto expected_lower = reference.lower_bound(query); + if (expected_lower == reference.end()) { + expect(actual_lower == tree.end(), "lower_bound should return end when appropriate"); + } else { + expect(actual_lower != tree.end(), "lower_bound should locate the expected key"); + expect(*actual_lower == *expected_lower, "lower_bound should match std::set"); + } + + auto actual_upper = tree.upper_bound(query); + const auto expected_upper = reference.upper_bound(query); + if (expected_upper == reference.end()) { + expect(actual_upper == tree.end(), "upper_bound should return end when appropriate"); + } else { + expect(actual_upper != tree.end(), "upper_bound should locate the expected key"); + expect(*actual_upper == *expected_upper, "upper_bound should match std::set"); + } + } + + BPlusTree copied(tree); + expect_matches(copied, reference); + + tree.insert(1'000); + expect(!copied.contains(1'000), "copy construction should produce independent storage"); + expect_matches(copied, reference); + + BPlusTree assigned; + assigned.insert(-1'000); + assigned = tree; + std::set assigned_reference = reference; + assigned_reference.insert(1'000); + expect_matches(assigned, assigned_reference); + + tree.insert(2'000); + expect(!assigned.contains(2'000), "copy assignment should produce independent storage"); + expect_matches(assigned, assigned_reference); + +} + +void test_empty_iteration_and_shuffled_erase() { + BPlusTree empty; + expect(empty.begin() == empty.end(), "an empty tree should have an empty range"); + + BPlusTree tree; + std::set reference; + std::mt19937 generator(0xE2A5E123U); + std::uniform_int_distribution distribution(-500, 500); + for (int iteration = 0; iteration < 2'000; ++iteration) { + const int value = distribution(generator); + tree.insert(value); + reference.insert(value); + } + expect_matches(tree, reference); + + std::vector removal_order(reference.begin(), reference.end()); + std::shuffle(removal_order.begin(), removal_order.end(), generator); + for (const int value : removal_order) { + expect(tree.erase(value), "existing key erase should succeed"); + reference.erase(value); + expect_matches(tree, reference); + expect(!tree.erase(value), "missing key erase should fail without mutation"); + } + + expect(tree.empty(), "erasing every key should leave an empty tree"); + expect(tree.begin() == tree.end(), "erased tree should have an empty range"); +} + +} // namespace + +int main() { + try { + test_fixed_seed_insertions_and_queries(); + test_empty_iteration_and_shuffled_erase(); + std::cout << "PASS: deterministic B+ tree evidence" << std::endl; + return 0; + } catch (const std::exception& error) { + std::cerr << "FAIL: " << error.what() << std::endl; + return 1; + } +} diff --git a/tests/parser_tests.cpp b/tests/parser_tests.cpp new file mode 100644 index 0000000..21005ee --- /dev/null +++ b/tests/parser_tests.cpp @@ -0,0 +1,100 @@ +#include "includes/parser/parser.h" + +#include +#include +#include +#include + +namespace { + +void expect_true(bool value, const std::string& message) { + if (!value) { + throw std::runtime_error(message); + } +} + +void expect_values( + MMap& tree, + const std::string& key, + const std::vector& expected, + const std::string& message +) { + if (!tree.contains(key) || tree.at(key) != expected) { + throw std::runtime_error(message); + } +} + +void test_quoted_literal_preserves_spaces_numbers_and_punctuation() { + Parser parser("insert into rooms values \"Room 101, West\""); + auto tree = parser.parse_tree(); + + expect_values( + tree, + "values", + {"Room 101, West"}, + "quoted insert value should remain one literal" + ); +} + +void test_quoted_keywords_remain_one_literal() { + Parser parser("insert into rooms values \"select from where, 101!\""); + auto tree = parser.parse_tree(); + + expect_values( + tree, + "values", + {"select from where, 101!"}, + "quoted keywords and punctuation should remain literal text" + ); +} + +void test_unmatched_quote_is_rejected() { + Parser parser("insert into rooms values \"Room 101, West"); + auto tree = parser.parse_tree(); + + expect_true(tree.empty(), "an unmatched quote should reject the statement"); + expect_true(parser.get_input().empty(), "an unmatched quote should discard partial tokens"); +} + +void test_unknown_unquoted_character_is_rejected() { + Parser parser("insert into rooms values Room@"); + auto tree = parser.parse_tree(); + + expect_true(tree.empty(), "an unknown unquoted character should reject the statement"); + expect_true( + parser.get_input().empty(), + "an unknown unquoted character should discard partial tokens" + ); +} + +void test_existing_select_grammar_accepts_a_quoted_condition() { + Parser parser("select name from rooms where label = \"Room 101, West\""); + auto tree = parser.parse_tree(); + + expect_values(tree, "command", {"select"}, "select command should remain supported"); + expect_values(tree, "fields", {"name"}, "select field should remain supported"); + expect_values(tree, "table_name", {"rooms"}, "select table should remain supported"); + expect_values( + tree, + "condition", + {"label", "=", "Room 101, West"}, + "quoted condition should remain one literal" + ); +} + +} // namespace + +int main() { + try { + test_quoted_literal_preserves_spaces_numbers_and_punctuation(); + test_quoted_keywords_remain_one_literal(); + test_unmatched_quote_is_rejected(); + test_unknown_unquoted_character_is_rejected(); + test_existing_select_grammar_accepts_a_quoted_condition(); + std::cout << "PASS: parser quote and invalid-token handling" << std::endl; + return 0; + } catch (const std::exception& error) { + std::cerr << "FAIL: " << error.what() << std::endl; + return 1; + } +} diff --git a/tests/sql_tests.cpp b/tests/sql_tests.cpp index be248d6..1c9ce93 100644 --- a/tests/sql_tests.cpp +++ b/tests/sql_tests.cpp @@ -1,6 +1,8 @@ #include "includes/sql/sql.h" +#include #include +#include #include #include #include @@ -33,6 +35,15 @@ void expect_records( } } +void expect_error(SQL& sql, const std::string& message_fragment) { + expect_true(sql.is_error(), "command should fail"); + expect_true( + sql.last_error().find(message_fragment) != std::string::npos, + "error should contain: " + message_fragment + ", received: " + + sql.last_error() + ); +} + void remove_table(const std::string& name) { std::filesystem::remove(name + ".bin"); std::filesystem::remove(name + "_fields.bin"); @@ -118,6 +129,174 @@ void test_second_batch_fixture_and_missing_file_errors() { remove_table("studentSelect02"); } +void test_command_failures_do_not_terminate_or_mutate_storage() { + remove_table("integrity_test"); + + SQL missing; + missing.command("select * from missing_table"); + expect_error(missing, "does not exist"); + + SQL sql; + sql.command("create table integrity_test fields first, last"); + expect_true(!sql.is_error(), "valid create should succeed"); + const auto empty_size = std::filesystem::file_size("integrity_test.bin"); + + sql.command("insert into integrity_test values only_one"); + expect_error(sql, "Expected 2 values, received 1"); + expect_equal( + static_cast(std::filesystem::file_size("integrity_test.bin")), + static_cast(empty_size), + "short insert must not change storage" + ); + + sql.command("insert into integrity_test values one, two, three"); + expect_error(sql, "Expected 2 values, received 3"); + expect_equal( + static_cast(std::filesystem::file_size("integrity_test.bin")), + static_cast(empty_size), + "long insert must not change storage" + ); + + sql.command("insert into integrity_test values Ada, Lovelace"); + expect_true(!sql.is_error(), "valid insert should succeed after errors"); + expect_equal( + static_cast(std::filesystem::file_size("integrity_test.bin")), + 200, + "two-field row should use two fixed-width slots" + ); + { + std::ifstream stored("integrity_test.bin", std::ios::binary); + std::vector bytes(200); + stored.read(bytes.data(), static_cast(bytes.size())); + expect_true(stored.gcount() == 200, "stored row should be complete"); + expect_true( + std::all_of(bytes.begin() + 3, bytes.begin() + 100, [](char byte) { + return byte == '\0'; + }), + "short values should be deterministically zero padded" + ); + } + + sql.command("create table integrity_test fields replacement"); + expect_error(sql, "already exists"); + SQL reopened; + const Table persisted = reopened.command("select * from integrity_test"); + expect_true(!reopened.is_error(), "existing data should survive rejected create"); + expect_equal(persisted.record_count(), 1, "rejected create preserves rows"); + + Table opened("integrity_test"); + Table assigned; + assigned = opened; + const Table assigned_projection = assigned.select({"first"}); + expect_true( + assigned_projection.get_fields() == std::vector({"first"}), + "table assignment should preserve field lookup metadata" + ); + + sql.command("select unknown from integrity_test"); + expect_error(sql, "Unknown selected field"); + sql.command("select * from integrity_test where unknown = Ada"); + expect_error(sql, "Unknown field"); + + remove_table("integrity_test"); +} + +void test_schema_value_and_corrupt_file_boundaries() { + remove_table("boundary_test"); + + SQL sql; + sql.command("create table boundary_test fields value, value"); + expect_error(sql, "Duplicate field name"); + expect_true( + !std::filesystem::exists("boundary_test.bin"), + "invalid schema must not create a data file" + ); + + sql.command("create table boundary_test fields value"); + expect_true(!sql.is_error(), "valid boundary table should be created"); + const std::string exact_value(100, 'x'); + sql.command("insert into boundary_test values \"" + exact_value + "\""); + expect_true(!sql.is_error(), "100-byte value should fit the fixed slot"); + const auto valid_size = std::filesystem::file_size("boundary_test.bin"); + + const std::string oversized_value(101, 'y'); + sql.command("insert into boundary_test values \"" + oversized_value + "\""); + expect_error(sql, "must not exceed 100 bytes"); + expect_equal( + static_cast(std::filesystem::file_size("boundary_test.bin")), + static_cast(valid_size), + "oversized value must not change storage" + ); + + { + std::ofstream corrupt("boundary_test.bin", std::ios::binary | std::ios::app); + corrupt.put('x'); + } + SQL corrupt_reader; + corrupt_reader.command("select * from boundary_test"); + expect_error(corrupt_reader, "partial row"); + + remove_table("boundary_test"); +} + +void test_malformed_filters_fail_closed() { + remove_table("filter_test"); + SQL sql; + sql.command("create table filter_test fields value"); + sql.command("insert into filter_test values x"); + sql.command("insert into filter_test values y"); + + const Table result = sql.command("select * from filter_test where value = x)"); + expect_error(sql, "parenthesis"); + expect_equal(result.record_count(), 0, "invalid filter must not return all rows"); + + bool direct_expression_failed = false; + try { + Table opened("filter_test"); + static_cast(opened.select({"value"}, {"value", "="})); + } catch (const std::invalid_argument&) { + direct_expression_failed = true; + } + expect_true( + direct_expression_failed, + "malformed direct expressions should throw instead of underflowing a stack" + ); + + remove_table("filter_test"); +} + +void test_schema_metadata_size_must_match_exactly() { + remove_table("metadata_extra_test"); + remove_table("metadata_short_test"); + + SQL sql; + sql.command("create table metadata_extra_test fields value"); + { + std::ofstream extra( + "metadata_extra_test_fields.bin", + std::ios::binary | std::ios::app + ); + extra.put('x'); + } + SQL extra_reader; + extra_reader.command("select * from metadata_extra_test"); + expect_error(extra_reader, "metadata size is invalid"); + + sql.command("create table metadata_short_test fields value"); + const auto metadata_size = + std::filesystem::file_size("metadata_short_test_fields.bin"); + std::filesystem::resize_file( + "metadata_short_test_fields.bin", + metadata_size - 1 + ); + SQL short_reader; + short_reader.command("select * from metadata_short_test"); + expect_error(short_reader, "metadata size is invalid"); + + remove_table("metadata_extra_test"); + remove_table("metadata_short_test"); +} + } // namespace int main() { @@ -128,6 +307,14 @@ int main() { std::cout << "PASS: repository batch fixture" << std::endl; test_second_batch_fixture_and_missing_file_errors(); std::cout << "PASS: second batch fixture and errors" << std::endl; + test_command_failures_do_not_terminate_or_mutate_storage(); + std::cout << "PASS: command failures preserve storage" << std::endl; + test_schema_value_and_corrupt_file_boundaries(); + std::cout << "PASS: schema, value, and corrupt file boundaries" << std::endl; + test_malformed_filters_fail_closed(); + std::cout << "PASS: malformed filters fail closed" << std::endl; + test_schema_metadata_size_must_match_exactly(); + std::cout << "PASS: schema metadata size is exact" << std::endl; return 0; } catch (const std::exception& error) { std::cerr << "FAIL: " << error.what() << std::endl;