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;