execsql2 2.32.0__py3-none-any.whl → 2.34.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 (40) hide show
  1. execsql/db/base.py +71 -8
  2. execsql/db/duckdb.py +6 -0
  3. execsql/db/mysql.py +54 -2
  4. execsql/db/oracle.py +36 -0
  5. execsql/db/postgres.py +17 -1
  6. execsql/db/sqlite.py +7 -1
  7. execsql/db/sqlserver.py +24 -0
  8. execsql/db/target_types.py +277 -0
  9. execsql/exporters/json.py +51 -7
  10. execsql/exporters/protocol.py +1 -0
  11. execsql/importers/base.py +1 -1
  12. execsql/importers/csv.py +1 -1
  13. execsql/metacommands/__init__.py +17 -1
  14. execsql/metacommands/io_export.py +3 -1
  15. execsql/metacommands/io_fileops.py +2 -2
  16. execsql/metacommands/reference.py +10 -0
  17. execsql/metacommands/upsert.py +13 -0
  18. execsql/script/engine.py +5 -7
  19. execsql/script/executor.py +3 -1
  20. execsql/script/variables.py +4 -0
  21. execsql/types.py +2 -2
  22. {execsql2-2.32.0.dist-info → execsql2-2.34.0.dist-info}/METADATA +3 -3
  23. {execsql2-2.32.0.dist-info → execsql2-2.34.0.dist-info}/RECORD +40 -39
  24. {execsql2-2.32.0.data → execsql2-2.34.0.data}/data/execsql2_extras/README.md +0 -0
  25. {execsql2-2.32.0.data → execsql2-2.34.0.data}/data/execsql2_extras/config_settings.sqlite +0 -0
  26. {execsql2-2.32.0.data → execsql2-2.34.0.data}/data/execsql2_extras/example_config_prompt.sql +0 -0
  27. {execsql2-2.32.0.data → execsql2-2.34.0.data}/data/execsql2_extras/make_config_db.sql +0 -0
  28. {execsql2-2.32.0.data → execsql2-2.34.0.data}/data/execsql2_extras/md_compare.sql +0 -0
  29. {execsql2-2.32.0.data → execsql2-2.34.0.data}/data/execsql2_extras/md_glossary.sql +0 -0
  30. {execsql2-2.32.0.data → execsql2-2.34.0.data}/data/execsql2_extras/md_upsert.sql +0 -0
  31. {execsql2-2.32.0.data → execsql2-2.34.0.data}/data/execsql2_extras/pg_compare.sql +0 -0
  32. {execsql2-2.32.0.data → execsql2-2.34.0.data}/data/execsql2_extras/pg_glossary.sql +0 -0
  33. {execsql2-2.32.0.data → execsql2-2.34.0.data}/data/execsql2_extras/script_template.sql +0 -0
  34. {execsql2-2.32.0.data → execsql2-2.34.0.data}/data/execsql2_extras/ss_compare.sql +0 -0
  35. {execsql2-2.32.0.data → execsql2-2.34.0.data}/data/execsql2_extras/ss_glossary.sql +0 -0
  36. {execsql2-2.32.0.data → execsql2-2.34.0.data}/data/execsql2_extras/ss_upsert.sql +0 -0
  37. {execsql2-2.32.0.dist-info → execsql2-2.34.0.dist-info}/WHEEL +0 -0
  38. {execsql2-2.32.0.dist-info → execsql2-2.34.0.dist-info}/entry_points.txt +0 -0
  39. {execsql2-2.32.0.dist-info → execsql2-2.34.0.dist-info}/licenses/LICENSE.txt +0 -0
  40. {execsql2-2.32.0.dist-info → execsql2-2.34.0.dist-info}/licenses/NOTICE +0 -0
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.
@@ -0,0 +1,277 @@
1
+ """The execsql data type of each column of a table that IMPORT or COPY loads.
2
+
3
+ A load into a table that already exists converts each value by the type of
4
+ the column it goes into, not by the type scanned from the source data
5
+ (issue #100). The adapters report each column's type: by the name their
6
+ catalog gives it (PostgreSQL, MySQL, SQL Server, DuckDB, Oracle), or by the
7
+ Python type their driver describes it with (Access, Firebird, ODBC DSNs).
8
+ The functions here turn that
9
+ into one of execsql's data types, or ``None`` when execsql has no
10
+ conversion that fits (arrays, JSON, UUIDs, enums, geometry, intervals, …).
11
+
12
+ Only the types that change how a value is converted are told apart: every
13
+ character type is :class:`DT_Text`, every integer type :class:`DT_Long`,
14
+ and so on. A ``DECIMAL``/``NUMERIC``/``NUMBER`` with scale 0 holds whole
15
+ numbers only, so it is :class:`DT_Long` too.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import datetime
21
+ import math
22
+ import re
23
+ from collections.abc import Callable
24
+ from decimal import Decimal
25
+ from typing import Any, TypeAlias
26
+
27
+ from execsql.exceptions import ErrInfo
28
+ from execsql.types import (
29
+ DataType,
30
+ DT_Binary,
31
+ DT_Boolean,
32
+ DT_Date,
33
+ DT_Decimal,
34
+ DT_Float,
35
+ DT_Long,
36
+ DT_Text,
37
+ DT_Time,
38
+ DT_Timestamp,
39
+ DT_TimestampTZ,
40
+ )
41
+
42
+ __all__ = ["ColumnKinds", "existing_table_converter", "kind_for_python_type", "kind_for_type_name"]
43
+
44
+ #: Lower-cased column name -> the execsql type values for it convert to, or None.
45
+ #: A string, not ``dict[...]``: on Python 3.10 that object passes
46
+ #: ``isinstance(x, type)``, so code that scans the adapter modules for classes
47
+ #: would take it for one.
48
+ ColumnKinds: TypeAlias = "dict[str, type[DataType] | None]"
49
+
50
+ _TEXT = {
51
+ "char",
52
+ "character",
53
+ "varchar",
54
+ "character varying",
55
+ "varchar2",
56
+ "nchar",
57
+ "national character",
58
+ "nvarchar",
59
+ "nvarchar2",
60
+ "national character varying",
61
+ "bpchar",
62
+ "text",
63
+ "ntext",
64
+ "tinytext",
65
+ "mediumtext",
66
+ "longtext",
67
+ "string",
68
+ "clob",
69
+ "nclob",
70
+ }
71
+ _INTEGER = {
72
+ "integer",
73
+ "int",
74
+ "int1",
75
+ "int2",
76
+ "int4",
77
+ "int8",
78
+ "tinyint",
79
+ "smallint",
80
+ "mediumint",
81
+ "bigint",
82
+ "hugeint",
83
+ "utinyint",
84
+ "usmallint",
85
+ "uinteger",
86
+ "ubigint",
87
+ "uhugeint",
88
+ "serial",
89
+ "smallserial",
90
+ "bigserial",
91
+ }
92
+ _DECIMAL = {"decimal", "numeric", "number", "money", "smallmoney"}
93
+ _FLOAT = {"float", "float4", "float8", "real", "double", "double precision", "binary_float", "binary_double"}
94
+ _BOOLEAN = {"boolean", "bool"}
95
+ _TIMESTAMP = {
96
+ "timestamp",
97
+ "timestamp without time zone",
98
+ "datetime",
99
+ "datetime2",
100
+ "smalldatetime",
101
+ "timestamp_s",
102
+ "timestamp_ms",
103
+ "timestamp_ns",
104
+ }
105
+ _TIMESTAMPTZ = {"timestamptz", "timestamp with time zone", "timestamp with local time zone", "datetimeoffset"}
106
+ _TIME = {"time", "time without time zone"}
107
+ _BINARY = {
108
+ "bytea",
109
+ "blob",
110
+ "tinyblob",
111
+ "mediumblob",
112
+ "longblob",
113
+ "binary",
114
+ "varbinary",
115
+ "binary varying",
116
+ "image",
117
+ "raw",
118
+ "long raw",
119
+ }
120
+
121
+ _PARAMS = re.compile(r"\(([^)]*)\)")
122
+
123
+
124
+ def kind_for_type_name(type_name: str, *, oracle: bool = False) -> type[DataType] | None:
125
+ """The execsql type for a column declared as *type_name* (as the DBMS's catalog spells it).
126
+
127
+ *oracle*: Oracle's ``DATE`` holds a time of day too, so it is a timestamp there.
128
+ """
129
+ name = type_name.strip().lower()
130
+ if not name or "[" in name or name.endswith(" array"):
131
+ return None
132
+ params = _PARAMS.search(name)
133
+ args = [a.strip() for a in params.group(1).split(",")] if params else []
134
+ base = " ".join(_PARAMS.sub(" ", name).replace(" unsigned", " ").replace(" zerofill", " ").split())
135
+ if base == "tinyint" and args == ["1"]:
136
+ return DT_Boolean # MySQL's BOOLEAN
137
+ if base == "bit":
138
+ # SQL Server's BIT; a wider BIT(n) is a bit string.
139
+ return DT_Boolean if args in ([], ["1"]) else None
140
+ if base in _TEXT:
141
+ return DT_Text
142
+ if base in _INTEGER:
143
+ return DT_Long
144
+ if base in _DECIMAL:
145
+ if args[1:] == ["0"] or len(args) == 1:
146
+ return DT_Long # DECIMAL(p,0), NUMERIC(p), NUMBER(p): whole numbers
147
+ return DT_Decimal
148
+ if base in _FLOAT:
149
+ return DT_Float
150
+ if base in _BOOLEAN:
151
+ return DT_Boolean
152
+ if base == "date":
153
+ return DT_Timestamp if oracle else DT_Date
154
+ if base in _TIMESTAMP:
155
+ return DT_Timestamp
156
+ if base in _TIMESTAMPTZ:
157
+ return DT_TimestampTZ
158
+ if base in _TIME:
159
+ return DT_Time
160
+ if base in _BINARY:
161
+ return DT_Binary
162
+ return None
163
+
164
+
165
+ def kind_for_python_type(type_code: object, scale: object = None) -> type[DataType] | None:
166
+ """The execsql type for a column a driver describes by the Python type it returns (pyodbc, firebird-driver).
167
+
168
+ Binary columns are left out: pyodbc describes a SQL Server
169
+ ``datetimeoffset`` as ``bytearray`` too.
170
+ """
171
+ if type_code is bool:
172
+ return DT_Boolean
173
+ if type_code is int:
174
+ return DT_Long
175
+ if type_code is float:
176
+ return DT_Float
177
+ if type_code is Decimal:
178
+ return DT_Long if scale == 0 else DT_Decimal
179
+ if type_code is str:
180
+ return DT_Text
181
+ if type_code is datetime.datetime:
182
+ return DT_Timestamp
183
+ if type_code is datetime.date:
184
+ return DT_Date
185
+ if type_code is datetime.time:
186
+ return DT_Time
187
+ return None
188
+
189
+
190
+ # ---------------------------------------------------------------------------
191
+ # Converting a value for the column it goes into
192
+ # ---------------------------------------------------------------------------
193
+
194
+ _INTEGER_TEXT = re.compile(r"^\s*[+-]?[0-9]+\s*$")
195
+ _NUMBER_TEXT = re.compile(r"^\s*[+-]?(?:[0-9]+\.?[0-9]*|\.[0-9]+)(?:[eE][+-]?[0-9]+)?\s*$")
196
+ # A whole number longer than this fits no integer column; it goes to the
197
+ # database as given, which rejects it, instead of being expanded here.
198
+ _MAX_INTEGER_DIGITS = 40
199
+
200
+ Converter = Callable[[Any], Any]
201
+
202
+
203
+ def _as_given(value: Any) -> Any:
204
+ return value
205
+
206
+
207
+ def _whole_number_converter(column: str, table: str, default: Converter) -> Converter:
208
+ """Convert a value for integer column *column*: a fraction is an error, never rounded.
209
+
210
+ A value that is not a number (NULL, a date, a word) gets *default*, the
211
+ conversion it had before, and the database takes or rejects that.
212
+ """
213
+
214
+ def convert(value: Any) -> Any:
215
+ if isinstance(value, bool):
216
+ return default(value)
217
+ if isinstance(value, int):
218
+ return value
219
+ if isinstance(value, str):
220
+ if _INTEGER_TEXT.match(value):
221
+ return int(value)
222
+ if not _NUMBER_TEXT.match(value):
223
+ return default(value)
224
+ number = Decimal(value)
225
+ elif isinstance(value, Decimal):
226
+ number = value
227
+ elif isinstance(value, float):
228
+ if not math.isfinite(value):
229
+ return default(value)
230
+ number = Decimal(value)
231
+ else:
232
+ return default(value)
233
+ if not number.is_finite() or number.adjusted() >= _MAX_INTEGER_DIGITS:
234
+ return default(value)
235
+ if number == number.to_integral_value():
236
+ return int(number)
237
+ raise ErrInfo(
238
+ type="error",
239
+ other_msg=(
240
+ f"Can't load {value!r} into column {column} of {table}: the column holds whole numbers, "
241
+ "and the database would round the value."
242
+ ),
243
+ )
244
+
245
+ return convert
246
+
247
+
248
+ def _true_false_as_number(default: Converter) -> Converter:
249
+ """Convert a value scanned as true/false for a number column: 1 or 0, read as before (``yes`` is 1)."""
250
+
251
+ def convert(value: Any) -> Any:
252
+ flag = default(value)
253
+ return int(flag) if isinstance(flag, bool) else flag
254
+
255
+ return convert
256
+
257
+
258
+ def existing_table_converter(
259
+ kind: type[DataType] | None,
260
+ scanned: type[DataType],
261
+ default: Converter,
262
+ column: str,
263
+ table: str,
264
+ ) -> Converter:
265
+ """How a value for *column* of existing table *table* is converted.
266
+
267
+ *kind* is the column's type (``None`` if execsql has none that fits),
268
+ *scanned* the type the source column scanned as, and *default* the
269
+ conversion to that scanned type, which a new table's values get.
270
+ """
271
+ if kind is DT_Text:
272
+ return _as_given
273
+ if kind in (DT_Long, DT_Decimal, DT_Float) and scanned is DT_Boolean:
274
+ return _true_false_as_number(default)
275
+ if kind is DT_Long:
276
+ return _whole_number_converter(column, table, default)
277
+ return default
execsql/exporters/json.py CHANGED
@@ -3,9 +3,11 @@ from __future__ import annotations
3
3
  """
4
4
  JSON export for execsql.
5
5
 
6
- Provides :func:`write_query_to_json` (standard JSON array of objects) and
7
- :func:`write_query_to_json_ts` (JSON with a top-level timestamp wrapper),
8
- both of which serialize a query result set to a file or stream.
6
+ Provides :func:`write_query_to_json` (standard JSON array of objects),
7
+ :func:`write_query_to_jsonl` (`JSON Lines <https://jsonlines.org/>`_, one
8
+ object per line) and :func:`write_query_to_json_ts` (JSON with a top-level
9
+ timestamp wrapper), all of which serialize a query result set to a file or
10
+ stream.
9
11
  """
10
12
 
11
13
  import json
@@ -18,7 +20,13 @@ from execsql.models import DataTable, to_json_type
18
20
  from execsql.utils.errors import exception_desc
19
21
  from execsql.utils.fileio import filewriter_close
20
22
 
21
- __all__ = ["write_query_to_json", "write_query_to_json_ts"]
23
+ __all__ = ["write_query_to_json", "write_query_to_json_ts", "write_query_to_jsonl"]
24
+
25
+
26
+ def _row_json(hdrs: list[str], row: Any) -> str:
27
+ """One row as a compact JSON object; JSON and JSONL write rows alike."""
28
+ dictdata = dict(zip(hdrs, [str(v) if isinstance(v, str) else v for v in row]))
29
+ return json.dumps(dictdata, separators=(",", ":"), default=str)
22
30
 
23
31
 
24
32
  def write_query_to_json(
@@ -60,14 +68,50 @@ def write_query_to_json(
60
68
  else:
61
69
  f.write(",\n")
62
70
  first = False
63
- dictdata = dict(zip(uhdrs, [str(v) if isinstance(v, str) else v for v in row]))
64
- jsondata = json.dumps(dictdata, separators=(",", ":"), default=str)
65
- f.write(str(jsondata))
71
+ f.write(_row_json(uhdrs, row))
66
72
  f.write("\n]\n")
67
73
  finally:
68
74
  f.close()
69
75
 
70
76
 
77
+ def write_query_to_jsonl(
78
+ select_stmt: str,
79
+ db: Any,
80
+ outfile: str,
81
+ append: bool = False,
82
+ desc: str | None = None,
83
+ zipfile: str | None = None,
84
+ ) -> None:
85
+ """Execute a SELECT and write the result set as JSON Lines: one JSON object per row, one row per line.
86
+
87
+ Values are written as :func:`write_query_to_json` writes them. A line
88
+ break inside a value is escaped (``\\n``), so each row stays on one line.
89
+ With *append* the rows are added to the end of the file, which stays
90
+ valid JSON Lines.
91
+ """
92
+ conf = _state.conf
93
+ try:
94
+ hdrs, rows = db.select_rowsource(select_stmt)
95
+ except ErrInfo:
96
+ raise
97
+ except Exception as e:
98
+ raise ErrInfo("db", select_stmt, exception_msg=exception_desc()) from e
99
+ f: Any
100
+ if zipfile is None:
101
+ filewriter_close(outfile)
102
+ from execsql.utils.fileio import EncodedFile
103
+
104
+ f = EncodedFile(outfile, conf.output_encoding).open("at" if append else "wt")
105
+ else:
106
+ f = ZipWriter(zipfile, outfile, append)
107
+ try:
108
+ uhdrs = [str(h) for h in hdrs]
109
+ for row in rows:
110
+ f.write(_row_json(uhdrs, row) + "\n")
111
+ finally:
112
+ f.close()
113
+
114
+
71
115
  def write_query_to_json_ts(
72
116
  select_stmt: str,
73
117
  db: Any,
@@ -51,6 +51,7 @@ class QueryExporter(Protocol):
51
51
  Conforming functions
52
52
  --------------------
53
53
  - ``write_query_to_json``
54
+ - ``write_query_to_jsonl``
54
55
  - ``write_query_to_html``
55
56
  - ``write_query_to_cgi_html``
56
57
  - ``write_query_to_latex``
execsql/importers/base.py CHANGED
@@ -142,7 +142,7 @@ def import_data_table(
142
142
  )
143
143
  import_cols = hdrs
144
144
  try:
145
- db.populate_table(schemaname, tablename, data, import_cols, get_ts)
145
+ db.populate_table(schemaname, tablename, data, import_cols, get_ts, existing_table=not is_new)
146
146
  db.commit()
147
147
  except ErrInfo:
148
148
  raise
execsql/importers/csv.py CHANGED
@@ -91,7 +91,7 @@ def importtable(
91
91
  other_msg=f"Non-existent table name ({tablename}) used with the IMPORT_FILE metacommand",
92
92
  )
93
93
  try:
94
- db.import_tabular_file(schemaname, tablename, inf, skipheader=True)
94
+ db.import_tabular_file(schemaname, tablename, inf, skipheader=True, existing_table=not is_new)
95
95
  db.commit()
96
96
  except ErrInfo:
97
97
  raise
@@ -474,13 +474,29 @@ JSON_VARIANT_FORMATS = ["JSON_TS", "JSON_TABLESCHEMA"]
474
474
  QUERY_EXPORT_FORMATS = (
475
475
  DELIMITED_FORMATS
476
476
  + TEXT_FORMATS
477
- + ["ODS", "XLSX", "JSON", "HTML", "CGI-HTML", "VALUES", "LATEX", "RAW", "B64", "FEATHER", "YAML", "MARKDOWN", "MD"]
477
+ + [
478
+ "ODS",
479
+ "XLSX",
480
+ "JSON",
481
+ "JSONL",
482
+ "HTML",
483
+ "CGI-HTML",
484
+ "VALUES",
485
+ "LATEX",
486
+ "RAW",
487
+ "B64",
488
+ "FEATHER",
489
+ "YAML",
490
+ "MARKDOWN",
491
+ "MD",
492
+ ]
478
493
  )
479
494
  TABLE_EXPORT_FORMATS = (
480
495
  DELIMITED_FORMATS
481
496
  + TEXT_FORMATS
482
497
  + [
483
498
  "JSON",
499
+ "JSONL",
484
500
  "XML",
485
501
  "VALUES",
486
502
  "HTML",
@@ -27,7 +27,7 @@ from execsql.exporters.delimited import write_delimited_file
27
27
  from execsql.exporters.duckdb import write_query_to_duckdb
28
28
  from execsql.exporters.feather import write_query_to_feather, write_query_to_hdf5
29
29
  from execsql.exporters.html import write_query_to_cgi_html, write_query_to_html
30
- from execsql.exporters.json import write_query_to_json, write_query_to_json_ts
30
+ from execsql.exporters.json import write_query_to_json, write_query_to_json_ts, write_query_to_jsonl
31
31
  from execsql.exporters.latex import write_query_to_latex
32
32
  from execsql.exporters.ods import write_queries_to_ods, write_query_to_ods
33
33
  from execsql.exporters.xlsx import write_queries_to_xlsx, write_query_to_xlsx
@@ -123,6 +123,8 @@ def _dispatch_format(
123
123
  write_query_to_xml(select_stmt, xml_table, db, outfile, append, desc=description, zipfile=zipfilename)
124
124
  elif filefmt == "json":
125
125
  write_query_to_json(select_stmt, db, outfile, append, desc=description, zipfile=zipfilename)
126
+ elif filefmt == "jsonl":
127
+ write_query_to_jsonl(select_stmt, db, outfile, append, desc=description, zipfile=zipfilename)
126
128
  elif filefmt in ("json_ts", "json_tableschema"):
127
129
  write_query_to_json_ts(select_stmt, db, outfile, append, not notype, desc=description, zipfile=zipfilename)
128
130
  elif filefmt == "values":
@@ -158,7 +158,7 @@ def x_copy(**kwargs: Any) -> None:
158
158
  return get_ts_tablespec
159
159
 
160
160
  try:
161
- db2.populate_table(schema2, table2, rows_for_insert, hdrs, get_ts)
161
+ db2.populate_table(schema2, table2, rows_for_insert, hdrs, get_ts, existing_table=not new_tbl2)
162
162
  db2.commit()
163
163
  except BaseException:
164
164
  _close_rowsource(rows_to_close)
@@ -245,7 +245,7 @@ def x_copy_query(**kwargs: Any) -> None:
245
245
  return get_ts_tablespec
246
246
 
247
247
  try:
248
- db2.populate_table(schema2, table2, rows_for_insert, hdrs, get_ts)
248
+ db2.populate_table(schema2, table2, rows_for_insert, hdrs, get_ts, existing_table=not new_tbl2)
249
249
  db2.commit()
250
250
  except BaseException:
251
251
  _close_rowsource(rows_to_close)
@@ -1364,6 +1364,16 @@ _SYSTEM_VARIABLES: tuple[SystemVariable, ...] = (
1364
1364
  "TRUE or FALSE: whether the changes were committed.",
1365
1365
  "metacommands.md#pg_upsert",
1366
1366
  ),
1367
+ SystemVariable(
1368
+ "$PG_UPSERT_STATUS",
1369
+ "How the run ended: committed, dry_run, no_changes, qa_failed, cancelled or db_error.",
1370
+ "metacommands.md#pg_upsert",
1371
+ ),
1372
+ SystemVariable(
1373
+ "$PG_UPSERT_ERROR",
1374
+ "The database error message when $PG_UPSERT_STATUS is db_error; otherwise empty.",
1375
+ "metacommands.md#pg_upsert",
1376
+ ),
1367
1377
  SystemVariable("$PG_UPSERT_STAGING_SCHEMA", "Staging schema name used.", "metacommands.md#pg_upsert"),
1368
1378
  SystemVariable("$PG_UPSERT_BASE_SCHEMA", "Base schema name used.", "metacommands.md#pg_upsert"),
1369
1379
  SystemVariable("$PG_UPSERT_TABLES", "Comma-separated list of table names processed.", "metacommands.md#pg_upsert"),
@@ -185,6 +185,11 @@ def _set_subvars(result: Any) -> None:
185
185
  sv("$PG_UPSERT_ROWS_UPDATED", str(result.total_updated))
186
186
  sv("$PG_UPSERT_ROWS_INSERTED", str(result.total_inserted))
187
187
  sv("$PG_UPSERT_COMMITTED", str(result.committed).upper())
188
+ # How the run ended: committed, dry_run, no_changes, qa_failed, cancelled
189
+ # or db_error. Empty for QA and CHECK, which do not run an upsert.
190
+ status = getattr(result, "status", None)
191
+ sv("$PG_UPSERT_STATUS", status.value if status is not None else "")
192
+ sv("$PG_UPSERT_ERROR", getattr(result, "error", None) or "")
188
193
  sv("$PG_UPSERT_STAGING_SCHEMA", result.staging_schema)
189
194
  sv("$PG_UPSERT_BASE_SCHEMA", result.base_schema)
190
195
  sv("$PG_UPSERT_TABLES", ", ".join(t.table_name for t in result.tables))
@@ -426,6 +431,14 @@ def _handle_pg_upsert_errors(fn: Any, metacommandline: str | None) -> Any:
426
431
  try:
427
432
  return fn()
428
433
  except UserCancelledError as exc:
434
+ # No result describes a run cancelled outside pg-upsert's own run();
435
+ # say what is known rather than leave an earlier run's values.
436
+ sv = _state.subvars.add_substitution
437
+ sv("$PG_UPSERT_STATUS", "cancelled")
438
+ sv("$PG_UPSERT_ERROR", "")
439
+ sv("$PG_UPSERT_COMMITTED", "FALSE")
440
+ sv("$PG_UPSERT_ROWS_UPDATED", "0")
441
+ sv("$PG_UPSERT_ROWS_INSERTED", "0")
429
442
  raise ErrInfo(
430
443
  "cmd",
431
444
  command_text=metacommandline,
execsql/script/engine.py CHANGED
@@ -108,6 +108,7 @@ class MetaCommand:
108
108
  """Match *cmd_str* against this entry's regex and, if it matches, invoke the handler.
109
109
 
110
110
  Returns ``(True, return_value)`` on a match, ``(False, None)`` otherwise.
111
+ Raises :class:`ErrInfo` when the handler fails.
111
112
  """
112
113
  # Runs the metacommand if the command string matches the regex.
113
114
  m = self.rx.match(cmd_str.strip())
@@ -125,13 +126,10 @@ class MetaCommand:
125
126
  er = ErrInfo("cmd", command_text=cmd_str, exception_msg=exception_desc())
126
127
  er.__cause__ = exc
127
128
  if er:
128
- if _state.status.halt_on_metacommand_err:
129
- from execsql.utils.errors import exit_now
130
-
131
- exit_now(1, er)
132
- if self.set_error_flag:
133
- _state.status.metacommand_error = True
134
- return True, None
129
+ # The caller records the error ($LAST_ERROR, $ERROR_MESSAGE,
130
+ # the log, the error history) and halts or carries on.
131
+ if self.set_error_flag or _state.status.halt_on_metacommand_err:
132
+ raise er
135
133
  else:
136
134
  if self.set_error_flag:
137
135
  _state.status.metacommand_error = False
@@ -350,7 +350,9 @@ def _exec_metacommand(
350
350
  if ctx.exec_log is not None:
351
351
  ctx.exec_log.log_status_info(f"Metacommand error: {logged_error(e)}")
352
352
  if status.halt_on_metacommand_err:
353
- raise e
353
+ exit_now(1, e)
354
+ if (manifest := _manifest.current()) is not None:
355
+ manifest.record_metacommand(cmd, source, line_no, metacommandlist)
354
356
  status.error_history.append((source, line_no, cmd, e.errmsg()))
355
357
  return None
356
358
  # No handler matched — truly unknown metacommand
@@ -317,6 +317,10 @@ class SubVarSet:
317
317
  Uses case-insensitive string search instead of per-variable regex
318
318
  compilation to avoid O(N) ``re.compile`` calls on every invocation.
319
319
  """
320
+ # Every token below starts with one of these, so text without them
321
+ # can't match; skip the scan over every defined variable.
322
+ if "!!" not in command_str and "!'!" not in command_str and '!"!' not in command_str:
323
+ return command_str, False
320
324
  cmd_lower = command_str.lower()
321
325
 
322
326
  def _check_nul(name: str, value: str) -> None:
execsql/types.py CHANGED
@@ -855,8 +855,8 @@ dbt_dsn.name_datatype(DT_Binary, "LONGBINARY")
855
855
 
856
856
  dbt_mysql = DbType("MySQL")
857
857
  dbt_mysql.name_datatype(DT_TimestampTZ, "varchar", True, "char")
858
- dbt_mysql.name_datatype(DT_Timestamp, "datetime", conv_mod_fn=lambda x: x if x is not None else "")
859
- dbt_mysql.name_datatype(DT_Date, "date", conv_mod_fn=lambda x: x if x is not None else "")
858
+ dbt_mysql.name_datatype(DT_Timestamp, "datetime")
859
+ dbt_mysql.name_datatype(DT_Date, "date")
860
860
  dbt_mysql.name_datatype(DT_Time, "time")
861
861
  dbt_mysql.name_datatype(DT_Integer, "integer", False, "signed integer")
862
862
  dbt_mysql.name_datatype(DT_Long, "bigint", False, "signed integer")
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: execsql2
3
- Version: 2.32.0
3
+ Version: 2.34.0
4
4
  Summary: Runs a SQL script against a PostgreSQL, SQLite, MariaDB/MySQL, DuckDB, Firebird, MS-Access, MS-SQL-Server, or Oracle database, or an ODBC DSN. Provides metacommands to import and export data, copy data between databases, conditionally execute SQL and metacommands, and dynamically alter SQL and metacommands with substitution variables.
5
5
  Project-URL: Homepage, https://execsql2.readthedocs.io/en/stable/
6
6
  Project-URL: Repository, https://github.com/geocoug/execsql
@@ -318,7 +318,7 @@ See the [full options reference](https://execsql2.readthedocs.io/en/stable/getti
318
318
  **Data movement**
319
319
 
320
320
  - Import data from CSV, TSV, JSON, Excel, OpenDocument, Feather, or Parquet files into a database table.
321
- - Export query results in 20+ formats including CSV, TSV, JSON, YAML, XML, HTML, Markdown, LaTeX, XLSX, OpenDocument, Feather, Parquet, HDF5, DuckDB, SQLite, plain text, and Jinja2 templates.
321
+ - Export query results in 20+ formats including CSV, TSV, JSON, JSON Lines, YAML, XML, HTML, Markdown, LaTeX, XLSX, OpenDocument, Feather, Parquet, HDF5, DuckDB, SQLite, plain text, and Jinja2 templates.
322
322
  - Copy data between databases, including across different DBMS types.
323
323
 
324
324
  **Script logic**
@@ -441,7 +441,7 @@ execsql format --no-sql --in-place scripts/
441
441
  ```yaml
442
442
  repos:
443
443
  - repo: https://github.com/geocoug/execsql
444
- rev: v2.32.0
444
+ rev: v2.34.0
445
445
  hooks:
446
446
  - id: execsql-format
447
447
  - id: execsql-lint
@@ -13,7 +13,7 @@ execsql/plugins.py,sha256=RykMlIjTyWcnQc9ygjLErS3wdcsZSsG9QTIvg4DwfAI,13626
13
13
  execsql/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
14
14
  execsql/state.py,sha256=rXhxx58dVFjhG58HGOXm3ewloVlvpcaYnW1rij4qjkM,23478
15
15
  execsql/state.pyi,sha256=ZDHZ1DPKYu4Qs4bcJT31XNv4GOt6O4s_zH4UHCowovw,4344
16
- execsql/types.py,sha256=XmLfqjXSxBJamZbz0JPodipXi1YzcN_2MjooPyzJsyY,33819
16
+ execsql/types.py,sha256=HiIhc8wqm9Trt8WYP40VU93uuBSPo1uPP3z-sMsUI1o,33719
17
17
  execsql/cli/__init__.py,sha256=FHg2lm2gt4L-XBnlVORn3qWHZ4739Ao-uwBIURfVIjg,2798
18
18
  execsql/cli/application.py,sha256=tsSYE8cerJxY6y73GM_Xr1WBwc6A0_al8y969B6lPP8,12542
19
19
  execsql/cli/dispatch.py,sha256=deyN2QQYvdzMSqGGa3CSMpaqdLNeMgc5Uke0mUbl764,2704
@@ -35,17 +35,18 @@ execsql/data/execsql.conf.template,sha256=Mz8wZxu0U2bfL-fsDpRHvdLkwNpfuZrl4QUMjp
35
35
  execsql/data/script_header.sql,sha256=a7WKScekNgJoxVEmMRTkWv8NsAzW1jJ4vJfGy9HVy0g,349
36
36
  execsql/db/__init__.py,sha256=jTbuafuKOqYtXFR1wvCOoKK5Lr3l1uErfaIbIr6UywI,1063
37
37
  execsql/db/access.py,sha256=fxiC3rtoOJzrNXNxau-w7oh1fd9tyh2_PMliBbFc164,21427
38
- execsql/db/base.py,sha256=WdF1X0xG01ZKaYNkxcyUSKJCV04cceThPkvERlN-iu0,44619
38
+ execsql/db/base.py,sha256=nMaODzV2_JqJzXJCshlWRQ9ZANPGK6IcIJ4_PBtXyT0,47714
39
39
  execsql/db/dsn.py,sha256=Evrc6MUllJVFDn0jZB_iks-aKCsXuNF9XYjAkttqgGQ,6995
40
- execsql/db/duckdb.py,sha256=6vD5b_tpvfByoFEajZNY5HLzVUnH1eOuPpzNGAKFsIQ,13624
40
+ execsql/db/duckdb.py,sha256=c8VdZcmZdmyH8abrxM8mUk0BGIbittp-oV6UK7lsbgg,13954
41
41
  execsql/db/factory.py,sha256=Uz7Xcbff6ZZbtLM55FhHCwGdo4KKfqU_1kyjvOHn-HY,5691
42
42
  execsql/db/firebird.py,sha256=CoNnzFtOFAkp8OJByXXXclitCPAAjM23eFJGfy3C7IE,10171
43
- execsql/db/mysql.py,sha256=FVId78NifBmNfEI8zmqjRMJ3FUy7hskv411LqABAAOQ,24198
43
+ execsql/db/mysql.py,sha256=X-brnIFiQaOwVbY0uqPrC4esclhBv4sg-gHW3EVxHNw,26923
44
44
  execsql/db/odbc.py,sha256=l03UNHi8sFtjHbgOKUNA2teKOisNk-8laQGCsHZs2eo,2526
45
- execsql/db/oracle.py,sha256=i5qPahsy_z1AuZT7l54Hzixknkvbzd-SCQgiVcTN94w,13796
46
- execsql/db/postgres.py,sha256=sX2azdSt9N6jm_I47Zg8fukdYExGHRq_7XwVRZcfgAo,23647
47
- execsql/db/sqlite.py,sha256=fEpNPkfJGnqm1Wx_rZK5SEqaWsBqLy85Jxk_5NsnqgM,12109
48
- execsql/db/sqlserver.py,sha256=6-wySx9BrSTAyXl5BnBLjRYEpl4_GubDjiGuvpehgco,10528
45
+ execsql/db/oracle.py,sha256=lUqdqzNOdNCkJCwbv9yHgaupeBuayZYsyoIDw76xrh4,15333
46
+ execsql/db/postgres.py,sha256=V3ldh51OIxuKkEAQz8k0TrzczbOFqoGMCSXlX3gH1-4,24433
47
+ execsql/db/sqlite.py,sha256=YTfESfdWdny4hIV23fjJbzFuEchq2IFAmQhRynweJL0,12308
48
+ execsql/db/sqlserver.py,sha256=WIVvTp1MNKaUZn912U7ErcjSqhaLTqnZ2hfv_Gcso74,11601
49
+ execsql/db/target_types.py,sha256=68nSyYY0mj6sgS26zeOYcFuUJA_ik08-0BeNsSM7UrE,8563
49
50
  execsql/db/tiers.py,sha256=PH2sUYwlYqcANGVKXprWQKVIlM6fpWU9q74djn8oFwc,2924
50
51
  execsql/debug/__init__.py,sha256=j6EGUR0dHzUhWN1mHHtf1-Lhjq3Sb1V-vmnq2Ztgj1M,178
51
52
  execsql/debug/repl.py,sha256=jnwrqRac4krc1pES7Ni-j1Dyw3mJIcWl5LQQIX1GkRw,20753
@@ -55,13 +56,13 @@ execsql/exporters/delimited.py,sha256=N0cP_CNlb_LLCUqbHmTQsOGufWnD_ddGdeFnXPE8Er
55
56
  execsql/exporters/duckdb.py,sha256=IQp7ayIJHB2lf6mBSxl6k5R2AeUEvgD75Q5Y55mH8jI,3214
56
57
  execsql/exporters/feather.py,sha256=tah48aep0cQRikZcmADXUPTzFddgDUT78Ndw_l0Hsj4,4178
57
58
  execsql/exporters/html.py,sha256=hwf9UHs7EZ7xiP3p9ilBIsAukXcDu-KO2dcQHVh2z30,10111
58
- execsql/exporters/json.py,sha256=wWGT6zjvIdWlO5FmXbaEw1xOhy23f1YY6bm5uQD_NS0,4393
59
+ execsql/exporters/json.py,sha256=kCR7HH_tC9xka5UDXLX4jgomXNEhhSHdRCIsYfEKiS4,5814
59
60
  execsql/exporters/latex.py,sha256=bdUN3GwfENUZHgpw6bvjsjfivDgzyf-HRkYTP-eH-pM,5480
60
61
  execsql/exporters/markdown.py,sha256=FuMGFxm9uZUOrdEuXT2ZZLCqmVDly7SMrFlrjP6kVEI,4827
61
62
  execsql/exporters/ods.py,sha256=hMKhUwLTKVf28JY1x7pdCGBl-WAE75oO9Oa7oUITS60,24916
62
63
  execsql/exporters/parquet.py,sha256=mbCo2Wlit1lkXmyo5EyLd_ATIHSICS49bouiBT2kdAs,1121
63
64
  execsql/exporters/pretty.py,sha256=SiJHBXIjoFsOHegXcdrtxYa6xSIPga1U0DQEy7pK4QY,4145
64
- execsql/exporters/protocol.py,sha256=BxATgz0xKHbB2FpZBeNg7wZfIiCohhD1awlr3JCec0c,4372
65
+ execsql/exporters/protocol.py,sha256=7PBFmvMlt2M33Aoo7pwze83SuasquozR1KS0vWQffYM,4403
65
66
  execsql/exporters/raw.py,sha256=9o3BMt7UYmnjbP8OEzmM2kNKfwwERi2yDcuNfUoEfuQ,2530
66
67
  execsql/exporters/sqlite.py,sha256=QjP_W90S-hecNqp2A-Vy58DD8MZitME1sTq4lqIS43w,3401
67
68
  execsql/exporters/templates.py,sha256=N9PRFet0mYuDQFu7PNbq5c6qGu2You2qfHpqnxg17NA,7065
@@ -77,8 +78,8 @@ execsql/gui/console.py,sha256=dzdSl4NJNvRd9VvlwRCXHvfnXjQPimyTx7NcibcUZC8,18305
77
78
  execsql/gui/desktop.py,sha256=y-T317NzSs9kcA4-NZW-Dx2cZ4Bv1mPoCr7hwQCJYoo,59467
78
79
  execsql/gui/tui.py,sha256=VoUtjSMopYtbtc8_-qV7YQAFvc-bS7zrZ5J2hl1cPxM,66853
79
80
  execsql/importers/__init__.py,sha256=zZwdQxMaValCNqUrVdvaA7XPU3J8NmqVJ4uolNWY2iU,299
80
- execsql/importers/base.py,sha256=_RkCCP8cgqXfNioFVhuo8DAmrbZ-YBdW3rdpKntv-BY,5608
81
- execsql/importers/csv.py,sha256=IANjZhAhKUXJo_W1RAR-6smVCLSO7_CVHAHdzhtzAck,5005
81
+ execsql/importers/base.py,sha256=4a4ypTr5qW38ByYbRW6cklveQ4Y7wMIYRa4-lFa_uZo,5635
82
+ execsql/importers/csv.py,sha256=RLEs0QLqaREGBQxo2ovYS6cRqLbTKi4rPBdkANDt56I,5032
82
83
  execsql/importers/feather.py,sha256=g2B69d2uv9vmnXcmjFyTVsMP40LYEzFYkhk3gD26mGw,1900
83
84
  execsql/importers/json.py,sha256=fL47h09Zzx4Qw3TTQRQJbdPdD3qVMiU-kK9C7j1IQzg,5534
84
85
  execsql/importers/ods.py,sha256=scSId1sQpvWxtyPsp6QWTmuXn-h34gbbC4FM2fmhvFA,2980
@@ -96,7 +97,7 @@ execsql/lsp/hover.py,sha256=JemZqJ8d7FHouMhW92jkfek14fL9rl6JBQU7JIXJHos,5038
96
97
  execsql/lsp/navigation.py,sha256=_f-zW82P4Wkelbsdedz_5l_ztFt-CFD8VSiVE5JaSAM,6436
97
98
  execsql/lsp/rename.py,sha256=fYk1gDSWWhqyoIGZO5hVIHPYYdGKNjgZS0mijFDFpac,3756
98
99
  execsql/lsp/server.py,sha256=gTjk1agZbygdbcowa3jkL45WJ37sxhJ58oiLB7BM6Bw,19893
99
- execsql/metacommands/__init__.py,sha256=MO5cF84jHakrgYDR8C4-YzeMTLU6oCDeB7x-kUpoVws,12970
100
+ execsql/metacommands/__init__.py,sha256=aMf-3IRjMpNFSUI-IMAcvL-kXZHq5qF6W_p3UawdQwM,13115
100
101
  execsql/metacommands/conditions.py,sha256=4DsMXr6vnfbtcKqgnxYV4UK4NmAfE_hpfgkFjlH0bjo,29844
101
102
  execsql/metacommands/connect.py,sha256=SPk4h9yiUgTD3w1gNOT_oYx8VShggsO357by5Hoy6OI,15344
102
103
  execsql/metacommands/control.py,sha256=9swW6X8jXDUpu5cHiAnQVx1ahFyWxlx7mjg4dqYfX5Y,9355
@@ -105,22 +106,22 @@ execsql/metacommands/debug.py,sha256=mKRT7uLwnw34Xl46fNDI_zH4MsPpZyihoLlonapbXuU
105
106
  execsql/metacommands/dispatch.py,sha256=so0ebcnv66NhVDtA1Bt4GelD0hsC5w7gy7rItz2Mdos,87430
106
107
  execsql/metacommands/effects.py,sha256=cRSo48R4nRJzkRR3OGHLhJzofcdAaNIobaxmn1N9TGw,3813
107
108
  execsql/metacommands/io.py,sha256=vlGBje5sgnqeilooMdhJDgSRIhysHy5_7LrKtik9Xjs,3011
108
- execsql/metacommands/io_export.py,sha256=YamxQwURRjZRpCG2z8W3QurncCmEmAyLFqDkxtHo5dY,14434
109
- execsql/metacommands/io_fileops.py,sha256=CmdgfgTHFWlzsA5XzsHeu6B9OTncb0K8n9uEgS68dkQ,13306
109
+ execsql/metacommands/io_export.py,sha256=_1o5T3Ejqq_0N1hYwaRPgDktn8nZT8sQLMyXPRgXEec,14587
110
+ execsql/metacommands/io_fileops.py,sha256=ru7zYgNV7rg0WtaDwhdYjy5iJTcecvjzR1PCdMNGG0A,13364
110
111
  execsql/metacommands/io_import.py,sha256=gJICeK1MOfG6D73I7m8zBCalvr_ZJzD-QBKOpKYeYXo,15684
111
112
  execsql/metacommands/io_write.py,sha256=yXDkMbQ3haDARVATVUYZprakyJHSyk1NywdKkFLpQ14,12783
112
113
  execsql/metacommands/prompt.py,sha256=PlXhtBYCembG_1SSCrwpQR10mGF3_jXodu4aCSHlSCM,37950
113
- execsql/metacommands/reference.py,sha256=yiv_qNp-MPI2UNBOzfwT11JU-xvWM9lw8yBZjP_VlsY,53953
114
+ execsql/metacommands/reference.py,sha256=qBsOfcu4pi3XQCL-ywusfbrTtpqwqrWUlVraj8tj5Co,54325
114
115
  execsql/metacommands/script_ext.py,sha256=CsUbyq8QFPnnvfX_k7fPlYNvvRw1xLmAWER4Exo5pwc,3658
115
116
  execsql/metacommands/system.py,sha256=6X2qmMpGUHDzyEluEotXt0Ks9jKQTpc2x0eoYNgN12M,7997
116
- execsql/metacommands/upsert.py,sha256=Dl7NfaAH8ziGBqcLCgcat3Hcw9vSewK1eo7-nzToI4g,20725
117
+ execsql/metacommands/upsert.py,sha256=hcYGonmh2sdijbqwNynn1-iajMnLp7x5vbRbtfjaf8c,21461
117
118
  execsql/script/__init__.py,sha256=I7hVO6wm3iOocxRRGEcFHhjdtL1moKbUYLB-wKbjPdk,3267
118
119
  execsql/script/ast.py,sha256=WqCpFxhxrT2IIGvbArTp3QS0BZryRZAb7IL4ri3I6xg,20591
119
120
  execsql/script/control.py,sha256=WqLy-HLPqHG3vEzYpKMiIJsD7LpORjyQuUWzFzcGz4w,2327
120
- execsql/script/engine.py,sha256=wjXKwPIis-T2Y8rLnRXQ_ymUFdXff1MlI2IIYmxihKo,18448
121
- execsql/script/executor.py,sha256=p3Aj7Ab8HwZRnmYds_e6h6yhRzCCa9XXbdVDkpg6NaQ,40855
121
+ execsql/script/engine.py,sha256=h1pUSXGqwJASLJizqzd9LBMn_U5Vuqc1UbaZk1xUinw,18468
122
+ execsql/script/executor.py,sha256=p6VWZeTMRPD_621I-CF4K2aaFaAbrY5I82hksrACFJQ,40999
122
123
  execsql/script/parser.py,sha256=z73v6t6ZbPUKkRu-UOIL6q1cCRDeoRvG2aXXC8NpMvE,37162
123
- execsql/script/variables.py,sha256=Tb01aHqOdWZbkgj-oW0ky3KWNy5N34XLD-Fm2eShYZM,17462
124
+ execsql/script/variables.py,sha256=XHRnViFp0zPy-skGpYbpckkYAcczqpw1lqTVLR9eZvo,17735
124
125
  execsql/utils/__init__.py,sha256=0uR6JwVJQRX3vceByNBduCAf5dd5assKjeqJUWvpZoA,278
125
126
  execsql/utils/auth.py,sha256=0LYKdNMhodq9R0B8WyKp69a-xlP9wOR6Ua9Lgo2aJnQ,14868
126
127
  execsql/utils/color.py,sha256=goQqhnLg8yERpFolQikA9zYSVcuAm0unKTJiWTbQm-U,634
@@ -135,22 +136,22 @@ execsql/utils/paths.py,sha256=7IIHT6vqXTfs6Ltfrzm_I2ZV4pU8IuedOLkHo6a1_K8,1175
135
136
  execsql/utils/regex.py,sha256=DGzPBl0Kt4--RbwPhoZI6Bm6N_CBUvI0DbqE4EF6qp8,3718
136
137
  execsql/utils/strings.py,sha256=UQNjpRCEFa1UO6feU-M-9e24wWAvizs_iu_4fFusLxo,8516
137
138
  execsql/utils/timer.py,sha256=eDYf5VzCNFk7oo90InJucUm3XcBdhYMogjZMqeg9xzc,1899
138
- execsql2-2.32.0.data/data/execsql2_extras/README.md,sha256=6f5bWiBlKRn8s6GVrU423-HFrU1Jwbk2AabxraLu3AY,5398
139
- execsql2-2.32.0.data/data/execsql2_extras/config_settings.sqlite,sha256=aY5cxR7Q7J6zJ4bC9lu5mHUrhy211Cq3MNKPQVCt02E,20480
140
- execsql2-2.32.0.data/data/execsql2_extras/example_config_prompt.sql,sha256=2e8KzzVWhho8KxYVHETSVmZdhW7wodioDsqBLSL6m4s,7487
141
- execsql2-2.32.0.data/data/execsql2_extras/make_config_db.sql,sha256=WwyC6dK-Eh5CAVppiBCDHqiI1_wEI9U95Ytpr4lsZkg,8726
142
- execsql2-2.32.0.data/data/execsql2_extras/md_compare.sql,sha256=ARYEcOlxWLxfw-KCKDeJRdCqnuCb9PVwGaHtITR2sYU,26535
143
- execsql2-2.32.0.data/data/execsql2_extras/md_glossary.sql,sha256=gu2PDTm295YvxR7cDp25Mf-xXHly0BvlMudoNOrep5Q,10931
144
- execsql2-2.32.0.data/data/execsql2_extras/md_upsert.sql,sha256=YNUTFS6yB4bZXs-wRDn6dN8jL5wIumzZyE2uo5arIos,113407
145
- execsql2-2.32.0.data/data/execsql2_extras/pg_compare.sql,sha256=WcupGxQoWtMwHVPuy42xCpP4gAV4Ttb1W-_hwh-3xb4,24529
146
- execsql2-2.32.0.data/data/execsql2_extras/pg_glossary.sql,sha256=CfQz2HZj2nMvJfYfQrAmNYdx0uRrOIWsCXwVo5gKv_k,9953
147
- execsql2-2.32.0.data/data/execsql2_extras/script_template.sql,sha256=s5i0HULxp49N2Cpd24iALleM_dBq5oLuN3Arx_4Z7-o,11140
148
- execsql2-2.32.0.data/data/execsql2_extras/ss_compare.sql,sha256=PpU-vPyqqC1faBoo5zwVZlZHYKkT79QZisWVlk4o-ss,26415
149
- execsql2-2.32.0.data/data/execsql2_extras/ss_glossary.sql,sha256=_oTiZHpadPXWrNs8VoRe9PZgX9XdK490NMVWVTVtjl4,13110
150
- execsql2-2.32.0.data/data/execsql2_extras/ss_upsert.sql,sha256=Dvst877h4KHn8ayYF9EXI_OxqLQ89wyfcnGYYT4szro,118304
151
- execsql2-2.32.0.dist-info/METADATA,sha256=q3H3q9BleScKMNArYi3SplieuzY-X6flVjmfU5KHib8,26927
152
- execsql2-2.32.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
153
- execsql2-2.32.0.dist-info/entry_points.txt,sha256=jOf53-4IVGjEZeNYUOF1GGaiPNfVZcNKxP3vDFKEyMA,58
154
- execsql2-2.32.0.dist-info/licenses/LICENSE.txt,sha256=LBdhuxejF8_bLCHZ2kWfmDXpDGUu914Gbd6_3JjCRe0,676
155
- execsql2-2.32.0.dist-info/licenses/NOTICE,sha256=McYzgxYav3U1OaVsY4Su1sfBrfmplpRdA9b6-gCDQCg,342
156
- execsql2-2.32.0.dist-info/RECORD,,
139
+ execsql2-2.34.0.data/data/execsql2_extras/README.md,sha256=6f5bWiBlKRn8s6GVrU423-HFrU1Jwbk2AabxraLu3AY,5398
140
+ execsql2-2.34.0.data/data/execsql2_extras/config_settings.sqlite,sha256=aY5cxR7Q7J6zJ4bC9lu5mHUrhy211Cq3MNKPQVCt02E,20480
141
+ execsql2-2.34.0.data/data/execsql2_extras/example_config_prompt.sql,sha256=2e8KzzVWhho8KxYVHETSVmZdhW7wodioDsqBLSL6m4s,7487
142
+ execsql2-2.34.0.data/data/execsql2_extras/make_config_db.sql,sha256=WwyC6dK-Eh5CAVppiBCDHqiI1_wEI9U95Ytpr4lsZkg,8726
143
+ execsql2-2.34.0.data/data/execsql2_extras/md_compare.sql,sha256=ARYEcOlxWLxfw-KCKDeJRdCqnuCb9PVwGaHtITR2sYU,26535
144
+ execsql2-2.34.0.data/data/execsql2_extras/md_glossary.sql,sha256=gu2PDTm295YvxR7cDp25Mf-xXHly0BvlMudoNOrep5Q,10931
145
+ execsql2-2.34.0.data/data/execsql2_extras/md_upsert.sql,sha256=YNUTFS6yB4bZXs-wRDn6dN8jL5wIumzZyE2uo5arIos,113407
146
+ execsql2-2.34.0.data/data/execsql2_extras/pg_compare.sql,sha256=WcupGxQoWtMwHVPuy42xCpP4gAV4Ttb1W-_hwh-3xb4,24529
147
+ execsql2-2.34.0.data/data/execsql2_extras/pg_glossary.sql,sha256=CfQz2HZj2nMvJfYfQrAmNYdx0uRrOIWsCXwVo5gKv_k,9953
148
+ execsql2-2.34.0.data/data/execsql2_extras/script_template.sql,sha256=s5i0HULxp49N2Cpd24iALleM_dBq5oLuN3Arx_4Z7-o,11140
149
+ execsql2-2.34.0.data/data/execsql2_extras/ss_compare.sql,sha256=PpU-vPyqqC1faBoo5zwVZlZHYKkT79QZisWVlk4o-ss,26415
150
+ execsql2-2.34.0.data/data/execsql2_extras/ss_glossary.sql,sha256=_oTiZHpadPXWrNs8VoRe9PZgX9XdK490NMVWVTVtjl4,13110
151
+ execsql2-2.34.0.data/data/execsql2_extras/ss_upsert.sql,sha256=Dvst877h4KHn8ayYF9EXI_OxqLQ89wyfcnGYYT4szro,118304
152
+ execsql2-2.34.0.dist-info/METADATA,sha256=-If95mpt8uHmRfovA0qRritNaIxwEIW6v3d71fcCAHE,26939
153
+ execsql2-2.34.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
154
+ execsql2-2.34.0.dist-info/entry_points.txt,sha256=jOf53-4IVGjEZeNYUOF1GGaiPNfVZcNKxP3vDFKEyMA,58
155
+ execsql2-2.34.0.dist-info/licenses/LICENSE.txt,sha256=LBdhuxejF8_bLCHZ2kWfmDXpDGUu914Gbd6_3JjCRe0,676
156
+ execsql2-2.34.0.dist-info/licenses/NOTICE,sha256=McYzgxYav3U1OaVsY4Su1sfBrfmplpRdA9b6-gCDQCg,342
157
+ execsql2-2.34.0.dist-info/RECORD,,