Skip to content

Commit

Permalink
Make the way the completion schema is defined more regular
Browse files Browse the repository at this point in the history
FEATURE: The `schema` option now allows nested objects to define
multiple levels of completions, as well as `self` completion options
for specific levels. The old format (using `tables`/`schemas`)
continues to work but is deprecated.

Issue #15
  • Loading branch information
marijnh committed Feb 12, 2024
1 parent 4ab5417 commit 5f9ea3b
Show file tree
Hide file tree
Showing 4 changed files with 109 additions and 34 deletions.
2 changes: 2 additions & 0 deletions src/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@ to communication around the project.

@SQLConfig

@SQLNamespace

@SQLDialect

@SQLDialectSpec
Expand Down
84 changes: 62 additions & 22 deletions src/complete.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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)
}
}
}
Expand All @@ -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)
Expand All @@ -146,10 +184,12 @@ export function completeFromSchema(schema: {[table: string]: readonly (string |
for (let name of parents) {
while (!level.children || !level.children[name]) {

This comment has been minimized.

Copy link
@CapricornJay

CapricornJay Apr 22, 2024

Code would throw error here "Cannot read properties of null (reading 'children')
TypeError: Cannot read properties of null (reading 'children')"

because level would be null when searching for object which is not present in schema

This comment has been minimized.

Copy link
@graup

graup Apr 26, 2024

Also encountering this.

This comment has been minimized.

Copy link
@graup

graup Apr 26, 2024

@marijnh not sure how to fix this, maybe just checking if defaultSchema is not null in the line below? Or change this line to 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
Expand Down
26 changes: 15 additions & 11 deletions src/sql.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
31 changes: 30 additions & 1 deletion test/test-complete.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
})
})

0 comments on commit 5f9ea3b

Please sign in to comment.