Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions packages/models/src/repositories/abstract.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,18 @@ export abstract class AbstractRepository<T extends ModelClass> 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
*/
Expand Down
9 changes: 6 additions & 3 deletions packages/models/src/repositories/memory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,14 +12,17 @@ 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;
};
};

// Lazy load WebdaQL
let WebdaQL: any & {
parse: (query: string) => Query;
parse: (query: string, allowedFields?: string[]) => Query;
} = null;

export type FindResult<T> = {
Expand Down Expand Up @@ -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`);
}
Expand Down Expand Up @@ -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
}
Expand Down
84 changes: 84 additions & 0 deletions packages/ql-ts-plugin/README.md
Original file line number Diff line number Diff line change
@@ -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<typeof User>;

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<T>` generic |
| `repo.iterate("...")` | Model type from `Repository<T>` 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
33 changes: 33 additions & 0 deletions packages/ql-ts-plugin/package.json
Original file line number Diff line number Diff line change
@@ -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"
]
}
Loading
Loading