diff --git a/src/README.md b/src/README.md index 3a79d97..0def001 100644 --- a/src/README.md +++ b/src/README.md @@ -25,6 +25,8 @@ to communication around the project. @SQLConfig +@SQLNamespace + @SQLDialect @SQLDialectSpec diff --git a/src/complete.ts b/src/complete.ts index 63595c4..69d5be4 100644 --- a/src/complete.ts +++ b/src/complete.ts @@ -3,7 +3,7 @@ import {EditorState, Text} from "@codemirror/state" import {syntaxTree} from "@codemirror/language" import {SyntaxNode} from "@lezer/common" import {Type, Keyword} from "./sql.grammar.terms" -import {type SQLDialect} from "./sql" +import {type SQLDialect, SQLNamespace} from "./sql" function tokenBefore(tree: SyntaxNode) { let cursor = tree.cursor().moveTo(tree.from, -1) @@ -93,23 +93,63 @@ function maybeQuoteCompletions(quote: string | null, completions: readonly Compl const Span = /^\w*$/, QuotedSpan = /^[`'"]?\w*[`'"]?$/ +function isSelfTag(namespace: SQLNamespace): namespace is {self: Completion, children: SQLNamespace} { + return (namespace as any).self && typeof (namespace as any).self.label == "string" +} + class CompletionLevel { list: Completion[] = [] children: {[name: string]: CompletionLevel} | undefined = undefined - child(name: string, idQuote: string) { + constructor(readonly idQuote: string) {} + + child(name: string) { let children = this.children || (this.children = Object.create(null)) let found = children[name] if (found) return found - if (name) this.list.push(nameCompletion(name, "type", idQuote)) - return (children[name] = new CompletionLevel) + if (name && !this.list.some(c => c.label == name)) this.list.push(nameCompletion(name, "type", this.idQuote)) + return (children[name] = new CompletionLevel(this.idQuote)) + } + + maybeChild(name: string) { + return this.children ? this.children[name] : null + } + + addCompletion(option: Completion) { + let found = this.list.findIndex(o => o.label == option.label) + if (found > -1) this.list[found] = option + else this.list.push(option) + } + + addCompletions(completions: readonly (Completion | string)[]) { + for (let option of completions) + this.addCompletion(typeof option == "string" ? nameCompletion(option, "property", this.idQuote) : option) } - addCompletions(list: readonly Completion[]) { - for (let option of list) { - let found = this.list.findIndex(o => o.label == option.label) - if (found > -1) this.list[found] = option - else this.list.push(option) + addNamespace(namespace: SQLNamespace, plainScope: CompletionLevel = this) { + if (Array.isArray(namespace)) { + this.addCompletions(namespace) + } else if (isSelfTag(namespace)) { + this.addNamespace(namespace.children) + } else { + this.addNamespaceObject(namespace as {[name: string]: SQLNamespace}, plainScope) + } + } + + addNamespaceObject(namespace: {[name: string]: SQLNamespace}, plainScope: CompletionLevel) { + for (let name of Object.keys(namespace)) { + let children = namespace[name], self: Completion | null = null + let parts = name.replace(/\\?\./g, p => p == "." ? "\0" : p).split("\0") + let scope = parts.length == 1 ? plainScope : this + if (isSelfTag(children)) { + self = children.self + children = children.children + } + for (let i = 0; i < parts.length; i++) { + if (self && i == parts.length - 1) scope.addCompletion(self) + scope = scope.child(parts[i].replace(/\\\./g, ".")) + } + scope.addNamespace(children) } } } @@ -119,24 +159,22 @@ function nameCompletion(label: string, type: string, idQuote: string): Completio return {label, type, apply: idQuote + label + idQuote} } -export function completeFromSchema(schema: {[table: string]: readonly (string | Completion)[]}, +// Some of this is more gnarly than it has to be because we're also +// supporting the deprecated, not-so-well-considered style of +// supplying the schema (dotted property names for schemas, separate +// `tables` and `schemas` completions). +export function completeFromSchema(schema: SQLNamespace, tables?: readonly Completion[], schemas?: readonly Completion[], defaultTableName?: string, defaultSchemaName?: string, dialect?: SQLDialect): CompletionSource { - let top = new CompletionLevel let idQuote = dialect?.spec.identifierQuotes?.[0] || '"' - let defaultSchema = top.child(defaultSchemaName || "", idQuote) - for (let table in schema) { - let parts = table.replace(/\\?\./g, p => p == "." ? "\0" : p).split("\0") - let base = parts.length == 1 ? defaultSchema : top - for (let part of parts) base = base.child(part.replace(/\\\./g, "."), idQuote) - for (let option of schema[table]) if (option) - base.list.push(typeof option == "string" ? nameCompletion(option, "property", idQuote) : option) - } + let top = new CompletionLevel(idQuote) + let defaultSchema = top.child(defaultSchemaName || "") + top.addNamespace(schema, defaultSchema) if (tables) defaultSchema.addCompletions(tables) if (schemas) top.addCompletions(schemas) top.addCompletions(defaultSchema.list) - if (defaultTableName) top.addCompletions(defaultSchema.child(defaultTableName, idQuote).list) + if (defaultTableName) top.addCompletions(defaultSchema.child(defaultTableName).list) return (context: CompletionContext) => { let {parents, from, quoted, empty, aliases} = sourceContext(context.state, context.pos) @@ -146,10 +184,12 @@ export function completeFromSchema(schema: {[table: string]: readonly (string | for (let name of parents) { while (!level.children || !level.children[name]) { if (level == top) level = defaultSchema - else if (level == defaultSchema && defaultTableName) level = level.child(defaultTableName, idQuote) + else if (level == defaultSchema && defaultTableName) level = level.child(defaultTableName) else return null } - level = level.child(name, idQuote) + let next = level.maybeChild(name) + if (!next) return null + level = next } let quoteAfter = quoted && context.state.sliceDoc(context.pos, context.pos + 1) == quoted let options = level.list diff --git a/src/sql.ts b/src/sql.ts index 49ff047..8707fc0 100644 --- a/src/sql.ts +++ b/src/sql.ts @@ -115,23 +115,27 @@ export class SQLDialect { } } +/// The type used to describe a level of the schema for +/// [completion](#lang-sql.SQLConfig.schema). Can be an array of +/// options (columns), an object mapping table or schema names to +/// deeper levels, or a `{self, children}` object that assigns a +/// completion option to use for its parent property, when the default option +/// (its name as label and type `"type"`) isn't suitable. +export type SQLNamespace = {[name: string]: SQLNamespace} + | {self: Completion, children: SQLNamespace} + | readonly (Completion | string)[] + /// Options used to configure an SQL extension. export interface SQLConfig { /// The [dialect](#lang-sql.SQLDialect) to use. Defaults to /// [`StandardSQL`](#lang-sql.StandardSQL). dialect?: SQLDialect, - /// An object that maps table names, optionally prefixed with a - /// schema name (`"schema.table"`) to options (columns) that can be - /// completed for that table. Use lower-case names here. - schema?: {[table: string]: readonly (string | Completion)[]}, - /// By default, the completions for the table names will be - /// generated from the `schema` object. But if you want to - /// customize them, you can pass an array of completions through - /// this option. + /// You can use this to define the schemas, tables, and their fields + /// for autocompletion. + schema?: SQLNamespace, + /// @hide tables?: readonly Completion[], - /// Similar to `tables`, if you want to provide completion objects - /// for your schemas rather than using the generated ones, pass them - /// here. + /// @hide schemas?: readonly Completion[], /// When given, columns from the named table can be completed /// directly at the top level. diff --git a/test/test-complete.ts b/test/test-complete.ts index cd68d5f..3562093 100644 --- a/test/test-complete.ts +++ b/test/test-complete.ts @@ -174,5 +174,34 @@ describe("SQL completion", () => { let s = {schema: {"db\\.conf": ["abc"]}} ist(str(get("db|", s)), '"db.conf"') ist(str(get('"db.conf".|', s)), "abc") - }) + }) + + it("supports nested schema declarations", () => { + let s = {schema: { + public: {users: ["email", "id"]}, + other: {users: ["name", "id"]}, + plain: ["one", "two"] + }} + ist(str(get("pl|", s)), "other, plain, public") + ist(str(get("plain.|", s)), "one, two") + ist(str(get("public.u|", s)), "users") + ist(str(get("public.users.e|", s)), "email, id") + }) + + it("supports self fields to specify table/schema completions", () => { + let s: SQLConfig = {schema: { + foo: { + self: {label: "foo", type: "keyword"}, + children: { + bar: { + self: {label: "bar", type: "constant"}, + children: ["a", "b"] + } + } + } + }} + ist(get("select f|", s)!.options[0].type, "keyword") + ist(get("select foo.|", s)!.options[0].type, "constant") + ist(get("select foo.|", s)!.options.length, 1) + }) })