413: Spec for CSV parsing with fn:parse-csv() - #533
Conversation
|
Looks like I need to remind myself how to sign commits... |
|
@fidothe Thank you! As the author of a book on XQuery that has a whole section on parsing CSV with XQuery 3.1, I know that readers will appreciate having this built in to the language! I wonder if you would consider a slightly different format - returning header and body rows in arrays instead of sequences. So instead of: map{"headers": map{"name":1, "city":2}, "body": [("Bob","Berlin"), ("Alice","Aachen")]}It would return: map{"headers": ["name", "city"], "body": [["Bob","Berlin"], ["Alice","Aachen"]]}Using your format, I struggled to write a function that would transform the parsed CSV into an HTML table—a standard use case. But using mine, this would be straightforward: xquery version "3.1";
declare function local:csv-to-table($csv-map) {
<table>
<thead>
<tr>{
for $header in $csv-map?headers?*
return
<th>{$header}</th>
}</tr>
</thead>
<tbody>{
for $row in $csv-map?body?*
return
<tr>{
for $cell in $row?*
return
<td>{$cell}</td>
}</tr>
}</tbody>
</table>
};
let $parsed-csv := map{"headers": ["name", "city"], "body": [["Bob","Berlin"], ["Alice","Aachen"]]}
return
local:csv-to-table($parsed-csv)This returns: <table>
<thead>
<tr>
<th>name</th>
<th>city</th>
</tr>
</thead>
<tbody>
<tr>
<td>Bob</td>
<td>Berlin</td>
</tr>
<tr>
<td>Alice</td>
<td>Aachen</td>
</tr>
</tbody>
</table>The difficulty with your format is in getting at the rows enclosing the cells. I think users will have to use xquery version "3.1";
declare function local:csv-to-table($csv-map) {
<table>
<thead>
<tr>{
for $header in map:keys($csv-map?headers)
return
<th>{$header}</th>
}</tr>
</thead>
<tbody>{
array:for-each(
$csv-map?body,
function($sequence) {
<tr>{
for $item in $sequence
return
<td>{$item}</td>
}</tr>}
)
}</tbody>
</table>
};
let $parsed-csv := map{"headers": map{"name":1, "city":2}, "body": [("Bob","Berlin"), ("Alice","Aachen")]}
return
local:csv-to-table($parsed-csv)Particularly for the body rows, I think most XQuery users would prefer the convenience of querying nested arrays over the complexity of arrays of sequences. |
|
I committed the cardinal sin of going on holiday the day after opening this PR, so I am writing this from a beach on a phone… I need to have access to a computer to check, but I thought I could use arrays-of-sequences to build a and it sounds like for-ing over an array rather than a sequence has more problems than my basic XQuery and reading of the spec suggested. I have some specific issues with arrays-of-fields around behaviour when accessing fields missing because of variable-length rows/records, but I’ll wait till I’m back to continue this discussion… |
|
We're introducing "for member $x in $array" so iterating over an array becomes easier... |
|
Having established that my understanding of <tbody>{
for member $row in $csv-map?body
return
<tr>{
for $cell in $row return <td>{$cell}</td>
}</tr>
}</tbody>I still think array-of-sequences is the best option for the low-level case because access by field index (directly or via a header lookup) cannot be guaranteed not to raise an exception if the record is an array thanks to being unable to guarantee all records have the same number of fields. I also think that the straightforward just-build-a-table-in-XML case, where the records and fields of a CSV are effectively one-to-one mapped onto rows and cells in an XML/HTML table, should be better supported, but I'm still trying to figure out whether that should be via more options to There are also a couple of other basic options I'm thinking about for |
|
I am inclined to agree with @joewiz that just returning a map of the headers is insufficient. Given that we won't be returning duplicate header strings, or empty-string header strings, you'd need to use |
|
I've now added a |
|
Some comments. Starting with the examples in 15.7.3 First example: Is the escape sequence \r\n correct? XPath doesn't recognise this as representing a newline, is parse-csv supposed to recognise it? Note that an XPath string can contain a literal newline: let $csv := I wonder if the name "columns" might be better than "headers"? I'm also wondering if the returned record could include an arity-2 function "cell" allowing Second example Is (1) missing from the extraction of the first-name? I'm not sure why you would want to do it this way. Wouldn't it be simpler to do: Also this is a good case for a string template: let $row := $data(1) Fourth example (15.7.3.2) I think it would be good to show this in XSLT as well as XQuery (and to identify the language used). Perhaps rename Shouldn't the I think I would write the 15.7.3.3 csv-to-xml This example doesn't suggest there is any significant benefit in using csv-to-xml in preference to doing it "by hand". 15.7.3.4 mapping field types The example uses the variable $headers before it is declared. Again an XSLT version would be nice. We should really have an fn:substitute() function so instead of you can write I'll add that as an issue... Now the spec of the function itself. There's a problem that If field-separator and quote-character are required to be single characters then we should say so and define an error code for when they aren't. I'm wondering about the precise relationship of our spec to the RFC. At the moment we just include it as a normative reference, without any glossing. But we're also clearly permitting variations, e.g. different characters for separators. Do we need to say a bit more about this? For example we're leaving the reader to work out that if the quote character is set to apostrophe, then it gets escaped as two apostrophes, but there's nothing either in our spec or the RFC that spells this out. Also the RFC uses "should" and "may" rather a lot (e.g. "Each line should contain the same number of fields throughout the file" (By "line" they mean "record"). Do we want to be more definitive? Must an implementation of our function accept a variable number of fields? I would expect to see some language along the lines of "An implementation MUST accept an input CSV that .... and MAY accept an input CSV that ....". I can't see the point of the return-headers option. If it's useful then add some motivation?
|
No, it's not correct. There's a tension here between an example that will run if copied and pasted into a script, and an example which communicates as simply as possible, and in the case of CSV there is a meaningful distinction between CRLF, CR, and LF, which needs to be unambiguously communicated. The main source of input for I presume this is also a problem when talking about the output of |
Perhaps "field" - the RFC 4180 terminology is "records" and "fields" rather than rows and columns, and I'm trying to follow that where possible. (All things being equal, I'd rather have "rows" and "columns", but following the RFC seems worth doing even if I am more used to the more spreadsheet-centric way of thinking about CSVs) |
I wondered about something like that, but including functions in the let $parsed-csv := fn:parse-csv("name,city\r\nBob,Berlin\r\n"),
$row := $parsed-csv?body(1)
return $parsed-csv?cell($row, "name") |
|
I'd suggest writing |
The default case, in most parser implementations, and in the latest draft of RFC 4180 (but not the published version), is to allow CRLF, CR, or LF and basically pick the one to use based on the first unquoted line ending candidate you encounter. Outside of that, I think we want to require a simple string record separator rather than permitting regexes. I'm just not sure how to represent that in the documentation.
That sounds good to me. |
There's a goal to ensure that all the information in the CSV is available without loss, but there's also a goal to provide the information with as much ease of use as possible. Adding one or more access functions alongside the raw data seems to achieve that. |
I suggest the default should be the same as for unparsed-text-lines: which is CRLF, CR, or LF with no requirement for consistency within the file. I think we just end up with the situation that the default is not one of the values that can be explicitly requested; the formal default becomes It might be worth a note that line-endings appearing in quoted fields are not normalized. Presumably trailing end-of-line spaces are also signficant. |
The RFC describes an idealised representation of a data format that has significant variation in the wild. The as-published RFC 4180 is a lot more conservative than the current drafts (Many Excel-produced CSVs of the time of its publication would not meet the spec in the RFC, and no CSVs produced in Germany or any other country using I think that I have made too many assumptions that implementations of As to the terminology, the RFC starts off using "record" and "field" to mean "row" and "column", but often slips into using "line" in place of "record". I would like to be consistent in our use of terminology, and would be happy to switch to "row" and "column" if people felt that the clash with XPath's "Record"-is-a-map-based-data-structure was too confusing. |
|
Sorry for my scarce feedback (I'm still abroad). As we already have |
|
For reference, the CoNLL-U format is defined at https://universaldependencies.org/format.html. That has a set of externally defined named headers. The extended format defines a custom set of columns as a comment on the first line -- that could be extracted and parsed separately, e.g. in combination with parsed-lines, so having the ability to pass the line sequence would be useful, as it would prevent reparsing the file. That format uses It would be useful to access these fields by name, e.g.: for $row in fn:parse-csv("en_ewt-ud-dev.conllu")
return $row?LEMMARegarding Sasha's performance comment, the English training file is 14MB, so it would be useful for implementors to be able to create a streaming implementation, e.g. by returning a sequence of arrays or maps instead of a single map with a body. |
|
I like the idea of a csv-to-xml function (along the lines of fn:analyze-string) which just delivers an XML structure of the form: For many XSLT users this may well be the simplest form of result to manipulate. I don't think there's even any need to customise the element names -- we don't do it with fn:analyze-string -- and people know how to transform this into the form they want. There could be an option to force-fit the column headers to NCNames so it becomes (Algorithm: simply replace any disallowed character by underscore; note that there's no need to make the names unique). |
In our implementation, we support different target formats for CSV via a
That’s also what we do if the target format is XML (with lax conversion enabled is enabled) if there’s no need to re-create the original structure. Empty strings are represented by a single underscore character. |
|
Based on feedback from the QTCG meeting I'm looking at implementing the following headline changes:
I'm going to learn more about the practicalities of streaming from both the user and implementer side and have another crack at those aspects, which may affect some of the above. |
ChristianGruen
left a comment
There was a problem hiding this comment.
(needs to be updated before it can be approved and merged)
ChristianGruen
left a comment
There was a problem hiding this comment.
(needs to be updated before it can be approved and merged)
|
The rewrite discussed on the QT4CG call on 2023-07-11 has been landed in 9e4331c. The biggest discussed issue remaining is functions for generating CSV strings from XDM input. There's also an open question about mapping from |
|
@ChristianGruen made a lot of typographic amends related to, primarily, proper typographic quotes, which I implemented. After that I realised that actually the FO spec almost entirely uses prime ( |
Thank you, Matt.
I hoped this was fixed. The formatting was aligned with the XSLT spec, which uses typographic quotes: https://github.com/qt4cg/qtspecs/pull/511/files Are you sure you are looking at the current version of the spec / can you give some examples? |
|
I fixed some remnants; maybe you’ve encountered exactly those: #634. |
|
I've squashed the commits and rebased off master ahead of the CG meeting today (2023-07-25). I've also tried signing the commit, but I'm not sure I've got that down correctly... |
* fn:parse-csv produces a simple sequence of arrays-of-strings from a CSV string. All more complex parsing (columns, filtering) is handled by new functions fn:csv-to-xdm and fn:csv-to-xml. * fn:csv-to-xdm handles processing CSV data into XDM structures. The record structures returned by this new function should be much more usable in XQuery settings. * fn:csv-to-xml to provide a simple representation of CSV data in XML, similar to fn:json-to-xml. This is primarily aimed at making many kinds of XSLT workflows simpler. A schema for the returned XML has been added. * Adds fn:csv-fetch-field-by-column to enable the behaviour of the function returned by csv-row-record?field (returned by fn:csv-to-xdm) to be more comprehensively documented, as well as allowing users to construct their own processing based on the fn:parse-csv primitive. Currently missing is an easy-to-understand way to let users map columns to richer data types than the default xs:string, along with functions to generate CSV data from XDM. Apostrophes and quotation marks in text in the added parts of the spec have been changed to use typographic quotes and true apostrophes.
|
Now with fixed commit signature |
| ["name", "city"] | ||
| ["Bob", "Berlin"], | ||
| ["Alice", "Aachen"] |
There was a problem hiding this comment.
There's a missing comma at the end of the first array in this sequence, as well at the end of the first array in the subsequent examples.
There was a problem hiding this comment.
Good catch, thanks.
| let $csv := fn:csv-to-xdm(`name,city{$crlf}Bob,Berlin`), | ||
| $headers := $parsed-csv?headers-record |
There was a problem hiding this comment.
I think $parsed-csv should be changed to $csv to match the name of the variable defined.
There was a problem hiding this comment.
Actually, line 7256 was supposed to have been deleted, sorry.
|
The CG agreed to merge this issue at meeting 043 |
* 471: Quotes (missing cases; see Matt’s comment in #533) * Single quotes fixed
This is a spec proposal for
fn:parse-csv()from #413.I've tried to cover off most of what was discussed in that issue, but I have avoided dealing with backlash escapes (per @ChristianGruen's early comment), sticking with the RFC 4180 quoting approach.
There are some issues with the structure where I tried to follow the existing structure of chapter 15, but that leaves the function definition in 15.4 separated by a lot of text before the wider format discussion in 15.7. The split between function def and context affects the JSON and HTML parsing functions too, so I have avoided trying to fix that as well in this PR.
If this meets with approval, I'll squash commits and rebase before merging.