velocity-python 0.0.145__py3-none-any.whl → 0.0.146__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.

Potentially problematic release.


This version of velocity-python might be problematic. Click here for more details.

velocity/__init__.py CHANGED
@@ -1,4 +1,4 @@
1
- __version__ = version = "0.0.145"
1
+ __version__ = version = "0.0.146"
2
2
 
3
3
  from . import aws
4
4
  from . import db
@@ -0,0 +1,192 @@
1
+ """
2
+ Error Handler Mixin for Lambda Handlers.
3
+
4
+ Provides standardized error handling, logging, and notification functionality
5
+ for Lambda handlers.
6
+ """
7
+
8
+ import copy
9
+ import os
10
+ import pprint
11
+ import time
12
+ from abc import ABC, abstractmethod
13
+ from typing import Dict, Any, Optional
14
+
15
+
16
+ class AwsSessionMixin(ABC):
17
+ """
18
+ Mixin class providing standardized error handling for Lambda handlers.
19
+
20
+ Handles error logging to sys_log table, email notifications to administrators,
21
+ and error metrics collection.
22
+ """
23
+
24
+ def handle_standard_error(self, tx, context, exception: Exception, tb_string: str):
25
+ """Handle errors with consistent logging and notification patterns"""
26
+
27
+ # Log to sys_log for centralized logging
28
+ self.log_error_to_system(tx, context, exception, tb_string)
29
+
30
+ # Determine if this error requires notification
31
+ if self._should_notify_error(exception):
32
+ self.send_error_notification(tx, context, exception, tb_string)
33
+
34
+ # Log error metrics for monitoring
35
+ self.log_error_metrics(tx, context, exception)
36
+
37
+ def log_error_to_system(self, tx, context, exception: Exception, tb_string: str):
38
+ """Log error to sys_log table"""
39
+ error_data = {
40
+ "level": "ERROR",
41
+ "message": str(exception),
42
+ "function": f"{self.__class__.__name__}.{context.action()}",
43
+ "traceback": tb_string,
44
+ "exception_type": exception.__class__.__name__,
45
+ "handler_name": self.__class__.__name__,
46
+ "action": context.action(),
47
+ "user_branch": os.environ.get("USER_BRANCH", "Unknown"),
48
+ "function_name": os.environ.get("AWS_LAMBDA_FUNCTION_NAME", "Unknown"),
49
+ "app_name": os.environ.get("ProjectName", "Unknown"),
50
+ "user_agent": "AWS Lambda",
51
+ "device_type": "Lambda",
52
+ "sys_modified_by": "Lambda",
53
+ }
54
+
55
+ # Add user context if available
56
+ try:
57
+ if hasattr(self, 'current_user') and self.current_user:
58
+ error_data["user_email"] = self.current_user.get("email_address")
59
+ except:
60
+ pass
61
+
62
+ tx.table("sys_log").insert(error_data)
63
+
64
+ def send_error_notification(self, tx, context, exception: Exception, tb_string: str):
65
+ """Send error notification email to administrators"""
66
+ try:
67
+ # Import here to avoid circular dependency
68
+ from support.app import helpers
69
+
70
+ environment = os.environ.get('USER_BRANCH', 'Unknown').title()
71
+ function_name = os.environ.get('AWS_LAMBDA_FUNCTION_NAME', 'Unknown')
72
+
73
+ subject = f"{environment} Lambda Error - {function_name}"
74
+
75
+ body = f"""
76
+ Error Details:
77
+ - Handler: {self.__class__.__name__}
78
+ - Action: {context.action()}
79
+ - Exception: {exception.__class__.__name__}
80
+ - Message: {str(exception)}
81
+ - Environment: {environment}
82
+ - Function: {function_name}
83
+
84
+ Full Traceback:
85
+ {tb_string}
86
+
87
+ Request Details:
88
+ {self._get_error_context(context)}
89
+ """
90
+
91
+ sender = self._get_error_notification_sender()
92
+ recipients = self._get_error_notification_recipients()
93
+
94
+ helpers.sendmail(
95
+ tx,
96
+ subject=subject,
97
+ body=body,
98
+ html=None,
99
+ sender=sender,
100
+ recipient=recipients[0],
101
+ cc=recipients[1:] if len(recipients) > 1 else None,
102
+ bcc=None,
103
+ email_settings_id=1001,
104
+ )
105
+ except Exception as email_error:
106
+ print(f"Failed to send error notification email: {email_error}")
107
+
108
+ def _should_notify_error(self, exception: Exception) -> bool:
109
+ """Determine if an error should trigger email notifications"""
110
+ # Don't notify for user authentication errors or validation errors
111
+ non_notification_types = [
112
+ "AuthenticationError",
113
+ "ValidationError",
114
+ "ValueError",
115
+ "AlertError"
116
+ ]
117
+
118
+ exception_name = exception.__class__.__name__
119
+
120
+ # Check for authentication-related exceptions
121
+ if "Authentication" in exception_name or "Auth" in exception_name:
122
+ return False
123
+
124
+ return exception_name not in non_notification_types
125
+
126
+ @abstractmethod
127
+ def _get_error_notification_recipients(self) -> list:
128
+ """
129
+ Get list of email recipients for error notifications.
130
+
131
+ Must be implemented by the handler class.
132
+
133
+ Returns:
134
+ List of email addresses to notify when errors occur
135
+
136
+ Example:
137
+ return ["admin@company.com", "devops@company.com"]
138
+ """
139
+ pass
140
+
141
+ @abstractmethod
142
+ def _get_error_notification_sender(self) -> str:
143
+ """
144
+ Get email sender for error notifications.
145
+
146
+ Must be implemented by the handler class.
147
+
148
+ Returns:
149
+ Email address to use as sender for error notifications
150
+
151
+ Example:
152
+ return "no-reply@company.com"
153
+ """
154
+ pass
155
+
156
+ def _get_error_context(self, context) -> str:
157
+ """Get sanitized request context for error reporting"""
158
+ try:
159
+ postdata = context.postdata()
160
+ sanitized = copy.deepcopy(postdata)
161
+
162
+ # Remove sensitive data
163
+ if "payload" in sanitized and isinstance(sanitized["payload"], dict):
164
+ sanitized["payload"].pop("cognito_user", None)
165
+
166
+ return pprint.pformat(sanitized)
167
+ except:
168
+ return "Unable to retrieve request context"
169
+
170
+ def log_error_metrics(self, tx, context, exception: Exception):
171
+ """Log error metrics for monitoring and alerting"""
172
+ try:
173
+ metrics_data = {
174
+ "metric_type": "error_count",
175
+ "handler_name": self.__class__.__name__,
176
+ "action": context.action(),
177
+ "exception_type": exception.__class__.__name__,
178
+ "environment": os.environ.get("USER_BRANCH", "Unknown"),
179
+ "function_name": os.environ.get("AWS_LAMBDA_FUNCTION_NAME", "Unknown"),
180
+ "timestamp": time.time(),
181
+ "sys_modified_by": "Lambda"
182
+ }
183
+
184
+ # Try to insert into metrics table if it exists
185
+ try:
186
+ tx.table("lambda_metrics").insert(metrics_data)
187
+ except:
188
+ # Metrics table might not exist yet, don't fail error handler
189
+ pass
190
+ except:
191
+ # Don't fail the error handler if metrics logging fails
192
+ pass
velocity/db/core/table.py CHANGED
@@ -1,4 +1,5 @@
1
1
  import sqlparse
2
+ from collections.abc import Mapping
2
3
  from velocity.db import exceptions
3
4
  from velocity.db.core.row import Row
4
5
  from velocity.db.core.result import Result
@@ -177,10 +178,14 @@ class Table:
177
178
  columns = []
178
179
 
179
180
  has_column = "sys_modified_count" in columns
180
- if has_column and not force:
181
+ has_row_column = "sys_modified_row" in columns
182
+
183
+ if has_column and has_row_column and not force:
181
184
  return
182
185
 
183
- sql, vals = self.sql.ensure_sys_modified_count(self.name, has_column=has_column)
186
+ sql, vals = self.sql.ensure_sys_modified_count(
187
+ self.name, has_column=has_column, has_row_column=has_row_column
188
+ )
184
189
  if kwds.get("sql_only", False):
185
190
  return sql, vals
186
191
  self.tx.execute(sql, vals, cursor=self.cursor())
@@ -487,6 +492,104 @@ class Table:
487
492
  result = self.tx.execute(sql, vals, cursor=self.cursor())
488
493
  return result.cursor.rowcount if result.cursor else 0
489
494
 
495
+ @create_missing
496
+ def update_or_insert(self, update_data, insert_data=None, where=None, pk=None, **kwds):
497
+ """
498
+ Attempts an UPDATE first; if no rows change, performs an INSERT guarded by NOT EXISTS.
499
+
500
+ :param update_data: Mapping of columns to update.
501
+ :param insert_data: Optional mapping used for the INSERT. When omitted, values are
502
+ derived from update_data combined with simple equality predicates
503
+ from ``where`` and primary key values.
504
+ :param where: Criteria for the UPDATE and existence check.
505
+ :param pk: Optional primary key mapping for UPDATE (merged into WHERE) and INSERT.
506
+ :param sql_only: When True, return the SQL/parameter tuples for both phases instead of executing.
507
+ :return: Number of rows affected, or a dict with ``update``/``insert`` entries when sql_only=True.
508
+ """
509
+ sql_only = kwds.get("sql_only", False)
510
+ if not isinstance(update_data, Mapping) or not update_data:
511
+ raise ValueError("update_data must be a non-empty mapping of column-value pairs.")
512
+ if where is None and pk is None:
513
+ raise ValueError("Either where or pk must be provided for update_or_insert.")
514
+
515
+ update_stmt = None
516
+ if sql_only:
517
+ update_stmt = self.update(update_data, where=where, pk=pk, sql_only=True)
518
+ else:
519
+ updated = self.update(update_data, where=where, pk=pk)
520
+ if updated:
521
+ return updated
522
+
523
+ if insert_data is not None:
524
+ if not isinstance(insert_data, Mapping):
525
+ raise ValueError("insert_data must be a mapping when provided.")
526
+ insert_payload = dict(insert_data)
527
+ else:
528
+ insert_payload = dict(update_data)
529
+ if isinstance(where, Mapping):
530
+ for key, val in where.items():
531
+ if not isinstance(key, str):
532
+ continue
533
+ if set("<>!=%").intersection(key):
534
+ continue
535
+ insert_payload.setdefault(key, val)
536
+ if isinstance(pk, Mapping):
537
+ for key, val in pk.items():
538
+ insert_payload.setdefault(key, val)
539
+
540
+ if not insert_payload:
541
+ raise ValueError("Unable to derive insert payload for update_or_insert.")
542
+
543
+ exists_where = None
544
+ if where is not None and pk is not None:
545
+ if isinstance(where, Mapping) and isinstance(pk, Mapping):
546
+ combined = dict(where)
547
+ combined.update(pk)
548
+ exists_where = combined
549
+ else:
550
+ exists_where = where
551
+ elif where is not None:
552
+ exists_where = where
553
+ else:
554
+ exists_where = pk
555
+
556
+ ins_builder = getattr(self.sql, "insnx", None) or getattr(
557
+ self.sql, "insert_if_not_exists", None
558
+ )
559
+ if ins_builder is None:
560
+ raise NotImplementedError(
561
+ "Current SQL dialect does not support insert-if-not-exists operations."
562
+ )
563
+
564
+ sql, vals = ins_builder(self.tx, self.name, insert_payload, exists_where)
565
+ if sql_only:
566
+ return {"update": update_stmt, "insert": (sql, vals)}
567
+ result = self.tx.execute(sql, vals, cursor=self.cursor())
568
+ return result.cursor.rowcount if result.cursor else 0
569
+
570
+ updins = update_or_insert
571
+
572
+ @create_missing
573
+ def insert_if_not_exists(self, data, where=None, **kwds):
574
+ """
575
+ Inserts `data` into the table only if the existence check (`where`) does not match any rows.
576
+
577
+ Usage:
578
+ table.insert_if_not_exists({'key_col': 'k', 'value': 'v'}, where={'key_col': 'k'})
579
+
580
+ :param data: dict of column -> value for insert
581
+ :param where: mapping/list/str used for the EXISTS check; if None primary keys are used and
582
+ must be present in `data`.
583
+ :return: rowcount (0 or 1) or (sql, params) when sql_only=True
584
+ """
585
+ sql, vals = self.sql.insert_if_not_exists(self.tx, self.name, data, where)
586
+ if kwds.get("sql_only", False):
587
+ return sql, vals
588
+ result = self.tx.execute(sql, vals, cursor=self.cursor())
589
+ return result.cursor.rowcount if result.cursor else 0
590
+
591
+ insnx = insert_if_not_exists
592
+
490
593
  upsert = merge
491
594
  indate = merge
492
595
 
@@ -450,7 +450,7 @@ END;
450
450
  return "\n".join(statements), tuple()
451
451
 
452
452
  @classmethod
453
- def ensure_sys_modified_count(cls, name):
453
+ def ensure_sys_modified_count(cls, name, has_column=False, has_row_column=False):
454
454
  """Ensure sys_modified_count column and associated triggers exist for the table."""
455
455
  table_identifier = quote(name)
456
456
  base_name = name.split(".")[-1].replace("`", "")
@@ -562,51 +562,164 @@ class SQL(BaseSQLDialect):
562
562
 
563
563
  @classmethod
564
564
  def merge(cls, tx, table, data, pk, on_conflict_do_nothing, on_conflict_update):
565
+ if not isinstance(data, Mapping) or not data:
566
+ raise ValueError("data must be a non-empty mapping of column-value pairs.")
567
+
568
+ table_helper = TableHelper(tx, table)
569
+ data = dict(data) # work with a copy to avoid mutating the caller's dict
570
+
565
571
  if pk is None:
566
572
  pkeys = tx.table(table).primary_keys()
567
573
  if not pkeys:
568
574
  raise ValueError("Primary key required for merge.")
569
- # If there are multiple primary keys, use all of them
570
- if len(pkeys) > 1:
571
- pk = {pk: data[pk] for pk in pkeys}
572
- else:
573
- pk = {pkeys[0]: data[pkeys[0]]}
574
- # Remove primary keys from data; they will be used in the conflict target
575
- data = {k: v for k, v in data.items() if k not in pk}
575
+ missing = [key for key in pkeys if key not in data]
576
+ if missing:
577
+ missing_cols = ", ".join(missing)
578
+ raise ValueError(
579
+ "Primary key values missing from data for merge: "
580
+ f"{missing_cols}. Provide pk=... or include the key values in data."
581
+ )
582
+ pk = {key: data[key] for key in pkeys}
583
+ else:
584
+ pk = dict(pk)
585
+ for key, value in pk.items():
586
+ if key in data and data[key] != value:
587
+ raise ValueError(
588
+ f"Conflicting values for primary key '{key}' between data and pk arguments."
589
+ )
576
590
 
577
- # Create a merged dictionary for insert (data + primary key columns)
578
- full_data = {}
579
- full_data.update(data)
580
- full_data.update(pk)
591
+ insert_data = dict(data)
592
+ insert_data.update(pk)
581
593
 
582
- sql, vals = cls.insert(table, full_data)
583
- sql = [sql]
584
- vals = list(vals) # Convert to a mutable list
594
+ update_data = {k: v for k, v in data.items() if k not in pk}
585
595
 
586
- if on_conflict_do_nothing != on_conflict_update:
587
- sql.append("ON CONFLICT")
588
- sql.append("(")
589
- sql.append(",".join(pk.keys()))
590
- sql.append(")")
591
- sql.append("DO")
592
- if on_conflict_do_nothing:
593
- sql.append("NOTHING")
594
- elif on_conflict_update:
595
- # Call update() with excluded=True to produce the SET clause for the upsert.
596
- sql_update, vals_update = cls.update(tx, table, data, pk, excluded=True)
597
- sql.append(sql_update)
598
- # Use list.extend to add the update values to vals.
599
- vals.extend(vals_update)
600
- else:
596
+ if not update_data and on_conflict_update:
597
+ # Nothing to update, fall back to a no-op on conflict resolution.
598
+ on_conflict_do_nothing = True
599
+ on_conflict_update = False
600
+
601
+ if on_conflict_do_nothing == on_conflict_update:
601
602
  raise Exception(
602
603
  "Update on conflict must have one and only one option to complete on conflict."
603
604
  )
604
605
 
606
+ sql, vals = cls.insert(table, insert_data)
607
+ sql = [sql]
608
+ vals = list(vals) # Convert to a mutable list
609
+
610
+ sql.append("ON CONFLICT")
611
+ conflict_columns = [TableHelper.quote(column) for column in pk.keys()]
612
+ sql.append("(")
613
+ sql.append(", ".join(conflict_columns))
614
+ sql.append(")")
615
+ sql.append("DO")
616
+ if on_conflict_do_nothing:
617
+ sql.append("NOTHING")
618
+ elif on_conflict_update:
619
+ sql_update, vals_update = cls.update(
620
+ tx, table, update_data, pk, excluded=True
621
+ )
622
+ sql.append(sql_update)
623
+ vals.extend(vals_update)
624
+
605
625
  import sqlparse
606
626
 
607
627
  final_sql = sqlparse.format(" ".join(sql), reindent=True, keyword_case="upper")
608
628
  return final_sql, tuple(vals)
609
629
 
630
+ @classmethod
631
+ def insnx(cls, tx, table, data, where=None):
632
+ """Insert only when the supplied predicate finds no existing row."""
633
+ if not table:
634
+ raise ValueError("Table name is required.")
635
+ if not isinstance(data, Mapping) or not data:
636
+ raise ValueError("data must be a non-empty mapping of column-value pairs.")
637
+
638
+ # Helper used for quoting and foreign key resolution
639
+ th = TableHelper(tx, table)
640
+ quote_helper = TableHelper(None, table)
641
+
642
+ columns_sql = []
643
+ select_parts = []
644
+ vals = []
645
+
646
+ for key, val in data.items():
647
+ columns_sql.append(quote_helper.quote(key.lower()))
648
+ if isinstance(val, str) and len(val) > 2 and val.startswith("@@") and val[2:]:
649
+ select_parts.append(val[2:])
650
+ else:
651
+ select_parts.append("%s")
652
+ vals.append(val)
653
+
654
+ if not select_parts:
655
+ raise ValueError("At least one column is required for insert.")
656
+
657
+ if where is None:
658
+ if tx is None:
659
+ raise ValueError(
660
+ "A transaction context is required when deriving WHERE from primary keys."
661
+ )
662
+ pk_cols = tx.table(table).primary_keys()
663
+ if not pk_cols:
664
+ raise ValueError("Primary key required to derive WHERE clause.")
665
+ missing = [pk for pk in pk_cols if pk not in data]
666
+ if missing:
667
+ raise ValueError(
668
+ "Missing primary key value(s) for insert condition: " + ", ".join(missing)
669
+ )
670
+ where = {pk: data[pk] for pk in pk_cols}
671
+
672
+ where_clauses = []
673
+ where_vals = []
674
+
675
+ if isinstance(where, Mapping):
676
+ compiled = []
677
+ for key, val in where.items():
678
+ compiled.append(th.make_predicate(key, val))
679
+ where = compiled
680
+
681
+ if isinstance(where, str):
682
+ where_clauses.append(where)
683
+ else:
684
+ try:
685
+ for predicate, value in where:
686
+ where_clauses.append(predicate)
687
+ if value is None:
688
+ continue
689
+ if isinstance(value, tuple):
690
+ where_vals.extend(value)
691
+ else:
692
+ where_vals.append(value)
693
+ except (TypeError, ValueError) as exc:
694
+ raise ValueError(
695
+ "Invalid WHERE clause format. Expected mapping, SQL string, or iterable of predicate/value pairs."
696
+ ) from exc
697
+
698
+ vals.extend(where_vals)
699
+
700
+ exists_sql = [
701
+ "SELECT 1 FROM",
702
+ TableHelper.quote(table),
703
+ ]
704
+ if where_clauses:
705
+ exists_sql.append("WHERE " + " AND ".join(where_clauses))
706
+
707
+ sql_parts = [
708
+ "INSERT INTO",
709
+ TableHelper.quote(table),
710
+ f"({','.join(columns_sql)})",
711
+ "SELECT",
712
+ ", ".join(select_parts),
713
+ "WHERE NOT EXISTS (",
714
+ " ".join(exists_sql),
715
+ ")",
716
+ ]
717
+
718
+ final_sql = sqlparse.format(" ".join(sql_parts), reindent=True, keyword_case="upper")
719
+ return final_sql, tuple(vals)
720
+
721
+ insert_if_not_exists = insnx
722
+
610
723
  @classmethod
611
724
  def version(cls):
612
725
  return "select version()", tuple()
@@ -704,6 +817,7 @@ class SQL(BaseSQLDialect):
704
817
  sys_created TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
705
818
  sys_modified TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
706
819
  sys_modified_by TEXT NOT NULL DEFAULT 'SYSTEM',
820
+ sys_modified_row TIMESTAMP NOT NULL DEFAULT CLOCK_TIMESTAMP(),
707
821
  sys_modified_count INTEGER NOT NULL DEFAULT 0,
708
822
  sys_dirty BOOLEAN NOT NULL DEFAULT FALSE,
709
823
  sys_table TEXT NOT NULL,
@@ -718,8 +832,9 @@ class SQL(BaseSQLDialect):
718
832
  BEGIN
719
833
  IF (TG_OP = 'INSERT') THEN
720
834
  NEW.sys_table := TG_TABLE_NAME;
721
- NEW.sys_created := clock_timestamp();
722
- NEW.sys_modified := clock_timestamp();
835
+ NEW.sys_created := transaction_timestamp();
836
+ NEW.sys_modified := transaction_timestamp();
837
+ NEW.sys_modified_row := clock_timestamp();
723
838
  NEW.sys_modified_count := 0;
724
839
  ELSIF (TG_OP = 'UPDATE') THEN
725
840
  NEW.sys_table := TG_TABLE_NAME;
@@ -731,7 +846,7 @@ class SQL(BaseSQLDialect):
731
846
  ELSE
732
847
  NEW.sys_dirty := TRUE;
733
848
  END IF;
734
- NEW.sys_modified := clock_timestamp();
849
+ NEW.sys_modified := transaction_timestamp();
735
850
  NEW.sys_modified_count := COALESCE(OLD.sys_modified_count, 0) + 1;
736
851
  END IF;
737
852
  END IF;
@@ -761,8 +876,10 @@ class SQL(BaseSQLDialect):
761
876
  return sql, tuple()
762
877
 
763
878
  @classmethod
764
- def ensure_sys_modified_count(cls, name, has_column=False):
765
- """Return SQL to backfill sys_modified_count and refresh the on_sys_modified trigger."""
879
+ def ensure_sys_modified_count(
880
+ cls, name, has_column=False, has_row_column=False
881
+ ):
882
+ """Return SQL to backfill sys_modified_count/sys_modified_row and refresh the on_sys_modified trigger."""
766
883
  if "." in name:
767
884
  schema_name, table_name = name.split(".", 1)
768
885
  else:
@@ -780,15 +897,21 @@ class SQL(BaseSQLDialect):
780
897
  )
781
898
  trigger_identifier = TableHelper.quote(trigger_name)
782
899
  column_name = TableHelper.quote("sys_modified_count")
900
+ row_column_name = TableHelper.quote("sys_modified_row")
783
901
 
784
902
  statements = []
785
903
  if not has_column:
786
904
  statements.append(
787
905
  f"ALTER TABLE {fqtn} ADD COLUMN {column_name} INTEGER NOT NULL DEFAULT 0;"
788
906
  )
907
+ if not has_row_column:
908
+ statements.append(
909
+ f"ALTER TABLE {fqtn} ADD COLUMN {row_column_name} TIMESTAMPTZ;"
910
+ )
789
911
 
790
912
  statements.extend([
791
913
  f"UPDATE {fqtn} SET {column_name} = 0 WHERE {column_name} IS NULL;",
914
+ f"UPDATE {fqtn} SET {row_column_name} = COALESCE({row_column_name}, clock_timestamp());",
792
915
  f"""
793
916
  CREATE OR REPLACE FUNCTION {schema_identifier}.on_sys_modified()
794
917
  RETURNS TRIGGER AS
@@ -796,8 +919,9 @@ class SQL(BaseSQLDialect):
796
919
  BEGIN
797
920
  IF (TG_OP = 'INSERT') THEN
798
921
  NEW.sys_table := TG_TABLE_NAME;
799
- NEW.sys_created := clock_timestamp();
800
- NEW.sys_modified := clock_timestamp();
922
+ NEW.sys_created := transaction_timestamp();
923
+ NEW.sys_modified := transaction_timestamp();
924
+ NEW.sys_modified_row := clock_timestamp();
801
925
  NEW.sys_modified_count := 0;
802
926
  ELSIF (TG_OP = 'UPDATE') THEN
803
927
  NEW.sys_table := TG_TABLE_NAME;
@@ -809,7 +933,8 @@ class SQL(BaseSQLDialect):
809
933
  ELSE
810
934
  NEW.sys_dirty := TRUE;
811
935
  END IF;
812
- NEW.sys_modified := clock_timestamp();
936
+ NEW.sys_modified := transaction_timestamp();
937
+ NEW.sys_modified_row := clock_timestamp();
813
938
  NEW.sys_modified_count := COALESCE(OLD.sys_modified_count, 0) + 1;
814
939
  END IF;
815
940
  END IF;
@@ -431,7 +431,7 @@ END;
431
431
  return "\n".join(statements), tuple()
432
432
 
433
433
  @classmethod
434
- def ensure_sys_modified_count(cls, name):
434
+ def ensure_sys_modified_count(cls, name, has_column=False, has_row_column=False):
435
435
  """Ensure sys_modified_count exists for SQLite tables."""
436
436
  table_identifier = quote(name)
437
437
  base_name = name.split(".")[-1].replace('"', "")
@@ -485,7 +485,9 @@ END;
485
485
  return "\n".join(statements), tuple()
486
486
 
487
487
  @classmethod
488
- def ensure_sys_modified_count(cls, name, has_column=False):
488
+ def ensure_sys_modified_count(
489
+ cls, name, has_column=False, has_row_column=False
490
+ ):
489
491
  """Ensure sys_modified_count exists for SQL Server tables along with maintenance triggers."""
490
492
  if "." in name:
491
493
  schema, table_name = name.split(".", 1)
@@ -1,8 +1,11 @@
1
1
  import unittest
2
2
  import decimal
3
+ from types import SimpleNamespace
4
+ from unittest import mock
3
5
  from velocity.db.servers.postgres.sql import SQL
4
6
  from velocity.db.servers.tablehelper import TableHelper
5
7
  from velocity.db.servers.postgres.types import TYPES
8
+ from velocity.db.core.table import Table
6
9
 
7
10
 
8
11
  class MockTx:
@@ -21,6 +24,51 @@ class MockTable:
21
24
  def column(self, column_name):
22
25
  return MockColumn()
23
26
 
27
+ def primary_keys(self):
28
+ return ["id"]
29
+
30
+
31
+ class DummyCursor:
32
+ def __init__(self, rowcount=0):
33
+ self.rowcount = rowcount
34
+
35
+
36
+ class DummyResult:
37
+ def __init__(self, rowcount=0):
38
+ self.cursor = DummyCursor(rowcount)
39
+
40
+
41
+ class DummyTx:
42
+ def __init__(self):
43
+ self.engine = SimpleNamespace(sql=SimpleNamespace(), schema_locked=False)
44
+ self.executed = []
45
+ self.next_results = []
46
+
47
+ def cursor(self):
48
+ return DummyCursor()
49
+
50
+ def create_savepoint(self, cursor=None):
51
+ sp_id = f"sp_{len(self.executed)}"
52
+ return sp_id
53
+
54
+ def release_savepoint(self, sp, cursor=None):
55
+ return None
56
+
57
+ def rollback_savepoint(self, sp, cursor=None):
58
+ return None
59
+
60
+ def execute(self, sql, params, cursor=None):
61
+ self.executed.append((sql, params))
62
+ if self.next_results:
63
+ return self.next_results.pop(0)
64
+ return DummyResult(0)
65
+
66
+ def table(self, table_name):
67
+ return MockTable()
68
+
69
+ def primary_keys(self):
70
+ return ["id"]
71
+
24
72
  class MockColumn:
25
73
  def __init__(self):
26
74
  self.py_type = str
@@ -218,6 +266,147 @@ class TestSQLModule(unittest.TestCase):
218
266
  self.assertIn("SET", sql_query)
219
267
  self.assertEqual(params, ("value1", 1))
220
268
 
269
+ def test_sql_insnx_with_explicit_where(self):
270
+ mock_tx = MockTx()
271
+ sql_query, params = SQL.insnx(
272
+ mock_tx,
273
+ table="my_table",
274
+ data={"id": 1, "column1": "value1"},
275
+ where={"column1": "value1"},
276
+ )
277
+ self.assertIn("INSERT INTO", sql_query)
278
+ self.assertIn("WHERE NOT EXISTS", sql_query)
279
+ self.assertIn("SELECT 1 FROM my_table", sql_query)
280
+ self.assertEqual(params, (1, "value1", "value1"))
281
+
282
+ def test_sql_insert_if_not_exists_alias(self):
283
+ mock_tx = MockTx()
284
+ sql_alias, params_alias = SQL.insert_if_not_exists(
285
+ mock_tx,
286
+ table="my_table",
287
+ data={"id": 1, "column1": "value1"},
288
+ where={"column1": "value1"},
289
+ )
290
+ sql_main, params_main = SQL.insnx(
291
+ mock_tx,
292
+ table="my_table",
293
+ data={"id": 1, "column1": "value1"},
294
+ where={"column1": "value1"},
295
+ )
296
+ self.assertEqual(sql_alias, sql_main)
297
+ self.assertEqual(params_alias, params_main)
298
+
299
+ def test_table_update_or_insert_updates_only(self):
300
+ tx = DummyTx()
301
+ table = Table(tx, "my_table")
302
+ table.cursor = mock.MagicMock(return_value=None)
303
+ table.update = mock.MagicMock(return_value=1)
304
+ ins_builder = mock.MagicMock()
305
+ table.sql = SimpleNamespace(insnx=ins_builder, insert_if_not_exists=ins_builder)
306
+
307
+ affected = table.update_or_insert(
308
+ update_data={"value": "new"},
309
+ insert_data={"id": 1, "value": "new"},
310
+ where={"id": 1},
311
+ )
312
+
313
+ self.assertEqual(affected, 1)
314
+ table.update.assert_called_once()
315
+ ins_builder.assert_not_called()
316
+
317
+ def test_table_update_or_insert_falls_back_to_insert(self):
318
+ tx = DummyTx()
319
+ table = Table(tx, "my_table")
320
+ table.cursor = mock.MagicMock(return_value=None)
321
+ table.update = mock.MagicMock(return_value=0)
322
+
323
+ captured = {}
324
+
325
+ def fake_insnx(tx_ctx, table_name, data, where):
326
+ captured["tx"] = tx_ctx
327
+ captured["table"] = table_name
328
+ captured["data"] = dict(data)
329
+ captured["where"] = where
330
+ return ("INSERT", ("a", "b"))
331
+
332
+ ins_builder = mock.MagicMock(side_effect=fake_insnx)
333
+ table.sql = SimpleNamespace(insnx=ins_builder, insert_if_not_exists=ins_builder)
334
+ tx.next_results.append(DummyResult(1))
335
+
336
+ affected = table.update_or_insert(
337
+ update_data={"value": "new"},
338
+ where={"id": 1},
339
+ pk={"id": 1},
340
+ )
341
+
342
+ self.assertEqual(affected, 1)
343
+ table.update.assert_called_once()
344
+ ins_builder.assert_called_once()
345
+ self.assertEqual(captured["table"], "my_table")
346
+ self.assertEqual(captured["data"], {"value": "new", "id": 1})
347
+ self.assertEqual(captured["where"], {"id": 1})
348
+
349
+ def test_table_update_or_insert_sql_only(self):
350
+ tx = DummyTx()
351
+ table = Table(tx, "my_table")
352
+ table.cursor = mock.MagicMock(return_value=None)
353
+ table.update = mock.MagicMock(return_value=("UPDATE sql", ("u",)))
354
+
355
+ ins_builder = mock.MagicMock(return_value=("INSERT sql", ("i",)))
356
+ table.sql = SimpleNamespace(insnx=ins_builder, insert_if_not_exists=ins_builder)
357
+
358
+ result = table.update_or_insert(
359
+ update_data={"value": "new"},
360
+ where={"id": 1},
361
+ pk={"id": 1},
362
+ sql_only=True,
363
+ )
364
+
365
+ self.assertEqual(result["update"], ("UPDATE sql", ("u",)))
366
+ self.assertEqual(result["insert"], ("INSERT sql", ("i",)))
367
+ table.update.assert_called_once_with({"value": "new"}, where={"id": 1}, pk={"id": 1}, sql_only=True)
368
+ ins_builder.assert_called_once()
369
+
370
+ def test_sql_merge_conflict_columns_are_quoted(self):
371
+ mock_tx = MockTx()
372
+ sql_query, _ = SQL.merge(
373
+ mock_tx,
374
+ table="my_table",
375
+ data={"payload": "value"},
376
+ pk={"select": 1},
377
+ on_conflict_do_nothing=False,
378
+ on_conflict_update=True,
379
+ )
380
+ self.assertIn('on conflict ("select")'.upper(), sql_query.upper())
381
+
382
+ def test_sql_merge_missing_auto_pk_values(self):
383
+ mock_tx = MockTx()
384
+ with self.assertRaisesRegex(
385
+ ValueError, "Primary key values missing from data for merge"
386
+ ):
387
+ SQL.merge(
388
+ mock_tx,
389
+ table="my_table",
390
+ data={"column1": "value1"},
391
+ pk=None,
392
+ on_conflict_do_nothing=False,
393
+ on_conflict_update=True,
394
+ )
395
+
396
+ def test_sql_merge_auto_pk_without_update_columns_falls_back_to_do_nothing(self):
397
+ mock_tx = MockTx()
398
+ sql_query, params = SQL.merge(
399
+ mock_tx,
400
+ table="my_table",
401
+ data={"id": 1},
402
+ pk=None,
403
+ on_conflict_do_nothing=False,
404
+ on_conflict_update=True,
405
+ )
406
+ self.assertIn("DO NOTHING", sql_query)
407
+ self.assertNotIn(" DO UPDATE", sql_query)
408
+ self.assertEqual(params, (1,))
409
+
221
410
  def test_get_type_mapping(self):
222
411
  self.assertEqual(TYPES.get_type("string"), "TEXT")
223
412
  self.assertEqual(TYPES.get_type(123), "BIGINT")
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: velocity-python
3
- Version: 0.0.145
3
+ Version: 0.0.146
4
4
  Summary: A rapid application development library for interfacing with data storage
5
5
  Author-email: Velocity Team <info@codeclubs.org>
6
6
  License-Expression: MIT
@@ -1,4 +1,4 @@
1
- velocity/__init__.py,sha256=YiAYR78munOqvA3RF6ToO4m1wnYR6ylUjBwWr1PHhjc,147
1
+ velocity/__init__.py,sha256=eTHbtySkoZwrKGo7-cejLeYBrazqLmWV7p4I7p5updo,147
2
2
  velocity/app/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
3
3
  velocity/app/invoices.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
4
4
  velocity/app/orders.py,sha256=fr1oTBjSFfyeMBUXRG06LV4jgwrlwYNL5mbEBleFwf0,6328
@@ -19,6 +19,7 @@ velocity/aws/handlers/response.py,sha256=s2Kw7yv5zAir1mEmfv6yBVIvRcRQ__xyryf1Srv
19
19
  velocity/aws/handlers/sqs_handler.py,sha256=azuV8DrFOh0hM13EnPzyYVBS-3fLe2fn9OPc4ho7sGc,3375
20
20
  velocity/aws/handlers/mixins/__init__.py,sha256=_zyEpsnKikF7D7X-F0GA4cyIrQ6wBq7k5j6Vhp17vaQ,623
21
21
  velocity/aws/handlers/mixins/activity_tracker.py,sha256=vyQ_8kpSprjzLoALDv7g2rVkfstn89Tbsg6Zb9GmVOk,6579
22
+ velocity/aws/handlers/mixins/aws_session_mixin.py,sha256=yTa2-n4zgv23wbW3uZUp-L4CUJy8vSL8IMMNjMlYFVg,6806
22
23
  velocity/aws/handlers/mixins/error_handler.py,sha256=uN2YF9v-3LzS3o_HdVpO-XMcPy3sS7SHjUg_LfbsG7Q,6803
23
24
  velocity/aws/handlers/mixins/legacy_mixin.py,sha256=_YhiPU-zzXQjGNSAKhoUwfTFlnczmU-3BkwNFqr0hYE,2117
24
25
  velocity/aws/handlers/mixins/standard_mixin.py,sha256=-wBX0PFlZAnxQsaMDEWr-xmU8TcRbQ4BZD3wmAKR2d0,2489
@@ -36,7 +37,7 @@ velocity/db/core/engine.py,sha256=mNlaFPruHO935phKPVrsxZprGYUvxW-zp2sBcBZ-KCg,20
36
37
  velocity/db/core/result.py,sha256=b0ie3yZAOj9S57x32uFFGKZ95zhImmZ0iXl0X1qYszc,12813
37
38
  velocity/db/core/row.py,sha256=yqxm03uEDy3oSbnkCtKyiqFdSqG3zXTq2HIHYKOvPY4,7291
38
39
  velocity/db/core/sequence.py,sha256=VMBc0ZjGnOaWTwKW6xMNTdP8rZ2umQ8ml4fHTTwuGq4,3904
39
- velocity/db/core/table.py,sha256=zzyVZA8azaxB0QPxNjGFUOEwhk62emAZvHhkFlnACyY,35450
40
+ velocity/db/core/table.py,sha256=1sgR0bKO-6aZ2tmv9P9TGck5WrBiGErYqqRFKKIObsI,39964
40
41
  velocity/db/core/transaction.py,sha256=unjmVkkfb7D8Wow6V8V8aLaxUZo316i--ksZxc4-I1Q,6613
41
42
  velocity/db/servers/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
42
43
  velocity/db/servers/tablehelper.py,sha256=Q48ObN5KD_U2sBP0GUcjaQjKeE4Hr351sPQirwQ0_1s,22163
@@ -48,28 +49,28 @@ velocity/db/servers/base/types.py,sha256=3LBxFCD35eeIsIqftpAJh0JjUVonDYemz2n6AMt
48
49
  velocity/db/servers/mysql/__init__.py,sha256=mASO5JB0xkzYngwx2X79yyKifYRqxIdfKFWutIHuw7k,2661
49
50
  velocity/db/servers/mysql/operators.py,sha256=wHmVSPxlPGbOdvQEmsfKhD25H8djovSbNcmacLHDVkI,1273
50
51
  velocity/db/servers/mysql/reserved.py,sha256=s-aFMwYJpZ_1FBcCMU8fOdhml2ET58-59ZnUm7iw5OU,3312
51
- velocity/db/servers/mysql/sql.py,sha256=_Ea84IJwILV4Mf70zM3r3PdFL2VHU2jWSCujFhiJlQM,22170
52
+ velocity/db/servers/mysql/sql.py,sha256=CxskGe86I-8idLNZmcG7IPqlc-BQM9cpJU6WS5KdCA0,22210
52
53
  velocity/db/servers/mysql/types.py,sha256=BMQf4TpsRo1JN-yOl1nSItTO-Juu2piSTNy5o_djBeM,3486
53
54
  velocity/db/servers/postgres/__init__.py,sha256=6YcTLXposmsrEaJgdUAM_QgD1TZDSILQrGcwWZ-dibk,2457
54
55
  velocity/db/servers/postgres/operators.py,sha256=y9k6enReeR5hJxU_lYYR2epoaw4qCxEqmYJJ5jjaVWA,1166
55
56
  velocity/db/servers/postgres/reserved.py,sha256=5tKLaqFV-HrWRj-nsrxl5KGbmeM3ukn_bPZK36XEu8M,3648
56
- velocity/db/servers/postgres/sql.py,sha256=qUzZE45pfvB4499W1UfpqvupKP1RuQyOrRmB1oJ6sJY,49791
57
+ velocity/db/servers/postgres/sql.py,sha256=oF0Bll75sTOrRQhBNo3dklRpUFhLixil4i09eVC9Y8Y,54450
57
58
  velocity/db/servers/postgres/types.py,sha256=W71x8iRx-IIJkQSjb29k-KGkqp-QS6SxB0BHYXd4k8w,6955
58
59
  velocity/db/servers/sqlite/__init__.py,sha256=EIx09YN1-Vm-4CXVcEf9DBgvd8FhIN9rEqIaSRrEcIk,2293
59
60
  velocity/db/servers/sqlite/operators.py,sha256=VzZgph8RrnHkIVqqWGqnJwcafgBzc_8ZQp-M8tMl-mw,1221
60
61
  velocity/db/servers/sqlite/reserved.py,sha256=4vOI06bjt8wg9KxdzDTF-iOd-ewY23NvSzthpdty2fA,1298
61
- velocity/db/servers/sqlite/sql.py,sha256=GD5m4oxvWTisfn-umnKtM0Qz8VUXTUnAqQWi9Uv2trQ,20908
62
+ velocity/db/servers/sqlite/sql.py,sha256=iAENHbN8mfVsQHoqnEppynVMP_PdqXJX8jZQDNzr0ro,20948
62
63
  velocity/db/servers/sqlite/types.py,sha256=jpCJeV25x4Iytf6D6GXgK3hVYFAAFV4WKJC-d-m4kdU,3102
63
64
  velocity/db/servers/sqlserver/__init__.py,sha256=LN8OycN7W8da_ZPRYnPQ-O3Bv_xjret9qV1ZCitZlOU,2708
64
65
  velocity/db/servers/sqlserver/operators.py,sha256=xK8_doDLssS38SRs1NoAI7XTO0-gNGMDS76nTVru4kE,1104
65
66
  velocity/db/servers/sqlserver/reserved.py,sha256=Gn5n9DjxcjM-7PsIZPYigD6XLvMAYGnz1IrPuN7Dp2Y,2120
66
- velocity/db/servers/sqlserver/sql.py,sha256=O57p267LZ6WcAhxRuLuzylqjfZvDzkRuwf8s-GgeWDo,26332
67
+ velocity/db/servers/sqlserver/sql.py,sha256=h4fnVuNWaQE2c2sEEkLSIlBGf3xZP-lDtwILhF2-g3c,26368
67
68
  velocity/db/servers/sqlserver/types.py,sha256=FAODYEO137m-WugpM89f9bQN9q6S2cjjUaz0a9gfE6M,3745
68
69
  velocity/db/tests/__init__.py,sha256=7-hilWb43cKnSnCeXcjFG-6LpziN5k443IpsIvuevP0,24
69
70
  velocity/db/tests/common_db_test.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
70
71
  velocity/db/tests/test_cursor_rowcount_fix.py,sha256=mZRL1SBb9Knh67CSFyvfwj_LAarE_ilfVwpQHW18Yy8,5507
71
72
  velocity/db/tests/test_db_utils.py,sha256=mSbEQXYKpWidX1FEnjrmt3q3K4ra0YTtQclrS46ufEE,8426
72
- velocity/db/tests/test_postgres.py,sha256=xp2gqsefCsvBZaG3ADT0lCPI-I2FbZeZ7GvXr77XvWc,9315
73
+ velocity/db/tests/test_postgres.py,sha256=NoBydNkGmXn8olXwva4C4sYV3cKzERd6Df0wHixxoyE,15554
73
74
  velocity/db/tests/test_postgres_unchanged.py,sha256=rNcy7S_HXazi_MjU8QjRZO4q8dULMeG4tg6eN-rPPz8,2998
74
75
  velocity/db/tests/test_process_error_robustness.py,sha256=CZr_co_o6PK7dejOr_gwdn0iKTzjWPTY5k-PwJ6oh9s,11361
75
76
  velocity/db/tests/test_result_caching.py,sha256=DgsGXWL4G79MZOslCjq_t8qtdhCcXkHjQqV5zsF6i6M,8960
@@ -121,8 +122,8 @@ velocity/misc/tests/test_merge.py,sha256=Vm5_jY5cVczw0hZF-3TYzmxFw81heJOJB-dvhCg
121
122
  velocity/misc/tests/test_oconv.py,sha256=fy4DwWGn_v486r2d_3ACpuBD-K1oOngNq1HJCGH7X-M,4694
122
123
  velocity/misc/tests/test_original_error.py,sha256=iWSd18tckOA54LoPQOGV5j9LAz2W-3_ZOwmyZ8-4YQc,1742
123
124
  velocity/misc/tests/test_timer.py,sha256=l9nrF84kHaFofvQYKInJmfoqC01wBhsUB18lVBgXCoo,2758
124
- velocity_python-0.0.145.dist-info/licenses/LICENSE,sha256=aoN245GG8s9oRUU89KNiGTU4_4OtnNmVi4hQeChg6rM,1076
125
- velocity_python-0.0.145.dist-info/METADATA,sha256=TIitIGC36flPGu3a3xOkNDb37UGmc45cpWgCQnsjOOs,34262
126
- velocity_python-0.0.145.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
127
- velocity_python-0.0.145.dist-info/top_level.txt,sha256=JW2vJPmodgdgSz7H6yoZvnxF8S3fTMIv-YJWCT1sNW0,9
128
- velocity_python-0.0.145.dist-info/RECORD,,
125
+ velocity_python-0.0.146.dist-info/licenses/LICENSE,sha256=aoN245GG8s9oRUU89KNiGTU4_4OtnNmVi4hQeChg6rM,1076
126
+ velocity_python-0.0.146.dist-info/METADATA,sha256=k6_Iu7vrWLlQQpgVFw09S7hAKJtLSzuUFAU-3BcM7V8,34262
127
+ velocity_python-0.0.146.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
128
+ velocity_python-0.0.146.dist-info/top_level.txt,sha256=JW2vJPmodgdgSz7H6yoZvnxF8S3fTMIv-YJWCT1sNW0,9
129
+ velocity_python-0.0.146.dist-info/RECORD,,