execsql2 2.33.0__py3-none-any.whl → 2.35.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 +1 -0
- execsql/config.py +2 -0
- execsql/data/execsql.conf.template +7 -0
- execsql/db/base.py +71 -8
- execsql/db/duckdb.py +6 -0
- execsql/db/mysql.py +54 -2
- execsql/db/oracle.py +36 -0
- execsql/db/postgres.py +17 -1
- execsql/db/sqlite.py +7 -1
- execsql/db/sqlserver.py +24 -0
- execsql/db/target_types.py +277 -0
- execsql/exporters/json.py +51 -7
- execsql/exporters/protocol.py +1 -0
- execsql/importers/base.py +1 -1
- execsql/importers/csv.py +1 -1
- execsql/metacommands/__init__.py +17 -1
- execsql/metacommands/data.py +7 -1
- execsql/metacommands/dispatch.py +7 -0
- execsql/metacommands/io_export.py +3 -1
- execsql/metacommands/io_fileops.py +2 -2
- execsql/metacommands/reference.py +1 -0
- execsql/models.py +30 -4
- execsql/script/executor.py +10 -10
- execsql/script/variables.py +4 -0
- execsql/types.py +2 -2
- execsql/utils/errors.py +2 -2
- {execsql2-2.33.0.dist-info → execsql2-2.35.0.dist-info}/METADATA +3 -3
- {execsql2-2.33.0.dist-info → execsql2-2.35.0.dist-info}/RECORD +45 -44
- {execsql2-2.33.0.data → execsql2-2.35.0.data}/data/execsql2_extras/README.md +0 -0
- {execsql2-2.33.0.data → execsql2-2.35.0.data}/data/execsql2_extras/config_settings.sqlite +0 -0
- {execsql2-2.33.0.data → execsql2-2.35.0.data}/data/execsql2_extras/example_config_prompt.sql +0 -0
- {execsql2-2.33.0.data → execsql2-2.35.0.data}/data/execsql2_extras/make_config_db.sql +0 -0
- {execsql2-2.33.0.data → execsql2-2.35.0.data}/data/execsql2_extras/md_compare.sql +0 -0
- {execsql2-2.33.0.data → execsql2-2.35.0.data}/data/execsql2_extras/md_glossary.sql +0 -0
- {execsql2-2.33.0.data → execsql2-2.35.0.data}/data/execsql2_extras/md_upsert.sql +0 -0
- {execsql2-2.33.0.data → execsql2-2.35.0.data}/data/execsql2_extras/pg_compare.sql +0 -0
- {execsql2-2.33.0.data → execsql2-2.35.0.data}/data/execsql2_extras/pg_glossary.sql +0 -0
- {execsql2-2.33.0.data → execsql2-2.35.0.data}/data/execsql2_extras/script_template.sql +0 -0
- {execsql2-2.33.0.data → execsql2-2.35.0.data}/data/execsql2_extras/ss_compare.sql +0 -0
- {execsql2-2.33.0.data → execsql2-2.35.0.data}/data/execsql2_extras/ss_glossary.sql +0 -0
- {execsql2-2.33.0.data → execsql2-2.35.0.data}/data/execsql2_extras/ss_upsert.sql +0 -0
- {execsql2-2.33.0.dist-info → execsql2-2.35.0.dist-info}/WHEEL +0 -0
- {execsql2-2.33.0.dist-info → execsql2-2.35.0.dist-info}/entry_points.txt +0 -0
- {execsql2-2.33.0.dist-info → execsql2-2.35.0.dist-info}/licenses/LICENSE.txt +0 -0
- {execsql2-2.33.0.dist-info → execsql2-2.35.0.dist-info}/licenses/NOTICE +0 -0
execsql/api.py
CHANGED
execsql/config.py
CHANGED
|
@@ -300,6 +300,7 @@ class ConfigData:
|
|
|
300
300
|
self._get_bool(cp, self._INPUT_SECTION, "boolean_int", "boolean_int")
|
|
301
301
|
self._get_bool(cp, self._INPUT_SECTION, "boolean_words", "boolean_words")
|
|
302
302
|
self._get_bool(cp, self._INPUT_SECTION, "empty_strings", "empty_strings")
|
|
303
|
+
self._get_bool(cp, self._INPUT_SECTION, "exact_decimals", "exact_decimals")
|
|
303
304
|
self._get_bool(cp, self._INPUT_SECTION, "only_strings", "only_strings")
|
|
304
305
|
self._get_bool(cp, self._INPUT_SECTION, "empty_rows", "empty_rows")
|
|
305
306
|
self._get_bool(cp, self._INPUT_SECTION, "delete_empty_columns", "del_empty_cols")
|
|
@@ -503,6 +504,7 @@ class ConfigData:
|
|
|
503
504
|
self.boolean_int = True
|
|
504
505
|
self.boolean_words = False
|
|
505
506
|
self.empty_strings = True
|
|
507
|
+
self.exact_decimals = False
|
|
506
508
|
self.only_strings = False
|
|
507
509
|
self.empty_rows = True
|
|
508
510
|
self.del_empty_cols = False
|
|
@@ -115,6 +115,13 @@
|
|
|
115
115
|
# Values: Yes or No. Default: Yes.
|
|
116
116
|
#empty_strings=Yes
|
|
117
117
|
|
|
118
|
+
# Whether a column of decimal numbers with different numbers of decimal places
|
|
119
|
+
# (1.5, 2.25) gets an exact DECIMAL(precision, scale) type when IMPORT or COPY
|
|
120
|
+
# creates a table. With No it gets a floating-point type, unless a value has
|
|
121
|
+
# more than 15 significant digits (then it is DECIMAL, so no digit is lost).
|
|
122
|
+
# Values: Yes or No. Default: No.
|
|
123
|
+
#exact_decimals=No
|
|
124
|
+
|
|
118
125
|
# Whether to fold column headers to lowercase, uppercase, or leave unchanged.
|
|
119
126
|
# Values: No, Lower, or Upper. Default: No.
|
|
120
127
|
#fold_column_headers=No
|
execsql/db/base.py
CHANGED
|
@@ -24,7 +24,9 @@ from collections.abc import Callable, Generator, Iterator
|
|
|
24
24
|
|
|
25
25
|
from execsql.exceptions import ErrInfo
|
|
26
26
|
from execsql.utils.errors import exception_desc
|
|
27
|
+
from execsql.db.target_types import ColumnKinds, existing_table_converter, kind_for_python_type
|
|
27
28
|
from execsql.db.tiers import SupportTier, announce_tier
|
|
29
|
+
from execsql.types import DataType
|
|
28
30
|
import execsql.state as _state
|
|
29
31
|
|
|
30
32
|
__all__ = ["Database", "DatabasePool"]
|
|
@@ -604,6 +606,42 @@ class Database(ABC):
|
|
|
604
606
|
rows = curs.fetchall()
|
|
605
607
|
return [row[0] for row in rows]
|
|
606
608
|
|
|
609
|
+
def column_kinds(self, sq_name: str) -> ColumnKinds:
|
|
610
|
+
"""The execsql data type each column of table *sq_name* converts values to (see ``db.target_types``).
|
|
611
|
+
|
|
612
|
+
*sq_name* is schema-qualified and quoted, as for an ``INSERT``, and
|
|
613
|
+
is looked up the way the ``INSERT`` finds it. Keys are lower-cased
|
|
614
|
+
column names; a column with no fitting execsql type maps to ``None``.
|
|
615
|
+
This default reads the driver's description of an empty query on the
|
|
616
|
+
table; adapters whose catalog names types more exactly override it.
|
|
617
|
+
"""
|
|
618
|
+
description, _rows = self._column_type_query(sq_name, f"select * from {sq_name} where 1=0;")
|
|
619
|
+
return {d[0].lower(): self._described_kind(d) for d in description}
|
|
620
|
+
|
|
621
|
+
def _described_kind(self, description: Any) -> type[DataType] | None:
|
|
622
|
+
"""The execsql type for one column of a cursor description (a DB-API 7-item sequence)."""
|
|
623
|
+
return kind_for_python_type(description[1], description[5])
|
|
624
|
+
|
|
625
|
+
def _column_type_query(self, sq_name: str, sql: str, params: list | None = None) -> tuple[Any, list]:
|
|
626
|
+
"""Run *sql* for :meth:`column_kinds` of *sq_name*; return the cursor description and the rows."""
|
|
627
|
+
with self._cursor() as curs:
|
|
628
|
+
try:
|
|
629
|
+
if params is None:
|
|
630
|
+
curs.execute(sql)
|
|
631
|
+
else:
|
|
632
|
+
curs.execute(sql, params)
|
|
633
|
+
except ErrInfo:
|
|
634
|
+
raise
|
|
635
|
+
except Exception as e:
|
|
636
|
+
self.rollback()
|
|
637
|
+
raise ErrInfo(
|
|
638
|
+
type="db",
|
|
639
|
+
command_text=sql,
|
|
640
|
+
exception_msg=exception_desc(),
|
|
641
|
+
other_msg=f"Failed to get the column types of table {sq_name} of {self.name()}",
|
|
642
|
+
) from e
|
|
643
|
+
return curs.description, curs.fetchall()
|
|
644
|
+
|
|
607
645
|
def view_exists(self, view_name: str, schema_name: str | None = None) -> bool:
|
|
608
646
|
"""Return ``True`` if *view_name* (optionally in *schema_name*) exists."""
|
|
609
647
|
with self._cursor() as curs:
|
|
@@ -689,11 +727,17 @@ class Database(ABC):
|
|
|
689
727
|
rowsource: Any,
|
|
690
728
|
column_list: list[str],
|
|
691
729
|
tablespec_src: Callable,
|
|
730
|
+
*,
|
|
731
|
+
existing_table: bool = False,
|
|
692
732
|
) -> None:
|
|
693
733
|
"""Bulk-insert rows from *rowsource* into *table_name* using the columns in *column_list*.
|
|
694
734
|
|
|
695
735
|
*rowsource* must be a generator yielding lists of values in column order.
|
|
696
736
|
*tablespec_src* is a zero-argument callable that returns the table's type specification.
|
|
737
|
+
Each value is converted to the type scanned for its column; with
|
|
738
|
+
*existing_table* (the table was there before the IMPORT or COPY), the
|
|
739
|
+
type of the column it goes into decides instead (see
|
|
740
|
+
``db.target_types.existing_table_converter``).
|
|
697
741
|
"""
|
|
698
742
|
# The rowsource argument must be a generator yielding a list of values for the columns of the table.
|
|
699
743
|
# The column_list argument must an iterable containing column names. This may be a subset of
|
|
@@ -713,8 +757,13 @@ class Database(ABC):
|
|
|
713
757
|
sel_cols = [col for col in ts_colnames if col in column_list]
|
|
714
758
|
incl_col = [col in column_list for col in ts_colnames]
|
|
715
759
|
# Type conversion functions for the rowsource.
|
|
716
|
-
|
|
717
|
-
|
|
760
|
+
converters = [self._scanned_type_converter(col.column_type()[1]) for col in tablespec.cols]
|
|
761
|
+
if existing_table:
|
|
762
|
+
kinds = self.column_kinds(sq_name)
|
|
763
|
+
converters = [
|
|
764
|
+
existing_table_converter(kinds.get(col.name.lower()), col.column_type()[1], convert, col.name, sq_name)
|
|
765
|
+
for col, convert in zip(tablespec.cols, converters)
|
|
766
|
+
]
|
|
718
767
|
# Construct INSERT statement.
|
|
719
768
|
columns = [self.type.quoted(col) for col in sel_cols]
|
|
720
769
|
colspec = ",".join(columns)
|
|
@@ -812,10 +861,7 @@ class Database(ABC):
|
|
|
812
861
|
line[i] = None
|
|
813
862
|
# Trailing values left off the line are NULL.
|
|
814
863
|
line = list(line) + [None] * (len(ts_colnames) - len(line))
|
|
815
|
-
lt = [
|
|
816
|
-
type_objs[i].from_data(val) if val is not None else None for i, val in enumerate(line)
|
|
817
|
-
]
|
|
818
|
-
lt = [type_mod_fn[i](v) if type_mod_fn[i] else v for i, v in enumerate(lt)]
|
|
864
|
+
lt = [converters[i](val) for i, val in enumerate(line)]
|
|
819
865
|
row = []
|
|
820
866
|
for i, v in enumerate(lt):
|
|
821
867
|
if incl_col[i]:
|
|
@@ -869,14 +915,31 @@ class Database(ABC):
|
|
|
869
915
|
f"IMPORT into {sq_name} complete: {total_rows} rows imported.",
|
|
870
916
|
)
|
|
871
917
|
|
|
918
|
+
def _scanned_type_converter(self, data_type: type[DataType]) -> Callable[[Any], Any]:
|
|
919
|
+
"""Convert a value to *data_type*, then apply this DBMS's adjustment for that type, if any.
|
|
920
|
+
|
|
921
|
+
NULL is not converted, but it is adjusted: MySQL's adjustment for
|
|
922
|
+
dates turns it into ``""``.
|
|
923
|
+
"""
|
|
924
|
+
from_data = data_type().from_data
|
|
925
|
+
adjust = self.type.dialect[data_type][3]
|
|
926
|
+
if adjust is None:
|
|
927
|
+
return lambda value: None if value is None else from_data(value)
|
|
928
|
+
return lambda value: adjust(None if value is None else from_data(value))
|
|
929
|
+
|
|
872
930
|
def import_tabular_file(
|
|
873
931
|
self,
|
|
874
932
|
schema_name: str | None,
|
|
875
933
|
table_name: str,
|
|
876
934
|
csv_file_obj: Any,
|
|
877
935
|
skipheader: bool,
|
|
936
|
+
*,
|
|
937
|
+
existing_table: bool = False,
|
|
878
938
|
) -> None:
|
|
879
|
-
"""Import a CSV/tabular file into *table_name*; column names must be compatible.
|
|
939
|
+
"""Import a CSV/tabular file into *table_name*; column names must be compatible.
|
|
940
|
+
|
|
941
|
+
*existing_table*: the table was there before the IMPORT (see :meth:`populate_table`).
|
|
942
|
+
"""
|
|
880
943
|
# Import a text (CSV) file containing tabular data to a table. Columns must be compatible.
|
|
881
944
|
if not self.table_exists(table_name, schema_name):
|
|
882
945
|
raise ErrInfo(
|
|
@@ -912,7 +975,7 @@ class Database(ABC):
|
|
|
912
975
|
|
|
913
976
|
f = csv_file_obj.reader()
|
|
914
977
|
next(f)
|
|
915
|
-
self.populate_table(schema_name, table_name, f, import_cols, get_ts)
|
|
978
|
+
self.populate_table(schema_name, table_name, f, import_cols, get_ts, existing_table=existing_table)
|
|
916
979
|
|
|
917
980
|
def import_entire_file(
|
|
918
981
|
self,
|
execsql/db/duckdb.py
CHANGED
|
@@ -18,6 +18,8 @@ from pathlib import Path
|
|
|
18
18
|
from typing import Any
|
|
19
19
|
|
|
20
20
|
from execsql.db.base import Database, statement_start
|
|
21
|
+
from execsql.db.target_types import kind_for_type_name
|
|
22
|
+
from execsql.types import DataType
|
|
21
23
|
from execsql.exceptions import ErrInfo
|
|
22
24
|
from execsql.utils.errors import exception_desc, fatal_error, install_hint
|
|
23
25
|
import execsql.state as _state
|
|
@@ -320,6 +322,10 @@ class DuckDBDatabase(Database):
|
|
|
320
322
|
|
|
321
323
|
return insert
|
|
322
324
|
|
|
325
|
+
def _described_kind(self, description: Any) -> type[DataType] | None:
|
|
326
|
+
"""DuckDB describes a column by its type's name (``DECIMAL(18,3)``, ``TIMESTAMP WITH TIME ZONE``)."""
|
|
327
|
+
return kind_for_type_name(str(description[1]))
|
|
328
|
+
|
|
323
329
|
def view_exists(self, view_name: str, schema_name: str | None = None) -> bool:
|
|
324
330
|
"""Return True if the named view exists in the DuckDB database."""
|
|
325
331
|
# DuckDB information_schema has no 'views' table; views are listed in 'tables'
|
execsql/db/mysql.py
CHANGED
|
@@ -13,6 +13,7 @@ import tempfile
|
|
|
13
13
|
from typing import Any, cast
|
|
14
14
|
|
|
15
15
|
from execsql.db.base import Database
|
|
16
|
+
from execsql.db.target_types import ColumnKinds, kind_for_type_name
|
|
16
17
|
from execsql.exceptions import ErrInfo
|
|
17
18
|
from execsql.utils.errors import exception_desc, fatal_error, install_hint
|
|
18
19
|
from execsql.utils.auth import (
|
|
@@ -41,6 +42,11 @@ _PYTHON_TO_MYSQL_CHARSET: dict[str, str] = {
|
|
|
41
42
|
}
|
|
42
43
|
|
|
43
44
|
|
|
45
|
+
def _text(value: Any) -> str:
|
|
46
|
+
"""A catalog value as text: some MySQL versions return ``SHOW COLUMNS`` types as bytes."""
|
|
47
|
+
return value.decode() if isinstance(value, (bytes, bytearray)) else str(value)
|
|
48
|
+
|
|
49
|
+
|
|
44
50
|
def _crlf_terminated(filename: str) -> bool:
|
|
45
51
|
"""True if the file's first line ends with CRLF.
|
|
46
52
|
|
|
@@ -239,6 +245,11 @@ class MySQLDatabase(Database):
|
|
|
239
245
|
self._fold_identifier(schema_name) if schema_name is not None else None,
|
|
240
246
|
)
|
|
241
247
|
|
|
248
|
+
def column_kinds(self, sq_name: str) -> ColumnKinds:
|
|
249
|
+
"""The execsql type of each column of *sq_name*, from ``SHOW COLUMNS`` (which tells BOOLEAN from TINYINT)."""
|
|
250
|
+
_description, rows = self._column_type_query(sq_name, f"show columns from {sq_name};")
|
|
251
|
+
return {str(row[0]).lower(): kind_for_type_name(_text(row[1])) for row in rows}
|
|
252
|
+
|
|
242
253
|
def view_exists(self, view_name: str, schema_name: str | None = None) -> bool:
|
|
243
254
|
return super().view_exists(
|
|
244
255
|
self._fold_identifier(view_name),
|
|
@@ -305,7 +316,11 @@ class MySQLDatabase(Database):
|
|
|
305
316
|
)
|
|
306
317
|
self.conn = db_conn()
|
|
307
318
|
remember_password("MySQL", self.db_name, self.user, self.server_name, port=self.port)
|
|
308
|
-
|
|
319
|
+
# A fixed mode, not the server's: quote_literal relies on
|
|
320
|
+
# backslash escapes being on (no NO_BACKSLASH_ESCAPES).
|
|
321
|
+
# STRICT_TRANS_TABLES makes a value MySQL can't store as
|
|
322
|
+
# given an error instead of a clamped or truncated one (#101).
|
|
323
|
+
self.execute("set session sql_mode='ANSI,STRICT_TRANS_TABLES';")
|
|
309
324
|
except SystemExit:
|
|
310
325
|
# If the user canceled the password prompt.
|
|
311
326
|
raise
|
|
@@ -343,14 +358,50 @@ class MySQLDatabase(Database):
|
|
|
343
358
|
rows = curs.fetchall()
|
|
344
359
|
return len(rows) > 0
|
|
345
360
|
|
|
361
|
+
def _fail_on_load_warnings(self, file_name: str, sq_name: str) -> None:
|
|
362
|
+
"""Roll back and raise if the LOAD DATA just run changed any value while the session is in strict mode.
|
|
363
|
+
|
|
364
|
+
``LOAD DATA LOCAL`` ignores strict mode: a value MySQL cannot store as
|
|
365
|
+
given is clamped or zeroed with a warning, not rejected (#101). This
|
|
366
|
+
makes the load fail as the same rows inserted one by one would. Notes
|
|
367
|
+
(a decimal rounded to the column's scale) are not warnings and pass, as
|
|
368
|
+
they do for an ``INSERT``. A script that turned strict mode off
|
|
369
|
+
(``SET SESSION sql_mode = 'ANSI'``) keeps MySQL's changed values.
|
|
370
|
+
"""
|
|
371
|
+
with self._cursor() as curs:
|
|
372
|
+
curs.execute("show warnings;")
|
|
373
|
+
warnings = curs.fetchall()
|
|
374
|
+
curs.execute("select @@session.sql_mode;")
|
|
375
|
+
mode = _text(curs.fetchone()[0]).upper()
|
|
376
|
+
if "STRICT_TRANS_TABLES" not in mode and "STRICT_ALL_TABLES" not in mode:
|
|
377
|
+
return
|
|
378
|
+
problems = [_text(row[2]) for row in warnings if _text(row[0]) in ("Warning", "Error")]
|
|
379
|
+
if not problems:
|
|
380
|
+
return
|
|
381
|
+
self.rollback()
|
|
382
|
+
shown = "; ".join(problems[:3])
|
|
383
|
+
more = f" (and {len(problems) - 3} more)" if len(problems) > 3 else ""
|
|
384
|
+
raise ErrInfo(
|
|
385
|
+
type="error",
|
|
386
|
+
other_msg=(
|
|
387
|
+
f"Can't import {file_name} into {sq_name}: MySQL would have stored changed values: {shown}{more}. "
|
|
388
|
+
"Nothing was loaded."
|
|
389
|
+
),
|
|
390
|
+
)
|
|
391
|
+
|
|
346
392
|
def import_tabular_file(
|
|
347
393
|
self,
|
|
348
394
|
schema_name: str | None,
|
|
349
395
|
table_name: str,
|
|
350
396
|
csv_file_obj: Any,
|
|
351
397
|
skipheader: bool,
|
|
398
|
+
*,
|
|
399
|
+
existing_table: bool = False,
|
|
352
400
|
) -> None:
|
|
353
|
-
"""Import a delimited file into a MySQL table.
|
|
401
|
+
"""Import a delimited file into a MySQL table.
|
|
402
|
+
|
|
403
|
+
The file's values go to the server as text, unconverted, so *existing_table* changes nothing here.
|
|
404
|
+
"""
|
|
354
405
|
# Import a file to a table. Columns must be compatible.
|
|
355
406
|
sq_name = self.schema_qualified_table_name(schema_name, table_name)
|
|
356
407
|
if not self.table_exists(table_name, schema_name):
|
|
@@ -436,6 +487,7 @@ class MySQLDatabase(Database):
|
|
|
436
487
|
)
|
|
437
488
|
try:
|
|
438
489
|
self.execute(import_sql)
|
|
490
|
+
self._fail_on_load_warnings(csv_file_obj.csvfname, sq_name)
|
|
439
491
|
finally:
|
|
440
492
|
if load_file != csv_file_obj.csvfname:
|
|
441
493
|
os.unlink(load_file)
|
execsql/db/oracle.py
CHANGED
|
@@ -12,6 +12,8 @@ import re
|
|
|
12
12
|
from typing import Any
|
|
13
13
|
|
|
14
14
|
from execsql.db.base import Database
|
|
15
|
+
from execsql.db.target_types import kind_for_type_name
|
|
16
|
+
from execsql.types import DataType
|
|
15
17
|
from execsql.db.tiers import SupportTier
|
|
16
18
|
from execsql.exceptions import ErrInfo
|
|
17
19
|
from execsql.utils.errors import exception_desc, fatal_error, install_hint
|
|
@@ -49,6 +51,29 @@ def _oracle_driver() -> Any:
|
|
|
49
51
|
return None
|
|
50
52
|
|
|
51
53
|
|
|
54
|
+
# oracledb DbType names -> the type names db.target_types reads. NUMBER is
|
|
55
|
+
# read from its precision and scale; a type not listed has no execsql type.
|
|
56
|
+
_ORACLE_TYPE_NAMES = {
|
|
57
|
+
"DB_TYPE_VARCHAR": "varchar2",
|
|
58
|
+
"DB_TYPE_NVARCHAR": "nvarchar2",
|
|
59
|
+
"DB_TYPE_CHAR": "char",
|
|
60
|
+
"DB_TYPE_NCHAR": "nchar",
|
|
61
|
+
"DB_TYPE_LONG": "clob",
|
|
62
|
+
"DB_TYPE_CLOB": "clob",
|
|
63
|
+
"DB_TYPE_NCLOB": "nclob",
|
|
64
|
+
"DB_TYPE_BINARY_FLOAT": "binary_float",
|
|
65
|
+
"DB_TYPE_BINARY_DOUBLE": "binary_double",
|
|
66
|
+
"DB_TYPE_BOOLEAN": "boolean",
|
|
67
|
+
"DB_TYPE_DATE": "date",
|
|
68
|
+
"DB_TYPE_TIMESTAMP": "timestamp",
|
|
69
|
+
"DB_TYPE_TIMESTAMP_TZ": "timestamp with time zone",
|
|
70
|
+
"DB_TYPE_TIMESTAMP_LTZ": "timestamp with local time zone",
|
|
71
|
+
"DB_TYPE_RAW": "raw",
|
|
72
|
+
"DB_TYPE_LONG_RAW": "long raw",
|
|
73
|
+
"DB_TYPE_BLOB": "blob",
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
|
|
52
77
|
class OracleDatabase(Database):
|
|
53
78
|
"""Oracle adapter using the python-oracledb driver."""
|
|
54
79
|
|
|
@@ -268,6 +293,17 @@ class OracleDatabase(Database):
|
|
|
268
293
|
rows = curs.fetchall()
|
|
269
294
|
return [row[0] for row in rows]
|
|
270
295
|
|
|
296
|
+
def _described_kind(self, description: Any) -> type[DataType] | None:
|
|
297
|
+
"""oracledb describes a column by a ``DbType`` (``DB_TYPE_NUMBER``), with its precision and scale."""
|
|
298
|
+
name = getattr(description[1], "name", "")
|
|
299
|
+
if name == "DB_TYPE_NUMBER":
|
|
300
|
+
precision, scale = description[4], description[5]
|
|
301
|
+
# NUMBER with no precision, and FLOAT, report scale -127.
|
|
302
|
+
type_name = f"number({precision},{scale})" if precision and scale is not None and scale >= 0 else "number"
|
|
303
|
+
else:
|
|
304
|
+
type_name = _ORACLE_TYPE_NAMES.get(name, "")
|
|
305
|
+
return kind_for_type_name(type_name, oracle=True)
|
|
306
|
+
|
|
271
307
|
def view_exists(self, view_name: str, schema_name: str | None = None) -> bool:
|
|
272
308
|
"""Return True if the named view exists in the Oracle database."""
|
|
273
309
|
# Oracle folds unquoted identifiers to uppercase.
|
execsql/db/postgres.py
CHANGED
|
@@ -14,6 +14,7 @@ import re
|
|
|
14
14
|
from typing import Any
|
|
15
15
|
|
|
16
16
|
from execsql.db.base import Database
|
|
17
|
+
from execsql.db.target_types import ColumnKinds, kind_for_type_name
|
|
17
18
|
from execsql.exceptions import ErrInfo
|
|
18
19
|
from execsql.utils.errors import exception_desc, fatal_error, install_hint
|
|
19
20
|
from execsql.utils.auth import (
|
|
@@ -224,6 +225,16 @@ class PostgresDatabase(Database):
|
|
|
224
225
|
rows = curs.fetchall()
|
|
225
226
|
return len(rows) > 0
|
|
226
227
|
|
|
228
|
+
def column_kinds(self, sq_name: str) -> ColumnKinds:
|
|
229
|
+
"""The execsql type of each column of *sq_name*, from the catalog (``regclass`` follows the search path)."""
|
|
230
|
+
_description, rows = self._column_type_query(
|
|
231
|
+
sq_name,
|
|
232
|
+
"select attname, format_type(atttypid, atttypmod) from pg_attribute "
|
|
233
|
+
"where attrelid = %s::regclass and attnum > 0 and not attisdropped order by attnum;",
|
|
234
|
+
[sq_name],
|
|
235
|
+
)
|
|
236
|
+
return {name.lower(): kind_for_type_name(type_name) for name, type_name in rows}
|
|
237
|
+
|
|
227
238
|
def view_exists(self, view_name: str, schema_name: str | None = None) -> bool:
|
|
228
239
|
"""Return True if the named view exists in the PostgreSQL database."""
|
|
229
240
|
if schema_name is not None:
|
|
@@ -279,8 +290,13 @@ class PostgresDatabase(Database):
|
|
|
279
290
|
table_name: str,
|
|
280
291
|
csv_file_obj: Any,
|
|
281
292
|
skipheader: bool,
|
|
293
|
+
*,
|
|
294
|
+
existing_table: bool = False,
|
|
282
295
|
) -> None:
|
|
283
|
-
"""Import a delimited file into a PostgreSQL table, using COPY when possible.
|
|
296
|
+
"""Import a delimited file into a PostgreSQL table, using COPY when possible.
|
|
297
|
+
|
|
298
|
+
The file's values go to the server as text, unconverted, so *existing_table* changes nothing here.
|
|
299
|
+
"""
|
|
284
300
|
# Import a file to a table. Columns must be compatible.
|
|
285
301
|
sq_name = self.schema_qualified_table_name(schema_name, table_name)
|
|
286
302
|
if not self.table_exists(table_name, schema_name):
|
execsql/db/sqlite.py
CHANGED
|
@@ -182,8 +182,14 @@ class SQLiteDatabase(Database):
|
|
|
182
182
|
rowsource: Any,
|
|
183
183
|
column_list: list[str],
|
|
184
184
|
tablespec_src: Any,
|
|
185
|
+
*,
|
|
186
|
+
existing_table: bool = False,
|
|
185
187
|
) -> None:
|
|
186
|
-
"""Populate a SQLite table from a row source generator.
|
|
188
|
+
"""Populate a SQLite table from a row source generator.
|
|
189
|
+
|
|
190
|
+
Values are inserted as the source gives them (dates, times and
|
|
191
|
+
decimals as text), so *existing_table* changes nothing here.
|
|
192
|
+
"""
|
|
187
193
|
# The rowsource argument must be a generator yielding a list of values for the columns of the table.
|
|
188
194
|
# The column_list argument must an iterable containing column names in the same order as produced by the rowsource.
|
|
189
195
|
sq_name = self.schema_qualified_table_name(None, table_name)
|
execsql/db/sqlserver.py
CHANGED
|
@@ -8,6 +8,7 @@ Server via ``pyodbc``. Corresponds to ``-t s`` on the CLI.
|
|
|
8
8
|
"""
|
|
9
9
|
|
|
10
10
|
from execsql.db.base import Database
|
|
11
|
+
from execsql.db.target_types import ColumnKinds, kind_for_type_name
|
|
11
12
|
from execsql.db.odbc import connection_string, fresh_sessions, loggable
|
|
12
13
|
from execsql.exceptions import ErrInfo
|
|
13
14
|
from execsql.utils.errors import fatal_error, install_hint
|
|
@@ -228,6 +229,29 @@ class SqlServerDatabase(Database):
|
|
|
228
229
|
rows = curs.fetchall()
|
|
229
230
|
return len(rows) > 0
|
|
230
231
|
|
|
232
|
+
def column_kinds(self, sq_name: str) -> ColumnKinds:
|
|
233
|
+
"""The execsql type of each column of *sq_name*, from ``sys.columns``.
|
|
234
|
+
|
|
235
|
+
pyodbc describes ``datetimeoffset`` and ``varbinary`` alike, so the
|
|
236
|
+
catalog is read instead. A table it does not find through
|
|
237
|
+
``object_id`` (a ``#temp`` table, which lives in tempdb) falls back
|
|
238
|
+
to the driver's description.
|
|
239
|
+
"""
|
|
240
|
+
_description, rows = self._column_type_query(
|
|
241
|
+
sq_name,
|
|
242
|
+
"select c.name, type_name(c.system_type_id), c.precision, c.scale from sys.columns c "
|
|
243
|
+
"where c.object_id = object_id(?) order by c.column_id;",
|
|
244
|
+
[sq_name],
|
|
245
|
+
)
|
|
246
|
+
if not rows:
|
|
247
|
+
return super().column_kinds(sq_name)
|
|
248
|
+
return {
|
|
249
|
+
name.lower(): kind_for_type_name(
|
|
250
|
+
f"{type_name}({precision},{scale})" if type_name in ("decimal", "numeric") else type_name,
|
|
251
|
+
)
|
|
252
|
+
for name, type_name, precision, scale in rows
|
|
253
|
+
}
|
|
254
|
+
|
|
231
255
|
def drop_table(self, tablename: str) -> None:
|
|
232
256
|
"""Drop *tablename*, schema-qualified and quoted by the caller."""
|
|
233
257
|
# SQL Server and Firebird will throw an error if there are foreign keys to the table.
|