commit 6ba2e23a77a401e570b7c8a71bf79d82b5bd9fe1 Author: abearxiong Date: Mon Dec 29 15:30:30 2025 +0800 init diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..76add87 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +node_modules +dist \ No newline at end of file diff --git a/.npmrc b/.npmrc new file mode 100644 index 0000000..a5aa07b --- /dev/null +++ b/.npmrc @@ -0,0 +1,2 @@ +//npm.xiongxiao.me/:_authToken=${ME_NPM_TOKEN} +//registry.npmjs.org/:_authToken=${NPM_TOKEN} diff --git a/README.md b/README.md new file mode 100644 index 0000000..79f4c5e --- /dev/null +++ b/README.md @@ -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"); +``` diff --git a/package.json b/package.json new file mode 100644 index 0000000..30aabce --- /dev/null +++ b/package.json @@ -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 (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" + } +} \ No newline at end of file diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..6ea02e7 --- /dev/null +++ b/src/index.ts @@ -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 = { + '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(); +} + + diff --git a/test/example.ts b/test/example.ts new file mode 100644 index 0000000..68526ee --- /dev/null +++ b/test/example.ts @@ -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); diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..f8054ee --- /dev/null +++ b/tsconfig.json @@ -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"] +}