execsql2 2.24.0__py3-none-any.whl → 2.25.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/api.py +169 -160
- execsql/cli/__init__.py +2 -0
- execsql/cli/commands/lint.py +2 -10
- execsql/cli/commands/run.py +35 -27
- execsql/cli/dsn.py +11 -4
- execsql/cli/lint.py +271 -28
- execsql/cli/options.py +1 -1
- execsql/cli/run.py +56 -19
- execsql/config.py +5 -19
- execsql/data/execsql.conf.template +14 -4
- execsql/db/access.py +29 -8
- execsql/db/base.py +40 -8
- execsql/db/dsn.py +13 -4
- execsql/db/duckdb.py +3 -1
- execsql/db/factory.py +11 -2
- execsql/db/firebird.py +21 -7
- execsql/db/mysql.py +37 -16
- execsql/db/oracle.py +49 -16
- execsql/db/postgres.py +30 -6
- execsql/db/sqlite.py +7 -9
- execsql/db/sqlserver.py +25 -8
- execsql/exporters/delimited.py +87 -28
- execsql/exporters/xlsx.py +24 -9
- execsql/exporters/zip.py +1 -2
- execsql/format.py +29 -2
- execsql/gui/desktop.py +1 -3
- execsql/gui/tui.py +2 -2
- execsql/importers/base.py +35 -1
- execsql/importers/csv.py +3 -1
- execsql/manifest.py +33 -22
- execsql/metacommands/conditions.py +6 -7
- execsql/metacommands/data.py +4 -1
- execsql/metacommands/debug.py +2 -4
- execsql/metacommands/io_fileops.py +62 -37
- execsql/metacommands/io_import.py +0 -1
- execsql/metacommands/prompt.py +2 -0
- execsql/metacommands/upsert.py +5 -2
- execsql/models.py +3 -2
- execsql/parser.py +14 -5
- execsql/plugins.py +24 -0
- execsql/script/engine.py +40 -11
- execsql/script/executor.py +9 -5
- execsql/script/parser.py +167 -118
- execsql/state.py +23 -9
- execsql/state.pyi +8 -0
- execsql/types.py +22 -13
- execsql/utils/auth.py +169 -24
- execsql/utils/datetime.py +55 -17
- execsql/utils/errors.py +71 -9
- execsql/utils/fileio.py +404 -117
- execsql/utils/gui.py +3 -0
- execsql/utils/mail.py +37 -12
- {execsql2-2.24.0.dist-info → execsql2-2.25.0.dist-info}/METADATA +18 -12
- {execsql2-2.24.0.dist-info → execsql2-2.25.0.dist-info}/RECORD +72 -72
- {execsql2-2.24.0.data → execsql2-2.25.0.data}/data/execsql2_extras/README.md +0 -0
- {execsql2-2.24.0.data → execsql2-2.25.0.data}/data/execsql2_extras/config_settings.sqlite +0 -0
- {execsql2-2.24.0.data → execsql2-2.25.0.data}/data/execsql2_extras/example_config_prompt.sql +0 -0
- {execsql2-2.24.0.data → execsql2-2.25.0.data}/data/execsql2_extras/make_config_db.sql +0 -0
- {execsql2-2.24.0.data → execsql2-2.25.0.data}/data/execsql2_extras/md_compare.sql +0 -0
- {execsql2-2.24.0.data → execsql2-2.25.0.data}/data/execsql2_extras/md_glossary.sql +0 -0
- {execsql2-2.24.0.data → execsql2-2.25.0.data}/data/execsql2_extras/md_upsert.sql +0 -0
- {execsql2-2.24.0.data → execsql2-2.25.0.data}/data/execsql2_extras/pg_compare.sql +0 -0
- {execsql2-2.24.0.data → execsql2-2.25.0.data}/data/execsql2_extras/pg_glossary.sql +0 -0
- {execsql2-2.24.0.data → execsql2-2.25.0.data}/data/execsql2_extras/pg_upsert.sql +0 -0
- {execsql2-2.24.0.data → execsql2-2.25.0.data}/data/execsql2_extras/script_template.sql +0 -0
- {execsql2-2.24.0.data → execsql2-2.25.0.data}/data/execsql2_extras/ss_compare.sql +0 -0
- {execsql2-2.24.0.data → execsql2-2.25.0.data}/data/execsql2_extras/ss_glossary.sql +0 -0
- {execsql2-2.24.0.data → execsql2-2.25.0.data}/data/execsql2_extras/ss_upsert.sql +0 -0
- {execsql2-2.24.0.dist-info → execsql2-2.25.0.dist-info}/WHEEL +0 -0
- {execsql2-2.24.0.dist-info → execsql2-2.25.0.dist-info}/entry_points.txt +0 -0
- {execsql2-2.24.0.dist-info → execsql2-2.25.0.dist-info}/licenses/LICENSE.txt +0 -0
- {execsql2-2.24.0.dist-info → execsql2-2.25.0.dist-info}/licenses/NOTICE +0 -0
|
@@ -46,7 +46,7 @@
|
|
|
46
46
|
#password_prompt=Yes
|
|
47
47
|
|
|
48
48
|
# Whether to use the OS credential store (keyring) for password storage.
|
|
49
|
-
# Requires:
|
|
49
|
+
# Requires the auth extra: uv tool install "execsql2[auth]"
|
|
50
50
|
# Values: Yes or No. Default: Yes.
|
|
51
51
|
#use_keyring=Yes
|
|
52
52
|
|
|
@@ -71,7 +71,8 @@
|
|
|
71
71
|
[input]
|
|
72
72
|
# Settings for handling imported data.
|
|
73
73
|
|
|
74
|
-
#
|
|
74
|
+
# MS Access: create columns for decimal data as NUMERIC(precision,scale)
|
|
75
|
+
# (Yes) or as DOUBLE (No) when IMPORT or COPY creates a table.
|
|
75
76
|
# Values: Yes or No. Default: No.
|
|
76
77
|
#access_use_numeric=No
|
|
77
78
|
|
|
@@ -261,6 +262,15 @@
|
|
|
261
262
|
# Values: Yes or No. Default: No.
|
|
262
263
|
#use_tls=No
|
|
263
264
|
|
|
265
|
+
# Whether to verify the SMTP server's certificate when use_ssl or use_tls is on.
|
|
266
|
+
# Turn off only for a test relay; prefer ca_file for an internal certificate.
|
|
267
|
+
# Values: Yes or No. Default: Yes.
|
|
268
|
+
#verify_certificate=Yes
|
|
269
|
+
|
|
270
|
+
# CA certificate file (PEM) to trust, for a server with an internal or
|
|
271
|
+
# self-signed certificate. Default: the system's trusted CAs.
|
|
272
|
+
#ca_file=
|
|
273
|
+
|
|
264
274
|
# Email body format.
|
|
265
275
|
# Values: plain or html. Default: plain.
|
|
266
276
|
#email_format=plain
|
|
@@ -297,8 +307,8 @@
|
|
|
297
307
|
# Values: Yes or No. Default: Yes.
|
|
298
308
|
#allow_rm_file=Yes
|
|
299
309
|
|
|
300
|
-
# Whether to allow the SERVE metacommand (which
|
|
301
|
-
#
|
|
310
|
+
# Whether to allow the SERVE metacommand (which copies a file to stdout
|
|
311
|
+
# with HTTP headers, for CGI use). Set to No to disable.
|
|
302
312
|
# Also controllable via --no-serve CLI flag.
|
|
303
313
|
# Values: Yes or No. Default: Yes.
|
|
304
314
|
#allow_serve=Yes
|
execsql/db/access.py
CHANGED
|
@@ -18,7 +18,14 @@ from execsql.db.base import Database, _fetched
|
|
|
18
18
|
from execsql.db.tiers import SupportTier
|
|
19
19
|
from execsql.exceptions import ErrInfo
|
|
20
20
|
from execsql.utils.errors import exception_desc, fatal_error
|
|
21
|
-
from execsql.utils.auth import
|
|
21
|
+
from execsql.utils.auth import (
|
|
22
|
+
begin_login,
|
|
23
|
+
clear_stored_password,
|
|
24
|
+
get_password,
|
|
25
|
+
is_login_failure,
|
|
26
|
+
password_from_keyring,
|
|
27
|
+
remember_password,
|
|
28
|
+
)
|
|
22
29
|
import execsql.state as _state
|
|
23
30
|
|
|
24
31
|
__all__ = ["AccessDatabase"]
|
|
@@ -76,7 +83,6 @@ class AccessDatabase(Database):
|
|
|
76
83
|
self.password = password
|
|
77
84
|
# Encoding is only applicable to Jet < 4.0: non-accdb databases.
|
|
78
85
|
self.encoding = encoding or "windows-1252"
|
|
79
|
-
self.encode_commands = True
|
|
80
86
|
self.dao_conn: Any = None
|
|
81
87
|
self.conn = None # ODBC connection
|
|
82
88
|
self.paramstr = "?"
|
|
@@ -104,9 +110,12 @@ class AccessDatabase(Database):
|
|
|
104
110
|
if self.conn is not None:
|
|
105
111
|
self.conn.close()
|
|
106
112
|
self.conn = None
|
|
113
|
+
begin_login()
|
|
107
114
|
if self.need_passwd and self.user and self.password is None:
|
|
108
115
|
self.password = get_password("MS-Access", self.db_name, self.user)
|
|
109
116
|
|
|
117
|
+
errors: list[BaseException] = []
|
|
118
|
+
|
|
110
119
|
def _try_odbc_drivers():
|
|
111
120
|
db_name = str(Path(self.db_name).resolve())
|
|
112
121
|
for cs, jet4flag in self.connection_strings:
|
|
@@ -120,7 +129,8 @@ class AccessDatabase(Database):
|
|
|
120
129
|
# the connect attempt at 30 s to match the other
|
|
121
130
|
# adapters.
|
|
122
131
|
self.conn = pyodbc.connect(connstr, timeout=30)
|
|
123
|
-
except Exception:
|
|
132
|
+
except Exception as e:
|
|
133
|
+
errors.append(e)
|
|
124
134
|
if _state.exec_log is not None:
|
|
125
135
|
_state.exec_log.log_status_info(
|
|
126
136
|
f"Could not connect via ODBC using: {re.sub(r'Pwd=[^;]*', 'Pwd=***', connstr)}",
|
|
@@ -134,7 +144,8 @@ class AccessDatabase(Database):
|
|
|
134
144
|
return True
|
|
135
145
|
return False
|
|
136
146
|
|
|
137
|
-
|
|
147
|
+
# Only a rejected password means the stored one is stale.
|
|
148
|
+
if not _try_odbc_drivers() and password_from_keyring() and any(is_login_failure(e) for e in errors):
|
|
138
149
|
clear_stored_password("MS-Access", self.db_name, self.user)
|
|
139
150
|
self.password = get_password(
|
|
140
151
|
"MS-Access",
|
|
@@ -144,6 +155,8 @@ class AccessDatabase(Database):
|
|
|
144
155
|
other_msg="(stored credential failed — enter current password)",
|
|
145
156
|
)
|
|
146
157
|
_try_odbc_drivers()
|
|
158
|
+
if self.conn and self.user:
|
|
159
|
+
remember_password("MS-Access", self.db_name, self.user)
|
|
147
160
|
|
|
148
161
|
if not self.conn:
|
|
149
162
|
raise ErrInfo(
|
|
@@ -158,10 +171,13 @@ class AccessDatabase(Database):
|
|
|
158
171
|
if self.dao_conn is not None:
|
|
159
172
|
self.dao_conn.Close()
|
|
160
173
|
self.dao_conn = None
|
|
174
|
+
begin_login()
|
|
161
175
|
if self.need_passwd and self.user and self.password is None:
|
|
162
176
|
self.password = get_password("MS-Access", self.db_name, self.user)
|
|
163
177
|
dao_engines = ("DAO.DBEngine.120", "DAO.DBEngine.36")
|
|
164
178
|
|
|
179
|
+
dao_errors: list[BaseException] = []
|
|
180
|
+
|
|
165
181
|
def _try_dao_engines():
|
|
166
182
|
for engine in dao_engines:
|
|
167
183
|
try:
|
|
@@ -175,7 +191,8 @@ class AccessDatabase(Database):
|
|
|
175
191
|
)
|
|
176
192
|
else:
|
|
177
193
|
self.dao_conn = daoEngine.OpenDatabase(self.db_name)
|
|
178
|
-
except Exception:
|
|
194
|
+
except Exception as e:
|
|
195
|
+
dao_errors.append(e)
|
|
179
196
|
if _state.exec_log is not None:
|
|
180
197
|
_state.exec_log.log_status_info(f"Could not connect via DAO using: {engine}")
|
|
181
198
|
else:
|
|
@@ -184,7 +201,7 @@ class AccessDatabase(Database):
|
|
|
184
201
|
return True
|
|
185
202
|
return False
|
|
186
203
|
|
|
187
|
-
if not _try_dao_engines() and password_from_keyring():
|
|
204
|
+
if not _try_dao_engines() and password_from_keyring() and any(is_login_failure(e) for e in dao_errors):
|
|
188
205
|
clear_stored_password("MS-Access", self.db_name, self.user)
|
|
189
206
|
self.password = get_password(
|
|
190
207
|
"MS-Access",
|
|
@@ -194,6 +211,8 @@ class AccessDatabase(Database):
|
|
|
194
211
|
other_msg="(stored credential failed — enter current password)",
|
|
195
212
|
)
|
|
196
213
|
_try_dao_engines()
|
|
214
|
+
if self.dao_conn and self.user:
|
|
215
|
+
remember_password("MS-Access", self.db_name, self.user)
|
|
197
216
|
|
|
198
217
|
if not self.dao_conn:
|
|
199
218
|
raise ErrInfo(
|
|
@@ -339,6 +358,9 @@ class AccessDatabase(Database):
|
|
|
339
358
|
|
|
340
359
|
return headers, iter(dict_row, None)
|
|
341
360
|
|
|
361
|
+
# table_exists() without a schema looks only where CREATE TABLE puts a table: one namespace.
|
|
362
|
+
unqualified_lookup_is_scoped = True
|
|
363
|
+
|
|
342
364
|
def table_exists(self, table_name: str, schema_name: str | None = None) -> bool:
|
|
343
365
|
"""Return True if the named table exists in the Access database.
|
|
344
366
|
|
|
@@ -418,9 +440,8 @@ class AccessDatabase(Database):
|
|
|
418
440
|
return False
|
|
419
441
|
|
|
420
442
|
def drop_table(self, tablename: str) -> None:
|
|
421
|
-
"""Drop
|
|
443
|
+
"""Drop *tablename*, quoted by the caller."""
|
|
422
444
|
self.dao_flush_check()
|
|
423
|
-
tablename = self.type.quoted(tablename)
|
|
424
445
|
self.execute(f"drop table {tablename};")
|
|
425
446
|
|
|
426
447
|
def as_datetime(self, val: Any) -> datetime.datetime | datetime.date | datetime.time | None:
|
execsql/db/base.py
CHANGED
|
@@ -98,7 +98,6 @@ class Database(ABC):
|
|
|
98
98
|
password: str | None
|
|
99
99
|
port: int | None
|
|
100
100
|
encoding: Any
|
|
101
|
-
encode_commands: bool
|
|
102
101
|
paramstr: str
|
|
103
102
|
conn: Any
|
|
104
103
|
autocommit: bool
|
|
@@ -143,7 +142,6 @@ class Database(ABC):
|
|
|
143
142
|
self.password: str | None = None
|
|
144
143
|
self.port = port
|
|
145
144
|
self.encoding = encoding
|
|
146
|
-
self.encode_commands = True
|
|
147
145
|
self.paramstr = "?"
|
|
148
146
|
self.conn = None
|
|
149
147
|
self.autocommit = True
|
|
@@ -494,6 +492,21 @@ class Database(ABC):
|
|
|
494
492
|
rows = curs.fetchall()
|
|
495
493
|
return len(rows) > 0
|
|
496
494
|
|
|
495
|
+
#: Whether :meth:`table_exists` without a schema already looks only where
|
|
496
|
+
#: an unqualified ``CREATE TABLE`` puts a table (one namespace, or the
|
|
497
|
+
#: search path). When not, the check is narrowed to :meth:`current_schema`.
|
|
498
|
+
unqualified_lookup_is_scoped = False
|
|
499
|
+
|
|
500
|
+
def current_schema(self) -> str | None:
|
|
501
|
+
"""The schema (MySQL: database) an unqualified table name is created in, or ``None`` if unknown."""
|
|
502
|
+
return None
|
|
503
|
+
|
|
504
|
+
def _query_current_schema(self, sql: str) -> str | None:
|
|
505
|
+
with self._cursor() as curs:
|
|
506
|
+
curs.execute(sql)
|
|
507
|
+
row = curs.fetchone()
|
|
508
|
+
return str(row[0]) if row and row[0] else None
|
|
509
|
+
|
|
497
510
|
def table_exists(self, table_name: str, schema_name: str | None = None) -> bool:
|
|
498
511
|
"""Return ``True`` if *table_name* (optionally in *schema_name*) exists."""
|
|
499
512
|
with self._cursor() as curs:
|
|
@@ -665,6 +678,17 @@ class Database(ABC):
|
|
|
665
678
|
eof = False
|
|
666
679
|
total_rows = 0
|
|
667
680
|
self.before_statement(sql)
|
|
681
|
+
# The rows go in as several batches and are committed once, by the
|
|
682
|
+
# caller. Most drivers hold every batch in the transaction they open
|
|
683
|
+
# themselves; DuckDB's commits each statement unless one is open, so a
|
|
684
|
+
# failure would leave the batches before it behind.
|
|
685
|
+
self.begin_transaction()
|
|
686
|
+
if self.driver_autocommits:
|
|
687
|
+
self.warn_once(
|
|
688
|
+
"import",
|
|
689
|
+
f"{self.name()} commits each statement as it runs, so an IMPORT or COPY that fails part-way "
|
|
690
|
+
"leaves the rows already loaded.",
|
|
691
|
+
)
|
|
668
692
|
|
|
669
693
|
# Optional rich progress bar for long-running imports.
|
|
670
694
|
use_progress = getattr(_state.conf, "show_progress", False)
|
|
@@ -739,6 +763,8 @@ class Database(ABC):
|
|
|
739
763
|
)
|
|
740
764
|
if not _state.conf.empty_strings and line[i].strip() == "":
|
|
741
765
|
line[i] = None
|
|
766
|
+
# Trailing values left off the line are NULL.
|
|
767
|
+
line = list(line) + [None] * (len(ts_colnames) - len(line))
|
|
742
768
|
lt = [
|
|
743
769
|
type_objs[i].from_data(val) if val is not None else None for i, val in enumerate(line)
|
|
744
770
|
]
|
|
@@ -777,13 +803,19 @@ class Database(ABC):
|
|
|
777
803
|
break
|
|
778
804
|
return total_rows
|
|
779
805
|
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
806
|
+
try:
|
|
807
|
+
with self._cursor() as curs:
|
|
808
|
+
if use_progress and progress_ctx is not None:
|
|
809
|
+
with progress_ctx:
|
|
810
|
+
task_id = progress_ctx.add_task(sq_name, total=None)
|
|
811
|
+
_import_loop(curs)
|
|
812
|
+
else:
|
|
784
813
|
_import_loop(curs)
|
|
785
|
-
|
|
786
|
-
|
|
814
|
+
except BaseException:
|
|
815
|
+
# A bad value or short row stops the load as surely as a failed
|
|
816
|
+
# insert does; either way none of it is kept.
|
|
817
|
+
self.rollback()
|
|
818
|
+
raise
|
|
787
819
|
|
|
788
820
|
if _state.exec_log:
|
|
789
821
|
_state.exec_log.log_status_info(
|
execsql/db/dsn.py
CHANGED
|
@@ -13,7 +13,14 @@ from execsql.db.base import Database
|
|
|
13
13
|
from execsql.db.tiers import SupportTier
|
|
14
14
|
from execsql.exceptions import ErrInfo
|
|
15
15
|
from execsql.utils.errors import exception_desc, fatal_error
|
|
16
|
-
from execsql.utils.auth import
|
|
16
|
+
from execsql.utils.auth import (
|
|
17
|
+
begin_login,
|
|
18
|
+
clear_stored_password,
|
|
19
|
+
get_password,
|
|
20
|
+
is_login_failure,
|
|
21
|
+
password_from_keyring,
|
|
22
|
+
remember_password,
|
|
23
|
+
)
|
|
17
24
|
import execsql.state as _state
|
|
18
25
|
|
|
19
26
|
__all__ = ["DsnDatabase"]
|
|
@@ -56,7 +63,6 @@ class DsnDatabase(Database):
|
|
|
56
63
|
self.type = dbt_dsn
|
|
57
64
|
self.password = password
|
|
58
65
|
self.encoding = encoding
|
|
59
|
-
self.encode_commands = True
|
|
60
66
|
self.paramstr = "?"
|
|
61
67
|
self.open_db()
|
|
62
68
|
self.password = None # Clear cleartext password after successful connection
|
|
@@ -72,6 +78,7 @@ class DsnDatabase(Database):
|
|
|
72
78
|
if self.conn is not None:
|
|
73
79
|
self.conn.close()
|
|
74
80
|
self.conn = None
|
|
81
|
+
begin_login()
|
|
75
82
|
if self.need_passwd and self.user and self.password is None:
|
|
76
83
|
self.password = get_password("DSN", self.db_name, self.user)
|
|
77
84
|
|
|
@@ -126,8 +133,9 @@ class DsnDatabase(Database):
|
|
|
126
133
|
|
|
127
134
|
try:
|
|
128
135
|
_try_connect()
|
|
129
|
-
except ErrInfo:
|
|
130
|
-
|
|
136
|
+
except ErrInfo as e:
|
|
137
|
+
# Only a rejected password means the stored one is stale.
|
|
138
|
+
if not (password_from_keyring() and is_login_failure(e)):
|
|
131
139
|
raise
|
|
132
140
|
clear_stored_password("DSN", self.db_name, self.user)
|
|
133
141
|
self.password = get_password(
|
|
@@ -139,6 +147,7 @@ class DsnDatabase(Database):
|
|
|
139
147
|
)
|
|
140
148
|
self.conn = None
|
|
141
149
|
_try_connect()
|
|
150
|
+
remember_password("DSN", self.db_name, self.user or "")
|
|
142
151
|
|
|
143
152
|
def exec_cmd(self, querycommand: str) -> None:
|
|
144
153
|
"""Execute a stored procedure by name."""
|
execsql/db/duckdb.py
CHANGED
|
@@ -74,9 +74,11 @@ class DuckDBDatabase(Database):
|
|
|
74
74
|
)
|
|
75
75
|
self.type = dbt_duckdb
|
|
76
76
|
self.catalog_name = Path(DuckDB_fn).stem
|
|
77
|
-
self.encode_commands = False
|
|
78
77
|
self.open_db()
|
|
79
78
|
|
|
79
|
+
def current_schema(self) -> str | None:
|
|
80
|
+
return self._query_current_schema("select current_schema()")
|
|
81
|
+
|
|
80
82
|
def __repr__(self) -> str:
|
|
81
83
|
return f"DuckDBDatabase({self.db_name!r})"
|
|
82
84
|
|
execsql/db/factory.py
CHANGED
|
@@ -64,7 +64,16 @@ def db_Postgres(
|
|
|
64
64
|
password: str | None = None,
|
|
65
65
|
) -> PostgresDatabase:
|
|
66
66
|
"""Open a new PostgreSQL connection via psycopg (psycopg3)."""
|
|
67
|
-
return PostgresDatabase(
|
|
67
|
+
return PostgresDatabase(
|
|
68
|
+
server_name,
|
|
69
|
+
database_name,
|
|
70
|
+
user,
|
|
71
|
+
pw_needed,
|
|
72
|
+
port,
|
|
73
|
+
new_db=new_db,
|
|
74
|
+
encoding=encoding,
|
|
75
|
+
password=password,
|
|
76
|
+
)
|
|
68
77
|
|
|
69
78
|
|
|
70
79
|
def db_SQLite(
|
|
@@ -143,7 +152,7 @@ def db_Oracle(
|
|
|
143
152
|
encoding: str | None = None,
|
|
144
153
|
password: str | None = None,
|
|
145
154
|
) -> OracleDatabase:
|
|
146
|
-
"""Open an Oracle database connection via
|
|
155
|
+
"""Open an Oracle database connection via python-oracledb."""
|
|
147
156
|
return OracleDatabase(server_name, database_name, user, pw_needed, port, encoding, password=password)
|
|
148
157
|
|
|
149
158
|
|
execsql/db/firebird.py
CHANGED
|
@@ -12,7 +12,14 @@ from execsql.db.base import Database
|
|
|
12
12
|
from execsql.db.tiers import SupportTier
|
|
13
13
|
from execsql.exceptions import ErrInfo
|
|
14
14
|
from execsql.utils.errors import exception_desc, fatal_error
|
|
15
|
-
from execsql.utils.auth import
|
|
15
|
+
from execsql.utils.auth import (
|
|
16
|
+
begin_login,
|
|
17
|
+
clear_stored_password,
|
|
18
|
+
get_password,
|
|
19
|
+
is_login_failure,
|
|
20
|
+
password_from_keyring,
|
|
21
|
+
remember_password,
|
|
22
|
+
)
|
|
16
23
|
import execsql.state as _state
|
|
17
24
|
|
|
18
25
|
__all__ = ["FirebirdDatabase"]
|
|
@@ -54,7 +61,6 @@ class FirebirdDatabase(Database):
|
|
|
54
61
|
self.type = dbt_firebird
|
|
55
62
|
self.password = password
|
|
56
63
|
self.encoding = encoding or "latin1"
|
|
57
|
-
self.encode_commands = True
|
|
58
64
|
self.paramstr = "?"
|
|
59
65
|
self.open_db()
|
|
60
66
|
self.password = None # Clear cleartext password after successful connection
|
|
@@ -100,28 +106,34 @@ class FirebirdDatabase(Database):
|
|
|
100
106
|
|
|
101
107
|
if self.conn is None:
|
|
102
108
|
try:
|
|
109
|
+
begin_login()
|
|
103
110
|
if self.user and self.need_passwd and not self.password:
|
|
104
111
|
self.password = get_password(
|
|
105
112
|
"Firebird",
|
|
106
113
|
self.db_name,
|
|
107
114
|
self.user,
|
|
108
115
|
server_name=self.server_name,
|
|
116
|
+
port=self.port,
|
|
109
117
|
)
|
|
110
118
|
try:
|
|
111
119
|
self.conn = db_conn()
|
|
112
|
-
except Exception:
|
|
113
|
-
|
|
120
|
+
except Exception as e:
|
|
121
|
+
# Only a rejected password means the stored one is stale;
|
|
122
|
+
# a timeout or a missing database leaves it alone.
|
|
123
|
+
if not (password_from_keyring() and is_login_failure(e)):
|
|
114
124
|
raise
|
|
115
|
-
clear_stored_password("Firebird", self.db_name, self.user, self.server_name)
|
|
125
|
+
clear_stored_password("Firebird", self.db_name, self.user, self.server_name, port=self.port)
|
|
116
126
|
self.password = get_password(
|
|
117
127
|
"Firebird",
|
|
118
128
|
self.db_name,
|
|
119
129
|
self.user,
|
|
120
130
|
server_name=self.server_name,
|
|
131
|
+
port=self.port,
|
|
121
132
|
skip_keyring=True,
|
|
122
133
|
other_msg="(stored credential failed — enter current password)",
|
|
123
134
|
)
|
|
124
135
|
self.conn = db_conn()
|
|
136
|
+
remember_password("Firebird", self.db_name, self.user, self.server_name, port=self.port)
|
|
125
137
|
except SystemExit:
|
|
126
138
|
# If the user canceled the password prompt.
|
|
127
139
|
raise
|
|
@@ -143,6 +155,9 @@ class FirebirdDatabase(Database):
|
|
|
143
155
|
raise
|
|
144
156
|
_state.subvars.add_substitution("$LAST_ROWCOUNT", curs.rowcount)
|
|
145
157
|
|
|
158
|
+
# table_exists() without a schema looks only where CREATE TABLE puts a table: one namespace.
|
|
159
|
+
unqualified_lookup_is_scoped = True
|
|
160
|
+
|
|
146
161
|
def table_exists(self, table_name: str, schema_name: str | None = None) -> bool:
|
|
147
162
|
"""Return True if the named table exists in the Firebird database."""
|
|
148
163
|
sql = (
|
|
@@ -243,8 +258,7 @@ class FirebirdDatabase(Database):
|
|
|
243
258
|
return len(rows) > 0
|
|
244
259
|
|
|
245
260
|
def drop_table(self, tablename: str) -> None:
|
|
246
|
-
"""Drop
|
|
261
|
+
"""Drop *tablename*, schema-qualified and quoted by the caller."""
|
|
247
262
|
# Firebird will thrown an error if there are foreign keys into the table.
|
|
248
|
-
tablename = self.type.quoted(tablename)
|
|
249
263
|
self.execute(f"DROP TABLE {tablename};")
|
|
250
264
|
self.conn.commit()
|
execsql/db/mysql.py
CHANGED
|
@@ -13,7 +13,14 @@ from typing import Any, cast
|
|
|
13
13
|
from execsql.db.base import Database
|
|
14
14
|
from execsql.exceptions import ErrInfo
|
|
15
15
|
from execsql.utils.errors import exception_desc, fatal_error
|
|
16
|
-
from execsql.utils.auth import
|
|
16
|
+
from execsql.utils.auth import (
|
|
17
|
+
begin_login,
|
|
18
|
+
clear_stored_password,
|
|
19
|
+
get_password,
|
|
20
|
+
is_login_failure,
|
|
21
|
+
password_from_keyring,
|
|
22
|
+
remember_password,
|
|
23
|
+
)
|
|
17
24
|
import execsql.state as _state
|
|
18
25
|
|
|
19
26
|
__all__ = ["MySQLDatabase"]
|
|
@@ -93,7 +100,6 @@ class MySQLDatabase(Database):
|
|
|
93
100
|
# Greek, or Cyrillic at all — it raises UnicodeEncodeError before the
|
|
94
101
|
# server is reached — so it silently capped what execsql could move.
|
|
95
102
|
self.encoding = encoding or "utf8mb4"
|
|
96
|
-
self.encode_commands = True
|
|
97
103
|
self.paramstr = "%s"
|
|
98
104
|
self.open_db()
|
|
99
105
|
self.password = None # Clear cleartext password after successful connection
|
|
@@ -149,6 +155,9 @@ class MySQLDatabase(Database):
|
|
|
149
155
|
"""Lowercase *name* when the server is case-insensitive (LCTN 1/2)."""
|
|
150
156
|
return name.lower() if self._lower_case_table_names() in (1, 2) else name
|
|
151
157
|
|
|
158
|
+
def current_schema(self) -> str | None:
|
|
159
|
+
return self._query_current_schema("select database()")
|
|
160
|
+
|
|
152
161
|
# NB: schema_exists is overridden below to return False unconditionally
|
|
153
162
|
# — MySQL's pre-existing behavior. The case-folding only matters for
|
|
154
163
|
# adapters where schema lookups are meaningful, which MySQL skips.
|
|
@@ -209,28 +218,34 @@ class MySQLDatabase(Database):
|
|
|
209
218
|
|
|
210
219
|
if self.conn is None:
|
|
211
220
|
try:
|
|
221
|
+
begin_login()
|
|
212
222
|
if self.user and self.need_passwd and not self.password:
|
|
213
223
|
self.password = get_password(
|
|
214
224
|
"MySQL",
|
|
215
225
|
self.db_name,
|
|
216
226
|
self.user,
|
|
217
227
|
server_name=self.server_name,
|
|
228
|
+
port=self.port,
|
|
218
229
|
)
|
|
219
230
|
try:
|
|
220
231
|
self.conn = db_conn()
|
|
221
|
-
except Exception:
|
|
222
|
-
|
|
232
|
+
except Exception as e:
|
|
233
|
+
# Only a rejected password means the stored one is stale;
|
|
234
|
+
# a timeout or a missing database leaves it alone.
|
|
235
|
+
if not (password_from_keyring() and is_login_failure(e)):
|
|
223
236
|
raise
|
|
224
|
-
clear_stored_password("MySQL", self.db_name, self.user, self.server_name)
|
|
237
|
+
clear_stored_password("MySQL", self.db_name, self.user, self.server_name, port=self.port)
|
|
225
238
|
self.password = get_password(
|
|
226
239
|
"MySQL",
|
|
227
240
|
self.db_name,
|
|
228
241
|
self.user,
|
|
229
242
|
server_name=self.server_name,
|
|
243
|
+
port=self.port,
|
|
230
244
|
skip_keyring=True,
|
|
231
245
|
other_msg="(stored credential failed — enter current password)",
|
|
232
246
|
)
|
|
233
247
|
self.conn = db_conn()
|
|
248
|
+
remember_password("MySQL", self.db_name, self.user, self.server_name, port=self.port)
|
|
234
249
|
self.execute("set session sql_mode='ANSI';")
|
|
235
250
|
except SystemExit:
|
|
236
251
|
# If the user canceled the password prompt.
|
|
@@ -306,8 +321,12 @@ class MySQLDatabase(Database):
|
|
|
306
321
|
)
|
|
307
322
|
import_cols = csv_file_cols
|
|
308
323
|
input_col_list = ",".join(import_cols)
|
|
324
|
+
# LOAD DATA is told the file has no escape character (ESCAPED BY ''),
|
|
325
|
+
# so a file where one was detected takes the row-by-row path, which
|
|
326
|
+
# reads the format that was detected.
|
|
309
327
|
if (
|
|
310
|
-
|
|
328
|
+
csv_file_obj.escapechar is None
|
|
329
|
+
and data_table_cols == csv_file_cols
|
|
311
330
|
and _state.conf.empty_strings
|
|
312
331
|
and _state.conf.empty_rows
|
|
313
332
|
and not _state.conf.del_empty_cols
|
|
@@ -315,19 +334,21 @@ class MySQLDatabase(Database):
|
|
|
315
334
|
and not _state.conf.trim_strings
|
|
316
335
|
and not _state.conf.replace_newlines
|
|
317
336
|
):
|
|
318
|
-
|
|
319
|
-
|
|
337
|
+
# String literals in this session treat a backslash as an escape,
|
|
338
|
+
# so the file name, delimiter and quote character are quoted with
|
|
339
|
+
# quote_literal, which doubles it.
|
|
340
|
+
import_sql = f"load data local infile {self.quote_literal(csv_file_obj.csvfname)} into table {sq_name}"
|
|
320
341
|
if csv_file_obj.encoding:
|
|
321
342
|
charset = _PYTHON_TO_MYSQL_CHARSET.get(csv_file_obj.encoding.lower(), csv_file_obj.encoding)
|
|
322
343
|
import_sql = f"{import_sql} character set {charset}"
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
344
|
+
import_sql = import_sql + " columns"
|
|
345
|
+
if csv_file_obj.delimiter:
|
|
346
|
+
import_sql = f"{import_sql} terminated by {self.quote_literal(csv_file_obj.delimiter)}"
|
|
347
|
+
if csv_file_obj.quotechar:
|
|
348
|
+
import_sql = f"{import_sql} optionally enclosed by {self.quote_literal(csv_file_obj.quotechar)}"
|
|
349
|
+
# MySQL's default ESCAPED BY '\\' turned C:\new into a newline and
|
|
350
|
+
# \N into NULL; every other backend keeps the text as written.
|
|
351
|
+
import_sql = f"{import_sql} escaped by ''"
|
|
331
352
|
# Tell LOAD DATA what ends a line. Its default is "\n", so a CRLF
|
|
332
353
|
# file left a carriage return on the last field of every row: the
|
|
333
354
|
# value was not empty, NULLIF below could not see it, and a date
|