daxiapi-cli 2.5.0 → 2.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +73 -0
- package/bin/index.js +1 -0
- package/commands/sector.js +80 -1
- package/commands/sql.js +66 -0
- package/lib/api.js +16 -1
- package/lib/sql-parser.js +348 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -91,10 +91,34 @@ daxiapi sector gn
|
|
|
91
91
|
|
|
92
92
|
# 热门概念板块(东方财富数据源)
|
|
93
93
|
daxiapi sector gn --type dfcf
|
|
94
|
+
|
|
95
|
+
# 行业板块详情(通过板块ID查询)
|
|
96
|
+
daxiapi sector bk_info --code BK0428
|
|
97
|
+
|
|
98
|
+
# 行业板块详情(通过板块名称查询,支持模糊匹配)
|
|
99
|
+
daxiapi sector bk_info --name 工程建筑
|
|
100
|
+
|
|
101
|
+
# 行业板块详情(指定数据源)
|
|
102
|
+
daxiapi sector bk_info --type ths --name 工程建筑
|
|
103
|
+
|
|
104
|
+
# 概念板块详情(通过板块ID查询)
|
|
105
|
+
daxiapi sector gn_info --code GN123
|
|
106
|
+
|
|
107
|
+
# 概念板块详情(通过板块名称查询,支持模糊匹配)
|
|
108
|
+
daxiapi sector gn_info --name 人工智能
|
|
109
|
+
|
|
110
|
+
# 概念板块详情(指定数据源)
|
|
111
|
+
daxiapi sector gn_info --type ths --name 人工智能
|
|
94
112
|
```
|
|
95
113
|
|
|
96
114
|
**sector stocks 排序字段**:`cs`(CS强度)、`zdf`(涨跌幅)、`sm`(市值)、`cg`(成交额)、`cr`(换手率)、`sctr`(SCTR排名)
|
|
97
115
|
|
|
116
|
+
**bk_info/gn_info 说明**:
|
|
117
|
+
- `bk_info`:查询行业板块详情,返回CS强度、多日涨跌幅、市场宽度、主力资金净流入等数据
|
|
118
|
+
- `gn_info`:查询概念板块详情,返回CS强度、多日涨跌幅、涨幅7%以上股票数、突破箱体股票数等数据
|
|
119
|
+
- 支持通过 `--code`(板块ID)或 `--name`(板块名称,模糊匹配)查询
|
|
120
|
+
- `--type` 可选数据源:`dfcf`(东方财富)或 `ths`(同花顺)
|
|
121
|
+
|
|
98
122
|
---
|
|
99
123
|
|
|
100
124
|
### 个股数据
|
|
@@ -164,6 +188,55 @@ daxiapi stock capital-flow 600031 --days 10
|
|
|
164
188
|
|
|
165
189
|
---
|
|
166
190
|
|
|
191
|
+
### SQL筛选
|
|
192
|
+
|
|
193
|
+
```bash
|
|
194
|
+
# 基础查询(查询指定日期的股票)
|
|
195
|
+
daxiapi sql "date='2026-06-17' LIMIT 10"
|
|
196
|
+
|
|
197
|
+
# 强势股筛选(RPS>70)
|
|
198
|
+
daxiapi sql "date='2026-06-17' AND rps_score>70 ORDER BY rps_score DESC LIMIT 20"
|
|
199
|
+
|
|
200
|
+
# 多条件组合(RPS>70、SCTR>60、CS>0)
|
|
201
|
+
daxiapi sql "date='2026-06-17' AND rps_score>70 AND sctr>60 AND cs>0 ORDER BY rps_score DESC LIMIT 15"
|
|
202
|
+
|
|
203
|
+
# 技术形态筛选(VCP形态)
|
|
204
|
+
daxiapi sql "date='2026-06-17' AND isVCP=1 ORDER BY rps_score DESC LIMIT 20"
|
|
205
|
+
|
|
206
|
+
# 区间范围查询(CS在0-15之间)
|
|
207
|
+
daxiapi sql "date='2026-06-17' AND cs in [0, 15] ORDER BY cs DESC LIMIT 20"
|
|
208
|
+
|
|
209
|
+
# 板块筛选(银行板块)
|
|
210
|
+
daxiapi sql "date='2026-06-17' AND bkName='银行' AND rps_score>70 ORDER BY rps_score DESC LIMIT 20"
|
|
211
|
+
```
|
|
212
|
+
|
|
213
|
+
**SQL语法说明**:
|
|
214
|
+
|
|
215
|
+
- **必须包含 `date` 条件**:所有查询必须指定日期,格式为 `date='YYYY-MM-DD'`
|
|
216
|
+
- **日期限制**:只支持查询最近10天内的数据
|
|
217
|
+
- **支持的运算符**:`=`, `!=`, `<>`, `>`, `<`, `>=`, `<=`, `in [...]`, `in (...)`
|
|
218
|
+
- **逻辑运算符**:`AND`, `OR`,支持括号嵌套
|
|
219
|
+
- **排序**:`ORDER BY field ASC/DESC`,支持多字段排序
|
|
220
|
+
- **数量限制**:`LIMIT N`,限制返回数量
|
|
221
|
+
|
|
222
|
+
**支持的查询字段**:
|
|
223
|
+
|
|
224
|
+
| 字段类别 | 字段名 | 说明 |
|
|
225
|
+
|---------|--------|------|
|
|
226
|
+
| 日期 | `date` | 查询日期(必填) |
|
|
227
|
+
| 基本信息 | `stockId`, `name` | 股票代码、名称 |
|
|
228
|
+
| 涨跌幅 | `zdf`, `zdf_5d`, `zdf_10d`, `zdf_20d` | 当日、5日、10日、20日涨跌幅 |
|
|
229
|
+
| 强度指标 | `cs`, `rps_score`, `sctr`, `sm`, `ml` | CS强度、RPS、SCTR、SM、ML |
|
|
230
|
+
| 技术形态 | `isVCP`, `isSOS`, `isSpring`, `isNewHigh` | VCP、SOS、Spring、新高形态 |
|
|
231
|
+
| 价格 | `close`, `open`, `high`, `low` | 收盘价、开盘价、最高价、最低价 |
|
|
232
|
+
| 成交量 | `vol`, `amount` | 成交量、成交额 |
|
|
233
|
+
| 市值 | `shizhi` | 总市值(亿元) |
|
|
234
|
+
| 板块 | `bkName`, `bkCode` | 板块名称、板块代码 |
|
|
235
|
+
|
|
236
|
+
**更多示例**:参考 [xiapi-stock-sql-screener Skill](../skills/xiapi-stock-sql-screener/SKILL.md)
|
|
237
|
+
|
|
238
|
+
---
|
|
239
|
+
|
|
167
240
|
### 涨跌停
|
|
168
241
|
|
|
169
242
|
```bash
|
package/bin/index.js
CHANGED
|
@@ -12,6 +12,7 @@ program
|
|
|
12
12
|
require('../commands/config')(program);
|
|
13
13
|
require('../commands/market')(program);
|
|
14
14
|
require('../commands/sector')(program);
|
|
15
|
+
require('../commands/sql')(program);
|
|
15
16
|
require('../commands/stock')(program);
|
|
16
17
|
require('../commands/kline')(program);
|
|
17
18
|
require('../commands/zdt')(program);
|
package/commands/sector.js
CHANGED
|
@@ -7,7 +7,7 @@ module.exports = function (program) {
|
|
|
7
7
|
const sectorCmd = program
|
|
8
8
|
.command('sector')
|
|
9
9
|
.description(
|
|
10
|
-
'获取A
|
|
10
|
+
'获取A股板块热力图、行业板块(详情)、概念板块(详情)、板块内个股排名等多维度板块数据,用于板块轮动分析与热点追踪。支持同花顺和东方财富分类。'
|
|
11
11
|
);
|
|
12
12
|
|
|
13
13
|
sectorCmd
|
|
@@ -52,6 +52,7 @@ module.exports = function (program) {
|
|
|
52
52
|
}
|
|
53
53
|
});
|
|
54
54
|
|
|
55
|
+
|
|
55
56
|
sectorCmd
|
|
56
57
|
.command('stocks')
|
|
57
58
|
.description('获取A股指定板块内股票排名,支持BK0428、0428、881155等多种板块代码格式。支持按强度(cs)、涨跌幅(zdf)、市值(sm)、成交额(cg)、换手率(cr)、SCTR排名等多种维度排序。返回板块内前20只股票的详细数据,可用于板块内强势股筛选和个股分析。')
|
|
@@ -122,4 +123,82 @@ module.exports = function (program) {
|
|
|
122
123
|
process.exit(1);
|
|
123
124
|
}
|
|
124
125
|
});
|
|
126
|
+
|
|
127
|
+
sectorCmd
|
|
128
|
+
.command('bk_info')
|
|
129
|
+
.description('获取A股行业板块详情数据,支持同花顺(ths)和东方财富(dfcf)两个数据源。可通过板块ID(code)或板块名称(name)查询,返回板块的CS强度、多日涨跌幅、市场宽度、主力资金净流入等详细数据。可用于板块深度分析和资金流向追踪。')
|
|
130
|
+
.option('--type <type>', '数据源类型 (dfcf|ths)', 'dfcf')
|
|
131
|
+
.option('--code <bkCode>', '板块ID')
|
|
132
|
+
.option('--name <bkName>', '板块名称(支持模糊匹配)')
|
|
133
|
+
.action(async options => {
|
|
134
|
+
try {
|
|
135
|
+
const token = config.getToken();
|
|
136
|
+
if (!token) {
|
|
137
|
+
const error = new Error('未配置 API Token');
|
|
138
|
+
error.response = { status: 401 };
|
|
139
|
+
throw error;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
if (!['dfcf', 'ths'].includes(options.type)) {
|
|
143
|
+
throw createParameterError(
|
|
144
|
+
'参数无效',
|
|
145
|
+
["参数 'type' 必须是 dfcf 或 ths"],
|
|
146
|
+
['daxiapi sector bk_info --type ths', 'daxiapi sector bk_info --type dfcf']
|
|
147
|
+
);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
if (!options.code && !options.name) {
|
|
151
|
+
throw createParameterError(
|
|
152
|
+
'参数缺失',
|
|
153
|
+
['必须提供 --code 或 --name 参数'],
|
|
154
|
+
['daxiapi sector bk_info --code BK0428', 'daxiapi sector bk_info --name 工程建筑']
|
|
155
|
+
);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const data = await api.getBkInfo(token, options.type, options.code, options.name);
|
|
159
|
+
output(data);
|
|
160
|
+
} catch (error) {
|
|
161
|
+
handleError(error);
|
|
162
|
+
process.exit(1);
|
|
163
|
+
}
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
sectorCmd
|
|
167
|
+
.command('gn_info')
|
|
168
|
+
.description('获取A股概念板块详情数据,支持同花顺(ths)和东方财富(dfcf)两个数据源。可通过板块ID(code)或板块名称(name)查询,返回板块的CS强度、多日涨跌幅、涨幅7%以上股票数、突破箱体股票数等详细数据。可用于概念板块深度分析和热点追踪。')
|
|
169
|
+
.option('--type <type>', '数据源类型 (dfcf|ths)', 'dfcf')
|
|
170
|
+
.option('--code <gnCode>', '概念板块ID')
|
|
171
|
+
.option('--name <gnName>', '概念板块名称(支持模糊匹配)')
|
|
172
|
+
.action(async options => {
|
|
173
|
+
try {
|
|
174
|
+
const token = config.getToken();
|
|
175
|
+
if (!token) {
|
|
176
|
+
const error = new Error('未配置 API Token');
|
|
177
|
+
error.response = { status: 401 };
|
|
178
|
+
throw error;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
if (!['dfcf', 'ths'].includes(options.type)) {
|
|
182
|
+
throw createParameterError(
|
|
183
|
+
'参数无效',
|
|
184
|
+
["参数 'type' 必须是 dfcf 或 ths"],
|
|
185
|
+
['daxiapi sector gn_info --type ths', 'daxiapi sector gn_info --type dfcf']
|
|
186
|
+
);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
if (!options.code && !options.name) {
|
|
190
|
+
throw createParameterError(
|
|
191
|
+
'参数缺失',
|
|
192
|
+
['必须提供 --code 或 --name 参数'],
|
|
193
|
+
['daxiapi sector gn_info --code 888123', 'daxiapi sector gn_info --name 人工智能']
|
|
194
|
+
);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
const data = await api.getGnInfo(token, options.type, options.code, options.name);
|
|
198
|
+
output(data);
|
|
199
|
+
} catch (error) {
|
|
200
|
+
handleError(error);
|
|
201
|
+
process.exit(1);
|
|
202
|
+
}
|
|
203
|
+
});
|
|
125
204
|
};
|
package/commands/sql.js
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
const config = require('../lib/config');
|
|
2
|
+
const api = require('../lib/api');
|
|
3
|
+
const {handleError, createParameterError} = require('../lib/error');
|
|
4
|
+
const {output} = require('../lib/output');
|
|
5
|
+
const {parseSql} = require('../lib/sql-parser');
|
|
6
|
+
|
|
7
|
+
module.exports = function (program) {
|
|
8
|
+
const sqlCmd = program
|
|
9
|
+
.command('sql')
|
|
10
|
+
.description(
|
|
11
|
+
'使用 SQL 条件筛选股票,支持自定义条件组合、排序和数量限制。' +
|
|
12
|
+
'支持等于、大于、小于、区间范围、IN枚举、字段间比较等多种条件写法,支持 AND/OR 逻辑组合和括号嵌套。' +
|
|
13
|
+
'返回股票代码、名称、涨跌幅、CS强度、RPS相对强度、SCTR技术排名、所属板块、概念等详细数据。' +
|
|
14
|
+
'可用于量化筛选、技术形态选股和自定义策略分析。'
|
|
15
|
+
);
|
|
16
|
+
|
|
17
|
+
sqlCmd
|
|
18
|
+
.argument('<condition>', 'SQL WHERE 条件(支持 ORDER BY 和 LIMIT)')
|
|
19
|
+
.action(async condition => {
|
|
20
|
+
try {
|
|
21
|
+
const token = config.getToken();
|
|
22
|
+
if (!token) {
|
|
23
|
+
const error = new Error('未配置 API Token');
|
|
24
|
+
error.response = {status: 401};
|
|
25
|
+
throw error;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
if (!condition) {
|
|
29
|
+
throw createParameterError(
|
|
30
|
+
'参数无效',
|
|
31
|
+
["参数 'condition' 不能为空"],
|
|
32
|
+
[
|
|
33
|
+
'daxiapi sql "date=\'2026-06-17\' AND isVCP=1"',
|
|
34
|
+
'daxiapi sql "date=\'2026-06-17\' AND rps_score>70 ORDER BY rps_score DESC LIMIT 20"'
|
|
35
|
+
]
|
|
36
|
+
);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// 解析 SQL 条件
|
|
40
|
+
const parsed = parseSql(condition);
|
|
41
|
+
if (parsed.error) {
|
|
42
|
+
throw createParameterError(
|
|
43
|
+
'SQL 解析失败',
|
|
44
|
+
[parsed.error],
|
|
45
|
+
[
|
|
46
|
+
'daxiapi sql "date=\'2026-06-17\' AND isVCP=1"',
|
|
47
|
+
'daxiapi sql "date=\'2026-06-17\' AND cs in [0, 15] AND rps_score>70 ORDER BY rps_score DESC LIMIT 10"'
|
|
48
|
+
]
|
|
49
|
+
);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// 调用服务端接口
|
|
53
|
+
const data = await api.querySqlStocks(
|
|
54
|
+
token,
|
|
55
|
+
parsed.conditions,
|
|
56
|
+
parsed.orderBy,
|
|
57
|
+
parsed.limit
|
|
58
|
+
);
|
|
59
|
+
|
|
60
|
+
output(data);
|
|
61
|
+
} catch (error) {
|
|
62
|
+
handleError(error);
|
|
63
|
+
process.exit(1);
|
|
64
|
+
}
|
|
65
|
+
});
|
|
66
|
+
};
|
package/lib/api.js
CHANGED
|
@@ -62,7 +62,14 @@ async function getBkData(token) {
|
|
|
62
62
|
const client = createClient(token);
|
|
63
63
|
return get(client, '/get_bk_data');
|
|
64
64
|
}
|
|
65
|
-
|
|
65
|
+
async function getBkInfo(token, type, code, name) {
|
|
66
|
+
const client = createClient(token);
|
|
67
|
+
return post(client, '/get_bk_info', { type, code, name });
|
|
68
|
+
}
|
|
69
|
+
async function getGnInfo(token, type, code, name) {
|
|
70
|
+
const client = createClient(token);
|
|
71
|
+
return post(client, '/get_gn_info', { type, code, name });
|
|
72
|
+
}
|
|
66
73
|
async function getSectorData(token, orderBy = 'cs', limit = 5) {
|
|
67
74
|
const client = createClient(token);
|
|
68
75
|
return post(client, '/get_sector_data', {orderBy, lmt: limit});
|
|
@@ -117,6 +124,11 @@ async function queryStockData(token, q, type = 'stock') {
|
|
|
117
124
|
return post(client, '/query_stock_data', {q, type});
|
|
118
125
|
}
|
|
119
126
|
|
|
127
|
+
async function querySqlStocks(token, conditions, orderBy, limit) {
|
|
128
|
+
const client = createClient(token);
|
|
129
|
+
return post(client, '/sql_query', { conditions, orderBy, limit });
|
|
130
|
+
}
|
|
131
|
+
|
|
120
132
|
|
|
121
133
|
|
|
122
134
|
async function getCapitalFlow(code, days =10) {
|
|
@@ -485,6 +497,8 @@ async function getNewsReport(code, pageSize = 25, pageIndex = 1, beginTime = '20
|
|
|
485
497
|
module.exports = {
|
|
486
498
|
getCapitalFlow,
|
|
487
499
|
getMarketData,
|
|
500
|
+
getBkInfo,
|
|
501
|
+
getGnInfo,
|
|
488
502
|
getMarketTemp,
|
|
489
503
|
getCompassData,
|
|
490
504
|
getMarketStyle,
|
|
@@ -500,6 +514,7 @@ module.exports = {
|
|
|
500
514
|
getZdtPool,
|
|
501
515
|
getSecId,
|
|
502
516
|
queryStockData,
|
|
517
|
+
querySqlStocks,
|
|
503
518
|
getPatternStocks,
|
|
504
519
|
getDividendScore,
|
|
505
520
|
getStockRank,
|
|
@@ -0,0 +1,348 @@
|
|
|
1
|
+
class Tokenizer {
|
|
2
|
+
constructor(text) {
|
|
3
|
+
this.text = text;
|
|
4
|
+
this.pos = 0;
|
|
5
|
+
this.tokens = [];
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
tokenize() {
|
|
9
|
+
while (this.pos < this.text.length) {
|
|
10
|
+
this.skipWhitespace();
|
|
11
|
+
if (this.pos >= this.text.length) break;
|
|
12
|
+
|
|
13
|
+
const char = this.text[this.pos];
|
|
14
|
+
|
|
15
|
+
if (char === '(') {
|
|
16
|
+
this.tokens.push({type: 'LPAREN', value: '('});
|
|
17
|
+
this.pos++;
|
|
18
|
+
} else if (char === ')') {
|
|
19
|
+
this.tokens.push({type: 'RPAREN', value: ')'});
|
|
20
|
+
this.pos++;
|
|
21
|
+
} else if (char === '[') {
|
|
22
|
+
this.tokens.push({type: 'LBRACKET', value: '['});
|
|
23
|
+
this.pos++;
|
|
24
|
+
} else if (char === ']') {
|
|
25
|
+
this.tokens.push({type: 'RBRACKET', value: ']'});
|
|
26
|
+
this.pos++;
|
|
27
|
+
} else if (char === ',') {
|
|
28
|
+
this.tokens.push({type: 'COMMA', value: ','});
|
|
29
|
+
this.pos++;
|
|
30
|
+
} else if ('=!<>'.includes(char)) {
|
|
31
|
+
this.readOperator();
|
|
32
|
+
} else if (char === "'" || char === '"') {
|
|
33
|
+
this.readString(char);
|
|
34
|
+
} else if (/\d/.test(char)) {
|
|
35
|
+
this.readNumber();
|
|
36
|
+
} else if (/[a-zA-Z_]/.test(char)) {
|
|
37
|
+
this.readWord();
|
|
38
|
+
} else {
|
|
39
|
+
throw new Error(`未知字符: ${char} at position ${this.pos}`);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
return this.tokens;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
skipWhitespace() {
|
|
46
|
+
while (this.pos < this.text.length && /\s/.test(this.text[this.pos])) {
|
|
47
|
+
this.pos++;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
readOperator() {
|
|
52
|
+
const start = this.pos;
|
|
53
|
+
const char = this.text[this.pos];
|
|
54
|
+
|
|
55
|
+
if (char === '!' && this.text[this.pos + 1] === '=') {
|
|
56
|
+
this.tokens.push({type: 'OP', value: '!='});
|
|
57
|
+
this.pos += 2;
|
|
58
|
+
} else if (char === '>' && this.text[this.pos + 1] === '=') {
|
|
59
|
+
this.tokens.push({type: 'OP', value: '>='});
|
|
60
|
+
this.pos += 2;
|
|
61
|
+
} else if (char === '<' && this.text[this.pos + 1] === '=') {
|
|
62
|
+
this.tokens.push({type: 'OP', value: '<='});
|
|
63
|
+
this.pos += 2;
|
|
64
|
+
} else if ('=!<>'.includes(char)) {
|
|
65
|
+
this.tokens.push({type: 'OP', value: char});
|
|
66
|
+
this.pos++;
|
|
67
|
+
} else {
|
|
68
|
+
throw new Error(`无效操作符 at position ${start}`);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
readString(quote) {
|
|
73
|
+
this.pos++;
|
|
74
|
+
const start = this.pos;
|
|
75
|
+
while (this.pos < this.text.length && this.text[this.pos] !== quote) {
|
|
76
|
+
this.pos++;
|
|
77
|
+
}
|
|
78
|
+
if (this.pos >= this.text.length) {
|
|
79
|
+
throw new Error(`未闭合的字符串 at position ${start - 1}`);
|
|
80
|
+
}
|
|
81
|
+
const value = this.text.substring(start, this.pos);
|
|
82
|
+
this.tokens.push({type: 'STRING', value});
|
|
83
|
+
this.pos++;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
readNumber() {
|
|
87
|
+
const start = this.pos;
|
|
88
|
+
while (this.pos < this.text.length && /[\d.]/.test(this.text[this.pos])) {
|
|
89
|
+
this.pos++;
|
|
90
|
+
}
|
|
91
|
+
const value = parseFloat(this.text.substring(start, this.pos));
|
|
92
|
+
this.tokens.push({type: 'NUMBER', value});
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
readWord() {
|
|
96
|
+
const start = this.pos;
|
|
97
|
+
while (this.pos < this.text.length && /[a-zA-Z0-9_]/.test(this.text[this.pos])) {
|
|
98
|
+
this.pos++;
|
|
99
|
+
}
|
|
100
|
+
const value = this.text.substring(start, this.pos);
|
|
101
|
+
|
|
102
|
+
const upper = value.toUpperCase();
|
|
103
|
+
if (upper === 'AND') {
|
|
104
|
+
this.tokens.push({type: 'AND', value: upper});
|
|
105
|
+
} else if (upper === 'OR') {
|
|
106
|
+
this.tokens.push({type: 'OR', value: upper});
|
|
107
|
+
} else if (upper === 'IN') {
|
|
108
|
+
this.tokens.push({type: 'IN', value: upper});
|
|
109
|
+
} else if (upper === 'BETWEEN') {
|
|
110
|
+
this.tokens.push({type: 'BETWEEN', value: upper});
|
|
111
|
+
} else {
|
|
112
|
+
this.tokens.push({type: 'IDENT', value});
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
class Parser {
|
|
118
|
+
constructor(tokens, fieldMap = {}) {
|
|
119
|
+
this.tokens = tokens;
|
|
120
|
+
this.pos = 0;
|
|
121
|
+
this.fieldMap = fieldMap;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
parse() {
|
|
125
|
+
if (this.tokens.length === 0) {
|
|
126
|
+
return {error: '空条件'};
|
|
127
|
+
}
|
|
128
|
+
const result = this.parseExpression();
|
|
129
|
+
if (this.pos < this.tokens.length) {
|
|
130
|
+
return {error: `未解析的 token: ${JSON.stringify(this.tokens[this.pos])}`};
|
|
131
|
+
}
|
|
132
|
+
return result;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
parseExpression() {
|
|
136
|
+
return this.parseOr();
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
parseOr() {
|
|
140
|
+
let left = this.parseAnd();
|
|
141
|
+
|
|
142
|
+
while (this.match('OR')) {
|
|
143
|
+
const right = this.parseAnd();
|
|
144
|
+
if (left.type === 'or') {
|
|
145
|
+
left.conditions.push(right);
|
|
146
|
+
} else {
|
|
147
|
+
left = {type: 'or', conditions: [left, right]};
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
return left;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
parseAnd() {
|
|
155
|
+
let left = this.parseFactor();
|
|
156
|
+
|
|
157
|
+
while (this.match('AND')) {
|
|
158
|
+
const right = this.parseFactor();
|
|
159
|
+
if (left.type === 'and') {
|
|
160
|
+
left.conditions.push(right);
|
|
161
|
+
} else {
|
|
162
|
+
left = {type: 'and', conditions: [left, right]};
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
return left;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
parseFactor() {
|
|
170
|
+
if (this.match('LPAREN')) {
|
|
171
|
+
const expr = this.parseExpression();
|
|
172
|
+
if (!this.match('RPAREN')) {
|
|
173
|
+
return {error: '缺少右括号 )'};
|
|
174
|
+
}
|
|
175
|
+
return expr;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
return this.parseCondition();
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
parseCondition() {
|
|
182
|
+
const fieldToken = this.expect('IDENT');
|
|
183
|
+
if (fieldToken.error) return fieldToken;
|
|
184
|
+
|
|
185
|
+
const field = fieldToken.value;
|
|
186
|
+
|
|
187
|
+
if (this.match('IN')) {
|
|
188
|
+
if (this.match('LPAREN')) {
|
|
189
|
+
const values = [];
|
|
190
|
+
do {
|
|
191
|
+
const valueToken = this.tokens[this.pos];
|
|
192
|
+
if (valueToken.type === 'STRING' || valueToken.type === 'NUMBER') {
|
|
193
|
+
values.push(valueToken.value);
|
|
194
|
+
this.pos++;
|
|
195
|
+
} else if (valueToken.type === 'IDENT') {
|
|
196
|
+
values.push(valueToken.value);
|
|
197
|
+
this.pos++;
|
|
198
|
+
} else {
|
|
199
|
+
return {error: 'IN 列表中无效的值'};
|
|
200
|
+
}
|
|
201
|
+
} while (this.match('COMMA'));
|
|
202
|
+
|
|
203
|
+
if (!this.match('RPAREN')) {
|
|
204
|
+
return {error: 'IN 列表缺少 )'};
|
|
205
|
+
}
|
|
206
|
+
return {type: 'condition', field, op: 'IN', value: values};
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
if (this.match('LBRACKET')) {
|
|
210
|
+
const minToken = this.tokens[this.pos];
|
|
211
|
+
if (minToken.type !== 'NUMBER' && minToken.type !== 'IDENT') {
|
|
212
|
+
return {error: '区间下限无效'};
|
|
213
|
+
}
|
|
214
|
+
const min = minToken.value;
|
|
215
|
+
this.pos++;
|
|
216
|
+
|
|
217
|
+
if (!this.match('COMMA')) {
|
|
218
|
+
return {error: '区间缺少逗号'};
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
const maxToken = this.tokens[this.pos];
|
|
222
|
+
if (maxToken.type !== 'NUMBER' && maxToken.type !== 'IDENT') {
|
|
223
|
+
return {error: '区间上限无效'};
|
|
224
|
+
}
|
|
225
|
+
const max = maxToken.value;
|
|
226
|
+
this.pos++;
|
|
227
|
+
|
|
228
|
+
if (!this.match('RBRACKET')) {
|
|
229
|
+
return {error: '区间缺少 ]'};
|
|
230
|
+
}
|
|
231
|
+
return {type: 'condition', field, op: 'BETWEEN', value: [min, max]};
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
return {error: 'IN 后缺少 ( 或 ['};
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
const opToken = this.expect('OP');
|
|
238
|
+
if (opToken.error) return opToken;
|
|
239
|
+
|
|
240
|
+
const op = opToken.value;
|
|
241
|
+
const valueToken = this.tokens[this.pos];
|
|
242
|
+
|
|
243
|
+
if (!valueToken) {
|
|
244
|
+
return {error: '缺少值'};
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
if (valueToken.type === 'STRING') {
|
|
248
|
+
this.pos++;
|
|
249
|
+
return {type: 'condition', field, op, value: valueToken.value};
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
if (valueToken.type === 'NUMBER') {
|
|
253
|
+
this.pos++;
|
|
254
|
+
return {type: 'condition', field, op, value: valueToken.value};
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
if (valueToken.type === 'IDENT') {
|
|
258
|
+
this.pos++;
|
|
259
|
+
if (this.fieldMap[valueToken.value]) {
|
|
260
|
+
return {type: 'condition', field, op: 'FIELD_CMP', cmpOp: op, value: valueToken.value};
|
|
261
|
+
}
|
|
262
|
+
return {type: 'condition', field, op, value: valueToken.value};
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
return {error: `无效的值: ${JSON.stringify(valueToken)}`};
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
match(type) {
|
|
269
|
+
if (this.pos < this.tokens.length && this.tokens[this.pos].type === type) {
|
|
270
|
+
this.pos++;
|
|
271
|
+
return true;
|
|
272
|
+
}
|
|
273
|
+
return false;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
expect(type) {
|
|
277
|
+
if (this.pos < this.tokens.length && this.tokens[this.pos].type === type) {
|
|
278
|
+
const token = this.tokens[this.pos];
|
|
279
|
+
this.pos++;
|
|
280
|
+
return token;
|
|
281
|
+
}
|
|
282
|
+
return {error: `期望 ${type},但得到 ${this.tokens[this.pos] ? this.tokens[this.pos].type : 'EOF'}`};
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
function parseSql(text, fieldMap = {}) {
|
|
287
|
+
text = text.trim();
|
|
288
|
+
if (!text) return {error: '请输入查询条件'};
|
|
289
|
+
|
|
290
|
+
text = text.replace(/^\s*SELECT\s+.*?\s+FROM\s+stocks\s*/i, '');
|
|
291
|
+
text = text.replace(/^\s*SELECT\s+\*\s+FROM\s+stocks\s*/i, '');
|
|
292
|
+
text = text.replace(/^\s*FROM\s+stocks\s*/i, '');
|
|
293
|
+
|
|
294
|
+
let whereText = '';
|
|
295
|
+
let orderByText = '';
|
|
296
|
+
let limitText = '';
|
|
297
|
+
|
|
298
|
+
const limitMatch = text.match(/\bLIMIT\s+(\d+)\s*$/i);
|
|
299
|
+
if (limitMatch) {
|
|
300
|
+
limitText = limitMatch[1];
|
|
301
|
+
text = text.substring(0, limitMatch.index).trim();
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
const orderMatch = text.match(/\bORDER\s+BY\s+(\w+)(?:\s+(ASC|DESC))?\s*$/i);
|
|
305
|
+
if (orderMatch) {
|
|
306
|
+
orderByText = orderMatch[0];
|
|
307
|
+
text = text.substring(0, orderMatch.index).trim();
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
const whereMatch = text.match(/^\s*WHERE\s+/i);
|
|
311
|
+
if (whereMatch) {
|
|
312
|
+
whereText = text.substring(whereMatch[0].length).trim();
|
|
313
|
+
} else {
|
|
314
|
+
whereText = text;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
if (!whereText) return {error: '请输入 WHERE 条件'};
|
|
318
|
+
|
|
319
|
+
try {
|
|
320
|
+
const tokenizer = new Tokenizer(whereText);
|
|
321
|
+
const tokens = tokenizer.tokenize();
|
|
322
|
+
const parser = new Parser(tokens, fieldMap);
|
|
323
|
+
const parseResult = parser.parse();
|
|
324
|
+
|
|
325
|
+
if (parseResult.error) return parseResult;
|
|
326
|
+
|
|
327
|
+
const result = {conditions: parseResult};
|
|
328
|
+
|
|
329
|
+
if (orderMatch) {
|
|
330
|
+
result.orderBy = {
|
|
331
|
+
field: orderMatch[1],
|
|
332
|
+
dir: (orderMatch[2] || 'DESC').toUpperCase()
|
|
333
|
+
};
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
if (limitText) {
|
|
337
|
+
const n = parseInt(limitText, 10);
|
|
338
|
+
if (n < 1 || n > 100) return {error: 'LIMIT 必须在 1~100 之间'};
|
|
339
|
+
result.limit = n;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
return result;
|
|
343
|
+
} catch (err) {
|
|
344
|
+
return {error: err.message};
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
module.exports = {Tokenizer, Parser, parseSql};
|