Skip to content

Commit e98306f

Browse files
authored
Excel DataLink (#9346)
- Adds the Excel format as one of the formats supported when creating a data link. - The data link can choose to read the file as a workbook, or read a sheet or range from it as a table, like `Excel_Format`. - Also updated Delimited format dialog to allow customizing the quote style.
1 parent 1f6db1e commit e98306f

20 files changed

Lines changed: 357 additions & 23 deletions

File tree

CHANGELOG.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -628,6 +628,7 @@
628628
- [Added `Xml_Document.write`][9299]
629629
- [Added `select_by_type` and `remove_by_type` to `Table` and `DB_Table`][9334]
630630
- [Make File./ only accept Text][9330]
631+
- [Implemented Excel Data Link][9346]
631632

632633
[debug-shortcuts]:
633634
https://github.com/enso-org/enso/blob/develop/app/gui/docs/product/shortcuts.md#debug
@@ -909,8 +910,9 @@
909910
[9249]: https://github.com/enso-org/enso/pull/9249
910911
[9269]: https://github.com/enso-org/enso/pull/9269
911912
[9299]: https://github.com/enso-org/enso/pull/9299
912-
[9344]: https://github.com/enso-org/enso/pull/9344
913913
[9330]: https://github.com/enso-org/enso/pull/9330
914+
[9334]: https://github.com/enso-org/enso/pull/9334
915+
[9346]: https://github.com/enso-org/enso/pull/9346
914916

915917
#### Enso Compiler
916918

app/ide-desktop/lib/dashboard/src/data/__tests__/dataLinkSchema.test.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,20 @@ v.test('correctly validates example S3 .datalink files with the schema', () => {
6666
}
6767
})
6868

69+
v.test('correctly validates example Table .datalink files with the schema', () => {
70+
const schemas = [
71+
'example-http-format-excel-workbook.datalink',
72+
'example-http-format-excel-sheet.datalink',
73+
'example-http-format-excel-range.datalink',
74+
'example-http-format-delimited-custom-quote.datalink',
75+
'example-http-format-delimited-ignore-quote.datalink',
76+
]
77+
for (const schema of schemas) {
78+
const json = loadDataLinkFile(path.resolve(TABLE_DATA_LINKS_ROOT, schema))
79+
testSchema(json, schema)
80+
}
81+
})
82+
6983
v.test('correctly validates example Database .datalink files with the schema', () => {
7084
const schemas = ['postgres-db.datalink', 'postgres-table.datalink']
7185
for (const schema of schemas) {

app/ide-desktop/lib/dashboard/src/data/dataLinkSchema.json

Lines changed: 136 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,13 @@
3030
},
3131
"required": ["type", "secretPath"]
3232
},
33+
"BooleanOrInfer": {
34+
"anyOf": [
35+
{ "title": "Infer", "const": "infer", "type": "string" },
36+
{ "title": "True", "const": true, "type": "boolean" },
37+
{ "title": "False", "const": false, "type": "boolean" }
38+
]
39+
},
3340

3441
"AwsAuth": {
3542
"title": "AWS Authentication",
@@ -174,7 +181,8 @@
174181
"anyOf": [
175182
{ "$ref": "#/$defs/DefaultFormat" },
176183
{ "$ref": "#/$defs/DelimitedFormat" },
177-
{ "$ref": "#/$defs/JsonFormat" }
184+
{ "$ref": "#/$defs/JsonFormat" },
185+
{ "$ref": "#/$defs/ExcelFormat" }
178186
]
179187
},
180188
"DefaultFormat": {
@@ -207,10 +215,17 @@
207215
"headers": {
208216
"title": "Headers",
209217
"description": "Whether a header row containing column names is present.",
210-
"type": "boolean"
218+
"$ref": "#/$defs/BooleanOrInfer"
219+
},
220+
"quote_style": {
221+
"title": "Custom Quotes",
222+
"anyOf": [
223+
{ "$ref": "#/$defs/DelimitedQuoteStyle" },
224+
{ "$ref": "#/$defs/DelimitedQuoteStyleNo" }
225+
]
211226
}
212227
},
213-
"required": ["type", "subType", "delimiter"]
228+
"required": ["type", "subType", "delimiter", "headers"]
214229
},
215230
"JsonFormat": {
216231
"title": "JSON",
@@ -220,6 +235,124 @@
220235
"subType": { "title": "Type", "const": "json", "type": "string" }
221236
},
222237
"required": ["type", "subType"]
238+
},
239+
"ExcelFormat": {
240+
"title": "Excel",
241+
"type": "object",
242+
"properties": {
243+
"type": { "title": "Type", "const": "format", "type": "string" },
244+
"subType": { "title": "Type", "const": "excel", "type": "string" },
245+
"section": {
246+
"title": "Section",
247+
"anyOf": [
248+
{ "$ref": "#/$defs/ExcelSectionWorkbook" },
249+
{ "$ref": "#/$defs/ExcelSectionSheet" },
250+
{ "$ref": "#/$defs/ExcelSectionRange" }
251+
]
252+
}
253+
},
254+
"required": ["type", "subType", "section"]
255+
},
256+
257+
"ExcelSectionWorkbook": {
258+
"title": "Workbook",
259+
"type": "object",
260+
"properties": {
261+
"type": { "const": "workbook", "type": "string" },
262+
"defaultSheet": {
263+
"title": "Default sheet",
264+
"$comment": "I want the title to be 'Default sheet (for writing)' to make it clear without hovering, but then the label is too wide. Can we make the modal wider?",
265+
"description": "The default sheet used when writing a table to this data link.",
266+
"type": "string",
267+
"minLength": 1,
268+
"default": "EnsoSheet"
269+
}
270+
},
271+
"required": ["type"]
272+
},
273+
"ExcelSectionSheet": {
274+
"title": "Sheet",
275+
"type": "object",
276+
"properties": {
277+
"type": { "const": "sheet", "type": "string" },
278+
"name": {
279+
"title": "Sheet name",
280+
"description": "Must not be blank.",
281+
"type": "string",
282+
"minLength": 1
283+
},
284+
"headers": {
285+
"title": "Headers",
286+
"description": "Whether a header row containing column names is present at the top of the sheet.",
287+
"$ref": "#/$defs/BooleanOrInfer"
288+
}
289+
},
290+
"required": ["type", "name", "headers"]
291+
},
292+
"ExcelSectionRange": {
293+
"title": "Range",
294+
"type": "object",
295+
"properties": {
296+
"type": { "const": "range", "type": "string" },
297+
"address": {
298+
"title": "Address",
299+
"description": "Must not be blank.",
300+
"type": "string",
301+
"minLength": 1,
302+
"examples": ["Sheet1!A1:B2"]
303+
},
304+
"headers": {
305+
"title": "Headers",
306+
"description": "Whether a header row containing column names is present at the top of the range.",
307+
"$ref": "#/$defs/BooleanOrInfer"
308+
}
309+
},
310+
"required": ["type", "address", "headers"]
311+
},
312+
313+
"DelimitedQuoteStyle": {
314+
"title": "Custom quotes",
315+
"type": "object",
316+
"properties": {
317+
"type": { "title": "Type", "const": "quote_style", "type": "string" },
318+
"subType": {
319+
"title": "Subtype",
320+
"const": "with_quotes",
321+
"type": "string"
322+
},
323+
"quote": {
324+
"title": "Quote",
325+
"description": "Must be exactly one character.",
326+
"type": "string",
327+
"minLength": 1,
328+
"maxLength": 1,
329+
"default": "\"",
330+
"examples": ["\"", "'"]
331+
},
332+
"escape": {
333+
"title": "Escape",
334+
"description": "Must be exactly one character. The character that is prepended to the quote character to escape it.",
335+
"type": "string",
336+
"minLength": 1,
337+
"maxLength": 1,
338+
"default": "\"",
339+
"examples": ["\"", "\\"]
340+
}
341+
},
342+
"required": ["type", "subType", "quote", "escape"]
343+
},
344+
"DelimitedQuoteStyleNo": {
345+
"type": "object",
346+
"title": "Ignore quotes",
347+
"properties": {
348+
"type": { "title": "Type", "const": "quote_style", "type": "string" },
349+
"subType": {
350+
"title": "Subtype",
351+
"const": "no_quotes",
352+
"type": "string"
353+
}
354+
},
355+
"required": ["type", "subType"]
223356
}
224357
}
225358
}

distribution/lib/Standard/Base/0.0.0-dev/src/Enso_Cloud/Internal/Enso_Path.enso

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -27,10 +27,13 @@ type Enso_Path
2727
if raw_segments.is_empty then Error.throw (Illegal_Argument.Error "Invalid path - it should contain at least one segment.") else
2828
organization_name = raw_segments.first
2929
segments = raw_segments.drop 1 . filter s-> s.is_empty.not
30-
if organization_name != Enso_User.current.name then Error.throw (Unimplemented.throw "Currently only resolving paths for the current user is supported.") else
31-
if segments.is_empty then Enso_Path.Value organization_name [] Nothing else
32-
asset_name = segments.last
33-
Enso_Path.Value organization_name (segments.drop (Index_Sub_Range.Last 1)) asset_name
30+
current_user_name = Enso_User.current.name
31+
# The `if_not_error` is a workaround for https://github.com/enso-org/enso/issues/9283 and it can be removed after that is fixed.
32+
current_user_name.if_not_error <|
33+
if organization_name != current_user_name then Unimplemented.throw "Currently only resolving paths for the current user is supported." else
34+
if segments.is_empty then Enso_Path.Value organization_name [] Nothing else
35+
asset_name = segments.last
36+
Enso_Path.Value organization_name (segments.drop (Index_Sub_Range.Last 1)) asset_name
3437

3538
## PRIVATE
3639
resolve_parent self =

distribution/lib/Standard/Base/0.0.0-dev/src/System/File_Format.enso

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import project.Any.Any
22
import project.Data.Json.JS_Object
33
import project.Data.Json.Json
44
import project.Data.Numbers.Integer
5+
import project.Data.Text.Case.Case
56
import project.Data.Text.Encoding.Encoding
67
import project.Data.Text.Text
78
import project.Data.Vector.Vector
@@ -245,3 +246,17 @@ JSON_Format.from (that : JS_Object) =
245246

246247
## A setting to infer the default behaviour of some option.
247248
type Infer
249+
250+
251+
## PRIVATE
252+
A helper for parsing the JSON representation of `Boolean | Infer`.
253+
It defaults to `Infer` if the value was `Nothing`.
254+
parse_boolean_with_infer (field_name : Text) (value : Boolean | Text | Nothing) -> Boolean | Infer = case value of
255+
True -> True
256+
False -> False
257+
Nothing -> Infer
258+
headers_text : Text -> case headers_text.to_case Case.Lower of
259+
"infer" -> Infer
260+
"true" -> True
261+
"false" -> False
262+
_ -> Error.throw (Illegal_Argument.Error ("The field `"+field_name+"` must be a boolean or the string `infer`."))

distribution/lib/Standard/Table/0.0.0-dev/src/Delimited/Delimited_Format.enso

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import Standard.Base.System.File_Format_Metadata.File_Format_Metadata
66
import Standard.Base.System.Input_Stream.Input_Stream
77
from Standard.Base.Metadata.Choice import Option
88
from Standard.Base.Widget_Helpers import make_file_read_delimiter_selector
9+
from Standard.Base.System.File_Format import parse_boolean_with_infer
910

1011
import project.Data.Data_Formatter.Data_Formatter
1112
import project.Data.Match_Columns.Match_Columns
@@ -169,14 +170,17 @@ Delimited_Format.from (that : JS_Object) =
169170
encoding = encoding_name
170171
. if_not_nothing (Encoding.from_name encoding_name)
171172
. if_nothing Encoding.utf_8
172-
headers = that.get "headers" . if_nothing Infer
173+
headers = that.get "headers" |> parse_boolean_with_infer "headers"
173174
skip_rows = that.get "skip_rows" . if_nothing 0
174175
row_limit = that.get "row_limit"
175176
keep_invalid_rows = that.get "keep_invalid_rows" . if_nothing True
177+
quote_style = case that.get "quote_style" of
178+
Nothing -> Quote_Style.With_Quotes
179+
json -> Quote_Style.from json
176180

177-
unsupported_fields = ["quote_style", "value_formatter", "line_endings", "comment_character"]
181+
unsupported_fields = ["value_formatter", "line_endings", "comment_character"]
178182
case unsupported_fields.find that.contains_key if_missing=Nothing of
179183
Nothing ->
180-
Delimited_Format.Delimited delimiter=delimiter encoding=encoding headers=headers skip_rows=skip_rows row_limit=row_limit keep_invalid_rows=keep_invalid_rows
184+
Delimited_Format.Delimited delimiter=delimiter encoding=encoding headers=headers skip_rows=skip_rows row_limit=row_limit quote_style=quote_style keep_invalid_rows=keep_invalid_rows
181185
field ->
182186
Error.throw (Illegal_Argument.Error ("The field `" ++ field ++ "` is currently not supported when deserializing the Delimited format from JSON."))

distribution/lib/Standard/Table/0.0.0-dev/src/Delimited/Quote_Style.enso

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
from Standard.Base import all
2+
import Standard.Base.Errors.Illegal_Argument.Illegal_Argument
23

34
type Quote_Style
45
## Does not handle quotes at all.
@@ -30,4 +31,16 @@ type Quote_Style
3031
The quote and escape characters must consist of exactly one code-point
3132
(i.e. it can be only one character and complex characters like emojis
3233
may not be used).
33-
With_Quotes (always_quote : Boolean = False) (quote : Text = '"') (quote_escape : Text = '"')
34+
With_Quotes (always_quote : Boolean = False) (quote : Text = '"') (quote_escape : Text = quote)
35+
36+
## PRIVATE
37+
Quote_Style.from (that : JS_Object) =
38+
sub_type = that.get "subType" if_missing=(Error.throw (Illegal_Argument.Error "Missing `subType` field in quote style."))
39+
case sub_type.to_case Case.Lower of
40+
"no_quotes" -> Quote_Style.No_Quotes
41+
"with_quotes" ->
42+
always_quote = that.get "alwaysQuote" if_missing=False
43+
quote = that.get "quote" if_missing='"'
44+
escape = that.get "escape" if_missing=quote
45+
Quote_Style.With_Quotes always_quote quote escape
46+
_ -> Error.throw (Illegal_Argument.Error ("Unknown quote style: " + sub_type))

distribution/lib/Standard/Table/0.0.0-dev/src/Excel/Excel_Format.enso

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import Standard.Base.System.File_Format_Metadata.File_Format_Metadata
66
import Standard.Base.System.Input_Stream.Input_Stream
77
from Standard.Base.Metadata.Choice import Option
88
from Standard.Base.Metadata.Widget import Text_Input, Numeric_Input
9+
from Standard.Base.System.File_Format import parse_boolean_with_infer
910

1011
import project.Data.Match_Columns.Match_Columns
1112
import project.Data.Table.Table
@@ -189,3 +190,22 @@ as_section (format : Excel_Format) -> Excel_Section = case format of
189190
Excel_Section.Worksheet sheet headers skip_rows row_limit
190191
Excel_Format.Range address headers skip_rows row_limit _ ->
191192
Excel_Section.Cell_Range address headers skip_rows row_limit
193+
194+
## PRIVATE
195+
Constructs an `Excel_Format` instance from JSON.
196+
Only a subset of options is currently supported.
197+
Excel_Format.from (that : JS_Object) =
198+
section = that.get "section" if_missing=(Error.throw (Illegal_Argument.Error "The `section` field is required."))
199+
case section.get "type" if_missing=(Error.throw (Illegal_Argument.Error "The `section.type` field is required.")) . to_case Case.Lower of
200+
"workbook" ->
201+
default_sheet = section.get "default_sheet" if_missing="EnsoSheet"
202+
Excel_Format.Workbook default_sheet=default_sheet
203+
"sheet" ->
204+
name = section.get "name" if_missing=(Error.throw (Illegal_Argument.Error "The `name` field is required."))
205+
headers = section.get "headers" |> parse_boolean_with_infer "headers"
206+
Excel_Format.Sheet sheet=name headers=headers
207+
"range" ->
208+
address = section.get "address" if_missing=(Error.throw (Illegal_Argument.Error "The `address` field is required."))
209+
headers = section.get "headers" |> parse_boolean_with_infer "headers"
210+
Excel_Format.Range address=address headers=headers
211+
unknown -> Error.throw (Illegal_Argument.Error "The `section.type` was "+unknown+" but it must be one of `workbook`, `sheet`, or `range`.")

std-bits/base/src/main/java/org/enso/base/file_format/FileFormatSPI.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ public static Value findFormatForDataLinkSubType(String subType) {
2222

2323
var providers =
2424
loader.stream()
25-
.filter(provider -> subType.equals(provider.get().getDataLinkFormatName()))
25+
.filter(provider -> subType.equalsIgnoreCase(provider.get().getDataLinkFormatName()))
2626
.toList();
2727
if (providers.isEmpty()) {
2828
return null;

std-bits/table/src/main/java/org/enso/table/read/ExcelFormatSPI.java

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,4 +13,9 @@ protected String getModuleName() {
1313
protected String getTypeName() {
1414
return "Excel_Format";
1515
}
16+
17+
@Override
18+
protected String getDataLinkFormatName() {
19+
return "excel";
20+
}
1621
}

0 commit comments

Comments
 (0)