diff --git a/gui/CHANGELOG.md b/gui/CHANGELOG.md
index 4b8f5d7cf3d..601249a7321 100644
--- a/gui/CHANGELOG.md
+++ b/gui/CHANGELOG.md
@@ -11,6 +11,11 @@
will be lost. In this build we added notification in statusbar to signalize
that the connection was lost and IDE must be restarted. In future IDE will try
to automatically reconnect.
+- [Database Visualizations][1335]. Visualizations for the Database library have
+ been added. The Table visualization now automatically executes the underlying
+ query to display its results in a table. In addition, the SQL Query
+ visualization allows the user to see the query that is going to be run against
+ the database.
![Bug Fixes](/docs/assets/tags/bug_fixes.svg)
diff --git a/gui/src/rust/ide/view/graph-editor/src/builtin/visualization/java_script.rs b/gui/src/rust/ide/view/graph-editor/src/builtin/visualization/java_script.rs
index 93a4a92fba9..681055e22f3 100644
--- a/gui/src/rust/ide/view/graph-editor/src/builtin/visualization/java_script.rs
+++ b/gui/src/rust/ide/view/graph-editor/src/builtin/visualization/java_script.rs
@@ -20,6 +20,16 @@ pub fn table_visualization() -> visualization::java_script::FallibleDefinition {
visualization::java_script::Definition::new_builtin(source)
}
+/// Return a `JavaScript` SQL visualization.
+pub fn sql_visualization() -> visualization::java_script::FallibleDefinition {
+ let loading_scripts = include_str!("java_script/helpers/loading.js");
+ let scrollable = include_str!("java_script/helpers/scrollable.js");
+ let source = include_str!("java_script/sql.js");
+ let source = format!("{}{}{}",loading_scripts,scrollable,source);
+
+ visualization::java_script::Definition::new_builtin(source)
+}
+
/// Return a `JavaScript` Scatter plot visualization.
pub fn scatter_plot_visualization() -> visualization::java_script::FallibleDefinition {
let loading_scripts = include_str!("java_script/helpers/loading.js");
diff --git a/gui/src/rust/ide/view/graph-editor/src/builtin/visualization/java_script/geoMap.js b/gui/src/rust/ide/view/graph-editor/src/builtin/visualization/java_script/geoMap.js
index 72a1b8190ea..d1d387c9107 100644
--- a/gui/src/rust/ide/view/graph-editor/src/builtin/visualization/java_script/geoMap.js
+++ b/gui/src/rust/ide/view/graph-editor/src/builtin/visualization/java_script/geoMap.js
@@ -127,7 +127,7 @@ class GeoMapVisualization extends Visualization {
columns = df.select ['label', 'latitude', 'longitude'] . columns
serialized = columns.map (c -> ['df_' + c.name, c.to_vector])
Json.from_pairs serialized . to_text
- _ -> df . to_json . to_text
+ _ -> df . to_json . to_text
`)
}
diff --git a/gui/src/rust/ide/view/graph-editor/src/builtin/visualization/java_script/sql.js b/gui/src/rust/ide/view/graph-editor/src/builtin/visualization/java_script/sql.js
new file mode 100644
index 00000000000..8d227266d1a
--- /dev/null
+++ b/gui/src/rust/ide/view/graph-editor/src/builtin/visualization/java_script/sql.js
@@ -0,0 +1,440 @@
+/** SQL visualization. */
+
+// =================
+// === Constants ===
+// =================
+
+/** The qualified name of the Text type. */
+const textType = 'Builtins.Main.Text'
+
+/** The module prefix added for unknown SQL types. */
+const customSqlTypePrefix = 'Standard.Database.Data.Sql.Sql_Type.'
+
+/** Specifies opacity of interpolation background color. */
+const interpolationBacgroundOpacity = 0.3
+
+/** The CSS styles for the visualization. */
+const visualizationStyle = `
+
+ `
+
+// =============================
+// === Script Initialisation ===
+// =============================
+
+loadScript('https://cdnjs.cloudflare.com/ajax/libs/sql-formatter/4.0.2/sql-formatter.min.js')
+
+// ===========================
+// === Table visualization ===
+// ===========================
+
+/**
+ * A visualization that pretty-prints generated SQL code and displays type hints related to
+ * interpolated query parameters.
+ */
+class SqlVisualization extends Visualization {
+ // TODO Change the type below once #837 is done:
+ // 'Standard.Database.Data.Table.Table | Standard.Database.Data.Column.Column'
+ static inputType = 'Any'
+ static label = 'SQL Query'
+
+ constructor(api) {
+ super(api)
+ this.setPreprocessorModule('Standard.Visualization.Sql.Visualization')
+ this.setPreprocessorCode(`x -> here.prepare_visualization x`)
+ }
+
+ onDataReceived(data) {
+ this.removeAllChildren()
+
+ let parsedData = data
+ if (typeof data === 'string') {
+ parsedData = JSON.parse(data)
+ }
+
+ let visHtml = visualizationStyle
+ if (parsedData.error !== undefined) {
+ visHtml += parsedData.error
+ } else {
+ const params = parsedData.interpolations.map(param =>
+ renderInterpolationParameter(this.theme, param)
+ )
+
+ let language = 'sql'
+ if (parsedData.dialect == 'postgresql') {
+ language = 'postgresql'
+ }
+
+ const formatted = sqlFormatter.format(parsedData.code, {
+ params: params,
+ language: language,
+ })
+
+ const codeRepresentation = '
' + formatted + '
'
+ visHtml += codeRepresentation
+ }
+
+ const containers = this.createContainers()
+ const parentContainer = containers.parent
+ containers.scrollable.innerHTML = visHtml
+ this.dom.appendChild(parentContainer)
+
+ const tooltip = new Tooltip(parentContainer)
+ const baseMismatches = this.dom.getElementsByClassName('mismatch')
+ const extendedMismatchAreas = this.dom.getElementsByClassName('mismatch-mouse-area')
+ setupMouseInteractionForMismatches(tooltip, baseMismatches)
+ setupMouseInteractionForMismatches(tooltip, extendedMismatchAreas)
+ }
+
+ /**
+ * Removes all children of this visualization's DOM.
+ *
+ * May be used to reset the visualization's content.
+ */
+ removeAllChildren() {
+ while (this.dom.firstChild) {
+ this.dom.removeChild(this.dom.lastChild)
+ }
+ }
+
+ /**
+ * Creates containers for the visualization.
+ */
+ createContainers() {
+ const parentContainer = document.createElement('div')
+ parentContainer.setAttributeNS(null, 'style', 'position: relative;')
+ const width = this.dom.getAttributeNS(null, 'width')
+ const height = this.dom.getAttributeNS(null, 'height')
+ const scrollable = document.createElement('div')
+ scrollable.setAttributeNS(null, 'id', 'vis-sql-view')
+ scrollable.setAttributeNS(null, 'class', 'scrollable')
+ scrollable.setAttributeNS(null, 'viewBox', '0 0 ' + width + ' ' + height)
+ scrollable.setAttributeNS(null, 'width', '100%')
+ scrollable.setAttributeNS(null, 'height', '100%')
+ const viewStyle = `width: ${width - 5}px;
+ height: ${height - 5}px;
+ overflow: scroll;
+ padding:2.5px;`
+ scrollable.setAttributeNS(null, 'style', viewStyle)
+ parentContainer.appendChild(scrollable)
+ return {
+ parent: parentContainer,
+ scrollable: scrollable,
+ }
+ }
+
+ setSize(size) {
+ this.dom.setAttributeNS(null, 'width', size[0])
+ this.dom.setAttributeNS(null, 'height', size[1])
+ }
+}
+
+// === Handling Colors ===
+
+/**
+ * Renders a 4-element array representing a color into a CSS-compatible rgba string.
+ */
+function convertColorToRgba(color) {
+ const r = 255 * color.red
+ const g = 255 * color.green
+ const b = 255 * color.blue
+ const a = color.alpha
+ return 'rgba(' + r + ',' + g + ',' + b + ',' + a + ')'
+}
+
+/** Replaces the alpha component of a color (represented as a 4-element array),
+ * returning a new color.
+ */
+function replaceAlpha(color, newAlpha) {
+ return {
+ red: color.red,
+ green: color.green,
+ blue: color.blue,
+ alpha: newAlpha,
+ }
+}
+
+// === HTML Rendering Helpers ===
+
+/**
+ * Renders a HTML representation of a message to be displayed in a tooltip,
+ * which explains a type mismatch.
+ */
+function renderTypeHintMessage(
+ receivedTypeName,
+ expectedTypeName,
+ receivedTypeColor,
+ expectedTypeColor
+) {
+ const received = new QualifiedTypeName(receivedTypeName)
+ const expected = new QualifiedTypeName(expectedTypeName)
+
+ let receivedPrefix = ''
+ if (received.moduleName !== null) {
+ receivedPrefix = '' + received.moduleName + '.'
+ }
+ const receivedStyledSpan = ''
+ const receivedSuffix = receivedStyledSpan + received.name + ''
+
+ let expectedPrefix = ''
+ if (expected.moduleName !== null) {
+ expectedPrefix = '' + expected.moduleName + '.'
+ }
+ const expectedStyledSpan = ''
+ const expectedSuffix = expectedStyledSpan + expected.name + ''
+
+ let message = 'Received ' + receivedPrefix + receivedSuffix + '
'
+ message += 'Expected ' + expectedPrefix + expectedSuffix + '
'
+ message += 'The database may perform an auto conversion.'
+ return message
+}
+
+/**
+ * Wraps a qualified type name.
+ *
+ * The `moduleName` field is the name of the module the type is from. It may be null if the original
+ * type name did not contain a module name.
+ * The `name` field is the simple name of the type itself.
+ */
+class QualifiedTypeName {
+ /** Creates a QualifiedTypeName instance from a string representation. */
+ constructor(typeName) {
+ let ix = typeName.lastIndexOf('.')
+ if (ix < 0) {
+ this.moduleName = null
+ this.name = typeName
+ } else {
+ this.moduleName = typeName.substr(0, ix)
+ this.name = typeName.substr(ix + 1)
+ }
+ }
+}
+
+/**
+ * Renders HTML for displaying an Enso parameter that is interpolated into the SQL code.
+ */
+function renderInterpolationParameter(theme, param) {
+ const actualType = param.actual_type
+ let value = param.value
+
+ if (actualType == textType) {
+ value = "'" + value.replaceAll("'", "''") + "'"
+ }
+
+ const actualTypeColor = theme.getColorForType(actualType)
+ const fgColor = actualTypeColor
+ let bgColor = replaceAlpha(fgColor, interpolationBacgroundOpacity)
+ const expectedEnsoType = param.expected_enso_type
+
+ if (actualType == expectedEnsoType) {
+ return renderRegularInterpolation(value, fgColor, bgColor)
+ } else {
+ let expectedType = expectedEnsoType
+ if (expectedType === null) {
+ expectedType = customSqlTypePrefix + param.expected_sql_type
+ }
+
+ const expectedTypeColor = theme.getColorForType(expectedType)
+ const hoverBgColor = expectedTypeColor
+ bgColor = replaceAlpha(hoverBgColor, interpolationBacgroundOpacity)
+ const hoverFgColor = theme.getForegroundColorForType(expectedType)
+
+ const message = renderTypeHintMessage(
+ actualType,
+ expectedType,
+ actualTypeColor,
+ expectedTypeColor
+ )
+
+ return renderMismatchedInterpolation(
+ value,
+ message,
+ fgColor,
+ bgColor,
+ hoverFgColor,
+ hoverBgColor
+ )
+ }
+}
+
+/**
+ * A helper that renders the HTML representation of a regular SQL interpolation.
+ */
+function renderRegularInterpolation(value, fgColor, bgColor) {
+ let html =
+ ''
+ html += value
+ html += '
'
+ return html
+}
+
+/**
+ * A helper that renders the HTML representation of a type-mismatched SQL interpolation.
+ *
+ * This only prepares the HTML code, to setup the interactions, `setupMouseInteractionForMismatches`
+ * must be called after these HTML elements are added to the DOM.
+ */
+function renderMismatchedInterpolation(
+ value,
+ message,
+ fgColor,
+ bgColor,
+ hoverFgColor,
+ hoverBgColor
+) {
+ let html = ''
+ html += '
'
+ html += '
'
+ html += value
+ html += '
'
+ html += '
'
+ return html
+}
+
+// === Tooltip ===
+
+/**
+ * A hint tooltip that can be displayed above elements.
+ */
+class Tooltip {
+ constructor(container) {
+ this.tooltip = document.createElement('div')
+ this.tooltip.setAttributeNS(null, 'class', 'tooltip')
+ container.appendChild(this.tooltip)
+ this.tooltipOwner = null
+ }
+
+ /**
+ * Hides the tooltip.
+ *
+ * The actor parameter specifies who is initiating the hiding.
+ * If this method is called but the tooltip has got a new owner in the meantime, the call is
+ * ignored.
+ */
+ hide(actor) {
+ if (this.tooltipOwner === null || this.tooltipOwner == actor) {
+ this.tooltipOwner = null
+ this.tooltip.style.opacity = 0
+ }
+ }
+
+ /**
+ * Shows the tooltip above the element represented by `actor`.
+ *
+ * Tooltip content is specified by the `message` which can include arbitrary HTML.
+ */
+ show(actor, message) {
+ this.tooltipOwner = actor
+ this.tooltip.innerHTML = message
+ this.tooltip.style.opacity = 1
+
+ const interpolantContainer = actor.parentElement
+ const codeContainer = interpolantContainer.parentElement
+ const scrollElement = codeContainer.parentElement
+
+ const scrollOffsetX = scrollElement.scrollLeft
+ const scrollOffsetY = scrollElement.scrollTop + scrollElement.offsetHeight
+
+ const interpolantOffsetX = interpolantContainer.offsetLeft
+ const interpolantOffsetY = interpolantContainer.offsetTop
+
+ const centeringOffset = (interpolantContainer.offsetWidth - this.tooltip.offsetWidth) / 2
+ const belowPadding = 3
+ const belowOffset = interpolantContainer.offsetHeight + belowPadding
+
+ const x = interpolantOffsetX - scrollOffsetX + centeringOffset
+ const y = interpolantOffsetY - scrollOffsetY + belowOffset
+
+ this.tooltip.style.transform = 'translate(' + x + 'px, ' + y + 'px)'
+ }
+}
+
+/**
+ * Sets up mouse events for the interpolated parameters that have a type mismatch.
+ */
+function setupMouseInteractionForMismatches(tooltip, elements) {
+ function interpolationMouseEnter(event) {
+ const target = this.parentElement.getElementsByClassName('mismatch')[0]
+ const fg = target.getAttribute('data-fgColorHover')
+ const bg = target.getAttribute('data-bgColorHover')
+ const message = decodeURIComponent(target.getAttribute('data-message'))
+ tooltip.show(target, message)
+ target.style.color = fg
+ target.style.backgroundColor = bg
+ }
+ function interpolationMouseLeave(event) {
+ const target = this.parentElement.getElementsByClassName('mismatch')[0]
+ const fg = target.getAttribute('data-fgColor')
+ const bg = target.getAttribute('data-bgColor')
+ target.style.color = fg
+ target.style.backgroundColor = bg
+ tooltip.hide(target)
+ }
+
+ for (let i = 0; i < elements.length; ++i) {
+ elements[i].addEventListener('mouseenter', interpolationMouseEnter)
+ elements[i].addEventListener('mouseleave', interpolationMouseLeave)
+ }
+}
+
+return SqlVisualization
diff --git a/gui/src/rust/ide/view/graph-editor/src/builtin/visualization/java_script/table.js b/gui/src/rust/ide/view/graph-editor/src/builtin/visualization/java_script/table.js
index d661170cc4f..efac0e95abc 100644
--- a/gui/src/rust/ide/view/graph-editor/src/builtin/visualization/java_script/table.js
+++ b/gui/src/rust/ide/view/graph-editor/src/builtin/visualization/java_script/table.js
@@ -16,16 +16,8 @@ class TableVisualization extends Visualization {
constructor(data) {
super(data)
- this.setPreprocessorModule('Standard.Table')
- this.setPreprocessorCode(`
- x -> case x of
- Table.Table _ ->
- header = ["header", x.columns.map .name]
- data = ["data", x.columns.map .to_vector . map (x -> x.take_start 2000) ]
- pairs = [header,data]
- Json.from_pairs pairs . to_text
- _ -> x . to_json . to_text
- `)
+ this.setPreprocessorModule('Standard.Visualization.Table.Visualization')
+ this.setPreprocessorCode(`x -> here.prepare_visualization x 1000`)
}
onDataReceived(data) {
@@ -132,9 +124,13 @@ class TableVisualization extends Visualization {
function genGenericTable(data, level) {
let result = ''
- data.forEach((point, ix) => {
- result += '' + ix + ' | ' + toTableCell(point, level) + '
'
- })
+ if (Array.isArray(data)) {
+ data.forEach((point, ix) => {
+ result += '' + ix + ' | ' + toTableCell(point, level) + '
'
+ })
+ } else {
+ result += '' + toTableCell(data, level) + '
'
+ }
return tableOf(result, level)
}
@@ -174,52 +170,137 @@ class TableVisualization extends Visualization {
}
}
+ function genDataframe(parsedData) {
+ let result = ''
+ function addHeader(content) {
+ result += '' + content + ' | '
+ }
+ function addCell(content) {
+ result += '' + content + ' | '
+ }
+ result += ''
+ parsedData.indices_header.forEach(addHeader)
+ parsedData.header.forEach(addHeader)
+ result += '
'
+ let rows = 0
+ if (parsedData.data.length > 0) {
+ rows = parsedData.data[0].length
+ } else if (parsedData.indices.length > 0) {
+ rows = parsedData.indices[0].length
+ }
+ for (let i = 0; i < rows; ++i) {
+ result += ''
+ parsedData.indices.forEach(ix => addHeader(ix[i]))
+ parsedData.data.forEach(col => addCell(col[i]))
+ result += '
'
+ }
+ return tableOf(result, 0)
+ }
+
while (this.dom.firstChild) {
this.dom.removeChild(this.dom.lastChild)
}
const style_dark = `
`
const style_light = `
`
const width = this.dom.getAttributeNS(null, 'width')
@@ -255,8 +344,8 @@ class TableVisualization extends Visualization {
tabElem.setAttributeNS(null, 'viewBox', '0 0 ' + width + ' ' + height)
tabElem.setAttributeNS(null, 'width', '100%')
tabElem.setAttributeNS(null, 'height', '100%')
- const tblViewStyle = `width: ${width - 10}px;
- height: ${height - 10}px;
+ const tblViewStyle = `width: ${width - 5}px;
+ height: ${height - 5}px;
overflow: scroll;
padding:2.5px;`
tabElem.setAttributeNS(null, 'style', tblViewStyle)
@@ -271,8 +360,34 @@ class TableVisualization extends Visualization {
if (document.getElementById('root').classList.contains('dark-theme')) {
style = style_dark
}
- const table = genTable(parsedData.data || parsedData, 0, parsedData.header)
- tabElem.innerHTML = style + table
+
+ if (parsedData.error !== undefined) {
+ tabElem.innerHTML = 'Error: ' + parsedData.error
+ } else if (parsedData.json !== undefined) {
+ const table = genTable(parsedData.json, 0, undefined)
+ tabElem.innerHTML = style + table
+ } else {
+ const table = genDataframe(parsedData)
+ let suffix = ''
+ const allRowsCount = parsedData.all_rows_count
+ if (allRowsCount !== undefined) {
+ const includedRowsCount = parsedData.data.length > 0 ? parsedData.data[0].length : 0
+ const hiddenCount = allRowsCount - includedRowsCount
+ if (hiddenCount > 0) {
+ let rows = 'rows'
+ if (hiddenCount == 1) {
+ rows = 'row'
+ }
+ suffix =
+ '… and ' +
+ hiddenCount +
+ ' more ' +
+ rows +
+ '.'
+ }
+ }
+ tabElem.innerHTML = style + table + suffix
+ }
}
setSize(size) {
diff --git a/gui/src/rust/ide/view/graph-editor/src/component/visualization/registry.rs b/gui/src/rust/ide/view/graph-editor/src/component/visualization/registry.rs
index 33bde3304d8..f5a8b1a784f 100644
--- a/gui/src/rust/ide/view/graph-editor/src/component/visualization/registry.rs
+++ b/gui/src/rust/ide/view/graph-editor/src/component/visualization/registry.rs
@@ -86,6 +86,7 @@ impl Registry {
self.try_add_java_script(builtin::visualization::java_script::scatter_plot_visualization());
self.try_add_java_script(builtin::visualization::java_script::histogram_visualization());
self.try_add_java_script(builtin::visualization::java_script::table_visualization());
+ self.try_add_java_script(builtin::visualization::java_script::sql_visualization());
self.try_add_java_script(builtin::visualization::java_script::geo_map_visualization());
}