icql-dba
Version:
An SQLite DB Adapter built with BetterSQLite3
295 lines (273 loc) • 13.4 kB
text/coffeescript
'use strict'
############################################################################################################
CND = require 'cnd'
rpr = CND.rpr
badge = 'ICQL-DBA/IMPORT-EXPORT-MIXIN'
debug = CND.get_logger 'debug', badge
warn = CND.get_logger 'warn', badge
info = CND.get_logger 'info', badge
urge = CND.get_logger 'urge', badge
help = CND.get_logger 'help', badge
whisper = CND.get_logger 'whisper', badge
echo = CND.echo.bind CND
#...........................................................................................................
PATH = require 'path'
FS = require 'fs'
E = require './errors'
{ misfit } = require './common'
# #-----------------------------------------------------------------------------------------------------------
# any_value_null = ( input_columns, object ) ->
# for k in input_columns
# return true unless object[ k ]?
# return false
# #-----------------------------------------------------------------------------------------------------------
# all_values_null = ( input_columns, object ) ->
# for k in input_columns
# return false if object[ k ]?
# return true
#-----------------------------------------------------------------------------------------------------------
@Import_export_mixin = ( clasz = Object ) => class extends clasz
#---------------------------------------------------------------------------------------------------------
import: ( cfg ) ->
cfg = { @types.defaults.dba_import_cfg..., cfg..., }
cfg.format ?= @_format_from_path cfg.path
@types.validate.dba_import_cfg cfg
switch cfg.format
# when 'db' then await @_import_db cfg
# when 'sql' then await @_import_sql cfg
when 'csv', 'tsv' then await @_import_csv_tsv cfg
else
if @types._import_formats.has cfg.format
throw new E.Dba_not_implemented '^dba@309^', "import format #{rpr cfg.format}"
throw new E.Dba_import_format_unknown '^dba@309^', cfg.format
return null
#=========================================================================================================
# FORMAT GUESSING
#---------------------------------------------------------------------------------------------------------
_extension_from_path: ( path ) -> if ( R = PATH.extname path ) is '' then null else R[ 1 .. ]
_format_from_path: ( path ) -> @_formats[ @._extension_from_path path ] ? null
#---------------------------------------------------------------------------------------------------------
_is_sqlite3_db: ( path ) ->
# validate.nonempty_text path
buffer = Buffer.alloc 16
fd = FS.openSync path
FS.readSync fd, buffer
return ( buffer.toString 'utf-8' ) is 'SQLite format 3\x00'
#---------------------------------------------------------------------------------------------------------
_import_db: ( cfg ) ->
tmp_schema = @_get_free_temp_schema()
@_attach { schema: tmp_schema, path: cfg.path, }
# debug '^469465^', @list_schemas()
@_attach { schema: cfg.schema, path: '', }
# debug '^469465^', @list_schemas()
@copy_schema { from_schema: tmp_schema, to_schema: cfg.schema, }
@_detach { schema: tmp_schema, }
return null
#---------------------------------------------------------------------------------------------------------
_import_sql: ( cfg ) ->
throw new E.Dba_format_unknown '^dba@310^', 'sql'
#---------------------------------------------------------------------------------------------------------
_import_csv_tsv: ( cfg ) -> new Promise ( resolve, reject ) =>
### TAINT always requires `ram: true` ###
### TAINT no streaming, no batching ###
### TAINT no configurable CSV parsing ###
### NOTE optimisation: instead of n-readlines, use (unpublished) `chunkreader` that reads n bytes,
only looks for last newline, then parses chunk ###
### NOTE optimisation: do not call `insert` for each line, but assemble big `insert .. values (...)`
statement (as done before, should be fastest) ###
parse_csv = require 'csv-parser'
cfg = {
@types.defaults.dba_import_cfg...,
@types.defaults.dba_import_cfg_csv...,
cfg..., }
@types.validate.dba_import_cfg_csv cfg
{ path
schema
transform
input_columns
table_columns
skip_any_null
skip_all_null
table_name
_extra } = cfg
if cfg.format is 'tsv' then parser_cfg_defaults = @types.defaults.dba_import_cfg_tsv_extra
else parser_cfg_defaults = @types.defaults.dba_import_cfg_csv_extra
parser_cfg = {
parser_cfg_defaults...,
_extra...,
columns: input_columns, }
#.......................................................................................................
if input_columns is false then parser_cfg.headers = false
else if input_columns is true then delete parser_cfg.headers
else parser_cfg.headers = input_columns
parser_cfg.skipComments = cfg.skip_comments
@types.validate.dba_import_cfg_csv_extra parser_cfg
#.......................................................................................................
stop = Symbol.for 'stop'
lnr = 0
buffer = null
batch_size = 1_000
@_attach { schema, ram: true, }
insert = null
is_first = true
row_count = 0
has_stopped = false
source = null
stream = null
#.......................................................................................................
flush = =>
return null if has_stopped
if is_first
is_first = false
{ insert
table_columns } = @_create_csv_table { schema, table_name, input_columns, table_columns, }
# debug '^324^', input_columns
# debug '^324^', table_columns
return unless buffer?
rows = buffer
buffer = null
#.....................................................................................................
for row in rows
row_count++
unless transform?
insert.run ( row[ column ] ? null for column of table_columns )
continue
#...................................................................................................
subrows = await transform { row, stop, }
if subrows is stop
has_stopped = true
source.destroy()
stream.destroy()
stream.emit 'end'
return null
continue unless subrows?
if @types.isa.list subrows
for subrow in subrows
insert.run ( subrow[ column ] for column of table_columns )
continue
insert.run ( subrows[ column ] for column of table_columns )
return null
# #.......................................................................................................
# echo '^3423^'
# ### !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! ###
# t2 = require '../../hengist/node_modules/through2'
# ### !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! ###
# xxx = true
# xxx = false
# if xxx
# source = FS.createReadStream path, { highWaterMark: 10, }
# stream = source.pipe t2 ( chunk, encoding, callback ) ->
# # debug '^333442^', chunk.length
# @push chunk
# callback()
# stream = stream.pipe parse_csv parser_cfg
# else
source = FS.createReadStream path
stream = source.pipe parse_csv parser_cfg
#.......................................................................................................
stream.on 'data', ( row ) =>
return null if has_stopped
all_columns_null = true
new_row = {}
#.....................................................................................................
for column in input_columns
v = row[ column ] ? null
v = v.trim() if v? and cfg.trim
v = null if v is ''
if v is null
return null if skip_any_null
new_row[ column ] = cfg.default_value
else
all_columns_null = false
new_row[ column ] = v
#.....................................................................................................
return null if skip_all_null and all_columns_null
( buffer ?= [] ).push new_row
await flush() if buffer.length >= batch_size
return null
#.......................................................................................................
stream.on 'headers', ( headers ) => input_columns = headers
stream.on 'end', => await flush(); resolve { row_count, }
#.......................................................................................................
return null
#---------------------------------------------------------------------------------------------------------
_create_csv_table: ( cfg ) ->
{ schema
input_columns
table_columns
table_name } = cfg
#.......................................................................................................
# debug '^3534^', { table_columns, input_columns, }
table_columns ?= input_columns
if @types.isa.list table_columns then do =>
_tc = table_columns
table_columns = {}
table_columns[ k ] = 'text' for k in _tc
# debug '^3534^', { table_columns, input_columns, }
#.......................................................................................................
schema_i = @sql.I schema
table_name_i = @sql.I table_name
columns_sql = ( "#{@sql.I n} #{@sql.I t}" for n, t of table_columns ).join ', '
placeholder_sql = ( "?" for _ of table_columns ).join ', '
create_sql = "create table #{schema_i}.#{table_name_i} ( #{columns_sql} );"
try @execute create_sql catch error
warn CND.reverse """when trying to execute SQL:
#{create_sql}
an error was encountered: #{error.message}"""
throw error
#.......................................................................................................
insert = @prepare "insert into #{schema_i}.#{table_name_i} values ( #{placeholder_sql} );"
return { insert, table_columns, }
#---------------------------------------------------------------------------------------------------------
_import_sql_single: ( cfg ) ->
@execute FS.readFileSync cfg.path, { encoding: 'utf-8', }
return null
#---------------------------------------------------------------------------------------------------------
_import_sql_batch: ( cfg ) ->
for statements from @_walk_batches ( @_walk_statements_from_path cfg.path ), cfg.batch_size
compound_statement = statements.join ''
count += compound_statement.length
@execute compound_statement
return null
#---------------------------------------------------------------------------------------------------------
_walk_statements_from_path: ( sql_path ) ->
### Given a path, iterate over SQL statements which are signalled by semicolons (`;`) that appear outside
of literals and comments (and the end of input). ###
### thx to https://stackabuse.com/reading-a-file-line-by-line-in-node-js/ ###
### thx to https://github.com/nacholibre/node-readlines ###
readlines = new ( require 'n-readlines' ) sql_path
#.......................................................................................................
cfg =
regExp: ( require 'mysql-tokenizer/lib/regexp-sql92' )
tokenize = ( require 'mysql-tokenizer' ) cfg
collector = null
# stream = FS.createReadStream sql_path
#.......................................................................................................
flush = ->
R = collector.join ''
collector = null
return R
#.......................................................................................................
while ( line = readlines.next() ) isnt false
for token, cur_idx in tokenize line + '\n'
if token is ';'
( collector ?= [] ).push token
yield flush()
continue
# if token.startsWith '--'
# continue
( collector ?= [] ).push token
#.......................................................................................................
yield flush() if collector?
return null
#---------------------------------------------------------------------------------------------------------
_walk_batches: ( iterator, batch_size = 1 ) ->
### Given an iterator and a batch size, iterate over lists of values yielded by the iterator. ###
batch = null
for d from iterator
( batch ?= [] ).push d
if batch.length >= batch_size
yield batch
batch = null
yield batch if batch?
return null