execsql2 2.12.3__py3-none-any.whl → 2.12.6__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 (24) hide show
  1. execsql/metacommands/__init__.py +9 -0
  2. execsql/metacommands/dispatch.py +30 -0
  3. execsql/metacommands/upsert.py +473 -0
  4. {execsql2-2.12.3.dist-info → execsql2-2.12.6.dist-info}/METADATA +4 -1
  5. {execsql2-2.12.3.dist-info → execsql2-2.12.6.dist-info}/RECORD +24 -23
  6. {execsql2-2.12.3.data → execsql2-2.12.6.data}/data/execsql2_extras/README.md +0 -0
  7. {execsql2-2.12.3.data → execsql2-2.12.6.data}/data/execsql2_extras/config_settings.sqlite +0 -0
  8. {execsql2-2.12.3.data → execsql2-2.12.6.data}/data/execsql2_extras/example_config_prompt.sql +0 -0
  9. {execsql2-2.12.3.data → execsql2-2.12.6.data}/data/execsql2_extras/execsql.conf +0 -0
  10. {execsql2-2.12.3.data → execsql2-2.12.6.data}/data/execsql2_extras/make_config_db.sql +0 -0
  11. {execsql2-2.12.3.data → execsql2-2.12.6.data}/data/execsql2_extras/md_compare.sql +0 -0
  12. {execsql2-2.12.3.data → execsql2-2.12.6.data}/data/execsql2_extras/md_glossary.sql +0 -0
  13. {execsql2-2.12.3.data → execsql2-2.12.6.data}/data/execsql2_extras/md_upsert.sql +0 -0
  14. {execsql2-2.12.3.data → execsql2-2.12.6.data}/data/execsql2_extras/pg_compare.sql +0 -0
  15. {execsql2-2.12.3.data → execsql2-2.12.6.data}/data/execsql2_extras/pg_glossary.sql +0 -0
  16. {execsql2-2.12.3.data → execsql2-2.12.6.data}/data/execsql2_extras/pg_upsert.sql +0 -0
  17. {execsql2-2.12.3.data → execsql2-2.12.6.data}/data/execsql2_extras/script_template.sql +0 -0
  18. {execsql2-2.12.3.data → execsql2-2.12.6.data}/data/execsql2_extras/ss_compare.sql +0 -0
  19. {execsql2-2.12.3.data → execsql2-2.12.6.data}/data/execsql2_extras/ss_glossary.sql +0 -0
  20. {execsql2-2.12.3.data → execsql2-2.12.6.data}/data/execsql2_extras/ss_upsert.sql +0 -0
  21. {execsql2-2.12.3.dist-info → execsql2-2.12.6.dist-info}/WHEEL +0 -0
  22. {execsql2-2.12.3.dist-info → execsql2-2.12.6.dist-info}/entry_points.txt +0 -0
  23. {execsql2-2.12.3.dist-info → execsql2-2.12.6.dist-info}/licenses/LICENSE.txt +0 -0
  24. {execsql2-2.12.3.dist-info → execsql2-2.12.6.dist-info}/licenses/NOTICE +0 -0
@@ -168,6 +168,11 @@ from execsql.metacommands.script_ext import (
168
168
  x_extendscript_sql,
169
169
  x_executescript,
170
170
  )
171
+ from execsql.metacommands.upsert import (
172
+ x_pg_upsert,
173
+ x_pg_upsert_check,
174
+ x_pg_upsert_qa,
175
+ )
171
176
  from execsql.metacommands.system import (
172
177
  x_system_cmd,
173
178
  x_email,
@@ -401,6 +406,10 @@ __all__ = [
401
406
  "x_write_warnings",
402
407
  "x_gui_level",
403
408
  "x_execute",
409
+ # upsert handlers
410
+ "x_pg_upsert",
411
+ "x_pg_upsert_check",
412
+ "x_pg_upsert_qa",
404
413
  # regex helpers
405
414
  "ins_rxs",
406
415
  "ins_quoted_rx",
@@ -168,6 +168,11 @@ from execsql.metacommands.script_ext import (
168
168
  x_extendscript_metacommand,
169
169
  x_extendscript_sql,
170
170
  )
171
+ from execsql.metacommands.upsert import (
172
+ x_pg_upsert,
173
+ x_pg_upsert_check,
174
+ x_pg_upsert_qa,
175
+ )
171
176
  from execsql.metacommands.system import (
172
177
  x_cancel_halt,
173
178
  x_cancel_halt_email,
@@ -2142,6 +2147,31 @@ def build_dispatch_table() -> MetaCommandList:
2142
2147
  x_write,
2143
2148
  )
2144
2149
 
2150
+ # ------------------------------------------------------------------
2151
+ # PG_UPSERT — pg-upsert integration (optional dependency)
2152
+ # ------------------------------------------------------------------
2153
+ # Order matters: CHECK and QA patterns must precede the general pattern
2154
+ # so that "PG_UPSERT CHECK ..." and "PG_UPSERT QA ..." are matched
2155
+ # before "PG_UPSERT FROM ...".
2156
+ mcl.add(
2157
+ r"^\s*PG_UPSERT\s+CHECK\s+FROM\s+(?P<staging_schema>\S+)\s+TO\s+(?P<base_schema>\S+)\s+TABLES\s+(?P<tail>.+)$",
2158
+ x_pg_upsert_check,
2159
+ description="PG_UPSERT CHECK",
2160
+ category="action",
2161
+ )
2162
+ mcl.add(
2163
+ r"^\s*PG_UPSERT\s+QA\s+FROM\s+(?P<staging_schema>\S+)\s+TO\s+(?P<base_schema>\S+)\s+TABLES\s+(?P<tail>.+)$",
2164
+ x_pg_upsert_qa,
2165
+ description="PG_UPSERT QA",
2166
+ category="action",
2167
+ )
2168
+ mcl.add(
2169
+ r"^\s*PG_UPSERT\s+FROM\s+(?P<staging_schema>\S+)\s+TO\s+(?P<base_schema>\S+)\s+TABLES\s+(?P<tail>.+)$",
2170
+ x_pg_upsert,
2171
+ description="PG_UPSERT",
2172
+ category="action",
2173
+ )
2174
+
2145
2175
  # ------------------------------------------------------------------
2146
2176
  # SUB (top-level variable assignment — kept near end so more specific
2147
2177
  # SUB_* patterns above take precedence)
@@ -0,0 +1,473 @@
1
+ """PG_UPSERT metacommand handler.
2
+
3
+ Integrates pg-upsert (https://pg-upsert.readthedocs.io/) as an optional
4
+ dependency, providing QA-checked, FK-dependency-ordered upserts from a
5
+ staging schema to a base schema on PostgreSQL.
6
+
7
+ Requires: ``pip install execsql2[upsert]``
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import json
13
+ import logging
14
+ import re
15
+ from typing import Any
16
+
17
+ import execsql.state as _state
18
+ from execsql.exceptions import ErrInfo
19
+ from execsql.types import dbt_postgres
20
+ from execsql.utils.errors import exception_desc
21
+
22
+
23
+ # ---------------------------------------------------------------------------
24
+ # Keyword parser for the trailing portion after TABLES
25
+ # ---------------------------------------------------------------------------
26
+
27
+ _KW_METHOD = re.compile(r"\bMETHOD\s+(upsert|update|insert)\b", re.IGNORECASE)
28
+ _KW_EXCLUDE = re.compile(
29
+ r"\bEXCLUDE\s+([\w\s,]+?)(?=\s+(?:METHOD|COMMIT|INTERACTIVE|COMPACT|EXCLUDE_NULL|LOGFILE|CLEANUP)\b|\s*$)",
30
+ re.IGNORECASE,
31
+ )
32
+ _KW_EXCLUDE_NULL = re.compile(
33
+ r"\bEXCLUDE_NULL\s+([\w\s,]+?)(?=\s+(?:METHOD|COMMIT|INTERACTIVE|COMPACT|EXCLUDE|LOGFILE|CLEANUP)\b|\s*$)",
34
+ re.IGNORECASE,
35
+ )
36
+ _KW_COMMIT = re.compile(r"\bCOMMIT\b", re.IGNORECASE)
37
+ _KW_INTERACTIVE = re.compile(r"\bINTERACTIVE\b", re.IGNORECASE)
38
+ _KW_COMPACT = re.compile(r"\bCOMPACT\b", re.IGNORECASE)
39
+ _KW_CLEANUP = re.compile(r"\bCLEANUP\b", re.IGNORECASE)
40
+ _KW_LOGFILE = re.compile(r"""\bLOGFILE\s+(?:"([^"]+)"|'([^']+)'|(\S+))""", re.IGNORECASE)
41
+
42
+ # All recognized keywords — used to split table names from options.
43
+ _ALL_KEYWORDS = re.compile(
44
+ r"\b(?:METHOD|COMMIT|INTERACTIVE|COMPACT|EXCLUDE_NULL|EXCLUDE|LOGFILE|CLEANUP)\b",
45
+ re.IGNORECASE,
46
+ )
47
+
48
+
49
+ def _parse_tables_and_options(tail: str) -> dict[str, Any]:
50
+ """Parse the trailing text after ``TABLES`` into table names and options.
51
+
52
+ Parameters
53
+ ----------
54
+ tail:
55
+ Everything captured after the ``TABLES`` keyword in the regex.
56
+
57
+ Returns
58
+ -------
59
+ dict with keys: tables, method, commit, interactive, compact,
60
+ exclude_cols, exclude_null_check_cols.
61
+ """
62
+ # Split at the first keyword to isolate the table list.
63
+ kw_match = _ALL_KEYWORDS.search(tail)
64
+ if kw_match:
65
+ table_part = tail[: kw_match.start()]
66
+ opts_part = tail[kw_match.start() :]
67
+ else:
68
+ table_part = tail
69
+ opts_part = ""
70
+
71
+ tables = [t.strip() for t in table_part.split(",") if t.strip()]
72
+
73
+ method = "upsert"
74
+ m = _KW_METHOD.search(opts_part)
75
+ if m:
76
+ method = m.group(1).lower()
77
+
78
+ exclude_cols: list[str] = []
79
+ m = _KW_EXCLUDE.search(opts_part)
80
+ if m:
81
+ exclude_cols = [c.strip() for c in m.group(1).split(",") if c.strip()]
82
+
83
+ exclude_null: list[str] = []
84
+ m = _KW_EXCLUDE_NULL.search(opts_part)
85
+ if m:
86
+ exclude_null = [c.strip() for c in m.group(1).split(",") if c.strip()]
87
+
88
+ logfile: str | None = None
89
+ m = _KW_LOGFILE.search(opts_part)
90
+ if m:
91
+ logfile = m.group(1) or m.group(2) or m.group(3)
92
+
93
+ return {
94
+ "tables": tables,
95
+ "method": method,
96
+ "commit": bool(_KW_COMMIT.search(opts_part)),
97
+ "interactive": bool(_KW_INTERACTIVE.search(opts_part)),
98
+ "compact": bool(_KW_COMPACT.search(opts_part)),
99
+ "exclude_cols": exclude_cols,
100
+ "exclude_null_check_cols": exclude_null,
101
+ "logfile": logfile,
102
+ "cleanup": bool(_KW_CLEANUP.search(opts_part)),
103
+ }
104
+
105
+
106
+ # ---------------------------------------------------------------------------
107
+ # Logging bridge: pg_upsert.display → execsql exec_log
108
+ # ---------------------------------------------------------------------------
109
+
110
+
111
+ class _ExecLogHandler(logging.Handler):
112
+ """Route pg_upsert's plain-text file logger to execsql's exec_log."""
113
+
114
+ def __init__(self, exec_log: Any) -> None:
115
+ super().__init__()
116
+ self._exec_log = exec_log
117
+
118
+ def emit(self, record: logging.LogRecord) -> None:
119
+ self._exec_log.log_user_msg(self.format(record))
120
+
121
+
122
+ class _FileWriterHandler(logging.Handler):
123
+ """Route pg_upsert log messages through execsql's async FileWriter.
124
+
125
+ This ensures that pg-upsert log output and execsql WRITE TEE output
126
+ arrive in the same order they were issued, since both go through the
127
+ same FileWriter queue.
128
+ """
129
+
130
+ def __init__(self, filename: str) -> None:
131
+ super().__init__()
132
+ self._filename = filename
133
+
134
+ def emit(self, record: logging.LogRecord) -> None:
135
+ from execsql.utils.fileio import filewriter_write
136
+
137
+ filewriter_write(self._filename, self.format(record) + "\n")
138
+
139
+
140
+ # ---------------------------------------------------------------------------
141
+ # Result → substitution variables
142
+ # ---------------------------------------------------------------------------
143
+
144
+
145
+ def _set_subvars(result: Any) -> None:
146
+ """Populate ``$PG_UPSERT_*`` substitution variables from an UpsertResult."""
147
+ sv = _state.subvars.add_substitution
148
+ sv("$PG_UPSERT_QA_PASSED", str(result.qa_passed).upper())
149
+ sv("$PG_UPSERT_ROWS_UPDATED", str(result.total_updated))
150
+ sv("$PG_UPSERT_ROWS_INSERTED", str(result.total_inserted))
151
+ sv("$PG_UPSERT_COMMITTED", str(result.committed).upper())
152
+ sv("$PG_UPSERT_STAGING_SCHEMA", result.staging_schema)
153
+ sv("$PG_UPSERT_BASE_SCHEMA", result.base_schema)
154
+ sv("$PG_UPSERT_TABLES", ", ".join(t.table_name for t in result.tables))
155
+ sv("$PG_UPSERT_METHOD", result.upsert_method)
156
+ sv("$PG_UPSERT_DURATION", str(result.duration_seconds))
157
+ sv("$PG_UPSERT_STARTED_AT", result.started_at)
158
+ sv("$PG_UPSERT_FINISHED_AT", result.finished_at)
159
+ sv("$PG_UPSERT_RESULT_JSON", json.dumps(result.to_dict(), separators=(",", ":")))
160
+
161
+
162
+ def _qa_failure_msg(result: Any) -> str:
163
+ """Build a concise QA failure message listing which tables failed."""
164
+ failed = [t.table_name for t in result.tables if not t.qa_passed]
165
+ if failed:
166
+ return f"PG_UPSERT QA failed for: {', '.join(failed)}"
167
+ return "PG_UPSERT QA checks failed."
168
+
169
+
170
+ # ---------------------------------------------------------------------------
171
+ # Import guard + helpers
172
+ # ---------------------------------------------------------------------------
173
+
174
+
175
+ def _require_pg_upsert() -> None:
176
+ """Raise ErrInfo if pg_upsert is not installed."""
177
+ try:
178
+ import pg_upsert # noqa: F401
179
+ except ImportError as exc:
180
+ raise ErrInfo(
181
+ "exception",
182
+ other_msg=("PG_UPSERT requires the pg-upsert package. Install it with: pip install execsql2[upsert]"),
183
+ ) from exc
184
+
185
+
186
+ def _require_postgres(db: Any, metacommandline: str | None) -> None:
187
+ """Raise ErrInfo if the current connection is not PostgreSQL."""
188
+ if db.type != dbt_postgres:
189
+ raise ErrInfo(
190
+ "cmd",
191
+ command_text=metacommandline,
192
+ other_msg=(f"PG_UPSERT requires a PostgreSQL connection. Current DBMS: {db.type.dbms_id}"),
193
+ )
194
+
195
+
196
+ def _build_result_from_qa_errors(ups: Any) -> Any:
197
+ """Build an UpsertResult from ``ups.qa_errors`` after a QA/CHECK run."""
198
+ from pg_upsert.models import TableResult, UpsertResult
199
+
200
+ table_results: dict[str, Any] = {}
201
+ for table_name in ups.tables:
202
+ table_results[table_name] = TableResult(table_name=table_name)
203
+ for err in ups.qa_errors:
204
+ if err.table in table_results:
205
+ table_results[err.table].qa_errors.append(err)
206
+ return UpsertResult(
207
+ tables=list(table_results.values()),
208
+ committed=False,
209
+ staging_schema=ups.staging_schema,
210
+ base_schema=ups.base_schema,
211
+ upsert_method=ups.upsert_method,
212
+ )
213
+
214
+
215
+ def _make_callback() -> Any:
216
+ """Return a pg-upsert pipeline callback that sets per-table subvars."""
217
+ from pg_upsert import CallbackEvent
218
+
219
+ def _on_event(event: Any) -> None:
220
+ sv = _state.subvars.add_substitution
221
+ sv("$PG_UPSERT_CURRENT_TABLE", event.table)
222
+ if event.event == CallbackEvent.QA_TABLE_COMPLETE:
223
+ sv("$PG_UPSERT_TABLE_QA_PASSED", str(event.qa_passed).upper())
224
+ elif event.event == CallbackEvent.UPSERT_TABLE_COMPLETE:
225
+ sv("$PG_UPSERT_TABLE_ROWS_UPDATED", str(event.rows_updated))
226
+ sv("$PG_UPSERT_TABLE_ROWS_INSERTED", str(event.rows_inserted))
227
+
228
+ return _on_event
229
+
230
+
231
+ def _create_pgupsert(
232
+ db: Any,
233
+ staging_schema: str,
234
+ base_schema: str,
235
+ opts: dict[str, Any],
236
+ ) -> Any:
237
+ """Create and return a PgUpsert instance with execsql's connection."""
238
+ from pg_upsert import PgUpsert
239
+
240
+ ui_mode = "tkinter"
241
+ if _state.conf:
242
+ ui_mode = _state.conf.gui_framework
243
+
244
+ ups = PgUpsert(
245
+ conn=db.conn,
246
+ staging_schema=staging_schema,
247
+ base_schema=base_schema,
248
+ tables=opts["tables"],
249
+ do_commit=opts["commit"],
250
+ interactive=opts["interactive"],
251
+ compact=opts["compact"],
252
+ upsert_method=opts["method"],
253
+ exclude_cols=opts["exclude_cols"],
254
+ exclude_null_check_cols=opts["exclude_null_check_cols"],
255
+ ui_mode=ui_mode,
256
+ callback=_make_callback(),
257
+ )
258
+ return ups
259
+
260
+
261
+ def _attach_log_handlers(
262
+ logfile: str | None = None,
263
+ ) -> tuple[list[logging.Logger], list[logging.Handler], dict[str, int]]:
264
+ """Attach logging handlers to pg_upsert loggers.
265
+
266
+ Always attaches the exec_log bridge to ``pg_upsert.display``.
267
+ If *logfile* is given, also attaches a FileHandler (append mode) to both
268
+ ``pg_upsert`` and ``pg_upsert.display`` — matching pg-upsert CLI behavior.
269
+
270
+ Returns (loggers, handlers) so the caller can detach in a finally block.
271
+ """
272
+ display_logger = logging.getLogger("pg_upsert.display")
273
+ # pg-upsert's display logger has propagate=False and level=NOTSET, which
274
+ # gives it an effective level of WARNING (inherited from root). Its messages
275
+ # are logged at INFO, so we must explicitly lower the level.
276
+ prev_display_level = display_logger.level
277
+ if display_logger.getEffectiveLevel() > logging.INFO:
278
+ display_logger.setLevel(logging.INFO)
279
+
280
+ exec_handler = _ExecLogHandler(_state.exec_log)
281
+ display_logger.addHandler(exec_handler)
282
+
283
+ loggers: list[logging.Logger] = [display_logger]
284
+ handlers: list[logging.Handler] = [exec_handler]
285
+ prev_levels: dict[str, int] = {"pg_upsert.display": prev_display_level}
286
+
287
+ if logfile:
288
+ file_handler = _FileWriterHandler(logfile)
289
+ file_handler.setFormatter(logging.Formatter("%(message)s"))
290
+ # Attach to both loggers, same as pg-upsert CLI does
291
+ main_logger = logging.getLogger("pg_upsert")
292
+ prev_levels["pg_upsert"] = main_logger.level
293
+ if main_logger.getEffectiveLevel() > logging.INFO:
294
+ main_logger.setLevel(logging.INFO)
295
+ main_logger.addHandler(file_handler)
296
+ display_logger.addHandler(file_handler)
297
+ loggers.append(main_logger)
298
+ handlers.append(file_handler)
299
+
300
+ return loggers, handlers, prev_levels
301
+
302
+
303
+ def _detach_log_handlers(
304
+ loggers: list[logging.Logger],
305
+ handlers: list[logging.Handler],
306
+ prev_levels: dict[str, int],
307
+ ) -> None:
308
+ """Remove all handlers added by ``_attach_log_handlers``."""
309
+ for handler in handlers:
310
+ for lgr in loggers:
311
+ lgr.removeHandler(handler)
312
+ if hasattr(handler, "close"):
313
+ handler.close()
314
+ # Restore original logger levels
315
+ for name, level in prev_levels.items():
316
+ logging.getLogger(name).setLevel(level)
317
+
318
+
319
+ def _run_with_autocommit_guard(db: Any, fn: Any) -> Any:
320
+ """Temporarily disable autocommit, run *fn*, then restore."""
321
+ was_autocommit = db.autocommit
322
+ if was_autocommit:
323
+ db.autocommit_off()
324
+ try:
325
+ return fn()
326
+ finally:
327
+ if was_autocommit:
328
+ db.autocommit_on()
329
+
330
+
331
+ def _handle_pg_upsert_errors(fn: Any, metacommandline: str | None) -> Any:
332
+ """Run *fn*, translating pg-upsert exceptions to ErrInfo."""
333
+ from pg_upsert import UserCancelledError
334
+
335
+ try:
336
+ return fn()
337
+ except UserCancelledError as exc:
338
+ raise ErrInfo(
339
+ "cmd",
340
+ command_text=metacommandline,
341
+ other_msg="PG_UPSERT cancelled by user.",
342
+ ) from exc
343
+ except ErrInfo:
344
+ raise
345
+ except Exception as exc:
346
+ raise ErrInfo(
347
+ "exception",
348
+ exception_msg=exception_desc(),
349
+ other_msg="PG_UPSERT failed unexpectedly.",
350
+ ) from exc
351
+
352
+
353
+ # ---------------------------------------------------------------------------
354
+ # Metacommand handlers
355
+ # ---------------------------------------------------------------------------
356
+
357
+
358
+ def x_pg_upsert(**kwargs: Any) -> None:
359
+ """PG_UPSERT FROM <staging> TO <base> TABLES <t1>, <t2> [options]
360
+
361
+ Full pipeline: QA checks → upsert → optional commit.
362
+ """
363
+ _require_pg_upsert()
364
+ db = _state.dbs.current()
365
+ metacommandline = kwargs.get("metacommandline")
366
+ _require_postgres(db, metacommandline)
367
+
368
+ staging = kwargs["staging_schema"]
369
+ base = kwargs["base_schema"]
370
+ opts = _parse_tables_and_options(kwargs["tail"])
371
+
372
+ ups = _create_pgupsert(db, staging, base, opts)
373
+ loggers, handlers, prev_levels = _attach_log_handlers(opts.get("logfile"))
374
+
375
+ try:
376
+ result = _run_with_autocommit_guard(
377
+ db,
378
+ lambda: _handle_pg_upsert_errors(ups.run, metacommandline),
379
+ )
380
+ finally:
381
+ _detach_log_handlers(loggers, handlers, prev_levels)
382
+
383
+ _set_subvars(result)
384
+ if opts.get("cleanup"):
385
+ ups.cleanup()
386
+
387
+ if not result.qa_passed:
388
+ raise ErrInfo(
389
+ "cmd",
390
+ command_text=metacommandline,
391
+ other_msg=_qa_failure_msg(result),
392
+ )
393
+
394
+
395
+ def x_pg_upsert_qa(**kwargs: Any) -> None:
396
+ """PG_UPSERT QA FROM <staging> TO <base> TABLES <t1>, <t2> [options]
397
+
398
+ QA-only mode: run all QA checks without upserting.
399
+ """
400
+ _require_pg_upsert()
401
+ db = _state.dbs.current()
402
+ metacommandline = kwargs.get("metacommandline")
403
+ _require_postgres(db, metacommandline)
404
+
405
+ staging = kwargs["staging_schema"]
406
+ base = kwargs["base_schema"]
407
+ opts = _parse_tables_and_options(kwargs["tail"])
408
+ opts["commit"] = False # QA-only never commits
409
+
410
+ ups = _create_pgupsert(db, staging, base, opts)
411
+ loggers, handlers, prev_levels = _attach_log_handlers(opts.get("logfile"))
412
+
413
+ try:
414
+ _run_with_autocommit_guard(
415
+ db,
416
+ lambda: _handle_pg_upsert_errors(ups.qa_all, metacommandline),
417
+ )
418
+ finally:
419
+ _detach_log_handlers(loggers, handlers, prev_levels)
420
+
421
+ result = _build_result_from_qa_errors(ups)
422
+ _set_subvars(result)
423
+ if opts.get("cleanup"):
424
+ ups.cleanup()
425
+
426
+ if not result.qa_passed:
427
+ raise ErrInfo(
428
+ "cmd",
429
+ command_text=metacommandline,
430
+ other_msg=_qa_failure_msg(result),
431
+ )
432
+
433
+
434
+ def x_pg_upsert_check(**kwargs: Any) -> None:
435
+ """PG_UPSERT CHECK FROM <staging> TO <base> TABLES <t1>, <t2>
436
+
437
+ Schema check only: column existence + type mismatch.
438
+ """
439
+ _require_pg_upsert()
440
+ db = _state.dbs.current()
441
+ metacommandline = kwargs.get("metacommandline")
442
+ _require_postgres(db, metacommandline)
443
+
444
+ staging = kwargs["staging_schema"]
445
+ base = kwargs["base_schema"]
446
+ opts = _parse_tables_and_options(kwargs["tail"])
447
+ opts["commit"] = False
448
+
449
+ ups = _create_pgupsert(db, staging, base, opts)
450
+ loggers, handlers, prev_levels = _attach_log_handlers(opts.get("logfile"))
451
+
452
+ try:
453
+ _run_with_autocommit_guard(
454
+ db,
455
+ lambda: _handle_pg_upsert_errors(
456
+ lambda: ups.qa_column_existence().qa_type_mismatch(),
457
+ metacommandline,
458
+ ),
459
+ )
460
+ finally:
461
+ _detach_log_handlers(loggers, handlers, prev_levels)
462
+
463
+ result = _build_result_from_qa_errors(ups)
464
+ _set_subvars(result)
465
+ if opts.get("cleanup"):
466
+ ups.cleanup()
467
+
468
+ if not result.qa_passed:
469
+ raise ErrInfo(
470
+ "cmd",
471
+ command_text=metacommandline,
472
+ other_msg=_qa_failure_msg(result),
473
+ )
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: execsql2
3
- Version: 2.12.3
3
+ Version: 2.12.6
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
6
6
  Project-URL: Repository, https://github.com/geocoug/execsql
@@ -51,6 +51,7 @@ Requires-Dist: keyring; extra == 'all'
51
51
  Requires-Dist: odfpy; extra == 'all'
52
52
  Requires-Dist: openpyxl; extra == 'all'
53
53
  Requires-Dist: oracledb; extra == 'all'
54
+ Requires-Dist: pg-upsert>=1.18.0; extra == 'all'
54
55
  Requires-Dist: polars; extra == 'all'
55
56
  Requires-Dist: psycopg2-binary; extra == 'all'
56
57
  Requires-Dist: pymysql; extra == 'all'
@@ -107,6 +108,8 @@ Provides-Extra: oracle
107
108
  Requires-Dist: oracledb; extra == 'oracle'
108
109
  Provides-Extra: postgres
109
110
  Requires-Dist: psycopg2-binary; extra == 'postgres'
111
+ Provides-Extra: upsert
112
+ Requires-Dist: pg-upsert>=1.18.0; extra == 'upsert'
110
113
  Description-Content-Type: text/markdown
111
114
 
112
115
  > [!NOTE]
@@ -61,13 +61,13 @@ execsql/importers/csv.py,sha256=Mu848WNzuhVO1ade-WurPyxqGOuVNRO8UwRF3-bav_I,4845
61
61
  execsql/importers/feather.py,sha256=g2B69d2uv9vmnXcmjFyTVsMP40LYEzFYkhk3gD26mGw,1900
62
62
  execsql/importers/ods.py,sha256=MJsdsjropzCvxAA3DDZfAL_AnmZ4yij7DnrjGyDJqHQ,2843
63
63
  execsql/importers/xls.py,sha256=e0Zfe47ZiCpA1Ae3XDJ1ko3sCiH3-8U6XLKi6NvD0jQ,3683
64
- execsql/metacommands/__init__.py,sha256=EmYUZZq1oaubbSQ26-8F9jJI_JnOJ2R697NeossXF1Q,11202
64
+ execsql/metacommands/__init__.py,sha256=jrp5PvAZyQYvZ0a_qYNxjv1ZqxDlGYE_P3dyqRTgVSM,11394
65
65
  execsql/metacommands/conditions.py,sha256=Fzrk83-pWbFOoKahYdQW7CZjQeh3zByDUbfgpTM_bjQ,29259
66
66
  execsql/metacommands/connect.py,sha256=Nsm0D91i3RX-R2rzQQ-Br-gULaI6Uvdn9fqb7DOAVfE,14804
67
67
  execsql/metacommands/control.py,sha256=PAZFK1ck5SDSm5QdFV1ctif3KpEiyYWIXdDceRWgQ6k,8513
68
68
  execsql/metacommands/data.py,sha256=tRQBGTAuW-eJ2tBNWaoZI9OjTyNNyHJISo7gOdL-sm8,11370
69
69
  execsql/metacommands/debug.py,sha256=pnT24dfvfOx8xFu86mO5czfVCGKbcvgBLyXnqaMWO4w,8184
70
- execsql/metacommands/dispatch.py,sha256=OQwLOo9XT3N9C96wsRt0zmu1Nn4HL-7cSBOsGCfp5V4,84041
70
+ execsql/metacommands/dispatch.py,sha256=2Ec7dJmOFstbY3tEuFoTODV8yaHpR-Sfa-ZydOmvIxo,85205
71
71
  execsql/metacommands/io.py,sha256=Duh60caM4go9JczbGYNMKKYpcMimwPzF6EQ_tshKxdE,2971
72
72
  execsql/metacommands/io_export.py,sha256=7lkCSnPhXy9FVau9_hT1u68NOVdG2DsWmvUh9hM1QWI,18359
73
73
  execsql/metacommands/io_fileops.py,sha256=RrcJTh_cgj7bJ-bezjo0yNl-fN3CoWV-aZ71z1KHYZs,9803
@@ -76,6 +76,7 @@ execsql/metacommands/io_write.py,sha256=NpL2aYGfBpbqmPpYsqniYltYfd_SCA1EQz3_4qSd
76
76
  execsql/metacommands/prompt.py,sha256=xd3mAkdbn4AE4hUPuSfN5DgZGUZmk-Db23iL-JJPwXs,36918
77
77
  execsql/metacommands/script_ext.py,sha256=TUgAldB2LSJAwZrCvDDi804hQ1d9BDQD2GDqHNPVOcM,2280
78
78
  execsql/metacommands/system.py,sha256=sUR5kLL7idTVg8WXIMdd-Kv7nkERIiaeL0beWsz8NyY,7293
79
+ execsql/metacommands/upsert.py,sha256=P2935aQHDZPiVwnXi0fGQ7Guxrm-Sy_YunyuSqVSegI,15880
79
80
  execsql/script/__init__.py,sha256=HbVQmQEVn4gBtzwy5_nlbDGuRnbWd4dI4nG-q1KyBxs,3498
80
81
  execsql/script/control.py,sha256=s-1eZdGARM6H1FwZ6VDdO_f50j7bvvRtTHesfUm9tbc,6144
81
82
  execsql/script/engine.py,sha256=6LYabzy1LI-_ISjYzTJos0BrLO62QF6FEKdqcN0YzK4,42995
@@ -92,24 +93,24 @@ execsql/utils/numeric.py,sha256=xh02ANSRk3nUpQ-rtm66ILoMqoi7HtzCoRMIOT9U8QI,1570
92
93
  execsql/utils/regex.py,sha256=diEzTZqU_HHwVMadPAvN1Vgzhl7I03eVaEFGCXyGGL8,3770
93
94
  execsql/utils/strings.py,sha256=5Dvzrk-9SIw2lpxXZQkiJbNyo1sy7iXXAtSULlZ0KG8,8488
94
95
  execsql/utils/timer.py,sha256=eDYf5VzCNFk7oo90InJucUm3XcBdhYMogjZMqeg9xzc,1899
95
- execsql2-2.12.3.data/data/execsql2_extras/README.md,sha256=sxwVyU0ZahCfANv56LahkyuM505kFjrMhe-1SvWE69E,4845
96
- execsql2-2.12.3.data/data/execsql2_extras/config_settings.sqlite,sha256=aY5cxR7Q7J6zJ4bC9lu5mHUrhy211Cq3MNKPQVCt02E,20480
97
- execsql2-2.12.3.data/data/execsql2_extras/example_config_prompt.sql,sha256=SY3Jxn1qcVm4kPW9xmmTfknHfvURXmeEYTbRjYrjGSw,7487
98
- execsql2-2.12.3.data/data/execsql2_extras/execsql.conf,sha256=_45iJ-KWZnB8uMW_gEg067MM5pmGJ-dVl7VbAZMunAE,9530
99
- execsql2-2.12.3.data/data/execsql2_extras/make_config_db.sql,sha256=WwyC6dK-Eh5CAVppiBCDHqiI1_wEI9U95Ytpr4lsZkg,8726
100
- execsql2-2.12.3.data/data/execsql2_extras/md_compare.sql,sha256=B8Wd7LZ0vnMY2qvA139JIEBkPObgRH2i5xj6PejTQt8,24092
101
- execsql2-2.12.3.data/data/execsql2_extras/md_glossary.sql,sha256=DJRHcU5NbFpxTTX-IwH3yRlsboj1q6BBGrUAHKn4Cuo,10796
102
- execsql2-2.12.3.data/data/execsql2_extras/md_upsert.sql,sha256=v_7GbWh_N1mBTmw3gvTrkagOVp2q0KmXvM8hE-DlFxY,112524
103
- execsql2-2.12.3.data/data/execsql2_extras/pg_compare.sql,sha256=9dWa8hnfy5dVJI-z2iGpd9JzQmI4j2ziMlEdpnr66ro,24352
104
- execsql2-2.12.3.data/data/execsql2_extras/pg_glossary.sql,sha256=pKjIIDsROAgJq2H-1qNEcRMAWManivcZ_AEVHzUUlic,9908
105
- execsql2-2.12.3.data/data/execsql2_extras/pg_upsert.sql,sha256=k7AFiGTLBy3nf-qO5QIaZrEYTAKvdxxU3JDLx9jqkzs,108315
106
- execsql2-2.12.3.data/data/execsql2_extras/script_template.sql,sha256=1Estacb_vm1FgK41k_G9nuduP1yiA-fQ1Kn4Z4mv5Ao,11153
107
- execsql2-2.12.3.data/data/execsql2_extras/ss_compare.sql,sha256=TsVxWm3cEpR5-EiMYXNhtaY0arSNeKZhsJdHdLA7xeI,24833
108
- execsql2-2.12.3.data/data/execsql2_extras/ss_glossary.sql,sha256=cLM7nN8JOIu9ZVP9oY9qdSK3hrnWJiDcX6nZmQQbQWI,13065
109
- execsql2-2.12.3.data/data/execsql2_extras/ss_upsert.sql,sha256=BCqmBykXBF-BpCgOFeG1qhf2XfScKsxPD17wd1hYfHw,120647
110
- execsql2-2.12.3.dist-info/METADATA,sha256=snFjAb6heF3pzQ-Yyxwmip-LgS0aH3OPjkQ_FXQucj8,17436
111
- execsql2-2.12.3.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
112
- execsql2-2.12.3.dist-info/entry_points.txt,sha256=sUOxkM-dN1eBGGpSpDLsAaE0yNXYQKWZAfxPOlMkQyk,90
113
- execsql2-2.12.3.dist-info/licenses/LICENSE.txt,sha256=LBdhuxejF8_bLCHZ2kWfmDXpDGUu914Gbd6_3JjCRe0,676
114
- execsql2-2.12.3.dist-info/licenses/NOTICE,sha256=sqVrM73Ys9zfvWC_P797lHfTnoPW_ETeBSrUTFaob0A,339
115
- execsql2-2.12.3.dist-info/RECORD,,
96
+ execsql2-2.12.6.data/data/execsql2_extras/README.md,sha256=sxwVyU0ZahCfANv56LahkyuM505kFjrMhe-1SvWE69E,4845
97
+ execsql2-2.12.6.data/data/execsql2_extras/config_settings.sqlite,sha256=aY5cxR7Q7J6zJ4bC9lu5mHUrhy211Cq3MNKPQVCt02E,20480
98
+ execsql2-2.12.6.data/data/execsql2_extras/example_config_prompt.sql,sha256=SY3Jxn1qcVm4kPW9xmmTfknHfvURXmeEYTbRjYrjGSw,7487
99
+ execsql2-2.12.6.data/data/execsql2_extras/execsql.conf,sha256=_45iJ-KWZnB8uMW_gEg067MM5pmGJ-dVl7VbAZMunAE,9530
100
+ execsql2-2.12.6.data/data/execsql2_extras/make_config_db.sql,sha256=WwyC6dK-Eh5CAVppiBCDHqiI1_wEI9U95Ytpr4lsZkg,8726
101
+ execsql2-2.12.6.data/data/execsql2_extras/md_compare.sql,sha256=B8Wd7LZ0vnMY2qvA139JIEBkPObgRH2i5xj6PejTQt8,24092
102
+ execsql2-2.12.6.data/data/execsql2_extras/md_glossary.sql,sha256=DJRHcU5NbFpxTTX-IwH3yRlsboj1q6BBGrUAHKn4Cuo,10796
103
+ execsql2-2.12.6.data/data/execsql2_extras/md_upsert.sql,sha256=v_7GbWh_N1mBTmw3gvTrkagOVp2q0KmXvM8hE-DlFxY,112524
104
+ execsql2-2.12.6.data/data/execsql2_extras/pg_compare.sql,sha256=9dWa8hnfy5dVJI-z2iGpd9JzQmI4j2ziMlEdpnr66ro,24352
105
+ execsql2-2.12.6.data/data/execsql2_extras/pg_glossary.sql,sha256=pKjIIDsROAgJq2H-1qNEcRMAWManivcZ_AEVHzUUlic,9908
106
+ execsql2-2.12.6.data/data/execsql2_extras/pg_upsert.sql,sha256=k7AFiGTLBy3nf-qO5QIaZrEYTAKvdxxU3JDLx9jqkzs,108315
107
+ execsql2-2.12.6.data/data/execsql2_extras/script_template.sql,sha256=1Estacb_vm1FgK41k_G9nuduP1yiA-fQ1Kn4Z4mv5Ao,11153
108
+ execsql2-2.12.6.data/data/execsql2_extras/ss_compare.sql,sha256=TsVxWm3cEpR5-EiMYXNhtaY0arSNeKZhsJdHdLA7xeI,24833
109
+ execsql2-2.12.6.data/data/execsql2_extras/ss_glossary.sql,sha256=cLM7nN8JOIu9ZVP9oY9qdSK3hrnWJiDcX6nZmQQbQWI,13065
110
+ execsql2-2.12.6.data/data/execsql2_extras/ss_upsert.sql,sha256=BCqmBykXBF-BpCgOFeG1qhf2XfScKsxPD17wd1hYfHw,120647
111
+ execsql2-2.12.6.dist-info/METADATA,sha256=QM1G8hAxmsNAj05cWEcly_22msjrWAEkNFinUGaH9Fc,17560
112
+ execsql2-2.12.6.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
113
+ execsql2-2.12.6.dist-info/entry_points.txt,sha256=sUOxkM-dN1eBGGpSpDLsAaE0yNXYQKWZAfxPOlMkQyk,90
114
+ execsql2-2.12.6.dist-info/licenses/LICENSE.txt,sha256=LBdhuxejF8_bLCHZ2kWfmDXpDGUu914Gbd6_3JjCRe0,676
115
+ execsql2-2.12.6.dist-info/licenses/NOTICE,sha256=sqVrM73Ys9zfvWC_P797lHfTnoPW_ETeBSrUTFaob0A,339
116
+ execsql2-2.12.6.dist-info/RECORD,,