velocity-python 0.0.66__tar.gz → 0.0.68__tar.gz

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 (65) hide show
  1. {velocity_python-0.0.66 → velocity_python-0.0.68}/PKG-INFO +1 -1
  2. {velocity_python-0.0.66 → velocity_python-0.0.68}/pyproject.toml +1 -1
  3. {velocity_python-0.0.66 → velocity_python-0.0.68}/src/velocity/__init__.py +1 -1
  4. {velocity_python-0.0.66 → velocity_python-0.0.68}/src/velocity/db/core/transaction.py +1 -0
  5. velocity_python-0.0.68/src/velocity/db/servers/postgres/__init__.py +19 -0
  6. {velocity_python-0.0.66 → velocity_python-0.0.68}/src/velocity/db/servers/postgres/sql.py +139 -90
  7. {velocity_python-0.0.66 → velocity_python-0.0.68}/src/velocity/db/servers/tablehelper.py +45 -14
  8. velocity_python-0.0.68/src/velocity/misc/tools.py +39 -0
  9. {velocity_python-0.0.66 → velocity_python-0.0.68}/src/velocity_python.egg-info/PKG-INFO +1 -1
  10. {velocity_python-0.0.66 → velocity_python-0.0.68}/src/velocity_python.egg-info/SOURCES.txt +1 -0
  11. velocity_python-0.0.66/src/velocity/db/servers/postgres/__init__.py +0 -19
  12. {velocity_python-0.0.66 → velocity_python-0.0.68}/LICENSE +0 -0
  13. {velocity_python-0.0.66 → velocity_python-0.0.68}/README.md +0 -0
  14. {velocity_python-0.0.66 → velocity_python-0.0.68}/setup.cfg +0 -0
  15. {velocity_python-0.0.66 → velocity_python-0.0.68}/src/velocity/aws/__init__.py +0 -0
  16. {velocity_python-0.0.66 → velocity_python-0.0.68}/src/velocity/aws/handlers/__init__.py +0 -0
  17. {velocity_python-0.0.66 → velocity_python-0.0.68}/src/velocity/aws/handlers/context.py +0 -0
  18. {velocity_python-0.0.66 → velocity_python-0.0.68}/src/velocity/aws/handlers/lambda_handler.py +0 -0
  19. {velocity_python-0.0.66 → velocity_python-0.0.68}/src/velocity/aws/handlers/response.py +0 -0
  20. {velocity_python-0.0.66 → velocity_python-0.0.68}/src/velocity/aws/handlers/sqs_handler.py +0 -0
  21. {velocity_python-0.0.66 → velocity_python-0.0.68}/src/velocity/db/__init__.py +0 -0
  22. {velocity_python-0.0.66 → velocity_python-0.0.68}/src/velocity/db/core/__init__.py +0 -0
  23. {velocity_python-0.0.66 → velocity_python-0.0.68}/src/velocity/db/core/column.py +0 -0
  24. {velocity_python-0.0.66 → velocity_python-0.0.68}/src/velocity/db/core/database.py +0 -0
  25. {velocity_python-0.0.66 → velocity_python-0.0.68}/src/velocity/db/core/decorators.py +0 -0
  26. {velocity_python-0.0.66 → velocity_python-0.0.68}/src/velocity/db/core/engine.py +0 -0
  27. {velocity_python-0.0.66 → velocity_python-0.0.68}/src/velocity/db/core/exceptions.py +0 -0
  28. {velocity_python-0.0.66 → velocity_python-0.0.68}/src/velocity/db/core/result.py +0 -0
  29. {velocity_python-0.0.66 → velocity_python-0.0.68}/src/velocity/db/core/row.py +0 -0
  30. {velocity_python-0.0.66 → velocity_python-0.0.68}/src/velocity/db/core/sequence.py +0 -0
  31. {velocity_python-0.0.66 → velocity_python-0.0.68}/src/velocity/db/core/table.py +0 -0
  32. {velocity_python-0.0.66 → velocity_python-0.0.68}/src/velocity/db/servers/__init__.py +0 -0
  33. {velocity_python-0.0.66 → velocity_python-0.0.68}/src/velocity/db/servers/mysql.py +0 -0
  34. {velocity_python-0.0.66 → velocity_python-0.0.68}/src/velocity/db/servers/mysql_reserved.py +0 -0
  35. {velocity_python-0.0.66 → velocity_python-0.0.68}/src/velocity/db/servers/postgres/operators.py +0 -0
  36. {velocity_python-0.0.66 → velocity_python-0.0.68}/src/velocity/db/servers/postgres/reserved.py +0 -0
  37. {velocity_python-0.0.66 → velocity_python-0.0.68}/src/velocity/db/servers/postgres/types.py +0 -0
  38. {velocity_python-0.0.66 → velocity_python-0.0.68}/src/velocity/db/servers/sqlite.py +0 -0
  39. {velocity_python-0.0.66 → velocity_python-0.0.68}/src/velocity/db/servers/sqlite_reserved.py +0 -0
  40. {velocity_python-0.0.66 → velocity_python-0.0.68}/src/velocity/db/servers/sqlserver.py +0 -0
  41. {velocity_python-0.0.66 → velocity_python-0.0.68}/src/velocity/db/servers/sqlserver_reserved.py +0 -0
  42. {velocity_python-0.0.66 → velocity_python-0.0.68}/src/velocity/misc/__init__.py +0 -0
  43. {velocity_python-0.0.66 → velocity_python-0.0.68}/src/velocity/misc/conv/__init__.py +0 -0
  44. {velocity_python-0.0.66 → velocity_python-0.0.68}/src/velocity/misc/conv/iconv.py +0 -0
  45. {velocity_python-0.0.66 → velocity_python-0.0.68}/src/velocity/misc/conv/oconv.py +0 -0
  46. {velocity_python-0.0.66 → velocity_python-0.0.68}/src/velocity/misc/db.py +0 -0
  47. {velocity_python-0.0.66 → velocity_python-0.0.68}/src/velocity/misc/export.py +0 -0
  48. {velocity_python-0.0.66 → velocity_python-0.0.68}/src/velocity/misc/format.py +0 -0
  49. {velocity_python-0.0.66 → velocity_python-0.0.68}/src/velocity/misc/mail.py +0 -0
  50. {velocity_python-0.0.66 → velocity_python-0.0.68}/src/velocity/misc/merge.py +0 -0
  51. {velocity_python-0.0.66 → velocity_python-0.0.68}/src/velocity/misc/timer.py +0 -0
  52. {velocity_python-0.0.66 → velocity_python-0.0.68}/src/velocity_python.egg-info/dependency_links.txt +0 -0
  53. {velocity_python-0.0.66 → velocity_python-0.0.68}/src/velocity_python.egg-info/requires.txt +0 -0
  54. {velocity_python-0.0.66 → velocity_python-0.0.68}/src/velocity_python.egg-info/top_level.txt +0 -0
  55. {velocity_python-0.0.66 → velocity_python-0.0.68}/tests/test_db.py +0 -0
  56. {velocity_python-0.0.66 → velocity_python-0.0.68}/tests/test_email_processing.py +0 -0
  57. {velocity_python-0.0.66 → velocity_python-0.0.68}/tests/test_format.py +0 -0
  58. {velocity_python-0.0.66 → velocity_python-0.0.68}/tests/test_iconv.py +0 -0
  59. {velocity_python-0.0.66 → velocity_python-0.0.68}/tests/test_merge.py +0 -0
  60. {velocity_python-0.0.66 → velocity_python-0.0.68}/tests/test_oconv.py +0 -0
  61. {velocity_python-0.0.66 → velocity_python-0.0.68}/tests/test_postgres.py +0 -0
  62. {velocity_python-0.0.66 → velocity_python-0.0.68}/tests/test_response.py +0 -0
  63. {velocity_python-0.0.66 → velocity_python-0.0.68}/tests/test_spreadsheet_functions.py +0 -0
  64. {velocity_python-0.0.66 → velocity_python-0.0.68}/tests/test_sql_builder.py +0 -0
  65. {velocity_python-0.0.66 → velocity_python-0.0.68}/tests/test_timer.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: velocity-python
3
- Version: 0.0.66
3
+ Version: 0.0.68
4
4
  Summary: A rapid application development library for interfacing with data storage
5
5
  Author-email: Paul Perez <pperez@codeclubs.org>
6
6
  Project-URL: Homepage, https://codeclubs.org/projects/velocity
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "velocity-python"
3
- version = "0.0.66"
3
+ version = "0.0.68"
4
4
  authors = [
5
5
  { name="Paul Perez", email="pperez@codeclubs.org" },
6
6
  ]
@@ -1,4 +1,4 @@
1
- __version__ = version = "0.0.66"
1
+ __version__ = version = "0.0.68"
2
2
 
3
3
  from . import aws
4
4
  from . import db
@@ -77,6 +77,7 @@ class Transaction:
77
77
 
78
78
  try:
79
79
  if parms:
80
+ # print(f"*** {id(self)} --> transaction.execute({sql}, {parms})")
80
81
  cursor.execute(sql, parms)
81
82
  else:
82
83
  cursor.execute(sql)
@@ -0,0 +1,19 @@
1
+ import os
2
+ import psycopg2
3
+ from .sql import SQL
4
+ from velocity.db.core import engine
5
+
6
+
7
+ def initialize(config=None, **kwargs):
8
+ if not config:
9
+ # Keep the default config inside this function.
10
+ config = {
11
+ "database": os.environ["DBDatabase"],
12
+ "host": os.environ["DBHost"],
13
+ "port": os.environ["DBPort"],
14
+ "user": os.environ["DBUser"],
15
+ "password": os.environ["DBPassword"],
16
+ }
17
+ config.update(kwargs)
18
+ print(config)
19
+ return engine.Engine(psycopg2, config, SQL)
@@ -1,7 +1,7 @@
1
1
  import re
2
2
  import hashlib
3
3
  import sqlparse
4
-
4
+ from psycopg2 import sql
5
5
 
6
6
  from velocity.db.core import exceptions
7
7
 
@@ -285,6 +285,20 @@ class SQL:
285
285
 
286
286
  @classmethod
287
287
  def update(cls, tx, table, data, where=None, pk=None, excluded=False):
288
+ """
289
+ Generate a Postgres UPDATE statement, handling the WHERE clause logic similar
290
+ to how the SELECT statement does. If you want to do an ON CONFLICT ... DO UPDATE,
291
+ that logic should generally live in `merge(...)` rather than here.
292
+
293
+ :param tx: Database/transaction context object (used by TableHelper)
294
+ :param table: Table name
295
+ :param data: Dictionary of columns to update
296
+ :param where: WHERE clause conditions (dict, list of tuples, or string)
297
+ :param pk: Primary key dict to merge with `where`
298
+ :param excluded: If True, creates `col = EXCLUDED.col` expressions (used in upsert)
299
+ :return: (sql_string, params_tuple)
300
+ """
301
+
288
302
  if not table:
289
303
  raise ValueError("Table name is required.")
290
304
  if not pk and not where:
@@ -293,102 +307,110 @@ class SQL:
293
307
  raise ValueError("data must be a non-empty mapping of column-value pairs.")
294
308
 
295
309
  th = TableHelper(tx, table)
296
-
297
310
  set_clauses = []
298
311
  vals = []
299
312
 
313
+ # Merge pk into where if pk is provided
300
314
  if pk:
301
315
  if where:
302
- where.update(pk)
316
+ # If where is a dict, update it; otherwise raise error
317
+ if isinstance(where, Mapping):
318
+ where = dict(where) # copy to avoid mutation
319
+ where.update(pk)
320
+ else:
321
+ raise ValueError(
322
+ "Cannot combine 'pk' with a non-dict 'where' clause."
323
+ )
303
324
  else:
304
325
  where = pk
305
326
 
306
- # Handle data columns (SET clause)
327
+ # Build SET clauses
307
328
  for col, val in data.items():
308
- col = th.resolve_references(
329
+ col_quoted = th.resolve_references(
309
330
  col, options={"alias_column": False, "alias_table": False}
310
331
  )
311
-
312
- # Normal column
313
332
  if excluded:
314
- set_clauses.append(f"{col} = EXCLUDED.{col}")
333
+ # For ON CONFLICT DO UPDATE statements, use the EXCLUDED value
334
+ set_clauses.append(f"{col_quoted} = EXCLUDED.{col_quoted}")
315
335
  else:
316
- set_clauses.append(f"{col} = %s")
336
+ set_clauses.append(f"{col_quoted} = %s")
317
337
  vals.append(val)
318
338
 
319
- # Extract the final where conditions and values
339
+ # Build WHERE clauses for a normal update (ignored when excluded is True)
320
340
  where_clauses = []
321
341
  if not excluded:
322
- # First handle user-provided WHERE conditions
323
- if isinstance(where, Mapping):
324
- for key, val in where.items():
325
- col, value = th.make_predicate(key, val)
326
- where_clauses.append(col)
327
- if isinstance(value, tuple):
328
- vals.extend(value)
329
- else:
330
- vals.append(value)
331
-
332
- # Final assembly of SQL
333
- sql = []
334
- sql.append("UPDATE")
335
- if not excluded:
336
- if th.foreign_keys:
337
- sql.append(
338
- f"{th.quote(table)} AS {th.quote(th.get_table_alias('current_table'))}"
342
+ if where:
343
+ if isinstance(where, Mapping):
344
+ new_where = []
345
+ for key, val in where.items():
346
+ new_where.append(th.make_predicate(key, val))
347
+ where = new_where
348
+ if isinstance(where, str):
349
+ where_clauses.append(where)
350
+ else:
351
+ for pred, value in where:
352
+ where_clauses.append(pred)
353
+ if value is None:
354
+ pass
355
+ elif isinstance(value, tuple):
356
+ vals.extend(value)
357
+ else:
358
+ vals.append(value)
359
+ if not where_clauses:
360
+ raise ValueError(
361
+ "No WHERE clause could be constructed. Update would affect all rows."
339
362
  )
340
- else:
341
- sql.append(TableHelper.quote(table))
342
- sql.append("SET " + ", ".join(set_clauses))
343
- if not excluded:
344
- if th.foreign_keys:
345
- for key, ref_info in th.foreign_keys.items():
346
- ref_table = ref_info["ref_table"]
347
- sql.append(
348
- f"LEFT JOIN {th.quote(ref_table)} AS {th.quote(ref_info['alias'])} "
349
- )
350
- where_clauses.append(
351
- f"{th.quote(th.get_table_alias('current_table'))}.{th.quote(ref_info['local_column'])} = {th.quote(ref_info['alias'])}.{th.quote(ref_info['ref_column'])}"
352
- )
353
363
 
354
- if not excluded:
364
+ # Construct final SQL
365
+ if excluded:
366
+ # For an upsert's DO UPDATE, only return the SET clause (no table name, no WHERE)
367
+ sql_parts = []
368
+ sql_parts.append("UPDATE")
369
+ sql_parts.append("SET " + ", ".join(set_clauses))
370
+ final_sql = sqlparse.format(
371
+ " ".join(sql_parts), reindent=True, keyword_case="upper"
372
+ )
373
+ return final_sql, tuple(vals)
374
+ else:
375
+ sql_parts = []
376
+ sql_parts.append("UPDATE")
377
+ sql_parts.append(th.quote(table))
378
+ sql_parts.append("SET " + ", ".join(set_clauses))
355
379
  if where_clauses:
356
- sql.append("WHERE " + " AND ".join(where_clauses))
357
- else:
358
- # Without a WHERE, this updates all rows.
359
- # If this is not desired, raise an error.
360
- if not excluded:
361
- raise ValueError(
362
- "No WHERE clause could be constructed. Update would affect all rows."
363
- )
364
-
365
- # Final assembled query
366
- sql = sqlparse.format(" ".join(sql), reindent=True, keyword_case="upper")
367
- return sql, tuple(vals)
380
+ sql_parts.append("WHERE " + " AND ".join(where_clauses))
381
+ final_sql = sqlparse.format(
382
+ " ".join(sql_parts), reindent=True, keyword_case="upper"
383
+ )
384
+ return final_sql, tuple(vals)
368
385
 
369
386
  @classmethod
370
387
  def insert(cls, table, data):
388
+ """
389
+ Generate an INSERT statement.
390
+ """
391
+
371
392
  keys = []
372
- vals = []
393
+ vals_placeholders = []
373
394
  args = []
374
395
  for key, val in data.items():
375
396
  keys.append(TableHelper.quote(key.lower()))
376
397
  if isinstance(val, str) and len(val) > 2 and val[:2] == "@@" and val[2:]:
377
- vals.append(val[2:])
398
+ vals_placeholders.append(val[2:])
378
399
  else:
379
- vals.append("%s")
400
+ vals_placeholders.append("%s")
380
401
  args.append(val)
381
402
 
382
- sql = ["INSERT INTO"]
383
- sql.append(TableHelper.quote(table))
384
- sql.append("(")
385
- sql.append(",".join(keys))
386
- sql.append(")")
387
- sql.append("VALUES")
388
- sql.append("(")
389
- sql.append(",".join(vals))
390
- sql.append(")")
391
- sql = sqlparse.format(" ".join(sql), reindent=True, keyword_case="upper")
403
+ sql_parts = []
404
+ sql_parts.append("INSERT INTO")
405
+ sql_parts.append(TableHelper.quote(table))
406
+ sql_parts.append("(")
407
+ sql_parts.append(",".join(keys))
408
+ sql_parts.append(")")
409
+ sql_parts.append("VALUES")
410
+ sql_parts.append("(")
411
+ sql_parts.append(",".join(vals_placeholders))
412
+ sql_parts.append(")")
413
+ sql = sqlparse.format(" ".join(sql_parts), reindent=True, keyword_case="upper")
392
414
  return sql, tuple(args)
393
415
 
394
416
  @classmethod
@@ -397,21 +419,23 @@ class SQL:
397
419
  pkeys = tx.table(table).primary_keys()
398
420
  if not pkeys:
399
421
  raise ValueError("Primary key required for merge.")
400
- # If there are multiple primary keys, we need to use all of them
422
+ # If there are multiple primary keys, use all of them
401
423
  if len(pkeys) > 1:
402
424
  pk = {pk: data[pk] for pk in pkeys}
403
425
  else:
404
426
  pk = {pkeys[0]: data[pkeys[0]]}
405
- # remove primary key from data
427
+ # Remove primary keys from data; they will be used in the conflict target
406
428
  data = {k: v for k, v in data.items() if k not in pk}
407
429
 
430
+ # Create a merged dictionary for insert (data + primary key columns)
408
431
  full_data = {}
409
432
  full_data.update(data)
410
433
  full_data.update(pk)
411
434
 
412
435
  sql, vals = cls.insert(table, full_data)
413
436
  sql = [sql]
414
- vals = list(vals)
437
+ vals = list(vals) # Convert to a mutable list
438
+
415
439
  if on_conflict_do_nothing != on_conflict_update:
416
440
  sql.append("ON CONFLICT")
417
441
  sql.append("(")
@@ -421,15 +445,20 @@ class SQL:
421
445
  if on_conflict_do_nothing:
422
446
  sql.append("NOTHING")
423
447
  elif on_conflict_update:
424
- sql2, vals2 = cls.update(tx, table, data, pk, excluded=True)
425
- sql.append(sql2)
426
- vals.extend(vals2)
448
+ # Call update() with excluded=True to produce the SET clause for the upsert.
449
+ sql_update, vals_update = cls.update(tx, table, data, pk, excluded=True)
450
+ sql.append(sql_update)
451
+ # Use list.extend to add the update values to vals.
452
+ vals.extend(vals_update)
427
453
  else:
428
454
  raise Exception(
429
455
  "Update on conflict must have one and only one option to complete on conflict."
430
456
  )
431
- sql = sqlparse.format(" ".join(sql), reindent=True, keyword_case="upper")
432
- return sql, tuple(vals)
457
+
458
+ import sqlparse
459
+
460
+ final_sql = sqlparse.format(" ".join(sql), reindent=True, keyword_case="upper")
461
+ return final_sql, tuple(vals)
433
462
 
434
463
  @classmethod
435
464
  def version(cls):
@@ -513,9 +542,9 @@ class SQL:
513
542
  @classmethod
514
543
  def create_table(cls, name, columns={}, drop=False):
515
544
  if "." in name:
516
- fqtn = name
545
+ fqtn = TableHelper.quote(name)
517
546
  else:
518
- fqtn = f"public.{name}"
547
+ fqtn = f"public.{TableHelper.quote(name)}"
519
548
  schema, table = fqtn.split(".")
520
549
  name = fqtn.replace(".", "_")
521
550
  sql = []
@@ -563,7 +592,7 @@ class SQL:
563
592
  )
564
593
 
565
594
  for key, val in columns.items():
566
- key = re.sub("<>!=%", "", key.lower())
595
+ key = re.sub("<>!=%", "", key)
567
596
  if key in system_fields:
568
597
  continue
569
598
  sql.append(
@@ -797,7 +826,7 @@ class SQL:
797
826
  if "." not in table and schema:
798
827
  table = f"{schema}.{table}"
799
828
  if isinstance(columns, (list, set)):
800
- columns = ",".join([TableHelper.quote(c.lower()) for c in columns])
829
+ columns = ",".join([TableHelper.quote(c) for c in columns])
801
830
  else:
802
831
  columns = TableHelper.quote(columns)
803
832
  sql = ["CREATE"]
@@ -858,7 +887,7 @@ class SQL:
858
887
  if "." not in table and schema:
859
888
  table = f"{schema}.{table}"
860
889
  if isinstance(columns, (list, set)):
861
- columns = ",".join([TableHelper.quote(c.lower()) for c in columns])
890
+ columns = ",".join([TableHelper.quote(c) for c in columns])
862
891
  else:
863
892
  columns = TableHelper.quote(columns)
864
893
  sql = ["DROP"]
@@ -880,7 +909,7 @@ class SQL:
880
909
 
881
910
  @classmethod
882
911
  def massage_data(cls, data):
883
- data = {key.lower(): val for key, val in data.items()}
912
+ data = {key: val for key, val in data.items()}
884
913
  primaryKey = set(cls.GetPrimaryKeyColumnNames())
885
914
  if not primaryKey:
886
915
  if not cls.Exists():
@@ -895,23 +924,43 @@ class SQL:
895
924
 
896
925
  @classmethod
897
926
  def alter_add(cls, table, columns, null_allowed=True):
927
+ """
928
+ Modify the table to add new columns. If the `value` is 'now()', treat it as a
929
+ TIMESTAMP type (optionally with a DEFAULT now() clause).
930
+ """
898
931
  sql = []
899
- null = "NOT NULL" if not null_allowed else ""
932
+ null_clause = "NOT NULL" if not null_allowed else ""
933
+
900
934
  if isinstance(columns, dict):
901
- for key, val in columns.items():
902
- key = re.sub("<>!=%", "", key.lower())
903
- sql.append(
904
- f"ALTER TABLE {TableHelper.quote(table)} ADD {TableHelper.quote(key)} {TYPES.get_type(val)} {null};"
905
- )
906
- sql = sqlparse.format(" ".join(sql), reindent=True, keyword_case="upper")
907
- return sql, tuple()
935
+ for col_name, val in columns.items():
936
+ col_name_clean = re.sub("<>!=%", "", col_name)
937
+ # If the user wants 'now()' to be recognized as a TIMESTAMP column:
938
+ if isinstance(val, str) and val.strip().lower() == "@@now()":
939
+ # We assume the user wants the type to be TIMESTAMP
940
+ # Optionally we can also add `DEFAULT now()` if desired
941
+ # so that newly added rows use the current timestamp
942
+ col_type = "TIMESTAMP"
943
+ sql.append(
944
+ f"ALTER TABLE {TableHelper.quote(table)} "
945
+ f"ADD {TableHelper.quote(col_name_clean)} {col_type} {null_clause};"
946
+ )
947
+ else:
948
+ # Normal code path: rely on your `TYPES.get_type(...)` logic
949
+ col_type = TYPES.get_type(val)
950
+ sql.append(
951
+ f"ALTER TABLE {TableHelper.quote(table)} "
952
+ f"ADD {TableHelper.quote(col_name_clean)} {col_type} {null_clause};"
953
+ )
954
+
955
+ final_sql = sqlparse.format(" ".join(sql), reindent=True, keyword_case="upper")
956
+ return final_sql, tuple()
908
957
 
909
958
  @classmethod
910
959
  def alter_drop(cls, table, columns):
911
960
  sql = [f"ALTER TABLE {TableHelper.quote(table)} DROP COLUMN"]
912
961
  if isinstance(columns, dict):
913
962
  for key, val in columns.items():
914
- key = re.sub("<>!=%", "", key.lower())
963
+ key = re.sub("<>!=%", "", key)
915
964
  sql.append(f"{key},")
916
965
  if sql[-1][-1] == ",":
917
966
  sql[-1] = sql[-1][:-1]
@@ -963,7 +1012,7 @@ class SQL:
963
1012
 
964
1013
  @classmethod
965
1014
  def delete(cls, tx, table, where):
966
- sql = [f"DELETE FROM {table}"]
1015
+ sql = [f"DELETE FROM {TableHelper.quote(table)}"]
967
1016
  vals = []
968
1017
  if where:
969
1018
  s, v = TableHelper(tx, table).make_where(where)
@@ -141,27 +141,58 @@ class TableHelper:
141
141
 
142
142
  def extract_column_name(self, sql_expression):
143
143
  """
144
- Extracts the 'bare' column name from an expression, ignoring function calls, operators, etc.
144
+ Extracts the 'bare' column name from a SQL expression.
145
+
146
+ Supports:
147
+ - Aliases (AS ...)
148
+ - Window functions (OVER(... ORDER BY ...))
149
+ - CAST(... AS ...)
150
+ - CASE WHEN ... THEN ... ELSE ... END
151
+ - Nested function calls
152
+ - Grabs column from inside expressions (e.g. PLAID_ERROR from SUM(CASE...))
153
+
154
+ Args:
155
+ sql_expression (str): SQL expression (SELECT column) string.
156
+
157
+ Returns:
158
+ str or None: Extracted column name or None if undetectable.
145
159
  """
146
- expr = sql_expression.replace('"', "")
160
+ expr = sql_expression.replace('"', "").strip()
147
161
 
148
- # Remove any alias part (e.g., "as num_donors").
149
- # This regex removes any trailing alias defined using "as".
162
+ # Remove trailing alias
150
163
  expr = re.sub(r"(?i)\s+as\s+\w+$", "", expr).strip()
151
164
 
152
- expr = self.remove_operator(expr)
153
- if not self.are_parentheses_balanced(expr):
154
- raise ValueError(f"Unbalanced parentheses in expression: {expr}")
165
+ # If OVER clause: extract column inside ORDER BY
166
+ over_match = re.search(r"(?i)OVER\s*\(\s*ORDER\s+BY\s+([^\s,)]+)", expr)
167
+ if over_match:
168
+ return over_match.group(1)
169
+
170
+ # Remove CAST(... AS ...)
171
+ while re.search(r"(?i)CAST\s*\(([^()]+?)\s+AS\s+[^\)]+\)", expr):
172
+ expr = re.sub(r"(?i)CAST\s*\(([^()]+?)\s+AS\s+[^\)]+\)", r"\1", expr)
173
+
174
+ # Remove CASE WHEN ... THEN ... ELSE ... END, keep just the WHEN part
175
+ while re.search(
176
+ r"(?i)CASE\s+WHEN\s+(.+?)\s+THEN\s+.+?(?:\s+ELSE\s+.+?)?\s+END", expr
177
+ ):
178
+ expr = re.sub(
179
+ r"(?i)CASE\s+WHEN\s+(.+?)\s+THEN\s+.+?(?:\s+ELSE\s+.+?)?\s+END",
180
+ r"\1",
181
+ expr,
182
+ )
155
183
 
156
- # Remove outer function calls, preserving inside parentheses.
157
- while "(" in expr or ")" in expr:
158
- expr = re.sub(r"\b\w+\s*\((.*?)\)", r"\1", expr)
184
+ # Unwrap function calls (SUM(...), MAX(...), etc.)
185
+ while re.search(r"\b\w+\s*\(([^()]+)\)", expr):
186
+ expr = re.sub(r"\b\w+\s*\(([^()]+)\)", r"\1", expr)
159
187
 
188
+ # If multiple columns, take the first
160
189
  if "," in expr:
161
190
  expr = expr.split(",")[0].strip()
162
191
 
163
- pattern = r"^([a-zA-Z0-9_]+\.\*|\*|[a-zA-Z0-9_>]+(?:\.[a-zA-Z0-9_]+)?)$"
164
- match = re.search(pattern, expr)
192
+ # Extract column name (basic or dotted like table.col or *)
193
+ match = re.search(
194
+ r"\b([a-zA-Z_][\w]*\.\*|\*|[a-zA-Z_][\w]*(?:\.[a-zA-Z_][\w]*)?)\b", expr
195
+ )
165
196
  return match.group(1) if match else None
166
197
 
167
198
  def are_parentheses_balanced(self, expression):
@@ -212,8 +243,8 @@ class TableHelper:
212
243
  # Lists / tuples => IN / NOT IN
213
244
  if isinstance(val, (list, tuple)) and "><" not in key:
214
245
  if "!" in key:
215
- return f"{column} NOT IN %s", list(val)
216
- return f"{column} IN %s", list(val)
246
+ return f"{column} != ANY(%s)", list(val)
247
+ return f"{column} = ANY(%s)", list(val)
217
248
 
218
249
  # "@@" => pass as literal
219
250
  if isinstance(val, str) and val.startswith("@@") and val[2:]:
@@ -0,0 +1,39 @@
1
+ import os
2
+ import inspect
3
+ import hashlib
4
+
5
+
6
+ def run_once(func, *args, **kwargs):
7
+ """
8
+ Executes 'func(*args, **kwargs)' only once across script runs.
9
+
10
+ The sentinel file name is automatically generated based on the function's
11
+ module and name. If the function is a lambda, a stable hash of its bytecode
12
+ is used in place of a function name.
13
+ """
14
+
15
+ def _generate_sentinel_name(_func):
16
+ # Use __module__ to get the name of the module where _func is defined.
17
+ module_name = getattr(_func, "__module__", "unknown_module")
18
+ # Use __name__ to get the function name; for lambdas, this is "<lambda>"
19
+ function_name = getattr(_func, "__name__", "unknown_func")
20
+
21
+ if function_name == "<lambda>":
22
+ # For lambdas, generate a stable name from a short hash of the bytecode.
23
+ code_hash = hashlib.md5(_func.__code__.co_code).hexdigest()
24
+ function_name = f"lambda_{code_hash[:8]}"
25
+
26
+ return f".has_run_once_{module_name}_{function_name}"
27
+
28
+ # Derive the sentinel filename from the function’s identity
29
+ sentinel_file = _generate_sentinel_name(func)
30
+
31
+ # Check if the sentinel file exists
32
+ if os.path.exists(sentinel_file):
33
+ print(f"Code in '{func.__name__}' has already been run. Skipping.")
34
+ else:
35
+ # Execute the function for the first time
36
+ func(*args, **kwargs)
37
+ # Create an empty sentinel file to mark as "done"
38
+ with open(sentinel_file, "w") as f:
39
+ f.write("")
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: velocity-python
3
- Version: 0.0.66
3
+ Version: 0.0.68
4
4
  Summary: A rapid application development library for interfacing with data storage
5
5
  Author-email: Paul Perez <pperez@codeclubs.org>
6
6
  Project-URL: Homepage, https://codeclubs.org/projects/velocity
@@ -40,6 +40,7 @@ src/velocity/misc/format.py
40
40
  src/velocity/misc/mail.py
41
41
  src/velocity/misc/merge.py
42
42
  src/velocity/misc/timer.py
43
+ src/velocity/misc/tools.py
43
44
  src/velocity/misc/conv/__init__.py
44
45
  src/velocity/misc/conv/iconv.py
45
46
  src/velocity/misc/conv/oconv.py
@@ -1,19 +0,0 @@
1
- import os
2
- import psycopg2
3
- from .sql import SQL
4
- from velocity.db.core import engine
5
-
6
- default_config = {
7
- "database": os.environ["DBDatabase"],
8
- "host": os.environ["DBHost"],
9
- "port": os.environ["DBPort"],
10
- "user": os.environ["DBUser"],
11
- "password": os.environ["DBPassword"],
12
- }
13
-
14
-
15
- def initialize(config=None, **kwargs):
16
- if not config:
17
- config = default_config.copy()
18
- config.update(kwargs)
19
- return engine.Engine(psycopg2, config, SQL)