get-objects-lib 0.1.0__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.
- get_objects_lib/__init__.py +12 -0
- get_objects_lib/dialect/__init__.py +3 -0
- get_objects_lib/dialect/dialect.py +11 -0
- get_objects_lib/dialect/expressions.py +116 -0
- get_objects_lib/dialect/generator.py +231 -0
- get_objects_lib/dialect/parser/__init__.py +3 -0
- get_objects_lib/dialect/parser/base.py +278 -0
- get_objects_lib/dialect/parser/non_semicolon.py +182 -0
- get_objects_lib/dialect/parser/sql_server.py +512 -0
- get_objects_lib/dialect/text_utils.py +22 -0
- get_objects_lib/dialect/tokenizer.py +51 -0
- get_objects_lib/objects/__init__.py +3 -0
- get_objects_lib/objects/dependencies.py +162 -0
- get_objects_lib/objects/header.py +160 -0
- get_objects_lib/objects/standardize.py +72 -0
- get_objects_lib-0.1.0.dist-info/METADATA +231 -0
- get_objects_lib-0.1.0.dist-info/RECORD +18 -0
- get_objects_lib-0.1.0.dist-info/WHEEL +4 -0
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
from dataclasses import dataclass
|
|
2
|
+
|
|
3
|
+
import sqlglot
|
|
4
|
+
from sqlglot import exp
|
|
5
|
+
|
|
6
|
+
from get_objects_lib.dialect import SqlServer
|
|
7
|
+
from get_objects_lib.dialect.expressions import ExecParameter
|
|
8
|
+
from get_objects_lib.objects.header import find_object_header, quote_name
|
|
9
|
+
|
|
10
|
+
PROCEDURE = 'procedure'
|
|
11
|
+
FUNCTION = 'function'
|
|
12
|
+
# Sin catalogo no se puede distinguir una tabla de una vista
|
|
13
|
+
TABLE_OR_VIEW = 'table_or_view'
|
|
14
|
+
|
|
15
|
+
# Tablas virtuales de un trigger
|
|
16
|
+
TRIGGER_PSEUDO_TABLES = {'inserted', 'deleted'}
|
|
17
|
+
|
|
18
|
+
# Funciones de tabla del sistema que se parsean igual que una de usuario
|
|
19
|
+
BUILTIN_TABLE_FUNCTIONS = {
|
|
20
|
+
'STRING_SPLIT', 'OPENJSON', 'OPENXML', 'OPENQUERY', 'OPENROWSET', 'OPENDATASOURCE',
|
|
21
|
+
'GENERATE_SERIES', 'CHANGETABLE', 'CONTAINSTABLE', 'FREETEXTTABLE',
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass(frozen=True)
|
|
26
|
+
class ObjectRef:
|
|
27
|
+
kind: str
|
|
28
|
+
name: str
|
|
29
|
+
schema: str | None = None
|
|
30
|
+
catalog: str | None = None
|
|
31
|
+
server: str | None = None # servidor vinculado: servidor.base.esquema.objeto
|
|
32
|
+
|
|
33
|
+
def __str__(self) -> str:
|
|
34
|
+
return '.'.join(quote_name(p) for p in (self.server, self.catalog, self.schema, self.name) if p)
|
|
35
|
+
|
|
36
|
+
@property
|
|
37
|
+
def _key(self) -> tuple:
|
|
38
|
+
# SQL Server no distingue mayusculas; sin esquema se asume dbo
|
|
39
|
+
return (
|
|
40
|
+
self.kind,
|
|
41
|
+
(self.server or '').lower(),
|
|
42
|
+
(self.catalog or '').lower(),
|
|
43
|
+
(self.schema or 'dbo').lower(),
|
|
44
|
+
self.name.lower(),
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def get_dependencies(sql: str) -> list[ObjectRef]:
|
|
49
|
+
"""
|
|
50
|
+
Procedimientos, funciones y tablas/vistas que usa un query o la
|
|
51
|
+
definicion de un objeto, en orden de aparicion y sin repetidos.
|
|
52
|
+
|
|
53
|
+
No ve lo que se ejecuta con SQL dinamico (EXEC(@sql), sp_executesql).
|
|
54
|
+
Ignora tablas temporales, variables de tabla, CTEs, alias, inserted /
|
|
55
|
+
deleted de los triggers y el propio objeto que se esta definiendo. En un
|
|
56
|
+
trigger, la tabla a la que pertenece cuenta como dependencia.
|
|
57
|
+
"""
|
|
58
|
+
header = find_object_header(sql)
|
|
59
|
+
if header:
|
|
60
|
+
statements = header.statements
|
|
61
|
+
own = ObjectRef(PROCEDURE, header.name, header.schema)._key[1:]
|
|
62
|
+
else:
|
|
63
|
+
statements = sqlglot.parse(sql, read=SqlServer)
|
|
64
|
+
own = None
|
|
65
|
+
|
|
66
|
+
refs: dict[tuple, ObjectRef] = {}
|
|
67
|
+
for statement in statements:
|
|
68
|
+
if statement is None:
|
|
69
|
+
continue
|
|
70
|
+
|
|
71
|
+
aliases = {a.name.lower() for a in statement.find_all(exp.TableAlias) if a.name}
|
|
72
|
+
ctes = {c.alias_or_name.lower() for c in statement.find_all(exp.CTE)}
|
|
73
|
+
|
|
74
|
+
for node in statement.walk(bfs=False):
|
|
75
|
+
ref = _reference(node, aliases, ctes)
|
|
76
|
+
if ref and ref._key[1:] != own:
|
|
77
|
+
refs.setdefault(ref._key, ref)
|
|
78
|
+
|
|
79
|
+
return list(refs.values())
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _reference(node: exp.Expr, aliases: set[str], ctes: set[str]) -> ObjectRef | None:
|
|
83
|
+
# EXEC dbo.sp ... / EXEC sp_executesql ...
|
|
84
|
+
if isinstance(node, exp.Execute):
|
|
85
|
+
target = node.this
|
|
86
|
+
if isinstance(target, exp.Table) and _is_name(target.this):
|
|
87
|
+
return _from_table(PROCEDURE, target)
|
|
88
|
+
return None
|
|
89
|
+
|
|
90
|
+
# dbo.fn(...) en cualquier expresion, incluido CROSS/OUTER APPLY
|
|
91
|
+
if isinstance(node, exp.Dot) and isinstance(node.expression, exp.Anonymous):
|
|
92
|
+
parts = _identifier_parts(node.this)
|
|
93
|
+
if not parts or any(p.lower() in aliases for p in parts):
|
|
94
|
+
# T.c.value(...), x.nodes(...): metodos XML sobre una columna o alias
|
|
95
|
+
return None
|
|
96
|
+
schema, catalog = parts[-1], (parts[-2] if len(parts) > 1 else None)
|
|
97
|
+
return ObjectRef(FUNCTION, node.expression.name, schema, catalog)
|
|
98
|
+
|
|
99
|
+
# fn(...) sin esquema en un APPLY
|
|
100
|
+
if isinstance(node, exp.Lateral) and isinstance(node.this, exp.Anonymous):
|
|
101
|
+
return _function_without_schema(node.this)
|
|
102
|
+
|
|
103
|
+
if not isinstance(node, exp.Table):
|
|
104
|
+
return None
|
|
105
|
+
|
|
106
|
+
# FROM dbo.fn(...) / FROM fn(...)
|
|
107
|
+
if isinstance(node.this, exp.Anonymous):
|
|
108
|
+
if isinstance(node.args.get('db'), ExecParameter):
|
|
109
|
+
return None # @xml.nodes(...)
|
|
110
|
+
if node.db:
|
|
111
|
+
return _from_table(FUNCTION, node, name=node.this.name)
|
|
112
|
+
return _function_without_schema(node.this)
|
|
113
|
+
|
|
114
|
+
if not _is_name(node.this) or node.this.args.get('temporary'):
|
|
115
|
+
return None # @tabla, #temporal
|
|
116
|
+
if node.find_ancestor(exp.Execute) or _is_create_target(node):
|
|
117
|
+
return None
|
|
118
|
+
if not node.db and (node.name.lower() in ctes or node.name.lower() in aliases):
|
|
119
|
+
return None # CTE, o UPDATE v ... FROM Venta v
|
|
120
|
+
if not node.db and node.name.lower() in TRIGGER_PSEUDO_TABLES:
|
|
121
|
+
return None # inserted / deleted de un trigger
|
|
122
|
+
|
|
123
|
+
return _from_table(TABLE_OR_VIEW, node)
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _is_name(node: exp.Expr) -> bool:
|
|
127
|
+
"""Identificador, o esquema.objeto de un nombre de cuatro partes."""
|
|
128
|
+
if isinstance(node, exp.Dot):
|
|
129
|
+
return isinstance(node.this, exp.Identifier) and isinstance(node.expression, exp.Identifier)
|
|
130
|
+
return isinstance(node, exp.Identifier)
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def _from_table(kind: str, table: exp.Table, name: str | None = None) -> ObjectRef:
|
|
134
|
+
if isinstance(table.this, exp.Dot):
|
|
135
|
+
# servidor.base.esquema.objeto: Table(this=Dot(esquema, objeto), db=base, catalog=servidor)
|
|
136
|
+
return ObjectRef(
|
|
137
|
+
kind, table.this.expression.name, table.this.this.name, table.db or None, table.catalog or None,
|
|
138
|
+
)
|
|
139
|
+
return ObjectRef(kind, name or table.name, table.db or None, table.catalog or None)
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def _function_without_schema(func: exp.Anonymous) -> ObjectRef | None:
|
|
143
|
+
if func.name.upper() in BUILTIN_TABLE_FUNCTIONS:
|
|
144
|
+
return None
|
|
145
|
+
return ObjectRef(FUNCTION, func.name)
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def _identifier_parts(node: exp.Expr) -> list[str] | None:
|
|
149
|
+
"""dbo -> ['dbo']; Otra.dbo -> ['Otra', 'dbo']; cualquier otra cosa -> None."""
|
|
150
|
+
if isinstance(node, exp.Identifier):
|
|
151
|
+
return [node.name]
|
|
152
|
+
if isinstance(node, exp.Dot):
|
|
153
|
+
left, right = _identifier_parts(node.this), _identifier_parts(node.expression)
|
|
154
|
+
return left + right if left and right else None
|
|
155
|
+
return None
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def _is_create_target(table: exp.Table) -> bool:
|
|
159
|
+
parent = table.parent
|
|
160
|
+
if isinstance(parent, (exp.UserDefinedFunction, exp.StoredProcedure)):
|
|
161
|
+
parent = parent.parent
|
|
162
|
+
return isinstance(parent, exp.Create) and parent.this in (table, table.parent)
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
import re
|
|
2
|
+
from dataclasses import dataclass
|
|
3
|
+
|
|
4
|
+
import sqlglot
|
|
5
|
+
from sqlglot import exp, TokenType
|
|
6
|
+
|
|
7
|
+
from get_objects_lib.dialect import SqlServer
|
|
8
|
+
from get_objects_lib.dialect.parser.base import BaseParser
|
|
9
|
+
|
|
10
|
+
OBJECT_TOKENS = (TokenType.PROCEDURE, TokenType.FUNCTION, TokenType.VIEW, TokenType.TRIGGER)
|
|
11
|
+
|
|
12
|
+
# Codigos de tipo de sys.objects / OBJECT_ID
|
|
13
|
+
PROCEDURE = 'P'
|
|
14
|
+
VIEW = 'V'
|
|
15
|
+
SCALAR_FUNCTION = 'FN'
|
|
16
|
+
INLINE_FUNCTION = 'IF'
|
|
17
|
+
TABLE_FUNCTION = 'TF'
|
|
18
|
+
TRIGGER = 'TR'
|
|
19
|
+
|
|
20
|
+
KIND_KEYWORDS = {
|
|
21
|
+
PROCEDURE: 'PROCEDURE',
|
|
22
|
+
VIEW: 'VIEW',
|
|
23
|
+
SCALAR_FUNCTION: 'FUNCTION',
|
|
24
|
+
INLINE_FUNCTION: 'FUNCTION',
|
|
25
|
+
TABLE_FUNCTION: 'FUNCTION',
|
|
26
|
+
TRIGGER: 'TRIGGER',
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass(frozen=True)
|
|
31
|
+
class ObjectHeader:
|
|
32
|
+
"""CREATE/ALTER de un procedimiento, funcion, vista o trigger dentro de un script."""
|
|
33
|
+
type_code: str
|
|
34
|
+
name: str
|
|
35
|
+
schema: str | None
|
|
36
|
+
# Posiciones en el texto original
|
|
37
|
+
start: int # inicio del CREATE/ALTER
|
|
38
|
+
body_start: int # justo despues de PROCEDURE/FUNCTION/VIEW
|
|
39
|
+
end: int # antes del GO que cierra el lote (o fin del texto)
|
|
40
|
+
statement: exp.Create
|
|
41
|
+
# Todas las sentencias del lote: en T-SQL el cuerpo llega hasta el GO
|
|
42
|
+
statements: tuple[exp.Expr, ...] = ()
|
|
43
|
+
|
|
44
|
+
@property
|
|
45
|
+
def keyword(self) -> str:
|
|
46
|
+
return KIND_KEYWORDS[self.type_code]
|
|
47
|
+
|
|
48
|
+
@property
|
|
49
|
+
def full_name(self) -> str:
|
|
50
|
+
return '.'.join(quote_name(p) for p in (self.schema, self.name) if p)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
# Palabras reservadas de T-SQL (docs de SQL Server, "Reserved Keywords")
|
|
54
|
+
RESERVED_KEYWORDS = set('''
|
|
55
|
+
ADD ALL ALTER AND ANY AS ASC AUTHORIZATION BACKUP BEGIN BETWEEN BREAK BROWSE BULK BY CASCADE CASE
|
|
56
|
+
CHECK CHECKPOINT CLOSE CLUSTERED COALESCE COLLATE COLUMN COMMIT COMPUTE CONSTRAINT CONTAINS
|
|
57
|
+
CONTAINSTABLE CONTINUE CONVERT CREATE CROSS CURRENT CURRENT_DATE CURRENT_TIME CURRENT_TIMESTAMP
|
|
58
|
+
CURRENT_USER CURSOR DATABASE DBCC DEALLOCATE DECLARE DEFAULT DELETE DENY DESC DISK DISTINCT
|
|
59
|
+
DISTRIBUTED DOUBLE DROP DUMP ELSE END ERRLVL ESCAPE EXCEPT EXEC EXECUTE EXISTS EXIT EXTERNAL FETCH
|
|
60
|
+
FILE FILLFACTOR FOR FOREIGN FREETEXT FREETEXTTABLE FROM FULL FUNCTION GOTO GRANT GROUP HAVING
|
|
61
|
+
HOLDLOCK IDENTITY IDENTITY_INSERT IDENTITYCOL IF IN INDEX INNER INSERT INTERSECT INTO IS JOIN KEY
|
|
62
|
+
KILL LEFT LIKE LINENO LOAD MERGE NATIONAL NOCHECK NONCLUSTERED NOT NULL NULLIF OF OFF OFFSETS ON
|
|
63
|
+
OPEN OPENDATASOURCE OPENQUERY OPENROWSET OPENXML OPTION OR ORDER OUTER OVER PERCENT PIVOT PLAN
|
|
64
|
+
PRECISION PRIMARY PRINT PROC PROCEDURE PUBLIC RAISERROR READ READTEXT RECONFIGURE REFERENCES
|
|
65
|
+
REPLICATION RESTORE RESTRICT RETURN REVERT REVOKE RIGHT ROLLBACK ROWCOUNT ROWGUIDCOL RULE SAVE
|
|
66
|
+
SCHEMA SECURITYAUDIT SELECT SEMANTICKEYPHRASETABLE SEMANTICSIMILARITYDETAILSTABLE
|
|
67
|
+
SEMANTICSIMILARITYTABLE SESSION_USER SET SETUSER SHUTDOWN SOME STATISTICS SYSTEM_USER TABLE
|
|
68
|
+
TABLESAMPLE TEXTSIZE THEN TO TOP TRAN TRANSACTION TRIGGER TRUNCATE TRY_CONVERT TSEQUAL UNION UNIQUE
|
|
69
|
+
UNPIVOT UPDATE UPDATETEXT USE USER VALUES VARYING VIEW WAITFOR WHEN WHERE WHILE WITH WITHIN
|
|
70
|
+
WRITETEXT
|
|
71
|
+
'''.split())
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def quote_name(part: str) -> str:
|
|
75
|
+
"""Corchetes solo si el nombre no es un identificador simple o es palabra reservada."""
|
|
76
|
+
if re.fullmatch(r'[A-Za-z_][A-Za-z0-9_@$#]*', part) and part.upper() not in RESERVED_KEYWORDS:
|
|
77
|
+
return part
|
|
78
|
+
return f"[{part.replace(']', ']]')}]"
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def split_batches(sql: str) -> list[tuple[int, int]]:
|
|
82
|
+
"""
|
|
83
|
+
(inicio, fin) de cada lote del script, sin los GO. Se usan los tokens (no
|
|
84
|
+
el texto) para no cortar en un GO dentro de un string o un comentario.
|
|
85
|
+
"""
|
|
86
|
+
tokens = SqlServer().tokenize(sql)
|
|
87
|
+
batches, start, i = [], 0, 0
|
|
88
|
+
while i < len(tokens):
|
|
89
|
+
length = BaseParser._batch_separator_length(tokens, i)
|
|
90
|
+
if length:
|
|
91
|
+
batches.append((start, tokens[i].start))
|
|
92
|
+
start = tokens[i + length - 1].end + 1
|
|
93
|
+
i += length
|
|
94
|
+
else:
|
|
95
|
+
i += 1
|
|
96
|
+
batches.append((start, len(sql)))
|
|
97
|
+
return batches
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def find_object_header(sql: str) -> ObjectHeader | None:
|
|
101
|
+
for start, end in split_batches(sql):
|
|
102
|
+
header = _find_in_batch(sql, start, end)
|
|
103
|
+
if header:
|
|
104
|
+
return header
|
|
105
|
+
return None
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _find_in_batch(sql: str, batch_start: int, batch_end: int) -> ObjectHeader | None:
|
|
109
|
+
tokens = SqlServer().tokenize(sql[batch_start:batch_end])
|
|
110
|
+
|
|
111
|
+
for i, token in enumerate(tokens):
|
|
112
|
+
is_create = token.token_type == TokenType.CREATE
|
|
113
|
+
is_alter = (
|
|
114
|
+
token.token_type == TokenType.ALTER
|
|
115
|
+
and not (i > 0 and tokens[i - 1].token_type == TokenType.OR)
|
|
116
|
+
)
|
|
117
|
+
if not (is_create or is_alter):
|
|
118
|
+
continue
|
|
119
|
+
|
|
120
|
+
j = i + 1
|
|
121
|
+
# CREATE OR ALTER
|
|
122
|
+
if is_create and j + 1 < len(tokens) and tokens[j].token_type == TokenType.OR:
|
|
123
|
+
j += 2
|
|
124
|
+
if j >= len(tokens) or tokens[j].token_type not in OBJECT_TOKENS:
|
|
125
|
+
continue
|
|
126
|
+
|
|
127
|
+
start = batch_start + token.start
|
|
128
|
+
statements = [s for s in sqlglot.parse(sql[start:batch_end], read=SqlServer) if s]
|
|
129
|
+
statement = statements[0]
|
|
130
|
+
if not isinstance(statement, exp.Create):
|
|
131
|
+
return None
|
|
132
|
+
|
|
133
|
+
table = statement.this if isinstance(statement.this, exp.Table) else statement.this.this
|
|
134
|
+
return ObjectHeader(
|
|
135
|
+
type_code=_type_code(statement),
|
|
136
|
+
name=table.name,
|
|
137
|
+
schema=table.db or None,
|
|
138
|
+
start=start,
|
|
139
|
+
body_start=batch_start + tokens[j].end + 1,
|
|
140
|
+
end=batch_end,
|
|
141
|
+
statement=statement,
|
|
142
|
+
statements=tuple(statements),
|
|
143
|
+
)
|
|
144
|
+
|
|
145
|
+
return None
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def _type_code(statement: exp.Create) -> str:
|
|
149
|
+
kind = statement.kind
|
|
150
|
+
if kind in ('PROCEDURE', 'PROC'):
|
|
151
|
+
return PROCEDURE
|
|
152
|
+
if kind == 'VIEW':
|
|
153
|
+
return VIEW
|
|
154
|
+
if kind == 'TRIGGER':
|
|
155
|
+
return TRIGGER
|
|
156
|
+
|
|
157
|
+
returns = statement.find(exp.ReturnsProperty)
|
|
158
|
+
if not returns or not returns.args.get('is_table'):
|
|
159
|
+
return SCALAR_FUNCTION
|
|
160
|
+
return TABLE_FUNCTION if returns.args.get('table') else INLINE_FUNCTION
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
from sqlglot import exp
|
|
2
|
+
|
|
3
|
+
from get_objects_lib.dialect.expressions import TriggerSpec
|
|
4
|
+
from get_objects_lib.objects.header import (
|
|
5
|
+
INLINE_FUNCTION, PROCEDURE, SCALAR_FUNCTION, TABLE_FUNCTION, TRIGGER, VIEW, find_object_header, quote_name,
|
|
6
|
+
)
|
|
7
|
+
|
|
8
|
+
# CREATE minimo de cada tipo. Debe ser del mismo tipo que el objeto real:
|
|
9
|
+
# ALTER FUNCTION no puede cambiar una funcion escalar a una de tabla, ni una
|
|
10
|
+
# de tabla en linea a una de varias sentencias.
|
|
11
|
+
STUBS = {
|
|
12
|
+
PROCEDURE: 'CREATE PROCEDURE {name} AS RETURN 0;',
|
|
13
|
+
VIEW: 'CREATE VIEW {name} AS SELECT 1 AS Stub;',
|
|
14
|
+
SCALAR_FUNCTION: 'CREATE FUNCTION {name}() RETURNS INT AS BEGIN RETURN NULL END;',
|
|
15
|
+
INLINE_FUNCTION: 'CREATE FUNCTION {name}() RETURNS TABLE AS RETURN SELECT 1 AS Stub;',
|
|
16
|
+
TABLE_FUNCTION: 'CREATE FUNCTION {name}() RETURNS @Stub TABLE (Stub INT) AS BEGIN RETURN END;',
|
|
17
|
+
# Misma tabla, momento y eventos: un trigger AFTER no se puede crear sobre
|
|
18
|
+
# una vista ni un INSTEAD OF repetido sobre la misma accion
|
|
19
|
+
TRIGGER: 'CREATE TRIGGER {name} ON {table} {timing} {events} AS RETURN;',
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def standardize_object(sql: str, ansi_nulls: bool = False, quoted_identifier: bool = False) -> str:
|
|
24
|
+
"""
|
|
25
|
+
Convierte el CREATE / ALTER / CREATE OR ALTER de un procedimiento, funcion
|
|
26
|
+
o vista a la forma estandar: crea un objeto minimo si no existe y despues
|
|
27
|
+
aplica el ALTER con la definicion real.
|
|
28
|
+
|
|
29
|
+
El cuerpo se copia tal cual del texto original (sin regenerarlo); lo que
|
|
30
|
+
haya antes del CREATE/ALTER (comentarios, SET, GO) se descarta.
|
|
31
|
+
"""
|
|
32
|
+
header = find_object_header(sql)
|
|
33
|
+
if header is None:
|
|
34
|
+
raise ValueError('No se encontro un CREATE/ALTER de PROCEDURE, FUNCTION, VIEW o TRIGGER')
|
|
35
|
+
|
|
36
|
+
name = header.full_name
|
|
37
|
+
stub = STUBS[header.type_code].format(name=name, **_trigger_parts(header))
|
|
38
|
+
body = sql[header.body_start:header.end].rstrip()
|
|
39
|
+
# Mismo fin de linea que el script original (CRLF en archivos de SSMS)
|
|
40
|
+
newline = '\r\n' if '\r\n' in sql else '\n'
|
|
41
|
+
|
|
42
|
+
return newline.join([
|
|
43
|
+
f'SET ANSI_NULLS {"ON" if ansi_nulls else "OFF"};',
|
|
44
|
+
'GO',
|
|
45
|
+
f'SET QUOTED_IDENTIFIER {"ON" if quoted_identifier else "OFF"};',
|
|
46
|
+
'GO',
|
|
47
|
+
f"IF OBJECT_ID(N'{_escape(name)}', N'{header.type_code}') IS NULL",
|
|
48
|
+
f" EXEC (N'{_escape(stub)}');",
|
|
49
|
+
'GO',
|
|
50
|
+
f'ALTER {header.keyword}{body}',
|
|
51
|
+
'GO',
|
|
52
|
+
'',
|
|
53
|
+
])
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _trigger_parts(header) -> dict[str, str]:
|
|
57
|
+
if header.type_code != TRIGGER:
|
|
58
|
+
return {}
|
|
59
|
+
spec = header.statement.find(TriggerSpec)
|
|
60
|
+
table = spec.args['table']
|
|
61
|
+
if not isinstance(table, exp.Table):
|
|
62
|
+
# ON DATABASE / ON ALL SERVER: no estan en sys.objects (OBJECT_ID)
|
|
63
|
+
raise ValueError('Los triggers DDL (ON DATABASE / ALL SERVER) no estan soportados')
|
|
64
|
+
return {
|
|
65
|
+
'table': '.'.join(quote_name(p) for p in (table.catalog, table.db, table.name) if p),
|
|
66
|
+
'timing': spec.args['timing'],
|
|
67
|
+
'events': ', '.join(e.name for e in spec.args['events']),
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _escape(text: str) -> str:
|
|
72
|
+
return text.replace("'", "''")
|
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
Metadata-Version: 2.3
|
|
2
|
+
Name: get-objects-lib
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Extrae objetos referenciados en scripts T-SQL
|
|
5
|
+
Keywords: sql,tsql,sqlglot
|
|
6
|
+
Author: Ivan-Rosales
|
|
7
|
+
Author-email: 61328655+Ivan-Rosales@users.noreply.github.com
|
|
8
|
+
Requires-Python: >=3.14
|
|
9
|
+
Classifier: Programming Language :: Python :: 3
|
|
10
|
+
Classifier: Operating System :: OS Independent
|
|
11
|
+
Requires-Dist: sqlglot (>=30.18.0,<31.0.0)
|
|
12
|
+
Project-URL: Repository, https://gitlabv2.intelisis-solutions.com:8008/TICS/Interfaces/utilidades/get_objects_lib
|
|
13
|
+
Description-Content-Type: text/markdown
|
|
14
|
+
|
|
15
|
+
# get-objects-lib
|
|
16
|
+
|
|
17
|
+
Parser y generador de **T-SQL (SQL Server)** construido sobre [sqlglot](https://github.com/tobymao/sqlglot),
|
|
18
|
+
pensado para scripts reales: sin `;` entre sentencias, con `GO`, bloques `BEGIN ... END`,
|
|
19
|
+
procedimientos, funciones, vistas y triggers. Encima del dialecto hay un paquete (`objects`) para
|
|
20
|
+
analizar objetos:
|
|
21
|
+
|
|
22
|
+
- **`get_dependencies`**: qué procedimientos, funciones y tablas/vistas usa un query u objeto.
|
|
23
|
+
- **`standardize_object`**: convierte un `CREATE` / `ALTER` / `CREATE OR ALTER` a una forma estándar
|
|
24
|
+
que se puede ejecutar siempre (crea un objeto mínimo si no existe y luego aplica el `ALTER`).
|
|
25
|
+
|
|
26
|
+
## Instalación
|
|
27
|
+
|
|
28
|
+
Requiere Python 3.14 y [Poetry](https://python-poetry.org/).
|
|
29
|
+
|
|
30
|
+
```bash
|
|
31
|
+
poetry install
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
Los ejemplos y las pruebas se corren desde la raíz del proyecto.
|
|
35
|
+
|
|
36
|
+
## Uso
|
|
37
|
+
|
|
38
|
+
### Dialecto: parsear y generar T-SQL
|
|
39
|
+
|
|
40
|
+
```python
|
|
41
|
+
import sqlglot
|
|
42
|
+
from get_objects_lib import SqlServer
|
|
43
|
+
|
|
44
|
+
sql = """
|
|
45
|
+
IF @Modo = 1
|
|
46
|
+
EXEC dbo.spProcesar @ID
|
|
47
|
+
ELSE
|
|
48
|
+
BEGIN
|
|
49
|
+
UPDATE Venta SET Estatus = 'X' WHERE ID = @ID
|
|
50
|
+
PRINT 'actualizado'
|
|
51
|
+
END
|
|
52
|
+
"""
|
|
53
|
+
|
|
54
|
+
for statement in sqlglot.parse(sql, read=SqlServer):
|
|
55
|
+
print(repr(statement)) # árbol de expresiones
|
|
56
|
+
print(statement.sql(dialect=SqlServer)) # de vuelta a T-SQL
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
`sqlglot.parse` devuelve una sentencia por elemento. Un `GO` aparece como su propia sentencia
|
|
60
|
+
(`BatchSeparator`).
|
|
61
|
+
|
|
62
|
+
### Dependencias de un objeto o query
|
|
63
|
+
|
|
64
|
+
```python
|
|
65
|
+
from get_objects_lib import get_dependencies
|
|
66
|
+
|
|
67
|
+
for ref in get_dependencies(sql):
|
|
68
|
+
print(ref.kind, ref) # p.ej. "procedure dbo.spProcesar", "table_or_view Venta"
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
Cada resultado es un `ObjectRef(kind, name, schema, catalog, server)`:
|
|
72
|
+
|
|
73
|
+
| `kind` | Qué detecta |
|
|
74
|
+
|---|---|
|
|
75
|
+
| `procedure` | `EXEC proc`, `EXEC @r = proc`, `INSERT ... EXEC proc`, `sp_executesql` |
|
|
76
|
+
| `function` | `dbo.fn(...)` en cualquier expresión, funciones de tabla en `FROM` y `CROSS/OUTER APPLY` |
|
|
77
|
+
| `table_or_view` | tablas y vistas (sin catálogo no se pueden distinguir), incluida la tabla de un trigger |
|
|
78
|
+
|
|
79
|
+
- Salen en orden de aparición y sin repetidos. Sin esquema se asume `dbo`, así que `SendHttp` y
|
|
80
|
+
`dbo.SendHttp` cuentan una sola vez.
|
|
81
|
+
- Se ignoran: tablas temporales (`#t`, `##t`), variables de tabla (`@t`), CTEs, alias,
|
|
82
|
+
`inserted`/`deleted` de los triggers, métodos XML (`T.c.value(...)`), funciones de tabla del
|
|
83
|
+
sistema (`STRING_SPLIT`, `OPENJSON`, ...) y el propio objeto que se está definiendo.
|
|
84
|
+
- Si el script define un objeto, se analiza todo su lote (hasta el `GO`), como hace SQL Server.
|
|
85
|
+
- Soporta nombres de hasta cuatro partes (`servidor.base.esquema.objeto`).
|
|
86
|
+
|
|
87
|
+
### Forma estándar de un objeto
|
|
88
|
+
|
|
89
|
+
```python
|
|
90
|
+
from get_objects_lib import standardize_object
|
|
91
|
+
|
|
92
|
+
print(standardize_object(sql)) # SET ... OFF por defecto
|
|
93
|
+
print(standardize_object(sql, ansi_nulls=True, quoted_identifier=True))
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
Para un script como:
|
|
97
|
+
|
|
98
|
+
```sql
|
|
99
|
+
SET ANSI_NULLS ON;
|
|
100
|
+
GO
|
|
101
|
+
-- Comentario de encabezado
|
|
102
|
+
CREATE PROCEDURE [dbo].[spEjemplo] @ID INT
|
|
103
|
+
AS
|
|
104
|
+
BEGIN
|
|
105
|
+
SELECT * FROM Venta WHERE ID = @ID
|
|
106
|
+
END
|
|
107
|
+
GO
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
genera:
|
|
111
|
+
|
|
112
|
+
```sql
|
|
113
|
+
SET ANSI_NULLS OFF;
|
|
114
|
+
GO
|
|
115
|
+
SET QUOTED_IDENTIFIER OFF;
|
|
116
|
+
GO
|
|
117
|
+
IF OBJECT_ID(N'dbo.spEjemplo', N'P') IS NULL
|
|
118
|
+
EXEC (N'CREATE PROCEDURE dbo.spEjemplo AS RETURN 0;');
|
|
119
|
+
GO
|
|
120
|
+
ALTER PROCEDURE [dbo].[spEjemplo] @ID INT
|
|
121
|
+
AS
|
|
122
|
+
BEGIN
|
|
123
|
+
SELECT * FROM Venta WHERE ID = @ID
|
|
124
|
+
END
|
|
125
|
+
GO
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
- Acepta `CREATE`, `ALTER` y `CREATE OR ALTER` de procedimientos, funciones, vistas y triggers.
|
|
129
|
+
- **El cuerpo se copia tal cual** del texto original (comentarios y formato incluidos); lo que va
|
|
130
|
+
antes del `CREATE`/`ALTER` y los lotes posteriores (p.ej. un `GRANT` tras el `GO`) se descartan.
|
|
131
|
+
- El `CREATE` mínimo es del mismo tipo que el objeto real, porque `ALTER` no puede cambiarlo:
|
|
132
|
+
|
|
133
|
+
| Tipo (`OBJECT_ID`) | Objeto mínimo |
|
|
134
|
+
|---|---|
|
|
135
|
+
| `P` procedimiento | `CREATE PROCEDURE x AS RETURN 0;` |
|
|
136
|
+
| `V` vista | `CREATE VIEW x AS SELECT 1 AS Stub;` |
|
|
137
|
+
| `FN` función escalar | `CREATE FUNCTION x() RETURNS INT AS BEGIN RETURN NULL END;` |
|
|
138
|
+
| `IF` función de tabla en línea | `CREATE FUNCTION x() RETURNS TABLE AS RETURN SELECT 1 AS Stub;` |
|
|
139
|
+
| `TF` función de tabla de varias sentencias | `CREATE FUNCTION x() RETURNS @Stub TABLE (Stub INT) AS BEGIN RETURN END;` |
|
|
140
|
+
| `TR` trigger | `CREATE TRIGGER x ON tabla <momento> <eventos> AS RETURN;` (misma tabla, momento y eventos) |
|
|
141
|
+
|
|
142
|
+
- El nombre va sin corchetes salvo que los necesite (espacios, palabras reservadas: `dbo.[Order]`).
|
|
143
|
+
- Respeta el fin de línea del script (CRLF en archivos de SSMS).
|
|
144
|
+
- Lanza `ValueError` si el script no define un objeto, o si es un trigger DDL (`ON DATABASE`).
|
|
145
|
+
|
|
146
|
+
## Qué cubre el dialecto
|
|
147
|
+
|
|
148
|
+
Además de lo que ya soporta el T-SQL de sqlglot, el dialecto `SqlServer` maneja:
|
|
149
|
+
|
|
150
|
+
- **Separación de sentencias sin `;`** y lotes con `GO` / `GO n` (solo en su línea; no dentro de
|
|
151
|
+
strings o comentarios).
|
|
152
|
+
- **Control de flujo**: `IF` / `ELSE IF` / `ELSE` con y sin `BEGIN`, condiciones con subconsultas
|
|
153
|
+
(`IF (SELECT COUNT(*) ...) > 0`), `WHILE`, `BREAK`, `CONTINUE`, `RETURN [expr]`, `GOTO` y
|
|
154
|
+
etiquetas, bloques `BEGIN ... END` sueltos, `BEGIN TRY ... END CATCH`, `WAITFOR`.
|
|
155
|
+
- **Objetos**: `CREATE` / `ALTER` / `CREATE OR ALTER` de procedimientos (con o sin paréntesis en
|
|
156
|
+
los parámetros, `OUTPUT`/`OUT`), funciones escalares y de tabla, vistas y triggers; opciones
|
|
157
|
+
`WITH RECOMPILE`, `SCHEMABINDING`, `EXECUTE AS`, `RETURNS NULL ON NULL INPUT`, etc.
|
|
158
|
+
- **Sentencias**: `EXEC` con parámetros con nombre, valor de retorno, SQL dinámico (`EXEC (...)`,
|
|
159
|
+
`EXEC (...) AT servidor`), `INSERT ... EXEC`, `INSERT ... SELECT`, `DEFAULT VALUES`,
|
|
160
|
+
`DELETE/UPDATE TOP (n)`, `MERGE`, CTEs, cursores (`DECLARE ... CURSOR`, `OPEN`, `FETCH`, `CLOSE`,
|
|
161
|
+
`DEALLOCATE`), transacciones (`BEGIN TRAN`, `COMMIT`, `ROLLBACK`, `SAVE TRAN`), `PRINT`,
|
|
162
|
+
`RAISERROR`, `THROW`, asignaciones compuestas (`+=`, `-=`, ...), `SET TRANSACTION ISOLATION LEVEL`.
|
|
163
|
+
- **Tablas**: pistas con y sin `WITH` (`FROM Venta (NOLOCK)`), índices en línea en `CREATE TABLE`
|
|
164
|
+
y `DECLARE @t TABLE`.
|
|
165
|
+
- **Comandos no modelados** (`DBCC`, `BULK INSERT`, `UPDATE STATISTICS`, `ENABLE/DISABLE TRIGGER`,
|
|
166
|
+
`EXECUTE AS`, `REVERT`, `CHECKPOINT`, ...) se conservan como `Command` de su sentencia.
|
|
167
|
+
- **Generación fiel a T-SQL**: no reemplaza funciones (`SYSDATETIME`, `SUSER_SNAME`, `DATENAME`,
|
|
168
|
+
`DATEDIFF`, ...), conserva el nombre de las funciones de usuario y escribe `INT`.
|
|
169
|
+
|
|
170
|
+
### Expresiones propias
|
|
171
|
+
|
|
172
|
+
Definidas en [`get_objects_lib/dialect/expressions.py`](get_objects_lib/dialect/expressions.py):
|
|
173
|
+
`AlterObject`, `BatchSeparator`, `BeginEnd`, `CompoundAssignment`, `CursorStatement`, `DeclareCursor`,
|
|
174
|
+
`ExecParameter`, `ExecuteAt`, `FetchCursor`, `Goto`, `InlineIndex`, `Label`, `LoopControl`, `Print`,
|
|
175
|
+
`RaiseError`, `ReturnStatement`, `SaveTransaction`, `Throw`, `Top`, `TriggerSpec`, `TryCatch`, `WaitFor`.
|
|
176
|
+
|
|
177
|
+
## Estructura
|
|
178
|
+
|
|
179
|
+
```
|
|
180
|
+
get_objects_lib/ paquete instalable; expone SqlServer, get_dependencies,
|
|
181
|
+
│ standardize_object, ObjectRef, ObjectHeader, find_object_header
|
|
182
|
+
├── dialect/
|
|
183
|
+
│ ├── dialect.py dialecto SqlServer (une tokenizer, parser y generador)
|
|
184
|
+
│ ├── tokenizer.py GO/PRINT/FETCH sin tragarse el script, +=, nombres de variables
|
|
185
|
+
│ ├── expressions.py expresiones propias de T-SQL
|
|
186
|
+
│ ├── generator.py generacion de T-SQL
|
|
187
|
+
│ ├── text_utils.py
|
|
188
|
+
│ └── parser/
|
|
189
|
+
│ ├── non_semicolon.py separa el script en sentencias (chunks) sin depender de ;
|
|
190
|
+
│ ├── base.py reglas de inicio de sentencia y de bloques
|
|
191
|
+
│ └── sql_server.py parseo de cada construccion de T-SQL
|
|
192
|
+
└── objects/
|
|
193
|
+
├── header.py encabezado del objeto (CREATE/ALTER, tipo, nombre, lote)
|
|
194
|
+
├── dependencies.py get_dependencies
|
|
195
|
+
└── standardize.py standardize_object
|
|
196
|
+
tests/ pruebas; ver tests/README.md
|
|
197
|
+
├── golden.py utilidades y generacion de resultados esperados
|
|
198
|
+
├── test_*.py
|
|
199
|
+
└── fixtures/
|
|
200
|
+
├── dialect/ scripts del parser/generador + expected/
|
|
201
|
+
└── objects/ scripts de objetos + expected/
|
|
202
|
+
```
|
|
203
|
+
|
|
204
|
+
## Pruebas
|
|
205
|
+
|
|
206
|
+
```bash
|
|
207
|
+
poetry run python -m unittest
|
|
208
|
+
```
|
|
209
|
+
|
|
210
|
+
Son pruebas *golden*: cada script de `tests/fixtures/` se compara con sus resultados esperados en
|
|
211
|
+
`expected/` (árbol, SQL generado, dependencias y forma estándar), y además todos los scripts deben
|
|
212
|
+
cumplir propiedades generales (el SQL generado es estable, el árbol es el mismo con CRLF, unidos con
|
|
213
|
+
`GO` conservan sus sentencias). Los casos se descubren solos.
|
|
214
|
+
|
|
215
|
+
Para agregar un caso: crea el script en `tests/fixtures/dialect/` o `tests/fixtures/objects/`, genera
|
|
216
|
+
sus resultados esperados con `poetry run python -m tests.golden <script>.sql`, **revísalos a mano** y
|
|
217
|
+
agrégalo al catálogo de [`tests/README.md`](tests/README.md), que describe qué cubre cada caso.
|
|
218
|
+
|
|
219
|
+
## Limitaciones y decisiones de diseño
|
|
220
|
+
|
|
221
|
+
- **Cuerpo de un procedimiento sin `BEGIN`**: en SQL Server llega hasta el `GO`; el parser toma una
|
|
222
|
+
sola sentencia (o hasta el `END` si tiene `BEGIN`). `get_dependencies` sí analiza todo el lote.
|
|
223
|
+
- **Un objeto por script**: si un script define varios, `standardize_object` y `get_dependencies`
|
|
224
|
+
toman el primero.
|
|
225
|
+
- **SQL dinámico**: lo que se ejecuta con `EXEC (@sql)` o `sp_executesql` no se analiza.
|
|
226
|
+
- **Comandos no modelados** (`Command`): su contenido no se analiza para dependencias (p.ej. la
|
|
227
|
+
tabla de un `BULK INSERT`).
|
|
228
|
+
- **Normalizaciones del generador** (equivalentes en T-SQL): agrega `AS` en los alias, escribe
|
|
229
|
+
`WITH (NOLOCK)` aunque el original no tenga `WITH`, `OUT` como `OUTPUT`, `IS NOT NULL` como
|
|
230
|
+
`NOT ... IS NULL`, y agrega alias a las columnas de tablas derivadas y CTEs (`SELECT ID AS ID`).
|
|
231
|
+
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
get_objects_lib/__init__.py,sha256=KjA6O95xtpO2bxmip7zmQq1WemcMBk4sEEvlA1qVixY,407
|
|
2
|
+
get_objects_lib/dialect/__init__.py,sha256=icwyd8tPZh_aapJoVyKcFAriIU624Bvx1BQHOdIApLE,82
|
|
3
|
+
get_objects_lib/dialect/dialect.py,sha256=c6a3ABNzWImM6emwhg_QK4lek5eKPQdd7GSTes6OZhg,375
|
|
4
|
+
get_objects_lib/dialect/expressions.py,sha256=w1A_ygewfG8mW-tae8CJYyvdp8sNzcviPJs_ClXdXKM,3645
|
|
5
|
+
get_objects_lib/dialect/generator.py,sha256=ZecR-6REKWxmuItDNFSixX-2lW02NDNpK10buz5UVNc,11897
|
|
6
|
+
get_objects_lib/dialect/parser/__init__.py,sha256=DNYPQkU45SXYSqf_-3MqNiRh17mlSmqe0prkdRh2Jtk,104
|
|
7
|
+
get_objects_lib/dialect/parser/base.py,sha256=iGVJN-hWXecxjX9bSaBrwBJvftcg_tr5I0jRjK-7eF0,11535
|
|
8
|
+
get_objects_lib/dialect/parser/non_semicolon.py,sha256=jvEkuNbyyk6WBjaKesXxNbVHh_vgYwhmvC4Xei9zYQ0,7677
|
|
9
|
+
get_objects_lib/dialect/parser/sql_server.py,sha256=oUr1mEE75NgRfPNKAXxf_1lePF0f133s7nq_o_DE1sA,23863
|
|
10
|
+
get_objects_lib/dialect/text_utils.py,sha256=bzwC9fctYgE0NGOAgJ5INjjCNzizfDejlAiQ0yG3YlM,624
|
|
11
|
+
get_objects_lib/dialect/tokenizer.py,sha256=xNkNRSoKJQ4v1HugzoUEIvhvCb323wiaeSsP1ZjCO-4,2265
|
|
12
|
+
get_objects_lib/objects/__init__.py,sha256=WPasqsK1SbjGTGBmveqj2FaJjoa5NW99CF0WzMzPY4g,223
|
|
13
|
+
get_objects_lib/objects/dependencies.py,sha256=B4EY-G9KMTgLS6LmN7kAht0DmCKkuHBsXbh5QaxJ09c,6363
|
|
14
|
+
get_objects_lib/objects/header.py,sha256=QALNGQrwDE82VAkPvgR1WWvGACtX2KJBv5rqEQFSVF4,6076
|
|
15
|
+
get_objects_lib/objects/standardize.py,sha256=cMan6pcTVunyvkC7PP3dcfD5jnQp8SHQcGmU0Imyit8,3112
|
|
16
|
+
get_objects_lib-0.1.0.dist-info/METADATA,sha256=bCwMFnQcJ1pFY9IN9P3DWEg9e5pnyPKIl5pDZS_95X4,10320
|
|
17
|
+
get_objects_lib-0.1.0.dist-info/WHEEL,sha256=XbeZDeTWKc1w7CSIyre5aMDU_-PohRwTQceYnisIYYY,88
|
|
18
|
+
get_objects_lib-0.1.0.dist-info/RECORD,,
|