1818import csv
1919import datetime
2020import io
21+ import json
2122import re
2223import shlex # Simple lexical analysis
2324from os .path import expanduser
@@ -145,8 +146,11 @@ def __init__(self, use_gui=True):
145146 # bitmap-enlarged by Windows, and the natural (content) size
146147 # would not fit the screen anyway
147148 self .tk_win .geometry (
148- f"{ self .tk_win .winfo_screenwidth () * 4 // 5 } "
149- f"x{ self .tk_win .winfo_screenheight () * 4 // 5 } "
149+ "%sx%s"
150+ % (
151+ self .tk_win .winfo_screenwidth () * 4 // 5 ,
152+ self .tk_win .winfo_screenheight () * 4 // 5 ,
153+ )
150154 )
151155
152156 # start from the real default font size (9 on Windows), so the
@@ -251,6 +255,8 @@ def create_menu(self):
251255 command = lambda : self .new_db (":memory:" , engine = "duckdb" ),
252256 )
253257 self .menu .add_command (label = "Open Database ..." , command = self .open_db )
258+ self .menu_recent = Menu (self .menu , postcommand = self .refresh_recent_menu )
259+ self .menu .add_cascade (menu = self .menu_recent , label = "Open Recent" )
254260 self .menu .add_command (
255261 label = "Open Database ...(legacy auto-commit)" ,
256262 command = lambda : self .open_db ("" ),
@@ -359,6 +365,7 @@ def new_db(self, filename="", engine=None):
359365 ):
360366 os .remove (filename )
361367 self .conn = Baresql (self .database_file , engine = engine )
368+ self .add_recent_db (filename )
362369 self .show_duckdb_demo ()
363370 self .actualize_db ()
364371
@@ -379,9 +386,43 @@ def open_db(self, filename="", isolation_level=None, engine=None):
379386 self .set_initialdir (filename )
380387 self .database_file = filename
381388 self .conn = Baresql (self .database_file , engine = engine )
389+ self .add_recent_db (filename )
382390 self .show_duckdb_demo ()
383391 self .actualize_db ()
384392
393+ def recent_dbs (self ):
394+ """return the list of recently opened databases"""
395+ try :
396+ with open (
397+ os .path .join (self .home , ".sqlite_bro_recent" ), encoding = "utf-8"
398+ ) as f :
399+ return [line .strip () for line in f if line .strip ()]
400+ except (OSError , IOError ):
401+ return []
402+
403+ def add_recent_db (self , filename ):
404+ """remember the ten most recently opened databases"""
405+ if filename in ("" , ":memory:" ):
406+ return
407+ filename = os .path .abspath (filename )
408+ recents = [filename ] + [r for r in self .recent_dbs () if r != filename ]
409+ try :
410+ with open (
411+ os .path .join (self .home , ".sqlite_bro_recent" ), "w" , encoding = "utf-8"
412+ ) as f :
413+ f .write ("\n " .join (recents [:10 ]))
414+ except (OSError , IOError ):
415+ pass
416+
417+ def refresh_recent_menu (self ):
418+ """(re)feed the 'Open Recent' menu, at click time"""
419+ self .menu_recent .delete (0 , "end" )
420+ for recent in self .recent_dbs ():
421+ if os .path .isfile (recent ):
422+ self .menu_recent .add_command (
423+ label = recent , command = lambda r = recent : self .open_db (r )
424+ )
425+
385426 def show_duckdb_demo (self ):
386427 """open the DuckDB welcome demo tab, once, when DuckDB is first used"""
387428 if (
@@ -1360,6 +1401,32 @@ def bip(c):
13601401 csv_file = csv_file .strip ('"' )
13611402 if (csv_file + "z" )[0 ] == "~" :
13621403 csv_file = os .path .join (self .home , csv_file [1 :])
1404+ if (
1405+ shell_list [0 ] == ".import"
1406+ and len (shell_list ) >= 2
1407+ and csv_file .lower ().endswith ((".json" , ".jsonl" , ".ndjson" ))
1408+ ): # the file extension decides the format
1409+ table_name = json_table_name (csv_file )
1410+ if len (shell_list ) >= 3 :
1411+ table_name = shell_list [2 ]
1412+ records = read_this_json (csv_file )
1413+ self .conn .insert_reader (
1414+ ((json .dumps (r , ensure_ascii = False ),) for r in records ),
1415+ table_name ,
1416+ 'CREATE TABLE "%s" (json TEXT)' % table_name ,
1417+ create_table = False ,
1418+ replace = False ,
1419+ )
1420+ dot_result = 'file %s imported in "%s"' % (
1421+ csv_file ,
1422+ table_name ,
1423+ )
1424+ if log is not None : # write to logFile
1425+ log .write (
1426+ '-- File %s imported in "%s"\n '
1427+ % (csv_file , table_name )
1428+ )
1429+ elif shell_list [0 ] == ".import" and len (shell_list ) >= 2 :
13631430 guess = guess_csv (csv_file )
13641431 if len (shell_list ) >= 3 :
13651432 guess .table_name = shell_list [2 ]
@@ -1613,10 +1680,19 @@ def import_csvtb(self, csv_file=None):
16131680 csv_file = filedialog .askopenfilename (
16141681 initialdir = self .initialdir ,
16151682 defaultextension = ".db" ,
1616- title = "Choose a csv fileto import " ,
1617- filetypes = [("default" , "*.csv" ), ("other" , "*.txt" ), ("all" , "*.*" )],
1683+ title = "Choose a csv or json file to import " ,
1684+ filetypes = [
1685+ ("default" , "*.csv" ),
1686+ ("json" , "*.json *.jsonl *.ndjson" ),
1687+ ("other" , "*.txt" ),
1688+ ("all" , "*.*" ),
1689+ ],
16181690 )
1619- if csv_file != "" :
1691+ if csv_file != "" and csv_file .lower ().endswith (
1692+ (".json" , ".jsonl" , ".ndjson" )
1693+ ): # the file extension decides the format
1694+ self .import_jsontb (csv_file )
1695+ elif csv_file != "" :
16201696 self .set_initialdir (csv_file )
16211697 # guess all via an object
16221698 guess = guess_csv (csv_file )
@@ -1658,6 +1734,35 @@ def import_csvtb(self, csv_file=None):
16581734 actions ,
16591735 )
16601736
1737+ def import_jsontb (self , json_file ):
1738+ """import a .json (array) or .jsonl file into a 1-column raw table,
1739+ and propose a first 'shredding' query in a new tab"""
1740+ self .set_initialdir (json_file )
1741+ table_name = json_table_name (json_file )
1742+ if self .conn .engine == "duckdb" :
1743+ # DuckDB reads (and types) json natively : do it in visible sql
1744+ base = table_name [: - len ("_raw" )]
1745+ query = (
1746+ 'CREATE OR REPLACE TABLE "%s" AS\n '
1747+ "SELECT * FROM read_json_auto('%s');\n "
1748+ 'SELECT * FROM "%s" LIMIT 100;'
1749+ ) % (base , json_file .replace ("'" , "''" ), base )
1750+ self .n .new_query_tab ("import %s" % base , query )
1751+ self .run_tab ()
1752+ else :
1753+ records = read_this_json (json_file )
1754+ self .conn .insert_reader (
1755+ ((json .dumps (r , ensure_ascii = False ),) for r in records ),
1756+ table_name ,
1757+ 'CREATE TABLE "%s" (json TEXT)' % table_name ,
1758+ create_table = True ,
1759+ replace = True ,
1760+ )
1761+ self .n .new_query_tab (
1762+ "shred %s" % table_name , shred_query (table_name , records )
1763+ )
1764+ self .actualize_db ()
1765+
16611766 def paste_csvtb (self ):
16621767 """paste clipboard into a table, via the csv import dialog"""
16631768 try :
@@ -2279,6 +2384,38 @@ def read_this_csv(csv_file, encoding, delimiter, quotechar, header, decim):
22792384 yield (row )
22802385
22812386
2387+ def read_this_json (json_file ):
2388+ """return the records of a .json (array or object) or .jsonl file"""
2389+ with open (json_file , encoding = "utf-8-sig" ) as f :
2390+ if json_file .lower ().endswith (".json" ):
2391+ data = json .load (f )
2392+ return data if isinstance (data , list ) else [data ]
2393+ return [json .loads (line ) for line in f if line .strip ()]
2394+
2395+
2396+ def json_table_name (json_file ):
2397+ """a table name from a file name : 'd:/some stuff.json' -> some_stuff_raw"""
2398+ base = os .path .splitext (os .path .basename (json_file ))[0 ]
2399+ return re .sub (r"\W" , "_" , base ) + "_raw"
2400+
2401+
2402+ def shred_query (table_name , records ):
2403+ """sql proposal shredding a raw json table, from the first record keys"""
2404+ first = records [0 ] if records else None
2405+ if not isinstance (first , dict ) or not first :
2406+ return 'SELECT json FROM "%s";' % table_name
2407+ cols = ",\n " .join (
2408+ " json_extract(json, '$.%s') AS \" %s\" "
2409+ % (k if re .match (r"^\w+$" , k ) else '"' + k + '"' , k .replace ('"' , '""' ))
2410+ for k in first
2411+ )
2412+ return (
2413+ '-- shredding "%s" : keys seen in the first record, edit at will\n '
2414+ 'SELECT\n %s\n FROM "%s";\n '
2415+ "-- nested data ? see json_each(json, '$.path') and json_tree\n "
2416+ ) % (table_name , cols , table_name )
2417+
2418+
22822419def copy_csv_close_ok (thetop , entries , actions ):
22832420 "copy a query result to the clipboard, then close the dialog (action)"
22842421 copy_csv_ok (thetop , entries , actions )
@@ -2792,12 +2929,41 @@ def write_rows(fout):
27922929 ) # PyPy as a strange list of list
27932930 writer .writerows (cursor .fetchall ())
27942931
2932+ def write_json_rows (fout , lines_mode ):
2933+ """one object per row ; a .json array, or .jsonl lines"""
2934+ cols = [i if isinstance (i , str ) else i [0 ] for i in cursor .description ]
2935+
2936+ def clean (v ):
2937+ if isinstance (v , (bytes , bytearray )):
2938+ return v .decode ("utf-8" , "replace" )
2939+ if v is None or isinstance (v , (int , float , str )):
2940+ return v
2941+ return str (v ) # datetime, Decimal, uuid, ...
2942+
2943+ first = True
2944+ for row in cursor :
2945+ record = json .dumps (
2946+ dict (zip (cols , [clean (v ) for v in row ])), ensure_ascii = False
2947+ )
2948+ if lines_mode :
2949+ fout .write (record + "\n " )
2950+ else :
2951+ fout .write (("[\n " if first else ",\n " ) + record )
2952+ first = False
2953+ if not lines_mode :
2954+ fout .write ("[]" if first else "\n ]\n " )
2955+
27952956 if hasattr (csv_file , "write" ): # file-like target (e.g. clipboard buffer)
27962957 write_rows (csv_file )
27972958 else :
27982959 write_mode = "w" if initialize else "a" # Write or Append
2960+ target = csv_file .lower ()
27992961 with open (csv_file , write_mode , newline = "" , encoding = encoding ) as fout :
2800- write_rows (fout )
2962+ if target .endswith ((".json" , ".jsonl" , ".ndjson" )):
2963+ # the file extension decides the format : json, not csv
2964+ write_json_rows (fout , not target .endswith (".json" ))
2965+ else :
2966+ write_rows (fout )
28012967 return nb_columns
28022968
28032969
0 commit comments