lkr-dev-cli 0.0.59__tar.gz → 0.0.60__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 (207) hide show
  1. lkr_dev_cli-0.0.60/.agents/skills/agentic-lookml/SKILL.md +202 -0
  2. lkr_dev_cli-0.0.60/.agents/skills/agentic-lookml/references/01-simple-single-table.md +51 -0
  3. lkr_dev_cli-0.0.60/.agents/skills/agentic-lookml/references/02-conditional-measures-and-bucketing.md +148 -0
  4. {lkr_dev_cli-0.0.59 → lkr_dev_cli-0.0.60}/.agents/skills/agentic-lookml/references/03-pk-fk-joins.md +14 -6
  5. {lkr_dev_cli-0.0.59 → lkr_dev_cli-0.0.60}/.agents/skills/agentic-lookml/references/04-triangle-inequality-joins.md +2 -2
  6. {lkr_dev_cli-0.0.59 → lkr_dev_cli-0.0.60}/.agents/skills/agentic-lookml/references/05-dimensional-non-key-joins.md +3 -1
  7. lkr_dev_cli-0.0.60/.agents/skills/agentic-lookml/references/07-measure-of-measures-reaggregation.md +111 -0
  8. {lkr_dev_cli-0.0.59 → lkr_dev_cli-0.0.60}/.agents/skills/agentic-lookml/references/08-window-functions-and-qualify.md +30 -14
  9. {lkr_dev_cli-0.0.59 → lkr_dev_cli-0.0.60}/.agents/skills/agentic-lookml/references/09-unions.md +27 -14
  10. {lkr_dev_cli-0.0.59 → lkr_dev_cli-0.0.60}/.agents/skills/agentic-lookml/references/10-unnest-arrays.md +13 -21
  11. {lkr_dev_cli-0.0.59 → lkr_dev_cli-0.0.60}/.agents/skills/agentic-lookml/references/11-json-columns-and-extract.md +22 -23
  12. {lkr_dev_cli-0.0.59 → lkr_dev_cli-0.0.60}/.agents/skills/agentic-lookml/references/12-table-wildcards.md +11 -23
  13. {lkr_dev_cli-0.0.59 → lkr_dev_cli-0.0.60}/.agents/skills/agentic-lookml/references/13-large-tables-and-partitioning.md +13 -20
  14. {lkr_dev_cli-0.0.59 → lkr_dev_cli-0.0.60}/.agents/skills/agentic-lookml/references/14-templated-filters-and-parameters.md +2 -2
  15. lkr_dev_cli-0.0.60/.agents/skills/agentic-lookml/references/measures-and-sql-functions.md +83 -0
  16. lkr_dev_cli-0.0.60/.agents/skills/agentic-lookml-workflow/SKILL.md +159 -0
  17. lkr_dev_cli-0.0.60/.agents/skills/agentic-lookml-workflow/examples/lookml-pr-checks.yml +305 -0
  18. lkr_dev_cli-0.0.60/.agents/skills/agentic-lookml-workflow/examples/template.model.lkml.tmpl +14 -0
  19. lkr_dev_cli-0.0.60/.agents/skills/agentic-lookml-workflow/scripts/configure_model.py +48 -0
  20. lkr_dev_cli-0.0.60/.agents/skills/agentic-lookml-workflow/scripts/create_github_pr.sh +95 -0
  21. lkr_dev_cli-0.0.60/.agents/skills/agentic-lookml-workflow/scripts/diff_lookml_tests.py +60 -0
  22. lkr_dev_cli-0.0.60/.agents/skills/agentic-lookml-workflow/scripts/init_branch.py +113 -0
  23. lkr_dev_cli-0.0.60/.agents/skills/agentic-lookml-workflow/scripts/pre_gate.py +827 -0
  24. lkr_dev_cli-0.0.60/.agents/skills/agentic-lookml-workflow/scripts/preflight.sh +32 -0
  25. lkr_dev_cli-0.0.60/.agents/skills/agentic-lookml-workflow/scripts/setup_repo.sh +139 -0
  26. lkr_dev_cli-0.0.60/.agents/skills/agentic-lookml-workflow/scripts/validate_and_commit_branch.py +130 -0
  27. lkr_dev_cli-0.0.60/.agents/skills/agentic-lookml-workflow/sub-skills/setup/SKILL.md +84 -0
  28. {lkr_dev_cli-0.0.59 → lkr_dev_cli-0.0.60}/.agents/skills/generate-view-from-table/SKILL.md +7 -7
  29. {lkr_dev_cli-0.0.59 → lkr_dev_cli-0.0.60}/.agents/skills/generate-view-from-table/scripts/generate_bronze_view.py +5 -1
  30. {lkr_dev_cli-0.0.59 → lkr_dev_cli-0.0.60}/.agents/skills/lkr-code-mode/SKILL.md +31 -19
  31. {lkr_dev_cli-0.0.59 → lkr_dev_cli-0.0.60}/.agents/skills/lkr-project-helper/SKILL.md +20 -25
  32. {lkr_dev_cli-0.0.59 → lkr_dev_cli-0.0.60}/.agents/skills/lkr-project-helper/scripts/create_custom_view.py +17 -5
  33. lkr_dev_cli-0.0.60/.agents/skills/lkr-project-helper/scripts/deploy_and_reset_workspace.py +84 -0
  34. lkr_dev_cli-0.0.60/.agents/skills/lkr-project-helper/scripts/ensure_project_and_model.py +91 -0
  35. {lkr_dev_cli-0.0.59 → lkr_dev_cli-0.0.60}/.agents/skills/lkr-project-helper/scripts/generate_lookml.py +21 -13
  36. {lkr_dev_cli-0.0.59 → lkr_dev_cli-0.0.60}/.agents/skills/lkr-project-helper/scripts/inspect_connections.py +9 -3
  37. {lkr_dev_cli-0.0.59 → lkr_dev_cli-0.0.60}/.agents/skills/lkr-project-helper/scripts/migrate_dashboard_to_udd.py +9 -3
  38. {lkr_dev_cli-0.0.59 → lkr_dev_cli-0.0.60}/.agents/skills/lkr-project-helper/scripts/upload_assets.py +13 -4
  39. lkr_dev_cli-0.0.60/.agents/skills/looker-expressions/SKILL.md +311 -0
  40. lkr_dev_cli-0.0.60/.agents/skills/looker-expressions/references/excel-equivalents.md +120 -0
  41. lkr_dev_cli-0.0.60/.agents/skills/looker-filters/SKILL.md +225 -0
  42. {lkr_dev_cli-0.0.59 → lkr_dev_cli-0.0.60}/.agents/skills/lookml/SKILL.md +4 -0
  43. lkr_dev_cli-0.0.60/.agents/skills/lookml/sub-skills/lookml-advanced-liquid/SKILL.md +238 -0
  44. {lkr_dev_cli-0.0.59 → lkr_dev_cli-0.0.60}/.agents/skills/lookml/sub-skills/lookml-fields/SKILL.md +7 -0
  45. {lkr_dev_cli-0.0.59 → lkr_dev_cli-0.0.60}/.agents/skills/lookml/sub-skills/lookml-fields/references/dimension.md +2 -2
  46. lkr_dev_cli-0.0.60/.agents/skills/lookml/sub-skills/lookml-fields/references/measure.md +72 -0
  47. {lkr_dev_cli-0.0.59 → lkr_dev_cli-0.0.60}/.agents/skills/lookml/sub-skills/lookml-fields/references/value_format.md +5 -5
  48. lkr_dev_cli-0.0.60/.agents/skills/lookml/sub-skills/lookml-ojof/SKILL.md +398 -0
  49. {lkr_dev_cli-0.0.59 → lkr_dev_cli-0.0.60}/.agents/skills/lookml/sub-skills/lookml-sets/SKILL.md +3 -1
  50. {lkr_dev_cli-0.0.59 → lkr_dev_cli-0.0.60}/.agents/skills/lookml/sub-skills/lookml-tests/SKILL.md +8 -6
  51. lkr_dev_cli-0.0.60/.agents/skills/lookml-field-curation/SKILL.md +332 -0
  52. lkr_dev_cli-0.0.60/.agents/skills/lookml-field-curation/scripts/enrich_partition_clustering.py +311 -0
  53. lkr_dev_cli-0.0.60/.agents/skills/lookml-field-curation/scripts/profile_string_fields.py +412 -0
  54. lkr_dev_cli-0.0.60/.github/workflows/ci-windows.yaml +55 -0
  55. lkr_dev_cli-0.0.60/.github/workflows/update-looker-skills.yml +131 -0
  56. {lkr_dev_cli-0.0.59 → lkr_dev_cli-0.0.60}/Makefile +1 -1
  57. {lkr_dev_cli-0.0.59 → lkr_dev_cli-0.0.60}/PKG-INFO +115 -9
  58. {lkr_dev_cli-0.0.59 → lkr_dev_cli-0.0.60}/README.md +114 -3
  59. {lkr_dev_cli-0.0.59 → lkr_dev_cli-0.0.60}/codemode.md +3 -3
  60. {lkr_dev_cli-0.0.59 → lkr_dev_cli-0.0.60}/lkr/auth/main.py +4 -1
  61. {lkr_dev_cli-0.0.59 → lkr_dev_cli-0.0.60}/lkr/auth/oauth.py +5 -4
  62. {lkr_dev_cli-0.0.59 → lkr_dev_cli-0.0.60}/lkr/auth_service.py +19 -13
  63. {lkr_dev_cli-0.0.59 → lkr_dev_cli-0.0.60}/lkr/classes.py +3 -2
  64. {lkr_dev_cli-0.0.59 → lkr_dev_cli-0.0.60}/lkr/codemode/download_swagger.py +1 -1
  65. {lkr_dev_cli-0.0.59 → lkr_dev_cli-0.0.60}/lkr/codemode/help.py +6 -4
  66. {lkr_dev_cli-0.0.59 → lkr_dev_cli-0.0.60}/lkr/codemode/main.py +120 -6
  67. {lkr_dev_cli-0.0.59 → lkr_dev_cli-0.0.60}/lkr/codemode/readme.py +7 -0
  68. {lkr_dev_cli-0.0.59 → lkr_dev_cli-0.0.60}/lkr/codemode/type.py +79 -55
  69. {lkr_dev_cli-0.0.59 → lkr_dev_cli-0.0.60}/lkr/main.py +11 -8
  70. {lkr_dev_cli-0.0.59 → lkr_dev_cli-0.0.60}/lkr/mcp/classes.py +5 -3
  71. {lkr_dev_cli-0.0.59 → lkr_dev_cli-0.0.60}/lkr/mcp/main.py +18 -21
  72. {lkr_dev_cli-0.0.59 → lkr_dev_cli-0.0.60}/lkr/mcp/utils.py +11 -2
  73. {lkr_dev_cli-0.0.59 → lkr_dev_cli-0.0.60}/lkr/observability/main.py +1 -1
  74. {lkr_dev_cli-0.0.59 → lkr_dev_cli-0.0.60}/lkr/observability/utils.py +8 -23
  75. {lkr_dev_cli-0.0.59 → lkr_dev_cli-0.0.60}/lkr/parse/__init__.py +8 -0
  76. lkr_dev_cli-0.0.60/lkr/parse/curation.py +405 -0
  77. {lkr_dev_cli-0.0.59 → lkr_dev_cli-0.0.60}/lkr/parse/dag.py +26 -6
  78. {lkr_dev_cli-0.0.59 → lkr_dev_cli-0.0.60}/lkr/parse/lookml.py +435 -47
  79. {lkr_dev_cli-0.0.59 → lkr_dev_cli-0.0.60}/lkr/parse/lookml_database_compare.py +9 -1
  80. lkr_dev_cli-0.0.60/lkr/parse/lookml_snapshot.py +1136 -0
  81. {lkr_dev_cli-0.0.59 → lkr_dev_cli-0.0.60}/lkr/parse/lookml_to_api.py +577 -106
  82. {lkr_dev_cli-0.0.59 → lkr_dev_cli-0.0.60}/lkr/parse/main.py +307 -24
  83. {lkr_dev_cli-0.0.59 → lkr_dev_cli-0.0.60}/lkr/parse/sql.py +0 -1
  84. {lkr_dev_cli-0.0.59 → lkr_dev_cli-0.0.60}/lkr/parse/sql_to_lookml.py +918 -118
  85. {lkr_dev_cli-0.0.59 → lkr_dev_cli-0.0.60}/lkr/parse/translations.py +10 -2
  86. {lkr_dev_cli-0.0.59 → lkr_dev_cli-0.0.60}/lkr/parse/types.py +7 -0
  87. {lkr_dev_cli-0.0.59 → lkr_dev_cli-0.0.60}/lkr/parse/user_attributes.py +17 -3
  88. {lkr_dev_cli-0.0.59 → lkr_dev_cli-0.0.60}/lkr/schema/_schema_rs.so +0 -0
  89. {lkr_dev_cli-0.0.59 → lkr_dev_cli-0.0.60}/lkr/schema/builder.py +3 -0
  90. {lkr_dev_cli-0.0.59 → lkr_dev_cli-0.0.60}/lkr/schema/filter_expressions.py +16 -6
  91. {lkr_dev_cli-0.0.59 → lkr_dev_cli-0.0.60}/lkr/schema/rust/Cargo.lock +10 -58
  92. {lkr_dev_cli-0.0.59 → lkr_dev_cli-0.0.60}/lkr/schema/rust/Cargo.toml +1 -1
  93. {lkr_dev_cli-0.0.59 → lkr_dev_cli-0.0.60}/lkr/schema/rust/src/lib.rs +1 -1
  94. lkr_dev_cli-0.0.60/lkr/schema/validator.py +491 -0
  95. {lkr_dev_cli-0.0.59 → lkr_dev_cli-0.0.60}/lkr/tools/agents.py +158 -3
  96. {lkr_dev_cli-0.0.59 → lkr_dev_cli-0.0.60}/lkr/tools/lookml.py +236 -54
  97. {lkr_dev_cli-0.0.59 → lkr_dev_cli-0.0.60}/lkr/tools/main.py +1 -1
  98. lkr_dev_cli-0.0.60/lkr/utils.py +26 -0
  99. {lkr_dev_cli-0.0.59 → lkr_dev_cli-0.0.60}/lkr.md +172 -6
  100. {lkr_dev_cli-0.0.59 → lkr_dev_cli-0.0.60}/parse.md +1 -1
  101. {lkr_dev_cli-0.0.59 → lkr_dev_cli-0.0.60}/pyproject.toml +1 -7
  102. {lkr_dev_cli-0.0.59 → lkr_dev_cli-0.0.60}/skills-lock.json +6 -0
  103. {lkr_dev_cli-0.0.59 → lkr_dev_cli-0.0.60}/tests/test_agents_tools.py +154 -0
  104. {lkr_dev_cli-0.0.59 → lkr_dev_cli-0.0.60}/tests/test_codemode.py +43 -1
  105. lkr_dev_cli-0.0.60/tests/test_lookml_field_curation.py +580 -0
  106. lkr_dev_cli-0.0.60/tests/test_lookml_snapshot.py +811 -0
  107. {lkr_dev_cli-0.0.59 → lkr_dev_cli-0.0.60}/tests/test_lookml_tools.py +257 -37
  108. {lkr_dev_cli-0.0.59 → lkr_dev_cli-0.0.60}/tests/test_oauth_account.py +59 -0
  109. {lkr_dev_cli-0.0.59 → lkr_dev_cli-0.0.60}/tests/test_parse_lookml.py +1889 -56
  110. {lkr_dev_cli-0.0.59 → lkr_dev_cli-0.0.60}/tests/test_schema.py +170 -4
  111. lkr_dev_cli-0.0.60/tests/test_sql_queries_workflow.py +1690 -0
  112. {lkr_dev_cli-0.0.59 → lkr_dev_cli-0.0.60}/uv.lock +11 -32
  113. lkr_dev_cli-0.0.59/.agents/skills/agentic-lookml/SKILL.md +0 -461
  114. lkr_dev_cli-0.0.59/.agents/skills/agentic-lookml/references/01-simple-single-table.md +0 -76
  115. lkr_dev_cli-0.0.59/.agents/skills/agentic-lookml/references/02-conditional-measures-and-bucketing.md +0 -159
  116. lkr_dev_cli-0.0.59/.agents/skills/agentic-lookml/references/07-measure-of-measures-reaggregation.md +0 -116
  117. lkr_dev_cli-0.0.59/.agents/skills/agentic-lookml-workflow/SKILL.md +0 -168
  118. lkr_dev_cli-0.0.59/.agents/skills/agentic-lookml-workflow/scripts/create_github_pr.sh +0 -56
  119. lkr_dev_cli-0.0.59/.agents/skills/agentic-lookml-workflow/scripts/init_branch.py +0 -32
  120. lkr_dev_cli-0.0.59/.agents/skills/agentic-lookml-workflow/scripts/init_sql_queries.py +0 -49
  121. lkr_dev_cli-0.0.59/.agents/skills/agentic-lookml-workflow/scripts/preflight.sh +0 -19
  122. lkr_dev_cli-0.0.59/.agents/skills/agentic-lookml-workflow/scripts/record_query_parity.py +0 -107
  123. lkr_dev_cli-0.0.59/.agents/skills/agentic-lookml-workflow/scripts/validate_and_commit_branch.py +0 -63
  124. lkr_dev_cli-0.0.59/.agents/skills/lkr-code-mode/scripts/examples.py +0 -46
  125. lkr_dev_cli-0.0.59/.agents/skills/lkr-code-mode/scripts/readme.py +0 -65
  126. lkr_dev_cli-0.0.59/.agents/skills/lkr-project-helper/scripts/configure_model.py +0 -52
  127. lkr_dev_cli-0.0.59/.agents/skills/lkr-project-helper/scripts/deploy_and_reset_workspace.py +0 -86
  128. lkr_dev_cli-0.0.59/.agents/skills/lkr-project-helper/scripts/ensure_project_and_model.py +0 -95
  129. lkr_dev_cli-0.0.59/.agents/skills/lkr-project-helper/scripts/init_project.py +0 -37
  130. lkr_dev_cli-0.0.59/.agents/skills/lkr-project-helper/scripts/validate_and_commit.py +0 -62
  131. lkr_dev_cli-0.0.59/.agents/skills/lookml/sub-skills/lookml-fields/references/measure.md +0 -75
  132. lkr_dev_cli-0.0.59/.github/workflows/lookml-pr-checks.yml +0 -61
  133. lkr_dev_cli-0.0.59/.github/workflows/update-looker-skills.yml +0 -87
  134. lkr_dev_cli-0.0.59/lkr/parse/lookml_snapshot.py +0 -668
  135. lkr_dev_cli-0.0.59/lkr/schema/validator.py +0 -64
  136. lkr_dev_cli-0.0.59/tests/test_lookml_snapshot.py +0 -313
  137. lkr_dev_cli-0.0.59/tests/test_sql_queries_workflow.py +0 -76
  138. {lkr_dev_cli-0.0.59 → lkr_dev_cli-0.0.60}/.agents/skills/agentic-lookml/references/06-nested-ctes-and-subqueries.md +0 -0
  139. {lkr_dev_cli-0.0.59 → lkr_dev_cli-0.0.60}/.agents/skills/lkr-code-mode/scripts/order_items_query.py +0 -0
  140. {lkr_dev_cli-0.0.59 → lkr_dev_cli-0.0.60}/.agents/skills/lkr-code-mode/scripts/personal_folder_dashboards.py +0 -0
  141. {lkr_dev_cli-0.0.59 → lkr_dev_cli-0.0.60}/.agents/skills/lkr-code-mode/scripts/traverse_folders.py +0 -0
  142. {lkr_dev_cli-0.0.59 → lkr_dev_cli-0.0.60}/.agents/skills/lkr-project-helper/scripts/bq_view_lookml_generator.py +0 -0
  143. {lkr_dev_cli-0.0.59 → lkr_dev_cli-0.0.60}/.agents/skills/lookml/sub-skills/lookml-access-grants/SKILL.md +0 -0
  144. {lkr_dev_cli-0.0.59 → lkr_dev_cli-0.0.60}/.agents/skills/lookml/sub-skills/lookml-explore/SKILL.md +0 -0
  145. {lkr_dev_cli-0.0.59 → lkr_dev_cli-0.0.60}/.agents/skills/lookml/sub-skills/lookml-explore/references/advanced.md +0 -0
  146. {lkr_dev_cli-0.0.59 → lkr_dev_cli-0.0.60}/.agents/skills/lookml/sub-skills/lookml-explore/references/joins.md +0 -0
  147. {lkr_dev_cli-0.0.59 → lkr_dev_cli-0.0.60}/.agents/skills/lookml/sub-skills/lookml-fields/references/dimension_group.md +0 -0
  148. {lkr_dev_cli-0.0.59 → lkr_dev_cli-0.0.60}/.agents/skills/lookml/sub-skills/lookml-fields/references/filter_parameter.md +0 -0
  149. {lkr_dev_cli-0.0.59 → lkr_dev_cli-0.0.60}/.agents/skills/lookml/sub-skills/lookml-liquid/SKILL.md +0 -0
  150. {lkr_dev_cli-0.0.59 → lkr_dev_cli-0.0.60}/.agents/skills/lookml/sub-skills/lookml-model/SKILL.md +0 -0
  151. {lkr_dev_cli-0.0.59 → lkr_dev_cli-0.0.60}/.agents/skills/lookml/sub-skills/lookml-refinements/SKILL.md +0 -0
  152. {lkr_dev_cli-0.0.59 → lkr_dev_cli-0.0.60}/.agents/skills/lookml/sub-skills/lookml-view/SKILL.md +0 -0
  153. {lkr_dev_cli-0.0.59 → lkr_dev_cli-0.0.60}/.agents/skills/lookml/sub-skills/lookml-view/references/derived_table.md +0 -0
  154. {lkr_dev_cli-0.0.59 → lkr_dev_cli-0.0.60}/.github/workflows/ci.yml +0 -0
  155. {lkr_dev_cli-0.0.59 → lkr_dev_cli-0.0.60}/.github/workflows/release.yml +0 -0
  156. {lkr_dev_cli-0.0.59 → lkr_dev_cli-0.0.60}/.github/workflows/website.yml +0 -0
  157. {lkr_dev_cli-0.0.59 → lkr_dev_cli-0.0.60}/.gitignore +0 -0
  158. {lkr_dev_cli-0.0.59 → lkr_dev_cli-0.0.60}/.pre-commit-config.yaml +0 -0
  159. {lkr_dev_cli-0.0.59 → lkr_dev_cli-0.0.60}/.python-version +0 -0
  160. {lkr_dev_cli-0.0.59 → lkr_dev_cli-0.0.60}/.vscode/launch.json +0 -0
  161. {lkr_dev_cli-0.0.59 → lkr_dev_cli-0.0.60}/.vscode/settings.json +0 -0
  162. {lkr_dev_cli-0.0.59 → lkr_dev_cli-0.0.60}/AGENTS.md +0 -0
  163. {lkr_dev_cli-0.0.59 → lkr_dev_cli-0.0.60}/Dockerfile +0 -0
  164. {lkr_dev_cli-0.0.59 → lkr_dev_cli-0.0.60}/LICENSE +0 -0
  165. {lkr_dev_cli-0.0.59 → lkr_dev_cli-0.0.60}/TODO.md +0 -0
  166. {lkr_dev_cli-0.0.59 → lkr_dev_cli-0.0.60}/cloudbuild.yaml +0 -0
  167. {lkr_dev_cli-0.0.59 → lkr_dev_cli-0.0.60}/lkr/__init__.py +0 -0
  168. {lkr_dev_cli-0.0.59 → lkr_dev_cli-0.0.60}/lkr/auth/__init__.py +0 -0
  169. {lkr_dev_cli-0.0.59 → lkr_dev_cli-0.0.60}/lkr/codemode/LOCAL.md +0 -0
  170. {lkr_dev_cli-0.0.59 → lkr_dev_cli-0.0.60}/lkr/codemode/__init__.py +0 -0
  171. {lkr_dev_cli-0.0.59 → lkr_dev_cli-0.0.60}/lkr/codemode/constant.py +0 -0
  172. {lkr_dev_cli-0.0.59 → lkr_dev_cli-0.0.60}/lkr/codemode/examples.py +0 -0
  173. {lkr_dev_cli-0.0.59 → lkr_dev_cli-0.0.60}/lkr/codemode/swagger.json +0 -0
  174. {lkr_dev_cli-0.0.59 → lkr_dev_cli-0.0.60}/lkr/constants.py +0 -0
  175. {lkr_dev_cli-0.0.59 → lkr_dev_cli-0.0.60}/lkr/custom_types.py +0 -0
  176. {lkr_dev_cli-0.0.59 → lkr_dev_cli-0.0.60}/lkr/exceptions.py +0 -0
  177. {lkr_dev_cli-0.0.59 → lkr_dev_cli-0.0.60}/lkr/extended_sdk_methods/__init__.py +0 -0
  178. {lkr_dev_cli-0.0.59 → lkr_dev_cli-0.0.60}/lkr/extended_sdk_methods/classes.py +0 -0
  179. {lkr_dev_cli-0.0.59 → lkr_dev_cli-0.0.60}/lkr/extended_sdk_methods/main.py +0 -0
  180. {lkr_dev_cli-0.0.59 → lkr_dev_cli-0.0.60}/lkr/logger.py +0 -0
  181. {lkr_dev_cli-0.0.59 → lkr_dev_cli-0.0.60}/lkr/observability/classes.py +0 -0
  182. {lkr_dev_cli-0.0.59 → lkr_dev_cli-0.0.60}/lkr/observability/embed_container.html +0 -0
  183. {lkr_dev_cli-0.0.59 → lkr_dev_cli-0.0.60}/lkr/parse/constants.py +0 -0
  184. {lkr_dev_cli-0.0.59 → lkr_dev_cli-0.0.60}/lkr/schema/__init__.py +0 -0
  185. {lkr_dev_cli-0.0.59 → lkr_dev_cli-0.0.60}/lkr/schema/dynamic_fields.py +0 -0
  186. {lkr_dev_cli-0.0.59 → lkr_dev_cli-0.0.60}/lkr/schema/fields.py +0 -0
  187. {lkr_dev_cli-0.0.59 → lkr_dev_cli-0.0.60}/lkr/schema/main.py +0 -0
  188. {lkr_dev_cli-0.0.59 → lkr_dev_cli-0.0.60}/lkr/tools/classes.py +0 -0
  189. {lkr_dev_cli-0.0.59 → lkr_dev_cli-0.0.60}/lkr/tools/permission_deprecation.py +0 -0
  190. {lkr_dev_cli-0.0.59 → lkr_dev_cli-0.0.60}/lkr_dev_cli/__init__.py +0 -0
  191. {lkr_dev_cli-0.0.59 → lkr_dev_cli-0.0.60}/scripts/schema/README.md +0 -0
  192. {lkr_dev_cli-0.0.59 → lkr_dev_cli-0.0.60}/scripts/schema/__init__.py +0 -0
  193. {lkr_dev_cli-0.0.59 → lkr_dev_cli-0.0.60}/scripts/schema/history.py +0 -0
  194. {lkr_dev_cli-0.0.59 → lkr_dev_cli-0.0.60}/tests/TESTING.md +0 -0
  195. {lkr_dev_cli-0.0.59 → lkr_dev_cli-0.0.60}/tests/test_bq_change_listener.py +0 -0
  196. {lkr_dev_cli-0.0.59 → lkr_dev_cli-0.0.60}/tests/test_dependency_resolution.py +0 -0
  197. {lkr_dev_cli-0.0.59 → lkr_dev_cli-0.0.60}/tests/test_deps.sh +0 -0
  198. {lkr_dev_cli-0.0.59 → lkr_dev_cli-0.0.60}/tests/test_extended_sdk_methods.py +0 -0
  199. {lkr_dev_cli-0.0.59 → lkr_dev_cli-0.0.60}/tests/test_init_sdk.py +0 -0
  200. {lkr_dev_cli-0.0.59 → lkr_dev_cli-0.0.60}/tests/test_lookml_database_compare.py +0 -0
  201. {lkr_dev_cli-0.0.59 → lkr_dev_cli-0.0.60}/tests/test_parse_sql.py +0 -0
  202. {lkr_dev_cli-0.0.59 → lkr_dev_cli-0.0.60}/tests/test_permission_deprecation.py +0 -0
  203. {lkr_dev_cli-0.0.59 → lkr_dev_cli-0.0.60}/tests/test_sql_join_analysis.py +0 -0
  204. {lkr_dev_cli-0.0.59 → lkr_dev_cli-0.0.60}/tests/test_translations.py +0 -0
  205. {lkr_dev_cli-0.0.59 → lkr_dev_cli-0.0.60}/tests/test_user_attributes.py +0 -0
  206. {lkr_dev_cli-0.0.59 → lkr_dev_cli-0.0.60}/tests/test_version.py +0 -0
  207. {lkr_dev_cli-0.0.59 → lkr_dev_cli-0.0.60}/ty.toml +0 -0
@@ -0,0 +1,202 @@
1
+ ---
2
+ name: agentic-lookml
3
+ description: >
4
+ Unified LookML engineering, SQL-to-LookML refactoring, and Medallion semantic modeling skill (`agentic-lookml`).
5
+ Combines the 14 SQL-to-LookML pattern playbooks (`lkr parse sql-lookml-compare`, Canonical Looker Query gate,
6
+ Query-layer Table Calculations and Custom Fields with `matches_filter`, and Bronze/Silver/Gold Medallion architecture)
7
+ with the LookML writing reference skills from `looker-open-source/looker-skills` into a single reference set.
8
+ Use when authoring LookML models, views, explores, fields, refinements, Liquid, sets, access grants, or data tests,
9
+ or when refactoring SQL queries into LookML.
10
+ ---
11
+
12
+ # Agentic LookML (`agentic-lookml`)
13
+
14
+ ## Overview and scope
15
+
16
+ `agentic-lookml` combines:
17
+ - The 14 SQL-to-LookML pattern playbooks in `references/`, paired with `lkr parse sql-lookml-compare`, the Canonical Looker Query translation gate, Query-layer triage, and the Bronze / Silver / Gold Medallion hierarchy.
18
+ - The 9 LookML syntax reference sub-skills under [lookml](../lookml/SKILL.md) (`.agents/skills/lookml/sub-skills/*/SKILL.md`).
19
+
20
+ **Scope boundaries:**
21
+ - **`drill_fields:` is out of scope:** Never author, add, or modify `drill_fields:` (or drill-only `set:` blocks) unless explicitly requested, and do not flag missing `drill_fields:`.
22
+ - **Field curation (`suggestable:`, bulk descriptions) is on-demand:** Only run [lookml-field-curation](../lookml-field-curation/SKILL.md) when explicitly asked to curate fields.
23
+
24
+ ## Unified reference index
25
+
26
+ ### SQL-to-LookML pattern playbooks (`references/`)
27
+
28
+ Each SQL query analyzed by `lkr parse sql-lookml-compare` can match one or multiple pattern categories simultaneously. Consult the matching playbook in `references/` after inspecting `compare_initial.json` (skip `01-simple-single-table.md`, `02-conditional-measures-and-bucketing.md`, and `06-nested-ctes-and-subqueries.md` when single-table / existing-Explore routing, the Canonical Looker Query gate, and the Triage table below already cover the query):
29
+
30
+ | # | Category ID | Pattern playbook | AST trigger summary | Target layer |
31
+ | :--- | :--- | :--- | :--- | :--- |
32
+ | 01 | `simple_single_table` | [01-simple-single-table.md](references/01-simple-single-table.md) | 1 table, no joins/CTEs/unions/windows, standard columns and single-pass aggregates | `bronze/:table.refinement.lkml` + `bronze/explores/:table.explore.lkml` (until a Silver join is needed) |
33
+ | 02 | `conditional_measures_and_bucketing` | [02-conditional-measures-and-bucketing.md](references/02-conditional-measures-and-bucketing.md) | `SUM(CASE WHEN ...)`, `COUNTIF`, row-level `CASE WHEN` buckets, or measure-to-measure ratios | Query Layer (`Custom Field` / `Table Calc`), `bronze/:table.refinement.lkml` (single-table DAG), or `silver/:view_a_:view_b_fields.view.lkml` (multi-view DAG, bare-joined) |
34
+ | 03 | `pk_fk_joins` | [03-pk-fk-joins.md](references/03-pk-fk-joins.md) | Equality joins (`t1.fk = t2.pk`) between entity and fact tables | `silver/explores/:name.explore.lkml` (`type: left_outer`) + `bronze/:table.refinement.lkml` (PKs) + `silver/:view_a_:view_b_fields.view.lkml` |
35
+ | 04 | `triangle_inequality_joins` | [04-triangle-inequality-joins.md](references/04-triangle-inequality-joins.md) | Join `ON` contains `<, <=, >, >=, BETWEEN` (SCD2, rolling windows, point-in-time `AS OF`) | Strictly `gold/explores/` (`extends: [silver]`, `tags: ["gold"]`) + Gold `AS OF` view or `bronze/calendar_dates.view.lkml` |
36
+ | 05 | `dimensional_non_key_joins` | [05-dimensional-non-key-joins.md](references/05-dimensional-non-key-joins.md) | Joins on non-unique attributes (`date = date`, `country = country`) or `FULL OUTER JOIN` across facts | Gold OJOF (`FULL OUTER JOIN ON FALSE` + `COALESCE` view), Silver `UNION ALL` SDT, Silver NDT, or `bronze/calendar_dates.view.lkml` |
37
+ | 06 | `nested_ctes_and_subqueries` | [06-nested-ctes-and-subqueries.md](references/06-nested-ctes-and-subqueries.md) | Pass-through CTEs or subqueries doing renames, scalar math, row filters, or `PK = FK` joins without grain changes | Flatten into `bronze/:table.refinement.lkml` + `silver/explores/` (0 Derived Tables) |
38
+ | 07 | `measure_of_measures_reaggregation` | [07-measure-of-measures-reaggregation.md](references/07-measure-of-measures-reaggregation.md) | Outer query aggregates, tiers, or filters on an inner `GROUP BY` aggregate column | `silver/:entity_facts.view.lkml` (consolidated ephemeral NDT `explore_source:`) or `gold/` if `bind_all_filters: yes` |
39
+ | 08 | `window_functions_and_qualify` | [08-window-functions-and-qualify.md](references/08-window-functions-and-qualify.md) | `OVER (PARTITION BY ... ORDER BY ...)` (`ROW_NUMBER`, `RANK`, `LAG`, `LEAD`) or `QUALIFY` | Query Layer (`Table Calc`), `silver/` NDT (`derived_column:` post-`GROUP BY`), `silver/` SDT (`${EXTENDED}` pre-`GROUP BY` / `QUALIFY`), or `gold/` (`bind_all_filters: yes`) |
40
+ | 09 | `unions` | [09-unions.md](references/09-unions.md) | `UNION ALL` or `UNION DISTINCT` across tables or CTEs | `silver/:name.view.lkml` (SDT with `${}` on everything, `extends: [base_view]`, `fields: [ALL_FIELDS*, -view.unneeded]`, compound PK) or `gold/` (Long Metrics) |
41
+ | 10 | `unnest_arrays` | [10-unnest-arrays.md](references/10-unnest-arrays.md) | `UNNEST(...)`, `CROSS/LEFT JOIN UNNEST`, or correlated `FROM UNNEST` scalar subqueries | `bronze/:table.refinement.lkml` (`1:1` inline using `${dimension}`) or `bronze/:table__:arr.view.lkml` (`WITH OFFSET` PK) + `bronze/explores/` and `silver/explores/` (`1:M`) |
42
+ | 11 | `json_columns_and_extract` | [11-json-columns-and-extract.md](references/11-json-columns-and-extract.md) | `JSON_VALUE`, `JSON_EXTRACT_SCALAR`, `JSON_QUERY_ARRAY`, `LAX_*`, or JSON path access (if SQL only wraps scalar columns via `TO_JSON_STRING(STRUCT(...))` in a pass-through CTE with no physical JSON column, unwrap directly to Bronze dimensions with zero companion views/derived tables) | `bronze/:table.refinement.lkml` (`hidden: yes`) + `bronze/:table__:col.view.lkml` (`SAFE_CAST`) + `bronze/explores/` (bare join `sql: ;;`) + `silver/explores/` |
43
+ | 12 | `table_wildcards` | [12-table-wildcards.md](references/12-table-wildcards.md) | `FROM table_*` or `_TABLE_SUFFIX` predicates | `bronze/` (`table_*` view, refinement, `bronze/explores/:prefix.explore.lkml` with Liquid `_TABLE_SUFFIX` pruning + `always_filter:`; detach `:prefix_base` + `silver/explores/` only when joined) |
44
+ | 13 | `large_tables_and_partitioning` | [13-large-tables-and-partitioning.md](references/13-large-tables-and-partitioning.md) | Physically partitioned/clustered warehouse tables, `APPROX_COUNT_DISTINCT`, or `aggregate_table` (on unpartitioned tables, rewrite `DATE(ts)` directly to `<group>_date` filters without `always_filter:`) | `bronze/` (actual column `dimension_group`, partition notes in refinement, `always_filter:` on physically partitioned tables only) + `gold/explores/` (`aggregate_table`) |
45
+ | 14 | `templated_filters_and_parameters` | [14-templated-filters-and-parameters.md](references/14-templated-filters-and-parameters.md) | Runtime filters required inside inner subqueries/cohorts (`{% condition %}`, `{% parameter %}`) | Strictly `gold/` and `gold/explores/` (`tags: ["gold"]`) paired with mandatory `always_filter:` |
46
+
47
+ Standard `sum`/`average`/`min`/`max`/`count_distinct` measures strip outer `ROUND(<agg>, k)` / `COALESCE(<agg>, 0)` into one native measure (`value_format_name: usd` / `decimal_0..4` / `id`, omitting `decimal_0` on `count_distinct` named with a plural/count noun not ending in `id`/`year`/`decade`), `CORR`/`COVAR_*` use `type: number` (`sql: CORR(${x}, ${y}) ;;`, `tags: ["non_additive"]`, `value_format_name: decimal_4`), `STDDEV`/`VARIANCE` **never** call raw `STDDEV*`/`VAR*` inside `type: number` (`lookml-to-api --validate` rejects them—decompose into hidden symmetric `type: sum` `_${col}_sum` [or reuse unfiltered `total_${col}`], `type: sum` `_${col}_sum_of_squares` [`POWER(${col}, 2)`], and non-null `${count}` / filtered `_${col}_count` with `SQRT(GREATEST(${_<col>_sum_of_squares} - (POWER(${_<col>_sum}, 2) / NULLIF(${<n>}, 0)), 0) / NULLIF(${<n>} - 1, 0))`, using `NULLIF(${<n>}, 0)` for `POP` and dropping `SQRT(...)` for `VARIANCE`, `tags: ["non_additive"]`), and `100.0 * <num_agg> / <den_agg>` or `<agg>(<ratio>) * 100` strips `100.0 *` / `* 100` with `value_format_name: percent_2` and renames `*_pct` / `*_percent` to `*_ratio` / `*_rate` / `*_share` (`pre_gate.py` aligns half-away-from-zero `ROUND`, outer/CTE `* 100`, and aliases); only open [measures-and-sql-functions.md](references/measures-and-sql-functions.md) **after** `compare_initial.json` returns for `COUNT(nullable_col)`, `WHERE` vs `filters:` splits, or `percentile`/`median`.
48
+
49
+ ### Companion skills & LookML syntax references
50
+
51
+ - **Workflow & tooling:** [agentic-lookml-workflow](../agentic-lookml-workflow/SKILL.md) ([setup](../agentic-lookml-workflow/sub-skills/setup/SKILL.md)), [generate-view-from-table](../generate-view-from-table/SKILL.md), and on-demand [lookml-field-curation](../lookml-field-curation/SKILL.md).
52
+ - **Filters & expressions:** [looker-filters](../looker-filters/SKILL.md) (single-field `filters:` string syntax, Custom Filters, and SDK filter keys; `type: string` filters only `^`-escape literal `"`, `%`, `_`, `,`, `^`, and leading `-`/space—`()`, `+`, `$`, `.`, `/`, and `&` need no escape; `"after YYYY/MM/DD"` is inclusive `>=` and `"before YYYY/MM/DD"` is exclusive `<`) and [looker-expressions](../looker-expressions/SKILL.md) (Looker expression formulas across post-query Table Calculations and `.test.lkml` `assert:` vs. SQL-pushdown Custom Fields, Custom Filters `filter_expression`, `matches_filter`, and NDT `expression_custom_filter:`). Skip reading both on standard LookML dimensions/measures, date/string `filters:` slices, `dynamic_fields` covered below, and `.test.lkml` equality/row-`index()` assertions—only open them for exotic filter/expression functions.
53
+ - **LookML syntax (`../lookml/`, read-only upstream):** [lookml](../lookml/SKILL.md) (`lookml-model`, `lookml-view`, `lookml-explore`, `lookml-fields`, `lookml-refinements`, `lookml-liquid`, `lookml-ojof`, `lookml-sets`, `lookml-access-grants`, `lookml-tests`). `agentic-lookml` + `references/` are self-contained for views, refinements, explores, Quick Start `query:` blocks, and `.test.lkml`—only open `../lookml/` for specialized parameters (`html:`, `link:`, `access_grant:`, `map_layer:`), and `agentic-lookml` rules (Medallion paths; ephemeral Silver NDTs; no unrequested `drill_fields:` / `set: detail`; no inline `view: +name` refinements in `.explore.lkml`) always supersede `../lookml/`.
54
+
55
+ ## How `lkr parse sql-lookml-compare` drives refactoring
56
+
57
+ `lkr parse sql-lookml-compare` outputs per-query `parsed_query` ASTs, matched `views`/`fields` (`primary_key`, `timeframes`, `via`, `unmodeled_db_columns`), `explores` & `join_analysis` (`status`, `relationship`, `inferred_relationship`, `safety_warnings`, `precedents`), plus `tests`, `aggregate_tables`, and `explore_queries` (`coverage`).
58
+
59
+ ## Canonical Looker Query translation gate
60
+
61
+ Before creating any derived table (NDT or SDT), check whether the SQL query reduces to a flat Canonical Looker Query (`SELECT dims, agg_measures FROM base LEFT JOIN joins WHERE filters GROUP BY dims HAVING measure_filters ORDER BY ... LIMIT ...`; see [How Looker Generates SQL](https://docs.cloud.google.com/looker/docs/how-looker-generates-sql)).
62
+
63
+ When a query (even with pass-through CTEs/subqueries) maps to this shape without grain changes or non-`PK = FK` joins using Bronze refinements, Silver `PK = FK` joins + bare-joined `*_fields` views, runtime filters, and post-aggregation `dynamic_fields`, model zero derived tables:
64
+ - Author an Explore Quick Start `query:` block containing **only** modeled `dimensions`, `measures`, `filters` (including `HAVING` measure predicates), `pivots`, `subtotals`, `limit` (unquoted integer), `sorts` (`[view.field: desc]`), `timezone`, and `description` (Looker's `explore: { query: {} }` never accepts `dynamic_fields`, so when a multi-query `input.sql` defines distinct filter/ratio variants across its queries, promote each variant with predicate-distinct canonical names so every query gets a native Quick Start `query:` block):
65
+
66
+ ```lookml
67
+ query: top_countries {
68
+ dimensions: [users.country]
69
+ measures: [orders.count, orders.total_net_revenue]
70
+ filters: [orders.status: "-Cancelled", orders.count: ">=30"]
71
+ sorts: [orders.total_net_revenue: desc]
72
+ limit: 20
73
+ }
74
+ ```
75
+
76
+ - Place Table Calculations or Custom Fields in `dynamic_fields` on the Looker `WriteQuery` / `run_inline_query` payload (`"limit": "500"` string in `WriteQuery` JSON):
77
+ - **Custom Dimensions / Measures:** `{"dimension": "d", "label": "D", "expression": "case(when(${v.f} = \"a\", \"A\"), \"B\")"}` or `{"measure": "m", "label": "M", "based_on": "v.f", "type": "count_distinct", "filter_expression": "${v.x} > 0"}`. List `.name` in `fields` too (`lkr schema validate` requires `hidden: no` on all LookML fields in `WriteQuery.fields` and `based_on`); use a Custom Dimension for ad-hoc string labels or 1-for-1 dimension math while keeping the canonical dimension in Quick Start `query:`.
78
+ - **Table Calculations:** `{"table_calculation": "tc", "label": "TC", "expression": "if(${v.den} = 0, null, ${v.num} / ${v.den})"}` or grand-total share `{"table_calculation": "pct_of_total", "label": "Pct of Total", "expression": "${v.m} / sum(${v.m})", "value_format_name": "percent_2"}` (keep `percent_*` table calculations in unscaled `[0, 1]` ratio form without `* 100`—`pre_gate.py` auto-scales `* 100`; do not list the `table_calculation` name in `fields`, place its `${v.*}` inputs in `fields`, and list `table_calculation` sorts *after* database sorts like `["orders.total_net_revenue desc", "pct_of_total desc"]`).
79
+ - **Row-level & timestamp projections:** Dimension-only queries omit `measures:` in `query:` (and omit `sorts:` when `input.sql` has no `ORDER BY`); map `TIMESTAMP`/`DATETIME` projections and sorts to `<group>_time` (never `hidden: yes` `<group>_raw` or duplicate `*_timestamp` dimensions), and if an ungrouped non-`DISTINCT` row query omits the base `primary_key`, append `<base_view>.<pk>` (or `common_helpers.no_group`) last in `inline_query.fields` so Looker cancels `GROUP BY` deduplication.
80
+
81
+ Escalate to Silver NDTs/SDTs or Gold specialty explores **only** when grain-changing reaggregations, pre-`GROUP BY` row windows/`QUALIFY`, `UNION ALL`, runtime-filtered subqueries, or non-key/inequality joins prevent a flat canonical query.
82
+
83
+ ## The Medallion LookML architecture (Bronze, Silver, and Gold)
84
+
85
+ ```text
86
+ ├── bronze/
87
+ │ ├── order_items.view.lkml # Autogenerated 1:1 physical table (DO NOT MODIFY)
88
+ │ ├── order_items.refinement.lkml # Created on-demand (PKs, single-table dims/measures, descriptions)
89
+ │ ├── orders__line_items.view.lkml # Companion UNNEST or JSON extracted view (:table__:col.view.lkml)
90
+ │ ├── calendar_dates.view.lkml # Standard calendar spine view for date joins
91
+ │ ├── common_helpers.view.lkml # Bare-joined helper: no_group (sql: 1 ;;) + current_timestamp
92
+ │ ├── :schema/ (:dataset/:schema/ on collision) # Nested ONLY when table names collide across schemas/projects
93
+ │ └── explores/
94
+ │ ├── events.explore.lkml # Unjoined single-table explore (hidden: no)
95
+ │ └── order_items_base.explore.lkml # Detached base explore (hidden: yes) once promoted to Silver
96
+ ├── silver/
97
+ │ ├── inventory_items_order_items_fields.view.lkml # Cross-view DAG dims & measures (alphabetical views + _fields, bare-joined)
98
+ │ ├── user_order_facts.view.lkml # Consolidated entity NDTs (ephemeral), ${EXTENDED} windows, UNION ALL SDTs
99
+ │ └── explores/
100
+ │ └── order_items.explore.lkml # PK=FK joins (default left_outer) + bare-joined *_fields; extends: [:table_base], hidden: no; ZERO Liquid
101
+ ├── gold/
102
+ │ ├── orders_dynamic_rank.view.lkml # Specialty views: bind_all_filters NDTs, Liquid SDTs, AS OF SDTs, OJOF COALESCE views
103
+ │ └── explores/
104
+ │ └── orders_subscription_attribution.explore.lkml # extends: [silver_explore], tags: ["gold"]; inequality joins, OJOF, Liquid + always_filter, aggregate_table
105
+ ├── tests/
106
+ │ ├── bronze/ # Single-table measures tested at table grain on immutable slices
107
+ │ └── silver/ # Silver joins, *_fields, and NDT/SDT metrics ONLY
108
+ └── project.model.lkml # Includes bronze/explores/**, silver/explores/**, gold/explores/**, tests/**/*.test.lkml
109
+ ```
110
+
111
+ ### Bronze layer (`bronze/`)
112
+
113
+ - **Immutable base views (`bronze/:table.view.lkml`):** Never hand-edit autogenerated base views or duplicate logic (`lkr parse lookml-to-api --validate` rejects duplicate logic and alias collisions). Reuse committed fields as-is (`lkr parse lookml snapshot` tracks `connections`, `measures`, `access`, `sql` [hashing SDT `derived_table.sql` verbatim so mutating an existing SDT sets `requires_review: true`—add a sibling `extends: [base_view]` SDT instead, whereas adding `column:`s or `derived_column:`s to an existing NDT `explore_source:` with unchanged `filters:` stays `requires_review: false`], and `tests`, not dimension `description`/`synonyms` or Explore `query:` blocks). On newly authored or touched fields, sanitize quirky SQL `AS` aliases into canonical domain identifiers, put genuine business terms/acronyms/standalone-honest SQL aliases in `synonyms: [...]` (never thesaurus filler or `WHERE`-filtered aliases on unfiltered fields), and reserve `alias: [...]` strictly for renaming already-committed LookML fields. Refine autogenerated `measure: count` in `bronze/:table.refinement.lkml` (`description:`, standalone-honest `synonyms:`, and `label:` **only** on a brand-new Explore—existing Explores already snapshot `"Count"`) and reference `<view>.count` everywhere.
114
+ - **Refinements (`bronze/:table.refinement.lkml`):** Create on-demand with first line `include: ":table.view.lkml"`. Holds verified PKs, single-table dimensions (`type: yesno`, `type: duration`, `EXTRACT(... FROM ${<group>_raw})` with `value_format_name: id` on years and `DAYOFWEEK` `1..7` UTC vs `day_of_week_index`, native `case:` buckets), single-table measures (`sum`, `average`, `count_distinct`, `percentile`, `median`, single-table filtered KPIs/ratios) whose `${...}` DAG stays within `:table` (except `${common_helpers.*}`), shared noun `group_label:` folders for `3+` sibling attribute dimensions (never under a date `dimension_group`'s `"<Name> Date"` label), country/domain guards on regional dimensions (e.g., gate US regions/ZIP zones with `${country} = 'United States'` and omit catch-all `else:` so international rows stay `NULL`), and structural/safety `tags:` on **newly authored or touched fields** (`tags: ["non_additive"]` on ratios/`average`/`min`/`max`/`count_distinct`/`APPROX_COUNT_DISTINCT`/`percentile`/`median`/`stddev`, `tags: ["foreign_key"]` on new join keys, and `tags: ["pii"]`/`"email"`/`"phone"` on sensitive columns—adding `tags: ["pii"]` on a newly created field is purely additive under `--allow-additions`, keeping `requires_review: false`; leave untouched fields as-is, and never duplicate `primary_key`, `hidden`, `suggestable`, `type`, or `value_format_name` in `tags:`).
115
+ - **Reusable Bronze helpers (`bronze/common_helpers.view.lkml` & `bronze/calendar_dates.view.lkml`):** Bare-join `common_helpers` (`join: common_helpers { sql: ;; relationship: one_to_one }`, exempted from `silver/*_fields.view.lkml` as a table-less Bronze helper) onto the base explore (and any explore joining the referencing view) for `common_helpers.no_group` (`primary_key: yes`, `hidden: yes`, `type: number`, `sql: 1 ;;`, paired with `cancel_grouping_fields: [common_helpers.no_group]`) or `common_helpers.current_timestamp_raw` (`dimension_group: current_timestamp { hidden: yes type: time timeframes: [raw, time, date, week, month, quarter, year] sql: CURRENT_TIMESTAMP() ;; }`). Model elapsed time (`TIMESTAMP_DIFF` / `DATE_DIFF`) via a `type: duration` `dimension_group` (`intervals: [day, ...]`, `sql_start: ${created_raw} ;;`, `sql_end: COALESCE(${sold_raw}, ${common_helpers.current_timestamp_raw}) ;;`) and reference `${days_<group>}` in downstream `case:` tiers—never repeat raw `TIMESTAMP_DIFF(..., CURRENT_TIMESTAMP(), ...)` inside a `CASE` dimension.
116
+ - **Bronze explores (`bronze/explores/`):**
117
+ - Serve unjoined single-table queries directly from `bronze/explores/:table.explore.lkml` (`explore: :table`, `hidden: no`, `label: "..."`, `view_name: :table`) until a cross-table `join:`, `silver/*_fields.view.lkml`, or Silver NDT/SDT is needed.
118
+ - Once a Silver join is needed, detach into `bronze/explores/:table_base.explore.lkml` (`explore: :table_base`, `hidden: yes`, `view_name: :table`, updating the `include:` path to `/silver/explores/:table.explore.lkml` in `tests/bronze/:table.test.lkml` and any existing `silver/*.view.lkml` NDTs referencing `/bronze/explores/:table.explore.lkml` so `explore_source: :table` blocks and `tests.snap.json.md` remain untouched) and extend in `silver/explores/:table.explore.lkml` (`explore: :table`, `extends: [:table_base]`, `hidden: no`, preserving the existing Explore `label: "..."` and migrating any Quick Start `query:` blocks so `measures.snap.json.md` stays additive, `view_name: :table`), preserving `<model>.:table`.
119
+ - Keep four physical intra-table mechanics in Bronze explores: (1) `LEFT JOIN UNNEST(${table.arr}) ... WITH OFFSET` (`one_to_many`), (2) bare joins (`sql: ;;`, `one_to_one`) for `bronze/:table__:json_col.view.lkml`, (3) wildcard `_TABLE_SUFFIX` shard pruning (`always_filter:` + Liquid `sql_always_where:` — the sole Liquid exception outside Gold), and (4) partition `always_filter:` on the real partition `dimension_group` (no `_PARTITIONDATE` pseudo-dimensions or `conditionally_filter:`).
120
+
121
+ ### Silver layer (`silver/`)
122
+
123
+ - **Cross-view `*_fields` views (`silver/:view_a_:view_b[..._fields].view.lkml`):** Any dimension or measure whose own expression DAG directly references `2+` views goes into a cohesive view named by sorting those directly referenced LookML view names alphabetically + `_fields` (e.g., `silver/order_items_products_users_fields.view.lkml`, co-locating `hidden: yes` helper dimensions used by those measures, treating NDTs/SDTs as their own view name, and never merging unrelated view sets into one mega-file), declares a hidden `dimension: pk { primary_key: yes hidden: yes ... }` (`type: number sql: ${base.id} ;;` when only bare-joined onto an `M:1` fact explore, or `CONCAT(...)` when also bare-joined onto a `1:M` parent/`UNNEST` explore), and is bare-joined (`sql: ;;`, `relationship: one_to_one`) onto Silver Explores joining those views. When the same `*_fields` file is bare-joined onto multiple Explores with different base grains (e.g., `explore: users` vs `explore: order_items`), keep all `(view_a, view_b)` fields in that single file and exclude grain-mismatched measures on each Explore via `explore: { fields: [ALL_FIELDS*, -order_items_users_fields.revenue_per_registered_user] }`.
124
+ - **Silver derived views (`silver/:name.view.lkml`):**
125
+ 1. Consolidated entity-grain NDTs (`explore_source:`, ephemeral by default; `derived_column:` for post-`GROUP BY` windows; never write `GROUP BY` SDTs with `{{ measure._sql }}`).
126
+ 2. Pre-`GROUP BY` row-window or single-table CDC `QUALIFY` SDTs (`extends: [base_view]` using `FROM (${EXTENDED}) AS {{ _table }}` or `FROM ${base_view.SQL_TABLE_NAME} AS {{ _table }}`).
127
+ 3. `UNION ALL` SDTs (`extends: [base_view]`, `FROM ${base_view.SQL_TABLE_NAME} AS {{ _table }}` with `{{ field._sql }}`, `fields: [ALL_FIELDS*, -view.unneeded]`, and a compound PK).
128
+ - **Silver explores (`silver/explores/:name.explore.lkml`):**
129
+ - Create **only** when cross-table `PK = FK` joins, `silver/*_fields.view.lkml`, or Silver NDT/SDT views are required (never create joinless pass-through Silver explores).
130
+ - Preserve query grain: never route `FROM :entity` (`FROM users`) onto a fact explore (`FROM order_items LEFT JOIN users`) when `:entity`-grain measures or `LEFT JOIN` 0-activity entities are queried; conversely, when SQL writes `FROM :parent INNER JOIN :child` and aggregates **only `:child` columns** grouped by `:parent` dimensions (zero `:parent`-grain measures), anchor on `:child`'s Silver Explore (`many_to_one` to `:parent`, adding `:parent.id: "NOT NULL"` if `:child`'s FK is nullable) rather than creating a `one_to_many` parent explore.
131
+ - Strict YAGNI on joins: join only tables referenced in `input.sql` or explicitly required by `metrics.md`. Default SQL `INNER JOIN` to `type: left_outer` (`many_to_one`, `one_to_one`, `one_to_many`) unless business semantics strictly require inner filtering; if `input.sql` tests both `LEFT JOIN` (all entities) and `INNER JOIN` (active/purchasing entities) on the same base entity, keep `type: left_outer` on the Explore and apply a non-null child filter (`child.id: "NOT NULL"` on `type: number` PKs, or `-NULL` on `type: string`) or filtered measure on the `INNER JOIN` query.
132
+ - Always declare `view_name:` explicitly, ensure every joined view has `primary_key: yes` (verifying cardinality via [03-pk-fk-joins.md](references/03-pk-fk-joins.md) when unknown), place any `fields: [ALL_FIELDS*, -view.field]` exclusions on `explore:` (never inside `join:`, where `ALL_FIELDS*` is undefined), and include zero runtime Liquid, inequality joins, non-key joins, or OJOF in Silver.
133
+
134
+ ### Gold layer (`gold/`)
135
+
136
+ - **Gold views (`gold/:name.view.lkml`) & explores (`gold/explores/:name.explore.lkml`):** Extend Silver explores (`extends: [silver_explore]`, explicit `view_name:`, `tags: ["gold"]` — omit `extends:` only for standalone Long Metrics `UNION ALL` SDT explores or 0-row `SELECT 0 FROM UNNEST([])` multi-table OJOF explores whose row grain spans unpivoted/disjoint branches, and never suffix explore names with `_gold`). Isolate `bind_all_filters: yes`, runtime Liquid (`{% condition %}`, `{% parameter %}`) paired with mandatory `always_filter:`, inequality/SCD2/`AS OF` joins, OJOF (`FULL OUTER JOIN ON FALSE` with `relationship: many_to_many` + `COALESCE` view), Long Metrics, and `aggregate_table:` blocks.
137
+ - **DRY SDT substitution rule (Silver & Gold):** Every SQL Derived Table must reference tables via `${view_name.SQL_TABLE_NAME} AS {{ _table }}` or `(${EXTENDED}) AS {{ _table }}` (never raw `db.schema.table`) and columns via `{{ field._sql }}`, `${field}`, or `${TABLE}.col` (never override `dimension: <pk> { sql: NULL ;; }` on an `extends: [base_view]` SDT that references `{{ <pk>._sql }}`, since `{{ <pk>._sql }}` resolves to the child view's `NULL` override). For a second table in an SDT join, bare-join an `_sdt_*` alias (`join: _sdt_other { from: other_view sql: ;; relationship: one_to_one fields: [] }`) and reference `AS {{ _sdt_other._name }}` + `{{ _sdt_other.field._sql }}`.
138
+
139
+ ## Triage: Query Layer vs. LookML
140
+
141
+ | Calculation type | Keep in Query / Dashboard (`dynamic_fields`) when... | Promote to LookML (`bronze/*.refinement.lkml` or `silver/*_fields.view.lkml`) when... |
142
+ | :--- | :--- | :--- |
143
+ | Filtered measure (`SUM(CASE WHEN ...)` / `COUNTIF`) | Infrequent single-tile comparison (`us_orders` vs `uk_orders`): use a Custom Measure with [looker-filters](../looker-filters/SKILL.md) or `matches_filter` ([looker-expressions](../looker-expressions/SKILL.md)). | Canonical domain KPI reused across queries or defined in `metrics.md` (`filters: [...]`; use a shared `hidden: yes` positive `type: yesno` dimension for cross-column `OR` or when filtering on a `case:` bucket whose label contains commas `,`—otherwise `^,`-escape literal commas). |
144
+ | Row-level bucketing (`CASE WHEN ...`) | Campaign- or report-specific promo/cohort bucket: use a Custom Dimension ([looker-expressions](../looker-expressions/SKILL.md)). | Reusable domain tier/flag/regime (`age_tier`, `is_sold`, `shopping_regime`) or expression over unmodeled `${TABLE}.col`. |
145
+ | Named Business Term (`metrics.md` / `terms/*`) | Unnamed one-off `WHERE` slice (`age < 25` with no named domain term): filter directly on the base dimension ([looker-filters](../looker-filters/SKILL.md)). | Promote or reuse a canonical LookML dimension (preserving persona/business titles in `label:` + `synonyms:`, without blanket `is_`, negated `yesno` twins, or unrequested `<term>_count` measures): **relative/rolling calendar period** (*"New Customer: current calendar year"*, *"Recent Signup: last 30 days"*) $\rightarrow$ never bake `CURRENT_TIMESTAMP()`/`CURRENT_DATE()` or a literal year into `is_<term>`; reuse `<group>_year` / `<group>_date` (`"this year"`, `"30 days"`, or `${<fact>.created_year} = ${<entity>.created_year}` Custom Filter / `years_since_signup = 0`) and enrich `description:` + standalone-honest `synonyms:`; **1:1 synonym or `"no"`/`IS NULL` state of an existing positive `yesno`** (`sold_at IS NULL` on `is_sold`) $\rightarrow$ enrich `synonyms:`/`description:` and filter `"no"`; **new time-invariant binary flag** $\rightarrow$ positive `type: yesno` `is_<term>`/`has_<term>` + noun `label: "<Term>"`; **categorical/bucketing** $\rightarrow$ canonical noun `<term>` with native `case: { when: { sql: ... ;; label: "..." } else: "..." }` (or `type: tier`), stripping `'1. '`–`'9. '` prefixes (`pre_gate.py` aligns stripped prefixes) and ordering `when:` in `1..N` display order (`else:` sorts last unless `alpha_sort: yes`; never put `#1` in `else:`, **explicitly negate earlier SQL branches** like `NOT (cond_3) AND cond_2` whenever promoting `#2` above `#3` to preserve short-circuit counts, omit `else:` on country/subset-scoped buckets so out-of-scope rows stay `NULL`, and group `3+` sibling attributes under a noun `group_label:`); **derived scalar/duration** $\rightarrow$ canonical noun `<term>` (`string`/`number` or `type: duration` `dimension_group` with `${common_helpers.current_timestamp_raw}`); **entity-grain aggregate cohort** $\rightarrow$ Silver Rollup NDT ([07-measure-of-measures-reaggregation.md](references/07-measure-of-measures-reaggregation.md)). |
146
+ | Post-aggregation math (`SUM(a)/SUM(b)`, `% of total`, `LAG`) | Visual display math (`pct_of_total`, `running_total()`, `offset()`): use a Table Calculation ([looker-expressions](../looker-expressions/SKILL.md)). | Foundational business ratio (`average_order_value`, `per_capita_spend`) defined in `metrics.md` or reused across queries/NDTs (`type: number`, `1.0 * ${num} / NULLIF(${den}, 0)`). |
147
+
148
+ ### Blast-radius autonomy tiers
149
+
150
+ 1. **Tier 1 (Auto-Apply):** Missing/stale Bronze views (`generate-view-from-table`), additive single-table dimensions/measures in `bronze/:table.refinement.lkml`, JSON/`1:1` `UNNEST` extractions, unaffected `tests`.
151
+ 2. **Tier 2 (Batch Confirm):** New `PK = FK` joins or `silver/*_fields.view.lkml` bare joins, Bronze physical-exception base explores (`UNNEST`, JSON, `_TABLE_SUFFIX`, partition `always_filter:`), consolidated Silver NDTs, or Query-layer vs LookML triage.
152
+ 3. **Tier 3 (Architectural Escalation):** Gold specialty explores (`tags: ["gold"]`) or edits to dimensions/measures with downstream `via` dependents or `tests`.
153
+
154
+ ## Standardized diagnostic contract & `./data/:id/summary.md`
155
+
156
+ When reporting `sql-lookml-compare` analysis, summarize: matched pattern IDs & AST triggers, matched views/fields (`via`) vs unmodeled columns, affected explores/tests, target Medallion layer, autonomy tier, and surgical LookML changes.
157
+
158
+ Record these six sections in `./data/:id/summary.md` for the GitHub PR:
159
+ 1. **Generating base views** (`bronze/:table.view.lkml` created/refreshed or verified complete)
160
+ 2. **Deconstructing query to canonical form** (`FROM`, `LEFT JOIN`, dims, measures, filters, sorts/limits, key modeling decisions, plus—whenever an NDT/SDT or Gold explore is used—the exact reason a flat Canonical Looker Query could not express it)
161
+ 3. **Query patterns from original query** (matched playbook IDs from the 14 categories)
162
+ 4. **Writing LookML (Bronze, Silver, and Gold)** (every file created/updated)
163
+ 5. **Pre-test validation and parity gate** (1–3 line parity outcome + any `Validation failed:` / `Updating LookML:` self-heals; do not duplicate the `/sql/<sql_id>` and `/x/<query_id>` URL table auto-rendered by `create_github_pr.sh`)
164
+ 6. **Writing and evaluating tests** (`tests/bronze/` and `tests/silver/` slices, baseline assertions, and targeted `run_lookml_test` result)
165
+
166
+ ## Opinionated LookML testing (`tests/`)
167
+
168
+ ### Single-push pre-test gate
169
+ Author Bronze refinements, Bronze explores, and any Silver/Gold views and explores **together before the first push**, validate locally (`lkr parse lookml-to-api --validate`, batch `lkr schema validate --explore-file`), push once, and run [pre_gate.py](../agentic-lookml-workflow/scripts/pre_gate.py) (including `--var test_queries_json` for immutable test slices) in a single sandbox call before writing `.test.lkml` files.
170
+
171
+ ### Incremental `test:` blocks & layer separation
172
+ - **Bronze (`tests/bronze/:table.test.lkml` or `:table_base.test.lkml`):** Test single-table refinement measures and dimensions/terms **only** in `tests/bronze/` (targeting `explore_source: :table` while unjoined; when detaching `:table` to Silver later, update **only** `include:` to `../../silver/explores/:table.explore.lkml` and never edit existing `explore_source: :table` blocks). When testing a newly authored or enriched **dimension / Business Term** with zero new measures, never invent throwaway measures just for the test: on a bounded historical date slice (`created_date: "2024-01-01"`, or bounded PK slice `id: "<=1000"` on timeless catalog tables), either filter `is_<term>: "yes"` (or `"no"` when reusing an existing positive `yesno`) or project `column: <term> { field: <view>.<term> }` (`sorts: [<view>.<term>: asc]`) alongside `column: count { field: <base_view>.count }` (for categorical/scalar terms, asserting per-row values via `index(${<view>.<term>}, 1) = "..." AND index(${<base_view>.count}, 1) = <N>`—always use UPPERCASE `AND`/`OR`/`NOT` and unquoted `yes`/`no` in `.test.lkml` `assert:` since `assert:` must return `yes` on every row)—or algebraic/structural invariants rather than `= <constant>` whenever the filter represents an open/mutable state (`sold_at IS NULL` / `is_sold: "no"`).
173
+ - **Silver (`tests/silver/:explore.test.lkml`):** Test **strictly** Silver joins, `silver/*_fields.view.lkml` dimensions/metrics, and Silver NDT/SDT views, grouped by join-path branch. **Skip `tests/silver/` entirely on single-table explores.**
174
+ - **Incremental across PRs, grouped within a PR:** Group sibling metrics *introduced in the current PR on the same filter slice* into **one** new `test: <table>_<slice>_metrics` block with multiple `column:` and `assert:` statements—**never modify, re-run, or copy numbers from already-committed `test:` blocks** from prior PRs (synthetic public datasets like `thelook_ecommerce` periodically regenerate historical row counts, and CI runs only newly added/edited `test:` blocks via `diff_lookml_tests.py`). Pass only the new `test_names` / `file_ids` to `validate_and_commit_branch.py`.
175
+ - **Explicit standalone `include:`s & model glob:** Every `.explore.lkml` and `.test.lkml` must explicitly `include:` its referenced `.view.lkml` / `.refinement.lkml` / `.explore.lkml` files, and `project.model.lkml` must include `/tests/**/*.test.lkml`.
176
+
177
+ ### State-machine immutability & rounding in assertions
178
+ - **Terminal / append-only states** (e.g., `created_date: "2024-01-01"` on append-only events/items, `is_sold: "yes"` anchored on past `sold_date`, settled `Complete` orders): Assert deterministic rounded equality (`expression: round(${view.measure}, 2) = 4520.25 ;;`, `round(${view.measure}, 3) = ...` for `decimal_3`, or `round(${view.measure}, 4) = 0.3467 ;;` for unscaled `percent_2` `[0, 1]` ratios; see [looker-expressions](../looker-expressions/SKILL.md)) using raw unformatted values from `.test_query_results`.
179
+ - **Never write unfiltered whole-table `= <constant>` assertions or unverified string-literal slice filters:** Every equality `test:` MUST filter `explore_source:` on a bounded historical cohort/date (or timeless catalog PK) slice, and never guess exact string dimension values (`click_journey_path: "home"`) in `.test.lkml` without verifying non-empty rows via `test_queries_json` (Looker rejects 0-row test queries); unfiltered table-wide tests break whenever warehouse tables append or regenerate rows and may only assert structural invariants (`>= 0`, `between(0, 1)`).
180
+ - **Open / mutable states** (e.g., `is_sold: "no"` / `sold_at IS NULL`, 0-order dormant cohorts `orders.order_id IS NULL`, `Processing` orders, active carts, or cumulative lifetime entity NDT buckets like `user_spend_facts` / `product_sales_facts` even when sliced by `id: "<=1000"`): Filtering by historical `created_date` or entity `id` does **not** freeze open/lifetime states because rows can transition or accumulate future orders later. **Never** write frozen `= <constant>` assertions on open-state metrics; rely on `pre_gate.py` parity and, if a `test:` is needed, assert structural or algebraic invariants (`${inventory_items.sunk_dusty_shelf_cost} >= 0`, `${orders_users_fields.ghost_users} = ${users.count} - ${orders.distinct_users}`, `${user_spend_facts.avg_customer_ltv} >= 300`).
181
+
182
+ ```lookml
183
+ # In tests/bronze/order_items.test.lkml (point include at ../../silver/explores/order_items.explore.lkml once detached)
184
+ include: "../../bronze/explores/order_items.explore.lkml"
185
+
186
+ test: order_items_2024_01_01_metrics {
187
+ explore_source: order_items {
188
+ column: total_sale_price { field: order_items.total_sale_price }
189
+ column: order_item_count { field: order_items.count }
190
+ filters: {
191
+ field: order_items.created_date
192
+ value: "2024-01-01"
193
+ }
194
+ }
195
+ assert: total_sale_price_matches {
196
+ expression: round(${order_items.total_sale_price}, 2) = 4520.25 ;;
197
+ }
198
+ assert: order_item_count_matches {
199
+ expression: ${order_items.count} = 84 ;;
200
+ }
201
+ }
202
+ ```
@@ -0,0 +1,51 @@
1
+ # Simple single-table queries (`simple_single_table`)
2
+
3
+ Single-table queries without joins, CTEs, unions, or window functions map to `bronze/:table.view.lkml` (autogenerated) + on-demand `bronze/:table.refinement.lkml` + `bronze/explores/:table.explore.lkml` (`hidden: no` until promoted to Silver; Tier 1 auto-apply).
4
+
5
+ ## Tool categorization rule (`lkr parse`)
6
+
7
+ The parser tags a query with `simple_single_table` when `parsed_query` meets all of these conditions:
8
+
9
+ - `len(parsed_query.tables) == 1` (a single physical table).
10
+ - `parsed_query.joins == []` (no `JOIN` or `UNNEST` joins).
11
+ - `parsed_query.ctes == []` and no nested `FROM (SELECT ...)` subqueries.
12
+ - `parsed_query.statement_type == "SELECT"` (no `UNION`).
13
+ - Every `col` in `parsed_query.columns` has `col.sql` containing only direct columns, simple scalar functions (`DATE_TRUNC`, `COALESCE`), or standard single-pass aggregations (`col.is_agg == True` with `COUNT`, `COUNT(DISTINCT ...)`, `SUM`, `AVG`, `MIN`, `MAX`, and no nested `CASE` or window functions).
14
+
15
+ ## Representative SQL
16
+
17
+ ```sql
18
+ SELECT
19
+ status,
20
+ DATE_TRUNC(created_at, MONTH) AS created_month,
21
+ COUNT(*) AS row_count,
22
+ SUM(sale_price) AS total_revenue
23
+ FROM `project.dataset.orders`
24
+ WHERE created_at >= '2026-01-01'
25
+ GROUP BY 1, 2
26
+ ```
27
+
28
+ ## How to handle
29
+
30
+ 1. **Bronze base view (`bronze/:table.view.lkml`):** Inspect `queries[].parsed_query.tables` and `queries[].views` in `lkr parse sql-lookml-compare`. Only invoke [generate-view-from-table](../../generate-view-from-table/SKILL.md) when a table is missing from `views` or `views[].unmodeled_db_columns` is non-empty. Never hand-edit `bronze/:table.view.lkml`.
31
+ 2. **Bronze refinement (`bronze/:table.refinement.lkml`, on-demand only):** Create only when adding primary keys, single-table dimensions/Business Terms (`yesno` if boolean-like with `is_<term>` + noun `label: "<Term>"`, or noun `<term>` for categorical/scalar/duration terms; see [SKILL.md](../SKILL.md#triage-query-layer-vs-lookml)) or measures (`sum`, `average`, `count_distinct`, `percentile`, `median`, filtered KPIs, ratios) whose entire `${...}` DAG stays within `:table`, `hidden: yes` / `suggestable: no` governance, or labels/descriptions/synonyms. Whenever the query uses aggregates or aggregate wrappers (`ROUND`, `* 100`, `COALESCE`, `COUNT(*)`), evaluate them per [measures-and-sql-functions.md](measures-and-sql-functions.md).
32
+ 3. **Explore placement (`bronze/explores/` vs `silver/explores/`):**
33
+ - **Existing Silver Explore (`silver/explores/:table.explore.lkml` with `view_name: :table`):** Serve the query and add Quick Start `query:` blocks there (keeping `bronze/explores/:table_base.explore.lkml` `hidden: yes`). Never route a standalone `FROM :entity` query (`FROM users`) onto a fact `LEFT JOIN` explore (`FROM order_items LEFT JOIN users`), which drops 0-activity entities.
34
+ - **Unjoined single-table query (no Silver explore yet):** Serve directly from `bronze/explores/:table.explore.lkml` (`explore: :table`, `hidden: no`, `label: "..."`, `view_name: :table`, plus Quick Start `query:` blocks) and test in `tests/bronze/:table.test.lkml` (`explore_source: :table`). Never create a joinless pass-through Silver explore.
35
+ - **Detaching to Silver when a join is needed:** Only when `:table` requires a cross-table `join:`, `silver/*_fields.view.lkml` bare join, or Silver NDT/SDT, detach into `bronze/explores/:table_base.explore.lkml` (`explore: :table_base`, `hidden: yes`, `view_name: :table`, updating **only `include:`** in `tests/bronze/:table.test.lkml` to `../../silver/explores/:table.explore.lkml` so existing `explore_source: :table` blocks and `tests.snap.json.md` remain untouched) and create `silver/explores/:table.explore.lkml` (`explore: :table`, `extends: [:table_base]`, `hidden: no`, `label: "..."`, `view_name: :table`).
36
+
37
+ ## LookML template (single-table measures in Bronze refinement)
38
+
39
+ ```lookml
40
+ # File: bronze/orders.refinement.lkml
41
+ include: "orders.view.lkml"
42
+
43
+ view: +orders {
44
+ measure: total_revenue {
45
+ type: sum
46
+ sql: ${sale_price} ;;
47
+ value_format_name: usd
48
+ description: "Sum of order sale price."
49
+ }
50
+ }
51
+ ```
@@ -0,0 +1,148 @@
1
+ # Conditional measures, bucketing, and ratios (`conditional_measures_and_bucketing`)
2
+
3
+ This category (`conditional_measures_and_bucketing`) routes ad-hoc or single-query calculations to the Query Layer (Custom Measure, Custom Dimension, or Table Calculation), reusable single-table dimensions and measures to `bronze/:table.refinement.lkml`, and any dimension or measure whose dependency DAG spans multiple views to a dedicated `silver/:view_a_:view_b_fields.view.lkml` view that is bare-joined (`sql: ;;`) into the Explore. These changes use Tier 2 batch confirmation to decide between Query Layer placement and Bronze or Silver LookML promotion.
4
+
5
+ ## Tool categorization rule (`lkr parse`)
6
+
7
+ The parser tags a query with `conditional_measures_and_bucketing` when any projection (`col` in `parsed_query.columns`) contains one of the following structures:
8
+
9
+ - `col.is_agg == True` and `col.sql` wraps `CASE`, `IF`, or `COUNTIF` (for example, `SUM(CASE WHEN status = 'Complete' THEN amount END)` or `COUNTIF(...)`).
10
+ - `col.is_agg == False` and `col.sql` contains `CASE` or `IF` for row-level bucketing.
11
+ - `col.is_agg == True` and `col.sql` contains arithmetic (`/` or `SAFE_DIVIDE`, `-`, `+`, `*`) combining two or more aggregate expressions (for example, `SUM(rev) / NULLIF(COUNT(DISTINCT id), 0)`).
12
+
13
+ ## Representative SQL
14
+
15
+ ```sql
16
+ SELECT
17
+ DATE(created_at) AS created_date,
18
+ CASE
19
+ WHEN age < 25 THEN 'Gen Z'
20
+ WHEN age BETWEEN 25 AND 40 THEN 'Millennial'
21
+ ELSE '40+'
22
+ END AS age_bucket,
23
+ COUNT(CASE WHEN country = 'US' THEN order_id END) AS us_orders,
24
+ COUNT(CASE WHEN country = 'UK' THEN order_id END) AS uk_orders,
25
+ SUM(CASE WHEN status = 'Complete' THEN sale_price ELSE 0 END) AS completed_revenue,
26
+ SUM(sale_price) / NULLIF(COUNT(DISTINCT order_id), 0) AS aov
27
+ FROM `project.dataset.orders`
28
+ GROUP BY 1, 2
29
+ ```
30
+
31
+ ## Impact of naive translation
32
+
33
+ 1. Turning every `COUNT(CASE WHEN country = 'US' ...)` into a permanent LookML measure causes an $N \times M$ measure explosion that bloats the Explore field picker.
34
+ 2. Putting cross-view dimensions or measures in Bronze (or inside a single table's view refinement) breaks layer boundaries and view portability. Bronze can hold dimensions and measures only when their entire dependency DAG references that single table; any field whose DAG references multiple views belongs in `silver/:view_a_:view_b_fields.view.lkml` and is bare-joined into the Explore.
35
+ 3. Writing `sql: SUM(CASE WHEN ${status} = 'Complete' THEN ${sale_price} END) ;;` with `type: number` breaks Looker symmetric aggregates when the view is joined.
36
+
37
+ ## How to handle (triage matrix)
38
+
39
+ The easiest way to author a Custom Measure in the Query or Dashboard Layer is using [looker-filters](../../looker-filters/SKILL.md) (for single-field filter strings, `,` OR lists, `^` escaping, numeric intervals, and relative/absolute date bounds) or `matches_filter(${view.field}, \`filter_expression\`)` in [looker-expressions](../../looker-expressions/SKILL.md). Custom Fields (`custom_fields`) and Table Calculations (`table_calculations`) live in the Looker API query payload (`dynamic_fields`), Saved Looks, or LookML Dashboards (`.dashboard.lookml`), and cannot be placed inside an `explore: { query: {} }` Quick Start block.
40
+
41
+ | Sub-pattern | Condition | Resolution | Target location |
42
+ | :--- | :--- | :--- | :--- |
43
+ | Universal report slice (not an intrinsic KPI) | Every metric shares an ad-hoc `WHERE X` or `CASE WHEN X`, and `metrics.md` does not define a recurring predicate-specific KPI | Strip from measure; use strictly unfiltered measure names (`total_revenue`, never a predicate-named alias) + runtime filter on `X` ([measures-and-sql-functions.md](measures-and-sql-functions.md), [looker-filters](../../looker-filters/SKILL.md)) | Query / Dashboard Filter |
44
+ | Infrequent filtered metric | Side-by-side comparison for one report (`us_orders` vs. `uk_orders`) | Keep as Custom Measure (`custom_fields`) using [looker-filters](../../looker-filters/SKILL.md) or ``matches_filter(${orders.country}, `US`)`` ([looker-expressions](../../looker-expressions/SKILL.md)) | Query / Dashboard Layer |
45
+ | Canonical single-table filtered KPI | Core business definition on 1 table (`completed_revenue`, `sunk_dusty_shelf_cost`), whether written as `CASE WHEN` or `WHERE` + `metrics.md` | Declarative LookML Measure with `filters: [status: "Complete"]` (never `CASE WHEN` or `ROUND` in `sql:`; skip unrequested unfiltered base measures per YAGNI) | `bronze/:table.refinement.lkml` |
46
+ | Compound cross-column `OR` filtered KPI | Measure filters with `OR` across two columns (`status = 'Complete' OR is_override = TRUE`) | For ad-hoc or single-report use, keep as a Custom Measure (``matches_filter(${orders.status}, `Complete`) OR ${orders.is_override}``) or Custom Dimension ([looker-expressions](../../looker-expressions/SKILL.md)). For a canonical single-table KPI, define a hidden `type: yesno` helper dimension (`is_completed_or_override`) and filter the measure on `filters: [is_completed_or_override: "yes"]` | Query / Dashboard Layer or `bronze/:table.refinement.lkml` |
47
+ | Cross-view dimension or measure (`gross_margin`, `total_gross_margin`) | Dimension or measure whose dependency DAG references multiple joined views (`${order_items.sale_price} - ${inventory_items.cost}` or filtered metric across views) | Move all fields in that cross-view DAG into an alphabetically named Silver `_fields` view (`silver/inventory_items_order_items_fields.view.lkml`) and bare-join (`sql: ;;`, `relationship: one_to_one`) into the Explore | `silver/:view_a_:view_b_fields.view.lkml` + `silver/explores/` |
48
+ | One-off bucketing | Ad-hoc campaign/report `CASE WHEN` | Keep as Custom Dimension (`custom_fields`, [looker-expressions](../../looker-expressions/SKILL.md)) | Query / Dashboard Layer |
49
+ | Lifecycle date `NULL` / `NOT NULL` state (`sold_at IS NULL`, `returned_at IS NOT NULL`) | SQL `WHERE` or `CASE WHEN` checks nullability on a date/timestamp column | Define a positive-form `type: yesno` dimension on `${<group>_raw} IS NOT NULL` (`is_sold`, `is_returned`) and filter measures/queries on `is_sold: "no"` or `"yes"` | `bronze/:table.refinement.lkml` |
50
+ | Reusable simple dimension or Business Term | Named domain terms (`metrics.md`), standard tiers, flags, date-parts, or buckets (`is_young_customer`, `age_tier`, `shopping_regime`, `full_name`) | Match shape to term semantics (never blanket-apply `is_`; zero speculative `<term>_count` measures; group `3+` sibling dimensions around one attribute under a shared noun `group_label:` like `"Age"` or `"Location"`, and gate country-specific `case:`/`SUBSTR` dimensions on `${country} = '...'` with no catch-all `else:` so out-of-scope rows return `NULL`): **if relative/rolling calendar period** (*"New Customer: current calendar year"*) $\rightarrow$ reuse `<group>_year` / `<group>_date` (`"this year"`, enriching `description:`/`synonyms:` rather than baking `CURRENT_TIMESTAMP()` into `is_<term>`); **if boolean-like** $\rightarrow$ `type: yesno` (`is_<term>`/`has_<term>` + noun `label: "<Term>"`); **if categorical** $\rightarrow$ noun `<term>` via native `case:` (strip SQL `'1. '`–`'9. '` sort prefixes from `label:`/`else:`, order `when:` branches `1..N` since `__sort_` sorts `else:` last unless `alpha_sort: yes`; use raw `sql: CASE ... END ;;` only for dynamic labels) or `type: tier`; **if scalar/duration** $\rightarrow$ noun `<term>` via `string`/`number`, or `type: duration` `dimension_group` (`sql_end: COALESCE(${sold_raw}, ${common_helpers.current_timestamp_raw}) ;;`) | `bronze/:table.refinement.lkml` (1 table, incl. `common_helpers`) or `silver/:view_a_:view_b_fields.view.lkml` (2+ views) |
51
+ | One-off display ratio | Visual math between two columns on one chart (`us_orders / uk_orders`) | Keep as Table Calculation (`if(${uk_orders} = 0, null, ${us_orders} / ${uk_orders})` — never `nullif()`, which does not exist in [looker-expressions](../../looker-expressions/SKILL.md)) | Query / Dashboard Layer |
52
+ | Single-table ratio | Canonical ratio within 1 table (`aov = total_rev / order_cnt`) | LookML `measure` (`type: number`, `sql: 1.0 * ${num} / NULLIF(${den}, 0) ;;`) | `bronze/:table.refinement.lkml` |
53
+ | Cross-table ratio | Canonical ratio across joined tables (`rev_per_user = order_items.total_revenue / users.count`) | LookML `measure` in an alphabetically named Silver `_fields` view (`silver/order_items_users_fields.view.lkml`), bare-joined (`sql: ;;`) into the Explore | `silver/:view_a_:view_b_fields.view.lkml` + `silver/explores/` |
54
+
55
+ ## When to create a `type: yesno` dimension (positive polarity & no opposites)
56
+
57
+ Use judgment rather than wrapping every filter condition in a boolean field, and enforce **strict positive polarity** whenever a `type: yesno` field is created:
58
+
59
+ 1. **Date and timestamp nullability (`NULL` / `-NULL`) — create ONE positive-polarity `type: yesno` dimension:** Whenever SQL tests `<timestamp_or_date> IS NULL` or `IS NOT NULL` (`sold_at IS NULL`, `returned_at IS NOT NULL`, `deleted_at IS NULL`), define a single **positive-form** `type: yesno` dimension (`is_sold`, `is_returned`, `is_deleted`) in `bronze/:table.refinement.lkml` against the `raw` timeframe (`sql: ${sold_raw} IS NOT NULL ;;`), then filter via `is_sold: "no"` (for unsold) or `is_sold: "yes"` (for sold) in measures and Explore queries. Using `${<group>_raw}` avoids Looker's `DATE()`/timezone cast on `${<group>_date}: "NULL"` and gives users and Conversational Analytics a clean `Yes / No` toggle.
60
+ 2. **Never create the opposite / negated twin:** Before adding any `type: yesno` dimension or filtered measure, inspect existing `views[].fields` in `lkr parse sql-lookml-compare`. Never author negated boolean names (`is_unsold`, `is_not_sold`, `is_unreturned`, `is_inactive`, `has_not_churned`), never add an inverse `yesno` field (`${sold_raw} IS NULL` or `NOT ${is_sold}`) alongside an existing positive `yesno` field (`is_sold`), and never speculatively create the opposite filtered measure (`sold_inventory_cost` when only `sunk_dusty_shelf_cost` was requested). Flip the filter value (`"yes"` vs. `"no"`) on the single positive field instead.
61
+ 3. **Compound `OR` helper for LookML measures — create a `hidden: yes` positive `type: yesno` dimension:** Because `measure: { filters: [...] }` combines multiple fields with `AND`, cross-column `OR` conditions (`${status} = 'Returned' OR ${is_returned}` — note `${yesno_dim}` expands directly as a parenthesized SQL boolean expression) require a hidden positive-form `type: yesno` helper dimension (`is_ever_returned`).
62
+ 4. **Unnamed ad-hoc string equality/non-null or numeric intervals — skip `type: yesno`:** Do not create boolean flags for unnamed one-off query slices (`status = 'Complete'`, `uri_dept IS NOT NULL`, `seg != ''`, `EXTRACT(HOUR FROM created_at) BETWEEN 0 AND 5`, `amount >= 100`); filter directly via `filters: [status: "Complete"]`, `filters: [uri_dept: "-NULL"]` (`"-EMPTY"` for `!= ''`; never `"NOT NULL"` on `type: string`), or `filters: [created_hour_utc: "[0, 5]"]`. **Exception — Named boolean-like Business Terms:** When `metrics.md` defines a named binary segment, cohort, or flag (e.g., *"Young Customer: under age 25"*), promote a positive `type: yesno` dimension (`dimension: is_young_customer`, explicit noun `label: "Young Customer"` since Looker appends `(Yes / No)`, `synonyms: [...]`) without adding an unrequested `<term>_count` measure. (Non-boolean Business Terms—categorical `CASE`/tiers, derived scalars, durations—use a plain noun `<term>` identifier without `is_`.)
63
+
64
+ ## LookML templates
65
+
66
+ ### Single-table dimensions and measures in Bronze refinement
67
+
68
+ ```lookml
69
+ # File: bronze/orders.refinement.lkml (Single-table dimensions & measures - entire DAG stays within orders + common_helpers)
70
+ include: "orders.view.lkml"
71
+
72
+ view: +orders {
73
+ dimension_group: fulfillment_age {
74
+ type: duration
75
+ intervals: [day, month, year]
76
+ sql_start: ${created_raw} ;;
77
+ sql_end: COALESCE(${delivered_raw}, ${common_helpers.current_timestamp_raw}) ;;
78
+ }
79
+
80
+ dimension: fulfillment_aging_tier {
81
+ case: {
82
+ when: {
83
+ sql: ${days_fulfillment_age} < 7 ;;
84
+ label: "Fast (<7 Days)"
85
+ }
86
+ when: {
87
+ sql: ${days_fulfillment_age} < 30 ;;
88
+ label: "Standard (7-29 Days)"
89
+ }
90
+ else: "Backlogged (30+ Days)"
91
+ }
92
+ }
93
+
94
+ dimension: age_tier {
95
+ type: tier
96
+ tiers: [25, 41]
97
+ style: integer
98
+ sql: ${age} ;;
99
+ }
100
+
101
+ dimension: is_returned {
102
+ type: yesno
103
+ sql: ${returned_raw} IS NOT NULL ;;
104
+ description: "Whether the order has been returned (returned_at is populated)."
105
+ }
106
+
107
+ dimension: is_ever_returned {
108
+ type: yesno
109
+ hidden: yes
110
+ sql: ${status} = 'Returned' OR ${is_returned} ;;
111
+ }
112
+
113
+ measure: count {
114
+ label: "Orders Count"
115
+ description: "Total number of orders."
116
+ }
117
+
118
+ measure: total_revenue {
119
+ type: sum
120
+ sql: ${sale_price} ;;
121
+ value_format_name: usd
122
+ }
123
+
124
+ measure: completed_revenue {
125
+ type: sum
126
+ sql: ${sale_price} ;;
127
+ filters: [status: "Complete"]
128
+ value_format_name: usd
129
+ }
130
+
131
+ measure: recognized_revenue {
132
+ type: sum
133
+ sql: ${sale_price} ;;
134
+ filters: [is_completed_or_override: "yes"]
135
+ value_format_name: usd
136
+ }
137
+
138
+ measure: average_order_value {
139
+ type: number
140
+ sql: 1.0 * ${total_revenue} / NULLIF(${count}, 0) ;;
141
+ value_format_name: usd
142
+ }
143
+ }
144
+ ```
145
+
146
+ ### Cross-view DAG dimensions and measures in Silver (`*_fields.view.lkml` + bare join)
147
+
148
+ Sort the direct LookML view names referenced via `${view.field}` alphabetically + `_fields` (e.g., `silver/inventory_items_order_items_fields.view.lkml`), include a hidden `dimension: pk { primary_key: yes hidden: yes ... }`, and bare-join (`sql: ;;`, `relationship: one_to_one`) onto the Silver Explore; see the full LookML template in [03-pk-fk-joins.md](03-pk-fk-joins.md#lookml-templates).
@@ -42,7 +42,7 @@ Anchor `silver/explores/:base.explore.lkml` on the finest-grain transaction tabl
42
42
 
43
43
  ### Ensure primary keys on the "one" side of joins (cardinality audit SQL)
44
44
 
45
- While primary keys are not required for raw Bronze views operating without joins, a verified primary key is essential on the "one" side of any join as soon as a Bronze view is extended or joined into Silver or Gold. If the joined view lacks a verified `primary_key: yes`, run the cardinality audit via `lkr code-mode` (including a partition filter if the table is partitioned):
45
+ While primary keys are not required for raw Bronze views operating without joins, a verified primary key is essential on the "one" side of any join as soon as a Bronze view is extended or joined into Silver or Gold. If the joined view lacks a verified `primary_key: yes`, run the cardinality audit via `lkr code-mode` (`sq = create_sql_query(body={"connection_name": conn, "sql": "..."}); run_sql_query(slug=sq["slug"], result_format="json")` — top-level Monty functions returning `dict`, never `sdk.*`; include a partition filter if partitioned):
46
46
 
47
47
  ```sql
48
48
  SELECT
@@ -70,23 +70,31 @@ Map the audit results to the LookML `relationship:` parameter:
70
70
  - When `duplicate_rows == 0` on both tables, set `relationship: one_to_one`.
71
71
  - When `duplicate_rows > 0` on the right table and `0` on the left, set `relationship: one_to_many`.
72
72
 
73
- ### Default `INNER JOIN` to `LEFT JOIN`
73
+ ### Default `INNER JOIN` to `LEFT JOIN` (`"NOT NULL"` and `"NULL"` on joined PKs)
74
74
 
75
- When converting SQL queries, assume `INNER JOIN` means `LEFT JOIN` (`type: left_outer`) in Silver Explores unless the business logic for a specific metric strictly requires inner join filtering. Looker handles `NULL` values from `left_outer` joins cleanly by coalescing `sum` measures with 0. Using `left_outer` prevents accidental drop-off of base fact records when users explore dimensions from joined tables. Only use `type: inner` if a business rule explicitly requires dropping unmatched base records across all queries on that Explore.
75
+ Default SQL `INNER JOIN` to `type: left_outer` in Silver Explores unless dropping unmatched rows is an invariant across all queries on that Explore; when a specific query needs `INNER JOIN` semantics on a `left_outer` Explore, filter the joined view's PK (`joined_view.id: "NOT NULL"` for `type: number` PKs—never `"-NULL"`, which is valid only on `type: string`), and for `LEFT JOIN` anti-join / 0-activity counts (`WHERE joined_view.id IS NULL` or `CASE WHEN joined_view.id IS NULL`), filter `joined_view.id: "NULL"` (valid on both `type: number` and `type: string`).
76
76
 
77
- ### Role-playing joins (same table joined multiple times)
77
+ ### Role-playing joins & `1:M` fanout `AVG(parent.col)` parity
78
78
 
79
- When `users` is joined twice (`buyer_id = u1.id` and `seller_id = u2.id`), use `from: users` and `view_label: "Buyer"` / `view_label: "Seller"` in `silver/explores/`.
79
+ - **Role-playing joins:** When `users` is joined twice (`buyer_id = u1.id` and `seller_id = u2.id`), use `from: users` and `view_label: "Buyer"` / `view_label: "Seller"`.
80
+ - **`1:M` fanout `AVG(parent.col)`, `M:1` `SUM(parent.col)`, & `1:M x 1:N` cross-product parity:** Whenever `1 parent` maps to `M child` rows (`users` <-> `order_items`) and SQL writes `AVG(parent.col)` without deduplicating `parent`, raw SQL weights `parent.col` by `child` row count while Looker's `parent.avg_<col>` promotes to entity-grain `average_distinct`: keep `parent.avg_<col>` pure on `parent` for Quick Start `query:` blocks, and for `pre_gate.py` parity define a hidden composite `primary_key: yes` (`CONCAT(CAST(${parent.id} AS STRING), '_', CAST(COALESCE(${child.id}, 0) AS STRING))`) plus `avg_activity_weighted_<col>` (`type: average`, `sql: ${parent.col} ;;`, `tags: ["non_additive"]`) in `silver/:sorted_views_fields.view.lkml`. Similarly, when a child fact query (`FROM order_items JOIN inventory_items`) sums a `M:1` parent column (`SUM(ii.cost)` COGS), define `total_cogs` on `silver/:sorted_views_fields.view.lkml` (`pk = ${child.id}`) rather than querying `parent.total_cost` (which triggers symmetric `SUM_DISTINCT` deduplication and `FLOAT64` `NUMERIC` truncation); and when SQL joins two `1:M` peers onto a parent (`users LEFT JOIN events LEFT JOIN order_items`, or `products` `1:M` `products__arr` `UNNEST` `x` `1:M` `order_items`), define composite `pk: CONCAT(CAST(COALESCE(${child.id}, 0) AS STRING), '_', CAST(${peer.pk} AS STRING))` on `silver/:sorted_views_fields.view.lkml` for cross-product `count` / `average` alongside `type: number` (`sql: COALESCE(SUM(${child.col}), 0) ;;`, `tags: ["non_additive"]`) for un-deduplicated `SUM(child.col)`.
80
81
 
81
82
  ### Cross-view dimensions and measures (`*_fields.view.lkml` + bare join)
82
83
 
83
- Any dimension or measure that anywhere in its dependency DAG references different views belongs in its own Silver view named by sorting the referenced view names alphabetically and appending `_fields` (`silver/inventory_items_order_items_fields.view.lkml` or `silver/order_items_users_fields.view.lkml`), and is bare-joined (`sql: ;;`, `relationship: one_to_one`) into the Explore that joins those views together.
84
+ Any dimension or measure whose expression directly references `2+` views belongs in a cohesive Silver view named by sorting those directly referenced LookML view names alphabetically + `_fields` (`silver/inventory_items_order_items_fields.view.lkml`, `silver/order_items_users_fields.view.lkml`; never merge unrelated view sets into one mega-file), bare-joined (`sql: ;;`, `relationship: one_to_one`) onto the Explore joining those views. When two Explores with different base grains (e.g., `explore: users` vs `explore: order_items`) bare-join the same `*_fields` view, keep one file and exclude grain-specific measures on the other Explore via `explore: { fields: [ALL_FIELDS*, -order_items_users_fields.revenue_per_registered_user] }`.
84
85
 
85
86
  ## LookML templates
86
87
 
87
88
  ```lookml
88
89
  # File: silver/order_items_users_fields.view.lkml
89
90
  view: order_items_users_fields {
91
+ dimension: pk {
92
+ primary_key: yes
93
+ hidden: yes
94
+ # Use type: number + sql: ${order_items.id} ;; when only bare-joined onto M:1 explore: order_items; use CONCAT when also joined onto 1:M explore: users
95
+ sql: CONCAT(CAST(${users.id} AS STRING), '_', CAST(COALESCE(${order_items.id}, 0) AS STRING)) ;;
96
+ }
97
+
90
98
  measure: revenue_per_user {
91
99
  type: number
92
100
  sql: 1.0 * ${order_items.gross_revenue} / NULLIF(${users.count}, 0) ;;
@@ -52,12 +52,12 @@ FROM matched WHERE rn = 1 GROUP BY 1;
52
52
 
53
53
  ### Isolate triangle and inequality joins in Gold
54
54
 
55
- Keep `silver/explores/orders.explore.lkml` strictly limited to `PK = FK` equality joins. Create `gold/explores/:name.explore.lkml` with `extends: [orders]`, `view_name: orders`, and `tags: ["gold"]`.
55
+ Keep `silver/explores/orders.explore.lkml` strictly limited to `PK = FK` equality joins. Create `gold/explores/:name.explore.lkml` with `extends: [orders]`, `view_name: orders`, and `tags: ["gold"]` (when the extended Silver Explore already joins `orders` via `PK = FK`, add the time-windowed inequality join under a role-playing alias `join: rolling_7d_orders { from: orders ... }` so inherited `orders` fields and tests remain untouched).
56
56
 
57
57
  ### Verify disjoint versus overlapping intervals
58
58
 
59
59
  1. For disjoint intervals (clean SCD Type 2 tables where one order matches at most one interval), join directly in `gold/explores/` with `relationship: many_to_one`.
60
- 2. For overlapping intervals (where one order can match multiple intervals or campaigns), set `relationship: one_to_many` (or `many_to_many`) in `gold/explores/` so Looker activates symmetric aggregates on `orders` measures.
60
+ 2. For overlapping intervals (where one order can match multiple intervals or campaigns) or intra-basket pair self-joins (`a.order_id = b.order_id AND a.category < b.category`), set `relationship: one_to_many` (or `many_to_many`) in `gold/explores/` (joining role-playing `paired_order_items`, `paired_products` on `${products.category} < ${paired_products.category}`, and any `paired_<ndt>` marginal facts) so Looker activates symmetric aggregates; when computing pair-grain measures across a `1:M` self-join (`AVG(TIMESTAMP_DIFF(o2.created_at, o1.created_at, HOUR) / 24.0)`), bare-join a Gold helper view declaring composite pair PK `CONCAT(CAST(${base.pk} AS STRING), '_', CAST(COALESCE(${alias.pk}, 0) AS STRING))`.
61
61
  3. For point-in-time `AS OF` attribution (keeping only the single most recent prior match), create a Gold Derived Table (`gold/order_latest_touch_map.view.lkml` using `${view.SQL_TABLE_NAME}` and `${view.field}` for every table and column reference) that resolves the `1:1` mapping (`order_id` to `latest_touch_id` via `QUALIFY ROW_NUMBER() = 1`), then join it into `gold/explores/`.
62
62
 
63
63
  ### Calendar spine table (`bronze/calendar_dates.view.lkml`)
@@ -62,7 +62,9 @@ FULL OUTER JOIN daily_targets AS t
62
62
 
63
63
  ### Implementation rules
64
64
 
65
- 1. When joining unrelated fact tables across shared dimensions, use the OJOF pattern in `gold/explores/:name.explore.lkml` (`tags: ["gold"]`) with `type: full_outer`, `relationship: many_to_many`, and `sql_on: FALSE ;;`. Pair it with a field-only view (`gold/:shared_dims.view.lkml`) that defines `COALESCE(${fact_a.dim}, ${fact_b.dim})` for each shared dimension and is joined via `sql: ;;` (never `sql_on: ;;` because the field-only view has no physical SQL table). This eliminates Cartesian multiplication between unrelated tables while providing a single unified dimension for dashboard filtering.
65
+ 1. When joining unrelated fact tables across shared dimensions, use the OJOF pattern in `gold/explores/:name.explore.lkml` (`tags: ["gold"]`):
66
+ - **2-table OJOF (self-contained template below — do not open `lookml-ojof`):** Extend an existing Silver Explore (`extends: [orders]`, `view_name: orders`), join the peer table with `type: full_outer`, `relationship: many_to_many`, and `sql_on: FALSE ;;`, and bare-join (`sql: ;;`, `relationship: one_to_one`) a field-only `COALESCE` view (`gold/:shared_dims.view.lkml`). When combining 2-table OJOF with a shared `{% condition %}` date filter or one-sided peer predicate (`event_type = 'purchase'`), guard `sql_always_where:` disjunctively (`({% if peer._in_query %}(${base.pk} IS NOT NULL AND {% condition f %} ${base.ts} {% endcondition %}) OR (${peer.pk} IS NOT NULL AND {% condition f %} ${peer.ts} {% endcondition %}){% else %}{% condition f %} ${base.ts} {% endcondition %}{% endif %})`) so `NULL` peer timestamps never zero out rows, and gate one-sided peer attributes inside `COALESCE(${base.dim}, CASE WHEN ${peer.type} = 'purchase' THEN ${peer.dim} END)` paired with `<shared_dim>: "NOT NULL"`.
67
+ - **3+-table 0-row OJOF Explore (self-contained — do not open `lookml-ojof`):** Use a 0-row `SELECT 0 AS pk FROM UNNEST(ARRAY<INT64>[])` base view (`primary_key: yes`, `hidden: yes`), join every peer fact/entity view via `type: full_outer`, `relationship: many_to_many` (**never** `one_to_one`, which disables symmetric aggregates and compiles `type: count` as `COUNT(*)` across all `FULL OUTER` branches instead of `COUNT(DISTINCT <table>.<pk>)`), and `sql_on: FALSE ;;`, bind any shared dimensions via `COALESCE({% if fact._in_query %} ${fact.fk}, {% endif %} NULL)` in a bare-joined (`sql: ;;`, `relationship: one_to_one`) Gold `COALESCE` view, and cross-join co-dimension views via `UNNEST([${codim_date.SQL_TABLE_NAME}])` only when needed.
66
68
  2. When `UNION ALL` is selected for overlapping schemas, always generate a compound primary key so the unioned table can safely participate in secondary foreign key joins without missing PK errors:
67
69
  ```lookml
68
70
  dimension: compound_pk {