Skip to content

Commit e5e1919

Browse files
authored
Fix: Encode model/column name for linage in case it has special chars (#1747)
1 parent 0a54796 commit e5e1919

File tree

5 files changed

+201
-113
lines changed

5 files changed

+201
-113
lines changed

web/client/src/library/components/editor/Editor.css

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -119,21 +119,23 @@
119119
}
120120

121121
.sqlmesh-model__column.--is-original,
122-
.sqlmesh-model__column.--is-original > span {
122+
.sqlmesh-model__column.--is-original > span,
123+
.sqlmesh-model__column.--is-alias,
124+
.sqlmesh-model__column.--is-alias > span {
123125
display: inline-block;
124126
color: var(--color-accent-300);
125127
font-weight: bold;
126128
}
127129

128-
.sqlmesh-model__column.--is-active-model.--is-derived,
129-
.sqlmesh-model__column.--is-active-model.--is-derived > span {
130+
.sqlmesh-model__column.--is-active-model.--is-alias,
131+
.sqlmesh-model__column.--is-active-model.--is-alias > span {
130132
display: inline-block;
131133
color: var(--color-brand-300);
132134
font-weight: bold;
133135
box-shadow: inset 0 -2px 0 0 transparent;
134136
}
135-
.sqlmesh-model__column.--is-active-model.--is-derived.--is-action-mode,
136-
.sqlmesh-model__column.--is-active-model.--is-derived.--is-action-mode > span {
137+
.sqlmesh-model__column.--is-active-model.--is-alias.--is-action-mode,
138+
.sqlmesh-model__column.--is-active-model.--is-alias.--is-action-mode > span {
137139
cursor: pointer;
138140
box-shadow: inset 0 -2px 0 0 var(--color-brand-300);
139141
}

web/client/src/library/components/editor/extensions/SQLMeshDialect.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -23,11 +23,11 @@ export const SQLMeshDialect: ExtensionSQLMeshDialect = function SQLMeshDialect(
2323
options = { types: '', keywords: '' },
2424
dialects,
2525
): LanguageSupport {
26-
const SQLKeywords = options.keywords
27-
const SQLTypes = options.types
26+
const SQLKeywords = options.keywords + ' coalesce sum count avg min max cast'
27+
const SQLTypes = options.types + ' string'
2828
const SQLMeshModelDictionary = getSQLMeshModelKeywords(dialects)
2929
const SQLMeshKeywords =
30-
'columns grain grains references metric tags audit model name kind owner cron start storage_format time_column partitioned_by pre post batch_size audits dialect'
30+
'path threshold jinja_query_begin number_of_rows jinja_end not_null forall criteria length unique_values interval_unit unique_key columns grain grains references metric tags audit model name kind owner cron start storage_format time_column partitioned_by pre post batch_size audits dialect'
3131
const SQLMeshTypes =
3232
'expression seed full incremental_by_time_range incremental_by_unique_key view embedded'
3333
const lang = SQLDialect.define({
Lines changed: 159 additions & 83 deletions
Original file line numberDiff line numberDiff line change
@@ -1,25 +1,35 @@
1-
import { syntaxTree } from '@codemirror/language'
21
import {
32
type EditorView,
43
type DecorationSet,
54
Decoration,
65
} from '@codemirror/view'
76
import { type ModelSQLMeshModel } from '@models/sqlmesh-model'
8-
import { isFalse } from '@utils/index'
7+
import { isFalse, isNil, isNotNil } from '@utils/index'
98
import { type Column } from '@api/client'
109
import clsx from 'clsx'
1110

11+
interface DecorationRange {
12+
from: number
13+
to: number
14+
}
15+
type MarkDecorationCallback = (
16+
options: DecorationRange & {
17+
name: string
18+
key: string
19+
},
20+
) => void
21+
1222
export function findModel(
1323
event: MouseEvent,
1424
models: Map<string, ModelSQLMeshModel>,
1525
): ModelSQLMeshModel | undefined {
16-
if (event.target == null) return
26+
if (isNil(event.target)) return
1727

1828
const el = event.target as HTMLElement
1929
const modelName =
2030
el.getAttribute('model') ?? el.parentElement?.getAttribute('model')
2131

22-
if (modelName == null) return
32+
if (isNil(modelName)) return
2333

2434
return models.get(modelName)
2535
}
@@ -28,13 +38,13 @@ export function findColumn(
2838
event: MouseEvent,
2939
model: ModelSQLMeshModel,
3040
): Column | undefined {
31-
if (event.target == null) return
41+
if (isNil(event.target)) return
3242

3343
const el = event.target as HTMLElement
3444
const columnName =
3545
el.getAttribute('column') ?? el.parentElement?.getAttribute('column')
3646

37-
if (columnName == null) return
47+
if (isNil(columnName)) return
3848

3949
return model.columns.find(c => c.name === columnName)
4050
}
@@ -46,89 +56,155 @@ export function getDecorations(
4656
columns: Set<string>,
4757
isActionMode: boolean,
4858
): DecorationSet {
49-
const decorations: any = []
50-
const modelColumns = model.columns.map(c => c.name)
59+
const ranges: any = []
60+
const columnNames = Array.from(columns)
61+
const modelNames = Array.from(new Set(models.values())).map(m => m.name)
62+
const modelColumns = model.columns.map(c => c.name.toLowerCase())
63+
const validLeftCharColumn = new Set(['.', '(', '[', ' ', '\n'])
64+
const validRightCharColumn = new Set([':', ')', ',', ']', ' ', '\n'])
5165

5266
for (const range of view.visibleRanges) {
53-
syntaxTree(view.state).iterate({
54-
from: range.from,
55-
to: range.to,
56-
enter({ from, to }) {
57-
// In case model name represented in qoutes
58-
// like in python files, we need to remove qoutes
59-
let maybeModelOrColumn = view.state.doc
60-
.sliceString(from - 1, to + 1)
61-
.replaceAll('"', '')
62-
.replaceAll("'", '')
63-
let isOriginal = false
64-
65-
if (
66-
maybeModelOrColumn.startsWith('.') ||
67-
maybeModelOrColumn.endsWith(':')
68-
) {
69-
isOriginal = true
70-
}
71-
72-
maybeModelOrColumn = maybeModelOrColumn.slice(
73-
1,
74-
maybeModelOrColumn.length - 1,
67+
getMarkDecorations(
68+
modelNames,
69+
view.state.doc.sliceString(range.from, range.to),
70+
range,
71+
({ from, to, name }) => {
72+
ranges.push(
73+
createMarkDecorationModel({
74+
model: name,
75+
isActionMode,
76+
isActiveModel: model.name === name,
77+
}).range(from, to),
7578
)
79+
},
80+
)
81+
82+
getMarkDecorations(
83+
columnNames,
84+
view.state.doc.sliceString(range.from, range.to),
85+
range,
86+
({ from, to, key }) => {
87+
const column = view.state.doc.sliceString(from, to)
88+
const word = view.state.doc.sliceString(from - 1, to + 1)
89+
const leftChar = view.state.doc.sliceString(from - 1, from)
90+
const rightChar = view.state.doc.sliceString(to, to + 1)
7691

7792
if (
78-
isFalse(isOriginal) &&
79-
columns.has(maybeModelOrColumn) &&
80-
!modelColumns.includes(maybeModelOrColumn)
81-
) {
82-
isOriginal = true
83-
}
84-
85-
let decoration
86-
87-
if (maybeModelOrColumn === model.name) {
88-
decoration = Decoration.mark({
89-
attributes: {
90-
class: clsx(
91-
'sqlmesh-model --is-active-model',
92-
isActionMode && '--is-action-mode',
93-
),
94-
model: maybeModelOrColumn,
95-
},
96-
}).range(from, to)
97-
} else if (models.get(maybeModelOrColumn) != null) {
98-
decoration = Decoration.mark({
99-
attributes: {
100-
class: clsx('sqlmesh-model', isActionMode && '--is-action-mode'),
101-
model: maybeModelOrColumn,
102-
},
103-
}).range(from, to)
104-
} else if (modelColumns.includes(maybeModelOrColumn)) {
105-
decoration = Decoration.mark({
106-
attributes: {
107-
class: clsx(
108-
'sqlmesh-model__column --is-active-model',
109-
isOriginal ? '--is-original' : '--is-derived',
110-
isActionMode && '--is-action-mode',
111-
),
112-
column: maybeModelOrColumn,
113-
},
114-
}).range(from, to)
115-
} else if (columns.has(maybeModelOrColumn)) {
116-
decoration = Decoration.mark({
117-
attributes: {
118-
class: clsx(
119-
'sqlmesh-model__column',
120-
isOriginal ? '--is-original' : '--is-derived',
121-
isActionMode && '--is-action-mode',
122-
),
123-
column: maybeModelOrColumn,
124-
},
125-
}).range(from, to)
126-
}
127-
128-
decoration != null && decorations.push(decoration)
93+
(isNotNil(leftChar) && isFalse(validLeftCharColumn.has(leftChar))) ||
94+
(isNotNil(rightChar) && isFalse(validRightCharColumn.has(rightChar)))
95+
)
96+
return
97+
98+
ranges.push(
99+
createMarkDecorationColumn({
100+
column,
101+
isOriginalColumn: isOriginalColumn(word),
102+
isActiveModel: modelColumns.includes(key),
103+
isActionMode,
104+
}).range(from, to),
105+
)
129106
},
130-
})
107+
)
108+
}
109+
110+
return Decoration.set(
111+
ranges.sort((a: DecorationRange, b: DecorationRange) => a.from - b.from),
112+
)
113+
}
114+
115+
function getMarkDecorations(
116+
list: string[],
117+
doc: string,
118+
range: DecorationRange,
119+
callback: MarkDecorationCallback,
120+
): void {
121+
const visisted = new Set()
122+
123+
for (const name of list) {
124+
const key = name.toLowerCase()
125+
126+
if (visisted.has(key)) continue
127+
128+
visisted.add(key)
129+
130+
const regex = new RegExp(name, 'ig')
131+
const regex_normalized = new RegExp(alternativeNameFormat(name), 'ig')
132+
let found
133+
134+
while (isNotNil((found = regex_normalized.exec(doc) ?? regex.exec(doc)))) {
135+
const from = range.from + found.index
136+
const to = from + found[0].length
137+
const options = {
138+
from,
139+
to,
140+
name,
141+
key,
142+
}
143+
144+
isNotNil(callback) && callback(options)
145+
}
131146
}
147+
}
148+
149+
function createMarkDecorationModel({
150+
model,
151+
isActionMode,
152+
isActiveModel,
153+
}: {
154+
model: string
155+
isActionMode: boolean
156+
isActiveModel: boolean
157+
}): Decoration {
158+
return Decoration.mark({
159+
attributes: {
160+
class: clsx(
161+
'sqlmesh-model',
162+
isActiveModel && '--is-active-model',
163+
isActionMode && '--is-action-mode',
164+
),
165+
model,
166+
},
167+
})
168+
}
169+
170+
function createMarkDecorationColumn({
171+
column,
172+
isOriginalColumn,
173+
isActionMode,
174+
isActiveModel,
175+
}: {
176+
column: string
177+
isOriginalColumn: boolean
178+
isActiveModel: boolean
179+
isActionMode: boolean
180+
}): Decoration {
181+
return Decoration.mark({
182+
attributes: {
183+
class: clsx(
184+
'sqlmesh-model__column',
185+
isOriginalColumn ? '--is-original' : '--is-alias',
186+
isActiveModel && '--is-active-model',
187+
isActionMode && '--is-action-mode',
188+
),
189+
column,
190+
},
191+
})
192+
}
193+
194+
function isOriginalColumn(column: string): boolean {
195+
return (
196+
column.startsWith('.') ||
197+
column.endsWith(':') ||
198+
column.startsWith('(') ||
199+
column.endsWith(')')
200+
)
201+
}
132202

133-
return Decoration.set(decorations)
203+
function alternativeNameFormat(modelName: string): string {
204+
return modelName.includes('"')
205+
? modelName
206+
: modelName
207+
.split('.')
208+
.map(name => `"${name}"`)
209+
.join('.')
134210
}

web/client/src/library/components/graph/Graph.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -171,7 +171,7 @@ const ModelColumnDisplay = memo(function ModelColumnDisplay({
171171
className={clsx('flex items-center', disabled && 'opacity-50')}
172172
>
173173
{disabled && <NoSymbolIcon className="w-3 h-3 mr-2" />}
174-
<b>{columnName}</b>
174+
<b>{decodeURI(columnName)}</b>
175175
</span>
176176
<span className="inline-block text-neutral-400 dark:text-neutral-300 ml-2">
177177
{columnType}
@@ -326,7 +326,7 @@ const ModelNodeHeaderHandles = memo(function ModelNodeHeaderHandles({
326326
)}
327327
onClick={handleClick}
328328
>
329-
{label}
329+
{decodeURI(label)}
330330
</span>
331331
<span className="flex justify-between mx-2 px-2 rounded-full bg-neutral-10">
332332
{count}

0 commit comments

Comments
 (0)