Skip to content

Commit 667fff6

Browse files
authored
Merge pull request #5 from cube-js/parse_explores
Parse explores
2 parents 4fd310f + dea43b4 commit 667fff6

File tree

7 files changed

+397
-90
lines changed

7 files changed

+397
-90
lines changed

.gitignore

+6
Original file line numberDiff line numberDiff line change
@@ -159,3 +159,9 @@ cython_debug/
159159
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
160160
#.idea/
161161
.pdm-python
162+
163+
164+
# default location to write files
165+
cubes/
166+
views/
167+
examples/

README.md

+8-2
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

+46-7
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
import pprint
22
import typer
3+
import yaml
34

4-
from lkml2cube.parser.loader import file_loader
5+
from lkml2cube.parser.explores import parse_explores, generate_cube_joins
6+
from lkml2cube.parser.loader import file_loader, write_files
57
from lkml2cube.parser.views import parse_view
68
from typing_extensions import Annotated
79

@@ -12,14 +14,17 @@ def callback():
1214
"""
1315
lkml2cube is a tool to convert LookML models into Cube data models.
1416
"""
15-
typer.echo(("lkml2cube is a tool to convert LookML models into Cube data models.\n"
16-
"Use lkml2cube --help to see usage."))
17+
# typer.echo(("lkml2cube is a tool to convert LookML models into Cube data models.\n"
18+
# "Use lkml2cube --help to see usage."))
19+
pass
1720

1821
@app.command()
1922
def cubes(
20-
file_path: Annotated[str, typer.Argument(help="The path for the file to read")],
21-
parseonly: Annotated[bool, typer.Option(help=("When present it will only show the python"
22-
" dict read from the lookml file"))] = False
23+
file_path: Annotated[str, typer.Argument(help="The path for the file to read")],
24+
parseonly: Annotated[bool, typer.Option(help=("When present it will only show the python"
25+
" dict read from the lookml file"))] = False,
26+
outputdir: Annotated[str, typer.Option(help="The path for the output files to be generated")] = '.',
27+
printonly: Annotated[bool, typer.Option(help="Print to stdout the parsed files")] = False,
2328
):
2429
"""
2530
Generate cubes-only given a LookML file that contains LookML Views.
@@ -30,7 +35,41 @@ def cubes(
3035
typer.echo(pprint.pformat(lookml_model))
3136
return
3237

33-
typer.echo(parse_view(lookml_model))
38+
cube_def = parse_view(lookml_model)
39+
cube_def = generate_cube_joins(cube_def, lookml_model)
40+
41+
if printonly:
42+
typer.echo(yaml.dump(cube_def))
43+
return
44+
45+
write_files(cube_def, outputdir=outputdir)
46+
47+
48+
49+
@app.command()
50+
def views(
51+
file_path: Annotated[str, typer.Argument(help="The path for the explore to read")],
52+
parseonly: Annotated[bool, typer.Option(help=("When present it will only show the python"
53+
" dict read from the lookml file"))] = False,
54+
outputdir: Annotated[str, typer.Option(help="The path for the output files to be generated")] = '.',
55+
printonly: Annotated[bool, typer.Option(help="Print to stdout the parsed files")] = False,
56+
):
57+
"""
58+
Generate cubes-only given a LookML file that contains LookML Views.
59+
"""
60+
61+
lookml_model = file_loader(file_path)
62+
if parseonly:
63+
typer.echo(pprint.pformat(lookml_model))
64+
return
65+
66+
cube_def = parse_explores(lookml_model)
67+
68+
if printonly:
69+
typer.echo(yaml.dump(cube_def))
70+
return
71+
72+
write_files(cube_def, outputdir=outputdir)
3473

3574
if __name__ == "__main__":
3675
app()

lkml2cube/parser/explores.py

+157
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,157 @@
1+
2+
import re
3+
import traceback
4+
import typer
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
146+
147+
148+
def parse_explores(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

+83-3
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,85 @@
1+
import glob
12
import lkml
3+
import typer
4+
import yaml
25

3-
def file_loader(file_path):
4-
lookml_model = lkml.load(open(file_path, 'r'))
5-
return lookml_model
6+
from os.path import abspath, dirname, join
7+
from pathlib import Path
8+
9+
visited_path = {}
10+
11+
def update_namespace(namespace, new_file):
12+
13+
if namespace is None:
14+
return new_file
15+
for key, value in new_file.items():
16+
if key in namespace and key in ('views', 'explores'):
17+
namespace[key] = namespace[key] + new_file[key]
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'):
21+
namespace[key] = new_file[key]
22+
elif key in ('connection'):
23+
pass # ignored keys
24+
else:
25+
typer.echo(f'Key not supported yet: {key}')
26+
return namespace
27+
28+
def file_loader(file_path_input, namespace=None):
29+
30+
file_paths = glob.glob(file_path_input)
31+
for file_path in file_paths:
32+
if file_path in visited_path:
33+
continue
34+
visited_path[file_path] = True
35+
lookml_model = lkml.load(open(file_path, 'r'))
36+
if 'includes' in lookml_model:
37+
for included_path in lookml_model['includes']:
38+
if namespace and 'includes' in namespace and included_path in namespace['includes']:
39+
continue
40+
if included_path.startswith('/'):
41+
included_path = included_path[1:]
42+
root_dir = dirname(abspath(file_path))
43+
namespace = file_loader(join(root_dir, included_path), namespace=namespace)
44+
namespace = update_namespace(namespace, lookml_model)
45+
return namespace
46+
47+
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')
51+
f.write(yaml.dump(cube_def))
52+
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)