A SQLite extension for reading large files line-by-line
github.com
github.com
I really dig SQLite, and I believe SQLite extensions will push it to another level. I rarely reach for Pandas or other "traditional" tools and query languages, and instead opt for plain ol' SQLite and other extensions. As a shameless plug, I recently started a blog series on SQLite and related tools and extensions if you want to learn more! Next week I'll be publishing more SQLite extensions for parsing HTML + making HTTP requests https://observablehq.com/@asg017/a-new-sqlite-blog-series
A few other SQLite extensions:
- xlite, for reading Excel files, in Rust https://github.com/x2bool/xlite
- sqlean, several small SQLite extensions in C https://github.com/nalgeon/sqlean
- mergestat, several SQLite extensions for developers (mainly Github's API) in Go https://github.com/mergestat/mergestat
I've played a tiny bit with SQLite Python extensions that perform HTTP requests, but I'm very unsure of the implications of this kind of thing.
What happens to the rest of the SQLite query engine if your custom function hits a page like https://httpbin.org/delay/10 with a deliberate 10 second delay before the request returns?
Maybe this is fine - your transaction takes 10 seconds longer but everything else continues to work as normal. I've not done the work to figure that out.
It'll come out in a week, but lmk if you want to play with it beforehand!
My guess is they'll be OK, but presumably any transactions will lock tables for the duration?
But if you're in journal mode then there's not much you can do. Please correct me if I'm wrong!
Hah, turns out I have an issue for that already, which I'd forgotten about: https://github.com/simonw/datasette-lite/issues/30
select
line -> '$.id' as id,
line -> '$.name' as name
from lines_read("my-file.ndjson");
This is using the new -> JSON operator which was added to SQLite a few months ago.The lines_read() thing there is a table-valued function, which means it returns a virtual table that you can query. This is a streaming operation which means it's safe to run it against a 100GB+ file without worrying about it sucking all of the data into memory at once.
Where this gets really useful is when you combine it with a create table statement:
create table names as select
line -> '$.id' as id,
line -> '$.name' as name
from lines_read("my-file.ndjson");
This should efficiently create a table with that exact subset of the data pulled from the newline-delimited JSON file.Is this available in Postgresql? Or what would be the equivalent?
OctoSQL[0] author here, this is really impressive! I like this much more than the approach taken by other sqlite-based tools which first load stuff into SQLite and then let you query it.
On the other hand, it does have a cons that it doesn't automatically infer the schema of the input JSON and you still have to manually parse the raw lines. Maybe it would be possible to surmount this by exposing a json-dedicated file reading function which also does SQL inference (I'm not knowledgable about SQLite internals)?
One piece of feedback is with regard to the benchmarks: I think it would be worth it to add additional benchmarks which work on slightly more complex datasets than the one used here. I did a comparison of this vs OctoSQL on the Brazil dataset, and - as expected - sqlite-lines wiped the floor with it. However, then I ran the following queries on a slightly more complex dataset (the Amazon review dataset in this case, from SPyQL's benchmark notebook[1]):
~> time OCTOSQL_NO_TELEMETRY=1 ./octosql "SELECT COUNT(*), AVG(overall)
FROM books.json
WHERE reviewerName = 'James'"
+-------+-------------------+
| count | avg_overall |
+-------+-------------------+
| 3010 | 4.402325581395349 |
+-------+-------------------+
real 0m49.805s
user 0m32.169s
sys 0m9.163s
~> time ./lines0-linux-amd64-sqlite3 :memory: "SELECT COUNT(*), AVG(json_extract(line, '$.overall'))
FROM lines_read('books.json')
WHERE json_extract(line, '$.reviewerName') = 'James'"
3010|4.40232558139535
real 1m47.933s
user 1m27.024s
sys 0m11.559s
and as you can see, the results go in a very different direction.But anyhow, congrats on the project, and I'm pumped to see what you come up with next!
[0]: https://github.com/cube2222/octosql
[1]: https://github.com/dcmoura/spyql/blob/master/notebooks/json_...
Re infering schema of input JSON: That would be slick! Though SQLite does have some limitations here with table-valued functions vs virtual table. I won't go into the specifics, but something like this isn't possible in SQLite:
select name, age from lines_json_read('students.json')
The "name" and "age" dynamic columns aren't possible when using the "table function" syntax, but something like this is possible using traditional "virtual table" syntax: create virtual table students using lines_json_read(filename="students.json");
select name, age from students;
It's a small difference, but definitely possible! Though parsing JSON in C is tricky, but would definitely accept contributions that figure it out.And re benchmarks - thanks for sharing! Yeah, they're pretty basic, so would love to add more complex ones. With the books.json example, I think what's happening is that in SQLite's JSON function, it parses the JSON each time in each json_extract function - so it parses twice for each row in that query. I also suspect that the long strings in "reviewText" might slow down, but can't be sure. Once I get some free time I'll add OctoSQL to the benchmark suite and this new books dataset
CREATE VIRTUAL TABLE students_json_read USING lines_json_read(schema="students-schema.json);
Then, that one table could be used for multiple files: SELECT * FROM students_json_read('school1-students.json');
SELECT * FROM students_json_read('school2-students.json'); $ cat hn.txt
11alex a
12brian b
12craig c
$ cat test.sql
.header on
.mode box
.load dist/lines0
select
substr(line, 1, 2) as id,
substr(line, 3, 7) as name,
substr(line, 11, 2) as age
from lines_read('hn.txt')
$ sqlite3x :memory: '.read test.sql'
┌────┬─────────┬─────┐
│ id │ name │ age │
├────┼─────────┼─────┤
│ 11 │ alex │ a │
│ 12 │ brian │ b │
│ 12 │ craig │ c │
└────┴─────────┴─────┘
Unfortunately SQLite's substr function is a bit awkward, but if each field has constant indicies it may workIf you use pandas, there is a reader for those `pd.read_fwf()`
I’m sure there are many more languages that support this, it’s just the only one where I had a need.
Inside those files you have multiple chunks and every line contains information about a transaction. So if you wanna get the transaction amount you would look for all characters between character n10 and n20 for example. (Those positions are fixed and follow the bank template).
We had success parsing it with python/node, but i think spinning up a sqllite instance, reading straight from the file and doing some calculations would be much faster.
CNAB file -> awk -> select from line_read().
Awk could, for example, transform the fixed-width file in a delimited one (I believe this extension does not strictly require json-formatted inputs). You vould also perform pre-filtering of the input data on awk side (see the "/ /" syntax).
If this extension supports reading from stdin, you could build a traditional unix command pipe, otherwise you would have to glue together the two commands with a named pipe.
Depending on your sensibility, this could feel ugly or very elegant. I am confident it would be very fast, not memory bound, and can be made strict and correct.
It seems slower than I would like, so I wonder if your code could be used as a stand alone library for parsing the files? My current implementation is able to parse about 25K Json documents per second when each document has 20 KV pairs. I used the Chicago crime data available for download on their open data portal.
How fast will your code parse it? Here is an example of three lines:
[ { "ID" : "10224738", "Case Number" : "HY411648", "Date" : "2015-09-05T13:30:00", "Block" : "043XX S WOOD ST", "IUCR" : "0486", "Primary Type" : "BATTERY", "Description" : "DOMESTIC BATTERY SIMPLE", "Location Description" : "RESIDENCE", "Arrest" : "false", "Domestic" : "true", "Beat" : 924, "District" : 9, "Ward" : 12, "Community Area" : 61, "FBI Code" : "08B", "X Coordinate" : "1165074", "Y Coordinate" : "1875917", "Year" : 2015, "Latitude" : 41.815117, "Longitude" : -87.670000 }, { "ID" : "10224742", "Case Number" : "HY411435", "Date" : "2015-09-05T10:55:00", "Block" : "082XX S LOOMIS BLVD", "IUCR" : "0610", "Primary Type" : "BURGLARY", "Description" : "FORCIBLE ENTRY", "Location Description" : "RESIDENCE", "Arrest" : "false", "Domestic" : "false", "Beat" : 614, "District" : 6, "Ward" : 21, "Community Area" : 71, "FBI Code" : "05", "X Coordinate" : "1168430", "Y Coordinate" : "1850165", "Year" : 2015, "Latitude" : 41.744379, "Longitude" : -87.658431 }, { "ID" : "10224745", "Case Number" : "HY411654", "Date" : "2015-09-05T11:30:00", "Block" : "031XX W WASHINGTON BLVD", "IUCR" : "0320", "Primary Type" : "ROBBERY", "Description" : "STRONGARM - NO WEAPON", "Location Description" : "STREET", "Arrest" : "false", "Domestic" : "true", "Beat" : 1222, "District" : 12, "Ward" : 27, "Community Area" : 27, "FBI Code" : "03", "X Coordinate" : "1155536", "Y Coordinate" : "1900515", "Year" : 2015, "Latitude" : 41.882814, "Longitude" : -87.704326 } ]
On a seperate note, I'm not sure if that data is public, but it makes me a bit uncomfortable to see lat/long on crime data on a public forum.
The data set blocks off the last two digits of the street address for anonymity but it doesn't look like they did the same for lat/long. If you drill down to a single block (e.g. 055XX S ROCKWELL ST), you will see 37 different latitudes and 37 different longitudes.