execsql2 2.23.1__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.
Files changed (92) hide show
  1. execsql/api.py +169 -160
  2. execsql/cli/__init__.py +19 -586
  3. execsql/cli/application.py +309 -0
  4. execsql/cli/commands/__init__.py +13 -0
  5. execsql/cli/commands/config.py +205 -0
  6. execsql/cli/commands/format.py +100 -0
  7. execsql/cli/commands/init.py +135 -0
  8. execsql/cli/commands/lint.py +225 -0
  9. execsql/cli/commands/listing.py +81 -0
  10. execsql/cli/commands/run.py +459 -0
  11. execsql/cli/dispatch.py +66 -0
  12. execsql/cli/dsn.py +11 -4
  13. execsql/cli/help.py +175 -24
  14. execsql/cli/lint.py +780 -100
  15. execsql/cli/options.py +108 -0
  16. execsql/cli/run.py +96 -32
  17. execsql/config.py +256 -173
  18. execsql/config_validation.py +282 -0
  19. execsql/data/execsql.conf.template +38 -5
  20. execsql/data/script_header.sql +23 -0
  21. execsql/db/access.py +29 -8
  22. execsql/db/base.py +40 -8
  23. execsql/db/dsn.py +13 -4
  24. execsql/db/duckdb.py +3 -1
  25. execsql/db/factory.py +13 -4
  26. execsql/db/firebird.py +21 -7
  27. execsql/db/mysql.py +37 -16
  28. execsql/db/oracle.py +49 -16
  29. execsql/db/postgres.py +30 -6
  30. execsql/db/sqlite.py +7 -9
  31. execsql/db/sqlserver.py +25 -8
  32. execsql/debug/repl.py +51 -193
  33. execsql/exceptions.py +9 -0
  34. execsql/exporters/delimited.py +87 -28
  35. execsql/exporters/xlsx.py +24 -9
  36. execsql/exporters/zip.py +1 -2
  37. execsql/format.py +115 -76
  38. execsql/gui/desktop.py +1 -3
  39. execsql/gui/tui.py +2 -2
  40. execsql/importers/base.py +35 -1
  41. execsql/importers/csv.py +3 -1
  42. execsql/interactive.py +232 -0
  43. execsql/manifest.py +200 -0
  44. execsql/metacommands/conditions.py +7 -8
  45. execsql/metacommands/control.py +2 -3
  46. execsql/metacommands/data.py +4 -1
  47. execsql/metacommands/debug.py +2 -4
  48. execsql/metacommands/effects.py +124 -0
  49. execsql/metacommands/io_fileops.py +62 -37
  50. execsql/metacommands/io_import.py +0 -1
  51. execsql/metacommands/prompt.py +2 -0
  52. execsql/metacommands/upsert.py +5 -2
  53. execsql/models.py +3 -2
  54. execsql/parser.py +14 -5
  55. execsql/plugins.py +24 -0
  56. execsql/script/__init__.py +0 -9
  57. execsql/script/ast.py +1 -1
  58. execsql/script/engine.py +46 -104
  59. execsql/script/executor.py +162 -39
  60. execsql/script/parser.py +167 -118
  61. execsql/state.py +34 -11
  62. execsql/state.pyi +11 -0
  63. execsql/types.py +22 -13
  64. execsql/utils/auth.py +169 -24
  65. execsql/utils/color.py +18 -0
  66. execsql/utils/datetime.py +55 -17
  67. execsql/utils/errors.py +82 -10
  68. execsql/utils/fileio.py +404 -117
  69. execsql/utils/gui.py +3 -0
  70. execsql/utils/mail.py +37 -12
  71. {execsql2-2.23.1.data → execsql2-2.25.0.data}/data/execsql2_extras/README.md +1 -1
  72. {execsql2-2.23.1.dist-info → execsql2-2.25.0.dist-info}/METADATA +122 -63
  73. execsql2-2.25.0.dist-info/RECORD +140 -0
  74. execsql2-2.25.0.dist-info/entry_points.txt +2 -0
  75. execsql2-2.23.1.dist-info/RECORD +0 -124
  76. execsql2-2.23.1.dist-info/entry_points.txt +0 -3
  77. {execsql2-2.23.1.data → execsql2-2.25.0.data}/data/execsql2_extras/config_settings.sqlite +0 -0
  78. {execsql2-2.23.1.data → execsql2-2.25.0.data}/data/execsql2_extras/example_config_prompt.sql +0 -0
  79. {execsql2-2.23.1.data → execsql2-2.25.0.data}/data/execsql2_extras/make_config_db.sql +0 -0
  80. {execsql2-2.23.1.data → execsql2-2.25.0.data}/data/execsql2_extras/md_compare.sql +0 -0
  81. {execsql2-2.23.1.data → execsql2-2.25.0.data}/data/execsql2_extras/md_glossary.sql +0 -0
  82. {execsql2-2.23.1.data → execsql2-2.25.0.data}/data/execsql2_extras/md_upsert.sql +0 -0
  83. {execsql2-2.23.1.data → execsql2-2.25.0.data}/data/execsql2_extras/pg_compare.sql +0 -0
  84. {execsql2-2.23.1.data → execsql2-2.25.0.data}/data/execsql2_extras/pg_glossary.sql +0 -0
  85. {execsql2-2.23.1.data → execsql2-2.25.0.data}/data/execsql2_extras/pg_upsert.sql +0 -0
  86. {execsql2-2.23.1.data → execsql2-2.25.0.data}/data/execsql2_extras/script_template.sql +0 -0
  87. {execsql2-2.23.1.data → execsql2-2.25.0.data}/data/execsql2_extras/ss_compare.sql +0 -0
  88. {execsql2-2.23.1.data → execsql2-2.25.0.data}/data/execsql2_extras/ss_glossary.sql +0 -0
  89. {execsql2-2.23.1.data → execsql2-2.25.0.data}/data/execsql2_extras/ss_upsert.sql +0 -0
  90. {execsql2-2.23.1.dist-info → execsql2-2.25.0.dist-info}/WHEEL +0 -0
  91. {execsql2-2.23.1.dist-info → execsql2-2.25.0.dist-info}/licenses/LICENSE.txt +0 -0
  92. {execsql2-2.23.1.dist-info → execsql2-2.25.0.dist-info}/licenses/NOTICE +0 -0
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(server_name, database_name, user, pw_needed, port, new_db=new_db, password=password)
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(
@@ -84,7 +93,7 @@ def db_SQLite(
84
93
  if not Path(sqlite_fn).exists():
85
94
  raise ErrInfo(
86
95
  type="error",
87
- other_msg=f'SQLite database file "{sqlite_fn}" does not exist.',
96
+ other_msg=f'SQLite database file "{sqlite_fn}" does not exist. Use -n (--new-db) to create it.',
88
97
  )
89
98
  return SQLiteDatabase(sqlite_fn)
90
99
 
@@ -129,7 +138,7 @@ def db_DuckDB(
129
138
  if not Path(duckdb_fn).exists():
130
139
  raise ErrInfo(
131
140
  type="error",
132
- other_msg=f'DuckDB database file "{duckdb_fn}" does not exist.',
141
+ other_msg=f'DuckDB database file "{duckdb_fn}" does not exist. Use -n (--new-db) to create it.',
133
142
  )
134
143
  return DuckDBDatabase(duckdb_fn)
135
144
 
@@ -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 cx_Oracle (python-oracledb)."""
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 clear_stored_password, get_password, password_from_keyring
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
- if not password_from_keyring():
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 the named table from the Firebird database."""
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 clear_stored_password, get_password, password_from_keyring
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
- if not password_from_keyring():
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
- data_table_cols == csv_file_cols
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
- safe_fname = csv_file_obj.csvfname.replace("'", "''")
319
- import_sql = f"load data local infile '{safe_fname}' into table {sq_name}"
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
- if csv_file_obj.delimiter or csv_file_obj.quotechar:
324
- import_sql = import_sql + " columns"
325
- if csv_file_obj.delimiter:
326
- safe_delim = csv_file_obj.delimiter.replace("'", "''")
327
- import_sql = f"{import_sql} terminated by '{safe_delim}'"
328
- if csv_file_obj.quotechar:
329
- safe_quote = csv_file_obj.quotechar.replace("'", "''")
330
- import_sql = f"{import_sql} optionally enclosed by '{safe_quote}'"
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
execsql/db/oracle.py CHANGED
@@ -15,14 +15,42 @@ from execsql.db.base import Database
15
15
  from execsql.db.tiers import SupportTier
16
16
  from execsql.exceptions import ErrInfo
17
17
  from execsql.utils.errors import exception_desc, fatal_error
18
- from execsql.utils.auth import clear_stored_password, get_password, password_from_keyring
18
+ from execsql.utils.auth import (
19
+ begin_login,
20
+ clear_stored_password,
21
+ get_password,
22
+ is_login_failure,
23
+ password_from_keyring,
24
+ remember_password,
25
+ )
19
26
  import execsql.state as _state
20
27
 
21
28
  __all__ = ["OracleDatabase"]
22
29
 
23
30
 
31
+ def _oracle_driver() -> Any:
32
+ """The Oracle driver module: python-oracledb, or else the legacy cx_Oracle.
33
+
34
+ ``execsql2[oracle]`` installs oracledb. cx_Oracle, the driver it replaced
35
+ (same ``makedsn`` / ``connect`` calls), is used only when it is the one
36
+ installed. ``None`` if neither is.
37
+ """
38
+ try:
39
+ import oracledb
40
+
41
+ return oracledb
42
+ except ImportError:
43
+ pass
44
+ try:
45
+ import cx_Oracle
46
+
47
+ return cx_Oracle
48
+ except ImportError:
49
+ return None
50
+
51
+
24
52
  class OracleDatabase(Database):
25
- """Oracle adapter using the cx_Oracle (python-oracledb) driver."""
53
+ """Oracle adapter using the python-oracledb driver."""
26
54
 
27
55
  #: Oracle commits every DDL statement at once, together with everything
28
56
  #: before it, including CREATE GLOBAL TEMPORARY TABLE.
@@ -46,11 +74,9 @@ class OracleDatabase(Database):
46
74
  encoding: str | None = "UTF8",
47
75
  password: str | None = None,
48
76
  ) -> None:
49
- try:
50
- import cx_Oracle # noqa: F401
51
- except Exception:
77
+ if _oracle_driver() is None:
52
78
  fatal_error(
53
- "The cx-Oracle module is required to connect to Oracle. See https://pypi.org/project/cx-Oracle/",
79
+ 'The oracledb module is required to connect to Oracle. Install it with: pip install "execsql2[oracle]"',
54
80
  )
55
81
  from execsql.types import dbt_oracle
56
82
 
@@ -64,7 +90,6 @@ class OracleDatabase(Database):
64
90
  self.type = dbt_oracle
65
91
  self.password = password
66
92
  self.encoding = encoding or "UTF8"
67
- self.encode_commands = False
68
93
  self.paramstr = ":1"
69
94
  self.open_db()
70
95
  self.password = None # Clear cleartext password after successful connection
@@ -77,39 +102,45 @@ class OracleDatabase(Database):
77
102
 
78
103
  def open_db(self) -> None:
79
104
  """Open a connection to the Oracle database."""
80
- import cx_Oracle
105
+ driver = _oracle_driver()
81
106
 
82
107
  def db_conn(db: OracleDatabase, db_name: str):
83
- dsn = cx_Oracle.makedsn(db.server_name, db.port, service_name=db_name)
108
+ dsn = driver.makedsn(db.server_name, db.port, service_name=db_name)
84
109
  if db.user and db.password:
85
- return cx_Oracle.connect(user=db.user, password=db.password, dsn=dsn)
110
+ return driver.connect(user=db.user, password=db.password, dsn=dsn)
86
111
  else:
87
- return cx_Oracle.connect(dsn=dsn)
112
+ return driver.connect(dsn=dsn)
88
113
 
89
114
  if self.conn is None:
90
115
  try:
116
+ begin_login()
91
117
  if self.user and self.need_passwd and not self.password:
92
118
  self.password = get_password(
93
119
  "Oracle",
94
120
  self.db_name,
95
121
  self.user,
96
122
  server_name=self.server_name,
123
+ port=self.port,
97
124
  )
98
125
  try:
99
126
  self.conn = db_conn(self, self.db_name)
100
- except Exception:
101
- if not password_from_keyring():
127
+ except Exception as e:
128
+ # Only a rejected password means the stored one is stale;
129
+ # a timeout or a missing database leaves it alone.
130
+ if not (password_from_keyring() and is_login_failure(e)):
102
131
  raise
103
- clear_stored_password("Oracle", self.db_name, self.user, self.server_name)
132
+ clear_stored_password("Oracle", self.db_name, self.user, self.server_name, port=self.port)
104
133
  self.password = get_password(
105
134
  "Oracle",
106
135
  self.db_name,
107
136
  self.user,
108
137
  server_name=self.server_name,
138
+ port=self.port,
109
139
  skip_keyring=True,
110
140
  other_msg="(stored credential failed — enter current password)",
111
141
  )
112
142
  self.conn = db_conn(self, self.db_name)
143
+ remember_password("Oracle", self.db_name, self.user, self.server_name, port=self.port)
113
144
  except SystemExit:
114
145
  # If the user canceled the password prompt.
115
146
  raise
@@ -153,6 +184,9 @@ class OracleDatabase(Database):
153
184
 
154
185
  raise DatabaseNotImplementedError(self.name(), "schema_exists")
155
186
 
187
+ def current_schema(self) -> str | None:
188
+ return self._query_current_schema("select sys_context('USERENV', 'CURRENT_SCHEMA') from dual")
189
+
156
190
  def table_exists(self, table_name: str, schema_name: str | None = None) -> bool:
157
191
  """Return True if the named table exists in the Oracle database."""
158
192
  # Oracle folds unquoted identifiers to uppercase; normalise so a lookup
@@ -288,8 +322,7 @@ class OracleDatabase(Database):
288
322
  return len(rows) > 0
289
323
 
290
324
  def drop_table(self, tablename: str) -> None:
291
- """Drop the named table with cascade constraints."""
292
- tablename = self.type.quoted(tablename)
325
+ """Drop *tablename* (schema-qualified and quoted by the caller) with cascade constraints."""
293
326
  self.execute(f"drop table {tablename} cascade constraints")
294
327
 
295
328
  def paramsubs(self, paramcount: int) -> str:
execsql/db/postgres.py CHANGED
@@ -16,7 +16,14 @@ from typing import Any
16
16
  from execsql.db.base import Database
17
17
  from execsql.exceptions import ErrInfo
18
18
  from execsql.utils.errors import exception_desc, fatal_error
19
- from execsql.utils.auth import clear_stored_password, get_password, password_from_keyring
19
+ from execsql.utils.auth import (
20
+ begin_login,
21
+ clear_stored_password,
22
+ get_password,
23
+ is_login_failure,
24
+ password_from_keyring,
25
+ remember_password,
26
+ )
20
27
  from execsql.utils.strings import encodings_match
21
28
  import execsql.state as _state
22
29
 
@@ -60,7 +67,6 @@ class PostgresDatabase(Database):
60
67
  self.type = dbt_postgres
61
68
  self.password = password
62
69
  self.new_db = new_db
63
- self.encode_commands = False
64
70
  self.paramstr = "%s"
65
71
  self.connect_timeout = connect_timeout
66
72
  self.open_db()
@@ -123,31 +129,37 @@ class PostgresDatabase(Database):
123
129
 
124
130
  if self.conn is None:
125
131
  try:
132
+ begin_login()
126
133
  if self.user and self.need_passwd and not self.password:
127
134
  self.password = get_password(
128
135
  "PostgreSQL",
129
136
  self.db_name,
130
137
  self.user,
131
138
  server_name=self.server_name,
139
+ port=self.port,
132
140
  )
133
141
  if self.new_db:
134
142
  create_db(self)
135
143
  try:
136
144
  self.conn = db_conn(self, self.db_name)
137
- except (ErrInfo, Exception):
138
- if not password_from_keyring():
145
+ except (ErrInfo, Exception) as e:
146
+ # Only a rejected password means the stored one is stale;
147
+ # a timeout or a missing database leaves it alone.
148
+ if not (password_from_keyring() and is_login_failure(e)):
139
149
  raise
140
150
  # Stored credential is stale — clear it and re-prompt.
141
- clear_stored_password("PostgreSQL", self.db_name, self.user, self.server_name)
151
+ clear_stored_password("PostgreSQL", self.db_name, self.user, self.server_name, port=self.port)
142
152
  self.password = get_password(
143
153
  "PostgreSQL",
144
154
  self.db_name,
145
155
  self.user,
146
156
  server_name=self.server_name,
157
+ port=self.port,
147
158
  skip_keyring=True,
148
159
  other_msg="(stored credential failed — enter current password)",
149
160
  )
150
161
  self.conn = db_conn(self, self.db_name)
162
+ remember_password("PostgreSQL", self.db_name, self.user, self.server_name, port=self.port)
151
163
  except SystemExit:
152
164
  # If the user canceled the password prompt.
153
165
  raise
@@ -178,6 +190,9 @@ class PostgresDatabase(Database):
178
190
  rows = curs.fetchall()
179
191
  return len(rows) > 0
180
192
 
193
+ # table_exists() without a schema looks only where CREATE TABLE puts a table: it searches the temp schema and the search path.
194
+ unqualified_lookup_is_scoped = True
195
+
181
196
  def table_exists(self, table_name: str, schema_name: str | None = None) -> bool:
182
197
  """Return True if the named table exists in the PostgreSQL database."""
183
198
  if schema_name is not None:
@@ -336,6 +351,8 @@ class PostgresDatabase(Database):
336
351
  and not _state.conf.trim_strings
337
352
  and not _state.conf.replace_newlines
338
353
  ):
354
+ from execsql.exporters.delimited import blank_lines_as_empty_rows
355
+
339
356
  # Use Postgres' COPY FROM method via psycopg3's cursor.copy() context manager.
340
357
  rf = csv_file_obj.open("rt")
341
358
  if skipheader:
@@ -364,7 +381,14 @@ class PostgresDatabase(Database):
364
381
  with self._cursor() as curs:
365
382
  try:
366
383
  with curs.copy(copy_cmd) as copy:
367
- while chunk := rf.read(_state.conf.import_buffer):
384
+ # COPY rejects a blank line; the row path reads it as an empty row.
385
+ for chunk in blank_lines_as_empty_rows(
386
+ rf,
387
+ delim,
388
+ csv_file_obj.quotechar,
389
+ len(csv_file_cols),
390
+ _state.conf.import_buffer,
391
+ ):
368
392
  copy.write(chunk)
369
393
  except ErrInfo:
370
394
  raise
execsql/db/sqlite.py CHANGED
@@ -45,7 +45,6 @@ class SQLiteDatabase(Database):
45
45
  encoding="UTF-8",
46
46
  )
47
47
  self.type = dbt_sqlite
48
- self.encode_commands = False
49
48
  self.timeout = timeout
50
49
  self.open_db()
51
50
 
@@ -95,6 +94,9 @@ class SQLiteDatabase(Database):
95
94
  self.rollback()
96
95
  raise
97
96
 
97
+ # table_exists() without a schema looks only where CREATE TABLE puts a table: one namespace.
98
+ unqualified_lookup_is_scoped = True
99
+
98
100
  def table_exists(self, table_name: str, schema_name: str | None = None) -> bool:
99
101
  """Return True if the named table exists in the SQLite database."""
100
102
  # SQLite stores names as-created; match case-insensitively so a lookup
@@ -170,8 +172,7 @@ class SQLiteDatabase(Database):
170
172
  return False
171
173
 
172
174
  def drop_table(self, tablename: str) -> None:
173
- """Drop the named table from the SQLite database if it exists."""
174
- tablename = self.type.quoted(tablename)
175
+ """Drop *tablename* (schema-qualified and quoted by the caller) if it exists."""
175
176
  self.execute(f"drop table if exists {tablename};")
176
177
 
177
178
  def populate_table(
@@ -237,14 +238,11 @@ class SQLiteDatabase(Database):
237
238
 
238
239
  last_line: list[Any] = []
239
240
  try:
240
- for datalineno, line in enumerate(rowsource):
241
+ for line in rowsource:
241
242
  # Skip empty rows.
242
243
  if not (len(line) == 1 and line[0] is None):
243
- if len(line) < len(columns):
244
- raise ErrInfo(
245
- type="error",
246
- other_msg=f"Too few values on data line {datalineno} of input.",
247
- )
244
+ # Trailing values left off the line are NULL.
245
+ line = list(line) + [None] * (len(ts_colnames) - len(line))
248
246
  if _state.conf.trim_strings or _state.conf.replace_newlines or not _state.conf.empty_strings:
249
247
  for i in range(len(line)):
250
248
  if line[i] is not None and isinstance(line[i], _state.stringtypes):
execsql/db/sqlserver.py CHANGED
@@ -12,7 +12,14 @@ import re
12
12
  from execsql.db.base import Database
13
13
  from execsql.exceptions import ErrInfo
14
14
  from execsql.utils.errors import fatal_error
15
- from execsql.utils.auth import clear_stored_password, get_password, password_from_keyring
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__ = ["SqlServerDatabase"]
@@ -47,11 +54,13 @@ class SqlServerDatabase(Database):
47
54
  self.type = dbt_sqlserver
48
55
  self.password = password
49
56
  self.encoding = encoding or "latin1" # Default on installation of SQL Server
50
- self.encode_commands = True
51
57
  self.paramstr = "?"
52
58
  self.open_db()
53
59
  self.password = None # Clear cleartext password after successful connection
54
60
 
61
+ def current_schema(self) -> str | None:
62
+ return self._query_current_schema("select schema_name()")
63
+
55
64
  def __repr__(self) -> str:
56
65
  return (
57
66
  f"SqlServerDatabase({self.server_name!r}, {self.db_name!r}, {self.user!r}, "
@@ -75,12 +84,14 @@ class SqlServerDatabase(Database):
75
84
  import pyodbc
76
85
 
77
86
  if self.conn is None:
87
+ begin_login()
78
88
  if self.user and self.need_passwd and not self.password:
79
89
  self.password = get_password(
80
90
  "SQL Server",
81
91
  self.db_name,
82
92
  self.user,
83
93
  server_name=self.server_name,
94
+ port=self.port,
84
95
  )
85
96
  # Use pyodbc to connect. Try different driver versions from newest to oldest.
86
97
  ssdrivers = (
@@ -94,6 +105,8 @@ class SqlServerDatabase(Database):
94
105
  "SQL Server",
95
106
  )
96
107
 
108
+ errors: list[BaseException] = []
109
+
97
110
  def _try_drivers():
98
111
  for drv in ssdrivers:
99
112
  if self.user:
@@ -117,7 +130,8 @@ class SqlServerDatabase(Database):
117
130
  # default; pyodbc treats `timeout` as the login-and-
118
131
  # query timeout on the connection.
119
132
  self.conn = pyodbc.connect(connstr, timeout=30)
120
- except Exception:
133
+ except Exception as e:
134
+ errors.append(e)
121
135
  if _state.exec_log is not None:
122
136
  _state.exec_log.log_status_info(
123
137
  f"Could not connect using: {re.sub(r'Pwd=[^;]*', 'Pwd=***', connstr)}",
@@ -130,18 +144,22 @@ class SqlServerDatabase(Database):
130
144
  return True
131
145
  return False
132
146
 
133
- if not _try_drivers() and password_from_keyring():
134
- # Stored credential is stale — clear it and re-prompt.
135
- clear_stored_password("SQL Server", self.db_name, self.user, self.server_name)
147
+ # Only a rejected password means the stored one is stale; a
148
+ # timeout or a missing database leaves it alone.
149
+ if not _try_drivers() and password_from_keyring() and any(is_login_failure(e) for e in errors):
150
+ clear_stored_password("SQL Server", self.db_name, self.user, self.server_name, port=self.port)
136
151
  self.password = get_password(
137
152
  "SQL Server",
138
153
  self.db_name,
139
154
  self.user,
140
155
  server_name=self.server_name,
156
+ port=self.port,
141
157
  skip_keyring=True,
142
158
  other_msg="(stored credential failed — enter current password)",
143
159
  )
144
160
  _try_drivers()
161
+ if self.conn:
162
+ remember_password("SQL Server", self.db_name, self.user, self.server_name, port=self.port)
145
163
 
146
164
  if not self.conn:
147
165
  raise ErrInfo(
@@ -193,9 +211,8 @@ class SqlServerDatabase(Database):
193
211
  return len(rows) > 0
194
212
 
195
213
  def drop_table(self, tablename: str) -> None:
196
- """Drop the named table from the SQL Server database."""
214
+ """Drop *tablename*, schema-qualified and quoted by the caller."""
197
215
  # SQL Server and Firebird will throw an error if there are foreign keys to the table.
198
- tablename = self.type.quoted(tablename)
199
216
  self.execute(f"drop table {tablename};")
200
217
 
201
218
  def import_entire_file(