sql-dialect-master 1.1.1__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.
Files changed (86) hide show
  1. backend/__init__.py +1 -0
  2. backend/api/__init__.py +1 -0
  3. backend/api/main.py +732 -0
  4. backend/api/middleware.py +274 -0
  5. backend/api/rate_limit_store.py +105 -0
  6. backend/api/readiness.py +178 -0
  7. backend/core/__init__.py +57 -0
  8. backend/core/cache.py +261 -0
  9. backend/core/column_hint_validation.py +22 -0
  10. backend/core/config.py +546 -0
  11. backend/core/constants.py +84 -0
  12. backend/core/exceptions.py +124 -0
  13. backend/core/function_call_scanner.py +64 -0
  14. backend/core/function_semantics.py +148 -0
  15. backend/core/functions_db.json +601 -0
  16. backend/core/functions_lookup.py +332 -0
  17. backend/core/metadata.py +71 -0
  18. backend/core/nl2sql.py +85 -0
  19. backend/core/nl2sql_components/__init__.py +72 -0
  20. backend/core/nl2sql_components/aggregations.py +35 -0
  21. backend/core/nl2sql_components/boolean_conditions.py +279 -0
  22. backend/core/nl2sql_components/core_extract.py +106 -0
  23. backend/core/nl2sql_components/evidence.py +122 -0
  24. backend/core/nl2sql_components/mappings.py +185 -0
  25. backend/core/nl2sql_components/relations.py +180 -0
  26. backend/core/nl2sql_components/templates.py +130 -0
  27. backend/core/nl2sql_components/tokenizer.py +187 -0
  28. backend/core/nl2sql_legacy.py +22 -0
  29. backend/core/nl2sql_pipeline/__init__.py +13 -0
  30. backend/core/nl2sql_pipeline/aggregations.py +26 -0
  31. backend/core/nl2sql_pipeline/clause_extract.py +97 -0
  32. backend/core/nl2sql_pipeline/dialect.py +139 -0
  33. backend/core/nl2sql_pipeline/intent.py +85 -0
  34. backend/core/nl2sql_pipeline/ir.py +70 -0
  35. backend/core/nl2sql_pipeline/models.py +30 -0
  36. backend/core/nl2sql_pipeline/orchestrator.py +576 -0
  37. backend/core/nl2sql_pipeline/predicates.py +182 -0
  38. backend/core/nl2sql_pipeline/renderer.py +248 -0
  39. backend/core/nl2sql_pipeline/resolution.py +69 -0
  40. backend/core/nl2sql_pipeline/suggestions.py +30 -0
  41. backend/core/nl2sql_pipeline/templates.py +371 -0
  42. backend/core/nl2sql_pipeline/validation.py +28 -0
  43. backend/core/p1_sql_scanner.py +196 -0
  44. backend/core/p2_data_validation.py +72 -0
  45. backend/core/parser.py +280 -0
  46. backend/core/post_processor.py +129 -0
  47. backend/core/rules.py +808 -0
  48. backend/core/semantic_diff.py +385 -0
  49. backend/core/transpiler.py +791 -0
  50. backend/core/type_mapping.json +593 -0
  51. backend/core/type_mapping.py +765 -0
  52. backend/utils/__init__.py +2 -0
  53. backend/utils/validators.py +407 -0
  54. frontend/__init__.py +10 -0
  55. frontend/app_context_v2.py +201 -0
  56. frontend/core/__init__.py +33 -0
  57. frontend/core/design_tokens.py +127 -0
  58. frontend/core/escaping.py +13 -0
  59. frontend/core/navigation.py +135 -0
  60. frontend/core/state.py +612 -0
  61. frontend/core/styles.py +523 -0
  62. frontend/core/themes.py +69 -0
  63. frontend/core/viewmodels.py +499 -0
  64. frontend/pages/__init__.py +0 -0
  65. frontend/pages/convert.py +283 -0
  66. frontend/pages/diff.py +157 -0
  67. frontend/pages/functions.py +100 -0
  68. frontend/pages/history.py +144 -0
  69. frontend/pages/lineage.py +168 -0
  70. frontend/pages/nl2sql.py +305 -0
  71. frontend/pages/query_analysis.py +120 -0
  72. frontend/pages/runtime.py +66 -0
  73. frontend/pages/settings.py +60 -0
  74. frontend/pages/templates.py +67 -0
  75. frontend/pages/types.py +110 -0
  76. frontend/templates_v2.py +53 -0
  77. frontend/ui/__init__.py +0 -0
  78. frontend/ui/command_palette.py +153 -0
  79. frontend/ui/diff.py +275 -0
  80. frontend/ui/editor.py +102 -0
  81. frontend/ui/status.py +255 -0
  82. sql_dialect_master-1.1.1.dist-info/METADATA +360 -0
  83. sql_dialect_master-1.1.1.dist-info/RECORD +86 -0
  84. sql_dialect_master-1.1.1.dist-info/WHEEL +5 -0
  85. sql_dialect_master-1.1.1.dist-info/licenses/LICENSE +21 -0
  86. sql_dialect_master-1.1.1.dist-info/top_level.txt +2 -0
@@ -0,0 +1,371 @@
1
+ #!/usr/bin/env python3
2
+ """P8 NL2SQL pipeline: template generation (moved from nl2sql_legacy).
3
+
4
+ Contains the per-template SQL string builder (``generate_from_template``)
5
+ and its helper (``template_suggestions``). Bodies are verbatim from
6
+ ``nl2sql_legacy._generate_from_template`` and
7
+ ``nl2sql_legacy._generate_suggestions_for_template``; they now call the
8
+ pure stage functions from ``renderer`` / ``dialect`` / ``validation``
9
+ / ``predicates`` / ``resolution`` / ``relations`` directly.
10
+ """
11
+ import logging
12
+ import re
13
+ from typing import Any, Dict, List, Optional
14
+
15
+ from ..nl2sql_components.evidence import (
16
+ EVIDENCE_AGGREGATION_PRESENT,
17
+ EVIDENCE_CONDITIONS_PRESENT,
18
+ EVIDENCE_GROUP_BY,
19
+ EVIDENCE_JOIN_KNOWN,
20
+ EVIDENCE_LIMIT,
21
+ EVIDENCE_PARSE_FAIL,
22
+ EVIDENCE_PARSE_OK,
23
+ EVIDENCE_TABLE_KNOWN,
24
+ EVIDENCE_TEMPLATE_MATCHED,
25
+ EvidenceItem,
26
+ GenerationEvidence,
27
+ )
28
+ from ..nl2sql_components.mappings import COLUMN_PATTERNS, TABLE_PATTERNS
29
+ from ..nl2sql_components.templates import QueryTemplate
30
+ from .dialect import apply_dialect_adjustments
31
+ from .models import NL2SQLResult
32
+ from .predicates import extract_conditions as extract_conditions_impl
33
+ from .renderer import add_limit, join_sql_kind
34
+ from .resolution import extract_joins, extract_table_subject
35
+ from .validation import validate_sql
36
+
37
+ logger = logging.getLogger(__name__)
38
+
39
+
40
+ def generate_from_template(
41
+ template: QueryTemplate,
42
+ match_groups: Dict[str, Any],
43
+ analysis: Dict[str, Any],
44
+ dialect: str,
45
+ table_hint: Optional[str],
46
+ column_hints: Optional[List[str]],
47
+ text_lower: str = "",
48
+ text_original: str = "",
49
+ table_patterns: Optional[Dict[str, str]] = None,
50
+ column_patterns: Optional[Dict[str, str]] = None,
51
+ ) -> NL2SQLResult:
52
+ """Generate SQL from a matched template.
53
+
54
+ Accumulates a structured GenerationEvidence trail whose weights
55
+ mirror the legacy heuristic exactly so the final score is
56
+ unchanged while the items explain it.
57
+ """
58
+ table_patterns = table_patterns or TABLE_PATTERNS
59
+ column_patterns = column_patterns or COLUMN_PATTERNS
60
+
61
+ explanation_parts = []
62
+ table = table_hint or (
63
+ analysis["tables"][0] if analysis["tables"] else "table_name"
64
+ )
65
+ columns = column_hints or analysis["columns"] or ["*"]
66
+ numbers = analysis["numbers"]
67
+ # Structured evidence trail; weights mirror the heuristic exactly.
68
+ evidence = GenerationEvidence()
69
+ evidence.items.append(
70
+ EvidenceItem("base", "Base confidence for template match", 0.7)
71
+ )
72
+ if table != "table_name":
73
+ evidence.items.append(EvidenceItem(
74
+ EVIDENCE_TABLE_KNOWN,
75
+ f"Table resolved to known name: {table}", 0.1, detail=table,
76
+ ))
77
+ # The clause-aware merge below combines the branch's own predicates
78
+ # with any template-external time/status condition without duplicating
79
+ # a clause the branch owns. The extractor must see the full original
80
+ # text for number extraction: the template capture alone silently
81
+ # drops conditions that sit outside the captured window.
82
+ external_conditions = extract_conditions_impl(
83
+ text_lower, text_original or text_lower, column_patterns
84
+ )
85
+
86
+ try:
87
+ if template.name == "top_n_query":
88
+ n = numbers[0] if numbers else "10"
89
+ order_col = analysis["columns"][0] if analysis["columns"] else "id"
90
+ # Determine ordering direction based on request text
91
+ request_text = str((match_groups or {}).get("match", "")).lower()
92
+ ascending_requested = bool(
93
+ re.search(r"\b(bottom|lowest|smallest)\b|最低|最少", request_text)
94
+ )
95
+ order_dir = "ASC" if ascending_requested else "DESC"
96
+ if ascending_requested:
97
+ explanation_parts.append(f"查询最低/最少{n}条记录")
98
+ else:
99
+ explanation_parts.append(f"查询前{n}条记录")
100
+ base_sql = (
101
+ f"SELECT *\nFROM {table}\n"
102
+ f"ORDER BY {order_col} {order_dir}"
103
+ )
104
+ sql = add_limit(base_sql, int(n), dialect)
105
+ evidence.items.append(EvidenceItem(
106
+ EVIDENCE_LIMIT,
107
+ f"Template limit applied: {n}", 0.1, detail=str(n),
108
+ ))
109
+ elif template.name == "count_by_group":
110
+ group_col = (
111
+ analysis["columns"][0] if analysis["columns"] else "category"
112
+ )
113
+ sql = (
114
+ f"SELECT {group_col}, COUNT(*) AS count\n"
115
+ f"FROM {table}\nGROUP BY {group_col}"
116
+ )
117
+ explanation_parts.append(f"按{group_col}分组统计")
118
+ evidence.items.append(EvidenceItem(
119
+ EVIDENCE_GROUP_BY,
120
+ f"Template GROUP BY on {group_col}", 0.1, detail=group_col,
121
+ ))
122
+ elif template.name == "time_range_query":
123
+ # The canonical D3 semantic expression is the single source of
124
+ # the date predicate. Other external conditions (comparisons,
125
+ # status flags) stay template-external and are composed by the
126
+ # clause-aware merge below, which de-duplicates instead of
127
+ # re-appending a second WHERE.
128
+ n = numbers[0] if numbers else "7"
129
+ date_col = (
130
+ "created_at"
131
+ if "created_at" in str(analysis["columns"])
132
+ else "date"
133
+ )
134
+ date_predicate = None
135
+ for c in external_conditions:
136
+ if "DATE_SUB" in c or "ADD_MONTHS" in c or "DATE_ADD" in c:
137
+ date_predicate = c
138
+ break
139
+ if date_predicate is None:
140
+ date_predicate = f"{date_col} >= DATE_SUB(CURRENT_DATE, {n})"
141
+ sql = f"SELECT *\nFROM {table}\nWHERE {date_predicate}"
142
+ explanation_parts.append(f"查询最近{n}天数据")
143
+ evidence.items.append(EvidenceItem(
144
+ EVIDENCE_CONDITIONS_PRESENT,
145
+ f"Template time range: {date_predicate}", 0.1, detail=table,
146
+ ))
147
+ elif template.name == "aggregate_query":
148
+ agg_func = "COUNT"
149
+ for token in analysis["tokens"]:
150
+ if token in ["平均", "average", "avg"]:
151
+ agg_func = "AVG"
152
+ elif token in ["总和", "sum", "合计"]:
153
+ agg_func = "SUM"
154
+ elif token in ["最大", "max", "maximum"]:
155
+ agg_func = "MAX"
156
+ elif token in ["最小", "min", "minimum"]:
157
+ agg_func = "MIN"
158
+ col = analysis["columns"][0] if analysis["columns"] else "*"
159
+ sql = f"SELECT {agg_func}({col}) AS result\nFROM {table}"
160
+ explanation_parts.append(f"计算{agg_func}({col})")
161
+ evidence.items.append(EvidenceItem(
162
+ EVIDENCE_AGGREGATION_PRESENT,
163
+ f"Template aggregation: {agg_func}({col})", 0.1, detail=agg_func,
164
+ ))
165
+ elif template.name == "condition_query":
166
+ col = analysis["columns"][0] if analysis["columns"] else "column"
167
+ value = numbers[0] if numbers else "0"
168
+ op = "="
169
+ for token in analysis["tokens"]:
170
+ if token in ["大于", "超过", "greater", ">"]:
171
+ op = ">"
172
+ elif token in ["小于", "低于", "less", "<"]:
173
+ op = "<"
174
+ elif token in ["不等于", "!="]:
175
+ op = "!="
176
+ elif token in ["大于等于", ">="]:
177
+ op = ">="
178
+ elif token in ["小于等于", "<="]:
179
+ op = "<="
180
+ condition_predicate = f"{col} {op} {value}"
181
+ # Fold in template-external conditions that are not already
182
+ # implied by the branch's own comparison. Date predicates are
183
+ # recognised by their canonical D3 forms; any other external
184
+ # condition (status, flags) is appended verbatim.
185
+ sql = f"SELECT *\nFROM {table}\nWHERE {condition_predicate}"
186
+ for c in external_conditions:
187
+ if c in sql:
188
+ continue
189
+ if "DATE_SUB" not in c and "ADD_MONTHS" not in c:
190
+ sql += f"\nAND {c}"
191
+ explanation_parts.append(f"条件: {col} {op} {value}")
192
+ evidence.items.append(EvidenceItem(
193
+ EVIDENCE_CONDITIONS_PRESENT,
194
+ f"Template condition: {col} {op} {value}", 0.1,
195
+ ))
196
+ elif template.name == "join_query":
197
+ # JOIN composition is owned by the enhanced path: route every
198
+ # join request through resolution.extract_joins, which only
199
+ # yields a canonical condition for known relationships. The
200
+ # template must not invent a condition of its own.
201
+ all_joins = extract_joins(text_lower, table_patterns)
202
+ joins = [j for j in all_joins if j["condition"] is not None]
203
+ # The join request is only safe when every pair the text names
204
+ # has a canonical relationship; otherwise fail the request.
205
+ pairs_blocked = [j for j in all_joins if j["condition"] is None]
206
+ if pairs_blocked:
207
+ blocked = " ⟷ ".join(
208
+ [j["subject"] for j in pairs_blocked]
209
+ + [j["table"] for j in pairs_blocked]
210
+ )
211
+ return NL2SQLResult(
212
+ success=False,
213
+ input_text=match_groups.get("match", ""),
214
+ sql=None,
215
+ dialect=dialect,
216
+ explanation=(
217
+ "Unable to safely infer the relationship between the "
218
+ f"table(s) involved ({blocked}). Please provide the "
219
+ "join condition or schema relationship."
220
+ ),
221
+ confidence=0.0,
222
+ evidence=GenerationEvidence(),
223
+ suggestions=[
224
+ "Specify the relationship between tables, "
225
+ "e.g., 'users.id = invoices.user_id'"
226
+ ],
227
+ parsed_elements=analysis,
228
+ )
229
+ if joins:
230
+ subject = extract_table_subject(text_lower, table_patterns)
231
+ sql = f"SELECT *\nFROM {subject or joins[0]['table']}"
232
+ for join in joins:
233
+ # Emit the structured join kind from JoinSpec when
234
+ # the user asked for a physical join (LEFT/RIGHT/
235
+ # INNER/OUTER); an unqualified join stays INNER.
236
+ kind = join_sql_kind(join)
237
+ sql += f"\n{kind} JOIN {join['table']} ON {join['condition']}"
238
+ explanation_parts.append(
239
+ f"关联: {subject or joins[0]['table']} ⟷ {join['table']}"
240
+ )
241
+ evidence.items.append(EvidenceItem(
242
+ EVIDENCE_JOIN_KNOWN,
243
+ f"Join in template: {join['table']} ON {join['condition']}",
244
+ 0.15, detail=join["condition"],
245
+ ))
246
+ else:
247
+ # No known pair resolvable — fail-safe too.
248
+ return NL2SQLResult(
249
+ success=False,
250
+ input_text=match_groups.get("match", ""),
251
+ sql=None,
252
+ dialect=dialect,
253
+ explanation=(
254
+ "Unable to safely infer the relationship between the "
255
+ "table(s). Please provide the join condition or "
256
+ "schema relationship."
257
+ ),
258
+ confidence=0.0,
259
+ evidence=GenerationEvidence(),
260
+ suggestions=[
261
+ "Specify the relationship between tables, "
262
+ "e.g., 'users.id = invoices.user_id'"
263
+ ],
264
+ parsed_elements=analysis,
265
+ )
266
+ elif template.name == "select_with_columns":
267
+ cols = ", ".join(columns) if columns != ["*"] else "*"
268
+ sql = f"SELECT {cols}\nFROM {table}"
269
+ explanation_parts.append(f"查询: {cols}")
270
+ else:
271
+ sql = f"SELECT *\nFROM {table}"
272
+ explanation_parts.append(f"查询表: {table}")
273
+
274
+ # Clause-aware predicate composition: add only the time/status
275
+ # conditions the composed SQL does not already carry. Branches
276
+ # that own their WHERE clause (time_range/condition) already
277
+ # folded the external conditions in above; this step then
278
+ # de-duplicates instead of appending a second WHERE.
279
+ merged_conditions: List[str] = []
280
+ for c in external_conditions:
281
+ base = c.split(" AND ")[0] if " AND " in c else c
282
+ base = base.strip()
283
+ if base in sql or c in sql:
284
+ continue
285
+ if not any(
286
+ kw in text_lower
287
+ for kw in [
288
+ "today", "yesterday", "tomorrow", "last", "past",
289
+ "本周", "本月", "本年", "今天", "昨天", "明天"
290
+ ]
291
+ ) and c in [
292
+ "status = 'active'", "status = 'inactive'",
293
+ "is_deleted = 1", "is_deleted = 0"
294
+ ]:
295
+ continue
296
+ merged_conditions.append(c)
297
+ if merged_conditions:
298
+ if "WHERE" in sql.upper():
299
+ sql = f"{sql}\nAND {' AND '.join(merged_conditions)}"
300
+ else:
301
+ sql = f"{sql}\nWHERE {' AND '.join(merged_conditions)}"
302
+ explanation_parts.append(f"日期条件: {len(merged_conditions)}个")
303
+ evidence.items.append(EvidenceItem(
304
+ EVIDENCE_CONDITIONS_PRESENT,
305
+ f"Merged {len(merged_conditions)} external condition(s)",
306
+ 0.1,
307
+ ))
308
+
309
+ sql = f"-- Generated for {dialect.upper()}\n{sql}"
310
+ sql = apply_dialect_adjustments(sql, dialect)
311
+ parse_adjustment = validate_sql(sql, dialect)
312
+
313
+ evidence.items.append(EvidenceItem(
314
+ EVIDENCE_TEMPLATE_MATCHED,
315
+ f"Template matched: {template.name}", 0.0, detail=template.name,
316
+ ))
317
+ if parse_adjustment > 0:
318
+ evidence.items.append(EvidenceItem(
319
+ EVIDENCE_PARSE_OK,
320
+ "Target dialect parser accepted the generated SQL",
321
+ parse_adjustment,
322
+ ))
323
+ elif parse_adjustment < 0:
324
+ evidence.items.append(EvidenceItem(
325
+ EVIDENCE_PARSE_FAIL,
326
+ "Target dialect parser rejected the generated SQL",
327
+ parse_adjustment,
328
+ ))
329
+
330
+ suggestions = template_suggestions(template, table, columns, dialect)
331
+
332
+ return NL2SQLResult(
333
+ success=True,
334
+ input_text=match_groups.get("match", ""),
335
+ sql=sql,
336
+ dialect=dialect,
337
+ explanation=" | ".join(explanation_parts),
338
+ confidence=evidence.score,
339
+ evidence=evidence,
340
+ suggestions=suggestions,
341
+ parsed_elements=analysis,
342
+ )
343
+ except Exception as exc:
344
+ logger.warning("Template generation failed: %s", exc)
345
+ return NL2SQLResult(
346
+ success=False,
347
+ input_text=str(match_groups),
348
+ dialect=dialect,
349
+ explanation=f"Template error: {exc}",
350
+ confidence=0.0,
351
+ evidence=GenerationEvidence(),
352
+ )
353
+
354
+
355
+ def template_suggestions(
356
+ template: QueryTemplate,
357
+ table: str,
358
+ columns: List[str],
359
+ dialect: str,
360
+ ) -> List[str]:
361
+ """Generate suggestions specific to template-based generation."""
362
+ suggestions = []
363
+ if table == "table_name":
364
+ suggestions.append("💡 请指定具体的表名,如:用户表、订单表、员工表")
365
+ if columns == ["*"]:
366
+ suggestions.append("💡 建议指定具体列名以提高查询性能")
367
+ if template.name == "join_query":
368
+ suggestions.append("💡 请确认关联条件是否正确")
369
+ if template.name == "time_range_query" and dialect == "oracle":
370
+ suggestions.append("💡 Oracle 日期函数语法可能需要调整")
371
+ return suggestions
@@ -0,0 +1,28 @@
1
+ #!/usr/bin/env python3
2
+ """P8 NL2SQL pipeline: AST validation (moved UNCHANGED from nl2sql_legacy).
3
+
4
+ Validates generated SQL against the target-dialect sqlglot parser and
5
+ returns the confidence adjustment (+0.15 / -0.2 / 0.0).
6
+ """
7
+ import sqlglot
8
+
9
+
10
+ def validate_sql(sql: str, dialect: str) -> float:
11
+ """Validate generated SQL syntax and return confidence adjustment.
12
+
13
+ Strips comment lines, parses the executable payload with the target
14
+ dialect's sqlglot reader, and returns:
15
+ +0.15 parser accepted
16
+ -0.2 parser rejected
17
+ 0.0 nothing to validate (empty after stripping comments)
18
+ """
19
+ try:
20
+ sql_to_validate = "\n".join(
21
+ line for line in sql.split("\n") if not line.strip().startswith("--")
22
+ )
23
+ if sql_to_validate.strip():
24
+ sqlglot.parse_one(sql_to_validate, read=dialect)
25
+ return 0.15
26
+ except Exception:
27
+ return -0.2
28
+ return 0.0
@@ -0,0 +1,196 @@
1
+ """Small lexical scanner for SQL rewrites that must avoid literals/comments."""
2
+ from __future__ import annotations
3
+
4
+ from typing import Iterator, Tuple
5
+
6
+
7
+ def executable_segments(sql: str) -> Iterator[Tuple[int, int]]:
8
+ """Yield [start, end) ranges outside strings, quoted identifiers and comments."""
9
+ i = 0
10
+ start = 0
11
+ n = len(sql)
12
+ state = "code"
13
+ dollar_tag = None
14
+ while i < n:
15
+ if state == "code":
16
+ if sql.startswith("--", i):
17
+ if start < i:
18
+ yield start, i
19
+ state = "line_comment"
20
+ i += 2
21
+ start = i
22
+ continue
23
+ if sql.startswith("/*", i):
24
+ if start < i:
25
+ yield start, i
26
+ state = "block_comment"
27
+ i += 2
28
+ start = i
29
+ continue
30
+ ch = sql[i]
31
+ if ch == "q" or ch == "Q":
32
+ if i + 2 < n and sql[i + 1] == "'":
33
+ opener = sql[i + 2]
34
+ closer = {"[": "]", "{": "}", "(": ")", "<": ">"}.get(opener, opener)
35
+ delimiter = f"{closer}'"
36
+ end = sql.find(delimiter, i + 3)
37
+ if end != -1:
38
+ if start < i:
39
+ yield start, i
40
+ i = end + len(delimiter)
41
+ start = i
42
+ continue
43
+ if ch == "'":
44
+ if start < i:
45
+ yield start, i
46
+ state = "single_quote"
47
+ i += 1
48
+ start = i
49
+ continue
50
+ if ch == '"':
51
+ if start < i:
52
+ yield start, i
53
+ state = "double_quote"
54
+ i += 1
55
+ start = i
56
+ continue
57
+ if ch == '`':
58
+ if start < i:
59
+ yield start, i
60
+ state = "backtick_quote"
61
+ i += 1
62
+ start = i
63
+ continue
64
+ if ch == '[':
65
+ if start < i:
66
+ yield start, i
67
+ state = "bracket_quote"
68
+ i += 1
69
+ start = i
70
+ continue
71
+ if ch == "$":
72
+ j = sql.find("$", i + 1)
73
+ if j != -1 and (sql[i + 1:j].replace("_", "").isalnum() or j == i + 1):
74
+ if start < i:
75
+ yield start, i
76
+ dollar_tag = sql[i:j + 1]
77
+ state = "dollar_quote"
78
+ i = j + 1
79
+ start = i
80
+ continue
81
+ i += 1
82
+ continue
83
+ if state == "single_quote":
84
+ if sql[i:i + 2] == "''":
85
+ i += 2
86
+ continue
87
+ if sql[i] == "\\" and i + 1 < n:
88
+ i += 2
89
+ continue
90
+ if sql[i] == "'":
91
+ state = "code"
92
+ i += 1
93
+ start = i
94
+ continue
95
+ i += 1
96
+ continue
97
+ if state == "double_quote":
98
+ if sql[i:i + 2] == '""':
99
+ i += 2
100
+ continue
101
+ if sql[i] == '"':
102
+ state = "code"
103
+ i += 1
104
+ start = i
105
+ continue
106
+ i += 1
107
+ continue
108
+ if state == "backtick_quote":
109
+ if sql[i:i + 2] == "``":
110
+ i += 2
111
+ continue
112
+ if sql[i] == '`':
113
+ state = "code"
114
+ i += 1
115
+ start = i
116
+ continue
117
+ i += 1
118
+ continue
119
+ if state == "bracket_quote":
120
+ if sql[i:i + 2] == "]]":
121
+ i += 2
122
+ continue
123
+ if sql[i] == ']':
124
+ state = "code"
125
+ i += 1
126
+ start = i
127
+ continue
128
+ i += 1
129
+ continue
130
+ if state == "line_comment":
131
+ if sql[i] in "\r\n":
132
+ state = "code"
133
+ start = i
134
+ i += 1
135
+ continue
136
+ if state == "block_comment":
137
+ if sql.startswith("*/", i):
138
+ state = "code"
139
+ i += 2
140
+ start = i
141
+ else:
142
+ i += 1
143
+ continue
144
+ if state == "dollar_quote":
145
+ if dollar_tag is None:
146
+ return
147
+ end = sql.find(dollar_tag, i)
148
+ if end == -1:
149
+ return
150
+ state = "code"
151
+ i = end + len(dollar_tag)
152
+ start = i
153
+ if state == "code" and start < n:
154
+ yield start, n
155
+
156
+
157
+ def mask_non_executable(sql: str) -> str:
158
+ """Replace non-code characters with spaces while preserving positions."""
159
+ masked = list(sql)
160
+ for start, end in _non_executable_ranges(sql):
161
+ for i in range(start, end):
162
+ masked[i] = " "
163
+ return "".join(masked)
164
+
165
+
166
+ def _non_executable_ranges(sql: str):
167
+ covered = []
168
+ cursor = 0
169
+ for start, end in executable_segments(sql):
170
+ if cursor < start:
171
+ covered.append((cursor, start))
172
+ cursor = end
173
+ if cursor < len(sql):
174
+ covered.append((cursor, len(sql)))
175
+ return covered
176
+
177
+
178
+ def split_top_level_args(args: str) -> list[str]:
179
+ """Split function arguments by top-level commas without crossing nested parens.
180
+
181
+ Uses mask_non_executable to skip commas inside quoted strings and comments.
182
+ """
183
+ masked = mask_non_executable(args)
184
+ parts: list[str] = []
185
+ start = 0
186
+ depth = 0
187
+ for index, char in enumerate(masked):
188
+ if char == "(":
189
+ depth += 1
190
+ elif char == ")" and depth > 0:
191
+ depth -= 1
192
+ elif char == "," and depth == 0:
193
+ parts.append(args[start:index].strip())
194
+ start = index + 1
195
+ parts.append(args[start:].strip())
196
+ return parts
@@ -0,0 +1,72 @@
1
+ """Reusable validators for packaged runtime JSON data."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+ from typing import Any
7
+
8
+ from .config import FUNCTION_CATEGORIES, SUPPORTED_DIALECTS
9
+ from .exceptions import ConfigurationError
10
+
11
+
12
+ def _error(path: Path, message: str) -> ConfigurationError:
13
+ return ConfigurationError(
14
+ f"Invalid runtime data in {path}: {message}",
15
+ details={"data_path": str(path)},
16
+ )
17
+
18
+
19
+ def _validate_function_entry(data_path: Path, index: int, entry: Any) -> None:
20
+ prefix = f"functions[{index}]"
21
+ if not isinstance(entry, dict):
22
+ raise _error(data_path, f"'{prefix}' must be an object")
23
+ name = entry.get("name")
24
+ category = entry.get("category")
25
+ dialects = entry.get("dialects")
26
+ if not isinstance(name, str) or not name.strip():
27
+ raise _error(data_path, f"'{prefix}.name' must be a non-empty string")
28
+ if not isinstance(category, str) or category.lower() not in FUNCTION_CATEGORIES:
29
+ raise _error(data_path, f"'{prefix}.category' must be a supported category")
30
+ if not isinstance(entry.get("description", ""), str) or not isinstance(entry.get("notes", ""), str):
31
+ raise _error(data_path, f"'{prefix}.description' and '{prefix}.notes' must be strings")
32
+ if not isinstance(dialects, dict):
33
+ raise _error(data_path, f"'{prefix}.dialects' must be an object")
34
+ unknown = sorted(set(dialects) - set(SUPPORTED_DIALECTS))
35
+ if unknown:
36
+ raise _error(data_path, f"'{prefix}.dialects' contains unsupported dialects: {unknown}")
37
+ if any(not isinstance(value, str) for value in dialects.values()):
38
+ raise _error(data_path, f"'{prefix}.dialects' values must be strings")
39
+
40
+ parameters = entry.get("parameters", [])
41
+ if not isinstance(parameters, list):
42
+ raise _error(data_path, f"'{prefix}.parameters' must be a list")
43
+ for pidx, parameter in enumerate(parameters):
44
+ if not isinstance(parameter, dict):
45
+ raise _error(data_path, f"'{prefix}.parameters[{pidx}]' must be an object")
46
+ for key in ("name", "type", "description"):
47
+ if not isinstance(parameter.get(key), str):
48
+ raise _error(data_path, f"'{prefix}.parameters[{pidx}].{key}' must be a string")
49
+ if not isinstance(parameter.get("required"), bool):
50
+ raise _error(data_path, f"'{prefix}.parameters[{pidx}].required' must be boolean")
51
+
52
+ examples = entry.get("examples", {})
53
+ if not isinstance(examples, dict) or any(
54
+ not isinstance(k, str) or not isinstance(v, str) for k, v in examples.items()
55
+ ):
56
+ raise _error(data_path, f"'{prefix}.examples' must map strings to strings")
57
+
58
+
59
+ def _validate_type_mapping_entry(data_path: Path, name: Any, entry: Any) -> None:
60
+ if not isinstance(name, str) or not name.strip():
61
+ raise _error(data_path, "mapping keys must be non-empty strings")
62
+ if not isinstance(entry, dict):
63
+ raise _error(data_path, f"mappings[{name!r}] must be an object")
64
+ unknown = sorted(set(entry) - (set(SUPPORTED_DIALECTS) | {"notes"}))
65
+ if unknown:
66
+ raise _error(data_path, f"mappings[{name!r}] contains unsupported keys: {unknown}")
67
+ for dialect, value in entry.items():
68
+ if dialect == "notes":
69
+ if not isinstance(value, str):
70
+ raise _error(data_path, f"mappings[{name!r}].notes must be a string")
71
+ elif not isinstance(value, str) or not value.strip():
72
+ raise _error(data_path, f"mappings[{name!r}].{dialect} must be a non-empty string")