diff --git a/packages/models/src/repositories/abstract.ts b/packages/models/src/repositories/abstract.ts index a0c496151..2661206e1 100644 --- a/packages/models/src/repositories/abstract.ts +++ b/packages/models/src/repositories/abstract.ts @@ -13,6 +13,18 @@ export abstract class AbstractRepository implements Reposi protected pks: string[], protected separator: string = "_" ) {} + + /** + * Get the list of allowed field names for this model from its JSON Schema metadata. + * Returns undefined if no schema is available (validation will be skipped). + */ + getAllowedFields(): string[] | undefined { + const schema = (this.model as any).Metadata?.Schema; + if (!schema?.properties) { + return undefined; + } + return Object.keys(schema.properties); + } /** * @inheritdoc */ diff --git a/packages/models/src/repositories/memory.ts b/packages/models/src/repositories/memory.ts index 481c397e2..d3ed3b272 100644 --- a/packages/models/src/repositories/memory.ts +++ b/packages/models/src/repositories/memory.ts @@ -12,6 +12,9 @@ export type Query = { limit: number; orderBy?: { field: string; direction: "ASC" | "DESC" }[]; continuationToken?: string; + type?: "DELETE" | "UPDATE" | "SELECT"; + fields?: string[]; + assignments?: { field: string; value: any }[]; filter: { eval: (item: any) => boolean; }; @@ -19,7 +22,7 @@ export type Query = { // Lazy load WebdaQL let WebdaQL: any & { - parse: (query: string) => Query; + parse: (query: string, allowedFields?: string[]) => Query; } = null; export type FindResult = { @@ -151,7 +154,7 @@ export class MemoryRepository< if (typeof query === "string") { try { WebdaQL ??= await import("@webda/ql"); - query = WebdaQL.parse(query); + query = WebdaQL.parse(query, this.getAllowedFields()); } catch (error) { throw new Error(`Failed to parse query: ${error} - @webda/ql peer dependencies may be missing`); } @@ -240,7 +243,7 @@ export class MemoryRepository< let q: Query; try { WebdaQL ??= await import("@webda/ql"); - q = WebdaQL.parse(query); // Ensure it is valid + q = WebdaQL.parse(query, this.getAllowedFields()); // Ensure it is valid if (!q.limit) { q.limit = 100; // Default pagination size } diff --git a/packages/ql-ts-plugin/README.md b/packages/ql-ts-plugin/README.md new file mode 100644 index 000000000..6bf12395d --- /dev/null +++ b/packages/ql-ts-plugin/README.md @@ -0,0 +1,84 @@ +# @webda/ql-ts-plugin + +TypeScript language service plugin for [WebdaQL](../ql/README.md). Validates field names in query strings against your model types and provides autocompletion — all inside your IDE. + +## Setup + +Install the plugin: + +```bash +npm install -D @webda/ql-ts-plugin +``` + +Add it to your `tsconfig.json`: + +```json +{ + "compilerOptions": { + "plugins": [{ "name": "@webda/ql-ts-plugin" }] + } +} +``` + +> **VSCode users:** Make sure you're using the workspace TypeScript version (`TypeScript: Select TypeScript Version` → `Use Workspace Version`), as plugins only run in the language service, not in `tsc`. + +## Features + +### Field validation + +The plugin detects calls to `repo.query()`, `repo.iterate()`, and `parse()` with string literal arguments. It parses the WebdaQL query and checks that SELECT fields and UPDATE SET targets exist on the model type. + +```ts +interface User { + name: string; + age: number; + status: string; + profile: { bio: string; avatar: string }; +} + +const repo: MemoryRepository; + +repo.query("name, age WHERE status = 'active'"); // ✅ +repo.query("name, oops WHERE status = 'active'"); // ❌ Unknown field "oops" in SELECT +repo.query("UPDATE SET role = 'admin' WHERE id = 1"); // ❌ Unknown assignment field "role" in UPDATE SET +repo.query("DELETE WHERE status = 'old'"); // ✅ (DELETE has no field projection) +``` + +Nested dot-notation fields are supported: + +```ts +repo.query("name, profile.bio WHERE status = 'active'"); // ✅ +repo.query("name, profile.secret WHERE status = 'active'"); // ❌ Unknown field "profile.secret" +``` + +### Autocompletion + +When your cursor is inside a query string in a field-list position (after `SELECT`, `SET`, or at the start of an implicit field list), the plugin suggests model property names. + +```ts +repo.query("name, | WHERE status = 'active'"); +// ^ autocomplete: age, status, profile.bio, profile.avatar +``` + +## How it works + +1. **Intercepts** calls to `.query()`, `.iterate()`, or `parse()` where the first argument is a string literal +2. **Resolves** the model type from the repository's generic parameter (via the return type of `.get()`) +3. **Parses** the query string with a lightweight field extractor (no ANTLR dependency) +4. **Reports** diagnostics if SELECT fields or UPDATE SET targets are not valid property names +5. **Offers** completion entries when the cursor is in a field-list context + +## Supported call patterns + +| Pattern | Field resolution | +|---------|-----------------| +| `repo.query("...")` | Model type from `Repository` generic | +| `repo.iterate("...")` | Model type from `Repository` generic | +| `parse("...", ["name", "age"])` | Allowed fields from the literal array argument | + +## Limitations + +- Only works with **string literals** — dynamic query strings (`repo.query(variable)`) cannot be checked +- Runs in the **language service only** (IDE), not during `tsc` builds +- Filter-level field references (e.g. `WHERE unknownField = 1`) are not yet validated — only SELECT and UPDATE SET targets +- ANTLR-level keywords (`AND`, `OR`, `LIKE`, `IN`, `CONTAINS`) must still be uppercase diff --git a/packages/ql-ts-plugin/package.json b/packages/ql-ts-plugin/package.json new file mode 100644 index 000000000..49ae7e932 --- /dev/null +++ b/packages/ql-ts-plugin/package.json @@ -0,0 +1,33 @@ +{ + "name": "@webda/ql-ts-plugin", + "version": "4.0.0-beta.1", + "description": "TypeScript language service plugin for WebdaQL — validates field names and provides autocompletion inside query strings", + "main": "lib/index.js", + "types": "lib/index.d.ts", + "type": "module", + "scripts": { + "build": "tsc", + "test": "vitest run" + }, + "keywords": [ + "typescript", + "plugin", + "webda", + "webdaql", + "language-service" + ], + "license": "LGPL-3.0-only", + "devDependencies": { + "typescript": "~5.8.0", + "vitest": "^3.0.0" + }, + "peerDependencies": { + "typescript": ">=5.0.0" + }, + "engines": { + "node": ">=22.0.0" + }, + "files": [ + "lib" + ] +} diff --git a/packages/ql-ts-plugin/src/index.ts b/packages/ql-ts-plugin/src/index.ts new file mode 100644 index 000000000..4cb0e3e5f --- /dev/null +++ b/packages/ql-ts-plugin/src/index.ts @@ -0,0 +1,339 @@ +/** + * @webda/ql-ts-plugin + * + * TypeScript language service plugin that validates WebdaQL query field names + * against model types and provides autocompletion inside query strings. + * + * Detects calls to: + * - repo.query("...") — repository query method + * - parse("...", ...) — WebdaQL parse function + * + * Resolves the model type from the repository generic parameter and checks + * that SELECT fields and UPDATE SET targets are valid property names. + * + * Setup in tsconfig.json: + * { + * "compilerOptions": { + * "plugins": [{ "name": "@webda/ql-ts-plugin" }] + * } + * } + */ + +import type tslib from "typescript/lib/tsserverlibrary"; +import { extractFields } from "./parser.js"; + +/** Target method names to intercept */ +const QUERY_METHODS = new Set(["query", "iterate"]); +const PARSE_FUNCTIONS = new Set(["parse"]); + +/** Custom diagnostic code */ +const DIAG_CODE = 99001; + +function init(modules: { typescript: typeof tslib }) { + const ts = modules.typescript; + + function create(info: tslib.server.PluginCreateInfo): tslib.LanguageService { + const langSvc = info.languageService; + + // ─── Helpers ─────────────────────────────────────────── + + /** + * Get the method name from a call expression + */ + function getCallName(node: tslib.CallExpression): string | undefined { + const expr = node.expression; + if (ts.isPropertyAccessExpression(expr)) { + return expr.name.text; + } + if (ts.isIdentifier(expr)) { + return expr.text; + } + return undefined; + } + + /** + * Recursively collect all property names from a type (top-level + nested with dot notation) + */ + function collectPropertyPaths(checker: tslib.TypeChecker, type: tslib.Type, prefix: string = "", depth: number = 0): string[] { + if (depth > 3) return []; // prevent infinite recursion + const paths: string[] = []; + for (const prop of type.getProperties()) { + const name = prefix ? `${prefix}.${prop.name}` : prop.name; + // Skip internal symbols + if (prop.name.startsWith("__") || prop.name.startsWith("_webda")) continue; + paths.push(name); + // Recurse into object types for dot-notation support + if (depth < 3) { + const propType = checker.getTypeOfSymbolAtLocation(prop, prop.valueDeclaration || prop.declarations![0]); + // Only recurse into plain object types (not arrays, primitives, etc.) + if (propType.getProperties().length > 0 && !(propType.flags & ts.TypeFlags.StringLike) && !checker.isArrayType(propType)) { + paths.push(...collectPropertyPaths(checker, propType, name, depth + 1)); + } + } + } + return paths; + } + + /** + * Resolve model field names from a repository.query() call. + * Walks up: Repository → InstanceType → property names + */ + function resolveFieldsFromRepoCall(checker: tslib.TypeChecker, node: tslib.CallExpression): string[] | undefined { + const expr = node.expression; + if (!ts.isPropertyAccessExpression(expr)) return undefined; + + const objType = checker.getTypeAtLocation(expr.expression); + // Look for a generic type argument that represents the model + // Repository → we want to get the properties of InstanceType + + // Strategy: find the return type of .get() on this object — that gives us Helpers> + const getMethod = objType.getProperty("get"); + if (!getMethod) return undefined; + + const getType = checker.getTypeOfSymbolAtLocation(getMethod, expr); + const signatures = getType.getCallSignatures(); + if (!signatures.length) return undefined; + + let returnType = checker.getReturnTypeOfSignature(signatures[0]); + // Unwrap Promise + if (returnType.symbol?.name === "Promise") { + const typeArgs = (returnType as tslib.TypeReference).typeArguments; + if (typeArgs?.length) returnType = typeArgs[0]; + } + + const props = collectPropertyPaths(checker, returnType); + return props.length > 0 ? props : undefined; + } + + /** + * Resolve model fields from a parse() call with allowedFields param. + * If the second argument is a type we can read, use that. + * Otherwise, try to infer from context. + */ + function resolveFieldsFromParseCall(checker: tslib.TypeChecker, node: tslib.CallExpression): string[] | undefined { + // If there's a second argument (allowedFields), resolve its type + if (node.arguments.length >= 2) { + const arg = node.arguments[1]; + const type = checker.getTypeAtLocation(arg); + // If it's a tuple of string literals, extract them + if (checker.isArrayType(type)) { + const typeArgs = (type as tslib.TypeReference).typeArguments; + if (typeArgs?.length) { + const elementType = typeArgs[0]; + if (elementType.isUnion()) { + const fields = elementType.types + .filter((t): t is tslib.StringLiteralType => !!(t.flags & ts.TypeFlags.StringLiteral)) + .map(t => t.value); + if (fields.length > 0) return fields; + } + if (elementType.flags & ts.TypeFlags.StringLiteral) { + return [(elementType as tslib.StringLiteralType).value]; + } + } + } + } + return undefined; + } + + /** + * Find the position of a field name within the query string for precise error highlighting + */ + function findFieldPosition(query: string, field: string, startSearch: number = 0): number { + // Find the field as a whole word + const idx = query.indexOf(field, startSearch); + if (idx === -1) return 0; + return idx; + } + + // ─── Diagnostics ────────────────────────────────────── + + const proxy: tslib.LanguageService = Object.create(null); + + proxy.getSemanticDiagnostics = (fileName: string): tslib.Diagnostic[] => { + const prior = langSvc.getSemanticDiagnostics(fileName); + const program = langSvc.getProgram(); + if (!program) return prior; + const sourceFile = program.getSourceFile(fileName); + if (!sourceFile) return prior; + const checker = program.getTypeChecker(); + const extra: tslib.Diagnostic[] = []; + + function visit(node: tslib.Node) { + if (ts.isCallExpression(node) && node.arguments.length >= 1) { + const firstArg = node.arguments[0]; + if (!ts.isStringLiteral(firstArg) && !ts.isNoSubstitutionTemplateLiteral(firstArg)) { + ts.forEachChild(node, visit); + return; + } + + const queryText = firstArg.text; + const callName = getCallName(node); + let allowedFields: string[] | undefined; + + if (callName && QUERY_METHODS.has(callName)) { + allowedFields = resolveFieldsFromRepoCall(checker, node); + } else if (callName && PARSE_FUNCTIONS.has(callName)) { + allowedFields = resolveFieldsFromParseCall(checker, node); + } + + if (allowedFields) { + const parsed = extractFields(queryText); + const allowedSet = new Set(allowedFields); + + const fieldsToCheck = [ + ...(parsed.fields || []).map(f => ({ field: f, kind: "field" as const })), + ...(parsed.assignmentFields || []).map(f => ({ field: f, kind: "assignment" as const })) + ]; + + for (const { field, kind } of fieldsToCheck) { + if (!allowedSet.has(field)) { + const offset = findFieldPosition(queryText, field); + const messageText = + kind === "assignment" + ? `Unknown assignment field "${field}" in UPDATE SET. Allowed: ${allowedFields.join(", ")}` + : `Unknown field "${field}" in SELECT. Allowed: ${allowedFields.join(", ")}`; + + extra.push({ + file: sourceFile, + start: firstArg.getStart() + 1 + offset, // +1 for the opening quote + length: field.length, + messageText, + category: ts.DiagnosticCategory.Error, + code: DIAG_CODE + }); + } + } + } + } + ts.forEachChild(node, visit); + } + + visit(sourceFile); + return [...prior, ...extra]; + }; + + // ─── Completions ────────────────────────────────────── + + proxy.getCompletionsAtPosition = ( + fileName: string, + position: number, + options: tslib.GetCompletionsAtPositionOptions | undefined + ): tslib.WithMetadata | undefined => { + const prior = langSvc.getCompletionsAtPosition(fileName, position, options); + const program = langSvc.getProgram(); + if (!program) return prior; + const sourceFile = program.getSourceFile(fileName); + if (!sourceFile) return prior; + const checker = program.getTypeChecker(); + + // Check if cursor is inside a string argument to query()/parse() + const token = findTokenAtPosition(sourceFile, position); + if (!token || (!ts.isStringLiteral(token) && !ts.isNoSubstitutionTemplateLiteral(token))) { + return prior; + } + + const callExpr = findParentCallExpression(token); + if (!callExpr || callExpr.arguments[0] !== token) return prior; + + const callName = getCallName(callExpr); + let allowedFields: string[] | undefined; + + if (callName && QUERY_METHODS.has(callName)) { + allowedFields = resolveFieldsFromRepoCall(checker, callExpr); + } else if (callName && PARSE_FUNCTIONS.has(callName)) { + allowedFields = resolveFieldsFromParseCall(checker, callExpr); + } + + if (!allowedFields) return prior; + + // Determine context: are we in a SELECT field list or UPDATE SET? + const queryText = token.text; + const cursorOffset = position - token.getStart() - 1; // -1 for opening quote + const textBeforeCursor = queryText.substring(0, cursorOffset); + + // Offer field completions if cursor is in a field-list or SET position + const upperBefore = textBeforeCursor.toUpperCase().trimStart(); + const isInFieldContext = + upperBefore.startsWith("SELECT") || + upperBefore.includes("SET") || + isImplicitSelectContext(textBeforeCursor); + + if (!isInFieldContext) return prior; + + const fieldEntries: tslib.CompletionEntry[] = allowedFields.map(field => ({ + name: field, + kind: ts.ScriptElementKind.memberVariableElement, + sortText: "0" + field, // sort before other completions + insertText: field + })); + + if (prior) { + return { + ...prior, + entries: [...fieldEntries, ...prior.entries] + }; + } + + return { + isGlobalCompletion: false, + isMemberCompletion: false, + isNewIdentifierLocation: false, + entries: fieldEntries + }; + }; + + // ─── Utility ────────────────────────────────────────── + + function findTokenAtPosition(sourceFile: tslib.SourceFile, position: number): tslib.Node | undefined { + function find(node: tslib.Node): tslib.Node | undefined { + if (position >= node.getStart() && position <= node.getEnd()) { + return ts.forEachChild(node, find) || node; + } + return undefined; + } + return find(sourceFile); + } + + function findParentCallExpression(node: tslib.Node): tslib.CallExpression | undefined { + let current = node.parent; + while (current) { + if (ts.isCallExpression(current)) return current; + current = current.parent; + } + return undefined; + } + + function isImplicitSelectContext(text: string): boolean { + // If text contains a comma before any operator, we're in an implicit SELECT + let inSingle = false; + let inDouble = false; + for (const ch of text) { + if (ch === "'" && !inDouble) inSingle = !inSingle; + else if (ch === '"' && !inSingle) inDouble = !inDouble; + else if (!inSingle && !inDouble) { + if (ch === ",") return true; + if (ch === "=" || ch === "!" || ch === "<" || ch === ">") return false; + } + } + // Also treat empty/identifier-only text as a potential field context + return /^\s*[a-zA-Z_.]*\s*$/.test(text); + } + + // ─── Proxy all other methods ────────────────────────── + + for (const k of Object.keys(langSvc) as (keyof tslib.LanguageService)[]) { + if (!(k in proxy)) { + const method = langSvc[k]; + if (typeof method === "function") { + (proxy as any)[k] = (...args: any[]) => (method as Function).apply(langSvc, args); + } + } + } + + return proxy; + } + + return { create }; +} + +export default init; diff --git a/packages/ql-ts-plugin/src/parser.spec.ts b/packages/ql-ts-plugin/src/parser.spec.ts new file mode 100644 index 000000000..1be71610b --- /dev/null +++ b/packages/ql-ts-plugin/src/parser.spec.ts @@ -0,0 +1,114 @@ +import { describe, it, expect } from "vitest"; +import { extractFields } from "./parser.js"; + +describe("extractFields", () => { + describe("SELECT", () => { + it("detects implicit SELECT (field list with comma)", () => { + const result = extractFields("name, age WHERE status = 'active'"); + expect(result.type).toBe("SELECT"); + expect(result.fields).toEqual(["name", "age"]); + }); + + it("detects explicit SELECT", () => { + const result = extractFields("SELECT name, age WHERE status = 'active'"); + expect(result.type).toBe("SELECT"); + expect(result.fields).toEqual(["name", "age"]); + }); + + it("handles lowercase select", () => { + const result = extractFields("select name, email where id = 1"); + expect(result.type).toBe("SELECT"); + expect(result.fields).toEqual(["name", "email"]); + }); + + it("handles nested dot-notation fields", () => { + const result = extractFields("name, profile.email WHERE active = TRUE"); + expect(result.type).toBe("SELECT"); + expect(result.fields).toEqual(["name", "profile.email"]); + }); + + it("handles SELECT without WHERE", () => { + const result = extractFields("name, age"); + expect(result.type).toBe("SELECT"); + expect(result.fields).toEqual(["name", "age"]); + }); + + it("handles SELECT with ORDER BY/LIMIT", () => { + const result = extractFields("name, age ORDER BY name ASC LIMIT 10"); + expect(result.type).toBe("SELECT"); + expect(result.fields).toEqual(["name", "age"]); + }); + }); + + describe("UPDATE", () => { + it("extracts assignment fields", () => { + const result = extractFields("UPDATE SET status = 'active' WHERE name = 'John'"); + expect(result.type).toBe("UPDATE"); + expect(result.assignmentFields).toEqual(["status"]); + }); + + it("extracts multiple assignment fields", () => { + const result = extractFields("UPDATE SET status = 'active', age = 30 WHERE id = 1"); + expect(result.type).toBe("UPDATE"); + expect(result.assignmentFields).toEqual(["status", "age"]); + }); + + it("extracts nested assignment fields", () => { + const result = extractFields("UPDATE SET profile.verified = TRUE WHERE id = 1"); + expect(result.type).toBe("UPDATE"); + expect(result.assignmentFields).toEqual(["profile.verified"]); + }); + + it("handles lowercase update set where", () => { + const result = extractFields("update set name = 'x' where id = 1"); + expect(result.type).toBe("UPDATE"); + expect(result.assignmentFields).toEqual(["name"]); + }); + }); + + describe("DELETE", () => { + it("returns DELETE type with no fields", () => { + const result = extractFields("DELETE WHERE status = 'old'"); + expect(result.type).toBe("DELETE"); + expect(result.fields).toBeUndefined(); + expect(result.assignmentFields).toBeUndefined(); + }); + + it("handles lowercase delete", () => { + const result = extractFields("delete where active = FALSE"); + expect(result.type).toBe("DELETE"); + }); + }); + + describe("plain filter", () => { + it("returns no type for plain filter queries", () => { + const result = extractFields("status = 'active' AND age > 18"); + expect(result.type).toBeUndefined(); + expect(result.fields).toBeUndefined(); + }); + + it("does not confuse a filter with an implicit SELECT", () => { + const result = extractFields("name = 'John'"); + expect(result.type).toBeUndefined(); + }); + }); + + describe("edge cases", () => { + it("does not match keywords inside quoted strings", () => { + const result = extractFields("name, age WHERE title = 'SELECT committee'"); + expect(result.type).toBe("SELECT"); + expect(result.fields).toEqual(["name", "age"]); + }); + + it("handles empty query", () => { + const result = extractFields(""); + expect(result.type).toBeUndefined(); + }); + + it("handles UPDATE without SET", () => { + const result = extractFields("UPDATE WHERE id = 1"); + expect(result.type).toBe("UPDATE"); + expect(result.assignmentFields).toBeUndefined(); + }); + }); +}); diff --git a/packages/ql-ts-plugin/src/parser.ts b/packages/ql-ts-plugin/src/parser.ts new file mode 100644 index 000000000..01c46419e --- /dev/null +++ b/packages/ql-ts-plugin/src/parser.ts @@ -0,0 +1,154 @@ +/** + * Lightweight WebdaQL query parser for the TS plugin. + * + * Extracts field names from SELECT and UPDATE SET clauses + * without needing the full ANTLR runtime. This is intentionally + * minimal — it only needs to identify which fields are referenced, + * not evaluate the query. + */ + +export interface ParsedFields { + type?: "DELETE" | "UPDATE" | "SELECT"; + /** Fields in a SELECT field list */ + fields?: string[]; + /** Assignment targets in UPDATE SET */ + assignmentFields?: string[]; +} + +/** + * Find the index of an unquoted keyword (case-insensitive) + */ +function findUnquotedKeyword(str: string, keyword: string): number { + let inSingle = false; + let inDouble = false; + const upper = keyword.toUpperCase(); + for (let i = 0; i <= str.length - upper.length; i++) { + const ch = str[i]; + if (ch === "'" && !inDouble) inSingle = !inSingle; + else if (ch === '"' && !inSingle) inDouble = !inDouble; + else if (!inSingle && !inDouble) { + if ( + str.substring(i, i + upper.length).toUpperCase() === upper && + (i === 0 || /\s/.test(str[i - 1])) && + (i + upper.length === str.length || /\s/.test(str[i + upper.length])) + ) { + return i; + } + } + } + return -1; +} + +/** + * Detect implicit SELECT: query has an unquoted comma before any operator + */ +function isImplicitSelect(query: string): boolean { + let inSingle = false; + let inDouble = false; + for (let i = 0; i < query.length; i++) { + const ch = query[i]; + if (ch === "'" && !inDouble) inSingle = !inSingle; + else if (ch === '"' && !inSingle) inDouble = !inDouble; + else if (!inSingle && !inDouble) { + if (ch === ",") return true; + if (ch === "=" || ch === "!" || ch === "<" || ch === ">") return false; + for (const kw of ["AND", "OR", "LIKE", "IN", "CONTAINS"]) { + if ( + query.substring(i, i + kw.length).toUpperCase() === kw && + (i === 0 || /\s/.test(query[i - 1])) && + (i + kw.length === query.length || /\s/.test(query[i + kw.length])) + ) { + return false; + } + } + } + } + return false; +} + +/** + * Parse comma-separated field names from a string, stopping at a boundary keyword + */ +function parseFieldList(str: string): string[] { + // Find end of field list (WHERE, ORDER BY, LIMIT, OFFSET or end of string) + let end = str.length; + for (const kw of ["WHERE", "ORDER BY", "LIMIT", "OFFSET"]) { + const idx = findUnquotedKeyword(str, kw); + if (idx !== -1 && idx < end) end = idx; + } + const fieldStr = str.substring(0, end).trim(); + if (!fieldStr) return []; + return fieldStr + .split(",") + .map(f => f.trim()) + .filter(f => f.length > 0); +} + +/** + * Parse assignment targets from UPDATE SET clause (field names only) + */ +function parseAssignmentFields(str: string): string[] { + const fields: string[] = []; + let current = ""; + let inSingle = false; + let inDouble = false; + for (const ch of str) { + if (ch === "'" && !inDouble) inSingle = !inSingle; + else if (ch === '"' && !inSingle) inDouble = !inDouble; + if (ch === "," && !inSingle && !inDouble) { + const eqIdx = current.indexOf("="); + if (eqIdx !== -1) fields.push(current.substring(0, eqIdx).trim()); + current = ""; + } else { + current += ch; + } + } + if (current.trim()) { + const eqIdx = current.indexOf("="); + if (eqIdx !== -1) fields.push(current.substring(0, eqIdx).trim()); + } + return fields; +} + +/** + * Extract field references from a WebdaQL query string. + * Returns the statement type and any field names that can be validated. + */ +export function extractFields(query: string): ParsedFields { + const trimmed = query.trimStart(); + const upper = trimmed.toUpperCase(); + + // DELETE WHERE ... + if (upper.startsWith("DELETE")) { + return { type: "DELETE" }; + } + + // UPDATE SET field = val, ... WHERE ... + if (upper.startsWith("UPDATE")) { + const afterUpdate = trimmed.substring(6).trimStart(); + if (!afterUpdate.toUpperCase().startsWith("SET")) { + return { type: "UPDATE" }; + } + const afterSet = afterUpdate.substring(3).trimStart(); + const whereIdx = findUnquotedKeyword(afterSet, "WHERE"); + const assignStr = whereIdx === -1 ? afterSet : afterSet.substring(0, whereIdx).trim(); + return { + type: "UPDATE", + assignmentFields: parseAssignmentFields(assignStr) + }; + } + + // Explicit SELECT + if (upper.startsWith("SELECT")) { + const body = trimmed.substring(6).trimStart(); + return { type: "SELECT", fields: parseFieldList(body) }; + } + + // Implicit SELECT (field list with commas before operators) + if (isImplicitSelect(trimmed)) { + return { type: "SELECT", fields: parseFieldList(trimmed) }; + } + + // Plain filter query — no fields to validate + return {}; +} diff --git a/packages/ql-ts-plugin/tsconfig.json b/packages/ql-ts-plugin/tsconfig.json new file mode 100644 index 000000000..7c1234f27 --- /dev/null +++ b/packages/ql-ts-plugin/tsconfig.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "target": "es2020", + "module": "es2020", + "moduleResolution": "node", + "outDir": "./lib", + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true + }, + "include": ["src/**/*"] +} diff --git a/packages/ql/README.md b/packages/ql/README.md index 87f12d021..d03713a40 100644 --- a/packages/ql/README.md +++ b/packages/ql/README.md @@ -1,3 +1,238 @@ -# @webda/webdaql +# @webda/ql -This is a package for WebdaQL, a query language for Webda. +WebdaQL is a SQL-inspired query language for [Webda](https://webda.io) models. It supports filtering, ordering, pagination, field projection, updates, and deletions. + +## Installation + +```bash +npm install @webda/ql +``` + +## Quick Start + +```ts +import { parse, QueryValidator } from "@webda/ql"; + +// Filter query +const query = parse("status = 'active' AND age > 18 ORDER BY name DESC LIMIT 50"); +query.filter.eval({ status: "active", age: 25 }); // true +query.limit; // 50 +query.orderBy; // [{ field: "name", direction: "DESC" }] +``` + +## Query Syntax + +### Filter Queries + +The base syntax is a filter expression with optional ordering and pagination: + +``` + [ORDER BY ASC|DESC, ...] [LIMIT ] [OFFSET ''] +``` + +#### Comparison Operators + +| Operator | Example | Description | +| ---------- | ------------------------------ | ---------------------------------- | +| `=` | `status = 'active'` | Equality (loose) | +| `!=` | `age != 0` | Inequality | +| `<` | `age < 18` | Less than | +| `<=` | `age <= 65` | Less than or equal | +| `>` | `score > 100` | Greater than | +| `>=` | `score >= 0` | Greater than or equal | +| `LIKE` | `name LIKE 'J%'` | Pattern match (`%` = any, `_` = one char) | +| `IN` | `role IN ['admin', 'editor']` | Membership in a set | +| `CONTAINS` | `tags CONTAINS 'urgent'` | Array contains value | + +#### Logical Operators + +Combine conditions with `AND` and `OR`. Use parentheses for grouping: + +``` +status = 'active' AND (role = 'admin' OR role = 'editor') +``` + +#### Values + +- Strings: single or double quotes (`'hello'`, `"hello"`) +- Integers: `42` +- Booleans: `TRUE`, `FALSE` + +#### Dot Notation + +Access nested attributes with dot notation: + +``` +user.profile.name = 'John' +``` + +### SELECT (Field Projection) + +Select specific fields to return. The `SELECT` keyword is optional when using a comma-separated field list: + +``` +name, age WHERE status = 'active' ORDER BY name ASC LIMIT 10 +``` + +Or with the explicit `SELECT` keyword: + +``` +SELECT name, age WHERE status = 'active' +``` + +Without a `WHERE` clause (select all items, specific fields): + +``` +name, email +``` + +Parsed result: + +```ts +const q = parse("name, age WHERE status = 'active'"); +q.type; // "SELECT" +q.fields; // ["name", "age"] +q.filter.eval({ status: "active" }); // true +``` + +### DELETE + +Delete items matching a condition: + +``` +DELETE WHERE status = 'inactive' +delete where age < 18 AND status = 'pending' LIMIT 100 +``` + +Parsed result: + +```ts +const q = parse("DELETE WHERE status = 'inactive'"); +q.type; // "DELETE" +q.filter.eval({ status: "inactive" }); // true +``` + +### UPDATE + +Update fields on items matching a condition: + +``` +UPDATE SET status = 'active' WHERE name = 'John' +update set status = 'active', age = 30 where name = 'John' +UPDATE SET profile.verified = true WHERE id = 1 +``` + +Parsed result: + +```ts +const q = parse("UPDATE SET status = 'active', age = 30 WHERE name = 'John'"); +q.type; // "UPDATE" +q.assignments; // [{ field: "status", value: "active" }, { field: "age", value: 30 }] +q.filter.eval({ name: "John" }); // true +``` + +## Field Validation + +The `parse()` function accepts an optional `allowedFields` parameter to validate SELECT fields and UPDATE SET targets at parse time: + +```ts +const allowed = ["name", "age", "status", "profile.email"]; + +// Valid fields pass +parse("name, age WHERE status = 'active'", allowed); + +// Unknown field throws SyntaxError +parse("name, unknown WHERE status = 'active'", allowed); +// => SyntaxError: Unknown field "unknown". Allowed fields: name, age, status, profile.email +``` + +You can also validate after parsing with `validateQueryFields()`: + +```ts +import { parse, validateQueryFields } from "@webda/ql"; + +const query = parse("name, age WHERE status = 'active'"); +validateQueryFields(query, ["name", "age", "status"]); // OK +validateQueryFields(query, ["status"]); // throws SyntaxError +``` + +When used with Webda repositories, field validation is automatic: the repository derives allowed fields from the model's JSON Schema metadata. + +## API Reference + +### `parse(query: string, allowedFields?: string[]): Query` + +Parse a query string into a `Query` object. Supports all statement types (filter, SELECT, DELETE, UPDATE). + +### `QueryValidator` + +Low-level parser for filter expressions. Use `parse()` for full statement support. + +```ts +const v = new QueryValidator("status = 'active' AND age > 18"); +v.eval({ status: "active", age: 25 }); // true +v.getExpression(); // Expression AST +v.getLimit(); // 1000 (default) +v.getOffset(); // "" +``` + +### `SetterValidator` + +Parse and apply assignment expressions: + +```ts +const target: any = {}; +new SetterValidator('name = "John" AND age = 30').eval(target); +// target = { name: "John", age: 30 } +``` + +### `PartialValidator` + +Evaluate queries with partial objects (missing fields are treated as matching): + +```ts +const v = new PartialValidator("name = 'John' AND age > 18"); +v.eval({ name: "John" }); // true (age undefined, skipped) +v.wasPartialMatch(); // true +v.eval({ name: "John" }, false); // false (strict mode) +``` + +### `PrependCondition(query, condition)` + +Merge a condition into an existing query: + +```ts +PrependCondition("status = 'active' ORDER BY name LIMIT 10", "age > 18"); +// => 'age > 18 AND status = "active" ORDER BY name ASC LIMIT 10' +``` + +### `validateQueryFields(query, allowedFields)` + +Validate SELECT fields and UPDATE assignments against an allowed field list. + +### `unsanitize(query)` + +Restore `<` and `>` from HTML-sanitized query strings (`<` / `>`). + +## Query Interface + +```ts +interface Query { + filter: Expression; + limit?: number; + continuationToken?: string; + orderBy?: { field: string; direction: "ASC" | "DESC" }[]; + type?: "DELETE" | "UPDATE" | "SELECT"; + fields?: string[]; + assignments?: { field: string; value: string | number | boolean }[]; + toString(): string; +} +``` + +## Grammar + +WebdaQL uses an [ANTLR4](https://www.antlr.org/) grammar for parsing filter expressions. The statement-level syntax (SELECT, DELETE, UPDATE) is handled by a TypeScript pre-parser that delegates the filter portion to the ANTLR engine. + +**Statement keywords are case-insensitive:** `DELETE`, `UPDATE`, `SET`, `SELECT`, `WHERE`, `TRUE`, `FALSE` can be written in any case (`delete where ...`, `Update Set ...`, etc.). + +**Filter-level keywords are case-sensitive and uppercase:** `AND`, `OR`, `LIKE`, `IN`, `CONTAINS`, `ORDER BY`, `ASC`, `DESC`, `LIMIT`, `OFFSET` must remain uppercase as they are handled by the ANTLR grammar. diff --git a/packages/ql/package.json b/packages/ql/package.json index f902c52a5..8e5de6c16 100644 --- a/packages/ql/package.json +++ b/packages/ql/package.json @@ -26,6 +26,7 @@ "antlr4ts": "^0.5.0-alpha.4" }, "devDependencies": { + "@webda/test": "workspace:*", "@testdeck/mocha": "^0.3.2", "@types/node": "22.0.0", "@webda/tsc-esm": "^4.0.0-beta.1", diff --git a/packages/ql/src/query.spec.ts b/packages/ql/src/query.spec.ts index 6a1041c56..2cd457c41 100644 --- a/packages/ql/src/query.spec.ts +++ b/packages/ql/src/query.spec.ts @@ -258,4 +258,258 @@ class QueryTest { ).eval({ attr1: "plop" }) ); } + + @test + deleteQuery() { + // Basic DELETE with WHERE condition + const q1 = WebdaQL.parse("DELETE WHERE status = 'inactive'"); + assert.strictEqual(q1.type, "DELETE"); + assert.ok(q1.filter.eval({ status: "inactive" })); + assert.ok(!q1.filter.eval({ status: "active" })); + + // DELETE with complex condition + const q2 = WebdaQL.parse("DELETE WHERE age < 18 AND status = 'pending'"); + assert.strictEqual(q2.type, "DELETE"); + assert.ok(q2.filter.eval({ age: 10, status: "pending" })); + assert.ok(!q2.filter.eval({ age: 20, status: "pending" })); + + // DELETE with LIMIT (delete at most N items) + const q3 = WebdaQL.parse("DELETE WHERE status = 'old' LIMIT 100"); + assert.strictEqual(q3.type, "DELETE"); + assert.strictEqual(q3.limit, 100); + + // toString round-trip + assert.ok(q1.toString().includes("DELETE")); + assert.ok(q1.toString().includes("status")); + } + + @test + updateQuery() { + // Basic UPDATE with SET and WHERE + const q1 = WebdaQL.parse("UPDATE SET status = 'active' WHERE name = 'John'"); + assert.strictEqual(q1.type, "UPDATE"); + assert.ok(q1.filter.eval({ name: "John" })); + assert.ok(!q1.filter.eval({ name: "Jane" })); + assert.deepStrictEqual(q1.assignments, [{ field: "status", value: "active" }]); + + // UPDATE with multiple SET assignments + const q2 = WebdaQL.parse("UPDATE SET status = 'active', age = 30 WHERE name = 'John'"); + assert.strictEqual(q2.type, "UPDATE"); + assert.deepStrictEqual(q2.assignments, [ + { field: "status", value: "active" }, + { field: "age", value: 30 } + ]); + + // UPDATE with nested attribute + const q3 = WebdaQL.parse("UPDATE SET profile.verified = TRUE WHERE id = 1"); + assert.strictEqual(q3.type, "UPDATE"); + assert.deepStrictEqual(q3.assignments, [{ field: "profile.verified", value: true }]); + + // UPDATE with LIMIT + const q4 = WebdaQL.parse("UPDATE SET status = 'archived' WHERE active = FALSE LIMIT 50"); + assert.strictEqual(q4.type, "UPDATE"); + assert.strictEqual(q4.limit, 50); + + // toString round-trip + assert.ok(q1.toString().includes("UPDATE")); + assert.ok(q1.toString().includes("SET")); + } + + @test + selectFields() { + // Implicit SELECT via field list (no SELECT keyword needed) + const q1 = WebdaQL.parse("name, age WHERE status = 'active'"); + assert.strictEqual(q1.type, "SELECT"); + assert.deepStrictEqual(q1.fields, ["name", "age"]); + assert.ok(q1.filter.eval({ status: "active" })); + + // Implicit SELECT with nested fields + const q2 = WebdaQL.parse("name, profile.email WHERE status = 'active'"); + assert.strictEqual(q2.type, "SELECT"); + assert.deepStrictEqual(q2.fields, ["name", "profile.email"]); + + // Implicit SELECT with ORDER BY, LIMIT, OFFSET + const q3 = WebdaQL.parse("name, age WHERE status = 'active' ORDER BY name ASC LIMIT 10 OFFSET 'token'"); + assert.strictEqual(q3.type, "SELECT"); + assert.deepStrictEqual(q3.fields, ["name", "age"]); + assert.strictEqual(q3.limit, 10); + assert.strictEqual(q3.continuationToken, "token"); + assert.deepStrictEqual(q3.orderBy, [{ field: "name", direction: "ASC" }]); + + // Implicit SELECT without WHERE (all items, specific fields) + const q4 = WebdaQL.parse("name, age"); + assert.strictEqual(q4.type, "SELECT"); + assert.deepStrictEqual(q4.fields, ["name", "age"]); + assert.ok(q4.filter instanceof WebdaQL.AndExpression); + assert.strictEqual((q4.filter as WebdaQL.AndExpression).children.length, 0); + + // Explicit SELECT keyword still works + const q4b = WebdaQL.parse("SELECT name, age WHERE status = 'active'"); + assert.strictEqual(q4b.type, "SELECT"); + assert.deepStrictEqual(q4b.fields, ["name", "age"]); + + // Regular query (no field list) should have type undefined + const q5 = WebdaQL.parse("status = 'active'"); + assert.strictEqual(q5.type, undefined); + assert.strictEqual(q5.fields, undefined); + + // Single field with WHERE — not a SELECT (no comma = could be a filter identifier) + const q6 = WebdaQL.parse("status = 'active' AND age > 18"); + assert.strictEqual(q6.type, undefined); + + // toString round-trip + assert.ok(q3.toString().includes("name")); + } + + @test + allowedFields() { + const allowed = ["name", "age", "status", "profile.email"]; + + // Valid SELECT fields pass + const q1 = WebdaQL.parse("name, age WHERE status = 'active'", allowed); + assert.strictEqual(q1.type, "SELECT"); + assert.deepStrictEqual(q1.fields, ["name", "age"]); + + // Unknown SELECT field throws + assert.throws( + () => WebdaQL.parse("name, unknown WHERE status = 'active'", allowed), + /Unknown field "unknown"/ + ); + + // Valid UPDATE assignment fields pass + const q2 = WebdaQL.parse("UPDATE SET status = 'active' WHERE name = 'John'", allowed); + assert.strictEqual(q2.type, "UPDATE"); + + // Unknown UPDATE assignment field throws + assert.throws( + () => WebdaQL.parse("UPDATE SET invalid = 'active' WHERE name = 'John'", allowed), + /Unknown assignment field "invalid"/ + ); + + // Dot-notation fields work + const q3 = WebdaQL.parse("name, profile.email WHERE status = 'active'", allowed); + assert.deepStrictEqual(q3.fields, ["name", "profile.email"]); + + // DELETE and plain queries are not affected by allowedFields + const q4 = WebdaQL.parse("DELETE WHERE status = 'old'", allowed); + assert.strictEqual(q4.type, "DELETE"); + + const q5 = WebdaQL.parse("status = 'active'", allowed); + assert.strictEqual(q5.type, undefined); + + // validateQueryFields can be called standalone + const parsed = WebdaQL.parse("name, age WHERE status = 'active'"); + WebdaQL.validateQueryFields(parsed, allowed); // should not throw + assert.throws( + () => WebdaQL.validateQueryFields(parsed, ["status"]), + /Unknown field "name"/ + ); + } + + @test + caseInsensitiveKeywords() { + // lowercase delete + const q1 = WebdaQL.parse("delete where status = 'inactive'"); + assert.strictEqual(q1.type, "DELETE"); + assert.ok(q1.filter.eval({ status: "inactive" })); + + // mixed case delete + const q1b = WebdaQL.parse("Delete Where status = 'old'"); + assert.strictEqual(q1b.type, "DELETE"); + + // lowercase update + const q2 = WebdaQL.parse("update set status = 'active' where name = 'John'"); + assert.strictEqual(q2.type, "UPDATE"); + assert.deepStrictEqual(q2.assignments, [{ field: "status", value: "active" }]); + assert.ok(q2.filter.eval({ name: "John" })); + + // mixed case update + const q2b = WebdaQL.parse("Update Set profile.verified = true Where id = 1"); + assert.strictEqual(q2b.type, "UPDATE"); + assert.deepStrictEqual(q2b.assignments, [{ field: "profile.verified", value: true }]); + + // lowercase select + const q3 = WebdaQL.parse("select name, age where status = 'active'"); + assert.strictEqual(q3.type, "SELECT"); + assert.deepStrictEqual(q3.fields, ["name", "age"]); + + // lowercase delete with limit (LIMIT is ANTLR-level, stays uppercase) + const q4 = WebdaQL.parse("delete where status = 'old' LIMIT 100"); + assert.strictEqual(q4.type, "DELETE"); + assert.strictEqual(q4.limit, 100); + + // lowercase boolean values in assignments + const q5 = WebdaQL.parse("UPDATE SET active = false WHERE id = 1"); + assert.deepStrictEqual(q5.assignments, [{ field: "active", value: false }]); + } + + @test + modelFieldValidation() { + // Simulate a model's JSON Schema properties (as getAllowedFields() would return) + const userFields = ["name", "email", "age", "status", "profile.bio", "profile.avatar"]; + + // SELECT: valid fields pass + const q1 = WebdaQL.parse("name, email WHERE status = 'active'", userFields); + assert.strictEqual(q1.type, "SELECT"); + assert.deepStrictEqual(q1.fields, ["name", "email"]); + + // SELECT: unknown field rejects + assert.throws( + () => WebdaQL.parse("name, password WHERE status = 'active'", userFields), + /Unknown field "password"/ + ); + + // SELECT: nested dot-notation field passes + const q2 = WebdaQL.parse("name, profile.bio WHERE age > 18", userFields); + assert.deepStrictEqual(q2.fields, ["name", "profile.bio"]); + + // SELECT: unknown nested field rejects + assert.throws( + () => WebdaQL.parse("name, profile.ssn WHERE age > 18", userFields), + /Unknown field "profile.ssn"/ + ); + + // UPDATE: valid assignment fields pass + const q3 = WebdaQL.parse("UPDATE SET status = 'banned', age = 0 WHERE name = 'spam'", userFields); + assert.strictEqual(q3.type, "UPDATE"); + assert.deepStrictEqual(q3.assignments, [ + { field: "status", value: "banned" }, + { field: "age", value: 0 } + ]); + + // UPDATE: unknown assignment field rejects + assert.throws( + () => WebdaQL.parse("UPDATE SET role = 'admin' WHERE name = 'hacker'", userFields), + /Unknown assignment field "role"/ + ); + + // UPDATE: nested assignment field validates + WebdaQL.parse("UPDATE SET profile.bio = 'hello' WHERE name = 'John'", userFields); + assert.throws( + () => WebdaQL.parse("UPDATE SET profile.secret = 'x' WHERE name = 'John'", userFields), + /Unknown assignment field "profile.secret"/ + ); + + // DELETE: not affected by allowedFields (no field projection) + const q4 = WebdaQL.parse("DELETE WHERE status = 'old'", userFields); + assert.strictEqual(q4.type, "DELETE"); + + // Plain filter: not affected by allowedFields + const q5 = WebdaQL.parse("status = 'active'", userFields); + assert.strictEqual(q5.type, undefined); + + // Standalone validateQueryFields works the same way + const parsed = WebdaQL.parse("name, age WHERE status = 'active'"); + WebdaQL.validateQueryFields(parsed, userFields); // passes + assert.throws( + () => WebdaQL.validateQueryFields(parsed, ["name"]), // age not allowed + /Unknown field "age"/ + ); + + // Empty allowedFields rejects everything + assert.throws( + () => WebdaQL.parse("name, email WHERE status = 'active'", []), + /Unknown field "name"/ + ); + } } diff --git a/packages/ql/src/query.ts b/packages/ql/src/query.ts index f0a3461f2..98474a7ad 100644 --- a/packages/ql/src/query.ts +++ b/packages/ql/src/query.ts @@ -307,6 +307,18 @@ export interface Query { * Order by clause */ orderBy?: OrderBy[]; + /** + * Statement type (DELETE, UPDATE, SELECT) or undefined for plain filter queries + */ + type?: "DELETE" | "UPDATE" | "SELECT"; + /** + * Projected field names for SELECT queries + */ + fields?: string[]; + /** + * Assignment list for UPDATE SET queries + */ + assignments?: { field: string; value: value }[]; /** * Get the string representation of the query */ @@ -1018,11 +1030,316 @@ export function unsanitize(query: string): string { return query.replace(/</g, "<").replace(/>/g, ">"); } +/** + * Find the index of an unquoted keyword in a string + * Tracks single and double quote state to avoid matching inside string literals. + * + * @param str - the string to search + * @param keyword - the keyword to find (must be surrounded by whitespace or at string boundaries) + * @returns index of the keyword, or -1 if not found + */ +function findUnquotedKeyword(str: string, keyword: string): number { + let inSingle = false; + let inDouble = false; + const upperKeyword = keyword.toUpperCase(); + for (let i = 0; i <= str.length - upperKeyword.length; i++) { + const ch = str[i]; + if (ch === "'" && !inDouble) { + inSingle = !inSingle; + } else if (ch === '"' && !inSingle) { + inDouble = !inDouble; + } else if (!inSingle && !inDouble) { + if ( + str.substring(i, i + upperKeyword.length).toUpperCase() === upperKeyword && + (i === 0 || /\s/.test(str[i - 1])) && + (i + upperKeyword.length === str.length || /\s/.test(str[i + upperKeyword.length])) + ) { + return i; + } + } + } + return -1; +} + +/** + * Parse a value literal string into its typed representation + * + * @param raw - trimmed value string (e.g. `"'hello'"`, `"42"`, `"TRUE"`) + * @returns the parsed value + */ +function parseValue(raw: string): value { + if ((raw.startsWith("'") && raw.endsWith("'")) || (raw.startsWith('"') && raw.endsWith('"'))) { + return raw.substring(1, raw.length - 1); + } + const upper = raw.toUpperCase(); + if (upper === "TRUE") return true; + if (upper === "FALSE") return false; + return parseInt(raw); +} + +/** + * Serialize a value to its WebdaQL string representation + */ +function formatValue(v: value): string { + if (typeof v === "string") return `"${v}"`; + if (typeof v === "boolean") return v ? "TRUE" : "FALSE"; + return String(v); +} + +/** + * Parse a comma-separated assignment list like `"status = 'active', age = 30"` + * + * @param str - the assignment list string + * @returns array of field/value assignment objects + */ +function parseAssignments(str: string): { field: string; value: value }[] { + const results: { field: string; value: value }[] = []; + // Split by unquoted commas + let current = ""; + let inSingle = false; + let inDouble = false; + for (const ch of str) { + if (ch === "'" && !inDouble) { + inSingle = !inSingle; + } else if (ch === '"' && !inSingle) { + inDouble = !inDouble; + } + if (ch === "," && !inSingle && !inDouble) { + results.push(parseOneAssignment(current)); + current = ""; + } else { + current += ch; + } + } + if (current.trim()) { + results.push(parseOneAssignment(current)); + } + return results; +} + +/** + * Parse a single assignment like `"status = 'active'"` into `{ field, value }` + */ +function parseOneAssignment(str: string): { field: string; value: value } { + const eqIdx = str.indexOf("="); + if (eqIdx === -1) { + throw new SyntaxError(`Invalid assignment: ${str}`); + } + return { + field: str.substring(0, eqIdx).trim(), + value: parseValue(str.substring(eqIdx + 1).trim()) + }; +} + +/** + * Parse the body after SELECT (or implicit select detection) into fields and remainder + */ +function parseSelectBody(body: string): StatementPrefix { + const whereIdx = findUnquotedKeyword(body, "WHERE"); + let fieldStr: string; + let remainder: string; + if (whereIdx === -1) { + // Check for ORDER BY, LIMIT, OFFSET without WHERE + let endOfFields = body.length; + for (const kw of ["ORDER BY", "LIMIT", "OFFSET"]) { + const idx = findUnquotedKeyword(body, kw); + if (idx !== -1 && idx < endOfFields) { + endOfFields = idx; + } + } + fieldStr = body.substring(0, endOfFields).trim(); + remainder = body.substring(endOfFields).trimStart(); + } else { + fieldStr = body.substring(0, whereIdx).trim(); + remainder = body.substring(whereIdx + 5).trimStart(); + } + const fields = fieldStr.split(",").map(f => f.trim()).filter(f => f.length > 0); + return { type: "SELECT", fields, remainder }; +} + +/** + * Detect if a query is an implicit SELECT (field list without the SELECT keyword). + * + * A query is an implicit SELECT if it contains an unquoted comma before any + * comparison operator or logical keyword. This distinguishes `name, age WHERE ...` + * from `name = 'John' AND ...`. + * + * @returns the query string (as the select body) if implicit SELECT, otherwise undefined + */ +function detectImplicitSelect(query: string): string | undefined { + let inSingle = false; + let inDouble = false; + for (let i = 0; i < query.length; i++) { + const ch = query[i]; + if (ch === "'" && !inDouble) { + inSingle = !inSingle; + } else if (ch === '"' && !inSingle) { + inDouble = !inDouble; + } else if (!inSingle && !inDouble) { + // If we hit a comma first, it's a field list + if (ch === ",") { + return query; + } + // If we hit an operator first, it's a filter expression + if (ch === "=" || ch === "!" || ch === "<" || ch === ">") { + return undefined; + } + // Check for keyword operators (AND, OR, LIKE, IN, CONTAINS) + for (const kw of ["AND", "OR", "LIKE", "IN", "CONTAINS"]) { + if ( + query.substring(i, i + kw.length) === kw && + (i === 0 || /\s/.test(query[i - 1])) && + (i + kw.length === query.length || /\s/.test(query[i + kw.length])) + ) { + return undefined; + } + } + } + } + return undefined; +} + +interface StatementPrefix { + type?: "DELETE" | "UPDATE" | "SELECT"; + fields?: string[]; + assignments?: { field: string; value: value }[]; + remainder: string; +} + +/** + * Extract statement prefix (DELETE/UPDATE/SELECT) from a query string + * and return the remainder for the ANTLR parser. + * + * @param query - full query string + * @returns parsed prefix info and the condition remainder + */ +function extractStatementPrefix(query: string): StatementPrefix { + const trimmed = query.trimStart(); + const upperTrimmed = trimmed.toUpperCase(); + + // DELETE WHERE + if (upperTrimmed.startsWith("DELETE")) { + const afterDelete = trimmed.substring(6).trimStart(); + if (afterDelete.toUpperCase().startsWith("WHERE")) { + return { type: "DELETE", remainder: afterDelete.substring(5).trimStart() }; + } + return { type: "DELETE", remainder: afterDelete }; + } + + // UPDATE SET WHERE + if (upperTrimmed.startsWith("UPDATE")) { + const afterUpdate = trimmed.substring(6).trimStart(); + if (!afterUpdate.toUpperCase().startsWith("SET")) { + throw new SyntaxError(`Expected SET after UPDATE (Query: ${query})`); + } + const afterSet = afterUpdate.substring(3).trimStart(); + const whereIdx = findUnquotedKeyword(afterSet, "WHERE"); + if (whereIdx === -1) { + // UPDATE SET assignments without WHERE → applies to all + return { + type: "UPDATE", + assignments: parseAssignments(afterSet), + remainder: "" + }; + } + const assignmentStr = afterSet.substring(0, whereIdx).trim(); + const remainder = afterSet.substring(whereIdx + 5).trimStart(); + return { + type: "UPDATE", + assignments: parseAssignments(assignmentStr), + remainder + }; + } + + // SELECT [WHERE ] — explicit or implicit + // Explicit: starts with SELECT keyword + // Implicit: starts with a comma-separated identifier list (contains a comma before any operator) + const selectBody = upperTrimmed.startsWith("SELECT") ? trimmed.substring(6).trimStart() : detectImplicitSelect(trimmed); + + if (selectBody !== undefined) { + return parseSelectBody(selectBody); + } + + return { remainder: query }; +} + +/** + * Validate that all fields and assignment targets in a query are within the allowed set. + * Checks SELECT fields, UPDATE SET assignment fields, and filter attribute references. + * + * @param query - parsed query to validate + * @param allowedFields - set of allowed field names (supports dot-notation) + * @throws {SyntaxError} if any field is not in the allowed set + */ +export function validateQueryFields(query: Query, allowedFields: string[]): void { + const allowed = new Set(allowedFields); + if (query.fields) { + for (const field of query.fields) { + if (!allowed.has(field)) { + throw new SyntaxError(`Unknown field "${field}". Allowed fields: ${allowedFields.join(", ")}`); + } + } + } + if (query.assignments) { + for (const assignment of query.assignments) { + if (!allowed.has(assignment.field)) { + throw new SyntaxError( + `Unknown assignment field "${assignment.field}". Allowed fields: ${allowedFields.join(", ")}` + ); + } + } + } +} + /** * Parse a query string into a Query object - * @param query - * @returns + * + * Supports plain filter queries, and statement prefixes: + * - `DELETE WHERE [LIMIT n]` + * - `UPDATE SET WHERE [LIMIT n]` + * - `SELECT [WHERE ] [ORDER BY ...] [LIMIT ...] [OFFSET ...]` + * - `, [WHERE ] ...` (implicit SELECT when comma-separated fields detected) + * + * @param query - the query string to parse + * @param allowedFields - optional list of allowed field names; if provided, SELECT fields + * and UPDATE SET targets are validated against this list and a SyntaxError is thrown for unknowns + * @returns parsed Query object */ -export function parse(query: string): Query { - return new QueryValidator(query).getQuery(); +export function parse(query: string, allowedFields?: string[]): Query { + const prefix = extractStatementPrefix(query); + const base = new QueryValidator(prefix.remainder).getQuery(); + if (!prefix.type) { + return base; + } + const result: Query = { + ...base, + type: prefix.type, + fields: prefix.fields, + assignments: prefix.assignments, + toString: () => { + const basePart = base.toString(); + switch (prefix.type) { + case "DELETE": { + const condition = basePart ? ` WHERE ${basePart}` : ""; + return `DELETE${condition}`.trim(); + } + case "UPDATE": { + const setPart = prefix.assignments!.map(a => `${a.field} = ${formatValue(a.value)}`).join(", "); + const condition = basePart ? ` WHERE ${basePart}` : ""; + return `UPDATE SET ${setPart}${condition}`.trim(); + } + case "SELECT": { + const fieldsPart = prefix.fields!.join(", "); + const condition = basePart ? ` WHERE ${basePart}` : ""; + return `SELECT ${fieldsPart}${condition}`.trim(); + } + default: + return basePart; + } + } + }; + if (allowedFields) { + validateQueryFields(result, allowedFields); + } + return result; } diff --git a/packages/test/package.json b/packages/test/package.json index 034d999e9..34dfae1bd 100644 --- a/packages/test/package.json +++ b/packages/test/package.json @@ -30,12 +30,13 @@ "codemod": "jscodeshift --parser=ts --extensions=tsx,ts -t ../codemod/current.mjs src" }, "dependencies": { - "@webda/decorators": "^4.0.0-beta.1", - "@webda/workout": "^4.0.0-beta.1" + "@webda/decorators": "workspace:*", + "@webda/workout": "workspace:*" }, "devDependencies": { "@types/node": "22.0.0", - "jscodeshift": "^17.0.0" + "jscodeshift": "^17.0.0", + "vitest": "^4.1.0" }, "peerDependenciesMeta": { "mocha": {