lkr-dev-cli 0.0.58__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 (198) 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.60/.agents/skills/agentic-lookml/references/03-pk-fk-joins.md +144 -0
  5. lkr_dev_cli-0.0.60/.agents/skills/agentic-lookml/references/04-triangle-inequality-joins.md +97 -0
  6. lkr_dev_cli-0.0.60/.agents/skills/agentic-lookml/references/05-dimensional-non-key-joins.md +128 -0
  7. lkr_dev_cli-0.0.60/.agents/skills/agentic-lookml/references/06-nested-ctes-and-subqueries.md +138 -0
  8. lkr_dev_cli-0.0.60/.agents/skills/agentic-lookml/references/07-measure-of-measures-reaggregation.md +111 -0
  9. lkr_dev_cli-0.0.60/.agents/skills/agentic-lookml/references/08-window-functions-and-qualify.md +159 -0
  10. lkr_dev_cli-0.0.60/.agents/skills/agentic-lookml/references/09-unions.md +137 -0
  11. lkr_dev_cli-0.0.60/.agents/skills/agentic-lookml/references/10-unnest-arrays.md +155 -0
  12. lkr_dev_cli-0.0.60/.agents/skills/agentic-lookml/references/11-json-columns-and-extract.md +165 -0
  13. lkr_dev_cli-0.0.60/.agents/skills/agentic-lookml/references/12-table-wildcards.md +134 -0
  14. lkr_dev_cli-0.0.60/.agents/skills/agentic-lookml/references/13-large-tables-and-partitioning.md +158 -0
  15. lkr_dev_cli-0.0.60/.agents/skills/agentic-lookml/references/14-templated-filters-and-parameters.md +123 -0
  16. lkr_dev_cli-0.0.60/.agents/skills/agentic-lookml/references/measures-and-sql-functions.md +83 -0
  17. lkr_dev_cli-0.0.60/.agents/skills/agentic-lookml-workflow/SKILL.md +159 -0
  18. lkr_dev_cli-0.0.60/.agents/skills/agentic-lookml-workflow/examples/lookml-pr-checks.yml +305 -0
  19. lkr_dev_cli-0.0.60/.agents/skills/agentic-lookml-workflow/examples/template.model.lkml.tmpl +14 -0
  20. lkr_dev_cli-0.0.60/.agents/skills/agentic-lookml-workflow/scripts/configure_model.py +48 -0
  21. lkr_dev_cli-0.0.60/.agents/skills/agentic-lookml-workflow/scripts/create_github_pr.sh +95 -0
  22. lkr_dev_cli-0.0.60/.agents/skills/agentic-lookml-workflow/scripts/diff_lookml_tests.py +60 -0
  23. lkr_dev_cli-0.0.60/.agents/skills/agentic-lookml-workflow/scripts/init_branch.py +113 -0
  24. lkr_dev_cli-0.0.60/.agents/skills/agentic-lookml-workflow/scripts/pre_gate.py +827 -0
  25. lkr_dev_cli-0.0.60/.agents/skills/agentic-lookml-workflow/scripts/preflight.sh +32 -0
  26. lkr_dev_cli-0.0.60/.agents/skills/agentic-lookml-workflow/scripts/setup_repo.sh +139 -0
  27. lkr_dev_cli-0.0.60/.agents/skills/agentic-lookml-workflow/scripts/validate_and_commit_branch.py +130 -0
  28. lkr_dev_cli-0.0.60/.agents/skills/agentic-lookml-workflow/sub-skills/setup/SKILL.md +84 -0
  29. lkr_dev_cli-0.0.60/.agents/skills/generate-view-from-table/SKILL.md +98 -0
  30. lkr_dev_cli-0.0.60/.agents/skills/generate-view-from-table/scripts/generate_bronze_view.py +33 -0
  31. {lkr_dev_cli-0.0.58 → lkr_dev_cli-0.0.60}/.agents/skills/lkr-code-mode/SKILL.md +31 -19
  32. {lkr_dev_cli-0.0.58 → lkr_dev_cli-0.0.60}/.agents/skills/lkr-project-helper/SKILL.md +20 -25
  33. {lkr_dev_cli-0.0.58 → lkr_dev_cli-0.0.60}/.agents/skills/lkr-project-helper/scripts/create_custom_view.py +17 -5
  34. lkr_dev_cli-0.0.60/.agents/skills/lkr-project-helper/scripts/deploy_and_reset_workspace.py +84 -0
  35. lkr_dev_cli-0.0.60/.agents/skills/lkr-project-helper/scripts/ensure_project_and_model.py +91 -0
  36. {lkr_dev_cli-0.0.58 → lkr_dev_cli-0.0.60}/.agents/skills/lkr-project-helper/scripts/generate_lookml.py +21 -13
  37. {lkr_dev_cli-0.0.58 → lkr_dev_cli-0.0.60}/.agents/skills/lkr-project-helper/scripts/inspect_connections.py +9 -3
  38. {lkr_dev_cli-0.0.58 → lkr_dev_cli-0.0.60}/.agents/skills/lkr-project-helper/scripts/migrate_dashboard_to_udd.py +9 -3
  39. {lkr_dev_cli-0.0.58 → lkr_dev_cli-0.0.60}/.agents/skills/lkr-project-helper/scripts/upload_assets.py +13 -4
  40. lkr_dev_cli-0.0.60/.agents/skills/looker-expressions/SKILL.md +311 -0
  41. lkr_dev_cli-0.0.60/.agents/skills/looker-expressions/references/excel-equivalents.md +120 -0
  42. lkr_dev_cli-0.0.60/.agents/skills/looker-filters/SKILL.md +225 -0
  43. lkr_dev_cli-0.0.60/.agents/skills/lookml/SKILL.md +28 -0
  44. lkr_dev_cli-0.0.60/.agents/skills/lookml/sub-skills/lookml-access-grants/SKILL.md +48 -0
  45. lkr_dev_cli-0.0.60/.agents/skills/lookml/sub-skills/lookml-advanced-liquid/SKILL.md +238 -0
  46. lkr_dev_cli-0.0.60/.agents/skills/lookml/sub-skills/lookml-explore/SKILL.md +161 -0
  47. lkr_dev_cli-0.0.60/.agents/skills/lookml/sub-skills/lookml-explore/references/advanced.md +55 -0
  48. lkr_dev_cli-0.0.60/.agents/skills/lookml/sub-skills/lookml-explore/references/joins.md +65 -0
  49. lkr_dev_cli-0.0.60/.agents/skills/lookml/sub-skills/lookml-fields/SKILL.md +124 -0
  50. lkr_dev_cli-0.0.60/.agents/skills/lookml/sub-skills/lookml-fields/references/dimension.md +88 -0
  51. lkr_dev_cli-0.0.60/.agents/skills/lookml/sub-skills/lookml-fields/references/dimension_group.md +111 -0
  52. lkr_dev_cli-0.0.60/.agents/skills/lookml/sub-skills/lookml-fields/references/filter_parameter.md +73 -0
  53. lkr_dev_cli-0.0.60/.agents/skills/lookml/sub-skills/lookml-fields/references/measure.md +72 -0
  54. lkr_dev_cli-0.0.60/.agents/skills/lookml/sub-skills/lookml-fields/references/value_format.md +201 -0
  55. lkr_dev_cli-0.0.60/.agents/skills/lookml/sub-skills/lookml-liquid/SKILL.md +182 -0
  56. lkr_dev_cli-0.0.60/.agents/skills/lookml/sub-skills/lookml-model/SKILL.md +117 -0
  57. lkr_dev_cli-0.0.60/.agents/skills/lookml/sub-skills/lookml-ojof/SKILL.md +398 -0
  58. lkr_dev_cli-0.0.60/.agents/skills/lookml/sub-skills/lookml-refinements/SKILL.md +117 -0
  59. lkr_dev_cli-0.0.60/.agents/skills/lookml/sub-skills/lookml-sets/SKILL.md +117 -0
  60. lkr_dev_cli-0.0.60/.agents/skills/lookml/sub-skills/lookml-tests/SKILL.md +107 -0
  61. lkr_dev_cli-0.0.60/.agents/skills/lookml/sub-skills/lookml-view/SKILL.md +97 -0
  62. lkr_dev_cli-0.0.60/.agents/skills/lookml/sub-skills/lookml-view/references/derived_table.md +179 -0
  63. lkr_dev_cli-0.0.60/.agents/skills/lookml-field-curation/SKILL.md +332 -0
  64. lkr_dev_cli-0.0.60/.agents/skills/lookml-field-curation/scripts/enrich_partition_clustering.py +311 -0
  65. lkr_dev_cli-0.0.60/.agents/skills/lookml-field-curation/scripts/profile_string_fields.py +412 -0
  66. lkr_dev_cli-0.0.60/.github/workflows/ci-windows.yaml +55 -0
  67. {lkr_dev_cli-0.0.58 → lkr_dev_cli-0.0.60}/.github/workflows/ci.yml +3 -3
  68. {lkr_dev_cli-0.0.58 → lkr_dev_cli-0.0.60}/.github/workflows/release.yml +5 -5
  69. lkr_dev_cli-0.0.60/.github/workflows/update-looker-skills.yml +131 -0
  70. {lkr_dev_cli-0.0.58 → lkr_dev_cli-0.0.60}/.pre-commit-config.yaml +2 -2
  71. {lkr_dev_cli-0.0.58 → lkr_dev_cli-0.0.60}/Makefile +3 -3
  72. {lkr_dev_cli-0.0.58 → lkr_dev_cli-0.0.60}/PKG-INFO +130 -14
  73. {lkr_dev_cli-0.0.58 → lkr_dev_cli-0.0.60}/README.md +129 -8
  74. {lkr_dev_cli-0.0.58 → lkr_dev_cli-0.0.60}/codemode.md +3 -3
  75. {lkr_dev_cli-0.0.58 → lkr_dev_cli-0.0.60}/lkr/auth/main.py +56 -18
  76. lkr_dev_cli-0.0.60/lkr/auth/oauth.py +378 -0
  77. {lkr_dev_cli-0.0.58 → lkr_dev_cli-0.0.60}/lkr/auth_service.py +136 -46
  78. {lkr_dev_cli-0.0.58 → lkr_dev_cli-0.0.60}/lkr/classes.py +5 -2
  79. {lkr_dev_cli-0.0.58 → lkr_dev_cli-0.0.60}/lkr/codemode/download_swagger.py +1 -1
  80. {lkr_dev_cli-0.0.58 → lkr_dev_cli-0.0.60}/lkr/codemode/help.py +6 -4
  81. {lkr_dev_cli-0.0.58 → lkr_dev_cli-0.0.60}/lkr/codemode/main.py +121 -6
  82. {lkr_dev_cli-0.0.58 → lkr_dev_cli-0.0.60}/lkr/codemode/readme.py +7 -0
  83. {lkr_dev_cli-0.0.58 → lkr_dev_cli-0.0.60}/lkr/codemode/type.py +79 -55
  84. lkr_dev_cli-0.0.60/lkr/constants.py +7 -0
  85. {lkr_dev_cli-0.0.58 → lkr_dev_cli-0.0.60}/lkr/extended_sdk_methods/__init__.py +5 -1
  86. {lkr_dev_cli-0.0.58 → lkr_dev_cli-0.0.60}/lkr/extended_sdk_methods/main.py +93 -10
  87. {lkr_dev_cli-0.0.58 → lkr_dev_cli-0.0.60}/lkr/main.py +16 -12
  88. {lkr_dev_cli-0.0.58 → lkr_dev_cli-0.0.60}/lkr/mcp/classes.py +5 -3
  89. {lkr_dev_cli-0.0.58 → lkr_dev_cli-0.0.60}/lkr/mcp/main.py +18 -21
  90. {lkr_dev_cli-0.0.58 → lkr_dev_cli-0.0.60}/lkr/mcp/utils.py +11 -2
  91. {lkr_dev_cli-0.0.58 → lkr_dev_cli-0.0.60}/lkr/observability/main.py +1 -1
  92. {lkr_dev_cli-0.0.58 → lkr_dev_cli-0.0.60}/lkr/observability/utils.py +8 -23
  93. {lkr_dev_cli-0.0.58 → lkr_dev_cli-0.0.60}/lkr/parse/__init__.py +110 -0
  94. {lkr_dev_cli-0.0.58 → lkr_dev_cli-0.0.60}/lkr/parse/constants.py +5 -0
  95. lkr_dev_cli-0.0.60/lkr/parse/curation.py +405 -0
  96. lkr_dev_cli-0.0.60/lkr/parse/dag.py +528 -0
  97. {lkr_dev_cli-0.0.58 → lkr_dev_cli-0.0.60}/lkr/parse/lookml.py +471 -52
  98. lkr_dev_cli-0.0.60/lkr/parse/lookml_database_compare.py +996 -0
  99. lkr_dev_cli-0.0.60/lkr/parse/lookml_snapshot.py +1136 -0
  100. {lkr_dev_cli-0.0.58 → lkr_dev_cli-0.0.60}/lkr/parse/lookml_to_api.py +578 -111
  101. lkr_dev_cli-0.0.60/lkr/parse/main.py +919 -0
  102. {lkr_dev_cli-0.0.58 → lkr_dev_cli-0.0.60}/lkr/parse/sql.py +186 -1
  103. lkr_dev_cli-0.0.60/lkr/parse/sql_to_lookml.py +3674 -0
  104. lkr_dev_cli-0.0.60/lkr/parse/translations.py +1057 -0
  105. {lkr_dev_cli-0.0.58 → lkr_dev_cli-0.0.60}/lkr/parse/types.py +218 -1
  106. lkr_dev_cli-0.0.60/lkr/parse/user_attributes.py +1049 -0
  107. {lkr_dev_cli-0.0.58 → lkr_dev_cli-0.0.60}/lkr/schema/_schema_rs.so +0 -0
  108. {lkr_dev_cli-0.0.58 → lkr_dev_cli-0.0.60}/lkr/schema/builder.py +3 -0
  109. {lkr_dev_cli-0.0.58 → lkr_dev_cli-0.0.60}/lkr/schema/filter_expressions.py +16 -6
  110. {lkr_dev_cli-0.0.58 → lkr_dev_cli-0.0.60}/lkr/schema/rust/Cargo.lock +10 -58
  111. {lkr_dev_cli-0.0.58 → lkr_dev_cli-0.0.60}/lkr/schema/rust/Cargo.toml +1 -1
  112. {lkr_dev_cli-0.0.58 → lkr_dev_cli-0.0.60}/lkr/schema/rust/src/lib.rs +1 -1
  113. lkr_dev_cli-0.0.60/lkr/schema/validator.py +491 -0
  114. {lkr_dev_cli-0.0.58 → lkr_dev_cli-0.0.60}/lkr/tools/agents.py +158 -3
  115. lkr_dev_cli-0.0.60/lkr/tools/lookml.py +1347 -0
  116. {lkr_dev_cli-0.0.58 → lkr_dev_cli-0.0.60}/lkr/tools/main.py +1 -1
  117. lkr_dev_cli-0.0.60/lkr/utils.py +26 -0
  118. {lkr_dev_cli-0.0.58 → lkr_dev_cli-0.0.60}/lkr.md +337 -36
  119. lkr_dev_cli-0.0.60/parse.md +65 -0
  120. {lkr_dev_cli-0.0.58 → lkr_dev_cli-0.0.60}/pyproject.toml +1 -7
  121. lkr_dev_cli-0.0.60/skills-lock.json +65 -0
  122. {lkr_dev_cli-0.0.58 → lkr_dev_cli-0.0.60}/tests/test_agents_tools.py +154 -0
  123. lkr_dev_cli-0.0.60/tests/test_bq_change_listener.py +281 -0
  124. {lkr_dev_cli-0.0.58 → lkr_dev_cli-0.0.60}/tests/test_codemode.py +43 -1
  125. lkr_dev_cli-0.0.60/tests/test_lookml_database_compare.py +350 -0
  126. lkr_dev_cli-0.0.60/tests/test_lookml_field_curation.py +580 -0
  127. lkr_dev_cli-0.0.60/tests/test_lookml_snapshot.py +811 -0
  128. {lkr_dev_cli-0.0.58 → lkr_dev_cli-0.0.60}/tests/test_lookml_tools.py +257 -37
  129. lkr_dev_cli-0.0.60/tests/test_oauth_account.py +504 -0
  130. lkr_dev_cli-0.0.60/tests/test_parse_lookml.py +4353 -0
  131. {lkr_dev_cli-0.0.58 → lkr_dev_cli-0.0.60}/tests/test_schema.py +170 -4
  132. lkr_dev_cli-0.0.60/tests/test_sql_join_analysis.py +452 -0
  133. lkr_dev_cli-0.0.60/tests/test_sql_queries_workflow.py +1690 -0
  134. lkr_dev_cli-0.0.60/tests/test_translations.py +415 -0
  135. lkr_dev_cli-0.0.60/tests/test_user_attributes.py +471 -0
  136. {lkr_dev_cli-0.0.58 → lkr_dev_cli-0.0.60}/uv.lock +11 -32
  137. lkr_dev_cli-0.0.58/.agents/skills/lkr-code-mode/scripts/examples.py +0 -46
  138. lkr_dev_cli-0.0.58/.agents/skills/lkr-code-mode/scripts/readme.py +0 -65
  139. lkr_dev_cli-0.0.58/.agents/skills/lkr-project-helper/scripts/configure_model.py +0 -52
  140. lkr_dev_cli-0.0.58/.agents/skills/lkr-project-helper/scripts/deploy_and_reset_workspace.py +0 -86
  141. lkr_dev_cli-0.0.58/.agents/skills/lkr-project-helper/scripts/ensure_project_and_model.py +0 -95
  142. lkr_dev_cli-0.0.58/.agents/skills/lkr-project-helper/scripts/init_project.py +0 -37
  143. lkr_dev_cli-0.0.58/.agents/skills/lkr-project-helper/scripts/validate_and_commit.py +0 -62
  144. lkr_dev_cli-0.0.58/lkr/auth/oauth.py +0 -254
  145. lkr_dev_cli-0.0.58/lkr/constants.py +0 -2
  146. lkr_dev_cli-0.0.58/lkr/parse/dag.py +0 -271
  147. lkr_dev_cli-0.0.58/lkr/parse/main.py +0 -270
  148. lkr_dev_cli-0.0.58/lkr/parse/sql_to_lookml.py +0 -1352
  149. lkr_dev_cli-0.0.58/lkr/schema/validator.py +0 -64
  150. lkr_dev_cli-0.0.58/lkr/tools/lookml.py +0 -581
  151. lkr_dev_cli-0.0.58/tests/test_oauth_account.py +0 -124
  152. lkr_dev_cli-0.0.58/tests/test_parse_lookml.py +0 -2173
  153. {lkr_dev_cli-0.0.58 → lkr_dev_cli-0.0.60}/.agents/skills/lkr-code-mode/scripts/order_items_query.py +0 -0
  154. {lkr_dev_cli-0.0.58 → lkr_dev_cli-0.0.60}/.agents/skills/lkr-code-mode/scripts/personal_folder_dashboards.py +0 -0
  155. {lkr_dev_cli-0.0.58 → lkr_dev_cli-0.0.60}/.agents/skills/lkr-code-mode/scripts/traverse_folders.py +0 -0
  156. {lkr_dev_cli-0.0.58 → lkr_dev_cli-0.0.60}/.agents/skills/lkr-project-helper/scripts/bq_view_lookml_generator.py +0 -0
  157. {lkr_dev_cli-0.0.58 → lkr_dev_cli-0.0.60}/.github/workflows/website.yml +0 -0
  158. {lkr_dev_cli-0.0.58 → lkr_dev_cli-0.0.60}/.gitignore +0 -0
  159. {lkr_dev_cli-0.0.58 → lkr_dev_cli-0.0.60}/.python-version +0 -0
  160. {lkr_dev_cli-0.0.58 → lkr_dev_cli-0.0.60}/.vscode/launch.json +0 -0
  161. {lkr_dev_cli-0.0.58 → lkr_dev_cli-0.0.60}/.vscode/settings.json +0 -0
  162. {lkr_dev_cli-0.0.58 → lkr_dev_cli-0.0.60}/AGENTS.md +0 -0
  163. {lkr_dev_cli-0.0.58 → lkr_dev_cli-0.0.60}/Dockerfile +0 -0
  164. {lkr_dev_cli-0.0.58 → lkr_dev_cli-0.0.60}/LICENSE +0 -0
  165. {lkr_dev_cli-0.0.58 → lkr_dev_cli-0.0.60}/TODO.md +0 -0
  166. {lkr_dev_cli-0.0.58 → lkr_dev_cli-0.0.60}/cloudbuild.yaml +0 -0
  167. {lkr_dev_cli-0.0.58 → lkr_dev_cli-0.0.60}/lkr/__init__.py +0 -0
  168. {lkr_dev_cli-0.0.58 → lkr_dev_cli-0.0.60}/lkr/auth/__init__.py +0 -0
  169. {lkr_dev_cli-0.0.58 → lkr_dev_cli-0.0.60}/lkr/codemode/LOCAL.md +0 -0
  170. {lkr_dev_cli-0.0.58 → lkr_dev_cli-0.0.60}/lkr/codemode/__init__.py +0 -0
  171. {lkr_dev_cli-0.0.58 → lkr_dev_cli-0.0.60}/lkr/codemode/constant.py +0 -0
  172. {lkr_dev_cli-0.0.58 → lkr_dev_cli-0.0.60}/lkr/codemode/examples.py +0 -0
  173. {lkr_dev_cli-0.0.58 → lkr_dev_cli-0.0.60}/lkr/codemode/swagger.json +0 -0
  174. {lkr_dev_cli-0.0.58 → lkr_dev_cli-0.0.60}/lkr/custom_types.py +0 -0
  175. {lkr_dev_cli-0.0.58 → lkr_dev_cli-0.0.60}/lkr/exceptions.py +0 -0
  176. {lkr_dev_cli-0.0.58 → lkr_dev_cli-0.0.60}/lkr/extended_sdk_methods/classes.py +0 -0
  177. {lkr_dev_cli-0.0.58 → lkr_dev_cli-0.0.60}/lkr/logger.py +0 -0
  178. {lkr_dev_cli-0.0.58 → lkr_dev_cli-0.0.60}/lkr/observability/classes.py +0 -0
  179. {lkr_dev_cli-0.0.58 → lkr_dev_cli-0.0.60}/lkr/observability/embed_container.html +0 -0
  180. {lkr_dev_cli-0.0.58 → lkr_dev_cli-0.0.60}/lkr/schema/__init__.py +0 -0
  181. {lkr_dev_cli-0.0.58 → lkr_dev_cli-0.0.60}/lkr/schema/dynamic_fields.py +0 -0
  182. {lkr_dev_cli-0.0.58 → lkr_dev_cli-0.0.60}/lkr/schema/fields.py +0 -0
  183. {lkr_dev_cli-0.0.58 → lkr_dev_cli-0.0.60}/lkr/schema/main.py +0 -0
  184. {lkr_dev_cli-0.0.58 → lkr_dev_cli-0.0.60}/lkr/tools/classes.py +0 -0
  185. {lkr_dev_cli-0.0.58 → lkr_dev_cli-0.0.60}/lkr/tools/permission_deprecation.py +0 -0
  186. {lkr_dev_cli-0.0.58 → lkr_dev_cli-0.0.60}/lkr_dev_cli/__init__.py +0 -0
  187. {lkr_dev_cli-0.0.58 → lkr_dev_cli-0.0.60}/scripts/schema/README.md +0 -0
  188. {lkr_dev_cli-0.0.58 → lkr_dev_cli-0.0.60}/scripts/schema/__init__.py +0 -0
  189. {lkr_dev_cli-0.0.58 → lkr_dev_cli-0.0.60}/scripts/schema/history.py +0 -0
  190. {lkr_dev_cli-0.0.58 → lkr_dev_cli-0.0.60}/tests/TESTING.md +0 -0
  191. {lkr_dev_cli-0.0.58 → lkr_dev_cli-0.0.60}/tests/test_dependency_resolution.py +0 -0
  192. {lkr_dev_cli-0.0.58 → lkr_dev_cli-0.0.60}/tests/test_deps.sh +0 -0
  193. {lkr_dev_cli-0.0.58 → lkr_dev_cli-0.0.60}/tests/test_extended_sdk_methods.py +0 -0
  194. {lkr_dev_cli-0.0.58 → lkr_dev_cli-0.0.60}/tests/test_init_sdk.py +0 -0
  195. {lkr_dev_cli-0.0.58 → lkr_dev_cli-0.0.60}/tests/test_parse_sql.py +0 -0
  196. {lkr_dev_cli-0.0.58 → lkr_dev_cli-0.0.60}/tests/test_permission_deprecation.py +0 -0
  197. {lkr_dev_cli-0.0.58 → lkr_dev_cli-0.0.60}/tests/test_version.py +0 -0
  198. {lkr_dev_cli-0.0.58 → 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).
@@ -0,0 +1,144 @@
1
+ # Standard foreign key joins (`pk_fk_joins`)
2
+
3
+ This category (`pk_fk_joins`) maps primary-key-to-foreign-key equality joins to `silver/explores/:base_view.explore.lkml` and places `primary_key: yes` definitions in `bronze/:table.refinement.lkml`. Changes qualify for Tier 1 auto-application once primary key uniqueness is verified via the Join Cardinality Audit SQL.
4
+
5
+ ## Tool categorization rule (`lkr parse`)
6
+
7
+ The parser tags a query with `pk_fk_joins` when `parsed_query.joins` and `queries[].join_analysis` meet all of these conditions:
8
+
9
+ - `len(parsed_query.joins) >= 1`.
10
+ - Each `join` has `join.equi_joins != []` and `join.non_equi_conditions == []` (equality comparisons `t1.fk = t2.pk`, optionally combined with static literal filter predicates).
11
+ - `join.join_type in ("INNER", "LEFT")` (or `join.side in ("", "LEFT")`).
12
+ - At least one side of `join.equi_joins` is a candidate primary or entity key (`id`, `*_id`, `uuid`, `code`, or a declared `primary_key: yes`), with `join_analysis[].inferred_relationship in ("many_to_one", "one_to_one", "one_to_many")`.
13
+
14
+ ## Representative SQL
15
+
16
+ ```sql
17
+ SELECT
18
+ u.country,
19
+ p.category,
20
+ COUNT(DISTINCT o.order_id) AS order_count,
21
+ SUM(oi.sale_price) AS gross_revenue
22
+ FROM `project.dataset.order_items` AS oi
23
+ LEFT JOIN `project.dataset.orders` AS o
24
+ ON oi.order_id = o.order_id
25
+ LEFT JOIN `project.dataset.users` AS u
26
+ ON o.user_id = u.id
27
+ LEFT JOIN `project.dataset.products` AS p
28
+ ON oi.product_id = p.id
29
+ GROUP BY 1, 2
30
+ ```
31
+
32
+ ## Impact of naive translation
33
+
34
+ 1. Omitting `primary_key: yes` on a joined view or defaulting `relationship:` to `many_to_one` when the join is actually `one_to_many` causes `SUM()` measures on the base table to multiply silently.
35
+ 2. Using raw table names like `orders.user_id = users.id` in `sql_on:` instead of `${orders.user_id} = ${users.id}` breaks view aliasing (`from:`).
36
+
37
+ ## How to handle
38
+
39
+ ### Choose the finest-grain fact table as the Silver Explore base
40
+
41
+ Anchor `silver/explores/:base.explore.lkml` on the finest-grain transaction table (`order_items`), joining parent entities (`orders`, `users`, `products`, `calendar_dates`) outward via `many_to_one`. Inspect `queries[].join_analysis` for `status`, `relationship`, `inferred_relationship`, `safety_warnings`, and `precedents`. Always explicitly declare `view_name:` on the Explore.
42
+
43
+ ### Ensure primary keys on the "one" side of joins (cardinality audit SQL)
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` (`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
+
47
+ ```sql
48
+ SELECT
49
+ COUNT(*) AS total_rows,
50
+ COUNT(DISTINCT join_key) AS distinct_keys,
51
+ COUNT(*) - COUNT(DISTINCT join_key) AS duplicate_rows
52
+ FROM `project.dataset.target_table`
53
+ WHERE _PARTITIONDATE >= DATE_SUB(CURRENT_DATE(), INTERVAL 30 DAY) -- if partitioned
54
+ ```
55
+
56
+ When `duplicate_rows == 0` on a single column, add `primary_key: yes` to the key dimension in `bronze/:target.refinement.lkml` (never hand-edit `bronze/:target.view.lkml`). When uniqueness requires multiple columns `(order_id, item_seq)`, add a hidden composite primary key to `bronze/:target.refinement.lkml`:
57
+
58
+ ```lookml
59
+ dimension: pk {
60
+ primary_key: yes
61
+ hidden: yes
62
+ type: string
63
+ sql: CONCAT(CAST(${order_id} AS STRING), '_', CAST(${item_seq} AS STRING)) ;;
64
+ }
65
+ ```
66
+
67
+ Map the audit results to the LookML `relationship:` parameter:
68
+
69
+ - When `duplicate_rows == 0` on the right table only, set `relationship: many_to_one`.
70
+ - When `duplicate_rows == 0` on both tables, set `relationship: one_to_one`.
71
+ - When `duplicate_rows > 0` on the right table and `0` on the left, set `relationship: one_to_many`.
72
+
73
+ ### Default `INNER JOIN` to `LEFT JOIN` (`"NOT NULL"` and `"NULL"` on joined PKs)
74
+
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
+
77
+ ### Role-playing joins & `1:M` fanout `AVG(parent.col)` parity
78
+
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)`.
81
+
82
+ ### Cross-view dimensions and measures (`*_fields.view.lkml` + bare join)
83
+
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] }`.
85
+
86
+ ## LookML templates
87
+
88
+ ```lookml
89
+ # File: silver/order_items_users_fields.view.lkml
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
+
98
+ measure: revenue_per_user {
99
+ type: number
100
+ sql: 1.0 * ${order_items.gross_revenue} / NULLIF(${users.count}, 0) ;;
101
+ value_format_name: usd
102
+ description: "Cross-join metric: Gross revenue divided by distinct users."
103
+ }
104
+ }
105
+ ```
106
+
107
+ ```lookml
108
+ # File: silver/explores/order_items.explore.lkml
109
+ include: "/bronze/order_items.view.lkml"
110
+ include: "/bronze/order_items.refinement.lkml"
111
+ include: "/bronze/orders.view.lkml"
112
+ include: "/bronze/orders.refinement.lkml"
113
+ include: "/bronze/users.view.lkml"
114
+ include: "/bronze/users.refinement.lkml"
115
+ include: "/silver/order_items_users_fields.view.lkml"
116
+
117
+ explore: order_items {
118
+ view_name: order_items
119
+
120
+ join: orders {
121
+ type: left_outer
122
+ relationship: many_to_one
123
+ sql_on: ${order_items.order_id} = ${orders.order_id} ;;
124
+ }
125
+
126
+ join: users {
127
+ type: left_outer
128
+ relationship: many_to_one
129
+ sql_on: ${orders.user_id} = ${users.id} ;;
130
+ }
131
+
132
+ join: order_items_users_fields {
133
+ view_label: "Order Items"
134
+ sql: ;;
135
+ relationship: one_to_one
136
+ }
137
+ }
138
+ ```
139
+
140
+ ## Architecture rules
141
+
142
+ Because `bronze/:table.view.lkml` is never hand-edited, single-column `primary_key: yes` overrides and composite `CONCAT(...)` primary keys (`type: string`, `hidden: yes`) are always declared in `bronze/:table.refinement.lkml`.
143
+
144
+ Silver Explores default SQL `INNER JOIN` clauses to `type: left_outer` so ad-hoc exploration on joined dimensions does not silently drop unmatched fact rows. Set `type: inner` only when dropping unmatched rows is an invariant across all queries on that Explore. Any dimension or measure whose dependency DAG spans multiple joined views lives in `silver/:view_a_:view_b_fields.view.lkml` and is bare-joined (`sql: ;;`) into the Explore.