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

@@ -18,6 +18,18 @@ TableHelper.reserved = reserved_words
18
18
  TableHelper.operators = OPERATORS
19
19
 
20
20
 
21
+ system_fields = [
22
+ "sys_id",
23
+ "sys_created",
24
+ "sys_modified",
25
+ "sys_modified_by",
26
+ "sys_dirty",
27
+ "sys_table",
28
+ "sys_modified_count",
29
+ "description",
30
+ ]
31
+
32
+
21
33
  def quote(data):
22
34
  """Quote MySQL identifiers."""
23
35
  if isinstance(data, list):
@@ -361,12 +373,127 @@ class SQL(BaseSQLDialect):
361
373
 
362
374
  @classmethod
363
375
  def create_table(cls, name, columns=None, drop=False):
376
+ if not name or not isinstance(name, str):
377
+ raise ValueError("Table name must be a non-empty string")
378
+
379
+ columns = columns or {}
380
+ table_identifier = quote(name)
381
+ base_name = name.split(".")[-1].replace("`", "")
382
+ base_name_sql = base_name.replace("'", "''")
383
+ trigger_prefix = re.sub(r"[^0-9A-Za-z_]+", "_", f"cc_sysmod_{base_name}")
384
+
385
+ statements = []
364
386
  if drop:
365
- drop_sql = f"DROP TABLE IF EXISTS {quote(name)}"
366
- return drop_sql
367
-
368
- # Basic CREATE TABLE - would need more implementation
369
- return f"CREATE TABLE {quote(name)} (id INT PRIMARY KEY AUTO_INCREMENT)"
387
+ statements.append(f"DROP TABLE IF EXISTS {table_identifier};")
388
+
389
+ statements.append(
390
+ f"""
391
+ CREATE TABLE {table_identifier} (
392
+ `sys_id` BIGINT NOT NULL AUTO_INCREMENT,
393
+ `sys_table` TEXT,
394
+ `sys_created` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
395
+ `sys_modified` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
396
+ `sys_modified_by` TEXT,
397
+ `sys_modified_count` INT NOT NULL DEFAULT 0,
398
+ `sys_dirty` TINYINT(1) NOT NULL DEFAULT 0,
399
+ `description` TEXT,
400
+ PRIMARY KEY (`sys_id`)
401
+ ) ENGINE=InnoDB;
402
+ """.strip()
403
+ )
404
+
405
+ for key, val in columns.items():
406
+ clean_key = re.sub("<>!=%", "", key)
407
+ if clean_key in system_fields:
408
+ continue
409
+ col_type = TYPES.get_type(val)
410
+ statements.append(
411
+ f"ALTER TABLE {table_identifier} ADD COLUMN {quote(clean_key)} {col_type};"
412
+ )
413
+
414
+ statements.extend(
415
+ [
416
+ f"DROP TRIGGER IF EXISTS {trigger_prefix}_bi;",
417
+ f"DROP TRIGGER IF EXISTS {trigger_prefix}_bu;",
418
+ f"""
419
+ CREATE TRIGGER {trigger_prefix}_bi
420
+ BEFORE INSERT ON {table_identifier}
421
+ FOR EACH ROW
422
+ BEGIN
423
+ SET NEW.sys_created = COALESCE(NEW.sys_created, NOW());
424
+ SET NEW.sys_modified = NOW();
425
+ SET NEW.sys_modified_count = 0;
426
+ SET NEW.sys_dirty = IFNULL(NEW.sys_dirty, 0);
427
+ SET NEW.sys_table = '{base_name_sql}';
428
+ END;
429
+ """.strip(),
430
+ f"""
431
+ CREATE TRIGGER {trigger_prefix}_bu
432
+ BEFORE UPDATE ON {table_identifier}
433
+ FOR EACH ROW
434
+ BEGIN
435
+ IF OLD.sys_dirty = TRUE AND NEW.sys_dirty = FALSE THEN
436
+ SET NEW.sys_dirty = 0;
437
+ SET NEW.sys_modified_count = IFNULL(OLD.sys_modified_count, 0);
438
+ ELSE
439
+ SET NEW.sys_dirty = 1;
440
+ SET NEW.sys_modified_count = IFNULL(OLD.sys_modified_count, 0) + 1;
441
+ END IF;
442
+ SET NEW.sys_created = OLD.sys_created;
443
+ SET NEW.sys_modified = NOW();
444
+ SET NEW.sys_table = '{base_name_sql}';
445
+ END;
446
+ """.strip(),
447
+ ]
448
+ )
449
+
450
+ return "\n".join(statements), tuple()
451
+
452
+ @classmethod
453
+ def ensure_sys_modified_count(cls, name, has_column=False, has_row_column=False):
454
+ """Ensure sys_modified_count column and associated triggers exist for the table."""
455
+ table_identifier = quote(name)
456
+ base_name = name.split(".")[-1].replace("`", "")
457
+ base_name_sql = base_name.replace("'", "''")
458
+ trigger_prefix = re.sub(r"[^0-9A-Za-z_]+", "_", f"cc_sysmod_{base_name}")
459
+
460
+ statements = [
461
+ f"ALTER TABLE {table_identifier} ADD COLUMN IF NOT EXISTS `sys_modified_count` INT NOT NULL DEFAULT 0;",
462
+ f"UPDATE {table_identifier} SET `sys_modified_count` = 0 WHERE `sys_modified_count` IS NULL;",
463
+ f"DROP TRIGGER IF EXISTS {trigger_prefix}_bi;",
464
+ f"DROP TRIGGER IF EXISTS {trigger_prefix}_bu;",
465
+ f"""
466
+ CREATE TRIGGER {trigger_prefix}_bi
467
+ BEFORE INSERT ON {table_identifier}
468
+ FOR EACH ROW
469
+ BEGIN
470
+ SET NEW.sys_created = COALESCE(NEW.sys_created, NOW());
471
+ SET NEW.sys_modified = NOW();
472
+ SET NEW.sys_modified_count = 0;
473
+ SET NEW.sys_dirty = IFNULL(NEW.sys_dirty, 0);
474
+ SET NEW.sys_table = '{base_name_sql}';
475
+ END;
476
+ """.strip(),
477
+ f"""
478
+ CREATE TRIGGER {trigger_prefix}_bu
479
+ BEFORE UPDATE ON {table_identifier}
480
+ FOR EACH ROW
481
+ BEGIN
482
+ IF OLD.sys_dirty = TRUE AND NEW.sys_dirty = FALSE THEN
483
+ SET NEW.sys_dirty = 0;
484
+ SET NEW.sys_modified_count = IFNULL(OLD.sys_modified_count, 0);
485
+ ELSE
486
+ SET NEW.sys_dirty = 1;
487
+ SET NEW.sys_modified_count = IFNULL(OLD.sys_modified_count, 0) + 1;
488
+ END IF;
489
+ SET NEW.sys_created = OLD.sys_created;
490
+ SET NEW.sys_modified = NOW();
491
+ SET NEW.sys_table = '{base_name_sql}';
492
+ END;
493
+ """.strip(),
494
+ ]
495
+
496
+ return "\n".join(statements), tuple()
370
497
 
371
498
  @classmethod
372
499
  def drop_table(cls, name):
@@ -18,39 +18,6 @@ TableHelper.reserved = reserved_words
18
18
  TableHelper.operators = OPERATORS
19
19
 
20
20
 
21
- def _get_table_helper(tx, table):
22
- """
23
- Utility function to create a TableHelper instance.
24
- Ensures consistent configuration across all SQL methods.
25
- """
26
- return TableHelper(tx, table)
27
-
28
-
29
- def _validate_table_name(table):
30
- """Validate table name format."""
31
- if not table or not isinstance(table, str):
32
- raise ValueError("Table name must be a non-empty string")
33
- # Add more validation as needed
34
- return table.strip()
35
-
36
-
37
- def _handle_predicate_errors(predicates, operation="WHERE"):
38
- """Process a list of predicates with error handling."""
39
- sql_parts = []
40
- vals = []
41
-
42
- for pred, val in predicates:
43
- sql_parts.append(pred)
44
- if val is None:
45
- pass
46
- elif isinstance(val, tuple):
47
- vals.extend(val)
48
- else:
49
- vals.append(val)
50
-
51
- return sql_parts, vals
52
-
53
-
54
21
  system_fields = [
55
22
  "sys_id",
56
23
  "sys_created",
@@ -143,7 +110,7 @@ class SQL(BaseSQLDialect):
143
110
  vals = []
144
111
 
145
112
  # Create table helper instance
146
- th = _get_table_helper(tx, table)
113
+ th = TableHelper(tx, table)
147
114
 
148
115
  # Handle columns and DISTINCT before aliasing
149
116
  if columns is None:
@@ -447,7 +414,7 @@ class SQL(BaseSQLDialect):
447
414
  if not isinstance(data, Mapping) or not data:
448
415
  raise ValueError("data must be a non-empty mapping of column-value pairs.")
449
416
 
450
- th = _get_table_helper(tx, table)
417
+ th = TableHelper(tx, table)
451
418
  set_clauses = []
452
419
  vals = []
453
420
 
@@ -595,51 +562,164 @@ class SQL(BaseSQLDialect):
595
562
 
596
563
  @classmethod
597
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
+
598
571
  if pk is None:
599
572
  pkeys = tx.table(table).primary_keys()
600
573
  if not pkeys:
601
574
  raise ValueError("Primary key required for merge.")
602
- # If there are multiple primary keys, use all of them
603
- if len(pkeys) > 1:
604
- pk = {pk: data[pk] for pk in pkeys}
605
- else:
606
- pk = {pkeys[0]: data[pkeys[0]]}
607
- # Remove primary keys from data; they will be used in the conflict target
608
- 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
+ )
609
590
 
610
- # Create a merged dictionary for insert (data + primary key columns)
611
- full_data = {}
612
- full_data.update(data)
613
- full_data.update(pk)
591
+ insert_data = dict(data)
592
+ insert_data.update(pk)
614
593
 
615
- sql, vals = cls.insert(table, full_data)
616
- sql = [sql]
617
- vals = list(vals) # Convert to a mutable list
594
+ update_data = {k: v for k, v in data.items() if k not in pk}
618
595
 
619
- if on_conflict_do_nothing != on_conflict_update:
620
- sql.append("ON CONFLICT")
621
- sql.append("(")
622
- sql.append(",".join(pk.keys()))
623
- sql.append(")")
624
- sql.append("DO")
625
- if on_conflict_do_nothing:
626
- sql.append("NOTHING")
627
- elif on_conflict_update:
628
- # Call update() with excluded=True to produce the SET clause for the upsert.
629
- sql_update, vals_update = cls.update(tx, table, data, pk, excluded=True)
630
- sql.append(sql_update)
631
- # Use list.extend to add the update values to vals.
632
- vals.extend(vals_update)
633
- 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:
634
602
  raise Exception(
635
603
  "Update on conflict must have one and only one option to complete on conflict."
636
604
  )
637
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
+
638
625
  import sqlparse
639
626
 
640
627
  final_sql = sqlparse.format(" ".join(sql), reindent=True, keyword_case="upper")
641
628
  return final_sql, tuple(vals)
642
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
+
643
723
  @classmethod
644
724
  def version(cls):
645
725
  return "select version()", tuple()
@@ -734,11 +814,13 @@ class SQL(BaseSQLDialect):
734
814
  f"""
735
815
  CREATE TABLE {fqtn} (
736
816
  sys_id BIGSERIAL PRIMARY KEY,
737
- sys_modified TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
738
817
  sys_created TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
739
- sys_modified_by TEXT,
818
+ sys_modified TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
819
+ sys_modified_by TEXT NOT NULL DEFAULT 'SYSTEM',
820
+ sys_modified_row TIMESTAMP NOT NULL DEFAULT CLOCK_TIMESTAMP(),
821
+ sys_modified_count INTEGER NOT NULL DEFAULT 0,
740
822
  sys_dirty BOOLEAN NOT NULL DEFAULT FALSE,
741
- sys_table TEXT,
823
+ sys_table TEXT NOT NULL,
742
824
  description TEXT
743
825
  );
744
826
 
@@ -747,19 +829,30 @@ class SQL(BaseSQLDialect):
747
829
  CREATE OR REPLACE FUNCTION {schema}.on_sys_modified()
748
830
  RETURNS TRIGGER AS
749
831
  $BODY$
750
- BEGIN
751
- -- update sys_modified on each insert/update.
752
- NEW.sys_modified := now();
753
- if (TG_OP = 'INSERT') THEN
754
- NEW.sys_created :=now();
755
- ELSEIF (TG_OP = 'UDPATE') THEN
756
- -- Do not allow sys_created to be modified.
757
- NEW.sys_created := OLD.sys_created;
832
+ BEGIN
833
+ IF (TG_OP = 'INSERT') THEN
834
+ NEW.sys_table := TG_TABLE_NAME;
835
+ NEW.sys_created := transaction_timestamp();
836
+ NEW.sys_modified := transaction_timestamp();
837
+ NEW.sys_modified_row := clock_timestamp();
838
+ NEW.sys_modified_count := 0;
839
+ ELSIF (TG_OP = 'UPDATE') THEN
840
+ NEW.sys_table := TG_TABLE_NAME;
841
+ NEW.sys_created := OLD.sys_created;
842
+ NEW.sys_modified_count := COALESCE(OLD.sys_modified_count, 0);
843
+ IF ROW(NEW.*) IS DISTINCT FROM ROW(OLD.*) THEN
844
+ IF OLD.sys_dirty IS TRUE AND NEW.sys_dirty IS FALSE THEN
845
+ NEW.sys_dirty := FALSE;
846
+ ELSE
847
+ NEW.sys_dirty := TRUE;
758
848
  END IF;
759
- -- Insert table name to row
760
- NEW.sys_table := TG_TABLE_NAME;
761
- RETURN NEW;
762
- END;
849
+ NEW.sys_modified := transaction_timestamp();
850
+ NEW.sys_modified_count := COALESCE(OLD.sys_modified_count, 0) + 1;
851
+ END IF;
852
+ END IF;
853
+
854
+ RETURN NEW;
855
+ END;
763
856
  $BODY$
764
857
  LANGUAGE plpgsql VOLATILE
765
858
  COST 100;
@@ -782,6 +875,88 @@ class SQL(BaseSQLDialect):
782
875
  sql = sqlparse.format(" ".join(sql), reindent=True, keyword_case="upper")
783
876
  return sql, tuple()
784
877
 
878
+ @classmethod
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."""
883
+ if "." in name:
884
+ schema_name, table_name = name.split(".", 1)
885
+ else:
886
+ schema_name = cls.default_schema
887
+ table_name = name
888
+
889
+ schema_identifier = TableHelper.quote(schema_name)
890
+ table_identifier = TableHelper.quote(table_name)
891
+ fqtn = f"{schema_identifier}.{table_identifier}"
892
+
893
+ trigger_name = (
894
+ f"on_update_row_{schema_name}_{table_name}"
895
+ .replace(".", "_")
896
+ .replace('"', "")
897
+ )
898
+ trigger_identifier = TableHelper.quote(trigger_name)
899
+ column_name = TableHelper.quote("sys_modified_count")
900
+ row_column_name = TableHelper.quote("sys_modified_row")
901
+
902
+ statements = []
903
+ if not has_column:
904
+ statements.append(
905
+ f"ALTER TABLE {fqtn} ADD COLUMN {column_name} INTEGER NOT NULL DEFAULT 0;"
906
+ )
907
+ if not has_row_column:
908
+ statements.append(
909
+ f"ALTER TABLE {fqtn} ADD COLUMN {row_column_name} TIMESTAMPTZ;"
910
+ )
911
+
912
+ statements.extend([
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());",
915
+ f"""
916
+ CREATE OR REPLACE FUNCTION {schema_identifier}.on_sys_modified()
917
+ RETURNS TRIGGER AS
918
+ $BODY$
919
+ BEGIN
920
+ IF (TG_OP = 'INSERT') THEN
921
+ NEW.sys_table := TG_TABLE_NAME;
922
+ NEW.sys_created := transaction_timestamp();
923
+ NEW.sys_modified := transaction_timestamp();
924
+ NEW.sys_modified_row := clock_timestamp();
925
+ NEW.sys_modified_count := 0;
926
+ ELSIF (TG_OP = 'UPDATE') THEN
927
+ NEW.sys_table := TG_TABLE_NAME;
928
+ NEW.sys_created := OLD.sys_created;
929
+ NEW.sys_modified_count := COALESCE(OLD.sys_modified_count, 0);
930
+ IF ROW(NEW.*) IS DISTINCT FROM ROW(OLD.*) THEN
931
+ IF OLD.sys_dirty IS TRUE AND NEW.sys_dirty IS FALSE THEN
932
+ NEW.sys_dirty := FALSE;
933
+ ELSE
934
+ NEW.sys_dirty := TRUE;
935
+ END IF;
936
+ NEW.sys_modified := transaction_timestamp();
937
+ NEW.sys_modified_row := clock_timestamp();
938
+ NEW.sys_modified_count := COALESCE(OLD.sys_modified_count, 0) + 1;
939
+ END IF;
940
+ END IF;
941
+ RETURN NEW;
942
+ END;
943
+ $BODY$
944
+ LANGUAGE plpgsql VOLATILE
945
+ COST 100;
946
+ """,
947
+ f"DROP TRIGGER IF EXISTS {trigger_identifier} ON {fqtn};",
948
+ f"""
949
+ CREATE TRIGGER {trigger_identifier}
950
+ BEFORE INSERT OR UPDATE ON {fqtn}
951
+ FOR EACH ROW EXECUTE PROCEDURE {schema_identifier}.on_sys_modified();
952
+ """,
953
+ ])
954
+
955
+ sql = sqlparse.format(
956
+ " ".join(statements), reindent=True, keyword_case="upper"
957
+ )
958
+ return sql, tuple()
959
+
785
960
  @classmethod
786
961
  def drop_table(cls, name):
787
962
  return f"drop table if exists {TableHelper.quote(name)} cascade;", tuple()