init
This commit is contained in:
2
.gitignore
vendored
Normal file
2
.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
node_modules
|
||||
dist
|
||||
2
.npmrc
Normal file
2
.npmrc
Normal file
@@ -0,0 +1,2 @@
|
||||
//npm.xiongxiao.me/:_authToken=${ME_NPM_TOKEN}
|
||||
//registry.npmjs.org/:_authToken=${NPM_TOKEN}
|
||||
69
README.md
Normal file
69
README.md
Normal 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
31
package.json
Normal 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
381
src/index.ts
Normal 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
55
test/example.ts
Normal 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
17
tsconfig.json
Normal 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"]
|
||||
}
|
||||
Reference in New Issue
Block a user