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.
Files changed (115) hide show
  1. {lkr_dev_cli-0.0.57 → lkr_dev_cli-0.0.58}/.pre-commit-config.yaml +7 -2
  2. {lkr_dev_cli-0.0.57 → lkr_dev_cli-0.0.58}/Makefile +2 -2
  3. {lkr_dev_cli-0.0.57 → lkr_dev_cli-0.0.58}/PKG-INFO +1 -1
  4. {lkr_dev_cli-0.0.57 → lkr_dev_cli-0.0.58}/lkr/codemode/main.py +1 -33
  5. {lkr_dev_cli-0.0.57 → lkr_dev_cli-0.0.58}/lkr/codemode/type.py +27 -0
  6. {lkr_dev_cli-0.0.57 → lkr_dev_cli-0.0.58}/lkr/parse/constants.py +2 -0
  7. {lkr_dev_cli-0.0.57 → lkr_dev_cli-0.0.58}/lkr/parse/lookml.py +1 -3
  8. lkr_dev_cli-0.0.58/lkr/parse/sql.py +445 -0
  9. {lkr_dev_cli-0.0.57 → lkr_dev_cli-0.0.58}/lkr/parse/sql_to_lookml.py +502 -141
  10. {lkr_dev_cli-0.0.57 → lkr_dev_cli-0.0.58}/lkr/parse/types.py +2 -0
  11. {lkr_dev_cli-0.0.57 → lkr_dev_cli-0.0.58}/lkr/schema/main.py +1 -1
  12. {lkr_dev_cli-0.0.57 → lkr_dev_cli-0.0.58}/lkr.md +105 -0
  13. {lkr_dev_cli-0.0.57 → lkr_dev_cli-0.0.58}/pyproject.toml +2 -1
  14. lkr_dev_cli-0.0.58/scripts/schema/__init__.py +1 -0
  15. {lkr_dev_cli-0.0.57 → lkr_dev_cli-0.0.58}/tests/test_dependency_resolution.py +3 -0
  16. {lkr_dev_cli-0.0.57 → lkr_dev_cli-0.0.58}/tests/test_parse_lookml.py +466 -0
  17. {lkr_dev_cli-0.0.57 → lkr_dev_cli-0.0.58}/tests/test_parse_sql.py +47 -0
  18. {lkr_dev_cli-0.0.57 → lkr_dev_cli-0.0.58}/uv.lock +457 -524
  19. lkr_dev_cli-0.0.57/lkr/codemode/__init__.py +0 -3
  20. lkr_dev_cli-0.0.57/lkr/parse/sql.py +0 -243
  21. {lkr_dev_cli-0.0.57 → lkr_dev_cli-0.0.58}/.agents/skills/lkr-code-mode/SKILL.md +0 -0
  22. {lkr_dev_cli-0.0.57 → lkr_dev_cli-0.0.58}/.agents/skills/lkr-code-mode/scripts/examples.py +0 -0
  23. {lkr_dev_cli-0.0.57 → lkr_dev_cli-0.0.58}/.agents/skills/lkr-code-mode/scripts/order_items_query.py +0 -0
  24. {lkr_dev_cli-0.0.57 → lkr_dev_cli-0.0.58}/.agents/skills/lkr-code-mode/scripts/personal_folder_dashboards.py +0 -0
  25. {lkr_dev_cli-0.0.57 → lkr_dev_cli-0.0.58}/.agents/skills/lkr-code-mode/scripts/readme.py +0 -0
  26. {lkr_dev_cli-0.0.57 → lkr_dev_cli-0.0.58}/.agents/skills/lkr-code-mode/scripts/traverse_folders.py +0 -0
  27. {lkr_dev_cli-0.0.57 → lkr_dev_cli-0.0.58}/.agents/skills/lkr-project-helper/SKILL.md +0 -0
  28. {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
  29. {lkr_dev_cli-0.0.57 → lkr_dev_cli-0.0.58}/.agents/skills/lkr-project-helper/scripts/configure_model.py +0 -0
  30. {lkr_dev_cli-0.0.57 → lkr_dev_cli-0.0.58}/.agents/skills/lkr-project-helper/scripts/create_custom_view.py +0 -0
  31. {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
  32. {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
  33. {lkr_dev_cli-0.0.57 → lkr_dev_cli-0.0.58}/.agents/skills/lkr-project-helper/scripts/generate_lookml.py +0 -0
  34. {lkr_dev_cli-0.0.57 → lkr_dev_cli-0.0.58}/.agents/skills/lkr-project-helper/scripts/init_project.py +0 -0
  35. {lkr_dev_cli-0.0.57 → lkr_dev_cli-0.0.58}/.agents/skills/lkr-project-helper/scripts/inspect_connections.py +0 -0
  36. {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
  37. {lkr_dev_cli-0.0.57 → lkr_dev_cli-0.0.58}/.agents/skills/lkr-project-helper/scripts/upload_assets.py +0 -0
  38. {lkr_dev_cli-0.0.57 → lkr_dev_cli-0.0.58}/.agents/skills/lkr-project-helper/scripts/validate_and_commit.py +0 -0
  39. {lkr_dev_cli-0.0.57 → lkr_dev_cli-0.0.58}/.github/workflows/ci.yml +0 -0
  40. {lkr_dev_cli-0.0.57 → lkr_dev_cli-0.0.58}/.github/workflows/release.yml +0 -0
  41. {lkr_dev_cli-0.0.57 → lkr_dev_cli-0.0.58}/.github/workflows/website.yml +0 -0
  42. {lkr_dev_cli-0.0.57 → lkr_dev_cli-0.0.58}/.gitignore +0 -0
  43. {lkr_dev_cli-0.0.57 → lkr_dev_cli-0.0.58}/.python-version +0 -0
  44. {lkr_dev_cli-0.0.57 → lkr_dev_cli-0.0.58}/.vscode/launch.json +0 -0
  45. {lkr_dev_cli-0.0.57 → lkr_dev_cli-0.0.58}/.vscode/settings.json +0 -0
  46. {lkr_dev_cli-0.0.57 → lkr_dev_cli-0.0.58}/AGENTS.md +0 -0
  47. {lkr_dev_cli-0.0.57 → lkr_dev_cli-0.0.58}/Dockerfile +0 -0
  48. {lkr_dev_cli-0.0.57 → lkr_dev_cli-0.0.58}/LICENSE +0 -0
  49. {lkr_dev_cli-0.0.57 → lkr_dev_cli-0.0.58}/README.md +0 -0
  50. {lkr_dev_cli-0.0.57 → lkr_dev_cli-0.0.58}/TODO.md +0 -0
  51. {lkr_dev_cli-0.0.57 → lkr_dev_cli-0.0.58}/cloudbuild.yaml +0 -0
  52. {lkr_dev_cli-0.0.57 → lkr_dev_cli-0.0.58}/codemode.md +0 -0
  53. {lkr_dev_cli-0.0.57 → lkr_dev_cli-0.0.58}/lkr/__init__.py +0 -0
  54. {lkr_dev_cli-0.0.57 → lkr_dev_cli-0.0.58}/lkr/auth/__init__.py +0 -0
  55. {lkr_dev_cli-0.0.57 → lkr_dev_cli-0.0.58}/lkr/auth/main.py +0 -0
  56. {lkr_dev_cli-0.0.57 → lkr_dev_cli-0.0.58}/lkr/auth/oauth.py +0 -0
  57. {lkr_dev_cli-0.0.57 → lkr_dev_cli-0.0.58}/lkr/auth_service.py +0 -0
  58. {lkr_dev_cli-0.0.57 → lkr_dev_cli-0.0.58}/lkr/classes.py +0 -0
  59. {lkr_dev_cli-0.0.57 → lkr_dev_cli-0.0.58}/lkr/codemode/LOCAL.md +0 -0
  60. {lkr_dev_cli-0.0.57/scripts/schema → lkr_dev_cli-0.0.58/lkr/codemode}/__init__.py +0 -0
  61. {lkr_dev_cli-0.0.57 → lkr_dev_cli-0.0.58}/lkr/codemode/constant.py +0 -0
  62. {lkr_dev_cli-0.0.57 → lkr_dev_cli-0.0.58}/lkr/codemode/download_swagger.py +0 -0
  63. {lkr_dev_cli-0.0.57 → lkr_dev_cli-0.0.58}/lkr/codemode/examples.py +0 -0
  64. {lkr_dev_cli-0.0.57 → lkr_dev_cli-0.0.58}/lkr/codemode/help.py +0 -0
  65. {lkr_dev_cli-0.0.57 → lkr_dev_cli-0.0.58}/lkr/codemode/readme.py +0 -0
  66. {lkr_dev_cli-0.0.57 → lkr_dev_cli-0.0.58}/lkr/codemode/swagger.json +0 -0
  67. {lkr_dev_cli-0.0.57 → lkr_dev_cli-0.0.58}/lkr/constants.py +0 -0
  68. {lkr_dev_cli-0.0.57 → lkr_dev_cli-0.0.58}/lkr/custom_types.py +0 -0
  69. {lkr_dev_cli-0.0.57 → lkr_dev_cli-0.0.58}/lkr/exceptions.py +0 -0
  70. {lkr_dev_cli-0.0.57 → lkr_dev_cli-0.0.58}/lkr/extended_sdk_methods/__init__.py +0 -0
  71. {lkr_dev_cli-0.0.57 → lkr_dev_cli-0.0.58}/lkr/extended_sdk_methods/classes.py +0 -0
  72. {lkr_dev_cli-0.0.57 → lkr_dev_cli-0.0.58}/lkr/extended_sdk_methods/main.py +0 -0
  73. {lkr_dev_cli-0.0.57 → lkr_dev_cli-0.0.58}/lkr/logger.py +0 -0
  74. {lkr_dev_cli-0.0.57 → lkr_dev_cli-0.0.58}/lkr/main.py +0 -0
  75. {lkr_dev_cli-0.0.57 → lkr_dev_cli-0.0.58}/lkr/mcp/classes.py +0 -0
  76. {lkr_dev_cli-0.0.57 → lkr_dev_cli-0.0.58}/lkr/mcp/main.py +0 -0
  77. {lkr_dev_cli-0.0.57 → lkr_dev_cli-0.0.58}/lkr/mcp/utils.py +0 -0
  78. {lkr_dev_cli-0.0.57 → lkr_dev_cli-0.0.58}/lkr/observability/classes.py +0 -0
  79. {lkr_dev_cli-0.0.57 → lkr_dev_cli-0.0.58}/lkr/observability/embed_container.html +0 -0
  80. {lkr_dev_cli-0.0.57 → lkr_dev_cli-0.0.58}/lkr/observability/main.py +0 -0
  81. {lkr_dev_cli-0.0.57 → lkr_dev_cli-0.0.58}/lkr/observability/utils.py +0 -0
  82. {lkr_dev_cli-0.0.57 → lkr_dev_cli-0.0.58}/lkr/parse/__init__.py +0 -0
  83. {lkr_dev_cli-0.0.57 → lkr_dev_cli-0.0.58}/lkr/parse/dag.py +0 -0
  84. {lkr_dev_cli-0.0.57 → lkr_dev_cli-0.0.58}/lkr/parse/lookml_to_api.py +0 -0
  85. {lkr_dev_cli-0.0.57 → lkr_dev_cli-0.0.58}/lkr/parse/main.py +0 -0
  86. {lkr_dev_cli-0.0.57 → lkr_dev_cli-0.0.58}/lkr/schema/__init__.py +0 -0
  87. {lkr_dev_cli-0.0.57 → lkr_dev_cli-0.0.58}/lkr/schema/_schema_rs.so +0 -0
  88. {lkr_dev_cli-0.0.57 → lkr_dev_cli-0.0.58}/lkr/schema/builder.py +0 -0
  89. {lkr_dev_cli-0.0.57 → lkr_dev_cli-0.0.58}/lkr/schema/dynamic_fields.py +0 -0
  90. {lkr_dev_cli-0.0.57 → lkr_dev_cli-0.0.58}/lkr/schema/fields.py +0 -0
  91. {lkr_dev_cli-0.0.57 → lkr_dev_cli-0.0.58}/lkr/schema/filter_expressions.py +0 -0
  92. {lkr_dev_cli-0.0.57 → lkr_dev_cli-0.0.58}/lkr/schema/rust/Cargo.lock +0 -0
  93. {lkr_dev_cli-0.0.57 → lkr_dev_cli-0.0.58}/lkr/schema/rust/Cargo.toml +0 -0
  94. {lkr_dev_cli-0.0.57 → lkr_dev_cli-0.0.58}/lkr/schema/rust/src/lib.rs +0 -0
  95. {lkr_dev_cli-0.0.57 → lkr_dev_cli-0.0.58}/lkr/schema/validator.py +0 -0
  96. {lkr_dev_cli-0.0.57 → lkr_dev_cli-0.0.58}/lkr/tools/agents.py +0 -0
  97. {lkr_dev_cli-0.0.57 → lkr_dev_cli-0.0.58}/lkr/tools/classes.py +0 -0
  98. {lkr_dev_cli-0.0.57 → lkr_dev_cli-0.0.58}/lkr/tools/lookml.py +0 -0
  99. {lkr_dev_cli-0.0.57 → lkr_dev_cli-0.0.58}/lkr/tools/main.py +0 -0
  100. {lkr_dev_cli-0.0.57 → lkr_dev_cli-0.0.58}/lkr/tools/permission_deprecation.py +0 -0
  101. {lkr_dev_cli-0.0.57 → lkr_dev_cli-0.0.58}/lkr_dev_cli/__init__.py +0 -0
  102. {lkr_dev_cli-0.0.57 → lkr_dev_cli-0.0.58}/scripts/schema/README.md +0 -0
  103. {lkr_dev_cli-0.0.57 → lkr_dev_cli-0.0.58}/scripts/schema/history.py +0 -0
  104. {lkr_dev_cli-0.0.57 → lkr_dev_cli-0.0.58}/tests/TESTING.md +0 -0
  105. {lkr_dev_cli-0.0.57 → lkr_dev_cli-0.0.58}/tests/test_agents_tools.py +0 -0
  106. {lkr_dev_cli-0.0.57 → lkr_dev_cli-0.0.58}/tests/test_codemode.py +0 -0
  107. {lkr_dev_cli-0.0.57 → lkr_dev_cli-0.0.58}/tests/test_deps.sh +0 -0
  108. {lkr_dev_cli-0.0.57 → lkr_dev_cli-0.0.58}/tests/test_extended_sdk_methods.py +0 -0
  109. {lkr_dev_cli-0.0.57 → lkr_dev_cli-0.0.58}/tests/test_init_sdk.py +0 -0
  110. {lkr_dev_cli-0.0.57 → lkr_dev_cli-0.0.58}/tests/test_lookml_tools.py +0 -0
  111. {lkr_dev_cli-0.0.57 → lkr_dev_cli-0.0.58}/tests/test_oauth_account.py +0 -0
  112. {lkr_dev_cli-0.0.57 → lkr_dev_cli-0.0.58}/tests/test_permission_deprecation.py +0 -0
  113. {lkr_dev_cli-0.0.57 → lkr_dev_cli-0.0.58}/tests/test_schema.py +0 -0
  114. {lkr_dev_cli-0.0.57 → lkr_dev_cli-0.0.58}/tests/test_version.py +0 -0
  115. {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
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: lkr-dev-cli
3
- Version: 0.0.57
3
+ Version: 0.0.58
4
4
  Summary: lkr: a command line interface for looker
5
5
  Author: bwebs
6
6
  License-Expression: MIT
@@ -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)