lkr-dev-cli 0.0.57__tar.gz → 0.0.58__tar.gz
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.
- {lkr_dev_cli-0.0.57 → lkr_dev_cli-0.0.58}/.pre-commit-config.yaml +7 -2
- {lkr_dev_cli-0.0.57 → lkr_dev_cli-0.0.58}/Makefile +2 -2
- {lkr_dev_cli-0.0.57 → lkr_dev_cli-0.0.58}/PKG-INFO +1 -1
- {lkr_dev_cli-0.0.57 → lkr_dev_cli-0.0.58}/lkr/codemode/main.py +1 -33
- {lkr_dev_cli-0.0.57 → lkr_dev_cli-0.0.58}/lkr/codemode/type.py +27 -0
- {lkr_dev_cli-0.0.57 → lkr_dev_cli-0.0.58}/lkr/parse/constants.py +2 -0
- {lkr_dev_cli-0.0.57 → lkr_dev_cli-0.0.58}/lkr/parse/lookml.py +1 -3
- lkr_dev_cli-0.0.58/lkr/parse/sql.py +445 -0
- {lkr_dev_cli-0.0.57 → lkr_dev_cli-0.0.58}/lkr/parse/sql_to_lookml.py +502 -141
- {lkr_dev_cli-0.0.57 → lkr_dev_cli-0.0.58}/lkr/parse/types.py +2 -0
- {lkr_dev_cli-0.0.57 → lkr_dev_cli-0.0.58}/lkr/schema/main.py +1 -1
- {lkr_dev_cli-0.0.57 → lkr_dev_cli-0.0.58}/lkr.md +105 -0
- {lkr_dev_cli-0.0.57 → lkr_dev_cli-0.0.58}/pyproject.toml +2 -1
- lkr_dev_cli-0.0.58/scripts/schema/__init__.py +1 -0
- {lkr_dev_cli-0.0.57 → lkr_dev_cli-0.0.58}/tests/test_dependency_resolution.py +3 -0
- {lkr_dev_cli-0.0.57 → lkr_dev_cli-0.0.58}/tests/test_parse_lookml.py +466 -0
- {lkr_dev_cli-0.0.57 → lkr_dev_cli-0.0.58}/tests/test_parse_sql.py +47 -0
- {lkr_dev_cli-0.0.57 → lkr_dev_cli-0.0.58}/uv.lock +457 -524
- lkr_dev_cli-0.0.57/lkr/codemode/__init__.py +0 -3
- lkr_dev_cli-0.0.57/lkr/parse/sql.py +0 -243
- {lkr_dev_cli-0.0.57 → lkr_dev_cli-0.0.58}/.agents/skills/lkr-code-mode/SKILL.md +0 -0
- {lkr_dev_cli-0.0.57 → lkr_dev_cli-0.0.58}/.agents/skills/lkr-code-mode/scripts/examples.py +0 -0
- {lkr_dev_cli-0.0.57 → lkr_dev_cli-0.0.58}/.agents/skills/lkr-code-mode/scripts/order_items_query.py +0 -0
- {lkr_dev_cli-0.0.57 → lkr_dev_cli-0.0.58}/.agents/skills/lkr-code-mode/scripts/personal_folder_dashboards.py +0 -0
- {lkr_dev_cli-0.0.57 → lkr_dev_cli-0.0.58}/.agents/skills/lkr-code-mode/scripts/readme.py +0 -0
- {lkr_dev_cli-0.0.57 → lkr_dev_cli-0.0.58}/.agents/skills/lkr-code-mode/scripts/traverse_folders.py +0 -0
- {lkr_dev_cli-0.0.57 → lkr_dev_cli-0.0.58}/.agents/skills/lkr-project-helper/SKILL.md +0 -0
- {lkr_dev_cli-0.0.57 → lkr_dev_cli-0.0.58}/.agents/skills/lkr-project-helper/scripts/bq_view_lookml_generator.py +0 -0
- {lkr_dev_cli-0.0.57 → lkr_dev_cli-0.0.58}/.agents/skills/lkr-project-helper/scripts/configure_model.py +0 -0
- {lkr_dev_cli-0.0.57 → lkr_dev_cli-0.0.58}/.agents/skills/lkr-project-helper/scripts/create_custom_view.py +0 -0
- {lkr_dev_cli-0.0.57 → lkr_dev_cli-0.0.58}/.agents/skills/lkr-project-helper/scripts/deploy_and_reset_workspace.py +0 -0
- {lkr_dev_cli-0.0.57 → lkr_dev_cli-0.0.58}/.agents/skills/lkr-project-helper/scripts/ensure_project_and_model.py +0 -0
- {lkr_dev_cli-0.0.57 → lkr_dev_cli-0.0.58}/.agents/skills/lkr-project-helper/scripts/generate_lookml.py +0 -0
- {lkr_dev_cli-0.0.57 → lkr_dev_cli-0.0.58}/.agents/skills/lkr-project-helper/scripts/init_project.py +0 -0
- {lkr_dev_cli-0.0.57 → lkr_dev_cli-0.0.58}/.agents/skills/lkr-project-helper/scripts/inspect_connections.py +0 -0
- {lkr_dev_cli-0.0.57 → lkr_dev_cli-0.0.58}/.agents/skills/lkr-project-helper/scripts/migrate_dashboard_to_udd.py +0 -0
- {lkr_dev_cli-0.0.57 → lkr_dev_cli-0.0.58}/.agents/skills/lkr-project-helper/scripts/upload_assets.py +0 -0
- {lkr_dev_cli-0.0.57 → lkr_dev_cli-0.0.58}/.agents/skills/lkr-project-helper/scripts/validate_and_commit.py +0 -0
- {lkr_dev_cli-0.0.57 → lkr_dev_cli-0.0.58}/.github/workflows/ci.yml +0 -0
- {lkr_dev_cli-0.0.57 → lkr_dev_cli-0.0.58}/.github/workflows/release.yml +0 -0
- {lkr_dev_cli-0.0.57 → lkr_dev_cli-0.0.58}/.github/workflows/website.yml +0 -0
- {lkr_dev_cli-0.0.57 → lkr_dev_cli-0.0.58}/.gitignore +0 -0
- {lkr_dev_cli-0.0.57 → lkr_dev_cli-0.0.58}/.python-version +0 -0
- {lkr_dev_cli-0.0.57 → lkr_dev_cli-0.0.58}/.vscode/launch.json +0 -0
- {lkr_dev_cli-0.0.57 → lkr_dev_cli-0.0.58}/.vscode/settings.json +0 -0
- {lkr_dev_cli-0.0.57 → lkr_dev_cli-0.0.58}/AGENTS.md +0 -0
- {lkr_dev_cli-0.0.57 → lkr_dev_cli-0.0.58}/Dockerfile +0 -0
- {lkr_dev_cli-0.0.57 → lkr_dev_cli-0.0.58}/LICENSE +0 -0
- {lkr_dev_cli-0.0.57 → lkr_dev_cli-0.0.58}/README.md +0 -0
- {lkr_dev_cli-0.0.57 → lkr_dev_cli-0.0.58}/TODO.md +0 -0
- {lkr_dev_cli-0.0.57 → lkr_dev_cli-0.0.58}/cloudbuild.yaml +0 -0
- {lkr_dev_cli-0.0.57 → lkr_dev_cli-0.0.58}/codemode.md +0 -0
- {lkr_dev_cli-0.0.57 → lkr_dev_cli-0.0.58}/lkr/__init__.py +0 -0
- {lkr_dev_cli-0.0.57 → lkr_dev_cli-0.0.58}/lkr/auth/__init__.py +0 -0
- {lkr_dev_cli-0.0.57 → lkr_dev_cli-0.0.58}/lkr/auth/main.py +0 -0
- {lkr_dev_cli-0.0.57 → lkr_dev_cli-0.0.58}/lkr/auth/oauth.py +0 -0
- {lkr_dev_cli-0.0.57 → lkr_dev_cli-0.0.58}/lkr/auth_service.py +0 -0
- {lkr_dev_cli-0.0.57 → lkr_dev_cli-0.0.58}/lkr/classes.py +0 -0
- {lkr_dev_cli-0.0.57 → lkr_dev_cli-0.0.58}/lkr/codemode/LOCAL.md +0 -0
- {lkr_dev_cli-0.0.57/scripts/schema → lkr_dev_cli-0.0.58/lkr/codemode}/__init__.py +0 -0
- {lkr_dev_cli-0.0.57 → lkr_dev_cli-0.0.58}/lkr/codemode/constant.py +0 -0
- {lkr_dev_cli-0.0.57 → lkr_dev_cli-0.0.58}/lkr/codemode/download_swagger.py +0 -0
- {lkr_dev_cli-0.0.57 → lkr_dev_cli-0.0.58}/lkr/codemode/examples.py +0 -0
- {lkr_dev_cli-0.0.57 → lkr_dev_cli-0.0.58}/lkr/codemode/help.py +0 -0
- {lkr_dev_cli-0.0.57 → lkr_dev_cli-0.0.58}/lkr/codemode/readme.py +0 -0
- {lkr_dev_cli-0.0.57 → lkr_dev_cli-0.0.58}/lkr/codemode/swagger.json +0 -0
- {lkr_dev_cli-0.0.57 → lkr_dev_cli-0.0.58}/lkr/constants.py +0 -0
- {lkr_dev_cli-0.0.57 → lkr_dev_cli-0.0.58}/lkr/custom_types.py +0 -0
- {lkr_dev_cli-0.0.57 → lkr_dev_cli-0.0.58}/lkr/exceptions.py +0 -0
- {lkr_dev_cli-0.0.57 → lkr_dev_cli-0.0.58}/lkr/extended_sdk_methods/__init__.py +0 -0
- {lkr_dev_cli-0.0.57 → lkr_dev_cli-0.0.58}/lkr/extended_sdk_methods/classes.py +0 -0
- {lkr_dev_cli-0.0.57 → lkr_dev_cli-0.0.58}/lkr/extended_sdk_methods/main.py +0 -0
- {lkr_dev_cli-0.0.57 → lkr_dev_cli-0.0.58}/lkr/logger.py +0 -0
- {lkr_dev_cli-0.0.57 → lkr_dev_cli-0.0.58}/lkr/main.py +0 -0
- {lkr_dev_cli-0.0.57 → lkr_dev_cli-0.0.58}/lkr/mcp/classes.py +0 -0
- {lkr_dev_cli-0.0.57 → lkr_dev_cli-0.0.58}/lkr/mcp/main.py +0 -0
- {lkr_dev_cli-0.0.57 → lkr_dev_cli-0.0.58}/lkr/mcp/utils.py +0 -0
- {lkr_dev_cli-0.0.57 → lkr_dev_cli-0.0.58}/lkr/observability/classes.py +0 -0
- {lkr_dev_cli-0.0.57 → lkr_dev_cli-0.0.58}/lkr/observability/embed_container.html +0 -0
- {lkr_dev_cli-0.0.57 → lkr_dev_cli-0.0.58}/lkr/observability/main.py +0 -0
- {lkr_dev_cli-0.0.57 → lkr_dev_cli-0.0.58}/lkr/observability/utils.py +0 -0
- {lkr_dev_cli-0.0.57 → lkr_dev_cli-0.0.58}/lkr/parse/__init__.py +0 -0
- {lkr_dev_cli-0.0.57 → lkr_dev_cli-0.0.58}/lkr/parse/dag.py +0 -0
- {lkr_dev_cli-0.0.57 → lkr_dev_cli-0.0.58}/lkr/parse/lookml_to_api.py +0 -0
- {lkr_dev_cli-0.0.57 → lkr_dev_cli-0.0.58}/lkr/parse/main.py +0 -0
- {lkr_dev_cli-0.0.57 → lkr_dev_cli-0.0.58}/lkr/schema/__init__.py +0 -0
- {lkr_dev_cli-0.0.57 → lkr_dev_cli-0.0.58}/lkr/schema/_schema_rs.so +0 -0
- {lkr_dev_cli-0.0.57 → lkr_dev_cli-0.0.58}/lkr/schema/builder.py +0 -0
- {lkr_dev_cli-0.0.57 → lkr_dev_cli-0.0.58}/lkr/schema/dynamic_fields.py +0 -0
- {lkr_dev_cli-0.0.57 → lkr_dev_cli-0.0.58}/lkr/schema/fields.py +0 -0
- {lkr_dev_cli-0.0.57 → lkr_dev_cli-0.0.58}/lkr/schema/filter_expressions.py +0 -0
- {lkr_dev_cli-0.0.57 → lkr_dev_cli-0.0.58}/lkr/schema/rust/Cargo.lock +0 -0
- {lkr_dev_cli-0.0.57 → lkr_dev_cli-0.0.58}/lkr/schema/rust/Cargo.toml +0 -0
- {lkr_dev_cli-0.0.57 → lkr_dev_cli-0.0.58}/lkr/schema/rust/src/lib.rs +0 -0
- {lkr_dev_cli-0.0.57 → lkr_dev_cli-0.0.58}/lkr/schema/validator.py +0 -0
- {lkr_dev_cli-0.0.57 → lkr_dev_cli-0.0.58}/lkr/tools/agents.py +0 -0
- {lkr_dev_cli-0.0.57 → lkr_dev_cli-0.0.58}/lkr/tools/classes.py +0 -0
- {lkr_dev_cli-0.0.57 → lkr_dev_cli-0.0.58}/lkr/tools/lookml.py +0 -0
- {lkr_dev_cli-0.0.57 → lkr_dev_cli-0.0.58}/lkr/tools/main.py +0 -0
- {lkr_dev_cli-0.0.57 → lkr_dev_cli-0.0.58}/lkr/tools/permission_deprecation.py +0 -0
- {lkr_dev_cli-0.0.57 → lkr_dev_cli-0.0.58}/lkr_dev_cli/__init__.py +0 -0
- {lkr_dev_cli-0.0.57 → lkr_dev_cli-0.0.58}/scripts/schema/README.md +0 -0
- {lkr_dev_cli-0.0.57 → lkr_dev_cli-0.0.58}/scripts/schema/history.py +0 -0
- {lkr_dev_cli-0.0.57 → lkr_dev_cli-0.0.58}/tests/TESTING.md +0 -0
- {lkr_dev_cli-0.0.57 → lkr_dev_cli-0.0.58}/tests/test_agents_tools.py +0 -0
- {lkr_dev_cli-0.0.57 → lkr_dev_cli-0.0.58}/tests/test_codemode.py +0 -0
- {lkr_dev_cli-0.0.57 → lkr_dev_cli-0.0.58}/tests/test_deps.sh +0 -0
- {lkr_dev_cli-0.0.57 → lkr_dev_cli-0.0.58}/tests/test_extended_sdk_methods.py +0 -0
- {lkr_dev_cli-0.0.57 → lkr_dev_cli-0.0.58}/tests/test_init_sdk.py +0 -0
- {lkr_dev_cli-0.0.57 → lkr_dev_cli-0.0.58}/tests/test_lookml_tools.py +0 -0
- {lkr_dev_cli-0.0.57 → lkr_dev_cli-0.0.58}/tests/test_oauth_account.py +0 -0
- {lkr_dev_cli-0.0.57 → lkr_dev_cli-0.0.58}/tests/test_permission_deprecation.py +0 -0
- {lkr_dev_cli-0.0.57 → lkr_dev_cli-0.0.58}/tests/test_schema.py +0 -0
- {lkr_dev_cli-0.0.57 → lkr_dev_cli-0.0.58}/tests/test_version.py +0 -0
- {lkr_dev_cli-0.0.57 → lkr_dev_cli-0.0.58}/ty.toml +0 -0
|
@@ -6,6 +6,11 @@ repos:
|
|
|
6
6
|
args: ["--default-index", "https://pypi.org/simple", "--no-config"]
|
|
7
7
|
- repo: local
|
|
8
8
|
hooks:
|
|
9
|
+
- id: uv-lock-index
|
|
10
|
+
name: check uv.lock index
|
|
11
|
+
entry: bash -c 'if grep -Fq "airlock-proxy.uplink" uv.lock; then echo "uv.lock needs default index, not proprietary"; exit 1; fi'
|
|
12
|
+
language: system
|
|
13
|
+
pass_filenames: false
|
|
9
14
|
- id: docs
|
|
10
15
|
name: generate cli docs
|
|
11
16
|
entry: bash -c "make docs && git add lkr.md"
|
|
@@ -13,12 +18,12 @@ repos:
|
|
|
13
18
|
pass_filenames: false
|
|
14
19
|
- id: ruff-check
|
|
15
20
|
name: ruff check
|
|
16
|
-
entry: uv run ruff check .
|
|
21
|
+
entry: uv run --extra all ruff check .
|
|
17
22
|
language: system
|
|
18
23
|
pass_filenames: false
|
|
19
24
|
- id: ty-check
|
|
20
25
|
name: ty check
|
|
21
|
-
entry: uv run ty check
|
|
26
|
+
entry: uv run --extra all ty check
|
|
22
27
|
language: system
|
|
23
28
|
pass_filenames: false
|
|
24
29
|
- id: test-and-coverage
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
.PHONY: docs test-deps codemode-test codemode-start schema-rs test-rs coverage-py coverage-rs coverage
|
|
2
2
|
|
|
3
3
|
docs:
|
|
4
|
-
uv run typer lkr/main.py utils docs --output lkr.md
|
|
4
|
+
uv run --extra all typer lkr/main.py utils docs --output lkr.md
|
|
5
5
|
|
|
6
6
|
test-deps:
|
|
7
7
|
python tests/test_dependency_resolution.py
|
|
@@ -17,7 +17,7 @@ test-rs:
|
|
|
17
17
|
cargo test --no-default-features --manifest-path lkr/schema/rust/Cargo.toml
|
|
18
18
|
|
|
19
19
|
coverage-py:
|
|
20
|
-
uv run pytest tests/test_schema.py --cov=lkr.schema --cov=scripts.schema --cov-report=term-missing --cov-report=xml:coverage.xml
|
|
20
|
+
uv run --extra all pytest tests/test_schema.py --cov=lkr.schema --cov=scripts.schema --cov-report=term-missing --cov-report=xml:coverage.xml
|
|
21
21
|
|
|
22
22
|
coverage-rs:
|
|
23
23
|
RUSTFLAGS="-C instrument-coverage" LLVM_PROFILE_FILE="target/cov-%p-%m.profraw" cargo test --no-default-features --manifest-path lkr/schema/rust/Cargo.toml
|
|
@@ -18,7 +18,7 @@ from lkr.codemode.constant import EXCLUDED_FUNCS
|
|
|
18
18
|
from lkr.codemode.examples import EXAMPLES
|
|
19
19
|
from lkr.codemode.help import lookup_function, search_help, search_with_lookups
|
|
20
20
|
from lkr.codemode.readme import get_readme
|
|
21
|
-
from lkr.codemode.type import lookup_type
|
|
21
|
+
from lkr.codemode.type import lookup_type, to_primitive
|
|
22
22
|
from lkr.logger import logger
|
|
23
23
|
|
|
24
24
|
__all__ = ["group"]
|
|
@@ -65,38 +65,6 @@ def get_mcp_sdk(ctx: LkrCtxObj):
|
|
|
65
65
|
return sdk
|
|
66
66
|
|
|
67
67
|
|
|
68
|
-
|
|
69
|
-
def to_primitive(obj):
|
|
70
|
-
seen = set()
|
|
71
|
-
|
|
72
|
-
def _to_primitive(o):
|
|
73
|
-
if isinstance(o, (str, int, float, bool, type(None))):
|
|
74
|
-
return o
|
|
75
|
-
|
|
76
|
-
obj_id = id(o)
|
|
77
|
-
if obj_id in seen:
|
|
78
|
-
return f"<Circular reference to {type(o).__name__}>"
|
|
79
|
-
seen.add(obj_id)
|
|
80
|
-
|
|
81
|
-
try:
|
|
82
|
-
if isinstance(o, list):
|
|
83
|
-
return [_to_primitive(item) for item in o]
|
|
84
|
-
elif isinstance(o, dict):
|
|
85
|
-
return {k: _to_primitive(v) for k, v in o.items()}
|
|
86
|
-
else:
|
|
87
|
-
try:
|
|
88
|
-
return _to_primitive(vars(o))
|
|
89
|
-
except TypeError:
|
|
90
|
-
return str(o)
|
|
91
|
-
except Exception: # noqa: BLE001
|
|
92
|
-
return str(o)
|
|
93
|
-
finally:
|
|
94
|
-
seen.remove(obj_id)
|
|
95
|
-
|
|
96
|
-
return _to_primitive(obj)
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
68
|
@mcp.tool()
|
|
101
69
|
def run_python_code(
|
|
102
70
|
code: str, dev_mode: bool = False, allow_update_session: bool = False
|
|
@@ -10,6 +10,33 @@ _swagger_data = None
|
|
|
10
10
|
_ext_definitions_cache = None
|
|
11
11
|
|
|
12
12
|
|
|
13
|
+
def to_primitive(obj):
|
|
14
|
+
seen = set()
|
|
15
|
+
|
|
16
|
+
def _to_primitive(o):
|
|
17
|
+
if isinstance(o, (str, int, float, bool, type(None))):
|
|
18
|
+
return o
|
|
19
|
+
|
|
20
|
+
obj_id = id(o)
|
|
21
|
+
if obj_id in seen:
|
|
22
|
+
return f"<Circular reference to {type(o).__name__}>"
|
|
23
|
+
seen.add(obj_id)
|
|
24
|
+
|
|
25
|
+
try:
|
|
26
|
+
if isinstance(o, list):
|
|
27
|
+
return [_to_primitive(item) for item in o]
|
|
28
|
+
if isinstance(o, dict):
|
|
29
|
+
return {k: _to_primitive(v) for k, v in o.items()}
|
|
30
|
+
try:
|
|
31
|
+
return _to_primitive(vars(o))
|
|
32
|
+
except Exception: # noqa: BLE001
|
|
33
|
+
return str(o)
|
|
34
|
+
finally:
|
|
35
|
+
seen.remove(obj_id)
|
|
36
|
+
|
|
37
|
+
return _to_primitive(obj)
|
|
38
|
+
|
|
39
|
+
|
|
13
40
|
def _get_swagger_data():
|
|
14
41
|
global _swagger_data
|
|
15
42
|
if _swagger_data is not None:
|
|
@@ -230,6 +230,8 @@ SQL_TO_LOOKML_DOC: dict[str, str] = {
|
|
|
230
230
|
"queries[].views[].fields[].via": "Transitive dependency path of ${...} references in LookML DAG leading to this field",
|
|
231
231
|
"queries[].views[].fields[].explores": "Explores exposing this field with active view alias and join info",
|
|
232
232
|
"queries[].views[].fields[].tests": "Existing LookML tests associated with this field that must pass",
|
|
233
|
+
"queries[].views[].unmodeled_db_columns": "Columns present in the database table or SQL query that have no matching field in this LookML view",
|
|
234
|
+
"queries[].views[].missing_db_columns": "Physical columns referenced by this LookML view (${TABLE}.col) that do not exist in the database table",
|
|
233
235
|
"queries[].explores": "All reachable explores exposing any matched fields from this query",
|
|
234
236
|
"queries[].tests": "All existing LookML tests across the LookML DAG that must still pass after changes",
|
|
235
237
|
}
|
|
@@ -388,9 +388,7 @@ class LookmlDerivedTable(_LookmlBase):
|
|
|
388
388
|
datagroup_trigger: str | None = None
|
|
389
389
|
distribution: str | None = None
|
|
390
390
|
distribution_style: str | None = None
|
|
391
|
-
explore_source:
|
|
392
|
-
str | dict[str, LookmlExploreSource | Any] | LookmlExploreSource | None
|
|
393
|
-
) = None
|
|
391
|
+
explore_source: dict[str, LookmlExploreSource] | LookmlExploreSource | None = None
|
|
394
392
|
increment_key: str | None = None
|
|
395
393
|
increment_offset: int | None = None
|
|
396
394
|
indexes: list[str] | None = None
|
|
@@ -0,0 +1,445 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
from typing import Any
|
|
3
|
+
|
|
4
|
+
import sqlglot
|
|
5
|
+
from pydantic import BaseModel, Field
|
|
6
|
+
from sqlglot import exp
|
|
7
|
+
from sqlglot.dialects.dialect import Dialect
|
|
8
|
+
from sqlglot.errors import ErrorLevel, SqlglotError
|
|
9
|
+
from sqlglot.optimizer.pushdown_projections import pushdown_projections
|
|
10
|
+
from sqlglot.optimizer.qualify import qualify
|
|
11
|
+
from sqlglot.tokens import Token, TokenType
|
|
12
|
+
|
|
13
|
+
__all__ = [
|
|
14
|
+
"CTESpec",
|
|
15
|
+
"ColumnSpec",
|
|
16
|
+
"JoinSpec",
|
|
17
|
+
"ParsedQuery",
|
|
18
|
+
"SqlParseResult",
|
|
19
|
+
"TableRef",
|
|
20
|
+
"parse_sql",
|
|
21
|
+
]
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class TableRef(BaseModel):
|
|
25
|
+
name: str
|
|
26
|
+
db: str | None = None
|
|
27
|
+
catalog: str | None = None
|
|
28
|
+
alias: str | None = None
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class JoinSpec(BaseModel):
|
|
32
|
+
table: str
|
|
33
|
+
side: str | None = None
|
|
34
|
+
kind: str | None = None
|
|
35
|
+
on_sql: str | None = None
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class ColumnSpec(BaseModel):
|
|
39
|
+
alias: str | None = None
|
|
40
|
+
sql: str
|
|
41
|
+
table: str | None = None
|
|
42
|
+
column: str | None = None
|
|
43
|
+
is_agg: bool = False
|
|
44
|
+
except_: list[str] = Field(default_factory=list)
|
|
45
|
+
replace: dict[str, str] = Field(default_factory=dict)
|
|
46
|
+
rename: dict[str, str] = Field(default_factory=dict)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class CTESpec(BaseModel):
|
|
50
|
+
name: str
|
|
51
|
+
sql: str
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class ParsedQuery(BaseModel):
|
|
55
|
+
statement_type: str | None = None
|
|
56
|
+
raw_sql: str
|
|
57
|
+
tables: list[TableRef] = Field(default_factory=list)
|
|
58
|
+
ctes: list[CTESpec] = Field(default_factory=list)
|
|
59
|
+
joins: list[JoinSpec] = Field(default_factory=list)
|
|
60
|
+
columns: list[ColumnSpec] = Field(default_factory=list)
|
|
61
|
+
where: str | None = None
|
|
62
|
+
having: str | None = None
|
|
63
|
+
group_by: list[str] = Field(default_factory=list)
|
|
64
|
+
order_by: list[str] = Field(default_factory=list)
|
|
65
|
+
limit: str | None = None
|
|
66
|
+
error: str | None = None
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class SqlParseResult(BaseModel):
|
|
70
|
+
queries: list[ParsedQuery] = Field(default_factory=list)
|
|
71
|
+
dialect: str | None = None
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _main_select(expr: exp.Expr) -> exp.Select | None:
|
|
75
|
+
target = (
|
|
76
|
+
expr.expression
|
|
77
|
+
if isinstance(expr, (exp.Create, exp.Insert)) and expr.expression
|
|
78
|
+
else expr
|
|
79
|
+
)
|
|
80
|
+
while isinstance(target, (exp.Subquery, exp.Paren, exp.SetOperation)):
|
|
81
|
+
target = target.this
|
|
82
|
+
return target if isinstance(target, exp.Select) else None
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _split_table_ref(tbl: exp.Table) -> TableRef:
|
|
86
|
+
name = tbl.name.strip("`\"'[] ")
|
|
87
|
+
db = (tbl.db or "").strip("`\"'[] ") or None
|
|
88
|
+
catalog = (tbl.catalog or "").strip("`\"'[] ") or None
|
|
89
|
+
if not db and not catalog and "." in name:
|
|
90
|
+
parts = [p.strip("`\"'[] ") for p in name.split(".") if p.strip("`\"'[] ")]
|
|
91
|
+
catalog, db, name = ([None, None, *parts])[-3:]
|
|
92
|
+
return TableRef(
|
|
93
|
+
name=name or "",
|
|
94
|
+
db=db,
|
|
95
|
+
catalog=catalog,
|
|
96
|
+
alias=tbl.alias or None,
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _apply_default_db_schema(
|
|
101
|
+
tbl: TableRef, db: str | None, schema: str | None
|
|
102
|
+
) -> TableRef:
|
|
103
|
+
if tbl.catalog and tbl.db:
|
|
104
|
+
return tbl
|
|
105
|
+
if tbl.db and not tbl.catalog:
|
|
106
|
+
return tbl.model_copy(update={"catalog": db}) if db else tbl
|
|
107
|
+
if not tbl.db and not tbl.catalog and (schema or db):
|
|
108
|
+
return tbl.model_copy(
|
|
109
|
+
update={"db": schema or db, "catalog": db if schema else None}
|
|
110
|
+
)
|
|
111
|
+
if tbl.catalog and not tbl.db and schema:
|
|
112
|
+
return tbl.model_copy(update={"db": schema})
|
|
113
|
+
return tbl
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _get_star(proj: exp.Expr) -> exp.Star | None:
|
|
117
|
+
inner = proj.this if isinstance(proj, exp.Alias) else proj
|
|
118
|
+
if isinstance(inner, exp.Star):
|
|
119
|
+
return inner
|
|
120
|
+
if isinstance(inner, exp.Column) and isinstance(inner.this, exp.Star):
|
|
121
|
+
return inner.this
|
|
122
|
+
return None
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _has_projection_star(expr: exp.Expr) -> bool:
|
|
126
|
+
return any(
|
|
127
|
+
not isinstance(s.parent, exp.AggFunc) for s in expr.find_all(exp.Star)
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _col_spec_from_proj(proj: exp.Expr, dialect: str | None = None) -> ColumnSpec:
|
|
132
|
+
inner = proj.this if isinstance(proj, exp.Alias) else proj
|
|
133
|
+
star = _get_star(proj)
|
|
134
|
+
if star is not None:
|
|
135
|
+
return ColumnSpec(
|
|
136
|
+
alias=proj.alias or None,
|
|
137
|
+
sql=proj.sql(dialect=dialect),
|
|
138
|
+
table=(inner.table or None) if isinstance(inner, exp.Column) else None,
|
|
139
|
+
column=None,
|
|
140
|
+
is_agg=False,
|
|
141
|
+
except_=[e.name for e in (star.args.get("except_") or []) if e.name],
|
|
142
|
+
replace={
|
|
143
|
+
e.alias: e.this.sql(dialect=dialect)
|
|
144
|
+
for e in (star.args.get("replace") or [])
|
|
145
|
+
if isinstance(e, exp.Alias) and e.alias and e.this
|
|
146
|
+
},
|
|
147
|
+
rename={
|
|
148
|
+
e.this.name: e.alias
|
|
149
|
+
for e in (star.args.get("rename") or [])
|
|
150
|
+
if isinstance(e, exp.Alias) and e.this and e.this.name and e.alias
|
|
151
|
+
},
|
|
152
|
+
)
|
|
153
|
+
return ColumnSpec(
|
|
154
|
+
alias=proj.alias or None,
|
|
155
|
+
sql=proj.sql(dialect=dialect),
|
|
156
|
+
table=(inner.table or None) if isinstance(inner, exp.Column) else None,
|
|
157
|
+
column=(inner.name or None) if isinstance(inner, exp.Column) else None,
|
|
158
|
+
is_agg=bool(inner.find(exp.AggFunc)),
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
# ponytail: AST-only star expansion by default; pass schema (seeded from LookML or Looker API) to expand leaf-table stars
|
|
163
|
+
def _optimize_stars(
|
|
164
|
+
expr: exp.Expr,
|
|
165
|
+
dialect: str | None = None,
|
|
166
|
+
schema: dict[str, Any] | None = None,
|
|
167
|
+
default_db: str | None = None,
|
|
168
|
+
default_schema: str | None = None,
|
|
169
|
+
) -> exp.Expr:
|
|
170
|
+
if not _has_projection_star(expr):
|
|
171
|
+
return expr
|
|
172
|
+
cte_names = {
|
|
173
|
+
cte.alias_or_name.lower()
|
|
174
|
+
for cte in expr.find_all(exp.CTE)
|
|
175
|
+
if cte.alias_or_name
|
|
176
|
+
}
|
|
177
|
+
if not (schema or cte_names or expr.find(exp.Subquery)):
|
|
178
|
+
return expr
|
|
179
|
+
|
|
180
|
+
norm_schema: dict[str, dict[str, dict[str, dict[str, str]]]] = {
|
|
181
|
+
(cat or "").lower() or "__default_cat__": {
|
|
182
|
+
(db or "").lower() or "__default_db__": {
|
|
183
|
+
t.lower(): {k.lower(): v for k, v in cols.items()}
|
|
184
|
+
for t, cols in tbls.items()
|
|
185
|
+
if cols
|
|
186
|
+
}
|
|
187
|
+
for db, tbls in dbs.items()
|
|
188
|
+
}
|
|
189
|
+
for cat, dbs in (schema or {}).items()
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
def _run(base_expr: exp.Expr, active_schema: dict[str, Any] | None) -> tuple[exp.Expr, exp.Expr]:
|
|
193
|
+
e = base_expr.copy()
|
|
194
|
+
for tbl in e.find_all(exp.Table):
|
|
195
|
+
if tbl.name and tbl.name.lower() not in cte_names:
|
|
196
|
+
t_ref = _split_table_ref(tbl)
|
|
197
|
+
if not t_ref.name.lower().startswith("__lkml_tbl_"):
|
|
198
|
+
t_ref = _apply_default_db_schema(t_ref, default_db, default_schema)
|
|
199
|
+
tbl.set("this", exp.to_identifier(t_ref.name))
|
|
200
|
+
if not tbl.alias:
|
|
201
|
+
tbl.set("alias", exp.TableAlias(this=exp.to_identifier(t_ref.name)))
|
|
202
|
+
tbl.set(
|
|
203
|
+
"catalog",
|
|
204
|
+
exp.to_identifier((t_ref.catalog or "").lower() or "__default_cat__"),
|
|
205
|
+
)
|
|
206
|
+
tbl.set(
|
|
207
|
+
"db",
|
|
208
|
+
exp.to_identifier((t_ref.db or "").lower() or "__default_db__"),
|
|
209
|
+
)
|
|
210
|
+
q = qualify(
|
|
211
|
+
e,
|
|
212
|
+
schema=active_schema or None,
|
|
213
|
+
infer_schema=True,
|
|
214
|
+
allow_partial_qualification=True,
|
|
215
|
+
validate_qualify_columns=False,
|
|
216
|
+
quote_identifiers=False,
|
|
217
|
+
identify=False,
|
|
218
|
+
)
|
|
219
|
+
p = pushdown_projections(q, schema=active_schema or None)
|
|
220
|
+
for node in (q, p):
|
|
221
|
+
for tbl in node.find_all(exp.Table):
|
|
222
|
+
if tbl.catalog == "__default_cat__":
|
|
223
|
+
tbl.set("catalog", None)
|
|
224
|
+
if tbl.db == "__default_db__":
|
|
225
|
+
tbl.set("db", None)
|
|
226
|
+
return q, p
|
|
227
|
+
|
|
228
|
+
try:
|
|
229
|
+
q1, p = _run(expr, norm_schema)
|
|
230
|
+
if _has_projection_star(p):
|
|
231
|
+
extra_cols = {"__unmodeled__": "UNKNOWN"}
|
|
232
|
+
for star in p.find_all(exp.Star):
|
|
233
|
+
for r in star.args.get("replace") or []:
|
|
234
|
+
if isinstance(r, exp.Alias) and r.alias:
|
|
235
|
+
extra_cols[r.alias.lower()] = "UNKNOWN"
|
|
236
|
+
for r in star.args.get("rename") or []:
|
|
237
|
+
if isinstance(r, exp.Alias) and r.this and r.this.name:
|
|
238
|
+
extra_cols[r.this.name.lower()] = "UNKNOWN"
|
|
239
|
+
for tbl in q1.find_all(exp.Table):
|
|
240
|
+
if tbl.name and tbl.name.lower() not in cte_names:
|
|
241
|
+
cat = (tbl.catalog or "").lower() or "__default_cat__"
|
|
242
|
+
db = (tbl.db or "").lower() or "__default_db__"
|
|
243
|
+
norm_schema.setdefault(cat, {}).setdefault(db, {}).setdefault(
|
|
244
|
+
tbl.name.lower(), dict(extra_cols)
|
|
245
|
+
)
|
|
246
|
+
_, p = _run(q1, norm_schema)
|
|
247
|
+
|
|
248
|
+
sel, opt_sel = _main_select(expr), _main_select(p)
|
|
249
|
+
orig_stars = [s for s in (sel.selects if sel else ()) if _get_star(s) is not None]
|
|
250
|
+
if opt_sel is not None and orig_stars:
|
|
251
|
+
if not any(
|
|
252
|
+
(proj.this if isinstance(proj, exp.Alias) else proj).name != "__unmodeled__"
|
|
253
|
+
and _get_star(proj) is None
|
|
254
|
+
for proj in opt_sel.selects
|
|
255
|
+
):
|
|
256
|
+
return expr
|
|
257
|
+
new_exprs: list[exp.Expr] = []
|
|
258
|
+
for proj in opt_sel.selects:
|
|
259
|
+
inner = proj.this if isinstance(proj, exp.Alias) else proj
|
|
260
|
+
if isinstance(inner, exp.Column) and inner.name == "__unmodeled__":
|
|
261
|
+
t_low = (inner.table or "").lower()
|
|
262
|
+
orig = next(
|
|
263
|
+
(
|
|
264
|
+
s
|
|
265
|
+
for s in orig_stars
|
|
266
|
+
if (
|
|
267
|
+
getattr(s.this if isinstance(s, exp.Alias) else s, "table", None)
|
|
268
|
+
or ""
|
|
269
|
+
).lower()
|
|
270
|
+
== t_low
|
|
271
|
+
),
|
|
272
|
+
orig_stars[0] if orig_stars else None,
|
|
273
|
+
)
|
|
274
|
+
if orig is not None:
|
|
275
|
+
orig_stars.remove(orig)
|
|
276
|
+
new_exprs.append(orig)
|
|
277
|
+
else:
|
|
278
|
+
new_exprs.append(proj)
|
|
279
|
+
opt_sel.set("expressions", new_exprs)
|
|
280
|
+
return p
|
|
281
|
+
except Exception: # noqa: BLE001
|
|
282
|
+
return expr
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
def _extract_query(
|
|
286
|
+
expr: exp.Expr,
|
|
287
|
+
raw_sql: str,
|
|
288
|
+
dialect: str | None = None,
|
|
289
|
+
schema: dict[str, Any] | None = None,
|
|
290
|
+
default_db: str | None = None,
|
|
291
|
+
default_schema: str | None = None,
|
|
292
|
+
) -> ParsedQuery:
|
|
293
|
+
ctes = [
|
|
294
|
+
CTESpec(name=cte.alias_or_name, sql=cte.this.sql(dialect=dialect))
|
|
295
|
+
for cte in expr.find_all(exp.CTE)
|
|
296
|
+
if cte.this is not None
|
|
297
|
+
]
|
|
298
|
+
cte_names = {c.name.lower() for c in ctes if c.name}
|
|
299
|
+
|
|
300
|
+
tables = [
|
|
301
|
+
_split_table_ref(tbl)
|
|
302
|
+
for tbl in expr.find_all(exp.Table)
|
|
303
|
+
if tbl.name and tbl.name.lower() not in cte_names
|
|
304
|
+
]
|
|
305
|
+
|
|
306
|
+
sel = _main_select(expr)
|
|
307
|
+
columns: list[ColumnSpec] = []
|
|
308
|
+
joins: list[JoinSpec] = []
|
|
309
|
+
|
|
310
|
+
if sel is not None:
|
|
311
|
+
proj_sel = (
|
|
312
|
+
_main_select(
|
|
313
|
+
_optimize_stars(expr, dialect, schema, default_db, default_schema)
|
|
314
|
+
)
|
|
315
|
+
or sel
|
|
316
|
+
if any(_get_star(p) is not None for p in sel.selects)
|
|
317
|
+
else sel
|
|
318
|
+
)
|
|
319
|
+
columns = [_col_spec_from_proj(proj, dialect) for proj in proj_sel.selects]
|
|
320
|
+
for j in sel.args.get("joins") or []:
|
|
321
|
+
on_expr = j.args.get("on")
|
|
322
|
+
joins.append(
|
|
323
|
+
JoinSpec(
|
|
324
|
+
table=j.this.sql(dialect=dialect) if j.this else "",
|
|
325
|
+
side=j.side or None,
|
|
326
|
+
kind=j.kind or None,
|
|
327
|
+
on_sql=on_expr.sql(dialect=dialect) if on_expr else None,
|
|
328
|
+
)
|
|
329
|
+
)
|
|
330
|
+
|
|
331
|
+
where_expr = expr.args.get("where") or (
|
|
332
|
+
sel.args.get("where") if sel is not None else None
|
|
333
|
+
)
|
|
334
|
+
having_expr = sel.args.get("having") if sel is not None else None
|
|
335
|
+
group_expr = sel.args.get("group") if sel is not None else None
|
|
336
|
+
order_expr = expr.args.get("order") or (
|
|
337
|
+
sel.args.get("order") if sel is not None else None
|
|
338
|
+
)
|
|
339
|
+
limit_expr = expr.args.get("limit") or (
|
|
340
|
+
sel.args.get("limit") if sel is not None else None
|
|
341
|
+
)
|
|
342
|
+
|
|
343
|
+
limit_val: str | None = None
|
|
344
|
+
if isinstance(limit_expr, exp.Limit) and limit_expr.expression is not None:
|
|
345
|
+
limit_val = limit_expr.expression.sql(dialect=dialect)
|
|
346
|
+
elif isinstance(limit_expr, exp.Fetch) and limit_expr.this is not None:
|
|
347
|
+
limit_val = limit_expr.this.sql(dialect=dialect)
|
|
348
|
+
|
|
349
|
+
return ParsedQuery(
|
|
350
|
+
statement_type=expr.key.upper(),
|
|
351
|
+
raw_sql=raw_sql,
|
|
352
|
+
tables=tables,
|
|
353
|
+
ctes=ctes,
|
|
354
|
+
joins=joins,
|
|
355
|
+
columns=columns,
|
|
356
|
+
where=where_expr.this.sql(dialect=dialect)
|
|
357
|
+
if isinstance(where_expr, exp.Where) and where_expr.this is not None
|
|
358
|
+
else None,
|
|
359
|
+
having=having_expr.this.sql(dialect=dialect)
|
|
360
|
+
if isinstance(having_expr, exp.Having) and having_expr.this is not None
|
|
361
|
+
else None,
|
|
362
|
+
group_by=[e.sql(dialect=dialect) for e in group_expr.expressions]
|
|
363
|
+
if isinstance(group_expr, exp.Group)
|
|
364
|
+
else [],
|
|
365
|
+
order_by=[e.sql(dialect=dialect) for e in order_expr.expressions]
|
|
366
|
+
if isinstance(order_expr, exp.Order)
|
|
367
|
+
else [],
|
|
368
|
+
limit=limit_val,
|
|
369
|
+
)
|
|
370
|
+
|
|
371
|
+
|
|
372
|
+
def parse_sql(
|
|
373
|
+
sql: str | None = None,
|
|
374
|
+
file: Path | str | None = None,
|
|
375
|
+
dialect: str | None = None,
|
|
376
|
+
schema: dict[str, Any] | None = None,
|
|
377
|
+
default_db: str | None = None,
|
|
378
|
+
default_schema: str | None = None,
|
|
379
|
+
) -> SqlParseResult:
|
|
380
|
+
"""Parse one or more semicolon-delimited SQL queries into structured Pydantic models."""
|
|
381
|
+
if bool(sql is not None) == bool(file is not None):
|
|
382
|
+
raise ValueError("Specify exactly one of 'sql' or 'file'")
|
|
383
|
+
|
|
384
|
+
sql_text = (
|
|
385
|
+
Path(file).read_text(encoding="utf-8") if file is not None else (sql or "")
|
|
386
|
+
)
|
|
387
|
+
if not sql_text.strip():
|
|
388
|
+
return SqlParseResult(queries=[], dialect=dialect)
|
|
389
|
+
|
|
390
|
+
try:
|
|
391
|
+
dialect_obj = Dialect.get_or_raise(dialect)
|
|
392
|
+
tokens = dialect_obj.tokenize(sql_text)
|
|
393
|
+
except SqlglotError as e:
|
|
394
|
+
return SqlParseResult(
|
|
395
|
+
queries=[ParsedQuery(raw_sql=sql_text.strip(), error=str(e))],
|
|
396
|
+
dialect=dialect,
|
|
397
|
+
)
|
|
398
|
+
|
|
399
|
+
chunks: list[list[Token]] = [[]]
|
|
400
|
+
for tok in tokens:
|
|
401
|
+
if tok.token_type == TokenType.SEMICOLON:
|
|
402
|
+
chunks.append([])
|
|
403
|
+
else:
|
|
404
|
+
chunks[-1].append(tok)
|
|
405
|
+
|
|
406
|
+
queries: list[ParsedQuery] = []
|
|
407
|
+
for chunk in chunks:
|
|
408
|
+
if not chunk:
|
|
409
|
+
continue
|
|
410
|
+
raw_chunk = sql_text[chunk[0].start : chunk[-1].end + 1].strip()
|
|
411
|
+
if not raw_chunk:
|
|
412
|
+
continue
|
|
413
|
+
try:
|
|
414
|
+
parsed_list = sqlglot.parse(
|
|
415
|
+
raw_chunk, read=dialect, error_level=ErrorLevel.RAISE
|
|
416
|
+
)
|
|
417
|
+
used_dialect = dialect
|
|
418
|
+
except SqlglotError as e:
|
|
419
|
+
if dialect is None and "`" in raw_chunk:
|
|
420
|
+
try:
|
|
421
|
+
parsed_list = sqlglot.parse(
|
|
422
|
+
raw_chunk, read="bigquery", error_level=ErrorLevel.RAISE
|
|
423
|
+
)
|
|
424
|
+
used_dialect = "bigquery"
|
|
425
|
+
except SqlglotError:
|
|
426
|
+
queries.append(ParsedQuery(raw_sql=raw_chunk, error=str(e)))
|
|
427
|
+
continue
|
|
428
|
+
else:
|
|
429
|
+
queries.append(ParsedQuery(raw_sql=raw_chunk, error=str(e)))
|
|
430
|
+
continue
|
|
431
|
+
|
|
432
|
+
for expr in parsed_list:
|
|
433
|
+
if expr is not None:
|
|
434
|
+
queries.append(
|
|
435
|
+
_extract_query(
|
|
436
|
+
expr,
|
|
437
|
+
raw_chunk,
|
|
438
|
+
dialect=used_dialect,
|
|
439
|
+
schema=schema,
|
|
440
|
+
default_db=default_db,
|
|
441
|
+
default_schema=default_schema,
|
|
442
|
+
)
|
|
443
|
+
)
|
|
444
|
+
|
|
445
|
+
return SqlParseResult(queries=queries, dialect=dialect)
|