mirror of https://github.com/dbcli/pgcli
Merge branch 'main' into listen
This commit is contained in:
commit
e6f12049a8
|
@ -1,6 +1,9 @@
|
|||
name: pgcli
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
pull_request:
|
||||
paths-ignore:
|
||||
- '**.rst'
|
||||
|
@ -11,7 +14,7 @@ jobs:
|
|||
|
||||
strategy:
|
||||
matrix:
|
||||
python-version: ["3.7", "3.8", "3.9", "3.10", "3.11"]
|
||||
python-version: ["3.8", "3.9", "3.10", "3.11", "3.12"]
|
||||
|
||||
services:
|
||||
postgres:
|
||||
|
@ -28,10 +31,10 @@ jobs:
|
|||
--health-retries 5
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Python ${{ matrix.python-version }}
|
||||
uses: actions/setup-python@v2
|
||||
uses: actions/setup-python@v4
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
|
||||
|
@ -72,7 +75,7 @@ jobs:
|
|||
pip install keyrings.alt>=3.1
|
||||
|
||||
- name: Run unit tests
|
||||
run: coverage run --source pgcli -m py.test
|
||||
run: coverage run --source pgcli -m pytest
|
||||
|
||||
- name: Run integration tests
|
||||
env:
|
||||
|
@ -86,7 +89,7 @@ jobs:
|
|||
|
||||
- name: Run Black
|
||||
run: black --check .
|
||||
if: matrix.python-version == '3.7'
|
||||
if: matrix.python-version == '3.8'
|
||||
|
||||
- name: Coverage
|
||||
run: |
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
repos:
|
||||
- repo: https://github.com/psf/black
|
||||
rev: 22.3.0
|
||||
rev: 23.3.0
|
||||
hooks:
|
||||
- id: black
|
||||
|
|
6
AUTHORS
6
AUTHORS
|
@ -126,6 +126,12 @@ Contributors:
|
|||
* Rigo Neri (rigoneri)
|
||||
* Anna Glasgall (annathyst)
|
||||
* Andy Schoenberger (andyscho)
|
||||
* Damien Baty (dbaty)
|
||||
* blag
|
||||
* Rob Berry (rob-b)
|
||||
* Sharon Yogev (sharonyogev)
|
||||
* Hollis Wu (holi0317)
|
||||
* Antonio Aguilar (crazybolillo)
|
||||
|
||||
Creator:
|
||||
--------
|
||||
|
|
12
README.rst
12
README.rst
|
@ -157,8 +157,9 @@ get this running in a development setup.
|
|||
|
||||
https://github.com/dbcli/pgcli/blob/master/DEVELOP.rst
|
||||
|
||||
Please feel free to reach out to me if you need help.
|
||||
My email: amjith.r@gmail.com, Twitter: `@amjithr <http://twitter.com/amjithr>`_
|
||||
Please feel free to reach out to us if you need help.
|
||||
* Amjith, pgcli author: amjith.r@gmail.com, Twitter: `@amjithr <http://twitter.com/amjithr>`_
|
||||
* Irina, pgcli maintainer: i.chernyavska@gmail.com, Twitter: `@irinatruong <http://twitter.com/irinatruong>`_
|
||||
|
||||
Detailed Installation Instructions:
|
||||
-----------------------------------
|
||||
|
@ -351,8 +352,7 @@ choice:
|
|||
|
||||
In [3]: my_result = _
|
||||
|
||||
Pgcli only runs on Python3.7+ since 4.0.0, if you use an old version of Python,
|
||||
you should use install ``pgcli <= 4.0.0``.
|
||||
Pgcli dropped support for Python<3.8 as of 4.0.0. If you need it, install ``pgcli <= 4.0.0``.
|
||||
|
||||
Thanks:
|
||||
-------
|
||||
|
@ -372,8 +372,8 @@ interface to Postgres database.
|
|||
Thanks to all the beta testers and contributors for your time and patience. :)
|
||||
|
||||
|
||||
.. |Build Status| image:: https://github.com/dbcli/pgcli/workflows/pgcli/badge.svg
|
||||
:target: https://github.com/dbcli/pgcli/actions?query=workflow%3Apgcli
|
||||
.. |Build Status| image:: https://github.com/dbcli/pgcli/actions/workflows/ci.yml/badge.svg?branch=main
|
||||
:target: https://github.com/dbcli/pgcli/actions/workflows/ci.yml
|
||||
|
||||
.. |CodeCov| image:: https://codecov.io/gh/dbcli/pgcli/branch/master/graph/badge.svg
|
||||
:target: https://codecov.io/gh/dbcli/pgcli
|
||||
|
|
|
@ -3,8 +3,35 @@ Upcoming
|
|||
|
||||
Features:
|
||||
---------
|
||||
* Support `PGAPPNAME` as an environment variable and `--application-name` as a command line argument.
|
||||
|
||||
* Changed the `destructive_warning` config to be a list of commands that are considered
|
||||
Bug fixes:
|
||||
----------
|
||||
|
||||
* Fix display of "short host" in prompt (with `\h`) for IPv4 addresses ([issue 964](https://github.com/dbcli/pgcli/issues/964)).
|
||||
* Fix backwards display of NOTICEs from a Function ([issue 1443](https://github.com/dbcli/pgcli/issues/1443))
|
||||
* Fix psycopg errors when installing on Windows. ([issue 1413](https://https://github.com/dbcli/pgcli/issues/1413))
|
||||
* Use a home-made function to display query duration instead of relying on a third-party library (the general behaviour does not change), which fixes the installation of `pgcli` on 32-bit architectures ([issue 1451](https://github.com/dbcli/pgcli/issues/1451))
|
||||
|
||||
==================
|
||||
4.0.1 (2023-10-30)
|
||||
==================
|
||||
|
||||
Internal:
|
||||
---------
|
||||
* Allow stable version of pendulum.
|
||||
|
||||
==================
|
||||
4.0.0 (2023-10-27)
|
||||
==================
|
||||
|
||||
Features:
|
||||
---------
|
||||
|
||||
* Ask for confirmation when quitting cli while a transaction is ongoing.
|
||||
* New `destructive_statements_require_transaction` config option to refuse to execute a
|
||||
destructive SQL statement if outside a transaction. This option is off by default.
|
||||
* Changed the `destructive_warning` config to be a list of commands that are considered
|
||||
destructive. This would allow you to be warned on `create`, `grant`, or `insert` queries.
|
||||
* Destructive warnings will now include the alias dsn connection string name if provided (-D option).
|
||||
* pgcli.magic will now work with connection URLs that use TLS client certificates for authentication
|
||||
|
@ -12,7 +39,28 @@ Features:
|
|||
Also prevents getting stuck in a retry loop.
|
||||
* Config option to not restart connection when cancelling a `destructive_warning` query. By default,
|
||||
it will now not restart.
|
||||
* Fix \ev not producing a correctly quoted "schema"."view"
|
||||
* Config option to always run with a single connection.
|
||||
* Add comment explaining default LESS environment variable behavior and change example pager setting.
|
||||
* Added `\echo` & `\qecho` special commands. ([issue 1335](https://github.com/dbcli/pgcli/issues/1335)).
|
||||
|
||||
Bug fixes:
|
||||
----------
|
||||
|
||||
* Fix `\ev` not producing a correctly quoted "schema"."view"
|
||||
* Fix 'invalid connection option "dsn"' ([issue 1373](https://github.com/dbcli/pgcli/issues/1373)).
|
||||
* Fix explain mode when used with `expand`, `auto_expand`, or `--explain-vertical-output` ([issue 1393](https://github.com/dbcli/pgcli/issues/1393)).
|
||||
* Fix sql-insert format emits NULL as 'None' ([issue 1408](https://github.com/dbcli/pgcli/issues/1408)).
|
||||
* Improve check for prompt-toolkit 3.0.6 ([issue 1416](https://github.com/dbcli/pgcli/issues/1416)).
|
||||
* Allow specifying an `alias_map_file` in the config that will use
|
||||
predetermined table aliases instead of generating aliases programmatically on
|
||||
the fly
|
||||
* Fixed SQL error when there is a comment on the first line: ([issue 1403](https://github.com/dbcli/pgcli/issues/1403))
|
||||
* Fix wrong usage of prompt instead of confirm when confirm execution of destructive query
|
||||
|
||||
Internal:
|
||||
---------
|
||||
|
||||
* Drop support for Python 3.7 and add 3.12.
|
||||
|
||||
3.5.0 (2022/09/15):
|
||||
===================
|
||||
|
|
|
@ -1 +1 @@
|
|||
__version__ = "3.5.0"
|
||||
__version__ = "4.0.1"
|
||||
|
|
|
@ -26,7 +26,9 @@ def keyring_initialize(keyring_enabled, *, logger):
|
|||
|
||||
try:
|
||||
keyring = importlib.import_module("keyring")
|
||||
except Exception as e: # ImportError for Python 2, ModuleNotFoundError for Python 3
|
||||
except (
|
||||
ModuleNotFoundError
|
||||
) as e: # ImportError for Python 2, ModuleNotFoundError for Python 3
|
||||
logger.warning("import keyring failed: %r.", e)
|
||||
|
||||
|
||||
|
|
|
@ -6,7 +6,6 @@ from .pgcompleter import PGCompleter
|
|||
|
||||
|
||||
class CompletionRefresher:
|
||||
|
||||
refreshers = OrderedDict()
|
||||
|
||||
def __init__(self):
|
||||
|
@ -39,7 +38,7 @@ class CompletionRefresher:
|
|||
args=(executor, special, callbacks, history, settings),
|
||||
name="completion_refresh",
|
||||
)
|
||||
self._completer_thread.setDaemon(True)
|
||||
self._completer_thread.daemon = True
|
||||
self._completer_thread.start()
|
||||
return [
|
||||
(None, None, None, "Auto-completion refresh started in the background.")
|
||||
|
|
|
@ -10,7 +10,8 @@ class ExplainOutputFormatter:
|
|||
self.max_width = max_width
|
||||
|
||||
def format_output(self, cur, headers, **output_kwargs):
|
||||
(data,) = cur.fetchone()
|
||||
# explain query results should always contain 1 row each
|
||||
[(data,)] = list(cur)
|
||||
explain_list = json.loads(data)
|
||||
visualizer = Visualizer(self.max_width)
|
||||
for explain in explain_list:
|
||||
|
|
151
pgcli/main.py
151
pgcli/main.py
|
@ -11,7 +11,6 @@ import logging
|
|||
import threading
|
||||
import shutil
|
||||
import functools
|
||||
import pendulum
|
||||
import datetime as dt
|
||||
import itertools
|
||||
import platform
|
||||
|
@ -64,15 +63,13 @@ from .config import (
|
|||
from .key_bindings import pgcli_bindings
|
||||
from .packages.formatter.sqlformatter import register_new_formatter
|
||||
from .packages.prompt_utils import confirm, confirm_destructive_query
|
||||
from .packages.parseutils import is_destructive
|
||||
from .packages.parseutils import parse_destructive_warning
|
||||
from .__init__ import __version__
|
||||
|
||||
click.disable_unicode_literals_warning = True
|
||||
|
||||
try:
|
||||
from urlparse import urlparse, unquote, parse_qs
|
||||
except ImportError:
|
||||
from urllib.parse import urlparse, unquote, parse_qs
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from getpass import getuser
|
||||
|
||||
|
@ -167,6 +164,7 @@ class PGCli:
|
|||
pgexecute=None,
|
||||
pgclirc_file=None,
|
||||
row_limit=None,
|
||||
application_name="pgcli",
|
||||
single_connection=False,
|
||||
less_chatty=None,
|
||||
prompt=None,
|
||||
|
@ -212,6 +210,8 @@ class PGCli:
|
|||
else:
|
||||
self.row_limit = c["main"].as_int("row_limit")
|
||||
|
||||
self.application_name = application_name
|
||||
|
||||
# if not specified, set to DEFAULT_MAX_FIELD_WIDTH
|
||||
# if specified but empty, set to None to disable truncation
|
||||
# ellipsis will take at least 3 symbols, so this can't be less than 3 if specified and > 0
|
||||
|
@ -234,6 +234,9 @@ class PGCli:
|
|||
self.destructive_warning_restarts_connection = c["main"].as_bool(
|
||||
"destructive_warning_restarts_connection"
|
||||
)
|
||||
self.destructive_statements_require_transaction = c["main"].as_bool(
|
||||
"destructive_statements_require_transaction"
|
||||
)
|
||||
|
||||
self.less_chatty = bool(less_chatty) or c["main"].as_bool("less_chatty")
|
||||
self.null_string = c["main"].get("null_string", "<null>")
|
||||
|
@ -264,6 +267,9 @@ class PGCli:
|
|||
# Initialize completer
|
||||
smart_completion = c["main"].as_bool("smart_completion")
|
||||
keyword_casing = c["main"]["keyword_casing"]
|
||||
single_connection = single_connection or c["main"].as_bool(
|
||||
"always_use_single_connection"
|
||||
)
|
||||
self.settings = {
|
||||
"casing_file": get_casing_file(c),
|
||||
"generate_casing_file": c["main"].as_bool("generate_casing_file"),
|
||||
|
@ -275,6 +281,7 @@ class PGCli:
|
|||
"single_connection": single_connection,
|
||||
"less_chatty": less_chatty,
|
||||
"keyword_casing": keyword_casing,
|
||||
"alias_map_file": c["main"]["alias_map_file"] or None,
|
||||
}
|
||||
|
||||
completer = PGCompleter(
|
||||
|
@ -298,7 +305,6 @@ class PGCli:
|
|||
raise PgCliQuitError
|
||||
|
||||
def register_special_commands(self):
|
||||
|
||||
self.pgspecial.register(
|
||||
self.change_db,
|
||||
"\\c",
|
||||
|
@ -360,6 +366,23 @@ class PGCli:
|
|||
"Change the table format used to output results",
|
||||
)
|
||||
|
||||
self.pgspecial.register(
|
||||
self.echo,
|
||||
"\\echo",
|
||||
"\\echo [string]",
|
||||
"Echo a string to stdout",
|
||||
)
|
||||
|
||||
self.pgspecial.register(
|
||||
self.echo,
|
||||
"\\qecho",
|
||||
"\\qecho [string]",
|
||||
"Echo a string to the query output channel.",
|
||||
)
|
||||
|
||||
def echo(self, pattern, **_):
|
||||
return [(None, None, None, pattern)]
|
||||
|
||||
def change_table_format(self, pattern, **_):
|
||||
try:
|
||||
if pattern not in TabularOutputFormatter().supported_formats:
|
||||
|
@ -429,15 +452,20 @@ class PGCli:
|
|||
except OSError as e:
|
||||
return [(None, None, None, str(e), "", False, True)]
|
||||
|
||||
if (
|
||||
self.destructive_warning
|
||||
and confirm_destructive_query(
|
||||
if self.destructive_warning:
|
||||
if (
|
||||
self.destructive_statements_require_transaction
|
||||
and not self.pgexecute.valid_transaction()
|
||||
and is_destructive(query, self.destructive_warning)
|
||||
):
|
||||
message = "Destructive statements must be run within a transaction. Command execution stopped."
|
||||
return [(None, None, None, message)]
|
||||
destroy = confirm_destructive_query(
|
||||
query, self.destructive_warning, self.dsn_alias
|
||||
)
|
||||
is False
|
||||
):
|
||||
message = "Wise choice. Command execution stopped."
|
||||
return [(None, None, None, message)]
|
||||
if destroy is False:
|
||||
message = "Wise choice. Command execution stopped."
|
||||
return [(None, None, None, message)]
|
||||
|
||||
on_error_resume = self.on_error == "RESUME"
|
||||
return self.pgexecute.run(
|
||||
|
@ -465,7 +493,6 @@ class PGCli:
|
|||
return [(None, None, None, message, "", True, True)]
|
||||
|
||||
def initialize_logging(self):
|
||||
|
||||
log_file = self.config["main"]["log_file"]
|
||||
if log_file == "default":
|
||||
log_file = config_location() + "log"
|
||||
|
@ -543,7 +570,7 @@ class PGCli:
|
|||
if not database:
|
||||
database = user
|
||||
|
||||
kwargs.setdefault("application_name", "pgcli")
|
||||
kwargs.setdefault("application_name", self.application_name)
|
||||
|
||||
# If password prompt is not forced but no password is provided, try
|
||||
# getting it from environment variable.
|
||||
|
@ -727,7 +754,16 @@ class PGCli:
|
|||
|
||||
try:
|
||||
if self.destructive_warning:
|
||||
destroy = confirm = confirm_destructive_query(
|
||||
if (
|
||||
self.destructive_statements_require_transaction
|
||||
and not self.pgexecute.valid_transaction()
|
||||
and is_destructive(text, self.destructive_warning)
|
||||
):
|
||||
click.secho(
|
||||
"Destructive statements must be run within a transaction."
|
||||
)
|
||||
raise KeyboardInterrupt
|
||||
destroy = confirm_destructive_query(
|
||||
text, self.destructive_warning, self.dsn_alias
|
||||
)
|
||||
if destroy is False:
|
||||
|
@ -756,7 +792,7 @@ class PGCli:
|
|||
click.secho(str(e), err=True, fg="red")
|
||||
if handle_closed_connection:
|
||||
self._handle_server_closed_connection(text)
|
||||
except (PgCliQuitError, EOFError) as e:
|
||||
except (PgCliQuitError, EOFError):
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error("sql: %r, error: %r", text, e)
|
||||
|
@ -764,7 +800,9 @@ class PGCli:
|
|||
click.secho(str(e), err=True, fg="red")
|
||||
else:
|
||||
try:
|
||||
if self.output_file and not text.startswith(("\\o ", "\\? ")):
|
||||
if self.output_file and not text.startswith(
|
||||
("\\o ", "\\? ", "\\echo ")
|
||||
):
|
||||
try:
|
||||
with open(self.output_file, "a", encoding="utf-8") as f:
|
||||
click.echo(text, file=f)
|
||||
|
@ -785,9 +823,9 @@ class PGCli:
|
|||
"Time: %0.03fs (%s), executed in: %0.03fs (%s)"
|
||||
% (
|
||||
query.total_time,
|
||||
pendulum.Duration(seconds=query.total_time).in_words(),
|
||||
duration_in_words(query.total_time),
|
||||
query.execution_time,
|
||||
pendulum.Duration(seconds=query.execution_time).in_words(),
|
||||
duration_in_words(query.execution_time),
|
||||
)
|
||||
)
|
||||
else:
|
||||
|
@ -808,6 +846,34 @@ class PGCli:
|
|||
logger.debug("Search path: %r", self.completer.search_path)
|
||||
return query
|
||||
|
||||
def _check_ongoing_transaction_and_allow_quitting(self):
|
||||
"""Return whether we can really quit, possibly by asking the
|
||||
user to confirm so if there is an ongoing transaction.
|
||||
"""
|
||||
if not self.pgexecute.valid_transaction():
|
||||
return True
|
||||
while 1:
|
||||
try:
|
||||
choice = click.prompt(
|
||||
"A transaction is ongoing. Choose `c` to COMMIT, `r` to ROLLBACK, `a` to abort exit.",
|
||||
default="a",
|
||||
)
|
||||
except click.Abort:
|
||||
# Print newline if user aborts with `^C`, otherwise
|
||||
# pgcli's prompt will be printed on the same line
|
||||
# (just after the confirmation prompt).
|
||||
click.echo(None, err=False)
|
||||
choice = "a"
|
||||
choice = choice.lower()
|
||||
if choice == "a":
|
||||
return False # do not quit
|
||||
if choice == "c":
|
||||
query = self.execute_command("commit")
|
||||
return query.successful # quit only if query is successful
|
||||
if choice == "r":
|
||||
query = self.execute_command("rollback")
|
||||
return query.successful # quit only if query is successful
|
||||
|
||||
def run_cli(self):
|
||||
logger = self.logger
|
||||
|
||||
|
@ -830,6 +896,10 @@ class PGCli:
|
|||
text = self.prompt_app.prompt()
|
||||
except KeyboardInterrupt:
|
||||
continue
|
||||
except EOFError:
|
||||
if not self._check_ongoing_transaction_and_allow_quitting():
|
||||
continue
|
||||
raise
|
||||
|
||||
try:
|
||||
text = self.handle_editor_command(text)
|
||||
|
@ -839,7 +909,12 @@ class PGCli:
|
|||
click.secho(str(e), err=True, fg="red")
|
||||
continue
|
||||
|
||||
self.handle_watch_command(text)
|
||||
try:
|
||||
self.handle_watch_command(text)
|
||||
except PgCliQuitError:
|
||||
if not self._check_ongoing_transaction_and_allow_quitting():
|
||||
continue
|
||||
raise
|
||||
|
||||
self.now = dt.datetime.today()
|
||||
|
||||
|
@ -1288,6 +1363,12 @@ class PGCli:
|
|||
type=click.INT,
|
||||
help="Set threshold for row limit prompt. Use 0 to disable prompt.",
|
||||
)
|
||||
@click.option(
|
||||
"--application-name",
|
||||
default="pgcli",
|
||||
envvar="PGAPPNAME",
|
||||
help="Application name for the connection.",
|
||||
)
|
||||
@click.option(
|
||||
"--less-chatty",
|
||||
"less_chatty",
|
||||
|
@ -1338,6 +1419,7 @@ def cli(
|
|||
pgclirc,
|
||||
dsn,
|
||||
row_limit,
|
||||
application_name,
|
||||
less_chatty,
|
||||
prompt,
|
||||
prompt_dsn,
|
||||
|
@ -1396,6 +1478,7 @@ def cli(
|
|||
never_prompt,
|
||||
pgclirc_file=pgclirc,
|
||||
row_limit=row_limit,
|
||||
application_name=application_name,
|
||||
single_connection=single_connection,
|
||||
less_chatty=less_chatty,
|
||||
prompt=prompt,
|
||||
|
@ -1623,7 +1706,8 @@ def format_output(title, cur, headers, status, settings, explain_mode=False):
|
|||
first_line = next(formatted)
|
||||
formatted = itertools.chain([first_line], formatted)
|
||||
if (
|
||||
not expanded
|
||||
not explain_mode
|
||||
and not expanded
|
||||
and max_width
|
||||
and len(strip_ansi(first_line)) > max_width
|
||||
and headers
|
||||
|
@ -1674,5 +1758,28 @@ def parse_service_info(service):
|
|||
return service_conf, service_file
|
||||
|
||||
|
||||
def duration_in_words(duration_in_seconds: float) -> str:
|
||||
if not duration_in_seconds:
|
||||
return "0 seconds"
|
||||
components = []
|
||||
hours, remainder = divmod(duration_in_seconds, 3600)
|
||||
if hours > 1:
|
||||
components.append(f"{hours} hours")
|
||||
elif hours == 1:
|
||||
components.append("1 hour")
|
||||
minutes, seconds = divmod(remainder, 60)
|
||||
if minutes > 1:
|
||||
components.append(f"{minutes} minutes")
|
||||
elif minutes == 1:
|
||||
components.append("1 minute")
|
||||
if seconds >= 2:
|
||||
components.append(f"{int(seconds)} seconds")
|
||||
elif seconds >= 1:
|
||||
components.append("1 second")
|
||||
elif seconds:
|
||||
components.append(f"{round(seconds, 3)} second")
|
||||
return " ".join(components)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
cli()
|
||||
|
|
|
@ -14,10 +14,13 @@ preprocessors = ()
|
|||
|
||||
|
||||
def escape_for_sql_statement(value):
|
||||
if value is None:
|
||||
return "NULL"
|
||||
|
||||
if isinstance(value, bytes):
|
||||
return f"X'{value.hex()}'"
|
||||
else:
|
||||
return "'{}'".format(value)
|
||||
|
||||
return "'{}'".format(value)
|
||||
|
||||
|
||||
def adapter(data, headers, table_format=None, **kwargs):
|
||||
|
@ -29,7 +32,7 @@ def adapter(data, headers, table_format=None, **kwargs):
|
|||
else:
|
||||
table_name = table[1]
|
||||
else:
|
||||
table_name = '"DUAL"'
|
||||
table_name = "DUAL"
|
||||
if table_format == "sql-insert":
|
||||
h = '", "'.join(headers)
|
||||
yield 'INSERT INTO "{}" ("{}") VALUES'.format(table_name, h)
|
||||
|
|
|
@ -16,9 +16,9 @@ def confirm_destructive_query(queries, keywords, alias):
|
|||
if alias:
|
||||
info += f" in {click.style(alias, fg='red')}"
|
||||
|
||||
prompt_text = f"{info}.\nDo you want to proceed? (y/n)"
|
||||
prompt_text = f"{info}.\nDo you want to proceed?"
|
||||
if is_destructive(queries, keywords) and sys.stdin.isatty():
|
||||
return prompt(prompt_text, type=bool)
|
||||
return confirm(prompt_text)
|
||||
|
||||
|
||||
def confirm(*args, **kwargs):
|
||||
|
|
|
@ -290,7 +290,6 @@ def suggest_special(text):
|
|||
|
||||
|
||||
def suggest_based_on_last_token(token, stmt):
|
||||
|
||||
if isinstance(token, str):
|
||||
token_v = token.lower()
|
||||
elif isinstance(token, Comparison):
|
||||
|
@ -399,7 +398,6 @@ def suggest_based_on_last_token(token, stmt):
|
|||
elif (token_v.endswith("join") and token.is_keyword) or (
|
||||
token_v in ("copy", "from", "update", "into", "describe", "truncate")
|
||||
):
|
||||
|
||||
schema = stmt.get_identifier_schema()
|
||||
tables = extract_tables(stmt.text_before_cursor)
|
||||
is_join = token_v.endswith("join") and token.is_keyword
|
||||
|
@ -436,7 +434,6 @@ def suggest_based_on_last_token(token, stmt):
|
|||
try:
|
||||
prev = stmt.get_previous_token(token).value.lower()
|
||||
if prev in ("drop", "alter", "create", "create or replace"):
|
||||
|
||||
# Suggest functions from either the currently-selected schema or the
|
||||
# public schema if no schema has been specified
|
||||
suggest = []
|
||||
|
|
|
@ -9,6 +9,10 @@ smart_completion = True
|
|||
# visible.)
|
||||
wider_completion_menu = False
|
||||
|
||||
# Do not create new connections for refreshing completions; Equivalent to
|
||||
# always running with the --single-connection flag.
|
||||
always_use_single_connection = False
|
||||
|
||||
# Multi-line mode allows breaking up the sql statements into multiple lines. If
|
||||
# this is set to True, then the end of the statements must have a semi-colon.
|
||||
# If this is set to False then sql statements can't be split into multiple
|
||||
|
@ -24,17 +28,22 @@ multi_line_mode = psql
|
|||
|
||||
# Destructive warning will alert you before executing a sql statement
|
||||
# that may cause harm to the database such as "drop table", "drop database",
|
||||
# "shutdown", "delete", or "update".
|
||||
# "shutdown", "delete", or "update".
|
||||
# You can pass a list of destructive commands or leave it empty if you want to skip all warnings.
|
||||
# "unconditional_update" will warn you of update statements that don't have a where clause
|
||||
destructive_warning = drop, shutdown, delete, truncate, alter, update, unconditional_update
|
||||
|
||||
# Destructive warning can restart the connection if this is enabled and the
|
||||
# user declines. This means that any current uncommitted transaction can be
|
||||
# aborted if the user doesn't want to proceed with a destructive_warning
|
||||
# statement.
|
||||
# When `destructive_warning` is on and the user declines to proceed with a
|
||||
# destructive statement, the current transaction (if any) is left untouched,
|
||||
# by default. When setting `destructive_warning_restarts_connection` to
|
||||
# "True", the connection to the server is restarted. In that case, the
|
||||
# transaction (if any) is rolled back.
|
||||
destructive_warning_restarts_connection = False
|
||||
|
||||
# When this option is on (and if `destructive_warning` is not empty),
|
||||
# destructive statements are not executed when outside of a transaction.
|
||||
destructive_statements_require_transaction = False
|
||||
|
||||
# Enables expand mode, which is similar to `\x` in psql.
|
||||
expand = False
|
||||
|
||||
|
@ -48,6 +57,14 @@ auto_retry_closed_connection = True
|
|||
# If set to True, table suggestions will include a table alias
|
||||
generate_aliases = False
|
||||
|
||||
# Path to a json file that specifies specific table aliases to use when generate_aliases is set to True
|
||||
# the format for this file should be:
|
||||
# {
|
||||
# "some_table_name": "desired_alias",
|
||||
# "some_other_table_name": "another_alias"
|
||||
# }
|
||||
alias_map_file =
|
||||
|
||||
# log_file location.
|
||||
# In Unix/Linux: ~/.config/pgcli/log
|
||||
# In Windows: %USERPROFILE%\AppData\Local\dbcli\pgcli\log
|
||||
|
@ -91,9 +108,10 @@ qualify_columns = if_more_than_one_table
|
|||
# When no schema is entered, only suggest objects in search_path
|
||||
search_path_filter = False
|
||||
|
||||
# Default pager.
|
||||
# By default 'PAGER' environment variable is used
|
||||
# pager = less -SRXF
|
||||
# Default pager. See https://www.pgcli.com/pager for more information on settings.
|
||||
# By default 'PAGER' environment variable is used. If the pager is less, and the 'LESS'
|
||||
# environment variable is not set, then LESS='-SRXF' will be automatically set.
|
||||
# pager = less
|
||||
|
||||
# Timing of sql statements and table rendering.
|
||||
timing = True
|
||||
|
|
|
@ -1,3 +1,4 @@
|
|||
import json
|
||||
import logging
|
||||
import re
|
||||
from itertools import count, repeat, chain
|
||||
|
@ -61,18 +62,38 @@ arg_default_type_strip_regex = re.compile(r"::[\w\.]+(\[\])?$")
|
|||
normalize_ref = lambda ref: ref if ref[0] == '"' else '"' + ref.lower() + '"'
|
||||
|
||||
|
||||
def generate_alias(tbl):
|
||||
def generate_alias(tbl, alias_map=None):
|
||||
"""Generate a table alias, consisting of all upper-case letters in
|
||||
the table name, or, if there are no upper-case letters, the first letter +
|
||||
all letters preceded by _
|
||||
param tbl - unescaped name of the table to alias
|
||||
"""
|
||||
if alias_map and tbl in alias_map:
|
||||
return alias_map[tbl]
|
||||
return "".join(
|
||||
[l for l in tbl if l.isupper()]
|
||||
or [l for l, prev in zip(tbl, "_" + tbl) if prev == "_" and l != "_"]
|
||||
)
|
||||
|
||||
|
||||
class InvalidMapFile(ValueError):
|
||||
pass
|
||||
|
||||
|
||||
def load_alias_map_file(path):
|
||||
try:
|
||||
with open(path) as fo:
|
||||
alias_map = json.load(fo)
|
||||
except FileNotFoundError as err:
|
||||
raise InvalidMapFile(
|
||||
f"Cannot read alias_map_file - {err.filename} does not exist"
|
||||
)
|
||||
except json.JSONDecodeError:
|
||||
raise InvalidMapFile(f"Cannot read alias_map_file - {path} is not valid json")
|
||||
else:
|
||||
return alias_map
|
||||
|
||||
|
||||
class PGCompleter(Completer):
|
||||
# keywords_tree: A dict mapping keywords to well known following keywords.
|
||||
# e.g. 'CREATE': ['TABLE', 'USER', ...],
|
||||
|
@ -100,6 +121,11 @@ class PGCompleter(Completer):
|
|||
self.call_arg_oneliner_max = settings.get("call_arg_oneliner_max", 2)
|
||||
self.search_path_filter = settings.get("search_path_filter")
|
||||
self.generate_aliases = settings.get("generate_aliases")
|
||||
alias_map_file = settings.get("alias_map_file")
|
||||
if alias_map_file is not None:
|
||||
self.alias_map = load_alias_map_file(alias_map_file)
|
||||
else:
|
||||
self.alias_map = None
|
||||
self.casing_file = settings.get("casing_file")
|
||||
self.insert_col_skip_patterns = [
|
||||
re.compile(pattern)
|
||||
|
@ -157,7 +183,6 @@ class PGCompleter(Completer):
|
|||
self.all_completions.update(additional_keywords)
|
||||
|
||||
def extend_schemata(self, schemata):
|
||||
|
||||
# schemata is a list of schema names
|
||||
schemata = self.escaped_names(schemata)
|
||||
metadata = self.dbmetadata["tables"]
|
||||
|
@ -226,7 +251,6 @@ class PGCompleter(Completer):
|
|||
self.all_completions.add(colname)
|
||||
|
||||
def extend_functions(self, func_data):
|
||||
|
||||
# func_data is a list of function metadata namedtuples
|
||||
|
||||
# dbmetadata['schema_name']['functions']['function_name'] should return
|
||||
|
@ -260,7 +284,6 @@ class PGCompleter(Completer):
|
|||
}
|
||||
|
||||
def extend_foreignkeys(self, fk_data):
|
||||
|
||||
# fk_data is a list of ForeignKey namedtuples, with fields
|
||||
# parentschema, childschema, parenttable, childtable,
|
||||
# parentcolumns, childcolumns
|
||||
|
@ -283,7 +306,6 @@ class PGCompleter(Completer):
|
|||
parcolmeta.foreignkeys.append(fk)
|
||||
|
||||
def extend_datatypes(self, type_data):
|
||||
|
||||
# dbmetadata['datatypes'][schema_name][type_name] should store type
|
||||
# metadata, such as composite type field names. Currently, we're not
|
||||
# storing any metadata beyond typename, so just store None
|
||||
|
@ -697,7 +719,6 @@ class PGCompleter(Completer):
|
|||
return self.find_matches(word_before_cursor, conds, meta="join")
|
||||
|
||||
def get_function_matches(self, suggestion, word_before_cursor, alias=False):
|
||||
|
||||
if suggestion.usage == "from":
|
||||
# Only suggest functions allowed in FROM clause
|
||||
|
||||
|
|
|
@ -1,7 +1,8 @@
|
|||
import ipaddress
|
||||
import logging
|
||||
import traceback
|
||||
from collections import namedtuple
|
||||
|
||||
import re
|
||||
import pgspecial as special
|
||||
import psycopg
|
||||
import psycopg.sql
|
||||
|
@ -17,6 +18,27 @@ ViewDef = namedtuple(
|
|||
)
|
||||
|
||||
|
||||
# we added this funcion to strip beginning comments
|
||||
# because sqlparse didn't handle tem well. It won't be needed if sqlparse
|
||||
# does parsing of this situation better
|
||||
|
||||
|
||||
def remove_beginning_comments(command):
|
||||
# Regular expression pattern to match comments
|
||||
pattern = r"^(/\*.*?\*/|--.*?)(?:\n|$)"
|
||||
|
||||
# Find and remove all comments from the beginning
|
||||
cleaned_command = command
|
||||
comments = []
|
||||
match = re.match(pattern, cleaned_command, re.DOTALL)
|
||||
while match:
|
||||
comments.append(match.group())
|
||||
cleaned_command = cleaned_command[len(match.group()) :].lstrip()
|
||||
match = re.match(pattern, cleaned_command, re.DOTALL)
|
||||
|
||||
return [cleaned_command, comments]
|
||||
|
||||
|
||||
def register_typecasters(connection):
|
||||
"""Casts date and timestamp values to string, resolves issues with out-of-range
|
||||
dates (e.g. BC) which psycopg can't handle"""
|
||||
|
@ -76,7 +98,6 @@ class ProtocolSafeCursor(psycopg.Cursor):
|
|||
|
||||
|
||||
class PGExecute:
|
||||
|
||||
# The boolean argument to the current_schemas function indicates whether
|
||||
# implicit schemas, e.g. pg_catalog
|
||||
search_path_query = """
|
||||
|
@ -182,7 +203,6 @@ class PGExecute:
|
|||
dsn=None,
|
||||
**kwargs,
|
||||
):
|
||||
|
||||
conn_params = self._conn_params.copy()
|
||||
|
||||
new_params = {
|
||||
|
@ -205,7 +225,11 @@ class PGExecute:
|
|||
|
||||
conn_params.update({k: v for k, v in new_params.items() if v})
|
||||
|
||||
conn_info = make_conninfo(**conn_params)
|
||||
if "dsn" in conn_params:
|
||||
other_params = {k: v for k, v in conn_params.items() if k != "dsn"}
|
||||
conn_info = make_conninfo(conn_params["dsn"], **other_params)
|
||||
else:
|
||||
conn_info = make_conninfo(**conn_params)
|
||||
conn = psycopg.connect(conn_info)
|
||||
conn.cursor_factory = ProtocolSafeCursor
|
||||
|
||||
|
@ -255,6 +279,11 @@ class PGExecute:
|
|||
|
||||
@property
|
||||
def short_host(self):
|
||||
try:
|
||||
ipaddress.ip_address(self.host)
|
||||
return self.host
|
||||
except ValueError:
|
||||
pass
|
||||
if "," in self.host:
|
||||
host, _, _ = self.host.partition(",")
|
||||
else:
|
||||
|
@ -314,21 +343,20 @@ class PGExecute:
|
|||
# sql parse doesn't split on a comment first + special
|
||||
# so we're going to do it
|
||||
|
||||
sqltemp = []
|
||||
removed_comments = []
|
||||
sqlarr = []
|
||||
cleaned_command = ""
|
||||
|
||||
if statement.startswith("--"):
|
||||
sqltemp = statement.split("\n")
|
||||
sqlarr.append(sqltemp[0])
|
||||
for i in sqlparse.split(sqltemp[1]):
|
||||
sqlarr.append(i)
|
||||
elif statement.startswith("/*"):
|
||||
sqltemp = statement.split("*/")
|
||||
sqltemp[0] = sqltemp[0] + "*/"
|
||||
for i in sqlparse.split(sqltemp[1]):
|
||||
sqlarr.append(i)
|
||||
else:
|
||||
sqlarr = sqlparse.split(statement)
|
||||
# could skip if statement doesn't match ^-- or ^/*
|
||||
cleaned_command, removed_comments = remove_beginning_comments(statement)
|
||||
|
||||
sqlarr = sqlparse.split(cleaned_command)
|
||||
|
||||
# now re-add the beginning comments if there are any, so that they show up in
|
||||
# log files etc when running these commands
|
||||
|
||||
if len(removed_comments) > 0:
|
||||
sqlarr = removed_comments + sqlarr
|
||||
|
||||
# run each sql query
|
||||
for sql in sqlarr:
|
||||
|
@ -414,7 +442,11 @@ class PGExecute:
|
|||
|
||||
def handle_notices(n):
|
||||
nonlocal title
|
||||
title = f"{n.message_primary}\n{n.message_detail}\n{title}"
|
||||
title = f"{title}"
|
||||
if n.message_primary is not None:
|
||||
title = f"{title}\n{n.message_primary}"
|
||||
if n.message_detail is not None:
|
||||
title = f"{title}\n{n.message_detail}"
|
||||
|
||||
self.conn.add_notice_handler(handle_notices)
|
||||
|
||||
|
|
|
@ -1,18 +1,14 @@
|
|||
from pkg_resources import packaging
|
||||
|
||||
import prompt_toolkit
|
||||
from prompt_toolkit.key_binding.vi_state import InputMode
|
||||
from prompt_toolkit.application import get_app
|
||||
|
||||
parse_version = packaging.version.parse
|
||||
|
||||
vi_modes = {
|
||||
InputMode.INSERT: "I",
|
||||
InputMode.NAVIGATION: "N",
|
||||
InputMode.REPLACE: "R",
|
||||
InputMode.INSERT_MULTIPLE: "M",
|
||||
}
|
||||
if parse_version(prompt_toolkit.__version__) >= parse_version("3.0.6"):
|
||||
# REPLACE_SINGLE is available in prompt_toolkit >= 3.0.6
|
||||
if "REPLACE_SINGLE" in {e.name for e in InputMode}:
|
||||
vi_modes[InputMode.REPLACE_SINGLE] = "R"
|
||||
|
||||
|
||||
|
|
|
@ -146,7 +146,7 @@ class Visualizer:
|
|||
elif self.explain.get("Max Rows") < plan["Actual Rows"]:
|
||||
self.explain["Max Rows"] = plan["Actual Rows"]
|
||||
|
||||
if not self.explain.get("MaxCost"):
|
||||
if not self.explain.get("Max Cost"):
|
||||
self.explain["Max Cost"] = plan["Actual Cost"]
|
||||
elif self.explain.get("Max Cost") < plan["Actual Cost"]:
|
||||
self.explain["Max Cost"] = plan["Actual Cost"]
|
||||
|
@ -171,7 +171,7 @@ class Visualizer:
|
|||
return self.warning_format("%.2f ms" % value)
|
||||
elif value < 60000:
|
||||
return self.critical_format(
|
||||
"%.2f s" % (value / 2000.0),
|
||||
"%.2f s" % (value / 1000.0),
|
||||
)
|
||||
else:
|
||||
return self.critical_format(
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
[tool.black]
|
||||
line-length = 88
|
||||
target-version = ['py36']
|
||||
target-version = ['py38']
|
||||
include = '\.pyi?$'
|
||||
exclude = '''
|
||||
/(
|
||||
|
@ -19,4 +19,3 @@ exclude = '''
|
|||
| tests/data
|
||||
)/
|
||||
'''
|
||||
|
||||
|
|
|
@ -57,7 +57,7 @@ def version(version_file):
|
|||
|
||||
def commit_for_release(version_file, ver):
|
||||
run_step("git", "reset")
|
||||
run_step("git", "add", version_file)
|
||||
run_step("git", "add", "-u")
|
||||
run_step("git", "commit", "--message", "Releasing version {}".format(ver))
|
||||
|
||||
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
pytest>=2.7.0
|
||||
tox>=1.9.2
|
||||
behave>=1.2.4
|
||||
black>=22.3.0
|
||||
black>=23.3.0
|
||||
pexpect==3.3; platform_system != "Windows"
|
||||
pre-commit>=1.16.0
|
||||
coverage>=5.0.4
|
||||
|
@ -10,4 +10,4 @@ docutils>=0.13.1
|
|||
autopep8>=1.3.3
|
||||
twine>=1.11.0
|
||||
wheel>=0.33.6
|
||||
sshtunnel>=0.4.0
|
||||
sshtunnel>=0.4.0
|
||||
|
|
13
setup.py
13
setup.py
|
@ -12,10 +12,10 @@ install_requirements = [
|
|||
# We still need to use pt-2 unless pt-3 released on Fedora32
|
||||
# see: https://github.com/dbcli/pgcli/pull/1197
|
||||
"prompt_toolkit>=2.0.6,<4.0.0",
|
||||
"psycopg >= 3.0.14",
|
||||
"psycopg >= 3.0.14; sys_platform != 'win32'",
|
||||
"psycopg-binary >= 3.0.14; sys_platform == 'win32'",
|
||||
"sqlparse >=0.3.0,<0.5",
|
||||
"configobj >= 5.0.6",
|
||||
"pendulum>=2.1.0",
|
||||
"cli_helpers[styles] >= 2.2.1",
|
||||
]
|
||||
|
||||
|
@ -27,11 +27,6 @@ install_requirements = [
|
|||
if platform.system() != "Windows" and not platform.system().startswith("CYGWIN"):
|
||||
install_requirements.append("setproctitle >= 1.1.9")
|
||||
|
||||
# Windows will require the binary psycopg to run pgcli
|
||||
if platform.system() == "Windows":
|
||||
install_requirements.append("psycopg-binary >= 3.0.14")
|
||||
|
||||
|
||||
setup(
|
||||
name="pgcli",
|
||||
author="Pgcli Core Team",
|
||||
|
@ -51,7 +46,7 @@ setup(
|
|||
"keyring": ["keyring >= 12.2.0"],
|
||||
"sshtunnel": ["sshtunnel >= 0.4.0"],
|
||||
},
|
||||
python_requires=">=3.7",
|
||||
python_requires=">=3.8",
|
||||
entry_points="""
|
||||
[console_scripts]
|
||||
pgcli=pgcli.main:cli
|
||||
|
@ -62,11 +57,11 @@ setup(
|
|||
"Operating System :: Unix",
|
||||
"Programming Language :: Python",
|
||||
"Programming Language :: Python :: 3",
|
||||
"Programming Language :: Python :: 3.7",
|
||||
"Programming Language :: Python :: 3.8",
|
||||
"Programming Language :: Python :: 3.9",
|
||||
"Programming Language :: Python :: 3.10",
|
||||
"Programming Language :: Python :: 3.11",
|
||||
"Programming Language :: Python :: 3.12",
|
||||
"Programming Language :: SQL",
|
||||
"Topic :: Database",
|
||||
"Topic :: Database :: Front-Ends",
|
||||
|
|
|
@ -23,6 +23,23 @@ Feature: run the cli,
|
|||
When we send "ctrl + d"
|
||||
then dbcli exits
|
||||
|
||||
Scenario: confirm exit when a transaction is ongoing
|
||||
When we begin transaction
|
||||
and we try to send "ctrl + d"
|
||||
then we see ongoing transaction message
|
||||
when we send "c"
|
||||
then dbcli exits
|
||||
|
||||
Scenario: cancel exit when a transaction is ongoing
|
||||
When we begin transaction
|
||||
and we try to send "ctrl + d"
|
||||
then we see ongoing transaction message
|
||||
when we send "a"
|
||||
then we see dbcli prompt
|
||||
when we rollback transaction
|
||||
when we send "ctrl + d"
|
||||
then dbcli exits
|
||||
|
||||
Scenario: interrupt current query via "ctrl + c"
|
||||
When we send sleep query
|
||||
and we send "ctrl + c"
|
||||
|
|
|
@ -164,10 +164,24 @@ def before_step(context, _):
|
|||
context.atprompt = False
|
||||
|
||||
|
||||
def is_known_problem(scenario):
|
||||
"""TODO: why is this not working in 3.12?"""
|
||||
if sys.version_info >= (3, 12):
|
||||
return scenario.name in (
|
||||
'interrupt current query via "ctrl + c"',
|
||||
"run the cli with --username",
|
||||
"run the cli with --user",
|
||||
"run the cli with --port",
|
||||
)
|
||||
return False
|
||||
|
||||
|
||||
def before_scenario(context, scenario):
|
||||
if scenario.name == "list databases":
|
||||
# not using the cli for that
|
||||
return
|
||||
if is_known_problem(scenario):
|
||||
scenario.skip()
|
||||
currentdb = None
|
||||
if "pgbouncer" in scenario.feature.tags:
|
||||
if context.pgbouncer_available:
|
||||
|
|
|
@ -64,13 +64,22 @@ def step_ctrl_d(context):
|
|||
"""
|
||||
Send Ctrl + D to hopefully exit.
|
||||
"""
|
||||
step_try_to_ctrl_d(context)
|
||||
context.cli.expect(pexpect.EOF, timeout=5)
|
||||
context.exit_sent = True
|
||||
|
||||
|
||||
@when('we try to send "ctrl + d"')
|
||||
def step_try_to_ctrl_d(context):
|
||||
"""
|
||||
Send Ctrl + D, perhaps exiting, perhaps not (if a transaction is
|
||||
ongoing).
|
||||
"""
|
||||
# turn off pager before exiting
|
||||
context.cli.sendcontrol("c")
|
||||
context.cli.sendline(r"\pset pager off")
|
||||
wrappers.wait_prompt(context)
|
||||
context.cli.sendcontrol("d")
|
||||
context.cli.expect(pexpect.EOF, timeout=5)
|
||||
context.exit_sent = True
|
||||
|
||||
|
||||
@when('we send "ctrl + c"')
|
||||
|
@ -87,6 +96,14 @@ def step_see_cancelled_query_warning(context):
|
|||
wrappers.expect_exact(context, "cancelled query", timeout=2)
|
||||
|
||||
|
||||
@then("we see ongoing transaction message")
|
||||
def step_see_ongoing_transaction_error(context):
|
||||
"""
|
||||
Make sure we receive the warning that a transaction is ongoing.
|
||||
"""
|
||||
context.cli.expect("A transaction is ongoing.", timeout=2)
|
||||
|
||||
|
||||
@when("we send sleep query")
|
||||
def step_send_sleep_15_seconds(context):
|
||||
"""
|
||||
|
@ -189,7 +206,7 @@ def step_resppond_to_destructive_command(context, response):
|
|||
"""Respond to destructive command."""
|
||||
wrappers.expect_exact(
|
||||
context,
|
||||
"You're about to run a destructive command.\r\nDo you want to proceed? (y/n):",
|
||||
"You're about to run a destructive command.\r\nDo you want to proceed? [y/N]:",
|
||||
timeout=2,
|
||||
)
|
||||
context.cli.sendline(response.strip())
|
||||
|
@ -199,3 +216,16 @@ def step_resppond_to_destructive_command(context, response):
|
|||
def step_send_password(context):
|
||||
wrappers.expect_exact(context, "Password for", timeout=5)
|
||||
context.cli.sendline(context.conf["pass"] or "DOES NOT MATTER")
|
||||
|
||||
|
||||
@when('we send "{text}"')
|
||||
def step_send_text(context, text):
|
||||
context.cli.sendline(text)
|
||||
# Try to detect whether we are exiting. If so, set `exit_sent`
|
||||
# so that `after_scenario` correctly cleans up.
|
||||
try:
|
||||
context.cli.expect(pexpect.EOF, timeout=0.2)
|
||||
except pexpect.TIMEOUT:
|
||||
pass
|
||||
else:
|
||||
context.exit_sent = True
|
||||
|
|
|
@ -3,6 +3,7 @@ Steps for behavioral style tests are defined in this module.
|
|||
Each step is defined by the string decorating it.
|
||||
This string is used to call the step in "*.feature" file.
|
||||
"""
|
||||
|
||||
import pexpect
|
||||
|
||||
from behave import when, then
|
||||
|
|
|
@ -16,7 +16,7 @@ def step_prepare_data(context):
|
|||
context.cli.sendline("drop table if exists a;")
|
||||
wrappers.expect_exact(
|
||||
context,
|
||||
"You're about to run a destructive command.\r\nDo you want to proceed? (y/n):",
|
||||
"You're about to run a destructive command.\r\nDo you want to proceed? [y/N]:",
|
||||
timeout=2,
|
||||
)
|
||||
context.cli.sendline("y")
|
||||
|
|
|
@ -3,10 +3,7 @@ import pexpect
|
|||
from pgcli.main import COLOR_CODE_REGEX
|
||||
import textwrap
|
||||
|
||||
try:
|
||||
from StringIO import StringIO
|
||||
except ImportError:
|
||||
from io import StringIO
|
||||
from io import StringIO
|
||||
|
||||
|
||||
def expect_exact(context, expected, timeout):
|
||||
|
|
|
@ -34,7 +34,7 @@ def test_output_sql_insert():
|
|||
"Jackson",
|
||||
"jackson_test@gmail.com",
|
||||
"132454789",
|
||||
"",
|
||||
None,
|
||||
"2022-09-09 19:44:32.712343+08",
|
||||
"2022-09-09 19:44:32.712343+08",
|
||||
]
|
||||
|
@ -58,7 +58,7 @@ def test_output_sql_insert():
|
|||
output_list = [l for l in output]
|
||||
expected = [
|
||||
'INSERT INTO "user" ("id", "name", "email", "phone", "description", "created_at", "updated_at") VALUES',
|
||||
" ('1', 'Jackson', 'jackson_test@gmail.com', '132454789', '', "
|
||||
" ('1', 'Jackson', 'jackson_test@gmail.com', '132454789', NULL, "
|
||||
+ "'2022-09-09 19:44:32.712343+08', '2022-09-09 19:44:32.712343+08')",
|
||||
";",
|
||||
]
|
||||
|
|
|
@ -0,0 +1,17 @@
|
|||
from unittest.mock import patch
|
||||
|
||||
from click.testing import CliRunner
|
||||
|
||||
from pgcli.main import cli
|
||||
from pgcli.pgexecute import PGExecute
|
||||
|
||||
|
||||
def test_application_name_in_env():
|
||||
runner = CliRunner()
|
||||
app_name = "wonderful_app"
|
||||
with patch.object(PGExecute, "__init__") as mock_pgxecute:
|
||||
runner.invoke(
|
||||
cli, ["127.0.0.1:5432/hello", "user"], env={"PGAPPNAME": app_name}
|
||||
)
|
||||
kwargs = mock_pgxecute.call_args.kwargs
|
||||
assert kwargs.get("application_name") == app_name
|
|
@ -11,6 +11,7 @@ except ImportError:
|
|||
|
||||
from pgcli.main import (
|
||||
obfuscate_process_password,
|
||||
duration_in_words,
|
||||
format_output,
|
||||
PGCli,
|
||||
OutputSettings,
|
||||
|
@ -216,7 +217,6 @@ def pset_pager_mocks():
|
|||
with mock.patch("pgcli.main.click.echo") as mock_echo, mock.patch(
|
||||
"pgcli.main.click.echo_via_pager"
|
||||
) as mock_echo_via_pager, mock.patch.object(cli, "prompt_app") as mock_app:
|
||||
|
||||
yield cli, mock_echo, mock_echo_via_pager, mock_app
|
||||
|
||||
|
||||
|
@ -297,6 +297,22 @@ def test_i_works(tmpdir, executor):
|
|||
run(executor, statement, pgspecial=cli.pgspecial)
|
||||
|
||||
|
||||
@dbtest
|
||||
def test_echo_works(executor):
|
||||
cli = PGCli(pgexecute=executor)
|
||||
statement = r"\echo asdf"
|
||||
result = run(executor, statement, pgspecial=cli.pgspecial)
|
||||
assert result == ["asdf"]
|
||||
|
||||
|
||||
@dbtest
|
||||
def test_qecho_works(executor):
|
||||
cli = PGCli(pgexecute=executor)
|
||||
statement = r"\qecho asdf"
|
||||
result = run(executor, statement, pgspecial=cli.pgspecial)
|
||||
assert result == ["asdf"]
|
||||
|
||||
|
||||
@dbtest
|
||||
def test_watch_works(executor):
|
||||
cli = PGCli(pgexecute=executor)
|
||||
|
@ -371,7 +387,6 @@ def test_quoted_db_uri(tmpdir):
|
|||
|
||||
|
||||
def test_pg_service_file(tmpdir):
|
||||
|
||||
with mock.patch.object(PGCli, "connect") as mock_connect:
|
||||
cli = PGCli(pgclirc_file=str(tmpdir.join("rcfile")))
|
||||
with open(tmpdir.join(".pg_service.conf").strpath, "w") as service_conf:
|
||||
|
@ -474,3 +489,28 @@ def test_application_name_db_uri(tmpdir):
|
|||
mock_pgexecute.assert_called_with(
|
||||
"bar", "bar", "", "baz.com", "", "", application_name="cow"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"duration_in_seconds,words",
|
||||
[
|
||||
(0, "0 seconds"),
|
||||
(0.0009, "0.001 second"),
|
||||
(0.0005, "0.001 second"),
|
||||
(0.0004, "0.0 second"), # not perfect, but will do
|
||||
(0.2, "0.2 second"),
|
||||
(1, "1 second"),
|
||||
(1.4, "1 second"),
|
||||
(2, "2 seconds"),
|
||||
(3.4, "3 seconds"),
|
||||
(60, "1 minute"),
|
||||
(61, "1 minute 1 second"),
|
||||
(123, "2 minutes 3 seconds"),
|
||||
(3600, "1 hour"),
|
||||
(7235, "2 hours 35 seconds"),
|
||||
(9005, "2 hours 30 minutes 5 seconds"),
|
||||
(86401, "24 hours 1 second"),
|
||||
],
|
||||
)
|
||||
def test_duration_in_words(duration_in_seconds, words):
|
||||
assert duration_in_words(duration_in_seconds) == words
|
||||
|
|
|
@ -0,0 +1,76 @@
|
|||
import pytest
|
||||
from pgcli import pgcompleter
|
||||
|
||||
|
||||
def test_load_alias_map_file_missing_file():
|
||||
with pytest.raises(
|
||||
pgcompleter.InvalidMapFile,
|
||||
match=r"Cannot read alias_map_file - /path/to/non-existent/file.json does not exist$",
|
||||
):
|
||||
pgcompleter.load_alias_map_file("/path/to/non-existent/file.json")
|
||||
|
||||
|
||||
def test_load_alias_map_file_invalid_json(tmp_path):
|
||||
fpath = tmp_path / "foo.json"
|
||||
fpath.write_text("this is not valid json")
|
||||
with pytest.raises(pgcompleter.InvalidMapFile, match=r".*is not valid json$"):
|
||||
pgcompleter.load_alias_map_file(str(fpath))
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"table_name, alias",
|
||||
[
|
||||
("SomE_Table", "SET"),
|
||||
("SOmeTabLe", "SOTL"),
|
||||
("someTable", "T"),
|
||||
],
|
||||
)
|
||||
def test_generate_alias_uses_upper_case_letters_from_name(table_name, alias):
|
||||
assert pgcompleter.generate_alias(table_name) == alias
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"table_name, alias",
|
||||
[
|
||||
("some_tab_le", "stl"),
|
||||
("s_ome_table", "sot"),
|
||||
("sometable", "s"),
|
||||
],
|
||||
)
|
||||
def test_generate_alias_uses_first_char_and_every_preceded_by_underscore(
|
||||
table_name, alias
|
||||
):
|
||||
assert pgcompleter.generate_alias(table_name) == alias
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"table_name, alias_map, alias",
|
||||
[
|
||||
("some_table", {"some_table": "my_alias"}, "my_alias"),
|
||||
],
|
||||
)
|
||||
def test_generate_alias_can_use_alias_map(table_name, alias_map, alias):
|
||||
assert pgcompleter.generate_alias(table_name, alias_map) == alias
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"table_name, alias_map, alias",
|
||||
[
|
||||
("SomeTable", {"SomeTable": "my_alias"}, "my_alias"),
|
||||
],
|
||||
)
|
||||
def test_generate_alias_prefers_alias_over_upper_case_name(
|
||||
table_name, alias_map, alias
|
||||
):
|
||||
assert pgcompleter.generate_alias(table_name, alias_map) == alias
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"table_name, alias",
|
||||
[
|
||||
("Some_tablE", "SE"),
|
||||
("SomeTab_le", "ST"),
|
||||
],
|
||||
)
|
||||
def test_generate_alias_prefers_upper_case_name_over_underscore_name(table_name, alias):
|
||||
assert pgcompleter.generate_alias(table_name) == alias
|
|
@ -304,9 +304,7 @@ def test_execute_from_commented_file_that_executes_another_file(
|
|||
|
||||
@dbtest
|
||||
def test_execute_commented_first_line_and_special(executor, pgspecial, tmpdir):
|
||||
# https://github.com/dbcli/pgcli/issues/1362
|
||||
|
||||
# just some base caes that should work also
|
||||
# just some base cases that should work also
|
||||
statement = "--comment\nselect now();"
|
||||
result = run(executor, statement, pgspecial=pgspecial)
|
||||
assert result != None
|
||||
|
@ -317,23 +315,43 @@ def test_execute_commented_first_line_and_special(executor, pgspecial, tmpdir):
|
|||
assert result != None
|
||||
assert result[1].find("now") >= 0
|
||||
|
||||
statement = "/*comment\ncomment line2*/\nselect now();"
|
||||
result = run(executor, statement, pgspecial=pgspecial)
|
||||
assert result != None
|
||||
assert result[1].find("now") >= 0
|
||||
|
||||
# https://github.com/dbcli/pgcli/issues/1362
|
||||
statement = "--comment\n\\h"
|
||||
result = run(executor, statement, pgspecial=pgspecial)
|
||||
assert result != None
|
||||
assert result[1].find("ALTER") >= 0
|
||||
assert result[1].find("ABORT") >= 0
|
||||
|
||||
statement = "--comment1\n--comment2\n\\h"
|
||||
result = run(executor, statement, pgspecial=pgspecial)
|
||||
assert result != None
|
||||
assert result[1].find("ALTER") >= 0
|
||||
assert result[1].find("ABORT") >= 0
|
||||
|
||||
statement = "/*comment*/\n\h;"
|
||||
result = run(executor, statement, pgspecial=pgspecial)
|
||||
assert result != None
|
||||
assert result[1].find("ALTER") >= 0
|
||||
assert result[1].find("ABORT") >= 0
|
||||
|
||||
statement = """/*comment1
|
||||
comment2*/
|
||||
\h"""
|
||||
result = run(executor, statement, pgspecial=pgspecial)
|
||||
assert result != None
|
||||
assert result[1].find("ALTER") >= 0
|
||||
assert result[1].find("ABORT") >= 0
|
||||
|
||||
statement = """/*comment1
|
||||
comment2*/
|
||||
/*comment 3
|
||||
comment4*/
|
||||
\\h"""
|
||||
result = run(executor, statement, pgspecial=pgspecial)
|
||||
assert result != None
|
||||
assert result[1].find("ALTER") >= 0
|
||||
assert result[1].find("ABORT") >= 0
|
||||
|
||||
statement = " /*comment*/\n\h;"
|
||||
result = run(executor, statement, pgspecial=pgspecial)
|
||||
assert result != None
|
||||
|
@ -352,6 +370,126 @@ def test_execute_commented_first_line_and_special(executor, pgspecial, tmpdir):
|
|||
assert result[1].find("ALTER") >= 0
|
||||
assert result[1].find("ABORT") >= 0
|
||||
|
||||
statement = """\\h /*comment4 */"""
|
||||
result = run(executor, statement, pgspecial=pgspecial)
|
||||
print(result)
|
||||
assert result != None
|
||||
assert result[0].find("No help") >= 0
|
||||
|
||||
# TODO: we probably don't want to do this but sqlparse is not parsing things well
|
||||
# we relly want it to find help but right now, sqlparse isn't dropping the /*comment*/
|
||||
# style comments after command
|
||||
|
||||
statement = """/*comment1*/
|
||||
\h
|
||||
/*comment4 */"""
|
||||
result = run(executor, statement, pgspecial=pgspecial)
|
||||
assert result != None
|
||||
assert result[0].find("No help") >= 0
|
||||
|
||||
# TODO: same for this one
|
||||
statement = """/*comment1
|
||||
comment3
|
||||
comment2*/
|
||||
\\h
|
||||
/*comment4
|
||||
comment5
|
||||
comment6*/"""
|
||||
result = run(executor, statement, pgspecial=pgspecial)
|
||||
assert result != None
|
||||
assert result[0].find("No help") >= 0
|
||||
|
||||
|
||||
@dbtest
|
||||
def test_execute_commented_first_line_and_normal(executor, pgspecial, tmpdir):
|
||||
# https://github.com/dbcli/pgcli/issues/1403
|
||||
|
||||
# just some base cases that should work also
|
||||
statement = "--comment\nselect now();"
|
||||
result = run(executor, statement, pgspecial=pgspecial)
|
||||
assert result != None
|
||||
assert result[1].find("now") >= 0
|
||||
|
||||
statement = "/*comment*/\nselect now();"
|
||||
result = run(executor, statement, pgspecial=pgspecial)
|
||||
assert result != None
|
||||
assert result[1].find("now") >= 0
|
||||
|
||||
# this simulates the original error (1403) without having to add/drop tables
|
||||
# since it was just an error on reading input files and not the actual
|
||||
# command itself
|
||||
|
||||
# test that the statement works
|
||||
statement = """VALUES (1, 'one'), (2, 'two'), (3, 'three');"""
|
||||
result = run(executor, statement, pgspecial=pgspecial)
|
||||
assert result != None
|
||||
assert result[5].find("three") >= 0
|
||||
|
||||
# test the statement with a \n in the middle
|
||||
statement = """VALUES (1, 'one'),\n (2, 'two'), (3, 'three');"""
|
||||
result = run(executor, statement, pgspecial=pgspecial)
|
||||
assert result != None
|
||||
assert result[5].find("three") >= 0
|
||||
|
||||
# test the statement with a newline in the middle
|
||||
statement = """VALUES (1, 'one'),
|
||||
(2, 'two'), (3, 'three');"""
|
||||
result = run(executor, statement, pgspecial=pgspecial)
|
||||
assert result != None
|
||||
assert result[5].find("three") >= 0
|
||||
|
||||
# now add a single comment line
|
||||
statement = """--comment\nVALUES (1, 'one'), (2, 'two'), (3, 'three');"""
|
||||
result = run(executor, statement, pgspecial=pgspecial)
|
||||
assert result != None
|
||||
assert result[5].find("three") >= 0
|
||||
|
||||
# doing without special char \n
|
||||
statement = """--comment
|
||||
VALUES (1,'one'),
|
||||
(2, 'two'), (3, 'three');"""
|
||||
result = run(executor, statement, pgspecial=pgspecial)
|
||||
assert result != None
|
||||
assert result[5].find("three") >= 0
|
||||
|
||||
# two comment lines
|
||||
statement = """--comment\n--comment2\nVALUES (1,'one'), (2, 'two'), (3, 'three');"""
|
||||
result = run(executor, statement, pgspecial=pgspecial)
|
||||
assert result != None
|
||||
assert result[5].find("three") >= 0
|
||||
|
||||
# doing without special char \n
|
||||
statement = """--comment
|
||||
--comment2
|
||||
VALUES (1,'one'), (2, 'two'), (3, 'three');
|
||||
"""
|
||||
result = run(executor, statement, pgspecial=pgspecial)
|
||||
assert result != None
|
||||
assert result[5].find("three") >= 0
|
||||
|
||||
# multiline comment + newline in middle of the statement
|
||||
statement = """/*comment
|
||||
comment2
|
||||
comment3*/
|
||||
VALUES (1,'one'),
|
||||
(2, 'two'), (3, 'three');"""
|
||||
result = run(executor, statement, pgspecial=pgspecial)
|
||||
assert result != None
|
||||
assert result[5].find("three") >= 0
|
||||
|
||||
# multiline comment + newline in middle of the statement
|
||||
# + comments after the statement
|
||||
statement = """/*comment
|
||||
comment2
|
||||
comment3*/
|
||||
VALUES (1,'one'),
|
||||
(2, 'two'), (3, 'three');
|
||||
--comment4
|
||||
--comment5"""
|
||||
result = run(executor, statement, pgspecial=pgspecial)
|
||||
assert result != None
|
||||
assert result[5].find("three") >= 0
|
||||
|
||||
|
||||
@dbtest
|
||||
def test_multiple_queries_same_line(executor):
|
||||
|
@ -552,6 +690,38 @@ def test_function_definition(executor):
|
|||
result = executor.function_definition("the_number_three")
|
||||
|
||||
|
||||
@dbtest
|
||||
def test_function_notice_order(executor):
|
||||
run(
|
||||
executor,
|
||||
"""
|
||||
CREATE OR REPLACE FUNCTION demo_order() RETURNS VOID AS
|
||||
$$
|
||||
BEGIN
|
||||
RAISE NOTICE 'first';
|
||||
RAISE NOTICE 'second';
|
||||
RAISE NOTICE 'third';
|
||||
RAISE NOTICE 'fourth';
|
||||
RAISE NOTICE 'fifth';
|
||||
RAISE NOTICE 'sixth';
|
||||
END;
|
||||
$$
|
||||
LANGUAGE plpgsql;
|
||||
""",
|
||||
)
|
||||
|
||||
executor.function_definition("demo_order")
|
||||
|
||||
result = run(executor, "select demo_order()")
|
||||
assert "first\nsecond\nthird\nfourth\nfifth\nsixth" in result[0]
|
||||
assert "+------------+" in result[1]
|
||||
assert "| demo_order |" in result[2]
|
||||
assert "|------------|" in result[3]
|
||||
assert "| |" in result[4]
|
||||
assert "+------------+" in result[5]
|
||||
assert "SELECT 1" in result[6]
|
||||
|
||||
|
||||
@dbtest
|
||||
def test_view_definition(executor):
|
||||
run(executor, "create table tbl1 (a text, b numeric)")
|
||||
|
@ -583,6 +753,10 @@ def test_short_host(executor):
|
|||
executor, "host", "localhost1.example.org,localhost2.example.org"
|
||||
):
|
||||
assert executor.short_host == "localhost1"
|
||||
with patch.object(executor, "host", "ec2-11-222-333-444.compute-1.amazonaws.com"):
|
||||
assert executor.short_host == "ec2-11-222-333-444"
|
||||
with patch.object(executor, "host", "1.2.3.4"):
|
||||
assert executor.short_host == "1.2.3.4"
|
||||
|
||||
|
||||
class VirtualCursor:
|
||||
|
|
Loading…
Reference in New Issue