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.
- velocity/__init__.py +1 -1
- velocity/db/core/column.py +25 -105
- velocity/db/core/database.py +79 -23
- velocity/db/core/decorators.py +84 -47
- velocity/db/core/engine.py +179 -184
- velocity/db/core/result.py +94 -49
- velocity/db/core/row.py +81 -46
- velocity/db/core/sequence.py +112 -22
- velocity/db/core/table.py +660 -243
- velocity/db/core/transaction.py +75 -77
- velocity/db/servers/mysql.py +5 -237
- velocity/db/servers/mysql_reserved.py +237 -0
- velocity/db/servers/postgres/__init__.py +19 -0
- velocity/db/servers/postgres/operators.py +23 -0
- velocity/db/servers/postgres/reserved.py +254 -0
- velocity/db/servers/postgres/sql.py +1041 -0
- velocity/db/servers/postgres/types.py +109 -0
- velocity/db/servers/sqlite.py +1 -210
- velocity/db/servers/sqlite_reserved.py +208 -0
- velocity/db/servers/sqlserver.py +1 -316
- velocity/db/servers/sqlserver_reserved.py +314 -0
- velocity/db/servers/tablehelper.py +277 -0
- velocity/misc/conv/iconv.py +277 -91
- velocity/misc/conv/oconv.py +5 -4
- velocity/misc/db.py +2 -2
- velocity/misc/format.py +2 -2
- {velocity_python-0.0.34.dist-info → velocity_python-0.0.64.dist-info}/METADATA +6 -6
- velocity_python-0.0.64.dist-info/RECORD +47 -0
- {velocity_python-0.0.34.dist-info → velocity_python-0.0.64.dist-info}/WHEEL +1 -1
- velocity/db/servers/postgres.py +0 -1396
- velocity_python-0.0.34.dist-info/RECORD +0 -39
- {velocity_python-0.0.34.dist-info → velocity_python-0.0.64.dist-info}/LICENSE +0 -0
- {velocity_python-0.0.34.dist-info → velocity_python-0.0.64.dist-info}/top_level.txt +0 -0
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
import pprint
|
|
2
|
+
import re
|
|
3
|
+
from collections.abc import Mapping
|
|
4
|
+
from ..core.table import Query
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class TableHelper:
|
|
8
|
+
"""
|
|
9
|
+
A helper class used to build SQL queries with joined/aliased tables,
|
|
10
|
+
including foreign key expansions, pointer syntax, etc.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
reserved = []
|
|
14
|
+
operators = {}
|
|
15
|
+
|
|
16
|
+
def __init__(self, tx, table):
|
|
17
|
+
self.tx = tx
|
|
18
|
+
self.letter = 65
|
|
19
|
+
self.table_aliases = {}
|
|
20
|
+
self.foreign_keys = {}
|
|
21
|
+
self.current_table = table
|
|
22
|
+
self.table_aliases["current_table"] = chr(self.letter)
|
|
23
|
+
self.letter += 1
|
|
24
|
+
|
|
25
|
+
def __str__(self):
|
|
26
|
+
return "\n".join(
|
|
27
|
+
f"{key}: {pprint.pformat(value)}" for key, value in vars(self).items()
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
def split_columns(self, query):
|
|
31
|
+
"""
|
|
32
|
+
Splits a string of comma-separated column expressions into a list, keeping parentheses balanced.
|
|
33
|
+
"""
|
|
34
|
+
columns = []
|
|
35
|
+
balance = 0
|
|
36
|
+
current = []
|
|
37
|
+
for char in query:
|
|
38
|
+
if char == "," and balance == 0:
|
|
39
|
+
columns.append("".join(current).strip())
|
|
40
|
+
current = []
|
|
41
|
+
else:
|
|
42
|
+
if char == "(":
|
|
43
|
+
balance += 1
|
|
44
|
+
elif char == ")":
|
|
45
|
+
balance -= 1
|
|
46
|
+
current.append(char)
|
|
47
|
+
if current:
|
|
48
|
+
columns.append("".join(current).strip())
|
|
49
|
+
return columns
|
|
50
|
+
|
|
51
|
+
def requires_joins(self):
|
|
52
|
+
return len(self.table_aliases) > 1
|
|
53
|
+
|
|
54
|
+
def has_pointer(self, column):
|
|
55
|
+
"""
|
|
56
|
+
Checks if there's an '>' in the column that indicates a pointer reference, e.g. 'local_column>foreign_column'.
|
|
57
|
+
"""
|
|
58
|
+
if not isinstance(column, str) or not re.search(r"^[a-zA-Z0-9_>*]", column):
|
|
59
|
+
raise Exception(f"Invalid column specified: {column}")
|
|
60
|
+
return bool(re.search(r"[a-zA-Z0-9_]+>[a-zA-Z0-9_]+", column))
|
|
61
|
+
|
|
62
|
+
def __fetch_foreign_data(self, key):
|
|
63
|
+
if key in self.foreign_keys:
|
|
64
|
+
return self.foreign_keys[key]
|
|
65
|
+
local_column, foreign_column = key.split(">")
|
|
66
|
+
foreign = self.tx.table(self.current_table).foreign_key_info(local_column)
|
|
67
|
+
if not foreign:
|
|
68
|
+
raise Exception(
|
|
69
|
+
f"Foreign key `{self.current_table}.{local_column}>{foreign_column}` not defined."
|
|
70
|
+
)
|
|
71
|
+
ref_table = foreign["referenced_table_name"]
|
|
72
|
+
ref_schema = foreign["referenced_table_schema"]
|
|
73
|
+
ref_column = foreign["referenced_column_name"]
|
|
74
|
+
if ref_table not in self.table_aliases:
|
|
75
|
+
self.table_aliases[ref_table] = chr(self.letter)
|
|
76
|
+
self.letter += 1
|
|
77
|
+
alias = self.table_aliases[ref_table]
|
|
78
|
+
data = {
|
|
79
|
+
"alias": alias,
|
|
80
|
+
"ref_table": ref_table,
|
|
81
|
+
"ref_schema": ref_schema,
|
|
82
|
+
"local_column": local_column,
|
|
83
|
+
"ref_column": ref_column,
|
|
84
|
+
}
|
|
85
|
+
self.foreign_keys[key] = data
|
|
86
|
+
return data
|
|
87
|
+
|
|
88
|
+
def resolve_references(self, key, options=None):
|
|
89
|
+
"""
|
|
90
|
+
Resolves pointer syntax or table alias references.
|
|
91
|
+
`options` can control whether to alias columns and/or tables.
|
|
92
|
+
"""
|
|
93
|
+
if options is None:
|
|
94
|
+
options = {"alias_column": True, "alias_table": False, "alias_only": False}
|
|
95
|
+
|
|
96
|
+
column = self.extract_column_name(key)
|
|
97
|
+
alias = self.get_table_alias("current_table")
|
|
98
|
+
if not key or not column:
|
|
99
|
+
raise Exception(f"Invalid key or column: key={key}, column={column}")
|
|
100
|
+
|
|
101
|
+
if not self.has_pointer(column):
|
|
102
|
+
# Standard column
|
|
103
|
+
if options.get("alias_table") and alias != "A":
|
|
104
|
+
name = alias + "." + self.quote(column)
|
|
105
|
+
else:
|
|
106
|
+
name = self.quote(column)
|
|
107
|
+
return self.remove_operator(key).replace(column, name)
|
|
108
|
+
|
|
109
|
+
local_column, foreign_column = column.split(">")
|
|
110
|
+
if options.get("alias_only"):
|
|
111
|
+
return f"{local_column}_{foreign_column}"
|
|
112
|
+
data = self.__fetch_foreign_data(column)
|
|
113
|
+
if options.get("alias_table"):
|
|
114
|
+
name = f"{self.get_table_alias(data['ref_table'])}.{self.quote(foreign_column)}"
|
|
115
|
+
else:
|
|
116
|
+
name = f"{data['ref_table']}.{self.quote(foreign_column)}"
|
|
117
|
+
|
|
118
|
+
result = self.remove_operator(key).replace(column, name)
|
|
119
|
+
if options.get("alias_column"):
|
|
120
|
+
result += f" as {local_column}_{foreign_column}"
|
|
121
|
+
return result
|
|
122
|
+
|
|
123
|
+
def get_operator(self, key, val):
|
|
124
|
+
"""
|
|
125
|
+
Determines the SQL operator from the start of `key` or defaults to '='.
|
|
126
|
+
"""
|
|
127
|
+
key = " ".join(key.replace('"', "").split())
|
|
128
|
+
for symbol, operator in self.operators.items():
|
|
129
|
+
if key.startswith(symbol):
|
|
130
|
+
return operator
|
|
131
|
+
return "="
|
|
132
|
+
|
|
133
|
+
def remove_operator(self, key):
|
|
134
|
+
"""
|
|
135
|
+
Strips recognized operator symbols from the start of `key`.
|
|
136
|
+
"""
|
|
137
|
+
for symbol in self.operators.keys():
|
|
138
|
+
if key.startswith(symbol):
|
|
139
|
+
return key.replace(symbol, "", 1)
|
|
140
|
+
return key
|
|
141
|
+
|
|
142
|
+
def extract_column_name(self, sql_expression):
|
|
143
|
+
"""
|
|
144
|
+
Extracts the 'bare' column name from an expression, ignoring function calls, operators, etc.
|
|
145
|
+
"""
|
|
146
|
+
expr = sql_expression.replace('"', "")
|
|
147
|
+
expr = self.remove_operator(expr)
|
|
148
|
+
if not self.are_parentheses_balanced(expr):
|
|
149
|
+
raise ValueError(f"Unbalanced parentheses in expression: {expr}")
|
|
150
|
+
|
|
151
|
+
# Remove outer function calls, preserving inside parentheses.
|
|
152
|
+
while "(" in expr or ")" in expr:
|
|
153
|
+
expr = re.sub(r"\b\w+\s*\((.*?)\)", r"\1", expr)
|
|
154
|
+
|
|
155
|
+
if "," in expr:
|
|
156
|
+
expr = expr.split(",")[0].strip()
|
|
157
|
+
|
|
158
|
+
pattern = r"^([a-zA-Z0-9_]+\.\*|\*|[a-zA-Z0-9_>]+(?:\.[a-zA-Z0-9_]+)?)$"
|
|
159
|
+
match = re.search(pattern, expr)
|
|
160
|
+
return match.group(1) if match else None
|
|
161
|
+
|
|
162
|
+
def are_parentheses_balanced(self, expression):
|
|
163
|
+
"""
|
|
164
|
+
Checks if parentheses in `expression` are balanced.
|
|
165
|
+
"""
|
|
166
|
+
stack = []
|
|
167
|
+
opening = "({["
|
|
168
|
+
closing = ")}]"
|
|
169
|
+
matching = {")": "(", "}": "{", "]": "["}
|
|
170
|
+
for char in expression:
|
|
171
|
+
if char in opening:
|
|
172
|
+
stack.append(char)
|
|
173
|
+
elif char in closing:
|
|
174
|
+
if not stack or stack.pop() != matching[char]:
|
|
175
|
+
return False
|
|
176
|
+
return not stack
|
|
177
|
+
|
|
178
|
+
def get_table_alias(self, table):
|
|
179
|
+
return self.table_aliases.get(table)
|
|
180
|
+
|
|
181
|
+
def make_predicate(self, key, val, options=None):
|
|
182
|
+
"""
|
|
183
|
+
Builds a piece of SQL and corresponding parameters for a WHERE/HAVING predicate based on `key`, `val`.
|
|
184
|
+
"""
|
|
185
|
+
if options is None:
|
|
186
|
+
options = {"alias_table": True, "alias_column": False}
|
|
187
|
+
case = None
|
|
188
|
+
column = self.resolve_references(key, options=options)
|
|
189
|
+
op = self.get_operator(key, val)
|
|
190
|
+
|
|
191
|
+
# Subquery?
|
|
192
|
+
if isinstance(val, Query):
|
|
193
|
+
if op in ("<>"):
|
|
194
|
+
return f"{column} NOT IN ({val})", val.params or None
|
|
195
|
+
return f"{column} IN ({val})", val.params or None
|
|
196
|
+
|
|
197
|
+
# Null / special markers
|
|
198
|
+
if val is None or isinstance(val, bool) or val in ("@@INFINITY", "@@UNKNOWN"):
|
|
199
|
+
if isinstance(val, str) and val.startswith("@@"):
|
|
200
|
+
val = val[2:]
|
|
201
|
+
if val is None:
|
|
202
|
+
val = "NULL"
|
|
203
|
+
if op == "<>":
|
|
204
|
+
return f"{column} IS NOT {str(val).upper()}", None
|
|
205
|
+
return f"{column} IS {str(val).upper()}", None
|
|
206
|
+
|
|
207
|
+
# Lists / tuples => IN / NOT IN
|
|
208
|
+
if isinstance(val, (list, tuple)) and "><" not in key:
|
|
209
|
+
if "!" in key:
|
|
210
|
+
return f"{column} NOT IN %s", list(val)
|
|
211
|
+
return f"{column} IN %s", list(val)
|
|
212
|
+
|
|
213
|
+
# "@@" => pass as literal
|
|
214
|
+
if isinstance(val, str) and val.startswith("@@") and val[2:]:
|
|
215
|
+
return f"{column} {op} {val[2:]}", None
|
|
216
|
+
|
|
217
|
+
# Between operators
|
|
218
|
+
if op in ["BETWEEN", "NOT BETWEEN"]:
|
|
219
|
+
return f"{column} {op} %s and %s", tuple(val)
|
|
220
|
+
|
|
221
|
+
if case:
|
|
222
|
+
return f"{case}({column}) {op} {case}(%s)", val
|
|
223
|
+
|
|
224
|
+
# Default single-parameter predicate
|
|
225
|
+
return f"{column} {op} %s", val
|
|
226
|
+
|
|
227
|
+
def make_where(self, where):
|
|
228
|
+
"""
|
|
229
|
+
Converts a dict-based WHERE into a list of predicate strings + param values.
|
|
230
|
+
"""
|
|
231
|
+
if isinstance(where, Mapping):
|
|
232
|
+
new_where = []
|
|
233
|
+
for key, val in where.items():
|
|
234
|
+
new_where.append(self.make_predicate(key, val))
|
|
235
|
+
where = new_where
|
|
236
|
+
|
|
237
|
+
sql = []
|
|
238
|
+
vals = []
|
|
239
|
+
if where:
|
|
240
|
+
sql.append("WHERE")
|
|
241
|
+
join = ""
|
|
242
|
+
for pred, val in where:
|
|
243
|
+
if join:
|
|
244
|
+
sql.append(join)
|
|
245
|
+
sql.append(pred)
|
|
246
|
+
join = "AND"
|
|
247
|
+
if val is not None:
|
|
248
|
+
if isinstance(val, tuple):
|
|
249
|
+
vals.extend(val)
|
|
250
|
+
else:
|
|
251
|
+
vals.append(val)
|
|
252
|
+
return " ".join(sql), tuple(vals)
|
|
253
|
+
|
|
254
|
+
@classmethod
|
|
255
|
+
def quote(cls, data):
|
|
256
|
+
"""
|
|
257
|
+
Quotes identifiers (columns/tables) if needed, especially if they match reserved words or contain special chars.
|
|
258
|
+
"""
|
|
259
|
+
if isinstance(data, list):
|
|
260
|
+
new_list = []
|
|
261
|
+
for item in data:
|
|
262
|
+
if item.startswith("@@"):
|
|
263
|
+
new_list.append(item[2:])
|
|
264
|
+
else:
|
|
265
|
+
new_list.append(cls.quote(item))
|
|
266
|
+
return new_list
|
|
267
|
+
|
|
268
|
+
parts = data.split(".")
|
|
269
|
+
quoted_parts = []
|
|
270
|
+
for part in parts:
|
|
271
|
+
if '"' in part:
|
|
272
|
+
quoted_parts.append(part)
|
|
273
|
+
elif part.upper() in cls.reserved or re.findall(r"[/]", part):
|
|
274
|
+
quoted_parts.append(f'"{part}"')
|
|
275
|
+
else:
|
|
276
|
+
quoted_parts.append(part)
|
|
277
|
+
return ".".join(quoted_parts)
|