Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
171 changes: 112 additions & 59 deletions web/pgadmin/messages.pot

Large diffs are not rendered by default.

16 changes: 14 additions & 2 deletions web/pgadmin/static/js/components/ReactCodeMirror/index.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import gettext from 'sources/gettext';
import { PgIconButton } from '../Buttons';
import { copyToClipboard } from '../../clipboard';
import { useDelayedCaller } from '../../custom_hooks';
import epFormatSQL from '../../../../tools/ep/static/js/ExplainPostgreSQL/formatSQL';

import Editor from './components/Editor';
import CustomPropTypes from '../../custom_prop_types';
Expand Down Expand Up @@ -70,8 +71,9 @@ export default function CodeMirror({className, currEditor, showCopyBtn=false, cu
const [showCopy, setShowCopy] = useState(false);
const preferences = usePreferences().getPreferencesForModule('sqleditor');
const editorPrefs = usePreferences().getPreferencesForModule('editor');
const epPrefs = usePreferences().getPreferencesForModule('ep');

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
Expand All @@ -95,7 +97,17 @@ export default function CodeMirror({className, currEditor, showCopyBtn=false, cu
sql = view.getValue();
selection = false;
}
let formattedSql = format(sql,formatPrefs);
let formattedSql;
if (epPrefs.explain_postgresql_format) {
try {
formattedSql = await epFormatSQL(sql);
} catch (e) {
console.error('Error formatting SQL using Explain PostgreSQL API:', e);
formattedSql = format(sql,formatPrefs);
}
} else {
formattedSql = format(sql,formatPrefs);
}
if(selection) {
view.replaceSelection(formattedSql);
} else {
Expand Down
3 changes: 3 additions & 0 deletions web/pgadmin/tools/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,9 @@ def register(self, app, options):
from .debugger import blueprint as module
app.register_blueprint(module)

from .ep import blueprint as module
app.register_blueprint(module)

from .erd import blueprint as module
app.register_blueprint(module)

Expand Down
268 changes: 268 additions & 0 deletions web/pgadmin/tools/ep/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,268 @@
##########################################################################
#
# 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 PostgreSQL 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

MODULE_NAME = 'ep'

class EPModule(PgAdminModule):
"""Explain PostgreSQL configuration module for pgAdmin."""

LABEL = gettext('Explain PostgreSQL')

def register_preferences(self):
"""
Register preferences for Explain PostgreSQL.
"""

self.explain_postgresql_api = self.preference.register(
'Explain PostgreSQL', 'explain_postgresql_api',
gettext("Explain PostgreSQL API"), 'text', 'https://explain.tensor.ru',
category_label=gettext('Configuration'),
help_str=gettext('Explain PostgreSQL API endpoint (e.g. https://explain-postgresql.com)'),
allow_blanks=False
)

self.explain_postgresql_private = self.preference.register(
'Explain PostgreSQL', 'explain_postgresql_private',
gettext("Private Plans"), 'boolean', False,
category_label=gettext('Configuration'),
help_str=gettext('Hide plans from public access on Explain PostgreSQL')
)

self.explain_module = self.preference.register(
'Explain PostgreSQL', 'explain_postgresql',
gettext("Explain Plan"), 'boolean', True,
category_label=gettext('Configuration'),
help_str=gettext('Analyze query plan via Explain PostgreSQL API')
)

self.explain_postgresql_format = self.preference.register(
'Explain PostgreSQL', 'explain_postgresql_format',
gettext("Format SQL"), 'boolean', True,
category_label=gettext('Configuration'),
help_str=gettext('Format SQL using Explain PostgreSQL API')
)

def get_exposed_url_endpoints(self):
"""
Returns the list of URLs exposed to the client.
"""
return [
'ep.explain_postgresql',
'ep.explain_postgresql_format',
]


# Initialise the module
blueprint = EPModule(MODULE_NAME, __name__, static_url_path='/static')

@blueprint.route(
'/explain_postgresql_format',
methods=["POST"], endpoint='explain_postgresql_format'
)
@pga_login_required
def explain_postgresql_format():
"""
This method is used to send sql to explain postgresql 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_postgresql_api = get_preference_value('explain_postgresql_api')

# Validate the API URL to prevent SSRF
if not is_valid_url(explain_postgresql_api):
return make_json_response(
success=0,
errormsg="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.')
)

is_error, data = send_post_request(explain_postgresql_api + '/beautifier-api', data)
if is_error:
return make_json_response(success=0, errormsg=data,
info=gettext('Failed to post data to the Explain Postgresql API'),
)

return make_json_response(success=1, data=data)


@blueprint.route(
'/explain_postgresql',
methods=["POST"], endpoint='explain_postgresql'
)
@pga_login_required
def explain_postgresql():
"""
This method is used to send plan to explain postgresql 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_postgresql_api = get_preference_value('explain_postgresql_api')

# Validate the API URL to prevent SSRF
if not is_valid_url(explain_postgresql_api):
return make_json_response(
success=0,
errormsg="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.')
)

explain_postgresql_private = get_preference_value('explain_postgresql_private')
data['private'] = explain_postgresql_private

is_error, response_data = send_post_request(explain_postgresql_api + '/explain', data)
if is_error:
return make_json_response(success=0, errormsg=response_data,
info=gettext('Failed to post data to the Explain Postgresql API'),
)

# 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")
return make_json_response(success=1, data=explain_postgresql_api + response_data)


def is_valid_url(url):
"""
Validate that a URL is safe to use (HTTP/HTTPS only, localhost and private IP ranges are allowed).

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, parse=False):
data = json.dumps(data).encode('utf-8')
headers = {
"Content-Type": "application/json; charset=utf-8",
"User-Agent": "pgAdmin4/ExplainModule",
"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')
if (parse):
return False, json.loads(response_data)
else:
return False, response_data
except Exception as e:
return True, str(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(),
NoRedirectHandler(), # Explicitly add NoRedirectHandler to prevent automatic redirects
urllib.request.HTTPHandler(),
urllib.request.HTTPSHandler()
)

def get_preference_value(name):
"""
Get a preference value, returning None if empty or not set.

Args:
name: The preference name (e.g., 'explain_postgresql_api')

Returns:
The preference value or None if empty/not set.
"""
try:
pref_module = Preferences.module(MODULE_NAME)
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
36 changes: 36 additions & 0 deletions web/pgadmin/tools/ep/static/js/ExplainPostgreSQL/formatSQL.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import url_for from 'sources/url_for';
import getApiInstance from '../../../../../static/js/api_instance';

export default async function formatSQL(sql) {
return new Promise((resolve, reject) => {
const api = getApiInstance();
api.post(
url_for('ep.explain_postgresql_format'),
JSON.stringify({
query_src: sql,
}))
.then((res) => {
if (!res.data?.data) {
reject('No data returned from formatting API');
return;
}
try {
const {btf_query, btf_query_text} = JSON.parse(res.data.data);
if (btf_query !== btf_query_text) {
resolve(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
reject(btf_query_text);
}
} catch (err) {
console.error(err);
reject(err.message);
}
})
.catch((err) => {
console.error(err);
reject(err.message);
});
});
};
Loading