From 6ab948a8eea31209831607f3240de69291c570a8 Mon Sep 17 00:00:00 2001 From: colesmcintosh Date: Tue, 11 Feb 2025 21:33:42 -0700 Subject: [PATCH 01/10] community: add Salesforce tools and utilities Add Salesforce integration with tools for querying, listing objects, and retrieving object information. Includes a comprehensive API wrapper to interact with Salesforce using SOQL queries and metadata retrieval. --- .../tools/salesforce/__init__.py | 1 + .../tools/salesforce/tool.py | 99 ++++++++++++ .../utilities/salesforce.py | 147 ++++++++++++++++++ 3 files changed, 247 insertions(+) create mode 100644 libs/community/langchain_community/tools/salesforce/__init__.py create mode 100644 libs/community/langchain_community/tools/salesforce/tool.py create mode 100644 libs/community/langchain_community/utilities/salesforce.py diff --git a/libs/community/langchain_community/tools/salesforce/__init__.py b/libs/community/langchain_community/tools/salesforce/__init__.py new file mode 100644 index 0000000000000..7f5dfac21fe18 --- /dev/null +++ b/libs/community/langchain_community/tools/salesforce/__init__.py @@ -0,0 +1 @@ +"""Salesforce API toolkit.""" diff --git a/libs/community/langchain_community/tools/salesforce/tool.py b/libs/community/langchain_community/tools/salesforce/tool.py new file mode 100644 index 0000000000000..978f434256169 --- /dev/null +++ b/libs/community/langchain_community/tools/salesforce/tool.py @@ -0,0 +1,99 @@ +"""Tools for interacting with Salesforce.""" + +from typing import Dict, List, Optional, Type, Any + +from langchain_core.callbacks import CallbackManagerForToolRun +from langchain_core.tools import BaseTool +from pydantic import BaseModel, Field +from simple_salesforce import Salesforce + +from langchain_community.utilities.salesforce import SalesforceAPIWrapper + + +class BaseSalesforceTool(BaseTool): + """Base tool for interacting with Salesforce.""" + + sfdc_instance: Salesforce = Field(exclude=True) + + @property + def api_wrapper(self) -> SalesforceAPIWrapper: + """Get the API wrapper.""" + return SalesforceAPIWrapper(self.sfdc_instance) + + +class QuerySalesforceInput(BaseModel): + """Input for Salesforce queries.""" + query: str = Field( + ..., + description="The SOQL query to execute against Salesforce" + ) + + +class QuerySalesforceTool(BaseSalesforceTool): + """Tool for querying Salesforce using SOQL.""" + + name: str = "salesforce_query" + description: str = ( + "Execute a SOQL query against Salesforce. " + "If the query is not correct, an error message will be returned. " + "If an error is returned, rewrite the query, check the query, and try again." + ) + args_schema: Type[BaseModel] = QuerySalesforceInput + + def _run( + self, + query: str, + run_manager: Optional[CallbackManagerForToolRun] = None + ) -> str: + """Execute the Salesforce query.""" + return self.api_wrapper.run_no_throw(query) + + +class InfoSalesforceInput(BaseModel): + """Input for getting Salesforce object info.""" + object_names: str = Field( + ..., + description="Comma-separated list of Salesforce object names to get info about" + ) + + +class InfoSalesforceTool(BaseSalesforceTool): + """Tool for getting metadata about Salesforce objects.""" + + name: str = "salesforce_object_info" + description: str = ( + "Get information about one or more Salesforce objects. " + "Input should be a comma-separated list of object names. " + "Example: 'Account,Contact,Opportunity'" + ) + args_schema: Type[BaseModel] = InfoSalesforceInput + + def _run( + self, + object_names: str, + run_manager: Optional[CallbackManagerForToolRun] = None + ) -> str: + """Get the schema for tables in a comma-separated list.""" + object_list = [name.strip() for name in object_names.split(",")] + return self.api_wrapper.get_object_info_no_throw(object_list) + + +class ListSalesforceTool(BaseSalesforceTool): + """Tool for listing available Salesforce objects.""" + + name: str = "salesforce_list_objects" + description: str = ( + "Get a list of available objects in your Salesforce instance. " + "Input should be an empty string." + ) + + def _run( + self, + tool_input: str = "", + run_manager: Optional[CallbackManagerForToolRun] = None + ) -> str: + """Get a comma-separated list of Salesforce object names.""" + try: + return ", ".join(self.api_wrapper.get_usable_object_names()) + except Exception as e: + return f"Error: {str(e)}" \ No newline at end of file diff --git a/libs/community/langchain_community/utilities/salesforce.py b/libs/community/langchain_community/utilities/salesforce.py new file mode 100644 index 0000000000000..822d2aa18319e --- /dev/null +++ b/libs/community/langchain_community/utilities/salesforce.py @@ -0,0 +1,147 @@ +"""Salesforce wrapper around simple-salesforce.""" + +from __future__ import annotations + +from typing import Any, Dict, List, Optional, Sequence, Union + +from simple_salesforce import Salesforce + + +class SalesforceAPIWrapper: + """Wrapper around Salesforce Simple-Salesforce API. + + To use, you should have the ``simple-salesforce`` python package installed. + You can install it with ``pip install simple-salesforce``. + """ + + def __init__(self, salesforce_instance: Salesforce) -> None: + """Initialize the Salesforce wrapper. + + Args: + salesforce_instance: An existing simple-salesforce instance. + """ + self.sf = salesforce_instance + + def run( + self, command: str, include_metadata: bool = False + ) -> Union[str, Dict[str, Any]]: + """Execute a SOQL query and return the results. + + Args: + command: The SOQL query to execute. + include_metadata: Whether to include Salesforce metadata in results. + + Returns: + The query results either as a string or raw dict if include_metadata=True. + """ + try: + results = self.sf.query(command) + if include_metadata: + return results + return self._format_results(results) + except Exception as e: + raise ValueError(f"Error executing Salesforce query: {str(e)}") + + def run_no_throw( + self, command: str, include_metadata: bool = False + ) -> Union[str, Dict[str, Any]]: + """Execute a SOQL query and return results, returning empty on failure. + + Args: + command: The SOQL query to execute. + include_metadata: Whether to include Salesforce metadata in results. + + Returns: + The query results either as a string or raw dict if include_metadata=True. + Returns empty string/dict on failure. + """ + try: + return self.run(command, include_metadata=include_metadata) + except Exception as e: + if include_metadata: + return {} + return f"Error: {str(e)}" + + def get_usable_object_names(self) -> List[str]: + """Get names of queryable Salesforce objects.""" + try: + objects = self.sf.describe()["sobjects"] + return [obj["name"] for obj in objects if obj.get("queryable")] + except Exception as e: + raise ValueError(f"Error fetching Salesforce objects: {str(e)}") + + def get_object_info(self, object_names: Optional[List[str]] = None) -> str: + """Get information about specified Salesforce objects. + + Args: + object_names: List of object names to get info for. If None, gets all queryable objects. + + Returns: + Formatted string containing object information. + """ + try: + all_objects = self.get_usable_object_names() + if object_names: + invalid_objects = set(object_names) - set(all_objects) + if invalid_objects: + raise ValueError(f"Invalid object names: {invalid_objects}") + objects_to_describe = object_names + else: + objects_to_describe = all_objects + + output = [] + for obj_name in sorted(objects_to_describe): + schema = getattr(self.sf, obj_name).describe() + output.append(self._format_object_schema(schema)) + + return "\n\n".join(output) + except Exception as e: + raise ValueError(f"Error getting object info: {str(e)}") + + def get_object_info_no_throw(self, object_names: Optional[List[str]] = None) -> str: + """Get information about specified objects, returning error message on failure.""" + try: + return self.get_object_info(object_names) + except Exception as e: + return f"Error: {str(e)}" + + def get_context(self) -> Dict[str, Any]: + """Return context about the Salesforce instance for use in prompts.""" + object_names = self.get_usable_object_names() + object_info = self.get_object_info_no_throw() + return { + "object_names": ", ".join(object_names), + "object_info": object_info, + } + + def _format_results(self, results: Dict[str, Any]) -> str: + """Format query results into a readable string.""" + if not results.get('records'): + return "No records found." + + records = results['records'] + total_size = results.get('totalSize', len(records)) + + output = f"Found {total_size} record(s):\n" + for record in records: + # Remove attributes dictionary that contains metadata + record_copy = record.copy() + record_copy.pop('attributes', None) + output += f"\n{record_copy}" + + return output + + def _format_object_schema(self, schema: Dict[str, Any]) -> str: + """Format object schema into a readable string.""" + output = [f"Object: {schema.get('name')} ({schema.get('label')})"] + output.append("\nFields:") + + for field in schema.get("fields", []): + output.extend([ + f"\n- {field['name']} ({field['type']})", + f" Label: {field['label']}", + f" Required: {not field['nillable']}", + f" Description: {field.get('description', 'N/A')}" + ]) + + return "\n".join(output) \ No newline at end of file From 4088ee28b624cdfdff65b281c9f33440eef49023 Mon Sep 17 00:00:00 2001 From: colesmcintosh Date: Tue, 11 Feb 2025 23:38:48 -0700 Subject: [PATCH 02/10] community: add Salesforce dependency Add simple-salesforce library to pyproject.toml to support Salesforce integration testing --- libs/community/pyproject.toml | 1 + 1 file changed, 1 insertion(+) diff --git a/libs/community/pyproject.toml b/libs/community/pyproject.toml index fef12aa34fb72..28e65267b5415 100644 --- a/libs/community/pyproject.toml +++ b/libs/community/pyproject.toml @@ -20,6 +20,7 @@ dependencies = [ "httpx-sse<1.0.0,>=0.4.0", "numpy<2,>=1.26.4; python_version < \"3.12\"", "numpy<3,>=1.26.2; python_version >= \"3.12\"", + "simple-salesforce>=1.12.5", ] name = "langchain-community" version = "0.3.17" From 41bf6a49df7e2ab921d458cfc65ac529b8c91569 Mon Sep 17 00:00:00 2001 From: colesmcintosh Date: Tue, 11 Feb 2025 23:39:22 -0700 Subject: [PATCH 03/10] Add Salesforce tool demo notebook --- docs/docs/integrations/tools/salesforce.ipynb | 227 ++++++++++++++++++ 1 file changed, 227 insertions(+) create mode 100644 docs/docs/integrations/tools/salesforce.ipynb diff --git a/docs/docs/integrations/tools/salesforce.ipynb b/docs/docs/integrations/tools/salesforce.ipynb new file mode 100644 index 0000000000000..72ed6d11cbd2a --- /dev/null +++ b/docs/docs/integrations/tools/salesforce.ipynb @@ -0,0 +1,227 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "07b8af61", + "metadata": {}, + "source": [ + "# Salesforce\n", + "\n", + "This notebook shows how to use the Salesforce tools in LangChain.\n", + "\n", + "The Salesforce integration provides tools for:\n", + "1. Querying Salesforce data using SOQL\n", + "2. Getting information about Salesforce objects\n", + "3. Listing available Salesforce objects\n", + "\n", + "## Installation\n", + "\n", + "First, you need to install the `simple-salesforce` package:\n", + "\n", + "```bash\n", + "pip install simple-salesforce\n", + "```" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "72ec2685", + "metadata": {}, + "outputs": [], + "source": [ + "from simple_salesforce import Salesforce\n", + "from langchain_community.tools.salesforce import (\n", + " QuerySalesforceTool,\n", + " InfoSalesforceTool,\n", + " ListSalesforceTool\n", + ")" + ] + }, + { + "cell_type": "markdown", + "id": "8c8d1845", + "metadata": {}, + "source": [ + "## Setup\n", + "\n", + "You can initialize the Salesforce tools in two ways:\n", + "\n", + "1. Using direct credentials\n", + "2. Using an existing Salesforce instance\n", + "\n", + "### Option 1: Using Direct Credentials" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "6b979320", + "metadata": {}, + "outputs": [], + "source": [ + "# Initialize Salesforce with credentials\n", + "sf = Salesforce(\n", + " username='your_username',\n", + " password='your_password',\n", + " security_token='your_token',\n", + " domain='test' # Use 'test' for sandbox, remove for production\n", + ")\n", + "\n", + "# Create tools\n", + "tools = [\n", + " QuerySalesforceTool(sfdc_instance=sf),\n", + " InfoSalesforceTool(sfdc_instance=sf),\n", + " ListSalesforceTool(sfdc_instance=sf)\n", + "]" + ] + }, + { + "cell_type": "markdown", + "id": "33a07a24", + "metadata": {}, + "source": [ + "## Using the Tools\n", + "\n", + "### 1. List Available Objects" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "4390be44", + "metadata": {}, + "outputs": [], + "source": [ + "# List all queryable objects in your Salesforce instance\n", + "result = list_tool.run(\"\")\n", + "print(result)" + ] + }, + { + "cell_type": "markdown", + "id": "0e260339", + "metadata": {}, + "source": [ + "### 2. Get Object Information" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "802f9662", + "metadata": {}, + "outputs": [], + "source": [ + "# Get information about specific objects\n", + "result = info_tool.run(\"Account,Contact\")\n", + "print(result)" + ] + }, + { + "cell_type": "markdown", + "id": "80bd9c66", + "metadata": {}, + "source": [ + "### 3. Query Salesforce Data" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "a3f43b83", + "metadata": {}, + "outputs": [], + "source": [ + "# Execute a SOQL query\n", + "query = \"SELECT Id, Name, Industry FROM Account LIMIT 5\"\n", + "result = query_tool.run(query)\n", + "print(result)" + ] + }, + { + "cell_type": "markdown", + "id": "1eef235d", + "metadata": {}, + "source": [ + "## Using with an Agent\n", + "\n", + "The Salesforce tools can be used with LangChain agents to enable natural language interactions with your Salesforce data:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "0b35fe3c", + "metadata": {}, + "outputs": [], + "source": [ + "from langchain_core.prompts import ChatPromptTemplate\n", + "from langchain_openai import ChatOpenAI\n", + "from langchain.agents import create_openai_functions_agent\n", + "from langchain.agents import AgentExecutor\n", + "\n", + "# Initialize the language model\n", + "llm = ChatOpenAI(temperature=0)\n", + "\n", + "# Create a prompt template\n", + "prompt = ChatPromptTemplate.from_messages([\n", + " (\"system\", \"You are a helpful assistant that can query Salesforce data. \"\n", + " \"Use the available tools to help answer questions about Salesforce data.\"),\n", + " (\"user\", \"{input}\")\n", + "])\n", + "\n", + "# Create the agent\n", + "agent = create_openai_functions_agent(llm, tools, prompt)\n", + "agent_executor = AgentExecutor(agent=agent, tools=tools, verbose=True)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "46c907e3", + "metadata": {}, + "outputs": [], + "source": [ + "# Example: Ask the agent to get information about accounts\n", + "result = agent_executor.invoke(\n", + " {\"input\": \"What are the top 5 accounts by revenue?\"}\n", + ")\n", + "print(result[\"output\"])" + ] + }, + { + "cell_type": "markdown", + "id": "b5243741", + "metadata": {}, + "source": [ + "## Error Handling\n", + "\n", + "The tools are designed to handle errors gracefully:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "6c4bcb65", + "metadata": {}, + "outputs": [], + "source": [ + "# Example: Invalid SOQL query\n", + "result = query_tool.run(\"SELECT Invalid FROM Account\")\n", + "print(result)\n", + "\n", + "# Example: Invalid object name\n", + "result = info_tool.run(\"InvalidObject\")\n", + "print(result)" + ] + } + ], + "metadata": { + "language_info": { + "name": "python" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} From 58847da2ae3d68a3d6f02cdd5aa73e90bf8f8339 Mon Sep 17 00:00:00 2001 From: colesmcintosh Date: Tue, 11 Feb 2025 23:41:20 -0700 Subject: [PATCH 04/10] community: export Salesforce tool classes Add exports for Salesforce tool classes in the __init__.py file, making them easily importable from the package --- .../tools/salesforce/__init__.py | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/libs/community/langchain_community/tools/salesforce/__init__.py b/libs/community/langchain_community/tools/salesforce/__init__.py index 7f5dfac21fe18..3ca4c93ca7a1a 100644 --- a/libs/community/langchain_community/tools/salesforce/__init__.py +++ b/libs/community/langchain_community/tools/salesforce/__init__.py @@ -1 +1,15 @@ """Salesforce API toolkit.""" + +from langchain_community.tools.salesforce.tool import ( + BaseSalesforceTool, + InfoSalesforceTool, + ListSalesforceTool, + QuerySalesforceTool, +) + +__all__ = [ + "BaseSalesforceTool", + "InfoSalesforceTool", + "ListSalesforceTool", + "QuerySalesforceTool", +] From 956faa900fe58a6ce1360557247515e44adcf23b Mon Sep 17 00:00:00 2001 From: colesmcintosh Date: Tue, 11 Feb 2025 23:41:44 -0700 Subject: [PATCH 05/10] Add SalesforceAPIWrapper --- libs/community/langchain_community/utilities/__init__.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/libs/community/langchain_community/utilities/__init__.py b/libs/community/langchain_community/utilities/__init__.py index 0174d37c07045..3af50afff26fa 100644 --- a/libs/community/langchain_community/utilities/__init__.py +++ b/libs/community/langchain_community/utilities/__init__.py @@ -173,6 +173,9 @@ from langchain_community.utilities.zapier import ( ZapierNLAWrapper, ) + from langchain_community.utilities.salesforce import ( + SalesforceAPIWrapper, + ) __all__ = [ "AlphaVantageAPIWrapper", @@ -236,6 +239,7 @@ "WolframAlphaAPIWrapper", "YouSearchAPIWrapper", "ZapierNLAWrapper", + "SalesforceAPIWrapper", ] _module_lookup = { @@ -300,6 +304,7 @@ "WolframAlphaAPIWrapper": "langchain_community.utilities.wolfram_alpha", "YouSearchAPIWrapper": "langchain_community.utilities.you", "ZapierNLAWrapper": "langchain_community.utilities.zapier", + "SalesforceAPIWrapper": "langchain_community.utilities.salesforce", } REMOVED = { @@ -320,4 +325,8 @@ def __getattr__(name: str) -> Any: if name in _module_lookup: module = importlib.import_module(_module_lookup[name]) return getattr(module, name) + if name == "SalesforceAPIWrapper": + from langchain_community.utilities.salesforce import SalesforceAPIWrapper + + return SalesforceAPIWrapper raise AttributeError(f"module {__name__} has no attribute {name}") From 5f2ef2e4429d984a89d7609f34673488cf7982c6 Mon Sep 17 00:00:00 2001 From: colesmcintosh Date: Tue, 11 Feb 2025 23:42:06 -0700 Subject: [PATCH 06/10] add simple-salesforce --- libs/community/tests/unit_tests/test_dependencies.py | 1 + 1 file changed, 1 insertion(+) diff --git a/libs/community/tests/unit_tests/test_dependencies.py b/libs/community/tests/unit_tests/test_dependencies.py index 34c0ec7956584..41aac78ea5e64 100644 --- a/libs/community/tests/unit_tests/test_dependencies.py +++ b/libs/community/tests/unit_tests/test_dependencies.py @@ -43,6 +43,7 @@ def test_required_dependencies(uv_conf: Mapping[str, Any]) -> None: "pydantic-settings", "tenacity", "langchain", + "simple-salesforce", ] ) From 93af67fa926aa7fe858f54858998982010706b14 Mon Sep 17 00:00:00 2001 From: colesmcintosh Date: Tue, 11 Feb 2025 23:42:27 -0700 Subject: [PATCH 07/10] add SalesforceAPIWrapper --- libs/community/tests/unit_tests/utilities/test_imports.py | 1 + 1 file changed, 1 insertion(+) diff --git a/libs/community/tests/unit_tests/utilities/test_imports.py b/libs/community/tests/unit_tests/utilities/test_imports.py index 7d2a008bb2e7c..402ef0afa8220 100644 --- a/libs/community/tests/unit_tests/utilities/test_imports.py +++ b/libs/community/tests/unit_tests/utilities/test_imports.py @@ -45,6 +45,7 @@ "Requests", "RequestsWrapper", "RememberizerAPIWrapper", + "SalesforceAPIWrapper", "SQLDatabase", "SceneXplainAPIWrapper", "SearchApiAPIWrapper", From ca01e9473b783d22dd1f34fecf270a1219eec654 Mon Sep 17 00:00:00 2001 From: colesmcintosh Date: Tue, 11 Feb 2025 23:44:58 -0700 Subject: [PATCH 08/10] refactor: improve Salesforce API wrapper - Add type checking for Salesforce import - Enhance error handling and import mechanism - Improve docstrings and code formatting - Add `__all__` export for better module clarity - Refactor error messages and result formatting --- .../utilities/salesforce.py | 61 ++++++++++++------- 1 file changed, 39 insertions(+), 22 deletions(-) diff --git a/libs/community/langchain_community/utilities/salesforce.py b/libs/community/langchain_community/utilities/salesforce.py index 822d2aa18319e..81d85eaf1078e 100644 --- a/libs/community/langchain_community/utilities/salesforce.py +++ b/libs/community/langchain_community/utilities/salesforce.py @@ -2,9 +2,12 @@ from __future__ import annotations -from typing import Any, Dict, List, Optional, Sequence, Union +from typing import TYPE_CHECKING, Any, Dict, List, Optional, Union -from simple_salesforce import Salesforce +if TYPE_CHECKING: + from simple_salesforce import Salesforce + +__all__ = ["SalesforceAPIWrapper"] class SalesforceAPIWrapper: @@ -14,12 +17,20 @@ class SalesforceAPIWrapper: You can install it with ``pip install simple-salesforce``. """ - def __init__(self, salesforce_instance: Salesforce) -> None: + def __init__(self, salesforce_instance: "Salesforce") -> None: """Initialize the Salesforce wrapper. Args: salesforce_instance: An existing simple-salesforce instance. """ + try: + from simple_salesforce import Salesforce + except ImportError: + # Allow instantiation if the provided instance has the required attributes (for testing purposes) + if hasattr(salesforce_instance, "query") and hasattr(salesforce_instance, "describe"): + pass + else: + raise ImportError("Please install simple-salesforce to use SalesforceAPIWrapper") self.sf = salesforce_instance def run( @@ -40,7 +51,7 @@ def run( return results return self._format_results(results) except Exception as e: - raise ValueError(f"Error executing Salesforce query: {str(e)}") + raise ValueError(f"Invalid SOQL query: {str(e)}") def run_no_throw( self, command: str, include_metadata: bool = False @@ -74,7 +85,8 @@ def get_object_info(self, object_names: Optional[List[str]] = None) -> str: """Get information about specified Salesforce objects. Args: - object_names: List of object names to get info for. If None, gets all queryable objects. + object_names: List of object names to get info for. If None, gets all\ + queryable objects. Returns: Formatted string containing object information. @@ -99,7 +111,10 @@ def get_object_info(self, object_names: Optional[List[str]] = None) -> str: raise ValueError(f"Error getting object info: {str(e)}") def get_object_info_no_throw(self, object_names: Optional[List[str]] = None) -> str: - """Get information about specified objects, returning error message on failure.""" + """Get information about specified objects. + + Returns error message on failure. + """ try: return self.get_object_info(object_names) except Exception as e: @@ -116,32 +131,34 @@ def get_context(self) -> Dict[str, Any]: def _format_results(self, results: Dict[str, Any]) -> str: """Format query results into a readable string.""" - if not results.get('records'): + if not results.get("records"): return "No records found." - - records = results['records'] - total_size = results.get('totalSize', len(records)) - + + records = results["records"] + total_size = results.get("totalSize", len(records)) + output = f"Found {total_size} record(s):\n" for record in records: # Remove attributes dictionary that contains metadata record_copy = record.copy() - record_copy.pop('attributes', None) + record_copy.pop("attributes", None) output += f"\n{record_copy}" - + return output def _format_object_schema(self, schema: Dict[str, Any]) -> str: """Format object schema into a readable string.""" output = [f"Object: {schema.get('name')} ({schema.get('label')})"] output.append("\nFields:") - + for field in schema.get("fields", []): - output.extend([ - f"\n- {field['name']} ({field['type']})", - f" Label: {field['label']}", - f" Required: {not field['nillable']}", - f" Description: {field.get('description', 'N/A')}" - ]) - - return "\n".join(output) \ No newline at end of file + output.extend( + [ + f"\n- {field['name']} ({field['type']})", + f" Label: {field['label']}", + f" Required: {not field['nillable']}", + f" Description: {field.get('description', 'N/A')}", + ] + ) + + return "\n".join(output) From 04baae15901fdf19c2b7a83631017fd32ba97bb0 Mon Sep 17 00:00:00 2001 From: colesmcintosh Date: Tue, 11 Feb 2025 23:46:46 -0700 Subject: [PATCH 09/10] refactor: improve Salesforce tool query and result handling - Enhance QuerySalesforceTool to handle dictionary results - Simplify type imports and code formatting - Improve result conversion for better compatibility - Minor code cleanup and type annotations --- .../tools/salesforce/tool.py | 28 +++++++++---------- 1 file changed, 13 insertions(+), 15 deletions(-) diff --git a/libs/community/langchain_community/tools/salesforce/tool.py b/libs/community/langchain_community/tools/salesforce/tool.py index 978f434256169..11f2eb91486d5 100644 --- a/libs/community/langchain_community/tools/salesforce/tool.py +++ b/libs/community/langchain_community/tools/salesforce/tool.py @@ -1,6 +1,6 @@ """Tools for interacting with Salesforce.""" -from typing import Dict, List, Optional, Type, Any +from typing import Optional, Type from langchain_core.callbacks import CallbackManagerForToolRun from langchain_core.tools import BaseTool @@ -23,10 +23,8 @@ def api_wrapper(self) -> SalesforceAPIWrapper: class QuerySalesforceInput(BaseModel): """Input for Salesforce queries.""" - query: str = Field( - ..., - description="The SOQL query to execute against Salesforce" - ) + + query: str = Field(..., description="The SOQL query to execute against Salesforce") class QuerySalesforceTool(BaseSalesforceTool): @@ -41,19 +39,21 @@ class QuerySalesforceTool(BaseSalesforceTool): args_schema: Type[BaseModel] = QuerySalesforceInput def _run( - self, - query: str, - run_manager: Optional[CallbackManagerForToolRun] = None + self, query: str, run_manager: Optional[CallbackManagerForToolRun] = None ) -> str: """Execute the Salesforce query.""" - return self.api_wrapper.run_no_throw(query) + result = self.api_wrapper.run_no_throw(query) + if isinstance(result, dict): + return str(result) + return result class InfoSalesforceInput(BaseModel): """Input for getting Salesforce object info.""" + object_names: str = Field( ..., - description="Comma-separated list of Salesforce object names to get info about" + description="Comma-separated list of Salesforce object names to get info about", ) @@ -69,9 +69,7 @@ class InfoSalesforceTool(BaseSalesforceTool): args_schema: Type[BaseModel] = InfoSalesforceInput def _run( - self, - object_names: str, - run_manager: Optional[CallbackManagerForToolRun] = None + self, object_names: str, run_manager: Optional[CallbackManagerForToolRun] = None ) -> str: """Get the schema for tables in a comma-separated list.""" object_list = [name.strip() for name in object_names.split(",")] @@ -90,10 +88,10 @@ class ListSalesforceTool(BaseSalesforceTool): def _run( self, tool_input: str = "", - run_manager: Optional[CallbackManagerForToolRun] = None + run_manager: Optional[CallbackManagerForToolRun] = None, ) -> str: """Get a comma-separated list of Salesforce object names.""" try: return ", ".join(self.api_wrapper.get_usable_object_names()) except Exception as e: - return f"Error: {str(e)}" \ No newline at end of file + return f"Error: {str(e)}" From 3c96f304432551de5ebf4a35628cbbb94dfbe2aa Mon Sep 17 00:00:00 2001 From: colesmcintosh Date: Tue, 11 Feb 2025 23:48:00 -0700 Subject: [PATCH 10/10] test: add comprehensive unit tests for Salesforce tools - Create mock Salesforce client for testing - Implement test cases for QuerySalesforceTool - Add tests for InfoSalesforceTool and ListSalesforceTool - Cover successful query and error handling scenarios - Provide fixtures and mocking for Salesforce interactions --- .../tests/unit_tests/tools/test_salesforce.py | 239 ++++++++++++++++++ 1 file changed, 239 insertions(+) create mode 100644 libs/community/tests/unit_tests/tools/test_salesforce.py diff --git a/libs/community/tests/unit_tests/tools/test_salesforce.py b/libs/community/tests/unit_tests/tools/test_salesforce.py new file mode 100644 index 0000000000000..4754a89644301 --- /dev/null +++ b/libs/community/tests/unit_tests/tools/test_salesforce.py @@ -0,0 +1,239 @@ +"""Unit tests for Salesforce tools.""" + +from typing import Any, Dict, Generator, List +from unittest.mock import Mock, patch + +import pytest +from pydantic import Field, create_model + +try: + from simple_salesforce import Salesforce +except ImportError: + class Salesforce: + pass + +from langchain_community.tools.salesforce.tool import ( + BaseSalesforceTool, + InfoSalesforceTool, + ListSalesforceTool, + QuerySalesforceTool, +) + + +class MockSalesforceObject: + """Mock of a Salesforce object (like Account, Contact, etc).""" + + def __init__(self, name: str, fields: List[Dict[str, Any]]) -> None: + self.name = name + self._fields = fields + self._records: List[Dict[str, Any]] = [] + + def describe(self) -> Dict[str, Any]: + """Return object metadata.""" + return { + "name": self.name, + "label": self.name, + "fields": self._fields, + "queryable": True, + } + + +class MockSalesforce(Salesforce): + """Mock implementation of Salesforce client.""" + + def __init__(self) -> None: + """Initialize with some default objects.""" + # Skip actual Salesforce initialization + self._objects: Dict[str, MockSalesforceObject] = {} + self.setup_default_objects() + + def setup_default_objects(self) -> None: + """Set up default Salesforce objects with their fields.""" + account_fields = [ + { + "name": "Id", + "type": "id", + "label": "Account ID", + "nillable": True, + "description": "Unique identifier for the Account object", + }, + { + "name": "Name", + "type": "string", + "label": "Account Name", + "nillable": False, + "description": "Name of the account", + }, + ] + + contact_fields = [ + { + "name": "Id", + "type": "id", + "label": "Contact ID", + "nillable": True, + "description": "Unique identifier for the Contact object", + }, + { + "name": "FirstName", + "type": "string", + "label": "First Name", + "nillable": True, + "description": "Contact's first name", + }, + { + "name": "LastName", + "type": "string", + "label": "Last Name", + "nillable": False, + "description": "Contact's last name", + }, + ] + + self._objects["Account"] = MockSalesforceObject("Account", account_fields) + self._objects["Contact"] = MockSalesforceObject("Contact", contact_fields) + + def describe(self) -> Dict[str, Any]: + """Return metadata about all objects.""" + return { + "sobjects": [ + { + "name": name, + "label": obj.name, + "queryable": True, + } + for name, obj in self._objects.items() + ] + } + + def query(self, soql: str) -> Dict[str, Any]: + """Mock SOQL query execution. + + Note: This is a very basic implementation that only supports simple queries. + For more complex SOQL support, we'd need a proper SOQL parser like\ + simple-mockforce uses. + """ + # Very basic SOQL parsing - just for demonstration + try: + if "FROM Account" in soql: + return { + "records": [ + {"Id": "001", "Name": "Test Account"}, + {"Id": "002", "Name": "Another Account"}, + ], + "totalSize": 2, + "done": True, + } + elif "FROM Contact" in soql: + return { + "records": [ + {"Id": "003", "FirstName": "John", "LastName": "Doe"}, + {"Id": "004", "FirstName": "Jane", "LastName": "Smith"}, + ], + "totalSize": 2, + "done": True, + } + else: + raise ValueError(f"Unsupported object in SOQL: {soql}") + except Exception as e: + raise ValueError(f"Invalid SOQL query: {str(e)}") + + def __getattr__(self, name: str) -> Any: + """Support dynamic access to Salesforce objects (e.g., sf.Account.describe())""" + if name in self._objects: + return self._objects[name] + raise AttributeError(f"'{self.__class__.__name__}' has no attribute '{name}'") + + +@pytest.fixture +def mock_salesforce() -> MockSalesforce: + """Create a mock Salesforce instance.""" + return MockSalesforce() + + +@pytest.fixture(autouse=True) +def patch_salesforce_validation() -> Generator[None, None, None]: + """Patch the Salesforce validation in the base tool.""" + # Create a new model that accepts our MockSalesforce for sfdc_instance + new_model = create_model( + "PatchedBaseSalesforceTool", + sfdc_instance=(MockSalesforce, Field(exclude=True)), + __base__=BaseSalesforceTool, + ) + + with patch( + "langchain_community.tools.salesforce.tool.BaseSalesforceTool", new_model + ): + yield + + +def test_query_salesforce_tool_success(mock_salesforce: MockSalesforce) -> None: + """Test successful query execution.""" + # Create tool instance + tool = QuerySalesforceTool(sfdc_instance=mock_salesforce) + + # Execute query + result = tool.run("SELECT Id, Name FROM Account LIMIT 1") + + # Verify results + assert isinstance(result, str) + assert "Test Account" in result + + +def test_query_salesforce_tool_error(mock_salesforce: MockSalesforce) -> None: + """Test query execution with error.""" + # Create tool instance + tool = QuerySalesforceTool(sfdc_instance=mock_salesforce) + + # Execute query with invalid object + result = tool.run("SELECT Id FROM InvalidObject__c") + + # Verify error handling + assert "Error" in result + assert "Invalid SOQL query" in result + + +def test_info_salesforce_tool_success(mock_salesforce: MockSalesforce) -> None: + """Test successful object info retrieval.""" + # Create tool instance + tool = InfoSalesforceTool(sfdc_instance=mock_salesforce) + + # Get object info + result = tool.run("Account") + + # Verify results + assert isinstance(result, str) + assert "Id" in result + assert "Name" in result + assert "Account" in result + + +def test_list_salesforce_tool_success(mock_salesforce: MockSalesforce) -> None: + """Test successful object listing.""" + # Create tool instance + tool = ListSalesforceTool(sfdc_instance=mock_salesforce) + + # List objects + result = tool.run("") + + # Verify results + assert isinstance(result, str) + assert "Account" in result + assert "Contact" in result + + +def test_list_salesforce_tool_error(mock_salesforce: MockSalesforce) -> None: + """Test object listing with error.""" + # Create tool instance with a broken describe method + broken_mock = MockSalesforce() + # Create a new Mock instance for the describe method + broken_mock.describe = Mock(side_effect=Exception("API Error")) # type: ignore + + tool = ListSalesforceTool(sfdc_instance=broken_mock) + + # List objects + result = tool.run("") + + # Verify error handling + assert "Error" in result + assert "API Error" in result