diff --git a/firebase-vscode/src/data-connect/allow-directive-completion.ts b/firebase-vscode/src/data-connect/allow-directive-completion.ts new file mode 100644 index 00000000000..73fe806bcc1 --- /dev/null +++ b/firebase-vscode/src/data-connect/allow-directive-completion.ts @@ -0,0 +1,245 @@ +import * as vscode from "vscode"; +import { Kind, OperationDefinitionNode, parse } from "graphql"; +import { + AllowDirectiveService, + AllowableField, +} from "./allow-directive-service"; +import { dataConnectConfigs } from "./config"; +import { unwrapTypeName } from "../utils/graphql"; + +interface AllowFieldsContext { + fieldsText: string; + stringStartOffset: number; +} + +interface FieldsParseResult { + listedFields: string[]; + partial: string; + nestingOnField?: string; +} + +/** Autocomplete provider for fields inside @allow(fields: "...") strings. */ +export class AllowDirectiveCompletionProvider + implements vscode.CompletionItemProvider { + constructor(private allowService: AllowDirectiveService) { } + + provideCompletionItems( + document: vscode.TextDocument, + position: vscode.Position, + ): vscode.CompletionItem[] | undefined { + const context = this.findAllowFieldsContext(document, position); + if (!context) { + return undefined; + } + + const dataTypeName = this.findDataTypeName(document, position); + if (!dataTypeName) { + return undefined; + } + + const configs = dataConnectConfigs.value?.tryReadValue; + if (!configs) { + return undefined; + } + try { + this.allowService.initialize( + configs.findEnclosingServiceForPath(document.fileName), + ); + } catch { + return undefined; + } + + if (!this.allowService.hasDataType(dataTypeName)) { + return undefined; + } + + const parsed = parseFieldsText(context.fieldsText); + return this.resolveCandidates(dataTypeName, parsed).map((field) => + toCompletionItem(field), + ); + } + + /** Detect if cursor is inside an @allow(fields: "...") string. */ + private findAllowFieldsContext( + document: vscode.TextDocument, + position: vscode.Position, + ): AllowFieldsContext | null { + const textUpToCursor = document.getText( + new vscode.Range(new vscode.Position(0, 0), position), + ); + + const pattern = /@allow\s*\(\s*fields\s*:\s*"/g; + let match: RegExpExecArray | null; + let lastMatch: RegExpExecArray | null = null; + while ((match = pattern.exec(textUpToCursor)) !== null) { + lastMatch = match; + } + if (!lastMatch) { + return null; + } + + const stringStart = lastMatch.index + lastMatch[0].length; + const textInsideQuotes = textUpToCursor.substring(stringStart); + + // Cursor is past the closing quote — not inside the string. + if (textInsideQuotes.includes('"')) { + return null; + } + + return { fieldsText: textInsideQuotes, stringStartOffset: stringStart }; + } + + /** Find the _Data type from the enclosing operation's variables. */ + private findDataTypeName( + document: vscode.TextDocument, + position: vscode.Position, + ): string | null { + let ast; + try { + ast = parse(document.getText()); + } catch { + return this.findDataTypeNameByRegex(document, position); + } + + const offset = document.offsetAt(position); + for (const def of ast.definitions) { + if (def.kind !== Kind.OPERATION_DEFINITION || !def.loc) { + continue; + } + if (offset >= def.loc.start && offset <= def.loc.end) { + return findDataTypeFromOperation(def); + } + } + return null; + } + + /** Regex fallback when the document can't be parsed. */ + private findDataTypeNameByRegex( + document: vscode.TextDocument, + position: vscode.Position, + ): string | null { + const text = document.getText( + new vscode.Range(new vscode.Position(0, 0), position), + ); + const pattern = /\$\w+\s*:\s*\[?(\w+_Data)\s*!?\]?\s*!?/g; + let match: RegExpExecArray | null; + let lastMatch: RegExpExecArray | null = null; + while ((match = pattern.exec(text)) !== null) { + lastMatch = match; + } + return lastMatch?.[1] ?? null; + } + + private resolveCandidates( + dataTypeName: string, + parsed: FieldsParseResult, + ): AllowableField[] { + let typeName = dataTypeName; + let nestedOnFieldName: string | undefined; + + if (parsed.nestingOnField) { + const onField = this.allowService + .getFieldCandidates(dataTypeName) + .find((f) => f.name === parsed.nestingOnField); + if (!onField?.childDataTypeName) { + return []; + } + typeName = onField.childDataTypeName; + nestedOnFieldName = parsed.nestingOnField; + } + + const listed = new Set(parsed.listedFields); + return this.allowService + .getFieldCandidates(typeName, nestedOnFieldName) + .filter( + (f) => + !listed.has(f.name) && + f.name.toLowerCase().startsWith(parsed.partial.toLowerCase()), + ); + } +} + +function toCompletionItem(field: AllowableField): vscode.CompletionItem { + const item = new vscode.CompletionItem( + field.name, + field.isRelational + ? vscode.CompletionItemKind.Module + : vscode.CompletionItemKind.Field, + ); + item.detail = field.typeName; + if (field.description) { + item.documentation = new vscode.MarkdownString(field.description); + } + if (field.isRelational) { + item.insertText = new vscode.SnippetString(`${field.name} { $0 }`); + item.command = { + command: "editor.action.triggerSuggest", + title: "Trigger Suggest", + }; + } + item.sortText = field.isRelational ? `z_${field.name}` : field.name; + return item; +} + +/** + * Tokenize the text inside @allow(fields: "...") up to the cursor. + * Tracks { } nesting to determine listed fields, partial word, and nesting context. + * + * Example: "userId notes_on_app { tex" + * → { listedFields: [], partial: "tex", nestingOnField: "notes_on_app" } + */ +export function parseFieldsText(text: string): FieldsParseResult { + const tokens = text.match(/[_A-Za-z][_0-9A-Za-z]*|[{}]/g) || []; + + let depth = 0; + const topLevelFields: string[] = []; + const nestedFields: string[] = []; + let lastOnField: string | undefined; + let currentOnField: string | undefined; + + for (const token of tokens) { + if (token === "{") { + depth++; + currentOnField = lastOnField; + continue; + } + if (token === "}") { + depth = Math.max(0, depth - 1); + if (depth === 0) { + currentOnField = undefined; + } + continue; + } + if (depth === 0) { + topLevelFields.push(token); + if (token.includes("_on_")) { + lastOnField = token; + } + } else { + nestedFields.push(token); + } + } + + const lastChar = text[text.length - 1]; + const endsClean = + !text.length || lastChar === " " || lastChar === "{" || lastChar === "}"; + + if (depth > 0) { + const partial = endsClean ? "" : nestedFields.pop() ?? ""; + return { listedFields: nestedFields, partial, nestingOnField: currentOnField }; + } + const partial = endsClean ? "" : topLevelFields.pop() ?? ""; + return { listedFields: topLevelFields, partial, nestingOnField: undefined }; +} + +function findDataTypeFromOperation( + def: OperationDefinitionNode, +): string | null { + for (const varDef of def.variableDefinitions ?? []) { + const typeName = unwrapTypeName(varDef.type); + if (typeName.endsWith("_Data")) { + return typeName; + } + } + return null; +} diff --git a/firebase-vscode/src/data-connect/allow-directive-service.ts b/firebase-vscode/src/data-connect/allow-directive-service.ts new file mode 100644 index 00000000000..bc6e38b0e70 --- /dev/null +++ b/firebase-vscode/src/data-connect/allow-directive-service.ts @@ -0,0 +1,259 @@ +import * as path from "path"; +import * as fs from "fs"; +import { + InputObjectTypeDefinitionNode, + InputValueDefinitionNode, + Kind, + TypeNode, + parse, +} from "graphql"; +import { ResolvedDataConnectConfig } from "./config"; +import { unwrapTypeName } from "../utils/graphql"; + +/** A field eligible for @allow(fields: "..."). */ +export interface AllowableField { + name: string; + typeName: string; + description?: string; + isRelational: boolean; + childDataTypeName?: string; +} + +interface RelationInfo { + childType: string; + refField: string; +} + +/** + * Resolves allowable fields for the @allow directive by parsing generated + * .dataconnect/schema/ files and user schema @ref directives. + */ +export class AllowDirectiveService { + private dataTypeCache = new Map(); + private relationCache = new Map(); + private fkColumnCache = new Map(); + private initializedForPath: string | undefined; + + /** Initialize (or no-op) for the given service config. */ + initialize(serviceConfig: ResolvedDataConnectConfig): void { + if (this.initializedForPath === serviceConfig.path) { + return; + } + this.invalidateCache(); + this.initializedForPath = serviceConfig.path; + this.parseGeneratedFiles(serviceConfig.path); + this.parseUserSchemaFiles(serviceConfig); + } + + invalidateCache(): void { + this.dataTypeCache.clear(); + this.relationCache.clear(); + this.fkColumnCache.clear(); + this.initializedForPath = undefined; + } + + /** Get allowable fields, optionally excluding parent FK in nested _on_ context. */ + getFieldCandidates( + dataTypeName: string, + nestedOnFieldName?: string, + ): AllowableField[] { + const dataType = this.dataTypeCache.get(dataTypeName); + if (!dataType?.fields) { + return []; + } + + const fkExclusions = nestedOnFieldName + ? this.getFkColumnsForOnField(nestedOnFieldName) + : new Set(); + + const results: AllowableField[] = []; + for (const field of dataType.fields) { + if (hasForbiddenDirective(field) || fkExclusions.has(field.name.value)) { + continue; + } + const isRelational = field.name.value.includes("_on_"); + results.push({ + name: field.name.value, + typeName: formatTypeNode(field.type), + description: field.description?.value, + isRelational, + childDataTypeName: isRelational + ? unwrapTypeName(field.type) + : undefined, + }); + } + return results; + } + + /** Get shallow DB column names only (no _on_ fields). */ + getShallowFields(dataTypeName: string): string[] { + return this.getFieldCandidates(dataTypeName) + .filter((f) => !f.isRelational) + .map((f) => f.name); + } + + hasDataType(dataTypeName: string): boolean { + return this.dataTypeCache.has(dataTypeName); + } + + private parseGeneratedFiles(servicePath: string): void { + const generatedDir = path.join(servicePath, ".dataconnect", "schema"); + if (!fs.existsSync(generatedDir)) { + return; + } + + for (const subdir of listSubdirectories(generatedDir)) { + const dir = path.join(generatedDir, subdir); + this.safeParseFile(path.join(dir, "input.gql"), (doc) => { + for (const def of doc.definitions) { + if ( + def.kind === Kind.INPUT_OBJECT_TYPE_DEFINITION && + def.name.value.endsWith("_Data") + ) { + this.dataTypeCache.set(def.name.value, def); + } + } + }); + this.safeParseFile(path.join(dir, "relation.gql"), (doc) => + this.extractRelations(doc), + ); + } + } + + private extractRelations(doc: ReturnType): void { + for (const def of doc.definitions) { + if (def.kind !== Kind.OBJECT_TYPE_EXTENSION || !def.fields) { + continue; + } + for (const field of def.fields) { + if (!field.name.value.includes("_on_")) { + continue; + } + const fromArg = field.directives + ?.find((d) => d.name.value === "fdc_generated") + ?.arguments?.find((a) => a.name.value === "from"); + if (fromArg?.value.kind !== Kind.STRING) { + continue; + } + const [childType, refField] = fromArg.value.value.split("."); + if (childType && refField) { + this.relationCache.set(field.name.value, { childType, refField }); + } + } + } + } + + private parseUserSchemaFiles( + serviceConfig: ResolvedDataConnectConfig, + ): void { + const schemaDirs = [ + serviceConfig.mainSchemaDir, + ...serviceConfig.secondarySchemaDirs, + ]; + for (const schemaDir of schemaDirs) { + const absDir = path.join(serviceConfig.path, schemaDir); + for (const file of findGqlFiles(absDir)) { + this.safeParseFile(file, (doc) => this.extractRefs(doc)); + } + } + } + + /** Extract @ref(fields: [String!]) directives to map FK column names. */ + private extractRefs(doc: ReturnType): void { + for (const def of doc.definitions) { + if (def.kind !== Kind.OBJECT_TYPE_DEFINITION || !def.fields) { + continue; + } + for (const field of def.fields) { + const fieldsArg = field.directives + ?.find((d) => d.name.value === "ref") + ?.arguments?.find((a) => a.name.value === "fields"); + if (fieldsArg?.value.kind !== Kind.LIST) { + continue; + } + const fkColumns = fieldsArg.value.values + .filter((v): v is { kind: typeof Kind.STRING; value: string } => + v.kind === Kind.STRING, + ) + .map((v) => v.value); + if (fkColumns.length) { + this.fkColumnCache.set( + `${def.name.value}.${field.name.value}`, + fkColumns, + ); + } + } + } + } + + /** + * Resolve FK columns to exclude in a nested _on_ context. + * Falls back to the default convention (refField + "Id") when no @ref is defined. + */ + private getFkColumnsForOnField(onFieldName: string): Set { + const relation = this.relationCache.get(onFieldName); + if (!relation) { + return new Set(); + } + const explicit = this.fkColumnCache.get( + `${relation.childType}.${relation.refField}`, + ); + return new Set(explicit ?? [`${relation.refField}Id`]); + } + + /** Read and parse a .gql file, swallowing errors gracefully. */ + private safeParseFile( + filePath: string, + handler: (doc: ReturnType) => void, + ): void { + if (!fs.existsSync(filePath)) { + return; + } + try { + handler(parse(fs.readFileSync(filePath, "utf-8"))); + } catch { + // Ignore parse/read failures — generated files may be incomplete. + } + } +} + +function hasForbiddenDirective(field: InputValueDefinitionNode): boolean { + return ( + field.directives?.some( + (d) => d.name.value === "fdc_forbiddenInVariables", + ) ?? false + ); +} + +function formatTypeNode(type: TypeNode): string { + if (type.kind === Kind.NON_NULL_TYPE) { + return `${formatTypeNode(type.type)}!`; + } + if (type.kind === Kind.LIST_TYPE) { + return `[${formatTypeNode(type.type)}]`; + } + return type.name.value; +} + +function listSubdirectories(dir: string): string[] { + return fs + .readdirSync(dir, { withFileTypes: true }) + .filter((d) => d.isDirectory()) + .map((d) => d.name); +} + +function findGqlFiles(dir: string): string[] { + if (!fs.existsSync(dir)) { + return []; + } + const results: string[] = []; + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + const fullPath = path.join(dir, entry.name); + if (entry.isDirectory()) { + results.push(...findGqlFiles(fullPath)); + } else if (entry.name.endsWith(".gql")) { + results.push(fullPath); + } + } + return results; +} diff --git a/firebase-vscode/src/data-connect/index.ts b/firebase-vscode/src/data-connect/index.ts index 8344520a4e6..8656a386ccb 100644 --- a/firebase-vscode/src/data-connect/index.ts +++ b/firebase-vscode/src/data-connect/index.ts @@ -22,7 +22,7 @@ import { dataConnectConfigs, registerDataConnectConfigs, } from "./config"; -import { locationToRange } from "../utils/graphql"; +import { locationToRange, unwrapTypeName } from "../utils/graphql"; import { Result } from "../result"; import { LanguageClient } from "vscode-languageclient/node"; import { registerTerminalTasks } from "./terminal"; @@ -33,13 +33,16 @@ import { registerDiagnostics } from "./diagnostics"; import { AnalyticsLogger } from "../analytics"; import { registerFirebaseMCP } from "./ai-tools/firebase-mcp"; import { ExecutionParamsService } from "./execution/execution-params"; +import { AllowDirectiveService } from "./allow-directive-service"; +import { AllowDirectiveCompletionProvider } from "./allow-directive-completion"; class CodeActionsProvider implements vscode.CodeActionProvider { constructor( private configs: Signal< Result | undefined >, - ) {} + private allowService: AllowDirectiveService, + ) { } provideCodeActions( document: vscode.TextDocument, @@ -51,7 +54,12 @@ class CodeActionsProvider implements vscode.CodeActionProvider { const results: (vscode.CodeAction | vscode.Command)[] = []; // TODO: replace w/ online-parser to work with malformed documents - const documentNode = graphql.parse(documentText); + let documentNode; + try { + documentNode = graphql.parse(documentText); + } catch { + return null; + } let definitionAtRange: graphql.DefinitionNode | undefined; let definitionIndex: number | undefined; @@ -85,9 +93,80 @@ class CodeActionsProvider implements vscode.CodeActionProvider { results, ); + // Add @allow quick fix for mutations with _Data variables missing @allow. + if (definitionAtRange.kind === graphql.Kind.OPERATION_DEFINITION) { + this.addAllowQuickFix( + document, + definitionAtRange as graphql.OperationDefinitionNode, + results, + ); + } + return results; } + private addAllowQuickFix( + document: vscode.TextDocument, + def: graphql.OperationDefinitionNode, + results: (vscode.CodeAction | vscode.Command)[], + ): void { + if (def.operation !== graphql.OperationTypeNode.MUTATION) { + return; + } + + // Check each _Data variable for a missing @allow. + for (const varDef of def.variableDefinitions ?? []) { + const typeName = unwrapTypeName(varDef.type); + if (!typeName.endsWith("_Data")) { + continue; + } + + const hasAllow = varDef.directives?.some( + (d) => d.name.value === "allow", + ); + if (hasAllow) { + continue; + } + + // Initialize service for this file's config. + try { + const serviceConfig = + this.configs.value?.tryReadValue?.findEnclosingServiceForPath( + document.uri.fsPath, + ); + if (!serviceConfig) { + return; + } + this.allowService.initialize(serviceConfig); + } catch { + return; + } + + const shallowFields = this.allowService.getShallowFields(typeName); + if (!shallowFields.length) { + continue; + } + + const fieldsStr = shallowFields.join(" "); + const allowText = `\n @allow(fields: "${fieldsStr}")`; + + // Insert after the variable's type annotation. + if (!varDef.type.loc) { + continue; + } + const insertPos = document.positionAt(varDef.type.loc.end); + + const action = new vscode.CodeAction( + `Add @allow with all DB columns for $${varDef.variable.name.value}`, + vscode.CodeActionKind.QuickFix, + ); + action.edit = new vscode.WorkspaceEdit(); + action.edit.insert(document.uri, insertPos, allowText); + action.isPreferred = true; + results.push(action); + } + } + private moveToConnector( document: vscode.TextDocument, documentText: string, @@ -138,15 +217,62 @@ export function registerFdc( ): Disposable { registerDiagnostics(context, dataConnectConfigs); const dataConnectToolkit = new DataConnectToolkit(broker); + const allowService = new AllowDirectiveService(); + + // Register @allow directive completion provider. + const allowCompletionProvider = + vscode.languages.registerCompletionItemProvider( + [{ scheme: "file", language: "graphql" }], + new AllowDirectiveCompletionProvider(allowService), + '"', + " ", + "{", + ); + + // Watch for generated schema file changes to invalidate the allow service cache. + const generatedSchemaWatcher = vscode.workspace.createFileSystemWatcher( + "**/.dataconnect/schema/**/*.gql", + ); + const invalidateAllowCache = () => allowService.invalidateCache(); + generatedSchemaWatcher.onDidChange(invalidateAllowCache); + generatedSchemaWatcher.onDidCreate(invalidateAllowCache); + generatedSchemaWatcher.onDidDelete(invalidateAllowCache); + + // Diagnostic collection for missing @allow on mutations. + const allowDiagnostics = + vscode.languages.createDiagnosticCollection("fdc-allow-directive"); + const updateAllowDiagnostics = (document: vscode.TextDocument) => { + if (document.languageId !== "graphql") { + return; + } + computeAllowDiagnostics(document, allowService, allowDiagnostics); + }; + // Update diagnostics on open, save, and change (debounced on change). + let diagnosticDebounceTimer: ReturnType | undefined; + context.subscriptions.push( + vscode.workspace.onDidOpenTextDocument(updateAllowDiagnostics), + vscode.workspace.onDidSaveTextDocument(updateAllowDiagnostics), + vscode.workspace.onDidChangeTextDocument((e) => { + clearTimeout(diagnosticDebounceTimer); + diagnosticDebounceTimer = setTimeout( + () => updateAllowDiagnostics(e.document), + 300, + ); + }), + allowDiagnostics, + ); const codeActions = vscode.languages.registerCodeActionsProvider( [ { scheme: "file", language: "graphql" }, { scheme: "untitled", language: "graphql" }, ], - new CodeActionsProvider(dataConnectConfigs), + new CodeActionsProvider(dataConnectConfigs, allowService), { - providedCodeActionKinds: [vscode.CodeActionKind.Refactor], + providedCodeActionKinds: [ + vscode.CodeActionKind.Refactor, + vscode.CodeActionKind.QuickFix, + ], }, ); @@ -209,9 +335,8 @@ export function registerFdc( { dispose: sub1 }, { dispose: effect(() => { - selectedProjectStatus.text = `$(mono-firebase) ${ - currentProjectId.value ?? "" - }`; + selectedProjectStatus.text = `$(mono-firebase) ${currentProjectId.value ?? "" + }`; selectedProjectStatus.show(); }), }, @@ -240,9 +365,9 @@ export function registerFdc( isTest ? [{ pattern: "/**/firebase-vscode/src/test/test_projects/**/*.gql" }] : [ - { scheme: "file", language: "graphql" }, - { scheme: "untitled", language: "graphql" }, - ], + { scheme: "file", language: "graphql" }, + { scheme: "untitled", language: "graphql" }, + ], operationCodeLensProvider, ), schemaCodeLensProvider, @@ -257,6 +382,8 @@ export function registerFdc( [{ scheme: "file", language: "yaml", pattern: "**/connector.yaml" }], configureSdkCodeLensProvider, ), + allowCompletionProvider, + generatedSchemaWatcher, { dispose: () => { client.stop(); @@ -264,3 +391,76 @@ export function registerFdc( }, ); } + +/** + * Compute diagnostics for mutations missing @allow directives. + * Shows an informational hint when a mutation has _Data variables but no @allow. + */ +function computeAllowDiagnostics( + document: vscode.TextDocument, + allowService: AllowDirectiveService, + collection: vscode.DiagnosticCollection, +): void { + let ast; + try { + ast = graphql.parse(document.getText()); + } catch { + // Can't parse — clear diagnostics for this file. + collection.delete(document.uri); + return; + } + + // Initialize the service for this file if possible. + const configs = dataConnectConfigs.value?.tryReadValue; + if (!configs) { + collection.delete(document.uri); + return; + } + try { + const serviceConfig = configs.findEnclosingServiceForPath( + document.fileName, + ); + allowService.initialize(serviceConfig); + } catch { + collection.delete(document.uri); + return; + } + + const diagnostics: vscode.Diagnostic[] = []; + for (const def of ast.definitions) { + if (def.kind !== graphql.Kind.OPERATION_DEFINITION) { + continue; + } + if (def.operation !== graphql.OperationTypeNode.MUTATION) { + continue; + } + + // Flag each _Data variable missing @allow. + for (const varDef of def.variableDefinitions ?? []) { + const typeName = unwrapTypeName(varDef.type); + if (!typeName.endsWith("_Data")) { + continue; + } + const hasAllow = varDef.directives?.some( + (d) => d.name.value === "allow", + ); + if (hasAllow) { + continue; + } + if (!varDef.loc) { + continue; + } + + const range = locationToRange(varDef.loc); + const diagnostic = new vscode.Diagnostic( + range, + `Missing @allow directive on $${varDef.variable.name.value}. Required for deployment (optional for local emulator).`, + vscode.DiagnosticSeverity.Information, + ); + diagnostic.source = "Firebase SQL Connect"; + diagnostics.push(diagnostic); + } + } + + collection.set(document.uri, diagnostics); +} diff --git a/firebase-vscode/src/utils/graphql.ts b/firebase-vscode/src/utils/graphql.ts index 603b6c72d13..9f321906db3 100644 --- a/firebase-vscode/src/utils/graphql.ts +++ b/firebase-vscode/src/utils/graphql.ts @@ -10,3 +10,14 @@ export function locationToRange(location: graphql.Location): vscode.Range { location.endToken.column - 1 ); } + +/** Unwrap NonNull / List type wrappers to get the named type string. */ +export function unwrapTypeName(type: graphql.TypeNode): string { + if (type.kind === graphql.Kind.NON_NULL_TYPE) { + return unwrapTypeName(type.type); + } + if (type.kind === graphql.Kind.LIST_TYPE) { + return unwrapTypeName(type.type); + } + return type.name.value; +}