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

Files changed (33) hide show
  1. velocity/__init__.py +1 -1
  2. velocity/db/core/column.py +25 -105
  3. velocity/db/core/database.py +79 -23
  4. velocity/db/core/decorators.py +84 -47
  5. velocity/db/core/engine.py +179 -184
  6. velocity/db/core/result.py +94 -49
  7. velocity/db/core/row.py +81 -46
  8. velocity/db/core/sequence.py +112 -22
  9. velocity/db/core/table.py +660 -243
  10. velocity/db/core/transaction.py +75 -77
  11. velocity/db/servers/mysql.py +5 -237
  12. velocity/db/servers/mysql_reserved.py +237 -0
  13. velocity/db/servers/postgres/__init__.py +19 -0
  14. velocity/db/servers/postgres/operators.py +23 -0
  15. velocity/db/servers/postgres/reserved.py +254 -0
  16. velocity/db/servers/postgres/sql.py +1041 -0
  17. velocity/db/servers/postgres/types.py +109 -0
  18. velocity/db/servers/sqlite.py +1 -210
  19. velocity/db/servers/sqlite_reserved.py +208 -0
  20. velocity/db/servers/sqlserver.py +1 -316
  21. velocity/db/servers/sqlserver_reserved.py +314 -0
  22. velocity/db/servers/tablehelper.py +277 -0
  23. velocity/misc/conv/iconv.py +277 -91
  24. velocity/misc/conv/oconv.py +5 -4
  25. velocity/misc/db.py +2 -2
  26. velocity/misc/format.py +2 -2
  27. {velocity_python-0.0.34.dist-info → velocity_python-0.0.64.dist-info}/METADATA +6 -6
  28. velocity_python-0.0.64.dist-info/RECORD +47 -0
  29. {velocity_python-0.0.34.dist-info → velocity_python-0.0.64.dist-info}/WHEEL +1 -1
  30. velocity/db/servers/postgres.py +0 -1396
  31. velocity_python-0.0.34.dist-info/RECORD +0 -39
  32. {velocity_python-0.0.34.dist-info → velocity_python-0.0.64.dist-info}/LICENSE +0 -0
  33. {velocity_python-0.0.34.dist-info → velocity_python-0.0.64.dist-info}/top_level.txt +0 -0
velocity/db/core/table.py CHANGED
@@ -1,4 +1,4 @@
1
- # table.py
1
+ import sqlparse
2
2
  from velocity.db import exceptions
3
3
  from velocity.db.core.row import Row
4
4
  from velocity.db.core.result import Result
@@ -11,34 +11,34 @@ from velocity.db.core.decorators import (
11
11
 
12
12
 
13
13
  class Query:
14
+ """
15
+ A utility class to store raw SQL and parameters without immediately executing.
16
+ """
17
+
14
18
  def __init__(self, sql, params=()):
15
- self.sql = sql
16
- self.params = params
19
+ self.sql = sqlparse.format(sql, reindent=True, keyword_case="upper")
20
+ self.params = tuple(params)
17
21
 
18
22
  def __str__(self):
19
23
  return self.sql
20
24
 
21
25
 
22
- class Table(object):
26
+ class Table:
27
+ """
28
+ Provides an interface for performing CRUD and metadata operations on a DB table.
29
+ """
30
+
23
31
  def __init__(self, tx, name):
24
32
  self.tx = tx
25
- assert self.tx
26
33
  self.name = name.lower()
27
- assert self.name
28
34
  self.sql = tx.engine.sql
29
- assert self.sql
30
35
 
31
36
  def __str__(self):
32
- return """
33
- Table: %s
34
- (table exists) %s
35
- Columns: %s
36
- Rows: %s
37
- """ % (
38
- self.name,
39
- self.exists(),
40
- len(self.columns),
41
- len(self),
37
+ return (
38
+ f"Table: {self.name}\n"
39
+ f"(table exists) {self.exists()}\n"
40
+ f"Columns: {len(self.columns())}\n"
41
+ f"Rows: {len(self)}\n"
42
42
  )
43
43
 
44
44
  def __enter__(self):
@@ -49,12 +49,14 @@ class Table(object):
49
49
  self.close()
50
50
 
51
51
  def close(self):
52
+ """
53
+ Closes the internal cursor if we have one open.
54
+ """
52
55
  try:
53
56
  self._cursor.close()
54
57
  except:
55
58
  pass
56
59
 
57
- @property
58
60
  def cursor(self):
59
61
  try:
60
62
  return self._cursor
@@ -63,75 +65,128 @@ class Table(object):
63
65
  return self._cursor
64
66
 
65
67
  def __call__(self, where=None):
66
- sql, val = self.sql.select(table=self.name, where=where)
68
+ """
69
+ Generator: SELECT rows matching the `where` condition.
70
+ """
71
+ sql, val = self.sql.select(self.tx, table=self.name, where=where)
67
72
  for data in self.tx.execute(sql, val):
68
73
  yield self.row(data)
69
74
 
70
75
  def __iter__(self):
71
- sql, val = self.sql.select(table=self.name, orderby="sys_id")
76
+ """
77
+ Iterate over all rows in ascending order by sys_id.
78
+ """
79
+ sql, val = self.sql.select(self.tx, table=self.name, orderby="sys_id")
72
80
  for data in self.tx.execute(sql, val):
73
81
  yield self.row(data)
74
82
 
75
- @property
76
- def sys_columns(self):
83
+ def sys_columns(self, **kwds):
84
+ """
85
+ Returns the raw list of columns, possibly skipping any additional system columns logic.
86
+ """
77
87
  sql, vals = self.sql.columns(self.name)
78
- result = self.tx.execute(sql, vals, cursor=self.cursor)
88
+ if kwds.get("sql_only", False):
89
+ return sql, vals
90
+ result = self.tx.execute(sql, vals, cursor=self.cursor())
79
91
  return [x[0] for x in result.as_tuple()]
80
92
 
81
- @property
82
93
  def columns(self):
83
- columns = []
84
- for column in self.sys_columns:
85
- if "sys_" not in column:
86
- columns.append(column)
87
- return columns
94
+ """
95
+ Returns column names, excluding columns that start with 'sys_'.
96
+ """
97
+ return [col for col in self.sys_columns() if not col.startswith("sys_")]
88
98
 
89
99
  @return_default(None, (exceptions.DbObjectExistsError,))
90
- def create_index(self, columns, unique=False, direction=None, where=None, **kwds):
100
+ def create_index(
101
+ self, columns, unique=False, direction=None, where=None, lower=None, **kwds
102
+ ):
103
+ """
104
+ Creates an index on the given columns. Returns None on success, or `None` if the index already exists.
105
+ If the object already exists, this function will return without raising an error, but the exisitng
106
+ index may have been created with different parameters.
107
+ """
91
108
  sql, vals = self.sql.create_index(
92
- self.name, columns, unique, direction, where, tbl=self, **kwds
109
+ self.tx,
110
+ table=self.name,
111
+ columns=columns,
112
+ unique=unique,
113
+ direction=direction,
114
+ where=where,
115
+ lower=lower,
93
116
  )
94
- self.tx.execute(sql, vals, cursor=self.cursor)
117
+ if kwds.get("sql_only", False):
118
+ return sql, vals
119
+ self.tx.execute(sql, vals, cursor=self.cursor())
95
120
 
96
121
  @return_default(None)
97
122
  def drop_index(self, columns, **kwds):
98
- sql, vals = self.sql.drop_index(self.name, columns, **kwds)
99
- self.tx.execute(sql, vals, cursor=self.cursor)
123
+ """
124
+ Drops an index for the specified columns.
125
+ """
126
+ sql, vals = self.sql.drop_index(self.name, columns)
127
+ if kwds.get("sql_only", False):
128
+ return sql, vals
129
+ self.tx.execute(sql, vals, cursor=self.cursor())
100
130
 
101
131
  @return_default(None)
102
132
  def drop_column(self, column):
133
+ """
134
+ Drops a column from this table.
135
+ """
103
136
  sql, vals = self.sql.drop_column(self.name, column)
104
- self.tx.execute(sql, vals, cursor=self.cursor)
105
-
106
- def create(self, columns={}, drop=False):
137
+ self.tx.execute(sql, vals, cursor=self.cursor())
138
+
139
+ def create(self, columns=None, drop=False):
140
+ """
141
+ Creates this table with system columns plus the given `columns` dictionary.
142
+ Optionally drops any existing table first.
143
+ """
144
+ columns = columns or {}
107
145
  sql, vals = self.sql.create_table(self.name, columns, drop)
108
- self.tx.execute(sql, vals, cursor=self.cursor)
146
+ self.tx.execute(sql, vals, cursor=self.cursor())
109
147
 
110
148
  def drop(self):
149
+ """
150
+ Drops this table if it exists.
151
+ """
111
152
  sql, vals = self.sql.drop_table(self.name)
112
- self.tx.execute(sql, vals, cursor=self.cursor)
153
+ self.tx.execute(sql, vals, cursor=self.cursor())
113
154
 
114
155
  def exists(self):
156
+ """
157
+ Returns True if this table already exists in the DB.
158
+ """
115
159
  sql, vals = self.sql.tables()
116
- result = self.tx.execute(sql, vals, cursor=self.cursor)
160
+ result = self.tx.execute(sql, vals, cursor=self.cursor())
117
161
  if "." in self.name:
118
- return bool(self.name in ["%s.%s" % x for x in result.as_tuple()])
119
- else:
120
- return bool(self.name in ["%s" % x[1] for x in result.as_tuple()])
162
+ return self.name in [f"{x[0]}.{x[1]}" for x in result.as_tuple()]
163
+ return self.name in [x[1] for x in result.as_tuple()]
121
164
 
122
165
  def column(self, name):
166
+ """
167
+ Returns a Column object for the given column name.
168
+ """
123
169
  return Column(self, name)
124
170
 
125
171
  def row(self, key=None, lock=None):
126
- if key == None:
172
+ """
173
+ Retrieves a Row instance for the given primary key or conditions dict. If `key` is None, returns a new row.
174
+ """
175
+ if key is None:
127
176
  return self.new(lock=lock)
128
- return Row(self, key)
177
+ return Row(self, key, lock=lock)
129
178
 
130
179
  def dict(self, key):
131
- row = self.find(key)
132
- return row.to_dict() if row else {}
180
+ """
181
+ Returns a row as a dictionary or empty dict if not found.
182
+ """
183
+ r = self.find(key)
184
+ return r.to_dict() if r else {}
133
185
 
134
186
  def rows(self, where=None, orderby=None, qty=None, lock=None, skip_locked=None):
187
+ """
188
+ Generator that yields Row objects matching `where`, up to `qty`.
189
+ """
135
190
  for key in self.ids(
136
191
  where=where, orderby=orderby, qty=qty, lock=lock, skip_locked=skip_locked
137
192
  ):
@@ -148,7 +203,10 @@ class Table(object):
148
203
  lock=None,
149
204
  skip_locked=None,
150
205
  ):
151
- for key in self.select(
206
+ """
207
+ Returns a generator of sys_id values for rows matching `where`.
208
+ """
209
+ results = self.select(
152
210
  "sys_id",
153
211
  where=where,
154
212
  orderby=orderby,
@@ -158,63 +216,76 @@ class Table(object):
158
216
  qty=qty,
159
217
  lock=lock,
160
218
  skip_locked=skip_locked,
161
- ):
219
+ )
220
+ for key in results:
162
221
  yield key["sys_id"]
163
222
 
164
223
  def set_id(self, start):
224
+ """
225
+ Sets the sequence for this table's sys_id to the given start value.
226
+ """
165
227
  sql, vals = self.sql.set_id(self.name, start)
166
- result = self.tx.execute(sql, vals, cursor=self.cursor)
167
-
168
- def new(self, data={"sys_modified": "@@CURRENT_TIMESTAMP"}, lock=None):
228
+ self.tx.execute(sql, vals, cursor=self.cursor())
229
+
230
+ def new(self, data=None, lock=None):
231
+ """
232
+ Inserts a new row with the given data and returns a Row object. If data is None, sets sys_modified automatically.
233
+ """
234
+ if data is None:
235
+ data = {"sys_modified": "@@CURRENT_TIMESTAMP"}
169
236
  if len(data) == 1 and "sys_id" in data:
170
237
  return self.row(data, lock=lock).touch()
171
- val = self.insert(data)
238
+ self.insert(data)
172
239
  sql, vals = self.sql.last_id(self.name)
173
240
  sys_id = self.tx.execute(sql, vals).scalar()
174
241
  return self.row(sys_id, lock=lock)
175
242
 
176
243
  def get(self, where, lock=None, use_where=False):
244
+ """
245
+ Gets or creates a row matching `where`. If multiple rows match, raises DuplicateRowsFoundError.
246
+ If none match, a new row is created with the non-operator aspects of `where`.
247
+ """
177
248
  if where is None:
178
- raise Exception("None is not allowed as a primary key")
249
+ raise Exception("None is not allowed as a primary key.")
179
250
  if isinstance(where, int):
180
251
  where = {"sys_id": where}
181
252
  result = self.select("sys_id", where=where, lock=lock).all()
182
253
  if len(result) > 1:
183
- sql = self.selectSQL("sys_id", where=where, lock=lock)
254
+ sql = self.select("sys_id", sql_only=True, where=where, lock=lock)
184
255
  raise exceptions.DuplicateRowsFoundError(
185
- "More than one entry found. {}".format(sql)
256
+ f"More than one entry found. {sql}"
186
257
  )
187
- elif len(result) < 1:
188
- where = where.copy()
189
- keys = list(where.keys())
190
- for key in keys:
191
- chars = set("<>!=%")
192
- if any((c in chars) for c in key):
193
- where.pop(key)
194
- return self.new(where, lock=lock)
258
+ if not result:
259
+ new_data = where.copy()
260
+ for k in list(new_data.keys()):
261
+ if set("<>!=%").intersection(k):
262
+ new_data.pop(k)
263
+ return self.new(new_data, lock=lock)
195
264
  if use_where:
196
265
  return Row(self, where, lock=lock)
197
- else:
198
- return Row(self, result[0]["sys_id"], lock=lock)
266
+ return Row(self, result[0]["sys_id"], lock=lock)
199
267
 
200
268
  @return_default(None)
201
269
  def find(self, where, lock=None, use_where=False):
270
+ """
271
+ Finds a single row matching `where`, or returns None if none found.
272
+ Raises DuplicateRowsFoundError if multiple rows match.
273
+ """
202
274
  if where is None:
203
- raise Exception("None is not allowed as a primary key")
275
+ raise Exception("None is not allowed as a primary key.")
204
276
  if isinstance(where, int):
205
277
  where = {"sys_id": where}
206
278
  result = self.select("sys_id", where=where, lock=lock).all()
279
+ if not result:
280
+ return None
207
281
  if len(result) > 1:
208
- sql = self.selectSQL("sys_id", where=where, lock=lock)
282
+ sql = self.select("sys_id", sql_only=True, where=where, lock=lock)
209
283
  raise exceptions.DuplicateRowsFoundError(
210
- "More than one entry found. {}".format(sql)
284
+ f"More than one entry found. {sql}"
211
285
  )
212
- elif len(result) < 1:
213
- return None
214
286
  if use_where:
215
287
  return Row(self, where, lock=lock)
216
- else:
217
- return Row(self, result[0]["sys_id"], lock=lock)
288
+ return Row(self, result[0]["sys_id"], lock=lock)
218
289
 
219
290
  @return_default(None)
220
291
  def first(
@@ -226,185 +297,222 @@ class Table(object):
226
297
  skip_locked=None,
227
298
  use_where=False,
228
299
  ):
300
+ """
301
+ Finds the first matching row (by `orderby`) or creates one if `create_new=True` and none found.
302
+ """
229
303
  if where is None:
230
- raise Exception("None is not allowed as a where clause")
304
+ raise Exception("None is not allowed as a where clause.")
231
305
  if isinstance(where, int):
232
306
  where = {"sys_id": where}
233
- result = self.select(
307
+ results = self.select(
234
308
  "sys_id", where=where, orderby=orderby, skip_locked=skip_locked
235
309
  ).all()
236
- if len(result) < 1:
310
+ if not results:
237
311
  if create_new:
238
- where = where.copy()
239
- keys = list(where.keys())
240
- for key in keys:
241
- chars = set("<>!=%")
242
- if any((c in chars) for c in key):
243
- where.pop(key)
244
- return self.new(where, lock=lock)
312
+ new_data = where.copy()
313
+ for k in list(new_data.keys()):
314
+ if set("<>!=%").intersection(k):
315
+ new_data.pop(k)
316
+ return self.new(new_data, lock=lock)
245
317
  return None
246
318
  if use_where:
247
319
  return Row(self, where, lock=lock)
248
- else:
249
- return Row(self, result[0]["sys_id"], lock=lock)
320
+ return Row(self, results[0]["sys_id"], lock=lock)
250
321
 
251
322
  @return_default(None)
252
323
  def one(self, where=None, orderby=None, lock=None, use_where=False):
324
+ """
325
+ Returns exactly one row matching `where`, or None if none found.
326
+ """
253
327
  if isinstance(where, int):
254
328
  where = {"sys_id": where}
255
- result = self.select("sys_id", where=where, orderby=orderby).all()
256
- if len(result) < 1:
329
+ results = self.select("sys_id", where=where, orderby=orderby).all()
330
+ if not results:
257
331
  return None
258
332
  if use_where:
259
333
  return Row(self, where, lock=lock)
260
- else:
261
- return Row(self, result[0]["sys_id"], lock=lock)
334
+ return Row(self, results[0]["sys_id"], lock=lock)
262
335
 
263
- @property
264
336
  def primary_keys(self):
337
+ """
338
+ Returns the list of primary key columns for this table.
339
+ """
265
340
  sql, vals = self.sql.primary_keys(self.name)
266
- result = self.tx.execute(sql, vals, cursor=self.cursor)
341
+ result = self.tx.execute(sql, vals, cursor=self.cursor())
267
342
  return [x[0] for x in result.as_tuple()]
268
343
 
269
- @property
270
344
  def foreign_keys(self):
345
+ """
346
+ Returns the list of foreign key columns for this table (may be incomplete).
347
+ """
271
348
  sql, vals = self.sql.primary_keys(self.name)
272
- result = self.tx.execute(sql, vals, cursor=self.cursor)
349
+ result = self.tx.execute(sql, vals, cursor=self.cursor())
273
350
  return [x[0] for x in result.as_tuple()]
274
351
 
275
- def foreign_key_info(self, column):
352
+ def foreign_key_info(self, column, **kwds):
353
+ """
354
+ Returns info about a foreign key for the specified column.
355
+ """
276
356
  sql, vals = self.sql.foreign_key_info(table=self.name, column=column)
277
- return self.tx.execute(sql, vals, cursor=self.cursor).one()
357
+ if kwds.get("sql_only", False):
358
+ return sql, vals
359
+ return self.tx.execute(sql, vals, cursor=self.cursor()).one()
278
360
 
279
361
  @return_default()
280
- def create_foreign_key(self, columns, key_to_table, key_to_columns="sys_id"):
362
+ def create_foreign_key(
363
+ self, columns, key_to_table, key_to_columns="sys_id", **kwds
364
+ ):
365
+ """
366
+ Creates a foreign key referencing `key_to_table(key_to_columns)`.
367
+ """
281
368
  sql, vals = self.sql.create_foreign_key(
282
369
  self.name, columns, key_to_table, key_to_columns
283
370
  )
284
- return self.tx.execute(sql, vals, cursor=self.cursor)
285
-
286
- def drop_foreign_key(self, columns, key_to_table, key_to_columns="sys_id"):
371
+ if kwds.get("sql_only", False):
372
+ return sql, vals
373
+ return self.tx.execute(sql, vals, cursor=self.cursor())
374
+
375
+ def drop_foreign_key(self, columns, key_to_table, key_to_columns="sys_id", **kwds):
376
+ """
377
+ Drops the specified foreign key constraint.
378
+ """
287
379
  sql, vals = self.sql.create_foreign_key(
288
380
  self.name, columns, key_to_table, key_to_columns
289
381
  )
290
- return self.tx.execute(sql, vals, cursor=self.cursor)
291
-
292
- def rename(self, name):
382
+ if kwds.get("sql_only", False):
383
+ return sql, vals
384
+ return self.tx.execute(sql, vals, cursor=self.cursor())
385
+
386
+ def rename(self, name, **kwds):
387
+ """
388
+ Renames this table.
389
+ """
293
390
  sql, vals = self.sql.rename_table(self.name, name)
294
- self.tx.execute(sql, vals, cursor=self.cursor)
391
+ if kwds.get("sql_only", False):
392
+ return sql, vals
393
+ self.tx.execute(sql, vals, cursor=self.cursor())
295
394
  self.name = name
296
395
 
297
396
  def lower_keys(self, arg):
397
+ """
398
+ Returns a copy of the dict `arg` with all keys lowercased.
399
+ """
298
400
  new = {}
299
401
  if isinstance(arg, dict):
300
- for key in list(arg.keys()):
301
- new[key.lower()] = arg[key]
402
+ for key, val in arg.items():
403
+ new[key.lower()] = val
302
404
  return new
303
405
 
304
406
  @create_missing
305
- def alter(self, columns):
407
+ def alter(self, columns, **kwds):
408
+ """
409
+ Adds any missing columns (based on the provided dict) to the table.
410
+ """
411
+ if not isinstance(columns, dict):
412
+ raise Exception("Columns must be a dict.")
413
+ columns = self.lower_keys(columns)
306
414
  diff = []
307
- if isinstance(columns, dict):
308
- columns = self.lower_keys(columns)
309
- # Need to maintain order of keys
310
- for k in list(columns.keys()):
311
- diff.append(k) if k not in self.sys_columns else None
312
- else:
313
- raise Exception(
314
- "I don't know how to handle columns data type in this context"
315
- )
415
+ for k in columns.keys():
416
+ if k not in self.sys_columns():
417
+ diff.append(k)
316
418
  if diff:
317
- new = dict([(key, columns[key]) for key in diff])
318
- sql, vals = self.sql.alter_add(self.name, new)
319
- self.tx.execute(sql, vals, cursor=self.cursor)
419
+ newcols = {key: columns[key] for key in diff}
420
+ sql, vals = self.sql.alter_add(self.name, newcols)
421
+ if kwds.get("sql_only", False):
422
+ return sql, vals
423
+ self.tx.execute(sql, vals, cursor=self.cursor())
320
424
 
321
425
  @create_missing
322
- def alter_type(self, column, type_or_value, nullable=True):
426
+ def alter_type(self, column, type_or_value, nullable=True, **kwds):
427
+ """
428
+ Alters the specified column to match a new SQL type (inferred from `type_or_value`).
429
+ """
323
430
  sql, vals = self.sql.alter_column_by_type(
324
431
  self.name, column, type_or_value, nullable
325
432
  )
326
- self.tx.execute(sql, vals, cursor=self.cursor)
433
+ if kwds.get("sql_only", False):
434
+ return sql, vals
435
+ self.tx.execute(sql, vals, cursor=self.cursor())
327
436
 
328
437
  @create_missing
329
- def update(self, data, pk, left_join=None, inner_join=None, outer_join=None):
330
- sql, vals = self.sql.update(
331
- self.name, data, pk, left_join, inner_join, outer_join
332
- )
333
- result = self.tx.execute(sql, vals, cursor=self.cursor)
438
+ def update(self, data, where=None, pk=None, **kwds):
439
+ """
440
+ Performs an UPDATE of rows matching `where` or `pk` with `data`.
441
+ """
442
+ sql, vals = self.sql.update(self.tx, self.name, data, where, pk)
443
+ if kwds.get("sql_only", False):
444
+ return sql, vals
445
+ result = self.tx.execute(sql, vals, cursor=self.cursor())
334
446
  return result.cursor.rowcount
335
447
 
336
448
  @reset_id_on_dup_key
337
449
  @create_missing
338
- def insert(self, data):
450
+ def insert(self, data, **kwds):
451
+ """
452
+ Performs an INSERT of the given data into this table. Resets sys_id on duplicate keys if needed.
453
+ """
339
454
  sql, vals = self.sql.insert(self.name, data)
340
- result = self.tx.execute(sql, vals, cursor=self.cursor)
455
+ if kwds.get("sql_only", False):
456
+ return sql, vals
457
+ result = self.tx.execute(sql, vals, cursor=self.cursor())
341
458
  return result.cursor.rowcount
342
459
 
343
460
  @reset_id_on_dup_key
344
461
  @create_missing
345
- def merge(self, data, pk):
462
+ def merge(self, data, pk=None, **kwds):
463
+ """
464
+ Implements an UPSERT (merge) with conflict handling on pk columns.
465
+ """
346
466
  sql, vals = self.sql.merge(
347
- self.name, data, pk, on_conflict_do_nothing=False, on_conflict_update=True
467
+ self.tx,
468
+ self.name,
469
+ data,
470
+ pk,
471
+ on_conflict_do_nothing=False,
472
+ on_conflict_update=True,
348
473
  )
349
- result = self.tx.execute(sql, vals, cursor=self.cursor)
474
+ if kwds.get("sql_only", False):
475
+ return sql, vals
476
+ result = self.tx.execute(sql, vals, cursor=self.cursor())
350
477
  return result.cursor.rowcount
351
478
 
352
479
  upsert = merge
353
480
  indate = merge
354
481
 
355
- def mergeSQL(self, data, pk):
356
- return self.sql.merge(
357
- self.name, data, pk, on_conflict_do_nothing=False, on_conflict_update=True
358
- )
359
-
360
- def insertSQL(self, data):
361
- return self.sql.insert(self.name, data)
362
-
363
- def updateSQL(self, data, pk, left_join=None, inner_join=None, outer_join=None):
364
- return self.sql.update(self.name, data, pk, left_join, inner_join, outer_join)
365
-
366
482
  @return_default(0)
367
- def count(self, where=None):
368
- sql, vals = self.sql.select(columns="count(*)", table=self.name, where=where)
369
- return self.tx.execute(sql, vals, cursor=self.cursor).scalar()
370
-
371
- @return_default(0)
372
- def sum(self, column, where=None):
483
+ def count(self, where=None, **kwds):
484
+ """
485
+ Returns the count of rows matching `where`.
486
+ """
373
487
  sql, vals = self.sql.select(
374
- columns="coalesce(sum(coalesce({},0)),0)".format(column),
375
- table=self.name,
376
- where=where,
488
+ self.tx, columns="count(*)", table=self.name, where=where
377
489
  )
378
- return self.tx.execute(sql, vals, cursor=self.cursor).scalar()
490
+ if kwds.get("sql_only", False):
491
+ return sql, vals
492
+ return self.tx.execute(sql, vals, cursor=self.cursor()).scalar()
379
493
 
380
494
  @return_default(0)
381
- def __len__(self):
382
- sql, vals = self.sql.select(columns="count(*)", table=self.name)
383
- return self.tx.execute(sql, vals, cursor=self.cursor).scalar()
384
-
385
- @return_default(None)
386
- def oldest(self, where={}, field="sys_modified", columns="sys_id", lock=None):
495
+ def sum(self, column, where=None, **kwds):
496
+ """
497
+ Returns the sum of the given column across rows matching `where`.
498
+ """
387
499
  sql, vals = self.sql.select(
388
- columns=columns,
500
+ self.tx,
501
+ columns=f"coalesce(sum(coalesce({column},0)),0)",
389
502
  table=self.name,
390
503
  where=where,
391
- orderby=field + " asc",
392
- qty=1,
393
- lock=lock,
394
504
  )
395
- return self.tx.execute(sql, vals, cursor=self.cursor).scalar()
505
+ if kwds.get("sql_only", False):
506
+ return sql, vals
507
+ return self.tx.execute(sql, vals, cursor=self.cursor()).scalar()
396
508
 
397
- @return_default(None)
398
- def newest(self, where={}, field="sys_modified", columns="sys_id", lock=None):
399
- sql, vals = self.sql.select(
400
- columns=columns,
401
- table=self.name,
402
- where=where,
403
- orderby=field + " desc",
404
- qty=1,
405
- lock=lock,
406
- )
407
- return self.tx.execute(sql, vals, cursor=self.cursor).scalar()
509
+ @return_default(0)
510
+ def __len__(self):
511
+ """
512
+ Returns count of all rows in the table.
513
+ """
514
+ sql, vals = self.sql.select(self.tx, columns="count(*)", table=self.name)
515
+ return self.tx.execute(sql, vals, cursor=self.cursor()).scalar()
408
516
 
409
517
  @return_default(Result())
410
518
  def select(
@@ -418,8 +526,13 @@ class Table(object):
418
526
  qty=None,
419
527
  lock=None,
420
528
  skip_locked=None,
529
+ **kwds,
421
530
  ):
531
+ """
532
+ Performs a SELECT query, returning a Result object (defaults to as_dict() transform).
533
+ """
422
534
  sql, vals = self.sql.select(
535
+ self.tx,
423
536
  columns=columns,
424
537
  table=self.name,
425
538
  where=where,
@@ -428,41 +541,21 @@ class Table(object):
428
541
  having=having,
429
542
  start=start,
430
543
  qty=qty,
431
- tbl=self,
432
544
  lock=lock,
433
545
  skip_locked=skip_locked,
434
546
  )
547
+ if kwds.get("sql_only", False):
548
+ return sql, vals
435
549
  return self.tx.execute(sql, vals)
436
550
 
437
551
  def list(self, *args, **kwds):
552
+ """
553
+ Shortcut to run a SELECT and retrieve .all() in a single call.
554
+ """
555
+ if kwds.get("sql_only", False):
556
+ raise Exception("sql_only is not supported for list queries")
438
557
  return self.select(*args, **kwds).all()
439
558
 
440
- def selectSQL(
441
- self,
442
- columns=None,
443
- where=None,
444
- orderby=None,
445
- groupby=None,
446
- having=None,
447
- start=None,
448
- qty=None,
449
- lock=None,
450
- skip_locked=None,
451
- ):
452
- return self.sql.select(
453
- columns=columns,
454
- table=self.name,
455
- where=where,
456
- orderby=orderby,
457
- groupby=groupby,
458
- having=having,
459
- start=start,
460
- qty=qty,
461
- tbl=self,
462
- lock=lock,
463
- skip_locked=skip_locked,
464
- )
465
-
466
559
  def query(
467
560
  self,
468
561
  columns=None,
@@ -475,7 +568,11 @@ class Table(object):
475
568
  lock=None,
476
569
  skip_locked=None,
477
570
  ):
571
+ """
572
+ Returns a Query object suitable for usage in sub-queries, etc.
573
+ """
478
574
  sql, vals = self.sql.select(
575
+ self.tx,
479
576
  columns=columns,
480
577
  table=self.name,
481
578
  where=where,
@@ -484,14 +581,12 @@ class Table(object):
484
581
  having=having,
485
582
  start=start,
486
583
  qty=qty,
487
- tbl=self,
488
584
  lock=lock,
489
585
  skip_locked=skip_locked,
490
586
  )
491
587
  if vals:
492
- raise Exception(
493
- "a query generator does not support dictionary type as where clause"
494
- )
588
+ # Not supporting dictionary-based 'where' in raw Query usage.
589
+ raise Exception("A query generator does not support dictionary-type WHERE.")
495
590
  return Query(sql)
496
591
 
497
592
  @return_default(Result())
@@ -506,8 +601,13 @@ class Table(object):
506
601
  qty=None,
507
602
  lock=None,
508
603
  skip_locked=None,
604
+ **kwds,
509
605
  ):
606
+ """
607
+ Similar to select(), but calls server_execute() instead of execute().
608
+ """
510
609
  sql, vals = self.sql.select(
610
+ self.tx,
511
611
  columns=columns,
512
612
  table=self.name,
513
613
  where=where,
@@ -516,14 +616,20 @@ class Table(object):
516
616
  having=having,
517
617
  start=start,
518
618
  qty=qty,
519
- tbl=self,
520
619
  lock=lock,
521
620
  skip_locked=skip_locked,
522
621
  )
622
+ if kwds.get("sql_only", False):
623
+ return sql, vals
523
624
  return self.tx.server_execute(sql, vals)
524
625
 
525
626
  @return_default(Result())
526
627
  def batch(self, size=100, *args, **kwds):
628
+ """
629
+ Generator that yields batches of rows (lists) of size `size`.
630
+ """
631
+ if kwds.get("sql_only", False):
632
+ raise Exception("sql_only is not supported for batch queries")
527
633
  current = 0
528
634
  while True:
529
635
  kwds["start"] = current
@@ -536,93 +642,404 @@ class Table(object):
536
642
  raise StopIteration
537
643
 
538
644
  def get_value(self, key, pk):
645
+ """
646
+ Returns a single scalar value of `key` for the row matching `pk`.
647
+ """
539
648
  return self.select(columns=key, where=pk).scalar()
540
649
 
541
650
  @return_default({})
542
- def get_row(self, where, lock=None):
651
+ def get_row(self, where, lock=None, **kwds):
652
+ """
653
+ Retrieves a single row as dict or an empty dict if none found.
654
+ """
543
655
  if not where:
544
656
  raise Exception("Unique key for the row to be retrieved is required.")
545
657
  sql, vals = self.sql.select(
546
- columns="*", table=self.name, where=where, lock=lock
658
+ self.tx, columns="*", table=self.name, where=where, lock=lock
547
659
  )
548
- return self.tx.execute(sql, vals, cursor=self.cursor).one()
549
-
550
- def delete(self, where):
660
+ if kwds.get("sql_only", False):
661
+ return sql, vals
662
+ return self.tx.execute(sql, vals, cursor=self.cursor()).one()
663
+
664
+ def delete(self, where, **kwds):
665
+ """
666
+ Deletes rows matching `where`. Raises Exception if `where` is falsy.
667
+ """
551
668
  if not where:
552
669
  raise Exception(
553
- "You just tried to delete an entire table. Use truncate instead."
670
+ "You just tried to delete an entire table. Use `truncate` instead."
554
671
  )
555
- sql, vals = self.sql.delete(table=self.name, where=where)
672
+ sql, vals = self.sql.delete(tx=self.tx, table=self.name, where=where)
673
+ if kwds.get("sql_only", False):
674
+ return sql, vals
556
675
  result = self.tx.execute(sql, vals)
557
676
  return result.cursor.rowcount
558
677
 
559
- def truncate(self):
678
+ def truncate(self, **kwds):
679
+ """
680
+ Truncates this table (removes all rows).
681
+ """
560
682
  sql, vals = self.sql.truncate(table=self.name)
683
+ if kwds.get("sql_only", False):
684
+ return sql, vals
561
685
  self.tx.execute(sql, vals)
562
686
 
563
- def duplicate_rows(self, columns=["sys_id"], where={}, group=False):
564
- sql, vals = self.sql.duplicate_rows(self.name, columns, where)
565
- for result in self.tx.execute(sql, vals):
566
- result.update(where)
567
- if group:
568
- yield self.tx.table(self.name).select(where=result).all()
569
- else:
570
- for row in self.tx.table(self.name).select(where=result):
571
- yield row
572
-
573
- def has_duplicates(self, columns=["sys_id"]):
574
- sql, vals = self.sql.duplicate_rows(self.name, columns)
575
- return bool([x for x in self.tx.execute(sql, vals)])
576
-
577
- def create_view(self, name, query, temp=False, silent=True):
687
+ def duplicate_rows(self, columns=None, where=None, orderby=None, **kwds):
688
+ """
689
+ Returns rows that have duplicates in the specified `columns`.
690
+ TBD: Move code to generate sql to the sql module. it should not be
691
+ here so different sql engines can use this function.
692
+ """
693
+ if not columns:
694
+ raise ValueError(
695
+ "You must specify at least one column to check for duplicates."
696
+ )
697
+ sql, vals = self.sql.select(
698
+ self.tx,
699
+ columns=columns,
700
+ table=self.name,
701
+ where=where,
702
+ groupby=columns,
703
+ having={">count(*)": 1},
704
+ )
705
+ if orderby:
706
+ orderby = [orderby] if isinstance(orderby, str) else orderby
707
+ else:
708
+ orderby = columns
709
+ subjoin = " AND ".join([f"t.{col} = dup.{col}" for col in columns])
710
+ ob = ", ".join(orderby)
711
+ final_sql = f"""
712
+ SELECT t.*
713
+ FROM {self.name} t
714
+ JOIN ({sql}) dup
715
+ ON {subjoin}
716
+ ORDER BY {ob}
717
+ """
718
+ if kwds.get("sql_only", False):
719
+ return final_sql, vals
720
+ return self.tx.execute(final_sql, vals)
721
+
722
+ def has_duplicates(self, columns=None, where=None, **kwds):
723
+ """
724
+ Returns True if there are duplicates in the specified columns, else False.
725
+ """
726
+ if not columns:
727
+ raise ValueError(
728
+ "You must specify at least one column to check for duplicates."
729
+ )
730
+ sql, vals = self.sql.select(
731
+ self.tx,
732
+ columns=["1"],
733
+ table=self.name,
734
+ where=where,
735
+ groupby=columns,
736
+ having={">count(*)": 1},
737
+ qty=1,
738
+ )
739
+ if kwds.get("sql_only", False):
740
+ return sql, vals
741
+ return bool(self.tx.execute(sql, vals).scalar())
742
+
743
+ def create_view(self, name, query, temp=False, silent=True, **kwds):
744
+ """
745
+ Creates (or replaces) a view.
746
+ """
578
747
  sql, vals = self.sql.create_view(
579
748
  name=name, query=query, temp=temp, silent=silent
580
749
  )
750
+ if kwds.get("sql_only", False):
751
+ return sql, vals
581
752
  return self.tx.execute(sql, vals)
582
753
 
583
- def drop_view(self, name, silent=True):
754
+ def drop_view(self, name, silent=True, **kwds):
755
+ """
756
+ Drops a view.
757
+ """
584
758
  sql, vals = self.sql.drop_view(name=name, silent=silent)
759
+ if kwds.get("sql_only", False):
760
+ return sql, vals
585
761
  return self.tx.execute(sql, vals)
586
762
 
587
- def alter_trigger(self, name="USER", state="ENABLE"):
763
+ def alter_trigger(self, name="USER", state="ENABLE", **kwds):
764
+ """
765
+ Alters a trigger's state on this table.
766
+ """
588
767
  sql, vals = self.sql.alter_trigger(table=self.name, state=state, name=name)
768
+ if kwds.get("sql_only", False):
769
+ return sql, vals
589
770
  return self.tx.execute(sql, vals)
590
771
 
591
- def rename_column(self, orig, new):
772
+ def rename_column(self, orig, new, **kwds):
773
+ """
774
+ Renames a column in this table.
775
+ """
592
776
  sql, vals = self.sql.rename_column(table=self.name, orig=orig, new=new)
777
+ if kwds.get("sql_only", False):
778
+ return sql, vals
593
779
  return self.tx.execute(sql, vals)
594
780
 
595
- def set_sequence(self, next_value=1000):
781
+ def set_sequence(self, next_value=1000, **kwds):
782
+ """
783
+ Sets the next value of the table's sys_id sequence.
784
+ """
596
785
  sql, vals = self.sql.set_sequence(table=self.name, next_value=next_value)
786
+ if kwds.get("sql_only", False):
787
+ return sql, vals
597
788
  return self.tx.execute(sql, vals).scalar()
598
789
 
599
- def get_sequence(self):
790
+ def get_sequence(self, **kwds):
791
+ """
792
+ Returns the current value of the table's sys_id sequence.
793
+ """
600
794
  sql, vals = self.sql.current_id(table=self.name)
795
+ if kwds.get("sql_only", False):
796
+ return sql, vals
601
797
  return self.tx.execute(sql, vals).scalar()
602
798
 
603
- def missing(self, list, column="sys_id", where=None):
799
+ def missing(self, list_, column="sys_id", where=None, **kwds):
800
+ """
801
+ Given a list of IDs, returns which ones are missing from this table's `column` (defaults to sys_id).
802
+ """
604
803
  sql, vals = self.sql.missing(
605
- table=self.name, list=list, column=column, where=where
804
+ tx=self.tx, table=self.name, list=list_, column=column, where=where
606
805
  )
806
+ if kwds.get("sql_only", False):
807
+ return sql, vals
607
808
  return self.tx.execute(sql, vals).as_simple_list().all()
608
809
 
609
- def lock(self, mode="ACCESS EXCLUSIVE", wait_for_lock=None):
610
- sql = f"""LOCK TABLE {self.name} IN {mode} MODE"""
810
+ def lock(self, mode="ACCESS EXCLUSIVE", wait_for_lock=None, **kwds):
811
+ """
812
+ Issues a LOCK TABLE statement for this table.
813
+ TBD: MOve SQL To sql module so we can use this function with other engines.
814
+ """
815
+ sql = f"LOCK TABLE {self.name} IN {mode} MODE"
611
816
  if not wait_for_lock:
612
817
  sql += " NOWAIT"
613
818
  vals = None
819
+ if kwds.get("sql_only", False):
820
+ return sql, vals
614
821
  return self.tx.execute(sql, vals)
615
822
 
616
823
  @return_default(0)
617
- def max(self, column, where=None):
824
+ def max(self, column, where=None, **kwds):
825
+ """
826
+ Returns the MAX() of the specified column.
827
+ """
618
828
  sql, vals = self.sql.select(
619
- columns="max({})".format(column), table=self.name, where=where
829
+ self.tx, columns=f"max({column})", table=self.name, where=where
620
830
  )
621
- return self.tx.execute(sql, vals, cursor=self.cursor).scalar()
831
+ if kwds.get("sql_only", False):
832
+ return sql, vals
833
+ return self.tx.execute(sql, vals, cursor=self.cursor()).scalar()
622
834
 
623
835
  @return_default(0)
624
- def min(self, column, where=None):
836
+ def min(self, column, where=None, **kwds):
837
+ """
838
+ Returns the MIN() of the specified column.
839
+ """
625
840
  sql, vals = self.sql.select(
626
- columns="min({})".format(column), table=self.name, where=where
841
+ self.tx, columns=f"min({column})", table=self.name, where=where
627
842
  )
628
- return self.tx.execute(sql, vals, cursor=self.cursor).scalar()
843
+ if kwds.get("sql_only", False):
844
+ return sql, vals
845
+ return self.tx.execute(sql, vals, cursor=self.cursor()).scalar()
846
+
847
+ @return_default(None)
848
+ def newest(self, where=None, **kwds):
849
+ """
850
+ Returns the row with the highest sys_created value.
851
+ """
852
+ return self.first(where=where, orderby="sys_modified DESC, sys_id DESC")
853
+
854
+ @return_default(None)
855
+ def oldest(self, where=None, **kwds):
856
+ """
857
+ Returns the row with the lowest sys_created value.
858
+ """
859
+ return self.first(where=where, orderby="sys_modified ASC, sys_id ASC")
860
+
861
+ def indexes(self, **kwds):
862
+ """
863
+ Returns detailed information about all indexes on this table.
864
+ """
865
+ sql, vals = self.sql.indexes(self.name)
866
+ if kwds.get("sql_only", False):
867
+ return sql, vals
868
+ return self.tx.execute(sql, vals, cursor=self.cursor())
869
+
870
+ def compare_rows(self, pk1, pk2):
871
+ """
872
+ Compares two rows in the given table identified by their primary keys.
873
+
874
+ Parameters:
875
+ table (Table): An instance of the Table class.
876
+ pk1: Primary key of the first row.
877
+ pk2: Primary key of the second row.
878
+
879
+ Returns:
880
+ A string that lists differences between the two rows. If no differences,
881
+ returns "Rows are identical."
882
+ """
883
+ # Retrieve rows based on primary keys.
884
+ data1 = self.row(pk1).to_dict()
885
+ data2 = self.row(pk2).to_dict()
886
+
887
+ differences = []
888
+
889
+ # Iterate through each column in the table (ignoring system columns).
890
+ for col in self.columns():
891
+ val1 = data1.get(col)
892
+ val2 = data2.get(col)
893
+
894
+ if val1 != val2:
895
+ differences.append(f"{col}: {val1} vs {val2}")
896
+
897
+ # Return a descriptive string of differences.
898
+ if differences:
899
+ differences.insert(0, f"Comparing {self.name}: {pk1} vs {pk2}")
900
+ differences.insert(0, f"--------------------------------------")
901
+ differences.append(f"--------------------------------------")
902
+ return "\n".join(differences)
903
+ else:
904
+ return f"{self.name} rows {pk1} and {pk2} are identical."
905
+
906
+ def interactive_merge_rows(self, left_pk, right_pk):
907
+ """
908
+ Interactively merge two rows from this table.
909
+
910
+ For each non-primary key column where the two rows differ, you'll be prompted to choose
911
+ whether you want the left or right value. After all choices are made, the merged row
912
+ is displayed for confirmation. If confirmed, the left row is updated with the chosen values.
913
+ Optionally, you can also choose to delete the right row.
914
+
915
+ Parameters:
916
+ left_pk: Primary key of the left (destination) row.
917
+ right_pk: Primary key of the right (source) row.
918
+ """
919
+ # Retrieve both rows and convert to dictionaries
920
+ left_row = self.row(left_pk)
921
+ right_row = self.row(right_pk)
922
+
923
+ left_data = left_row.to_dict()
924
+ right_data = right_row.to_dict()
925
+
926
+ # Get primary key columns to skip them in the merge.
927
+ pk_columns = self.primary_keys()
928
+
929
+ merged_values = {}
930
+
931
+ print("Comparing rows:\n")
932
+ # Iterate through all non-primary key columns.
933
+ for col in self.columns():
934
+ if col in pk_columns:
935
+ continue # Do not compare or merge primary key columns.
936
+
937
+ left_val = left_data.get(col)
938
+ right_val = right_data.get(col)
939
+
940
+ # If values are the same, simply use one of them.
941
+ if left_val == right_val:
942
+ merged_values[col] = left_val
943
+ else:
944
+ print(f"Column: '{col}'")
945
+ print(f" Left value: `{left_val}` Right value: `{right_val}`")
946
+ choice = None
947
+ # Prompt until a valid choice is provided.
948
+ while choice not in ("L", "R"):
949
+ choice = (
950
+ input("Choose which value to use (L for left, R for right): ")
951
+ .strip()
952
+ .upper()
953
+ )
954
+ merged_values[col] = left_val if choice == "L" else right_val
955
+ print("") # Blank line for readability.
956
+
957
+ # Display the merged row preview.
958
+ print("\nThe merged row will be:")
959
+ for col, value in merged_values.items():
960
+ print(f" {col}: {value}")
961
+
962
+ # Final confirmation before applying changes.
963
+ confirm = (
964
+ input("\nApply these changes and merge the rows? (y/n): ").strip().lower()
965
+ )
966
+ # Optionally, ask if the right row should be deleted.
967
+ delete_right = (
968
+ input("Do you want to delete the right row? (y/n): ").strip().lower()
969
+ )
970
+
971
+ if confirm != "y":
972
+ print("Merge cancelled. No changes made.")
973
+ return
974
+
975
+ if delete_right == "y":
976
+ self.delete(where={"sys_id": right_pk})
977
+ print("Right row deleted.")
978
+
979
+ # Update the left row with the merged values.
980
+ left_row.update(merged_values)
981
+ print("Merge applied: Left row updated with merged values.")
982
+ print("Merge completed.")
983
+
984
+ def find_duplicates(self, columns, sql_only=False):
985
+ """
986
+ Returns duplicate groups from the table based on the specified columns in a case-insensitive way.
987
+
988
+ For each column, the subquery computes:
989
+ - lower(column) AS normalized_<column>
990
+ - array_agg(column) AS variations_<column>
991
+ - array_agg(sys_id) AS sys_ids
992
+ - COUNT(*) AS total_count
993
+
994
+ The subquery groups by lower(column) values and retains only groups with more than one row.
995
+
996
+ Example SQL for a single column "email_address":
997
+
998
+ SELECT
999
+ lower(email_address) AS normalized_email_address,
1000
+ array_agg(email_address) AS variations_email_address,
1001
+ array_agg(sys_id) AS sys_ids,
1002
+ COUNT(*) AS total_count
1003
+ FROM donor_users
1004
+ GROUP BY lower(email_address)
1005
+ HAVING COUNT(*) > 1
1006
+
1007
+ Parameters:
1008
+ columns (list or str): Column name or list of column names to check duplicates on.
1009
+ sql_only (bool): If True, returns the SQL string and an empty tuple; otherwise,
1010
+ executes the query using self.tx.execute.
1011
+
1012
+ Returns:
1013
+ A tuple of (SQL string, parameters) if sql_only is True, otherwise the result of executing the query.
1014
+ """
1015
+ if not columns:
1016
+ raise ValueError(
1017
+ "You must specify at least one column to check for duplicates."
1018
+ )
1019
+
1020
+ if isinstance(columns, str):
1021
+ columns = [columns]
1022
+
1023
+ # Build subquery SELECT clause parts for normalized values and variations.
1024
+ normalized_cols = [f"lower({col}) AS normalized_{col}" for col in columns]
1025
+ variations_cols = [f"array_agg({col}) AS variations_{col}" for col in columns]
1026
+
1027
+ subquery_select = ",\n ".join(
1028
+ normalized_cols
1029
+ + variations_cols
1030
+ + ["array_agg(sys_id) AS sys_ids", "COUNT(*) AS total_count"]
1031
+ )
1032
+
1033
+ groupby_clause = ", ".join([f"lower({col})" for col in columns])
1034
+
1035
+ subquery = f"""
1036
+ SELECT
1037
+ {subquery_select}
1038
+ FROM {self.name}
1039
+ GROUP BY {groupby_clause}
1040
+ HAVING COUNT(*) > 1
1041
+ """
1042
+
1043
+ if sql_only:
1044
+ return subquery, ()
1045
+ return self.tx.execute(subquery, ())