From cc7aca70c3384c0dbff953d2655b1ee82f18a1df Mon Sep 17 00:00:00 2001 From: chester Date: Sun, 10 May 2026 01:33:09 +0800 Subject: [PATCH 01/10] =?UTF-8?q?-=20FIX=20=E4=BD=BF=E5=BE=97=20parser=20?= =?UTF-8?q?=E6=94=AF=E6=8C=81=20partial=20ALTER?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- PARTIAL_ALTER_PATCH_PLAN.md | 327 +++++++++++++++++++++++++++ controller/sql_dispatcher.cpp | 120 ++++++++++ tests/parserNdispatcher_test_plan.md | 16 +- tests/test_parser_dispatcher.cpp | 168 ++++++++++++++ utils/sql_parser/table_parser.cpp | 103 ++++++++- 5 files changed, 729 insertions(+), 5 deletions(-) create mode 100644 PARTIAL_ALTER_PATCH_PLAN.md diff --git a/PARTIAL_ALTER_PATCH_PLAN.md b/PARTIAL_ALTER_PATCH_PLAN.md new file mode 100644 index 0000000..5fb5a9f --- /dev/null +++ b/PARTIAL_ALTER_PATCH_PLAN.md @@ -0,0 +1,327 @@ +# PARTIAL_ALTER_PATCH_PLAN + +目标:在不改变现有 `ALTER TABLE ... MODIFY COLUMN ...` 完整替换语义的前提下,补充面向用户和前端的局部列修改语法。局部修改语法只表达一个明确变更点,由 dispatcher 读取旧列定义并补成完整 `ColumnDefinition`,再复用现有 `table_service::modifyColumn(...)`。 + +本计划只覆盖列级局部 ALTER;`MODIFY CONSTRAINT` 仍保持完整约束定义提交,约束 patch 语义后续如有需要单独规划。 + +## 阶段一:局部 ALTER COLUMN 语法与执行补丁 + +### 1. 影响范围 + +允许修改: + +1. [utils/sql_parser/sql_tokenizer.h](utils/sql_parser/sql_tokenizer.h) + - 如现有 token 集合缺少 `RENAME / TO / SET`,补充关键字 token;若 parser 只按 lexeme 判断,可不新增。 +2. [utils/sql_parser/sql_tokenizer.cpp](utils/sql_parser/sql_tokenizer.cpp) + - 补齐新增关键字识别。 +3. [utils/sql_parser/table_parser.cpp](utils/sql_parser/table_parser.cpp) + - 在 `ALTER_TABLE` 分支中新增局部 alterAction 解析。 +4. [controller/sql_dispatcher.cpp](controller/sql_dispatcher.cpp) + - 新增局部 alterAction 分发。 + - 读取旧 schema,构造完整 `ColumnDefinition` 后调用 service。 +5. [service/service.h](service/service.h) + - 首选不修改;若需要暴露 schema 查询辅助函数,再最小补充。 +6. [service/table_service.cpp](service/table_service.cpp) + - 首选不修改;复用现有 `modifyColumn(tableName, oldColumnName, definition)`。 +7. [tests/test_parser_dispatcher.cpp](tests/test_parser_dispatcher.cpp) + - 覆盖 parser payload 与 dispatcher 落库效果。 +8. [tests/parserNdispatcher_test_plan.md](tests/parserNdispatcher_test_plan.md) + - 更新 parser/dispatcher 测试说明。 +9. [tests/service_test_plan.md](tests/service_test_plan.md) + - 如新增 service 辅助行为,补充对应说明;若只走 dispatcher,可只补 dispatcher 说明。 + +不修改: + +1. 现有 `ALTER TABLE ... MODIFY COLUMN ...` 的完整替换定义语义。 +2. 现有 `ADD COLUMN / DROP COLUMN / ADD CONSTRAINT / DROP CONSTRAINT / MODIFY CONSTRAINT` 语义。 +3. CLI/GUI 执行链;新语法仍走 `SqlClientEngine -> SqlDispatcher -> parser/service`。 + +### 2. 数据流函数级收口 + +#### 2.1 `ALTER TABLE t ALTER COLUMN c SET DEFAULT v` + +```text +SQL +-> sqlparser::parseSql(...) +-> table_parser 生成 ParseResult + commandType = ALTER_TABLE + payload.tableName = t + payload.alterAction = ALTER_COLUMN_SET_DEFAULT + payload.columnName = c + payload.defaultValue = v +-> SqlDispatcher::execAlterTable(...) +-> load current table schema +-> find old column c +-> build ColumnDefinition from old column and old generated constraints +-> set definition.column.defaultValue = v +-> table_service::modifyColumn(t, c, definition) +-> SqlExecResult +``` + +#### 2.2 `ALTER TABLE t ALTER COLUMN c DROP DEFAULT` + +```text +SQL +-> parser payload alterAction = ALTER_COLUMN_DROP_DEFAULT +-> dispatcher reads old column +-> copy old definition +-> definition.column.defaultValue = "" +-> table_service::modifyColumn(t, c, definition) +``` + +#### 2.3 `ALTER TABLE t ALTER COLUMN c SET NOT NULL` + +```text +SQL +-> parser payload alterAction = ALTER_COLUMN_SET_NOT_NULL +-> dispatcher reads old column +-> copy old definition +-> definition.column.notNull = true +-> table_service::modifyColumn(t, c, definition) +``` + +#### 2.4 `ALTER TABLE t ALTER COLUMN c DROP NOT NULL` + +```text +SQL +-> parser payload alterAction = ALTER_COLUMN_DROP_NOT_NULL +-> dispatcher reads old column +-> copy old definition +-> definition.column.notNull = false +-> table_service::modifyColumn(t, c, definition) +``` + +#### 2.5 `ALTER TABLE t RENAME COLUMN old_name TO new_name` + +```text +SQL +-> parser payload alterAction = RENAME_COLUMN + payload.columnName = old_name + payload.newColumnName = new_name +-> dispatcher reads old column +-> copy old definition +-> definition.column.name = new_name +-> table_service::modifyColumn(t, old_name, definition) +``` + +#### 2.6 可选:`ALTER TABLE t ALTER COLUMN c TYPE VARCHAR(20)` + +```text +SQL +-> parser payload alterAction = ALTER_COLUMN_SET_TYPE + payload.columnName = c + payload.type = VARCHAR + payload.length = 20 +-> dispatcher reads old column +-> copy old definition +-> definition.column.type / length = new type info +-> table_service::modifyColumn(t, c, definition) +``` + +本项建议作为同一 patch 的可选能力。如果时间紧,优先实现 default / not null / rename。 + +### 3. 函数实现的输入输出级收口与关键描述 + +#### 3.1 `sqlparser::parseSql(sqlText)` + +输入: + +1. `ALTER TABLE t ALTER COLUMN c SET DEFAULT 18` +2. `ALTER TABLE t ALTER COLUMN c DROP DEFAULT` +3. `ALTER TABLE t ALTER COLUMN c SET NOT NULL` +4. `ALTER TABLE t ALTER COLUMN c DROP NOT NULL` +5. `ALTER TABLE t RENAME COLUMN old_name TO new_name` +6. 可选:`ALTER TABLE t ALTER COLUMN c TYPE VARCHAR(20)` + +输出: + +```cpp +ParseResult { + success = true, + commandType = "ALTER_TABLE", + payload = { + "tableName": "...", + "alterAction": "...", + "columnName": "...", + ... + } +} +``` + +关键逻辑: + +1. `ALTER COLUMN` 只解析局部变更,不调用 `parseColumnSegment()`。 +2. `SET DEFAULT` 必须输出 `defaultValue`,允许字符串、数字和普通 identifier literal。 +3. `DROP DEFAULT` 不输出空默认值来表达“未提供”,而是用独立 `alterAction` 表达清空。 +4. `SET NOT NULL / DROP NOT NULL` 使用独立 `alterAction`,避免把 bool 默认值误解成用户意图。 +5. `RENAME COLUMN` 必须同时输出旧列名和新列名。 +6. 语法不完整时返回 parser error,不进入 dispatcher。 + +#### 3.2 `SqlDispatcher::execAlterTable(parseResult)` + +输入: + +```cpp +ParseResult { + commandType = "ALTER_TABLE", + payload["alterAction"] = one of partial alter actions +} +``` + +输出: + +```cpp +SqlExecResult { + success = true/false, + errorMessage = "...", + text = "Column altered" / "Column renamed" +} +``` + +关键逻辑: + +1. 校验 `tableName`、`columnName`、`newColumnName/defaultValue/type` 等必要字段。 +2. 读取当前表 schema,定位旧列;列不存在时返回明确错误。 +3. 把旧列转换成完整 `ColumnDefinition`。 +4. 保留旧列关联的列级生成约束意图: + - 旧列被主键约束覆盖时,`definition.primaryKey = true`。 + - 旧列被单列 unique 约束覆盖时,`definition.unique = true`。 + - 旧列有 check 字段或列级 check 约束时,保留 check。 + - 外键列级生成约束如无法可靠还原,首轮不主动从表级 FK 反推为列级定义。 +5. 只改当前 alterAction 指定的字段。 +6. 调用 `table_service::modifyColumn(tableName, oldColumnName, definition)`。 +7. 不直接写 repo,不绕过 service 校验与回滚。 + +#### 3.3 `buildColumnDefinitionFromExistingSchema(schema, columnName)` + +建议新增为 `sql_dispatcher.cpp` 内部匿名命名空间 helper。 + +输入: + +```cpp +const tabledef::TableSchema &schema +QString columnName +``` + +输出: + +```cpp +std::optional +// 或 bool + out param,按项目现有风格选择 +``` + +关键逻辑: + +1. 查找 `schema.columns` 中的目标列。 +2. 把 `tabledef::Column` 复制到 `ColumnDefinition::column`。 +3. 扫描 `schema.constraints`,尽量还原单列生成约束: + - `PrimaryKey` 且只包含该列:`primaryKey = true` + - `Unique` 且只包含该列:`unique = true` + - `Check` 如与列级 check 可明确对应,保留到 `checkClause` +4. 多列约束保持表级约束,不塞回 `ColumnDefinition`。 + +#### 3.4 `table_service::modifyColumn(tableName, oldColumnName, definition)` + +输入: + +```cpp +tableName: 当前表名 +oldColumnName: 被修改列旧名 +definition: dispatcher 补齐后的完整新定义 +``` + +输出: + +```cpp +TaskResult { + success, + errorMessage, + affectedRowCount +} +``` + +关键逻辑: + +1. 复用现有实现。 +2. 类型、默认值、not null、重命名对存量数据的影响继续由 service 校验。 +3. 索引、约束、表数据回滚继续由 service 负责。 +4. 如果现有 service 行为暴露出“无法保留某类表级约束”的问题,应优先在 dispatcher helper 限制输入并返回明确错误,而不是绕过 service。 + +### 4. 测试用例 + +#### 4.1 parser 测试 + +新增或扩展 [tests/test_parser_dispatcher.cpp](tests/test_parser_dispatcher.cpp): + +1. `test_parseAlterColumnSetDefault` + - 输入:`ALTER TABLE student ALTER COLUMN age SET DEFAULT 18` + - 断言:`alterAction == ALTER_COLUMN_SET_DEFAULT`,`columnName == age`,`defaultValue == 18` +2. `test_parseAlterColumnDropDefault` + - 输入:`ALTER TABLE student ALTER COLUMN age DROP DEFAULT` + - 断言:`alterAction == ALTER_COLUMN_DROP_DEFAULT` +3. `test_parseAlterColumnSetNotNull` + - 输入:`ALTER TABLE student ALTER COLUMN name SET NOT NULL` + - 断言:`alterAction == ALTER_COLUMN_SET_NOT_NULL` +4. `test_parseAlterColumnDropNotNull` + - 输入:`ALTER TABLE student ALTER COLUMN name DROP NOT NULL` + - 断言:`alterAction == ALTER_COLUMN_DROP_NOT_NULL` +5. `test_parseRenameColumn` + - 输入:`ALTER TABLE student RENAME COLUMN old_name TO new_name` + - 断言:`columnName == old_name`,`newColumnName == new_name` +6. 可选:`test_parseAlterColumnSetType` + - 输入:`ALTER TABLE student ALTER COLUMN name TYPE VARCHAR(64)` + - 断言:`type == VARCHAR`,`length == 64` + +#### 4.2 dispatcher 集成测试 + +1. `test_dispatchAlterColumnSetDefaultPreservesOtherAttributes` + - 建表:`name VARCHAR(20) NOT NULL` + - 执行:`ALTER TABLE t ALTER COLUMN name SET DEFAULT 'anonymous'` + - 断言:`type/length/notNull` 保持不变,`defaultValue` 更新。 +2. `test_dispatchAlterColumnDropDefaultPreservesNotNull` + - 建表:`age INT NOT NULL DEFAULT 18` + - 执行:`DROP DEFAULT` + - 断言:`defaultValue` 为空,`notNull == true`。 +3. `test_dispatchAlterColumnSetNotNullRejectsExistingEmptyValues` + - 表中已有空值。 + - 执行:`SET NOT NULL` + - 断言:失败且错误来自 service 约束校验。 +4. `test_dispatchAlterColumnDropNotNullPreservesDefault` + - 建表:`age INT NOT NULL DEFAULT 18` + - 执行:`DROP NOT NULL` + - 断言:`notNull == false`,`defaultValue == 18`。 +5. `test_dispatchRenameColumnPreservesDataAndIndexes` + - 建表并创建索引。 + - 执行:`RENAME COLUMN old_name TO new_name` + - 断言:数据可查询,新列名可见,旧列名不可用,索引元数据更新。 +6. `test_dispatchRenameColumnRejectsDuplicateColumn` + - 新列名已存在。 + - 断言:失败且 schema 不变。 +7. 可选:`test_dispatchAlterColumnSetTypeValidatesExistingRows` + - 存量数据无法转换时失败,schema 不变。 + +#### 4.3 CLI / GUI 回归测试 + +不需要新增专门 CLI/GUI 行为测试。原因: + +1. 新语法走同一条 parser/dispatcher/service 执行路径。 +2. CLI 只负责传入 SQL 与展示结果。 +3. GUI 后续如果使用局部 ALTER,也应通过 `SqlClientEngine` 执行 SQL。 + +保留全量 `--run-tests` 回归即可: + +```powershell +$env:QT_QPA_PLATFORM='offscreen' +$env:PATH='E:\Qt\6.9.2\msvc2022_64\bin;' + $env:PATH +.\build\Desktop_Qt_6_9_2_MSVC2022_64bit-Debug\DBMS.exe --run-tests +``` + +### 5. 验收边界 + +1. 现有 `MODIFY COLUMN` 测试继续通过,语义不变化。 +2. 局部 ALTER 不依赖 parser 默认值猜测用户意图。 +3. 所有局部 ALTER 最终都复用 `table_service::modifyColumn(...)`。 +4. 失败时 schema、数据、约束、索引不应出现半更新。 +5. 前端以后可以选择: + - 图形化完整编辑器继续提交完整 `MODIFY COLUMN`。 + - 快捷操作使用局部 `ALTER COLUMN`。 diff --git a/controller/sql_dispatcher.cpp b/controller/sql_dispatcher.cpp index 9a588f3..32f4641 100644 --- a/controller/sql_dispatcher.cpp +++ b/controller/sql_dispatcher.cpp @@ -87,6 +87,118 @@ ColumnDefinition columnDefinitionFromPayload(const QVariantMap &columnMap) return definition; } +bool columnDefinitionFromExistingSchema(const tabledef::TableSchema &schema, + const QString &columnName, + ColumnDefinition *definition, + QString *error) +{ + if (definition == nullptr) { + if (error != nullptr) *error = QStringLiteral("column definition output pointer cannot be null"); + return false; + } + + const int columnIndex = tabledef::findColumnIndex(schema, columnName); + if (columnIndex < 0) { + if (error != nullptr) *error = QStringLiteral("column '%1' does not exist").arg(columnName); + return false; + } + + ColumnDefinition result; + result.column = schema.columns.at(columnIndex); + result.checkClause = result.column.check; + + for (const tabledef::Constraint &constraint : schema.constraints) { + if (!tabledef::constraintTouchesColumn(constraint, columnName)) { + continue; + } + + if (constraint.columns.size() != 1 || constraint.columns.value(0) != columnName) { + if (error != nullptr) { + *error = QStringLiteral("partial ALTER COLUMN cannot preserve multi-column constraint '%1'") + .arg(constraint.name); + } + return false; + } + + if (tabledef::isPrimaryKeyConstraint(constraint)) { + result.primaryKey = true; + } else if (tabledef::isUniqueConstraint(constraint)) { + result.unique = true; + } else if (tabledef::isForeignKeyConstraint(constraint)) { + result.referencedTable = constraint.referencedTable; + result.referencedColumns = constraint.referencedColumns; + result.onDeleteAction = constraint.onDeleteAction; + result.onUpdateAction = constraint.onUpdateAction; + } else if (constraint.type == tabledef::ConstraintType::Check) { + result.checkClause = constraint.checkClause; + result.column.check = constraint.checkClause; + } + } + + *definition = result; + return true; +} + +SqlExecResult execPartialAlterColumn(const QString &tableName, + const sqlparser::ParseResult &p, + const QString &action) +{ + const QString columnName = p.payload.value(QStringLiteral("columnName")).toString().trimmed(); + if (columnName.isEmpty()) { + return {false, QStringLiteral("ALTER TABLE ALTER COLUMN requires columnName")}; + } + + QString error; + const tabledef::TableSchema schema = loadUserTableSchema(tableName, &error); + if (!error.isEmpty()) { + return {false, error}; + } + + ColumnDefinition definition; + if (!columnDefinitionFromExistingSchema(schema, columnName, &definition, &error)) { + return {false, error}; + } + + QString oldColumnName = columnName; + if (action == QStringLiteral("ALTER_COLUMN_SET_DEFAULT")) { + definition.column.defaultValue = p.payload.value(QStringLiteral("defaultValue")).toString(); + } else if (action == QStringLiteral("ALTER_COLUMN_DROP_DEFAULT")) { + definition.column.defaultValue.clear(); + } else if (action == QStringLiteral("ALTER_COLUMN_SET_NOT_NULL")) { + definition.column.notNull = true; + } else if (action == QStringLiteral("ALTER_COLUMN_DROP_NOT_NULL")) { + definition.column.notNull = false; + } else if (action == QStringLiteral("ALTER_COLUMN_SET_TYPE")) { + const QString typeName = p.payload.value(QStringLiteral("type")).toString().trimmed(); + if (typeName.isEmpty()) { + return {false, QStringLiteral("ALTER TABLE ALTER COLUMN TYPE requires type")}; + } + definition.column.type = columnTypeFromSql(typeName); + if (definition.column.type == tabledef::ColumnType::Varchar) { + definition.column.length = p.payload.contains(QStringLiteral("length")) + ? p.payload.value(QStringLiteral("length")).toInt() + : 255; + } else { + definition.column.length = 0; + } + } else if (action == QStringLiteral("RENAME_COLUMN")) { + const QString newColumnName = p.payload.value(QStringLiteral("newColumnName")).toString().trimmed(); + if (newColumnName.isEmpty()) { + return {false, QStringLiteral("ALTER TABLE RENAME COLUMN requires newColumnName")}; + } + definition.column.name = newColumnName; + } else { + return {false, QStringLiteral("ALTER TABLE: unsupported partial column action %1").arg(action)}; + } + + TaskResult result = table_service::modifyColumn(tableName, oldColumnName, definition); + if (result.success) { + return {true, {}, action == QStringLiteral("RENAME_COLUMN") ? QStringLiteral("Column renamed") + : QStringLiteral("Column altered")}; + } + return {false, result.errorMessage}; +} + tabledef::Constraint constraintFromPayload(const QVariantMap &constraintMap, const QString &tableName, int ordinal) @@ -385,6 +497,14 @@ SqlExecResult SqlDispatcher::execAlterTable(const sqlparser::ParseResult& p) { if (r.success) return {true, {}, "Column modified"}; return {false, r.errorMessage}; } + if (action == "ALTER_COLUMN_SET_DEFAULT" + || action == "ALTER_COLUMN_DROP_DEFAULT" + || action == "ALTER_COLUMN_SET_NOT_NULL" + || action == "ALTER_COLUMN_DROP_NOT_NULL" + || action == "ALTER_COLUMN_SET_TYPE" + || action == "RENAME_COLUMN") { + return execPartialAlterColumn(tableName, p, action); + } if (action == "ADD_CONSTRAINT") { if (!payloadHasMap(p, QStringLiteral("constraint"))) { return {false, QStringLiteral("ALTER TABLE ADD CONSTRAINT requires a complete constraint payload")}; diff --git a/tests/parserNdispatcher_test_plan.md b/tests/parserNdispatcher_test_plan.md index 80a0f60..9680f96 100644 --- a/tests/parserNdispatcher_test_plan.md +++ b/tests/parserNdispatcher_test_plan.md @@ -5,7 +5,6 @@ 本文件记录当前已经落地的 parser / dispatcher 回归范围,直接对应 [test_parser_dispatcher.cpp](E:/Qt-projects/DBMS/tests/test_parser_dispatcher.cpp)。 当前覆盖目标分成两层: - - parser 是否按协议输出正确的 `commandType + payload` - dispatcher 是否把 payload 正确下推到现有 service,并拒绝不完整输入 @@ -22,7 +21,7 @@ - `test_parseInsertWithoutColumnListProducesSingleRowPayload` 验证无列名 `INSERT` 仍会产出单行 `rows` payload。 - `test_parseAlterAndIndexProduceCompletePayload` - 验证 `ALTER TABLE ADD/MODIFY COLUMN`、`ADD/MODIFY CONSTRAINT`、`CREATE/DROP INDEX` 会输出完整 payload。 + 验证 `ALTER TABLE ADD/MODIFY COLUMN`、`ADD/MODIFY CONSTRAINT`、`ALTER COLUMN SET/DROP DEFAULT`、`ALTER COLUMN SET/DROP NOT NULL`、`ALTER COLUMN TYPE`、`RENAME COLUMN`、`CREATE/DROP INDEX` 会输出完整 payload。 - `test_parseAlterForeignKeyAndMultiColumnIndexPayload` 验证 `ALTER TABLE ... ADD CONSTRAINT FOREIGN KEY ...` 和多列索引的 payload 收口。 @@ -43,6 +42,12 @@ 验证 `SHOW CREATE TABLE` 贯通到 service。 - `test_dispatcherAlterSqlPathsCallService` 验证 `ALTER TABLE ADD/MODIFY COLUMN/CONSTRAINT` 的 SQL 会真正改动 schema。 +- `test_dispatcherPartialAlterColumnPreservesAttributes` + 验证 partial `ALTER COLUMN` 只修改指定属性,并保留旧列的类型、长度、默认值、nullable 等未指定属性。 +- `test_dispatcherPartialAlterSetNotNullRejectsExistingEmptyValues` + 验证 `ALTER COLUMN ... SET NOT NULL` 会复用底层约束校验,拒绝已有空值的数据表。 +- `test_dispatcherRenameColumnPreservesDataAndIndexMetadata` + 验证 `RENAME COLUMN` 会保留行数据,并同步更新索引元数据中的列名。 - `test_dispatcherWhereAndLimitFlowToService` 验证 `SELECT / UPDATE / DELETE` 的简单 `WHERE` 和 `LIMIT` 会真正下推到 `tuple_service`。 - `test_dispatcherIndexSqlUsesService` @@ -60,7 +65,6 @@ ## 当前结论 当前 parser / dispatcher 测试已经覆盖: - - `CREATE TABLE` - `SELECT ... WHERE ... AND ... LIMIT ...` - `UPDATE ... WHERE ...` @@ -68,7 +72,11 @@ - `INSERT` - `ALTER TABLE ADD/MODIFY COLUMN` - `ALTER TABLE ADD/MODIFY CONSTRAINT` +- `ALTER TABLE ALTER COLUMN SET/DROP DEFAULT` +- `ALTER TABLE ALTER COLUMN SET/DROP NOT NULL` +- `ALTER TABLE ALTER COLUMN TYPE` +- `ALTER TABLE RENAME COLUMN` - `CREATE INDEX / DROP INDEX` -- 关键的失败边界与 payload 不完整拒绝策略 +- 关键失败边界与 payload 不完整拒绝策略 仍未追求的是“所有语法变体穷举”,而不是主协议缺失。 diff --git a/tests/test_parser_dispatcher.cpp b/tests/test_parser_dispatcher.cpp index 6219ca2..41e4861 100644 --- a/tests/test_parser_dispatcher.cpp +++ b/tests/test_parser_dispatcher.cpp @@ -538,6 +538,48 @@ private slots: QCOMPARE(dropIndex.commandType, QStringLiteral("DROP_INDEX")); QCOMPARE(dropIndex.payload.value(QStringLiteral("indexName")).toString(), QStringLiteral("idx_student_name")); QCOMPARE(dropIndex.payload.value(QStringLiteral("tableName")).toString(), QStringLiteral("student")); + + const sqlparser::ParseResult setDefault = sqlparser::parseSql( + QStringLiteral("ALTER TABLE student ALTER COLUMN age SET DEFAULT 18")); + QVERIFY2(setDefault.success, qPrintable(setDefault.errorMessage)); + QCOMPARE(setDefault.payload.value(QStringLiteral("alterAction")).toString(), + QStringLiteral("ALTER_COLUMN_SET_DEFAULT")); + QCOMPARE(setDefault.payload.value(QStringLiteral("columnName")).toString(), QStringLiteral("age")); + QCOMPARE(setDefault.payload.value(QStringLiteral("defaultValue")).toString(), QStringLiteral("18")); + + const sqlparser::ParseResult dropDefault = sqlparser::parseSql( + QStringLiteral("ALTER TABLE student ALTER COLUMN age DROP DEFAULT")); + QVERIFY2(dropDefault.success, qPrintable(dropDefault.errorMessage)); + QCOMPARE(dropDefault.payload.value(QStringLiteral("alterAction")).toString(), + QStringLiteral("ALTER_COLUMN_DROP_DEFAULT")); + + const sqlparser::ParseResult setNotNull = sqlparser::parseSql( + QStringLiteral("ALTER TABLE student ALTER COLUMN name SET NOT NULL")); + QVERIFY2(setNotNull.success, qPrintable(setNotNull.errorMessage)); + QCOMPARE(setNotNull.payload.value(QStringLiteral("alterAction")).toString(), + QStringLiteral("ALTER_COLUMN_SET_NOT_NULL")); + + const sqlparser::ParseResult dropNotNull = sqlparser::parseSql( + QStringLiteral("ALTER TABLE student ALTER COLUMN name DROP NOT NULL")); + QVERIFY2(dropNotNull.success, qPrintable(dropNotNull.errorMessage)); + QCOMPARE(dropNotNull.payload.value(QStringLiteral("alterAction")).toString(), + QStringLiteral("ALTER_COLUMN_DROP_NOT_NULL")); + + const sqlparser::ParseResult renameColumn = sqlparser::parseSql( + QStringLiteral("ALTER TABLE student RENAME COLUMN old_name TO new_name")); + QVERIFY2(renameColumn.success, qPrintable(renameColumn.errorMessage)); + QCOMPARE(renameColumn.payload.value(QStringLiteral("alterAction")).toString(), + QStringLiteral("RENAME_COLUMN")); + QCOMPARE(renameColumn.payload.value(QStringLiteral("columnName")).toString(), QStringLiteral("old_name")); + QCOMPARE(renameColumn.payload.value(QStringLiteral("newColumnName")).toString(), QStringLiteral("new_name")); + + const sqlparser::ParseResult setType = sqlparser::parseSql( + QStringLiteral("ALTER TABLE student ALTER COLUMN name TYPE VARCHAR(64)")); + QVERIFY2(setType.success, qPrintable(setType.errorMessage)); + QCOMPARE(setType.payload.value(QStringLiteral("alterAction")).toString(), + QStringLiteral("ALTER_COLUMN_SET_TYPE")); + QCOMPARE(setType.payload.value(QStringLiteral("type")).toString(), QStringLiteral("VARCHAR")); + QCOMPARE(setType.payload.value(QStringLiteral("length")).toInt(), 64); } void test_parseAlterForeignKeyAndMultiColumnIndexPayload() @@ -641,6 +683,132 @@ private slots: QCOMPARE(uniqueConstraint.columns, QStringList({QStringLiteral("name"), QStringLiteral("age")})); } + void test_dispatcherPartialAlterColumnPreservesAttributes() + { + const QString databaseName = QStringLiteral("test_parser_dispatcher_partial_alter_db"); + const QString tableName = QStringLiteral("test_parser_dispatcher_partial_alter_table"); + ensureDatabase(databaseName); + + tabledef::TableSchema schema; + schema.tableName = tableName; + schema.columns = { + makeColumn(QStringLiteral("id"), tabledef::ColumnType::Int, 0, true), + makeColumn(QStringLiteral("name"), tabledef::ColumnType::Varchar, 20, true), + makeColumn(QStringLiteral("age"), tabledef::ColumnType::Int, 0, false, QStringLiteral("18")), + }; + schema.constraints = { + makePrimaryKey(QStringLiteral("pk_%1_id").arg(tableName), {QStringLiteral("id")}), + }; + ensureTable(tableName, schema); + + SqlDispatcher dispatcher; + SqlExecResult result = dispatcher.execute( + QStringLiteral("ALTER TABLE %1 ALTER COLUMN name SET DEFAULT 'anonymous'").arg(tableName)); + QVERIFY2(result.success, qPrintable(result.errorMessage)); + + QString error; + tabledef::TableSchema loaded = loadUserTableSchema(tableName, &error); + QVERIFY2(error.isEmpty(), qPrintable(error)); + tabledef::Column nameColumn = findColumn(loaded, QStringLiteral("name")); + QCOMPARE(nameColumn.length, 20); + QVERIFY(nameColumn.notNull); + QCOMPARE(nameColumn.defaultValue, QStringLiteral("anonymous")); + + result = dispatcher.execute( + QStringLiteral("ALTER TABLE %1 ALTER COLUMN age DROP DEFAULT").arg(tableName)); + QVERIFY2(result.success, qPrintable(result.errorMessage)); + loaded = loadUserTableSchema(tableName, &error); + QVERIFY2(error.isEmpty(), qPrintable(error)); + tabledef::Column ageColumn = findColumn(loaded, QStringLiteral("age")); + QCOMPARE(ageColumn.type, tabledef::ColumnType::Int); + QVERIFY(!ageColumn.notNull); + QVERIFY(ageColumn.defaultValue.isEmpty()); + + result = dispatcher.execute( + QStringLiteral("ALTER TABLE %1 ALTER COLUMN name DROP NOT NULL").arg(tableName)); + QVERIFY2(result.success, qPrintable(result.errorMessage)); + loaded = loadUserTableSchema(tableName, &error); + QVERIFY2(error.isEmpty(), qPrintable(error)); + nameColumn = findColumn(loaded, QStringLiteral("name")); + QVERIFY(!nameColumn.notNull); + QCOMPARE(nameColumn.defaultValue, QStringLiteral("anonymous")); + + result = dispatcher.execute( + QStringLiteral("ALTER TABLE %1 ALTER COLUMN name TYPE VARCHAR(64)").arg(tableName)); + QVERIFY2(result.success, qPrintable(result.errorMessage)); + loaded = loadUserTableSchema(tableName, &error); + QVERIFY2(error.isEmpty(), qPrintable(error)); + nameColumn = findColumn(loaded, QStringLiteral("name")); + QCOMPARE(nameColumn.type, tabledef::ColumnType::Varchar); + QCOMPARE(nameColumn.length, 64); + QCOMPARE(nameColumn.defaultValue, QStringLiteral("anonymous")); + } + + void test_dispatcherPartialAlterSetNotNullRejectsExistingEmptyValues() + { + const QString databaseName = QStringLiteral("test_parser_dispatcher_partial_not_null_db"); + const QString tableName = QStringLiteral("test_parser_dispatcher_partial_not_null_table"); + ensureDatabase(databaseName); + + tabledef::TableSchema schema; + schema.tableName = tableName; + schema.columns = { + makeColumn(QStringLiteral("id"), tabledef::ColumnType::Int, 0, true), + makeColumn(QStringLiteral("nickname"), tabledef::ColumnType::Varchar, 20, false), + }; + schema.constraints = { + makePrimaryKey(QStringLiteral("pk_%1_id").arg(tableName), {QStringLiteral("id")}), + }; + ensureTable(tableName, schema); + seedRows(tableName, {makeRow({{QStringLiteral("id"), QStringLiteral("1")}, + {QStringLiteral("nickname"), QString()}})}); + + SqlDispatcher dispatcher; + const SqlExecResult result = dispatcher.execute( + QStringLiteral("ALTER TABLE %1 ALTER COLUMN nickname SET NOT NULL").arg(tableName)); + QVERIFY(!result.success); + QVERIFY2(result.errorMessage.contains(QStringLiteral("cannot be null")), + qPrintable(result.errorMessage)); + } + + void test_dispatcherRenameColumnPreservesDataAndIndexMetadata() + { + const QString databaseName = QStringLiteral("test_parser_dispatcher_rename_column_db"); + const QString tableName = QStringLiteral("test_parser_dispatcher_rename_column_table"); + ensureDatabase(databaseName); + ensureTable(tableName, baseSchema(tableName)); + seedRows(tableName, {makeRow({{QStringLiteral("id"), QStringLiteral("1")}, + {QStringLiteral("name"), QStringLiteral("alice")}})}); + + SqlDispatcher dispatcher; + SqlExecResult result = dispatcher.execute( + QStringLiteral("CREATE INDEX idx_%1_name ON %1(name)").arg(tableName)); + QVERIFY2(result.success, qPrintable(result.errorMessage)); + + result = dispatcher.execute( + QStringLiteral("ALTER TABLE %1 RENAME COLUMN name TO full_name").arg(tableName)); + QVERIFY2(result.success, qPrintable(result.errorMessage)); + + QString error; + const tabledef::TableSchema schema = loadUserTableSchema(tableName, &error); + QVERIFY2(error.isEmpty(), qPrintable(error)); + QVERIFY(tabledef::hasColumn(schema, QStringLiteral("full_name"))); + QVERIFY(!tabledef::hasColumn(schema, QStringLiteral("name"))); + + const tabledef::IndexMeta index = findIndexMeta(databaseName, + tableName, + service::getDataRoot(), + QStringLiteral("idx_%1_name").arg(tableName), + &error); + QVERIFY2(error.isEmpty(), qPrintable(error)); + QCOMPARE(index.columnNames, QStringList({QStringLiteral("full_name")})); + + const SelectRowsResult rows = selectAllRows(tableName); + QVERIFY2(rows.success, qPrintable(rows.errorMessage)); + QCOMPARE(rows.resultTable.columns, QStringList({QStringLiteral("id"), QStringLiteral("full_name")})); + QCOMPARE(rows.resultTable.rows.at(0), QStringList({QStringLiteral("1"), QStringLiteral("alice")})); + } + void test_dispatcherAlterRejectsIncompletePayload() { const QString databaseName = QStringLiteral("test_parser_dispatcher_alter_incomplete_db"); diff --git a/utils/sql_parser/table_parser.cpp b/utils/sql_parser/table_parser.cpp index a664333..f7004c2 100644 --- a/utils/sql_parser/table_parser.cpp +++ b/utils/sql_parser/table_parser.cpp @@ -448,10 +448,20 @@ ParseResult parseTableSql(const QString& sql, const QVector& tokens) const int end = lastMeaningfulTokenIndex(tokens); if (end < 0) return {false, "ALTER TABLE: unsupported syntax", cmdType, {}}; - int actionIndex = -1; + int actionSearchStart = 0; for (int i = 0; i <= end; ++i) { + if (lexemeIs(tokens, i, QStringLiteral("TABLE"))) { + actionSearchStart = i + 2; + break; + } + } + + int actionIndex = -1; + for (int i = actionSearchStart; i <= end; ++i) { if (lexemeIs(tokens, i, QStringLiteral("ADD")) + || lexemeIs(tokens, i, QStringLiteral("ALTER")) || lexemeIs(tokens, i, QStringLiteral("MODIFY")) + || lexemeIs(tokens, i, QStringLiteral("RENAME")) || lexemeIs(tokens, i, QStringLiteral("DROP"))) { actionIndex = i; break; @@ -489,6 +499,83 @@ ParseResult parseTableSql(const QString& sql, const QVector& tokens) return {true, "", cmdType, payload}; } + if (lexemeIs(tokens, actionIndex, QStringLiteral("ALTER"))) { + if (!lexemeIs(tokens, actionIndex + 1, QStringLiteral("COLUMN")) + || actionIndex + 2 > end + || tokens[actionIndex + 2].type != TokenType::IDENTIFIER) { + return {false, "ALTER TABLE ALTER COLUMN: expected column name", cmdType, {}}; + } + + const QString columnName = tokens[actionIndex + 2].lexeme; + const int operationIndex = actionIndex + 3; + if (operationIndex > end) { + return {false, "ALTER TABLE ALTER COLUMN: expected operation", cmdType, {}}; + } + + if (lexemeIs(tokens, operationIndex, QStringLiteral("SET"))) { + if (operationIndex + 1 > end) { + return {false, "ALTER TABLE ALTER COLUMN SET: expected operation", cmdType, {}}; + } + if (lexemeIs(tokens, operationIndex + 1, QStringLiteral("DEFAULT"))) { + if (operationIndex + 2 > end) { + return {false, "ALTER TABLE ALTER COLUMN SET DEFAULT: expected value", cmdType, {}}; + } + payload.insert(QStringLiteral("alterAction"), QStringLiteral("ALTER_COLUMN_SET_DEFAULT")); + payload.insert(QStringLiteral("columnName"), columnName); + payload.insert(QStringLiteral("defaultValue"), tokens[operationIndex + 2].lexeme); + return {true, "", cmdType, payload}; + } + if (lexemeIs(tokens, operationIndex + 1, QStringLiteral("NOT")) + && operationIndex + 2 <= end + && lexemeIs(tokens, operationIndex + 2, QStringLiteral("NULL"))) { + payload.insert(QStringLiteral("alterAction"), QStringLiteral("ALTER_COLUMN_SET_NOT_NULL")); + payload.insert(QStringLiteral("columnName"), columnName); + return {true, "", cmdType, payload}; + } + return {false, "ALTER TABLE ALTER COLUMN SET: unsupported operation", cmdType, {}}; + } + + if (lexemeIs(tokens, operationIndex, QStringLiteral("DROP"))) { + if (operationIndex + 1 > end) { + return {false, "ALTER TABLE ALTER COLUMN DROP: expected operation", cmdType, {}}; + } + if (lexemeIs(tokens, operationIndex + 1, QStringLiteral("DEFAULT"))) { + payload.insert(QStringLiteral("alterAction"), QStringLiteral("ALTER_COLUMN_DROP_DEFAULT")); + payload.insert(QStringLiteral("columnName"), columnName); + return {true, "", cmdType, payload}; + } + if (lexemeIs(tokens, operationIndex + 1, QStringLiteral("NOT")) + && operationIndex + 2 <= end + && lexemeIs(tokens, operationIndex + 2, QStringLiteral("NULL"))) { + payload.insert(QStringLiteral("alterAction"), QStringLiteral("ALTER_COLUMN_DROP_NOT_NULL")); + payload.insert(QStringLiteral("columnName"), columnName); + return {true, "", cmdType, payload}; + } + return {false, "ALTER TABLE ALTER COLUMN DROP: unsupported operation", cmdType, {}}; + } + + if (lexemeIs(tokens, operationIndex, QStringLiteral("TYPE"))) { + if (operationIndex + 1 > end) { + return {false, "ALTER TABLE ALTER COLUMN TYPE: expected type", cmdType, {}}; + } + payload.insert(QStringLiteral("alterAction"), QStringLiteral("ALTER_COLUMN_SET_TYPE")); + payload.insert(QStringLiteral("columnName"), columnName); + payload.insert(QStringLiteral("type"), tokens[operationIndex + 1].lexeme.toUpper()); + if (operationIndex + 2 <= end && tokens[operationIndex + 2].type == TokenType::LPAREN) { + const int right = findMatchingParen(tokens, operationIndex + 2, end); + if (right < 0) { + return {false, "ALTER TABLE ALTER COLUMN TYPE: unmatched type length parenthesis", cmdType, {}}; + } + if (operationIndex + 3 < right && tokens[operationIndex + 3].type == TokenType::INTEGER_LIT) { + payload.insert(QStringLiteral("length"), tokens[operationIndex + 3].lexeme.toInt()); + } + } + return {true, "", cmdType, payload}; + } + + return {false, "ALTER TABLE ALTER COLUMN: unsupported operation", cmdType, {}}; + } + if (lexemeIs(tokens, actionIndex, QStringLiteral("MODIFY"))) { if (lexemeIs(tokens, actionIndex + 1, QStringLiteral("COLUMN")) || tokens[actionIndex + 1].type == TokenType::IDENTIFIER) { @@ -523,6 +610,20 @@ ParseResult parseTableSql(const QString& sql, const QVector& tokens) return {true, "", cmdType, payload}; } + if (lexemeIs(tokens, actionIndex, QStringLiteral("RENAME"))) { + if (!lexemeIs(tokens, actionIndex + 1, QStringLiteral("COLUMN")) + || actionIndex + 4 > end + || tokens[actionIndex + 2].type != TokenType::IDENTIFIER + || !lexemeIs(tokens, actionIndex + 3, QStringLiteral("TO")) + || tokens[actionIndex + 4].type != TokenType::IDENTIFIER) { + return {false, "ALTER TABLE RENAME COLUMN: expected old column name TO new column name", cmdType, {}}; + } + payload.insert(QStringLiteral("alterAction"), QStringLiteral("RENAME_COLUMN")); + payload.insert(QStringLiteral("columnName"), tokens[actionIndex + 2].lexeme); + payload.insert(QStringLiteral("newColumnName"), tokens[actionIndex + 4].lexeme); + return {true, "", cmdType, payload}; + } + if (lexemeIs(tokens, actionIndex, QStringLiteral("DROP"))) { if (lexemeIs(tokens, actionIndex + 1, QStringLiteral("COLUMN"))) { payload.insert(QStringLiteral("alterAction"), QStringLiteral("DROP_COLUMN")); From 4b55501673872076ec73b57a4f54fcea3f647e62 Mon Sep 17 00:00:00 2001 From: chester Date: Sat, 16 May 2026 22:04:24 +0800 Subject: [PATCH 02/10] =?UTF-8?q?-=20PLAN=20=E5=88=AB=E5=90=8D=E9=80=BB?= =?UTF-8?q?=E8=BE=91=E8=A1=A5=E5=85=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ALIAS_PATCH_PLAN.md | 907 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 907 insertions(+) create mode 100644 ALIAS_PATCH_PLAN.md diff --git a/ALIAS_PATCH_PLAN.md b/ALIAS_PATCH_PLAN.md new file mode 100644 index 0000000..c0cb6e1 --- /dev/null +++ b/ALIAS_PATCH_PLAN.md @@ -0,0 +1,907 @@ +# ALIAS_PATCH_PLAN + +目标:在不扩展 SELECT 查询能力边界的前提下,为现有单表 SELECT 能力补齐表别名、限定列名、投影别名和相关子查询外层别名引用。 + +本文只规划“名字解析增强”,不规划新的关系代数能力。换句话说,本计划让现有可执行查询可以用更自然的 SQL 名字写法表达,但不新增 JOIN、GROUP BY、聚合、表达式投影、多表 FROM 或多列 ORDER BY。 + +## 总体收口边界 + +### 支持 + +1. 单表 FROM 别名: + +```sql +SELECT id FROM student s; +SELECT id FROM student AS s; +``` + +2. 限定列引用: + +```sql +SELECT s.id FROM student s; +SELECT student.id FROM student; +SELECT * FROM student s WHERE s.id = 1; +SELECT * FROM student s ORDER BY s.id DESC; +``` + +3. 投影别名: + +```sql +SELECT id AS sid FROM student; +SELECT id sid FROM student; +SELECT id AS sid FROM student ORDER BY sid DESC; +``` + +4. 相关子查询外层别名: + +```sql +SELECT * FROM parent p +WHERE EXISTS ( + SELECT * FROM child c WHERE c.parent_id = p.id +); +``` + +5. 兼容旧写法: + +```sql +SELECT * FROM parent +WHERE EXISTS ( + SELECT * FROM child WHERE child.parent_id = outer.id +); +``` + +### 不支持 + +1. 多表 FROM。 +2. JOIN。 +3. FROM 子查询。 +4. UNION。 +5. GROUP BY / HAVING。 +6. COUNT / SUM / AVG / MIN / MAX 等聚合函数。 +7. SELECT 表达式投影,例如 `SELECT a + b`。 +8. ORDER BY 表达式。 +9. 多列 ORDER BY。 +10. WHERE 中使用投影别名。 +11. `SELECT * AS alias`。 +12. 投影别名参与相关子查询绑定。 + +### 作用域规则 + +1. 本计划仍只处理单表 SELECT,所以未限定列名按当前表列解析。 +2. 本地作用域优先于外层作用域: + - 子查询内 `c.id` 优先解析为子查询本地别名 `c`。 + - 只有不属于子查询本地表名/别名的限定名前缀,才可作为外层相关引用。 +3. 外层别名引用按完整名绑定: + - `p.id` 必须能在外层 row context 中找到 `p.id`。 +4. 旧兼容引用 `outer.id` 继续有效。 +5. 投影别名只影响输出列名和 ORDER BY 解析,不影响 WHERE 解析。 + +--- + +## 阶段一:SELECT 解析层补齐别名 payload + +### 0. 常量增量 + +本阶段不新增全局常量。 + +如果实现时需要内部字符串 key,必须只在 parser 层局部使用以下 payload key,不新增公共常量文件: + +1. `tableAlias` +2. `projectionItems` +3. `sourceColumn` +4. `outputColumn` +5. `orderByColumn` +6. `orderByOutputAlias` + +### 1. 影响文件范围 + +允许修改: + +1. [utils/sql_parser/tuple_parser.cpp](utils/sql_parser/tuple_parser.cpp) +2. [utils/sql_parser/sql_tokenizer.h](utils/sql_parser/sql_tokenizer.h) +3. [utils/sql_parser/sql_tokenizer.cpp](utils/sql_parser/sql_tokenizer.cpp) +4. [tests/test_parser_dispatcher.cpp](tests/test_parser_dispatcher.cpp) +5. [tests/parserNdispatcher_test_plan.md](tests/parserNdispatcher_test_plan.md) + +首选不修改: + +1. `utils/sql_parser/sql_parser.h` +2. `controller/sql_dispatcher.cpp` +3. `service/service.h` + +如果 `AS` 目前已经能作为普通 lexeme 判断,则不新增 token;如果 tokenizer 会把 `AS` 作为普通 identifier 且影响解析可读性,可以新增 `TokenType::AS`,但仅用于 SELECT alias 解析。 + +### 2. 函数级完整数据流 + +#### 2.1 FROM 表别名 + +```text +SQL: SELECT id FROM student s +-> sqlparser::parseSql(...) +-> parseTupleSql(...) +-> 定位 FROM 后第一个 IDENTIFIER 作为 tableName = student +-> 检查 tableName 后、WHERE/ORDER/LIMIT/;/EOF 前的可选别名 +-> payload.tableName = student +-> payload.tableAlias = s +``` + +```text +SQL: SELECT id FROM student AS s +-> parseTupleSql(...) +-> tableName = student +-> AS 后 IDENTIFIER 作为 tableAlias = s +``` + +#### 2.2 投影列与投影别名 + +```text +SQL: SELECT s.id AS sid FROM student s +-> parseTupleSql(...) +-> projectionItems[0].sourceColumn = s.id +-> projectionItems[0].outputColumn = sid +-> projection = ["s.id"] // 兼容旧调用 +-> projectionOutputColumns = ["sid"] // 如需要可新增 payload key +``` + +```text +SQL: SELECT id sid FROM student +-> parseTupleSql(...) +-> projectionItems[0].sourceColumn = id +-> projectionItems[0].outputColumn = sid +``` + +```text +SQL: SELECT * FROM student s +-> selectAll = true +-> projection 仍按旧逻辑清空 +-> 不允许 SELECT * AS x +``` + +#### 2.3 ORDER BY 与投影别名 + +```text +SQL: SELECT id AS sid FROM student ORDER BY sid DESC +-> parseTupleSql(...) +-> projectionItems: id -> sid +-> orderByColumn = sid +-> orderByDescending = true +-> 不在 parser 层判断 sid 是真实列还是投影别名 +``` + +### 3. 函数级输入输出与内部逻辑 + +#### 3.1 `parseTupleSql(const QString &sql, const QVector &tokens)` + +输入: + +1. 原始 SELECT SQL。 +2. tokenizer 输出 token 列表。 + +输出: + +1. 成功时: + - `ParseResult.success = true` + - `commandType = "SELECT"` + - payload 至少包含: + - `tableName` + - `tableAlias`,无别名时为空字符串或不存在 + - `projection` + - `projectionItems` + - `selectAll` + - `whereAst` + - `conditions` + - `orderByColumn` + - `orderByDescending` + - `limit` +2. 失败时: + - `success = false` + - `errorMessage` 明确指出 SELECT alias / projection alias / trailing token 错误。 + +内部逻辑收口: + +1. FROM 后只允许一个表名和最多一个别名。 +2. 别名不得为保留 clause keyword: + - `WHERE` + - `ORDER` + - `LIMIT` +3. `AS` 后必须有 identifier。 +4. 投影 item 只允许: + - `*` + - `col` + - `table.col` + - `alias.col` + - 上述列引用加 `AS alias` + - 上述列引用后直接跟 alias +5. 多个投影 item 仍按逗号分隔。 +6. 不解析表达式投影。 +7. 不在 parser 层解析 projection alias 与真实列冲突;冲突由执行层名字解析统一处理。 + +#### 3.2 `parseOrderByClause(...)` + +输入: + +1. token 列表。 +2. `ORDER` token index。 + +输出: + +1. `payload.orderByColumn` + - 可以是 `id` + - 可以是 `s.id` + - 可以是投影别名 `sid` +2. `payload.orderByDescending` + +内部逻辑收口: + +1. 仍只允许单个 ORDER BY item。 +2. item 后只允许可选 `ASC` / `DESC`。 +3. 方向 token 后只允许: + - `LIMIT` + - `;` + - EOF +4. `ORDER BY a, b` 必须失败。 + +### 4. 测试用例构建 + +新增 parser 测试: + +1. `SELECT id FROM student s` + - `tableName = student` + - `tableAlias = s` +2. `SELECT id FROM student AS s` + - `tableAlias = s` +3. `SELECT s.id FROM student s` + - `projectionItems[0].sourceColumn = s.id` + - `outputColumn` 默认可为 `id` 或 `s.id`,执行阶段最终定义。 +4. `SELECT s.id AS sid FROM student s` + - `sourceColumn = s.id` + - `outputColumn = sid` +5. `SELECT id sid FROM student` + - `outputColumn = sid` +6. `SELECT id AS sid FROM student ORDER BY sid DESC` + - `orderByColumn = sid` + - `orderByDescending = true` +7. 失败用例: + - `SELECT * AS x FROM student` + - `SELECT id FROM student AS` + - `SELECT id FROM student s extra` + - `SELECT id FROM student ORDER BY a, b` + +--- + +## 阶段二:名字解析与执行层投影/排序归一化 + +### 0. 常量增量 + +本阶段不新增全局常量。 + +允许新增局部结构体,建议位于 `controller/sql_dispatcher.cpp` 或匿名 namespace: + +```cpp +struct SelectProjectionItem { + QString sourceName; + QString resolvedColumnName; + QString outputName; +}; + +struct SelectNameResolution { + QString tableName; + QString tableAlias; + QMap visibleColumnToRealColumn; + QMap outputAliasToRealColumn; +}; +``` + +如果发现 `TableDmlService::selectRows(...)` 必须接收输出列名,则优先新增一个局部 wrapper,避免扩大 service 公共签名。只有在无法复用时,才考虑给 `SelectRowsResult` 后处理输出列名。 + +### 1. 影响文件范围 + +允许修改: + +1. [controller/sql_dispatcher.cpp](controller/sql_dispatcher.cpp) +2. [controller/nest_query.cpp](controller/nest_query.cpp) +3. [service/table_dml_service.cpp](service/table_dml_service.cpp) +4. [service/tuple_service.cpp](service/tuple_service.cpp) +5. [service/service.h](service/service.h),仅在必须扩展 `OrderByClause` 或 SELECT 输出别名时修改。 +6. [tests/test_parser_dispatcher.cpp](tests/test_parser_dispatcher.cpp) +7. [tests/test_logic.cpp](tests/test_logic.cpp) + +首选不修改: + +1. `repo::*` +2. `table_service::*` +3. `database_service::*` + +### 2. 函数级完整数据流 + +#### 2.1 普通 SELECT 投影别名 + +```text +SQL: SELECT s.id AS sid FROM student s +-> parser payload: + tableName = student + tableAlias = s + projectionItems = [{ sourceColumn: s.id, outputColumn: sid }] +-> SqlDispatcher::execSelect(...) +-> load schema / build SelectNameResolution +-> resolve s.id -> id +-> tuple_service::selectRows(tableName, ["id"], ..., orderBy) +-> result.resultTable.columns 从 ["id"] 改写为 ["sid"] +-> SqlExecResult +``` + +#### 2.2 WHERE 限定列名 + +```text +SQL: SELECT * FROM student s WHERE s.id = 1 +-> parser whereAst 中 ColumnRef name = s.id +-> dispatcher / executor 构建 rowContext: + id + student.id + s.id +-> logic evaluator 直接找到 s.id +-> 命中行 +``` + +对于 simple conditions: + +```text +SQL: SELECT * FROM student s WHERE s.id = 1 +-> tuple_parser::isSimpleWhereNode 当前会拒绝带点列名 +-> 允许保持 complex whereAst 路径 +-> 不强求索引优化命中 +``` + +如后续要让索引优化命中,可单独把 `s.id` 归一化为 `id` 后进入 `conditions`,本计划不作为必须项。 + +#### 2.3 ORDER BY 投影别名 + +```text +SQL: SELECT id AS sid FROM student ORDER BY sid DESC +-> parser orderByColumn = sid +-> dispatcher 识别 sid 是 outputAlias +-> resolved orderBy.columnName = id +-> tuple_service::selectRows(... orderBy id DESC) +-> result columns 改写为 sid +``` + +#### 2.4 ORDER BY 限定列名 + +```text +SQL: SELECT * FROM student s ORDER BY s.id DESC +-> parser orderByColumn = s.id +-> dispatcher resolve s.id -> id +-> service ORDER BY id DESC +``` + +### 3. 函数级输入输出与内部逻辑 + +#### 3.1 `SqlDispatcher::execSelect(const sqlparser::ParseResult &p)` + +输入: + +1. SELECT ParseResult。 +2. payload 中的 `tableName`、`tableAlias`、`projectionItems`、`orderByColumn`。 + +输出: + +1. 旧输出格式保持不变。 +2. `SelectRowsResult.resultTable.columns` 使用投影输出名: + - `SELECT id AS sid` 输出 `sid` + - `SELECT s.id` 输出默认列名建议为 `id` + - `SELECT *` 输出真实列名 + +内部逻辑: + +1. 加载 schema。 +2. 构建可见名映射: + +```text +id -> id +student.id -> id +s.id -> id +outer.id -> id // 仅在外层 row context 使用;dispatcher 投影解析可不接受 outer.id +``` + +3. 解析 projection: + - `*` 直接保留。 + - `s.id` / `student.id` / `id` 解析成真实列 `id`。 + - output alias 单独记录。 +4. 解析 ORDER BY: + - 优先匹配 projection output alias。 + - 再匹配真实列/限定列。 + - 未匹配时报 `ORDER BY column 'x' does not exist`。 +5. 调用 `tuple_service::selectRows(...)` 时传真实列名。 +6. 返回前改写输出列名。 + +#### 3.2 `QueryExecutor::execSelect(...)` + +输入: + +1. 子查询 SELECT ParseResult。 +2. 可选 `CorrelationBindings`。 + +输出: + +1. 支持本地别名列引用。 +2. 支持外层别名列引用。 +3. 旧 `outer.id` 继续支持。 + +内部逻辑: + +1. 从 payload 读取 `tableAlias`。 +2. 构建本地 row context 时同时写入: + +```text +id +tableName.id +tableAlias.id +``` + +3. 如果存在外层 bindings,merge 后包含: + +```text +p.id +outer.id +``` + +4. projection 解析时使用和 dispatcher 一致的名字解析规则。 + +#### 3.3 `buildRowContext(...)` + +输入: + +1. `TableSchema schema` +2. `TableData table` +3. `rowIndex` +4. 新增可选 `tableAlias` + +输出: + +1. `LogicRowContext.cellsByName` 包含: + - 裸列名 + - 表名限定列 + - 别名限定列 + +内部逻辑: + +1. 对每一列写入 `column.name`。 +2. 写入 `schema.tableName + "." + column.name`。 +3. 如果 `tableAlias` 非空,写入 `tableAlias + "." + column.name`。 +4. 空字符串仍按 NULL-like 处理。 + +#### 3.4 `mergeBindings(...)` + +输入: + +1. 当前子查询 rowContext。 +2. 外层 correlation bindings。 + +输出: + +1. 子查询 rowContext 增加所有外层绑定。 + +内部逻辑: + +1. 保持当前按 binding.name 插入。 +2. 如果 binding 是 `p.id`,不得自动覆盖本地 `id`。 +3. 如果 binding 是 `outer.id`,只作为兼容入口。 +4. 内层本地 alias 与外层 alias 同名时,本地构建在前,merge 外层时不得覆盖已有本地限定名;如现有实现会覆盖,应改为 `if (!contains) insert`。 + +### 4. 测试用例构建 + +新增 dispatcher / executor 集成测试: + +1. 表别名投影: + +```sql +SELECT s.id FROM student s; +``` + +期望: + +1. 查询成功。 +2. 返回行数正确。 +3. 输出列名为 `id`。 + +2. 投影别名: + +```sql +SELECT id AS sid FROM student; +``` + +期望: + +1. 查询成功。 +2. 输出列名为 `sid`。 + +3. WHERE 限定列名: + +```sql +SELECT * FROM student s WHERE s.id = 1; +``` + +期望: + +1. 查询成功。 +2. 只返回 id = 1 的行。 + +4. ORDER BY 限定列名: + +```sql +SELECT * FROM student s ORDER BY s.id DESC; +``` + +期望: + +1. 查询成功。 +2. id 降序。 + +5. ORDER BY 投影别名: + +```sql +SELECT id AS sid FROM student ORDER BY sid DESC; +``` + +期望: + +1. 查询成功。 +2. 输出列名为 `sid`。 +3. 值按真实列 id 降序。 + +--- + +## 阶段三:相关子查询外层别名支持 + +### 0. 常量增量 + +本阶段不新增全局常量。 + +保留字符串约定: + +1. `outer.`:旧兼容外层引用前缀。 +2. `tableAlias.column`:新外层别名引用格式。 + +### 1. 影响文件范围 + +允许修改: + +1. [utils/logic/logic_parser.cpp](utils/logic/logic_parser.cpp) +2. [utils/logic/subquery_logic.cpp](utils/logic/subquery_logic.cpp) +3. [controller/nest_query.cpp](controller/nest_query.cpp) +4. [tests/test_logic.cpp](tests/test_logic.cpp) +5. [tests/test_parser_dispatcher.cpp](tests/test_parser_dispatcher.cpp) + +首选不修改: + +1. `utils/logic/logic_types.h` +2. `utils/logic/logic_tokenizer.cpp` +3. `utils/logic/logic_evaluator.cpp` + +如果必须在 `LogicNode` 中记录本地 table alias 集合,才修改 `logic_types.h`;首选从 subquery SQL 的 payload 中读取 `tableName/tableAlias` 并在收集 outer names 时判断。 + +### 2. 函数级完整数据流 + +```text +SQL: +SELECT * FROM parent p +WHERE EXISTS ( + SELECT * FROM child c WHERE c.parent_id = p.id +) + +-> 外层 parseTupleSql: + tableName = parent + tableAlias = p + whereAst = EXISTS subquery node + +-> logic_parser capture subquery SQL: + SELECT * FROM child c WHERE c.parent_id = p.id + +-> collectOuterNamesFromText(subquerySql): + parse subquery SELECT + local tableName = child + local tableAlias = c + whereAst contains c.parent_id and p.id + c.parent_id 是本地限定列 + p.id 不是本地 tableName/tableAlias,也不是 outer. 旧前缀 + 收集 referencedOuterNames = ["p.id"] + +-> 外层逐行执行: + buildRowContext(parent, alias p) + rowContext contains: + id + parent.id + p.id + outer.id + +-> buildCorrelationBindings(rowContext, ["p.id"]) + binding p.id = 当前外层行 id + +-> executeCorrelatedSelect(subquerySql, binding p.id) + 子查询逐行构建 local rowContext: + parent_id + child.parent_id + c.parent_id + merge binding: + p.id + evaluate c.parent_id = p.id +``` + +### 3. 函数级输入输出与内部逻辑 + +#### 3.1 `collectOuterNamesFromText(const QString &text, QStringList *names, LogicError *error)` + +输入: + +1. 子查询 SQL 原文。 + +输出: + +1. `names` 包含子查询中引用的外层限定名。 +2. 本地 tableName/tableAlias 限定名不进入 `names`。 + +内部逻辑: + +1. 调用 `sqlparser::parseSql(text)`。 +2. 读取: + - `payload.tableName` + - `payload.tableAlias` +3. 本地允许前缀集合: + +```text +tableName + "." +tableAlias + "." +``` + +4. 遍历 whereAst: + - `outer.xxx`:加入外层引用。 + - `localTable.xxx`:本地引用,忽略。 + - `localAlias.xxx`:本地引用,忽略。 + - `other.xxx`:加入外层引用。 +5. 如果 `other.xxx` 既不是本地也无法在外层绑定,执行阶段报 missing correlated binding。 + +#### 3.2 `buildCorrelationBindings(const LogicRowContext &outerRowContext, const QStringList &referencedOuterNames)` + +输入: + +1. 外层 row context。 +2. 相关引用名列表,例如: + - `p.id` + - `outer.id` + +输出: + +1. `CorrelationBindings.items`。 + +内部逻辑: + +1. 先按完整名查找: + - `p.id` + - `outer.id` +2. 如果查不到,保留当前 fallback:取点号后的 local name 再查找。 +3. fallback 只为旧兼容和缺省别名服务,不得覆盖完整名优先规则。 + +#### 3.3 `QueryExecutor::execSelect(...)` + +输入: + +1. SELECT ParseResult。 +2. 可选外层 bindings。 + +输出: + +1. 子查询 SELECT 正常执行。 +2. 缺少相关绑定时报: + +```text +missing correlated binding 'p.id' +``` + +内部逻辑: + +1. 进入每行 evaluation 前,构造 local row context。 +2. local row context 包含本地裸列、表名限定列、别名限定列。 +3. merge 外层 bindings。 +4. 评估 whereAst。 + +### 4. 测试用例构建 + +新增 logic parser 测试: + +1. 新外层别名引用: + +```text +EXISTS (SELECT id FROM child c WHERE c.parent_id = p.id) +``` + +期望: + +```text +referencedOuterNames = ["p.id"] +``` + +2. 本地别名不被误判: + +```text +EXISTS (SELECT id FROM child c WHERE c.parent_id = 1) +``` + +期望: + +```text +referencedOuterNames = [] +``` + +3. 旧写法兼容: + +```text +EXISTS (SELECT id FROM child WHERE child.parent_id = outer.id) +``` + +期望: + +```text +referencedOuterNames = ["outer.id"] +``` + +新增 dispatcher / integration 测试: + +1. `EXISTS` 相关子查询: + +```sql +SELECT * FROM parent p +WHERE EXISTS ( + SELECT * FROM child c WHERE c.parent_id = p.id +); +``` + +数据: + +```text +parent: (1), (2), (3) +child: (10, 1), (11, 1), (12, 3) +``` + +期望: + +```text +返回 parent id = 1, 3 +``` + +2. `IN` 相关子查询: + +```sql +SELECT * FROM parent p +WHERE p.id IN ( + SELECT c.parent_id FROM child c WHERE c.parent_id = p.id +); +``` + +期望: + +```text +返回 parent id = 1, 3 +``` + +3. `ANY/ALL` 相关子查询如果现有测试已覆盖 `outer.id`,新增同构 alias 版本;如果执行层目前不支持该 SQL 形态,则只补 logic 层测试,不扩大执行范围。 + +--- + +## 阶段四:文档与回归收口 + +### 0. 常量增量 + +本阶段不新增常量。 + +### 1. 影响文件范围 + +允许修改: + +1. [tests/parserNdispatcher_test_plan.md](tests/parserNdispatcher_test_plan.md) +2. [tests/TEST_PLAN.md](tests/TEST_PLAN.md) +3. [INTEGRATION_AND_STRESS_TEST_PLAN.md](INTEGRATION_AND_STRESS_TEST_PLAN.md),仅当集成压测文档需要说明相关子查询 alias 时修改。 + +不修改: + +1. 性能图表脚本。 +2. 压测规模。 +3. 已有索引排序图表命名。 + +### 2. 函数级完整数据流 + +本阶段不新增运行时数据流,只对前述阶段的数据流做文档化: + +```text +SQL alias syntax +-> parser payload +-> dispatcher name resolution +-> service execution +-> rowContext alias binding +-> subquery correlated binding +-> tests +``` + +### 3. 函数级输入输出与内部逻辑 + +本阶段不新增函数。 + +文档必须明确: + +1. 别名增强只覆盖单表 SELECT。 +2. 投影别名只影响输出和 ORDER BY。 +3. WHERE 不支持投影别名。 +4. 相关子查询支持外层表别名。 +5. 旧 `outer.xxx` 写法保留。 + +### 4. 测试用例构建 + +最终回归命令: + +```powershell +& 'E:\Qt\Tools\CMake_64\bin\cmake.exe' --build build/codex-vs-debug --config Debug --parallel 1 +``` + +```powershell +$env:QT_QPA_PLATFORM='offscreen' +$env:PATH='E:\Qt\6.9.2\msvc2022_64\bin;' + $env:PATH +& 'E:\Qt-projects\DBMS\build\codex-vs-debug\Debug\DBMS.exe' --run-tests +``` + +如果实施影响索引排序相关 SELECT 路径,再额外刷新性能 CSV 和图表: + +```powershell +$env:DBMS_PERF_CSV_PATH='E:\Qt-projects\DBMS\build\performance_samples.csv' +$env:DBMS_STRESS_ROW_COUNTS='50,100,200,500' +& 'E:\Qt-projects\DBMS\build\codex-vs-debug\Debug\DBMS.exe' --run-tests +``` + +```powershell +wsl bash -lc "source ~/miniconda3/etc/profile.d/conda.sh && conda activate base && cd /mnt/e/Qt-projects/DBMS && python tests/tools/plot_performance_charts.py" +``` + +--- + +## 实施顺序建议 + +1. 阶段一先单独完成 parser payload 和 parser 测试。 +2. 阶段二完成普通 SELECT 的别名投影、WHERE 限定列、ORDER BY 限定列和投影别名。 +3. 阶段三再接相关子查询外层别名。 +4. 阶段四补文档并跑全量回归。 + +阶段二完成前,不进入阶段三。原因:相关子查询 alias 依赖本地 row context 和限定列解析,直接做阶段三会把 parser 问题和执行问题混在一起。 + +--- + +## 验收口径 + +本计划完成时,以下 SQL 必须通过: + +```sql +SELECT s.id FROM student s; +SELECT id AS sid FROM student ORDER BY sid DESC; +SELECT * FROM student s WHERE s.id = 1; +SELECT * FROM student s ORDER BY s.id DESC; +SELECT * FROM parent p +WHERE EXISTS ( + SELECT * FROM child c WHERE c.parent_id = p.id +); +``` + +以下 SQL 必须继续失败: + +```sql +SELECT * AS x FROM student; +SELECT id FROM student s extra; +SELECT id FROM student ORDER BY a, b; +SELECT a + b FROM student; +SELECT COUNT(*) FROM student; +SELECT * FROM a JOIN b ON a.id = b.id; +``` + +完成后不得破坏: + +1. 旧 `SELECT * FROM t WHERE id = 1`。 +2. 旧 `SELECT id FROM t LIMIT 1`。 +3. 旧 `SELECT id FROM t ORDER BY id DESC`。 +4. 旧 `outer.id` 相关子查询。 +5. 索引排序专项压测。 From 773c7cec7c70b942ee0ff73c57ffff452d432d80 Mon Sep 17 00:00:00 2001 From: chester Date: Sat, 16 May 2026 22:24:52 +0800 Subject: [PATCH 03/10] =?UTF-8?q?-=20ADDED=20=E8=A1=A5=E5=85=85=E4=BA=86?= =?UTF-8?q?=E5=88=AB=E5=90=8D=E6=9F=A5=E8=AF=A2=E7=9A=84=E5=AE=9E=E7=8E=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ALIAS_PATCH_PLAN.md | 24 +++ controller/nest_query.cpp | 105 +++++++++++- controller/sql_dispatcher.cpp | 240 ++++++++++++++++++++++++++- tests/TEST_PLAN.md | 4 +- tests/parserNdispatcher_test_plan.md | 10 +- tests/test_logic.cpp | 38 +++-- tests/test_parser_dispatcher.cpp | 107 ++++++++++++ utils/logic/logic_parser.cpp | 36 ++-- utils/sql_parser/tuple_parser.cpp | 230 ++++++++++++++++++++++++- 9 files changed, 740 insertions(+), 54 deletions(-) diff --git a/ALIAS_PATCH_PLAN.md b/ALIAS_PATCH_PLAN.md index c0cb6e1..3d1db3c 100644 --- a/ALIAS_PATCH_PLAN.md +++ b/ALIAS_PATCH_PLAN.md @@ -4,6 +4,30 @@ 本文只规划“名字解析增强”,不规划新的关系代数能力。换句话说,本计划让现有可执行查询可以用更自然的 SQL 名字写法表达,但不新增 JOIN、GROUP BY、聚合、表达式投影、多表 FROM 或多列 ORDER BY。 +## 当前实现状态 + +状态:已完成,并通过全量回归。 + +已落地能力: + +1. parser 输出单表 `tableAlias`、`projectionItems`、限定列名和投影别名 payload。 +2. dispatcher 将投影列、限定列名、`ORDER BY` 列名和投影别名归一化为真实列名后下推 service。 +3. 查询执行器为本地行上下文补充裸列名、表名限定列和表别名限定列。 +4. 相关子查询支持外层表别名引用,例如 `p.id`,并保留旧 `outer.id` 兼容路径。 +5. 本地作用域优先于外层作用域,外层 binding merge 不覆盖本地同名限定列。 + +最近验证: + +```powershell +& 'E:\Qt\Tools\CMake_64\bin\cmake.exe' --build build/codex-vs-debug --config Debug --parallel 1 +``` + +```powershell +$env:QT_QPA_PLATFORM='offscreen' +$env:PATH='E:\Qt\6.9.2\msvc2022_64\bin;' + $env:PATH +& 'E:\Qt-projects\DBMS\build\codex-vs-debug\Debug\DBMS.exe' --run-tests +``` + ## 总体收口边界 ### 支持 diff --git a/controller/nest_query.cpp b/controller/nest_query.cpp index 834bd57..609380a 100644 --- a/controller/nest_query.cpp +++ b/controller/nest_query.cpp @@ -81,7 +81,8 @@ QStringList requiredOuterReferences(const logic::LogicNode &node) logic::LogicRowContext buildRowContext(const tabledef::TableSchema &schema, const repo::TableData &table, - int rowIndex) + int rowIndex, + const QString &tableAlias = QString()) { logic::LogicRowContext rowContext; rowContext.tableName = schema.tableName; @@ -98,6 +99,18 @@ logic::LogicRowContext buildRowContext(const tabledef::TableSchema &schema, logic::LogicCellValue{value, column.type, value.isEmpty()}); + if (!schema.tableName.trimmed().isEmpty()) { + rowContext.cellsByName.insert(schema.tableName + QLatin1Char('.') + column.name, + logic::LogicCellValue{value, + column.type, + value.isEmpty()}); + } + if (!tableAlias.trimmed().isEmpty()) { + rowContext.cellsByName.insert(tableAlias.trimmed() + QLatin1Char('.') + column.name, + logic::LogicCellValue{value, + column.type, + value.isEmpty()}); + } } return rowContext; } @@ -109,9 +122,66 @@ void mergeBindings(logic::LogicRowContext *rowContext, const logic::CorrelationB } for (const logic::CorrelatedBinding &binding : bindings.items) { - rowContext->cellsByName.insert(binding.name, - logic::LogicCellValue{binding.value, binding.type, binding.isNull}); + if (!rowContext->cellsByName.contains(binding.name)) { + rowContext->cellsByName.insert(binding.name, + logic::LogicCellValue{binding.value, binding.type, binding.isNull}); + } + } +} + +QString unqualifiedName(const QString &name) +{ + const int dotIndex = name.lastIndexOf(QLatin1Char('.')); + return dotIndex >= 0 && dotIndex + 1 < name.size() ? name.mid(dotIndex + 1) : name; +} + +QMap visibleColumnMap(const tabledef::TableSchema &schema, const QString &tableAlias) +{ + QMap visible; + for (const tabledef::Column &column : schema.columns) { + visible.insert(column.name, column.name); + if (!schema.tableName.trimmed().isEmpty()) { + visible.insert(schema.tableName + QLatin1Char('.') + column.name, column.name); + } + if (!tableAlias.trimmed().isEmpty()) { + visible.insert(tableAlias.trimmed() + QLatin1Char('.') + column.name, column.name); + } } + return visible; +} + +bool resolveColumnName(const QMap &visibleColumns, + const QString &name, + QString *resolved, + QString *error) +{ + const auto found = visibleColumns.constFind(name.trimmed()); + if (found == visibleColumns.constEnd()) { + if (error != nullptr) { + *error = QStringLiteral("SELECT: column '%1' does not exist").arg(name); + } + return false; + } + if (resolved != nullptr) { + *resolved = found.value(); + } + return true; +} + +QVariantList projectionItemsFromPayload(const QVariantMap &payload) +{ + QVariantList items = payload.value(QStringLiteral("projectionItems")).toList(); + if (!items.isEmpty()) { + return items; + } + const QStringList projection = payload.value(QStringLiteral("projection")).toStringList(); + for (const QString &columnName : projection) { + QVariantMap item; + item.insert(QStringLiteral("sourceColumn"), columnName); + item.insert(QStringLiteral("outputColumn"), unqualifiedName(columnName)); + items.append(item); + } + return items; } bool applySimpleConditions(const repo::TableData &table, @@ -294,6 +364,8 @@ QueryExecuteResult QueryExecutor::execSelect(const sqlparser::ParseResult &parse const QStringList projection = parsed.payload.value(QStringLiteral("projection")).toStringList(); const bool selectAll = parsed.payload.value(QStringLiteral("selectAll"), false).toBool(); const int limit = parsed.payload.value(QStringLiteral("limit"), -1).toInt(); + const QString tableAlias = parsed.payload.value(QStringLiteral("tableAlias")).toString().trimmed(); + const QMap visibleColumns = visibleColumnMap(schema, tableAlias); auto columnTypeForName = [&](const QString &columnName) { for (const tabledef::Column &column : schema.columns) { @@ -306,6 +378,8 @@ QueryExecuteResult QueryExecutor::execSelect(const sqlparser::ParseResult &parse SelectRowsResult selectResult; selectResult.success = true; + QStringList resolvedProjection; + QStringList outputProjection; if (selectAll) { selectResult.resultTable.columns = schema.columns.isEmpty() @@ -323,10 +397,23 @@ QueryExecuteResult QueryExecutor::execSelect(const sqlparser::ParseResult &parse result.text = result.errorMessage; return result; } - selectResult.resultTable.columns = projection; - for (const QString &columnName : projection) { - selectResult.columnTypes.append(columnTypeForName(columnName)); + const QVariantList projectionItems = projectionItemsFromPayload(parsed.payload); + for (const QVariant &itemValue : projectionItems) { + const QVariantMap item = itemValue.toMap(); + const QString sourceColumn = item.value(QStringLiteral("sourceColumn")).toString(); + const QString outputColumn = item.value(QStringLiteral("outputColumn"), unqualifiedName(sourceColumn)).toString(); + QString resolvedColumn; + if (!resolveColumnName(visibleColumns, sourceColumn, &resolvedColumn, &error)) { + result.success = false; + result.errorMessage = error; + result.text = error; + return result; + } + resolvedProjection.append(resolvedColumn); + outputProjection.append(outputColumn.trimmed().isEmpty() ? unqualifiedName(sourceColumn) : outputColumn); + selectResult.columnTypes.append(columnTypeForName(resolvedColumn)); } + selectResult.resultTable.columns = outputProjection; } const bool useSimpleConditions = !hasWhereAst @@ -345,7 +432,7 @@ QueryExecuteResult QueryExecutor::execSelect(const sqlparser::ParseResult &parse } for (int rowIndex = 0; rowIndex < tableData.rows.size(); ++rowIndex) { - logic::LogicRowContext rowContext = buildRowContext(schema, tableData, rowIndex); + logic::LogicRowContext rowContext = buildRowContext(schema, tableData, rowIndex, tableAlias); if (bindings != nullptr) { mergeBindings(&rowContext, *bindings); } @@ -375,7 +462,7 @@ QueryExecuteResult QueryExecutor::execSelect(const sqlparser::ParseResult &parse projectedRow = tableData.rows.at(rowIndex); } else { projectedRow.reserve(projection.size()); - for (const QString &columnName : projection) { + for (const QString &columnName : resolvedProjection) { int columnIndex = -1; for (int schemaIndex = 0; schemaIndex < schema.columns.size(); ++schemaIndex) { if (schema.columns.at(schemaIndex).name == columnName) { @@ -404,4 +491,4 @@ QueryExecuteResult QueryExecutor::execSelect(const sqlparser::ParseResult &parse return result; } -} // namespace service \ No newline at end of file +} // namespace service diff --git a/controller/sql_dispatcher.cpp b/controller/sql_dispatcher.cpp index 1ab09db..c391cee 100644 --- a/controller/sql_dispatcher.cpp +++ b/controller/sql_dispatcher.cpp @@ -269,6 +269,218 @@ bool simpleConditionsFromPayload(const QVariantList &conditionsPayload, return true; } +struct SelectProjectionItem { + QString sourceName; + QString resolvedColumnName; + QString outputName; +}; + +struct SelectNameResolution { + QString tableName; + QString tableAlias; + QMap visibleColumnToRealColumn; + QMap outputAliasToRealColumn; +}; + +QString unqualifiedName(const QString &name) +{ + const int dotIndex = name.lastIndexOf(QLatin1Char('.')); + return dotIndex >= 0 && dotIndex + 1 < name.size() ? name.mid(dotIndex + 1) : name; +} + +SelectNameResolution buildSelectNameResolution(const tabledef::TableSchema &schema, + const QString &tableAlias) +{ + SelectNameResolution resolution; + resolution.tableName = schema.tableName; + resolution.tableAlias = tableAlias.trimmed(); + for (const tabledef::Column &column : schema.columns) { + resolution.visibleColumnToRealColumn.insert(column.name, column.name); + if (!schema.tableName.trimmed().isEmpty()) { + resolution.visibleColumnToRealColumn.insert(schema.tableName + QLatin1Char('.') + column.name, column.name); + } + if (!resolution.tableAlias.isEmpty()) { + resolution.visibleColumnToRealColumn.insert(resolution.tableAlias + QLatin1Char('.') + column.name, column.name); + } + } + return resolution; +} + +bool resolveVisibleColumn(const SelectNameResolution &resolution, + const QString &name, + QString *resolvedColumn, + QString *error) +{ + const QString trimmed = name.trimmed(); + const auto found = resolution.visibleColumnToRealColumn.constFind(trimmed); + if (found != resolution.visibleColumnToRealColumn.constEnd()) { + if (resolvedColumn != nullptr) { + *resolvedColumn = found.value(); + } + return true; + } + if (error != nullptr) { + *error = QStringLiteral("SELECT: column '%1' does not exist").arg(name); + } + return false; +} + +QVariantList projectionItemsFromPayload(const QVariantMap &payload) +{ + QVariantList items = payload.value(QStringLiteral("projectionItems")).toList(); + if (!items.isEmpty()) { + return items; + } + const QStringList projection = payload.value(QStringLiteral("projection")).toStringList(); + for (const QString &columnName : projection) { + QVariantMap item; + item.insert(QStringLiteral("sourceColumn"), columnName); + item.insert(QStringLiteral("outputColumn"), unqualifiedName(columnName)); + items.append(item); + } + return items; +} + +bool resolveProjectionItems(const SelectNameResolution &resolution, + const QVariantList &payloadItems, + QList *items, + QString *error) +{ + if (items != nullptr) { + items->clear(); + } + for (const QVariant &value : payloadItems) { + if (value.typeId() != QMetaType::QVariantMap) { + if (error != nullptr) { + *error = QStringLiteral("SELECT projection payload is incomplete"); + } + return false; + } + const QVariantMap map = value.toMap(); + const QString sourceName = map.value(QStringLiteral("sourceColumn")).toString().trimmed(); + const QString outputName = map.value(QStringLiteral("outputColumn"), unqualifiedName(sourceName)).toString().trimmed(); + QString resolvedColumn; + if (!resolveVisibleColumn(resolution, sourceName, &resolvedColumn, error)) { + return false; + } + if (items != nullptr) { + items->append(SelectProjectionItem{sourceName, + resolvedColumn, + outputName.isEmpty() ? unqualifiedName(sourceName) : outputName}); + } + } + return true; +} + +QStringList resolvedProjectionColumns(const QList &items) +{ + QStringList columns; + for (const SelectProjectionItem &item : items) { + columns.append(item.resolvedColumnName); + } + return columns; +} + +QStringList outputProjectionColumns(const QList &items) +{ + QStringList columns; + for (const SelectProjectionItem &item : items) { + columns.append(item.outputName); + } + return columns; +} + +bool resolveOrderByColumn(const SelectNameResolution &resolution, + const QList &projectionItems, + const QString &rawOrderBy, + QString *resolvedColumn, + QString *error) +{ + const QString trimmed = rawOrderBy.trimmed(); + if (trimmed.isEmpty()) { + if (resolvedColumn != nullptr) { + resolvedColumn->clear(); + } + return true; + } + for (const SelectProjectionItem &item : projectionItems) { + if (item.outputName == trimmed) { + if (resolvedColumn != nullptr) { + *resolvedColumn = item.resolvedColumnName; + } + return true; + } + } + if (resolveVisibleColumn(resolution, trimmed, resolvedColumn, nullptr)) { + return true; + } + if (error != nullptr) { + *error = QStringLiteral("ORDER BY column '%1' does not exist").arg(rawOrderBy); + } + return false; +} + +bool normalizeSimpleConditions(const SelectNameResolution &resolution, + QList *conditions, + QString *error) +{ + if (conditions == nullptr) { + return true; + } + for (SimpleCondition &condition : *conditions) { + QString resolvedColumn; + if (!resolveVisibleColumn(resolution, condition.columnName, &resolvedColumn, error)) { + return false; + } + condition.columnName = resolvedColumn; + } + return true; +} + +bool prepareSelectNames(const sqlparser::ParseResult &p, + const tabledef::TableSchema &schema, + QStringList *resolvedProjection, + QStringList *outputProjection, + OrderByClause *orderBy, + QString *error) +{ + const SelectNameResolution resolution = + buildSelectNameResolution(schema, p.payload.value(QStringLiteral("tableAlias")).toString()); + QList projectionItems; + const QStringList legacyProjection = p.payload.value(QStringLiteral("projection")).toStringList(); + const bool selectAll = p.payload.value(QStringLiteral("selectAll"), false).toBool() + || (legacyProjection.size() == 1 && legacyProjection.first() == QStringLiteral("*")); + if (!selectAll) { + if (!resolveProjectionItems(resolution, + projectionItemsFromPayload(p.payload), + &projectionItems, + error)) { + return false; + } + } + + QString resolvedOrderBy; + if (!resolveOrderByColumn(resolution, + projectionItems, + p.payload.value(QStringLiteral("orderByColumn")).toString(), + &resolvedOrderBy, + error)) { + return false; + } + + if (resolvedProjection != nullptr) { + *resolvedProjection = selectAll ? QStringList{QStringLiteral("*")} : resolvedProjectionColumns(projectionItems); + } + if (outputProjection != nullptr) { + *outputProjection = selectAll ? QStringList{} : outputProjectionColumns(projectionItems); + } + if (orderBy != nullptr) { + orderBy->columnName = resolvedOrderBy; + orderBy->descending = p.payload.value(QStringLiteral("orderByDescending"), false).toBool(); + } + return true; +} + } // namespace // ============================================================ @@ -606,6 +818,13 @@ SqlExecResult SqlDispatcher::execSelect(const sqlparser::ParseResult& p) { if (currentDatabase.isEmpty()) return {false, "No database selected. Use USE database_name;"}; + const QString table = p.payload["tableName"].toString(); + QString schemaError; + const tabledef::TableSchema schema = loadUserTableSchema(table, &schemaError); + if (!schemaError.isEmpty()) { + return {false, schemaError}; + } + if (p.payload.value(QStringLiteral("hasComplexWhere")).toBool()) { QueryExecutor executor; const QueryExecuteResult queryResult = executor.executeParsed(p, @@ -629,22 +848,33 @@ SqlExecResult SqlDispatcher::execSelect(const sqlparser::ParseResult& p) { p.payload}; } - QString table = p.payload["tableName"].toString(); - QStringList projection = p.payload["projection"].toStringList(); + QStringList projection; + QStringList outputProjection; const int limit = p.payload.value(QStringLiteral("limit"), -1).toInt(); OrderByClause orderBy; - orderBy.columnName = p.payload.value(QStringLiteral("orderByColumn")).toString().trimmed(); - orderBy.descending = p.payload.value(QStringLiteral("orderByDescending"), false).toBool(); + QString nameError; + if (!prepareSelectNames(p, schema, &projection, &outputProjection, &orderBy, &nameError)) { + return {false, nameError}; + } // WHERE 尚未完整实现,暂不传递条件 QList conditions; QString conditionError; if (!simpleConditionsFromPayload(p.payload.value(QStringLiteral("conditions")).toList(), &conditions, &conditionError)) { return {false, conditionError}; } + if (!normalizeSimpleConditions(buildSelectNameResolution(schema, p.payload.value(QStringLiteral("tableAlias")).toString()), + &conditions, + &conditionError)) { + return {false, conditionError}; + } auto r = tuple_service::selectRows(table, projection, conditions, limit, orderBy); - if (r.success) + if (r.success) { + if (!outputProjection.isEmpty()) { + r.resultTable.columns = outputProjection; + } return {true, {}, formatSelectResult(r), r.affectedRowCount, r}; + } return {false, r.errorMessage}; } diff --git a/tests/TEST_PLAN.md b/tests/TEST_PLAN.md index 22792aa..2eb2be5 100644 --- a/tests/TEST_PLAN.md +++ b/tests/TEST_PLAN.md @@ -82,7 +82,7 @@ | 测试输入 | 预期输出 | 通过真值 | | --- | --- | --- | -| `CREATE TABLE`、`SELECT`、`INSERT`、`UPDATE`、`DELETE`、`ALTER TABLE`、`CREATE INDEX` 等 SQL。 | parser 生成正确 payload;dispatcher 成功执行合法 SQL 并拒绝非法 payload。 | 解析字段、执行结果、错误路径均符合断言。 | +| `CREATE TABLE`、`SELECT`、`INSERT`、`UPDATE`、`DELETE`、`ALTER TABLE`、`CREATE INDEX` 等 SQL。 | parser 生成正确 payload;dispatcher 成功执行合法 SQL 并拒绝非法 payload;单表 `SELECT` 支持表别名、限定列名、投影别名和 `ORDER BY` 投影别名。 | 解析字段、执行结果、错误路径均符合断言。 | ### 2.3 DDL 建表、删表与表结构维护 @@ -178,7 +178,7 @@ | 测试输入 | 预期输出 | 通过真值 | | --- | --- | --- | -| `EXISTS`、`IN`、`ANY`、`ALL`、相关子查询、缺失绑定。 | 子查询按外层行逐行求值;非法绑定返回错误。 | 子查询布尔结果、错误信息和最终查询结果符合断言。 | +| `EXISTS`、`IN`、`ANY`、`ALL`、相关子查询、缺失绑定、外层表别名引用。 | 子查询按外层行逐行求值;支持 `p.id` 这类外层别名绑定并兼容旧 `outer.id`;非法绑定返回错误。 | 子查询布尔结果、错误信息和最终查询结果符合断言。 | ### 2.11 索引创建、删除与查询维护 diff --git a/tests/parserNdispatcher_test_plan.md b/tests/parserNdispatcher_test_plan.md index 5358922..108a9bf 100644 --- a/tests/parserNdispatcher_test_plan.md +++ b/tests/parserNdispatcher_test_plan.md @@ -15,7 +15,7 @@ - `test_parseCreateTableUsesQtBasePayload` 验证 `CREATE TABLE` 输出的 `columns`、`constraints` 都是 Qt 基础类型,并保留列级 / 表级 FK 动作。 - `test_parseSelectLimitAndSimpleWhere` - 验证 `SELECT ... WHERE ... AND ... LIMIT ...` 能正确输出 `projection / tableName / limit / conditions`。 + 验证 `SELECT ... WHERE ... AND ... LIMIT ...` 能正确输出 `projection / tableName / limit / conditions`,并覆盖单表别名、限定列名、投影别名和 `ORDER BY` 投影别名 payload。 - `test_parseUpdateAndDeleteSupportSimpleWhere` 验证 `UPDATE`、`DELETE` 的简单 `WHERE` 会转成 `conditions`。 - `test_parseInsertWithoutColumnListProducesSingleRowPayload` @@ -28,7 +28,7 @@ ### parser 失败路径 - `test_parseSelectLimitAndSimpleWhere` - 验证 `SELECT ... ORDER BY ...` 能输出排序列和升降序 payload。 + 验证 `SELECT ... ORDER BY ...` 能输出排序列和升降序 payload,并拒绝 `SELECT * AS alias`、多列排序和表名后多余 token。 - `test_parseUpdateAndDeleteSupportSimpleWhere` 验证 `>` 这类非等值谓词被拒绝。 - `test_parseWhereRejectsUnsupportedForms` @@ -50,6 +50,10 @@ 验证 `RENAME COLUMN` 会保留行数据,并同步更新索引元数据中的列名。 - `test_dispatcherWhereAndLimitFlowToService` 验证 `SELECT / UPDATE / DELETE` 的简单 `WHERE` 和 `LIMIT` 会真正下推到 `tuple_service`。 +- `test_dispatchSelectAliasAndQualifiedColumns` + 验证 dispatcher 能解析单表别名、限定列名、投影别名和 `ORDER BY` 投影别名,并按输出别名返回结果列。 +- `test_dispatchCorrelatedSubqueryUsesOuterAlias` + 验证相关子查询可以使用外层表别名引用,例如 `p.id`,同时保持本地别名作用域优先。 - `test_dispatcherIndexSqlUsesService` 验证 `CREATE INDEX / DROP INDEX` 会真正下推到 `table_service`。 - `test_dispatcherUniqueAndMultiColumnIndexSqlUseService` @@ -67,6 +71,8 @@ 当前 parser / dispatcher 测试已经覆盖: - `CREATE TABLE` - `SELECT ... WHERE ... AND ... LIMIT ...` +- `SELECT` 单表别名、限定列名、投影别名和 `ORDER BY` 投影别名 +- `EXISTS` 相关子查询外层表别名引用 - `UPDATE ... WHERE ...` - `DELETE ... WHERE ...` - `INSERT` diff --git a/tests/test_logic.cpp b/tests/test_logic.cpp index c2e0a6d..992032a 100644 --- a/tests/test_logic.cpp +++ b/tests/test_logic.cpp @@ -180,7 +180,7 @@ private slots: QStringLiteral(" SELECT id FROM child WHERE child.parent_id = outer.id ")); } - void test_parseCorrelatedReferenceRejectsTableNamePrefix() + void test_parseCorrelatedReferenceCollectsOuterTablePrefix() { const QString expression = QStringLiteral( "EXISTS (SELECT id FROM child WHERE child.parent_id = parent.id)"); @@ -189,11 +189,11 @@ private slots: QVERIFY2(tokenized.success, qPrintable(tokenized.error.message)); const logic::LogicParseResult parsed = logic::parseLogicTokens(expression, tokenized.tokens); - QVERIFY(!parsed.success); - QVERIFY(parsed.error.message.contains(QStringLiteral("outer.xxx"))); + QVERIFY2(parsed.success, qPrintable(parsed.error.message)); + QCOMPARE(parsed.root.referencedOuterNames, QStringList({QStringLiteral("parent.id")})); } - void test_parseCorrelatedReferenceRejectsAliasPrefix() + void test_parseCorrelatedReferenceAllowsLocalAliasPrefix() { const QString expression = QStringLiteral( "EXISTS (SELECT id FROM child c WHERE c.parent_id = outer.id)"); @@ -202,8 +202,21 @@ private slots: QVERIFY2(tokenized.success, qPrintable(tokenized.error.message)); const logic::LogicParseResult parsed = logic::parseLogicTokens(expression, tokenized.tokens); - QVERIFY(!parsed.success); - QVERIFY(parsed.error.message.contains(QStringLiteral("outer.xxx"))); + QVERIFY2(parsed.success, qPrintable(parsed.error.message)); + QCOMPARE(parsed.root.referencedOuterNames, QStringList({QStringLiteral("outer.id")})); + } + + void test_parseCorrelatedReferenceCollectsOuterAliasPrefix() + { + const QString expression = QStringLiteral( + "EXISTS (SELECT id FROM child c WHERE c.parent_id = p.id)"); + + const logic::LogicTokenizeResult tokenized = logic::tokenizeLogicExpression(expression); + QVERIFY2(tokenized.success, qPrintable(tokenized.error.message)); + + const logic::LogicParseResult parsed = logic::parseLogicTokens(expression, tokenized.tokens); + QVERIFY2(parsed.success, qPrintable(parsed.error.message)); + QCOMPARE(parsed.root.referencedOuterNames, QStringList({QStringLiteral("p.id")})); } void test_buildCorrelationBindingsExtractsTypedOuterValues() @@ -849,8 +862,8 @@ private slots: QVERIFY2(tokenized.success, qPrintable(tokenized.error.message)); const logic::LogicParseResult parsed = logic::parseLogicTokens(expression, tokenized.tokens); - QVERIFY(!parsed.success); - QVERIFY(parsed.error.position >= 0); + QVERIFY2(parsed.success, qPrintable(parsed.error.message)); + QCOMPARE(parsed.root.referencedOuterNames, QStringList({QStringLiteral("parent.id")})); } void test_existsSubqueryWithoutExecutorReturnsError() @@ -1002,13 +1015,8 @@ private slots: const auto tokenized = logic::tokenizeLogicExpression(expr); QVERIFY2(tokenized.success, qPrintable(tokenized.error.message)); const auto parsed = logic::parseLogicTokens(expr, tokenized.tokens); - QVERIFY(!parsed.success); - QCOMPARE(parsed.error.message, QStringLiteral("only outer.xxx is allowed in correlated subqueries")); - const int start = expr.indexOf('('); - const int end = expr.lastIndexOf(')'); - const QString sub = expr.mid(start + 1, end - start - 1); - const int expectedPos = sub.indexOf(QStringLiteral("parent.id")); - QCOMPARE(parsed.error.position, expectedPos); + QVERIFY2(parsed.success, qPrintable(parsed.error.message)); + QCOMPARE(parsed.root.referencedOuterNames, QStringList({QStringLiteral("parent.id")})); } // EXISTS missing parentheses diff --git a/tests/test_parser_dispatcher.cpp b/tests/test_parser_dispatcher.cpp index d31c527..3411aa3 100644 --- a/tests/test_parser_dispatcher.cpp +++ b/tests/test_parser_dispatcher.cpp @@ -252,6 +252,29 @@ private slots: const sqlparser::ParseResult trailingAfterLimit = sqlparser::parseSql( QStringLiteral("SELECT * FROM student LIMIT 2 garbage")); QVERIFY(!trailingAfterLimit.success); + + const sqlparser::ParseResult aliasedTable = sqlparser::parseSql( + QStringLiteral("SELECT s.id AS sid FROM student AS s ORDER BY sid DESC")); + QVERIFY2(aliasedTable.success, qPrintable(aliasedTable.errorMessage)); + QCOMPARE(aliasedTable.payload.value(QStringLiteral("tableAlias")).toString(), QStringLiteral("s")); + QCOMPARE(aliasedTable.payload.value(QStringLiteral("projection")).toStringList(), + QStringList({QStringLiteral("s.id")})); + const QVariantMap projectionItem = aliasedTable.payload.value(QStringLiteral("projectionItems")).toList().first().toMap(); + QCOMPARE(projectionItem.value(QStringLiteral("sourceColumn")).toString(), QStringLiteral("s.id")); + QCOMPARE(projectionItem.value(QStringLiteral("outputColumn")).toString(), QStringLiteral("sid")); + QCOMPARE(aliasedTable.payload.value(QStringLiteral("orderByColumn")).toString(), QStringLiteral("sid")); + + const sqlparser::ParseResult starAlias = sqlparser::parseSql( + QStringLiteral("SELECT * AS x FROM student")); + QVERIFY(!starAlias.success); + + const sqlparser::ParseResult extraAlias = sqlparser::parseSql( + QStringLiteral("SELECT id FROM student s extra")); + QVERIFY(!extraAlias.success); + + const sqlparser::ParseResult multiOrder = sqlparser::parseSql( + QStringLiteral("SELECT id FROM student ORDER BY a, b")); + QVERIFY(!multiOrder.success); } void test_parseUpdateAndDeleteSupportSimpleWhere() @@ -369,6 +392,90 @@ private slots: QCOMPARE(result.selectResult.resultTable.rows.first().value(0), QStringLiteral("1")); } + void test_dispatchSelectAliasAndQualifiedColumns() + { + const QString databaseName = QStringLiteral("test_parser_dispatcher_select_alias_db"); + const QString tableName = QStringLiteral("test_parser_dispatcher_select_alias_table"); + + ensureDatabase(databaseName); + ensureTable(tableName, baseSchema(tableName)); + seedRows(tableName, + QList>{ + makeRow({{QStringLiteral("id"), QStringLiteral("1")}, + {QStringLiteral("name"), QStringLiteral("alice")}}), + makeRow({{QStringLiteral("id"), QStringLiteral("2")}, + {QStringLiteral("name"), QStringLiteral("bob")}}), + }); + + SqlDispatcher dispatcher; + SqlExecResult result = dispatcher.execute( + QStringLiteral("SELECT s.id AS sid FROM %1 AS s ORDER BY sid DESC").arg(tableName)); + QVERIFY2(result.success, qPrintable(result.errorMessage)); + QCOMPARE(result.selectResult.resultTable.columns, QStringList({QStringLiteral("sid")})); + QCOMPARE(result.selectResult.resultTable.rows.size(), 2); + QCOMPARE(result.selectResult.resultTable.rows.at(0).value(0), QStringLiteral("2")); + QCOMPARE(result.selectResult.resultTable.rows.at(1).value(0), QStringLiteral("1")); + + result = dispatcher.execute( + QStringLiteral("SELECT * FROM %1 s WHERE s.id = 1").arg(tableName)); + QVERIFY2(result.success, qPrintable(result.errorMessage)); + QCOMPARE(result.selectResult.resultTable.rows.size(), 1); + QCOMPARE(result.selectResult.resultTable.rows.first().value(1), QStringLiteral("alice")); + } + + void test_dispatchCorrelatedSubqueryUsesOuterAlias() + { + const QString databaseName = QStringLiteral("test_parser_dispatcher_correlated_alias_db"); + ensureDatabase(databaseName); + + tabledef::TableSchema parentSchema; + parentSchema.tableName = QStringLiteral("parent_alias"); + parentSchema.columns = { + makeColumn(QStringLiteral("id"), tabledef::ColumnType::Int, 0, true), + makeColumn(QStringLiteral("name"), tabledef::ColumnType::Varchar, 32), + }; + parentSchema.constraints = { + makePrimaryKey(QStringLiteral("pk_parent_alias_id"), {QStringLiteral("id")}), + }; + ensureTable(parentSchema.tableName, parentSchema); + + tabledef::TableSchema childSchema; + childSchema.tableName = QStringLiteral("child_alias"); + childSchema.columns = { + makeColumn(QStringLiteral("id"), tabledef::ColumnType::Int, 0, true), + makeColumn(QStringLiteral("parent_id"), tabledef::ColumnType::Int), + }; + childSchema.constraints = { + makePrimaryKey(QStringLiteral("pk_child_alias_id"), {QStringLiteral("id")}), + }; + ensureTable(childSchema.tableName, childSchema); + + seedRows(parentSchema.tableName, + {makeRow({{QStringLiteral("id"), QStringLiteral("1")}, + {QStringLiteral("name"), QStringLiteral("alice")}}), + makeRow({{QStringLiteral("id"), QStringLiteral("2")}, + {QStringLiteral("name"), QStringLiteral("bob")}}), + makeRow({{QStringLiteral("id"), QStringLiteral("3")}, + {QStringLiteral("name"), QStringLiteral("carol")}})}); + seedRows(childSchema.tableName, + {makeRow({{QStringLiteral("id"), QStringLiteral("10")}, + {QStringLiteral("parent_id"), QStringLiteral("1")}}), + makeRow({{QStringLiteral("id"), QStringLiteral("11")}, + {QStringLiteral("parent_id"), QStringLiteral("1")}}), + makeRow({{QStringLiteral("id"), QStringLiteral("12")}, + {QStringLiteral("parent_id"), QStringLiteral("3")}})}); + + SqlDispatcher dispatcher; + const SqlExecResult result = dispatcher.execute( + QStringLiteral("SELECT p.id FROM parent_alias p " + "WHERE EXISTS (SELECT c.id FROM child_alias c WHERE c.parent_id = p.id)")); + QVERIFY2(result.success, qPrintable(result.errorMessage)); + QCOMPARE(result.selectResult.resultTable.columns, QStringList({QStringLiteral("id")})); + QCOMPARE(result.selectResult.resultTable.rows.size(), 2); + QCOMPARE(result.selectResult.resultTable.rows.at(0).value(0), QStringLiteral("1")); + QCOMPARE(result.selectResult.resultTable.rows.at(1).value(0), QStringLiteral("3")); + } + void test_dispatchUpdateAndDeleteKeepQualifiedWhereOnAstPath() { const QString databaseName = QStringLiteral("test_parser_dispatcher_qualified_dml_db"); diff --git a/utils/logic/logic_parser.cpp b/utils/logic/logic_parser.cpp index a0d7546..66afe2c 100644 --- a/utils/logic/logic_parser.cpp +++ b/utils/logic/logic_parser.cpp @@ -48,7 +48,7 @@ bool appendOuterName(QStringList *names, const QString &name) } bool collectOuterNamesFromNode(const LogicNode &node, - const QString &allowedTablePrefix, + const QStringList &localPrefixes, QStringList *names, LogicError *error, const QString &expressionText) @@ -61,20 +61,22 @@ bool collectOuterNamesFromNode(const LogicNode &node, if (node.reference.scope == LogicReferenceScope::Outer) { appendOuterName(names, node.reference.name); } else if (node.reference.name.contains(QLatin1Char('.'))) { - if (!allowedTablePrefix.isEmpty() && node.reference.name.startsWith(allowedTablePrefix)) { - return true; + bool localReference = false; + for (const QString &prefix : localPrefixes) { + if (!prefix.isEmpty() && node.reference.name.startsWith(prefix)) { + localReference = true; + break; + } } - if (error != nullptr) { - error->message = QStringLiteral("only outer.xxx is allowed in correlated subqueries"); - const int position = expressionText.indexOf(node.reference.name); - error->position = position >= 0 ? position : -1; + if (localReference) { + return true; } - return false; + appendOuterName(names, node.reference.name); } } for (const LogicNode &child : node.children) { - if (!collectOuterNamesFromNode(child, allowedTablePrefix, names, error, expressionText)) { + if (!collectOuterNamesFromNode(child, localPrefixes, names, error, expressionText)) { return false; } } @@ -138,13 +140,19 @@ bool collectOuterNamesFromText(const QString &text, QStringList *names, LogicErr names->clear(); } - const QString allowedTablePrefix = parsedSql.payload.value(QStringLiteral("tableName")).toString().trimmed().isEmpty() - ? QString() - : parsedSql.payload.value(QStringLiteral("tableName")).toString().trimmed() + QLatin1Char('.'); + QStringList localPrefixes; + const QString tableName = parsedSql.payload.value(QStringLiteral("tableName")).toString().trimmed(); + const QString tableAlias = parsedSql.payload.value(QStringLiteral("tableAlias")).toString().trimmed(); + if (!tableName.isEmpty()) { + localPrefixes.append(tableName + QLatin1Char('.')); + } + if (!tableAlias.isEmpty()) { + localPrefixes.append(tableAlias + QLatin1Char('.')); + } if (parsedSql.payload.contains(QStringLiteral("whereAst"))) { const LogicNode whereAst = parsedSql.payload.value(QStringLiteral("whereAst")).value(); - if (!collectOuterNamesFromNode(whereAst, allowedTablePrefix, names, error, text)) { + if (!collectOuterNamesFromNode(whereAst, localPrefixes, names, error, text)) { return false; } } @@ -663,4 +671,4 @@ LogicParseResult parseLogicTokens(const QString &expressionText, const QList &tokens, + int *index, + int endExclusive, + QString *name) +{ + if (index == nullptr || name == nullptr || *index >= endExclusive) { + return false; + } + if (!isIdentifierLike(tokens[*index].type)) { + return false; + } + + QString result = tokens[*index].lexeme; + ++(*index); + if (*index + 1 < endExclusive + && tokens[*index].type == TokenType::DOT + && isIdentifierLike(tokens[*index + 1].type)) { + result += QLatin1Char('.'); + result += tokens[*index + 1].lexeme; + *index += 2; + } + + *name = result; + return true; +} + +static QString defaultOutputNameForSource(const QString &sourceName) +{ + const int dotIndex = sourceName.lastIndexOf(QLatin1Char('.')); + return dotIndex >= 0 && dotIndex + 1 < sourceName.size() + ? sourceName.mid(dotIndex + 1) + : sourceName; +} + +static bool parseProjectionItems(const QVector &tokens, + int from, + int to, + QStringList *projection, + QVariantList *projectionItems, + QString *error) +{ + if (projection != nullptr) { + projection->clear(); + } + if (projectionItems != nullptr) { + projectionItems->clear(); + } + if (from > to) { + if (error != nullptr) { + *error = QStringLiteral("SELECT: expected projection columns"); + } + return false; + } + + int index = from; + while (index <= to) { + if (tokens[index].type == TokenType::COMMA) { + if (error != nullptr) { + *error = QStringLiteral("SELECT: expected projection column"); + } + return false; + } + + QString sourceColumn; + if (tokens[index].type == TokenType::STAR) { + sourceColumn = QStringLiteral("*"); + ++index; + if (index <= to && !isClauseTerminator(tokens[index].type) && tokens[index].type != TokenType::COMMA) { + if (error != nullptr) { + *error = QStringLiteral("SELECT: '*' cannot have an alias"); + } + return false; + } + } else if (tokens[index].type == TokenType::LPAREN) { + const int right = findMatchingParen(tokens, index); + if (right < 0 || right > to) { + if (error != nullptr) { + *error = QStringLiteral("SELECT: unmatched projection parenthesis"); + } + return false; + } + if (index + 1 >= right || !parseQualifiedIdentifier(tokens, &(++index), right, &sourceColumn)) { + if (error != nullptr) { + *error = QStringLiteral("SELECT: expected projection column"); + } + return false; + } + if (index != right) { + if (error != nullptr) { + *error = QStringLiteral("SELECT: unsupported projection expression"); + } + return false; + } + index = right + 1; + } else if (!parseQualifiedIdentifier(tokens, &index, to + 1, &sourceColumn)) { + if (error != nullptr) { + *error = QStringLiteral("SELECT: expected projection column"); + } + return false; + } + + QString outputColumn = defaultOutputNameForSource(sourceColumn); + if (index <= to && tokens[index].type != TokenType::COMMA) { + if (tokens[index].lexeme.compare(QStringLiteral("AS"), Qt::CaseInsensitive) == 0) { + ++index; + if (index > to || !isIdentifierLike(tokens[index].type)) { + if (error != nullptr) { + *error = QStringLiteral("SELECT: expected projection alias after AS"); + } + return false; + } + outputColumn = tokens[index].lexeme; + ++index; + } else if (isIdentifierLike(tokens[index].type)) { + outputColumn = tokens[index].lexeme; + ++index; + } else { + if (error != nullptr) { + *error = QStringLiteral("SELECT: unsupported projection token '%1'").arg(tokens[index].lexeme); + } + return false; + } + } + + if (projection != nullptr) { + projection->append(sourceColumn); + } + if (projectionItems != nullptr) { + QVariantMap item; + item.insert(QStringLiteral("sourceColumn"), sourceColumn); + item.insert(QStringLiteral("outputColumn"), outputColumn); + projectionItems->append(item); + } + + if (index > to) { + break; + } + if (tokens[index].type != TokenType::COMMA) { + if (error != nullptr) { + *error = QStringLiteral("SELECT: expected ',' between projection columns"); + } + return false; + } + ++index; + if (index > to) { + if (error != nullptr) { + *error = QStringLiteral("SELECT: expected projection column after ','"); + } + return false; + } + } + + return true; +} + static bool parseSimpleConditions(const QVector &tokens, int from, int to, @@ -298,8 +472,16 @@ static bool parseOrderByClause(const QVector &tokens, return true; } if (orderIdx + 2 >= tokens.size() - || tokens[orderIdx + 1].type != TokenType::BY - || tokens[orderIdx + 2].type != TokenType::IDENTIFIER) { + || tokens[orderIdx + 1].type != TokenType::BY) { + if (error != nullptr) { + *error = QStringLiteral("ORDER BY: expected column name"); + } + return false; + } + + int itemIndex = orderIdx + 2; + QString orderByColumn; + if (!parseQualifiedIdentifier(tokens, &itemIndex, tokens.size(), &orderByColumn)) { if (error != nullptr) { *error = QStringLiteral("ORDER BY: expected column name"); } @@ -307,7 +489,7 @@ static bool parseOrderByClause(const QVector &tokens, } bool descending = false; - const int directionIndex = orderIdx + 3; + const int directionIndex = itemIndex; int nextIndex = directionIndex; if (directionIndex < tokens.size() && tokens[directionIndex].type != TokenType::LIMIT @@ -337,7 +519,7 @@ static bool parseOrderByClause(const QVector &tokens, } if (payload != nullptr) { - payload->insert(QStringLiteral("orderByColumn"), tokens[orderIdx + 2].lexeme); + payload->insert(QStringLiteral("orderByColumn"), orderByColumn); payload->insert(QStringLiteral("orderByDescending"), descending); } return true; @@ -354,16 +536,24 @@ ParseResult parseTupleSql(const QString& sql, const QVector& tokens) { // ── SELECT ── if (cmdType == "SELECT") { QStringList projection; + QVariantList projectionItems; QString table; + QString tableAlias; int tableIndex = -1; int fromIdx = -1; for (int i = 1; i < tokens.size(); ++i) { if (tokens[i].type == TokenType::FROM) { fromIdx = i; break; } if (tokens[i].type == TokenType::END_OF_INPUT) break; - if (tokens[i].type == TokenType::STAR || - tokens[i].type == TokenType::IDENTIFIER) - projection.append(tokens[i].lexeme); + } + + if (fromIdx < 0) { + return {false, "SELECT: expected FROM table", cmdType, {}}; + } + + QString projectionError; + if (!parseProjectionItems(tokens, 1, fromIdx - 1, &projection, &projectionItems, &projectionError)) { + return {false, projectionError, cmdType, {}}; } if (fromIdx >= 0) { @@ -399,6 +589,29 @@ ParseResult parseTupleSql(const QString& sql, const QVector& tokens) { return {false, "SELECT: unsupported clause 'LIMIT'", cmdType, {}}; } + const int tableTailEnd = whereIdx >= 0 + ? whereIdx + : (orderIdx >= 0 + ? orderIdx + : (limitIdx >= 0 ? limitIdx : lastMeaningfulTokenIndex(tokens) + 1)); + int aliasIndex = tableIndex + 1; + if (aliasIndex < tableTailEnd) { + if (tokens[aliasIndex].lexeme.compare(QStringLiteral("AS"), Qt::CaseInsensitive) == 0) { + ++aliasIndex; + if (aliasIndex >= tableTailEnd || tokens[aliasIndex].type != TokenType::IDENTIFIER) { + return {false, "SELECT: expected table alias after AS", cmdType, {}}; + } + tableAlias = tokens[aliasIndex].lexeme; + ++aliasIndex; + } else if (tokens[aliasIndex].type == TokenType::IDENTIFIER) { + tableAlias = tokens[aliasIndex].lexeme; + ++aliasIndex; + } + } + if (aliasIndex < tableTailEnd) { + return {false, QStringLiteral("SELECT: unsupported trailing token '%1'").arg(tokens[aliasIndex].lexeme), cmdType, {}}; + } + QString whereError; const int whereEndClause = orderIdx >= 0 ? orderIdx : limitIdx; if (!extractWherePayload(sql, tokens, whereIdx, whereEndClause, &payload, &whereError)) { @@ -422,9 +635,12 @@ ParseResult parseTupleSql(const QString& sql, const QVector& tokens) { payload["selectAll"] = projectionIsSelectAll(projection); if (payload.value(QStringLiteral("selectAll")).toBool()) { projection.clear(); + projectionItems.clear(); } payload["projection"] = projection; + payload["projectionItems"] = projectionItems; payload["tableName"] = table; + payload["tableAlias"] = tableAlias; payload["limit"] = limit; return {true, "", cmdType, payload}; From 75d6c1bcfac02abedecb9f17cb85fd788075d277 Mon Sep 17 00:00:00 2001 From: chester Date: Sat, 16 May 2026 22:35:54 +0800 Subject: [PATCH 04/10] =?UTF-8?q?-=20PLAN=20=E6=9E=84=E5=BB=BA=E4=BA=86FRO?= =?UTF-8?q?M=E5=A4=9A=E8=A1=A8=20/=20=E5=A4=96=E8=BF=9E=E6=8E=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- FROM_MULTI_PLAN.md | 1343 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 1343 insertions(+) create mode 100644 FROM_MULTI_PLAN.md diff --git a/FROM_MULTI_PLAN.md b/FROM_MULTI_PLAN.md new file mode 100644 index 0000000..a87601d --- /dev/null +++ b/FROM_MULTI_PLAN.md @@ -0,0 +1,1343 @@ +# FROM_MULTI_PLAN + +目标:在现有单表 `SELECT`、表别名、限定列名、投影别名和相关子查询别名绑定能力基础上,补齐多表 `FROM` 查询能力。 + +本文同时规划两种语法: + +1. 逗号多表: + +```sql +SELECT a.id, b.name FROM a, b WHERE a.id = b.a_id; +``` + +2. 显式 JOIN: + +```sql +SELECT a.id, b.name FROM a JOIN b ON a.id = b.a_id; +``` + +本文规划 `INNER`、`LEFT`、`RIGHT`、`FULL` 四类 JOIN 语义。`JOIN` 不带修饰词时视为 `INNER JOIN`;显式 `INNER JOIN` 可作为同义支持。本文不规划 `NATURAL JOIN`、`CROSS JOIN`、`USING`、FROM 子查询、GROUP BY、聚合函数、UNION 或表达式投影。 + +## 总体收口边界 + +### 支持 + +1. 多表逗号 FROM: + +```sql +SELECT a.id, b.name FROM a, b WHERE a.id = b.a_id; +``` + +2. 多表别名: + +```sql +SELECT s.id, c.name FROM student s, class c WHERE s.class_id = c.id; +SELECT s.id FROM student AS s, class AS c WHERE s.class_id = c.id; +``` + +3. 显式 JOIN ... ON: + +```sql +SELECT s.id, c.name FROM student s JOIN class c ON s.class_id = c.id; +SELECT s.id FROM student AS s INNER JOIN class AS c ON s.class_id = c.id; +``` + +4. 显式外连接,并允许别名: + +```sql +SELECT s.id, c.name FROM student s LEFT JOIN class c ON s.class_id = c.id; +SELECT s.id, c.name FROM student AS s RIGHT JOIN class AS c ON s.class_id = c.id; +SELECT s.id, c.name FROM student s FULL JOIN class c ON s.class_id = c.id; +``` + +5. 多个 JOIN 串联: + +```sql +SELECT s.id, c.name, d.name +FROM student s +JOIN class c ON s.class_id = c.id +JOIN department d ON c.department_id = d.id; +``` + +6. JOIN ON 与 WHERE 合并过滤: + +```sql +SELECT s.id +FROM student s JOIN class c ON s.class_id = c.id +WHERE c.name = 'cs'; +``` + +7. 投影别名和 ORDER BY 投影别名沿用现有规则: + +```sql +SELECT s.id AS sid, c.name AS cname +FROM student s JOIN class c ON s.class_id = c.id +ORDER BY sid DESC; +``` + +8. 相关子查询可以引用多表外层别名: + +```sql +SELECT s.id +FROM student s JOIN class c ON s.class_id = c.id +WHERE EXISTS ( + SELECT x.id FROM score x WHERE x.student_id = s.id +); +``` + +### 不支持 + +1. `NATURAL JOIN`。 +2. `CROSS JOIN`。 +3. `JOIN ... USING (...)`。 +4. FROM 子查询。 +5. 多表 `UPDATE` / `DELETE`。 +6. 多列 `ORDER BY`。 +7. `ORDER BY` 表达式。 +8. `SELECT a + b` 这类表达式投影。 +9. 聚合函数与 `GROUP BY`。 +10. `UNION`。 +11. 多数据库限定名,例如 `db.table.column`。 + +### 名字解析规则 + +1. 多表查询中,限定列名是推荐写法。 +2. 裸列名只有在所有可见表中唯一时才允许。 +3. 如果裸列名在多个表中出现,必须报错: + +```text +ambiguous column 'id' +``` + +4. 表名和表别名都可作为限定前缀。 +5. 同一个查询作用域内,不允许重复表别名。 +6. 如果两个表同名且都没有别名,必须报错;重复表必须使用不同别名。 +7. 本地作用域优先于外层相关子查询作用域。 +8. 投影别名只影响输出列名和 `ORDER BY`,不参与 `WHERE` / `ON` 绑定。 +9. 外连接补空值使用当前系统的 NULL-like 表示,即空字符串 `QString()`。 + +--- + +## 阶段一:tokenizer 与 SELECT parser 扩展 FROM source payload + +### 0. 常量增量 + +建议新增 token: + +1. `TokenType::JOIN` +2. `TokenType::ON` +3. `TokenType::INNER` +4. `TokenType::LEFT` +5. `TokenType::RIGHT` +6. `TokenType::FULL` + +建议新增 parser payload key。优先局部字符串常量,不新增公共常量文件: + +1. `fromSources` +2. `joins` +3. `joinType` +4. `leftSourceIndex` +5. `rightSourceIndex` +6. `onAst` +7. `isMultiTable` + +建议 payload 结构: + +```text +fromSources: [ + { tableName: "student", tableAlias: "s" }, + { tableName: "class", tableAlias: "c" } +] + +joins: [ + { + joinType: "inner", + leftSourceIndex: 0, + rightSourceIndex: 1, + onAst: LogicNode(a.id = b.a_id) + } +] +``` + +`joinType` 允许值: + +1. `inner` +2. `left` +3. `right` +4. `full` + +兼容策略: + +1. 单表 SELECT 继续输出旧字段: + - `tableName` + - `tableAlias` +2. 所有 SELECT 都输出 `fromSources`。 +3. 单表时 `isMultiTable = false`,多表或 JOIN 时 `isMultiTable = true`。 + +### 1. 影响文件范围 + +允许修改: + +1. [utils/sql_parser/sql_tokenizer.h](utils/sql_parser/sql_tokenizer.h) +2. [utils/sql_parser/sql_tokenizer.cpp](utils/sql_parser/sql_tokenizer.cpp) +3. [utils/sql_parser/tuple_parser.cpp](utils/sql_parser/tuple_parser.cpp) +4. [utils/logic/logic_parser.cpp](utils/logic/logic_parser.cpp),仅当需要复用逻辑解析入口或改善 ON 错误信息。 +5. [tests/test_parser_dispatcher.cpp](tests/test_parser_dispatcher.cpp) +6. [tests/parserNdispatcher_test_plan.md](tests/parserNdispatcher_test_plan.md) + +首选不修改: + +1. `service/service.h` +2. `service/tuple_service.cpp` +3. `service/table_dml_service.cpp` + +### 2. 函数级完整数据流 + +#### 2.1 逗号 FROM + +```text +SQL: +SELECT a.id, b.name FROM a, b WHERE a.id = b.a_id + +-> sqlparser::parseSql(...) +-> parseTupleSql(...) +-> parseProjectionItems(...) 得到 projectionItems +-> parseFromSources(...) + source[0] = { tableName: a, tableAlias: "" } + source[1] = { tableName: b, tableAlias: "" } +-> parse WHERE 为 whereAst +-> payload.fromSources = [a, b] +-> payload.isMultiTable = true +-> payload.joins = [] +``` + +#### 2.2 JOIN ... ON + +```text +SQL: +SELECT s.id, c.name FROM student s JOIN class c ON s.class_id = c.id WHERE c.name = 'cs' + +-> parseTupleSql(...) +-> parseFromSourcesAndJoins(...) + source[0] = student alias s + JOIN token + source[1] = class alias c + ON expression text = s.class_id = c.id + onAst = LogicNode(Comparison) +-> parse WHERE expression text = c.name = 'cs' +-> payload.fromSources +-> payload.joins[0].onAst +-> payload.whereAst +``` + +#### 2.3 多 JOIN 串联 + +```text +SQL: +SELECT s.id, d.name +FROM student s +JOIN class c ON s.class_id = c.id +JOIN dept d ON c.dept_id = d.id + +-> source[0] = student alias s +-> source[1] = class alias c +-> joins[0] = source 0 join source 1 on s.class_id = c.id +-> source[2] = dept alias d +-> joins[1] = current joined rowset join source 2 on c.dept_id = d.id +``` + +`leftSourceIndex` 对第二个 JOIN 可记录为 `-1` 表示“当前累计 rowset”,或继续记录为前一个右表 index。执行层以 joins 顺序为准,不依赖 left index 做关系优化。 + +### 3. 函数级输入输出与内部逻辑 + +#### 3.1 `parseTupleSql(const QString &sql, const QVector &tokens)` + +输入: + +1. 原始 SQL。 +2. tokenizer token 列表。 + +输出: + +1. 成功时: + - `ParseResult.success = true` + - `commandType = "SELECT"` + - payload 至少包含: + - `projection` + - `projectionItems` + - `fromSources` + - `tableName`,兼容旧单表入口。 + - `tableAlias`,兼容旧单表入口。 + - `joins` + - `isMultiTable` + - `whereAst` + - `orderByColumn` + - `orderByDescending` + - `limit` +2. 失败时: + - `success = false` + - `errorMessage` 明确指出 FROM/JOIN/ON 语法错误。 + +内部逻辑: + +1. `SELECT ... FROM` 前的投影解析继续复用 `parseProjectionItems(...)`。 +2. `FROM` 后进入新的 `parseFromClause(...)`。 +3. `parseFromClause(...)` 读取到第一个 clause terminator: + - `WHERE` + - `ORDER` + - `LIMIT` + - `;` + - EOF +4. 表 source 格式只允许: + - `table` + - `table alias` + - `table AS alias` +5. 逗号 source 与 JOIN source 不建议混用。第一版可允许: + +```sql +FROM a, b JOIN c ON b.id = c.b_id +``` + +但执行语义需要清晰。为降低风险,首版建议拒绝逗号和 JOIN 混用: + +```text +SELECT: cannot mix comma FROM and JOIN in the same FROM clause +``` + +6. `JOIN` 后必须是 table source。 +7. `JOIN` 后必须有 `ON`。 +8. `ON` 表达式结束位置是下一个 `JOIN`、`WHERE`、`ORDER`、`LIMIT`、`;` 或 EOF。 +9. `ON` 表达式用现有 logic parser 解析成 `LogicNode`。 +10. `JOIN` 不带 `ON` 必须失败。 +11. `INNER JOIN` 与 `JOIN` 生成相同 payload,`joinType = inner`。 +12. `LEFT JOIN`、`RIGHT JOIN`、`FULL JOIN` 必须生成对应 `joinType`,并允许左右表都使用别名。 +13. `NATURAL/CROSS/USING` 必须失败,并给出不支持信息。 + +#### 3.2 `parseFromClause(...)` + +建议签名: + +```cpp +static bool parseFromClause(const QVector &tokens, + int fromIndex, + int clauseEndIndex, + QVariantList *fromSources, + QVariantList *joins, + QString *singleTableName, + QString *singleTableAlias, + bool *isMultiTable, + QString *error); +``` + +输入: + +1. token 列表。 +2. `FROM` token index。 +3. FROM clause 结束 index。 + +输出: + +1. `fromSources`:按 SQL 出现顺序排列。 +2. `joins`:按 JOIN 执行顺序排列。 +3. `singleTableName/tableAlias`:兼容旧字段。 +4. `isMultiTable`。 + +内部逻辑: + +1. 调用 `parseTableSource(...)` 解析首表。 +2. 如果遇到 `,`,进入 comma mode。 +3. 如果遇到 `JOIN`、`INNER JOIN`、`LEFT JOIN`、`RIGHT JOIN` 或 `FULL JOIN`,进入 join mode。 +4. mode 一旦确定,不允许混用。 +5. 每次新增 source 时执行别名冲突检查。 +6. 表名和别名都写入 visible prefix 集合;如果重复表名无别名,报错。 + +#### 3.3 `parseTableSource(...)` + +建议签名: + +```cpp +static bool parseTableSource(const QVector &tokens, + int startIndex, + int endIndex, + QVariantMap *source, + int *nextIndex, + QString *error); +``` + +输入: + +1. token 列表。 +2. table source 起始位置。 +3. table source 最大结束位置。 + +输出: + +```text +source.tableName +source.tableAlias +nextIndex +``` + +内部逻辑: + +1. 起始 token 必须是 identifier-like table name。 +2. 支持可选 `AS alias`。 +3. 支持无 `AS` alias。 +4. alias 不能是 clause keyword 或 join keyword。 +5. 不支持 `schema.table` 或 `db.table`;如果出现点号,报错。 + +#### 3.4 `parseJoinOnAst(...)` + +输入: + +1. `ON` 之后的 token range。 + +输出: + +1. `LogicNode onAst`。 + +内部逻辑: + +1. 将 token range 对应 SQL 文本片段交给 logic parser。 +2. 支持现有 WHERE 支持的条件能力。 +3. 不引入新的表达式能力。 +4. 空 ON 表达式失败。 +5. `ON a.id = b.id AND b.x = 1` 合法。 + +### 4. 测试用例构建 + +新增 parser 测试: + +1. 逗号多表: + +```sql +SELECT a.id, b.name FROM a, b WHERE a.id = b.a_id; +``` + +期望: + +```text +fromSources.size = 2 +isMultiTable = true +joins.size = 0 +whereAst 存在 +``` + +2. 多表 alias: + +```sql +SELECT s.id, c.name FROM student s, class AS c WHERE s.class_id = c.id; +``` + +期望: + +```text +source[0] tableName=student tableAlias=s +source[1] tableName=class tableAlias=c +``` + +3. JOIN ON: + +```sql +SELECT s.id FROM student s JOIN class c ON s.class_id = c.id; +``` + +期望: + +```text +fromSources.size = 2 +joins.size = 1 +joins[0].joinType = inner +joins[0].onAst 存在 +``` + +4. INNER JOIN: + +```sql +SELECT s.id FROM student s INNER JOIN class c ON s.class_id = c.id; +``` + +期望同 JOIN。 + +5. JOIN + WHERE: + +```sql +SELECT s.id FROM student s JOIN class c ON s.class_id = c.id WHERE c.name = 'cs'; +``` + +期望 `onAst` 与 `whereAst` 都存在。 + +6. LEFT / RIGHT / FULL JOIN,并允许别名: + +```sql +SELECT s.id FROM student s LEFT JOIN class c ON s.class_id = c.id; +SELECT s.id FROM student s RIGHT JOIN class c ON s.class_id = c.id; +SELECT s.id FROM student s FULL JOIN class c ON s.class_id = c.id; +``` + +期望: + +```text +joinType 分别为 left / right / full +source[0] tableAlias=s +source[1] tableAlias=c +onAst 存在 +``` + +失败用例: + +1. `SELECT * FROM a JOIN b;` +2. `SELECT * FROM a JOIN b ON;` +3. `SELECT * FROM a NATURAL JOIN b;` +4. `SELECT * FROM a CROSS JOIN b;` +5. `SELECT * FROM a JOIN b USING (id);` +6. `SELECT * FROM a, b JOIN c ON b.id = c.id;` +7. `SELECT * FROM a x, b x;` +8. `SELECT * FROM a, a;` + +--- + +## 阶段二:多表名字解析模型与虚拟结果表 + +### 0. 常量增量 + +本阶段不新增全局常量。 + +建议新增局部结构体,优先放在 `controller/nest_query.cpp` 和 `controller/sql_dispatcher.cpp` 的匿名 namespace 中;如果两处重复明显,再移动到一个小型共享 helper。 + +```cpp +struct SelectTableSource { + QString tableName; + QString tableAlias; + tabledef::TableSchema schema; + repo::TableData data; +}; + +struct MultiTableCell { + QString sourceTableName; + QString sourceAlias; + QString columnName; + QString value; +}; + +struct JoinedRow { + QMap cellsByName; + QVector outputValuesByQualifiedColumn; +}; + +struct MultiTableNameResolution { + QMap visibleNameToQualifiedName; + QSet ambiguousBareColumns; + QStringList outputColumns; +}; +``` + +建议内部标准列名格式: + +```text +sourcePrefix.columnName +``` + +其中 `sourcePrefix` 优先使用表别名;无别名时使用表名。 + +### 1. 影响文件范围 + +允许修改: + +1. [controller/sql_dispatcher.cpp](controller/sql_dispatcher.cpp) +2. [controller/nest_query.cpp](controller/nest_query.cpp) +3. [utils/logic/subquery_logic.cpp](utils/logic/subquery_logic.cpp),仅当 outer binding 构造需要多表 context 适配。 +4. [utils/logic/subquery_logic.h](utils/logic/subquery_logic.h),仅当签名必须扩展。 +5. [tests/test_parser_dispatcher.cpp](tests/test_parser_dispatcher.cpp) +6. [tests/test_query_executor.cpp](tests/test_query_executor.cpp) + +首选不修改: + +1. `service/tuple_service.cpp` +2. `service/table_dml_service.cpp` +3. `repo/*` + +### 2. 函数级完整数据流 + +#### 2.1 dispatcher 多表 SELECT + +```text +SQL: +SELECT s.id, c.name +FROM student s, class c +WHERE s.class_id = c.id + +-> SqlDispatcher::execSelect(parsed) +-> payload.isMultiTable = true +-> QueryExecutor::execSelect(parsed, nullptr) +-> load all schemas/data +-> buildMultiTableNameResolution(...) +-> build cartesian product rows +-> evaluate whereAst on joined row context +-> project s.id, c.name +-> SqlExecResult.resultTable +``` + +#### 2.2 JOIN SELECT + +```text +SQL: +SELECT s.id, c.name +FROM student s JOIN class c ON s.class_id = c.id +WHERE c.name = 'cs' + +-> QueryExecutor::execSelect(...) +-> load source[0] student rows as initial rowset +-> for join[0]: + combine current rowset with class rows + evaluate joins[0].onAst + keep matching rows +-> evaluate whereAst +-> project +``` + +#### 2.3 LEFT / RIGHT / FULL JOIN SELECT + +```text +SQL: +SELECT s.id, c.name FROM student s LEFT JOIN class c ON s.class_id = c.id + +-> QueryExecutor::execSelect(...) +-> load student/class +-> left source rows as initial rowset +-> joinType = left +-> for each left row: + combine with every right row + evaluate ON + if at least one match: keep matched combined rows + if no match: keep left row + right-side NULL-like cells +-> WHERE / ORDER BY / LIMIT / projection +``` + +`RIGHT JOIN` 按同样规则保留右侧未匹配行;`FULL JOIN` 同时保留左右两侧未匹配行。 + +### 3. 函数级输入输出与内部逻辑 + +#### 3.1 `SqlDispatcher::execSelect(const sqlparser::ParseResult &p)` + +输入: + +1. SELECT ParseResult。 + +输出: + +1. `SqlExecResult`。 + +内部逻辑: + +1. 如果 `payload.isMultiTable != true`,保持现有单表快路径。 +2. 如果是多表 SELECT,统一交给 `QueryExecutor::execSelect(...)`。 +3. 不通过 `tuple_service::selectRows(...)`,因为 tuple service 当前以单表为基本单位。 +4. dispatcher 层仍负责把 `QueryExecuteResult` 转成 `SqlExecResult`。 + +#### 3.2 `QueryExecutor::execSelect(...)` + +输入: + +1. SELECT ParseResult。 +2. 可选 `CorrelationBindings`。 + +输出: + +1. `QueryExecuteResult.success = true` 和 `SelectRowsResult`。 +2. 失败时返回明确错误。 + +内部逻辑: + +1. 读取 `fromSources`。 +2. 如果只有一个 source,走现有单表执行逻辑。 +3. 如果多个 source: + - 加载每个 source 的 schema。 + - 加载每个 source 的 table data。 + - 构建多表名字解析。 + - 构建 joined rowset。 + - 执行 WHERE。 + - 执行 ORDER BY。 + - 执行 LIMIT。 + - 执行投影。 +4. 外层 bindings merge 到每一条 joined row context。 + +#### 3.3 `buildMultiTableNameResolution(...)` + +建议签名: + +```cpp +static bool buildMultiTableNameResolution(const QList &sources, + MultiTableNameResolution *resolution, + QString *error); +``` + +输入: + +1. 已加载 schema 的 source 列表。 + +输出: + +1. `visibleNameToQualifiedName`: + - `s.id -> s.id` + - `student.id -> s.id` + - 裸 `id -> s.id`,仅当唯一。 +2. `ambiguousBareColumns`。 + +内部逻辑: + +1. 每个 source 的 canonical prefix: + - 有 alias 用 alias。 + - 无 alias 用 tableName。 +2. 每列写入: + - `canonicalPrefix.column` + - `tableName.column` + - `alias.column`,如果 alias 非空。 +3. 裸列名: + - 第一次出现,暂存。 + - 第二次来自不同 source,移入 ambiguous。 +4. 如果限定名前缀不存在,解析时报: + +```text +unknown table or alias 'x' +``` + +5. 如果裸列名 ambiguous,解析时报: + +```text +ambiguous column 'id' +``` + +#### 3.4 `buildInitialJoinedRows(...)` + +建议签名: + +```cpp +static QVector buildInitialJoinedRows(const SelectTableSource &source); +``` + +输入: + +1. 一个 source。 + +输出: + +1. 每一行一个 `LogicRowContext`。 + +内部逻辑: + +1. 对每行写入: + - 裸列名,若当前单 source 内唯一。 + - `tableName.column` + - `alias.column` + - `canonicalPrefix.column` +2. 多表组合阶段重新处理裸列名歧义;最终多表 context 中 ambiguous 裸列不应出现,避免误绑定。 + +#### 3.5 `joinRowsets(...)` + +建议签名: + +```cpp +static QVector joinRowsets(const QVector &leftRows, + const SelectTableSource &rightSource, + const QString &joinType, + const logic::LogicNode *onAst, + const logic::CorrelationBindings *bindings, + QString *error); +``` + +输入: + +1. 当前累计 rowset。 +2. 右表 source。 +3. join type:`inner` / `left` / `right` / `full`。 +4. 可选 ON AST。 +5. 可选外层 bindings。 + +输出: + +1. 按 join type 生成的 joined rowset。 + +内部逻辑: + +1. 对每条 left row 和 right row 做组合。 +2. 合并 cells 时: + - 限定名全部保留。 + - 裸列名只在不 ambiguous 时保留。 + - 外层 binding 不覆盖本地同名 key。 +3. 如果有 `onAst`,用 logic evaluator 评估。 +4. `inner`: + - `True` 保留,`False/Unknown` 丢弃。 +5. `left`: + - 匹配行按 `inner` 保留。 + - 某条 left row 没有任何匹配时,补一条 right source 全列为空字符串的 combined row。 +6. `right`: + - 匹配行按 `inner` 保留。 + - 某条 right row 没有任何匹配时,补一条 left side 全列为空字符串的 combined row。 +7. `full`: + - 同时执行 left-preserve 与 right-preserve。 +8. unmatched 补空行中的列 key 必须完整包含表名限定列和别名限定列,值为 `QString()`。 + +#### 3.6 `buildCommaCartesianRows(...)` + +输入: + +1. 多个 source。 +2. 可选外层 bindings。 + +输出: + +1. 完整笛卡尔积 rowset。 + +内部逻辑: + +1. 从第一个 source rowset 开始。 +2. 逐个 source 做无 ON 的 `joinRowsets(...)`。 +3. 生成后再由 WHERE 过滤。 +4. 第一版不做 join predicate 下推优化。 + +#### 3.7 `resolveMultiTableProjection(...)` + +输入: + +1. `projectionItems`。 +2. `MultiTableNameResolution`。 + +输出: + +1. resolved projection 列 key 列表。 +2. output column 列名列表。 + +内部逻辑: + +1. `SELECT *`: + - 输出所有 source 的真实列。 + - 如果列名冲突,输出建议为 `prefix.column`。 + - 如果列名不冲突,可输出裸列名;为稳定起见,首版建议多表 `SELECT *` 输出 `prefix.column`。 +2. `SELECT s.id`: + - resolve 为 `s.id`。 + - 默认输出列名为 `id`。 +3. `SELECT id`: + - 如果唯一,resolve。 + - 如果 ambiguous,报错。 +4. `SELECT s.id AS sid`: + - 输出列名 `sid`。 + +#### 3.8 `resolveMultiTableOrderBy(...)` + +输入: + +1. `orderByColumn`。 +2. 投影别名映射。 +3. 多表名字解析。 + +输出: + +1. joined row context 中可读取的列 key。 + +内部逻辑: + +1. 优先匹配投影输出别名。 +2. 再匹配限定列名。 +3. 再匹配唯一裸列名。 +4. ambiguous 报错。 +5. 保持单列 ORDER BY 限制。 + +### 4. 测试用例构建 + +新增 dispatcher / executor 测试: + +1. 逗号 FROM 等值连接: + +```sql +SELECT s.id, c.name FROM student s, class c WHERE s.class_id = c.id; +``` + +数据: + +```text +student: (1, 10), (2, 20), (3, 99) +class: (10, cs), (20, math) +``` + +期望: + +```text +返回 2 行:1/cs, 2/math +``` + +2. JOIN ON 等值连接: + +```sql +SELECT s.id, c.name FROM student s JOIN class c ON s.class_id = c.id; +``` + +期望同上。 + +3. LEFT JOIN 保留左侧未匹配行,并允许别名: + +```sql +SELECT s.id, c.name FROM student s LEFT JOIN class c ON s.class_id = c.id ORDER BY s.id ASC; +``` + +期望: + +```text +返回 student 全部 3 行;class 未匹配的第 3 行 c.name 为空字符串。 +``` + +4. RIGHT JOIN 保留右侧未匹配行,并允许别名: + +```sql +SELECT s.id, c.name FROM student s RIGHT JOIN class c ON s.class_id = c.id ORDER BY c.id ASC; +``` + +期望: + +```text +返回 class 全部行;没有 student 的 class 行中 s.id 为空字符串。 +``` + +5. FULL JOIN 保留两侧未匹配行,并允许别名: + +```sql +SELECT s.id, c.name FROM student s FULL JOIN class c ON s.class_id = c.id; +``` + +期望: + +```text +返回 inner 匹配行 + left-only 行 + right-only 行。 +``` + +6. JOIN ON + WHERE: + +```sql +SELECT s.id FROM student s JOIN class c ON s.class_id = c.id WHERE c.name = 'cs'; +``` + +期望只返回 student id = 1。 + +7. 多 JOIN: + +```sql +SELECT s.id, d.name +FROM student s +JOIN class c ON s.class_id = c.id +JOIN dept d ON c.dept_id = d.id; +``` + +期望按链路返回。 + +8. 投影别名 + ORDER BY: + +```sql +SELECT s.id AS sid FROM student s JOIN class c ON s.class_id = c.id ORDER BY sid DESC; +``` + +期望输出列名 `sid`,按 id 降序。 + +9. ambiguous 裸列失败: + +```sql +SELECT id FROM student s JOIN class c ON s.class_id = c.id; +``` + +如果两表都有 `id`,期望失败并包含 `ambiguous column 'id'`。 + +10. 唯一裸列成功: + +```sql +SELECT name FROM student s JOIN class c ON s.class_id = c.id; +``` + +仅当 `name` 只存在一张表时成功。 + +--- + +## 阶段三:ON / WHERE / 相关子查询作用域收口 + +### 0. 常量增量 + +本阶段不新增全局常量。 + +建议继续使用已有: + +1. `LogicRowContext` +2. `CorrelationBindings` +3. `referencedOuterNames` + +### 1. 影响文件范围 + +允许修改: + +1. [utils/logic/logic_parser.cpp](utils/logic/logic_parser.cpp) +2. [utils/logic/subquery_logic.cpp](utils/logic/subquery_logic.cpp) +3. [controller/nest_query.cpp](controller/nest_query.cpp) +4. [tests/test_logic.cpp](tests/test_logic.cpp) +5. [tests/test_query_executor.cpp](tests/test_query_executor.cpp) + +首选不修改: + +1. `utils/logic/logic_types.h` +2. `utils/logic/logic_evaluator.cpp` + +### 2. 函数级完整数据流 + +```text +SQL: +SELECT s.id +FROM student s JOIN class c ON s.class_id = c.id +WHERE EXISTS ( + SELECT x.id FROM score x WHERE x.student_id = s.id +) + +-> 外层 parser: + fromSources = student/s, class/c + joins[0].onAst = s.class_id = c.id + whereAst = EXISTS subquery +-> QueryExecutor outer: + joined row context contains: + s.id + student.id + c.id + class.id +-> logic_parser collectOuterNamesFromText(subquery): + local source score/x + x.student_id 是本地 + s.id 是外层 +-> buildCorrelationBindings(outer row context, ["s.id"]) +-> 子查询执行时 merge s.id binding +``` + +### 3. 函数级输入输出与内部逻辑 + +#### 3.1 `collectOuterNamesFromText(...)` + +输入: + +1. 子查询 SQL 原文。 + +输出: + +1. 外层引用名列表。 + +内部逻辑: + +1. 读取子查询 `fromSources`。 +2. 本地前缀集合包含所有本地 tableName 和 tableAlias。 +3. 遍历子查询 whereAst 和 join onAst: + - 本地前缀忽略。 + - `outer.` 前缀收集。 + - 非本地限定名前缀收集为外层引用。 +4. 如果子查询内部也是多表,所有本地 source 前缀都必须被识别为本地。 + +#### 3.2 `requiredOuterReferences(const LogicNode &whereAst)` + +输入: + +1. 外层 WHERE AST。 + +输出: + +1. 子查询节点所需外层引用。 + +内部逻辑: + +1. 继续使用 `LogicNode.referencedOuterNames`。 +2. 多表外层 row context 中按完整名绑定。 +3. 对旧 `outer.id` 保持 fallback。 + +#### 3.3 `buildCorrelationBindings(...)` + +输入: + +1. 多表 joined row context。 +2. 外层引用名列表。 + +输出: + +1. correlation bindings。 + +内部逻辑: + +1. 完整名优先: + - `s.id` + - `student.id` +2. `outer.id` fallback: + - 如果外层 context 有唯一裸 `id`,可绑定。 + - 如果裸 `id` ambiguous,不允许 fallback 静默绑定,返回 missing/ambiguous binding 错误。 +3. 不自动把所有外层列都注入子查询,只注入 referenced names。 + +### 4. 测试用例构建 + +新增 logic / query executor 测试: + +1. 子查询收集多表外层别名: + +```sql +EXISTS (SELECT x.id FROM score x WHERE x.student_id = s.id) +``` + +在外层 source 为 `student s, class c` 时,期望收集 `s.id`。 + +2. 子查询本地多表别名不误判: + +```sql +EXISTS ( + SELECT x.id FROM score x JOIN exam e ON x.exam_id = e.id WHERE e.name = 'mid' +) +``` + +期望外层引用为空。 + +3. 多表外层相关 EXISTS: + +```sql +SELECT s.id +FROM student s JOIN class c ON s.class_id = c.id +WHERE EXISTS (SELECT x.id FROM score x WHERE x.student_id = s.id); +``` + +期望只返回有 score 的 student。 + +4. `outer.id` 在多表 ambiguous 时失败: + +```sql +SELECT s.id FROM student s JOIN class c ON s.class_id = c.id +WHERE EXISTS (SELECT x.id FROM score x WHERE x.student_id = outer.id); +``` + +如果外层 `id` 在 student/class 都存在,期望失败,避免错误绑定。 + +--- + +## 阶段四:LIMIT / ORDER BY / 输出 schema 与错误路径细化 + +### 0. 常量增量 + +本阶段不新增全局常量。 + +### 1. 影响文件范围 + +允许修改: + +1. [controller/nest_query.cpp](controller/nest_query.cpp) +2. [controller/sql_dispatcher.cpp](controller/sql_dispatcher.cpp) +3. [tests/test_parser_dispatcher.cpp](tests/test_parser_dispatcher.cpp) +4. [tests/test_query_executor.cpp](tests/test_query_executor.cpp) + +### 2. 函数级完整数据流 + +```text +joined rowset +-> WHERE / ON filtered rows +-> ORDER BY sort key resolved from output alias or visible column +-> LIMIT truncate +-> projection output table +``` + +### 3. 函数级输入输出与内部逻辑 + +#### 3.1 多表 ORDER BY + +输入: + +1. filtered joined rows。 +2. resolved order key。 +3. column type 信息。 + +输出: + +1. 排序后的 rows。 + +内部逻辑: + +1. 尽量复用单表 `applyOrderBy` 的比较规则。 +2. column type 从 resolved column 对应 source schema 中取得。 +3. NULL-like 空字符串处理沿用当前单表排序语义。 +4. 投影别名优先于真实列名。 + +#### 3.2 多表 LIMIT + +输入: + +1. sorted rows。 +2. limit。 + +输出: + +1. 截断后的 rows。 + +内部逻辑: + +1. `limit < 0` 不截断。 +2. `limit = 0` 返回空结果。 +3. 行数不足时返回全部。 + +#### 3.3 输出 schema + +输入: + +1. resolved projection。 +2. output aliases。 + +输出: + +1. `TableData.columns`。 +2. `TableData.rows`。 + +内部逻辑: + +1. `SELECT s.id AS sid` 输出 `sid`。 +2. `SELECT s.id` 默认输出 `id`。 +3. `SELECT *` 多表首版输出 `prefix.column`,避免列名冲突。 +4. 如果用户显式投影两个同名输出列,允许重复列名还是拒绝需要收口。建议首版允许,因为 SQL 允许结果集中重复显示名;测试只依赖列位置。 + +### 4. 测试用例构建 + +新增测试: + +1. `ORDER BY` 限定列: + +```sql +SELECT s.id, c.name FROM student s JOIN class c ON s.class_id = c.id ORDER BY c.name DESC; +``` + +2. `ORDER BY` 投影别名: + +```sql +SELECT c.name AS cname FROM student s JOIN class c ON s.class_id = c.id ORDER BY cname ASC; +``` + +3. `LIMIT`: + +```sql +SELECT s.id FROM student s JOIN class c ON s.class_id = c.id ORDER BY s.id DESC LIMIT 1; +``` + +4. `SELECT *` 输出: + +```sql +SELECT * FROM student s JOIN class c ON s.class_id = c.id; +``` + +期望 columns 包含 `s.id` / `s.class_id` / `c.id` / `c.name` 或按最终 canonical prefix 规则稳定输出。 + +--- + +## 阶段五:文档、回归与性能风险收口 + +### 0. 常量增量 + +本阶段不新增常量。 + +### 1. 影响文件范围 + +允许修改: + +1. [tests/parserNdispatcher_test_plan.md](tests/parserNdispatcher_test_plan.md) +2. [tests/TEST_PLAN.md](tests/TEST_PLAN.md) +3. [INTEGRATION_AND_STRESS_TEST_PLAN.md](INTEGRATION_AND_STRESS_TEST_PLAN.md),仅当新增多表性能采样时修改。 + +### 2. 函数级完整数据流 + +本阶段不新增运行时数据流,只文档化: + +```text +SQL multi-table SELECT +-> parser fromSources / joins +-> name resolution +-> joined rowset construction +-> ON / WHERE evaluation +-> ORDER BY / LIMIT +-> projection output +``` + +### 3. 函数级输入输出与内部逻辑 + +本阶段不新增函数。 + +文档必须明确: + +1. 多表 FROM 第一版是嵌套循环执行,不做 join reorder。 +2. `JOIN` 默认是 `INNER JOIN`。 +3. `LEFT JOIN` / `RIGHT JOIN` / `FULL JOIN` 支持 `ON` 和表别名,未匹配侧用空字符串表示 NULL-like 值。 +4. 逗号 FROM 与 JOIN 首版不混用。 +5. 裸列名 ambiguous 必须失败。 +6. 多表 `SELECT *` 输出列名采用稳定限定名。 +7. 不新增聚合、UNION、`NATURAL JOIN`、`CROSS JOIN` 或 `USING`。 + +### 4. 测试用例构建 + +最终回归命令: + +```powershell +& 'E:\Qt\Tools\CMake_64\bin\cmake.exe' --build build/codex-vs-debug --config Debug --parallel 1 +``` + +```powershell +$env:QT_QPA_PLATFORM='offscreen' +$env:PATH='E:\Qt\6.9.2\msvc2022_64\bin;' + $env:PATH +& 'E:\Qt-projects\DBMS\build\codex-vs-debug\Debug\DBMS.exe' --run-tests +``` + +建议新增小规模多表性能烟测,但不并入现有压力测试总原则,除非后续明确要把多表查询纳入性能报告: + +```text +student rows = 50, class rows = 10 +JOIN ON select elapsed_ms +comma FROM + WHERE elapsed_ms +``` + +--- + +## 实施顺序建议 + +1. 阶段一只做 parser payload 和 parser 测试。 +2. 阶段二实现多表执行器,先支持逗号 FROM,再支持 JOIN ON。 +3. 阶段三补相关子查询在多表外层作用域下的绑定。 +4. 阶段四收紧 ORDER BY / LIMIT / 输出 schema / 错误信息。 +5. 阶段五补文档和全量回归。 + +不要先做优化。第一版目标是语义正确、错误明确、作用域不乱。性能优化可以在 JOIN 基本语义稳定后再单独规划,例如等值连接索引命中、ON predicate 下推、join reorder。 + +--- + +## 验收口径 + +本计划完成时,以下 SQL 必须通过: + +```sql +SELECT s.id, c.name FROM student s, class c WHERE s.class_id = c.id; +SELECT s.id, c.name FROM student s JOIN class c ON s.class_id = c.id; +SELECT s.id, c.name FROM student s LEFT JOIN class c ON s.class_id = c.id; +SELECT s.id, c.name FROM student s RIGHT JOIN class c ON s.class_id = c.id; +SELECT s.id, c.name FROM student s FULL JOIN class c ON s.class_id = c.id; +SELECT s.id FROM student s JOIN class c ON s.class_id = c.id WHERE c.name = 'cs'; +SELECT s.id AS sid FROM student s JOIN class c ON s.class_id = c.id ORDER BY sid DESC LIMIT 1; +SELECT s.id FROM student s JOIN class c ON s.class_id = c.id +WHERE EXISTS (SELECT x.id FROM score x WHERE x.student_id = s.id); +``` + +以下 SQL 必须失败: + +```sql +SELECT * FROM a JOIN b; +SELECT * FROM a JOIN b ON; +SELECT * FROM a NATURAL JOIN b; +SELECT * FROM a CROSS JOIN b; +SELECT * FROM a JOIN b USING (id); +SELECT * FROM a, b JOIN c ON b.id = c.id; +SELECT id FROM student s JOIN class c ON s.class_id = c.id; -- 两表都有 id 时 ambiguous +SELECT * FROM a x, b x; +SELECT * FROM a, a; +``` + +完成后不得破坏: + +1. 单表 `SELECT * FROM t WHERE id = 1`。 +2. 单表表别名 `SELECT s.id FROM student s`。 +3. 单表投影别名 `SELECT id AS sid FROM student ORDER BY sid DESC`。 +4. 旧 `outer.id` 相关子查询。 +5. 现有索引排序专项压测。 From d22c447c986a57a80629b685f114ecab7f77da20 Mon Sep 17 00:00:00 2001 From: chester Date: Sat, 16 May 2026 22:55:57 +0800 Subject: [PATCH 05/10] =?UTF-8?q?-=20ADDED=20=E5=AE=9E=E7=8E=B0=E4=BA=86FR?= =?UTF-8?q?OM=E5=A4=9A=E8=A1=A8/JOIN=E9=80=BB=E8=BE=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- controller/nest_query.cpp | 660 +++++++++++++++++++++++++++++ controller/sql_dispatcher.cpp | 3 +- tests/test_parser_dispatcher.cpp | 124 ++++++ utils/sql_parser/sql_tokenizer.cpp | 8 + utils/sql_parser/sql_tokenizer.h | 1 + utils/sql_parser/tuple_parser.cpp | 364 ++++++++++++++-- 6 files changed, 1130 insertions(+), 30 deletions(-) diff --git a/controller/nest_query.cpp b/controller/nest_query.cpp index 609380a..d9c417f 100644 --- a/controller/nest_query.cpp +++ b/controller/nest_query.cpp @@ -4,6 +4,7 @@ #include "../utils/service_common/service_common.h" #include +#include namespace service { @@ -33,6 +34,31 @@ struct QueryExecutionScope } }; +struct SelectTableSource +{ + QString tableName; + QString tableAlias; + tabledef::TableSchema schema; + repo::TableData data; +}; + +struct MultiProjectionItem +{ + QString sourceName; + QString resolvedKey; + QString outputName; + tabledef::ColumnType type = tabledef::ColumnType::Varchar; +}; + +struct MultiNameResolution +{ + QMap visibleNameToKey; + QMap keyTypes; + QSet ambiguousBareColumns; + QStringList starKeys; + QStringList starOutputColumns; +}; + bool simpleConditionsFromPayload(const QVariantList &conditionsPayload, QList *conditions, QString *error) @@ -150,6 +176,255 @@ QMap visibleColumnMap(const tabledef::TableSchema &schema, con return visible; } +QString canonicalPrefix(const SelectTableSource &source) +{ + const QString alias = source.tableAlias.trimmed(); + return alias.isEmpty() ? source.tableName.trimmed() : alias; +} + +QString qualifiedKey(const SelectTableSource &source, const tabledef::Column &column) +{ + return canonicalPrefix(source) + QLatin1Char('.') + column.name; +} + +logic::LogicCellValue nullCell(tabledef::ColumnType type) +{ + return logic::LogicCellValue{QString(), type, true}; +} + +bool loadSourcesFromPayload(const QVariantMap &payload, + QList *sources, + QString *error) +{ + if (sources != nullptr) { + sources->clear(); + } + + QVariantList sourcePayload = payload.value(QStringLiteral("fromSources")).toList(); + if (sourcePayload.isEmpty()) { + QVariantMap source; + source.insert(QStringLiteral("tableName"), payload.value(QStringLiteral("tableName")).toString()); + source.insert(QStringLiteral("tableAlias"), payload.value(QStringLiteral("tableAlias")).toString()); + sourcePayload.append(source); + } + + for (const QVariant &value : sourcePayload) { + const QVariantMap sourceMap = value.toMap(); + const QString tableName = sourceMap.value(QStringLiteral("tableName")).toString().trimmed(); + const QString tableAlias = sourceMap.value(QStringLiteral("tableAlias")).toString().trimmed(); + if (tableName.isEmpty()) { + if (error != nullptr) { + *error = QStringLiteral("SELECT: expected table name"); + } + return false; + } + + QString schemaError; + const tabledef::TableSchema schema = loadUserTableSchema(tableName, &schemaError); + if (!schemaError.isEmpty()) { + if (error != nullptr) { + *error = schemaError; + } + return false; + } + + const SelectRowsResult rows = tuple_service::selectRows(tableName, + QStringList{}, + QList{}, + -1); + if (!rows.success) { + if (error != nullptr) { + *error = rows.errorMessage; + } + return false; + } + + if (sources != nullptr) { + sources->append(SelectTableSource{tableName, tableAlias, schema, rows.resultTable}); + } + } + return true; +} + +bool buildMultiNameResolution(const QList &sources, + MultiNameResolution *resolution, + QString *error) +{ + if (resolution == nullptr) { + return true; + } + resolution->visibleNameToKey.clear(); + resolution->keyTypes.clear(); + resolution->ambiguousBareColumns.clear(); + resolution->starKeys.clear(); + resolution->starOutputColumns.clear(); + + QMap firstBareColumnKey; + QSet prefixes; + QSet unaliasedTables; + + for (const SelectTableSource &source : sources) { + const QString prefix = canonicalPrefix(source); + if (prefix.isEmpty()) { + if (error != nullptr) { + *error = QStringLiteral("SELECT: expected table name"); + } + return false; + } + if (prefixes.contains(prefix)) { + if (error != nullptr) { + *error = QStringLiteral("SELECT: duplicate table alias '%1'").arg(prefix); + } + return false; + } + prefixes.insert(prefix); + if (source.tableAlias.trimmed().isEmpty()) { + if (unaliasedTables.contains(source.tableName)) { + if (error != nullptr) { + *error = QStringLiteral("SELECT: duplicate table '%1' requires aliases").arg(source.tableName); + } + return false; + } + unaliasedTables.insert(source.tableName); + } + + for (const tabledef::Column &column : source.schema.columns) { + const QString key = qualifiedKey(source, column); + resolution->keyTypes.insert(key, column.type); + resolution->starKeys.append(key); + resolution->starOutputColumns.append(key); + + resolution->visibleNameToKey.insert(prefix + QLatin1Char('.') + column.name, key); + resolution->visibleNameToKey.insert(source.tableName + QLatin1Char('.') + column.name, key); + if (!source.tableAlias.trimmed().isEmpty()) { + resolution->visibleNameToKey.insert(source.tableAlias + QLatin1Char('.') + column.name, key); + } + + if (!firstBareColumnKey.contains(column.name)) { + firstBareColumnKey.insert(column.name, key); + } else if (firstBareColumnKey.value(column.name) != key) { + resolution->ambiguousBareColumns.insert(column.name); + } + } + } + + for (auto it = firstBareColumnKey.cbegin(); it != firstBareColumnKey.cend(); ++it) { + if (!resolution->ambiguousBareColumns.contains(it.key())) { + resolution->visibleNameToKey.insert(it.key(), it.value()); + } + } + return true; +} + +bool resolveMultiColumn(const MultiNameResolution &resolution, + const QString &name, + QString *resolvedKey, + QString *error) +{ + const QString trimmed = name.trimmed(); + if (resolution.ambiguousBareColumns.contains(trimmed)) { + if (error != nullptr) { + *error = QStringLiteral("ambiguous column '%1'").arg(trimmed); + } + return false; + } + const auto found = resolution.visibleNameToKey.constFind(trimmed); + if (found == resolution.visibleNameToKey.constEnd()) { + const int dotIndex = trimmed.indexOf(QLatin1Char('.')); + if (error != nullptr) { + if (dotIndex > 0) { + *error = QStringLiteral("unknown table or alias '%1'").arg(trimmed.left(dotIndex)); + } else { + *error = QStringLiteral("SELECT: column '%1' does not exist").arg(trimmed); + } + } + return false; + } + if (resolvedKey != nullptr) { + *resolvedKey = found.value(); + } + return true; +} + +logic::LogicRowContext buildSourceRowContext(const SelectTableSource &source, int rowIndex) +{ + logic::LogicRowContext rowContext; + rowContext.tableName = source.tableName; + if (rowIndex < 0 || rowIndex >= source.data.rows.size()) { + return rowContext; + } + + const repo::TableRow &row = source.data.rows.at(rowIndex); + const QString prefix = canonicalPrefix(source); + for (int columnIndex = 0; columnIndex < source.schema.columns.size(); ++columnIndex) { + const tabledef::Column &column = source.schema.columns.at(columnIndex); + const QString value = columnIndex < row.size() ? row.at(columnIndex) : QString(); + const logic::LogicCellValue cell{value, column.type, value.isEmpty()}; + rowContext.cellsByName.insert(prefix + QLatin1Char('.') + column.name, cell); + rowContext.cellsByName.insert(source.tableName + QLatin1Char('.') + column.name, cell); + if (!source.tableAlias.trimmed().isEmpty()) { + rowContext.cellsByName.insert(source.tableAlias + QLatin1Char('.') + column.name, cell); + } + rowContext.cellsByName.insert(qualifiedKey(source, column), cell); + } + return rowContext; +} + +logic::LogicRowContext buildNullSourceContext(const SelectTableSource &source) +{ + logic::LogicRowContext rowContext; + rowContext.tableName = source.tableName; + const QString prefix = canonicalPrefix(source); + for (const tabledef::Column &column : source.schema.columns) { + const logic::LogicCellValue cell = nullCell(column.type); + rowContext.cellsByName.insert(prefix + QLatin1Char('.') + column.name, cell); + rowContext.cellsByName.insert(source.tableName + QLatin1Char('.') + column.name, cell); + if (!source.tableAlias.trimmed().isEmpty()) { + rowContext.cellsByName.insert(source.tableAlias + QLatin1Char('.') + column.name, cell); + } + rowContext.cellsByName.insert(qualifiedKey(source, column), cell); + } + return rowContext; +} + +logic::LogicRowContext mergeRowContexts(const logic::LogicRowContext &left, + const logic::LogicRowContext &right) +{ + logic::LogicRowContext merged = left; + for (auto it = right.cellsByName.cbegin(); it != right.cellsByName.cend(); ++it) { + merged.cellsByName.insert(it.key(), it.value()); + } + return merged; +} + +void removeAmbiguousBareColumns(logic::LogicRowContext *rowContext, + const MultiNameResolution &resolution) +{ + if (rowContext == nullptr) { + return; + } + for (const QString &name : resolution.ambiguousBareColumns) { + rowContext->cellsByName.remove(name); + } +} + +void addUniqueBareColumns(logic::LogicRowContext *rowContext, + const MultiNameResolution &resolution) +{ + if (rowContext == nullptr) { + return; + } + for (auto it = resolution.visibleNameToKey.cbegin(); it != resolution.visibleNameToKey.cend(); ++it) { + if (it.key().contains(QLatin1Char('.'))) { + continue; + } + const auto cell = rowContext->cellsByName.constFind(it.value()); + if (cell != rowContext->cellsByName.constEnd()) { + rowContext->cellsByName.insert(it.key(), cell.value()); + } + } +} + bool resolveColumnName(const QMap &visibleColumns, const QString &name, QString *resolved, @@ -205,6 +480,387 @@ bool applySimpleConditions(const repo::TableData &table, return true; } +bool evaluateFilter(const logic::LogicNode *ast, + logic::LogicRowContext rowContext, + const logic::CorrelationBindings *bindings, + const logic::LogicEvalContext &evalContext, + QString *error) +{ + if (bindings != nullptr) { + mergeBindings(&rowContext, *bindings); + } + if (ast == nullptr) { + return true; + } + const logic::LogicEvalResult evalResult = logic::evaluateLogicExpression(*ast, rowContext, evalContext); + if (!evalResult.success) { + if (error != nullptr) { + *error = evalResult.error.message; + } + return false; + } + return evalResult.truth == logic::LogicTruthValue::True; +} + +QVector rowsForSource(const SelectTableSource &source) +{ + QVector rows; + rows.reserve(source.data.rows.size()); + for (int rowIndex = 0; rowIndex < source.data.rows.size(); ++rowIndex) { + rows.append(buildSourceRowContext(source, rowIndex)); + } + return rows; +} + +QVector joinRowsets(const QVector &leftRows, + const SelectTableSource &rightSource, + const QString &joinType, + const logic::LogicNode *onAst, + const logic::CorrelationBindings *bindings, + const logic::LogicEvalContext &evalContext, + QString *error) +{ + QVector output; + const QVector rightRows = rowsForSource(rightSource); + QVector matchedRight(rightRows.size(), false); + const logic::LogicRowContext nullRight = buildNullSourceContext(rightSource); + + for (const logic::LogicRowContext &leftRow : leftRows) { + bool matchedLeft = false; + for (int rightIndex = 0; rightIndex < rightRows.size(); ++rightIndex) { + logic::LogicRowContext combined = mergeRowContexts(leftRow, rightRows.at(rightIndex)); + QString evalError; + const bool matched = evaluateFilter(onAst, combined, bindings, evalContext, &evalError); + if (!evalError.isEmpty()) { + if (error != nullptr) { + *error = evalError; + } + return {}; + } + if (matched) { + matchedLeft = true; + matchedRight[rightIndex] = true; + output.append(combined); + } + } + if (!matchedLeft && (joinType == QStringLiteral("left") || joinType == QStringLiteral("full"))) { + output.append(mergeRowContexts(leftRow, nullRight)); + } + } + + if (joinType == QStringLiteral("right") || joinType == QStringLiteral("full")) { + logic::LogicRowContext nullLeft; + if (!leftRows.isEmpty()) { + for (auto it = leftRows.first().cellsByName.cbegin(); it != leftRows.first().cellsByName.cend(); ++it) { + nullLeft.cellsByName.insert(it.key(), nullCell(it.value().type)); + } + } + for (int rightIndex = 0; rightIndex < rightRows.size(); ++rightIndex) { + if (!matchedRight.value(rightIndex)) { + output.append(mergeRowContexts(nullLeft, rightRows.at(rightIndex))); + } + } + } + return output; +} + +bool resolveMultiProjection(const QVariantMap &payload, + const MultiNameResolution &resolution, + QList *projectionItems, + QStringList *outputColumns, + QList *columnTypes, + QString *error) +{ + if (projectionItems != nullptr) projectionItems->clear(); + if (outputColumns != nullptr) outputColumns->clear(); + if (columnTypes != nullptr) columnTypes->clear(); + + const bool selectAll = payload.value(QStringLiteral("selectAll"), false).toBool(); + if (selectAll) { + for (int i = 0; i < resolution.starKeys.size(); ++i) { + const QString key = resolution.starKeys.at(i); + const tabledef::ColumnType type = resolution.keyTypes.value(key, tabledef::ColumnType::Varchar); + const QString outputName = resolution.starOutputColumns.value(i, key); + if (projectionItems != nullptr) { + projectionItems->append(MultiProjectionItem{key, key, outputName, type}); + } + if (outputColumns != nullptr) outputColumns->append(outputName); + if (columnTypes != nullptr) columnTypes->append(type); + } + return true; + } + + const QVariantList items = projectionItemsFromPayload(payload); + if (items.isEmpty()) { + if (error != nullptr) { + *error = QStringLiteral("SELECT: expected projection columns"); + } + return false; + } + for (const QVariant &value : items) { + const QVariantMap item = value.toMap(); + const QString sourceName = item.value(QStringLiteral("sourceColumn")).toString(); + const QString outputName = item.value(QStringLiteral("outputColumn"), unqualifiedName(sourceName)).toString(); + QString resolvedKey; + if (!resolveMultiColumn(resolution, sourceName, &resolvedKey, error)) { + return false; + } + const tabledef::ColumnType type = resolution.keyTypes.value(resolvedKey, tabledef::ColumnType::Varchar); + const QString finalOutputName = outputName.trimmed().isEmpty() ? unqualifiedName(sourceName) : outputName; + if (projectionItems != nullptr) { + projectionItems->append(MultiProjectionItem{sourceName, resolvedKey, finalOutputName, type}); + } + if (outputColumns != nullptr) outputColumns->append(finalOutputName); + if (columnTypes != nullptr) columnTypes->append(type); + } + return true; +} + +QueryExecuteResult makeResultFromSelect(const SelectRowsResult &selectResult, + const QString &text); + +bool resolveMultiOrderBy(const QVariantMap &payload, + const MultiNameResolution &resolution, + const QList &projectionItems, + QString *orderKey, + tabledef::ColumnType *orderType, + bool *descending, + QString *error) +{ + if (orderKey != nullptr) orderKey->clear(); + if (orderType != nullptr) *orderType = tabledef::ColumnType::Varchar; + if (descending != nullptr) *descending = payload.value(QStringLiteral("orderByDescending"), false).toBool(); + + const QString rawOrderBy = payload.value(QStringLiteral("orderByColumn")).toString().trimmed(); + if (rawOrderBy.isEmpty()) { + return true; + } + for (const MultiProjectionItem &item : projectionItems) { + if (item.outputName == rawOrderBy) { + if (orderKey != nullptr) *orderKey = item.resolvedKey; + if (orderType != nullptr) *orderType = item.type; + return true; + } + } + QString resolved; + if (!resolveMultiColumn(resolution, rawOrderBy, &resolved, error)) { + return false; + } + if (orderKey != nullptr) *orderKey = resolved; + if (orderType != nullptr) *orderType = resolution.keyTypes.value(resolved, tabledef::ColumnType::Varchar); + return true; +} + +int compareCellValues(const QString &left, + const QString &right, + tabledef::ColumnType type) +{ + if (left.isEmpty() && right.isEmpty()) return 0; + if (left.isEmpty()) return -1; + if (right.isEmpty()) return 1; + if (type == tabledef::ColumnType::Int) { + bool leftOk = false; + bool rightOk = false; + const qlonglong leftValue = left.toLongLong(&leftOk); + const qlonglong rightValue = right.toLongLong(&rightOk); + if (leftOk && rightOk) { + return leftValue < rightValue ? -1 : (leftValue > rightValue ? 1 : 0); + } + } else if (type == tabledef::ColumnType::Float) { + bool leftOk = false; + bool rightOk = false; + const double leftValue = left.toDouble(&leftOk); + const double rightValue = right.toDouble(&rightOk); + if (leftOk && rightOk) { + return leftValue < rightValue ? -1 : (leftValue > rightValue ? 1 : 0); + } + } + const int cmp = QString::compare(left, right); + return cmp < 0 ? -1 : (cmp > 0 ? 1 : 0); +} + +void sortJoinedRows(QVector *rows, + const QString &orderKey, + tabledef::ColumnType orderType, + bool descending) +{ + if (rows == nullptr || orderKey.trimmed().isEmpty()) { + return; + } + std::stable_sort(rows->begin(), rows->end(), [&](const logic::LogicRowContext &left, const logic::LogicRowContext &right) { + const QString leftValue = left.cellsByName.value(orderKey).value; + const QString rightValue = right.cellsByName.value(orderKey).value; + const int comparison = compareCellValues(leftValue, rightValue, orderType); + return descending ? comparison > 0 : comparison < 0; + }); +} + +QueryExecuteResult execMultiTableSelect(QueryExecutor *executor, + const sqlparser::ParseResult &parsed, + const logic::CorrelationBindings *bindings) +{ + QueryExecuteResult result; + + QString error; + QList sources; + if (!loadSourcesFromPayload(parsed.payload, &sources, &error)) { + result.success = false; + result.errorMessage = error; + result.text = error; + return result; + } + if (sources.isEmpty()) { + result.success = false; + result.errorMessage = QStringLiteral("SELECT: expected table name"); + result.text = result.errorMessage; + return result; + } + + MultiNameResolution resolution; + if (!buildMultiNameResolution(sources, &resolution, &error)) { + result.success = false; + result.errorMessage = error; + result.text = error; + return result; + } + + logic::LogicEvalContext evalContext; + evalContext.subqueryExecutor = executor; + evalContext.currentDatabase = currentDatabase; + evalContext.dataRoot = getDataRoot(); + evalContext.allowSubquery = true; + + logic::LogicNode whereAst; + const bool hasWhereAst = parsed.payload.contains(QStringLiteral("whereAst")); + if (hasWhereAst) { + whereAst = parsed.payload.value(QStringLiteral("whereAst")).value(); + } + + QVector joinedRows = rowsForSource(sources.first()); + const QVariantList joinPayload = parsed.payload.value(QStringLiteral("joins")).toList(); + if (joinPayload.isEmpty()) { + for (int sourceIndex = 1; sourceIndex < sources.size(); ++sourceIndex) { + joinedRows = joinRowsets(joinedRows, + sources.at(sourceIndex), + QStringLiteral("inner"), + nullptr, + bindings, + evalContext, + &error); + if (!error.isEmpty()) { + result.success = false; + result.errorMessage = error; + result.text = error; + return result; + } + } + } else { + for (const QVariant &joinValue : joinPayload) { + const QVariantMap joinMap = joinValue.toMap(); + const int rightIndex = joinMap.value(QStringLiteral("rightSourceIndex")).toInt(); + if (rightIndex <= 0 || rightIndex >= sources.size()) { + result.success = false; + result.errorMessage = QStringLiteral("SELECT: invalid JOIN payload"); + result.text = result.errorMessage; + return result; + } + const logic::LogicNode onAst = joinMap.value(QStringLiteral("onAst")).value(); + joinedRows = joinRowsets(joinedRows, + sources.at(rightIndex), + joinMap.value(QStringLiteral("joinType"), QStringLiteral("inner")).toString(), + &onAst, + bindings, + evalContext, + &error); + if (!error.isEmpty()) { + result.success = false; + result.errorMessage = error; + result.text = error; + return result; + } + } + } + + for (logic::LogicRowContext &row : joinedRows) { + removeAmbiguousBareColumns(&row, resolution); + addUniqueBareColumns(&row, resolution); + } + + QVector filteredRows; + filteredRows.reserve(joinedRows.size()); + for (logic::LogicRowContext row : joinedRows) { + QString evalError; + const bool include = evaluateFilter(hasWhereAst ? &whereAst : nullptr, + row, + bindings, + evalContext, + &evalError); + if (!evalError.isEmpty()) { + result.success = false; + result.errorMessage = evalError; + result.text = evalError; + return result; + } + if (include) { + filteredRows.append(row); + } + } + + QList projectionItems; + QStringList outputColumns; + QList columnTypes; + if (!resolveMultiProjection(parsed.payload, + resolution, + &projectionItems, + &outputColumns, + &columnTypes, + &error)) { + result.success = false; + result.errorMessage = error; + result.text = error; + return result; + } + + QString orderKey; + tabledef::ColumnType orderType = tabledef::ColumnType::Varchar; + bool descending = false; + if (!resolveMultiOrderBy(parsed.payload, + resolution, + projectionItems, + &orderKey, + &orderType, + &descending, + &error)) { + result.success = false; + result.errorMessage = error; + result.text = error; + return result; + } + sortJoinedRows(&filteredRows, orderKey, orderType, descending); + + const int limit = parsed.payload.value(QStringLiteral("limit"), -1).toInt(); + SelectRowsResult selectResult; + selectResult.success = true; + selectResult.resultTable.columns = outputColumns; + selectResult.columnTypes = columnTypes; + + int emitted = 0; + for (const logic::LogicRowContext &row : filteredRows) { + if (limit >= 0 && emitted >= limit) { + break; + } + repo::TableRow outputRow; + outputRow.reserve(projectionItems.size()); + for (const MultiProjectionItem &item : projectionItems) { + outputRow.append(row.cellsByName.value(item.resolvedKey).value); + } + selectResult.resultTable.rows.append(outputRow); + ++emitted; + } + selectResult.affectedRowCount = selectResult.resultTable.rows.size(); + return makeResultFromSelect(selectResult, QString()); +} + QueryExecuteResult makeResultFromSelect(const SelectRowsResult &selectResult, const QString &text = QString()) { @@ -305,6 +961,10 @@ QueryExecuteResult QueryExecutor::execSelect(const sqlparser::ParseResult &parse return result; } + if (parsed.payload.value(QStringLiteral("isMultiTable")).toBool()) { + return execMultiTableSelect(this, parsed, bindings); + } + const QString tableName = parsed.payload.value(QStringLiteral("tableName")).toString(); if (tableName.trimmed().isEmpty()) { result.success = false; diff --git a/controller/sql_dispatcher.cpp b/controller/sql_dispatcher.cpp index c391cee..f358265 100644 --- a/controller/sql_dispatcher.cpp +++ b/controller/sql_dispatcher.cpp @@ -825,7 +825,8 @@ SqlExecResult SqlDispatcher::execSelect(const sqlparser::ParseResult& p) { return {false, schemaError}; } - if (p.payload.value(QStringLiteral("hasComplexWhere")).toBool()) { + if (p.payload.value(QStringLiteral("hasComplexWhere")).toBool() + || p.payload.value(QStringLiteral("isMultiTable")).toBool()) { QueryExecutor executor; const QueryExecuteResult queryResult = executor.executeParsed(p, QueryExecuteContext{currentDatabase, diff --git a/tests/test_parser_dispatcher.cpp b/tests/test_parser_dispatcher.cpp index 3411aa3..2a74541 100644 --- a/tests/test_parser_dispatcher.cpp +++ b/tests/test_parser_dispatcher.cpp @@ -275,6 +275,37 @@ private slots: const sqlparser::ParseResult multiOrder = sqlparser::parseSql( QStringLiteral("SELECT id FROM student ORDER BY a, b")); QVERIFY(!multiOrder.success); + + const sqlparser::ParseResult commaFrom = sqlparser::parseSql( + QStringLiteral("SELECT s.id, c.name FROM student s, class c WHERE s.class_id = c.id")); + QVERIFY2(commaFrom.success, qPrintable(commaFrom.errorMessage)); + QCOMPARE(commaFrom.payload.value(QStringLiteral("isMultiTable")).toBool(), true); + QCOMPARE(commaFrom.payload.value(QStringLiteral("fromSources")).toList().size(), 2); + QCOMPARE(commaFrom.payload.value(QStringLiteral("joins")).toList().size(), 0); + + const sqlparser::ParseResult leftJoin = sqlparser::parseSql( + QStringLiteral("SELECT s.id FROM student s LEFT JOIN class c ON s.class_id = c.id")); + QVERIFY2(leftJoin.success, qPrintable(leftJoin.errorMessage)); + QCOMPARE(leftJoin.payload.value(QStringLiteral("fromSources")).toList().size(), 2); + const QVariantMap leftJoinPayload = leftJoin.payload.value(QStringLiteral("joins")).toList().first().toMap(); + QCOMPARE(leftJoinPayload.value(QStringLiteral("joinType")).toString(), QStringLiteral("left")); + QVERIFY(leftJoinPayload.contains(QStringLiteral("onAst"))); + + const sqlparser::ParseResult rightJoin = sqlparser::parseSql( + QStringLiteral("SELECT s.id FROM student s RIGHT JOIN class c ON s.class_id = c.id")); + QVERIFY2(rightJoin.success, qPrintable(rightJoin.errorMessage)); + QCOMPARE(rightJoin.payload.value(QStringLiteral("joins")).toList().first().toMap().value(QStringLiteral("joinType")).toString(), + QStringLiteral("right")); + + const sqlparser::ParseResult fullJoin = sqlparser::parseSql( + QStringLiteral("SELECT s.id FROM student s FULL JOIN class c ON s.class_id = c.id")); + QVERIFY2(fullJoin.success, qPrintable(fullJoin.errorMessage)); + QCOMPARE(fullJoin.payload.value(QStringLiteral("joins")).toList().first().toMap().value(QStringLiteral("joinType")).toString(), + QStringLiteral("full")); + + QVERIFY(!sqlparser::parseSql(QStringLiteral("SELECT * FROM a JOIN b")).success); + QVERIFY(!sqlparser::parseSql(QStringLiteral("SELECT * FROM a NATURAL JOIN b")).success); + QVERIFY(!sqlparser::parseSql(QStringLiteral("SELECT * FROM a, b JOIN c ON b.id = c.id")).success); } void test_parseUpdateAndDeleteSupportSimpleWhere() @@ -476,6 +507,99 @@ private slots: QCOMPARE(result.selectResult.resultTable.rows.at(1).value(0), QStringLiteral("3")); } + void test_dispatchMultiTableFromAndJoins() + { + const QString databaseName = QStringLiteral("test_parser_dispatcher_multi_from_db"); + ensureDatabase(databaseName); + + tabledef::TableSchema studentSchema; + studentSchema.tableName = QStringLiteral("student_multi"); + studentSchema.columns = { + makeColumn(QStringLiteral("id"), tabledef::ColumnType::Int, 0, true), + makeColumn(QStringLiteral("class_id"), tabledef::ColumnType::Int), + makeColumn(QStringLiteral("name"), tabledef::ColumnType::Varchar, 32), + }; + studentSchema.constraints = { + makePrimaryKey(QStringLiteral("pk_student_multi_id"), {QStringLiteral("id")}), + }; + ensureTable(studentSchema.tableName, studentSchema); + + tabledef::TableSchema classSchema; + classSchema.tableName = QStringLiteral("class_multi"); + classSchema.columns = { + makeColumn(QStringLiteral("id"), tabledef::ColumnType::Int, 0, true), + makeColumn(QStringLiteral("name"), tabledef::ColumnType::Varchar, 32), + }; + classSchema.constraints = { + makePrimaryKey(QStringLiteral("pk_class_multi_id"), {QStringLiteral("id")}), + }; + ensureTable(classSchema.tableName, classSchema); + + seedRows(studentSchema.tableName, + {makeRow({{QStringLiteral("id"), QStringLiteral("1")}, + {QStringLiteral("class_id"), QStringLiteral("10")}, + {QStringLiteral("name"), QStringLiteral("alice")}}), + makeRow({{QStringLiteral("id"), QStringLiteral("2")}, + {QStringLiteral("class_id"), QStringLiteral("20")}, + {QStringLiteral("name"), QStringLiteral("bob")}}), + makeRow({{QStringLiteral("id"), QStringLiteral("3")}, + {QStringLiteral("class_id"), QStringLiteral("99")}, + {QStringLiteral("name"), QStringLiteral("carol")}})}); + seedRows(classSchema.tableName, + {makeRow({{QStringLiteral("id"), QStringLiteral("10")}, + {QStringLiteral("name"), QStringLiteral("cs")}}), + makeRow({{QStringLiteral("id"), QStringLiteral("20")}, + {QStringLiteral("name"), QStringLiteral("math")}}), + makeRow({{QStringLiteral("id"), QStringLiteral("30")}, + {QStringLiteral("name"), QStringLiteral("art")}})}); + + SqlDispatcher dispatcher; + SqlExecResult result = dispatcher.execute( + QStringLiteral("SELECT s.id, c.name FROM student_multi s, class_multi c " + "WHERE s.class_id = c.id ORDER BY s.id ASC")); + QVERIFY2(result.success, qPrintable(result.errorMessage)); + QCOMPARE(result.selectResult.resultTable.rows.size(), 2); + QCOMPARE(result.selectResult.resultTable.rows.at(0).value(0), QStringLiteral("1")); + QCOMPARE(result.selectResult.resultTable.rows.at(0).value(1), QStringLiteral("cs")); + QCOMPARE(result.selectResult.resultTable.rows.at(1).value(0), QStringLiteral("2")); + QCOMPARE(result.selectResult.resultTable.rows.at(1).value(1), QStringLiteral("math")); + + result = dispatcher.execute( + QStringLiteral("SELECT s.id AS sid, c.name FROM student_multi s " + "JOIN class_multi c ON s.class_id = c.id ORDER BY sid DESC")); + QVERIFY2(result.success, qPrintable(result.errorMessage)); + QCOMPARE(result.selectResult.resultTable.columns.first(), QStringLiteral("sid")); + QCOMPARE(result.selectResult.resultTable.rows.size(), 2); + QCOMPARE(result.selectResult.resultTable.rows.at(0).value(0), QStringLiteral("2")); + + result = dispatcher.execute( + QStringLiteral("SELECT s.id, c.name FROM student_multi s " + "LEFT JOIN class_multi c ON s.class_id = c.id ORDER BY s.id ASC")); + QVERIFY2(result.success, qPrintable(result.errorMessage)); + QCOMPARE(result.selectResult.resultTable.rows.size(), 3); + QCOMPARE(result.selectResult.resultTable.rows.at(2).value(0), QStringLiteral("3")); + QCOMPARE(result.selectResult.resultTable.rows.at(2).value(1), QString()); + + result = dispatcher.execute( + QStringLiteral("SELECT s.id, c.name FROM student_multi s " + "RIGHT JOIN class_multi c ON s.class_id = c.id ORDER BY c.id ASC")); + QVERIFY2(result.success, qPrintable(result.errorMessage)); + QCOMPARE(result.selectResult.resultTable.rows.size(), 3); + QCOMPARE(result.selectResult.resultTable.rows.at(2).value(0), QString()); + QCOMPARE(result.selectResult.resultTable.rows.at(2).value(1), QStringLiteral("art")); + + result = dispatcher.execute( + QStringLiteral("SELECT s.id, c.name FROM student_multi s " + "FULL JOIN class_multi c ON s.class_id = c.id ORDER BY c.name ASC")); + QVERIFY2(result.success, qPrintable(result.errorMessage)); + QCOMPARE(result.selectResult.resultTable.rows.size(), 4); + + result = dispatcher.execute( + QStringLiteral("SELECT id FROM student_multi s JOIN class_multi c ON s.class_id = c.id")); + QVERIFY(!result.success); + QVERIFY(result.errorMessage.contains(QStringLiteral("ambiguous column"))); + } + void test_dispatchUpdateAndDeleteKeepQualifiedWhereOnAstPath() { const QString databaseName = QStringLiteral("test_parser_dispatcher_qualified_dml_db"); diff --git a/utils/sql_parser/sql_tokenizer.cpp b/utils/sql_parser/sql_tokenizer.cpp index a7f9254..f6bed30 100644 --- a/utils/sql_parser/sql_tokenizer.cpp +++ b/utils/sql_parser/sql_tokenizer.cpp @@ -39,6 +39,14 @@ TokenType SqlTokenizer::keywordLookup(const QString& word) { if (w == "CONSTRAINT") return TokenType::CONSTRAINT; if (w == "INDEX") return TokenType::INDEX; if (w == "ON") return TokenType::ON; + if (w == "JOIN") return TokenType::JOIN; + if (w == "INNER") return TokenType::INNER; + if (w == "LEFT") return TokenType::LEFT; + if (w == "RIGHT") return TokenType::RIGHT; + if (w == "FULL") return TokenType::FULL; + if (w == "NATURAL") return TokenType::NATURAL; + if (w == "CROSS") return TokenType::CROSS; + if (w == "USING") return TokenType::USING; if (w == "USER") return TokenType::USER; if (w == "IDENTIFIED") return TokenType::IDENTIFIED; if (w == "BY") return TokenType::BY; diff --git a/utils/sql_parser/sql_tokenizer.h b/utils/sql_parser/sql_tokenizer.h index a1f7215..cb15d06 100644 --- a/utils/sql_parser/sql_tokenizer.h +++ b/utils/sql_parser/sql_tokenizer.h @@ -19,6 +19,7 @@ enum class TokenType { USE, SHOW, DESC, SELECT, FROM, WHERE, ORDER, LIMIT, INSERT, INTO, VALUES, UPDATE, SET, DELETE, ALTER, ADD, COLUMN, MODIFY, CONSTRAINT, INDEX, ON, + JOIN, INNER, LEFT, RIGHT, FULL, NATURAL, CROSS, USING, USER, IDENTIFIED, BY, GRANT, REVOKE, ALL, TO, LOGIN, PRIMARY_KEY, FOREIGN_KEY, REFERENCES, KEY, ASC, UNIQUE, CHECK, DEFAULT, NULL_VAL, NOT, AUTO_INCREMENT, diff --git a/utils/sql_parser/tuple_parser.cpp b/utils/sql_parser/tuple_parser.cpp index 5544975..d5fce4c 100644 --- a/utils/sql_parser/tuple_parser.cpp +++ b/utils/sql_parser/tuple_parser.cpp @@ -198,6 +198,32 @@ static bool isClauseTerminator(TokenType type) || type == TokenType::END_OF_INPUT; } +static bool isJoinStart(TokenType type) +{ + return type == TokenType::JOIN + || type == TokenType::INNER + || type == TokenType::LEFT + || type == TokenType::RIGHT + || type == TokenType::FULL + || type == TokenType::NATURAL + || type == TokenType::CROSS; +} + +static bool isFromItemTerminator(TokenType type) +{ + return isClauseTerminator(type) + || type == TokenType::COMMA + || isJoinStart(type) + || type == TokenType::ON; +} + +static bool isAliasForbidden(TokenType type) +{ + return isFromItemTerminator(type) + || type == TokenType::BY + || type == TokenType::USING; +} + static bool isIdentifierLike(TokenType type) { return type == TokenType::IDENTIFIER @@ -234,6 +260,294 @@ static bool parseQualifiedIdentifier(const QVector &tokens, return true; } +static bool parseLogicAstFromTokenRange(const QString &sql, + const QVector &tokens, + int from, + int to, + logic::LogicNode *ast, + QString *error) +{ + const QString text = sliceClauseText(sql, tokens, from, to); + if (text.trimmed().isEmpty()) { + if (error != nullptr) { + *error = QStringLiteral("JOIN: expected ON condition"); + } + return false; + } + + const logic::LogicTokenizeResult tokenized = logic::tokenizeLogicExpression(text); + if (!tokenized.success) { + if (error != nullptr) { + *error = tokenized.error.message; + } + return false; + } + + const logic::LogicParseResult parsed = logic::parseLogicTokens(text, tokenized.tokens); + if (!parsed.success) { + if (error != nullptr) { + *error = parsed.error.message; + } + return false; + } + if (ast != nullptr) { + *ast = parsed.root; + } + return true; +} + +static bool parseTableSource(const QVector &tokens, + int startIndex, + int endExclusive, + QVariantMap *source, + int *nextIndex, + QString *error) +{ + if (startIndex >= endExclusive || !isIdentifierLike(tokens[startIndex].type)) { + if (error != nullptr) { + *error = QStringLiteral("SELECT: expected table name"); + } + return false; + } + if (startIndex + 1 < endExclusive && tokens[startIndex + 1].type == TokenType::DOT) { + if (error != nullptr) { + *error = QStringLiteral("SELECT: database-qualified table names are not supported"); + } + return false; + } + + QVariantMap parsedSource; + parsedSource.insert(QStringLiteral("tableName"), tokens[startIndex].lexeme); + parsedSource.insert(QStringLiteral("tableAlias"), QString()); + + int index = startIndex + 1; + if (index < endExclusive && !isFromItemTerminator(tokens[index].type)) { + if (tokens[index].lexeme.compare(QStringLiteral("AS"), Qt::CaseInsensitive) == 0) { + ++index; + if (index >= endExclusive || !isIdentifierLike(tokens[index].type) || isAliasForbidden(tokens[index].type)) { + if (error != nullptr) { + *error = QStringLiteral("SELECT: expected table alias after AS"); + } + return false; + } + parsedSource.insert(QStringLiteral("tableAlias"), tokens[index].lexeme); + ++index; + } else if (isIdentifierLike(tokens[index].type) && !isAliasForbidden(tokens[index].type)) { + parsedSource.insert(QStringLiteral("tableAlias"), tokens[index].lexeme); + ++index; + } + } + + if (source != nullptr) { + *source = parsedSource; + } + if (nextIndex != nullptr) { + *nextIndex = index; + } + return true; +} + +static QString sourceAliasOrTable(const QVariantMap &source) +{ + const QString alias = source.value(QStringLiteral("tableAlias")).toString().trimmed(); + return alias.isEmpty() ? source.value(QStringLiteral("tableName")).toString().trimmed() : alias; +} + +static bool validateSourceNames(const QVariantList &sources, QString *error) +{ + QSet visiblePrefixes; + QSet unaliasedTables; + for (const QVariant &value : sources) { + const QVariantMap source = value.toMap(); + const QString tableName = source.value(QStringLiteral("tableName")).toString().trimmed(); + const QString alias = source.value(QStringLiteral("tableAlias")).toString().trimmed(); + const QString prefix = alias.isEmpty() ? tableName : alias; + if (prefix.isEmpty()) { + if (error != nullptr) { + *error = QStringLiteral("SELECT: expected table name"); + } + return false; + } + if (visiblePrefixes.contains(prefix)) { + if (error != nullptr) { + *error = QStringLiteral("SELECT: duplicate table alias '%1'").arg(prefix); + } + return false; + } + visiblePrefixes.insert(prefix); + if (alias.isEmpty()) { + if (unaliasedTables.contains(tableName)) { + if (error != nullptr) { + *error = QStringLiteral("SELECT: duplicate table '%1' requires aliases").arg(tableName); + } + return false; + } + unaliasedTables.insert(tableName); + } + } + return true; +} + +static bool parseFromClause(const QString &sql, + const QVector &tokens, + int fromIndex, + int clauseEndIndex, + QVariantList *fromSources, + QVariantList *joins, + QString *singleTableName, + QString *singleTableAlias, + bool *isMultiTable, + QString *error) +{ + if (fromSources != nullptr) { + fromSources->clear(); + } + if (joins != nullptr) { + joins->clear(); + } + + const int endExclusive = clauseEndIndex; + int index = fromIndex + 1; + QVariantMap firstSource; + if (!parseTableSource(tokens, index, endExclusive, &firstSource, &index, error)) { + return false; + } + + QVariantList parsedSources; + QVariantList parsedJoins; + parsedSources.append(firstSource); + + enum class FromMode { None, Comma, Join }; + FromMode mode = FromMode::None; + + while (index < endExclusive) { + if (tokens[index].type == TokenType::COMMA) { + if (mode == FromMode::Join) { + if (error != nullptr) { + *error = QStringLiteral("SELECT: cannot mix comma FROM and JOIN in the same FROM clause"); + } + return false; + } + mode = FromMode::Comma; + ++index; + QVariantMap source; + if (!parseTableSource(tokens, index, endExclusive, &source, &index, error)) { + return false; + } + parsedSources.append(source); + continue; + } + + QString joinType; + if (tokens[index].type == TokenType::JOIN) { + if (mode == FromMode::Comma) { + if (error != nullptr) { + *error = QStringLiteral("SELECT: cannot mix comma FROM and JOIN in the same FROM clause"); + } + return false; + } + joinType = QStringLiteral("inner"); + ++index; + } else if (tokens[index].type == TokenType::INNER + || tokens[index].type == TokenType::LEFT + || tokens[index].type == TokenType::RIGHT + || tokens[index].type == TokenType::FULL) { + if (mode == FromMode::Comma) { + if (error != nullptr) { + *error = QStringLiteral("SELECT: cannot mix comma FROM and JOIN in the same FROM clause"); + } + return false; + } + if (tokens[index].type == TokenType::INNER) joinType = QStringLiteral("inner"); + if (tokens[index].type == TokenType::LEFT) joinType = QStringLiteral("left"); + if (tokens[index].type == TokenType::RIGHT) joinType = QStringLiteral("right"); + if (tokens[index].type == TokenType::FULL) joinType = QStringLiteral("full"); + ++index; + if (index >= endExclusive || tokens[index].type != TokenType::JOIN) { + if (error != nullptr) { + *error = QStringLiteral("SELECT: expected JOIN after join type"); + } + return false; + } + ++index; + } else if (tokens[index].type == TokenType::NATURAL || tokens[index].type == TokenType::CROSS) { + if (error != nullptr) { + *error = QStringLiteral("SELECT: unsupported JOIN type '%1'").arg(tokens[index].lexeme); + } + return false; + } else { + if (error != nullptr) { + *error = QStringLiteral("SELECT: unsupported trailing token '%1'").arg(tokens[index].lexeme); + } + return false; + } + + mode = FromMode::Join; + QVariantMap rightSource; + const int rightSourceIndex = parsedSources.size(); + if (!parseTableSource(tokens, index, endExclusive, &rightSource, &index, error)) { + return false; + } + parsedSources.append(rightSource); + + if (index >= endExclusive || tokens[index].type != TokenType::ON) { + if (index < endExclusive && tokens[index].type == TokenType::USING) { + if (error != nullptr) { + *error = QStringLiteral("SELECT: JOIN USING is not supported"); + } + return false; + } + if (error != nullptr) { + *error = QStringLiteral("SELECT: JOIN requires ON condition"); + } + return false; + } + + const int onStart = index + 1; + int onEndExclusive = onStart; + while (onEndExclusive < endExclusive && !isJoinStart(tokens[onEndExclusive].type)) { + ++onEndExclusive; + } + logic::LogicNode onAst; + QString onError; + if (!parseLogicAstFromTokenRange(sql, tokens, onStart, onEndExclusive - 1, &onAst, &onError)) { + if (error != nullptr) { + *error = onError; + } + return false; + } + + QVariantMap join; + join.insert(QStringLiteral("joinType"), joinType); + join.insert(QStringLiteral("leftSourceIndex"), rightSourceIndex - 1); + join.insert(QStringLiteral("rightSourceIndex"), rightSourceIndex); + join.insert(QStringLiteral("onAst"), QVariant::fromValue(onAst)); + parsedJoins.append(join); + index = onEndExclusive; + } + + if (!validateSourceNames(parsedSources, error)) { + return false; + } + + if (fromSources != nullptr) { + *fromSources = parsedSources; + } + if (joins != nullptr) { + *joins = parsedJoins; + } + if (singleTableName != nullptr) { + *singleTableName = firstSource.value(QStringLiteral("tableName")).toString(); + } + if (singleTableAlias != nullptr) { + *singleTableAlias = firstSource.value(QStringLiteral("tableAlias")).toString(); + } + if (isMultiTable != nullptr) { + *isMultiTable = parsedSources.size() > 1; + } + return true; +} + static QString defaultOutputNameForSource(const QString &sourceName) { const int dotIndex = sourceName.lastIndexOf(QLatin1Char('.')); @@ -537,9 +851,10 @@ ParseResult parseTupleSql(const QString& sql, const QVector& tokens) { if (cmdType == "SELECT") { QStringList projection; QVariantList projectionItems; + QVariantList fromSources; + QVariantList joins; QString table; QString tableAlias; - int tableIndex = -1; int fromIdx = -1; for (int i = 1; i < tokens.size(); ++i) { @@ -556,20 +871,10 @@ ParseResult parseTupleSql(const QString& sql, const QVector& tokens) { return {false, projectionError, cmdType, {}}; } - if (fromIdx >= 0) { - for (int i = fromIdx + 1; i < tokens.size(); ++i) { - if (tokens[i].type == TokenType::IDENTIFIER) - { table = tokens[i].lexeme; tableIndex = i; break; } - } - } - - if (table.isEmpty()) - return {false, "SELECT: expected FROM table", cmdType, {}}; - int whereIdx = -1; int orderIdx = -1; int limitIdx = -1; - for (int i = tableIndex + 1; i < tokens.size(); ++i) { + for (int i = fromIdx + 1; i < tokens.size(); ++i) { if (tokens[i].type == TokenType::WHERE && whereIdx < 0) { whereIdx = i; } else if (tokens[i].type == TokenType::ORDER && orderIdx < 0) { @@ -594,22 +899,20 @@ ParseResult parseTupleSql(const QString& sql, const QVector& tokens) { : (orderIdx >= 0 ? orderIdx : (limitIdx >= 0 ? limitIdx : lastMeaningfulTokenIndex(tokens) + 1)); - int aliasIndex = tableIndex + 1; - if (aliasIndex < tableTailEnd) { - if (tokens[aliasIndex].lexeme.compare(QStringLiteral("AS"), Qt::CaseInsensitive) == 0) { - ++aliasIndex; - if (aliasIndex >= tableTailEnd || tokens[aliasIndex].type != TokenType::IDENTIFIER) { - return {false, "SELECT: expected table alias after AS", cmdType, {}}; - } - tableAlias = tokens[aliasIndex].lexeme; - ++aliasIndex; - } else if (tokens[aliasIndex].type == TokenType::IDENTIFIER) { - tableAlias = tokens[aliasIndex].lexeme; - ++aliasIndex; - } - } - if (aliasIndex < tableTailEnd) { - return {false, QStringLiteral("SELECT: unsupported trailing token '%1'").arg(tokens[aliasIndex].lexeme), cmdType, {}}; + + bool isMultiTable = false; + QString fromError; + if (!parseFromClause(sql, + tokens, + fromIdx, + tableTailEnd, + &fromSources, + &joins, + &table, + &tableAlias, + &isMultiTable, + &fromError)) { + return {false, fromError, cmdType, {}}; } QString whereError; @@ -627,7 +930,7 @@ ParseResult parseTupleSql(const QString& sql, const QVector& tokens) { QString limitError; const int limitParseStart = limitIdx >= 0 ? limitIdx - : ((orderIdx >= 0 || whereIdx >= 0) ? tokens.size() - 1 : tableIndex + 1); + : ((orderIdx >= 0 || whereIdx >= 0) ? tokens.size() - 1 : tableTailEnd); if (!parseSelectLimit(tokens, limitParseStart, &limit, &limitError)) { return {false, limitError, cmdType, {}}; } @@ -641,6 +944,9 @@ ParseResult parseTupleSql(const QString& sql, const QVector& tokens) { payload["projectionItems"] = projectionItems; payload["tableName"] = table; payload["tableAlias"] = tableAlias; + payload["fromSources"] = fromSources; + payload["joins"] = joins; + payload["isMultiTable"] = isMultiTable; payload["limit"] = limit; return {true, "", cmdType, payload}; From 360d430db21e313c128cbb3f451b530d4ab936b8 Mon Sep 17 00:00:00 2001 From: chester Date: Sat, 16 May 2026 23:28:12 +0800 Subject: [PATCH 06/10] =?UTF-8?q?-=20FIX=20=E6=8C=89=E7=85=A7Agent?= =?UTF-8?q?=E8=A6=81=E6=B1=82=E5=81=9A=E5=87=BA=E4=BA=86=E4=BF=AE=E6=AD=A3?= =?UTF-8?q?=20=20-=20=E7=9B=B8=E5=85=B3=E5=AD=90=E6=9F=A5=E8=AF=A2?= =?UTF-8?q?=E7=8E=B0=E6=94=B9=E6=88=90=E5=90=8C=E6=97=B6=E7=9C=8B=20fromSo?= =?UTF-8?q?urces=20=E5=92=8C=E5=90=84=E4=B8=AA=20JOIN=20...=20ON=E3=80=82?= =?UTF-8?q?=20=20-=20=E5=A4=9A=E8=A1=A8=20RIGHT/FULL=20JOIN=20=E5=9C=A8?= =?UTF-8?q?=E5=B7=A6=E4=BE=A7=E4=B8=BA=E7=A9=BA=E6=97=B6=EF=BC=8C=E8=A1=A5?= =?UTF-8?q?=E4=BA=86=E5=B7=A6=E4=BE=A7=E5=88=97=E7=9A=84=E7=A9=BA=E5=80=BC?= =?UTF-8?q?=E6=A8=A1=E6=9D=BF=E3=80=82=20=20=20-=20ALTER=20TABLE=20?= =?UTF-8?q?=E8=A7=A3=E6=9E=90=E7=8E=B0=E4=BC=9A=E6=8B=92=E7=BB=9D=E5=B0=BE?= =?UTF-8?q?=E9=83=A8=E5=A4=9A=E4=BD=99=20token=EF=BC=8C=E5=B9=B6=E4=B8=94?= =?UTF-8?q?=E4=B8=8D=E5=86=8D=E5=90=9E=E6=8E=89=E6=9C=AA=E7=9F=A5=E5=88=97?= =?UTF-8?q?=E6=AE=B5=E5=86=85=E5=AE=B9=E3=80=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- controller/nest_query.cpp | 25 ++++++++++++----- tests/test_logic.cpp | 16 +++++++++++ tests/test_parser_dispatcher.cpp | 6 +++++ utils/logic/logic_parser.cpp | 45 ++++++++++++++++++++++++++----- utils/sql_parser/table_parser.cpp | 29 +++++++++++++++++++- 5 files changed, 108 insertions(+), 13 deletions(-) diff --git a/controller/nest_query.cpp b/controller/nest_query.cpp index d9c417f..b9c6014 100644 --- a/controller/nest_query.cpp +++ b/controller/nest_query.cpp @@ -192,6 +192,16 @@ logic::LogicCellValue nullCell(tabledef::ColumnType type) return logic::LogicCellValue{QString(), type, true}; } +logic::LogicRowContext buildNullContextFromSample(const logic::LogicRowContext &sample) +{ + logic::LogicRowContext nullContext; + nullContext.tableName = sample.tableName; + for (auto it = sample.cellsByName.cbegin(); it != sample.cellsByName.cend(); ++it) { + nullContext.cellsByName.insert(it.key(), nullCell(it.value().type)); + } + return nullContext; +} + bool loadSourcesFromPayload(const QVariantMap &payload, QList *sources, QString *error) @@ -518,6 +528,7 @@ QVector joinRowsets(const QVector output; @@ -549,12 +560,9 @@ QVector joinRowsets(const QVector(); } + logic::LogicRowContext nullTemplate = buildNullSourceContext(sources.first()); QVector joinedRows = rowsForSource(sources.first()); const QVariantList joinPayload = parsed.payload.value(QStringLiteral("joins")).toList(); if (joinPayload.isEmpty()) { @@ -746,6 +755,7 @@ QueryExecuteResult execMultiTableSelect(QueryExecutor *executor, nullptr, bindings, evalContext, + &nullTemplate, &error); if (!error.isEmpty()) { result.success = false; @@ -753,6 +763,7 @@ QueryExecuteResult execMultiTableSelect(QueryExecutor *executor, result.text = error; return result; } + nullTemplate = mergeRowContexts(nullTemplate, buildNullSourceContext(sources.at(sourceIndex))); } } else { for (const QVariant &joinValue : joinPayload) { @@ -771,6 +782,7 @@ QueryExecuteResult execMultiTableSelect(QueryExecutor *executor, &onAst, bindings, evalContext, + &nullTemplate, &error); if (!error.isEmpty()) { result.success = false; @@ -778,6 +790,7 @@ QueryExecuteResult execMultiTableSelect(QueryExecutor *executor, result.text = error; return result; } + nullTemplate = mergeRowContexts(nullTemplate, buildNullSourceContext(sources.at(rightIndex))); } } diff --git a/tests/test_logic.cpp b/tests/test_logic.cpp index 992032a..7e14d03 100644 --- a/tests/test_logic.cpp +++ b/tests/test_logic.cpp @@ -219,6 +219,22 @@ private slots: QCOMPARE(parsed.root.referencedOuterNames, QStringList({QStringLiteral("p.id")})); } + void test_parseCorrelatedSubqueryCollectsMultiTableOuterReferences() + { + const QString expression = QStringLiteral( + "EXISTS (SELECT a.id FROM a JOIN b ON a.bid = outer.bid WHERE b.name = outer.name)"); + + const logic::LogicTokenizeResult tokenized = logic::tokenizeLogicExpression(expression); + QVERIFY2(tokenized.success, qPrintable(tokenized.error.message)); + + const logic::LogicParseResult parsed = logic::parseLogicTokens(expression, tokenized.tokens); + QVERIFY2(parsed.success, qPrintable(parsed.error.message)); + QCOMPARE(parsed.root.type, logic::LogicNodeType::ExistsSubquery); + QCOMPARE(parsed.root.referencedOuterNames.size(), 2); + QVERIFY(parsed.root.referencedOuterNames.contains(QStringLiteral("outer.bid"))); + QVERIFY(parsed.root.referencedOuterNames.contains(QStringLiteral("outer.name"))); + } + void test_buildCorrelationBindingsExtractsTypedOuterValues() { const logic::LogicRowContext outerRowContext = makeOuterRowContext(); diff --git a/tests/test_parser_dispatcher.cpp b/tests/test_parser_dispatcher.cpp index 2a74541..445f967 100644 --- a/tests/test_parser_dispatcher.cpp +++ b/tests/test_parser_dispatcher.cpp @@ -826,6 +826,12 @@ private slots: QStringLiteral("ALTER_COLUMN_SET_TYPE")); QCOMPARE(setType.payload.value(QStringLiteral("type")).toString(), QStringLiteral("VARCHAR")); QCOMPARE(setType.payload.value(QStringLiteral("length")).toInt(), 64); + + QVERIFY(!sqlparser::parseSql(QStringLiteral("ALTER TABLE student ALTER COLUMN age SET DEFAULT 18 garbage")).success); + QVERIFY(!sqlparser::parseSql(QStringLiteral("ALTER TABLE student ALTER COLUMN age DROP DEFAULT garbage")).success); + QVERIFY(!sqlparser::parseSql(QStringLiteral("ALTER TABLE student ALTER COLUMN age TYPE VARCHAR(64) garbage")).success); + QVERIFY(!sqlparser::parseSql(QStringLiteral("ALTER TABLE student RENAME COLUMN old_name TO new_name extra")).success); + QVERIFY(!sqlparser::parseSql(QStringLiteral("ALTER TABLE student DROP COLUMN age extra")).success); } void test_parseAlterForeignKeyAndMultiColumnIndexPayload() diff --git a/utils/logic/logic_parser.cpp b/utils/logic/logic_parser.cpp index 66afe2c..307b4cb 100644 --- a/utils/logic/logic_parser.cpp +++ b/utils/logic/logic_parser.cpp @@ -47,6 +47,28 @@ bool appendOuterName(QStringList *names, const QString &name) return true; } +void appendLocalPrefixesFromSource(const QVariantMap &source, QStringList *localPrefixes) +{ + if (localPrefixes == nullptr) { + return; + } + + const QString tableName = source.value(QStringLiteral("tableName")).toString().trimmed(); + const QString tableAlias = source.value(QStringLiteral("tableAlias")).toString().trimmed(); + if (!tableName.isEmpty()) { + const QString prefix = tableName + QLatin1Char('.'); + if (!localPrefixes->contains(prefix)) { + localPrefixes->append(prefix); + } + } + if (!tableAlias.isEmpty()) { + const QString prefix = tableAlias + QLatin1Char('.'); + if (!localPrefixes->contains(prefix)) { + localPrefixes->append(prefix); + } + } +} + bool collectOuterNamesFromNode(const LogicNode &node, const QStringList &localPrefixes, QStringList *names, @@ -141,13 +163,12 @@ bool collectOuterNamesFromText(const QString &text, QStringList *names, LogicErr } QStringList localPrefixes; - const QString tableName = parsedSql.payload.value(QStringLiteral("tableName")).toString().trimmed(); - const QString tableAlias = parsedSql.payload.value(QStringLiteral("tableAlias")).toString().trimmed(); - if (!tableName.isEmpty()) { - localPrefixes.append(tableName + QLatin1Char('.')); + const QVariantList sources = parsedSql.payload.value(QStringLiteral("fromSources")).toList(); + for (const QVariant &sourceValue : sources) { + appendLocalPrefixesFromSource(sourceValue.toMap(), &localPrefixes); } - if (!tableAlias.isEmpty()) { - localPrefixes.append(tableAlias + QLatin1Char('.')); + if (localPrefixes.isEmpty()) { + appendLocalPrefixesFromSource(parsedSql.payload, &localPrefixes); } if (parsedSql.payload.contains(QStringLiteral("whereAst"))) { @@ -157,6 +178,18 @@ bool collectOuterNamesFromText(const QString &text, QStringList *names, LogicErr } } + const QVariantList joins = parsedSql.payload.value(QStringLiteral("joins")).toList(); + for (const QVariant &joinValue : joins) { + const QVariantMap joinMap = joinValue.toMap(); + if (!joinMap.contains(QStringLiteral("onAst"))) { + continue; + } + const LogicNode onAst = joinMap.value(QStringLiteral("onAst")).value(); + if (!collectOuterNamesFromNode(onAst, localPrefixes, names, error, text)) { + return false; + } + } + return true; } diff --git a/utils/sql_parser/table_parser.cpp b/utils/sql_parser/table_parser.cpp index f7004c2..d3da2d7 100644 --- a/utils/sql_parser/table_parser.cpp +++ b/utils/sql_parser/table_parser.cpp @@ -263,7 +263,8 @@ bool parseColumnSegment(const QVector &tokens, result.insert(QStringLiteral("onUpdateAction"), onUpdateAction); break; } else { - ++i; + if (error != nullptr) *error = QStringLiteral("CREATE TABLE: unsupported column token '%1'").arg(tokens[i].lexeme); + return false; } } @@ -520,6 +521,9 @@ ParseResult parseTableSql(const QString& sql, const QVector& tokens) if (operationIndex + 2 > end) { return {false, "ALTER TABLE ALTER COLUMN SET DEFAULT: expected value", cmdType, {}}; } + if (operationIndex + 3 <= end) { + return {false, "ALTER TABLE ALTER COLUMN SET DEFAULT: unsupported trailing syntax", cmdType, {}}; + } payload.insert(QStringLiteral("alterAction"), QStringLiteral("ALTER_COLUMN_SET_DEFAULT")); payload.insert(QStringLiteral("columnName"), columnName); payload.insert(QStringLiteral("defaultValue"), tokens[operationIndex + 2].lexeme); @@ -528,6 +532,9 @@ ParseResult parseTableSql(const QString& sql, const QVector& tokens) if (lexemeIs(tokens, operationIndex + 1, QStringLiteral("NOT")) && operationIndex + 2 <= end && lexemeIs(tokens, operationIndex + 2, QStringLiteral("NULL"))) { + if (operationIndex + 3 <= end) { + return {false, "ALTER TABLE ALTER COLUMN SET NOT NULL: unsupported trailing syntax", cmdType, {}}; + } payload.insert(QStringLiteral("alterAction"), QStringLiteral("ALTER_COLUMN_SET_NOT_NULL")); payload.insert(QStringLiteral("columnName"), columnName); return {true, "", cmdType, payload}; @@ -540,6 +547,9 @@ ParseResult parseTableSql(const QString& sql, const QVector& tokens) return {false, "ALTER TABLE ALTER COLUMN DROP: expected operation", cmdType, {}}; } if (lexemeIs(tokens, operationIndex + 1, QStringLiteral("DEFAULT"))) { + if (operationIndex + 2 <= end) { + return {false, "ALTER TABLE ALTER COLUMN DROP DEFAULT: unsupported trailing syntax", cmdType, {}}; + } payload.insert(QStringLiteral("alterAction"), QStringLiteral("ALTER_COLUMN_DROP_DEFAULT")); payload.insert(QStringLiteral("columnName"), columnName); return {true, "", cmdType, payload}; @@ -547,6 +557,9 @@ ParseResult parseTableSql(const QString& sql, const QVector& tokens) if (lexemeIs(tokens, operationIndex + 1, QStringLiteral("NOT")) && operationIndex + 2 <= end && lexemeIs(tokens, operationIndex + 2, QStringLiteral("NULL"))) { + if (operationIndex + 3 <= end) { + return {false, "ALTER TABLE ALTER COLUMN DROP NOT NULL: unsupported trailing syntax", cmdType, {}}; + } payload.insert(QStringLiteral("alterAction"), QStringLiteral("ALTER_COLUMN_DROP_NOT_NULL")); payload.insert(QStringLiteral("columnName"), columnName); return {true, "", cmdType, payload}; @@ -566,9 +579,14 @@ ParseResult parseTableSql(const QString& sql, const QVector& tokens) if (right < 0) { return {false, "ALTER TABLE ALTER COLUMN TYPE: unmatched type length parenthesis", cmdType, {}}; } + if (right < end) { + return {false, "ALTER TABLE ALTER COLUMN TYPE: unsupported trailing syntax", cmdType, {}}; + } if (operationIndex + 3 < right && tokens[operationIndex + 3].type == TokenType::INTEGER_LIT) { payload.insert(QStringLiteral("length"), tokens[operationIndex + 3].lexeme.toInt()); } + } else if (operationIndex + 2 <= end) { + return {false, "ALTER TABLE ALTER COLUMN TYPE: unsupported trailing syntax", cmdType, {}}; } return {true, "", cmdType, payload}; } @@ -618,6 +636,9 @@ ParseResult parseTableSql(const QString& sql, const QVector& tokens) || tokens[actionIndex + 4].type != TokenType::IDENTIFIER) { return {false, "ALTER TABLE RENAME COLUMN: expected old column name TO new column name", cmdType, {}}; } + if (actionIndex + 5 <= end) { + return {false, "ALTER TABLE RENAME COLUMN: unsupported trailing syntax", cmdType, {}}; + } payload.insert(QStringLiteral("alterAction"), QStringLiteral("RENAME_COLUMN")); payload.insert(QStringLiteral("columnName"), tokens[actionIndex + 2].lexeme); payload.insert(QStringLiteral("newColumnName"), tokens[actionIndex + 4].lexeme); @@ -626,11 +647,17 @@ ParseResult parseTableSql(const QString& sql, const QVector& tokens) if (lexemeIs(tokens, actionIndex, QStringLiteral("DROP"))) { if (lexemeIs(tokens, actionIndex + 1, QStringLiteral("COLUMN"))) { + if (actionIndex + 3 <= end) { + return {false, "ALTER TABLE DROP COLUMN: unsupported trailing syntax", cmdType, {}}; + } payload.insert(QStringLiteral("alterAction"), QStringLiteral("DROP_COLUMN")); if (actionIndex + 2 < tokens.size()) payload.insert(QStringLiteral("columnName"), tokens[actionIndex + 2].lexeme); return {true, "", cmdType, payload}; } if (lexemeIs(tokens, actionIndex + 1, QStringLiteral("CONSTRAINT"))) { + if (actionIndex + 3 <= end) { + return {false, "ALTER TABLE DROP CONSTRAINT: unsupported trailing syntax", cmdType, {}}; + } payload.insert(QStringLiteral("alterAction"), QStringLiteral("DROP_CONSTRAINT")); if (actionIndex + 2 < tokens.size()) payload.insert(QStringLiteral("constraintName"), tokens[actionIndex + 2].lexeme); return {true, "", cmdType, payload}; From 34d3ff2345530419e23921eddc4feba8a54ce271 Mon Sep 17 00:00:00 2001 From: chester Date: Sun, 17 May 2026 12:32:56 +0800 Subject: [PATCH 07/10] =?UTF-8?q?-=20FIX=20=E6=8C=89=E7=85=A7=20=E5=AE=A1?= =?UTF-8?q?=E6=9F=A5Agent=20=E5=AE=A1=E6=A0=B8=E7=BB=93=E6=9E=9C=E5=81=9A?= =?UTF-8?q?=E5=87=BA=E8=B0=83=E6=95=B4=EF=BC=9A=20=20=20-=20=E9=81=BF?= =?UTF-8?q?=E5=85=8D=E4=BA=86=20self=20join=20=E4=B8=AD=E8=A1=A8=E5=90=8D?= =?UTF-8?q?=E9=99=90=E5=AE=9A=E5=88=97=E9=9D=99=E9=BB=98=E9=94=99=E7=BB=91?= =?UTF-8?q?=EF=BC=9B=20=20=20-=20=E6=8B=92=E7=BB=9D=20JOIN=20ON=20?= =?UTF-8?q?=E5=90=8E=E6=8B=BC=E6=8E=A5=E9=9D=9E=E6=B3=95=20FROM=20?= =?UTF-8?q?=E9=A1=B9=EF=BC=9B=20=20=20-=20=E4=B8=A5=E6=A0=BC=E6=A0=A1?= =?UTF-8?q?=E9=AA=8C=20ALTER=20COLUMN=20TYPE=20=E9=95=BF=E5=BA=A6=E8=AF=AD?= =?UTF-8?q?=E6=B3=95=EF=BC=9B=20=20=20-=20=E8=A1=A5=E5=85=85=E5=AE=A1?= =?UTF-8?q?=E6=9F=A5=E8=BE=B9=E7=95=8C=E5=9B=9E=E5=BD=92=E7=94=A8=E4=BE=8B?= =?UTF-8?q?=E3=80=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- controller/nest_query.cpp | 10 +++++++++- tests/test_parser_dispatcher.cpp | 8 ++++++++ utils/sql_parser/table_parser.cpp | 6 ++++-- utils/sql_parser/tuple_parser.cpp | 15 ++++++++++++++- 4 files changed, 35 insertions(+), 4 deletions(-) diff --git a/controller/nest_query.cpp b/controller/nest_query.cpp index b9c6014..92e2963 100644 --- a/controller/nest_query.cpp +++ b/controller/nest_query.cpp @@ -272,9 +272,15 @@ bool buildMultiNameResolution(const QList &sources, QMap firstBareColumnKey; QSet prefixes; QSet unaliasedTables; + QMap tableNameCounts; + + for (const SelectTableSource &source : sources) { + tableNameCounts[source.tableName.trimmed()] += 1; + } for (const SelectTableSource &source : sources) { const QString prefix = canonicalPrefix(source); + const bool tableNameIsUnique = tableNameCounts.value(source.tableName.trimmed()) == 1; if (prefix.isEmpty()) { if (error != nullptr) { *error = QStringLiteral("SELECT: expected table name"); @@ -305,7 +311,9 @@ bool buildMultiNameResolution(const QList &sources, resolution->starOutputColumns.append(key); resolution->visibleNameToKey.insert(prefix + QLatin1Char('.') + column.name, key); - resolution->visibleNameToKey.insert(source.tableName + QLatin1Char('.') + column.name, key); + if (tableNameIsUnique) { + resolution->visibleNameToKey.insert(source.tableName + QLatin1Char('.') + column.name, key); + } if (!source.tableAlias.trimmed().isEmpty()) { resolution->visibleNameToKey.insert(source.tableAlias + QLatin1Char('.') + column.name, key); } diff --git a/tests/test_parser_dispatcher.cpp b/tests/test_parser_dispatcher.cpp index 445f967..1305aa1 100644 --- a/tests/test_parser_dispatcher.cpp +++ b/tests/test_parser_dispatcher.cpp @@ -305,6 +305,7 @@ private slots: QVERIFY(!sqlparser::parseSql(QStringLiteral("SELECT * FROM a JOIN b")).success); QVERIFY(!sqlparser::parseSql(QStringLiteral("SELECT * FROM a NATURAL JOIN b")).success); + QVERIFY(!sqlparser::parseSql(QStringLiteral("SELECT * FROM a JOIN b ON a.id = b.id, c")).success); QVERIFY(!sqlparser::parseSql(QStringLiteral("SELECT * FROM a, b JOIN c ON b.id = c.id")).success); } @@ -598,6 +599,12 @@ private slots: QStringLiteral("SELECT id FROM student_multi s JOIN class_multi c ON s.class_id = c.id")); QVERIFY(!result.success); QVERIFY(result.errorMessage.contains(QStringLiteral("ambiguous column"))); + + result = dispatcher.execute( + QStringLiteral("SELECT student_multi.id FROM student_multi x " + "JOIN student_multi y ON x.id = y.id")); + QVERIFY(!result.success); + QVERIFY(result.errorMessage.contains(QStringLiteral("unknown table or alias"))); } void test_dispatchUpdateAndDeleteKeepQualifiedWhereOnAstPath() @@ -830,6 +837,7 @@ private slots: QVERIFY(!sqlparser::parseSql(QStringLiteral("ALTER TABLE student ALTER COLUMN age SET DEFAULT 18 garbage")).success); QVERIFY(!sqlparser::parseSql(QStringLiteral("ALTER TABLE student ALTER COLUMN age DROP DEFAULT garbage")).success); QVERIFY(!sqlparser::parseSql(QStringLiteral("ALTER TABLE student ALTER COLUMN age TYPE VARCHAR(64) garbage")).success); + QVERIFY(!sqlparser::parseSql(QStringLiteral("ALTER TABLE student ALTER COLUMN age TYPE VARCHAR(64, 2)")).success); QVERIFY(!sqlparser::parseSql(QStringLiteral("ALTER TABLE student RENAME COLUMN old_name TO new_name extra")).success); QVERIFY(!sqlparser::parseSql(QStringLiteral("ALTER TABLE student DROP COLUMN age extra")).success); } diff --git a/utils/sql_parser/table_parser.cpp b/utils/sql_parser/table_parser.cpp index d3da2d7..84c60a5 100644 --- a/utils/sql_parser/table_parser.cpp +++ b/utils/sql_parser/table_parser.cpp @@ -582,9 +582,11 @@ ParseResult parseTableSql(const QString& sql, const QVector& tokens) if (right < end) { return {false, "ALTER TABLE ALTER COLUMN TYPE: unsupported trailing syntax", cmdType, {}}; } - if (operationIndex + 3 < right && tokens[operationIndex + 3].type == TokenType::INTEGER_LIT) { - payload.insert(QStringLiteral("length"), tokens[operationIndex + 3].lexeme.toInt()); + if (right != operationIndex + 4 + || tokens[operationIndex + 3].type != TokenType::INTEGER_LIT) { + return {false, "ALTER TABLE ALTER COLUMN TYPE: expected single integer length", cmdType, {}}; } + payload.insert(QStringLiteral("length"), tokens[operationIndex + 3].lexeme.toInt()); } else if (operationIndex + 2 <= end) { return {false, "ALTER TABLE ALTER COLUMN TYPE: unsupported trailing syntax", cmdType, {}}; } diff --git a/utils/sql_parser/tuple_parser.cpp b/utils/sql_parser/tuple_parser.cpp index d5fce4c..2b8f358 100644 --- a/utils/sql_parser/tuple_parser.cpp +++ b/utils/sql_parser/tuple_parser.cpp @@ -217,6 +217,13 @@ static bool isFromItemTerminator(TokenType type) || type == TokenType::ON; } +static bool isOnExpressionTerminator(TokenType type) +{ + return isClauseTerminator(type) + || type == TokenType::COMMA + || isJoinStart(type); +} + static bool isAliasForbidden(TokenType type) { return isFromItemTerminator(type) @@ -505,9 +512,15 @@ static bool parseFromClause(const QString &sql, const int onStart = index + 1; int onEndExclusive = onStart; - while (onEndExclusive < endExclusive && !isJoinStart(tokens[onEndExclusive].type)) { + while (onEndExclusive < endExclusive && !isOnExpressionTerminator(tokens[onEndExclusive].type)) { ++onEndExclusive; } + if (onEndExclusive < endExclusive && tokens[onEndExclusive].type == TokenType::COMMA) { + if (error != nullptr) { + *error = QStringLiteral("SELECT: cannot mix comma FROM and JOIN in the same FROM clause"); + } + return false; + } logic::LogicNode onAst; QString onError; if (!parseLogicAstFromTokenRange(sql, tokens, onStart, onEndExclusive - 1, &onAst, &onError)) { From 1a34234e627629f68da74a976283e7963b03801e Mon Sep 17 00:00:00 2001 From: chester Date: Sun, 17 May 2026 12:56:45 +0800 Subject: [PATCH 08/10] =?UTF-8?q?-=20FIX=20=E6=8C=89=E7=85=A7=E5=AE=A1?= =?UTF-8?q?=E6=9F=A5Agent=E8=A6=81=E6=B1=82=E7=BB=A7=E7=BB=AD=E6=94=B6?= =?UTF-8?q?=E7=B4=A7=E5=A4=9A=E8=A1=A8=20JOIN=EF=BC=9A=20=20=20-=20self=20?= =?UTF-8?q?join=20=E5=8F=AA=E5=85=81=E8=AE=B8=E9=80=9A=E8=BF=87=E5=88=AB?= =?UTF-8?q?=E5=90=8D=E8=AE=BF=E9=97=AE=E9=87=8D=E5=A4=8D=E8=A1=A8=EF=BC=8C?= =?UTF-8?q?=E9=81=BF=E5=85=8D=E8=A1=A8=E5=90=8D=E9=99=90=E5=AE=9A=E5=88=97?= =?UTF-8?q?=E9=94=99=E7=BB=91=EF=BC=9B=20=20=20-=20alias=20=E4=B8=8D?= =?UTF-8?q?=E5=85=81=E8=AE=B8=E6=92=9E=E5=90=8C=E4=B8=80=20FROM=20?= =?UTF-8?q?=E4=BD=9C=E7=94=A8=E5=9F=9F=E5=86=85=E7=9A=84=E7=9C=9F=E5=AE=9E?= =?UTF-8?q?=E8=A1=A8=E5=90=8D=EF=BC=9B=20=20=20-=20SELECT=20=E6=A0=87?= =?UTF-8?q?=E8=AF=86=E7=AC=A6=E8=A7=84=E5=88=99=E4=B8=8E=20DDL=20=E4=BF=9D?= =?UTF-8?q?=E6=8C=81=E4=B8=80=E8=87=B4=EF=BC=8C=E4=B8=8D=E5=86=8D=E6=8E=A5?= =?UTF-8?q?=E5=8F=97=E7=B1=BB=E5=9E=8B=E5=85=B3=E9=94=AE=E5=AD=97=EF=BC=9B?= =?UTF-8?q?=20=20=20-=20JOIN=20ON=20=E5=90=8E=E7=9A=84=E9=9D=9E=E6=B3=95?= =?UTF-8?q?=20FROM=20=E9=A1=B9=E4=BC=9A=E8=A2=AB=E6=98=8E=E7=A1=AE?= =?UTF-8?q?=E6=8B=92=E7=BB=9D=EF=BC=9B=20=20=20-=20ALTER=20COLUMN=20TYPE?= =?UTF-8?q?=20=E9=95=BF=E5=BA=A6=E5=8F=82=E6=95=B0=E6=94=B9=E4=B8=BA?= =?UTF-8?q?=E4=B8=A5=E6=A0=BC=E5=8D=95=E6=95=B4=E6=95=B0=E6=A0=A1=E9=AA=8C?= =?UTF-8?q?=EF=BC=9B=20=20=20-=20=E8=A1=A5=E5=85=85=E5=AF=B9=E5=BA=94?= =?UTF-8?q?=E5=9B=9E=E5=BD=92=E7=94=A8=E4=BE=8B=E3=80=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- controller/nest_query.cpp | 104 +++++++++++++++++++++--------- tests/test_parser_dispatcher.cpp | 13 +++- utils/sql_parser/tuple_parser.cpp | 57 +++++++++++----- 3 files changed, 128 insertions(+), 46 deletions(-) diff --git a/controller/nest_query.cpp b/controller/nest_query.cpp index 92e2963..6a08acf 100644 --- a/controller/nest_query.cpp +++ b/controller/nest_query.cpp @@ -40,6 +40,7 @@ struct SelectTableSource QString tableAlias; tabledef::TableSchema schema; repo::TableData data; + bool tableNameQualifierVisible = true; }; struct MultiProjectionItem @@ -250,12 +251,72 @@ bool loadSourcesFromPayload(const QVariantMap &payload, } if (sources != nullptr) { - sources->append(SelectTableSource{tableName, tableAlias, schema, rows.resultTable}); + sources->append(SelectTableSource{tableName, tableAlias, schema, rows.resultTable, true}); } } return true; } +bool validateAndAnnotateSourceNames(QList *sources, QString *error) +{ + if (sources == nullptr) { + return true; + } + + QMap tableNameCounts; + for (const SelectTableSource &source : *sources) { + tableNameCounts[source.tableName.trimmed()] += 1; + } + + QSet reservedUniqueTablePrefixes; + QSet aliases; + QSet unaliasedTables; + for (SelectTableSource &source : *sources) { + const QString tableName = source.tableName.trimmed(); + const QString alias = source.tableAlias.trimmed(); + if (tableName.isEmpty()) { + if (error != nullptr) { + *error = QStringLiteral("SELECT: expected table name"); + } + return false; + } + if (alias.isEmpty()) { + if (unaliasedTables.contains(tableName) || tableNameCounts.value(tableName) > 1) { + if (error != nullptr) { + *error = QStringLiteral("SELECT: duplicate table '%1' requires aliases").arg(tableName); + } + return false; + } + unaliasedTables.insert(tableName); + } + + if (tableNameCounts.value(tableName) == 1) { + if (reservedUniqueTablePrefixes.contains(tableName) || aliases.contains(tableName)) { + if (error != nullptr) { + *error = QStringLiteral("SELECT: duplicate table qualifier '%1'").arg(tableName); + } + return false; + } + reservedUniqueTablePrefixes.insert(tableName); + } + + if (!alias.isEmpty()) { + if (aliases.contains(alias) + || reservedUniqueTablePrefixes.contains(alias) + || tableNameCounts.contains(alias)) { + if (error != nullptr) { + *error = QStringLiteral("SELECT: duplicate table qualifier '%1'").arg(alias); + } + return false; + } + aliases.insert(alias); + } + + source.tableNameQualifierVisible = tableNameCounts.value(tableName) == 1; + } + return true; +} + bool buildMultiNameResolution(const QList &sources, MultiNameResolution *resolution, QString *error) @@ -270,39 +331,14 @@ bool buildMultiNameResolution(const QList &sources, resolution->starOutputColumns.clear(); QMap firstBareColumnKey; - QSet prefixes; - QSet unaliasedTables; - QMap tableNameCounts; - - for (const SelectTableSource &source : sources) { - tableNameCounts[source.tableName.trimmed()] += 1; - } - for (const SelectTableSource &source : sources) { const QString prefix = canonicalPrefix(source); - const bool tableNameIsUnique = tableNameCounts.value(source.tableName.trimmed()) == 1; if (prefix.isEmpty()) { if (error != nullptr) { *error = QStringLiteral("SELECT: expected table name"); } return false; } - if (prefixes.contains(prefix)) { - if (error != nullptr) { - *error = QStringLiteral("SELECT: duplicate table alias '%1'").arg(prefix); - } - return false; - } - prefixes.insert(prefix); - if (source.tableAlias.trimmed().isEmpty()) { - if (unaliasedTables.contains(source.tableName)) { - if (error != nullptr) { - *error = QStringLiteral("SELECT: duplicate table '%1' requires aliases").arg(source.tableName); - } - return false; - } - unaliasedTables.insert(source.tableName); - } for (const tabledef::Column &column : source.schema.columns) { const QString key = qualifiedKey(source, column); @@ -311,7 +347,7 @@ bool buildMultiNameResolution(const QList &sources, resolution->starOutputColumns.append(key); resolution->visibleNameToKey.insert(prefix + QLatin1Char('.') + column.name, key); - if (tableNameIsUnique) { + if (source.tableNameQualifierVisible) { resolution->visibleNameToKey.insert(source.tableName + QLatin1Char('.') + column.name, key); } if (!source.tableAlias.trimmed().isEmpty()) { @@ -379,7 +415,9 @@ logic::LogicRowContext buildSourceRowContext(const SelectTableSource &source, in const QString value = columnIndex < row.size() ? row.at(columnIndex) : QString(); const logic::LogicCellValue cell{value, column.type, value.isEmpty()}; rowContext.cellsByName.insert(prefix + QLatin1Char('.') + column.name, cell); - rowContext.cellsByName.insert(source.tableName + QLatin1Char('.') + column.name, cell); + if (source.tableNameQualifierVisible) { + rowContext.cellsByName.insert(source.tableName + QLatin1Char('.') + column.name, cell); + } if (!source.tableAlias.trimmed().isEmpty()) { rowContext.cellsByName.insert(source.tableAlias + QLatin1Char('.') + column.name, cell); } @@ -396,7 +434,9 @@ logic::LogicRowContext buildNullSourceContext(const SelectTableSource &source) for (const tabledef::Column &column : source.schema.columns) { const logic::LogicCellValue cell = nullCell(column.type); rowContext.cellsByName.insert(prefix + QLatin1Char('.') + column.name, cell); - rowContext.cellsByName.insert(source.tableName + QLatin1Char('.') + column.name, cell); + if (source.tableNameQualifierVisible) { + rowContext.cellsByName.insert(source.tableName + QLatin1Char('.') + column.name, cell); + } if (!source.tableAlias.trimmed().isEmpty()) { rowContext.cellsByName.insert(source.tableAlias + QLatin1Char('.') + column.name, cell); } @@ -725,6 +765,12 @@ QueryExecuteResult execMultiTableSelect(QueryExecutor *executor, result.text = error; return result; } + if (!validateAndAnnotateSourceNames(&sources, &error)) { + result.success = false; + result.errorMessage = error; + result.text = error; + return result; + } if (sources.isEmpty()) { result.success = false; result.errorMessage = QStringLiteral("SELECT: expected table name"); diff --git a/tests/test_parser_dispatcher.cpp b/tests/test_parser_dispatcher.cpp index 1305aa1..dc0c017 100644 --- a/tests/test_parser_dispatcher.cpp +++ b/tests/test_parser_dispatcher.cpp @@ -307,6 +307,10 @@ private slots: QVERIFY(!sqlparser::parseSql(QStringLiteral("SELECT * FROM a NATURAL JOIN b")).success); QVERIFY(!sqlparser::parseSql(QStringLiteral("SELECT * FROM a JOIN b ON a.id = b.id, c")).success); QVERIFY(!sqlparser::parseSql(QStringLiteral("SELECT * FROM a, b JOIN c ON b.id = c.id")).success); + QVERIFY(!sqlparser::parseSql(QStringLiteral("SELECT INT FROM student")).success); + QVERIFY(!sqlparser::parseSql(QStringLiteral("SELECT id FROM INT")).success); + QVERIFY(!sqlparser::parseSql(QStringLiteral("SELECT id FROM student INT")).success); + QVERIFY(!sqlparser::parseSql(QStringLiteral("SELECT * FROM student s, class student")).success); } void test_parseUpdateAndDeleteSupportSimpleWhere() @@ -604,7 +608,14 @@ private slots: QStringLiteral("SELECT student_multi.id FROM student_multi x " "JOIN student_multi y ON x.id = y.id")); QVERIFY(!result.success); - QVERIFY(result.errorMessage.contains(QStringLiteral("unknown table or alias"))); + QVERIFY(result.errorMessage.contains(QStringLiteral("duplicate table qualifier")) + || result.errorMessage.contains(QStringLiteral("unknown table or alias"))); + + result = dispatcher.execute( + QStringLiteral("SELECT x.id, y.name FROM student_multi x " + "JOIN student_multi y ON x.id = y.id ORDER BY x.id ASC")); + QVERIFY2(result.success, qPrintable(result.errorMessage)); + QCOMPARE(result.selectResult.resultTable.rows.size(), 3); } void test_dispatchUpdateAndDeleteKeepQualifiedWhereOnAstPath() diff --git a/utils/sql_parser/tuple_parser.cpp b/utils/sql_parser/tuple_parser.cpp index 2b8f358..f437fe8 100644 --- a/utils/sql_parser/tuple_parser.cpp +++ b/utils/sql_parser/tuple_parser.cpp @@ -233,12 +233,7 @@ static bool isAliasForbidden(TokenType type) static bool isIdentifierLike(TokenType type) { - return type == TokenType::IDENTIFIER - || type == TokenType::INT_TYPE - || type == TokenType::FLOAT_TYPE - || type == TokenType::CHAR_TYPE - || type == TokenType::VARCHAR_TYPE - || type == TokenType::TEXT_TYPE; + return type == TokenType::IDENTIFIER; } static bool parseQualifiedIdentifier(const QVector &tokens, @@ -362,26 +357,30 @@ static QString sourceAliasOrTable(const QVariantMap &source) static bool validateSourceNames(const QVariantList &sources, QString *error) { - QSet visiblePrefixes; + QMap tableNameCounts; + QSet tableNames; + for (const QVariant &value : sources) { + const QVariantMap source = value.toMap(); + const QString tableName = source.value(QStringLiteral("tableName")).toString().trimmed(); + if (!tableName.isEmpty()) { + tableNameCounts[tableName] += 1; + tableNames.insert(tableName); + } + } + + QSet reservedUniqueTablePrefixes; + QSet aliases; QSet unaliasedTables; for (const QVariant &value : sources) { const QVariantMap source = value.toMap(); const QString tableName = source.value(QStringLiteral("tableName")).toString().trimmed(); const QString alias = source.value(QStringLiteral("tableAlias")).toString().trimmed(); - const QString prefix = alias.isEmpty() ? tableName : alias; - if (prefix.isEmpty()) { + if (tableName.isEmpty()) { if (error != nullptr) { *error = QStringLiteral("SELECT: expected table name"); } return false; } - if (visiblePrefixes.contains(prefix)) { - if (error != nullptr) { - *error = QStringLiteral("SELECT: duplicate table alias '%1'").arg(prefix); - } - return false; - } - visiblePrefixes.insert(prefix); if (alias.isEmpty()) { if (unaliasedTables.contains(tableName)) { if (error != nullptr) { @@ -389,8 +388,34 @@ static bool validateSourceNames(const QVariantList &sources, QString *error) } return false; } + if (tableNameCounts.value(tableName) > 1) { + if (error != nullptr) { + *error = QStringLiteral("SELECT: duplicate table '%1' requires aliases").arg(tableName); + } + return false; + } unaliasedTables.insert(tableName); } + if (tableNameCounts.value(tableName) == 1) { + if (reservedUniqueTablePrefixes.contains(tableName) || aliases.contains(tableName)) { + if (error != nullptr) { + *error = QStringLiteral("SELECT: duplicate table qualifier '%1'").arg(tableName); + } + return false; + } + reservedUniqueTablePrefixes.insert(tableName); + } + if (!alias.isEmpty()) { + if (aliases.contains(alias) + || reservedUniqueTablePrefixes.contains(alias) + || tableNames.contains(alias)) { + if (error != nullptr) { + *error = QStringLiteral("SELECT: duplicate table qualifier '%1'").arg(alias); + } + return false; + } + aliases.insert(alias); + } } return true; } From 36bfb362b1bc503b5ee13d5c878212d67d639667 Mon Sep 17 00:00:00 2001 From: chester Date: Sun, 17 May 2026 13:11:19 +0800 Subject: [PATCH 09/10] =?UTF-8?q?FIX=20=E6=94=B6=E7=B4=A7=20JOIN=20?= =?UTF-8?q?=E7=BB=91=E5=AE=9A=E4=B8=8E=20ALTER=20payload=20=E6=A0=A1?= =?UTF-8?q?=E9=AA=8C=20-=20JOIN=20ON=20=E8=AF=84=E4=BC=B0=E5=89=8D?= =?UTF-8?q?=E8=A1=A5=E9=BD=90=E5=94=AF=E4=B8=80=E8=A3=B8=E5=88=97=E5=90=8D?= =?UTF-8?q?=20-=20=E9=99=90=E5=AE=9A=E5=A4=96=E5=B1=82=E5=BC=95=E7=94=A8?= =?UTF-8?q?=E4=B8=8D=E5=86=8D=20fallback=20=E5=88=B0=E8=A3=B8=E5=88=97=20-?= =?UTF-8?q?=20partial=20ALTER=20=E7=BC=BA=E5=B0=91=E5=BF=85=E8=A6=81=20pay?= =?UTF-8?q?load=20=E5=AD=97=E6=AE=B5=E6=97=B6=E7=9B=B4=E6=8E=A5=E6=8B=92?= =?UTF-8?q?=E7=BB=9D=20-=20=E8=A1=A5=E5=85=85=20JOIN=20=E7=BB=91=E5=AE=9A?= =?UTF-8?q?=E5=92=8C=20ALTER=20=E7=BC=BA=E5=AD=97=E6=AE=B5=E5=9B=9E?= =?UTF-8?q?=E5=BD=92=E7=94=A8=E4=BE=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- controller/nest_query.cpp | 5 +++++ controller/sql_dispatcher.cpp | 11 ++++++++--- tests/test_logic.cpp | 12 ++++++++++++ tests/test_parser_dispatcher.cpp | 28 ++++++++++++++++++++++++++++ utils/logic/subquery_logic.cpp | 8 ++++++-- 5 files changed, 59 insertions(+), 5 deletions(-) diff --git a/controller/nest_query.cpp b/controller/nest_query.cpp index 6a08acf..85bfe4f 100644 --- a/controller/nest_query.cpp +++ b/controller/nest_query.cpp @@ -573,6 +573,7 @@ QVector rowsForSource(const SelectTableSource &source) QVector joinRowsets(const QVector &leftRows, const SelectTableSource &rightSource, const QString &joinType, + const MultiNameResolution &resolution, const logic::LogicNode *onAst, const logic::CorrelationBindings *bindings, const logic::LogicEvalContext &evalContext, @@ -588,6 +589,8 @@ QVector joinRowsets(const QVector Date: Sun, 17 May 2026 13:34:06 +0800 Subject: [PATCH 10/10] =?UTF-8?q?-=20AMEND=20=E6=8A=9B=E5=BC=83=E4=BA=86?= =?UTF-8?q?=E5=AE=B9=E6=98=93=E9=80=A0=E6=88=90=E9=97=AE=E9=A2=98=E7=9A=84?= =?UTF-8?q?=20outer=20=E7=9A=84=E6=97=A7=E7=9A=84=E5=AD=90=E6=9F=A5?= =?UTF-8?q?=E8=AF=A2=E9=80=BB=E8=BE=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ALIAS_PATCH_PLAN.md | 7 ++- FROM_MULTI_PLAN.md | 5 +- controller/nest_query.cpp | 68 +++++++++++++++++++++---- tests/TEST_PLAN.md | 6 ++- tests/test_logic.cpp | 92 ++++++++++++++++++++++------------ tests/test_query_executor.cpp | 31 +++++++----- utils/logic/logic_parser.cpp | 28 ++++++----- utils/logic/subquery_logic.cpp | 21 -------- 8 files changed, 164 insertions(+), 94 deletions(-) diff --git a/ALIAS_PATCH_PLAN.md b/ALIAS_PATCH_PLAN.md index 3d1db3c..6f489cd 100644 --- a/ALIAS_PATCH_PLAN.md +++ b/ALIAS_PATCH_PLAN.md @@ -1,4 +1,4 @@ -# ALIAS_PATCH_PLAN +# ALIAS_PATCH_PLAN 目标:在不扩展 SELECT 查询能力边界的前提下,为现有单表 SELECT 能力补齐表别名、限定列名、投影别名和相关子查询外层别名引用。 @@ -13,9 +13,11 @@ 1. parser 输出单表 `tableAlias`、`projectionItems`、限定列名和投影别名 payload。 2. dispatcher 将投影列、限定列名、`ORDER BY` 列名和投影别名归一化为真实列名后下推 service。 3. 查询执行器为本地行上下文补充裸列名、表名限定列和表别名限定列。 -4. 相关子查询支持外层表别名引用,例如 `p.id`,并保留旧 `outer.id` 兼容路径。 +4. 相关子查询支持外层表别名引用,例如 `p.id`。 5. 本地作用域优先于外层作用域,外层 binding merge 不覆盖本地同名限定列。 +后续收口更新:`outer` 不再是特殊相关引用前缀;它只按普通表名/表别名限定符处理,相关子查询必须使用外层表名或表别名限定引用。 + 最近验证: ```powershell @@ -929,3 +931,4 @@ SELECT * FROM a JOIN b ON a.id = b.id; 3. 旧 `SELECT id FROM t ORDER BY id DESC`。 4. 旧 `outer.id` 相关子查询。 5. 索引排序专项压测。 + diff --git a/FROM_MULTI_PLAN.md b/FROM_MULTI_PLAN.md index a87601d..e9a2367 100644 --- a/FROM_MULTI_PLAN.md +++ b/FROM_MULTI_PLAN.md @@ -1,7 +1,9 @@ -# FROM_MULTI_PLAN +# FROM_MULTI_PLAN 目标:在现有单表 `SELECT`、表别名、限定列名、投影别名和相关子查询别名绑定能力基础上,补齐多表 `FROM` 查询能力。 +当前收口更新:`outer` 不再是特殊相关引用前缀;它只按普通表名/表别名限定符处理,所有外层引用必须使用外层表名或表别名限定列名。 + 本文同时规划两种语法: 1. 逗号多表: @@ -1341,3 +1343,4 @@ SELECT * FROM a, a; 3. 单表投影别名 `SELECT id AS sid FROM student ORDER BY sid DESC`。 4. 旧 `outer.id` 相关子查询。 5. 现有索引排序专项压测。 + diff --git a/controller/nest_query.cpp b/controller/nest_query.cpp index 85bfe4f..c56b87a 100644 --- a/controller/nest_query.cpp +++ b/controller/nest_query.cpp @@ -1,6 +1,7 @@ #include "nest_query.h" #include "../utils/logic/logic.h" +#include "../utils/logic/logic_ast.h" #include "../utils/service_common/service_common.h" #include @@ -88,19 +89,68 @@ bool simpleConditionsFromPayload(const QVariantList &conditionsPayload, return true; } -QStringList requiredOuterReferences(const logic::LogicNode &node) +void appendRequiredReference(QStringList *names, const QString &name) +{ + if (names == nullptr || name.trimmed().isEmpty() || names->contains(name)) { + return; + } + names->append(name); +} + +QStringList selectLocalPrefixes(const QVariantMap &payload) +{ + QStringList prefixes; + auto appendSource = [&](const QVariantMap &source) { + const QString tableName = source.value(QStringLiteral("tableName")).toString().trimmed(); + const QString tableAlias = source.value(QStringLiteral("tableAlias")).toString().trimmed(); + if (!tableName.isEmpty() && !prefixes.contains(tableName + QLatin1Char('.'))) { + prefixes.append(tableName + QLatin1Char('.')); + } + if (!tableAlias.isEmpty() && !prefixes.contains(tableAlias + QLatin1Char('.'))) { + prefixes.append(tableAlias + QLatin1Char('.')); + } + }; + + const QVariantList sources = payload.value(QStringLiteral("fromSources")).toList(); + if (!sources.isEmpty()) { + for (const QVariant &sourceValue : sources) { + appendSource(sourceValue.toMap()); + } + return prefixes; + } + + appendSource(payload); + return prefixes; +} + +bool isLocalQualifiedReference(const QString &name, const QStringList &localPrefixes) +{ + for (const QString &prefix : localPrefixes) { + if (!prefix.isEmpty() && name.startsWith(prefix)) { + return true; + } + } + return false; +} + +QStringList requiredOuterReferences(const logic::LogicNode &node, const QStringList &localPrefixes) { QStringList names; - if (node.type == logic::LogicNodeType::ColumnRef - && node.reference.scope == logic::LogicReferenceScope::Outer) { - names.append(node.reference.name); + if (logic::isSubqueryNodeType(node.type)) { + return names; + } + if (node.type == logic::LogicNodeType::ColumnRef) { + if (node.reference.scope == logic::LogicReferenceScope::Outer) { + appendRequiredReference(&names, node.reference.name); + } else if (node.reference.name.contains(QLatin1Char('.')) + && !isLocalQualifiedReference(node.reference.name, localPrefixes)) { + appendRequiredReference(&names, node.reference.name); + } } for (const logic::LogicNode &child : node.children) { - const QStringList childNames = requiredOuterReferences(child); + const QStringList childNames = requiredOuterReferences(child, localPrefixes); for (const QString &name : childNames) { - if (!names.contains(name)) { - names.append(name); - } + appendRequiredReference(&names, name); } } return names; @@ -1079,7 +1129,7 @@ QueryExecuteResult QueryExecutor::execSelect(const sqlparser::ParseResult &parse } if (bindings != nullptr && hasWhereAst) { - const QStringList requiredReferences = requiredOuterReferences(whereAst); + const QStringList requiredReferences = requiredOuterReferences(whereAst, selectLocalPrefixes(parsed.payload)); for (const QString &requiredReference : requiredReferences) { const bool hasBinding = std::any_of(bindings->items.cbegin(), bindings->items.cend(), [&](const logic::CorrelatedBinding &binding) { return binding.name == requiredReference; diff --git a/tests/TEST_PLAN.md b/tests/TEST_PLAN.md index 2eb2be5..3fea66e 100644 --- a/tests/TEST_PLAN.md +++ b/tests/TEST_PLAN.md @@ -1,4 +1,4 @@ -# DBMS 测试计划 +# DBMS 测试计划 ## 1. 测试计划 @@ -178,7 +178,7 @@ | 测试输入 | 预期输出 | 通过真值 | | --- | --- | --- | -| `EXISTS`、`IN`、`ANY`、`ALL`、相关子查询、缺失绑定、外层表别名引用。 | 子查询按外层行逐行求值;支持 `p.id` 这类外层别名绑定并兼容旧 `outer.id`;非法绑定返回错误。 | 子查询布尔结果、错误信息和最终查询结果符合断言。 | +| `EXISTS`、`IN`、`ANY`、`ALL`、相关子查询、缺失绑定、外层表别名引用。 | 子查询按外层行逐行求值;支持 `p.id` 这类外层别名绑定;`outer` 不再是特殊前缀,仅可作为普通表别名使用;非法绑定返回错误。 | 子查询布尔结果、错误信息和最终查询结果符合断言。 | ### 2.11 索引创建、删除与查询维护 @@ -583,3 +583,5 @@ $env:DBMS_STRESS_ROW_COUNTS='50,100,200,500' ```powershell .\build\codex-vs-debug\Debug\DBMS.exe --run-tests --skip-stress-tests ``` + + diff --git a/tests/test_logic.cpp b/tests/test_logic.cpp index 22ef2c7..e01e71b 100644 --- a/tests/test_logic.cpp +++ b/tests/test_logic.cpp @@ -1,4 +1,4 @@ -#include "../controller/nest_query.h" +#include "../controller/nest_query.h" #include "../service/service.h" #include "../utils/logic/logic.h" @@ -17,8 +17,12 @@ logic::LogicRowContext makeOuterRowContext() rowContext.tableName = QStringLiteral("parent"); rowContext.cellsByName.insert(QStringLiteral("id"), logic::LogicCellValue{QStringLiteral("10"), tabledef::ColumnType::Int, false}); + rowContext.cellsByName.insert(QStringLiteral("parent.id"), + logic::LogicCellValue{QStringLiteral("10"), tabledef::ColumnType::Int, false}); rowContext.cellsByName.insert(QStringLiteral("name"), logic::LogicCellValue{QStringLiteral("alice"), tabledef::ColumnType::Varchar, false}); + rowContext.cellsByName.insert(QStringLiteral("parent.name"), + logic::LogicCellValue{QStringLiteral("alice"), tabledef::ColumnType::Varchar, false}); return rowContext; } @@ -153,7 +157,7 @@ private slots: void test_parseCorrelatedExistsCollectsOuterReferences() { const QString expression = QStringLiteral( - "EXISTS (SELECT id FROM child WHERE child.parent_id = outer.id)"); + "EXISTS (SELECT id FROM child WHERE child.parent_id = parent.id)"); const logic::LogicTokenizeResult tokenized = logic::tokenizeLogicExpression(expression); QVERIFY2(tokenized.success, qPrintable(tokenized.error.message)); @@ -162,14 +166,14 @@ private slots: QVERIFY2(parsed.success, qPrintable(parsed.error.message)); QCOMPARE(parsed.root.type, logic::LogicNodeType::ExistsSubquery); QCOMPARE(parsed.root.subquerySql, - QStringLiteral("SELECT id FROM child WHERE child.parent_id = outer.id")); - QCOMPARE(parsed.root.referencedOuterNames, QStringList({QStringLiteral("outer.id")})); + QStringLiteral("SELECT id FROM child WHERE child.parent_id = parent.id")); + QCOMPARE(parsed.root.referencedOuterNames, QStringList({QStringLiteral("parent.id")})); } void test_parseSubqueryPreservesRawText() { const QString expression = QStringLiteral( - "EXISTS ( SELECT id FROM child WHERE child.parent_id = outer.id )"); + "EXISTS ( SELECT id FROM child WHERE child.parent_id = parent.id )"); const logic::LogicTokenizeResult tokenized = logic::tokenizeLogicExpression(expression); QVERIFY2(tokenized.success, qPrintable(tokenized.error.message)); @@ -177,7 +181,7 @@ private slots: const logic::LogicParseResult parsed = logic::parseLogicTokens(expression, tokenized.tokens); QVERIFY2(parsed.success, qPrintable(parsed.error.message)); QCOMPARE(parsed.root.subquerySql, - QStringLiteral(" SELECT id FROM child WHERE child.parent_id = outer.id ")); + QStringLiteral(" SELECT id FROM child WHERE child.parent_id = parent.id ")); } void test_parseCorrelatedReferenceCollectsOuterTablePrefix() @@ -193,17 +197,30 @@ private slots: QCOMPARE(parsed.root.referencedOuterNames, QStringList({QStringLiteral("parent.id")})); } + void test_parseCorrelatedReferenceAllowsOuterAsLocalAlias() + { + const QString expression = QStringLiteral( + "EXISTS (SELECT id FROM child outer WHERE outer.parent_id = parent.id)"); + + const logic::LogicTokenizeResult tokenized = logic::tokenizeLogicExpression(expression); + QVERIFY2(tokenized.success, qPrintable(tokenized.error.message)); + + const logic::LogicParseResult parsed = logic::parseLogicTokens(expression, tokenized.tokens); + QVERIFY2(parsed.success, qPrintable(parsed.error.message)); + QCOMPARE(parsed.root.referencedOuterNames, QStringList({QStringLiteral("parent.id")})); + } + void test_parseCorrelatedReferenceAllowsLocalAliasPrefix() { const QString expression = QStringLiteral( - "EXISTS (SELECT id FROM child c WHERE c.parent_id = outer.id)"); + "EXISTS (SELECT id FROM child c WHERE c.parent_id = parent.id)"); const logic::LogicTokenizeResult tokenized = logic::tokenizeLogicExpression(expression); QVERIFY2(tokenized.success, qPrintable(tokenized.error.message)); const logic::LogicParseResult parsed = logic::parseLogicTokens(expression, tokenized.tokens); QVERIFY2(parsed.success, qPrintable(parsed.error.message)); - QCOMPARE(parsed.root.referencedOuterNames, QStringList({QStringLiteral("outer.id")})); + QCOMPARE(parsed.root.referencedOuterNames, QStringList({QStringLiteral("parent.id")})); } void test_parseCorrelatedReferenceCollectsOuterAliasPrefix() @@ -222,7 +239,7 @@ private slots: void test_parseCorrelatedSubqueryCollectsMultiTableOuterReferences() { const QString expression = QStringLiteral( - "EXISTS (SELECT a.id FROM a JOIN b ON a.bid = outer.bid WHERE b.name = outer.name)"); + "EXISTS (SELECT a.id FROM a JOIN b ON a.bid = parent.bid WHERE b.name = parent.name)"); const logic::LogicTokenizeResult tokenized = logic::tokenizeLogicExpression(expression); QVERIFY2(tokenized.success, qPrintable(tokenized.error.message)); @@ -231,18 +248,20 @@ private slots: QVERIFY2(parsed.success, qPrintable(parsed.error.message)); QCOMPARE(parsed.root.type, logic::LogicNodeType::ExistsSubquery); QCOMPARE(parsed.root.referencedOuterNames.size(), 2); - QVERIFY(parsed.root.referencedOuterNames.contains(QStringLiteral("outer.bid"))); - QVERIFY(parsed.root.referencedOuterNames.contains(QStringLiteral("outer.name"))); + QVERIFY(parsed.root.referencedOuterNames.contains(QStringLiteral("parent.bid"))); + QVERIFY(parsed.root.referencedOuterNames.contains(QStringLiteral("parent.name"))); } void test_buildCorrelationBindingsExtractsTypedOuterValues() { - const logic::LogicRowContext outerRowContext = makeOuterRowContext(); + logic::LogicRowContext outerRowContext = makeOuterRowContext(); + outerRowContext.cellsByName.insert(QStringLiteral("parent.id"), + logic::LogicCellValue{QStringLiteral("10"), tabledef::ColumnType::Int, false}); const logic::CorrelationBindings bindings = logic::buildCorrelationBindings(outerRowContext, - {QStringLiteral("outer.id")}); + {QStringLiteral("parent.id")}); QCOMPARE(bindings.items.size(), 1); - QCOMPARE(bindings.items.first().name, QStringLiteral("outer.id")); + QCOMPARE(bindings.items.first().name, QStringLiteral("parent.id")); QCOMPARE(bindings.items.first().value, QStringLiteral("10")); QCOMPARE(bindings.items.first().type, tabledef::ColumnType::Int); QCOMPARE(bindings.items.first().isNull, false); @@ -251,16 +270,16 @@ private slots: void test_correlatedSubqueryPrefersExactOuterBindingName() { logic::LogicRowContext outerRowContext = makeOuterRowContext(); - outerRowContext.cellsByName.insert(QStringLiteral("outer.id"), + outerRowContext.cellsByName.insert(QStringLiteral("parent.id"), logic::LogicCellValue{QStringLiteral("10"), tabledef::ColumnType::Int, false}); outerRowContext.cellsByName.insert(QStringLiteral("id"), logic::LogicCellValue{QStringLiteral("1"), tabledef::ColumnType::Int, false}); const logic::CorrelationBindings bindings = logic::buildCorrelationBindings(outerRowContext, - {QStringLiteral("outer.id")}); + {QStringLiteral("parent.id")}); QCOMPARE(bindings.items.size(), 1); - QCOMPARE(bindings.items.first().name, QStringLiteral("outer.id")); + QCOMPARE(bindings.items.first().name, QStringLiteral("parent.id")); QCOMPARE(bindings.items.first().value, QStringLiteral("10")); } @@ -279,7 +298,7 @@ private slots: void test_correlatedSubqueryUsesExactOuterBindingDuringEvaluation() { const QString expression = QStringLiteral( - "EXISTS (SELECT id FROM child WHERE child.parent_id = outer.id)"); + "EXISTS (SELECT id FROM child WHERE child.parent_id = parent.id)"); const logic::LogicTokenizeResult tokenized = logic::tokenizeLogicExpression(expression); QVERIFY2(tokenized.success, qPrintable(tokenized.error.message)); const logic::LogicParseResult parsed = logic::parseLogicTokens(expression, tokenized.tokens); @@ -291,7 +310,7 @@ private slots: evalContext.allowSubquery = true; logic::LogicRowContext outerRowContext = makeOuterRowContext(); - outerRowContext.cellsByName.insert(QStringLiteral("outer.id"), + outerRowContext.cellsByName.insert(QStringLiteral("parent.id"), logic::LogicCellValue{QStringLiteral("10"), tabledef::ColumnType::Int, false}); outerRowContext.cellsByName.insert(QStringLiteral("id"), logic::LogicCellValue{QStringLiteral("1"), tabledef::ColumnType::Int, false}); @@ -300,7 +319,7 @@ private slots: QVERIFY2(result.success, qPrintable(result.error.message)); QCOMPARE(result.truth, logic::LogicTruthValue::True); QCOMPARE(executor.observedBindings.items.size(), 1); - QCOMPARE(executor.observedBindings.items.first().name, QStringLiteral("outer.id")); + QCOMPARE(executor.observedBindings.items.first().name, QStringLiteral("parent.id")); QCOMPARE(executor.observedBindings.items.first().value, QStringLiteral("10")); } @@ -342,7 +361,7 @@ private slots: void test_checkConstraintRejectsSubqueries() { - const QString expression = QStringLiteral("EXISTS (SELECT id FROM child WHERE child.parent_id = outer.id)"); + const QString expression = QStringLiteral("EXISTS (SELECT id FROM child WHERE child.parent_id = parent.id)"); const logic::LogicTokenizeResult tokenized = logic::tokenizeLogicExpression(expression); QVERIFY2(tokenized.success, qPrintable(tokenized.error.message)); @@ -416,18 +435,20 @@ private slots: evalContext.allowSubquery = true; const QString anyExpression = QStringLiteral( - "score = ANY (SELECT parent_id FROM child WHERE child.parent_id = outer.id)"); + "score = ANY (SELECT parent_id FROM child WHERE child.parent_id = parent.id)"); const logic::LogicTokenizeResult anyTokenized = logic::tokenizeLogicExpression(anyExpression); QVERIFY2(anyTokenized.success, qPrintable(anyTokenized.error.message)); const logic::LogicParseResult anyParsed = logic::parseLogicTokens(anyExpression, anyTokenized.tokens); QVERIFY2(anyParsed.success, qPrintable(anyParsed.error.message)); QCOMPARE(anyParsed.root.type, logic::LogicNodeType::QuantifiedSubquery); - QCOMPARE(anyParsed.root.referencedOuterNames, QStringList({QStringLiteral("outer.id")})); + QCOMPARE(anyParsed.root.referencedOuterNames, QStringList({QStringLiteral("parent.id")})); logic::LogicRowContext anyTrueRow; anyTrueRow.tableName = QStringLiteral("parent"); anyTrueRow.cellsByName.insert(QStringLiteral("id"), logic::LogicCellValue{QStringLiteral("10"), tabledef::ColumnType::Int, false}); + anyTrueRow.cellsByName.insert(QStringLiteral("parent.id"), + logic::LogicCellValue{QStringLiteral("10"), tabledef::ColumnType::Int, false}); anyTrueRow.cellsByName.insert(QStringLiteral("score"), logic::LogicCellValue{QStringLiteral("10"), tabledef::ColumnType::Int, false}); const logic::LogicEvalResult anyTrueResult = logic::evaluateLogicExpression(anyParsed.root, @@ -446,13 +467,13 @@ private slots: QCOMPARE(anyFalseResult.truth, logic::LogicTruthValue::False); const QString allExpression = QStringLiteral( - "score > ALL (SELECT parent_id FROM child WHERE child.parent_id = outer.id)"); + "score > ALL (SELECT parent_id FROM child WHERE child.parent_id = parent.id)"); const logic::LogicTokenizeResult allTokenized = logic::tokenizeLogicExpression(allExpression); QVERIFY2(allTokenized.success, qPrintable(allTokenized.error.message)); const logic::LogicParseResult allParsed = logic::parseLogicTokens(allExpression, allTokenized.tokens); QVERIFY2(allParsed.success, qPrintable(allParsed.error.message)); QCOMPARE(allParsed.root.type, logic::LogicNodeType::QuantifiedSubquery); - QCOMPARE(allParsed.root.referencedOuterNames, QStringList({QStringLiteral("outer.id")})); + QCOMPARE(allParsed.root.referencedOuterNames, QStringList({QStringLiteral("parent.id")})); logic::LogicRowContext allTrueRow = anyTrueRow; allTrueRow.cellsByName.insert(QStringLiteral("score"), @@ -481,7 +502,7 @@ private slots: evalContext.allowSubquery = true; const QString anyExpression = QStringLiteral( - "score = ANY (SELECT parent_id FROM child WHERE child.parent_id = outer.id)"); + "score = ANY (SELECT parent_id FROM child WHERE child.parent_id = parent.id)"); const auto anyTokenized = logic::tokenizeLogicExpression(anyExpression); QVERIFY2(anyTokenized.success, qPrintable(anyTokenized.error.message)); const auto anyParsed = logic::parseLogicTokens(anyExpression, anyTokenized.tokens); @@ -491,6 +512,8 @@ private slots: anyRow.tableName = QStringLiteral("parent"); anyRow.cellsByName.insert(QStringLiteral("id"), logic::LogicCellValue{QStringLiteral("10"), tabledef::ColumnType::Int, false}); + anyRow.cellsByName.insert(QStringLiteral("parent.id"), + logic::LogicCellValue{QStringLiteral("10"), tabledef::ColumnType::Int, false}); anyRow.cellsByName.insert(QStringLiteral("score"), logic::LogicCellValue{QStringLiteral("30"), tabledef::ColumnType::Int, false}); @@ -499,7 +522,7 @@ private slots: QCOMPARE(anyResult.truth, logic::LogicTruthValue::Unknown); const QString allExpression = QStringLiteral( - "score > ALL (SELECT parent_id FROM child WHERE child.parent_id = outer.id)"); + "score > ALL (SELECT parent_id FROM child WHERE child.parent_id = parent.id)"); const auto allTokenized = logic::tokenizeLogicExpression(allExpression); QVERIFY2(allTokenized.success, qPrintable(allTokenized.error.message)); const auto allParsed = logic::parseLogicTokens(allExpression, allTokenized.tokens); @@ -509,6 +532,8 @@ private slots: allRow.tableName = QStringLiteral("parent"); allRow.cellsByName.insert(QStringLiteral("id"), logic::LogicCellValue{QStringLiteral("10"), tabledef::ColumnType::Int, false}); + allRow.cellsByName.insert(QStringLiteral("parent.id"), + logic::LogicCellValue{QStringLiteral("10"), tabledef::ColumnType::Int, false}); allRow.cellsByName.insert(QStringLiteral("score"), logic::LogicCellValue{QStringLiteral("20"), tabledef::ColumnType::Int, false}); @@ -725,7 +750,7 @@ private slots: void test_tokenizerPopulatesFieldsAndKeywordTypes() { - const QString expr = QStringLiteral("a AND outer.id IS NULL"); + const QString expr = QStringLiteral("a AND parent.id IS NULL"); const logic::LogicTokenizeResult tokenized = logic::tokenizeLogicExpression(expr); QVERIFY2(tokenized.success, qPrintable(tokenized.error.message)); @@ -750,7 +775,7 @@ private slots: void test_captureSubqueryWithNestedParentheses() { const QString expression = QStringLiteral( - "EXISTS (SELECT (id) FROM child WHERE child.parent_id = outer.id)"); + "EXISTS (SELECT (id) FROM child WHERE child.parent_id = parent.id)"); const logic::LogicTokenizeResult tokenized = logic::tokenizeLogicExpression(expression); QVERIFY2(tokenized.success, qPrintable(tokenized.error.message)); @@ -759,7 +784,7 @@ private slots: QVERIFY2(parsed.success, qPrintable(parsed.error.message)); QCOMPARE(parsed.root.type, logic::LogicNodeType::ExistsSubquery); QVERIFY(parsed.root.subquerySql.contains(QStringLiteral("(id)"))); - QCOMPARE(parsed.root.referencedOuterNames, QStringList({QStringLiteral("outer.id")})); + QCOMPARE(parsed.root.referencedOuterNames, QStringList({QStringLiteral("parent.id")})); } void test_notInLiteralListNegation() @@ -869,7 +894,7 @@ private slots: void test_parseCorrelatedExistsAllowsSelectStarSubquery() { const QString expression = QStringLiteral( - "EXISTS (SELECT * FROM child WHERE child.parent_id = outer.id)"); + "EXISTS (SELECT * FROM child WHERE child.parent_id = parent.id)"); const auto tokenized = logic::tokenizeLogicExpression(expression); QVERIFY2(tokenized.success, qPrintable(tokenized.error.message)); @@ -877,8 +902,8 @@ private slots: const auto parsed = logic::parseLogicTokens(expression, tokenized.tokens); QVERIFY2(parsed.success, qPrintable(parsed.error.message)); QCOMPARE(parsed.root.type, logic::LogicNodeType::ExistsSubquery); - QCOMPARE(parsed.root.subquerySql, QStringLiteral("SELECT * FROM child WHERE child.parent_id = outer.id")); - QCOMPARE(parsed.root.referencedOuterNames, QStringList({QStringLiteral("outer.id")})); + QCOMPARE(parsed.root.subquerySql, QStringLiteral("SELECT * FROM child WHERE child.parent_id = parent.id")); + QCOMPARE(parsed.root.referencedOuterNames, QStringList({QStringLiteral("parent.id")})); } void test_parseCorrelatedReferenceErrorPosition() @@ -1121,3 +1146,4 @@ int service_tests::runLogicTests() } #include "test_logic.moc" + diff --git a/tests/test_query_executor.cpp b/tests/test_query_executor.cpp index c7d730a..c2b4e46 100644 --- a/tests/test_query_executor.cpp +++ b/tests/test_query_executor.cpp @@ -1,4 +1,4 @@ -#include "../controller/nest_query.h" +#include "../controller/nest_query.h" #include "../service/service.h" #include "../utils/logic/logic.h" @@ -96,6 +96,8 @@ logic::LogicRowContext makeOuterRow(const QString &idValue) rowContext.tableName = QStringLiteral("parent"); rowContext.cellsByName.insert(QStringLiteral("id"), logic::LogicCellValue{idValue, tabledef::ColumnType::Int, false}); + rowContext.cellsByName.insert(QStringLiteral("parent.id"), + logic::LogicCellValue{idValue, tabledef::ColumnType::Int, false}); return rowContext; } @@ -165,10 +167,10 @@ private slots: QueryExecutor executor; const logic::LogicTokenizeResult tokenized = logic::tokenizeLogicExpression( - QStringLiteral("EXISTS (SELECT id FROM child WHERE child.parent_id = outer.id)")); + QStringLiteral("EXISTS (SELECT id FROM child WHERE child.parent_id = parent.id)")); QVERIFY2(tokenized.success, qPrintable(tokenized.error.message)); const logic::LogicParseResult parsed = logic::parseLogicTokens( - QStringLiteral("EXISTS (SELECT id FROM child WHERE child.parent_id = outer.id)"), + QStringLiteral("EXISTS (SELECT id FROM child WHERE child.parent_id = parent.id)"), tokenized.tokens); QVERIFY2(parsed.success, qPrintable(parsed.error.message)); @@ -190,7 +192,7 @@ private slots: seedRow(databaseName, tableName, {QStringLiteral("1"), QStringLiteral("10")}, m_dataRoot); QueryExecutor executor; - const QString expression = QStringLiteral("EXISTS (SELECT id FROM child WHERE child.parent_id = outer.id)"); + const QString expression = QStringLiteral("EXISTS (SELECT id FROM child WHERE child.parent_id = parent.id)"); const logic::LogicTokenizeResult tokenized = logic::tokenizeLogicExpression(expression); QVERIFY2(tokenized.success, qPrintable(tokenized.error.message)); const logic::LogicParseResult parsed = logic::parseLogicTokens(expression, tokenized.tokens); @@ -216,7 +218,7 @@ private slots: QueryExecutor executor; const QString expression = QStringLiteral( - "id IN (SELECT parent_id FROM child WHERE child.parent_id = outer.id)"); + "id IN (SELECT parent_id FROM child WHERE child.parent_id = parent.id)"); const logic::LogicTokenizeResult tokenized = logic::tokenizeLogicExpression(expression); QVERIFY2(tokenized.success, qPrintable(tokenized.error.message)); const logic::LogicParseResult parsed = logic::parseLogicTokens(expression, tokenized.tokens); @@ -345,7 +347,7 @@ private slots: const logic::CorrelationBindings bindings; const service::QueryExecuteContext context{databaseName, m_dataRoot}; const QueryExecuteResult result = executor.executeCorrelatedSelect( - QStringLiteral("SELECT id FROM child WHERE child.parent_id = outer.id"), + QStringLiteral("SELECT id FROM child WHERE child.parent_id = parent.id"), bindings, context); @@ -364,14 +366,14 @@ private slots: QueryExecutor executor; logic::CorrelationBindings bindings; - bindings.items.append(logic::CorrelatedBinding{QStringLiteral("outer.id"), + bindings.items.append(logic::CorrelatedBinding{QStringLiteral("parent.id"), QStringLiteral("10"), tabledef::ColumnType::Int, false}); const service::QueryExecuteContext context{databaseName, m_dataRoot}; const QueryExecuteResult result = executor.executeCorrelatedSelect( - QStringLiteral("SELECT id FROM child WHERE child.parent_id = outer.id"), + QStringLiteral("SELECT id FROM child WHERE child.parent_id = parent.id"), bindings, context); @@ -402,8 +404,8 @@ private slots: const QueryExecuteResult result = executor.executeSql( QStringLiteral( "SELECT id FROM parent WHERE EXISTS (" - "SELECT id FROM child WHERE EXISTS (" - "SELECT id FROM grandchild WHERE grandchild.child_id = outer.id))"), + "SELECT c.id FROM child c WHERE EXISTS (" + "SELECT id FROM grandchild WHERE grandchild.child_id = c.parent_id))"), context); QVERIFY2(result.success, qPrintable(result.errorMessage)); @@ -428,7 +430,7 @@ private slots: seedRow(databaseName, tableName, {QStringLiteral("1"), QStringLiteral("10")}, m_dataRoot); QueryExecutor executor; - const QString expression = QStringLiteral("EXISTS (SELECT id FROM child WHERE child.parent_id = outer.id)"); + const QString expression = QStringLiteral("EXISTS (SELECT id FROM child WHERE child.parent_id = parent.id)"); const logic::LogicTokenizeResult tokenized = logic::tokenizeLogicExpression(expression); QVERIFY2(tokenized.success, qPrintable(tokenized.error.message)); const logic::LogicParseResult parsed = logic::parseLogicTokens(expression, tokenized.tokens); @@ -455,7 +457,7 @@ private slots: seedRow(databaseName, tableName, {QStringLiteral("1"), QStringLiteral("10")}, m_dataRoot); QueryExecutor executor; - const QString expression = QStringLiteral("EXISTS (SELECT id FROM child WHERE child.parent_id = outer.id)"); + const QString expression = QStringLiteral("EXISTS (SELECT id FROM child WHERE child.parent_id = parent.id)"); const logic::LogicTokenizeResult tokenized = logic::tokenizeLogicExpression(expression); QVERIFY2(tokenized.success, qPrintable(tokenized.error.message)); const logic::LogicParseResult parsed = logic::parseLogicTokens(expression, tokenized.tokens); @@ -465,6 +467,8 @@ private slots: outerRow.tableName = QStringLiteral("parent"); outerRow.cellsByName.insert(QStringLiteral("id"), logic::LogicCellValue{QString(), tabledef::ColumnType::Int, true}); + outerRow.cellsByName.insert(QStringLiteral("parent.id"), + logic::LogicCellValue{QString(), tabledef::ColumnType::Int, true}); const logic::LogicEvalResult result = logic::evaluateLogicExpression(parsed.root, outerRow, @@ -478,7 +482,7 @@ private slots: void test_correlatedSubqueryExecutesPerRowWithoutCache() { CountingSubqueryExecutor executor; - const QString expression = QStringLiteral("EXISTS (SELECT id FROM child WHERE child.parent_id = outer.id)"); + const QString expression = QStringLiteral("EXISTS (SELECT id FROM child WHERE child.parent_id = parent.id)"); const logic::LogicTokenizeResult tokenized = logic::tokenizeLogicExpression(expression); QVERIFY2(tokenized.success, qPrintable(tokenized.error.message)); const logic::LogicParseResult parsed = logic::parseLogicTokens(expression, tokenized.tokens); @@ -515,3 +519,4 @@ int service_tests::runQueryExecutorTests() } #include "test_query_executor.moc" + diff --git a/utils/logic/logic_parser.cpp b/utils/logic/logic_parser.cpp index 307b4cb..a46f3b6 100644 --- a/utils/logic/logic_parser.cpp +++ b/utils/logic/logic_parser.cpp @@ -69,6 +69,16 @@ void appendLocalPrefixesFromSource(const QVariantMap &source, QStringList *local } } +bool isLocalQualifiedReference(const QString &name, const QStringList &localPrefixes) +{ + for (const QString &prefix : localPrefixes) { + if (!prefix.isEmpty() && name.startsWith(prefix)) { + return true; + } + } + return false; +} + bool collectOuterNamesFromNode(const LogicNode &node, const QStringList &localPrefixes, QStringList *names, @@ -76,21 +86,16 @@ bool collectOuterNamesFromNode(const LogicNode &node, const QString &expressionText) { for (const QString &referencedName : node.referencedOuterNames) { - appendOuterName(names, referencedName); + if (!isLocalQualifiedReference(referencedName, localPrefixes)) { + appendOuterName(names, referencedName); + } } if (node.type == LogicNodeType::ColumnRef) { if (node.reference.scope == LogicReferenceScope::Outer) { appendOuterName(names, node.reference.name); } else if (node.reference.name.contains(QLatin1Char('.'))) { - bool localReference = false; - for (const QString &prefix : localPrefixes) { - if (!prefix.isEmpty() && node.reference.name.startsWith(prefix)) { - localReference = true; - break; - } - } - if (localReference) { + if (isLocalQualifiedReference(node.reference.name, localPrefixes)) { return true; } appendOuterName(names, node.reference.name); @@ -216,10 +221,7 @@ LogicNode makeColumnNode(const LogicToken &token) LogicNode node; node.type = LogicNodeType::ColumnRef; node.rawText = token.rawText; - if (token.rawText.startsWith(QStringLiteral("outer."))) { - node.reference.scope = LogicReferenceScope::Outer; - node.reference.name = token.rawText; - } else if (token.rawText.contains(QLatin1Char('.'))) { + if (token.rawText.contains(QLatin1Char('.'))) { node.reference.scope = LogicReferenceScope::Local; node.reference.name = token.rawText; } else { diff --git a/utils/logic/subquery_logic.cpp b/utils/logic/subquery_logic.cpp index daf8221..0527017 100644 --- a/utils/logic/subquery_logic.cpp +++ b/utils/logic/subquery_logic.cpp @@ -12,19 +12,6 @@ using service::QueryExecuteContext; using service::QueryExecuteResult; using service::QueryExecutor; -namespace { - -QString bindingKeyToLocalName(const QString &bindingName) -{ - const int dotIndex = bindingName.indexOf(QLatin1Char('.')); - if (dotIndex >= 0 && dotIndex + 1 < bindingName.size()) { - return bindingName.mid(dotIndex + 1); - } - return bindingName; -} - -} // namespace - LogicSubqueryExecutorAdapter::LogicSubqueryExecutorAdapter(QueryExecutor *executor) : m_executor(executor) { @@ -48,15 +35,7 @@ CorrelationBindings buildCorrelationBindings(const LogicRowContext &outerRowCont { CorrelationBindings bindings; for (const QString &outerName : referencedOuterNames) { - const QString localName = bindingKeyToLocalName(outerName); auto it = outerRowContext.cellsByName.constFind(outerName); - if (it == outerRowContext.cellsByName.constEnd() - && outerName.startsWith(QStringLiteral("outer."))) { - it = outerRowContext.cellsByName.constFind(localName); - if (it == outerRowContext.cellsByName.constEnd()) { - continue; - } - } if (it == outerRowContext.cellsByName.constEnd()) { continue; }