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.
Files changed (45) hide show
  1. execsql/api.py +1 -0
  2. execsql/config.py +2 -0
  3. execsql/data/execsql.conf.template +7 -0
  4. execsql/db/base.py +71 -8
  5. execsql/db/duckdb.py +6 -0
  6. execsql/db/mysql.py +54 -2
  7. execsql/db/oracle.py +36 -0
  8. execsql/db/postgres.py +17 -1
  9. execsql/db/sqlite.py +7 -1
  10. execsql/db/sqlserver.py +24 -0
  11. execsql/db/target_types.py +277 -0
  12. execsql/exporters/json.py +51 -7
  13. execsql/exporters/protocol.py +1 -0
  14. execsql/importers/base.py +1 -1
  15. execsql/importers/csv.py +1 -1
  16. execsql/metacommands/__init__.py +17 -1
  17. execsql/metacommands/data.py +7 -1
  18. execsql/metacommands/dispatch.py +7 -0
  19. execsql/metacommands/io_export.py +3 -1
  20. execsql/metacommands/io_fileops.py +2 -2
  21. execsql/metacommands/reference.py +1 -0
  22. execsql/models.py +30 -4
  23. execsql/script/executor.py +10 -10
  24. execsql/script/variables.py +4 -0
  25. execsql/types.py +2 -2
  26. execsql/utils/errors.py +2 -2
  27. {execsql2-2.33.0.dist-info → execsql2-2.35.0.dist-info}/METADATA +3 -3
  28. {execsql2-2.33.0.dist-info → execsql2-2.35.0.dist-info}/RECORD +45 -44
  29. {execsql2-2.33.0.data → execsql2-2.35.0.data}/data/execsql2_extras/README.md +0 -0
  30. {execsql2-2.33.0.data → execsql2-2.35.0.data}/data/execsql2_extras/config_settings.sqlite +0 -0
  31. {execsql2-2.33.0.data → execsql2-2.35.0.data}/data/execsql2_extras/example_config_prompt.sql +0 -0
  32. {execsql2-2.33.0.data → execsql2-2.35.0.data}/data/execsql2_extras/make_config_db.sql +0 -0
  33. {execsql2-2.33.0.data → execsql2-2.35.0.data}/data/execsql2_extras/md_compare.sql +0 -0
  34. {execsql2-2.33.0.data → execsql2-2.35.0.data}/data/execsql2_extras/md_glossary.sql +0 -0
  35. {execsql2-2.33.0.data → execsql2-2.35.0.data}/data/execsql2_extras/md_upsert.sql +0 -0
  36. {execsql2-2.33.0.data → execsql2-2.35.0.data}/data/execsql2_extras/pg_compare.sql +0 -0
  37. {execsql2-2.33.0.data → execsql2-2.35.0.data}/data/execsql2_extras/pg_glossary.sql +0 -0
  38. {execsql2-2.33.0.data → execsql2-2.35.0.data}/data/execsql2_extras/script_template.sql +0 -0
  39. {execsql2-2.33.0.data → execsql2-2.35.0.data}/data/execsql2_extras/ss_compare.sql +0 -0
  40. {execsql2-2.33.0.data → execsql2-2.35.0.data}/data/execsql2_extras/ss_glossary.sql +0 -0
  41. {execsql2-2.33.0.data → execsql2-2.35.0.data}/data/execsql2_extras/ss_upsert.sql +0 -0
  42. {execsql2-2.33.0.dist-info → execsql2-2.35.0.dist-info}/WHEEL +0 -0
  43. {execsql2-2.33.0.dist-info → execsql2-2.35.0.dist-info}/entry_points.txt +0 -0
  44. {execsql2-2.33.0.dist-info → execsql2-2.35.0.dist-info}/licenses/LICENSE.txt +0 -0
  45. {execsql2-2.33.0.dist-info → execsql2-2.35.0.dist-info}/licenses/NOTICE +0 -0
execsql/api.py CHANGED
@@ -178,6 +178,7 @@ class _LibraryConfig:
178
178
  self.boolean_int = True
179
179
  self.boolean_words = False
180
180
  self.empty_strings = True
181
+ self.exact_decimals = False
181
182
  self.only_strings = False
182
183
  self.empty_rows = True
183
184
  self.del_empty_cols = False
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
- type_objs = [col.column_type()[1]() for col in tablespec.cols]
717
- type_mod_fn = [self.type.dialect[col.column_type()[1]][3] for col in tablespec.cols]
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
- self.execute("set session sql_mode='ANSI';")
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.