Skip to content

Commit 95af4f3

Browse files
committed
Add lkml constants support
1 parent a7c495c commit 95af4f3

File tree

7 files changed

+267
-7
lines changed

7 files changed

+267
-7
lines changed

docs/lkml2cube_parser_loader.md

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
# Table of Contents
22

33
* [lkml2cube.parser.loader](#lkml2cube.parser.loader)
4+
* [substitute\_constants](#lkml2cube.parser.loader.substitute_constants)
45
* [update\_namespace](#lkml2cube.parser.loader.update_namespace)
56
* [file\_loader](#lkml2cube.parser.loader.file_loader)
67
* [write\_single\_file](#lkml2cube.parser.loader.write_single_file)
@@ -12,6 +13,34 @@
1213

1314
# lkml2cube.parser.loader
1415

16+
<a id="lkml2cube.parser.loader.substitute_constants"></a>
17+
18+
#### substitute\_constants
19+
20+
```python
21+
def substitute_constants(obj, constants)
22+
```
23+
24+
Recursively substitute constants in strings using @{constant_name} syntax.
25+
26+
**Arguments**:
27+
28+
- `obj` _any_ - The object to process (can be dict, list, str, or any other type).
29+
- `constants` _dict_ - Dictionary mapping constant names to their values.
30+
31+
32+
**Returns**:
33+
34+
- `any` - The processed object with constants substituted.
35+
36+
37+
**Example**:
38+
39+
>>> constants = {'city': 'Tokyo'}
40+
>>> obj = {'label': '@{city} Users'}
41+
>>> substitute_constants(obj, constants)
42+
- `{'label'` - 'Tokyo Users'}
43+
1544
<a id="lkml2cube.parser.loader.update_namespace"></a>
1645

1746
#### update\_namespace

lkml2cube/parser/explores.py

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -186,8 +186,7 @@ def generate_cube_joins(cube_def, lookml_model):
186186
cube = get_cube_from_cube_def(cube_def, cube_right)
187187
if not cube:
188188
console.print(
189-
f'Cube referenced in explores not found: {join_element["name"]}',
190-
style="bold red",
189+
f'Cube referenced in explores not found: {join_element["name"]}'
191190
)
192191
continue
193192

lkml2cube/parser/loader.py

Lines changed: 52 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,44 @@
1313
visited_path = {}
1414

1515

16+
def substitute_constants(obj, constants):
17+
"""Recursively substitute constants in strings using @{constant_name} syntax.
18+
19+
Args:
20+
obj (any): The object to process (can be dict, list, str, or any other type).
21+
constants (dict): Dictionary mapping constant names to their values.
22+
23+
Returns:
24+
any: The processed object with constants substituted.
25+
26+
Example:
27+
>>> constants = {'city': 'Tokyo'}
28+
>>> obj = {'label': '@{city} Users'}
29+
>>> substitute_constants(obj, constants)
30+
{'label': 'Tokyo Users'}
31+
"""
32+
if isinstance(obj, str):
33+
# Replace @{constant_name} with the constant value
34+
import re
35+
pattern = r'@\{([^}]+)\}'
36+
37+
def replace_constant(match):
38+
constant_name = match.group(1)
39+
if constant_name in constants:
40+
return constants[constant_name]
41+
else:
42+
# Keep the original if constant not found
43+
return match.group(0)
44+
45+
return re.sub(pattern, replace_constant, obj)
46+
elif isinstance(obj, dict):
47+
return {key: substitute_constants(value, constants) for key, value in obj.items()}
48+
elif isinstance(obj, list):
49+
return [substitute_constants(item, constants) for item in obj]
50+
else:
51+
return obj
52+
53+
1654
def update_namespace(namespace, new_file):
1755
"""Update namespace with new file content, merging lists and handling conflicts.
1856
@@ -37,12 +75,15 @@ def update_namespace(namespace, new_file):
3775
namespace[key] = namespace[key] + new_file[key]
3876
elif key in namespace and key in ("includes"): # remove duplicates
3977
namespace[key] = list(set(namespace[key] + new_file[key]))
40-
elif key in ("views", "explores", "includes"):
78+
elif key in namespace and key in ("constants"):
79+
# Merge constants - later ones override earlier ones
80+
namespace[key].extend(new_file[key])
81+
elif key in ("views", "explores", "includes", "constants"):
4182
namespace[key] = new_file[key]
4283
elif key in ("connection"):
4384
pass # ignored keys
4485
else:
45-
console.print(f"Key not supported yet: {key}", style="bold red")
86+
console.print(f"Key not supported yet: {key}")
4687
return namespace
4788

4889

@@ -90,6 +131,15 @@ def file_loader(file_path_input, rootdir_param, namespace=None):
90131
join(root_dir, included_path), rootdir_param, namespace=namespace
91132
)
92133
namespace = update_namespace(namespace, lookml_model)
134+
135+
# Apply constant substitution if constants are available
136+
if namespace and "constants" in namespace:
137+
# Convert constants list to dictionary for substitution
138+
constants_dict = {}
139+
for constant in namespace["constants"]:
140+
constants_dict[constant["name"]] = constant["value"]
141+
namespace = substitute_constants(namespace, constants_dict)
142+
93143
return namespace
94144

95145

lkml2cube/parser/views.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -78,7 +78,7 @@ def parse_view(lookml_model, raise_when_views_not_present=True):
7878
parent_views.append(view_item)
7979
found = True
8080
if not found:
81-
console.print(f"View not found: {lkml_view}", style="bold red")
81+
console.print(f"View not found: {lkml_view}")
8282
parent_views.append(view)
8383

8484
# MRO is left to right
@@ -179,7 +179,7 @@ def parse_view(lookml_model, raise_when_views_not_present=True):
179179
for measure in view.get("measures", []):
180180
if measure["type"] not in type_map:
181181
msg = f'Measure type: {measure["type"]} not implemented yet:\n# {measure}'
182-
console.print(f"# {msg}", style="bold red")
182+
console.print(f"# {msg}")
183183
continue
184184

185185
cube_measure = {

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11

22
[project]
33
name = "lkml2cube"
4-
version = "0.2.9"
4+
version = "0.2.10"
55
description = "Looker ML to Cube converter"
66
authors = [
77
{name = "Paco Valdez", email = "paco@cube.dev"},
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
constant: city {
2+
value: "Okayama"
3+
}
4+
5+
constant: country {
6+
value: "Japan"
7+
}
8+
9+
explore: users {
10+
label: "@{city} Users"
11+
description: "Users from @{city}, @{country}"
12+
13+
join: orders {
14+
relationship: one_to_many
15+
sql_on: ${users.id} = ${orders.user_id} ;;
16+
type: left_outer
17+
}
18+
}
19+
20+
view: users {
21+
label: "@{city} Users Data"
22+
sql_table_name: users ;;
23+
24+
dimension: id {
25+
type: number
26+
primary_key: yes
27+
sql: ${TABLE}.id ;;
28+
}
29+
30+
dimension: name {
31+
type: string
32+
label: "User Name in @{city}"
33+
sql: ${TABLE}.name ;;
34+
}
35+
36+
measure: count {
37+
type: count
38+
label: "Count of @{city} Users"
39+
}
40+
}

tests/test_constants.py

Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,142 @@
1+
"""
2+
Tests for LookML constants parsing and substitution.
3+
4+
This module tests the constant parsing functionality, including:
5+
- Parsing constants from LookML files
6+
- Substituting constants in strings using @{constant_name} syntax
7+
- Handling constants in views, explores, and their properties
8+
"""
9+
10+
import pytest
11+
import os
12+
from lkml2cube.parser.loader import file_loader, substitute_constants
13+
14+
15+
class TestConstantParsing:
16+
"""Test constant parsing and substitution functionality."""
17+
18+
def test_constant_substitution_simple(self):
19+
"""Test basic constant substitution in strings."""
20+
constants = {'city': 'Tokyo', 'country': 'Japan'}
21+
22+
# Test simple string substitution
23+
result = substitute_constants("@{city} Users", constants)
24+
assert result == "Tokyo Users"
25+
26+
# Test multiple constants in one string
27+
result = substitute_constants("Users from @{city}, @{country}", constants)
28+
assert result == "Users from Tokyo, Japan"
29+
30+
def test_constant_substitution_in_dict(self):
31+
"""Test constant substitution in dictionary structures."""
32+
constants = {'city': 'Okayama'}
33+
34+
obj = {
35+
'label': '@{city} Users',
36+
'description': 'Users from @{city}',
37+
'nested': {
38+
'field': '@{city} data'
39+
}
40+
}
41+
42+
result = substitute_constants(obj, constants)
43+
44+
assert result['label'] == 'Okayama Users'
45+
assert result['description'] == 'Users from Okayama'
46+
assert result['nested']['field'] == 'Okayama data'
47+
48+
def test_constant_substitution_in_list(self):
49+
"""Test constant substitution in list structures."""
50+
constants = {'city': 'Tokyo'}
51+
52+
obj = ['@{city} Users', '@{city} Orders', 'Static string']
53+
54+
result = substitute_constants(obj, constants)
55+
56+
assert result[0] == 'Tokyo Users'
57+
assert result[1] == 'Tokyo Orders'
58+
assert result[2] == 'Static string'
59+
60+
def test_constant_not_found(self):
61+
"""Test behavior when constant is not found."""
62+
constants = {'city': 'Tokyo'}
63+
64+
# Should leave unknown constants unchanged
65+
result = substitute_constants("@{city} Users from @{unknown}", constants)
66+
assert result == "Tokyo Users from @{unknown}"
67+
68+
def test_constants_in_lookml_file(self):
69+
"""Test loading and parsing constants from a LookML file."""
70+
file_path = os.path.join(os.path.dirname(__file__), "samples", "lkml", "constants_test.lkml")
71+
72+
# Reset visited_path to ensure clean state
73+
from lkml2cube.parser.loader import visited_path
74+
visited_path.clear()
75+
76+
# Load the model
77+
model = file_loader(file_path, None)
78+
79+
# Check that constants were parsed
80+
assert "constants" in model
81+
constants_dict = {c["name"]: c["value"] for c in model["constants"]}
82+
assert "city" in constants_dict
83+
assert "country" in constants_dict
84+
assert constants_dict["city"] == "Okayama"
85+
assert constants_dict["country"] == "Japan"
86+
87+
def test_constant_substitution_in_loaded_model(self):
88+
"""Test that constants are substituted in loaded LookML model."""
89+
file_path = os.path.join(os.path.dirname(__file__), "samples", "lkml", "constants_test.lkml")
90+
91+
# Reset visited_path to ensure clean state
92+
from lkml2cube.parser.loader import visited_path
93+
visited_path.clear()
94+
95+
# Load the model
96+
model = file_loader(file_path, None)
97+
98+
# Check that model was loaded
99+
assert model is not None
100+
101+
# Check that constants were substituted in explores
102+
explores = model.get("explores", [])
103+
assert len(explores) > 0
104+
105+
users_explore = next((e for e in explores if e["name"] == "users"), None)
106+
assert users_explore is not None
107+
assert users_explore["label"] == "Okayama Users"
108+
assert users_explore["description"] == "Users from Okayama, Japan"
109+
110+
def test_constant_substitution_in_views(self):
111+
"""Test that constants are substituted in view definitions."""
112+
file_path = os.path.join(os.path.dirname(__file__), "samples", "lkml", "constants_test.lkml")
113+
114+
# Reset visited_path to ensure clean state
115+
from lkml2cube.parser.loader import visited_path
116+
visited_path.clear()
117+
118+
# Load the model
119+
model = file_loader(file_path, None)
120+
121+
# Check that model was loaded
122+
assert model is not None
123+
124+
# Check that constants were substituted in views
125+
views = model.get("views", [])
126+
assert len(views) > 0
127+
128+
users_view = next((v for v in views if v["name"] == "users"), None)
129+
assert users_view is not None
130+
assert users_view["label"] == "Okayama Users Data"
131+
132+
# Check dimension labels
133+
dimensions = users_view.get("dimensions", [])
134+
name_dimension = next((d for d in dimensions if d["name"] == "name"), None)
135+
assert name_dimension is not None
136+
assert name_dimension["label"] == "User Name in Okayama"
137+
138+
# Check measure labels
139+
measures = users_view.get("measures", [])
140+
count_measure = next((m for m in measures if m["name"] == "count"), None)
141+
assert count_measure is not None
142+
assert count_measure["label"] == "Count of Okayama Users"

0 commit comments

Comments
 (0)