-
Notifications
You must be signed in to change notification settings - Fork 836
Add SQL formatting and query plan analysis module via explain-postgresql.com API #9726
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
MGorkov
wants to merge
6
commits into
pgadmin-org:master
Choose a base branch
from
MGorkov:master
base: master
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
Show all changes
6 commits
Select commit
Hold shift + click to select a range
2c0e741
Add integration with external Explain PostgreSQL API for query plans …
MGorkov 28fe939
fix coderabbit issues
MGorkov 4653a9f
fix coderabbit issues again
MGorkov f9e341b
fix
MGorkov 117e25c
fix possible BadRequest raising
MGorkov f956529
fix url opener
MGorkov File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Some comments aren't visible on the classic Files Changed page.
There are no files selected for viewing
Large diffs are not rendered by default.
Oops, something went wrong.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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
36
web/pgadmin/tools/ep/static/js/ExplainPostgreSQL/formatSQL.js
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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); | ||
| }); | ||
| }); | ||
| }; |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.