execsql2 2.1.2__py3-none-any.whl → 2.4.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 (94) hide show
  1. execsql/cli/__init__.py +436 -0
  2. execsql/cli/dsn.py +86 -0
  3. execsql/cli/help.py +140 -0
  4. execsql/{cli.py → cli/run.py} +14 -589
  5. execsql/config.py +65 -1
  6. execsql/db/access.py +27 -15
  7. execsql/db/base.py +328 -215
  8. execsql/db/dsn.py +10 -5
  9. execsql/db/duckdb.py +6 -2
  10. execsql/db/factory.py +21 -0
  11. execsql/db/firebird.py +27 -19
  12. execsql/db/mysql.py +12 -7
  13. execsql/db/oracle.py +15 -11
  14. execsql/db/postgres.py +31 -16
  15. execsql/db/sqlite.py +15 -11
  16. execsql/db/sqlserver.py +16 -5
  17. execsql/exceptions.py +25 -7
  18. execsql/exporters/base.py +12 -1
  19. execsql/exporters/delimited.py +80 -35
  20. execsql/exporters/duckdb.py +6 -2
  21. execsql/exporters/feather.py +10 -6
  22. execsql/exporters/html.py +89 -69
  23. execsql/exporters/json.py +52 -45
  24. execsql/exporters/latex.py +37 -27
  25. execsql/exporters/ods.py +32 -11
  26. execsql/exporters/parquet.py +5 -2
  27. execsql/exporters/pretty.py +16 -9
  28. execsql/exporters/raw.py +22 -16
  29. execsql/exporters/sqlite.py +6 -2
  30. execsql/exporters/templates.py +39 -21
  31. execsql/exporters/values.py +26 -20
  32. execsql/exporters/xls.py +30 -11
  33. execsql/exporters/xml.py +31 -13
  34. execsql/exporters/zip.py +15 -0
  35. execsql/importers/base.py +6 -4
  36. execsql/importers/csv.py +8 -6
  37. execsql/importers/feather.py +6 -4
  38. execsql/importers/ods.py +6 -4
  39. execsql/importers/xls.py +6 -4
  40. execsql/metacommands/__init__.py +208 -1548
  41. execsql/metacommands/conditions.py +101 -27
  42. execsql/metacommands/control.py +8 -4
  43. execsql/metacommands/data.py +6 -6
  44. execsql/metacommands/debug.py +6 -2
  45. execsql/metacommands/dispatch.py +2011 -0
  46. execsql/metacommands/io.py +67 -1310
  47. execsql/metacommands/io_export.py +442 -0
  48. execsql/metacommands/io_fileops.py +287 -0
  49. execsql/metacommands/io_import.py +398 -0
  50. execsql/metacommands/io_write.py +248 -0
  51. execsql/metacommands/prompt.py +22 -66
  52. execsql/metacommands/system.py +7 -2
  53. execsql/models.py +7 -0
  54. execsql/parser.py +10 -0
  55. execsql/py.typed +0 -0
  56. execsql/script/__init__.py +95 -0
  57. execsql/script/control.py +162 -0
  58. execsql/{script.py → script/engine.py} +184 -402
  59. execsql/script/variables.py +281 -0
  60. execsql/types.py +49 -20
  61. execsql/utils/auth.py +2 -0
  62. execsql/utils/crypto.py +4 -6
  63. execsql/utils/datetime.py +1 -0
  64. execsql/utils/errors.py +11 -0
  65. execsql/utils/fileio.py +33 -8
  66. execsql/utils/gui.py +46 -0
  67. execsql/utils/mail.py +7 -17
  68. execsql/utils/numeric.py +2 -0
  69. execsql/utils/regex.py +9 -0
  70. execsql/utils/strings.py +16 -0
  71. execsql/utils/timer.py +2 -0
  72. execsql2-2.4.0.data/data/execsql2_extras/README.md +65 -0
  73. {execsql2-2.1.2.data → execsql2-2.4.0.data}/data/execsql2_extras/execsql.conf +1 -1
  74. {execsql2-2.1.2.dist-info → execsql2-2.4.0.dist-info}/METADATA +13 -6
  75. execsql2-2.4.0.dist-info/RECORD +108 -0
  76. execsql2-2.1.2.data/data/execsql2_extras/READ_ME.rst +0 -127
  77. execsql2-2.1.2.dist-info/RECORD +0 -96
  78. {execsql2-2.1.2.data → execsql2-2.4.0.data}/data/execsql2_extras/config_settings.sqlite +0 -0
  79. {execsql2-2.1.2.data → execsql2-2.4.0.data}/data/execsql2_extras/example_config_prompt.sql +0 -0
  80. {execsql2-2.1.2.data → execsql2-2.4.0.data}/data/execsql2_extras/make_config_db.sql +0 -0
  81. {execsql2-2.1.2.data → execsql2-2.4.0.data}/data/execsql2_extras/md_compare.sql +0 -0
  82. {execsql2-2.1.2.data → execsql2-2.4.0.data}/data/execsql2_extras/md_glossary.sql +0 -0
  83. {execsql2-2.1.2.data → execsql2-2.4.0.data}/data/execsql2_extras/md_upsert.sql +0 -0
  84. {execsql2-2.1.2.data → execsql2-2.4.0.data}/data/execsql2_extras/pg_compare.sql +0 -0
  85. {execsql2-2.1.2.data → execsql2-2.4.0.data}/data/execsql2_extras/pg_glossary.sql +0 -0
  86. {execsql2-2.1.2.data → execsql2-2.4.0.data}/data/execsql2_extras/pg_upsert.sql +0 -0
  87. {execsql2-2.1.2.data → execsql2-2.4.0.data}/data/execsql2_extras/script_template.sql +0 -0
  88. {execsql2-2.1.2.data → execsql2-2.4.0.data}/data/execsql2_extras/ss_compare.sql +0 -0
  89. {execsql2-2.1.2.data → execsql2-2.4.0.data}/data/execsql2_extras/ss_glossary.sql +0 -0
  90. {execsql2-2.1.2.data → execsql2-2.4.0.data}/data/execsql2_extras/ss_upsert.sql +0 -0
  91. {execsql2-2.1.2.dist-info → execsql2-2.4.0.dist-info}/WHEEL +0 -0
  92. {execsql2-2.1.2.dist-info → execsql2-2.4.0.dist-info}/entry_points.txt +0 -0
  93. {execsql2-2.1.2.dist-info → execsql2-2.4.0.dist-info}/licenses/LICENSE.txt +0 -0
  94. {execsql2-2.1.2.dist-info → execsql2-2.4.0.dist-info}/licenses/NOTICE +0 -0
execsql/db/dsn.py CHANGED
@@ -15,8 +15,12 @@ from execsql.utils.errors import exception_desc, fatal_error
15
15
  from execsql.utils.auth import clear_stored_password, get_password, password_from_keyring
16
16
  import execsql.state as _state
17
17
 
18
+ __all__ = ["DsnDatabase"]
19
+
18
20
 
19
21
  class DsnDatabase(Database):
22
+ """Generic ODBC adapter that connects to any data source registered as an ODBC DSN via pyodbc."""
23
+
20
24
  # There's no telling what is actually connected to a DSN, so this uses
21
25
  # generic Database methods almost exclusively. Only 'exec_cmd()' is
22
26
  # overridden, and that uses the method for SQL Server because the DAO
@@ -79,23 +83,23 @@ class DsnDatabase(Database):
79
83
  def _try_connect():
80
84
  try:
81
85
  _dsn_connect()
82
- except Exception:
86
+ except Exception as e:
83
87
  excdesc = exception_desc()
84
88
  if "Optional feature not implemented" in excdesc:
85
89
  try:
86
90
  _dsn_connect(autocommit=True)
87
- except Exception:
91
+ except Exception as e:
88
92
  raise ErrInfo(
89
93
  type="exception",
90
94
  exception_msg=exception_desc(),
91
95
  other_msg=f"Can't open DSN database {self.db_name} using ODBC",
92
- )
96
+ ) from e
93
97
  else:
94
98
  raise ErrInfo(
95
99
  type="exception",
96
100
  exception_msg=excdesc,
97
101
  other_msg=f"Can't open DSN database {self.db_name} using ODBC",
98
- )
102
+ ) from e
99
103
 
100
104
  try:
101
105
  _try_connect()
@@ -136,5 +140,6 @@ class DsnDatabase(Database):
136
140
  with open(file_name, "rb") as f:
137
141
  filedata = f.read()
138
142
  sq_name = self.schema_qualified_table_name(schema_name, table_name)
139
- sql = f"insert into {sq_name} ({column_name}) values ({self.paramsubs(1)});"
143
+ quoted_col = self.quote_identifier(column_name)
144
+ sql = f"insert into {sq_name} ({quoted_col}) values ({self.paramsubs(1)});"
140
145
  self.cursor().execute(sql, (pyodbc.Binary(filedata),))
execsql/db/duckdb.py CHANGED
@@ -15,8 +15,12 @@ from execsql.exceptions import ErrInfo
15
15
  from execsql.utils.errors import exception_desc, fatal_error
16
16
  import execsql.state as _state
17
17
 
18
+ __all__ = ["DuckDBDatabase"]
19
+
18
20
 
19
21
  class DuckDBDatabase(Database):
22
+ """DuckDB in-process analytics adapter using the duckdb package."""
23
+
20
24
  def __init__(self, DuckDB_fn: str) -> None:
21
25
  try:
22
26
  import duckdb # noqa: F401
@@ -48,12 +52,12 @@ class DuckDBDatabase(Database):
48
52
  self.conn = duckdb.connect(self.db_name, read_only=False)
49
53
  except ErrInfo:
50
54
  raise
51
- except Exception:
55
+ except Exception as e:
52
56
  raise ErrInfo(
53
57
  type="exception",
54
58
  exception_msg=exception_desc(),
55
59
  other_msg=f"Can't open DuckDB database {self.db_name}",
56
- )
60
+ ) from e
57
61
 
58
62
  def exec_cmd(self, querycommand: str) -> None:
59
63
  # DuckDB does not support stored functions, so the querycommand
execsql/db/factory.py CHANGED
@@ -25,6 +25,18 @@ from execsql.db.duckdb import DuckDBDatabase
25
25
  from execsql.db.mysql import MySQLDatabase
26
26
  from execsql.db.firebird import FirebirdDatabase
27
27
 
28
+ __all__ = [
29
+ "db_Access",
30
+ "db_Dsn",
31
+ "db_DuckDB",
32
+ "db_Firebird",
33
+ "db_MySQL",
34
+ "db_Oracle",
35
+ "db_Postgres",
36
+ "db_SQLite",
37
+ "db_SqlServer",
38
+ ]
39
+
28
40
 
29
41
  def db_Access(
30
42
  Access_fn: str,
@@ -32,6 +44,7 @@ def db_Access(
32
44
  user: str | None = None,
33
45
  encoding: str | None = None,
34
46
  ) -> AccessDatabase:
47
+ """Open an MS Access database file (.mdb or .accdb) via DAO/ODBC."""
35
48
  if not Path(Access_fn).exists():
36
49
  raise ErrInfo(
37
50
  type="error",
@@ -50,6 +63,7 @@ def db_Postgres(
50
63
  new_db: bool = False,
51
64
  password: str | None = None,
52
65
  ) -> PostgresDatabase:
66
+ """Open a new PostgreSQL connection via psycopg2."""
53
67
  return PostgresDatabase(server_name, database_name, user, pw_needed, port, new_db=new_db, password=password)
54
68
 
55
69
 
@@ -58,6 +72,7 @@ def db_SQLite(
58
72
  new_db: bool = False,
59
73
  encoding: str | None = None,
60
74
  ) -> SQLiteDatabase:
75
+ """Open a SQLite database file via the standard-library sqlite3 module."""
61
76
  if new_db:
62
77
  from execsql.utils.fileio import check_dir
63
78
 
@@ -79,6 +94,7 @@ def db_SqlServer(
79
94
  port: int | None = None,
80
95
  encoding: str | None = None,
81
96
  ) -> SqlServerDatabase:
97
+ """Open a Microsoft SQL Server connection via pyodbc."""
82
98
  return SqlServerDatabase(server_name, database_name, user, pw_needed, port, encoding)
83
99
 
84
100
 
@@ -90,6 +106,7 @@ def db_MySQL(
90
106
  port: int | None = None,
91
107
  encoding: str | None = None,
92
108
  ) -> MySQLDatabase:
109
+ """Open a MySQL or MariaDB connection via pymysql."""
93
110
  return MySQLDatabase(server_name, database_name, user, pw_needed, port, encoding)
94
111
 
95
112
 
@@ -98,6 +115,7 @@ def db_DuckDB(
98
115
  new_db: bool = False,
99
116
  encoding: str | None = None,
100
117
  ) -> DuckDBDatabase:
118
+ """Open a DuckDB in-process analytics database file via the duckdb package."""
101
119
  if new_db:
102
120
  from execsql.utils.fileio import check_dir
103
121
 
@@ -119,6 +137,7 @@ def db_Oracle(
119
137
  port: int | None = None,
120
138
  encoding: str | None = None,
121
139
  ) -> OracleDatabase:
140
+ """Open an Oracle database connection via cx_Oracle (python-oracledb)."""
122
141
  return OracleDatabase(server_name, database_name, user, pw_needed, port, encoding)
123
142
 
124
143
 
@@ -130,6 +149,7 @@ def db_Firebird(
130
149
  port: int | None = None,
131
150
  encoding: str | None = None,
132
151
  ) -> FirebirdDatabase:
152
+ """Open a Firebird database connection via the firebird-driver package."""
133
153
  return FirebirdDatabase(server_name, database_name, user, pw_needed, port, encoding)
134
154
 
135
155
 
@@ -139,4 +159,5 @@ def db_Dsn(
139
159
  pw_needed: bool = True,
140
160
  encoding: str | None = None,
141
161
  ) -> DsnDatabase:
162
+ """Open a connection to any ODBC data source registered under *dsn_name*."""
142
163
  return DsnDatabase(dsn_name=dsn_name, user_name=user, need_passwd=pw_needed, encoding=encoding)
execsql/db/firebird.py CHANGED
@@ -14,8 +14,12 @@ from execsql.utils.errors import exception_desc, fatal_error
14
14
  from execsql.utils.auth import clear_stored_password, get_password, password_from_keyring
15
15
  import execsql.state as _state
16
16
 
17
+ __all__ = ["FirebirdDatabase"]
18
+
17
19
 
18
20
  class FirebirdDatabase(Database):
21
+ """Firebird adapter using the firebird-driver (fdb) package."""
22
+
19
23
  def __init__(
20
24
  self,
21
25
  server_name: str,
@@ -30,7 +34,7 @@ class FirebirdDatabase(Database):
30
34
  import fdb as firebird_lib # noqa: F401
31
35
  except Exception:
32
36
  fatal_error(
33
- "The fdb module is required to connect to MySQL. See https://pypi.python.org/pypi/fdb/",
37
+ "The fdb module is required to connect to Firebird. See https://pypi.python.org/pypi/fdb/",
34
38
  )
35
39
  from execsql.types import dbt_firebird
36
40
 
@@ -104,9 +108,9 @@ class FirebirdDatabase(Database):
104
108
  raise
105
109
  except ErrInfo:
106
110
  raise
107
- except Exception:
111
+ except Exception as e:
108
112
  msg = f"Failed to open Firebird database {self.db_name} on {self.server_name}"
109
- raise ErrInfo(type="exception", exception_msg=exception_desc(), other_msg=msg)
113
+ raise ErrInfo(type="exception", exception_msg=exception_desc(), other_msg=msg) from e
110
114
 
111
115
  def exec_cmd(self, querycommand: str) -> None:
112
116
  # The querycommand must be a stored function (/procedure)
@@ -122,15 +126,15 @@ class FirebirdDatabase(Database):
122
126
  def table_exists(self, table_name: str, schema_name: str | None = None) -> bool:
123
127
  curs = self.cursor()
124
128
  sql = (
125
- f"SELECT RDB$RELATION_NAME FROM RDB$RELATIONS "
126
- f"WHERE RDB$SYSTEM_FLAG=0 AND RDB$VIEW_BLR IS NULL "
127
- f"AND RDB$RELATION_NAME='{table_name.upper()}';"
129
+ "SELECT RDB$RELATION_NAME FROM RDB$RELATIONS "
130
+ "WHERE RDB$SYSTEM_FLAG=0 AND RDB$VIEW_BLR IS NULL "
131
+ "AND RDB$RELATION_NAME=?;"
128
132
  )
129
133
  try:
130
- curs.execute(sql)
134
+ curs.execute(sql, (table_name.upper(),))
131
135
  except ErrInfo:
132
136
  raise
133
- except Exception:
137
+ except Exception as e:
134
138
  try:
135
139
  self.rollback()
136
140
  except Exception:
@@ -140,7 +144,7 @@ class FirebirdDatabase(Database):
140
144
  command_text=sql,
141
145
  exception_msg=exception_desc(),
142
146
  other_msg=f"Failed test for existence of Firebird table {table_name}",
143
- )
147
+ ) from e
144
148
  rows = curs.fetchall()
145
149
  self.conn.commit()
146
150
  curs.close()
@@ -153,7 +157,9 @@ class FirebirdDatabase(Database):
153
157
  schema_name: str | None = None,
154
158
  ) -> bool:
155
159
  curs = self.cursor()
156
- sql = f"select first 1 {column_name} from {table_name};"
160
+ quoted_col = self.quote_identifier(column_name)
161
+ quoted_tbl = self.quote_identifier(table_name)
162
+ sql = f"select first 1 {quoted_col} from {quoted_tbl};"
157
163
  try:
158
164
  curs.execute(sql)
159
165
  except Exception:
@@ -162,36 +168,37 @@ class FirebirdDatabase(Database):
162
168
 
163
169
  def table_columns(self, table_name: str, schema_name: str | None = None) -> list[str]:
164
170
  curs = self.cursor()
165
- sql = f"select first 1 * from {table_name};"
171
+ quoted_tbl = self.quote_identifier(table_name)
172
+ sql = f"select first 1 * from {quoted_tbl};"
166
173
  try:
167
174
  curs.execute(sql)
168
175
  except ErrInfo:
169
176
  raise
170
- except Exception:
177
+ except Exception as e:
171
178
  self.rollback()
172
179
  raise ErrInfo(
173
180
  type="db",
174
181
  command_text=sql,
175
182
  exception_msg=exception_desc(),
176
183
  other_msg=f"Failed to get column names for table {table_name} of {self.name()}",
177
- )
184
+ ) from e
178
185
  return [d[0] for d in curs.description]
179
186
 
180
187
  def view_exists(self, view_name: str, schema_name: str | None = None) -> bool:
181
188
  curs = self.cursor()
182
- sql = f"select distinct rdb$view_name from rdb$view_relations where rdb$view_name = '{view_name}';"
189
+ sql = "select distinct rdb$view_name from rdb$view_relations where rdb$view_name = ?;"
183
190
  try:
184
- curs.execute(sql)
191
+ curs.execute(sql, (view_name,))
185
192
  except ErrInfo:
186
193
  raise
187
- except Exception:
194
+ except Exception as e:
188
195
  self.rollback()
189
196
  raise ErrInfo(
190
197
  type="db",
191
198
  command_text=sql,
192
199
  exception_msg=exception_desc(),
193
200
  other_msg=f"Failed test for existence of Firebird view {view_name}",
194
- )
201
+ ) from e
195
202
  rows = curs.fetchall()
196
203
  curs.close()
197
204
  return len(rows) > 0
@@ -202,8 +209,9 @@ class FirebirdDatabase(Database):
202
209
  def role_exists(self, rolename: str) -> bool:
203
210
  curs = self.cursor()
204
211
  curs.execute(
205
- f"SELECT DISTINCT USER FROM RDB$USER_PRIVILEGES WHERE USER = '{rolename}' union "
206
- f" SELECT DISTINCT RDB$ROLE_NAME FROM RDB$ROLES WHERE RDB$ROLE_NAME = '{rolename}';",
212
+ "SELECT DISTINCT USER FROM RDB$USER_PRIVILEGES WHERE USER = ? union "
213
+ " SELECT DISTINCT RDB$ROLE_NAME FROM RDB$ROLES WHERE RDB$ROLE_NAME = ?;",
214
+ (rolename, rolename),
207
215
  )
208
216
  rows = curs.fetchall()
209
217
  curs.close()
execsql/db/mysql.py CHANGED
@@ -16,8 +16,12 @@ from execsql.utils.errors import exception_desc, fatal_error
16
16
  from execsql.utils.auth import clear_stored_password, get_password, password_from_keyring
17
17
  import execsql.state as _state
18
18
 
19
+ __all__ = ["MySQLDatabase"]
20
+
19
21
 
20
22
  class MySQLDatabase(Database):
23
+ """MySQL and MariaDB adapter using the pymysql package."""
24
+
21
25
  def __init__(
22
26
  self,
23
27
  server_name: str,
@@ -109,9 +113,9 @@ class MySQLDatabase(Database):
109
113
  raise
110
114
  except ErrInfo:
111
115
  raise
112
- except Exception:
116
+ except Exception as e:
113
117
  msg = f"Failed to open MySQL database {self.db_name} on {self.server_name}"
114
- raise ErrInfo(type="exception", exception_msg=exception_desc(), other_msg=msg)
118
+ raise ErrInfo(type="exception", exception_msg=exception_desc(), other_msg=msg) from e
115
119
 
116
120
  def exec_cmd(self, querycommand: str) -> None:
117
121
  # The querycommand must be a stored function (/procedure)
@@ -130,9 +134,10 @@ class MySQLDatabase(Database):
130
134
  def role_exists(self, rolename: str) -> bool:
131
135
  curs = self.cursor()
132
136
  curs.execute(
133
- f"select distinct user as role from mysql.user where user = '{rolename}'"
134
- f" union select distinct role_name as role from information_schema.applicable_roles"
135
- f" where role_name = '{rolename}'",
137
+ "select distinct user as role from mysql.user where user = %s"
138
+ " union select distinct role_name as role from information_schema.applicable_roles"
139
+ " where role_name = %s",
140
+ (rolename, rolename),
136
141
  )
137
142
  rows = curs.fetchall()
138
143
  curs.close()
@@ -274,14 +279,14 @@ class MySQLDatabase(Database):
274
279
  curs.executemany(sql_template, b)
275
280
  except ErrInfo:
276
281
  raise
277
- except Exception:
282
+ except Exception as e:
278
283
  self.rollback()
279
284
  raise ErrInfo(
280
285
  type="db",
281
286
  command_text=sql_template,
282
287
  exception_msg=exception_desc(),
283
288
  other_msg=f"Import from file into table {sq_name}, line {{{line}}}",
284
- )
289
+ ) from e
285
290
  total_rows += len(b)
286
291
  interval = _state.conf.import_progress_interval
287
292
  if _state.exec_log and interval > 0 and total_rows % interval == 0:
execsql/db/oracle.py CHANGED
@@ -16,15 +16,19 @@ from execsql.utils.errors import exception_desc, fatal_error
16
16
  from execsql.utils.auth import clear_stored_password, get_password, password_from_keyring
17
17
  import execsql.state as _state
18
18
 
19
+ __all__ = ["OracleDatabase"]
20
+
19
21
 
20
22
  class OracleDatabase(Database):
23
+ """Oracle adapter using the cx_Oracle (python-oracledb) driver."""
24
+
21
25
  def __init__(
22
26
  self,
23
27
  server_name: str,
24
28
  db_name: str,
25
29
  user_name: str | None,
26
30
  need_passwd: bool = False,
27
- port: int | None = 5432,
31
+ port: int | None = 1521,
28
32
  encoding: str | None = "UTF8",
29
33
  password: str | None = None,
30
34
  ) -> None:
@@ -95,9 +99,9 @@ class OracleDatabase(Database):
95
99
  raise
96
100
  except ErrInfo:
97
101
  raise
98
- except Exception:
102
+ except Exception as e:
99
103
  msg = f"Failed to open Oracle database {self.db_name} on {self.server_name}"
100
- raise ErrInfo(type="exception", exception_msg=exception_desc(), other_msg=msg)
104
+ raise ErrInfo(type="exception", exception_msg=exception_desc(), other_msg=msg) from e
101
105
 
102
106
  def execute(self, sql: Any, paramlist: list | None = None) -> None:
103
107
  # Strip any semicolon off the end and pass to the parent method.
@@ -141,14 +145,14 @@ class OracleDatabase(Database):
141
145
  curs.execute(sql, params)
142
146
  except ErrInfo:
143
147
  raise
144
- except Exception:
148
+ except Exception as e:
145
149
  self.rollback()
146
150
  raise ErrInfo(
147
151
  type="db",
148
152
  command_text=sql,
149
153
  exception_msg=exception_desc(),
150
154
  other_msg=f"Failed test for existence of table {table_name} in {self.name()}",
151
- )
155
+ ) from e
152
156
  rows = curs.fetchall()
153
157
  curs.close()
154
158
  return len(rows) > 0
@@ -170,14 +174,14 @@ class OracleDatabase(Database):
170
174
  curs.execute(sql, params)
171
175
  except ErrInfo:
172
176
  raise
173
- except Exception:
177
+ except Exception as e:
174
178
  self.rollback()
175
179
  raise ErrInfo(
176
180
  type="db",
177
181
  command_text=sql,
178
182
  exception_msg=exception_desc(),
179
183
  other_msg=f"Failed test for existence of column {column_name} in table {table_name} of {self.name()}",
180
- )
184
+ ) from e
181
185
  rows = curs.fetchall()
182
186
  curs.close()
183
187
  return len(rows) > 0
@@ -194,14 +198,14 @@ class OracleDatabase(Database):
194
198
  curs.execute(sql, params)
195
199
  except ErrInfo:
196
200
  raise
197
- except Exception:
201
+ except Exception as e:
198
202
  self.rollback()
199
203
  raise ErrInfo(
200
204
  type="db",
201
205
  command_text=sql,
202
206
  exception_msg=exception_desc(),
203
207
  other_msg=f"Failed to get column names for table {table_name} of {self.name()}",
204
- )
208
+ ) from e
205
209
  rows = curs.fetchall()
206
210
  curs.close()
207
211
  return [row[0] for row in rows]
@@ -218,14 +222,14 @@ class OracleDatabase(Database):
218
222
  curs.execute(sql, params)
219
223
  except ErrInfo:
220
224
  raise
221
- except Exception:
225
+ except Exception as e:
222
226
  self.rollback()
223
227
  raise ErrInfo(
224
228
  type="db",
225
229
  command_text=sql,
226
230
  exception_msg=exception_desc(),
227
231
  other_msg=f"Failed test for existence of view {view_name} in {self.name()}",
228
- )
232
+ ) from e
229
233
  rows = curs.fetchall()
230
234
  curs.close()
231
235
  return len(rows) > 0
execsql/db/postgres.py CHANGED
@@ -19,11 +19,14 @@ from execsql.utils.auth import clear_stored_password, get_password, password_fro
19
19
  from execsql.utils.strings import encodings_match
20
20
  import execsql.state as _state
21
21
 
22
+ __all__ = ["PostgresDatabase"]
22
23
 
23
24
  DEFAULT_CONNECT_TIMEOUT = 30 # seconds
24
25
 
25
26
 
26
27
  class PostgresDatabase(Database):
28
+ """PostgreSQL adapter using psycopg2, with schema support, server-side COPY, and keyring auth."""
29
+
27
30
  def __init__(
28
31
  self,
29
32
  server_name: str,
@@ -87,18 +90,20 @@ class PostgresDatabase(Database):
87
90
  port=db.port,
88
91
  connect_timeout=db.connect_timeout,
89
92
  )
90
- except Exception:
93
+ except Exception as e:
91
94
  msg = (
92
95
  f"Failed to open PostgreSQL database {self.db_name} on {self.server_name}; "
93
96
  "check server and database name, and validity of credentials"
94
97
  )
95
- raise ErrInfo(type="exception", exception_msg=exception_desc(), other_msg=msg)
98
+ raise ErrInfo(type="exception", exception_msg=exception_desc(), other_msg=msg) from e
96
99
 
97
100
  def create_db(db: PostgresDatabase) -> None:
98
101
  conn = db_conn(db, "postgres")
99
102
  conn.autocommit = True
100
103
  curs = conn.cursor()
101
- curs.execute(f"create database {db.db_name} encoding '{db.encoding}';")
104
+ quoted_name = db.quote_identifier(db.db_name)
105
+ quoted_enc = db.quote_identifier(db.encoding)
106
+ curs.execute(f"create database {quoted_name} encoding {quoted_enc};")
102
107
  conn.close()
103
108
 
104
109
  if self.conn is None:
@@ -133,9 +138,9 @@ class PostgresDatabase(Database):
133
138
  raise
134
139
  except ErrInfo:
135
140
  raise
136
- except Exception:
141
+ except Exception as e:
137
142
  msg = f"Failed to open PostgreSQL database {self.db_name} on {self.server_name}"
138
- raise ErrInfo(type="exception", exception_msg=exception_desc(), other_msg=msg)
143
+ raise ErrInfo(type="exception", exception_msg=exception_desc(), other_msg=msg) from e
139
144
  # (Re)set the encoding to match the database.
140
145
  self.encoding = self.conn.encoding
141
146
 
@@ -177,14 +182,14 @@ class PostgresDatabase(Database):
177
182
  curs.execute(sql, params)
178
183
  except ErrInfo:
179
184
  raise
180
- except Exception:
185
+ except Exception as e:
181
186
  self.rollback()
182
187
  raise ErrInfo(
183
188
  type="db",
184
189
  command_text=sql,
185
190
  exception_msg=exception_desc(),
186
191
  other_msg=f"Failed test for existence of table {table_name} in {self.name()}",
187
- )
192
+ ) from e
188
193
  rows = curs.fetchall()
189
194
  curs.close()
190
195
  return len(rows) > 0
@@ -209,14 +214,14 @@ class PostgresDatabase(Database):
209
214
  curs.execute(sql, params)
210
215
  except ErrInfo:
211
216
  raise
212
- except Exception:
217
+ except Exception as e:
213
218
  self.rollback()
214
219
  raise ErrInfo(
215
220
  type="db",
216
221
  command_text=sql,
217
222
  exception_msg=exception_desc(),
218
223
  other_msg=f"Failed test for existence of view {view_name} in {self.name()}",
219
- )
224
+ ) from e
220
225
  rows = curs.fetchall()
221
226
  curs.close()
222
227
  return len(rows) > 0
@@ -304,9 +309,18 @@ class PostgresDatabase(Database):
304
309
  # ASCII unit separator, which, if it had been used for its intended purpose,
305
310
  # should have been identified as the delimiter, so presumably it has not been used.
306
311
  delim = csv_file_obj.delimiter if csv_file_obj.delimiter else chr(31)
307
- copy_cmd = f"copy {sq_name} ({input_col_list}) from stdin with (format csv, null '', delimiter '{delim}'"
312
+ if len(delim) != 1:
313
+ raise ErrInfo(
314
+ type="error",
315
+ other_msg=f"Invalid delimiter for COPY: expected single character, got {len(delim)} characters",
316
+ )
317
+ safe_delim = delim.replace("'", "''")
318
+ copy_cmd = (
319
+ f"copy {sq_name} ({input_col_list}) from stdin with (format csv, null '', delimiter '{safe_delim}'"
320
+ )
308
321
  if csv_file_obj.quotechar:
309
- copy_cmd = copy_cmd + f", quote '{csv_file_obj.quotechar}'"
322
+ safe_quote = csv_file_obj.quotechar.replace("'", "''")
323
+ copy_cmd = copy_cmd + f", quote '{safe_quote}'"
310
324
  copy_cmd = copy_cmd + ")"
311
325
  _state.exec_log.log_status_info(
312
326
  f"IMPORTing {csv_file_obj.csvfname} using Postgres' fast file reading routine",
@@ -315,13 +329,13 @@ class PostgresDatabase(Database):
315
329
  curs.copy_expert(copy_cmd, rf, _state.conf.import_buffer)
316
330
  except ErrInfo:
317
331
  raise
318
- except Exception:
332
+ except Exception as e:
319
333
  self.rollback()
320
334
  raise ErrInfo(
321
335
  type="exception",
322
336
  exception_msg=exception_desc(),
323
337
  other_msg=f"Can't import from file to table {sq_name}",
324
- )
338
+ ) from e
325
339
  else:
326
340
  data_indexes = [csv_file_cols_q.index(col) for col in import_cols]
327
341
  paramspec = ",".join(["%s"] * len(import_cols))
@@ -398,14 +412,14 @@ class PostgresDatabase(Database):
398
412
  curs.executemany(sql_template, b)
399
413
  except ErrInfo:
400
414
  raise
401
- except Exception:
415
+ except Exception as e:
402
416
  self.rollback()
403
417
  raise ErrInfo(
404
418
  type="db",
405
419
  command_text=sql_template,
406
420
  exception_msg=exception_desc(),
407
421
  other_msg=f"Can't load data into table {sq_name} of {self.name()} from line {{{line}}}",
408
- )
422
+ ) from e
409
423
  total_rows += len(b)
410
424
  interval = _state.conf.import_progress_interval
411
425
  if _state.exec_log and interval > 0 and total_rows % interval == 0:
@@ -431,5 +445,6 @@ class PostgresDatabase(Database):
431
445
  with open(file_name, "rb") as f:
432
446
  filedata = f.read()
433
447
  sq_name = self.schema_qualified_table_name(schema_name, table_name)
434
- sql = f"insert into {sq_name} ({column_name}) values ({self.paramsubs(1)});"
448
+ quoted_col = self.quote_identifier(column_name)
449
+ sql = f"insert into {sq_name} ({quoted_col}) values ({self.paramsubs(1)});"
435
450
  self.cursor().execute(sql, (psycopg2.Binary(filedata),))