diff --git a/docs/en_US/developer_tools.rst b/docs/en_US/developer_tools.rst index cc9cd1347bf..a61dc11c0c6 100644 --- a/docs/en_US/developer_tools.rst +++ b/docs/en_US/developer_tools.rst @@ -18,3 +18,4 @@ PL/SQL code. erd_tool psql_tool ai_tools + expl_tensor diff --git a/docs/en_US/expl_tensor.rst b/docs/en_US/expl_tensor.rst new file mode 100644 index 00000000000..0be8c61983f --- /dev/null +++ b/docs/en_US/expl_tensor.rst @@ -0,0 +1,165 @@ +.. _expl_tensor: + +*************************** +`Explain Tensor`:index: +*************************** + +**Explain Tensor** is a powerful module integrated into pgAdmin 4 that enables advanced analysis of query execution plans and beautification of SQL code. This tool helps developers and DBAs understand how PostgreSQL executes queries, identify performance bottlenecks, and optimize database workloads effectively. + +Key Features: + + * Visual representation of query execution plans with detailed node information. + * SQL formatting and beautification capabilities for improved readability. + * Integration with pgAdmin’s browser interface for seamless workflow. + +**Requirements:** + +Before using the Explain Tensor module, ensure the following: + + 1. You are connected to a PostgreSQL server with sufficient privileges to execute commands. + + 2. Ensure Explain Tensor are enabled in the server configuration (set ``EXPLAIN_TENSOR_ENABLED`` to ``True`` in ``config.py``). + + 3. Configure Explain Tensor in :ref:`Preferences → Explain Tensor `. + +**Note:** + + * Using Explain Tensor requires an active internet connection. + + * When analyzing query plans via Explain Tensor, the plan and query are sent to a third-party service + (by default: https://explain.tensor.ru). + + +Configuring Explain Tensor +****************************** + +To configure Explain Tensor, navigate to *File → Preferences → Explain Tensor*. + +.. image:: images/preferences_expl_tensor.png + :alt: Explain Tensor preferences + :align: center + +1. Set the **Explain Plan** switch to *True*. + +2. Enter the *Explain Tensor API* URL (default: https://explain.tensor.ru). + +3. Set the **Format SQL** switch to *True* if you want to use the SQL formatting capability. + +4. Set the **Private Plans** switch to *True* if you want to store plans in your personal archive. + +After configuring, click *Save* to apply the changes. + + +Using Explain Tensor +************************* + +To analyze a query plan: + +1. Open the **Query Tool** in pgAdmin. + +2. Enter your SQL query (e.g., ``SELECT * FROM pg_stat_activity``). + +3. Select the ``Buffers`` and ``Timing`` options from the dropdown menu next to the **Explain Analyze** button in the toolbar. + +4. Click the **Explain Analyze** button in the toolbar (or press ``Shift+F7``) to generate the execution plan. + + Upon successful generation, the *Explain Tensor* panel will appear. + + .. image:: images/expl_tensor_analyze.png + :alt: Example of Explain Tensor output + :align: center + + +Understanding Execution Plans +***************************** + +Each node in the execution plan represents a step in query processing. The Explain Tensor module displays: + + * **Plan Tree** – A simplified view of the execution algorithm. Numeric indicators are displayed separately and highlighted with colors indicating load intensity. + + Hover over nodes for tooltips with extended information. Nodes are color-coded based on performance impact: + + * Green – Low cost + * Yellow – Medium cost + * Red – High cost (potential bottleneck) + + .. image:: images/expl_tensor_plantree.png + :alt: Example of Explain Tensor plan tree + :align: center + + * **Diagram** – Shows real dependencies between nodes and resource flows. + + .. image:: images/expl_tensor_diagram.png + :alt: Example of Explain Tensor diagram + :align: center + + * **Schema** – Visualizes database tables and their relationships. + + .. image:: images/expl_tensor_schema.png + :alt: Example of Explain Tensor schema + :align: center + + * **Statistics** – Summary statistics allow you to analyze large plans in aggregated form, sorted by any metric such as execution time, disk reads, cache usage, or filtered rows. + + .. image:: images/expl_tensor_stats.png + :alt: Example of Explain Tensor statistics + :align: center + + * **Pie Chart** – Helps quickly identify dominant nodes and their approximate share of resource consumption. + + .. image:: images/expl_tensor_piechart.png + :alt: Example of Explain Tensor pie chart + :align: center + + * **Tiled Visualization** – Allows compact evaluation of node connections in large plans and highlights problematic sections. + + .. image:: images/expl_tensor_tilemap.png + :alt: Example of Explain Tensor tiled visualization + :align: center + + * **Smart Recommendations** – Automatically generated based on structural and resource metrics, these provide precise guidance on resolving performance issues. + + .. image:: images/expl_tensor_recs.png + :alt: Example of Explain Tensor recommendations + :align: center + + * **Personal Archive** – Contains all the plans you've analyzed, giving you instant access regardless of whether they were published publicly. + + .. image:: images/expl_tensor_personal.png + :alt: Example of Explain Tensor personal archive + :align: center + + +Formatting SQL Code +******************* + +The **Format SQL** feature automatically indents and aligns SQL statements for better clarity. + +To format the SQL query, use the *Edit* → *Format SQL* button (or press ``Ctrl+K``). + +Example input: + +.. code-block:: sql + + SELECT u.name, p.title FROM users u JOIN posts p ON u.id=p.user_id WHERE u.active=true ORDER BY p.created_at DESC; + +Formatted output: + +.. code-block:: sql + + SELECT + u.name + , p.title + FROM + users u + JOIN + posts p + ON u.id = p.user_id + WHERE + u.active = TRUE + ORDER BY + p.created_at DESC; + + +This makes complex queries easier to read and debug. + diff --git a/docs/en_US/images/expl_tensor_analyze.png b/docs/en_US/images/expl_tensor_analyze.png new file mode 100644 index 00000000000..6acf5f65a61 Binary files /dev/null and b/docs/en_US/images/expl_tensor_analyze.png differ diff --git a/docs/en_US/images/expl_tensor_diagram.png b/docs/en_US/images/expl_tensor_diagram.png new file mode 100644 index 00000000000..0334c17bbed Binary files /dev/null and b/docs/en_US/images/expl_tensor_diagram.png differ diff --git a/docs/en_US/images/expl_tensor_personal.png b/docs/en_US/images/expl_tensor_personal.png new file mode 100644 index 00000000000..dd1c5f60089 Binary files /dev/null and b/docs/en_US/images/expl_tensor_personal.png differ diff --git a/docs/en_US/images/expl_tensor_piechart.png b/docs/en_US/images/expl_tensor_piechart.png new file mode 100644 index 00000000000..ddbe8fc12f3 Binary files /dev/null and b/docs/en_US/images/expl_tensor_piechart.png differ diff --git a/docs/en_US/images/expl_tensor_plantree.png b/docs/en_US/images/expl_tensor_plantree.png new file mode 100644 index 00000000000..4881013ac96 Binary files /dev/null and b/docs/en_US/images/expl_tensor_plantree.png differ diff --git a/docs/en_US/images/expl_tensor_recs.png b/docs/en_US/images/expl_tensor_recs.png new file mode 100644 index 00000000000..612d53435da Binary files /dev/null and b/docs/en_US/images/expl_tensor_recs.png differ diff --git a/docs/en_US/images/expl_tensor_schema.png b/docs/en_US/images/expl_tensor_schema.png new file mode 100644 index 00000000000..96156fbce36 Binary files /dev/null and b/docs/en_US/images/expl_tensor_schema.png differ diff --git a/docs/en_US/images/expl_tensor_stats.png b/docs/en_US/images/expl_tensor_stats.png new file mode 100644 index 00000000000..9aac2cd226f Binary files /dev/null and b/docs/en_US/images/expl_tensor_stats.png differ diff --git a/docs/en_US/images/expl_tensor_tilemap.png b/docs/en_US/images/expl_tensor_tilemap.png new file mode 100644 index 00000000000..9e9cae7d40c Binary files /dev/null and b/docs/en_US/images/expl_tensor_tilemap.png differ diff --git a/docs/en_US/images/preferences_expl_tensor.png b/docs/en_US/images/preferences_expl_tensor.png new file mode 100644 index 00000000000..2620a757d76 Binary files /dev/null and b/docs/en_US/images/preferences_expl_tensor.png differ diff --git a/docs/en_US/preferences.rst b/docs/en_US/preferences.rst index 0fcf6413bd5..0eeb679dd85 100644 --- a/docs/en_US/preferences.rst +++ b/docs/en_US/preferences.rst @@ -775,6 +775,27 @@ Use the fields on the *Options* panel to specify storage preferences. * When the *Show hidden files and folders?* switch is set to *True*, the file manager will display hidden files and folders. +.. _the-explain-tensor-node: + +The Explain Tensor Node +*************************** + +Use the preferences found in the *Explain Tensor* node of the tree control to configure the custom Explain Analyze module. + +.. image:: images/preferences_expl_tensor.png + :alt: Explain Tensor preferences + :align: center + +**Note:** Explain Tensor must be enabled in the server configuration (``EXPLAIN_TENSOR_ENABLED = True`` in ``config.py``) for these preferences to be available. + +* When the **Explain Plan** switch is set to *True*, the **Explain Analyze** command will use a third-party service to analyze the execution plan. + +* Use the **Explain Tensor API** field to specify the API URL. + +* When the **Format SQL** switch is set to *True*, the **Format SQL** command will use a third-party service to format the SQL code. + +* When the **Private Plans** switch is set to *True*, all analyzed plans will be stored in your personal archive on the third-party service. + Using 'setup.py' command line script #################################### diff --git a/web/config.py b/web/config.py index a814d75e6f1..dde90e604be 100644 --- a/web/config.py +++ b/web/config.py @@ -1050,6 +1050,16 @@ # Users can override this in their preferences. MAX_LLM_TOOL_ITERATIONS = 20 +########################################################################## +# Explain Tensor Settings +########################################################################## + +# Master switch to enable/disable Eplain Tensor features entirely. +# When False, all Explain Tensor features are disabled and cannot be enabled +# by users through preferences. When True, users can configure Explain Tensor +# settings in preferences. +EXPLAIN_TENSOR_ENABLED = False + ############################################################################# # Patch the default config with custom config and other manipulations ############################################################################# diff --git a/web/pgadmin/messages.pot b/web/pgadmin/messages.pot index 87f057708b1..3879ed89839 100644 --- a/web/pgadmin/messages.pot +++ b/web/pgadmin/messages.pot @@ -8,14 +8,14 @@ msgid "" msgstr "" "Project-Id-Version: PROJECT VERSION\n" "Report-Msgid-Bugs-To: EMAIL@ADDRESS\n" -"POT-Creation-Date: 2026-03-26 17:05+0530\n" +"POT-Creation-Date: 2026-04-09 15:08+0300\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=utf-8\n" "Content-Transfer-Encoding: 8bit\n" -"Generated-By: Babel 2.17.0\n" +"Generated-By: Babel 2.18.0\n" #: pgadmin/__init__.py:350 pgadmin/authenticate/internal.py:26 msgid "Incorrect username or password." @@ -482,7 +482,7 @@ msgstr "" msgid "Nodes" msgstr "" -#: pgadmin/browser/__init__.py:766 +#: pgadmin/browser/__init__.py:769 #, python-brace-format msgid "" "The master password could not be retrieved from the MASTER_PASSWORD_HOOK " @@ -490,26 +490,26 @@ msgid "" "correctly." msgstr "" -#: pgadmin/browser/__init__.py:792 +#: pgadmin/browser/__init__.py:795 msgid "Incorrect master password" msgstr "" -#: pgadmin/browser/__init__.py:838 +#: pgadmin/browser/__init__.py:841 msgid "Master password cannot be empty" msgstr "" -#: pgadmin/browser/__init__.py:922 +#: pgadmin/browser/__init__.py:925 msgid "pgAdmin user password changed successfully" msgstr "" -#: pgadmin/browser/__init__.py:969 +#: pgadmin/browser/__init__.py:972 #, python-brace-format msgid "" "Your account is authenticated using an external {} source. Please contact" " the administrators of this service if you need to reset your password." msgstr "" -#: pgadmin/browser/__init__.py:1081 +#: pgadmin/browser/__init__.py:1084 msgid "" "You successfully reset your password but your account is locked. Please " "contact the Administrator." @@ -890,6 +890,7 @@ msgid "Toggle comment" msgstr "" #: pgadmin/browser/register_editor_preferences.py:109 +#: pgadmin/tools/expl_tensor/__init__.py:70 #: pgadmin/tools/sqleditor/static/js/components/sections/MainToolBar.jsx:631 msgid "Format SQL" msgstr "" @@ -1147,9 +1148,9 @@ msgstr "" #: pgadmin/browser/utils.py:445 pgadmin/static/js/utils.js:434 #: pgadmin/tools/backup/__init__.py:587 #: pgadmin/tools/grant_wizard/__init__.py:101 -#: pgadmin/tools/sqleditor/__init__.py:929 -#: pgadmin/tools/sqleditor/__init__.py:1103 -#: pgadmin/tools/sqleditor/__init__.py:1549 pgadmin/utils/exception.py:37 +#: pgadmin/tools/sqleditor/__init__.py:936 +#: pgadmin/tools/sqleditor/__init__.py:1110 +#: pgadmin/tools/sqleditor/__init__.py:1556 pgadmin/utils/exception.py:37 msgid "Connection to the server has been lost." msgstr "" @@ -1309,7 +1310,7 @@ msgstr "" #: pgadmin/browser/server_groups/servers/databases/schemas/tables/compound_triggers/__init__.py:510 #: pgadmin/browser/server_groups/servers/databases/schemas/tables/constraints/check_constraint/__init__.py:485 #: pgadmin/browser/server_groups/servers/databases/schemas/tables/constraints/index_constraint/__init__.py:550 -#: pgadmin/browser/server_groups/servers/databases/schemas/tables/indexes/__init__.py:557 +#: pgadmin/browser/server_groups/servers/databases/schemas/tables/indexes/__init__.py:556 #: pgadmin/browser/server_groups/servers/databases/schemas/tables/row_security_policies/__init__.py:379 #: pgadmin/browser/server_groups/servers/databases/schemas/tables/rules/__init__.py:342 #: pgadmin/browser/server_groups/servers/databases/schemas/tables/triggers/__init__.py:598 @@ -1345,7 +1346,7 @@ msgstr "" #: pgadmin/browser/server_groups/servers/__init__.py:1671 #: pgadmin/tools/schema_diff/__init__.py:374 -#: pgadmin/tools/sqleditor/__init__.py:2658 +#: pgadmin/tools/sqleditor/__init__.py:2665 msgid "Server connected." msgstr "" @@ -1524,7 +1525,7 @@ msgstr "" #: pgadmin/browser/server_groups/servers/databases/schemas/tables/constraints/exclusion_constraint/__init__.py:702 #: pgadmin/browser/server_groups/servers/databases/schemas/tables/constraints/foreign_key/__init__.py:767 #: pgadmin/browser/server_groups/servers/databases/schemas/tables/constraints/index_constraint/__init__.py:755 -#: pgadmin/browser/server_groups/servers/databases/schemas/tables/indexes/__init__.py:737 +#: pgadmin/browser/server_groups/servers/databases/schemas/tables/indexes/__init__.py:735 #: pgadmin/browser/server_groups/servers/databases/schemas/tables/partitions/__init__.py:773 #: pgadmin/browser/server_groups/servers/databases/schemas/tables/row_security_policies/__init__.py:509 #: pgadmin/browser/server_groups/servers/databases/schemas/tables/rules/__init__.py:441 @@ -5630,11 +5631,11 @@ msgstr "" msgid "Indexes" msgstr "" -#: pgadmin/browser/server_groups/servers/databases/schemas/tables/indexes/__init__.py:553 +#: pgadmin/browser/server_groups/servers/databases/schemas/tables/indexes/__init__.py:552 msgid "You must provide one or more column to create index." msgstr "" -#: pgadmin/browser/server_groups/servers/databases/schemas/tables/indexes/__init__.py:757 +#: pgadmin/browser/server_groups/servers/databases/schemas/tables/indexes/__init__.py:755 msgid "Index is dropped" msgstr "" @@ -10649,8 +10650,8 @@ msgstr "" #: pgadmin/static/js/components/ReactCodeMirror/components/FindDialog.jsx:181 #: pgadmin/static/js/components/ReactCodeMirror/components/GotoDialog.jsx:83 #: pgadmin/static/js/helpers/Layout/index.jsx:68 -#: pgadmin/static/js/helpers/Layout/index.jsx:412 -#: pgadmin/static/js/helpers/Layout/index.jsx:511 +#: pgadmin/static/js/helpers/Layout/index.jsx:437 +#: pgadmin/static/js/helpers/Layout/index.jsx:536 #: pgadmin/static/js/helpers/ModalProvider.jsx:334 msgid "Close" msgstr "" @@ -11034,6 +11035,10 @@ msgid "Activity" msgstr "" #: pgadmin/dashboard/static/js/Dashboard.jsx:319 +#: pgadmin/tools/expl_tensor/__init__.py:43 +#: pgadmin/tools/expl_tensor/__init__.py:51 +#: pgadmin/tools/expl_tensor/__init__.py:62 +#: pgadmin/tools/expl_tensor/__init__.py:71 msgid "Configuration" msgstr "" @@ -12630,7 +12635,9 @@ msgstr "" #: pgadmin/misc/cloud/static/js/azure.js:34 #: pgadmin/misc/cloud/static/js/google.js:35 #: pgadmin/static/js/SchemaView/SchemaState/SchemaState.js:158 +#: pgadmin/static/js/components/ReactCodeMirror/index.jsx:234 #: pgadmin/static/js/tree/ObjectExplorer/ObjectExplorerFilter.jsx:58 +#: pgadmin/tools/expl_tensor/static/js/index.jsx:93 #: pgadmin/tools/user_management/static/js/Users.jsx:261 msgid "Loading..." msgstr "" @@ -13508,7 +13515,7 @@ msgid "New Folder" msgstr "" #: pgadmin/misc/file_manager/static/js/components/FileManager.jsx:822 -#: pgadmin/static/js/helpers/Layout/index.jsx:536 +#: pgadmin/static/js/helpers/Layout/index.jsx:561 msgid "Rename" msgstr "" @@ -13572,7 +13579,7 @@ msgstr "" msgid "The master password is not set." msgstr "" -#: pgadmin/misc/workspaces/__init__.py:53 pgadmin/tools/__init__.py:87 +#: pgadmin/misc/workspaces/__init__.py:53 pgadmin/tools/__init__.py:90 #: pgadmin/tools/schema_diff/__init__.py:117 #: pgadmin/tools/sqleditor/__init__.py:182 msgid "This URL cannot be requested directly." @@ -14446,6 +14453,7 @@ msgid "Heap Blocks" msgstr "" #: pgadmin/static/js/Explain/index.jsx:513 +#: pgadmin/tools/expl_tensor/static/js/index.jsx:29 msgid "" "Use the Explain/Explain Analyze button to generate the plan for a query. " "Alternatively, you can also execute \"EXPLAIN (FORMAT JSON) [QUERY]\"." @@ -14713,12 +14721,12 @@ msgstr "" msgid "Accesskey" msgstr "" -#: pgadmin/static/js/components/ReactCodeMirror/index.jsx:51 +#: pgadmin/static/js/components/ReactCodeMirror/index.jsx:55 #: pgadmin/tools/sqleditor/static/js/components/sections/QueryHistory.jsx:316 msgid "Copied!" msgstr "" -#: pgadmin/static/js/components/ReactCodeMirror/index.jsx:51 +#: pgadmin/static/js/components/ReactCodeMirror/index.jsx:55 #: pgadmin/tools/sqleditor/static/js/components/sections/QueryHistory.jsx:308 #: pgadmin/tools/sqleditor/static/js/components/sections/QueryHistory.jsx:311 #: pgadmin/tools/sqleditor/static/js/components/sections/ResultSetToolbar.jsx:418 @@ -14767,21 +14775,21 @@ msgstr "" msgid "Clear" msgstr "" -#: pgadmin/static/js/helpers/Layout/index.jsx:442 +#: pgadmin/static/js/helpers/Layout/index.jsx:467 msgid "Maximise" msgstr "" -#: pgadmin/static/js/helpers/Layout/index.jsx:445 +#: pgadmin/static/js/helpers/Layout/index.jsx:470 #: pgadmin/tools/restore/__init__.py:45 pgadmin/tools/restore/__init__.py:140 #: pgadmin/tools/restore/static/js/restore.js:155 msgid "Restore" msgstr "" -#: pgadmin/static/js/helpers/Layout/index.jsx:519 +#: pgadmin/static/js/helpers/Layout/index.jsx:544 msgid "Close Others" msgstr "" -#: pgadmin/static/js/helpers/Layout/index.jsx:527 +#: pgadmin/static/js/helpers/Layout/index.jsx:552 msgid "Close All" msgstr "" @@ -15910,8 +15918,8 @@ msgstr "" #: pgadmin/tools/erd/static/js/erd_tool/components/MainToolBar.jsx:113 #: pgadmin/tools/sqleditor/static/js/components/sections/MainToolBar.jsx:335 -#: pgadmin/tools/sqleditor/static/js/components/sections/ResultSet.jsx:991 -#: pgadmin/tools/sqleditor/static/js/components/sections/ResultSet.jsx:1200 +#: pgadmin/tools/sqleditor/static/js/components/sections/ResultSet.jsx:993 +#: pgadmin/tools/sqleditor/static/js/components/sections/ResultSet.jsx:1238 msgid "Unsaved changes" msgstr "" @@ -16060,6 +16068,61 @@ msgstr "" msgid "Failed to get data. Please delete this table." msgstr "" +#: pgadmin/tools/expl_tensor/__init__.py:29 +#: pgadmin/tools/sqleditor/static/js/components/sections/ResultSet.jsx:1050 +msgid "Explain Tensor" +msgstr "" + +#: pgadmin/tools/expl_tensor/__init__.py:42 +msgid "Explain Plan" +msgstr "" + +#: pgadmin/tools/expl_tensor/__init__.py:44 +msgid "Analyze query plan via Explain Tensor API" +msgstr "" + +#: pgadmin/tools/expl_tensor/__init__.py:49 +msgid "Explain Tensor API" +msgstr "" + +#: pgadmin/tools/expl_tensor/__init__.py:53 +msgid "Explain Tensor API endpoint (e.g. https://explain.tensor.ru)" +msgstr "" + +#: pgadmin/tools/expl_tensor/__init__.py:61 +msgid "Private Plans" +msgstr "" + +#: pgadmin/tools/expl_tensor/__init__.py:64 +msgid "Hide plans from public access on Explain Tensor" +msgstr "" + +#: pgadmin/tools/expl_tensor/__init__.py:72 +msgid "Format SQL using Explain Tensor API" +msgstr "" + +#: pgadmin/tools/expl_tensor/__init__.py:124 +#: pgadmin/tools/expl_tensor/__init__.py:176 +msgid "JSON payload must be an object, not null, array, or scalar value" +msgstr "" + +#: pgadmin/tools/expl_tensor/__init__.py:136 +#: pgadmin/tools/expl_tensor/__init__.py:188 +msgid "Invalid API endpoint URL. Only HTTP/HTTPS URLs are allowed." +msgstr "" + +#: pgadmin/tools/expl_tensor/__init__.py:139 +#: pgadmin/tools/expl_tensor/__init__.py:191 +msgid "" +"The provided API endpoint is not valid. Only HTTP/HTTPS URLs are " +"permitted." +msgstr "" + +#: pgadmin/tools/expl_tensor/__init__.py:150 +#: pgadmin/tools/expl_tensor/__init__.py:206 +msgid "Failed to post data to the Explain Tensor API" +msgstr "" + #: pgadmin/tools/grant_wizard/__init__.py:383 #, python-brace-format msgid "Unable to fetch the {} objects" @@ -17107,64 +17170,64 @@ msgstr "" msgid "Query tool" msgstr "" -#: pgadmin/tools/sqleditor/__init__.py:1112 +#: pgadmin/tools/sqleditor/__init__.py:1119 msgid "******* Error *******" msgstr "" -#: pgadmin/tools/sqleditor/__init__.py:1531 +#: pgadmin/tools/sqleditor/__init__.py:1538 msgid "No primary key found for this object, so unable to save records." msgstr "" -#: pgadmin/tools/sqleditor/__init__.py:1870 +#: pgadmin/tools/sqleditor/__init__.py:1877 #: pgadmin/tools/sqleditor/utils/query_tool_connection_check.py:67 #: pgadmin/tools/sqleditor/utils/start_running_query.py:115 msgid "Either transaction object or session object not found." msgstr "" -#: pgadmin/tools/sqleditor/__init__.py:2112 +#: pgadmin/tools/sqleditor/__init__.py:2119 msgid "Could not find the required parameter (query)." msgstr "" -#: pgadmin/tools/sqleditor/__init__.py:2222 +#: pgadmin/tools/sqleditor/__init__.py:2229 msgid "No active result cursor." msgstr "" -#: pgadmin/tools/sqleditor/__init__.py:2231 +#: pgadmin/tools/sqleditor/__init__.py:2238 msgid "Could not find the required parameters (rowpos, colpos)." msgstr "" -#: pgadmin/tools/sqleditor/__init__.py:2239 +#: pgadmin/tools/sqleditor/__init__.py:2246 msgid "The selected cell contains NULL." msgstr "" -#: pgadmin/tools/sqleditor/__init__.py:2853 -#: pgadmin/tools/sqleditor/__init__.py:3076 +#: pgadmin/tools/sqleditor/__init__.py:2860 +#: pgadmin/tools/sqleditor/__init__.py:3083 msgid "" "AI features are not configured. Please configure an LLM provider in " "Preferences > AI." msgstr "" -#: pgadmin/tools/sqleditor/__init__.py:2871 +#: pgadmin/tools/sqleditor/__init__.py:2878 msgid "Database connection not available." msgstr "" -#: pgadmin/tools/sqleditor/__init__.py:2883 +#: pgadmin/tools/sqleditor/__init__.py:2890 msgid "Please provide a message." msgstr "" -#: pgadmin/tools/sqleditor/__init__.py:2895 +#: pgadmin/tools/sqleditor/__init__.py:2902 msgid "Analyzing your request..." msgstr "" -#: pgadmin/tools/sqleditor/__init__.py:2937 +#: pgadmin/tools/sqleditor/__init__.py:2944 msgid "Querying the database..." msgstr "" -#: pgadmin/tools/sqleditor/__init__.py:3099 +#: pgadmin/tools/sqleditor/__init__.py:3106 msgid "Please provide an EXPLAIN plan to analyze." msgstr "" -#: pgadmin/tools/sqleditor/__init__.py:3108 +#: pgadmin/tools/sqleditor/__init__.py:3115 msgid "Analyzing query plan..." msgstr "" @@ -17254,12 +17317,12 @@ msgid "An unexpected error occurred - ensure you are logged into the application msgstr "" #: pgadmin/tools/sqleditor/static/js/components/QueryToolComponent.jsx:235 -msgid "Query History" +#: pgadmin/tools/sqleditor/static/js/components/sections/NLQChatPanel.jsx:1119 +msgid "AI Assistant" msgstr "" #: pgadmin/tools/sqleditor/static/js/components/QueryToolComponent.jsx:236 -#: pgadmin/tools/sqleditor/static/js/components/sections/NLQChatPanel.jsx:1119 -msgid "AI Assistant" +msgid "Query History" msgstr "" #: pgadmin/tools/sqleditor/static/js/components/QueryToolComponent.jsx:244 @@ -17327,27 +17390,27 @@ msgstr "" msgid "Sort/Filter options" msgstr "" -#: pgadmin/tools/sqleditor/static/js/components/QueryToolConstants.js:100 +#: pgadmin/tools/sqleditor/static/js/components/QueryToolConstants.js:101 #: pgadmin/tools/sqleditor/utils/constant_definition.py:28 msgid "The session is idle and there is no current transaction." msgstr "" -#: pgadmin/tools/sqleditor/static/js/components/QueryToolConstants.js:101 +#: pgadmin/tools/sqleditor/static/js/components/QueryToolConstants.js:102 #: pgadmin/tools/sqleditor/utils/constant_definition.py:29 msgid "A command is currently in progress." msgstr "" -#: pgadmin/tools/sqleditor/static/js/components/QueryToolConstants.js:102 +#: pgadmin/tools/sqleditor/static/js/components/QueryToolConstants.js:103 #: pgadmin/tools/sqleditor/utils/constant_definition.py:30 msgid "The session is idle in a valid transaction block." msgstr "" -#: pgadmin/tools/sqleditor/static/js/components/QueryToolConstants.js:103 +#: pgadmin/tools/sqleditor/static/js/components/QueryToolConstants.js:104 #: pgadmin/tools/sqleditor/utils/constant_definition.py:31 msgid "The session is idle in a failed transaction block." msgstr "" -#: pgadmin/tools/sqleditor/static/js/components/QueryToolConstants.js:104 +#: pgadmin/tools/sqleditor/static/js/components/QueryToolConstants.js:105 #: pgadmin/tools/sqleditor/utils/constant_definition.py:32 msgid "The connection with the server is bad." msgstr "" @@ -17627,7 +17690,7 @@ msgid "Execute options" msgstr "" #: pgadmin/tools/sqleditor/static/js/components/sections/MainToolBar.jsx:567 -#: pgadmin/tools/sqleditor/static/js/components/sections/ResultSet.jsx:955 +#: pgadmin/tools/sqleditor/static/js/components/sections/ResultSet.jsx:957 #: pgadmin/utils/constants.py:25 msgid "Explain" msgstr "" @@ -17929,143 +17992,143 @@ msgstr "" msgid "Remove All" msgstr "" -#: pgadmin/tools/sqleditor/static/js/components/sections/ResultSet.jsx:144 +#: pgadmin/tools/sqleditor/static/js/components/sections/ResultSet.jsx:145 msgid "hr" msgstr "" -#: pgadmin/tools/sqleditor/static/js/components/sections/ResultSet.jsx:145 +#: pgadmin/tools/sqleditor/static/js/components/sections/ResultSet.jsx:146 msgid "min" msgstr "" -#: pgadmin/tools/sqleditor/static/js/components/sections/ResultSet.jsx:146 +#: pgadmin/tools/sqleditor/static/js/components/sections/ResultSet.jsx:147 msgid "secs" msgstr "" -#: pgadmin/tools/sqleditor/static/js/components/sections/ResultSet.jsx:147 +#: pgadmin/tools/sqleditor/static/js/components/sections/ResultSet.jsx:148 msgid "msec" msgstr "" -#: pgadmin/tools/sqleditor/static/js/components/sections/ResultSet.jsx:201 +#: pgadmin/tools/sqleditor/static/js/components/sections/ResultSet.jsx:202 msgid "Refetching latest results..." msgstr "" -#: pgadmin/tools/sqleditor/static/js/components/sections/ResultSet.jsx:201 -#: pgadmin/tools/sqleditor/static/js/components/sections/ResultSet.jsx:928 +#: pgadmin/tools/sqleditor/static/js/components/sections/ResultSet.jsx:202 +#: pgadmin/tools/sqleditor/static/js/components/sections/ResultSet.jsx:930 msgid "Waiting for the query to complete..." msgstr "" -#: pgadmin/tools/sqleditor/static/js/components/sections/ResultSet.jsx:364 +#: pgadmin/tools/sqleditor/static/js/components/sections/ResultSet.jsx:365 msgid "Connection Error" msgstr "" -#: pgadmin/tools/sqleditor/static/js/components/sections/ResultSet.jsx:367 #: pgadmin/tools/sqleditor/static/js/components/sections/ResultSet.jsx:368 +#: pgadmin/tools/sqleditor/static/js/components/sections/ResultSet.jsx:369 msgid "Execution Cancelled!" msgstr "" -#: pgadmin/tools/sqleditor/static/js/components/sections/ResultSet.jsx:370 +#: pgadmin/tools/sqleditor/static/js/components/sections/ResultSet.jsx:371 msgid "Execution Cancelled" msgstr "" -#: pgadmin/tools/sqleditor/static/js/components/sections/ResultSet.jsx:441 +#: pgadmin/tools/sqleditor/static/js/components/sections/ResultSet.jsx:442 msgid "Server Connected." msgstr "" -#: pgadmin/tools/sqleditor/static/js/components/sections/ResultSet.jsx:742 +#: pgadmin/tools/sqleditor/static/js/components/sections/ResultSet.jsx:743 #: pgadmin/tools/sqleditor/static/js/components/sections/StatusBar.jsx:66 msgid "Query complete" msgstr "" -#: pgadmin/tools/sqleditor/static/js/components/sections/ResultSet.jsx:745 +#: pgadmin/tools/sqleditor/static/js/components/sections/ResultSet.jsx:746 #, python-format msgid "Query returned successfully in %s." msgstr "" -#: pgadmin/tools/sqleditor/static/js/components/sections/ResultSet.jsx:751 +#: pgadmin/tools/sqleditor/static/js/components/sections/ResultSet.jsx:752 #, python-format msgid "Successfully run. Total query runtime: %s." msgstr "" -#: pgadmin/tools/sqleditor/static/js/components/sections/ResultSet.jsx:752 +#: pgadmin/tools/sqleditor/static/js/components/sections/ResultSet.jsx:753 #, python-format msgid "%s rows affected." msgstr "" -#: pgadmin/tools/sqleditor/static/js/components/sections/ResultSet.jsx:992 -#: pgadmin/tools/sqleditor/static/js/components/sections/ResultSet.jsx:1201 +#: pgadmin/tools/sqleditor/static/js/components/sections/ResultSet.jsx:994 +#: pgadmin/tools/sqleditor/static/js/components/sections/ResultSet.jsx:1239 msgid "" "The data has been modified, but not saved. Are you sure you wish to " "discard the changes?" msgstr "" -#: pgadmin/tools/sqleditor/static/js/components/sections/ResultSet.jsx:1007 +#: pgadmin/tools/sqleditor/static/js/components/sections/ResultSet.jsx:1009 msgid "Applying the new filter..." msgstr "" -#: pgadmin/tools/sqleditor/static/js/components/sections/ResultSet.jsx:1061 -#: pgadmin/tools/sqleditor/static/js/components/sections/ResultSet.jsx:1070 +#: pgadmin/tools/sqleditor/static/js/components/sections/ResultSet.jsx:1099 +#: pgadmin/tools/sqleditor/static/js/components/sections/ResultSet.jsx:1108 msgid "Downloading results..." msgstr "" -#: pgadmin/tools/sqleditor/static/js/components/sections/ResultSet.jsx:1063 -#: pgadmin/tools/sqleditor/static/js/components/sections/ResultSet.jsx:1072 +#: pgadmin/tools/sqleditor/static/js/components/sections/ResultSet.jsx:1101 +#: pgadmin/tools/sqleditor/static/js/components/sections/ResultSet.jsx:1110 #, python-format msgid "Downloading results(%s)..." msgstr "" -#: pgadmin/tools/sqleditor/static/js/components/sections/ResultSet.jsx:1078 +#: pgadmin/tools/sqleditor/static/js/components/sections/ResultSet.jsx:1116 msgid "Setting the limit on the result..." msgstr "" -#: pgadmin/tools/sqleditor/static/js/components/sections/ResultSet.jsx:1092 +#: pgadmin/tools/sqleditor/static/js/components/sections/ResultSet.jsx:1130 msgid "Removing the filter..." msgstr "" -#: pgadmin/tools/sqleditor/static/js/components/sections/ResultSet.jsx:1153 +#: pgadmin/tools/sqleditor/static/js/components/sections/ResultSet.jsx:1191 msgid "Fetching rows..." msgstr "" -#: pgadmin/tools/sqleditor/static/js/components/sections/ResultSet.jsx:1227 +#: pgadmin/tools/sqleditor/static/js/components/sections/ResultSet.jsx:1265 msgid "Save data changes?" msgstr "" -#: pgadmin/tools/sqleditor/static/js/components/sections/ResultSet.jsx:1230 +#: pgadmin/tools/sqleditor/static/js/components/sections/ResultSet.jsx:1268 msgid "The data has changed. Do you want to save changes?" msgstr "" -#: pgadmin/tools/sqleditor/static/js/components/sections/ResultSet.jsx:1256 +#: pgadmin/tools/sqleditor/static/js/components/sections/ResultSet.jsx:1294 msgid "Saving data..." msgstr "" -#: pgadmin/tools/sqleditor/static/js/components/sections/ResultSet.jsx:1283 +#: pgadmin/tools/sqleditor/static/js/components/sections/ResultSet.jsx:1321 msgid "This query was generated by pgAdmin as part of a \"Save Data\" operation" msgstr "" -#: pgadmin/tools/sqleditor/static/js/components/sections/ResultSet.jsx:1295 +#: pgadmin/tools/sqleditor/static/js/components/sections/ResultSet.jsx:1333 msgid "" "Saving data changes was rolled back but the current transaction is still " "active; previous queries are unaffected." msgstr "" -#: pgadmin/tools/sqleditor/static/js/components/sections/ResultSet.jsx:1338 +#: pgadmin/tools/sqleditor/static/js/components/sections/ResultSet.jsx:1376 msgid "Data saved successfully." msgstr "" -#: pgadmin/tools/sqleditor/static/js/components/sections/ResultSet.jsx:1340 +#: pgadmin/tools/sqleditor/static/js/components/sections/ResultSet.jsx:1378 msgid "Auto-commit is off. You still need to commit changes to the database." msgstr "" -#: pgadmin/tools/sqleditor/static/js/components/sections/ResultSet.jsx:1502 +#: pgadmin/tools/sqleditor/static/js/components/sections/ResultSet.jsx:1542 msgid "Geometry Viewer" msgstr "" -#: pgadmin/tools/sqleditor/static/js/components/sections/ResultSet.jsx:1657 +#: pgadmin/tools/sqleditor/static/js/components/sections/ResultSet.jsx:1697 #: pgadmin/tools/sqleditor/static/js/components/sections/ResultSetToolbar.jsx:439 #: pgadmin/utils/constants.py:32 msgid "Graph Visualiser" msgstr "" -#: pgadmin/tools/sqleditor/static/js/components/sections/ResultSet.jsx:1674 +#: pgadmin/tools/sqleditor/static/js/components/sections/ResultSet.jsx:1714 msgid "No data output. Execute a query to get output." msgstr "" diff --git a/web/pgadmin/static/js/components/ReactCodeMirror/index.jsx b/web/pgadmin/static/js/components/ReactCodeMirror/index.jsx index c40256dba34..6fb60d7aa0f 100644 --- a/web/pgadmin/static/js/components/ReactCodeMirror/index.jsx +++ b/web/pgadmin/static/js/components/ReactCodeMirror/index.jsx @@ -19,6 +19,10 @@ import gettext from 'sources/gettext'; import { PgIconButton } from '../Buttons'; import { copyToClipboard } from '../../clipboard'; import { useDelayedCaller } from '../../custom_hooks'; +import explTensorFormatSQL from '../../../../tools/expl_tensor/static/js/formatSQL'; +import getApiInstance from '../../../../static/js/api_instance'; +import url_for from 'sources/url_for'; +import Loader from '../Loader'; import Editor from './components/Editor'; import CustomPropTypes from '../../custom_prop_types'; @@ -68,10 +72,26 @@ export default function CodeMirror({className, currEditor, showCopyBtn=false, cu const [[showFind, isReplace, findKey], setShowFind] = useState([false, false, false]); const [showGoto, setShowGoto] = useState(false); const [showCopy, setShowCopy] = useState(false); + const [loading, setLoading] = useState(false); const preferences = usePreferences().getPreferencesForModule('sqleditor'); const editorPrefs = usePreferences().getPreferencesForModule('editor'); + const explTensorPrefs = usePreferences().getPreferencesForModule('expl_tensor'); + + const api = getApiInstance(); + + let explTensorEnabled = false; + api.get(url_for('expl_tensor.status')) + .then((res)=>{ + if(res.data?.success && res.data?.data?.system_enabled) { + explTensorEnabled = true; + } + }) + .catch((e)=>{ + console.error(`Error getting Explain Tensor status: ${e}`); + }); + - const formatSQL = (view)=>{ + const formatSQL = async (view)=>{ let selection = true, sql = view.getSelection(); /* New library does not support capitalize casing so if a user has set capitalize casing we will @@ -95,7 +115,23 @@ export default function CodeMirror({className, currEditor, showCopyBtn=false, cu sql = view.getValue(); selection = false; } - let formattedSql = format(sql,formatPrefs); + let formattedSql; + if (explTensorEnabled && explTensorPrefs.explain_tensor_format) { + let loadingTimeout = setTimeout(() => { + setLoading(true); + }, 500); + try { + formattedSql = await explTensorFormatSQL(sql); + } catch (e) { + console.error('Error formatting SQL using Explain Tensor API:', e); + formattedSql = format(sql,formatPrefs); + } finally { + clearTimeout(loadingTimeout); + setLoading(false); + } + } else { + formattedSql = format(sql,formatPrefs); + } if(selection) { view.replaceSelection(formattedSql); } else { @@ -195,6 +231,7 @@ export default function CodeMirror({className, currEditor, showCopyBtn=false, cu {showCopy && } + {loading && } diff --git a/web/pgadmin/tools/__init__.py b/web/pgadmin/tools/__init__.py index 3e50a8dab4f..fd9eb0b6cfd 100644 --- a/web/pgadmin/tools/__init__.py +++ b/web/pgadmin/tools/__init__.py @@ -34,6 +34,9 @@ def register(self, app, options): from .debugger import blueprint as module app.register_blueprint(module) + from .expl_tensor import blueprint as module + app.register_blueprint(module) + from .erd import blueprint as module app.register_blueprint(module) diff --git a/web/pgadmin/tools/expl_tensor/__init__.py b/web/pgadmin/tools/expl_tensor/__init__.py new file mode 100644 index 00000000000..b318fd69f57 --- /dev/null +++ b/web/pgadmin/tools/expl_tensor/__init__.py @@ -0,0 +1,340 @@ +########################################################################## +# +# pgAdmin 4 - PostgreSQL Tools +# +# Copyright (C) 2013 - 2026, The pgAdmin Development Team +# This software is released under the PostgreSQL Licence +# +########################################################################## + +"""A blueprint module implementing Explain Tensor configuration.""" + +import json +import urllib.request +from urllib.parse import urlparse +from flask import request, current_app +from flask_babel import gettext +from pgadmin.utils import PgAdminModule +from pgadmin.utils.preferences import Preferences +from pgadmin.utils.ajax import make_json_response +from pgadmin.user_login_check import pga_login_required +import config + +MODULE_NAME = 'expl_tensor' + + +class ExplTensorModule(PgAdminModule): + """Explain Tensor configuration module for pgAdmin.""" + + LABEL = gettext('Explain Tensor') + + def register_preferences(self): + """ + Register preferences for Explain Tensor. + """ + + # Don't register Explain Tensor preferences if EXPLAIN_TENSOR is disabled at system level + if not getattr(config, 'EXPLAIN_TENSOR_ENABLED', False): + return + + self.explain_module = self.preference.register( + 'Explain Tensor', 'explain_tensor', + gettext("Explain Plan"), 'boolean', False, + category_label=gettext('Configuration'), + help_str=gettext('Analyze query plan via Explain Tensor API') + ) + + self.explain_tensor_api = self.preference.register( + 'Explain Tensor', 'explain_tensor_api', + gettext("Explain Tensor API"), 'text', + 'https://explain.tensor.ru', + category_label=gettext('Configuration'), + help_str=gettext( + 'Explain Tensor API endpoint ' + '(e.g. https://explain.tensor.ru)' + ), + allow_blanks=False + ) + + self.explain_tensor_private = self.preference.register( + 'Explain Tensor', 'explain_tensor_private', + gettext("Private Plans"), 'boolean', False, + category_label=gettext('Configuration'), + help_str=gettext( + 'Hide plans from public access on Explain Tensor' + ) + ) + + self.explain_tensor_format = self.preference.register( + 'Explain Tensor', 'explain_tensor_format', + gettext("Format SQL"), 'boolean', False, + category_label=gettext('Configuration'), + help_str=gettext('Format SQL using Explain Tensor API') + ) + + def get_exposed_url_endpoints(self): + """ + Returns the list of URLs exposed to the client. + """ + return [ + 'expl_tensor.status', + 'expl_tensor.explain', + 'expl_tensor.formatSQL', + ] + + +# Initialise the module +blueprint = ExplTensorModule(MODULE_NAME, __name__, static_url_path='/static') + + +@blueprint.route("/status", methods=["GET"], endpoint='status') +@pga_login_required +def get_status(): + """ + Get the status of the Explain Tensor configuration. + Indicates whether the analysis of query plans + via the Explain Tensor API is currently enabled + """ + + return make_json_response( + success=1, + data={ + 'enabled': get_preference_value(MODULE_NAME, 'explain_tensor'), + 'system_enabled': getattr(config, 'EXPLAIN_TENSOR_ENABLED', False), + } + ) + + +@blueprint.route( + '/formatSQL', + methods=["POST"], endpoint='formatSQL' +) +@pga_login_required +def formatSQL(): + """ + This method is used to send sql to explain tensor beatifier api. + """ + + data = request.get_json(silent=True) + if not isinstance(data, dict): + return make_json_response( + success=0, + errormsg="Invalid JSON payload. Expected an object/dictionary.", + info=gettext( + 'JSON payload must be an object,' + ' not null, array, or scalar value' + ), + ) + + explain_tensor_api = get_preference_value(MODULE_NAME, 'explain_tensor_api') + + # Validate the API URL to prevent SSRF + if not is_valid_url(explain_tensor_api): + return make_json_response( + success=0, + errormsg=gettext( + 'Invalid API endpoint URL. Only HTTP/HTTPS URLs are allowed.' + ), + info=gettext( + 'The provided API endpoint is not valid. ' + 'Only HTTP/HTTPS URLs are permitted.' + ) + ) + + api_url = explain_tensor_api + '/beautifier-api' + is_error, data = send_post_request(api_url, data) + if is_error: + return make_json_response( + success=0, + errormsg=str(data), + info=gettext('Failed to post data to the Explain Tensor API'), + data={ + 'code': getattr(data, 'code', None), + 'url': api_url, + } + ) + + return make_json_response(success=1, data=data) + + +@blueprint.route( + '/explain', + methods=["POST"], endpoint='explain' +) +@pga_login_required +def explain(): + """ + This method is used to send plan to explain tensor api. + """ + + data = request.get_json(silent=True) + if not isinstance(data, dict): + return make_json_response( + success=0, + errormsg="Invalid JSON payload. Expected an object/dictionary.", + info=gettext( + 'JSON payload must be an object, ' + 'not null, array, or scalar value' + ), + ) + + explain_tensor_api = get_preference_value(MODULE_NAME, 'explain_tensor_api') + + # Validate the API URL to prevent SSRF + if not is_valid_url(explain_tensor_api): + return make_json_response( + success=0, + errormsg=gettext( + 'Invalid API endpoint URL. Only HTTP/HTTPS URLs are allowed.' + ), + info=gettext( + 'The provided API endpoint is not valid. ' + 'Only HTTP/HTTPS URLs are permitted.' + ) + ) + + pref_name = 'explain_tensor_private' + explain_tensor_private = get_preference_value(MODULE_NAME, pref_name) + data['private'] = explain_tensor_private + + api_url = explain_tensor_api + '/explain' + is_error, response_data = send_post_request(api_url, data) + if is_error: + return make_json_response( + success=0, + errormsg=str(response_data), + info=gettext('Failed to post data to the Explain Tensor API'), + data={ + 'code': getattr(response_data, 'code', None), + 'url': api_url, + } + ) + + # response_data should be a relative path from 302 Location header + if not response_data.startswith('/'): + return make_json_response( + success=0, + errormsg='Unexpected response format from API' + ) + lang = get_preference_value('misc', 'user_language') + res_data = explain_tensor_api + response_data + if lang in ('ru', 'en'): + res_data += '?lang=' + lang + return make_json_response(success=1, data=res_data) + + +def is_valid_url(url): + """ + Validate that a URL is safe to use (HTTP/HTTPS only). + + Args: + url: The URL to validate + + Returns: + bool: True if URL is valid, False otherwise + """ + if not url: + return False + + try: + parsed = urlparse(url) + + # Only allow http and https schemes + if parsed.scheme not in ('http', 'https'): + return False + + hostname = parsed.hostname + if not hostname: + return False + + return True + except Exception: + return False + + +def send_post_request(url_api, data): + data = json.dumps(data).encode('utf-8') + headers = { + "Content-Type": "application/json; charset=utf-8", + "User-Agent": "pgAdmin4/ExplainTensor", + "Method": "POST" + } + try: + req = urllib.request.Request(url_api, data, headers) + with no302opener.open(req, timeout=10) as response: + if (response.code == 302): + return False, response.headers["Location"] + response_data = response.read().decode('utf-8') + return False, response_data + except Exception as e: + return True, e + + +class No302HTTPErrorProcessor(urllib.request.HTTPErrorProcessor): + + def http_response(self, request, response): + code, msg, hdrs = response.code, response.msg, response.info() + + if (code == 302): + return response + + # According to RFC 2616, "2xx" code indicates that the client's + # request was successfully received, understood, and accepted. + if not (200 <= code < 300): + response = self.parent.error( + 'http', request, response, code, msg, hdrs) + + return response + + https_response = http_response + + +class NoRedirectHandler(urllib.request.HTTPRedirectHandler): + """ + A redirect handler that prevents automatic redirects by returning None + from redirect_request, allowing 302 responses to be handled properly. + """ + def redirect_request(self, req, fp, code, msg, headers, newurl): + """ + Return None to disable automatic redirects. + """ + return None + + +# Build opener without HTTPRedirectHandler +# so No302HTTPErrorProcessor can handle 302 responses +no302opener = urllib.request.build_opener( + No302HTTPErrorProcessor(), + # Explicitly add NoRedirectHandler to prevent automatic redirects + NoRedirectHandler(), + urllib.request.HTTPHandler(), + urllib.request.HTTPSHandler() +) + + +def get_preference_value(module, name): + """ + Get a preference value, returning None if empty or not set. + + Args: + module: The preference module (e.g., 'expl_tensor') + name: The preference name (e.g., 'explain_tensor_api') + + Returns: + The preference value or None if empty/not set. + """ + try: + pref_module = Preferences.module(module) + if pref_module: + pref = pref_module.preference(name) + if pref: + value = pref.get() + if isinstance(value, str): + value = value.strip() + return value or None + return value + except Exception as e: + current_app.logger.debug( + f"Failed to retrieve preference '{name}': {e}" + ) + return None diff --git a/web/pgadmin/tools/expl_tensor/static/js/formatSQL.js b/web/pgadmin/tools/expl_tensor/static/js/formatSQL.js new file mode 100644 index 00000000000..5ec151ff570 --- /dev/null +++ b/web/pgadmin/tools/expl_tensor/static/js/formatSQL.js @@ -0,0 +1,46 @@ +import url_for from 'sources/url_for'; +import getApiInstance from '../../../../static/js/api_instance'; + +export default async function formatSQL(sql) { + const api = getApiInstance(); + const url = url_for('expl_tensor.formatSQL'); + const postData = JSON.stringify({ + query_src: sql, + }); + try { + const response = await api.post(url, postData); + const result = response.data; + let data; + + if (result?.success) { + if (!result.data) throw new Error('No data returned from formatting API'); + data = JSON.parse(result.data); + } else if (result?.data?.code === 401) { + const retryRes = await fetch(result.data.url, { + method: 'POST', + credentials: 'include', + headers: { 'Content-Type': 'application/json;charset=utf-8' }, + body: postData, + }); + + if (!retryRes.ok) throw new Error(`HTTP error ${retryRes.status}`); + data = await retryRes.json(); + } else { + throw new Error(`${result?.errormsg || 'Unknown error'} info: ${result?.info}`); + } + + const { btf_query, btf_query_text } = data || {}; + + if (btf_query !== btf_query_text) { + return btf_query_text; + } else { + // Server returns identical text in both fields in case of an error + // In this scenario, we use the local formatter as a fallback + throw new Error(btf_query_text); + } + + } catch (err) { + console.error(err); + throw err; + } +}; diff --git a/web/pgadmin/tools/expl_tensor/static/js/index.jsx b/web/pgadmin/tools/expl_tensor/static/js/index.jsx new file mode 100644 index 00000000000..750a26ae451 --- /dev/null +++ b/web/pgadmin/tools/expl_tensor/static/js/index.jsx @@ -0,0 +1,117 @@ +///////////////////////////////////////////////////////////// +// +// pgAdmin 4 - PostgreSQL Tools +// +// Copyright (C) 2013 - 2026, The pgAdmin Development Team +// This software is released under the PostgreSQL Licence +// +////////////////////////////////////////////////////////////// +import { Box } from '@mui/material'; +import { styled } from '@mui/material/styles'; +import { useState, useEffect } from 'react'; +import _ from 'lodash'; +import gettext from 'sources/gettext'; +import url_for from 'sources/url_for'; +import getApiInstance from '../../../../static/js/api_instance'; +import PropTypes from 'prop-types'; +import EmptyPanelMessage from '../../../../static/js/components/EmptyPanelMessage'; +import Loader from '../../../../static/js/components/Loader'; + +const StyledBox = styled(Box)(({theme}) => ({ + '& .Explain-tabPanel': { + padding: '0 !important', + backgroundColor: theme.palette.background.default + ' !important', + } +})); + +export default function ExplainTensor({ + plans=[], + emptyMessage=gettext('Use the Explain/Explain Analyze button to generate the plan for a query. Alternatively, you can also execute "EXPLAIN (FORMAT JSON) [QUERY]".'), + sql='', +}) { + + const [data, setData] = useState(''); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(''); + + useEffect(() => { + if (_.isEmpty(plans)) return; + setIsLoading(true); + const api = getApiInstance(); + const postData = { + plan: JSON.stringify(plans), + query: sql, + }; + api.post( + url_for('expl_tensor.explain'), + postData, + ) + .then((res) => { + if (res.data?.success) { + setData(res.data?.data); + setIsLoading(false); + } else if (res.data?.data?.code == 401) { + fetch(res.data?.data?.url, { + method: 'POST', + credentials: 'include', + headers: { + 'Content-Type': 'application/json;charset=utf-8' + }, + body: JSON.stringify(postData), + }) + .then((res) => { + if (res.ok) { + setData(res.url); + } else { + setError(`HTTP error ${res.status}`); + } + }) + .catch((err) => { + setError(err?.message); + }) + .finally(() => { + setIsLoading(false); + }); + } else { + setError(`${res.data?.info} : ${res.data?.errormsg}`); + setIsLoading(false); + } + }) + .catch((err) => { + setError(err?.message); + setIsLoading(false); + }); + }, [plans]); + + if(_.isEmpty(plans)) { + return ( + + {emptyMessage && } + + ); + } + if (isLoading) return ; + if (error) return ( + + {} + + ); + return ( +
+