velocity-python 0.0.35__py3-none-any.whl → 0.0.65__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.
@@ -0,0 +1,109 @@
1
+ import decimal
2
+ import datetime
3
+
4
+
5
+ class TYPES:
6
+ """
7
+ A simple mapping of Python types <-> SQL types (for PostgreSQL).
8
+ """
9
+
10
+ TEXT = "TEXT"
11
+ INTEGER = "INTEGER"
12
+ NUMERIC = "NUMERIC"
13
+ DATETIME_TZ = "TIMESTAMP WITH TIME ZONE"
14
+ TIMESTAMP_TZ = "TIMESTAMP WITH TIME ZONE"
15
+ DATETIME = "TIMESTAMP WITHOUT TIME ZONE"
16
+ TIMESTAMP = "TIMESTAMP WITHOUT TIME ZONE"
17
+ DATE = "DATE"
18
+ TIME_TZ = "TIME WITH TIME ZONE"
19
+ TIME = "TIME WITHOUT TIME ZONE"
20
+ BIGINT = "BIGINT"
21
+ SMALLINT = "SMALLINT"
22
+ BOOLEAN = "BOOLEAN"
23
+ BINARY = "BYTEA"
24
+ INTERVAL = "INTERVAL"
25
+
26
+ @classmethod
27
+ def get_type(cls, v):
28
+ """
29
+ Returns a suitable SQL type string for a Python value/object.
30
+ """
31
+ if isinstance(v, str) and v.startswith("@@"):
32
+ # e.g. @@CURRENT_TIMESTAMP => special usage
33
+ return v[2:] or cls.TEXT
34
+ if isinstance(v, str) or v is str:
35
+ return cls.TEXT
36
+ if isinstance(v, bool) or v is bool:
37
+ return cls.BOOLEAN
38
+ if isinstance(v, int) or v is int:
39
+ return cls.BIGINT
40
+ if isinstance(v, float) or v is float:
41
+ return f"{cls.NUMERIC}(19, 6)"
42
+ if isinstance(v, decimal.Decimal) or v is decimal.Decimal:
43
+ return f"{cls.NUMERIC}(19, 6)"
44
+ if isinstance(v, datetime.datetime) or v is datetime.datetime:
45
+ return cls.DATETIME
46
+ if isinstance(v, datetime.date) or v is datetime.date:
47
+ return cls.DATE
48
+ if isinstance(v, datetime.time) or v is datetime.time:
49
+ return cls.TIME
50
+ if isinstance(v, datetime.timedelta) or v is datetime.timedelta:
51
+ return cls.INTERVAL
52
+ if isinstance(v, bytes) or v is bytes:
53
+ return cls.BINARY
54
+ return cls.TEXT
55
+
56
+ @classmethod
57
+ def get_conv(cls, v):
58
+ """
59
+ Returns a base SQL type for expression usage (e.g. CAST).
60
+ """
61
+ if isinstance(v, str) and v.startswith("@@"):
62
+ return v[2:] or cls.TEXT
63
+ if isinstance(v, str) or v is str:
64
+ return cls.TEXT
65
+ if isinstance(v, bool) or v is bool:
66
+ return cls.BOOLEAN
67
+ if isinstance(v, int) or v is int:
68
+ return cls.BIGINT
69
+ if isinstance(v, float) or v is float:
70
+ return cls.NUMERIC
71
+ if isinstance(v, decimal.Decimal) or v is decimal.Decimal:
72
+ return cls.NUMERIC
73
+ if isinstance(v, datetime.datetime) or v is datetime.datetime:
74
+ return cls.DATETIME
75
+ if isinstance(v, datetime.date) or v is datetime.date:
76
+ return cls.DATE
77
+ if isinstance(v, datetime.time) or v is datetime.time:
78
+ return cls.TIME
79
+ if isinstance(v, datetime.timedelta) or v is datetime.timedelta:
80
+ return cls.INTERVAL
81
+ if isinstance(v, bytes) or v is bytes:
82
+ return cls.BINARY
83
+ return cls.TEXT
84
+
85
+ @classmethod
86
+ def py_type(cls, v):
87
+ """
88
+ Returns the Python type that corresponds to an SQL type string.
89
+ """
90
+ v = str(v).upper()
91
+ if v == cls.INTEGER or v == cls.SMALLINT or v == cls.BIGINT:
92
+ return int
93
+ if v == cls.NUMERIC:
94
+ return decimal.Decimal
95
+ if v == cls.TEXT:
96
+ return str
97
+ if v == cls.BOOLEAN:
98
+ return bool
99
+ if v == cls.DATE:
100
+ return datetime.date
101
+ if v == cls.TIME or v == cls.TIME_TZ:
102
+ return datetime.time
103
+ if v == cls.DATETIME or v == cls.TIMESTAMP:
104
+ return datetime.datetime
105
+ if v == cls.INTERVAL:
106
+ return datetime.timedelta
107
+ if v == cls.DATETIME_TZ or v == cls.TIMESTAMP_TZ:
108
+ return datetime.datetime
109
+ raise Exception(f"Unmapped type {v}")
@@ -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)