diff --git a/extensions/mssql/l10n/bundle.l10n.json b/extensions/mssql/l10n/bundle.l10n.json index 44b39f6c9a..73c8517209 100644 --- a/extensions/mssql/l10n/bundle.l10n.json +++ b/extensions/mssql/l10n/bundle.l10n.json @@ -3034,6 +3034,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", @@ -3041,12 +3043,16 @@ "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.", "Expose as MCP DML tools": "Expose as MCP DML tools", "Allows MCP clients to use generic create, read, update, and delete tools for this table.": "Allows MCP clients to use generic create, read, update, and delete tools for this table.", "Enable MCP in API Type to use this DML tools setting.": "Enable MCP in API Type to use this DML tools setting.", "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"] @@ -3065,12 +3071,17 @@ "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", "Warnings": "Warnings", "{0} of {1} enabled/{0} is the number of enabled entities{1} is the total number of entities": { "message": "{0} of {1} enabled", "comment": ["{0} is the number of enabled entities", "{1} is the total number of entities"] }, "Read": "Read", + "Exec": "Exec", "Tables": "Tables", "Views": "Views", "Stored Procedures": "Stored Procedures", @@ -3103,9 +3114,11 @@ "Make everything read-only": "Make everything read-only", "Enable all CRUD operations": "Enable all CRUD operations", "Include all columns": "Include all columns", + "Customize column access": "Customize column access", "Entity name used in API routes": "Entity name used in API routes", "View Config": "View Config", "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", @@ -3122,9 +3135,21 @@ "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 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", @@ -3148,6 +3173,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"] @@ -3403,7 +3432,6 @@ "Tags": "Tags", "Add tag": "Add tag", "Remove tag": "Remove tag", - "Key": "Key", "Tag keys must be unique.": "Tag keys must be unique.", "Firewall": "Firewall", "General Purpose": "General Purpose", diff --git a/extensions/mssql/package.json b/extensions/mssql/package.json index 43434386c6..dd1ee98e35 100644 --- a/extensions/mssql/package.json +++ b/extensions/mssql/package.json @@ -3770,7 +3770,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, table-only mcpDmlToolsEnabled, 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", @@ -3876,6 +3876,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": { @@ -3883,6 +3927,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": [ @@ -3918,12 +3974,40 @@ } ] }, + "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": "Table only. When true, exposes the table through generic MCP DML tools (create/read/update/delete) if MCP is globally enabled. When false, emits mcp { dml-tools: false } for the table.", + "description": "When true, enables generic MCP DML tools for this entity.", "type": "boolean" }, "storedProcedureRestMethods": { @@ -3937,10 +4021,7 @@ "type": "string", "enum": [ "get", - "post", - "put", - "patch", - "delete" + "post" ] } }, @@ -3960,6 +4041,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, @@ -3984,7 +4069,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": { @@ -4047,6 +4132,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": { @@ -4066,6 +4315,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 20ea469100..d903121958 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 }; @@ -72,7 +73,8 @@ interface DabParameterOutput { interface DabPermissionAction { action: string; fields?: { - exclude: string[]; + include?: string[]; + exclude?: string[]; }; } @@ -166,7 +168,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 +194,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 +222,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,50 +232,37 @@ export class DabConfigFileBuilder { })); } - const mcpConfig = this.buildMcpProperty(entity, isMcpEnabled); - if (mcpConfig) { - output.mcp = mcpConfig; + if (isMcpEnabled) { + output.mcp = this.buildMcpProperty(entity); } return output; } - private buildMcpProperty( - entity: Dab.DabEntityConfig, - isMcpEnabled: boolean, - ): DabEntityOutput["mcp"] | undefined { - if (!isMcpEnabled) { - return undefined; - } - + private buildFieldsProperty(entity: Dab.DabEntityConfig): Dab.DabFieldConfig[] { if (entity.sourceType === Dab.EntitySourceType.StoredProcedure) { - return entity.advancedSettings.exposeAsMcpCustomTool !== false - ? { - "custom-tool": true, - "dml-tools": false, - } - : undefined; + return []; } - if (entity.advancedSettings.mcpDmlToolsEnabled === undefined) { - return undefined; + 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 { - "dml-tools": entity.advancedSettings.mcpDmlToolsEnabled, - }; - } - - private buildKeyFieldsProperty(entity: Dab.DabEntityConfig): { "key-fields"?: string[] } { - if (entity.sourceType === Dab.EntitySourceType.StoredProcedure || entity.fields?.length) { - return {}; - } - - const keyFields = entity.columns - .filter((column) => column.isPrimaryKey) - .map((column) => column.name); - - return keyFields.length > 0 ? { "key-fields": keyFields } : {}; + return entity.columns.map((column) => ({ + name: column.name, + ...(column.isPrimaryKey ? { isPrimaryKey: true } : {}), + })); } /** @@ -361,7 +353,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 } : {}), }; } @@ -373,33 +367,52 @@ 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) => - hiddenColumns.length > 0 && action !== Dab.EntityAction.Delete + const allColumns = entity.columns.map((column) => column.name); + + return Dab.getEntityPermissions(entity) + .filter((permission) => permission.actions.length > 0) + .map((permission) => ({ + role: permission.role, + actions: permission.actions.map((action) => { + const actionFieldAccess = permission.fieldAccess?.find( + (access) => access.action === action, + ); + if (actionFieldAccess || permission.fieldAccess?.length) { + return { + action, + fields: { + include: [...(actionFieldAccess?.fields ?? allColumns)], + }, + }; + } + + return hiddenColumns.length > 0 && action !== Dab.EntityAction.Delete ? { action, fields: { exclude: [...hiddenColumns], }, } - : action, - ), - }, - ]; + : 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 1ec5c2613e..8a91e08518 100644 --- a/extensions/mssql/src/schemaDesigner/schemaDesignerWebviewController.ts +++ b/extensions/mssql/src/schemaDesigner/schemaDesignerWebviewController.ts @@ -23,6 +23,12 @@ import { DabService } from "../services/dabService"; import { Dab } from "../sharedInterfaces/dab"; import { CopilotChat } from "../sharedInterfaces/copilotChat"; import { addMcpServerToWorkspace } from "../copilot/copilotUtils"; +import type { IMetadataService } from "../services/metadataService"; +import type { + DabDatabaseObjectMetadata, + DabStoredProcedureParameterMetadata, + DabViewColumnMetadata, +} from "../sharedInterfaces/metadata"; import { getSchemaDesignerDefinitionOutput, SchemaDesignerDefinitionOutput, @@ -509,6 +515,12 @@ export class SchemaDesignerWebviewController extends WebviewPanelController< }); // DAB request handlers + this.onRequest(Dab.GetDatabaseObjectsRequest.type, async () => { + return { + sourceObjects: await this.getDabDatabaseObjects(), + }; + }); + this.onRequest(Dab.GenerateConfigRequest.type, async (payload) => { return this._dabService.generateConfig(payload.config, { connectionString: this.connectionString, @@ -666,6 +678,151 @@ export class SchemaDesignerWebviewController extends WebviewPanelController< }); } + private async getDabDatabaseObjects(): Promise { + if (!this.connectionUri) { + return []; + } + + const metadataService = this.mainController.metadataService; + const [views, storedProcedures] = await Promise.all([ + metadataService.listDabViews(this.connectionUri, this.databaseName), + metadataService.listDabStoredProcedures(this.connectionUri, this.databaseName), + ]); + const [viewColumnsByView, parametersByProcedure] = await Promise.all([ + this.getDabViewColumnsByView(metadataService, this.connectionUri, views), + this.getDabStoredProcedureParametersByProcedure( + metadataService, + this.connectionUri, + storedProcedures, + ), + ]); + + const viewObjects = views.map((view) => { + const columns = viewColumnsByView.get(view.id) ?? []; + return { + id: view.id, + sourceType: Dab.EntitySourceType.View, + schemaName: view.schema, + sourceName: view.name, + columns: columns.map((column) => ({ + id: column.id, + name: column.name, + dataType: column.dataType, + isPrimaryKey: column.isPrimaryKey, + isSupported: Dab.isDataTypeSupportedForDab(column.dataType), + isExposed: true, + })), + fields: columns.map((column) => ({ + name: column.name, + ...(column.isPrimaryKey ? { isPrimaryKey: true } : {}), + })), + }; + }); + + const storedProcedureObjects = storedProcedures.map((procedure) => { + const parameters = parametersByProcedure.get(procedure.id) ?? []; + return { + id: procedure.id, + sourceType: Dab.EntitySourceType.StoredProcedure, + schemaName: procedure.schema, + sourceName: procedure.name, + columns: [], + parameters: parameters.map((parameter) => ({ + name: parameter.name.replace(/^@/, ""), + dataType: parameter.dataType, + isRequired: true, + })), + }; + }); + + return [...viewObjects, ...storedProcedureObjects]; + } + + private async getDabViewColumnsByView( + metadataService: IMetadataService, + ownerUri: string, + views: DabDatabaseObjectMetadata[], + ): Promise> { + if (views.length === 0) { + return new Map(); + } + + try { + return await metadataService.getDabViewColumnsByView(ownerUri, this.databaseName); + } catch (error) { + logger.warn( + `Failed to load DAB view columns in bulk. Falling back to per-view metadata. ${getErrorMessage(error)}`, + ); + } + + return new Map( + await Promise.all( + views.map(async (view) => { + try { + return [ + view.id, + await metadataService.getDabViewColumns( + ownerUri, + view.schema, + view.name, + this.databaseName, + ), + ] as const; + } catch (error) { + logger.warn( + `Failed to load DAB view columns for ${view.schema}.${view.name}. ${getErrorMessage(error)}`, + ); + return [view.id, [] as DabViewColumnMetadata[]] as const; + } + }), + ), + ); + } + + private async getDabStoredProcedureParametersByProcedure( + metadataService: IMetadataService, + ownerUri: string, + storedProcedures: DabDatabaseObjectMetadata[], + ): Promise> { + if (storedProcedures.length === 0) { + return new Map(); + } + + try { + return await metadataService.getDabStoredProcedureParametersByProcedure( + ownerUri, + this.databaseName, + ); + } catch (error) { + logger.warn( + `Failed to load DAB stored procedure parameters in bulk. Falling back to per-procedure metadata. ${getErrorMessage(error)}`, + ); + } + + return new Map( + await Promise.all( + storedProcedures.map(async (procedure) => { + try { + return [ + procedure.id, + await metadataService.getDabStoredProcedureParameters( + ownerUri, + procedure.schema, + procedure.name, + this.databaseName, + ), + ] as const; + } catch (error) { + logger.warn( + `Failed to load DAB stored procedure parameters for ${procedure.schema}.${procedure.name}. ${getErrorMessage(error)}`, + ); + return [procedure.id, [] as DabStoredProcedureParameterMetadata[]] as const; + } + }), + ), + ); + } + private setupReducers() { this.registerReducer("dismissCopilotChatDiscovery", async (state, payload) => { if (!payload?.scenario) { diff --git a/extensions/mssql/src/services/metadataService.ts b/extensions/mssql/src/services/metadataService.ts index e6509c683b..b8a580a07b 100644 --- a/extensions/mssql/src/services/metadataService.ts +++ b/extensions/mssql/src/services/metadataService.ts @@ -4,6 +4,8 @@ *--------------------------------------------------------------------------------------------*/ import SqlToolsServiceClient from "../languageservice/serviceclient"; +import { RequestType } from "vscode-languageclient"; +import { SimpleExecuteResult } from "vscode-mssql"; import { GetServerContextualizationRequest, ListDatabasesRequest, @@ -13,6 +15,9 @@ import { } from "../models/contracts/metadataContracts"; import { ColumnMetadata, + DabDatabaseObjectMetadata, + DabStoredProcedureParameterMetadata, + DabViewColumnMetadata, DatabaseInfo, GetServerContextualizationParams, GetServerContextualizationResult, @@ -24,8 +29,160 @@ import { TableMetadataParams, TableMetadataResult, } from "../sharedInterfaces/metadata"; +import { bracketEscapeSqlIdentifier } from "../models/utils"; +import { escapeStringLiteral } from "../utils/sqlStringUtils"; import { getErrorMessage } from "../utils/utils"; +const simpleExecuteRequest = new RequestType< + { ownerUri: string; queryString: string }, + SimpleExecuteResult, + void, + void +>("query/simpleexecute"); + +const listDabViewsQuery = ` +SELECT + SCHEMA_NAME(v.schema_id) AS [schema_name], + v.name AS [object_name], + CONCAT('view:', SCHEMA_NAME(v.schema_id), '.', v.name) AS [object_id] +FROM sys.views AS v +WHERE v.is_ms_shipped = 0 +ORDER BY SCHEMA_NAME(v.schema_id), v.name;`; + +const listDabStoredProceduresQuery = ` +SELECT + SCHEMA_NAME(p.schema_id) AS [schema_name], + p.name AS [object_name], + CONCAT('stored-procedure:', SCHEMA_NAME(p.schema_id), '.', p.name) AS [object_id] +FROM sys.procedures AS p +WHERE p.is_ms_shipped = 0 +ORDER BY SCHEMA_NAME(p.schema_id), p.name;`; + +const listDabViewColumnsQuery = ` +;WITH ranked_unique_indexes AS +( + SELECT + i.object_id, + i.index_id, + ROW_NUMBER() OVER ( + PARTITION BY i.object_id + ORDER BY + CASE WHEN i.is_primary_key = 1 THEN 0 ELSE 1 END, + i.index_id + ) AS [rank] + FROM sys.indexes AS i + INNER JOIN sys.views AS v + ON v.object_id = i.object_id + WHERE v.is_ms_shipped = 0 + AND i.is_unique = 1 + AND i.is_hypothetical = 0 + AND i.has_filter = 0 +), +selected_unique_index AS +( + SELECT + object_id, + index_id + FROM ranked_unique_indexes + WHERE [rank] = 1 +) +SELECT + CONCAT('view:', SCHEMA_NAME(v.schema_id), '.', v.name) AS [object_id], + CONCAT('view:', SCHEMA_NAME(v.schema_id), '.', v.name, ':', c.name) AS [column_id], + c.name AS [column_name], + TYPE_NAME(c.user_type_id) AS [data_type], + c.column_id AS [ordinal], + CAST(CASE WHEN ic.column_id IS NULL THEN 0 ELSE 1 END AS bit) AS [is_primary_key] +FROM sys.views AS v +INNER JOIN sys.columns AS c + ON c.object_id = v.object_id +LEFT JOIN selected_unique_index AS sui + ON sui.object_id = v.object_id +LEFT JOIN sys.index_columns AS ic + ON ic.object_id = sui.object_id + AND ic.index_id = sui.index_id + AND ic.column_id = c.column_id + AND ic.key_ordinal > 0 + AND ic.is_included_column = 0 +WHERE v.is_ms_shipped = 0 +ORDER BY SCHEMA_NAME(v.schema_id), v.name, c.column_id;`; + +const listDabStoredProcedureParametersQuery = ` +SELECT + CONCAT('stored-procedure:', SCHEMA_NAME(sp.schema_id), '.', sp.name) AS [object_id], + p.name AS [parameter_name], + TYPE_NAME(p.user_type_id) AS [data_type], + p.parameter_id AS [ordinal] +FROM sys.procedures AS sp +INNER JOIN sys.parameters AS p + ON p.object_id = sp.object_id +WHERE sp.is_ms_shipped = 0 + AND p.parameter_id > 0 +ORDER BY SCHEMA_NAME(sp.schema_id), sp.name, p.parameter_id;`; + +function getDabViewColumnsQuery(schema: string, viewName: string): string { + return ` +DECLARE @schemaName sysname = N'${escapeStringLiteral(schema)}'; +DECLARE @viewName sysname = N'${escapeStringLiteral(viewName)}'; +DECLARE @viewObjectId int = OBJECT_ID(QUOTENAME(@schemaName) + N'.' + QUOTENAME(@viewName)); + +;WITH selected_unique_index AS +( + SELECT TOP (1) + i.object_id, + i.index_id + FROM sys.indexes AS i + WHERE i.object_id = @viewObjectId + AND i.is_unique = 1 + AND i.is_hypothetical = 0 + AND i.has_filter = 0 + ORDER BY + CASE WHEN i.is_primary_key = 1 THEN 0 ELSE 1 END, + i.index_id +) +SELECT + CONCAT('view:', SCHEMA_NAME(v.schema_id), '.', v.name, ':', c.name) AS [column_id], + c.name AS [column_name], + TYPE_NAME(c.user_type_id) AS [data_type], + c.column_id AS [ordinal], + CAST(CASE + WHEN EXISTS ( + SELECT 1 + FROM selected_unique_index AS sui + INNER JOIN sys.index_columns AS ic + ON ic.object_id = sui.object_id + AND ic.index_id = sui.index_id + AND ic.column_id = c.column_id + AND ic.key_ordinal > 0 + AND ic.is_included_column = 0 + ) THEN 1 + ELSE 0 + END AS bit) AS [is_primary_key] +FROM sys.views AS v +INNER JOIN sys.columns AS c + ON c.object_id = v.object_id +WHERE v.object_id = @viewObjectId +ORDER BY c.column_id;`; +} + +function getDabStoredProcedureParametersQuery(schema: string, procedureName: string): string { + return ` +DECLARE @schemaName sysname = N'${escapeStringLiteral(schema)}'; +DECLARE @procedureName sysname = N'${escapeStringLiteral(procedureName)}'; +DECLARE @procedureObjectId int = OBJECT_ID( + QUOTENAME(@schemaName) + N'.' + QUOTENAME(@procedureName) +); + +SELECT + p.name AS [parameter_name], + TYPE_NAME(p.user_type_id) AS [data_type], + p.parameter_id AS [ordinal] +FROM sys.parameters AS p +WHERE p.object_id = @procedureObjectId + AND p.parameter_id > 0 +ORDER BY p.parameter_id;`; +} + /** * Interface for the Metadata Service that handles database metadata operations. */ @@ -63,6 +220,37 @@ export interface IMetadataService { */ getViewInfo(ownerUri: string, schema: string, objectName: string): Promise; + listDabViews(ownerUri: string, databaseName?: string): Promise; + + listDabStoredProcedures( + ownerUri: string, + databaseName?: string, + ): Promise; + + getDabViewColumnsByView( + ownerUri: string, + databaseName?: string, + ): Promise>; + + getDabViewColumns( + ownerUri: string, + schema: string, + objectName: string, + databaseName?: string, + ): Promise; + + getDabStoredProcedureParametersByProcedure( + ownerUri: string, + databaseName?: string, + ): Promise>; + + getDabStoredProcedureParameters( + ownerUri: string, + schema: string, + objectName: string, + databaseName?: string, + ): Promise; + /** * Lists all databases on the connected server. * @@ -152,6 +340,108 @@ export class MetadataService implements IMetadataService { return this.getObjectColumnInfo(ownerUri, schema, objectName, "view"); } + public async listDabViews( + ownerUri: string, + databaseName?: string, + ): Promise { + try { + const result = await this.executeSimpleQuery(ownerUri, listDabViewsQuery, databaseName); + return this.parseDabDatabaseObjects(result); + } catch (error) { + this._client.logger.error(getErrorMessage(error)); + throw error; + } + } + + public async listDabStoredProcedures( + ownerUri: string, + databaseName?: string, + ): Promise { + try { + const result = await this.executeSimpleQuery( + ownerUri, + listDabStoredProceduresQuery, + databaseName, + ); + return this.parseDabDatabaseObjects(result); + } catch (error) { + this._client.logger.error(getErrorMessage(error)); + throw error; + } + } + + public async getDabViewColumns( + ownerUri: string, + schema: string, + objectName: string, + databaseName?: string, + ): Promise { + try { + const result = await this.executeSimpleQuery( + ownerUri, + getDabViewColumnsQuery(schema, objectName), + databaseName, + ); + return this.parseDabViewColumns(result); + } catch (error) { + this._client.logger.error(getErrorMessage(error)); + throw error; + } + } + + public async getDabViewColumnsByView( + ownerUri: string, + databaseName?: string, + ): Promise> { + try { + const result = await this.executeSimpleQuery( + ownerUri, + listDabViewColumnsQuery, + databaseName, + ); + return this.parseDabViewColumnsByObject(result); + } catch (error) { + this._client.logger.error(getErrorMessage(error)); + throw error; + } + } + + public async getDabStoredProcedureParameters( + ownerUri: string, + schema: string, + objectName: string, + databaseName?: string, + ): Promise { + try { + const result = await this.executeSimpleQuery( + ownerUri, + getDabStoredProcedureParametersQuery(schema, objectName), + databaseName, + ); + return this.parseDabStoredProcedureParameters(result); + } catch (error) { + this._client.logger.error(getErrorMessage(error)); + throw error; + } + } + + public async getDabStoredProcedureParametersByProcedure( + ownerUri: string, + databaseName?: string, + ): Promise> { + try { + const result = await this.executeSimpleQuery( + ownerUri, + listDabStoredProcedureParametersQuery, + databaseName, + ); + return this.parseDabStoredProcedureParametersByObject(result); + } catch (error) { + this._client.logger.error(getErrorMessage(error)); + throw error; + } + } + /** * Retrieves column metadata for a specific table or view. * @@ -187,6 +477,158 @@ export class MetadataService implements IMetadataService { } } + private async executeSimpleQuery( + ownerUri: string, + queryString: string, + databaseName?: string, + ): Promise { + return this._client.sendRequest(simpleExecuteRequest, { + ownerUri, + queryString: this.withDatabaseContext(queryString, databaseName), + }); + } + + private withDatabaseContext(queryString: string, databaseName?: string): string { + if (!databaseName?.trim()) { + return queryString; + } + return `USE ${bracketEscapeSqlIdentifier(databaseName.trim())}; +${queryString}`; + } + + private getCellDisplayValue( + result: SimpleExecuteResult, + rowIndex: number, + columnIndex: number, + ): string | undefined { + const row = result?.rows?.[rowIndex]; + const cell = row?.[columnIndex]; + if (!cell || cell.isNull) { + return undefined; + } + return cell.displayValue; + } + + private getBooleanCellValue( + result: SimpleExecuteResult, + rowIndex: number, + columnIndex: number, + ): boolean { + const value = (this.getCellDisplayValue(result, rowIndex, columnIndex) ?? "") + .trim() + .toLowerCase(); + return value === "1" || value === "true"; + } + + private getPositiveOrdinalCellValue( + result: SimpleExecuteResult, + rowIndex: number, + columnIndex: number, + ): number | undefined { + const rawValue = this.getCellDisplayValue(result, rowIndex, columnIndex); + if (!rawValue) { + return undefined; + } + + const ordinal = Number(rawValue); + return Number.isInteger(ordinal) && ordinal > 0 ? ordinal : undefined; + } + + private parseDabDatabaseObjects(result: SimpleExecuteResult): DabDatabaseObjectMetadata[] { + return (result?.rows ?? []) + .map((_, index) => { + const schema = this.getCellDisplayValue(result, index, 0); + const name = this.getCellDisplayValue(result, index, 1); + const id = this.getCellDisplayValue(result, index, 2); + if (!schema || !name || !id) { + return undefined; + } + return { id, schema, name }; + }) + .filter((object): object is DabDatabaseObjectMetadata => !!object); + } + + private parseDabViewColumns(result: SimpleExecuteResult): DabViewColumnMetadata[] { + return (result?.rows ?? []) + .map((_, index) => { + const id = this.getCellDisplayValue(result, index, 0); + const name = this.getCellDisplayValue(result, index, 1); + const dataType = this.getCellDisplayValue(result, index, 2); + const ordinal = this.getPositiveOrdinalCellValue(result, index, 3); + const isPrimaryKey = this.getBooleanCellValue(result, index, 4); + if (!id || !name || !dataType || ordinal === undefined) { + return undefined; + } + return { id, name, dataType, ordinal, isPrimaryKey }; + }) + .filter((column): column is DabViewColumnMetadata => !!column); + } + + private parseDabViewColumnsByObject( + result: SimpleExecuteResult, + ): Map { + const columnsByObject = new Map(); + for (let index = 0; index < (result?.rows ?? []).length; index++) { + const objectId = this.getCellDisplayValue(result, index, 0); + const id = this.getCellDisplayValue(result, index, 1); + const name = this.getCellDisplayValue(result, index, 2); + const dataType = this.getCellDisplayValue(result, index, 3); + const ordinal = this.getPositiveOrdinalCellValue(result, index, 4); + const isPrimaryKey = this.getBooleanCellValue(result, index, 5); + if (!objectId || !id || !name || !dataType || ordinal === undefined) { + continue; + } + + const columns = columnsByObject.get(objectId) ?? []; + columns.push({ id, name, dataType, ordinal, isPrimaryKey }); + columnsByObject.set(objectId, columns); + } + + return columnsByObject; + } + + private parseDabStoredProcedureParameters( + result: SimpleExecuteResult, + ): DabStoredProcedureParameterMetadata[] { + return (result?.rows ?? []) + .map((_, index) => { + const name = this.getCellDisplayValue(result, index, 0); + const dataType = this.getCellDisplayValue(result, index, 1); + const ordinal = this.getPositiveOrdinalCellValue(result, index, 2); + if (!name || !dataType || ordinal === undefined) { + return undefined; + } + const parameter: DabStoredProcedureParameterMetadata = { + name, + dataType, + ordinal, + }; + return parameter; + }) + .filter((parameter): parameter is DabStoredProcedureParameterMetadata => !!parameter); + } + + private parseDabStoredProcedureParametersByObject( + result: SimpleExecuteResult, + ): Map { + const parametersByObject = new Map(); + for (let index = 0; index < (result?.rows ?? []).length; index++) { + const objectId = this.getCellDisplayValue(result, index, 0); + const name = this.getCellDisplayValue(result, index, 1); + const dataType = this.getCellDisplayValue(result, index, 2); + const ordinal = this.getPositiveOrdinalCellValue(result, index, 3); + if (!objectId || !name || !dataType || ordinal === undefined) { + continue; + } + + const parameters = parametersByObject.get(objectId) ?? []; + parameters.push({ name, dataType, ordinal }); + parametersByObject.set(objectId, parameters); + } + + return parametersByObject; + } + /** * Lists all databases on the connected server. * diff --git a/extensions/mssql/src/sharedInterfaces/dab.ts b/extensions/mssql/src/sharedInterfaces/dab.ts index 493ec19078..acae2ed858 100644 --- a/extensions/mssql/src/sharedInterfaces/dab.ts +++ b/extensions/mssql/src/sharedInterfaces/dab.ts @@ -123,6 +123,22 @@ export namespace Dab { Authenticated = "authenticated", } + export const supportedAuthorizationRoles = [ + AuthorizationRole.Anonymous, + AuthorizationRole.Authenticated, + ] as const; + + export interface EntityPermissionFieldAccess { + action: EntityAction; + fields: string[]; + } + + export interface EntityPermissionConfig { + role: AuthorizationRole; + actions: EntityAction[]; + fieldAccess?: EntityPermissionFieldAccess[]; + } + /** * Advanced configuration options for an entity */ @@ -132,9 +148,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) */ @@ -163,8 +188,13 @@ export namespace Dab { */ graphQLEnabled?: boolean; /** - * Whether this table entity should be exposed through MCP DML tools. - * Defaults to true in DAB when omitted. + * 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; /** @@ -177,9 +207,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; } /** @@ -266,13 +301,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[]; @@ -421,6 +456,16 @@ export namespace Dab { ); } + export interface GetDatabaseObjectsResponse { + sourceObjects: DabSourceObject[]; + } + + export namespace GetDatabaseObjectsRequest { + export const type = new RequestType( + "dab/getDatabaseObjects", + ); + } + /** * Entity reference for DAB tool operations. * Exactly one form is supported: id OR schemaName+tableName OR schemaName+sourceName+sourceType. @@ -454,13 +499,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 }; @@ -1108,8 +1188,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; @@ -1129,7 +1209,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): { @@ -1137,10 +1220,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" }); } @@ -1165,7 +1252,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 }; } /** @@ -1189,13 +1279,262 @@ 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)); + } + + function normalizePermissionFieldAccess( + fieldAccess: EntityPermissionFieldAccess[] | undefined, + actions: EntityAction[], + sourceType?: EntitySourceType, + ): EntityPermissionFieldAccess[] | undefined { + if (!fieldAccess?.length || sourceType === EntitySourceType.StoredProcedure) { + return undefined; + } + + const actionSet = new Set(actions); + const normalized = fieldAccess + .filter((access) => actionSet.has(access.action)) + .map((access) => ({ + action: access.action, + fields: [...new Set(access.fields)], + })); + return normalized.length > 0 ? normalized : undefined; + } + + export function getEntityPermissions(entity: DabEntityConfig): EntityPermissionConfig[] { + if (entity.advancedSettings.permissions?.length) { + const byRole = new Map(); + const fieldAccessByRole = new Map< + AuthorizationRole, + EntityPermissionFieldAccess[] | undefined + >(); + 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), + ); + fieldAccessByRole.set(permission.role, permission.fieldAccess); + } + return supportedAuthorizationRoles.map((role) => ({ + role, + actions: byRole.get(role) ?? [], + fieldAccess: normalizePermissionFieldAccess( + fieldAccessByRole.get(role), + byRole.get(role) ?? [], + entity.sourceType, + ), + })); + } + + 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 } + : {}), + })), }; } @@ -1243,13 +1582,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 @@ -1261,12 +1604,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, + } + : {}), }, }; } @@ -1291,6 +1650,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], @@ -1301,7 +1772,7 @@ export namespace Dab { fields: cloneFields(entity.fields), parameters: cloneParameters(entity.parameters), unsupportedReasons: cloneUnsupportedReasons(entity.unsupportedReasons), - advancedSettings: { ...entity.advancedSettings }, + advancedSettings: normalizeAdvancedSettings(entity), })), }; } @@ -1387,9 +1858,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, }; } @@ -1482,7 +1957,7 @@ export namespace Dab { export function createDefaultConfigFromSources(sourceObjects: DabSourceObject[]): DabConfig { return { - apiTypes: [ApiType.Rest, ApiType.GraphQL, ApiType.Mcp], + apiTypes: [...defaultApiTypes], entities: sourceObjects.map((sourceObject) => createDefaultEntityConfigFromSource(sourceObject), ), diff --git a/extensions/mssql/src/sharedInterfaces/metadata.ts b/extensions/mssql/src/sharedInterfaces/metadata.ts index 848cfb214d..85a9802b99 100644 --- a/extensions/mssql/src/sharedInterfaces/metadata.ts +++ b/extensions/mssql/src/sharedInterfaces/metadata.ts @@ -54,6 +54,26 @@ export interface ColumnMetadata { isTrustworthyForUniqueness?: boolean; } +export interface DabDatabaseObjectMetadata { + id: string; + schema: string; + name: string; +} + +export interface DabViewColumnMetadata { + id: string; + name: string; + dataType: string; + isPrimaryKey: boolean; + ordinal: number; +} + +export interface DabStoredProcedureParameterMetadata { + name: string; + dataType: string; + ordinal: number; +} + /** * Represents information about a database. */ diff --git a/extensions/mssql/src/webviews/common/locConstants.ts b/extensions/mssql/src/webviews/common/locConstants.ts index 73344b1f92..d9cb86039e 100644 --- a/extensions/mssql/src/webviews/common/locConstants.ts +++ b/extensions/mssql/src/webviews/common/locConstants.ts @@ -1436,6 +1436,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.", @@ -1447,6 +1451,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.", @@ -1461,6 +1466,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", @@ -1486,6 +1498,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) { @@ -1512,6 +1528,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"), @@ -1558,6 +1575,7 @@ export class LocConstants { makeReadOnly: l10n.t("Make everything read-only"), enableAllCruds: l10n.t("Enable all CRUD operations"), includeAllColumns: l10n.t("Include all columns"), + customizeColumnAccess: l10n.t("Customize column access"), entityNameDescription: l10n.t("Entity name used in API routes"), viewConfig: l10n.t("View Config"), deploy: l10n.t("Deploy"), @@ -1565,6 +1583,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.", @@ -1588,9 +1609,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"), @@ -1626,6 +1664,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 dab9bbda68..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; @@ -60,6 +61,7 @@ export const DabProvider: React.FC = ({ children }) => { const currentFilteredTables = useSchemaDesignerSelector((s) => s?.currentFilteredTables) ?? []; const [dabConfig, setDabConfig] = useState(null); + const [dabSourceObjects, setDabSourceObjects] = useState([]); const [dabTextFilter, setDabTextFilter] = useState(""); const [dabConfigTextFileContent, setDabConfigTextFileContent] = useState(""); const [dabDeploymentState, setDabDeploymentState] = useState( @@ -68,6 +70,7 @@ export const DabProvider: React.FC = ({ children }) => { const dabConfigRef = useRef(dabConfig); const extractSchemaRef = useRef<() => ReturnType>(extractSchema); + const dabSourceObjectsRef = useRef(dabSourceObjects); useEffect(() => { dabConfigRef.current = dabConfig; @@ -77,16 +80,22 @@ export const DabProvider: React.FC = ({ children }) => { extractSchemaRef.current = extractSchema; }, [extractSchema]); + useEffect(() => { + dabSourceObjectsRef.current = dabSourceObjects; + }, [dabSourceObjects]); + useEffect(() => { registerSchemaDesignerDabToolHandlers({ extensionRpc, isInitializedRef, waitForInitialization, getCurrentDabConfig: () => dabConfigRef.current, - getCurrentSourceObjects: () => - extractSchemaRef + getCurrentSourceObjects: () => [ + ...extractSchemaRef .current() .tables.map((table) => Dab.createSourceObjectFromTable(table)), + ...dabSourceObjectsRef.current, + ], commitDabConfig: (config) => { dabConfigRef.current = config; setDabConfig(config); @@ -95,13 +104,19 @@ export const DabProvider: React.FC = ({ children }) => { }, [extensionRpc, waitForInitialization]); const initializeDabConfig = useCallback(() => { - void extensionRpc - .sendRequest(Dab.GetCachedConfigRequest.type) - .then((response) => { + void Promise.all([ + extensionRpc.sendRequest(Dab.GetCachedConfigRequest.type), + extensionRpc.sendRequest(Dab.GetDatabaseObjectsRequest.type).catch(() => ({ + sourceObjects: [], + })), + ]) + .then(([response, databaseObjects]) => { const schema = extractSchema(); - const sourceObjects = schema.tables.map((table) => - Dab.createSourceObjectFromTable(table), - ); + const sourceObjects = [ + ...schema.tables.map((table) => Dab.createSourceObjectFromTable(table)), + ...databaseObjects.sourceObjects, + ]; + setDabSourceObjects(databaseObjects.sourceObjects); const baseConfig = response.config ?? Dab.createDefaultConfigFromSources(sourceObjects); const synced = Dab.syncConfigWithSources(baseConfig, sourceObjects); @@ -120,12 +135,15 @@ export const DabProvider: React.FC = ({ children }) => { } const schema = extractSchema(); - const sourceObjects = schema.tables.map((table) => Dab.createSourceObjectFromTable(table)); + const sourceObjects = [ + ...schema.tables.map((table) => Dab.createSourceObjectFromTable(table)), + ...dabSourceObjects, + ]; const synced = Dab.syncConfigWithSources(dabConfig, sourceObjects); if (synced.changed) { setDabConfig(synced.config); } - }, [dabConfig, extractSchema]); + }, [dabConfig, dabSourceObjects, extractSchema]); const updateDabApiTypes = useCallback((apiTypes: Dab.ApiType[]) => { setDabConfig((prev) => { @@ -154,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, + } + : {}), + }, }; }), }; @@ -186,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) { @@ -244,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) { @@ -506,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 bcf6d4350c..8ca4eb7e39 100644 --- a/extensions/mssql/src/webviews/pages/SchemaDesigner/dab/dabEntityFilters.ts +++ b/extensions/mssql/src/webviews/pages/SchemaDesigner/dab/dabEntityFilters.ts @@ -12,14 +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 { @@ -27,27 +39,30 @@ export function getDabSchemaFilterKey(schemaName: string): string { } export function getDabEntityFilterCount(filters: DabEntityFilters): number { - return (filters.status === DabEntityStatusFilter.All ? 0 : 1) + filters.schemas.length; -} - -export function isDabTableEntity(entity: Dab.DabEntityConfig): boolean { - return (entity.sourceType ?? Dab.EntitySourceType.Table) === Dab.EntitySourceType.Table; + return ( + (filters.status === DabEntityStatusFilter.All ? 0 : 1) + + filters.schemas.length + + filters.sourceTypes.length + + filters.apiTypes.length + + filters.authTypes.length + ); } export function doesEntityMatchDabFilters( entity: Dab.DabEntityConfig, filters: DabEntityFilters, ): boolean { - if (!isDabTableEntity(entity)) { + if (filters.status === DabEntityStatusFilter.Enabled && !Dab.isEntityExposed(entity)) { return false; } - if (filters.status === DabEntityStatusFilter.Enabled && !entity.isEnabled) { + if (filters.status === DabEntityStatusFilter.Disabled && Dab.isEntityExposed(entity)) { return false; } - if (filters.status === DabEntityStatusFilter.Disabled && entity.isEnabled) { - return false; - } - if (filters.status === DabEntityStatusFilter.Warnings && entity.isSupported) { + if ( + filters.status === DabEntityStatusFilter.Warnings && + !Dab.hasBlockingUnsupportedReason(entity) && + !Dab.hasFixableKeyWarning(entity) + ) { return false; } if ( @@ -57,6 +72,46 @@ export function doesEntityMatchDabFilters( return false; } + const sourceType = entity.sourceType ?? Dab.EntitySourceType.Table; + 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; } diff --git a/extensions/mssql/src/webviews/pages/SchemaDesigner/dab/dabEntitySettingsDialog.tsx b/extensions/mssql/src/webviews/pages/SchemaDesigner/dab/dabEntitySettingsDialog.tsx index 291ddc712e..1c3b652430 100644 --- a/extensions/mssql/src/webviews/pages/SchemaDesigner/dab/dabEntitySettingsDialog.tsx +++ b/extensions/mssql/src/webviews/pages/SchemaDesigner/dab/dabEntitySettingsDialog.tsx @@ -11,46 +11,60 @@ 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: "900px", maxWidth: "calc(100vw - 32px)", backgroundColor: "var(--vscode-editor-background)", + display: "flex", + flexDirection: "column", }, 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", + flex: 1, + minHeight: 0, + height: "100%", + overflow: "hidden", backgroundColor: "var(--vscode-editor-background)", - paddingTop: "16px", - paddingBottom: "16px", + padding: 0, + boxSizing: "border-box", + }, + settingsLayout: { + display: "grid", + gridTemplateColumns: "150px minmax(0, 1fr)", + columnGap: "18px", + alignItems: "start", + height: "100%", + minHeight: 0, + padding: "0 18px 18px 0", + boxSizing: "border-box", }, headerTitleContent: { display: "flex", @@ -77,6 +91,29 @@ const useStyles = makeStyles({ color: tokens.colorNeutralForeground3, flexShrink: 0, }, + tabs: { + zIndex: 3, + alignSelf: "start", + height: "100%", + minHeight: 0, + padding: "12px 8px", + overflow: "hidden", + backgroundColor: "var(--vscode-editorWidget-background, var(--vscode-editor-background))", + borderRight: `1px solid ${tokens.colorNeutralStroke2}`, + }, + tabPanel: { + display: "flex", + flexDirection: "column", + rowGap: "22px", + minWidth: 0, + height: "100%", + minHeight: 0, + overflowY: "auto", + scrollPaddingBottom: "96px", + paddingTop: "18px", + paddingBottom: "96px", + boxSizing: "border-box", + }, section: { display: "flex", flexDirection: "column", @@ -87,21 +124,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, @@ -112,63 +149,180 @@ const useStyles = makeStyles({ lineHeight: tokens.lineHeightBase200, color: tokens.colorNeutralForeground2, }, - labelWithInfo: { - display: "inline-flex", - alignItems: "center", - columnGap: "4px", + fieldHint: { + color: tokens.colorNeutralForeground4, + fontSize: tokens.fontSizeBase200, + lineHeight: tokens.lineHeightBase200, }, - infoButton: { - color: tokens.colorNeutralForeground3, - minWidth: "16px", - width: "16px", - height: "16px", - padding: 0, - verticalAlign: "middle", + roleCard: { + display: "flex", + flexDirection: "column", + rowGap: "8px", + padding: "10px 12px", + borderRadius: "4px", + border: `1px solid ${tokens.colorNeutralStroke2}`, + backgroundColor: "var(--vscode-editorWidget-background, transparent)", }, - roleButtonsContainer: { + roleHeader: { display: "flex", - gap: "8px", + alignItems: "center", + justifyContent: "space-between", + columnGap: "8px", }, - roleButton: { - flex: 1, + actionRow: { + display: "flex", + flexWrap: "wrap", + gap: "8px 12px", + paddingLeft: "24px", + }, + permissionGrid: { display: "flex", flexDirection: "column", + maxHeight: "220px", + overflowY: "auto", + border: `1px solid ${tokens.colorNeutralStroke2}`, + borderRadius: "4px", + marginLeft: "24px", + }, + permissionGridHeader: { + display: "grid", + gridTemplateColumns: "minmax(140px, 1fr) repeat(4, 72px)", + position: "sticky", + top: 0, + zIndex: 1, + backgroundColor: "var(--vscode-editorWidget-background, var(--vscode-editor-background))", + borderBottom: `1px solid ${tokens.colorNeutralStroke2}`, + color: tokens.colorNeutralForeground3, + fontSize: tokens.fontSizeBase200, + fontWeight: tokens.fontWeightSemibold, + }, + permissionGridRow: { + display: "grid", + gridTemplateColumns: "minmax(140px, 1fr) repeat(4, 72px)", + position: "absolute", + left: 0, + right: 0, + top: 0, alignItems: "center", - justifyContent: "center", - padding: "12px", - minHeight: "60px", - whiteSpace: "normal", + minHeight: "34px", + borderBottom: `1px solid ${tokens.colorNeutralStroke2}`, }, - roleButtonLabel: { - fontWeight: 600, - lineHeight: "18px", + permissionGridBody: { + position: "relative", + width: "100%", }, - roleButtonLabelSelected: { - color: tokens.colorNeutralForegroundOnBrand, + permissionGridCell: { + minWidth: 0, + padding: "5px 8px", }, - roleButtonLabelUnselected: { + permissionGridNameCell: { + fontFamily: tokens.fontFamilyMonospace, color: tokens.colorNeutralForeground1, + overflow: "hidden", + textOverflow: "ellipsis", + whiteSpace: "nowrap", }, - roleButtonContent: { + columnAccessSummary: { display: "flex", - flexDirection: "column", alignItems: "center", - gap: "4px", - textAlign: "center", + justifyContent: "space-between", + gap: "8px", + paddingLeft: "24px", }, - roleButtonDescription: { - fontSize: "11px", - lineHeight: "14px", + methodGroup: { + display: "flex", + flexWrap: "wrap", + gap: "10px", }, - roleButtonDescriptionSelected: { - color: tokens.colorNeutralForegroundOnBrand, + metadataTable: { + width: "100%", + borderCollapse: "collapse", + fontSize: tokens.fontSizeBase200, }, - roleButtonDescriptionUnselected: { - color: tokens.colorNeutralForeground2, + metadataViewport: { + maxHeight: "300px", + overflowY: "auto", + border: `1px solid ${tokens.colorNeutralStroke2}`, + borderRadius: "4px", + boxSizing: "border-box", }, - sourceText: { - fontSize: "12px", + 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, + }, + metadataGridBody: { + position: "relative", + width: "100%", + }, + metadataGridRow: { + display: "grid", + position: "absolute", + left: 0, + right: 0, + top: 0, + alignItems: "center", + borderBottom: `1px solid ${tokens.colorNeutralStroke2}`, + fontSize: tokens.fontSizeBase200, + }, + columnMetadataGrid: { + gridTemplateColumns: + "56px minmax(140px, 1fr) 120px minmax(120px, 1fr) minmax(140px, 1.4fr)", + columnGap: "8px", + }, + parameterMetadataGrid: { + gridTemplateColumns: + "minmax(160px, 1fr) 120px 88px minmax(120px, 1fr) minmax(160px, 1.4fr)", + columnGap: "8px", + }, + metadataGridCell: { + minWidth: 0, + padding: "6px 8px", + outline: "none", + "&:focus-visible": { + outline: "1px solid var(--vscode-focusBorder)", + outlineOffset: "-1px", + }, + }, + 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", @@ -182,139 +336,556 @@ 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 = 5; +const PARAMETER_METADATA_GRID_COLUMN_COUNT = 5; +const TABLE_PERMISSION_ACTIONS = [ + Dab.EntityAction.Create, + Dab.EntityAction.Read, + Dab.EntityAction.Update, + Dab.EntityAction.Delete, +] as const; + +const SETTINGS_TABS: DabSettingsTab[] = [ + "identity", + "permissions", + "rest", + "graphql", + "mcp", + "schema", +]; + 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; } +interface PermissionColumnAccessGridProps { + classes: ReturnType; + role: Dab.AuthorizationRole; + permission: Dab.EntityPermissionConfig; + actions: Dab.EntityAction[]; + columns: Dab.DabColumnConfig[]; + entity: Dab.DabEntityConfig; + getPermissionActionFields: ( + permission: Dab.EntityPermissionConfig, + action: Dab.EntityAction, + ) => string[]; + onChange: ( + role: Dab.AuthorizationRole, + column: Dab.DabColumnConfig, + action: Dab.EntityAction, + enabled: boolean, + ) => void; +} + +function PermissionColumnAccessGrid({ + classes, + role, + permission, + actions, + columns, + entity, + getPermissionActionFields, + onChange, +}: PermissionColumnAccessGridProps) { + const scrollRef = useRef(null); + const virtualizer = useVirtualizer({ + count: columns.length, + getScrollElement: () => scrollRef.current, + estimateSize: () => 34, + overscan: 8, + }); + + return ( +
+
+
+ {locConstants.schemaDesigner.columnName} +
+ {TABLE_PERMISSION_ACTIONS.map((action) => ( +
+ {getActionLabel(action)} +
+ ))} +
+
+ {virtualizer.getVirtualItems().map((virtualRow) => { + const column = columns[virtualRow.index]; + const isLogicalKey = Dab.isLogicalKeyColumn(entity, column); + return ( +
+
+ {column.name} +
+ {TABLE_PERMISSION_ACTIONS.map((action) => { + const actionEnabled = actions.includes(action); + const checked = + actionEnabled && + (isLogicalKey || + getPermissionActionFields(permission, action).some( + (field) => + Dab.normalizeDabIdentifier(field) === + Dab.normalizeDabIdentifier(column.name), + )); + return ( +
+ + onChange( + role, + column, + action, + data.checked === true, + ) + } + aria-label={`${role} ${action} ${column.name}`} + /> +
+ ); + })} +
+ ); + })} +
+
+ ); +} + +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], + fieldAccess: permission.fieldAccess?.map((access) => ({ + action: access.action, + fields: [...access.fields], + })), + })), + 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 [activeTab, setActiveTab] = useState("identity"); + const [expandedColumnAccessRoles, setExpandedColumnAccessRoles] = useState< + Set + >(new Set()); + const drawerBodyRef = 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(() => { + const drawerBody = drawerBodyRef.current; + const section = getSectionElement(value); + if (!drawerBody || !section) { + return; + } - const updateCustomGraphQLSingularType = (value: string) => { - setLocalSettings((prev) => ({ - ...prev, - customGraphQLType: undefined, - customGraphQLSingularType: value || undefined, - })); - }; + drawerBody.scrollTo({ + top: Math.max(0, section.offsetTop - 12), + 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) => { + setActiveTab(value); + scrollToSelectedTab(value); }; - const updateStoredProcedureRestMethod = (method: Dab.RestMethod) => { - setLocalSettings((prev) => ({ - ...prev, - storedProcedureRestMethods: [method], - })); - }; + useEffect(() => { + if (open) { + const editingEntity = cloneEntityForEditing(entity); + setLocalEntity(editingEntity); + setExpandedColumnAccessRoles( + new Set( + Dab.getEntityPermissions(editingEntity) + .filter((permission) => permission.fieldAccess?.length) + .map((permission) => permission.role), + ), + ); + const tab = initialTab ?? "identity"; + setActiveTab(tab); + scrollToSelectedTab(tab, "auto"); + } + }, [entity, initialTab, open]); + + useEffect(() => { + const drawerBody = drawerBodyRef.current; + if (!open || !drawerBody) { + return; + } + + const handleScroll = () => { + let currentTab: DabSettingsTab = "identity"; + for (const tab of SETTINGS_TABS) { + const section = getSectionElement(tab); + if (section && section.offsetTop - 48 <= drawerBody.scrollTop) { + currentTab = tab; + } + } + setActiveTab(currentTab); + }; - const updateStoredProcedureGraphQLOperation = (operation: Dab.GraphQLOperation) => { - setLocalSettings((prev) => ({ ...prev, storedProcedureGraphQLOperation: operation })); + handleScroll(); + drawerBody.addEventListener("scroll", handleScroll, { passive: true }); + return () => drawerBody.removeEventListener("scroll", handleScroll); + }, [open]); + + 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 updateExposeAsMcpCustomTool = (value: boolean) => { - setLocalSettings((prev) => ({ ...prev, exposeAsMcpCustomTool: value })); + 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 updateMcpDmlToolsEnabled = (value: boolean) => { - setLocalSettings((prev) => ({ ...prev, mcpDmlToolsEnabled: 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 mcpDmlToolsEnabled = localSettings.mcpDmlToolsEnabled !== 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." @@ -336,34 +907,254 @@ 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], + fieldAccess: permission.fieldAccess?.map((access) => ({ + action: access.action, + fields: [...access.fields], + })), + })), + }, + }; + }); + }; + + 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) + : [], + fieldAccess: enabled ? permission.fieldAccess : undefined, + } + : 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, + fieldAccess: enabled + ? permission.fieldAccess + : permission.fieldAccess?.filter((access) => access.action !== action), + }; + }); + updatePermissions(updatedPermissions); + }; + + const getPermissionActionFields = ( + permission: Dab.EntityPermissionConfig, + action: Dab.EntityAction, + ): string[] => { + const explicitFields = permission.fieldAccess?.find( + (access) => access.action === action, + )?.fields; + return explicitFields ?? localEntity.columns.map((column) => column.name); + }; + + const updateRoleColumnAction = ( + role: Dab.AuthorizationRole, + column: Dab.DabColumnConfig, + action: Dab.EntityAction, + enabled: boolean, + ) => { + setLocalEntity((prev) => { + const updatedPermissions = Dab.getEntityPermissions(prev).map((permission) => { + if (permission.role !== role) { + return permission; + } + + const allColumnNames = prev.columns.map((c) => c.name); + const currentFields = + permission.fieldAccess?.find((access) => access.action === action)?.fields ?? + allColumnNames; + const nextFields = enabled + ? [...new Set([...currentFields, column.name])] + : currentFields.filter( + (field) => + Dab.normalizeDabIdentifier(field) !== + Dab.normalizeDabIdentifier(column.name), + ); + const existingFieldAccess = + permission.fieldAccess?.filter((access) => access.action !== action) ?? []; + const fieldAccess = + nextFields.length === allColumnNames.length + ? existingFieldAccess + : [...existingFieldAccess, { action, fields: nextFields }]; + + return { + ...permission, + fieldAccess: fieldAccess.length > 0 ? fieldAccess : undefined, + }; + }); + + 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], + fieldAccess: permission.fieldAccess?.map((access) => ({ + action: access.action, + fields: [...access.fields], + })), + })), + }, + }; + }); + }; + + 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 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: @@ -378,19 +1169,10 @@ export function DabEntitySettingsDialog({ {title} ); - const renderLabelWithInfo = (label: string, infoText: string) => ( - - {label} - - + + {enabled && ( +
+ {allowedActions.map((action) => ( + + updateRoleAction(role, action, data.checked === true) + } + /> + ))} +
+ )} + {showColumnAccess && ( + <> +
+ {roleColumnAccessLabel} + +
+ {isColumnAccessExpanded && ( + + )} + + )} + + ); + }; + + 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.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 ( +
+
+ + 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], + fieldAccess: permission.fieldAccess?.map((access) => ({ + action: access.action, + fields: [...access.fields], + })), + })); + 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} - /> - {isEntityRestEnabled && ( +
+ 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} + + +
+
+ {renderSectionTitle(locConstants.schemaDesigner.identity)} +
+ + + updateAdvancedSettings({ entityName: data.value }) + } + /> + + +