execsql2 2.26.0__py3-none-any.whl → 2.28.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- execsql/cli/commands/__init__.py +1 -0
- execsql/cli/commands/format.py +2 -1
- execsql/cli/commands/listing.py +32 -5
- execsql/cli/commands/lsp.py +64 -0
- execsql/cli/dispatch.py +1 -1
- execsql/cli/help.py +44 -96
- execsql/cli/lint.py +58 -22
- execsql/db/access.py +4 -4
- execsql/db/base.py +17 -3
- execsql/db/dsn.py +2 -2
- execsql/db/duckdb.py +14 -2
- execsql/db/firebird.py +2 -3
- execsql/db/mysql.py +2 -2
- execsql/db/oracle.py +5 -5
- execsql/db/postgres.py +2 -3
- execsql/db/sqlserver.py +2 -2
- execsql/exporters/duckdb.py +2 -2
- execsql/exporters/ods.py +2 -2
- execsql/exporters/templates.py +2 -2
- execsql/exporters/xls.py +3 -3
- execsql/exporters/xlsx.py +2 -2
- execsql/exporters/yaml.py +3 -4
- execsql/format.py +71 -21
- execsql/importers/base.py +6 -10
- execsql/lsp/__init__.py +6 -0
- execsql/lsp/code_actions.py +217 -0
- execsql/lsp/completion.py +203 -0
- execsql/lsp/diagnostics.py +66 -0
- execsql/lsp/document.py +210 -0
- execsql/lsp/folding.py +59 -0
- execsql/lsp/formatting.py +37 -0
- execsql/lsp/hover.py +119 -0
- execsql/lsp/navigation.py +169 -0
- execsql/lsp/rename.py +91 -0
- execsql/lsp/server.py +358 -0
- execsql/metacommands/debug.py +2 -2
- execsql/metacommands/dispatch.py +7 -0
- execsql/metacommands/io_fileops.py +6 -6
- execsql/metacommands/reference.py +1404 -0
- execsql/metacommands/upsert.py +2 -5
- execsql/models.py +11 -0
- execsql/utils/errors.py +16 -0
- {execsql2-2.26.0.dist-info → execsql2-2.28.0.dist-info}/METADATA +34 -18
- {execsql2-2.26.0.dist-info → execsql2-2.28.0.dist-info}/RECORD +62 -49
- {execsql2-2.26.0.data → execsql2-2.28.0.data}/data/execsql2_extras/README.md +0 -0
- {execsql2-2.26.0.data → execsql2-2.28.0.data}/data/execsql2_extras/config_settings.sqlite +0 -0
- {execsql2-2.26.0.data → execsql2-2.28.0.data}/data/execsql2_extras/example_config_prompt.sql +0 -0
- {execsql2-2.26.0.data → execsql2-2.28.0.data}/data/execsql2_extras/make_config_db.sql +0 -0
- {execsql2-2.26.0.data → execsql2-2.28.0.data}/data/execsql2_extras/md_compare.sql +0 -0
- {execsql2-2.26.0.data → execsql2-2.28.0.data}/data/execsql2_extras/md_glossary.sql +0 -0
- {execsql2-2.26.0.data → execsql2-2.28.0.data}/data/execsql2_extras/md_upsert.sql +0 -0
- {execsql2-2.26.0.data → execsql2-2.28.0.data}/data/execsql2_extras/pg_compare.sql +0 -0
- {execsql2-2.26.0.data → execsql2-2.28.0.data}/data/execsql2_extras/pg_glossary.sql +0 -0
- {execsql2-2.26.0.data → execsql2-2.28.0.data}/data/execsql2_extras/pg_upsert.sql +0 -0
- {execsql2-2.26.0.data → execsql2-2.28.0.data}/data/execsql2_extras/script_template.sql +0 -0
- {execsql2-2.26.0.data → execsql2-2.28.0.data}/data/execsql2_extras/ss_compare.sql +0 -0
- {execsql2-2.26.0.data → execsql2-2.28.0.data}/data/execsql2_extras/ss_glossary.sql +0 -0
- {execsql2-2.26.0.data → execsql2-2.28.0.data}/data/execsql2_extras/ss_upsert.sql +0 -0
- {execsql2-2.26.0.dist-info → execsql2-2.28.0.dist-info}/WHEEL +0 -0
- {execsql2-2.26.0.dist-info → execsql2-2.28.0.dist-info}/entry_points.txt +0 -0
- {execsql2-2.26.0.dist-info → execsql2-2.28.0.dist-info}/licenses/LICENSE.txt +0 -0
- {execsql2-2.26.0.dist-info → execsql2-2.28.0.dist-info}/licenses/NOTICE +0 -0
execsql/db/oracle.py
CHANGED
|
@@ -14,7 +14,7 @@ from typing import Any
|
|
|
14
14
|
from execsql.db.base import Database
|
|
15
15
|
from execsql.db.tiers import SupportTier
|
|
16
16
|
from execsql.exceptions import ErrInfo
|
|
17
|
-
from execsql.utils.errors import exception_desc, fatal_error
|
|
17
|
+
from execsql.utils.errors import exception_desc, fatal_error, install_hint
|
|
18
18
|
from execsql.utils.auth import (
|
|
19
19
|
begin_login,
|
|
20
20
|
clear_stored_password,
|
|
@@ -76,7 +76,7 @@ class OracleDatabase(Database):
|
|
|
76
76
|
) -> None:
|
|
77
77
|
if _oracle_driver() is None:
|
|
78
78
|
fatal_error(
|
|
79
|
-
|
|
79
|
+
f"The oracledb module is required to connect to Oracle. {install_hint('oracle')}",
|
|
80
80
|
)
|
|
81
81
|
from execsql.types import dbt_oracle
|
|
82
82
|
|
|
@@ -164,12 +164,12 @@ class OracleDatabase(Database):
|
|
|
164
164
|
else:
|
|
165
165
|
return super().select_data(sql)
|
|
166
166
|
|
|
167
|
-
def select_rowsource(self, sql: str) -> Any:
|
|
167
|
+
def select_rowsource(self, sql: str, *, decode_bytes: bool = True) -> Any:
|
|
168
168
|
"""Return column names and an iterable that yields rows one at a time."""
|
|
169
169
|
if sql[-1:] == ";":
|
|
170
|
-
return super().select_rowsource(sql[:-1])
|
|
170
|
+
return super().select_rowsource(sql[:-1], decode_bytes=decode_bytes)
|
|
171
171
|
else:
|
|
172
|
-
return super().select_rowsource(sql)
|
|
172
|
+
return super().select_rowsource(sql, decode_bytes=decode_bytes)
|
|
173
173
|
|
|
174
174
|
def select_rowdict(self, sql: str) -> Any:
|
|
175
175
|
"""Return column names and an iterable that yields rows as dictionaries."""
|
execsql/db/postgres.py
CHANGED
|
@@ -15,7 +15,7 @@ from typing import Any
|
|
|
15
15
|
|
|
16
16
|
from execsql.db.base import Database
|
|
17
17
|
from execsql.exceptions import ErrInfo
|
|
18
|
-
from execsql.utils.errors import exception_desc, fatal_error
|
|
18
|
+
from execsql.utils.errors import exception_desc, fatal_error, install_hint
|
|
19
19
|
from execsql.utils.auth import (
|
|
20
20
|
begin_login,
|
|
21
21
|
clear_stored_password,
|
|
@@ -51,8 +51,7 @@ class PostgresDatabase(Database):
|
|
|
51
51
|
import psycopg # noqa: F401
|
|
52
52
|
except Exception:
|
|
53
53
|
fatal_error(
|
|
54
|
-
"The psycopg module (psycopg3) is required to connect to PostgreSQL.
|
|
55
|
-
"See https://www.psycopg.org/psycopg3/",
|
|
54
|
+
f"The psycopg module (psycopg3) is required to connect to PostgreSQL. {install_hint('postgres')}",
|
|
56
55
|
)
|
|
57
56
|
from execsql.types import dbt_postgres
|
|
58
57
|
|
execsql/db/sqlserver.py
CHANGED
|
@@ -11,7 +11,7 @@ import re
|
|
|
11
11
|
|
|
12
12
|
from execsql.db.base import Database
|
|
13
13
|
from execsql.exceptions import ErrInfo
|
|
14
|
-
from execsql.utils.errors import fatal_error
|
|
14
|
+
from execsql.utils.errors import fatal_error, install_hint
|
|
15
15
|
from execsql.utils.auth import (
|
|
16
16
|
begin_login,
|
|
17
17
|
clear_stored_password,
|
|
@@ -41,7 +41,7 @@ class SqlServerDatabase(Database):
|
|
|
41
41
|
try:
|
|
42
42
|
import pyodbc # noqa: F401
|
|
43
43
|
except Exception:
|
|
44
|
-
fatal_error("The pyodbc module is required.
|
|
44
|
+
fatal_error(f"The pyodbc module is required to connect to SQL Server. {install_hint('mssql')}")
|
|
45
45
|
from execsql.types import dbt_sqlserver
|
|
46
46
|
|
|
47
47
|
super().__init__(
|
execsql/exporters/duckdb.py
CHANGED
|
@@ -33,9 +33,9 @@ def export_duckdb(
|
|
|
33
33
|
try:
|
|
34
34
|
import duckdb
|
|
35
35
|
except Exception:
|
|
36
|
-
from execsql.utils.errors import fatal_error
|
|
36
|
+
from execsql.utils.errors import fatal_error, install_hint
|
|
37
37
|
|
|
38
|
-
fatal_error("The duckdb module is required to export data in that format.")
|
|
38
|
+
fatal_error(f"The duckdb module is required to export data in that format. {install_hint('duckdb')}")
|
|
39
39
|
return
|
|
40
40
|
|
|
41
41
|
from execsql.models import DataTable
|
execsql/exporters/ods.py
CHANGED
|
@@ -20,7 +20,7 @@ import execsql.state as _state
|
|
|
20
20
|
from execsql.exceptions import OdsFileError
|
|
21
21
|
from execsql.exceptions import ErrInfo
|
|
22
22
|
from execsql.script import current_script_line
|
|
23
|
-
from execsql.utils.errors import exception_desc, fatal_error
|
|
23
|
+
from execsql.utils.errors import exception_desc, fatal_error, install_hint
|
|
24
24
|
from execsql.utils.fileio import filewriter_close
|
|
25
25
|
from execsql.utils.strings import unquoted
|
|
26
26
|
|
|
@@ -57,7 +57,7 @@ class OdsFile:
|
|
|
57
57
|
import odf.number # noqa: F401
|
|
58
58
|
import odf.style # noqa: F401
|
|
59
59
|
except ImportError:
|
|
60
|
-
fatal_error("The odfpy library is needed to create OpenDocument spreadsheets.")
|
|
60
|
+
fatal_error(f"The odfpy library is needed to create OpenDocument spreadsheets. {install_hint('formats')}")
|
|
61
61
|
self.filename: str | None = None
|
|
62
62
|
self.wbk: Any = None
|
|
63
63
|
self.cell_style_names: list[str] = []
|
execsql/exporters/templates.py
CHANGED
|
@@ -14,7 +14,7 @@ from typing import Any
|
|
|
14
14
|
|
|
15
15
|
import execsql.state as _state
|
|
16
16
|
from execsql.exceptions import ErrInfo
|
|
17
|
-
from execsql.utils.errors import fatal_error
|
|
17
|
+
from execsql.utils.errors import fatal_error, install_hint
|
|
18
18
|
from execsql.utils.fileio import filewriter_close
|
|
19
19
|
|
|
20
20
|
__all__ = ["StrTemplateReport", "JinjaTemplateReport", "report_query"]
|
|
@@ -91,7 +91,7 @@ class JinjaTemplateReport:
|
|
|
91
91
|
self._jinja2 = jinja2
|
|
92
92
|
except ImportError:
|
|
93
93
|
fatal_error(
|
|
94
|
-
"The jinja2 library is required to produce reports with the Jinja2 templating system.
|
|
94
|
+
f"The jinja2 library is required to produce reports with the Jinja2 templating system. {install_hint('formats')}",
|
|
95
95
|
)
|
|
96
96
|
conf = _state.conf
|
|
97
97
|
template_root = getattr(conf, "template_root", None)
|
execsql/exporters/xls.py
CHANGED
|
@@ -22,7 +22,7 @@ from pathlib import Path
|
|
|
22
22
|
from typing import Any, cast
|
|
23
23
|
|
|
24
24
|
from execsql.exceptions import XlsFileError, XlsxFileError
|
|
25
|
-
from execsql.utils.errors import fatal_error
|
|
25
|
+
from execsql.utils.errors import fatal_error, install_hint
|
|
26
26
|
|
|
27
27
|
__all__ = ["XlsFile", "XlsxFile"]
|
|
28
28
|
|
|
@@ -51,7 +51,7 @@ class XlsFile:
|
|
|
51
51
|
|
|
52
52
|
self._xlrd = xlrd
|
|
53
53
|
except ImportError:
|
|
54
|
-
fatal_error("The xlrd library is needed to read Excel (.xls) spreadsheets.")
|
|
54
|
+
fatal_error(f"The xlrd library is needed to read Excel (.xls) spreadsheets. {install_hint('formats')}")
|
|
55
55
|
self.filename: str | None = None
|
|
56
56
|
self.encoding: str | None = None
|
|
57
57
|
self.wbk: Any = None
|
|
@@ -190,7 +190,7 @@ class XlsxFile:
|
|
|
190
190
|
|
|
191
191
|
self._openpyxl = openpyxl
|
|
192
192
|
except ImportError:
|
|
193
|
-
fatal_error("The openpyxl library is needed to read Excel (.xlsx) spreadsheets.")
|
|
193
|
+
fatal_error(f"The openpyxl library is needed to read Excel (.xlsx) spreadsheets. {install_hint('formats')}")
|
|
194
194
|
self.filename: str | None = None
|
|
195
195
|
self.encoding: str | None = None
|
|
196
196
|
self.wbk: Any = None
|
execsql/exporters/xlsx.py
CHANGED
|
@@ -20,7 +20,7 @@ from execsql.exceptions import ErrInfo
|
|
|
20
20
|
from execsql.exporters.base import ExportRecord
|
|
21
21
|
from execsql.exporters.pretty import prettyprint_query
|
|
22
22
|
from execsql.script import current_script_line
|
|
23
|
-
from execsql.utils.errors import exception_desc, fatal_error
|
|
23
|
+
from execsql.utils.errors import exception_desc, fatal_error, install_hint
|
|
24
24
|
from execsql.utils.fileio import filewriter_close
|
|
25
25
|
from execsql.utils.strings import unquoted
|
|
26
26
|
|
|
@@ -39,7 +39,7 @@ def _require_openpyxl() -> Any:
|
|
|
39
39
|
return openpyxl
|
|
40
40
|
except ImportError:
|
|
41
41
|
fatal_error(
|
|
42
|
-
"The openpyxl library is needed to write Excel (.xlsx) spreadsheets (
|
|
42
|
+
f"The openpyxl library is needed to write Excel (.xlsx) spreadsheets. {install_hint('formats')}",
|
|
43
43
|
)
|
|
44
44
|
|
|
45
45
|
|
execsql/exporters/yaml.py
CHANGED
|
@@ -6,8 +6,7 @@ YAML export for execsql.
|
|
|
6
6
|
Provides :func:`write_query_to_yaml` which serialises a query result set as a
|
|
7
7
|
YAML sequence of mappings (one mapping per row).
|
|
8
8
|
|
|
9
|
-
Requires the ``PyYAML`` package (
|
|
10
|
-
``pip install 'execsql2[formats]'``).
|
|
9
|
+
Requires the ``PyYAML`` package (the ``formats`` extra).
|
|
11
10
|
"""
|
|
12
11
|
|
|
13
12
|
from typing import Any
|
|
@@ -15,7 +14,7 @@ from typing import Any
|
|
|
15
14
|
import execsql.state as _state
|
|
16
15
|
from execsql.exceptions import ErrInfo
|
|
17
16
|
from execsql.exporters.zip import ZipWriter
|
|
18
|
-
from execsql.utils.errors import exception_desc
|
|
17
|
+
from execsql.utils.errors import exception_desc, install_hint
|
|
19
18
|
from execsql.utils.fileio import filewriter_close
|
|
20
19
|
|
|
21
20
|
__all__ = ["write_query_to_yaml"]
|
|
@@ -54,7 +53,7 @@ def write_query_to_yaml(
|
|
|
54
53
|
except ImportError as exc:
|
|
55
54
|
raise ErrInfo(
|
|
56
55
|
"error",
|
|
57
|
-
other_msg=
|
|
56
|
+
other_msg=f"PyYAML is required for FORMAT YAML export. {install_hint('formats')}",
|
|
58
57
|
) from exc
|
|
59
58
|
|
|
60
59
|
conf = _state.conf
|
execsql/format.py
CHANGED
|
@@ -21,11 +21,14 @@ from types import ModuleType
|
|
|
21
21
|
__all__ = ["collect_paths", "format_file", "open_dollar_quote", "parse_keyword", "run_formatter"]
|
|
22
22
|
|
|
23
23
|
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
24
|
+
def _sqlglot_missing_msg() -> str:
|
|
25
|
+
from execsql.utils.errors import install_hint
|
|
26
|
+
|
|
27
|
+
return (
|
|
28
|
+
"execsql format requires sqlglot for SQL reformatting.\n"
|
|
29
|
+
f" {install_hint('formatter')}\n"
|
|
30
|
+
" Or skip SQL reformatting with the --no-sql flag."
|
|
31
|
+
)
|
|
29
32
|
|
|
30
33
|
|
|
31
34
|
def _require_sqlglot():
|
|
@@ -36,7 +39,7 @@ def _require_sqlglot():
|
|
|
36
39
|
|
|
37
40
|
return sqlglot
|
|
38
41
|
except ImportError as e:
|
|
39
|
-
raise ImportError(
|
|
42
|
+
raise ImportError(_sqlglot_missing_msg()) from e
|
|
40
43
|
|
|
41
44
|
|
|
42
45
|
# ---------------------------------------------------------------------------
|
|
@@ -532,20 +535,57 @@ def _statement_sources(sqlglot: ModuleType, sql: str) -> list[str]:
|
|
|
532
535
|
return sources
|
|
533
536
|
|
|
534
537
|
|
|
535
|
-
def
|
|
536
|
-
"""
|
|
538
|
+
def _ends_with_semicolon(sqlglot: ModuleType, sql: str) -> bool:
|
|
539
|
+
"""Whether the last token of *sql* is a statement-ending ``;``."""
|
|
540
|
+
tokens = sqlglot.tokenize(sql, read="postgres")
|
|
541
|
+
return bool(tokens) and tokens[-1].token_type.name == "SEMICOLON"
|
|
542
|
+
|
|
543
|
+
|
|
544
|
+
def _layout_tokens(sqlglot: ModuleType, sql: str) -> list[tuple[str, str]]:
|
|
545
|
+
"""*sql*'s tokens, as (type, text), with everything the formatter may change taken out.
|
|
537
546
|
|
|
538
|
-
Whitespace and comments are not tokens. Keywords and unquoted names
|
|
539
|
-
compared without case; strings and quoted
|
|
547
|
+
Whitespace and comments are not tokens. Keywords and unquoted names
|
|
548
|
+
(function names included) are compared without case; strings and quoted
|
|
549
|
+
names exactly. The type is compared too, so a string that came back as a
|
|
550
|
+
quoted name of the same text is a change.
|
|
540
551
|
"""
|
|
541
552
|
return [
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
553
|
+
(
|
|
554
|
+
token.token_type.name,
|
|
555
|
+
token.text
|
|
556
|
+
if token.token_type.name.endswith("STRING") or token.token_type.name == "IDENTIFIER"
|
|
557
|
+
else token.text.upper(),
|
|
558
|
+
)
|
|
545
559
|
for token in sqlglot.tokenize(sql, read="postgres")
|
|
546
560
|
]
|
|
547
561
|
|
|
548
562
|
|
|
563
|
+
# An optimizer hint (``/*+ ... */``, Oracle and MySQL) or a MySQL executable
|
|
564
|
+
# comment (``/*! ... */``) is SQL the server reads, though it looks like a
|
|
565
|
+
# comment. sqlglot keeps neither: it writes them back as ``/* + ... */`` and
|
|
566
|
+
# moves a hint away from its keyword, which disables it.
|
|
567
|
+
_RX_HINT_COMMENT = re.compile(r"/\*[+!]")
|
|
568
|
+
|
|
569
|
+
|
|
570
|
+
def _has_hint_comment(sqlglot: ModuleType, sql: str) -> bool:
|
|
571
|
+
"""Whether *sql* holds a hint or executable comment outside its strings and quoted names."""
|
|
572
|
+
if not _RX_HINT_COMMENT.search(sql):
|
|
573
|
+
return False
|
|
574
|
+
tokens = [(t.start, t.end) for t in sqlglot.tokenize(sql, read="postgres")]
|
|
575
|
+
return any(not any(start <= m.start() <= end for start, end in tokens) for m in _RX_HINT_COMMENT.finditer(sql))
|
|
576
|
+
|
|
577
|
+
|
|
578
|
+
def _as_written(source: str) -> str:
|
|
579
|
+
"""A statement's source text to keep as written, without the space that followed the previous ``;``.
|
|
580
|
+
|
|
581
|
+
A statement that starts on its own line keeps that line's indentation.
|
|
582
|
+
"""
|
|
583
|
+
source = source.rstrip()
|
|
584
|
+
body = source.lstrip()
|
|
585
|
+
lead = source[: len(source) - len(body)]
|
|
586
|
+
return source[lead.rfind("\n") + 1 :] if "\n" in lead else body
|
|
587
|
+
|
|
588
|
+
|
|
549
589
|
def _sqlglot_format(
|
|
550
590
|
sql_lines: list[str],
|
|
551
591
|
sql_indent: int = 4,
|
|
@@ -596,9 +636,10 @@ def _sqlglot_format(
|
|
|
596
636
|
# parse is left exactly as written.
|
|
597
637
|
ast = sqlglot.parse(protected, read="postgres", error_level=sqlglot_errors.ErrorLevel.RAISE)
|
|
598
638
|
nodes = [node for node in ast if node is not None]
|
|
599
|
-
sources =
|
|
600
|
-
if
|
|
639
|
+
sources = _statement_sources(sqlglot, protected)
|
|
640
|
+
if len(sources) != len(nodes) and (not rewrite_sql or _has_hint_comment(sqlglot, protected)):
|
|
601
641
|
return sql_lines
|
|
642
|
+
aligned = len(sources) == len(nodes)
|
|
602
643
|
statements: list[str] = []
|
|
603
644
|
for i, node in enumerate(nodes):
|
|
604
645
|
# For Command nodes (psql backslash commands, ERROR:, etc.)
|
|
@@ -614,20 +655,29 @@ def _sqlglot_format(
|
|
|
614
655
|
max_text_width=120,
|
|
615
656
|
)
|
|
616
657
|
generated = re.sub(r"\bINTO TEMPORARY\b(?!\s+TABLE)", "INTO TEMPORARY TABLE", generated)
|
|
617
|
-
if
|
|
618
|
-
|
|
658
|
+
if aligned and (
|
|
659
|
+
_has_hint_comment(sqlglot, sources[i])
|
|
660
|
+
or (not rewrite_sql and _layout_tokens(sqlglot, generated) != _layout_tokens(sqlglot, sources[i]))
|
|
661
|
+
):
|
|
662
|
+
generated = _as_written(sources[i])
|
|
619
663
|
statements.append(generated)
|
|
620
664
|
stmts = [s for s in statements if s]
|
|
621
665
|
if not stmts:
|
|
622
666
|
return sql_lines
|
|
623
667
|
|
|
668
|
+
# Terminate the last statement only if the source did: execsql refuses
|
|
669
|
+
# a script whose last statement is unfinished, and formatting must not
|
|
670
|
+
# turn it into one that runs.
|
|
671
|
+
terminated = _ends_with_semicolon(sqlglot, protected)
|
|
672
|
+
|
|
624
673
|
# Safety: if sqlglot produced more statements than the input had
|
|
625
|
-
# semicolons, it likely split a
|
|
626
|
-
# into multiple pseudo-statements.
|
|
627
|
-
|
|
674
|
+
# semicolons (plus an unterminated last one), it likely split a
|
|
675
|
+
# fragment (e.g. a SELECT column list) into multiple pseudo-statements.
|
|
676
|
+
# Fall back to the original text.
|
|
677
|
+
if len(stmts) > max(input_semis + (0 if terminated else 1), 1):
|
|
628
678
|
return sql_lines
|
|
629
679
|
|
|
630
|
-
joined = ";\n".join(stmts) + ";"
|
|
680
|
+
joined = ";\n".join(stmts) + (";" if terminated else "")
|
|
631
681
|
|
|
632
682
|
# Content-loss check: sqlglot with IGNORE error level can silently
|
|
633
683
|
# drop tokens it doesn't understand (e.g. ``ERROR: ...``). If the
|
execsql/importers/base.py
CHANGED
|
@@ -16,15 +16,7 @@ from execsql.exceptions import ErrInfo
|
|
|
16
16
|
from execsql.db.base import Database
|
|
17
17
|
import execsql.state as _state
|
|
18
18
|
|
|
19
|
-
__all__ = ["import_data_table", "refuse_existing_table"
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
def table_exists_if_known(db: Database, table_name: str, schema_name: str | None = None) -> bool | None:
|
|
23
|
-
"""Whether the table exists, or ``None`` when the database cannot say (no catalog access, ...)."""
|
|
24
|
-
try:
|
|
25
|
-
return db.table_exists(table_name, schema_name)
|
|
26
|
-
except Exception:
|
|
27
|
-
return None
|
|
19
|
+
__all__ = ["import_data_table", "refuse_existing_table"]
|
|
28
20
|
|
|
29
21
|
|
|
30
22
|
def refuse_existing_table(db: Database, schema_name: str | None, table_name: str) -> None:
|
|
@@ -43,7 +35,11 @@ def refuse_existing_table(db: Database, schema_name: str | None, table_name: str
|
|
|
43
35
|
lookup_schema = None
|
|
44
36
|
if lookup_schema is None:
|
|
45
37
|
return # An unqualified lookup would search every schema.
|
|
46
|
-
|
|
38
|
+
try:
|
|
39
|
+
exists = db.table_exists_where_created(table_name, lookup_schema)
|
|
40
|
+
except Exception:
|
|
41
|
+
return # The database cannot say; CREATE TABLE reports any conflict.
|
|
42
|
+
if exists:
|
|
47
43
|
name = db.schema_qualified_table_name(schema_name, table_name)
|
|
48
44
|
raise ErrInfo(
|
|
49
45
|
type="cmd",
|
execsql/lsp/__init__.py
ADDED
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
"""Quick fixes for lint findings.
|
|
2
|
+
|
|
3
|
+
Each fix is checked before it is offered: a spelling fix must make the
|
|
4
|
+
metacommand match a dispatch-table form (P003) or make lint's finding go away
|
|
5
|
+
(P004, P002). A fix that would not clear the finding is never shown.
|
|
6
|
+
|
|
7
|
+
- P003 unknown metacommand: a misspelled keyword, e.g. ``EXPROT`` -> ``EXPORT``.
|
|
8
|
+
- P004 unknown condition: a misspelled conditional test, e.g. ``HASROWZ`` -> ``HASROWS``.
|
|
9
|
+
- V001 undefined variable: a near-miss of a variable the script defines (or of a system variable).
|
|
10
|
+
- P002 split dollar quote: put the statement between ``BEGIN SQL`` and ``END SQL``.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import difflib
|
|
16
|
+
import re
|
|
17
|
+
from collections.abc import Iterator, Sequence
|
|
18
|
+
|
|
19
|
+
from lsprotocol import types
|
|
20
|
+
|
|
21
|
+
from execsql.cli.lint import _dispatch_table, _with_placeholders, lint_source
|
|
22
|
+
from execsql.format import open_dollar_quote
|
|
23
|
+
from execsql.lsp.document import ScriptIndex
|
|
24
|
+
from execsql.metacommands import reference
|
|
25
|
+
|
|
26
|
+
__all__ = ["code_actions"]
|
|
27
|
+
|
|
28
|
+
_RX_METACOMMAND = re.compile(r"^(?P<lead>\s*--\s*!x!\s*)(?P<command>.*?)\s*$", re.I)
|
|
29
|
+
_RX_WORD = re.compile(r"[A-Za-z_][A-Za-z_0-9]*")
|
|
30
|
+
_RX_UNDEFINED = re.compile(r"!!(?P<name>[^!]+)!!")
|
|
31
|
+
_SUGGESTIONS = 3 # per misspelled word
|
|
32
|
+
_CUTOFF = 0.7
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _vocabulary() -> set[str]:
|
|
36
|
+
"""Every literal word of a metacommand's syntax: keywords and words such as TO, AS, WITH."""
|
|
37
|
+
words: set[str] = set()
|
|
38
|
+
for entry in reference.metacommands():
|
|
39
|
+
for form in entry.forms:
|
|
40
|
+
words.update(re.findall(r"\b[A-Z][A-Z_]+\b", re.sub(r"<[^>]*>", " ", form)))
|
|
41
|
+
return words
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _cased_like(word: str, written: str) -> str:
|
|
45
|
+
return word.lower() if written.islower() else word
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _close(word: str, choices: set[str] | list[str]) -> list[str]:
|
|
49
|
+
return difflib.get_close_matches(word.upper(), list(choices), n=_SUGGESTIONS, cutoff=_CUTOFF)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _replace(text: str, start: int, end: int, new: str) -> str:
|
|
53
|
+
return text[:start] + new + text[end:]
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _range(line: int, start: int, end: int) -> types.Range:
|
|
57
|
+
return types.Range(start=types.Position(line=line, character=start), end=types.Position(line=line, character=end))
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _fix(title: str, uri: str, diagnostic: types.Diagnostic, edits: list[types.TextEdit]) -> types.CodeAction:
|
|
61
|
+
return types.CodeAction(
|
|
62
|
+
title=title,
|
|
63
|
+
kind=types.CodeActionKind.QuickFix,
|
|
64
|
+
diagnostics=[diagnostic],
|
|
65
|
+
edit=types.WorkspaceEdit(changes={uri: edits}),
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _count(source: str, path: str | None, code: str) -> int:
|
|
70
|
+
return sum(1 for issue in lint_source(source, path or "<untitled>", script_path=path) if issue.code == code)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _clears(lines: list[str], path: str | None, code: str, before: tuple[int, int], row: int, new_line: str) -> bool:
|
|
74
|
+
"""Whether replacing line *row* with *new_line* removes a *code* finding without adding a parse error.
|
|
75
|
+
|
|
76
|
+
*before* is the (*code*, P001) count of the unchanged script.
|
|
77
|
+
"""
|
|
78
|
+
fixed = "\n".join(lines[:row] + [new_line] + lines[row + 1 :]) + "\n"
|
|
79
|
+
return _count(fixed, path, code) < before[0] and _count(fixed, path, "P001") <= before[1]
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _metacommand_fixes(text: str, row: int) -> Iterator[tuple[str, int, int, str]]:
|
|
83
|
+
"""(suggestion, start, end, written) for each word whose replacement makes the metacommand match."""
|
|
84
|
+
m = _RX_METACOMMAND.match(text)
|
|
85
|
+
if not m:
|
|
86
|
+
return
|
|
87
|
+
vocabulary = _vocabulary()
|
|
88
|
+
offset = m.start("command")
|
|
89
|
+
command = m.group("command")
|
|
90
|
+
table = _dispatch_table()
|
|
91
|
+
for word in _RX_WORD.finditer(command):
|
|
92
|
+
if word.group().upper() in vocabulary:
|
|
93
|
+
continue
|
|
94
|
+
for candidate in _close(word.group(), vocabulary):
|
|
95
|
+
fixed = _replace(command, word.start(), word.end(), candidate)
|
|
96
|
+
if table.get_match(fixed) is not None or table.get_match(_with_placeholders(fixed)) is not None:
|
|
97
|
+
yield candidate, offset + word.start(), offset + word.end(), word.group()
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _p003(uri: str, lines: list[str], diagnostic: types.Diagnostic) -> list[types.CodeAction]:
|
|
101
|
+
row = diagnostic.range.start.line
|
|
102
|
+
return [
|
|
103
|
+
_fix(
|
|
104
|
+
f"Change {written} to {_cased_like(candidate, written)}",
|
|
105
|
+
uri,
|
|
106
|
+
diagnostic,
|
|
107
|
+
[types.TextEdit(range=_range(row, start, end), new_text=_cased_like(candidate, written))],
|
|
108
|
+
)
|
|
109
|
+
for candidate, start, end, written in _metacommand_fixes(lines[row], row)
|
|
110
|
+
]
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _p004(uri: str, path: str | None, lines: list[str], diagnostic: types.Diagnostic) -> list[types.CodeAction]:
|
|
114
|
+
row = diagnostic.range.start.line
|
|
115
|
+
text = lines[row]
|
|
116
|
+
m = _RX_METACOMMAND.match(text)
|
|
117
|
+
if not m:
|
|
118
|
+
return []
|
|
119
|
+
names = {c.name for c in reference.conditions() if c.name not in ("NOT", "OR")}
|
|
120
|
+
source = "\n".join(lines) + "\n"
|
|
121
|
+
before = (_count(source, path, "P004"), _count(source, path, "P001"))
|
|
122
|
+
actions = []
|
|
123
|
+
for word in _RX_WORD.finditer(text, m.start("command")):
|
|
124
|
+
if word.group().upper() in names or not text[word.end() :].lstrip().startswith("("):
|
|
125
|
+
continue
|
|
126
|
+
for candidate in _close(word.group(), names):
|
|
127
|
+
new = _cased_like(candidate, word.group())
|
|
128
|
+
if _clears(lines, path, "P004", before, row, _replace(text, word.start(), word.end(), new)):
|
|
129
|
+
edit = types.TextEdit(range=_range(row, word.start(), word.end()), new_text=new)
|
|
130
|
+
actions.append(_fix(f"Change {word.group()} to {new}", uri, diagnostic, [edit]))
|
|
131
|
+
return actions
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _v001(index: ScriptIndex, uri: str, diagnostic: types.Diagnostic) -> list[types.CodeAction]:
|
|
135
|
+
m = _RX_UNDEFINED.search(diagnostic.message)
|
|
136
|
+
if not m:
|
|
137
|
+
return []
|
|
138
|
+
row = diagnostic.range.start.line
|
|
139
|
+
written = m.group("name")
|
|
140
|
+
actions = []
|
|
141
|
+
for ref in index.references:
|
|
142
|
+
loc = ref.location
|
|
143
|
+
if loc.path != index.path or loc.line != row or ref.name.lower() != written.lower():
|
|
144
|
+
continue
|
|
145
|
+
prefix = ref.name[: len(ref.name) - len(ref.name.lstrip("~+"))]
|
|
146
|
+
bare = ref.name[len(prefix) :]
|
|
147
|
+
if bare.startswith("$"):
|
|
148
|
+
choices = {v.name for v in reference.system_variables() if not v.name.endswith("_x")}
|
|
149
|
+
spelled = {name: name for name in choices}
|
|
150
|
+
else:
|
|
151
|
+
spelled = {key: index.spellings[key] for key in index.definitions}
|
|
152
|
+
for candidate in _close(bare, set(spelled)):
|
|
153
|
+
new = prefix + spelled[candidate]
|
|
154
|
+
edit = types.TextEdit(range=_range(row, loc.start, loc.end), new_text=new)
|
|
155
|
+
actions.append(_fix(f"Change !!{ref.name}!! to !!{new}!!", uri, diagnostic, [edit]))
|
|
156
|
+
break
|
|
157
|
+
return actions
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def _statement_end(lines: list[str], row: int) -> int | None:
|
|
161
|
+
"""The line that ends the statement whose dollar-quoted body opens on *row*."""
|
|
162
|
+
open_tag: str | None = None
|
|
163
|
+
for end in range(row, len(lines)):
|
|
164
|
+
open_tag = open_dollar_quote(lines[end] + "\n", open_tag)
|
|
165
|
+
if open_tag is None and end > row and lines[end].rstrip().endswith(";"):
|
|
166
|
+
return end
|
|
167
|
+
return None
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def _p002(uri: str, path: str | None, lines: list[str], diagnostic: types.Diagnostic) -> list[types.CodeAction]:
|
|
171
|
+
row = diagnostic.range.start.line
|
|
172
|
+
end = _statement_end(lines, row)
|
|
173
|
+
if end is None:
|
|
174
|
+
return []
|
|
175
|
+
indent = lines[row][: len(lines[row]) - len(lines[row].lstrip())]
|
|
176
|
+
fixed = (
|
|
177
|
+
lines[:row]
|
|
178
|
+
+ [f"{indent}-- !x! BEGIN SQL"]
|
|
179
|
+
+ lines[row : end + 1]
|
|
180
|
+
+ [f"{indent}-- !x! END SQL"]
|
|
181
|
+
+ lines[end + 1 :]
|
|
182
|
+
)
|
|
183
|
+
if _count("\n".join(fixed) + "\n", path, "P002") >= _count("\n".join(lines) + "\n", path, "P002"):
|
|
184
|
+
return []
|
|
185
|
+
edits = [
|
|
186
|
+
types.TextEdit(range=_range(row, 0, 0), new_text=f"{indent}-- !x! BEGIN SQL\n"),
|
|
187
|
+
types.TextEdit(range=_range(end, len(lines[end]), len(lines[end])), new_text=f"\n{indent}-- !x! END SQL"),
|
|
188
|
+
]
|
|
189
|
+
return [_fix("Put the statement between BEGIN SQL and END SQL", uri, diagnostic, edits)]
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def code_actions(
|
|
193
|
+
index: ScriptIndex,
|
|
194
|
+
uri: str,
|
|
195
|
+
source: str,
|
|
196
|
+
diagnostics: Sequence[types.Diagnostic],
|
|
197
|
+
) -> list[types.CodeAction]:
|
|
198
|
+
"""The quick fixes for the execsql *diagnostics* the editor asks about."""
|
|
199
|
+
lines = source.splitlines()
|
|
200
|
+
actions: list[types.CodeAction] = []
|
|
201
|
+
for diagnostic in diagnostics:
|
|
202
|
+
if diagnostic.source != "execsql" or diagnostic.range.start.line >= len(lines):
|
|
203
|
+
continue
|
|
204
|
+
if diagnostic.code == "P003":
|
|
205
|
+
found = _p003(uri, lines, diagnostic)
|
|
206
|
+
elif diagnostic.code == "P004":
|
|
207
|
+
found = _p004(uri, index.path, lines, diagnostic)
|
|
208
|
+
elif diagnostic.code == "V001":
|
|
209
|
+
found = _v001(index, uri, diagnostic)
|
|
210
|
+
elif diagnostic.code == "P002":
|
|
211
|
+
found = _p002(uri, index.path, lines, diagnostic)
|
|
212
|
+
else:
|
|
213
|
+
continue
|
|
214
|
+
if len(found) == 1:
|
|
215
|
+
found[0].is_preferred = True
|
|
216
|
+
actions.extend(found)
|
|
217
|
+
return actions
|