From 9c81137444b91f594d66ca7cc38bf873f500fe72 Mon Sep 17 00:00:00 2001 From: "Aasim Khan (from Dev Box)" Date: Thu, 21 May 2026 13:16:52 -0700 Subject: [PATCH 1/9] Fix DAB designer quickstart issues --- extensions/mssql/l10n/bundle.l10n.json | 9 +- .../mssql/src/dab/dabConfigFileBuilder.ts | 44 ++++- extensions/mssql/src/sharedInterfaces/dab.ts | 31 ++- .../mssql/src/webviews/common/locConstants.ts | 15 +- .../dab/dabEntitySettingsDialog.tsx | 178 +++++++++++------- .../SchemaDesigner/dab/dabEntityTable.tsx | 84 ++++----- .../schemaDesignerRpcHandlers.ts | 161 ++++++++++++++-- localization/xliff/vscode-mssql.xlf | 23 ++- 8 files changed, 398 insertions(+), 147 deletions(-) diff --git a/extensions/mssql/l10n/bundle.l10n.json b/extensions/mssql/l10n/bundle.l10n.json index f82b862b82..0696b1f3cb 100644 --- a/extensions/mssql/l10n/bundle.l10n.json +++ b/extensions/mssql/l10n/bundle.l10n.json @@ -877,7 +877,7 @@ "Expose this entity through GraphQL": "Expose this entity through GraphQL", "Enable GraphQL in API Type to expose this entity through GraphQL.": "Enable GraphQL in API Type to expose this entity through GraphQL.", "Stored procedure REST methods": "Stored procedure REST methods", - "Select the HTTP methods that can execute this stored procedure. DAB defaults to POST.": "Select the HTTP methods that can execute this stored procedure. DAB defaults to POST.", + "Select the HTTP method that can execute this stored procedure. DAB defaults to POST.": "Select the HTTP method that can execute this stored procedure. DAB defaults to POST.", "Stored procedure GraphQL operation": "Stored procedure GraphQL operation", "Choose whether this stored procedure appears as a GraphQL mutation or query. DAB defaults to mutation.": "Choose whether this stored procedure appears as a GraphQL mutation or query. DAB defaults to mutation.", "Mutation": "Mutation", @@ -967,6 +967,7 @@ "Used in API routes and responses": "Used in API routes and responses", "Authorization Role": "Authorization Role", "Define who can access this endpoint": "Define who can access this endpoint", + "Define who can execute this stored procedure": "Define who can execute this stored procedure", "Disabled globally": "Disabled globally", "Anonymous": "Anonymous", "No authentication required": "No authentication required", @@ -975,7 +976,10 @@ "Custom REST Path": "Custom REST Path", "Optional - Override default api/entityName path": "Optional - Override default api/entityName path", "Custom GraphQL Type": "Custom GraphQL Type", - "Optional - Override default GraphQL type name": "Optional - Override default GraphQL type name", + "Custom GraphQL Singular Type": "Custom GraphQL Singular Type", + "Optional - Override default GraphQL singular type name": "Optional - Override default GraphQL singular type name", + "Custom GraphQL Plural Type": "Custom GraphQL Plural Type", + "Optional - Override default GraphQL plural type name": "Optional - Override default GraphQL plural type name", "Source": "Source", "Source: {0}/{0} is the fully qualified DAB source object name": { "message": "Source: {0}", @@ -983,6 +987,7 @@ }, "Initializing DAB configuration...": "Initializing DAB configuration...", "No entities found": "No entities found", + "Select all entities": "Select all entities", "Toggle all entities in {0}/{0} is the schema name": { "message": "Toggle all entities in {0}", "comment": ["{0} is the schema name"] diff --git a/extensions/mssql/src/dab/dabConfigFileBuilder.ts b/extensions/mssql/src/dab/dabConfigFileBuilder.ts index 0e0f608cad..9e3294c4e3 100644 --- a/extensions/mssql/src/dab/dabConfigFileBuilder.ts +++ b/extensions/mssql/src/dab/dabConfigFileBuilder.ts @@ -50,11 +50,13 @@ interface DabEntityOutput { "primary-key"?: boolean; }>; rest: boolean | { path?: string; methods?: string[] } | undefined; - graphql: boolean | { type?: string; operation?: string } | undefined; + graphql: boolean | { type?: DabGraphQLTypeOutput; operation?: string } | undefined; permissions: DabPermissionEntry[]; mcp?: { "custom-tool"?: boolean; "dml-tools"?: boolean }; } +type DabGraphQLTypeOutput = string | { singular: string; plural?: string }; + interface DabPermissionEntry { role: string; actions: Array; @@ -265,7 +267,9 @@ export class DabConfigFileBuilder { const customPath = entity.advancedSettings.customRestPath; const restMethods = entity.sourceType === Dab.EntitySourceType.StoredProcedure - ? entity.advancedSettings.storedProcedureRestMethods + ? this.getStoredProcedureRestMethod( + entity.advancedSettings.storedProcedureRestMethods, + ) : undefined; const restConfig: { path?: string; methods?: string[] } = {}; if (customPath) { @@ -277,6 +281,16 @@ export class DabConfigFileBuilder { return Object.keys(restConfig).length > 0 ? restConfig : undefined; } + private getStoredProcedureRestMethod(methods?: Dab.RestMethod[]): Dab.RestMethod[] { + const method = + methods?.find((configuredMethod) => + Dab.storedProcedureAllowedRestMethods.some( + (allowedMethod) => allowedMethod === configuredMethod, + ), + ) ?? Dab.RestMethod.Post; + return [method]; + } + /** * Builds the GraphQL property for a single entity. * @@ -287,13 +301,13 @@ export class DabConfigFileBuilder { */ private buildGraphQLProperty( entity: Dab.DabEntityConfig, - ): undefined | { type?: string; operation?: string } { - const customType = entity.advancedSettings.customGraphQLType; + ): undefined | { type?: DabGraphQLTypeOutput; operation?: string } { + const customType = this.buildGraphQLTypeProperty(entity.advancedSettings); const graphQLOperation = entity.sourceType === Dab.EntitySourceType.StoredProcedure ? entity.advancedSettings.storedProcedureGraphQLOperation : undefined; - const graphqlConfig: { type?: string; operation?: string } = {}; + const graphqlConfig: { type?: DabGraphQLTypeOutput; operation?: string } = {}; if (customType) { graphqlConfig.type = customType; } @@ -303,6 +317,26 @@ export class DabConfigFileBuilder { return Object.keys(graphqlConfig).length > 0 ? graphqlConfig : undefined; } + private buildGraphQLTypeProperty( + settings: Dab.EntityAdvancedSettings, + ): DabGraphQLTypeOutput | undefined { + const singular = settings.customGraphQLSingularType ?? settings.customGraphQLType; + const plural = settings.customGraphQLPluralType; + + if (!singular) { + return undefined; + } + + if (singular && !plural) { + return singular; + } + + return { + singular, + plural, + }; + } + private buildParameterProperty(parameter: Dab.DabParameterConfig): DabParameterOutput { return { name: parameter.name, diff --git a/extensions/mssql/src/sharedInterfaces/dab.ts b/extensions/mssql/src/sharedInterfaces/dab.ts index 4d185ddf77..363400d39f 100644 --- a/extensions/mssql/src/sharedInterfaces/dab.ts +++ b/extensions/mssql/src/sharedInterfaces/dab.ts @@ -46,6 +46,7 @@ export namespace Dab { RestMethod.Patch, RestMethod.Delete, ]; + export const storedProcedureAllowedRestMethods = [RestMethod.Get, RestMethod.Post] as const; /** * Canonicalizes REST methods so semantically equivalent method sets do not @@ -87,12 +88,15 @@ export namespace Dab { return undefined; } - export function validateDabCustomGraphQLType(value: string): string | undefined { + export function validateDabCustomGraphQLType( + value: string, + fieldName = "customGraphQLType", + ): string | undefined { if (value.length > maxDabEntityNameLength) { - return `customGraphQLType must be ${maxDabEntityNameLength} characters or fewer.`; + return `${fieldName} must be ${maxDabEntityNameLength} characters or fewer.`; } if (!dabGraphQLTypePattern.test(value)) { - return "customGraphQLType must be a valid GraphQL name."; + return `${fieldName} must be a valid GraphQL name.`; } return undefined; } @@ -141,9 +145,18 @@ export namespace Dab { */ restEnabled?: boolean; /** - * Custom GraphQL type name (overrides default entity name) + * Legacy custom GraphQL type name (overrides default entity name). + * Kept so older cached DAB configs continue to load. */ customGraphQLType?: string; + /** + * Custom singular GraphQL type name (overrides default entity name). + */ + customGraphQLSingularType?: string; + /** + * Custom plural GraphQL type name (overrides default pluralization). + */ + customGraphQLPluralType?: string; /** * Whether this entity should be exposed through GraphQL when GraphQL is globally enabled. * Defaults to true. @@ -427,11 +440,17 @@ export namespace Dab { export type DabEntitySettingsPatch = Partial< Omit< EntityAdvancedSettings, - "customRestPath" | "customGraphQLType" | "storedProcedureRestMethods" + | "customRestPath" + | "customGraphQLType" + | "customGraphQLSingularType" + | "customGraphQLPluralType" + | "storedProcedureRestMethods" > > & { customRestPath?: string | null; - customGraphQLType?: string | null; + customGraphQLType?: string | { singular: string; plural?: string | null } | null; + customGraphQLSingularType?: string | null; + customGraphQLPluralType?: string | null; storedProcedureRestMethods?: RestMethod[] | null; }; diff --git a/extensions/mssql/src/webviews/common/locConstants.ts b/extensions/mssql/src/webviews/common/locConstants.ts index a669b243e6..7124e2f5a3 100644 --- a/extensions/mssql/src/webviews/common/locConstants.ts +++ b/extensions/mssql/src/webviews/common/locConstants.ts @@ -1357,7 +1357,7 @@ export class LocConstants { ), storedProcedureRestMethods: l10n.t("Stored procedure REST methods"), storedProcedureRestMethodsHelp: l10n.t( - "Select the HTTP methods that can execute this stored procedure. DAB defaults to POST.", + "Select the HTTP method that can execute this stored procedure. DAB defaults to POST.", ), storedProcedureGraphQLOperation: l10n.t("Stored procedure GraphQL operation"), storedProcedureGraphQLOperationHelp: l10n.t( @@ -1495,6 +1495,9 @@ export class LocConstants { entityNameHelp: l10n.t("Used in API routes and responses"), authorizationRole: l10n.t("Authorization Role"), authorizationRoleHelp: l10n.t("Define who can access this endpoint"), + authorizationRoleStoredProcedureHelp: l10n.t( + "Define who can execute this stored procedure", + ), disabledGlobally: l10n.t("Disabled globally"), anonymous: l10n.t("Anonymous"), anonymousDescription: l10n.t("No authentication required"), @@ -1503,7 +1506,14 @@ export class LocConstants { customRestPath: l10n.t("Custom REST Path"), customRestPathHelp: l10n.t("Optional - Override default api/entityName path"), customGraphQLType: l10n.t("Custom GraphQL Type"), - customGraphQLTypeHelp: l10n.t("Optional - Override default GraphQL type name"), + customGraphQLSingularType: l10n.t("Custom GraphQL Singular Type"), + customGraphQLSingularTypeHelp: l10n.t( + "Optional - Override default GraphQL singular type name", + ), + customGraphQLPluralType: l10n.t("Custom GraphQL Plural Type"), + customGraphQLPluralTypeHelp: l10n.t( + "Optional - Override default GraphQL plural type name", + ), applyChanges: l10n.t("Apply Changes"), source: l10n.t("Source"), sourceWithName: (sourceName: string) => @@ -1515,6 +1525,7 @@ export class LocConstants { loading: l10n.t("Loading..."), initializingDabConfig: l10n.t("Initializing DAB configuration..."), noEntitiesFound: l10n.t("No entities found"), + selectAllEntities: l10n.t("Select all entities"), toggleAllEntitiesInSchema: (schemaName: string) => l10n.t({ message: "Toggle all entities in {0}", diff --git a/extensions/mssql/src/webviews/pages/SchemaDesigner/dab/dabEntitySettingsDialog.tsx b/extensions/mssql/src/webviews/pages/SchemaDesigner/dab/dabEntitySettingsDialog.tsx index 37e9b04662..9210edff87 100644 --- a/extensions/mssql/src/webviews/pages/SchemaDesigner/dab/dabEntitySettingsDialog.tsx +++ b/extensions/mssql/src/webviews/pages/SchemaDesigner/dab/dabEntitySettingsDialog.tsx @@ -185,16 +185,6 @@ const useStyles = makeStyles({ flexWrap: "wrap", gap: "6px", }, - methodChip: { - borderRadius: "999px", - fontSize: "12px", - minWidth: "unset", - }, - methodChipSelected: { - border: "1px solid var(--vscode-textLink-foreground)", - color: "var(--vscode-textLink-foreground)", - backgroundColor: "color-mix(in srgb, var(--vscode-textLink-foreground) 20%, transparent)", - }, }); interface DabEntitySettingsDialogProps { @@ -254,27 +244,30 @@ export function DabEntitySettingsDialog({ setLocalSettings((prev) => ({ ...prev, restEnabled: value })); }; - const updateCustomGraphQLType = (value: string) => { - setLocalSettings((prev) => ({ ...prev, customGraphQLType: value || undefined })); + const updateCustomGraphQLSingularType = (value: string) => { + setLocalSettings((prev) => ({ + ...prev, + customGraphQLType: undefined, + customGraphQLSingularType: value || undefined, + })); + }; + + const updateCustomGraphQLPluralType = (value: string) => { + setLocalSettings((prev) => ({ + ...prev, + customGraphQLPluralType: value || undefined, + })); }; const updateGraphQLEnabled = (value: boolean) => { setLocalSettings((prev) => ({ ...prev, graphQLEnabled: value })); }; - const updateStoredProcedureRestMethod = (method: Dab.RestMethod, isEnabled: boolean) => { - setLocalSettings((prev) => { - const methods = prev.storedProcedureRestMethods ?? [Dab.RestMethod.Post]; - const nextMethods = isEnabled - ? [...methods, method] - : methods.filter((existingMethod) => existingMethod !== method); - return { - ...prev, - storedProcedureRestMethods: nextMethods.length - ? Dab.normalizeRestMethods(nextMethods) - : [Dab.RestMethod.Post], - }; - }); + const updateStoredProcedureRestMethod = (method: Dab.RestMethod) => { + setLocalSettings((prev) => ({ + ...prev, + storedProcedureRestMethods: [method], + })); }; const updateStoredProcedureGraphQLOperation = (operation: Dab.GraphQLOperation) => { @@ -294,13 +287,22 @@ export function DabEntitySettingsDialog({ const storedProcedureRestMethods = localSettings.storedProcedureRestMethods ?? [ Dab.RestMethod.Post, ]; + const storedProcedureRestMethod = + storedProcedureRestMethods.find((method) => + Dab.storedProcedureAllowedRestMethods.some((allowedMethod) => allowedMethod === method), + ) ?? Dab.RestMethod.Post; const storedProcedureGraphQLOperation = localSettings.storedProcedureGraphQLOperation ?? Dab.GraphQLOperation.Mutation; const exposeAsMcpCustomTool = localSettings.exposeAsMcpCustomTool !== false; + const authorizationRoleHelp = isStoredProcedure + ? locConstants.schemaDesigner.authorizationRoleStoredProcedureHelp + : locConstants.schemaDesigner.authorizationRoleHelp; const sourceObjectName = `${entity.schemaName}.${entity.sourceName ?? entity.tableName}`; const entityName = localSettings.entityName.trim(); const customRestPath = localSettings.customRestPath?.trim() ?? ""; - const customGraphQLType = localSettings.customGraphQLType?.trim() ?? ""; + const customGraphQLSingularType = + (localSettings.customGraphQLSingularType ?? localSettings.customGraphQLType)?.trim() ?? ""; + const customGraphQLPluralType = localSettings.customGraphQLPluralType?.trim() ?? ""; const normalizedExistingEntityNames = new Set( existingEntityNames.map(Dab.normalizeDabIdentifier), ); @@ -312,14 +314,24 @@ export function DabEntitySettingsDialog({ : Dab.validateDabEntityName(entityName); const customRestPathValidationMessage = customRestPath.length > 0 ? Dab.validateDabCustomRestPath(customRestPath) : undefined; - const customGraphQLTypeValidationMessage = - customGraphQLType.length > 0 - ? Dab.validateDabCustomGraphQLType(customGraphQLType) + const customGraphQLSingularTypeValidationMessage = + customGraphQLPluralType.length > 0 && customGraphQLSingularType.length === 0 + ? "customGraphQLSingularType is required when customGraphQLPluralType is set." + : customGraphQLSingularType.length > 0 + ? Dab.validateDabCustomGraphQLType( + customGraphQLSingularType, + "customGraphQLSingularType", + ) + : undefined; + const customGraphQLPluralTypeValidationMessage = + customGraphQLPluralType.length > 0 + ? Dab.validateDabCustomGraphQLType(customGraphQLPluralType, "customGraphQLPluralType") : undefined; const hasValidationError = !!entityNameValidationMessage || !!customRestPathValidationMessage || - !!customGraphQLTypeValidationMessage; + !!customGraphQLSingularTypeValidationMessage || + !!customGraphQLPluralTypeValidationMessage; const handleApply = () => { if (hasValidationError) { @@ -330,7 +342,14 @@ export function DabEntitySettingsDialog({ ...localSettings, entityName, customRestPath: customRestPath.length > 0 ? customRestPath : undefined, - customGraphQLType: customGraphQLType.length > 0 ? customGraphQLType : undefined, + customGraphQLType: undefined, + customGraphQLSingularType: + customGraphQLSingularType.length > 0 ? customGraphQLSingularType : undefined, + customGraphQLPluralType: + customGraphQLPluralType.length > 0 ? customGraphQLPluralType : undefined, + ...(isStoredProcedure + ? { storedProcedureRestMethods: [storedProcedureRestMethod] } + : {}), }); }; @@ -402,6 +421,7 @@ export function DabEntitySettingsDialog({
{renderSectionTitle(locConstants.schemaDesigner.authorizationRole)}
- - {locConstants.schemaDesigner.authorizationRoleHelp} - + {authorizationRoleHelp}
-
- {Object.values(Dab.RestMethod).map( + + updateStoredProcedureRestMethod( + data.value as Dab.RestMethod, + ) + }> + {Dab.storedProcedureAllowedRestMethods.map( (method) => ( - - updateStoredProcedureRestMethod( - method, - !storedProcedureRestMethods.includes( - method, - ), - ) - } - aria-label={method.toUpperCase()}> - {method.toUpperCase()} - + value={method} + label={method.toUpperCase()} + /> ), )} -
+ )} @@ -622,31 +629,63 @@ export function DabEntitySettingsDialog({ 0} validationState={ - customGraphQLTypeValidationMessage + customGraphQLSingularTypeValidationMessage ? "error" : undefined } validationMessage={ - customGraphQLTypeValidationMessage + customGraphQLSingularTypeValidationMessage } hint={{ children: locConstants.schemaDesigner - .customGraphQLTypeHelp, + .customGraphQLSingularTypeHelp, className: classes.fieldHint, }}> - updateCustomGraphQLType(data.value) + updateCustomGraphQLSingularType( + data.value, + ) + } + /> + + + + updateCustomGraphQLPluralType( + data.value, + ) } /> @@ -657,6 +696,7 @@ export function DabEntitySettingsDialog({ locConstants.schemaDesigner .storedProcedureGraphQLOperation } + required hint={{ children: locConstants.schemaDesigner diff --git a/extensions/mssql/src/webviews/pages/SchemaDesigner/dab/dabEntityTable.tsx b/extensions/mssql/src/webviews/pages/SchemaDesigner/dab/dabEntityTable.tsx index a23acd930e..699fb98a88 100644 --- a/extensions/mssql/src/webviews/pages/SchemaDesigner/dab/dabEntityTable.tsx +++ b/extensions/mssql/src/webviews/pages/SchemaDesigner/dab/dabEntityTable.tsx @@ -127,10 +127,6 @@ function formatUnsupportedReasons(entity: Dab.DabEntityConfig): string { .join("; "); } -function getEntityFullName(entity: Dab.DabEntityConfig): string { - return `${entity.schemaName}.${entity.sourceName ?? entity.tableName}`; -} - function getSchemaGroupKey(schemaName: string): string { return schemaName.trim().toLowerCase(); } @@ -433,7 +429,6 @@ export const DabEntityTable = ({ entityFilters }: DabEntityTableProps) => { updateDabEntitySettings, updateDabApiTypes, dabTextFilter, - currentFilteredTables, } = context; const [expandedRows, setExpandedRows] = useState>(() => @@ -446,12 +441,6 @@ export const DabEntityTable = ({ entityFilters }: DabEntityTableProps) => { const hasInitializedExpandedRows = useRef(Boolean(dabConfig)); const scrollContainerRef = useRef(null); - const initialEnabledEntities = useRef>( - new Set( - dabConfig?.entities.filter((e) => e.isEnabled).map((e) => getEntityFullName(e)) ?? [], - ), - ); - useEffect(() => { if (!dabConfig) { return; @@ -459,30 +448,9 @@ export const DabEntityTable = ({ entityFilters }: DabEntityTableProps) => { if (!hasInitializedExpandedRows.current) { setExpandedRows(createDefaultExpandedRows(dabConfig)); - initialEnabledEntities.current = new Set( - dabConfig.entities.filter((e) => e.isEnabled).map((e) => getEntityFullName(e)), - ); hasInitializedExpandedRows.current = true; } - - const tablesToCheck: Set = - currentFilteredTables.length > 0 - ? new Set(currentFilteredTables) - : initialEnabledEntities.current; - - dabConfig.entities.forEach((entity) => { - if ((entity.sourceType ?? Dab.EntitySourceType.Table) !== Dab.EntitySourceType.Table) { - return; - } - - const fullName = getEntityFullName(entity); - const shouldCheck = tablesToCheck.has(fullName); - - if (initialEnabledEntities.current.has(fullName) && shouldCheck !== entity.isEnabled) { - toggleDabEntity(entity.id, shouldCheck); - } - }); - }, [currentFilteredTables, dabConfig, toggleDabEntity]); + }, [dabConfig]); const allActions = useMemo( () => [ @@ -717,18 +685,30 @@ export const DabEntityTable = ({ entityFilters }: DabEntityTableProps) => { [filteredEntities, headerActionState, toggleDabEntityAction], ); - // ── Schema-level checkbox ── + // ── Entity selection checkboxes ── + + const entitySelectionState = useCallback((entities: Dab.DabEntityConfig[]): CheckedState => { + const supported = entities.filter((e) => e.isSupported); + const enabledCount = supported.filter((e) => e.isEnabled).length; + return getCheckedState(supported.length, enabledCount); + }, []); - const toggleSchemaEntities = useCallback( + const toggleEntities = useCallback( (entities: Dab.DabEntityConfig[]) => { const supported = entities.filter((e) => e.isSupported); - const enabledCount = supported.filter((e) => e.isEnabled).length; - const shouldEnable = getCheckedState(supported.length, enabledCount) !== "checked"; + const shouldEnable = entitySelectionState(supported) !== "checked"; for (const entity of supported) { - toggleDabEntity(entity.id, shouldEnable); + if (entity.isEnabled !== shouldEnable) { + toggleDabEntity(entity.id, shouldEnable); + } } }, - [toggleDabEntity], + [entitySelectionState, toggleDabEntity], + ); + + const headerSelectionState = useMemo( + () => entitySelectionState(filteredEntities), + [entitySelectionState, filteredEntities], ); // ── Settings dialog ── @@ -782,15 +762,14 @@ export const DabEntityTable = ({ entityFilters }: DabEntityTableProps) => { (row: FlatRow) => { if (row.type === "schema") { const supported = row.entities.filter((e) => e.isSupported); - const enabledCount = supported.filter((e) => e.isEnabled).length; - const checkState = getCheckedState(supported.length, enabledCount); + const checkState = entitySelectionState(row.entities); return (
toggleSchemaEntities(row.entities)} + onChange={() => toggleEntities(row.entities)} aria-label={locConstants.schemaDesigner.toggleAllEntitiesInSchema( row.schemaName, )} @@ -801,15 +780,14 @@ export const DabEntityTable = ({ entityFilters }: DabEntityTableProps) => { if (row.type === "objectGroup") { const supported = row.entities.filter((e) => e.isSupported); - const enabledCount = supported.filter((e) => e.isEnabled).length; - const checkState = getCheckedState(supported.length, enabledCount); + const checkState = entitySelectionState(row.entities); return (
toggleSchemaEntities(row.entities)} + onChange={() => toggleEntities(row.entities)} aria-label={sourceTypeLabels[row.sourceType]} />
@@ -879,10 +857,11 @@ export const DabEntityTable = ({ entityFilters }: DabEntityTableProps) => { }, [ classes.centeredCell, + entitySelectionState, sourceTypeLabels, toggleDabColumnExposure, toggleDabEntity, - toggleSchemaEntities, + toggleEntities, ], ); @@ -1181,7 +1160,16 @@ export const DabEntityTable = ({ entityFilters }: DabEntityTableProps) => { () => [ createTableColumn({ columnId: "select", - renderHeaderCell: () => , + renderHeaderCell: () => ( +
+ e.isSupported).length === 0} + onChange={() => toggleEntities(filteredEntities)} + aria-label={locConstants.schemaDesigner.selectAllEntities} + /> +
+ ), renderCell: renderSelectContent, }), createTableColumn({ @@ -1260,6 +1248,7 @@ export const DabEntityTable = ({ entityFilters }: DabEntityTableProps) => { classes.sortableHeader, filteredEntities, headerActionState, + headerSelectionState, renderActionContent, renderExpandContent, renderNameContent, @@ -1267,6 +1256,7 @@ export const DabEntityTable = ({ entityFilters }: DabEntityTableProps) => { renderSettingsContent, renderSourceContent, sortDirection, + toggleEntities, toggleHeaderAction, ], ); diff --git a/extensions/mssql/src/webviews/pages/SchemaDesigner/schemaDesignerRpcHandlers.ts b/extensions/mssql/src/webviews/pages/SchemaDesigner/schemaDesignerRpcHandlers.ts index 98a82820b5..fe2672ed19 100644 --- a/extensions/mssql/src/webviews/pages/SchemaDesigner/schemaDesignerRpcHandlers.ts +++ b/extensions/mssql/src/webviews/pages/SchemaDesigner/schemaDesignerRpcHandlers.ts @@ -1164,6 +1164,14 @@ function normalizeDabConfigForVersion(config: Dab.DabConfig) { entity.advancedSettings.customGraphQLType !== undefined ? entity.advancedSettings.customGraphQLType : undefined, + customGraphQLSingularType: + entity.advancedSettings.customGraphQLSingularType !== undefined + ? entity.advancedSettings.customGraphQLSingularType + : undefined, + customGraphQLPluralType: + entity.advancedSettings.customGraphQLPluralType !== undefined + ? entity.advancedSettings.customGraphQLPluralType + : undefined, graphQLEnabled: entity.advancedSettings.graphQLEnabled, storedProcedureRestMethods: entity.advancedSettings.storedProcedureRestMethods !== undefined @@ -1681,23 +1689,29 @@ function applyDabToolChange( case "customGraphQLType": if (value === null || typeof value === "undefined") { delete updatedSettings.customGraphQLType; + delete updatedSettings.customGraphQLSingularType; + delete updatedSettings.customGraphQLPluralType; break; } - if (typeof value !== "string") { + if ( + typeof value !== "string" && + !(typeof value === "object" && value !== null && !Array.isArray(value)) + ) { return { success: false, reason: "invalid_request", - message: "customGraphQLType must be a string or null.", + message: + "customGraphQLType must be a string, singular/plural object, or null.", }; } - if (value.trim().length === 0) { + if (typeof value === "string" && value.trim().length === 0) { return { success: false, reason: "invalid_request", message: "customGraphQLType cannot be an empty string.", }; } - { + if (typeof value === "string") { const trimmedValue = value.trim(); const validationError = Dab.validateDabCustomGraphQLType(trimmedValue); if (validationError) { @@ -1707,9 +1721,116 @@ function applyDabToolChange( message: validationError, }; } - updatedSettings.customGraphQLType = trimmedValue; + delete updatedSettings.customGraphQLType; + updatedSettings.customGraphQLSingularType = trimmedValue; + delete updatedSettings.customGraphQLPluralType; + } else { + const graphQLTypeValue = value as Record; + const singularValue = graphQLTypeValue.singular; + const pluralValue = graphQLTypeValue.plural; + if (typeof singularValue !== "string" || singularValue.trim() === "") { + return { + success: false, + reason: "invalid_request", + message: + "customGraphQLType.singular must be a non-empty string.", + }; + } + if ( + typeof pluralValue !== "undefined" && + pluralValue !== null && + (typeof pluralValue !== "string" || pluralValue.trim() === "") + ) { + return { + success: false, + reason: "invalid_request", + message: + "customGraphQLType.plural must be a non-empty string or null.", + }; + } + + const singular = singularValue.trim(); + const plural = + typeof pluralValue === "string" ? pluralValue.trim() : undefined; + const singularValidationError = Dab.validateDabCustomGraphQLType( + singular, + "customGraphQLType.singular", + ); + if (singularValidationError) { + return { + success: false, + reason: "invalid_request", + message: singularValidationError, + }; + } + const pluralValidationError = plural + ? Dab.validateDabCustomGraphQLType( + plural, + "customGraphQLType.plural", + ) + : undefined; + if (pluralValidationError) { + return { + success: false, + reason: "invalid_request", + message: pluralValidationError, + }; + } + + delete updatedSettings.customGraphQLType; + updatedSettings.customGraphQLSingularType = singular; + if (plural) { + updatedSettings.customGraphQLPluralType = plural; + } else { + delete updatedSettings.customGraphQLPluralType; + } + } + break; + case "customGraphQLSingularType": + case "customGraphQLPluralType": { + const propertyName = key; + if (value === null || typeof value === "undefined") { + if (propertyName === "customGraphQLSingularType") { + delete updatedSettings.customGraphQLSingularType; + } else { + delete updatedSettings.customGraphQLPluralType; + } + break; + } + if (typeof value !== "string") { + return { + success: false, + reason: "invalid_request", + message: `${propertyName} must be a string or null.`, + }; + } + if (value.trim().length === 0) { + return { + success: false, + reason: "invalid_request", + message: `${propertyName} cannot be an empty string.`, + }; + } + const trimmedValue = value.trim(); + const validationError = Dab.validateDabCustomGraphQLType( + trimmedValue, + propertyName, + ); + if (validationError) { + return { + success: false, + reason: "invalid_request", + message: validationError, + }; + } + delete updatedSettings.customGraphQLType; + if (propertyName === "customGraphQLSingularType") { + updatedSettings.customGraphQLSingularType = trimmedValue; + } else { + updatedSettings.customGraphQLPluralType = trimmedValue; } break; + } case "graphQLEnabled": if (typeof value !== "boolean") { return { @@ -1736,26 +1857,29 @@ function applyDabToolChange( "storedProcedureRestMethods can only be set for stored procedure entities.", }; } - if (!Array.isArray(value) || value.length === 0) { + if (!Array.isArray(value) || value.length !== 1) { return { success: false, reason: "invalid_request", - message: "storedProcedureRestMethods must be a non-empty array.", + message: + "storedProcedureRestMethods must contain exactly one method.", }; } for (const method of value) { - if (!Object.values(Dab.RestMethod).includes(method as Dab.RestMethod)) { + if ( + !Dab.storedProcedureAllowedRestMethods.some( + (allowedMethod) => allowedMethod === method, + ) + ) { return { success: false, reason: "invalid_request", message: - "storedProcedureRestMethods must contain valid REST methods.", + "storedProcedureRestMethods must contain either 'get' or 'post'.", }; } } - updatedSettings.storedProcedureRestMethods = Dab.normalizeRestMethods( - value as Dab.RestMethod[], - ); + updatedSettings.storedProcedureRestMethods = [value[0] as Dab.RestMethod]; break; case "storedProcedureGraphQLOperation": if ( @@ -1812,6 +1936,19 @@ function applyDabToolChange( } } + if ( + updatedSettings.customGraphQLPluralType && + !updatedSettings.customGraphQLSingularType && + !updatedSettings.customGraphQLType + ) { + return { + success: false, + reason: "validation_error", + message: + "customGraphQLSingularType is required when customGraphQLPluralType is set.", + }; + } + config.entities[resolvedEntity.index] = { ...resolvedEntity.entity, advancedSettings: updatedSettings, diff --git a/localization/xliff/vscode-mssql.xlf b/localization/xliff/vscode-mssql.xlf index 244082505f..e029b8f421 100644 --- a/localization/xliff/vscode-mssql.xlf +++ b/localization/xliff/vscode-mssql.xlf @@ -1718,6 +1718,12 @@ Currently signed in as: + + Custom GraphQL Plural Type + + + Custom GraphQL Singular Type + Custom GraphQL Type @@ -1863,6 +1869,9 @@ Define who can access this endpoint + + Define who can execute this stored procedure + Definition @@ -4717,8 +4726,11 @@ Optional (False) - - Optional - Override default GraphQL type name + + Optional - Override default GraphQL plural type name + + + Optional - Override default GraphQL singular type name Optional - Override default api/entityName path @@ -5851,6 +5863,9 @@ Select all + + Select all entities + Select all options @@ -5904,8 +5919,8 @@ Select the Azure Data Studio settings.json file to scan for connection groups and connections. - - Select the HTTP methods that can execute this stored procedure. DAB defaults to POST. + + Select the HTTP method that can execute this stored procedure. DAB defaults to POST. Select the SQL Server Container Image From f4fe00e3ff3e6308edbe71597b5a3dc47b7d3dd4 Mon Sep 17 00:00:00 2001 From: "Aasim Khan (from Dev Box)" Date: Thu, 21 May 2026 13:57:26 -0700 Subject: [PATCH 2/9] Update DAB designer unit test expectations --- .../mssql/test/unit/dab/dabConfigFileBuilder.test.ts | 5 ++--- extensions/mssql/test/unit/dabTool.test.ts | 12 +++++------- 2 files changed, 7 insertions(+), 10 deletions(-) diff --git a/extensions/mssql/test/unit/dab/dabConfigFileBuilder.test.ts b/extensions/mssql/test/unit/dab/dabConfigFileBuilder.test.ts index a30e8747b2..2672ce3a15 100644 --- a/extensions/mssql/test/unit/dab/dabConfigFileBuilder.test.ts +++ b/extensions/mssql/test/unit/dab/dabConfigFileBuilder.test.ts @@ -427,7 +427,7 @@ suite("DabConfigFileBuilder Tests", () => { expect(entity.permissions).to.deep.equal([ { role: "anonymous", actions: ["execute"] }, ]); - expect(entity.rest).to.be.undefined; + expect(entity.rest).to.deep.equal({ methods: ["post"] }); expect(entity.graphql).to.be.undefined; expect(entity.mcp).to.deep.equal({ "custom-tool": true, @@ -579,7 +579,6 @@ suite("DabConfigFileBuilder Tests", () => { entityName: "GetUsers", authorizationRole: Dab.AuthorizationRole.Anonymous, storedProcedureRestMethods: [ - Dab.RestMethod.Post, Dab.RestMethod.Get, Dab.RestMethod.Post, ], @@ -590,7 +589,7 @@ suite("DabConfigFileBuilder Tests", () => { const result = builder.build(config, defaultConnectionInfo); const parsed = JSON.parse(result); expect(parsed.entities["GetUsers"].rest).to.deep.equal({ - methods: ["get", "post"], + methods: ["get"], }); }); }); diff --git a/extensions/mssql/test/unit/dabTool.test.ts b/extensions/mssql/test/unit/dabTool.test.ts index 6e59cc23b3..2b97aa7a54 100644 --- a/extensions/mssql/test/unit/dabTool.test.ts +++ b/extensions/mssql/test/unit/dabTool.test.ts @@ -1299,7 +1299,9 @@ suite("DabTool Tests", () => { expect(settings).to.exist; expect(settings?.entityName).to.equal("UsersApi"); expect(settings?.customRestPath).to.equal("/users"); - expect(settings?.customGraphQLType).to.equal("UsersType"); + expect(settings?.customGraphQLType).to.equal(undefined); + expect(settings?.customGraphQLSingularType).to.equal("UsersType"); + expect(settings?.customGraphQLPluralType).to.equal(undefined); }); test("apply_changes patch_entity_settings rejects unsafe string values", async () => { @@ -1499,11 +1501,7 @@ suite("DabTool Tests", () => { type: "patch_entity_settings", entity: { id: "stored-procedure:dbo.GetUsers" }, set: { - storedProcedureRestMethods: [ - Dab.RestMethod.Post, - Dab.RestMethod.Get, - Dab.RestMethod.Post, - ], + storedProcedureRestMethods: [Dab.RestMethod.Get], storedProcedureGraphQLOperation: Dab.GraphQLOperation.Query, }, }, @@ -1515,7 +1513,7 @@ suite("DabTool Tests", () => { throw new Error("Expected success response"); } const settings = result.config?.entities[0].advancedSettings; - expect(settings?.storedProcedureRestMethods).to.deep.equal(["get", "post"]); + expect(settings?.storedProcedureRestMethods).to.deep.equal(["get"]); expect(settings?.storedProcedureGraphQLOperation).to.equal("query"); }); From 6818256b08f06dd2b12d695da1773829c0425ca5 Mon Sep 17 00:00:00 2001 From: "Aasim Khan (from Dev Box)" Date: Thu, 21 May 2026 17:57:11 -0700 Subject: [PATCH 3/9] Rename DAB auth role section to permissions --- extensions/mssql/l10n/bundle.l10n.json | 2 +- extensions/mssql/src/webviews/common/locConstants.ts | 2 +- localization/xliff/vscode-mssql.xlf | 6 +++--- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/extensions/mssql/l10n/bundle.l10n.json b/extensions/mssql/l10n/bundle.l10n.json index 0696b1f3cb..4605302feb 100644 --- a/extensions/mssql/l10n/bundle.l10n.json +++ b/extensions/mssql/l10n/bundle.l10n.json @@ -965,7 +965,7 @@ "REST": "REST", "Entity Name": "Entity Name", "Used in API routes and responses": "Used in API routes and responses", - "Authorization Role": "Authorization Role", + "Permissions": "Permissions", "Define who can access this endpoint": "Define who can access this endpoint", "Define who can execute this stored procedure": "Define who can execute this stored procedure", "Disabled globally": "Disabled globally", diff --git a/extensions/mssql/src/webviews/common/locConstants.ts b/extensions/mssql/src/webviews/common/locConstants.ts index 7124e2f5a3..2436b30f66 100644 --- a/extensions/mssql/src/webviews/common/locConstants.ts +++ b/extensions/mssql/src/webviews/common/locConstants.ts @@ -1493,7 +1493,7 @@ export class LocConstants { rest: l10n.t("REST"), entityName: l10n.t("Entity Name"), entityNameHelp: l10n.t("Used in API routes and responses"), - authorizationRole: l10n.t("Authorization Role"), + authorizationRole: l10n.t("Permissions"), authorizationRoleHelp: l10n.t("Define who can access this endpoint"), authorizationRoleStoredProcedureHelp: l10n.t( "Define who can execute this stored procedure", diff --git a/localization/xliff/vscode-mssql.xlf b/localization/xliff/vscode-mssql.xlf index e029b8f421..8107539099 100644 --- a/localization/xliff/vscode-mssql.xlf +++ b/localization/xliff/vscode-mssql.xlf @@ -483,9 +483,6 @@ Authentication not supported - - Authorization Role - Auto Arrange @@ -4824,6 +4821,9 @@ Perform checksum before writing to media + + Permissions + Pick from multiple SQL Server versions, including SQL Server 2025 with built-in AI capabilities like vector search and JSON enhancements. From fd00c5ef9efcc1e6b389bc38935f22ea97db1d95 Mon Sep 17 00:00:00 2001 From: "Aasim Khan (from Dev Box)" Date: Thu, 21 May 2026 18:02:37 -0700 Subject: [PATCH 4/9] Move DAB setting hints to info tooltips --- .../dab/dabEntitySettingsDialog.tsx | 121 +++++++++--------- 1 file changed, 61 insertions(+), 60 deletions(-) diff --git a/extensions/mssql/src/webviews/pages/SchemaDesigner/dab/dabEntitySettingsDialog.tsx b/extensions/mssql/src/webviews/pages/SchemaDesigner/dab/dabEntitySettingsDialog.tsx index 9210edff87..7530d7854e 100644 --- a/extensions/mssql/src/webviews/pages/SchemaDesigner/dab/dabEntitySettingsDialog.tsx +++ b/extensions/mssql/src/webviews/pages/SchemaDesigner/dab/dabEntitySettingsDialog.tsx @@ -24,9 +24,10 @@ import { RadioGroup, Text, ToggleButton, + Tooltip, tokens, } from "@fluentui/react-components"; -import { Dismiss24Regular, Table16Regular } from "@fluentui/react-icons"; +import { Dismiss24Regular, Info16Regular, Table16Regular } from "@fluentui/react-icons"; import { useEffect, useState } from "react"; import { locConstants } from "../../../common/locConstants"; import { Dab } from "../../../../sharedInterfaces/dab"; @@ -120,6 +121,19 @@ const useStyles = makeStyles({ fontWeight: tokens.fontWeightRegular, lineHeight: tokens.lineHeightBase200, }, + labelWithInfo: { + display: "inline-flex", + alignItems: "center", + columnGap: "4px", + }, + infoButton: { + color: tokens.colorNeutralForeground3, + minWidth: "16px", + width: "16px", + height: "16px", + padding: 0, + verticalAlign: "middle", + }, roleButtonsContainer: { display: "flex", gap: "8px", @@ -294,9 +308,6 @@ export function DabEntitySettingsDialog({ const storedProcedureGraphQLOperation = localSettings.storedProcedureGraphQLOperation ?? Dab.GraphQLOperation.Mutation; const exposeAsMcpCustomTool = localSettings.exposeAsMcpCustomTool !== false; - const authorizationRoleHelp = isStoredProcedure - ? locConstants.schemaDesigner.authorizationRoleStoredProcedureHelp - : locConstants.schemaDesigner.authorizationRoleHelp; const sourceObjectName = `${entity.schemaName}.${entity.sourceName ?? entity.tableName}`; const entityName = localSettings.entityName.trim(); const customRestPath = localSettings.customRestPath?.trim() ?? ""; @@ -369,6 +380,21 @@ export function DabEntitySettingsDialog({ {title} ); + const renderLabelWithInfo = (label: string, infoText: string) => ( + + {label} + + - - - - - + + )} +
+ + )} + + + + + + ); } From c8a9262944c0bcf371abc4605a8f4bdab4bfd327 Mon Sep 17 00:00:00 2001 From: "Aasim Khan (from Dev Box)" Date: Thu, 21 May 2026 18:08:20 -0700 Subject: [PATCH 6/9] Style DAB settings drawer header and footer --- .../SchemaDesigner/dab/dabEntitySettingsDialog.tsx | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/extensions/mssql/src/webviews/pages/SchemaDesigner/dab/dabEntitySettingsDialog.tsx b/extensions/mssql/src/webviews/pages/SchemaDesigner/dab/dabEntitySettingsDialog.tsx index 3aa38497db..c68a82c11a 100644 --- a/extensions/mssql/src/webviews/pages/SchemaDesigner/dab/dabEntitySettingsDialog.tsx +++ b/extensions/mssql/src/webviews/pages/SchemaDesigner/dab/dabEntitySettingsDialog.tsx @@ -37,12 +37,18 @@ const useStyles = makeStyles({ drawer: { width: "640px", maxWidth: "calc(100vw - 32px)", + backgroundColor: "var(--vscode-editor-background)", + }, + drawerHeader: { + backgroundColor: "var(--vscode-editorWidget-background, var(--vscode-editor-background))", + borderBottom: "1px solid var(--vscode-editorGroup-border)", }, drawerBody: { display: "flex", flexDirection: "column", rowGap: "16px", overflowY: "auto", + backgroundColor: "var(--vscode-editor-background)", }, headerTitleContent: { display: "flex", @@ -174,6 +180,8 @@ const useStyles = makeStyles({ columnGap: "12px", paddingTop: "12px", marginTop: 0, + backgroundColor: "var(--vscode-editorWidget-background, var(--vscode-editor-background))", + borderTop: "1px solid var(--vscode-editorGroup-border)", }, actionButton: { minWidth: "132px", @@ -414,7 +422,7 @@ export function DabEntitySettingsDialog({ open={open} onOpenChange={(_, { open }) => onOpenChange(open)} className={classes.drawer}> - + Date: Thu, 21 May 2026 18:10:20 -0700 Subject: [PATCH 7/9] Add padding to DAB settings drawer body --- .../pages/SchemaDesigner/dab/dabEntitySettingsDialog.tsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/extensions/mssql/src/webviews/pages/SchemaDesigner/dab/dabEntitySettingsDialog.tsx b/extensions/mssql/src/webviews/pages/SchemaDesigner/dab/dabEntitySettingsDialog.tsx index c68a82c11a..fc415ac63d 100644 --- a/extensions/mssql/src/webviews/pages/SchemaDesigner/dab/dabEntitySettingsDialog.tsx +++ b/extensions/mssql/src/webviews/pages/SchemaDesigner/dab/dabEntitySettingsDialog.tsx @@ -49,6 +49,8 @@ const useStyles = makeStyles({ rowGap: "16px", overflowY: "auto", backgroundColor: "var(--vscode-editor-background)", + paddingTop: "16px", + paddingBottom: "16px", }, headerTitleContent: { display: "flex", From e9632f3a248881fb6967eff637e556a1e6b5aa5f Mon Sep 17 00:00:00 2001 From: "Aasim Khan (from Dev Box)" Date: Fri, 22 May 2026 17:16:37 -0700 Subject: [PATCH 8/9] feat: Enhance DAB tool functionality with new entity surface and permission management - Updated `schemaDesignerRpcHandlers.ts` to include new methods for managing entity surface settings and permissions. - Introduced `createEntityWithSurfaceEnabled` to handle enabling/disabling API types for entities. - Added `validatePermissionConfig` to validate permissions for entities, ensuring correct role and action configurations. - Enhanced `applyDabToolChange` to support new changes related to entity surface and permissions. - Updated tests in `dabConfigFileBuilder.test.ts`, `dabSharedInterfaces.test.ts`, `dabTool.test.ts`, and `dabToolManifest.test.ts` to cover new functionalities and ensure correctness. - Adjusted existing tests to reflect changes in entity configurations and expected outputs. --- extensions/mssql/l10n/bundle.l10n.json | 30 +- extensions/mssql/package.json | 292 ++- extensions/mssql/src/copilot/tools/dabTool.ts | 35 + .../mssql/src/dab/dabConfigFileBuilder.ts | 101 +- .../schemaDesignerWebviewController.ts | 1 + extensions/mssql/src/sharedInterfaces/dab.ts | 481 ++++- .../mssql/src/webviews/common/locConstants.ts | 43 + .../pages/SchemaDesigner/dab/dabContext.tsx | 49 +- .../SchemaDesigner/dab/dabEntityFilters.ts | 65 +- .../dab/dabEntitySettingsDialog.tsx | 1916 +++++++++++++---- .../SchemaDesigner/dab/dabEntityTable.tsx | 886 +++++--- .../pages/SchemaDesigner/dab/dabPills.css | 55 + .../pages/SchemaDesigner/dab/dabPills.tsx | 30 + .../pages/SchemaDesigner/dab/dabToolbar.tsx | 265 +-- .../schemaDesignerRpcHandlers.ts | 468 +++- .../unit/dab/dabConfigFileBuilder.test.ts | 66 +- .../test/unit/dab/dabSharedInterfaces.test.ts | 11 +- extensions/mssql/test/unit/dabTool.test.ts | 52 +- .../mssql/test/unit/dabToolManifest.test.ts | 26 +- localization/xliff/vscode-mssql.xlf | 76 + 20 files changed, 3922 insertions(+), 1026 deletions(-) create mode 100644 extensions/mssql/src/webviews/pages/SchemaDesigner/dab/dabPills.css create mode 100644 extensions/mssql/src/webviews/pages/SchemaDesigner/dab/dabPills.tsx diff --git a/extensions/mssql/l10n/bundle.l10n.json b/extensions/mssql/l10n/bundle.l10n.json index 4605302feb..d4730d3cd0 100644 --- a/extensions/mssql/l10n/bundle.l10n.json +++ b/extensions/mssql/l10n/bundle.l10n.json @@ -876,6 +876,8 @@ "Enable REST in API Type to expose this entity through REST.": "Enable REST in API Type to expose this entity through REST.", "Expose this entity through GraphQL": "Expose this entity through GraphQL", "Enable GraphQL in API Type to expose this entity through GraphQL.": "Enable GraphQL in API Type to expose this entity through GraphQL.", + "Expose this entity through MCP": "Expose this entity through MCP", + "Enable MCP in API Type to expose this entity through MCP.": "Enable MCP in API Type to expose this entity through MCP.", "Stored procedure REST methods": "Stored procedure REST methods", "Select the HTTP method that can execute this stored procedure. DAB defaults to POST.": "Select the HTTP method that can execute this stored procedure. DAB defaults to POST.", "Stored procedure GraphQL operation": "Stored procedure GraphQL operation", @@ -883,9 +885,13 @@ "Mutation": "Mutation", "Query": "Query", "MCP custom tool": "MCP custom tool", + "MCP DML tools": "MCP DML tools", "Expose as MCP custom tool": "Expose as MCP custom tool", "Creates a dedicated MCP tool for this stored procedure. When disabled, the procedure can still be available through generic MCP execute tools if MCP is enabled.": "Creates a dedicated MCP tool for this stored procedure. When disabled, the procedure can still be available through generic MCP execute tools if MCP is enabled.", "Enable MCP in API Type to use this custom tool setting.": "Enable MCP in API Type to use this custom tool setting.", + "DML tools expose this entity through generic MCP tools.": "DML tools expose this entity through generic MCP tools.", + "DML tools expose this stored procedure through generic execute tools.": "DML tools expose this stored procedure through generic execute tools.", + "Custom tool creates a dedicated MCP tool for this stored procedure.": "Custom tool creates a dedicated MCP tool for this stored procedure.", "{0} is not enabled globally/{0} is the API type, e.g. REST, GraphQL, or MCP": { "message": "{0} is not enabled globally", "comment": ["{0} is the API type, e.g. REST, GraphQL, or MCP"] @@ -904,6 +910,10 @@ "Filter entities": "Filter entities", "Status": "Status", "Object type": "Object type", + "Exposed via": "Exposed via", + "Auth mode": "Auth mode", + "Not exposed": "Not exposed", + "No permissions": "No permissions", "Enabled": "Enabled", "Disabled": "Disabled", "Warnings": "Warnings", @@ -915,6 +925,7 @@ "Read": "Read", "Update": "Update", "Execute": "Execute", + "Exec": "Exec", "View": "View", "Stored Procedure": "Stored Procedure", "Tables": "Tables", @@ -954,6 +965,7 @@ "Deploy": "Deploy", "Local container deployment is currently only supported with SQL Authentication connections.": "Local container deployment is currently only supported with SQL Authentication connections.", "At least one API type must be selected.": "At least one API type must be selected.", + "Select at least one logical key column before applying or deploying this exposed table or view.": "Select at least one logical key column before applying or deploying this exposed table or view.", "Authentication not supported": "Authentication not supported", "In the Data API builder experience, local container deployment is only available for connections using SQL Authentication. Your current connection type is not supported.": "In the Data API builder experience, local container deployment is only available for connections using SQL Authentication. Your current connection type is not supported.", "Unsupported data types detected": "Unsupported data types detected", @@ -970,9 +982,22 @@ "Define who can execute this stored procedure": "Define who can execute this stored procedure", "Disabled globally": "Disabled globally", "Anonymous": "Anonymous", + "Anon": "Anon", "No authentication required": "No authentication required", "Authenticated": "Authenticated", + "Auth": "Auth", "Requires user authentication": "Requires user authentication", + "Alias": "Alias", + "Key": "Key", + "Logical key": "Logical key", + "Expose": "Expose", + "Exposed": "Exposed", + "Hidden": "Hidden", + "Required": "Required", + "Required parameter": "Required parameter", + "Optional": "Optional", + "No columns were discovered for this entity.": "No columns were discovered for this entity.", + "No parameters were discovered for this stored procedure.": "No parameters were discovered for this stored procedure.", "Custom REST Path": "Custom REST Path", "Optional - Override default api/entityName path": "Optional - Override default api/entityName path", "Custom GraphQL Type": "Custom GraphQL Type", @@ -996,6 +1021,10 @@ "message": "Enable {0}", "comment": ["{0} is the entity name"] }, + "Include {0}/{0} is the entity name": { + "message": "Include {0}", + "comment": ["{0} is the entity name"] + }, "Toggle columns for {0}/{0} is the entity name": { "message": "Toggle columns for {0}", "comment": ["{0} is the entity name"] @@ -2831,7 +2860,6 @@ }, "Invalid SQL Server password for {0}. Password must be 8–128 characters long and meet the complexity requirements. For more information see https://docs.microsoft.com/sql/relational-databases/security/password-policy": "Invalid SQL Server password for {0}. Password must be 8–128 characters long and meet the complexity requirements. For more information see https://docs.microsoft.com/sql/relational-databases/security/password-policy", "{0} password doesn't match the confirmation password": "{0} password doesn't match the confirmation password", - "Required": "Required", "You must accept the license": "You must accept the license", "Failed to load publish profile": "Failed to load publish profile", "Publish profile saved to: {0}": "Publish profile saved to: {0}", diff --git a/extensions/mssql/package.json b/extensions/mssql/package.json index 0fab203ac0..d8d234382e 100644 --- a/extensions/mssql/package.json +++ b/extensions/mssql/package.json @@ -3762,7 +3762,7 @@ }, { "name": "mssql_dab", - "modelDescription": "Work with the Data API builder configuration in the currently active schema designer. Use 'get_state' to inspect it and 'apply_changes' to modify it; use 'show' only to open the Data API builder view for a connectionId when needed. Operations: 'get_state' returns the current version, summary, and bounded configuration state (full config may be omitted for large configurations); 'apply_changes' uses payload { expectedVersion, targetHint?, changes } and returns the post-mutation version plus summary, with optional returnState control. Mutations require 'expectedVersion'; on mismatch, returns 'stale_state' with the current version and latest summary/config according to returnState. Entity refs are either { id }, { schemaName, tableName } for table compatibility, or { schemaName, sourceName, sourceType } where sourceType is table, view, or stored-procedure. Column refs are either { id } or { name }. Change types: { type: 'set_api_types', apiTypes }, { type: 'add_entity', entity }, { type: 'remove_entity', entity }, { type: 'set_entity_enabled', entity, isEnabled }, { type: 'set_entity_actions', entity, enabledActions }, { type: 'set_column_exposed', entity, column, isExposed }, { type: 'patch_entity_settings', entity, set }, { type: 'set_only_enabled_entities', entities }, { type: 'set_all_entities_enabled', isEnabled }. patch_entity_settings.set supports entityName, authorizationRole, customRestPath, restEnabled, customGraphQLType, graphQLEnabled, and stored-procedure-only storedProcedureRestMethods, storedProcedureGraphQLOperation, exposeAsMcpCustomTool.", + "modelDescription": "Work with the Data API builder configuration in the currently active schema designer. Use 'get_state' to inspect it and 'apply_changes' to modify it; use 'show' only to open the Data API builder view for a connectionId when needed. Operations: 'get_state' returns the current version, summary, and bounded configuration state (full config may be omitted for large configurations); 'apply_changes' uses payload { expectedVersion, targetHint?, changes } and returns the post-mutation version plus summary, with optional returnState control. Mutations require 'expectedVersion'; on mismatch, returns 'stale_state' with the current version and latest summary/config according to returnState. Entity refs are either { id }, { schemaName, tableName } for table compatibility, or { schemaName, sourceName, sourceType } where sourceType is table, view, or stored-procedure. Column refs are either { id } or { name }. Change types: { type: 'set_api_types', apiTypes }, { type: 'add_entity', entity }, { type: 'remove_entity', entity }, { type: 'set_entity_enabled', entity, isEnabled } for legacy all-surface compatibility, { type: 'set_entity_surface', entity, apiType, isEnabled }, { type: 'set_entity_actions', entity, enabledActions } for legacy selected-role compatibility, { type: 'set_entity_permissions', entity, permissions }, { type: 'set_column_exposed', entity, column, isExposed }, { type: 'set_field_metadata', entity, field, alias?, description?, isPrimaryKey? }, { type: 'set_parameter_metadata', entity, parameter, isRequired?, defaultValue?, clearDefault?, description? }, { type: 'set_entity_mcp', entity, enabled?, dmlToolsEnabled?, customToolEnabled? }, { type: 'patch_entity_settings', entity, set }, { type: 'set_only_enabled_entities', entities }, { type: 'set_all_entities_enabled', isEnabled }. patch_entity_settings.set supports entityName, description, authorizationRole, customRestPath, restEnabled, customGraphQLType, customGraphQLSingularType, customGraphQLPluralType, graphQLEnabled, mcpEnabled, mcpDmlToolsEnabled, and stored-procedure-only storedProcedureRestMethods, storedProcedureGraphQLOperation, exposeAsMcpCustomTool, mcpCustomToolEnabled.", "tags": [ "databases", "mssql", @@ -3868,6 +3868,50 @@ } ] }, + "parameterRef": { + "type": "object", + "properties": { + "name": { + "type": "string", + "minLength": 1 + } + }, + "required": [ + "name" + ], + "additionalProperties": false + }, + "entityPermission": { + "type": "object", + "properties": { + "role": { + "type": "string", + "enum": [ + "anonymous", + "authenticated" + ] + }, + "actions": { + "type": "array", + "uniqueItems": true, + "items": { + "type": "string", + "enum": [ + "create", + "read", + "update", + "delete", + "execute" + ] + } + } + }, + "required": [ + "role", + "actions" + ], + "additionalProperties": false + }, "advancedSettingsPatch": { "type": "object", "properties": { @@ -3875,6 +3919,18 @@ "type": "string", "minLength": 1 }, + "description": { + "description": "Optional entity description. Use null to clear.", + "oneOf": [ + { + "type": "string", + "minLength": 1 + }, + { + "type": "null" + } + ] + }, "authorizationRole": { "type": "string", "enum": [ @@ -3910,10 +3966,42 @@ } ] }, + "customGraphQLSingularType": { + "description": "Entity GraphQL singular type override. Use null to clear.", + "oneOf": [ + { + "type": "string", + "minLength": 1 + }, + { + "type": "null" + } + ] + }, + "customGraphQLPluralType": { + "description": "Entity GraphQL plural type override. Requires customGraphQLSingularType. Use null to clear.", + "oneOf": [ + { + "type": "string", + "minLength": 1 + }, + { + "type": "null" + } + ] + }, "graphQLEnabled": { "description": "When true, exposes the entity through GraphQL if GraphQL is globally enabled.", "type": "boolean" }, + "mcpEnabled": { + "description": "When true, exposes the entity through MCP if MCP is globally enabled.", + "type": "boolean" + }, + "mcpDmlToolsEnabled": { + "description": "When true, enables generic MCP DML tools for this entity.", + "type": "boolean" + }, "storedProcedureRestMethods": { "description": "Stored procedure only. HTTP methods that can execute the stored procedure through REST. Use null to clear and use the DAB default.", "oneOf": [ @@ -3925,10 +4013,7 @@ "type": "string", "enum": [ "get", - "post", - "put", - "patch", - "delete" + "post" ] } }, @@ -3948,6 +4033,10 @@ "exposeAsMcpCustomTool": { "description": "Stored procedure only. When true, exposes the stored procedure as a dedicated MCP custom tool and hides it from generic MCP DML tools.", "type": "boolean" + }, + "mcpCustomToolEnabled": { + "description": "Stored procedure only. When true, exposes the stored procedure as a dedicated MCP custom tool.", + "type": "boolean" } }, "minProperties": 1, @@ -3972,7 +4061,7 @@ "minLength": 1 }, "payload": { - "description": "Operation-specific payload. apply_changes: { expectedVersion, targetHint?, changes }. Change types: set_api_types { apiTypes }, add_entity { entity }, remove_entity { entity }, set_entity_enabled { entity, isEnabled }, set_entity_actions { entity, enabledActions }, set_column_exposed { entity, column, isExposed }, patch_entity_settings { entity, set }, set_only_enabled_entities { entities }, set_all_entities_enabled { isEnabled }.", + "description": "Operation-specific payload. apply_changes: { expectedVersion, targetHint?, changes }. Change types: set_api_types { apiTypes }, add_entity { entity }, remove_entity { entity }, set_entity_enabled { entity, isEnabled }, set_entity_surface { entity, apiType, isEnabled }, set_entity_actions { entity, enabledActions }, set_entity_permissions { entity, permissions }, set_column_exposed { entity, column, isExposed }, set_field_metadata { entity, field, alias?, description?, isPrimaryKey? }, set_parameter_metadata { entity, parameter, isRequired?, defaultValue?, clearDefault?, description? }, set_entity_mcp { entity, enabled?, dmlToolsEnabled?, customToolEnabled? }, patch_entity_settings { entity, set }, set_only_enabled_entities { entities }, set_all_entities_enabled { isEnabled }.", "title": "Payload", "type": "object", "properties": { @@ -4035,6 +4124,170 @@ ], "additionalProperties": false }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "set_field_metadata" + ] + }, + "entity": { + "$ref": "#/$defs/entityRef" + }, + "field": { + "$ref": "#/$defs/columnRef" + }, + "alias": { + "oneOf": [ + { + "type": "string", + "minLength": 1 + }, + { + "type": "null" + } + ] + }, + "description": { + "oneOf": [ + { + "type": "string", + "minLength": 1 + }, + { + "type": "null" + } + ] + }, + "isPrimaryKey": { + "type": "boolean" + } + }, + "required": [ + "type", + "entity", + "field" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "set_parameter_metadata" + ] + }, + "entity": { + "$ref": "#/$defs/entityRef" + }, + "parameter": { + "$ref": "#/$defs/parameterRef" + }, + "isRequired": { + "type": "boolean" + }, + "defaultValue": { + "oneOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "clearDefault": { + "type": "boolean" + }, + "description": { + "oneOf": [ + { + "type": "string", + "minLength": 1 + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "type", + "entity", + "parameter" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "set_entity_mcp" + ] + }, + "entity": { + "$ref": "#/$defs/entityRef" + }, + "enabled": { + "type": "boolean" + }, + "dmlToolsEnabled": { + "type": "boolean" + }, + "customToolEnabled": { + "type": "boolean" + } + }, + "required": [ + "type", + "entity" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "set_entity_surface" + ] + }, + "entity": { + "$ref": "#/$defs/entityRef" + }, + "apiType": { + "type": "string", + "enum": [ + "rest", + "graphql", + "mcp" + ] + }, + "isEnabled": { + "type": "boolean" + } + }, + "required": [ + "type", + "entity", + "apiType", + "isEnabled" + ], + "additionalProperties": false + }, { "type": "object", "properties": { @@ -4054,6 +4307,33 @@ ], "additionalProperties": false }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "set_entity_permissions" + ] + }, + "entity": { + "$ref": "#/$defs/entityRef" + }, + "permissions": { + "type": "array", + "minItems": 1, + "items": { + "$ref": "#/$defs/entityPermission" + } + } + }, + "required": [ + "type", + "entity", + "permissions" + ], + "additionalProperties": false + }, { "type": "object", "properties": { diff --git a/extensions/mssql/src/copilot/tools/dabTool.ts b/extensions/mssql/src/copilot/tools/dabTool.ts index db2e58824a..ebd13ccbd8 100644 --- a/extensions/mssql/src/copilot/tools/dabTool.ts +++ b/extensions/mssql/src/copilot/tools/dabTool.ts @@ -48,8 +48,13 @@ interface DabToolChangeCounts { remove_entity_count: number; set_api_types_count: number; set_entity_enabled_count: number; + set_entity_surface_count: number; set_entity_actions_count: number; + set_entity_permissions_count: number; set_column_exposed_count: number; + set_field_metadata_count: number; + set_parameter_metadata_count: number; + set_entity_mcp_count: number; patch_entity_settings_count: number; set_only_enabled_entities_count: number; set_all_entities_enabled_count: number; @@ -60,8 +65,13 @@ interface DabToolReceipt { removeEntityCount: number; setApiTypesCount: number; setEntityEnabledCount: number; + setEntitySurfaceCount: number; setEntityActionsCount: number; + setEntityPermissionsCount: number; setColumnExposedCount: number; + setFieldMetadataCount: number; + setParameterMetadataCount: number; + setEntityMcpCount: number; patchEntitySettingsCount: number; setOnlyEnabledEntitiesCount: number; setAllEntitiesEnabledCount: number; @@ -150,8 +160,13 @@ export class DabTool extends ToolBase { remove_entity_count: 0, set_api_types_count: 0, set_entity_enabled_count: 0, + set_entity_surface_count: 0, set_entity_actions_count: 0, + set_entity_permissions_count: 0, set_column_exposed_count: 0, + set_field_metadata_count: 0, + set_parameter_metadata_count: 0, + set_entity_mcp_count: 0, patch_entity_settings_count: 0, set_only_enabled_entities_count: 0, set_all_entities_enabled_count: 0, @@ -171,12 +186,27 @@ export class DabTool extends ToolBase { case "set_entity_enabled": counts.set_entity_enabled_count++; break; + case "set_entity_surface": + counts.set_entity_surface_count++; + break; case "set_entity_actions": counts.set_entity_actions_count++; break; + case "set_entity_permissions": + counts.set_entity_permissions_count++; + break; case "set_column_exposed": counts.set_column_exposed_count++; break; + case "set_field_metadata": + counts.set_field_metadata_count++; + break; + case "set_parameter_metadata": + counts.set_parameter_metadata_count++; + break; + case "set_entity_mcp": + counts.set_entity_mcp_count++; + break; case "patch_entity_settings": counts.patch_entity_settings_count++; break; @@ -197,8 +227,13 @@ export class DabTool extends ToolBase { removeEntityCount: counts.remove_entity_count, setApiTypesCount: counts.set_api_types_count, setEntityEnabledCount: counts.set_entity_enabled_count, + setEntitySurfaceCount: counts.set_entity_surface_count, setEntityActionsCount: counts.set_entity_actions_count, + setEntityPermissionsCount: counts.set_entity_permissions_count, setColumnExposedCount: counts.set_column_exposed_count, + setFieldMetadataCount: counts.set_field_metadata_count, + setParameterMetadataCount: counts.set_parameter_metadata_count, + setEntityMcpCount: counts.set_entity_mcp_count, patchEntitySettingsCount: counts.patch_entity_settings_count, setOnlyEnabledEntitiesCount: counts.set_only_enabled_entities_count, setAllEntitiesEnabledCount: counts.set_all_entities_enabled_count, diff --git a/extensions/mssql/src/dab/dabConfigFileBuilder.ts b/extensions/mssql/src/dab/dabConfigFileBuilder.ts index 9e3294c4e3..1eeb9a277a 100644 --- a/extensions/mssql/src/dab/dabConfigFileBuilder.ts +++ b/extensions/mssql/src/dab/dabConfigFileBuilder.ts @@ -37,10 +37,11 @@ interface DabRuntimeConfig { * https://learn.microsoft.com/en-us/azure/data-api-builder/configuration/entities#entities */ interface DabEntityOutput { + description?: string; source: { type: string; object: string; - "key-fields"?: string[]; + description?: string; parameters?: DabParameterOutput[]; }; fields?: Array<{ @@ -52,7 +53,7 @@ interface DabEntityOutput { rest: boolean | { path?: string; methods?: string[] } | undefined; graphql: boolean | { type?: DabGraphQLTypeOutput; operation?: string } | undefined; permissions: DabPermissionEntry[]; - mcp?: { "custom-tool"?: boolean; "dml-tools"?: boolean }; + mcp?: boolean | { "custom-tool"?: boolean; "dml-tools"?: boolean }; } type DabGraphQLTypeOutput = string | { singular: string; plural?: string }; @@ -166,7 +167,7 @@ export class DabConfigFileBuilder { ): Record { const result: Record = {}; for (const entity of entities) { - if (!entity.isEnabled || !entity.isSupported) { + if (!entity.isSupported || !Dab.isEntityExposed(entity)) { continue; } result[entity.advancedSettings.entityName] = this.buildEntityEntry( @@ -192,18 +193,20 @@ export class DabConfigFileBuilder { isMcpEnabled: boolean, ): DabEntityOutput { const restConfig = - isRestEnabled && entity.advancedSettings.restEnabled !== false + isRestEnabled && Dab.isEntityRestEnabled(entity) ? this.buildRestProperty(entity) : false; const graphqlConfig = - isGraphQLEnabled && entity.advancedSettings.graphQLEnabled !== false + isGraphQLEnabled && Dab.isEntityGraphQLEnabled(entity) ? this.buildGraphQLProperty(entity) : false; + const description = entity.advancedSettings.description?.trim(); const output: DabEntityOutput = { + ...(description ? { description } : {}), source: { type: entity.sourceType ?? Dab.EntitySourceType.Table, object: `${entity.schemaName}.${entity.sourceName ?? entity.tableName}`, - ...this.buildKeyFieldsProperty(entity), + ...(description ? { description } : {}), ...(entity.sourceType === Dab.EntitySourceType.StoredProcedure && entity.parameters?.length ? { @@ -218,8 +221,9 @@ export class DabConfigFileBuilder { permissions: this.buildPermissions(entity), }; - if (entity.fields?.length) { - output.fields = entity.fields.map((field) => ({ + const fields = this.buildFieldsProperty(entity); + if (fields.length) { + output.fields = fields.map((field) => ({ name: field.name, ...(field.alias ? { alias: field.alias } : {}), ...(field.description ? { description: field.description } : {}), @@ -227,30 +231,37 @@ export class DabConfigFileBuilder { })); } - if ( - isMcpEnabled && - entity.sourceType === Dab.EntitySourceType.StoredProcedure && - entity.advancedSettings.exposeAsMcpCustomTool !== false - ) { - output.mcp = { - "custom-tool": true, - "dml-tools": false, - }; + if (isMcpEnabled) { + output.mcp = this.buildMcpProperty(entity); } return output; } - private buildKeyFieldsProperty(entity: Dab.DabEntityConfig): { "key-fields"?: string[] } { - if (entity.sourceType === Dab.EntitySourceType.StoredProcedure || entity.fields?.length) { - return {}; + private buildFieldsProperty(entity: Dab.DabEntityConfig): Dab.DabFieldConfig[] { + if (entity.sourceType === Dab.EntitySourceType.StoredProcedure) { + return []; } - const keyFields = entity.columns - .filter((column) => column.isPrimaryKey) - .map((column) => column.name); + if (entity.fields?.length) { + const fieldsByName = new Map( + entity.fields.map((field) => [Dab.normalizeDabIdentifier(field.name), field]), + ); + return entity.columns.map((column) => { + const field = fieldsByName.get(Dab.normalizeDabIdentifier(column.name)); + return { + name: column.name, + ...(field?.alias ? { alias: field.alias } : {}), + ...(field?.description ? { description: field.description } : {}), + ...((field?.isPrimaryKey ?? column.isPrimaryKey) ? { isPrimaryKey: true } : {}), + }; + }); + } - return keyFields.length > 0 ? { "key-fields": keyFields } : {}; + return entity.columns.map((column) => ({ + name: column.name, + ...(column.isPrimaryKey ? { isPrimaryKey: true } : {}), + })); } /** @@ -341,7 +352,9 @@ export class DabConfigFileBuilder { return { name: parameter.name, ...(parameter.isRequired !== undefined ? { required: parameter.isRequired } : {}), - ...(parameter.defaultValue !== undefined ? { default: parameter.defaultValue } : {}), + ...(parameter.defaultValue !== undefined && parameter.defaultValue !== null + ? { default: String(parameter.defaultValue) } + : {}), ...(parameter.description ? { description: parameter.description } : {}), }; } @@ -353,23 +366,15 @@ export class DabConfigFileBuilder { * @returns The permissions for the entity. */ private buildPermissions(entity: Dab.DabEntityConfig): DabPermissionEntry[] { - if (entity.sourceType === Dab.EntitySourceType.StoredProcedure) { - return [ - { - role: entity.advancedSettings.authorizationRole, - actions: [Dab.EntityAction.Execute], - }, - ]; - } - const hiddenColumns = entity.columns - .filter((column) => !column.isExposed) + .filter((column) => !column.isExposed && !Dab.isLogicalKeyColumn(entity, column)) .map((column) => column.name); - return [ - { - role: entity.advancedSettings.authorizationRole, - actions: entity.enabledActions.map((action) => + return Dab.getEntityPermissions(entity) + .filter((permission) => permission.actions.length > 0) + .map((permission) => ({ + role: permission.role, + actions: permission.actions.map((action) => hiddenColumns.length > 0 && action !== Dab.EntityAction.Delete ? { action, @@ -379,7 +384,21 @@ export class DabConfigFileBuilder { } : action, ), - }, - ]; + })); + } + + private buildMcpProperty( + entity: Dab.DabEntityConfig, + ): boolean | { "custom-tool"?: boolean; "dml-tools"?: boolean } { + if (!Dab.isEntityMcpEnabled(entity)) { + return false; + } + + return { + "dml-tools": Dab.isEntityMcpDmlToolsEnabled(entity), + ...(entity.sourceType === Dab.EntitySourceType.StoredProcedure + ? { "custom-tool": Dab.isEntityMcpCustomToolEnabled(entity) } + : {}), + }; } } diff --git a/extensions/mssql/src/schemaDesigner/schemaDesignerWebviewController.ts b/extensions/mssql/src/schemaDesigner/schemaDesignerWebviewController.ts index 7e4a63b040..5e4a305fcc 100644 --- a/extensions/mssql/src/schemaDesigner/schemaDesignerWebviewController.ts +++ b/extensions/mssql/src/schemaDesigner/schemaDesignerWebviewController.ts @@ -729,6 +729,7 @@ export class SchemaDesignerWebviewController extends WebviewPanelController< parameters: parameters.map((parameter) => ({ name: parameter.name.replace(/^@/, ""), dataType: parameter.dataType, + isRequired: true, })), }; }); diff --git a/extensions/mssql/src/sharedInterfaces/dab.ts b/extensions/mssql/src/sharedInterfaces/dab.ts index 363400d39f..a5df16ccf3 100644 --- a/extensions/mssql/src/sharedInterfaces/dab.ts +++ b/extensions/mssql/src/sharedInterfaces/dab.ts @@ -123,6 +123,16 @@ export namespace Dab { Authenticated = "authenticated", } + export const supportedAuthorizationRoles = [ + AuthorizationRole.Anonymous, + AuthorizationRole.Authenticated, + ] as const; + + export interface EntityPermissionConfig { + role: AuthorizationRole; + actions: EntityAction[]; + } + /** * Advanced configuration options for an entity */ @@ -132,9 +142,18 @@ export namespace Dab { */ entityName: string; /** - * Authorization role for the entity + * Optional description for the entity. Written to both entity.description and + * source.description in generated DAB config. + */ + description?: string; + /** + * Legacy single authorization role for older cached configs. */ authorizationRole: AuthorizationRole; + /** + * Role-specific permission actions. + */ + permissions?: EntityPermissionConfig[]; /** * Custom REST path (overrides default /api/entityName) */ @@ -162,6 +181,16 @@ export namespace Dab { * Defaults to true. */ graphQLEnabled?: boolean; + /** + * Whether this entity should be exposed through MCP when MCP is globally enabled. + * Defaults to true. + */ + mcpEnabled?: boolean; + /** + * Whether MCP DML tools should be enabled for this entity. + * Defaults to true when MCP is enabled. + */ + mcpDmlToolsEnabled?: boolean; /** * Stored procedure REST methods. Defaults to POST. */ @@ -172,9 +201,14 @@ export namespace Dab { storedProcedureGraphQLOperation?: GraphQLOperation; /** * Whether a stored procedure entity should be exposed as a dedicated MCP custom tool. - * Defaults to true for stored procedures. + * Defaults to false for stored procedures. */ exposeAsMcpCustomTool?: boolean; + /** + * Preferred MCP custom-tool setting. Kept separate from exposeAsMcpCustomTool + * so older cached configs continue to load. + */ + mcpCustomToolEnabled?: boolean; } /** @@ -261,13 +295,13 @@ export namespace Dab { */ isEnabled: boolean; /** - * Whether this table is supported by DAB. - * Tables without primary keys or with unsupported data types are not supported. + * Whether this entity is supported by DAB. + * Unsupported data types are blocking; missing keys are fixable warnings. */ isSupported: boolean; /** - * Structured reasons why the table is not supported. - * Only set when isSupported is false. Converted to localized + * Structured reasons why the entity is unsupported or needs user input. + * Converted to localized * strings in the UI layer. */ unsupportedReasons?: DabUnsupportedReason[]; @@ -459,13 +493,48 @@ export namespace Dab { | { type: "add_entity"; entity: DabEntityRef } | { type: "remove_entity"; entity: DabEntityRef } | { type: "set_entity_enabled"; entity: DabEntityRef; isEnabled: boolean } + | { + type: "set_entity_surface"; + entity: DabEntityRef; + apiType: ApiType; + isEnabled: boolean; + } | { type: "set_entity_actions"; entity: DabEntityRef; enabledActions: EntityAction[] } + | { + type: "set_entity_permissions"; + entity: DabEntityRef; + permissions: EntityPermissionConfig[]; + } | { type: "set_column_exposed"; entity: DabEntityRef; column: DabColumnRef; isExposed: boolean; } + | { + type: "set_field_metadata"; + entity: DabEntityRef; + field: DabColumnRef; + alias?: string | null; + description?: string | null; + isPrimaryKey?: boolean; + } + | { + type: "set_parameter_metadata"; + entity: DabEntityRef; + parameter: { name: string }; + isRequired?: boolean; + defaultValue?: string | number | boolean | null; + clearDefault?: boolean; + description?: string | null; + } + | { + type: "set_entity_mcp"; + entity: DabEntityRef; + enabled?: boolean; + dmlToolsEnabled?: boolean; + customToolEnabled?: boolean; + } | { type: "patch_entity_settings"; entity: DabEntityRef; set: DabEntitySettingsPatch } | { type: "set_only_enabled_entities"; entities: DabEntityRef[] } | { type: "set_all_entities_enabled"; isEnabled: boolean }; @@ -1113,8 +1182,8 @@ export namespace Dab { /** * Validates whether a schema table is supported by DAB. - * Runs all checks and collects all reasons for unsupported tables. - * @returns An object with isSupported and an optional reason string. + * Runs all checks and collects blocking issues plus fixable key warnings. + * @returns An object with isSupported and optional structured reasons. */ export function validateTableForDab(table: SchemaDesigner.Table): { isSupported: boolean; @@ -1134,7 +1203,10 @@ export namespace Dab { reasons.push({ type: "unsupportedDataTypes", columns: details }); } - return reasons.length > 0 ? { isSupported: false, reasons } : { isSupported: true }; + const hasBlockingReason = reasons.some((reason) => reason.type === "unsupportedDataTypes"); + return reasons.length > 0 + ? { isSupported: !hasBlockingReason, reasons } + : { isSupported: true }; } export function validateSourceObjectForDab(sourceObject: DabSourceObject): { @@ -1142,10 +1214,14 @@ export namespace Dab { reasons?: DabUnsupportedReason[]; } { const reasons: DabUnsupportedReason[] = []; + const inferredKeyNames = inferLogicalKeyColumnNames( + sourceObject.columns, + sourceObject.sourceName, + ); const hasPrimaryKey = - sourceObject.sourceType === EntitySourceType.Table - ? sourceObject.columns.some((c) => c.isPrimaryKey) - : (sourceObject.fields ?? []).some((field) => field.isPrimaryKey); + sourceObject.sourceType !== EntitySourceType.StoredProcedure && + ((sourceObject.fields ?? []).some((field) => field.isPrimaryKey) || + inferredKeyNames.size > 0); if (sourceObject.sourceType !== EntitySourceType.StoredProcedure && !hasPrimaryKey) { reasons.push({ type: "noPrimaryKey" }); } @@ -1170,7 +1246,10 @@ export namespace Dab { reasons.push({ type: "unsupportedDataTypes", columns: details }); } - return reasons.length > 0 ? { isSupported: false, reasons } : { isSupported: true }; + const hasBlockingReason = reasons.some((reason) => reason.type === "unsupportedDataTypes"); + return reasons.length > 0 + ? { isSupported: !hasBlockingReason, reasons } + : { isSupported: true }; } /** @@ -1194,13 +1273,233 @@ export namespace Dab { }; } + export const defaultApiTypes: ApiType[] = [ApiType.Rest, ApiType.GraphQL, ApiType.Mcp]; + + function normalizeKeyCandidateName(value?: string): string { + return (value ?? "").replace(/[^a-zA-Z0-9]/g, "").toLowerCase(); + } + + function isIdSuffixCandidate(columnName: string): boolean { + return ( + /^id$/i.test(columnName) || + /(^|[_\-\s])id$/i.test(columnName) || + /[A-Z0-9]ID$/.test(columnName) || + /[a-z0-9]Id$/.test(columnName) + ); + } + + function inferLogicalKeyColumnNames( + columns: DabColumnConfig[], + sourceName?: string, + ): Set { + const physicalKeys = columns.filter((column) => column.isPrimaryKey); + if (physicalKeys.length > 0) { + return new Set(physicalKeys.map((column) => normalizeDabIdentifier(column.name))); + } + + const supportedColumns = columns.filter((column) => column.isSupported); + const normalizedSourceName = normalizeKeyCandidateName(sourceName); + const exactIdColumns = supportedColumns.filter( + (column) => normalizeKeyCandidateName(column.name) === "id", + ); + if (exactIdColumns.length === 1) { + return new Set([normalizeDabIdentifier(exactIdColumns[0].name)]); + } + + const sourceIdColumns = + normalizedSourceName.length > 0 + ? supportedColumns.filter( + (column) => + normalizeKeyCandidateName(column.name) === `${normalizedSourceName}id`, + ) + : []; + if (sourceIdColumns.length === 1) { + return new Set([normalizeDabIdentifier(sourceIdColumns[0].name)]); + } + + const idSuffixColumns = supportedColumns.filter((column) => + isIdSuffixCandidate(column.name), + ); + if (idSuffixColumns.length > 0 && idSuffixColumns.length <= 2) { + return new Set(idSuffixColumns.map((column) => normalizeDabIdentifier(column.name))); + } + + return new Set(); + } + + const crudActions = [ + EntityAction.Create, + EntityAction.Read, + EntityAction.Update, + EntityAction.Delete, + ]; + + export function getDefaultPermissionsForSource( + sourceType?: EntitySourceType, + ): EntityPermissionConfig[] { + if (sourceType === EntitySourceType.StoredProcedure) { + return [ + { role: AuthorizationRole.Anonymous, actions: [] }, + { role: AuthorizationRole.Authenticated, actions: [EntityAction.Execute] }, + ]; + } + + return [ + { role: AuthorizationRole.Anonymous, actions: [EntityAction.Read] }, + { role: AuthorizationRole.Authenticated, actions: [...crudActions] }, + ]; + } + + function normalizePermissionActions( + actions: EntityAction[] | undefined, + sourceType?: EntitySourceType, + ): EntityAction[] { + const allowedActions = + sourceType === EntitySourceType.StoredProcedure + ? new Set([EntityAction.Execute]) + : new Set(crudActions); + return [...new Set(actions ?? [])].filter((action) => allowedActions.has(action)); + } + + export function getEntityPermissions(entity: DabEntityConfig): EntityPermissionConfig[] { + if (entity.advancedSettings.permissions?.length) { + const byRole = new Map(); + for (const role of supportedAuthorizationRoles) { + byRole.set(role, []); + } + for (const permission of entity.advancedSettings.permissions) { + if (!supportedAuthorizationRoles.includes(permission.role)) { + continue; + } + byRole.set( + permission.role, + normalizePermissionActions(permission.actions, entity.sourceType), + ); + } + return supportedAuthorizationRoles.map((role) => ({ + role, + actions: byRole.get(role) ?? [], + })); + } + + const legacyRole = entity.advancedSettings.authorizationRole ?? AuthorizationRole.Anonymous; + const legacyActions = normalizePermissionActions(entity.enabledActions, entity.sourceType); + return supportedAuthorizationRoles.map((role) => ({ + role, + actions: role === legacyRole ? legacyActions : [], + })); + } + + export function getPermissionActionsForRole( + entity: DabEntityConfig, + role: AuthorizationRole, + ): EntityAction[] { + return ( + getEntityPermissions(entity).find((permission) => permission.role === role)?.actions ?? + [] + ); + } + + export function hasEntityPermission(entity: DabEntityConfig, role: AuthorizationRole): boolean { + return getPermissionActionsForRole(entity, role).length > 0; + } + + export function isEntityRestEnabled(entity: DabEntityConfig): boolean { + return entity.advancedSettings.restEnabled !== false; + } + + export function isEntityGraphQLEnabled(entity: DabEntityConfig): boolean { + return entity.advancedSettings.graphQLEnabled !== false; + } + + export function isEntityMcpEnabled(entity: DabEntityConfig): boolean { + if (entity.advancedSettings.mcpEnabled === false) { + return false; + } + + const dmlToolsEnabled = isEntityMcpDmlToolsEnabled(entity); + if (entity.sourceType === EntitySourceType.StoredProcedure) { + return dmlToolsEnabled || isEntityMcpCustomToolEnabled(entity); + } + + return dmlToolsEnabled; + } + + export function isEntityMcpDmlToolsEnabled(entity: DabEntityConfig): boolean { + return entity.advancedSettings.mcpDmlToolsEnabled !== false; + } + + export function isEntityMcpCustomToolEnabled(entity: DabEntityConfig): boolean { + return ( + entity.advancedSettings.mcpCustomToolEnabled ?? + entity.advancedSettings.exposeAsMcpCustomTool ?? + false + ); + } + + export function isEntityExposed(entity: DabEntityConfig): boolean { + return ( + isEntityRestEnabled(entity) || + isEntityGraphQLEnabled(entity) || + isEntityMcpEnabled(entity) + ); + } + + export function hasLogicalKey(entity: DabEntityConfig): boolean { + if (entity.sourceType === EntitySourceType.StoredProcedure) { + return true; + } + + if (entity.fields !== undefined) { + return entity.fields.some((field) => field.isPrimaryKey); + } + + return entity.columns.some((column) => column.isPrimaryKey); + } + + export function getFieldForColumn( + entity: DabEntityConfig, + columnName: string, + ): DabFieldConfig | undefined { + return entity.fields?.find( + (field) => normalizeDabIdentifier(field.name) === normalizeDabIdentifier(columnName), + ); + } + + export function isLogicalKeyColumn(entity: DabEntityConfig, column: DabColumnConfig): boolean { + const field = getFieldForColumn(entity, column.name); + return field !== undefined ? field.isPrimaryKey === true : column.isPrimaryKey; + } + + export function hasBlockingUnsupportedReason(entity: DabEntityConfig): boolean { + return (entity.unsupportedReasons ?? []).some( + (reason) => reason.type === "unsupportedDataTypes", + ); + } + + export function hasFixableKeyWarning(entity: DabEntityConfig): boolean { + return ( + entity.sourceType !== EntitySourceType.StoredProcedure && + !hasLogicalKey(entity) && + (entity.unsupportedReasons ?? []).some((reason) => reason.type === "noPrimaryKey") + ); + } + export function createSourceObjectFromTable(table: SchemaDesigner.Table): DabSourceObject { + const columns = table.columns.map((column) => createDefaultColumnConfig(column)); + const inferredKeyNames = inferLogicalKeyColumnNames(columns, table.name); return { id: table.id, sourceType: EntitySourceType.Table, schemaName: table.schema, sourceName: table.name, - columns: table.columns.map((column) => createDefaultColumnConfig(column)), + columns, + fields: columns.map((column) => ({ + name: column.name, + ...(inferredKeyNames.has(normalizeDabIdentifier(column.name)) + ? { isPrimaryKey: true } + : {}), + })), }; } @@ -1248,13 +1547,17 @@ export namespace Dab { ): DabEntityConfig { const { isSupported, reasons } = validateSourceObjectForDab(sourceObject); const isStoredProcedure = sourceObject.sourceType === EntitySourceType.StoredProcedure; + const hasMissingKeyWarning = + sourceObject.sourceType !== EntitySourceType.StoredProcedure && + reasons?.some((reason) => reason.type === "noPrimaryKey") === true; + const isIncludedByDefault = isSupported && !hasMissingKeyWarning; return { id: sourceObject.id, sourceType: sourceObject.sourceType, sourceName: sourceObject.sourceName, tableName: sourceObject.sourceName, schemaName: sourceObject.schemaName, - isEnabled: isSupported, + isEnabled: isIncludedByDefault, isSupported, unsupportedReasons: reasons, enabledActions: isStoredProcedure @@ -1266,12 +1569,28 @@ export namespace Dab { EntityAction.Delete, ], columns: sourceObject.columns.map((column) => ({ ...column })), - fields: sourceObject.fields?.map((field) => ({ ...field })), - parameters: sourceObject.parameters?.map((parameter) => ({ ...parameter })), + fields: + sourceObject.sourceType === EntitySourceType.StoredProcedure + ? undefined + : syncFieldsWithSource(sourceObject.fields, sourceObject), + parameters: sourceObject.parameters?.map((parameter) => ({ + ...parameter, + isRequired: parameter.isRequired ?? true, + })), advancedSettings: { entityName: sourceObject.sourceName, authorizationRole: AuthorizationRole.Anonymous, - ...(isStoredProcedure ? { exposeAsMcpCustomTool: true } : {}), + permissions: getDefaultPermissionsForSource(sourceObject.sourceType), + restEnabled: isIncludedByDefault, + graphQLEnabled: isIncludedByDefault, + mcpEnabled: isIncludedByDefault, + mcpDmlToolsEnabled: isIncludedByDefault, + ...(isStoredProcedure + ? { + exposeAsMcpCustomTool: false, + mcpCustomToolEnabled: false, + } + : {}), }, }; } @@ -1296,6 +1615,118 @@ export namespace Dab { return parameters?.map((parameter) => ({ ...parameter })); } + function createDefaultFieldsFromColumns( + columns: DabColumnConfig[], + sourceName?: string, + ): DabFieldConfig[] { + const inferredKeyNames = inferLogicalKeyColumnNames(columns, sourceName); + return columns.map((column) => ({ + name: column.name, + ...(inferredKeyNames.has(normalizeDabIdentifier(column.name)) + ? { isPrimaryKey: true } + : {}), + })); + } + + function syncFieldsWithSource( + existingFields: DabFieldConfig[] | undefined, + sourceObject: DabSourceObject, + ): DabFieldConfig[] | undefined { + if (sourceObject.sourceType === EntitySourceType.StoredProcedure) { + return undefined; + } + + const sourceFields = sourceObject.fields?.length + ? sourceObject.fields + : createDefaultFieldsFromColumns(sourceObject.columns, sourceObject.sourceName); + const inferredKeyNames = inferLogicalKeyColumnNames( + sourceObject.columns, + sourceObject.sourceName, + ); + const existingByName = new Map( + (existingFields ?? []).map((field) => [normalizeDabIdentifier(field.name), field]), + ); + + return sourceFields.map((sourceField) => { + const normalizedName = normalizeDabIdentifier(sourceField.name); + const existingField = existingByName.get(normalizedName); + return { + name: sourceField.name, + ...(existingField?.alias ? { alias: existingField.alias } : {}), + ...(existingField?.description ? { description: existingField.description } : {}), + ...((existingField?.isPrimaryKey ?? + sourceField.isPrimaryKey ?? + inferredKeyNames.has(normalizedName)) + ? { isPrimaryKey: true } + : {}), + }; + }); + } + + function syncParametersWithSource( + existingParameters: DabParameterConfig[] | undefined, + sourceParameters: DabParameterConfig[] | undefined, + ): DabParameterConfig[] | undefined { + if (!sourceParameters?.length) { + return undefined; + } + + const existingByName = new Map( + (existingParameters ?? []).map((parameter) => [ + normalizeDabIdentifier(parameter.name.replace(/^@/, "")), + parameter, + ]), + ); + + return sourceParameters.map((sourceParameter) => { + const normalizedName = sourceParameter.name.replace(/^@/, ""); + const existingParameter = existingByName.get(normalizeDabIdentifier(normalizedName)); + return { + name: normalizedName, + dataType: sourceParameter.dataType, + isRequired: existingParameter?.isRequired ?? sourceParameter.isRequired ?? true, + ...(existingParameter?.defaultValue !== undefined + ? { defaultValue: existingParameter.defaultValue } + : sourceParameter.defaultValue !== undefined + ? { defaultValue: sourceParameter.defaultValue } + : {}), + ...(existingParameter?.description + ? { description: existingParameter.description } + : sourceParameter.description + ? { description: sourceParameter.description } + : {}), + }; + }); + } + + function normalizeAdvancedSettings(entity: DabEntityConfig): EntityAdvancedSettings { + const isStoredProcedure = entity.sourceType === EntitySourceType.StoredProcedure; + const legacyCustomTool = entity.advancedSettings.exposeAsMcpCustomTool; + const customToolEnabled = + entity.advancedSettings.mcpCustomToolEnabled ?? + (legacyCustomTool !== undefined ? legacyCustomTool : false); + const hasExplicitSurfaceSettings = + entity.advancedSettings.restEnabled !== undefined || + entity.advancedSettings.graphQLEnabled !== undefined || + entity.advancedSettings.mcpEnabled !== undefined; + const defaultSurfaceEnabled = hasExplicitSurfaceSettings ? true : entity.isEnabled; + + return { + ...entity.advancedSettings, + permissions: getEntityPermissions(entity), + restEnabled: entity.advancedSettings.restEnabled ?? defaultSurfaceEnabled, + graphQLEnabled: entity.advancedSettings.graphQLEnabled ?? defaultSurfaceEnabled, + mcpEnabled: entity.advancedSettings.mcpEnabled ?? defaultSurfaceEnabled, + mcpDmlToolsEnabled: entity.advancedSettings.mcpDmlToolsEnabled ?? true, + ...(isStoredProcedure + ? { + exposeAsMcpCustomTool: customToolEnabled, + mcpCustomToolEnabled: customToolEnabled, + } + : {}), + }; + } + function cloneConfig(config: DabConfig): DabConfig { return { apiTypes: [...config.apiTypes], @@ -1306,7 +1737,7 @@ export namespace Dab { fields: cloneFields(entity.fields), parameters: cloneParameters(entity.parameters), unsupportedReasons: cloneUnsupportedReasons(entity.unsupportedReasons), - advancedSettings: { ...entity.advancedSettings }, + advancedSettings: normalizeAdvancedSettings(entity), })), }; } @@ -1392,9 +1823,13 @@ export namespace Dab { isSupported, unsupportedReasons: reasons, columns: syncedColumns.columns, - fields: cloneFields(sourceObject.fields), - parameters: cloneParameters(sourceObject.parameters), - // Unsupported entities must remain disabled until the schema is fixed. + fields: syncFieldsWithSource(entity.fields, { + ...sourceObject, + columns: syncedColumns.columns, + }), + parameters: syncParametersWithSource(entity.parameters, sourceObject.parameters), + advancedSettings: normalizeAdvancedSettings(entity), + // Entities with blocking unsupported types must remain disabled until the schema is fixed. isEnabled: !isSupported ? false : entity.isEnabled, }; } @@ -1487,7 +1922,7 @@ export namespace Dab { export function createDefaultConfigFromSources(sourceObjects: DabSourceObject[]): DabConfig { return { - apiTypes: [ApiType.Rest], + apiTypes: [...defaultApiTypes], entities: sourceObjects.map((sourceObject) => createDefaultEntityConfigFromSource(sourceObject), ), diff --git a/extensions/mssql/src/webviews/common/locConstants.ts b/extensions/mssql/src/webviews/common/locConstants.ts index 2436b30f66..3795f7fad3 100644 --- a/extensions/mssql/src/webviews/common/locConstants.ts +++ b/extensions/mssql/src/webviews/common/locConstants.ts @@ -1355,6 +1355,10 @@ export class LocConstants { enableGraphQLForEntityHelp: l10n.t( "Enable GraphQL in API Type to expose this entity through GraphQL.", ), + enableMcpForEntity: l10n.t("Expose this entity through MCP"), + enableMcpForEntityHelp: l10n.t( + "Enable MCP in API Type to expose this entity through MCP.", + ), storedProcedureRestMethods: l10n.t("Stored procedure REST methods"), storedProcedureRestMethodsHelp: l10n.t( "Select the HTTP method that can execute this stored procedure. DAB defaults to POST.", @@ -1366,6 +1370,7 @@ export class LocConstants { graphqlMutation: l10n.t("Mutation"), graphqlQuery: l10n.t("Query"), mcpCustomTool: l10n.t("MCP custom tool"), + mcpDmlTools: l10n.t("MCP DML tools"), exposeAsMcpCustomTool: l10n.t("Expose as MCP custom tool"), exposeAsMcpCustomToolHelp: l10n.t( "Creates a dedicated MCP tool for this stored procedure. When disabled, the procedure can still be available through generic MCP execute tools if MCP is enabled.", @@ -1373,6 +1378,13 @@ export class LocConstants { enableMcpForCustomToolHelp: l10n.t( "Enable MCP in API Type to use this custom tool setting.", ), + mcpDmlToolsHelp: l10n.t("DML tools expose this entity through generic MCP tools."), + mcpStoredProcedureDmlToolsHelp: l10n.t( + "DML tools expose this stored procedure through generic execute tools.", + ), + mcpCustomToolHelp: l10n.t( + "Custom tool creates a dedicated MCP tool for this stored procedure.", + ), apiTypeNotEnabledGlobally: (apiType: string) => l10n.t({ message: "{0} is not enabled globally", @@ -1398,6 +1410,10 @@ export class LocConstants { filterEntitiesTitle: l10n.t("Filter entities"), status: l10n.t("Status"), objectType: l10n.t("Object type"), + exposedVia: l10n.t("Exposed via"), + authMode: l10n.t("Auth mode"), + notExposed: l10n.t("Not exposed"), + noPermissions: l10n.t("No permissions"), clearAllFilters: l10n.t("Clear all"), entityStatusFilterLabel: (status: "all" | "enabled" | "disabled" | "warnings") => { switch (status) { @@ -1424,6 +1440,7 @@ export class LocConstants { read: l10n.t("Read"), update: l10n.t("Update"), execute: l10n.t("Execute"), + executeShort: l10n.t("Exec"), view: l10n.t("View"), storedProcedure: l10n.t("Stored Procedure"), tables: l10n.t("Tables"), @@ -1477,6 +1494,9 @@ export class LocConstants { "Local container deployment is currently only supported with SQL Authentication connections.", ), atLeastOneApiTypeRequired: l10n.t("At least one API type must be selected."), + missingLogicalKeyRequired: l10n.t( + "Select at least one logical key column before applying or deploying this exposed table or view.", + ), authenticationNotSupported: l10n.t("Authentication not supported"), dabDeploymentNotSupportedBanner: l10n.t( "In the Data API builder experience, local container deployment is only available for connections using SQL Authentication. Your current connection type is not supported.", @@ -1500,9 +1520,26 @@ export class LocConstants { ), disabledGlobally: l10n.t("Disabled globally"), anonymous: l10n.t("Anonymous"), + anonymousShort: l10n.t("Anon"), anonymousDescription: l10n.t("No authentication required"), authenticated: l10n.t("Authenticated"), + authenticatedShort: l10n.t("Auth"), authenticatedDescription: l10n.t("Requires user authentication"), + description: l10n.t("Description"), + parameters: l10n.t("Parameters"), + alias: l10n.t("Alias"), + key: l10n.t("Key"), + logicalKey: l10n.t("Logical key"), + expose: l10n.t("Expose"), + exposed: l10n.t("Exposed"), + hidden: l10n.t("Hidden"), + required: l10n.t("Required"), + requiredParameter: l10n.t("Required parameter"), + optional: l10n.t("Optional"), + noColumnsDiscovered: l10n.t("No columns were discovered for this entity."), + noParametersDiscovered: l10n.t( + "No parameters were discovered for this stored procedure.", + ), customRestPath: l10n.t("Custom REST Path"), customRestPathHelp: l10n.t("Optional - Override default api/entityName path"), customGraphQLType: l10n.t("Custom GraphQL Type"), @@ -1538,6 +1575,12 @@ export class LocConstants { args: [entityName], comment: ["{0} is the entity name"], }), + includeEntity: (entityName: string) => + l10n.t({ + message: "Include {0}", + args: [entityName], + comment: ["{0} is the entity name"], + }), toggleEntityColumns: (entityName: string) => l10n.t({ message: "Toggle columns for {0}", diff --git a/extensions/mssql/src/webviews/pages/SchemaDesigner/dab/dabContext.tsx b/extensions/mssql/src/webviews/pages/SchemaDesigner/dab/dabContext.tsx index 0a7ec4e13b..48b65b3fe9 100644 --- a/extensions/mssql/src/webviews/pages/SchemaDesigner/dab/dabContext.tsx +++ b/extensions/mssql/src/webviews/pages/SchemaDesigner/dab/dabContext.tsx @@ -24,6 +24,7 @@ interface DabContextProps { toggleDabEntityAction: (entityId: string, action: Dab.EntityAction, isEnabled: boolean) => void; toggleDabColumnExposure: (entityId: string, columnId: string, isExposed: boolean) => void; updateDabEntitySettings: (entityId: string, settings: Dab.EntityAdvancedSettings) => void; + updateDabEntityConfig: (entity: Dab.DabEntityConfig) => void; dabTextFilter: string; setDabTextFilter: (text: string) => void; dabConfigTextFileContent: string; @@ -171,10 +172,19 @@ export const DabProvider: React.FC = ({ children }) => { return { ...entity, isEnabled, - columns: entity.columns.map((column) => ({ - ...column, - isExposed: isEnabled, - })), + advancedSettings: { + ...entity.advancedSettings, + restEnabled: isEnabled, + graphQLEnabled: isEnabled, + mcpEnabled: isEnabled, + mcpDmlToolsEnabled: isEnabled, + ...(entity.sourceType === Dab.EntitySourceType.StoredProcedure + ? { + exposeAsMcpCustomTool: false, + mcpCustomToolEnabled: false, + } + : {}), + }, }; }), }; @@ -203,7 +213,20 @@ export const DabProvider: React.FC = ({ children }) => { const enabledActions = isEnabled ? [...e.enabledActions, action] : e.enabledActions.filter((a) => a !== action); - return { ...e, enabledActions }; + const role = e.advancedSettings.authorizationRole; + const permissions = Dab.getEntityPermissions(e).map((permission) => + permission.role === role + ? { ...permission, actions: enabledActions } + : permission, + ); + return { + ...e, + enabledActions, + advancedSettings: { + ...e.advancedSettings, + permissions, + }, + }; }); if (!didChange) { @@ -261,6 +284,21 @@ export const DabProvider: React.FC = ({ children }) => { [], ); + const updateDabEntityConfig = useCallback((updatedEntity: Dab.DabEntityConfig) => { + setDabConfig((prev) => { + if (!prev) { + return prev; + } + + return { + ...prev, + entities: prev.entities.map((entity) => + entity.id === updatedEntity.id ? updatedEntity : entity, + ), + }; + }); + }, []); + // Auto-generate text config whenever dabConfig changes useEffect(() => { if (!dabConfig) { @@ -523,6 +561,7 @@ export const DabProvider: React.FC = ({ children }) => { toggleDabEntityAction, toggleDabColumnExposure, updateDabEntitySettings, + updateDabEntityConfig, dabTextFilter, setDabTextFilter, dabConfigTextFileContent, diff --git a/extensions/mssql/src/webviews/pages/SchemaDesigner/dab/dabEntityFilters.ts b/extensions/mssql/src/webviews/pages/SchemaDesigner/dab/dabEntityFilters.ts index 2c4ba44c0f..8ca4eb7e39 100644 --- a/extensions/mssql/src/webviews/pages/SchemaDesigner/dab/dabEntityFilters.ts +++ b/extensions/mssql/src/webviews/pages/SchemaDesigner/dab/dabEntityFilters.ts @@ -12,16 +12,26 @@ export enum DabEntityStatusFilter { Warnings = "warnings", } +export enum DabEntityAuthFilter { + Anonymous = "anonymous", + Authenticated = "authenticated", + None = "none", +} + export interface DabEntityFilters { status: DabEntityStatusFilter; schemas: string[]; sourceTypes: Dab.EntitySourceType[]; + apiTypes: Array; + authTypes: DabEntityAuthFilter[]; } export const defaultDabEntityFilters: DabEntityFilters = { status: DabEntityStatusFilter.All, schemas: [], sourceTypes: [], + apiTypes: [], + authTypes: [], }; export function getDabSchemaFilterKey(schemaName: string): string { @@ -32,7 +42,9 @@ export function getDabEntityFilterCount(filters: DabEntityFilters): number { return ( (filters.status === DabEntityStatusFilter.All ? 0 : 1) + filters.schemas.length + - filters.sourceTypes.length + filters.sourceTypes.length + + filters.apiTypes.length + + filters.authTypes.length ); } @@ -40,13 +52,17 @@ export function doesEntityMatchDabFilters( entity: Dab.DabEntityConfig, filters: DabEntityFilters, ): boolean { - if (filters.status === DabEntityStatusFilter.Enabled && !entity.isEnabled) { + if (filters.status === DabEntityStatusFilter.Enabled && !Dab.isEntityExposed(entity)) { return false; } - if (filters.status === DabEntityStatusFilter.Disabled && entity.isEnabled) { + if (filters.status === DabEntityStatusFilter.Disabled && Dab.isEntityExposed(entity)) { return false; } - if (filters.status === DabEntityStatusFilter.Warnings && entity.isSupported) { + if ( + filters.status === DabEntityStatusFilter.Warnings && + !Dab.hasBlockingUnsupportedReason(entity) && + !Dab.hasFixableKeyWarning(entity) + ) { return false; } if ( @@ -57,7 +73,46 @@ export function doesEntityMatchDabFilters( } const sourceType = entity.sourceType ?? Dab.EntitySourceType.Table; - return filters.sourceTypes.length === 0 || filters.sourceTypes.includes(sourceType); + if (filters.sourceTypes.length > 0 && !filters.sourceTypes.includes(sourceType)) { + return false; + } + + if (filters.apiTypes.length > 0) { + const enabledApiTypes: Array = []; + if (Dab.isEntityRestEnabled(entity)) { + enabledApiTypes.push(Dab.ApiType.Rest); + } + if (Dab.isEntityGraphQLEnabled(entity)) { + enabledApiTypes.push(Dab.ApiType.GraphQL); + } + if (Dab.isEntityMcpEnabled(entity)) { + enabledApiTypes.push(Dab.ApiType.Mcp); + } + if (enabledApiTypes.length === 0) { + enabledApiTypes.push("none"); + } + if (!filters.apiTypes.some((apiType) => enabledApiTypes.includes(apiType))) { + return false; + } + } + + if (filters.authTypes.length > 0) { + const enabledAuthTypes: DabEntityAuthFilter[] = []; + if (Dab.hasEntityPermission(entity, Dab.AuthorizationRole.Anonymous)) { + enabledAuthTypes.push(DabEntityAuthFilter.Anonymous); + } + if (Dab.hasEntityPermission(entity, Dab.AuthorizationRole.Authenticated)) { + enabledAuthTypes.push(DabEntityAuthFilter.Authenticated); + } + if (enabledAuthTypes.length === 0) { + enabledAuthTypes.push(DabEntityAuthFilter.None); + } + if (!filters.authTypes.some((authType) => enabledAuthTypes.includes(authType))) { + return false; + } + } + + return true; } export function toggleDabEntityFilterValue(values: T[], value: T, allValues?: T[]): T[] { diff --git a/extensions/mssql/src/webviews/pages/SchemaDesigner/dab/dabEntitySettingsDialog.tsx b/extensions/mssql/src/webviews/pages/SchemaDesigner/dab/dabEntitySettingsDialog.tsx index fc415ac63d..c1f3c631db 100644 --- a/extensions/mssql/src/webviews/pages/SchemaDesigner/dab/dabEntitySettingsDialog.tsx +++ b/extensions/mssql/src/webviews/pages/SchemaDesigner/dab/dabEntitySettingsDialog.tsx @@ -11,31 +11,33 @@ import { DrawerHeader, DrawerHeaderTitle, Field, + InfoLabel, Input, makeStyles, MessageBar, MessageBarActions, MessageBarBody, MessageBarTitle, - mergeClasses, + OverlayDrawer, Radio, RadioGroup, - OverlayDrawer, + Tab, + TabList, Text, - ToggleButton, - Tooltip, + Textarea, tokens, } from "@fluentui/react-components"; -import { Dismiss24Regular, Info16Regular, Table16Regular } from "@fluentui/react-icons"; -import { useEffect, useState } from "react"; -import { locConstants } from "../../../common/locConstants"; +import { Dismiss24Regular, Table16Regular } from "@fluentui/react-icons"; +import { KeyboardEvent, useEffect, useMemo, useRef, useState } from "react"; +import { useVirtualizer } from "@tanstack/react-virtual"; import { Dab } from "../../../../sharedInterfaces/dab"; +import { locConstants } from "../../../common/locConstants"; import { StoredProcedureIcon16Regular } from "../../../common/icons/storedProcedure"; import { ViewIcon16Regular } from "../../../common/icons/view"; const useStyles = makeStyles({ drawer: { - width: "640px", + width: "720px", maxWidth: "calc(100vw - 32px)", backgroundColor: "var(--vscode-editor-background)", }, @@ -46,11 +48,11 @@ const useStyles = makeStyles({ drawerBody: { display: "flex", flexDirection: "column", - rowGap: "16px", + rowGap: "18px", overflowY: "auto", backgroundColor: "var(--vscode-editor-background)", - paddingTop: "16px", - paddingBottom: "16px", + paddingTop: 0, + paddingBottom: "18px", }, headerTitleContent: { display: "flex", @@ -77,6 +79,18 @@ const useStyles = makeStyles({ color: tokens.colorNeutralForeground3, flexShrink: 0, }, + tabs: { + position: "sticky", + top: 0, + zIndex: 3, + backgroundColor: "var(--vscode-editor-background)", + borderBottom: `1px solid ${tokens.colorNeutralStroke2}`, + }, + tabPanel: { + display: "flex", + flexDirection: "column", + rowGap: "18px", + }, section: { display: "flex", flexDirection: "column", @@ -87,21 +101,21 @@ const useStyles = makeStyles({ fontWeight: tokens.fontWeightSemibold, color: tokens.colorNeutralForeground1, }, - sectionDisabled: { - opacity: 0.75, - }, sectionBody: { display: "flex", flexDirection: "column", rowGap: "10px", }, + twoColumnGrid: { + display: "grid", + gridTemplateColumns: "1fr 1fr", + columnGap: "10px", + rowGap: "10px", + }, disabledMessageBar: { border: `1px solid ${tokens.colorPaletteYellowBorder2}`, backgroundColor: "transparent", }, - disabledMessageBarIcon: { - color: tokens.colorPaletteYellowForeground2, - }, disabledMessageBarTitle: { fontSize: tokens.fontSizeBase200, fontWeight: tokens.fontWeightSemibold, @@ -113,69 +127,124 @@ const useStyles = makeStyles({ color: tokens.colorNeutralForeground2, }, fieldHint: { - display: "block", color: tokens.colorNeutralForeground4, fontSize: tokens.fontSizeBase200, - fontWeight: tokens.fontWeightRegular, lineHeight: tokens.lineHeightBase200, }, - labelWithInfo: { - display: "inline-flex", - alignItems: "center", - columnGap: "4px", + roleCard: { + display: "flex", + flexDirection: "column", + rowGap: "8px", + padding: "10px 12px", + borderRadius: "4px", + border: `1px solid ${tokens.colorNeutralStroke2}`, + backgroundColor: "var(--vscode-editorWidget-background, transparent)", }, - infoButton: { - color: tokens.colorNeutralForeground3, - minWidth: "16px", - width: "16px", - height: "16px", - padding: 0, - verticalAlign: "middle", + roleHeader: { + display: "flex", + alignItems: "center", + justifyContent: "space-between", + columnGap: "8px", }, - roleButtonsContainer: { + actionRow: { display: "flex", - gap: "8px", + flexWrap: "wrap", + gap: "8px 12px", + paddingLeft: "24px", }, - roleButton: { - flex: 1, + methodGroup: { display: "flex", - flexDirection: "column", - alignItems: "center", - justifyContent: "center", - padding: "12px", - minHeight: "60px", - whiteSpace: "normal", + flexWrap: "wrap", + gap: "10px", }, - roleButtonLabel: { - fontWeight: 600, - lineHeight: "18px", + metadataTable: { + width: "100%", + borderCollapse: "collapse", + fontSize: tokens.fontSizeBase200, }, - roleButtonLabelSelected: { - color: tokens.colorNeutralForegroundOnBrand, + metadataViewport: { + maxHeight: "300px", + overflowY: "auto", + border: `1px solid ${tokens.colorNeutralStroke2}`, + borderRadius: "4px", }, - roleButtonLabelUnselected: { - color: tokens.colorNeutralForeground1, + metadataGridHeader: { + display: "grid", + position: "sticky", + top: 0, + zIndex: 1, + backgroundColor: "var(--vscode-editorWidget-background, var(--vscode-editor-background))", + borderBottom: `1px solid ${tokens.colorNeutralStroke2}`, + fontSize: tokens.fontSizeBase200, + color: tokens.colorNeutralForeground3, + fontWeight: tokens.fontWeightSemibold, }, - roleButtonContent: { - display: "flex", - flexDirection: "column", + metadataGridBody: { + position: "relative", + width: "100%", + }, + metadataGridRow: { + display: "grid", + position: "absolute", + left: 0, + right: 0, + top: 0, alignItems: "center", - gap: "4px", - textAlign: "center", + borderBottom: `1px solid ${tokens.colorNeutralStroke2}`, + fontSize: tokens.fontSizeBase200, }, - roleButtonDescription: { - fontSize: "11px", - lineHeight: "14px", + columnMetadataGrid: { + gridTemplateColumns: + "64px 56px minmax(140px, 1fr) 120px minmax(120px, 1fr) minmax(140px, 1.4fr)", + columnGap: "8px", }, - roleButtonDescriptionSelected: { - color: tokens.colorNeutralForegroundOnBrand, + parameterMetadataGrid: { + gridTemplateColumns: + "minmax(160px, 1fr) 120px 88px minmax(120px, 1fr) minmax(160px, 1.4fr)", + columnGap: "8px", }, - roleButtonDescriptionUnselected: { - color: tokens.colorNeutralForeground2, + metadataGridCell: { + minWidth: 0, + padding: "6px 8px", + outline: "none", + "&:focus-visible": { + outline: "1px solid var(--vscode-focusBorder)", + outlineOffset: "-1px", + }, }, - sourceText: { - fontSize: "12px", + tableHeaderCell: { + padding: "6px 8px", + textAlign: "left", color: tokens.colorNeutralForeground3, + fontWeight: tokens.fontWeightSemibold, + borderBottom: `1px solid ${tokens.colorNeutralStroke2}`, + whiteSpace: "nowrap", + }, + tableCell: { + padding: "6px 8px", + borderBottom: `1px solid ${tokens.colorNeutralStroke2}`, + verticalAlign: "middle", + }, + tableNameCell: { + fontFamily: tokens.fontFamilyMonospace, + color: tokens.colorNeutralForeground1, + whiteSpace: "nowrap", + }, + tableTypeCell: { + fontFamily: tokens.fontFamilyMonospace, + color: tokens.colorNeutralForeground3, + whiteSpace: "nowrap", + }, + compactInput: { + width: "100%", + minWidth: 0, + maxWidth: "100%", + boxSizing: "border-box", + }, + emptyMetadata: { + color: tokens.colorNeutralForeground3, + fontSize: tokens.fontSizeBase200, + padding: "8px 0", }, drawerFooter: { alignSelf: "stretch", @@ -189,134 +258,429 @@ const useStyles = makeStyles({ minWidth: "132px", whiteSpace: "nowrap", }, - checkboxGroup: { - display: "flex", - flexDirection: "column", - rowGap: "4px", - }, - methodGroup: { - display: "flex", - flexWrap: "wrap", - gap: "6px", + deepLinkFocus: { + outline: "1px solid var(--vscode-focusBorder)", + outlineOffset: "2px", + borderRadius: "3px", }, }); +type DabSettingsTab = "identity" | "permissions" | "rest" | "graphql" | "mcp" | "schema"; +type MetadataGridKind = "columns" | "parameters"; + +const COLUMN_METADATA_GRID_COLUMN_COUNT = 6; +const PARAMETER_METADATA_GRID_COLUMN_COUNT = 5; + interface DabEntitySettingsDialogProps { entity: Dab.DabEntityConfig; existingEntityNames: string[]; isRestEnabled: boolean; isGraphQLEnabled: boolean; isMcpEnabled: boolean; + initialTab?: DabSettingsTab; open: boolean; onOpenChange: (open: boolean) => void; - onApply: (settings: Dab.EntityAdvancedSettings) => void; + onApply: (entity: Dab.DabEntityConfig) => void; onEnableApiType: (apiType: Dab.ApiType) => void; } +function cloneEntityForEditing(entity: Dab.DabEntityConfig): Dab.DabEntityConfig { + const fields = + entity.sourceType === Dab.EntitySourceType.StoredProcedure + ? undefined + : entity.columns.map((column) => { + const field = Dab.getFieldForColumn(entity, column.name); + return { + name: column.name, + alias: field?.alias, + description: field?.description, + isPrimaryKey: + field !== undefined ? field.isPrimaryKey === true : column.isPrimaryKey, + }; + }); + + return { + ...entity, + enabledActions: [...entity.enabledActions], + columns: entity.columns.map((column) => ({ ...column })), + fields, + parameters: entity.parameters?.map((parameter) => ({ + ...parameter, + name: parameter.name.replace(/^@/, ""), + isRequired: parameter.isRequired ?? true, + })), + advancedSettings: { + ...entity.advancedSettings, + permissions: Dab.getEntityPermissions(entity).map((permission) => ({ + role: permission.role, + actions: [...permission.actions], + })), + restEnabled: Dab.isEntityRestEnabled(entity), + graphQLEnabled: Dab.isEntityGraphQLEnabled(entity), + mcpEnabled: Dab.isEntityMcpEnabled(entity), + mcpDmlToolsEnabled: Dab.isEntityMcpDmlToolsEnabled(entity), + mcpCustomToolEnabled: Dab.isEntityMcpCustomToolEnabled(entity), + exposeAsMcpCustomTool: Dab.isEntityMcpCustomToolEnabled(entity), + }, + }; +} + +function getStoredProcedureRestMethod(settings: Dab.EntityAdvancedSettings): Dab.RestMethod { + return ( + settings.storedProcedureRestMethods?.find((method) => + Dab.storedProcedureAllowedRestMethods.some((allowedMethod) => allowedMethod === method), + ) ?? Dab.RestMethod.Post + ); +} + +function getAllowedActions(sourceType?: Dab.EntitySourceType): Dab.EntityAction[] { + return sourceType === Dab.EntitySourceType.StoredProcedure + ? [Dab.EntityAction.Execute] + : [ + Dab.EntityAction.Create, + Dab.EntityAction.Read, + Dab.EntityAction.Update, + Dab.EntityAction.Delete, + ]; +} + +function getActionLabel(action: Dab.EntityAction): string { + switch (action) { + case Dab.EntityAction.Create: + return locConstants.schemaDesigner.create; + case Dab.EntityAction.Read: + return locConstants.schemaDesigner.read; + case Dab.EntityAction.Update: + return locConstants.schemaDesigner.update; + case Dab.EntityAction.Delete: + return locConstants.common.delete; + case Dab.EntityAction.Execute: + return locConstants.schemaDesigner.execute; + } +} + +function getDefaultActionsForRole( + sourceType: Dab.EntitySourceType | undefined, + role: Dab.AuthorizationRole, +): Dab.EntityAction[] { + const configuredDefault = + Dab.getDefaultPermissionsForSource(sourceType).find( + (permission) => permission.role === role, + )?.actions ?? []; + return configuredDefault.length > 0 ? configuredDefault : getAllowedActions(sourceType); +} + export function DabEntitySettingsDialog({ entity, existingEntityNames, isRestEnabled, isGraphQLEnabled, isMcpEnabled, + initialTab, open, onOpenChange, onApply, onEnableApiType, }: DabEntitySettingsDialogProps) { const classes = useStyles(); - - // Local state for form - initialized when dialog opens - const [localSettings, setLocalSettings] = useState( - entity.advancedSettings, + const [localEntity, setLocalEntity] = useState(() => + cloneEntityForEditing(entity), ); + const [selectedTab, setSelectedTab] = useState("identity"); + const [activeTab, setActiveTab] = useState("identity"); + const drawerBodyRef = useRef(null); + const tabsRef = useRef(null); + const metadataScrollRef = useRef(null); + const identitySectionRef = useRef(null); + const permissionsSectionRef = useRef(null); + const restSectionRef = useRef(null); + const graphQLSectionRef = useRef(null); + const mcpSectionRef = useRef(null); + const schemaSectionRef = useRef(null); + const deepLinkFocusRef = useRef(null); - // Reset local state when dialog opens - useEffect(() => { - if (open) { - setLocalSettings(entity.advancedSettings); + const getSectionElement = (value: DabSettingsTab): HTMLElement | null => { + switch (value) { + case "identity": + return identitySectionRef.current; + case "permissions": + return permissionsSectionRef.current; + case "rest": + return restSectionRef.current; + case "graphql": + return graphQLSectionRef.current; + case "mcp": + return mcpSectionRef.current; + case "schema": + return schemaSectionRef.current; } - }, [open, entity.advancedSettings]); - - const handleCancel = () => { - onOpenChange(false); }; - const updateEntityName = (value: string) => { - setLocalSettings((prev) => ({ ...prev, entityName: value })); - }; + const focusSectionControl = (value: DabSettingsTab) => { + const section = getSectionElement(value); + const focusTarget = section?.querySelector( + [ + "input:not([disabled])", + "textarea:not([disabled])", + "button:not([disabled])", + "[role='checkbox']:not([aria-disabled='true'])", + "[role='radio']:not([aria-disabled='true'])", + "[tabindex]:not([tabindex='-1'])", + ].join(","), + ); + deepLinkFocusRef.current?.classList.remove(classes.deepLinkFocus); + deepLinkFocusRef.current = null; - const updateAuthorizationRole = (role: Dab.AuthorizationRole) => { - setLocalSettings((prev) => ({ ...prev, authorizationRole: role })); - }; + if (!focusTarget) { + return; + } - const updateCustomRestPath = (value: string) => { - setLocalSettings((prev) => ({ ...prev, customRestPath: value || undefined })); + focusTarget.focus({ preventScroll: true }); + focusTarget.classList.add(classes.deepLinkFocus); + deepLinkFocusRef.current = focusTarget; + focusTarget.addEventListener( + "blur", + () => { + focusTarget.classList.remove(classes.deepLinkFocus); + if (deepLinkFocusRef.current === focusTarget) { + deepLinkFocusRef.current = null; + } + }, + { once: true }, + ); }; - const updateRestEnabled = (value: boolean) => { - setLocalSettings((prev) => ({ ...prev, restEnabled: value })); - }; + const scrollToSelectedTab = (value: DabSettingsTab, behavior: ScrollBehavior = "smooth") => { + window.setTimeout(() => { + if (value === "schema") { + const drawerBody = drawerBodyRef.current; + const schemaSection = schemaSectionRef.current; + if (!drawerBody || !schemaSection) { + return; + } - const updateCustomGraphQLSingularType = (value: string) => { - setLocalSettings((prev) => ({ - ...prev, - customGraphQLType: undefined, - customGraphQLSingularType: value || undefined, - })); - }; + drawerBody.scrollTo({ + top: Math.max( + 0, + schemaSection.offsetTop - (tabsRef.current?.offsetHeight ?? 0), + ), + behavior, + }); + } else { + drawerBodyRef.current?.scrollTo({ top: 0, behavior }); + } - const updateCustomGraphQLPluralType = (value: string) => { - setLocalSettings((prev) => ({ - ...prev, - customGraphQLPluralType: value || undefined, - })); + window.setTimeout(() => focusSectionControl(value), behavior === "auto" ? 0 : 180); + }, 0); }; - const updateGraphQLEnabled = (value: boolean) => { - setLocalSettings((prev) => ({ ...prev, graphQLEnabled: value })); + const handleTabSelect = (value: DabSettingsTab) => { + setSelectedTab(value); + setActiveTab(value); + scrollToSelectedTab(value); }; - const updateStoredProcedureRestMethod = (method: Dab.RestMethod) => { - setLocalSettings((prev) => ({ - ...prev, - storedProcedureRestMethods: [method], - })); + useEffect(() => { + if (open) { + setLocalEntity(cloneEntityForEditing(entity)); + const tab = initialTab ?? "identity"; + setSelectedTab(tab); + setActiveTab(tab); + scrollToSelectedTab(tab, "auto"); + } + }, [entity, initialTab, open]); + + useEffect(() => { + const drawerBody = drawerBodyRef.current; + if (!open || !drawerBody) { + return; + } + + const handleScroll = () => { + const schemaSection = schemaSectionRef.current; + if (!schemaSection) { + setActiveTab(selectedTab); + return; + } + + const stickyTabsHeight = tabsRef.current?.offsetHeight ?? 0; + const activationOffset = Math.max(0, schemaSection.offsetTop - stickyTabsHeight - 12); + setActiveTab(drawerBody.scrollTop >= activationOffset ? "schema" : selectedTab); + }; + + handleScroll(); + drawerBody.addEventListener("scroll", handleScroll, { passive: true }); + return () => drawerBody.removeEventListener("scroll", handleScroll); + }, [open, selectedTab]); + + const settings = localEntity.advancedSettings; + const isStoredProcedure = localEntity.sourceType === Dab.EntitySourceType.StoredProcedure; + const sourceObjectName = `${localEntity.schemaName}.${ + localEntity.sourceName ?? localEntity.tableName + }`; + const entityName = settings.entityName.trim(); + const description = settings.description?.trim() ?? ""; + const customRestPath = settings.customRestPath?.trim() ?? ""; + const customGraphQLSingularType = + (settings.customGraphQLSingularType ?? settings.customGraphQLType)?.trim() ?? ""; + const customGraphQLPluralType = settings.customGraphQLPluralType?.trim() ?? ""; + const storedProcedureRestMethod = getStoredProcedureRestMethod(settings); + const storedProcedureGraphQLOperation = + settings.storedProcedureGraphQLOperation ?? Dab.GraphQLOperation.Mutation; + const isEntityRestEnabled = settings.restEnabled !== false; + const isEntityGraphQLEnabled = settings.graphQLEnabled !== false; + const isEntityMcpDmlToolsEnabled = settings.mcpDmlToolsEnabled !== false; + const isEntityMcpCustomToolEnabled = + settings.mcpCustomToolEnabled ?? settings.exposeAsMcpCustomTool ?? false; + const isEntityMcpEnabled = Dab.isEntityMcpEnabled(localEntity); + const permissions = useMemo(() => Dab.getEntityPermissions(localEntity), [localEntity]); + const parameters = localEntity.parameters ?? []; + const columnVirtualizer = useVirtualizer({ + count: localEntity.columns.length, + getScrollElement: () => metadataScrollRef.current, + estimateSize: () => 41, + overscan: 8, + }); + const parameterVirtualizer = useVirtualizer({ + count: parameters.length, + getScrollElement: () => metadataScrollRef.current, + estimateSize: () => 41, + overscan: 8, + }); + const isLocallyExposed = Dab.isEntityExposed(localEntity); + + const focusMetadataCell = (kind: MetadataGridKind, rowIndex: number, columnIndex: number) => { + const rowCount = kind === "columns" ? localEntity.columns.length : parameters.length; + const columnCount = + kind === "columns" + ? COLUMN_METADATA_GRID_COLUMN_COUNT + : PARAMETER_METADATA_GRID_COLUMN_COUNT; + const nextRow = Math.min(Math.max(rowIndex, 0), Math.max(rowCount - 1, 0)); + const nextColumn = Math.min(Math.max(columnIndex, 0), columnCount - 1); + const virtualizer = kind === "columns" ? columnVirtualizer : parameterVirtualizer; + virtualizer.scrollToIndex(nextRow, { align: "auto" }); + + requestAnimationFrame(() => { + requestAnimationFrame(() => { + metadataScrollRef.current + ?.querySelector( + `[data-dab-metadata-kind="${kind}"][data-dab-row-index="${nextRow}"][data-dab-column-index="${nextColumn}"]`, + ) + ?.focus(); + }); + }); }; - const updateStoredProcedureGraphQLOperation = (operation: Dab.GraphQLOperation) => { - setLocalSettings((prev) => ({ ...prev, storedProcedureGraphQLOperation: operation })); + const focusCellControl = (cell: HTMLElement) => { + cell.querySelector( + [ + "input:not([disabled])", + "textarea:not([disabled])", + "button:not([disabled])", + "[role='checkbox']:not([aria-disabled='true'])", + "[role='radio']:not([aria-disabled='true'])", + ].join(","), + )?.focus(); }; - const updateExposeAsMcpCustomTool = (value: boolean) => { - setLocalSettings((prev) => ({ ...prev, exposeAsMcpCustomTool: value })); + const handleMetadataGridKeyDown = ( + event: KeyboardEvent, + kind: MetadataGridKind, + rowCount: number, + columnCount: number, + ) => { + const target = event.target as HTMLElement; + const cell = target.closest("[data-dab-metadata-cell]"); + if (!cell) { + return; + } + + const rowIndex = Number(cell.dataset.dabRowIndex); + const columnIndex = Number(cell.dataset.dabColumnIndex); + if (!Number.isFinite(rowIndex) || !Number.isFinite(columnIndex)) { + return; + } + + const isTextInput = + target instanceof HTMLInputElement || + target instanceof HTMLTextAreaElement || + target.isContentEditable; + const key = event.key; + + if (isTextInput && key !== "ArrowUp" && key !== "ArrowDown" && key !== "Escape") { + return; + } + + let nextRow = rowIndex; + let nextColumn = columnIndex; + switch (key) { + case "ArrowDown": + nextRow += 1; + break; + case "ArrowUp": + nextRow -= 1; + break; + case "ArrowRight": + nextColumn += 1; + break; + case "ArrowLeft": + nextColumn -= 1; + break; + case "Home": + nextColumn = 0; + break; + case "End": + nextColumn = columnCount - 1; + break; + case "PageDown": + nextRow += 8; + break; + case "PageUp": + nextRow -= 8; + break; + case "Enter": + if (target === cell) { + event.preventDefault(); + focusCellControl(cell); + } + return; + case "Escape": + if (target !== cell) { + event.preventDefault(); + cell.focus(); + } + return; + default: + return; + } + + event.preventDefault(); + focusMetadataCell( + kind, + Math.min(Math.max(nextRow, 0), Math.max(rowCount - 1, 0)), + Math.min(Math.max(nextColumn, 0), columnCount - 1), + ); }; - const isStoredProcedure = entity.sourceType === Dab.EntitySourceType.StoredProcedure; - const isAnonymousSelected = localSettings.authorizationRole === Dab.AuthorizationRole.Anonymous; - const isAuthenticatedSelected = - localSettings.authorizationRole === Dab.AuthorizationRole.Authenticated; - const isEntityRestEnabled = localSettings.restEnabled !== false; - const isEntityGraphQLEnabled = localSettings.graphQLEnabled !== false; - const storedProcedureRestMethods = localSettings.storedProcedureRestMethods ?? [ - Dab.RestMethod.Post, - ]; - const storedProcedureRestMethod = - storedProcedureRestMethods.find((method) => - Dab.storedProcedureAllowedRestMethods.some((allowedMethod) => allowedMethod === method), - ) ?? Dab.RestMethod.Post; - const storedProcedureGraphQLOperation = - localSettings.storedProcedureGraphQLOperation ?? Dab.GraphQLOperation.Mutation; - const exposeAsMcpCustomTool = localSettings.exposeAsMcpCustomTool !== false; - const sourceObjectName = `${entity.schemaName}.${entity.sourceName ?? entity.tableName}`; - const entityName = localSettings.entityName.trim(); - const customRestPath = localSettings.customRestPath?.trim() ?? ""; - const customGraphQLSingularType = - (localSettings.customGraphQLSingularType ?? localSettings.customGraphQLType)?.trim() ?? ""; - const customGraphQLPluralType = localSettings.customGraphQLPluralType?.trim() ?? ""; - const normalizedExistingEntityNames = new Set( - existingEntityNames.map(Dab.normalizeDabIdentifier), + const getMetadataCellProps = ( + kind: MetadataGridKind, + rowIndex: number, + columnIndex: number, + ) => ({ + role: "gridcell", + tabIndex: 0, + "data-dab-metadata-cell": true, + "data-dab-metadata-kind": kind, + "data-dab-row-index": rowIndex, + "data-dab-column-index": columnIndex, + "aria-colindex": columnIndex + 1, + }); + + const normalizedExistingEntityNames = useMemo( + () => new Set(existingEntityNames.map(Dab.normalizeDabIdentifier)), + [existingEntityNames], ); + const entityNameValidationMessage = entityName.length === 0 ? "entityName must be a non-empty string." @@ -338,34 +702,181 @@ export function DabEntitySettingsDialog({ customGraphQLPluralType.length > 0 ? Dab.validateDabCustomGraphQLType(customGraphQLPluralType, "customGraphQLPluralType") : undefined; + const missingLogicalKeyValidationMessage = + isLocallyExposed && !isStoredProcedure && !Dab.hasLogicalKey(localEntity) + ? locConstants.schemaDesigner.missingLogicalKeyRequired + : undefined; const hasValidationError = !!entityNameValidationMessage || !!customRestPathValidationMessage || !!customGraphQLSingularTypeValidationMessage || - !!customGraphQLPluralTypeValidationMessage; + !!customGraphQLPluralTypeValidationMessage || + !!missingLogicalKeyValidationMessage; - const handleApply = () => { - if (hasValidationError) { - return; - } + const updateAdvancedSettings = (patch: Partial) => { + setLocalEntity((prev) => ({ + ...prev, + advancedSettings: { + ...prev.advancedSettings, + ...patch, + }, + })); + }; - onApply({ - ...localSettings, - entityName, - customRestPath: customRestPath.length > 0 ? customRestPath : undefined, - customGraphQLType: undefined, - customGraphQLSingularType: - customGraphQLSingularType.length > 0 ? customGraphQLSingularType : undefined, - customGraphQLPluralType: - customGraphQLPluralType.length > 0 ? customGraphQLPluralType : undefined, + const updateMcpParentEnabled = (enabled: boolean) => { + updateAdvancedSettings({ + mcpEnabled: enabled, + mcpDmlToolsEnabled: enabled, ...(isStoredProcedure - ? { storedProcedureRestMethods: [storedProcedureRestMethod] } + ? { + exposeAsMcpCustomTool: false, + mcpCustomToolEnabled: false, + } : {}), }); }; + const updateMcpDmlToolsEnabled = (enabled: boolean) => { + updateAdvancedSettings({ + mcpEnabled: isStoredProcedure ? enabled || isEntityMcpCustomToolEnabled : enabled, + mcpDmlToolsEnabled: enabled, + }); + }; + + const updateMcpCustomToolEnabled = (enabled: boolean) => { + updateAdvancedSettings({ + mcpEnabled: enabled || isEntityMcpDmlToolsEnabled, + exposeAsMcpCustomTool: enabled, + mcpCustomToolEnabled: enabled, + }); + }; + + const updatePermissions = (updatedPermissions: Dab.EntityPermissionConfig[]) => { + setLocalEntity((prev) => { + const activePermission = + updatedPermissions.find( + (permission) => + permission.role === prev.advancedSettings.authorizationRole && + permission.actions.length > 0, + ) ?? + updatedPermissions.find((permission) => permission.actions.length > 0) ?? + updatedPermissions[0]; + + return { + ...prev, + enabledActions: activePermission ? [...activePermission.actions] : [], + advancedSettings: { + ...prev.advancedSettings, + authorizationRole: + activePermission?.role ?? prev.advancedSettings.authorizationRole, + permissions: updatedPermissions.map((permission) => ({ + role: permission.role, + actions: [...permission.actions], + })), + }, + }; + }); + }; + + const updateRoleEnabled = (role: Dab.AuthorizationRole, enabled: boolean) => { + const updatedPermissions = permissions.map((permission) => + permission.role === role + ? { + ...permission, + actions: enabled + ? permission.actions.length > 0 + ? permission.actions + : getDefaultActionsForRole(localEntity.sourceType, role) + : [], + } + : permission, + ); + updatePermissions(updatedPermissions); + }; + + const updateRoleAction = ( + role: Dab.AuthorizationRole, + action: Dab.EntityAction, + enabled: boolean, + ) => { + const updatedPermissions = permissions.map((permission) => { + if (permission.role !== role) { + return permission; + } + + const actions = enabled + ? [...new Set([...permission.actions, action])] + : permission.actions.filter((a) => a !== action); + return { ...permission, actions }; + }); + updatePermissions(updatedPermissions); + }; + + const updateField = ( + column: Dab.DabColumnConfig, + patch: Partial>, + ) => { + setLocalEntity((prev) => { + const currentFields = + prev.fields ?? + prev.columns.map((c) => ({ + name: c.name, + isPrimaryKey: c.isPrimaryKey, + })); + const fields = currentFields.map((field) => + Dab.normalizeDabIdentifier(field.name) === Dab.normalizeDabIdentifier(column.name) + ? { ...field, ...patch } + : field, + ); + const logicalKey = patch.isPrimaryKey ?? Dab.isLogicalKeyColumn(prev, column); + return { + ...prev, + fields, + columns: prev.columns.map((c) => + c.id === column.id && logicalKey ? { ...c, isExposed: true } : c, + ), + }; + }); + }; + + const updateColumnExposure = (column: Dab.DabColumnConfig, isExposed: boolean) => { + setLocalEntity((prev) => { + if (!isExposed && Dab.isLogicalKeyColumn(prev, column)) { + return prev; + } + + return { + ...prev, + columns: prev.columns.map((c) => (c.id === column.id ? { ...c, isExposed } : c)), + }; + }); + }; + + const updateParameter = ( + parameterName: string, + patch: Partial & { clearDefault?: boolean }, + ) => { + setLocalEntity((prev) => ({ + ...prev, + parameters: prev.parameters?.map((parameter) => { + if ( + Dab.normalizeDabIdentifier(parameter.name.replace(/^@/, "")) !== + Dab.normalizeDabIdentifier(parameterName.replace(/^@/, "")) + ) { + return parameter; + } + + const updated: Dab.DabParameterConfig = { ...parameter, ...patch }; + if (patch.clearDefault) { + delete updated.defaultValue; + } + return updated; + }), + })); + }; + const renderSourceIcon = () => { - switch (entity.sourceType ?? Dab.EntitySourceType.Table) { + switch (localEntity.sourceType ?? Dab.EntitySourceType.Table) { case Dab.EntitySourceType.View: return ; case Dab.EntitySourceType.StoredProcedure: @@ -380,19 +891,10 @@ export function DabEntitySettingsDialog({ {title} ); - const renderLabelWithInfo = (label: string, infoText: string) => ( - - {label} - - +
+ {enabled && ( +
+ {allowedActions.map((action) => ( + + updateRoleAction(role, action, data.checked === true) + } + /> + ))} +
+ )} +
+ ); + }; + + const renderColumnsSection = () => { + if (isStoredProcedure) { + return undefined; + } + + return ( +
+ {renderSectionTitle(locConstants.schemaDesigner.columns)} + {missingLogicalKeyValidationMessage && ( + + {missingLogicalKeyValidationMessage} + + )} + {localEntity.columns.length === 0 ? ( + + {locConstants.schemaDesigner.noColumnsDiscovered} + + ) : ( +
+ handleMetadataGridKeyDown( + event, + "columns", + localEntity.columns.length, + COLUMN_METADATA_GRID_COLUMN_COUNT, + ) + }> +
+
+ {locConstants.schemaDesigner.expose} +
+
+ {locConstants.schemaDesigner.key} +
+
+ {locConstants.schemaDesigner.entityName} +
+
+ {locConstants.schemaDesigner.dataType} +
+
+ {locConstants.schemaDesigner.alias} +
+
+ {locConstants.schemaDesigner.description} +
+
+
+ {columnVirtualizer.getVirtualItems().map((virtualRow) => { + const column = localEntity.columns[virtualRow.index]; + const field = Dab.getFieldForColumn(localEntity, column.name); + const isLogicalKey = Dab.isLogicalKeyColumn(localEntity, column); + return ( +
+
+ + updateColumnExposure( + column, + data.checked === true, + ) + } + aria-label={locConstants.schemaDesigner.exposeColumn( + column.name, + )} + /> +
+
+ + updateField(column, { + isPrimaryKey: data.checked === true, + }) + } + aria-label={locConstants.schemaDesigner.logicalKey} + /> +
+
+ {column.name} +
+
+ {column.dataType} +
+
+ + updateField(column, { + alias: data.value || undefined, + }) + } + /> +
+
+ + updateField(column, { + description: data.value || undefined, + }) + } + /> +
+
+ ); + })} +
+
+ )} +
+ ); + }; + + const renderParametersSection = () => { + if (!isStoredProcedure) { + return undefined; + } + + return ( +
+ {renderSectionTitle(locConstants.schemaDesigner.parameters)} + {parameters.length === 0 ? ( + + {locConstants.schemaDesigner.noParametersDiscovered} + + ) : ( +
+ handleMetadataGridKeyDown( + event, + "parameters", + parameters.length, + PARAMETER_METADATA_GRID_COLUMN_COUNT, + ) + }> +
+
+ {locConstants.schemaDesigner.entityName} +
+
+ {locConstants.schemaDesigner.dataType} +
+
+ {locConstants.schemaDesigner.required} +
+
+ {locConstants.schemaDesigner.defaultValue} +
+
+ {locConstants.schemaDesigner.description} +
+
+
+ {parameterVirtualizer.getVirtualItems().map((virtualRow) => { + const parameter = parameters[virtualRow.index]; + return ( +
+
+ @{parameter.name.replace(/^@/, "")} +
+
+ {parameter.dataType ?? ""} +
+
+ + updateParameter(parameter.name, { + isRequired: data.checked === true, + }) + } + /> +
+
+ + updateParameter(parameter.name, { + defaultValue: data.value || undefined, + clearDefault: data.value.length === 0, + }) + } + /> +
+
+ + updateParameter(parameter.name, { + description: data.value || undefined, + }) + } + /> +
+
+ ); + })} +
+
+ )} +
+ ); + }; + + const handleCancel = () => { + onOpenChange(false); + }; + + const handleApply = () => { + if (hasValidationError) { + return; + } + + const sanitizedPermissions = Dab.getEntityPermissions(localEntity).map((permission) => ({ + role: permission.role, + actions: [...permission.actions], + })); + const activePermission = + sanitizedPermissions.find( + (permission) => + permission.role === settings.authorizationRole && permission.actions.length > 0, + ) ?? + sanitizedPermissions.find((permission) => permission.actions.length > 0) ?? + sanitizedPermissions[0]; + + const sanitizedEntity: Dab.DabEntityConfig = { + ...localEntity, + isEnabled: Dab.isEntityExposed(localEntity), + enabledActions: activePermission ? [...activePermission.actions] : [], + columns: localEntity.columns.map((column) => + Dab.isLogicalKeyColumn(localEntity, column) + ? { ...column, isExposed: true } + : { ...column }, + ), + fields: isStoredProcedure + ? undefined + : localEntity.columns.map((column) => { + const field = Dab.getFieldForColumn(localEntity, column.name); + return { + name: column.name, + ...(field?.alias?.trim() ? { alias: field.alias.trim() } : {}), + ...(field?.description?.trim() + ? { description: field.description.trim() } + : {}), + isPrimaryKey: field?.isPrimaryKey === true, + }; + }), + parameters: localEntity.parameters?.map((parameter) => ({ + name: parameter.name.replace(/^@/, ""), + dataType: parameter.dataType, + isRequired: parameter.isRequired !== false, + ...(parameter.defaultValue !== undefined && parameter.defaultValue !== "" + ? { defaultValue: String(parameter.defaultValue) } + : {}), + ...(parameter.description?.trim() + ? { description: parameter.description.trim() } + : {}), + })), + advancedSettings: { + ...settings, + entityName, + description: description.length > 0 ? description : undefined, + authorizationRole: activePermission?.role ?? settings.authorizationRole, + permissions: sanitizedPermissions, + customRestPath: customRestPath.length > 0 ? customRestPath : undefined, + customGraphQLType: undefined, + customGraphQLSingularType: + customGraphQLSingularType.length > 0 ? customGraphQLSingularType : undefined, + customGraphQLPluralType: + customGraphQLPluralType.length > 0 ? customGraphQLPluralType : undefined, + storedProcedureRestMethods: isStoredProcedure + ? [storedProcedureRestMethod] + : undefined, + storedProcedureGraphQLOperation: isStoredProcedure + ? storedProcedureGraphQLOperation + : undefined, + mcpEnabled: isEntityMcpEnabled, + mcpDmlToolsEnabled: isEntityMcpEnabled && isEntityMcpDmlToolsEnabled, + exposeAsMcpCustomTool: isStoredProcedure ? isEntityMcpCustomToolEnabled : undefined, + mcpCustomToolEnabled: isStoredProcedure ? isEntityMcpCustomToolEnabled : undefined, + }, + }; + + onApply(sanitizedEntity); + }; + return ( - -
- {renderSectionTitle(locConstants.schemaDesigner.identity)} -
- - updateEntityName(data.value)} - /> - -
-
- -
- {renderSectionTitle(locConstants.schemaDesigner.authorizationRole)} -
-
- - updateAuthorizationRole(Dab.AuthorizationRole.Anonymous) - }> -
- - {locConstants.schemaDesigner.anonymous} - - - {locConstants.schemaDesigner.anonymousDescription} - -
-
- - updateAuthorizationRole(Dab.AuthorizationRole.Authenticated) - }> -
- - {locConstants.schemaDesigner.authenticated} - - - {locConstants.schemaDesigner.authenticatedDescription} - -
-
-
-
-
- -
- {renderSectionTitle(locConstants.schemaDesigner.rest)} -
- {!isRestEnabled ? ( - renderDisabledBanner(Dab.ApiType.Rest, locConstants.schemaDesigner.rest) - ) : ( - <> - updateRestEnabled(!!data.checked)} - label={locConstants.schemaDesigner.enableRestForEntity} + + handleTabSelect(data.value as DabSettingsTab)}> + {locConstants.schemaDesigner.identity} + {locConstants.schemaDesigner.authorizationRole} + {locConstants.schemaDesigner.rest} + {locConstants.schemaDesigner.graphql} + {locConstants.schemaDesigner.mcp} + + {isStoredProcedure + ? locConstants.schemaDesigner.parameters + : locConstants.schemaDesigner.columns} + + +
+