Skip to content

Commit dea43b4

Browse files
committed
Add explores parser
1 parent 219e631 commit dea43b4

File tree

6 files changed

+298
-95
lines changed

6 files changed

+298
-95
lines changed

README.md

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,11 +15,17 @@ pip install lkml2cube
1515
### Convert LookML views into Cube YAML definition.
1616

1717
```sh
18-
lkml2cube cubes path/to/file.view.lkml
18+
lkml2cube cubes path/to/file.view.lkml --outputdir examples/
1919
```
2020

2121
### Show Python dict representation of the LookerML object
2222

2323
```sh
2424
lkml2cube cubes --parseonly path/to/file.view.lkml
25-
```
25+
```
26+
27+
### Convert LookML Explroes into Cube's views YAML definition.
28+
29+
```sh
30+
lkml2cube views path/to/file.explore.lkml --outputdir examples/
31+
```

lkml2cube/main.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
import typer
33
import yaml
44

5-
from lkml2cube.parser.explores import parse_explores
5+
from lkml2cube.parser.explores import parse_explores, generate_cube_joins
66
from lkml2cube.parser.loader import file_loader, write_files
77
from lkml2cube.parser.views import parse_view
88
from typing_extensions import Annotated
@@ -36,6 +36,7 @@ def cubes(
3636
return
3737

3838
cube_def = parse_view(lookml_model)
39+
cube_def = generate_cube_joins(cube_def, lookml_model)
3940

4041
if printonly:
4142
typer.echo(yaml.dump(cube_def))

lkml2cube/parser/explores.py

Lines changed: 152 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,157 @@
11

2-
import yaml
2+
import re
3+
import traceback
34
import typer
4-
from pprint import pprint
5+
6+
from pprint import pformat
7+
8+
from lkml2cube.parser.views import parse_view
9+
10+
snake_case = r'\{([a-zA-Z]+(?:_[a-zA-Z]+)*\.[a-zA-Z]+(?:_[a-zA-Z]+)*)\}'
11+
12+
def snakify(s):
13+
return '_'.join(
14+
re.sub('([A-Z][a-z]+)', r' \1',
15+
re.sub('([A-Z]+)', r' \1',
16+
s.replace('-', ' '))).split()
17+
).lower()
18+
19+
def build_cube_name_look_up(cube_def):
20+
if 'cube_name_look_up' in cube_def:
21+
return
22+
cube_name_look_up = {}
23+
for cube_element in cube_def['cubes']:
24+
cube_name_look_up[cube_element['name']] = cube_element
25+
cube_def['cube_name_look_up'] = cube_name_look_up
26+
27+
def get_cube_from_cube_def(cube_def, cube_name):
28+
if 'cube_name_look_up' not in cube_def:
29+
build_cube_name_look_up(cube_def)
30+
if cube_name in cube_def['cube_name_look_up']:
31+
return cube_def['cube_name_look_up'][cube_name]
32+
return None
33+
34+
def get_cube_names_from_join_condition(join_condition):
35+
return [cube.split('.')[0] for cube in re.findall(snake_case, join_condition)]
36+
37+
def traverse_graph(join_paths, cube_left, cube_right):
38+
# Create a queue for BFS
39+
queue = []
40+
queue.append([cube_left])
41+
42+
while queue:
43+
#Dequeue a vertex from queue
44+
tmp_path = queue.pop(0)
45+
# If this adjacent node is the destination node,
46+
# then return true
47+
last_node = tmp_path[len(tmp_path)-1]
48+
if last_node == cube_right:
49+
return '.'.join(tmp_path)
50+
# Else, continue to do BFS
51+
if last_node in join_paths:
52+
for cube in join_paths[last_node]:
53+
if cube not in tmp_path:
54+
new_path = []
55+
new_path = tmp_path + [cube]
56+
queue.append(new_path)
57+
58+
typer.echo(f'Cubes are not reachable: {cube_left}, {cube_right}')
59+
return '.'.join(cube_left, cube_right)
60+
61+
62+
def generate_cube_joins(cube_def, lookml_model):
63+
for explore in lookml_model['explores']:
64+
if 'joins' not in explore:
65+
continue
66+
67+
for join_element in explore['joins']:
68+
try:
69+
cube_right = join_element['name']
70+
71+
joined_cubes = [cube for cube in get_cube_names_from_join_condition(join_element['sql_on']) if cube != cube_right]
72+
if joined_cubes:
73+
if 'from' in join_element:
74+
cube = {
75+
'name': cube_right,
76+
'extends': join_element['from'],
77+
'shown': False,
78+
}
79+
cube_def['cubes'].append(cube)
80+
else:
81+
cube = get_cube_from_cube_def(cube_def, cube_right)
82+
83+
join_condition = join_element['sql_on']
84+
85+
if 'joins' not in cube:
86+
cube['joins'] = []
87+
88+
cube['joins'].append({
89+
'name': joined_cubes[0],
90+
'sql': join_condition,
91+
'relationship': join_element['relationship']
92+
})
93+
except Exception:
94+
typer.echo(f'Error while parsing explore: {pformat(explore)}')
95+
typer.echo(traceback.format_exc())
96+
97+
return cube_def
98+
99+
def generate_cube_views(cube_def, lookml_model):
100+
if 'views' not in cube_def:
101+
cube_def['views'] = []
102+
for explore in lookml_model['explores']:
103+
try:
104+
central_cube = explore['name']
105+
view_name = snakify(explore['label'])
106+
view = {
107+
'name': view_name,
108+
'description': explore['label'],
109+
'cubes': [{
110+
'join_path': central_cube,
111+
'includes': "*",
112+
'alias': view_name
113+
}]
114+
}
115+
116+
if 'joins' not in explore:
117+
cube_def['views'].append(view)
118+
continue
119+
# Create Graph
120+
join_paths = {}
121+
for join_element in explore['joins']:
122+
cube_right = join_element['name']
123+
cube_left = [cube for cube in get_cube_names_from_join_condition(join_element['sql_on']) if cube != cube_right][0]
124+
125+
if cube_left in join_paths:
126+
join_paths[cube_left].append(cube_right)
127+
else:
128+
join_paths[cube_left] = [cube_right]
129+
# traverse graph
130+
for join_element in explore['joins']:
131+
cube_right = join_element['name']
132+
join_path = {
133+
'join_path': traverse_graph(join_paths, central_cube, cube_right),
134+
'includes': "*",
135+
'alias': cube_right
136+
}
137+
view['cubes'].append(join_path)
138+
139+
# End
140+
cube_def['views'].append(view)
141+
142+
except Exception:
143+
typer.echo(f'Error while parsing explore: {pformat(explore)}')
144+
typer.echo(traceback.format_exc())
145+
return cube_def
5146

6147

7148
def parse_explores(lookml_model):
8-
return pprint(lookml_model)
149+
# First we read all possible lookml views.
150+
cube_def = parse_view(lookml_model, raise_when_views_not_present=False)
151+
if 'explores' not in lookml_model:
152+
raise Exception('LookML explores are needed to generate Cube Views, no explore found in path.')
153+
cube_def = generate_cube_joins(cube_def, lookml_model)
154+
155+
cube_def = generate_cube_views(cube_def, lookml_model)
156+
157+
return cube_def

lkml2cube/parser/loader.py

Lines changed: 43 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -9,12 +9,15 @@
99
visited_path = {}
1010

1111
def update_namespace(namespace, new_file):
12+
1213
if namespace is None:
1314
return new_file
1415
for key, value in new_file.items():
1516
if key in namespace and key in ('views', 'explores'):
1617
namespace[key] = namespace[key] + new_file[key]
17-
elif key in ('views', 'explores'):
18+
elif key in namespace and key in ('includes'): # remove duplicates
19+
namespace[key] = list(set(namespace[key] + new_file[key]))
20+
elif key in ('views', 'explores', 'includes'):
1821
namespace[key] = new_file[key]
1922
elif key in ('connection'):
2023
pass # ignored keys
@@ -23,6 +26,7 @@ def update_namespace(namespace, new_file):
2326
return namespace
2427

2528
def file_loader(file_path_input, namespace=None):
29+
2630
file_paths = glob.glob(file_path_input)
2731
for file_path in file_paths:
2832
if file_path in visited_path:
@@ -31,6 +35,8 @@ def file_loader(file_path_input, namespace=None):
3135
lookml_model = lkml.load(open(file_path, 'r'))
3236
if 'includes' in lookml_model:
3337
for included_path in lookml_model['includes']:
38+
if namespace and 'includes' in namespace and included_path in namespace['includes']:
39+
continue
3440
if included_path.startswith('/'):
3541
included_path = included_path[1:]
3642
root_dir = dirname(abspath(file_path))
@@ -39,10 +45,41 @@ def file_loader(file_path_input, namespace=None):
3945
return namespace
4046

4147

42-
def write_files(cube_def, outputdir, file_name = 'my_cubes.yml'):
43-
if 'cubes' in cube_def and len(cube_def['cubes']) == 1:
44-
file_name = cube_def['cubes'][0]['name'] + '.yml'
45-
Path(join(outputdir, 'cubes')).mkdir(parents=True, exist_ok=True)
46-
f = open(join(outputdir, 'cubes', file_name), 'w')
48+
def write_single_file(cube_def: dict, outputdir: str, subdir: str = 'cubes', file_name: str = 'my_cubes.yml'):
49+
50+
f = open(join(outputdir, subdir, file_name), 'w')
4751
f.write(yaml.dump(cube_def))
4852
f.close()
53+
54+
55+
def write_files(cube_def, outputdir):
56+
57+
if not cube_def:
58+
raise Exception('No cube definition available')
59+
60+
for cube_root_element in ('cubes', 'views'):
61+
62+
if cube_root_element in cube_def:
63+
64+
Path(join(outputdir, cube_root_element)).mkdir(parents=True, exist_ok=True)
65+
66+
if len(cube_def[cube_root_element]) == 1:
67+
write_single_file(cube_def=cube_def,
68+
outputdir=outputdir,
69+
subdir=cube_root_element,
70+
file_name=cube_def[cube_root_element][0]['name'] + '.yml')
71+
72+
elif len(cube_def[cube_root_element]) > 1:
73+
for cube_element in cube_def[cube_root_element]:
74+
new_def = {
75+
cube_root_element: [cube_element]
76+
}
77+
write_single_file(cube_def=new_def,
78+
outputdir=outputdir,
79+
subdir=cube_root_element,
80+
file_name=cube_element['name'] + '.yml')
81+
else:
82+
# Empty 'cubes' definition
83+
# not expected but not invalid
84+
pass
85+

0 commit comments

Comments
 (0)