This commit is contained in:
2025-12-29 15:30:30 +08:00
commit 6ba2e23a77
7 changed files with 557 additions and 0 deletions

2
.gitignore vendored Normal file
View File

@@ -0,0 +1,2 @@
node_modules
dist

2
.npmrc Normal file
View File

@@ -0,0 +1,2 @@
//npm.xiongxiao.me/:_authToken=${ME_NPM_TOKEN}
//registry.npmjs.org/:_authToken=${NPM_TOKEN}

69
README.md Normal file
View File

@@ -0,0 +1,69 @@
# JS Filter
轻量、易读、可解析、可执行的类 SQL 过滤语法,用于 JavaScript 数组过滤。
## 语法特性
- **WHERE**: 条件过滤
- **ORDER BY**: 排序
- **LIMIT**: 限制结果数量
- **操作符**: `=`, `!=`, `>`, `<`, `>=`, `<=`, `IN`, `CONTAINS`
- **逻辑**: `AND`, `OR`
## 语法示例
```javascript
// 基础过滤
filter(users, "WHERE metadata.type = 'user'");
// 多条件
filter(users, "WHERE metadata.tags CONTAINS 'premium' AND metadata.region = 'beijing'");
// 排序
filter(users, "WHERE metadata.type = 'user' ORDER BY metadata.created_at DESC");
// 限制数量
filter(users, "WHERE metadata.type = 'user' ORDER BY metadata.created_at DESC LIMIT 10");
// IN 操作
filter(users, "WHERE metadata.region IN ['beijing', 'shanghai']");
```
## 使用方法
```javascript
import { filter } from '@kevisual/js-filter';
const users = [
{
metadata: {
tags: ['premium', 'active'],
type: 'user',
region: 'beijing',
created_at: '2024-03-15T10:00:00Z',
},
},
{
metadata: {
tags: ['free'],
type: 'admin',
region: 'shanghai',
created_at: '2024-01-10T09:00:00Z',
},
},
{
metadata: {
tags: ['enterprise', 'premium'],
type: 'user',
region: 'guangzhou',
created_at: '2024-04-05T12:00:00Z',
},
},
];
// 查找 premium 用户
const premiumUsers = filter(users, "WHERE metadata.tags CONTAINS 'premium'");
// 查找北京的用户,按创建时间倒序
const beijingUsers = filter(users, "WHERE metadata.region = 'beijing' ORDER BY metadata.created_at DESC");
```

31
package.json Normal file
View File

@@ -0,0 +1,31 @@
{
"name": "@kevisual/js-filter",
"version": "0.0.1",
"description": "sql like filter for js arrays",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"scripts": {
"build": "tsc"
},
"keywords": [],
"author": "abearxiong <xiongxiao@xiongxiao.me> (https://www.xiongxiao.me)",
"license": "MIT",
"packageManager": "pnpm@10.26.0",
"type": "module",
"exports": {
".": {
"import": "./dist/index.js",
"types": "./dist/index.d.ts"
}
},
"files": [
"dist",
"src"
],
"devDependencies": {
"typescript": "^5.0.0"
},
"publishConfig": {
"access": "public"
}
}

381
src/index.ts Normal file
View File

@@ -0,0 +1,381 @@
type Token = {
type: 'WHERE' | 'AND' | 'OR' | 'ORDER' | 'BY' | 'ASC' | 'DESC' | 'LIMIT'
| 'EQ' | 'NEQ' | 'GT' | 'LT' | 'GTE' | 'LTE' | 'IN' | 'CONTAINS'
| 'IDENTIFIER' | 'STRING' | 'NUMBER' | 'COMMA' | 'LBRACKET' | 'RBRACKET'
| 'EOF';
value: string;
pos: number;
};
type ASTNode = {
type: 'WhereClause' | 'Condition' | 'OrderClause' | 'LimitClause' | 'LogicalOp'
| 'Field' | 'Value' | 'InList';
left?: ASTNode;
right?: ASTNode;
field?: string;
operator?: string;
value?: any;
values?: any[];
items?: ASTNode[];
};
class Lexer {
private pos = 0;
private input: string;
constructor(input: string) {
this.input = input.trim();
}
private skipWhitespace(): void {
while (this.pos < this.input.length && /\s/.test(this.input[this.pos])) {
this.pos++;
}
}
private readString(): string {
this.pos++;
let result = '';
while (this.pos < this.input.length && this.input[this.pos] !== "'") {
result += this.input[this.pos];
this.pos++;
}
this.pos++;
return result;
}
private readNumber(): string {
let result = '';
while (this.pos < this.input.length && /[\d.]/.test(this.input[this.pos])) {
result += this.input[this.pos];
this.pos++;
}
return result;
}
private readIdentifier(): string {
let result = '';
while (this.pos < this.input.length && /[\w.]/.test(this.input[this.pos])) {
result += this.input[this.pos];
this.pos++;
}
return result;
}
private isKeyword(value: string): boolean {
const keywords = ['WHERE', 'AND', 'OR', 'ORDER', 'BY', 'ASC', 'DESC', 'LIMIT', 'IN', 'CONTAINS'];
return keywords.includes(value.toUpperCase());
}
nextToken(): Token {
this.skipWhitespace();
if (this.pos >= this.input.length) {
return { type: 'EOF', value: '', pos: this.pos };
}
const char = this.input[this.pos];
if (char === "'") {
return { type: 'STRING', value: this.readString(), pos: this.pos };
}
if (/\d/.test(char)) {
return { type: 'NUMBER', value: this.readNumber(), pos: this.pos };
}
if (char === ',') {
this.pos++;
return { type: 'COMMA', value: ',', pos: this.pos };
}
if (char === '[') {
this.pos++;
return { type: 'LBRACKET', value: '[', pos: this.pos };
}
if (char === ']') {
this.pos++;
return { type: 'RBRACKET', value: ']', pos: this.pos };
}
if (/[=<>!]/.test(char)) {
this.pos++;
if (char === '=' && this.pos < this.input.length && this.input[this.pos] === '=') {
this.pos++;
return { type: 'EQ', value: '==', pos: this.pos };
}
if (char === '!' && this.pos < this.input.length && this.input[this.pos] === '=') {
this.pos++;
return { type: 'NEQ', value: '!=', pos: this.pos };
}
if (char === '>') {
if (this.pos < this.input.length && this.input[this.pos] === '=') {
this.pos++;
return { type: 'GTE', value: '>=', pos: this.pos };
}
return { type: 'GT', value: '>', pos: this.pos };
}
if (char === '<') {
if (this.pos < this.input.length && this.input[this.pos] === '=') {
this.pos++;
return { type: 'LTE', value: '<=', pos: this.pos };
}
return { type: 'LT', value: '<', pos: this.pos };
}
return { type: 'EQ', value: char, pos: this.pos };
}
const identifier = this.readIdentifier();
const upperIdentifier = identifier.toUpperCase();
const keywords: Record<string, Token['type']> = {
'WHERE': 'WHERE',
'AND': 'AND',
'OR': 'OR',
'ORDER': 'ORDER',
'BY': 'BY',
'ASC': 'ASC',
'DESC': 'DESC',
'LIMIT': 'LIMIT',
'IN': 'IN',
'CONTAINS': 'CONTAINS'
};
if (keywords[upperIdentifier]) {
return { type: keywords[upperIdentifier], value: upperIdentifier, pos: this.pos };
}
return { type: 'IDENTIFIER', value: identifier, pos: this.pos };
}
}
class Parser {
private lexer: Lexer;
private currentToken: Token;
constructor(lexer: Lexer) {
this.lexer = lexer;
this.currentToken = this.lexer.nextToken();
}
private eat(type: Token['type']): void {
if (this.currentToken.type === type) {
this.currentToken = this.lexer.nextToken();
} else {
throw new Error(`Expected ${type}, got ${this.currentToken.type}`);
}
}
parse(): ASTNode[] {
const statements: ASTNode[] = [];
if (this.currentToken.type === 'WHERE') {
statements.push(this.parseWhere());
}
if (this.currentToken.type === 'ORDER') {
statements.push(this.parseOrder());
}
if (this.currentToken.type === 'LIMIT') {
statements.push(this.parseLimit());
}
return statements;
}
private parseWhere(): ASTNode {
this.eat('WHERE');
return { type: 'WhereClause', left: this.parseCondition() };
}
private parseCondition(): ASTNode {
let left = this.parseExpression();
while (this.currentToken.type === 'AND' || this.currentToken.type === 'OR') {
const op = this.currentToken.type;
this.eat(op);
const right = this.parseExpression();
left = { type: 'LogicalOp', left, right, operator: op };
}
return left;
}
private parseExpression(): ASTNode {
const field = this.currentToken.value;
this.eat('IDENTIFIER');
let operator: string;
let value: any;
if (this.currentToken.type === 'CONTAINS') {
operator = 'CONTAINS';
this.eat('CONTAINS');
value = this.parseValue();
} else if (this.currentToken.type === 'IN') {
operator = 'IN';
this.eat('IN');
value = this.parseInList();
} else {
const opType = this.currentToken.type;
this.eat(opType);
operator = opType;
value = this.parseValue();
}
return { type: 'Condition', field, operator, value };
}
private parseInList(): any[] {
this.eat('LBRACKET');
const values: any[] = [];
while (this.currentToken.type !== 'RBRACKET') {
const node = this.parseValue();
values.push(node.value);
if (this.currentToken.type === 'COMMA') {
this.eat('COMMA');
}
}
this.eat('RBRACKET');
return values;
}
private parseValue(): ASTNode {
if (this.currentToken.type === 'STRING') {
const value = this.currentToken.value;
this.eat('STRING');
return { type: 'Value', value };
}
if (this.currentToken.type === 'NUMBER') {
const value = parseFloat(this.currentToken.value);
this.eat('NUMBER');
return { type: 'Value', value };
}
throw new Error(`Expected value, got ${this.currentToken.type}`);
}
private parseOrder(): ASTNode {
this.eat('ORDER');
this.eat('BY');
const field = this.currentToken.value;
this.eat('IDENTIFIER');
let direction = 'ASC';
if (this.currentToken.type === 'ASC' || this.currentToken.type === 'DESC') {
direction = this.currentToken.value;
this.eat(this.currentToken.type);
}
return { type: 'OrderClause', field, value: direction };
}
private parseLimit(): ASTNode {
this.eat('LIMIT');
const value = parseFloat(this.currentToken.value);
this.eat('NUMBER');
return { type: 'LimitClause', value };
}
}
class Executor {
private getValueByPath(obj: any, path: string): any {
return path.split('.').reduce((acc, part) => acc?.[part], obj);
}
evaluateCondition(node: ASTNode, item: any): boolean {
if (node.type === 'Condition') {
const fieldValue = this.getValueByPath(item, node.field!);
const { operator, value } = node;
const actualValue = value?.type === 'Value' ? value.value : value;
switch (operator) {
case 'EQ':
return fieldValue === actualValue;
case 'NEQ':
return fieldValue !== actualValue;
case 'GT':
return fieldValue > actualValue;
case 'LT':
return fieldValue < actualValue;
case 'GTE':
return fieldValue >= actualValue;
case 'LTE':
return fieldValue <= actualValue;
case 'IN':
return Array.isArray(actualValue) && actualValue.includes(fieldValue);
case 'CONTAINS':
return Array.isArray(fieldValue) && fieldValue.includes(actualValue);
default:
return false;
}
}
if (node.type === 'LogicalOp') {
const leftResult = this.evaluateCondition(node.left!, item);
const rightResult = this.evaluateCondition(node.right!, item);
return node.operator === 'AND'
? leftResult && rightResult
: leftResult || rightResult;
}
return true;
}
execute(statements: ASTNode[], data: any[]): any[] {
let result = [...data];
for (const stmt of statements) {
if (stmt.type === 'WhereClause') {
result = result.filter(item => this.evaluateCondition(stmt.left!, item));
} else if (stmt.type === 'OrderClause') {
const field = stmt.field!;
const direction = stmt.value;
result.sort((a, b) => {
const aVal = this.getValueByPath(a, field);
const bVal = this.getValueByPath(b, field);
if (aVal === bVal) return 0;
const cmp = aVal > bVal ? 1 : -1;
return direction === 'ASC' ? cmp : -cmp;
});
} else if (stmt.type === 'LimitClause') {
result = result.slice(0, stmt.value);
}
}
return result;
}
}
export function createFilter(query: string): (item: any) => boolean {
const lexer = new Lexer(query);
const parser = new Parser(lexer);
const ast = parser.parse();
const executor = new Executor();
const whereClause = ast.find(stmt => stmt.type === 'WhereClause');
if (!whereClause) {
return () => true;
}
return (item: any) => executor.evaluateCondition(whereClause.left!, item);
}
export function filter(data: any[], query: string): any[] {
const lexer = new Lexer(query);
const parser = new Parser(lexer);
const ast = parser.parse();
const executor = new Executor();
return executor.execute(ast, data);
}
export function parse(query: string): ASTNode[] {
const lexer = new Lexer(query);
const parser = new Parser(lexer);
return parser.parse();
}

55
test/example.ts Normal file
View File

@@ -0,0 +1,55 @@
import { filter } from '../src/index.ts';
const users = [
{
metadata: {
tags: ['premium', 'active'],
type: 'user',
region: 'beijing',
created_at: '2024-03-15T10:00:00Z'
}
},
{
metadata: {
tags: ['free'],
type: 'admin',
region: 'shanghai',
created_at: '2024-01-10T09:00:00Z'
}
},
{
metadata: {
tags: ['enterprise', 'premium'],
type: 'user',
region: 'guangzhou',
created_at: '2024-04-05T12:00:00Z'
}
}
];
console.log('=== 方式1: users.filter(createFilter("...")) ===');
console.log('\n=== premium 用户 ===');
console.log(filter(users, "WHERE metadata.tags CONTAINS 'premium'"));
console.log('\n=== 北京用户 ===');
console.log(filter(users, "WHERE metadata.region = 'beijing'"));
console.log('\n=== user 类型用户 ===');
console.log(filter(users, "WHERE metadata.type = 'user'"));
console.log('\n=== premium 且 user 类型 ===');
console.log(filter(users, "WHERE metadata.tags CONTAINS 'premium' AND metadata.type = 'user'"));
console.log('\n=== 北京或上海用户 ===');
console.log(filter(users, "WHERE metadata.region IN ['beijing', 'shanghai']"));
console.log('\n=== 方式2: filter(users, "...") (支持 ORDER BY 和 LIMIT) ===');
console.log('\n=== 北京用户,按时间倒序 ===');
console.log(filter(users, "WHERE metadata.region = 'beijing' ORDER BY metadata.created_at DESC"));
console.log('\n=== 前2个用户按时间倒序 ===');
const limitResult = filter(users, "ORDER BY metadata.created_at DESC LIMIT 2");
console.log('Limit result length:', limitResult.length);
console.log(limitResult);

17
tsconfig.json Normal file
View File

@@ -0,0 +1,17 @@
{
"compilerOptions": {
"target": "ES2020",
"module": "ES2020",
"moduleResolution": "node",
"lib": ["ES2020"],
"declaration": true,
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}