简介:这份山东科技大学《数据库系统概论》课程设计实验报告,面向计算机科学与技术专业学生及需要完成 DBMS 基础实践的学习者,聚焦简单 DBMS 中创建表与修改表两项核心功能的实现。报告围绕 CREATE TABLE、ALTER TABLE 的语句解析、表结构存储与动态修改展开,涵盖需求分析、设计思想、物理存储结构、程序流程及命令行与图形化界面等任务要求,可作为课程设计写作与功能拆解的参考范例。资源包共 1 个 doc 文件,约 408KB,内容集中,便于直接查阅与打印。已有 934 人学习,说明其在同类课程设计选题中具有一定参考价值。读者可从中了解表名、列名、数据类型与完整性约束的提取思路,以及 table.txt 持久化表信息、显示已建表、保存退出等模块的组织方式,并借此梳理从任务书到实验报告的结构脉络,为数据库系统概论课程实践提供可复用的写作框架与实现思路。
1. 从一条 CREATE TABLE 说起:这套课程设计资源到底能跑出什么
答辩前一周,某高校计算机专业的一位同学抱着笔记本过来,说他的数据库系统课程设计能建表,但一执行 ALTER TABLE 就整张表消失。翻完代码发现不是逻辑不会,而是 table.txt 的读写顺序和内存数组对不上。这类“实现一个简单 DBMS”的题目通常只要求 CREATE TABLE 和 ALTER TABLE 两条语句,但真正卡人的是词法切分、字段约束识别和表结构持久化。这份资源适合正在做数据库系统课程设计、需要一套能跑通的 C++ 参考实现的人,也适合想搞清楚 DBMS 系统目录到底怎么存表结构的初学者。下面按我拆代码的顺序,从存储结构、解析链路、修改表、排错到验证,一步步走完。
2. 表结构怎么落盘:并行字符数组加一个文本文件的物理存储设计
这套实现最核心的选择,是没有用结构体,而是用几组并行的三维字符数组来存所有表信息。刚看到char tableName[LEN][MAX]、char fieldName[LEN][LEN][MAX]这种写法会觉得别扭,但它和课程设计的需求是匹配的:表数量有限、字段数量有限、每个名称长度有限,数组足够,而且省掉了链表和动态内存的释放问题。真正要讲清楚的是每个维度的含义,以及为什么文件读写必须和数组布局严格镜像。
2.1 并行数组的维度含义与容量边界
先看一组可以直接编译的定义。这里的LEN、MAX、COL不是随便取的,它们决定了这套 DBMS 能承受多少张表、每张表多少列、每个标识符多长。课程设计通常给一个班几十人演示,取 32 张表、32 列、64 字符已经有余量;如果取太小,演示到第三张表就下标越界,取太大又浪费栈空间,尤其是把数组定义在main里的时候。
#include <iostream> #include <fstream> #include <cstring> #include <cctype> using namespace std; const int MAX = 64; // 单个表名/字段名/类型串的最大长度 const int LEN = 32; // 最多支持的表数量 const int COL = 32; // 单表最多字段数 char tableName[LEN][MAX]; // 每张表的表名 int colCount[LEN]; // 每张表的字段个数 char fieldName[LEN][COL][MAX]; // [表][列] 字段名 char fieldType[LEN][COL][MAX]; // [表][列] 类型:int/char/float/double/date char fieldLen[LEN][COL][MAX]; // [表][列] 长度串,如 "(50)",无长度为空串 char fieldCond[LEN][COL][MAX]; // [表][列] 约束:primary_key/unique/not_null/NULL int tableCount = 0; // 当前已存在的表数量参数说明上,tableCount是所有操作的“当前水位”,新建表时它是新表下标,保存时它是循环上界,读取时它从文件里恢复。colCount[i]是第 i 张表的字段数,ALTER TABLE ADD 之后要加一,DROP 之后要减一,显示表信息时用它控制内层循环。fieldLen单独存成带括号的字符串,是为了显示时直接拼在类型后面,比如char(20);如果只存数字,显示逻辑还要再补括号,反而多一处分支。约束在内存和文件里统一用下划线形式,是因为后面用>>逐词读取时,带空格的primary key会被拆成两个 token,读出来就串位了。
提示:数组维度顺序一旦定下,所有函数都必须按
[表][列]访问。我见过把fieldName[ti][j]写成fieldName[j][ti]的版本,小数据量下不报错,表一多就出现“改 A 表字段,B 表跟着变”的玄学现象。
2.2 table.txt 的格式设计与读写镜像
文件格式不需要复杂,但必须满足一个条件:写入顺序和读取顺序完全一致。这套设计采用“先表数、再逐表、表内先表名和列数、再逐列四元组”的结构。第一行是表数量,后面每张表先读表名和字段数,再循环读每个字段的名字、类型、长度、约束。这样读取时不需要任何回溯,两个嵌套循环就能还原全部数组。
bool saveAll(const char* path) { ofstream out(path, ios::out | ios::trunc); // trunc:全量重写,先清空 if (!out) return false; out << tableCount << '\n'; for (int i = 0; i < tableCount; ++i) { out << tableName[i] << '\n' << colCount[i] << '\n'; for (int j = 0; j < colCount[i]; ++j) { out << fieldName[i][j] << ' ' << fieldType[i][j] << ' ' << (fieldLen[i][j][0] ? fieldLen[i][j] : "-") << ' ' << fieldCond[i][j] << '\n'; } } out.close(); return true; }逻辑说明:ios::trunc是关键,它保证每次保存都从空文件开始写,不会把旧内容留在后面。原文思路里提到“修改表之前先把表中数据清除,再重新存储”,落到代码就是这一句。参数上,path建议用相对路径./table.txt,不要写死到某台机器的桌面目录,否则换一台机器演示直接读不到文件。字段长度为空时写一个-占位,是为了让读取端用统一的>>连续读四个 token,不需要判断“这一行有没有长度”。
bool loadAll(const char* path) { ifstream in(path, ios::in); if (!in) return false; // 首次运行没有文件,属于正常情况 tableCount = 0; if (!(in >> tableCount)) return true; if (tableCount < 0 || tableCount > LEN) { tableCount = 0; return false; } for (int i = 0; i < tableCount; ++i) { in >> tableName[i] >> colCount[i]; if (colCount[i] < 0 || colCount[i] > COL) { tableCount = i; return false; } for (int j = 0; j < colCount[i]; ++j) { in >> fieldName[i][j] >> fieldType[i][j] >> fieldLen[i][j] >> fieldCond[i][j]; if (strcmp(fieldLen[i][j], "-") == 0) fieldLen[i][j][0] = '\0'; } } return true; }这里没有用while(!in.eof()),而是先读表数量,再用计数循环。eof()的标志位是在一次读取失败后才置位的,用while(!eof())往往会在最后多跑一轮,把上一轮的字段数或表名重复写进数组,表现就是“重启程序后多出一张空表”。读取端还加了tableCount和colCount的范围检查,防止手改 table.txt 之后把数组写穿。
2.3 全量重写与原地修改的取舍
ALTER TABLE 修改表结构后,怎么落盘有两条路:一是定位到文件里某张表的位置,原地覆盖那几个字节;二是把内存数组全部重写一遍。这套实现选的是后者,我认为对课程设计来说是正确选择。原地修改要求文件格式定长、字段数变化时还要移动后续内容,C 标准库的随机读写很容易把文件写花;全量重写虽然多写几次磁盘,但逻辑只有“清空、循环、关闭”三步,出错面小得多。
代价也有:如果程序在保存前崩溃,内存里的修改全丢。课程设计阶段可以接受,因为演示时都是“改完立刻保存”。如果要做成稍微像样的版本,可以写临时文件table.tmp,写完rename覆盖table.txt,这样至少不会出现写一半的半截文件。图形界面那部分不用另写一套解析,按钮回调里拼出同样格式的 SQL 字符串,丢给同一个parseCreate或parseAlter就行,核心仍然是这套数组和文件读写。
3. CREATE TABLE 解析:关键字判定、字段抽取与约束识别的完整链路
建表语句的解析是整套代码里最容易写成一锅粥的部分。原文的做法是边扫描边判断create、table,再用do-while逐字段提取。这个思路能跑,但分支深、变量多,一处下标写错就整段崩。我的建议是把“切词”和“语义判断”分开:先用一个nextToken把 SQL 串切成 token,再按顺序判断关键字、表名、字段名、类型、长度、约束。这样每个函数只做一件事,调试时也容易在 token 级别打印。
3.1 先切词再比对,别边扫边判
nextToken的行为要明确:跳过前导空格,读到空格、逗号、分号、左括号或右括号就停,但不吃掉这些分隔符。这样调用方可以看到当前停在哪个分隔符上,决定下一步是继续读字段还是结束。关键字统一转小写再比较,标识符保留原样,避免把用户写的表名Student改成student之后显示不一致。
#include <string> static std::string lower(std::string s) { for (size_t i = 0; i < s.size(); ++i) s[i] = (char)tolower((unsigned char)s[i]); return s; } // 从 sql 的 pos 位置取一个 token,pos 停在该 token 后的分隔符上 std::string nextToken(const std::string& sql, size_t& pos) { while (pos < sql.size() && isspace((unsigned char)sql[pos])) ++pos; size_t start = pos; while (pos < sql.size() && !isspace((unsigned char)sql[pos]) && sql[pos] != ',' && sql[pos] != ';' && sql[pos] != '(' && sql[pos] != ')') ++pos; return sql.substr(start, pos - start); }参数说明:pos是引用传参,函数返回后它停在分隔符上,下一次调用会先跳空格。如果当前字符就是逗号或括号,函数返回空串,调用方要根据空串做分支。这个设计比“把分隔符一起吃掉”多几行判断,但换来的是不会把char(20)里的(和20粘成一个 token。VC6 对std::string的支持没问题,但范围for不支持,所以循环还是用下标写。
3.2 字段三元组抽取:名字、类型、长度
建表语句的主体是括号里的字段列表。每轮先读字段名,再读类型,然后看紧跟着的是不是左括号。如果是,说明这个类型带长度,把括号里的数字读出来存进fieldLen;如果不是,长度串置空。这里有一个容易翻车的点:原文用f_length + 48把整数转成字符,只对 0 到 9 成立,用户写char(20)会存成char(2)后面再跟一个乱七八糟的字符。正确做法是用sprintf或std::to_string转成字符串,再拼括号。
bool parseCreate(const std::string& sql) { size_t pos = 0; if (lower(nextToken(sql, pos)) != "create") return false; if (lower(nextToken(sql, pos)) != "table") return false; std::string tname = nextToken(sql, pos); if (tname.empty() || tableCount >= LEN) return false; int ti = tableCount; strcpy(tableName[ti], tname.c_str()); colCount[ti] = 0; while (pos < sql.size() && sql[pos] != '(') ++pos; // 跳到左括号 if (pos >= sql.size()) return false; ++pos; // 跳过 '(' while (pos < sql.size()) { std::string col = nextToken(sql, pos); if (col.empty()) { ++pos; continue; } // 当前是逗号或括号 if (col == ")") break; if (colCount[ti] >= COL) return false; std::string type = lower(nextToken(sql, pos)); std::string len; if (pos < sql.size() && sql[pos] == '(') { ++pos; while (pos < sql.size() && sql[pos] != ')') len += sql[pos++]; if (pos < sql.size()) ++pos; // 吃掉 ')' } // 约束可能是两个词:primary key / not null std::string c1 = lower(nextToken(sql, pos)); std::string c2; if (c1 == "primary" || c1 == "not") c2 = lower(nextToken(sql, pos)); std::string cond = normalizeCond(c1, c2); int j = colCount[ti]; strcpy(fieldName[ti][j], col.c_str()); strcpy(fieldType[ti][j], type.c_str()); if (len.empty()) fieldLen[ti][j][0] = '\0'; else sprintf(fieldLen[ti][j], "(%s)", len.c_str()); strcpy(fieldCond[ti][j], cond.c_str()); colCount[ti]++; } tableCount = ti + 1; return true; }逻辑说明:ti在函数开头就固定下来,后面所有写入都落在同一张表上,避免中途修改tableCount导致半张表写进新下标。col.empty()时执行++pos是为了越过逗号;如果当前是右括号,nextToken返回空串,下一轮会读到)然后break。sprintf的目标缓冲区是fieldLen[ti][j],长度 64 足够放(50)这种短串,但如果用户输入超长数字,仍要在读取长度时限制位数。
3.3 约束识别与表级约束的边界
约束归一化建议单独写一个函数,输入两个 token,输出文件里用的下划线形式。primary key合并成primary_key,not null合并成not_null,unique原样,什么都没有就是NULL。这样保存和读取都只用>>逐词读,不会出现“读到 primary 就停,key 留到下一行”的串行问题。
| 输入片段 | 内存/文件表示 | 说明 |
|---|---|---|
| primary key | primary_key | 两个 token 合并 |
| not null | not_null | 两个 token 合并 |
| unique | unique | 单个 token |
| 无约束 | NULL | 占位,保证四元组完整 |
需要说清楚边界:这套数组只支持列级约束,不支持PRIMARY KEY(id, name)这种表级约束。课程设计题目里虽然提到“表完整性约束条件”,但真正演示时一般只做列级。如果非要支持表级,得再加一组“表约束”数组,存约束类型和涉及的列下标,解析时在字段循环结束后再扫一遍括号内的内容。这个工作量不小,建议先把手头的列级约束跑稳,再考虑扩展。
4. ALTER TABLE 的三种动作:ADD、DROP、MODIFY 怎么改到数组里
修改表比建表多了一步“找表”。用户输入的 SQL 里先有表名,程序要拿这个表名去tableName数组里做字符串比较,找到下标之后才能改对应的colCount和字段数组。原文代码里用al_num从 0 循环到number,找不到就报“表不存在”,这个流程是对的。真正需要小心的是 ADD 之后的字段追加位置、DROP 之后的数组左移,以及 MODIFY 只改类型长度不改约束这几个细节。
4.1 定位表:ALTER 的第一步不是解析动作
解析 ALTER 语句时,顺序应该是:判alter、判table、取表名、查表是否存在、再判add/drop/modify。如果先把动作解析完再查表,用户输入一个不存在的表名时,程序可能已经改了某个全局下标,后面报错退出却留下半截状态。把查表放在动作之前,找不到就直接返回,内存数组不会被碰。
int findTable(const char* name) { for (int i = 0; i < tableCount; ++i) if (strcmp(tableName[i], name) == 0) return i; return -1; } bool parseAlter(const std::string& sql) { size_t pos = 0; if (lower(nextToken(sql, pos)) != "alter") return false; if (lower(nextToken(sql, pos)) != "table") return false; std::string tname = nextToken(sql, pos); int ti = findTable(tname.c_str()); if (ti < 0) return false; // 表不存在,直接失败 std::string action = lower(nextToken(sql, pos)); if (action == "add") return doAdd(ti, sql, pos); if (action == "drop") return doDrop(ti, sql, pos); if (action == "modify") return doModify(ti, sql, pos); return false; }参数说明:ti是表在数组里的下标,后面三个do*函数都只操作ti这一张表,不会影响其他表。findTable用strcmp做精确匹配,所以表名大小写必须一致;如果想让Student和student视为同一张表,在存表名时统一转小写,显示时再按原样显示。这个选择要在文档里写清楚,否则演示时输入大小写不同的表名会报“不存在”,看起来像 bug。
4.2 ADD 追加、DROP 左移、MODIFY 定点改
ADD 最简单:新字段放在colCount[ti]的位置,四个数组各写一份,然后字段数加一。前提是colCount[ti] < COL,否则拒绝。DROP 要区分按列名删还是按约束名删,找到目标列之后,把后面所有列整体前移一位,再把字段数减一。左移的顺序必须从目标位置往后,先覆盖再递增,不能从后往前,否则会把后面的数据覆盖掉。
bool alterAdd(int ti, const char* col, const char* type, const char* len, const char* cond) { if (colCount[ti] >= COL) return false; int j = colCount[ti]; strcpy(fieldName[ti][j], col); strcpy(fieldType[ti][j], type); strcpy(fieldLen[ti][j], len); strcpy(fieldCond[ti][j], cond); colCount[ti]++; return true; } bool alterDrop(int ti, const char* colOrCond) { for (int j = 0; j < colCount[ti]; ++j) { if (strcmp(fieldName[ti][j], colOrCond) == 0 || strcmp(fieldCond[ti][j], colOrCond) == 0) { for (int k = j; k < colCount[ti] - 1; ++k) { strcpy(fieldName[ti][k], fieldName[ti][k + 1]); strcpy(fieldType[ti][k], fieldType[ti][k + 1]); strcpy(fieldLen[ti][k], fieldLen[ti][k + 1]); strcpy(fieldCond[ti][k], fieldCond[ti][k + 1]); } colCount[ti]--; return true; } } return false; }逻辑说明:alterDrop找到第一匹配就返回,不会继续删后面同名的列;如果一张表里有两列都叫id,这本身在建表时就该拒绝,修改阶段不再重复校验。左移完成后,原来最后一列的数据还留在数组里,但colCount已经减一,后续循环不会访问它,保存时也不会写出,所以不用手动清零。alterAdd的len参数传空串表示无长度,调用方在解析时已经拼好带括号的串。
MODIFY 只改类型和长度,不动约束,也不动字段名。实现上就是遍历找到列名,覆盖fieldType和fieldLen两个数组。如果用户想把int改成char(20),直接覆盖即可;如果新类型不带长度,把fieldLen置空。需要提醒的是,MODIFY 不检查已有数据能否转换,因为这套课程设计只存表结构、不存行数据,所以不存在“把字符串塞进 int 列”的问题。如果后续要加数据存储,这一步必须做类型兼容检查。
bool alterModify(int ti, const char* col, const char* newType, const char* newLen) { for (int j = 0; j < colCount[ti]; ++j) { if (strcmp(fieldName[ti][j], col) == 0) { strcpy(fieldType[ti][j], newType); strcpy(fieldLen[ti][j], newLen); return true; } } return false; }4.3 修改后的落盘与一致性检查
三个动作改完内存数组,都要走一次saveAll。这里有一个顺序问题:先保存再打印“修改成功”,还是先打印再保存?建议先保存,保存失败就报错,不要给用户“已经改好”的假象。保存之后可以立刻调用一次loadAll回读,把文件内容重新灌回数组,再和修改后的内存做一次逐字段比较。这个回读校验在调试期非常有用,能提前发现“写进去的顺序和读出来的顺序不一致”这类问题。
另一个容易忽略的点是表名重复。CREATE TABLE 时如果没有检查findTable(tname) >= 0,用户建两张同名表,tableCount会加二,但 ALTER 永远只改到第一张,第二张变成幽灵表。建表函数开头加一句重名判断,比事后排查省很多时间。同理,ALTER ADD 时如果新列名和已有列重名,也应该拒绝,否则 DROP 按列名删的时候只能删到第一列,行为不确定。
5. 避坑与排查:路径硬编码、长度单字符、EOF 多读一轮这些坑
这套代码能跑起来之后,真正花时间的是排错。下面五条是我在类似课程设计里反复见到的翻车点,每条按“现象、原因、解决”写,照着对照基本能把大部分“玄学”定位到具体行号。
5.1 换机器就读不到表:路径写死
现象:在自己电脑上能正常建表、保存、重启读取,拷给同学演示时一启动就显示“无表可显示”,或者直接崩溃。
原因:文件路径写成了绝对路径,比如某台机器的桌面目录。换一台机器,那个目录不存在,ifstream打开失败,但代码没有判断打开结果,继续用未初始化的数组,后面就出现随机数据。
解决:统一用相对路径./table.txt,并且在loadAll和saveAll里判断打开是否成功。首次运行没有文件是正常情况,返回空表即可;保存失败要打印错误并让用户知道。如果确实需要指定路径,从命令行参数读,不要写死在源码里。
5.2 char(20) 存成 char(2):长度只转了一位
现象:建表时写name char(20),显示出来变成char(2)后面跟一个乱码字符,或者长度完全不对。
原因:用f_length + 48把整数转成字符,这只对 0 到 9 成立。20 加 48 得到 68,赋给char之后不是字符'2',拼出来的长度串自然错。
解决:用sprintf(buf, "(%d)", f_length)或std::to_string转字符串。同时限制长度位数,比如最多三位,防止用户输入char(999999)把缓冲区写穿。读取长度时也要判断字符是不是数字,遇到非数字立刻报错,不要继续累加。
5.3 约束字符串赋值报错或乱码:字符赋给数组
现象:编译时报“不能将 const char* 转换为 char”,或者编译通过但显示约束时只有一个字符。
原因:原文里有fieldCondition[number][i] = 'primary key';这类写法,左边是单个char元素,右边是字符串字面量,类型不匹配。即使强转通过,也只会取到第一个字符。
解决:约束数组按字符串处理,用strcpy(fieldCond[ti][j], "primary_key")或snprintf。同时统一约束的表示形式,内存、文件、显示三处用同一套映射,不要一处存primary key、另一处存primary_key,否则比较永远不相等。
5.4 重启后多出一张空表:EOF 判断滞后
现象:保存了三张表,重启读取变成四张,最后一张表名为空、字段数为 0;再保存一次,空表被写进文件,越滚越多。
原因:用while(!ioFile.eof())做读取循环。eof标志是在一次读取尝试失败后才置位的,循环体在最后一次会多执行一轮,把上一轮的变量又写一遍。如果循环里还有number++,就会多出一个下标。
解决:改成“先读数量、再计数循环”的结构,或者用while (in >> tableName[i] >> colCount[i])这种把读取本身作为条件的写法。读取端加范围检查,tableCount超过LEN、colCount超过COL就截断并报错,避免手改文件把数组写穿。
5.5 改完表结构,文件里出现两张同名表:追加写没清空
现象:ALTER TABLE 之后保存,再读取时发现同一张表出现两次,字段数一张是旧的、一张是新的。
原因:保存时用了ios::app追加模式,或者先定位到文件末尾再写,旧表结构没有被清除。读取端按顺序读,自然读到两份。
解决:保存统一用ios::out | ios::trunc,每次全量重写。如果担心写一半断电,先写table.tmp,写完close再rename覆盖正式文件。这个习惯在课程设计阶段看似多余,但能避免“演示前文件损坏、当场重装环境”的尴尬。
6. 从能跑到可验证:用边界 SQL 用例把解析器压一遍
代码能建一张表不代表解析器稳。我一般会准备一组边界 SQL,按顺序喂给程序,每一步用固定的 dump 格式打印内存数组,重启后再 dump 一次,对比两次输出是否一致。这套方法不需要图形界面,也不需要单元测试框架,一个dump函数加diff就够。
void dump() { cout << "TABLES " << tableCount << '\n'; for (int i = 0; i < tableCount; ++i) { cout << "TABLE " << tableName[i] << ' ' << colCount[i] << '\n'; for (int j = 0; j < colCount[i]; ++j) { cout << " " << fieldName[i][j] << ' ' << fieldType[i][j] << ' ' << fieldLen[i][j] << ' ' << fieldCond[i][j] << '\n'; } } }把dump绑到命令行参数上,比如dbms dump > before.txt,执行几条 ALTER 之后再dbms dump > after.txt,用diff before.txt after.txt看变化是否符合预期。重启程序再 dump 一次,和after.txt对比,就能验证持久化有没有丢字段、有没有多表。
下面这组用例覆盖了长度、约束、表不存在、重复列这几个高频边界:
| 用例 | 输入 | 期望结果 |
|---|---|---|
| 基本建表 | CREATE TABLE student (id int primary key, name char(20) not null); | 2 列,约束为 primary_key、not_null |
| 多位长度 | CREATE TABLE t2 (c char(100)); | fieldLen 为 (100),不是 (1) |
| 无长度类型 | CREATE TABLE t3 (age int); | fieldLen 为空串 |
| 重名表 | 再次 CREATE TABLE student (...); | 拒绝,tableCount 不变 |
| ADD | ALTER TABLE student ADD sex char(2); | 列数加一,新列在末尾 |
| DROP 列 | ALTER TABLE student DROP name; | 列数减一,sex 前移 |
| MODIFY | ALTER TABLE student MODIFY id char(10); | 类型和长度更新,约束不变 |
| 表不存在 | ALTER TABLE teacher ADD x int; | 报错,文件不被改写 |
这套用例跑完,基本能确认解析器的 token 切分、数组下标、文件读写三块是对的。剩下的是显示格式和菜单交互,那些不影响核心逻辑,放在最后调。
从那以后我每次交课程设计前,都会把table.txt删掉,从零建三张表、改两次结构、重启读取、再 dump 对比,确认冷启动和热路径一致才敢打包。希望帮到你。资源包里保留了 VC6 与 CodeBlocks 两套工程文件、table.txt示例和上面这组测试 SQL,需要的话按项目说明获取。
本文还有配套的精品资源,点击获取